Files
app/frontend/scripts/no-em-dash.ts
T
stroblme baa73bb3a3 no-em-dash pre-commit hook for user-facing strings, existing em-dashes rewritten
frontend/scripts/no-em-dash.ts walks src/ with the TypeScript parser and flags
an em-dash in JSX text, string literals and template text. A bare "—"
placeholder passes, separators go in no-em-dash.allow.json. The script is
identical to index's copy.
2026-10-15 22:03:36 +02:00

78 lines
2.7 KiB
TypeScript

/**
* Flags em-dashes in user-facing strings: JSX text, string literals and
* template literal text under `src/`. Comments are not checked. See Microcopy
* in the root DESIGN-GUIDELINES.md.
*
* bun frontend/scripts/no-em-dash.ts [files…] # no files: all of src/
*
* A literal that is only "—" is the empty-value placeholder and passes. The
* other exception, a separator inside one line, goes in `no-em-dash.allow.json`
* as the file (relative to `src/`) and the exact string. A template literal is
* listed by its source text, backticks included.
*
* The same file lives in app/ and index/. Keep the two identical.
*/
import { readdirSync, readFileSync } from "node:fs"
import { join, relative, resolve } from "node:path"
import ts from "typescript"
import allow from "./no-em-dash.allow.json"
const src = resolve(import.meta.dir, "../src")
const skip = /(^|\/)client\/|\.gen\.ts$|\.(check|spec|test)\.ts$/
const dash = /—|—/
const allowed = allow as Record<string, string[]>
const squash = (s: string) => s.replace(/\s+/g, " ").trim()
const walk = (dir: string): string[] =>
readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
e.isDirectory() ? walk(join(dir, e.name)) : [join(dir, e.name)],
)
const args = process.argv.slice(2).map((f) => resolve(f))
const files = (args.length ? args : walk(src)).filter((f) => {
const rel = relative(src, f)
return /\.tsx?$/.test(f) && !rel.startsWith("..") && !skip.test(rel)
})
let found = 0
for (const file of files) {
const rel = relative(src, file)
const sf = ts.createSourceFile(
file,
readFileSync(file, "utf8"),
ts.ScriptTarget.Latest,
true,
file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
)
const report = (node: ts.Node, text: string) => {
if (text === "—" || allowed[rel]?.includes(text)) return
const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf))
console.log(`${relative(process.cwd(), file)}:${line + 1}: ${text}`)
found++
}
const visit = (node: ts.Node) => {
if (ts.isTemplateExpression(node)) {
const parts = [node.head, ...node.templateSpans.map((s) => s.literal)]
if (parts.some((p) => dash.test(p.text))) report(node, node.getText(sf))
} else if (
(ts.isStringLiteral(node) ||
ts.isNoSubstitutionTemplateLiteral(node) ||
ts.isJsxText(node)) &&
dash.test(node.text)
) {
report(node, squash(node.text))
}
ts.forEachChild(node, visit)
}
visit(sf)
}
if (found) {
console.log(
`\n${found} em-dash(es) in user-facing strings. Rewrite them (Microcopy in DESIGN-GUIDELINES.md) or allowlist a separator in frontend/scripts/no-em-dash.allow.json.`,
)
process.exit(1)
}