@tiaforge-ui/sinter 0.2.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.
package/README.md CHANGED
@@ -96,7 +96,7 @@ Measurements and sources: [`docs/realworld-comparison.md`](docs/realworld-compar
96
96
  islands, which are plain custom elements.
97
97
  - You can't have a build step.
98
98
  - Your backend renders HTML, not JSON.
99
- - You need stability guarantees. sinter is at v0.2.
99
+ - You need stability guarantees. sinter is at v0.3.
100
100
 
101
101
  ## Setup
102
102
 
@@ -147,12 +147,13 @@ Then write `data-sn-*` attributes in your HTML pages. Optional:
147
147
  ## Development
148
148
 
149
149
  ```sh
150
- npm test # vitest (happy-dom)
150
+ npm test # vitest (happy-dom); CI runs typecheck, build and tests on every push (Node 22/24/26)
151
151
  npm run typecheck
152
152
  npm run build # dist/
153
153
  npm run size # size per page and module, with budgets
154
154
  npm run bench # store and flush benchmarks (docs/benchmark.md)
155
155
  npm run docs # regenerate llms.txt, html-custom-data.json (also the VS Code extension's) and docs/diagnostics.md
156
+ npm run release -- 0.3.0 # bump versions, date the changelog, commit "Release 0.3.0", tag v0.3.0 (not pushed)
156
157
  ```
157
158
 
158
159
  License: MIT. Contributions are accepted under the Developer Certificate of Origin
@@ -64,7 +64,8 @@ export function check(o) {
64
64
  const tsconfig = join(outDir, 'tsconfig.json');
65
65
  writeFileSync(tsconfig, JSON.stringify({
66
66
  compilerOptions: COMPILER_OPTIONS,
67
- files: ['sinter.d.ts', ...support.openapi.map((f) => relative(outDir, f)), ...[...shadows.keys()].map((f) => relative(outDir, f).split('\\').join('/'))],
67
+ // the types module always, so augmentations in it (declare global, a library's import type {}) apply
68
+ files: ['sinter.d.ts', ...(support.types ? [relative(outDir, support.types.file).split('\\').join('/')] : []), ...support.openapi.map((f) => relative(outDir, f)), ...[...shadows.keys()].map((f) => relative(outDir, f).split('\\').join('/'))],
68
69
  }, null, 2) + '\n');
69
70
  const tsc = o.tsc ?? findTsc(o.root);
70
71
  const run = spawnSync(process.execPath, [tsc, '-p', tsconfig, '--pretty', 'false'], { encoding: 'utf8', cwd: outDir });
@@ -38,7 +38,7 @@ export declare function shadow(o: ShadowOptions): Shadow;
38
38
  /** Maps a 1-based shadow position to the HTML, or null for generated lines. */
39
39
  export declare function mapPosition(regions: Region[], line: number, col: number): CheckLoc | null;
40
40
  /** `sinter.d.ts`: the types the shadow files refer to. */
41
- export declare const DECLARATIONS = "// Generated by `sinter check`. Do not edit.\nexport interface Meta {\n loading: boolean;\n error: Err | null;\n loadedAt: number | null;\n pending: number;\n total: number | null;\n page?: number | null;\n pages?: number | null;\n from?: number | null;\n to?: number | null;\n hasPrev?: boolean;\n hasNext?: boolean;\n}\nexport interface Err { status: number | null; body: any; message: string }\nexport interface CallContext { signal: AbortSignal; request(url: string, init?: { method?: string; body?: unknown; headers?: Record<string, string>; signal?: AbortSignal }): Promise<any>; el: Element | null }\n/** an action's parameters without its trailing context */\nexport type CallArgs<F> = F extends (...a: infer P) => any ? (P extends [...infer A, infer L] ? ([L] extends [CallContext] ? A : P) : P) : never;\nexport type Item<T> = NonNullable<T> extends readonly (infer U)[] ? U : NonNullable<T> extends Iterable<infer U> ? U : any;\nexport type El<K extends string> = K extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[K] : HTMLElement;\nexport type Ev<K extends string> = K extends keyof HTMLElementEventMap ? HTMLElementEventMap[K] : CustomEvent;\nexport interface Store<R> {\n refresh(name: keyof R & string): void;\n upsert<K extends keyof R & string>(name: K, item: Item<R[K]>): void;\n patch<K extends keyof R & string>(name: K, item: Partial<Item<R[K]>>, set: Partial<Item<R[K]>>): void;\n remove<K extends keyof R & string>(name: K, item: Partial<Item<R[K]>>): void;\n page(name: keyof R & string, to: 'next' | 'prev' | 'first' | 'last' | number): void;\n close(): void;\n reset(): void;\n emit(event: string, detail?: unknown): void;\n}\ntype Style = 'short' | 'medium' | 'long' | 'full';\nexport function date(v: unknown, style?: Style): string;\nexport function time(v: unknown, style?: Style): string;\nexport function datetime(v: unknown, style?: Style): string;\nexport function relative(v: unknown, now?: unknown): string;\nexport function number(v: unknown, opts?: Intl.NumberFormatOptions): string;\nexport function bytes(v: unknown): string;\n";
41
+ export declare const DECLARATIONS = "// Generated by `sinter check`. Do not edit.\nexport interface Meta {\n loading: boolean;\n error: Err | null;\n loadedAt: number | null;\n pending: number;\n total: number | null;\n page?: number | null;\n pages?: number | null;\n from?: number | null;\n to?: number | null;\n hasPrev?: boolean;\n hasNext?: boolean;\n}\nexport interface Err { status: number | null; body: any; message: string; detail: string | null }\ndeclare global {\n /**\n * Value types for attributes bound with data-sn-attr. Empty here; a library extends it to type its own\n * attributes, e.g. tinystyle (import type {} from '@tiaforge-ui/tinystyle/sinter' in the types module).\n */\n interface SinterAttributes {}\n}\n/** what data-sn-attr accepts: the keys SinterAttributes types, anything else as before */\nexport type Attrs = { [K in keyof SinterAttributes]?: SinterAttributes[K] } & Record<string, unknown>;\nexport interface CallContext { signal: AbortSignal; request(url: string, init?: { method?: string; body?: unknown; headers?: Record<string, string>; signal?: AbortSignal }): Promise<any>; el: Element | null }\n/** an action's parameters without its trailing context */\nexport type CallArgs<F> = F extends (...a: infer P) => any ? (P extends [...infer A, infer L] ? ([L] extends [CallContext] ? A : P) : P) : never;\nexport type Item<T> = NonNullable<T> extends readonly (infer U)[] ? U : NonNullable<T> extends Iterable<infer U> ? U : any;\nexport type El<K extends string> = K extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[K] : HTMLElement;\nexport type Ev<K extends string> = K extends keyof HTMLElementEventMap ? HTMLElementEventMap[K] : CustomEvent;\nexport interface Store<R> {\n refresh(name: keyof R & string): void;\n upsert<K extends keyof R & string>(name: K, item: Item<R[K]>): void;\n patch<K extends keyof R & string>(name: K, item: Partial<Item<R[K]>>, set: Partial<Item<R[K]>>): void;\n remove<K extends keyof R & string>(name: K, item: Partial<Item<R[K]>>): void;\n page(name: keyof R & string, to: 'next' | 'prev' | 'first' | 'last' | number): void;\n showModal(id: string): void;\n close(id?: string): void;\n reset(): void;\n emit(event: string, detail?: unknown): void;\n}\ntype Style = 'short' | 'medium' | 'long' | 'full';\nexport function date(v: unknown, style?: Style): string;\nexport function time(v: unknown, style?: Style): string;\nexport function datetime(v: unknown, style?: Style): string;\nexport function relative(v: unknown, now?: unknown): string;\nexport function number(v: unknown, opts?: Intl.NumberFormatOptions): string;\nexport function bytes(v: unknown): string;\n";
42
42
  /**
43
43
  * Maps a 1-based position in an HTML file to the shadow, or null when it is not inside an
44
44
  * expression. `lines` are the shadow's lines.
@@ -115,7 +115,7 @@ export function shadow(o) {
115
115
  line(`} */${after}`);
116
116
  return;
117
117
  }
118
- const text = t.kind === 'openapi'
118
+ const text = t.kind === 'decl' ? decl(t.name) : t.kind === 'openapi'
119
119
  ? schemaToTs(t.schema, (n) => `$O${t.spec}.${typeName(n)}`)
120
120
  : t.kind === 'call'
121
121
  ? (t.args ? `${decl('CallArgs')}<typeof $A.${t.name}>` : `Awaited<ReturnType<typeof $A.${t.name}>>`)
@@ -135,6 +135,10 @@ export function shadow(o) {
135
135
  f.names.forEach((n, k) => { at(f.locs?.[k]); line(`${n},`); });
136
136
  line(`} = ${f.init === null ? '$any' : `$e${f.init}()`};`);
137
137
  }
138
+ else if (f.kind === 'let') {
139
+ at(f.loc);
140
+ line(`const ${f.name} = ${f.init === null ? '$any' : `$e${f.init}()`};`);
141
+ }
138
142
  else {
139
143
  at(f.loc);
140
144
  line(`const ${f.item} = ${f.init === null ? '$any' : `$item($e${f.init}())`};`);
@@ -233,7 +237,16 @@ export interface Meta {
233
237
  hasPrev?: boolean;
234
238
  hasNext?: boolean;
235
239
  }
236
- export interface Err { status: number | null; body: any; message: string }
240
+ export interface Err { status: number | null; body: any; message: string; detail: string | null }
241
+ declare global {
242
+ /**
243
+ * Value types for attributes bound with data-sn-attr. Empty here; a library extends it to type its own
244
+ * attributes, e.g. tinystyle (import type {} from '@tiaforge-ui/tinystyle/sinter' in the types module).
245
+ */
246
+ interface SinterAttributes {}
247
+ }
248
+ /** what data-sn-attr accepts: the keys SinterAttributes types, anything else as before */
249
+ export type Attrs = { [K in keyof SinterAttributes]?: SinterAttributes[K] } & Record<string, unknown>;
237
250
  export interface CallContext { signal: AbortSignal; request(url: string, init?: { method?: string; body?: unknown; headers?: Record<string, string>; signal?: AbortSignal }): Promise<any>; el: Element | null }
238
251
  /** an action's parameters without its trailing context */
239
252
  export type CallArgs<F> = F extends (...a: infer P) => any ? (P extends [...infer A, infer L] ? ([L] extends [CallContext] ? A : P) : P) : never;
@@ -246,7 +259,8 @@ export interface Store<R> {
246
259
  patch<K extends keyof R & string>(name: K, item: Partial<Item<R[K]>>, set: Partial<Item<R[K]>>): void;
247
260
  remove<K extends keyof R & string>(name: K, item: Partial<Item<R[K]>>): void;
248
261
  page(name: keyof R & string, to: 'next' | 'prev' | 'first' | 'last' | number): void;
249
- close(): void;
262
+ showModal(id: string): void;
263
+ close(id?: string): void;
250
264
  reset(): void;
251
265
  emit(event: string, detail?: unknown): void;
252
266
  }
@@ -7,7 +7,7 @@ export interface CheckLoc {
7
7
  chain: string[];
8
8
  }
9
9
  /**
10
- * A scope frame: a `data-sn-state` object or a `data-sn-for` loop. `init` is the
10
+ * A scope frame: a `data-sn-state` object, a `data-sn-for` loop or a `data-sn-let` name. `init` is the
11
11
  * expression that creates it (the state literal, the list), or null when that
12
12
  * expression did not compile.
13
13
  */
@@ -27,6 +27,13 @@ export type CheckFrame =
27
27
  init: number | null;
28
28
  loc?: CheckLoc;
29
29
  indexLoc?: CheckLoc;
30
+ }
31
+ /** a `data-sn-let` name; `loc`: where the name is written */
32
+ | {
33
+ kind: 'let';
34
+ name: string;
35
+ init: number | null;
36
+ loc?: CheckLoc;
30
37
  };
31
38
  /** A type the checker writes for something other than a resource. */
32
39
  export type TypeRef =
@@ -53,13 +60,18 @@ export type TypeRef =
53
60
  kind: 'call';
54
61
  name: string;
55
62
  args?: boolean;
63
+ }
64
+ /** a type sinter declares itself (\`Attrs\` for \`data-sn-attr\`) */
65
+ | {
66
+ kind: 'decl';
67
+ name: string;
56
68
  };
57
69
  export interface CheckExpr {
58
70
  src: string;
59
71
  /** `value` returns the expression; `handler` runs it as statements */
60
72
  mode: 'value' | 'handler';
61
73
  loc: CheckLoc;
62
- /** frames visible here, in the order sinter resolves them last: states outer to inner, then loops outer to inner */
74
+ /** frames visible here, in the order sinter resolves them last: states, then loops, then lets, each outer to inner */
63
75
  frames: number[];
64
76
  /** tag of the element the expression sits on, for `$el` */
65
77
  tag: string;
@@ -8,7 +8,7 @@ import { ATTRIBUTES, ELEMENTS } from './vocabulary.js';
8
8
  import { matchOperation, matchResource, queryNames, walk } from './openapi.js';
9
9
  const ATTRS = ATTRIBUTES.map((a) => a.name);
10
10
  const METHODS = ['get', 'post', 'put', 'patch', 'delete'];
11
- const ACTION_ONLY = ['body', 'trigger', 'debounce', 'concurrency', 'optimistic', 'then', 'catch'];
11
+ const ACTION_ONLY = ['body', 'trigger', 'debounce', 'concurrency', 'optimistic', 'then', 'catch', 'confirm', 'encoding'];
12
12
  const UNSAFE_ELEMENTS = new Set(['script', 'style', 'iframe', 'object', 'embed', 'base', 'meta', 'link', 'animate', 'set', 'animatemotion', 'animatetransform']);
13
13
  const FORM_CONTROLS = new Set(['input', 'select', 'textarea']);
14
14
  const RESOURCE_ATTRS = ELEMENTS.find((e) => e.name === 'sn-resource').attrs;
@@ -55,7 +55,7 @@ class Compiler {
55
55
  this.page = {
56
56
  resources: new Map(), helpers: new Set(opts.helpers.names), namespaces: ns,
57
57
  actions: new Set([...(opts.actions?.names ?? []), ...Object.keys(opts.actions?.namespaces ?? {})]),
58
- usedHelpers: new Set(), usedNs: new Map(), usesNow: false, pathParams: new Set(),
58
+ usedHelpers: new Set(), usedNs: new Map(), usesNow: false, pathParams: new Set(), dialogs: new Set(),
59
59
  };
60
60
  }
61
61
  run(html) {
@@ -100,7 +100,7 @@ class Compiler {
100
100
  return {
101
101
  html: parse5.serialize(doc),
102
102
  ir, fns: this.fns,
103
- features: [...(this.opts.dev ? ['dev'] : []), ...['sse', 'optimistic', 'paginate', 'persist', 'cache', 'each'].filter((f) => this.features.has(f))],
103
+ features: [...(this.opts.dev ? ['dev'] : []), ...['sse', 'optimistic', 'paginate', 'persist', 'cache', 'each', 'listbox'].filter((f) => this.features.has(f))],
104
104
  helpers: { user, builtin }, namespaces, calls: [...this.calls].sort(),
105
105
  diagnostics: this.diagnostics, files: [...this.files].sort(),
106
106
  ...(this.check ? { check: this.check } : {}),
@@ -321,7 +321,7 @@ class Compiler {
321
321
  // the browser merges adjacent text when it parses the output; merge now so node paths agree
322
322
  mergeText(parent);
323
323
  }
324
- // ---- 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)
325
325
  declare(parent, inTemplate = false) {
326
326
  for (const child of parent.childNodes) {
327
327
  if (!isElement(child))
@@ -349,6 +349,11 @@ class Compiler {
349
349
  this.resourceEls.set(name, child);
350
350
  continue;
351
351
  }
352
+ if (child.tagName === 'dialog') {
353
+ const id = attr(child, 'id');
354
+ if (id)
355
+ this.page.dialogs.add(id);
356
+ }
352
357
  this.declare(child, inTemplate);
353
358
  if (child.tagName === 'template')
354
359
  this.declare(child.content, true);
@@ -412,7 +417,7 @@ class Compiler {
412
417
  // unknown attributes
413
418
  const known = new Set(ATTRS.map((a) => `data-sn-${a}`));
414
419
  for (const a of sn) {
415
- 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-'))
416
421
  continue;
417
422
  this.report(el, a.name, 'SN101', `\`${a.name}\` is not a sinter attribute.${didYouMean(a.name, [...known, 'data-sn-on-click'])}`, 0);
