@tiaforge-ui/sinter 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +39 -12
  2. package/dist/check/index.d.ts +4 -1
  3. package/dist/check/index.js +9 -8
  4. package/dist/check/shadow.d.ts +3 -1
  5. package/dist/check/shadow.js +53 -5
  6. package/dist/cli.js +2 -2
  7. package/dist/compiler/check-unit.d.ts +26 -2
  8. package/dist/compiler/compile.d.ts +4 -0
  9. package/dist/compiler/compile.js +314 -30
  10. package/dist/compiler/diagnostic-docs.js +20 -5
  11. package/dist/compiler/diagnostics.d.ts +3 -0
  12. package/dist/compiler/diagnostics.js +3 -0
  13. package/dist/compiler/expr.d.ts +24 -0
  14. package/dist/compiler/expr.js +53 -8
  15. package/dist/compiler/index.d.ts +3 -2
  16. package/dist/compiler/index.js +70 -16
  17. package/dist/compiler/vocabulary.d.ts +1 -0
  18. package/dist/compiler/vocabulary.js +9 -3
  19. package/dist/each.d.ts +3 -0
  20. package/dist/each.js +121 -0
  21. package/dist/index.d.ts +1 -1
  22. package/dist/ir.d.ts +30 -1
  23. package/dist/listbox.d.ts +3 -0
  24. package/dist/listbox.js +66 -0
  25. package/dist/lsp/typescript.js +1 -1
  26. package/dist/lsp/workspace.js +3 -2
  27. package/dist/paginate.js +18 -4
  28. package/dist/project.d.ts +6 -0
  29. package/dist/project.js +22 -19
  30. package/dist/runtime/app.d.ts +35 -1
  31. package/dist/runtime/app.js +178 -40
  32. package/dist/runtime/config.d.ts +10 -3
  33. package/dist/runtime/config.js +10 -3
  34. package/dist/runtime/request.d.ts +1 -1
  35. package/dist/runtime/request.js +6 -1
  36. package/dist/runtime/store.d.ts +7 -4
  37. package/dist/runtime/store.js +154 -41
  38. package/dist/sse.js +8 -5
  39. package/dist/testing/index.d.ts +2 -0
  40. package/dist/testing/index.js +17 -2
  41. package/dist/vite.js +4 -2
  42. package/docs/diagnostics.md +35 -5
  43. package/html-custom-data.json +46 -2
  44. package/llms.txt +112 -6
  45. package/package.json +6 -1
@@ -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,8 @@ 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';
41
+ readonly SN310: 'unknown-action';
39
42
  readonly SN401: 'parse-error';
40
43
  readonly SN501: 'unused-resource';
41
44
  readonly SN502: 'non-reactive-value';
@@ -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,8 @@ export const CODES = {
19
20
  SN306: 'include-cycle',
20
21
  SN307: 'openapi-invalid',
21
22
  SN308: 'unknown-schema-field',
23
+ SN309: 'unknown-dialog',
24
+ SN310: 'unknown-action',
22
25
  SN401: 'parse-error',
23
26
  SN501: 'unused-resource',
24
27
  SN502: 'non-reactive-value',
@@ -19,19 +19,35 @@ 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;
25
35
  paginate: string | null;
26
36
  used: boolean;
37
+ /** keyset with `offset-param`: `page(name, n)` and `$meta.<name>.page` are available */
38
+ jumps?: boolean;
27
39
  }
28
40
  export interface Scope {
29
41
  loops: LoopEntry[];
30
42
  states: StateEntry[];
43
+ /** `data-sn-let` names, outer to inner */
44
+ lets?: LetEntry[];
31
45
  }
