@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.
@@ -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 { names, namespaces } = exportsOf(source);
48
- manifest.names = names;
49
- for (const [ns, spec] of namespaces) {
50
- const path = resolve(spec, options.file);
51
- let members = [];
52
- if (path) {
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
- function exportsOf(source) {
66
- const ast = acorn.parse(source, { ecmaVersion: 'latest', sourceType: 'module' });
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);
@@ -19,9 +19,10 @@ export const ATTRIBUTES = [
19
19
  { name: 'put', value: 'URL template', doc: 'PUT action' },
20
20
  { name: 'patch', value: 'URL template', doc: 'PATCH action' },
21
21
  { name: 'delete', value: 'URL template', doc: 'DELETE action' },
22
+ { name: 'call', value: 'action call', doc: 'local action: calls a function from the actions module, e.g. pgp.decrypt(msg)' },
22
23
  { name: 'body', value: 'value expression', doc: 'JSON request body; overrides form serialization' },
23
24
  { name: 'trigger', value: 'DOM event name', doc: 'overrides the default trigger' },
24
- { name: 'debounce', value: 'milliseconds', doc: 'delays the trigger, resets on each event' },
25
+ { name: 'debounce', value: 'milliseconds', doc: 'delays the trigger, resets on each event; without a method, delays the element\'s data-sn-on-* handlers' },
25
26
  { name: 'concurrency', value: 'block | queue | latest', doc: 'mutations only; default block for forms, queue otherwise' },
26
27
  { name: 'optimistic', value: 'handler expression', doc: 'patch/upsert/remove calls applied before the request, rolled back on failure' },
27
28
  { name: 'then', value: 'handler expression', doc: 'runs after a 2xx response; $result = parsed body' },
@@ -31,7 +32,7 @@ export const ATTRIBUTES = [
31
32
  ];
32
33
  export const ON_DOC = { name: 'on-<event>', value: 'handler expression', doc: 'plain event handler, no request; e.g. data-sn-on-click' };
33
34
  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' },
35
+ { 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
36
  { name: 'sn-sync', attrs: ['src'], doc: 'one server event stream' },
36
37
  { 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
38
  { name: 'sn-include', attrs: ['src'], doc: 'inlines a partial at build time' },
package/dist/each.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import type { Feature } from './runtime/app.js';
2
+ declare const each: Feature;
3
+ export default each;
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';
package/dist/ir.d.ts CHANGED
@@ -8,7 +8,11 @@ export interface Paginate {
8
8
  /** keyset/append: object literal evaluated with the boundary item as `$.$p` */
9
9
  cursor: number | null;
10
10
  directionParam: string;
11
+ /** keyset/append: the value sent for each direction, where it differs from the direction's name */
12
+ directionValues?: Partial<Record<'first' | 'prev' | 'next' | 'last', string>>;
11
13
  pageParam: string;
14
+ /** keyset: offset parameter that lets `page(name, n)` jump; the server skips (n - 1) * size items */
15
+ offsetParam?: string;
12
16
  }
13
17
  export interface ResourceDef {
14
18
  name: string;
@@ -29,6 +33,14 @@ export interface ResourceDef {
29
33
  data: number | null;
30
34
  /** `cache`: the oldest cached data to show, in ms (0: any age); absent when not cached */
31
35
  cache?: number;
36
+ /** `call="ns.fn(args)"`: loaded from an action function instead of `src` (then empty) */
37
+ call?: CallDef;
38
+ /** `each="a in accounts"`: one request per list item (src evaluated with `item` in scope), merged into one collection */
39
+ each?: {
40
+ item: string;
41
+ list: number;
42
+ of?: string;
43
+ };
32
44
  }
33
45
  export interface EventDef {
34
46
  name: string;
@@ -42,6 +54,8 @@ export interface EventDef {
42
54
  export interface SyncDef {
43
55
  src: UrlTemplate;
44
56
  events: EventDef[];
57
+ /** index of the node (in `roots`) whose scope `src` and the events' expressions are evaluated in, -1 (or absent) for page scope */
58
+ scope?: number;
45
59
  }
46
60
  export type BindingKind = 'text' | 'attr' | 'class' | 'style' | 'show' | 'model' | 'prop' | 'for' | 'if' | 'else' | 'on';
47
61
  export interface Binding {
@@ -66,6 +80,8 @@ export interface Binding {
66
80
  e?: 1;
67
81
  /** the expression reads a loop index, so it re-runs when only the row's position changed */
68
82
  i?: 1;
83
+ /** handler on an element without a method: `data-sn-debounce` milliseconds */
84
+ ms?: number;
69
85
  }
70
86
  export interface NodeDef {
71
87
  /** `data-sn` id (roots) */
@@ -80,6 +96,8 @@ export interface NodeDef {
80
96
  sid?: number;
81
97
  /** persist key for the whole state object */
82
98
  persist?: string;
99
+ /** the element is a `<dialog>` or inside one of its block, so its bindings wait while that dialog is closed */
100
+ dl?: 1;
83
101
  b: Binding[];
84
102
  }
85
103
  export interface TemplateDef {
@@ -95,9 +113,16 @@ export interface TemplateDef {
95
113
  /** for: function that finds the bound elements of one cloned row: (first content node) => elements, in `nodes` order */
96
114
  find?: number;
97
115
  }
116
+ /** a local action call: `args` evaluates the arguments (an array), `run(args, ctx)` calls the function */
117
+ export interface CallDef {
118
+ args: number;
119
+ run: number;
120
+ }
98
121
  export interface ActionDef {
99
- method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
122
+ /** `CALL`: `data-sn-call`, with `call` set and an empty `url` */
123
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'CALL';
100
124
  url: UrlTemplate;
125
+ call?: CallDef;
101
126
  body: number | null;
102
127
  trigger: string;
103
128
  debounce: number;
@@ -59,7 +59,7 @@ export class TypeScriptBridge {
59
59
  this.filesChanged([support.declarations, ...support.openapi]);
60
60
  this.support = support;
61
61
  for (const [page, unit] of units) {
62
- const s = shadowOf(unit, support, this.root, project.helpers);
62
+ const s = shadowOf(unit, support, this.root, project.helpers, project.actions);
63
63
  const prev = this.shadows.get(page);
64
64
  if (prev?.text === s.text) {
65
65
  prev.unit = unit;
@@ -48,13 +48,14 @@ export class Workspace {
48
48
  }
49
49
  const p = project(root, options);
50
50
  const inputs = new Set(VITE_CONFIGS.map((c) => join(root, c)));
51
- for (const f of [p.helpersFile, p.types, ...p.openapi.map((s) => s.file)])
51
+ for (const f of [p.helpersFile, p.actionsFile, p.types, ...p.openapi.map((s) => s.file)])
52
52
  if (f)
53
53
  inputs.add(f);
54
54
  for (const spec of options.openapi == null ? [] : [].concat(options.openapi))
55
55
  inputs.add(resolve(root, spec));
56
56
  for (const ext of ['.js', '.ts']) {
57
57
  inputs.add(join(root, 'src/sinter.helpers' + ext));
58
+ inputs.add(join(root, 'src/sinter.actions' + ext));
58
59
  inputs.add(join(root, 'src/sinter.types' + ext));
59
60
  }
60
61
  inputs.add(join(root, 'src/sinter.types.d.ts'));
@@ -166,7 +167,7 @@ export class Workspace {
166
167
  }
167
168
  try {
168
169
  const out = compile(html, {
169
- file: page, root: state.root, dev: false, helpers: p.helpers, config: p.config, readFile, check: true, openapi: p.openapi,
170
+ file: page, root: state.root, dev: false, helpers: p.helpers, actions: p.actions, config: p.config, readFile, check: true, openapi: p.openapi,
170
171
  });
171
172
  state.includes.set(page, out.files.map((f) => resolve(f)).filter((f) => f !== page));
172
173
  state.diagnostics.set(page, out.diagnostics);
package/dist/paginate.js CHANGED
@@ -7,14 +7,14 @@ class PagerImpl {
7
7
  this.cfg = loader.def.paginate;
8
8
  this.move = this.first();
9
9
  const m = loader.res.meta;
10
- if (this.cfg.mode === 'offset')
10
+ if (this.cfg.mode === 'offset' || this.cfg.offsetParam)
11
11
  m.page = 1;
12
12
  Object.assign(m, { pages: null, from: null, to: null, hasPrev: false, hasNext: false });
13
13
  }
14
14
  base(dir, size = this.cfg.size) {
15
15
  const p = { [this.cfg.sizeParam]: String(size) };
16
16
  if (dir)
17
- p[this.cfg.directionParam] = dir;
17
+ p[this.cfg.directionParam] = this.cfg.directionValues?.[dir] ?? dir;
18
18
  return p;
19
19
  }
20
20
  first() {
@@ -49,8 +49,10 @@ class PagerImpl {
49
49
  scope.$p = item;
50
50
  const c = this.loader.app.fns[this.cfg.cursor](scope);
51
51
  const out = {};
52
+ // like a URL template's query parameters: null and undefined fields are left out
52
53
  for (const [k, v] of Object.entries(c ?? {}))
53
- out[k] = v == null ? '' : String(v);
54
+ if (v != null)
55
+ out[k] = String(v);
54
56
  return out;
55
57
  };
56
58
  let move;
@@ -80,7 +82,16 @@ class PagerImpl {
80
82
  }
81
83
  else {
82
84
  if (typeof target === 'number') {
83
- this.loader.app.warn('keyset pagination cannot jump to a page number; use paginate="offset"');
85
+ if (!this.cfg.offsetParam) {
86
+ this.loader.app.warn('keyset pagination cannot jump to a page number; add offset-param or use paginate="offset"');
87
+ return;
88
+ }
89
+ // the server skips (n - 1) pages; prev/next continue by cursor from there
90
+ let n = Math.max(1, Math.floor(target));
91
+ if (meta.pages != null)
92
+ n = Math.min(n, meta.pages);
93
+ move = { dir: 'page', page: n, params: { [this.cfg.offsetParam]: String((n - 1) * this.cfg.size), ...this.base('first') }, from, to };
94
+ void this.loader.load(move);
84
95
  return;
85
96
  }
86
97
  if (this.cfg.mode === 'append' && target !== 'next')
@@ -158,6 +169,9 @@ class PagerImpl {
158
169
  hasPrev = full;
159
170
  break;
160
171
  }
172
+ // keyset with jumps: the page that holds the last item shown (after `last`, the last page)
173
+ if (this.cfg.offsetParam && move.dir !== 'page')
174
+ meta.page = to != null ? Math.max(1, Math.ceil(to / size)) : null;
161
175
  Object.assign(meta, { pages, from, to, hasPrev, hasNext });
162
176
  this.loader.res.onDirty('meta');
163
177
  }
package/dist/project.d.ts CHANGED
@@ -3,6 +3,8 @@ import type { OpenApiSpec } from './compiler/openapi.js';
3
3
  export interface ProjectOptions {
4
4
  /** helpers module, relative to the root (default `src/sinter.helpers.js`, or `.ts`) */
5
5
  helpers?: string;
6
+ /** actions module for `data-sn-call` and `<sn-resource call>` (default `src/sinter.actions.js`, or `.ts`) */
7
+ actions?: string;
6
8
  /** app configuration module imported before mount (default `src/sinter.config.js`, or `.ts`) */
7
9
  config?: string;
8
10
  /** module whose exported types `type="…"` on `<sn-resource>` may name (default `src/sinter.types.ts`, `.d.ts` or `.js`) */
@@ -15,6 +17,10 @@ export interface Project {
15
17
  helpers: HelperManifest;
16
18
  /** absolute path of the helpers module, or null */
17
19
  helpersFile: string | null;
20
+ /** the actions module's functions and namespaces (same shape as helpers) */
21
+ actions: HelperManifest;
22
+ /** absolute path of the actions module, or null */
23
+ actionsFile: string | null;
18
24
  /** root-relative URL of the config module (`/src/sinter.config.js`), or null */
19
25
  config: string | null;
20
26
  /** absolute path of the types module, or null */
package/dist/project.js CHANGED
@@ -20,25 +20,28 @@ export function project(root, options = {}) {
20
20
  return readFileSync(real, 'utf8');
21
21
  };
22
22
  const url = (rel) => '/' + rel.split(sep).join('/');
23
- let helpers = { module: null, names: [], namespaces: {} };
24
- const helpersRel = find(options.helpers, 'src/sinter.helpers');
25
- const helpersFile = helpersRel ? resolve(root, helpersRel) : null;
26
- if (helpersRel && helpersFile) {
27
- helpers = readHelperManifest(readFileSync(helpersFile, 'utf8'), {
28
- module: url(helpersRel),
29
- file: helpersFile,
30
- readFile,
31
- resolve: (spec, from) => {
32
- if (!spec.startsWith('.'))
23
+ const manifest = (given, base) => {
24
+ const rel = find(given, base);
25
+ const file = rel ? resolve(root, rel) : null;
26
+ if (!rel || !file)
27
+ return [{ module: null, names: [], namespaces: {} }, null];
28
+ return [readHelperManifest(readFileSync(file, 'utf8'), {
29
+ module: url(rel),
30
+ file,
31
+ readFile,
32
+ resolve: (spec, from) => {
33
+ if (!spec.startsWith('.'))
34
+ return null;
35
+ const b = resolve(dirname(from), spec);
36
+ for (const q of [b, b.replace(/\.js$/, '.ts'), `${b}.js`, `${b}.ts`])
37
+ if (existsSync(q))
38
+ return q;
33
39
  return null;
34
- const base = resolve(dirname(from), spec);
35
- for (const p of [base, base.replace(/\.js$/, '.ts'), `${base}.js`, `${base}.ts`])
36
- if (existsSync(p))
37
- return p;
38
- return null;
39
- },
40
- });
41
- }
40
+ },
41
+ }), file];
42
+ };
43
+ const [helpers, helpersFile] = manifest(options.helpers, 'src/sinter.helpers');
44
+ const [actions, actionsFile] = manifest(options.actions, 'src/sinter.actions');
42
45
  const configRel = find(options.config, 'src/sinter.config');
43
46
  const typesRel = find(options.types, 'src/sinter.types', ['.ts', '.d.ts', '.js']);
44
47
  const openapi = [];
@@ -70,7 +73,7 @@ export function project(root, options = {}) {
70
73
  openapi.push({ file, doc });
71
74
  }
72
75
  return {
73
- root, helpers, helpersFile, readFile, openapi, diagnostics,
76
+ root, helpers, helpersFile, actions, actionsFile, readFile, openapi, diagnostics,
74
77
  config: configRel ? url(configRel) : null,
75
78
  types: typesRel ? resolve(root, typesRel) : null,
76
79
  };
@@ -47,13 +47,35 @@ export interface Pager {
47
47
  appends(move: unknown): boolean;
48
48
  received(items: unknown[], total: number | null, move: unknown): void;
49
49
  }
50
+ /** The last argument of an action function (`data-sn-call`, `<sn-resource call>`). */
51
+ export interface CallContext {
52
+ /** aborts when the page is hidden (pagehide) or, for a resource, when its inputs change */
53
+ signal: AbortSignal;
54
+ /** a request through the app's middleware chain (401 handling, origins, timeout) */
55
+ request(url: string, init?: SendInit): Promise<unknown>;
56
+ /** the element whose action this is; null for a resource */
57
+ el: Element | null;
58
+ }
50
59
  export interface ResourceLoader {
51
60
  def: ResourceDef;
52
61
  res: Resource;
53
62
  app: App;
54
63
  pager: Pager | null;
64
+ /** first load (or embedded/cached data) */
65
+ start(): void;
66
+ /** `src`, `when` (or an `each` list) may have changed */
67
+ changed(): void;
55
68
  load(move?: unknown): Promise<void>;
56
- refetch(): Promise<void>;
69
+ refetch(hint?: object): Promise<void>;
70
+ /** for feature modules: the scope `src` and `when` are evaluated in */
71
+ scope(): any;
72
+ /** for feature modules: a shared GET (concurrent loads of one URL share one request) */
73
+ get(url: string, signal: AbortSignal): Promise<unknown>;
74
+ /** for feature modules: the collection items of a response body (`items` path, checked, cleaned) */
75
+ items(body: unknown): any[];
76
+ /** for feature modules: a failed load: `$meta.error`, then the unhandled-error path */
77
+ failed(e: unknown): void;
78
+ loading(on: boolean): void;
57
79
  }
58
80
  export interface App {
59
81
  readonly ir: IR;
@@ -67,6 +89,9 @@ export interface App {
67
89
  optimistic?: {
68
90
  begin(fn: number, scope: any): () => void;
69
91
  };
92
+ each?: {
93
+ fanOut(loader: ResourceLoader): void;
94
+ };
70
95
  paginate?: {
71
96
  pager(loader: ResourceLoader): Pager;
72
97
  page(name: string, target: string | number): void;
@@ -108,6 +133,8 @@ export interface App {
108
133
  state<T = any>(el: Element): StateHandle<T>;
109
134
  destroy(): void;
110
135
  url(tpl: UrlTemplate, scope: any): string;
136
+ /** the scope of root node `i` (its state), or the page scope for -1 */
137
+ scopeOf(i: number): any;
111
138
  mark(path: string, scope?: any): void;
112
139
  flush(): void;
113
140
  record(change: () => void): () => void;