quario 0.6.0 → 0.8.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 +183 -67
- package/README.md +58 -17
- package/lib/format.js +5 -28
- package/lib/image.js +92 -0
- package/lib/index.d.ts +127 -12
- package/lib/index.js +17 -8
- package/lib/license.js +5 -5
- package/lib/math.js +64 -17
- package/lib/memo.js +49 -0
- package/lib/plan.js +385 -73
- package/lib/reducers.js +48 -1
- package/lib/stream.js +74 -25
- package/lib/style.js +50 -3
- package/package.json +3 -3
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/image.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the engine reads out of an image item's bytes: which format they are,
|
|
3
|
+
* and how big the picture is. Read, never decoded -- a magic number, a PNG's
|
|
4
|
+
* IHDR and a JPEG's frame header, and nothing here looks at a pixel.
|
|
5
|
+
*
|
|
6
|
+
* Both answers ride on the image event, so no target reads these headers
|
|
7
|
+
* again. That is the whole of `docs/adr/0067`: two targets used to keep a
|
|
8
|
+
* parser each, in different units and with the same bug, and the engine was
|
|
9
|
+
* already in these bytes to name the format.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// The two raster formats an image item may carry, as the bytes announce
|
|
13
|
+
// themselves: PNG's 8-byte signature and JPEG's start-of-image marker. Read,
|
|
14
|
+
// never decoded -- the engine answers which format the bytes are, not what
|
|
15
|
+
// they depict.
|
|
16
|
+
let PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
17
|
+
let JPEG = [0xff, 0xd8, 0xff];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The image seam: name the format a `source` expression's bytes hold, or null
|
|
21
|
+
* when they are not image bytes at all -- including when they are not bytes.
|
|
22
|
+
* The traversal sniffs once per image event and the answer rides on the event,
|
|
23
|
+
* so no consumer repeats it.
|
|
24
|
+
*
|
|
25
|
+
* @param {any} bytes The value a `source` expression yielded.
|
|
26
|
+
* @returns {"png" | "jpeg" | null} The format, or null when unreadable.
|
|
27
|
+
*/
|
|
28
|
+
export function sniff(bytes) {
|
|
29
|
+
if (!(bytes instanceof Uint8Array)) return null;
|
|
30
|
+
if (PNG.every((byte, i) => bytes[i] === byte)) return "png";
|
|
31
|
+
if (JPEG.every((byte, i) => bytes[i] === byte)) return "jpeg";
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @type {(bytes: Uint8Array, at: number) => number} */
|
|
36
|
+
let word = (bytes, at) => (bytes[at] << 8) | bytes[at + 1];
|
|
37
|
+
|
|
38
|
+
// A PNG states each dimension in four bytes, so two words make one number.
|
|
39
|
+
/** @type {(bytes: Uint8Array, at: number) => number} */
|
|
40
|
+
let long = (bytes, at) => word(bytes, at) * 0x10000 + word(bytes, at + 2);
|
|
41
|
+
|
|
42
|
+
// The three codes in C0..CF that are not frame headers: DHT, the JPG
|
|
43
|
+
// extension, and DAC. Named as a set so the frame test is one range and one
|
|
44
|
+
// lookup rather than a chain of exclusions.
|
|
45
|
+
let NOT_FRAME = new Set([0xc4, 0xc8, 0xcc]);
|
|
46
|
+
|
|
47
|
+
// The dimensions live in the frame header, which is the first SOFn marker.
|
|
48
|
+
// SOF2 is a frame like SOF0, so a progressive file is read like a baseline one.
|
|
49
|
+
/** @type {(code: number) => boolean} */
|
|
50
|
+
let isFrame = (code) => code >= 0xc0 && code <= 0xcf && !NOT_FRAME.has(code);
|
|
51
|
+
|
|
52
|
+
// A PNG carries the two numbers in its IHDR at a fixed offset.
|
|
53
|
+
/** @type {(bytes: Uint8Array) => { width: number, height: number } } */
|
|
54
|
+
let pngSize = (bytes) => ({ width: long(bytes, 16), height: long(bytes, 20) });
|
|
55
|
+
|
|
56
|
+
/** @type {(bytes: Uint8Array) => { width: number, height: number } } */
|
|
57
|
+
let jpegSize = (bytes) => {
|
|
58
|
+
for (let at = 2; at + 9 < bytes.length; at += 2 + word(bytes, at + 2)) {
|
|
59
|
+
if (bytes[at] !== 0xff) break;
|
|
60
|
+
if (isFrame(bytes[at + 1])) return { width: word(bytes, at + 7), height: word(bytes, at + 5) };
|
|
61
|
+
}
|
|
62
|
+
return { width: 0, height: 0 };
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// One reader per format `sniff` names.
|
|
66
|
+
/** @type {Record<string, (bytes: Uint8Array) => { width: number, height: number }>} */
|
|
67
|
+
let READERS = { png: pngSize, jpeg: jpegSize };
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The image's own size in pixels, or null when the header does not carry one
|
|
71
|
+
* -- bytes whose magic numbers `sniff` vouched for, truncated before the
|
|
72
|
+
* dimensions. Read, never decoded: a PNG's IHDR and a JPEG's frame header
|
|
73
|
+
* carry the two numbers, and nothing here looks at a pixel.
|
|
74
|
+
*
|
|
75
|
+
* The size rides on the image event beside the format, so every target places
|
|
76
|
+
* a picture from a field rather than parsing the same two headers again, and a
|
|
77
|
+
* file too short to state its size fails once, in the engine, for every target
|
|
78
|
+
* alike (`docs/adr/0067`).
|
|
79
|
+
*
|
|
80
|
+
* @param {Uint8Array} bytes The image file.
|
|
81
|
+
* @param {"png" | "jpeg"} format The format `sniff` named.
|
|
82
|
+
* @returns {{ width: number, height: number } | null} The size, if readable.
|
|
83
|
+
*/
|
|
84
|
+
export function dimensions(bytes, format) {
|
|
85
|
+
// Keyed rather than a ternary so the format list lives in one place: a
|
|
86
|
+
// format `sniff` learns later and this table does not would throw here
|
|
87
|
+
// rather than be read silently as the one on the ternary's else branch.
|
|
88
|
+
let size = READERS[format](bytes);
|
|
89
|
+
// A zero is how either reader says "the header stopped before this" -- an
|
|
90
|
+
// absent byte reads as zero, and no image is zero wide.
|
|
91
|
+
return size.width > 0 && size.height > 0 ? size : null;
|
|
92
|
+
}
|
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,55 @@ 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;
|
|
508
|
+
|
|
509
|
+
/** Which page value a token *is*, on the two whose interpolation says so. */
|
|
510
|
+
export type PageField = "page.number" | "page.total";
|
|
450
511
|
|
|
451
|
-
/**
|
|
452
|
-
|
|
512
|
+
/**
|
|
513
|
+
* One interpolation's pre-stringify value (sjabloon's value token).
|
|
514
|
+
*
|
|
515
|
+
* `field` is present where the interpolation is exactly `{{ page.number }}` or
|
|
516
|
+
* `{{ page.total }}`. The value is unchanged -- the number this render saw --
|
|
517
|
+
* and `field` says which page value the token stands for, so a target whose
|
|
518
|
+
* own document format numbers pages writes a live field there instead
|
|
519
|
+
* (SCHEMA.md, "Page bands"). Anything computed from a page value carries no
|
|
520
|
+
* `field`, and a target that paginates itself, or not at all, ignores the key.
|
|
521
|
+
*/
|
|
522
|
+
export type ValueToken = SjabloonValueToken & TokenStyle & { field?: PageField };
|
|
453
523
|
|
|
454
524
|
export type Token = LiteralToken | ValueToken;
|
|
455
525
|
|
|
526
|
+
/** One of a cell's styled runs, as `styledRuns` groups them back out. */
|
|
527
|
+
export interface TokenRun {
|
|
528
|
+
/** The style this run's tokens carry, or `null` where the cell's applies. */
|
|
529
|
+
style: Record<string, unknown> | null;
|
|
530
|
+
tokens: Token[];
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Group a cell's tokens into its styled runs, in order. Consecutive tokens
|
|
535
|
+
* with equal styles are one run — a lossless rule, because equal styles render
|
|
536
|
+
* identically, so grouping survives a JSON round trip. Equality is compared
|
|
537
|
+
* one level deep, which is exhaustive rather than approximate: `format` is the
|
|
538
|
+
* only object-valued declaration and is itself flat.
|
|
539
|
+
*
|
|
540
|
+
* The built-in targets consume it, so a custom target grouping through it
|
|
541
|
+
* cannot drift from them.
|
|
542
|
+
*/
|
|
543
|
+
export function styledRuns(tokens: Token[]): TokenRun[];
|
|
544
|
+
|
|
456
545
|
export interface EventCell {
|
|
457
546
|
tokens: Token[];
|
|
458
547
|
style?: Record<string, unknown>;
|
|
@@ -552,6 +641,14 @@ export interface ImageEvent {
|
|
|
552
641
|
bytes: Uint8Array;
|
|
553
642
|
/** Sniffed from the bytes' magic numbers, so no consumer repeats it. */
|
|
554
643
|
format: ImageFormat;
|
|
644
|
+
/**
|
|
645
|
+
* The image's own width in pixels, read from the same header. Bytes too
|
|
646
|
+
* short to state a size never reach a target: that is a render error on
|
|
647
|
+
* every target alike.
|
|
648
|
+
*/
|
|
649
|
+
width: number;
|
|
650
|
+
/** The image's own height in pixels, read from the same header. */
|
|
651
|
+
height: number;
|
|
555
652
|
fit: ImageFit;
|
|
556
653
|
/** The rendered `alt` template, present when the item declares one. */
|
|
557
654
|
alt?: Token[];
|
|
@@ -744,17 +841,35 @@ export interface Problem {
|
|
|
744
841
|
readonly diagnostic?: QuarioDiagnostic;
|
|
745
842
|
}
|
|
746
843
|
|
|
844
|
+
/**
|
|
845
|
+
* An advisory: the document declares something nothing will read. Not a
|
|
846
|
+
* `Problem` at lower severity, and not its shape either — a problem carries
|
|
847
|
+
* the engine's located `diagnostic` when one authenticated the fault, and a
|
|
848
|
+
* `source` when it came from compiling one. Neither can happen here: nothing
|
|
849
|
+
* raises, and the engine decides a declaration will not be read after every
|
|
850
|
+
* compile has already succeeded. A warning that did locate into an authored
|
|
851
|
+
* source would add the field back; carrying one nothing sets would promise a
|
|
852
|
+
* location that never arrives.
|
|
853
|
+
*/
|
|
854
|
+
export interface Warning {
|
|
855
|
+
readonly path: string;
|
|
856
|
+
readonly message: string;
|
|
857
|
+
}
|
|
858
|
+
|
|
747
859
|
/**
|
|
748
860
|
* The plan: both readings of the one traversal, kept. `report` is the
|
|
749
861
|
* compiled report, or null while the document has problems; `problems` is the
|
|
750
|
-
* structured list `validate()` flattens to strings; `
|
|
751
|
-
*
|
|
752
|
-
* the
|
|
862
|
+
* structured list `validate()` flattens to strings; `warnings` is the advisory
|
|
863
|
+
* half, which never nulls the report; `anchors` maps each compiled source's
|
|
864
|
+
* schema path to the anchors and group handles it reads — the unfiltered
|
|
865
|
+
* complement of `names`, which excludes them. `problems` is fatal and
|
|
866
|
+
* `warnings` is advisory, both in document order from the one descent.
|
|
753
867
|
*/
|
|
754
868
|
export interface Plan {
|
|
755
869
|
readonly report: CompiledReport | null;
|
|
756
870
|
readonly problems: readonly Problem[];
|
|
757
871
|
readonly anchors: Readonly<Record<string, readonly string[]>>;
|
|
872
|
+
readonly warnings: readonly Warning[];
|
|
758
873
|
}
|
|
759
874
|
|
|
760
875
|
export interface Quario {
|
|
@@ -767,8 +882,8 @@ export interface Quario {
|
|
|
767
882
|
report(schema: ReportSchema, functions?: FunctionRegistry): CompiledReport;
|
|
768
883
|
/**
|
|
769
884
|
* 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.
|
|
885
|
+
* problems, advisory warnings, and per-node anchor sets — one descent per
|
|
886
|
+
* edit for a host that validates and renders in a loop.
|
|
772
887
|
*/
|
|
773
888
|
plan(schema: unknown, functions?: FunctionRegistry): Plan;
|
|
774
889
|
}
|
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/license.js
CHANGED
|
@@ -5,13 +5,13 @@
|
|
|
5
5
|
// Verification is entirely offline, and asynchronous only because WebCrypto
|
|
6
6
|
// is.
|
|
7
7
|
|
|
8
|
-
// Release date of this version, written into the
|
|
9
|
-
// release-
|
|
10
|
-
// spelling that `scripts/license/keygen.mjs` matches on,
|
|
11
|
-
// reassigns them. A key is valid for every version released inside its window
|
|
8
|
+
// Release date of this version, written into the Version PR's commit by
|
|
9
|
+
// `scripts/stamp-release-date.mjs` (the `release-date` mark). Both constants
|
|
10
|
+
// keep the `let` spelling that `scripts/license/keygen.mjs` matches on,
|
|
11
|
+
// though nothing reassigns them. A key is valid for every version released inside its window
|
|
12
12
|
// (LICENSE section 7), so validity compares ISO date strings and never reads
|
|
13
13
|
// a clock — accepted output stays accepted.
|
|
14
|
-
let RELEASE = "2026-09-
|
|
14
|
+
let RELEASE = "2026-09-14"; // release-date
|
|
15
15
|
// The verifying half of the signing pair: the 65-byte uncompressed P-256
|
|
16
16
|
// point, base64url. The private half never enters the repo;
|
|
17
17
|
// scripts/license/sign.mjs mints keys against it.
|
package/lib/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
|
+
};
|