@tiaforge-ui/sinter 0.1.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 +39 -12
- package/dist/check/index.d.ts +4 -1
- package/dist/check/index.js +9 -8
- package/dist/check/shadow.d.ts +3 -1
- package/dist/check/shadow.js +53 -5
- package/dist/cli.js +2 -2
- package/dist/compiler/check-unit.d.ts +26 -2
- package/dist/compiler/compile.d.ts +4 -0
- package/dist/compiler/compile.js +314 -30
- package/dist/compiler/diagnostic-docs.js +20 -5
- package/dist/compiler/diagnostics.d.ts +3 -0
- package/dist/compiler/diagnostics.js +3 -0
- package/dist/compiler/expr.d.ts +24 -0
- package/dist/compiler/expr.js +53 -8
- package/dist/compiler/index.d.ts +3 -2
- package/dist/compiler/index.js +70 -16
- package/dist/compiler/vocabulary.d.ts +1 -0
- package/dist/compiler/vocabulary.js +9 -3
- package/dist/each.d.ts +3 -0
- package/dist/each.js +121 -0
- package/dist/index.d.ts +1 -1
- package/dist/ir.d.ts +30 -1
- package/dist/listbox.d.ts +3 -0
- package/dist/listbox.js +66 -0
- package/dist/lsp/typescript.js +1 -1
- package/dist/lsp/workspace.js +3 -2
- package/dist/paginate.js +18 -4
- package/dist/project.d.ts +6 -0
- package/dist/project.js +22 -19
- package/dist/runtime/app.d.ts +35 -1
- package/dist/runtime/app.js +178 -40
- package/dist/runtime/config.d.ts +10 -3
- package/dist/runtime/config.js +10 -3
- package/dist/runtime/request.d.ts +1 -1
- package/dist/runtime/request.js +6 -1
- package/dist/runtime/store.d.ts +7 -4
- package/dist/runtime/store.js +154 -41
- package/dist/sse.js +8 -5
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +17 -2
- package/dist/vite.js +4 -2
- package/docs/diagnostics.md +35 -5
- package/html-custom-data.json +46 -2
- package/llms.txt +112 -6
- package/package.json +6 -1
|
@@ -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,8 @@ 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';
|
|
41
|
+
readonly SN310: 'unknown-action';
|
|
39
42
|
readonly SN401: 'parse-error';
|
|
40
43
|
readonly SN501: 'unused-resource';
|
|
41
44
|
readonly SN502: 'non-reactive-value';
|
|
@@ -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,8 @@ export const CODES = {
|
|
|
19
20
|
SN306: 'include-cycle',
|
|
20
21
|
SN307: 'openapi-invalid',
|
|
21
22
|
SN308: 'unknown-schema-field',
|
|
23
|
+
SN309: 'unknown-dialog',
|
|
24
|
+
SN310: 'unknown-action',
|
|
22
25
|
SN401: 'parse-error',
|
|
23
26
|
SN501: 'unused-resource',
|
|
24
27
|
SN502: 'non-reactive-value',
|
package/dist/compiler/expr.d.ts
CHANGED
|
@@ -19,19 +19,35 @@ 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;
|
|
25
35
|
paginate: string | null;
|
|
26
36
|
used: boolean;
|
|
37
|
+
/** keyset with `offset-param`: `page(name, n)` and `$meta.<name>.page` are available */
|
|
38
|
+
jumps?: boolean;
|
|
27
39
|
}
|
|
28
40
|
export interface Scope {
|
|
29
41
|
loops: LoopEntry[];
|
|
30
42
|
states: StateEntry[];
|
|
43
|
+
/** `data-sn-let` names, outer to inner */
|
|
44
|
+
lets?: LetEntry[];
|
|
31
45
|
}
|
|
32
46
|
export interface PageInfo {
|
|
33
47
|
resources: Map<string, ResourceInfo>;
|
|
34
48
|
helpers: Set<string>;
|
|
49
|
+
/** action functions and namespaces: only `data-sn-call` and `<sn-resource call>` may name them */
|
|
50
|
+
actions?: Set<string>;
|
|
35
51
|
namespaces: Map<string, Set<string>>;
|
|
36
52
|
/** imports used by emitted code */
|
|
37
53
|
usedHelpers: Set<string>;
|
|
@@ -39,6 +55,8 @@ export interface PageInfo {
|
|
|
39
55
|
usesNow: boolean;
|
|
40
56
|
/** `:name` segments of the page's `data-sn-path` patterns */
|
|
41
57
|
pathParams: Set<string>;
|
|
58
|
+
/** literal `id`s of the page's `<dialog>` elements, for `showModal()` and `close()` */
|
|
59
|
+
dialogs: Set<string>;
|
|
42
60
|
}
|
|
43
61
|
export interface ExprOptions {
|
|
44
62
|
mode: Mode;
|
|
@@ -46,6 +64,8 @@ export interface ExprOptions {
|
|
|
46
64
|
magic?: string[];
|
|
47
65
|
/** free identifiers resolve to fields of `$.$p` (sn-event, cursor) */
|
|
48
66
|
payload?: boolean;
|
|
67
|
+
/** with `payload`: state names in scope resolve to the state first (`<sn-event>`) */
|
|
68
|
+
payloadState?: boolean;
|
|
49
69
|
/** parse as `{...}` object literal; returns its entries */
|
|
50
70
|
object?: 'literal-keys' | 'any-keys' | 'state';
|
|
51
71
|
report: (code: Code, message: string, offset: number, hint?: string) => void;
|
|
@@ -60,6 +80,8 @@ export interface ObjectEntry {
|
|
|
60
80
|
export interface ExprResult {
|
|
61
81
|
ok: boolean;
|
|
62
82
|
code: string;
|
|
83
|
+
/** `code` without its `($) =>` wrapper (value expressions) */
|
|
84
|
+
inner: string;
|
|
63
85
|
deps: string[];
|
|
64
86
|
writes: string[];
|
|
65
87
|
usesEl: boolean;
|
|
@@ -73,6 +95,8 @@ export interface ExprResult {
|
|
|
73
95
|
ast: Node | null;
|
|
74
96
|
/** names resolved to payload fields (`payload` mode) */
|
|
75
97
|
payloadNames?: string[];
|
|
98
|
+
/** state names read where the payload is the scope (they shadow payload fields of the same name) */
|
|
99
|
+
payloadStates?: string[];
|
|
76
100
|
}
|
|
77
101
|
export declare const ALL_MAGIC: string[];
|
|
78
102
|
export declare const STORE_FNS: string[];
|
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'];
|
|
@@ -50,6 +50,7 @@ export function analyze(src, scope, page, opts) {
|
|
|
50
50
|
let usesIndex = false;
|
|
51
51
|
let ok = true;
|
|
52
52
|
const payloadNames = new Set();
|
|
53
|
+
const payloadStates = new Set();
|
|
53
54
|
const mode = opts.mode;
|
|
54
55
|
const magicAllowed = new Set([...EVERYWHERE_MAGIC, ...(opts.magic ?? [])]);
|
|
55
56
|
const report = (code, message, offset, hint) => {
|
|
@@ -95,7 +96,7 @@ export function analyze(src, scope, page, opts) {
|
|
|
95
96
|
return fail();
|
|
96
97
|
}
|
|
97
98
|
function fail() {
|
|
98
|
-
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 };
|
|
99
100
|
}
|
|
100
101
|
// ---- resolution
|
|
101
102
|
function resolve(name, locals, offset, member) {
|
|
@@ -117,7 +118,21 @@ export function analyze(src, scope, page, opts) {
|
|
|
117
118
|
report('SN201', `\`${name}\` is not defined.${didYouMean(name, ALL_MAGIC)}`, offset);
|
|
118
119
|
return null;
|
|
119
120
|
}
|
|
121
|
+
if (opts.payload && opts.payloadState) {
|
|
122
|
+
for (let i = scope.states.length - 1; i >= 0; i--) {
|
|
123
|
+
if (scope.states[i].names.has(name)) {
|
|
124
|
+
payloadStates.add(name);
|
|
125
|
+
return { kind: 'state', sid: scope.states[i].sid };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
120
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
|
+
}
|
|
121
136
|
for (let i = scope.loops.length - 1; i >= 0; i--) {
|
|
122
137
|
if (scope.loops[i].name === name)
|
|
123
138
|
return { kind: 'loop', entry: scope.loops[i] };
|
|
@@ -148,6 +163,10 @@ export function analyze(src, scope, page, opts) {
|
|
|
148
163
|
payloadNames.add(name);
|
|
149
164
|
return { kind: 'payload' };
|
|
150
165
|
}
|
|
166
|
+
if (page.actions?.has(name)) {
|
|
167
|
+
report('SN203', `\`${name}\` is an action: actions run through \`data-sn-call\` or \`<sn-resource call>\`, not inside expressions`, offset);
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
151
170
|
if (BROWSER.has(name)) {
|
|
152
171
|
report('SN202', `\`${name}\` is a browser API; expressions may use JavaScript, not the browser. Use an island`, offset);
|
|
153
172
|
return null;
|
|
@@ -157,7 +176,7 @@ export function analyze(src, scope, page, opts) {
|
|
|
157
176
|
return { kind: 'payload' };
|
|
158
177
|
}
|
|
159
178
|
const candidates = [
|
|
160
|
-
...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]),
|
|
161
180
|
...page.resources.keys(), ...page.helpers, ...page.namespaces.keys(), ...STORE_FNS, ...BUILTIN_HELPERS,
|
|
162
181
|
];
|
|
163
182
|
report('SN201', `\`${name}\` is not defined here.${didYouMean(name, candidates)}`, offset);
|
|
@@ -170,6 +189,7 @@ export function analyze(src, scope, page, opts) {
|
|
|
170
189
|
case 'global':
|
|
171
190
|
case 'helper': return name;
|
|
172
191
|
case 'loop': return `$.${name}`;
|
|
192
|
+
case 'let': return `(${res.entry.code})`;
|
|
173
193
|
case 'state': return `$.$s${sid}.${name}`;
|
|
174
194
|
case 'resource': return `$.$r.${name}`;
|
|
175
195
|
case 'magic': return `$.${name}`;
|
|
@@ -189,6 +209,7 @@ export function analyze(src, scope, page, opts) {
|
|
|
189
209
|
return { segs: res.entry.alias ? [...res.entry.alias] : null, resource: res.entry.resource };
|
|
190
210
|
case 'local':
|
|
191
211
|
return { segs: res.info.alias ? [...res.info.alias] : null, resource: res.info.resource };
|
|
212
|
+
case 'let': return { segs: null, resource: res.entry.resource };
|
|
192
213
|
default: return { segs: null, resource: null };
|
|
193
214
|
}
|
|
194
215
|
}
|
|
@@ -278,8 +299,8 @@ export function analyze(src, scope, page, opts) {
|
|
|
278
299
|
else if (PAGE_FIELDS.includes(field) && !r.paginate) {
|
|
279
300
|
report('SN303', `\`$meta.${rname}.${field}\` is a pagination field, but \`${rname}\` has no \`paginate\``, n.start);
|
|
280
301
|
}
|
|
281
|
-
else if (field === 'page' && r.paginate !== 'offset') {
|
|
282
|
-
report('SN303', `\`$meta.${rname}.page\` exists only with \`paginate="offset"\``, n.start);
|
|
302
|
+
else if (field === 'page' && r.paginate !== 'offset' && !r.jumps) {
|
|
303
|
+
report('SN303', `\`$meta.${rname}.page\` exists only with \`paginate="offset"\`, or \`paginate="keyset"\` with \`offset-param\``, n.start);
|
|
283
304
|
}
|
|
284
305
|
}
|
|
285
306
|
}
|
|
@@ -302,6 +323,15 @@ export function analyze(src, scope, page, opts) {
|
|
|
302
323
|
sid = res.sid;
|
|
303
324
|
if (res.kind === 'loop' && res.entry.kind === 'index')
|
|
304
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
|
+
}
|
|
305
335
|
const text = emitName(n.name, res, sid);
|
|
306
336
|
if (text !== n.name || shorthand)
|
|
307
337
|
edits.push({ start: n.start, end: n.end, text: shorthand ? `${n.name}: ${text}` : text });
|
|
@@ -459,6 +489,10 @@ export function analyze(src, scope, page, opts) {
|
|
|
459
489
|
report('SN203', 'only state variables and their properties can be assigned', at);
|
|
460
490
|
return;
|
|
461
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
|
+
}
|
|
462
496
|
if (cp.resource) {
|
|
463
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');
|
|
464
498
|
return;
|
|
@@ -557,6 +591,15 @@ export function analyze(src, scope, page, opts) {
|
|
|
557
591
|
return;
|
|
558
592
|
}
|
|
559
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
|
+
}
|
|
560
603
|
if (['refresh', 'upsert', 'patch', 'remove', 'page'].includes(name)) {
|
|
561
604
|
if (!first || first.type !== 'Literal' || typeof first.value !== 'string') {
|
|
562
605
|
report('SN301', `\`${name}()\` takes the resource name as a string literal`, n.start);
|
|
@@ -572,8 +615,10 @@ export function analyze(src, scope, page, opts) {
|
|
|
572
615
|
report('SN303', `\`${first.value}\` has no \`paginate\`; \`page()\` needs a paginated resource`, n.start);
|
|
573
616
|
const target = n.arguments[1];
|
|
574
617
|
const named = target?.type === 'Literal' && typeof target.value === 'string' ? target.value : null;
|
|
575
|
-
if (r.paginate && r.paginate !== 'offset' &&
|
|
576
|
-
report('SN304',
|
|
618
|
+
if (r.paginate && r.paginate !== 'offset' && !named && !(r.paginate === 'keyset' && r.jumps)) {
|
|
619
|
+
report('SN304', r.paginate === 'keyset'
|
|
620
|
+
? 'keyset pagination cannot jump to a page number. Add `offset-param` if the server also accepts an offset, or use `paginate="offset"`'
|
|
621
|
+
: 'keyset pagination cannot jump to a page number. Use `paginate="offset"`', target?.start ?? n.start);
|
|
577
622
|
}
|
|
578
623
|
else if (r.paginate === 'append' && named && named !== 'next') {
|
|
579
624
|
report('SN304', '`append` pagination only supports `page(…, \'next\')`', target.start);
|
|
@@ -827,7 +872,7 @@ export function analyze(src, scope, page, opts) {
|
|
|
827
872
|
}
|
|
828
873
|
}
|
|
829
874
|
}
|
|
830
|
-
return { ok, code, deps: [...deps].sort(), writes: [...writes].sort(), usesEl, usesIndex, entries, path, resource, ast, ...(opts.payload ? { payloadNames: [...payloadNames].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() } : {}) };
|
|
831
876
|
}
|
|
832
877
|
export function applyEdits(src, edits) {
|
|
833
878
|
const sorted = [...edits].sort((a, b) => b.start - a.start || (b.end - b.start) - (a.end - a.start));
|
package/dist/compiler/index.d.ts
CHANGED
|
@@ -8,10 +8,11 @@ export { compileParts };
|
|
|
8
8
|
export declare const PACKAGE = "@tiaforge-ui/sinter";
|
|
9
9
|
/** Compiles annotated HTML into a rewritten page and its page module. Pure and deterministic. */
|
|
10
10
|
export declare function compile(html: string, options: CompileOptions): CompileResult;
|
|
11
|
-
export declare function pageModule(parts: CompiledParts, options: Pick<CompileOptions, 'helpers' | 'dev' | 'config'>): string;
|
|
11
|
+
export declare function pageModule(parts: CompiledParts, options: Pick<CompileOptions, 'helpers' | 'dev' | 'config' | 'actions'>): string;
|
|
12
12
|
/**
|
|
13
13
|
* Reads a helpers module's export names statically. `export * as m from './x.js'`
|
|
14
|
-
* becomes a namespace whose members are read from `x.js` the same way.
|
|
14
|
+
* becomes a namespace whose members are read from `x.js` the same way. `export * from './y.js'`
|
|
15
|
+
* re-exports (e.g. paraglide's message index) are followed, relative paths only.
|
|
15
16
|
*/
|
|
16
17
|
export declare function readHelperManifest(source: string, options: {
|
|
17
18
|
module: string;
|
package/dist/compiler/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as acorn from 'acorn';
|
|
2
|
+
import { tsPlugin } from '@sveltejs/acorn-typescript';
|
|
2
3
|
import { compileParts } from './compile.js';
|
|
3
4
|
export { formatDiagnostic, docsUrl, CODES } from './diagnostics.js';
|
|
4
5
|
export { DIAGNOSTIC_DOCS } from './diagnostic-docs.js';
|
|
@@ -25,6 +26,21 @@ export function pageModule(parts, options) {
|
|
|
25
26
|
const mod = options.helpers.namespaces[ns].module;
|
|
26
27
|
lines.push(`import { ${members.map((m) => `${m} as ${ns}$${m}`).join(', ')} } from ${JSON.stringify(mod)};`);
|
|
27
28
|
}
|
|
29
|
+
// actions (`data-sn-call`): imported as `$a$name` / `$a$ns$member`, only those the page calls
|
|
30
|
+
const acts = options.actions;
|
|
31
|
+
if (acts?.module) {
|
|
32
|
+
const direct = parts.calls.filter((c) => !c.includes('.'));
|
|
33
|
+
if (direct.length)
|
|
34
|
+
lines.push(`import { ${direct.map((c) => `${c} as $a$${c}`).join(', ')} } from ${JSON.stringify(acts.module)};`);
|
|
35
|
+
const byNs = new Map();
|
|
36
|
+
for (const c of parts.calls.filter((x) => x.includes('.'))) {
|
|
37
|
+
const [ns, member] = c.split('.');
|
|
38
|
+
byNs.set(ns, [...(byNs.get(ns) ?? []), member]);
|
|
39
|
+
}
|
|
40
|
+
for (const [ns, members] of byNs) {
|
|
41
|
+
lines.push(`import { ${members.map((x) => `${x} as $a$${ns}$${x}`).join(', ')} } from ${JSON.stringify(acts.namespaces[ns].module)};`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
28
44
|
if (options.config)
|
|
29
45
|
lines.push(`import ${JSON.stringify(options.config)};`);
|
|
30
46
|
lines.push('');
|
|
@@ -39,46 +55,81 @@ export function pageModule(parts, options) {
|
|
|
39
55
|
}
|
|
40
56
|
/**
|
|
41
57
|
* Reads a helpers module's export names statically. `export * as m from './x.js'`
|
|
42
|
-
* becomes a namespace whose members are read from `x.js` the same way.
|
|
58
|
+
* becomes a namespace whose members are read from `x.js` the same way. `export * from './y.js'`
|
|
59
|
+
* re-exports (e.g. paraglide's message index) are followed, relative paths only.
|
|
43
60
|
*/
|
|
44
61
|
export function readHelperManifest(source, options) {
|
|
45
62
|
const manifest = { module: options.module, names: [], namespaces: {} };
|
|
46
63
|
const resolve = options.resolve ?? ((spec, from) => (spec.startsWith('.') ? joinPath(from, spec) : null));
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
try {
|
|
54
|
-
members = exportsOf(options.readFile(path)).names;
|
|
55
|
-
}
|
|
56
|
-
catch {
|
|
57
|
-
members = [];
|
|
58
|
-
}
|
|
64
|
+
const read = (file) => {
|
|
65
|
+
try {
|
|
66
|
+
return exportsOf(options.readFile(file), file);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
59
70
|
}
|
|
71
|
+
};
|
|
72
|
+
/** the module's own value exports plus those of its `export *` re-exports, transitively */
|
|
73
|
+
const valueNames = (file, exports, seen) => {
|
|
74
|
+
const names = new Set(exports.names);
|
|
75
|
+
for (const spec of exports.reexports) {
|
|
76
|
+
const path = spec.startsWith('.') ? resolve(spec, file) : null;
|
|
77
|
+
if (!path || seen.has(path))
|
|
78
|
+
continue;
|
|
79
|
+
seen.add(path);
|
|
80
|
+
const sub = read(path);
|
|
81
|
+
if (sub)
|
|
82
|
+
for (const n of valueNames(path, sub, seen))
|
|
83
|
+
names.add(n);
|
|
84
|
+
}
|
|
85
|
+
return [...names].sort();
|
|
86
|
+
};
|
|
87
|
+
const top = exportsOf(source, options.file);
|
|
88
|
+
manifest.names = valueNames(options.file, top, new Set([options.file]));
|
|
89
|
+
for (const [ns, spec] of top.namespaces) {
|
|
90
|
+
const path = resolve(spec, options.file);
|
|
91
|
+
const exports = path ? read(path) : null;
|
|
92
|
+
const members = path && exports ? valueNames(path, exports, new Set([path])) : [];
|
|
60
93
|
const modSpec = spec.startsWith('.') ? joinPath(options.module, spec) : spec;
|
|
61
94
|
manifest.namespaces[ns] = { module: modSpec, members };
|
|
62
95
|
}
|
|
63
96
|
return manifest;
|
|
64
97
|
}
|
|
65
|
-
|
|
66
|
-
|
|
98
|
+
/** acorn extended with TypeScript syntax, for `.ts` helper and namespace modules */
|
|
99
|
+
let tsParser = null;
|
|
100
|
+
function parseModule(source, file) {
|
|
101
|
+
const options = { ecmaVersion: 'latest', sourceType: 'module' };
|
|
102
|
+
if (!/\.[cm]?tsx?$/.test(file))
|
|
103
|
+
return acorn.parse(source, options);
|
|
104
|
+
tsParser ??= acorn.Parser.extend(tsPlugin());
|
|
105
|
+
return tsParser.parse(source, options);
|
|
106
|
+
}
|
|
107
|
+
/** Value exports only: type-only exports (`export type`, `export interface`, `export { type X }`) are skipped. */
|
|
108
|
+
function exportsOf(source, file) {
|
|
109
|
+
const ast = parseModule(source, file);
|
|
67
110
|
const names = [];
|
|
68
111
|
const namespaces = [];
|
|
112
|
+
const reexports = [];
|
|
113
|
+
const typeOnly = (n) => n.exportKind === 'type';
|
|
69
114
|
for (const st of ast.body) {
|
|
115
|
+
if (typeOnly(st))
|
|
116
|
+
continue;
|
|
70
117
|
if (st.type === 'ExportNamedDeclaration') {
|
|
71
118
|
const d = st.declaration;
|
|
72
119
|
if (d?.type === 'FunctionDeclaration' && d.id)
|
|
73
120
|
names.push(d.id.name);
|
|
74
121
|
else if (d?.type === 'ClassDeclaration' && d.id)
|
|
75
122
|
names.push(d.id.name);
|
|
123
|
+
else if (d?.type === 'TSEnumDeclaration')
|
|
124
|
+
names.push(d.id.name);
|
|
76
125
|
else if (d?.type === 'VariableDeclaration') {
|
|
77
126
|
for (const v of d.declarations)
|
|
78
127
|
if (v.id.type === 'Identifier')
|
|
79
128
|
names.push(v.id.name);
|
|
80
129
|
}
|
|
81
130
|
for (const s of st.specifiers) {
|
|
131
|
+
if (typeOnly(s))
|
|
132
|
+
continue;
|
|
82
133
|
const exported = s.exported.type === 'Identifier' ? s.exported.name : String(s.exported.value);
|
|
83
134
|
if (exported !== 'default')
|
|
84
135
|
names.push(exported);
|
|
@@ -88,8 +139,11 @@ function exportsOf(source) {
|
|
|
88
139
|
const ns = st.exported.type === 'Identifier' ? st.exported.name : String(st.exported.value);
|
|
89
140
|
namespaces.push([ns, String(st.source.value)]);
|
|
90
141
|
}
|
|
142
|
+
else if (st.type === 'ExportAllDeclaration') {
|
|
143
|
+
reexports.push(String(st.source.value));
|
|
144
|
+
}
|
|
91
145
|
}
|
|
92
|
-
return { names: [...new Set(names)].sort(), namespaces };
|
|
146
|
+
return { names: [...new Set(names)].sort(), namespaces, reexports };
|
|
93
147
|
}
|
|
94
148
|
function joinPath(from, spec) {
|
|
95
149
|
const dir = from.slice(0, from.lastIndexOf('/') + 1);
|
|
@@ -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,25 +14,30 @@ 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' },
|
|
19
21
|
{ name: 'put', value: 'URL template', doc: 'PUT action' },
|
|
20
22
|
{ name: 'patch', value: 'URL template', doc: 'PATCH action' },
|
|
21
23
|
{ name: 'delete', value: 'URL template', doc: 'DELETE action' },
|
|
24
|
+
{ name: 'call', value: 'action call', doc: 'local action: calls a function from the actions module, e.g. pgp.decrypt(msg)' },
|
|
22
25
|
{ name: 'body', value: 'value expression', doc: 'JSON request body; overrides form serialization' },
|
|
23
26
|
{ name: 'trigger', value: 'DOM event name', doc: 'overrides the default trigger' },
|
|
24
|
-
{ name: 'debounce', value: 'milliseconds', doc: 'delays the trigger, resets on each event' },
|
|
27
|
+
{ name: 'debounce', value: 'milliseconds', doc: 'delays the trigger, resets on each event; without a method, delays the element\'s data-sn-on-* handlers' },
|
|
25
28
|
{ name: 'concurrency', value: 'block | queue | latest', doc: 'mutations only; default block for forms, queue otherwise' },
|
|
26
29
|
{ name: 'optimistic', value: 'handler expression', doc: 'patch/upsert/remove calls applied before the request, rolled back on failure' },
|
|
27
30
|
{ name: 'then', value: 'handler expression', doc: 'runs after a 2xx response; $result = parsed body' },
|
|
28
|
-
{ 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' },
|
|
29
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' },
|
|
30
35
|
{ name: 'data', value: 'resource name', doc: 'on <script type="application/json"> in <head>: initial data for a resource' },
|
|
31
36
|
];
|
|
32
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' };
|
|
33
39
|
export const ELEMENTS = [
|
|
34
|
-
{ name: 'sn-resource', attrs: ['name', 'src', 'key', 'items', 'total', 'order', 'when', 'paginate', 'page-size', 'size-param', 'cursor', 'direction-param', 'page-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' },
|
|
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' },
|
|
35
41
|
{ name: 'sn-sync', attrs: ['src'], doc: 'one server event stream' },
|
|
36
42
|
{ name: 'sn-event', attrs: ['name', 'resource', 'op', 'item', 'set', 'when', 'fallback', 'type'], doc: 'maps one server event to a change message; child of <sn-sync>' },
|
|
37
43
|
{ name: 'sn-include', attrs: ['src'], doc: 'inlines a partial at build time' },
|
package/dist/each.d.ts
ADDED
package/dist/each.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
const isAbort = (e) => e instanceof DOMException && e.name == 'AbortError';
|
|
2
|
+
/**
|
|
3
|
+
* Sources are keyed by their resolved URL. An item belongs to a source when it came in that
|
|
4
|
+
* source's last response, or when `src` resolves to the source's URL with the item itself in
|
|
5
|
+
* place of the list item, so an item a server event added is replaced by the next response.
|
|
6
|
+
*/
|
|
7
|
+
function fanOut(l, app) {
|
|
8
|
+
const { def, res: r } = l;
|
|
9
|
+
const each = def.each;
|
|
10
|
+
const sources = new Map();
|
|
11
|
+
const queued = new Set();
|
|
12
|
+
let pending = 0, again = null;
|
|
13
|
+
const urlOf = (item) => {
|
|
14
|
+
if (!item || typeof item != 'object')
|
|
15
|
+
return null;
|
|
16
|
+
try {
|
|
17
|
+
return app.url(def.src, Object.assign(Object.create(l.scope()), { [each.item]: item }));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
/** the confirmed items minus those of source `u`, plus `items` */
|
|
24
|
+
const swap = (u, s, items) => {
|
|
25
|
+
const mine = (x) => s.keys.has(r.keyOf(x)) || urlOf(x) === u;
|
|
26
|
+
r.replace([...r.base().items.filter((x) => !mine(x)), ...items]);
|
|
27
|
+
};
|
|
28
|
+
async function load(u) {
|
|
29
|
+
const s = sources.get(u);
|
|
30
|
+
if (!s)
|
|
31
|
+
return;
|
|
32
|
+
s.c?.abort();
|
|
33
|
+
const ctl = (s.c = new AbortController()), n = ++s.seq, mark = r.startLoad();
|
|
34
|
+
let loaded = false;
|
|
35
|
+
pending++;
|
|
36
|
+
l.loading(true);
|
|
37
|
+
try {
|
|
38
|
+
const body = await l.get(u, ctl.signal);
|
|
39
|
+
if (n != s.seq || sources.get(u) != s)
|
|
40
|
+
return;
|
|
41
|
+
const items = l.items(body);
|
|
42
|
+
swap(u, s, items);
|
|
43
|
+
s.keys = new Set(items.map((x) => r.keyOf(x)));
|
|
44
|
+
s.answered = loaded = true;
|
|
45
|
+
r.meta.error = null;
|
|
46
|
+
// loadedAt: every current source has answered once
|
|
47
|
+
if ([...sources.values()].every((x) => x.answered))
|
|
48
|
+
r.meta.loadedAt = app.env.now();
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
if (n != s.seq || isAbort(e))
|
|
52
|
+
return;
|
|
53
|
+
s.answered = true;
|
|
54
|
+
l.failed(e);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
r.endLoad(mark, loaded);
|
|
58
|
+
if (n == s.seq)
|
|
59
|
+
s.c = null;
|
|
60
|
+
l.loading(--pending > 0);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
l.changed = () => {
|
|
64
|
+
const scope = l.scope();
|
|
65
|
+
let list = [];
|
|
66
|
+
try {
|
|
67
|
+
if (def.when == null || app.fns[def.when](scope))
|
|
68
|
+
list = app.fns[each.list](scope) ?? [];
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
app.warn(17, def.name, e.message);
|
|
72
|
+
}
|
|
73
|
+
const want = new Set();
|
|
74
|
+
for (const item of Array.isArray(list) ? list : [...list]) {
|
|
75
|
+
const u = urlOf(item);
|
|
76
|
+
if (u != null)
|
|
77
|
+
want.add(u);
|
|
78
|
+
}
|
|
79
|
+
for (const [u, s] of sources) {
|
|
80
|
+
if (want.has(u))
|
|
81
|
+
continue;
|
|
82
|
+
s.seq++;
|
|
83
|
+
s.c?.abort();
|
|
84
|
+
sources.delete(u);
|
|
85
|
+
swap(u, s, []);
|
|
86
|
+
}
|
|
87
|
+
for (const u of want) {
|
|
88
|
+
if (sources.has(u))
|
|
89
|
+
continue;
|
|
90
|
+
sources.set(u, { c: null, seq: 0, keys: new Set(), answered: false });
|
|
91
|
+
void load(u);
|
|
92
|
+
}
|
|
93
|
+
// nothing to load: loaded once the list itself is (an empty list is an answer, a pending one is not)
|
|
94
|
+
if (!sources.size && r.meta.loadedAt == null && (!each.of || app.resource(each.of).meta.loadedAt != null)) {
|
|
95
|
+
r.meta.loadedAt = app.env.now();
|
|
96
|
+
r.onDirty('meta');
|
|
97
|
+
}
|
|
98
|
+
l.loading(pending > 0);
|
|
99
|
+
};
|
|
100
|
+
l.start = () => l.changed();
|
|
101
|
+
l.load = () => Promise.all([...sources.keys()].map(load)).then(() => { });
|
|
102
|
+
// a hint (an event's payload) that resolves to one source's URL reloads only that source;
|
|
103
|
+
// several refetches in one task share one round of requests
|
|
104
|
+
l.refetch = (hint) => {
|
|
105
|
+
const u = hint ? urlOf(hint) : null;
|
|
106
|
+
queued.add(u != null && sources.has(u) ? u : '*');
|
|
107
|
+
return (again ??= Promise.resolve().then(() => {
|
|
108
|
+
again = null;
|
|
109
|
+
const urls = queued.has('*') ? [...sources.keys()] : [...queued];
|
|
110
|
+
queued.clear();
|
|
111
|
+
return Promise.all(urls.map(load)).then(() => { });
|
|
112
|
+
}));
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const each = {
|
|
116
|
+
name: 'each',
|
|
117
|
+
install(app) {
|
|
118
|
+
app.ext.each = { fanOut: (loader) => fanOut(loader, app) };
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
export default each;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { mount, instance, type App, type Feature, type MountOptions, type StateHandle, type Transport } from './runtime/app.js';
|
|
1
|
+
export { mount, instance, type App, type CallContext, type Feature, type MountOptions, type StateHandle, type Transport } from './runtime/app.js';
|
|
2
2
|
export { configure, credentials, header, onUnauthorized, loginRedirect, takeReturnUrl, clearPersisted, SinterError, type Config, type Middleware, type LoginRedirectOptions, } from './runtime/config.js';
|
|
3
3
|
export type { Resource, Change, Meta } from './runtime/store.js';
|
|
4
4
|
export { $k } from './runtime/security.js';
|