@techninja/clearstack 0.4.3 → 0.4.5
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 +14 -3
- package/bin/cli.js +8 -3
- package/docs/I18N.md +372 -111
- package/lib/check.js +54 -21
- package/lib/server-proc.js +150 -0
- package/lib/spec-config.js +48 -4
- package/lib/spec-i18n-locales.js +86 -0
- package/lib/spec-i18n.js +60 -62
- package/lib/spec-scan.js +101 -0
- package/lib/spec-utils.js +12 -107
- package/lib/watch-lifecycle.js +47 -0
- package/lib/watch-ui.js +148 -0
- package/lib/watch-widgets.js +82 -0
- package/lib/watch.js +131 -0
- package/package.json +7 -2
- package/templates/shared/docs/clearstack/I18N.md +372 -111
|
@@ -4,144 +4,319 @@
|
|
|
4
4
|
|
|
5
5
|
Hardcoding UI strings is a foot gun. Retrofitting i18n into an existing app
|
|
6
6
|
requires touching every component — a painful, error-prone refactor. Starting
|
|
7
|
-
with `
|
|
8
|
-
|
|
7
|
+
with `msg\`` from day one costs almost nothing and keeps the door open while
|
|
8
|
+
also clearly separating UI copy from component logic.
|
|
9
9
|
|
|
10
|
-
##
|
|
10
|
+
## The Pattern — Hybrids-Native (`localize` + `msg\``)
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
Hybrids has first-class i18n built in. Template text content is **translated
|
|
13
|
+
automatically** — no source changes needed for simple strings. Use `msg\`` for
|
|
14
|
+
dynamic values, plural forms, and attribute content.
|
|
15
|
+
|
|
16
|
+
```js
|
|
17
|
+
import { html, define, localize, msg } from 'hybrids';
|
|
14
18
|
|
|
15
|
-
|
|
19
|
+
// Simple static text — translated automatically, no code change needed
|
|
20
|
+
html`<button>Save</button>`
|
|
16
21
|
|
|
17
|
-
|
|
18
|
-
|
|
22
|
+
// Dynamic value in a template
|
|
23
|
+
html`<div>${msg`${count} variants scored`}</div>`
|
|
19
24
|
|
|
25
|
+
// As an attribute (title, placeholder, aria-label)
|
|
26
|
+
html`<button title="${msg`Add item`}">+</button>`
|
|
27
|
+
|
|
28
|
+
// Outside a template (utils, handlers, queues) — works anywhere
|
|
29
|
+
const status = msg`Loading…`;
|
|
20
30
|
```
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Six Translation Scenarios
|
|
35
|
+
|
|
36
|
+
Understanding which strings need what treatment is the key to avoiding bugs.
|
|
37
|
+
|
|
38
|
+
| Scenario | Auto-translated? | What to do |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| Static text in `html\`` template | ✅ Yes | Nothing — Hybrids handles it |
|
|
41
|
+
| Attributes (title, placeholder, aria-label) | ❌ No | Wrap value with `msg\`` |
|
|
42
|
+
| Dynamic expressions in helper functions | ❌ No | Wrap with `msg\`` |
|
|
43
|
+
| Module-level constants | ❌ No | Use lazy getters (see below) |
|
|
44
|
+
| Plural forms | ❌ No | Use `msg\`` + dictionary entry |
|
|
45
|
+
| Strings outside templates (utils, handlers) | ❌ No | Use `msg\`` (works anywhere) |
|
|
46
|
+
|
|
47
|
+
### The Module-Level Constant Trap
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
// BAD — msg evaluates at import time, before locale loads
|
|
51
|
+
const TABS = [
|
|
52
|
+
{ id: 'home', label: msg`Home` },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
// GOOD — lazy getter defers evaluation to render time
|
|
56
|
+
const TABS = [
|
|
57
|
+
{ id: 'home', get label() { return msg`Home`; } },
|
|
58
|
+
];
|
|
25
59
|
```
|
|
26
60
|
|
|
61
|
+
This matters because ES module static imports resolve before top-level `await`
|
|
62
|
+
executes. Any `msg` call in a module-level constant evaluates before the locale
|
|
63
|
+
file finishes loading.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## App Init — Timing Is Everything
|
|
68
|
+
|
|
69
|
+
`localize()` **must** be called before templates compile (first render).
|
|
70
|
+
Use top-level `await` in the router module:
|
|
71
|
+
|
|
27
72
|
```js
|
|
28
|
-
|
|
73
|
+
// router/index.js
|
|
74
|
+
import '#utils/i18n-init.js'; // sync — registers English plurals
|
|
29
75
|
|
|
30
|
-
//
|
|
31
|
-
|
|
76
|
+
// Load locale translations — MUST await before any component renders
|
|
77
|
+
const _lang = navigator.language?.slice(0, 2);
|
|
78
|
+
if (_lang && _lang !== 'en') {
|
|
79
|
+
await import(`#locales/${_lang}.js`).catch(() => {});
|
|
80
|
+
}
|
|
32
81
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
t('greeting', { name: 'James' }) // → 'Hello, James!'
|
|
36
|
-
t('missing.key') // → 'missing.key' (never throws)
|
|
82
|
+
import AppView from '#pages/app/view.js';
|
|
83
|
+
// ... define router component
|
|
37
84
|
```
|
|
38
85
|
|
|
39
|
-
|
|
86
|
+
**Why `await`?** Without it, the dynamic import is fire-and-forget. Components
|
|
87
|
+
render before translations register, and static template text compiles with
|
|
88
|
+
English keys permanently (Hybrids compiles templates once on first render).
|
|
40
89
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## File Layout
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
src/
|
|
96
|
+
├── utils/
|
|
97
|
+
│ └── i18n-init.js ← English plural forms + identity entries
|
|
98
|
+
├── locales/
|
|
99
|
+
│ └── es.js ← Full Spanish dictionary
|
|
100
|
+
└── router/
|
|
101
|
+
└── index.js ← await import locale before render
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### `i18n-init.js` — English Plurals + Identity Entries
|
|
44
105
|
|
|
45
106
|
```js
|
|
46
|
-
import {
|
|
107
|
+
import { localize } from 'hybrids';
|
|
47
108
|
|
|
48
|
-
|
|
49
|
-
|
|
109
|
+
localize('default', {
|
|
110
|
+
// Plural forms (English needs these for correct singular/plural)
|
|
111
|
+
'${0} items scored': {
|
|
112
|
+
message: { one: '${0} item scored', other: '${0} items scored' },
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
// Identity entries — required for every msg``-wrapped string
|
|
116
|
+
'Add item': { message: 'Add item' },
|
|
117
|
+
'Loading…': { message: 'Loading…' },
|
|
118
|
+
});
|
|
119
|
+
```
|
|
50
120
|
|
|
51
|
-
|
|
52
|
-
|
|
121
|
+
**Why identity entries?** Once any `localize()` call populates the dictionary,
|
|
122
|
+
Hybrids logs "Missing translation" warnings for any `msg` key not found. Since
|
|
123
|
+
`i18n-init.js` always loads (for English plurals), the dictionary is never
|
|
124
|
+
empty. Every `msg`-wrapped string must be registered.
|
|
53
125
|
|
|
54
|
-
|
|
55
|
-
|
|
126
|
+
Static template text does NOT need identity entries — Hybrids auto-extracts
|
|
127
|
+
those keys during template compilation.
|
|
56
128
|
|
|
57
|
-
|
|
129
|
+
### `locales/es.js` — Full Locale Dictionary
|
|
130
|
+
|
|
131
|
+
```js
|
|
58
132
|
import { localize } from 'hybrids';
|
|
59
|
-
|
|
60
|
-
localize('
|
|
133
|
+
|
|
134
|
+
localize('default', {
|
|
135
|
+
// Static template text (auto-extracted by `hybrids extract`)
|
|
136
|
+
'Save': { message: 'Guardar' },
|
|
137
|
+
'Cancel': { message: 'Cancelar' },
|
|
138
|
+
|
|
139
|
+
// msg``-wrapped strings
|
|
140
|
+
'Add item': { message: 'Agregar elemento' },
|
|
141
|
+
'Loading…': { message: 'Cargando…' },
|
|
142
|
+
|
|
143
|
+
// Plural forms
|
|
144
|
+
'${0} items scored': {
|
|
145
|
+
message: { one: '${0} elemento puntuado', other: '${0} elementos puntuados' },
|
|
146
|
+
},
|
|
147
|
+
});
|
|
61
148
|
```
|
|
62
149
|
|
|
63
|
-
|
|
150
|
+
**Why `'default'` not `'es'`?** The `'default'` key is appended to the language
|
|
151
|
+
search list as a universal fallback. This means a single file works for `es`,
|
|
152
|
+
`es-MX`, `es-AR` without registering each variant. Only load it for non-English
|
|
153
|
+
users (via the conditional `await import`).
|
|
64
154
|
|
|
65
|
-
|
|
66
|
-
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Plural Forms
|
|
158
|
+
|
|
159
|
+
### Single Noun
|
|
67
160
|
|
|
68
161
|
```js
|
|
69
|
-
|
|
70
|
-
|
|
162
|
+
html`<p>${msg`${count} items in cart`}</p>`
|
|
163
|
+
```
|
|
71
164
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
}
|
|
165
|
+
Hybrids uses the **first** `${N}` value for `Intl.PluralRules` selection:
|
|
166
|
+
|
|
167
|
+
```js
|
|
168
|
+
localize('default', {
|
|
169
|
+
'${0} items in cart': {
|
|
170
|
+
message: { one: '${0} item in cart', other: '${0} items in cart' },
|
|
171
|
+
},
|
|
172
|
+
});
|
|
82
173
|
```
|
|
83
174
|
|
|
84
|
-
|
|
175
|
+
### Multiple Nouns in One String
|
|
176
|
+
|
|
177
|
+
Each noun needs its own `msg` call for independent plural selection:
|
|
85
178
|
|
|
86
179
|
```js
|
|
87
|
-
//
|
|
88
|
-
|
|
180
|
+
// BAD — only first value drives plural selection
|
|
181
|
+
msg`${files} files and ${folders} folders`
|
|
182
|
+
|
|
183
|
+
// GOOD — nested msg calls
|
|
184
|
+
msg`${msg`${files} file${files}`} and ${msg`${folders} folder${folders}`}`
|
|
185
|
+
```
|
|
89
186
|
|
|
187
|
+
The inner `msg` calls produce keys like `${0} file${1}` — the second
|
|
188
|
+
placeholder is the same count value, used as a "hint" that this is a
|
|
189
|
+
pluralizable noun:
|
|
190
|
+
|
|
191
|
+
```js
|
|
90
192
|
localize('default', {
|
|
91
|
-
'${0}
|
|
92
|
-
message: { one: '${0}
|
|
93
|
-
}
|
|
193
|
+
'${0} file${1}': {
|
|
194
|
+
message: { one: '${0} file', other: '${0} files' },
|
|
195
|
+
},
|
|
196
|
+
'${0} folder${1}': {
|
|
197
|
+
message: { one: '${0} folder', other: '${0} folders' },
|
|
198
|
+
},
|
|
199
|
+
'${0} and ${1}': { message: '${0} and ${1}' },
|
|
94
200
|
});
|
|
95
201
|
```
|
|
96
202
|
|
|
97
|
-
|
|
203
|
+
### Zero Special Case
|
|
98
204
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
205
|
+
```js
|
|
206
|
+
'${0} items': {
|
|
207
|
+
message: {
|
|
208
|
+
zero: 'No items', // count === 0
|
|
209
|
+
one: '${0} item', // count === 1
|
|
210
|
+
other: '${0} items', // everything else
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
```
|
|
107
214
|
|
|
108
|
-
|
|
215
|
+
---
|
|
109
216
|
|
|
110
|
-
|
|
111
|
-
|
|
217
|
+
## Helper Functions — The Invisible Boundary
|
|
218
|
+
|
|
219
|
+
Strings in standalone helper functions (outside `html\`` templates) are
|
|
220
|
+
**invisible to Hybrids' auto-translation**. This is the most common source
|
|
221
|
+
of missed translations.
|
|
112
222
|
|
|
113
223
|
```js
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
224
|
+
// BAD — stat() is a helper, label is a dynamic expression
|
|
225
|
+
function stat(label, value) {
|
|
226
|
+
return html`<span class="stat-label">${label}</span>`;
|
|
227
|
+
}
|
|
228
|
+
stat('Coverage', '95%'); // "Coverage" never gets translated
|
|
229
|
+
|
|
230
|
+
// GOOD — wrap at the call site
|
|
231
|
+
stat(msg`Coverage`, '95%');
|
|
232
|
+
```
|
|
117
233
|
|
|
118
|
-
|
|
119
|
-
|
|
234
|
+
**Rule**: If a string passes through a function parameter before reaching a
|
|
235
|
+
template, it needs `msg` at the call site.
|
|
120
236
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## Attributes
|
|
240
|
+
|
|
241
|
+
Hybrids auto-translates text content inside elements but **never** attributes:
|
|
242
|
+
|
|
243
|
+
```js
|
|
244
|
+
// "Save" auto-translates, but title does NOT
|
|
245
|
+
html`<button title="Save changes">Save</button>`
|
|
246
|
+
|
|
247
|
+
// Fix: wrap attribute value with msg
|
|
248
|
+
html`<button title="${msg`Save changes`}">Save</button>`
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Common attributes that need `msg`:
|
|
252
|
+
|
|
253
|
+
- `title` (tooltips)
|
|
254
|
+
- `placeholder` (inputs)
|
|
255
|
+
- `aria-label` / `aria-description`
|
|
256
|
+
- Custom element string attributes
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## Content Data Translations (Large Datasets)
|
|
261
|
+
|
|
262
|
+
For apps with large translatable content (product catalogs, editorial content,
|
|
263
|
+
CMS data), use a separate data-layer approach:
|
|
264
|
+
|
|
265
|
+
```js
|
|
266
|
+
// utils/i18n-data.js — fetches translation packs from CDN
|
|
267
|
+
export async function loadContentPack(lang) { ... }
|
|
268
|
+
export function translateItem(item) { ... }
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Key patterns:
|
|
272
|
+
|
|
273
|
+
- **Lazy loading** — only fetch when needed (e.g., gene explorer opens)
|
|
274
|
+
- **In-place mutation** — translate object fields directly
|
|
275
|
+
- **Preserve originals** — store English in `_field_en` for bilingual search
|
|
276
|
+
- **Identity for English** — `loadContentPack()` returns null immediately,
|
|
277
|
+
all translate functions no-op
|
|
278
|
+
|
|
279
|
+
### Bilingual Search
|
|
280
|
+
|
|
281
|
+
When translations mutate searchable fields in-place, search must match both:
|
|
282
|
+
|
|
283
|
+
```js
|
|
284
|
+
function translateItem(item) {
|
|
285
|
+
if (!_pack) return;
|
|
286
|
+
item._name_en = item.name; // preserve original
|
|
287
|
+
item.name = _pack[item.id]?.name; // overwrite with translation
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function matchesSearch(item, query) {
|
|
291
|
+
return item.name.includes(query) || item._name_en?.includes(query);
|
|
127
292
|
}
|
|
128
293
|
```
|
|
129
294
|
|
|
130
|
-
|
|
295
|
+
### Category/Tag Lookups
|
|
131
296
|
|
|
132
|
-
|
|
133
|
-
documentation.
|
|
297
|
+
Finite enumerable sets (categories, tags, statuses) translate via flat maps:
|
|
134
298
|
|
|
299
|
+
```json
|
|
300
|
+
{
|
|
301
|
+
"categories": { "Electronics": "Electrónica", "Books": "Libros" },
|
|
302
|
+
"tags": { "bestseller": "más vendido", "new": "nuevo" }
|
|
303
|
+
}
|
|
135
304
|
```
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
305
|
+
|
|
306
|
+
Use the English key internally (filtering, routing, Set membership) and the
|
|
307
|
+
translated value for display only:
|
|
308
|
+
|
|
309
|
+
```js
|
|
310
|
+
// Internal — always English key
|
|
311
|
+
const filtered = items.filter(i => i.category === activeCategory);
|
|
312
|
+
|
|
313
|
+
// Display — translated
|
|
314
|
+
html`<span>${translateCategory(item.category)}</span>`
|
|
140
315
|
```
|
|
141
316
|
|
|
142
|
-
|
|
317
|
+
---
|
|
143
318
|
|
|
144
|
-
## Extracting Keys
|
|
319
|
+
## Extracting Keys
|
|
145
320
|
|
|
146
321
|
Hybrids ships a CLI extractor that scans source files and generates a
|
|
147
322
|
translation-ready JSON file:
|
|
@@ -151,47 +326,133 @@ npx hybrids extract ./src ./locales/en.json
|
|
|
151
326
|
npx hybrids extract --include-path ./src ./locales/en.json
|
|
152
327
|
```
|
|
153
328
|
|
|
154
|
-
Wire it as a script
|
|
329
|
+
Wire it as a script:
|
|
155
330
|
|
|
156
331
|
```json
|
|
157
332
|
{ "scripts": { "i18n:extract": "hybrids extract ./src ./locales/en.json" } }
|
|
158
333
|
```
|
|
159
334
|
|
|
160
|
-
|
|
161
|
-
|
|
335
|
+
**What the extractor catches:**
|
|
336
|
+
|
|
337
|
+
- Static text content in `html\`` templates
|
|
338
|
+
- `msg\`` calls (both in templates and standalone)
|
|
339
|
+
|
|
340
|
+
**What it misses:**
|
|
341
|
+
|
|
342
|
+
- Strings passed as arguments to helper functions
|
|
343
|
+
- Template literals without `msg` tag
|
|
344
|
+
- Content from external data sources
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
## Validation Script
|
|
349
|
+
|
|
350
|
+
Add `i18n:check` to catch drift between source and locale files:
|
|
351
|
+
|
|
352
|
+
```bash
|
|
353
|
+
pnpm run i18n:check
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
What it reports:
|
|
357
|
+
|
|
358
|
+
```
|
|
359
|
+
i18n check (es)
|
|
360
|
+
✓ 247/247 template keys translated
|
|
361
|
+
✗ 3 msg`` keys missing: 'Rescore', 'Data Source', 'Imputed'
|
|
362
|
+
⚠ 2 stale keys (removed from source, still in locale)
|
|
363
|
+
✓ 12/12 plural forms complete
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
Implementation:
|
|
367
|
+
|
|
368
|
+
1. Run `hybrids extract` to get all template keys
|
|
369
|
+
2. Scan source for `msg\`` calls via regex to get dynamic keys
|
|
370
|
+
3. Compare against each locale file
|
|
371
|
+
4. Report missing, stale, and incomplete plural entries
|
|
372
|
+
|
|
373
|
+
Wire into CI to catch regressions:
|
|
374
|
+
|
|
375
|
+
```yaml
|
|
376
|
+
- run: pnpm run i18n:check --fail-on-missing
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
---
|
|
162
380
|
|
|
163
381
|
## Adding a Language
|
|
164
382
|
|
|
165
|
-
1. Run `
|
|
166
|
-
2.
|
|
167
|
-
3.
|
|
168
|
-
4.
|
|
383
|
+
1. Run `pnpm run i18n:extract` to get the current key list
|
|
384
|
+
2. Create `locales/<lang>.js` with `localize('default', { ... })`
|
|
385
|
+
3. Add the conditional import in `router/index.js`
|
|
386
|
+
4. Run `pnpm run i18n:check` to verify coverage
|
|
387
|
+
|
|
388
|
+
---
|
|
389
|
+
|
|
390
|
+
## Common Pitfalls
|
|
169
391
|
|
|
170
|
-
|
|
392
|
+
| Pitfall | Symptom | Fix |
|
|
393
|
+
|---|---|---|
|
|
394
|
+
| Locale loads after first render | Static text stuck in English forever | `await` the locale import |
|
|
395
|
+
| `msg` in module-level constant | Returns English despite locale loaded | Use lazy getter |
|
|
396
|
+
| String in helper function param | Never translated | Wrap with `msg` at call site |
|
|
397
|
+
| Attribute value | Never translated | Use `msg` in expression |
|
|
398
|
+
| Missing identity entry | Console warning, works in English, breaks locale lookup | Add to `i18n-init.js` |
|
|
399
|
+
| Translated category used for filtering | Filters break (Set mismatch) | Use English key internally, translate for display only |
|
|
400
|
+
| Plain template literal for plural | "1 items" | Use `msg` + plural dictionary entry |
|
|
401
|
+
| Multiple plurals in one string | Only first noun pluralizes correctly | Nested `msg` calls |
|
|
171
402
|
|
|
172
|
-
|
|
403
|
+
---
|
|
173
404
|
|
|
405
|
+
## Pattern A — `t()` Cascade (Optional)
|
|
406
|
+
|
|
407
|
+
> **Note**: In practice, `msg\`` covers all use cases including strings outside
|
|
408
|
+
> templates. Pattern A is available for projects that prefer structured keys
|
|
409
|
+
> (`domain.concept`) over natural-language keys, or need a 4-layer override
|
|
410
|
+
> cascade for white-labeling.
|
|
411
|
+
|
|
412
|
+
A 4-layer message cascade resolved at call time:
|
|
413
|
+
|
|
414
|
+
```
|
|
415
|
+
1. App defaults → DEFAULTS in utils/i18n.js (English, ships with app)
|
|
416
|
+
2. Locale file → /locales/<lang>.json
|
|
417
|
+
3. Overrides → /locales/overrides.json (project customizes English)
|
|
418
|
+
4. Locale overrides → /locales/overrides.<lang>.json
|
|
174
419
|
```
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
420
|
+
|
|
421
|
+
```js
|
|
422
|
+
import { t, loadLocale } from '#utils/i18n.js';
|
|
423
|
+
await loadLocale(navigator.language);
|
|
424
|
+
|
|
425
|
+
t('general.loading') // → 'Loading…'
|
|
426
|
+
t('greeting', { name: 'James' }) // → 'Hello, James!'
|
|
427
|
+
t('missing.key') // → 'missing.key' (never throws)
|
|
179
428
|
```
|
|
180
429
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
-
|
|
184
|
-
-
|
|
185
|
-
-
|
|
186
|
-
|
|
430
|
+
### When to Use Pattern A Over `msg\``
|
|
431
|
+
|
|
432
|
+
- White-label apps where English copy itself varies per deployment
|
|
433
|
+
- Structured key namespacing (`error.notFound` vs natural text)
|
|
434
|
+
- Server-side rendering where Hybrids isn't available
|
|
435
|
+
- Shared validation/error messages between client and server
|
|
436
|
+
|
|
437
|
+
### Key Naming Convention
|
|
438
|
+
|
|
439
|
+
`domain.concept` — dot-separated, lowercase, no spaces:
|
|
440
|
+
|
|
441
|
+
```
|
|
442
|
+
general.loading general.error general.save general.cancel
|
|
443
|
+
nav.home nav.back nav.signIn
|
|
444
|
+
error.notFound error.offline
|
|
445
|
+
```
|
|
187
446
|
|
|
188
|
-
|
|
447
|
+
---
|
|
189
448
|
|
|
190
449
|
## Rules
|
|
191
450
|
|
|
192
|
-
- **
|
|
193
|
-
- **Never hardcode UI strings**
|
|
451
|
+
- **Use `msg\`` for any user-visible string** that isn't static template text
|
|
452
|
+
- **Never hardcode UI strings** in helper function parameters
|
|
194
453
|
- **Plural forms belong in the dictionary**, not inline ternaries
|
|
195
|
-
- **`
|
|
196
|
-
- **Locale files are optional** — English-only apps just use `
|
|
197
|
-
- **Keys fall back to themselves** —
|
|
454
|
+
- **`await` the locale import** before any component renders
|
|
455
|
+
- **Locale files are optional** — English-only apps just use `i18n-init.js`
|
|
456
|
+
- **Keys fall back to themselves** — missing translations show English, never crash
|
|
457
|
+
- **Use English keys internally** for filtering/routing, translate for display
|
|
458
|
+
- **Test with a non-English locale** early — don't wait until "i18n sprint"
|