stitch2 0.10.0 → 0.12.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 +8 -1
- package/dist/elements.js +2 -1
- package/dist/i18n-report.js +15 -4
- package/dist/i18n.js +69 -11
- package/package.json +1 -1
- package/skills/stitch2-i18n/SKILL.md +21 -0
package/README.md
CHANGED
|
@@ -197,6 +197,9 @@ shows every locale and the screens use the same strings as the product:
|
|
|
197
197
|
<ex-header title="Settings" data-t-title="settings.title"></ex-header>
|
|
198
198
|
<p data-t="workout.sets" data-t-args="count=16">16 sets</p> <!-- plural form picked by count -->
|
|
199
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>
|
|
200
203
|
```
|
|
201
204
|
|
|
202
205
|
```json
|
|
@@ -209,7 +212,11 @@ shows every locale and the screens use the same strings as the product:
|
|
|
209
212
|
```
|
|
210
213
|
|
|
211
214
|
Messages are JSON or a JS/TS module (its default export, else its first exported object); nested objects become
|
|
212
|
-
dotted keys, and plurals are objects keyed by CLDR category (`one`, `few`, `other`…).
|
|
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. Styled parts inside a sentence (a name in bold) are slots:
|
|
218
|
+
`<p data-t="library.showing">At <b data-t-slot="profile">Home gym</b></p>` keeps the `<b>` where the translation puts
|
|
219
|
+
`{profile}`. Two pseudo-languages need
|
|
213
220
|
no translations: **pseudo** makes every text about 40% longer, accented and bracketed, so cut text shows, and
|
|
214
221
|
**pseudo-rtl** lays the page out right to left.
|
|
215
222
|
|
package/dist/elements.js
CHANGED
|
@@ -42,7 +42,8 @@ export function defineElements(components, options) {
|
|
|
42
42
|
connectedCallback() {
|
|
43
43
|
const props = {};
|
|
44
44
|
for (const a of Array.from(this.attributes)) {
|
|
45
|
-
|
|
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-'))
|
|
46
47
|
continue;
|
|
47
48
|
props[camel(a.name)] = a.value === '' ? true : a.value;
|
|
48
49
|
}
|
package/dist/i18n-report.js
CHANGED
|
@@ -21,7 +21,10 @@ const locales = opt('--locale')?.split(',') ?? realLocales();
|
|
|
21
21
|
const suggest = args.includes('--suggest');
|
|
22
22
|
/** Attributes that hold text people read. On components (a dash in the tag) any attribute that reads as words. */
|
|
23
23
|
const TEXT_ATTRS = new Set(['title', 'label', 'placeholder', 'aria-label', 'alt']);
|
|
24
|
-
const NOT_TEXT = /^(class|id|style|href|src|srcset|
|
|
24
|
+
const NOT_TEXT = /^(class|id|style|href|src|srcset|for|role|slot|lang|dir|icon|variant|tone|size|width|height|viewbox|d|fill|stroke|xmlns|rel|target|tabindex|loading|translate)$/;
|
|
25
|
+
// On native elements these are identifiers and form values; on components (<x-row name="API keys">) they are
|
|
26
|
+
// usually what the component shows, so there they count as text when they read as words.
|
|
27
|
+
const NATIVE_ONLY = /^(type|name|value)$/;
|
|
25
28
|
const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr']);
|
|
26
29
|
const words = (s) => /\p{L}{2,}/u.test(s);
|
|
27
30
|
const readsAsText = (s) => words(s) && (/\s/.test(s.trim()) || /\p{Lu}/u.test(s) || /[^\x00-\x7f]/.test(s));
|
|
@@ -61,12 +64,20 @@ export function scan(html) {
|
|
|
61
64
|
const plural = 'data-t-args' in a && /(^|,)\s*count\s*=/.test(a['data-t-args']);
|
|
62
65
|
const skip = !!top?.skip || 'data-t-skip' in a || a.translate === 'no';
|
|
63
66
|
for (const [name, value] of Object.entries(a)) {
|
|
64
|
-
|
|
67
|
+
const keyed = name === 'data-t' || (name.startsWith('data-t-') && name !== 'data-t-args' && name !== 'data-t-skip');
|
|
68
|
+
if (keyed && value.includes('{'))
|
|
69
|
+
for (const m of value.matchAll(/\{([^{}|@][^{}|]*)(?:\|([^}]*))?\}/g))
|
|
70
|
+
uses.push({ key: m[1], line, plural: plural || /(^|;)\s*count\s*=/.test(m[2] ?? '') });
|
|
71
|
+
else if (name === 'data-t')
|
|
65
72
|
uses.push({ key: value, line, plural });
|
|
66
|
-
else if (
|
|
73
|
+
else if (keyed && name !== 'data-t-slot')
|
|
67
74
|
for (const k of value.split(','))
|
|
68
75
|
uses.push({ key: k.trim(), line, plural });
|
|
69
|
-
else if (!skip &&
|
|
76
|
+
else if (!skip &&
|
|
77
|
+
!name.startsWith('data-') &&
|
|
78
|
+
!NOT_TEXT.test(name) &&
|
|
79
|
+
!(NATIVE_ONLY.test(name) && !tag.includes('-')) &&
|
|
80
|
+
!('data-t-' + name in a)) {
|
|
70
81
|
const textual = TEXT_ATTRS.has(name) ? words(value) : tag.includes('-') && readsAsText(value);
|
|
71
82
|
if (textual)
|
|
72
83
|
loose.push({ text: value, line, attr: name });
|
package/dist/i18n.js
CHANGED
|
@@ -13,6 +13,12 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
13
13
|
* <span data-t="nav.library">Exercises</span> the element's text
|
|
14
14
|
* <gg-header title="Settings" data-t-title="settings.title"> an attribute (also comma lists: data-t-options)
|
|
15
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
|
+
* <p data-t="lib.showing">At <b data-t-slot="place">Home gym</b></p>
|
|
21
|
+
* rich text: the slot keeps its markup, at {place}
|
|
16
22
|
*
|
|
17
23
|
* Opened with ?lang=<locale> (the canvas's Language switch, and check), the page's text is replaced from that
|
|
18
24
|
* locale's messages before its components render, and the page gets lang and dir. Two pseudo-languages need no
|
|
@@ -105,7 +111,25 @@ export async function pagePayload(locale) {
|
|
|
105
111
|
function runtime(p) {
|
|
106
112
|
const missing = new Set(p.missing);
|
|
107
113
|
const plural = new Intl.PluralRules(p.lang);
|
|
114
|
+
// {@date|value=…;weekday=short…} and {@number|value=…;maximumFractionDigits=1…}: Intl's options, in the locale.
|
|
115
|
+
const intl = (key, args) => {
|
|
116
|
+
const opts = {};
|
|
117
|
+
for (const [k, v] of Object.entries(args))
|
|
118
|
+
if (k !== 'value')
|
|
119
|
+
opts[k] = v === 'true' ? true : v === 'false' ? false : /^\d+$/.test(v) ? Number(v) : v;
|
|
120
|
+
try {
|
|
121
|
+
return key === '@date'
|
|
122
|
+
? // As written, whatever the viewer's time zone: the value is read and shown in UTC.
|
|
123
|
+
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'))
|
|
124
|
+
: new Intl.NumberFormat(p.lang, opts).format(Number(args.value));
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
108
130
|
const t = (key, args) => {
|
|
131
|
+
if (key === '@date' || key === '@number')
|
|
132
|
+
return intl(key, args ?? {});
|
|
109
133
|
let msg;
|
|
110
134
|
if (args && args.count !== undefined) {
|
|
111
135
|
const n = Number(args.count);
|
|
@@ -116,11 +140,11 @@ function runtime(p) {
|
|
|
116
140
|
return undefined;
|
|
117
141
|
return msg.replace(/\{(\w+)\}/g, (m, k) => (args && args[k] !== undefined ? args[k] : m));
|
|
118
142
|
};
|
|
119
|
-
const known = (key) => key in p.messages || key + '.other' in p.messages;
|
|
143
|
+
const known = (key) => key.startsWith('@') || key in p.messages || key + '.other' in p.messages;
|
|
120
144
|
const lacks = (key) => missing.has(key) || missing.has(key + '.other') || !known(key);
|
|
121
|
-
const parseArgs = (s) => {
|
|
145
|
+
const parseArgs = (s, sep = ',') => {
|
|
122
146
|
const out = {};
|
|
123
|
-
for (const part of (s ?? '').split(
|
|
147
|
+
for (const part of (s ?? '').split(sep)) {
|
|
124
148
|
const i = part.indexOf('=');
|
|
125
149
|
if (i > 0)
|
|
126
150
|
out[part.slice(0, i).trim()] = part.slice(i + 1).trim();
|
|
@@ -141,21 +165,55 @@ function runtime(p) {
|
|
|
141
165
|
for (const a of Array.from(el.attributes)) {
|
|
142
166
|
if (a.name !== 'data-t' && !a.name.startsWith('data-t-'))
|
|
143
167
|
continue;
|
|
168
|
+
if (a.name === 'data-t-args' || a.name === 'data-t-skip' || a.name === 'data-t-slot')
|
|
169
|
+
continue;
|
|
144
170
|
const args = parseArgs(el.getAttribute('data-t-args'));
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
171
|
+
let v;
|
|
172
|
+
if (a.value.includes('{')) {
|
|
173
|
+
// A template: each {key} or {key|name=value;name=value} becomes its message, the rest stays as written.
|
|
174
|
+
let whole = true;
|
|
175
|
+
v = a.value.replace(/\{([^{}|]+)(?:\|([^}]*))?\}/g, (_m, key, inline) => {
|
|
176
|
+
keys.push(key);
|
|
177
|
+
const r = t(key, { ...args, ...parseArgs(inline, ';') });
|
|
178
|
+
if (r === undefined)
|
|
179
|
+
whole = false;
|
|
180
|
+
return r ?? '';
|
|
181
|
+
});
|
|
182
|
+
if (!whole)
|
|
183
|
+
v = undefined;
|
|
184
|
+
}
|
|
185
|
+
else if (a.name === 'data-t') {
|
|
186
|
+
v = t(a.value, args);
|
|
149
187
|
keys.push(a.value);
|
|
150
188
|
}
|
|
151
|
-
else
|
|
152
|
-
|
|
189
|
+
else {
|
|
190
|
+
// A list attribute (options="A,B"): one key per item.
|
|
153
191
|
const parts = a.value.split(',').map((k) => k.trim());
|
|
154
192
|
const vals = parts.map((k) => t(k, args));
|
|
155
|
-
if (vals.every((
|
|
156
|
-
|
|
193
|
+
if (vals.every((x) => x !== undefined))
|
|
194
|
+
v = vals.join(',');
|
|
157
195
|
keys.push(...parts);
|
|
158
196
|
}
|
|
197
|
+
if (v === undefined)
|
|
198
|
+
continue;
|
|
199
|
+
if (a.name === 'data-t') {
|
|
200
|
+
// Rich text: children marked data-t-slot="name" keep their markup and take the place of {name}.
|
|
201
|
+
const slots = new Map();
|
|
202
|
+
for (const s of Array.from(el.querySelectorAll('[data-t-slot]'))) {
|
|
203
|
+
const outer = s.parentElement?.closest('[data-t-slot]');
|
|
204
|
+
if (!outer || !el.contains(outer))
|
|
205
|
+
slots.set(s.getAttribute('data-t-slot') ?? '', s);
|
|
206
|
+
}
|
|
207
|
+
if (!slots.size)
|
|
208
|
+
el.textContent = v;
|
|
209
|
+
else {
|
|
210
|
+
el.textContent = '';
|
|
211
|
+
for (const part of v.split(/(\{\w+\})/))
|
|
212
|
+
el.append(slots.get(part.slice(1, -1)) ?? document.createTextNode(part));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
else
|
|
216
|
+
el.setAttribute(a.name.slice(7), v);
|
|
159
217
|
}
|
|
160
218
|
if (!keys.length)
|
|
161
219
|
continue;
|
package/package.json
CHANGED
|
@@ -40,8 +40,29 @@ Use the app's own files. Never copy messages into the design folder: two copies
|
|
|
40
40
|
<input placeholder="Search" data-t-placeholder="exercises.search" />
|
|
41
41
|
<p data-t="workout.sets" data-t-args="count=16">16 sets</p> <!-- {count} filled in, plural form by count -->
|
|
42
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}">
|
|
43
44
|
```
|
|
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
|
+
A sentence with styled parts inside (a name in bold, a highlighted number) keeps them as **slots**: mark each part
|
|
57
|
+
`data-t-slot="<placeholder>"` and key the sentence. The translation decides where each part goes:
|
|
58
|
+
|
|
59
|
+
```html
|
|
60
|
+
<p data-t="library.showing" data-t-args="count=312">Showing what you can do at
|
|
61
|
+
<b data-t-slot="profile" translate="no">Home gym</b>: 312 exercises.</p>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Never split such a sentence into separately keyed pieces: word order differs between languages.
|
|
65
|
+
|
|
45
66
|
- Keep the source text in the markup: the screen reads correctly without stitch2, and a missing key shows it.
|
|
46
67
|
- Key every string the product owns: labels, buttons, headings, empty states, hints, units, tab names, the
|
|
47
68
|
placeholder and aria-label too. Sample data (an exercise name, a user's note, a number) gets `translate="no"`.
|