Skip to content

Commit ca9b670

Browse files
committed
feat: internationalise the app with english and british
Signed-off-by: Simon Emms <simon@simonemms.com>
1 parent e8d2f23 commit ca9b670

17 files changed

Lines changed: 511 additions & 54 deletions

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ repos:
1010
args:
1111
- --autofix
1212
- --no-sort-keys
13+
- --no-ensure-ascii
1314
- id: check-json
1415
- id: check-yaml
1516
exclude: charts

CLAUDE.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ tooling over novelty or abstraction.
3838
- **Runtime:** Node.js (Docker)
3939
- **Linting:** ESLint (flat config)
4040
- **Formatting:** Prettier (project-defined config)
41+
- **i18n:** i18next (required)
4142

4243
Do not:
4344

@@ -49,6 +50,70 @@ Do not:
4950

5051
---
5152

53+
## Internationalisation (i18n) requirements (mandatory)
54+
55+
This project **must** use an established i18n library, not a custom implementation.
56+
57+
### Library
58+
59+
- Use **i18next** with a Svelte-compatible integration (do not build our own
60+
i18n layer).
61+
- Keep translations in JSON resource files.
62+
- Prefer **literal Unicode characters** in i18n JSON (e.g. ``, `£`, ``)
63+
over HTML entities (e.g. `&hellip;`) or escaped codepoints (e.g. `\u2026`)
64+
unless a tool forces escaping.
65+
66+
### Supported locales (for now)
67+
68+
- `en` (English, International) — default
69+
- `en-GB` (English, British)
70+
71+
### Locale selection priority (authoritative)
72+
73+
1. User-selected locale in the UI (persisted)
74+
2. Browser language headers (e.g. `navigator.languages`)
75+
3. Fallback to `en`
76+
77+
### Fallback behaviour
78+
79+
- `en-GB` **must** fall back to `en` for missing keys so British English only
80+
needs to override deltas.
81+
- Do not duplicate `en` strings into `en-GB` unless the wording/spelling
82+
genuinely differs.
83+
84+
### Hard rule: no new plain text in the UI
85+
86+
- **Do not introduce new user-visible strings inline** in Svelte components,
87+
stores, routes, or helpers.
88+
- Any new user-visible text **must** be added as an i18n string key and rendered
89+
via `t(...)`.
90+
- This includes:
91+
- Button labels, headings, breadcrumbs, empty states
92+
- Toasts, alerts, errors shown to users
93+
- Inspector labels and field names
94+
- Modal titles and helper text
95+
96+
Allowed exceptions (narrow):
97+
98+
- User data (workflow names, task names, branch labels, etc.)
99+
- Debug-only `console.*` output (must not be user-facing)
100+
101+
### Key naming + structure guidance
102+
103+
- Use a predictable, boring hierarchy (e.g. `editor.*`, `sidebar.*`, `inspector.*`,
104+
`errors.*`, `actions.*`).
105+
- Prefer stable keys over copy-based keys (do not bake full sentences into key
106+
names).
107+
- Keep strings short and composable where it helps reuse, but do not over-abstract.
108+
109+
### Testing expectation
110+
111+
- Any feature work that introduces UI text must include the corresponding `en` key.
112+
- If a string is British-specific, add it to `en-GB`; otherwise only `en` is
113+
required.
114+
115+
---
116+
52117
## Runtime & deployment assumptions
53118

54119
- The app runs as a **Node server**, not a static site
@@ -210,6 +275,8 @@ Validation errors must be:
210275
- Inspector panels edit **node config**, not graph topology
211276
- UI must reflect validation state clearly
212277
- Avoid auto-magic graph rewrites
278+
- All user-visible UI strings must come from i18n resources via `t(...)` (no
279+
inline copy).
213280

214281
---
215282

@@ -454,3 +521,5 @@ When asked to “build” something:
454521

455522
- Start with the smallest viable, end-to-end slice
456523
- Prefer scaffolding that can grow over finished-looking systems
524+
- Add or extend tests to lock in behaviour
525+
- All new user-visible UI text must use i18n (i18next), not inline strings

package-lock.json

Lines changed: 43 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"eslint-config-prettier": "^10.1.8",
3636
"eslint-plugin-svelte": "^3.15.1",
3737
"globals": "^17.4.0",
38+
"i18next": "^25.8.18",
3839
"js-yaml": "^4.1.1",
3940
"prettier": "^3.8.1",
4041
"prettier-plugin-svelte": "^3.5.1",

