diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml
new file mode 100644
index 0000000..a7fa982
--- /dev/null
+++ b/.github/workflows/qa.yml
@@ -0,0 +1,19 @@
+name: Localization QA
+
+on:
+ push:
+ branches: [ develop ]
+ pull_request:
+ branches: [ develop ]
+
+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.cjs
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
---
diff --git a/localization-qa.cjs b/localization-qa.cjs
new file mode 100644
index 0000000..baec15a
--- /dev/null
+++ b/localization-qa.cjs
@@ -0,0 +1,488 @@
+/**
+ * Localization QA for Wraith Protocol documentation
+ *
+ * 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
+ *
+ * Exits with code 1 when any issue is found.
+ */
+
+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']);
+
+function parseFrontMatter(content) {
+ const raw = content.replace(/^\uFEFF/, '');
+ let attrs = {};
+ let body = raw;
+
+ const fmMatch = raw.match(/^---[ \t]*\r?\n([\s\S]*?)^---[ \t]*(?:\r?\n|$)/m);
+ if (fmMatch) {
+ const attrsObj = {};
+ 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 (value !== '' && !isNaN(value)) value = Number(value);
+ attrsObj[match[1]] = value;
+ }
+ }
+ attrs = attrsObj;
+ body = raw.substring(fmMatch[0].length).trim();
+ }
+
+ return { attrs, body };
+}
+
+function extractHeadings(body) {
+ const headings = [];
+ 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+(.*)$/);
+ 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
+ if (SKIP_FILES.has(entry.name)) 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;
+}
+
+// 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');
+
+ // 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 translation = parseTranslatedKey(pageKey);
+ const declaredLocale = hasLocaleMetadata(parsed.attrs)
+ ? String(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 || '',
+ isTranslated: translation !== null,
+ expectedLocale: translation ? translation.locale : null,
+ sourcePageKey: translation ? translation.sourcePageKey : null,
+ declaredLocale,
+ canonical: getCanonicalMetadata(parsed.attrs, parsed.body),
+ 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.isTranslated) {
+ 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 translatedPages) {
+ const fd = pageMap.get(pageKey);
+ const sourcePageKey = fd.sourcePageKey;
+
+ const sourceFd = pageMap.get(sourcePageKey);
+
+ if (!sourceFd) {
+ issues.push({
+ type: 'missing_source',
+ page: pageKey,
+ message: `Could not find source English page for translated page: ${sourcePageKey}`,
+ });
+ continue;
+ }
+
+ if (sourceFd) {
+ // 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);
+
+ if (sourceLevels.join(',') !== translatedLevels.join(',')) {
+ issues.push({
+ type: 'heading_drift',
+ page: pageKey,
+ message: `Heading structure mismatch: source=${sourceLevels.join(',')} vs translation=${translatedLevels.join(',')}`,
+ details: {
+ sourceHeadings: sourceFd.headings.map(h => `${h.level}: ${h.text}`),
+ translatedHeadings: fd.headings.map(h => `${h.level}: ${h.text}`),
+ },
+ });
+ }
+
+ // 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 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 fd.links) {
+ if (sourceUrls.has(translatedLink.url)) continue;
+
+ 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 },
+ });
+ }
+ }
+
+ // 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 on translated pages
+ console.log('\n=== Locale Metadata Check ===');
+ 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(` ✓ ${pageKey}: locale="${fd.declaredLocale}"`);
+ }
+ }
+
+ // 8. Check canonical metadata on translated pages
+ console.log('\n=== Canonical Metadata Check ===');
+ if (translatedPages.size === 0) {
+ console.log(' (no translated pages found)');
+ }
+ for (const pageKey of translatedPages) {
+ const fd = pageMap.get(pageKey);
+ if (!fd.canonical) {
+ issues.push({
+ type: 'missing_canonical',
+ page: pageKey,
+ message: 'Missing canonical metadata: add "canonical: /" to the front matter or a tag',
+ });
+ console.log(` ✗ ${pageKey}: missing canonical metadata`);
+ } else {
+ console.log(` ✓ ${pageKey}: canonical="${fd.canonical}"`);
+ }
+ }
+
+ // 9. Report summary
+ console.log('\n=== QA Summary ===');
+
+ const byType = {};
+ for (const issue of issues) {
+ byType[issue.type] = (byType[issue.type] || 0) + 1;
+ }
+
+ for (const [type, count] of Object.entries(byType)) {
+ console.log(` ${type}: ${count}`);
+ }
+
+ console.log(`\nTotal issues: ${issues.length}`);
+
+ if (issues.length === 0) {
+ console.log('✓ All checks passed! Documentation is structurally sound.');
+ } else {
+ console.log('\n=== Detailed Issues ===');
+ for (const issue of issues) {
+ console.log(`\n[${issue.type}] ${issue.page}: ${issue.message}`);
+ if (issue.details) {
+ console.log(` Details: ${JSON.stringify(issue.details)}`);
+ }
+ }
+ }
+
+ return { issues, fileData, pageMap, translatedPages, sourceLanguagePages, supportedTranslated };
+}
+
+module.exports = {
+ runQA,
+ parseFrontMatter,
+ extractHeadings,
+ countCodeFences,
+ extractLinks,
+ classifyLink,
+ hasLocaleMetadata,
+ parseTranslatedKey,
+ getCanonicalMetadata,
+};
+
+// Run if executed directly
+if (require.main === module) {
+ const result = runQA();
+ if (result.issues.length > 0) {
+ process.exit(1);
+ }
+}
\ No newline at end of file