@tiaforge-ui/sinter 0.0.0-stage → 0.1.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 (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +132 -2
  3. package/dist/cache.d.ts +5 -0
  4. package/dist/cache.js +95 -0
  5. package/dist/check/index.d.ts +82 -0
  6. package/dist/check/index.js +156 -0
  7. package/dist/check/openapi-types.d.ts +6 -0
  8. package/dist/check/openapi-types.js +84 -0
  9. package/dist/check/shadow.d.ts +47 -0
  10. package/dist/check/shadow.js +245 -0
  11. package/dist/cli.d.ts +2 -0
  12. package/dist/cli.js +67 -0
  13. package/dist/compiler/check-unit.d.ts +97 -0
  14. package/dist/compiler/check-unit.js +3 -0
  15. package/dist/compiler/compile.d.ts +62 -0
  16. package/dist/compiler/compile.js +1133 -0
  17. package/dist/compiler/diagnostic-docs.d.ts +10 -0
  18. package/dist/compiler/diagnostic-docs.js +137 -0
  19. package/dist/compiler/diagnostics.d.ts +56 -0
  20. package/dist/compiler/diagnostics.js +76 -0
  21. package/dist/compiler/expr.d.ts +89 -0
  22. package/dist/compiler/expr.js +838 -0
  23. package/dist/compiler/index.d.ts +21 -0
  24. package/dist/compiler/index.js +106 -0
  25. package/dist/compiler/openapi.d.ts +40 -0
  26. package/dist/compiler/openapi.js +177 -0
  27. package/dist/compiler/vocabulary.d.ts +12 -0
  28. package/dist/compiler/vocabulary.js +38 -0
  29. package/dist/dev.d.ts +10 -0
  30. package/dist/dev.js +92 -0
  31. package/dist/index.d.ts +7 -0
  32. package/dist/index.js +4 -0
  33. package/dist/ir.d.ts +127 -0
  34. package/dist/ir.js +121 -0
  35. package/dist/lsp/quickfix.d.ts +15 -0
  36. package/dist/lsp/quickfix.js +58 -0
  37. package/dist/lsp/rpc.d.ts +31 -0
  38. package/dist/lsp/rpc.js +120 -0
  39. package/dist/lsp/server.d.ts +36 -0
  40. package/dist/lsp/server.js +281 -0
  41. package/dist/lsp/spec-location.d.ts +9 -0
  42. package/dist/lsp/spec-location.js +42 -0
  43. package/dist/lsp/typescript.d.ts +77 -0
  44. package/dist/lsp/typescript.js +242 -0
  45. package/dist/lsp/workspace.d.ts +56 -0
  46. package/dist/lsp/workspace.js +227 -0
  47. package/dist/optimistic.d.ts +3 -0
  48. package/dist/optimistic.js +15 -0
  49. package/dist/paginate.d.ts +3 -0
  50. package/dist/paginate.js +184 -0
  51. package/dist/persist.d.ts +3 -0
  52. package/dist/persist.js +55 -0
  53. package/dist/project.d.ts +36 -0
  54. package/dist/project.js +100 -0
  55. package/dist/runtime/app.d.ts +125 -0
  56. package/dist/runtime/app.js +1183 -0
  57. package/dist/runtime/config.d.ts +53 -0
  58. package/dist/runtime/config.js +118 -0
  59. package/dist/runtime/env.d.ts +18 -0
  60. package/dist/runtime/env.js +23 -0
  61. package/dist/runtime/helpers.d.ts +9 -0
  62. package/dist/runtime/helpers.js +55 -0
  63. package/dist/runtime/request.d.ts +23 -0
  64. package/dist/runtime/request.js +93 -0
  65. package/dist/runtime/security.d.ts +12 -0
  66. package/dist/runtime/security.js +34 -0
  67. package/dist/runtime/store.d.ts +95 -0
  68. package/dist/runtime/store.js +142 -0
  69. package/dist/sse.d.ts +7 -0
  70. package/dist/sse.js +206 -0
  71. package/dist/testing/index.d.ts +71 -0
  72. package/dist/testing/index.js +345 -0
  73. package/dist/vite.d.ts +11 -0
  74. package/dist/vite.js +94 -0
  75. package/dist/ws.d.ts +18 -0
  76. package/dist/ws.js +57 -0
  77. package/docs/diagnostics.md +273 -0
  78. package/html-custom-data.json +234 -0
  79. package/llms.txt +531 -0
  80. package/package.json +90 -4
package/llms.txt ADDED
@@ -0,0 +1,531 @@
1
+ # sinter
2
+
3
+ > sinter binds JSON APIs to plain HTML: keyed resources cached from the server, kept current from server events, rendered through <template> bindings, with optimistic mutations. A build step compiles annotated HTML into a page module; the browser runs no parser and no eval.
4
+
5
+ Package: @tiaforge-ui/sinter. Every directive is a `data-sn-*` attribute or an `<sn-*>` element. Anything else is a build error with a suggestion.
6
+
7
+ ## Attributes
8
+
9
+ - `data-sn-state` (object literal): declares local reactive state for the element and its subtree
10
+ - `data-sn-text` (value expression): sets textContent; never HTML
11
+ - `data-sn-attr` (object literal {href: …}): literal keys; null/false removes, true sets empty; URL values are checked
12
+ - `data-sn-class` (object literal {unread: …}): toggles each class
13
+ - `data-sn-style` (object literal {'--color': …}): sets each property with style.setProperty
14
+ - `data-sn-show` (value expression): toggles the hidden attribute
15
+ - `data-sn-prop` (object literal): sets JS properties; custom elements only
16
+ - `data-sn-model` (state path): two-way binding for form controls
17
+ - `data-sn-for` (item in list | (item, i) in list): repeats a <template> per item
18
+ - `data-sn-key` (value expression): row identity; defaults to the resource key or object identity
19
+ - `data-sn-if` (value expression): renders a <template> while truthy
20
+ - `data-sn-else`: on a <template> directly after an if template
21
+ - `data-sn-persist` (storage key): persists the model or state in localStorage under sn:
22
+ - `data-sn-get` (URL template): GET action; latest wins
23
+ - `data-sn-post` (URL template): POST action
24
+ - `data-sn-put` (URL template): PUT action
25
+ - `data-sn-patch` (URL template): PATCH action
26
+ - `data-sn-delete` (URL template): DELETE action
27
+ - `data-sn-body` (value expression): JSON request body; overrides form serialization
28
+ - `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-concurrency` (block | queue | latest): mutations only; default block for forms, queue otherwise
31
+ - `data-sn-optimistic` (handler expression): patch/upsert/remove calls applied before the request, rolled back on failure
32
+ - `data-sn-then` (handler expression): runs after a 2xx response; $result = parsed body
33
+ - `data-sn-catch` (handler expression): runs after an error; $error = {status, body, message}
34
+ - `data-sn-path` (URL patterns /a/:b …): on <html>: the path shapes this page is served for; `:name` segments appear read-only in $url
35
+ - `data-sn-data` (resource name): on <script type="application/json"> in <head>: initial data for a resource
36
+ - `data-sn-on-<event>` (handler expression): plain event handler, no request; e.g. data-sn-on-click
37
+
38
+ ## Elements
39
+
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-sync>` `src`: one server event stream
42
+ - `<sn-event>` `name`, `resource`, `op`, `item`, `set`, `when`, `fallback`, `type`: maps one server event to a change message; child of <sn-sync>
43
+ - `<sn-include>` `src`: inlines a partial at build time
44
+
45
+ ## Expressions and scope
46
+
47
+ - Attribute values are JavaScript expressions, checked at build time. Value expressions (text, attr, class, show, for, if, key, body, state, URL interpolations) cannot assign, use `++`, `delete`, `await` or `import()`.
48
+ - Handlers (`data-sn-on-*`, `then`, `catch`) are statements separated by `;`: assignments to state, `$url.<param>` and `$locale`, and calls. No declarations or loops.
49
+ - `data-sn-optimistic` may only call `patch`, `upsert`, `remove`.
50
+ - A name resolves innermost first: loop variables, then the nearest `data-sn-state`, then resources, then magic names, helpers and store functions, then JavaScript globals. Browser APIs (`window`, `document`, `fetch`, timers, …) are refused: use an island.
51
+ - Resources are read-only: copy an item with `structuredClone(c)` to edit it, write with `patch()`.
52
+ - Helpers are plain function calls, never pipes: `date()`, `time()`, `datetime()`, `relative()`, `number()`, `bytes()`. Helper namespaces: `m.inbox_title()`.
53
+ - URL templates: `/api/x?id={c.id}`; interpolations are URL-encoded; the template must start with a literal `/path` or `https://origin`.
54
+ - Use `$now` instead of `Date.now()` so bindings update.
55
+
56
+ ## Magic names
57
+
58
+ `$event`, `$el`, `$url`, `$now`, `$locale`, `$result`, `$error`, `$meta`. `$event` in handlers, `$result` in `then`, `$error` (`{status, body, message}`) in `catch`; `$meta.<resource>` has `loading`, `error`, `loadedAt`, `pending`, `total`, and with `paginate`: `page` (offset), `pages`, `from`, `to`, `hasPrev`, `hasNext`.
59
+
60
+ ## Store functions
61
+
62
+ `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)`.
63
+
64
+ ## Diagnostics
65
+
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
+
68
+ ## Recipes
69
+
70
+ Each recipe comes with its test; copy both.
71
+
72
+ ### List with filter
73
+
74
+ ```html
75
+ <sn-resource name="people" src="/api/people" items="people" key="id" order="name"></sn-resource>
76
+
77
+ <section data-sn-state="{ q: '' }">
78
+ <input type="search" placeholder="Search" data-sn-model="q">
79
+ <p data-sn-show="$meta.people.loading" aria-busy="true">Loading…</p>
80
+ <ul>
81
+ <template data-sn-for="p in people.filter(p => p.name.toLowerCase().includes(q.toLowerCase()))">
82
+ <li data-sn-text="p.name"></li>
83
+ </template>
84
+ </ul>
85
+ </section>
86
+ ```
87
+
88
+ ```ts
89
+ import { expect, test } from 'vitest';
90
+ import { render, mockRequests } from '@tiaforge-ui/sinter/testing';
91
+ import page from './list-filter.html?raw';
92
+
93
+
94
+ test('filters the list as you type', async () => {
95
+ const requests = mockRequests({ 'GET /api/people': { people: [{ id: 1, name: 'Bob' }, { id: 2, name: 'Ada' }] } });
96
+ const { root, flush, settle } = await render(page, { requests });
97
+ await settle();
98
+ expect([...root.querySelectorAll('li')].map((li) => li.textContent)).toEqual(['Ada', 'Bob']);
99
+
100
+ const input = root.querySelector('input')!;
101
+ input.value = 'ad';
102
+ input.dispatchEvent(new Event('input', { bubbles: true }));
103
+ await flush();
104
+ expect([...root.querySelectorAll('li')].map((li) => li.textContent)).toEqual(['Ada']);
105
+ });
106
+ ```
107
+
108
+ ### Form dialog with repeatable fields
109
+
110
+ ```html
111
+ <sn-resource name="people" src="/api/people" key="id" order="name"></sn-resource>
112
+
113
+ <section data-sn-state="{ draft: null }">
114
+ <button data-sn-on-click="draft = {name: '', emails: [{value: ''}]}" commandfor="edit" command="show-modal">New</button>
115
+ <ul>
116
+ <template data-sn-for="p in people">
117
+ <li>
118
+ <span data-sn-text="p.name"></span>
119
+ <button data-sn-on-click="draft = structuredClone(p)" commandfor="edit" command="show-modal">Edit</button>
120
+ </li>
121
+ </template>
122
+ </ul>
123
+
124
+ <dialog id="edit">
125
+ <form data-sn-post="/api/people" data-sn-body="draft"
126
+ data-sn-then="upsert('people', $result); draft = null; close()"
127
+ data-sn-catch="emit('toast', $error.message)">
128
+ <input data-sn-model="draft.name" required>
129
+ <template data-sn-for="(e, i) in draft.emails">
130
+ <input type="email" data-sn-model="draft.emails[i].value">
131
+ </template>
132
+ <button type="button" data-sn-on-click="draft.emails.push({value: ''})">Add email</button>
133
+ <button type="submit">Save</button>
134
+ </form>
135
+ </dialog>
136
+ </section>
137
+ ```
138
+
139
+ ```ts
140
+ import { expect, test } from 'vitest';
141
+ import { render, mockRequests } from '@tiaforge-ui/sinter/testing';
142
+ import page from './form-dialog.html?raw';
143
+
144
+
145
+ test('saves the draft and upserts the result', async () => {
146
+ const requests = mockRequests({
147
+ 'GET /api/people': [],
148
+ 'POST /api/people': (call) => ({ id: 1, ...(call.body as object) }),
149
+ });
150
+ const { root, flush, settle } = await render(page, { requests });
151
+ await settle();
152
+ root.querySelector('button')!.click(); // New
153
+ const dialog = root.querySelector('dialog')!;
154
+ dialog.showModal();
155
+ dialog.dispatchEvent(new Event('toggle'));
156
+ await flush();
157
+
158
+ const [name, email] = root.querySelectorAll('dialog input');
159
+ (name as HTMLInputElement).value = 'Ada';
160
+ name!.dispatchEvent(new Event('input', { bubbles: true }));
161
+ (email as HTMLInputElement).value = 'ada@example.com';
162
+ email!.dispatchEvent(new Event('input', { bubbles: true }));
163
+ root.querySelector('form')!.requestSubmit();
164
+ await settle();
165
+
166
+ expect(requests.calls.at(-1)!.body).toEqual({ name: 'Ada', emails: [{ value: 'ada@example.com' }] });
167
+ expect(root.querySelector('li span')!.textContent).toBe('Ada');
168
+ expect(dialog.open).toBe(false);
169
+ });
170
+ ```
171
+
172
+ ### SSE sync
173
+
174
+ ```html
175
+ <sn-resource name="people" src="/api/people" key="id" order="name"></sn-resource>
176
+
177
+ <sn-sync src="/api/events">
178
+ <sn-event name="person_updated" resource="people" op="upsert" item="person" fallback="refetch"></sn-event>
179
+ <sn-event name="person_removed" resource="people" op="remove"></sn-event>
180
+ </sn-sync>
181
+
182
+ <ul>
183
+ <template data-sn-for="p in people"><li data-sn-text="p.name"></li></template>
184
+ </ul>
185
+ ```
186
+
187
+ ```ts
188
+ import { expect, test } from 'vitest';
189
+ import { render, mockRequests, mockStream } from '@tiaforge-ui/sinter/testing';
190
+ import page from './sse-sync.html?raw';
191
+
192
+
193
+ test('applies server events to the list', async () => {
194
+ const requests = mockRequests({ 'GET /api/people': [{ id: 1, name: 'Bob' }] });
195
+ const stream = mockStream();
196
+ const { root, flush, settle } = await render(page, { requests, streams: { '/api/events': stream } });
197
+ await settle();
198
+
199
+ stream.emit('person_updated', { person: { id: 2, name: 'Ada' } });
200
+ stream.emit('person_removed', { id: 1 });
201
+ await flush();
202
+ expect([...root.querySelectorAll('li')].map((li) => li.textContent)).toEqual(['Ada']);
203
+ });
204
+ ```
205
+
206
+ ### Optimistic toggle
207
+
208
+ ```html
209
+ <sn-resource name="messages" src="/api/messages" key="id"></sn-resource>
210
+
211
+ <template data-sn-for="m in messages">
212
+ <article>
213
+ <span data-sn-text="m.subject"></span>
214
+ <button data-sn-patch="/api/messages/{m.id}"
215
+ data-sn-body="{is_flagged: !m.is_flagged}"
216
+ data-sn-optimistic="patch('messages', m, {is_flagged: !m.is_flagged})"
217
+ data-sn-attr="{'aria-pressed': m.is_flagged ? 'true' : 'false'}">★</button>
218
+ </article>
219
+ </template>
220
+ ```
221
+
222
+ ```ts
223
+ import { expect, test } from 'vitest';
224
+ import { render, mockRequests } from '@tiaforge-ui/sinter/testing';
225
+ import page from './optimistic-toggle.html?raw';
226
+
227
+
228
+ test('flags at once and rolls back when the server refuses', async () => {
229
+ const requests = mockRequests({
230
+ 'GET /api/messages': [{ id: 1, subject: 'Hi', is_flagged: false }],
231
+ 'PATCH /api/messages/1': { status: 500 },
232
+ });
233
+ const { root, flush, settle } = await render(page, { requests });
234
+ await settle();
235
+ const star = root.querySelector('button')!;
236
+
237
+ star.click();
238
+ await flush();
239
+ expect(star.getAttribute('aria-pressed')).toBe('true'); // optimistic
240
+
241
+ await settle();
242
+ expect(star.getAttribute('aria-pressed')).toBe('false'); // rolled back
243
+ });
244
+ ```
245
+
246
+ ### Pagination
247
+
248
+ ```html
249
+ <sn-resource name="messages" src="/api/messages" items="messages" total="total" key="id"
250
+ paginate="keyset" page-size="2" cursor="{before: id}"></sn-resource>
251
+
252
+ <ul>
253
+ <template data-sn-for="m in messages"><li data-sn-text="m.subject"></li></template>
254
+ </ul>
255
+ <nav>
256
+ <button data-sn-on-click="page('messages', 'prev')" data-sn-attr="{disabled: !$meta.messages.hasPrev}">‹</button>
257
+ <span data-sn-text="`${$meta.messages.from}–${$meta.messages.to} of ${$meta.messages.total}`"></span>
258
+ <button data-sn-on-click="page('messages', 'next')" data-sn-attr="{disabled: !$meta.messages.hasNext}">›</button>
259
+ </nav>
260
+ ```
261
+
262
+ ```ts
263
+ import { expect, test } from 'vitest';
264
+ import { render, mockRequests } from '@tiaforge-ui/sinter/testing';
265
+ import page from './pagination.html?raw';
266
+
267
+ const all = [5, 4, 3, 2, 1].map((id) => ({ id, subject: `m${id}` }));
268
+
269
+ test('moves to the next page with a cursor', async () => {
270
+ const requests = mockRequests({
271
+ 'GET /api/messages': (call) => {
272
+ const q = new URL(call.url, 'http://x').searchParams;
273
+ const before = Number(q.get('before') ?? Infinity);
274
+ const items = q.get('direction') === 'next' ? all.filter((m) => m.id < before) : all;
275
+ return { messages: items.slice(0, Number(q.get('limit'))), total: all.length };
276
+ },
277
+ });
278
+ const { root, settle } = await render(page, { requests });
279
+ await settle();
280
+ expect(root.querySelector('span')!.textContent).toBe('1–2 of 5');
281
+
282
+ root.querySelector<HTMLButtonElement>('nav button:last-of-type')!.click();
283
+ await settle();
284
+ expect([...root.querySelectorAll('li')].map((li) => li.textContent)).toEqual(['m3', 'm2']);
285
+ expect(root.querySelector('span')!.textContent).toBe('3–4 of 5');
286
+ expect(requests.calls.at(-1)!.url).toBe('/api/messages?before=4&limit=2&direction=next');
287
+ });
288
+ ```
289
+
290
+ ### SignalR (or another transport) for <sn-sync>
291
+
292
+ ```html
293
+ <!-- The page is the same as with SSE: only the configured transport differs (signalr-transport.js). -->
294
+ <sn-resource name="people" src="/api/people" key="id" order="name"></sn-resource>
295
+
296
+ <sn-sync src="/hubs/people">
297
+ <sn-event name="person_updated" resource="people" op="upsert" item="person"></sn-event>
298
+ <sn-event name="person_removed" resource="people" op="remove"></sn-event>
299
+ </sn-sync>
300
+
301
+ <ul>
302
+ <template data-sn-for="p in people"><li data-sn-text="p.name"></li></template>
303
+ </ul>
304
+ ```
305
+
306
+ ```js
307
+ // SignalR transport for <sn-sync>, in your app (sinter does not depend on @microsoft/signalr).
308
+ // In src/sinter.config.js:
309
+ //
310
+ // import { HubConnectionBuilder } from '@microsoft/signalr';
311
+ // import { configure } from '@tiaforge-ui/sinter';
312
+ // import { signalRTransport } from './signalr-transport.js';
313
+ //
314
+ // configure({
315
+ // transport: (url) => url.startsWith('/hubs/')
316
+ // ? signalRTransport(() => new HubConnectionBuilder().withUrl(url).build())
317
+ // : undefined,
318
+ // });
319
+ //
320
+ // Server: `await Clients.All.SendAsync("person_updated", new { person })`; the first argument is
321
+ // the payload. Hub method names are case-insensitive in SignalR, so keep event names distinct in
322
+ // more than case. Do not call withAutomaticReconnect(): sinter reconnects (session probe, backoff,
323
+ // refetch of everything the stream feeds), and two reconnect loops would race. Auth is the hub's:
324
+ // cookies, or withUrl(url, { accessTokenFactory }); request middleware does not apply.
325
+
326
+ /** @param {() => import('@microsoft/signalr').HubConnection} build a new, unstarted connection */
327
+ export function signalRTransport(build) {
328
+ let hub = null;
329
+ let deliver = null;
330
+ const names = new Set();
331
+ const add = (name) => {
332
+ if (!hub || names.has(name)) return;
333
+ names.add(name);
334
+ const h = hub;
335
+ h.on(name, (payload) => { if (hub === h) deliver(name, payload ?? null); });
336
+ };
337
+ return {
338
+ connect(url, onEvent, onState, initial) {
339
+ const h = hub = build();
340
+ deliver = onEvent;
341
+ names.clear();
342
+ for (const n of initial) add(n);
343
+ h.onclose(() => { if (hub === h) { hub = null; onState('error'); } });
344
+ h.start().then(() => { if (hub === h) onState('open'); }, () => { if (hub === h) { hub = null; onState('error'); } });
345
+ },
346
+ listen: add,
347
+ close() {
348
+ const h = hub;
349
+ hub = null; // nothing is reported after close()
350
+ h?.stop().catch(() => {});
351
+ },
352
+ };
353
+ }
354
+ ```
355
+
356
+ ```ts
357
+ import { afterEach, expect, test } from 'vitest';
358
+ import { configure } from '@tiaforge-ui/sinter';
359
+ import { render, mockRequests } from '@tiaforge-ui/sinter/testing';
360
+ import { signalRTransport } from './signalr-transport.js';
361
+ import page from './signalr.html?raw';
362
+
363
+ // A stand-in for HubConnection: the test calls invoke() where the server would call SendAsync().
364
+ function fakeHub() {
365
+ const handlers = new Map<string, (payload: unknown) => void>();
366
+ let onclose = () => {};
367
+ return {
368
+ started: false,
369
+ on(name: string, fn: (payload: unknown) => void) { handlers.set(name, fn); },
370
+ onclose(fn: () => void) { onclose = fn; },
371
+ async start() { this.started = true; },
372
+ async stop() { this.started = false; },
373
+ invoke(name: string, payload: unknown) { handlers.get(name)?.(payload); },
374
+ lose() { this.started = false; onclose(); },
375
+ };
376
+ }
377
+
378
+ afterEach(() => configure({ transport: null }));
379
+
380
+ test('applies hub messages to the list', async () => {
381
+ const hubs: Array<ReturnType<typeof fakeHub>> = [];
382
+ configure({ transport: (url) => (url.startsWith('/hubs/') ? signalRTransport(() => { hubs.push(fakeHub()); return hubs.at(-1) as never; }) : undefined) });
383
+ const requests = mockRequests({ 'GET /api/people': [{ id: 1, name: 'Bob' }] });
384
+ const { root, flush, settle, advance } = await render(page, { requests });
385
+ await settle();
386
+ expect(hubs[0]!.started).toBe(true);
387
+
388
+ hubs[0]!.invoke('person_updated', { person: { id: 2, name: 'Ada' } });
389
+ hubs[0]!.invoke('person_removed', { id: 1 });
390
+ await flush();
391
+ expect([...root.querySelectorAll('li')].map((li) => li.textContent)).toEqual(['Ada']);
392
+
393
+ // a lost connection: sinter reconnects with a new hub and refetches what the stream feeds
394
+ hubs[0]!.lose();
395
+ await advance(2000); // the backoff (1 s ± jitter) before reconnecting
396
+ await settle();
397
+ expect(hubs).toHaveLength(2);
398
+ expect(requests.calls.filter((c) => c.path === '/api/people')).toHaveLength(2);
399
+ });
400
+ ```
401
+
402
+ ### Typed resources, state and events
403
+
404
+ ```html
405
+ <sn-resource name="contacts" src="/api/contacts" items="contacts" key="id" type="Contact[]"></sn-resource>
406
+
407
+ <sn-sync src="/api/events">
408
+ <sn-event name="contact_updated" resource="contacts" op="upsert" item="contact" type="ContactEvent"></sn-event>
409
+ </sn-sync>
410
+
411
+ <section data-sn-state="{ editing: /** @type {Contact|null} */ (null) }">
412
+ <ul>
413
+ <template data-sn-for="c in contacts">
414
+ <li>
415
+ <span data-sn-text="c.name"></span>
416
+ <small data-sn-text="c.email ?? 'no email'"></small>
417
+ <button data-sn-on-click="editing = structuredClone(c)">Edit</button>
418
+ </li>
419
+ </template>
420
+ </ul>
421
+ <template data-sn-if="editing">
422
+ <form data-sn-put="/api/contacts/{editing.id}" data-sn-body="editing"
423
+ data-sn-then="upsert('contacts', $result); editing = null">
424
+ <input data-sn-model="editing.name" required>
425
+ <button>Save</button>
426
+ </form>
427
+ </template>
428
+ </section>
429
+ ```
430
+
431
+ ```ts
432
+ // src/sinter.types.ts
433
+ export interface Contact { id: number; name: string; email: string | null }
434
+ export interface ContactEvent { contact: Contact }
435
+ ```
436
+
437
+ ```ts
438
+ import { expect, test } from 'vitest';
439
+ import { checkPage } from '@tiaforge-ui/sinter/check';
440
+ import page from './typed.html?raw';
441
+ import types from './typed.types.ts?raw';
442
+
443
+ // In CI: `npx sinter check` (pages and options from the Vite config).
444
+ const files = { 'src/sinter.types.ts': types };
445
+
446
+ test('every expression type-checks', () => {
447
+ expect(checkPage(page, { files })).toEqual([]);
448
+ });
449
+
450
+ test('a typo in a field is an error at its position, with a suggestion', () => {
451
+ const [d] = checkPage(page.replace('c.email ??', 'c.emial ??'), { files });
452
+ expect(d).toMatchObject({ code: 'SN208', file: 'index.html', line: 12 });
453
+ expect(d!.message).toMatch(/Property 'emial' does not exist on type 'Contact'. Did you mean 'email'\?/);
454
+ });
455
+
456
+ test('an event payload that does not fit the resource is an error', () => {
457
+ const [d] = checkPage(page.replace('item="contact"', 'item="contact.name"'), { files });
458
+ expect(d!.message).toMatch(/Type 'string' is not assignable to type 'Contact'/);
459
+ });
460
+ ```
461
+
462
+ ### Cached list across page loads
463
+
464
+ ```html
465
+ <!-- `cache`: the next page load shows this list at once, then revalidates.
466
+ `$meta.inbox.loading` with data present means "updating"; `$meta.inbox.loadedAt`
467
+ stays null until the server has answered on this page. -->
468
+ <sn-resource name="inbox" src="/api/messages?folder=inbox" key="id" order="-date" cache="1h"></sn-resource>
469
+
470
+ <p data-sn-show="$meta.inbox.loading && inbox.length">Updating…</p>
471
+ <ul>
472
+ <template data-sn-for="m in inbox">
473
+ <li data-sn-text="m.subject"></li>
474
+ </template>
475
+ </ul>
476
+ ```
477
+
478
+ ```ts
479
+ import { expect, test } from 'vitest';
480
+ import { render, mockRequests, memoryStorage } from '@tiaforge-ui/sinter/testing';
481
+ import page from './cached-list.html?raw';
482
+
483
+ const subjects = (root: HTMLElement) => [...root.querySelectorAll('li')].map((li) => li.textContent);
484
+
485
+ test('the next page load shows the cached list while it revalidates', async () => {
486
+ // one tab: the same sessionStorage for both page loads
487
+ const session = memoryStorage();
488
+ const first = await render(page, { session, requests: mockRequests({
489
+ 'GET /api/messages': [{ id: 1, subject: 'Welcome', date: '2026-01-01' }],
490
+ }) });
491
+ await first.settle();
492
+
493
+ const { root, settle } = await render(page, { session, requests: mockRequests({
494
+ 'GET /api/messages': [{ id: 2, subject: 'Lunch?', date: '2026-01-02' }, { id: 1, subject: 'Welcome', date: '2026-01-01' }],
495
+ }) });
496
+ expect(subjects(root)).toEqual(['Welcome']); // cached, before any response
497
+ expect(root.querySelector('p')!.hidden).toBe(false); // "Updating…"
498
+
499
+ await settle();
500
+ expect(subjects(root)).toEqual(['Lunch?', 'Welcome']);
501
+ expect(root.querySelector('p')!.hidden).toBe(true);
502
+ });
503
+ ```
504
+
505
+ ## Type checking
506
+
507
+ `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`.
508
+
509
+ - `<sn-resource … type="Contact[]">`: the type of what `items` selects, using names exported by `src/sinter.types.ts`. `type="any"` opts out; no `type` is warning SN504.
510
+ - `sinter({ openapi: 'openapi.yml' })`: resources without `type` are matched to a GET operation by `src` (server prefix stripped, `{…}` matches a path parameter) and typed from its JSON response at `items`. Every build then checks `items`/`total` against the schema (SN308) and query parameter names (SN505).
511
+ - State takes a JSDoc cast where the literal is not enough: `data-sn-state="{ editing: /** @type {Contact|null} */ (null) }"`.
512
+ - Actions matched in the spec: `data-sn-body` is checked against the request schema, `$result` in `then` has the response type.
513
+ - `<sn-event … type="ContactEvent">` types the payload; `item` must fit the resource's item, `set` part of it; `cursor` reads the item. No `type` on an event with payload expressions is warning SN506.
514
+ - Errors are SN208 at the HTML position, with TypeScript's message. Null checks are off. In tests: `checkPage(html, { files, openapi })` from `@tiaforge-ui/sinter/check`.
515
+
516
+ ## Deploying streams
517
+
518
+ Each open EventSource holds one HTTP connection, and HTTP/1.1 allows 6 per origin: with more than 5 tabs open, pages stop loading. Serve streams over HTTP/2 (any TLS reverse proxy such as nginx or Caddy) or use the WebSocket transport; dev builds warn about HTTP/1.1.
519
+
520
+ ## Editors
521
+
522
+ `npx sinter lsp --stdio` is a language server for the project's HTML files: sinter's diagnostics as you type (partials checked through the pages that include them), and with TypeScript 7 type errors, hover, completion in expressions, go to definition and a did-you-mean quick fix. VS Code: the extension in `editors/vscode`. Setup for Neovim, Helix and Emacs: `docs/editors.md`. Every diagnostic links to its explanation in `docs/diagnostics.md`.
523
+
524
+ ## Testing
525
+
526
+ Write a test next to the markup. `@tiaforge-ui/sinter/testing` compiles the page with the real compiler and mounts it:
527
+
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).
529
+ - `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
+ - `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
+ - `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,92 @@
1
1
  {
2
2
  "name": "@tiaforge-ui/sinter",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Bind JSON APIs to plain HTML: keyed resources, SSE sync, template bindings and optimistic mutations, compiled at build time.",
5
+ "license": "MIT",
6
+ "author": "Tia Heinrich",
7
+ "homepage": "https://github.com/tiaforge/sinter#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/tiaforge/sinter.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/tiaforge/sinter/issues"
14
+ },
15
+ "keywords": [
16
+ "html",
17
+ "json",
18
+ "api",
19
+ "rest",
20
+ "sse",
21
+ "server-sent-events",
22
+ "websocket",
23
+ "vite",
24
+ "vite-plugin",
25
+ "compiler",
26
+ "templates",
27
+ "data-binding",
28
+ "optimistic-ui",
29
+ "hypermedia"
30
+ ],
31
+ "type": "module",
32
+ "sideEffects": false,
33
+ "exports": {
34
+ ".": "./dist/index.js",
35
+ "./sse": "./dist/sse.js",
36
+ "./ws": "./dist/ws.js",
37
+ "./optimistic": "./dist/optimistic.js",
38
+ "./paginate": "./dist/paginate.js",
39
+ "./persist": "./dist/persist.js",
40
+ "./cache": "./dist/cache.js",
41
+ "./compiler": "./dist/compiler/index.js",
42
+ "./vite": "./dist/vite.js",
43
+ "./check": "./dist/check/index.js",
44
+ "./testing": "./dist/testing/index.js",
45
+ "./dev": "./dist/dev.js"
46
+ },
47
+ "bin": {
48
+ "sinter": "dist/cli.js"
49
+ },
50
+ "files": [
51
+ "dist",
52
+ "llms.txt",
53
+ "html-custom-data.json",
54
+ "docs/diagnostics.md"
55
+ ],
56
+ "scripts": {
57
+ "build": "tsc -p tsconfig.build.json",
58
+ "typecheck": "tsc -p tsconfig.json --noEmit",
59
+ "test": "vitest run",
60
+ "size": "npm run build && node scripts/size.mjs",
61
+ "docs": "UPDATE_DOCS=1 vitest run test/docs.test.ts",
62
+ "prepublishOnly": "rm -rf dist && npm run build && npm test"
63
+ },
64
+ "dependencies": {
65
+ "acorn": "^8.19.0",
66
+ "acorn-walk": "^8.3.5",
67
+ "parse5": "^8.1.0",
68
+ "yaml": "^2.9.1"
69
+ },
70
+ "devDependencies": {
71
+ "@types/node": "^26.6.6",
72
+ "happy-dom": "^21.0.4",
73
+ "jsdom": "^30.1.3",
74
+ "typescript": "^7.0.2",
75
+ "vite": "^8.3.5",
76
+ "vitest": "^5.1.0"
77
+ },
78
+ "peerDependencies": {
79
+ "vite": ">=6"
80
+ },
81
+ "peerDependenciesMeta": {
82
+ "vite": {
83
+ "optional": true
84
+ }
85
+ },
86
+ "engines": {
87
+ "node": "^20.19.0 || >=22.12.0"
88
+ },
89
+ "publishConfig": {
90
+ "access": "public"
91
+ }
92
+ }