/** * 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 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) }