quario 0.3.0 → 0.5.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 +96 -5
- package/lib/format.js +11 -4
- package/lib/index.d.ts +104 -10
- package/lib/index.js +1 -0
- package/lib/license.js +1 -1
- package/lib/plan.js +224 -66
- package/lib/stream.js +65 -2
- package/lib/style.js +112 -8
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,98 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.0] - 2026-09-05
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`STYLE_NAMES`**, every name in the style vocabulary as a read-only list,
|
|
15
|
+
in the order the specification's table lists them. A tool that offers the
|
|
16
|
+
vocabulary — the editor's style rail — reads it from here instead of
|
|
17
|
+
carrying a copy that has to learn each new name.
|
|
18
|
+
- **`span` lets a cell cover several table columns**, so a label reaches across
|
|
19
|
+
them instead of being pushed sideways by empty cells. A literal positive
|
|
20
|
+
integer; absent is 1. It is legal on the two cells a document writes: a
|
|
21
|
+
column's `header`, where the columns it covers omit their own `header`, and a
|
|
22
|
+
total row's cells, whose spans must sum to the column count exactly. A data
|
|
23
|
+
row's cells are the columns' own, so there is no cell there to carry one — a
|
|
24
|
+
line that runs across the table is a `split` item below it. A span covers and
|
|
25
|
+
does not vote: a cell over more than one column has no say in their widths,
|
|
26
|
+
and a column nothing votes on takes the cell-padding floor.
|
|
27
|
+
- **`valign`** joins the style vocabulary: `"top"`, `"middle"` or `"bottom"`,
|
|
28
|
+
a literal or an `=` expression like `align`. It is legal exactly where a box
|
|
29
|
+
is taller than its content asked for — table cells, headers, totals, `row`,
|
|
30
|
+
and split slots, text or image — and a definition error on a stacked item and
|
|
31
|
+
on a band image, whose boxes have no such slack. A row's or a split's layers
|
|
32
|
+
under its cells' or slots' own. Undeclared is not a declaration: each target
|
|
33
|
+
keeps its own default.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- **A table row's `style` now resolves onto that row's cells**, the box
|
|
38
|
+
included, instead of meaning something different on every output. Before,
|
|
39
|
+
padding and border on `detail.header`, `detail.row` or a total row's `style`
|
|
40
|
+
reached no cell at all: a fragment dropped them, a worksheet turned them into
|
|
41
|
+
an edge on each cell, and a page drew one box around the row. Now they layer
|
|
42
|
+
under each cell's own, the way `bold` and `valign` on a row already did, so
|
|
43
|
+
one declaration means one thing everywhere.
|
|
44
|
+
What this makes possible is declining the cell padding a row at a time:
|
|
45
|
+
`"paddingTop": 0, "paddingBottom": 0` on `detail.row` sets the pitch for
|
|
46
|
+
every data row, where before it had to be written on each cell.
|
|
47
|
+
Two things move for documents that already declared a box on a row.
|
|
48
|
+
A row's `borderLeft` is now an edge on each of the row's cells rather than
|
|
49
|
+
one at the row's outer left — write it on the first column's cells to get the
|
|
50
|
+
single edge back. And a row's border now occupies height, as a cell's border
|
|
51
|
+
always has, so a bordered row is taller by its border's width.
|
|
52
|
+
A border side is won whole by the cell: a cell naming any of that side's
|
|
53
|
+
three keys takes the side entirely, so a cell that failed soft on one name
|
|
54
|
+
does not inherit the row's other two.
|
|
55
|
+
On the event stream, a `row`, `total-row` or `table-start` `style` now
|
|
56
|
+
carries only what layers by ordinary means, and a row whose whole block was
|
|
57
|
+
box carries no `style` at all.
|
|
58
|
+
|
|
59
|
+
## [0.4.0] - 2026-09-03
|
|
60
|
+
|
|
61
|
+
### Changed
|
|
62
|
+
|
|
63
|
+
- **`format: "date"` now reads a date string, not only a `Date`.** A JSON
|
|
64
|
+
document has no date type, so the kind could not be reached from parsed
|
|
65
|
+
data without reviving every date field by hand first. It now revives two
|
|
66
|
+
forms itself: a calendar date `2026-08-14`, and a timestamp naming its
|
|
67
|
+
offset (`2026-08-14T12:30:00Z` or `+02:00`). **This changes existing
|
|
68
|
+
output**: a cell declaring the kind over `"2026-08-14"` rendered
|
|
69
|
+
`2026-08-14` and now renders `14/8/2026` under an `en-IE` instance. A
|
|
70
|
+
zoneless `2026-08-14T00:00:00` is not read — it means local time, so it
|
|
71
|
+
would present a different day per machine — and neither is a loose
|
|
72
|
+
`14/8/2026`, a partial `2026-08`, a lowercase `t`/`z`, or a day that does
|
|
73
|
+
not exist. Anything unread renders as authored, as before.
|
|
74
|
+
|
|
75
|
+
The revival is the `Date` a host would have injected, timezone included: a
|
|
76
|
+
calendar date is UTC midnight, so under a western instance timezone it
|
|
77
|
+
presents as the previous day, exactly as `new Date("2026-08-14")` does.
|
|
78
|
+
Only `date` revives — `number`, `currency` and `percent` never read a
|
|
79
|
+
string, because JSON already carries numbers — and epoch milliseconds stay
|
|
80
|
+
a number under every kind.
|
|
81
|
+
|
|
82
|
+
- **`typed(tokens, kind?)` takes the cell's `format` kind.** Passing `"date"`
|
|
83
|
+
opts into the same revival, for consumers that write typed cells. The
|
|
84
|
+
one-argument call is unchanged.
|
|
85
|
+
|
|
86
|
+
### Fixed
|
|
87
|
+
|
|
88
|
+
- **The TypeScript declarations accept the box.** The per-side `padding*` and
|
|
89
|
+
`border*` names, and the table's `detail.header`, were validated by the
|
|
90
|
+
engine from 0.3.0 but were missing from the shipped declarations, so a
|
|
91
|
+
report declaring the padding or the border sides that release introduced
|
|
92
|
+
was rejected by the compiler as an unknown property and needed a cast to
|
|
93
|
+
get past it. They now type exactly as the engine reads them, on a band
|
|
94
|
+
image's `style` as well. Four names come with them, for annotating your own
|
|
95
|
+
helpers: `LineStyle` (`"solid" | "dashed" | "dotted"`), beside `Align` and
|
|
96
|
+
`FormatKind`; `TableHeaderBox`, the type of `detail.header`; and
|
|
97
|
+
`BoxDeclarations` with `Side`, the per-side names as one type, which
|
|
98
|
+
`StyleDeclarations` was built from without being nameable. Nothing
|
|
99
|
+
about rendering changes: a report that compiled through a cast produces the
|
|
100
|
+
same output without one.
|
|
101
|
+
|
|
10
102
|
## [0.3.0] - 2026-09-02
|
|
11
103
|
|
|
12
104
|
### Added
|
|
@@ -16,20 +108,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
16
108
|
`row.style`, not the report default. Locale, currency code, and timezone
|
|
17
109
|
live on `quario({ locale, currency, timeZone })`. The public `format()`
|
|
18
110
|
helper is how targets present a kind; a kind on the wrong type contributes
|
|
19
|
-
nothing
|
|
111
|
+
nothing.
|
|
20
112
|
|
|
21
113
|
- **The report header may pin a `height` from the page top.** Dual-shaped
|
|
22
114
|
like `detail`: an item array, or `{ height, items }`. `page.margin` is a
|
|
23
115
|
document field (one number, all four sides), required with `height` and
|
|
24
116
|
legal without. Authored `spaceBefore` on the first occupying item of the
|
|
25
|
-
next band is refused
|
|
117
|
+
next band is refused.
|
|
26
118
|
|
|
27
119
|
- **`spaceBefore` / `spaceAfter` return as item flow spacing.** Blank space
|
|
28
120
|
before or after a band item, in points, including band images and splits as
|
|
29
121
|
band items. Adjacent gaps add. Table cells, `row.style`, headers, totals,
|
|
30
122
|
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`).
|
|
123
|
+
or strip top; page-band items keep it. Leading and inset stay cut.
|
|
33
124
|
|
|
34
125
|
- **Per-side padding and border on the closed style vocabulary.**
|
|
35
126
|
`paddingTop` / `Right` / `Bottom` / `Left` (points, ≥ 0) and, per side,
|
|
@@ -50,7 +141,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
50
141
|
array or `{ style, cells }`. After, it is absent or a non-empty array of
|
|
51
142
|
`{ cells, style?, visible? }`. A one-row total is `[{ cells: [...] }]`.
|
|
52
143
|
`total: []` is a definition error. Paths are `detail.total[r].cells[i]`.
|
|
53
|
-
The stream yields one `total-row` per emitted row
|
|
144
|
+
The stream yields one `total-row` per emitted row.
|
|
54
145
|
|
|
55
146
|
- **A visible text item occupies a line at its own `size`.** Empty display
|
|
56
147
|
used to take the report default's leading in PDF and collapse in HTML;
|
package/lib/format.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Defaults (`en-US`, UTC) are pinned so a PDF without a host locale still
|
|
9
9
|
* renders the same bytes on every machine (docs/adr/0041, docs/adr/0025).
|
|
10
10
|
*/
|
|
11
|
-
import { finiteDate, finiteNum } from "./stream.js";
|
|
11
|
+
import { finiteDate, finiteNum, reviveDate } from "./stream.js";
|
|
12
12
|
/** @type {(options: any) => string} */
|
|
13
13
|
let localeOf = (options) => options?.locale || "en-US";
|
|
14
14
|
/** @type {(options: any) => string} */
|
|
@@ -31,11 +31,18 @@ let asMoney = (value, locale, options) => {
|
|
|
31
31
|
if (!finiteNum(value) || !currency) return;
|
|
32
32
|
return new Intl.NumberFormat(locale, { style: "currency", currency }).format(value);
|
|
33
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).
|
|
34
39
|
/** @type {(value: any, locale: string, options: any) => string | undefined} */
|
|
35
|
-
let asDate = (value, locale, options) =>
|
|
36
|
-
finiteDate(value)
|
|
37
|
-
|
|
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)
|
|
38
44
|
: undefined;
|
|
45
|
+
};
|
|
39
46
|
|
|
40
47
|
/** @type {Record<string, (value: any, locale: string, options: any) => string | undefined>} */
|
|
41
48
|
let KINDS = { number: asNumber, currency: asMoney, percent: asPercent, date: asDate };
|
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
|
+
/**
|
|
91
|
+
* Vertical alignment within a box taller than its content: a table cell or
|
|
92
|
+
* row, a split slot (text or image). A definition error on a stacked item
|
|
93
|
+
* and on a band image, whose boxes have no such slack.
|
|
94
|
+
*/
|
|
95
|
+
valign?: ExpressionValue<VAlign>;
|
|
56
96
|
/** How a number or date is presented. Locale stays on the instance. */
|
|
57
97
|
format?: ExpressionValue<FormatKind>;
|
|
58
98
|
/** Blank space before this item, in points. */
|
|
@@ -70,6 +110,7 @@ export interface SortKey {
|
|
|
70
110
|
export type CellValue = string;
|
|
71
111
|
|
|
72
112
|
export type Align = "left" | "center" | "right";
|
|
113
|
+
export type VAlign = "top" | "middle" | "bottom";
|
|
73
114
|
export type FormatKind = "number" | "currency" | "percent" | "date";
|
|
74
115
|
|
|
75
116
|
export interface Cell {
|
|
@@ -87,14 +128,15 @@ export interface TextItem extends Cell {
|
|
|
87
128
|
export type ImageFit = "natural" | "width";
|
|
88
129
|
|
|
89
130
|
/**
|
|
90
|
-
* The declarations an image item accepts
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* own, so the
|
|
131
|
+
* The declarations an image item accepts: the box, plus the four names that
|
|
132
|
+
* are not about text. The rest of the vocabulary describes text, which an
|
|
133
|
+
* image does not have, so any other name on one is a definition error. A
|
|
134
|
+
* narrowing of the one vocabulary rather than a second list of its own, so the
|
|
135
|
+
* two cannot drift.
|
|
94
136
|
*/
|
|
95
137
|
export type ImageStyleDeclarations = Pick<
|
|
96
138
|
StyleDeclarations,
|
|
97
|
-
"background" | "align" | "spaceBefore" | "spaceAfter"
|
|
139
|
+
"background" | "align" | "spaceBefore" | "spaceAfter" | keyof BoxDeclarations
|
|
98
140
|
>;
|
|
99
141
|
|
|
100
142
|
/**
|
|
@@ -150,10 +192,19 @@ export interface TableHeader {
|
|
|
150
192
|
value: CellValue;
|
|
151
193
|
/** Literal only — never an expression. */
|
|
152
194
|
style?: StyleDeclarations;
|
|
195
|
+
/**
|
|
196
|
+
* How many columns this header covers — a literal integer >= 1, never an
|
|
197
|
+
* expression; absent is 1. The columns it covers omit their own `header`.
|
|
198
|
+
*/
|
|
199
|
+
span?: number;
|
|
153
200
|
}
|
|
154
201
|
|
|
155
202
|
export interface TableColumn extends Cell {
|
|
156
|
-
|
|
203
|
+
/**
|
|
204
|
+
* Absent exactly when a neighbour's header `span` covers this column: every
|
|
205
|
+
* column's header slot is filled once, by its own or by a span.
|
|
206
|
+
*/
|
|
207
|
+
header?: string | TableHeader;
|
|
157
208
|
/** Column width as a percentage of the table width (0 < width <= 100). */
|
|
158
209
|
width?: number;
|
|
159
210
|
}
|
|
@@ -163,13 +214,35 @@ export interface TableRow {
|
|
|
163
214
|
style?: StyleDeclarations;
|
|
164
215
|
}
|
|
165
216
|
|
|
217
|
+
/**
|
|
218
|
+
* One cell of a total row: an ordinary cell, plus the columns it covers. The
|
|
219
|
+
* spans in a row must sum to the column count exactly.
|
|
220
|
+
*/
|
|
221
|
+
export type TotalCell = Cell & {
|
|
222
|
+
/**
|
|
223
|
+
* How many columns this cell covers — a literal integer >= 1, never an
|
|
224
|
+
* expression; absent is 1.
|
|
225
|
+
*/
|
|
226
|
+
span?: number;
|
|
227
|
+
};
|
|
228
|
+
|
|
166
229
|
export interface TotalRow {
|
|
167
|
-
cells: [
|
|
230
|
+
cells: [TotalCell, ...TotalCell[]];
|
|
168
231
|
visible?: ExpressionValue<boolean>;
|
|
169
232
|
style?: StyleDeclarations;
|
|
170
233
|
}
|
|
171
234
|
|
|
235
|
+
/**
|
|
236
|
+
* The header row's own box — distinct from `TableHeader`, which is one
|
|
237
|
+
* column's header cell. This is the box the row wears, replayed with the
|
|
238
|
+
* header cells on every page continuation.
|
|
239
|
+
*/
|
|
240
|
+
export interface TableHeaderBox {
|
|
241
|
+
style?: StyleDeclarations;
|
|
242
|
+
}
|
|
243
|
+
|
|
172
244
|
export interface TableDetail {
|
|
245
|
+
header?: TableHeaderBox;
|
|
173
246
|
row?: TableRow;
|
|
174
247
|
columns: [TableColumn, ...TableColumn[]];
|
|
175
248
|
total?: [TotalRow, ...TotalRow[]];
|
|
@@ -312,6 +385,11 @@ export type Token = LiteralToken | ValueToken;
|
|
|
312
385
|
export interface EventCell {
|
|
313
386
|
tokens: Token[];
|
|
314
387
|
style?: Record<string, unknown>;
|
|
388
|
+
/**
|
|
389
|
+
* How many columns this cell covers, present only when more than one: a
|
|
390
|
+
* span of 1 is the absence of one and never reaches the stream.
|
|
391
|
+
*/
|
|
392
|
+
span?: number;
|
|
315
393
|
/**
|
|
316
394
|
* The definition's schema path, on cells that belong to one: a `row` cell
|
|
317
395
|
* carries its column's (`detail.columns[i]`), a `total-row` cell its
|
|
@@ -445,7 +523,8 @@ export interface TableStartEvent {
|
|
|
445
523
|
type: "table-start";
|
|
446
524
|
/** Always `detail` — the table definition's schema path. */
|
|
447
525
|
path: string;
|
|
448
|
-
|
|
526
|
+
/** One entry per column. `header` is absent when a neighbour's span covers it. */
|
|
527
|
+
columns: { header?: EventCell; path: string; width?: number }[];
|
|
449
528
|
/** The header-row box from `detail.header`, when declared. */
|
|
450
529
|
style?: Record<string, unknown>;
|
|
451
530
|
}
|
|
@@ -657,8 +736,16 @@ export function format(
|
|
|
657
736
|
* The typed-cell seam: exactly one value token holding a finite number, a
|
|
658
737
|
* boolean, or a valid Date keeps its pre-stringify value; anything else —
|
|
659
738
|
* including a lone null — reports `undefined` and joins to display text.
|
|
739
|
+
*
|
|
740
|
+
* Passing the cell's resolved `format` kind opts into the seam's one
|
|
741
|
+
* coercion: under `"date"`, a string in either accepted RFC 3339 form —
|
|
742
|
+
* `YYYY-MM-DD`, or a timestamp naming its offset — revives to the Date it
|
|
743
|
+
* names. Omitting the argument leaves the seam as it was.
|
|
660
744
|
*/
|
|
661
|
-
export function typed(
|
|
745
|
+
export function typed(
|
|
746
|
+
tokens: readonly Token[],
|
|
747
|
+
kind?: unknown,
|
|
748
|
+
): number | boolean | Date | undefined;
|
|
662
749
|
|
|
663
750
|
/**
|
|
664
751
|
* Whether a band item's `role` names one of the report's own bands — its
|
|
@@ -668,3 +755,10 @@ export function typed(tokens: readonly Token[]): number | boolean | Date | undef
|
|
|
668
755
|
* one: the question is whose band a role names, not what a walk can hand over.
|
|
669
756
|
*/
|
|
670
757
|
export function isReportBand(role: string | undefined): boolean;
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Every name in the closed style vocabulary, in the order SCHEMA.md's table
|
|
761
|
+
* lists them. A tool that offers the vocabulary imports this rather than
|
|
762
|
+
* keeping a copy.
|
|
763
|
+
*/
|
|
764
|
+
export const STYLE_NAMES: readonly string[];
|
package/lib/index.js
CHANGED
|
@@ -25,6 +25,7 @@ import { opt } from "./stream.js";
|
|
|
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
27
|
export { format } from "./format.js";
|
|
28
|
+
export { STYLE_NAMES } from "./style.js";
|
|
28
29
|
export { isDiagnostic } from "./locate.js";
|
|
29
30
|
|
|
30
31
|
/** @typedef {import("./scope.js").Scope} Scope */
|
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-05"; // 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
|
@@ -54,8 +54,11 @@ import {
|
|
|
54
54
|
checkReportStyle,
|
|
55
55
|
checkRowStyle,
|
|
56
56
|
checkSlotImageStyle,
|
|
57
|
+
checkItemStyle,
|
|
57
58
|
checkSlotStyle,
|
|
58
59
|
checkStyle,
|
|
60
|
+
fanBox,
|
|
61
|
+
isBoxName,
|
|
59
62
|
} from "./style.js";
|
|
60
63
|
|
|
61
64
|
// Tolerance on the column-width sum. Widths are literal numbers an author may
|
|
@@ -67,11 +70,29 @@ let WIDTH_EPS = 1e-9;
|
|
|
67
70
|
// plan rather than riding the state a plan accumulates.
|
|
68
71
|
/** @type {any} */
|
|
69
72
|
let NIL = () => null;
|
|
73
|
+
// The two halves a table row's `style` compiles to, and the pair a row that
|
|
74
|
+
// declares none wears.
|
|
75
|
+
/** @typedef {{ box: (scope: Scope) => any, rest: (scope: Scope) => any }} RowStyles */
|
|
76
|
+
/** @type {RowStyles} */
|
|
77
|
+
let NO_ROW_STYLE = { box: NIL, rest: NIL };
|
|
70
78
|
/** @type {any} */
|
|
71
79
|
let EMPTY = () => [];
|
|
72
80
|
// What `$.params` reads when a report declares none.
|
|
73
81
|
let NONE = Object.freeze({});
|
|
74
82
|
|
|
83
|
+
// How many table columns one cell covers. A literal, for the same reason a
|
|
84
|
+
// column `width` is: the column geometry is allocated before any row is laid
|
|
85
|
+
// out, so it cannot depend on one. Absent is 1, and an unreadable span reads
|
|
86
|
+
// as 1 so the coverage walk above it reports where the author actually went
|
|
87
|
+
// wrong instead of cascading. `spanned` is the emit side: a span of 1 is the
|
|
88
|
+
// absence of one, and never reaches the stream.
|
|
89
|
+
/** @type {(span: any) => number} */
|
|
90
|
+
let spanValue = (span) => (Number.isInteger(span) && span >= 1 ? span : 1);
|
|
91
|
+
/** @type {(span: any) => boolean} */
|
|
92
|
+
let validSpan = (span) => span == null || spanValue(span) === span;
|
|
93
|
+
/** @type {(span: number) => number} */
|
|
94
|
+
let spanned = (span) => (span > 1 ? span : 0);
|
|
95
|
+
|
|
75
96
|
/** @type {(value: any) => boolean} */
|
|
76
97
|
let isExpr = (value) => typeof value === "string" && value[0] === "=";
|
|
77
98
|
/** @type {(from: Iterable<string>, into: Set<string>) => void} */
|
|
@@ -564,21 +585,56 @@ let readers = ({ bad, attempt }, { prop, cell, parseFold }) => {
|
|
|
564
585
|
for (let [name, value] of entries) style[name] = value(scope);
|
|
565
586
|
return style;
|
|
566
587
|
};
|
|
567
|
-
/** @type {(block: any) => boolean} */
|
|
568
|
-
let isComputed = (block) => Object.values(block).some(isExpr);
|
|
569
588
|
/**
|
|
570
589
|
* @type {(block: any, path: string, check: (name: string, value: any) => string | null)
|
|
571
590
|
* => (scope: Scope) => any}
|
|
572
591
|
*/
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
592
|
+
// A compiled block, constant-folded when nothing in it defers to render, so
|
|
593
|
+
// a literal style is resolved once and every node that wears it is handed
|
|
594
|
+
// the same frozen object.
|
|
595
|
+
/** @type {(block: any, entries: [string, Eval][]) => (scope: Scope) => any} */
|
|
596
|
+
let folded = (block, entries) => {
|
|
576
597
|
if (!entries.length) return NIL;
|
|
577
598
|
let resolve = styleFn(entries);
|
|
578
|
-
if (
|
|
599
|
+
if (entries.some(([name]) => isExpr(block[name]))) return resolve;
|
|
579
600
|
let constant = Object.freeze(resolve({}));
|
|
580
601
|
return () => constant;
|
|
581
602
|
};
|
|
603
|
+
/**
|
|
604
|
+
* @type {(block: any, path: string, check: (name: string, value: any) => string | null)
|
|
605
|
+
* => [string, Eval][]}
|
|
606
|
+
*/
|
|
607
|
+
let styleEntries = (block, path, check) => {
|
|
608
|
+
let entries = readStyles(block, path, check);
|
|
609
|
+
for (let [name, msg] of checkBorderSides(block, check)) bad(path + "." + name, msg);
|
|
610
|
+
return entries;
|
|
611
|
+
};
|
|
612
|
+
/**
|
|
613
|
+
* @type {(block: any, path: string, check: (name: string, value: any) => string | null)
|
|
614
|
+
* => (scope: Scope) => any}
|
|
615
|
+
*/
|
|
616
|
+
let compileStyles = (block, path, check) => folded(block, styleEntries(block, path, check));
|
|
617
|
+
// A table row's block is split where it is compiled rather than at every
|
|
618
|
+
// row: its box half resolves onto the row's cells and the rest stays on the
|
|
619
|
+
// row (SCHEMA.md, "Style declarations"). Both halves fold like any other
|
|
620
|
+
// block, so a literal row style is partitioned once per table.
|
|
621
|
+
/**
|
|
622
|
+
* @type {(block: any, path: string, check: (name: string, value: any) => string | null)
|
|
623
|
+
* => RowStyles}
|
|
624
|
+
*/
|
|
625
|
+
let compileRowStyles = (block, path, check) => {
|
|
626
|
+
let entries = styleEntries(block, path, check);
|
|
627
|
+
return {
|
|
628
|
+
box: folded(
|
|
629
|
+
block,
|
|
630
|
+
entries.filter(([name]) => isBoxName(name)),
|
|
631
|
+
),
|
|
632
|
+
rest: folded(
|
|
633
|
+
block,
|
|
634
|
+
entries.filter(([name]) => !isBoxName(name)),
|
|
635
|
+
),
|
|
636
|
+
};
|
|
637
|
+
};
|
|
582
638
|
/**
|
|
583
639
|
* @type {(def: any, path: string, check?: (name: string, value: any) => string | null)
|
|
584
640
|
* => (scope: Scope) => any}
|
|
@@ -589,6 +645,14 @@ let readers = ({ bad, attempt }, { prop, cell, parseFold }) => {
|
|
|
589
645
|
if (block == null || !obj(block, stylePath)) return NIL;
|
|
590
646
|
return compileStyles(block, stylePath, check);
|
|
591
647
|
};
|
|
648
|
+
/** The same, for the three blocks that sit on a table row. */
|
|
649
|
+
/** @type {(def: any, path: string) => RowStyles} */
|
|
650
|
+
let rowStylesOf = (def, path) => {
|
|
651
|
+
let block = def.style;
|
|
652
|
+
let stylePath = pathTo(path, "style");
|
|
653
|
+
if (block == null || !obj(block, stylePath)) return NO_ROW_STYLE;
|
|
654
|
+
return compileRowStyles(block, stylePath, checkRowStyle);
|
|
655
|
+
};
|
|
592
656
|
// A cell value: one sjabloon template. Emphasis is the cell's own `style`;
|
|
593
657
|
// markup in literal text is meaningful only to the HTML target.
|
|
594
658
|
/** @type {(value: any, path: string) => (scope: Scope, opts?: any) => any} */
|
|
@@ -703,6 +767,7 @@ let readers = ({ bad, attempt }, { prop, cell, parseFold }) => {
|
|
|
703
767
|
columnsOf,
|
|
704
768
|
takeOf,
|
|
705
769
|
stylesOf,
|
|
770
|
+
rowStylesOf,
|
|
706
771
|
cellValue,
|
|
707
772
|
foldsOf,
|
|
708
773
|
sortOf,
|
|
@@ -738,17 +803,53 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
|
|
|
738
803
|
return cellShape(tpl, visible, stylesOf(def, path, checkCellStyle));
|
|
739
804
|
};
|
|
740
805
|
|
|
806
|
+
/** @type {(def: any, path: string) => number} */
|
|
807
|
+
let spanOf = (def, path) => {
|
|
808
|
+
if (validSpan(def.span)) return spanValue(def.span);
|
|
809
|
+
bad(path + ".span", "expected a positive integer (span >= 1)");
|
|
810
|
+
return 1;
|
|
811
|
+
};
|
|
812
|
+
|
|
741
813
|
// A cell without a row, so `@` stays unbound. A bare string is shorthand for
|
|
742
814
|
// `{ value }` and keeps its authored path, so problems locate where written.
|
|
743
|
-
|
|
815
|
+
// A span needs the object form -- a bare string has nowhere to carry one.
|
|
816
|
+
// Absent is a problem here: a column that legitimately declares no header is
|
|
817
|
+
// one a neighbour's span covers, and `headerSlot` answers that before this
|
|
818
|
+
// is reached.
|
|
819
|
+
/**
|
|
820
|
+
* @type {(def: any, path: string) => { cell: (scope: Scope) => any, span: number }}
|
|
821
|
+
*/
|
|
744
822
|
let headerOf = (def, path) => {
|
|
745
|
-
if (typeof def === "string")
|
|
823
|
+
if (typeof def === "string")
|
|
824
|
+
return { cell: cellShape(cellValue(def, path), null, NIL), span: 1 };
|
|
746
825
|
if (!record(def)) {
|
|
747
826
|
bad(path, "expected a template string or object");
|
|
748
|
-
return cellShape(EMPTY, null, NIL);
|
|
827
|
+
return { cell: cellShape(EMPTY, null, NIL), span: 1 };
|
|
749
828
|
}
|
|
750
|
-
keys(def, ["value", "style"], path);
|
|
751
|
-
return cellOf(def, path);
|
|
829
|
+
keys(def, ["value", "style", "span"], path);
|
|
830
|
+
return { cell: cellOf(def, path), span: spanOf(def, path) };
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
// A column a neighbour's span covers declares no header of its own. One that
|
|
834
|
+
// declares one anyway is the overlap half of the coverage rule.
|
|
835
|
+
/** @type {(def: any, path: string) => null} */
|
|
836
|
+
let covered = (def, path) => {
|
|
837
|
+
if (def != null) bad(path, "covered by an earlier header's span");
|
|
838
|
+
return null;
|
|
839
|
+
};
|
|
840
|
+
// The per-column half of the coverage rule. `headerCover` decides which slot
|
|
841
|
+
// a column is in; this reports it, in the column's own turn, so header
|
|
842
|
+
// problems still come out in the documented key order.
|
|
843
|
+
/**
|
|
844
|
+
* @type {(def: any, path: string, slot: string) =>
|
|
845
|
+
* { cell: (scope: Scope) => any, span: number } | null}
|
|
846
|
+
*/
|
|
847
|
+
let headerSlot = (def, path, slot) => {
|
|
848
|
+
if (slot === "unreached") return null;
|
|
849
|
+
if (slot === "covered") return covered(def, path);
|
|
850
|
+
let header = headerOf(def, path);
|
|
851
|
+
if (slot === "over") bad(path + ".span", "reaches past the last column");
|
|
852
|
+
return header;
|
|
752
853
|
};
|
|
753
854
|
|
|
754
855
|
/** @type {(width: any) => boolean} */
|
|
@@ -759,11 +860,11 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
|
|
|
759
860
|
bad(path + ".width", "expected a percentage number (0 < width <= 100)");
|
|
760
861
|
return NaN;
|
|
761
862
|
};
|
|
762
|
-
/** @type {(def: any, path: string) => any} */
|
|
763
|
-
let columnOf = (def, path) => {
|
|
863
|
+
/** @type {(def: any, path: string, slot: string) => any} */
|
|
864
|
+
let columnOf = (def, path, slot) => {
|
|
764
865
|
if (!obj(def, path)) return null;
|
|
765
866
|
keys(def, ["header", "value", "visible", "style", "width"], path);
|
|
766
|
-
let header =
|
|
867
|
+
let header = headerSlot(def.header, path + ".header", slot);
|
|
767
868
|
let tpl = cellValue(def.value, path + ".value");
|
|
768
869
|
let visible = visibleOf(def, path);
|
|
769
870
|
let styles = stylesOf(def, path, checkCellStyle);
|
|
@@ -773,11 +874,14 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
|
|
|
773
874
|
return { header, cell: cellShape(tpl, visible, styles), width: widthOf(def.width, path), path };
|
|
774
875
|
};
|
|
775
876
|
|
|
776
|
-
/**
|
|
877
|
+
/**
|
|
878
|
+
* @type {(def: any, path: string) =>
|
|
879
|
+
* { cell: (scope: Scope) => any, span: number } | null}
|
|
880
|
+
*/
|
|
777
881
|
let totalOf = (def, path) => {
|
|
778
882
|
if (!obj(def, path)) return null;
|
|
779
|
-
keys(def, ["value", "visible", "style"], path);
|
|
780
|
-
return cellOf(def, path);
|
|
883
|
+
keys(def, ["value", "visible", "style", "span"], path);
|
|
884
|
+
return { cell: cellOf(def, path), span: spanOf(def, path) };
|
|
781
885
|
};
|
|
782
886
|
|
|
783
887
|
// A text item: a cell with no slot to keep, so a hidden one drops itself
|
|
@@ -793,7 +897,7 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
|
|
|
793
897
|
let shape = cellShape(
|
|
794
898
|
tpl,
|
|
795
899
|
inSlot ? visible : null,
|
|
796
|
-
stylesOf(def, path, inSlot ? checkSlotStyle :
|
|
900
|
+
stylesOf(def, path, inSlot ? checkSlotStyle : checkItemStyle),
|
|
797
901
|
);
|
|
798
902
|
// The cell shape first, then the item's own fields, so a cell reads
|
|
799
903
|
// the same wherever it appears in the stream.
|
|
@@ -998,7 +1102,7 @@ let nodes = ({ bad }, { arr, obj, keys, expression, cellValue, visibleOf, styles
|
|
|
998
1102
|
*/
|
|
999
1103
|
let bands = (
|
|
1000
1104
|
{ bad, runNames },
|
|
1001
|
-
{ arr, obj, keys, named, expression, visibleOf, columnsOf, takeOf,
|
|
1105
|
+
{ arr, obj, keys, named, expression, visibleOf, columnsOf, takeOf, rowStylesOf, foldsOf, sortOf },
|
|
1002
1106
|
{ columnOf, itemsOf, totalOf },
|
|
1003
1107
|
) => {
|
|
1004
1108
|
// Stacked items, one pass per row.
|
|
@@ -1014,30 +1118,71 @@ let bands = (
|
|
|
1014
1118
|
}
|
|
1015
1119
|
};
|
|
1016
1120
|
|
|
1017
|
-
/** @type {(value: any) => { rowVisible: Eval | null, rowStyles:
|
|
1121
|
+
/** @type {(value: any) => { rowVisible: Eval | null, rowStyles: RowStyles }} */
|
|
1018
1122
|
let rowLook = (value) => {
|
|
1019
1123
|
if (value.row == null || !obj(value.row, "detail.row"))
|
|
1020
|
-
return { rowVisible: null, rowStyles:
|
|
1124
|
+
return { rowVisible: null, rowStyles: NO_ROW_STYLE };
|
|
1021
1125
|
keys(value.row, ["visible", "style"], "detail.row");
|
|
1022
1126
|
return {
|
|
1023
1127
|
rowVisible: visibleOf(value.row, "detail.row"),
|
|
1024
|
-
rowStyles:
|
|
1128
|
+
rowStyles: rowStylesOf(value.row, "detail.row"),
|
|
1025
1129
|
};
|
|
1026
1130
|
};
|
|
1027
1131
|
// The header-row box: `style` only, a different path from each column's
|
|
1028
1132
|
// `header` cell. Absent when the table declares none.
|
|
1029
|
-
/** @type {(value: any) =>
|
|
1133
|
+
/** @type {(value: any) => RowStyles} */
|
|
1030
1134
|
let headerLook = (value) => {
|
|
1031
|
-
if (value.header == null || !obj(value.header, "detail.header")) return
|
|
1135
|
+
if (value.header == null || !obj(value.header, "detail.header")) return NO_ROW_STYLE;
|
|
1032
1136
|
keys(value.header, ["style"], "detail.header");
|
|
1033
|
-
return
|
|
1137
|
+
return rowStylesOf(value.header, "detail.header");
|
|
1138
|
+
};
|
|
1139
|
+
// Which slot each column's header sits in -- the same invariant a total row's
|
|
1140
|
+
// cells carry, walked positionally rather than summed because the diagnostic
|
|
1141
|
+
// has to name the column it broke at. Read off the raw definitions, before
|
|
1142
|
+
// any column is parsed, so `columnOf` can report its verdict in the column's
|
|
1143
|
+
// own turn. Spans are read leniently: an unreadable one covers a single
|
|
1144
|
+
// column and gets its own diagnostic when the column is parsed.
|
|
1145
|
+
//
|
|
1146
|
+
// `own` fills its own slot, `covered` is filled by a neighbour's span, `over`
|
|
1147
|
+
// reaches past the last column, and everything after an `over` is
|
|
1148
|
+
// `unreached` -- one broken span should not also report every column behind
|
|
1149
|
+
// it as headerless.
|
|
1150
|
+
// How many columns a definition's header claims, read leniently: a malformed
|
|
1151
|
+
// column, a missing header and an unreadable span all claim one here, and
|
|
1152
|
+
// each gets its own diagnostic when the column is parsed.
|
|
1153
|
+
/** @type {(def: any) => number} */
|
|
1154
|
+
let headerSpan = (def) => {
|
|
1155
|
+
let header = record(def) ? def.header : null;
|
|
1156
|
+
return record(header) ? spanValue(header.span) : 1;
|
|
1157
|
+
};
|
|
1158
|
+
/** @type {(slots: string[], from: number, to: number, slot: string) => void} */
|
|
1159
|
+
let fill = (slots, from, to, slot) => {
|
|
1160
|
+
for (let at = from; at < to; at++) slots[at] = slot;
|
|
1161
|
+
};
|
|
1162
|
+
/** @type {(defs: any[]) => string[]} */
|
|
1163
|
+
let headerCover = (defs) => {
|
|
1164
|
+
let slots = defs.map(() => "own");
|
|
1165
|
+
let at = 0;
|
|
1166
|
+
while (at < defs.length) {
|
|
1167
|
+
let span = headerSpan(defs[at]);
|
|
1168
|
+
if (at + span > defs.length) {
|
|
1169
|
+
slots[at] = "over";
|
|
1170
|
+
fill(slots, at + 1, defs.length, "unreached");
|
|
1171
|
+
return slots;
|
|
1172
|
+
}
|
|
1173
|
+
fill(slots, at + 1, at + span, "covered");
|
|
1174
|
+
at += span;
|
|
1175
|
+
}
|
|
1176
|
+
return slots;
|
|
1034
1177
|
};
|
|
1035
1178
|
/** @type {(value: any) => { defs: any[] | null, columns: any[] }} */
|
|
1036
1179
|
let tableColumns = (value) => {
|
|
1037
1180
|
let defs = arr(value.columns, "detail.columns", true);
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1181
|
+
let list = defs || [];
|
|
1182
|
+
if (defs && !list.length) bad("detail.columns", "expected at least one column");
|
|
1183
|
+
let slots = headerCover(list);
|
|
1184
|
+
let columns = list
|
|
1185
|
+
.map((def, i) => columnOf(def, "detail.columns[" + i + "]", slots[i]))
|
|
1041
1186
|
.filter((column) => column != null);
|
|
1042
1187
|
checkShares(bad, columns, "detail.columns", "column");
|
|
1043
1188
|
return { defs, columns };
|
|
@@ -1047,8 +1192,8 @@ let bands = (
|
|
|
1047
1192
|
totals
|
|
1048
1193
|
.map((def, i) => {
|
|
1049
1194
|
let path = prefix + "[" + i + "]";
|
|
1050
|
-
let
|
|
1051
|
-
return
|
|
1195
|
+
let total = totalOf(def, path);
|
|
1196
|
+
return total && { cell: total.cell, span: total.span, path };
|
|
1052
1197
|
})
|
|
1053
1198
|
.filter((entry) => entry != null);
|
|
1054
1199
|
/** @type {(rows: any[] | null) => any[]} */
|
|
@@ -1058,7 +1203,7 @@ let bands = (
|
|
|
1058
1203
|
};
|
|
1059
1204
|
/**
|
|
1060
1205
|
* @type {(value: any, defs: any[] | null) =>
|
|
1061
|
-
* { styles:
|
|
1206
|
+
* { styles: RowStyles, visible: any, cells: any[] }[] | null}
|
|
1062
1207
|
*/
|
|
1063
1208
|
let tableTotals = (value, defs) =>
|
|
1064
1209
|
value.total == null
|
|
@@ -1066,76 +1211,91 @@ let bands = (
|
|
|
1066
1211
|
: nonempty(arr(value.total, "detail.total", true)).map((def, r) =>
|
|
1067
1212
|
totalRowOf(def, "detail.total[" + r + "]", defs),
|
|
1068
1213
|
);
|
|
1214
|
+
// Read leniently: a cell whose span is unreadable, or which is not an object
|
|
1215
|
+
// at all, covers one column here, and its own diagnostic comes from parsing
|
|
1216
|
+
// it. Otherwise one mistake would report twice.
|
|
1069
1217
|
/** @type {(cells: any[], defs: any[] | null) => boolean} */
|
|
1070
|
-
let
|
|
1218
|
+
let covering = (cells, defs) =>
|
|
1219
|
+
!defs ||
|
|
1220
|
+
cells.reduce((all, cell) => all + spanValue(record(cell) ? cell.span : null), 0) ===
|
|
1221
|
+
defs.length;
|
|
1071
1222
|
/** @type {(cells: any[] | null, defs: any[] | null, path: string) => any[]} */
|
|
1072
1223
|
let counted = (cells, defs, path) => {
|
|
1073
1224
|
if (!cells) return [];
|
|
1074
|
-
if (!
|
|
1225
|
+
if (!covering(cells, defs)) bad(path + ".cells", "expected cells covering every column");
|
|
1075
1226
|
return cells;
|
|
1076
1227
|
};
|
|
1077
1228
|
/**
|
|
1078
1229
|
* @type {(def: any, path: string, defs: any[] | null) =>
|
|
1079
|
-
* { styles:
|
|
1230
|
+
* { styles: RowStyles, visible: any, cells: any[] }}
|
|
1080
1231
|
*/
|
|
1081
1232
|
let totalRowOf = (def, path, defs) => {
|
|
1082
|
-
if (!obj(def, path)) return { styles:
|
|
1233
|
+
if (!obj(def, path)) return { styles: NO_ROW_STYLE, visible: null, cells: [] };
|
|
1083
1234
|
keys(def, ["cells", "style", "visible"], path);
|
|
1084
1235
|
let cells = counted(arr(def.cells, path + ".cells", true), defs, path);
|
|
1085
1236
|
return {
|
|
1086
|
-
styles:
|
|
1237
|
+
styles: rowStylesOf(def, path),
|
|
1087
1238
|
visible: visibleOf(def, path),
|
|
1088
1239
|
cells: mapTotals(cells, path + ".cells"),
|
|
1089
1240
|
};
|
|
1090
1241
|
};
|
|
1091
1242
|
/**
|
|
1092
1243
|
* @type {(rows: any[], scope: Scope, runners: RunnerSet, columns: any[],
|
|
1093
|
-
* rowVisible: Eval | null, rowStyles:
|
|
1244
|
+
* rowVisible: Eval | null, rowStyles: RowStyles) => Generator<any>}
|
|
1094
1245
|
*/
|
|
1095
1246
|
function* tableRows(rows, scope, runners, columns, rowVisible, rowStyles) {
|
|
1096
1247
|
for (let row of rows) {
|
|
1097
1248
|
let scopeOfRow = runners.bind(scope, row);
|
|
1098
|
-
if (
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
),
|
|
1105
|
-
},
|
|
1106
|
-
{ style: rowStyles(scopeOfRow), run: scopeOfRow.run },
|
|
1107
|
-
);
|
|
1249
|
+
if (hidden(rowVisible, scopeOfRow)) continue;
|
|
1250
|
+
let cells = columns.map((column) =>
|
|
1251
|
+
Object.assign(column.cell(scopeOfRow), { path: column.path }),
|
|
1252
|
+
);
|
|
1253
|
+
fanBox(rowStyles.box(scopeOfRow), cells);
|
|
1254
|
+
yield opt({ type: "row", cells }, { style: rowStyles.rest(scopeOfRow), run: scopeOfRow.run });
|
|
1108
1255
|
}
|
|
1109
1256
|
}
|
|
1110
1257
|
/**
|
|
1111
|
-
* @type {(total: { styles:
|
|
1258
|
+
* @type {(total: { styles: RowStyles, visible: any, cells: any[] }[] | null,
|
|
1112
1259
|
* scope: Scope) => Generator<any>}
|
|
1113
1260
|
*/
|
|
1114
1261
|
function* emitTotals(total, scope) {
|
|
1115
1262
|
if (!total) return;
|
|
1116
1263
|
for (let row of total) {
|
|
1117
1264
|
if (hidden(row.visible, scope)) continue;
|
|
1118
|
-
|
|
1119
|
-
{
|
|
1120
|
-
type: "total-row",
|
|
1121
|
-
cells: row.cells.map(({ cell, path }) => Object.assign(cell(scope), { path })),
|
|
1122
|
-
},
|
|
1123
|
-
{ style: row.styles(scope) },
|
|
1265
|
+
let cells = row.cells.map(({ cell, span, path }) =>
|
|
1266
|
+
opt(Object.assign(cell(scope), { path }), { span: spanned(span) }),
|
|
1124
1267
|
);
|
|
1268
|
+
fanBox(row.styles.box(scope), cells);
|
|
1269
|
+
yield opt({ type: "total-row", cells }, { style: row.styles.rest(scope) });
|
|
1125
1270
|
}
|
|
1126
1271
|
}
|
|
1127
|
-
/** @type {(columns: any[], total: { styles:
|
|
1272
|
+
/** @type {(columns: any[], total: { styles: RowStyles, visible: any, cells: any[] }[] | null, rowVisible: Eval | null, rowStyles: RowStyles, headerStyles: RowStyles) => Band} */
|
|
1128
1273
|
let tableBand = (columns, total, rowVisible, rowStyles, headerStyles) =>
|
|
1129
1274
|
function* (rows, scope, runners) {
|
|
1275
|
+
// A covered column carries no `header` at all: the header row is the
|
|
1276
|
+
// cells the columns declare, which is fewer than the columns whenever
|
|
1277
|
+
// one spans. A consumer that reads no `span` still gets one cell per
|
|
1278
|
+
// uncovered column, which is the shape this had before spans existed.
|
|
1279
|
+
let cols = columns.map((column) =>
|
|
1280
|
+
opt(
|
|
1281
|
+
{},
|
|
1282
|
+
{
|
|
1283
|
+
header:
|
|
1284
|
+
column.header &&
|
|
1285
|
+
opt(column.header.cell(scope), { span: spanned(column.header.span) }),
|
|
1286
|
+
path: column.path,
|
|
1287
|
+
width: column.width,
|
|
1288
|
+
},
|
|
1289
|
+
),
|
|
1290
|
+
);
|
|
1291
|
+
// The header-row box fans onto the cells that are there, which for the
|
|
1292
|
+
// same reason is fewer than the columns whenever one spans. The list is
|
|
1293
|
+
// built only when there is a box to put on it.
|
|
1294
|
+
let box = headerStyles.box(scope);
|
|
1295
|
+
if (box) fanBox(box, cols.map((column) => column.header).filter(Boolean));
|
|
1130
1296
|
yield opt(
|
|
1131
|
-
{
|
|
1132
|
-
|
|
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) },
|
|
1297
|
+
{ type: "table-start", path: "detail", columns: cols },
|
|
1298
|
+
{ style: headerStyles.rest(scope) },
|
|
1139
1299
|
);
|
|
1140
1300
|
yield* tableRows(rows, scope, runners, columns, rowVisible, rowStyles);
|
|
1141
1301
|
yield* emitTotals(total, scope);
|
|
@@ -1329,15 +1489,13 @@ let headerHeightOf = (height, bad) => {
|
|
|
1329
1489
|
else if (!posPts(height)) bad("header.height", "expected a positive number of points");
|
|
1330
1490
|
return posPts(height) ? height : null;
|
|
1331
1491
|
};
|
|
1332
|
-
/** @type {(itemsOf: any) => { items: any, height: number | null }} */
|
|
1333
|
-
let emptyHeader = (itemsOf) => ({ items: itemsOf(null, "header", "report-header"), height: null });
|
|
1334
1492
|
/** @type {(value: any, keys: any, itemsOf: any, bad: any) => { items: any, height: number | null }} */
|
|
1335
1493
|
let reportHeaderOf = (value, keys, itemsOf, bad) => {
|
|
1336
1494
|
if (value == null || Array.isArray(value))
|
|
1337
1495
|
return { items: itemsOf(value, "header", "report-header"), height: null };
|
|
1338
1496
|
if (!record(value)) {
|
|
1339
1497
|
bad("header", "expected an array of items or { height, items }");
|
|
1340
|
-
return
|
|
1498
|
+
return { items: itemsOf(null, "header", "report-header"), height: null };
|
|
1341
1499
|
}
|
|
1342
1500
|
keys(value, ["height", "items"], "header");
|
|
1343
1501
|
return {
|
package/lib/stream.js
CHANGED
|
@@ -24,6 +24,59 @@ 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
|
+
// An absent group is the timestamp half of an optional match, not a breach.
|
|
42
|
+
/** @type {(bound: [string | undefined, number, number]) => boolean} */
|
|
43
|
+
let within = ([n, lo, hi]) => n === undefined || (+n >= lo && +n <= hi);
|
|
44
|
+
// A non-string never matches: `exec` would coerce one, and a single-element
|
|
45
|
+
// array stringifies to its element, which would revive an array as a date.
|
|
46
|
+
/** @type {(value: any) => RegExpExecArray | null} */
|
|
47
|
+
let rfc3339 = (value) => (typeof value === "string" ? RFC3339.exec(value) : null);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Revive a date string the way a host would have revived it — the accepted
|
|
51
|
+
* forms are exactly those `new Date(string)` reads identically on every
|
|
52
|
+
* machine, so declaring `format: "date"` and injecting `Date` values into the
|
|
53
|
+
* render data are two spellings of one document (ADR 0041).
|
|
54
|
+
*
|
|
55
|
+
* The components are range-checked here rather than left to the parser:
|
|
56
|
+
* ECMAScript rolls an out-of-range day over (`2026-02-30` becomes 2 March)
|
|
57
|
+
* where a date that does not exist should revive nothing at all. A leap
|
|
58
|
+
* second (`:60`) is refused on the same ground — RFC 3339 admits it and
|
|
59
|
+
* ECMAScript does not, so it would be a second rollover.
|
|
60
|
+
*
|
|
61
|
+
* @type {(value: any) => Date | undefined}
|
|
62
|
+
*/
|
|
63
|
+
export let reviveDate = (value) => {
|
|
64
|
+
let parts = rfc3339(value);
|
|
65
|
+
if (!parts) return undefined;
|
|
66
|
+
let [, year, month, day, hour, minute, second, offsetHour, offsetMinute] = parts;
|
|
67
|
+
/** @type {[string | undefined, number, number][]} */
|
|
68
|
+
let bounds = [
|
|
69
|
+
[month, 1, 12],
|
|
70
|
+
[day, 1, new Date(Date.UTC(+year, +month, 0)).getUTCDate()],
|
|
71
|
+
[hour, 0, 23],
|
|
72
|
+
[minute, 0, 59],
|
|
73
|
+
[second, 0, 59],
|
|
74
|
+
[offsetHour, 0, 23],
|
|
75
|
+
[offsetMinute, 0, 59],
|
|
76
|
+
];
|
|
77
|
+
let date = bounds.every(within) ? new Date(value) : new Date(NaN);
|
|
78
|
+
return finiteDate(date) ? date : undefined;
|
|
79
|
+
};
|
|
27
80
|
|
|
28
81
|
/**
|
|
29
82
|
* The typed-cell seam: exactly one value token holding a finite number, a
|
|
@@ -31,13 +84,23 @@ let asTyped = (value) => {
|
|
|
31
84
|
* including a lone null, matching the display-text treatment everywhere
|
|
32
85
|
* else — reports `undefined` and joins to text.
|
|
33
86
|
*
|
|
87
|
+
* Passing the cell's declared `format` kind opts into the one coercion the
|
|
88
|
+
* seam performs: under `"date"`, a string in either accepted RFC 3339 form
|
|
89
|
+
* revives to the `Date` it names. The gate is the declaration, and the kind
|
|
90
|
+
* is `date` alone, because a date is the one type a JSON document cannot
|
|
91
|
+
* carry — a number arrives as a number, so no other kind has anything to
|
|
92
|
+
* revive (ADR 0041). Callers that read no style omit the argument and see
|
|
93
|
+
* the seam exactly as it was.
|
|
94
|
+
*
|
|
34
95
|
* @param {any[]} tokens A cell's tokens.
|
|
96
|
+
* @param {unknown} [kind] The cell's resolved `format` declaration.
|
|
35
97
|
* @returns {number | boolean | Date | undefined} The native value, if any.
|
|
36
98
|
*/
|
|
37
|
-
export function typed(tokens) {
|
|
99
|
+
export function typed(tokens, kind) {
|
|
38
100
|
let [token] = tokens;
|
|
39
101
|
if (tokens.length !== 1 || !("value" in token)) return undefined;
|
|
40
|
-
|
|
102
|
+
// `??` and not `||`: a boolean cell's own `false` is a typed value.
|
|
103
|
+
return asTyped(token.value) ?? asDeclared(token.value, kind);
|
|
41
104
|
}
|
|
42
105
|
|
|
43
106
|
// The report's own bands, by the role their items wear (CONTEXT.md, "Band").
|
package/lib/style.js
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The closed style vocabulary
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* its own formatting model, and nothing here knows about any of them.
|
|
2
|
+
* The closed style vocabulary — target-neutral names with typed literal values —
|
|
3
|
+
* and the resolutions the event stream is defined in terms of. Two things, and
|
|
4
|
+
* the split between them is the file's organising claim.
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* The **checks** are what the traversal runs: unknown names and mistyped
|
|
7
|
+
* literals are definition errors; `=` results stay lenient at render.
|
|
8
|
+
*
|
|
9
|
+
* The **resolutions** are what a declaration means before any target sees it.
|
|
10
|
+
* A row's box is the one there is (SCHEMA.md, "Style declarations"): it says
|
|
11
|
+
* what the document means rather than what a surface paints, so every target
|
|
12
|
+
* is handed the answer instead of reaching its own. That is the same line
|
|
13
|
+
* `./stream.js` sits on, and the opposite side of it from `finite`/`HEX`,
|
|
14
|
+
* which are restated per target on purpose because a coercion is each target's
|
|
15
|
+
* own edge.
|
|
16
|
+
*
|
|
17
|
+
* Past those, each target maps these names to its own formatting model, and
|
|
18
|
+
* nothing here knows about any of them.
|
|
10
19
|
*/
|
|
11
20
|
/** @type {(value: any) => boolean} */
|
|
12
21
|
let finite = (value) => typeof value === "number" && Number.isFinite(value);
|
|
@@ -19,6 +28,8 @@ let spec = (msg, ok) => ({ ok, msg });
|
|
|
19
28
|
let COLOR = spec("expected a #rgb or #rrggbb color", isHex);
|
|
20
29
|
let FLAG = spec("expected a boolean", (value) => typeof value === "boolean");
|
|
21
30
|
let ALIGNMENTS = ["left", "center", "right"];
|
|
31
|
+
// "middle", not "center": that word is already the horizontal one.
|
|
32
|
+
let VALIGNMENTS = ["top", "middle", "bottom"];
|
|
22
33
|
let LINES = ["solid", "dashed", "dotted"];
|
|
23
34
|
let SIDES = ["Top", "Right", "Bottom", "Left"];
|
|
24
35
|
let BORDER_PARTS = ["Width", "Style", "Color"];
|
|
@@ -49,6 +60,7 @@ let STYLES = {
|
|
|
49
60
|
color: COLOR,
|
|
50
61
|
background: COLOR,
|
|
51
62
|
align: spec("expected left, center, or right", (value) => ALIGNMENTS.includes(value)),
|
|
63
|
+
valign: spec("expected top, middle, or bottom", (value) => VALIGNMENTS.includes(value)),
|
|
52
64
|
format: spec("expected number, currency, percent, or date", (value) => FORMATS.includes(value)),
|
|
53
65
|
};
|
|
54
66
|
let FORMATS = ["number", "currency", "percent", "date"];
|
|
@@ -73,6 +85,27 @@ for (let name of FLOW) {
|
|
|
73
85
|
STYLES[name] = POINTS;
|
|
74
86
|
IMAGE_STYLES.push(name);
|
|
75
87
|
}
|
|
88
|
+
// The vocabulary as a list, in the order SCHEMA.md's table reads it: the text
|
|
89
|
+
// names, the flow, then the box one part at a time across the four sides. A
|
|
90
|
+
// tool that offers the vocabulary (the editor's style rail) imports this
|
|
91
|
+
// rather than keeping a copy that has to be told about every new name.
|
|
92
|
+
/** @type {readonly string[]} */
|
|
93
|
+
// The box half of the vocabulary, generated once from the same two tables the
|
|
94
|
+
// checks read. `isBoxName` is the only place the question "does this name
|
|
95
|
+
// belong to the box?" is answered, so a name that does not start with
|
|
96
|
+
// `padding` or `border` could join without every asker being told separately.
|
|
97
|
+
let BOX_NAMES = [
|
|
98
|
+
...SIDES.map((side) => "padding" + side),
|
|
99
|
+
...BORDER_PARTS.flatMap((part) => SIDES.map((side) => "border" + side + part)),
|
|
100
|
+
];
|
|
101
|
+
let BOX = new Set(BOX_NAMES);
|
|
102
|
+
/** @type {(name: string) => boolean} */
|
|
103
|
+
export let isBoxName = (name) => BOX.has(name);
|
|
104
|
+
export let STYLE_NAMES = Object.freeze([
|
|
105
|
+
...Object.keys(STYLES).filter((name) => !FLOW.includes(name) && !isBoxName(name)),
|
|
106
|
+
...FLOW,
|
|
107
|
+
...BOX_NAMES,
|
|
108
|
+
]);
|
|
76
109
|
// One check for both the validating traversal and the compile path: an error
|
|
77
110
|
// message for a declaration, or null when it is acceptable (expressions defer
|
|
78
111
|
// to render).
|
|
@@ -150,9 +183,80 @@ let withoutFlow = (check, subject) => withoutNames(FLOW_NAMES, check, subject);
|
|
|
150
183
|
|
|
151
184
|
export let checkCellStyle = withoutFlow(checkStyle, "a table cell");
|
|
152
185
|
export let checkSlotStyle = withoutFlow(checkStyle, "a split slot");
|
|
153
|
-
|
|
186
|
+
// `valign` is legal exactly where a box has height it did not ask for
|
|
187
|
+
// (CONTEXT.md "Box": slack). A slot's box is the split's height, so a slot --
|
|
188
|
+
// text or image -- reads it; a band image's box is its own, so it does not.
|
|
189
|
+
// That is the one name an image accepts in a slot and nowhere else, the same
|
|
190
|
+
// shape of reason `width` reaches an item only there (docs/adr/0029).
|
|
191
|
+
let SLOT_IMAGE_STYLES = [...IMAGE_STYLES, "valign"];
|
|
192
|
+
export let checkSlotImageStyle = withoutFlow(
|
|
193
|
+
narrowed(SLOT_IMAGE_STYLES, "an image"),
|
|
194
|
+
"a split slot",
|
|
195
|
+
);
|
|
196
|
+
// A stacked text item's box is exactly its content and padding: no slack, so
|
|
197
|
+
// `valign` is refused rather than left unread.
|
|
198
|
+
/** @type {Record<string, true>} */
|
|
199
|
+
let STACKED_NAMES = { valign: true };
|
|
200
|
+
export let checkItemStyle = withoutNames(STACKED_NAMES, checkStyle, "a stacked item");
|
|
154
201
|
// A row box has no cell value, so `format` is refused there the way flow
|
|
155
202
|
// spacing is — it is a presentation of a value, not of a band of cells.
|
|
156
203
|
/** @type {Record<string, true>} */
|
|
157
204
|
let ROW_NAMES = { ...FLOW_NAMES, format: true };
|
|
158
205
|
export let checkRowStyle = withoutNames(ROW_NAMES, checkStyle, "a table row");
|
|
206
|
+
|
|
207
|
+
// --- resolution --------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
// A row's `style` resolves onto its cells (SCHEMA.md, "Style declarations").
|
|
210
|
+
// The box is the half that cannot arrive any other way: a table row is not a
|
|
211
|
+
// box on any surface -- not a `<tr>`, not a worksheet row, not a band of the
|
|
212
|
+
// PDF's own -- so it is resolved here, once, and no target is left to have an
|
|
213
|
+
// opinion about it. Every other declaration on a row reaches its cells by the
|
|
214
|
+
// layering each target already does, which is why only the box moves.
|
|
215
|
+
//
|
|
216
|
+
// Which names are the box is decided where the block is compiled, not here: a
|
|
217
|
+
// literal row style is partitioned once per table rather than once per row.
|
|
218
|
+
// What is left for render is the per-cell layering below.
|
|
219
|
+
|
|
220
|
+
// The three names of each border side, built once. `layer` reaches for them
|
|
221
|
+
// per cell, and rebuilding twelve strings per cell was most of what this cost.
|
|
222
|
+
/** @type {Record<string, string[]>} */
|
|
223
|
+
let BORDER_NAMES = Object.fromEntries(
|
|
224
|
+
SIDES.map((side) => [side, BORDER_PARTS.map((part) => "border" + side + part)]),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
/** @type {(block: any, side: string) => boolean} */
|
|
228
|
+
let ownsSide = (block, side) => BORDER_NAMES[side].some((name) => Object.hasOwn(block, name));
|
|
229
|
+
|
|
230
|
+
// The spread has already written every name the cell declared, so taking a
|
|
231
|
+
// side is dropping what is left of the row's: the names of that side the cell
|
|
232
|
+
// did not name itself.
|
|
233
|
+
/** @type {(out: any, over: any, side: string) => void} */
|
|
234
|
+
let takeSide = (out, over, side) => {
|
|
235
|
+
for (let name of BORDER_NAMES[side]) if (!Object.hasOwn(over, name)) delete out[name];
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// A border side is won whole by the cell: a cell that named any of the three
|
|
239
|
+
// keys takes that side entirely, so a cell that failed-soft on one name does
|
|
240
|
+
// not pick up the row's other two and become a stroke nobody declared. Only
|
|
241
|
+
// the sides the row's box actually declares can be taken from it, so the
|
|
242
|
+
// caller settles which those are once and hands them down.
|
|
243
|
+
/** @type {(under: any, over: any, sides: string[]) => any} */
|
|
244
|
+
let layer = (under, over, sides) => {
|
|
245
|
+
if (!over) return under;
|
|
246
|
+
let out = { ...under, ...over };
|
|
247
|
+
for (let side of sides) if (ownsSide(over, side)) takeSide(out, over, side);
|
|
248
|
+
return out;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Resolve a row's box onto the row's cells. The cells are written in place:
|
|
253
|
+
* both they and the box were built for this one event and nothing else holds
|
|
254
|
+
* them yet. A row that declares no box is the common case and costs one test.
|
|
255
|
+
*
|
|
256
|
+
* @type {(box: any, cells: any[]) => void}
|
|
257
|
+
*/
|
|
258
|
+
export let fanBox = (box, cells) => {
|
|
259
|
+
if (!box) return;
|
|
260
|
+
let sides = SIDES.filter((side) => ownsSide(box, side));
|
|
261
|
+
for (let cell of cells) cell.style = layer(box, cell.style, sides);
|
|
262
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "quario",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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": "11 kB"
|
|
55
55
|
}
|
|
56
56
|
],
|
|
57
57
|
"engines": {
|