From 175f1ae34b675312d040b2f34a68252a77f6742d Mon Sep 17 00:00:00 2001 From: iciker Date: Thu, 27 Aug 2026 08:28:45 +0800 Subject: [PATCH 1/3] feat: add Simplified Chinese localization --- bun.lock | 9 + package.json | 2 + scripts/i18n-audit.ts | 103 +++ src/components/Dialog.svelte | 11 +- src/components/EditModal.svelte | 3 +- src/components/EventModal.svelte | 2 +- src/components/GeneratingCard.svelte | 7 +- src/components/LeaderPane.svelte | 5 +- src/components/Picker.svelte | 13 +- src/components/StatusBar.svelte | 2 +- src/components/SubjectPanel.svelte | 6 +- src/lib/i18n.test.ts | 310 +++++++ src/lib/i18n.ts | 1142 ++++++++++++++++++++++++++ src/lib/store.svelte.ts | 17 + src/main.ts | 6 + src/views/AddSource.svelte | 13 +- src/views/AnalyticsView.svelte | 82 +- src/views/CalendarView.svelte | 9 +- src/views/Cheatsheet.svelte | 14 +- src/views/Citations.svelte | 2 +- src/views/ExamView.svelte | 2 +- src/views/Settings.svelte | 26 +- src/views/SubjectView.svelte | 31 +- 23 files changed, 1740 insertions(+), 77 deletions(-) create mode 100644 scripts/i18n-audit.ts create mode 100644 src/lib/i18n.test.ts create mode 100644 src/lib/i18n.ts 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/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..afd7876 --- /dev/null +++ b/scripts/i18n-audit.ts @@ -0,0 +1,103 @@ +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([ + "Cortex", + "Esc", + "Groq", + "OpenAI", + "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}