@tiaforge-ui/sinter 0.1.0 → 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.
@@ -1,6 +1,7 @@
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';
@@ -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,6 +54,7 @@ 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,
57
+ actions: new Set([...(opts.actions?.names ?? []), ...Object.keys(opts.actions?.namespaces ?? {})]),
53
58
  usedHelpers: new Set(), usedNs: new Map(), usesNow: false, pathParams: new Set(),
54
59
  };
55
60
  }
@@ -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'].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;
@@ -251,6 +343,8 @@ 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;
@@ -352,6 +446,9 @@ class Compiler {
352
446
  return null;
353
447
  }
354
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;
355
452
  const nodeIdx = ctx.list.length;
356
453
  let scope = ctx.scope;
357
454
  let result = null;
@@ -408,6 +505,17 @@ class Compiler {
408
505
  this.model(el, node, scope, get('persist'));
409
506
  else if (has('persist') && !has('state'))
410
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
+ }
411
519
  for (const a of sn) {
412
520
  if (!a.name.startsWith('data-sn-on-'))
413
521
  continue;
@@ -418,16 +526,19 @@ class Compiler {
418
526
  }
419
527
  const r = this.expr(el, a.name, scope, { mode: 'handler', magic: ['$event'] });
420
528
  if (r.ok)
421
- node.b.push(binding({ k: 'on', n: event, f: this.fn(r.code), w: r.writes }));
529
+ node.b.push(binding({ k: 'on', n: event, f: this.fn(r.code), w: r.writes, ...(handlerDebounce > 0 ? { ms: handlerDebounce } : {}) }));
422
530
  }
423
- const methods = METHODS.filter((m) => has(m));
424
531
  if (methods.length > 1)
425
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);
426
535
  if (methods.length)
427
536
  this.action(el, node, scope, methods[0], get);
537
+ else if (isCall)
538
+ this.action(el, node, scope, 'call', get);
428
539
  else
429
540
  for (const n of ACTION_ONLY)
430
- if (has(n))
541
+ if (has(n) && n !== 'debounce')
431
542
  this.report(el, `data-sn-${n}`, 'SN102', `\`data-sn-${n}\` needs a method attribute (\`data-sn-get\`, \`-post\`, …) on the same element`, 0);
432
543
  // templates
433
544
  let content = null;
@@ -476,7 +587,7 @@ class Compiler {
476
587
  }
477
588
  else {
478
589
  this.walkChildren(el, {
479
- ...ctx, scope,
590
+ ...ctx, scope, dialog: inDialog,
480
591
  stateIdx: node.state !== undefined && hasDirectives ? nodeIdx : ctx.stateIdx,
481
592
  path: ctx.tid >= 0 ? [...ctx.path, index] : ctx.path,
482
593
  });
@@ -495,9 +606,12 @@ class Compiler {
495
606
  this.templates.push({ id: tid, nodes: [] });
496
607
  return tid;
497
608
  }
609
+ /** Index of a function in the page's table; identical code shares one entry (one compile in the engine). */
498
610
  fn(code) {
499
- this.fns.push(code);
500
- return this.fns.length - 1;
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;
501
615
  }
502
616
  expr(el, attrName, scope, o, value, shift = 0) {
503
617
  const src = value ?? attr(el, attrName) ?? '';
@@ -541,7 +655,7 @@ class Compiler {
541
655
  let event = null;
542
656
  if (attrName.startsWith('data-sn-on-'))
543
657
  event = attrName.slice('data-sn-on-'.length);
544
- else if (o.mode !== 'value') {
658
+ else if (o.mode !== 'value' || o.magic?.includes('$event')) {
545
659
  event = attr(el, 'data-sn-trigger') ?? (el.tagName === 'form' ? 'submit' : FORM_CONTROLS.has(el.tagName) ? 'change' : 'click');
546
660
  }
547
661
  const e = {
@@ -549,7 +663,7 @@ class Compiler {
549
663
  result: attrName === 'data-sn-then', error: attrName === 'data-sn-catch',
550
664
  };
551
665
  if (o.payload)
552
- e.payload = { names: r.payloadNames ?? [], type: null };
666
+ e.payload = { names: r.payloadNames ?? [], type: null, ...(r.payloadStates?.length ? { states: r.payloadStates } : {}) };
553
667
  this.check.exprs.push(e);
554
668
  return this.check.exprs.length - 1;
555
669
  }
@@ -649,7 +763,11 @@ class Compiler {
649
763
  node.b.push(b);
650
764
  }
651
765
  action(el, node, scope, method, get) {
652
- const url = this.urlTemplate(el, `data-sn-${method}`, scope);
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);
653
771
  const tag = el.tagName;
654
772
  const isForm = tag === 'form';
655
773
  const trigger = get('trigger') ?? (isForm ? 'submit' : FORM_CONTROLS.has(tag) ? 'change' : 'click');
@@ -674,17 +792,19 @@ class Compiler {
674
792
  return { f: r.ok ? this.fn(r.code) : null, w: r.writes };
675
793
  };
676
794
  // 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;
795
+ const op = method !== 'call' && this.check && this.opts.openapi?.length ? matchOperation(this.opts.openapi, url, method) : null;
678
796
  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);
797
+ const body = fnOf('body', { mode: 'value', magic: ['$event'] }, typed?.request != null ? { returns: { kind: 'openapi', spec: typed.spec, schema: typed.request } } : undefined);
680
798
  const optimistic = fnOf('optimistic', { mode: 'optimistic' });
681
799
  if (optimistic.f !== null)
682
800
  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);
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);
684
803
  const catch_ = fnOf('catch', { mode: 'handler', magic: ['$event', '$error'] });
685
804
  const def = {
686
805
  method: method.toUpperCase(), url, body: body.f, trigger, debounce, concurrency,
687
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 } } : {}),
688
808
  };
