@techninja/clearstack 0.4.0 → 0.4.3

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/bin/cli.js CHANGED
@@ -62,7 +62,7 @@ async function run(action) {
62
62
  } else if (action === 'check') {
63
63
  const subs = args.filter((a) => a !== cmd && !a.startsWith('-'));
64
64
  const { check } = await import('../lib/check.js');
65
- await check(process.cwd(), subs.join(' ') || undefined);
65
+ await check(process.cwd(), subs.join(' ') || undefined, { verbose: !!flags.verbose });
66
66
  } else if (action === 'report') {
67
67
  const { report } = await import('../lib/report.js');
68
68
  report(process.cwd(), { json: !!flags.json });
package/docs/I18N.md CHANGED
@@ -4,12 +4,18 @@
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')` from day one costs almost nothing and keeps the door open while
8
- also clearly separating UI and exact wording concerns.
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
9
 
10
- ## The Pattern
10
+ ## Two Patterns — Use Both
11
11
 
12
- A 4-layer message cascade. Last layer wins:
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.
13
19
 
14
20
  ```
15
21
  1. App defaults → DEFAULTS in utils/i18n.js (English, ships with app)
@@ -18,91 +24,174 @@ A 4-layer message cascade. Last layer wins:
18
24
  4. Locale overrides → /locales/overrides.<lang>.json (project customizes locale)
19
25
  ```
20
26
 
21
- This means:
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
+ ```
22
38
 
23
- - The platform ships sensible English defaults
24
- - Projects override just what they need in `overrides.json`
25
- - Adding a new language only requires a `<lang>.json` file
26
- - No build step, no external dependency
39
+ ### Pattern B `localize` + `msg\`` (Hybrids-native, template strings)
27
40
 
28
- ## Usage
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.
29
44
 
30
45
  ```js
31
- import { t, loadLocale } from '#utils/i18n.js';
46
+ import { html, define, localize, msg } from 'hybrids';
32
47
 
33
- // Call once at app init (router or top-level component connect)
34
- await loadLocale(navigator.language);
48
+ // Simple static text translated automatically, no code change needed
49
+ html`<button>Save</button>`
35
50
 
36
- // Simple key
37
- t('general.loading') // 'Loading…'
51
+ // Dynamic value in a template
52
+ html`<div>${msg`${count} variants scored`}</div>`
38
53
 
39
- // With interpolation
40
- t('order.thanks', { name: 'James' }) // → 'Thanks, James!'
54
+ // As an attribute
55
+ html`<my-button label="${msg`Submit`}"></my-button>`
41
56
 
42
- // Falls back to key if not found never throws
43
- t('missing.key') // 'missing.key'
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);
44
61
  ```
45
62
 
46
- ## Key Naming Convention
63
+ #### Plural Forms
64
+
65
+ The `msg\`` helper uses`Intl.PluralRules` automatically when the dictionary
66
+ entry is an object with plural keys:
47
67
 
48
- `domain.action` or `component.concept` — dot-separated, lowercase, no spaces.
68
+ ```js
69
+ // In a template:
70
+ html`<p>${msg`${count} variants matched`}</p>`
49
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
+ }
50
82
  ```
51
- general.loading general.error general.back
52
- nav.home nav.signIn nav.signOut
53
- cart.add cart.empty cart.checkout
54
- product.inStock product.outOfStock
55
- error.notFound error.offline
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
+ });
56
95
  ```
57
96
 
58
- Avoid generic keys like `button1` or `label`. Keys should be readable as
59
- documentation.
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)` |
60
107
 
61
108
  ## App Init
62
109
 
63
- Call `loadLocale()` once before rendering. In a Hybrids app, the router's
64
- `connect` is the right place:
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:
65
112
 
66
113
  ```js
67
114
  // router/index.js
68
115
  import { loadLocale } from '#utils/i18n.js';
116
+ import { localize } from 'hybrids';
69
117
 
70
- await loadLocale(navigator.language);
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
+ }
71
128
  ```
72
129
 
73
- For SSR/static sites, call it in the page's `connect` handler instead.
130
+ ## Key Naming Convention (Pattern A)
74
131
 
75
- ## Adding a Language
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
+ ```
76
141
 
77
- 1. Create `/locales/<lang>.json` with translated keys
78
- 2. Only include keys that differ from English — missing keys fall back to defaults
79
- 3. Optionally add `/locales/overrides.<lang>.json` for project-specific overrides
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:
80
155
 
81
156
  ```json
