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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
78 lines
2.7 KiB
TypeScript
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)
|
|
}
|