quario 0.9.0 → 0.10.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
@@ -1,5 +1,48 @@
1
1
  # quario
2
2
 
3
+ ## 0.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - **A group's `break` names a position, and `break: "page"` is retired.** Its values are `"before"`, `"between"`, `"after"` and `"around"`. Write `break: "before"` where you wrote `break: "page"`. That is the whole of the migration, and it changes nothing the report produces.
8
+
9
+ The boundary between two consecutive instances always turns a page. `before` adds the leading edge of the run, so the first instance opens a page of its own. `between` adds neither edge, which keeps the first instance on the page the report header opened — the case the old vocabulary could not express, and the reason for the change. `after` adds the trailing edge, and `around` adds both. A trailing edge turns the page for what follows the run, so a report whose last band is that group reads `after` as `between` and `around` as `before`. A nested group takes its two edges from each instance of the group above it, rather than from the document. `"page"` said which unit a break used, where the four say where it falls; one set cannot say both and still read at a glance.
10
+
11
+ **`reset: "page"` turns no page of its own, and now needs a `break` beside it.** It says only that a new `page.number` / `page.total` sequence starts at this instance. A sequence owns whole pages, so `reset` requires `break` to be `"before"` or `"around"`. Any other `break`, and `reset` with no `break` at all, is a definition error the compile reports. Add `break: "before"` to a group that declares `reset` alone today.
12
+
13
+ The HTML target adds `q-break` to an instance whose leading edge turns, as before, and the new `q-break-after` to one whose trailing edge turns. `@quario/html/style.css` gains `.q-break-after { break-after: page }` beside the rule it already shipped for `.q-break`.
14
+
15
+ The Word target also stops losing a page break a table would swallow. A table carries no paragraph properties, so a break owed where one starts had nowhere to sit and reached the next paragraph instead, on the wrong page or on none. It now gets a paragraph of its own, the same carrier a section break already took.
16
+
17
+ The render-event stream states the two edges rather than the four positions. `group-start` carries `break` where a page turns before the instance, and the new `breakAfter` where one turns after it, so a target reads boundaries and never the position that asked for them.
18
+
19
+ - **Two new reducers: `first` and `last`.** They report the value an expression takes on the first and the last row in scope. Like every reducer, each one arrives on three surfaces at once: a declared aggregate on a report or a group, a runner under `run`, and a function you can call inline over a row's own array.
20
+
21
+ ```jsonc
22
+ // the customer a region's rows open and close on
23
+ { "aggregates": { "opened": "first:=@.customer", "closed": "last:=@.customer" } }
24
+ ```
25
+
26
+ ```
27
+ {{ first(@.lines, l => l.sku) }} the first line's article number
28
+ ```
29
+
30
+ They are the first reducers that read the **order** of the rows they fold, so the spec now says what each scope hands them. Every scope reports the ends of the rows it folded, which is not always what the page ends on. A report folds after `where`, its `sort` and its `take`, and before it splits the rows into groups. A group that declares a `sort` and no `take` folds its partition before it orders that partition, so its handle holds the partition's ends. Declare `first` under a group's `run` when a detail row needs the row that group opens on as rendered.
31
+
32
+ An empty scope gives `null` for both, next to `avg`, `min` and `max`. Neither reducer skips a null: a scope whose first row holds `null` reports that `null`.
33
+
34
+ Reports that already render are unaffected. A document that spelled `first:=...` before was a definition error, and it now compiles.
35
+
36
+ - **A hollow instance's `group-end` carries `hollow: true`.** An instance whose bands emitted nothing between its two brackets is hollow, and a hollow instance is not there: no space, no gap, no page turned by opening one, no outline entry. The stream now says which instances those are, on the closing bracket, so a target reads one field rather than counting the events between the brackets for itself. An instance holding nothing but hollow instances is hollow too, and one holding a child that drew is not. The field is absent, never `false`, on an instance that drew.
37
+
38
+ `@quario/layout`, and through it `@quario/pdf`, the viewer and the editor, read the field and keep no tracker of their own. What they render is unchanged.
39
+
40
+ - **`splits(events)` folds a split bracket into one event.** A split reaches the event stream as `split-start`, one `item` or `image` per slot, then `split-end`, and every consumer that wanted a split whole kept that bracket for itself. `splits` does it once: read `walk(splits(events), handlers)` and register a `split` handler, which receives the opening event's `role`, `path`, `slots` and `style` plus the slot events in order under `items`. Every other event passes through unchanged, lazily.
41
+
42
+ The stream itself is unchanged. A consumer that registers no `split-start` handler still receives the slots as ordinary events and stacks them, as before. The `SplitStartEvent` declaration now names its `path`, which the stream always carried.
43
+
44
+ Every built-in target reads its splits this way now, so a custom target can too.
45
+
3
46
  ## 0.9.0
4
47
 
5
48
  ### Minor Changes
package/README.md CHANGED
@@ -11,6 +11,18 @@ The engine does not escape text, emit HTML, or format pages or cells. Install a
11
11
  want output. Use this package when you want to _build_ a target, or to consume report structure
12
12
  as data.
13
13
 
