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/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
- // Building an `Intl` formatter costs about 22 µs; using one costs 0.3 µs. A
28
- // report presents cell after cell from a handful of distinct formats, so
29
- // constructing per token spent 3.2 s on a hundred thousand presented cells
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 CAP = 2048;
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
- /** A cell value: one template string. */
122
- export type CellValue = string;
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
- /** The image's textual stand-in, as a cell value. */
198
- alt?: CellValue;
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
- /** One interpolation's pre-stringify value (sjabloon's value token). */
452
- export type ValueToken = SjabloonValueToken;
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; `anchors` maps each
751
- * compiled source's schema path to the anchors and group handles it reads —
752
- * the unfiltered complement of `names`, which excludes them.
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 edit for a host that
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 { currencyOf, FORMAT_VOCABULARY, fractionDigits, STYLE_NAMES } from "./style.js";
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 traversal's own objects
93
- // go to exactly one caller, so freezing here cannot disturb a second reader.
94
- // The diagnostic stays an engine-minted error and is deliberately not frozen.
95
- /** @type {(problems: any[]) => readonly any[]} */
96
- let freezeProblems = (problems) => Object.freeze(problems.map((problem) => Object.freeze(problem)));
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: freezeProblems(planned.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 version commit by
9
- // release-please (`x-release-please-date`). Both constants keep the `let`
10
- // spelling that `scripts/license/keygen.mjs` matches on, though nothing
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-07"; // x-release-please-date
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
- new Intl.NumberFormat("en-US", {
24
- minimumFractionDigits: n,
25
- maximumFractionDigits: n,
26
- useGrouping: false,
27
- roundingMode: /** @type {any} */ (mode),
28
- }).format(x),
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 = Math.trunc(+(n ?? 0));
53
- if (!honourable(digits)) throw new RangeError("n must be a number from 0 to 100");
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
- /** @type {(mode: string) => (x: any, n: any) => any} */
58
- let rounder = (mode) => (x, n) => {
59
- let digits = places(n);
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
+ };