// THE COPY SCAN SEES WHAT THE LINE SCAN COULD NOT. // // check-i18n's bare-English half used to read .tsx a line at a time with two // regexes and a two-word floor. `LINE_SCAN` below is that scan, verbatim; the // fixture is the list of shapes the 3.23 audit said it could not see. Each // one is asserted MISSED by the old scan and CAUGHT by the syntax-tree scan // (scripts/i18nScan.mjs), so the claim that the rewrite was needed is a test, // not a comment. import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { readShellDictionary, scanDom, scanTsx } from "../scripts/i18nScan.mjs"; /** The retired scan (scripts/check-i18n.mjs before the rewrite), verbatim. */ function LINE_SCAN(source: string): string[] { const copyWords = (text: string): number => (text.replace(/\b[\w-]+[./][\w-]+\b/g, " ").match(/[A-Za-z]{2,}/g) ?? []).length; const CODEISH = /[={}<>]|&&|\|\||=>|\(\)/; const out: string[] = []; source.split("\n").forEach((line) => { for (const mm of line.matchAll(/>([^<>{}\n]+)]/.test(line)) continue; out.push(text); } for (const mm of line.matchAll(/\b(placeholder|title|aria-label|alt)="([^"]+)"/g)) { if (copyWords(mm[2]) < 2) continue; out.push(mm[2]); } }); return out; } const CASES: [name: string, tsx: string, caught: string][] = [ ["a single word", `const A = () => ;`, "Save"], ["an attribute expression", `const A = ({ open }: { open: boolean }) => ;`, "Hide it"], ["a template literal", "const A = ({ n }: { n: string }) => ;", "Open … now"], ["JSX text split across lines", `const A = () => (\n
\n Nothing here yet\n
\n);`, "Nothing here yet"], ["aria-description", `const A = () => ;`, "Drag to reorder"], ["aria-valuetext", `const A = () => ;`, "Half way"], ["a braced string child", `const A = () => {"Loading the shelf"};`, "Loading the shelf"], ["a fallback after ??", `const A = ({ v }: { v?: string }) => ;`, "Type a name"], ]; describe("the JSX copy scan (scripts/i18nScan.mjs)", () => { for (const [name, tsx, caught] of CASES) { it(`${name}: missed by the line scan, caught on the tree`, () => { assert.deepEqual(LINE_SCAN(tsx), [], "the old scan should have missed this"); assert.deepEqual(scanTsx(tsx).map((f) => f.text), [caught]); }); } it("leaves what is not copy alone", () => { const quiet = [ `const A = () => ;`, `const A = () => Ctrl K;`, `const A = () => ;`, `const A = () => ;`, `const A = () => GitHub;`, `const A = ({ a }: { a: string }) => ;`, `const A = () => (\n // not copy: a folder's literal name on disk\n \n);`, ]; for (const tsx of quiet) assert.deepEqual(scanTsx(tsx), [], tsx); }); }); describe("the DOM copy scan", () => { it("follows a multi-line ternary into both arms", () => { const ts = `btn.title = folded\n ? "Unfold section"\n : "Fold section";`; assert.deepEqual(scanDom(ts).map((f) => f.text), ["Unfold section", "Fold section"]); }); it("reads setAttribute's value, never its name, and not a t() argument", () => { assert.deepEqual(scanDom(`n.setAttribute("aria-label", "Close panel"); n.setAttribute("title", t("closePanel"));`).map((f) => f.text), ["Close panel"]); }); it("in the shell: el() props and children, and a JSON error body", () => { const shell = [ `el("button", { textContent: "Connect now" });`, `el("p", {}, "Nothing was shared");`, `new Response(JSON.stringify({ error: "The pocket vault is not open yet", code: "booting" }));`, `el("p", { textContent: t.connect }, t.connectLede);`, ].join("\n"); assert.deepEqual(scanDom(shell, "x.ts", { shell: true }).map((f) => f.text), ["Connect now", "Nothing was shared", "The pocket vault is not open yet"]); // The pocket's 501 reasons and its path refusals are keys, spoken in the // reader's language; a sentence typed in their place is English. const pocket = [ `return fail(501, "Uploading needs a server", "pocket");`, `return fail(501, speak("refuseUpload"), "pocket");`, `return fail(404, "Not found");`, `throw new PocketVaultError("That path is not a path");`, `throw new PocketVaultError("vaultPathNotPath");`, ].join("\n"); assert.deepEqual(scanDom(pocket, "x.ts", { shell: true }).map((f) => f.text), ["Uploading needs a server", "That path is not a path"]); // The client's object literals are data (release notes, tables), not DOM. assert.deepEqual(scanDom(`const row = { title: "Some title" };`), []); }); }); describe("the shell's dictionary", () => { const dict = readShellDictionary(` const en = { hello: "Hello", bye: (who: string) => \`Goodbye, \${who}\`, count: (n: number) => \`\${n} note\${n === 1 ? "" : "s"}\`, }; type Copy = typeof en; const ar: Copy = { hello: "Hello", bye: (who: string) => \`وداعًا\`, count: (n: number) => \`\${n} من الملاحظات\`, };`); it("reads both columns, with each function's interpolated parameters", () => { assert.deepEqual([...dict.en.keys()], ["hello", "bye", "count"]); assert.deepEqual(dict.en.get("bye")?.holes, ["who"]); assert.deepEqual(dict.ar.get("bye")?.holes, [], "the Arabic dropped the name — check-i18n reports it"); assert.deepEqual(dict.en.get("count")?.holes, ["n"]); assert.deepEqual(dict.ar.get("count")?.holes, ["n"], "a plural branch interpolates the same parameter"); assert.equal(dict.ar.get("hello")?.text, "Hello", "an untranslated value — check-i18n reports it"); }); });