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:
2026-10-15 22:03:36 +02:00
parent 8d3165c8c4
commit baa73bb3a3
16 changed files with 115 additions and 27 deletions
+10
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
{}
+77
View File
@@ -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)
}
@@ -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 },
)
@@ -802,8 +802,8 @@ export function DashboardEditor({
>
<span className="truncate text-sm">
{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.`}
</span>
<Button
variant="outline"
@@ -885,7 +885,7 @@ export function DashboardEditor({
hasDraft={hasDraft}
saving={save.isPending}
publishing={publish.isPending}
draftHint="Saved — publish to put it on the panels"
draftHint="Saved. Publish to put it on the panels"
onPublish={() => void publishDashboard()}
/>
+1 -1
View File
@@ -293,7 +293,7 @@ export function FlowDock({
saving={saving}
publishing={publishing}
offline={!connected}
draftHint="Saved — publish to put it live"
draftHint="Saved. Publish to put it live"
onPublish={onPublish}
/>
</Dock>
+1 -1
View File
@@ -1139,7 +1139,7 @@ function FlowEditorInner({
JSON.stringify({ nodes: picked, sources } satisfies NodeClipboard),
)
if (!stored) {
notify("That is too much to copy — the browser store is full.", "error")
notify("That is too much to copy. The browser store is full.", "error")
}
}, [canvasNodes, definitions, flowName, selectedId, sourceTypes])
+3 -3
View File
@@ -70,7 +70,7 @@ const NODE_ICONS = {
const STATUS_STYLES = {
queued: {
dot: "bg-muted-foreground",
label: "Queued — waiting for a machine",
label: "Queued, waiting for a machine",
},
running: { dot: "bg-primary animate-pulse", label: "Running" },
success: { dot: "bg-status-success", label: "Last run succeeded" },
@@ -282,7 +282,7 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
)}
aria-label={
failedEarlier
? `Failed at ${failedAt} — show the traceback`
? `Failed at ${failedAt}. Show the traceback`
: "Show what this node printed"
}
data-testid="node-traceback"
@@ -299,7 +299,7 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
</TooltipTrigger>
<TooltipContent>
{failedEarlier
? `Failed at ${failedAt} — show the traceback`
? `Failed at ${failedAt}. Show the traceback`
: "Show the traceback"}
</TooltipContent>
</Tooltip>
@@ -64,7 +64,7 @@ export function LiveIndicator() {
<Activity className="size-4 shrink-0 text-muted-foreground" />
<span className="font-medium">Live updates disconnected</span>
<span className="text-muted-foreground">
values and activity are frozen — reconnecting…
values and activity are frozen, reconnecting…
</span>
</motion.div>
) : null}
+3 -3
View File
@@ -255,7 +255,7 @@ export function RunsScreen({
names={selected.map(shortId)}
noun="run"
pending={remove.isPending}
description="A run still going is cancelled first. What it recorded — its numbers, its nodes and the outputs other runs reuse from the cache — goes with it, and nothing brings it back."
description="A run still going is cancelled first. Its numbers, its nodes and the outputs other runs reuse from the cache go with it, and nothing brings them back."
onConfirm={() => remove.mutate(selected)}
/>
@@ -288,10 +288,10 @@ export function RunsScreen({
{runs.length} run{runs.length === 1 ? "" : "s"}
{selected.length > 0 ? `, ${selected.length} picked` : ""}
{selected.length >= MAX_SELECTION
? ` — ${MAX_SELECTION} is as many as one comparison carries`
? `. ${MAX_SELECTION} is as many as one comparison carries`
: ""}
{!hasNextPage && runs.length >= LIST_CAP
? ` — the newest ${LIST_CAP}, which is as deep as this list reads`
? `. The newest ${LIST_CAP} is as deep as this list reads`
: ""}
</p>
</div>
+1 -1
View File
@@ -283,7 +283,7 @@ export async function cancelThenDelete(runId: string) {
* left as an empty chart, which reads as a fault.
*/
export const NO_CURVE =
"No curve was recorded. A node restored from the cache is read back from the run that produced it, so this draws nothing once that run has been deleted — its outputs are still on the result."
"No curve was recorded. A node restored from the cache is read back from the run that produced it, so this draws nothing once that run has been deleted. Its outputs are still on the result."
/**
* Which restored nodes lost their curve, and with which deleted run.
@@ -159,7 +159,7 @@ export function RemoteAccess() {
<CardHeader>
<CardTitle>Remote access</CardTitle>
<CardDescription>
Reach this instance from fluksio.com. Entirely optional — without it,
Reach this instance from fluksio.com. Entirely optional. Without it,
this instance talks to nothing outside your network.
</CardDescription>
</CardHeader>
@@ -179,7 +179,7 @@ export function RemoteAccess() {
</span>
</>
) : status.last_error ? (
`Reconnecting — ${status.last_error}`
`Reconnecting: ${status.last_error}`
) : (
"Reconnecting…"
)}
+3 -3
View File
@@ -125,7 +125,7 @@ function ThisBrowser() {
if (!webpush.supported()) {
return (
<p className="text-sm text-muted-foreground">
This browser cannot receive notifications. They need HTTPS — over plain
This browser cannot receive notifications. They need HTTPS. Over plain
http on a local address, no browser will allow them.
</p>
)
@@ -215,7 +215,7 @@ function AlertsForm({ initial }: { initial: AlertsConfig }) {
<span className="text-sm text-muted-foreground">
{draft.enabled
? "Failures reach the channels below."
: "Switched off — failures only reach the log."}
: "Switched off. Failures only reach the log."}
</span>
</div>
<Switch
@@ -313,7 +313,7 @@ function AlertsForm({ initial }: { initial: AlertsConfig }) {
disabled={dirty || test.isPending}
title={
dirty
? "Save first — the engine sends through the stored channel"
? "Save first. The engine sends through the stored channel"
: "Send one alert through this channel"
}
data-testid="test-channel"
+2 -2
View File
@@ -147,8 +147,8 @@ function Modules() {
className="text-sm text-muted-foreground"
data-testid="modules-adopted"
>
Adopted — this is the environment Fluksio itself is installed in.
Add a package the way you added the rest:{" "}
Adopted. This is the environment Fluksio itself is installed in. Add
a package the way you added the rest:{" "}
<span className="font-mono">pip install …</span>, then sync or
restart so the workers pick it up. For a venv the engine owns
instead, set <span className="font-mono">NODE_VENV=managed</span>.
+3 -3
View File
@@ -117,12 +117,12 @@ function Machines() {
{String(provisioner.name)} can start{" "}
{(provisioner.profiles as string[]).join(", ")}
{outstanding.length > 0
? ` — asked for ${outstanding
? `. Asked for ${outstanding
.map((job) => `${job.profile} (job ${job.job})`)
.join(", ")}`
: ""}
{provisioner.last_error
? ` — last failed: ${String(provisioner.last_error)}`
? `. Last failed: ${String(provisioner.last_error)}`
: ""}
</p>
)
@@ -269,7 +269,7 @@ function Flavors() {
<span className="font-mono text-xs text-muted-foreground">
{flavor.cpus} cpu · {Math.round((flavor.ram ?? 0) / 1024)} GB
{flavor.gpus ? ` · ${flavor.gpus} gpu` : ""}
{flavor.description ? ` — ${flavor.description}` : ""}
{flavor.description ? ` · ${flavor.description}` : ""}
</span>
</span>
{mayEdit ? (
+2 -2
View File
@@ -9,8 +9,8 @@ function extractErrorMessage(err: ApiError): string {
const offline = offlineDetail(err)
if (offline) {
return offline.lastSeen
? `Not delivered — the instance is offline (last seen ${ago(offline.lastSeen)})`
: "Not delivered — the instance is offline"
? `Not delivered. The instance is offline (last seen ${ago(offline.lastSeen)})`
: "Not delivered. The instance is offline"
}
if (err instanceof AxiosError) {