@tiaforge-ui/sinter 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +158 -2
- package/dist/cache.d.ts +5 -0
- package/dist/cache.js +95 -0
- package/dist/check/index.d.ts +85 -0
- package/dist/check/index.js +156 -0
- package/dist/check/openapi-types.d.ts +6 -0
- package/dist/check/openapi-types.js +84 -0
- package/dist/check/shadow.d.ts +49 -0
- package/dist/check/shadow.js +279 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +67 -0
- package/dist/compiler/check-unit.d.ts +109 -0
- package/dist/compiler/check-unit.js +3 -0
- package/dist/compiler/compile.d.ts +66 -0
- package/dist/compiler/compile.js +1316 -0
- package/dist/compiler/diagnostic-docs.d.ts +10 -0
- package/dist/compiler/diagnostic-docs.js +142 -0
- package/dist/compiler/diagnostics.d.ts +57 -0
- package/dist/compiler/diagnostics.js +77 -0
- package/dist/compiler/expr.d.ts +97 -0
- package/dist/compiler/expr.js +853 -0
- package/dist/compiler/index.d.ts +22 -0
- package/dist/compiler/index.js +160 -0
- package/dist/compiler/openapi.d.ts +40 -0
- package/dist/compiler/openapi.js +177 -0
- package/dist/compiler/vocabulary.d.ts +12 -0
- package/dist/compiler/vocabulary.js +39 -0
- package/dist/dev.d.ts +10 -0
- package/dist/dev.js +92 -0
- package/dist/each.d.ts +3 -0
- package/dist/each.js +121 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +4 -0
- package/dist/ir.d.ts +152 -0
- package/dist/ir.js +121 -0
- package/dist/lsp/quickfix.d.ts +15 -0
- package/dist/lsp/quickfix.js +58 -0
- package/dist/lsp/rpc.d.ts +31 -0
- package/dist/lsp/rpc.js +120 -0
- package/dist/lsp/server.d.ts +36 -0
- package/dist/lsp/server.js +281 -0
- package/dist/lsp/spec-location.d.ts +9 -0
- package/dist/lsp/spec-location.js +42 -0
- package/dist/lsp/typescript.d.ts +77 -0
- package/dist/lsp/typescript.js +242 -0
- package/dist/lsp/workspace.d.ts +56 -0
- package/dist/lsp/workspace.js +228 -0
- package/dist/optimistic.d.ts +3 -0
- package/dist/optimistic.js +15 -0
- package/dist/paginate.d.ts +3 -0
- package/dist/paginate.js +198 -0
- package/dist/persist.d.ts +3 -0
- package/dist/persist.js +55 -0
- package/dist/project.d.ts +42 -0
- package/dist/project.js +103 -0
- package/dist/runtime/app.d.ts +152 -0
- package/dist/runtime/app.js +1256 -0
- package/dist/runtime/config.d.ts +53 -0
- package/dist/runtime/config.js +118 -0
- package/dist/runtime/env.d.ts +18 -0
- package/dist/runtime/env.js +23 -0
- package/dist/runtime/helpers.d.ts +9 -0
- package/dist/runtime/helpers.js +55 -0
- package/dist/runtime/request.d.ts +23 -0
- package/dist/runtime/request.js +93 -0
- package/dist/runtime/security.d.ts +12 -0
- package/dist/runtime/security.js +34 -0
- package/dist/runtime/store.d.ts +98 -0
- package/dist/runtime/store.js +255 -0
- package/dist/sse.d.ts +7 -0
- package/dist/sse.js +209 -0
- package/dist/testing/index.d.ts +73 -0
- package/dist/testing/index.js +359 -0
- package/dist/vite.d.ts +11 -0
- package/dist/vite.js +96 -0
- package/dist/ws.d.ts +18 -0
- package/dist/ws.js +57 -0
- package/docs/diagnostics.md +283 -0
- package/html-custom-data.json +250 -0
- package/llms.txt +632 -0
- package/package.json +93 -4
package/llms.txt
ADDED
|
@@ -0,0 +1,632 @@
|
|
|
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-call` (action call): local action: calls a function from the actions module, e.g. pgp.decrypt(msg)
|
|
28
|
+
- `data-sn-body` (value expression): JSON request body; overrides form serialization
|
|
29
|
+
- `data-sn-trigger` (DOM event name): overrides the default trigger
|
|
30
|
+
- `data-sn-debounce` (milliseconds): delays the trigger, resets on each event; without a method, delays the element's data-sn-on-* handlers
|
|
31
|
+
- `data-sn-concurrency` (block | queue | latest): mutations only; default block for forms, queue otherwise
|
|
32
|
+
- `data-sn-optimistic` (handler expression): patch/upsert/remove calls applied before the request, rolled back on failure
|
|
33
|
+
- `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}
|
|
35
|
+
- `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
|
+
- `data-sn-data` (resource name): on <script type="application/json"> in <head>: initial data for a resource
|
|
37
|
+
- `data-sn-on-<event>` (handler expression): plain event handler, no request; e.g. data-sn-on-click
|
|
38
|
+
|
|
39
|
+
## Elements
|
|
40
|
+
|
|
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
|
|
42
|
+
- `<sn-sync>` `src`: one server event stream
|
|
43
|
+
- `<sn-event>` `name`, `resource`, `op`, `item`, `set`, `when`, `fallback`, `type`: maps one server event to a change message; child of <sn-sync>
|
|
44
|
+
- `<sn-include>` `src`: inlines a partial at build time
|
|
45
|
+
|
|
46
|
+
## Expressions and scope
|
|
47
|
+
|
|
48
|
+
- 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()`.
|
|
49
|
+
- Handlers (`data-sn-on-*`, `then`, `catch`) are statements separated by `;`: assignments to state, `$url.<param>` and `$locale`, and calls. No declarations or loops.
|
|
50
|
+
- `data-sn-optimistic` may only call `patch`, `upsert`, `remove`.
|
|
51
|
+
- 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.
|
|
52
|
+
- Resources are read-only: copy an item with `structuredClone(c)` to edit it, write with `patch()`.
|
|
53
|
+
- Helpers are plain function calls, never pipes: `date()`, `time()`, `datetime()`, `relative()`, `number()`, `bytes()`. Helper namespaces: `m.inbox_title()`.
|
|
54
|
+
- URL templates: `/api/x?id={c.id}`; interpolations are URL-encoded; the template must start with a literal `/path` or `https://origin`.
|
|
55
|
+
- Use `$now` instead of `Date.now()` so bindings update.
|
|
56
|
+
|
|
57
|
+
## Magic names
|
|
58
|
+
|
|
59
|
+
`$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`.
|
|
60
|
+
|
|
61
|
+
## Store functions
|
|
62
|
+
|
|
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)`.
|
|
64
|
+
|
|
65
|
+
## Diagnostics
|
|
66
|
+
|
|
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`.
|
|
68
|
+
|
|
69
|
+
## Recipes
|
|
70
|
+
|
|
71
|
+
Each recipe comes with its test; copy both.
|
|
72
|
+
|
|
73
|
+
### List with filter
|
|
74
|
+
|
|
75
|
+
```html
|
|
76
|
+
<sn-resource name="people" src="/api/people" items="people" key="id" order="name"></sn-resource>
|
|
77
|
+
|
|
78
|
+
<section data-sn-state="{ q: '' }">
|
|
79
|
+
<input type="search" placeholder="Search" data-sn-model="q">
|
|
80
|
+
<p data-sn-show="$meta.people.loading" aria-busy="true">Loading…</p>
|
|
81
|
+
<ul>
|
|
82
|
+
<template data-sn-for="p in people.filter(p => p.name.toLowerCase().includes(q.toLowerCase()))">
|
|
83
|
+
<li data-sn-text="p.name"></li>
|
|
84
|
+
</template>
|
|
85
|
+
</ul>
|
|
86
|
+
</section>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { expect, test } from 'vitest';
|
|
91
|
+
import { render, mockRequests } from '@tiaforge-ui/sinter/testing';
|
|
92
|
+
import page from './list-filter.html?raw';
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
test('filters the list as you type', async () => {
|
|
96
|
+
const requests = mockRequests({ 'GET /api/people': { people: [{ id: 1, name: 'Bob' }, { id: 2, name: 'Ada' }] } });
|
|
97
|
+
const { root, flush, settle } = await render(page, { requests });
|
|
98
|
+
await settle();
|
|
99
|
+
expect([...root.querySelectorAll('li')].map((li) => li.textContent)).toEqual(['Ada', 'Bob']);
|
|
100
|
+
|
|
101
|
+
const input = root.querySelector('input')!;
|
|
102
|
+
input.value = 'ad';
|
|
103
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
104
|
+
await flush();
|
|
105
|
+
expect([...root.querySelectorAll('li')].map((li) => li.textContent)).toEqual(['Ada']);
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Form dialog with repeatable fields
|
|
110
|
+
|
|
111
|
+
```html
|
|
112
|
+
<sn-resource name="people" src="/api/people" key="id" order="name"></sn-resource>
|
|
113
|
+
|
|
114
|
+
<section data-sn-state="{ draft: null }">
|
|
115
|
+
<button data-sn-on-click="draft = {name: '', emails: [{value: ''}]}" commandfor="edit" command="show-modal">New</button>
|
|
116
|
+
<ul>
|
|
117
|
+
<template data-sn-for="p in people">
|
|
118
|
+
<li>
|
|
119
|
+
<span data-sn-text="p.name"></span>
|
|
120
|
+
<button data-sn-on-click="draft = structuredClone(p)" commandfor="edit" command="show-modal">Edit</button>
|
|
121
|
+
</li>
|
|
122
|
+
</template>
|
|
123
|
+
</ul>
|
|
124
|
+
|
|
125
|
+
<dialog id="edit">
|
|
126
|
+
<form data-sn-post="/api/people" data-sn-body="draft"
|
|
127
|
+
data-sn-then="upsert('people', $result); draft = null; close()"
|
|
128
|
+
data-sn-catch="emit('toast', $error.message)">
|
|
129
|
+
<input data-sn-model="draft.name" required>
|
|
130
|
+
<template data-sn-for="(e, i) in draft.emails">
|
|
131
|
+
<input type="email" data-sn-model="draft.emails[i].value">
|
|
132
|
+
</template>
|
|
133
|
+
<button type="button" data-sn-on-click="draft.emails.push({value: ''})">Add email</button>
|
|
134
|
+
<button type="submit">Save</button>
|
|
135
|
+
</form>
|
|
136
|
+
</dialog>
|
|
137
|
+
</section>
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
import { expect, test } from 'vitest';
|
|
142
|
+
import { render, mockRequests } from '@tiaforge-ui/sinter/testing';
|
|
143
|
+
import page from './form-dialog.html?raw';
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
test('saves the draft and upserts the result', async () => {
|
|
147
|
+
const requests = mockRequests({
|
|
148
|
+
'GET /api/people': [],
|
|
149
|
+
'POST /api/people': (call) => ({ id: 1, ...(call.body as object) }),
|
|
150
|
+
});
|
|
151
|
+
const { root, flush, settle } = await render(page, { requests });
|
|
152
|
+
await settle();
|
|
153
|
+
root.querySelector('button')!.click(); // New
|
|
154
|
+
const dialog = root.querySelector('dialog')!;
|
|
155
|
+
dialog.showModal();
|
|
156
|
+
dialog.dispatchEvent(new Event('toggle'));
|
|
157
|
+
await flush();
|
|
158
|
+
|
|
159
|
+
const [name, email] = root.querySelectorAll('dialog input');
|
|
160
|
+
(name as HTMLInputElement).value = 'Ada';
|
|
161
|
+
name!.dispatchEvent(new Event('input', { bubbles: true }));
|
|
162
|
+
(email as HTMLInputElement).value = 'ada@example.com';
|
|
163
|
+
email!.dispatchEvent(new Event('input', { bubbles: true }));
|
|
164
|
+
root.querySelector('form')!.requestSubmit();
|
|
165
|
+
await settle();
|
|
166
|
+
|
|
167
|
+
expect(requests.calls.at(-1)!.body).toEqual({ name: 'Ada', emails: [{ value: 'ada@example.com' }] });
|
|
168
|
+
expect(root.querySelector('li span')!.textContent).toBe('Ada');
|
|
169
|
+
expect(dialog.open).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### SSE sync
|
|
174
|
+
|
|
175
|
+
```html
|
|
176
|
+
<sn-resource name="people" src="/api/people" key="id" order="name"></sn-resource>
|
|
177
|
+
|
|
178
|
+
<sn-sync src="/api/events">
|
|
179
|
+
<sn-event name="person_updated" resource="people" op="upsert" item="person" fallback="refetch"></sn-event>
|
|
180
|
+
<sn-event name="person_removed" resource="people" op="remove"></sn-event>
|
|
181
|
+
</sn-sync>
|
|
182
|
+
|
|
183
|
+
<ul>
|
|
184
|
+
<template data-sn-for="p in people"><li data-sn-text="p.name"></li></template>
|
|
185
|
+
</ul>
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
import { expect, test } from 'vitest';
|
|
190
|
+
import { render, mockRequests, mockStream } from '@tiaforge-ui/sinter/testing';
|
|
191
|
+
import page from './sse-sync.html?raw';
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
test('applies server events to the list', async () => {
|
|
195
|
+
const requests = mockRequests({ 'GET /api/people': [{ id: 1, name: 'Bob' }] });
|
|
196
|
+
const stream = mockStream();
|
|
197
|
+
const { root, flush, settle } = await render(page, { requests, streams: { '/api/events': stream } });
|
|
198
|
+
await settle();
|
|
199
|
+
|
|
200
|
+
stream.emit('person_updated', { person: { id: 2, name: 'Ada' } });
|
|
201
|
+
stream.emit('person_removed', { id: 1 });
|
|
202
|
+
await flush();
|
|
203
|
+
expect([...root.querySelectorAll('li')].map((li) => li.textContent)).toEqual(['Ada']);
|
|
204
|
+
});
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Optimistic toggle
|
|
208
|
+
|
|
209
|
+
```html
|
|
210
|
+
<sn-resource name="messages" src="/api/messages" key="id"></sn-resource>
|
|
211
|
+
|
|
212
|
+
<template data-sn-for="m in messages">
|
|
213
|
+
<article>
|
|
214
|
+
<span data-sn-text="m.subject"></span>
|
|
215
|
+
<button data-sn-patch="/api/messages/{m.id}"
|
|
216
|
+
data-sn-body="{is_flagged: !m.is_flagged}"
|
|
217
|
+
data-sn-optimistic="patch('messages', m, {is_flagged: !m.is_flagged})"
|
|
218
|
+
data-sn-attr="{'aria-pressed': m.is_flagged ? 'true' : 'false'}">★</button>
|
|
219
|
+
</article>
|
|
220
|
+
</template>
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
import { expect, test } from 'vitest';
|
|
225
|
+
import { render, mockRequests } from '@tiaforge-ui/sinter/testing';
|
|
226
|
+
import page from './optimistic-toggle.html?raw';
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
test('flags at once and rolls back when the server refuses', async () => {
|
|
230
|
+
const requests = mockRequests({
|
|
231
|
+
'GET /api/messages': [{ id: 1, subject: 'Hi', is_flagged: false }],
|
|
232
|
+
'PATCH /api/messages/1': { status: 500 },
|
|
233
|
+
});
|
|
234
|
+
const { root, flush, settle } = await render(page, { requests });
|
|
235
|
+
await settle();
|
|
236
|
+
const star = root.querySelector('button')!;
|
|
237
|
+
|
|
238
|
+
star.click();
|
|
239
|
+
await flush();
|
|
240
|
+
expect(star.getAttribute('aria-pressed')).toBe('true'); // optimistic
|
|
241
|
+
|
|
242
|
+
await settle();
|
|
243
|
+
expect(star.getAttribute('aria-pressed')).toBe('false'); // rolled back
|
|
244
|
+
});
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### Pagination
|
|
248
|
+
|
|
249
|
+
```html
|
|
250
|
+
<sn-resource name="messages" src="/api/messages" items="messages" total="total" key="id"
|
|
251
|
+
paginate="keyset" page-size="2" cursor="{before: id}"></sn-resource>
|
|
252
|
+
|
|
253
|
+
<ul>
|
|
254
|
+
<template data-sn-for="m in messages"><li data-sn-text="m.subject"></li></template>
|
|
255
|
+
</ul>
|
|
256
|
+
<nav>
|
|
257
|
+
<button data-sn-on-click="page('messages', 'prev')" data-sn-attr="{disabled: !$meta.messages.hasPrev}">‹</button>
|
|
258
|
+
<span data-sn-text="`${$meta.messages.from}–${$meta.messages.to} of ${$meta.messages.total}`"></span>
|
|
259
|
+
<button data-sn-on-click="page('messages', 'next')" data-sn-attr="{disabled: !$meta.messages.hasNext}">›</button>
|
|
260
|
+
</nav>
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
import { expect, test } from 'vitest';
|
|
265
|
+
import { render, mockRequests } from '@tiaforge-ui/sinter/testing';
|
|
266
|
+
import page from './pagination.html?raw';
|
|
267
|
+
|
|
268
|
+
const all = [5, 4, 3, 2, 1].map((id) => ({ id, subject: `m${id}` }));
|
|
269
|
+
|
|
270
|
+
test('moves to the next page with a cursor', async () => {
|
|
271
|
+
const requests = mockRequests({
|
|
272
|
+
'GET /api/messages': (call) => {
|
|
273
|
+
const q = new URL(call.url, 'http://x').searchParams;
|
|
274
|
+
const before = Number(q.get('before') ?? Infinity);
|
|
275
|
+
const items = q.get('direction') === 'next' ? all.filter((m) => m.id < before) : all;
|
|
276
|
+
return { messages: items.slice(0, Number(q.get('limit'))), total: all.length };
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
const { root, settle } = await render(page, { requests });
|
|
280
|
+
await settle();
|
|
281
|
+
expect(root.querySelector('span')!.textContent).toBe('1–2 of 5');
|
|
282
|
+
|
|
283
|
+
root.querySelector<HTMLButtonElement>('nav button:last-of-type')!.click();
|
|
284
|
+
await settle();
|
|
285
|
+
expect([...root.querySelectorAll('li')].map((li) => li.textContent)).toEqual(['m3', 'm2']);
|
|
286
|
+
expect(root.querySelector('span')!.textContent).toBe('3–4 of 5');
|
|
287
|
+
expect(requests.calls.at(-1)!.url).toBe('/api/messages?before=4&limit=2&direction=next');
|
|
288
|
+
});
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
### SignalR (or another transport) for <sn-sync>
|
|
292
|
+
|
|
293
|
+
```html
|
|
294
|
+
<!-- The page is the same as with SSE: only the configured transport differs (signalr-transport.js). -->
|
|
295
|
+
<sn-resource name="people" src="/api/people" key="id" order="name"></sn-resource>
|
|
296
|
+
|
|
297
|
+
<sn-sync src="/hubs/people">
|
|
298
|
+
<sn-event name="person_updated" resource="people" op="upsert" item="person"></sn-event>
|
|
299
|
+
<sn-event name="person_removed" resource="people" op="remove"></sn-event>
|
|
300
|
+
</sn-sync>
|
|
301
|
+
|
|
302
|
+
<ul>
|
|
303
|
+
<template data-sn-for="p in people"><li data-sn-text="p.name"></li></template>
|
|
304
|
+
</ul>
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
```js
|
|
308
|
+
// SignalR transport for <sn-sync>, in your app (sinter does not depend on @microsoft/signalr).
|
|
309
|
+
// In src/sinter.config.js:
|
|
310
|
+
//
|
|
311
|
+
// import { HubConnectionBuilder } from '@microsoft/signalr';
|
|
312
|
+
// import { configure } from '@tiaforge-ui/sinter';
|
|
313
|
+
// import { signalRTransport } from './signalr-transport.js';
|
|
314
|
+
//
|
|
315
|
+
// configure({
|
|
316
|
+
// transport: (url) => url.startsWith('/hubs/')
|
|
317
|
+
// ? signalRTransport(() => new HubConnectionBuilder().withUrl(url).build())
|
|
318
|
+
// : undefined,
|
|
319
|
+
// });
|
|
320
|
+
//
|
|
321
|
+
// Server: `await Clients.All.SendAsync("person_updated", new { person })`; the first argument is
|
|
322
|
+
// the payload. Hub method names are case-insensitive in SignalR, so keep event names distinct in
|
|
323
|
+
// more than case. Do not call withAutomaticReconnect(): sinter reconnects (session probe, backoff,
|
|
324
|
+
// refetch of everything the stream feeds), and two reconnect loops would race. Auth is the hub's:
|
|
325
|
+
// cookies, or withUrl(url, { accessTokenFactory }); request middleware does not apply.
|
|
326
|
+
|
|
327
|
+
/** @param {() => import('@microsoft/signalr').HubConnection} build a new, unstarted connection */
|
|
328
|
+
export function signalRTransport(build) {
|
|
329
|
+
let hub = null;
|
|
330
|
+
let deliver = null;
|
|
331
|
+
const names = new Set();
|
|
332
|
+
const add = (name) => {
|
|
333
|
+
if (!hub || names.has(name)) return;
|
|
334
|
+
names.add(name);
|
|
335
|
+
const h = hub;
|
|
336
|
+
h.on(name, (payload) => { if (hub === h) deliver(name, payload ?? null); });
|
|
337
|
+
};
|
|
338
|
+
return {
|
|
339
|
+
connect(url, onEvent, onState, initial) {
|
|
340
|
+
const h = hub = build();
|
|
341
|
+
deliver = onEvent;
|
|
342
|
+
names.clear();
|
|
343
|
+
for (const n of initial) add(n);
|
|
344
|
+
h.onclose(() => { if (hub === h) { hub = null; onState('error'); } });
|
|
345
|
+
h.start().then(() => { if (hub === h) onState('open'); }, () => { if (hub === h) { hub = null; onState('error'); } });
|
|
346
|
+
},
|
|
347
|
+
listen: add,
|
|
348
|
+
close() {
|
|
349
|
+
const h = hub;
|
|
350
|
+
hub = null; // nothing is reported after close()
|
|
351
|
+
h?.stop().catch(() => {});
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
```ts
|
|
358
|
+
import { afterEach, expect, test } from 'vitest';
|
|
359
|
+
import { configure } from '@tiaforge-ui/sinter';
|
|
360
|
+
import { render, mockRequests } from '@tiaforge-ui/sinter/testing';
|
|
361
|
+
import { signalRTransport } from './signalr-transport.js';
|
|
362
|
+
import page from './signalr.html?raw';
|
|
363
|
+
|
|
364
|
+
// A stand-in for HubConnection: the test calls invoke() where the server would call SendAsync().
|
|
365
|
+
function fakeHub() {
|
|
366
|
+
const handlers = new Map<string, (payload: unknown) => void>();
|
|
367
|
+
let onclose = () => {};
|
|
368
|
+
return {
|
|
369
|
+
started: false,
|
|
370
|
+
on(name: string, fn: (payload: unknown) => void) { handlers.set(name, fn); },
|
|
371
|
+
onclose(fn: () => void) { onclose = fn; },
|
|
372
|
+
async start() { this.started = true; },
|
|
373
|
+
async stop() { this.started = false; },
|
|
374
|
+
invoke(name: string, payload: unknown) { handlers.get(name)?.(payload); },
|
|
375
|
+
lose() { this.started = false; onclose(); },
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
afterEach(() => configure({ transport: null }));
|
|
380
|
+
|
|
381
|
+
test('applies hub messages to the list', async () => {
|
|
382
|
+
const hubs: Array<ReturnType<typeof fakeHub>> = [];
|
|
383
|
+
configure({ transport: (url) => (url.startsWith('/hubs/') ? signalRTransport(() => { hubs.push(fakeHub()); return hubs.at(-1) as never; }) : undefined) });
|
|
384
|
+
const requests = mockRequests({ 'GET /api/people': [{ id: 1, name: 'Bob' }] });
|
|
385
|
+
const { root, flush, settle, advance } = await render(page, { requests });
|
|
386
|
+
await settle();
|
|
387
|
+
expect(hubs[0]!.started).toBe(true);
|
|
388
|
+
|
|
389
|
+
hubs[0]!.invoke('person_updated', { person: { id: 2, name: 'Ada' } });
|
|
390
|
+
hubs[0]!.invoke('person_removed', { id: 1 });
|
|
391
|
+
await flush();
|
|
392
|
+
expect([...root.querySelectorAll('li')].map((li) => li.textContent)).toEqual(['Ada']);
|
|
393
|
+
|
|
394
|
+
// a lost connection: sinter reconnects with a new hub and refetches what the stream feeds
|
|
395
|
+
hubs[0]!.lose();
|
|
396
|
+
await advance(2000); // the backoff (1 s ± jitter) before reconnecting
|
|
397
|
+
await settle();
|
|
398
|
+
expect(hubs).toHaveLength(2);
|
|
399
|
+
expect(requests.calls.filter((c) => c.path === '/api/people')).toHaveLength(2);
|
|
400
|
+
});
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
### Typed resources, state and events
|
|
404
|
+
|
|
405
|
+
```html
|
|
406
|
+
<sn-resource name="contacts" src="/api/contacts" items="contacts" key="id" type="Contact[]"></sn-resource>
|
|
407
|
+
|
|
408
|
+
<sn-sync src="/api/events">
|
|
409
|
+
<sn-event name="contact_updated" resource="contacts" op="upsert" item="contact" type="ContactEvent"></sn-event>
|
|
410
|
+
</sn-sync>
|
|
411
|
+
|
|
412
|
+
<section data-sn-state="{ editing: /** @type {Contact|null} */ (null) }">
|
|
413
|
+
<ul>
|
|
414
|
+
<template data-sn-for="c in contacts">
|
|
415
|
+
<li>
|
|
416
|
+
<span data-sn-text="c.name"></span>
|
|
417
|
+
<small data-sn-text="c.email ?? 'no email'"></small>
|
|
418
|
+
<button data-sn-on-click="editing = structuredClone(c)">Edit</button>
|
|
419
|
+
</li>
|
|
420
|
+
</template>
|
|
421
|
+
</ul>
|
|
422
|
+
<template data-sn-if="editing">
|
|
423
|
+
<form data-sn-put="/api/contacts/{editing.id}" data-sn-body="editing"
|
|
424
|
+
data-sn-then="upsert('contacts', $result); editing = null">
|
|
425
|
+
<input data-sn-model="editing.name" required>
|
|
426
|
+
<button>Save</button>
|
|
427
|
+
</form>
|
|
428
|
+
</template>
|
|
429
|
+
</section>
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
// src/sinter.types.ts
|
|
434
|
+
export interface Contact { id: number; name: string; email: string | null }
|
|
435
|
+
export interface ContactEvent { contact: Contact }
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
```ts
|
|
439
|
+
import { expect, test } from 'vitest';
|
|
440
|
+
import { checkPage } from '@tiaforge-ui/sinter/check';
|
|
441
|
+
import page from './typed.html?raw';
|
|
442
|
+
import types from './typed.types.ts?raw';
|
|
443
|
+
|
|
444
|
+
// In CI: `npx sinter check` (pages and options from the Vite config).
|
|
445
|
+
const files = { 'src/sinter.types.ts': types };
|
|
446
|
+
|
|
447
|
+
test('every expression type-checks', () => {
|
|
448
|
+
expect(checkPage(page, { files })).toEqual([]);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test('a typo in a field is an error at its position, with a suggestion', () => {
|
|
452
|
+
const [d] = checkPage(page.replace('c.email ??', 'c.emial ??'), { files });
|
|
453
|
+
expect(d).toMatchObject({ code: 'SN208', file: 'index.html', line: 12 });
|
|
454
|
+
expect(d!.message).toMatch(/Property 'emial' does not exist on type 'Contact'. Did you mean 'email'\?/);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test('an event payload that does not fit the resource is an error', () => {
|
|
458
|
+
const [d] = checkPage(page.replace('item="contact"', 'item="contact.name"'), { files });
|
|
459
|
+
expect(d!.message).toMatch(/Type 'string' is not assignable to type 'Contact'/);
|
|
460
|
+
});
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
### Cached list across page loads
|
|
464
|
+
|
|
465
|
+
```html
|
|
466
|
+
<!-- `cache`: the next page load shows this list at once, then revalidates.
|
|
467
|
+
`$meta.inbox.loading` with data present means "updating"; `$meta.inbox.loadedAt`
|
|
468
|
+
stays null until the server has answered on this page. -->
|
|
469
|
+
<sn-resource name="inbox" src="/api/messages?folder=inbox" key="id" order="-date" cache="1h"></sn-resource>
|
|
470
|
+
|
|
471
|
+
<p data-sn-show="$meta.inbox.loading && inbox.length">Updating…</p>
|
|
472
|
+
<ul>
|
|
473
|
+
<template data-sn-for="m in inbox">
|
|
474
|
+
<li data-sn-text="m.subject"></li>
|
|
475
|
+
</template>
|
|
476
|
+
</ul>
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
```ts
|
|
480
|
+
import { expect, test } from 'vitest';
|
|
481
|
+
import { render, mockRequests, memoryStorage } from '@tiaforge-ui/sinter/testing';
|
|
482
|
+
import page from './cached-list.html?raw';
|
|
483
|
+
|
|
484
|
+
const subjects = (root: HTMLElement) => [...root.querySelectorAll('li')].map((li) => li.textContent);
|
|
485
|
+
|
|
486
|
+
test('the next page load shows the cached list while it revalidates', async () => {
|
|
487
|
+
// one tab: the same sessionStorage for both page loads
|
|
488
|
+
const session = memoryStorage();
|
|
489
|
+
const first = await render(page, { session, requests: mockRequests({
|
|
490
|
+
'GET /api/messages': [{ id: 1, subject: 'Welcome', date: '2026-01-01' }],
|
|
491
|
+
}) });
|
|
492
|
+
await first.settle();
|
|
493
|
+
|
|
494
|
+
const { root, settle } = await render(page, { session, requests: mockRequests({
|
|
495
|
+
'GET /api/messages': [{ id: 2, subject: 'Lunch?', date: '2026-01-02' }, { id: 1, subject: 'Welcome', date: '2026-01-01' }],
|
|
496
|
+
}) });
|
|
497
|
+
expect(subjects(root)).toEqual(['Welcome']); // cached, before any response
|
|
498
|
+
expect(root.querySelector('p')!.hidden).toBe(false); // "Updating…"
|
|
499
|
+
|
|
500
|
+
await settle();
|
|
501
|
+
expect(subjects(root)).toEqual(['Lunch?', 'Welcome']);
|
|
502
|
+
expect(root.querySelector('p')!.hidden).toBe(true);
|
|
503
|
+
});
|
|
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
|
+
|
|
606
|
+
## Type checking
|
|
607
|
+
|
|
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`.
|
|
609
|
+
|
|
610
|
+
- `<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.
|
|
611
|
+
- `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).
|
|
612
|
+
- State takes a JSDoc cast where the literal is not enough: `data-sn-state="{ editing: /** @type {Contact|null} */ (null) }"`.
|
|
613
|
+
- Actions matched in the spec: `data-sn-body` is checked against the request schema, `$result` in `then` has the response type.
|
|
614
|
+
- `<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.
|
|
615
|
+
- 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`.
|
|
616
|
+
|
|
617
|
+
## Deploying streams
|
|
618
|
+
|
|
619
|
+
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.
|
|
620
|
+
|
|
621
|
+
## Editors
|
|
622
|
+
|
|
623
|
+
`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`.
|
|
624
|
+
|
|
625
|
+
## Testing
|
|
626
|
+
|
|
627
|
+
Write a test next to the markup. `@tiaforge-ui/sinter/testing` compiles the page with the real compiler and mounts it:
|
|
628
|
+
|
|
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).
|
|
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.
|
|
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).
|
|
632
|
+
- `flush()` runs the scheduler (optimistic changes are visible), `settle()` finishes requests, queues and debounces, `advance(ms)` moves sinter's clock.
|