@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 +1 -1
- package/docs/I18N.md +142 -53
- package/lib/check.js +9 -5
- package/lib/spec-i18n.js +119 -0
- package/package.json +1 -1
- package/templates/shared/docs/clearstack/I18N.md +142 -53
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
|
|
8
|
-
also clearly separating UI
|
|
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
|
-
##
|
|
10
|
+
## Two Patterns — Use Both
|
|
11
11
|
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
46
|
+
import { html, define, localize, msg } from 'hybrids';
|
|
32
47
|
|
|
33
|
-
//
|
|
34
|
-
|
|
48
|
+
// Simple static text — translated automatically, no code change needed
|
|
49
|
+
html`<button>Save</button>`
|
|
35
50
|
|
|
36
|
-
//
|
|
37
|
-
|
|
51
|
+
// Dynamic value in a template
|
|
52
|
+
html`<div>${msg`${count} variants scored`}</div>`
|
|
38
53
|
|
|
39
|
-
//
|
|
40
|
-
|
|
54
|
+
// As an attribute
|
|
55
|
+
html`<my-button label="${msg`Submit`}"></my-button>`
|
|
41
56
|
|
|
42
|
-
//
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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()`
|
|
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
|
-
|
|
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
|
-
|
|
130
|
+
## Key Naming Convention (Pattern A)
|
|
74
131
|
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
+
## Adding a Language
|
|
93
164
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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()
|
|
105
|
-
- **Never hardcode UI strings** in
|
|
106
|
-
- **
|
|
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: '
|
|
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.`);
|
package/lib/spec-i18n.js
ADDED
|
@@ -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
|
@@ -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
|
|
8
|
-
also clearly separating UI
|
|
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
|
-
##
|
|
10
|
+
## Two Patterns — Use Both
|
|
11
11
|
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
46
|
+
import { html, define, localize, msg } from 'hybrids';
|
|
32
47
|
|
|
33
|
-
//
|
|
34
|
-
|
|
48
|
+
// Simple static text — translated automatically, no code change needed
|
|
49
|
+
html`<button>Save</button>`
|
|
35
50
|
|
|
36
|
-
//
|
|
37
|
-
|
|
51
|
+
// Dynamic value in a template
|
|
52
|
+
html`<div>${msg`${count} variants scored`}</div>`
|
|
38
53
|
|
|
39
|
-
//
|
|
40
|
-
|
|
54
|
+
// As an attribute
|
|
55
|
+
html`<my-button label="${msg`Submit`}"></my-button>`
|
|
41
56
|
|
|
42
|
-
//
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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()`
|
|
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
|
-
|
|
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
|
-
|
|
130
|
+
## Key Naming Convention (Pattern A)
|
|
74
131
|
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
+
## Adding a Language
|
|
93
164
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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()
|
|
105
|
-
- **Never hardcode UI strings** in
|
|
106
|
-
- **
|
|
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
|