82
- // locales/es.json
83
- {
84
- "general.loading": "Cargando…",
85
- "general.error": "Algo salió mal.",
86
- "nav.home": "Inicio"
87
- }
157
+ { "scripts": { "i18n:extract": "hybrids extract ./src ./locales/en.json" } }
88
158
  ```
89
159
 
90
- ## Project Overrides
160
+ The extractor is optional — use it when you're ready to hand off to a
161
+ translator or add a second language.
91
162
 
92
- To customize English strings (e.g. brand voice) without touching `i18n.js`:
163
+ ## Adding a Language
93
164
 
94
- ```json
95
- // locales/overrides.json
96
- {
97
- "general.loading": "Analyzing your DNA…",
98
- "error.offline": "Check your connection and try again."
99
- }
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
100
179
  ```
101
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
+
102
190
  ## Rules
103
191
 
104
- - **Always use `t()`** for any user-visible string in components
105
- - **Never hardcode UI strings** in component render functions
106
- - **Keys fall back to themselves** `t('missing')` returns `'missing'`, never crashes
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
107
195
  - **`loadLocale()` is safe to call multiple times** — last call wins
108
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,12 +7,14 @@
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
- /** @typedef {{ key: string, name: string, parent?: string, run: () => boolean }} Check */
17
+ /** @typedef {{ key: string, name: string, parent?: string, run: (opts?: object) => boolean }} Check */
16
18
 
17
19
  /** Find all jsconfig.json files — main config + subdirectories. */
18
20
  function findTypeConfigs(dir, runner) {
@@ -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: (runOpts) => checkI18n(dir, cfg.ignore, 'i18n readiness', runOpts).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)`,
@@ -81,7 +85,7 @@ export function parentKeys(checks) {
81
85
  }
82
86
 
83
87
  /** Run the full spec compliance check (used by clearstack CLI and scripts/spec.js). */
