@tiaforge-ui/sinter 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/check/index.js +2 -1
- package/dist/check/shadow.d.ts +1 -1
- package/dist/check/shadow.js +17 -3
- package/dist/compiler/check-unit.d.ts +14 -2
- package/dist/compiler/compile.js +111 -10
- package/dist/compiler/diagnostic-docs.js +10 -0
- package/dist/compiler/diagnostics.d.ts +2 -0
- package/dist/compiler/diagnostics.js +2 -0
- package/dist/compiler/expr.d.ts +16 -0
- package/dist/compiler/expr.js +34 -4
- package/dist/compiler/vocabulary.d.ts +1 -0
- package/dist/compiler/vocabulary.js +6 -1
- package/dist/ir.d.ts +4 -0
- package/dist/listbox.d.ts +3 -0
- package/dist/listbox.js +66 -0
- package/dist/runtime/app.d.ts +7 -0
- package/dist/runtime/app.js +83 -18
- package/dist/runtime/config.d.ts +8 -1
- package/dist/runtime/config.js +9 -2
- package/dist/runtime/request.d.ts +1 -1
- package/dist/runtime/request.js +6 -1
- package/dist/testing/index.js +2 -1
- package/docs/diagnostics.md +20 -0
- package/html-custom-data.json +29 -1
- package/llms.txt +8 -3
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -96,7 +96,7 @@ Measurements and sources: [`docs/realworld-comparison.md`](docs/realworld-compar
|
|
|
96
96
|
islands, which are plain custom elements.
|
|
97
97
|
- You can't have a build step.
|
|
98
98
|
- Your backend renders HTML, not JSON.
|
|
99
|
-
- You need stability guarantees. sinter is at v0.
|
|
99
|
+
- You need stability guarantees. sinter is at v0.3.
|
|
100
100
|
|
|
101
101
|
## Setup
|
|
102
102
|
|
|
@@ -147,12 +147,13 @@ Then write `data-sn-*` attributes in your HTML pages. Optional:
|
|
|
147
147
|
## Development
|
|
148
148
|
|
|
149
149
|
```sh
|
|
150
|
-
npm test # vitest (happy-dom)
|
|
150
|
+
npm test # vitest (happy-dom); CI runs typecheck, build and tests on every push (Node 22/24/26)
|
|
151
151
|
npm run typecheck
|
|
152
152
|
npm run build # dist/
|
|
153
153
|
npm run size # size per page and module, with budgets
|
|
154
154
|
npm run bench # store and flush benchmarks (docs/benchmark.md)
|
|
155
155
|
npm run docs # regenerate llms.txt, html-custom-data.json (also the VS Code extension's) and docs/diagnostics.md
|
|
156
|
+
npm run release -- 0.3.0 # bump versions, date the changelog, commit "Release 0.3.0", tag v0.3.0 (not pushed)
|
|
156
157
|
```
|
|
157
158
|
|
|
158
159
|
License: MIT. Contributions are accepted under the Developer Certificate of Origin
|
package/dist/check/index.js
CHANGED
|
@@ -64,7 +64,8 @@ export function check(o) {
|
|
|
64
64
|
const tsconfig = join(outDir, 'tsconfig.json');
|
|
65
65
|
writeFileSync(tsconfig, JSON.stringify({
|
|
66
66
|
compilerOptions: COMPILER_OPTIONS,
|
|
67
|
-
|
|
67
|
+
// the types module always, so augmentations in it (declare global, a library's import type {}) apply
|
|
68
|
+
files: ['sinter.d.ts', ...(support.types ? [relative(outDir, support.types.file).split('\\').join('/')] : []), ...support.openapi.map((f) => relative(outDir, f)), ...[...shadows.keys()].map((f) => relative(outDir, f).split('\\').join('/'))],
|
|
68
69
|
}, null, 2) + '\n');
|
|
69
70
|
const tsc = o.tsc ?? findTsc(o.root);
|
|
70
71
|
const run = spawnSync(process.execPath, [tsc, '-p', tsconfig, '--pretty', 'false'], { encoding: 'utf8', cwd: outDir });
|
package/dist/check/shadow.d.ts
CHANGED
|
@@ -38,7 +38,7 @@ export declare function shadow(o: ShadowOptions): Shadow;
|
|
|
38
38
|
/** Maps a 1-based shadow position to the HTML, or null for generated lines. */
|
|
39
39
|
export declare function mapPosition(regions: Region[], line: number, col: number): CheckLoc | null;
|
|
40
40
|
/** `sinter.d.ts`: the types the shadow files refer to. */
|
|
41
|
-
export declare const DECLARATIONS = "// Generated by `sinter check`. Do not edit.\nexport interface Meta {\n loading: boolean;\n error: Err | null;\n loadedAt: number | null;\n pending: number;\n total: number | null;\n page?: number | null;\n pages?: number | null;\n from?: number | null;\n to?: number | null;\n hasPrev?: boolean;\n hasNext?: boolean;\n}\nexport interface Err { status: number | null; body: any; message: string }\nexport interface CallContext { signal: AbortSignal; request(url: string, init?: { method?: string; body?: unknown; headers?: Record<string, string>; signal?: AbortSignal }): Promise<any>; el: Element | null }\n/** an action's parameters without its trailing context */\nexport type CallArgs<F> = F extends (...a: infer P) => any ? (P extends [...infer A, infer L] ? ([L] extends [CallContext] ? A : P) : P) : never;\nexport type Item<T> = NonNullable<T> extends readonly (infer U)[] ? U : NonNullable<T> extends Iterable<infer U> ? U : any;\nexport type El<K extends string> = K extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[K] : HTMLElement;\nexport type Ev<K extends string> = K extends keyof HTMLElementEventMap ? HTMLElementEventMap[K] : CustomEvent;\nexport interface Store<R> {\n refresh(name: keyof R & string): void;\n upsert<K extends keyof R & string>(name: K, item: Item<R[K]>): void;\n patch<K extends keyof R & string>(name: K, item: Partial<Item<R[K]>>, set: Partial<Item<R[K]>>): void;\n remove<K extends keyof R & string>(name: K, item: Partial<Item<R[K]>>): void;\n page(name: keyof R & string, to: 'next' | 'prev' | 'first' | 'last' | number): void;\n close(): void;\n reset(): void;\n emit(event: string, detail?: unknown): void;\n}\ntype Style = 'short' | 'medium' | 'long' | 'full';\nexport function date(v: unknown, style?: Style): string;\nexport function time(v: unknown, style?: Style): string;\nexport function datetime(v: unknown, style?: Style): string;\nexport function relative(v: unknown, now?: unknown): string;\nexport function number(v: unknown, opts?: Intl.NumberFormatOptions): string;\nexport function bytes(v: unknown): string;\n";
|
|
41
|
+
export declare const DECLARATIONS = "// Generated by `sinter check`. Do not edit.\nexport interface Meta {\n loading: boolean;\n error: Err | null;\n loadedAt: number | null;\n pending: number;\n total: number | null;\n page?: number | null;\n pages?: number | null;\n from?: number | null;\n to?: number | null;\n hasPrev?: boolean;\n hasNext?: boolean;\n}\nexport interface Err { status: number | null; body: any; message: string; detail: string | null }\ndeclare global {\n /**\n * Value types for attributes bound with data-sn-attr. Empty here; a library extends it to type its own\n * attributes, e.g. tinystyle (import type {} from '@tiaforge-ui/tinystyle/sinter' in the types module).\n */\n interface SinterAttributes {}\n}\n/** what data-sn-attr accepts: the keys SinterAttributes types, anything else as before */\nexport type Attrs = { [K in keyof SinterAttributes]?: SinterAttributes[K] } & Record<string, unknown>;\nexport interface CallContext { signal: AbortSignal; request(url: string, init?: { method?: string; body?: unknown; headers?: Record<string, string>; signal?: AbortSignal }): Promise<any>; el: Element | null }\n/** an action's parameters without its trailing context */\nexport type CallArgs<F> = F extends (...a: infer P) => any ? (P extends [...infer A, infer L] ? ([L] extends [CallContext] ? A : P) : P) : never;\nexport type Item<T> = NonNullable<T> extends readonly (infer U)[] ? U : NonNullable<T> extends Iterable<infer U> ? U : any;\nexport type El<K extends string> = K extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[K] : HTMLElement;\nexport type Ev<K extends string> = K extends keyof HTMLElementEventMap ? HTMLElementEventMap[K] : CustomEvent;\nexport interface Store<R> {\n refresh(name: keyof R & string): void;\n upsert<K extends keyof R & string>(name: K, item: Item<R[K]>): void;\n patch<K extends keyof R & string>(name: K, item: Partial<Item<R[K]>>, set: Partial<Item<R[K]>>): void;\n remove<K extends keyof R & string>(name: K, item: Partial<Item<R[K]>>): void;\n page(name: keyof R & string, to: 'next' | 'prev' | 'first' | 'last' | number): void;\n showModal(id: string): void;\n close(id?: string): void;\n reset(): void;\n emit(event: string, detail?: unknown): void;\n}\ntype Style = 'short' | 'medium' | 'long' | 'full';\nexport function date(v: unknown, style?: Style): string;\nexport function time(v: unknown, style?: Style): string;\nexport function datetime(v: unknown, style?: Style): string;\nexport function relative(v: unknown, now?: unknown): string;\nexport function number(v: unknown, opts?: Intl.NumberFormatOptions): string;\nexport function bytes(v: unknown): string;\n";
|
|
42
42
|
/**
|
|
43
43
|
* Maps a 1-based position in an HTML file to the shadow, or null when it is not inside an
|
|
44
44
|
* expression. `lines` are the shadow's lines.
|
package/dist/check/shadow.js
CHANGED
|
@@ -115,7 +115,7 @@ export function shadow(o) {
|
|
|
115
115
|
line(`} */${after}`);
|
|
116
116
|
return;
|
|
117
117
|
}
|
|
118
|
-
const text = t.kind === 'openapi'
|
|
118
|
+
const text = t.kind === 'decl' ? decl(t.name) : t.kind === 'openapi'
|
|
119
119
|
? schemaToTs(t.schema, (n) => `$O${t.spec}.${typeName(n)}`)
|
|
120
120
|
: t.kind === 'call'
|
|
121
121
|
? (t.args ? `${decl('CallArgs')}<typeof $A.${t.name}>` : `Awaited<ReturnType<typeof $A.${t.name}>>`)
|
|
@@ -135,6 +135,10 @@ export function shadow(o) {
|
|
|
135
135
|
f.names.forEach((n, k) => { at(f.locs?.[k]); line(`${n},`); });
|
|
136
136
|
line(`} = ${f.init === null ? '$any' : `$e${f.init}()`};`);
|
|
137
137
|
}
|
|
138
|
+
else if (f.kind === 'let') {
|
|
139
|
+
at(f.loc);
|
|
140
|
+
line(`const ${f.name} = ${f.init === null ? '$any' : `$e${f.init}()`};`);
|
|
141
|
+
}
|
|
138
142
|
else {
|
|
139
143
|
at(f.loc);
|
|
140
144
|
line(`const ${f.item} = ${f.init === null ? '$any' : `$item($e${f.init}())`};`);
|
|
@@ -233,7 +237,16 @@ export interface Meta {
|
|
|
233
237
|
hasPrev?: boolean;
|
|
234
238
|
hasNext?: boolean;
|
|
235
239
|
}
|
|
236
|
-
export interface Err { status: number | null; body: any; message: string }
|
|
240
|
+
export interface Err { status: number | null; body: any; message: string; detail: string | null }
|
|
241
|
+
declare global {
|
|
242
|
+
/**
|
|
243
|
+
* Value types for attributes bound with data-sn-attr. Empty here; a library extends it to type its own
|
|
244
|
+
* attributes, e.g. tinystyle (import type {} from '@tiaforge-ui/tinystyle/sinter' in the types module).
|
|
245
|
+
*/
|
|
246
|
+
interface SinterAttributes {}
|
|
247
|
+
}
|
|
248
|
+
/** what data-sn-attr accepts: the keys SinterAttributes types, anything else as before */
|
|
249
|
+
export type Attrs = { [K in keyof SinterAttributes]?: SinterAttributes[K] } & Record<string, unknown>;
|
|
237
250
|
export interface CallContext { signal: AbortSignal; request(url: string, init?: { method?: string; body?: unknown; headers?: Record<string, string>; signal?: AbortSignal }): Promise<any>; el: Element | null }
|
|
238
251
|
/** an action's parameters without its trailing context */
|
|
239
252
|
export type CallArgs<F> = F extends (...a: infer P) => any ? (P extends [...infer A, infer L] ? ([L] extends [CallContext] ? A : P) : P) : never;
|
|
@@ -246,7 +259,8 @@ export interface Store<R> {
|
|
|
246
259
|
patch<K extends keyof R & string>(name: K, item: Partial<Item<R[K]>>, set: Partial<Item<R[K]>>): void;
|
|
247
260
|
remove<K extends keyof R & string>(name: K, item: Partial<Item<R[K]>>): void;
|
|
248
261
|
page(name: keyof R & string, to: 'next' | 'prev' | 'first' | 'last' | number): void;
|
|
249
|
-
|
|
262
|
+
showModal(id: string): void;
|
|
263
|
+
close(id?: string): void;
|
|
250
264
|
reset(): void;
|
|
251
265
|
emit(event: string, detail?: unknown): void;
|
|
252
266
|
}
|
|
@@ -7,7 +7,7 @@ export interface CheckLoc {
|
|
|
7
7
|
chain: string[];
|
|
8
8
|
}
|
|
9
9
|
/**
|
|
10
|
-
* A scope frame: a `data-sn-state` object or a `data-sn-
|
|
10
|
+
* A scope frame: a `data-sn-state` object, a `data-sn-for` loop or a `data-sn-let` name. `init` is the
|
|
11
11
|
* expression that creates it (the state literal, the list), or null when that
|
|
12
12
|
* expression did not compile.
|
|
13
13
|
*/
|
|
@@ -27,6 +27,13 @@ export type CheckFrame =
|
|
|
27
27
|
init: number | null;
|
|
28
28
|
loc?: CheckLoc;
|
|
29
29
|
indexLoc?: CheckLoc;
|
|
30
|
+
}
|
|
31
|
+
/** a `data-sn-let` name; `loc`: where the name is written */
|
|
32
|
+
| {
|
|
33
|
+
kind: 'let';
|
|
34
|
+
name: string;
|
|
35
|
+
init: number | null;
|
|
36
|
+
loc?: CheckLoc;
|
|
30
37
|
};
|
|
31
38
|
/** A type the checker writes for something other than a resource. */
|
|
32
39
|
export type TypeRef =
|
|
@@ -53,13 +60,18 @@ export type TypeRef =
|
|
|
53
60
|
kind: 'call';
|
|
54
61
|
name: string;
|
|
55
62
|
args?: boolean;
|
|
63
|
+
}
|
|
64
|
+
/** a type sinter declares itself (\`Attrs\` for \`data-sn-attr\`) */
|
|
65
|
+
| {
|
|
66
|
+
kind: 'decl';
|
|
67
|
+
name: string;
|
|
56
68
|
};
|
|
57
69
|
export interface CheckExpr {
|
|
58
70
|
src: string;
|
|
59
71
|
/** `value` returns the expression; `handler` runs it as statements */
|
|
60
72
|
mode: 'value' | 'handler';
|
|
61
73
|
loc: CheckLoc;
|
|
62
|
-
/** frames visible here, in the order sinter resolves them last: states
|
|
74
|
+
/** frames visible here, in the order sinter resolves them last: states, then loops, then lets, each outer to inner */
|
|
63
75
|
frames: number[];
|
|
64
76
|
/** tag of the element the expression sits on, for `$el` */
|
|
65
77
|
tag: string;
|
package/dist/compiler/compile.js
CHANGED
|
@@ -8,7 +8,7 @@ import { ATTRIBUTES, ELEMENTS } from './vocabulary.js';
|
|
|
8
8
|
import { matchOperation, matchResource, queryNames, walk } from './openapi.js';
|
|
9
9
|
const ATTRS = ATTRIBUTES.map((a) => a.name);
|
|
10
10
|
const METHODS = ['get', 'post', 'put', 'patch', 'delete'];
|
|
11
|
-
const ACTION_ONLY = ['body', 'trigger', 'debounce', 'concurrency', 'optimistic', 'then', 'catch'];
|
|
11
|
+
const ACTION_ONLY = ['body', 'trigger', 'debounce', 'concurrency', 'optimistic', 'then', 'catch', 'confirm', 'encoding'];
|
|
12
12
|
const UNSAFE_ELEMENTS = new Set(['script', 'style', 'iframe', 'object', 'embed', 'base', 'meta', 'link', 'animate', 'set', 'animatemotion', 'animatetransform']);
|
|
13
13
|
const FORM_CONTROLS = new Set(['input', 'select', 'textarea']);
|
|
14
14
|
const RESOURCE_ATTRS = ELEMENTS.find((e) => e.name === 'sn-resource').attrs;
|
|
@@ -55,7 +55,7 @@ class Compiler {
|
|
|
55
55
|
this.page = {
|
|
56
56
|
resources: new Map(), helpers: new Set(opts.helpers.names), namespaces: ns,
|
|
57
57
|
actions: new Set([...(opts.actions?.names ?? []), ...Object.keys(opts.actions?.namespaces ?? {})]),
|
|
58
|
-
usedHelpers: new Set(), usedNs: new Map(), usesNow: false, pathParams: new Set(),
|
|
58
|
+
usedHelpers: new Set(), usedNs: new Map(), usesNow: false, pathParams: new Set(), dialogs: new Set(),
|
|
59
59
|
};
|
|
60
60
|
}
|
|
61
61
|
run(html) {
|
|
@@ -100,7 +100,7 @@ class Compiler {
|
|
|
100
100
|
return {
|
|
101
101
|
html: parse5.serialize(doc),
|
|
102
102
|
ir, fns: this.fns,
|
|
103
|
-
features: [...(this.opts.dev ? ['dev'] : []), ...['sse', 'optimistic', 'paginate', 'persist', 'cache', 'each'].filter((f) => this.features.has(f))],
|
|
103
|
+
features: [...(this.opts.dev ? ['dev'] : []), ...['sse', 'optimistic', 'paginate', 'persist', 'cache', 'each', 'listbox'].filter((f) => this.features.has(f))],
|
|
104
104
|
helpers: { user, builtin }, namespaces, calls: [...this.calls].sort(),
|
|
105
105
|
diagnostics: this.diagnostics, files: [...this.files].sort(),
|
|
106
106
|
...(this.check ? { check: this.check } : {}),
|
|
@@ -321,7 +321,7 @@ class Compiler {
|
|
|
321
321
|
// the browser merges adjacent text when it parses the output; merge now so node paths agree
|
|
322
322
|
mergeText(parent);
|
|
323
323
|
}
|
|
324
|
-
// ---- declarations (first pass: names only, so expressions can see every resource)
|
|
324
|
+
// ---- declarations (first pass: names only, so expressions can see every resource and dialog)
|
|
325
325
|
declare(parent, inTemplate = false) {
|
|
326
326
|
for (const child of parent.childNodes) {
|
|
327
327
|
if (!isElement(child))
|
|
@@ -349,6 +349,11 @@ class Compiler {
|
|
|
349
349
|
this.resourceEls.set(name, child);
|
|
350
350
|
continue;
|
|
351
351
|
}
|
|
352
|
+
if (child.tagName === 'dialog') {
|
|
353
|
+
const id = attr(child, 'id');
|
|
354
|
+
if (id)
|
|
355
|
+
this.page.dialogs.add(id);
|
|
356
|
+
}
|
|
352
357
|
this.declare(child, inTemplate);
|
|
353
358
|
if (child.tagName === 'template')
|
|
354
359
|
this.declare(child.content, true);
|
|
@@ -412,7 +417,7 @@ class Compiler {
|
|
|
412
417
|
// unknown attributes
|
|
413
418
|
const known = new Set(ATTRS.map((a) => `data-sn-${a}`));
|
|
414
419
|
for (const a of sn) {
|
|
415
|
-
if (known.has(a.name) || a.name.startsWith('data-sn-on-'))
|
|
420
|
+
if (known.has(a.name) || a.name.startsWith('data-sn-on-') || a.name.startsWith('data-sn-debounce-'))
|
|
416
421
|
continue;
|
|
417
422
|
this.report(el, a.name, 'SN101', `\`${a.name}\` is not a sinter attribute.${didYouMean(a.name, [...known, 'data-sn-on-click'])}`, 0);
|
|
418
423
|
}
|
|
@@ -466,13 +471,32 @@ class Compiler {
|
|
|
466
471
|
const locs = first.map((e) => this.locOf(el, 'data-sn-state', e.offset));
|
|
467
472
|
this.check.frames.push({ kind: 'state', names: [...entry.names], init: this.lastExpr, locs });
|
|
468
473
|
}
|
|
469
|
-
|
|
474
|
+
this.checkNames(el, 'data-sn-state', scope, [...entry.names]);
|
|
475
|
+
scope = { ...scope, states: [...scope.states, entry] };
|
|
470
476
|
if (has('persist') && !has('model')) {
|
|
471
477
|
node.persist = get('persist');
|
|
472
478
|
this.features.add('persist');
|
|
473
479
|
}
|
|
474
480
|
}
|
|
475
481
|
}
|
|
482
|
+
// derived names: the element and its subtree read them like state
|
|
483
|
+
if (has('let'))
|
|
484
|
+
scope = this.lets(el, scope);
|
|
485
|
+
// a text field with suggestions: the listbox feature handles the keys (see src/listbox.ts)
|
|
486
|
+
if (has('listbox')) {
|
|
487
|
+
const list = get('listbox');
|
|
488
|
+
if (!['input', 'textarea'].includes(el.tagName))
|
|
489
|
+
this.report(el, 'data-sn-listbox', 'SN102', '`data-sn-listbox` belongs on a text `<input>` or `<textarea>`', 0);
|
|
490
|
+
else if (list && !/^[A-Za-z][\w-]*$/.test(list))
|
|
491
|
+
this.report(el, 'data-sn-listbox', 'SN401', '`data-sn-listbox` takes the literal `id` of the suggestion list, or nothing for the nearest `[role="listbox"]` around the input', 0);
|
|
492
|
+
else {
|
|
493
|
+
setAttr(el, 'role', 'combobox');
|
|
494
|
+
if (list)
|
|
495
|
+
setAttr(el, 'aria-controls', list);
|
|
496
|
+
setAttr(el, 'aria-autocomplete', 'list');
|
|
497
|
+
this.features.add('listbox');
|
|
498
|
+
}
|
|
499
|
+
}
|
|
476
500
|
const isTemplate = el.tagName === 'template';
|
|
477
501
|
const valueB = (k, name) => {
|
|
478
502
|
const r = this.expr(el, name, scope, { mode: 'value' });
|
|
@@ -516,6 +540,16 @@ class Compiler {
|
|
|
516
540
|
if (!sn.some((a) => a.name.startsWith('data-sn-on-')))
|
|
517
541
|
this.report(el, 'data-sn-debounce', 'SN102', '`data-sn-debounce` needs a method attribute (`data-sn-get`, `-post`, …) or a `data-sn-on-*` handler on the same element', 0);
|
|
518
542
|
}
|
|
543
|
+
// `data-sn-debounce-<event>`: only that event's handler waits
|
|
544
|
+
for (const a of sn) {
|
|
545
|
+
if (!a.name.startsWith('data-sn-debounce-'))
|
|
546
|
+
continue;
|
|
547
|
+
const event = a.name.slice('data-sn-debounce-'.length), ms = Number(a.value);
|
|
548
|
+
if (!Number.isFinite(ms) || ms < 0 || a.value.trim() === '')
|
|
549
|
+
this.report(el, a.name, 'SN401', `\`${a.name}\` takes milliseconds`, 0);
|
|
550
|
+
if (!has(`on-${event}`))
|
|
551
|
+
this.report(el, a.name, 'SN102', `\`${a.name}\` needs a \`data-sn-on-${event}\` handler on the same element`, 0);
|
|
552
|
+
}
|
|
519
553
|
for (const a of sn) {
|
|
520
554
|
if (!a.name.startsWith('data-sn-on-'))
|
|
521
555
|
continue;
|
|
@@ -525,8 +559,9 @@ class Compiler {
|
|
|
525
559
|
continue;
|
|
526
560
|
}
|
|
527
561
|
const r = this.expr(el, a.name, scope, { mode: 'handler', magic: ['$event'] });
|
|
562
|
+
const own = get(`debounce-${event}`), ms = own !== undefined ? Number(own) || 0 : handlerDebounce;
|
|
528
563
|
if (r.ok)
|
|
529
|
-
node.b.push(binding({ k: 'on', n: event, f: this.fn(r.code), w: r.writes, ...(
|
|
564
|
+
node.b.push(binding({ k: 'on', n: event, f: this.fn(r.code), w: r.writes, ...(ms > 0 ? { ms } : {}) }));
|
|
530
565
|
}
|
|
531
566
|
if (methods.length > 1)
|
|
532
567
|
this.report(el, `data-sn-${methods[1]}`, 'SN102', 'an element can have only one method attribute', 0);
|
|
@@ -627,6 +662,61 @@ class Compiler {
|
|
|
627
662
|
}
|
|
628
663
|
return r;
|
|
629
664
|
}
|
|
665
|
+
// ---- derived names
|
|
666
|
+
/**
|
|
667
|
+
* `data-sn-let="{ s: suggestions(q), n: s.length }"`: each name is a value expression, inlined
|
|
668
|
+
* where it is read; later names may use earlier ones.
|
|
669
|
+
*/
|
|
670
|
+
lets(el, scope) {
|
|
671
|
+
const src = attr(el, 'data-sn-let') ?? '';
|
|
672
|
+
let obj;
|
|
673
|
+
try {
|
|
674
|
+
obj = parseExpressionAt(src, 0, { ecmaVersion: 'latest' });
|
|
675
|
+
}
|
|
676
|
+
catch (e) {
|
|
677
|
+
this.report(el, 'data-sn-let', 'SN401', e.message.replace(/ \(\d+:\d+\)$/, ''), e.pos ?? 0);
|
|
678
|
+
return scope;
|
|
679
|
+
}
|
|
680
|
+
if (obj.type !== 'ObjectExpression') {
|
|
681
|
+
this.report(el, 'data-sn-let', 'SN401', '`data-sn-let` takes an object literal: `{ name: expression, … }`', 0);
|
|
682
|
+
return scope;
|
|
683
|
+
}
|
|
684
|
+
for (const p of obj.properties) {
|
|
685
|
+
if (p.type !== 'Property' || p.computed || p.shorthand || p.key.type !== 'Identifier' || p.kind !== 'init' || p.method) {
|
|
686
|
+
this.report(el, 'data-sn-let', 'SN401', '`data-sn-let` entries are `name: expression`', p.start);
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
const name = p.key.name;
|
|
690
|
+
if (name.startsWith('$')) {
|
|
691
|
+
this.report(el, 'data-sn-let', 'SN401', 'names starting with `$` are reserved', p.key.start);
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
if (!this.checkNames(el, 'data-sn-let', scope, [name], p.key.start))
|
|
695
|
+
continue;
|
|
696
|
+
const v = p.value;
|
|
697
|
+
const r = this.expr(el, 'data-sn-let', scope, { mode: 'value' }, src.slice(v.start, v.end), v.start);
|
|
698
|
+
const entry = { name, code: r.inner, deps: r.deps, usesEl: r.usesEl, usesIndex: r.usesIndex, resource: r.resource };
|
|
699
|
+
if (this.check) {
|
|
700
|
+
this.frameOf.set(entry, this.check.frames.length);
|
|
701
|
+
this.check.frames.push({ kind: 'let', name, init: r.ok ? this.lastExpr : null, loc: this.locOf(el, 'data-sn-let', p.key.start) });
|
|
702
|
+
}
|
|
703
|
+
scope = { ...scope, lets: [...(scope.lets ?? []), entry] };
|
|
704
|
+
}
|
|
705
|
+
return scope;
|
|
706
|
+
}
|
|
707
|
+
/** A name declared here must not hide a let, or be hidden by one (SN209); false on a conflict. */
|
|
708
|
+
checkNames(el, attrName, scope, names, offset = 0) {
|
|
709
|
+
let ok = true;
|
|
710
|
+
for (const n of names) {
|
|
711
|
+
const isLet = (scope.lets ?? []).some((l) => l.name === n);
|
|
712
|
+
const other = attrName === 'data-sn-let' && (scope.loops.some((l) => l.name === n) || scope.states.some((s) => s.names.has(n)));
|
|
713
|
+
if (isLet || other) {
|
|
714
|
+
this.report(el, attrName, 'SN209', `\`${n}\` is already defined here${isLet ? ' by `data-sn-let`' : ''}; use another name`, offset);
|
|
715
|
+
ok = false;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return ok;
|
|
719
|
+
}
|
|
630
720
|
// ---- check unit
|
|
631
721
|
/** Adds type information to the expression `expr()` recorded last. */
|
|
632
722
|
typeLast(info) {
|
|
@@ -647,7 +737,7 @@ class Compiler {
|
|
|
647
737
|
}
|
|
648
738
|
recordExpr(el, attrName, scope, o, src, shift, r) {
|
|
649
739
|
const frames = [];
|
|
650
|
-
for (const f of [...scope.states, ...scope.loops]) {
|
|
740
|
+
for (const f of [...scope.states, ...scope.loops, ...(scope.lets ?? [])]) {
|
|
651
741
|
const i = this.frameOf.get(f);
|
|
652
742
|
if (i !== undefined && !frames.includes(i))
|
|
653
743
|
frames.push(i);
|
|
@@ -670,6 +760,8 @@ class Compiler {
|
|
|
670
760
|
// ---- bindings
|
|
671
761
|
attrBinding(el, node, scope) {
|
|
672
762
|
const r = this.expr(el, 'data-sn-attr', scope, { mode: 'value', object: 'literal-keys' });
|
|
763
|
+
if (this.check)
|
|
764
|
+
this.typeLast({ returns: { kind: 'decl', name: 'Attrs' } });
|
|
673
765
|
if (!r.entries)
|
|
674
766
|
return;
|
|
675
767
|
for (const e of r.entries) {
|
|
@@ -801,10 +893,18 @@ class Compiler {
|
|
|
801
893
|
const then = fnOf('then', { mode: 'handler', magic: ['$event', '$result'] }, typed?.body != null ? { resultType: { kind: 'openapi', spec: typed.spec, schema: typed.body } }
|
|
802
894
|
: call ? { resultType: { kind: 'call', name: call.name } } : undefined);
|
|
803
895
|
const catch_ = fnOf('catch', { mode: 'handler', magic: ['$event', '$error'] });
|
|
896
|
+
const confirm = fnOf('confirm', { mode: 'value', magic: ['$event'] });
|
|
897
|
+
const encoding = get('encoding');
|
|
898
|
+
if (encoding !== undefined && encoding !== 'form' && encoding !== 'json')
|
|
899
|
+
this.report(el, 'data-sn-encoding', 'SN401', '`data-sn-encoding` is `json` (the default) or `form`', 0);
|
|
900
|
+
if (encoding !== undefined && method === 'call')
|
|
901
|
+
this.report(el, 'data-sn-encoding', 'SN102', '`data-sn-encoding` belongs to a method attribute; a call sends its own requests', 0);
|
|
804
902
|
const def = {
|
|
805
903
|
method: method.toUpperCase(), url, body: body.f, trigger, debounce, concurrency,
|
|
806
904
|
optimistic: optimistic.f, then: then.f, catch: catch_.f, w: [...new Set([...then.w, ...catch_.w])].sort(), form: isForm,
|
|
807
905
|
...(call ? { call: { args: call.args, run: call.run } } : {}),
|
|
906
|
+
...(confirm.f !== null ? { confirm: confirm.f } : {}),
|
|
907
|
+
...(encoding === 'form' ? { enc: 'form' } : {}),
|
|
808
908
|
};
|
|
809
909
|
this.actions.push(def);
|
|
810
910
|
node.b.push(binding({ k: 'on', n: trigger, x: this.actions.length - 1 }));
|
|
@@ -839,7 +939,8 @@ class Compiler {
|
|
|
839
939
|
frame.indexLoc = this.locOf(el, 'data-sn-for', value.indexOf(index, itemAt + item.length));
|
|
840
940
|
this.check.frames.push(frame);
|
|
841
941
|
}
|
|
842
|
-
|
|
942
|
+
this.checkNames(el, 'data-sn-for', scope, index ? [item, index] : [item]);
|
|
943
|
+
const inner = { ...scope, loops };
|
|
843
944
|
t.key = null;
|
|
844
945
|
t.keyFields = null;
|
|
845
946
|
if (keySrc !== undefined) {
|
|
@@ -953,7 +1054,7 @@ class Compiler {
|
|
|
953
1054
|
this.frameOf.set(loop, this.check.frames.length);
|
|
954
1055
|
this.check.frames.push({ kind: 'loop', item: m[1], index: null, init: listExpr, loc: this.locOf(el, 'each', eachAttr.indexOf(m[1])) });
|
|
955
1056
|
}
|
|
956
|
-
srcScope = { loops: [...ctx.scope.loops, loop]
|
|
1057
|
+
srcScope = { ...ctx.scope, loops: [...ctx.scope.loops, loop] };
|
|
957
1058
|
if (list.ok)
|
|
958
1059
|
each = { item: m[1], list: this.fn(list.code), ...(list.resource ? { of: list.resource } : {}) };
|
|
959
1060
|
this.features.add('each');
|
|
@@ -59,6 +59,11 @@ export const DIAGNOSTIC_DOCS = {
|
|
|
59
59
|
means: 'TypeScript found a type error in an expression. Reported by `sinter check`, `sinter({ typecheck: true })` and the editor integration.',
|
|
60
60
|
fix: 'Fix the expression, or the type: `type` on `<sn-resource>`, the OpenAPI spec, or a JSDoc cast in `data-sn-state`.',
|
|
61
61
|
},
|
|
62
|
+
SN209: {
|
|
63
|
+
example: '`s` is already defined here by `data-sn-let`; use another name',
|
|
64
|
+
means: '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.',
|
|
65
|
+
fix: 'Rename the let, or the state or loop variable.',
|
|
66
|
+
},
|
|
62
67
|
SN301: {
|
|
63
68
|
example: 'resource `mesages` is not declared on this page',
|
|
64
69
|
means: 'A store call, `$meta` or embedded data (`data-sn-data`) names a resource that no `<sn-resource>` on the page or its partials declares.',
|
|
@@ -99,6 +104,11 @@ export const DIAGNOSTIC_DOCS = {
|
|
|
99
104
|
means: 'The resource matched an operation in the OpenAPI spec, but its `items` or `total` path is not in the response schema.',
|
|
100
105
|
fix: 'Fix the path in `items` or `total`, or the spec if the API changed.',
|
|
101
106
|
},
|
|
107
|
+
SN309: {
|
|
108
|
+
example: 'no `<dialog id="compose-dialg">` on this page. Did you mean `compose-dialog`?',
|
|
109
|
+
means: '`showModal(id)` and `close(id)` name a `<dialog>` by its literal `id`, which must be on the page or one of its partials.',
|
|
110
|
+
fix: 'Fix the id, or add `id` to the `<dialog>`. `close()` without an argument closes the dialog around the element.',
|
|
111
|
+
},
|
|
102
112
|
SN310: {
|
|
103
113
|
example: '`pgp.decrpyt` is not exported by the actions module. Did you mean `pgp.decrypt`?',
|
|
104
114
|
means: '`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`.',
|
|
@@ -28,6 +28,7 @@ export declare const CODES: {
|
|
|
28
28
|
readonly SN206: 'unknown-namespace-member';
|
|
29
29
|
readonly SN207: 'unsafe-sink';
|
|
30
30
|
readonly SN208: 'type-error';
|
|
31
|
+
readonly SN209: 'name-conflict';
|
|
31
32
|
readonly SN301: 'unknown-resource';
|
|
32
33
|
readonly SN302: 'duplicate-resource';
|
|
33
34
|
readonly SN303: 'unknown-meta-field';
|
|
@@ -36,6 +37,7 @@ export declare const CODES: {
|
|
|
36
37
|
readonly SN306: 'include-cycle';
|
|
37
38
|
readonly SN307: 'openapi-invalid';
|
|
38
39
|
readonly SN308: 'unknown-schema-field';
|
|
40
|
+
readonly SN309: 'unknown-dialog';
|
|
39
41
|
readonly SN310: 'unknown-action';
|
|
40
42
|
readonly SN401: 'parse-error';
|
|
41
43
|
readonly SN501: 'unused-resource';
|
|
@@ -11,6 +11,7 @@ export const CODES = {
|
|
|
11
11
|
SN206: 'unknown-namespace-member',
|
|
12
12
|
SN207: 'unsafe-sink',
|
|
13
13
|
SN208: 'type-error',
|
|
14
|
+
SN209: 'name-conflict',
|
|
14
15
|
SN301: 'unknown-resource',
|
|
15
16
|
SN302: 'duplicate-resource',
|
|
16
17
|
SN303: 'unknown-meta-field',
|
|
@@ -19,6 +20,7 @@ export const CODES = {
|
|
|
19
20
|
SN306: 'include-cycle',
|
|
20
21
|
SN307: 'openapi-invalid',
|
|
21
22
|
SN308: 'unknown-schema-field',
|
|
23
|
+
SN309: 'unknown-dialog',
|
|
22
24
|
SN310: 'unknown-action',
|
|
23
25
|
SN401: 'parse-error',
|
|
24
26
|
SN501: 'unused-resource',
|
package/dist/compiler/expr.d.ts
CHANGED
|
@@ -19,6 +19,16 @@ export interface StateEntry {
|
|
|
19
19
|
sid: number;
|
|
20
20
|
names: Set<string>;
|
|
21
21
|
}
|
|
22
|
+
/** A `data-sn-let` name: a value expression inlined wherever the name is read. */
|
|
23
|
+
export interface LetEntry {
|
|
24
|
+
name: string;
|
|
25
|
+
/** the compiled expression (without the `($) =>` wrapper) */
|
|
26
|
+
code: string;
|
|
27
|
+
deps: string[];
|
|
28
|
+
usesEl: boolean;
|
|
29
|
+
usesIndex: boolean;
|
|
30
|
+
resource: string | null;
|
|
31
|
+
}
|
|
22
32
|
export interface ResourceInfo {
|
|
23
33
|
name: string;
|
|
24
34
|
key: string[] | null;
|
|
@@ -30,6 +40,8 @@ export interface ResourceInfo {
|
|
|
30
40
|
export interface Scope {
|
|
31
41
|
loops: LoopEntry[];
|
|
32
42
|
states: StateEntry[];
|
|
43
|
+
/** `data-sn-let` names, outer to inner */
|
|
44
|
+
lets?: LetEntry[];
|
|
33
45
|
}
|
|
34
46
|
export interface PageInfo {
|
|
35
47
|
resources: Map<string, ResourceInfo>;
|
|
@@ -43,6 +55,8 @@ export interface PageInfo {
|
|
|
43
55
|
usesNow: boolean;
|
|
44
56
|
/** `:name` segments of the page's `data-sn-path` patterns */
|
|
45
57
|
pathParams: Set<string>;
|
|
58
|
+
/** literal `id`s of the page's `<dialog>` elements, for `showModal()` and `close()` */
|
|
59
|
+
dialogs: Set<string>;
|
|
46
60
|
}
|
|
47
61
|
export interface ExprOptions {
|
|
48
62
|
mode: Mode;
|
|
@@ -66,6 +80,8 @@ export interface ObjectEntry {
|
|
|
66
80
|
export interface ExprResult {
|
|
67
81
|
ok: boolean;
|
|
68
82
|
code: string;
|
|
83
|
+
/** `code` without its `($) =>` wrapper (value expressions) */
|
|
84
|
+
inner: string;
|
|
69
85
|
deps: string[];
|
|
70
86
|
writes: string[];
|
|
71
87
|
usesEl: boolean;
|
package/dist/compiler/expr.js
CHANGED
|
@@ -5,7 +5,7 @@ import * as acorn from 'acorn';
|
|
|
5
5
|
import { didYouMean } from './diagnostics.js';
|
|
6
6
|
const EVERYWHERE_MAGIC = ['$el', '$url', '$now', '$locale', '$meta'];
|
|
7
7
|
export const ALL_MAGIC = ['$event', '$el', '$url', '$now', '$locale', '$result', '$error', '$meta'];
|
|
8
|
-
export const STORE_FNS = ['refresh', 'upsert', 'patch', 'remove', 'page', 'close', 'reset', 'emit'];
|
|
8
|
+
export const STORE_FNS = ['refresh', 'upsert', 'patch', 'remove', 'page', 'showModal', 'close', 'reset', 'emit'];
|
|
9
9
|
export const BUILTIN_HELPERS = ['date', 'time', 'datetime', 'relative', 'number', 'bytes'];
|
|
10
10
|
export const META_FIELDS = ['loading', 'error', 'loadedAt', 'pending', 'total', 'page', 'pages', 'from', 'to', 'hasPrev', 'hasNext'];
|
|
11
11
|
const PAGE_FIELDS = ['page', 'pages', 'from', 'to', 'hasPrev', 'hasNext'];
|
|
@@ -96,7 +96,7 @@ export function analyze(src, scope, page, opts) {
|
|
|
96
96
|
return fail();
|
|
97
97
|
}
|
|
98
98
|
function fail() {
|
|
99
|
-
return { ok: false, code: '() => undefined', deps: [], writes: [], usesEl: false, usesIndex: false, path: null, resource: null, ast: null };
|
|
99
|
+
return { ok: false, code: '() => undefined', inner: 'undefined', deps: [], writes: [], usesEl: false, usesIndex: false, path: null, resource: null, ast: null };
|
|
100
100
|
}
|
|
101
101
|
// ---- resolution
|
|
102
102
|
function resolve(name, locals, offset, member) {
|
|
@@ -127,6 +127,12 @@ export function analyze(src, scope, page, opts) {
|
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
if (!opts.payload) {
|
|
130
|
+
// a let cannot share a name with a loop or state in its scope (SN209), so lets resolve first
|
|
131
|
+
const lets = scope.lets ?? [];
|
|
132
|
+
for (let i = lets.length - 1; i >= 0; i--) {
|
|
133
|
+
if (lets[i].name === name)
|
|
134
|
+
return { kind: 'let', entry: lets[i] };
|
|
135
|
+
}
|
|
130
136
|
for (let i = scope.loops.length - 1; i >= 0; i--) {
|
|
131
137
|
if (scope.loops[i].name === name)
|
|
132
138
|
return { kind: 'loop', entry: scope.loops[i] };
|
|
@@ -170,7 +176,7 @@ export function analyze(src, scope, page, opts) {
|
|
|
170
176
|
return { kind: 'payload' };
|
|
171
177
|
}
|
|
172
178
|
const candidates = [
|
|
173
|
-
...scope.loops.map((l) => l.name), ...scope.states.flatMap((s) => [...s.names]),
|
|
179
|
+
...(scope.lets ?? []).map((l) => l.name), ...scope.loops.map((l) => l.name), ...scope.states.flatMap((s) => [...s.names]),
|
|
174
180
|
...page.resources.keys(), ...page.helpers, ...page.namespaces.keys(), ...STORE_FNS, ...BUILTIN_HELPERS,
|
|
175
181
|
];
|
|
176
182
|
report('SN201', `\`${name}\` is not defined here.${didYouMean(name, candidates)}`, offset);
|
|
@@ -183,6 +189,7 @@ export function analyze(src, scope, page, opts) {
|
|
|
183
189
|
case 'global':
|
|
184
190
|
case 'helper': return name;
|
|
185
191
|
case 'loop': return `$.${name}`;
|
|
192
|
+
case 'let': return `(${res.entry.code})`;
|
|
186
193
|
case 'state': return `$.$s${sid}.${name}`;
|
|
187
194
|
case 'resource': return `$.$r.${name}`;
|
|
188
195
|
case 'magic': return `$.${name}`;
|
|
@@ -202,6 +209,7 @@ export function analyze(src, scope, page, opts) {
|
|
|
202
209
|
return { segs: res.entry.alias ? [...res.entry.alias] : null, resource: res.entry.resource };
|
|
203
210
|
case 'local':
|
|
204
211
|
return { segs: res.info.alias ? [...res.info.alias] : null, resource: res.info.resource };
|
|
212
|
+
case 'let': return { segs: null, resource: res.entry.resource };
|
|
205
213
|
default: return { segs: null, resource: null };
|
|
206
214
|
}
|
|
207
215
|
}
|
|
@@ -315,6 +323,15 @@ export function analyze(src, scope, page, opts) {
|
|
|
315
323
|
sid = res.sid;
|
|
316
324
|
if (res.kind === 'loop' && res.entry.kind === 'index')
|
|
317
325
|
usesIndex = true;
|
|
326
|
+
if (res.kind === 'let') {
|
|
327
|
+
// the inlined expression reads what the let reads
|
|
328
|
+
for (const d of res.entry.deps)
|
|
329
|
+
deps.add(d);
|
|
330
|
+
if (res.entry.usesEl)
|
|
331
|
+
usesEl = true;
|
|
332
|
+
if (res.entry.usesIndex)
|
|
333
|
+
usesIndex = true;
|
|
334
|
+
}
|
|
318
335
|
const text = emitName(n.name, res, sid);
|
|
319
336
|
if (text !== n.name || shorthand)
|
|
320
337
|
edits.push({ start: n.start, end: n.end, text: shorthand ? `${n.name}: ${text}` : text });
|
|
@@ -472,6 +489,10 @@ export function analyze(src, scope, page, opts) {
|
|
|
472
489
|
report('SN203', 'only state variables and their properties can be assigned', at);
|
|
473
490
|
return;
|
|
474
491
|
}
|
|
492
|
+
if (cp.res.kind === 'let') {
|
|
493
|
+
report('SN203', `\`${cp.root}\` is derived (\`data-sn-let\`) and cannot be assigned; assign the state it is computed from`, at);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
475
496
|
if (cp.resource) {
|
|
476
497
|
report('SN205', `\`${src.slice(target.start, target.end)}\` mutates a resource. Use \`patch('${cp.resource}', …, {…})\``, at, 'resources are read-only in expressions; copy with structuredClone() to edit');
|
|
477
498
|
return;
|
|
@@ -570,6 +591,15 @@ export function analyze(src, scope, page, opts) {
|
|
|
570
591
|
return;
|
|
571
592
|
}
|
|
572
593
|
const first = n.arguments[0];
|
|
594
|
+
if (name === 'showModal' || (name === 'close' && first)) {
|
|
595
|
+
if (!first || first.type !== 'Literal' || typeof first.value !== 'string') {
|
|
596
|
+
report('SN309', `\`${name}()\` takes the dialog's \`id\` as a string literal`, n.start);
|
|
597
|
+
}
|
|
598
|
+
else if (!page.dialogs.has(first.value)) {
|
|
599
|
+
report('SN309', `no \`<dialog id="${first.value}">\` on this page.${didYouMean(first.value, page.dialogs)}`, first.start);
|
|
600
|
+
}
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
573
603
|
if (['refresh', 'upsert', 'patch', 'remove', 'page'].includes(name)) {
|
|
574
604
|
if (!first || first.type !== 'Literal' || typeof first.value !== 'string') {
|
|
575
605
|
report('SN301', `\`${name}()\` takes the resource name as a string literal`, n.start);
|
|
@@ -842,7 +872,7 @@ export function analyze(src, scope, page, opts) {
|
|
|
842
872
|
}
|
|
843
873
|
}
|
|
844
874
|
}
|
|
845
|
-
return { ok, code, deps: [...deps].sort(), writes: [...writes].sort(), usesEl, usesIndex, entries, path, resource, ast, ...(opts.payload ? { payloadNames: [...payloadNames].sort(), payloadStates: [...payloadStates].sort() } : {}) };
|
|
875
|
+
return { ok, code, inner: out, deps: [...deps].sort(), writes: [...writes].sort(), usesEl, usesIndex, entries, path, resource, ast, ...(opts.payload ? { payloadNames: [...payloadNames].sort(), payloadStates: [...payloadStates].sort() } : {}) };
|
|
846
876
|
}
|
|
847
877
|
export function applyEdits(src, edits) {
|
|
848
878
|
const sorted = [...edits].sort((a, b) => b.start - a.start || (b.end - b.start) - (a.end - a.start));
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// The compiler, llms.txt and html-custom-data.json are all built from it.
|
|
3
3
|
export const ATTRIBUTES = [
|
|
4
4
|
{ name: 'state', value: 'object literal', doc: 'declares local reactive state for the element and its subtree' },
|
|
5
|
+
{ name: 'let', value: 'object literal {name: expression}', doc: 'derived names for the element and its subtree, computed where they are read; later names may use earlier ones' },
|
|
5
6
|
{ name: 'text', value: 'value expression', doc: 'sets textContent; never HTML' },
|
|
6
7
|
{ name: 'attr', value: 'object literal {href: …}', doc: 'literal keys; null/false removes, true sets empty; URL values are checked' },
|
|
7
8
|
{ name: 'class', value: 'object literal {unread: …}', doc: 'toggles each class' },
|
|
@@ -13,6 +14,7 @@ export const ATTRIBUTES = [
|
|
|
13
14
|
{ name: 'key', value: 'value expression', doc: 'row identity; defaults to the resource key or object identity' },
|
|
14
15
|
{ name: 'if', value: 'value expression', doc: 'renders a <template> while truthy' },
|
|
15
16
|
{ name: 'else', value: '', doc: 'on a <template> directly after an if template' },
|
|
17
|
+
{ name: 'listbox', value: 'id of the suggestion list, or empty', doc: '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`' },
|
|
16
18
|
{ name: 'persist', value: 'storage key', doc: 'persists the model or state in localStorage under sn:' },
|
|
17
19
|
{ name: 'get', value: 'URL template', doc: 'GET action; latest wins' },
|
|
18
20
|
{ name: 'post', value: 'URL template', doc: 'POST action' },
|
|
@@ -26,11 +28,14 @@ export const ATTRIBUTES = [
|
|
|
26
28
|
{ name: 'concurrency', value: 'block | queue | latest', doc: 'mutations only; default block for forms, queue otherwise' },
|
|
27
29
|
{ name: 'optimistic', value: 'handler expression', doc: 'patch/upsert/remove calls applied before the request, rolled back on failure' },
|
|
28
30
|
{ name: 'then', value: 'handler expression', doc: 'runs after a 2xx response; $result = parsed body' },
|
|
29
|
-
{ name: 'catch', value: 'handler expression', doc: 'runs after an error; $error = {status, body, message}' },
|
|
31
|
+
{ name: 'catch', value: 'handler expression', doc: 'runs after an error; $error = {status, body, message, detail}' },
|
|
32
|
+
{ name: 'confirm', value: 'value expression', doc: 'asks this question before the action runs (configure({ confirm }), window.confirm by default); no question when falsy' },
|
|
33
|
+
{ name: 'encoding', value: 'json | form', doc: 'form: the body (data-sn-body, arrays as repeated keys, or the form) goes as application/x-www-form-urlencoded' },
|
|
30
34
|
{ name: 'path', value: 'URL patterns /a/:b …', doc: 'on <html>: the path shapes this page is served for; `:name` segments appear read-only in $url' },
|
|
31
35
|
{ name: 'data', value: 'resource name', doc: 'on <script type="application/json"> in <head>: initial data for a resource' },
|
|
32
36
|
];
|
|
33
37
|
export const ON_DOC = { name: 'on-<event>', value: 'handler expression', doc: 'plain event handler, no request; e.g. data-sn-on-click' };
|
|
38
|
+
export const DEBOUNCE_ON_DOC = { name: 'debounce-<event>', value: 'milliseconds', doc: 'delays only that event\'s data-sn-on-<event> handler (it runs with the last event); e.g. data-sn-debounce-input' };
|
|
34
39
|
export const ELEMENTS = [
|
|
35
40
|
{ name: 'sn-resource', attrs: ['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'], doc: 'a named, page-wide cache of server data; `cache` (or `cache="10m"`) shows the data from the last page load at once, then revalidates' },
|
|
36
41
|
{ name: 'sn-sync', attrs: ['src'], doc: 'one server event stream' },
|
package/dist/ir.d.ts
CHANGED
|
@@ -134,6 +134,10 @@ export interface ActionDef {
|
|
|
134
134
|
w: string[];
|
|
135
135
|
/** element is a form (serialize, disable in block mode) */
|
|
136
136
|
form: boolean;
|
|
137
|
+
/** `data-sn-confirm`: the question asked before the action runs (none when it is falsy) */
|
|
138
|
+
confirm?: number;
|
|
139
|
+
/** `data-sn-encoding="form"`: the body goes as application/x-www-form-urlencoded */
|
|
140
|
+
enc?: 'form';
|
|
137
141
|
}
|
|
138
142
|
export interface IR {
|
|
139
143
|
v: 1;
|
package/dist/listbox.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** the input's list: `aria-controls`, else the nearest listbox around it */
|
|
2
|
+
const listOf = (input) => {
|
|
3
|
+
const id = input.getAttribute('aria-controls');
|
|
4
|
+
if (id)
|
|
5
|
+
return input.ownerDocument.getElementById(id);
|
|
6
|
+
for (let p = input.parentElement; p; p = p.parentElement) {
|
|
7
|
+
const list = p.querySelector('[role="listbox"]');
|
|
8
|
+
if (list)
|
|
9
|
+
return list;
|
|
10
|
+
}
|
|
11
|
+
return null;
|
|
12
|
+
};
|
|
13
|
+
const options = (input) => {
|
|
14
|
+
const list = listOf(input);
|
|
15
|
+
if (!list || list.closest('[hidden]'))
|
|
16
|
+
return [];
|
|
17
|
+
return [...list.querySelectorAll('[role="option"]')].filter((o) => !o.closest('[hidden]'));
|
|
18
|
+
};
|
|
19
|
+
const mark = (all, next) => {
|
|
20
|
+
for (const o of all)
|
|
21
|
+
o.setAttribute('aria-selected', String(o == next));
|
|
22
|
+
next?.scrollIntoView?.({ block: 'nearest' });
|
|
23
|
+
};
|
|
24
|
+
const listbox = {
|
|
25
|
+
name: 'listbox',
|
|
26
|
+
install(app) {
|
|
27
|
+
const root = app.root;
|
|
28
|
+
root.addEventListener('keydown', (e) => {
|
|
29
|
+
const input = e.target.closest?.('[role="combobox"]');
|
|
30
|
+
if (!input || e.isComposing)
|
|
31
|
+
return;
|
|
32
|
+
const all = options(input);
|
|
33
|
+
if (!all.length)
|
|
34
|
+
return;
|
|
35
|
+
const at = all.findIndex((o) => o.getAttribute('aria-selected') == 'true');
|
|
36
|
+
const key = e.key;
|
|
37
|
+
if (key == 'ArrowDown' || key == 'ArrowUp') {
|
|
38
|
+
e.preventDefault();
|
|
39
|
+
mark(all, key == 'ArrowDown' ? all[Math.min(at + 1, all.length - 1)] : at > 0 ? all[at - 1] : null);
|
|
40
|
+
}
|
|
41
|
+
else if ((key == 'Enter' && at >= 0) || (key == 'Tab' && (at >= 0 || all.length == 1))) {
|
|
42
|
+
e.preventDefault();
|
|
43
|
+
mark(all, null);
|
|
44
|
+
all[Math.max(at, 0)].click();
|
|
45
|
+
}
|
|
46
|
+
else if (key == 'Escape') {
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
mark(all, null);
|
|
49
|
+
input.dispatchEvent(new CustomEvent('dismiss', { bubbles: true }));
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
root.addEventListener('input', (e) => {
|
|
53
|
+
const input = e.target.closest?.('[role="combobox"]');
|
|
54
|
+
if (input)
|
|
55
|
+
mark(options(input), null);
|
|
56
|
+
});
|
|
57
|
+
root.addEventListener('mousedown', (e) => {
|
|
58
|
+
// only in lists that belong to a combobox: keep the focus in the input
|
|
59
|
+
const option = e.target.closest?.('[role="option"]');
|
|
60
|
+
const list = option?.closest('[role="listbox"]');
|
|
61
|
+
if (list && [...root.querySelectorAll('[role="combobox"]')].some((c) => listOf(c) == list))
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
export default listbox;
|
package/dist/runtime/app.d.ts
CHANGED
|
@@ -81,6 +81,8 @@ export interface App {
|
|
|
81
81
|
readonly ir: IR;
|
|
82
82
|
readonly fns: Fn[];
|
|
83
83
|
readonly env: Env;
|
|
84
|
+
/** the element or document this instance is mounted on */
|
|
85
|
+
readonly root: Document | Element;
|
|
84
86
|
readonly req: RequestLayer;
|
|
85
87
|
/** the base scope: resources, status, magic names */
|
|
86
88
|
readonly page: any;
|
|
@@ -149,4 +151,9 @@ export declare function withParams(url: string, params: Record<string, string> |
|
|
|
149
151
|
/** Indexes (into `seq`) of a longest increasing subsequence, ignoring -1 entries. */
|
|
150
152
|
export declare function lis(seq: number[]): Set<number>;
|
|
151
153
|
/** Serializes a form's named controls to nested JSON, or FormData if it has a file input. */
|
|
154
|
+
/**
|
|
155
|
+
* A body as form fields (`data-sn-encoding="form"`): a form's own fields, or an object's keys with
|
|
156
|
+
* arrays as repeated keys; null and undefined values are left out. Files stay multipart.
|
|
157
|
+
*/
|
|
158
|
+
export declare function formBody(body: unknown): URLSearchParams | FormData | null;
|
|
152
159
|
export declare function serialize(form: HTMLFormElement): unknown;
|
package/dist/runtime/app.js
CHANGED
|
@@ -55,6 +55,8 @@ export function mount(root, o) {
|
|
|
55
55
|
const types = new Set(), selects = new Set(), deferred = new Set();
|
|
56
56
|
const listeners = new Map(), cleanups = [];
|
|
57
57
|
const shared = new Map();
|
|
58
|
+
/** `showModal(id)`, `close(id)`: the dialog of this mount root with that id */
|
|
59
|
+
const dialog = (id) => [...root.querySelectorAll('dialog')].find((d) => d.id === id);
|
|
58
60
|
const rootRbs = [], rootScopes = [], found = new Map();
|
|
59
61
|
let dirty = [], cur = [], scheduled = false, flushing = false, round = 0;
|
|
60
62
|
const res = (name) => {
|
|
@@ -90,7 +92,12 @@ export function mount(root, o) {
|
|
|
90
92
|
}
|
|
91
93
|
}
|
|
92
94
|
/** a failed call: SinterErrors as they are, anything else with status null */
|
|
93
|
-
const callError = (e) =>
|
|
95
|
+
const callError = (e) => {
|
|
96
|
+
if (e instanceof SinterError)
|
|
97
|
+
return e;
|
|
98
|
+
const detail = (e instanceof Error ? e.message : typeof e == 'string' ? e : '') || null;
|
|
99
|
+
return new SinterError(null, e, detail ?? String(e), detail);
|
|
100
|
+
};
|
|
94
101
|
/** Per flush round: how dirty paths reach a template's rows (0 not, 1 only marked elements, 2 any row). */
|
|
95
102
|
let rowHits = new Map();
|
|
96
103
|
/**
|
|
@@ -142,7 +149,7 @@ export function mount(root, o) {
|
|
|
142
149
|
return false;
|
|
143
150
|
};
|
|
144
151
|
const app = {
|
|
145
|
-
ir, fns, env, req, transport: o.transport ?? (() => undefined), ext: {},
|
|
152
|
+
ir, fns, env, req, root, transport: o.transport ?? (() => undefined), ext: {},
|
|
146
153
|
beforeFlush: [], afterFlush: [], onListen: [], states: [], busy: 0,
|
|
147
154
|
get isDirty() { return dirty.length > 0 || scheduled; },
|
|
148
155
|
dev: false,
|
|
@@ -267,12 +274,14 @@ export function mount(root, o) {
|
|
|
267
274
|
} };
|
|
268
275
|
},
|
|
269
276
|
storeFns: (el) => ({
|
|
277
|
+
showModal: (id) => { const d = dialog(id); if (d && !d.open)
|
|
278
|
+
d.showModal(); },
|
|
270
279
|
refresh: (n) => res(n).refetch(),
|
|
271
280
|
upsert: forward('upsert'),
|
|
272
281
|
patch: forward('patch'),
|
|
273
282
|
remove: forward('remove'),
|
|
274
283
|
page: (n, t) => app.ext.paginate.page(n, t),
|
|
275
|
-
close: () => el?.closest('dialog')?.close(),
|
|
284
|
+
close: (id) => (id ? dialog(id) : el?.closest('dialog'))?.close(),
|
|
276
285
|
reset: () => {
|
|
277
286
|
const form = el?.closest('form');
|
|
278
287
|
if (!form)
|
|
@@ -926,27 +935,42 @@ export function mount(root, o) {
|
|
|
926
935
|
body = fns[a.call.args](s);
|
|
927
936
|
else {
|
|
928
937
|
url = app.url(a.url, s);
|
|
929
|
-
|
|
938
|
+
const fields = a.form && !get && a.method != 'DELETE';
|
|
939
|
+
body = a.body != null ? fns[a.body](s) : fields ? (a.enc == 'form' ? formFields(rb.el) : serialize(rb.el)) : undefined;
|
|
940
|
+
if (a.enc == 'form')
|
|
941
|
+
body = formBody(body);
|
|
930
942
|
}
|
|
931
943
|
}
|
|
932
944
|
catch (err) {
|
|
933
945
|
return unhandled(toError(err), rb.el);
|
|
934
946
|
}
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
else if (a.concurrency == 'latest') {
|
|
944
|
-
st.w ? st.w.drop() : app.busy++;
|
|
945
|
-
st.w = { run, drop };
|
|
947
|
+
// a question first: the action runs only when the user agrees
|
|
948
|
+
const question = a.confirm != null ? fns[a.confirm](s) : null;
|
|
949
|
+
if (question) {
|
|
950
|
+
const yes = config.confirm(String(question));
|
|
951
|
+
if (yes instanceof Promise)
|
|
952
|
+
return void yes.then((y) => y && go());
|
|
953
|
+
if (!yes)
|
|
954
|
+
return;
|
|
946
955
|
}
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
956
|
+
go();
|
|
957
|
+
function go() {
|
|
958
|
+
const drop = a.optimistic != null ? app.ext.optimistic.begin(a.optimistic, s) : () => { };
|
|
959
|
+
if (get) {
|
|
960
|
+
st.c?.abort();
|
|
961
|
+
return void send(rb, a, st, url, body, drop, (st.c = new AbortController()));
|
|
962
|
+
}
|
|
963
|
+
const run = () => void send(rb, a, st, url, body, drop);
|
|
964
|
+
if (!st.n)
|
|
965
|
+
run();
|
|
966
|
+
else if (a.concurrency == 'latest') {
|
|
967
|
+
st.w ? st.w.drop() : app.busy++;
|
|
968
|
+
st.w = { run, drop };
|
|
969
|
+
}
|
|
970
|
+
else {
|
|
971
|
+
app.busy++;
|
|
972
|
+
st.q.push(run);
|
|
973
|
+
}
|
|
950
974
|
}
|
|
951
975
|
}
|
|
952
976
|
async function send(rb, a, st, url, body, drop, c) {
|
|
@@ -1229,6 +1253,47 @@ export function lis(seq) {
|
|
|
1229
1253
|
return keep;
|
|
1230
1254
|
}
|
|
1231
1255
|
/** Serializes a form's named controls to nested JSON, or FormData if it has a file input. */
|
|
1256
|
+
/**
|
|
1257
|
+
* A body as form fields (`data-sn-encoding="form"`): a form's own fields, or an object's keys with
|
|
1258
|
+
* arrays as repeated keys; null and undefined values are left out. Files stay multipart.
|
|
1259
|
+
*/
|
|
1260
|
+
export function formBody(body) {
|
|
1261
|
+
if (body == null)
|
|
1262
|
+
return null;
|
|
1263
|
+
if (body instanceof FormData) {
|
|
1264
|
+
const out = new URLSearchParams();
|
|
1265
|
+
for (const [k, v] of body) {
|
|
1266
|
+
if (typeof v != 'string')
|
|
1267
|
+
return body;
|
|
1268
|
+
out.append(k, v);
|
|
1269
|
+
}
|
|
1270
|
+
return out;
|
|
1271
|
+
}
|
|
1272
|
+
if (body instanceof URLSearchParams)
|
|
1273
|
+
return body;
|
|
1274
|
+
const out = new URLSearchParams();
|
|
1275
|
+
for (const [k, v] of Object.entries(body)) {
|
|
1276
|
+
for (const x of Array.isArray(v) ? v : [v])
|
|
1277
|
+
if (x != null)
|
|
1278
|
+
out.append(k, typeof x == 'object' ? JSON.stringify(x) : String(x));
|
|
1279
|
+
}
|
|
1280
|
+
return out;
|
|
1281
|
+
}
|
|
1282
|
+
/** A form's fields as browsers submit them; with a file input, multipart. */
|
|
1283
|
+
function formFields(form) {
|
|
1284
|
+
const out = new URLSearchParams();
|
|
1285
|
+
for (const c of form.elements) {
|
|
1286
|
+
if (!c.name || c.disabled || /^(submit|button|reset|image|fieldset|output)$/.test(c.type))
|
|
1287
|
+
continue;
|
|
1288
|
+
if (c.type == 'file')
|
|
1289
|
+
return new FormData(form);
|
|
1290
|
+
if ((c.type == 'checkbox' || c.type == 'radio') && !c.checked)
|
|
1291
|
+
continue;
|
|
1292
|
+
for (const v of c.type == 'select-multiple' ? [...c.selectedOptions].map((o) => o.value) : [c.value])
|
|
1293
|
+
out.append(c.name, v);
|
|
1294
|
+
}
|
|
1295
|
+
return out;
|
|
1296
|
+
}
|
|
1232
1297
|
export function serialize(form) {
|
|
1233
1298
|
const controls = [...form.elements];
|
|
1234
1299
|
if (controls.some((c) => c.type == 'file'))
|
package/dist/runtime/config.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface Config {
|
|
|
13
13
|
origins: string[];
|
|
14
14
|
/** the transport for a `<sn-sync>` stream URL; `undefined` (or no function) means EventSource */
|
|
15
15
|
transport: ((url: string) => Transport | undefined) | null;
|
|
16
|
+
/** `data-sn-confirm`: asks the question; the action runs on true (window.confirm by default) */
|
|
17
|
+
confirm: (question: string) => boolean | Promise<boolean>;
|
|
16
18
|
env: Env;
|
|
17
19
|
}
|
|
18
20
|
export declare const config: Config;
|
|
@@ -23,7 +25,12 @@ export declare function configure(options: Partial<Omit<Config, 'env'>> & {
|
|
|
23
25
|
export declare class SinterError extends Error {
|
|
24
26
|
readonly status: number | null;
|
|
25
27
|
readonly body: unknown;
|
|
26
|
-
|
|
28
|
+
readonly detail: string | null;
|
|
29
|
+
/**
|
|
30
|
+
* @param detail what went wrong in words: the server's message or text, or a thrown error's
|
|
31
|
+
* message; null when there is none (`message` then falls back to the HTTP status)
|
|
32
|
+
*/
|
|
33
|
+
constructor(status: number | null, body: unknown, message: string, detail?: string | null);
|
|
27
34
|
}
|
|
28
35
|
/** page-load cache entries (`cache` on `<sn-resource>`), in sessionStorage */
|
|
29
36
|
export declare const CACHE_PREFIX = "sn:c:";
|
package/dist/runtime/config.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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;
|
package/dist/runtime/request.js
CHANGED
|
@@ -44,6 +44,10 @@ 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');
|
|
@@ -71,7 +75,8 @@ 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
|
-
|
|
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
|
}
|
package/dist/testing/index.js
CHANGED
|
@@ -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;
|
package/docs/diagnostics.md
CHANGED
|
@@ -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.
|
|
132
|
+
|
|
133
|
+
**Fix:** Rename the let, or the state or loop variable.
|
|
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:
|
package/html-custom-data.json
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -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": {
|