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