@tiaforge-ui/sinter 0.1.0 → 0.3.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 (45) hide show
  1. package/README.md +39 -12
  2. package/dist/check/index.d.ts +4 -1
  3. package/dist/check/index.js +9 -8
  4. package/dist/check/shadow.d.ts +3 -1
  5. package/dist/check/shadow.js +53 -5
  6. package/dist/cli.js +2 -2
  7. package/dist/compiler/check-unit.d.ts +26 -2
  8. package/dist/compiler/compile.d.ts +4 -0
  9. package/dist/compiler/compile.js +314 -30
  10. package/dist/compiler/diagnostic-docs.js +20 -5
  11. package/dist/compiler/diagnostics.d.ts +3 -0
  12. package/dist/compiler/diagnostics.js +3 -0
  13. package/dist/compiler/expr.d.ts +24 -0
  14. package/dist/compiler/expr.js +53 -8
  15. package/dist/compiler/index.d.ts +3 -2
  16. package/dist/compiler/index.js +70 -16
  17. package/dist/compiler/vocabulary.d.ts +1 -0
  18. package/dist/compiler/vocabulary.js +9 -3
  19. package/dist/each.d.ts +3 -0
  20. package/dist/each.js +121 -0
  21. package/dist/index.d.ts +1 -1
  22. package/dist/ir.d.ts +30 -1
  23. package/dist/listbox.d.ts +3 -0
  24. package/dist/listbox.js +66 -0
  25. package/dist/lsp/typescript.js +1 -1
  26. package/dist/lsp/workspace.js +3 -2
  27. package/dist/paginate.js +18 -4
  28. package/dist/project.d.ts +6 -0
  29. package/dist/project.js +22 -19
  30. package/dist/runtime/app.d.ts +35 -1
  31. package/dist/runtime/app.js +178 -40
  32. package/dist/runtime/config.d.ts +10 -3
  33. package/dist/runtime/config.js +10 -3
  34. package/dist/runtime/request.d.ts +1 -1
  35. package/dist/runtime/request.js +6 -1
  36. package/dist/runtime/store.d.ts +7 -4
  37. package/dist/runtime/store.js +154 -41
  38. package/dist/sse.js +8 -5
  39. package/dist/testing/index.d.ts +2 -0
  40. package/dist/testing/index.js +17 -2
  41. package/dist/vite.js +4 -2
  42. package/docs/diagnostics.md +35 -5
  43. package/html-custom-data.json +46 -2
  44. package/llms.txt +112 -6
  45. package/package.json +6 -1
@@ -1,13 +1,14 @@
1
1
  // The compiler: one pure, deterministic function from annotated HTML to a
2
2
  // rewritten page plus its IR and function table.
3
3
  import * as parse5 from 'parse5';
4
+ import { parseExpressionAt } from 'acorn';
4
5
  import { didYouMean, severityOf } from './diagnostics.js';
5
6
  import { analyze } from './expr.js';
6
7
  import { ATTRIBUTES, ELEMENTS } from './vocabulary.js';
7
8
  import { matchOperation, matchResource, queryNames, walk } from './openapi.js';
8
9
  const ATTRS = ATTRIBUTES.map((a) => a.name);
9
10
  const METHODS = ['get', 'post', 'put', 'patch', 'delete'];
10
- const ACTION_ONLY = ['body', 'trigger', 'debounce', 'concurrency', 'optimistic', 'then', 'catch'];
11
+ const ACTION_ONLY = ['body', 'trigger', 'debounce', 'concurrency', 'optimistic', 'then', 'catch', 'confirm', 'encoding'];
11
12
  const UNSAFE_ELEMENTS = new Set(['script', 'style', 'iframe', 'object', 'embed', 'base', 'meta', 'link', 'animate', 'set', 'animatemotion', 'animatetransform']);
12
13
  const FORM_CONTROLS = new Set(['input', 'select', 'textarea']);
13
14
  const RESOURCE_ATTRS = ELEMENTS.find((e) => e.name === 'sn-resource').attrs;