src/lib/i18n/index.svelte.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
* Copyright 2025 - 2026 Zigflow authors <https://github.com/zigflow/studio/graphs/contributors>
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
// Zigflow Studio — i18n module
17+
//
18+
// Uses a .svelte.ts file so that module-level $state is available.
19+
// The reactive version counter `_v` is incremented on every locale change;
20+
// any Svelte template expression that calls t() will re-evaluate because
21+
// t() reads _v, creating a reactive dependency.
22+
import i18next from 'i18next';
23+
24+
import enGbJson from './messages/en-GB.json';
25+
import enJson from './messages/en.json';
26+
27+
export type { Locale } from './locales';
28+
export { parseAcceptLanguage, resolveLocale } from './locales';
29+
30+
// Module-level reactive version counter. Incrementing this causes every
31+
// call to t() that appears in a Svelte reactive context to re-evaluate.
32+
let _v = $state(0);
33+
34+
/**
35+
* Initialise (or change language of) the i18next singleton.
36+
* Safe to call multiple times — idempotent for the same locale.
37+
*/
38+
export function initI18n(locale: string): void {
39+
if (i18next.isInitialized) {
40+
i18next.changeLanguage(locale);
41+
} else {
42+
// init() is synchronous when resources are provided directly.
43+
i18next.init({
44+
lng: locale,
45+
resources: {
46+
en: { translation: enJson },
47+
'en-GB': { translation: enGbJson },
48+
},
49+
fallbackLng: {
50+
'en-GB': ['en'],
51+
default: ['en'],
52+
},
53+
interpolation: { escapeValue: false },
54+
});
55+
}
56+
// Bump the reactive counter so every t() call re-evaluates.
57+
_v += 1;
58+
}
59+
60+
/**
61+
* Translate a dot-separated key.
62+
*
63+
* Reads the reactive `_v` counter unconditionally so that Svelte re-evaluates
64+
* any template expression containing t() whenever the locale changes —
65+
* including the first call to initI18n() after the component mounts.
66+
*
67+
* `ver < 0` is structurally impossible (counter only increments from 0) but
68+
* the comparison keeps ESLint quiet while preserving the reactive read.
69+
*/
70+
export function t(key: string, options?: Record<string, unknown>): string {
71+
// Read _v unconditionally so Svelte tracks it as a dependency even before
72+
// i18next is initialised. Short-circuiting `|| _v < 0` would skip the read
73+
// when isInitialized is false, losing the dependency.
74+
const ver = _v;
75+
if (!i18next.isInitialized || ver < 0) return key;
76+
return i18next.t(key, options);
77+
}

src/lib/i18n/locales.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Copyright 2025 - 2026 Zigflow authors <https://github.com/zigflow/studio/graphs/contributors>
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
export type Locale = 'en' | 'en-GB';
18+
19+
/**
20+
* Map any string to a supported Locale.
21+
* Exact match for 'en-GB'; everything else that starts with 'en' maps to 'en'.
22+
* Unknown locales fall back to 'en'.
23+
*/
24+
export function resolveLocale(input: string | null | undefined): Locale {
25+
if (input === 'en-GB') return 'en-GB';
26+
return 'en';
27+
}
28+
29+
/**
30+
* Parse an Accept-Language header and return the best-matching supported
31+
* Locale. Respects q-values; first explicit en-GB match wins.
32+
*/
33+
export function parseAcceptLanguage(header: string | null): Locale {
34+
if (!header) return 'en';
35+
36+
// Each part looks like "en-GB;q=0.9" or just "en".
37+
const parts = header.split(',').map((s) => {
38+
const [langRaw, qRaw] = s.trim().split(';q=');
39+
return {
40+
lang: (langRaw ?? '').trim(),
41+
q: qRaw !== undefined ? parseFloat(qRaw) : 1.0,
42+
};
43+
});
44+
45+
// Sort by descending quality.
46+
parts.sort((a, b) => b.q - a.q);
47+
48+
for (const { lang } of parts) {
49+
if (lang === 'en-GB' || lang === 'en_GB') return 'en-GB';
50+
if (lang.toLowerCase().startsWith('en')) return 'en';
51+
}
52+
53+
return 'en';
54+
}

src/lib/i18n/messages/en-GB.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{}

src/lib/i18n/messages/en.json

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
{
2+
"app": {
3+
"name": "Zigflow Studio"
4+
},
5+
"workflows": {
6+
"title": "Workflows",
7+
"empty": "No workflow files found.",
8+
"newPlaceholder": "workflow-name",
9+
"newButton": "New workflow"
10+
},
11+
"editor": {
12+
"saving": "Saving…",
13+
"saved": "Saved",
14+
"unsaved": "Unsaved changes",
15+
"saveFailed": "Save failed",
16+
"save": "Save",
17+
"language": "Language",
18+
"localeEn": "English (International)",
19+
"localeEnGb": "English (British)",
20+
"exportYaml": "Export YAML",
21+
"noGraph": "No graph to display.",
22+
"exportDialog": "YAML export"
23+
},
24+
"export": {
25+
"title": "Exported YAML",
26+
"close": "Close"
27+
},
28+
"inspector": {
29+
"empty": "Select a node to inspect it.",
30+
"id": "ID",
31+
"kind": "Kind",
32+
"detail": "Detail",
33+
"condition": "Condition",
34+
"structure": "Structure",
35+
"branches": "Branches",
36+
"removeBranch": "Remove branch {{label}}",
37+
"addBranch": "+ Add branch",
38+
"sections": "Sections",
39+
"enterTryBody": "Enter try body",
40+
"enterCatchBlock": "Enter catch block",
41+
"addCatchBlock": "+ Add catch block",
42+
"enterLoopBody": "Enter loop body",
43+
"comingSoon": "Full editing UI coming soon.",
44+
"moveUp": "↑ Move up",
45+
"moveUpLabel": "Move task up",
46+
"moveDown": "↓ Move down",
47+
"moveDownLabel": "Move task down",
48+
"delete": "Delete task"
49+
},
50+
"sidebar": {
51+
"document": "Document",
52+
"workflows": "Workflows",
53+
"addWorkflow": "+ Add workflow",
54+
"tasks": "Tasks",
55+
"controlFlow": "Control flow"
56+
},
57+
"canvas": {
58+
"ariaLabel": "Workflow canvas",
59+
"tryBody": "try body",
60+
"catchBlock": "catch block",
61+
"loopBody": "body"
62+
}
63+
}

0 commit comments

Comments
 (0)