@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.
@@ -2,7 +2,8 @@
2
2
  // comes only from configure() in JavaScript, never from the DOM.
3
3
  import { defaultEnv } from './env.js';
4
4
  export const config = {
5
- request: [], sessionProbe: null, onError: null, locale: null, urlSchemes: [], origins: [], transport: null, env: defaultEnv,
5
+ request: [], sessionProbe: null, onError: null, locale: null, urlSchemes: [], origins: [], transport: null,
6
+ confirm: (question) => globalThis.confirm(question), env: defaultEnv,
6
7
  };
7
8
  export function configure(options) {
8
9
  const { env, ...rest } = options;
@@ -14,10 +15,16 @@ export function configure(options) {
14
15
  export class SinterError extends Error {
15
16
  status;
16
17
  body;
17
- constructor(status, body, message) {
18
+ detail;
19
+ /**
20
+ * @param detail what went wrong in words: the server's message or text, or a thrown error's
21
+ * message; null when there is none (`message` then falls back to the HTTP status)
22
+ */
23
+ constructor(status, body, message, detail = null) {
18
24
  super(message);
19
25
  this.status = status;
20
26
  this.body = body;
27
+ this.detail = detail;
21
28
  this.name = 'SinterError';
22
29
  }
23
30
  }
@@ -44,7 +44,7 @@ export function number(v, opts) {
44
44
  const BYTE_UNITS = ['byte', 'kilobyte', 'megabyte', 'gigabyte', 'terabyte'];
45
45
  export function bytes(v) {
46
46
  let n = Number(v);
47
- if (v == null || Number.isNaN(n))
47
+ if (v == null || v === '' || Number.isNaN(n))
48
48
  return '';
49
49
  let i = 0;
50
50
  while (Math.abs(n) >= 1024 && i < BYTE_UNITS.length - 1) {
@@ -3,7 +3,7 @@ import type { Env } from './env.js';
3
3
  export declare const TIMEOUT = 30000;
4
4
  export interface SendInit {
5
5
  method?: string;
6
- /** JSON-serialized unless FormData */
6
+ /** JSON-serialized unless FormData or URLSearchParams (sent form-encoded) */
7
7
  body?: unknown;
8
8
  headers?: Record<string, string>;
9
9
  signal?: AbortSignal | undefined;
@@ -44,14 +44,18 @@ export function createRequestLayer(env, chain) {
44
44
  let body = init.body;
45
45
  if (body == null || method == 'GET')
46
46
  body = undefined;
47
+ else if (body instanceof URLSearchParams) {
48
+ body = body.toString();
49
+ headers.set('Content-Type', 'application/x-www-form-urlencoded;charset=UTF-8');
50
+ }
47
51
  else if (!(body instanceof FormData)) {
48
52
  body = JSON.stringify(body);
49
53
  headers.set('Content-Type', 'application/json');
50
54
  }
51
55
  headers.has('Accept') || headers.set('Accept', 'application/json');
52
56
  // mutations outlive the page: a navigation right after a click must not cancel the write
53
- // (keepalive bodies are limited to 64 KB; larger ones and uploads go without it)
54
- const keepalive = method != 'GET' && method != 'HEAD' && (body == null || (typeof body == 'string' && body.length < 60_000));
57
+ // (keepalive bodies are limited to 64 KiB; larger ones and uploads go without it)
58
+ const keepalive = method != 'GET' && method != 'HEAD' && (body == null || (typeof body == 'string' && bytes(body) < 60_000));
55
59
  inflight++;
56
60
  try {
57
61
  const res = await run(new Request(target, { method, headers, body: body ?? null, credentials: 'same-origin', signal: init.signal ?? null, keepalive }));
@@ -71,12 +75,15 @@ export function createRequestLayer(env, chain) {
71
75
  const res = await raw(url, init), body = await parseBody(res);
72
76
  if (!res.ok) {
73
77
  const b = body, m = typeof body == 'string' ? body.length < 200 && body.trim() : b?.message ?? b?.error;
74
- throw new SinterError(res.status, body, typeof m == 'string' && m ? m : `${res.status} ${res.statusText}`.trim());
78
+ const detail = typeof m == 'string' && m ? m : null;
79
+ throw new SinterError(res.status, body, detail ?? `${res.status} ${res.statusText}`.trim(), detail);
75
80
  }
76
81
  return body;
77
82
  }
78
83
  return { send, raw, get inflight() { return inflight; } };
79
84
  }
85
+ /** UTF-8 size of a string; a short one cannot reach the limit (at most 3 bytes per UTF-16 unit). */
86
+ const bytes = (s) => (s.length < 20_000 ? s.length : new TextEncoder().encode(s).length);
80
87
  /** 204 or empty → null; JSON content type → parsed; anything else → text. */
81
88
  export async function parseBody(res) {
82
89
  const text = res.status == 204 ? '' : await res.text();
@@ -160,7 +160,8 @@ export function createResource(name, o) {
160
160
  }
161
161
  };
162
162
  function visible() {
163
- if (view)
163
+ // a locale change re-sorts strings: a view sorted for another locale is stale
164
+ if (view && !(coll && field && o.locale?.() !== sortedFor))
164
165
  return view;
165
166
  if (!coll)
166
167
  return (view = { l: [], get: () => undefined, s: over ? over.s : single });
package/dist/sse.js CHANGED
@@ -199,10 +199,13 @@ class Connection {
199
199
  const sse = {
200
200
  name: 'sse',
201
201
  install(app) {
202
- // connect after mount has created resources and loaders
202
+ // connect after mount has created resources and loaders, unless the app is gone by then
203
+ let alive = true;
204
+ app.addCleanup(() => { alive = false; });
203
205
  app.env.queueMicrotask(() => {
204
- for (const def of app.ir.syncs)
205
- new Connection(app, def).start();
206
+ if (alive)
207
+ for (const def of app.ir.syncs)
208
+ new Connection(app, def).start();
206
209
  });
207
210
  },
208
211
  };
@@ -13,8 +13,9 @@ import optimistic from '../optimistic.js';
13
13
  import paginate from '../paginate.js';
14
14
  import each from '../each.js';
15
15
  import persist from '../persist.js';
16
+ import listbox from '../listbox.js';
16
17
  import sse from '../sse.js';
17
- const FEATURES = { dev, sse, optimistic, paginate, persist, cache, each };
18
+ const FEATURES = { dev, sse, optimistic, paginate, persist, cache, each, listbox };
18
19
  function isResponseSpec(v) {
19
20
  if (!v || typeof v !== 'object' || Array.isArray(v))
20
21
  return false;
@@ -88,7 +88,7 @@ Error, for example:
88
88
 
89
89
  > `m.is_read = true` mutates a resource. Use `patch('messages', m, {is_read: true})`
90
90
 
91
- Resource data is read-only in expressions: it mirrors the server, and a direct write would be lost on the next refresh or event.
91
+ 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.
92
92
 
93
93
  **Fix:** Use the `patch()` call from the message, or copy the item with `structuredClone(c)` to edit it in state.
94
94
 
@@ -108,9 +108,9 @@ Error, for example:
108
108
 
109
109
  > `onclick` cannot be bound with `data-sn-attr`; use `data-sn-on-click`
110
110
 
111
- 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.
111
+ 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.
112
112
 
113
- **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.
113
+ **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.
114
114
 
115
115
  ## SN208 type-error
116
116
 
@@ -122,6 +122,16 @@ TypeScript found a type error in an expression. Reported by `sinter check`, `sin
122
122
 
123
123
  **Fix:** Fix the expression, or the type: `type` on `<sn-resource>`, the OpenAPI spec, or a JSDoc cast in `data-sn-state`.
124
124
 
125
+ ## SN209 name-conflict
126
+
127
+ Error, for example:
128
+
129
+ > `s` is already defined here by `data-sn-let`; use another name
130
+
131
+ A `data-sn-let` name is inlined wherever it is read, so it cannot share a name with a state variable or loop variable in its scope, or be redeclared inside it. A resource cannot take the name of a store function (`refresh`, `upsert`, `patch`, `remove`, `page`, `showModal`, `close`, `reset`, `emit`).
132
+
133
+ **Fix:** Rename the let, the state or loop variable, or the resource.
134
+
125
135
  ## SN301 unknown-resource
126
136
 
127
137
  Error, for example:
@@ -202,6 +212,16 @@ The resource matched an operation in the OpenAPI spec, but its `items` or `total
202
212
 
203
213
  **Fix:** Fix the path in `items` or `total`, or the spec if the API changed.
204
214
 
215
+ ## SN309 unknown-dialog
216
+
217
+ Error, for example:
218
+
219
+ > no `<dialog id="compose-dialg">` on this page. Did you mean `compose-dialog`?
220
+
221
+ `showModal(id)` and `close(id)` name a `<dialog>` by its literal `id`, which must be on the page or one of its partials.
222
+
223
+ **Fix:** Fix the id, or add `id` to the `<dialog>`. `close()` without an argument closes the dialog around the element.
224
+
205
225
  ## SN310 unknown-action
206
226
 
207
227
  Error, for example:
@@ -118,6 +118,10 @@
118
118
  "name": "data-sn-state",
119
119
  "description": "declares local reactive state for the element and its subtree (object literal)"
120
120
  },
121
+ {
122
+ "name": "data-sn-let",
123
+ "description": "derived names for the element and its subtree, computed where they are read; later names may use earlier ones (object literal {name: expression})"
124
+ },
121
125
  {
122
126
  "name": "data-sn-text",
123
127
  "description": "sets textContent; never HTML (value expression)"
@@ -162,6 +166,10 @@
162
166
  "name": "data-sn-else",
163
167
  "description": "on a <template> directly after an if template"
164
168
  },
169
+ {
170
+ "name": "data-sn-listbox",
171
+ "description": "on a text input: arrow keys mark a [role=option] of that list (empty: the nearest [role=listbox] around the input), Enter or Tab clicks it, Escape fires `dismiss` (id of the suggestion list, or empty)"
172
+ },
165
173
  {
166
174
  "name": "data-sn-persist",
167
175
  "description": "persists the model or state in localStorage under sn: (storage key)"
@@ -216,7 +224,15 @@
216
224
  },
217
225
  {
218
226
  "name": "data-sn-catch",
219
- "description": "runs after an error; $error = {status, body, message} (handler expression)"
227
+ "description": "runs after an error; $error = {status, body, message, detail} (handler expression)"
228
+ },
229
+ {
230
+ "name": "data-sn-confirm",
231
+ "description": "asks this question before the action runs (configure({ confirm }), window.confirm by default); no question when falsy (value expression)"
232
+ },
233
+ {
234
+ "name": "data-sn-encoding",
235
+ "description": "form: the body (data-sn-body, arrays as repeated keys, or the form) goes as application/x-www-form-urlencoded (json | form)"
220
236
  },
221
237
  {
222
238
  "name": "data-sn-path",
@@ -245,6 +261,18 @@
245
261
  {
246
262
  "name": "data-sn-on-keydown",
247
263
  "description": "plain event handler, no request; e.g. data-sn-on-click"
264
+ },
265
+ {
266
+ "name": "data-sn-debounce-input",
267
+ "description": "delays only that event's data-sn-on-<event> handler (it runs with the last event); e.g. data-sn-debounce-input"
268
+ },
269
+ {
270
+ "name": "data-sn-debounce-keyup",
271
+ "description": "delays only that event's data-sn-on-<event> handler (it runs with the last event); e.g. data-sn-debounce-input"
272
+ },
273
+ {
274
+ "name": "data-sn-debounce-scroll",
275
+ "description": "delays only that event's data-sn-on-<event> handler (it runs with the last event); e.g. data-sn-debounce-input"
248
276
  }
249
277
  ]
250
278
  }
package/llms.txt CHANGED
@@ -7,6 +7,7 @@ Package: @tiaforge-ui/sinter. Every directive is a `data-sn-*` attribute or an `
7
7
  ## Attributes
8
8
 
9
9
  - `data-sn-state` (object literal): declares local reactive state for the element and its subtree
10
+ - `data-sn-let` (object literal {name: expression}): derived names for the element and its subtree, computed where they are read; later names may use earlier ones
10
11
  - `data-sn-text` (value expression): sets textContent; never HTML
11
12
  - `data-sn-attr` (object literal {href: …}): literal keys; null/false removes, true sets empty; URL values are checked
12
13
  - `data-sn-class` (object literal {unread: …}): toggles each class
@@ -18,6 +19,7 @@ Package: @tiaforge-ui/sinter. Every directive is a `data-sn-*` attribute or an `
18
19
  - `data-sn-key` (value expression): row identity; defaults to the resource key or object identity
19
20
  - `data-sn-if` (value expression): renders a <template> while truthy
20
21
  - `data-sn-else`: on a <template> directly after an if template
22
+ - `data-sn-listbox` (id of the suggestion list, or empty): on a text input: arrow keys mark a [role=option] of that list (empty: the nearest [role=listbox] around the input), Enter or Tab clicks it, Escape fires `dismiss`
21
23
  - `data-sn-persist` (storage key): persists the model or state in localStorage under sn:
22
24
  - `data-sn-get` (URL template): GET action; latest wins
23
25
  - `data-sn-post` (URL template): POST action
@@ -31,10 +33,13 @@ Package: @tiaforge-ui/sinter. Every directive is a `data-sn-*` attribute or an `
31
33
  - `data-sn-concurrency` (block | queue | latest): mutations only; default block for forms, queue otherwise
32
34
  - `data-sn-optimistic` (handler expression): patch/upsert/remove calls applied before the request, rolled back on failure
33
35
  - `data-sn-then` (handler expression): runs after a 2xx response; $result = parsed body
34
- - `data-sn-catch` (handler expression): runs after an error; $error = {status, body, message}
36
+ - `data-sn-catch` (handler expression): runs after an error; $error = {status, body, message, detail}
37
+ - `data-sn-confirm` (value expression): asks this question before the action runs (configure({ confirm }), window.confirm by default); no question when falsy
38
+ - `data-sn-encoding` (json | form): form: the body (data-sn-body, arrays as repeated keys, or the form) goes as application/x-www-form-urlencoded
35
39
  - `data-sn-path` (URL patterns /a/:b …): on <html>: the path shapes this page is served for; `:name` segments appear read-only in $url
36
40
  - `data-sn-data` (resource name): on <script type="application/json"> in <head>: initial data for a resource
37
41
  - `data-sn-on-<event>` (handler expression): plain event handler, no request; e.g. data-sn-on-click
42
+ - `data-sn-debounce-<event>` (milliseconds): delays only that event's data-sn-on-<event> handler (it runs with the last event); e.g. data-sn-debounce-input
38
43
 
39
44
  ## Elements
40
45
 
@@ -60,11 +65,11 @@ Package: @tiaforge-ui/sinter. Every directive is a `data-sn-*` attribute or an `
60
65
 
61
66
  ## Store functions
62
67
 
63
- `refresh()`, `upsert()`, `patch()`, `remove()`, `page()`, `close()`, `reset()`, `emit()`: `refresh('name')`, `upsert('name', item)`, `patch('name', item, {…})`, `remove('name', item)`, `page('name', 'next' | 'prev' | 'first' | 'last' | n)`, `close()` the nearest dialog, `reset()` the nearest form, `emit('event', detail)`.
68
+ `refresh()`, `upsert()`, `patch()`, `remove()`, `page()`, `showModal()`, `close()`, `reset()`, `emit()`: `refresh('name')`, `upsert('name', item)`, `patch('name', item, {…})`, `remove('name', item)`, `page('name', 'next' | 'prev' | 'first' | 'last' | n)`, `showModal('id')` opens a dialog by its literal id, `close()` the nearest dialog or `close('id')` that one, `reset()` the nearest form, `emit('event', detail)`.
64
69
 
65
70
  ## Diagnostics
66
71
 
67
- `SN101 unknown-attribute`, `SN102 wrong-element`, `SN103 missing-key`, `SN104 unsupported-model`, `SN201 unknown-identifier`, `SN202 forbidden-global`, `SN203 side-effect`, `SN204 optimistic-scope`, `SN205 resource-mutation`, `SN206 unknown-namespace-member`, `SN207 unsafe-sink`, `SN208 type-error`, `SN301 unknown-resource`, `SN302 duplicate-resource`, `SN303 unknown-meta-field`, `SN304 keyset-jump`, `SN305 include-not-found`, `SN306 include-cycle`, `SN307 openapi-invalid`, `SN308 unknown-schema-field`, `SN310 unknown-action`, `SN401 parse-error`, `SN501 unused-resource`, `SN502 non-reactive-value`, `SN503 data-bound-id`, `SN504 untyped-resource`, `SN505 unknown-query-param`, `SN506 untyped-event`.
72
+ `SN101 unknown-attribute`, `SN102 wrong-element`, `SN103 missing-key`, `SN104 unsupported-model`, `SN201 unknown-identifier`, `SN202 forbidden-global`, `SN203 side-effect`, `SN204 optimistic-scope`, `SN205 resource-mutation`, `SN206 unknown-namespace-member`, `SN207 unsafe-sink`, `SN208 type-error`, `SN209 name-conflict`, `SN301 unknown-resource`, `SN302 duplicate-resource`, `SN303 unknown-meta-field`, `SN304 keyset-jump`, `SN305 include-not-found`, `SN306 include-cycle`, `SN307 openapi-invalid`, `SN308 unknown-schema-field`, `SN309 unknown-dialog`, `SN310 unknown-action`, `SN401 parse-error`, `SN501 unused-resource`, `SN502 non-reactive-value`, `SN503 data-bound-id`, `SN504 untyped-resource`, `SN505 unknown-query-param`, `SN506 untyped-event`.
68
73
 
69
74
  ## Recipes
70
75
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiaforge-ui/sinter",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Bind JSON APIs to plain HTML: keyed resources, SSE sync, template bindings and optimistic mutations, compiled at build time.",
5
5
  "license": "MIT",
6
6
  "author": "Tia Heinrich",
@@ -38,6 +38,7 @@
38
38
  "./paginate": "./dist/paginate.js",
39
39
  "./each": "./dist/each.js",
40
40
  "./persist": "./dist/persist.js",
41
+ "./listbox": "./dist/listbox.js",
41
42
  "./cache": "./dist/cache.js",
42
43
  "./compiler": "./dist/compiler/index.js",
43
44
  "./vite": "./dist/vite.js",
@@ -61,6 +62,7 @@
61
62
  "bench": "vitest run --config bench/runtime/vitest.config.ts",
62
63
  "size": "npm run build && node scripts/size.mjs",
63
64
  "docs": "UPDATE_DOCS=1 vitest run test/docs.test.ts",
65
+ "release": "node scripts/release.mjs",
64
66
  "prepublishOnly": "rm -rf dist && npm run build && npm test"
65
67
  },
66
68
  "dependencies": {