@tiaforge-ui/sinter 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,9 +11,10 @@ import cache from '../cache.js';
11
11
  import dev from '../dev.js';
12
12
  import optimistic from '../optimistic.js';
13
13
  import paginate from '../paginate.js';
14
+ import each from '../each.js';
14
15
  import persist from '../persist.js';
15
16
  import sse from '../sse.js';
16
- const FEATURES = { dev, sse, optimistic, paginate, persist, cache };
17
+ const FEATURES = { dev, sse, optimistic, paginate, persist, cache, each };
17
18
  function isResponseSpec(v) {
18
19
  if (!v || typeof v !== 'object' || Array.isArray(v))
19
20
  return false;
@@ -172,9 +173,17 @@ export async function render(html, options = {}) {
172
173
  else if (v && typeof v === 'object')
173
174
  manifest.namespaces[k] = { module: k, members: Object.keys(v) };
174
175
  }
176
+ const actions = options.actions ?? {};
177
+ const actionManifest = { module: 'actions', names: [], namespaces: {} };
178
+ for (const [k, v] of Object.entries(actions)) {
179
+ if (typeof v === 'function')
180
+ actionManifest.names.push(k);
181
+ else if (v && typeof v === 'object')
182
+ actionManifest.namespaces[k] = { module: k, members: Object.keys(v) };
183
+ }
175
184
  const files = options.files ?? {};
176
185
  const parts = compileParts(html, {
177
- file: PAGE, root: '/test', dev: true, helpers: manifest, moduleSrc: 'about:blank',
186
+ file: PAGE, root: '/test', dev: true, helpers: manifest, actions: actionManifest, moduleSrc: 'about:blank',
178
187
  readFile: (p) => {
179
188
  const rel = p.startsWith('/test/') ? p.slice('/test/'.length) : p;
180
189
  const hit = files[rel] ?? files[`./${rel}`] ?? files[p];
@@ -203,6 +212,11 @@ export async function render(html, options = {}) {
203
212
  values.push(helpers[ns][m]);
204
213
  }
205
214
  }
215
+ for (const c of parts.calls) {
216
+ const [a, b] = c.split('.');
217
+ names.push(`$a$${c.replace('.', '$')}`);
218
+ values.push(b === undefined ? actions[a] : actions[a][b]);
219
+ }
206
220
  // eslint-disable-next-line no-new-func -- tests only; pages load a compiled module
207
221
  const fns = new Function(...names, `"use strict"; return [\n${parts.fns.join(',\n')}\n];`)(...values);
208
222
  // the page
package/dist/vite.js CHANGED
@@ -51,7 +51,7 @@ export default function sinter(options = {}) {
51
51
  return;
52
52
  const { check } = await import('./check/index.js');
53
53
  const p = project(root, options);
54
- const r = check({ root, units: [...units.values()], helpers: p.helpers, types: p.types, openapi: p.openapi });
54
+ const r = check({ root, units: [...units.values()], helpers: p.helpers, actions: p.actions, types: p.types, openapi: p.openapi });
55
55
  units.clear();
56
56
  if (r.diagnostics.length)
57
57
  this.error(`sinter check: ${r.diagnostics.length} type error(s)\n${r.diagnostics.map(formatDiagnostic).join('\n')}`);
@@ -66,7 +66,7 @@ export default function sinter(options = {}) {
66
66
  if (p.diagnostics.length)
67
67
  throw new Error(`sinter: ${p.diagnostics.map(formatDiagnostic).join('\n')}`);
68
68
  const out = compile(html, {
69
- file, root, dev, helpers: p.helpers, moduleSrc, config: p.config, readFile: p.readFile,
69
+ file, root, dev, helpers: p.helpers, actions: p.actions, moduleSrc, config: p.config, readFile: p.readFile,
70
70
  check: !!options.typecheck && !dev, openapi: p.openapi,
71
71
  });
72
72
  if (out.check)
@@ -75,6 +75,8 @@ export default function sinter(options = {}) {
75
75
  watch(f);
76
76
  if (p.helpersFile)
77
77
  watch(p.helpersFile);
78
+ if (p.actionsFile)
79
+ watch(p.actionsFile);
78
80
  const errors = out.diagnostics.filter((d) => d.severity === 'error');
79
81
  for (const d of out.diagnostics)
80
82
  if (d.severity === 'warning')
@@ -148,7 +148,7 @@ Error, for example:
148
148
 
149
149
  > `$meta.contacts.loadng` is not a status field. Did you mean `loading`?
150
150
 
151
- `$meta.<resource>` has a fixed set of fields: `loading`, `error`, `loadedAt`, `pending`, `total`, and with `paginate` also `page`, `pages`, `from`, `to`, `hasPrev`, `hasNext`.
151
+ `$meta.<resource>` has a fixed set of fields: `loading`, `error`, `loadedAt`, `pending`, `total`, and with `paginate` also `pages`, `from`, `to`, `hasPrev`, `hasNext`; `page` with `paginate="offset"`, or keyset with `offset-param`.
152
152
 
153
153
  **Fix:** Use the suggested field. A pagination field needs `paginate` on the resource.
154
154
 
@@ -156,11 +156,11 @@ Error, for example:
156
156
 
157
157
  Error, for example:
158
158
 
159
- > keyset pagination cannot jump to a page number. Use `paginate="offset"`
159
+ > keyset pagination cannot jump to a page number. Add `offset-param` if the server also accepts an offset, or use `paginate="offset"`
160
160
 
161
- Keyset (cursor) pagination only knows the next and previous page, so `page('name', 7)` has nothing to jump to.
161
+ Keyset (cursor) pagination only knows the next and previous page, so `page('name', 7)` has nothing to jump to, unless the server can also skip items by offset.
162
162
 
163
- **Fix:** Use `paginate="offset"` if the API supports offsets, or only step with `next`, `prev` and `first`.
163
+ **Fix:** If the API accepts an offset next to its cursor, add `offset-param="offset"`: the jump is sent as an offset and `prev`/`next` continue by cursor. Otherwise use `paginate="offset"`, or only step with `first`, `prev`, `next` and `last`.
164
164
 
165
165
  ## SN305 include-not-found
166
166
 
@@ -202,6 +202,16 @@ The resource matched an operation in the OpenAPI spec, but its `items` or `total
202
202
 
203
203
  **Fix:** Fix the path in `items` or `total`, or the spec if the API changed.
204
204
 
205
+ ## SN310 unknown-action
206
+
207
+ Error, for example:
208
+
209
+ > `pgp.decrpyt` is not exported by the actions module. Did you mean `pgp.decrypt`?
210
+
211
+ `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`.
212
+
213
+ **Fix:** Use the suggested name, or export the function from the actions module.
214
+
205
215
  ## SN401 parse-error
206
216
 
207
217
  Error, for example:
@@ -250,7 +260,7 @@ Warning, for example:
250
260
 
251
261
  The type checker has no type for the resource, so expressions that use it are not checked. Reported by `sinter check` only.
252
262
 
253
- **Fix:** Add `type="Contact[]"` with a type from `src/sinter.types.ts`, configure an OpenAPI spec that matches `src`, or write `type="any"` to opt out.
263
+ **Fix:** Add `type="Contact[]"` with a type from `src/sinter.types.ts`, configure an OpenAPI spec that matches `src`, load it with `call` (typed by what the action returns), or write `type="any"` to opt out.
254
264
 
255
265
  ## SN505 unknown-query-param
256
266
 
@@ -11,6 +11,12 @@
11
11
  {
12
12
  "name": "src"
13
13
  },
14
+ {
15
+ "name": "call"
16
+ },
17
+ {
18
+ "name": "each"
19
+ },
14
20
  {
15
21
  "name": "key"
16
22
  },
@@ -41,9 +47,15 @@
41
47
  {
42
48
  "name": "direction-param"
43
49
  },
50
+ {
51
+ "name": "direction-values"
52
+ },
44
53
  {
45
54
  "name": "page-param"
46
55
  },
56
+ {
57
+ "name": "offset-param"
58
+ },
47
59
  {
48
60
  "name": "type"
49
61
  },
@@ -174,6 +186,10 @@
174
186
  "name": "data-sn-delete",
175
187
  "description": "DELETE action (URL template)"
176
188
  },
189
+ {
190
+ "name": "data-sn-call",
191
+ "description": "local action: calls a function from the actions module, e.g. pgp.decrypt(msg) (action call)"
192
+ },
177
193
  {
178
194
  "name": "data-sn-body",
179
195
  "description": "JSON request body; overrides form serialization (value expression)"
@@ -184,7 +200,7 @@
184
200
  },
185
201
  {
186
202
  "name": "data-sn-debounce",
187
- "description": "delays the trigger, resets on each event (milliseconds)"
203
+ "description": "delays the trigger, resets on each event; without a method, delays the element's data-sn-on-* handlers (milliseconds)"
188
204
  },
189
205
  {
190
206
  "name": "data-sn-concurrency",
package/llms.txt CHANGED
@@ -24,9 +24,10 @@ Package: @tiaforge-ui/sinter. Every directive is a `data-sn-*` attribute or an `
24
24
  - `data-sn-put` (URL template): PUT action
25
25
  - `data-sn-patch` (URL template): PATCH action
26
26
  - `data-sn-delete` (URL template): DELETE action
27
+ - `data-sn-call` (action call): local action: calls a function from the actions module, e.g. pgp.decrypt(msg)
27
28
  - `data-sn-body` (value expression): JSON request body; overrides form serialization
28
29
  - `data-sn-trigger` (DOM event name): overrides the default trigger
29
- - `data-sn-debounce` (milliseconds): delays the trigger, resets on each event
30
+ - `data-sn-debounce` (milliseconds): delays the trigger, resets on each event; without a method, delays the element's data-sn-on-* handlers
30
31
  - `data-sn-concurrency` (block | queue | latest): mutations only; default block for forms, queue otherwise
31
32
  - `data-sn-optimistic` (handler expression): patch/upsert/remove calls applied before the request, rolled back on failure
32
33
  - `data-sn-then` (handler expression): runs after a 2xx response; $result = parsed body
@@ -37,7 +38,7 @@ Package: @tiaforge-ui/sinter. Every directive is a `data-sn-*` attribute or an `
37
38
 
38
39
  ## Elements
39
40
 
40
- - `<sn-resource>` `name`, `src`, `key`, `items`, `total`, `order`, `when`, `paginate`, `page-size`, `size-param`, `cursor`, `direction-param`, `page-param`, `type`, `cache`: a named, page-wide cache of server data; `cache` (or `cache="10m"`) shows the data from the last page load at once, then revalidates
41
+ - `<sn-resource>` `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`: a named, page-wide cache of server data; `cache` (or `cache="10m"`) shows the data from the last page load at once, then revalidates
41
42
  - `<sn-sync>` `src`: one server event stream
42
43
  - `<sn-event>` `name`, `resource`, `op`, `item`, `set`, `when`, `fallback`, `type`: maps one server event to a change message; child of <sn-sync>
43
44
  - `<sn-include>` `src`: inlines a partial at build time
@@ -63,7 +64,7 @@ Package: @tiaforge-ui/sinter. Every directive is a `data-sn-*` attribute or an `
63
64
 
64
65
  ## Diagnostics
65
66
 
66
- `SN101 unknown-attribute`, `SN102 wrong-element`, `SN103 missing-key`, `SN104 unsupported-model`, `SN201 unknown-identifier`, `SN202 forbidden-global`, `SN203 side-effect`, `SN204 optimistic-scope`, `SN205 resource-mutation`, `SN206 unknown-namespace-member`, `SN207 unsafe-sink`, `SN208 type-error`, `SN301 unknown-resource`, `SN302 duplicate-resource`, `SN303 unknown-meta-field`, `SN304 keyset-jump`, `SN305 include-not-found`, `SN306 include-cycle`, `SN307 openapi-invalid`, `SN308 unknown-schema-field`, `SN401 parse-error`, `SN501 unused-resource`, `SN502 non-reactive-value`, `SN503 data-bound-id`, `SN504 untyped-resource`, `SN505 unknown-query-param`, `SN506 untyped-event`.
67
+ `SN101 unknown-attribute`, `SN102 wrong-element`, `SN103 missing-key`, `SN104 unsupported-model`, `SN201 unknown-identifier`, `SN202 forbidden-global`, `SN203 side-effect`, `SN204 optimistic-scope`, `SN205 resource-mutation`, `SN206 unknown-namespace-member`, `SN207 unsafe-sink`, `SN208 type-error`, `SN301 unknown-resource`, `SN302 duplicate-resource`, `SN303 unknown-meta-field`, `SN304 keyset-jump`, `SN305 include-not-found`, `SN306 include-cycle`, `SN307 openapi-invalid`, `SN308 unknown-schema-field`, `SN310 unknown-action`, `SN401 parse-error`, `SN501 unused-resource`, `SN502 non-reactive-value`, `SN503 data-bound-id`, `SN504 untyped-resource`, `SN505 unknown-query-param`, `SN506 untyped-event`.
67
68
 
68
69
  ## Recipes
69
70
 
@@ -502,6 +503,106 @@ test('the next page load shows the cached list while it revalidates', async () =
502
503
  });
503
504
  ```
504
505
 
506
+ ### Local actions: hash a file, then upload it
507
+
508
+ ```html
509
+ <section data-sn-state="{ file: null, url: '', error: '' }">
510
+ <sn-resource name="digest" call="sha256(file)" when="file"></sn-resource>
511
+
512
+ <input type="file" data-sn-on-change="file = $el.files[0] ?? null; url = ''">
513
+ <p data-sn-show="$meta.digest.loading">Hashing…</p>
514
+ <code data-sn-text="digest"></code>
515
+
516
+ <button data-sn-call="upload(file, digest)" data-sn-attr="{disabled: !digest}"
517
+ data-sn-then="url = $result.url; error = ''" data-sn-catch="error = $error.message">Upload</button>
518
+ <a data-sn-show="url" data-sn-attr="{href: url}">Uploaded</a>
519
+ <p role="alert" data-sn-text="error"></p>
520
+ </section>
521
+ ```
522
+
523
+ ```js
524
+ // src/sinter.actions.js: functions pages call with data-sn-call and <sn-resource call>.
525
+ // The last argument is sinter's context; with JSDoc types, `sinter check` checks the calls.
526
+ /** @import { CallContext } from '@tiaforge-ui/sinter' */
527
+
528
+ /**
529
+ * The file's SHA-256 as hex; choosing another file aborts the running hash through ctx.signal.
530
+ * @param {File} file
531
+ * @param {CallContext} ctx
532
+ * @returns {Promise<string>}
533
+ */
534
+ export async function sha256(file, ctx) {
535
+ const bytes = await file.arrayBuffer();
536
+ ctx.signal.throwIfAborted();
537
+ const hash = await crypto.subtle.digest('SHA-256', bytes);
538
+ return [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, '0')).join('');
539
+ }
540
+
541
+ /**
542
+ * ctx.request goes through the app's middleware (headers, 401 handling, allowed origins).
543
+ * @param {File} file
544
+ * @param {string} digest
545
+ * @param {CallContext} ctx
546
+ * @returns {Promise<{ url: string }>}
547
+ */
548
+ export async function upload(file, digest, ctx) {
549
+ const form = new FormData();
550
+ form.append('file', file);
551
+ form.append('sha256', digest);
552
+ return /** @type {Promise<{ url: string }>} */ (ctx.request('/api/uploads', { method: 'POST', body: form }));
553
+ }
554
+ ```
555
+
556
+ ```ts
557
+ import { expect, test } from 'vitest';
558
+ import { checkPage } from '@tiaforge-ui/sinter/check';
559
+ import { render, mockRequests } from '@tiaforge-ui/sinter/testing';
560
+ import * as actions from './local-actions.actions.js';
561
+ import source from './local-actions.actions.js?raw';
562
+ import page from './local-actions.html?raw';
563
+
564
+ const DIGEST = 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'; // sha256('abc')
565
+
566
+ function choose(input: HTMLInputElement, file: File) {
567
+ Object.defineProperty(input, 'files', { value: [file], configurable: true });
568
+ input.dispatchEvent(new Event('change', { bubbles: true }));
569
+ }
570
+
571
+ test('hashes the chosen file, then uploads it with its digest', async () => {
572
+ const requests = mockRequests({ 'POST /api/uploads': { url: '/files/1' } });
573
+ const { root, settle } = await render(page, { actions, requests });
574
+ const button = root.querySelector('button')!;
575
+ expect(button.disabled).toBe(true);
576
+
577
+ choose(root.querySelector('input')!, new File(['abc'], 'abc.txt'));
578
+ await settle();
579
+ expect(root.querySelector('code')!.textContent).toBe(DIGEST);
580
+ expect(button.disabled).toBe(false);
581
+
582
+ button.click();
583
+ await settle();
584
+ expect((requests.calls[0]!.body as FormData).get('sha256')).toBe(DIGEST);
585
+ expect(root.querySelector('a')!.getAttribute('href')).toBe('/files/1');
586
+ });
587
+
588
+ test('a failed upload shows the server message', async () => {
589
+ const requests = mockRequests({ 'POST /api/uploads': { status: 413, body: { message: 'File too large' } } });
590
+ const { root, settle } = await render(page, { actions, requests });
591
+ choose(root.querySelector('input')!, new File(['abc'], 'abc.txt'));
592
+ await settle();
593
+ root.querySelector('button')!.click();
594
+ await settle();
595
+ expect(root.querySelector('[role=alert]')!.textContent).toBe('File too large');
596
+ });
597
+
598
+ test('sinter check: arguments are checked against the actions', () => {
599
+ const files = { 'src/sinter.actions.js': source };
600
+ expect(checkPage(page, { files })).toEqual([]);
601
+ const [d] = checkPage(page.replace('upload(file, digest)', 'upload(file)'), { files });
602
+ expect([d!.code, d!.message]).toEqual(['SN208', expect.stringContaining("is not assignable to type '[file: File, digest: string]'")]);
603
+ });
604
+ ```
605
+
505
606
  ## Type checking
506
607
 
507
608
  `npx sinter check` type-checks every expression with the project's TypeScript (pages and options come from the Vite config). `sinter({ typecheck: true })` runs it in `vite build`.
@@ -525,7 +626,7 @@ Each open EventSource holds one HTTP connection, and HTTP/1.1 allows 6 per origi
525
626
 
526
627
  Write a test next to the markup. `@tiaforge-ui/sinter/testing` compiles the page with the real compiler and mounts it:
527
628
 
528
- - `render(html, { requests, streams, url, locale, now, helpers, files, storage, session })` → `{ root, app, flush, settle, advance, navigations, window }`. Pass one `memoryStorage()` as `session` to two renders to test what carries over between page loads (the `cache` attribute).
629
+ - `render(html, { requests, streams, url, locale, now, helpers, actions, files, storage, session })` → `{ root, app, flush, settle, advance, navigations, window }`. Pass one `memoryStorage()` as `session` to two renders to test what carries over between page loads (the `cache` attribute).
529
630
  - `mockRequests({ 'GET /api/x': json | {status, body} | (call) => … })`: records `calls`; responses are held until `settle()`; an unmatched request fails the test and lists the routes.
530
631
  - `mockStream()`: `emit(name, payload)`, `drop()`, `connected`. A stream URL with no mock uses the transport from `configure({ transport })`, so a custom transport can be tested over a fake connection (see the SignalR recipe).
531
632
  - `flush()` runs the scheduler (optimistic changes are visible), `settle()` finishes requests, queues and debounces, `advance(ms)` moves sinter's clock.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiaforge-ui/sinter",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Bind JSON APIs to plain HTML: keyed resources, SSE sync, template bindings and optimistic mutations, compiled at build time.",
5
5
  "license": "MIT",
6
6
  "author": "Tia Heinrich",
@@ -36,6 +36,7 @@
36
36
  "./ws": "./dist/ws.js",
37
37
  "./optimistic": "./dist/optimistic.js",
38
38
  "./paginate": "./dist/paginate.js",
39
+ "./each": "./dist/each.js",
39
40
  "./persist": "./dist/persist.js",
40
41
  "./cache": "./dist/cache.js",
41
42
  "./compiler": "./dist/compiler/index.js",
@@ -57,11 +58,13 @@
57
58
  "build": "tsc -p tsconfig.build.json",
58
59
  "typecheck": "tsc -p tsconfig.json --noEmit",
59
60
  "test": "vitest run",
61
+ "bench": "vitest run --config bench/runtime/vitest.config.ts",
60
62
  "size": "npm run build && node scripts/size.mjs",
61
63
  "docs": "UPDATE_DOCS=1 vitest run test/docs.test.ts",
62
64
  "prepublishOnly": "rm -rf dist && npm run build && npm test"
63
65
  },
64
66
  "dependencies": {
67
+ "@sveltejs/acorn-typescript": "^1.0.13",
65
68
  "acorn": "^8.19.0",
66
69
  "acorn-walk": "^8.3.5",
67
70
  "parse5": "^8.1.0",