@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
package/README.md CHANGED
@@ -2,8 +2,9 @@
2
2
 
3
3
  **Your JSON API, bound to plain HTML.** sinter loads and caches server data, keeps it current from
4
4
  server events, renders it through `<template>` bindings and runs mutations with optimistic updates,
5
- without components, a router or a store library. A build step compiles the HTML, so the browser runs
6
- no parser and no `eval`.
5
+ without components, a router or a store library. A compiler resolves every name in every expression
6
+ at build time, and type-checks it when you give it your TypeScript types or OpenAPI spec. A typo
7
+ fails the build instead of the page, and the browser runs no parser and no `eval`.
7
8
 
8
9
  ```html
9
10
  <sn-resource name="contacts" src="/api/contacts" items="contacts" key="id" order="name"></sn-resource>
@@ -30,6 +31,26 @@ That page loads the contacts, keeps them sorted, applies updates other users mak
30
31
  filters as you type, and removes a contact at once when you click Delete, putting it back if the
31
32
  server refuses.
32
33
 
34
+ ## Mistakes fail the build
35
+
36
+ The compiler knows every name an expression can use: the state around it, loop variables,
37
+ resources, helpers. Misspell `q` in the filter above and the build stops:
38
+
39
+ ```text
40
+ contacts.html:11:70 error SN201 unknown-identifier: `qq` is not defined here. Did you mean `q`?
41
+ ```
42
+
43
+ The same pass rejects code that would break at runtime: writing to server data
44
+ (`c.name = 'x'` is `SN205`, use `patch('contacts', c, {…})`) and browser APIs inside expressions
45
+ (`localStorage` is `SN202`, use an island). With a `type` on the resource or your OpenAPI spec,
46
+ `sinter check` also type-checks fields, request bodies and event payloads, so `article.titel`
47
+ reports `Property 'titel' does not exist on type 'Article'. Did you mean 'title'?`. Your editor
48
+ shows these errors as you type.
49
+
50
+ htmx, Datastar and Alpine read their attribute strings in the browser. Nothing checks them against
51
+ your data before they ship, so the same typo shows up only when that code runs: as a console error,
52
+ or as an element that stays empty.
53
+
33
54
  ## Why sinter
34
55
 
35
56
  - **The server-state plumbing is gone.** You don't write loading flags, stale-response guards,
@@ -37,11 +58,11 @@ server refuses.
37
58
  They are attributes.
38
59
  - **Optimistic updates that stay correct**, also when requests overlap or a server event arrives
39
60
  while a request is in flight.
61
+ - **Checked before it ships.** Every name in every expression is resolved at build time and
62
+ type-checked against your TypeScript types or your OpenAPI spec. Your editor shows the errors as
63
+ you type, with hover, completion and go to definition.
40
64
  - **Less code.** The [RealWorld](examples/realworld) app takes 735 lines in sinter. The official
41
- Angular version takes 2,210. Each page downloads 12–14 KB of JS.
42
- - **Errors at build time.** Every expression is type-checked against your TypeScript types or your
43
- OpenAPI spec. Your editor shows the errors as you type, with hover, completion and go to
44
- definition.
65
+ Angular version takes 2,210. Each page downloads 14–16 KB of JS.
45
66
  - **Safe by construction.** Directives are compiled, not read from the live DOM. Pages run under
46
67
  `script-src 'self'` with no `unsafe-eval`, and markup injected at runtime stays inert.
47
68
  - **Plain, valid HTML.** There is one way to do each thing, and everything is visible on the element.
@@ -57,11 +78,14 @@ page load (the shared code comes from the browser cache).
57
78
 
58
79
  **htmx, Datastar.** These expect a server that renders HTML for every page and interaction. If you
59
80
  have a JSON API, you would have to build and maintain that server. sinter uses the API you already
60
- have, and its mutations are optimistic where htmx waits for the server's answer. If your backend
61
- renders HTML anyway and you want no build step, htmx is a good choice.
81
+ have, and its mutations are optimistic where htmx waits for the server's answer. Their attributes
82
+ are read in the browser, so a wrong name surfaces at runtime; sinter's are checked at build time.
83
+ If your backend renders HTML anyway and you want no build step, htmx is a good choice.
62
84
 
63
85
  **Alpine, petite-vue.** These also render JSON with client-side templates, but they leave fetching,
64
- caching, live updates and rollback to you.
86
+ caching, live updates and rollback to you. Their expressions are compiled in the browser with
87
+ `new Function`, so they need `unsafe-eval` in the CSP (Alpine has a restricted CSP build), and
88
+ nothing checks the names or fields they use until the code runs.
65
89
 
66
90
  Measurements and sources: [`docs/realworld-comparison.md`](docs/realworld-comparison.md).
67
91
 
@@ -72,7 +96,7 @@ Measurements and sources: [`docs/realworld-comparison.md`](docs/realworld-compar
72
96
  islands, which are plain custom elements.
73
97
  - You can't have a build step.
74
98
  - Your backend renders HTML, not JSON.
75
- - You need stability guarantees. sinter is at v0.1.
99
+ - You need stability guarantees. sinter is at v0.3.
76
100
 
77
101
  ## Setup
78
102
 
@@ -113,6 +137,7 @@ Then write `data-sn-*` attributes in your HTML pages. Optional:
113
137
  | `@tiaforge-ui/sinter/ws` | WebSocket transport for `<sn-sync>` |
114
138
  | `@tiaforge-ui/sinter/optimistic` | optimistic layers |
115
139
  | `@tiaforge-ui/sinter/paginate` | keyset, offset, append |
140
+ | `@tiaforge-ui/sinter/each` | fan-out resources (`each="a in accounts"`) |
116
141
  | `@tiaforge-ui/sinter/persist` | `data-sn-persist` |
117
142
  | `@tiaforge-ui/sinter/compiler` | `compile()` |
118
143
  | `@tiaforge-ui/sinter/vite` | Vite plugin |
@@ -122,11 +147,13 @@ Then write `data-sn-*` attributes in your HTML pages. Optional:
122
147
  ## Development
123
148
 
124
149
  ```sh
