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.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
Reference in New Issue
Block a user