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:
@@ -40,6 +40,16 @@ repos:
|
|||||||
language: pygrep
|
language: pygrep
|
||||||
files: ^frontend/src/.*\.tsx$
|
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
|
- id: local-ruff-check
|
||||||
name: ruff check
|
name: ruff check
|
||||||
entry: uv run ruff check --force-exclude --fix --exit-non-zero-on-fix
|
entry: uv run ruff check --force-exclude --fix --exit-non-zero-on-fix
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -39,8 +39,8 @@ export function ConnectionNotice() {
|
|||||||
}
|
}
|
||||||
notify(
|
notify(
|
||||||
connection.lastSeen
|
connection.lastSeen
|
||||||
? `Instance offline — last seen ${ago(connection.lastSeen / 1000)}, reconnecting…`
|
? `Instance offline (last seen ${ago(connection.lastSeen / 1000)}), reconnecting…`
|
||||||
: "Instance offline — reconnecting…",
|
: "Instance offline, reconnecting…",
|
||||||
"warning",
|
"warning",
|
||||||
{ id: ID, persistent: true },
|
{ id: ID, persistent: true },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -802,8 +802,8 @@ export function DashboardEditor({
|
|||||||
>
|
>
|
||||||
<span className="truncate text-sm">
|
<span className="truncate text-sm">
|
||||||
{clipped.length === 1
|
{clipped.length === 1
|
||||||
? "1 widget sits below the canvas — a panel this size will not show it."
|
? "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.`}
|
: `${clipped.length} widgets sit below the canvas. A panel this size will not show them.`}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -885,7 +885,7 @@ export function DashboardEditor({
|
|||||||
hasDraft={hasDraft}
|
hasDraft={hasDraft}
|
||||||
saving={save.isPending}
|
saving={save.isPending}
|
||||||
publishing={publish.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()}
|
onPublish={() => void publishDashboard()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ export function FlowDock({
|
|||||||
saving={saving}
|
saving={saving}
|
||||||
publishing={publishing}
|
publishing={publishing}
|
||||||
offline={!connected}
|
offline={!connected}
|
||||||
draftHint="Saved — publish to put it live"
|
draftHint="Saved. Publish to put it live"
|
||||||
onPublish={onPublish}
|
onPublish={onPublish}
|
||||||
/>
|
/>
|
||||||
</Dock>
|
</Dock>
|
||||||
|
|||||||
@@ -1139,7 +1139,7 @@ function FlowEditorInner({
|
|||||||
JSON.stringify({ nodes: picked, sources } satisfies NodeClipboard),
|
JSON.stringify({ nodes: picked, sources } satisfies NodeClipboard),
|
||||||
)
|
)
|
||||||
if (!stored) {
|
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])
|
}, [canvasNodes, definitions, flowName, selectedId, sourceTypes])
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ const NODE_ICONS = {
|
|||||||
const STATUS_STYLES = {
|
const STATUS_STYLES = {
|
||||||
queued: {
|
queued: {
|
||||||
dot: "bg-muted-foreground",
|
dot: "bg-muted-foreground",
|
||||||
label: "Queued — waiting for a machine",
|
label: "Queued, waiting for a machine",
|
||||||
},
|
},
|
||||||
running: { dot: "bg-primary animate-pulse", label: "Running" },
|
running: { dot: "bg-primary animate-pulse", label: "Running" },
|
||||||
success: { dot: "bg-status-success", label: "Last run succeeded" },
|
success: { dot: "bg-status-success", label: "Last run succeeded" },
|
||||||
@@ -282,7 +282,7 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
|
|||||||
)}
|
)}
|
||||||
aria-label={
|
aria-label={
|
||||||
failedEarlier
|
failedEarlier
|
||||||
? `Failed at ${failedAt} — show the traceback`
|
? `Failed at ${failedAt}. Show the traceback`
|
||||||
: "Show what this node printed"
|
: "Show what this node printed"
|
||||||
}
|
}
|
||||||
data-testid="node-traceback"
|
data-testid="node-traceback"
|
||||||
@@ -299,7 +299,7 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{failedEarlier
|
{failedEarlier
|
||||||
? `Failed at ${failedAt} — show the traceback`
|
? `Failed at ${failedAt}. Show the traceback`
|
||||||
: "Show the traceback"}
|
: "Show the traceback"}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function LiveIndicator() {
|
|||||||
<Activity className="size-4 shrink-0 text-muted-foreground" />
|
<Activity className="size-4 shrink-0 text-muted-foreground" />
|
||||||
<span className="font-medium">Live updates disconnected</span>
|
<span className="font-medium">Live updates disconnected</span>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
values and activity are frozen — reconnecting…
|
values and activity are frozen, reconnecting…
|
||||||
</span>
|
</span>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ export function RunsScreen({
|
|||||||
names={selected.map(shortId)}
|
names={selected.map(shortId)}
|
||||||
noun="run"
|
noun="run"
|
||||||
pending={remove.isPending}
|
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)}
|
onConfirm={() => remove.mutate(selected)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -288,10 +288,10 @@ export function RunsScreen({
|
|||||||
{runs.length} run{runs.length === 1 ? "" : "s"}
|
{runs.length} run{runs.length === 1 ? "" : "s"}
|
||||||
{selected.length > 0 ? `, ${selected.length} picked` : ""}
|
{selected.length > 0 ? `, ${selected.length} picked` : ""}
|
||||||
{selected.length >= MAX_SELECTION
|
{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
|
{!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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ export async function cancelThenDelete(runId: string) {
|
|||||||
* left as an empty chart, which reads as a fault.
|
* left as an empty chart, which reads as a fault.
|
||||||
*/
|
*/
|
||||||
export const NO_CURVE =
|
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.
|
* Which restored nodes lost their curve, and with which deleted run.
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export function RemoteAccess() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Remote access</CardTitle>
|
<CardTitle>Remote access</CardTitle>
|
||||||
<CardDescription>
|
<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.
|
this instance talks to nothing outside your network.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -179,7 +179,7 @@ export function RemoteAccess() {
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : status.last_error ? (
|
) : status.last_error ? (
|
||||||
`Reconnecting — ${status.last_error}`
|
`Reconnecting: ${status.last_error}`
|
||||||
) : (
|
) : (
|
||||||
"Reconnecting…"
|
"Reconnecting…"
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ function ThisBrowser() {
|
|||||||
if (!webpush.supported()) {
|
if (!webpush.supported()) {
|
||||||
return (
|
return (
|
||||||
<p className="text-sm text-muted-foreground">
|
<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.
|
http on a local address, no browser will allow them.
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
@@ -215,7 +215,7 @@ function AlertsForm({ initial }: { initial: AlertsConfig }) {
|
|||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{draft.enabled
|
{draft.enabled
|
||||||
? "Failures reach the channels below."
|
? "Failures reach the channels below."
|
||||||
: "Switched off — failures only reach the log."}
|
: "Switched off. Failures only reach the log."}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -313,7 +313,7 @@ function AlertsForm({ initial }: { initial: AlertsConfig }) {
|
|||||||
disabled={dirty || test.isPending}
|
disabled={dirty || test.isPending}
|
||||||
title={
|
title={
|
||||||
dirty
|
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"
|
: "Send one alert through this channel"
|
||||||
}
|
}
|
||||||
data-testid="test-channel"
|
data-testid="test-channel"
|
||||||
|
|||||||
@@ -147,8 +147,8 @@ function Modules() {
|
|||||||
className="text-sm text-muted-foreground"
|
className="text-sm text-muted-foreground"
|
||||||
data-testid="modules-adopted"
|
data-testid="modules-adopted"
|
||||||
>
|
>
|
||||||
Adopted — this is the environment Fluksio itself is installed in.
|
Adopted. This is the environment Fluksio itself is installed in. Add
|
||||||
Add a package the way you added the rest:{" "}
|
a package the way you added the rest:{" "}
|
||||||
<span className="font-mono">pip install …</span>, then sync or
|
<span className="font-mono">pip install …</span>, then sync or
|
||||||
restart so the workers pick it up. For a venv the engine owns
|
restart so the workers pick it up. For a venv the engine owns
|
||||||
instead, set <span className="font-mono">NODE_VENV=managed</span>.
|
instead, set <span className="font-mono">NODE_VENV=managed</span>.
|
||||||
|
|||||||
@@ -117,12 +117,12 @@ function Machines() {
|
|||||||
{String(provisioner.name)} can start{" "}
|
{String(provisioner.name)} can start{" "}
|
||||||
{(provisioner.profiles as string[]).join(", ")}
|
{(provisioner.profiles as string[]).join(", ")}
|
||||||
{outstanding.length > 0
|
{outstanding.length > 0
|
||||||
? ` — asked for ${outstanding
|
? `. Asked for ${outstanding
|
||||||
.map((job) => `${job.profile} (job ${job.job})`)
|
.map((job) => `${job.profile} (job ${job.job})`)
|
||||||
.join(", ")}`
|
.join(", ")}`
|
||||||
: ""}
|
: ""}
|
||||||
{provisioner.last_error
|
{provisioner.last_error
|
||||||
? ` — last failed: ${String(provisioner.last_error)}`
|
? `. Last failed: ${String(provisioner.last_error)}`
|
||||||
: ""}
|
: ""}
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
@@ -269,7 +269,7 @@ function Flavors() {
|
|||||||
<span className="font-mono text-xs text-muted-foreground">
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
{flavor.cpus} cpu · {Math.round((flavor.ram ?? 0) / 1024)} GB
|
{flavor.cpus} cpu · {Math.round((flavor.ram ?? 0) / 1024)} GB
|
||||||
{flavor.gpus ? ` · ${flavor.gpus} gpu` : ""}
|
{flavor.gpus ? ` · ${flavor.gpus} gpu` : ""}
|
||||||
{flavor.description ? ` — ${flavor.description}` : ""}
|
{flavor.description ? ` · ${flavor.description}` : ""}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{mayEdit ? (
|
{mayEdit ? (
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ function extractErrorMessage(err: ApiError): string {
|
|||||||
const offline = offlineDetail(err)
|
const offline = offlineDetail(err)
|
||||||
if (offline) {
|
if (offline) {
|
||||||
return offline.lastSeen
|
return offline.lastSeen
|
||||||
? `Not delivered — the instance is offline (last seen ${ago(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"
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err instanceof AxiosError) {
|
if (err instanceof AxiosError) {
|
||||||
|
|||||||
Reference in New Issue
Block a user