quario 0.5.0 → 0.7.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/README.md CHANGED
@@ -51,9 +51,10 @@ surface at compile time, so compile at startup and render in your request path.
51
51
 
52
52
  ### `quario(options?)`
53
53
 
54
- Creates a configured instance with host-level controls: `{ query?, license? }`. Returns
55
- `{ report, license }`. `license` settles with this instance's key verification as
56
- `{ licensed, licensee?, id? }`.
54
+ Creates a configured instance with host-level controls: `{ query?, license?, locale?, currency?,
55
+ timeZone? }` — the query budget, the license key, and the `format` configuration (`en-US`, no
56
+ default currency, UTC). Returns `{ report, plan, license }`. `license` settles with this instance's
57
+ key verification as `{ licensed, licensee?, id? }`.
57
58
 
58
59
  ### `report(schema, functions?)`
59
60
 
@@ -62,7 +63,8 @@ Compiles a report and returns the compiled report. `stream(data)` is the raw eve
62
63
  (e.g. `html()` from `@quario/html`) or your own.
63
64
 
64
65
  A render is async: quario awaits key verification before the target sees its first event. A
65
- malformed target throws at compile time, like any other definition error.
66
+ malformed target throws synchronously from `render`, before the first event; definition problems
67
+ throw earlier, at `report()`.
66
68
 
67
69
  The data pre-pass (select, filter, sort, aggregate) runs when you call the renderer, because a
68
70
  report header may interpolate a report aggregate. Event emission pulls on demand, so a consumer
@@ -72,27 +74,29 @@ The compiled report carries metadata:
72
74
 
73
75
  ```js
74
76
  report.names; // free variable names the expressions read, excluding engine anchors
75
- report.functions; // registry function names the definition calls
77
+ report.functions; // { name, arity } per registry function the definition calls
76
78
  report.paths; // padvinder's deeply frozen dependency topology for the `data` query
77
79
  ```
78
80
 
79
81
  Events arrive in render order: `report-start`, report `header` items, then either the `empty`
80
82
  items or the group/detail walk, then `footer` items, `report-end`. Group instances bracket their
81
83
  content with `group-start`/`group-end`; a table detail yields `table-start`, one `row` per visible
82
- row, an optional `total-row`, and `table-end`.
83
-
84
- | Event | Carries |
85
- | -------------- | ------------------------------------------------------------------------------------------- |
86
- | `report-start` | `params`, resolved report `aggregates`, optional `page` band closures, `columns`, `marking` |
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
- | `group-end` | `name`, `depth` |
91
- | `table-start` | `path`, `columns` (each `header`, `path`, optional `width`) |
92
- | `row` | `cells`, optional `style`, `run` |
93
- | `total-row` | `cells` |
94
- | `table-end` | - |
95
- | `report-end` | - |
84
+ row, one `total-row` per emitted total row, and `table-end`.
85
+
86
+ | Event | Carries |
87
+ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
88
+ | `report-start` | `params`, resolved report `aggregates`, optional `page` band closures, `columns`, `style` (the report default), `marking`, `margin`, `headerHeight`, and the instance's `locale` / `currency` / `timeZone` when set |
89
+ | `item` | `role`, `path`, `tokens`, optional `style`, `run` |
90
+ | `image` | `role`, `path`, `bytes`, `format` (`png` \| `jpeg`), `fit`, optional `alt`, `style`, `run` |
91
+ | `split-start` | `role`, `slots` (each an optional `width`), optional `style`; one `item` or `image` per slot follows, then `split-end` |
92
+ | `split-end` | - |
93
+ | `group-start` | `name`, `depth`, `key`, `aggregates`, `path`, optional `break`, `reset`, `columns` |
94
+ | `group-end` | `name`, `depth` |
95
+ | `table-start` | `path`, `header` (`cells`, optional `style`), `columns` (each an optional `width`) |
96
+ | `row` | `cells`, optional `style`, `run` |
97
+ | `total-row` | `cells`, optional `style` |
98
+ | `table-end` | - |
99
+ | `report-end` | - |
96
100
 
