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/CHANGELOG.md +156 -0
- package/LICENSE +219 -0
- package/README.md +237 -1
- package/lib/index.d.ts +617 -0
- package/lib/index.js +290 -0
- package/lib/license.js +141 -0
- package/lib/locate.js +67 -0
- package/lib/names.js +19 -0
- package/lib/plan.js +1388 -0
- package/lib/scope.js +162 -0
- package/lib/stream.js +177 -0
- package/lib/style.js +81 -0
- package/package.json +55 -2
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
|
+
};
|