@tiaforge-ui/sinter 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/cache.js +9 -2
- package/dist/check/index.js +2 -1
- package/dist/check/shadow.d.ts +1 -1
- package/dist/check/shadow.js +18 -4
- package/dist/compiler/check-unit.d.ts +14 -2
- package/dist/compiler/compile.js +120 -13
- package/dist/compiler/diagnostic-docs.js +13 -3
- 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 +56 -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/ir.js +21 -3
- package/dist/listbox.d.ts +3 -0
- package/dist/listbox.js +66 -0
- package/dist/paginate.js +11 -4
- package/dist/persist.js +4 -1
- package/dist/runtime/app.d.ts +9 -0
- package/dist/runtime/app.js +96 -27
- package/dist/runtime/config.d.ts +8 -1
- package/dist/runtime/config.js +9 -2
- package/dist/runtime/helpers.js +1 -1
- package/dist/runtime/request.d.ts +1 -1
- package/dist/runtime/request.js +10 -3
- package/dist/runtime/store.js +2 -1
- package/dist/sse.js +6 -3
- package/dist/testing/index.js +2 -1
- package/docs/diagnostics.md +23 -3
- 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/cache.js
CHANGED
|
@@ -68,22 +68,29 @@ const cache = {
|
|
|
68
68
|
loaded(def, r, url, items, total, appended) {
|
|
69
69
|
let t = tracked.get(def.name);
|
|
70
70
|
if (!t) {
|
|
71
|
-
const entry = (t = { def, r, url, dirty: false, appended });
|
|
71
|
+
const entry = (t = { def, r, url, dirty: false, appended, off: false });
|
|
72
72
|
// confirmed changes after the load (server events, action results); layers never fire this
|
|
73
73
|
r.subscribe(() => { entry.dirty = true; });
|
|
74
74
|
tracked.set(def.name, t);
|
|
75
75
|
}
|
|
76
76
|
t.url = url;
|
|
77
77
|
t.appended = appended;
|
|
78
|
+
t.off = false;
|
|
78
79
|
// `append` caches only its first page: later pages would be shown as if they were it
|
|
79
80
|
if (!appended)
|
|
80
81
|
write(def, url, items, total);
|
|
81
82
|
t.dirty = false;
|
|
82
83
|
},
|
|
84
|
+
// the emptied resource must not replace the entry of its last URL
|
|
85
|
+
cleared(def) {
|
|
86
|
+
const t = tracked.get(def.name);
|
|
87
|
+
if (t)
|
|
88
|
+
t.off = true;
|
|
89
|
+
},
|
|
83
90
|
};
|
|
84
91
|
app.addCleanup(app.env.listen('pagehide', () => {
|
|
85
92
|
for (const t of tracked.values()) {
|
|
86
|
-
if (!t.dirty || t.appended || t.def.paginate?.mode == 'append')
|
|
93
|
+
if (!t.dirty || t.off || t.appended || t.def.paginate?.mode == 'append')
|
|
87
94
|
continue;
|
|
88
95
|
const b = t.r.base();
|
|
89
96
|
write(t.def, t.url, b.items, b.total);
|
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
|
@@ -103,7 +103,7 @@ export function shadow(o) {
|
|
|
103
103
|
line('/** @type {string} */');
|
|
104
104
|
line('let $locale = $any;');
|
|
105
105
|
line(`/** @type {${decl('Store')}<{ ${types.join('; ')} }>} */`);
|
|
106
|
-
line(`const { ${STORE_FNS.join(', ')} } = $any;`);
|
|
106
|
+
line(`const { ${STORE_FNS.filter((f) => !unit.resources.some((r) => r.name === f)).join(', ')} } = $any;`);
|
|
107
107
|
line(`/** @template T @param {T} list @returns {${decl('Item')}<T>} */`);
|
|
108
108
|
line('function $item(list) { return $any; }');
|
|
109
109
|
/** a JSDoc tag with a type; a type the app wrote maps its errors to where it was written */
|
|
@@ -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
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
import * as parse5 from 'parse5';
|
|
4
4
|
import { parseExpressionAt } from 'acorn';
|
|
5
5
|
import { didYouMean, severityOf } from './diagnostics.js';
|
|
6
|
-
import { analyze } from './expr.js';
|
|
6
|
+
import { analyze, STORE_FNS } from './expr.js';
|
|
7
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))
|
|
@@ -340,6 +340,10 @@ class Compiler {
|
|
|
340
340
|
this.report(child, 'name', 'SN302', `resource \`${name}\` is declared twice`, 0);
|
|
341
341
|
continue;
|
|
342
342
|
}
|
|
343
|
+
if (STORE_FNS.includes(name)) {
|
|
344
|
+
this.report(child, 'name', 'SN209', `\`${name}\` is a store function (\`${name}()\`); a resource cannot take its name`, 0);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
343
347
|
const key = attr(child, 'key');
|
|
344
348
|
const paginate = attr(child, 'paginate');
|
|
345
349
|
const info = { name, key: key ? key.split(',').map((s) => s.trim()).filter(Boolean) : null, paginate: paginate ?? null, used: false };
|
|
@@ -349,6 +353,11 @@ class Compiler {
|
|
|
349
353
|
this.resourceEls.set(name, child);
|
|
350
354
|
continue;
|
|
351
355
|
}
|
|
356
|
+
if (child.tagName === 'dialog') {
|
|
357
|
+
const id = attr(child, 'id');
|
|
358
|
+
if (id)
|
|
359
|
+
this.page.dialogs.add(id);
|
|
360
|
+
}
|
|
352
361
|
this.declare(child, inTemplate);
|
|
353
362
|
if (child.tagName === 'template')
|
|
354
363
|
this.declare(child.content, true);
|
|
@@ -412,7 +421,7 @@ class Compiler {
|
|
|
412
421
|
// unknown attributes
|
|
413
422
|
const known = new Set(ATTRS.map((a) => `data-sn-${a}`));
|
|
414
423
|
for (const a of sn) {
|
|
415
|
-
if (known.has(a.name) || a.name.startsWith('data-sn-on-'))
|
|
424
|
+
if (known.has(a.name) || a.name.startsWith('data-sn-on-') || a.name.startsWith('data-sn-debounce-'))
|
|
416
425
|
continue;
|
|
417
426
|
this.report(el, a.name, 'SN101', `\`${a.name}\` is not a sinter attribute.${didYouMean(a.name, [...known, 'data-sn-on-click'])}`, 0);
|
|
418
427
|
}
|
|
@@ -466,13 +475,32 @@ class Compiler {
|
|
|
466
475
|
const locs = first.map((e) => this.locOf(el, 'data-sn-state', e.offset));
|
|
467
476
|
this.check.frames.push({ kind: 'state', names: [...entry.names], init: this.lastExpr, locs });
|
|
468
477
|
}
|
|
469
|
-
|
|
478
|
+
this.checkNames(el, 'data-sn-state', scope, [...entry.names]);
|
|
479
|
+
scope = { ...scope, states: [...scope.states, entry] };
|
|
470
480
|
if (has('persist') && !has('model')) {
|
|
471
481
|
node.persist = get('persist');
|
|
472
482
|
this.features.add('persist');
|
|
473
483
|
}
|
|
474
484
|
}
|
|
475
485
|
}
|
|
486
|
+
// derived names: the element and its subtree read them like state
|
|
487
|
+
if (has('let'))
|
|
488
|
+
scope = this.lets(el, scope);
|
|
489
|
+
// a text field with suggestions: the listbox feature handles the keys (see src/listbox.ts)
|
|
490
|
+
if (has('listbox')) {
|
|
491
|
+
const list = get('listbox');
|
|
492
|
+
if (!['input', 'textarea'].includes(el.tagName))
|
|
493
|
+
this.report(el, 'data-sn-listbox', 'SN102', '`data-sn-listbox` belongs on a text `<input>` or `<textarea>`', 0);
|
|
494
|
+
else if (list && !/^[A-Za-z][\w-]*$/.test(list))
|
|
495
|
+
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);
|
|
496
|
+
else {
|
|
497
|
+
setAttr(el, 'role', 'combobox');
|
|
498
|
+
if (list)
|
|
499
|
+
setAttr(el, 'aria-controls', list);
|
|
500
|
+
setAttr(el, 'aria-autocomplete', 'list');
|
|
501
|
+
this.features.add('listbox');
|
|
502
|
+
}
|
|
503
|
+
}
|
|
476
504
|
const isTemplate = el.tagName === 'template';
|
|
477
505
|
const valueB = (k, name) => {
|
|
478
506
|
const r = this.expr(el, name, scope, { mode: 'value' });
|
|
@@ -516,6 +544,16 @@ class Compiler {
|
|
|
516
544
|
if (!sn.some((a) => a.name.startsWith('data-sn-on-')))
|
|
517
545
|
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
546
|
}
|
|
547
|
+
// `data-sn-debounce-<event>`: only that event's handler waits
|
|
548
|
+
for (const a of sn) {
|
|
549
|
+
if (!a.name.startsWith('data-sn-debounce-'))
|
|
550
|
+
continue;
|
|
551
|
+
const event = a.name.slice('data-sn-debounce-'.length), ms = Number(a.value);
|
|
552
|
+
if (!Number.isFinite(ms) || ms < 0 || a.value.trim() === '')
|
|
553
|
+
this.report(el, a.name, 'SN401', `\`${a.name}\` takes milliseconds`, 0);
|
|
554
|
+
if (!has(`on-${event}`))
|
|
555
|
+
this.report(el, a.name, 'SN102', `\`${a.name}\` needs a \`data-sn-on-${event}\` handler on the same element`, 0);
|
|
556
|
+
}
|
|
519
557
|
for (const a of sn) {
|
|
520
558
|
if (!a.name.startsWith('data-sn-on-'))
|
|
521
559
|
continue;
|
|
@@ -525,8 +563,9 @@ class Compiler {
|
|
|
525
563
|
continue;
|
|
526
564
|
}
|
|
527
565
|
const r = this.expr(el, a.name, scope, { mode: 'handler', magic: ['$event'] });
|
|
566
|
+
const own = get(`debounce-${event}`), ms = own !== undefined ? Number(own) || 0 : handlerDebounce;
|
|
528
567
|
if (r.ok)
|
|
529
|
-
node.b.push(binding({ k: 'on', n: event, f: this.fn(r.code), w: r.writes, ...(
|
|
568
|
+
node.b.push(binding({ k: 'on', n: event, f: this.fn(r.code), w: r.writes, ...(ms > 0 ? { ms } : {}) }));
|
|
530
569
|
}
|
|
531
570
|
if (methods.length > 1)
|
|
532
571
|
this.report(el, `data-sn-${methods[1]}`, 'SN102', 'an element can have only one method attribute', 0);
|
|
@@ -627,6 +666,61 @@ class Compiler {
|
|
|
627
666
|
}
|
|
628
667
|
return r;
|
|
629
668
|
}
|
|
669
|
+
// ---- derived names
|
|
670
|
+
/**
|
|
671
|
+
* `data-sn-let="{ s: suggestions(q), n: s.length }"`: each name is a value expression, inlined
|
|
672
|
+
* where it is read; later names may use earlier ones.
|
|
673
|
+
*/
|
|
674
|
+
lets(el, scope) {
|
|
675
|
+
const src = attr(el, 'data-sn-let') ?? '';
|
|
676
|
+
let obj;
|
|
677
|
+
try {
|
|
678
|
+
obj = parseExpressionAt(src, 0, { ecmaVersion: 'latest' });
|
|
679
|
+
}
|
|
680
|
+
catch (e) {
|
|
681
|
+
this.report(el, 'data-sn-let', 'SN401', e.message.replace(/ \(\d+:\d+\)$/, ''), e.pos ?? 0);
|
|
682
|
+
return scope;
|
|
683
|
+
}
|
|
684
|
+
if (obj.type !== 'ObjectExpression') {
|
|
685
|
+
this.report(el, 'data-sn-let', 'SN401', '`data-sn-let` takes an object literal: `{ name: expression, … }`', 0);
|
|
686
|
+
return scope;
|
|
687
|
+
}
|
|
688
|
+
for (const p of obj.properties) {
|
|
689
|
+
if (p.type !== 'Property' || p.computed || p.shorthand || p.key.type !== 'Identifier' || p.kind !== 'init' || p.method) {
|
|
690
|
+
this.report(el, 'data-sn-let', 'SN401', '`data-sn-let` entries are `name: expression`', p.start);
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
const name = p.key.name;
|
|
694
|
+
if (name.startsWith('$')) {
|
|
695
|
+
this.report(el, 'data-sn-let', 'SN401', 'names starting with `$` are reserved', p.key.start);
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
if (!this.checkNames(el, 'data-sn-let', scope, [name], p.key.start))
|
|
699
|
+
continue;
|
|
700
|
+
const v = p.value;
|
|
701
|
+
const r = this.expr(el, 'data-sn-let', scope, { mode: 'value' }, src.slice(v.start, v.end), v.start);
|
|
702
|
+
const entry = { name, code: r.inner, deps: r.deps, usesEl: r.usesEl, usesIndex: r.usesIndex, resource: r.resource };
|
|
703
|
+
if (this.check) {
|
|
704
|
+
this.frameOf.set(entry, this.check.frames.length);
|
|
705
|
+
this.check.frames.push({ kind: 'let', name, init: r.ok ? this.lastExpr : null, loc: this.locOf(el, 'data-sn-let', p.key.start) });
|
|
706
|
+
}
|
|
707
|
+
scope = { ...scope, lets: [...(scope.lets ?? []), entry] };
|
|
708
|
+
}
|
|
709
|
+
return scope;
|
|
710
|
+
}
|
|
711
|
+
/** A name declared here must not hide a let, or be hidden by one (SN209); false on a conflict. */
|
|
712
|
+
checkNames(el, attrName, scope, names, offset = 0) {
|
|
713
|
+
let ok = true;
|
|
714
|
+
for (const n of names) {
|
|
715
|
+
const isLet = (scope.lets ?? []).some((l) => l.name === n);
|
|
716
|
+
const other = attrName === 'data-sn-let' && (scope.loops.some((l) => l.name === n) || scope.states.some((s) => s.names.has(n)));
|
|
717
|
+
if (isLet || other) {
|
|
718
|
+
this.report(el, attrName, 'SN209', `\`${n}\` is already defined here${isLet ? ' by `data-sn-let`' : ''}; use another name`, offset);
|
|
719
|
+
ok = false;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return ok;
|
|
723
|
+
}
|
|
630
724
|
// ---- check unit
|
|
631
725
|
/** Adds type information to the expression `expr()` recorded last. */
|
|
632
726
|
typeLast(info) {
|
|
@@ -647,7 +741,7 @@ class Compiler {
|
|
|
647
741
|
}
|
|
648
742
|
recordExpr(el, attrName, scope, o, src, shift, r) {
|
|
649
743
|
const frames = [];
|
|
650
|
-
for (const f of [...scope.states, ...scope.loops]) {
|
|
744
|
+
for (const f of [...scope.states, ...scope.loops, ...(scope.lets ?? [])]) {
|
|
651
745
|
const i = this.frameOf.get(f);
|
|
652
746
|
if (i !== undefined && !frames.includes(i))
|
|
653
747
|
frames.push(i);
|
|
@@ -670,6 +764,8 @@ class Compiler {
|
|
|
670
764
|
// ---- bindings
|
|
671
765
|
attrBinding(el, node, scope) {
|
|
672
766
|
const r = this.expr(el, 'data-sn-attr', scope, { mode: 'value', object: 'literal-keys' });
|
|
767
|
+
if (this.check)
|
|
768
|
+
this.typeLast({ returns: { kind: 'decl', name: 'Attrs' } });
|
|
673
769
|
if (!r.entries)
|
|
674
770
|
return;
|
|
675
771
|
for (const e of r.entries) {
|
|
@@ -801,10 +897,18 @@ class Compiler {
|
|
|
801
897
|
const then = fnOf('then', { mode: 'handler', magic: ['$event', '$result'] }, typed?.body != null ? { resultType: { kind: 'openapi', spec: typed.spec, schema: typed.body } }
|
|
802
898
|
: call ? { resultType: { kind: 'call', name: call.name } } : undefined);
|
|
803
899
|
const catch_ = fnOf('catch', { mode: 'handler', magic: ['$event', '$error'] });
|
|
900
|
+
const confirm = fnOf('confirm', { mode: 'value', magic: ['$event'] });
|
|
901
|
+
const encoding = get('encoding');
|
|
902
|
+
if (encoding !== undefined && encoding !== 'form' && encoding !== 'json')
|
|
903
|
+
this.report(el, 'data-sn-encoding', 'SN401', '`data-sn-encoding` is `json` (the default) or `form`', 0);
|
|
904
|
+
if (encoding !== undefined && method === 'call')
|
|
905
|
+
this.report(el, 'data-sn-encoding', 'SN102', '`data-sn-encoding` belongs to a method attribute; a call sends its own requests', 0);
|
|
804
906
|
const def = {
|
|
805
907
|
method: method.toUpperCase(), url, body: body.f, trigger, debounce, concurrency,
|
|
806
908
|
optimistic: optimistic.f, then: then.f, catch: catch_.f, w: [...new Set([...then.w, ...catch_.w])].sort(), form: isForm,
|
|
807
909
|
...(call ? { call: { args: call.args, run: call.run } } : {}),
|
|
910
|
+
...(confirm.f !== null ? { confirm: confirm.f } : {}),
|
|
911
|
+
...(encoding === 'form' ? { enc: 'form' } : {}),
|
|
808
912
|
};
|
|
809
913
|
this.actions.push(def);
|
|
810
914
|
node.b.push(binding({ k: 'on', n: trigger, x: this.actions.length - 1 }));
|
|
@@ -839,7 +943,8 @@ class Compiler {
|
|
|
839
943
|
frame.indexLoc = this.locOf(el, 'data-sn-for', value.indexOf(index, itemAt + item.length));
|
|
840
944
|
this.check.frames.push(frame);
|
|
841
945
|
}
|
|
842
|
-
|
|
946
|
+
this.checkNames(el, 'data-sn-for', scope, index ? [item, index] : [item]);
|
|
947
|
+
const inner = { ...scope, loops };
|
|
843
948
|
t.key = null;
|
|
844
949
|
t.keyFields = null;
|
|
845
950
|
if (keySrc !== undefined) {
|
|
@@ -905,8 +1010,10 @@ class Compiler {
|
|
|
905
1010
|
if (lit)
|
|
906
1011
|
parts.push(lit);
|
|
907
1012
|
const first = parts[0];
|
|
908
|
-
|
|
909
|
-
|
|
1013
|
+
// a literal origin followed by a value needs its `/`: `https://api.example.com{x}` lets `x` extend the host
|
|
1014
|
+
const origin = parts.length > 1 ? /^https?:\/\/[^/{}]+\//i : /^https?:\/\/[^/{}]+(\/|$)/i;
|
|
1015
|
+
if (typeof first !== 'string' || !(/^\/[^/\\]/.test(first) || first === '/' || origin.test(first))) {
|
|
1016
|
+
this.report(el, attrName, 'SN207', 'a URL template must start with a literal same-origin path (`/api/…`) or a literal `https://` origin (with a `/` after the host when a value follows)', 0, 'a leading interpolation or `//` lets a value choose the host');
|
|
910
1017
|
}
|
|
911
1018
|
return parts;
|
|
912
1019
|
}
|
|
@@ -953,7 +1060,7 @@ class Compiler {
|
|
|
953
1060
|
this.frameOf.set(loop, this.check.frames.length);
|
|
954
1061
|
this.check.frames.push({ kind: 'loop', item: m[1], index: null, init: listExpr, loc: this.locOf(el, 'each', eachAttr.indexOf(m[1])) });
|
|
955
1062
|
}
|
|
956
|
-
srcScope = { loops: [...ctx.scope.loops, loop]
|
|
1063
|
+
srcScope = { ...ctx.scope, loops: [...ctx.scope.loops, loop] };
|
|
957
1064
|
if (list.ok)
|
|
958
1065
|
each = { item: m[1], list: this.fn(list.code), ...(list.resource ? { of: list.resource } : {}) };
|
|
959
1066
|
this.features.add('each');
|
|
@@ -41,7 +41,7 @@ export const DIAGNOSTIC_DOCS = {
|
|
|
41
41
|
},
|
|
42
42
|
SN205: {
|
|
43
43
|
example: '`m.is_read = true` mutates a resource. Use `patch(\'messages\', m, {is_read: true})`',
|
|
44
|
-
means: 'Resource data is read-only in expressions: it mirrors the server, and a direct write would be lost on the next refresh or event.',
|
|
44
|
+
means: 'Resource data is read-only in expressions: it mirrors the server, and a direct write (an assignment, a mutating method such as `push()`, or `Object.assign()` on an item) would be lost on the next refresh or event.',
|
|
45
45
|
fix: 'Use the `patch()` call from the message, or copy the item with `structuredClone(c)` to edit it in state.',
|
|
46
46
|
},
|
|
47
47
|
SN206: {
|
|
@@ -51,14 +51,19 @@ export const DIAGNOSTIC_DOCS = {
|
|
|
51
51
|
},
|
|
52
52
|
SN207: {
|
|
53
53
|
example: '`onclick` cannot be bound with `data-sn-attr`; use `data-sn-on-click`',
|
|
54
|
-
means: 'The binding could run script or inject markup from data: event-handler attributes, `style`, `srcdoc`, `innerHTML`, bindings on `<script>` and similar elements, URL templates that do not start with a literal path
|
|
55
|
-
fix: 'Use the safe equivalent the message names: `data-sn-on-*` for events, `data-sn-style` for styles, a literal `/path` at the start of a URL template.',
|
|
54
|
+
means: 'The binding could run script or inject markup from data: event-handler attributes, `style`, `srcdoc`, `innerHTML`, bindings on `<script>` and similar elements, URL templates that do not start with a literal path (or with a literal origin that lacks its `/` before a value), persisted password fields, or `constructor`, `__proto__` and `prototype` named in an expression.',
|
|
55
|
+
fix: 'Use the safe equivalent the message names: `data-sn-on-*` for events, `data-sn-style` for styles, a literal `/path` (or `https://host/`) at the start of a URL template.',
|
|
56
56
|
},
|
|
57
57
|
SN208: {
|
|
58
58
|
example: 'Property \'nmae\' does not exist on type \'Contact\'. (TS2339)',
|
|
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. A resource cannot take the name of a store function (`refresh`, `upsert`, `patch`, `remove`, `page`, `showModal`, `close`, `reset`, `emit`).',
|
|
65
|
+
fix: 'Rename the let, the state or loop variable, or the resource.',
|
|
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;
|