diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5482a1c2..f480a917 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -187,16 +187,23 @@ Supported locales: Guidelines: - Add English keys first, then update every locale file in `src/core/i18n/translations/`. -- User-facing copy is not complete until every supported locale has complete, accurate localized text in the same PR. Do not ship English-only page body, FAQ, table, example, schema, or SEO copy with only localized metadata, and do not make only part of a page original while leaving the rest as English fallback copy. +- Hard merge rule: user-facing copy is not complete until every supported locale in the same PR has complete, accurate localized text. This includes headings, body copy, CTAs, FAQ, table text, examples, schema-visible text, and SEO metadata. +- Hard merge rule: every supported locale has complete, accurate localized text before a PR can merge. +- No English-only originality: new or rewritten content cannot be authored only for `en` while other locales receive fallback, literal filler, or metadata-only localization. +- No partial originality: the complete affected user-facing surface must be localized. Do not make only one locale, one section, or only above-the-fold copy original while leaving the rest as generic fallback copy. +- partial originality is not acceptable for any user-facing copy, SEO copy, FAQ, table, example, or schema-visible text. +- Partial localization is a merge blocker. Split scope before opening the PR if the full multilingual copy cannot be reviewed accurately. - Preserve technical terms such as `JSON`, `JWT`, `API`, `Base64`, and `UUID` when that is the natural localized form. - For Chinese copy, use spaces between Chinese text and English terms or numbers where readability requires it. - Keep labels short enough for compact tool controls. +- Use the [i18n glossary](docs/i18n/glossary.md) for privacy, runtime, workflow, and tool-family terminology. - For larger localized copy or SEO template changes, follow the [localization quality review checklist](docs/specs/localization-quality-review.md). Checks: ```bash npm run check:i18n +npm run check:i18n-qa npm run check:metadata-localization ``` diff --git a/docs/i18n/glossary.md b/docs/i18n/glossary.md new file mode 100644 index 00000000..2293404c --- /dev/null +++ b/docs/i18n/glossary.md @@ -0,0 +1,44 @@ +# Byteflow i18n Glossary + +This glossary is the source of truth for user-facing localization of privacy, runtime, workflow, and tool-family terms. Every copy change must keep all supported locales aligned: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `de`, and `fr`. + +## Rules + +- Localize complete page copy in the same PR: title, description, headings, body copy, CTAs, FAQ, table text, examples, schema-visible text, and SEO metadata. +- Hard merge rule: user-facing copy is not complete until every supported locale in the same PR has complete, accurate localized text. +- User-facing copy must ship as complete, accurate localized text across every supported locale. +- No English-only originality: do not author original content only for `en` while other locales receive fallback, literal filler, or metadata-only localization. +- No partial originality: the complete affected user-facing surface must be localized. Do not make only one locale, one section, or only above-the-fold copy original while leaving the rest as generic fallback copy. +- Partial localization is a merge blocker. Split scope before opening the PR if accurate localization for all supported locales is not ready. +- Keep technical tokens such as `JSON`, `JWT`, `API`, `Base64`, `UUID`, `SHA-256`, and `HMAC` unchanged when that is the natural local form. +- Prefer clear product language over literal translation when a locale has a more natural technical term. + +## Privacy And Runtime Terms + +| Concept | en | zh-CN | zh-TW | ja | ko | de | fr | +| --- | --- | --- | --- | --- | --- | --- | --- | +| Browser-local | Browser-local | 浏览器本地 | 瀏覽器本地 | ブラウザ内 | 브라우저 로컬 | browserlokal | local dans le navigateur | +| Offline capable | Offline capable | 支持离线 | 支援離線 | オフライン対応 | 오프라인 가능 | offlinefähig | utilisable hors ligne | +| External request | External request | 外部请求 | 外部請求 | 外部リクエスト | 외부 요청 | externe Anfrage | requête externe | +| Sensitive input | Sensitive input | 敏感输入 | 敏感輸入 | 機密入力 | 민감한 입력 | sensible Eingabe | entrée sensible | +| Privacy-first | Privacy-first | 隐私优先 | 隱私優先 | プライバシー重視 | 개인정보 우선 | datenschutzfreundlich | respectueux de la vie privée | +| Local-first | Local-first | 本地优先 | 本地優先 | ローカル優先 | 로컬 우선 | lokal zuerst | local-first | + +## Tool Family Terms + +| Concept | en | zh-CN | zh-TW | ja | ko | de | fr | +| --- | --- | --- | --- | --- | --- | --- | --- | +| Formatter | Formatter | 格式化工具 | 格式化工具 | フォーマッター | 포매터 | Formatierer | formateur | +| Encoder | Encoder | 编码器 | 編碼器 | エンコーダー | 인코더 | Encoder | encodeur | +| Decoder | Decoder | 解码器 | 解碼器 | デコーダー | 디코더 | Decoder | décodeur | +| Hash | Hash | 哈希 | 雜湊 | ハッシュ | 해시 | Hash | hash | +| Workflow | Workflow | 工作流 | 工作流 | ワークフロー | 워크플로 | Workflow | workflow | +| Pipeline | Pipeline | Pipeline | Pipeline | パイプライン | 파이프라인 | Pipeline | pipeline | + +## Writing Notes + +- Chinese: use Simplified Chinese for `zh-CN` and Traditional Chinese for `zh-TW`. Keep spaces around Latin technical tokens when readability improves. +- Japanese: keep particles natural and avoid English carryover except accepted technical tokens. +- Korean: check particles after English tokens and avoid awkward transliteration. +- German: prefer compounds where natural, but avoid overlong button text. +- French: keep accents, agreement, and apostrophes correct; do not use unaccented fallback copy. diff --git a/docs/specs/localization-quality-review.md b/docs/specs/localization-quality-review.md index 897d870f..b3428572 100644 --- a/docs/specs/localization-quality-review.md +++ b/docs/specs/localization-quality-review.md @@ -16,7 +16,12 @@ The previous localization pass allowed weak copy to land for four concrete reaso - Do not use synthetic translation generation for production localized copy. - Treat checked-in localized JSON as user-facing copy, even when the path contains `generated`. - For every localized copy change, review all supported locales: `zh-CN`, `zh-TW`, `ja`, `ko`, `de`, and `fr`. -- New user-facing pages, guides, comparisons, alternatives, tutorials, FAQ, schema-visible text, examples, tables, and SEO copy must be fully localized for every supported locale in the same change. Localized titles or descriptions are not enough when the body remains English, and partial originality is not acceptable. +- Hard merge rule: user-facing copy is not complete until every supported locale in the same PR has complete, accurate localized text. +- New user-facing pages, guides, comparisons, alternatives, tutorials, headings, CTAs, FAQ, schema-visible text, examples, tables, and SEO copy must be fully localized for every supported locale in the same change. +- No English-only originality: original content cannot exist only in `en` while non-English locales receive fallback, literal filler, or metadata-only localization. Localized titles or descriptions are not enough when the body remains English. +- No partial originality: original treatment must cover the complete affected user-facing surface, not only one locale, one section, or only above-the-fold copy. +- Partial localization is a merge blocker. Reduce the PR scope instead of shipping partial multilingual content. +- Use `docs/i18n/glossary.md` as the source of truth for privacy, runtime, workflow, and tool-family terms. - Preserve accepted technical terms such as `JSON`, `YAML`, `TOML`, `JWT`, `API`, `Base32`, `Base58`, `Base64`, and `UUID` when that is the natural localized form. - Translate workflow terms deliberately. Do not leave `fixture`, `payload`, or similar English terms in non-English copy unless the locale commonly uses that term in technical UI. - Check grammar and typography, not just key presence. Watch for Korean particles, Japanese spacing around particles, German compound terms, French agreement, and Chinese spacing around Latin technical terms. diff --git a/package.json b/package.json index 8baa9147..0e96a91b 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "scripts": { "dev": "next dev", "analyze": "ANALYZE=true next build --webpack", - "validate": "npm run check:sw-version && npm run check:sitemap-lastmod && npm run check:security-headers && npm run check:pwa-manifests && npm run check:runtime-scripts && npm run check:bundle-boundaries && npm run check:performance-budget && npm run check:og-tool-images && npm run check:ia-stability && npm run check:analytics-taxonomy && npm run check:registry-manifests && npm run check:tool-index && npm run check:legacy-routes && npm run check:client-tool-lookup && npm run check:privacy-copy && npm run check:i18n && npm run check:types", + "validate": "npm run check:sw-version && npm run check:sitemap-lastmod && npm run check:security-headers && npm run check:pwa-manifests && npm run check:runtime-scripts && npm run check:bundle-boundaries && npm run check:performance-budget && npm run check:og-tool-images && npm run check:ia-stability && npm run check:analytics-taxonomy && npm run check:registry-manifests && npm run check:tool-index && npm run check:legacy-routes && npm run check:client-tool-lookup && npm run check:privacy-copy && npm run check:i18n && npm run check:i18n-qa && npm run check:types", "build:app": "next build", "build:post": "npm run check:canonical && npm run check:hreflang && npm run check:legacy-routes && npm run check:metadata-localization && npm run check:jsonld-structured-data && npm run check:performance-budget:report && npm run check:rendered-i18n-copy && npm run check:related-tools && npm run check:content-template:legacy-paths && npm run check:content-template && npm run check:content-template:quality && npm run check:content-template:uniqueness && npm run check:faq-schema && npm run check:content-template:zh-cn && npm run check:content-template:quality:zh-cn && npm run check:faq-schema:zh-cn && npm run check:content-template:zh-tw && npm run check:content-template:quality:zh-tw && npm run check:faq-schema:zh-tw && npm run check:content-template:ja && npm run check:content-template:quality:ja && npm run check:faq-schema:ja && npm run check:content-template:ko && npm run check:content-template:quality:ko && npm run check:faq-schema:ko && npm run check:content-template:de && npm run check:content-template:quality:de && npm run check:faq-schema:de && npm run check:content-template:fr && npm run check:content-template:quality:fr && npm run check:faq-schema:fr && npm run postprocess:export-html-lang && npm run check:export-html-lang && npm run postprocess:export-robots-meta && npm run check:export-robots-meta && npm run build:sw", "build": "npm run validate && npm run build:app && npm run build:post", @@ -62,6 +62,7 @@ "test:e2e:smoke": "node scripts/e2e/run-playwright-smoke.js", "test:e2e:pwa": "node scripts/e2e/run-playwright-smoke.js --pwa", "check:i18n": "node scripts/gates/check-i18n.js", + "check:i18n-qa": "node scripts/gates/check-i18n-qa.js", "check:i18n:ratchet": "node scripts/gates/check-i18n-untranslated-ratchet.js --mode=fail", "check:i18n:ratchet:warn": "node scripts/gates/check-i18n-untranslated-ratchet.js --mode=warn", "check:i18n:ratchet:fail": "node scripts/gates/check-i18n-untranslated-ratchet.js --mode=fail", diff --git a/scripts/gates/check-i18n-qa.js b/scripts/gates/check-i18n-qa.js new file mode 100644 index 00000000..0c657d4c --- /dev/null +++ b/scripts/gates/check-i18n-qa.js @@ -0,0 +1,338 @@ +#!/usr/bin/env node + +import fs from "node:fs" +import path from "node:path" + +const PROJECT_ROOT = process.cwd() +const GLOSSARY_PATH = path.join(PROJECT_ROOT, "docs/i18n/glossary.md") +const CONTRIBUTING_PATH = path.join(PROJECT_ROOT, "CONTRIBUTING.md") +const LOCALIZATION_REVIEW_PATH = path.join(PROJECT_ROOT, "docs/specs/localization-quality-review.md") +const TRANSLATIONS_DIR = path.join(PROJECT_ROOT, "src/core/i18n/translations") +const I18N_SOURCE_PATH = path.join(PROJECT_ROOT, "src/core/i18n/i18n.ts") +const LOCALIZED_META_PATH = path.join(PROJECT_ROOT, "src/core/seo/localized-meta-copy.ts") +const ROUTE_INTENT_COPY_PATH = path.join(PROJECT_ROOT, "src/core/seo/route-intent-copy.ts") + +const LOCALES = ["en", "zh-CN", "zh-TW", "ja", "ko", "de", "fr"] +const NON_EN_LOCALES = LOCALES.filter((locale) => locale !== "en") + +const GLOSSARY_TERMS = [ + "Browser-local", + "Offline capable", + "External request", + "Sensitive input", + "Privacy-first", + "Local-first", + "Formatter", + "Encoder", + "Decoder", + "Hash", + "Workflow", + "Pipeline", +] + +const REQUIRED_POLICY_PHRASES = [ + "complete, accurate localized text", + "every supported locale in the same PR", + "No English-only originality", + "No partial originality", + "complete affected user-facing surface", + "metadata-only localization", + "Partial localization is a merge blocker", +] + +const REQUIRED_META_SLUGS = [ + "api-auth-header-mistakes", + "certificate-chain-basics-for-developers", + "convert-curl-to-fetch-python", + "csp-mistakes-that-break-production", + "dns-records-uptime", + "json-formatting-errors", + "json-schema-validation-checklist", + "json-vs-json5-differences", + "mock-openapi-quickly", + "openapi-debugging-workflow-checklist", + "robots-txt-testing-checklist", + "validate-json-before-api-requests", + "md5-digest-generator", + "sha1-digest-generator", + "sha224-digest-generator", + "sha256-digest-generator", + "sha384-digest-generator", + "sha512-digest-generator", +] + +const ACCEPTED_TECHNICAL_TOKENS = [ + "API", + "Base32", + "Base58", + "Base64", + "CSS", + "CSP", + "DevTools", + "DNS", + "HMAC", + "HTML", + "HTTP", + "JSON", + "JWT", + "MD5", + "OpenAPI", + "Pipeline", + "PWA", + "SHA", + "SVG", + "TOML", + "URL", + "UUID", + "YAML", + "byteflow.tools", + "cURL", + "fetch", + "jwt.io", +] + +const NON_LATIN_SCRIPT_REQUIREMENTS = { + "zh-CN": /[\u3400-\u9FFF]/, + "zh-TW": /[\u3400-\u9FFF]/, + ja: /[\u3040-\u30FF\u3400-\u9FFF]/, + ko: /[\uAC00-\uD7AF]/, +} + +function readText(filePath) { + return fs.readFileSync(filePath, "utf8") +} + +function readJson(filePath) { + return JSON.parse(readText(filePath)) +} + +function addIssue(issues, message) { + issues.push(message) +} + +function extractQuotedStringArray(source, exportName) { + const match = source.match(new RegExp(`export\\s+const\\s+${exportName}\\s*=\\s*\\[([\\s\\S]*?)\\]\\s+as\\s+const`)) + if (!match) return [] + return [...match[1].matchAll(/"([^"]+)"/g)].map((item) => item[1]) +} + +function flattenStrings(value, out = []) { + if (typeof value === "string") { + out.push(value) + return out + } + if (Array.isArray(value)) { + for (const item of value) flattenStrings(item, out) + return out + } + if (value && typeof value === "object") { + for (const item of Object.values(value)) flattenStrings(item, out) + } + return out +} + +function normalize(value) { + return value.replace(/\s+/g, " ").trim() +} + +function removeAcceptedTechnicalTokens(value) { + let result = value + for (const token of ACCEPTED_TECHNICAL_TOKENS) { + result = result.replace(new RegExp(`\\b${token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"), " ") + } + return result +} + +function hasSuspiciousEnglishSentence(value) { + const withoutTechnicalTokens = removeAcceptedTechnicalTokens(value) + return /[A-Za-z]{4,}\s+[A-Za-z]{4,}\s+[A-Za-z]{4,}/.test(withoutTechnicalTokens) +} + +function extractObjectBlock(source, objectName) { + const marker = `${objectName}:` + const start = source.indexOf(marker) + if (start < 0) return "" + + const objectStart = source.indexOf("{", start) + if (objectStart < 0) return "" + + let depth = 0 + for (let index = objectStart; index < source.length; index += 1) { + const char = source[index] + if (char === "{") depth += 1 + if (char === "}") { + depth -= 1 + if (depth === 0) { + return source.slice(objectStart, index + 1) + } + } + } + return "" +} + +function extractLocalizedMetaValues(source, objectName, slug, locale) { + const objectBlock = extractObjectBlock(source, objectName) + const slugMatch = objectBlock.match(new RegExp(`"${slug}"\\s*:\\s*\\{([\\s\\S]*?)\\n\\s*\\},`)) + if (!slugMatch) return null + const localeMatch = slugMatch[1].match(new RegExp(`${locale.includes("-") ? `"${locale}"` : locale}\\s*:\\s*"([^"]+)"`)) + return localeMatch?.[1] ?? null +} + +function checkGlossary(issues) { + if (!fs.existsSync(GLOSSARY_PATH)) { + addIssue(issues, "Missing docs/i18n/glossary.md") + return + } + + const glossary = readText(GLOSSARY_PATH) + for (const locale of LOCALES) { + if (!glossary.includes(locale)) { + addIssue(issues, `Glossary does not mention locale ${locale}`) + } + } + for (const term of GLOSSARY_TERMS) { + if (!glossary.includes(term)) { + addIssue(issues, `Glossary missing required term: ${term}`) + } + } + + for (const phrase of REQUIRED_POLICY_PHRASES) { + if (!glossary.includes(phrase)) { + addIssue(issues, `Glossary missing policy phrase: ${phrase}`) + } + } +} + +function checkRepositoryPolicy(issues) { + const contributing = readText(CONTRIBUTING_PATH) + const review = readText(LOCALIZATION_REVIEW_PATH) + const combined = `${contributing}\n${review}` + + for (const phrase of REQUIRED_POLICY_PHRASES) { + if (!combined.includes(phrase)) { + addIssue(issues, `Repository localization policy missing phrase: ${phrase}`) + } + } + if (!combined.includes("docs/i18n/glossary.md")) { + addIssue(issues, "Repository docs do not link to docs/i18n/glossary.md") + } +} + +function checkLocaleFiles(issues) { + const sourceLocales = extractQuotedStringArray(readText(I18N_SOURCE_PATH), "LOCALES") + if (sourceLocales.join("|") !== LOCALES.join("|")) { + addIssue(issues, `LOCALES mismatch: expected ${LOCALES.join(", ")}, got ${sourceLocales.join(", ")}`) + } + + const baseData = readJson(path.join(TRANSLATIONS_DIR, "en.json")) + const baseStrings = flattenStrings(baseData).filter((value) => normalize(value).length > 0) + + for (const locale of LOCALES) { + const localePath = path.join(TRANSLATIONS_DIR, `${locale}.json`) + if (!fs.existsSync(localePath)) { + addIssue(issues, `Missing translation file for ${locale}`) + continue + } + + const data = readJson(localePath) + const values = flattenStrings(data).filter((value) => normalize(value).length > 0) + if (values.length !== baseStrings.length) { + addIssue(issues, `${locale}.json string count differs from en.json (${values.length} vs ${baseStrings.length})`) + } + + const scriptRequirement = NON_LATIN_SCRIPT_REQUIREMENTS[locale] + if (!scriptRequirement) continue + const longValues = values.filter((value) => normalize(value).length >= 24) + const missingScript = longValues.filter((value) => !scriptRequirement.test(value)) + if (missingScript.length > Math.floor(longValues.length * 0.08)) { + addIssue(issues, `${locale}.json has too many long values without locale script (${missingScript.length}/${longValues.length})`) + } + } +} + +function checkLocalizedMetadataSource(issues) { + const source = readText(LOCALIZED_META_PATH) + + for (const slug of REQUIRED_META_SLUGS) { + for (const locale of NON_EN_LOCALES) { + const scriptRequirement = NON_LATIN_SCRIPT_REQUIREMENTS[locale] + const title = extractLocalizedMetaValues(source, "TITLE_OVERRIDES", slug, locale) + if (!title) { + addIssue(issues, `TITLE_OVERRIDES missing ${slug} ${locale}`) + continue + } + if (scriptRequirement && hasSuspiciousEnglishSentence(title)) { + addIssue(issues, `TITLE_OVERRIDES ${slug} ${locale} looks English: ${title}`) + } + + if (scriptRequirement && !scriptRequirement.test(title)) { + addIssue(issues, `TITLE_OVERRIDES ${slug} ${locale} lacks locale script: ${title}`) + } + } + } + + for (const slug of ["md5-digest-generator", "sha1-digest-generator", "sha224-digest-generator", "sha256-digest-generator", "sha384-digest-generator", "sha512-digest-generator"]) { + for (const locale of NON_EN_LOCALES) { + const scriptRequirement = NON_LATIN_SCRIPT_REQUIREMENTS[locale] + const description = extractLocalizedMetaValues(source, "DESCRIPTION_OVERRIDES", slug, locale) + if (!description) { + addIssue(issues, `DESCRIPTION_OVERRIDES missing ${slug} ${locale}`) + continue + } + if (scriptRequirement && hasSuspiciousEnglishSentence(description)) { + addIssue(issues, `DESCRIPTION_OVERRIDES ${slug} ${locale} looks English: ${description}`) + } + } + } +} + +function checkRouteIntentCopy(issues) { + const source = readText(ROUTE_INTENT_COPY_PATH) + + for (const locale of LOCALES) { + const localeKey = locale.includes("-") ? `"${locale}"` : locale + if (!source.includes(`${localeKey}:`)) { + addIssue(issues, `Route intent copy missing locale ${locale}`) + } + } + + for (const locale of NON_EN_LOCALES) { + const scriptRequirement = NON_LATIN_SCRIPT_REQUIREMENTS[locale] + const block = extractObjectBlock(source, locale.includes("-") ? `"${locale}"` : locale) + if (!block) continue + const values = [...block.matchAll(/:\s*"([^"]+)"/g)].map((match) => match[1]) + for (const value of values) { + if (scriptRequirement && hasSuspiciousEnglishSentence(value)) { + addIssue(issues, `Route intent copy ${locale} looks English: ${value}`) + } + if (scriptRequirement && !scriptRequirement.test(value)) { + addIssue(issues, `Route intent copy ${locale} lacks locale script: ${value}`) + } + } + } +} + +function main() { + const issues = [] + + checkGlossary(issues) + checkRepositoryPolicy(issues) + checkLocaleFiles(issues) + checkLocalizedMetadataSource(issues) + checkRouteIntentCopy(issues) + + if (issues.length > 0) { + console.error(`[check:i18n-qa] Found ${issues.length} issue(s):`) + for (const issue of issues.slice(0, 160)) { + console.error(`- ${issue}`) + } + if (issues.length > 160) { + console.error(`... truncated ${issues.length - 160} additional issue(s).`) + } + process.exit(1) + } + + console.log("[check:i18n-qa] OK: glossary, locale coverage, metadata source, and localized route intent copy passed.") +} + +main() diff --git a/scripts/gates/check-metadata-localization.js b/scripts/gates/check-metadata-localization.js index ff3a396b..69ad2f43 100644 --- a/scripts/gates/check-metadata-localization.js +++ b/scripts/gates/check-metadata-localization.js @@ -71,6 +71,22 @@ function run() { const localized = extractMetadata(fs.readFileSync(htmlFile, "utf8")) const english = extractMetadata(fs.readFileSync(enFile, "utf8")) + if (!localized.title) { + issues.push({ + locale, + rel, + field: "title", + value: "(empty)", + }) + } + if (!localized.description) { + issues.push({ + locale, + rel, + field: "description", + value: "(empty)", + }) + } if (localized.title && localized.title === english.title) { issues.push({ locale, diff --git a/tests/guards/i18n-qa-gates.test.ts b/tests/guards/i18n-qa-gates.test.ts new file mode 100644 index 00000000..9aa4f055 --- /dev/null +++ b/tests/guards/i18n-qa-gates.test.ts @@ -0,0 +1,91 @@ +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { execFileSync } from "node:child_process" +import { describe, expect, it } from "vitest" +import { LOCALES } from "@/core/i18n/i18n" + +const PROJECT_ROOT = process.cwd() + +function runNodeScript(scriptPath: string, env: Record = {}, cwd = PROJECT_ROOT) { + return execFileSync(process.execPath, [path.join(PROJECT_ROOT, scriptPath)], { + cwd, + env: { + ...process.env, + ...env, + }, + encoding: "utf8", + }) +} + +function writeHtml(filePath: string, html: string) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, html) +} + +function alternateLinks(slug: string) { + return [ + ...LOCALES.map((locale) => ``), + ``, + ].join("\n") +} + +describe("BF-029 i18n QA gates", () => { + it("passes the repository i18n QA gate", () => { + const output = runNodeScript("scripts/gates/check-i18n-qa.js") + + expect(output).toContain("[check:i18n-qa] OK") + }) + + it("checks nested localized routes in canonical and hreflang gates", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "byteflow-i18n-gate-")) + const slug = "how-to/decode-jwt-locally" + + try { + for (const locale of LOCALES) { + writeHtml( + path.join(tempDir, locale, "how-to", "decode-jwt-locally.html"), + [ + "", + ``, + alternateLinks(slug), + "Nested page", + ].join("\n"), + ) + } + + const canonicalOutput = runNodeScript("scripts/gates/check-canonical.js", { + CANONICAL_SCAN_DIR: tempDir, + }) + const hreflangOutput = runNodeScript("scripts/gates/check-hreflang.js", { + HREFLANG_SCAN_DIR: tempDir, + }) + + expect(canonicalOutput).toContain("OK: 7 page(s) verified") + expect(hreflangOutput).toContain("OK: 7 page(s) verified") + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it("fails metadata localization when a localized description is empty", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "byteflow-metadata-gate-")) + const appDir = path.join(tempRoot, ".next", "server", "app") + + try { + writeHtml( + path.join(appDir, "en", "json-formatter.html"), + "JSON Formatter", + ) + writeHtml( + path.join(appDir, "zh-CN", "json-formatter.html"), + "JSON 格式化工具", + ) + + expect(() => runNodeScript("scripts/gates/check-metadata-localization.js", { + }, tempRoot)).toThrow(/description/) + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }) + } + }) +})