quario 0.0.1 → 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/lib/plan.js ADDED
@@ -0,0 +1,1388 @@
1
+ /**
2
+ * The schema traversal: one descent through the report document that checks
3
+ * each node against its closed key set — in the documented key order, which is
4
+ * the order the allowed-key list spells out — and compiles the closures that
5
+ * render it, collecting problems instead of throwing. `validate()` reads the
6
+ * problems, `events()` the closures. One traversal, read two ways: a schema key
7
+ * is checked once, beside the code that compiles it, and never in two walkers.
8
+ * (The safety suite greps every source file for the forbidden string-to-code
9
+ * constructs, so they must not appear even in a comment.)
10
+ *
11
+ * It is a traversal, never a walk: that word belongs to the event stream and
12
+ * its driver (CONTEXT.md). Six layers hold it, each a factory named for what it
13
+ * returns and taking the layers it builds on, composed by `plan()` at the foot
14
+ * of the file:
15
+ *
16
+ * 1. `planState` — what the whole descent accumulates: problems, names,
17
+ * functions, and the stubs a failed compile stands in
18
+ * 2. `primitives` — authored source to located evaluator; the only layer that
19
+ * throws, which is why every reader wraps it in `attempt`
20
+ * 3. `readers` — one per kind of declared value: each checks, compiles and
21
+ * reports, and the nodes call them in documented key order
22
+ * 4. `nodes` — the document's addressable pieces: cells, headers,
23
+ * columns, totals, items
24
+ * 5. `bands` — the generators those pieces render through: the detail
25
+ * band and the group chain around it
26
+ * 6. `traverse` — the root descent, in the schema's documented key order
27
+ *
28
+ * Only `plan` is exported. A layer reachable from outside this file is the
29
+ * first step toward the validate/compile split the single traversal rules out.
30
+ */
31
+ // Each engine's own predicate, not the union `locate.js` exports: every call
32
+ // site knows which engine it just called, and asking that one is what stops a
33
+ // sjabloon-shaped error being trusted where an xprsn error was expected.
34
+ import { isDiagnostic as isQueryDiagnostic, query, relocate as relocateQuery } from "padvinder";
35
+ import {
36
+ compile,
37
+ isDiagnostic as isXprsnDiagnostic,
38
+ relocate as relocateXprsn,
39
+ signatures,
40
+ } from "xprsn";
41
+ import {
42
+ isDiagnostic as isTemplateDiagnostic,
43
+ relocate as relocateTemplate,
44
+ template,
45
+ } from "sjabloon";
46
+ import { LOCATION, fault, isDiagnostic, locate } from "./locate.js";
47
+ import { ANCHORS, BLOCKED, NAME, RESERVED, record } from "./names.js";
48
+ import { AGG, REDUCERS, RUN, aggregateValue, withRow } from "./scope.js";
49
+ import { opt, sniff } from "./stream.js";
50
+ import { checkImageStyle, checkReportStyle, checkStyle } from "./style.js";
51
+
52
+ // Tolerance on the column-width sum. Widths are literal numbers an author may
53
+ // write as thirds, so an exact comparison rejects 33.3 x 3 for a rounding error
54
+ // nobody wrote. Small enough that it can never excuse a real over-commitment.
55
+ let WIDTH_EPS = 1e-9;
56
+
57
+ // What a failed compile stands in. Stateless, so one of each serves every
58
+ // plan rather than riding the state a plan accumulates.
59
+ /** @type {any} */
60
+ let NIL = () => null;
61
+ /** @type {any} */
62
+ let EMPTY = () => [];
63
+ // What `$.params` reads when a report declares none.
64
+ let NONE = Object.freeze({});
65
+
66
+ /** @type {(value: any) => boolean} */
67
+ let isExpr = (value) => typeof value === "string" && value[0] === "=";
68
+ /** @type {(from: Iterable<string>, into: Set<string>) => void} */
69
+ let collect = (from, into) => {
70
+ for (let name of from) into.add(name);
71
+ };
72
+ /** @type {(visible: any, scope: any) => any} */
73
+ let hidden = (visible, scope) => visible && visible(scope) === false;
74
+
75
+ // A text item's closed key set, and the same set inside a split, where the
76
+ // slot's width share joins it.
77
+ let TEXT_KEYS = ["type", "value", "visible", "style"];
78
+ let TEXT_SLOT_KEYS = [...TEXT_KEYS, "width"];
79
+ let IMAGE_KEYS = ["type", "source", "fit", "alt", "visible", "style"];
80
+ let IMAGE_SLOT_KEYS = [...IMAGE_KEYS, "width"];
81
+
82
+ // Which detail events carry the row's running values: the ones holding a cell.
83
+ // A split's bracket holds none, so run rides the item and image events inside
84
+ // it rather than the bracket around them.
85
+ /** @type {(event: any) => boolean} */
86
+ let carriesRun = (event) => event.type === "item" || event.type === "image";
87
+ /** @type {(value: any, path: string, role: string, itemsOf: any) => any} */
88
+ let maybeItems = (value, path, role, itemsOf) =>
89
+ value != null ? itemsOf(value, path, role) : null;
90
+
91
+ // The width-share rule, shared by a table's columns and a split's slots: the
92
+ // two carry the same percentages under the same arithmetic, and one reader for
93
+ // both is what keeps them from drifting into two rules wearing one name.
94
+ /** @type {(sized: boolean, noun: string) => string} */
95
+ let shareMsg = (sized, noun) =>
96
+ sized
97
+ ? "expected " + noun + " widths to total at most 100"
98
+ : "expected " +
99
+ noun +
100
+ " widths to total under 100, leaving room for the " +
101
+ noun +
102
+ "s without one";
103
+ // The authored shares worth summing, or null when there is nothing to decide.
104
+ // Nothing authored is nothing to check; a width out of range (a NaN, per the
105
+ // width verdict) already carries its own problem, and one nobody can read
106
+ // leaves it undecidable whether its part is one of those needing room.
107
+ /** @type {(parts: { width?: any }[]) => number[] | null} */
108
+ let sharesOf = (parts) => {
109
+ let shares = parts.map((part) => part.width).filter((width) => width != null);
110
+ return !shares.length || shares.some(Number.isNaN) ? null : shares;
111
+ };
112
+ // Authored shares must leave room for the parts without one. Widths are
113
+ // literal, so the sum is known here; a tolerance keeps three at 33.3 from
114
+ // summing to 100.00000000000001 and failing on IEEE 754 alone.
115
+ /** @type {(bad: any, parts: { width?: any }[], path: string, noun: string) => void} */
116
+ let checkShares = (bad, parts, path, noun) => {
117
+ let shares = sharesOf(parts);
118
+ if (!shares) return;
119
+ let total = shares.reduce((all, width) => all + width, 0);
120
+ let sized = shares.length === parts.length;
121
+ if (total > (sized ? 100 + WIDTH_EPS : 100 - WIDTH_EPS)) bad(path, shareMsg(sized, noun));
122
+ };
123
+
124
+ // One pass over a report's constants: it checks each value, copies it, and
125
+ // freezes the copy. Copying is what keeps a consumer from reaching back into
126
+ // the compiled schema through a nested object; freezing is what stops them
127
+ // mutating what they were handed.
128
+ //
129
+ // The vocabulary is JSON's, not structured clone's, because the document a
130
+ // report is written in is JSON (SCHEMA.md, "Deliberate asymmetries"): a Date,
131
+ // a Map or a typed array cannot appear in one, so accepting them here would
132
+ // only ever serve a schema built in JS -- and it is what forced the caveat
133
+ // about the values freezing cannot lock. Rejecting them makes the freeze
134
+ // total. NaN and Infinity go with them: JSON cannot hold either, and
135
+ // coercing them to null would turn a definition error into a wrong value
136
+ // three bands away.
137
+ //
138
+ // `seen` rejects cycles rather than surviving them, for the same reason: a
139
+ // JSON document has no way to write one.
140
+ /** @type {(value: any) => boolean} */
141
+ let isJsonAtom = (value) =>
142
+ value === null || typeof value === "boolean" || typeof value === "string";
143
+ /** @type {(value: any, reject: () => any) => any} */
144
+ let jsonNumber = (value, reject) => {
145
+ // NaN and Infinity are numbers no JSON document can hold.
146
+ if (typeof value !== "number") return undefined;
147
+ if (Number.isFinite(value)) return value;
148
+ return reject();
149
+ };
150
+ /** @type {(value: any) => boolean} */
151
+ let isPlain = (value) => {
152
+ // Plain objects and arrays only. A Date, Map, Set, typed array or class
153
+ // instance all pass a bare `typeof value === "object"`; the prototype is
154
+ // what separates them from something `JSON.parse` could have produced.
155
+ let proto = Object.getPrototypeOf(value);
156
+ return Array.isArray(value) || proto === Object.prototype || proto === null;
157
+ };
158
+ /** @type {(value: any, path: string, bad: (path: string, msg: string) => void, seen: Set<object>) => any} */
159
+ let jsonChildren = (value, path, bad, seen) => {
160
+ if (Array.isArray(value))
161
+ return value.map((nested, i) => jsonCopy(nested, path + "[" + i + "]", bad, seen));
162
+ return Object.fromEntries(
163
+ Object.entries(value).map(([key, nested]) => [
164
+ key,
165
+ jsonCopy(nested, path + "." + key, bad, seen),
166
+ ]),
167
+ );
168
+ };
169
+ /** @type {(value: any, path: string, bad: (path: string, msg: string) => void, seen: Set<object>, reject: () => any) => any} */
170
+ let jsonObject = (value, path, bad, seen, reject) => {
171
+ if (typeof value !== "object") return reject();
172
+ // `seen` holds the ancestors of the value being copied, not everything
173
+ // visited, so a value referenced twice as a sibling -- legal JSON, and what
174
+ // a DAG looks like -- is copied twice rather than rejected. Only a value
175
+ // that contains itself is refused, and the path names the key that closes
176
+ // the loop.
177
+ if (!isPlain(value) || seen.has(value)) return reject();
178
+ seen.add(value);
179
+ let copy = jsonChildren(value, path, bad, seen);
180
+ seen.delete(value);
181
+ return Object.freeze(copy);
182
+ };
183
+ /** @type {(value: any, path: string, bad: (path: string, msg: string) => void, seen: Set<object>) => any} */
184
+ let jsonCopy = (value, path, bad, seen) => {
185
+ let reject = () => {
186
+ bad(path, "expected a JSON value");
187
+ return null;
188
+ };
189
+ if (isJsonAtom(value)) return value;
190
+ let number = jsonNumber(value, reject);
191
+ if (number !== undefined) return number;
192
+ return jsonObject(value, path, bad, seen, reject);
193
+ };
194
+
195
+ /** @typedef {import("./scope.js").Scope} Scope */
196
+ /** @typedef {import("./scope.js").Eval} Eval */
197
+ /** @typedef {import("./scope.js").Fold} Fold */
198
+ /** @typedef {import("./scope.js").RunnerSet} RunnerSet */
199
+ /** @typedef {(rows: any[], scope: Scope, runners: RunnerSet) => Generator<any>} Band */
200
+ /** @typedef {ReturnType<typeof planState>} State */
201
+ /** @typedef {ReturnType<typeof primitives>} Primitives */
202
+ /** @typedef {ReturnType<typeof readers>} Readers */
203
+ /** @typedef {ReturnType<typeof nodes>} Nodes */
204
+ /** @typedef {ReturnType<typeof bands>} Bands */
205
+
206
+ // --- 1. plan state --------------------------------------------------------
207
+ //
208
+ // Everything the descent accumulates, and the two ways it records a fault:
209
+ // `bad`/`add` for a problem it can describe itself, `attempt` for a compile
210
+ // step that threw.
211
+
212
+ /**
213
+ * @param {any} group
214
+ * @param {Set<string>} bound
215
+ */
216
+ let bindHandle = (group, bound) => {
217
+ if (record(group) && typeof group.name === "string") bound.add(group.name);
218
+ };
219
+ /**
220
+ * @param {any} schema
221
+ * @param {Set<string>} bound
222
+ */
223
+ let bindHandles = (schema, bound) => {
224
+ if (!record(schema) || !Array.isArray(schema.groups)) return;
225
+ for (let group of schema.groups) bindHandle(group, bound);
226
+ };
227
+
228
+ /**
229
+ * @param {any} schema The report document.
230
+ * @param {Record<string, Function>} [funcs] Functions callable from expressions.
231
+ */
232
+ let planState = (schema, funcs) => {
233
+ /** @type {Record<string, (...args: any[]) => any>} */
234
+ let FNS = /** @type {any} */ ({ ...REDUCERS, ...funcs });
235
+ /** @type {Set<string>} */
236
+ let names = new Set();
237
+ /** @type {Set<string>} */
238
+ let functions = new Set();
239
+ // Engine-provided anchors plus this report's group handles: bound at
240
+ // evaluation time, so they are not free variables and stay out of `names`.
241
+ // Seeded from ANCHORS, not RESERVED -- a name merely held for a future anchor
242
+ // is still a free variable today and must keep showing up in `names`.
243
+ let BND = new Set(ANCHORS);
244
+ bindHandles(schema, BND);
245
+
246
+ // Structured problems: the path was a live value at every site that reports
247
+ // one, so it is kept as a field rather than recovered from the message. The
248
+ // message stays the whole located string — `validate()`'s view is
249
+ // `problems.map((p) => p.message)`, unchanged.
250
+ /** @type {{ path: string, source?: string, message: string, diagnostic?: any }[]} */
251
+ let problems = [];
252
+ // Kept so `events()` rethrows the engine's located error, not a bare one.
253
+ /** @type {any} */
254
+ let firstDiag = null;
255
+ // Where a caught error says it happened: `locate()` stamps that on the copy
256
+ // it throws, so nothing here parses a message back apart.
257
+ /** @type {(error: any) => { path: string, source?: any }} */
258
+ let where = (error) => (error && error[LOCATION]) || { path: "" };
259
+ // A caught error carries its location as data (locate.js), so the problem
260
+ // keeps the path and source structurally; the diagnostic rides along per
261
+ // problem — every problem keeps its offsets, not only the first.
262
+ /** @type {(msg: string, error?: any) => any} */
263
+ let located = (msg, error) => {
264
+ let at = where(error);
265
+ /** @type {any} */
266
+ let problem = { path: at.path, message: msg };
267
+ if (at.source !== undefined) problem.source = String(at.source);
268
+ if (isDiagnostic(error)) problem.diagnostic = error;
269
+ return problem;
270
+ };
271
+ /** @type {(msg: string, error?: any) => void} */
272
+ let add = (msg, error) => {
273
+ // Identity, not shape: locate() copies an untrusted host throw with
274
+ // `new error.constructor(...)`, so a host class that stamps `code` on
275
+ // itself produces a copy shaped like a diagnostic. Only an error the
276
+ // engines authenticated is worth rethrowing as one (quario-lkz).
277
+ if (!problems.length && isDiagnostic(error)) firstDiag = error;
278
+ problems.push(located(msg, error));
279
+ };
280
+ /** @type {(path: string, msg: string) => void} */
281
+ let bad = (path, msg) => problems.push({ path, message: path + ": " + msg });
282
+ // Record a throwing compile step and stand in a stub, so the traversal reports
283
+ // the whole definition rather than only its first fault.
284
+ /** @type {<T>(fn: () => T, fallback: T) => T} */
285
+ let attempt = (compileStep, fallback) => {
286
+ try {
287
+ return compileStep();
288
+ } catch (error) {
289
+ add(/** @type {any} */ (error).message, error);
290
+ return fallback;
291
+ }
292
+ };
293
+ // The one register two layers share: a report-level `run` block and every
294
+ // group's are one namespace, so a name declared twice across them is a
295
+ // definition error. `groupNames` needs no such sharing and stays in `bands`.
296
+ /** @type {Set<string>} */
297
+ let runNames = new Set();
298
+
299
+ // Which anchors each compiled source actually reads, keyed by its schema
300
+ // path. Retention, not computation: the engines report every root-name read
301
+ // in `.reads` — the unfiltered view `bound` keeps out of `.names` — and this
302
+ // keeps the anchor-and-handle subset a consumer placing a node needs.
303
+ /** @type {Map<string, Set<string>>} */
304
+ let anchors = new Map();
305
+ /** @type {(path: string, reads: { name: string }[]) => void} */
306
+ let anchorsOf = (path, reads) => {
307
+ for (let { name } of reads) {
308
+ if (!BND.has(name)) continue;
309
+ let set = anchors.get(path);
310
+ if (!set) anchors.set(path, (set = new Set()));
311
+ set.add(name);
312
+ }
313
+ };
314
+
315
+ return {
316
+ FNS,
317
+ names,
318
+ functions,
319
+ BND,
320
+ problems,
321
+ runNames,
322
+ anchors,
323
+ anchorsOf,
324
+ add,
325
+ bad,
326
+ attempt,
327
+ diagnostic: () => firstDiag,
328
+ };
329
+ };
330
+
331
+ // --- 2. compile primitives ------------------------------------------------
332
+ //
333
+ // Authored source becomes a located evaluator. These throw; every caller wraps
334
+ // them in `attempt`, which is what keeps one bad cell from ending the descent.
335
+
336
+ /** @type {(source: any, path: string, FNS: any, BND: Set<string>) => any} */
337
+ let compileProp = (source, path, FNS, BND) => {
338
+ try {
339
+ return compile(source.slice(1), FNS, { bound: BND });
340
+ } catch (error) {
341
+ locate(path, source, error, isXprsnDiagnostic(error) && relocateXprsn, 1);
342
+ }
343
+ };
344
+ /** @type {(expression: any, path: string, source: any) => Eval} */
345
+ let runProp = (expression, path, source) => (scope) => {
346
+ try {
347
+ return expression(scope);
348
+ } catch (error) {
349
+ locate(path, source, error, expression.isDiagnostic(error) && relocateXprsn, 1);
350
+ }
351
+ };
352
+ /** @type {(src: string, path: string, source: any, FNS: any, BND: Set<string>) => any} */
353
+ let compileCell = (src, path, source, FNS, BND) => {
354
+ try {
355
+ return template(src, FNS, { bound: BND });
356
+ } catch (error) {
357
+ locate(path, source, error, isTemplateDiagnostic(error) && relocateTemplate);
358
+ }
359
+ };
360
+ /** @type {(tokens: any, path: string, source: any) => (scope: Scope) => any} */
361
+ let runCell = (tokens, path, source) => (scope) => {
362
+ try {
363
+ return tokens.scoped(scope);
364
+ } catch (error) {
365
+ locate(path, source, error, tokens.isDiagnostic(error) && relocateTemplate);
366
+ }
367
+ };
368
+ /** @type {(source: any) => string} */
369
+ let asText = (source) => (typeof source === "string" ? source : "");
370
+ /** @type {(spec: string) => { fn: string, src: string, colon: number }} */
371
+ let splitFold = (spec) => {
372
+ let colon = spec.indexOf(":");
373
+ if (colon < 0) return { fn: spec, src: "", colon };
374
+ return { fn: spec.slice(0, colon), src: spec.slice(colon + 1), colon };
375
+ };
376
+ /** @type {(colon: number, path: string) => void} */
377
+ let checkCount = (colon, path) => {
378
+ // oxlint-disable-next-line no-unused-expressions
379
+ colon < 0 || fault(path, "count does not take an expression");
380
+ };
381
+ /** @type {(src: string, fn: string, path: string) => void} */
382
+ let checkExprFold = (src, fn, path) => {
383
+ // oxlint-disable-next-line no-unused-expressions
384
+ (src.startsWith("=") && src.length > 1) || fault(path, 'expected "' + fn + ':=<expression>"');
385
+ };
386
+
387
+ /** @param {State} state */
388
+ let primitives = ({ FNS, BND, names, functions, anchorsOf }) => {
389
+ // A computed property: a literal, or an `=` xprsn expression. xprsn's
390
+ // `bound` keeps engine-provided anchors/handles out of the reported `names`.
391
+ /** @type {(spec: any, path: string) => Eval} */
392
+ let prop = (source, path) => {
393
+ if (!isExpr(source)) return () => source;
394
+ let expression = compileProp(source, path, FNS, BND);
395
+ collect(expression.names, names);
396
+ collect(expression.functions, functions);
397
+ anchorsOf(path, expression.reads);
398
+ return runProp(expression, path, source);
399
+ };
400
+
401
+ // A text cell: a sjabloon template, compiled with the token edition and
402
+ // rendered over quario's own scope chain, which already binds the anchors:
403
+ // `$` at the render base, `@` on the detail row, neither anywhere else — so
404
+ // a group or report band leaves `@` unbound and a stray `@.field` throws.
405
+ // sjabloon's `bound` excludes its loop vars / `$` / `@` and quario's engine
406
+ // names alike from the reported `names`. A render yields the cell's public
407
+ // token stream: literal runs verbatim plus each interpolation's
408
+ // pre-stringify value. Templates have no raw form — a `{{{ }}}` tag is a
409
+ // located SJABLOON_RAW_TAG definition error — so escaping is entirely a
410
+ // render-target concern at the markup edge, with no exceptions to carry
411
+ // through the stream.
412
+ /** @type {(str: any, path: string) => (scope: Scope) => any} */
413
+ let cell = (source, path) => {
414
+ let tokens = compileCell(asText(source), path, source, FNS, BND);
415
+ collect(tokens.names, names);
416
+ collect(tokens.functions, functions);
417
+ anchorsOf(path, tokens.reads);
418
+ return runCell(tokens, path, source);
419
+ };
420
+
421
+ // Parse "reducer:=expr" into a reducer name and a compiled per-row expression
422
+ // (null for `count`, which takes no expression).
423
+ /** @type {(spec: string, path: string, allowed: Set<string>) => Fold} */
424
+ let parseFold = (spec, path, allowed) => {
425
+ let { fn, src, colon } = splitFold(spec);
426
+ // oxlint-disable-next-line no-unused-expressions
427
+ allowed.has(fn) || fault(path, 'unknown reducer "' + fn + '"');
428
+ if (fn === "count") checkCount(colon, path);
429
+ else checkExprFold(src, fn, path);
430
+ return { fn, per: src ? prop(src, path) : null };
431
+ };
432
+
433
+ return { prop, cell, parseFold };
434
+ };
435
+
436
+ // --- 3. readers -----------------------------------------------------------
437
+ //
438
+ // One reader per kind of declared value. Each checks, compiles and reports; the
439
+ // nodes below call them in the documented key order.
440
+
441
+ /**
442
+ * @param {State} state
443
+ * @param {Primitives} primitives
444
+ */
445
+ let readers = ({ bad, attempt }, { prop, cell, parseFold }) => {
446
+ /** @type {(value: any, path: string) => boolean} */
447
+ let obj = (value, path) => {
448
+ if (record(value)) return true;
449
+ bad(path, "expected an object");
450
+ return false;
451
+ };
452
+ /** @type {(value: any, path: string, required?: boolean) => any[] | null} */
453
+ let arr = (value, path, required = false) => {
454
+ if (value == null && !required) return null;
455
+ if (Array.isArray(value)) return value;
456
+ bad(path, "expected an array");
457
+ return null;
458
+ };
459
+ // Every schema object is a closed set, so typos and misplaced fields
460
+ // (page.size, sort on a table detail) fail loud instead of doing nothing.
461
+ // One schema path from a parent's and a key. The document root's path is the
462
+ // empty string, so the dot is conditional -- `style.family`, never
463
+ // `.style.family`. Every reader that can be reached with the root joins
464
+ // through here rather than restating the rule.
465
+ /** @type {(path: string, key: string) => string} */
466
+ let pathTo = (path, key) => (path ? path + "." + key : key);
467
+ /** @type {(value: any, allowed: string[], path: string) => void} */
468
+ let keys = (value, allowed, path) => {
469
+ for (let key of Object.keys(value))
470
+ if (!allowed.includes(key)) bad(pathTo(path, key), 'unknown key "' + key + '"');
471
+ };
472
+ /** @type {(name: string, path: string, reserved?: Set<string> | null) => void} */
473
+ let checkName = (name, path, reserved) => {
474
+ if (BLOCKED.has(name)) bad(path, '"' + name + '" is blocked');
475
+ if (reserved && reserved.has(name)) bad(path, '"' + name + '" is reserved');
476
+ };
477
+ /** @type {(name: any, path: string, reserved?: Set<string> | null) => void} */
478
+ let named = (name, path, reserved) => {
479
+ if (typeof name !== "string" || !NAME.test(name)) bad(path, "expected an expression name");
480
+ else checkName(name, path, reserved);
481
+ };
482
+ // A required `=expression`: a group's `by`, `where`, a sort key.
483
+ /** @type {(value: any, path: string) => Eval} */
484
+ let expression = (value, path) => {
485
+ if (!isExpr(value) || value.length === 1) {
486
+ bad(path, "expected an =expression");
487
+ return NIL;
488
+ }
489
+ return attempt(() => prop(value, path), NIL);
490
+ };
491
+ // A literal `visible` must be an actual boolean; only expressions may
492
+ // compute one (the same strictness split as style literals).
493
+ /** @type {(def: any, path: string) => Eval | null} */
494
+ let visibleOf = (def, path) => {
495
+ let value = def.visible;
496
+ if (value == null) return null;
497
+ if (typeof value === "boolean" || isExpr(value))
498
+ return attempt(() => prop(value, path + ".visible"), null);
499
+ bad(path + ".visible", "expected a boolean or an =expression");
500
+ return null;
501
+ };
502
+ // Page columns: how many vertical strips the declaring node's flow content
503
+ // is laid out in. Literal only — nothing computes a count — and at least
504
+ // two, because one column is the absence of the key and two spellings for
505
+ // one document is spec surface (ADR 0013). `enclosed` says an outer node
506
+ // already columns this content: nesting is a definition error located here,
507
+ // on the inner declaration, since the outer one is the valid one.
508
+ /** @type {(value: any, path: string, enclosed?: boolean) => string | null} */
509
+ let badColumns = (value, path, enclosed) => {
510
+ if (enclosed) return "page columns cannot nest in a columned region";
511
+ if (!Number.isInteger(value) || value < 2) return "expected an integer count (columns >= 2)";
512
+ return null;
513
+ };
514
+ /** @type {(value: any, path: string, enclosed?: boolean) => number | null} */
515
+ let columnsOf = (value, path, enclosed) => {
516
+ if (value == null) return null;
517
+ let problem = badColumns(value, path, enclosed);
518
+ if (problem) bad(path, problem);
519
+ return problem ? null : value;
520
+ };
521
+ // Literal integer ≥ 1. Omitting the key keeps every row; `take: 0` would be
522
+ // a second spelling for empty (ADR 0013).
523
+ /** @type {(value: any, path: string) => number | null} */
524
+ let takeOf = (value, path) => {
525
+ if (value == null) return null;
526
+ if (!Number.isInteger(value) || value < 1) {
527
+ bad(path, "expected an integer count (take >= 1)");
528
+ return null;
529
+ }
530
+ return value;
531
+ };
532
+ // A declared style block resolves to plain data — the closed vocabulary's
533
+ // names in declaration order, null when empty — and stays data; a render
534
+ // target maps it to its own formatting model (the HTML target to inline
535
+ // CSS). An all-literal block folds to one frozen object at compile time.
536
+ /**
537
+ * @type {(block: any, path: string, check: (name: string, value: any) => string | null)
538
+ * => [string, Eval][]}
539
+ */
540
+ let readStyles = (block, path, check) => {
541
+ /** @type {[string, Eval][]} */
542
+ let entries = [];
543
+ for (let [name, value] of Object.entries(block)) {
544
+ let stylePath = path + "." + name;
545
+ let problem = check(name, value);
546
+ if (problem) bad(stylePath, problem);
547
+ else entries.push([name, attempt(() => prop(value, stylePath), NIL)]);
548
+ }
549
+ return entries;
550
+ };
551
+ /** @type {(entries: [string, Eval][]) => (scope: Scope) => Scope} */
552
+ let styleFn = (entries) => (scope) => {
553
+ /** @type {Scope} */
554
+ let style = {};
555
+ for (let [name, value] of entries) style[name] = value(scope);
556
+ return style;
557
+ };
558
+ /** @type {(block: any) => boolean} */
559
+ let isComputed = (block) => Object.values(block).some(isExpr);
560
+ /**
561
+ * @type {(block: any, path: string, check: (name: string, value: any) => string | null)
562
+ * => (scope: Scope) => any}
563
+ */
564
+ let compileStyles = (block, path, check) => {
565
+ let entries = readStyles(block, path, check);
566
+ if (!entries.length) return NIL;
567
+ let resolve = styleFn(entries);
568
+ if (isComputed(block)) return resolve;
569
+ let constant = Object.freeze(resolve({}));
570
+ return () => constant;
571
+ };
572
+ /**
573
+ * @type {(def: any, path: string, check?: (name: string, value: any) => string | null)
574
+ * => (scope: Scope) => any}
575
+ */
576
+ let stylesOf = (def, path, check = checkStyle) => {
577
+ let block = def.style;
578
+ let stylePath = pathTo(path, "style");
579
+ if (block == null || !obj(block, stylePath)) return NIL;
580
+ return compileStyles(block, stylePath, check);
581
+ };
582
+ // A cell value: one sjabloon template. Emphasis is the cell's own `style`;
583
+ // markup in literal text is meaningful only to the HTML target.
584
+ /** @type {(value: any, path: string) => (scope: Scope, opts?: any) => any} */
585
+ let cellValue = (value, path) => {
586
+ if (typeof value === "string") return attempt(() => cell(value, path), EMPTY);
587
+ bad(path, "expected a template string");
588
+ return EMPTY;
589
+ };
590
+ /** @type {(seen: Set<string> | undefined, name: string, foldPath: string) => void} */
591
+ let noteRun = (seen, name, foldPath) => {
592
+ if (!seen) return;
593
+ if (seen.has(name)) bad(foldPath, 'duplicate run name "' + name + '"');
594
+ else seen.add(name);
595
+ };
596
+ /** @type {(spec: any, foldPath: string, allowed: Set<string>) => Fold | null} */
597
+ let readFold = (spec, foldPath, allowed) => {
598
+ if (typeof spec !== "string") {
599
+ bad(foldPath, "expected a reducer specification");
600
+ return null;
601
+ }
602
+ return attempt(() => parseFold(spec, foldPath, allowed), null);
603
+ };
604
+ /** @type {(name: string, spec: any, path: string, allowed: Set<string>, reserved: Set<string> | null, seen?: Set<string>) => [string, Fold] | null} */
605
+ let oneFold = (name, spec, path, allowed, reserved, seen) => {
606
+ let foldPath = path + "." + name;
607
+ named(name, foldPath, reserved);
608
+ noteRun(seen, name, foldPath);
609
+ let fold = readFold(spec, foldPath, allowed);
610
+ return fold && [name, fold];
611
+ };
612
+ /**
613
+ * @type {(value: any, path: string, allowed: Set<string>,
614
+ * reserved: Set<string> | null, seen?: Set<string>) => [string, Fold][]}
615
+ */
616
+ let readFolds = (value, path, allowed, reserved, seen) => {
617
+ /** @type {[string, Fold][]} */
618
+ let out = [];
619
+ for (let [name, spec] of Object.entries(value)) {
620
+ let fold = oneFold(name, spec, path, allowed, reserved, seen);
621
+ if (fold) out.push(fold);
622
+ }
623
+ return out;
624
+ };
625
+ // Declared aggregates and running values, both spelled "reducer:=expr".
626
+ /**
627
+ * @type {(value: any, path: string, allowed: Set<string>,
628
+ * reserved: Set<string> | null, seen?: Set<string>) => [string, Fold][]}
629
+ */
630
+ let foldsOf = (value, path, allowed, reserved, seen) => {
631
+ if (value == null || !obj(value, path)) return [];
632
+ return readFolds(value, path, allowed, reserved, seen);
633
+ };
634
+ /** @type {(dir: any, path: string) => void} */
635
+ let checkDir = (dir, path) => {
636
+ if (dir == null || dir === "asc" || dir === "desc") return;
637
+ bad(path, 'unknown sort direction "' + dir + '"');
638
+ };
639
+ /** @type {(spec: any, i: number, path: string) => { by: Eval, sign: number }[]} */
640
+ let sortKey = (spec, i, path) => {
641
+ let keyPath = path + "[" + i + "]";
642
+ if (!obj(spec, keyPath)) return [];
643
+ keys(spec, ["by", "dir"], keyPath);
644
+ let by = expression(spec.by, keyPath + ".by");
645
+ checkDir(spec.dir, keyPath + ".dir");
646
+ return [{ by, sign: spec.dir === "desc" ? -1 : 1 }];
647
+ };
648
+ // A sort spec (array of { by, dir }) becomes a comparator over rows.
649
+ /** @type {(value: any, path: string) => ((rows: any[], scope: Scope) => any[]) | null} */
650
+ let sortOf = (value, path) => {
651
+ let specs = arr(value, path);
652
+ if (!specs || !specs.length) return null;
653
+ let sortKeys = specs.flatMap((spec, i) => sortKey(spec, i, path));
654
+ if (!sortKeys.length) return null;
655
+ let width = sortKeys.length;
656
+ // Resolve every row's keys once, sort an index array, then materialise.
657
+ // Still one evaluation per row per key rather than one per comparison,
658
+ // but the keys live in a single flat row-major array and the rows are
659
+ // reordered through their indices. Both shapes build a few arrays across
660
+ // the pass; what this one drops is the per-row wrapper object and key
661
+ // array, and the scope child it used to build once *per key* per row.
662
+ return (rows, scope) => {
663
+ let resolved = [];
664
+ for (let row of rows) {
665
+ // One scope child per row: every key of that row reads the same one.
666
+ let bound = withRow(scope, row);
667
+ for (let key of sortKeys) resolved.push(key.by(bound));
668
+ }
669
+ // `sort` is stable, so a full tie leaves the indices — and with them
670
+ // the rows — in the order they arrived.
671
+ let order = Array.from(rows, (_, i) => i);
672
+ order.sort((a, b) => {
673
+ let aAt = a * width;
674
+ let bAt = b * width;
675
+ for (let i = 0; i < width; i++) {
676
+ let x = resolved[aAt + i];
677
+ let y = resolved[bAt + i];
678
+ if (x < y) return -sortKeys[i].sign;
679
+ if (x > y) return sortKeys[i].sign;
680
+ }
681
+ return 0;
682
+ });
683
+ return order.map((i) => rows[i]);
684
+ };
685
+ };
686
+ return {
687
+ obj,
688
+ arr,
689
+ keys,
690
+ named,
691
+ expression,
692
+ visibleOf,
693
+ columnsOf,
694
+ takeOf,
695
+ stylesOf,
696
+ cellValue,
697
+ foldsOf,
698
+ sortOf,
699
+ };
700
+ };
701
+
702
+ // --- 4. node compilers ----------------------------------------------------
703
+ //
704
+ // The document's addressable pieces. Each returns a closure that renders one
705
+ // piece of the stream from a scope.
706
+
707
+ /**
708
+ * @param {State} state
709
+ * @param {Readers} readers
710
+ */
711
+ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, stylesOf }) => {
712
+ // `tokens` plus its truthy `style`. A hidden cell keeps its slot with no
713
+ // tokens so a column keeps its alignment; items pass `visible` null and drop
714
+ // themselves instead, having no slot to keep. The anchor story lives on
715
+ // `cell` in primitives: the scope chain itself carries `$` and `@`.
716
+ /**
717
+ * @type {(tpl: any, visible: Eval | null, styles: (scope: Scope) => any)
718
+ * => (scope: Scope) => any}
719
+ */
720
+ let cellShape = (tpl, visible, styles) => (scope) =>
721
+ opt({ tokens: hidden(visible, scope) ? [] : tpl(scope) }, { style: styles(scope) });
722
+
723
+ // A table cell: the shape of a total cell and of an object column header.
724
+ /** @type {(def: any, path: string, valuePath?: string) => (scope: Scope) => any} */
725
+ let cellOf = (def, path, valuePath = path + ".value") => {
726
+ let tpl = cellValue(def.value, valuePath);
727
+ let visible = visibleOf(def, path);
728
+ return cellShape(tpl, visible, stylesOf(def, path));
729
+ };
730
+
731
+ // A cell without a row, so `@` stays unbound. A bare string is shorthand for
732
+ // `{ value }` and keeps its authored path, so problems locate where written.
733
+ /** @type {(def: any, path: string) => (scope: Scope) => any} */
734
+ let headerOf = (def, path) => {
735
+ if (typeof def === "string") return cellShape(cellValue(def, path), null, NIL);
736
+ if (!record(def)) {
737
+ bad(path, "expected a template string or object");
738
+ return cellShape(EMPTY, null, NIL);
739
+ }
740
+ keys(def, ["value", "style"], path);
741
+ return cellOf(def, path);
742
+ };
743
+
744
+ /** @type {(width: any) => boolean} */
745
+ let validWidth = (width) => typeof width === "number" && width > 0 && width <= 100;
746
+ /** @type {(width: any, path: string) => any} */
747
+ let widthOf = (width, path) => {
748
+ if (width == null || validWidth(width)) return width;
749
+ bad(path + ".width", "expected a percentage number (0 < width <= 100)");
750
+ return NaN;
751
+ };
752
+ /** @type {(def: any, path: string) => any} */
753
+ let columnOf = (def, path) => {
754
+ if (!obj(def, path)) return null;
755
+ keys(def, ["header", "value", "visible", "style", "width"], path);
756
+ let header = headerOf(def.header, path + ".header");
757
+ let tpl = cellValue(def.value, path + ".value");
758
+ let visible = visibleOf(def, path);
759
+ let styles = stylesOf(def, path);
760
+ // A width is a percentage of the table width, resolved by every target.
761
+ // The range verdict is owned here: an unreadable width comes back as NaN,
762
+ // so the total check below reads the verdict instead of re-deciding it.
763
+ return { header, cell: cellShape(tpl, visible, styles), width: widthOf(def.width, path), path };
764
+ };
765
+
766
+ /** @type {(def: any, path: string) => ((scope: Scope) => any) | null} */
767
+ let totalOf = (def, path) => {
768
+ if (!obj(def, path)) return null;
769
+ keys(def, ["value", "visible", "style"], path);
770
+ return cellOf(def, path);
771
+ };
772
+
773
+ // A text item: a cell with no slot to keep, so a hidden one drops itself
774
+ // instead of keeping empty tokens the way a table cell does. Inside a split
775
+ // it is the other way round -- a slot holds its place with empty tokens, so
776
+ // the line's geometry does not move with the data (SCHEMA.md, "Split item").
777
+ /** @type {(def: any, path: string, role: string, inSlot?: boolean) => (scope: Scope) => any} */
778
+ let textOf = (def, path, role, inSlot = false) => {
779
+ keys(def, inSlot ? TEXT_SLOT_KEYS : TEXT_KEYS, path);
780
+ checkTextType(def.type, path);
781
+ let tpl = cellValue(def.value, path + ".value");
782
+ let visible = visibleOf(def, path);
783
+ let shape = cellShape(tpl, inSlot ? visible : null, stylesOf(def, path));
784
+ // The cell shape first, then the item's own fields, so a cell reads
785
+ // the same wherever it appears in the stream.
786
+ return (scope) => {
787
+ if (!inSlot && hidden(visible, scope)) return null;
788
+ let event = shape(scope);
789
+ event.type = "item";
790
+ event.role = role;
791
+ event.path = path;
792
+ return event;
793
+ };
794
+ };
795
+
796
+ // The fallback shape's verdict on a `type` it does not name, reported after
797
+ // this node's key set and in the documented key order -- and then read as
798
+ // text, so the rest of its keys are checked rather than left behind the one
799
+ // problem.
800
+ /** @type {(type: any, path: string) => void} */
801
+ let checkTextType = (type, path) => {
802
+ if (type === "text") return;
803
+ bad(path + ".type", type == null ? "required" : 'unknown item type "' + type + '"');
804
+ };
805
+
806
+ /** @type {(fit: any, path: string) => void} */
807
+ let checkFit = (fit, path) => {
808
+ if (fit == null || fit === "natural" || fit === "width") return;
809
+ bad(path, 'unknown fit "' + fit + '"');
810
+ };
811
+ /** @type {(def: any, path: string) => any} */
812
+ let altOf = (def, path) => (def.alt != null ? cellValue(def.alt, path + ".alt") : null);
813
+ /** @type {(alt: any, scope: Scope) => any} */
814
+ let imageAlt = (alt, scope) => alt && alt(scope);
815
+ /** @type {(fields: any, scope: Scope, bytes: any) => any} */
816
+ let imageEvent = (fields, scope, bytes) => {
817
+ // The bytes stay bytes. Sniffing reads their magic numbers -- it never
818
+ // decodes the image, and nothing here turns them into a string -- and
819
+ // the answer rides on the event so no target sniffs a second time.
820
+ let format = sniff(bytes);
821
+ // The one place `locate` originates an error instead of re-throwing a
822
+ // caught one: the fault is quario's own verdict on the bytes, so it
823
+ // carries the location every other render error does and, having no
824
+ // engine diagnostic behind it, stays out of `isDiagnostic`.
825
+ if (!format)
826
+ locate(
827
+ fields.path + ".source",
828
+ fields.sourceSpec,
829
+ TypeError("expected a Uint8Array of PNG or JPEG bytes"),
830
+ );
831
+ return opt(
832
+ { type: "image", role: fields.role, bytes, format, fit: fields.fit, path: fields.path },
833
+ { alt: imageAlt(fields.alt, scope), style: fields.styles(scope) },
834
+ );
835
+ };
836
+ /** @type {(fields: any) => (scope: Scope) => any} */
837
+ let renderImage = (fields) => (scope) => {
838
+ if (hidden(fields.visible, scope)) return null;
839
+ let bytes = fields.source(scope);
840
+ // Nullish is the missing-data twin of `visible: false`: no event and no
841
+ // error, so a report whose input sometimes lacks a logo needs no guard.
842
+ if (bytes == null) return null;
843
+ return imageEvent(fields, scope, bytes);
844
+ };
845
+ // An image item: bytes an `=` expression yields, sized by `fit`, with an
846
+ // optional textual stand-in. JSON has no way to write bytes, so `source` is
847
+ // expression-only; the vocabulary an image accepts is `style.js`'s to narrow.
848
+ /** @type {(def: any, path: string, role: string, inSlot?: boolean) => (scope: Scope) => any} */
849
+ let imageOf = (def, path, role, inSlot = false) => {
850
+ keys(def, inSlot ? IMAGE_SLOT_KEYS : IMAGE_KEYS, path);
851
+ let source = expression(def.source, path + ".source");
852
+ checkFit(def.fit, path + ".fit");
853
+ // Read in the documented key order, and each read once: `stylesOf` reports
854
+ // as it reads, so compiling it twice would report this node's style
855
+ // problems twice, and reading it early would report them out of order.
856
+ let alt = altOf(def, path);
857
+ let visible = visibleOf(def, path);
858
+ let styles = stylesOf(def, path, checkImageStyle);
859
+ let render = renderImage({
860
+ source,
861
+ fit: def.fit ?? "natural",
862
+ alt,
863
+ visible,
864
+ styles,
865
+ path,
866
+ sourceSpec: def.source,
867
+ role,
868
+ });
869
+ if (!inSlot) return render;
870
+ // A slot keeps its place whatever the data does: a hidden image, or a
871
+ // nullish source, leaves the same empty-token placeholder a hidden text
872
+ // slot leaves, so the widths either side of it do not move. `cellShape`
873
+ // with no template is that placeholder, so the two cannot drift.
874
+ let empty = cellShape(EMPTY, null, styles);
875
+ return (scope) => render(scope) ?? { ...empty(scope), type: "item", role };
876
+ };
877
+
878
+ // One slot of a split: an ordinary item, plus the width share that is a slot
879
+ // property rather than an item one -- which is why `width` reaches an item's
880
+ // key set only here (docs/adr/0029).
881
+ /**
882
+ * @type {(def: any, path: string, role: string)
883
+ * => { render: (scope: Scope) => any, width: any } | null}
884
+ */
885
+ let slotOf = (def, path, role) => {
886
+ if (!obj(def, path)) return null;
887
+ // Placement inside placement is the coordinate system quario does not
888
+ // have, so the nesting verdict is named rather than left to read as an
889
+ // unknown item type.
890
+ if (def.type === "split") {
891
+ bad(path + ".type", "a split cannot nest");
892
+ return null;
893
+ }
894
+ return {
895
+ render: (def.type === "image" ? imageOf : textOf)(def, path, role, true),
896
+ width: widthOf(def.width, path),
897
+ };
898
+ };
899
+
900
+ // A split: slots placed across the content width, bracketed in the stream so
901
+ // every slot crosses as the ordinary item or image event it already is.
902
+ /** @type {(def: any, path: string, role: string) => (scope: Scope) => any} */
903
+ let splitOf = (def, path, role) => {
904
+ keys(def, ["type", "slots", "visible", "style"], path);
905
+ let list = arr(def.slots, path + ".slots", true);
906
+ let defs = list || [];
907
+ // Two is the floor: a one-slot split is a text item wearing more syntax,
908
+ // and permitting it would be a second way to say one thing. Read off the
909
+ // array `arr` accepted, so a missing `slots` reports "required" once
910
+ // rather than that and this.
911
+ if (list && defs.length < 2) bad(path + ".slots", "expected at least two slots");
912
+ let slots = defs
913
+ .map((slot, i) => slotOf(slot, path + ".slots[" + i + "]", role))
914
+ .filter((slot) => slot != null);
915
+ checkShares(bad, slots, path + ".slots", "slot");
916
+ let visible = visibleOf(def, path);
917
+ let styles = stylesOf(def, path);
918
+ // Every width is a literal the traversal already resolved, so the geometry
919
+ // the bracket carries is the same array on every render -- built here
920
+ // rather than rebuilt per row in a detail band. Frozen because one array
921
+ // now reaches every render: a consumer must not be able to reach back
922
+ // through an event and change what a later render sees.
923
+ let geometry = Object.freeze(
924
+ slots.map((slot) => Object.freeze(opt({}, { width: slot.width }))),
925
+ );
926
+ return (scope) => {
927
+ if (hidden(visible, scope)) return null;
928
+ let out = [opt({ type: "split-start", role, slots: geometry }, { style: styles(scope) })];
929
+ for (let slot of slots) out.push(slot.render(scope));
930
+ out.push({ type: "split-end" });
931
+ return out;
932
+ };
933
+ };
934
+
935
+ /** @type {(def: any, itemPath: string, role: string) => ((scope: Scope) => any) | null} */
936
+ let itemOf = (def, itemPath, role) => {
937
+ if (!obj(def, itemPath)) return null;
938
+ // `type` picks the item's shape; `textOf` owns the verdict on the ones
939
+ // it does not name, having the closed key set that reports first.
940
+ if (def.type === "split") return splitOf(def, itemPath, role);
941
+ return (def.type === "image" ? imageOf : textOf)(def, itemPath, role);
942
+ };
943
+ /** @type {(list: ((scope: Scope) => any)[]) => (scope: Scope) => Generator<any>} */
944
+ let yieldItems = (list) =>
945
+ function* (scope) {
946
+ for (let render of list) {
947
+ let event = render(scope);
948
+ // A split renders to its whole bracket; every other item to one event.
949
+ if (Array.isArray(event)) yield* event;
950
+ else if (event) yield event;
951
+ }
952
+ };
953
+
954
+ // `role` (`detail`, `group-header`, ...) becomes the HTML target's stable
955
+ // `q-<role>` class, which print CSS targets for styling and breaks.
956
+ /**
957
+ * @type {(value: any, path: string, role: string)
958
+ * => (scope: Scope) => Generator<any>}
959
+ */
960
+ let itemsOf = (value, path, role) => {
961
+ /** @type {((scope: Scope) => any)[]} */
962
+ let list = [];
963
+ for (let [i, def] of (arr(value, path) || []).entries()) {
964
+ let render = itemOf(def, path + "[" + i + "]", role);
965
+ if (render) list.push(render);
966
+ }
967
+ return yieldItems(list);
968
+ };
969
+
970
+ return { columnOf, itemsOf, totalOf };
971
+ };
972
+
973
+ // --- 5. band generators ---------------------------------------------------
974
+ //
975
+ // The generators a report is a stack of: the detail band at the leaf, and the
976
+ // group chain that partitions rows around it.
977
+
978
+ /**
979
+ * @param {State} state
980
+ * @param {Readers} readers
981
+ * @param {Nodes} nodes
982
+ */
983
+ let bands = (
984
+ { bad, runNames },
985
+ { arr, obj, keys, named, expression, visibleOf, columnsOf, takeOf, stylesOf, foldsOf, sortOf },
986
+ { columnOf, itemsOf, totalOf },
987
+ ) => {
988
+ // Stacked items, one pass per row.
989
+ /** @type {(items: (scope: Scope) => Generator<any>) => Band} */
990
+ let itemsBand = (items) =>
991
+ function* (rows, scope, runners) {
992
+ for (let row of rows) {
993
+ let scopeOfRow = runners.bind(scope, row);
994
+ // Detail items carry the row's run values as data; group and report
995
+ // band items never do (they have no current row).
996
+ for (let event of items(scopeOfRow))
997
+ yield carriesRun(event) ? opt(event, { run: scopeOfRow.run }) : event;
998
+ }
999
+ };
1000
+
1001
+ /** @type {(value: any) => { rowVisible: Eval | null, rowStyles: (scope: Scope) => any }} */
1002
+ let rowLook = (value) => {
1003
+ if (value.row == null || !obj(value.row, "detail.row"))
1004
+ return { rowVisible: null, rowStyles: NIL };
1005
+ keys(value.row, ["visible", "style"], "detail.row");
1006
+ return {
1007
+ rowVisible: visibleOf(value.row, "detail.row"),
1008
+ rowStyles: stylesOf(value.row, "detail.row"),
1009
+ };
1010
+ };
1011
+ /** @type {(value: any) => { defs: any[] | null, columns: any[] }} */
1012
+ let tableColumns = (value) => {
1013
+ let defs = arr(value.columns, "detail.columns", true);
1014
+ if (defs && !defs.length) bad("detail.columns", "expected at least one column");
1015
+ let columns = (defs || [])
1016
+ .map((def, i) => columnOf(def, "detail.columns[" + i + "]"))
1017
+ .filter((column) => column != null);
1018
+ checkShares(bad, columns, "detail.columns", "column");
1019
+ return { defs, columns };
1020
+ };
1021
+ /** @type {(totals: any[] | null, defs: any[] | null) => void} */
1022
+ let checkTotalCount = (totals, defs) => {
1023
+ if (totals && defs && totals.length !== defs.length)
1024
+ bad("detail.total", "expected one cell per column");
1025
+ };
1026
+ /** @type {(totals: any[]) => any[]} */
1027
+ let mapTotals = (totals) =>
1028
+ totals
1029
+ .map((def, i) => {
1030
+ let cell = totalOf(def, "detail.total[" + i + "]");
1031
+ return cell && { cell, path: "detail.total[" + i + "]" };
1032
+ })
1033
+ .filter((entry) => entry != null);
1034
+ /** @type {(value: any, defs: any[] | null) => any[] | null} */
1035
+ let tableTotals = (value, defs) => {
1036
+ let totals = arr(value.total, "detail.total");
1037
+ checkTotalCount(totals, defs);
1038
+ return totals ? mapTotals(totals) : null;
1039
+ };
1040
+ /**
1041
+ * @type {(rows: any[], scope: Scope, runners: RunnerSet, columns: any[],
1042
+ * rowVisible: Eval | null, rowStyles: (scope: Scope) => any) => Generator<any>}
1043
+ */
1044
+ function* tableRows(rows, scope, runners, columns, rowVisible, rowStyles) {
1045
+ for (let row of rows) {
1046
+ let scopeOfRow = runners.bind(scope, row);
1047
+ if (!hidden(rowVisible, scopeOfRow))
1048
+ yield opt(
1049
+ {
1050
+ type: "row",
1051
+ cells: columns.map((column) =>
1052
+ Object.assign(column.cell(scopeOfRow), { path: column.path }),
1053
+ ),
1054
+ },
1055
+ { style: rowStyles(scopeOfRow), run: scopeOfRow.run },
1056
+ );
1057
+ }
1058
+ }
1059
+ /** @type {(columns: any[], total: any[] | null, rowVisible: Eval | null, rowStyles: (scope: Scope) => any) => Band} */
1060
+ let tableBand = (columns, total, rowVisible, rowStyles) =>
1061
+ function* (rows, scope, runners) {
1062
+ yield {
1063
+ type: "table-start",
1064
+ path: "detail",
1065
+ columns: columns.map((column) =>
1066
+ opt({ header: column.header(scope), path: column.path }, { width: column.width }),
1067
+ ),
1068
+ };
1069
+ yield* tableRows(rows, scope, runners, columns, rowVisible, rowStyles);
1070
+ if (total)
1071
+ yield {
1072
+ type: "total-row",
1073
+ cells: total.map(({ cell, path }) => Object.assign(cell(scope), { path })),
1074
+ };
1075
+ yield { type: "table-end" };
1076
+ };
1077
+ /** @type {(value: any) => Band} */
1078
+ let tableOf = (value) => {
1079
+ keys(value, ["row", "columns", "total"], "detail");
1080
+ let { rowVisible, rowStyles } = rowLook(value);
1081
+ let { defs, columns } = tableColumns(value);
1082
+ return tableBand(columns, tableTotals(value, defs), rowVisible, rowStyles);
1083
+ };
1084
+
1085
+ // The detail band is polymorphic: an array of items stacks them per row, a
1086
+ // `{ columns, total }` object is a real table.
1087
+ /** @type {(value: any) => Band} */
1088
+ let detailOf = (value) => {
1089
+ if (Array.isArray(value) || value == null) return itemsBand(itemsOf(value, "detail", "detail"));
1090
+ if (!record(value)) {
1091
+ bad("detail", "expected an array of items or a table object");
1092
+ return itemsBand(itemsOf(null, "detail", "detail"));
1093
+ }
1094
+ return tableOf(value);
1095
+ };
1096
+ // Group names are unique across the whole report, so the register outlives
1097
+ // any one call of the recursion below.
1098
+ /** @type {Set<string>} */
1099
+ let groupNames = new Set();
1100
+
1101
+ /** @type {(name: any, path: string) => void} */
1102
+ let noteGroup = (name, path) => {
1103
+ if (typeof name !== "string") return;
1104
+ if (groupNames.has(name)) bad(path, 'duplicate group name "' + name + '"');
1105
+ else groupNames.add(name);
1106
+ };
1107
+ /** @type {(value: any, path: string, kind: string) => void} */
1108
+ let checkHint = (value, path, kind) => {
1109
+ if (value == null || value === "page") return;
1110
+ bad(path, "unknown " + kind + ' "' + value + '"');
1111
+ };
1112
+ /** @type {(aggs: [string, Fold][], rows: any[], scope: Scope, handle: Scope) => Scope} */
1113
+ let applyAggs = (aggs, rows, scope, handle) => {
1114
+ /** @type {Scope} */
1115
+ let aggregates = {};
1116
+ for (let [aggName, fold] of aggs)
1117
+ aggregates[aggName] = handle[aggName] = aggregateValue(fold, rows, scope);
1118
+ return aggregates;
1119
+ };
1120
+ // Without `take`, aggregates fold over the whole partition before its rows
1121
+ // are ordered, so a group sort key may read the handle. With `take`, the
1122
+ // rank filter has to run first — sort then slice — and aggregates fold over
1123
+ // what remains, so a sort key that reads this group's own aggregates is
1124
+ // null, the same rule a report sort already has. The handle is bound after
1125
+ // applyAggs when there is no take, so a sibling aggregate still throws.
1126
+ /** @type {(ctx: any, key: any, groupRows: any[], scope: Scope) => any} */
1127
+ let openInstance = (ctx, key, groupRows, scope) => {
1128
+ let groupScope = Object.create(scope);
1129
+ /** @type {Scope} */
1130
+ let handle = { key };
1131
+ if (ctx.take == null) {
1132
+ let aggregates = applyAggs(ctx.aggs, groupRows, groupScope, handle);
1133
+ groupScope[ctx.name] = handle;
1134
+ return {
1135
+ groupScope,
1136
+ aggregates,
1137
+ rows: ctx.sort ? ctx.sort(groupRows, groupScope) : groupRows,
1138
+ };
1139
+ }
1140
+ groupScope[ctx.name] = handle;
1141
+ let rows = (ctx.sort ? ctx.sort(groupRows, groupScope) : groupRows).slice(0, ctx.take);
1142
+ return { groupScope, aggregates: applyAggs(ctx.aggs, rows, groupScope, handle), rows };
1143
+ };
1144
+ /** @type {(ctx: any) => Band} */
1145
+ let groupWalk = (ctx) =>
1146
+ function* (rows, scope, runners) {
1147
+ let parts = Map.groupBy(rows, (row) => ctx.by(withRow(scope, row)));
1148
+ for (let [key, groupRows] of parts) {
1149
+ // A group band has no current row, so `@` stays unbound here.
1150
+ let { groupScope, aggregates, rows: ordered } = openInstance(ctx, key, groupRows, scope);
1151
+ yield opt(
1152
+ {
1153
+ type: "group-start",
1154
+ name: ctx.name,
1155
+ depth: ctx.index,
1156
+ key,
1157
+ aggregates,
1158
+ path: ctx.path,
1159
+ },
1160
+ { break: ctx.pageBreak, reset: ctx.reset, columns: ctx.columns },
1161
+ );
1162
+ yield* ctx.header(groupScope);
1163
+ yield* ctx.inner(ordered, groupScope, runners.extend(ctx.running));
1164
+ yield* ctx.footer(groupScope);
1165
+ yield { type: "group-end", name: ctx.name, depth: ctx.index };
1166
+ }
1167
+ };
1168
+ /** @type {(def: any, path: string, defs: any[], index: number, detail: any, columned?: boolean) => Band} */
1169
+ let compileGroup = (def, path, defs, index, detail, columned) => {
1170
+ keys(
1171
+ def,
1172
+ [
1173
+ "name",
1174
+ "by",
1175
+ "break",
1176
+ "reset",
1177
+ "columns",
1178
+ "sort",
1179
+ "take",
1180
+ "aggregates",
1181
+ "run",
1182
+ "header",
1183
+ "footer",
1184
+ ],
1185
+ path,
1186
+ );
1187
+ named(def.name, path + ".name", RESERVED);
1188
+ noteGroup(def.name, path + ".name");
1189
+ let by = expression(def.by, path + ".by");
1190
+ checkHint(def.break, path + ".break", "break");
1191
+ checkHint(def.reset, path + ".reset", "reset");
1192
+ let columns = columnsOf(def.columns, path + ".columns", columned);
1193
+ return groupWalk({
1194
+ by,
1195
+ sort: sortOf(def.sort, path + ".sort"),
1196
+ take: takeOf(def.take, path + ".take"),
1197
+ aggs: foldsOf(def.aggregates, path + ".aggregates", AGG, new Set(["key"])),
1198
+ running: foldsOf(def.run, path + ".run", RUN, null, runNames),
1199
+ header: itemsOf(def.header, path + ".header", "group-header"),
1200
+ footer: itemsOf(def.footer, path + ".footer", "group-footer"),
1201
+ inner: bandOf(defs, index + 1, detail, columned || columns != null),
1202
+ name: def.name,
1203
+ path,
1204
+ pageBreak: def.break,
1205
+ reset: def.reset,
1206
+ columns,
1207
+ index,
1208
+ });
1209
+ };
1210
+
1211
+ // The band chain, outermost group first: each group partitions its rows and
1212
+ // recurses; the detail band sits at the leaf.
1213
+ /** @type {(defs: any[], index: number, detail: any, columned?: boolean) => Band} */
1214
+ let bandOf = (defs, index, detail, columned) => {
1215
+ if (index >= defs.length) return detailOf(detail);
1216
+ let path = "groups[" + index + "]";
1217
+ let def = defs[index];
1218
+ if (!obj(def, path)) return bandOf(defs, index + 1, detail, columned);
1219
+ return compileGroup(def, path, defs, index, detail, columned);
1220
+ };
1221
+
1222
+ return { bandOf };
1223
+ };
1224
+
1225
+ // --- 6. the traversal -----------------------------------------------------
1226
+
1227
+ /** @type {(source: string, options: any) => any} */
1228
+ let compileQuery = (source, options) => {
1229
+ try {
1230
+ // padvinder itself defaults maxDepth to 500 (safety by construction since
1231
+ // 0.6.0), so untrusted deep documents throw typed with no quario-side
1232
+ // budget; hosts override per key — Infinity opts a budget out.
1233
+ return query(source, undefined, options && options.query);
1234
+ } catch (error) {
1235
+ locate("data", source, error, isQueryDiagnostic(error) && relocateQuery);
1236
+ }
1237
+ };
1238
+ /** @type {(schema: any, obj: any, named: any, bad: any) => any} */
1239
+ let paramsOf = (schema, obj, named, bad) => {
1240
+ if (schema.params == null || !obj(schema.params, "params")) return NONE;
1241
+ for (let name of Object.keys(schema.params)) named(name, "params." + name);
1242
+ return jsonCopy(schema.params, "params", bad, new Set());
1243
+ };
1244
+ /** @type {(schema: any, bad: any, attempt: any, options: any) => any} */
1245
+ let selectOf = (schema, bad, attempt, options) => {
1246
+ if (typeof schema.data !== "string") {
1247
+ bad("data", "expected a JSONPath string");
1248
+ return null;
1249
+ }
1250
+ return attempt(() => compileQuery(schema.data, options), null);
1251
+ };
1252
+ /** @type {(value: any, path: string, expression: any) => any} */
1253
+ let maybeExpr = (value, path, expression) => (value != null ? expression(value, path) : null);
1254
+ /** @type {(schema: any, obj: any, keys: any, itemsOf: any) => { pageHeader: any, pageFooter: any }} */
1255
+ let pageBands = (schema, obj, keys, itemsOf) => {
1256
+ if (schema.page == null || !obj(schema.page, "page"))
1257
+ return { pageHeader: null, pageFooter: null };
1258
+ keys(schema.page, ["header", "footer"], "page");
1259
+ return {
1260
+ pageHeader: maybeItems(schema.page.header, "page.header", "page-header", itemsOf),
1261
+ pageFooter: maybeItems(schema.page.footer, "page.footer", "page-footer", itemsOf),
1262
+ };
1263
+ };
1264
+
1265
+ /**
1266
+ * The root descent, in the schema's documented key order. `params` and `data`
1267
+ * are checked here the way every other key is — by compiling what they declare
1268
+ * — so the frozen params copy and the compiled query runner come back with the
1269
+ * band closures instead of being made a second time at render.
1270
+ *
1271
+ * @param {State} state
1272
+ * @param {Readers} readers
1273
+ * @param {Nodes} nodes
1274
+ * @param {Bands} bands
1275
+ * @param {any} schema The report document.
1276
+ * @param {{query?: {maxNodes?: number, maxDepth?: number, maxResults?: number}}} [options]
1277
+ * Host controls. The query budgets are per-execution, so they change what a
1278
+ * render may traverse, never what this descent reports.
1279
+ */
1280
+ let traverse = (
1281
+ { add, bad, attempt, runNames },
1282
+ { arr, obj, keys, named, expression, columnsOf, takeOf, foldsOf, sortOf, stylesOf },
1283
+ { itemsOf },
1284
+ { bandOf },
1285
+ schema,
1286
+ options,
1287
+ ) => {
1288
+ if (!record(schema)) {
1289
+ add("schema: expected an object");
1290
+ return null;
1291
+ }
1292
+ keys(
1293
+ schema,
1294
+ [
1295
+ "params",
1296
+ "data",
1297
+ "where",
1298
+ "sort",
1299
+ "take",
1300
+ "aggregates",
1301
+ "run",
1302
+ "style",
1303
+ "header",
1304
+ "empty",
1305
+ "columns",
1306
+ "groups",
1307
+ "detail",
1308
+ "footer",
1309
+ "page",
1310
+ ],
1311
+ "",
1312
+ );
1313
+ // Frozen either way, so `$.params` reads the same whether or not a report
1314
+ // declares any. A count here columns every group inside it, so the band
1315
+ // chain below carries the verdict down as `enclosed`.
1316
+ let params = paramsOf(schema, obj, named, bad);
1317
+ let select = selectOf(schema, bad, attempt, options);
1318
+ let where = maybeExpr(schema.where, "where", expression);
1319
+ let sort = sortOf(schema.sort, "sort");
1320
+ let take = takeOf(schema.take, "take");
1321
+ let aggs = foldsOf(schema.aggregates, "aggregates", AGG, new Set(["params", "input"]));
1322
+ let running = foldsOf(schema.run, "run", RUN, null, runNames);
1323
+ // The report default: one style block for the whole document, narrowed to
1324
+ // the declarations a typeface is made of. It resolves once per render in
1325
+ // report scope rather than per node, which is why it crosses the seam as a
1326
+ // `report-start` field instead of being merged into every item's style --
1327
+ // nothing here allocates per cell (docs/adr/0033).
1328
+ let style = stylesOf(schema, "", checkReportStyle);
1329
+ let header = itemsOf(schema.header, "header", "report-header");
1330
+ // Null when undeclared, so the render decides on the compiled band rather
1331
+ // than reading `schema.empty` a second time from outside the traversal.
1332
+ let empty = maybeItems(schema.empty, "empty", "empty", itemsOf);
1333
+ let columns = columnsOf(schema.columns, "columns");
1334
+ let band = bandOf(arr(schema.groups, "groups") || [], 0, schema.detail, columns != null);
1335
+ let footer = itemsOf(schema.footer, "footer", "report-footer");
1336
+ // Page bands compile like any other band, but render per page, so the
1337
+ // stream hands paginated targets closures instead of events.
1338
+ let { pageHeader, pageFooter } = pageBands(schema, obj, keys, itemsOf);
1339
+ return {
1340
+ select,
1341
+ params,
1342
+ where,
1343
+ sort,
1344
+ take,
1345
+ aggs,
1346
+ running,
1347
+ style,
1348
+ header,
1349
+ empty,
1350
+ columns,
1351
+ footer,
1352
+ band,
1353
+ pageHeader,
1354
+ pageFooter,
1355
+ };
1356
+ };
1357
+
1358
+ /**
1359
+ * One compilation of one report definition: the six layers above, composed.
1360
+ * Each takes the state and the layers it builds on, so what a layer reads is
1361
+ * visible in its signature.
1362
+ *
1363
+ * @param {any} schema The report document.
1364
+ * @param {Record<string, Function>} [funcs] Functions callable from expressions.
1365
+ * @param {{query?: {maxNodes?: number, maxDepth?: number, maxResults?: number}}} [options]
1366
+ * Host controls, for the one key that has any: the data query's budgets.
1367
+ */
1368
+ export let plan = (schema, funcs, options) => {
1369
+ let state = planState(schema, funcs);
1370
+ let prim = primitives(state);
1371
+ let read = readers(state, prim);
1372
+ let node = nodes(state, read);
1373
+ let band = bands(state, read, node);
1374
+ return {
1375
+ compiled: traverse(state, read, node, band, schema, options),
1376
+ problems: state.problems,
1377
+ anchors: state.anchors,
1378
+ names: state.names,
1379
+ // Registry metadata comes from xprsn's one signature convention, described
1380
+ // over just the called subset — every called name resolved from the
1381
+ // registry at compile time, and the rebuilt object keeps the flat name
1382
+ // set's call-first-seen order.
1383
+ functions: signatures(
1384
+ Object.fromEntries(Array.from(state.functions, (name) => [name, state.FNS[name]])),
1385
+ ),
1386
+ diagnostic: state.diagnostic,
1387
+ };
1388
+ };