418
423
  }
@@ -466,13 +471,32 @@ class Compiler {
466
471
  const locs = first.map((e) => this.locOf(el, 'data-sn-state', e.offset));
467
472
  this.check.frames.push({ kind: 'state', names: [...entry.names], init: this.lastExpr, locs });
468
473
  }
469
- 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] };
470
476
  if (has('persist') && !has('model')) {
471
477
  node.persist = get('persist');
472
478
  this.features.add('persist');
473
479
  }
474
480
  }
475
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
+ }
476
500
  const isTemplate = el.tagName === 'template';
477
501
  const valueB = (k, name) => {
478
502
  const r = this.expr(el, name, scope, { mode: 'value' });
@@ -516,6 +540,16 @@ class Compiler {
516
540
  if (!sn.some((a) => a.name.startsWith('data-sn-on-')))
517
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);
518
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
+ }
519
553
  for (const a of sn) {
520
554
  if (!a.name.startsWith('data-sn-on-'))
521
555
  continue;
@@ -525,8 +559,9 @@ class Compiler {
525
559
  continue;
526
560
  }
527
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;
528
563
  if (r.ok)
529
- node.b.push(binding({ k: 'on', n: event, f: this.fn(r.code), w: r.writes, ...(handlerDebounce > 0 ? { ms: handlerDebounce } : {}) }));
564
+ node.b.push(binding({ k: 'on', n: event, f: this.fn(r.code), w: r.writes, ...(ms > 0 ? { ms } : {}) }));
530
565
  }
