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/CHANGELOG.md +509 -0
- package/README.md +87 -39
- package/lib/band-height.js +123 -0
- package/lib/format.js +117 -32
- package/lib/index.d.ts +273 -31
- package/lib/index.js +29 -16
- package/lib/license.js +1 -1
- package/lib/locate.js +32 -0
- package/lib/math.js +118 -0
- package/lib/memo.js +49 -0
- package/lib/plan.js +652 -223
- package/lib/precision.js +75 -0
- package/lib/reducers.js +212 -0
- package/lib/scope.js +7 -86
- package/lib/stream.js +93 -14
- package/lib/style.js +448 -41
- package/package.json +2 -2
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
|
|
55
|
-
`
|
|
56
|
-
`{
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
| `
|
|
90
|
-
| `
|
|
91
|
-
| `
|
|
92
|
-
| `
|
|
93
|
-
| `
|
|
94
|
-
| `
|
|
95
|
-
| `
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
152
|
-
every problem structurally as `{ path, source?, message, diagnostic? }` (the `message`
|
|
153
|
-
`validate()`'s string, and every problem keeps its own located diagnostic with
|
|
154
|
-
offsets, not only the first),
|
|
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
|
|
166
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
222
|
+
await report.render(html(), data);
|
|
176
223
|
} catch (e) {
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
4
|
-
* `style` and the token raw, and HTML/PDF stringify at the
|
|
5
|
-
* the wrong type, or on null, contributes nothing — the
|
|
6
|
-
* `display()`. Invalid locale or currency codes fail the
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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}
|
|
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,
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
148
|
+
return KINDS[decl.kind](value, localeOf(options), decl, options, style);
|
|
64
149
|
} catch {
|
|
65
150
|
return;
|
|
66
151
|
}
|