@tiaforge-ui/sinter 0.0.0-stage → 0.2.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.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +158 -2
  3. package/dist/cache.d.ts +5 -0
  4. package/dist/cache.js +95 -0
  5. package/dist/check/index.d.ts +85 -0
  6. package/dist/check/index.js +156 -0
  7. package/dist/check/openapi-types.d.ts +6 -0
  8. package/dist/check/openapi-types.js +84 -0
  9. package/dist/check/shadow.d.ts +49 -0
  10. package/dist/check/shadow.js +279 -0
  11. package/dist/cli.d.ts +2 -0
  12. package/dist/cli.js +67 -0
  13. package/dist/compiler/check-unit.d.ts +109 -0
  14. package/dist/compiler/check-unit.js +3 -0
  15. package/dist/compiler/compile.d.ts +66 -0
  16. package/dist/compiler/compile.js +1316 -0
  17. package/dist/compiler/diagnostic-docs.d.ts +10 -0
  18. package/dist/compiler/diagnostic-docs.js +142 -0
  19. package/dist/compiler/diagnostics.d.ts +57 -0
  20. package/dist/compiler/diagnostics.js +77 -0
  21. package/dist/compiler/expr.d.ts +97 -0
  22. package/dist/compiler/expr.js +853 -0
  23. package/dist/compiler/index.d.ts +22 -0
  24. package/dist/compiler/index.js +160 -0
  25. package/dist/compiler/openapi.d.ts +40 -0
  26. package/dist/compiler/openapi.js +177 -0
  27. package/dist/compiler/vocabulary.d.ts +12 -0
  28. package/dist/compiler/vocabulary.js +39 -0
  29. package/dist/dev.d.ts +10 -0
  30. package/dist/dev.js +92 -0
  31. package/dist/each.d.ts +3 -0
  32. package/dist/each.js +121 -0
  33. package/dist/index.d.ts +7 -0
  34. package/dist/index.js +4 -0
  35. package/dist/ir.d.ts +152 -0
  36. package/dist/ir.js +121 -0
  37. package/dist/lsp/quickfix.d.ts +15 -0
  38. package/dist/lsp/quickfix.js +58 -0
  39. package/dist/lsp/rpc.d.ts +31 -0
  40. package/dist/lsp/rpc.js +120 -0
  41. package/dist/lsp/server.d.ts +36 -0
  42. package/dist/lsp/server.js +281 -0
  43. package/dist/lsp/spec-location.d.ts +9 -0
  44. package/dist/lsp/spec-location.js +42 -0
  45. package/dist/lsp/typescript.d.ts +77 -0
  46. package/dist/lsp/typescript.js +242 -0
  47. package/dist/lsp/workspace.d.ts +56 -0
  48. package/dist/lsp/workspace.js +228 -0
  49. package/dist/optimistic.d.ts +3 -0
  50. package/dist/optimistic.js +15 -0
  51. package/dist/paginate.d.ts +3 -0
  52. package/dist/paginate.js +198 -0
  53. package/dist/persist.d.ts +3 -0
  54. package/dist/persist.js +55 -0
  55. package/dist/project.d.ts +42 -0
  56. package/dist/project.js +103 -0
  57. package/dist/runtime/app.d.ts +152 -0
  58. package/dist/runtime/app.js +1256 -0
  59. package/dist/runtime/config.d.ts +53 -0
  60. package/dist/runtime/config.js +118 -0
  61. package/dist/runtime/env.d.ts +18 -0
  62. package/dist/runtime/env.js +23 -0
  63. package/dist/runtime/helpers.d.ts +9 -0
  64. package/dist/runtime/helpers.js +55 -0
  65. package/dist/runtime/request.d.ts +23 -0
  66. package/dist/runtime/request.js +93 -0
  67. package/dist/runtime/security.d.ts +12 -0
  68. package/dist/runtime/security.js +34 -0
  69. package/dist/runtime/store.d.ts +98 -0
  70. package/dist/runtime/store.js +255 -0
  71. package/dist/sse.d.ts +7 -0
  72. package/dist/sse.js +209 -0
  73. package/dist/testing/index.d.ts +73 -0
  74. package/dist/testing/index.js +359 -0
  75. package/dist/vite.d.ts +11 -0
  76. package/dist/vite.js +96 -0
  77. package/dist/ws.d.ts +18 -0
  78. package/dist/ws.js +57 -0
  79. package/docs/diagnostics.md +283 -0
  80. package/html-custom-data.json +250 -0
  81. package/llms.txt +632 -0
  82. package/package.json +93 -4