689
809
  this.actions.push(def);
690
810
  node.b.push(binding({ k: 'on', n: trigger, x: this.actions.length - 1 }));
@@ -803,10 +923,50 @@ class Compiler {
803
923
  this.report(el, a.name, 'SN101', `\`${a.name}\` is not an attribute of \`<sn-resource>\`.${didYouMean(a.name, RESOURCE_ATTRS)}`, 0);
804
924
  }
805
925
  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);
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
+ }
808
937
  const deps = new Set();
809
- const src = this.urlTemplate(el, 'src', ctx.scope, {}, deps);
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;
810
970
  let when = null;
811
971
  if (attr(el, 'when') != null) {
812
972
  const r = this.expr(el, 'when', ctx.scope, { mode: 'value' });
@@ -842,10 +1002,28 @@ class Compiler {
842
1002
  mode: mode, size, sizeParam: attr(el, 'size-param') ?? 'limit', cursor,
843
1003
  directionParam: attr(el, 'direction-param') ?? 'direction', pageParam: attr(el, 'page-param') ?? 'page',
844
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
+ }
845
1023
  this.features.add('paginate');
846
1024
  }
847
1025
  }
848
- for (const a of ['page-size', 'size-param', 'cursor', 'direction-param', 'page-param']) {
1026
+ for (const a of ['page-size', 'size-param', 'cursor', 'direction-param', 'direction-values', 'page-param', 'offset-param']) {
849
1027
  if (attr(el, a) != null && !paginate && mode == null)
850
1028
  this.report(el, a, 'SN102', `\`${a}\` needs \`paginate\``, 0);
851
1029
  }
@@ -862,13 +1040,16 @@ class Compiler {
862
1040
  }
863
1041
  const order = attr(el, 'order');
864
1042
  const type = attr(el, 'type')?.trim() || null;
865
- const openapi = type ? null : this.openapi(el, src, attr(el, 'items') ?? null, attr(el, 'total') ?? null);
1043
+ const openapi = type || hasCall ? null : this.openapi(el, src, attr(el, 'items') ?? null, attr(el, 'total') ?? null);
866
1044
  if (this.check) {
867
1045
  const r = { name, type, loc: this.locOf(el, type ? 'type' : null), nameLoc: this.locOf(el, 'name') };
868
1046
  if (openapi && 'schema' in openapi)
869
1047
  r.openapi = openapi;
1048
+ if (call)
1049
+ r.call = { name: call.name, items: attr(el, 'items') ?? null };
870
1050
  this.check.resources.push(r);
871
- if (!type && !r.openapi) {
1051
+ // a resource fed by an action is typed by the action's result
1052
+ if (!type && !r.openapi && !r.call) {
872
1053
  const hint = openapi && 'closest' in openapi
873
1054
  ? `no OpenAPI GET operation matches \`src\`${openapi.closest ? ` (closest path: \`${openapi.closest}\`)` : ''}; add \`type="…"\`, or \`type="any"\` to leave it unchecked`
874
1055
  : 'add `type="…"` with a type from the types module, or `type="any"` to leave it unchecked';
@@ -879,6 +1060,8 @@ class Compiler {
879
1060
  name, src, key: info.key, items: attr(el, 'items') ?? null, total: attr(el, 'total') ?? null,
880
1061
  order: order ? order.trim() : null, when, paginate, deps: [...deps].sort(), scope: ctx.stateIdx, data: null,
881
1062
  ...(cache !== undefined ? { cache } : {}),
1063
+ ...(each ? { each } : {}),
1064
+ ...(call ? { call: { args: call.args, run: call.run } } : {}),
882
1065
  });
883
1066
  }
884
1067
  /** Matches a resource against the OpenAPI specs; checks `items`, `total` and query parameter names. */
@@ -940,7 +1123,7 @@ class Compiler {
940
1123
  if (ev)
941
1124
  events.push(ev);
942
1125
  }
943
- this.syncs.push({ src, events });
1126
+ this.syncs.push({ src, events, ...(ctx.stateIdx >= 0 ? { scope: ctx.stateIdx } : {}) });
944
1127
  this.features.add('sse');
945
1128
  }
946
1129
  event(el, ctx) {
@@ -972,7 +1155,7 @@ class Compiler {
972
1155
  const fnOf = (n, object = false) => {
973
1156
  if (attr(el, n) == null)
974
1157
  return null;
975
- const r = this.expr(el, n, scope, { mode: 'value', payload: true });
1158
+ const r = this.expr(el, n, scope, { mode: 'value', payload: true, payloadState: true });
976
1159
  this.typeLast({
977
1160
  payload: payloadType,
978
1161
  ...(n === 'item' ? { returns: { kind: 'item', resource } } : n === 'set' ? { returns: { kind: 'item', resource, partial: true } } : {}),
@@ -1011,7 +1194,7 @@ class Compiler {
1011
1194
  const usesEl = (r) => ({ ...(r.usesEl ? { e: 1 } : {}), ...(r.usesIndex ? { i: 1 } : {}) });
1012
1195
  function binding(b) {
1013
1196
  const out = { k: b.k };
1014
- for (const key of ['f', 'd', 'n', 'x', 'a', 'y', 'w', 'p', 'e', 'i']) {
1197
+ for (const key of ['f', 'd', 'n', 'x', 'a', 'y', 'w', 'p', 'e', 'i', 'ms']) {
1015
1198
  const v = b[key];
1016
1199
  if (v === undefined || (Array.isArray(v) && v.length === 0 && key !== 'd'))
1017
1200
  continue;
@@ -71,13 +71,13 @@ export const DIAGNOSTIC_DOCS = {
71
71
  },
72
72
  SN303: {
73
73
  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`.',
74
+ 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
75
  fix: 'Use the suggested field. A pagination field needs `paginate` on the resource.',
76
76
  },
77
77
  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`.',
78
+ example: 'keyset pagination cannot jump to a page number. Add `offset-param` if the server also accepts an offset, or use `paginate="offset"`',
79
+ 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.',
80
+ 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
81
  },
82
82
  SN305: {
83
83
  example: 'partial `./partials/contact-dialg.html` not found or outside the project root',
@@ -99,6 +99,11 @@ export const DIAGNOSTIC_DOCS = {
99
99
  means: 'The resource matched an operation in the OpenAPI spec, but its `items` or `total` path is not in the response schema.',
100
100
  fix: 'Fix the path in `items` or `total`, or the spec if the API changed.',
101
101
  },
102
+ SN310: {
103
+ example: '`pgp.decrpyt` is not exported by the actions module. Did you mean `pgp.decrypt`?',
104
+ 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`.',
105
+ fix: 'Use the suggested name, or export the function from the actions module.',
106
+ },
102
107
  SN401: {
103
108
  example: '`data-sn-for` takes `item in list` or `(item, i) in list`',
104
109
  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 +127,7 @@ export const DIAGNOSTIC_DOCS = {
122
127
  SN504: {
123
128
  example: 'resource `contacts` has no type: expressions using it are not checked',
124
129
  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.',
130
+ 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
131
  },
127
132
  SN505: {
128
133
  example: '`GET /articles` declares no query parameter `tagg`. Did you mean `tag`?',
@@ -36,6 +36,7 @@ export declare const CODES: {
36
36
  readonly SN306: 'include-cycle';
37
37
  readonly SN307: 'openapi-invalid';
38
38
  readonly SN308: 'unknown-schema-field';
39
+ readonly SN310: 'unknown-action';
39
40
  readonly SN401: 'parse-error';
40
41
  readonly SN501: 'unused-resource';
41
42
  readonly SN502: 'non-reactive-value';
@@ -19,6 +19,7 @@ export const CODES = {
19
19
  SN306: 'include-cycle',
20
20
  SN307: 'openapi-invalid',
21
21
  SN308: 'unknown-schema-field',
22
+ SN310: 'unknown-action',
22
23
  SN401: 'parse-error',
23
24
  SN501: 'unused-resource',
24
25
  SN502: 'non-reactive-value',
@@ -24,6 +24,8 @@ export interface ResourceInfo {
24
24
  key: string[] | null;
25
25
  paginate: string | null;
26
26
  used: boolean;
27
+ /** keyset with `offset-param`: `page(name, n)` and `$meta.<name>.page` are available */
28
+ jumps?: boolean;
27
29
  }
28
30
  export interface Scope {
29
31
  loops: LoopEntry[];
@@ -32,6 +34,8 @@ export interface Scope {
32
34
  export interface PageInfo {
33
35
  resources: Map<string, ResourceInfo>;
34
36
  helpers: Set<string>;
37
+ /** action functions and namespaces: only `data-sn-call` and `<sn-resource call>` may name them */
38
+ actions?: Set<string>;
35
39
  namespaces: Map<string, Set<string>>;
36
40
  /** imports used by emitted code */
37
41
  usedHelpers: Set<string>;
@@ -46,6 +50,8 @@ export interface ExprOptions {
46
50
  magic?: string[];
47
51
  /** free identifiers resolve to fields of `$.$p` (sn-event, cursor) */
48
52
  payload?: boolean;
53
+ /** with `payload`: state names in scope resolve to the state first (`<sn-event>`) */
54
+ payloadState?: boolean;
49
55
  /** parse as `{...}` object literal; returns its entries */
50
56
  object?: 'literal-keys' | 'any-keys' | 'state';
51
57
  report: (code: Code, message: string, offset: number, hint?: string) => void;
@@ -73,6 +79,8 @@ export interface ExprResult {
73
79
  ast: Node | null;
74
80
  /** names resolved to payload fields (`payload` mode) */
75
81
  payloadNames?: string[];
82
+ /** state names read where the payload is the scope (they shadow payload fields of the same name) */
83
+ payloadStates?: string[];
76
84
  }
77
85
  export declare const ALL_MAGIC: string[];
78
86
  export declare const STORE_FNS: string[];
@@ -50,6 +50,7 @@ export function analyze(src, scope, page, opts) {
50
50
  let usesIndex = false;
51
51
  let ok = true;
52
52
  const payloadNames = new Set();
53
+ const payloadStates = new Set();
53
54
  const mode = opts.mode;
54
55
  const magicAllowed = new Set([...EVERYWHERE_MAGIC, ...(opts.magic ?? [])]);
55
56
  const report = (code, message, offset, hint) => {
@@ -117,6 +118,14 @@ export function analyze(src, scope, page, opts) {
117
118
  report('SN201', `\`${name}\` is not defined.${didYouMean(name, ALL_MAGIC)}`, offset);
118
119
  return null;
119
120
  }
121
+ if (opts.payload && opts.payloadState) {
122
+ for (let i = scope.states.length - 1; i >= 0; i--) {
123
+ if (scope.states[i].names.has(name)) {
124
+ payloadStates.add(name);
125
+ return { kind: 'state', sid: scope.states[i].sid };
126
+ }
127
+ }
128
+ }
120
129
  if (!opts.payload) {
121
130
  for (let i = scope.loops.length - 1; i >= 0; i--) {
122
131
  if (scope.loops[i].name === name)
@@ -148,6 +157,10 @@ export function analyze(src, scope, page, opts) {
148
157
  payloadNames.add(name);
149
158
  return { kind: 'payload' };
150
159
  }
160
+ if (page.actions?.has(name)) {
161
+ report('SN203', `\`${name}\` is an action: actions run through \`data-sn-call\` or \`<sn-resource call>\`, not inside expressions`, offset);
162
+ return null;
163
+ }
151
164
  if (BROWSER.has(name)) {
152
165
  report('SN202', `\`${name}\` is a browser API; expressions may use JavaScript, not the browser. Use an island`, offset);
153
166
  return null;
@@ -278,8 +291,8 @@ export function analyze(src, scope, page, opts) {
278
291
  else if (PAGE_FIELDS.includes(field) && !r.paginate) {
279
292
  report('SN303', `\`$meta.${rname}.${field}\` is a pagination field, but \`${rname}\` has no \`paginate\``, n.start);
280
293
  }
281
- else if (field === 'page' && r.paginate !== 'offset') {
282
- report('SN303', `\`$meta.${rname}.page\` exists only with \`paginate="offset"\``, n.start);
294
+ else if (field === 'page' && r.paginate !== 'offset' && !r.jumps) {
295
+ report('SN303', `\`$meta.${rname}.page\` exists only with \`paginate="offset"\`, or \`paginate="keyset"\` with \`offset-param\``, n.start);
283
296
  }
284
297
  }
285
298
  }
@@ -572,8 +585,10 @@ export function analyze(src, scope, page, opts) {
572
585
  report('SN303', `\`${first.value}\` has no \`paginate\`; \`page()\` needs a paginated resource`, n.start);
573
586
  const target = n.arguments[1];
574
587
  const named = target?.type === 'Literal' && typeof target.value === 'string' ? target.value : null;
575
- if (r.paginate && r.paginate !== 'offset' && (!named)) {
576
- report('SN304', 'keyset pagination cannot jump to a page number. Use `paginate="offset"`', target?.start ?? n.start);
588
+ if (r.paginate && r.paginate !== 'offset' && !named && !(r.paginate === 'keyset' && r.jumps)) {
589
+ report('SN304', r.paginate === 'keyset'
590
+ ? 'keyset pagination cannot jump to a page number. Add `offset-param` if the server also accepts an offset, or use `paginate="offset"`'
591
+ : 'keyset pagination cannot jump to a page number. Use `paginate="offset"`', target?.start ?? n.start);
577
592
  }
578
593
  else if (r.paginate === 'append' && named && named !== 'next') {
579
594
  report('SN304', '`append` pagination only supports `page(…, \'next\')`', target.start);
@@ -827,7 +842,7 @@ export function analyze(src, scope, page, opts) {
827
842
  }
828
843
  }
829
844
  }
830
- return { ok, code, deps: [...deps].sort(), writes: [...writes].sort(), usesEl, usesIndex, entries, path, resource, ast, ...(opts.payload ? { payloadNames: [...payloadNames].sort() } : {}) };
845
+ return { ok, code, deps: [...deps].sort(), writes: [...writes].sort(), usesEl, usesIndex, entries, path, resource, ast, ...(opts.payload ? { payloadNames: [...payloadNames].sort(), payloadStates: [...payloadStates].sort() } : {}) };
831
846
  }
832
847
  export function applyEdits(src, edits) {
833
848
  const sorted = [...edits].sort((a, b) => b.start - a.start || (b.end - b.start) - (a.end - a.start));
@@ -8,10 +8,11 @@ export { compileParts };
8
8
  export declare const PACKAGE = "@tiaforge-ui/sinter";
9
9
  /** Compiles annotated HTML into a rewritten page and its page module. Pure and deterministic. */
10
10
  export declare function compile(html: string, options: CompileOptions): CompileResult;
11
- export declare function pageModule(parts: CompiledParts, options: Pick<CompileOptions, 'helpers' | 'dev' | 'config'>): string;
11
+ export declare function pageModule(parts: CompiledParts, options: Pick<CompileOptions, 'helpers' | 'dev' | 'config' | 'actions'>): string;
12
12
  /**
13
13
  * Reads a helpers module's export names statically. `export * as m from './x.js'`
14
- * becomes a namespace whose members are read from `x.js` the same way.
14
+ * becomes a namespace whose members are read from `x.js` the same way. `export * from './y.js'`
15
+ * re-exports (e.g. paraglide's message index) are followed, relative paths only.
15
16
  */
16
17
  export declare function readHelperManifest(source: string, options: {
17
18
  module: string;