From 6ef876b63747dda703d4177f7bc37ce0c309c3ad Mon Sep 17 00:00:00 2001 From: Vivian-04 Date: Fri, 25 Sep 2026 12:14:04 +0100 Subject: [PATCH 1/6] feat: add localization QA script for translated documentation QA #162 --- localization-qa.js | 458 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 458 insertions(+) create mode 100644 localization-qa.js diff --git a/localization-qa.js b/localization-qa.js new file mode 100644 index 0000000..e662b1a --- /dev/null +++ b/localization-qa.js @@ -0,0 +1,458 @@ +/** + * Localization QA for Wraith Protocol documentation + * + * Checks translated documentation pages for: + * - Stale links + * - Missing sections + * - Code drift + * - Language metadata + */ + +const fs = require('fs'); +const path = require('path'); + +const DOCS_DIR = path.join(__dirname); +const MARKDOWN_EXT = ['.md', '.mdx']; + +const SKIP_DIRS = new Set(['.git', '.agents', 'node_modules']); +const SKIP_FILES = new Set(['docs.json', 'CLAUDE.md', 'PR_DESCRIPTION.md']); + +function parseFrontMatter(content) { + let attrs = {}; + let body = content; + + const fmMatch = content.match(/^---[\s\S]*?^---/m); + if (fmMatch) { + const fmContent = content.substring(3, fmMatch[0].lastIndexOf('---')).trim(); + const attrsObj = {}; + for (const line of fmContent.split('\n')) { + const match = line.match(/^(\w+):\s*(.*)$/); + if (match) { + let value = match[2].trim(); + if (value === 'true') value = true; + else if (value === 'false') value = false; + else if (!isNaN(value)) value = Number(value); + attrsObj[match[1]] = value; + } + } + attrs = attrsObj; + + const fmEndIndex = content.indexOf('---', 3); + if (fmEndIndex !== -1) { + body = content.substring(fmEndIndex + 3).trim(); + } + } + + return { attrs, body }; +} + +function extractHeadings(body) { + const headings = []; + const lines = body.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const match = line.match(/^(#{1,6})\s+(.*)$/); + if (match) { + headings.push({ + level: match[1].length, + text: match[2].trim(), + line: i + 1, + }); + } + } + return headings; +} + +function countCodeFences(body) { + const lines = body.split('\n'); + let count = 0; + let inFence = false; + for (const line of lines) { + if (line.trim().startsWith('```')) { + inFence = !inFence; + count++; + } + } + return count; +} + +function extractLinks(body) { + const links = []; + const regex = /\[([^\]]+)\]\(([^)]+)\)/g; + let match; + while ((match = regex.exec(body)) !== null) { + links.push({ + text: match[1], + url: match[2], + line: match.index, + }); + } + return links; +} + +function classifyLink(url) { + if (!url) return 'external'; + if (url.startsWith('/') || url.startsWith('#')) return 'internal'; + if (url.startsWith('http://') || url.startsWith('https://')) return 'external'; + return 'internal'; +} + +function normalizePagePath(page) { + if (!page) return ''; + let p = page.replace(/^\/|\/$/g, ''); + return p.toLowerCase(); +} + +function getNavPages(navigation) { + const pages = []; + + if (!navigation || !navigation.tabs || !Array.isArray(navigation.tabs)) return pages; + + for (const tab of navigation.tabs) { + const groups = tab.groups; + if (!groups || !Array.isArray(groups)) continue; + + for (const group of groups) { + const groupPages = group.pages; + if (groupPages && Array.isArray(groupPages)) { + for (const page of groupPages) { + pages.push(normalizePagePath(page)); + } + } + if (group.items && Array.isArray(group.items)) { + for (const subItem of group.items) { + const subPages = subItem.pages; + if (subPages && Array.isArray(subPages)) { + for (const page of subPages) { + pages.push(normalizePagePath(page)); + } + } + const subGroups = subItem.groups; + if (subGroups && Array.isArray(subGroups)) { + for (const subGroup of subGroups) { + const sgPages = subGroup.pages; + if (sgPages && Array.isArray(sgPages)) { + for (const page of sgPages) { + pages.push(normalizePagePath(page)); + } + } + } + } + } + } + } + } + + return pages; +} + +function getAllMDXFiles() { + const files = []; + + function walk(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (SKIP_DIRS.has(entry.name)) continue; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + } else if (MARKDOWN_EXT.some(ext => entry.name.endsWith(ext))) { + // Skip non-doc files + const basename = entry.name.replace(path.extname(entry.name), ''); + if (SKIP_FILES.has(basename)) continue; + files.push(fullPath); + } + } + } + + walk(DOCS_DIR); + return files; +} + +function getPageKey(filePath) { + const rel = path.relative(DOCS_DIR, filePath); + const withoutExt = path.extname(rel) ? rel.replace(path.extname(rel), '') : rel; + return withoutExt.replace(/\\/g, '/'); +} + +function hasLocaleMetadata(attrs) { + return attrs.locale !== undefined && attrs.locale !== null; +} + +function runQA() { + console.log('=== Wraith Protocol Localization QA ===\n'); + + // 1. Read docs.json + const docsJSONPath = path.join(DOCS_DIR, 'docs.json'); + let navigation; + if (fs.existsSync(docsJSONPath)) { + const jsonContent = fs.readFileSync(docsJSONPath, 'utf-8'); + const docsConfig = JSON.parse(jsonContent); + navigation = docsConfig.navigation; + console.log('Loaded navigation from docs.json\n'); + } else { + console.warn('docs.json not found\n'); + navigation = null; + } + + // Get canonical page paths from navigation + const canonicalPages = navigation ? getNavPages(navigation) : []; + console.log(`Canonical pages from navigation: ${canonicalPages.length}\n`); + console.log(' ', canonicalPages.join(', '), '\n'); + + // 2. Get all markdown files + const allFiles = getAllMDXFiles(); + console.log(`Total markdown files found: ${allFiles.length}\n`); + + // 3. Parse all files + const fileData = []; + + for (const filePath of allFiles) { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const parsed = parseFrontMatter(content); + const pageKey = getPageKey(filePath); + const hasLocale = hasLocaleMetadata(parsed.attrs); + const locale = hasLocale ? parsed.attrs.locale : null; + + const headings = extractHeadings(parsed.body); + const codeFenceCount = countCodeFences(parsed.body); + const links = extractLinks(parsed.body); + const classifiedLinks = links.map(l => ({ + ...l, + type: classifyLink(l.url), + })); + + fileData.push({ + filePath, + pageKey, + title: parsed.attrs.title || 'Untitled', + description: parsed.attrs.description || '', + locale, + hasLocaleMetadata: hasLocale, + headings, + codeFenceCount, + links: classifiedLinks, + rawBody: parsed.body, + }); + } catch (err) { + console.error(`Error parsing ${filePath}:`, err.message); + } + } + + // Build map of page keys + const pageMap = new Map(); + for (const fd of fileData) { + pageMap.set(fd.pageKey, fd); + } + + // 4. Identify source vs translated pages + const translatedPages = new Set(); + const sourceLanguagePages = new Set(); + + for (const [key, fd] of pageMap) { + if (fd.hasLocaleMetadata) { + translatedPages.add(key); + } else { + sourceLanguagePages.add(key); + } + } + + console.log(`Source language (English) pages: ${sourceLanguagePages.size}`); + console.log(`Translated pages: ${translatedPages.size}\n`); + + // 5. Define supported translated pages (from navigation) + const supportedTranslated = new Set(); + if (navigation) { + for (const pageKey of canonicalPages) { + if (!pageMap.has(pageKey)) { + console.log(` Warning: Page in navigation but not found in docs: ${pageKey}`); + } else { + supportedTranslated.add(pageKey); + } + } + } + + console.log(`Supported pages for translation: ${supportedTranslated.size}\n`); + + // 6. Compare structure between source and translated pages + const issues = []; + + for (const pageKey of supportedTranslated) { + const fd = pageMap.get(pageKey); + if (!fd) { + issues.push({ + type: 'missing_page', + page: pageKey, + message: 'Page defined in navigation but not found in docs directory', + }); + continue; + } + + // Check if translated + if (!fd.hasLocaleMetadata) { + issues.push({ + type: 'untranslated', + page: pageKey, + message: 'Page is missing locale metadata - not marked as translated', + }); + } + + // Compare with source version (same key in English) + const sourceFd = pageMap.get(pageKey); + + if (sourceFd) { + // Compare headings + const sourceHeadingTexts = sourceFd.headings.map(h => h.text); + const translatedHeadingTexts = fd.headings.map(h => h.text); + + const missingInTranslation = sourceHeadingTexts.filter(h => !translatedHeadingTexts.includes(h)); + const extraInTranslation = translatedHeadingTexts.filter(h => !sourceHeadingTexts.includes(h)); + + if (missingInTranslation.length > 0) { + issues.push({ + type: 'heading_drift', + page: pageKey, + message: `Missing headings in translation: ${missingInTranslation.join(', ')}`, + details: { + sourceHeadings: sourceHeadingTexts, + translatedHeadings: translatedHeadingTexts, + }, + }); + } + + if (extraInTranslation.length > 0) { + issues.push({ + type: 'heading_drift', + page: pageKey, + message: `Extra headings in translation not in source: ${extraInTranslation.join(', ')}`, + details: { + sourceHeadings: sourceHeadingTexts, + translatedHeadings: translatedHeadingTexts, + }, + }); + } + + // Compare code fence counts + if (sourceFd.codeFenceCount !== fd.codeFenceCount) { + issues.push({ + type: 'code_drift', + page: pageKey, + message: `Code fence count mismatch: source=${sourceFd.codeFenceCount}, translation=${fd.codeFenceCount}`, + details: { + sourceFenceCount: sourceFd.codeFenceCount, + translatedFenceCount: fd.codeFenceCount, + }, + }); + } + + // Compare link structure + const sourceLinks = sourceFd.links.map(l => ({ ...l, type: classifyLink(l.url) })); + const translatedLinks = fd.links.map(l => ({ ...l, type: classifyLink(l.url) })); + + // Links in translation not in source + for (const translatedLink of translatedLinks) { + const matchingSourceLink = sourceLinks.find( + sl => sl.url === translatedLink.url && sl.text === translatedLink.text + ); + + if (!matchingSourceLink) { + if (translatedLink.type === 'external') { + issues.push({ + type: 'link_structural', + page: pageKey, + message: `Link in translation not in source: ${translatedLink.text} (${translatedLink.url})`, + details: { type: translatedLink.type }, + }); + } else { + issues.push({ + type: 'link_structural', + page: pageKey, + message: `Internal link in translation not in source: ${translatedLink.text} (${translatedLink.url})`, + details: { type: translatedLink.type }, + }); + } + } + } + + // Links in source missing from translation + for (const sourceLink of sourceLinks) { + const hasMatching = translatedLinks.some( + tl => tl.url === sourceLink.url && tl.text === sourceLink.text + ); + if (!hasMatching && sourceLink.type === 'internal') { + issues.push({ + type: 'link_missing', + page: pageKey, + message: `Internal link missing from translation: ${sourceLink.text} (${sourceLink.url})`, + }); + } + } + } + } + + // 7. Check locale metadata + console.log('\n=== Locale Metadata Check ==='); + for (const [key, fd] of pageMap) { + if (fd.hasLocaleMetadata) { + console.log(` ✓ ${key}: locale="${fd.locale}"`); + } else { + console.log(` ✗ ${key}: missing locale metadata`); + } + } + +// 8. Check canonical links +console.log('\n=== Canonical Links Check ==='); + for (const fd of fileData) { + // Check for canonical link by looking for tag with rel=canonical + const hasCanonicalLink = / Date: Sat, 26 Sep 2026 16:37:09 +0100 Subject: [PATCH 2/6] Fix localization QA logic and add CI workflow --- .github/workflows/qa.yml | 19 +++++++++++++++++++ localization-qa.js | 35 ++++++++++++++++------------------- 2 files changed, 35 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/qa.yml diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml new file mode 100644 index 0000000..d8537d7 --- /dev/null +++ b/.github/workflows/qa.yml @@ -0,0 +1,19 @@ +name: Localization QA + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + qa: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Run Localization QA + run: node localization-qa.js diff --git a/localization-qa.js b/localization-qa.js index e662b1a..73c005b 100644 --- a/localization-qa.js +++ b/localization-qa.js @@ -278,29 +278,25 @@ function runQA() { // 6. Compare structure between source and translated pages const issues = []; - for (const pageKey of supportedTranslated) { + for (const pageKey of translatedPages) { const fd = pageMap.get(pageKey); - if (!fd) { - issues.push({ - type: 'missing_page', - page: pageKey, - message: 'Page defined in navigation but not found in docs directory', - }); - continue; + + let sourcePageKey = pageKey; + if (fd.locale && pageKey.endsWith(`.${fd.locale}`)) { + sourcePageKey = pageKey.slice(0, -(fd.locale.length + 1)); } - // Check if translated - if (!fd.hasLocaleMetadata) { + const sourceFd = pageMap.get(sourcePageKey); + + if (!sourceFd) { issues.push({ - type: 'untranslated', + type: 'missing_source', page: pageKey, - message: 'Page is missing locale metadata - not marked as translated', + message: `Could not find source English page for translated page: ${sourcePageKey}`, }); + continue; } - // Compare with source version (same key in English) - const sourceFd = pageMap.get(pageKey); - if (sourceFd) { // Compare headings const sourceHeadingTexts = sourceFd.headings.map(h => h.text); @@ -431,11 +427,9 @@ console.log('\n=== Canonical Links Check ==='); console.log(` ${type}: ${count}`); } - const untranslatedCount = sourceLanguagePages.size; console.log(`\nTotal structural issues: ${issues.length}`); - console.log(`Untranslated pages (no locale metadata): ${untranslatedCount}`); - if (issues.length === 0 && untranslatedCount === 0) { + if (issues.length === 0) { console.log('✓ All checks passed! Documentation is structurally sound.'); } else { console.log('\n=== Detailed Issues ==='); @@ -454,5 +448,8 @@ module.exports = { runQA, parseFrontMatter, extractHeadings, countCodeFences, ex // Run if executed directly if (require.main === module) { - runQA(); + const result = runQA(); + if (result.issues.length > 0) { + process.exit(1); + } } \ No newline at end of file From 25c53647868041e172248d9b1c2ce9dde6977df4 Mon Sep 17 00:00:00 2001 From: Vivian-04 Date: Mon, 28 Sep 2026 02:27:01 +0100 Subject: [PATCH 3/6] fix: rename localization-qa to .cjs for ESM compatibility --- .github/workflows/qa.yml | 2 +- localization-qa.js => localization-qa.cjs | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename localization-qa.js => localization-qa.cjs (100%) diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index d8537d7..9362bbf 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -16,4 +16,4 @@ jobs: with: node-version: '20' - name: Run Localization QA - run: node localization-qa.js + run: node localization-qa.cjs diff --git a/localization-qa.js b/localization-qa.cjs similarity index 100% rename from localization-qa.js rename to localization-qa.cjs From 8bb6633b7ae289392eadade800eb6934fe426f90 Mon Sep 17 00:00:00 2001 From: Vivian-04 Date: Mon, 28 Sep 2026 12:40:59 +0100 Subject: [PATCH 4/6] feat: fail localization QA on missing locale or canonical metadata --- localization-qa.cjs | 247 +++++++++++++++++++++++++------------------- 1 file changed, 140 insertions(+), 107 deletions(-) diff --git a/localization-qa.cjs b/localization-qa.cjs index 73c005b..baec15a 100644 --- a/localization-qa.cjs +++ b/localization-qa.cjs @@ -1,11 +1,15 @@ /** * Localization QA for Wraith Protocol documentation - * - * Checks translated documentation pages for: - * - Stale links - * - Missing sections + * + * Checks translated documentation pages (detected by a locale suffix such as + * `guides/stellar-quickstart.es`) for: + * - Missing locale metadata ("locale" front matter) + * - Missing canonical metadata ("canonical" front matter or ) + * - Stale links (compared by URL against the English source) + * - Missing sections (heading outline comparison) * - Code drift - * - Language metadata + * + * Exits with code 1 when any issue is found. */ const fs = require('fs'); @@ -15,32 +19,28 @@ const DOCS_DIR = path.join(__dirname); const MARKDOWN_EXT = ['.md', '.mdx']; const SKIP_DIRS = new Set(['.git', '.agents', 'node_modules']); -const SKIP_FILES = new Set(['docs.json', 'CLAUDE.md', 'PR_DESCRIPTION.md']); +const SKIP_FILES = new Set(['docs.json', 'CLAUDE.md']); function parseFrontMatter(content) { + const raw = content.replace(/^\uFEFF/, ''); let attrs = {}; - let body = content; + let body = raw; - const fmMatch = content.match(/^---[\s\S]*?^---/m); + const fmMatch = raw.match(/^---[ \t]*\r?\n([\s\S]*?)^---[ \t]*(?:\r?\n|$)/m); if (fmMatch) { - const fmContent = content.substring(3, fmMatch[0].lastIndexOf('---')).trim(); const attrsObj = {}; - for (const line of fmContent.split('\n')) { + for (const line of fmMatch[1].split(/\r?\n/)) { const match = line.match(/^(\w+):\s*(.*)$/); if (match) { let value = match[2].trim(); if (value === 'true') value = true; else if (value === 'false') value = false; - else if (!isNaN(value)) value = Number(value); + else if (value !== '' && !isNaN(value)) value = Number(value); attrsObj[match[1]] = value; } } attrs = attrsObj; - - const fmEndIndex = content.indexOf('---', 3); - if (fmEndIndex !== -1) { - body = content.substring(fmEndIndex + 3).trim(); - } + body = raw.substring(fmMatch[0].length).trim(); } return { attrs, body }; @@ -48,7 +48,7 @@ function parseFrontMatter(content) { function extractHeadings(body) { const headings = []; - const lines = body.split('\n'); + const lines = body.split(/\r?\n/); for (let i = 0; i < lines.length; i++) { const line = lines[i]; const match = line.match(/^(#{1,6})\s+(.*)$/); @@ -158,8 +158,7 @@ function getAllMDXFiles() { walk(fullPath); } else if (MARKDOWN_EXT.some(ext => entry.name.endsWith(ext))) { // Skip non-doc files - const basename = entry.name.replace(path.extname(entry.name), ''); - if (SKIP_FILES.has(basename)) continue; + if (SKIP_FILES.has(entry.name)) continue; files.push(fullPath); } } @@ -179,6 +178,31 @@ function hasLocaleMetadata(attrs) { return attrs.locale !== undefined && attrs.locale !== null; } +// A page whose filename carries a locale suffix (e.g. guides/stellar-quickstart.es) +// is a translation. Detection is filename-based so a translated page that forgets +// its "locale" front matter is still counted and reported as a failure. +function parseTranslatedKey(pageKey) { + const base = pageKey.slice(pageKey.lastIndexOf('/') + 1); + const match = base.match(/^(.+)\.([a-z]{2})$/); + if (!match) return null; + return { + locale: match[2], + sourcePageKey: pageKey.slice(0, -(match[2].length + 1)), + }; +} + +// Canonical metadata is either a "canonical" front matter field or a +// tag in the page body. Returns the URL, or null. +function getCanonicalMetadata(attrs, body) { + if (attrs.canonical !== undefined && attrs.canonical !== null && attrs.canonical !== '') { + return String(attrs.canonical).trim(); + } + const tag = body.match(/]*\brel=["'][^"']*canonical[^"']*["'][^>]*>/i); + if (!tag) return null; + const href = tag[0].match(/\bhref=["']([^"']+)["']/i); + return href ? href[1] : null; +} + function runQA() { console.log('=== Wraith Protocol Localization QA ===\n'); @@ -212,8 +236,10 @@ function runQA() { const content = fs.readFileSync(filePath, 'utf-8'); const parsed = parseFrontMatter(content); const pageKey = getPageKey(filePath); - const hasLocale = hasLocaleMetadata(parsed.attrs); - const locale = hasLocale ? parsed.attrs.locale : null; + const translation = parseTranslatedKey(pageKey); + const declaredLocale = hasLocaleMetadata(parsed.attrs) + ? String(parsed.attrs.locale) + : null; const headings = extractHeadings(parsed.body); const codeFenceCount = countCodeFences(parsed.body); @@ -228,8 +254,11 @@ function runQA() { pageKey, title: parsed.attrs.title || 'Untitled', description: parsed.attrs.description || '', - locale, - hasLocaleMetadata: hasLocale, + isTranslated: translation !== null, + expectedLocale: translation ? translation.locale : null, + sourcePageKey: translation ? translation.sourcePageKey : null, + declaredLocale, + canonical: getCanonicalMetadata(parsed.attrs, parsed.body), headings, codeFenceCount, links: classifiedLinks, @@ -251,7 +280,7 @@ function runQA() { const sourceLanguagePages = new Set(); for (const [key, fd] of pageMap) { - if (fd.hasLocaleMetadata) { + if (fd.isTranslated) { translatedPages.add(key); } else { sourceLanguagePages.add(key); @@ -280,14 +309,10 @@ function runQA() { for (const pageKey of translatedPages) { const fd = pageMap.get(pageKey); - - let sourcePageKey = pageKey; - if (fd.locale && pageKey.endsWith(`.${fd.locale}`)) { - sourcePageKey = pageKey.slice(0, -(fd.locale.length + 1)); - } + const sourcePageKey = fd.sourcePageKey; const sourceFd = pageMap.get(sourcePageKey); - + if (!sourceFd) { issues.push({ type: 'missing_source', @@ -298,33 +323,20 @@ function runQA() { } if (sourceFd) { - // Compare headings - const sourceHeadingTexts = sourceFd.headings.map(h => h.text); - const translatedHeadingTexts = fd.headings.map(h => h.text); + // Compare heading structure. Heading text is translated, so only the + // outline (count and nesting levels) is compared — a section added to or + // dropped from the translation changes the outline. + const sourceLevels = sourceFd.headings.map(h => h.level); + const translatedLevels = fd.headings.map(h => h.level); - const missingInTranslation = sourceHeadingTexts.filter(h => !translatedHeadingTexts.includes(h)); - const extraInTranslation = translatedHeadingTexts.filter(h => !sourceHeadingTexts.includes(h)); - - if (missingInTranslation.length > 0) { + if (sourceLevels.join(',') !== translatedLevels.join(',')) { issues.push({ type: 'heading_drift', page: pageKey, - message: `Missing headings in translation: ${missingInTranslation.join(', ')}`, + message: `Heading structure mismatch: source=${sourceLevels.join(',')} vs translation=${translatedLevels.join(',')}`, details: { - sourceHeadings: sourceHeadingTexts, - translatedHeadings: translatedHeadingTexts, - }, - }); - } - - if (extraInTranslation.length > 0) { - issues.push({ - type: 'heading_drift', - page: pageKey, - message: `Extra headings in translation not in source: ${extraInTranslation.join(', ')}`, - details: { - sourceHeadings: sourceHeadingTexts, - translatedHeadings: translatedHeadingTexts, + sourceHeadings: sourceFd.headings.map(h => `${h.level}: ${h.text}`), + translatedHeadings: fd.headings.map(h => `${h.level}: ${h.text}`), }, }); } @@ -342,76 +354,87 @@ function runQA() { }); } - // Compare link structure - const sourceLinks = sourceFd.links.map(l => ({ ...l, type: classifyLink(l.url) })); - const translatedLinks = fd.links.map(l => ({ ...l, type: classifyLink(l.url) })); + // Compare link targets. Link text is translated, so URLs are compared. + const sourceUrls = new Set(sourceFd.links.map(l => l.url)); + const translatedUrls = new Set(fd.links.map(l => l.url)); // Links in translation not in source - for (const translatedLink of translatedLinks) { - const matchingSourceLink = sourceLinks.find( - sl => sl.url === translatedLink.url && sl.text === translatedLink.text - ); - - if (!matchingSourceLink) { - if (translatedLink.type === 'external') { - issues.push({ - type: 'link_structural', - page: pageKey, - message: `Link in translation not in source: ${translatedLink.text} (${translatedLink.url})`, - details: { type: translatedLink.type }, - }); - } else { - issues.push({ - type: 'link_structural', - page: pageKey, - message: `Internal link in translation not in source: ${translatedLink.text} (${translatedLink.url})`, - details: { type: translatedLink.type }, - }); - } - } - } + for (const translatedLink of fd.links) { + if (sourceUrls.has(translatedLink.url)) continue; - // Links in source missing from translation - for (const sourceLink of sourceLinks) { - const hasMatching = translatedLinks.some( - tl => tl.url === sourceLink.url && tl.text === sourceLink.text - ); - if (!hasMatching && sourceLink.type === 'internal') { + if (translatedLink.type === 'external') { + issues.push({ + type: 'link_structural', + page: pageKey, + message: `Link in translation not in source: ${translatedLink.text} (${translatedLink.url})`, + details: { type: translatedLink.type }, + }); + } else { issues.push({ - type: 'link_missing', + type: 'link_structural', page: pageKey, - message: `Internal link missing from translation: ${sourceLink.text} (${sourceLink.url})`, + message: `Internal link in translation not in source: ${translatedLink.text} (${translatedLink.url})`, + details: { type: translatedLink.type }, }); } } + + // Internal links in source missing from translation + for (const sourceLink of sourceFd.links) { + if (sourceLink.type !== 'internal') continue; + if (translatedUrls.has(sourceLink.url)) continue; + + issues.push({ + type: 'link_missing', + page: pageKey, + message: `Internal link missing from translation: ${sourceLink.text} (${sourceLink.url})`, + }); + } } } - // 7. Check locale metadata + // 7. Check locale metadata on translated pages console.log('\n=== Locale Metadata Check ==='); - for (const [key, fd] of pageMap) { - if (fd.hasLocaleMetadata) { - console.log(` ✓ ${key}: locale="${fd.locale}"`); + if (translatedPages.size === 0) { + console.log(' (no translated pages found)'); + } + for (const pageKey of translatedPages) { + const fd = pageMap.get(pageKey); + if (!fd.declaredLocale) { + issues.push({ + type: 'missing_locale', + page: pageKey, + message: `Missing locale metadata: add "locale: ${fd.expectedLocale}" to the front matter`, + }); + console.log(` ✗ ${pageKey}: missing locale metadata`); + } else if (fd.declaredLocale !== fd.expectedLocale) { + issues.push({ + type: 'locale_mismatch', + page: pageKey, + message: `Locale metadata "${fd.declaredLocale}" does not match the file suffix ".${fd.expectedLocale}"`, + }); + console.log(` ✗ ${pageKey}: locale="${fd.declaredLocale}" does not match file suffix ".${fd.expectedLocale}"`); } else { - console.log(` ✗ ${key}: missing locale metadata`); + console.log(` ✓ ${pageKey}: locale="${fd.declaredLocale}"`); } } -// 8. Check canonical links -console.log('\n=== Canonical Links Check ==='); - for (const fd of fileData) { - // Check for canonical link by looking for tag with rel=canonical - const hasCanonicalLink = /" to the front matter or a tag', + }); + console.log(` ✗ ${pageKey}: missing canonical metadata`); } else { - console.log(` ✗ ${fd.pageKey}: missing canonical link`); + console.log(` ✓ ${pageKey}: canonical="${fd.canonical}"`); } } @@ -427,7 +450,7 @@ console.log('\n=== Canonical Links Check ==='); console.log(` ${type}: ${count}`); } - console.log(`\nTotal structural issues: ${issues.length}`); + console.log(`\nTotal issues: ${issues.length}`); if (issues.length === 0) { console.log('✓ All checks passed! Documentation is structurally sound.'); @@ -444,7 +467,17 @@ console.log('\n=== Canonical Links Check ==='); return { issues, fileData, pageMap, translatedPages, sourceLanguagePages, supportedTranslated }; } -module.exports = { runQA, parseFrontMatter, extractHeadings, countCodeFences, extractLinks, classifyLink, hasLocaleMetadata }; +module.exports = { + runQA, + parseFrontMatter, + extractHeadings, + countCodeFences, + extractLinks, + classifyLink, + hasLocaleMetadata, + parseTranslatedKey, + getCanonicalMetadata, +}; // Run if executed directly if (require.main === module) { From 95db79afdeacfee7572afc7fa632360c84e55fb4 Mon Sep 17 00:00:00 2001 From: Vivian-04 Date: Mon, 28 Sep 2026 12:40:59 +0100 Subject: [PATCH 5/6] ci: run localization QA against develop --- .github/workflows/qa.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 9362bbf..a7fa982 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -2,9 +2,9 @@ name: Localization QA on: push: - branches: [ main ] + branches: [ develop ] pull_request: - branches: [ main ] + branches: [ develop ] jobs: qa: From b0d035f1afb5d46aeae39519b796eb2424f79691 Mon Sep 17 00:00:00 2001 From: Vivian-04 Date: Mon, 28 Sep 2026 16:08:29 +0100 Subject: [PATCH 6/6] fix(i18n): add locale metadata to Spanish quickstart --- guides/stellar-quickstart.es.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/guides/stellar-quickstart.es.mdx b/guides/stellar-quickstart.es.mdx index d7c1fad..fa351a2 100644 --- a/guides/stellar-quickstart.es.mdx +++ b/guides/stellar-quickstart.es.mdx @@ -2,6 +2,8 @@ title: "Guía de inicio rápido — Stellar" description: "Crea un agente Wraith en Stellar, fúndalo en la red de pruebas, envía tu primer pago oculto y escanea pagos entrantes — en menos de 10 minutos." keywords: "Stellar, soroban, freighter, xlm, lumen, friendbot, stellar.expert, meta-address, stealth payment" +locale: es +canonical: /guides/stellar-quickstart ---