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 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. The rest of the vocabulary describes
84
- * text, which an image does not have, so any other name on one is a definition
85
- * error. A narrowing of the one vocabulary rather than a second list of its
86
- * own, so the two cannot drift.
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<StyleDeclarations, "background" | "align">;
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?: Cell[];
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
- header?: Item[];
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(tokens: readonly Token[]): number | boolean | Date | undefined;
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
- { page, columns, style: style(base), marking: marking() },
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
- if (!rows.length && empty) yield* empty(base);
214
- else yield* band(rows, base, runners);
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}}} [options]
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-01"; // x-release-please-date
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 { checkImageStyle, checkReportStyle, checkStyle } from "./style.js";
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(tpl, inSlot ? visible : null, stylesOf(def, path));
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, checkImageStyle);
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[] | null, defs: any[] | null) => void} */
1022
- let checkTotalCount = (totals, defs) => {
1023
- if (totals && defs && totals.length !== defs.length)
1024
- bad("detail.total", "expected one cell per column");
1025
- };
1026
- /** @type {(totals: any[]) => any[]} */
1027
- let mapTotals = (totals) =>
1045
+ /** @type {(totals: any[], prefix: string) => any[]} */
1046
+ let mapTotals = (totals, prefix) =>
1028
1047
  totals
1029
1048
  .map((def, i) => {
1030
- let cell = totalOf(def, "detail.total[" + i + "]");
1031
- return cell && { cell, path: "detail.total[" + i + "]" };
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 {(value: any, defs: any[] | null) => any[] | null} */
1035
- let tableTotals = (value, defs) => {
1036
- let totals = arr(value.total, "detail.total");
1037
- checkTotalCount(totals, defs);
1038
- return totals ? mapTotals(totals) : null;
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
- /** @type {(columns: any[], total: any[] | null, rowVisible: Eval | null, rowStyles: (scope: Scope) => any) => Band} */
1060
- let tableBand = (columns, total, rowVisible, rowStyles) =>
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
- type: "table-start",
1064
- path: "detail",
1065
- columns: columns.map((column) =>
1066
- opt({ header: column.header(scope), path: column.path }, { width: column.width }),
1067
- ),
1068
- };
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
- if (total)
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 {(schema: any, obj: any, keys: any, itemsOf: any) => { pageHeader: any, pageFooter: any }} */
1255
- let pageBands = (schema, obj, keys, itemsOf) => {
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 = itemsOf(schema.header, "header", "report-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
- return asTyped(token.value);
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
- // The declarations an image item accepts. The rest of the vocabulary describes
23
- // text, which an image does not have, so anything else on one is a definition
24
- // error rather than a silent no-op (SCHEMA.md, "Image item"). The subset lives
25
- // beside the vocabulary it narrows, and `checkImageStyle` below is how the
26
- // traversal asks for it -- it keeps no second list of its own.
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.2.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": "9 kB"
54
+ "limit": "10 kB"
55
55
  }
56
56
  ],
57
57
  "engines": {