@delurio/cli 0.0.0-stage → 0.1.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/dist/kit.js ADDED
@@ -0,0 +1,710 @@
1
+ // A kit says how the components of ui.yaml look in one product: design tokens (W3C design tokens JSON, written out as
2
+ // CSS custom properties), a template per component and optional CSS. Templates are data: a few slots and flags are
3
+ // filled in, nothing in a kit is ever run, and anything that could reach the network or run a script is refused when
4
+ // the kit loads.
5
+ import { existsSync, readFileSync, statSync } from 'node:fs';
6
+ import { dirname, join, resolve } from 'node:path';
7
+ import { parse } from 'yaml';
8
+ export class KitError extends Error {
9
+ }
10
+ const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
11
+ const SLOTS = ['text', 'name', 'id', 'intent', 'children'];
12
+ const FLAGS = ['disabled', 'named', 'conditional'];
13
+ // Lists are drawn without a wrapper around each item, so their templates must be one element the renderer can mark.
14
+ const SINGLE = new Set(['List', 'ListItem']);
15
+ const VOID = new Set(['area', 'br', 'col', 'hr', 'img', 'input', 'source', 'track', 'wbr']);
16
+ const DENIED_TAGS = new Set(['script', 'style', 'iframe', 'frame', 'frameset', 'object', 'embed', 'applet', 'link', 'meta', 'base', 'form', 'noscript', 'template', 'portal', 'audio', 'video']);
17
+ // a quoted address in CSS, which image-set() fetches without url()
18
+ const FETCHED_STRING = /["'](https?:)?\/\//i;
19
+ const URL_ATTRS = new Set(['href', 'src', 'srcset', 'action', 'formaction', 'xlink:href', 'poster', 'data', 'background', 'ping', 'codebase', 'cite', 'longdesc', 'manifest', 'srcdoc', 'lowsrc']);
20
+ function checkAttr(where, tag, attr, value) {
21
+ const no = (why) => {
22
+ throw new KitError(`${where}: <${tag} ${attr}>: ${why}`);
23
+ };
24
+ if (!/^[a-z_:][-a-z0-9_:.]*$/.test(attr))
25
+ no('not an attribute name');
26
+ if (attr.startsWith('on'))
27
+ no('event handlers are not allowed: a kit is data, never code');
28
+ if (/javascript:/i.test(value))
29
+ no('javascript: is not allowed');
30
+ if (URL_ATTRS.has(attr) && value !== '' && !value.startsWith('#') && !(attr === 'src' && value.startsWith('data:image/'))) {
31
+ no('may only point inside the page ("#…") or, for src, hold a data:image; nothing is fetched');
32
+ }
33
+ if (attr === 'style' && (/url\(\s*(?!['"]?(data:|#))/i.test(value) || FETCHED_STRING.test(value.replace(/url\([^)]*\)/gi, '')) || /expression\(|@import/i.test(value)))
34
+ no('a style may only use data: URLs');
35
+ }
36
+ // Reads a template, checks that it is well-formed HTML with slots only where a value can stand, and splits it into parts.
37
+ function parseTemplate(source, where, component) {
38
+ const src = source.trim();
39
+ const fail = (why) => {
40
+ throw new KitError(`${where}: ${why}`);
41
+ };
42
+ const root = [];
43
+ let parts = root;
44
+ const frames = [];
45
+ let lit = '';
46
+ const flush = () => {
47
+ if (lit !== '')
48
+ parts.push({ lit });
49
+ lit = '';
50
+ };
51
+ let mode = 'text';
52
+ const stack = [];
53
+ let svg = 0;
54
+ let tag = '';
55
+ let attr = '';
56
+ let value = '';
57
+ let opened = 0; // tags opened so far: a section that opens inside a tag must close inside the same one
58
+ let topElements = 0;
59
+ let topText = false;
60
+ let rootEnd = -1;
61
+ const key = () => `${mode}|${stack.join('>')}|${mode === 'text' ? '' : opened}|${mode === 'dq' || mode === 'sq' ? attr : ''}`;
62
+ let i = 0;
63
+ while (i < src.length) {
64
+ const c = src[i];
65
+ const marker = c === '{' && mode !== 'comment' ? /^\{([?!/]?)([a-z]+)\}/.exec(src.slice(i)) : null;
66
+ if (marker !== null) {
67
+ const [whole, sigil, word] = marker;
68
+ if (sigil === '') {
69
+ if (!SLOTS.includes(word))
70
+ fail(`unknown slot {${word}}; the slots are ${SLOTS.map((s) => `{${s}}`).join(' ')}`);
71
+ if (word === 'children' && component !== 'List')
72
+ fail('{children} is only for List');
73
+ if (mode !== 'text' && mode !== 'dq' && mode !== 'sq')
74
+ fail(`{${word}} must stand in text or inside a quoted attribute value`);
75
+ if (word === 'children' && mode !== 'text')
76
+ fail('{children} must stand in text');
77
+ if (mode === 'text' && stack.length === 0)
78
+ topText = true;
79
+ if (mode !== 'text')
80
+ value += whole;
81
+ flush();
82
+ parts.push({ slot: word, ctx: mode === 'dq' || mode === 'sq' ? mode : 'text' });
83
+ }
84
+ else if (sigil === '/') {
85
+ const f = frames.pop();
86
+ if (f === undefined || f.flag !== word)
87
+ fail(`{/${word}} does not close the section that is open`);
88
+ else {
89
+ if (f.key !== key())
90
+ fail(`{?${word}}…{/${word}} must close in the same place it opens (same element, tag or attribute)`);
91
+ flush();
92
+ parts = f.outer;
93
+ }
94
+ }
95
+ else {
96
+ if (!FLAGS.includes(word))
97
+ fail(`unknown flag ${word}; the flags are ${FLAGS.join(', ')}`);
98
+ if (frames.some((f) => f.flag === word))
99
+ fail(`{${sigil}${word}} inside a section on the same flag`);
100
+ flush();
101
+ const sec = { flag: word, negate: sigil === '!', parts: [] };
102
+ parts.push(sec);
103
+ frames.push({ flag: word, outer: parts, key: key() });
104
+ parts = sec.parts;
105
+ }
106
+ i += whole.length;
107
+ continue;
108
+ }
109
+ if (mode === 'comment') {
110
+ if (src.startsWith('-->', i)) {
111
+ mode = 'text';
112
+ lit += '-->';
113
+ i += 3;
114
+ }
115
+ else {
116
+ lit += c;
117
+ i += 1;
118
+ }
119
+ continue;
120
+ }
121
+ if (mode === 'dq' || mode === 'sq') {
122
+ if (c === (mode === 'dq' ? '"' : "'")) {
123
+ checkAttr(where, tag, attr, value);
124
+ mode = 'tag';
125
+ }
126
+ else
127
+ value += c;
128
+ lit += c;
129
+ i += 1;
130
+ continue;
131
+ }
132
+ if (mode === 'tag') {
133
+ if (c === '>' || src.startsWith('/>', i)) {
134
+ const self = c === '/';
135
+ if (self && svg === 0 && !VOID.has(tag))
136
+ fail(`<${tag} />: only void elements and SVG may close themselves`);
137
+ if (!self && !VOID.has(tag)) {
138
+ stack.push(tag);
139
+ if (tag === 'svg')
140
+ svg += 1;
141
+ }
142
+ lit += self ? '/>' : '>';
143
+ i += self ? 2 : 1;
144
+ mode = 'text';
145
+ attr = '';
146
+ if (stack.length === 0)
147
+ rootEnd = i;
148
+ continue;
149
+ }
150
+ if (/\s/.test(c)) {
151
+ lit += c;
152
+ i += 1;
153
+ continue;
154
+ }
155
+ const name = /^[^\s"'>/={]+/.exec(src.slice(i));
156
+ if (name === null)
157
+ fail(`unexpected "${c}" in <${tag}>`);
158
+ else {
159
+ attr = name[0].toLowerCase();
160
+ lit += name[0];
161
+ i += name[0].length;
162
+ const eq = /^\s*=\s*/.exec(src.slice(i));
163
+ if (eq === null) {
164
+ checkAttr(where, tag, attr, '');
165
+ continue;
166
+ }
167
+ lit += eq[0];
168
+ i += eq[0].length;
169
+ const q = src[i];
170
+ if (q === '"' || q === "'") {
171
+ mode = q === '"' ? 'dq' : 'sq';
172
+ value = '';
173
+ lit += q;
174
+ i += 1;
175
+ }
176
+ else {
177
+ const bare = /^[^\s"'>`=<{]+/.exec(src.slice(i));
178
+ if (bare === null)
179
+ fail(`<${tag} ${attr}=…>: write the value in quotes`);
180
+ else {
181
+ checkAttr(where, tag, attr, bare[0]);
182
+ lit += bare[0];
183
+ i += bare[0].length;
184
+ }
185
+ }
186
+ }
187
+ continue;
188
+ }
189
+ // text
190
+ if (c === '<') {
191
+ if (src.startsWith('<!--', i)) {
192
+ mode = 'comment';
193
+ lit += '<!--';
194
+ i += 4;
195
+ continue;
196
+ }
197
+ const close = /^<\/([a-zA-Z][a-zA-Z0-9-]*)\s*>/.exec(src.slice(i));
198
+ if (close !== null) {
199
+ const name = close[1].toLowerCase();
200
+ const top = stack.pop();
201
+ if (top !== name)
202
+ fail(`</${name}> closes ${top === undefined ? 'nothing' : `<${top}>`}`);
203
+ if (name === 'svg')
204
+ svg -= 1;
205
+ lit += close[0];
206
+ i += close[0].length;
207
+ if (stack.length === 0)
208
+ rootEnd = i;
209
+ continue;
210
+ }
211
+ const open = /^<([a-zA-Z][a-zA-Z0-9-]*)/.exec(src.slice(i));
212
+ if (open === null)
213
+ fail('a bare "<" in text: write &lt;');
214
+ else {
215
+ tag = open[1].toLowerCase();
216
+ if (DENIED_TAGS.has(tag))
217
+ fail(`<${tag}> is not allowed in a kit template`);
218
+ if (stack.length === 0)
219
+ topElements += 1;
220
+ opened += 1;
221
+ lit += open[0];
222
+ i += open[0].length;
223
+ mode = 'tag';
224
+ }
225
+ continue;
226
+ }
227
+ if (stack.length === 0 && !/\s/.test(c))
228
+ topText = true;
229
+ lit += c;
230
+ i += 1;
231
+ }
232
+ flush();
233
+ if (mode !== 'text')
234
+ fail('the template ends inside a tag, an attribute value or a comment');
235
+ if (stack.length > 0)
236
+ fail(`<${stack[stack.length - 1]}> is not closed`);
237
+ if (frames.length > 0)
238
+ fail(`{?${frames[frames.length - 1].flag}} is not closed`);
239
+ if (component === 'List' && !src.includes('{children}'))
240
+ fail('a List template needs {children}, where its items go');
241
+ if (SINGLE.has(component) && (topElements !== 1 || topText || rootEnd !== src.length || !src.startsWith('<'))) {
242
+ fail(`a ${component} template must be one element (the renderer marks it with the node it draws)`);
243
+ }
244
+ return { source: src, parts: root };
245
+ }
246
+ function fillParts(parts, f, intent) {
247
+ let out = '';
248
+ for (const p of parts) {
249
+ if ('lit' in p)
250
+ out += p.lit;
251
+ else if ('slot' in p) {
252
+ if (p.slot === 'children')
253
+ out += f.children ?? '';
254
+ else {
255
+ const v = p.slot === 'intent' ? intent : f[p.slot];
256
+ out += p.ctx === 'sq' ? esc(v).replace(/'/g, '&#39;') : esc(v);
257
+ }
258
+ }
259
+ else if (f[p.flag] !== p.negate)
260
+ out += fillParts(p.parts, f, intent);
261
+ }
262
+ return out;
263
+ }
264
+ // The node drawn with the kit, or null when the kit has no template for the component.
265
+ export function drawWith(kit, component, f, warn) {
266
+ const c = kit.components[component];
267
+ if (c === undefined)
268
+ return null;
269
+ let intent = f.intent ?? '';
270
+ if (c.intents !== undefined) {
271
+ const mapped = f.intent === undefined ? c.intents.default : c.intents[f.intent];
272
+ if (f.intent !== undefined && mapped === undefined)
273
+ warn(`kit ${kit.id}: ${component} has no entry for intent ${f.intent} (node ${f.id}); drawn as ${c.intents.default ?? 'no intent'}`);
274
+ intent = mapped ?? c.intents.default ?? '';
275
+ }
276
+ const t = (f.role === undefined ? undefined : c.roles[f.role]) ?? c.template;
277
+ return fillParts(t.parts, f, intent);
278
+ }
279
+ // A List or ListItem is drawn without a wrapper: the node id goes on the template's one element, and on the page the
280
+ // annotation chips go inside it, last.
281
+ export function mark(html, id, chips) {
282
+ let q = '';
283
+ let end = -1;
284
+ for (let i = 0; i < html.length && end < 0; i++) {
285
+ const c = html[i];
286
+ if (q !== '') {
287
+ if (c === q)
288
+ q = '';
289
+ }
290
+ else if (c === '"' || c === "'")
291
+ q = c;
292
+ else if (c === '>')
293
+ end = html[i - 1] === '/' ? i - 1 : i;
294
+ }
295
+ const close = html.lastIndexOf('</');
296
+ return `${html.slice(0, end)} data-node="${esc(id)}"${html.slice(end, close)}${chips}${html.slice(close)}`;
297
+ }
298
+ // ---- CSS
299
+ // Checks kit CSS before it is scoped to the wireframe: nothing fetched, no way out of the wireframe's rule, nothing
300
+ // that cannot sit inside it. Throws on the first problem.
301
+ function checkCss(css, where) {
302
+ const fail = (why) => {
303
+ throw new KitError(`${where}: ${why}`);
304
+ };
305
+ if (css.includes('<'))
306
+ fail('"<" is not allowed in kit CSS (percent-encode it in a data: URL)');
307
+ if (/expression\(|javascript:|-moz-binding|behavior\s*:/i.test(css))
308
+ fail('script-like CSS is not allowed');
309
+ let depth = 0;
310
+ let prelude = '';
311
+ let i = 0;
312
+ while (i < css.length) {
313
+ const c = css[i];
314
+ if (css.startsWith('/*', i)) {
315
+ const end = css.indexOf('*/', i + 2);
316
+ if (end < 0)
317
+ fail('a comment is not closed');
318
+ i = end + 2;
319
+ continue;
320
+ }
321
+ if (c === '"' || c === "'") {
322
+ let j = i + 1;
323
+ while (j < css.length && css[j] !== c && css[j] !== '\n')
324
+ j += css[j] === '\\' ? 2 : 1;
325
+ if (css[j] !== c)
326
+ fail('a string is not closed');
327
+ prelude += css.slice(i, j + 1);
328
+ i = j + 1;
329
+ continue;
330
+ }
331
+ if (c === '{') {
332
+ const p = prelude.trim();
333
+ const at = /^@([a-z-]+)/i.exec(p);
334
+ if (at !== null && !['media', 'supports', 'container'].includes(at[1].toLowerCase())) {
335
+ fail(`@${at[1]} is not allowed in kit CSS (only @media, @supports and @container, which can sit inside the wireframe's rule)`);
336
+ }
337
+ if (at === null) {
338
+ for (const sel of p.split(',')) {
339
+ if (/^(:root|html|body)\b/i.test(sel.trim()))
340
+ fail(`"${sel.trim()}" never matches inside the wireframe: style the screen itself with &`);
341
+ }
342
+ }
343
+ depth += 1;
344
+ prelude = '';
345
+ }
346
+ else if (c === '}') {
347
+ depth -= 1;
348
+ if (depth < 0)
349
+ fail('a "}" closes nothing');
350
+ prelude = '';
351
+ }
352
+ else if (c === ';') {
353
+ if (/^\s*@/.test(prelude))
354
+ fail(`${prelude.trim()} is not allowed in kit CSS`);
355
+ prelude = '';
356
+ }
357
+ else
358
+ prelude += c;
359
+ i += 1;
360
+ }
361
+ if (depth !== 0)
362
+ fail('a "{" is not closed');
363
+ if (/^\s*@/.test(prelude))
364
+ fail(`${prelude.trim()} is not allowed in kit CSS`);
365
+ for (const m of css.matchAll(/url\(\s*(['"]?)([^'")]*)/gi)) {
366
+ if (!m[2].startsWith('data:') && !m[2].startsWith('#'))
367
+ fail(`url(${m[2]}) would be fetched; only data: URLs are allowed`);
368
+ }
369
+ if (FETCHED_STRING.test(css.replace(/url\([^)]*\)/gi, '')))
370
+ fail('a quoted address would be fetched; only data: URLs are allowed');
371
+ }
372
+ // ---- design tokens
373
+ const GENERIC_FAMILIES = new Set(['serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded', 'math', 'emoji', 'fangsong']);
374
+ const UNITS = new Set(['px', 'rem', 'em', '%', 'ms', 's']);
375
+ function cssName(path, where) {
376
+ for (const seg of path)
377
+ if (/[{}.]/.test(seg) || seg === '')
378
+ throw new KitError(`${where}: token name "${seg}" may not be empty or contain {, } or .`);
379
+ return `--${path.map((s) => s.replace(/[^A-Za-z0-9_-]+/g, '-')).join('-')}`;
380
+ }
381
+ // Reads one W3C design tokens file (the Design Tokens Community Group format): groups nest, a token has `$value`,
382
+ // `$type` is inherited from its group, and "{group.token}" is an alias, written as var(--group-token).
383
+ function readTokens(file, all) {
384
+ let doc;
385
+ try {
386
+ doc = JSON.parse(readFileSync(file, 'utf8'));
387
+ }
388
+ catch (err) {
389
+ throw new KitError(`${file}: ${err instanceof Error ? err.message : String(err)}`);
390
+ }
391
+ const decls = [];
392
+ const refs = [];
393
+ const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
394
+ const walk = (node, path, type) => {
395
+ if ('$extends' in node || '$ref' in node)
396
+ throw new KitError(`${file}: ${path.join('.') || 'the root'}: $extends and $ref are not supported; write an alias "{group.token}"`);
397
+ for (const [k, v] of Object.entries(node)) {
398
+ if (k.startsWith('$'))
399
+ continue;
400
+ const here = [...path, k];
401
+ if (!isObj(v))
402
+ throw new KitError(`${file}: ${here.join('.')} is neither a group nor a token (a token has $value)`);
403
+ const t = typeof v.$type === 'string' ? v.$type : type;
404
+ if ('$value' in v)
405
+ token(here, t, v.$value);
406
+ else
407
+ walk(v, here, t);
408
+ }
409
+ };
410
+ const token = (path, type, value) => {
411
+ const where = `${file}: ${path.join('.')}`;
412
+ const name = cssName(path, file);
413
+ const alias = (v) => {
414
+ if (typeof v !== 'string')
415
+ return null;
416
+ const m = /^\{([^{}]+)\}$/.exec(v);
417
+ if (m === null)
418
+ return null;
419
+ const to = cssName(m[1].split('.'), where);
420
+ refs.push({ from: name, to });
421
+ return `var(${to})`;
422
+ };
423
+ const plain = (v) => {
424
+ const a = alias(v);
425
+ if (a !== null)
426
+ return a;
427
+ if (typeof v === 'number')
428
+ return String(v);
429
+ if (typeof v !== 'string')
430
+ throw new KitError(`${where}: expected a string or a number, got ${JSON.stringify(v)}`);
431
+ if (/[;{}<]|url\(|@|\\/i.test(v))
432
+ throw new KitError(`${where}: ${JSON.stringify(v)} is not a plain CSS value`);
433
+ return v;
434
+ };
435
+ const dimension = (v) => {
436
+ if (isObj(v)) {
437
+ if (typeof v.value !== 'number' || typeof v.unit !== 'string' || !UNITS.has(v.unit))
438
+ throw new KitError(`${where}: a dimension needs a number value and one of the units ${[...UNITS].join(' ')}`);
439
+ return `${v.value}${v.unit}`;
440
+ }
441
+ return plain(v);
442
+ };
443
+ const color = (v) => {
444
+ if (!isObj(v))
445
+ return plain(v);
446
+ const a = typeof v.alpha === 'number' ? v.alpha : 1;
447
+ if (typeof v.hex === 'string' && /^#[0-9a-fA-F]{6}$/.test(v.hex)) {
448
+ if (a === 1)
449
+ return v.hex;
450
+ const n = parseInt(v.hex.slice(1), 16);
451
+ return `rgb(${n >> 16} ${(n >> 8) & 255} ${n & 255} / ${a})`;
452
+ }
453
+ const comps = Array.isArray(v.components) ? v.components.map((x) => (typeof x === 'number' ? String(x) : 'none')).join(' ') : '';
454
+ const space = String(v.colorSpace);
455
+ if (comps === '')
456
+ throw new KitError(`${where}: a colour needs components or a hex`);
457
+ const tail = a === 1 ? '' : ` / ${a}`;
458
+ if (['oklch', 'oklab', 'lab', 'lch'].includes(space))
459
+ return `${space}(${comps}${tail})`;
460
+ if (['srgb', 'srgb-linear', 'display-p3', 'a98-rgb', 'prophoto-rgb', 'rec2020', 'xyz-d50', 'xyz-d65'].includes(space))
461
+ return `color(${space} ${comps}${tail})`;
462
+ throw new KitError(`${where}: colour space ${space} is not supported (give a hex as well)`);
463
+ };
464
+ const family = (v) => {
465
+ if (!Array.isArray(v))
466
+ return plain(v);
467
+ return v.map((f) => {
468
+ const s = plain(f);
469
+ return GENERIC_FAMILIES.has(s) || s.startsWith('var(') || s.startsWith('"') ? s : `"${s.replace(/"/g, '')}"`;
470
+ }).join(', ');
471
+ };
472
+ const shadow = (v) => {
473
+ if (Array.isArray(v))
474
+ return v.map(shadow).join(', ');
475
+ if (!isObj(v))
476
+ return plain(v);
477
+ return [v.inset === true ? 'inset' : '', dimension(v.offsetX), dimension(v.offsetY), dimension(v.blur), dimension(v.spread ?? { value: 0, unit: 'px' }), color(v.color)].filter((s) => s !== '').join(' ');
478
+ };
479
+ const out = (n, v) => {
480
+ if (all.has(n))
481
+ throw new KitError(`${where}: ${n} is defined twice`);
482
+ all.add(n);
483
+ decls.push([n, v]);
484
+ };
485
+ const a = alias(value);
486
+ if (a !== null)
487
+ return out(name, a);
488
+ switch (type) {
489
+ case 'color':
490
+ return out(name, color(value));
491
+ case 'dimension':
492
+ case 'duration':
493
+ return out(name, dimension(value));
494
+ case 'fontFamily':
495
+ return out(name, family(value));
496
+ case 'fontWeight':
497
+ case 'number':
498
+ return out(name, plain(value));
499
+ case 'cubicBezier':
500
+ if (!Array.isArray(value) || value.length !== 4)
501
+ throw new KitError(`${where}: a cubicBezier is four numbers`);
502
+ return out(name, `cubic-bezier(${value.map(plain).join(', ')})`);
503
+ case 'shadow':
504
+ return out(name, shadow(value));
505
+ case 'border':
506
+ if (!isObj(value))
507
+ return out(name, plain(value));
508
+ return out(name, `${dimension(value.width)} ${plain(value.style)} ${color(value.color)}`);
509
+ case 'typography': {
510
+ if (!isObj(value))
511
+ throw new KitError(`${where}: a typography token is an object`);
512
+ // one custom property per field: --name-font-family, --name-font-size, ...
513
+ const fields = [['fontFamily', family], ['fontSize', dimension], ['fontWeight', plain], ['letterSpacing', dimension], ['lineHeight', plain]];
514
+ for (const [f, conv] of fields)
515
+ if (value[f] !== undefined)
516
+ out(`${name}-${f.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)}`, conv(value[f]));
517
+ return;
518
+ }
519
+ case undefined:
520
+ if (isObj(value) && 'unit' in value)
521
+ return out(name, dimension(value));
522
+ if (isObj(value) && ('colorSpace' in value || 'hex' in value))
523
+ return out(name, color(value));
524
+ return out(name, plain(value));
525
+ default:
526
+ throw new KitError(`${where}: $type ${type} is not supported yet`);
527
+ }
528
+ };
529
+ if (!isObj(doc))
530
+ throw new KitError(`${file}: a tokens file is a JSON object`);
531
+ walk(doc, [], typeof doc.$type === 'string' ? doc.$type : undefined);
532
+ return { decls, refs };
533
+ }
534
+ function readTokenSet(dir, files, where, known) {
535
+ const names = new Set();
536
+ const decls = [];
537
+ const refs = [];
538
+ for (const f of files) {
539
+ const r = readTokens(join(dir, f), names);
540
+ decls.push(...r.decls);
541
+ refs.push(...r.refs);
542
+ }
543
+ const visible = new Set([...names, ...(known ?? [])]);
544
+ for (const r of refs)
545
+ if (!visible.has(r.to))
546
+ throw new KitError(`${where}: ${r.from} refers to ${r.to}, which no tokens file defines`);
547
+ if (known !== undefined)
548
+ for (const [n] of decls)
549
+ if (!known.has(n))
550
+ throw new KitError(`${where}: dark token ${n} has no light value`);
551
+ // an alias cycle would leave every token on it without a value
552
+ const edges = new Map();
553
+ for (const r of refs)
554
+ edges.set(r.from, [...(edges.get(r.from) ?? []), r.to]);
555
+ const state = new Map();
556
+ const visit = (n, trail) => {
557
+ if (state.get(n) === 2)
558
+ return;
559
+ if (state.get(n) === 1)
560
+ throw new KitError(`${where}: alias cycle ${[...trail, n].join(' -> ')}`);
561
+ state.set(n, 1);
562
+ for (const t of edges.get(n) ?? [])
563
+ visit(t, [...trail, n]);
564
+ state.set(n, 2);
565
+ };
566
+ for (const n of edges.keys())
567
+ visit(n, []);
568
+ return decls;
569
+ }
570
+ // ---- loading
571
+ const list = (v, where) => {
572
+ if (v === undefined)
573
+ return [];
574
+ const xs = Array.isArray(v) ? v : [v];
575
+ for (const x of xs)
576
+ if (typeof x !== 'string')
577
+ throw new KitError(`${where}: expected a file name or a list of them`);
578
+ return xs;
579
+ };
580
+ function only(obj, keys, where) {
581
+ for (const k of Object.keys(obj))
582
+ if (!keys.includes(k))
583
+ throw new KitError(`${where}: unknown key ${k} (known: ${keys.join(', ')})`);
584
+ }
585
+ export function loadKit(path) {
586
+ if (!existsSync(path))
587
+ throw new KitError(`kit ${path} does not exist`);
588
+ const file = statSync(path).isDirectory() ? join(path, 'kit.yaml') : path;
589
+ if (!existsSync(file))
590
+ throw new KitError(`kit ${path} has no kit.yaml`);
591
+ const dir = dirname(file);
592
+ const raw = parse(readFileSync(file, 'utf8'));
593
+ if (raw === null || typeof raw !== 'object')
594
+ throw new KitError(`${file}: empty`);
595
+ if (raw.kind !== 'kit')
596
+ throw new KitError(`${file} is not a kit (kind: kit is missing)`);
597
+ only(raw, ['schema', 'kind', 'id', 'title', 'source', 'tokens', 'dark', 'css', 'stage', 'components'], file);
598
+ if (typeof raw.id !== 'string' || !/^[a-z0-9][a-z0-9-]*$/.test(raw.id))
599
+ throw new KitError(`${file}: id must be lowercase letters, digits and dashes`);
600
+ const id = raw.id;
601
+ const comps = raw.components;
602
+ if (comps === undefined || typeof comps !== 'object' || Object.keys(comps).length === 0)
603
+ throw new KitError(`${file}: components is missing`);
604
+ const components = {};
605
+ const css = [];
606
+ for (const f of list(raw.css, `${file}: css`)) {
607
+ const text = readFileSync(join(dir, f), 'utf8');
608
+ checkCss(text, join(dir, f));
609
+ css.push(text.trim());
610
+ }
611
+ for (const [name, spec] of Object.entries(comps)) {
612
+ const where = `${file}: ${name}`;
613
+ if (name === 'Page')
614
+ throw new KitError(`${where}: Page is the screen itself and has no template; style the screen with & in the kit CSS`);
615
+ if (spec === null || typeof spec !== 'object')
616
+ throw new KitError(`${where}: needs a template`);
617
+ only(spec, ['template', 'roles', 'intents', 'css'], where);
618
+ if (typeof spec.template !== 'string')
619
+ throw new KitError(`${where}: needs a template`);
620
+ const roles = {};
621
+ for (const [role, t] of Object.entries((spec.roles ?? {}))) {
622
+ if (typeof t !== 'string')
623
+ throw new KitError(`${where}: roles.${role} must be a template`);
624
+ roles[role] = parseTemplate(t, `${where}: roles.${role}`, name);
625
+ }
626
+ let intents;
627
+ if (spec.intents !== undefined) {
628
+ intents = spec.intents;
629
+ for (const [k, v] of Object.entries(intents))
630
+ if (typeof v !== 'string')
631
+ throw new KitError(`${where}: intents.${k} must be a word`);
632
+ }
633
+ if (spec.css !== undefined) {
634
+ if (typeof spec.css !== 'string')
635
+ throw new KitError(`${where}: css must be CSS text`);
636
+ checkCss(spec.css, `${where}: css`);
637
+ css.push(spec.css.trim());
638
+ }
639
+ components[name] = { template: parseTemplate(spec.template, `${where}: template`, name), roles, intents };
640
+ }
641
+ const light = readTokenSet(dir, list(raw.tokens, `${file}: tokens`), `${file}: tokens`);
642
+ const dark = readTokenSet(dir, list(raw.dark, `${file}: dark`), `${file}: dark`, new Set(light.map(([n]) => n)));
643
+ const stage = (raw.stage ?? {});
644
+ only(stage, ['zoom'], `${file}: stage`);
645
+ const zoom = stage.zoom ?? 1.5;
646
+ if (typeof zoom !== 'number' || zoom < 0.5 || zoom > 4)
647
+ throw new KitError(`${file}: stage.zoom must be a number from 0.5 to 4`);
648
+ return { id, title: typeof raw.title === 'string' ? raw.title : id, file, components, light, dark, css: css.join('\n\n'), zoom };
649
+ }
650
+ // `--kit <path>` wins over `kit:` in ui.yaml (a path relative to the graph); `--kit none` draws the fixed components.
651
+ export function resolveKit(graphDir, uiKit, flagValue) {
652
+ if (flagValue === 'none')
653
+ return null;
654
+ if (flagValue !== undefined)
655
+ return loadKit(resolve(flagValue));
656
+ if (uiKit !== undefined)
657
+ return loadKit(resolve(graphDir, uiKit));
658
+ return null;
659
+ }
660
+ // The CSS a kit adds: its tokens and its CSS, scoped to the wireframe (`.wf.kit`, with native CSS nesting), and the
661
+ // few rules that keep the page's own marks readable or fit the screens to the video stage.
662
+ export function kitCss(kit, target) {
663
+ const decls = (ds) => ds.map(([n, v]) => `${n}: ${v};`).join(' ');
664
+ const out = [`/* kit ${kit.id} */`];
665
+ if (kit.light.length > 0)
666
+ out.push(`.wf.kit { ${decls(kit.light)} }`);
667
+ if (target === 'page' && kit.dark.length > 0)
668
+ out.push(`@media (prefers-color-scheme: dark) { .wf.kit { ${decls(kit.dark)} } }`);
669
+ out.push(`.wf.kit {\n${kit.css}\n}`);
670
+ // a frame is a pure function of time, and a screen is a still picture: nothing in it moves
671
+ out.push('.wf.kit *, .wf.kit *::before, .wf.kit *::after { animation: none !important; transition: none !important; }');
672
+ if (target === 'page') {
673
+ // the chips and outlines are the page's marks, so they keep the page's colours whatever the kit names its tokens
674
+ out.push('.screen { --page-muted: var(--muted); --page-line: var(--line); --page-focus: var(--focus); }');
675
+ out.push('.wf.kit .chip { color: var(--page-muted); border-color: var(--page-line); background: none; }');
676
+ out.push('.wf.kit .wf-cond { outline-color: var(--page-muted); } .wf.kit .wf-focus { outline-color: var(--page-focus); }');
677
+ out.push(':where(.wf.kit) button:disabled:not(.wf-btn, .wf-link) { opacity: 1; }');
678
+ }
679
+ else {
680
+ out.push(`:root { --kit-zoom: ${kit.zoom}; } .page { padding: 0; } .view .appcard { padding: 38px 46px 30px; }`);
681
+ out.push('.view .wf.kit { zoom: var(--kit-zoom); padding: calc(26px / var(--kit-zoom)) calc(46px / var(--kit-zoom)) calc(32px / var(--kit-zoom)); }');
682
+ out.push('.view .wf.kit .pressed { transform: scale(.95); }');
683
+ }
684
+ return `\n${out.join('\n')}\n`;
685
+ }
686
+ // Runs once in the video stage, before the first frame: a kit draws at its own sizes, so its screens are drawn at the
687
+ // largest zoom (at most the kit's) at which every one of them fits between the window's top and the caption. One zoom
688
+ // for all, so a screen does not change size from step to step.
689
+ export function kitStageScript(kit) {
690
+ return `(() => {
691
+ const room = 480; // the window's page starts at y 146; captions start near y 627
692
+ const win = document.getElementById('win');
693
+ const views = [...document.querySelectorAll('.view')];
694
+ const fits = (z) => {
695
+ document.documentElement.style.setProperty('--kit-zoom', String(z));
696
+ return views.every((v) => {
697
+ const wf = v.querySelector('.wf.kit');
698
+ if (wf === null) return true;
699
+ v.style.display = 'block';
700
+ const h = wf.getBoundingClientRect().height;
701
+ v.style.display = '';
702
+ return h <= room;
703
+ });
704
+ };
705
+ win.style.display = 'block';
706
+ let z = ${kit.zoom};
707
+ while (z > 0.5 && !fits(z)) z = Math.round((z - 0.05) * 100) / 100;
708
+ win.style.display = '';
709
+ })();`;
710
+ }