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/scope.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scope model at render time: the anchors a band binds, the reducers that
|
|
3
|
+
* fold a scope's rows, and the runners that fold them in render order. One
|
|
4
|
+
* home for every running value, so a new fold has one place to be declared.
|
|
5
|
+
*/
|
|
6
|
+
/** @typedef {Record<string, any>} Scope */
|
|
7
|
+
/** @typedef {(scope: Scope) => any} Eval */
|
|
8
|
+
/** @typedef {{ fn: string, per: Eval | null }} Fold */
|
|
9
|
+
/** @typedef {[string, Fold][]} Folds */
|
|
10
|
+
/** @typedef {[string, Eval][]} Runners */
|
|
11
|
+
/**
|
|
12
|
+
* The runner set a band renders under: every runner in scope, as one value.
|
|
13
|
+
* `bind` steps them, so a band calls it exactly once per detail row.
|
|
14
|
+
* @typedef {{ extend: (folds: Folds) => RunnerSet,
|
|
15
|
+
* bind: (scope: Scope, row: any) => Scope }} RunnerSet
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// Built-in reducers. Each takes an array and an optional xprsn lambda, so an
|
|
19
|
+
// author can aggregate a row's own sub-array inline (`sum(@.lines, l => l.total)`);
|
|
20
|
+
// the same reducers back the declared group aggregates. Coercion is quiet: only
|
|
21
|
+
// a finite number contributes, everything else folds to 0, as the spec promises.
|
|
22
|
+
/** @type {(value: any) => number} */
|
|
23
|
+
let num = (value) => {
|
|
24
|
+
let n = +value;
|
|
25
|
+
return Number.isFinite(n) ? n : 0;
|
|
26
|
+
};
|
|
27
|
+
/** @typedef {(rows: any[] | null | undefined, of?: ((row: any) => any) | null) => any} Reducer */
|
|
28
|
+
// The per-row read every reducer but `count` makes of its lambda: a projection
|
|
29
|
+
// rather than a predicate. `count` is the one that filters instead, which is
|
|
30
|
+
// why `countDistinct` reads its lambda this way despite the shared prefix.
|
|
31
|
+
/** @type {(rows: any[] | null | undefined, of?: ((row: any) => any) | null) => any[]} */
|
|
32
|
+
let project = (rows, of) => (rows || []).map((row) => (of ? of(row) : row));
|
|
33
|
+
// `min`/`max` share one shape: project through the lambda, then pick a winner.
|
|
34
|
+
/** @type {(pick: (a: any, b: any) => any) => Reducer} */
|
|
35
|
+
let extreme = (pick) => (rows, of) => {
|
|
36
|
+
let values = project(rows, of);
|
|
37
|
+
return values.length ? values.reduce(pick) : null;
|
|
38
|
+
};
|
|
39
|
+
/** @type {Record<string, Reducer>} */
|
|
40
|
+
export let REDUCERS = {
|
|
41
|
+
sum: (rows, of) => (rows || []).reduce((total, row) => total + num(of ? of(row) : row), 0),
|
|
42
|
+
count: (rows, of) => (of ? (rows || []).filter(of) : rows || []).length,
|
|
43
|
+
// Distinctness is a `Set`'s — SameValueZero, which is also what `Map.groupBy`
|
|
44
|
+
// gives a group's `by` key, so this counts exactly what that key would
|
|
45
|
+
// partition into. Objects compare by reference there and here alike.
|
|
46
|
+
countDistinct: (rows, of) => new Set(project(rows, of)).size,
|
|
47
|
+
avg: (rows, of) => (rows && rows.length ? REDUCERS.sum(rows, of) / rows.length : null),
|
|
48
|
+
min: extreme((a, b) => (a < b ? a : b)),
|
|
49
|
+
max: extreme((a, b) => (a > b ? a : b)),
|
|
50
|
+
};
|
|
51
|
+
export let AGG = new Set(Object.keys(REDUCERS));
|
|
52
|
+
export let RUN = new Set([...AGG, "prev"]);
|
|
53
|
+
|
|
54
|
+
// Bind a row as `@` on a child of `scope`, ready for an xprsn per-row read.
|
|
55
|
+
/** @type {(scope: Scope, row: any) => Scope} */
|
|
56
|
+
export let withRow = (scope, row) => {
|
|
57
|
+
let child = Object.create(scope);
|
|
58
|
+
child["@"] = row;
|
|
59
|
+
return child;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Bind the page anchor (`{ number, total }`) on a child of `scope` for a page
|
|
63
|
+
// band render. `@` stays unbound — a page band has no current row.
|
|
64
|
+
/** @type {(scope: Scope, page: any) => Scope} */
|
|
65
|
+
export let withPage = (scope, page) => {
|
|
66
|
+
let child = Object.create(scope);
|
|
67
|
+
child.page = page;
|
|
68
|
+
return child;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Evaluate one declared aggregate over `rows` by delegating to the same reducers
|
|
72
|
+
// authors call inline: the per-row xprsn expression becomes the lambda.
|
|
73
|
+
/** @type {(fold: Fold, rows: any[], scope: Scope) => any} */
|
|
74
|
+
export let aggregateValue = ({ fn, per }, rows, scope) =>
|
|
75
|
+
REDUCERS[fn](rows, per ? (row) => per(withRow(scope, row)) : null);
|
|
76
|
+
|
|
77
|
+
// Folded across rows in render order, one factory per name. `min`/`max` have
|
|
78
|
+
// no zero, so they seed to the first row's value — the one deliberate
|
|
79
|
+
// `undefined` here, since xprsn reads absent values as null.
|
|
80
|
+
//
|
|
81
|
+
// Every name in `REDUCERS` needs a factory here. `RUN` derives from `AGG`
|
|
82
|
+
// rather than enumerating a second list, so a reducer added there without a
|
|
83
|
+
// runner added here passes validation and then throws at render. The invariant
|
|
84
|
+
// is a test rather than a comment alone — semantics.test.js, "every reducer
|
|
85
|
+
// the run block accepts has a runner behind it".
|
|
86
|
+
/** @type {Record<string, () => (value: any) => any>} */
|
|
87
|
+
let RUNNERS = {
|
|
88
|
+
sum: () => {
|
|
89
|
+
let total = 0;
|
|
90
|
+
return (value) => (total += num(value));
|
|
91
|
+
},
|
|
92
|
+
count: () => {
|
|
93
|
+
let seen = 0;
|
|
94
|
+
return () => ++seen;
|
|
95
|
+
},
|
|
96
|
+
countDistinct: () => {
|
|
97
|
+
let seen = new Set();
|
|
98
|
+
return (value) => seen.add(value).size;
|
|
99
|
+
},
|
|
100
|
+
avg: () => {
|
|
101
|
+
let total = 0,
|
|
102
|
+
seen = 0;
|
|
103
|
+
return (value) => (total += num(value)) / ++seen;
|
|
104
|
+
},
|
|
105
|
+
min: () => {
|
|
106
|
+
let lowest = /** @type {any} */ (undefined);
|
|
107
|
+
return (value) => (lowest = lowest === undefined || value < lowest ? value : lowest);
|
|
108
|
+
},
|
|
109
|
+
max: () => {
|
|
110
|
+
let highest = /** @type {any} */ (undefined);
|
|
111
|
+
return (value) => (highest = highest === undefined || value > highest ? value : highest);
|
|
112
|
+
},
|
|
113
|
+
prev: () => {
|
|
114
|
+
let previous = /** @type {any} */ (null);
|
|
115
|
+
return (value) => {
|
|
116
|
+
let out = previous;
|
|
117
|
+
previous = value;
|
|
118
|
+
return out;
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
// The runner set as a value, and the two things a band does with it. `extend`
|
|
123
|
+
// is the whole of persist-vs-reset: a group's own runners are fresh per
|
|
124
|
+
// instance, the outer ones fold on unchanged.
|
|
125
|
+
/** @type {(runners: Runners) => RunnerSet} */
|
|
126
|
+
let runnerSet = (runners) => {
|
|
127
|
+
/** @type {RunnerSet} */
|
|
128
|
+
let value = {
|
|
129
|
+
extend: (folds) => {
|
|
130
|
+
// Declaring no runners is not a change of set, so a band whose `run`
|
|
131
|
+
// block is absent renders under this very value rather than a copy.
|
|
132
|
+
if (!folds.length) return value;
|
|
133
|
+
let started = folds.map(([name, { fn, per }]) => {
|
|
134
|
+
let step = RUNNERS[fn]();
|
|
135
|
+
return /** @type {[string, Eval]} */ ([name, (scope) => step(per ? per(scope) : 1)]);
|
|
136
|
+
});
|
|
137
|
+
return runnerSet([...runners, ...started]);
|
|
138
|
+
},
|
|
139
|
+
// The per-row detail scope: `@` bound to the row, its running values folded
|
|
140
|
+
// in render order and exposed as `run`. With no runners in scope, `run`
|
|
141
|
+
// stays unbound so a stray `run.x` throws instead of silently reading null
|
|
142
|
+
// — the same fail-loud stance as `@` outside detail bands.
|
|
143
|
+
bind: (scope, row) => {
|
|
144
|
+
let child = withRow(scope, row);
|
|
145
|
+
if (!runners.length) return child;
|
|
146
|
+
/** @type {Scope} */
|
|
147
|
+
let run = {};
|
|
148
|
+
// Every runner steps once per row, outer first; the traversal's shared
|
|
149
|
+
// `runNames` register keeps run names unique across the whole report, so
|
|
150
|
+
// no name is ever written twice and the order decides nothing.
|
|
151
|
+
for (let [name, runner] of runners) run[name] = runner(child);
|
|
152
|
+
child.run = run;
|
|
153
|
+
return child;
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
return value;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// A report starts its runners the way every band under it does: by extending
|
|
160
|
+
// the set it renders in, which for the report itself is the empty one.
|
|
161
|
+
/** @type {(folds: Folds) => RunnerSet} */
|
|
162
|
+
export let startRunners = (folds) => runnerSet([]).extend(folds);
|
package/lib/stream.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The event stream's own rules: when a cell's tokens keep their native value,
|
|
3
|
+
* which format an image item's bytes hold, whose band an item's role names,
|
|
4
|
+
* how an optional field reaches an event, and the driver a target consumes the
|
|
5
|
+
* stream through.
|
|
6
|
+
*
|
|
7
|
+
* `breathe` sits here because the driver batches on it, not because handing the
|
|
8
|
+
* loop back is a stream rule — the two are independent, and a target breathes
|
|
9
|
+
* while stamping finished pages, which is no walk at all (CONTEXT.md).
|
|
10
|
+
*/
|
|
11
|
+
// The token-join sjabloon renders with — literals verbatim, values through
|
|
12
|
+
// the scalar `display()` rule — re-exported together so event consumers
|
|
13
|
+
// derive display text from a cell's tokens without hand-rolling the join.
|
|
14
|
+
export { display, text } from "sjabloon";
|
|
15
|
+
|
|
16
|
+
/** @type {(value: any) => boolean} */
|
|
17
|
+
let finiteNum = (value) => typeof value === "number" && Number.isFinite(value);
|
|
18
|
+
/** @type {(value: any) => boolean} */
|
|
19
|
+
let finiteDate = (value) => value instanceof Date && Number.isFinite(value.getTime());
|
|
20
|
+
/** @type {(value: any) => number | boolean | Date | undefined} */
|
|
21
|
+
let asTyped = (value) => {
|
|
22
|
+
if (finiteNum(value)) return value;
|
|
23
|
+
if (typeof value === "boolean") return value;
|
|
24
|
+
if (finiteDate(value)) return value;
|
|
25
|
+
return undefined;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The typed-cell seam: exactly one value token holding a finite number, a
|
|
30
|
+
* boolean, or a valid Date keeps its pre-stringify value. Anything else —
|
|
31
|
+
* including a lone null, matching the display-text treatment everywhere
|
|
32
|
+
* else — reports `undefined` and joins to text.
|
|
33
|
+
*
|
|
34
|
+
* @param {any[]} tokens A cell's tokens.
|
|
35
|
+
* @returns {number | boolean | Date | undefined} The native value, if any.
|
|
36
|
+
*/
|
|
37
|
+
export function typed(tokens) {
|
|
38
|
+
let [token] = tokens;
|
|
39
|
+
if (tokens.length !== 1 || !("value" in token)) return undefined;
|
|
40
|
+
return asTyped(token.value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The report's own bands, by the role their items wear (CONTEXT.md, "Band").
|
|
44
|
+
// The page ones are in it although no walk emits them: the question is whose
|
|
45
|
+
// band a role names, not what a walk hands over (ADR 0028).
|
|
46
|
+
let REPORT_BANDS = new Set([
|
|
47
|
+
"report-header",
|
|
48
|
+
"report-footer",
|
|
49
|
+
"empty",
|
|
50
|
+
"page-header",
|
|
51
|
+
"page-footer",
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether a band item's `role` names one of the report's own bands rather than
|
|
56
|
+
* a group instance's. This says whose band it is, never how to lay one out —
|
|
57
|
+
* what a consumer does with the answer is its own. See SCHEMA.md ("Event
|
|
58
|
+
* stream") for the contract and ADR 0028 for why the engine classifies rather
|
|
59
|
+
* than carrying a verdict on the events themselves.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} role An item or image event's `role`.
|
|
62
|
+
* @returns {boolean} True for the report's own bands.
|
|
63
|
+
*/
|
|
64
|
+
export function isReportBand(role) {
|
|
65
|
+
return REPORT_BANDS.has(role);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The two raster formats an image item may carry, as the bytes announce
|
|
69
|
+
// themselves: PNG's 8-byte signature and JPEG's start-of-image marker. Read,
|
|
70
|
+
// never decoded -- the engine answers which format the bytes are, not what
|
|
71
|
+
// they depict.
|
|
72
|
+
let PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
73
|
+
let JPEG = [0xff, 0xd8, 0xff];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The image seam: name the format a `source` expression's bytes hold, or null
|
|
77
|
+
* when they are not image bytes at all -- including when they are not bytes.
|
|
78
|
+
* The traversal sniffs once per image event and the answer rides on the event,
|
|
79
|
+
* so no consumer repeats it.
|
|
80
|
+
*
|
|
81
|
+
* @param {any} bytes The value a `source` expression yielded.
|
|
82
|
+
* @returns {"png" | "jpeg" | null} The format, or null when unreadable.
|
|
83
|
+
*/
|
|
84
|
+
export function sniff(bytes) {
|
|
85
|
+
if (!(bytes instanceof Uint8Array)) return null;
|
|
86
|
+
if (PNG.every((byte, i) => bytes[i] === byte)) return "png";
|
|
87
|
+
if (JPEG.every((byte, i) => bytes[i] === byte)) return "jpeg";
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The cooperative hand-back's plumbing. A timer is the slowest way a runtime will
|
|
92
|
+
// hand the loop back and, until this landed, the only one quario used: measured
|
|
93
|
+
// on an M-series Mac it costs ~1.20ms per hand-back against ~0.015ms for
|
|
94
|
+
// setImmediate, so a 10k-row render spent about 30% of its wall-clock waiting
|
|
95
|
+
// on timers. Browsers are worse — once nested-timer depth passes five, the
|
|
96
|
+
// HTML spec clamps a zero timer to 4ms, i.e. ~4ms per batch on a long report.
|
|
97
|
+
// Deliberately not scheduler.yield(): it is Chrome-only, and its continuation
|
|
98
|
+
// is scheduled ahead of other host tasks, which is the opposite of what handing
|
|
99
|
+
// the loop back is for. The timer stays as the last rung for a runtime that
|
|
100
|
+
// offers neither of the others; no runtime quario supports reaches it.
|
|
101
|
+
//
|
|
102
|
+
// setImmediate is Node's alone, so reaching it is a feature detection and not
|
|
103
|
+
// an import: this module runs in browsers too, where importing node:timers
|
|
104
|
+
// would not resolve. The lookup goes through globalThis because the package's
|
|
105
|
+
// types are deliberately DOM-only (only @quario/xlsx pulls Node's in) — a
|
|
106
|
+
// typed global would mean a dependency for one name.
|
|
107
|
+
let immediate = /** @type {any} */ (globalThis).setImmediate;
|
|
108
|
+
/** @type {MessageChannel | undefined} */
|
|
109
|
+
let channel;
|
|
110
|
+
/** @type {(() => void)[]} */
|
|
111
|
+
let waiting = [];
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Hand the event loop back to the host, resolving once it has had its turn.
|
|
115
|
+
* The walk driver calls it between batches, and a target running a loop of its
|
|
116
|
+
* own — a paginated target stamping its finished pages, say — calls it the
|
|
117
|
+
* same way, so a long report never blocks the host. Which kind of task the turn
|
|
118
|
+
* is taken as is current behaviour rather than a promise. See SCHEMA.md
|
|
119
|
+
* ("Handing the loop back"), which is normative.
|
|
120
|
+
*
|
|
121
|
+
* @returns {Promise<void>} Resolves once the host has had its turn.
|
|
122
|
+
*/
|
|
123
|
+
export async function breathe() {
|
|
124
|
+
if (typeof immediate === "function") {
|
|
125
|
+
await new Promise((resolve) => immediate(resolve));
|
|
126
|
+
} else if (typeof MessageChannel === "function") {
|
|
127
|
+
if (!channel) {
|
|
128
|
+
channel = new MessageChannel();
|
|
129
|
+
// One message wakes exactly one waiter, so concurrent renders take their
|
|
130
|
+
// turns in order instead of racing for a single slot — a lost wake-up
|
|
131
|
+
// here would hang a render, not merely slow it down.
|
|
132
|
+
channel.port1.onmessage = () => waiting.shift()?.();
|
|
133
|
+
}
|
|
134
|
+
let port = channel.port2;
|
|
135
|
+
await new Promise((resolve) => {
|
|
136
|
+
waiting.push(/** @type {() => void} */ (resolve));
|
|
137
|
+
port.postMessage(0);
|
|
138
|
+
});
|
|
139
|
+
} else {
|
|
140
|
+
await new Promise((resolve) => setTimeout(resolve));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The walk driver: dispatch one render's event stream to per-event handlers,
|
|
146
|
+
* so a target writes no event loop of its own — in stream order, exactly once
|
|
147
|
+
* each, pulled lazily, handing the loop back every 1000 events. A missing
|
|
148
|
+
* handler ignores that event, a handler that throws rejects without draining
|
|
149
|
+
* the rest, and handlers are synchronous: a promise one returns is not
|
|
150
|
+
* awaited. The stream's first event reaches its handler before a second one is
|
|
151
|
+
* pulled, so a target settles what it needs from `report-start` — page band
|
|
152
|
+
* closures, the marking — in that handler rather than pulling the stream
|
|
153
|
+
* itself. No opening event is required: the driver reads `event.type` and
|
|
154
|
+
* nothing else, so a stream that starts part-way through walks like any other.
|
|
155
|
+
* See SCHEMA.md ("The walk driver"), which is normative.
|
|
156
|
+
*
|
|
157
|
+
* @param {Iterable<any>} events One render's event stream.
|
|
158
|
+
* @param {Record<string, (event: any) => void>} handlers Per-event handlers.
|
|
159
|
+
* @returns {Promise<void>} Resolves when the stream is exhausted.
|
|
160
|
+
*/
|
|
161
|
+
export async function walk(events, handlers) {
|
|
162
|
+
let seen = 0;
|
|
163
|
+
for (let event of events) {
|
|
164
|
+
handlers[event.type]?.(event);
|
|
165
|
+
if (++seen % 1000 === 0) await breathe();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Optional public event fields are present only when truthy — the one rule
|
|
170
|
+
// for optional event data, owned here. What rides it is therefore any value
|
|
171
|
+
// whose falsy form means "undeclared"; a field whose zero or empty string is
|
|
172
|
+
// meaningful cannot use this seam.
|
|
173
|
+
/** @type {(base: any, extra: any) => any} */
|
|
174
|
+
export let opt = (base, extra) => {
|
|
175
|
+
for (let key in extra) if (extra[key]) base[key] = extra[key];
|
|
176
|
+
return base;
|
|
177
|
+
};
|
package/lib/style.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The closed style vocabulary, as the traversal checks it: target-neutral names
|
|
3
|
+
* with typed literal values. Unknown names and mistyped literals are definition
|
|
4
|
+
* errors; `=` results stay lenient at render. Each target maps these names to
|
|
5
|
+
* its own formatting model, and nothing here knows about any of them.
|
|
6
|
+
*
|
|
7
|
+
* `finite`/`HEX` are restated per target on purpose: those coercions are each
|
|
8
|
+
* target's own edge, never shared engine code — unlike the stream rules
|
|
9
|
+
* (`typed`), which every target reads from `./stream.js`.
|
|
10
|
+
*/
|
|
11
|
+
/** @type {(value: any) => boolean} */
|
|
12
|
+
let finite = (value) => typeof value === "number" && Number.isFinite(value);
|
|
13
|
+
let HEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
14
|
+
/** @type {(value: any) => boolean} */
|
|
15
|
+
let isHex = (value) => typeof value === "string" && HEX.test(value);
|
|
16
|
+
/** @typedef {{ ok: (value: any) => boolean, msg: string }} StyleSpec */
|
|
17
|
+
/** @type {(msg: string, ok: (value: any) => boolean) => StyleSpec} */
|
|
18
|
+
let spec = (msg, ok) => ({ ok, msg });
|
|
19
|
+
let COLOR = spec("expected a #rgb or #rrggbb color", isHex);
|
|
20
|
+
let FLAG = spec("expected a boolean", (value) => typeof value === "boolean");
|
|
21
|
+
let ALIGNMENTS = ["left", "center", "right"];
|
|
22
|
+
// The declarations an image item accepts. The rest of the vocabulary describes
|
|
23
|
+
// text, which an image does not have, so anything else on one is a definition
|
|
24
|
+
// error rather than a silent no-op (SCHEMA.md, "Image item"). The subset lives
|
|
25
|
+
// beside the vocabulary it narrows, and `checkImageStyle` below is how the
|
|
26
|
+
// traversal asks for it -- it keeps no second list of its own.
|
|
27
|
+
let IMAGE_STYLES = ["align", "background"];
|
|
28
|
+
// The declarations a report default accepts. A report default states what the
|
|
29
|
+
// document is set in, so it carries only the two declarations that describe a
|
|
30
|
+
// typeface -- and, unlike the rest of the vocabulary, only the two no band-role
|
|
31
|
+
// default has to argue with. Anything else is a definition error rather than a
|
|
32
|
+
// silent no-op, exactly as on an image (SCHEMA.md, "Report default").
|
|
33
|
+
let REPORT_STYLES = ["family", "size"];
|
|
34
|
+
/** @type {Record<string, StyleSpec>} */
|
|
35
|
+
let STYLES = {
|
|
36
|
+
family: spec(
|
|
37
|
+
"expected a font family name",
|
|
38
|
+
(value) => typeof value === "string" && value.length > 0,
|
|
39
|
+
),
|
|
40
|
+
size: spec("expected a positive number of points", (value) => finite(value) && value > 0),
|
|
41
|
+
bold: FLAG,
|
|
42
|
+
italic: FLAG,
|
|
43
|
+
underline: FLAG,
|
|
44
|
+
strikethrough: FLAG,
|
|
45
|
+
uppercase: FLAG,
|
|
46
|
+
color: COLOR,
|
|
47
|
+
background: COLOR,
|
|
48
|
+
align: spec("expected left, center, or right", (value) => ALIGNMENTS.includes(value)),
|
|
49
|
+
};
|
|
50
|
+
// One check for both the validating traversal and the compile path: an error
|
|
51
|
+
// message for a declaration, or null when it is acceptable (expressions defer
|
|
52
|
+
// to render).
|
|
53
|
+
/** @type {(value: any) => boolean} */
|
|
54
|
+
let isExpr = (value) => typeof value === "string" && value[0] === "=";
|
|
55
|
+
/** @type {(name: string, value: any) => string | null} */
|
|
56
|
+
export let checkStyle = (name, value) => {
|
|
57
|
+
// Own-key lookup: a declaration named after an Object.prototype member
|
|
58
|
+
// (`toString`, `constructor`) is unknown, not an inherited function.
|
|
59
|
+
if (!Object.hasOwn(STYLES, name)) return 'unknown style "' + name + '"';
|
|
60
|
+
if (isExpr(value)) return null;
|
|
61
|
+
let rule = STYLES[name];
|
|
62
|
+
return rule.ok(value) ? null : rule.msg;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// The same check narrowed to a subset of the vocabulary, so a declaration a
|
|
66
|
+
// node cannot wear is refused as pointedly as an unknown name rather than
|
|
67
|
+
// silently doing nothing. A node that takes part of the vocabulary names the
|
|
68
|
+
// checker this makes rather than passing a flag, which is what keeps each
|
|
69
|
+
// subset and its wording together, here.
|
|
70
|
+
//
|
|
71
|
+
// `subject` completes "does not apply to ___", so it carries its own article.
|
|
72
|
+
/** @type {(allowed: string[], subject: string) => (name: string, value: any) => string | null} */
|
|
73
|
+
let narrowed = (allowed, subject) => (name, value) =>
|
|
74
|
+
Object.hasOwn(STYLES, name) && !allowed.includes(name)
|
|
75
|
+
? 'style "' + name + '" does not apply to ' + subject
|
|
76
|
+
: checkStyle(name, value);
|
|
77
|
+
|
|
78
|
+
export let checkImageStyle = narrowed(IMAGE_STYLES, "an image");
|
|
79
|
+
// "a report default" rather than the key's name: the key is `style` like every
|
|
80
|
+
// other one, so the message has to say which `style` refused it.
|
|
81
|
+
export let checkReportStyle = narrowed(REPORT_STYLES, "a report default");
|
package/package.json
CHANGED
|
@@ -1,7 +1,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "quario",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A tiny, runtime-neutral report engine — in the makings, not yet released",
|
|
5
5
|
"homepage": "https://getquario.com",
|
|
6
|
-
"license": "
|
|
6
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/getquario/quario.git",
|
|
10
|
+
"directory": "packages/quario"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"CHANGELOG.md",
|
|
14
|
+
"lib"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"types": "lib/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./lib/index.d.ts",
|
|
21
|
+
"default": "./lib/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./package.json": "./package.json"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"check": "npm run size && npm test && npm run test:browser",
|
|
27
|
+
"size": "size-limit",
|
|
28
|
+
"test": "npm run test:unit && npm run test:types",
|
|
29
|
+
"test:browser": "node test/browser/setup.js",
|
|
30
|
+
"test:types": "tsc && attw --pack . --profile esm-only",
|
|
31
|
+
"test:unit": "node --disallow-code-generation-from-strings --test --test-concurrency=1 test/*.test.js",
|
|
32
|
+
"prepack": "../../scripts/release-date.mjs && node -e \"require('fs').copyFileSync('../../LICENSE','LICENSE')\"",
|
|
33
|
+
"postpack": "node -e \"require('fs').rmSync('LICENSE',{force:true})\""
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"padvinder": "^0.9.0",
|
|
37
|
+
"sjabloon": "^0.12.0",
|
|
38
|
+
"xprsn": "^0.11.1"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@arethetypeswrong/cli": "^0.18.3",
|
|
42
|
+
"@size-limit/preset-small-lib": "^13.0.3",
|
|
43
|
+
"size-limit": "^13.0.3",
|
|
44
|
+
"typescript": "^7.0.2"
|
|
45
|
+
},
|
|
46
|
+
"size-limit": [
|
|
47
|
+
{
|
|
48
|
+
"path": "lib/index.js",
|
|
49
|
+
"ignore": [
|
|
50
|
+
"xprsn",
|
|
51
|
+
"sjabloon",
|
|
52
|
+
"padvinder"
|
|
53
|
+
],
|
|
54
|
+
"limit": "9 kB"
|
|
55
|
+
}
|
|
56
|
+
],
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=22.0.0"
|
|
59
|
+
}
|
|
7
60
|
}
|