@techninja/clearstack 0.4.1 → 0.4.4

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.
@@ -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 `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.
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
- ## Two PatternsUse Both
10
+ ## The PatternHybrids-Native (`localize` + `msg\``)
11
11
 
12
- Clearstack supports two complementary approaches. They are not mutually
13
- exclusive; most apps will use both.
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
- ### Pattern A`t()` cascade (non-template strings)
19
+ // Simple static text translated automatically, no code change needed
20
+ html`<button>Save</button>`
16
21
 
17
- A 4-layer message cascade resolved at call time. Best for strings outside
18
- Hybrids templates: error messages, queue status, validators, utility functions.
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
- 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)
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
- import { t, loadLocale } from '#utils/i18n.js';
73
+ // router/index.js
74
+ import '#utils/i18n-init.js'; // sync — registers English plurals
29
75
 
30
- // Call once at app init
31
- await loadLocale(navigator.language);
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
- 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)
82
+ import AppView from '#pages/app/view.js';
83
+ // ... define router component
37
84
  ```
38
85
 
39
- ### Pattern B `localize` + `msg\`` (Hybrids-native, template strings)
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
- 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.
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 { html, define, localize, msg } from 'hybrids';
107
+ import { localize } from 'hybrids';
47
108
 
48
- // Simple static text — translated automatically, no code change needed
49
- html`<button>Save</button>`
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
- // Dynamic value in a template
52
- html`<div>${msg`${count} variants scored`}</div>`
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
- // As an attribute
55
- html`<my-button label="${msg`Submit`}"></my-button>`
126
+ Static template text does NOT need identity entries — Hybrids auto-extracts
127
+ those keys during template compilation.
56
128
 
57
- // Load translations once before first render (router/index.js or app init)
129
+ ### `locales/es.js` Full Locale Dictionary
130
+
131
+ ```js
58
132
  import { localize } from 'hybrids';
59
- const msgs = await fetch('/locales/es.json').then(r => r.json());
60
- localize('es', msgs);
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
- #### Plural Forms
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
- The `msg\`` helper uses`Intl.PluralRules` automatically when the dictionary
66
- entry is an object with plural keys:
155
+ ---
156
+
157
+ ## Plural Forms
158
+
159
+ ### Single Noun
67
160
 
68
161
  ```js
69
- // In a template:
70
- html`<p>${msg`${count} variants matched`}</p>`
162
+ html`<p>${msg`${count} items in cart`}</p>`
163
+ ```
71
164
 
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
- }
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
- For English-only apps with irregular plurals, use `msg\`` with a`localize('default', ...)` entry:
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
- // Avoids inline ternaries like: `${n} trait${n !== 1 ? 's' : ''}`
88
- html`<p>${msg`${n} traits scored`}</p>`
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} traits scored': {
92
- message: { one: '${0} trait scored', other: '${0} traits scored' }
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
- ## Which Pattern to Use
203
+ ### Zero Special Case
98
204
 
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)` |
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
- ## App Init
215
+ ---
109
216
 
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:
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
- // router/index.js
115
- import { loadLocale } from '#utils/i18n.js';
116
- import { localize } from 'hybrids';
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
- // Pattern A cascade for non-template strings
119
- loadLocale(navigator.language);
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
- // 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));
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
- ## Key Naming Convention (Pattern A)
295
+ ### Category/Tag Lookups
131
296
 
132
- `domain.concept` dot-separated, lowercase, no spaces. Keys should read as
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
- 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
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
- Avoid `button1`, `label`, or anything that doesn't describe the content.
317
+ ---
143
318
 
144
- ## Extracting Keys (Pattern B)
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 so translators always have a fresh key file:
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
- The extractor is optional — use it when you're ready to hand off to a
161
- translator or add a second language.
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 `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`
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
- ## Spec Check
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
- `npm run spec code i18n` reports readiness without blocking the build:
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
- ✅ 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
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
- - **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)
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
- The check always passes — it surfaces gaps, not failures.
447
+ ---
189
448
 
190
449
  ## Rules
191
450
 
192
- - **Always use `t()` or `msg\`** for any user-visible string in components
193
- - **Never hardcode UI strings** directly in render functions
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
- - **`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
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"