quario 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,197 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-09-02
11
+
12
+ ### Added
13
+
14
+ - **`format` is a closed style name.** `number` / `currency` / `percent` /
15
+ `date` on text items, column cells, headers, and totals. Not images, not
16
+ `row.style`, not the report default. Locale, currency code, and timezone
17
+ live on `quario({ locale, currency, timeZone })`. The public `format()`
18
+ helper is how targets present a kind; a kind on the wrong type contributes
19
+ nothing (`docs/adr/0041`).
20
+
21
+ - **The report header may pin a `height` from the page top.** Dual-shaped
22
+ like `detail`: an item array, or `{ height, items }`. `page.margin` is a
23
+ document field (one number, all four sides), required with `height` and
24
+ legal without. Authored `spaceBefore` on the first occupying item of the
25
+ next band is refused (`docs/adr/0038`).
26
+
27
+ - **`spaceBefore` / `spaceAfter` return as item flow spacing.** Blank space
28
+ before or after a band item, in points, including band images and splits as
29
+ band items. Adjacent gaps add. Table cells, `row.style`, headers, totals,
30
+ and split slots refuse the names. `spaceBefore` drops at a fresh body page
31
+ or strip top; page-band items keep it. Leading and inset stay cut
32
+ (`docs/adr/0037`).
33
+
34
+ - **Per-side padding and border on the closed style vocabulary.**
35
+ `paddingTop` / `Right` / `Bottom` / `Left` (points, ≥ 0) and, per side,
36
+ `border*Width`, `border*Style` (`solid` | `dashed` | `dotted`),
37
+ `border*Color` (`#rgb` / `#rrggbb`). A border side is all three names or
38
+ none; width `0` is no stroke; an incomplete literal is a definition error,
39
+ and an incomplete expression result at render contributes nothing rather
40
+ than a solid black stroke. The names are legal wherever `background` is,
41
+ including images, plus `row.style`. The report default still takes only
42
+ `family` and `size`. Column `%` widths are border-box.
43
+
44
+ - **`detail.header` is the header-row box.** `{ style }` only, a different
45
+ path from `columns[i].header`. It crosses the seam as `table-start.style`.
46
+
47
+ ### Changed
48
+
49
+ - **A table total is N rows.** Before, `total` was one row: a flat cell
50
+ array or `{ style, cells }`. After, it is absent or a non-empty array of
51
+ `{ cells, style?, visible? }`. A one-row total is `[{ cells: [...] }]`.
52
+ `total: []` is a definition error. Paths are `detail.total[r].cells[i]`.
53
+ The stream yields one `total-row` per emitted row (`docs/adr/0042`).
54
+
55
+ - **A visible text item occupies a line at its own `size`.** Empty display
56
+ used to take the report default's leading in PDF and collapse in HTML;
57
+ `"a\n\nb"` broke in PDF and collapsed to a space in HTML. A visible item
58
+ now occupies at least one line set in that item's `size`, and a literal
59
+ newline is a line break, on every target that can show a line. Empty
60
+ table cells stay contentless for height. This is not a spacing primitive;
61
+ `visible: false` is still how an item leaves the layout.
62
+
63
+ ## [0.2.0] - 2026-09-01
64
+
65
+ ### Added
66
+
67
+ - **A report default: one `style` block for the whole document.** A top-level
68
+ `style` beside `header`/`detail`/`footer` states the typeface a report is set
69
+ in, so a document with one face names it once instead of on every item, every
70
+ table column, every header and every total cell. It takes `family` and
71
+ `size`; any other declaration there is a located definition error, exactly as
72
+ a text declaration on an image item is. Values are literals or `=`
73
+ expressions like any other style block's, resolved once per render in report
74
+ scope.
75
+
76
+ - **`report-start` carries the resolved report default as `style`.** A
77
+ report-level fact, never merged into an item's own `style`: an event's
78
+ `style` stays what the author wrote on that node, and a consumer composes the
79
+ default itself, once — under its own band-role defaults and under every
80
+ event's own style. Absent when the report declares none, so a consumer
81
+ written before this field renders in its own baseline exactly as it did.
82
+
83
+ - **An `uppercase` style declaration.** A boolean beside `bold` and `italic`,
84
+ literal or an `=` expression, for the capitalised column labels business
85
+ forms are usually set with. It is capitals, not small caps — real small caps
86
+ need a font feature the PDF target's built-in faces cannot supply, so the
87
+ declaration promises only what every target can draw. Image items keep
88
+ refusing it, as they refuse every text declaration.
89
+
90
+ - **A `split` item places values across the line instead of down the band.**
91
+ The invoice header's "seller left, customer right", which a band could not
92
+ say before. `{ "type": "split", "slots": [...] }` takes two or more slots,
93
+ each an ordinary `text` or `image` item plus an optional `width` percentage
94
+ under the same rules a table column's obeys; width-less slots divide what
95
+ the sized ones leave, evenly. A split is always the full content width and
96
+ never nests — it says what sits beside what, never where anything is — and
97
+ a slot that renders nothing keeps its width, so a line's geometry never
98
+ moves with the data. Splits may appear in every item array except table and
99
+ total cells.
100
+
101
+ - **`split-start` / `split-end` bracket a split's slots on the event stream.**
102
+ `split-start` carries the slot geometry, then one ordinary `item` or `image`
103
+ event per slot in order, then `split-end`. Existing consumers need no
104
+ change: the walk driver's missing-handler rule means a target that ignores
105
+ the bracket still receives the slot items and renders them stacked.
106
+
107
+ - **`quario().plan(schema, funcs?)` hands the whole traversal over at once.**
108
+ Returns `{ report, problems, anchors }` from one descent: the compiled
109
+ report (`null` while the document has problems), every problem as
110
+ `{ path, source?, message, diagnostic? }`, and a map from each compiled
111
+ source's schema path to the anchors and group handles it reads. A host that
112
+ validates and renders per edit — an editor — pays one traversal instead of
113
+ two, can point at a problem in its own UI without parsing a message apart,
114
+ and can tell where a node may safely move. `validate()` is unchanged: its
115
+ strings are those problems' `message` fields.
116
+ - **Every problem keeps its own located diagnostic.** `validate()` retained
117
+ `start`/`end` offsets on at most one problem per document; the structured
118
+ list carries each problem's own, so a consumer can underline the offending
119
+ character in every faulty expression rather than only the first.
120
+ - **Events name the definition behind them.** `item`, `image` and
121
+ `group-start` events carry `path`, the schema path of the node that
122
+ produced them; `table-start` carries `detail` and its columns their own,
123
+ and every table cell carries its column definition's path (a total cell its
124
+ `detail.total[i]` entry). Rows carry no identity — a row is data, not
125
+ schema. Additive: a consumer that ignores the field is unaffected.
126
+ - **`display(value)` joins one value the way `text()` does.** The scalar
127
+ display rule behind the token join, re-exported beside `text()` so a stream
128
+ consumer that stringifies token values itself renders exactly what the
129
+ official targets render — Dates included.
130
+
131
+ - **`maxDepth: Infinity` opts a query budget out.** The data query's traversal
132
+ budgets accept an explicit `Infinity` per key for "this budget, unbounded".
133
+ The 500-deep default is unchanged — it is now padvinder's own, applied for
134
+ every consumer rather than added by quario at the seam.
135
+
136
+ - **Located data-query errors carry padvinder's code and span.** A `data`
137
+ query that does not parse now surfaces with padvinder's `code` and
138
+ `start`/`end` offsets into the query you wrote — filter faults included —
139
+ alongside the band/item path quario already attached. The code names the
140
+ category of mistake: `PADVINDER_MISSING_ROOT`, `PADVINDER_BAD_SELECTOR`,
141
+ `PADVINDER_UNCLOSED_BRACKET`, `PADVINDER_BAD_STRING`,
142
+ `PADVINDER_UNKNOWN_FUNCTION`, or `PADVINDER_SYNTAX` for a path character or
143
+ filter body that is open-endedly not a query. Traversal budgets exceeded at
144
+ render time keep `limit`/`actual` and carry no span.
145
+
146
+ ### Changed
147
+
148
+ - **Cells render straight over the engine scope chain.** A text cell no longer
149
+ allocates a wrapper scope and an anchor pair per cell per row — quario's
150
+ chain already binds `$` at the render base and `@` on the detail row, and
151
+ sjabloon now renders over it as-is. A 4-column stream over a million rows
152
+ went from 1.7s to 0.5s. No report changes what it renders. Requires
153
+ sjabloon 0.11.
154
+
155
+ - **A compiled report's `functions` carry signatures.** Each entry is now
156
+ `{ name, arity, doc? }` instead of a bare name — `arity` from the function's
157
+ declared parameter count (or its own numeric `arity` where rest parameters
158
+ mislead `length`), `doc` from an own `doc` string when it carries one — in
159
+ the same call-first-seen order. `names` is unchanged. Requires xprsn 0.11
160
+ and sjabloon 0.11.
161
+
162
+ - **A bare `Date` renders as ISO 8601 UTC, the same on every machine.**
163
+ Display text for a `Date` value was `String(date)`, which bakes the
164
+ rendering host's timezone and locale into the output — so one report
165
+ produced different bytes on different machines. Every target now renders a
166
+ valid `Date` as its `toISOString()` form through the one shared display
167
+ rule; an invalid `Date` keeps its deterministic `Invalid Date` text.
168
+ Reports that want a formatted date keep using a registered function,
169
+ exactly as before.
170
+
171
+ - **A bad literal pattern in the data query is a definition error.** A typo'd
172
+ I-Regexp written as a string literal in `match()`/`search()` used to
173
+ produce a plausible empty report with no signal; `report()` and
174
+ `validate()` now surface it at compile time as a located error, with
175
+ offsets at the pattern literal in the query. The class and `code` are
176
+ treffer's — a malformed pattern is a `SyntaxError` with `TREFFER_SYNTAX`,
177
+ one past treffer's limits a `RangeError` with a `TREFFER_MAX_*` code and
178
+ `limit`/`actual` — on the same rule that already gives xprsn's codes to a
179
+ fault in an expression: the engine that decided the fault names it. A
180
+ pattern that arrives from render data keeps RFC 9535 semantics and still
181
+ matches nothing at render time. Requires padvinder 0.8.
182
+
183
+ - **Each engine relocates its own diagnostic.** A located error is now a copy
184
+ made by the engine that raised it (xprsn, sjabloon, or padvinder), so it
185
+ carries every field that engine puts on a diagnostic — nothing is lost in
186
+ quario's re-throw, today or when an engine adds a field — and it still
187
+ passes that engine's own `isDiagnostic`. Host errors are wrapped as plain
188
+ errors with no diagnostic metadata, exactly as before. Requires xprsn 0.10,
189
+ sjabloon 0.9, and padvinder 0.5. No report changes what it renders.
190
+
191
+ ### Fixed
192
+
193
+ - **A host error cannot pose as the report's diagnostic.** `report()` rethrows
194
+ the first definition problem's located engine error; it chose that error by
195
+ probing for a `code` property, so a host error class that stamps `code` on
196
+ itself in its constructor was rethrown as if the engines had raised it. The
197
+ choice now goes through the same identity-based authentication as
198
+ `isDiagnostic`; everything else falls back to a plain `SyntaxError` naming
199
+ the first problem, as before.
200
+
10
201
  ## [0.1.0] - 2026-08-27
