@techninja/clearstack 0.3.49 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,6 +36,7 @@ A project/task tracker that exercises every pattern in the spec: API-backed enti
36
36
  | [SERVER_AND_DEPS.md](./docs/SERVER_AND_DEPS.md) | Express server, import maps, vendor dependency loading |
37
37
  | [BACKEND_API_SPEC.md](./docs/BACKEND_API_SPEC.md) | REST CRUD, JSON Schema via HEAD, entity management |
38
38
  | [TESTING.md](./docs/TESTING.md) | Testing philosophy, tools, patterns, phase checkpoints |
39
+ | [I18N.md](./docs/I18N.md) | Internationalization — 4-layer cascade, `t()` usage, conventions |
39
40
  | [BUILD_LOG.md](./docs/BUILD_LOG.md) | How this project was built — LLM-human collaboration proof |
40
41
  | [QUICKSTART.md](./docs/QUICKSTART.md) | Scaffolder setup, development workflow, updating, compliance |
41
42
 
package/docs/I18N.md ADDED
@@ -0,0 +1,197 @@
1
+ # Internationalization (i18n)
2
+
3
+ ## Why This Matters
4
+
5
+ Hardcoding UI strings is a foot gun. Retrofitting i18n into an existing app
6
+ requires touching every component — a painful, error-prone refactor. Starting
7
+ with `t('key')` or `msg\`` from day one costs almost nothing and keeps the door
8
+ open while also clearly separating UI copy from component logic.
9
+
10
+ ## Two Patterns — Use Both
11
+
12
+ Clearstack supports two complementary approaches. They are not mutually
13
+ exclusive; most apps will use both.
14
+
15
+ ### Pattern A — `t()` cascade (non-template strings)
16
+
17
+ A 4-layer message cascade resolved at call time. Best for strings outside
18
+ Hybrids templates: error messages, queue status, validators, utility functions.
19
+
20
+ ```
21
+ 1. App defaults → DEFAULTS in utils/i18n.js (English, ships with app)
22
+ 2. Locale file → /locales/<lang>.json (e.g. es.json)
23
+ 3. Overrides → /locales/overrides.json (project customizes English)
24
+ 4. Locale overrides → /locales/overrides.<lang>.json (project customizes locale)
25
+ ```
26
+
27
+ ```js
28
+ import { t, loadLocale } from '#utils/i18n.js';
29
+
30
+ // Call once at app init
31
+ await loadLocale(navigator.language);
32
+
33
+ t('general.loading') // → 'Loading…'
34
+ t('task.empty') // → 'No tasks yet.'
35
+ t('greeting', { name: 'James' }) // → 'Hello, James!'
36
+ t('missing.key') // → 'missing.key' (never throws)
37
+ ```
38
+
39
+ ### Pattern B — `localize` + `msg\`` (Hybrids-native, template strings)
40
+
41
+ Hybrids has first-class i18n built in. Template text content is **translated
42
+ automatically** — no source changes needed for simple strings. Use `msg\`` for
43
+ dynamic values, plural forms, and attribute content.
44
+
45
+ ```js
46
+ import { html, define, localize, msg } from 'hybrids';
47
+
48
+ // Simple static text — translated automatically, no code change needed
49
+ html`<button>Save</button>`
50
+
51
+ // Dynamic value in a template
52
+ html`<div>${msg`${count} variants scored`}</div>`
53
+
54
+ // As an attribute
55
+ html`<my-button label="${msg`Submit`}"></my-button>`
56
+
57
+ // Load translations once before first render (router/index.js or app init)
58
+ import { localize } from 'hybrids';
59
+ const msgs = await fetch('/locales/es.json').then(r => r.json());
60
+ localize('es', msgs);
61
+ ```
62
+
63
+ #### Plural Forms
64
+
65
+ The `msg\`` helper uses`Intl.PluralRules` automatically when the dictionary
66
+ entry is an object with plural keys:
67
+
68
+ ```js
69
+ // In a template:
70
+ html`<p>${msg`${count} variants matched`}</p>`
71
+
72
+ // In locales/es.json (or via localize()):
73
+ {
74
+ "${0} variants matched": {
75
+ "message": {
76
+ "one": "${0} variante encontrada",
77
+ "other": "${0} variantes encontradas",
78
+ "zero": "Ninguna variante encontrada"
79
+ }
80
+ }
81
+ }
82
+ ```
83
+
84
+ For English-only apps with irregular plurals, use `msg\`` with a`localize('default', ...)` entry:
85
+
86
+ ```js
87
+ // Avoids inline ternaries like: `${n} trait${n !== 1 ? 's' : ''}`
88
+ html`<p>${msg`${n} traits scored`}</p>`
89
+
90
+ localize('default', {
91
+ '${0} traits scored': {
92
+ message: { one: '${0} trait scored', other: '${0} traits scored' }
93
+ }
94
+ });
95
+ ```
96
+
97
+ ## Which Pattern to Use
98
+
99
+ | Situation | Pattern |
100
+ |---|---|
101
+ | String inside `html\`` template | B — auto-translated or `msg\`` |
102
+ | Plural form | B — `msg\`` + plural object |
103
+ | String outside a template (util, queue, validator) | A — `t()` |
104
+ | Attribute value on a component | B — `msg\`` in expression |
105
+ | Brand voice / English overrides | A — `overrides.json` |
106
+ | Adding a full language | B — `localize(lang, msgs)` |
107
+
108
+ ## App Init
109
+
110
+ Call `loadLocale()` (Pattern A) and/or `localize()` (Pattern B) once before
111
+ first render. The router's `connect` is the right place in a Hybrids app:
112
+
113
+ ```js
114
+ // router/index.js
115
+ import { loadLocale } from '#utils/i18n.js';
116
+ import { localize } from 'hybrids';
117
+
118
+ // Pattern A — cascade for non-template strings
119
+ loadLocale(navigator.language);
120
+
121
+ // Pattern B — load a locale file for template auto-translation
122
+ const lang = navigator.language.split('-')[0];
123
+ if (lang !== 'en') {
124
+ fetch(`/locales/${lang}.json`)
125
+ .then(r => r.ok ? r.json() : {})
126
+ .then(msgs => localize(lang, msgs));
127
+ }
128
+ ```
129
+
130
+ ## Key Naming Convention (Pattern A)
131
+
132
+ `domain.concept` — dot-separated, lowercase, no spaces. Keys should read as
133
+ documentation.
134
+
135
+ ```
136
+ general.loading general.error general.save general.cancel
137
+ nav.home nav.back nav.signIn
138
+ error.notFound error.offline
139
+ task.empty task.addTask task.deleteConfirm
140
+ ```
141
+
142
+ Avoid `button1`, `label`, or anything that doesn't describe the content.
143
+
144
+ ## Extracting Keys (Pattern B)
145
+
146
+ Hybrids ships a CLI extractor that scans source files and generates a
147
+ translation-ready JSON file:
148
+
149
+ ```bash
150
+ npx hybrids extract ./src ./locales/en.json
151
+ npx hybrids extract --include-path ./src ./locales/en.json
152
+ ```
153
+
154
+ Wire it as a script so translators always have a fresh key file:
155
+
156
+ ```json
157
+ { "scripts": { "i18n:extract": "hybrids extract ./src ./locales/en.json" } }
158
+ ```
159
+
160
+ The extractor is optional — use it when you're ready to hand off to a
161
+ translator or add a second language.
162
+
163
+ ## Adding a Language
164
+
165
+ 1. Run `npm run i18n:extract` to get the current key list
166
+ 2. Translate into `locales/<lang>.json` — only include keys that differ
167
+ 3. Load it at init via `localize(lang, msgs)`
168
+ 4. For Pattern A overrides: add `locales/overrides.<lang>.json`
169
+
170
+ ## Spec Check
171
+
172
+ `npm run spec code i18n` reports readiness without blocking the build:
173
+
174
+ ```
175
+ ✅ i18n readiness (12ms)
176
+ Pattern: t() ×9, msg` ×3
177
+ Init: yes · Overrides: yes · Languages: es, fr
178
+ Unwrapped: ~4 prose strings across 2 template files
179
+ ```
180
+
181
+ - **Pattern** — which i18n calls are present and how many files use them
182
+ - **Init** — whether `loadLocale` or `localize` is called at startup
183
+ - **Overrides** — whether `locales/overrides.json` exists
184
+ - **Languages** — locale files found beyond English
185
+ - **Unwrapped** — estimated prose strings in templates not yet wrapped in
186
+ `t()`/`msg\`` (heuristic, not exhaustive)
187
+
188
+ The check always passes — it surfaces gaps, not failures.
189
+
190
+ ## Rules
191
+
192
+ - **Always use `t()` or `msg\`** for any user-visible string in components
193
+ - **Never hardcode UI strings** directly in render functions
194
+ - **Plural forms belong in the dictionary**, not inline ternaries
195
+ - **`loadLocale()` is safe to call multiple times** — last call wins
196
+ - **Locale files are optional** — English-only apps just use `overrides.json`
197
+ - **Keys fall back to themselves** — `t('missing')` returns `'missing'`, never throws
package/lib/check.js CHANGED
@@ -7,9 +7,11 @@
7
7
  import { existsSync, readdirSync } from 'node:fs';
8
8
  import { resolve } from 'node:path';
9
9
  import { checkFileLines, runCmd, countFiles, checkImports } from './spec-utils.js';
10
+ import { checkI18n } from './spec-i18n.js';
10
11
  import { loadConfig, buildCmds, detectRunner } from './spec-config.js';
11
12
 
12
13
  export { checkFileLines, runCmd, countFiles, findFiles, elapsed, checkImports } from './spec-utils.js';
14
+ export { checkI18n } from './spec-i18n.js';
13
15
  export { loadConfig, buildCmds, detectRunner } from './spec-config.js';
14
16
 
15
17
  /** @typedef {{ key: string, name: string, parent?: string, run: () => boolean }} Check */
@@ -48,8 +50,10 @@ export function buildChecks(dir, cfg, cmds) {
48
50
  run: () => runCmd('Markdown', cmds.mdlint, dir, `${md()} files`).pass },
49
51
  { key: 'prettier', name: 'Prettier', parent: 'format',
50
52
  run: () => runCmd('Prettier', cmds.prettier, dir, `${js()} files`).pass },
51
- { key: 'code', name: `Code (max ${cfg.codeMax} lines)`,
53
+ { key: 'lines', name: `Code (max ${cfg.codeMax} lines)`, parent: 'code',
52
54
  run: () => checkFileLines(dir, cfg.codeExt, cfg.codeMax, cfg.ignore, `Code (max ${cfg.codeMax} lines)`, { exclude: cfg.testPattern }).pass },
55
+ { key: 'i18n', name: 'i18n readiness', parent: 'code',
56
+ run: () => checkI18n(dir, cfg.ignore, 'i18n readiness').pass },
53
57
  { key: 'tests', name: `Tests (max ${cfg.testMax} lines)`,
54
58
  run: () => checkFileLines(dir, cfg.codeExt, cfg.testMax, cfg.ignore, `Tests (max ${cfg.testMax} lines)`, { include: cfg.testPattern }).pass },
55
59
  { key: 'docs', name: `Docs (max ${cfg.docsMax} lines)`,
@@ -0,0 +1,90 @@
1
+ /**
2
+ * i18n readiness check — detects patterns, locale files, hardcoded prose density.
3
+ * Always passes (informational). Surfaces coverage gaps without blocking the build.
4
+ * @module lib/spec-i18n
5
+ */
6
+
7
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
8
+ import { resolve } from 'node:path';
9
+ import { findFiles, elapsed } from './spec-utils.js';
10
+
11
+ /** @typedef {import('./spec-utils.js').CheckResult} CheckResult */
12
+
13
+ const PROSE_RE = />\s*[A-Z][a-z]{2,}[^<$`\\]{3,}\s*[<$]/g;
14
+ const T_RE = /\bt\s*\(/;
15
+ const MSG_RE = /\bmsg`/;
16
+ const LOC_RE = /\blocalize\s*\(/;
17
+ const INIT_RE = /loadLocale|localize\s*\(/;
18
+ const TPL_RE = /html`[\s\S]*?`/g;
19
+
20
+ /**
21
+ * Check i18n readiness — informational, always passes.
22
+ * @param {string} root
23
+ * @param {string[]} ignoreDirs
24
+ * @param {string} label
25
+ * @param {{ quiet?: boolean }} [opts]
26
+ * @returns {CheckResult}
27
+ */
28
+ export function checkI18n(root, ignoreDirs, label, opts) {
29
+ const start = performance.now();
30
+ const srcFiles = findFiles(root, ['.js'], ignoreDirs, root).filter(
31
+ (f) =>
32
+ f.startsWith('src/') &&
33
+ !f.includes('vendor/') &&
34
+ !f.includes('deps/') &&
35
+ !f.endsWith('.test.js'),
36
+ );
37
+
38
+ let usesT = 0,
39
+ usesMsg = 0,
40
+ usesLocalize = 0,
41
+ hasInit = false,
42
+ hardcodedHits = 0,
43
+ templateFiles = 0;
44
+
45
+ for (const file of srcFiles) {
46
+ const src = readFileSync(resolve(root, file), 'utf-8');
47
+ if (T_RE.test(src)) usesT++;
48
+ if (MSG_RE.test(src)) usesMsg++;
49
+ if (LOC_RE.test(src)) usesLocalize++;
50
+ if (/router\/index|app.*init|main\.js/.test(file) && INIT_RE.test(src)) hasInit = true;
51
+ const templates = src.match(TPL_RE);
52
+ if (templates) {
53
+ templateFiles++;
54
+ for (const tpl of templates) {
55
+ const hits = tpl.match(PROSE_RE);
56
+ if (hits) hardcodedHits += hits.length;
57
+ }
58
+ }
59
+ }
60
+
61
+ const localesDir = existsSync(resolve(root, 'src/locales'))
62
+ ? resolve(root, 'src/locales')
63
+ : existsSync(resolve(root, 'locales'))
64
+ ? resolve(root, 'locales')
65
+ : null;
66
+
67
+ const localeFiles = localesDir
68
+ ? readdirSync(localesDir).filter((f) => f.endsWith('.json') && f !== 'overrides.json')
69
+ : [];
70
+ const languages = localeFiles.map((f) => f.replace(/\.json$/, '').replace(/^overrides\./, ''));
71
+ const hasOverrides = localesDir ? existsSync(resolve(localesDir, 'overrides.json')) : false;
72
+
73
+ const patternParts = [
74
+ usesT ? `t() ×${usesT}` : '',
75
+ usesMsg ? `msg\` ×${usesMsg}` : '',
76
+ usesLocalize ? `localize() ×${usesLocalize}` : '',
77
+ ].filter(Boolean);
78
+
79
+ const time = elapsed(start);
80
+ if (!opts?.quiet) {
81
+ const icon = patternParts.length ? '✅' : '⚠️ ';
82
+ const pattern = patternParts.length ? patternParts.join(', ') : 'none detected';
83
+ const langs = languages.length ? languages.join(', ') : 'en only';
84
+ const init = `${hasInit ? 'yes' : 'not detected'} · Overrides: ${hasOverrides ? 'yes' : 'no'} · Languages: ${langs}`;
85
+ const unwrapped = hardcodedHits > 0 ? `\n Unwrapped: ~${hardcodedHits} prose strings across ${templateFiles} template files` : '';
86
+ console.log(` ${icon} ${label} (${time})\n Pattern: ${pattern}\n Init: ${init}${unwrapped}`);
87
+ }
88
+
89
+ return { pass: true, label, time };
90
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@techninja/clearstack",
3
- "version": "0.3.49",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "A no-build web component framework specification — scaffold, validate, and evolve spec-compliant projects",
6
6
  "bin": {
@@ -0,0 +1,197 @@
1
+ # Internationalization (i18n)
2
+
3
+ ## Why This Matters
4
+
5
+ Hardcoding UI strings is a foot gun. Retrofitting i18n into an existing app
6
+ requires touching every component — a painful, error-prone refactor. Starting
7
+ with `t('key')` or `msg\`` from day one costs almost nothing and keeps the door
8
+ open while also clearly separating UI copy from component logic.
9
+
10
+ ## Two Patterns — Use Both
11
+
12
+ Clearstack supports two complementary approaches. They are not mutually
13
+ exclusive; most apps will use both.
14
+
15
+ ### Pattern A — `t()` cascade (non-template strings)
16
+
17
+ A 4-layer message cascade resolved at call time. Best for strings outside
18
+ Hybrids templates: error messages, queue status, validators, utility functions.
19
+
20
+ ```
21
+ 1. App defaults → DEFAULTS in utils/i18n.js (English, ships with app)
22
+ 2. Locale file → /locales/<lang>.json (e.g. es.json)
23
+ 3. Overrides → /locales/overrides.json (project customizes English)
24
+ 4. Locale overrides → /locales/overrides.<lang>.json (project customizes locale)
25
+ ```
26
+
27
+ ```js
28
+ import { t, loadLocale } from '#utils/i18n.js';
29
+
30
+ // Call once at app init
31
+ await loadLocale(navigator.language);
32
+
33
+ t('general.loading') // → 'Loading…'
34
+ t('task.empty') // → 'No tasks yet.'
35
+ t('greeting', { name: 'James' }) // → 'Hello, James!'
36
+ t('missing.key') // → 'missing.key' (never throws)
37
+ ```
38
+
39
+ ### Pattern B — `localize` + `msg\`` (Hybrids-native, template strings)
40
+
41
+ Hybrids has first-class i18n built in. Template text content is **translated
42
+ automatically** — no source changes needed for simple strings. Use `msg\`` for
43
+ dynamic values, plural forms, and attribute content.
44
+
45
+ ```js
46
+ import { html, define, localize, msg } from 'hybrids';
47
+
48
+ // Simple static text — translated automatically, no code change needed
49
+ html`<button>Save</button>`
50
+
51
+ // Dynamic value in a template
52
+ html`<div>${msg`${count} variants scored`}</div>`
53
+
54
+ // As an attribute
55
+ html`<my-button label="${msg`Submit`}"></my-button>`
56
+
57
+ // Load translations once before first render (router/index.js or app init)
58
+ import { localize } from 'hybrids';
59
+ const msgs = await fetch('/locales/es.json').then(r => r.json());
60
+ localize('es', msgs);
61
+ ```
62
+
63
+ #### Plural Forms
64
+
65
+ The `msg\`` helper uses`Intl.PluralRules` automatically when the dictionary
66
+ entry is an object with plural keys:
67
+
68
+ ```js
69
+ // In a template:
70
+ html`<p>${msg`${count} variants matched`}</p>`
71
+
72
+ // In locales/es.json (or via localize()):
73
+ {
74
+ "${0} variants matched": {
75
+ "message": {
76
+ "one": "${0} variante encontrada",
77
+ "other": "${0} variantes encontradas",
78
+ "zero": "Ninguna variante encontrada"
79
+ }
80
+ }
81
+ }
82
+ ```
83
+
84
+ For English-only apps with irregular plurals, use `msg\`` with a`localize('default', ...)` entry:
85
+
86
+ ```js
87
+ // Avoids inline ternaries like: `${n} trait${n !== 1 ? 's' : ''}`
88
+ html`<p>${msg`${n} traits scored`}</p>`
89
+
90
+ localize('default', {
91
+ '${0} traits scored': {
92
+ message: { one: '${0} trait scored', other: '${0} traits scored' }
93
+ }
94
+ });
95
+ ```
96
+
97
+ ## Which Pattern to Use
98
+
99
+ | Situation | Pattern |
100
+ |---|---|
101
+ | String inside `html\`` template | B — auto-translated or `msg\`` |
102
+ | Plural form | B — `msg\`` + plural object |
103
+ | String outside a template (util, queue, validator) | A — `t()` |
104
+ | Attribute value on a component | B — `msg\`` in expression |
105
+ | Brand voice / English overrides | A — `overrides.json` |
106
+ | Adding a full language | B — `localize(lang, msgs)` |
107
+
108
+ ## App Init
109
+
110
+ Call `loadLocale()` (Pattern A) and/or `localize()` (Pattern B) once before
111
+ first render. The router's `connect` is the right place in a Hybrids app:
112
+
113
+ ```js
114
+ // router/index.js
115
+ import { loadLocale } from '#utils/i18n.js';
116
+ import { localize } from 'hybrids';
117
+
118
+ // Pattern A — cascade for non-template strings
119
+ loadLocale(navigator.language);
120
+
121
+ // Pattern B — load a locale file for template auto-translation
122
+ const lang = navigator.language.split('-')[0];
123
+ if (lang !== 'en') {
124
+ fetch(`/locales/${lang}.json`)
125
+ .then(r => r.ok ? r.json() : {})
126
+ .then(msgs => localize(lang, msgs));
127
+ }
128
+ ```
129
+
130
+ ## Key Naming Convention (Pattern A)
131
+
132
+ `domain.concept` — dot-separated, lowercase, no spaces. Keys should read as
133
+ documentation.
134
+
135
+ ```
136
+ general.loading general.error general.save general.cancel
137
+ nav.home nav.back nav.signIn
138
+ error.notFound error.offline
139
+ task.empty task.addTask task.deleteConfirm
140
+ ```
141
+
142
+ Avoid `button1`, `label`, or anything that doesn't describe the content.
143
+
144
+ ## Extracting Keys (Pattern B)
145
+
146
+ Hybrids ships a CLI extractor that scans source files and generates a
147
+ translation-ready JSON file:
148
+
149
+ ```bash
150
+ npx hybrids extract ./src ./locales/en.json
151
+ npx hybrids extract --include-path ./src ./locales/en.json
152
+ ```
153
+
154
+ Wire it as a script so translators always have a fresh key file:
155
+
156
+ ```json
157
+ { "scripts": { "i18n:extract": "hybrids extract ./src ./locales/en.json" } }
158
+ ```
159
+
160
+ The extractor is optional — use it when you're ready to hand off to a
161
+ translator or add a second language.
162
+
163
+ ## Adding a Language
164
+
165
+ 1. Run `npm run i18n:extract` to get the current key list
166
+ 2. Translate into `locales/<lang>.json` — only include keys that differ
167
+ 3. Load it at init via `localize(lang, msgs)`
168
+ 4. For Pattern A overrides: add `locales/overrides.<lang>.json`
169
+
170
+ ## Spec Check
171
+
172
+ `npm run spec code i18n` reports readiness without blocking the build:
173
+
174
+ ```
175
+ ✅ i18n readiness (12ms)
176
+ Pattern: t() ×9, msg` ×3
177
+ Init: yes · Overrides: yes · Languages: es, fr
178
+ Unwrapped: ~4 prose strings across 2 template files
179
+ ```
180
+
181
+ - **Pattern** — which i18n calls are present and how many files use them
182
+ - **Init** — whether `loadLocale` or `localize` is called at startup
183
+ - **Overrides** — whether `locales/overrides.json` exists
184
+ - **Languages** — locale files found beyond English
185
+ - **Unwrapped** — estimated prose strings in templates not yet wrapped in
186
+ `t()`/`msg\`` (heuristic, not exhaustive)
187
+
188
+ The check always passes — it surfaces gaps, not failures.
189
+
190
+ ## Rules
191
+
192
+ - **Always use `t()` or `msg\`** for any user-visible string in components
193
+ - **Never hardcode UI strings** directly in render functions
194
+ - **Plural forms belong in the dictionary**, not inline ternaries
195
+ - **`loadLocale()` is safe to call multiple times** — last call wins
196
+ - **Locale files are optional** — English-only apps just use `overrides.json`
197
+ - **Keys fall back to themselves** — `t('missing')` returns `'missing'`, never throws
@@ -0,0 +1,3 @@
1
+ {
2
+ "general.loading": "Loading…"
3
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * i18n — 4-layer message cascade.
3
+ *
4
+ * Resolution order (last wins):
5
+ * 1. App defaults (English, defined in this file)
6
+ * 2. Locale file (/locales/<lang>.json)
7
+ * 3. Project overrides (/locales/overrides.json)
8
+ * 4. Project locale overrides (/locales/overrides.<lang>.json)
9
+ *
10
+ * Usage:
11
+ * import { t, loadLocale } from '#utils/i18n.js';
12
+ * await loadLocale(navigator.language); // call once at app init
13
+ * t('general.loading') // → 'Loading…'
14
+ * t('greeting', { name: 'James' }) // → 'Hello, James!'
15
+ *
16
+ * Key convention: component.action or domain.concept
17
+ * Add app-specific defaults below and override per-project in overrides.json.
18
+ * @module utils/i18n
19
+ */
20
+
21
+ /** @type {Record<string, string>} */
22
+ const DEFAULTS = {
23
+ 'general.loading': 'Loading…',
24
+ 'general.error': 'Something went wrong.',
25
+ 'general.retry': 'Try again',
26
+ 'general.back': 'Back',
27
+ 'general.close': 'Close',
28
+ 'general.save': 'Save',
29
+ 'general.cancel': 'Cancel',
30
+ 'general.confirm': 'Confirm',
31
+ 'general.noResults': 'No results found.',
32
+ 'nav.home': 'Home',
33
+ 'error.notFound': 'Page not found.',
34
+ 'error.offline': 'You appear to be offline.',
35
+ };
36
+
37
+ /** @type {Record<string, string>} */
38
+ let active = { ...DEFAULTS };
39
+
40
+ /** @param {string} url @returns {Promise<Record<string, string>>} */
41
+ async function fetchJson(url) {
42
+ try {
43
+ const res = await fetch(url);
44
+ return res.ok ? await res.json() : {};
45
+ } catch {
46
+ return {};
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Load and merge all i18n layers. Call once on app init.
52
+ * @param {string} [locale] - BCP 47 locale string e.g. 'es', 'es-MX'
53
+ */
54
+ export async function loadLocale(locale) {
55
+ const lang = locale?.split('-')[0] || '';
56
+ const isEnglish = !lang || lang === 'en';
57
+
58
+ const [localeStrings, overrides, localeOverrides] = await Promise.all([
59
+ isEnglish ? {} : fetchJson(`/locales/${lang}.json`),
60
+ fetchJson('/locales/overrides.json'),
61
+ isEnglish ? {} : fetchJson(`/locales/overrides.${lang}.json`),
62
+ ]);
63
+
64
+ active = { ...DEFAULTS, ...localeStrings, ...overrides, ...localeOverrides };
65
+ }
66
+
67
+ /**
68
+ * Get a translated string with optional {param} interpolation.
69
+ * Falls back to the key itself if not found.
70
+ * @param {string} key
71
+ * @param {Record<string, string>} [params]
72
+ * @returns {string}
73
+ */
74
+ export function t(key, params) {
75
+ let msg = active[key] ?? key;
76
+ if (params) {
77
+ for (const [k, v] of Object.entries(params)) {
78
+ msg = msg.replace(`{${k}}`, v);
79
+ }
80
+ }
81
+ return msg;
82
+ }
83
+
84
+ /** @returns {string} Active locale language code e.g. 'en', 'es' */
85
+ export function getLocale() {
86
+ return navigator.language?.split('-')[0] || 'en';
87
+ }