@@ -0,0 +1,1316 @@
1
+ // The compiler: one pure, deterministic function from annotated HTML to a
2
+ // rewritten page plus its IR and function table.
3
+ import * as parse5 from 'parse5';
4
+ import { parseExpressionAt } from 'acorn';
5
+ import { didYouMean, severityOf } from './diagnostics.js';
6
+ import { analyze } from './expr.js';
7
+ import { ATTRIBUTES, ELEMENTS } from './vocabulary.js';
8
+ import { matchOperation, matchResource, queryNames, walk } from './openapi.js';
9
+ const ATTRS = ATTRIBUTES.map((a) => a.name);
10
+ const METHODS = ['get', 'post', 'put', 'patch', 'delete'];
11
+ const ACTION_ONLY = ['body', 'trigger', 'debounce', 'concurrency', 'optimistic', 'then', 'catch'];
12
+ const UNSAFE_ELEMENTS = new Set(['script', 'style', 'iframe', 'object', 'embed', 'base', 'meta', 'link', 'animate', 'set', 'animatemotion', 'animatetransform']);
13
+ const FORM_CONTROLS = new Set(['input', 'select', 'textarea']);
14
+ const RESOURCE_ATTRS = ELEMENTS.find((e) => e.name === 'sn-resource').attrs;
15
+ const EVENT_ATTRS = ELEMENTS.find((e) => e.name === 'sn-event').attrs;
16
+ const IDENT = /^[A-Za-z_][\w]*$/;
17
+ const BAD_PARAM = /^(constructor|__proto__|prototype)$/;
18
+ const SECRET_AUTOCOMPLETE = /^(cc-|one-time-code$|current-password$|new-password$)/;
19
+ export function compileParts(html, options) {
20
+ return new Compiler(options).run(html);
21
+ }
22
+ class Compiler {
23
+ opts;
24
+ diagnostics = [];
25
+ files = new Set();
26
+ fns = [];
27
+ fnIndex = new Map();
28
+ origins = new WeakMap();
29
+ lineStarts = new Map();
30
+ page;
31
+ resources = [];
32
+ resourceEls = new Map();
33
+ syncs = [];
34
+ roots = [];
35
+ templates = [];
36
+ actions = [];
37
+ remove = [];
38
+ embedded = new Map();
39
+ features = new Set();
40
+ /** action functions called by the page (`archive`, `pgp.decrypt`) */
41
+ calls = new Set();
42
+ nextId = 0;
43
+ nextSid = 0;
44
+ check;
45
+ /** StateEntry / LoopEntry → frame index in `check.frames` */
46
+ frameOf = new Map();
47
+ /** index in `check.exprs` of the expression `expr()` recorded last, or null */
48
+ lastExpr = null;
49
+ constructor(opts) {
50
+ this.opts = opts;
51
+ this.check = opts.check ? { file: opts.file, resources: [], frames: [], exprs: [] } : null;
52
+ const ns = new Map();
53
+ for (const [k, v] of Object.entries(opts.helpers.namespaces))
54
+ ns.set(k, new Set(v.members));
55
+ this.page = {
56
+ resources: new Map(), helpers: new Set(opts.helpers.names), namespaces: ns,
57
+ actions: new Set([...(opts.actions?.names ?? []), ...Object.keys(opts.actions?.namespaces ?? {})]),
58
+ usedHelpers: new Set(), usedNs: new Map(), usesNow: false, pathParams: new Set(),
59
+ };
60
+ }
61
+ run(html) {
62
+ const doc = parse5.parse(html, { sourceCodeLocationInfo: true });
63
+ this.tag(doc, { file: this.opts.file, src: html, chain: [] });
64
+ this.expandIncludes(doc, [this.opts.file]);
65
+ this.declare(doc);
66
+ const paths = this.pathPatterns(doc);
67
+ this.walkChildren(doc, { scope: { loops: [], states: [] }, list: this.roots, tid: -1, path: [], stateIdx: -1 });
68
+ for (const [name, id] of this.embedded) {
69
+ const r = this.resources.find((x) => x.name === name);
70
+ if (r)
71
+ r.data = id;
72
+ }
73
+ for (const info of this.page.resources.values()) {
74
+ if (!info.used) {
75
+ const el = this.resourceEls.get(info.name);
76
+ this.report(el, 'name', 'SN501', `resource \`${info.name}\` is declared but never read`, 0);
77
+ }
78
+ }
79
+ for (const el of this.remove)
80
+ detach(el);
81
+ this.injectScript(doc);
82
+ // row templates (data-sn-for) get a compiled element lookup; if/else branches render once per
83
+ // toggle and use the generic path walk, which costs no code per template
84
+ const rowTemplates = new Set([...this.roots, ...this.templates.flatMap((t) => t.nodes)].flatMap((n) => n.b.filter((b) => b.k === 'for').map((b) => b.x)));
85
+ for (const t of this.templates)
86
+ if (t.nodes.length && rowTemplates.has(t.id))
87
+ t.find = this.fn(findCode(t.nodes));
88
+ const ir = { v: 1, resources: this.resources, syncs: this.syncs, roots: this.roots, templates: this.templates, actions: this.actions };
89
+ if (this.page.usesNow)
90
+ ir.now = true;
91
+ if (paths.length)
92
+ ir.path = paths;
93
+ const builtin = [];
94
+ const user = [];
95
+ for (const h of [...this.page.usedHelpers].sort())
96
+ (this.page.helpers.has(h) ? user : builtin).push(h);
97
+ const namespaces = {};
98
+ for (const [k, v] of [...this.page.usedNs].sort(([a], [b]) => a.localeCompare(b)))
99
+ namespaces[k] = [...v].sort();
100
+ return {
101
+ html: parse5.serialize(doc),
102
+ ir, fns: this.fns,
103
+ features: [...(this.opts.dev ? ['dev'] : []), ...['sse', 'optimistic', 'paginate', 'persist', 'cache', 'each'].filter((f) => this.features.has(f))],
104
+ helpers: { user, builtin }, namespaces, calls: [...this.calls].sort(),
105
+ diagnostics: this.diagnostics, files: [...this.files].sort(),
106
+ ...(this.check ? { check: this.check } : {}),
107
+ };
108
+ }
109
+ // ---- positions and diagnostics
110
+ tag(node, origin) {
111
+ this.origins.set(node, origin);
112
+ if ('childNodes' in node)
113
+ for (const c of node.childNodes)
114
+ this.tag(c, origin);
115
+ if (node.nodeName === 'template')
116
+ this.tag(node.content, origin);
117
+ }
118
+ lineCol(src, offset) {
119
+ let starts = this.lineStarts.get(src);
120
+ if (!starts) {
121
+ starts = [0];
122
+ for (let i = 0; i < src.length; i++)
123
+ if (src[i] === '\n')
124
+ starts.push(i + 1);
125
+ this.lineStarts.set(src, starts);
126
+ }
127
+ let lo = 0, hi = starts.length - 1;
128
+ while (lo < hi) {
129
+ const mid = (lo + hi + 1) >> 1;
130
+ if (starts[mid] <= offset)
131
+ lo = mid;
132
+ else
133
+ hi = mid - 1;
134
+ }
135
+ return { line: lo + 1, col: offset - starts[lo] + 1 };
136
+ }
137
+ /** Offset in the element's source file where the value of `attr` starts. */
138
+ valueOffset(el, attr) {
139
+ const loc = el.sourceCodeLocation;
140
+ if (!loc)
141
+ return 0;
142
+ if (!attr)
143
+ return loc.startOffset;
144
+ const a = loc.attrs?.[attr];
145
+ if (!a)
146
+ return loc.startOffset;
147
+ const src = this.origins.get(el)?.src ?? '';
148
+ let i = a.startOffset + attr.length;
149
+ while (i < a.endOffset && src[i] !== '=')
150
+ i++;
151
+ if (i >= a.endOffset)
152
+ return a.startOffset;
153
+ i++;
154
+ while (i < a.endOffset && /\s/.test(src[i]))
155
+ i++;
156
+ if (src[i] === '"' || src[i] === "'")
157
+ i++;
158
+ return i;
159
+ }
160
+ /** `inExpr`: the offset points into an expression; otherwise offset 0 is about the whole attribute (or element) */
161
+ /**
162
+ * `data-sn-call` / `call`: exactly one call of an actions module function, `name(args)` or
163
+ * `ns.name(args)`. Compiles to two table entries: the arguments as one array expression, and a
164
+ * runner that calls the imported function with them and the context.
165
+ */
166
+ callExpr(el, attrName, scope, deps, magic) {
167
+ const text = attr(el, attrName) ?? '';
168
+ const shape = `\`${attrName}\` is one call of an action, e.g. \`pgp.decrypt(msg)\``;
169
+ let ast;
170
+ try {
171
+ ast = parseExpressionAt(text, 0, { ecmaVersion: 'latest' });
172
+ }
173
+ catch (e) {
174
+ this.report(el, attrName, 'SN401', `\`${attrName}\`: ${e.message.replace(/ \(\d+:\d+\)$/, '')}`, e.pos ?? 0);
175
+ return null;
176
+ }
177
+ if (ast.type !== 'CallExpression' || ast.optional || ast.end !== text.trimEnd().length) {
178
+ this.report(el, attrName, 'SN401', shape, 0);
179
+ return null;
180
+ }
181
+ const acts = this.opts.actions ?? { module: null, names: [], namespaces: {} };
182
+ const callee = ast.callee;
183
+ const all = [...acts.names, ...Object.entries(acts.namespaces).flatMap(([ns, m]) => m.members.map((x) => `${ns}.${x}`))];
184
+ let name, local;
185
+ if (callee.type === 'Identifier') {
186
+ name = callee.name;
187
+ local = `$a$${name}`;
188
+ if (!acts.names.includes(name)) {
189
+ this.report(el, attrName, 'SN310', `\`${name}\` is not exported by the actions module.${didYouMean(name, all)}`, callee.start);
190
+ return null;
191
+ }
192
+ }
193
+ else if (callee.type === 'MemberExpression' && !callee.computed && callee.object.type === 'Identifier' && callee.property.type === 'Identifier') {
194
+ name = `${callee.object.name}.${callee.property.name}`;
195
+ local = `$a$${callee.object.name}$${callee.property.name}`;
196
+ if (!acts.namespaces[callee.object.name]?.members.includes(callee.property.name)) {
197
+ this.report(el, attrName, 'SN310', `\`${name}\` is not exported by the actions module.${didYouMean(name, all)}`, callee.start);
198
+ return null;
199
+ }
200
+ }
201
+ else {
202
+ this.report(el, attrName, 'SN401', shape, callee.start);
203
+ return null;
204
+ }
205
+ const open = text.indexOf('(', callee.end);
206
+ const r = this.expr(el, attrName, scope, { mode: 'value', ...(magic ? { magic } : {}) }, `[${text.slice(open + 1, ast.end - 1)}]`, open);
207
+ if (this.check)
208
+ this.typeLast({ returns: { kind: 'call', name, args: true } });
209
+ if (deps)
210
+ for (const d of r.deps)
211
+ deps.add(d);
212
+ if (!r.ok)
213
+ return null;
214
+ this.calls.add(name);
215
+ return { args: this.fn(r.code), run: this.fn(`($a, $c) => ${local}(...$a, $c)`), name };
216
+ }
217
+ /** `direction-values="{prev: 'backward', next: 'forward'}"`: static data, string values for known directions only */
218
+ directionValues(el) {
219
+ const text = attr(el, 'direction-values');
220
+ const DIRS = ['first', 'prev', 'next', 'last'];
221
+ const fail = (message, offset = 0) => { this.report(el, 'direction-values', 'SN401', message, offset); return null; };
222
+ let ast;
223
+ try {
224
+ ast = parseExpressionAt(text, 0, { ecmaVersion: 'latest' });
225
+ }
226
+ catch (e) {
227
+ return fail(`\`direction-values\`: ${e.message.replace(/ \(\d+:\d+\)$/, '')}`, e.pos ?? 0);
228
+ }
229
+ if (ast.type !== 'ObjectExpression' || ast.end !== text.trimEnd().length) {
230
+ return fail("`direction-values` is an object literal of strings, e.g. `{prev: 'backward', next: 'forward'}`");
231
+ }
232
+ const out = {};
233
+ for (const p of ast.properties) {
234
+ if (p.type !== 'Property' || p.computed || p.kind !== 'init' || p.method || p.shorthand) {
235
+ return fail("`direction-values` is an object literal of strings, e.g. `{prev: 'backward', next: 'forward'}`", p.start);
236
+ }
237
+ const key = p.key.type === 'Identifier' ? p.key.name : p.key.type === 'Literal' ? String(p.key.value) : '';
238
+ if (!DIRS.includes(key)) {
239
+ return fail(`\`${key}\` is not a direction; directions are \`first\`, \`prev\`, \`next\` and \`last\`.${didYouMean(key, DIRS)}`, p.key.start);
240
+ }
241
+ if (p.value.type !== 'Literal' || typeof p.value.value !== 'string') {
242
+ return fail(`the value for \`${key}\` must be a string literal`, p.value.start);
243
+ }
244
+ out[key] = p.value.value;
245
+ }
246
+ return out;
247
+ }
248
+ report(el, attr, code, message, offset, hint, inExpr = false) {
249
+ const origin = (el && this.origins.get(el)) || { file: this.opts.file, src: '', chain: [] };
250
+ const base = el ? this.valueOffset(el, attr) : 0;
251
+ const { line, col } = el?.sourceCodeLocation ? this.lineCol(origin.src, base + offset) : { line: 1, col: 1 };
252
+ let msg = message;
253
+ if (origin.chain.length)
254
+ msg += ` (included from ${origin.chain.join(' ← ')})`;
255
+ const d = { severity: severityOf(code), code, message: msg, file: origin.file, line, col };
256
+ if (hint)
257
+ d.hint = hint;
258
+ const loc = el?.sourceCodeLocation;
259
+ if (loc && !inExpr && offset === 0) {
260
+ const a = attr ? loc.attrs?.[attr] : null;
261
+ const [from, to] = a ? [a.startOffset, a.endOffset] : [loc.startOffset + 1, loc.startOffset + 1 + el.tagName.length];
262
+ const start = this.lineCol(origin.src, from);
263
+ const end = this.lineCol(origin.src, to);
264
+ d.span = { line: start.line, col: start.col, endLine: end.line, endCol: end.col };
265
+ }
266
+ this.diagnostics.push(d);
267
+ }
268
+ reporter(el, attr, shift = 0) {
269
+ return (code, message, offset, hint) => this.report(el, attr, code, message, offset + shift, hint, true);
270
+ }
271
+ // ---- includes
272
+ expandIncludes(parent, chain) {
273
+ const children = [...parent.childNodes];
274
+ for (const child of children) {
275
+ if (!isElement(child))
276
+ continue;
277
+ if (child.tagName === 'sn-include') {
278
+ this.include(child, chain);
279
+ continue;
280
+ }
281
+ this.expandIncludes(child, chain);
282
+ if (child.tagName === 'template')
283
+ this.expandIncludes(child.content, chain);
284
+ }
285
+ }
286
+ include(el, chain) {
287
+ const src = attr(el, 'src');
288
+ const origin = this.origins.get(el);
289
+ const fail = (code, msg) => { this.report(el, 'src', code, msg, 0); detach(el); };
290
+ if (src == null || src === '' || src.includes('{'))
291
+ return fail('SN305', 'partial `src` must be a literal path');
292
+ for (const a of el.attrs)
293
+ if (a.name !== 'src')
294
+ this.report(el, a.name, 'SN101', `\`${a.name}\` is not an attribute of \`<sn-include>\``, 0);
295
+ const resolved = resolvePath(dirname(origin.file), src);
296
+ const root = normalize(this.opts.root).replace(/\/$/, '');
297
+ if (!(resolved === root || resolved.startsWith(root + '/'))) {
298
+ return fail('SN305', `partial \`${src}\` not found or outside the project root`);
299
+ }
300
+ if (chain.includes(resolved)) {
301
+ return fail('SN306', `${[...chain, resolved].map(basename).join(' → ')} includes itself`);
302
+ }
303
+ let text;
304
+ try {
305
+ text = this.opts.readFile(resolved);
306
+ }
307
+ catch {
308
+ return fail('SN305', `partial \`${src}\` not found or outside the project root`);
309
+ }
310
+ this.files.add(resolved);
311
+ const frag = parse5.parseFragment(text, { sourceCodeLocationInfo: true });
312
+ const loc = el.sourceCodeLocation ? this.lineCol(origin.src, el.sourceCodeLocation.startOffset) : { line: 1, col: 1 };
313
+ const here = `${basename(origin.file)}:${loc.line}`;
314
+ this.tag(frag, { file: resolved, src: text, chain: [here, ...origin.chain] });
315
+ this.expandIncludes(frag, [...chain, resolved]);
316
+ const parent = el.parentNode;
317
+ const i = parent.childNodes.indexOf(el);
318
+ for (const c of frag.childNodes)
319
+ c.parentNode = parent;
320
+ parent.childNodes.splice(i, 1, ...frag.childNodes);
321
+ // the browser merges adjacent text when it parses the output; merge now so node paths agree
322
+ mergeText(parent);
323
+ }
324
+ // ---- declarations (first pass: names only, so expressions can see every resource)
325
+ declare(parent, inTemplate = false) {
326
+ for (const child of parent.childNodes) {
327
+ if (!isElement(child))
328
+ continue;
329
+ if (child.tagName === 'sn-resource') {
330
+ if (inTemplate) {
331
+ this.report(child, null, 'SN102', '`<sn-resource>` cannot be inside a `<template>`', 0);
332
+ continue;
333
+ }
334
+ const name = attr(child, 'name');
335
+ if (!name || !IDENT.test(name) || name.startsWith('$')) {
336
+ this.report(child, 'name', 'SN401', 'resource `name` must be a valid JavaScript identifier', 0);
337
+ continue;
338
+ }
339
+ if (this.page.resources.has(name)) {
340
+ this.report(child, 'name', 'SN302', `resource \`${name}\` is declared twice`, 0);
341
+ continue;
342
+ }
343
+ const key = attr(child, 'key');
344
+ const paginate = attr(child, 'paginate');
345
+ const info = { name, key: key ? key.split(',').map((s) => s.trim()).filter(Boolean) : null, paginate: paginate ?? null, used: false };
346
+ if (paginate === 'keyset' && attr(child, 'offset-param') != null)
347
+ info.jumps = true;
348
+ this.page.resources.set(name, info);
349
+ this.resourceEls.set(name, child);
350
+ continue;
351
+ }
352
+ this.declare(child, inTemplate);
353
+ if (child.tagName === 'template')
354
+ this.declare(child.content, true);
355
+ }
356
+ }
357
+ /** `<html data-sn-path="/a /b/:id">`: the URL shapes this page is served for. */
358
+ pathPatterns(doc) {
359
+ const html = doc.childNodes.find((n) => isElement(n) && n.tagName === 'html');
360
+ const value = html && attr(html, 'data-sn-path');
361
+ if (value == null)
362
+ return [];
363
+ const patterns = value.trim().split(/\s+/).filter(Boolean);
364
+ for (const p of patterns) {
365
+ const segs = p.split('/').slice(1);
366
+ const bad = !p.startsWith('/') || p.startsWith('//') || segs.some((s, i) => s[0] === ':' ? !IDENT.test(s.slice(1)) || BAD_PARAM.test(s.slice(1)) : (s === '' && i < segs.length - 1) || /[?#:]/.test(s));
367
+ if (bad)
368
+ this.report(html, 'data-sn-path', 'SN401', `\`${p}\` is not a path pattern; use literal segments and \`:name\`, e.g. \`/article/:slug\``, value.indexOf(p));
369
+ for (const s of segs)
370
+ if (s[0] === ':')
371
+ this.page.pathParams.add(s.slice(1));
372
+ }
373
+ return patterns;
374
+ }
375
+ // ---- main walk
376
+ walkChildren(parent, ctx) {
377
+ let lastIf = null;
378
+ const kids = parent.childNodes;
379
+ for (let i = 0; i < kids.length; i++) {
380
+ const child = kids[i];
381
+ if (!isElement(child)) {
382
+ if (child.nodeName === '#text' && child.value.trim() === '')
383
+ continue;
384
+ if (child.nodeName === '#comment')
385
+ continue;
386
+ lastIf = null;
387
+ continue;
388
+ }
389
+ const res = this.element(child, ctx, i, lastIf);
390
+ lastIf = res;
391
+ }
392
+ }
393
+ element(el, ctx, index, prevIf) {
394
+ switch (el.tagName) {
395
+ case 'sn-resource':
396
+ this.resource(el, ctx);
397
+ return null;
398
+ case 'sn-sync':
399
+ this.sync(el, ctx);
400
+ return null;
401
+ case 'sn-event':
402
+ this.report(el, null, 'SN102', '`<sn-event>` must be a child of `<sn-sync>`', 0);
403
+ this.remove.push(el);
404
+ return null;
405
+ }
406
+ if (el.tagName.startsWith('sn-')) {
407
+ this.report(el, null, 'SN101', `\`<${el.tagName}>\` is not a sinter element.${didYouMean(el.tagName, ['sn-resource', 'sn-sync', 'sn-event', 'sn-include'])}`, 0);
408
+ }
409
+ const sn = el.attrs.filter((a) => a.name.startsWith('data-sn-'));
410
+ const get = (n) => sn.find((a) => a.name === `data-sn-${n}`)?.value;
411
+ const has = (n) => sn.some((a) => a.name === `data-sn-${n}`);
412
+ // unknown attributes
413
+ const known = new Set(ATTRS.map((a) => `data-sn-${a}`));
414
+ for (const a of sn) {
415
+ if (known.has(a.name) || a.name.startsWith('data-sn-on-'))
416
+ continue;
417
+ this.report(el, a.name, 'SN101', `\`${a.name}\` is not a sinter attribute.${didYouMean(a.name, [...known, 'data-sn-on-click'])}`, 0);
418
+ }
419
+ if (has('path') && el.tagName !== 'html')
420
+ this.report(el, 'data-sn-path', 'SN102', '`data-sn-path` belongs on `<html>`', 0);
421
+ // embedded data
422
+ if (has('data')) {
423
+ if (el.tagName !== 'script' || attr(el, 'type') !== 'application/json') {
424
+ this.report(el, 'data-sn-data', 'SN102', '`data-sn-data` must be on a `<script type="application/json">`', 0);
425
+ }
426
+ else {
427
+ const name = get('data');
428
+ if (!this.page.resources.has(name)) {
429
+ this.report(el, 'data-sn-data', 'SN301', `resource \`${name}\` is not declared on this page.${didYouMean(name, this.page.resources.keys())}`, 0);
430
+ }
431
+ else if (ctx.tid >= 0) {
432
+ this.report(el, 'data-sn-data', 'SN102', 'embedded data cannot be inside a `<template>`', 0);
433
+ }
434
+ else {
435
+ const id = this.nextId++;
436
+ this.embedded.set(name, id);
437
+ setAttr(el, 'data-sn', String(id));
438
+ }
439
+ }
440
+ stripSn(el);
441
+ return null;
442
+ }
443
+ if (sn.length && UNSAFE_ELEMENTS.has(el.tagName.toLowerCase())) {
444
+ this.report(el, sn[0].name, 'SN207', `\`<${el.tagName}>\` cannot carry sinter bindings`, 0);
445
+ stripSn(el);
446
+ return null;
447
+ }
448
+ const node = { tag: el.tagName, p: ctx.stateIdx, b: [] };
449
+ const inDialog = ctx.dialog || el.tagName === 'dialog';
450
+ if (inDialog)
451
+ node.dl = 1;
452
+ const nodeIdx = ctx.list.length;
453
+ let scope = ctx.scope;
454
+ let result = null;
455
+ // state
456
+ if (has('state')) {
457
+ const r = this.expr(el, 'data-sn-state', scope, { mode: 'value', object: 'state' });
458
+ if (r.ok) {
459
+ const sid = this.nextSid++;
460
+ node.state = this.fn(r.code);
461
+ node.sid = sid;
462
+ const entry = { sid, names: new Set(r.entries.map((e) => e.key)) };
463
+ if (this.check) {
464
+ this.frameOf.set(entry, this.check.frames.length);
465
+ const first = [...entry.names].map((n) => r.entries.find((e) => e.key === n));
466
+ const locs = first.map((e) => this.locOf(el, 'data-sn-state', e.offset));
467
+ this.check.frames.push({ kind: 'state', names: [...entry.names], init: this.lastExpr, locs });
468
+ }
469
+ scope = { loops: scope.loops, states: [...scope.states, entry] };
470
+ if (has('persist') && !has('model')) {
471
+ node.persist = get('persist');
472
+ this.features.add('persist');
473
+ }
474
+ }
475
+ }
476
+ const isTemplate = el.tagName === 'template';
477
+ const valueB = (k, name) => {
478
+ const r = this.expr(el, name, scope, { mode: 'value' });
479
+ if (r.ok)
480
+ node.b.push(binding({ k, f: this.fn(r.code), d: r.deps, ...usesEl(r) }));
481
+ };
482
+ for (const n of ['for', 'if', 'else']) {
483
+ if (has(n) && !isTemplate)
484
+ this.report(el, `data-sn-${n}`, 'SN102', `\`data-sn-${n}\` must be on a \`<template>\``, 0);
485
+ }
486
+ if (has('key') && !(isTemplate && has('for')))
487
+ this.report(el, 'data-sn-key', 'SN102', '`data-sn-key` belongs on a `<template data-sn-for>`', 0);
488
+ if (has('text'))
489
+ valueB('text', 'data-sn-text');
490
+ if (has('show'))
491
+ valueB('show', 'data-sn-show');
492
+ if (has('attr'))
493
+ this.attrBinding(el, node, scope);
494
+ if (has('class'))
495
+ this.objectBinding(el, node, scope, 'class');
496
+ if (has('style'))
497
+ this.objectBinding(el, node, scope, 'style');
498
+ if (has('prop')) {
499
+ if (!el.tagName.includes('-'))
500
+ this.report(el, 'data-sn-prop', 'SN102', '`data-sn-prop` is for custom elements only; use `data-sn-attr`', 0);
501
+ else
502
+ this.objectBinding(el, node, scope, 'prop');
503
+ }
504
+ if (has('model'))
505
+ this.model(el, node, scope, get('persist'));
506
+ else if (has('persist') && !has('state'))
507
+ this.report(el, 'data-sn-persist', 'SN102', '`data-sn-persist` needs `data-sn-model` or `data-sn-state` on the same element', 0);
508
+ const methods = METHODS.filter((m) => has(m));
509
+ const isCall = has('call');
510
+ // without a method, `data-sn-debounce` delays the element's own handlers
511
+ let handlerDebounce = 0;
512
+ if (!methods.length && !isCall && has('debounce')) {
513
+ handlerDebounce = Number(get('debounce'));
514
+ if (!Number.isFinite(handlerDebounce) || handlerDebounce < 0)
515
+ this.report(el, 'data-sn-debounce', 'SN401', '`data-sn-debounce` takes milliseconds', 0);
516
+ if (!sn.some((a) => a.name.startsWith('data-sn-on-')))
517
+ this.report(el, 'data-sn-debounce', 'SN102', '`data-sn-debounce` needs a method attribute (`data-sn-get`, `-post`, …) or a `data-sn-on-*` handler on the same element', 0);
518
+ }
519
+ for (const a of sn) {
520
+ if (!a.name.startsWith('data-sn-on-'))
521
+ continue;
522
+ const event = a.name.slice('data-sn-on-'.length);
523
+ if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(event)) {
524
+ this.report(el, a.name, 'SN101', `event names are lowercase kebab-case: \`${a.name}\``, 0);
525
+ continue;
526
+ }
527
+ const r = this.expr(el, a.name, scope, { mode: 'handler', magic: ['$event'] });
528
+ if (r.ok)
529
+ node.b.push(binding({ k: 'on', n: event, f: this.fn(r.code), w: r.writes, ...(handlerDebounce > 0 ? { ms: handlerDebounce } : {}) }));
530
+ }
531
+ if (methods.length > 1)
532
+ this.report(el, `data-sn-${methods[1]}`, 'SN102', 'an element can have only one method attribute', 0);
533
+ if (methods.length && isCall)
534
+ this.report(el, 'data-sn-call', 'SN102', 'an element has one action: a method attribute or `data-sn-call`, not both', 0);
535
+ if (methods.length)
536
+ this.action(el, node, scope, methods[0], get);
537
+ else if (isCall)
538
+ this.action(el, node, scope, 'call', get);
539
+ else
540
+ for (const n of ACTION_ONLY)
541
+ if (has(n) && n !== 'debounce')
542
+ this.report(el, `data-sn-${n}`, 'SN102', `\`data-sn-${n}\` needs a method attribute (\`data-sn-get\`, \`-post\`, …) on the same element`, 0);
543
+ // templates
544
+ let content = null;
545
+ if (isTemplate && has('for'))
546
+ content = this.forTemplate(el, node, scope, get('for'), get('key'));
547
+ else if (isTemplate && has('if')) {
548
+ const r = this.expr(el, 'data-sn-if', scope, { mode: 'value' });
549
+ const tid = this.newTemplate();
550
+ const b = binding({ k: 'if', f: this.fn(r.code), d: r.deps, x: tid, ...usesEl(r) });
551
+ node.b.push(b);
552
+ result = { node, binding: b };
553
+ content = { tid, scope };
554
+ }
555
+ else if (isTemplate && has('else')) {
556
+ if (get('else'))
557
+ this.report(el, 'data-sn-else', 'SN401', '`data-sn-else` takes no value', 0);
558
+ if (!prevIf)
559
+ this.report(el, 'data-sn-else', 'SN102', '`data-sn-else` must be on a `<template>` directly after a `data-sn-if` template', 0);
560
+ const tid = this.newTemplate();
561
+ node.b.push(binding({ k: 'else', x: tid }));
562
+ if (prevIf)
563
+ prevIf.binding.y = nodeIdx;
564
+ content = { tid, scope };
565
+ }
566
+ const hasDirectives = node.b.length > 0 || node.state !== undefined;
567
+ if (hasDirectives) {
568
+ if (ctx.tid < 0) {
569
+ node.id = this.nextId++;
570
+ setAttr(el, 'data-sn', String(node.id));
571
+ }
572
+ else
573
+ node.path = [...ctx.path, index];
574
+ if (this.opts.dev)
575
+ node.src = this.position(el);
576
+ ctx.list.push(node);
577
+ }
578
+ stripSn(el);
579
+ if (content) {
580
+ const t = this.templates[content.tid];
581
+ this.walkChildren(el.content, { scope: content.scope, list: t.nodes, tid: content.tid, path: [], stateIdx: -1 });
582
+ }
583
+ else if (isTemplate) {
584
+ if (hasSnDeep(el.content)) {
585
+ this.report(el, null, 'SN102', 'directives inside a `<template>` need `data-sn-for` or `data-sn-if` on it', 0);
586
+ }
587
+ }
588
+ else {
589
+ this.walkChildren(el, {
590
+ ...ctx, scope, dialog: inDialog,
591
+ stateIdx: node.state !== undefined && hasDirectives ? nodeIdx : ctx.stateIdx,
592
+ path: ctx.tid >= 0 ? [...ctx.path, index] : ctx.path,
593
+ });
594
+ }
595
+ return result;
596
+ }
597
+ position(el) {
598
+ const o = this.origins.get(el);
599
+ if (!o || !el.sourceCodeLocation)
600
+ return this.opts.file;
601
+ const { line, col } = this.lineCol(o.src, el.sourceCodeLocation.startOffset);
602
+ return `${o.file}:${line}:${col}`;
603
+ }
604
+ newTemplate() {
605
+ const tid = this.templates.length;
606
+ this.templates.push({ id: tid, nodes: [] });
607
+ return tid;
608
+ }
609
+ /** Index of a function in the page's table; identical code shares one entry (one compile in the engine). */
610
+ fn(code) {
611
+ let i = this.fnIndex.get(code);
612
+ if (i === undefined)
613
+ this.fnIndex.set(code, (i = this.fns.push(code) - 1));
614
+ return i;
615
+ }
616
+ expr(el, attrName, scope, o, value, shift = 0) {
617
+ const src = value ?? attr(el, attrName) ?? '';
618
+ const r = analyze(src, scope, this.page, { ...o, report: this.reporter(el, attrName, shift) });
619
+ this.lastExpr = null;
620
+ if (this.check) {
621
+ const i = this.recordExpr(el, attrName, scope, o, src, shift, r);
622
+ // a broken expression is there for editor completion only; nothing refers to it
623
+ if (r.ok)
624
+ this.lastExpr = i;
625
+ else
626
+ this.check.exprs[i].broken = true;
627
+ }
628
+ return r;
629
+ }
630
+ // ---- check unit
631
+ /** Adds type information to the expression `expr()` recorded last. */
632
+ typeLast(info) {
633
+ if (!this.check || this.lastExpr === null)
634
+ return;
635
+ const e = this.check.exprs[this.lastExpr];
636
+ if (info.resultType)
637
+ e.resultType = info.resultType;
638
+ if (info.returns)
639
+ e.returns = info.returns;
640
+ if (info.payload && e.payload)
641
+ e.payload.type = info.payload;
642
+ }
643
+ locOf(el, attrName, shift = 0) {
644
+ const origin = this.origins.get(el) ?? { file: this.opts.file, src: '', chain: [] };
645
+ const { line, col } = el.sourceCodeLocation ? this.lineCol(origin.src, this.valueOffset(el, attrName) + shift) : { line: 1, col: 1 };
646
+ return { file: origin.file, line, col, chain: origin.chain };
647
+ }
648
+ recordExpr(el, attrName, scope, o, src, shift, r) {
649
+ const frames = [];
650
+ for (const f of [...scope.states, ...scope.loops]) {
651
+ const i = this.frameOf.get(f);
652
+ if (i !== undefined && !frames.includes(i))
653
+ frames.push(i);
654
+ }
655
+ let event = null;
656
+ if (attrName.startsWith('data-sn-on-'))
657
+ event = attrName.slice('data-sn-on-'.length);
658
+ else if (o.mode !== 'value' || o.magic?.includes('$event')) {
659
+ event = attr(el, 'data-sn-trigger') ?? (el.tagName === 'form' ? 'submit' : FORM_CONTROLS.has(el.tagName) ? 'change' : 'click');
660
+ }
661
+ const e = {
662
+ src, mode: o.mode === 'value' ? 'value' : 'handler', loc: this.locOf(el, attrName, shift), frames, tag: el.tagName, event,
663
+ result: attrName === 'data-sn-then', error: attrName === 'data-sn-catch',
664
+ };
665
+ if (o.payload)
666
+ e.payload = { names: r.payloadNames ?? [], type: null, ...(r.payloadStates?.length ? { states: r.payloadStates } : {}) };
667
+ this.check.exprs.push(e);
668
+ return this.check.exprs.length - 1;
669
+ }
670
+ // ---- bindings
671
+ attrBinding(el, node, scope) {
672
+ const r = this.expr(el, 'data-sn-attr', scope, { mode: 'value', object: 'literal-keys' });
673
+ if (!r.entries)
674
+ return;
675
+ for (const e of r.entries) {
676
+ const key = e.key.toLowerCase();
677
+ if (key.startsWith('on')) {
678
+ this.report(el, 'data-sn-attr', 'SN207', `\`${e.key}\` cannot be bound with \`data-sn-attr\`; use \`data-sn-on-${key.slice(2)}\``, e.offset);
679
+ continue;
680
+ }
681
+ if (key === 'style') {
682
+ this.report(el, 'data-sn-attr', 'SN207', '`style` cannot be bound with `data-sn-attr`; use `data-sn-style`', e.offset);
683
+ continue;
684
+ }
685
+ if (key === 'srcdoc') {
686
+ this.report(el, 'data-sn-attr', 'SN207', '`srcdoc` cannot be bound', e.offset);
687
+ continue;
688
+ }
689
+ if (key.startsWith('data-sn')) {
690
+ this.report(el, 'data-sn-attr', 'SN207', `\`${e.key}\` cannot be bound`, e.offset);
691
+ continue;
692
+ }
693
+ if ((key === 'id' || key === 'name') && !e.literal) {
694
+ this.report(el, 'data-sn-attr', 'SN503', `\`${key}\` bound from data can shadow globals other scripts read (DOM clobbering)`, e.offset);
695
+ }
696
+ if (r.ok)
697
+ node.b.push(binding({ k: 'attr', n: e.key, f: this.fn(e.code), d: e.deps, ...usesEl(r) }));
698
+ }
699
+ }
700
+ objectBinding(el, node, scope, k) {
701
+ const name = `data-sn-${k}`;
702
+ const r = this.expr(el, name, scope, { mode: 'value', object: k === 'class' ? 'any-keys' : 'literal-keys' });
703
+ if (!r.entries)
704
+ return;
705
+ for (const e of r.entries) {
706
+ let key = e.key;
707
+ if (k === 'prop' && ['innerhtml', 'outerhtml', 'srcdoc', 'innertext', 'outertext'].includes(key.toLowerCase())) {
708
+ this.report(el, name, 'SN207', `\`${key}\` cannot be set with \`data-sn-prop\``, e.offset);
709
+ continue;
710
+ }
711
+ if (k === 'style' && !key.startsWith('--'))
712
+ key = key.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
713
+ if (r.ok)
714
+ node.b.push(binding({ k, n: key, f: this.fn(e.code), d: e.deps, ...usesEl(r) }));
715
+ }
716
+ }
717
+ model(el, node, scope, persist) {
718
+ const tag = el.tagName;
719
+ const type = (attr(el, 'type') ?? 'text').toLowerCase();
720
+ let kind;
721
+ if (tag === 'select')
722
+ kind = attr(el, 'multiple') != null ? 'multi' : 'select';
723
+ else if (tag === 'textarea')
724
+ kind = 'text';
725
+ else if (tag === 'input') {
726
+ if (type === 'file') {
727
+ this.report(el, 'data-sn-model', 'SN104', '`data-sn-model` does not support `type="file"`; submit the form instead', 0);
728
+ return;
729
+ }
730
+ kind = type === 'number' || type === 'range' ? 'number' : type === 'checkbox' ? 'checkbox' : type === 'radio' ? 'radio' : 'text';
731
+ }
732
+ else if (tag.includes('-'))
733
+ kind = 'text';
734
+ else {
735
+ this.report(el, 'data-sn-model', 'SN102', '`data-sn-model` must be on `<input>`, `<select>` or `<textarea>`', 0);
736
+ return;
737
+ }
738
+ if (persist !== undefined) {
739
+ const ac = (attr(el, 'autocomplete') ?? '').toLowerCase().trim();
740
+ if (type === 'password' || SECRET_AUTOCOMPLETE.test(ac)) {
741
+ this.report(el, 'data-sn-persist', 'SN207', '`data-sn-persist` is refused on password, one-time-code and payment fields', 0);
742
+ persist = undefined;
743
+ }
744
+ else
745
+ this.features.add('persist');
746
+ }
747
+ const r = this.expr(el, 'data-sn-model', scope, { mode: 'value' });
748
+ if (!r.ok)
749
+ return;
750
+ if (r.resource) {
751
+ this.report(el, 'data-sn-model', 'SN205', `\`data-sn-model\` cannot write to resource \`${r.resource}\`; copy the item into state with structuredClone()`, 0);
752
+ return;
753
+ }
754
+ if (!r.path || !(r.path[0].startsWith('$s') && r.path.length >= 2)) {
755
+ this.report(el, 'data-sn-model', 'SN203', '`data-sn-model` takes a state path such as `q` or `draft.fn_name`', 0);
756
+ return;
757
+ }
758
+ const inner = r.code.slice('($) => ('.length, -1);
759
+ const setter = this.fn(`($, v) => { ${inner} = v; }`);
760
+ const b = binding({ k: 'model', n: kind, f: this.fn(r.code), a: setter, d: r.deps, w: [r.path.join('.')], ...usesEl(r) });
761
+ if (persist !== undefined)
762
+ b.p = persist;
763
+ node.b.push(b);
764
+ }
765
+ action(el, node, scope, method, get) {
766
+ // arguments and body are taken when the trigger fires, so they see its event
767
+ const call = method === 'call' ? this.callExpr(el, 'data-sn-call', scope, undefined, ['$event']) : null;
768
+ if (method === 'call' && get('body') !== undefined)
769
+ this.report(el, 'data-sn-body', 'SN102', '`data-sn-body` belongs to a method attribute; a call takes its arguments in `data-sn-call`', 0);
770
+ const url = method === 'call' ? [] : this.urlTemplate(el, `data-sn-${method}`, scope);
771
+ const tag = el.tagName;
772
+ const isForm = tag === 'form';
773
+ const trigger = get('trigger') ?? (isForm ? 'submit' : FORM_CONTROLS.has(tag) ? 'change' : 'click');
774
+ if (!/^[a-z][a-z0-9-]*$/.test(trigger))
775
+ this.report(el, 'data-sn-trigger', 'SN401', '`data-sn-trigger` takes a DOM event name', 0);
776
+ const debounce = get('debounce') !== undefined ? Number(get('debounce')) : 0;
777
+ if (!Number.isFinite(debounce) || debounce < 0)
778
+ this.report(el, 'data-sn-debounce', 'SN401', '`data-sn-debounce` takes milliseconds', 0);
779
+ let concurrency = (get('concurrency') ?? (isForm ? 'block' : 'queue'));
780
+ if (!['block', 'queue', 'latest'].includes(concurrency)) {
781
+ this.report(el, 'data-sn-concurrency', 'SN401', '`data-sn-concurrency` is `block`, `queue` or `latest`', 0);
782
+ concurrency = 'queue';
783
+ }
784
+ if (get('concurrency') && method === 'get')
785
+ this.report(el, 'data-sn-concurrency', 'SN102', '`data-sn-concurrency` applies to mutations only; `GET` actions are always latest-wins', 0);
786
+ const fnOf = (n, o, type) => {
787
+ if (get(n) === undefined)
788
+ return { f: null, w: [] };
789
+ const r = this.expr(el, `data-sn-${n}`, scope, o);
790
+ if (type)
791
+ this.typeLast(type);
792
+ return { f: r.ok ? this.fn(r.code) : null, w: r.writes };
793
+ };
794
+ // with OpenAPI specs: the body must fit the request schema, `$result` gets the response's type
795
+ const op = method !== 'call' && this.check && this.opts.openapi?.length ? matchOperation(this.opts.openapi, url, method) : null;
796
+ const typed = op && 'spec' in op ? op : null;
797
+ const body = fnOf('body', { mode: 'value', magic: ['$event'] }, typed?.request != null ? { returns: { kind: 'openapi', spec: typed.spec, schema: typed.request } } : undefined);
798
+ const optimistic = fnOf('optimistic', { mode: 'optimistic' });
799
+ if (optimistic.f !== null)
800
+ this.features.add('optimistic');
801
+ const then = fnOf('then', { mode: 'handler', magic: ['$event', '$result'] }, typed?.body != null ? { resultType: { kind: 'openapi', spec: typed.spec, schema: typed.body } }
802
+ : call ? { resultType: { kind: 'call', name: call.name } } : undefined);
803
+ const catch_ = fnOf('catch', { mode: 'handler', magic: ['$event', '$error'] });
804
+ const def = {
805
+ method: method.toUpperCase(), url, body: body.f, trigger, debounce, concurrency,
806
+ optimistic: optimistic.f, then: then.f, catch: catch_.f, w: [...new Set([...then.w, ...catch_.w])].sort(), form: isForm,
807
+ ...(call ? { call: { args: call.args, run: call.run } } : {}),
808
+ };
809
+ this.actions.push(def);
810
+ node.b.push(binding({ k: 'on', n: trigger, x: this.actions.length - 1 }));
811
+ }
812
+ forTemplate(el, node, scope, value, keySrc) {
813
+ const m = /^\s*(?:\(\s*([A-Za-z_][\w]*)\s*,\s*([A-Za-z_][\w]*)\s*\)|([A-Za-z_][\w]*))\s+in\s+/.exec(value);
814
+ if (!m) {
815
+ this.report(el, 'data-sn-for', 'SN401', '`data-sn-for` takes `item in list` or `(item, i) in list`', 0);
816
+ return null;
817
+ }
818
+ const item = (m[1] ?? m[3]);
819
+ const index = m[2] ?? null;
820
+ const list = this.expr(el, 'data-sn-for', scope, { mode: 'value' }, value.slice(m[0].length), m[0].length);
821
+ const listExpr = this.lastExpr;
822
+ const tid = this.newTemplate();
823
+ const t = this.templates[tid];
824
+ const ii = `$i${tid}`;
825
+ t.item = item;
826
+ t.index = index;
827
+ t.ii = ii;
828
+ const resInfo = list.resource ? this.page.resources.get(list.resource) : undefined;
829
+ const alias = list.path && list.path[0].startsWith('$s') ? [...list.path, `{${ii}}`] : null;
830
+ const loops = [...scope.loops, { name: item, kind: 'item', alias, resource: list.resource }];
831
+ if (index)
832
+ loops.push({ name: index, kind: 'index', alias: null, resource: null, of: alias });
833
+ if (this.check) {
834
+ for (const l of loops.slice(scope.loops.length))
835
+ this.frameOf.set(l, this.check.frames.length);
836
+ const itemAt = value.indexOf(item, value.indexOf('(') >= 0 && value.indexOf('(') < m[0].length ? value.indexOf('(') : 0);
837
+ const frame = { kind: 'loop', item, index, init: listExpr, loc: this.locOf(el, 'data-sn-for', itemAt) };
838
+ if (index)
839
+ frame.indexLoc = this.locOf(el, 'data-sn-for', value.indexOf(index, itemAt + item.length));
840
+ this.check.frames.push(frame);
841
+ }
842
+ const inner = { loops, states: scope.states };
843
+ t.key = null;
844
+ t.keyFields = null;
845
+ if (keySrc !== undefined) {
846
+ const k = this.expr(el, 'data-sn-key', inner, { mode: 'value' });
847
+ if (k.ok)
848
+ t.key = this.fn(k.code);
849
+ }
850
+ else if (resInfo?.key) {
851
+ t.keyFields = resInfo.key;
852
+ }
853
+ else if (list.ast && primitiveList(list.ast)) {
854
+ this.report(el, 'data-sn-for', 'SN103', 'a `data-sn-for` over primitive values needs `data-sn-key`', m[0].length);
855
+ }
856
+ // a plain list path changes the rows only through its structure or replaced elements (`#`), not element fields
857
+ const deps = alias && list.deps.length == 1 && list.deps[0] == list.path.join('.') ? [list.deps[0] + '.#'] : list.deps;
858
+ node.b.push(binding({ k: 'for', f: this.fn(list.code), d: deps, x: tid, ...usesEl(list) }));
859
+ return { tid, scope: inner };
860
+ }
861
+ urlTemplate(el, attrName, scope, exprOpts = {}, deps) {
862
+ const value = attr(el, attrName) ?? '';
863
+ const parts = [];
864
+ let i = 0;
865
+ let lit = '';
866
+ while (i < value.length) {
867
+ const c = value[i];
868
+ if (c !== '{') {
869
+ lit += c;
870
+ i++;
871
+ continue;
872
+ }
873
+ // find the matching brace, skipping strings
874
+ let depth = 0, j = i, q = null;
875
+ for (; j < value.length; j++) {
876
+ const d = value[j];
877
+ if (q) {
878
+ if (d === '\\')
879
+ j++;
880
+ else if (d === q)
881
+ q = null;
882
+ continue;
883
+ }
884
+ if (d === '"' || d === "'" || d === '`')
885
+ q = d;
886
+ else if (d === '{')
887
+ depth++;
888
+ else if (d === '}' && --depth === 0)
889
+ break;
890
+ }
891
+ if (j >= value.length) {
892
+ this.report(el, attrName, 'SN401', 'unclosed `{` in URL template', i);
893
+ return parts;
894
+ }
895
+ if (lit)
896
+ parts.push(lit);
897
+ lit = '';
898
+ const r = this.expr(el, attrName, scope, { mode: 'value', ...exprOpts }, value.slice(i + 1, j), i + 1);
899
+ parts.push(this.fn(r.code));
900
+ if (deps)
901
+ for (const d of r.deps)
902
+ deps.add(d);
903
+ i = j + 1;
904
+ }
905
+ if (lit)
906
+ parts.push(lit);
907
+ const first = parts[0];
908
+ if (typeof first !== 'string' || !(/^\/[^/\\]/.test(first) || first === '/' || /^https?:\/\/[^/{}]+(\/|$)/i.test(first))) {
909
+ this.report(el, attrName, 'SN207', 'a URL template must start with a literal same-origin path (`/api/…`) or a literal `https://` origin', 0, 'a leading interpolation or `//` lets a value choose the host');
910
+ }
911
+ return parts;
912
+ }
913
+ // ---- declaration elements
914
+ resource(el, ctx) {
915
+ this.remove.push(el);
916
+ const name = attr(el, 'name');
917
+ if (!name || !this.page.resources.has(name) || this.resourceEls.get(name) !== el)
918
+ return;
919
+ if (ctx.tid >= 0)
920
+ return;
921
+ for (const a of el.attrs) {
922
+ if (!RESOURCE_ATTRS.includes(a.name))
923
+ this.report(el, a.name, 'SN101', `\`${a.name}\` is not an attribute of \`<sn-resource>\`.${didYouMean(a.name, RESOURCE_ATTRS)}`, 0);
924
+ }
925
+ const info = this.page.resources.get(name);
926
+ const hasCall = attr(el, 'call') != null;
927
+ if (hasCall && attr(el, 'src') != null)
928
+ this.report(el, 'call', 'SN102', 'a resource loads from `src` or from `call`, not both', 0);
929
+ else if (!hasCall && attr(el, 'src') == null)
930
+ this.report(el, null, 'SN401', `resource \`${name}\` needs a \`src\` (or a \`call\`)`, 0);
931
+ if (hasCall) {
932
+ for (const a of ['paginate', 'each', 'cache']) {
933
+ if (attr(el, a) != null)
934
+ this.report(el, a, 'SN401', `\`${a}\` does not combine with \`call\` in v0.2`, 0);
935
+ }
936
+ }
937
+ const deps = new Set();
938
+ // `each="a in accounts"`: src is evaluated once per list item, with the item in scope
939
+ let each;
940
+ let srcScope = ctx.scope;
941
+ const eachAttr = attr(el, 'each');
942
+ if (eachAttr != null) {
943
+ const m = /^\s*([A-Za-z_$][\w$]*)\s+in\s+/.exec(eachAttr);
944
+ if (!m)
945
+ this.report(el, 'each', 'SN401', '`each` takes `item in list`, e.g. `a in accounts`', 0);
946
+ else {
947
+ const list = this.expr(el, 'each', ctx.scope, { mode: 'value' }, eachAttr.slice(m[0].length), m[0].length);
948
+ const listExpr = this.lastExpr;
949
+ for (const d of list.deps)
950
+ deps.add(d);
951
+ const loop = { name: m[1], kind: 'item', alias: null, resource: list.resource };
952
+ if (this.check) {
953
+ this.frameOf.set(loop, this.check.frames.length);
954
+ this.check.frames.push({ kind: 'loop', item: m[1], index: null, init: listExpr, loc: this.locOf(el, 'each', eachAttr.indexOf(m[1])) });
955
+ }
956
+ srcScope = { loops: [...ctx.scope.loops, loop], states: ctx.scope.states };
957
+ if (list.ok)
958
+ each = { item: m[1], list: this.fn(list.code), ...(list.resource ? { of: list.resource } : {}) };
959
+ this.features.add('each');
960
+ if (!info.key)
961
+ this.report(el, 'each', 'SN401', 'an `each` resource is a collection: it needs `key`, with a field that tells the sources apart', 0);
962
+ for (const a of ['paginate', 'total', 'cache']) {
963
+ if (attr(el, a) != null)
964
+ this.report(el, a, 'SN401', `\`${a}\` does not combine with \`each\`: the items come from several requests`, 0);
965
+ }
966
+ }
967
+ }
968
+ const src = hasCall ? [] : this.urlTemplate(el, 'src', srcScope, {}, deps);
969
+ const call = hasCall ? this.callExpr(el, 'call', ctx.scope, deps) : null;
970
+ let when = null;
971
+ if (attr(el, 'when') != null) {
972
+ const r = this.expr(el, 'when', ctx.scope, { mode: 'value' });
973
+ when = this.fn(r.code);
974
+ for (const d of r.deps)
975
+ deps.add(d);
976
+ }
977
+ let paginate = null;
978
+ const mode = attr(el, 'paginate');
979
+ if (mode != null) {
980
+ if (!['keyset', 'offset', 'append'].includes(mode)) {
981
+ this.report(el, 'paginate', 'SN401', '`paginate` is `keyset`, `offset` or `append`', 0);
982
+ }
983
+ else {
984
+ if (!info.key)
985
+ this.report(el, 'paginate', 'SN401', 'a paginated resource needs `key`', 0);
986
+ const size = Number(attr(el, 'page-size') ?? '');
987
+ if (!Number.isInteger(size) || size <= 0)
988
+ this.report(el, 'page-size', 'SN401', 'a paginated resource needs a positive `page-size`', 0);
989
+ let cursor = null;
990
+ if (mode !== 'offset') {
991
+ if (attr(el, 'cursor') == null)
992
+ this.report(el, null, 'SN401', `\`paginate="${mode}"\` needs \`cursor\``, 0);
993
+ else {
994
+ const r = this.expr(el, 'cursor', ctx.scope, { mode: 'value', payload: true });
995
+ this.typeLast({ payload: { kind: 'item', resource: name } });
996
+ if (r.ast && r.ast.type !== 'ObjectExpression')
997
+ this.report(el, 'cursor', 'SN401', '`cursor` must be an object literal', 0);
998
+ cursor = this.fn(r.code);
999
+ }
1000
+ }
1001
+ paginate = {
1002
+ mode: mode, size, sizeParam: attr(el, 'size-param') ?? 'limit', cursor,
1003
+ directionParam: attr(el, 'direction-param') ?? 'direction', pageParam: attr(el, 'page-param') ?? 'page',
1004
+ };
1005
+ const offsetParam = attr(el, 'offset-param');
1006
+ if (offsetParam != null) {
1007
+ if (mode !== 'keyset')
1008
+ this.report(el, 'offset-param', 'SN401', `\`offset-param\` applies to \`paginate="keyset"\`; ${mode === 'offset' ? 'offset pages jump with `page-param`' : 'append mode only loads more'}`, 0);
1009
+ else if (!/^[\w.\-\[\]]+$/.test(offsetParam))
1010
+ this.report(el, 'offset-param', 'SN401', '`offset-param` is a query parameter name', 0);
1011
+ else
1012
+ paginate.offsetParam = offsetParam;
1013
+ }
1014
+ if (attr(el, 'direction-values') != null) {
1015
+ if (mode === 'offset')
1016
+ this.report(el, 'direction-values', 'SN401', '`direction-values` applies to `paginate="keyset"` and `paginate="append"`; offset pages send `page-param`', 0);
1017
+ else {
1018
+ const values = this.directionValues(el);
1019
+ if (values)
1020
+ paginate.directionValues = values;
1021
+ }
1022
+ }
1023
+ this.features.add('paginate');
1024
+ }
1025
+ }
1026
+ for (const a of ['page-size', 'size-param', 'cursor', 'direction-param', 'direction-values', 'page-param', 'offset-param']) {
1027
+ if (attr(el, a) != null && !paginate && mode == null)
1028
+ this.report(el, a, 'SN102', `\`${a}\` needs \`paginate\``, 0);
1029
+ }
1030
+ let cache;
1031
+ const cacheAttr = attr(el, 'cache');
1032
+ if (cacheAttr != null) {
1033
+ const m = /^\s*(?:(\d+)\s*(s|m|h|d))?\s*$/.exec(cacheAttr);
1034
+ if (!m)
1035
+ this.report(el, 'cache', 'SN401', '`cache` takes a maximum age such as `30s`, `10m`, `2h` or `1d`, or no value for any age', 0);
1036
+ else {
1037
+ cache = m[1] ? Number(m[1]) * { s: 1e3, m: 6e4, h: 36e5, d: 864e5 }[m[2]] : 0;
1038
+ this.features.add('cache');
1039
+ }
1040
+ }
1041
+ const order = attr(el, 'order');
1042
+ const type = attr(el, 'type')?.trim() || null;
1043
+ const openapi = type || hasCall ? null : this.openapi(el, src, attr(el, 'items') ?? null, attr(el, 'total') ?? null);
1044
+ if (this.check) {
1045
+ const r = { name, type, loc: this.locOf(el, type ? 'type' : null), nameLoc: this.locOf(el, 'name') };
1046
+ if (openapi && 'schema' in openapi)
1047
+ r.openapi = openapi;
1048
+ if (call)
1049
+ r.call = { name: call.name, items: attr(el, 'items') ?? null };
1050
+ this.check.resources.push(r);
1051
+ // a resource fed by an action is typed by the action's result
1052
+ if (!type && !r.openapi && !r.call) {
1053
+ const hint = openapi && 'closest' in openapi
1054
+ ? `no OpenAPI GET operation matches \`src\`${openapi.closest ? ` (closest path: \`${openapi.closest}\`)` : ''}; add \`type="…"\`, or \`type="any"\` to leave it unchecked`
1055
+ : 'add `type="…"` with a type from the types module, or `type="any"` to leave it unchecked';
1056
+ this.report(el, null, 'SN504', `resource \`${name}\` has no type: expressions using it are not checked`, 0, hint);
1057
+ }
1058
+ }
1059
+ this.resources.push({
1060
+ name, src, key: info.key, items: attr(el, 'items') ?? null, total: attr(el, 'total') ?? null,
1061
+ order: order ? order.trim() : null, when, paginate, deps: [...deps].sort(), scope: ctx.stateIdx, data: null,
1062
+ ...(cache !== undefined ? { cache } : {}),
1063
+ ...(each ? { each } : {}),
1064
+ ...(call ? { call: { args: call.args, run: call.run } } : {}),
1065
+ });
1066
+ }
1067
+ /** Matches a resource against the OpenAPI specs; checks `items`, `total` and query parameter names. */
1068
+ openapi(el, src, items, total) {
1069
+ const specs = this.opts.openapi;
1070
+ if (!specs?.length)
1071
+ return null;
1072
+ const m = matchResource(specs, src);
1073
+ if ('closest' in m)
1074
+ return m;
1075
+ const doc = specs[m.spec].doc;
1076
+ const label = `GET ${m.path}`;
1077
+ let schema = m.body;
1078
+ if (schema == null)
1079
+ return { closest: m.path };
1080
+ for (const [field, path] of [['items', items], ['total', total]]) {
1081
+ if (!path)
1082
+ continue;
1083
+ const w = walk(doc, m.body, path);
1084
+ if ('missing' in w) {
1085
+ this.report(el, field, 'SN308', `the response of \`${label}\` has no \`${w.missing}\`.${didYouMean(w.missing, w.have)}`, 0, w.have.length ? `it has ${w.have.map((h) => `\`${h}\``).join(', ')}` : undefined);
1086
+ if (field === 'items')
1087
+ return { closest: m.path };
1088
+ }
1089
+ else if (field === 'items')
1090
+ schema = w.schema;
1091
+ }
1092
+ const raw = attr(el, 'src') ?? '';
1093
+ for (const q of queryNames(src)) {
1094
+ if (m.query.includes(q))
1095
+ continue;
1096
+ const at = raw.indexOf(q + '=', raw.indexOf('?'));
1097
+ this.report(el, 'src', 'SN505', `\`${label}\` declares no query parameter \`${q}\`.${didYouMean(q, m.query)}`, Math.max(at, 0));
1098
+ }
1099
+ return { spec: m.spec, label, schema };
1100
+ }
1101
+ sync(el, ctx) {
1102
+ this.remove.push(el);
1103
+ if (ctx.tid >= 0) {
1104
+ this.report(el, null, 'SN102', '`<sn-sync>` cannot be inside a `<template>`', 0);
1105
+ return;
1106
+ }
1107
+ for (const a of el.attrs)
1108
+ if (a.name !== 'src')
1109
+ this.report(el, a.name, 'SN101', `\`${a.name}\` is not an attribute of \`<sn-sync>\``, 0);
1110
+ const src = this.urlTemplate(el, 'src', ctx.scope);
1111
+ const events = [];
1112
+ for (const c of el.childNodes) {
1113
+ if (!isElement(c)) {
1114
+ if (c.nodeName === '#text' && c.value.trim())
1115
+ this.report(el, null, 'SN102', '`<sn-sync>` may only contain `<sn-event>` elements', 0);
1116
+ continue;
1117
+ }
1118
+ if (c.tagName !== 'sn-event') {
1119
+ this.report(c, null, 'SN102', '`<sn-sync>` may only contain `<sn-event>` elements', 0);
1120
+ continue;
1121
+ }
1122
+ const ev = this.event(c, ctx);
1123
+ if (ev)
1124
+ events.push(ev);
1125
+ }
1126
+ this.syncs.push({ src, events, ...(ctx.stateIdx >= 0 ? { scope: ctx.stateIdx } : {}) });
1127
+ this.features.add('sse');
1128
+ }
1129
+ event(el, ctx) {
1130
+ for (const a of el.attrs) {
1131
+ if (!EVENT_ATTRS.includes(a.name))
1132
+ this.report(el, a.name, 'SN101', `\`${a.name}\` is not an attribute of \`<sn-event>\`.${didYouMean(a.name, EVENT_ATTRS)}`, 0);
1133
+ }
1134
+ const name = attr(el, 'name');
1135
+ const resource = attr(el, 'resource');
1136
+ const op = attr(el, 'op');
1137
+ if (!name) {
1138
+ this.report(el, null, 'SN401', '`<sn-event>` needs `name`', 0);
1139
+ return null;
1140
+ }
1141
+ if (!resource || !this.page.resources.has(resource)) {
1142
+ this.report(el, 'resource', 'SN301', `resource \`${resource ?? ''}\` is not declared on this page.${didYouMean(resource ?? '', this.page.resources.keys())}`, 0);
1143
+ return null;
1144
+ }
1145
+ if (!op || !['upsert', 'patch', 'remove', 'refetch'].includes(op)) {
1146
+ this.report(el, 'op', 'SN401', '`op` is `upsert`, `patch`, `remove` or `refetch`', 0);
1147
+ return null;
1148
+ }
1149
+ const scope = ctx.scope;
1150
+ const typeAttr = attr(el, 'type')?.trim() || null;
1151
+ const payloadType = typeAttr && typeAttr !== 'any' ? { kind: 'ts', text: typeAttr, loc: this.locOf(el, 'type') } : null;
1152
+ if (this.check && !typeAttr && ['item', 'set', 'when'].some((n) => attr(el, n) != null)) {
1153
+ this.report(el, null, 'SN506', `event \`${name}\` has no type: expressions reading its payload are not checked`, 0, 'add `type="…"` with the payload type from the types module, or `type="any"` to leave it unchecked');
1154
+ }
1155
+ const fnOf = (n, object = false) => {
1156
+ if (attr(el, n) == null)
1157
+ return null;
1158
+ const r = this.expr(el, n, scope, { mode: 'value', payload: true, payloadState: true });
1159
+ this.typeLast({
1160
+ payload: payloadType,
1161
+ ...(n === 'item' ? { returns: { kind: 'item', resource } } : n === 'set' ? { returns: { kind: 'item', resource, partial: true } } : {}),
1162
+ });
1163
+ if (object && r.ast && r.ast.type !== 'ObjectExpression')
1164
+ this.report(el, n, 'SN401', `\`${n}\` must be an object literal`, 0);
1165
+ return this.fn(r.code);
1166
+ };
1167
+ const item = fnOf('item');
1168
+ const set = fnOf('set', true);
1169
+ if (item !== null && op !== 'upsert')
1170
+ this.report(el, 'item', 'SN102', '`item` applies to `op="upsert"`', 0);
1171
+ if (set !== null && op !== 'patch')
1172
+ this.report(el, 'set', 'SN102', '`set` applies to `op="patch"`', 0);
1173
+ if (op === 'patch' && set === null)
1174
+ this.report(el, null, 'SN401', '`op="patch"` needs `set`', 0);
1175
+ const fallback = attr(el, 'fallback');
1176
+ if (fallback != null && fallback !== 'refetch')
1177
+ this.report(el, 'fallback', 'SN401', '`fallback` takes `refetch`', 0);
1178
+ return { name, resource, op, item, set, when: fnOf('when'), fallback: fallback === 'refetch' ? 'refetch' : null };
1179
+ }
1180
+ injectScript(doc) {
1181
+ const html = doc.childNodes.find((n) => isElement(n) && n.tagName === 'html');
1182
+ const head = html?.childNodes.find((n) => isElement(n) && n.tagName === 'head');
1183
+ if (!head)
1184
+ return;
1185
+ const src = this.opts.moduleSrc ?? `./${basename(this.opts.file).replace(/\.html?$/, '')}.sinter.js`;
1186
+ const frag = parse5.parseFragment(`<script type="module" src="${src.replace(/[&"<>]/g, (c) => `&#${c.charCodeAt(0)};`)}"></script>`);
1187
+ const script = frag.childNodes[0];
1188
+ script.parentNode = head;
1189
+ head.childNodes.push(script);
1190
+ }
1191
+ }
1192
+ // ---- helpers
1193
+ /** Flags for the runtime: `e` when the expression reads `$el`, `i` when it reads a loop index. */
1194
+ const usesEl = (r) => ({ ...(r.usesEl ? { e: 1 } : {}), ...(r.usesIndex ? { i: 1 } : {}) });
1195
+ function binding(b) {
1196
+ const out = { k: b.k };
1197
+ for (const key of ['f', 'd', 'n', 'x', 'a', 'y', 'w', 'p', 'e', 'i', 'ms']) {
1198
+ const v = b[key];
1199
+ if (v === undefined || (Array.isArray(v) && v.length === 0 && key !== 'd'))
1200
+ continue;
1201
+ out[key] = v;
1202
+ }
1203
+ if (out.d && out.d.length === 0)
1204
+ delete out.d;
1205
+ return out;
1206
+ }
1207
+ function isElement(n) {
1208
+ return 'tagName' in n;
1209
+ }
1210
+ function attr(el, name) {
1211
+ return el.attrs.find((a) => a.name === name)?.value;
1212
+ }
1213
+ function setAttr(el, name, value) {
1214
+ const a = el.attrs.find((x) => x.name === name);
1215
+ if (a)
1216
+ a.value = value;
1217
+ else
1218
+ el.attrs.push({ name, value });
1219
+ }
1220
+ function stripSn(el) {
1221
+ el.attrs = el.attrs.filter((a) => !a.name.startsWith('data-sn-'));
1222
+ }
1223
+ function hasSnDeep(parent) {
1224
+ for (const c of parent.childNodes) {
1225
+ if (!isElement(c))
1226
+ continue;
1227
+ if (c.attrs.some((a) => a.name.startsWith('data-sn-')))
1228
+ return true;
1229
+ if (hasSnDeep(c))
1230
+ return true;
1231
+ if (c.tagName === 'template' && hasSnDeep(c.content))
1232
+ return true;
1233
+ }
1234
+ return false;
1235
+ }
1236
+ /** Merges adjacent text nodes, as parsing the serialized HTML would. */
1237
+ function mergeText(parent) {
1238
+ const kids = parent.childNodes;
1239
+ for (let i = kids.length - 1; i > 0; i--) {
1240
+ if (kids[i].nodeName === '#text' && kids[i - 1].nodeName === '#text') {
1241
+ kids[i - 1].value += kids[i].value;
1242
+ kids.splice(i, 1);
1243
+ }
1244
+ }
1245
+ }
1246
+ function detach(el) {
1247
+ const p = el.parentNode;
1248
+ if (!p)
1249
+ return;
1250
+ const i = p.childNodes.indexOf(el);
1251
+ if (i >= 0)
1252
+ p.childNodes.splice(i, 1);
1253
+ el.parentNode = null;
1254
+ }
1255
+ function primitiveList(ast) {
1256
+ const a = ast;
1257
+ if (a.type === 'ArrayExpression')
1258
+ return (a.elements ?? []).every((e) => e && (e.type === 'Literal' || e.type === 'TemplateLiteral'));
1259
+ if (a.type === 'CallExpression' && a.callee?.type === 'MemberExpression') {
1260
+ const obj = a.callee.object, prop = a.callee.property?.name;
1261
+ if (obj?.type === 'Identifier' && obj.name === 'Object' && prop === 'keys')
1262
+ return true;
1263
+ if (prop === 'split')
1264
+ return true;
1265
+ }
1266
+ return false;
1267
+ }
1268
+ export function normalize(p) {
1269
+ const abs = p.startsWith('/');
1270
+ const out = [];
1271
+ for (const seg of p.split('/')) {
1272
+ if (!seg || seg === '.')
1273
+ continue;
1274
+ if (seg === '..')
1275
+ out.pop();
1276
+ else
1277
+ out.push(seg);
1278
+ }
1279
+ return (abs ? '/' : '') + out.join('/');
1280
+ }
1281
+ export function dirname(p) {
1282
+ const i = p.lastIndexOf('/');
1283
+ return i <= 0 ? (p.startsWith('/') ? '/' : '.') : p.slice(0, i);
1284
+ }
1285
+ export function basename(p) {
1286
+ return p.slice(p.lastIndexOf('/') + 1);
1287
+ }
1288
+ function resolvePath(dir, rel) {
1289
+ return normalize(rel.startsWith('/') ? rel : `${dir}/${rel}`);
1290
+ }
1291
+ /**
1292
+ * A row template's element lookup as straight-line code: each path prefix is walked once
1293
+ * (`firstChild` / `nextSibling`), and an element whose tag does not match comes out null.
1294
+ * The argument is the clone's first content node (the row element itself for a one-element row).
1295
+ */
1296
+ export function findCode(nodes) {
1297
+ const names = new Map(), decls = [];
1298
+ const node = (p) => {
1299
+ const key = p.join('.');
1300
+ let name = names.get(key);
1301
+ if (name)
1302
+ return name;
1303
+ const last = p[p.length - 1];
1304
+ const expr = last > 0 ? `${node([...p.slice(0, -1), last - 1])}?.nextSibling`
1305
+ : p.length > 1 ? `${node(p.slice(0, -1))}?.firstChild` : 'c';
1306
+ name = 'n' + names.size;
1307
+ names.set(key, name);
1308
+ decls.push(`${name} = ${expr}`);
1309
+ return name;
1310
+ };
1311
+ const out = nodes.map((nd) => {
1312
+ const x = node(nd.path);
1313
+ return `${x}?.localName == ${JSON.stringify(nd.tag)} ? ${x} : null`;
1314
+ });
1315
+ return `(c) => { const ${decls.join(', ')}; return [${out.join(', ')}]; }`;
1316
+ }