11
202
 
12
203
  ### Added
package/README.md CHANGED
@@ -84,11 +84,11 @@ row, an optional `total-row`, and `table-end`.
84
84
  | Event | Carries |
85
85
  | -------------- | ------------------------------------------------------------------------------------------- |
86
86
  | `report-start` | `params`, resolved report `aggregates`, optional `page` band closures, `columns`, `marking` |
87
- | `item` | `role`, `tokens`, optional `style`, `run` |
88
- | `image` | `role`, `bytes`, `format` (`png` \| `jpeg`), `fit`, optional `alt`, `style`, `run` |
89
- | `group-start` | `name`, `depth`, `key`, `aggregates`, optional `break`, `reset`, `columns` |
87
+ | `item` | `role`, `path`, `tokens`, optional `style`, `run` |
88
+ | `image` | `role`, `path`, `bytes`, `format` (`png` \| `jpeg`), `fit`, optional `alt`, `style`, `run` |
89
+ | `group-start` | `name`, `depth`, `key`, `aggregates`, `path`, optional `break`, `reset`, `columns` |
90
90
  | `group-end` | `name`, `depth` |
91
- | `table-start` | `columns` (each `header`, optional `width`) |
91
+ | `table-start` | `path`, `columns` (each `header`, `path`, optional `width`) |
92
92
  | `row` | `cells`, optional `style`, `run` |
