quario 0.2.0 → 0.4.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 +95 -0
- package/lib/format.js +67 -0
- package/lib/index.d.ts +116 -10
- package/lib/index.js +54 -8
- package/lib/license.js +1 -1
- package/lib/plan.js +184 -42
- package/lib/stream.js +69 -4
- package/lib/style.js +82 -5
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,101 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-09-03
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **`format: "date"` now reads a date string, not only a `Date`.** A JSON
|
|
15
|
+
document has no date type, so the kind could not be reached from parsed
|
|
16
|
+
data without reviving every date field by hand first. It now revives two
|
|
17
|
+
forms itself: a calendar date `2026-08-14`, and a timestamp naming its
|
|
18
|
+
offset (`2026-08-14T12:30:00Z` or `+02:00`). **This changes existing
|
|
19
|
+
output**: a cell declaring the kind over `"2026-08-14"` rendered
|
|
20
|
+
`2026-08-14` and now renders `14/8/2026` under an `en-IE` instance. A
|
|
21
|
+
zoneless `2026-08-14T00:00:00` is not read — it means local time, so it
|
|
22
|
+
would present a different day per machine — and neither is a loose
|
|
23
|
+
`14/8/2026`, a partial `2026-08`, a lowercase `t`/`z`, or a day that does
|
|
24
|
+
not exist. Anything unread renders as authored, as before.
|
|
25
|
+
|
|
26
|
+
The revival is the `Date` a host would have injected, timezone included: a
|
|
27
|
+
calendar date is UTC midnight, so under a western instance timezone it
|
|
28
|
+
presents as the previous day, exactly as `new Date("2026-08-14")` does.
|
|
29
|
+
Only `date` revives — `number`, `currency` and `percent` never read a
|
|
30
|
+
string, because JSON already carries numbers — and epoch milliseconds stay
|
|
31
|
+
a number under every kind.
|
|
32
|
+
|
|
33
|
+
- **`typed(tokens, kind?)` takes the cell's `format` kind.** Passing `"date"`
|
|
34
|
+
opts into the same revival, for consumers that write typed cells. The
|
|
35
|
+
one-argument call is unchanged.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- **The TypeScript declarations accept the box.** The per-side `padding*` and
|
|
40
|
+
`border*` names, and the table's `detail.header`, were validated by the
|
|
41
|
+
engine from 0.3.0 but were missing from the shipped declarations, so a
|
|
42
|
+
report declaring the padding or the border sides that release introduced
|
|
43
|
+
was rejected by the compiler as an unknown property and needed a cast to
|
|
44
|
+
get past it. They now type exactly as the engine reads them, on a band
|
|
45
|
+
image's `style` as well. Four names come with them, for annotating your own
|
|
46
|
+
helpers: `LineStyle` (`"solid" | "dashed" | "dotted"`), beside `Align` and
|
|
47
|
+
`FormatKind`; `TableHeaderBox`, the type of `detail.header`; and
|
|
48
|
+
`BoxDeclarations` with `Side`, the per-side names as one type, which
|
|
49
|
+
`StyleDeclarations` was built from without being nameable. Nothing
|
|
50
|
+
about rendering changes: a report that compiled through a cast produces the
|
|
51
|
+
same output without one.
|
|
52
|
+
|
|
53
|
+
## [0.3.0] - 2026-09-02
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
|
|
57
|
+
- **`format` is a closed style name.** `number` / `currency` / `percent` /
|
|
58
|
+
`date` on text items, column cells, headers, and totals. Not images, not
|
|
59
|
+
`row.style`, not the report default. Locale, currency code, and timezone
|
|
60
|
+
live on `quario({ locale, currency, timeZone })`. The public `format()`
|
|
61
|
+
helper is how targets present a kind; a kind on the wrong type contributes
|
|
62
|
+
nothing.
|
|
63
|
+
|
|
64
|
+
- **The report header may pin a `height` from the page top.** Dual-shaped
|
|
65
|
+
like `detail`: an item array, or `{ height, items }`. `page.margin` is a
|
|
66
|
+
document field (one number, all four sides), required with `height` and
|
|
67
|
+
legal without. Authored `spaceBefore` on the first occupying item of the
|
|
68
|
+
next band is refused.
|
|
69
|
+
|
|
70
|
+
- **`spaceBefore` / `spaceAfter` return as item flow spacing.** Blank space
|
|
71
|
+
before or after a band item, in points, including band images and splits as
|
|
72
|
+
band items. Adjacent gaps add. Table cells, `row.style`, headers, totals,
|
|
73
|
+
and split slots refuse the names. `spaceBefore` drops at a fresh body page
|
|
74
|
+
or strip top; page-band items keep it. Leading and inset stay cut.
|
|
75
|
+
|
|
76
|
+
- **Per-side padding and border on the closed style vocabulary.**
|
|
77
|
+
`paddingTop` / `Right` / `Bottom` / `Left` (points, ≥ 0) and, per side,
|
|
78
|
+
`border*Width`, `border*Style` (`solid` | `dashed` | `dotted`),
|
|
79
|
+
`border*Color` (`#rgb` / `#rrggbb`). A border side is all three names or
|
|
80
|
+
none; width `0` is no stroke; an incomplete literal is a definition error,
|
|
81
|
+
and an incomplete expression result at render contributes nothing rather
|
|
82
|
+
than a solid black stroke. The names are legal wherever `background` is,
|
|
83
|
+
including images, plus `row.style`. The report default still takes only
|
|
84
|
+
`family` and `size`. Column `%` widths are border-box.
|
|
85
|
+
|
|
86
|
+
- **`detail.header` is the header-row box.** `{ style }` only, a different
|
|
87
|
+
path from `columns[i].header`. It crosses the seam as `table-start.style`.
|
|
88
|
+
|
|
89
|
+
### Changed
|
|
90
|
+
|
|
91
|
+
- **A table total is N rows.** Before, `total` was one row: a flat cell
|
|
92
|
+
array or `{ style, cells }`. After, it is absent or a non-empty array of
|
|
93
|
+
`{ cells, style?, visible? }`. A one-row total is `[{ cells: [...] }]`.
|
|
94
|
+
`total: []` is a definition error. Paths are `detail.total[r].cells[i]`.
|
|
95
|
+
The stream yields one `total-row` per emitted row.
|
|
96
|
+
|
|
97
|
+
- **A visible text item occupies a line at its own `size`.** Empty display
|
|
98
|
+
used to take the report default's leading in PDF and collapse in HTML;
|
|
99
|
+
`"a\n\nb"` broke in PDF and collapsed to a space in HTML. A visible item
|
|
100
|
+
now occupies at least one line set in that item's `size`, and a literal
|
|
101
|
+
newline is a line break, on every target that can show a line. Empty
|
|
102
|
+
table cells stay contentless for height. This is not a spacing primitive;
|
|
103
|
+
`visible: false` is still how an item leaves the layout.
|
|
104
|
+
|
|
10
105
|
## [0.2.0] - 2026-09-01
|
|
11
106
|
|
|
12
107
|
### Added
|
package/lib/format.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
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, reviveDate } 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
|
+
// A string in an accepted RFC 3339 form revives to the Date a host would have
|
|
35
|
+
// injected, then presents like any other Date — including in the instance's
|
|
36
|
+
// timezone, so a calendar date under a western zone presents as the day
|
|
37
|
+
// before, exactly as an injected `new Date("2026-08-14")` does today. The two
|
|
38
|
+
// spellings never disagree, which is the point (ADR 0041).
|
|
39
|
+
/** @type {(value: any, locale: string, options: any) => string | undefined} */
|
|
40
|
+
let asDate = (value, locale, options) => {
|
|
41
|
+
let date = finiteDate(value) ? value : reviveDate(value);
|
|
42
|
+
return date
|
|
43
|
+
? new Intl.DateTimeFormat(locale, { timeZone: zoneOf(options) }).format(date)
|
|
44
|
+
: undefined;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** @type {Record<string, (value: any, locale: string, options: any) => string | undefined>} */
|
|
48
|
+
let KINDS = { number: asNumber, currency: asMoney, percent: asPercent, date: asDate };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {unknown} value The interpolation's pre-stringify token.
|
|
52
|
+
* @param {unknown} kind A resolved `format` declaration.
|
|
53
|
+
* @param {{ locale?: string, currency?: string, timeZone?: string } | null} [options]
|
|
54
|
+
* The instance's locale, currency code, and timezone.
|
|
55
|
+
* @returns {string | undefined} The presented text, or nothing when this
|
|
56
|
+
* kind does not apply.
|
|
57
|
+
*/
|
|
58
|
+
export function format(value, kind, options) {
|
|
59
|
+
if (typeof kind !== "string") return;
|
|
60
|
+
let present = KINDS[kind];
|
|
61
|
+
if (!present) return;
|
|
62
|
+
try {
|
|
63
|
+
return present(value, localeOf(options), options);
|
|
64
|
+
} catch {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -28,6 +28,40 @@ export type JsonValue =
|
|
|
28
28
|
| JsonValue[]
|
|
29
29
|
| { [key: string]: JsonValue };
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* The four sides, as the key fragments the names are built from. Not a value an
|
|
33
|
+
* author ever writes: only the pieces `padding<Side>` and `border<Side><Part>`
|
|
34
|
+
* are spelled out of it, and it is exported so the box below can be named.
|
|
35
|
+
*/
|
|
36
|
+
export type Side = "Top" | "Right" | "Bottom" | "Left";
|
|
37
|
+
|
|
38
|
+
/** The line a border side is drawn with. */
|
|
39
|
+
export type LineStyle = "solid" | "dashed" | "dotted";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The box: padding and border per side. Written as a mapping over the four
|
|
43
|
+
* sides, the way the runtime builds the names, so a side can never be spelled
|
|
44
|
+
* out in one place and forgotten in another.
|
|
45
|
+
*
|
|
46
|
+
* A border side is width, style, and colour together, or none — an incomplete
|
|
47
|
+
* literal side is a definition error the traversal reports, which no type can
|
|
48
|
+
* state. Padding is not flow spacing: `spaceBefore` / `spaceAfter` are blank
|
|
49
|
+
* space *between* items, and live on the vocabulary below.
|
|
50
|
+
*/
|
|
51
|
+
export type BoxDeclarations = {
|
|
52
|
+
/** Inset on that side, in points (>= 0). */
|
|
53
|
+
[S in Side as `padding${S}`]?: ExpressionValue<number>;
|
|
54
|
+
} & {
|
|
55
|
+
/** Stroke width on that side, in points (>= 0). `0` is a complete side that draws nothing. */
|
|
56
|
+
[S in Side as `border${S}Width`]?: ExpressionValue<number>;
|
|
57
|
+
} & {
|
|
58
|
+
/** Line style of that side. */
|
|
59
|
+
[S in Side as `border${S}Style`]?: ExpressionValue<LineStyle>;
|
|
60
|
+
} & {
|
|
61
|
+
/** Stroke color of that side, `#rgb`/`#rrggbb`. */
|
|
62
|
+
[S in Side as `border${S}Color`]?: ExpressionValue<string>;
|
|
63
|
+
};
|
|
64
|
+
|
|
31
65
|
/**
|
|
32
66
|
* The closed, target-neutral style vocabulary. Literal values are validated
|
|
33
67
|
* strictly (unknown names and mistyped literals are definition errors);
|
|
@@ -36,7 +70,7 @@ export type JsonValue =
|
|
|
36
70
|
* its own formatting model (the HTML target to inline CSS, the PDF target to
|
|
37
71
|
* faces, points, and rects).
|
|
38
72
|
*/
|
|
39
|
-
export interface StyleDeclarations {
|
|
73
|
+
export interface StyleDeclarations extends BoxDeclarations {
|
|
40
74
|
/** `'sans'` | `'serif'` | `'mono'`, or an embedded font family name. */
|
|
41
75
|
family?: ExpressionValue<string>;
|
|
42
76
|
/** Font size in points. */
|
|
@@ -53,6 +87,12 @@ export interface StyleDeclarations {
|
|
|
53
87
|
background?: ExpressionValue<string>;
|
|
54
88
|
/** Horizontal alignment within the cell. */
|
|
55
89
|
align?: ExpressionValue<Align>;
|
|
90
|
+
/** How a number or date is presented. Locale stays on the instance. */
|
|
91
|
+
format?: ExpressionValue<FormatKind>;
|
|
92
|
+
/** Blank space before this item, in points. */
|
|
93
|
+
spaceBefore?: ExpressionValue<number>;
|
|
94
|
+
/** Blank space after this item, in points. */
|
|
95
|
+
spaceAfter?: ExpressionValue<number>;
|
|
56
96
|
}
|
|
57
97
|
|
|
58
98
|
export interface SortKey {
|
|
@@ -64,6 +104,7 @@ export interface SortKey {
|
|
|
64
104
|
export type CellValue = string;
|
|
65
105
|
|
|
66
106
|
export type Align = "left" | "center" | "right";
|
|
107
|
+
export type FormatKind = "number" | "currency" | "percent" | "date";
|
|
67
108
|
|
|
68
109
|
export interface Cell {
|
|
69
110
|
value: CellValue;
|
|
@@ -80,12 +121,16 @@ export interface TextItem extends Cell {
|
|
|
80
121
|
export type ImageFit = "natural" | "width";
|
|
81
122
|
|
|
82
123
|
/**
|
|
83
|
-
* The declarations an image item accepts
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* own, so the
|
|
124
|
+
* The declarations an image item accepts: the box, plus the four names that
|
|
125
|
+
* are not about text. The rest of the vocabulary describes text, which an
|
|
126
|
+
* image does not have, so any other name on one is a definition error. A
|
|
127
|
+
* narrowing of the one vocabulary rather than a second list of its own, so the
|
|
128
|
+
* two cannot drift.
|
|
87
129
|
*/
|
|
88
|
-
export type ImageStyleDeclarations = Pick<
|
|
130
|
+
export type ImageStyleDeclarations = Pick<
|
|
131
|
+
StyleDeclarations,
|
|
132
|
+
"background" | "align" | "spaceBefore" | "spaceAfter" | keyof BoxDeclarations
|
|
133
|
+
>;
|
|
89
134
|
|
|
90
135
|
/**
|
|
91
136
|
* What a report default declares: the two declarations a typeface is made of.
|
|
@@ -153,10 +198,26 @@ export interface TableRow {
|
|
|
153
198
|
style?: StyleDeclarations;
|
|
154
199
|
}
|
|
155
200
|
|
|
201
|
+
export interface TotalRow {
|
|
202
|
+
cells: [Cell, ...Cell[]];
|
|
203
|
+
visible?: ExpressionValue<boolean>;
|
|
204
|
+
style?: StyleDeclarations;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The header row's own box — distinct from `TableHeader`, which is one
|
|
209
|
+
* column's header cell. This is the box the row wears, replayed with the
|
|
210
|
+
* header cells on every page continuation.
|
|
211
|
+
*/
|
|
212
|
+
export interface TableHeaderBox {
|
|
213
|
+
style?: StyleDeclarations;
|
|
214
|
+
}
|
|
215
|
+
|
|
156
216
|
export interface TableDetail {
|
|
217
|
+
header?: TableHeaderBox;
|
|
157
218
|
row?: TableRow;
|
|
158
219
|
columns: [TableColumn, ...TableColumn[]];
|
|
159
|
-
total?:
|
|
220
|
+
total?: [TotalRow, ...TotalRow[]];
|
|
160
221
|
}
|
|
161
222
|
|
|
162
223
|
export interface Group {
|
|
@@ -195,6 +256,8 @@ export interface Group {
|
|
|
195
256
|
export interface PageBands {
|
|
196
257
|
header?: Item[];
|
|
197
258
|
footer?: Item[];
|
|
259
|
+
/** Inset on all four sides, in points. Required when the report header declares `height`. */
|
|
260
|
+
margin?: number;
|
|
198
261
|
}
|
|
199
262
|
|
|
200
263
|
export interface ReportSchema {
|
|
@@ -216,7 +279,11 @@ export interface ReportSchema {
|
|
|
216
279
|
* declared `size` does not change a report header's headline size.
|
|
217
280
|
*/
|
|
218
281
|
style?: ReportStyleDeclarations;
|
|
219
|
-
|
|
282
|
+
/**
|
|
283
|
+
* Dual-shaped like `detail`: an item array, or `{ height, items }` when the
|
|
284
|
+
* header pins a flow box from the page top.
|
|
285
|
+
*/
|
|
286
|
+
header?: Item[] | { height: number; items: Item[] };
|
|
220
287
|
empty?: Item[];
|
|
221
288
|
/**
|
|
222
289
|
* Flow the report body in this many page columns (integer >= 2). Columned
|
|
@@ -254,6 +321,12 @@ export interface QuarioOptions {
|
|
|
254
321
|
* report this instance compiles marks its output as unlicensed.
|
|
255
322
|
*/
|
|
256
323
|
license?: string;
|
|
324
|
+
/** Locale for `format`. Defaults to `"en-US"` when a kind is presented. */
|
|
325
|
+
locale?: string;
|
|
326
|
+
/** Currency code for `format: "currency"`. Absent, that kind contributes nothing. */
|
|
327
|
+
currency?: string;
|
|
328
|
+
/** Timezone for `format: "date"`. Defaults to `"UTC"`. */
|
|
329
|
+
timeZone?: string;
|
|
257
330
|
}
|
|
258
331
|
|
|
259
332
|
/** The settled result of an instance's license key verification. */
|
|
@@ -287,7 +360,7 @@ export interface EventCell {
|
|
|
287
360
|
/**
|
|
288
361
|
* The definition's schema path, on cells that belong to one: a `row` cell
|
|
289
362
|
* 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.
|
|
363
|
+
* `detail.total[r].cells[i]` entry. Absent on page-band cells reached by closure.
|
|
291
364
|
*/
|
|
292
365
|
path?: string;
|
|
293
366
|
}
|
|
@@ -334,6 +407,16 @@ export interface ReportStartEvent {
|
|
|
334
407
|
* states it so no target has to know it.
|
|
335
408
|
*/
|
|
336
409
|
marking?: string;
|
|
410
|
+
/** The document's `page.margin`, when declared. */
|
|
411
|
+
margin?: number;
|
|
412
|
+
/** The report header's authored `height`, when the object form is used. */
|
|
413
|
+
headerHeight?: number;
|
|
414
|
+
/** Present when the instance was constructed with `locale`. */
|
|
415
|
+
locale?: string;
|
|
416
|
+
/** Present when the instance was constructed with `currency`. */
|
|
417
|
+
currency?: string;
|
|
418
|
+
/** Present when the instance was constructed with `timeZone`. */
|
|
419
|
+
timeZone?: string;
|
|
337
420
|
}
|
|
338
421
|
|
|
339
422
|
export interface ItemEvent {
|
|
@@ -408,6 +491,8 @@ export interface TableStartEvent {
|
|
|
408
491
|
/** Always `detail` — the table definition's schema path. */
|
|
409
492
|
path: string;
|
|
410
493
|
columns: { header: EventCell; path: string; width?: number }[];
|
|
494
|
+
/** The header-row box from `detail.header`, when declared. */
|
|
495
|
+
style?: Record<string, unknown>;
|
|
411
496
|
}
|
|
412
497
|
|
|
413
498
|
export interface RowEvent {
|
|
@@ -420,6 +505,8 @@ export interface RowEvent {
|
|
|
420
505
|
export interface TotalRowEvent {
|
|
421
506
|
type: "total-row";
|
|
422
507
|
cells: EventCell[];
|
|
508
|
+
/** The total-row box from that row's `style`, when declared. */
|
|
509
|
+
style?: Record<string, unknown>;
|
|
423
510
|
}
|
|
424
511
|
|
|
425
512
|
export interface TableEndEvent {
|
|
@@ -600,12 +687,31 @@ export function text(tokens: readonly Token[]): string;
|
|
|
600
687
|
*/
|
|
601
688
|
export function display(value: unknown): string;
|
|
602
689
|
|
|
690
|
+
/**
|
|
691
|
+
* Present a token for a resolved `format` kind. Returns nothing when the kind
|
|
692
|
+
* does not apply, so the caller falls back to `display()`. Locale defaults to
|
|
693
|
+
* `"en-US"`, dates to UTC.
|
|
694
|
+
*/
|
|
695
|
+
export function format(
|
|
696
|
+
value: unknown,
|
|
697
|
+
kind: unknown,
|
|
698
|
+
options?: { locale?: string; currency?: string; timeZone?: string } | null,
|
|
699
|
+
): string | undefined;
|
|
700
|
+
|
|
603
701
|
/**
|
|
604
702
|
* The typed-cell seam: exactly one value token holding a finite number, a
|
|
605
703
|
* boolean, or a valid Date keeps its pre-stringify value; anything else —
|
|
606
704
|
* including a lone null — reports `undefined` and joins to display text.
|
|
705
|
+
*
|
|
706
|
+
* Passing the cell's resolved `format` kind opts into the seam's one
|
|
707
|
+
* coercion: under `"date"`, a string in either accepted RFC 3339 form —
|
|
708
|
+
* `YYYY-MM-DD`, or a timestamp naming its offset — revives to the Date it
|
|
709
|
+
* names. Omitting the argument leaves the seam as it was.
|
|
607
710
|
*/
|
|
608
|
-
export function typed(
|
|
711
|
+
export function typed(
|
|
712
|
+
tokens: readonly Token[],
|
|
713
|
+
kind?: unknown,
|
|
714
|
+
): number | boolean | Date | undefined;
|
|
609
715
|
|
|
610
716
|
/**
|
|
611
717
|
* Whether a band item's `role` names one of the report's own bands — its
|
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-03"; // 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;
|
|
@@ -24,6 +24,61 @@ let asTyped = (value) => {
|
|
|
24
24
|
if (finiteDate(value)) return value;
|
|
25
25
|
return undefined;
|
|
26
26
|
};
|
|
27
|
+
// The seam's one coercion, kept behind the declared kind (ADR 0041).
|
|
28
|
+
/** @type {(value: any, kind: unknown) => Date | undefined} */
|
|
29
|
+
let asDeclared = (value, kind) => (kind === "date" ? reviveDate(value) : undefined);
|
|
30
|
+
|
|
31
|
+
// The two RFC 3339 forms a JSON document conventionally carries a date in:
|
|
32
|
+
// a calendar date, and a timestamp that names its offset. A zoneless
|
|
33
|
+
// `2026-08-14T00:00:00` is deliberately absent — ECMAScript reads it as local
|
|
34
|
+
// time, so the same document would present a different day per machine, which
|
|
35
|
+
// ADR 0025 rules out. Uppercase `T`/`Z` only, for the same reason: the lower
|
|
36
|
+
// case RFC 3339 permits is outside the ECMAScript format and parses at the
|
|
37
|
+
// engine's discretion.
|
|
38
|
+
let RFC3339 =
|
|
39
|
+
/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2})))?$/;
|
|
40
|
+
|
|
41
|
+
/** @type {(year: string, month: string) => number} */
|
|
42
|
+
let monthLength = (year, month) => new Date(Date.UTC(+year, +month, 0)).getUTCDate();
|
|
43
|
+
// An absent group is the timestamp half of an optional match, not a breach.
|
|
44
|
+
/** @type {(bound: [string | undefined, number, number]) => boolean} */
|
|
45
|
+
let within = ([n, lo, hi]) => n === undefined || (+n >= lo && +n <= hi);
|
|
46
|
+
// A non-string never matches: `exec` would coerce one, and a single-element
|
|
47
|
+
// array stringifies to its element, which would revive an array as a date.
|
|
48
|
+
/** @type {(value: any) => RegExpExecArray | null} */
|
|
49
|
+
let rfc3339 = (value) => (typeof value === "string" ? RFC3339.exec(value) : null);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Revive a date string the way a host would have revived it — the accepted
|
|
53
|
+
* forms are exactly those `new Date(string)` reads identically on every
|
|
54
|
+
* machine, so declaring `format: "date"` and injecting `Date` values into the
|
|
55
|
+
* render data are two spellings of one document (ADR 0041).
|
|
56
|
+
*
|
|
57
|
+
* The components are range-checked here rather than left to the parser:
|
|
58
|
+
* ECMAScript rolls an out-of-range day over (`2026-02-30` becomes 2 March)
|
|
59
|
+
* where a date that does not exist should revive nothing at all. A leap
|
|
60
|
+
* second (`:60`) is refused on the same ground — RFC 3339 admits it and
|
|
61
|
+
* ECMAScript does not, so it would be a second rollover.
|
|
62
|
+
*
|
|
63
|
+
* @type {(value: any) => Date | undefined}
|
|
64
|
+
*/
|
|
65
|
+
export let reviveDate = (value) => {
|
|
66
|
+
let parts = rfc3339(value);
|
|
67
|
+
if (!parts) return undefined;
|
|
68
|
+
let [, year, month, day, hour, minute, second, offsetHour, offsetMinute] = parts;
|
|
69
|
+
/** @type {[string | undefined, number, number][]} */
|
|
70
|
+
let bounds = [
|
|
71
|
+
[month, 1, 12],
|
|
72
|
+
[day, 1, monthLength(year, month)],
|
|
73
|
+
[hour, 0, 23],
|
|
74
|
+
[minute, 0, 59],
|
|
75
|
+
[second, 0, 59],
|
|
76
|
+
[offsetHour, 0, 23],
|
|
77
|
+
[offsetMinute, 0, 59],
|
|
78
|
+
];
|
|
79
|
+
let date = bounds.every(within) ? new Date(value) : new Date(NaN);
|
|
80
|
+
return finiteDate(date) ? date : undefined;
|
|
81
|
+
};
|
|
27
82
|
|
|
28
83
|
/**
|
|
29
84
|
* The typed-cell seam: exactly one value token holding a finite number, a
|
|
@@ -31,13 +86,23 @@ let asTyped = (value) => {
|
|
|
31
86
|
* including a lone null, matching the display-text treatment everywhere
|
|
32
87
|
* else — reports `undefined` and joins to text.
|
|
33
88
|
*
|
|
89
|
+
* Passing the cell's declared `format` kind opts into the one coercion the
|
|
90
|
+
* seam performs: under `"date"`, a string in either accepted RFC 3339 form
|
|
91
|
+
* revives to the `Date` it names. The gate is the declaration, and the kind
|
|
92
|
+
* is `date` alone, because a date is the one type a JSON document cannot
|
|
93
|
+
* carry — a number arrives as a number, so no other kind has anything to
|
|
94
|
+
* revive (ADR 0041). Callers that read no style omit the argument and see
|
|
95
|
+
* the seam exactly as it was.
|
|
96
|
+
*
|
|
34
97
|
* @param {any[]} tokens A cell's tokens.
|
|
98
|
+
* @param {unknown} [kind] The cell's resolved `format` declaration.
|
|
35
99
|
* @returns {number | boolean | Date | undefined} The native value, if any.
|
|
36
100
|
*/
|
|
37
|
-
export function typed(tokens) {
|
|
101
|
+
export function typed(tokens, kind) {
|
|
38
102
|
let [token] = tokens;
|
|
39
103
|
if (tokens.length !== 1 || !("value" in token)) return undefined;
|
|
40
|
-
|
|
104
|
+
// `??` and not `||`: a boolean cell's own `false` is a typed value.
|
|
105
|
+
return asTyped(token.value) ?? asDeclared(token.value, kind);
|
|
41
106
|
}
|
|
42
107
|
|
|
43
108
|
// The report's own bands, by the role their items wear (CONTEXT.md, "Band").
|
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.4.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": {
|