quario 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/lib/format.js +60 -0
- package/lib/index.d.ts +57 -4
- package/lib/index.js +54 -8
- package/lib/license.js +1 -1
- package/lib/plan.js +184 -42
- package/lib/stream.js +2 -2
- package/lib/style.js +82 -5
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-09-02
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`format` is a closed style name.** `number` / `currency` / `percent` /
|
|
15
|
+
`date` on text items, column cells, headers, and totals. Not images, not
|
|
16
|
+
`row.style`, not the report default. Locale, currency code, and timezone
|
|
17
|
+
live on `quario({ locale, currency, timeZone })`. The public `format()`
|
|
18
|
+
helper is how targets present a kind; a kind on the wrong type contributes
|
|
19
|
+
nothing (`docs/adr/0041`).
|
|
20
|
+
|
|
21
|
+
- **The report header may pin a `height` from the page top.** Dual-shaped
|
|
22
|
+
like `detail`: an item array, or `{ height, items }`. `page.margin` is a
|
|
23
|
+
document field (one number, all four sides), required with `height` and
|
|
24
|
+
legal without. Authored `spaceBefore` on the first occupying item of the
|
|
25
|
+
next band is refused (`docs/adr/0038`).
|
|
26
|
+
|
|
27
|
+
- **`spaceBefore` / `spaceAfter` return as item flow spacing.** Blank space
|
|
28
|
+
before or after a band item, in points, including band images and splits as
|
|
29
|
+
band items. Adjacent gaps add. Table cells, `row.style`, headers, totals,
|
|
30
|
+
and split slots refuse the names. `spaceBefore` drops at a fresh body page
|
|
31
|
+
or strip top; page-band items keep it. Leading and inset stay cut
|
|
32
|
+
(`docs/adr/0037`).
|
|
33
|
+
|
|
34
|
+
- **Per-side padding and border on the closed style vocabulary.**
|
|
35
|
+
`paddingTop` / `Right` / `Bottom` / `Left` (points, ≥ 0) and, per side,
|
|
36
|
+
`border*Width`, `border*Style` (`solid` | `dashed` | `dotted`),
|
|
37
|
+
`border*Color` (`#rgb` / `#rrggbb`). A border side is all three names or
|
|
38
|
+
none; width `0` is no stroke; an incomplete literal is a definition error,
|
|
39
|
+
and an incomplete expression result at render contributes nothing rather
|
|
40
|
+
than a solid black stroke. The names are legal wherever `background` is,
|
|
41
|
+
including images, plus `row.style`. The report default still takes only
|
|
42
|
+
`family` and `size`. Column `%` widths are border-box.
|
|
43
|
+
|
|
44
|
+
- **`detail.header` is the header-row box.** `{ style }` only, a different
|
|
45
|
+
path from `columns[i].header`. It crosses the seam as `table-start.style`.
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
|
|
49
|
+
- **A table total is N rows.** Before, `total` was one row: a flat cell
|
|
50
|
+
array or `{ style, cells }`. After, it is absent or a non-empty array of
|
|
51
|
+
`{ cells, style?, visible? }`. A one-row total is `[{ cells: [...] }]`.
|
|
52
|
+
`total: []` is a definition error. Paths are `detail.total[r].cells[i]`.
|
|
53
|
+
The stream yields one `total-row` per emitted row (`docs/adr/0042`).
|
|
54
|
+
|
|
55
|
+
- **A visible text item occupies a line at its own `size`.** Empty display
|
|
56
|
+
used to take the report default's leading in PDF and collapse in HTML;
|
|
57
|
+
`"a\n\nb"` broke in PDF and collapsed to a space in HTML. A visible item
|
|
58
|
+
now occupies at least one line set in that item's `size`, and a literal
|
|
59
|
+
newline is a line break, on every target that can show a line. Empty
|
|
60
|
+
table cells stay contentless for height. This is not a spacing primitive;
|
|
61
|
+
`visible: false` is still how an item leaves the layout.
|
|
62
|
+
|
|
10
63
|
## [0.2.0] - 2026-09-01
|
|
11
64
|
|
|
12
65
|
### Added
|
package/lib/format.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Present a cell's raw token as `number` / `currency` / `percent` / `date`.
|
|
3
|
+
* Targets import this instead of each other: the stream keeps the kind on
|
|
4
|
+
* `style` and the token raw, and HTML/PDF stringify at the edge. A kind on
|
|
5
|
+
* the wrong type, or on null, contributes nothing — the caller falls back to
|
|
6
|
+
* `display()`. Invalid locale or currency codes fail the same way.
|
|
7
|
+
*
|
|
8
|
+
* Defaults (`en-US`, UTC) are pinned so a PDF without a host locale still
|
|
9
|
+
* renders the same bytes on every machine (docs/adr/0041, docs/adr/0025).
|
|
10
|
+
*/
|
|
11
|
+
import { finiteDate, finiteNum } from "./stream.js";
|
|
12
|
+
/** @type {(options: any) => string} */
|
|
13
|
+
let localeOf = (options) => options?.locale || "en-US";
|
|
14
|
+
/** @type {(options: any) => string} */
|
|
15
|
+
let zoneOf = (options) => options?.timeZone || "UTC";
|
|
16
|
+
|
|
17
|
+
/** @type {(value: any, locale: string) => string | undefined} */
|
|
18
|
+
let asNumber = (value, locale) =>
|
|
19
|
+
finiteNum(value) ? new Intl.NumberFormat(locale).format(value) : undefined;
|
|
20
|
+
/** @type {(value: any, locale: string) => string | undefined} */
|
|
21
|
+
let asPercent = (value, locale) =>
|
|
22
|
+
finiteNum(value) ? new Intl.NumberFormat(locale, { style: "percent" }).format(value) : undefined;
|
|
23
|
+
/** @type {(options: any) => string | null} */
|
|
24
|
+
let currencyOf = (options) => {
|
|
25
|
+
let currency = options?.currency;
|
|
26
|
+
return typeof currency === "string" && currency ? currency : null;
|
|
27
|
+
};
|
|
28
|
+
/** @type {(value: any, locale: string, options: any) => string | undefined} */
|
|
29
|
+
let asMoney = (value, locale, options) => {
|
|
30
|
+
let currency = currencyOf(options);
|
|
31
|
+
if (!finiteNum(value) || !currency) return;
|
|
32
|
+
return new Intl.NumberFormat(locale, { style: "currency", currency }).format(value);
|
|
33
|
+
};
|
|
34
|
+
/** @type {(value: any, locale: string, options: any) => string | undefined} */
|
|
35
|
+
let asDate = (value, locale, options) =>
|
|
36
|
+
finiteDate(value)
|
|
37
|
+
? new Intl.DateTimeFormat(locale, { timeZone: zoneOf(options) }).format(value)
|
|
38
|
+
: undefined;
|
|
39
|
+
|
|
40
|
+
/** @type {Record<string, (value: any, locale: string, options: any) => string | undefined>} */
|
|
41
|
+
let KINDS = { number: asNumber, currency: asMoney, percent: asPercent, date: asDate };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {unknown} value The interpolation's pre-stringify token.
|
|
45
|
+
* @param {unknown} kind A resolved `format` declaration.
|
|
46
|
+
* @param {{ locale?: string, currency?: string, timeZone?: string } | null} [options]
|
|
47
|
+
* The instance's locale, currency code, and timezone.
|
|
48
|
+
* @returns {string | undefined} The presented text, or nothing when this
|
|
49
|
+
* kind does not apply.
|
|
50
|
+
*/
|
|
51
|
+
export function format(value, kind, options) {
|
|
52
|
+
if (typeof kind !== "string") return;
|
|
53
|
+
let present = KINDS[kind];
|
|
54
|
+
if (!present) return;
|
|
55
|
+
try {
|
|
56
|
+
return present(value, localeOf(options), options);
|
|
57
|
+
} catch {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -53,6 +53,12 @@ export interface StyleDeclarations {
|
|
|
53
53
|
background?: ExpressionValue<string>;
|
|
54
54
|
/** Horizontal alignment within the cell. */
|
|
55
55
|
align?: ExpressionValue<Align>;
|
|
56
|
+
/** How a number or date is presented. Locale stays on the instance. */
|
|
57
|
+
format?: ExpressionValue<FormatKind>;
|
|
58
|
+
/** Blank space before this item, in points. */
|
|
59
|
+
spaceBefore?: ExpressionValue<number>;
|
|
60
|
+
/** Blank space after this item, in points. */
|
|
61
|
+
spaceAfter?: ExpressionValue<number>;
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
export interface SortKey {
|
|
@@ -64,6 +70,7 @@ export interface SortKey {
|
|
|
64
70
|
export type CellValue = string;
|
|
65
71
|
|
|
66
72
|
export type Align = "left" | "center" | "right";
|
|
73
|
+
export type FormatKind = "number" | "currency" | "percent" | "date";
|
|
67
74
|
|
|
68
75
|
export interface Cell {
|
|
69
76
|
value: CellValue;
|
|
@@ -85,7 +92,10 @@ export type ImageFit = "natural" | "width";
|
|
|
85
92
|
* error. A narrowing of the one vocabulary rather than a second list of its
|
|
86
93
|
* own, so the two cannot drift.
|
|
87
94
|
*/
|
|
88
|
-
export type ImageStyleDeclarations = Pick<
|
|
95
|
+
export type ImageStyleDeclarations = Pick<
|
|
96
|
+
StyleDeclarations,
|
|
97
|
+
"background" | "align" | "spaceBefore" | "spaceAfter"
|
|
98
|
+
>;
|
|
89
99
|
|
|
90
100
|
/**
|
|
91
101
|
* What a report default declares: the two declarations a typeface is made of.
|
|
@@ -153,10 +163,16 @@ export interface TableRow {
|
|
|
153
163
|
style?: StyleDeclarations;
|
|
154
164
|
}
|
|
155
165
|
|
|
166
|
+
export interface TotalRow {
|
|
167
|
+
cells: [Cell, ...Cell[]];
|
|
168
|
+
visible?: ExpressionValue<boolean>;
|
|
169
|
+
style?: StyleDeclarations;
|
|
170
|
+
}
|
|
171
|
+
|
|
156
172
|
export interface TableDetail {
|
|
157
173
|
row?: TableRow;
|
|
158
174
|
columns: [TableColumn, ...TableColumn[]];
|
|
159
|
-
total?:
|
|
175
|
+
total?: [TotalRow, ...TotalRow[]];
|
|
160
176
|
}
|
|
161
177
|
|
|
162
178
|
export interface Group {
|
|
@@ -195,6 +211,8 @@ export interface Group {
|
|
|
195
211
|
export interface PageBands {
|
|
196
212
|
header?: Item[];
|
|
197
213
|
footer?: Item[];
|
|
214
|
+
/** Inset on all four sides, in points. Required when the report header declares `height`. */
|
|
215
|
+
margin?: number;
|
|
198
216
|
}
|
|
199
217
|
|
|
200
218
|
export interface ReportSchema {
|
|
@@ -216,7 +234,11 @@ export interface ReportSchema {
|
|
|
216
234
|
* declared `size` does not change a report header's headline size.
|
|
217
235
|
*/
|
|
218
236
|
style?: ReportStyleDeclarations;
|
|
219
|
-
|
|
237
|
+
/**
|
|
238
|
+
* Dual-shaped like `detail`: an item array, or `{ height, items }` when the
|
|
239
|
+
* header pins a flow box from the page top.
|
|
240
|
+
*/
|
|
241
|
+
header?: Item[] | { height: number; items: Item[] };
|
|
220
242
|
empty?: Item[];
|
|
221
243
|
/**
|
|
222
244
|
* Flow the report body in this many page columns (integer >= 2). Columned
|
|
@@ -254,6 +276,12 @@ export interface QuarioOptions {
|
|
|
254
276
|
* report this instance compiles marks its output as unlicensed.
|
|
255
277
|
*/
|
|
256
278
|
license?: string;
|
|
279
|
+
/** Locale for `format`. Defaults to `"en-US"` when a kind is presented. */
|
|
280
|
+
locale?: string;
|
|
281
|
+
/** Currency code for `format: "currency"`. Absent, that kind contributes nothing. */
|
|
282
|
+
currency?: string;
|
|
283
|
+
/** Timezone for `format: "date"`. Defaults to `"UTC"`. */
|
|
284
|
+
timeZone?: string;
|
|
257
285
|
}
|
|
258
286
|
|
|
259
287
|
/** The settled result of an instance's license key verification. */
|
|
@@ -287,7 +315,7 @@ export interface EventCell {
|
|
|
287
315
|
/**
|
|
288
316
|
* The definition's schema path, on cells that belong to one: a `row` cell
|
|
289
317
|
* carries its column's (`detail.columns[i]`), a `total-row` cell its
|
|
290
|
-
* `detail.total[i]` entry. Absent on page-band cells reached by closure.
|
|
318
|
+
* `detail.total[r].cells[i]` entry. Absent on page-band cells reached by closure.
|
|
291
319
|
*/
|
|
292
320
|
path?: string;
|
|
293
321
|
}
|
|
@@ -334,6 +362,16 @@ export interface ReportStartEvent {
|
|
|
334
362
|
* states it so no target has to know it.
|
|
335
363
|
*/
|
|
336
364
|
marking?: string;
|
|
365
|
+
/** The document's `page.margin`, when declared. */
|
|
366
|
+
margin?: number;
|
|
367
|
+
/** The report header's authored `height`, when the object form is used. */
|
|
368
|
+
headerHeight?: number;
|
|
369
|
+
/** Present when the instance was constructed with `locale`. */
|
|
370
|
+
locale?: string;
|
|
371
|
+
/** Present when the instance was constructed with `currency`. */
|
|
372
|
+
currency?: string;
|
|
373
|
+
/** Present when the instance was constructed with `timeZone`. */
|
|
374
|
+
timeZone?: string;
|
|
337
375
|
}
|
|
338
376
|
|
|
339
377
|
export interface ItemEvent {
|
|
@@ -408,6 +446,8 @@ export interface TableStartEvent {
|
|
|
408
446
|
/** Always `detail` — the table definition's schema path. */
|
|
409
447
|
path: string;
|
|
410
448
|
columns: { header: EventCell; path: string; width?: number }[];
|
|
449
|
+
/** The header-row box from `detail.header`, when declared. */
|
|
450
|
+
style?: Record<string, unknown>;
|
|
411
451
|
}
|
|
412
452
|
|
|
413
453
|
export interface RowEvent {
|
|
@@ -420,6 +460,8 @@ export interface RowEvent {
|
|
|
420
460
|
export interface TotalRowEvent {
|
|
421
461
|
type: "total-row";
|
|
422
462
|
cells: EventCell[];
|
|
463
|
+
/** The total-row box from that row's `style`, when declared. */
|
|
464
|
+
style?: Record<string, unknown>;
|
|
423
465
|
}
|
|
424
466
|
|
|
425
467
|
export interface TableEndEvent {
|
|
@@ -600,6 +642,17 @@ export function text(tokens: readonly Token[]): string;
|
|
|
600
642
|
*/
|
|
601
643
|
export function display(value: unknown): string;
|
|
602
644
|
|
|
645
|
+
/**
|
|
646
|
+
* Present a token for a resolved `format` kind. Returns nothing when the kind
|
|
647
|
+
* does not apply, so the caller falls back to `display()`. Locale defaults to
|
|
648
|
+
* `"en-US"`, dates to UTC.
|
|
649
|
+
*/
|
|
650
|
+
export function format(
|
|
651
|
+
value: unknown,
|
|
652
|
+
kind: unknown,
|
|
653
|
+
options?: { locale?: string; currency?: string; timeZone?: string } | null,
|
|
654
|
+
): string | undefined;
|
|
655
|
+
|
|
603
656
|
/**
|
|
604
657
|
* The typed-cell seam: exactly one value token holding a finite number, a
|
|
605
658
|
* boolean, or a valid Date keeps its pre-stringify value; anything else —
|
package/lib/index.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { relocate as relocateQuery } from "padvinder";
|
|
16
16
|
import { MARKING, verify } from "./license.js";
|
|
17
|
-
import { err, locate } from "./locate.js";
|
|
17
|
+
import { err, fault, locate } from "./locate.js";
|
|
18
18
|
import { BLOCKED, NAME, record } from "./names.js";
|
|
19
19
|
import { plan } from "./plan.js";
|
|
20
20
|
import { aggregateValue, startRunners, withPage, withRow } from "./scope.js";
|
|
@@ -24,6 +24,7 @@ import { opt } from "./stream.js";
|
|
|
24
24
|
// diagnostic predicate in ./locate.js. Re-exported here because a consumer
|
|
25
25
|
// imports them from the package, not from a file inside it.
|
|
26
26
|
export { breathe, display, isReportBand, text, typed, walk } from "./stream.js";
|
|
27
|
+
export { format } from "./format.js";
|
|
27
28
|
export { isDiagnostic } from "./locate.js";
|
|
28
29
|
|
|
29
30
|
/** @typedef {import("./scope.js").Scope} Scope */
|
|
@@ -126,7 +127,31 @@ function events(schema, funcs, options, marking) {
|
|
|
126
127
|
let planned = plan(schema, funcs, options);
|
|
127
128
|
if (planned.problems.length)
|
|
128
129
|
throw planned.diagnostic() || SyntaxError(planned.problems[0].message);
|
|
129
|
-
return assemble(planned, schema, marking);
|
|
130
|
+
return assemble(planned, schema, marking, options);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** @type {(event: any) => boolean} */
|
|
134
|
+
let occupying = (event) =>
|
|
135
|
+
event.type === "item" || event.type === "image" || event.type === "split-start";
|
|
136
|
+
/** @type {(n: any) => boolean} */
|
|
137
|
+
let nonzeroLead = (n) => Number.isFinite(n) && n !== 0;
|
|
138
|
+
/** @type {(event: any) => string} */
|
|
139
|
+
let eventPath = (event) => event.path || "header";
|
|
140
|
+
/** @type {(event: any) => void} */
|
|
141
|
+
let refuseLead = (event) => {
|
|
142
|
+
if (nonzeroLead(event.style?.spaceBefore))
|
|
143
|
+
fault(eventPath(event) + ".style.spaceBefore", "must be 0 after a height-declared header");
|
|
144
|
+
};
|
|
145
|
+
/** @type {(events: Iterable<any>) => Generator<any>} */
|
|
146
|
+
function* guardPin(events) {
|
|
147
|
+
let pending = true;
|
|
148
|
+
for (let event of events) {
|
|
149
|
+
if (pending && occupying(event)) {
|
|
150
|
+
pending = false;
|
|
151
|
+
refuseLead(event);
|
|
152
|
+
}
|
|
153
|
+
yield event;
|
|
154
|
+
}
|
|
130
155
|
}
|
|
131
156
|
|
|
132
157
|
/**
|
|
@@ -137,11 +162,14 @@ function events(schema, funcs, options, marking) {
|
|
|
137
162
|
* @param {ReturnType<typeof plan>} planned The finished traversal.
|
|
138
163
|
* @param {any} schema The report document, for locating `data` faults.
|
|
139
164
|
* @param {() => string | false} marking This render's unlicensed marking.
|
|
165
|
+
* @param {{query?: {maxNodes?: number, maxDepth?: number, maxResults?: number},
|
|
166
|
+
* locale?: string, currency?: string, timeZone?: string} | undefined} options
|
|
167
|
+
* Host controls closed over this compile.
|
|
140
168
|
* @returns {{(data?: any): Generator<object, void, undefined>, names: string[],
|
|
141
169
|
* functions: { name: string, arity: number, doc?: string }[],
|
|
142
170
|
* paths: readonly any[]}} Event stream factory.
|
|
143
171
|
*/
|
|
144
|
-
function assemble(planned, schema, marking) {
|
|
172
|
+
function assemble(planned, schema, marking, options) {
|
|
145
173
|
// Null only for a non-object schema, which is itself a collected problem.
|
|
146
174
|
// `select` and `params` come from the traversal too: it checked them, so
|
|
147
175
|
// nothing here compiles or clones a second time.
|
|
@@ -161,6 +189,8 @@ function assemble(planned, schema, marking) {
|
|
|
161
189
|
band,
|
|
162
190
|
pageHeader,
|
|
163
191
|
pageFooter,
|
|
192
|
+
headerHeight,
|
|
193
|
+
margin,
|
|
164
194
|
} = /** @type {NonNullable<typeof planned.compiled>} */ (planned.compiled);
|
|
165
195
|
|
|
166
196
|
// The runner's own faults are located per render: a budget is spent while
|
|
@@ -203,15 +233,29 @@ function assemble(planned, schema, marking) {
|
|
|
203
233
|
// only where it goes. Read as the event is yielded, so a render drained
|
|
204
234
|
// before verification settles counts as unlicensed (SCHEMA.md, "License
|
|
205
235
|
// keys").
|
|
236
|
+
/** @type {(options: any) => { locale?: string, currency?: string, timeZone?: string }} */
|
|
237
|
+
let intlOf = (host) => ({
|
|
238
|
+
locale: host?.locale,
|
|
239
|
+
currency: host?.currency,
|
|
240
|
+
timeZone: host?.timeZone,
|
|
241
|
+
});
|
|
206
242
|
/** @type {() => Generator<object, void, undefined>} */
|
|
207
243
|
function* walked() {
|
|
208
244
|
yield opt(
|
|
209
245
|
{ type: "report-start", params: root.params, aggregates },
|
|
210
|
-
{
|
|
246
|
+
{
|
|
247
|
+
page,
|
|
248
|
+
columns,
|
|
249
|
+
style: style(base),
|
|
250
|
+
marking: marking(),
|
|
251
|
+
...intlOf(options),
|
|
252
|
+
margin,
|
|
253
|
+
headerHeight,
|
|
254
|
+
},
|
|
211
255
|
);
|
|
212
256
|
yield* header(base);
|
|
213
|
-
|
|
214
|
-
|
|
257
|
+
let body = !rows.length && empty ? empty(base) : band(rows, base, runners);
|
|
258
|
+
yield* headerHeight != null ? guardPin(body) : body;
|
|
215
259
|
yield* footer(base);
|
|
216
260
|
yield { type: "report-end" };
|
|
217
261
|
}
|
|
@@ -236,8 +280,10 @@ function assemble(planned, schema, marking) {
|
|
|
236
280
|
* ("Instances and targets").
|
|
237
281
|
*
|
|
238
282
|
* @param {{query?: {maxNodes?: number, maxDepth?: number, maxResults?: number},
|
|
239
|
-
* license?: string, trust?: {publicKey?: string, release?: string}
|
|
283
|
+
* license?: string, trust?: {publicKey?: string, release?: string},
|
|
284
|
+
* locale?: string, currency?: string, timeZone?: string}} [options]
|
|
240
285
|
* Host configuration. `trust` is internal, not API — see ./license.js.
|
|
286
|
+
* `locale` / `currency` / `timeZone` present `format` (docs/adr/0041).
|
|
241
287
|
* @returns {{license: Promise<LicenseInfo>,
|
|
242
288
|
* report: (schema: any, funcs?: Record<string, Function>) => any,
|
|
243
289
|
* plan: (schema: any, funcs?: Record<string, Function>) => any}} The instance.
|
|
@@ -281,7 +327,7 @@ export function quario(options) {
|
|
|
281
327
|
plan(schema, funcs) {
|
|
282
328
|
let planned = plan(schema, funcs, options);
|
|
283
329
|
return {
|
|
284
|
-
report: planned.problems.length ? null : wrap(assemble(planned, schema, marking)),
|
|
330
|
+
report: planned.problems.length ? null : wrap(assemble(planned, schema, marking, options)),
|
|
285
331
|
problems: freezeProblems(planned.problems),
|
|
286
332
|
anchors: freezeAnchors(planned.anchors),
|
|
287
333
|
};
|
package/lib/license.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// reassigns them. A key is valid for every version released inside its window
|
|
12
12
|
// (LICENSE section 7), so validity compares ISO date strings and never reads
|
|
13
13
|
// a clock — accepted output stays accepted.
|
|
14
|
-
let RELEASE = "2026-09-
|
|
14
|
+
let RELEASE = "2026-09-02"; // x-release-please-date
|
|
15
15
|
// The verifying half of the signing pair: the 65-byte uncompressed P-256
|
|
16
16
|
// point, base64url. The private half never enters the repo;
|
|
17
17
|
// scripts/license/sign.mjs mints keys against it.
|
package/lib/plan.js
CHANGED
|
@@ -47,7 +47,16 @@ import { LOCATION, fault, isDiagnostic, locate } from "./locate.js";
|
|
|
47
47
|
import { ANCHORS, BLOCKED, NAME, RESERVED, record } from "./names.js";
|
|
48
48
|
import { AGG, REDUCERS, RUN, aggregateValue, withRow } from "./scope.js";
|
|
49
49
|
import { opt, sniff } from "./stream.js";
|
|
50
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
checkBorderSides,
|
|
52
|
+
checkCellStyle,
|
|
53
|
+
checkImageStyle,
|
|
54
|
+
checkReportStyle,
|
|
55
|
+
checkRowStyle,
|
|
56
|
+
checkSlotImageStyle,
|
|
57
|
+
checkSlotStyle,
|
|
58
|
+
checkStyle,
|
|
59
|
+
} from "./style.js";
|
|
51
60
|
|
|
52
61
|
// Tolerance on the column-width sum. Widths are literal numbers an author may
|
|
53
62
|
// write as thirds, so an exact comparison rejects 33.3 x 3 for a rounding error
|
|
@@ -563,6 +572,7 @@ let readers = ({ bad, attempt }, { prop, cell, parseFold }) => {
|
|
|
563
572
|
*/
|
|
564
573
|
let compileStyles = (block, path, check) => {
|
|
565
574
|
let entries = readStyles(block, path, check);
|
|
575
|
+
for (let [name, msg] of checkBorderSides(block, check)) bad(path + "." + name, msg);
|
|
566
576
|
if (!entries.length) return NIL;
|
|
567
577
|
let resolve = styleFn(entries);
|
|
568
578
|
if (isComputed(block)) return resolve;
|
|
@@ -725,7 +735,7 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
|
|
|
725
735
|
let cellOf = (def, path, valuePath = path + ".value") => {
|
|
726
736
|
let tpl = cellValue(def.value, valuePath);
|
|
727
737
|
let visible = visibleOf(def, path);
|
|
728
|
-
return cellShape(tpl, visible, stylesOf(def, path));
|
|
738
|
+
return cellShape(tpl, visible, stylesOf(def, path, checkCellStyle));
|
|
729
739
|
};
|
|
730
740
|
|
|
731
741
|
// A cell without a row, so `@` stays unbound. A bare string is shorthand for
|
|
@@ -756,7 +766,7 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
|
|
|
756
766
|
let header = headerOf(def.header, path + ".header");
|
|
757
767
|
let tpl = cellValue(def.value, path + ".value");
|
|
758
768
|
let visible = visibleOf(def, path);
|
|
759
|
-
let styles = stylesOf(def, path);
|
|
769
|
+
let styles = stylesOf(def, path, checkCellStyle);
|
|
760
770
|
// A width is a percentage of the table width, resolved by every target.
|
|
761
771
|
// The range verdict is owned here: an unreadable width comes back as NaN,
|
|
762
772
|
// so the total check below reads the verdict instead of re-deciding it.
|
|
@@ -780,7 +790,11 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
|
|
|
780
790
|
checkTextType(def.type, path);
|
|
781
791
|
let tpl = cellValue(def.value, path + ".value");
|
|
782
792
|
let visible = visibleOf(def, path);
|
|
783
|
-
let shape = cellShape(
|
|
793
|
+
let shape = cellShape(
|
|
794
|
+
tpl,
|
|
795
|
+
inSlot ? visible : null,
|
|
796
|
+
stylesOf(def, path, inSlot ? checkSlotStyle : checkStyle),
|
|
797
|
+
);
|
|
784
798
|
// The cell shape first, then the item's own fields, so a cell reads
|
|
785
799
|
// the same wherever it appears in the stream.
|
|
786
800
|
return (scope) => {
|
|
@@ -845,6 +859,8 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
|
|
|
845
859
|
// An image item: bytes an `=` expression yields, sized by `fit`, with an
|
|
846
860
|
// optional textual stand-in. JSON has no way to write bytes, so `source` is
|
|
847
861
|
// expression-only; the vocabulary an image accepts is `style.js`'s to narrow.
|
|
862
|
+
/** @type {(inSlot: boolean) => (name: string, value: any) => string | null} */
|
|
863
|
+
let imageLook = (inSlot) => (inSlot ? checkSlotImageStyle : checkImageStyle);
|
|
848
864
|
/** @type {(def: any, path: string, role: string, inSlot?: boolean) => (scope: Scope) => any} */
|
|
849
865
|
let imageOf = (def, path, role, inSlot = false) => {
|
|
850
866
|
keys(def, inSlot ? IMAGE_SLOT_KEYS : IMAGE_KEYS, path);
|
|
@@ -855,7 +871,7 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
|
|
|
855
871
|
// problems twice, and reading it early would report them out of order.
|
|
856
872
|
let alt = altOf(def, path);
|
|
857
873
|
let visible = visibleOf(def, path);
|
|
858
|
-
let styles = stylesOf(def, path,
|
|
874
|
+
let styles = stylesOf(def, path, imageLook(inSlot));
|
|
859
875
|
let render = renderImage({
|
|
860
876
|
source,
|
|
861
877
|
fit: def.fit ?? "natural",
|
|
@@ -1005,9 +1021,17 @@ let bands = (
|
|
|
1005
1021
|
keys(value.row, ["visible", "style"], "detail.row");
|
|
1006
1022
|
return {
|
|
1007
1023
|
rowVisible: visibleOf(value.row, "detail.row"),
|
|
1008
|
-
rowStyles: stylesOf(value.row, "detail.row"),
|
|
1024
|
+
rowStyles: stylesOf(value.row, "detail.row", checkRowStyle),
|
|
1009
1025
|
};
|
|
1010
1026
|
};
|
|
1027
|
+
// The header-row box: `style` only, a different path from each column's
|
|
1028
|
+
// `header` cell. Absent when the table declares none.
|
|
1029
|
+
/** @type {(value: any) => (scope: Scope) => any} */
|
|
1030
|
+
let headerLook = (value) => {
|
|
1031
|
+
if (value.header == null || !obj(value.header, "detail.header")) return NIL;
|
|
1032
|
+
keys(value.header, ["style"], "detail.header");
|
|
1033
|
+
return stylesOf(value.header, "detail.header", checkRowStyle);
|
|
1034
|
+
};
|
|
1011
1035
|
/** @type {(value: any) => { defs: any[] | null, columns: any[] }} */
|
|
1012
1036
|
let tableColumns = (value) => {
|
|
1013
1037
|
let defs = arr(value.columns, "detail.columns", true);
|
|
@@ -1018,24 +1042,51 @@ let bands = (
|
|
|
1018
1042
|
checkShares(bad, columns, "detail.columns", "column");
|
|
1019
1043
|
return { defs, columns };
|
|
1020
1044
|
};
|
|
1021
|
-
/** @type {(totals: any[]
|
|
1022
|
-
let
|
|
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) =>
|
|
1045
|
+
/** @type {(totals: any[], prefix: string) => any[]} */
|
|
1046
|
+
let mapTotals = (totals, prefix) =>
|
|
1028
1047
|
totals
|
|
1029
1048
|
.map((def, i) => {
|
|
1030
|
-
let
|
|
1031
|
-
|
|
1049
|
+
let path = prefix + "[" + i + "]";
|
|
1050
|
+
let cell = totalOf(def, path);
|
|
1051
|
+
return cell && { cell, path };
|
|
1032
1052
|
})
|
|
1033
1053
|
.filter((entry) => entry != null);
|
|
1034
|
-
/** @type {(
|
|
1035
|
-
let
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1054
|
+
/** @type {(rows: any[] | null) => any[]} */
|
|
1055
|
+
let nonempty = (rows) => {
|
|
1056
|
+
if (rows && !rows.length) bad("detail.total", "expected at least one total row");
|
|
1057
|
+
return rows || [];
|
|
1058
|
+
};
|
|
1059
|
+
/**
|
|
1060
|
+
* @type {(value: any, defs: any[] | null) =>
|
|
1061
|
+
* { styles: (scope: Scope) => any, visible: any, cells: any[] }[] | null}
|
|
1062
|
+
*/
|
|
1063
|
+
let tableTotals = (value, defs) =>
|
|
1064
|
+
value.total == null
|
|
1065
|
+
? null
|
|
1066
|
+
: nonempty(arr(value.total, "detail.total", true)).map((def, r) =>
|
|
1067
|
+
totalRowOf(def, "detail.total[" + r + "]", defs),
|
|
1068
|
+
);
|
|
1069
|
+
/** @type {(cells: any[], defs: any[] | null) => boolean} */
|
|
1070
|
+
let aligned = (cells, defs) => !defs || cells.length === defs.length;
|
|
1071
|
+
/** @type {(cells: any[] | null, defs: any[] | null, path: string) => any[]} */
|
|
1072
|
+
let counted = (cells, defs, path) => {
|
|
1073
|
+
if (!cells) return [];
|
|
1074
|
+
if (!aligned(cells, defs)) bad(path + ".cells", "expected one cell per column");
|
|
1075
|
+
return cells;
|
|
1076
|
+
};
|
|
1077
|
+
/**
|
|
1078
|
+
* @type {(def: any, path: string, defs: any[] | null) =>
|
|
1079
|
+
* { styles: (scope: Scope) => any, visible: any, cells: any[] }}
|
|
1080
|
+
*/
|
|
1081
|
+
let totalRowOf = (def, path, defs) => {
|
|
1082
|
+
if (!obj(def, path)) return { styles: NIL, visible: null, cells: [] };
|
|
1083
|
+
keys(def, ["cells", "style", "visible"], path);
|
|
1084
|
+
let cells = counted(arr(def.cells, path + ".cells", true), defs, path);
|
|
1085
|
+
return {
|
|
1086
|
+
styles: stylesOf(def, path, checkRowStyle),
|
|
1087
|
+
visible: visibleOf(def, path),
|
|
1088
|
+
cells: mapTotals(cells, path + ".cells"),
|
|
1089
|
+
};
|
|
1039
1090
|
};
|
|
1040
1091
|
/**
|
|
1041
1092
|
* @type {(rows: any[], scope: Scope, runners: RunnerSet, columns: any[],
|
|
@@ -1056,30 +1107,47 @@ let bands = (
|
|
|
1056
1107
|
);
|
|
1057
1108
|
}
|
|
1058
1109
|
}
|
|
1059
|
-
/**
|
|
1060
|
-
|
|
1110
|
+
/**
|
|
1111
|
+
* @type {(total: { styles: (scope: Scope) => any, visible: any, cells: any[] }[] | null,
|
|
1112
|
+
* scope: Scope) => Generator<any>}
|
|
1113
|
+
*/
|
|
1114
|
+
function* emitTotals(total, scope) {
|
|
1115
|
+
if (!total) return;
|
|
1116
|
+
for (let row of total) {
|
|
1117
|
+
if (hidden(row.visible, scope)) continue;
|
|
1118
|
+
yield opt(
|
|
1119
|
+
{
|
|
1120
|
+
type: "total-row",
|
|
1121
|
+
cells: row.cells.map(({ cell, path }) => Object.assign(cell(scope), { path })),
|
|
1122
|
+
},
|
|
1123
|
+
{ style: row.styles(scope) },
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
/** @type {(columns: any[], total: { styles: (scope: Scope) => any, visible: any, cells: any[] }[] | null, rowVisible: Eval | null, rowStyles: (scope: Scope) => any, headerStyles: (scope: Scope) => any) => Band} */
|
|
1128
|
+
let tableBand = (columns, total, rowVisible, rowStyles, headerStyles) =>
|
|
1061
1129
|
function* (rows, scope, runners) {
|
|
1062
|
-
yield
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1130
|
+
yield opt(
|
|
1131
|
+
{
|
|
1132
|
+
type: "table-start",
|
|
1133
|
+
path: "detail",
|
|
1134
|
+
columns: columns.map((column) =>
|
|
1135
|
+
opt({ header: column.header(scope), path: column.path }, { width: column.width }),
|
|
1136
|
+
),
|
|
1137
|
+
},
|
|
1138
|
+
{ style: headerStyles(scope) },
|
|
1139
|
+
);
|
|
1069
1140
|
yield* tableRows(rows, scope, runners, columns, rowVisible, rowStyles);
|
|
1070
|
-
|
|
1071
|
-
yield {
|
|
1072
|
-
type: "total-row",
|
|
1073
|
-
cells: total.map(({ cell, path }) => Object.assign(cell(scope), { path })),
|
|
1074
|
-
};
|
|
1141
|
+
yield* emitTotals(total, scope);
|
|
1075
1142
|
yield { type: "table-end" };
|
|
1076
1143
|
};
|
|
1077
1144
|
/** @type {(value: any) => Band} */
|
|
1078
1145
|
let tableOf = (value) => {
|
|
1079
|
-
keys(value, ["row", "columns", "total"], "detail");
|
|
1146
|
+
keys(value, ["header", "row", "columns", "total"], "detail");
|
|
1147
|
+
let headerStyles = headerLook(value);
|
|
1080
1148
|
let { rowVisible, rowStyles } = rowLook(value);
|
|
1081
1149
|
let { defs, columns } = tableColumns(value);
|
|
1082
|
-
return tableBand(columns, tableTotals(value, defs), rowVisible, rowStyles);
|
|
1150
|
+
return tableBand(columns, tableTotals(value, defs), rowVisible, rowStyles, headerStyles);
|
|
1083
1151
|
};
|
|
1084
1152
|
|
|
1085
1153
|
// The detail band is polymorphic: an array of items stacks them per row, a
|
|
@@ -1251,17 +1319,87 @@ let selectOf = (schema, bad, attempt, options) => {
|
|
|
1251
1319
|
};
|
|
1252
1320
|
/** @type {(value: any, path: string, expression: any) => any} */
|
|
1253
1321
|
let maybeExpr = (value, path, expression) => (value != null ? expression(value, path) : null);
|
|
1254
|
-
/** @type {(
|
|
1255
|
-
let
|
|
1322
|
+
/** @type {(n: any) => boolean} */
|
|
1323
|
+
let posPts = (n) => typeof n === "number" && Number.isFinite(n) && n > 0;
|
|
1324
|
+
/** @type {(n: any) => boolean} */
|
|
1325
|
+
let padPts = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0;
|
|
1326
|
+
/** @type {(height: any, bad: any) => number | null} */
|
|
1327
|
+
let headerHeightOf = (height, bad) => {
|
|
1328
|
+
if (height == null) bad("header.height", "required");
|
|
1329
|
+
else if (!posPts(height)) bad("header.height", "expected a positive number of points");
|
|
1330
|
+
return posPts(height) ? height : null;
|
|
1331
|
+
};
|
|
1332
|
+
/** @type {(itemsOf: any) => { items: any, height: number | null }} */
|
|
1333
|
+
let emptyHeader = (itemsOf) => ({ items: itemsOf(null, "header", "report-header"), height: null });
|
|
1334
|
+
/** @type {(value: any, keys: any, itemsOf: any, bad: any) => { items: any, height: number | null }} */
|
|
1335
|
+
let reportHeaderOf = (value, keys, itemsOf, bad) => {
|
|
1336
|
+
if (value == null || Array.isArray(value))
|
|
1337
|
+
return { items: itemsOf(value, "header", "report-header"), height: null };
|
|
1338
|
+
if (!record(value)) {
|
|
1339
|
+
bad("header", "expected an array of items or { height, items }");
|
|
1340
|
+
return emptyHeader(itemsOf);
|
|
1341
|
+
}
|
|
1342
|
+
keys(value, ["height", "items"], "header");
|
|
1343
|
+
return {
|
|
1344
|
+
items: itemsOf(value.items, "header.items", "report-header"),
|
|
1345
|
+
height: headerHeightOf(value.height, bad),
|
|
1346
|
+
};
|
|
1347
|
+
};
|
|
1348
|
+
|
|
1349
|
+
/** @type {(margin: any, bad: any) => number | null} */
|
|
1350
|
+
let pageMarginOf = (margin, bad) => {
|
|
1351
|
+
if (margin != null && !padPts(margin))
|
|
1352
|
+
bad("page.margin", "expected a non-negative number of points");
|
|
1353
|
+
return padPts(margin) ? margin : null;
|
|
1354
|
+
};
|
|
1355
|
+
/** @type {(schema: any, obj: any, keys: any, itemsOf: any, bad: any) => { pageHeader: any, pageFooter: any, margin: number | null }} */
|
|
1356
|
+
let pageBands = (schema, obj, keys, itemsOf, bad) => {
|
|
1256
1357
|
if (schema.page == null || !obj(schema.page, "page"))
|
|
1257
|
-
return { pageHeader: null, pageFooter: null };
|
|
1258
|
-
keys(schema.page, ["header", "footer"], "page");
|
|
1358
|
+
return { pageHeader: null, pageFooter: null, margin: null };
|
|
1359
|
+
keys(schema.page, ["header", "footer", "margin"], "page");
|
|
1259
1360
|
return {
|
|
1260
1361
|
pageHeader: maybeItems(schema.page.header, "page.header", "page-header", itemsOf),
|
|
1261
1362
|
pageFooter: maybeItems(schema.page.footer, "page.footer", "page-footer", itemsOf),
|
|
1363
|
+
margin: pageMarginOf(schema.page.margin, bad),
|
|
1262
1364
|
};
|
|
1263
1365
|
};
|
|
1264
1366
|
|
|
1367
|
+
/** @type {(def: any) => boolean} */
|
|
1368
|
+
let shown = (def) => !(def && def.visible === false);
|
|
1369
|
+
/** @type {(list: any, path: string) => [any, string] | null} */
|
|
1370
|
+
let firstOccupying = (list, path) => {
|
|
1371
|
+
if (!Array.isArray(list)) return null;
|
|
1372
|
+
let i = list.findIndex(shown);
|
|
1373
|
+
return i < 0 ? null : [list[i], path + "[" + i + "]"];
|
|
1374
|
+
};
|
|
1375
|
+
/** @type {(schema: any) => any} */
|
|
1376
|
+
let firstGroupHeader = (schema) => {
|
|
1377
|
+
let groups = schema.groups;
|
|
1378
|
+
return Array.isArray(groups) && groups[0] ? groups[0].header : null;
|
|
1379
|
+
};
|
|
1380
|
+
/** @type {(schema: any) => [any, string] | null} */
|
|
1381
|
+
let nextAfterHeader = (schema) =>
|
|
1382
|
+
firstOccupying(firstGroupHeader(schema), "groups[0].header") ||
|
|
1383
|
+
firstOccupying(schema.detail, "detail");
|
|
1384
|
+
/** @type {(n: any) => boolean} */
|
|
1385
|
+
let nonzeroLead = (n) => typeof n === "number" && n !== 0;
|
|
1386
|
+
/** @type {(next: [any, string], bad: any) => void} */
|
|
1387
|
+
let refuseLiteralLead = (next, bad) => {
|
|
1388
|
+
if (nonzeroLead(next[0].style?.spaceBefore))
|
|
1389
|
+
bad(next[1] + ".style.spaceBefore", "must be 0 after a height-declared header");
|
|
1390
|
+
};
|
|
1391
|
+
/** @type {(schema: any, headerHeight: number | null, bad: any) => void} */
|
|
1392
|
+
let refusePinnedLead = (schema, headerHeight, bad) => {
|
|
1393
|
+
if (headerHeight == null) return;
|
|
1394
|
+
let next = nextAfterHeader(schema);
|
|
1395
|
+
if (next) refuseLiteralLead(next, bad);
|
|
1396
|
+
};
|
|
1397
|
+
/** @type {(headerHeight: number | null, schema: any, bad: any) => void} */
|
|
1398
|
+
let needPinnedMargin = (headerHeight, schema, bad) => {
|
|
1399
|
+
if (headerHeight != null && schema.page?.margin == null)
|
|
1400
|
+
bad("page.margin", "required when header declares height");
|
|
1401
|
+
};
|
|
1402
|
+
|
|
1265
1403
|
/**
|
|
1266
1404
|
* The root descent, in the schema's documented key order. `params` and `data`
|
|
1267
1405
|
* are checked here the way every other key is — by compiling what they declare
|
|
@@ -1326,7 +1464,8 @@ let traverse = (
|
|
|
1326
1464
|
// `report-start` field instead of being merged into every item's style --
|
|
1327
1465
|
// nothing here allocates per cell (docs/adr/0033).
|
|
1328
1466
|
let style = stylesOf(schema, "", checkReportStyle);
|
|
1329
|
-
let header =
|
|
1467
|
+
let { items: header, height: headerHeight } = reportHeaderOf(schema.header, keys, itemsOf, bad);
|
|
1468
|
+
refusePinnedLead(schema, headerHeight, bad);
|
|
1330
1469
|
// Null when undeclared, so the render decides on the compiled band rather
|
|
1331
1470
|
// than reading `schema.empty` a second time from outside the traversal.
|
|
1332
1471
|
let empty = maybeItems(schema.empty, "empty", "empty", itemsOf);
|
|
@@ -1335,7 +1474,8 @@ let traverse = (
|
|
|
1335
1474
|
let footer = itemsOf(schema.footer, "footer", "report-footer");
|
|
1336
1475
|
// Page bands compile like any other band, but render per page, so the
|
|
1337
1476
|
// stream hands paginated targets closures instead of events.
|
|
1338
|
-
let { pageHeader, pageFooter } = pageBands(schema, obj, keys, itemsOf);
|
|
1477
|
+
let { pageHeader, pageFooter, margin } = pageBands(schema, obj, keys, itemsOf, bad);
|
|
1478
|
+
needPinnedMargin(headerHeight, schema, bad);
|
|
1339
1479
|
return {
|
|
1340
1480
|
select,
|
|
1341
1481
|
params,
|
|
@@ -1346,12 +1486,14 @@ let traverse = (
|
|
|
1346
1486
|
running,
|
|
1347
1487
|
style,
|
|
1348
1488
|
header,
|
|
1489
|
+
headerHeight,
|
|
1349
1490
|
empty,
|
|
1350
1491
|
columns,
|
|
1351
1492
|
footer,
|
|
1352
1493
|
band,
|
|
1353
1494
|
pageHeader,
|
|
1354
1495
|
pageFooter,
|
|
1496
|
+
margin,
|
|
1355
1497
|
};
|
|
1356
1498
|
};
|
|
1357
1499
|
|
package/lib/stream.js
CHANGED
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
export { display, text } from "sjabloon";
|
|
15
15
|
|
|
16
16
|
/** @type {(value: any) => boolean} */
|
|
17
|
-
let finiteNum = (value) => typeof value === "number" && Number.isFinite(value);
|
|
17
|
+
export let finiteNum = (value) => typeof value === "number" && Number.isFinite(value);
|
|
18
18
|
/** @type {(value: any) => boolean} */
|
|
19
|
-
let finiteDate = (value) => value instanceof Date && Number.isFinite(value.getTime());
|
|
19
|
+
export let finiteDate = (value) => value instanceof Date && Number.isFinite(value.getTime());
|
|
20
20
|
/** @type {(value: any) => number | boolean | Date | undefined} */
|
|
21
21
|
let asTyped = (value) => {
|
|
22
22
|
if (finiteNum(value)) return value;
|
package/lib/style.js
CHANGED
|
@@ -19,11 +19,14 @@ let spec = (msg, ok) => ({ ok, msg });
|
|
|
19
19
|
let COLOR = spec("expected a #rgb or #rrggbb color", isHex);
|
|
20
20
|
let FLAG = spec("expected a boolean", (value) => typeof value === "boolean");
|
|
21
21
|
let ALIGNMENTS = ["left", "center", "right"];
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
//
|
|
26
|
-
//
|
|
22
|
+
let LINES = ["solid", "dashed", "dotted"];
|
|
23
|
+
let SIDES = ["Top", "Right", "Bottom", "Left"];
|
|
24
|
+
let BORDER_PARTS = ["Width", "Style", "Color"];
|
|
25
|
+
// The declarations an image item accepts. Text names are refused; the box and
|
|
26
|
+
// flow spacing are legal, because a band image has a box and sits in the flow
|
|
27
|
+
// the way a text item does (SCHEMA.md, "Image item"). The subset lives beside
|
|
28
|
+
// the vocabulary it narrows, and `checkImageStyle` below is how the traversal
|
|
29
|
+
// asks for it -- it keeps no second list of its own.
|
|
27
30
|
let IMAGE_STYLES = ["align", "background"];
|
|
28
31
|
// The declarations a report default accepts. A report default states what the
|
|
29
32
|
// document is set in, so it carries only the two declarations that describe a
|
|
@@ -46,7 +49,30 @@ let STYLES = {
|
|
|
46
49
|
color: COLOR,
|
|
47
50
|
background: COLOR,
|
|
48
51
|
align: spec("expected left, center, or right", (value) => ALIGNMENTS.includes(value)),
|
|
52
|
+
format: spec("expected number, currency, percent, or date", (value) => FORMATS.includes(value)),
|
|
49
53
|
};
|
|
54
|
+
let FORMATS = ["number", "currency", "percent", "date"];
|
|
55
|
+
let POINTS = spec(
|
|
56
|
+
"expected a non-negative number of points",
|
|
57
|
+
(value) => finite(value) && value >= 0,
|
|
58
|
+
);
|
|
59
|
+
let LINE = spec("expected solid, dashed, or dotted", (value) => LINES.includes(value));
|
|
60
|
+
/** @type {Record<string, StyleSpec>} */
|
|
61
|
+
let BORDER = { Width: POINTS, Style: LINE, Color: COLOR };
|
|
62
|
+
for (let side of SIDES) {
|
|
63
|
+
STYLES["padding" + side] = POINTS;
|
|
64
|
+
IMAGE_STYLES.push("padding" + side);
|
|
65
|
+
for (let part of BORDER_PARTS) {
|
|
66
|
+
let name = "border" + side + part;
|
|
67
|
+
STYLES[name] = BORDER[part];
|
|
68
|
+
IMAGE_STYLES.push(name);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
let FLOW = ["spaceBefore", "spaceAfter"];
|
|
72
|
+
for (let name of FLOW) {
|
|
73
|
+
STYLES[name] = POINTS;
|
|
74
|
+
IMAGE_STYLES.push(name);
|
|
75
|
+
}
|
|
50
76
|
// One check for both the validating traversal and the compile path: an error
|
|
51
77
|
// message for a declaration, or null when it is acceptable (expressions defer
|
|
52
78
|
// to render).
|
|
@@ -62,6 +88,29 @@ export let checkStyle = (name, value) => {
|
|
|
62
88
|
return rule.ok(value) ? null : rule.msg;
|
|
63
89
|
};
|
|
64
90
|
|
|
91
|
+
// A border side is width, style, and colour together, or none. Incomplete
|
|
92
|
+
// literals are a definition error; a side that still has an expression defers
|
|
93
|
+
// to render, where an incomplete result contributes nothing rather than
|
|
94
|
+
// becoming a solid black stroke. Per-name checks run first; a side that
|
|
95
|
+
// already has a located problem is left alone so the author sees one cause.
|
|
96
|
+
/** @type {(present: string[]) => boolean} */
|
|
97
|
+
let incomplete = (present) => present.length > 0 && present.length < 3;
|
|
98
|
+
/** @type {(block: any, check: (name: string, value: any) => string | null, name: string) => boolean} */
|
|
99
|
+
let deferred = (block, check, name) => isExpr(block[name]) || !!check(name, block[name]);
|
|
100
|
+
|
|
101
|
+
/** @type {(block: any, check?: (name: string, value: any) => string | null) => [string, string][]} */
|
|
102
|
+
export let checkBorderSides = (block, check = checkStyle) => {
|
|
103
|
+
/** @type {[string, string][]} */
|
|
104
|
+
let out = [];
|
|
105
|
+
for (let side of SIDES) {
|
|
106
|
+
let names = BORDER_PARTS.map((part) => "border" + side + part);
|
|
107
|
+
let present = names.filter((name) => Object.hasOwn(block, name));
|
|
108
|
+
if (incomplete(present) && !present.some((name) => deferred(block, check, name)))
|
|
109
|
+
out.push([present[0], "border" + side + " needs width, style, and color together"]);
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
};
|
|
113
|
+
|
|
65
114
|
// The same check narrowed to a subset of the vocabulary, so a declaration a
|
|
66
115
|
// node cannot wear is refused as pointedly as an unknown name rather than
|
|
67
116
|
// silently doing nothing. A node that takes part of the vocabulary names the
|
|
@@ -79,3 +128,31 @@ export let checkImageStyle = narrowed(IMAGE_STYLES, "an image");
|
|
|
79
128
|
// "a report default" rather than the key's name: the key is `style` like every
|
|
80
129
|
// other one, so the message has to say which `style` refused it.
|
|
81
130
|
export let checkReportStyle = narrowed(REPORT_STYLES, "a report default");
|
|
131
|
+
|
|
132
|
+
// Flow spacing is a band-item pad. A table cell, a row box, and a split slot
|
|
133
|
+
// have no flow, so the names are refused there the way a text declaration is
|
|
134
|
+
// on an image (docs/adr/0037).
|
|
135
|
+
/** @type {Record<string, true>} */
|
|
136
|
+
let FLOW_NAMES = Object.fromEntries(FLOW.map((name) => [name, true]));
|
|
137
|
+
/**
|
|
138
|
+
* @type {(names: Record<string, true>, check: (name: string, value: any) => string | null, subject: string) =>
|
|
139
|
+
* (name: string, value: any) => string | null}
|
|
140
|
+
*/
|
|
141
|
+
let withoutNames = (names, check, subject) => (name, value) =>
|
|
142
|
+
Object.hasOwn(names, name)
|
|
143
|
+
? 'style "' + name + '" does not apply to ' + subject
|
|
144
|
+
: check(name, value);
|
|
145
|
+
/**
|
|
146
|
+
* @type {(check: (name: string, value: any) => string | null, subject: string) =>
|
|
147
|
+
* (name: string, value: any) => string | null}
|
|
148
|
+
*/
|
|
149
|
+
let withoutFlow = (check, subject) => withoutNames(FLOW_NAMES, check, subject);
|
|
150
|
+
|
|
151
|
+
export let checkCellStyle = withoutFlow(checkStyle, "a table cell");
|
|
152
|
+
export let checkSlotStyle = withoutFlow(checkStyle, "a split slot");
|
|
153
|
+
export let checkSlotImageStyle = withoutFlow(checkImageStyle, "a split slot");
|
|
154
|
+
// A row box has no cell value, so `format` is refused there the way flow
|
|
155
|
+
// spacing is — it is a presentation of a value, not of a band of cells.
|
|
156
|
+
/** @type {Record<string, true>} */
|
|
157
|
+
let ROW_NAMES = { ...FLOW_NAMES, format: true };
|
|
158
|
+
export let checkRowStyle = withoutNames(ROW_NAMES, checkStyle, "a table row");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "quario",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A tiny, runtime-neutral report engine — in the makings, not yet released",
|
|
5
5
|
"homepage": "https://getquario.com",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"sjabloon",
|
|
52
52
|
"padvinder"
|
|
53
53
|
],
|
|
54
|
-
"limit": "
|
|
54
|
+
"limit": "10 kB"
|
|
55
55
|
}
|
|
56
56
|
],
|
|
57
57
|
"engines": {
|