125
- npm test # vitest (happy-dom)
150
+ npm test # vitest (happy-dom); CI runs typecheck, build and tests on every push (Node 22/24/26)
126
151
  npm run typecheck
127
152
  npm run build # dist/
128
- npm run size # size budget per module
153
+ npm run size # size per page and module, with budgets
154
+ npm run bench # store and flush benchmarks (docs/benchmark.md)
129
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)
130
157
  ```
131
158
 
132
159
  License: MIT. Contributions are accepted under the Developer Certificate of Origin
@@ -9,6 +9,8 @@ export interface CheckOptions {
9
9
  /** from `compile(…, { check: true })`, one per page */
10
10
  units: CheckUnit[];
11
11
  helpers: HelperManifest;
12
+ /** the actions module (`data-sn-call`), same shape as helpers */
13
+ actions?: HelperManifest;
12
14
  /** absolute path of the types module, or null */
13
15
  types: string | null;
14
16
  /** the specs the pages were compiled with (`compile(…, { openapi })`) */
@@ -57,12 +59,13 @@ export declare const COMPILER_OPTIONS: {
57
59
  target: string;
58
60
  module: string;
59
61
  moduleResolution: string;
62
+ allowImportingTsExtensions: boolean;
60
63
  lib: string[];
61
64
  types: string[];
62
65
  };
63
66
  /** Where a page's shadow file goes. */
64
67
  export declare function shadowPath(support: Support, root: string, page: string): string;
65
- export declare function shadowOf(unit: CheckUnit, support: Support, root: string, helpers: HelperManifest): Shadow;
68
+ export declare function shadowOf(unit: CheckUnit, support: Support, root: string, helpers: HelperManifest, actions?: HelperManifest): Shadow;
66
69
  /** A TypeScript error at a 1-based shadow position, as an SN208 diagnostic at the HTML position. */
67
70
  export declare function typeDiagnostic(unit: CheckUnit, regions: Region[], line: number, col: number, code: string, message: string, root: string): Diagnostic;
68
71
  export declare function check(o: CheckOptions): CheckResult;
@@ -25,14 +25,14 @@ export function writeSupport(o) {
25
25
  /** The compiler options shadow files are checked with. */
26
26
  export const COMPILER_OPTIONS = {
27
27
  allowJs: true, checkJs: true, strict: true, strictNullChecks: false, noImplicitAny: false, noEmit: true, skipLibCheck: true,
28
- target: 'es2022', module: 'esnext', moduleResolution: 'bundler', lib: ['es2023', 'dom', 'dom.iterable'], types: [],
28
+ target: 'es2022', module: 'esnext', moduleResolution: 'bundler', allowImportingTsExtensions: true, lib: ['es2023', 'dom', 'dom.iterable'], types: [],
29
29
  };
30
30
  /** Where a page's shadow file goes. */
31
31
  export function shadowPath(support, root, page) {
32
32
  return join(support.outDir, relative(root, page) + '.js');
33
33
  }
34
- export function shadowOf(unit, support, root, helpers) {
35
- return shadow({ unit, file: shadowPath(support, root, unit.file), root, declarations: support.declarations, types: support.types, helpers, openapi: support.openapi });
34
+ export function shadowOf(unit, support, root, helpers, actions) {
35
+ return shadow({ unit, file: shadowPath(support, root, unit.file), root, declarations: support.declarations, types: support.types, helpers, actions, openapi: support.openapi });
36
36
  }
37
37
  /** A TypeScript error at a 1-based shadow position, as an SN208 diagnostic at the HTML position. */
38
38
  export function typeDiagnostic(unit, regions, line, col, code, message, root) {
@@ -56,7 +56,7 @@ export function check(o) {
56
56
  const shadows = new Map();
57
57
  for (const unit of o.units) {
58
58
  const file = shadowPath(support, o.root, unit.file);
59
- const s = shadowOf(unit, support, o.root, o.helpers);
59
+ const s = shadowOf(unit, support, o.root, o.helpers, o.actions);
60
60
  mkdirSync(dirname(file), { recursive: true });
61
61
  writeFileSync(file, s.text);
62
62
  shadows.set(resolve(file), { unit, regions: s.regions, brokenFrom: s.brokenFrom });
@@ -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 });
@@ -72,7 +73,7 @@ export function check(o) {
72
73
  throw run.error;
73
74
  const diagnostics = parseTsc(run.stdout, outDir, o.root, shadows);
74
75
  const resources = o.units.reduce((n, u) => n + u.resources.length, 0);
75
- const typed = o.units.reduce((n, u) => n + u.resources.filter((r) => r.type || r.openapi).length, 0);
76
+ const typed = o.units.reduce((n, u) => n + u.resources.filter((r) => r.type || r.openapi || r.call).length, 0);
76
77
  const viaOpenapi = o.units.reduce((n, u) => n + u.resources.filter((r) => !r.type && r.openapi).length, 0);
77
78
  return { diagnostics, resources, typed, openapi: viaOpenapi };
78
79
  }
@@ -91,8 +92,8 @@ export function checkPage(html, o = {}) {
91
92
  const file = join(root, 'index.html');
92
93
  writeFileSync(file, html);
93
94
  const p = project(root, o);
94
- const out = compile(html, { file, root, dev: false, helpers: p.helpers, readFile: p.readFile, check: true, openapi: p.openapi });
95
- const r = check({ root, units: out.check ? [out.check] : [], helpers: p.helpers, types: p.types, openapi: p.openapi, tsc: findTsc(process.cwd()) });
95
+ const out = compile(html, { file, root, dev: false, helpers: p.helpers, actions: p.actions, readFile: p.readFile, check: true, openapi: p.openapi });
96
+ const r = check({ root, units: out.check ? [out.check] : [], helpers: p.helpers, actions: p.actions, types: p.types, openapi: p.openapi, tsc: findTsc(process.cwd()) });
96
97
  return [...p.diagnostics, ...out.diagnostics, ...r.diagnostics].map((d) => ({ ...d, file: relative(root, d.file) }));
97
98
  }
98
99
  finally {
@@ -14,6 +14,8 @@ export interface ShadowOptions {
14
14
  names: string[];
15
15
  } | null;
16
16
  helpers: HelperManifest;
17
+ /** the actions module, for `data-sn-call` arguments and results */
18
+ actions?: HelperManifest;
17
19
  /** absolute paths of the declarations generated per OpenAPI spec, by spec index */
18
20
  openapi?: string[];
19
21
  }
@@ -36,7 +38,7 @@ export declare function shadow(o: ShadowOptions): Shadow;
36
38
  /** Maps a 1-based shadow position to the HTML, or null for generated lines. */
37
39
  export declare function mapPosition(regions: Region[], line: number, col: number): CheckLoc | null;
38
40
  /** `sinter.d.ts`: the types the shadow files refer to. */
39
- 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;\n pages?: number | null;\n from?: number | null;\n to?: number | null;\n hasPrev?: boolean;\n hasNext?: boolean;\n}\nexport interface Err { status: number; body: any; message: string }\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";
40
42
  /**
41
43
  * Maps a 1-based position in an HTML file to the shadow, or null when it is not inside an
42
44
  * expression. `lines` are the shadow's lines.
@@ -51,7 +51,12 @@ export function shadow(o) {
51
51
  if (!taken.has(ns))
52
52
  line(`import * as ${ns} from '${moduleSpec(module)}';`);
53
53
  }
54
+ if (o.actions?.module && (unit.resources.some((r) => r.call) || unit.exprs.some((e) => e.returns?.kind === 'call' || e.resultType?.kind === 'call'))) {
55
+ line(`import * as $A from '${moduleSpec(o.actions.module)}';`);
56
+ }
54
57
  line('const $any = /** @type {any} */ (null);');
58
+ /** what a resource fed by `call` holds: the awaited result, at its `items` path */
59
+ const callResult = (c) => `Awaited<ReturnType<typeof $A.${c.name}>>${(c.items ? c.items.split('.') : []).map((k) => `[${JSON.stringify(k)}]`).join('')}`;
55
60
  const types = [];
56
61
  for (const r of unit.resources) {
57
62
  const t = r.type ?? 'any';
@@ -64,6 +69,11 @@ export function shadow(o) {
64
69
  line(' * }');
65
70
  line(' */');
66
71
  }
72
+ else if (r.call) {
73
+ const t = callResult(r.call);
74
+ types[types.length - 1] = `${JSON.stringify(r.name)}: ${t}`;
75
+ line(`/** @type {${t}} */`);
76
+ }
67
77
  else if (r.openapi) {
68
78
  const t = schemaToTs(r.openapi.schema, (n) => `$O${r.openapi.spec}.${typeName(n)}`);
69
79
  types[types.length - 1] = `${JSON.stringify(r.name)}: ${t}`;
@@ -74,6 +84,14 @@ export function shadow(o) {
74
84
  line('/** @type {any} */');
75
85
  regions.push({ start: out.length + 1, lines: 1, loc: r.nameLoc ?? r.loc, fixed: true });
76
86
  line(`const ${r.name} = $any;`);
87
+ // with `type`, what the action returns (at `items`) must fit it; reported on `type`
88
+ if (r.call && r.type) {
89
+ // own line: TypeScript drops a JSDoc tag that follows `{` on the same line
90
+ line('{');
91
+ regions.push({ start: out.length + 1, lines: 1, loc: r.loc, fixed: true });
92
+ line(`/** @type {${r.type.replace(/\s+/g, ' ')}} */ const $fits = /** @type {${callResult(r.call)}} */ ($any); void $fits;`);
93
+ line('}');
94
+ }
77
95
  }
78
96
  const names = unit.resources.map((r) => JSON.stringify(r.name)).join(' | ') || 'never';
79
97
  line(`/** @type {Record<${names}, ${decl('Meta')}>} */`);
@@ -97,9 +115,11 @@ export function shadow(o) {
97
115
  line(`} */${after}`);
98
116
  return;
99
117
  }
100
- const text = t.kind === 'openapi'
118
+ const text = t.kind === 'decl' ? decl(t.name) : t.kind === 'openapi'
101
119
  ? schemaToTs(t.schema, (n) => `$O${t.spec}.${typeName(n)}`)
102
- : `${t.partial ? 'Partial<' : ''}${decl('Item')}<typeof ${t.resource}>${t.partial ? '>' : ''}`;
120
+ : t.kind === 'call'
121
+ ? (t.args ? `${decl('CallArgs')}<typeof $A.${t.name}>` : `Awaited<ReturnType<typeof $A.${t.name}>>`)
122
+ : `${t.partial ? 'Partial<' : ''}${decl('Item')}<typeof ${t.resource}>${t.partial ? '>' : ''}`;
103
123
  line(`/** @${tag} {${text}} */${after}`);
104
124
  };
105
125
  /** declarations for the frames of an expression; returns the number of blocks opened */
@@ -115,6 +135,10 @@ export function shadow(o) {
115
135
  f.names.forEach((n, k) => { at(f.locs?.[k]); line(`${n},`); });
116
136
  line(`} = ${f.init === null ? '$any' : `$e${f.init}()`};`);
117
137
  }
138
+ else if (f.kind === 'let') {
139
+ at(f.loc);
140
+ line(`const ${f.name} = ${f.init === null ? '$any' : `$e${f.init}()`};`);
141
+ }
118
142
  else {
119
143
  at(f.loc);
120
144
  line(`const ${f.item} = ${f.init === null ? '$any' : `$item($e${f.init}())`};`);
@@ -148,8 +172,19 @@ export function shadow(o) {
148
172
  regions.push({ start: out.length + 1, lines: 1, loc: e.loc, fixed: true });
149
173
  line(`const ${n} = $p.${n};`);
150
174
  }
175
+ // a state name shadows a payload field of the same name: an error when the payload type has it
176
+ if (e.payload.type) {
177
+ for (const n of e.payload.states ?? []) {
178
+ const msg = `state \`${n}\` shadows the payload field \`${n}\`: rename the state`;
179
+ regions.push({ start: out.length + 1, lines: 1, loc: e.loc, fixed: true });
180
+ line(`/** @type {${JSON.stringify(n)} extends keyof typeof $p ? ${JSON.stringify(msg)} : true} */ const $shadows_${n} = true; void $shadows_${n};`);
181
+ }
182
+ }
151
183
  }
