@tiaforge-ui/sinter 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -11
- package/dist/check/index.d.ts +4 -1
- package/dist/check/index.js +7 -7
- package/dist/check/shadow.d.ts +3 -1
- package/dist/check/shadow.js +37 -3
- package/dist/cli.js +2 -2
- package/dist/compiler/check-unit.d.ts +12 -0
- package/dist/compiler/compile.d.ts +4 -0
- package/dist/compiler/compile.js +206 -23
- package/dist/compiler/diagnostic-docs.js +10 -5
- package/dist/compiler/diagnostics.d.ts +1 -0
- package/dist/compiler/diagnostics.js +1 -0
- package/dist/compiler/expr.d.ts +8 -0
- package/dist/compiler/expr.js +20 -5
- package/dist/compiler/index.d.ts +3 -2
- package/dist/compiler/index.js +70 -16
- package/dist/compiler/vocabulary.js +3 -2
- 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 +26 -1
- 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 +28 -1
- package/dist/runtime/app.js +97 -24
- package/dist/runtime/config.d.ts +3 -3
- package/dist/runtime/config.js +1 -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 +16 -2
- package/dist/vite.js +4 -2
- package/docs/diagnostics.md +15 -5
- package/html-custom-data.json +17 -1
- package/llms.txt +105 -4
- package/package.json +4 -1
package/dist/compiler/compile.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The compiler: one pure, deterministic function from annotated HTML to a
|
|
2
2
|
// rewritten page plus its IR and function table.
|
|
3
3
|
import * as parse5 from 'parse5';
|
|
4
|
+
import { parseExpressionAt } from 'acorn';
|
|
4
5
|
import { didYouMean, severityOf } from './diagnostics.js';
|
|
5
6
|
import { analyze } from './expr.js';
|
|
6
7
|
import { ATTRIBUTES, ELEMENTS } from './vocabulary.js';
|
|
@@ -23,6 +24,7 @@ class Compiler {
|
|
|
23
24
|
diagnostics = [];
|
|
24
25
|
files = new Set();
|
|
25
26
|
fns = [];
|
|
27
|
+
fnIndex = new Map();
|
|
26
28
|
origins = new WeakMap();
|
|
27
29
|
lineStarts = new Map();
|
|
28
30
|
page;
|
|
@@ -35,6 +37,8 @@ class Compiler {
|
|
|
35
37
|
remove = [];
|
|
36
38
|
embedded = new Map();
|
|
37
39
|
features = new Set();
|
|
40
|
+
/** action functions called by the page (`archive`, `pgp.decrypt`) */
|
|
41
|
+
calls = new Set();
|
|
38
42
|
nextId = 0;
|
|
39
43
|
nextSid = 0;
|
|
40
44
|
check;
|
|
@@ -50,6 +54,7 @@ class Compiler {
|
|
|
50
54
|
ns.set(k, new Set(v.members));
|
|
51
55
|
this.page = {
|
|
52
56
|
resources: new Map(), helpers: new Set(opts.helpers.names), namespaces: ns,
|
|
57
|
+
actions: new Set([...(opts.actions?.names ?? []), ...Object.keys(opts.actions?.namespaces ?? {})]),
|
|
53
58
|
usedHelpers: new Set(), usedNs: new Map(), usesNow: false, pathParams: new Set(),
|
|
54
59
|
};
|
|
55
60
|
}
|
|
@@ -95,8 +100,8 @@ class Compiler {
|
|
|
95
100
|
return {
|
|
96
101
|
html: parse5.serialize(doc),
|
|
97
102
|
ir, fns: this.fns,
|
|
98
|
-
features: [...(this.opts.dev ? ['dev'] : []), ...['sse', 'optimistic', 'paginate', 'persist', 'cache'].filter((f) => this.features.has(f))],
|
|
99
|
-
helpers: { user, builtin }, namespaces,
|
|
103
|
+
features: [...(this.opts.dev ? ['dev'] : []), ...['sse', 'optimistic', 'paginate', 'persist', 'cache', 'each'].filter((f) => this.features.has(f))],
|
|
104
|
+
helpers: { user, builtin }, namespaces, calls: [...this.calls].sort(),
|
|
100
105
|
diagnostics: this.diagnostics, files: [...this.files].sort(),
|
|
101
106
|
...(this.check ? { check: this.check } : {}),
|
|
102
107
|
};
|
|
@@ -153,6 +158,93 @@ class Compiler {
|
|
|
153
158
|
return i;
|
|
154
159
|
}
|
|
155
160
|
/** `inExpr`: the offset points into an expression; otherwise offset 0 is about the whole attribute (or element) */
|
|
161
|
+
/**
|
|
162
|
+
* `data-sn-call` / `call`: exactly one call of an actions module function, `name(args)` or
|
|
163
|
+
* `ns.name(args)`. Compiles to two table entries: the arguments as one array expression, and a
|
|
164
|
+
* runner that calls the imported function with them and the context.
|
|
165
|
+
*/
|
|
166
|
+
callExpr(el, attrName, scope, deps, magic) {
|
|
167
|
+
const text = attr(el, attrName) ?? '';
|
|
168
|
+
const shape = `\`${attrName}\` is one call of an action, e.g. \`pgp.decrypt(msg)\``;
|
|
169
|
+
let ast;
|
|
170
|
+
try {
|
|
171
|
+
ast = parseExpressionAt(text, 0, { ecmaVersion: 'latest' });
|
|
172
|
+
}
|
|
173
|
+
catch (e) {
|
|
174
|
+
this.report(el, attrName, 'SN401', `\`${attrName}\`: ${e.message.replace(/ \(\d+:\d+\)$/, '')}`, e.pos ?? 0);
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
if (ast.type !== 'CallExpression' || ast.optional || ast.end !== text.trimEnd().length) {
|
|
178
|
+
this.report(el, attrName, 'SN401', shape, 0);
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
const acts = this.opts.actions ?? { module: null, names: [], namespaces: {} };
|
|
182
|
+
const callee = ast.callee;
|
|
183
|
+
const all = [...acts.names, ...Object.entries(acts.namespaces).flatMap(([ns, m]) => m.members.map((x) => `${ns}.${x}`))];
|
|
184
|
+
let name, local;
|
|
185
|
+
if (callee.type === 'Identifier') {
|
|
186
|
+
name = callee.name;
|
|
187
|
+
local = `$a$${name}`;
|
|
188
|
+
if (!acts.names.includes(name)) {
|
|
189
|
+
this.report(el, attrName, 'SN310', `\`${name}\` is not exported by the actions module.${didYouMean(name, all)}`, callee.start);
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
else if (callee.type === 'MemberExpression' && !callee.computed && callee.object.type === 'Identifier' && callee.property.type === 'Identifier') {
|
|
194
|
+
name = `${callee.object.name}.${callee.property.name}`;
|
|
195
|
+
local = `$a$${callee.object.name}$${callee.property.name}`;
|
|
196
|
+
if (!acts.namespaces[callee.object.name]?.members.includes(callee.property.name)) {
|
|
197
|
+
this.report(el, attrName, 'SN310', `\`${name}\` is not exported by the actions module.${didYouMean(name, all)}`, callee.start);
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
this.report(el, attrName, 'SN401', shape, callee.start);
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
const open = text.indexOf('(', callee.end);
|
|
206
|
+
const r = this.expr(el, attrName, scope, { mode: 'value', ...(magic ? { magic } : {}) }, `[${text.slice(open + 1, ast.end - 1)}]`, open);
|
|
207
|
+
if (this.check)
|
|
208
|
+
this.typeLast({ returns: { kind: 'call', name, args: true } });
|
|
209
|
+
if (deps)
|
|
210
|
+
for (const d of r.deps)
|
|
211
|
+
deps.add(d);
|
|
212
|
+
if (!r.ok)
|
|
213
|
+
return null;
|
|
214
|
+
this.calls.add(name);
|
|
215
|
+
return { args: this.fn(r.code), run: this.fn(`($a, $c) => ${local}(...$a, $c)`), name };
|
|
216
|
+
}
|
|
217
|
+
/** `direction-values="{prev: 'backward', next: 'forward'}"`: static data, string values for known directions only */
|
|
218
|
+
directionValues(el) {
|
|
219
|
+
const text = attr(el, 'direction-values');
|
|
220
|
+
const DIRS = ['first', 'prev', 'next', 'last'];
|
|
221
|
+
const fail = (message, offset = 0) => { this.report(el, 'direction-values', 'SN401', message, offset); return null; };
|
|
222
|
+
let ast;
|
|
223
|
+
try {
|
|
224
|
+
ast = parseExpressionAt(text, 0, { ecmaVersion: 'latest' });
|
|
225
|
+
}
|
|
226
|
+
catch (e) {
|
|
227
|
+
return fail(`\`direction-values\`: ${e.message.replace(/ \(\d+:\d+\)$/, '')}`, e.pos ?? 0);
|
|
228
|
+
}
|
|
229
|
+
if (ast.type !== 'ObjectExpression' || ast.end !== text.trimEnd().length) {
|
|
230
|
+
return fail("`direction-values` is an object literal of strings, e.g. `{prev: 'backward', next: 'forward'}`");
|
|
231
|
+
}
|
|
232
|
+
const out = {};
|
|
233
|
+
for (const p of ast.properties) {
|
|
234
|
+
if (p.type !== 'Property' || p.computed || p.kind !== 'init' || p.method || p.shorthand) {
|
|
235
|
+
return fail("`direction-values` is an object literal of strings, e.g. `{prev: 'backward', next: 'forward'}`", p.start);
|
|
236
|
+
}
|
|
237
|
+
const key = p.key.type === 'Identifier' ? p.key.name : p.key.type === 'Literal' ? String(p.key.value) : '';
|
|
238
|
+
if (!DIRS.includes(key)) {
|
|
239
|
+
return fail(`\`${key}\` is not a direction; directions are \`first\`, \`prev\`, \`next\` and \`last\`.${didYouMean(key, DIRS)}`, p.key.start);
|
|
240
|
+
}
|
|
241
|
+
if (p.value.type !== 'Literal' || typeof p.value.value !== 'string') {
|
|
242
|
+
return fail(`the value for \`${key}\` must be a string literal`, p.value.start);
|
|
243
|
+
}
|
|
244
|
+
out[key] = p.value.value;
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
156
248
|
report(el, attr, code, message, offset, hint, inExpr = false) {
|
|
157
249
|
const origin = (el && this.origins.get(el)) || { file: this.opts.file, src: '', chain: [] };
|
|
158
250
|
const base = el ? this.valueOffset(el, attr) : 0;
|
|
@@ -251,6 +343,8 @@ class Compiler {
|
|
|
251
343
|
const key = attr(child, 'key');
|
|
252
344
|
const paginate = attr(child, 'paginate');
|
|
253
345
|
const info = { name, key: key ? key.split(',').map((s) => s.trim()).filter(Boolean) : null, paginate: paginate ?? null, used: false };
|
|
346
|
+
if (paginate === 'keyset' && attr(child, 'offset-param') != null)
|
|
347
|
+
info.jumps = true;
|
|
254
348
|
this.page.resources.set(name, info);
|
|
255
349
|
this.resourceEls.set(name, child);
|
|
256
350
|
continue;
|
|
@@ -352,6 +446,9 @@ class Compiler {
|
|
|
352
446
|
return null;
|
|
353
447
|
}
|
|
354
448
|
const node = { tag: el.tagName, p: ctx.stateIdx, b: [] };
|
|
449
|
+
const inDialog = ctx.dialog || el.tagName === 'dialog';
|
|
450
|
+
if (inDialog)
|
|
451
|
+
node.dl = 1;
|
|
355
452
|
const nodeIdx = ctx.list.length;
|
|
356
453
|
let scope = ctx.scope;
|
|
357
454
|
let result = null;
|
|
@@ -408,6 +505,17 @@ class Compiler {
|
|
|
408
505
|
this.model(el, node, scope, get('persist'));
|
|
409
506
|
else if (has('persist') && !has('state'))
|
|
410
507
|
this.report(el, 'data-sn-persist', 'SN102', '`data-sn-persist` needs `data-sn-model` or `data-sn-state` on the same element', 0);
|
|
508
|
+
const methods = METHODS.filter((m) => has(m));
|
|
509
|
+
const isCall = has('call');
|
|
510
|
+
// without a method, `data-sn-debounce` delays the element's own handlers
|
|
511
|
+
let handlerDebounce = 0;
|
|
512
|
+
if (!methods.length && !isCall && has('debounce')) {
|
|
513
|
+
handlerDebounce = Number(get('debounce'));
|
|
514
|
+
if (!Number.isFinite(handlerDebounce) || handlerDebounce < 0)
|
|
515
|
+
this.report(el, 'data-sn-debounce', 'SN401', '`data-sn-debounce` takes milliseconds', 0);
|
|
516
|
+
if (!sn.some((a) => a.name.startsWith('data-sn-on-')))
|
|
517
|
+
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
|
+
}
|
|
411
519
|
for (const a of sn) {
|
|
412
520
|
if (!a.name.startsWith('data-sn-on-'))
|
|
413
521
|
continue;
|
|
@@ -418,16 +526,19 @@ class Compiler {
|
|
|
418
526
|
}
|
|
419
527
|
const r = this.expr(el, a.name, scope, { mode: 'handler', magic: ['$event'] });
|
|
420
528
|
if (r.ok)
|
|
421
|
-
node.b.push(binding({ k: 'on', n: event, f: this.fn(r.code), w: r.writes }));
|
|
529
|
+
node.b.push(binding({ k: 'on', n: event, f: this.fn(r.code), w: r.writes, ...(handlerDebounce > 0 ? { ms: handlerDebounce } : {}) }));
|
|
422
530
|
}
|
|
423
|
-
const methods = METHODS.filter((m) => has(m));
|
|
424
531
|
if (methods.length > 1)
|
|
425
532
|
this.report(el, `data-sn-${methods[1]}`, 'SN102', 'an element can have only one method attribute', 0);
|
|
533
|
+
if (methods.length && isCall)
|
|
534
|
+
this.report(el, 'data-sn-call', 'SN102', 'an element has one action: a method attribute or `data-sn-call`, not both', 0);
|
|
426
535
|
if (methods.length)
|
|
427
536
|
this.action(el, node, scope, methods[0], get);
|
|
537
|
+
else if (isCall)
|
|
538
|
+
this.action(el, node, scope, 'call', get);
|
|
428
539
|
else
|
|
429
540
|
for (const n of ACTION_ONLY)
|
|
430
|
-
if (has(n))
|
|
541
|
+
if (has(n) && n !== 'debounce')
|
|
431
542
|
this.report(el, `data-sn-${n}`, 'SN102', `\`data-sn-${n}\` needs a method attribute (\`data-sn-get\`, \`-post\`, …) on the same element`, 0);
|
|
432
543
|
// templates
|
|
433
544
|
let content = null;
|
|
@@ -476,7 +587,7 @@ class Compiler {
|
|
|
476
587
|
}
|
|
477
588
|
else {
|
|
478
589
|
this.walkChildren(el, {
|
|
479
|
-
...ctx, scope,
|
|
590
|
+
...ctx, scope, dialog: inDialog,
|
|
480
591
|
stateIdx: node.state !== undefined && hasDirectives ? nodeIdx : ctx.stateIdx,
|
|
481
592
|
path: ctx.tid >= 0 ? [...ctx.path, index] : ctx.path,
|
|
482
593
|
});
|
|
@@ -495,9 +606,12 @@ class Compiler {
|
|
|
495
606
|
this.templates.push({ id: tid, nodes: [] });
|
|
496
607
|
return tid;
|
|
497
608
|
}
|
|
609
|
+
/** Index of a function in the page's table; identical code shares one entry (one compile in the engine). */
|
|
498
610
|
fn(code) {
|
|
499
|
-
this.
|
|
500
|
-
|
|
611
|
+
let i = this.fnIndex.get(code);
|
|
612
|
+
if (i === undefined)
|
|
613
|
+
this.fnIndex.set(code, (i = this.fns.push(code) - 1));
|
|
614
|
+
return i;
|
|
501
615
|
}
|
|
502
616
|
expr(el, attrName, scope, o, value, shift = 0) {
|
|
503
617
|
const src = value ?? attr(el, attrName) ?? '';
|
|
@@ -541,7 +655,7 @@ class Compiler {
|
|
|
541
655
|
let event = null;
|
|
542
656
|
if (attrName.startsWith('data-sn-on-'))
|
|
543
657
|
event = attrName.slice('data-sn-on-'.length);
|
|
544
|
-
else if (o.mode !== 'value') {
|
|
658
|
+
else if (o.mode !== 'value' || o.magic?.includes('$event')) {
|
|
545
659
|
event = attr(el, 'data-sn-trigger') ?? (el.tagName === 'form' ? 'submit' : FORM_CONTROLS.has(el.tagName) ? 'change' : 'click');
|
|
546
660
|
}
|
|
547
661
|
const e = {
|
|
@@ -549,7 +663,7 @@ class Compiler {
|
|
|
549
663
|
result: attrName === 'data-sn-then', error: attrName === 'data-sn-catch',
|
|
550
664
|
};
|
|
551
665
|
if (o.payload)
|
|
552
|
-
e.payload = { names: r.payloadNames ?? [], type: null };
|
|
666
|
+
e.payload = { names: r.payloadNames ?? [], type: null, ...(r.payloadStates?.length ? { states: r.payloadStates } : {}) };
|
|
553
667
|
this.check.exprs.push(e);
|
|
554
668
|
return this.check.exprs.length - 1;
|
|
555
669
|
}
|
|
@@ -649,7 +763,11 @@ class Compiler {
|
|
|
649
763
|
node.b.push(b);
|
|
650
764
|
}
|
|
651
765
|
action(el, node, scope, method, get) {
|
|
652
|
-
|
|
766
|
+
// arguments and body are taken when the trigger fires, so they see its event
|
|
767
|
+
const call = method === 'call' ? this.callExpr(el, 'data-sn-call', scope, undefined, ['$event']) : null;
|
|
768
|
+
if (method === 'call' && get('body') !== undefined)
|
|
769
|
+
this.report(el, 'data-sn-body', 'SN102', '`data-sn-body` belongs to a method attribute; a call takes its arguments in `data-sn-call`', 0);
|
|
770
|
+
const url = method === 'call' ? [] : this.urlTemplate(el, `data-sn-${method}`, scope);
|
|
653
771
|
const tag = el.tagName;
|
|
654
772
|
const isForm = tag === 'form';
|
|
655
773
|
const trigger = get('trigger') ?? (isForm ? 'submit' : FORM_CONTROLS.has(tag) ? 'change' : 'click');
|
|
@@ -674,17 +792,19 @@ class Compiler {
|
|
|
674
792
|
return { f: r.ok ? this.fn(r.code) : null, w: r.writes };
|
|
675
793
|
};
|
|
676
794
|
// with OpenAPI specs: the body must fit the request schema, `$result` gets the response's type
|
|
677
|
-
const op = this.check && this.opts.openapi?.length ? matchOperation(this.opts.openapi, url, method) : null;
|
|
795
|
+
const op = method !== 'call' && this.check && this.opts.openapi?.length ? matchOperation(this.opts.openapi, url, method) : null;
|
|
678
796
|
const typed = op && 'spec' in op ? op : null;
|
|
679
|
-
const body = fnOf('body', { mode: 'value' }, typed?.request != null ? { returns: { kind: 'openapi', spec: typed.spec, schema: typed.request } } : undefined);
|
|
797
|
+
const body = fnOf('body', { mode: 'value', magic: ['$event'] }, typed?.request != null ? { returns: { kind: 'openapi', spec: typed.spec, schema: typed.request } } : undefined);
|
|
680
798
|
const optimistic = fnOf('optimistic', { mode: 'optimistic' });
|
|
681
799
|
if (optimistic.f !== null)
|
|
682
800
|
this.features.add('optimistic');
|
|
683
|
-
const then = fnOf('then', { mode: 'handler', magic: ['$event', '$result'] }, typed?.body != null ? { resultType: { kind: 'openapi', spec: typed.spec, schema: typed.body } }
|
|
801
|
+
const then = fnOf('then', { mode: 'handler', magic: ['$event', '$result'] }, typed?.body != null ? { resultType: { kind: 'openapi', spec: typed.spec, schema: typed.body } }
|
|
802
|
+
: call ? { resultType: { kind: 'call', name: call.name } } : undefined);
|
|
684
803
|
const catch_ = fnOf('catch', { mode: 'handler', magic: ['$event', '$error'] });
|
|
685
804
|
const def = {
|
|
686
805
|
method: method.toUpperCase(), url, body: body.f, trigger, debounce, concurrency,
|
|
687
806
|
optimistic: optimistic.f, then: then.f, catch: catch_.f, w: [...new Set([...then.w, ...catch_.w])].sort(), form: isForm,
|
|
807
|
+
...(call ? { call: { args: call.args, run: call.run } } : {}),
|
|
688
808
|
};
|
|
689
809
|
this.actions.push(def);
|
|
690
810
|
node.b.push(binding({ k: 'on', n: trigger, x: this.actions.length - 1 }));
|
|
@@ -803,10 +923,50 @@ class Compiler {
|
|
|
803
923
|
this.report(el, a.name, 'SN101', `\`${a.name}\` is not an attribute of \`<sn-resource>\`.${didYouMean(a.name, RESOURCE_ATTRS)}`, 0);
|
|
804
924
|
}
|
|
805
925
|
const info = this.page.resources.get(name);
|
|
806
|
-
|
|
807
|
-
|
|
926
|
+
const hasCall = attr(el, 'call') != null;
|
|
927
|
+
if (hasCall && attr(el, 'src') != null)
|
|
928
|
+
this.report(el, 'call', 'SN102', 'a resource loads from `src` or from `call`, not both', 0);
|
|
929
|
+
else if (!hasCall && attr(el, 'src') == null)
|
|
930
|
+
this.report(el, null, 'SN401', `resource \`${name}\` needs a \`src\` (or a \`call\`)`, 0);
|
|
931
|
+
if (hasCall) {
|
|
932
|
+
for (const a of ['paginate', 'each', 'cache']) {
|
|
933
|
+
if (attr(el, a) != null)
|
|
934
|
+
this.report(el, a, 'SN401', `\`${a}\` does not combine with \`call\` in v0.2`, 0);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
808
937
|
const deps = new Set();
|
|
809
|
-
|
|
938
|
+
// `each="a in accounts"`: src is evaluated once per list item, with the item in scope
|
|
939
|
+
let each;
|
|
940
|
+
let srcScope = ctx.scope;
|
|
941
|
+
const eachAttr = attr(el, 'each');
|
|
942
|
+
if (eachAttr != null) {
|
|
943
|
+
const m = /^\s*([A-Za-z_$][\w$]*)\s+in\s+/.exec(eachAttr);
|
|
944
|
+
if (!m)
|
|
945
|
+
this.report(el, 'each', 'SN401', '`each` takes `item in list`, e.g. `a in accounts`', 0);
|
|
946
|
+
else {
|
|
947
|
+
const list = this.expr(el, 'each', ctx.scope, { mode: 'value' }, eachAttr.slice(m[0].length), m[0].length);
|
|
948
|
+
const listExpr = this.lastExpr;
|
|
949
|
+
for (const d of list.deps)
|
|
950
|
+
deps.add(d);
|
|
951
|
+
const loop = { name: m[1], kind: 'item', alias: null, resource: list.resource };
|
|
952
|
+
if (this.check) {
|
|
953
|
+
this.frameOf.set(loop, this.check.frames.length);
|
|
954
|
+
this.check.frames.push({ kind: 'loop', item: m[1], index: null, init: listExpr, loc: this.locOf(el, 'each', eachAttr.indexOf(m[1])) });
|
|
955
|
+
}
|
|
956
|
+
srcScope = { loops: [...ctx.scope.loops, loop], states: ctx.scope.states };
|
|
957
|
+
if (list.ok)
|
|
958
|
+
each = { item: m[1], list: this.fn(list.code), ...(list.resource ? { of: list.resource } : {}) };
|
|
959
|
+
this.features.add('each');
|
|
960
|
+
if (!info.key)
|
|
961
|
+
this.report(el, 'each', 'SN401', 'an `each` resource is a collection: it needs `key`, with a field that tells the sources apart', 0);
|
|
962
|
+
for (const a of ['paginate', 'total', 'cache']) {
|
|
963
|
+
if (attr(el, a) != null)
|
|
964
|
+
this.report(el, a, 'SN401', `\`${a}\` does not combine with \`each\`: the items come from several requests`, 0);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
const src = hasCall ? [] : this.urlTemplate(el, 'src', srcScope, {}, deps);
|
|
969
|
+
const call = hasCall ? this.callExpr(el, 'call', ctx.scope, deps) : null;
|
|
810
970
|
let when = null;
|
|
811
971
|
if (attr(el, 'when') != null) {
|
|
812
972
|
const r = this.expr(el, 'when', ctx.scope, { mode: 'value' });
|
|
@@ -842,10 +1002,28 @@ class Compiler {
|
|
|
842
1002
|
mode: mode, size, sizeParam: attr(el, 'size-param') ?? 'limit', cursor,
|
|
843
1003
|
directionParam: attr(el, 'direction-param') ?? 'direction', pageParam: attr(el, 'page-param') ?? 'page',
|
|
844
1004
|
};
|
|
1005
|
+
const offsetParam = attr(el, 'offset-param');
|
|
1006
|
+
if (offsetParam != null) {
|
|
1007
|
+
if (mode !== 'keyset')
|
|
1008
|
+
this.report(el, 'offset-param', 'SN401', `\`offset-param\` applies to \`paginate="keyset"\`; ${mode === 'offset' ? 'offset pages jump with `page-param`' : 'append mode only loads more'}`, 0);
|
|
1009
|
+
else if (!/^[\w.\-\[\]]+$/.test(offsetParam))
|
|
1010
|
+
this.report(el, 'offset-param', 'SN401', '`offset-param` is a query parameter name', 0);
|
|
1011
|
+
else
|
|
1012
|
+
paginate.offsetParam = offsetParam;
|
|
1013
|
+
}
|
|
1014
|
+
if (attr(el, 'direction-values') != null) {
|
|
1015
|
+
if (mode === 'offset')
|
|
1016
|
+
this.report(el, 'direction-values', 'SN401', '`direction-values` applies to `paginate="keyset"` and `paginate="append"`; offset pages send `page-param`', 0);
|
|
1017
|
+
else {
|
|
1018
|
+
const values = this.directionValues(el);
|
|
1019
|
+
if (values)
|
|
1020
|
+
paginate.directionValues = values;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
845
1023
|
this.features.add('paginate');
|
|
846
1024
|
}
|
|
847
1025
|
}
|
|
848
|
-
for (const a of ['page-size', 'size-param', 'cursor', 'direction-param', 'page-param']) {
|
|
1026
|
+
for (const a of ['page-size', 'size-param', 'cursor', 'direction-param', 'direction-values', 'page-param', 'offset-param']) {
|
|
849
1027
|
if (attr(el, a) != null && !paginate && mode == null)
|
|
850
1028
|
this.report(el, a, 'SN102', `\`${a}\` needs \`paginate\``, 0);
|
|
851
1029
|
}
|
|
@@ -862,13 +1040,16 @@ class Compiler {
|
|
|
862
1040
|
}
|
|
863
1041
|
const order = attr(el, 'order');
|
|
864
1042
|
const type = attr(el, 'type')?.trim() || null;
|
|
865
|
-
const openapi = type ? null : this.openapi(el, src, attr(el, 'items') ?? null, attr(el, 'total') ?? null);
|
|
1043
|
+
const openapi = type || hasCall ? null : this.openapi(el, src, attr(el, 'items') ?? null, attr(el, 'total') ?? null);
|
|
866
1044
|
if (this.check) {
|
|
867
1045
|
const r = { name, type, loc: this.locOf(el, type ? 'type' : null), nameLoc: this.locOf(el, 'name') };
|
|
868
1046
|
if (openapi && 'schema' in openapi)
|
|
869
1047
|
r.openapi = openapi;
|
|
1048
|
+
if (call)
|
|
1049
|
+
r.call = { name: call.name, items: attr(el, 'items') ?? null };
|
|
870
1050
|
this.check.resources.push(r);
|
|
871
|
-
|
|
1051
|
+
// a resource fed by an action is typed by the action's result
|
|
1052
|
+
if (!type && !r.openapi && !r.call) {
|
|
872
1053
|
const hint = openapi && 'closest' in openapi
|
|
873
1054
|
? `no OpenAPI GET operation matches \`src\`${openapi.closest ? ` (closest path: \`${openapi.closest}\`)` : ''}; add \`type="…"\`, or \`type="any"\` to leave it unchecked`
|
|
874
1055
|
: 'add `type="…"` with a type from the types module, or `type="any"` to leave it unchecked';
|
|
@@ -879,6 +1060,8 @@ class Compiler {
|
|
|
879
1060
|
name, src, key: info.key, items: attr(el, 'items') ?? null, total: attr(el, 'total') ?? null,
|
|
880
1061
|
order: order ? order.trim() : null, when, paginate, deps: [...deps].sort(), scope: ctx.stateIdx, data: null,
|
|
881
1062
|
...(cache !== undefined ? { cache } : {}),
|
|
1063
|
+
...(each ? { each } : {}),
|
|
1064
|
+
...(call ? { call: { args: call.args, run: call.run } } : {}),
|
|
882
1065
|
});
|
|
883
1066
|
}
|
|
884
1067
|
/** Matches a resource against the OpenAPI specs; checks `items`, `total` and query parameter names. */
|
|
@@ -940,7 +1123,7 @@ class Compiler {
|
|
|
940
1123
|
if (ev)
|
|
941
1124
|
events.push(ev);
|
|
942
1125
|
}
|
|
943
|
-
this.syncs.push({ src, events });
|
|
1126
|
+
this.syncs.push({ src, events, ...(ctx.stateIdx >= 0 ? { scope: ctx.stateIdx } : {}) });
|
|
944
1127
|
this.features.add('sse');
|
|
945
1128
|
}
|
|
946
1129
|
event(el, ctx) {
|
|
@@ -972,7 +1155,7 @@ class Compiler {
|
|
|
972
1155
|
const fnOf = (n, object = false) => {
|
|
973
1156
|
if (attr(el, n) == null)
|
|
974
1157
|
return null;
|
|
975
|
-
const r = this.expr(el, n, scope, { mode: 'value', payload: true });
|
|
1158
|
+
const r = this.expr(el, n, scope, { mode: 'value', payload: true, payloadState: true });
|
|
976
1159
|
this.typeLast({
|
|
977
1160
|
payload: payloadType,
|
|
978
1161
|
...(n === 'item' ? { returns: { kind: 'item', resource } } : n === 'set' ? { returns: { kind: 'item', resource, partial: true } } : {}),
|
|
@@ -1011,7 +1194,7 @@ class Compiler {
|
|
|
1011
1194
|
const usesEl = (r) => ({ ...(r.usesEl ? { e: 1 } : {}), ...(r.usesIndex ? { i: 1 } : {}) });
|
|
1012
1195
|
function binding(b) {
|
|
1013
1196
|
const out = { k: b.k };
|
|
1014
|
-
for (const key of ['f', 'd', 'n', 'x', 'a', 'y', 'w', 'p', 'e', 'i']) {
|
|
1197
|
+
for (const key of ['f', 'd', 'n', 'x', 'a', 'y', 'w', 'p', 'e', 'i', 'ms']) {
|
|
1015
1198
|
const v = b[key];
|
|
1016
1199
|
if (v === undefined || (Array.isArray(v) && v.length === 0 && key !== 'd'))
|
|
1017
1200
|
continue;
|
|
@@ -71,13 +71,13 @@ export const DIAGNOSTIC_DOCS = {
|
|
|
71
71
|
},
|
|
72
72
|
SN303: {
|
|
73
73
|
example: '`$meta.contacts.loadng` is not a status field. Did you mean `loading`?',
|
|
74
|
-
means: '`$meta.<resource>` has a fixed set of fields: `loading`, `error`, `loadedAt`, `pending`, `total`, and with `paginate` also `
|
|
74
|
+
means: '`$meta.<resource>` has a fixed set of fields: `loading`, `error`, `loadedAt`, `pending`, `total`, and with `paginate` also `pages`, `from`, `to`, `hasPrev`, `hasNext`; `page` with `paginate="offset"`, or keyset with `offset-param`.',
|
|
75
75
|
fix: 'Use the suggested field. A pagination field needs `paginate` on the resource.',
|
|
76
76
|
},
|
|
77
77
|
SN304: {
|
|
78
|
-
example: 'keyset pagination cannot jump to a page number.
|
|
79
|
-
means: 'Keyset (cursor) pagination only knows the next and previous page, so `page(\'name\', 7)` has nothing to jump to.',
|
|
80
|
-
fix: '
|
|
78
|
+
example: 'keyset pagination cannot jump to a page number. Add `offset-param` if the server also accepts an offset, or use `paginate="offset"`',
|
|
79
|
+
means: 'Keyset (cursor) pagination only knows the next and previous page, so `page(\'name\', 7)` has nothing to jump to, unless the server can also skip items by offset.',
|
|
80
|
+
fix: 'If the API accepts an offset next to its cursor, add `offset-param="offset"`: the jump is sent as an offset and `prev`/`next` continue by cursor. Otherwise use `paginate="offset"`, or only step with `first`, `prev`, `next` and `last`.',
|
|
81
81
|
},
|
|
82
82
|
SN305: {
|
|
83
83
|
example: 'partial `./partials/contact-dialg.html` not found or outside the project root',
|
|
@@ -99,6 +99,11 @@ export const DIAGNOSTIC_DOCS = {
|
|
|
99
99
|
means: 'The resource matched an operation in the OpenAPI spec, but its `items` or `total` path is not in the response schema.',
|
|
100
100
|
fix: 'Fix the path in `items` or `total`, or the spec if the API changed.',
|
|
101
101
|
},
|
|
102
|
+
SN310: {
|
|
103
|
+
example: '`pgp.decrpyt` is not exported by the actions module. Did you mean `pgp.decrypt`?',
|
|
104
|
+
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`.',
|
|
105
|
+
fix: 'Use the suggested name, or export the function from the actions module.',
|
|
106
|
+
},
|
|
102
107
|
SN401: {
|
|
103
108
|
example: '`data-sn-for` takes `item in list` or `(item, i) in list`',
|
|
104
109
|
means: 'An attribute value has the wrong form: a JavaScript syntax error (the column points inside the attribute), or a value outside what the attribute accepts, such as `paginate` or `data-sn-concurrency`.',
|
|
@@ -122,7 +127,7 @@ export const DIAGNOSTIC_DOCS = {
|
|
|
122
127
|
SN504: {
|
|
123
128
|
example: 'resource `contacts` has no type: expressions using it are not checked',
|
|
124
129
|
means: 'The type checker has no type for the resource, so expressions that use it are not checked. Reported by `sinter check` only.',
|
|
125
|
-
fix: 'Add `type="Contact[]"` with a type from `src/sinter.types.ts`, configure an OpenAPI spec that matches `src`, or write `type="any"` to opt out.',
|
|
130
|
+
fix: 'Add `type="Contact[]"` with a type from `src/sinter.types.ts`, configure an OpenAPI spec that matches `src`, load it with `call` (typed by what the action returns), or write `type="any"` to opt out.',
|
|
126
131
|
},
|
|
127
132
|
SN505: {
|
|
128
133
|
example: '`GET /articles` declares no query parameter `tagg`. Did you mean `tag`?',
|
|
@@ -36,6 +36,7 @@ export declare const CODES: {
|
|
|
36
36
|
readonly SN306: 'include-cycle';
|
|
37
37
|
readonly SN307: 'openapi-invalid';
|
|
38
38
|
readonly SN308: 'unknown-schema-field';
|
|
39
|
+
readonly SN310: 'unknown-action';
|
|
39
40
|
readonly SN401: 'parse-error';
|
|
40
41
|
readonly SN501: 'unused-resource';
|
|
41
42
|
readonly SN502: 'non-reactive-value';
|
package/dist/compiler/expr.d.ts
CHANGED
|
@@ -24,6 +24,8 @@ export interface ResourceInfo {
|
|
|
24
24
|
key: string[] | null;
|
|
25
25
|
paginate: string | null;
|
|
26
26
|
used: boolean;
|
|
27
|
+
/** keyset with `offset-param`: `page(name, n)` and `$meta.<name>.page` are available */
|
|
28
|
+
jumps?: boolean;
|
|
27
29
|
}
|
|
28
30
|
export interface Scope {
|
|
29
31
|
loops: LoopEntry[];
|
|
@@ -32,6 +34,8 @@ export interface Scope {
|
|
|
32
34
|
export interface PageInfo {
|
|
33
35
|
resources: Map<string, ResourceInfo>;
|
|
34
36
|
helpers: Set<string>;
|
|
37
|
+
/** action functions and namespaces: only `data-sn-call` and `<sn-resource call>` may name them */
|
|
38
|
+
actions?: Set<string>;
|
|
35
39
|
namespaces: Map<string, Set<string>>;
|
|
36
40
|
/** imports used by emitted code */
|
|
37
41
|
usedHelpers: Set<string>;
|
|
@@ -46,6 +50,8 @@ export interface ExprOptions {
|
|
|
46
50
|
magic?: string[];
|
|
47
51
|
/** free identifiers resolve to fields of `$.$p` (sn-event, cursor) */
|
|
48
52
|
payload?: boolean;
|
|
53
|
+
/** with `payload`: state names in scope resolve to the state first (`<sn-event>`) */
|
|
54
|
+
payloadState?: boolean;
|
|
49
55
|
/** parse as `{...}` object literal; returns its entries */
|
|
50
56
|
object?: 'literal-keys' | 'any-keys' | 'state';
|
|
51
57
|
report: (code: Code, message: string, offset: number, hint?: string) => void;
|
|
@@ -73,6 +79,8 @@ export interface ExprResult {
|
|
|
73
79
|
ast: Node | null;
|
|
74
80
|
/** names resolved to payload fields (`payload` mode) */
|
|
75
81
|
payloadNames?: string[];
|
|
82
|
+
/** state names read where the payload is the scope (they shadow payload fields of the same name) */
|
|
83
|
+
payloadStates?: string[];
|
|
76
84
|
}
|
|
77
85
|
export declare const ALL_MAGIC: string[];
|
|
78
86
|
export declare const STORE_FNS: string[];
|
package/dist/compiler/expr.js
CHANGED
|
@@ -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) => {
|
|
@@ -117,6 +118,14 @@ 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) {
|
|
121
130
|
for (let i = scope.loops.length - 1; i >= 0; i--) {
|
|
122
131
|
if (scope.loops[i].name === name)
|
|
@@ -148,6 +157,10 @@ export function analyze(src, scope, page, opts) {
|
|
|
148
157
|
payloadNames.add(name);
|
|
149
158
|
return { kind: 'payload' };
|
|
150
159
|
}
|
|
160
|
+
if (page.actions?.has(name)) {
|
|
161
|
+
report('SN203', `\`${name}\` is an action: actions run through \`data-sn-call\` or \`<sn-resource call>\`, not inside expressions`, offset);
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
151
164
|
if (BROWSER.has(name)) {
|
|
152
165
|
report('SN202', `\`${name}\` is a browser API; expressions may use JavaScript, not the browser. Use an island`, offset);
|
|
153
166
|
return null;
|
|
@@ -278,8 +291,8 @@ export function analyze(src, scope, page, opts) {
|
|
|
278
291
|
else if (PAGE_FIELDS.includes(field) && !r.paginate) {
|
|
279
292
|
report('SN303', `\`$meta.${rname}.${field}\` is a pagination field, but \`${rname}\` has no \`paginate\``, n.start);
|
|
280
293
|
}
|
|
281
|
-
else if (field === 'page' && r.paginate !== 'offset') {
|
|
282
|
-
report('SN303', `\`$meta.${rname}.page\` exists only with \`paginate="offset"\``, n.start);
|
|
294
|
+
else if (field === 'page' && r.paginate !== 'offset' && !r.jumps) {
|
|
295
|
+
report('SN303', `\`$meta.${rname}.page\` exists only with \`paginate="offset"\`, or \`paginate="keyset"\` with \`offset-param\``, n.start);
|
|
283
296
|
}
|
|
284
297
|
}
|
|
285
298
|
}
|
|
@@ -572,8 +585,10 @@ export function analyze(src, scope, page, opts) {
|
|
|
572
585
|
report('SN303', `\`${first.value}\` has no \`paginate\`; \`page()\` needs a paginated resource`, n.start);
|
|
573
586
|
const target = n.arguments[1];
|
|
574
587
|
const named = target?.type === 'Literal' && typeof target.value === 'string' ? target.value : null;
|
|
575
|
-
if (r.paginate && r.paginate !== 'offset' &&
|
|
576
|
-
report('SN304',
|
|
588
|
+
if (r.paginate && r.paginate !== 'offset' && !named && !(r.paginate === 'keyset' && r.jumps)) {
|
|
589
|
+
report('SN304', r.paginate === 'keyset'
|
|
590
|
+
? 'keyset pagination cannot jump to a page number. Add `offset-param` if the server also accepts an offset, or use `paginate="offset"`'
|
|
591
|
+
: 'keyset pagination cannot jump to a page number. Use `paginate="offset"`', target?.start ?? n.start);
|
|
577
592
|
}
|
|
578
593
|
else if (r.paginate === 'append' && named && named !== 'next') {
|
|
579
594
|
report('SN304', '`append` pagination only supports `page(…, \'next\')`', target.start);
|
|
@@ -827,7 +842,7 @@ export function analyze(src, scope, page, opts) {
|
|
|
827
842
|
}
|
|
828
843
|
}
|
|
829
844
|
}
|
|
830
|
-
return { ok, code, deps: [...deps].sort(), writes: [...writes].sort(), usesEl, usesIndex, entries, path, resource, ast, ...(opts.payload ? { payloadNames: [...payloadNames].sort() } : {}) };
|
|
845
|
+
return { ok, code, deps: [...deps].sort(), writes: [...writes].sort(), usesEl, usesIndex, entries, path, resource, ast, ...(opts.payload ? { payloadNames: [...payloadNames].sort(), payloadStates: [...payloadStates].sort() } : {}) };
|
|
831
846
|
}
|
|
832
847
|
export function applyEdits(src, edits) {
|
|
833
848
|
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;
|