84
- export async function check(projectDir, scope) {
88
+ export async function check(projectDir, scope, opts) {
85
89
  const cfg = loadConfig(projectDir);
86
90
  const cmds = buildCmds(projectDir);
87
91
  const checks = buildChecks(projectDir, cfg, cmds);
@@ -94,13 +98,13 @@ export async function check(projectDir, scope) {
94
98
  console.log(`Available: ${[...tops, ...parentKeys(checks)].join(', ')}`);
95
99
  process.exit(1);
96
100
  }
97
- const ok = matched.every((c) => c.run());
101
+ const ok = matched.every((c) => c.run(opts));
98
102
  if (!ok) process.exit(1);
99
103
  return;
100
104
  }
101
105
 
102
106
  console.log('🔍 Clearstack compliance checking now... 💙\n');
103
- const results = checks.map((c) => c.run());
107
+ const results = checks.map((c) => c.run(opts));
104
108
  const passed = results.filter(Boolean).length;
105
109
  console.log(`\n${'='.repeat(40)}`);
106
110
  console.log(`${passed}/${results.length} checks passed.`);
@@ -0,0 +1,119 @@
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
+ /** Strip leading > and trailing <$ from a prose match, trim. */
21
+ const cleanHit = (h) => h.replace(/^>\s*/, '').replace(/\s*[<$]$/, '').trim();
22
+
23
+ /**
24
+ * Check i18n readiness — informational, always passes.
25
+ * @param {string} root
26
+ * @param {string[]} ignoreDirs
27
+ * @param {string} label
28
+ * @param {{ quiet?: boolean, verbose?: boolean }} [opts]
29
+ * @returns {CheckResult}
30
+ */
31
+ export function checkI18n(root, ignoreDirs, label, opts) {
32
+ const start = performance.now();
33
+ const srcFiles = findFiles(root, ['.js'], ignoreDirs, root).filter(
34
+ (f) =>
35
+ f.startsWith('src/') &&
36
+ !f.includes('vendor/') &&
37
+ !f.includes('deps/') &&
38
+ !f.endsWith('.test.js'),
39
+ );
40
+
41
+ let usesT = 0,
42
+ usesMsg = 0,
43
+ usesLocalize = 0,
44
+ hasInit = false,
45
+ hardcodedHits = 0,
46
+ templateFiles = 0;
47
+ /** @type {Array<{file: string, text: string, line: number}>} */
48
+ const verboseHits = [];
49
+
50
+ for (const file of srcFiles) {
51
+ const src = readFileSync(resolve(root, file), 'utf-8');
52
+ if (T_RE.test(src)) usesT++;
53
+ if (MSG_RE.test(src)) usesMsg++;
54
+ if (LOC_RE.test(src)) usesLocalize++;
55
+ if (/router\/index|app.*init|main\.js/.test(file) && INIT_RE.test(src)) hasInit = true;
56
+ const templates = src.match(TPL_RE);
57
+ if (templates) {
58
+ templateFiles++;
59
+ for (const tpl of templates) {
60
+ const hits = tpl.match(PROSE_RE);
61
+ if (hits) {
62
+ hardcodedHits += hits.length;
63
+ if (opts?.verbose) {
64
+ for (const h of hits) {
65
+ const text = cleanHit(h);
66
+ const idx = src.indexOf(text);
67
+ const line = idx >= 0 ? src.slice(0, idx).split('\n').length : 0;
68
+ verboseHits.push({ file, text, line });
69
+ }
70
+ }
71
+ }
72
+ }
73
+ }
74
+ }
75
+
76
+ const localesDir = existsSync(resolve(root, 'src/locales'))
77
+ ? resolve(root, 'src/locales')
78
+ : existsSync(resolve(root, 'locales'))
79
+ ? resolve(root, 'locales')
80
+ : null;
81
+
82
+ const localeFiles = localesDir
83
+ ? readdirSync(localesDir).filter((f) => f.endsWith('.json') && f !== 'overrides.json')
84
+ : [];
85
+ const languages = localeFiles.map((f) => f.replace(/\.json$/, '').replace(/^overrides\./, ''));
86
+ const hasOverrides = localesDir ? existsSync(resolve(localesDir, 'overrides.json')) : false;
87
+
88
+ const patternParts = [
89
+ usesT ? `t() \u00d7${usesT}` : '',
90
+ usesMsg ? `msg\` \u00d7${usesMsg}` : '',
91
+ usesLocalize ? `localize() \u00d7${usesLocalize}` : '',
92
+ ].filter(Boolean);
93
+
94
+ const time = elapsed(start);
95
+ if (!opts?.quiet) {
96
+ const icon = patternParts.length ? '\u2705' : '\u26a0\ufe0f ';
97
+ const pattern = patternParts.length ? patternParts.join(', ') : 'none detected';
98
+ const langs = languages.length ? languages.join(', ') : 'en only';
99
+ const init = `${hasInit ? 'yes' : 'not detected'} \u00b7 Overrides: ${hasOverrides ? 'yes' : 'no'} \u00b7 Languages: ${langs}`;
100
+ const unwrapped = hardcodedHits > 0 ? `\n Unwrapped: ~${hardcodedHits} prose strings across ${templateFiles} template files` : '';
101
+ console.log(` ${icon} ${label} (${time})\n Pattern: ${pattern}\n Init: ${init}${unwrapped}`);
102
+ if (opts?.verbose && verboseHits.length) {
103
+ console.log('');
104
+ const byFile = {};
105
+ for (const v of verboseHits) {
106
+ (byFile[v.file] ||= []).push(v);
107
+ }
108
+ for (const [file, entries] of Object.entries(byFile)) {
109
+ console.log(` ${file}`);
110
+ for (const e of entries) {
111
+ const truncated = e.text.length > 60 ? e.text.slice(0, 60) + '\u2026' : e.text;
112
+ console.log(` L${e.line || '?'} ${truncated}`);
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ return { pass: true, label, time };
119
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@techninja/clearstack",
3
- "version": "0.4.0",
3
+ "version": "0.4.3",
4
4
  "type": "module",
5
5
  "description": "A no-build web component framework specification — scaffold, validate, and evolve spec-compliant projects",
6
6
  "bin": {
@@ -4,12 +4,18 @@
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')` from day one costs almost nothing and keeps the door open while
8
- also clearly separating UI and exact wording concerns.
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
9
 
10
- ## The Pattern
10
+ ## Two Patterns — Use Both
11
11
 
12
- A 4-layer message cascade. Last layer wins:
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.
13
19
 
14
20
  ```
15
21
  1. App defaults → DEFAULTS in utils/i18n.js (English, ships with app)
@@ -18,91 +24,174 @@ A 4-layer message cascade. Last layer wins:
18
24
  4. Locale overrides → /locales/overrides.<lang>.json (project customizes locale)
19
25
  ```
20
26
 
21
- This means:
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
+ ```
22
38
 
23
- - The platform ships sensible English defaults
24
- - Projects override just what they need in `overrides.json`
25
- - Adding a new language only requires a `<lang>.json` file
26
- - No build step, no external dependency
39
+ ### Pattern B `localize` + `msg\`` (Hybrids-native, template strings)
27
40
 
28
- ## Usage
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.
29
44
 
30
45
  ```js
31
- import { t, loadLocale } from '#utils/i18n.js';
46
+ import { html, define, localize, msg } from 'hybrids';
32
47
 
33
- // Call once at app init (router or top-level component connect)
34
- await loadLocale(navigator.language);
48
+ // Simple static text translated automatically, no code change needed
49
+ html`<button>Save</button>`
35
50
 
36
- // Simple key
37
- t('general.loading') // 'Loading…'
51
+ // Dynamic value in a template
52
+ html`<div>${msg`${count} variants scored`}</div>`
38
53
 
39
- // With interpolation
40
- t('order.thanks', { name: 'James' }) // → 'Thanks, James!'
54
+ // As an attribute
55
+ html`<my-button label="${msg`Submit`}"></my-button>`
41
56
 
42
- // Falls back to key if not found never throws
43
- t('missing.key') // 'missing.key'
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);
44
61
  ```
45
62
 
46
- ## Key Naming Convention
63
+ #### Plural Forms
64
+
65
+ The `msg\`` helper uses`Intl.PluralRules` automatically when the dictionary
66
+ entry is an object with plural keys:
47
67
 
48
- `domain.action` or `component.concept` — dot-separated, lowercase, no spaces.
68
+ ```js
69
+ // In a template:
70
+ html`<p>${msg`${count} variants matched`}</p>`
49
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
+ }
50
82
  ```
51
- general.loading general.error general.back
52
- nav.home nav.signIn nav.signOut
53
- cart.add cart.empty cart.checkout
54
- product.inStock product.outOfStock
55
- error.notFound error.offline
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
+ });
56
95
  ```
57
96
 
58
- Avoid generic keys like `button1` or `label`. Keys should be readable as
59
- documentation.
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)` |
60
107
 
61
108
  ## App Init
62
109
 
63
- Call `loadLocale()` once before rendering. In a Hybrids app, the router's
64
- `connect` is the right place:
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:
65
112
 
66
113
  ```js
67
114
  // router/index.js
68
115
  import { loadLocale } from '#utils/i18n.js';
116
+ import { localize } from 'hybrids';
69
117
 
70
- await loadLocale(navigator.language);
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
+ }
71
128
  ```
72
129
 
73
- For SSR/static sites, call it in the page's `connect` handler instead.
130
+ ## Key Naming Convention (Pattern A)
74
131
 
75
- ## Adding a Language
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
+ ```
76
141
 
77
- 1. Create `/locales/<lang>.json` with translated keys
78
- 2. Only include keys that differ from English — missing keys fall back to defaults
79
- 3. Optionally add `/locales/overrides.<lang>.json` for project-specific overrides
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:
80
155
 
81
156
  ```json
82
- // locales/es.json
83
- {
84
- "general.loading": "Cargando…",
85
- "general.error": "Algo salió mal.",
86
- "nav.home": "Inicio"
87
- }
157
+ { "scripts": { "i18n:extract": "hybrids extract ./src ./locales/en.json" } }
88
158
  ```
89
159
 
90
- ## Project Overrides
160
+ The extractor is optional — use it when you're ready to hand off to a
161
+ translator or add a second language.
91
162
 
92
- To customize English strings (e.g. brand voice) without touching `i18n.js`:
163
+ ## Adding a Language
93
164
 
94
- ```json
95
- // locales/overrides.json
96
- {
97
- "general.loading": "Analyzing your DNA…",
98
- "error.offline": "Check your connection and try again."
99
- }
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
100
179
  ```
101
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
+
102
190
  ## Rules
103
191
 
104
- - **Always use `t()`** for any user-visible string in components
105
- - **Never hardcode UI strings** in component render functions
106
- - **Keys fall back to themselves** `t('missing')` returns `'missing'`, never crashes
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
107
195
  - **`loadLocale()` is safe to call multiple times** — last call wins
108
196
  - **Locale files are optional** — English-only apps just use `overrides.json`
197
+ - **Keys fall back to themselves** — `t('missing')` returns `'missing'`, never throws