531
566
  if (methods.length > 1)
532
567
  this.report(el, `data-sn-${methods[1]}`, 'SN102', 'an element can have only one method attribute', 0);
@@ -627,6 +662,61 @@ class Compiler {
627
662
  }
628
663
  return r;
629
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
+ }
630
720
  // ---- check unit
631
721
  /** Adds type information to the expression `expr()` recorded last. */
632
722
  typeLast(info) {
@@ -647,7 +737,7 @@ class Compiler {
647
737
  }
648
738
  recordExpr(el, attrName, scope, o, src, shift, r) {
649
739
  const frames = [];
650
- for (const f of [...scope.states, ...scope.loops]) {
740
+ for (const f of [...scope.states, ...scope.loops, ...(scope.lets ?? [])]) {
651
741
  const i = this.frameOf.get(f);
652
742
  if (i !== undefined && !frames.includes(i))
653
743
  frames.push(i);
@@ -670,6 +760,8 @@ class Compiler {
670
760
  // ---- bindings
671
761
  attrBinding(el, node, scope) {
672
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' } });
673
765
  if (!r.entries)
674
766
  return;
675
767
  for (const e of r.entries) {
@@ -801,10 +893,18 @@ class Compiler {
801
893
  const then = fnOf('then', { mode: 'handler', magic: ['$event', '$result'] }, typed?.body != null ? { resultType: { kind: 'openapi', spec: typed.spec, schema: typed.body } }
802
894
  : call ? { resultType: { kind: 'call', name: call.name } } : undefined);
803
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);
804
902
  const def = {
805
903
  method: method.toUpperCase(), url, body: body.f, trigger, debounce, concurrency,
806
904
  optimistic: optimistic.f, then: then.f, catch: catch_.f, w: [...new Set([...then.w, ...catch_.w])].sort(), form: isForm,
807
905
  ...(call ? { call: { args: call.args, run: call.run } } : {}),
906
+ ...(confirm.f !== null ? { confirm: confirm.f } : {}),
907
+ ...(encoding === 'form' ? { enc: 'form' } : {}),
808
908
  };
809
909
  this.actions.push(def);
810
910
  node.b.push(binding({ k: 'on', n: trigger, x: this.actions.length - 1 }));
@@ -839,7 +939,8 @@ class Compiler {
839
939
  frame.indexLoc = this.locOf(el, 'data-sn-for', value.indexOf(index, itemAt + item.length));
840
940
  this.check.frames.push(frame);
841
941
  }
842
- const inner = { loops, states: scope.states };
942
+ this.checkNames(el, 'data-sn-for', scope, index ? [item, index] : [item]);
943
+ const inner = { ...scope, loops };
843
944
  t.key = null;
844
945
  t.keyFields = null;
845
946
  if (keySrc !== undefined) {
@@ -953,7 +1054,7 @@ class Compiler {
953
1054
  this.frameOf.set(loop, this.check.frames.length);
954
1055
  this.check.frames.push({ kind: 'loop', item: m[1], index: null, init: listExpr, loc: this.locOf(el, 'each', eachAttr.indexOf(m[1])) });
955
1056
  }
956
- srcScope = { loops: [...ctx.scope.loops, loop], states: ctx.scope.states };
1057
+ srcScope = { ...ctx.scope, loops: [...ctx.scope.loops, loop] };
957
1058
  if (list.ok)
958
1059
  each = { item: m[1], list: this.fn(list.code), ...(list.resource ? { of: list.resource } : {}) };
959
1060
  this.features.add('each');
@@ -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.',
@@ -99,6 +104,11 @@ 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
+ },
102
112
  SN310: {
103
113
  example: '`pgp.decrpyt` is not exported by the actions module. Did you mean `pgp.decrypt`?',
104
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`.',
@@ -28,6 +28,7 @@ export declare const CODES: {
28
28
  readonly SN206: 'unknown-namespace-member';
29
29
  readonly SN207: 'unsafe-sink';
30
30
  readonly SN208: 'type-error';
31
+ readonly SN209: 'name-conflict';
31
32
  readonly SN301: 'unknown-resource';
32
33
  readonly SN302: 'duplicate-resource';
33
34
  readonly SN303: 'unknown-meta-field';
@@ -36,6 +37,7 @@ export declare const CODES: {
36
37
  readonly SN306: 'include-cycle';
37
38
  readonly SN307: 'openapi-invalid';
38
39
  readonly SN308: 'unknown-schema-field';
40
+ readonly SN309: 'unknown-dialog';
39
41
  readonly SN310: 'unknown-action';
40
42
  readonly SN401: 'parse-error';
41
43
  readonly SN501: 'unused-resource';
@@ -11,6 +11,7 @@ export const CODES = {
11
11
  SN206: 'unknown-namespace-member',
12
12
  SN207: 'unsafe-sink',
13
13
  SN208: 'type-error',
14
+ SN209: 'name-conflict',
14
15
  SN301: 'unknown-resource',
15
16
  SN302: 'duplicate-resource',
16
17
  SN303: 'unknown-meta-field',
@@ -19,6 +20,7 @@ export const CODES = {
19
20
  SN306: 'include-cycle',
20
21
  SN307: 'openapi-invalid',
21
22
  SN308: 'unknown-schema-field',
23
+ SN309: 'unknown-dialog',
22
24
  SN310: 'unknown-action',
23
25
  SN401: 'parse-error',
24
26
  SN501: 'unused-resource',
@@ -19,6 +19,16 @@ export interface StateEntry {
19
19
  sid: number;
20
20
  names: Set<string>;
21
21
  }
22
+ /** A `data-sn-let` name: a value expression inlined wherever the name is read. */
23
+ export interface LetEntry {
24
+ name: string;
25
+ /** the compiled expression (without the `($) =>` wrapper) */
26
+ code: string;
27
+ deps: string[];
28
+ usesEl: boolean;
29
+ usesIndex: boolean;
30
+ resource: string | null;
31
+ }
22
32
  export interface ResourceInfo {
23
33
  name: string;
24
34
  key: string[] | null;
@@ -30,6 +40,8 @@ export interface ResourceInfo {
30
40
  export interface Scope {
31
41
  loops: LoopEntry[];
32
42
  states: StateEntry[];
43
+ /** `data-sn-let` names, outer to inner */
44
+ lets?: LetEntry[];
33
45
  }
34
46
  export interface PageInfo {
35
47
  resources: Map<string, ResourceInfo>;
@@ -43,6 +55,8 @@ export interface PageInfo {
43
55
  usesNow: boolean;
44
56
  /** `:name` segments of the page's `data-sn-path` patterns */
45
57
  pathParams: Set<string>;
58
+ /** literal `id`s of the page's `<dialog>` elements, for `showModal()` and `close()` */
59
+ dialogs: Set<string>;
46
60
  }
47
61
  export interface ExprOptions {
48
62
  mode: Mode;
@@ -66,6 +80,8 @@ export interface ObjectEntry {
66
80
  export interface ExprResult {
67
81
  ok: boolean;
68
82
  code: string;
83
+ /** `code` without its `($) =>` wrapper (value expressions) */
84
+ inner: string;
69
85
  deps: string[];
70
86
  writes: string[];
71
87
  usesEl: boolean;
@@ -5,7 +5,7 @@ import * as acorn from 'acorn';
5
5
  import { didYouMean } from './diagnostics.js';
6
6
  const EVERYWHERE_MAGIC = ['$el', '$url', '$now', '$locale', '$meta'];
7
7
  export const ALL_MAGIC = ['$event', '$el', '$url', '$now', '$locale', '$result', '$error', '$meta'];
8
- export const STORE_FNS = ['refresh', 'upsert', 'patch', 'remove', 'page', 'close', 'reset', 'emit'];
8
+ export const STORE_FNS = ['refresh', 'upsert', 'patch', 'remove', 'page', 'showModal', 'close', 'reset', 'emit'];
9
9
  export const BUILTIN_HELPERS = ['date', 'time', 'datetime', 'relative', 'number', 'bytes'];
10
10
  export const META_FIELDS = ['loading', 'error', 'loadedAt', 'pending', 'total', 'page', 'pages', 'from', 'to', 'hasPrev', 'hasNext'];
11
11
  const PAGE_FIELDS = ['page', 'pages', 'from', 'to', 'hasPrev', 'hasNext'];
@@ -96,7 +96,7 @@ export function analyze(src, scope, page, opts) {
96
96
  return fail();
97
97
  }
98
98
  function fail() {
99
- return { ok: false, code: '() => undefined', deps: [], writes: [], usesEl: false, usesIndex: false, path: null, resource: null, ast: null };
99
+ return { ok: false, code: '() => undefined', inner: 'undefined', deps: [], writes: [], usesEl: false, usesIndex: false, path: null, resource: null, ast: null };
100
100
  }
101
101
  // ---- resolution
102
102
  function resolve(name, locals, offset, member) {
@@ -127,6 +127,12 @@ export function analyze(src, scope, page, opts) {
127
127
  }
128
128
  }
129
129
  if (!opts.payload) {
130
+ // a let cannot share a name with a loop or state in its scope (SN209), so lets resolve first
131
+ const lets = scope.lets ?? [];
132
+ for (let i = lets.length - 1; i >= 0; i--) {
133
+ if (lets[i].name === name)
134
+ return { kind: 'let', entry: lets[i] };
135
+ }
130
136
  for (let i = scope.loops.length - 1; i >= 0; i--) {
131
137
  if (scope.loops[i].name === name)
132
138
  return { kind: 'loop', entry: scope.loops[i] };
@@ -170,7 +176,7 @@ export function analyze(src, scope, page, opts) {
170
176
  return { kind: 'payload' };
171
177
  }
172
178
  const candidates = [
173
- ...scope.loops.map((l) => l.name), ...scope.states.flatMap((s) => [...s.names]),
179
+ ...(scope.lets ?? []).map((l) => l.name), ...scope.loops.map((l) => l.name), ...scope.states.flatMap((s) => [...s.names]),
174
180
  ...page.resources.keys(), ...page.helpers, ...page.namespaces.keys(), ...STORE_FNS, ...BUILTIN_HELPERS,
175
181
  ];
176
182
  report('SN201', `\`${name}\` is not defined here.${didYouMean(name, candidates)}`, offset);
@@ -183,6 +189,7 @@ export function analyze(src, scope, page, opts) {
183
189
  case 'global':
184
190
  case 'helper': return name;
185
191
  case 'loop': return `$.${name}`;
192
+ case 'let': return `(${res.entry.code})`;
186
193
  case 'state': return `$.$s${sid}.${name}`;
187
194
  case 'resource': return `$.$r.${name}`;
188
195
  case 'magic': return `$.${name}`;
@@ -202,6 +209,7 @@ export function analyze(src, scope, page, opts) {
202
209
  return { segs: res.entry.alias ? [...res.entry.alias] : null, resource: res.entry.resource };
203
210
  case 'local':
204
211
  return { segs: res.info.alias ? [...res.info.alias] : null, resource: res.info.resource };
212
+ case 'let': return { segs: null, resource: res.entry.resource };
205
213
  default: return { segs: null, resource: null };
206
214
  }
207
215
  }
@@ -315,6 +323,15 @@ export function analyze(src, scope, page, opts) {
315
323
  sid = res.sid;
316
324
  if (res.kind === 'loop' && res.entry.kind === 'index')
317
325
  usesIndex = true;
326
+ if (res.kind === 'let') {
327
+ // the inlined expression reads what the let reads
328
+ for (const d of res.entry.deps)
329
+ deps.add(d);
330
+ if (res.entry.usesEl)
331
+ usesEl = true;
332
+ if (res.entry.usesIndex)
333
+ usesIndex = true;
334
+ }
318
335
  const text = emitName(n.name, res, sid);
319
336
  if (text !== n.name || shorthand)
320
337
  edits.push({ start: n.start, end: n.end, text: shorthand ? `${n.name}: ${text}` : text });
@@ -472,6 +489,10 @@ export function analyze(src, scope, page, opts) {
472
489
  report('SN203', 'only state variables and their properties can be assigned', at);
473
490
  return;
474
491
  }
492
+ if (cp.res.kind === 'let') {
493
+ report('SN203', `\`${cp.root}\` is derived (\`data-sn-let\`) and cannot be assigned; assign the state it is computed from`, at);
494
+ return;
495
+ }
475
496
  if (cp.resource) {
476
497
  report('SN205', `\`${src.slice(target.start, target.end)}\` mutates a resource. Use \`patch('${cp.resource}', …, {…})\``, at, 'resources are read-only in expressions; copy with structuredClone() to edit');
477
498
  return;
@@ -570,6 +591,15 @@ export function analyze(src, scope, page, opts) {
570
591
  return;
571
592
  }
572
593
  const first = n.arguments[0];
594
+ if (name === 'showModal' || (name === 'close' && first)) {
595
+ if (!first || first.type !== 'Literal' || typeof first.value !== 'string') {
596
+ report('SN309', `\`${name}()\` takes the dialog's \`id\` as a string literal`, n.start);
597
+ }
598
+ else if (!page.dialogs.has(first.value)) {
599
+ report('SN309', `no \`<dialog id="${first.value}">\` on this page.${didYouMean(first.value, page.dialogs)}`, first.start);
600
+ }
601
+ return;
602
+ }
573
603
  if (['refresh', 'upsert', 'patch', 'remove', 'page'].includes(name)) {
574
604
  if (!first || first.type !== 'Literal' || typeof first.value !== 'string') {
575
605
  report('SN301', `\`${name}()\` takes the resource name as a string literal`, n.start);
@@ -842,7 +872,7 @@ export function analyze(src, scope, page, opts) {
842
872
  }
843
873
  }
844
874
  }
845
- return { ok, code, deps: [...deps].sort(), writes: [...writes].sort(), usesEl, usesIndex, entries, path, resource, ast, ...(opts.payload ? { payloadNames: [...payloadNames].sort(), payloadStates: [...payloadStates].sort() } : {}) };
875
+ return { ok, code, inner: out, deps: [...deps].sort(), writes: [...writes].sort(), usesEl, usesIndex, entries, path, resource, ast, ...(opts.payload ? { payloadNames: [...payloadNames].sort(), payloadStates: [...payloadStates].sort() } : {}) };
846
876
  }
847
877
  export function applyEdits(src, edits) {
848
878
  const sorted = [...edits].sort((a, b) => b.start - a.start || (b.end - b.start) - (a.end - a.start));
@@ -5,6 +5,7 @@ export interface AttrDoc {
5
5
  }
6
6
  export declare const ATTRIBUTES: AttrDoc[];
7
7
  export declare const ON_DOC: AttrDoc;
8
+ export declare const DEBOUNCE_ON_DOC: AttrDoc;
8
9
  export declare const ELEMENTS: Array<{
9
10
  name: string;
10
11
  attrs: string[];
@@ -2,6 +2,7 @@
2
2
  // The compiler, llms.txt and html-custom-data.json are all built from it.
3
3
  export const ATTRIBUTES = [
4
4
  { name: 'state', value: 'object literal', doc: 'declares local reactive state for the element and its subtree' },
5
+ { name: 'let', value: 'object literal {name: expression}', doc: 'derived names for the element and its subtree, computed where they are read; later names may use earlier ones' },
5
6
  { name: 'text', value: 'value expression', doc: 'sets textContent; never HTML' },
6
7
  { name: 'attr', value: 'object literal {href: …}', doc: 'literal keys; null/false removes, true sets empty; URL values are checked' },
7
8
  { name: 'class', value: 'object literal {unread: …}', doc: 'toggles each class' },
@@ -13,6 +14,7 @@ export const ATTRIBUTES = [
13
14
  { name: 'key', value: 'value expression', doc: 'row identity; defaults to the resource key or object identity' },
14
15
  { name: 'if', value: 'value expression', doc: 'renders a <template> while truthy' },
15
16
  { name: 'else', value: '', doc: 'on a <template> directly after an if template' },
17
+ { name: 'listbox', value: 'id of the suggestion list, or empty', doc: 'on a text input: arrow keys mark a [role=option] of that list (empty: the nearest [role=listbox] around the input), Enter or Tab clicks it, Escape fires `dismiss`' },
16
18
  { name: 'persist', value: 'storage key', doc: 'persists the model or state in localStorage under sn:' },
17
19
  { name: 'get', value: 'URL template', doc: 'GET action; latest wins' },
18
20
  { name: 'post', value: 'URL template', doc: 'POST action' },
@@ -26,11 +28,14 @@ export const ATTRIBUTES = [
26
28
  { name: 'concurrency', value: 'block | queue | latest', doc: 'mutations only; default block for forms, queue otherwise' },
27
29
  { name: 'optimistic', value: 'handler expression', doc: 'patch/upsert/remove calls applied before the request, rolled back on failure' },
28
30
  { name: 'then', value: 'handler expression', doc: 'runs after a 2xx response; $result = parsed body' },
29
- { name: 'catch', value: 'handler expression', doc: 'runs after an error; $error = {status, body, message}' },
31
+ { name: 'catch', value: 'handler expression', doc: 'runs after an error; $error = {status, body, message, detail}' },
32
+ { name: 'confirm', value: 'value expression', doc: 'asks this question before the action runs (configure({ confirm }), window.confirm by default); no question when falsy' },
33
+ { name: 'encoding', value: 'json | form', doc: 'form: the body (data-sn-body, arrays as repeated keys, or the form) goes as application/x-www-form-urlencoded' },
30
34
  { name: 'path', value: 'URL patterns /a/:b …', doc: 'on <html>: the path shapes this page is served for; `:name` segments appear read-only in $url' },
31
35
  { name: 'data', value: 'resource name', doc: 'on <script type="application/json"> in <head>: initial data for a resource' },
32
36
  ];
33
37
  export const ON_DOC = { name: 'on-<event>', value: 'handler expression', doc: 'plain event handler, no request; e.g. data-sn-on-click' };
38
+ export const DEBOUNCE_ON_DOC = { name: 'debounce-<event>', value: 'milliseconds', doc: 'delays only that event\'s data-sn-on-<event> handler (it runs with the last event); e.g. data-sn-debounce-input' };
34
39
  export const ELEMENTS = [
35
40
  { name: 'sn-resource', attrs: ['name', 'src', 'call', 'each', 'key', 'items', 'total', 'order', 'when', 'paginate', 'page-size', 'size-param', 'cursor', 'direction-param', 'direction-values', 'page-param', 'offset-param', 'type', 'cache'], doc: 'a named, page-wide cache of server data; `cache` (or `cache="10m"`) shows the data from the last page load at once, then revalidates' },
36
41
  { name: 'sn-sync', attrs: ['src'], doc: 'one server event stream' },
package/dist/ir.d.ts CHANGED
@@ -134,6 +134,10 @@ export interface ActionDef {
134
134
  w: string[];
135
135
  /** element is a form (serialize, disable in block mode) */
136
136
  form: boolean;
137
+ /** `data-sn-confirm`: the question asked before the action runs (none when it is falsy) */
138
+ confirm?: number;
139
+ /** `data-sn-encoding="form"`: the body goes as application/x-www-form-urlencoded */
140
+ enc?: 'form';
137
141
  }
138
142
  export interface IR {
139
143
  v: 1;
@@ -0,0 +1,3 @@
1
+ import type { Feature } from './runtime/app.js';
2
+ declare const listbox: Feature;
3
+ export default listbox;
@@ -0,0 +1,66 @@
1
+ /** the input's list: `aria-controls`, else the nearest listbox around it */
2
+ const listOf = (input) => {
3
+ const id = input.getAttribute('aria-controls');
4
+ if (id)
5
+ return input.ownerDocument.getElementById(id);
6
+ for (let p = input.parentElement; p; p = p.parentElement) {
7
+ const list = p.querySelector('[role="listbox"]');
8
+ if (list)
9
+ return list;
10
+ }
11
+ return null;
12
+ };
13
+ const options = (input) => {
14
+ const list = listOf(input);
15
+ if (!list || list.closest('[hidden]'))
16
+ return [];
17
+ return [...list.querySelectorAll('[role="option"]')].filter((o) => !o.closest('[hidden]'));
18
+ };
19
+ const mark = (all, next) => {
20
+ for (const o of all)
21
+ o.setAttribute('aria-selected', String(o == next));
22
+ next?.scrollIntoView?.({ block: 'nearest' });
23
+ };
24
+ const listbox = {
25
+ name: 'listbox',
26
+ install(app) {
27
+ const root = app.root;
28
+ root.addEventListener('keydown', (e) => {
29
+ const input = e.target.closest?.('[role="combobox"]');
30
+ if (!input || e.isComposing)
31
+ return;
32
+ const all = options(input);
33
+ if (!all.length)
34
+ return;
35
+ const at = all.findIndex((o) => o.getAttribute('aria-selected') == 'true');
36
+ const key = e.key;
37
+ if (key == 'ArrowDown' || key == 'ArrowUp') {
38
+ e.preventDefault();
39
+ mark(all, key == 'ArrowDown' ? all[Math.min(at + 1, all.length - 1)] : at > 0 ? all[at - 1] : null);
40
+ }
41
+ else if ((key == 'Enter' && at >= 0) || (key == 'Tab' && (at >= 0 || all.length == 1))) {
42
+ e.preventDefault();
43
+ mark(all, null);
44
+ all[Math.max(at, 0)].click();
45
+ }
46
+ else if (key == 'Escape') {
47
+ e.preventDefault();
48
+ mark(all, null);
49
+ input.dispatchEvent(new CustomEvent('dismiss', { bubbles: true }));
50
+ }
51
+ });
52
+ root.addEventListener('input', (e) => {
53
+ const input = e.target.closest?.('[role="combobox"]');
54
+ if (input)
55
+ mark(options(input), null);
56
+ });
57
+ root.addEventListener('mousedown', (e) => {
58
+ // only in lists that belong to a combobox: keep the focus in the input
59
+ const option = e.target.closest?.('[role="option"]');
60
+ const list = option?.closest('[role="listbox"]');
61
+ if (list && [...root.querySelectorAll('[role="combobox"]')].some((c) => listOf(c) == list))
62
+ e.preventDefault();
63
+ });
64
+ },
65
+ };
66
+ export default listbox;
@@ -81,6 +81,8 @@ export interface App {
81
81
  readonly ir: IR;
82
82
  readonly fns: Fn[];
83
83
  readonly env: Env;
84
+ /** the element or document this instance is mounted on */
85
+ readonly root: Document | Element;
84
86
  readonly req: RequestLayer;
85
87
  /** the base scope: resources, status, magic names */
86
88
  readonly page: any;
@@ -149,4 +151,9 @@ export declare function withParams(url: string, params: Record<string, string> |
149
151
  /** Indexes (into `seq`) of a longest increasing subsequence, ignoring -1 entries. */
150
152
  export declare function lis(seq: number[]): Set<number>;
151
153
  /** Serializes a form's named controls to nested JSON, or FormData if it has a file input. */
154
+ /**
155
+ * A body as form fields (`data-sn-encoding="form"`): a form's own fields, or an object's keys with
156
+ * arrays as repeated keys; null and undefined values are left out. Files stay multipart.
157
+ */
158
+ export declare function formBody(body: unknown): URLSearchParams | FormData | null;
152
159
  export declare function serialize(form: HTMLFormElement): unknown;
@@ -55,6 +55,8 @@ export function mount(root, o) {
55
55
  const types = new Set(), selects = new Set(), deferred = new Set();
56
56
  const listeners = new Map(), cleanups = [];
57
57
  const shared = new Map();
58
+ /** `showModal(id)`, `close(id)`: the dialog of this mount root with that id */
59
+ const dialog = (id) => [...root.querySelectorAll('dialog')].find((d) => d.id === id);
58
60
  const rootRbs = [], rootScopes = [], found = new Map();
59
61
  let dirty = [], cur = [], scheduled = false, flushing = false, round = 0;
60
62
  const res = (name) => {
@@ -90,7 +92,12 @@ export function mount(root, o) {
90
92
  }
91
93
  }
92
94
  /** a failed call: SinterErrors as they are, anything else with status null */
93
- const callError = (e) => (e instanceof SinterError ? e : new SinterError(null, e, e?.message || String(e)));
95
+ const callError = (e) => {
96
+ if (e instanceof SinterError)
97
+ return e;
98
+ const detail = (e instanceof Error ? e.message : typeof e == 'string' ? e : '') || null;
99
+ return new SinterError(null, e, detail ?? String(e), detail);
100
+ };
94
101
  /** Per flush round: how dirty paths reach a template's rows (0 not, 1 only marked elements, 2 any row). */
95
102
  let rowHits = new Map();
96
103
  /**
@@ -142,7 +149,7 @@ export function mount(root, o) {
142
149
  return false;
143
150
  };
144
151
  const app = {
145
- ir, fns, env, req, transport: o.transport ?? (() => undefined), ext: {},
152
+ ir, fns, env, req, root, transport: o.transport ?? (() => undefined), ext: {},
146
153
  beforeFlush: [], afterFlush: [], onListen: [], states: [], busy: 0,
147
154
  get isDirty() { return dirty.length > 0 || scheduled; },
148
155
  dev: false,
@@ -267,12 +274,14 @@ export function mount(root, o) {
267
274
  } };
268
275
  },
269
276
  storeFns: (el) => ({
277
+ showModal: (id) => { const d = dialog(id); if (d && !d.open)
278
+ d.showModal(); },
270
279
  refresh: (n) => res(n).refetch(),
271
280
  upsert: forward('upsert'),
272
281
  patch: forward('patch'),
273
282
  remove: forward('remove'),
274
283
  page: (n, t) => app.ext.paginate.page(n, t),
275
- close: () => el?.closest('dialog')?.close(),
284
+ close: (id) => (id ? dialog(id) : el?.closest('dialog'))?.close(),
276
285
  reset: () => {
277
286
  const form = el?.closest('form');
278
287
  if (!form)
@@ -926,27 +935,42 @@ export function mount(root, o) {
926
935
  body = fns[a.call.args](s);
927
936
  else {
928
937
  url = app.url(a.url, s);
929
- body = a.body != null ? fns[a.body](s) : a.form && !get && a.method != 'DELETE' ? serialize(rb.el) : undefined;
938
+ const fields = a.form && !get && a.method != 'DELETE';
939
+ body = a.body != null ? fns[a.body](s) : fields ? (a.enc == 'form' ? formFields(rb.el) : serialize(rb.el)) : undefined;
940
+ if (a.enc == 'form')
941
+ body = formBody(body);
930
942
  }
931
943
  }
932
944
  catch (err) {
933
945
  return unhandled(toError(err), rb.el);
934
946
  }
935
- const drop = a.optimistic != null ? app.ext.optimistic.begin(a.optimistic, s) : () => { };
936
- if (get) {
937
- st.c?.abort();
938
- return void send(rb, a, st, url, body, drop, (st.c = new AbortController()));
939
- }
940
- const run = () => void send(rb, a, st, url, body, drop);
941
- if (!st.n)
942
- run();
943
- else if (a.concurrency == 'latest') {
944
- st.w ? st.w.drop() : app.busy++;
945
- st.w = { run, drop };
947
+ // a question first: the action runs only when the user agrees
948
+ const question = a.confirm != null ? fns[a.confirm](s) : null;
949
+ if (question) {
950
+ const yes = config.confirm(String(question));
951
+ if (yes instanceof Promise)
952
+ return void yes.then((y) => y && go());
953
+ if (!yes)
954
+ return;
946
955
  }
947
- else {
948
- app.busy++;
949
- st.q.push(run);
956
+ go();
957
+ function go() {
958
+ const drop = a.optimistic != null ? app.ext.optimistic.begin(a.optimistic, s) : () => { };
959
+ if (get) {
960
+ st.c?.abort();
961
+ return void send(rb, a, st, url, body, drop, (st.c = new AbortController()));
962
+ }
963
+ const run = () => void send(rb, a, st, url, body, drop);
964
+ if (!st.n)
965
+ run();
966
+ else if (a.concurrency == 'latest') {
967
+ st.w ? st.w.drop() : app.busy++;
968
+ st.w = { run, drop };
969
+ }
970
+ else {
971
+ app.busy++;
972
+ st.q.push(run);
973
+ }
950
974
  }
951
975
  }
952
976
  async function send(rb, a, st, url, body, drop, c) {
@@ -1229,6 +1253,47 @@ export function lis(seq) {
1229
1253
  return keep;
1230
1254
  }
1231
1255
  /** Serializes a form's named controls to nested JSON, or FormData if it has a file input. */
1256
+ /**
1257
+ * A body as form fields (`data-sn-encoding="form"`): a form's own fields, or an object's keys with
1258
+ * arrays as repeated keys; null and undefined values are left out. Files stay multipart.
1259
+ */
1260
+ export function formBody(body) {
1261
+ if (body == null)
1262
+ return null;
1263
+ if (body instanceof FormData) {
1264
+ const out = new URLSearchParams();
1265
+ for (const [k, v] of body) {
1266
+ if (typeof v != 'string')
1267
+ return body;
1268
+ out.append(k, v);
1269
+ }
1270
+ return out;
1271
+ }
1272
+ if (body instanceof URLSearchParams)
1273
+ return body;
1274
+ const out = new URLSearchParams();
1275
+ for (const [k, v] of Object.entries(body)) {
1276
+ for (const x of Array.isArray(v) ? v : [v])
1277
+ if (x != null)
1278
+ out.append(k, typeof x == 'object' ? JSON.stringify(x) : String(x));
1279
+ }
1280
+ return out;
1281
+ }
1282
+ /** A form's fields as browsers submit them; with a file input, multipart. */
1283
+ function formFields(form) {
1284
+ const out = new URLSearchParams();
1285
+ for (const c of form.elements) {
1286
+ if (!c.name || c.disabled || /^(submit|button|reset|image|fieldset|output)$/.test(c.type))
1287
+ continue;
1288
+ if (c.type == 'file')
1289
+ return new FormData(form);
1290
+ if ((c.type == 'checkbox' || c.type == 'radio') && !c.checked)
1291
+ continue;
1292
+ for (const v of c.type == 'select-multiple' ? [...c.selectedOptions].map((o) => o.value) : [c.value])
1293
+ out.append(c.name, v);
1294
+ }
1295
+ return out;
1296
+ }
1232
1297
  export function serialize(form) {
1233
1298
  const controls = [...form.elements];
1234
1299
  if (controls.some((c) => c.type == 'file'))
@@ -13,6 +13,8 @@ export interface Config {
13
13
  origins: string[];
14
14
  /** the transport for a `<sn-sync>` stream URL; `undefined` (or no function) means EventSource */
15
15
  transport: ((url: string) => Transport | undefined) | null;
16
+ /** `data-sn-confirm`: asks the question; the action runs on true (window.confirm by default) */
17
+ confirm: (question: string) => boolean | Promise<boolean>;
16
18
  env: Env;
17
19
  }
18
20
  export declare const config: Config;
@@ -23,7 +25,12 @@ export declare function configure(options: Partial<Omit<Config, 'env'>> & {
23
25
  export declare class SinterError extends Error {
24
26
  readonly status: number | null;
25
27
  readonly body: unknown;
26
- constructor(status: number | null, body: unknown, message: string);
28
+ readonly detail: string | null;
29
+ /**
30
+ * @param detail what went wrong in words: the server's message or text, or a thrown error's
31
+ * message; null when there is none (`message` then falls back to the HTTP status)
32
+ */
33
+ constructor(status: number | null, body: unknown, message: string, detail?: string | null);
27
34
  }
28
35
  /** page-load cache entries (`cache` on `<sn-resource>`), in sessionStorage */
29
36
  export declare const CACHE_PREFIX = "sn:c:";
@@ -2,7 +2,8 @@
2
2
  // comes only from configure() in JavaScript, never from the DOM.
3
3
  import { defaultEnv } from './env.js';
4
4
  export const config = {
5
- request: [], sessionProbe: null, onError: null, locale: null, urlSchemes: [], origins: [], transport: null, env: defaultEnv,
5
+ request: [], sessionProbe: null, onError: null, locale: null, urlSchemes: [], origins: [], transport: null,
6
+ confirm: (question) => globalThis.confirm(question), env: defaultEnv,
6
7
  };
7
8
  export function configure(options) {
8
9
  const { env, ...rest } = options;
@@ -14,10 +15,16 @@ export function configure(options) {
14
15
  export class SinterError extends Error {
15
16
  status;
16
17
  body;
17
- constructor(status, body, message) {
18
+ detail;
19
+ /**
20
+ * @param detail what went wrong in words: the server's message or text, or a thrown error's
21
+ * message; null when there is none (`message` then falls back to the HTTP status)
22
+ */
23
+ constructor(status, body, message, detail = null) {
18
24
  super(message);
19
25
  this.status = status;
20
26
  this.body = body;
27
+ this.detail = detail;
21
28
  this.name = 'SinterError';
22
29
  }
23
30
  }
@@ -3,7 +3,7 @@ import type { Env } from './env.js';
3
3
  export declare const TIMEOUT = 30000;
4
4
  export interface SendInit {
5
5
  method?: string;
6
- /** JSON-serialized unless FormData */
6
+ /** JSON-serialized unless FormData or URLSearchParams (sent form-encoded) */
7
7
  body?: unknown;
8
8
  headers?: Record<string, string>;
9
9
  signal?: AbortSignal | undefined;
@@ -44,6 +44,10 @@ export function createRequestLayer(env, chain) {
44
44
  let body = init.body;
45
45
  if (body == null || method == 'GET')
46
46
  body = undefined;
47
+ else if (body instanceof URLSearchParams) {
48
+ body = body.toString();
49
+ headers.set('Content-Type', 'application/x-www-form-urlencoded;charset=UTF-8');
50
+ }
47
51
  else if (!(body instanceof FormData)) {
48
52
  body = JSON.stringify(body);
49
53
  headers.set('Content-Type', 'application/json');
@@ -71,7 +75,8 @@ export function createRequestLayer(env, chain) {
71
75
  const res = await raw(url, init), body = await parseBody(res);
72
76
  if (!res.ok) {
73
77
  const b = body, m = typeof body == 'string' ? body.length < 200 && body.trim() : b?.message ?? b?.error;
74
- throw new SinterError(res.status, body, typeof m == 'string' && m ? m : `${res.status} ${res.statusText}`.trim());
78
+ const detail = typeof m == 'string' && m ? m : null;
79
+ throw new SinterError(res.status, body, detail ?? `${res.status} ${res.statusText}`.trim(), detail);
75
80
  }
76
81
  return body;
77
82
  }
@@ -13,8 +13,9 @@ import optimistic from '../optimistic.js';
13
13
  import paginate from '../paginate.js';
14
14
  import each from '../each.js';
15
15
  import persist from '../persist.js';
16
+ import listbox from '../listbox.js';
16
17
  import sse from '../sse.js';
17
- const FEATURES = { dev, sse, optimistic, paginate, persist, cache, each };
18
+ const FEATURES = { dev, sse, optimistic, paginate, persist, cache, each, listbox };
18
19
  function isResponseSpec(v) {
19
20
  if (!v || typeof v !== 'object' || Array.isArray(v))
20
21
  return false;
@@ -122,6 +122,16 @@ TypeScript found a type error in an expression. Reported by `sinter check`, `sin
122
122
 
123
123
  **Fix:** Fix the expression, or the type: `type` on `<sn-resource>`, the OpenAPI spec, or a JSDoc cast in `data-sn-state`.
124
124
 
125
+ ## SN209 name-conflict
126
+
127
+ Error, for example:
128
+
129
+ > `s` is already defined here by `data-sn-let`; use another name
130
+
131
+ 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.
132
+
133
+ **Fix:** Rename the let, or the state or loop variable.
134
+
125
135
  ## SN301 unknown-resource
126
136
 
127
137
  Error, for example:
@@ -202,6 +212,16 @@ The resource matched an operation in the OpenAPI spec, but its `items` or `total
202
212
 
203
213
  **Fix:** Fix the path in `items` or `total`, or the spec if the API changed.
204
214
 
215
+ ## SN309 unknown-dialog
216
+
217
+ Error, for example:
218
+
219
+ > no `<dialog id="compose-dialg">` on this page. Did you mean `compose-dialog`?
220
+
221
+ `showModal(id)` and `close(id)` name a `<dialog>` by its literal `id`, which must be on the page or one of its partials.
222
+
223
+ **Fix:** Fix the id, or add `id` to the `<dialog>`. `close()` without an argument closes the dialog around the element.
224
+
205
225
  ## SN310 unknown-action
206
226
 
207
227
  Error, for example:
@@ -118,6 +118,10 @@
118
118
  "name": "data-sn-state",
119
119
  "description": "declares local reactive state for the element and its subtree (object literal)"
120
120
  },
121
+ {
122
+ "name": "data-sn-let",
123
+ "description": "derived names for the element and its subtree, computed where they are read; later names may use earlier ones (object literal {name: expression})"
124
+ },
121
125
  {
122
126
  "name": "data-sn-text",
123
127
  "description": "sets textContent; never HTML (value expression)"
@@ -162,6 +166,10 @@
162
166
  "name": "data-sn-else",
163
167
  "description": "on a <template> directly after an if template"
164
168
  },
169
+ {
170
+ "name": "data-sn-listbox",
171
+ "description": "on a text input: arrow keys mark a [role=option] of that list (empty: the nearest [role=listbox] around the input), Enter or Tab clicks it, Escape fires `dismiss` (id of the suggestion list, or empty)"
172
+ },
165
173
  {
166
174
  "name": "data-sn-persist",
167
175
  "description": "persists the model or state in localStorage under sn: (storage key)"
@@ -216,7 +224,15 @@
216
224
  },
217
225
  {
218
226
  "name": "data-sn-catch",
219
- "description": "runs after an error; $error = {status, body, message} (handler expression)"
227
+ "description": "runs after an error; $error = {status, body, message, detail} (handler expression)"
228
+ },
229
+ {
230
+ "name": "data-sn-confirm",
231
+ "description": "asks this question before the action runs (configure({ confirm }), window.confirm by default); no question when falsy (value expression)"
232
+ },
233
+ {
234
+ "name": "data-sn-encoding",
235
+ "description": "form: the body (data-sn-body, arrays as repeated keys, or the form) goes as application/x-www-form-urlencoded (json | form)"
220
236
  },
221
237
  {
222
238
  "name": "data-sn-path",
@@ -245,6 +261,18 @@
245
261
  {
246
262
  "name": "data-sn-on-keydown",
247
263
  "description": "plain event handler, no request; e.g. data-sn-on-click"
264
+ },
265
+ {
266
+ "name": "data-sn-debounce-input",
267
+ "description": "delays only that event's data-sn-on-<event> handler (it runs with the last event); e.g. data-sn-debounce-input"
268
+ },
269
+ {
270
+ "name": "data-sn-debounce-keyup",
271
+ "description": "delays only that event's data-sn-on-<event> handler (it runs with the last event); e.g. data-sn-debounce-input"
272
+ },
273
+ {
274
+ "name": "data-sn-debounce-scroll",
275
+ "description": "delays only that event's data-sn-on-<event> handler (it runs with the last event); e.g. data-sn-debounce-input"
248
276
  }
249
277
  ]
250
278
  }
package/llms.txt CHANGED
@@ -7,6 +7,7 @@ Package: @tiaforge-ui/sinter. Every directive is a `data-sn-*` attribute or an `
7
7
  ## Attributes
8
8
 
9
9
  - `data-sn-state` (object literal): declares local reactive state for the element and its subtree
10
+ - `data-sn-let` (object literal {name: expression}): derived names for the element and its subtree, computed where they are read; later names may use earlier ones
10
11
  - `data-sn-text` (value expression): sets textContent; never HTML
11
12
  - `data-sn-attr` (object literal {href: …}): literal keys; null/false removes, true sets empty; URL values are checked
12
13
  - `data-sn-class` (object literal {unread: …}): toggles each class
@@ -18,6 +19,7 @@ Package: @tiaforge-ui/sinter. Every directive is a `data-sn-*` attribute or an `
18
19
  - `data-sn-key` (value expression): row identity; defaults to the resource key or object identity
19
20
  - `data-sn-if` (value expression): renders a <template> while truthy
20
21
  - `data-sn-else`: on a <template> directly after an if template
22
+ - `data-sn-listbox` (id of the suggestion list, or empty): on a text input: arrow keys mark a [role=option] of that list (empty: the nearest [role=listbox] around the input), Enter or Tab clicks it, Escape fires `dismiss`
21
23
  - `data-sn-persist` (storage key): persists the model or state in localStorage under sn:
22
24
  - `data-sn-get` (URL template): GET action; latest wins
23
25
  - `data-sn-post` (URL template): POST action
@@ -31,10 +33,13 @@ Package: @tiaforge-ui/sinter. Every directive is a `data-sn-*` attribute or an `
31
33
  - `data-sn-concurrency` (block | queue | latest): mutations only; default block for forms, queue otherwise
32
34
  - `data-sn-optimistic` (handler expression): patch/upsert/remove calls applied before the request, rolled back on failure
33
35
  - `data-sn-then` (handler expression): runs after a 2xx response; $result = parsed body
34
- - `data-sn-catch` (handler expression): runs after an error; $error = {status, body, message}
36
+ - `data-sn-catch` (handler expression): runs after an error; $error = {status, body, message, detail}
37
+ - `data-sn-confirm` (value expression): asks this question before the action runs (configure({ confirm }), window.confirm by default); no question when falsy
38
+ - `data-sn-encoding` (json | form): form: the body (data-sn-body, arrays as repeated keys, or the form) goes as application/x-www-form-urlencoded
35
39
  - `data-sn-path` (URL patterns /a/:b …): on <html>: the path shapes this page is served for; `:name` segments appear read-only in $url
36
40
  - `data-sn-data` (resource name): on <script type="application/json"> in <head>: initial data for a resource
37
41
  - `data-sn-on-<event>` (handler expression): plain event handler, no request; e.g. data-sn-on-click
42
+ - `data-sn-debounce-<event>` (milliseconds): delays only that event's data-sn-on-<event> handler (it runs with the last event); e.g. data-sn-debounce-input
38
43
 
39
44
  ## Elements
40
45
 
@@ -60,11 +65,11 @@ Package: @tiaforge-ui/sinter. Every directive is a `data-sn-*` attribute or an `
60
65
 
61
66
  ## Store functions
62
67
 
63
- `refresh()`, `upsert()`, `patch()`, `remove()`, `page()`, `close()`, `reset()`, `emit()`: `refresh('name')`, `upsert('name', item)`, `patch('name', item, {…})`, `remove('name', item)`, `page('name', 'next' | 'prev' | 'first' | 'last' | n)`, `close()` the nearest dialog, `reset()` the nearest form, `emit('event', detail)`.
68
+ `refresh()`, `upsert()`, `patch()`, `remove()`, `page()`, `showModal()`, `close()`, `reset()`, `emit()`: `refresh('name')`, `upsert('name', item)`, `patch('name', item, {…})`, `remove('name', item)`, `page('name', 'next' | 'prev' | 'first' | 'last' | n)`, `showModal('id')` opens a dialog by its literal id, `close()` the nearest dialog or `close('id')` that one, `reset()` the nearest form, `emit('event', detail)`.
64
69
 
65
70
  ## Diagnostics
66
71
 
67
- `SN101 unknown-attribute`, `SN102 wrong-element`, `SN103 missing-key`, `SN104 unsupported-model`, `SN201 unknown-identifier`, `SN202 forbidden-global`, `SN203 side-effect`, `SN204 optimistic-scope`, `SN205 resource-mutation`, `SN206 unknown-namespace-member`, `SN207 unsafe-sink`, `SN208 type-error`, `SN301 unknown-resource`, `SN302 duplicate-resource`, `SN303 unknown-meta-field`, `SN304 keyset-jump`, `SN305 include-not-found`, `SN306 include-cycle`, `SN307 openapi-invalid`, `SN308 unknown-schema-field`, `SN310 unknown-action`, `SN401 parse-error`, `SN501 unused-resource`, `SN502 non-reactive-value`, `SN503 data-bound-id`, `SN504 untyped-resource`, `SN505 unknown-query-param`, `SN506 untyped-event`.
72
+ `SN101 unknown-attribute`, `SN102 wrong-element`, `SN103 missing-key`, `SN104 unsupported-model`, `SN201 unknown-identifier`, `SN202 forbidden-global`, `SN203 side-effect`, `SN204 optimistic-scope`, `SN205 resource-mutation`, `SN206 unknown-namespace-member`, `SN207 unsafe-sink`, `SN208 type-error`, `SN209 name-conflict`, `SN301 unknown-resource`, `SN302 duplicate-resource`, `SN303 unknown-meta-field`, `SN304 keyset-jump`, `SN305 include-not-found`, `SN306 include-cycle`, `SN307 openapi-invalid`, `SN308 unknown-schema-field`, `SN309 unknown-dialog`, `SN310 unknown-action`, `SN401 parse-error`, `SN501 unused-resource`, `SN502 non-reactive-value`, `SN503 data-bound-id`, `SN504 untyped-resource`, `SN505 unknown-query-param`, `SN506 untyped-event`.
68
73
 
69
74
  ## Recipes
70
75
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiaforge-ui/sinter",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Bind JSON APIs to plain HTML: keyed resources, SSE sync, template bindings and optimistic mutations, compiled at build time.",
5
5
  "license": "MIT",
6
6
  "author": "Tia Heinrich",
@@ -38,6 +38,7 @@
38
38
  "./paginate": "./dist/paginate.js",
39
39
  "./each": "./dist/each.js",
40
40
  "./persist": "./dist/persist.js",
41
+ "./listbox": "./dist/listbox.js",
41
42
  "./cache": "./dist/cache.js",
42
43
  "./compiler": "./dist/compiler/index.js",
43
44
  "./vite": "./dist/vite.js",
@@ -61,6 +62,7 @@
61
62
  "bench": "vitest run --config bench/runtime/vitest.config.ts",
62
63
  "size": "npm run build && node scripts/size.mjs",
63
64
  "docs": "UPDATE_DOCS=1 vitest run test/docs.test.ts",
65
+ "release": "node scripts/release.mjs",
64
66
  "prepublishOnly": "rm -rf dist && npm run build && npm test"
65
67
  },
66
68
  "dependencies": {