@@ -23,6 +24,7 @@ class Compiler {
23
24
  diagnostics = [];
24
25
  files = new Set();
25
26
  fns = [];
27
+ fnIndex = new Map();
26
28
  origins = new WeakMap();
27
29
  lineStarts = new Map();
28
30
  page;
@@ -35,6 +37,8 @@ class Compiler {
35
37
  remove = [];
36
38
  embedded = new Map();
37
39
  features = new Set();
40
+ /** action functions called by the page (`archive`, `pgp.decrypt`) */
41
+ calls = new Set();
38
42
  nextId = 0;
39
43
  nextSid = 0;
40
44
  check;
@@ -50,7 +54,8 @@ class Compiler {
50
54
  ns.set(k, new Set(v.members));
51
55
  this.page = {
52
56
  resources: new Map(), helpers: new Set(opts.helpers.names), namespaces: ns,
53
- usedHelpers: new Set(), usedNs: new Map(), usesNow: false, pathParams: new Set(),
57
+ actions: new Set([...(opts.actions?.names ?? []), ...Object.keys(opts.actions?.namespaces ?? {})]),
58
+ usedHelpers: new Set(), usedNs: new Map(), usesNow: false, pathParams: new Set(), dialogs: new Set(),
54
59
  };
55
60
  }
56
61
  run(html) {
@@ -95,8 +100,8 @@ class Compiler {
95
100
  return {
96
101
  html: parse5.serialize(doc),
97
102
  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,
103
+ features: [...(this.opts.dev ? ['dev'] : []), ...['sse', 'optimistic', 'paginate', 'persist', 'cache', 'each', 'listbox'].filter((f) => this.features.has(f))],
104
+ helpers: { user, builtin }, namespaces, calls: [...this.calls].sort(),
100
105
  diagnostics: this.diagnostics, files: [...this.files].sort(),
101
106
  ...(this.check ? { check: this.check } : {}),
102
107
  };
@@ -153,6 +158,93 @@ class Compiler {
153
158
  return i;
154
159
  }
155
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
+ }
156
248
  report(el, attr, code, message, offset, hint, inExpr = false) {
157
249
  const origin = (el && this.origins.get(el)) || { file: this.opts.file, src: '', chain: [] };
158
250
  const base = el ? this.valueOffset(el, attr) : 0;
@@ -229,7 +321,7 @@ class Compiler {
229
321
  // the browser merges adjacent text when it parses the output; merge now so node paths agree
230
322
  mergeText(parent);
231
323
  }
232
- // ---- declarations (first pass: names only, so expressions can see every resource)
324
+ // ---- declarations (first pass: names only, so expressions can see every resource and dialog)
233
325
  declare(parent, inTemplate = false) {
234
326
  for (const child of parent.childNodes) {
235
327
  if (!isElement(child))
@@ -251,10 +343,17 @@ class Compiler {
251
343
  const key = attr(child, 'key');
252
344
  const paginate = attr(child, 'paginate');
253
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;
254
348
  this.page.resources.set(name, info);
255
349
  this.resourceEls.set(name, child);
256
350
  continue;
257
351
  }
352
+ if (child.tagName === 'dialog') {
353
+ const id = attr(child, 'id');
354
+ if (id)
355
+ this.page.dialogs.add(id);
356
+ }
258
357
  this.declare(child, inTemplate);
259
358
  if (child.tagName === 'template')
260
359
  this.declare(child.content, true);
@@ -318,7 +417,7 @@ class Compiler {
318
417
  // unknown attributes
319
418
  const known = new Set(ATTRS.map((a) => `data-sn-${a}`));
320
419
  for (const a of sn) {
321
- if (known.has(a.name) || a.name.startsWith('data-sn-on-'))
420
+ if (known.has(a.name) || a.name.startsWith('data-sn-on-') || a.name.startsWith('data-sn-debounce-'))
322
421
  continue;
323
422
  this.report(el, a.name, 'SN101', `\`${a.name}\` is not a sinter attribute.${didYouMean(a.name, [...known, 'data-sn-on-click'])}`, 0);
324
423
  }
@@ -352,6 +451,9 @@ class Compiler {
352
451
  return null;
353
452
  }
354
453
  const node = { tag: el.tagName, p: ctx.stateIdx, b: [] };
454
+ const inDialog = ctx.dialog || el.tagName === 'dialog';
455
+ if (inDialog)
456
+ node.dl = 1;
355
457
  const nodeIdx = ctx.list.length;
356
458
  let scope = ctx.scope;
357
459
  let result = null;
@@ -369,13 +471,32 @@ class Compiler {
369
471
  const locs = first.map((e) => this.locOf(el, 'data-sn-state', e.offset));
370
472
  this.check.frames.push({ kind: 'state', names: [...entry.names], init: this.lastExpr, locs });
371
473
  }
372
- scope = { loops: scope.loops, states: [...scope.states, entry] };
474
+ this.checkNames(el, 'data-sn-state', scope, [...entry.names]);
475
+ scope = { ...scope, states: [...scope.states, entry] };
373
476
  if (has('persist') && !has('model')) {
374
477
  node.persist = get('persist');
375
478
  this.features.add('persist');
376
479
  }
377
480
  }
378
481
  }
482
+ // derived names: the element and its subtree read them like state
483
+ if (has('let'))
484
+ scope = this.lets(el, scope);
485
+ // a text field with suggestions: the listbox feature handles the keys (see src/listbox.ts)
486
+ if (has('listbox')) {
487
+ const list = get('listbox');
488
+ if (!['input', 'textarea'].includes(el.tagName))
489
+ this.report(el, 'data-sn-listbox', 'SN102', '`data-sn-listbox` belongs on a text `<input>` or `<textarea>`', 0);
490
+ else if (list && !/^[A-Za-z][\w-]*$/.test(list))
491
+ this.report(el, 'data-sn-listbox', 'SN401', '`data-sn-listbox` takes the literal `id` of the suggestion list, or nothing for the nearest `[role="listbox"]` around the input', 0);
492
+ else {
493
+ setAttr(el, 'role', 'combobox');
494
+ if (list)
495
+ setAttr(el, 'aria-controls', list);
496
+ setAttr(el, 'aria-autocomplete', 'list');
497
+ this.features.add('listbox');
498
+ }
499
+ }
379
500
  const isTemplate = el.tagName === 'template';
380
501
  const valueB = (k, name) => {
381
502
  const r = this.expr(el, name, scope, { mode: 'value' });
@@ -408,6 +529,27 @@ class Compiler {
408
529
  this.model(el, node, scope, get('persist'));
409
530
  else if (has('persist') && !has('state'))
410
531
  this.report(el, 'data-sn-persist', 'SN102', '`data-sn-persist` needs `data-sn-model` or `data-sn-state` on the same element', 0);
532
+ const methods = METHODS.filter((m) => has(m));
533
+ const isCall = has('call');
534
+ // without a method, `data-sn-debounce` delays the element's own handlers
535
+ let handlerDebounce = 0;
536
+ if (!methods.length && !isCall && has('debounce')) {
537
+ handlerDebounce = Number(get('debounce'));
538
+ if (!Number.isFinite(handlerDebounce) || handlerDebounce < 0)
539
+ this.report(el, 'data-sn-debounce', 'SN401', '`data-sn-debounce` takes milliseconds', 0);
540
+ if (!sn.some((a) => a.name.startsWith('data-sn-on-')))
541
+ 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);
542
+ }
543
+ // `data-sn-debounce-<event>`: only that event's handler waits
544
+ for (const a of sn) {
545
+ if (!a.name.startsWith('data-sn-debounce-'))
546
+ continue;
547
+ const event = a.name.slice('data-sn-debounce-'.length), ms = Number(a.value);
548
+ if (!Number.isFinite(ms) || ms < 0 || a.value.trim() === '')
549
+ this.report(el, a.name, 'SN401', `\`${a.name}\` takes milliseconds`, 0);
550
+ if (!has(`on-${event}`))
551
+ this.report(el, a.name, 'SN102', `\`${a.name}\` needs a \`data-sn-on-${event}\` handler on the same element`, 0);
552
+ }
411
553
  for (const a of sn) {
412
554
  if (!a.name.startsWith('data-sn-on-'))
413
555
  continue;
@@ -417,17 +559,21 @@ class Compiler {
417
559
  continue;
418
560
  }
419
561
  const r = this.expr(el, a.name, scope, { mode: 'handler', magic: ['$event'] });
562
+ const own = get(`debounce-${event}`), ms = own !== undefined ? Number(own) || 0 : handlerDebounce;
420
563
  if (r.ok)
421
- node.b.push(binding({ k: 'on', n: event, f: this.fn(r.code), w: r.writes }));
564
+ node.b.push(binding({ k: 'on', n: event, f: this.fn(r.code), w: r.writes, ...(ms > 0 ? { ms } : {}) }));
422
565
  }
423
- const methods = METHODS.filter((m) => has(m));
424
566
  if (methods.length > 1)
425
567
  this.report(el, `data-sn-${methods[1]}`, 'SN102', 'an element can have only one method attribute', 0);
568
+ if (methods.length && isCall)
569
+ this.report(el, 'data-sn-call', 'SN102', 'an element has one action: a method attribute or `data-sn-call`, not both', 0);
426
570
  if (methods.length)
427
571
  this.action(el, node, scope, methods[0], get);
572
+ else if (isCall)
573
+ this.action(el, node, scope, 'call', get);
428
574
  else
429
575
  for (const n of ACTION_ONLY)
430
- if (has(n))
576
+ if (has(n) && n !== 'debounce')
431
577
  this.report(el, `data-sn-${n}`, 'SN102', `\`data-sn-${n}\` needs a method attribute (\`data-sn-get\`, \`-post\`, …) on the same element`, 0);
432
578
  // templates
433
579
  let content = null;
@@ -476,7 +622,7 @@ class Compiler {
476
622
  }
477
623
  else {
478
624
  this.walkChildren(el, {
479
- ...ctx, scope,
625
+ ...ctx, scope, dialog: inDialog,
480
626
  stateIdx: node.state !== undefined && hasDirectives ? nodeIdx : ctx.stateIdx,
481
627
  path: ctx.tid >= 0 ? [...ctx.path, index] : ctx.path,
482
628
  });
@@ -495,9 +641,12 @@ class Compiler {
495
641
  this.templates.push({ id: tid, nodes: [] });
496
642
  return tid;
497
643
  }
644
+ /** Index of a function in the page's table; identical code shares one entry (one compile in the engine). */
498
645
  fn(code) {
499
- this.fns.push(code);
500
- return this.fns.length - 1;
646
+ let i = this.fnIndex.get(code);
647
+ if (i === undefined)
648
+ this.fnIndex.set(code, (i = this.fns.push(code) - 1));
649
+ return i;
501
650
  }
502
651
  expr(el, attrName, scope, o, value, shift = 0) {
503
652
  const src = value ?? attr(el, attrName) ?? '';
@@ -513,6 +662,61 @@ class Compiler {
513
662
  }
514
663
  return r;
515
664
  }
665
+ // ---- derived names
666
+ /**
667
+ * `data-sn-let="{ s: suggestions(q), n: s.length }"`: each name is a value expression, inlined
668
+ * where it is read; later names may use earlier ones.
669
+ */
670
+ lets(el, scope) {
671
+ const src = attr(el, 'data-sn-let') ?? '';
672
+ let obj;
673
+ try {
674
+ obj = parseExpressionAt(src, 0, { ecmaVersion: 'latest' });
675
+ }
676
+ catch (e) {
677
+ this.report(el, 'data-sn-let', 'SN401', e.message.replace(/ \(\d+:\d+\)$/, ''), e.pos ?? 0);
678
+ return scope;
679
+ }
680
+ if (obj.type !== 'ObjectExpression') {
681
+ this.report(el, 'data-sn-let', 'SN401', '`data-sn-let` takes an object literal: `{ name: expression, … }`', 0);
682
+ return scope;
683
+ }
684
+ for (const p of obj.properties) {
685
+ if (p.type !== 'Property' || p.computed || p.shorthand || p.key.type !== 'Identifier' || p.kind !== 'init' || p.method) {
686
+ this.report(el, 'data-sn-let', 'SN401', '`data-sn-let` entries are `name: expression`', p.start);
687
+ continue;
688
+ }
689
+ const name = p.key.name;
690
+ if (name.startsWith('$')) {
691
+ this.report(el, 'data-sn-let', 'SN401', 'names starting with `$` are reserved', p.key.start);
692
+ continue;
693
+ }
694
+ if (!this.checkNames(el, 'data-sn-let', scope, [name], p.key.start))
695
+ continue;
696
+ const v = p.value;
697
+ const r = this.expr(el, 'data-sn-let', scope, { mode: 'value' }, src.slice(v.start, v.end), v.start);
698
+ const entry = { name, code: r.inner, deps: r.deps, usesEl: r.usesEl, usesIndex: r.usesIndex, resource: r.resource };
699
+ if (this.check) {
700
+ this.frameOf.set(entry, this.check.frames.length);
701
+ this.check.frames.push({ kind: 'let', name, init: r.ok ? this.lastExpr : null, loc: this.locOf(el, 'data-sn-let', p.key.start) });
702
+ }
703
+ scope = { ...scope, lets: [...(scope.lets ?? []), entry] };
704
+ }
705
+ return scope;
706
+ }
707
+ /** A name declared here must not hide a let, or be hidden by one (SN209); false on a conflict. */
708
+ checkNames(el, attrName, scope, names, offset = 0) {
709
+ let ok = true;
710
+ for (const n of names) {
711
+ const isLet = (scope.lets ?? []).some((l) => l.name === n);
712
+ const other = attrName === 'data-sn-let' && (scope.loops.some((l) => l.name === n) || scope.states.some((s) => s.names.has(n)));
713
+ if (isLet || other) {
714
+ this.report(el, attrName, 'SN209', `\`${n}\` is already defined here${isLet ? ' by `data-sn-let`' : ''}; use another name`, offset);
715
+ ok = false;
716
+ }
717
+ }
718
+ return ok;
719
+ }
516
720
  // ---- check unit
517
721
  /** Adds type information to the expression `expr()` recorded last. */
518
722
  typeLast(info) {
@@ -533,7 +737,7 @@ class Compiler {
533
737
  }
534
738
  recordExpr(el, attrName, scope, o, src, shift, r) {
535
739
  const frames = [];
536
- for (const f of [...scope.states, ...scope.loops]) {
740
+ for (const f of [...scope.states, ...scope.loops, ...(scope.lets ?? [])]) {
537
741
  const i = this.frameOf.get(f);
538
742
  if (i !== undefined && !frames.includes(i))
539
743
  frames.push(i);
@@ -541,7 +745,7 @@ class Compiler {
541
745
  let event = null;
542
746
  if (attrName.startsWith('data-sn-on-'))
543
747
  event = attrName.slice('data-sn-on-'.length);
544
- else if (o.mode !== 'value') {
748
+ else if (o.mode !== 'value' || o.magic?.includes('$event')) {
545
749
  event = attr(el, 'data-sn-trigger') ?? (el.tagName === 'form' ? 'submit' : FORM_CONTROLS.has(el.tagName) ? 'change' : 'click');
546
750
  }
547
751
  const e = {
@@ -549,13 +753,15 @@ class Compiler {
549
753
  result: attrName === 'data-sn-then', error: attrName === 'data-sn-catch',
550
754
  };
551
755
  if (o.payload)
552
- e.payload = { names: r.payloadNames ?? [], type: null };
756
+ e.payload = { names: r.payloadNames ?? [], type: null, ...(r.payloadStates?.length ? { states: r.payloadStates } : {}) };
553
757
  this.check.exprs.push(e);
554
758
  return this.check.exprs.length - 1;
555
759
  }
556
760
  // ---- bindings
557
761
  attrBinding(el, node, scope) {
558
762
  const r = this.expr(el, 'data-sn-attr', scope, { mode: 'value', object: 'literal-keys' });
763
+ if (this.check)
764
+ this.typeLast({ returns: { kind: 'decl', name: 'Attrs' } });
559
765
  if (!r.entries)
560
766
  return;
561
767
  for (const e of r.entries) {
@@ -649,7 +855,11 @@ class Compiler {
649
855
  node.b.push(b);
650
856
  }
651
857
  action(el, node, scope, method, get) {
652
- const url = this.urlTemplate(el, `data-sn-${method}`, scope);
858
+ // arguments and body are taken when the trigger fires, so they see its event
859
+ const call = method === 'call' ? this.callExpr(el, 'data-sn-call', scope, undefined, ['$event']) : null;
860
+ if (method === 'call' && get('body') !== undefined)
861
+ 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);
862
+ const url = method === 'call' ? [] : this.urlTemplate(el, `data-sn-${method}`, scope);
653
863
  const tag = el.tagName;
654
864
  const isForm = tag === 'form';
655
865
  const trigger = get('trigger') ?? (isForm ? 'submit' : FORM_CONTROLS.has(tag) ? 'change' : 'click');
@@ -674,17 +884,27 @@ class Compiler {
674
884
  return { f: r.ok ? this.fn(r.code) : null, w: r.writes };
675
885
  };
676
886
  // 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;
887
+ const op = method !== 'call' && this.check && this.opts.openapi?.length ? matchOperation(this.opts.openapi, url, method) : null;
678
888
  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);
889
+ const body = fnOf('body', { mode: 'value', magic: ['$event'] }, typed?.request != null ? { returns: { kind: 'openapi', spec: typed.spec, schema: typed.request } } : undefined);
680
890
  const optimistic = fnOf('optimistic', { mode: 'optimistic' });
681
891
  if (optimistic.f !== null)
682
892
  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);
893
+ const then = fnOf('then', { mode: 'handler', magic: ['$event', '$result'] }, typed?.body != null ? { resultType: { kind: 'openapi', spec: typed.spec, schema: typed.body } }
894
+ : call ? { resultType: { kind: 'call', name: call.name } } : undefined);
684
895
  const catch_ = fnOf('catch', { mode: 'handler', magic: ['$event', '$error'] });
896
+ const confirm = fnOf('confirm', { mode: 'value', magic: ['$event'] });
897
+ const encoding = get('encoding');
898
+ if (encoding !== undefined && encoding !== 'form' && encoding !== 'json')
899
+ this.report(el, 'data-sn-encoding', 'SN401', '`data-sn-encoding` is `json` (the default) or `form`', 0);
900
+ if (encoding !== undefined && method === 'call')
901
+ this.report(el, 'data-sn-encoding', 'SN102', '`data-sn-encoding` belongs to a method attribute; a call sends its own requests', 0);
685
902
  const def = {
686
903
  method: method.toUpperCase(), url, body: body.f, trigger, debounce, concurrency,
687
904
  optimistic: optimistic.f, then: then.f, catch: catch_.f, w: [...new Set([...then.w, ...catch_.w])].sort(), form: isForm,
905
+ ...(call ? { call: { args: call.args, run: call.run } } : {}),
906
+ ...(confirm.f !== null ? { confirm: confirm.f } : {}),
907
+ ...(encoding === 'form' ? { enc: 'form' } : {}),
688
908
  };
689
909
  this.actions.push(def);
690
910
  node.b.push(binding({ k: 'on', n: trigger, x: this.actions.length - 1 }));
@@ -719,7 +939,8 @@ class Compiler {
719
939
  frame.indexLoc = this.locOf(el, 'data-sn-for', value.indexOf(index, itemAt + item.length));
720
940
  this.check.frames.push(frame);
721
941
  }
722
- const inner = { loops, states: scope.states };
942
+ this.checkNames(el, 'data-sn-for', scope, index ? [item, index] : [item]);
943
+ const inner = { ...scope, loops };
723
944
  t.key = null;
724
945
  t.keyFields = null;
725
946
  if (keySrc !== undefined) {
@@ -803,10 +1024,50 @@ class Compiler {
803
1024
  this.report(el, a.name, 'SN101', `\`${a.name}\` is not an attribute of \`<sn-resource>\`.${didYouMean(a.name, RESOURCE_ATTRS)}`, 0);
804
1025
  }
805
1026
  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);
1027
+ const hasCall = attr(el, 'call') != null;
1028
+ if (hasCall && attr(el, 'src') != null)
1029
+ this.report(el, 'call', 'SN102', 'a resource loads from `src` or from `call`, not both', 0);
1030
+ else if (!hasCall && attr(el, 'src') == null)
1031
+ this.report(el, null, 'SN401', `resource \`${name}\` needs a \`src\` (or a \`call\`)`, 0);
1032
+ if (hasCall) {
1033
+ for (const a of ['paginate', 'each', 'cache']) {
1034
+ if (attr(el, a) != null)
1035
+ this.report(el, a, 'SN401', `\`${a}\` does not combine with \`call\` in v0.2`, 0);
1036
+ }
1037
+ }
808
1038
  const deps = new Set();
809
- const src = this.urlTemplate(el, 'src', ctx.scope, {}, deps);
1039
+ // `each="a in accounts"`: src is evaluated once per list item, with the item in scope
1040
+ let each;
1041
+ let srcScope = ctx.scope;
1042
+ const eachAttr = attr(el, 'each');
1043
+ if (eachAttr != null) {
1044
+ const m = /^\s*([A-Za-z_$][\w$]*)\s+in\s+/.exec(eachAttr);
1045
+ if (!m)
1046
+ this.report(el, 'each', 'SN401', '`each` takes `item in list`, e.g. `a in accounts`', 0);
1047
+ else {
1048
+ const list = this.expr(el, 'each', ctx.scope, { mode: 'value' }, eachAttr.slice(m[0].length), m[0].length);
1049
+ const listExpr = this.lastExpr;
1050
+ for (const d of list.deps)
1051
+ deps.add(d);
1052
+ const loop = { name: m[1], kind: 'item', alias: null, resource: list.resource };
1053
+ if (this.check) {
1054
+ this.frameOf.set(loop, this.check.frames.length);
1055
+ this.check.frames.push({ kind: 'loop', item: m[1], index: null, init: listExpr, loc: this.locOf(el, 'each', eachAttr.indexOf(m[1])) });
1056
+ }
1057
+ srcScope = { ...ctx.scope, loops: [...ctx.scope.loops, loop] };
1058
+ if (list.ok)
1059
+ each = { item: m[1], list: this.fn(list.code), ...(list.resource ? { of: list.resource } : {}) };
1060
+ this.features.add('each');
1061
+ if (!info.key)
1062
+ this.report(el, 'each', 'SN401', 'an `each` resource is a collection: it needs `key`, with a field that tells the sources apart', 0);
1063
+ for (const a of ['paginate', 'total', 'cache']) {
1064
+ if (attr(el, a) != null)
1065
+ this.report(el, a, 'SN401', `\`${a}\` does not combine with \`each\`: the items come from several requests`, 0);
1066
+ }
1067
+ }
1068
+ }
1069
+ const src = hasCall ? [] : this.urlTemplate(el, 'src', srcScope, {}, deps);
1070
+ const call = hasCall ? this.callExpr(el, 'call', ctx.scope, deps) : null;
810
1071
  let when = null;
811
1072
  if (attr(el, 'when') != null) {
812
1073
  const r = this.expr(el, 'when', ctx.scope, { mode: 'value' });
@@ -842,10 +1103,28 @@ class Compiler {
842
1103
  mode: mode, size, sizeParam: attr(el, 'size-param') ?? 'limit', cursor,
843
1104
  directionParam: attr(el, 'direction-param') ?? 'direction', pageParam: attr(el, 'page-param') ?? 'page',
844
1105
  };
1106
+ const offsetParam = attr(el, 'offset-param');
1107
+ if (offsetParam != null) {
1108
+ if (mode !== 'keyset')
1109
+ 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);
1110
+ else if (!/^[\w.\-\[\]]+$/.test(offsetParam))
1111
+ this.report(el, 'offset-param', 'SN401', '`offset-param` is a query parameter name', 0);
1112
+ else
1113
+ paginate.offsetParam = offsetParam;
1114
+ }
1115
+ if (attr(el, 'direction-values') != null) {
1116
+ if (mode === 'offset')
1117
+ this.report(el, 'direction-values', 'SN401', '`direction-values` applies to `paginate="keyset"` and `paginate="append"`; offset pages send `page-param`', 0);
1118
+ else {
1119
+ const values = this.directionValues(el);
1120
+ if (values)
1121
+ paginate.directionValues = values;
1122
+ }
1123
+ }
845
1124
  this.features.add('paginate');
846
1125
  }
847
1126
  }
848
- for (const a of ['page-size', 'size-param', 'cursor', 'direction-param', 'page-param']) {
1127
+ for (const a of ['page-size', 'size-param', 'cursor', 'direction-param', 'direction-values', 'page-param', 'offset-param']) {
849
1128
  if (attr(el, a) != null && !paginate && mode == null)
850
1129
  this.report(el, a, 'SN102', `\`${a}\` needs \`paginate\``, 0);
851
1130
  }
@@ -862,13 +1141,16 @@ class Compiler {
862
1141
  }
863
1142
  const order = attr(el, 'order');
864
1143
  const type = attr(el, 'type')?.trim() || null;
865
- const openapi = type ? null : this.openapi(el, src, attr(el, 'items') ?? null, attr(el, 'total') ?? null);
1144
+ const openapi = type || hasCall ? null : this.openapi(el, src, attr(el, 'items') ?? null, attr(el, 'total') ?? null);
866
1145
  if (this.check) {
867
1146
  const r = { name, type, loc: this.locOf(el, type ? 'type' : null), nameLoc: this.locOf(el, 'name') };
868
1147
  if (openapi && 'schema' in openapi)
869
1148
  r.openapi = openapi;
1149
+ if (call)
1150
+ r.call = { name: call.name, items: attr(el, 'items') ?? null };
870
1151
  this.check.resources.push(r);
871
- if (!type && !r.openapi) {
1152
+ // a resource fed by an action is typed by the action's result
1153
+ if (!type && !r.openapi && !r.call) {
872
1154
  const hint = openapi && 'closest' in openapi
873
1155
  ? `no OpenAPI GET operation matches \`src\`${openapi.closest ? ` (closest path: \`${openapi.closest}\`)` : ''}; add \`type="…"\`, or \`type="any"\` to leave it unchecked`
874
1156
  : 'add `type="…"` with a type from the types module, or `type="any"` to leave it unchecked';
@@ -879,6 +1161,8 @@ class Compiler {
879
1161
  name, src, key: info.key, items: attr(el, 'items') ?? null, total: attr(el, 'total') ?? null,
880
1162
  order: order ? order.trim() : null, when, paginate, deps: [...deps].sort(), scope: ctx.stateIdx, data: null,
881
1163
  ...(cache !== undefined ? { cache } : {}),
1164
+ ...(each ? { each } : {}),
1165
+ ...(call ? { call: { args: call.args, run: call.run } } : {}),
882
1166
  });
883
1167
  }
884
1168
  /** Matches a resource against the OpenAPI specs; checks `items`, `total` and query parameter names. */
@@ -940,7 +1224,7 @@ class Compiler {
940
1224
  if (ev)
941
1225
  events.push(ev);
942
1226
  }
943
- this.syncs.push({ src, events });
1227
+ this.syncs.push({ src, events, ...(ctx.stateIdx >= 0 ? { scope: ctx.stateIdx } : {}) });
944
1228
  this.features.add('sse');
945
1229
  }
946
1230
  event(el, ctx) {
@@ -972,7 +1256,7 @@ class Compiler {
972
1256
  const fnOf = (n, object = false) => {
973
1257
  if (attr(el, n) == null)
974
1258
  return null;
975
- const r = this.expr(el, n, scope, { mode: 'value', payload: true });
1259
+ const r = this.expr(el, n, scope, { mode: 'value', payload: true, payloadState: true });
976
1260
  this.typeLast({
977
1261
  payload: payloadType,
978
1262
  ...(n === 'item' ? { returns: { kind: 'item', resource } } : n === 'set' ? { returns: { kind: 'item', resource, partial: true } } : {}),
@@ -1011,7 +1295,7 @@ class Compiler {
1011
1295
  const usesEl = (r) => ({ ...(r.usesEl ? { e: 1 } : {}), ...(r.usesIndex ? { i: 1 } : {}) });
1012
1296
  function binding(b) {
1013
1297
  const out = { k: b.k };
1014
- for (const key of ['f', 'd', 'n', 'x', 'a', 'y', 'w', 'p', 'e', 'i']) {
1298
+ for (const key of ['f', 'd', 'n', 'x', 'a', 'y', 'w', 'p', 'e', 'i', 'ms']) {
1015
1299
  const v = b[key];
1016
1300
  if (v === undefined || (Array.isArray(v) && v.length === 0 && key !== 'd'))
1017
1301
  continue;
@@ -59,6 +59,11 @@ export const DIAGNOSTIC_DOCS = {
59
59
  means: 'TypeScript found a type error in an expression. Reported by `sinter check`, `sinter({ typecheck: true })` and the editor integration.',
60
60
  fix: 'Fix the expression, or the type: `type` on `<sn-resource>`, the OpenAPI spec, or a JSDoc cast in `data-sn-state`.',
61
61
  },
62
+ SN209: {
63
+ example: '`s` is already defined here by `data-sn-let`; use another name',
64
+ means: 'A `data-sn-let` name is inlined wherever it is read, so it cannot share a name with a state variable or loop variable in its scope, or be redeclared inside it.',
65
+ fix: 'Rename the let, or the state or loop variable.',
66
+ },
62
67
  SN301: {
63
68
  example: 'resource `mesages` is not declared on this page',
64
69
  means: 'A store call, `$meta` or embedded data (`data-sn-data`) names a resource that no `<sn-resource>` on the page or its partials declares.',
@@ -71,13 +76,13 @@ export const DIAGNOSTIC_DOCS = {
71
76
  },
72
77
  SN303: {
73
78
  example: '`$meta.contacts.loadng` is not a status field. Did you mean `loading`?',
74
- means: '`$meta.<resource>` has a fixed set of fields: `loading`, `error`, `loadedAt`, `pending`, `total`, and with `paginate` also `page`, `pages`, `from`, `to`, `hasPrev`, `hasNext`.',
79
+ means: '`$meta.<resource>` has a fixed set of fields: `loading`, `error`, `loadedAt`, `pending`, `total`, and with `paginate` also `pages`, `from`, `to`, `hasPrev`, `hasNext`; `page` with `paginate="offset"`, or keyset with `offset-param`.',
75
80
  fix: 'Use the suggested field. A pagination field needs `paginate` on the resource.',
76
81
  },
77
82
  SN304: {
78
- example: 'keyset pagination cannot jump to a page number. Use `paginate="offset"`',
79
- means: 'Keyset (cursor) pagination only knows the next and previous page, so `page(\'name\', 7)` has nothing to jump to.',
80
- fix: 'Use `paginate="offset"` if the API supports offsets, or only step with `next`, `prev` and `first`.',
83
+ example: 'keyset pagination cannot jump to a page number. Add `offset-param` if the server also accepts an offset, or use `paginate="offset"`',
84
+ means: 'Keyset (cursor) pagination only knows the next and previous page, so `page(\'name\', 7)` has nothing to jump to, unless the server can also skip items by offset.',
85
+ fix: 'If the API accepts an offset next to its cursor, add `offset-param="offset"`: the jump is sent as an offset and `prev`/`next` continue by cursor. Otherwise use `paginate="offset"`, or only step with `first`, `prev`, `next` and `last`.',
81
86
  },
82
87
  SN305: {
83
88
  example: 'partial `./partials/contact-dialg.html` not found or outside the project root',
@@ -99,6 +104,16 @@ export const DIAGNOSTIC_DOCS = {
99
104
  means: 'The resource matched an operation in the OpenAPI spec, but its `items` or `total` path is not in the response schema.',
100
105
  fix: 'Fix the path in `items` or `total`, or the spec if the API changed.',
101
106
  },
107
+ SN309: {
108
+ example: 'no `<dialog id="compose-dialg">` on this page. Did you mean `compose-dialog`?',
109
+ means: '`showModal(id)` and `close(id)` name a `<dialog>` by its literal `id`, which must be on the page or one of its partials.',
110
+ fix: 'Fix the id, or add `id` to the `<dialog>`. `close()` without an argument closes the dialog around the element.',
111
+ },
112
+ SN310: {
113
+ example: '`pgp.decrpyt` is not exported by the actions module. Did you mean `pgp.decrypt`?',
114
+ means: '`data-sn-call` and `<sn-resource call>` name a function the actions module (`src/sinter.actions.js` or `.ts`) exports, directly or through `export * as ns`.',
115
+ fix: 'Use the suggested name, or export the function from the actions module.',
116
+ },
102
117
  SN401: {
103
118
  example: '`data-sn-for` takes `item in list` or `(item, i) in list`',
104
119
  means: 'An attribute value has the wrong form: a JavaScript syntax error (the column points inside the attribute), or a value outside what the attribute accepts, such as `paginate` or `data-sn-concurrency`.',
@@ -122,7 +137,7 @@ export const DIAGNOSTIC_DOCS = {
122
137
  SN504: {
123
138
  example: 'resource `contacts` has no type: expressions using it are not checked',
124
139
  means: 'The type checker has no type for the resource, so expressions that use it are not checked. Reported by `sinter check` only.',
125
- fix: 'Add `type="Contact[]"` with a type from `src/sinter.types.ts`, configure an OpenAPI spec that matches `src`, or write `type="any"` to opt out.',
140
+ fix: 'Add `type="Contact[]"` with a type from `src/sinter.types.ts`, configure an OpenAPI spec that matches `src`, load it with `call` (typed by what the action returns), or write `type="any"` to opt out.',
126
141
  },
127
142
  SN505: {
128
143
  example: '`GET /articles` declares no query parameter `tagg`. Did you mean `tag`?',