diff --git a/bun.lock b/bun.lock index 9e821ee..43b8be0 100644 --- a/bun.lock +++ b/bun.lock @@ -16,6 +16,7 @@ "@sveltejs/vite-plugin-svelte": "^5.0.3", "@tauri-apps/cli": "^2.1.0", "@tsconfig/svelte": "^5.0.4", + "@types/bun": "^1.4.0", "@types/katex": "^0.16.8", "autoprefixer": "^10.4.20", "postcss": "^8.4.49", @@ -212,10 +213,14 @@ "@tsconfig/svelte": ["@tsconfig/svelte@5.0.8", "", {}, "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ=="], + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="], + "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -240,6 +245,8 @@ "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], "caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="], @@ -406,6 +413,8 @@ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], diff --git a/docs/testing/zh-cn-localization-completion.tdd.md b/docs/testing/zh-cn-localization-completion.tdd.md new file mode 100644 index 0000000..e988c61 --- /dev/null +++ b/docs/testing/zh-cn-localization-completion.tdd.md @@ -0,0 +1,32 @@ +# Simplified Chinese localization completion — TDD evidence + +## Source and user journey + +This work was derived directly from the request to eliminate the 418 remaining +English UI audit findings. No external plan file was used. + +As a user who selects Simplified Chinese, I want every reviewed static Svelte UI +string to have a Chinese rendering, while technical identifiers and user-authored +content remain unchanged. + +## RED → GREEN evidence + +| Guarantee | Test or command | Type | Result | Evidence | +|---|---|---|---|---| +| Every Svelte UI file is included in the localization audit | `bun test src/lib/i18n.test.ts` | Integration | PASS | The suite checks all 58 `.svelte` files. | +| The previous untranslated inventory is rejected | `bun test src/lib/i18n.test.ts` before the translation update | Regression | RED | The new assertion failed with 418 findings. | +| No reviewed static English copy remains without a translation or explicit technical classification | `bun test src/lib/i18n.test.ts` after the translation update | Regression | PASS | 13 tests passed; the global finding list is empty. | +| The standalone audit agrees with the regression test | `bun run i18n:audit` | Static audit | PASS | `0 untranslated static strings across 58 Svelte files`. | +| The localized application still type-checks and bundles | `bun run check` and `bun run build` | Build | PASS | Svelte reported 0 errors and 0 warnings; Vite completed the production build. | + +## Coverage and known gaps + +`bun test --coverage src/lib/i18n.test.ts` reports 86.32% line coverage for the +static audit module. Aggregate coverage is 43.36% because the same module imports +the browser-only DOM observer from `i18n.ts`; the Bun test environment has no DOM +implementation and therefore cannot execute that path. Translation behavior, +dynamic patterns, user-content boundaries, and the full 58-file audit are covered. +The DOM observer itself remains a browser/E2E coverage gap. + +The audit intentionally preserves four technical examples: a DOI placeholder, an +API-key placeholder, an author-name format, and the `Osaka Jade` theme name. diff --git a/package.json b/package.json index bcbb722..151d865 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "build": "vite build", "preview": "vite preview", "check": "svelte-check --tsconfig ./tsconfig.json", + "i18n:audit": "bun scripts/i18n-audit.ts", "sidecars": "bun scripts/fetch-sidecars.mjs", "tauri": "tauri" }, @@ -24,6 +25,7 @@ "@sveltejs/vite-plugin-svelte": "^5.0.3", "@tauri-apps/cli": "^2.1.0", "@tsconfig/svelte": "^5.0.4", + "@types/bun": "^1.4.0", "@types/katex": "^0.16.8", "autoprefixer": "^10.4.20", "postcss": "^8.4.49", diff --git a/scripts/i18n-audit.ts b/scripts/i18n-audit.ts new file mode 100644 index 0000000..9c74c15 --- /dev/null +++ b/scripts/i18n-audit.ts @@ -0,0 +1,107 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { parse } from "svelte/compiler"; +import { translateText } from "../src/lib/i18n"; + +const TECHNICAL_COPY = new Set([ + "10.xxxx/…", + "AIza…", + "Cortex", + "Esc", + "Groq", + "Last, F.; Last, F.", + "OpenAI", + "Osaka Jade", + "Tailscale", + "Rust · Tauri · Svelte", + "Omarchy ·", + "age", + "cortex", + "ffmpeg", + "mpv", + "rclone", + "sync", + "syncd", + "yt-dlp", +]); + +function isTechnicalCopy(source: string): boolean { + return TECHNICAL_COPY.has(source) + || /^(?:https?:\/\/|~\/|\/)[^ ]+$/.test(source) + || /^[A-Z][A-Z0-9_-]+(?:=|[-])?…?$/.test(source) + || /^[A-Z][A-Z0-9_]+=/.test(source) + || /^(?:[\w.-]+\/)+[\w.-]+$/.test(source) + || /^[\w.-]+\/$/.test(source) + || /^[\w.-]+:[\w.-]+$/.test(source) + || /^[a-z0-9]+(?:[-/.][a-z0-9]+)+$/.test(source) + || /^[a-z0-9]{30,}$/.test(source) + || /^…?[\w.-]+\.[a-z]{2,}$/.test(source) + || /^(?:docker compose|git pull|sudo |rclone |age-keygen)/.test(source) + || /^(?:[\w.-]+:)?[\w.-]+\.(?:com|edu|net|org)(?:\/\S*)?$/.test(source); +} + +function hasSkipAttribute(value: Record): boolean { + const attributes = value.attributes; + return Array.isArray(attributes) && attributes.some((attribute) => { + return attribute + && typeof attribute === "object" + && "name" in attribute + && attribute.name === "data-i18n-skip"; + }); +} + +export function svelteFilesUnder(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) return svelteFilesUnder(path); + return entry.isFile() && entry.name.endsWith(".svelte") ? [path] : []; + }); +} + +export function findUntranslatedStaticCopy(files = svelteFilesUnder("src")): string[] { + const untranslated: string[] = []; + for (const file of files) { + const ast = parse(readFileSync(file, "utf8"), { filename: file }) as any; + const seen = new Set(); + const visit = (value: any, parent: any = null, skipped = false) => { + if (!value || typeof value !== "object" || seen.has(value)) return; + seen.add(value); + const skipChildren = skipped || hasSkipAttribute(value); + if (!skipChildren && value.type === "Text" && typeof value.data === "string") { + const inAttribute = parent?.type === "Attribute" || parent?.type?.endsWith?.("Directive"); + const translatedAttribute = inAttribute && ["title", "placeholder", "aria-label"].includes(parent.name); + if (!inAttribute || translatedAttribute) { + const source = value.data.trim().replace(/\s+/g, " "); + if ( + /[A-Za-z]{2}/.test(source) + && !isTechnicalCopy(source) + && translateText(source, "zh-CN") === source + ) { + untranslated.push(`${file}: ${source}`); + } + } + } + for (const [key, child] of Object.entries(value)) { + if (key === "parent" || key === "metadata") continue; + if (Array.isArray(child)) child.forEach((item) => visit(item, value, skipChildren)); + else visit(child, value, skipChildren); + } + }; + visit(ast.html); + } + return untranslated.sort(); +} + +if (import.meta.main) { + const findings = findUntranslatedStaticCopy(); + const counts = new Map(); + for (const entry of findings) { + const file = entry.slice(0, entry.indexOf(":")); + counts.set(file, (counts.get(file) ?? 0) + 1); + } + const byFile = [...counts] + .map(([file, count]) => `${file}: ${count}`) + .join("\n"); + console.log(`${findings.length} untranslated static strings across ${svelteFilesUnder("src").length} Svelte files`); + if (byFile) console.log(byFile); +} diff --git a/src/components/Dialog.svelte b/src/components/Dialog.svelte index dc6d574..3ad5f8b 100644 --- a/src/components/Dialog.svelte +++ b/src/components/Dialog.svelte @@ -3,6 +3,7 @@ // window.confirm / window.prompt. Driven entirely by app.dialog; resolves via // app.resolveDialog. Mounted once globally in App.svelte. import { app } from "../lib/store.svelte"; + import { translateText } from "../lib/i18n"; let inputEl = $state(null); let draft = $state(""); @@ -45,19 +46,19 @@ {#if app.dialog}