14
+ ## Contents
15
+
16
+ - [Install](#install)
17
+ - [Quick start](#quick-start)
18
+ - [API](#api)
19
+ - [The scope model](#the-scope-model)
20
+ - [Options](#options)
21
+ - [Writing a render target](#writing-a-render-target)
22
+ - [Content Security Policy](#content-security-policy)
23
+ - [Documentation](#documentation)
24
+ - [License](#license)
25
+
14
26
  ## Install
15
27
 
16
28
  ```bash
@@ -90,7 +102,7 @@ row, one `total-row` per emitted total row, and `table-end`.
90
102
  | `image` | `role`, `path`, `bytes`, `format` (`png` \| `jpeg`), `fit`, optional `alt`, `style`, `run` |
91
103
  | `split-start` | `role`, `slots` (each an optional `width`), optional `style`. One `item` or `image` per slot follows, then `split-end` |
92
104
  | `split-end` | - |
93
- | `group-start` | `name`, `depth`, `key`, `aggregates`, `path`, optional `break`, `reset`, `columns` |
105
+ | `group-start` | `name`, `depth`, `key`, `aggregates`, `path`, optional `break`, `breakAfter`, `reset`, `columns` |
94
106
  | `group-end` | `name`, `depth` |
95
107
  | `table-start` | `path`, `header` (`cells`, optional `style`), `columns` (each an optional `width`) |
96
108
  | `row` | `cells`, optional `style`, `run` |
@@ -155,6 +167,14 @@ itself.
155
167
  `breathe()` is that return alone. Await it between batches of a loop you own. `walk` already
156
168
  calls it for you.
157
169
 
170
+ ### `splits(events)`
171
+
172
+ A split reaches the stream as a bracket: `split-start`, one `item` or `image` per slot, `split-end`.
173
+ `splits` folds each bracket into one `split` event that carries the opening's fields and the slot
174
+ events under `items`, and passes every other event through, lazily. Read `walk(splits(events),
175
+ handlers)` and register a `split` handler where you want a split whole, and keep no bracket state of
176
+ your own. Every official target reads its splits this way.
177
+
158
178
  ### Presentation helpers
159
179
 
160
180
  A target that stringifies imports these rather than restating them, so every surface presents a
@@ -261,7 +281,7 @@ carry `code`, `limit`, and `actual`. Every render starts with fresh counters.
261
281
  ## Writing a render target
262
282
 
263
283
  Read the stream through the public API. Do not reach for engine internals. A complete, tested
264
- Markdown target lives in the repository at `example/markdown.js` in about 70 lines. The built-in
284
+ Markdown target lives in the repository at `example/markdown.js` in under 60 lines of code. The built-in
265
285
  targets use the same public API.
266
286
 
267
287
  Two rules a target owes its users: escape or neutralize every `value` token at your own edge, and
@@ -12,10 +12,8 @@
12
12
  * node a fault names.
13
13
  *
14
14
  * The traversal half is a guard the descent carries rather than a second walk
15
- * of the schema: `plan.js` offers it each band as it compiles it, in the order
16
- * it already visits them, so a fault lands in the documented key order beside
17
- * every other problem and the group chain is read once
18
- * (docs/agents/semantics.md, "One traversal, read two ways").
15
+ * of the schema: `plan.js` offers it each band as it compiles it, so a fault
16
+ * lands in the documented key order beside every other problem.
19
17
  */
20
18
 
21
19
  import { isExpr, positivePts } from "./style.js";
@@ -74,19 +72,16 @@ let firstOccupying = (list, path) => {
74
72
  * The guard the traversal carries while it descends.
75
73
  *
76
74
  * Which band sits under the pinned box is partly the document's answer and
77
- * partly the data's. With rows, every group level opens nested before any
78
- * detail row, so the first group header carrying an occupying item is that
79
- * band, and `detail` is it only when no group header has one — which is the
80
- * order `bandOf` already compiles them in, so `body` takes the first band that
81
- * says anything and ignores the rest. With no rows the `empty` band replaces
82
- * all of it, so it is an independent candidate that `instead` judges on its
83
- * own. Both outcomes are reachable for any report declaring both, and the
84
- * author declared each, so a lead on either is refused.
75
+ * partly the data's. With rows, every group level opens before any detail
76
+ * row, so the first group header carrying an occupying item is that band —
77
+ * the order `bandOf` compiles them in, so `body` takes the first band that
78
+ * says anything. With no rows the `empty` band replaces all of it, judged on
79
+ * its own by `instead`. Both outcomes are reachable for any report declaring
80
+ * both, so a lead on either is refused.
85
81
  *
86
- * A table `detail` reaches neither, and needs no case of its own:
87
- * `table-start` and `row` are not occupying events, and the style vocabulary
88
- * refuses `spaceBefore` on a table, a table row and a table cell alike, so
89
- * there is no lead a table could carry.
82
+ * A table `detail` reaches neither and needs no case: `table-start` and `row`
83
+ * are not occupying events, and the style vocabulary refuses `spaceBefore` on
84
+ * a table, a row and a cell alike.
90
85
  *
91
86
  * @param {(path: string, message: string) => void} bad The traversal's collector.
92
87
  * @returns {{ arm: (height: number | null) => void,
package/lib/format.js CHANGED
@@ -11,11 +11,9 @@
11
11
  * renders the same bytes on every machine (docs/adr/0041, docs/adr/0025).
12
12
  *
13
13
  * The kind, its digit count, and a date's form all come off the one resolved
14
- * declaration `formatOf` answers — the same object the stream carries and the
15
- * XLSX target builds its number formats from — so a cell shows the same
16
- * digits wherever it is rendered (docs/adr/0054, docs/adr/0056). A kind that
17
- * carries no count presents nothing here and the caller falls back to
18
- * `display()`.
14
+ * declaration `formatOf` answers — the same object the XLSX target builds its
15
+ * number formats from — so a cell shows the same digits wherever it is
16
+ * rendered (docs/adr/0054, docs/adr/0056).
19
17
  */
20
18
  import { boundedMemo } from "./memo.js";
21
19
  import { currencyOf, formatOf } from "./style.js";
@@ -29,29 +27,23 @@ let zoneOf = (options) => options?.timeZone || "UTC";
29
27
  // carries the rules a bounded memo has; what is here is what only this call
30
28
  // site knows -- its key, and its cap.
31
29
  //
32
- // **The key is everything the formatter is made from**, and it is values
33
- // rather than an object identity because no identity survives both paths: a
34
- // literal style block folds to one frozen declaration every cell shares, while
35
- // a block holding an `=` expression resolves a fresh one per cell (`plan.js`).
36
- // Keying on values hits in both. Get a piece of it wrong and one cell presents
37
- // under another's formatter -- a USD row reading as EUR -- which is the
38
- // failure `test/semantics.test.js` pins a pair for, one piece at a time. Each
39
- // pair must differ in **only** the piece it is there for: EUR against JPY
40
- // would prove nothing about the code, because their digit counts already
41
- // differ and that piece would separate them on its own.
30
+ // **The key is everything the formatter is made from**, by value rather than
31
+ // object identity, because no identity survives both paths: a literal style
32
+ // block folds to one frozen declaration every cell shares, while a block
33
+ // holding an `=` resolves a fresh one per cell. Get a piece wrong and one
34
+ // cell presents under another's formatter a USD row reading as EUR which
35
+ // `test/semantics.test.js` pins a pair for, one piece at a time. Each pair
36
+ // must differ in **only** the piece it is there for.
42
37
  //
43
38
  // **The cap is the part to read**, because the key space is not the host's to
44
39
  // bound. A currency code reaches this from author data through
45
40
  // `currency: "=@.ccy"`, gated only to three uppercase letters, and `digits`
46
- // can be an `=` result too: 17,576 codes times 21 counts is 369,000 keys for a
47
- // single locale. Measured at 244 bytes retained per entry, that is about 90 MB
48
- // held for the life of the process on data nobody vetted.
41
+ // can be an `=` result too: 17,576 codes times 21 counts is 369,000 keys for
42
+ // one locale, about 90 MB at 244 bytes retained per entry.
49
43
  //
50
- // 2048 leaves the margin a threshold like this wants on both sides: about
51
- // 500 KB at the cap, against a real report reaching a small multiple of the
52
- // ISO codes actually in circulation, which is under two hundred. Raising it
53
- // costs bytes and lowering it costs rebuilt formatters; neither is a
54
- // correctness knob.
44
+ // 2048 is about 500 KB at the cap, against a real report reaching a small
45
+ // multiple of the ISO codes in circulation. Neither bound is a correctness
46
+ // knob.
55
47
  //
56
48
  // Memoising a fact that cannot change within an ICU version, like
57
49
  // `fractionDigits`' own minor-units map (docs/adr/0025).
package/lib/index.d.ts CHANGED
@@ -367,11 +367,15 @@ export interface TableDetail {
367
367
  export interface Group {
368
368
  name: string;
369
369
  by: `=${string}`;
370
- /** Start every instance of this group on a new page (paginated targets). */
371
- break?: "page";
372
370
  /**
373
- * Restart `page.number` / `page.total` at every instance. Each instance also
374
- * starts on a fresh page, the same rule `break` has.
371
+ * Where this group turns a page (paginated targets). Every boundary between
372
+ * consecutive instances turns; `before` adds the run's leading edge, `after`
373
+ * its trailing edge, and `around` both.
374
+ */
375
+ break?: "before" | "between" | "after" | "around";
376
+ /**
377
+ * Restart `page.number` / `page.total` at every instance. It turns no page
378
+ * of its own, so it requires `break` to be `"before"` or `"around"`.
375
379
  */
376
380
  reset?: "page";
377
381
  /**
@@ -664,6 +668,8 @@ export interface ImageEvent {
664
668
  export interface SplitStartEvent {
665
669
  type: "split-start";
666
670
  role: ItemRole;
671
+ /** The split definition's schema path. */
672
+ path: string;
667
673
  /** One entry per slot, in order; `width` is absent on a width-less slot. */
668
674
  slots: { width?: number }[];
669
675
  style?: Record<string, unknown>;
@@ -673,6 +679,15 @@ export interface SplitEndEvent {
673
679
  type: "split-end";
674
680
  }
675
681
 
682
+ /**
683
+ * A split bracket folded whole by `splits()`: the opening event's fields, and
684
+ * the slot events in slot order under `items`. No stream emits it.
685
+ */
686
+ export interface SplitEvent extends Omit<SplitStartEvent, "type"> {
687
+ type: "split";
688
+ items: (ItemEvent | ImageEvent)[];
689
+ }
690
+
676
691
  export interface GroupStartEvent {
677
692
  type: "group-start";
678
693
  name: string;
@@ -681,7 +696,10 @@ export interface GroupStartEvent {
681
696
  depth: number;
682
697
  key: unknown;
683
698
  aggregates: Record<string, unknown>;
699
+ /** A page turns before this instance, resolved from the group's `break`. */
684
700
  break?: "page";
701
+ /** A page turns after this instance, resolved from the group's `break`. */
702
+ breakAfter?: "page";
685
703
  /** Restart `page.number` / `page.total` at every instance (paginated targets). */
686
704
  reset?: "page";
687
705
  /** The declared page column count, present when the group declares one. */
@@ -692,6 +710,8 @@ export interface GroupEndEvent {
692
710
  type: "group-end";
693
711
  name: string;
694
712
  depth: number;
713
+ /** Present when the instance is hollow: nothing but group brackets arrived between its ends. */
714
+ hollow?: true;
695
715
  }
696
716
 
697
717
  export interface TableStartEvent {
@@ -766,9 +786,21 @@ export interface ReportEventStream {
766
786
  * ignores that event.
767
787
  */
768
788
  export type WalkHandlers = {
769
- [K in ReportEvent["type"]]?: (event: Extract<ReportEvent, { type: K }>) => void;
789
+ [K in WalkEvent["type"]]?: (event: Extract<WalkEvent, { type: K }>) => void;
770
790
  };
771
791
 
792
+ /** What the walk driver dispatches: a stream event, or a split `splits()` folded. */
793
+ export type WalkEvent = ReportEvent | SplitEvent;
794
+
795
+ /**
796
+ * The split bracket folded: every `split-start`, its slot events and its
797
+ * `split-end` become one `split` event, and every other event passes through
798
+ * untouched, lazily. Read `splits(stream)` where a split is wanted whole rather
799
+ * than keeping a bracket of your own; register a `split` handler and no
800
+ * `split-start` or `split-end` one.
801
+ */
802
+ export function splits(events: Iterable<ReportEvent>): Generator<WalkEvent, void>;
803
+
772
804
  /**
773
805
  * The walk driver: dispatch one render's event stream to per-event handlers,
774
806
  * in stream order and exactly once each, pulling lazily and handing the loop
@@ -778,7 +810,7 @@ export type WalkHandlers = {
778
810
  * than pulling the stream itself; no opening event is required, and a stream
779
811
  * that starts part-way through walks like any other.
780
812
  */
781
- export function walk(events: Iterable<ReportEvent>, handlers: WalkHandlers): Promise<void>;
813
+ export function walk(events: Iterable<WalkEvent>, handlers: WalkHandlers): Promise<void>;
782
814
 
783
815
  /**
784
816
  * Hand the event loop back to the host, resolving once it has had its turn.
package/lib/index.js CHANGED
@@ -24,7 +24,7 @@ import { opt } from "./stream.js";
24
24
  // The event stream's public seam, single-sourced in ./stream.js, and the
25
25
  // diagnostic predicate in ./locate.js. Re-exported here because a consumer
26
26
  // imports them from the package, not from a file inside it.
27
- export { breathe, display, isReportBand, styledRuns, text, typed, walk } from "./stream.js";
27
+ export { breathe, display, isReportBand, splits, styledRuns, text, typed, walk } from "./stream.js";
28
28
  export { format } from "./format.js";
29
29
  export {
30
30
  currencyOf,
package/lib/license.js CHANGED
@@ -11,7 +11,7 @@
11
11
  // though nothing 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-15"; // release-date
14
+ let RELEASE = "2026-09-19"; // release-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/locate.js CHANGED
@@ -41,14 +41,12 @@ export let fault = (path, msg) => {
41
41
  * An image failure a **target** raises, named on the item that asked for the
42
42
  * bytes. The engine vouches for an image's magic numbers and no further
43
43
  * (SCHEMA.md, "Image item"), so every failure past that point belongs to
44
- * whichever target was sizing or embedding the file and each of them was
45
- * inventing its own wording, down to a corrupt PNG reaching a host as
46
- * pdf-lib's bare "Invalid typed array length: 0" (quario-e0bz).
44
+ * whichever target was sizing or embedding the file, and one wording serves
45
+ * all of them.
47
46
  *
48
- * Here rather than in a target because three of them raise it and no two of
49
- * them may depend on each other; the engine is what they all already have.
50
- * Minted rather than thrown, unlike `fault` beside it, so a caller can raise
51
- * it from a `catch` or a rejection in whichever style its own code is.
47
+ * Here rather than in a target because three of them raise it and no two may
48
+ * depend on each other. Minted rather than thrown, unlike `fault` beside it,
49
+ * so a caller can raise it from a `catch` or a rejection.
52
50
  *
53
51
  * The shape is the one every render error has: the path prefixes the message
54
52
  * and the failing class survives behind it. It carries no `LOCATION` and is no
package/lib/math.js CHANGED
@@ -7,37 +7,28 @@
7
7
  * error.
8
8
  *
9
9
  * `round`, `floor` and `ceil` round the DECIMAL the author wrote, not the
10
- * binary double it is stored as, and `Intl.NumberFormat` is how they reach it:
11
- * its rounding starts from the shortest decimal that round-trips, so
12
- * `round(2.675, 2)` is 2.68 where `Math.round(2.675 * 100) / 100` is 2.67 and
13
- * `Number((2.675).toFixed(2))` is also 2.67. Those two disagree with each other
14
- * as well as with us, on different inputs -- the whole argument, with the
15
- * table, is in docs/adr/0050. The locale is hardcoded `en-US` rather than the
16
- * instance's: this is arithmetic, not presentation, and a locale with
10
+ * binary double it is stored as, and `Intl.NumberFormat` is how they reach
11
+ * it: its rounding starts from the shortest decimal that round-trips, so
12
+ * `round(2.675, 2)` is 2.68 where both `Math.round(2.675 * 100) / 100` and
13
+ * `Number((2.675).toFixed(2))` are 2.67 (docs/adr/0050). The locale is
14
+ * hardcoded `en-US`: this is arithmetic, not presentation, and a locale with
17
15
  * non-ASCII digits or its own grouping would not parse back through `Number`.
18
16
  */
19
17
  import { boundedMemo } from "./memo.js";
20
18
 
21
19
  // The formatters are memoised behind `./memo.js`, the same seam `format()`
22
- // uses -- one bounded store each, not one between them. ADR 0050 declined this
23
- // cache and records why the decline expired; what is here is what only this
24
- // call site knows, its key and its cap.
20
+ // uses one bounded store each, not one between them (docs/adr/0050).
25
21
  //
26
22
  // **The key is the mode and the count**, which is all that varies: the locale
27
- // and `useGrouping` are constants here rather than configuration, precisely so
28
- // the answer parses back through `Number`, so they are not in it. The mode
29
- // leads, as the kind does in `format()`'s key, and the two shapes read the
30
- // same way. Drop either piece and a `floor` presents a `round`'s answer, or a
31
- // three-digit call presents two -- pinned a piece at a time in
23
+ // and `useGrouping` are constants here, precisely so the answer parses back
24
+ // through `Number`. Drop either piece and a `floor` presents a `round`'s
25
+ // answer, or a three-digit call presents two pinned a piece at a time in
32
26
  // `test/frozen.test.js`.
33
27
  //
34
28
  // **The cap is unreachable, and that is the point of stating it.** `places`
35
29
  // admits 0 to 100 and there are three modes, so this store holds at most 303
36
- // entries however hostile the data is -- `n` may be an `=` result, but it
37
- // cannot be an unbounded one. 512 is headroom over that rather than a
38
- // threshold: if the admitted range ever widened, the store would empty and
39
- // rebuild rather than grow without limit, which is the same degradation
40
- // `format()`'s cap buys and no correctness knob either way.
30
+ // entries however hostile the data is. 512 is headroom, so a widened range
31
+ // would empty and rebuild the store rather than grow it without limit.
41
32
  let memo = boundedMemo(512);
42
33
 
43
34
  /** @type {(x: number, n: number, mode: string) => number} */
package/lib/plan.js CHANGED
@@ -154,6 +154,23 @@ let TEXT_SLOT_KEYS = [...TEXT_KEYS, "width"];
154
154
  let IMAGE_KEYS = ["type", "source", "fit", "alt", "visible", "style"];
155
155
  let IMAGE_SLOT_KEYS = [...IMAGE_KEYS, "width"];
156
156
 
157
+ // A group's two structural literals. `break` names a position rather than a
158
+ // unit (ADR 0077), and this table is the whole of what its four values mean:
159
+ // every boundary `between` consecutive instances turns a page, `lead` adds the
160
+ // run's own leading edge, and `trail` its trailing one. One row says everything
161
+ // about one value, so a reader checks a value by reading it rather than by
162
+ // intersecting sets. `reset` names a page-number sequence and turns nothing.
163
+ let EDGES = new Map([
164
+ ["before", { between: "page", lead: "page", trail: null }],
165
+ ["between", { between: "page", lead: null, trail: null }],
166
+ ["after", { between: "page", lead: null, trail: "page" }],
167
+ ["around", { between: "page", lead: "page", trail: "page" }],
168
+ ]);
169
+ // The `break` values that leave the run's leading edge alone, and so cannot
170
+ // carry a `reset`. Read off the table, so it cannot drift from it.
171
+ let UNLED = new Set([...EDGES].filter(([, edges]) => !edges.lead).map(([value]) => value));
172
+ let RESETS = new Set(["page"]);
173
+
157
174
  // Which detail events carry the row's running values: the ones holding a cell.
158
175
  // A split's bracket holds none, so run rides the item and image events inside
159
176
  // it rather than the bracket around them.
@@ -236,21 +253,15 @@ let formatsMoney = (block) => {
236
253
  };
237
254
 
238
255
  // One pass over a report's constants: it checks each value, copies it, and
239
- // freezes the copy. Copying is what keeps a consumer from reaching back into
240
- // the compiled schema through a nested object; freezing is what stops them
241
- // mutating what they were handed.
256
+ // freezes the copy. Copying keeps a consumer from reaching back into the
257
+ // compiled schema through a nested object; freezing stops them mutating what
258
+ // they were handed.
242
259
  //
243
- // The vocabulary is JSON's, not structured clone's, because the document a
244
- // report is written in is JSON (SCHEMA.md, "Deliberate asymmetries"): a Date,
245
- // a Map or a typed array cannot appear in one, so accepting them here would
246
- // only ever serve a schema built in JS -- and it is what forced the caveat
247
- // about the values freezing cannot lock. Rejecting them makes the freeze
248
- // total. NaN and Infinity go with them: JSON cannot hold either, and
260
+ // The vocabulary is JSON's, not structured clone's, because a report document
261
+ // is JSON (SCHEMA.md, "Deliberate asymmetries"): rejecting a Date, a Map or a
262
+ // typed array makes the freeze total. NaN and Infinity go with them
249
263
  // coercing them to null would turn a definition error into a wrong value
250
- // three bands away.
251
- //
252
- // `seen` rejects cycles rather than surviving them, for the same reason: a
253
- // JSON document has no way to write one.
264
+ // three bands away — and `seen` rejects cycles, which JSON cannot write.
254
265
  /** @type {(value: any) => boolean} */
255
266
  let isJsonAtom = (value) =>
256
267
  value === null || typeof value === "boolean" || typeof value === "string";
@@ -485,17 +496,15 @@ let runProp = (expression, path, source) => (scope) => {
485
496
  };
486
497
  // The [live field](../../../CONTEXT.md#live-field): which page value a token
487
498
  // *is*, for a target whose own format numbers pages. sjabloon offers each
488
- // interpolation's expression source once, while compiling, which is the only
489
- // place the words the author wrote still exist -- a rendered token carries the
490
- // number and nothing about where it came from. Reading the source here instead
491
- // is what hard constraint 2 forbids, and a sentinel bound to `page.number` was
492
- // ruled out because xprsn's `==` is strict: it would break `=page.number == 1`.
499
+ // interpolation's expression source once while compiling, the only place the
500
+ // author's words still exist. Reading the source here instead is what hard
501
+ // constraint 2 forbids, and a sentinel bound to `page.number` was ruled out
502
+ // because xprsn's `==` is strict: it would break `=page.number == 1`.
493
503
  //
494
- // One table for the whole compile: the answer depends on the expression alone,
495
- // never on where the cell sits. A Map rather than a lookup object, because the
496
- // key is author source and nothing reached through `Object.prototype` is an
497
- // answer to this question; each name is written once and is its own answer,
498
- // frozen because every cell that says it shares the one object.
504
+ // One table for the whole compile: the answer depends on the expression
505
+ // alone. A Map rather than a lookup object, because the key is author source
506
+ // and nothing reached through `Object.prototype` answers this. Frozen,
507
+ // because every cell that says a name shares the one object.
499
508
  const PAGE_FIELDS = /** @type {Map<string, { field: import("./index.js").PageField }>} */ (
500
509
  new Map(["page.number", "page.total"].map((field) => [field, Object.freeze({ field })]))
501
510
  );
@@ -558,13 +567,10 @@ let primitives = ({ FNS, BND, names, functions, anchorsOf }) => {
558
567
  // rendered over quario's own scope chain, which already binds the anchors:
559
568
  // `$` at the render base, `@` on the detail row, neither anywhere else — so
560
569
  // a group or report band leaves `@` unbound and a stray `@.field` throws.
561
- // sjabloon's `bound` excludes its loop vars / `$` / `@` and quario's engine
562
- // names alike from the reported `names`. A render yields the cell's public
563
- // token stream: literal runs verbatim plus each interpolation's
564
- // pre-stringify value. Templates have no raw form a `{{{ }}}` tag is a
565
- // located SJABLOON_RAW_TAG definition error — so escaping is entirely a
566
- // render-target concern at the markup edge, with no exceptions to carry
567
- // through the stream.
570
+ // sjabloon's `bound` excludes its loop vars, `$`, `@` and quario's engine
571
+ // names from the reported `names`. Templates have no raw form — a
572
+ // `{{{ }}}` tag is a located SJABLOON_RAW_TAG definition error — so
573
+ // escaping is entirely a render target's concern at the markup edge.
568
574
  /** @type {(str: any, path: string) => (scope: Scope) => any} */
569
575
  let cell = (source, path) => {
570
576
  let tokens = compileCell(asText(source), path, source, FNS, BND);
@@ -692,12 +698,10 @@ let readers = ({ bad, warn, attempt }, { prop, cell, parseFold }, /** @type {any
692
698
  // warning rather than a definition error because the kind is a *later* edit
693
699
  // away and an editor session must never refuse a document mid-edit.
694
700
  //
695
- // The `currency` is read off `entries` rather than off the block, so one the
696
- // vocabulary already refused earns no second entry about that declaration:
697
- // `entries` is exactly what survived `check`. The `format` beside it is read
698
- // as written, because a `format` that was refused is a kind the engine will
699
- // not have either -- the code is unread all the same, and saying so is not a
700
- // second entry about the same key.
701
+ // The `currency` is read off `entries`, exactly what survived `check`, so
702
+ // one the vocabulary already refused earns no second entry. The `format`
703
+ // beside it is read as written: a refused `format` is a kind the engine
704
+ // will not have either, so the code is unread all the same.
701
705
  /** @type {(block: any, path: string, entries: StyleEntry[]) => void} */
702
706
  let unreadCurrency = (block, path, entries) => {
703
707
  if (!entries.some(([name]) => name === "currency")) return;
@@ -739,13 +743,11 @@ let readers = ({ bad, warn, attempt }, { prop, cell, parseFold }, /** @type {any
739
743
  // a guarded name that answers is written where it was declared rather than
740
744
  // after the ones that never needed guarding.
741
745
  //
742
- // `format` resolves after the loop rather than inside it, and the order is
743
- // what makes that necessary: a `currency` kind takes its digit count from
744
- // the code the cell wears, so the code has to have been written -- or
745
- // voided by `guarded` -- before the kind can be resolved over it. Doing it
746
- // per entry would make one declaration depend on where another sits in the
747
- // block. The instance's options come with it, for the cell that declares no
748
- // code of its own and takes the instance's (docs/adr/0056).
746
+ // `format` resolves after the loop, not inside it: a `currency` kind takes
747
+ // its digit count from the code the cell wears, so the code must have been
748
+ // written or voided by `guarded` first. Per entry would make one
749
+ // declaration depend on where another sits in the block. The instance's
750
+ // options come with it, for a cell declaring no code (docs/adr/0056).
749
751
  /** @type {(entries: StyleEntry[]) => (scope: Scope) => Scope} */
750
752
  let blockFn = (entries) => (scope) => {
751
753
  /** @type {Scope} */
@@ -912,16 +914,13 @@ let readers = ({ bad, warn, attempt }, { prop, cell, parseFold }, /** @type {any
912
914
  // settle it. That is the whole of why the warning below is incomplete by
913
915
  // construction (SCHEMA.md, "Validation").
914
916
  //
915
- // The two delimiters are the one place quario names sjabloon's syntax rather
916
- // than asking for it, and hard constraint 2 is why that is written down here
917
- // instead of passing unremarked. It is a *shape* test and not a parse:
918
- // nothing is tokenised, extracted or evaluated, the compile above stays the
919
- // only thing that reads the source, and the answer decides an advisory alone.
920
- // It has no false positives -- text outside every tag cannot be conditional,
921
- // since a block opens with `{{#`, and sjabloon strips no whitespace around a
922
- // standalone tag -- so a wrong answer costs a missing warning, never a
923
- // warning on a correct document. A value that is not one template is not this
924
- // question at all: a run array's runs each answer it for themselves.
917
+ // The two delimiters are the one place quario names sjabloon's syntax
918
+ // rather than asking for it (hard constraint 2). It is a *shape* test, not
919
+ // a parse: nothing is tokenised or evaluated, and the answer decides an
920
+ // advisory alone. It has no false positives text outside every tag cannot
921
+ // be conditional, a block opening with `{{#`, and sjabloon strips no
922
+ // whitespace around a standalone tag so a wrong answer costs a missing
923
+ // warning, never a warning on a correct document.
925
924
  /** @type {(src: any) => boolean} */
926
925
  let neverPresents = (src) =>
927
926
  typeof src === "string" && !(src.startsWith("{{") && src.endsWith("}}"));
@@ -1582,17 +1581,16 @@ let bands = (
1582
1581
  keys(value.header, ["style"], "detail.header");
1583
1582
  return rowStylesOf(value.header, "detail.header");
1584
1583
  };
1585
- // Which slot each column's header sits in -- the same invariant a total row's
1586
- // cells carry, walked positionally rather than summed because the diagnostic
1587
- // has to name the column it broke at. Read off the raw definitions, before
1588
- // any column is parsed, so `columnOf` can report its verdict in the column's
1589
- // own turn. Spans are read leniently: an unreadable one covers a single
1590
- // column and gets its own diagnostic when the column is parsed.
1584
+ // Which slot each column's header sits in, walked positionally rather than
1585
+ // summed because the diagnostic has to name the column it broke at. Read
1586
+ // off the raw definitions, before any column is parsed, so `columnOf` can
1587
+ // report its verdict in the column's own turn. An unreadable span covers a
1588
+ // single column and gets its own diagnostic there.
1591
1589
  //
1592
- // `own` fills its own slot, `covered` is filled by a neighbour's span, `over`
1593
- // reaches past the last column, and everything after an `over` is
1594
- // `unreached` -- one broken span should not also report every column behind
1595
- // it as headerless.
1590
+ // `own` fills its own slot, `covered` is filled by a neighbour's span,
1591
+ // `over` reaches past the last column, and everything after an `over` is
1592
+ // `unreached` one broken span should not report every column behind it as
1593
+ // headerless.
1596
1594
  // How many columns a definition's header claims, read leniently: a malformed
1597
1595
  // column, a missing header and an unreadable span all claim one here, and
1598
1596
  // each gets its own diagnostic when the column is parsed.
@@ -1799,11 +1797,25 @@ let bands = (
1799
1797
  if (groupNames.has(name)) bad(path, 'duplicate group name "' + name + '"');
1800
1798
  else groupNames.add(name);
1801
1799
  };
1802
- /** @type {(value: any, path: string, kind: string) => void} */
1803
- let checkHint = (value, path, kind) => {
1804
- if (value == null || value === "page") return;
1800
+ /** @type {(value: any, path: string, kind: string,
1801
+ * known: { has: (value: string) => boolean }) => void} */
1802
+ let checkHint = (value, path, kind, known) => {
1803
+ if (value == null || known.has(value)) return;
1805
1804
  bad(path, "unknown " + kind + ' "' + value + '"');
1806
1805
  };
1806
+ // A group's two structural literals, and the one rule tying them together.
1807
+ // A page-number sequence owns whole pages, so it needs a leading boundary
1808
+ // `break` may not supply (ADR 0077). That is read off the pair, and only for
1809
+ // the values this compile understood: an unknown `break` already has its own
1810
+ // problem, and a second about what it fails to supply would be that one
1811
+ // twice over.
1812
+ /** @type {(def: any, path: string) => void} */
1813
+ let checkHints = (def, path) => {
1814
+ checkHint(def.break, path + ".break", "break", EDGES);
1815
+ checkHint(def.reset, path + ".reset", "reset", RESETS);
1816
+ if (def.reset === "page" && (def.break == null || UNLED.has(def.break)))
1817
+ bad(path + ".reset", 'reset needs break "before" or "around"');
1818
+ };
1807
1819
  /** @type {(aggs: [string, Fold][], rows: any[], scope: Scope, handle: Scope) => Scope} */
1808
1820
  let applyAggs = (aggs, rows, scope, handle) => {
1809
1821
  /** @type {Scope} */
@@ -1840,10 +1852,38 @@ let bands = (
1840
1852
  rows,
1841
1853
  };
1842
1854
  };
1855
+ // One instance's bands, in order, as the events they yield.
1856
+ /** @type {(ctx: any, groupScope: any, ordered: any[], runners: any) => Generator<any>} */
1857
+ let instanceBands = function* (ctx, groupScope, ordered, runners) {
1858
+ yield* ctx.header(groupScope);
1859
+ yield* ctx.inner(ordered, groupScope, runners.extend(ctx.running));
1860
+ yield* ctx.footer(groupScope);
1861
+ };
1862
+ // Pass an instance's events through, and answer whether any of them
1863
+ // occupied the document. Only a group bracket can arrive without something
1864
+ // being there, so an instance holding nothing but hollow instances is hollow
1865
+ // itself, and one holding a child that drew is not.
1866
+ /** @type {(events: Iterable<any>) => Generator<any, boolean>} */
1867
+ let occupied = function* (events) {
1868
+ let drew = false;
1869
+ for (let event of events) {
1870
+ drew ||= event.type !== "group-start" && event.type !== "group-end";
1871
+ yield event;
1872
+ }
1873
+ return drew;
1874
+ };
1843
1875
  /** @type {(ctx: any) => Band} */
1844
1876
  let groupWalk = (ctx) =>
1845
1877
  function* (rows, scope, runners) {
1846
1878
  let parts = Map.groupBy(rows, (row) => ctx.by(withRow(scope, row)));
1879
+ // The four `break` values resolved to `ctx.edges` at compile, so a
1880
+ // target reads boundaries rather than positions: every boundary between
1881
+ // consecutive instances is the later instance's leading edge, and the
1882
+ // run's own two edges fall on its first and its last. The run is this
1883
+ // call's partition, so a nested group's edges belong to the parent
1884
+ // instance holding it rather than to the document.
1885
+ let last = parts.size - 1;
1886
+ let index = 0;
1847
1887
  for (let [key, groupRows] of parts) {
1848
1888
  // A group band has no current row, so `@` stays unbound here.
1849
1889
  let { groupScope, aggregates, rows: ordered } = openInstance(ctx, key, groupRows, scope);
@@ -1856,12 +1896,18 @@ let bands = (
1856
1896
  aggregates,
1857
1897
  path: ctx.path,
1858
1898
  },
1859
- { break: ctx.pageBreak, reset: ctx.reset, columns: ctx.columns },
1899
+ {
1900
+ break: index > 0 ? ctx.edges.between : ctx.edges.lead,
1901
+ breakAfter: index === last ? ctx.edges.trail : null,
1902
+ reset: ctx.reset,
1903
+ columns: ctx.columns,
1904
+ },
1860
1905
  );
1861
- yield* ctx.header(groupScope);
1862
- yield* ctx.inner(ordered, groupScope, runners.extend(ctx.running));
1863
- yield* ctx.footer(groupScope);
1864
- yield { type: "group-end", name: ctx.name, depth: ctx.index };
1906
+ let drew = yield* occupied(instanceBands(ctx, groupScope, ordered, runners));
1907
+ // The closing bracket says whether the instance is hollow, so no
1908
+ // target rediscovers it from the stream (docs/adr/0060, as amended).
1909
+ yield opt({ type: "group-end", name: ctx.name, depth: ctx.index }, { hollow: !drew });
1910
+ index++;
1865
1911
  }
1866
1912
  };
1867
1913
  /** @type {(def: any, path: string, defs: any[], index: number, detail: any, columned?: boolean) => Band} */
@@ -1886,8 +1932,7 @@ let bands = (
1886
1932
  named(def.name, path + ".name", RESERVED);
1887
1933
  noteGroup(def.name, path + ".name");
1888
1934
  let by = expression(def.by, path + ".by");
1889
- checkHint(def.break, path + ".break", "break");
1890
- checkHint(def.reset, path + ".reset", "reset");
1935
+ checkHints(def, path);
1891
1936
  let columns = columnsOf(def.columns, path + ".columns", columned);
1892
1937
  return groupWalk({
1893
1938
  by,
@@ -1900,7 +1945,7 @@ let bands = (
1900
1945
  inner: bandOf(defs, index + 1, detail, columned || columns != null),
1901
1946
  name: def.name,
1902
1947
  path,
1903
- pageBreak: def.break,
1948
+ edges: EDGES.get(def.break) ?? NONE,
1904
1949
  reset: def.reset,
1905
1950
  columns,
1906
1951
  index,
package/lib/reducers.js CHANGED
@@ -5,14 +5,11 @@
5
5
  * form `sum(@.lines, l => l.total)` and a declared aggregate alike — a step
6
6
  * folded across rows in render order for a declared running value, and a
7
7
  * spelling rule, since `count` is the one declared form that takes no
8
- * expression. Those three used to live in two hand-enumerated tables, a
9
- * derived name set, and a reducer named in the validator, so adding a reducer
10
- * was three edits and forgetting the runner passed validation and threw a raw
11
- * TypeError at render. One entry per reducer; every role derives from it.
8
+ * expression. One entry per reducer; every role derives from it.
12
9
  *
13
10
  * `prev` is the entry with no fold. There is no whole-array "previous", so it
14
- * is a running value only and that missing half is exactly what keeps it out
15
- * of an `aggregates` block, where `RUN = AGG ∪ {prev}` used to say so by hand.
11
+ * is a running value only, and that missing half is what keeps it out of an
12
+ * `aggregates` block.
16
13
  *
17
14
  * Coercion is quiet: only a finite number contributes, everything else folds
18
15
  * to 0, as the spec promises.
@@ -32,12 +29,22 @@ let num = (value) => {
32
29
  // why `countDistinct` reads its lambda this way despite the shared prefix.
33
30
  /** @type {(rows: any[] | null | undefined, of?: ((row: any) => any) | null) => any[]} */
34
31
  let project = (rows, of) => (rows || []).map((row) => (of ? of(row) : row));
35
- // `min`/`max` share one shape: project through the lambda, then pick a winner.
36
- /** @type {(pick: (a: any, b: any) => any) => Reducer} */
37
- let extreme = (pick) => (rows, of) => {
32
+ // The shape every reducer with no zero shares: project through the lambda,
33
+ // then answer from a nonempty array. An empty one has no answer, so it is
34
+ // `null` -- and an inline caller can tell, because a call is not the member
35
+ // read that normalizes an absent value.
36
+ /** @type {(from: (values: any[]) => any) => Reducer} */
37
+ let folded = (from) => (rows, of) => {
38
38
  let values = project(rows, of);
39
- return values.length ? values.reduce(pick) : null;
39
+ return values.length ? from(values) : null;
40
40
  };
41
+ // `min`/`max` pick a winner out of the projection. `first`/`last` take an end
42
+ // of it instead, which makes them the only folds that read the array's order
43
+ // rather than only its contents -- so the order a scope hands them is
44
+ // author-visible where no other reducer's is. SCHEMA.md, "Aggregates", says
45
+ // which order each scope hands.
46
+ /** @type {(pick: (a: any, b: any) => any) => Reducer} */
47
+ let extreme = (pick) => folded((values) => values.reduce(pick));
41
48
  /** @type {Reducer} */
42
49
  let sum = (rows, of) => (rows || []).reduce((total, row) => total + num(of ? of(row) : row), 0);
43
50
 
@@ -45,9 +52,10 @@ let sum = (rows, of) => (rows || []).reduce((total, row) => total + num(of ? of(
45
52
  * The table. Each entry is one reducer entire: how it folds an array, how it
46
53
  * steps a row, and whether its declared form carries `:=<expression>`.
47
54
  *
48
- * The running halves fold in render order. `min`/`max` have no zero, so they
49
- * seed to the first row's value — the one deliberate `undefined` here, since
50
- * xprsn reads absent values as null.
55
+ * The running halves fold in render order. `min`, `max` and `first` have no
56
+ * zero, so they seed to the first row's value — the deliberate `undefined`s
57
+ * here, since xprsn reads absent values as null and a runner's step only ever
58
+ * sees what an xprsn expression returned.
51
59
  * @type {Record<string, Entry>}
52
60
  */
53
61
  let REGISTRY = {
@@ -104,6 +112,23 @@ let REGISTRY = {
104
112
  },
105
113
  expr: true,
106
114
  },
115
+ first: {
116
+ fold: folded((values) => values[0]),
117
+ run: () => {
118
+ let opened = /** @type {any} */ (undefined);
119
+ return (value) => (opened = opened === undefined ? value : opened);
120
+ },
121
+ expr: true,
122
+ },
123
+ last: {
124
+ // The runner is the identity on purpose: the last row seen *is* this row,
125
+ // so `run.last` says what `@` already says. It exists because the run
126
+ // vocabulary is the reducer vocabulary, and SCHEMA.md states it rather
127
+ // than leaving the derivation to surprise an author (ADR 0006).
128
+ fold: folded((values) => values.at(-1)),
129
+ run: () => (value) => value,
130
+ expr: true,
131
+ },
107
132
  prev: {
108
133
  fold: null,
109
134
  run: () => {
@@ -177,19 +202,16 @@ let aLambda = demands(
177
202
  // nothing at all.
178
203
  //
179
204
  // The guard wraps the table's own entries because this is the one place the
180
- // reducer's name is in hand to say. Only one of the two consumers can carry an
181
- // authored mistake -- `plan.js` spreads this record into an expression's
182
- // function scope, where the arguments are whatever the author wrote -- while
183
- // `scope.js` folds rows the engine grouped through a compiled lambda, so
184
- // neither demand can fire there. That dead check is the price of one door
185
- // instead of two, and it is a comparison per fold, not per row. Nullish passes
186
- // both: an absent read is null, so `sum(@.lines)` on a row with no lines folds
187
- // the empty array, which is the answer the spec's six give.
205
+ // reducer's name is in hand to say. Only `plan.js` can carry an authored
206
+ // mistake here, since `scope.js` folds rows the engine grouped through a
207
+ // compiled lambda; that dead check is the price of one door instead of two,
208
+ // and it costs a comparison per fold, not per row. Nullish passes both: an
209
+ // absent read is null, so `sum(@.lines)` on a row with no lines folds the
210
+ // empty array.
188
211
  //
189
212
  // It carries no engine code: the mistake is quario's own verdict on an
190
- // authored call, like `round`'s bad `n`, and SCHEMA.md's "Validation" tells a
191
- // host to recognise a located error with `isDiagnostic` rather than by probing
192
- // a `code` anything could fake.
213
+ // authored call, and SCHEMA.md's "Validation" tells a host to recognise a
214
+ // located error with `isDiagnostic` rather than by probing a `code`.
193
215
  /** @type {(fn: string, fold: Reducer) => Reducer} */
194
216
  let guarded = (fn, fold) => (rows, of) => {
195
217
  anArray(fn, rows);
package/lib/stream.js CHANGED
@@ -93,11 +93,9 @@ export let reviveDate = (value) => {
93
93
  *
94
94
  * Passing the cell's resolved `format` declaration opts into the one coercion
95
95
  * the seam performs: under the `date` kind, a string in either accepted RFC
96
- * 3339 form revives to the `Date` it names. The gate is the declaration, and
97
- * the kind is `date` alone, because a date is the one type a JSON document
98
- * cannot carry a number arrives as a number, so no other kind has anything
99
- * to revive (ADR 0041). Callers that read no style omit the argument and see
100
- * the seam exactly as it was.
96
+ * 3339 form revives to the `Date` it names. `date` alone, because a date is
97
+ * the one type a JSON document cannot carry (ADR 0041). Callers that read no
98
+ * style omit the argument.
101
99
  *
102
100
  * @param {any[]} tokens A cell's tokens.
103
101
  * @param {unknown} [decl] The cell's resolved `format` declaration — the
@@ -112,13 +110,11 @@ export function typed(tokens, decl) {
112
110
  }
113
111
 
114
112
  /**
115
- * Whether a token list renders exactly one value token -- the seam `typed()`
116
- * gates on, and the same one `format` is read under. A run holding a literal
117
- * and an interpolation, or two interpolations, presents no single value, so a
118
- * declaration that presents a value has nothing to speak about (ADR 0061).
119
- *
120
- * Internal to the package: `typed()` is the public reading of it, and the
121
- * traversal asks it of the tokens each style block governs.
113
+ * Whether a token list renders exactly one value token the seam `typed()`
114
+ * gates on, and the same one `format` is read under. A run holding two
115
+ * interpolations presents no single value, so a declaration that presents a
116
+ * value has nothing to speak about (ADR 0061). Internal: `typed()` is the
117
+ * public reading of it.
122
118
  *
123
119
  * @type {(tokens: any[]) => boolean}
124
120
  */
@@ -156,13 +152,9 @@ let joins = (last, style) => !!last && sameStyle(last.style, style);
156
152
 
157
153
  /**
158
154
  * Group a cell's tokens into its styled runs, in order. Consecutive tokens
159
- * with equal styles are one run — a lossless rule, because equal styles render
160
- * identically, so grouping survives a JSON round trip and a target that
161
- * reserialized the stream reads the same runs back.
162
- *
163
- * Shipped so the built-in targets and a custom one cannot drift: the stream
164
- * carries a cell's runs flattened into one `tokens` array with an optional
165
- * per-token `style`, and this is the whole rule for reading them back
155
+ * with equal styles are one run — lossless, so grouping survives a JSON round
156
+ * trip. Shipped so the built-in targets and a custom one cannot drift: this
157
+ * is the whole rule for reading a cell's flattened `tokens` back
166
158
  * (SCHEMA.md, "Event stream"; ADR 0061).
167
159
  *
168
160
  * @param {any[]} tokens A cell's tokens.
@@ -194,11 +186,10 @@ let REPORT_BANDS = new Set([
194
186
  ]);
195
187
 
196
188
  /**
197
- * Whether a band item's `role` names one of the report's own bands rather than
198
- * a group instance's. This says whose band it is, never how to lay one out —
199
- * what a consumer does with the answer is its own. See SCHEMA.md ("Event
200
- * stream") for the contract and ADR 0028 for why the engine classifies rather
201
- * than carrying a verdict on the events themselves.
189
+ * Whether a band item's `role` names one of the report's own bands rather
190
+ * than a group instance's. This says whose band it is, never how to lay one
191
+ * out. ADR 0028 says why the engine classifies rather than carrying a verdict
192
+ * on the events themselves.
202
193
  *
203
194
  * @param {string} role An item or image event's `role`.
204
195
  * @returns {boolean} True for the report's own bands.
@@ -207,22 +198,18 @@ export function isReportBand(role) {
207
198
  return REPORT_BANDS.has(role);
208
199
  }
209
200
 
210
- // The cooperative hand-back's plumbing. A timer is the slowest way a runtime will
211
- // hand the loop back and, until this landed, the only one quario used: measured
212
- // on an M-series Mac it costs ~1.20ms per hand-back against ~0.015ms for
213
- // setImmediate, so a 10k-row render spent about 30% of its wall-clock waiting
214
- // on timers. Browsers are worse — once nested-timer depth passes five, the
215
- // HTML spec clamps a zero timer to 4ms, i.e. ~4ms per batch on a long report.
216
- // Deliberately not scheduler.yield(): it is Chrome-only, and its continuation
217
- // is scheduled ahead of other host tasks, which is the opposite of what handing
218
- // the loop back is for. The timer stays as the last rung for a runtime that
219
- // offers neither of the others; no runtime quario supports reaches it.
201
+ // The cooperative hand-back's plumbing. A timer is the slowest rung: ~1.20ms
202
+ // per hand-back against ~0.015ms for setImmediate, so a 10k-row render spent
203
+ // about 30% of its wall-clock on timers. Browsers are worse past a
204
+ // nested-timer depth of five the HTML spec clamps a zero timer to 4ms.
205
+ // Deliberately not scheduler.yield(): Chrome-only, and its continuation is
206
+ // scheduled ahead of other host tasks, the opposite of what handing the loop
207
+ // back is for. The timer stays as the last rung, which no runtime quario
208
+ // supports reaches.
220
209
  //
221
- // setImmediate is Node's alone, so reaching it is a feature detection and not
222
- // an import: this module runs in browsers too, where importing node:timers
223
- // would not resolve. The lookup goes through globalThis because the package's
224
- // types are deliberately DOM-only (only @quario/xlsx pulls Node's in) — a
225
- // typed global would mean a dependency for one name.
210
+ // setImmediate is Node's alone, so reaching it is a feature detection rather
211
+ // than an import: this module runs in browsers, where node:timers would not
212
+ // resolve. Through globalThis because the package's types are DOM-only.
226
213
  let immediate = /** @type {any} */ (globalThis).setImmediate;
227
214
  /** @type {MessageChannel | undefined} */
228
215
  let channel;
@@ -265,13 +252,10 @@ export async function breathe() {
265
252
  * so a target writes no event loop of its own — in stream order, exactly once
266
253
  * each, pulled lazily, handing the loop back every 1000 events. A missing
267
254
  * handler ignores that event, a handler that throws rejects without draining
268
- * the rest, and handlers are synchronous: a promise one returns is not
269
- * awaited. The stream's first event reaches its handler before a second one is
270
- * pulled, so a target settles what it needs from `report-start` — page band
271
- * closures, the marking in that handler rather than pulling the stream
272
- * itself. No opening event is required: the driver reads `event.type` and
273
- * nothing else, so a stream that starts part-way through walks like any other.
274
- * See SCHEMA.md ("The walk driver"), which is normative.
255
+ * the rest, and handlers are synchronous. The first event reaches its handler
256
+ * before a second is pulled, so a target settles what it needs from
257
+ * `report-start` there. No opening event is required: the driver reads
258
+ * `event.type` and nothing else. SCHEMA.md ("The walk driver") is normative.
275
259
  *
276
260
  * @param {Iterable<any>} events One render's event stream.
277
261
  * @param {Record<string, (event: any) => void>} handlers Per-event handlers.
@@ -285,6 +269,48 @@ export async function walk(events, handlers) {
285
269
  }
286
270
  }
287
271
 
272
+ // What each event does to the bracket being folded, and what it yields, if
273
+ // anything. Outside a bracket an event passes straight through; inside, it is
274
+ // a slot. A stray `split-end` with no opening -- a stream taken up part-way --
275
+ // yields nothing.
276
+ /** @type {Record<string, (fold: { bracket: any }, event: any) => any>} */
277
+ let FOLD = {
278
+ "split-start": (fold, event) => {
279
+ fold.bracket = { ...event, type: "split", items: [] };
280
+ return null;
281
+ },
282
+ "split-end": (fold) => {
283
+ let out = fold.bracket;
284
+ fold.bracket = null;
285
+ return out;
286
+ },
287
+ };
288
+ /** @type {(fold: { bracket: any }, event: any) => any} */
289
+ let plain = (fold, event) => {
290
+ if (!fold.bracket) return event;
291
+ fold.bracket.items.push(event);
292
+ return null;
293
+ };
294
+
295
+ /**
296
+ * The split bracket, folded: `split-start`, the slot events between, and
297
+ * `split-end` become one `split` event wearing the opening's `role`, `path`,
298
+ * `slots` and `style`, with the slot events in order under `items`. Every
299
+ * other event passes through untouched, lazily, so a consumer that wants a
300
+ * split whole reads `splits(stream)` instead of keeping a bracket of its own.
301
+ * SCHEMA.md ("The walk driver") is normative.
302
+ *
303
+ * @param {Iterable<any>} events One render's event stream.
304
+ * @returns {Generator<any, void>} The same stream, each bracket one event.
305
+ */
306
+ export function* splits(events) {
307
+ let fold = { bracket: null };
308
+ for (let event of events) {
309
+ let out = (FOLD[event.type] ?? plain)(fold, event);
310
+ if (out) yield out;
311
+ }
312
+ }
313
+
288
314
  // Optional public event fields are present only when truthy — the one rule
289
315
  // for optional event data, owned here. What rides it is therefore any value
290
316
  // whose falsy form means "undeclared"; a field whose zero or empty string is
package/lib/style.js CHANGED
@@ -9,13 +9,8 @@
9
9
  * The **resolutions** are what a declaration means before any target sees it.
10
10
  * A row's box is the one there is (SCHEMA.md, "Style declarations"): it says
11
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.
12
+ * is handed the answer. `finite`/`HEX` sit on the other side of that line and
13
+ * are restated per target, a coercion being each target's own edge.
19
14
  */
20
15
  import { record } from "./names.js";
21
16
  import { defaultDigits } from "./precision.js";
@@ -147,10 +142,9 @@ export let FORMAT_VOCABULARY = Object.freeze({
147
142
  * the guard every name from a document gets: a `kind` resolved off
148
143
  * `Object.prototype` is not a kind.
149
144
  *
150
- * Exported because the typed-cell seam asks the same question and only that
151
- * question: `typed()` gates its one coercion on the kind being `date`, and
152
- * resolving a whole declaration to read one field off it would allocate per
153
- * cell for nothing (`./stream.js`).
145
+ * Exported because `typed()` asks the same question and only that question:
146
+ * resolving a whole declaration to read one field would allocate per cell
147
+ * for nothing (`./stream.js`).
154
148
  *
155
149
  * @type {(value: any) => string | undefined}
156
150
  */
@@ -398,19 +392,15 @@ export let checkRowStyle = withoutNames(ROW_NAMES, checkStyle, "a table row");
398
392
  // --- resolution --------------------------------------------------------------
399
393
 
400
394
  // A resolved `=` result the vocabulary rejects, judged once where every style
401
- // block resolves rather than at each surface that would read it. What the
402
- // declaration falls back *to* decides what becomes of it, and that question
403
- // partitions the vocabulary three ways -- omitted, kept, voided. The argument
404
- // for each is docs/adr/0055; what is restated here is only which is which, and
405
- // why the two exceptions cannot be folded into the general rule.
395
+ // block resolves. What the declaration falls back *to* partitions the
396
+ // vocabulary three ways omitted, kept, voided (docs/adr/0055).
406
397
  //
407
- // A literal never reaches here: `checkStyle` already refused it at compile, so
408
- // only an `=` entry is ever guarded.
398
+ // A literal never reaches here: `checkStyle` refused it at compile, so only
399
+ // an `=` entry is ever guarded.
409
400
  //
410
- // `format` then takes one more step that no other name needs, below: whatever
411
- // survives is **resolved**, the shorthand widened and the kind's own answer
412
- // filled in, because what crosses the stream is the declaration with its digit
413
- // count or its form already on it (docs/adr/0056).
401
+ // `format` then takes one more step below: whatever survives is **resolved**,
402
+ // the shorthand widened and the kind's own answer filled in, so what crosses
403
+ // the stream carries its digit count or its form (docs/adr/0056).
414
404
 
415
405
  // The sentinel a failed result becomes when its name is one the engine omits.
416
406
  // A symbol, so nothing an expression can evaluate to is ever equal to it --
@@ -424,20 +414,16 @@ export let SKIP = Symbol("skip");
424
414
  * Wrap a style entry's compiled `=` result in the rule its name answers to.
425
415
  *
426
416
  * **Omitted** is the general rule and needs no branch: the key is never
427
- * written, so the layer below stands. That is what the spec means by
428
- * "contributes nothing, same as omit" (SCHEMA.md, "Style declarations") -- a
429
- * report-header item whose `bold` returns `1`, or whose `size` returns
430
- * `"big"`, keeps its band-role bold and its band-role size rather than losing
431
- * them to a value no target can read. It is the rule for the box too: side
432
- * ownership is settled over the names a block declares, before any of this
433
- * resolves, so dropping a rejected `borderTopColor` cannot hand the cell its
434
- * row's other two (docs/adr/0062).
417
+ * written, so the layer below stands "contributes nothing, same as omit"
418
+ * (SCHEMA.md). It is the rule for the box too: side ownership is settled over
419
+ * the names a block declares, before any of this resolves, so dropping a
420
+ * rejected `borderTopColor` cannot hand the cell its row's other two
421
+ * (docs/adr/0062).
435
422
  *
436
- * **Voided** is `currency` alone. Its fallback is not the layer below but the
437
- * instance's own code, so omitting would present one denomination's money
438
- * under another's name -- the one failure louder than no formatting at all.
439
- * `null` keeps the cell's answer, which nothing can present, so the cell drops
440
- * to `display()` as a bogus `format` kind does (docs/adr/0041).
423
+ * **Voided** is `currency` alone. Its fallback is the instance's own code,
424
+ * not the layer below, so omitting would present one denomination's money
425
+ * under another's name. `null` keeps an answer nothing can present, so the
426
+ * cell drops to `display()` (docs/adr/0041).
441
427
  *
442
428
  * @type {(name: string, resolve: (scope: any) => any) => (scope: any) => any}
443
429
  */
@@ -506,10 +492,8 @@ let presentedNumber = (kind, own, style, options) => {
506
492
  * own answer and no consumer computes one (docs/adr/0053, docs/adr/0056).
507
493
  *
508
494
  * A `currency` whose code Intl will not read resolves to a kind with **no**
509
- * `digits` the withdrawal that leaves the worksheet writing no number
510
- * format. A declared `digits` still crosses there: it resolved on its own
511
- * terms, and making it depend on a sibling declaration would be a special
512
- * case bought for nothing.
495
+ * `digits`, the withdrawal that leaves the worksheet writing no number
496
+ * format. A declared `digits` still crosses: it resolved on its own terms.
513
497
  *
514
498
  * @param {{ format?: unknown, currency?: unknown } | null} [style] A cell's style.
515
499
  * @param {{ currency?: string } | null} [options] The instance's options.
@@ -529,15 +513,10 @@ export let formatOf = (style, options) => {
529
513
  * past `guarded` that a name needs here, and the last thing a style block has
530
514
  * done to it before a target sees it (docs/adr/0056).
531
515
  *
532
- * By the time this runs the declaration is already sound: a literal passed
533
- * `checkStyle` at compile, and an `=` result passed `guarded`, which omits a
534
- * kind it cannot read the way it omits any other rejected value. So the
535
- * `delete` below is not the omit rule -- that is `guarded`'s -- but the answer
536
- * to a kind that reads and still presents nothing, which today is only a
537
- * `currency` whose code Intl will not take.
538
- *
539
- * The block is returned rather than mutated silently, because it is the value
540
- * `styleFn` hands on.
516
+ * By the time this runs the declaration is sound: a literal passed
517
+ * `checkStyle` at compile, an `=` result passed `guarded`. So the `delete`
518
+ * below is not the omit rule but the answer to a kind that reads and still
519
+ * presents nothing today only a `currency` whose code Intl will not take.
541
520
  *
542
521
  * @type {(style: any, options?: any) => any}
543
522
  */
@@ -556,9 +535,8 @@ export let resolveFormat = (style, options) => {
556
535
  * rather than once per helper.
557
536
  *
558
537
  * A resolved style already carries the answer, and a target reads it off the
559
- * declaration rather than calling this. What this is for is a host holding a
560
- * style the stream has not resolved: the shorthand is widened here first, so
561
- * either may be asked.
538
+ * declaration rather than calling this. This is for a host holding a style
539
+ * the stream has not resolved: the shorthand is widened here first.
562
540
  *
563
541
  * @param {{ format?: unknown, currency?: unknown } | null} [style] A cell's style.
564
542
  * @param {{ currency?: string } | null} [options] The instance's currency code.
@@ -570,14 +548,12 @@ export let fractionDigits = (style, options) => formatOf(style, options)?.digits
570
548
 
571
549
  // A row's `style` resolves onto its cells (SCHEMA.md, "Style declarations").
572
550
  // The box is the half that cannot arrive any other way: a table row is not a
573
- // box on any surface -- not a `<tr>`, not a worksheet row, not a band of the
574
- // PDF's own -- so it is resolved here, once, and no target is left to have an
575
- // opinion about it. Every other declaration on a row reaches its cells by the
576
- // layering each target already does, which is why only the box moves.
551
+ // box on any surface, so it is resolved here once and no target is left with
552
+ // an opinion. Every other declaration reaches the cells by the layering each
553
+ // target already does, which is why only the box moves.
577
554
  //
578
- // Which names are the box is decided where the block is compiled, not here: a
579
- // literal row style is partitioned once per table rather than once per row.
580
- // So is which of them reaches which cell -- see `boxReaching`. What is left
555
+ // Which names are the box, and which reach which cell, are decided where the
556
+ // block is compiled once per table rather than once per row. What is left
581
557
  // for render is writing the answer.
582
558
 
583
559
  // The three names of each border side, built once, which is what lets
@@ -591,12 +567,10 @@ let BORDER_NAMES = Object.fromEntries(
591
567
  * The names an under-box loses once an over-box has spoken: every name the
592
568
  * over-box states, plus the other two of any border side it states one of.
593
569
  *
594
- * This is the whole-side rule, over declaration names rather than resolved
595
- * objects -- which is all it ever needed, since a compiled block writes every
596
- * name it declares whatever the values resolve to. Composing the totals block
597
- * into its rows uses it at compile time, so no third style level survives to
598
- * render (docs/adr/0049); fanning a row's box onto its cells uses it for the
599
- * same reason (docs/adr/0062).
570
+ * The whole-side rule, over declaration names rather than resolved objects: a
571
+ * compiled block writes every name it declares whatever the values resolve
572
+ * to. Used composing the totals block into its rows (docs/adr/0049) and
573
+ * fanning a row's box onto its cells (docs/adr/0062).
600
574
  *
601
575
  * @type {(names: Iterable<string>) => Set<string>}
602
576
  */
@@ -612,12 +586,10 @@ export let coveredNames = (names) => {
612
586
  * Which of a row box's names reach one cell: the row's, less everything the
613
587
  * cell's own declarations cover. A border side is won whole by the cell that
614
588
  * names any of its three parts, so a cell that failed-soft on one name does
615
- * not pick up the row's other two and become a stroke nobody declared -- the
616
- * question settled here, once per table, rather than per cell per row.
589
+ * not pick up the row's other two. Settled once per table.
617
590
  *
618
- * `null` is the answer for a cell that declares no box name at all, which is
619
- * the common case: the row's box is that cell's box entire, so `fanBox` hands
620
- * the object over rather than copying it name by name.
591
+ * `null` is the common case, a cell declaring no box name: the row's box is
592
+ * that cell's entire, so `fanBox` hands the object over rather than copying.
621
593
  *
622
594
  * @type {(rowNames: readonly string[], cellNames: readonly string[]) => string[] | null}
623
595
  */
package/package.json CHANGED
@@ -1,7 +1,15 @@
1
1
  {
2
2
  "name": "quario",
3
- "version": "0.9.0",
4
- "description": "A tiny, runtime-neutral report engine in the makings, not yet released",
3
+ "version": "0.10.0",
4
+ "description": "Tiny, CSP-safe report engine for JavaScript. One JSON document renders to HTML, PDF, XLSX, CSV and Word.",
5
+ "keywords": [
6
+ "csp",
7
+ "json",
8
+ "pdf",
9
+ "report",
10
+ "reporting",
11
+ "xlsx"
12
+ ],
5
13
  "homepage": "https://getquario.com",
6
14
  "license": "SEE LICENSE IN LICENSE",
7
15
  "repository": {
@@ -51,7 +59,7 @@
51
59
  "sjabloon",
52
60
  "padvinder"
53
61
  ],
54
- "limit": "14 kB"
62
+ "limit": "14.2 kB"
55
63
  }
56
64
  ],
57
65
  "engines": {