93
93
  | `total-row` | `cells` |
94
94
  | `table-end` | - |
@@ -145,6 +145,21 @@ validate({ data: "$.o[*]", sort: [{ by: "=@.a", dir: "up" }] });
145
145
  `report()` throws on the first problem instead. Validation and compilation share one traversal,
146
146
  so `validate()` can never disagree with what `report()` accepts.
147
147
 
148
+ ### `quario().plan(schema, functions?)`
149
+
150
+ The one traversal, whole — for hosts that validate and render in a loop, like an editor. Returns
151
+ `{ report, problems, anchors }`: the compiled report (`null` while the document has problems),
152
+ every problem structurally as `{ path, source?, message, diagnostic? }` (the `message` is exactly
153
+ `validate()`'s string, and every problem keeps its own located diagnostic with `start`/`end`
154
+ offsets, not only the first), and `anchors`, mapping each compiled source's schema path to the
155
+ anchors and group handles it reads — the unfiltered complement of `names`.
156
+
157
+ ```js
158
+ const { report, problems, anchors } = quario().plan(schema);
159
+ if (report) await report.render(html(), data);
160
+ else console.error(problems[0].path, problems[0].message);
161
+ ```
162
+
148
163
  ### `isDiagnostic(error)`
149
164
 
150
165
  True when a caught value is one of the stack's located errors: quario's own, or one thrown
