/** * Keeps quoted UI labels in the docs tied to the app's string catalogue. * * npm run strings report only * npm run strings -- --fix rewrite unambiguous labels into `{{key}}` * * A label written as `Archive` is a snapshot that goes stale silently. Written * as `{{archive}}` it is resolved from packages/intl at build time, so renaming * the string in the app updates the docs. * * The script never invents strings: it only matches text that an existing * zero-argument key already produces. */ import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs"; import { join, relative } from "node:path"; import { pathToFileURL } from "node:url"; const HELP = new URL("..", import.meta.url).pathname; const CONTENTS = join(HELP, "contents"); const FIX = process.argv.includes("--fix"); const { buildReverseIndex, resolveString } = await import( pathToFileURL(join(HELP, ".vitepress/strings.mts")).href ).catch(async () => { // .mts needs a TS-aware loader; fall back to the transpiled copy VitePress uses. return await import(pathToFileURL(join(HELP, ".vitepress/strings.mjs")).href); }); const byValue = buildReverseIndex(); function walk(dir, out = []) { for (const entry of readdirSync(dir)) { if (["public", "_versions"].includes(entry) || /^v\d/.test(entry)) continue; const p = join(dir, entry); if (statSync(p).isDirectory()) walk(p, out); else if (p.endsWith(".md")) out.push(p); } return out; } /** Split frontmatter off; we only touch the body. */ function splitFrontmatter(text) { if (!text.startsWith("---\n")) return ["", text]; const end = text.indexOf("\n---\n", 3); return end === -1 ? ["", text] : [text.slice(0, end + 5), text.slice(end + 5)]; } const stats = { files: 0, alreadyKeys: 0, converted: 0, ambiguous: new Map(), unmatched: new Map() }; for (const file of walk(CONTENTS)) { const original = readFileSync(file, "utf8"); const [frontmatter, body] = splitFrontmatter(original); const rel = relative(CONTENTS, file); stats.alreadyKeys += (body.match(/\{\{[A-Za-z][A-Za-z0-9_]*\}\}/g) || []).length; let changed = false; const fences = []; // Protect fenced code blocks from rewriting. const masked = body.replace(/```[\s\S]*?```/g, (m) => { fences.push(m); return `FENCE${fences.length - 1}`; }); const next = masked.replace(/`([^`\n]{2,60})`/g, (match, label) => { if (label.startsWith("{{")) return match; const keys = byValue.get(label); if (!keys) { if (/^[A-Z]/.test(label) && label.split(" ").length <= 4) stats.unmatched.set(label, (stats.unmatched.get(label) || new Set()).add(rel)); return match; } if (keys.length > 1) { stats.ambiguous.set(label, keys); return match; } changed = true; stats.converted++; return `\`{{${keys[0]}}}\``; }); if (changed && FIX) { const restored = next.replace(/FENCE(\d+)/g, (_, i) => fences[Number(i)]); writeFileSync(file, frontmatter + restored); stats.files++; } } console.log( FIX ? `Rewrote ${stats.converted} labels into string keys across ${stats.files} files.` : `${stats.converted} labels could be rewritten as string keys (run with --fix).` ); console.log(`${stats.alreadyKeys} labels are already keys.`); if (stats.ambiguous.size) { console.log(`\n${stats.ambiguous.size} labels map to more than one key — left alone:`); for (const [label, keys] of [...stats.ambiguous].slice(0, 15)) console.log(` "${label}" -> ${keys.join(", ")}`); } if (stats.unmatched.size) { const sorted = [...stats.unmatched].sort((a, b) => b[1].size - a[1].size).slice(0, 20); console.log( `\n${stats.unmatched.size} capitalised labels have no matching string (they may be third-party UI, or genuinely absent from the catalogue):` ); for (const [label, files] of sorted) console.log(` "${label}" — ${[...files].slice(0, 2).join(", ")}`); }