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 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`…). Two pseudo-languages need
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
- if (a.name.startsWith('data-s2-'))
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
  }
@@ -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|type|name|value|for|role|slot|lang|dir|icon|variant|tone|size|width|height|viewbox|d|fill|stroke|xmlns|rel|target|tabindex|loading|translate)$/;
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
- if (name === 'data-t')
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 (name.startsWith('data-t-') && name !== 'data-t-args' && name !== 'data-t-skip')
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 && !name.startsWith('data-') && !NOT_TEXT.test(name) && !('data-t-' + name in a)) {
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
- if (a.name === 'data-t') {
146
- const v = t(a.value, args);
147
- if (v !== undefined)
148
- el.textContent = v;
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 if (a.name !== 'data-t-args' && a.name !== 'data-t-skip') {
152
- const attr = a.name.slice(7);
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((v) => v !== undefined))
156
- el.setAttribute(attr, vals.join(','));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitch2",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "A design canvas, layout and type checks, component consistency and screen versioning for agent-built UI, on Google's DESIGN.md format.",
5
5
  "license": "MIT",
6
6
  "author": "ifokeev",
@@ -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"`.