@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
package/dist/runtime/app.js
CHANGED
|
@@ -30,11 +30,10 @@ const after = (el) => {
|
|
|
30
30
|
const ET = EventTarget.prototype;
|
|
31
31
|
/** Per-element registries, kept on the element (cheaper than WeakMaps on the row creation path). */
|
|
32
32
|
const HANDLERS = Symbol('sinter.on'), MODEL = Symbol('sinter.model'), STATE = Symbol('sinter.state');
|
|
33
|
-
const
|
|
33
|
+
const indexReaders = new WeakMap();
|
|
34
34
|
/** row templates: whether each holds one element (see oneElement()) */
|
|
35
35
|
const prepared = new WeakMap();
|
|
36
36
|
const simple = new WeakMap(), SIMPLE = new Set(['text', 'attr', 'class', 'style', 'show', 'prop', 'on']);
|
|
37
|
-
const hasDialog = (tpl) => dialogsIn.get(tpl) ?? (dialogsIn.set(tpl, !!tpl.content.querySelector('dialog')), dialogsIn.get(tpl));
|
|
38
37
|
/** The instance mount() created on `root`; throws if none. */
|
|
39
38
|
export function instance(root) {
|
|
40
39
|
const app = instances.get(root);
|
|
@@ -56,6 +55,8 @@ export function mount(root, o) {
|
|
|
56
55
|
const types = new Set(), selects = new Set(), deferred = new Set();
|
|
57
56
|
const listeners = new Map(), cleanups = [];
|
|
58
57
|
const shared = new Map();
|
|
58
|
+
/** `showModal(id)`, `close(id)`: the dialog of this mount root with that id */
|
|
59
|
+
const dialog = (id) => [...root.querySelectorAll('dialog')].find((d) => d.id === id);
|
|
59
60
|
const rootRbs = [], rootScopes = [], found = new Map();
|
|
60
61
|
let dirty = [], cur = [], scheduled = false, flushing = false, round = 0;
|
|
61
62
|
const res = (name) => {
|
|
@@ -76,6 +77,27 @@ export function mount(root, o) {
|
|
|
76
77
|
};
|
|
77
78
|
/** Elements written through callbacks (`$.$m(row, …)`) since the last flush. */
|
|
78
79
|
const marked = new Set();
|
|
80
|
+
/** debounced handlers' pending timers */
|
|
81
|
+
const timers = new WeakMap();
|
|
82
|
+
/** action calls' signal: aborts when the page is hidden */
|
|
83
|
+
let hide = new AbortController();
|
|
84
|
+
/** runs an action function; it counts as busy (for the testing module's settle) while it runs */
|
|
85
|
+
async function invoke(run, args, ctx) {
|
|
86
|
+
app.busy++;
|
|
87
|
+
try {
|
|
88
|
+
return await fns[run](args, ctx);
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
app.busy--;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** a failed call: SinterErrors as they are, anything else with status null */
|
|
95
|
+
const callError = (e) => {
|
|
96
|
+
if (e instanceof SinterError)
|
|
97
|
+
return e;
|
|
98
|
+
const detail = (e instanceof Error ? e.message : typeof e == 'string' ? e : '') || null;
|
|
99
|
+
return new SinterError(null, e, detail ?? String(e), detail);
|
|
100
|
+
};
|
|
79
101
|
/** Per flush round: how dirty paths reach a template's rows (0 not, 1 only marked elements, 2 any row). */
|
|
80
102
|
let rowHits = new Map();
|
|
81
103
|
/**
|
|
@@ -127,7 +149,7 @@ export function mount(root, o) {
|
|
|
127
149
|
return false;
|
|
128
150
|
};
|
|
129
151
|
const app = {
|
|
130
|
-
ir, fns, env, req, transport: o.transport ?? (() => undefined), ext: {},
|
|
152
|
+
ir, fns, env, req, root, transport: o.transport ?? (() => undefined), ext: {},
|
|
131
153
|
beforeFlush: [], afterFlush: [], onListen: [], states: [], busy: 0,
|
|
132
154
|
get isDirty() { return dirty.length > 0 || scheduled; },
|
|
133
155
|
dev: false,
|
|
@@ -170,6 +192,7 @@ export function mount(root, o) {
|
|
|
170
192
|
c();
|
|
171
193
|
instances.delete(root);
|
|
172
194
|
},
|
|
195
|
+
scopeOf: (i) => (i < 0 ? app.page : rootScopes[i] ?? app.page),
|
|
173
196
|
url(tpl, s) {
|
|
174
197
|
let out = '', query = false, dropped = false;
|
|
175
198
|
tpl.forEach((p, i) => {
|
|
@@ -251,12 +274,14 @@ export function mount(root, o) {
|
|
|
251
274
|
} };
|
|
252
275
|
},
|
|
253
276
|
storeFns: (el) => ({
|
|
277
|
+
showModal: (id) => { const d = dialog(id); if (d && !d.open)
|
|
278
|
+
d.showModal(); },
|
|
254
279
|
refresh: (n) => res(n).refetch(),
|
|
255
280
|
upsert: forward('upsert'),
|
|
256
281
|
patch: forward('patch'),
|
|
257
282
|
remove: forward('remove'),
|
|
258
283
|
page: (n, t) => app.ext.paginate.page(n, t),
|
|
259
|
-
close: () => el?.closest('dialog')?.close(),
|
|
284
|
+
close: (id) => (id ? dialog(id) : el?.closest('dialog'))?.close(),
|
|
260
285
|
reset: () => {
|
|
261
286
|
const form = el?.closest('form');
|
|
262
287
|
if (!form)
|
|
@@ -298,6 +323,10 @@ export function mount(root, o) {
|
|
|
298
323
|
}
|
|
299
324
|
for (const def of ir.resources)
|
|
300
325
|
createLoader(def);
|
|
326
|
+
// action calls see the page leave through ctx.signal
|
|
327
|
+
if (ir.actions.some((x) => x.call) || ir.resources.some((x) => x.call)) {
|
|
328
|
+
app.addCleanup(env.listen('pagehide', () => { hide.abort(); hide = new AbortController(); }));
|
|
329
|
+
}
|
|
301
330
|
// listeners for every event type the page binds, rows included
|
|
302
331
|
listen('toggle');
|
|
303
332
|
for (const nd of [...ir.roots, ...ir.templates.flatMap((t) => t.nodes)])
|
|
@@ -390,10 +419,10 @@ export function mount(root, o) {
|
|
|
390
419
|
cleanups.push(() => ET.removeEventListener.call(root, type, dispatch, true));
|
|
391
420
|
}
|
|
392
421
|
/**
|
|
393
|
-
*
|
|
394
|
-
*
|
|
422
|
+
* Only elements the compiler marked as in a dialog of their own block (`dl`) are checked for a
|
|
423
|
+
* closed dialog; dialogs outside a template defer the whole list.
|
|
395
424
|
*/
|
|
396
|
-
function bindNodes(defs, els, base, rbs
|
|
425
|
+
function bindNodes(defs, els, base, rbs) {
|
|
397
426
|
// scopes of elements that declare state (and their descendants); most rows have none
|
|
398
427
|
let scopes = null;
|
|
399
428
|
for (let i = 0; i < defs.length; i++) {
|
|
@@ -416,7 +445,7 @@ export function mount(root, o) {
|
|
|
416
445
|
rootScopes[i] = s;
|
|
417
446
|
if (el)
|
|
418
447
|
for (const b of nd.b) {
|
|
419
|
-
const rb = { b, el, s, v: UNSET, c:
|
|
448
|
+
const rb = { b, el, s, v: UNSET, c: !!nd.dl };
|
|
420
449
|
if (b.k == 'on') {
|
|
421
450
|
const h = el[HANDLERS];
|
|
422
451
|
el[HANDLERS] = h == null ? rb : Array.isArray(h) ? (h.push(rb), h) : [h, rb];
|
|
@@ -484,7 +513,7 @@ export function mount(root, o) {
|
|
|
484
513
|
/** Evaluates and writes one binding if needed; false while it waits in a closed dialog. */
|
|
485
514
|
function process(rb, level) {
|
|
486
515
|
const force = level == 2 || (level == 1 && !!rb.b.i);
|
|
487
|
-
if (!force && !deferred.has(rb) && !(rb.b.d && hit(depsOf(rb.b, rb.b.d), rb.s)))
|
|
516
|
+
if (!force && !(deferred.size && deferred.has(rb)) && !(rb.b.d && hit(rb.d ??= depsOf(rb.b, rb.b.d), rb.s)))
|
|
488
517
|
return true;
|
|
489
518
|
if (rb.c && rb.el.closest('dialog:not([open])')) {
|
|
490
519
|
deferred.add(rb);
|
|
@@ -762,7 +791,7 @@ export function mount(root, o) {
|
|
|
762
791
|
els[k] = node && node.localName == defs[k].tag ? node : null;
|
|
763
792
|
}
|
|
764
793
|
}
|
|
765
|
-
if (isSimple(
|
|
794
|
+
if (isSimple(t)) {
|
|
766
795
|
// only value bindings and handlers: bind and write each once, as walk(rbs, 2) would
|
|
767
796
|
for (let k = 0; k < defs.length; k++) {
|
|
768
797
|
const el = els[k];
|
|
@@ -782,7 +811,7 @@ export function mount(root, o) {
|
|
|
782
811
|
}
|
|
783
812
|
}
|
|
784
813
|
else {
|
|
785
|
-
bindNodes(defs, els, s, rbs
|
|
814
|
+
bindNodes(defs, els, s, rbs);
|
|
786
815
|
walk(rbs, 2);
|
|
787
816
|
}
|
|
788
817
|
const nodes = [];
|
|
@@ -804,10 +833,10 @@ export function mount(root, o) {
|
|
|
804
833
|
return one;
|
|
805
834
|
}
|
|
806
835
|
/** A row template whose elements only have value bindings and handlers (no state, model, nested template or dialog). */
|
|
807
|
-
function isSimple(
|
|
836
|
+
function isSimple(t) {
|
|
808
837
|
let v = simple.get(t);
|
|
809
838
|
if (v == null) {
|
|
810
|
-
v =
|
|
839
|
+
v = t.nodes.every((nd) => !nd.dl && nd.state == null && nd.b.every((b) => SIMPLE.has(b.k)));
|
|
811
840
|
simple.set(t, v);
|
|
812
841
|
}
|
|
813
842
|
return v;
|
|
@@ -843,7 +872,7 @@ export function mount(root, o) {
|
|
|
843
872
|
const h = el[HANDLERS], hs = h && (Array.isArray(h) ? h : [h]).filter((x) => x.b.n == e.type);
|
|
844
873
|
if (hs?.length) {
|
|
845
874
|
for (const h of hs)
|
|
846
|
-
h.b.x != null ? trigger(h, e) : handle(h, h.b.f, e, h.b.w);
|
|
875
|
+
h.b.x != null ? trigger(h, e) : h.b.ms ? later(h, e) : handle(h, h.b.f, e, h.b.w);
|
|
847
876
|
return;
|
|
848
877
|
}
|
|
849
878
|
if (el == root)
|
|
@@ -864,6 +893,16 @@ export function mount(root, o) {
|
|
|
864
893
|
if (writes?.some((w) => w.startsWith('$url')))
|
|
865
894
|
writeUrl();
|
|
866
895
|
}
|
|
896
|
+
/** A debounced handler: runs once `ms` after the last event, with that event. */
|
|
897
|
+
function later(rb, e) {
|
|
898
|
+
const t = timers.get(rb);
|
|
899
|
+
if (t) {
|
|
900
|
+
env.clearTimeout(t);
|
|
901
|
+
app.busy--;
|
|
902
|
+
}
|
|
903
|
+
app.busy++;
|
|
904
|
+
timers.set(rb, env.setTimeout(() => { timers.delete(rb); app.busy--; handle(rb, rb.b.f, e, rb.b.w); }, rb.b.ms, 'debounce'));
|
|
905
|
+
}
|
|
867
906
|
// ---- actions
|
|
868
907
|
function trigger(rb, e) {
|
|
869
908
|
const a = ir.actions[rb.b.x], el = rb.el;
|
|
@@ -889,29 +928,49 @@ export function mount(root, o) {
|
|
|
889
928
|
if (!get && a.concurrency == 'block' && st.n)
|
|
890
929
|
return;
|
|
891
930
|
const s = child(rb.s, { $el: rb.el, $event: e });
|
|
892
|
-
let url, body;
|
|
931
|
+
let url = '', body;
|
|
893
932
|
try {
|
|
894
|
-
|
|
895
|
-
|
|
933
|
+
// a call's arguments are taken when it is triggered, like a request's URL and body
|
|
934
|
+
if (a.call)
|
|
935
|
+
body = fns[a.call.args](s);
|
|
936
|
+
else {
|
|
937
|
+
url = app.url(a.url, s);
|
|
938
|
+
const fields = a.form && !get && a.method != 'DELETE';
|
|
939
|
+
body = a.body != null ? fns[a.body](s) : fields ? (a.enc == 'form' ? formFields(rb.el) : serialize(rb.el)) : undefined;
|
|
940
|
+
if (a.enc == 'form')
|
|
941
|
+
body = formBody(body);
|
|
942
|
+
}
|
|
896
943
|
}
|
|
897
944
|
catch (err) {
|
|
898
945
|
return unhandled(toError(err), rb.el);
|
|
899
946
|
}
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
else if (a.concurrency == 'latest') {
|
|
909
|
-
st.w ? st.w.drop() : app.busy++;
|
|
910
|
-
st.w = { run, drop };
|
|
947
|
+
// a question first: the action runs only when the user agrees
|
|
948
|
+
const question = a.confirm != null ? fns[a.confirm](s) : null;
|
|
949
|
+
if (question) {
|
|
950
|
+
const yes = config.confirm(String(question));
|
|
951
|
+
if (yes instanceof Promise)
|
|
952
|
+
return void yes.then((y) => y && go());
|
|
953
|
+
if (!yes)
|
|
954
|
+
return;
|
|
911
955
|
}
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
956
|
+
go();
|
|
957
|
+
function go() {
|
|
958
|
+
const drop = a.optimistic != null ? app.ext.optimistic.begin(a.optimistic, s) : () => { };
|
|
959
|
+
if (get) {
|
|
960
|
+
st.c?.abort();
|
|
961
|
+
return void send(rb, a, st, url, body, drop, (st.c = new AbortController()));
|
|
962
|
+
}
|
|
963
|
+
const run = () => void send(rb, a, st, url, body, drop);
|
|
964
|
+
if (!st.n)
|
|
965
|
+
run();
|
|
966
|
+
else if (a.concurrency == 'latest') {
|
|
967
|
+
st.w ? st.w.drop() : app.busy++;
|
|
968
|
+
st.w = { run, drop };
|
|
969
|
+
}
|
|
970
|
+
else {
|
|
971
|
+
app.busy++;
|
|
972
|
+
st.q.push(run);
|
|
973
|
+
}
|
|
915
974
|
}
|
|
916
975
|
}
|
|
917
976
|
async function send(rb, a, st, url, body, drop, c) {
|
|
@@ -924,7 +983,9 @@ export function mount(root, o) {
|
|
|
924
983
|
x.disabled = true;
|
|
925
984
|
}
|
|
926
985
|
try {
|
|
927
|
-
const result =
|
|
986
|
+
const result = a.call
|
|
987
|
+
? await invoke(a.call.run, body, { signal: hide.signal, request: (u, i) => req.send(u, i), el })
|
|
988
|
+
: await req.send(url, { method: a.method, body, signal: c?.signal });
|
|
928
989
|
if (c && st.c != c)
|
|
929
990
|
return;
|
|
930
991
|
drop();
|
|
@@ -935,7 +996,7 @@ export function mount(root, o) {
|
|
|
935
996
|
drop();
|
|
936
997
|
if (isAbort(err) || (c && st.c != c))
|
|
937
998
|
return;
|
|
938
|
-
const error = toError(err);
|
|
999
|
+
const error = a.call ? callError(err) : toError(err);
|
|
939
1000
|
a.catch != null ? handle(rb, a.catch, null, a.w, { $error: error }) : unhandled(error, el);
|
|
940
1001
|
}
|
|
941
1002
|
finally {
|
|
@@ -968,14 +1029,34 @@ export function mount(root, o) {
|
|
|
968
1029
|
}
|
|
969
1030
|
// ---- resources
|
|
970
1031
|
function createLoader(def) {
|
|
971
|
-
const r = createResource(def.name, { key: def.key, order: def.order, locale: () => config.locale?.get(), warn, prep: (x) => (app.prep ? app.prep(x) : x) });
|
|
1032
|
+
const r = createResource(def.name, { key: def.key, order: def.order, locale: () => config.locale?.get(), warn, prep: (x) => (app.prep ? app.prep(x) : x), freeze: app.dev });
|
|
972
1033
|
r.onDirty = (what) => app.mark((what == 'data' ? '$r.' : '$meta.') + def.name);
|
|
973
1034
|
resources.set(def.name, r);
|
|
974
1035
|
Object.defineProperty(app.page.$r, def.name, { get: r.value, enumerable: true });
|
|
975
1036
|
app.page.$meta[def.name] = r.meta;
|
|
976
1037
|
let url = null, c = null, seq = 0, again = null;
|
|
1038
|
+
const scope = () => app.scopeOf(def.scope);
|
|
1039
|
+
/** `call`: the arguments of the current load; the identity of a load is its arguments' JSON */
|
|
1040
|
+
let args = [], calls = 0;
|
|
977
1041
|
const resolve = () => {
|
|
978
|
-
const s =
|
|
1042
|
+
const s = scope();
|
|
1043
|
+
if (def.call) {
|
|
1044
|
+
try {
|
|
1045
|
+
if (def.when != null && !fns[def.when](s))
|
|
1046
|
+
return null;
|
|
1047
|
+
args = fns[def.call.args](s);
|
|
1048
|
+
try {
|
|
1049
|
+
return 'call:' + JSON.stringify(args);
|
|
1050
|
+
}
|
|
1051
|
+
catch {
|
|
1052
|
+
return `call:#${++calls}`;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
catch (e) {
|
|
1056
|
+
warn(17, def.name, e.message);
|
|
1057
|
+
return null;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
979
1060
|
try {
|
|
980
1061
|
return def.when != null && !fns[def.when](s) ? null : app.url(def.src, s);
|
|
981
1062
|
}
|
|
@@ -1010,8 +1091,23 @@ export function mount(root, o) {
|
|
|
1010
1091
|
};
|
|
1011
1092
|
/** the request URL for the current page of `u` */
|
|
1012
1093
|
const request = (u, m = l.pager?.current()) => withParams(u, m ? l.pager.params(m) : null);
|
|
1094
|
+
const fail = (e) => {
|
|
1095
|
+
const error = toError(e);
|
|
1096
|
+
r.meta.error = error;
|
|
1097
|
+
r.onDirty('meta');
|
|
1098
|
+
unhandled(error, null, def.name);
|
|
1099
|
+
};
|
|
1013
1100
|
const l = {
|
|
1014
1101
|
def, res: r, app, pager: null,
|
|
1102
|
+
scope, get: loadShared, failed: fail, loading,
|
|
1103
|
+
items(body) {
|
|
1104
|
+
let items = pick(def.items, body);
|
|
1105
|
+
if (!Array.isArray(items)) {
|
|
1106
|
+
warn(12, def.name, def.items ?? '(response)');
|
|
1107
|
+
items = [];
|
|
1108
|
+
}
|
|
1109
|
+
return items.map(clean);
|
|
1110
|
+
},
|
|
1015
1111
|
start() {
|
|
1016
1112
|
const el = def.data != null ? found.get(def.data) : null;
|
|
1017
1113
|
let data;
|
|
@@ -1069,7 +1165,9 @@ export function mount(root, o) {
|
|
|
1069
1165
|
loading(true);
|
|
1070
1166
|
try {
|
|
1071
1167
|
const req = withParams(url, m ? l.pager.params(m) : null);
|
|
1072
|
-
const body =
|
|
1168
|
+
const body = def.call
|
|
1169
|
+
? await invoke(def.call.run, args, { signal: ctl.signal, request: (u, i) => app.req.send(u, i), el: null })
|
|
1170
|
+
: await loadShared(req, ctl.signal);
|
|
1073
1171
|
if (n != seq)
|
|
1074
1172
|
return;
|
|
1075
1173
|
store(body, req, m);
|
|
@@ -1080,10 +1178,7 @@ export function mount(root, o) {
|
|
|
1080
1178
|
catch (e) {
|
|
1081
1179
|
if (n != seq || isAbort(e))
|
|
1082
1180
|
return;
|
|
1083
|
-
|
|
1084
|
-
r.meta.error = error;
|
|
1085
|
-
r.onDirty('meta');
|
|
1086
|
-
unhandled(error, null, def.name);
|
|
1181
|
+
fail(def.call ? callError(e) : e);
|
|
1087
1182
|
}
|
|
1088
1183
|
finally {
|
|
1089
1184
|
// writes made while this load ran win over its (older) response
|
|
@@ -1095,6 +1190,8 @@ export function mount(root, o) {
|
|
|
1095
1190
|
}
|
|
1096
1191
|
},
|
|
1097
1192
|
};
|
|
1193
|
+
if (def.each)
|
|
1194
|
+
app.ext.each.fanOut(l);
|
|
1098
1195
|
if (def.paginate)
|
|
1099
1196
|
l.pager = app.ext.paginate.pager(l);
|
|
1100
1197
|
r.loader = l;
|
|
@@ -1156,6 +1253,47 @@ export function lis(seq) {
|
|
|
1156
1253
|
return keep;
|
|
1157
1254
|
}
|
|
1158
1255
|
/** Serializes a form's named controls to nested JSON, or FormData if it has a file input. */
|
|
1256
|
+
/**
|
|
1257
|
+
* A body as form fields (`data-sn-encoding="form"`): a form's own fields, or an object's keys with
|
|
1258
|
+
* arrays as repeated keys; null and undefined values are left out. Files stay multipart.
|
|
1259
|
+
*/
|
|
1260
|
+
export function formBody(body) {
|
|
1261
|
+
if (body == null)
|
|
1262
|
+
return null;
|
|
1263
|
+
if (body instanceof FormData) {
|
|
1264
|
+
const out = new URLSearchParams();
|
|
1265
|
+
for (const [k, v] of body) {
|
|
1266
|
+
if (typeof v != 'string')
|
|
1267
|
+
return body;
|
|
1268
|
+
out.append(k, v);
|
|
1269
|
+
}
|
|
1270
|
+
return out;
|
|
1271
|
+
}
|
|
1272
|
+
if (body instanceof URLSearchParams)
|
|
1273
|
+
return body;
|
|
1274
|
+
const out = new URLSearchParams();
|
|
1275
|
+
for (const [k, v] of Object.entries(body)) {
|
|
1276
|
+
for (const x of Array.isArray(v) ? v : [v])
|
|
1277
|
+
if (x != null)
|
|
1278
|
+
out.append(k, typeof x == 'object' ? JSON.stringify(x) : String(x));
|
|
1279
|
+
}
|
|
1280
|
+
return out;
|
|
1281
|
+
}
|
|
1282
|
+
/** A form's fields as browsers submit them; with a file input, multipart. */
|
|
1283
|
+
function formFields(form) {
|
|
1284
|
+
const out = new URLSearchParams();
|
|
1285
|
+
for (const c of form.elements) {
|
|
1286
|
+
if (!c.name || c.disabled || /^(submit|button|reset|image|fieldset|output)$/.test(c.type))
|
|
1287
|
+
continue;
|
|
1288
|
+
if (c.type == 'file')
|
|
1289
|
+
return new FormData(form);
|
|
1290
|
+
if ((c.type == 'checkbox' || c.type == 'radio') && !c.checked)
|
|
1291
|
+
continue;
|
|
1292
|
+
for (const v of c.type == 'select-multiple' ? [...c.selectedOptions].map((o) => o.value) : [c.value])
|
|
1293
|
+
out.append(c.name, v);
|
|
1294
|
+
}
|
|
1295
|
+
return out;
|
|
1296
|
+
}
|
|
1159
1297
|
export function serialize(form) {
|
|
1160
1298
|
const controls = [...form.elements];
|
|
1161
1299
|
if (controls.some((c) => c.type == 'file'))
|
package/dist/runtime/config.d.ts
CHANGED
|
@@ -13,17 +13,24 @@ export interface Config {
|
|
|
13
13
|
origins: string[];
|
|
14
14
|
/** the transport for a `<sn-sync>` stream URL; `undefined` (or no function) means EventSource */
|
|
15
15
|
transport: ((url: string) => Transport | undefined) | null;
|
|
16
|
+
/** `data-sn-confirm`: asks the question; the action runs on true (window.confirm by default) */
|
|
17
|
+
confirm: (question: string) => boolean | Promise<boolean>;
|
|
16
18
|
env: Env;
|
|
17
19
|
}
|
|
18
20
|
export declare const config: Config;
|
|
19
21
|
export declare function configure(options: Partial<Omit<Config, 'env'>> & {
|
|
20
22
|
env?: Partial<Env>;
|
|
21
23
|
}): void;
|
|
22
|
-
/** `$error`: a non-2xx response, a network error (status 0) or a
|
|
24
|
+
/** `$error`: a non-2xx response, a network error (status 0), a refused request, or a failed action call (status null). */
|
|
23
25
|
export declare class SinterError extends Error {
|
|
24
|
-
readonly status: number;
|
|
26
|
+
readonly status: number | null;
|
|
25
27
|
readonly body: unknown;
|
|
26
|
-
|
|
28
|
+
readonly detail: string | null;
|
|
29
|
+
/**
|
|
30
|
+
* @param detail what went wrong in words: the server's message or text, or a thrown error's
|
|
31
|
+
* message; null when there is none (`message` then falls back to the HTTP status)
|
|
32
|
+
*/
|
|
33
|
+
constructor(status: number | null, body: unknown, message: string, detail?: string | null);
|
|
27
34
|
}
|
|
28
35
|
/** page-load cache entries (`cache` on `<sn-resource>`), in sessionStorage */
|
|
29
36
|
export declare const CACHE_PREFIX = "sn:c:";
|
package/dist/runtime/config.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// comes only from configure() in JavaScript, never from the DOM.
|
|
3
3
|
import { defaultEnv } from './env.js';
|
|
4
4
|
export const config = {
|
|
5
|
-
request: [], sessionProbe: null, onError: null, locale: null, urlSchemes: [], origins: [], transport: null,
|
|
5
|
+
request: [], sessionProbe: null, onError: null, locale: null, urlSchemes: [], origins: [], transport: null,
|
|
6
|
+
confirm: (question) => globalThis.confirm(question), env: defaultEnv,
|
|
6
7
|
};
|
|
7
8
|
export function configure(options) {
|
|
8
9
|
const { env, ...rest } = options;
|
|
@@ -10,14 +11,20 @@ export function configure(options) {
|
|
|
10
11
|
if (env)
|
|
11
12
|
config.env = { ...config.env, ...env };
|
|
12
13
|
}
|
|
13
|
-
/** `$error`: a non-2xx response, a network error (status 0) or a
|
|
14
|
+
/** `$error`: a non-2xx response, a network error (status 0), a refused request, or a failed action call (status null). */
|
|
14
15
|
export class SinterError extends Error {
|
|
15
16
|
status;
|
|
16
17
|
body;
|
|
17
|
-
|
|
18
|
+
detail;
|
|
19
|
+
/**
|
|
20
|
+
* @param detail what went wrong in words: the server's message or text, or a thrown error's
|
|
21
|
+
* message; null when there is none (`message` then falls back to the HTTP status)
|
|
22
|
+
*/
|
|
23
|
+
constructor(status, body, message, detail = null) {
|
|
18
24
|
super(message);
|
|
19
25
|
this.status = status;
|
|
20
26
|
this.body = body;
|
|
27
|
+
this.detail = detail;
|
|
21
28
|
this.name = 'SinterError';
|
|
22
29
|
}
|
|
23
30
|
}
|
|
@@ -3,7 +3,7 @@ import type { Env } from './env.js';
|
|
|
3
3
|
export declare const TIMEOUT = 30000;
|
|
4
4
|
export interface SendInit {
|
|
5
5
|
method?: string;
|
|
6
|
-
/** JSON-serialized unless FormData */
|
|
6
|
+
/** JSON-serialized unless FormData or URLSearchParams (sent form-encoded) */
|
|
7
7
|
body?: unknown;
|
|
8
8
|
headers?: Record<string, string>;
|
|
9
9
|
signal?: AbortSignal | undefined;
|
package/dist/runtime/request.js
CHANGED
|
@@ -44,6 +44,10 @@ export function createRequestLayer(env, chain) {
|
|
|
44
44
|
let body = init.body;
|
|
45
45
|
if (body == null || method == 'GET')
|
|
46
46
|
body = undefined;
|
|
47
|
+
else if (body instanceof URLSearchParams) {
|
|
48
|
+
body = body.toString();
|
|
49
|
+
headers.set('Content-Type', 'application/x-www-form-urlencoded;charset=UTF-8');
|
|
50
|
+
}
|
|
47
51
|
else if (!(body instanceof FormData)) {
|
|
48
52
|
body = JSON.stringify(body);
|
|
49
53
|
headers.set('Content-Type', 'application/json');
|
|
@@ -71,7 +75,8 @@ export function createRequestLayer(env, chain) {
|
|
|
71
75
|
const res = await raw(url, init), body = await parseBody(res);
|
|
72
76
|
if (!res.ok) {
|
|
73
77
|
const b = body, m = typeof body == 'string' ? body.length < 200 && body.trim() : b?.message ?? b?.error;
|
|
74
|
-
|
|
78
|
+
const detail = typeof m == 'string' && m ? m : null;
|
|
79
|
+
throw new SinterError(res.status, body, detail ?? `${res.status} ${res.statusText}`.trim(), detail);
|
|
75
80
|
}
|
|
76
81
|
return body;
|
|
77
82
|
}
|
package/dist/runtime/store.d.ts
CHANGED
|
@@ -29,14 +29,14 @@ export type Op = {
|
|
|
29
29
|
export interface Meta {
|
|
30
30
|
loading: boolean;
|
|
31
31
|
error: {
|
|
32
|
-
status: number;
|
|
32
|
+
status: number | null;
|
|
33
33
|
body: unknown;
|
|
34
34
|
message: string;
|
|
35
35
|
} | null;
|
|
36
36
|
loadedAt: number | null;
|
|
37
37
|
pending: number;
|
|
38
38
|
total: number | null;
|
|
39
|
-
page?: number;
|
|
39
|
+
page?: number | null;
|
|
40
40
|
pages?: number | null;
|
|
41
41
|
from?: number | null;
|
|
42
42
|
to?: number | null;
|
|
@@ -50,6 +50,8 @@ export interface ResourceOptions {
|
|
|
50
50
|
warn?: (code: number, ...args: unknown[]) => void;
|
|
51
51
|
/** applied to every stored item (dev builds freeze them) */
|
|
52
52
|
prep?: <T>(item: T) => T;
|
|
53
|
+
/** hand out frozen lists (dev builds) */
|
|
54
|
+
freeze?: boolean;
|
|
53
55
|
}
|
|
54
56
|
/** The public store handle (`app.resource(name)`), plus the hooks loaders and features use. */
|
|
55
57
|
export interface Resource<T extends object = any> {
|
|
@@ -66,7 +68,8 @@ export interface Resource<T extends object = any> {
|
|
|
66
68
|
upsert(item: T): void;
|
|
67
69
|
patch(key: string | T, fields: Partial<T>): void;
|
|
68
70
|
remove(key: string | T): void;
|
|
69
|
-
|
|
71
|
+
/** `hint`: a server event's payload; an `each` resource reloads only the source it names */
|
|
72
|
+
refetch(hint?: object): Promise<void>;
|
|
70
73
|
subscribe(fn: (change: Change<T>) => void): () => void;
|
|
71
74
|
keyOf(item: string | T): string;
|
|
72
75
|
/**
|
|
@@ -86,7 +89,7 @@ export interface Resource<T extends object = any> {
|
|
|
86
89
|
/** while set, upsert/patch/remove are recorded instead of applied */
|
|
87
90
|
recording: Op[] | null;
|
|
88
91
|
loader: {
|
|
89
|
-
refetch(): Promise<void>;
|
|
92
|
+
refetch(hint?: object): Promise<void>;
|
|
90
93
|
} | null;
|
|
91
94
|
onDirty(what: 'data' | 'meta'): void;
|
|
92
95
|
}
|