quario 0.6.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 +123 -0
- package/README.md +39 -15
- package/lib/format.js +5 -28
- package/lib/index.d.ts +106 -11
- package/lib/index.js +17 -8
- package/lib/math.js +64 -17
- package/lib/memo.js +49 -0
- package/lib/plan.js +345 -59
- package/lib/reducers.js +48 -1
- package/lib/stream.js +74 -2
- package/lib/style.js +48 -2
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,129 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.7.0] - 2026-09-07
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Styled runs.** Bold a word, colour a phrase, or format one value inside a
|
|
15
|
+
sentence, and have it survive into the PDF and the spreadsheet — not only the
|
|
16
|
+
HTML, which was the only place `<b>` in a template ever meant anything. A run
|
|
17
|
+
carries the inline declarations only: no padding, borders or spacing, which
|
|
18
|
+
belong to a whole line or block.
|
|
19
|
+
|
|
20
|
+
A cell's tokens carry a run's resolved style on the tokens it covers, and
|
|
21
|
+
`styledRuns(tokens)` groups them back out — the same rule every built-in
|
|
22
|
+
target reads them through, so a custom target cannot drift from them.
|
|
23
|
+
`RUN_STYLE_NAMES` is the inline half of the vocabulary as a list, beside
|
|
24
|
+
`STYLE_NAMES`, for a tool that offers an author what a run may wear.
|
|
25
|
+
|
|
26
|
+
- **`plan()` now reports declarations that nothing will read.** A definition can
|
|
27
|
+
be perfectly valid and still say something the engine quietly drops, and
|
|
28
|
+
until now the only way to find out was to notice the output was wrong.
|
|
29
|
+
`plan()` returns a `warnings` list beside `problems` — same shape, same
|
|
30
|
+
document order, one entry per declaration — and it currently catches three:
|
|
31
|
+
|
|
32
|
+
- a `format` on a cell whose value can never present a single interpolation —
|
|
33
|
+
`"Due {{ due }}"` under `format: "date"` — which the engine leaves unread;
|
|
34
|
+
- a `currency` on a cell whose `format` is not `"currency"`, which the engine
|
|
35
|
+
reads only under that kind and otherwise ignores, so the cell renders in
|
|
36
|
+
the instance's currency and the code you wrote does nothing;
|
|
37
|
+
- a table where every column declares a `width` and the widths total under
|
|
38
|
+
100, which leaves the trailing share of the table unused — usually a column
|
|
39
|
+
edited down without its neighbours being adjusted.
|
|
40
|
+
|
|
41
|
+
`problems` is unchanged and still fatal: a definition with problems compiles
|
|
42
|
+
to nothing, while one carrying only warnings compiles and renders exactly as
|
|
43
|
+
before. Hosts that ignore the new field are unaffected, and `validate()` is
|
|
44
|
+
untouched.
|
|
45
|
+
|
|
46
|
+
Warnings are advisory and deliberately incomplete — they catch what can be
|
|
47
|
+
seen in the definition alone, without data, so a quiet `warnings` list is not
|
|
48
|
+
a promise that every declaration will be read.
|
|
49
|
+
|
|
50
|
+
### Changed
|
|
51
|
+
|
|
52
|
+
- **A `format` declaration now needs one value to speak about, and a cell can
|
|
53
|
+
give it one.** Previously a `format` on a cell reached every interpolation in
|
|
54
|
+
it and skipped the literal text between them, which meant
|
|
55
|
+
`"{{ amount }} of {{ quantity }}"` under `format: "currency"` rendered the
|
|
56
|
+
quantity as money too — and the page and the spreadsheet disagreed about it,
|
|
57
|
+
the page formatting inside a sentence where the sheet did not.
|
|
58
|
+
|
|
59
|
+
A cell `value` may now be a list of **styled runs** —
|
|
60
|
+
`{ "value": "…", "style": { … } }` — each with its own declarations, and
|
|
61
|
+
`format` applies to a run holding a single interpolation.
|
|
62
|
+
|
|
63
|
+
**What changes in an existing report:** a cell that mixes text and an
|
|
64
|
+
interpolation under a `format` stops presenting that value and renders it
|
|
65
|
+
plainly, in every target. `"Due {{ due }}"` with `format: "date"` was
|
|
66
|
+
`Due 14 Aug 2026` on the page and `Due 2026-08-14` in the sheet; it is now
|
|
67
|
+
the plain form in both. To restore it, split the value and leave the
|
|
68
|
+
declaration where it is:
|
|
69
|
+
|
|
70
|
+
```jsonc
|
|
71
|
+
{
|
|
72
|
+
"value": [{ "value": "Due " }, { "value": "{{ due }}" }],
|
|
73
|
+
"style": { "format": "date" }
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
A cell's `format` reaches its runs, so nothing moves and nothing is declared
|
|
78
|
+
twice. `plan()` reports the cells this affects wherever it can see them
|
|
79
|
+
without data.
|
|
80
|
+
|
|
81
|
+
### Fixed
|
|
82
|
+
|
|
83
|
+
- **`round`, `floor` and `ceil` reuse their formatters instead of rebuilding
|
|
84
|
+
one per call.** The three reach the decimal you wrote through `Intl`, and
|
|
85
|
+
each call built a fresh formatter to do it — so a detail column of
|
|
86
|
+
`{{ round(@.qty * @.price, 2) }}` paid for one per row. A hundred-thousand
|
|
87
|
+
row report with two such columns drained in 3.5 s; it now drains in 0.2 s.
|
|
88
|
+
Every answer is the value it was before: the rounding mode and the digit
|
|
89
|
+
count are both part of what a reused formatter is found by, so no call can
|
|
90
|
+
be handed one built for another.
|
|
91
|
+
|
|
92
|
+
- **`round`, `floor` and `ceil` name themselves when they refuse an `n`.** All
|
|
93
|
+
three threw `n must be a number from 0 to 100`, so a cell calling more than
|
|
94
|
+
one was told how it failed and never which call; the message now opens with
|
|
95
|
+
the function, as a reducer's already did. The located path still names the
|
|
96
|
+
cell — one cell holds as many calls as you write, which is the whole reason
|
|
97
|
+
the name is worth having.
|
|
98
|
+
|
|
99
|
+
**An `n` that cannot be read as a number at all now gets the same answer.**
|
|
100
|
+
A `BigInt` or a `Symbol` reaching `n` from render data used to surface the
|
|
101
|
+
runtime's own `Cannot convert a BigInt value to a number`, and an object
|
|
102
|
+
carrying a throwing `valueOf` surfaced whatever it threw — each located at
|
|
103
|
+
the cell but naming neither the function nor anything an author could act
|
|
104
|
+
on. All of them are now the same refusal.
|
|
105
|
+
|
|
106
|
+
- **`isDiagnostic` is documented for what it actually answers.** The README
|
|
107
|
+
said it was true for "one of the stack's located errors: quario's own, or one
|
|
108
|
+
thrown directly by xprsn, sjabloon, or padvinder", and its example read a
|
|
109
|
+
`false` as "one of your own functions failed". Only an engine's own error is
|
|
110
|
+
a diagnostic: quario's verdicts on a document — an unknown reducer, a
|
|
111
|
+
misused inline reducer, image bytes it will not vouch for — are located just
|
|
112
|
+
the same and are not diagnostics, so a host following that example rethrew
|
|
113
|
+
report faults as its own. Nothing about the guard changes; it never behaved
|
|
114
|
+
the way the page described.
|
|
115
|
+
|
|
116
|
+
- **A misused inline reducer says what it is, instead of how it folds.**
|
|
117
|
+
`{{ min(1, 2) }}` — the two-argument scalar `min` quario does not have —
|
|
118
|
+
failed at the cell with `(rows || []).map is not a function`, the fold's own
|
|
119
|
+
internals, naming neither the reducer nor the mistake. It now reads
|
|
120
|
+
`min is a reducer over an array, not a scalar function; got a number`, and a
|
|
121
|
+
second argument that is not a lambda says so in the same words rather than
|
|
122
|
+
reaching a host as `of is not a function`. Register a function of your own to
|
|
123
|
+
shadow a built-in reducer when a report needs the scalar.
|
|
124
|
+
|
|
125
|
+
**Two reducers used to answer instead of failing**, each reading a `length`
|
|
126
|
+
off a value that is not a collection: `count('ab')` presented `2` — a
|
|
127
|
+
string's own length, folded as if it were a count of rows — while `count(1)`
|
|
128
|
+
and `avg(1, 2)` presented nothing at all. All three are now the same located
|
|
129
|
+
error. A reducer over an **absent** array is unchanged and still not a
|
|
130
|
+
mistake: `sum(@.lines)` on a row carrying no lines is `0`, exactly as an
|
|
131
|
+
empty array gives.
|
|
132
|
+
|
|
10
133
|
## [0.6.0] - 2026-09-07
|
|
11
134
|
|
|
12
135
|
### Added
|
package/README.md
CHANGED
|
@@ -127,6 +127,14 @@ display text. Passing the cell's resolved `format` kind opts into the seam's one
|
|
|
127
127
|
real numeric cells;
|
|
128
128
|
[`@quario/csv`](https://www.npmjs.com/package/@quario/csv) is the short form.
|
|
129
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
|
+
|
|
130
138
|
### `walk(events, handlers)` / `breathe()`
|
|
131
139
|
|
|
132
140
|
`walk` is the delivery driver every official target uses. Pass one render's event iterable and
|
|
@@ -154,7 +162,8 @@ cell the same way:
|
|
|
154
162
|
- `currencyOf(style?, options?)` — which code a money cell wears: its own, else the instance's.
|
|
155
163
|
- `isReportBand(role)` — whether a role names one of the report's own bands rather than a
|
|
156
164
|
group's.
|
|
157
|
-
- `STYLE_NAMES` — the closed style vocabulary, in the spec's order
|
|
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.
|
|
158
167
|
|
|
159
168
|
### `validate(schema, functions?)`
|
|
160
169
|
|
|
@@ -172,34 +181,49 @@ so `validate()` can never disagree with what `report()` accepts.
|
|
|
172
181
|
### `quario().plan(schema, functions?)`
|
|
173
182
|
|
|
174
183
|
The one traversal, whole — for hosts that validate and render in a loop, like an editor. Returns
|
|
175
|
-
`{ report, problems, anchors }`: the compiled report (`null` while the document has
|
|
176
|
-
every problem structurally as `{ path, source?, message, diagnostic? }` (the `message`
|
|
177
|
-
`validate()`'s string, and every problem keeps its own located diagnostic with
|
|
178
|
-
offsets, not only the first),
|
|
179
|
-
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.
|
|
180
199
|
|
|
181
200
|
```js
|
|
182
|
-
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);
|
|
183
203
|
if (report) await report.render(html(), data);
|
|
184
204
|
else console.error(problems[0].path, problems[0].message);
|
|
185
205
|
```
|
|
186
206
|
|
|
187
207
|
### `isDiagnostic(error)`
|
|
188
208
|
|
|
189
|
-
True when a caught value is
|
|
190
|
-
|
|
191
|
-
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.
|
|
192
212
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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`.
|
|
196
219
|
|
|
197
220
|
```js
|
|
198
221
|
try {
|
|
199
222
|
await report.render(html(), data);
|
|
200
223
|
} catch (e) {
|
|
201
|
-
|
|
202
|
-
|
|
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;
|
|
203
227
|
}
|
|
204
228
|
```
|
|
205
229
|
|
package/lib/format.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* carries no count presents nothing here and the caller falls back to
|
|
18
18
|
* `display()`.
|
|
19
19
|
*/
|
|
20
|
+
import { boundedMemo } from "./memo.js";
|
|
20
21
|
import { currencyOf, formatOf } from "./style.js";
|
|
21
22
|
import { finiteDate, finiteNum, reviveDate } from "./stream.js";
|
|
22
23
|
/** @type {(options: any) => string} */
|
|
@@ -24,11 +25,9 @@ let localeOf = (options) => options?.locale || "en-US";
|
|
|
24
25
|
/** @type {(options: any) => string} */
|
|
25
26
|
let zoneOf = (options) => options?.timeZone || "UTC";
|
|
26
27
|
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// where 0.2 s does (a report of mixed currencies rendered to HTML), and the
|
|
31
|
-
// formatters are memoised.
|
|
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.
|
|
32
31
|
//
|
|
33
32
|
// **The key is everything the formatter is made from**, and it is values
|
|
34
33
|
// rather than an object identity because no identity survives both paths: a
|
|
@@ -48,12 +47,6 @@ let zoneOf = (options) => options?.timeZone || "UTC";
|
|
|
48
47
|
// single locale. Measured at 244 bytes retained per entry, that is about 90 MB
|
|
49
48
|
// held for the life of the process on data nobody vetted.
|
|
50
49
|
//
|
|
51
|
-
// So the map is bounded, and past the bound it is emptied rather than grown.
|
|
52
|
-
// A workload presenting more distinct formats than the cap pays what it paid
|
|
53
|
-
// before any of this and never more, which is the one cost that needs no
|
|
54
|
-
// argument -- and it can never hand back a formatter built for another cell,
|
|
55
|
-
// because clearing forgets rather than reuses.
|
|
56
|
-
//
|
|
57
50
|
// 2048 leaves the margin a threshold like this wants on both sides: about
|
|
58
51
|
// 500 KB at the cap, against a real report reaching a small multiple of the
|
|
59
52
|
// ISO codes actually in circulation, which is under two hundred. Raising it
|
|
@@ -62,23 +55,7 @@ let zoneOf = (options) => options?.timeZone || "UTC";
|
|
|
62
55
|
//
|
|
63
56
|
// Memoising a fact that cannot change within an ICU version, like
|
|
64
57
|
// `fractionDigits`' own minor-units map (docs/adr/0025).
|
|
65
|
-
let
|
|
66
|
-
/** @type {Map<string, any>} */
|
|
67
|
-
let formatters = new Map();
|
|
68
|
-
// A construction that throws -- an unreadable locale, zone, or currency code
|
|
69
|
-
// -- caches nothing: it reaches `format()`'s own catch, and a later cell asks
|
|
70
|
-
// again rather than finding nothing sitting in the map under a good key.
|
|
71
|
-
// Missing on `undefined` rather than `has()`, which is what `precision.js`
|
|
72
|
-
// needs because *it* caches an absent answer; a formatter is never one.
|
|
73
|
-
/** @type {(key: string, make: () => any) => any} */
|
|
74
|
-
let memo = (key, make) => {
|
|
75
|
-
let formatter = formatters.get(key);
|
|
76
|
-
if (formatter !== undefined) return formatter;
|
|
77
|
-
formatter = make();
|
|
78
|
-
if (formatters.size >= CAP) formatters.clear();
|
|
79
|
-
formatters.set(key, formatter);
|
|
80
|
-
return formatter;
|
|
81
|
-
};
|
|
58
|
+
let memo = boundedMemo(2048);
|
|
82
59
|
|
|
83
60
|
// The kind leads, so a number's key can never read as a date's; the currency
|
|
84
61
|
// is empty for the two kinds that wear none.
|
package/lib/index.d.ts
CHANGED
|
@@ -118,8 +118,52 @@ export interface SortKey {
|
|
|
118
118
|
dir?: "asc" | "desc";
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
/**
|
|
122
|
-
|
|
121
|
+
/**
|
|
122
|
+
* The names a styled run may wear, in the vocabulary's own order. A tool that
|
|
123
|
+
* offers them — the editor's style rail — reads this rather than keeping a
|
|
124
|
+
* copy, exactly as it reads `STYLE_NAMES` for the whole set.
|
|
125
|
+
*/
|
|
126
|
+
export const RUN_STYLE_NAMES: readonly string[];
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The declarations a styled run accepts: the inline half of the vocabulary,
|
|
130
|
+
* and nothing else. The box and flow spacing describe a block and `align`
|
|
131
|
+
* describes a line, and neither means anything on part of one — so a run is
|
|
132
|
+
* narrowed from the other end than an image item is. A narrowing of the one
|
|
133
|
+
* vocabulary rather than a second list of its own, so the two cannot drift.
|
|
134
|
+
*/
|
|
135
|
+
export type RunStyleDeclarations = Pick<
|
|
136
|
+
StyleDeclarations,
|
|
137
|
+
| "family"
|
|
138
|
+
| "size"
|
|
139
|
+
| "bold"
|
|
140
|
+
| "italic"
|
|
141
|
+
| "underline"
|
|
142
|
+
| "strikethrough"
|
|
143
|
+
| "color"
|
|
144
|
+
| "background"
|
|
145
|
+
| "uppercase"
|
|
146
|
+
| "format"
|
|
147
|
+
| "currency"
|
|
148
|
+
>;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* One styled run: a stretch of a cell's text with its own declarations. A
|
|
152
|
+
* closed two-key object — `{{#if}}` already conditions a run's own text, so
|
|
153
|
+
* there is no `visible` on one.
|
|
154
|
+
*/
|
|
155
|
+
export interface StyledRun {
|
|
156
|
+
value: string;
|
|
157
|
+
/** Literal only — never an expression. */
|
|
158
|
+
style?: RunStyleDeclarations;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* A cell value: one template string, or a non-empty array of styled runs. A
|
|
163
|
+
* cell written as one template string is one run, so a one-run array says
|
|
164
|
+
* exactly what that string says.
|
|
165
|
+
*/
|
|
166
|
+
export type CellValue = string | StyledRun[];
|
|
123
167
|
|
|
124
168
|
export type Align = "left" | "center" | "right";
|
|
125
169
|
export type VAlign = "top" | "middle" | "bottom";
|
|
@@ -194,8 +238,12 @@ export interface ImageItem {
|
|
|
194
238
|
source: `=${string}`;
|
|
195
239
|
/** Defaults to `"natural"`. */
|
|
196
240
|
fit?: ImageFit;
|
|
197
|
-
/**
|
|
198
|
-
|
|
241
|
+
/**
|
|
242
|
+
* The image's textual stand-in: one template string. A styled run means
|
|
243
|
+
* nothing on a stand-in for a picture, so this is the one cell value the
|
|
244
|
+
* widened type does not reach.
|
|
245
|
+
*/
|
|
246
|
+
alt?: string;
|
|
199
247
|
visible?: ExpressionValue<boolean>;
|
|
200
248
|
style?: ImageStyleDeclarations;
|
|
201
249
|
}
|
|
@@ -445,14 +493,43 @@ export type ItemRole =
|
|
|
445
493
|
| "page-header"
|
|
446
494
|
| "page-footer";
|
|
447
495
|
|
|
496
|
+
/**
|
|
497
|
+
* The resolved declarations for one stretch of a cell's text: the cell's own
|
|
498
|
+
* with its styled run's layered over them, already composed, so a consumer
|
|
499
|
+
* applies it exactly where it applies the cell's `style` rather than merging
|
|
500
|
+
* the two. Present only where it differs from the cell's own.
|
|
501
|
+
*/
|
|
502
|
+
export interface TokenStyle {
|
|
503
|
+
style?: Record<string, unknown>;
|
|
504
|
+
}
|
|
505
|
+
|
|
448
506
|
/** One static text run of a cell's template, verbatim (sjabloon's literal token). */
|
|
449
|
-
export type LiteralToken = SjabloonLiteralToken;
|
|
507
|
+
export type LiteralToken = SjabloonLiteralToken & TokenStyle;
|
|
450
508
|
|
|
451
509
|
/** One interpolation's pre-stringify value (sjabloon's value token). */
|
|
452
|
-
export type ValueToken = SjabloonValueToken;
|
|
510
|
+
export type ValueToken = SjabloonValueToken & TokenStyle;
|
|
453
511
|
|
|
454
512
|
export type Token = LiteralToken | ValueToken;
|
|
455
513
|
|
|
514
|
+
/** One of a cell's styled runs, as `styledRuns` groups them back out. */
|
|
515
|
+
export interface TokenRun {
|
|
516
|
+
/** The style this run's tokens carry, or `null` where the cell's applies. */
|
|
517
|
+
style: Record<string, unknown> | null;
|
|
518
|
+
tokens: Token[];
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Group a cell's tokens into its styled runs, in order. Consecutive tokens
|
|
523
|
+
* with equal styles are one run — a lossless rule, because equal styles render
|
|
524
|
+
* identically, so grouping survives a JSON round trip. Equality is compared
|
|
525
|
+
* one level deep, which is exhaustive rather than approximate: `format` is the
|
|
526
|
+
* only object-valued declaration and is itself flat.
|
|
527
|
+
*
|
|
528
|
+
* The built-in targets consume it, so a custom target grouping through it
|
|
529
|
+
* cannot drift from them.
|
|
530
|
+
*/
|
|
531
|
+
export function styledRuns(tokens: Token[]): TokenRun[];
|
|
532
|
+
|
|
456
533
|
export interface EventCell {
|
|
457
534
|
tokens: Token[];
|
|
458
535
|
style?: Record<string, unknown>;
|
|
@@ -744,17 +821,35 @@ export interface Problem {
|
|
|
744
821
|
readonly diagnostic?: QuarioDiagnostic;
|
|
745
822
|
}
|
|
746
823
|
|
|
824
|
+
/**
|
|
825
|
+
* An advisory: the document declares something nothing will read. Not a
|
|
826
|
+
* `Problem` at lower severity, and not its shape either — a problem carries
|
|
827
|
+
* the engine's located `diagnostic` when one authenticated the fault, and a
|
|
828
|
+
* `source` when it came from compiling one. Neither can happen here: nothing
|
|
829
|
+
* raises, and the engine decides a declaration will not be read after every
|
|
830
|
+
* compile has already succeeded. A warning that did locate into an authored
|
|
831
|
+
* source would add the field back; carrying one nothing sets would promise a
|
|
832
|
+
* location that never arrives.
|
|
833
|
+
*/
|
|
834
|
+
export interface Warning {
|
|
835
|
+
readonly path: string;
|
|
836
|
+
readonly message: string;
|
|
837
|
+
}
|
|
838
|
+
|
|
747
839
|
/**
|
|
748
840
|
* The plan: both readings of the one traversal, kept. `report` is the
|
|
749
841
|
* compiled report, or null while the document has problems; `problems` is the
|
|
750
|
-
* structured list `validate()` flattens to strings; `
|
|
751
|
-
*
|
|
752
|
-
* the
|
|
842
|
+
* structured list `validate()` flattens to strings; `warnings` is the advisory
|
|
843
|
+
* half, which never nulls the report; `anchors` maps each compiled source's
|
|
844
|
+
* schema path to the anchors and group handles it reads — the unfiltered
|
|
845
|
+
* complement of `names`, which excludes them. `problems` is fatal and
|
|
846
|
+
* `warnings` is advisory, both in document order from the one descent.
|
|
753
847
|
*/
|
|
754
848
|
export interface Plan {
|
|
755
849
|
readonly report: CompiledReport | null;
|
|
756
850
|
readonly problems: readonly Problem[];
|
|
757
851
|
readonly anchors: Readonly<Record<string, readonly string[]>>;
|
|
852
|
+
readonly warnings: readonly Warning[];
|
|
758
853
|
}
|
|
759
854
|
|
|
760
855
|
export interface Quario {
|
|
@@ -767,8 +862,8 @@ export interface Quario {
|
|
|
767
862
|
report(schema: ReportSchema, functions?: FunctionRegistry): CompiledReport;
|
|
768
863
|
/**
|
|
769
864
|
* The one traversal, whole: compiled report (null on problems), structured
|
|
770
|
-
* problems, and per-node anchor sets — one descent per
|
|
771
|
-
* validates and renders in a loop.
|
|
865
|
+
* problems, advisory warnings, and per-node anchor sets — one descent per
|
|
866
|
+
* edit for a host that validates and renders in a loop.
|
|
772
867
|
*/
|
|
773
868
|
plan(schema: unknown, functions?: FunctionRegistry): Plan;
|
|
774
869
|
}
|
package/lib/index.js
CHANGED
|
@@ -24,9 +24,15 @@ 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, text, typed, walk } from "./stream.js";
|
|
27
|
+
export { breathe, display, isReportBand, styledRuns, text, typed, walk } from "./stream.js";
|
|
28
28
|
export { format } from "./format.js";
|
|
29
|
-
export {
|
|
29
|
+
export {
|
|
30
|
+
currencyOf,
|
|
31
|
+
FORMAT_VOCABULARY,
|
|
32
|
+
fractionDigits,
|
|
33
|
+
RUN_STYLE_NAMES,
|
|
34
|
+
STYLE_NAMES,
|
|
35
|
+
} from "./style.js";
|
|
30
36
|
export { imageError, isDiagnostic } from "./locate.js";
|
|
31
37
|
|
|
32
38
|
/** @typedef {import("./scope.js").Scope} Scope */
|
|
@@ -89,11 +95,13 @@ export function validate(schema, funcs) {
|
|
|
89
95
|
return plan(schema, funcs).problems.map((problem) => problem.message);
|
|
90
96
|
}
|
|
91
97
|
|
|
92
|
-
// The structured problems, frozen for handing out: the
|
|
93
|
-
// go to exactly one caller, so freezing here cannot
|
|
94
|
-
// The diagnostic stays an engine-minted error and is
|
|
95
|
-
|
|
96
|
-
|
|
98
|
+
// The structured problems and warnings, frozen for handing out: the
|
|
99
|
+
// traversal's own objects go to exactly one caller, so freezing here cannot
|
|
100
|
+
// disturb a second reader. The diagnostic stays an engine-minted error and is
|
|
101
|
+
// deliberately not frozen. One helper for both lists, because a warning is a
|
|
102
|
+
// problem's shape less the field that cannot occur on one.
|
|
103
|
+
/** @type {(entries: any[]) => readonly any[]} */
|
|
104
|
+
let freezeEntries = (entries) => Object.freeze(entries.map((entry) => Object.freeze(entry)));
|
|
97
105
|
|
|
98
106
|
// The per-node anchor sets, as frozen host data (CONTEXT.md, "Freeze"): which
|
|
99
107
|
// anchors and group handles each compiled source reads, keyed by schema path.
|
|
@@ -333,8 +341,9 @@ export function quario(options) {
|
|
|
333
341
|
let planned = plan(schema, funcs, options);
|
|
334
342
|
return {
|
|
335
343
|
report: planned.problems.length ? null : wrap(assemble(planned, schema, marking, options)),
|
|
336
|
-
problems:
|
|
344
|
+
problems: freezeEntries(planned.problems),
|
|
337
345
|
anchors: freezeAnchors(planned.anchors),
|
|
346
|
+
warnings: freezeEntries(planned.warnings),
|
|
338
347
|
};
|
|
339
348
|
},
|
|
340
349
|
};
|
package/lib/math.js
CHANGED
|
@@ -15,17 +15,44 @@
|
|
|
15
15
|
* table, is in docs/adr/0050. The locale is hardcoded `en-US` rather than the
|
|
16
16
|
* instance's: this is arithmetic, not presentation, and a locale with
|
|
17
17
|
* non-ASCII digits or its own grouping would not parse back through `Number`.
|
|
18
|
-
* Formatters are built per call, deliberately -- see the ADR.
|
|
19
18
|
*/
|
|
19
|
+
import { boundedMemo } from "./memo.js";
|
|
20
|
+
|
|
21
|
+
// 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.
|
|
25
|
+
//
|
|
26
|
+
// **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
|
|
32
|
+
// `test/frozen.test.js`.
|
|
33
|
+
//
|
|
34
|
+
// **The cap is unreachable, and that is the point of stating it.** `places`
|
|
35
|
+
// 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.
|
|
41
|
+
let memo = boundedMemo(512);
|
|
42
|
+
|
|
20
43
|
/** @type {(x: number, n: number, mode: string) => number} */
|
|
21
44
|
let decimal = (x, n, mode) =>
|
|
22
45
|
Number(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
46
|
+
memo(
|
|
47
|
+
mode + "|" + n,
|
|
48
|
+
() =>
|
|
49
|
+
new Intl.NumberFormat("en-US", {
|
|
50
|
+
minimumFractionDigits: n,
|
|
51
|
+
maximumFractionDigits: n,
|
|
52
|
+
useGrouping: false,
|
|
53
|
+
roundingMode: /** @type {any} */ (mode),
|
|
54
|
+
}),
|
|
55
|
+
).format(x),
|
|
29
56
|
);
|
|
30
57
|
// The one test both halves make of `x`. Named because the rule it stands for
|
|
31
58
|
// is the interesting part: rounding a value that is not a finite number would
|
|
@@ -47,16 +74,36 @@ let honourable = (digits) => Number.isFinite(digits) && digits >= 0 && digits <=
|
|
|
47
74
|
// `maximumFractionDigits`, an option the author never wrote, so the message is
|
|
48
75
|
// ours. Fractional `n` truncates rather than throwing, which is what Intl does
|
|
49
76
|
// with it and is not worth a second rule for an author to hold.
|
|
50
|
-
/** @type {(n: any) => number} */
|
|
51
|
-
let places = (n) => {
|
|
52
|
-
let digits =
|
|
53
|
-
|
|
77
|
+
/** @type {(fn: string, n: any) => number} */
|
|
78
|
+
let places = (fn, n) => {
|
|
79
|
+
let digits = NaN;
|
|
80
|
+
// `+` is the other way `n` fails, and it used to fail rawly: a BigInt or a
|
|
81
|
+
// Symbol makes the coercion throw a TypeError of its own, and an object
|
|
82
|
+
// carrying a hostile `valueOf` throws whatever it likes. Each arrived
|
|
83
|
+
// located at the cell and naming nothing -- the very fault this rule closes
|
|
84
|
+
// -- and render data may be untrusted (SCHEMA.md, "Trust and Content
|
|
85
|
+
// Security Policy"), so an `n` that cannot even be read is folded into the
|
|
86
|
+
// one answer an author can act on rather than surfacing V8's.
|
|
87
|
+
try {
|
|
88
|
+
digits = Math.trunc(+(n ?? 0));
|
|
89
|
+
} catch {
|
|
90
|
+
digits = NaN;
|
|
91
|
+
}
|
|
92
|
+
if (!honourable(digits)) throw new RangeError(fn + "'s n must be a number from 0 to 100");
|
|
54
93
|
return digits;
|
|
55
94
|
};
|
|
56
95
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
96
|
+
// The name is threaded rather than prefixed by a wrapper around the registry.
|
|
97
|
+
// `places` is the only place a built-in refuses an argument -- `abs` and the
|
|
98
|
+
// folds never do -- so a catch on every built-in call would exist to serve one
|
|
99
|
+
// function's messages. A mechanical prefix would also cost both halves their
|
|
100
|
+
// own sentence: a reducer names itself inside its (`min is a reducer over an
|
|
101
|
+
// array`) where a scalar names an argument it owns (`round's n`), and a
|
|
102
|
+
// uniform `name: ` joiner fits neither without rewriting the other
|
|
103
|
+
// (quario-gr4h).
|
|
104
|
+
/** @type {(fn: string, mode: string) => (x: any, n: any) => any} */
|
|
105
|
+
let rounder = (fn, mode) => (x, n) => {
|
|
106
|
+
let digits = places(fn, n);
|
|
60
107
|
return finite(x) ? decimal(x, digits, mode) : x;
|
|
61
108
|
};
|
|
62
109
|
/** @type {Record<string, (...args: any[]) => any>} */
|
|
@@ -64,8 +111,8 @@ export let MATH = {
|
|
|
64
111
|
// Written `(x, n)` rather than `(x, n = 0)` on purpose: xprsn's signatures()
|
|
65
112
|
// reports arity from `fn.length`, and a default parameter would describe
|
|
66
113
|
// these to an editor as taking one argument.
|
|
67
|
-
round: rounder("halfExpand"),
|
|
68
|
-
floor: rounder("floor"),
|
|
69
|
-
ceil: rounder("ceil"),
|
|
114
|
+
round: rounder("round", "halfExpand"),
|
|
115
|
+
floor: rounder("floor", "floor"),
|
|
116
|
+
ceil: rounder("ceil", "ceil"),
|
|
70
117
|
abs: (x) => (finite(x) ? Math.abs(x) : x),
|
|
71
118
|
};
|
package/lib/memo.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bounded store the engine's `Intl` call sites are memoised behind.
|
|
3
|
+
*
|
|
4
|
+
* Building an `Intl` formatter costs about 20 µs and using one costs about
|
|
5
|
+
* 0.4 µs, so every path that builds one per call spends fifty times what it
|
|
6
|
+
* needs to. Two do: `./format.js` presents a token at the markup edge, and
|
|
7
|
+
* `./math.js` reaches a decimal through a formatter's `roundingMode`
|
|
8
|
+
* (docs/adr/0050). One rule, stated once, rather than the same six lines
|
|
9
|
+
* twice.
|
|
10
|
+
*
|
|
11
|
+
* **A factory, so each site owns its store.** One shared map would let a
|
|
12
|
+
* report turning over thousands of currency formatters evict the three a
|
|
13
|
+
* rounder ever holds, which is the caller with no unbounded key space paying
|
|
14
|
+
* for the caller that has one. The cap is passed rather than fixed here for
|
|
15
|
+
* the same reason: it is a claim about a key space, and only the call site
|
|
16
|
+
* knows what its keys are made of.
|
|
17
|
+
*
|
|
18
|
+
* **Past the cap the store is emptied rather than grown.** A workload with
|
|
19
|
+
* more distinct keys than the cap pays what it paid before any of this and
|
|
20
|
+
* never more, and it can never be handed a value built for another key,
|
|
21
|
+
* because clearing forgets rather than reuses.
|
|
22
|
+
*
|
|
23
|
+
* **The seam is the store, not the key.** The two keys have no shape in
|
|
24
|
+
* common -- four pieces off a resolved style declaration against a rounding
|
|
25
|
+
* mode and a count -- so a shared builder would be a joiner both callers
|
|
26
|
+
* already have in `+`, and it would put the piece that decides correctness a
|
|
27
|
+
* module away from the site that knows what the pieces are. They were
|
|
28
|
+
* designed together and each says so; nothing else about them is shared.
|
|
29
|
+
*
|
|
30
|
+
* **A `make` that throws caches nothing.** It reaches the caller's own catch
|
|
31
|
+
* and a later call asks again, rather than finding nothing sitting under a
|
|
32
|
+
* good key. That is why a miss is `undefined` rather than `has()`:
|
|
33
|
+
* `./precision.js` deliberately caches an *absent* answer and so needs
|
|
34
|
+
* `has()`, which is why it is not on this seam.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** @type {(cap: number) => (key: string, make: () => any) => any} */
|
|
38
|
+
export let boundedMemo = (cap) => {
|
|
39
|
+
/** @type {Map<string, any>} */
|
|
40
|
+
let store = new Map();
|
|
41
|
+
return (key, make) => {
|
|
42
|
+
let value = store.get(key);
|
|
43
|
+
if (value !== undefined) return value;
|
|
44
|
+
value = make();
|
|
45
|
+
if (store.size >= cap) store.clear();
|
|
46
|
+
store.set(key, value);
|
|
47
|
+
return value;
|
|
48
|
+
};
|
|
49
|
+
};
|