32
46
  export interface PageInfo {
33
47
  resources: Map<string, ResourceInfo>;
34
48
  helpers: Set<string>;
49
+ /** action functions and namespaces: only `data-sn-call` and `<sn-resource call>` may name them */
50
+ actions?: Set<string>;
35
51
  namespaces: Map<string, Set<string>>;
36
52
  /** imports used by emitted code */
37
53
  usedHelpers: Set<string>;
@@ -39,6 +55,8 @@ export interface PageInfo {
39
55
  usesNow: boolean;
40
56
  /** `:name` segments of the page's `data-sn-path` patterns */
41
57
  pathParams: Set<string>;
58
+ /** literal `id`s of the page's `<dialog>` elements, for `showModal()` and `close()` */
59
+ dialogs: Set<string>;
42
60
  }
43
61
  export interface ExprOptions {
44
62
  mode: Mode;
@@ -46,6 +64,8 @@ export interface ExprOptions {
46
64
  magic?: string[];
47
65
  /** free identifiers resolve to fields of `$.$p` (sn-event, cursor) */
48
66
  payload?: boolean;
67
+ /** with `payload`: state names in scope resolve to the state first (`<sn-event>`) */
68
+ payloadState?: boolean;
49
69
  /** parse as `{...}` object literal; returns its entries */
50
70
  object?: 'literal-keys' | 'any-keys' | 'state';
51
71
  report: (code: Code, message: string, offset: number, hint?: string) => void;
@@ -60,6 +80,8 @@ export interface ObjectEntry {
60
80
  export interface ExprResult {
61
81
  ok: boolean;
62
82
  code: string;
83
+ /** `code` without its `($) =>` wrapper (value expressions) */
84
+ inner: string;
63
85
  deps: string[];
64
86
  writes: string[];
65
87
  usesEl: boolean;
@@ -73,6 +95,8 @@ export interface ExprResult {
73
95
  ast: Node | null;
74
96
  /** names resolved to payload fields (`payload` mode) */
75
97
  payloadNames?: string[];
98
+ /** state names read where the payload is the scope (they shadow payload fields of the same name) */
99
+ payloadStates?: string[];
76
100
  }
77
101
  export declare const ALL_MAGIC: string[];
78
102
  export declare const STORE_FNS: string[];
@@ -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'];
@@ -50,6 +50,7 @@ export function analyze(src, scope, page, opts) {
50
50
  let usesIndex = false;
51
51
  let ok = true;
52
52
  const payloadNames = new Set();
53
+ const payloadStates = new Set();
53
54
  const mode = opts.mode;
54
55
  const magicAllowed = new Set([...EVERYWHERE_MAGIC, ...(opts.magic ?? [])]);
55
56
  const report = (code, message, offset, hint) => {
@@ -95,7 +96,7 @@ export function analyze(src, scope, page, opts) {
95
96
  return fail();
96
97
  }
97
98
  function fail() {
98
- 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 };
99
100
  }
100
101
  // ---- resolution
101
102
  function resolve(name, locals, offset, member) {
@@ -117,7 +118,21 @@ export function analyze(src, scope, page, opts) {
117
118
  report('SN201', `\`${name}\` is not defined.${didYouMean(name, ALL_MAGIC)}`, offset);
118
119
  return null;
119
120
  }
121
+ if (opts.payload && opts.payloadState) {
122
+ for (let i = scope.states.length - 1; i >= 0; i--) {
123
+ if (scope.states[i].names.has(name)) {
124
+ payloadStates.add(name);
125
+ return { kind: 'state', sid: scope.states[i].sid };
126
+ }
127
+ }
128
+ }
120
129
  if (!opts.payload) {
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
+ }
121
136
  for (let i = scope.loops.length - 1; i >= 0; i--) {
122
137
  if (scope.loops[i].name === name)
123
138
  return { kind: 'loop', entry: scope.loops[i] };
@@ -148,6 +163,10 @@ export function analyze(src, scope, page, opts) {
148
163
  payloadNames.add(name);
149
164
  return { kind: 'payload' };
150
165
  }
166
+ if (page.actions?.has(name)) {
167
+ report('SN203', `\`${name}\` is an action: actions run through \`data-sn-call\` or \`<sn-resource call>\`, not inside expressions`, offset);
168
+ return null;
169
+ }
151
170
  if (BROWSER.has(name)) {
152
171
  report('SN202', `\`${name}\` is a browser API; expressions may use JavaScript, not the browser. Use an island`, offset);
153
172
  return null;
@@ -157,7 +176,7 @@ export function analyze(src, scope, page, opts) {
157
176
  return { kind: 'payload' };
158
177
  }
159
178
  const candidates = [
160
- ...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]),
161
180
  ...page.resources.keys(), ...page.helpers, ...page.namespaces.keys(), ...STORE_FNS, ...BUILTIN_HELPERS,
162
181
  ];
163
182
  report('SN201', `\`${name}\` is not defined here.${didYouMean(name, candidates)}`, offset);
@@ -170,6 +189,7 @@ export function analyze(src, scope, page, opts) {
170
189
  case 'global':
171
190
  case 'helper': return name;
172
191
  case 'loop': return `$.${name}`;
192
+ case 'let': return `(${res.entry.code})`;
173
193
  case 'state': return `$.$s${sid}.${name}`;
174
194
  case 'resource': return `$.$r.${name}`;
175
195
  case 'magic': return `$.${name}`;
@@ -189,6 +209,7 @@ export function analyze(src, scope, page, opts) {
189
209
  return { segs: res.entry.alias ? [...res.entry.alias] : null, resource: res.entry.resource };
190
210
  case 'local':
191
211
  return { segs: res.info.alias ? [...res.info.alias] : null, resource: res.info.resource };
212
+ case 'let': return { segs: null, resource: res.entry.resource };
192
213
  default: return { segs: null, resource: null };
193
214
  }
194
215
  }
@@ -278,8 +299,8 @@ export function analyze(src, scope, page, opts) {
278
299
  else if (PAGE_FIELDS.includes(field) && !r.paginate) {
279
300
  report('SN303', `\`$meta.${rname}.${field}\` is a pagination field, but \`${rname}\` has no \`paginate\``, n.start);
280
301
  }
281
- else if (field === 'page' && r.paginate !== 'offset') {
282
- report('SN303', `\`$meta.${rname}.page\` exists only with \`paginate="offset"\``, n.start);
302
+ else if (field === 'page' && r.paginate !== 'offset' && !r.jumps) {
303
+ report('SN303', `\`$meta.${rname}.page\` exists only with \`paginate="offset"\`, or \`paginate="keyset"\` with \`offset-param\``, n.start);
283
304
  }
284
305
  }
285
306
  }
@@ -302,6 +323,15 @@ export function analyze(src, scope, page, opts) {
302
323
  sid = res.sid;
303
324
  if (res.kind === 'loop' && res.entry.kind === 'index')
304
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
+ }
305
335
  const text = emitName(n.name, res, sid);
306
336
  if (text !== n.name || shorthand)
307
337
  edits.push({ start: n.start, end: n.end, text: shorthand ? `${n.name}: ${text}` : text });
@@ -459,6 +489,10 @@ export function analyze(src, scope, page, opts) {
459
489
  report('SN203', 'only state variables and their properties can be assigned', at);
460
490
  return;
461
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
+ }
462
496
  if (cp.resource) {
463
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');
464
498
  return;
@@ -557,6 +591,15 @@ export function analyze(src, scope, page, opts) {
557
591
  return;
558
592
  }
559
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
+ }
560
603
  if (['refresh', 'upsert', 'patch', 'remove', 'page'].includes(name)) {
561
604
  if (!first || first.type !== 'Literal' || typeof first.value !== 'string') {
562
605
  report('SN301', `\`${name}()\` takes the resource name as a string literal`, n.start);
@@ -572,8 +615,10 @@ export function analyze(src, scope, page, opts) {
572
615
  report('SN303', `\`${first.value}\` has no \`paginate\`; \`page()\` needs a paginated resource`, n.start);
573
616
  const target = n.arguments[1];
574
617
  const named = target?.type === 'Literal' && typeof target.value === 'string' ? target.value : null;
575
- if (r.paginate && r.paginate !== 'offset' && (!named)) {
576
- report('SN304', 'keyset pagination cannot jump to a page number. Use `paginate="offset"`', target?.start ?? n.start);
618
+ if (r.paginate && r.paginate !== 'offset' && !named && !(r.paginate === 'keyset' && r.jumps)) {
619
+ report('SN304', r.paginate === 'keyset'
620
+ ? 'keyset pagination cannot jump to a page number. Add `offset-param` if the server also accepts an offset, or use `paginate="offset"`'
621
+ : 'keyset pagination cannot jump to a page number. Use `paginate="offset"`', target?.start ?? n.start);
577
622
  }
578
623
  else if (r.paginate === 'append' && named && named !== 'next') {
579
624
  report('SN304', '`append` pagination only supports `page(…, \'next\')`', target.start);
@@ -827,7 +872,7 @@ export function analyze(src, scope, page, opts) {
827
872
  }
828
873
  }
829
874
  }
830
- return { ok, code, deps: [...deps].sort(), writes: [...writes].sort(), usesEl, usesIndex, entries, path, resource, ast, ...(opts.payload ? { payloadNames: [...payloadNames].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() } : {}) };
831
876
  }
832
877
  export function applyEdits(src, edits) {
833
878
  const sorted = [...edits].sort((a, b) => b.start - a.start || (b.end - b.start) - (a.end - a.start));
@@ -8,10 +8,11 @@ export { compileParts };
8
8
  export declare const PACKAGE = "@tiaforge-ui/sinter";
9
9
  /** Compiles annotated HTML into a rewritten page and its page module. Pure and deterministic. */
10
10
  export declare function compile(html: string, options: CompileOptions): CompileResult;
11
- export declare function pageModule(parts: CompiledParts, options: Pick<CompileOptions, 'helpers' | 'dev' | 'config'>): string;
11
+ export declare function pageModule(parts: CompiledParts, options: Pick<CompileOptions, 'helpers' | 'dev' | 'config' | 'actions'>): string;
12
12
  /**
13
13
  * Reads a helpers module's export names statically. `export * as m from './x.js'`
14
- * becomes a namespace whose members are read from `x.js` the same way.
14
+ * becomes a namespace whose members are read from `x.js` the same way. `export * from './y.js'`
15
+ * re-exports (e.g. paraglide's message index) are followed, relative paths only.
15
16
  */
16
17
  export declare function readHelperManifest(source: string, options: {
17
18
  module: string;
@@ -1,4 +1,5 @@
1
1
  import * as acorn from 'acorn';
2
+ import { tsPlugin } from '@sveltejs/acorn-typescript';
2
3
  import { compileParts } from './compile.js';
3
4
  export { formatDiagnostic, docsUrl, CODES } from './diagnostics.js';
4
5
  export { DIAGNOSTIC_DOCS } from './diagnostic-docs.js';
@@ -25,6 +26,21 @@ export function pageModule(parts, options) {
25
26
  const mod = options.helpers.namespaces[ns].module;
26
27
  lines.push(`import { ${members.map((m) => `${m} as ${ns}$${m}`).join(', ')} } from ${JSON.stringify(mod)};`);
27
28
  }
29
+ // actions (`data-sn-call`): imported as `$a$name` / `$a$ns$member`, only those the page calls
30
+ const acts = options.actions;
31
+ if (acts?.module) {
32
+ const direct = parts.calls.filter((c) => !c.includes('.'));
33
+ if (direct.length)
34
+ lines.push(`import { ${direct.map((c) => `${c} as $a$${c}`).join(', ')} } from ${JSON.stringify(acts.module)};`);
35
+ const byNs = new Map();
36
+ for (const c of parts.calls.filter((x) => x.includes('.'))) {
37
+ const [ns, member] = c.split('.');
38
+ byNs.set(ns, [...(byNs.get(ns) ?? []), member]);
39
+ }
40
+ for (const [ns, members] of byNs) {
41
+ lines.push(`import { ${members.map((x) => `${x} as $a$${ns}$${x}`).join(', ')} } from ${JSON.stringify(acts.namespaces[ns].module)};`);
42
+ }
43
+ }
28
44
  if (options.config)
29
45
  lines.push(`import ${JSON.stringify(options.config)};`);
30
46
  lines.push('');
@@ -39,46 +55,81 @@ export function pageModule(parts, options) {
39
55
  }
40
56
  /**
41
57
  * Reads a helpers module's export names statically. `export * as m from './x.js'`
42
- * becomes a namespace whose members are read from `x.js` the same way.
58
+ * becomes a namespace whose members are read from `x.js` the same way. `export * from './y.js'`
59
+ * re-exports (e.g. paraglide's message index) are followed, relative paths only.
43
60
  */
44
61
  export function readHelperManifest(source, options) {
45
62
  const manifest = { module: options.module, names: [], namespaces: {} };
46
63
  const resolve = options.resolve ?? ((spec, from) => (spec.startsWith('.') ? joinPath(from, spec) : null));
47
- const { names, namespaces } = exportsOf(source);
48
- manifest.names = names;
49
- for (const [ns, spec] of namespaces) {
50
- const path = resolve(spec, options.file);
51
- let members = [];
52
- if (path) {
53
- try {
54
- members = exportsOf(options.readFile(path)).names;
55
- }
56
- catch {
57
- members = [];
58
- }
64
+ const read = (file) => {
65
+ try {
66
+ return exportsOf(options.readFile(file), file);
67
+ }
68
+ catch {
69
+ return null;
59
70
  }
71
+ };
72
+ /** the module's own value exports plus those of its `export *` re-exports, transitively */
73
+ const valueNames = (file, exports, seen) => {
74
+ const names = new Set(exports.names);
75
+ for (const spec of exports.reexports) {
76
+ const path = spec.startsWith('.') ? resolve(spec, file) : null;
77
+ if (!path || seen.has(path))
78
+ continue;
79
+ seen.add(path);
80
+ const sub = read(path);
81
+ if (sub)
82
+ for (const n of valueNames(path, sub, seen))
83
+ names.add(n);
84
+ }
85
+ return [...names].sort();
86
+ };
87
+ const top = exportsOf(source, options.file);
88
+ manifest.names = valueNames(options.file, top, new Set([options.file]));
89
+ for (const [ns, spec] of top.namespaces) {
90
+ const path = resolve(spec, options.file);
91
+ const exports = path ? read(path) : null;
92
+ const members = path && exports ? valueNames(path, exports, new Set([path])) : [];
60
93
  const modSpec = spec.startsWith('.') ? joinPath(options.module, spec) : spec;
61
94
  manifest.namespaces[ns] = { module: modSpec, members };
62
95
  }
63
96
  return manifest;
64
97
  }
65
- function exportsOf(source) {
66
- const ast = acorn.parse(source, { ecmaVersion: 'latest', sourceType: 'module' });
98
+ /** acorn extended with TypeScript syntax, for `.ts` helper and namespace modules */
99
+ let tsParser = null;
100
+ function parseModule(source, file) {
101
+ const options = { ecmaVersion: 'latest', sourceType: 'module' };
102
+ if (!/\.[cm]?tsx?$/.test(file))
103
+ return acorn.parse(source, options);
104
+ tsParser ??= acorn.Parser.extend(tsPlugin());
105
+ return tsParser.parse(source, options);
106
+ }
107
+ /** Value exports only: type-only exports (`export type`, `export interface`, `export { type X }`) are skipped. */
108
+ function exportsOf(source, file) {
109
+ const ast = parseModule(source, file);
67
110
  const names = [];
68
111
  const namespaces = [];
112
+ const reexports = [];
113
+ const typeOnly = (n) => n.exportKind === 'type';
69
114
  for (const st of ast.body) {
115
+ if (typeOnly(st))
116
+ continue;
70
117
  if (st.type === 'ExportNamedDeclaration') {
71
118
  const d = st.declaration;
72
119
  if (d?.type === 'FunctionDeclaration' && d.id)
73
120
  names.push(d.id.name);
74
121
  else if (d?.type === 'ClassDeclaration' && d.id)
75
122
  names.push(d.id.name);
123
+ else if (d?.type === 'TSEnumDeclaration')
124
+ names.push(d.id.name);
76
125
  else if (d?.type === 'VariableDeclaration') {
77
126
  for (const v of d.declarations)
78
127
  if (v.id.type === 'Identifier')
79
128
  names.push(v.id.name);
80
129
  }
81
130
  for (const s of st.specifiers) {
131
+ if (typeOnly(s))
132
+ continue;
82
133
  const exported = s.exported.type === 'Identifier' ? s.exported.name : String(s.exported.value);
83
134
  if (exported !== 'default')
84
135
  names.push(exported);
@@ -88,8 +139,11 @@ function exportsOf(source) {
88
139
  const ns = st.exported.type === 'Identifier' ? st.exported.name : String(st.exported.value);
89
140
  namespaces.push([ns, String(st.source.value)]);
90
141
  }
142
+ else if (st.type === 'ExportAllDeclaration') {
143
+ reexports.push(String(st.source.value));
144
+ }
91
145
  }
92
- return { names: [...new Set(names)].sort(), namespaces };
146
+ return { names: [...new Set(names)].sort(), namespaces, reexports };
93
147
  }
94
148
  function joinPath(from, spec) {
95
149
  const dir = from.slice(0, from.lastIndexOf('/') + 1);
@@ -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,25 +14,30 @@ 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' },
19
21
  { name: 'put', value: 'URL template', doc: 'PUT action' },
20
22
  { name: 'patch', value: 'URL template', doc: 'PATCH action' },
21
23
  { name: 'delete', value: 'URL template', doc: 'DELETE action' },
24
+ { name: 'call', value: 'action call', doc: 'local action: calls a function from the actions module, e.g. pgp.decrypt(msg)' },
22
25
  { name: 'body', value: 'value expression', doc: 'JSON request body; overrides form serialization' },
23
26
  { name: 'trigger', value: 'DOM event name', doc: 'overrides the default trigger' },
24
- { name: 'debounce', value: 'milliseconds', doc: 'delays the trigger, resets on each event' },
27
+ { name: 'debounce', value: 'milliseconds', doc: 'delays the trigger, resets on each event; without a method, delays the element\'s data-sn-on-* handlers' },
25
28
  { name: 'concurrency', value: 'block | queue | latest', doc: 'mutations only; default block for forms, queue otherwise' },
26
29
  { name: 'optimistic', value: 'handler expression', doc: 'patch/upsert/remove calls applied before the request, rolled back on failure' },
27
30
  { name: 'then', value: 'handler expression', doc: 'runs after a 2xx response; $result = parsed body' },
28
- { 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' },
29
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' },
30
35
  { name: 'data', value: 'resource name', doc: 'on <script type="application/json"> in <head>: initial data for a resource' },
31
36
  ];
32
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' };
33
39
  export const ELEMENTS = [
34
- { name: 'sn-resource', attrs: ['name', 'src', 'key', 'items', 'total', 'order', 'when', 'paginate', 'page-size', 'size-param', 'cursor', 'direction-param', 'page-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' },
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' },
35
41
  { name: 'sn-sync', attrs: ['src'], doc: 'one server event stream' },
36
42
  { name: 'sn-event', attrs: ['name', 'resource', 'op', 'item', 'set', 'when', 'fallback', 'type'], doc: 'maps one server event to a change message; child of <sn-sync>' },
37
43
  { name: 'sn-include', attrs: ['src'], doc: 'inlines a partial at build time' },
package/dist/each.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import type { Feature } from './runtime/app.js';
2
+ declare const each: Feature;
3
+ export default each;
package/dist/each.js ADDED
@@ -0,0 +1,121 @@
1
+ const isAbort = (e) => e instanceof DOMException && e.name == 'AbortError';
2
+ /**
3
+ * Sources are keyed by their resolved URL. An item belongs to a source when it came in that
4
+ * source's last response, or when `src` resolves to the source's URL with the item itself in
5
+ * place of the list item, so an item a server event added is replaced by the next response.
6
+ */
7
+ function fanOut(l, app) {
8
+ const { def, res: r } = l;
9
+ const each = def.each;
10
+ const sources = new Map();
11
+ const queued = new Set();
12
+ let pending = 0, again = null;
13
+ const urlOf = (item) => {
14
+ if (!item || typeof item != 'object')
15
+ return null;
16
+ try {
17
+ return app.url(def.src, Object.assign(Object.create(l.scope()), { [each.item]: item }));
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ };
23
+ /** the confirmed items minus those of source `u`, plus `items` */
24
+ const swap = (u, s, items) => {
25
+ const mine = (x) => s.keys.has(r.keyOf(x)) || urlOf(x) === u;
26
+ r.replace([...r.base().items.filter((x) => !mine(x)), ...items]);
27
+ };
28
+ async function load(u) {
29
+ const s = sources.get(u);
30
+ if (!s)
31
+ return;
32
+ s.c?.abort();
33
+ const ctl = (s.c = new AbortController()), n = ++s.seq, mark = r.startLoad();
34
+ let loaded = false;
35
+ pending++;
36
+ l.loading(true);
37
+ try {
38
+ const body = await l.get(u, ctl.signal);
39
+ if (n != s.seq || sources.get(u) != s)
40
+ return;
41
+ const items = l.items(body);
42
+ swap(u, s, items);
43
+ s.keys = new Set(items.map((x) => r.keyOf(x)));
44
+ s.answered = loaded = true;
45
+ r.meta.error = null;
46
+ // loadedAt: every current source has answered once
47
+ if ([...sources.values()].every((x) => x.answered))
48
+ r.meta.loadedAt = app.env.now();
49
+ }
50
+ catch (e) {
51
+ if (n != s.seq || isAbort(e))
52
+ return;
53
+ s.answered = true;
54
+ l.failed(e);
55
+ }
56
+ finally {
57
+ r.endLoad(mark, loaded);
58
+ if (n == s.seq)
59
+ s.c = null;
60
+ l.loading(--pending > 0);
61
+ }
62
+ }
63
+ l.changed = () => {
64
+ const scope = l.scope();
65
+ let list = [];
66
+ try {
67
+ if (def.when == null || app.fns[def.when](scope))
68
+ list = app.fns[each.list](scope) ?? [];
69
+ }
70
+ catch (e) {
71
+ app.warn(17, def.name, e.message);
72
+ }
73
+ const want = new Set();
74
+ for (const item of Array.isArray(list) ? list : [...list]) {
75
+ const u = urlOf(item);
76
+ if (u != null)
77
+ want.add(u);
78
+ }
79
+ for (const [u, s] of sources) {
80
+ if (want.has(u))
81
+ continue;
82
+ s.seq++;
83
+ s.c?.abort();
84
+ sources.delete(u);
85
+ swap(u, s, []);
86
+ }
87
+ for (const u of want) {
88
+ if (sources.has(u))
89
+ continue;
90
+ sources.set(u, { c: null, seq: 0, keys: new Set(), answered: false });
91
+ void load(u);
92
+ }
93
+ // nothing to load: loaded once the list itself is (an empty list is an answer, a pending one is not)
94
+ if (!sources.size && r.meta.loadedAt == null && (!each.of || app.resource(each.of).meta.loadedAt != null)) {
95
+ r.meta.loadedAt = app.env.now();
96
+ r.onDirty('meta');
97
+ }
98
+ l.loading(pending > 0);
99
+ };
100
+ l.start = () => l.changed();
101
+ l.load = () => Promise.all([...sources.keys()].map(load)).then(() => { });
102
+ // a hint (an event's payload) that resolves to one source's URL reloads only that source;
103
+ // several refetches in one task share one round of requests
104
+ l.refetch = (hint) => {
105
+ const u = hint ? urlOf(hint) : null;
106
+ queued.add(u != null && sources.has(u) ? u : '*');
107
+ return (again ??= Promise.resolve().then(() => {
108
+ again = null;
109
+ const urls = queued.has('*') ? [...sources.keys()] : [...queued];
110
+ queued.clear();
111
+ return Promise.all(urls.map(load)).then(() => { });
112
+ }));
113
+ };
114
+ }
115
+ const each = {
116
+ name: 'each',
117
+ install(app) {
118
+ app.ext.each = { fanOut: (loader) => fanOut(loader, app) };
119
+ },
120
+ };
121
+ export default each;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { mount, instance, type App, type Feature, type MountOptions, type StateHandle, type Transport } from './runtime/app.js';
1
+ export { mount, instance, type App, type CallContext, type Feature, type MountOptions, type StateHandle, type Transport } from './runtime/app.js';
2
2
  export { configure, credentials, header, onUnauthorized, loginRedirect, takeReturnUrl, clearPersisted, SinterError, type Config, type Middleware, type LoginRedirectOptions, } from './runtime/config.js';
3
3
  export type { Resource, Change, Meta } from './runtime/store.js';
4
4
  export { $k } from './runtime/security.js';