97
101
  **Cells carry tokens.** A cell is `{ tokens, style? }`. Each token is either `{ literal }`
98
102
  (static template text, verbatim) or `{ value }` (one interpolation's _pre-format_ value, the
@@ -106,21 +110,31 @@ own edge.
106
110
  **`report-start.marking`** carries the evaluation wording when the render is unlicensed, or while
107
111
  verification is still settling. Licensed streams omit it. Targets place the marking; they do not
108
112
  author its wording. **`columns`** on `report-start` / `group-start` is the declared
109
- page column count when present. No shipped target honors it yet;
113
+ page column count when present. `@quario/pdf` and `@quario/html` lay page columns out;
110
114
  xlsx never will.
111
115
 
112
116
  ### `text(tokens)`
113
117
 
114
- Joins a token stream to display text: literals verbatim, values as `String(value ?? "")`, run
115
- styles ignored. Re-exported from sjabloon so consumers do not hand-roll the join.
118
+ Joins a token stream to display text: literals verbatim, values through `display()` (a `Date`
119
+ as ISO 8601, nullish as the empty string), run styles ignored. Re-exported from sjabloon so consumers do not hand-roll the join.
116
120
 
117
- ### `typed(tokens)`
121
+ ### `typed(tokens, kind?)`
118
122
 
119
123
  Exactly one value token holding a finite number, a boolean, or a valid `Date` keeps that
120
124
  pre-stringify value. Anything else, including a lone null, reports `undefined` and joins to
121
- display text. Spreadsheet consumers use this for real numeric cells;
125
+ display text. Passing the cell's resolved `format` kind opts into the seam's one coercion: under
126
+ `"date"`, an RFC 3339 string revives to the `Date` it names. Spreadsheet consumers use this for
127
+ real numeric cells;
122
128
  [`@quario/csv`](https://www.npmjs.com/package/@quario/csv) is the short form.
123
129
 
130
+ ### `styledRuns(tokens)`
131
+
132
+ Groups a cell's tokens into its **styled runs**, in order — each `{ style, tokens }`,
133
+ with `style` `null` where the tokens carry none and the cell's own applies. Consecutive tokens
134
+ with equal styles are one run, which is lossless: equal styles render identically, so the grouping
135
+ survives a JSON round trip. Every built-in target reads a cell's runs through this, so a custom
136
+ target cannot drift from them.
137
+
124
138
  ### `walk(events, handlers)` / `breathe()`
125
139
 
126
140
  `walk` is the delivery driver every official target uses. Pass one render's event iterable and
@@ -132,6 +146,25 @@ itself.
132
146
  `breathe()` is that hand-back alone. Await it between batches of a loop you own; `walk` already
133
147
  calls it for you.
134
148
 
149
+ ### Presentation helpers
150
+
151
+ A target that stringifies imports these rather than restating them, so every surface presents a
152
+ cell the same way:
153
+
154
+ - `display(value)` — the scalar rule `text()` joins with: a `Date` as ISO 8601 UTC, nullish as
155
+ the empty string, everything else `String(value)`.
156
+ - `format(value, style?, options?)` — presents a token under the cell's resolved `format`
157
+ declaration (its kind and modifier, and for `currency` the cell's own code); `undefined` when the kind does not apply, so the caller falls back to
158
+ `display()`.
159
+ - `fractionDigits(style?, options?)` — the digit count a resolved `format` declaration presents:
160
+ the kind's own (two for `number` and `percent`, a currency's minor units for `currency`) unless
161
+ the declaration's `digits` overrides it; `undefined` where there is no count.
162
+ - `currencyOf(style?, options?)` — which code a money cell wears: its own, else the instance's.
163
+ - `isReportBand(role)` — whether a role names one of the report's own bands rather than a
164
+ group's.
165
+ - `STYLE_NAMES` — the closed style vocabulary, in the spec's order, and
166
+ `RUN_STYLE_NAMES` — the inline half of it, which is what a styled run may wear.
167
+
135
168
  ### `validate(schema, functions?)`
136
169
 
137
170
  Checks a definition without rendering it. Returns every problem as a path-prefixed string; an
@@ -148,34 +181,49 @@ so `validate()` can never disagree with what `report()` accepts.
148
181
  ### `quario().plan(schema, functions?)`
149
182
 
150
183
  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`.
184
+ `{ report, problems, anchors, warnings }`: the compiled report (`null` while the document has
185
+ problems), every problem structurally as `{ path, source?, message, diagnostic? }` (the `message`
186
+ is exactly `validate()`'s string, and every problem keeps its own located diagnostic with
187
+ `start`/`end` offsets, not only the first), `anchors`, mapping each compiled source's schema path
188
+ to the anchors and group handles it reads — the unfiltered complement of `names` — and
189
+ `warnings`.
190
+
191
+ A warning is `{ path, source?, message }`: the document declares something nothing will read. It
192
+ is not a problem at a lower severity, which is why it has no `diagnostic` — nothing raised, the
193
+ engine decided. Neither warning below locates into an authored source, so none carries a `source`
194
+ today. **A warning is never fatal**: a document carrying only warnings compiles and
195
+ renders, so `report` is `null` on `problems` alone. Two declarations warn today — a `currency` on
196
+ a cell whose `format` is not `"currency"`, and a table where every column is sized and the widths
197
+ total under 100 — and the list is advisory and deliberately incomplete, so a quiet one is not a
198
+ promise that every declaration will be read. `validate()` returns problems only.
156
199
 
157
200
  ```js
158
- const { report, problems, anchors } = quario().plan(schema);
201
+ const { report, problems, anchors, warnings } = quario().plan(schema);
202
+ for (const warning of warnings) console.warn(warning.message);
159
203
  if (report) await report.render(html(), data);
160
204
  else console.error(problems[0].path, problems[0].message);
161
205
  ```
162
206
 
163
207
  ### `isDiagnostic(error)`
164
208
 
165
- True when a caught value is one of the stack's located errors: quario's own, or one thrown
166
- directly by xprsn, sjabloon, or padvinder. Authentication checks identity. An error that only
167
- matches the shape does not pass.
209
+ True when a caught value is a **located diagnostic**: an error xprsn, sjabloon or padvinder
210
+ minted — thrown by that engine, or re-thrown by quario with the engine original behind it.
211
+ Authentication checks identity. An error that only matches the shape does not pass.
168
212
 
169
- Located errors name their band path and offending source while keeping their original type
170
- (`SyntaxError`, `TypeError`, `RangeError`). They carry `code`, `start`/`end` offsets, and (for
171
- query budget failures) `limit` and `actual`.
213
+ Being located is not what the guard reads: quario's own verdicts on a document and a registered
214
+ function's own throw are located too, and neither is a diagnostic. Errors a report throws
215
+ name the path they failed at — and the offending source, where there is one — while keeping their
216
+ original type (`SyntaxError`, `TypeError`, `RangeError`). What a diagnostic adds on top is
217
+ metadata an engine vouches for: `code`, `start`/`end` offsets, and, for a query budget in place
218
+ of those offsets, `limit` and `actual`.
172
219
 
173
220
  ```js
174
221
  try {
175
- await renderer(data);
222
+ await report.render(html(), data);
176
223
  } catch (e) {
177
- if (isDiagnostic(e)) console.error("report problem:", e.message);
178
- else throw e; // one of your own functions failed
224
+ // Every error names where it failed; a diagnostic also carries an engine's own metadata.
225
+ if (isDiagnostic(e)) console.error(e.code, e.start, e.end);
226
+ throw e;
179
227
  }
180
228
  ```
181
229
 
@@ -0,0 +1,123 @@
1
+ /**
2
+ * The band-height rule: what a report header's declared `height` forbids of
3
+ * whatever follows its box (docs/adr/0038, docs/adr/0052).
4
+ *
5
+ * A pinned header ends at a fixed offset from the page top, so the band under
6
+ * it starts there and an authored lead would push it off the pin. The rule is
7
+ * therefore about one item — the first occupying item of the band that follows
8
+ * — and it is enforced twice, because which item that is can be a fact about
9
+ * the document or a fact about the render. `plan.js` refuses the ones the
10
+ * document settles; `index.js` refuses the rest as the stream produces them.
11
+ * Both read this file, so the two never disagree about what a lead is or which
12
+ * node a fault names.
13
+ *
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").
19
+ */
20
+
21
+ import { isExpr, positivePts } from "./style.js";
22
+
23
+ /**
24
+ * A lead: authored flow spacing before an item that a target would actually
25
+ * paint. A positive number of points — the style vocabulary's own shape, less
26
+ * the zero it admits, which is the value the pin asks for. `spaceOf` in
27
+ * @quario/layout applies the same test; @quario/html's `isPad` is deliberately
28
+ * wider, admitting the `0` that is harmless to emit as CSS. They are restated
29
+ * per package on purpose: the engine is their peer dependency, so it cannot
30
+ * import either.
31
+ *
32
+ * @type {(n: any) => boolean}
33
+ */
34
+ export let isLead = positivePts;
35
+
36
+ /** The one wording, so both enforcement sites report the same sentence. */
37
+ export let LEAD_REFUSED = "must be 0 after a height-declared header";
38
+
39
+ /**
40
+ * The path of the key a lead fault names: the offending item's own
41
+ * `style.spaceBefore`.
42
+ *
43
+ * @param {string} path The item definition's schema path.
44
+ * @returns {string} The located key.
45
+ */
46
+ export let leadPath = (path) => path + ".style.spaceBefore";
47
+
48
+ /**
49
+ * An occupancy the document leaves to the data: a `visible` written as an
50
+ * expression, or an image, whose bytes are expression-only and whose event is
51
+ * absent when its `source` yields nothing (SCHEMA.md, "Event stream"). Neither
52
+ * is a defect — they are how an author writes an optional item — but they mean
53
+ * the traversal cannot say which item comes first, so it says nothing rather
54
+ * than guessing (docs/adr/0052).
55
+ *
56
+ * @type {(def: any) => boolean}
57
+ */
58
+ let dataDecides = (def) => isExpr(def?.visible) || def?.type === "image";
59
+
60
+ /**
61
+ * The first item of a band the document settles as occupying, `undefined` when
62
+ * only the render settles it, and null for a band settled to occupy nothing.
63
+ *
64
+ * @type {(list: any, path: string) => { def: any, path: string } | undefined | null}
65
+ */
66
+ let firstOccupying = (list, path) => {
67
+ if (!Array.isArray(list)) return null;
68
+ let i = list.findIndex((def) => def?.visible !== false);
69
+ if (i < 0) return null;
70
+ return dataDecides(list[i]) ? undefined : { def: list[i], path: path + "[" + i + "]" };
71
+ };
72
+
73
+ /**
74
+ * The guard the traversal carries while it descends.
75
+ *
76
+ * 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.
85
+ *
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.
90
+ *
91
+ * @param {(path: string, message: string) => void} bad The traversal's collector.
92
+ * @returns {{ arm: (height: number | null) => void,
93
+ * instead: (list: any, path: string) => void,
94
+ * body: (list: any, path: string) => void }} The guard.
95
+ */
96
+ export let pinGuard = (bad) => {
97
+ let pinned = false;
98
+ let settled = true;
99
+ /** @type {(found: { def: any, path: string } | undefined | null) => void} */
100
+ let refuse = (found) => {
101
+ if (found && isLead(found.def.style?.spaceBefore)) bad(leadPath(found.path), LEAD_REFUSED);
102
+ };
103
+ return {
104
+ arm: (/** @type {number | null} */ height) => {
105
+ pinned = height != null;
106
+ settled = !pinned;
107
+ },
108
+ instead: (/** @type {any} */ list, /** @type {string} */ path) => {
109
+ if (pinned) refuse(firstOccupying(list, path));
110
+ },
111
+ body: (/** @type {any} */ list, /** @type {string} */ path) => {
112
+ if (settled) return;
113
+ let found = firstOccupying(list, path);
114
+ // A band settled to occupy nothing is not the one under the box, so the
115
+ // descent keeps looking; one only the render settles ends the search
116
+ // without a verdict, because whether the band below is next is exactly
117
+ // what it left open.
118
+ if (found === null) return;
119
+ settled = true;
120
+ refuse(found);
121
+ },
122
+ };
123
+ };
package/lib/format.js CHANGED
@@ -1,66 +1,151 @@
1
1
  /**
2
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.
3
+ * Targets import this instead of each other: the stream keeps the resolved
4
+ * declaration on `style` and the token raw, and HTML/PDF stringify at the
5
+ * edge. A kind on the wrong type, or on null, contributes nothing — the
6
+ * caller falls back to `display()`. Invalid locale or currency codes fail the
7
+ * same way. Under the `currency` kind a cell's own `style.currency` names the
8
+ * denomination and beats the instance's default code.
7
9
  *
8
10
  * Defaults (`en-US`, UTC) are pinned so a PDF without a host locale still
9
11
  * renders the same bytes on every machine (docs/adr/0041, docs/adr/0025).
12
+ *
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()`.
10
19
  */
20
+ import { boundedMemo } from "./memo.js";
21
+ import { currencyOf, formatOf } from "./style.js";
11
22
  import { finiteDate, finiteNum, reviveDate } from "./stream.js";
12
23
  /** @type {(options: any) => string} */
13
24
  let localeOf = (options) => options?.locale || "en-US";
14
25
  /** @type {(options: any) => string} */
15
26
  let zoneOf = (options) => options?.timeZone || "UTC";
16
27
 
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;
28
+ // The formatters are memoised behind `./memo.js`'s bounded store, which
29
+ // carries the rules a bounded memo has; what is here is what only this call
30
+ // site knows -- its key, and its cap.
31
+ //
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.
42
+ //
43
+ // **The cap is the part to read**, because the key space is not the host's to
44
+ // bound. A currency code reaches this from author data through
45
+ // `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.
49
+ //
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.
55
+ //
56
+ // Memoising a fact that cannot change within an ICU version, like
57
+ // `fractionDigits`' own minor-units map (docs/adr/0025).
58
+ let memo = boundedMemo(2048);
59
+
60
+ // The kind leads, so a number's key can never read as a date's; the currency
61
+ // is empty for the two kinds that wear none.
62
+ /** @type {(locale: string, decl: any, rest?: any) => string} */
63
+ let numberKey = (locale, decl, rest) =>
64
+ decl.kind + "|" + locale + "|" + decl.digits + "|" + (rest?.currency || "");
65
+
66
+ /** @type {(value: any, locale: string, decl: any, rest?: any) => string | undefined} */
67
+ let asFixed = (value, locale, decl, rest) => {
68
+ if (!finiteNum(value) || decl.digits === undefined) return;
69
+ return memo(
70
+ numberKey(locale, decl, rest),
71
+ () =>
72
+ new Intl.NumberFormat(locale, {
73
+ ...rest,
74
+ minimumFractionDigits: decl.digits,
75
+ maximumFractionDigits: decl.digits,
76
+ }),
77
+ ).format(value);
27
78
  };
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);
79
+
80
+ /** @type {(value: any, locale: string, decl: any) => string | undefined} */
81
+ let asNumber = (value, locale, decl) => asFixed(value, locale, decl);
82
+ /** @type {(value: any, locale: string, decl: any) => string | undefined} */
83
+ let asPercent = (value, locale, decl) =>
84
+ // `style: "percent"` multiplies by 100 and the digits count the *displayed*
85
+ // places, which is what Excel's `0.00%` already means too — so the count
86
+ // crosses to the sheet with no translation (docs/adr/0056).
87
+ asFixed(value, locale, decl, { style: "percent" });
88
+ // The code the cell actually wears is what the digit count was asked about, not
89
+ // the instance's: minor units are a property of the currency, so a row that
90
+ // names JPY presents no decimals beside one naming EUR that presents two
91
+ // (docs/adr/0041, docs/adr/0054).
92
+ /** @type {(value: any, locale: string, decl: any, options: any, style: any) => string | undefined} */
93
+ let asMoney = (value, locale, decl, options, style) => {
94
+ let currency = currencyOf(style, options);
95
+ return currency ? asFixed(value, locale, decl, { style: "currency", currency }) : undefined;
33
96
  };
34
97
  // A string in an accepted RFC 3339 form revives to the Date a host would have
35
98
  // injected, then presents like any other Date — including in the instance's
36
99
  // timezone, so a calendar date under a western zone presents as the day
37
100
  // before, exactly as an injected `new Date("2026-08-14")` does today. The two
38
101
  // 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) => {
102
+ //
103
+ // The form is Intl's own `dateStyle`, and it is always present: a declaration
104
+ // that named none resolved to `medium` (docs/adr/0056). This is the half that
105
+ // does not converge — the worksheet approximates it (CONTEXT.md, "Form").
106
+ // `date` leads for the same reason the number kinds do, so the two key shapes
107
+ // live beside each other and "one piece at a time" is auditable in one place.
108
+ /** @type {(locale: string, decl: any, zone: string) => string} */
109
+ let dateKey = (locale, decl, zone) => "date|" + locale + "|" + decl.form + "|" + zone;
110
+
111
+ /** @type {(value: any, locale: string, decl: any, options: any) => string | undefined} */
112
+ let asDate = (value, locale, decl, options) => {
41
113
  let date = finiteDate(value) ? value : reviveDate(value);
42
- return date
43
- ? new Intl.DateTimeFormat(locale, { timeZone: zoneOf(options) }).format(date)
44
- : undefined;
114
+ if (!date) return;
115
+ let zone = zoneOf(options);
116
+ return memo(
117
+ dateKey(locale, decl, zone),
118
+ () => new Intl.DateTimeFormat(locale, { timeZone: zone, dateStyle: decl.form }),
119
+ ).format(date);
45
120
  };
46
121
 
47
- /** @type {Record<string, (value: any, locale: string, options: any) => string | undefined>} */
122
+ /** @type {Record<string, (value: any, locale: string, decl: any, options: any, style: any) => string | undefined>} */
48
123
  let KINDS = { number: asNumber, currency: asMoney, percent: asPercent, date: asDate };
49
124
 
50
125
  /**
126
+ * The helper takes the whole resolved `style`, not just the declaration, so
127
+ * the question "which declarations does presentation read?" is answered here
128
+ * once rather than once per target (docs/adr/0014).
129
+ *
51
130
  * @param {unknown} value The interpolation's pre-stringify token.
52
- * @param {unknown} kind A resolved `format` declaration.
131
+ * @param {{ format?: unknown, currency?: unknown } | null} [style] The cell's
132
+ * resolved style block: its `format` declaration, and its own `currency`
133
+ * code when it declares one.
53
134
  * @param {{ locale?: string, currency?: string, timeZone?: string } | null} [options]
54
- * The instance's locale, currency code, and timezone.
135
+ * The instance's locale, default currency code, and timezone.
55
136
  * @returns {string | undefined} The presented text, or nothing when this
56
137
  * kind does not apply.
57
138
  */
58
- export function format(value, kind, options) {
59
- if (typeof kind !== "string") return;
60
- let present = KINDS[kind];
61
- if (!present) return;
139
+ export function format(value, style, options) {
140
+ // A style the stream resolved already carries its declaration whole; one a
141
+ // host built by hand may still hold the shorthand, so it is widened here
142
+ // rather than refused. No own-key guard on the lookup: `formatOf` answers a
143
+ // kind only from the closed vocabulary, which is exactly `KINDS`' own keys,
144
+ // so nothing an `=` expression resolved to can reach this table.
145
+ let decl = formatOf(style, options);
146
+ if (!decl) return;
62
147
  try {
63
- return present(value, localeOf(options), options);
148
+ return KINDS[decl.kind](value, localeOf(options), decl, options, style);
64
149
  } catch {
65
150
  return;
66
151
  }