@tiaforge-ui/sinter 0.2.0 → 0.3.1

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
package/dist/cache.js CHANGED
@@ -68,22 +68,29 @@ const cache = {
68
68
  loaded(def, r, url, items, total, appended) {
69
69
  let t = tracked.get(def.name);
70
70
  if (!t) {
71
- const entry = (t = { def, r, url, dirty: false, appended });
71
+ const entry = (t = { def, r, url, dirty: false, appended, off: false });
72
72
  // confirmed changes after the load (server events, action results); layers never fire this
73
73
  r.subscribe(() => { entry.dirty = true; });
74
74
  tracked.set(def.name, t);
75
75
  }
76
76
  t.url = url;
77
77
  t.appended = appended;
78
+ t.off = false;
78
79
  // `append` caches only its first page: later pages would be shown as if they were it
79
80
  if (!appended)
80
81
  write(def, url, items, total);
81
82
  t.dirty = false;
82
83
  },
84
+ // the emptied resource must not replace the entry of its last URL
85
+ cleared(def) {
86
+ const t = tracked.get(def.name);
87
+ if (t)
88
+ t.off = true;
89
+ },
83
90
  };
84
91
  app.addCleanup(app.env.listen('pagehide', () => {
85
92
  for (const t of tracked.values()) {
86
- if (!t.dirty || t.appended || t.def.paginate?.mode == 'append')
93
+ if (!t.dirty || t.off || t.appended || t.def.paginate?.mode == 'append')
87
94
  continue;
88
95
  const b = t.r.base();
89
96
  write(t.def, t.url, b.items, b.total);
@@ -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.
@@ -103,7 +103,7 @@ export function shadow(o) {
103
103
  line('/** @type {string} */');
104
104
  line('let $locale = $any;');
105
105
  line(`/** @type {${decl('Store')}<{ ${types.join('; ')} }>} */`);
106
- line(`const { ${STORE_FNS.join(', ')} } = $any;`);
106
+ line(`const { ${STORE_FNS.filter((f) => !unit.resources.some((r) => r.name === f)).join(', ')} } = $any;`);
107
107
  line(`/** @template T @param {T} list @returns {${decl('Item')}<T>} */`);
108
108
  line('function $item(list) { return $any; }');
109
109
  /** a JSDoc tag with a type; a type the app wrote maps its errors to where it was written */
@@ -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;
@@ -3,12 +3,12 @@
3
3
  import * as parse5 from 'parse5';
4
4
  import { parseExpressionAt } from 'acorn';
5
5
  import { didYouMean, severityOf } from './diagnostics.js';
6
- import { analyze } from './expr.js';
6
+ import { analyze, STORE_FNS } from './expr.js';
7
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))
@@ -340,6 +340,10 @@ class Compiler {
340
340
  this.report(child, 'name', 'SN302', `resource \`${name}\` is declared twice`, 0);
341
341
  continue;
342
342
  }
343
+ if (STORE_FNS.includes(name)) {
344
+ this.report(child, 'name', 'SN209', `\`${name}\` is a store function (\`${name}()\`); a resource cannot take its name`, 0);
345
+ continue;
346
+ }
343
347
  const key = attr(child, 'key');
344
348
  const paginate = attr(child, 'paginate');
345
349
  const info = { name, key: key ? key.split(',').map((s) => s.trim()).filter(Boolean) : null, paginate: paginate ?? null, used: false };
@@ -349,6 +353,11 @@ class Compiler {
349
353
  this.resourceEls.set(name, child);
350
354
  continue;
351
355
  }
356
+ if (child.tagName === 'dialog') {
357
+ const id = attr(child, 'id');
358
+ if (id)
359
+ this.page.dialogs.add(id);
360
+ }
352
361
  this.declare(child, inTemplate);
353
362
  if (child.tagName === 'template')
354
363
  this.declare(child.content, true);
@@ -412,7 +421,7 @@ class Compiler {
412
421
  // unknown attributes
413
422
  const known = new Set(ATTRS.map((a) => `data-sn-${a}`));
414
423
  for (const a of sn) {
415
- if (known.has(a.name) || a.name.startsWith('data-sn-on-'))
424
+ if (known.has(a.name) || a.name.startsWith('data-sn-on-') || a.name.startsWith('data-sn-debounce-'))
416
425
  continue;
417
426
  this.report(el, a.name, 'SN101', `\`${a.name}\` is not a sinter attribute.${didYouMean(a.name, [...known, 'data-sn-on-click'])}`, 0);
418
427
  }
@@ -466,13 +475,32 @@ class Compiler {
466
475
  const locs = first.map((e) => this.locOf(el, 'data-sn-state', e.offset));
467
476
  this.check.frames.push({ kind: 'state', names: [...entry.names], init: this.lastExpr, locs });
468
477
  }
469
- scope = { loops: scope.loops, states: [...scope.states, entry] };
478
+ this.checkNames(el, 'data-sn-state', scope, [...entry.names]);
479
+ scope = { ...scope, states: [...scope.states, entry] };
470
480
  if (has('persist') && !has('model')) {
471
481
  node.persist = get('persist');
472
482
  this.features.add('persist');
473
483
  }
474
484
  }
475
485
  }
486
+ // derived names: the element and its subtree read them like state
487
+ if (has('let'))
488
+ scope = this.lets(el, scope);
489
+ // a text field with suggestions: the listbox feature handles the keys (see src/listbox.ts)
490
+ if (has('listbox')) {
491
+ const list = get('listbox');
492
+ if (!['input', 'textarea'].includes(el.tagName))
493
+ this.report(el, 'data-sn-listbox', 'SN102', '`data-sn-listbox` belongs on a text `<input>` or `<textarea>`', 0);
494
+ else if (list && !/^[A-Za-z][\w-]*$/.test(list))
495
+ 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);
496
+ else {
497
+ setAttr(el, 'role', 'combobox');
498
+ if (list)
499
+ setAttr(el, 'aria-controls', list);
500
+ setAttr(el, 'aria-autocomplete', 'list');
501
+ this.features.add('listbox');
502
+ }
503
+ }
476
504
  const isTemplate = el.tagName === 'template';
477
505
  const valueB = (k, name) => {
478
506
  const r = this.expr(el, name, scope, { mode: 'value' });
@@ -516,6 +544,16 @@ class Compiler {
516
544
  if (!sn.some((a) => a.name.startsWith('data-sn-on-')))
517
545
  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
546
  }
547
+ // `data-sn-debounce-<event>`: only that event's handler waits
548
+ for (const a of sn) {
549
+ if (!a.name.startsWith('data-sn-debounce-'))
550
+ continue;
551
+ const event = a.name.slice('data-sn-debounce-'.length), ms = Number(a.value);
552
+ if (!Number.isFinite(ms) || ms < 0 || a.value.trim() === '')
553
+ this.report(el, a.name, 'SN401', `\`${a.name}\` takes milliseconds`, 0);
554
+ if (!has(`on-${event}`))
555
+ this.report(el, a.name, 'SN102', `\`${a.name}\` needs a \`data-sn-on-${event}\` handler on the same element`, 0);
556
+ }
519
557
  for (const a of sn) {
520
558
  if (!a.name.startsWith('data-sn-on-'))
521
559
  continue;
@@ -525,8 +563,9 @@ class Compiler {
525
563
  continue;
526
564
  }
527
565
  const r = this.expr(el, a.name, scope, { mode: 'handler', magic: ['$event'] });
566
+ const own = get(`debounce-${event}`), ms = own !== undefined ? Number(own) || 0 : handlerDebounce;
528
567
  if (r.ok)
529
- node.b.push(binding({ k: 'on', n: event, f: this.fn(r.code), w: r.writes, ...(handlerDebounce > 0 ? { ms: handlerDebounce } : {}) }));
568
+ node.b.push(binding({ k: 'on', n: event, f: this.fn(r.code), w: r.writes, ...(ms > 0 ? { ms } : {}) }));
530
569
  }
531
570
  if (methods.length > 1)
532
571
  this.report(el, `data-sn-${methods[1]}`, 'SN102', 'an element can have only one method attribute', 0);
@@ -627,6 +666,61 @@ class Compiler {
627
666
  }
628
667
  return r;
629
668
  }
669
+ // ---- derived names
670
+ /**
671
+ * `data-sn-let="{ s: suggestions(q), n: s.length }"`: each name is a value expression, inlined
672
+ * where it is read; later names may use earlier ones.
673
+ */
674
+ lets(el, scope) {
675
+ const src = attr(el, 'data-sn-let') ?? '';
676
+ let obj;
677
+ try {
678
+ obj = parseExpressionAt(src, 0, { ecmaVersion: 'latest' });
679
+ }
680
+ catch (e) {
681
+ this.report(el, 'data-sn-let', 'SN401', e.message.replace(/ \(\d+:\d+\)$/, ''), e.pos ?? 0);
682
+ return scope;
683
+ }
684
+ if (obj.type !== 'ObjectExpression') {
685
+ this.report(el, 'data-sn-let', 'SN401', '`data-sn-let` takes an object literal: `{ name: expression, … }`', 0);
686
+ return scope;
687
+ }
688
+ for (const p of obj.properties) {
689
+ if (p.type !== 'Property' || p.computed || p.shorthand || p.key.type !== 'Identifier' || p.kind !== 'init' || p.method) {
690
+ this.report(el, 'data-sn-let', 'SN401', '`data-sn-let` entries are `name: expression`', p.start);
691
+ continue;
692
+ }
693
+ const name = p.key.name;
694
+ if (name.startsWith('$')) {
695
+ this.report(el, 'data-sn-let', 'SN401', 'names starting with `$` are reserved', p.key.start);
696
+ continue;
697
+ }
698
+ if (!this.checkNames(el, 'data-sn-let', scope, [name], p.key.start))
699
+ continue;
700
+ const v = p.value;
701
+ const r = this.expr(el, 'data-sn-let', scope, { mode: 'value' }, src.slice(v.start, v.end), v.start);
702
+ const entry = { name, code: r.inner, deps: r.deps, usesEl: r.usesEl, usesIndex: r.usesIndex, resource: r.resource };
703
+ if (this.check) {
704
+ this.frameOf.set(entry, this.check.frames.length);
705
+ this.check.frames.push({ kind: 'let', name, init: r.ok ? this.lastExpr : null, loc: this.locOf(el, 'data-sn-let', p.key.start) });
706
+ }
707
+ scope = { ...scope, lets: [...(scope.lets ?? []), entry] };
708
+ }
709
+ return scope;
710
+ }
711
+ /** A name declared here must not hide a let, or be hidden by one (SN209); false on a conflict. */
712
+ checkNames(el, attrName, scope, names, offset = 0) {
713
+ let ok = true;
714
+ for (const n of names) {
715
+ const isLet = (scope.lets ?? []).some((l) => l.name === n);
716
+ const other = attrName === 'data-sn-let' && (scope.loops.some((l) => l.name === n) || scope.states.some((s) => s.names.has(n)));
717
+ if (isLet || other) {
718
+ this.report(el, attrName, 'SN209', `\`${n}\` is already defined here${isLet ? ' by `data-sn-let`' : ''}; use another name`, offset);
719
+ ok = false;
720
+ }
721
+ }
722
+ return ok;
723
+ }
630
724
  // ---- check unit
631
725
  /** Adds type information to the expression `expr()` recorded last. */
632
726
  typeLast(info) {
@@ -647,7 +741,7 @@ class Compiler {
647
741
  }
648
742
  recordExpr(el, attrName, scope, o, src, shift, r) {
649
743
  const frames = [];
650
- for (const f of [...scope.states, ...scope.loops]) {
744
+ for (const f of [...scope.states, ...scope.loops, ...(scope.lets ?? [])]) {
651
745
  const i = this.frameOf.get(f);
652
746
  if (i !== undefined && !frames.includes(i))
653
747
  frames.push(i);
@@ -670,6 +764,8 @@ class Compiler {
670
764
  // ---- bindings
671
765
  attrBinding(el, node, scope) {
672
766
  const r = this.expr(el, 'data-sn-attr', scope, { mode: 'value', object: 'literal-keys' });
767
+ if (this.check)
768
+ this.typeLast({ returns: { kind: 'decl', name: 'Attrs' } });
673
769
  if (!r.entries)
674
770
  return;
675
771
  for (const e of r.entries) {
@@ -801,10 +897,18 @@ class Compiler {
801
897
  const then = fnOf('then', { mode: 'handler', magic: ['$event', '$result'] }, typed?.body != null ? { resultType: { kind: 'openapi', spec: typed.spec, schema: typed.body } }
802
898
  : call ? { resultType: { kind: 'call', name: call.name } } : undefined);
803
899
  const catch_ = fnOf('catch', { mode: 'handler', magic: ['$event', '$error'] });
900
+ const confirm = fnOf('confirm', { mode: 'value', magic: ['$event'] });
901
+ const encoding = get('encoding');
902
+ if (encoding !== undefined && encoding !== 'form' && encoding !== 'json')
903
+ this.report(el, 'data-sn-encoding', 'SN401', '`data-sn-encoding` is `json` (the default) or `form`', 0);
904
+ if (encoding !== undefined && method === 'call')
905
+ this.report(el, 'data-sn-encoding', 'SN102', '`data-sn-encoding` belongs to a method attribute; a call sends its own requests', 0);
804
906
  const def = {
805
907
  method: method.toUpperCase(), url, body: body.f, trigger, debounce, concurrency,
806
908
  optimistic: optimistic.f, then: then.f, catch: catch_.f, w: [...new Set([...then.w, ...catch_.w])].sort(), form: isForm,
807
909
  ...(call ? { call: { args: call.args, run: call.run } } : {}),
910
+ ...(confirm.f !== null ? { confirm: confirm.f } : {}),
911
+ ...(encoding === 'form' ? { enc: 'form' } : {}),
808
912
  };
809
913
  this.actions.push(def);
810
914
  node.b.push(binding({ k: 'on', n: trigger, x: this.actions.length - 1 }));
@@ -839,7 +943,8 @@ class Compiler {
839
943
  frame.indexLoc = this.locOf(el, 'data-sn-for', value.indexOf(index, itemAt + item.length));
840
944
  this.check.frames.push(frame);
841
945
  }
842
- const inner = { loops, states: scope.states };
946
+ this.checkNames(el, 'data-sn-for', scope, index ? [item, index] : [item]);
947
+ const inner = { ...scope, loops };
843
948
  t.key = null;
844
949
  t.keyFields = null;
845
950
  if (keySrc !== undefined) {
@@ -905,8 +1010,10 @@ class Compiler {
905
1010
  if (lit)
906
1011
  parts.push(lit);
907
1012
  const first = parts[0];
908
- if (typeof first !== 'string' || !(/^\/[^/\\]/.test(first) || first === '/' || /^https?:\/\/[^/{}]+(\/|$)/i.test(first))) {
909
- this.report(el, attrName, 'SN207', 'a URL template must start with a literal same-origin path (`/api/…`) or a literal `https://` origin', 0, 'a leading interpolation or `//` lets a value choose the host');
1013
+ // a literal origin followed by a value needs its `/`: `https://api.example.com{x}` lets `x` extend the host
1014
+ const origin = parts.length > 1 ? /^https?:\/\/[^/{}]+\//i : /^https?:\/\/[^/{}]+(\/|$)/i;
1015
+ if (typeof first !== 'string' || !(/^\/[^/\\]/.test(first) || first === '/' || origin.test(first))) {
1016
+ this.report(el, attrName, 'SN207', 'a URL template must start with a literal same-origin path (`/api/…`) or a literal `https://` origin (with a `/` after the host when a value follows)', 0, 'a leading interpolation or `//` lets a value choose the host');
910
1017
  }
911
1018
  return parts;
912
1019
  }
@@ -953,7 +1060,7 @@ class Compiler {
953
1060
  this.frameOf.set(loop, this.check.frames.length);
954
1061
  this.check.frames.push({ kind: 'loop', item: m[1], index: null, init: listExpr, loc: this.locOf(el, 'each', eachAttr.indexOf(m[1])) });
955
1062
  }
956
- srcScope = { loops: [...ctx.scope.loops, loop], states: ctx.scope.states };
1063
+ srcScope = { ...ctx.scope, loops: [...ctx.scope.loops, loop] };
957
1064
  if (list.ok)
958
1065
  each = { item: m[1], list: this.fn(list.code), ...(list.resource ? { of: list.resource } : {}) };
959
1066
  this.features.add('each');
@@ -41,7 +41,7 @@ export const DIAGNOSTIC_DOCS = {
41
41
  },
42
42
  SN205: {
43
43
  example: '`m.is_read = true` mutates a resource. Use `patch(\'messages\', m, {is_read: true})`',
44
- means: 'Resource data is read-only in expressions: it mirrors the server, and a direct write would be lost on the next refresh or event.',
44
+ means: 'Resource data is read-only in expressions: it mirrors the server, and a direct write (an assignment, a mutating method such as `push()`, or `Object.assign()` on an item) would be lost on the next refresh or event.',
45
45
  fix: 'Use the `patch()` call from the message, or copy the item with `structuredClone(c)` to edit it in state.',
46
46
  },
47
47
  SN206: {
@@ -51,14 +51,19 @@ export const DIAGNOSTIC_DOCS = {
51
51
  },
52
52
  SN207: {
53
53
  example: '`onclick` cannot be bound with `data-sn-attr`; use `data-sn-on-click`',
54
- means: 'The binding could run script or inject markup from data: event-handler attributes, `style`, `srcdoc`, `innerHTML`, bindings on `<script>` and similar elements, URL templates that do not start with a literal path, or persisted password fields.',
55
- fix: 'Use the safe equivalent the message names: `data-sn-on-*` for events, `data-sn-style` for styles, a literal `/path` at the start of a URL template.',
54
+ means: 'The binding could run script or inject markup from data: event-handler attributes, `style`, `srcdoc`, `innerHTML`, bindings on `<script>` and similar elements, URL templates that do not start with a literal path (or with a literal origin that lacks its `/` before a value), persisted password fields, or `constructor`, `__proto__` and `prototype` named in an expression.',
55
+ fix: 'Use the safe equivalent the message names: `data-sn-on-*` for events, `data-sn-style` for styles, a literal `/path` (or `https://host/`) at the start of a URL template.',
56
56
  },
57
57
  SN208: {
58
58
  example: 'Property \'nmae\' does not exist on type \'Contact\'. (TS2339)',
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. A resource cannot take the name of a store function (`refresh`, `upsert`, `patch`, `remove`, `page`, `showModal`, `close`, `reset`, `emit`).',
65
+ fix: 'Rename the let, the state or loop variable, or the resource.',
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;