152
184
  if (e.mode === 'value') {
185
+ // action arguments and bodies are taken when the trigger fires
186
+ if (e.event !== null)
187
+ line(`/** @type {${decl(`Ev<${JSON.stringify(e.event)}>`)}} */ const $event = $any;`);
153
188
  // a value of the wrong type as a whole is reported on `return`
154
189
  regions.push({ start: out.length + 1, lines: 1, loc: e.loc, fixed: true });
155
190
  line('return (');
@@ -195,14 +230,26 @@ export interface Meta {
195
230
  loadedAt: number | null;
196
231
  pending: number;
197
232
  total: number | null;
198
- page?: number;
233
+ page?: number | null;
199
234
  pages?: number | null;
200
235
  from?: number | null;
201
236
  to?: number | null;
202
237
  hasPrev?: boolean;
203
238
  hasNext?: boolean;
204
239
  }
205
- export interface Err { status: number; 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>;
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 }
251
+ /** an action's parameters without its trailing context */
252
+ export type CallArgs<F> = F extends (...a: infer P) => any ? (P extends [...infer A, infer L] ? ([L] extends [CallContext] ? A : P) : P) : never;
206
253
  export type Item<T> = NonNullable<T> extends readonly (infer U)[] ? U : NonNullable<T> extends Iterable<infer U> ? U : any;
207
254
  export type El<K extends string> = K extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[K] : HTMLElement;
208
255
  export type Ev<K extends string> = K extends keyof HTMLElementEventMap ? HTMLElementEventMap[K] : CustomEvent;
@@ -212,7 +259,8 @@ export interface Store<R> {
212
259
  patch<K extends keyof R & string>(name: K, item: Partial<Item<R[K]>>, set: Partial<Item<R[K]>>): void;
213
260
  remove<K extends keyof R & string>(name: K, item: Partial<Item<R[K]>>): void;
214
261
  page(name: keyof R & string, to: 'next' | 'prev' | 'first' | 'last' | number): void;
215
- close(): void;
262
+ showModal(id: string): void;
263
+ close(id?: string): void;
216
264
  reset(): void;
217
265
  emit(event: string, detail?: unknown): void;
218
266
  }
package/dist/cli.js CHANGED
@@ -36,12 +36,12 @@ async function main(argv) {
36
36
  const diagnostics = [...p.diagnostics];
37
37
  const units = [];
38
38
  for (const file of files) {
39
- const out = compile(readFileSync(file, 'utf8'), { file, root, dev: false, helpers: p.helpers, config: p.config, readFile: p.readFile, check: true, openapi: p.openapi });
39
+ const out = compile(readFileSync(file, 'utf8'), { file, root, dev: false, helpers: p.helpers, actions: p.actions, config: p.config, readFile: p.readFile, check: true, openapi: p.openapi });
40
40
  diagnostics.push(...out.diagnostics);
41
41
  if (out.check)
42
42
  units.push(out.check);
43
43
  }
44
- const r = check({ root, units, helpers: p.helpers, types: p.types, openapi: p.openapi });
44
+ const r = check({ root, units, helpers: p.helpers, actions: p.actions, types: p.types, openapi: p.openapi });
45
45
  diagnostics.push(...r.diagnostics);
46
46
  for (const d of diagnostics)
47
47
  console.log(formatDiagnostic({ ...d, file: relative(process.cwd(), d.file) || d.file }) + '\n');
@@ -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 =
@@ -47,13 +54,24 @@ export type TypeRef =
47
54
  kind: 'item';
48
55
  resource: string;
49
56
  partial?: boolean;
57
+ }
58
+ /** an action (`archive`, `pgp.decrypt`): its arguments (`args`) or its awaited result */
59
+ | {
60
+ kind: 'call';
61
+ name: string;
62
+ args?: boolean;
63
+ }
64
+ /** a type sinter declares itself (\`Attrs\` for \`data-sn-attr\`) */
65
+ | {
66
+ kind: 'decl';
67
+ name: string;
50
68
  };
51
69
  export interface CheckExpr {
52
70
  src: string;
53
71
  /** `value` returns the expression; `handler` runs it as statements */
54
72
  mode: 'value' | 'handler';
55
73
  loc: CheckLoc;
56
- /** 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 */
57
75
  frames: number[];
58
76
  /** tag of the element the expression sits on, for `$el` */
59
77
  tag: string;
@@ -70,6 +88,7 @@ export interface CheckExpr {
70
88
  payload?: {
71
89
  names: string[];
72
90
  type: TypeRef | null;
91
+ states?: string[];
73
92
  };
74
93
  /** the compiler reported an error in it (it may not even parse); kept for editor completion, never type-checked */
75
94
  broken?: boolean;
@@ -88,6 +107,11 @@ export interface CheckResource {
88
107
  label: string;
89
108
  schema: unknown;
90
109
  };
110
+ /** `call`: the action and the `items` path into its result; with `type`, the result must fit it */
111
+ call?: {
112
+ name: string;
113
+ items: string | null;
114
+ };
91
115
  }
92
116
  export interface CheckUnit {
93
117
  file: string;
@@ -25,6 +25,8 @@ export interface CompileOptions {
25
25
  check?: boolean;
26
26
  /** parsed OpenAPI specs: resources without `type` are matched against them */
27
27
  openapi?: OpenApiSpec[];
28
+ /** the actions module (`data-sn-call`, `<sn-resource call>`), same shape as helpers */
29
+ actions?: HelperManifest;
28
30
  }
29
31
  export interface CompileResult {
30
32
  html: string;
@@ -45,6 +47,8 @@ export interface CompiledParts {
45
47
  builtin: string[];
46
48
  };
47
49
  namespaces: Record<string, string[]>;
50
+ /** action functions called, as written: `archive`, `pgp.decrypt` */
51
+ calls: string[];
48
52
  diagnostics: Diagnostic[];
49
53
  files: string[];
50
54
  /** with `check: true` */