@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 +1 -0
- package/docs/I18N.md +197 -0
- package/lib/check.js +5 -1
- package/lib/spec-i18n.js +90 -0
- package/package.json +1 -1
- package/templates/shared/docs/clearstack/I18N.md +197 -0
- package/templates/shared/src/locales/overrides.json +3 -0
- package/templates/shared/src/utils/i18n.js +87 -0
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: '
|
|
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)`,
|
package/lib/spec-i18n.js
ADDED
|
@@ -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
|
@@ -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,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
|
+
}
|