stitch2 0.9.1 → 0.11.0
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 +50 -3
- package/config.schema.json +24 -0
- package/dist/canvas.html +40 -6
- package/dist/check.js +58 -8
- package/dist/cli.js +4 -0
- package/dist/config.d.ts +14 -0
- package/dist/elements.d.ts +9 -0
- package/dist/elements.js +14 -1
- package/dist/i18n-report.d.ts +17 -0
- package/dist/i18n-report.js +138 -0
- package/dist/i18n.d.ts +29 -0
- package/dist/i18n.js +275 -0
- package/dist/languages-page.d.ts +1 -0
- package/dist/languages-page.js +100 -0
- package/dist/pick.js +1 -1
- package/dist/serve.js +15 -3
- package/dist/typecheck.d.ts +2 -0
- package/package.json +1 -1
- package/skills/stitch2/SKILL.md +7 -3
- package/skills/stitch2-i18n/SKILL.md +105 -0
package/README.md
CHANGED
|
@@ -37,6 +37,8 @@ file that already exists. From there the agent spends its effort on the design.
|
|
|
37
37
|
and status filters, your DESIGN.md drawn as tokens and rules, the component catalog, and an inspector to
|
|
38
38
|
approve, archive or leave a note on a version. Live reload; copyable references to paste to an agent.
|
|
39
39
|
With a light and a dark theme (DESIGN.md's `light-*` colours), a switch (`T`) shows every screen in either one.
|
|
40
|
+
With languages configured, a Language switch (`L`) shows every screen in one of them, and the Languages page
|
|
41
|
+
shows a screen side by side in each.
|
|
40
42
|
Pick mode (`P`) highlights any element of a screen and copies a reference with its source line, such as
|
|
41
43
|
`home mobile v1 (design/screens/home/mobile-v1.html:36) <gg-button> “Start Push”`; Shift-click collects
|
|
42
44
|
several to copy at once or add to the version's note.
|
|
@@ -44,7 +46,8 @@ file that already exists. From there the agent spends its effort on the design.
|
|
|
44
46
|
other text, labels cut short with an ellipsis, content under fixed bars, fixed bars that overlap each other, tap
|
|
45
47
|
targets (WCAG 2.2), text contrast in every theme, text that is not one of DESIGN.md's type levels, capitals without tracking,
|
|
46
48
|
large numbers in monospace, components built by hand, components that differ between screens, and colours
|
|
47
|
-
written as values instead of DESIGN.md tokens.
|
|
49
|
+
written as values instead of DESIGN.md tokens. Each screen is also run in long and right-to-left
|
|
50
|
+
pseudo-languages and your locales (see below).
|
|
48
51
|
- **Consistency report** (`stitch2 consistency`): every shared component (tab bar, header, buttons, chips, …)
|
|
49
52
|
cropped from every screen, next to the catalog's version, with what differs (sizes, fonts, icon set, icons).
|
|
50
53
|
- **Type audit** (`stitch2 type`): every text style a screen uses, and the level it matches.
|
|
@@ -73,7 +76,7 @@ file that already exists. From there the agent spends its effort on the design.
|
|
|
73
76
|
- **Skills** for agents (`skills/`): `stitch2` (the design workflow, principles, typography, components,
|
|
74
77
|
versions), `stitch2-design-md` (building the design system), `stitch2-variants` (fresh-eyes versions and
|
|
75
78
|
testing skill changes), `stitch2-consistency` (verifying and fixing
|
|
76
|
-
consistency), `stitch2-app-sync` (keeping the app and the designs in step: design first, then the app) and,
|
|
79
|
+
consistency), `stitch2-i18n` (screens in every language), `stitch2-app-sync` (keeping the app and the designs in step: design first, then the app) and,
|
|
77
80
|
optional, `stitch2-import-from-google-stitch` (drafting screens in Google Stitch through its MCP server).
|
|
78
81
|
|
|
79
82
|
## Setup
|
|
@@ -183,6 +186,49 @@ for (const locale of ['de', 'ru', 'ar']) {
|
|
|
183
186
|
Warnings worth reading for translations: `truncated` (a label cut short with an ellipsis), `text-crowded` (two
|
|
184
187
|
controls' labels nearly touching) and `clipped`.
|
|
185
188
|
|
|
189
|
+
## Screens in every language
|
|
190
|
+
|
|
191
|
+
A screen is written once, in the source language, with each product string marked by its message key from your
|
|
192
|
+
app's own translation files. stitch2 swaps the text in when the screen is opened in another language, so one file
|
|
193
|
+
shows every locale and the screens use the same strings as the product:
|
|
194
|
+
|
|
195
|
+
```html
|
|
196
|
+
<span data-t="nav.home">Home</span>
|
|
197
|
+
<ex-header title="Settings" data-t-title="settings.title"></ex-header>
|
|
198
|
+
<p data-t="workout.sets" data-t-args="count=16">16 sets</p> <!-- plural form picked by count -->
|
|
199
|
+
<p translate="no">Dumbbell Bench Press</p> <!-- sample content stays as written -->
|
|
200
|
+
<ex-row meta="5 exercises · Linear" data-t-meta="{plan.exercises|count=5} · {plan.linear}"></ex-row> <!-- a template -->
|
|
201
|
+
<ex-row meta="Sun, 4 Oct · 7,920 kg"
|
|
202
|
+
data-t-meta="{@date|value=2026-10-04;weekday=short;day=numeric;month=short} · {@number|value=7920} {unit.kg}"></ex-row>
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
```json
|
|
206
|
+
"i18n": {
|
|
207
|
+
"source": "en",
|
|
208
|
+
"locales": ["en", "de", "ru", "ar"],
|
|
209
|
+
"messages": { "en": "src/i18n/en.ts", "*": "src/i18n/locales/{locale}.ts" },
|
|
210
|
+
"check": ["de", "ar"]
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Messages are JSON or a JS/TS module (its default export, else its first exported object); nested objects become
|
|
215
|
+
dotted keys, and plurals are objects keyed by CLDR category (`one`, `few`, `other`…). A value with braces is a
|
|
216
|
+
template: each `{key|name=value;…}` becomes its message and the rest is kept, and `{@date|…}` and `{@number|…}`
|
|
217
|
+
format with `Intl` in the screen's language. Two pseudo-languages need
|
|
218
|
+
no translations: **pseudo** makes every text about 40% longer, accented and bracketed, so cut text shows, and
|
|
219
|
+
**pseudo-rtl** lays the page out right to left.
|
|
220
|
+
|
|
221
|
+
- The canvas's Language switch (`L`) shows every screen in one language; the **Languages** page shows each
|
|
222
|
+
screen side by side in the check languages, or in all of them, with the number of missing translations.
|
|
223
|
+
- `stitch2 check` runs every screen in pseudo, pseudo-rtl and the locales in `check`; an issue that only shows
|
|
224
|
+
in one language carries it ("pseudo: Text overflows…"). A key missing from the source messages is an error.
|
|
225
|
+
- `stitch2 i18n` lists, per screen, keys the source or a locale lacks, translations whose `{placeholders}`
|
|
226
|
+
differ, and text with no key; `--suggest` names existing messages with the same text.
|
|
227
|
+
- Components that draw their own labels read them with `translate(key, fallback)` from `stitch2/elements`.
|
|
228
|
+
|
|
229
|
+
The `stitch2-i18n` skill teaches agents to write keyed screens and to fix text that does not fit: room in the
|
|
230
|
+
layout first, then a shorter natural word, never a smaller type size.
|
|
231
|
+
|
|
186
232
|
## Commands
|
|
187
233
|
|
|
188
234
|
| Command | What it does |
|
|
@@ -191,7 +237,8 @@ controls' labels nearly touching) and `clipped`.
|
|
|
191
237
|
| `stitch2 init --name N --primary #hex [options]` | Writes a complete DESIGN.md from a few choices |
|
|
192
238
|
| `stitch2 extract <url or file…>` | Writes a DESIGN.md measured from an existing site or screens |
|
|
193
239
|
| `stitch2 canvas` | The canvas at http://localhost:4400 (`PORT` and `HOST` to change) |
|
|
194
|
-
| `stitch2 check [filter] [--shots] [--strict]` | All checks, in every theme; `--shots` saves renders to `<design>/renders/` (`--light.png
|
|
240
|
+
| `stitch2 check [filter] [--shots] [--strict] [--locales l,…]` | All checks, in every theme and check language; `--shots` saves renders to `<design>/renders/` (`--light.png`, `--pseudo.png`…) |
|
|
241
|
+
| `stitch2 i18n [filter] [--locale l] [--suggest] [--json]` | Message keys the source or a locale lacks, and screen text with no key |
|
|
195
242
|
| `stitch2 consistency [kind…]` | The consistency report, `<design>/consistency/index.html` |
|
|
196
243
|
| `stitch2 type [filter] [--json]` | The type audit |
|
|
197
244
|
| `stitch2 screens [name] [--status s] [--json]` | Versions, statuses and notes |
|
package/config.schema.json
CHANGED
|
@@ -67,6 +67,30 @@
|
|
|
67
67
|
"items": { "type": "string" },
|
|
68
68
|
"description": "The themes every screen must work in, by their data-theme value. Defaults to DESIGN.md's: dark (the plain colours), plus light when it has light-* colours. The canvas switches between them and check runs in each."
|
|
69
69
|
},
|
|
70
|
+
"i18n": {
|
|
71
|
+
"type": "object",
|
|
72
|
+
"additionalProperties": false,
|
|
73
|
+
"description": "Languages. Screens mark product text with message keys (data-t, data-t-<attr>, data-t-args); the canvas switches language and check runs each screen in the pseudo-languages and the locales in check.",
|
|
74
|
+
"properties": {
|
|
75
|
+
"source": { "type": "string", "default": "en", "description": "The language screens are written in." },
|
|
76
|
+
"locales": { "type": "array", "items": { "type": "string" }, "description": "Locales with translations, besides the source language." },
|
|
77
|
+
"messages": {
|
|
78
|
+
"description": "Message files, relative to this file: JSON or a JS/TS module (default export, else its first exported object). A pattern with {locale}, or a map per locale with * for the rest.",
|
|
79
|
+
"oneOf": [
|
|
80
|
+
{ "type": "string" },
|
|
81
|
+
{ "type": "object", "additionalProperties": { "type": "string" } }
|
|
82
|
+
]
|
|
83
|
+
},
|
|
84
|
+
"check": {
|
|
85
|
+
"description": "Locales stitch2 check runs besides pseudo and pseudo-rtl, or \"all\".",
|
|
86
|
+
"oneOf": [
|
|
87
|
+
{ "type": "array", "items": { "type": "string" } },
|
|
88
|
+
{ "const": "all" }
|
|
89
|
+
]
|
|
90
|
+
},
|
|
91
|
+
"rtl": { "type": "array", "items": { "type": "string" }, "description": "Right-to-left locales, if not ar, he, fa, ur, ps and yi." }
|
|
92
|
+
}
|
|
93
|
+
},
|
|
70
94
|
"skills": {
|
|
71
95
|
"type": "array",
|
|
72
96
|
"items": {
|
package/dist/canvas.html
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
.seg button { background: none; border: 0; border-radius: 6px; color: var(--muted); cursor: pointer; flex: 1; padding: 4px 6px; white-space: nowrap; }
|
|
28
28
|
.seg button.on { background: var(--panel2); color: var(--text); }
|
|
29
29
|
.seg[hidden] { display: none; }
|
|
30
|
+
#langFilter { background: var(--bg); border: 1px solid var(--edge); border-radius: 8px; color: var(--text); padding: 5px 8px; width: 100%; }
|
|
30
31
|
#tree { flex: 1; overflow: auto; padding: 6px 8px 16px; }
|
|
31
32
|
#tree h4 { color: var(--faint); font-size: 11px; letter-spacing: .06em; margin: 14px 6px 6px; text-transform: uppercase; }
|
|
32
33
|
.item { align-items: center; background: none; border: 0; border-radius: 6px; color: var(--muted); cursor: pointer; display: flex; gap: 8px; padding: 6px 8px; text-align: left; width: 100%; }
|
|
@@ -107,6 +108,7 @@
|
|
|
107
108
|
<div class="seg" id="statusFilter"><button type="button" data-v="active" class="on" title="Everything except archived versions">Active</button><button type="button" data-v="approved" title="The reference versions agents build on">Approved</button><button type="button" data-v="review" title="Finished by an agent, waiting for your decision">Needs review</button><button type="button" data-v="all" title="Including archived versions">All</button></div>
|
|
108
109
|
<div class="seg" id="deviceFilter"><button type="button" data-v="all" class="on">All devices</button><button type="button" data-v="mobile">Mobile</button><button type="button" data-v="desktop">Desktop</button></div>
|
|
109
110
|
<div class="seg" id="themeFilter" hidden title="Show every screen in this theme (T)"></div>
|
|
111
|
+
<select id="langFilter" title="Show every screen in this language (L)" aria-label="Language"></select>
|
|
110
112
|
</header>
|
|
111
113
|
<nav id="tree"></nav>
|
|
112
114
|
<footer>
|
|
@@ -140,8 +142,29 @@ let designDir = 'design'
|
|
|
140
142
|
let THEMES = ['dark']
|
|
141
143
|
let theme = localStorage.getItem('stitch2-theme') ?? 'dark'
|
|
142
144
|
const themed = () => THEMES.length > 1
|
|
143
|
-
|
|
144
|
-
|
|
145
|
+
// Languages: screens reload in the one picked (?lang=…); the source language shows them as written.
|
|
146
|
+
let LANGS = ['en']
|
|
147
|
+
let SOURCE = 'en'
|
|
148
|
+
let lang = localStorage.getItem('stitch2-lang') ?? 'en'
|
|
149
|
+
const langQuery = () => (lang === SOURCE ? '' : `&lang=${encodeURIComponent(lang)}`)
|
|
150
|
+
const themeNote = () =>
|
|
151
|
+
[themed() && theme !== THEMES[0] ? `in the ${theme} theme` : '', lang !== SOURCE ? `in ${lang}` : '']
|
|
152
|
+
.filter(Boolean)
|
|
153
|
+
.map((s) => ' ' + s)
|
|
154
|
+
.join(',')
|
|
155
|
+
const forTheme = (issues) =>
|
|
156
|
+
(issues ?? []).filter((i) => (!i.theme || !themed() || i.theme === theme) && (!i.locale || i.locale === lang))
|
|
157
|
+
const langName = (l) =>
|
|
158
|
+
l === 'pseudo' ? 'Pseudo: long text' : l === 'pseudo-rtl' ? 'Pseudo: right to left' : `${new Intl.DisplayNames([l], { type: 'language' }).of(l) ?? l} (${l})${l === SOURCE ? ', source' : ''}`
|
|
159
|
+
function setLang(l) {
|
|
160
|
+
lang = l
|
|
161
|
+
localStorage.setItem('stitch2-lang', l)
|
|
162
|
+
document.getElementById('langFilter').value = l
|
|
163
|
+
for (const f of frames.values()) f.iframe.src = `/${f.screen.path}?pick${langQuery()}`
|
|
164
|
+
for (const f of frames.values()) badge(f)
|
|
165
|
+
if (selected) inspector(selected)
|
|
166
|
+
summary()
|
|
167
|
+
}
|
|
145
168
|
function applyTheme(f) {
|
|
146
169
|
if (!themed()) return
|
|
147
170
|
try { f.iframe.contentDocument.documentElement.dataset.theme = theme } catch {}
|
|
@@ -283,6 +306,7 @@ function tree() {
|
|
|
283
306
|
item('DESIGN.md', 'tokens · rules', docOpen('/design'), () => openDoc('/design'))
|
|
284
307
|
item('Consistency report', 'components × screens', docOpen('/consistency/index.html'), () => openDoc('/consistency/index.html'))
|
|
285
308
|
item('App comparison', 'approved × live app', docOpen('/compare/index.html'), () => openDoc('/compare/index.html'))
|
|
309
|
+
item('Languages', 'screens × languages', docOpen('/languages'), () => openDoc('/languages'))
|
|
286
310
|
const all = rows()
|
|
287
311
|
const comp = all.find((r) => r.key === '~components')
|
|
288
312
|
if (comp?.visible.length) {
|
|
@@ -379,7 +403,7 @@ function frameFor(s) {
|
|
|
379
403
|
const iframe = el.querySelector('iframe')
|
|
380
404
|
iframe.style.width = `${s.width}px`
|
|
381
405
|
iframe.style.height = `${s.width > 700 ? 800 : 844}px`
|
|
382
|
-
iframe.src = `/${s.path}?pick`
|
|
406
|
+
iframe.src = `/${s.path}?pick${langQuery()}`
|
|
383
407
|
const f = { el, iframe, screen: s, height: s.width > 700 ? 800 : 844 }
|
|
384
408
|
iframe.addEventListener('load', () => { applyTheme(f); measure(f); setTimeout(() => measure(f), 800); setTimeout(() => measure(f), 2500) })
|
|
385
409
|
el.querySelector('.shield').addEventListener('click', () => select(f, false))
|
|
@@ -410,7 +434,7 @@ function inspector(f) {
|
|
|
410
434
|
const s = f.screen
|
|
411
435
|
const issues = forTheme(checks.screens?.[s.path])
|
|
412
436
|
box.innerHTML = `<h3><span class="dot ${s.status}"></span><span class="h"></span><button type="button" class="copy" title="Copy a reference to paste to an agent">Copy</button><button type="button" class="close" title="Close (Esc)">×</button></h3>
|
|
413
|
-
<div class="facts"><span class="f"></span> · <a target="_blank"></a></div>
|
|
437
|
+
<div class="facts"><span class="f"></span> · <a target="_blank"></a> · <a href="#" class="langs" title="This screen in every language, side by side">languages</a></div>
|
|
414
438
|
${s.kind === 'screen' ? `<div class="status">${['approved', 'review', 'draft', 'archived'].map((st) => `<button type="button" data-st="${st}" title="${STATUS_HINT[st]}" class="${s.status === st ? 'on' : ''}">${st === 'approved' ? 'Approve' : st === 'review' ? 'Needs review' : st === 'draft' ? 'Draft' : 'Archive'}</button>`).join('')}</div>
|
|
415
439
|
<textarea placeholder="Note for the agent: what to keep, what to change"></textarea><button type="button" class="save">Save note</button>` : ''}
|
|
416
440
|
<h4>Checks</h4><ul></ul>`
|
|
@@ -445,6 +469,7 @@ function inspector(f) {
|
|
|
445
469
|
const a = box.querySelector('.facts a')
|
|
446
470
|
a.href = `/${s.path}`
|
|
447
471
|
a.textContent = s.path
|
|
472
|
+
box.querySelector('.facts .langs').onclick = (e) => { e.preventDefault(); openDoc(`/languages?screen=${encodeURIComponent(s.path)}`) }
|
|
448
473
|
box.querySelector('h3 .close').onclick = () => select(null)
|
|
449
474
|
box.querySelector('h3 .copy').onclick = () => copy(ref(s))
|
|
450
475
|
for (const b of box.querySelectorAll('.status button')) b.onclick = () => setMeta(s.path, { status: b.dataset.st })
|
|
@@ -508,14 +533,15 @@ function elementAt(e) {
|
|
|
508
533
|
return el ? { f, el } : null
|
|
509
534
|
}
|
|
510
535
|
function describe(el) {
|
|
536
|
+
const key = el.closest('[data-s2-key]')?.getAttribute('data-s2-key')
|
|
511
537
|
const text = (el.innerText || el.getAttribute('aria-label') || el.getAttribute('placeholder') || el.getAttribute('alt') || el.value || '').trim().replace(/\s+/g, ' ')
|
|
512
|
-
return { tag: el.getAttribute('data-s2-tag') ?? el.localName, line: el.getAttribute('data-s2-line'), text: text.length > 48 ? `${text.slice(0, 47)}…` : text }
|
|
538
|
+
return { tag: el.getAttribute('data-s2-tag') ?? el.localName, line: el.getAttribute('data-s2-line'), key, text: text.length > 48 ? `${text.slice(0, 47)}…` : text }
|
|
513
539
|
}
|
|
514
540
|
function elementRef(f, el) {
|
|
515
541
|
const d = describe(el)
|
|
516
542
|
const s = f.screen
|
|
517
543
|
const who = s.kind === 'components' ? 'component catalog' : `${s.screen} ${s.device} ${s.version}`
|
|
518
|
-
return `${who}${themeNote()} (${designDir}/${s.path}:${d.line}) <${d.tag}>${d.text ? ` “${d.text}”` : ''}`
|
|
544
|
+
return `${who}${themeNote()} (${designDir}/${s.path}:${d.line}) <${d.tag}>${d.text ? ` “${d.text}”` : ''}${d.key ? ` [${d.key}]` : ''}`
|
|
519
545
|
}
|
|
520
546
|
function drawBox(f, el, cls, label) {
|
|
521
547
|
const r = el.getBoundingClientRect()
|
|
@@ -682,6 +708,7 @@ addEventListener('keydown', (e) => {
|
|
|
682
708
|
if (e.key === 'Escape') { closeDoc(); select(null) }
|
|
683
709
|
if (e.key === 'f' || e.key === 'F') fit()
|
|
684
710
|
if ((e.key === 't' || e.key === 'T') && themed()) setTheme(THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length])
|
|
711
|
+
if (e.key === 'l' || e.key === 'L') setLang(LANGS[(LANGS.indexOf(lang) + 1) % LANGS.length])
|
|
685
712
|
if (e.key === '0') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, 1)
|
|
686
713
|
if (e.key === '+' || e.key === '=') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, view.z * 1.2)
|
|
687
714
|
if (e.key === '-') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, view.z / 1.2)
|
|
@@ -712,6 +739,13 @@ fetch('/api/config').then((r) => r.json()).then((c) => {
|
|
|
712
739
|
seg.innerHTML = THEMES.map((t) => `<button type="button" data-v="${t}">${t[0].toUpperCase()}${t.slice(1)}</button>`).join('')
|
|
713
740
|
for (const b of seg.querySelectorAll('button')) b.onclick = () => setTheme(b.dataset.v)
|
|
714
741
|
setTheme(theme)
|
|
742
|
+
SOURCE = c.sourceLocale ?? 'en'
|
|
743
|
+
LANGS = c.locales?.length ? c.locales : [SOURCE]
|
|
744
|
+
if (!LANGS.includes(lang)) lang = SOURCE
|
|
745
|
+
const sel = document.getElementById('langFilter')
|
|
746
|
+
sel.innerHTML = LANGS.map((l) => `<option value="${l}">${langName(l)}</option>`).join('')
|
|
747
|
+
sel.value = lang
|
|
748
|
+
sel.onchange = () => setLang(sel.value)
|
|
715
749
|
document.title = c.name
|
|
716
750
|
load(true)
|
|
717
751
|
})
|
package/dist/check.js
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
* the canvas shows as badges.
|
|
6
6
|
* Every screen is checked in its own theme, then again in each other theme DESIGN.md defines (designThemes):
|
|
7
7
|
* colours change there, so contrast is checked again, tagged with the theme.
|
|
8
|
-
*
|
|
8
|
+
* Then again in each check language (i18n.ts: the pseudo-languages and the config's): layout problems that
|
|
9
|
+
* show only there (a label running into its neighbour, text cut off, a control drawn backwards right to left),
|
|
10
|
+
* and keys a locale has no translation for, tagged with the locale.
|
|
11
|
+
* Usage: stitch2 check [path filter…] [--shots] [--strict] [--locales ru,ar | all | none]
|
|
9
12
|
* --shots also save page-tall renders to design/renders/ (one per theme)
|
|
10
13
|
* --strict exit 1 when any screen has errors (for CI and agent loops)
|
|
11
14
|
*/
|
|
@@ -16,6 +19,7 @@ import { colourIssues } from './colours.js';
|
|
|
16
19
|
import { config } from './config.js';
|
|
17
20
|
import { collectComponents, collectOptions, consistencyIssues, reuseIssues, } from './consistency.js';
|
|
18
21
|
import { designThemes, typeLevels } from './design-md.js';
|
|
22
|
+
import { checkLocales } from './i18n.js';
|
|
19
23
|
import { listScreens, ROOT } from './screens.js';
|
|
20
24
|
import { startServer } from './serve.js';
|
|
21
25
|
import { collectText, typeIssues } from './typecheck.js';
|
|
@@ -23,7 +27,9 @@ import { audit } from './audit.js';
|
|
|
23
27
|
const args = process.argv.slice(2);
|
|
24
28
|
const shots = args.includes('--shots');
|
|
25
29
|
const strict = args.includes('--strict');
|
|
26
|
-
const
|
|
30
|
+
const localesFlag = args.indexOf('--locales');
|
|
31
|
+
const locales = checkLocales(localesFlag >= 0 ? args[localesFlag + 1] : undefined);
|
|
32
|
+
const filters = args.filter((a, i) => !a.startsWith('--') && (localesFlag < 0 || i !== localesFlag + 1));
|
|
27
33
|
const screens = listScreens().filter((s) => !filters.length || filters.some((f) => s.path.includes(f)));
|
|
28
34
|
const levels = typeLevels();
|
|
29
35
|
const themes = designThemes();
|
|
@@ -61,15 +67,58 @@ for (const s of screens) {
|
|
|
61
67
|
}
|
|
62
68
|
if (themes.length > 1)
|
|
63
69
|
await setTheme(base);
|
|
70
|
+
const typed = typeIssues(levels, await page.evaluate(collectText));
|
|
71
|
+
if (shots)
|
|
72
|
+
await shoot(s);
|
|
73
|
+
// The other languages, in the screen's own theme. Reported once per element and kind, in the first locale.
|
|
74
|
+
const unknownSeen = new Set();
|
|
75
|
+
for (const locale of locales) {
|
|
76
|
+
await page
|
|
77
|
+
.goto(`http://127.0.0.1:${server.port}/${s.path}?lang=${encodeURIComponent(locale)}`, { waitUntil: 'networkidle', timeout: 30_000 })
|
|
78
|
+
.catch(() => { });
|
|
79
|
+
await page.waitForTimeout(600);
|
|
80
|
+
for (const i of await page.evaluate(audit, { comfortable: config.comfortableTarget })) {
|
|
81
|
+
const key = `${i.type} ${i.selector}`;
|
|
82
|
+
if (i.type === 'contrast' || seen.has(key))
|
|
83
|
+
continue;
|
|
84
|
+
seen.add(key);
|
|
85
|
+
themed.push({ ...i, locale, message: `${locale}: ${i.message}` });
|
|
86
|
+
}
|
|
87
|
+
const lost = await page.evaluate(() => window.__stitch2?.missing ?? []);
|
|
88
|
+
for (const m of lost) {
|
|
89
|
+
const selector = `[data-s2-key="${m.all}"]`;
|
|
90
|
+
const unknown = m.unknown.split(' ').filter(Boolean);
|
|
91
|
+
const fresh = unknown.filter((k) => !unknownSeen.has(k));
|
|
92
|
+
fresh.forEach((k) => unknownSeen.add(k));
|
|
93
|
+
if (fresh.length)
|
|
94
|
+
themed.push({
|
|
95
|
+
severity: 'error',
|
|
96
|
+
type: 'i18n-unknown-key',
|
|
97
|
+
selector,
|
|
98
|
+
text: m.text,
|
|
99
|
+
message: `No message for ${fresh.join(' ')}: add it to the source language's messages`,
|
|
100
|
+
});
|
|
101
|
+
const lacks = m.keys.split(' ').filter((k) => !unknown.includes(k));
|
|
102
|
+
if (lacks.length)
|
|
103
|
+
themed.push({
|
|
104
|
+
severity: 'warning',
|
|
105
|
+
type: 'i18n-missing',
|
|
106
|
+
locale,
|
|
107
|
+
selector,
|
|
108
|
+
text: m.text,
|
|
109
|
+
message: `${locale}: no translation for ${lacks.join(' ')}`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
if (shots)
|
|
113
|
+
await shoot(s, undefined, locale);
|
|
114
|
+
}
|
|
64
115
|
results[s.path] = [
|
|
65
116
|
...layout,
|
|
66
117
|
...themed,
|
|
67
|
-
...
|
|
118
|
+
...typed,
|
|
68
119
|
...reuseIssues(s.path, found),
|
|
69
120
|
...colourIssues(s.path),
|
|
70
121
|
];
|
|
71
|
-
if (shots)
|
|
72
|
-
await shoot(s);
|
|
73
122
|
}
|
|
74
123
|
await browser.close();
|
|
75
124
|
server.close();
|
|
@@ -79,12 +128,13 @@ async function setTheme(theme) {
|
|
|
79
128
|
}, theme);
|
|
80
129
|
await page.waitForTimeout(150);
|
|
81
130
|
}
|
|
82
|
-
/** A page-tall render; renders in another theme than the screen's own get a --<theme> suffix. */
|
|
83
|
-
async function shoot(s, theme) {
|
|
131
|
+
/** A page-tall render; renders in another theme or language than the screen's own get a --<theme> suffix. */
|
|
132
|
+
async function shoot(s, theme, locale) {
|
|
84
133
|
const height = await page.evaluate(() => document.documentElement.scrollHeight);
|
|
85
134
|
await page.setViewportSize({ width: s.width, height: Math.max(844, Math.min(height, 6000)) });
|
|
86
135
|
mkdirSync(join(ROOT, 'renders'), { recursive: true });
|
|
87
|
-
const
|
|
136
|
+
const suffix = theme ?? locale;
|
|
137
|
+
const name = s.path.replaceAll('/', '__').replace(/\.html$/, suffix ? `--${suffix}.png` : '.png');
|
|
88
138
|
await page.screenshot({ path: join(ROOT, 'renders', name) });
|
|
89
139
|
await page.setViewportSize({ width: s.width, height: 844 });
|
|
90
140
|
}
|
package/dist/cli.js
CHANGED
|
@@ -38,6 +38,10 @@ const COMMANDS = {
|
|
|
38
38
|
compare: ['compare.ts', '[screen…] [--strict] The live app against the approved screens ("app" in the config)'],
|
|
39
39
|
elements: ['build-elements.ts', '<entry> [--out file] Bundle the project\'s components (defineElements) for the screens'],
|
|
40
40
|
lint: ['', "Google's DESIGN.md linter on the project's DESIGN.md"],
|
|
41
|
+
i18n: [
|
|
42
|
+
'i18n-report.ts',
|
|
43
|
+
'[filter] [--locale l] [--suggest] [--json] Keys screens use that a locale lacks, and screen text with no key',
|
|
44
|
+
],
|
|
41
45
|
screens: ['list.ts', '[name] [--status s] [--json] Screen versions, statuses and notes'],
|
|
42
46
|
localize: ['assets.ts', '[filter] Copy remote images a screen uses into its assets/ folder'],
|
|
43
47
|
sandbox: [
|
package/dist/config.d.ts
CHANGED
|
@@ -38,6 +38,8 @@ export interface LabConfig {
|
|
|
38
38
|
prepare?: string;
|
|
39
39
|
}>;
|
|
40
40
|
};
|
|
41
|
+
/** Languages: message files the screens' data-t keys come from (see i18n.ts). */
|
|
42
|
+
i18n?: I18nConfig;
|
|
41
43
|
/** Token files stitch2 tokens also writes for the app (see exports.ts). */
|
|
42
44
|
exports?: {
|
|
43
45
|
format: 'css' | 'tailwind4' | 'dtcg';
|
|
@@ -49,6 +51,18 @@ export interface LabConfig {
|
|
|
49
51
|
};
|
|
50
52
|
}[];
|
|
51
53
|
}
|
|
54
|
+
export interface I18nConfig {
|
|
55
|
+
/** Locales with translations, besides the source language. */
|
|
56
|
+
locales?: string[];
|
|
57
|
+
/** The language screens are written in (default en). */
|
|
58
|
+
source?: string;
|
|
59
|
+
/** Where messages live, relative to the config: "src/i18n/{locale}.json", or per locale with "*" as the rest. */
|
|
60
|
+
messages?: string | Record<string, string>;
|
|
61
|
+
/** Locales stitch2 check runs besides the pseudo-languages ("all" for every one). */
|
|
62
|
+
check?: string[] | 'all';
|
|
63
|
+
/** Right-to-left locales, if not the usual (ar, he, fa, ur, ps, yi). */
|
|
64
|
+
rtl?: string[];
|
|
65
|
+
}
|
|
52
66
|
export declare const CONFIG_NAME = "stitch2.config.json";
|
|
53
67
|
export declare const CONFIG_FILE: string | undefined;
|
|
54
68
|
/** The folder paths in the config are relative to (the project root). */
|
package/dist/elements.d.ts
CHANGED
|
@@ -29,4 +29,13 @@ export interface ElementsOptions<C> {
|
|
|
29
29
|
/** Runs after every element has rendered, e.g. to draw icon placeholders (lucide.createIcons). */
|
|
30
30
|
after?: () => void;
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* A message in the locale the screen is shown in (the canvas's Language switch), for labels a component draws
|
|
34
|
+
* itself. Without one (the source language, or outside stitch2) it returns the fallback.
|
|
35
|
+
*
|
|
36
|
+
* render: (C, props) => renderToStaticMarkup(createElement(C, { labels: { home: translate('nav.home', 'Home') }, ...props }))
|
|
37
|
+
*/
|
|
38
|
+
export declare function translate(key: string, fallback: string, args?: Record<string, string>): string;
|
|
39
|
+
/** The locale the screen is shown in ("en", "ru", "ar-XB" for the right-to-left pseudo-language), or undefined. */
|
|
40
|
+
export declare const screenLocale: () => string | undefined;
|
|
32
41
|
export declare function defineElements<C>(components: Record<string, C>, options: ElementsOptions<C>): void;
|
package/dist/elements.js
CHANGED
|
@@ -18,6 +18,18 @@
|
|
|
18
18
|
* values stay strings. The element's content becomes children, rendered where the component puts them.
|
|
19
19
|
* Bundle the entry with `stitch2 elements <entry>› and load the bundle in the screens and the template.
|
|
20
20
|
*/
|
|
21
|
+
/**
|
|
22
|
+
* A message in the locale the screen is shown in (the canvas's Language switch), for labels a component draws
|
|
23
|
+
* itself. Without one (the source language, or outside stitch2) it returns the fallback.
|
|
24
|
+
*
|
|
25
|
+
* render: (C, props) => renderToStaticMarkup(createElement(C, { labels: { home: translate('nav.home', 'Home') }, ...props }))
|
|
26
|
+
*/
|
|
27
|
+
export function translate(key, fallback, args) {
|
|
28
|
+
const s = globalThis.__stitch2;
|
|
29
|
+
return s?.t(key, args) ?? fallback;
|
|
30
|
+
}
|
|
31
|
+
/** The locale the screen is shown in ("en", "ru", "ar-XB" for the right-to-left pseudo-language), or undefined. */
|
|
32
|
+
export const screenLocale = () => globalThis.__stitch2?.lang;
|
|
21
33
|
const camel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
22
34
|
export function defineElements(components, options) {
|
|
23
35
|
const { prefix, render } = options;
|
|
@@ -30,7 +42,8 @@ export function defineElements(components, options) {
|
|
|
30
42
|
connectedCallback() {
|
|
31
43
|
const props = {};
|
|
32
44
|
for (const a of Array.from(this.attributes)) {
|
|
33
|
-
|
|
45
|
+
// stitch2's own markers and the message keys (i18n.ts translated them already) are not props.
|
|
46
|
+
if (a.name.startsWith('data-s2-') || a.name === 'data-t' || a.name.startsWith('data-t-'))
|
|
34
47
|
continue;
|
|
35
48
|
props[camel(a.name)] = a.value === '' ? true : a.value;
|
|
36
49
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
interface Use {
|
|
2
|
+
key: string;
|
|
3
|
+
line: number;
|
|
4
|
+
plural: boolean;
|
|
5
|
+
}
|
|
6
|
+
interface Loose {
|
|
7
|
+
text: string;
|
|
8
|
+
line: number;
|
|
9
|
+
attr?: string;
|
|
10
|
+
suggest?: string[];
|
|
11
|
+
}
|
|
12
|
+
/** The keys a screen uses and its text with no key, from its markup (scripts, styles and SVG left out). */
|
|
13
|
+
export declare function scan(html: string): {
|
|
14
|
+
uses: Use[];
|
|
15
|
+
loose: Loose[];
|
|
16
|
+
};
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How far the screens are translated, for agents writing them in several languages:
|
|
3
|
+
*
|
|
4
|
+
* stitch2 i18n [filter] [--locale l] [--suggest] [--json]
|
|
5
|
+
*
|
|
6
|
+
* Errors: keys screens use (data-t, data-t-<attr>) that the source messages lack, which show the screen's own
|
|
7
|
+
* text in every language. Warnings: keys a locale lacks (they fall back to the source language), translations
|
|
8
|
+
* whose {placeholders} differ from the source's, and product text in screens with no key. Text that is sample
|
|
9
|
+
* content (names, numbers, user input) opts out with translate="no" or data-t-skip. --suggest names source
|
|
10
|
+
* messages with the same text as an unkeyed one. Archived screens are skipped. Exits 1 on errors.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { ROOT } from './config.js';
|
|
15
|
+
import { loadMessages, realLocales, sourceLocale } from './i18n.js';
|
|
16
|
+
import { listScreens } from './screens.js';
|
|
17
|
+
const args = process.argv.slice(2);
|
|
18
|
+
const opt = (n) => (args.includes(n) ? args[args.indexOf(n) + 1] : undefined);
|
|
19
|
+
const filter = args.find((a, i) => !a.startsWith('--') && args[i - 1] !== '--locale');
|
|
20
|
+
const locales = opt('--locale')?.split(',') ?? realLocales();
|
|
21
|
+
const suggest = args.includes('--suggest');
|
|
22
|
+
/** Attributes that hold text people read. On components (a dash in the tag) any attribute that reads as words. */
|
|
23
|
+
const TEXT_ATTRS = new Set(['title', 'label', 'placeholder', 'aria-label', 'alt']);
|
|
24
|
+
const NOT_TEXT = /^(class|id|style|href|src|srcset|type|name|value|for|role|slot|lang|dir|icon|variant|tone|size|width|height|viewbox|d|fill|stroke|xmlns|rel|target|tabindex|loading|translate)$/;
|
|
25
|
+
const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr']);
|
|
26
|
+
const words = (s) => /\p{L}{2,}/u.test(s);
|
|
27
|
+
const readsAsText = (s) => words(s) && (/\s/.test(s.trim()) || /\p{Lu}/u.test(s) || /[^\x00-\x7f]/.test(s));
|
|
28
|
+
function attrsOf(s) {
|
|
29
|
+
const out = {};
|
|
30
|
+
for (const m of s.matchAll(/([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g))
|
|
31
|
+
out[m[1].toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? '';
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
/** The keys a screen uses and its text with no key, from its markup (scripts, styles and SVG left out). */
|
|
35
|
+
export function scan(html) {
|
|
36
|
+
const uses = [];
|
|
37
|
+
const loose = [];
|
|
38
|
+
const body = html.replace(/<(script|style|svg|template)\b[\s\S]*?<\/\1>|<!--[\s\S]*?-->/gi, (m) => m.replace(/[^\n]/g, ' '));
|
|
39
|
+
const start = body.search(/<body\b/i);
|
|
40
|
+
const lineAt = (i) => body.slice(0, i).split('\n').length;
|
|
41
|
+
const stack = [];
|
|
42
|
+
const re = /<(\/?)([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>|([^<]+)/g;
|
|
43
|
+
re.lastIndex = Math.max(0, start);
|
|
44
|
+
for (let m = re.exec(body); m; m = re.exec(body)) {
|
|
45
|
+
const line = lineAt(m.index);
|
|
46
|
+
const top = stack[stack.length - 1];
|
|
47
|
+
if (m[5] !== undefined) {
|
|
48
|
+
const text = m[5].replace(/\s+/g, ' ').trim();
|
|
49
|
+
if (words(text) && !top?.keyed && !top?.skip)
|
|
50
|
+
loose.push({ text: text.replaceAll('&', '&'), line });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const tag = m[2].toLowerCase();
|
|
54
|
+
if (m[1]) {
|
|
55
|
+
const at = stack.map((s) => s.tag).lastIndexOf(tag);
|
|
56
|
+
if (at >= 0)
|
|
57
|
+
stack.length = at;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const a = attrsOf(m[3]);
|
|
61
|
+
const plural = 'data-t-args' in a && /(^|,)\s*count\s*=/.test(a['data-t-args']);
|
|
62
|
+
const skip = !!top?.skip || 'data-t-skip' in a || a.translate === 'no';
|
|
63
|
+
for (const [name, value] of Object.entries(a)) {
|
|
64
|
+
const keyed = name === 'data-t' || (name.startsWith('data-t-') && name !== 'data-t-args' && name !== 'data-t-skip');
|
|
65
|
+
if (keyed && value.includes('{'))
|
|
66
|
+
for (const m of value.matchAll(/\{([^{}|@][^{}|]*)(?:\|([^}]*))?\}/g))
|
|
67
|
+
uses.push({ key: m[1], line, plural: plural || /(^|;)\s*count\s*=/.test(m[2] ?? '') });
|
|
68
|
+
else if (name === 'data-t')
|
|
69
|
+
uses.push({ key: value, line, plural });
|
|
70
|
+
else if (keyed)
|
|
71
|
+
for (const k of value.split(','))
|
|
72
|
+
uses.push({ key: k.trim(), line, plural });
|
|
73
|
+
else if (!skip && !name.startsWith('data-') && !NOT_TEXT.test(name) && !('data-t-' + name in a)) {
|
|
74
|
+
const textual = TEXT_ATTRS.has(name) ? words(value) : tag.includes('-') && readsAsText(value);
|
|
75
|
+
if (textual)
|
|
76
|
+
loose.push({ text: value, line, attr: name });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!m[4] && !VOID.has(tag))
|
|
80
|
+
stack.push({ tag, keyed: !!top?.keyed || 'data-t' in a, skip });
|
|
81
|
+
}
|
|
82
|
+
return { uses, loose };
|
|
83
|
+
}
|
|
84
|
+
const has = (msgs, u) => u.key in msgs || (u.plural && u.key + '.other' in msgs);
|
|
85
|
+
const holes = (s) => [...s.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort().join(',');
|
|
86
|
+
const source = await loadMessages(sourceLocale());
|
|
87
|
+
const byLocale = Object.fromEntries(await Promise.all(locales.map(async (l) => [l, await loadMessages(l)])));
|
|
88
|
+
const byText = new Map();
|
|
89
|
+
for (const [k, v] of Object.entries(source)) {
|
|
90
|
+
const n = v.trim().toLowerCase();
|
|
91
|
+
byText.set(n, [...(byText.get(n) ?? []), k]);
|
|
92
|
+
}
|
|
93
|
+
const screens = listScreens().filter((s) => s.kind === 'screen' && s.status !== 'archived' && (!filter || s.path.includes(filter) || s.screen === filter));
|
|
94
|
+
const report = screens.map((s) => {
|
|
95
|
+
const { uses, loose } = scan(readFileSync(join(ROOT, s.path), 'utf8'));
|
|
96
|
+
const unknown = uses.filter((u) => !has(source, u));
|
|
97
|
+
const missing = {};
|
|
98
|
+
const placeholders = [];
|
|
99
|
+
for (const l of locales) {
|
|
100
|
+
const msgs = byLocale[l];
|
|
101
|
+
const lacks = [...new Set(uses.filter((u) => has(source, u) && !has(msgs, u)).map((u) => u.key))];
|
|
102
|
+
if (lacks.length)
|
|
103
|
+
missing[l] = lacks;
|
|
104
|
+
for (const u of uses)
|
|
105
|
+
if (u.key in msgs && u.key in source && holes(msgs[u.key]) !== holes(source[u.key]))
|
|
106
|
+
placeholders.push({ locale: l, key: u.key });
|
|
107
|
+
}
|
|
108
|
+
if (suggest)
|
|
109
|
+
for (const x of loose)
|
|
110
|
+
x.suggest = byText.get(x.text.toLowerCase());
|
|
111
|
+
return { path: s.path, keys: new Set(uses.map((u) => u.key)).size, unknown, missing, placeholders, loose };
|
|
112
|
+
});
|
|
113
|
+
if (args.includes('--json'))
|
|
114
|
+
console.log(JSON.stringify({ source: sourceLocale(), locales, screens: report }, null, 2));
|
|
115
|
+
else {
|
|
116
|
+
if (!Object.keys(source).length)
|
|
117
|
+
console.log('No source messages: set i18n.messages in stitch2.config.json (see the stitch2-i18n skill).\n');
|
|
118
|
+
for (const r of report) {
|
|
119
|
+
const lines = [];
|
|
120
|
+
for (const u of r.unknown)
|
|
121
|
+
lines.push(` error line ${u.line}: ${u.key} is not in the ${sourceLocale()} messages`);
|
|
122
|
+
for (const [l, keys] of Object.entries(r.missing))
|
|
123
|
+
lines.push(` warn ${l} lacks ${keys.length}: ${keys.join(', ')}`);
|
|
124
|
+
for (const p of r.placeholders)
|
|
125
|
+
lines.push(` warn ${p.locale} ${p.key}: placeholders differ from ${sourceLocale()}`);
|
|
126
|
+
for (const x of r.loose)
|
|
127
|
+
lines.push(` text line ${x.line}${x.attr ? ` [${x.attr}]` : ''}: "${x.text.slice(0, 60)}" has no key${x.suggest ? ` (${x.suggest.join(' or ')}?)` : ''}`);
|
|
128
|
+
console.log(`${r.path} ${r.keys} keys${lines.length ? '' : ', all translated'}`);
|
|
129
|
+
if (lines.length)
|
|
130
|
+
console.log(lines.join('\n'));
|
|
131
|
+
}
|
|
132
|
+
const errors = report.reduce((n, r) => n + r.unknown.length, 0);
|
|
133
|
+
const loose = report.reduce((n, r) => n + r.loose.length, 0);
|
|
134
|
+
const lacking = locales.filter((l) => report.some((r) => r.missing[l]));
|
|
135
|
+
console.log(`\n${screens.length} screens, ${errors} unknown keys, ${loose} texts with no key${lacking.length ? `, missing translations in ${lacking.join(', ')}` : ''}.`);
|
|
136
|
+
}
|
|
137
|
+
if (report.some((r) => r.unknown.length))
|
|
138
|
+
process.exitCode = 1;
|
package/dist/i18n.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type I18nConfig } from './config.ts';
|
|
2
|
+
export type { I18nConfig };
|
|
3
|
+
export declare const PSEUDO: readonly ['pseudo', 'pseudo-rtl'];
|
|
4
|
+
export declare const i18nConfig: () => I18nConfig;
|
|
5
|
+
export declare const sourceLocale: () => string;
|
|
6
|
+
export declare const realLocales: () => string[];
|
|
7
|
+
export declare const isRtl: (locale: string) => boolean;
|
|
8
|
+
/** Every language the canvas offers: the source, the pseudo-languages, and the project's own. */
|
|
9
|
+
export declare const allLocales: () => string[];
|
|
10
|
+
/** The locales check runs: with an i18n block, the pseudo-languages plus the configured ones; else only --locales. */
|
|
11
|
+
export declare function checkLocales(override?: string): string[];
|
|
12
|
+
/**
|
|
13
|
+
* A locale's messages: JSON, or a JS/TS module (its default export, else its first exported object; Node strips
|
|
14
|
+
* the types). Re-read when the file changes. Empty when the file is missing.
|
|
15
|
+
*/
|
|
16
|
+
export declare function loadMessages(locale: string): Promise<Record<string, string>>;
|
|
17
|
+
/** What a page needs to show a locale: its messages over the source language's, and its direction. */
|
|
18
|
+
export declare function pagePayload(locale: string): Promise<{
|
|
19
|
+
locale: string;
|
|
20
|
+
lang: string;
|
|
21
|
+
dir: string;
|
|
22
|
+
pseudo: boolean;
|
|
23
|
+
messages: {
|
|
24
|
+
[x: string]: string;
|
|
25
|
+
};
|
|
26
|
+
missing: string[];
|
|
27
|
+
}>;
|
|
28
|
+
/** The script for a screen opened in a locale; serve adds it after pick mode has numbered the lines. */
|
|
29
|
+
export declare function localeScript(locale: string): Promise<string>;
|
package/dist/i18n.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Languages for screens. A screen is written in the source language and marks the text that belongs to the product
|
|
11
|
+
* with message keys from the project's own translation files:
|
|
12
|
+
*
|
|
13
|
+
* <span data-t="nav.library">Exercises</span> the element's text
|
|
14
|
+
* <gg-header title="Settings" data-t-title="settings.title"> an attribute (also comma lists: data-t-options)
|
|
15
|
+
* <p data-t="count.sets" data-t-args="count=16">16 sets</p> {count} filled in, plural form picked by count
|
|
16
|
+
* <x-row meta="5 exercises · Linear" data-t-meta="{plan.count|count=5} · {plan.linear}">
|
|
17
|
+
* a template: messages in braces, the rest kept
|
|
18
|
+
* <x-row meta="Sun, 4 Oct" data-t-meta="{@date|value=2026-10-04;weekday=short;day=numeric;month=short}">
|
|
19
|
+
* dates and numbers ({@number|value=7920}) by Intl
|
|
20
|
+
*
|
|
21
|
+
* Opened with ?lang=<locale> (the canvas's Language switch, and check), the page's text is replaced from that
|
|
22
|
+
* locale's messages before its components render, and the page gets lang and dir. Two pseudo-languages need no
|
|
23
|
+
* translations: "pseudo" makes every text about 40% longer with accented letters, "pseudo-rtl" lays the page out
|
|
24
|
+
* right to left. Project components can read the same messages with translate() from stitch2/elements.
|
|
25
|
+
*/
|
|
26
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
27
|
+
import { extname, resolve } from 'node:path';
|
|
28
|
+
import { pathToFileURL } from 'node:url';
|
|
29
|
+
import { BASE, config } from './config.js';
|
|
30
|
+
export const PSEUDO = ['pseudo', 'pseudo-rtl'];
|
|
31
|
+
const RTL = ['ar', 'he', 'fa', 'ur', 'ps', 'yi'];
|
|
32
|
+
export const i18nConfig = () => config.i18n ?? {};
|
|
33
|
+
export const sourceLocale = () => i18nConfig().source ?? 'en';
|
|
34
|
+
export const realLocales = () => i18nConfig().locales?.filter((l) => l !== sourceLocale()) ?? [];
|
|
35
|
+
export const isRtl = (locale) => locale === 'pseudo-rtl' || (i18nConfig().rtl ?? RTL).includes(locale.split('-')[0].toLowerCase());
|
|
36
|
+
/** Every language the canvas offers: the source, the pseudo-languages, and the project's own. */
|
|
37
|
+
export const allLocales = () => [sourceLocale(), ...PSEUDO, ...realLocales()];
|
|
38
|
+
/** The locales check runs: with an i18n block, the pseudo-languages plus the configured ones; else only --locales. */
|
|
39
|
+
export function checkLocales(override) {
|
|
40
|
+
if (override === 'none')
|
|
41
|
+
return [];
|
|
42
|
+
const list = override ? (override === 'all' ? [...PSEUDO, ...realLocales()] : override.split(',')) : undefined;
|
|
43
|
+
if (list)
|
|
44
|
+
return list.filter((l) => l !== sourceLocale());
|
|
45
|
+
if (!config.i18n)
|
|
46
|
+
return [];
|
|
47
|
+
const c = i18nConfig().check;
|
|
48
|
+
return [...PSEUDO, ...(c === 'all' ? realLocales() : (c ?? []))];
|
|
49
|
+
}
|
|
50
|
+
function messagesFile(locale) {
|
|
51
|
+
const m = i18nConfig().messages;
|
|
52
|
+
if (!m)
|
|
53
|
+
return undefined;
|
|
54
|
+
const pattern = typeof m === 'string' ? m : (m[locale] ?? m['*']);
|
|
55
|
+
return pattern ? resolve(BASE, pattern.replaceAll('{locale}', locale)) : undefined;
|
|
56
|
+
}
|
|
57
|
+
/** Nested objects become dotted keys; only strings are kept. */
|
|
58
|
+
function flatten(o, prefix = '', out = {}) {
|
|
59
|
+
if (o && typeof o === 'object')
|
|
60
|
+
for (const [k, v] of Object.entries(o)) {
|
|
61
|
+
const key = prefix ? prefix + '.' + k : k;
|
|
62
|
+
if (typeof v === 'string')
|
|
63
|
+
out[key] = v;
|
|
64
|
+
else
|
|
65
|
+
flatten(v, key, out);
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
const cache = new Map();
|
|
70
|
+
/**
|
|
71
|
+
* A locale's messages: JSON, or a JS/TS module (its default export, else its first exported object; Node strips
|
|
72
|
+
* the types). Re-read when the file changes. Empty when the file is missing.
|
|
73
|
+
*/
|
|
74
|
+
export async function loadMessages(locale) {
|
|
75
|
+
const file = messagesFile(locale);
|
|
76
|
+
if (!file || !existsSync(file))
|
|
77
|
+
return {};
|
|
78
|
+
const at = statSync(file).mtimeMs;
|
|
79
|
+
const hit = cache.get(file);
|
|
80
|
+
if (hit && hit.at === at)
|
|
81
|
+
return hit.messages;
|
|
82
|
+
let data;
|
|
83
|
+
if (extname(file) === '.json')
|
|
84
|
+
data = JSON.parse(readFileSync(file, 'utf8'));
|
|
85
|
+
else {
|
|
86
|
+
const mod = (await import(__rewriteRelativeImportExtension(pathToFileURL(file).href + '?t=' + at)));
|
|
87
|
+
data = mod.default ?? Object.values(mod).find((v) => v && typeof v === 'object');
|
|
88
|
+
}
|
|
89
|
+
const messages = flatten(data);
|
|
90
|
+
cache.set(file, { at, messages });
|
|
91
|
+
return messages;
|
|
92
|
+
}
|
|
93
|
+
/** What a page needs to show a locale: its messages over the source language's, and its direction. */
|
|
94
|
+
export async function pagePayload(locale) {
|
|
95
|
+
const pseudo = PSEUDO.includes(locale);
|
|
96
|
+
const source = await loadMessages(sourceLocale());
|
|
97
|
+
const own = pseudo || locale === sourceLocale() ? {} : await loadMessages(locale);
|
|
98
|
+
return {
|
|
99
|
+
locale,
|
|
100
|
+
lang: locale === 'pseudo' ? sourceLocale() + '-XA' : locale === 'pseudo-rtl' ? 'ar-XB' : locale,
|
|
101
|
+
dir: isRtl(locale) ? 'rtl' : 'ltr',
|
|
102
|
+
pseudo: locale === 'pseudo',
|
|
103
|
+
// Keys the locale lacks fall back to the source language, and are reported.
|
|
104
|
+
messages: { ...source, ...own },
|
|
105
|
+
missing: pseudo || locale === sourceLocale() ? [] : Object.keys(source).filter((k) => !(k in own)),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/** Runs in the page. Plain JS on purpose: it is sent as source into the screen's head. */
|
|
109
|
+
function runtime(p) {
|
|
110
|
+
const missing = new Set(p.missing);
|
|
111
|
+
const plural = new Intl.PluralRules(p.lang);
|
|
112
|
+
// {@date|value=…;weekday=short…} and {@number|value=…;maximumFractionDigits=1…}: Intl's options, in the locale.
|
|
113
|
+
const intl = (key, args) => {
|
|
114
|
+
const opts = {};
|
|
115
|
+
for (const [k, v] of Object.entries(args))
|
|
116
|
+
if (k !== 'value')
|
|
117
|
+
opts[k] = v === 'true' ? true : v === 'false' ? false : /^\d+$/.test(v) ? Number(v) : v;
|
|
118
|
+
try {
|
|
119
|
+
return key === '@date'
|
|
120
|
+
? // As written, whatever the viewer's time zone: the value is read and shown in UTC.
|
|
121
|
+
new Intl.DateTimeFormat(p.lang, { timeZone: 'UTC', ...opts }).format(new Date(/T.*(Z|[+-]\d\d:?\d\d)$/.test(args.value ?? '') || !/T/.test(args.value ?? '') ? (args.value ?? '') : args.value + 'Z'))
|
|
122
|
+
: new Intl.NumberFormat(p.lang, opts).format(Number(args.value));
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const t = (key, args) => {
|
|
129
|
+
if (key === '@date' || key === '@number')
|
|
130
|
+
return intl(key, args ?? {});
|
|
131
|
+
let msg;
|
|
132
|
+
if (args && args.count !== undefined) {
|
|
133
|
+
const n = Number(args.count);
|
|
134
|
+
msg = p.messages[key + '.' + plural.select(n)] ?? p.messages[key + '.other'];
|
|
135
|
+
}
|
|
136
|
+
msg = msg ?? p.messages[key];
|
|
137
|
+
if (msg === undefined)
|
|
138
|
+
return undefined;
|
|
139
|
+
return msg.replace(/\{(\w+)\}/g, (m, k) => (args && args[k] !== undefined ? args[k] : m));
|
|
140
|
+
};
|
|
141
|
+
const known = (key) => key.startsWith('@') || key in p.messages || key + '.other' in p.messages;
|
|
142
|
+
const lacks = (key) => missing.has(key) || missing.has(key + '.other') || !known(key);
|
|
143
|
+
const parseArgs = (s, sep = ',') => {
|
|
144
|
+
const out = {};
|
|
145
|
+
for (const part of (s ?? '').split(sep)) {
|
|
146
|
+
const i = part.indexOf('=');
|
|
147
|
+
if (i > 0)
|
|
148
|
+
out[part.slice(0, i).trim()] = part.slice(i + 1).trim();
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
};
|
|
152
|
+
const lostList = [];
|
|
153
|
+
window.__stitch2 = { locale: p.locale, lang: p.lang, dir: p.dir, t, missing: lostList };
|
|
154
|
+
document.documentElement.lang = p.lang;
|
|
155
|
+
document.documentElement.dir = p.dir;
|
|
156
|
+
let done = false;
|
|
157
|
+
const translate = () => {
|
|
158
|
+
if (done)
|
|
159
|
+
return;
|
|
160
|
+
done = true;
|
|
161
|
+
for (const el of Array.from(document.querySelectorAll('*'))) {
|
|
162
|
+
const keys = [];
|
|
163
|
+
for (const a of Array.from(el.attributes)) {
|
|
164
|
+
if (a.name !== 'data-t' && !a.name.startsWith('data-t-'))
|
|
165
|
+
continue;
|
|
166
|
+
if (a.name === 'data-t-args' || a.name === 'data-t-skip')
|
|
167
|
+
continue;
|
|
168
|
+
const args = parseArgs(el.getAttribute('data-t-args'));
|
|
169
|
+
let v;
|
|
170
|
+
if (a.value.includes('{')) {
|
|
171
|
+
// A template: each {key} or {key|name=value;name=value} becomes its message, the rest stays as written.
|
|
172
|
+
let whole = true;
|
|
173
|
+
v = a.value.replace(/\{([^{}|]+)(?:\|([^}]*))?\}/g, (_m, key, inline) => {
|
|
174
|
+
keys.push(key);
|
|
175
|
+
const r = t(key, { ...args, ...parseArgs(inline, ';') });
|
|
176
|
+
if (r === undefined)
|
|
177
|
+
whole = false;
|
|
178
|
+
return r ?? '';
|
|
179
|
+
});
|
|
180
|
+
if (!whole)
|
|
181
|
+
v = undefined;
|
|
182
|
+
}
|
|
183
|
+
else if (a.name === 'data-t') {
|
|
184
|
+
v = t(a.value, args);
|
|
185
|
+
keys.push(a.value);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
// A list attribute (options="A,B"): one key per item.
|
|
189
|
+
const parts = a.value.split(',').map((k) => k.trim());
|
|
190
|
+
const vals = parts.map((k) => t(k, args));
|
|
191
|
+
if (vals.every((x) => x !== undefined))
|
|
192
|
+
v = vals.join(',');
|
|
193
|
+
keys.push(...parts);
|
|
194
|
+
}
|
|
195
|
+
if (v === undefined)
|
|
196
|
+
continue;
|
|
197
|
+
if (a.name === 'data-t')
|
|
198
|
+
el.textContent = v;
|
|
199
|
+
else
|
|
200
|
+
el.setAttribute(a.name.slice(7), v);
|
|
201
|
+
}
|
|
202
|
+
if (!keys.length)
|
|
203
|
+
continue;
|
|
204
|
+
el.setAttribute('data-s2-key', keys.join(' '));
|
|
205
|
+
const lost = keys.filter(lacks);
|
|
206
|
+
if (lost.length) {
|
|
207
|
+
el.setAttribute('data-s2-i18n-missing', lost.join(' '));
|
|
208
|
+
lostList.push({
|
|
209
|
+
keys: lost.join(' '),
|
|
210
|
+
all: keys.join(' '),
|
|
211
|
+
text: (el.textContent ?? '').trim().slice(0, 60),
|
|
212
|
+
// Not even in the source language: the screen shows its own text in every language.
|
|
213
|
+
unknown: lost.filter((k) => !known(k)).join(' '),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
// Pseudo-language: every visible text, accented and about 40% longer, bracketed so a cut end shows.
|
|
219
|
+
const MAP = {
|
|
220
|
+
a: 'á', b: 'ƀ', c: 'ç', d: 'ð', e: 'é', f: 'ƒ', g: 'ĝ', h: 'ĥ', i: 'í', j: 'ĵ', k: 'ķ', l: 'ļ', m: 'ɱ', n: 'ñ',
|
|
221
|
+
o: 'ó', p: 'þ', r: 'ŕ', s: 'š', t: 'ţ', u: 'ú', w: 'ŵ', y: 'ý', z: 'ž',
|
|
222
|
+
A: 'Á', B: 'Ɓ', C: 'Ç', D: 'Ð', E: 'É', G: 'Ĝ', H: 'Ĥ', I: 'Í', J: 'Ĵ', K: 'Ķ', L: 'Ļ', N: 'Ñ', O: 'Ó',
|
|
223
|
+
R: 'Ŕ', S: 'Š', T: 'Ţ', U: 'Ú', W: 'Ŵ', Y: 'Ý', Z: 'Ž',
|
|
224
|
+
};
|
|
225
|
+
const seen = new WeakSet();
|
|
226
|
+
const stretch = (s) => {
|
|
227
|
+
const core = s.trim();
|
|
228
|
+
if (!/\p{L}/u.test(core))
|
|
229
|
+
return s;
|
|
230
|
+
const extra = Math.max(1, Math.round(core.length * 0.3));
|
|
231
|
+
const lead = s.slice(0, s.indexOf(core));
|
|
232
|
+
return lead + '[' + core.replace(/[A-Za-z]/g, (c) => MAP[c] ?? c) + ' ' + '·'.repeat(extra) + ']' + s.slice(lead.length + core.length);
|
|
233
|
+
};
|
|
234
|
+
const SKIP = 'script,style,code,pre,textarea,[data-t-skip],svg';
|
|
235
|
+
const pseudoAll = (root) => {
|
|
236
|
+
const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
237
|
+
for (let n = w.nextNode(); n; n = w.nextNode()) {
|
|
238
|
+
if (seen.has(n) || n.parentElement?.closest(SKIP))
|
|
239
|
+
continue;
|
|
240
|
+
seen.add(n);
|
|
241
|
+
n.textContent = stretch(n.textContent ?? '');
|
|
242
|
+
}
|
|
243
|
+
if (root instanceof Element || root instanceof Document)
|
|
244
|
+
for (const el of Array.from(root.querySelectorAll('[placeholder]'))) {
|
|
245
|
+
if (el.hasAttribute('data-s2-pseudo'))
|
|
246
|
+
continue;
|
|
247
|
+
el.setAttribute('data-s2-pseudo', '');
|
|
248
|
+
el.setAttribute('placeholder', stretch(el.getAttribute('placeholder') ?? ''));
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
// Translate once the body is parsed and before components render: the first customElements.define (the
|
|
252
|
+
// components' deferred bundle) or DOMContentLoaded, whichever comes first.
|
|
253
|
+
const define = customElements.define.bind(customElements);
|
|
254
|
+
customElements.define = (name, cls, opts) => {
|
|
255
|
+
translate();
|
|
256
|
+
return define(name, cls, opts);
|
|
257
|
+
};
|
|
258
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
259
|
+
translate();
|
|
260
|
+
if (!p.pseudo)
|
|
261
|
+
return;
|
|
262
|
+
pseudoAll(document.body);
|
|
263
|
+
new MutationObserver((list) => {
|
|
264
|
+
for (const m of list)
|
|
265
|
+
for (const n of Array.from(m.addedNodes))
|
|
266
|
+
pseudoAll(n);
|
|
267
|
+
}).observe(document.body, { childList: true, subtree: true });
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
/** The script for a screen opened in a locale; serve adds it after pick mode has numbered the lines. */
|
|
271
|
+
export async function localeScript(locale) {
|
|
272
|
+
const payload = await pagePayload(locale);
|
|
273
|
+
const json = JSON.stringify(payload).replaceAll('<', '\\u003c');
|
|
274
|
+
return '<script>(' + runtime.toString() + ')(' + json + ')</script>';
|
|
275
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function languagesPage(only?: string): string;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Languages page (/languages on the canvas server): each screen in a row, once per language, so a label that
|
|
3
|
+
* only breaks in German or a layout that flips wrong in Arabic shows next to the others. ?screen=<path> shows one
|
|
4
|
+
* screen; otherwise every active screen. Starts with the check languages; "All" adds every locale.
|
|
5
|
+
*/
|
|
6
|
+
import { allLocales, checkLocales, sourceLocale } from './i18n.js';
|
|
7
|
+
import { listScreens } from './screens.js';
|
|
8
|
+
export function languagesPage(only) {
|
|
9
|
+
const screens = listScreens()
|
|
10
|
+
.filter((s) => (only ? s.path === only : s.kind === 'screen' && s.status !== 'archived'))
|
|
11
|
+
.map((s) => ({ path: s.path, label: s.kind === 'components' ? 'Component catalog' : s.screen + ' ' + s.device + ' ' + s.version, width: s.width }));
|
|
12
|
+
const data = { screens, source: sourceLocale(), check: [sourceLocale(), ...checkLocales()], all: allLocales() };
|
|
13
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>Languages</title><style>
|
|
14
|
+
body { background: #0f0f11; color: #e4e4e7; font: 13px/1.4 system-ui, sans-serif; margin: 0; }
|
|
15
|
+
header { align-items: center; background: #0f0f11; border-bottom: 1px solid #26262b; display: flex; gap: 12px; padding: 12px 140px 12px 20px; position: sticky; top: 0; z-index: 1; }
|
|
16
|
+
header h1 { font-size: 15px; margin: 0 8px 0 0; }
|
|
17
|
+
header p { color: #71717a; margin: 0 0 0 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
18
|
+
.seg { background: #18181b; border: 1px solid #26262b; border-radius: 8px; display: flex; padding: 2px; }
|
|
19
|
+
.seg button { background: none; border: 0; border-radius: 6px; color: #a1a1aa; cursor: pointer; font: inherit; padding: 4px 10px; }
|
|
20
|
+
.seg button.on { background: #27272a; color: #fafafa; }
|
|
21
|
+
section { padding: 16px 20px 8px; }
|
|
22
|
+
section h2 { color: #a1a1aa; font-size: 13px; font-weight: 600; margin: 0 0 10px; }
|
|
23
|
+
section h2 code { color: #71717a; font-weight: 400; margin-left: 8px; }
|
|
24
|
+
.row { align-items: flex-start; display: flex; gap: 24px; overflow-x: auto; padding-bottom: 16px; }
|
|
25
|
+
.frame { flex: none; }
|
|
26
|
+
.frame .label { align-items: center; color: #a1a1aa; display: flex; flex-wrap: wrap; gap: 4px 6px; margin-bottom: 6px; min-width: 0; white-space: nowrap; }
|
|
27
|
+
.frame .label .n { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
28
|
+
.frame .label > :not(.n) { flex: none; }
|
|
29
|
+
.frame .label b { color: #fafafa; }
|
|
30
|
+
.frame .miss { background: #422006; border-radius: 99px; color: #fbbf24; font-size: 11px; padding: 1px 7px; }
|
|
31
|
+
.frame .label button { background: #18181b; border: 1px solid #26262b; border-radius: 6px; color: #a1a1aa; cursor: pointer; font: inherit; font-size: 11px; margin-left: auto; padding: 1px 7px; }
|
|
32
|
+
.frame .box { border-radius: 14px; overflow: hidden; }
|
|
33
|
+
.frame iframe { background: #fff; border: 0; display: block; }
|
|
34
|
+
</style></head><body>
|
|
35
|
+
<header><h1>Languages</h1>
|
|
36
|
+
<div class="seg" id="set"><button type="button" data-v="check" title="The source, the pseudo-languages and the ones check runs">Check languages</button><button type="button" data-v="all">All</button></div>
|
|
37
|
+
<div class="seg" id="zoom"><button type="button" data-v="0.4">40%</button><button type="button" data-v="0.6">60%</button><button type="button" data-v="1">100%</button></div>
|
|
38
|
+
<p>Pseudo: every text about 40% longer, and right to left. Copy a frame to reference it.</p></header>
|
|
39
|
+
<main></main>
|
|
40
|
+
<script>
|
|
41
|
+
const D = ${JSON.stringify(data).replaceAll('<', '\\u003c')}
|
|
42
|
+
const theme = localStorage.getItem('stitch2-theme')
|
|
43
|
+
let set = localStorage.getItem('stitch2-lang-set') ?? 'check'
|
|
44
|
+
let zoom = Number(localStorage.getItem('stitch2-lang-zoom') ?? 0.4)
|
|
45
|
+
const name = (l) => l === 'pseudo' ? 'Pseudo: long text' : l === 'pseudo-rtl' ? 'Pseudo: right to left' : (new Intl.DisplayNames([l], { type: 'language' }).of(l) ?? l) + (l === D.source ? ', source' : '')
|
|
46
|
+
async function copy(text) {
|
|
47
|
+
try { await navigator.clipboard.writeText(text) } catch { const t = document.createElement('textarea'); t.value = text; document.body.append(t); t.select(); document.execCommand('copy'); t.remove() }
|
|
48
|
+
}
|
|
49
|
+
function render() {
|
|
50
|
+
for (const b of document.querySelectorAll('#set button')) b.classList.toggle('on', b.dataset.v === set)
|
|
51
|
+
for (const b of document.querySelectorAll('#zoom button')) b.classList.toggle('on', Number(b.dataset.v) === zoom)
|
|
52
|
+
const langs = set === 'all' ? D.all : D.check
|
|
53
|
+
const main = document.querySelector('main')
|
|
54
|
+
main.innerHTML = ''
|
|
55
|
+
if (!D.screens.length) main.innerHTML = '<section><h2>No screens.</h2></section>'
|
|
56
|
+
for (const s of D.screens) {
|
|
57
|
+
const sec = document.createElement('section')
|
|
58
|
+
sec.innerHTML = '<h2></h2><div class="row"></div>'
|
|
59
|
+
sec.querySelector('h2').textContent = s.label
|
|
60
|
+
const code = document.createElement('code'); code.textContent = s.path; sec.querySelector('h2').append(code)
|
|
61
|
+
for (const l of langs) {
|
|
62
|
+
const f = document.createElement('div')
|
|
63
|
+
f.className = 'frame'
|
|
64
|
+
f.style.width = s.width * zoom + 'px'
|
|
65
|
+
f.innerHTML = '<div class="label"><b></b><span class="n"></span><span class="miss" hidden></span><button type="button">Copy</button></div><div class="box"><iframe loading="lazy" scrolling="no"></iframe></div>'
|
|
66
|
+
f.querySelector('b').textContent = l
|
|
67
|
+
f.querySelector('.n').textContent = name(l)
|
|
68
|
+
f.querySelector('button').onclick = () => copy(s.label + (l === D.source ? '' : ' in ' + l) + ' (' + s.path + ')')
|
|
69
|
+
const box = f.querySelector('.box')
|
|
70
|
+
const frame = f.querySelector('iframe')
|
|
71
|
+
frame.style.width = s.width + 'px'
|
|
72
|
+
frame.style.height = '844px'
|
|
73
|
+
frame.style.zoom = zoom
|
|
74
|
+
frame.src = '/' + s.path + (l === D.source ? '' : '?lang=' + encodeURIComponent(l))
|
|
75
|
+
const measure = () => {
|
|
76
|
+
try {
|
|
77
|
+
const doc = frame.contentDocument
|
|
78
|
+
frame.style.height = Math.max(doc.documentElement.scrollHeight, 400) + 'px'
|
|
79
|
+
const miss = frame.contentWindow.__stitch2?.missing ?? []
|
|
80
|
+
const m = f.querySelector('.miss')
|
|
81
|
+
m.hidden = !miss.length
|
|
82
|
+
m.textContent = miss.length + ' missing'
|
|
83
|
+
f.querySelector('.n').hidden = !!miss.length && zoom < 0.6
|
|
84
|
+
m.title = miss.map((x) => x.keys).join(', ')
|
|
85
|
+
} catch {}
|
|
86
|
+
}
|
|
87
|
+
frame.addEventListener('load', () => {
|
|
88
|
+
if (theme) try { frame.contentDocument.documentElement.dataset.theme = theme } catch {}
|
|
89
|
+
measure(); setTimeout(measure, 800); setTimeout(measure, 2500)
|
|
90
|
+
})
|
|
91
|
+
sec.querySelector('.row').append(f)
|
|
92
|
+
}
|
|
93
|
+
main.append(sec)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
for (const b of document.querySelectorAll('#set button')) b.onclick = () => { set = b.dataset.v; localStorage.setItem('stitch2-lang-set', set); render() }
|
|
97
|
+
for (const b of document.querySelectorAll('#zoom button')) b.onclick = () => { zoom = Number(b.dataset.v); localStorage.setItem('stitch2-lang-zoom', String(zoom)); render() }
|
|
98
|
+
render()
|
|
99
|
+
</script></body></html>`;
|
|
100
|
+
}
|
package/dist/pick.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
/** Tags that are never picked by themselves: their nearest picked ancestor stands for them. */
|
|
10
10
|
const SKIP = new Set(['body', 'br', 'i', 'script', 'style', 'template', 'path', 'line', 'polyline', 'polygon', 'circle', 'ellipse', 'rect', 'g', 'defs', 'stop', 'use', 'text', 'tspan']);
|
|
11
11
|
/** Runs in the screen before its own scripts: components that replace themselves pass their line on. */
|
|
12
|
-
const SHIM = `<script>(()=>{const d=customElements.define.bind(customElements);customElements.define=(name,cls,opts)=>{const p=cls.prototype,cb=p.connectedCallback;if(cb)p.connectedCallback=function(...a){const line=this.getAttribute('data-s2-line'),parent=this.parentNode,before=this.previousSibling,after=this.nextSibling;const r=cb.apply(this,a);if(line){if(this.isConnected)this.setAttribute('data-s2-tag',name);else if(parent)for(let n=before?before.nextSibling:parent.firstChild;n&&n!==after;n=n.nextSibling)if(n.nodeType===1&&!n.hasAttribute('data-s2-line')){n.setAttribute('data-s2-line',line);n.setAttribute('data-s2-tag',name)}}return r};return d(name,cls,opts)}})()</script>`;
|
|
12
|
+
const SHIM = `<script>(()=>{const d=customElements.define.bind(customElements);customElements.define=(name,cls,opts)=>{const p=cls.prototype,cb=p.connectedCallback;if(cb)p.connectedCallback=function(...a){const line=this.getAttribute('data-s2-line'),parent=this.parentNode,before=this.previousSibling,after=this.nextSibling;const r=cb.apply(this,a);if(line){const key=this.getAttribute('data-s2-key');if(this.isConnected)this.setAttribute('data-s2-tag',name);else if(parent)for(let n=before?before.nextSibling:parent.firstChild;n&&n!==after;n=n.nextSibling)if(n.nodeType===1&&!n.hasAttribute('data-s2-line')){n.setAttribute('data-s2-line',line);n.setAttribute('data-s2-tag',name);if(key)n.setAttribute('data-s2-key',key)}}return r};return d(name,cls,opts)}})()</script>`;
|
|
13
13
|
/** The page with source lines on its body's elements and the shim at the start of its head. */
|
|
14
14
|
export function annotate(html) {
|
|
15
15
|
const bodyAt = html.search(/<body[\s>]/i);
|
package/dist/serve.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The stitch2 server: the canvas at /, DESIGN.md drawn at /design, the screen list at /api/screens
|
|
2
|
+
* The stitch2 server: the canvas at /, DESIGN.md drawn at /design, screens side by side per language at /languages, the screen list at /api/screens
|
|
3
3
|
* (POST /api/screens/meta sets a version's status or note), check results at /api/checks, live-reload events
|
|
4
4
|
* at /events, and every file under design/ by its path. Local only (127.0.0.1).
|
|
5
5
|
* Usage: stitch2 canvas (PORT=4400 by default)
|
|
@@ -10,7 +10,9 @@ import { dirname, extname, join, normalize } from 'node:path';
|
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
11
|
import { CONFIG_FILE, config } from './config.js';
|
|
12
12
|
import { designThemes } from './design-md.js';
|
|
13
|
+
import { allLocales, localeScript, sourceLocale } from './i18n.js';
|
|
13
14
|
import { designPage } from './design-page.js';
|
|
15
|
+
import { languagesPage } from './languages-page.js';
|
|
14
16
|
import { annotate } from './pick.js';
|
|
15
17
|
/**
|
|
16
18
|
* Inlines <link rel="stylesheet" type="text/tailwindcss" href="…">: the Tailwind v4 browser build reads @theme and
|
|
@@ -71,6 +73,10 @@ export function startServer(port) {
|
|
|
71
73
|
res.writeHead(200, { 'content-type': TYPES['.html'], 'cache-control': 'no-store' });
|
|
72
74
|
return res.end(designPage());
|
|
73
75
|
}
|
|
76
|
+
if (url.pathname === '/languages') {
|
|
77
|
+
res.writeHead(200, { 'content-type': TYPES['.html'], 'cache-control': 'no-store' });
|
|
78
|
+
return res.end(languagesPage(url.searchParams.get('screen') ?? undefined));
|
|
79
|
+
}
|
|
74
80
|
if (url.pathname === '/compare/index.html' && !existsSync(join(ROOT, 'compare/index.html'))) {
|
|
75
81
|
res.writeHead(200, { 'content-type': TYPES['.html'], 'cache-control': 'no-store' });
|
|
76
82
|
return res.end('<body style="background:#0f0f11;color:#a1a1aa;font:15px system-ui;padding:32px">No comparison yet: run <code>stitch2 compare</code> with the app running.</body>');
|
|
@@ -115,7 +121,7 @@ export function startServer(port) {
|
|
|
115
121
|
return;
|
|
116
122
|
}
|
|
117
123
|
if (url.pathname === '/api/config')
|
|
118
|
-
return json({ ...config, themes: designThemes() });
|
|
124
|
+
return json({ ...config, themes: designThemes(), locales: allLocales(), sourceLocale: sourceLocale() });
|
|
119
125
|
if (url.pathname === '/api/screens')
|
|
120
126
|
return json(listScreens());
|
|
121
127
|
if (url.pathname === '/api/checks') {
|
|
@@ -145,7 +151,13 @@ export function startServer(port) {
|
|
|
145
151
|
// The canvas loads screens with ?pick: their elements carry source lines for picking.
|
|
146
152
|
if (extname(file) === '.html') {
|
|
147
153
|
const html = inlineTailwind(readFileSync(file, 'utf8'), file);
|
|
148
|
-
|
|
154
|
+
const page = url.searchParams.has('pick') ? annotate(html) : html;
|
|
155
|
+
const lang = url.searchParams.get('lang');
|
|
156
|
+
if (!lang || lang === sourceLocale())
|
|
157
|
+
return res.end(page);
|
|
158
|
+
// After the lines are numbered: the locale's script may span several lines.
|
|
159
|
+
void localeScript(lang).then((script) => res.end(page.replace(/<head(\s[^>]*)?>/i, (h) => h + script)), (e) => res.end(page.replace(/<body([^>]*)>/i, (b) => b + '<pre style="color:#e5484d">stitch2: ' + String(e) + '</pre>')));
|
|
160
|
+
return;
|
|
149
161
|
}
|
|
150
162
|
else
|
|
151
163
|
res.end(readFileSync(file));
|
package/dist/typecheck.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export interface Issue {
|
|
|
11
11
|
text: string;
|
|
12
12
|
/** Set when the issue depends on the theme (contrast): the data-theme it was found in. */
|
|
13
13
|
theme?: string;
|
|
14
|
+
/** Set when the issue shows only in another language: the locale it was found in. */
|
|
15
|
+
locale?: string;
|
|
14
16
|
}
|
|
15
17
|
export interface TextItem {
|
|
16
18
|
selector: string;
|
package/package.json
CHANGED
package/skills/stitch2/SKILL.md
CHANGED
|
@@ -42,7 +42,8 @@ References, read before the first screen:
|
|
|
42
42
|
4. **Build** the next version, `<screens>/<screen>/<device>-v<N>.html`, from the project's template (status
|
|
43
43
|
`draft`; never edit an approved version). Assemble it from the components; plain markup only where none
|
|
44
44
|
applies, with token classes, `.type-<level>` classes for text, the spacing scale, and the states DESIGN.md
|
|
45
|
-
defines.
|
|
45
|
+
defines. When the config has `i18n`, mark every product string with its message key (`data-t`, see
|
|
46
|
+
**stitch2-i18n**). Never add classes to a component to change how it looks, and never write a colour value (hex,
|
|
46
47
|
`rgb()`, `hsl()`): only DESIGN.md's colours, as token classes or `var(--<prefix>-<name>)`.
|
|
47
48
|
5. **Check** `stitch2 check <path> --shots --strict`. Fix every error (overflow, wrapped labels, content under
|
|
48
49
|
fixed bars, fixed bars overlapping each other, tap targets, contrast, cramped text) and every warning about type (`type-*`), hand-built
|
|
@@ -51,7 +52,9 @@ References, read before the first screen:
|
|
|
51
52
|
short with an ellipsis (`truncated`) is a warning to fix with room, not a smaller size. When DESIGN.md has a
|
|
52
53
|
light and a dark theme, every screen is checked in both: an issue marked with a theme
|
|
53
54
|
("light theme: Low contrast…") is fixed in DESIGN.md's colours for that theme when it shows on many
|
|
54
|
-
screens, or in the screen when it is one element. Look at both renders (`--light.png`).
|
|
55
|
+
screens, or in the screen when it is one element. Look at both renders (`--light.png`). With `i18n`, check
|
|
56
|
+
also runs the screen in the pseudo-languages and the config's locales; fix what they flag as the
|
|
57
|
+
**stitch2-i18n** skill says.
|
|
55
58
|
6. **Look and critique.** View the render in `<design>/renders/` (crop the first screenful). Score it with the
|
|
56
59
|
rubric in principles.md, fix what fails, repeat 5–6 at most three times.
|
|
57
60
|
7. **Hand over.** Set the version's status to `review`, point the user to it on the canvas, and report what
|
|
@@ -65,7 +68,8 @@ References, read before the first screen:
|
|
|
65
68
|
|---|---|
|
|
66
69
|
| `setup [--name N --primary #hex] [options]` | Sets a project up in one step; keeps every file that exists |
|
|
67
70
|
| `canvas` | The canvas at http://localhost:4400: screens and versions, DESIGN.md, the consistency report, approval and notes |
|
|
68
|
-
| `check [filter] [--shots] [--strict]` | Layout, type, reuse and consistency checks; `--shots` saves renders |
|
|
71
|
+
| `check [filter] [--shots] [--strict] [--locales l,…]` | Layout, type, reuse and consistency checks, in every theme and check language; `--shots` saves renders |
|
|
72
|
+
| `i18n [filter] [--locale l] [--suggest]` | Message keys screens use that the source or a locale lacks, and text with no key (stitch2-i18n) |
|
|
69
73
|
| `consistency [kind…]` | Every shared component cropped from every screen, next to the catalog's version |
|
|
70
74
|
| `type [filter] [--json]` | Every text style a screen uses and the type level it matches |
|
|
71
75
|
| `screens [name] [--status s] [--json]` | Every version with its status and the user's note |
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: stitch2-i18n
|
|
3
|
+
description: Design screens that work in every language the product ships — text marked with the project's message keys, checked in pseudo-languages, right to left and the real locales, side by side on the canvas. Use when a project has i18n in stitch2.config.json, when writing or changing screen text, when the user mentions languages, translations, RTL or a label that breaks in one language, or before handing over screens of a multilingual product.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Screens in every language with stitch2
|
|
7
|
+
|
|
8
|
+
A screen is written once, in the source language, and every product string in it carries a **message key** from
|
|
9
|
+
the project's own translation files. stitch2 replaces the text from those files when the screen is opened in
|
|
10
|
+
another language, so one HTML file shows every locale, and the screen and the app share the same strings.
|
|
11
|
+
The checks then run each screen in the languages that break layouts: **pseudo** (every text about 40% longer,
|
|
12
|
+
accented, in brackets, so a cut end shows), **pseudo-rtl** (right to left) and the locales in the config.
|
|
13
|
+
|
|
14
|
+
## Setup
|
|
15
|
+
|
|
16
|
+
`stitch2.config.json` names the messages; paths are relative to it:
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
"i18n": {
|
|
20
|
+
"source": "en",
|
|
21
|
+
"locales": ["en", "de", "ru", "ar"],
|
|
22
|
+
"messages": { "en": "src/i18n/en.ts", "*": "src/i18n/locales/{locale}.ts" },
|
|
23
|
+
"check": ["de", "ar"]
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`messages` is one pattern (`"src/i18n/{locale}.json"`) or a map with `*` for the rest. JSON, or a JS/TS module:
|
|
28
|
+
its default export, else its first exported object. Nested objects become dotted keys (`nav.home`). Plurals
|
|
29
|
+
are objects keyed by CLDR category: `{ "one": "{count} set", "other": "{count} sets" }`. `check` lists the
|
|
30
|
+
locales `stitch2 check` runs besides the two pseudo-languages; pick the longest (German, Finnish, Russian) and
|
|
31
|
+
one right-to-left language, or `"all"`.
|
|
32
|
+
|
|
33
|
+
Use the app's own files. Never copy messages into the design folder: two copies drift.
|
|
34
|
+
|
|
35
|
+
## Marking text
|
|
36
|
+
|
|
37
|
+
```html
|
|
38
|
+
<span data-t="nav.home">Home</span> <!-- the element's text -->
|
|
39
|
+
<s2-header title="Settings" data-t-title="settings.title"> <!-- an attribute, before the component renders -->
|
|
40
|
+
<input placeholder="Search" data-t-placeholder="exercises.search" />
|
|
41
|
+
<p data-t="workout.sets" data-t-args="count=16">16 sets</p> <!-- {count} filled in, plural form by count -->
|
|
42
|
+
<p translate="no">Dumbbell Bench Press</p> <!-- sample content: names, user input, numbers -->
|
|
43
|
+
<s2-row meta="5 exercises · Linear" data-t-meta="{plan.exercises|count=5} · {plan.linear}">
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
A value with braces is a **template**: each `{key}` or `{key|name=value;name=value}` becomes its message and
|
|
47
|
+
the rest stays as written. Use it for lines the app builds from several messages and data (a meta line, a
|
|
48
|
+
list of `label=url` links: `data-t-links="{site.features}=#features,{site.docs}=/docs"`); sample data inside
|
|
49
|
+
it stays literal. Without braces, `data-t-<attr>` on a comma list takes one key per item.
|
|
50
|
+
|
|
51
|
+
Dates and numbers are written by the locale too: `{@date|value=2026-10-04;weekday=short;day=numeric;month=short}`
|
|
52
|
+
("Sun, 4 Oct", "вс, 4 окт.") and `{@number|value=7920}` ("7,920", "7 920"); the options are `Intl.DateTimeFormat`'s and
|
|
53
|
+
`Intl.NumberFormat`'s, and dates are read and shown in UTC. Pair a number with the unit's message:
|
|
54
|
+
`{@number|value=82.5} {unit.kg}`.
|
|
55
|
+
|
|
56
|
+
- Keep the source text in the markup: the screen reads correctly without stitch2, and a missing key shows it.
|
|
57
|
+
- Key every string the product owns: labels, buttons, headings, empty states, hints, units, tab names, the
|
|
58
|
+
placeholder and aria-label too. Sample data (an exercise name, a user's note, a number) gets `translate="no"`.
|
|
59
|
+
- `data-t` replaces the element's whole text, so put it on the innermost element holding just that string;
|
|
60
|
+
an icon next to the label stays outside it.
|
|
61
|
+
- Components that draw their own labels (a tab bar's tab names) take them from `translate(key, fallback)` in
|
|
62
|
+
`stitch2/elements` (or `window.__stitch2.t(key)` in plain JS), so the labels follow the language.
|
|
63
|
+
- Reuse the app's keys. Search the source messages for the text before inventing one (`stitch2 i18n --suggest`
|
|
64
|
+
lists matches). A new string gets a key in the app's naming scheme, added to the **source** messages first and
|
|
65
|
+
then to every locale; when you cannot write a good translation, add it to the source only and say so: the
|
|
66
|
+
locale falls back to the source and the report lists it.
|
|
67
|
+
|
|
68
|
+
## Check
|
|
69
|
+
|
|
70
|
+
1. `stitch2 i18n [filter] --suggest`: per screen, keys the source lacks (errors: they show the screen's own text
|
|
71
|
+
everywhere), keys each locale lacks, translations whose `{placeholders}` differ from the source, and text
|
|
72
|
+
with no key. Fix until it reports no unknown keys and no unkeyed product text.
|
|
73
|
+
2. `stitch2 check <path> --shots`: runs the screen in the source, then in each check language. Issues that only
|
|
74
|
+
appear in a language carry it ("pseudo: Text overflows…", "ar: …"); renders are saved as
|
|
75
|
+
`--pseudo.png`, `--ar.png`. `--locales de,ar`, `all` or `none` overrides the config for one run.
|
|
76
|
+
3. Look at the screen side by side: the canvas's **Languages** page (sidebar, or "languages" in a screen's
|
|
77
|
+
inspector) shows it once per language at the same size; the Language switch (`L`) shows the whole canvas in
|
|
78
|
+
one language. Read the pseudo frames for cut or overlapping text and the right-to-left ones for anything
|
|
79
|
+
that did not flip.
|
|
80
|
+
|
|
81
|
+
## Fixing text that does not fit
|
|
82
|
+
|
|
83
|
+
In this order, and never by shrinking the type below its level:
|
|
84
|
+
|
|
85
|
+
1. **Make room in the layout.** Let the text wrap where a second line is fine (headings, hints, cards); let a
|
|
86
|
+
row stack its label above its value; give a button the full width; move a secondary action out of a crowded
|
|
87
|
+
bar. Fixed widths and `whitespace-nowrap` on text are the usual cause.
|
|
88
|
+
2. **Use a shorter natural word** in that locale, the one a native speaker would use (a translator's choice,
|
|
89
|
+
not an abbreviation the product does not use elsewhere). Change it in the locale's messages, which the app
|
|
90
|
+
shares.
|
|
91
|
+
3. **Change the component** when one component fails in many languages (a tab label, a chip): fix it once in
|
|
92
|
+
the component and the catalog, then re-check every screen.
|
|
93
|
+
|
|
94
|
+
Ellipsis is acceptable only for user content (a long exercise name), never for a label the product owns.
|
|
95
|
+
|
|
96
|
+
## Right to left
|
|
97
|
+
|
|
98
|
+
Use logical properties so layouts flip by themselves: `ms-*`/`me-*`, `ps-*`/`pe-*`, `start-*`/`end-*`,
|
|
99
|
+
`text-start`, `rounded-s-*`. Icons that point (back, next, chevrons, progress) get `rtl:-scale-x-100`;
|
|
100
|
+
icons that show objects (a dumbbell, a clock) and numbers, charts and media controls do not flip.
|
|
101
|
+
|
|
102
|
+
## Hand over
|
|
103
|
+
|
|
104
|
+
Tell the user which languages you checked, what you changed to make each fit (layout, word, component), and
|
|
105
|
+
which locales still fall back to the source for new keys, by key.
|