diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3db3dc1..4041dbd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,6 +40,16 @@ repos: language: pygrep files: ^frontend/src/.*\.tsx$ + # No em-dashes in user-facing strings; see Microcopy in the root + # DESIGN-GUIDELINES.md. A separator inside one line goes in + # frontend/scripts/no-em-dash.allow.json. Whole tree: + # `pre-commit run no-em-dash --all-files`. + - id: no-em-dash + name: no em-dashes in user-facing strings + entry: bun frontend/scripts/no-em-dash.ts + language: system + files: ^frontend/src/.*\.tsx?$ + - id: local-ruff-check name: ruff check entry: uv run ruff check --force-exclude --fix --exit-non-zero-on-fix diff --git a/frontend/scripts/no-em-dash.allow.json b/frontend/scripts/no-em-dash.allow.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/frontend/scripts/no-em-dash.allow.json @@ -0,0 +1 @@ +{} diff --git a/frontend/scripts/no-em-dash.ts b/frontend/scripts/no-em-dash.ts new file mode 100644 index 0000000..23903b4 --- /dev/null +++ b/frontend/scripts/no-em-dash.ts @@ -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 +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) +} diff --git a/frontend/src/components/Common/ConnectionNotice.tsx b/frontend/src/components/Common/ConnectionNotice.tsx index 03954d2..a13894b 100644 --- a/frontend/src/components/Common/ConnectionNotice.tsx +++ b/frontend/src/components/Common/ConnectionNotice.tsx @@ -39,8 +39,8 @@ export function ConnectionNotice() { } notify( connection.lastSeen - ? `Instance offline — last seen ${ago(connection.lastSeen / 1000)}, reconnecting…` - : "Instance offline — reconnecting…", + ? `Instance offline (last seen ${ago(connection.lastSeen / 1000)}), reconnecting…` + : "Instance offline, reconnecting…", "warning", { id: ID, persistent: true }, ) diff --git a/frontend/src/components/Dashboard/DashboardEditor.tsx b/frontend/src/components/Dashboard/DashboardEditor.tsx index 58604eb..33fac94 100644 --- a/frontend/src/components/Dashboard/DashboardEditor.tsx +++ b/frontend/src/components/Dashboard/DashboardEditor.tsx @@ -802,8 +802,8 @@ export function DashboardEditor({ > {clipped.length === 1 - ? "1 widget sits below the canvas — a panel this size will not show it." - : `${clipped.length} widgets sit below the canvas — a panel this size will not show them.`} + ? "1 widget sits below the canvas. A panel this size will not show it." + : `${clipped.length} widgets sit below the canvas. A panel this size will not show them.`}