package/lib/format.js ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Present a cell's raw token as `number` / `currency` / `percent` / `date`.
3
+ * Targets import this instead of each other: the stream keeps the kind on
4
+ * `style` and the token raw, and HTML/PDF stringify at the edge. A kind on
5
+ * the wrong type, or on null, contributes nothing — the caller falls back to
6
+ * `display()`. Invalid locale or currency codes fail the same way.
7
+ *
8
+ * Defaults (`en-US`, UTC) are pinned so a PDF without a host locale still
9
+ * renders the same bytes on every machine (docs/adr/0041, docs/adr/0025).
10
+ */
11
+ import { finiteDate, finiteNum } from "./stream.js";
12
+ /** @type {(options: any) => string} */
13
+ let localeOf = (options) => options?.locale || "en-US";
14
+ /** @type {(options: any) => string} */
15
+ let zoneOf = (options) => options?.timeZone || "UTC";
16
+
17
+ /** @type {(value: any, locale: string) => string | undefined} */
18
+ let asNumber = (value, locale) =>
19
+ finiteNum(value) ? new Intl.NumberFormat(locale).format(value) : undefined;
20
+ /** @type {(value: any, locale: string) => string | undefined} */
21
+ let asPercent = (value, locale) =>
22
+ finiteNum(value) ? new Intl.NumberFormat(locale, { style: "percent" }).format(value) : undefined;
23
+ /** @type {(options: any) => string | null} */
24
+ let currencyOf = (options) => {
25
+ let currency = options?.currency;
26
+ return typeof currency === "string" && currency ? currency : null;
27
+ };
28
+ /** @type {(value: any, locale: string, options: any) => string | undefined} */
29
+ let asMoney = (value, locale, options) => {
30
+ let currency = currencyOf(options);
31
+ if (!finiteNum(value) || !currency) return;
32
+ return new Intl.NumberFormat(locale, { style: "currency", currency }).format(value);
33
+ };
34
+ /** @type {(value: any, locale: string, options: any) => string | undefined} */
35
+ let asDate = (value, locale, options) =>
36
+ finiteDate(value)
37
+ ? new Intl.DateTimeFormat(locale, { timeZone: zoneOf(options) }).format(value)
38
+ : undefined;
39
+
40
+ /** @type {Record<string, (value: any, locale: string, options: any) => string | undefined>} */
41
+ let KINDS = { number: asNumber, currency: asMoney, percent: asPercent, date: asDate };
42
+
43
+ /**
44
+ * @param {unknown} value The interpolation's pre-stringify token.
45
+ * @param {unknown} kind A resolved `format` declaration.
46
+ * @param {{ locale?: string, currency?: string, timeZone?: string } | null} [options]
47
+ * The instance's locale, currency code, and timezone.
48
+ * @returns {string | undefined} The presented text, or nothing when this
49
+ * kind does not apply.
50
+ */
51
+ export function format(value, kind, options) {
52
+ if (typeof kind !== "string") return;
53
+ let present = KINDS[kind];
54
+ if (!present) return;
55
+ try {
56
+ return present(value, localeOf(options), options);
57
+ } catch {
58
+ return;
59
+ }
60
+ }