From 484eec7b3c91e1830afea172e729eaf61dae3e88 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:30:48 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=8C=90=20=E6=B7=BB=E5=8A=A0=E7=BF=BB?= =?UTF-8?q?=E8=AF=91=E5=AE=8C=E6=95=B4=E6=80=A7=E6=9C=BA=E6=A2=B0=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=20(check:i18n)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #1587 (pt-BR) 合并后发现 messages.json 与 monaco 编辑器语言遗漏,且当时无机制拦截; 本次新增 scripts/check-i18n.mjs 并接入 pnpm lint / lint:ci,机械校验: - src/locales//*.json 各命名空间 key 是否与 en-US 一一对应(缺失/多余均报错) - src/locales//index.ts 是否导出全部命名空间 - src/assets/_locales//messages.json 与 en/messages.json 的 key 是否一致 (尚未创建该目录时仅提示,不阻塞) - docs/references/terminology-.md 是否存在(每个 locale 必须有,缺失即报错) - src/pkg/utils/monaco-editor/langs.ts 中 editorLangs 各 locale 的 key 是否与 en-US 一致 (尚未创建该 locale 条目时仅提示;已创建则 key 必须对齐) 已用 scriptscat/scriptcat 官方仓库的 PR #1568(韩语)与 #1587(pt-BR)实际验证: 正确通过完整、正确的翻译提交,也能在人为剔除 key / 文件时正确报错。 Co-Authored-By: Claude Sonnet 5 --- docs/translation.md | 15 +- package.json | 7 +- scripts/check-i18n.mjs | 335 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 353 insertions(+), 4 deletions(-) create mode 100644 scripts/check-i18n.mjs diff --git a/docs/translation.md b/docs/translation.md index 7a2ac5683..4b9c149e5 100644 --- a/docs/translation.md +++ b/docs/translation.md @@ -66,4 +66,17 @@ 2. 使用目标语言的自然表达;对同一 ScriptCat 概念使用规范中的固定术语,不要基于相近措辞合并不同的脚本类型。 3. 对需结合语境的术语,先核对实际功能、控件类型与上下文文案再决定用词。 4. 保留 i18next 插值、程序标识符、HTML/React 标记、URL 与元数据标识符(`@match`、`@require` 等)。 -5. 完成后复查本次修改,确认符合对应术语规范,并检查命名一致性、名词/动词混用等问题。 \ No newline at end of file +5. 完成后复查本次修改,确认符合对应术语规范,并检查命名一致性、名词/动词混用等问题。 +6. 运行 `pnpm run check:i18n`(或 `pnpm lint`,已内含此检查),确认没有遗漏或多余的翻译 key。 + +## 机械检查:遗漏翻译 / Mechanical check: missing translations + +`scripts/check-i18n.mjs`(`pnpm run check:i18n`)在每次 `pnpm lint` / `pnpm lint:ci` 时自动运行,用于捕获人工审阅容易漏掉的问题: + +- `src/locales//*.json` 中每个 key 是否与 `en-US`(模板 / fallback 语言)的 key 集合一一对应,缺失或多余的 key 都会报错。 +- `src/locales//index.ts` 是否导出了 `en-US` 拥有的全部命名空间。 +- `src/assets/_locales//messages.json`(`chrome.i18n` 语言文件,见上文"翻译工作流"一节)是否与 `en/messages.json` 的 key 一致;如果某个 locale 尚未创建该目录,只会给出提示(warning),不会导致失败。 +- `docs/references/terminology-.md` 是否存在:**`src/locales/` 下的每一个 locale 都必须有对应的术语规范文件**,缺失会导致检查失败——不允许新增或修改某个 locale 却不提交其 `terminology-.md`。 +- `src/pkg/utils/monaco-editor/langs.ts` 中 `editorLangs`(编辑器悬浮提示、脚本头字段提示等)的 key 是否与 `en-US` 一致;如果某个 locale 尚未在 `editorLangs` 中创建条目,只会给出提示(warning);但只要该 locale 已有条目,其 key 缺失或多余就会报错。 + +这个脚本无法判断翻译措辞是否准确、是否符合术语规范——那部分仍需人工审阅并遵循本文件与对应的 `terminology-.md`;它只保证不会有 key 或术语规范文件被整段遗漏。 \ No newline at end of file diff --git a/package.json b/package.json index 67d9c7b04..16f331519 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "build": "cross-env NODE_ENV=production rspack build", "pack": "node ./scripts/pack.js", "typecheck": "tsc --noEmit", - "lint": "concurrently -g \"prettier --check --cache \\\"**/*.{ts,tsx,js,jsx,mjs}\\\"\" \"tsc --noEmit\" && eslint . --cache --cache-location .eslintcache", - "lint:ci": "concurrently -g \"prettier --check --cache \\\"**/*.{ts,tsx,js,jsx,mjs}\\\"\" \"tsc --noEmit\" && eslint . --cache --cache-location .eslintcache", + "lint": "concurrently -g \"prettier --check --cache \\\"**/*.{ts,tsx,js,jsx,mjs}\\\"\" \"tsc --noEmit\" \"pnpm run check:i18n\" && eslint . --cache --cache-location .eslintcache", + "lint:ci": "concurrently -g \"prettier --check --cache \\\"**/*.{ts,tsx,js,jsx,mjs}\\\"\" \"tsc --noEmit\" \"pnpm run check:i18n\" && eslint . --cache --cache-location .eslintcache", "lint-fix": "concurrently -g \"prettier --write --cache \\\"**/*.{ts,tsx,js,jsx,mjs}\\\"\" \"tsc --noEmit\" && eslint --fix . --cache --cache-location .eslintcache", "test": "vitest --no-coverage --reporter=verbose", "test:ci": "vitest run --no-coverage --reporter=default --reporter.summary=false", @@ -25,7 +25,8 @@ "test:e2e": "pnpm exec playwright test", "test:e2e:ui": "pnpm exec playwright test --ui", "validate:yaml": "node ./scripts/validate-yaml.mjs", - "validate:yaml:all": "node ./scripts/validate-yaml.mjs --all" + "validate:yaml:all": "node ./scripts/validate-yaml.mjs --all", + "check:i18n": "node ./scripts/check-i18n.mjs" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.mjs new file mode 100644 index 000000000..ccf5eea97 --- /dev/null +++ b/scripts/check-i18n.mjs @@ -0,0 +1,335 @@ +#!/usr/bin/env node + +// Mechanical check for missing/extra translations in a locale PR. +// +// Checks: +// 1. src/locales//*.json namespace files — every key path present in the +// en-US reference must exist in every other locale (and vice versa for extras). +// 2. src/locales//index.ts — must export every namespace that en-US exports. +// 3. src/assets/_locales//messages.json — key parity against the en/ reference, +// for locales that already have a directory (chrome.i18n store-listing strings). +// 4. docs/references/terminology-.md — every locale under src/locales MUST have one. +// A translation PR that adds/changes a locale without its terminology file is rejected. +// 5. src/pkg/utils/monaco-editor/langs.ts — for locales that already have an `editorLangs` entry, +// its key set must match en-US (hover prompts, script-header field prompts). +// +// Locales that only exist under src/locales but have no src/assets/_locales directory, or no +// editorLangs entry yet, are reported as a warning, not a failure — those two surfaces are +// optional/best-effort (see docs/translation.md); once a locale HAS an entry, its keys must +// stay in sync with en-US or the check fails. + +import process from "node:process"; +import { readdirSync, readFileSync, existsSync, statSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, ".."); + +const LOCALES_DIR = path.join(ROOT, "src/locales"); +const REFERENCE_LOCALE = "en-US"; +const CHROME_LOCALES_DIR = path.join(ROOT, "src/assets/_locales"); +const CHROME_REFERENCE_LOCALE = "en"; +const TERMINOLOGY_DIR = path.join(ROOT, "docs/references"); +const MONACO_LANGS_PATH = path.join(ROOT, "src/pkg/utils/monaco-editor/langs.ts"); + +// chrome.i18n `_locales` directories don't follow one fixed rule (some are the bare language +// subtag like "ko", others are the full region-qualified code like "zh_CN" or "pt_BR"), and a +// hardcoded i18next-locale -> chrome-dir map silently rots the moment a PR adds a directory this +// script doesn't know about yet. So resolve it by scanning what's actually on disk instead. +function findChromeDirName(locale, actualChromeDirs) { + const [lang, region] = locale.split("-"); + const candidates = region ? [`${lang}_${region}`, lang] : [lang]; + return candidates.find((candidate) => actualChromeDirs.includes(candidate)); +} + +function isDir(p) { + return existsSync(p) && statSync(p).isDirectory(); +} + +function readJson(p) { + return JSON.parse(readFileSync(p, "utf8")); +} + +function flattenKeys(obj, prefix = "", out = new Set()) { + for (const [key, value] of Object.entries(obj)) { + const keyPath = prefix ? `${prefix}.${key}` : key; + if (value && typeof value === "object" && !Array.isArray(value)) { + flattenKeys(value, keyPath, out); + } else { + out.add(keyPath); + } + } + return out; +} + +function diffKeys(referenceKeys, targetKeys) { + const missing = [...referenceKeys].filter((k) => !targetKeys.has(k)).sort(); + const extra = [...targetKeys].filter((k) => !referenceKeys.has(k)).sort(); + return { missing, extra }; +} + +// Parse `export const editorLangs = { "locale": { ... } } as const` in a TS source file +// (not JSON, so it needs real AST parsing rather than JSON.parse) and return a Map from +// locale code to its flattened key set, resolving identifier references (e.g. a +// `grantValuePrompts: grantValuePromptsEnUS` property) against the file's top-level consts. +function parseEditorLangs(filePath) { + const source = readFileSync(filePath, "utf8"); + const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + + function unwrap(node) { + while (ts.isAsExpression(node) || ts.isParenthesizedExpression(node) || ts.isSatisfiesExpression?.(node)) { + node = node.expression; + } + return node; + } + + const topLevelConsts = new Map(); + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const decl of statement.declarationList.declarations) { + if (!decl.initializer) continue; + const initializer = unwrap(decl.initializer); + if (ts.isObjectLiteralExpression(initializer)) { + topLevelConsts.set(decl.name.getText(sourceFile), initializer); + } + } + } + + function propName(nameNode) { + if (ts.isIdentifier(nameNode) || ts.isStringLiteral(nameNode) || ts.isNumericLiteral(nameNode)) { + return nameNode.text; + } + return nameNode.getText(sourceFile); + } + + function flattenNode(node, prefix, out) { + node = unwrap(node); + if (ts.isObjectLiteralExpression(node)) { + for (const prop of node.properties) { + if (!ts.isPropertyAssignment(prop)) continue; + const key = propName(prop.name); + flattenNode(prop.initializer, prefix ? `${prefix}.${key}` : key, out); + } + return; + } + if (ts.isIdentifier(node) && topLevelConsts.has(node.text)) { + flattenNode(topLevelConsts.get(node.text), prefix, out); + return; + } + // Leaf: string/template literal, `.replace(...)` call, or an unresolved identifier. + out.add(prefix); + } + + const editorLangsNode = topLevelConsts.get("editorLangs"); + if (!editorLangsNode) { + throw new Error(`Could not find "export const editorLangs = ..." in ${filePath}`); + } + + const localeMap = new Map(); + for (const prop of editorLangsNode.properties) { + if (!ts.isPropertyAssignment(prop)) continue; + const locale = propName(prop.name); + const keys = new Set(); + flattenNode(prop.initializer, "", keys); + localeMap.set(locale, keys); + } + return localeMap; +} + +let hasError = false; +const problems = []; + +function reportError(message) { + hasError = true; + problems.push({ level: "error", message }); +} + +function reportWarning(message) { + problems.push({ level: "warning", message }); +} + +// --- 1 & 2: src/locales namespace files + index.ts exports --- + +const localeDirs = readdirSync(LOCALES_DIR).filter((name) => isDir(path.join(LOCALES_DIR, name))); + +if (!localeDirs.includes(REFERENCE_LOCALE)) { + reportError(`Reference locale directory "${REFERENCE_LOCALE}" not found under src/locales.`); +} else { + const referenceDir = path.join(LOCALES_DIR, REFERENCE_LOCALE); + const namespaceFiles = readdirSync(referenceDir) + .filter((name) => name.endsWith(".json")) + .sort(); + + const otherLocales = localeDirs.filter((name) => name !== REFERENCE_LOCALE).sort(); + + for (const locale of otherLocales) { + const localeDir = path.join(LOCALES_DIR, locale); + + // index.ts must export every namespace en-US exports. + const indexPath = path.join(localeDir, "index.ts"); + if (!existsSync(indexPath)) { + reportError(`src/locales/${locale}/index.ts is missing.`); + } else { + const indexSource = readFileSync(indexPath, "utf8"); + for (const file of namespaceFiles) { + const ns = path.basename(file, ".json"); + if (!indexSource.includes(`"./${ns}.json"`)) { + reportError(`src/locales/${locale}/index.ts does not export namespace "${ns}" (missing "./${ns}.json").`); + } + } + } + + for (const file of namespaceFiles) { + const referenceJson = readJson(path.join(referenceDir, file)); + const referenceKeys = flattenKeys(referenceJson); + + const targetPath = path.join(localeDir, file); + if (!existsSync(targetPath)) { + reportError(`src/locales/${locale}/${file} is missing entirely (${referenceKeys.size} keys untranslated).`); + continue; + } + + const targetJson = readJson(targetPath); + const targetKeys = flattenKeys(targetJson); + const { missing, extra } = diffKeys(referenceKeys, targetKeys); + + if (missing.length > 0) { + reportError( + `src/locales/${locale}/${file} is missing ${missing.length} key(s) present in ${REFERENCE_LOCALE}:\n` + + missing.map((k) => ` - ${k}`).join("\n") + ); + } + if (extra.length > 0) { + reportError( + `src/locales/${locale}/${file} has ${extra.length} key(s) not present in ${REFERENCE_LOCALE} (stale or misspelled key?):\n` + + extra.map((k) => ` - ${k}`).join("\n") + ); + } + } + } +} + +// --- 3: src/assets/_locales//messages.json --- + +if (isDir(CHROME_LOCALES_DIR)) { + const chromeReferencePath = path.join(CHROME_LOCALES_DIR, CHROME_REFERENCE_LOCALE, "messages.json"); + if (!existsSync(chromeReferencePath)) { + reportError(`Reference file ${path.relative(ROOT, chromeReferencePath)} not found.`); + } else { + const chromeReferenceKeys = flattenKeys(readJson(chromeReferencePath)); + const actualChromeDirs = readdirSync(CHROME_LOCALES_DIR).filter((name) => + isDir(path.join(CHROME_LOCALES_DIR, name)) + ); + + for (const locale of localeDirs) { + if (locale === REFERENCE_LOCALE) continue; + const chromeDirName = findChromeDirName(locale, actualChromeDirs); + + if (!chromeDirName) { + reportWarning( + `src/assets/_locales has no directory for locale "${locale}" yet (optional, but consider adding it — see docs/translation.md).` + ); + continue; + } + + const messagesPath = path.join(CHROME_LOCALES_DIR, chromeDirName, "messages.json"); + if (!existsSync(messagesPath)) { + reportError(`src/assets/_locales/${chromeDirName}/ exists but is missing messages.json.`); + continue; + } + + const targetKeys = flattenKeys(readJson(messagesPath)); + const { missing, extra } = diffKeys(chromeReferenceKeys, targetKeys); + + if (missing.length > 0) { + reportError( + `src/assets/_locales/${chromeDirName}/messages.json is missing ${missing.length} key(s) present in ` + + `${CHROME_REFERENCE_LOCALE}/messages.json:\n` + + missing.map((k) => ` - ${k}`).join("\n") + ); + } + if (extra.length > 0) { + reportError( + `src/assets/_locales/${chromeDirName}/messages.json has ${extra.length} key(s) not present in ` + + `${CHROME_REFERENCE_LOCALE}/messages.json:\n` + + extra.map((k) => ` - ${k}`).join("\n") + ); + } + } + } +} + +// --- 4: docs/references/terminology-.md --- + +for (const locale of localeDirs) { + const terminologyPath = path.join(TERMINOLOGY_DIR, `terminology-${locale}.md`); + if (!existsSync(terminologyPath)) { + reportError( + `docs/references/terminology-${locale}.md is missing. Every locale under src/locales/ must have a ` + + `terminology guide — see docs/translation.md § 各语言术语规范 / Per-locale terminology.` + ); + } +} + +// --- 5: src/pkg/utils/monaco-editor/langs.ts --- + +if (!existsSync(MONACO_LANGS_PATH)) { + reportError(`${path.relative(ROOT, MONACO_LANGS_PATH)} is missing.`); +} else { + const editorLangsByLocale = parseEditorLangs(MONACO_LANGS_PATH); + const relPath = path.relative(ROOT, MONACO_LANGS_PATH); + + if (!editorLangsByLocale.has(REFERENCE_LOCALE)) { + reportError(`${relPath}: editorLangs has no "${REFERENCE_LOCALE}" entry to use as a reference.`); + } else { + const referenceKeys = editorLangsByLocale.get(REFERENCE_LOCALE); + + for (const locale of localeDirs) { + if (locale === REFERENCE_LOCALE) continue; + + if (!editorLangsByLocale.has(locale)) { + reportWarning( + `${relPath}: editorLangs has no "${locale}" entry yet (${referenceKeys.size} keys untranslated for the ` + + `Monaco editor's hover prompts; optional, but consider adding it).` + ); + continue; + } + + const targetKeys = editorLangsByLocale.get(locale); + const { missing, extra } = diffKeys(referenceKeys, targetKeys); + + if (missing.length > 0) { + reportError( + `${relPath}: editorLangs["${locale}"] is missing ${missing.length} key(s) present in "${REFERENCE_LOCALE}":\n` + + missing.map((k) => ` - ${k}`).join("\n") + ); + } + if (extra.length > 0) { + reportError( + `${relPath}: editorLangs["${locale}"] has ${extra.length} key(s) not present in "${REFERENCE_LOCALE}" (stale or misspelled key?):\n` + + extra.map((k) => ` - ${k}`).join("\n") + ); + } + } + } +} + +// --- report --- + +if (problems.length === 0) { + console.log("✅ i18n check passed: all locales match the en-US / en reference key sets."); + process.exit(0); +} + +for (const { level, message } of problems) { + console.error(level === "error" ? `\n❌ ${message}` : `\n⚠️ ${message}`); +} + +if (hasError) { + console.error("\ni18n check failed. Fix the missing/extra keys above before submitting the translation PR."); + process.exit(1); +} + +console.log("\n✅ i18n check passed (with warnings above)."); From 5b7a5ca841a4eea3f426e4c473370d93c8384e17 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:37:46 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=94=A7=20=E6=94=AF=E6=8C=81=E6=8B=86?= =?UTF-8?q?=E5=88=86=E5=90=8E=E7=9A=84=20monaco-editor=20langs=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用 PR #1605 实测时发现:check-i18n.mjs 硬编码了单文件 src/pkg/utils/monaco-editor/langs.ts 路径,一旦该文件被拆分为 langs/.ts + langs/index.ts(如 #1605 所做),检查会误报 "文件缺失"。改为先探测 langs/index.ts 是否存在,再退回单文件路径;并将 key 展开逻辑改为 按模块解析(parseModule/flattenNode),支持跨文件解析 import 绑定(如 index.ts 里 "pt-BR": ptBR 指向 ./pt-BR.ts 的 default export)与同文件 shorthand 属性 (如 pt-BR.ts 内的 grantValuePrompts,)。 已用 #1605 的实际分支验证:新结构下 0 误报,人为在拆分后的 pt-BR.ts 中删除顶层 key 与 grantValuePrompts 子 key 均能正确报出具体路径。 Co-Authored-By: Claude Sonnet 5 --- scripts/check-i18n.mjs | 160 +++++++++++++++++++++++++++++------------ 1 file changed, 114 insertions(+), 46 deletions(-) diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.mjs index ccf5eea97..abb4fd530 100644 --- a/scripts/check-i18n.mjs +++ b/scripts/check-i18n.mjs @@ -32,7 +32,6 @@ const REFERENCE_LOCALE = "en-US"; const CHROME_LOCALES_DIR = path.join(ROOT, "src/assets/_locales"); const CHROME_REFERENCE_LOCALE = "en"; const TERMINOLOGY_DIR = path.join(ROOT, "docs/references"); -const MONACO_LANGS_PATH = path.join(ROOT, "src/pkg/utils/monaco-editor/langs.ts"); // chrome.i18n `_locales` directories don't follow one fixed rule (some are the bare language // subtag like "ko", others are the full region-qualified code like "zh_CN" or "pt_BR"), and a @@ -70,69 +69,133 @@ function diffKeys(referenceKeys, targetKeys) { return { missing, extra }; } -// Parse `export const editorLangs = { "locale": { ... } } as const` in a TS source file -// (not JSON, so it needs real AST parsing rather than JSON.parse) and return a Map from -// locale code to its flattened key set, resolving identifier references (e.g. a -// `grantValuePrompts: grantValuePromptsEnUS` property) against the file's top-level consts. -function parseEditorLangs(filePath) { - const source = readFileSync(filePath, "utf8"); - const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); +// Find the entry point for the Monaco editor's language data, whether it's still the original +// single file or has been split into src/pkg/utils/monaco-editor/langs/.ts + index.ts +// (each locale imported into an `editorLangs` map). Support both so a refactor of this file +// doesn't make the check report a false "file missing". +function findEditorLangsEntry() { + const splitIndex = path.join(ROOT, "src/pkg/utils/monaco-editor/langs/index.ts"); + if (existsSync(splitIndex)) return splitIndex; + const singleFile = path.join(ROOT, "src/pkg/utils/monaco-editor/langs.ts"); + if (existsSync(singleFile)) return singleFile; + return null; +} - function unwrap(node) { - while (ts.isAsExpression(node) || ts.isParenthesizedExpression(node) || ts.isSatisfiesExpression?.(node)) { - node = node.expression; - } - return node; +function unwrapExpression(node) { + while (node && (ts.isAsExpression(node) || ts.isParenthesizedExpression(node))) { + node = node.expression; + } + return node; +} + +function propName(nameNode, sourceFile) { + if (ts.isIdentifier(nameNode) || ts.isStringLiteral(nameNode) || ts.isNumericLiteral(nameNode)) { + return nameNode.text; } + return nameNode.getText(sourceFile); +} + +const moduleCache = new Map(); + +// Parse one TS module: its top-level `const x = {...}` object literals (for resolving +// same-file identifier references like `grantValuePrompts: grantValuePromptsEnUS`), its +// default-import bindings resolved to absolute file paths (for the split-file layout, where +// `editorLangs` maps a locale to an imported identifier), and its `export default {...}`. +function parseModule(filePath) { + if (moduleCache.has(filePath)) return moduleCache.get(filePath); + + const source = readFileSync(filePath, "utf8"); + const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); const topLevelConsts = new Map(); + const imports = new Map(); + let defaultExport; + for (const statement of sourceFile.statements) { - if (!ts.isVariableStatement(statement)) continue; - for (const decl of statement.declarationList.declarations) { - if (!decl.initializer) continue; - const initializer = unwrap(decl.initializer); - if (ts.isObjectLiteralExpression(initializer)) { - topLevelConsts.set(decl.name.getText(sourceFile), initializer); + if (ts.isVariableStatement(statement)) { + for (const decl of statement.declarationList.declarations) { + if (!decl.initializer) continue; + const initializer = unwrapExpression(decl.initializer); + if (ts.isObjectLiteralExpression(initializer)) { + topLevelConsts.set(decl.name.getText(sourceFile), initializer); + } + } + } else if ( + ts.isImportDeclaration(statement) && + statement.importClause?.name && + ts.isStringLiteral(statement.moduleSpecifier) && + statement.moduleSpecifier.text.startsWith(".") + ) { + const baseDir = path.dirname(filePath); + const specifier = statement.moduleSpecifier.text; + const resolved = [ + path.join(baseDir, `${specifier}.ts`), + path.join(baseDir, `${specifier}.tsx`), + path.join(baseDir, specifier, "index.ts"), + ].find((candidate) => existsSync(candidate)); + if (resolved) { + imports.set(statement.importClause.name.getText(sourceFile), resolved); } + } else if (ts.isExportAssignment(statement) && !statement.isExportEquals) { + const expr = unwrapExpression(statement.expression); + if (ts.isObjectLiteralExpression(expr)) defaultExport = expr; } } - function propName(nameNode) { - if (ts.isIdentifier(nameNode) || ts.isStringLiteral(nameNode) || ts.isNumericLiteral(nameNode)) { - return nameNode.text; - } - return nameNode.getText(sourceFile); - } + const moduleInfo = { sourceFile, topLevelConsts, imports, defaultExport }; + moduleCache.set(filePath, moduleInfo); + return moduleInfo; +} - function flattenNode(node, prefix, out) { - node = unwrap(node); - if (ts.isObjectLiteralExpression(node)) { - for (const prop of node.properties) { - if (!ts.isPropertyAssignment(prop)) continue; - const key = propName(prop.name); - flattenNode(prop.initializer, prefix ? `${prefix}.${key}` : key, out); +// Flatten an object-literal AST node into dot-path keys, resolving identifiers against the +// given module's own top-level consts first, then (for the split-file layout) against its +// imports — recursing into the imported module's default export with ITS OWN scope. +function flattenNode(node, prefix, out, moduleInfo) { + node = unwrapExpression(node); + if (ts.isObjectLiteralExpression(node)) { + for (const prop of node.properties) { + if (ts.isPropertyAssignment(prop)) { + const key = propName(prop.name, moduleInfo.sourceFile); + flattenNode(prop.initializer, prefix ? `${prefix}.${key}` : key, out, moduleInfo); + } else if (ts.isShorthandPropertyAssignment(prop)) { + const key = propName(prop.name, moduleInfo.sourceFile); + flattenNode(prop.name, prefix ? `${prefix}.${key}` : key, out, moduleInfo); } - return; } - if (ts.isIdentifier(node) && topLevelConsts.has(node.text)) { - flattenNode(topLevelConsts.get(node.text), prefix, out); + return; + } + if (ts.isIdentifier(node)) { + if (moduleInfo.topLevelConsts.has(node.text)) { + flattenNode(moduleInfo.topLevelConsts.get(node.text), prefix, out, moduleInfo); return; } - // Leaf: string/template literal, `.replace(...)` call, or an unresolved identifier. - out.add(prefix); + if (moduleInfo.imports.has(node.text)) { + const importedModule = parseModule(moduleInfo.imports.get(node.text)); + if (importedModule.defaultExport) { + flattenNode(importedModule.defaultExport, prefix, out, importedModule); + return; + } + } } + // Leaf: string/template literal, `.replace(...)` call, or an unresolved identifier. + out.add(prefix); +} - const editorLangsNode = topLevelConsts.get("editorLangs"); +// Return a Map from locale code to its flattened key set, for either layout of the Monaco +// editor's language data. +function parseEditorLangs(entryPath) { + const moduleInfo = parseModule(entryPath); + const editorLangsNode = moduleInfo.topLevelConsts.get("editorLangs"); if (!editorLangsNode) { - throw new Error(`Could not find "export const editorLangs = ..." in ${filePath}`); + throw new Error(`Could not find "export const editorLangs = ..." in ${entryPath}`); } const localeMap = new Map(); for (const prop of editorLangsNode.properties) { if (!ts.isPropertyAssignment(prop)) continue; - const locale = propName(prop.name); + const locale = propName(prop.name, moduleInfo.sourceFile); const keys = new Set(); - flattenNode(prop.initializer, "", keys); + flattenNode(prop.initializer, "", keys, moduleInfo); localeMap.set(locale, keys); } return localeMap; @@ -273,13 +336,18 @@ for (const locale of localeDirs) { } } -// --- 5: src/pkg/utils/monaco-editor/langs.ts --- +// --- 5: src/pkg/utils/monaco-editor/langs.ts (or the split langs/.ts + index.ts) --- + +const editorLangsEntry = findEditorLangsEntry(); -if (!existsSync(MONACO_LANGS_PATH)) { - reportError(`${path.relative(ROOT, MONACO_LANGS_PATH)} is missing.`); +if (!editorLangsEntry) { + reportError( + `Could not find the Monaco editor's language data — neither src/pkg/utils/monaco-editor/langs.ts nor ` + + `src/pkg/utils/monaco-editor/langs/index.ts exists.` + ); } else { - const editorLangsByLocale = parseEditorLangs(MONACO_LANGS_PATH); - const relPath = path.relative(ROOT, MONACO_LANGS_PATH); + const editorLangsByLocale = parseEditorLangs(editorLangsEntry); + const relPath = path.relative(ROOT, editorLangsEntry); if (!editorLangsByLocale.has(REFERENCE_LOCALE)) { reportError(`${relPath}: editorLangs has no "${REFERENCE_LOCALE}" entry to use as a reference.`); From b3cbe7d28c34273e5cd8bfe4c8800c434a82b732 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:50:26 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=94=92=20pre-commit=20=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=E5=BC=BA=E5=88=B6=E7=BF=BB=E8=AF=91=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E6=80=A7=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 的 lint:ci 已经跑 check:i18n,但那只在 push/PR 之后才会看到红叉。 本次让本地 pre-commit 钩子在暂存区涉及翻译相关路径 (src/locales/、src/assets/_locales/、docs/references/terminology-*.md、 src/pkg/utils/monaco-editor/langs.ts 或 langs/ 拆分文件)时, 提前跑一遍 pnpm run check:i18n,不通过则直接拒绝提交, 不需要等到推送后在 CI 里才发现。 真正能拦住"不通过就不许合并"的还有一层:仓库 main 分支的 branch protection 需要把 Lint 设为 required status check, 这一步需要 scriptscat/scriptcat 的仓库管理员在 GitHub 设置里配置, 不是贡献者这边能做的。 Co-Authored-By: Claude Sonnet 5 --- .husky/pre-commit | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.husky/pre-commit b/.husky/pre-commit index 1a227336a..2f14c282d 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -30,6 +30,20 @@ fi rm -f "$lint_files" +# 涉及翻译相关文件时,强制跑机械完整性检查(缺失/多余 key、缺失 messages.json、 +# 缺失 terminology 规范文件等),未通过则拒绝提交 +i18n_files=$(mktemp) +git diff --cached --name-only -z --diff-filter=ACMR -- \ + "src/locales/*" "src/assets/_locales/*" "docs/references/terminology-*.md" \ + "src/pkg/utils/monaco-editor/langs.ts" "src/pkg/utils/monaco-editor/langs/*" > "$i18n_files" + +if [ -s "$i18n_files" ]; then + rm -f "$i18n_files" + pnpm run check:i18n || exit 1 +else + rm -f "$i18n_files" +fi + # 在 main 或 release/* 分支上提交时额外跑测试 branch=$(git rev-parse --abbrev-ref HEAD) if [ "$branch" = "main" ] || echo "$branch" | grep -q "^release/"; then From 450cc0b685d6f1c86ba54bdd52897ece780639f4 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:53:09 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=94=A7=20pre-commit=20=E6=9C=BA?= =?UTF-8?q?=E6=A2=B0=E6=A3=80=E6=9F=A5=E8=8C=83=E5=9B=B4=E6=94=B6=E7=AA=84?= =?UTF-8?q?=E4=B8=BA=E4=BB=85=20json=20=E6=96=87=E4=BB=B6=E6=94=B9?= =?UTF-8?q?=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前的触发范围包含 terminology-*.md、langs.ts/langs/* 等非 json 文件, 导致本 PR 自身改动 .husky/pre-commit、scripts/check-i18n.mjs 这类非 翻译内容的提交也可能被牵连(虽然本次未命中,但范围过宽)。收窄为只在 暂存区包含 src/locales/**/*.json 或 src/assets/_locales/**/*.json 时才 触发 pnpm run check:i18n,其余改动(含本 PR 自身)不受影响,仍可正常 提交推送。CI 侧的 lint:ci 不受影响,仍覆盖全部 5 类检查。 Co-Authored-By: Claude Sonnet 5 --- .husky/pre-commit | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index 2f14c282d..95edecfa2 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -30,12 +30,11 @@ fi rm -f "$lint_files" -# 涉及翻译相关文件时,强制跑机械完整性检查(缺失/多余 key、缺失 messages.json、 -# 缺失 terminology 规范文件等),未通过则拒绝提交 +# 涉及翻译 json 文件时,强制跑机械完整性检查(缺失/多余 key、缺失 messages.json 等), +# 未通过则拒绝提交 i18n_files=$(mktemp) git diff --cached --name-only -z --diff-filter=ACMR -- \ - "src/locales/*" "src/assets/_locales/*" "docs/references/terminology-*.md" \ - "src/pkg/utils/monaco-editor/langs.ts" "src/pkg/utils/monaco-editor/langs/*" > "$i18n_files" + "src/locales/*.json" "src/assets/_locales/*.json" > "$i18n_files" if [ -s "$i18n_files" ]; then rm -f "$i18n_files"