quario 0.3.0 → 0.4.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 +47 -5
- package/lib/format.js +11 -4
- package/lib/index.d.ts +60 -7
- package/lib/license.js +1 -1
- package/lib/stream.js +67 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-09-03
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **`format: "date"` now reads a date string, not only a `Date`.** A JSON
|
|
15
|
+
document has no date type, so the kind could not be reached from parsed
|
|
16
|
+
data without reviving every date field by hand first. It now revives two
|
|
17
|
+
forms itself: a calendar date `2026-08-14`, and a timestamp naming its
|
|
18
|
+
offset (`2026-08-14T12:30:00Z` or `+02:00`). **This changes existing
|
|
19
|
+
output**: a cell declaring the kind over `"2026-08-14"` rendered
|
|
20
|
+
`2026-08-14` and now renders `14/8/2026` under an `en-IE` instance. A
|
|
21
|
+
zoneless `2026-08-14T00:00:00` is not read — it means local time, so it
|
|
22
|
+
would present a different day per machine — and neither is a loose
|
|
23
|
+
`14/8/2026`, a partial `2026-08`, a lowercase `t`/`z`, or a day that does
|
|
24
|
+
not exist. Anything unread renders as authored, as before.
|
|
25
|
+
|
|
26
|
+
The revival is the `Date` a host would have injected, timezone included: a
|
|
27
|
+
calendar date is UTC midnight, so under a western instance timezone it
|
|
28
|
+
presents as the previous day, exactly as `new Date("2026-08-14")` does.
|
|
29
|
+
Only `date` revives — `number`, `currency` and `percent` never read a
|
|
30
|
+
string, because JSON already carries numbers — and epoch milliseconds stay
|
|
31
|
+
a number under every kind.
|
|
32
|
+
|
|
33
|
+
- **`typed(tokens, kind?)` takes the cell's `format` kind.** Passing `"date"`
|
|
34
|
+
opts into the same revival, for consumers that write typed cells. The
|
|
35
|
+
one-argument call is unchanged.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- **The TypeScript declarations accept the box.** The per-side `padding*` and
|
|
40
|
+
`border*` names, and the table's `detail.header`, were validated by the
|
|
41
|
+
engine from 0.3.0 but were missing from the shipped declarations, so a
|
|
42
|
+
report declaring the padding or the border sides that release introduced
|
|
43
|
+
was rejected by the compiler as an unknown property and needed a cast to
|
|
44
|
+
get past it. They now type exactly as the engine reads them, on a band
|
|
45
|
+
image's `style` as well. Four names come with them, for annotating your own
|
|
46
|
+
helpers: `LineStyle` (`"solid" | "dashed" | "dotted"`), beside `Align` and
|
|
47
|
+
`FormatKind`; `TableHeaderBox`, the type of `detail.header`; and
|
|
48
|
+
`BoxDeclarations` with `Side`, the per-side names as one type, which
|
|
49
|
+
`StyleDeclarations` was built from without being nameable. Nothing
|
|
50
|
+
about rendering changes: a report that compiled through a cast produces the
|
|
51
|
+
same output without one.
|
|
52
|
+
|
|
10
53
|
## [0.3.0] - 2026-09-02
|
|
11
54
|
|
|
12
55
|
### Added
|
|
@@ -16,20 +59,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
16
59
|
`row.style`, not the report default. Locale, currency code, and timezone
|
|
17
60
|
live on `quario({ locale, currency, timeZone })`. The public `format()`
|
|
18
61
|
helper is how targets present a kind; a kind on the wrong type contributes
|
|
19
|
-
nothing
|
|
62
|
+
nothing.
|
|
20
63
|
|
|
21
64
|
- **The report header may pin a `height` from the page top.** Dual-shaped
|
|
22
65
|
like `detail`: an item array, or `{ height, items }`. `page.margin` is a
|
|
23
66
|
document field (one number, all four sides), required with `height` and
|
|
24
67
|
legal without. Authored `spaceBefore` on the first occupying item of the
|
|
25
|
-
next band is refused
|
|
68
|
+
next band is refused.
|
|
26
69
|
|
|
27
70
|
- **`spaceBefore` / `spaceAfter` return as item flow spacing.** Blank space
|
|
28
71
|
before or after a band item, in points, including band images and splits as
|
|
29
72
|
band items. Adjacent gaps add. Table cells, `row.style`, headers, totals,
|
|
30
73
|
and split slots refuse the names. `spaceBefore` drops at a fresh body page
|
|
31
|
-
or strip top; page-band items keep it. Leading and inset stay cut
|
|
32
|
-
(`docs/adr/0037`).
|
|
74
|
+
or strip top; page-band items keep it. Leading and inset stay cut.
|
|
33
75
|
|
|
34
76
|
- **Per-side padding and border on the closed style vocabulary.**
|
|
35
77
|
`paddingTop` / `Right` / `Bottom` / `Left` (points, ≥ 0) and, per side,
|
|
@@ -50,7 +92,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
50
92
|
array or `{ style, cells }`. After, it is absent or a non-empty array of
|
|
51
93
|
`{ cells, style?, visible? }`. A one-row total is `[{ cells: [...] }]`.
|
|
52
94
|
`total: []` is a definition error. Paths are `detail.total[r].cells[i]`.
|
|
53
|
-
The stream yields one `total-row` per emitted row
|
|
95
|
+
The stream yields one `total-row` per emitted row.
|
|
54
96
|
|
|
55
97
|
- **A visible text item occupies a line at its own `size`.** Empty display
|
|
56
98
|
used to take the report default's leading in PDF and collapse in HTML;
|
package/lib/format.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Defaults (`en-US`, UTC) are pinned so a PDF without a host locale still
|
|
9
9
|
* renders the same bytes on every machine (docs/adr/0041, docs/adr/0025).
|
|
10
10
|
*/
|
|
11
|
-
import { finiteDate, finiteNum } from "./stream.js";
|
|
11
|
+
import { finiteDate, finiteNum, reviveDate } from "./stream.js";
|
|
12
12
|
/** @type {(options: any) => string} */
|
|
13
13
|
let localeOf = (options) => options?.locale || "en-US";
|
|
14
14
|
/** @type {(options: any) => string} */
|
|
@@ -31,11 +31,18 @@ let asMoney = (value, locale, options) => {
|
|
|
31
31
|
if (!finiteNum(value) || !currency) return;
|
|
32
32
|
return new Intl.NumberFormat(locale, { style: "currency", currency }).format(value);
|
|
33
33
|
};
|
|
34
|
+
// A string in an accepted RFC 3339 form revives to the Date a host would have
|
|
35
|
+
// injected, then presents like any other Date — including in the instance's
|
|
36
|
+
// timezone, so a calendar date under a western zone presents as the day
|
|
37
|
+
// before, exactly as an injected `new Date("2026-08-14")` does today. The two
|
|
38
|
+
// spellings never disagree, which is the point (ADR 0041).
|
|
34
39
|
/** @type {(value: any, locale: string, options: any) => string | undefined} */
|
|
35
|
-
let asDate = (value, locale, options) =>
|
|
36
|
-
finiteDate(value)
|
|
37
|
-
|
|
40
|
+
let asDate = (value, locale, options) => {
|
|
41
|
+
let date = finiteDate(value) ? value : reviveDate(value);
|
|
42
|
+
return date
|
|
43
|
+
? new Intl.DateTimeFormat(locale, { timeZone: zoneOf(options) }).format(date)
|
|
38
44
|
: undefined;
|
|
45
|
+
};
|
|
39
46
|
|
|
40
47
|
/** @type {Record<string, (value: any, locale: string, options: any) => string | undefined>} */
|
|
41
48
|
let KINDS = { number: asNumber, currency: asMoney, percent: asPercent, date: asDate };
|
package/lib/index.d.ts
CHANGED
|
@@ -28,6 +28,40 @@ export type JsonValue =
|
|
|
28
28
|
| JsonValue[]
|
|
29
29
|
| { [key: string]: JsonValue };
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* The four sides, as the key fragments the names are built from. Not a value an
|
|
33
|
+
* author ever writes: only the pieces `padding<Side>` and `border<Side><Part>`
|
|
34
|
+
* are spelled out of it, and it is exported so the box below can be named.
|
|
35
|
+
*/
|
|
36
|
+
export type Side = "Top" | "Right" | "Bottom" | "Left";
|
|
37
|
+
|
|
38
|
+
/** The line a border side is drawn with. */
|
|
39
|
+
export type LineStyle = "solid" | "dashed" | "dotted";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The box: padding and border per side. Written as a mapping over the four
|
|
43
|
+
* sides, the way the runtime builds the names, so a side can never be spelled
|
|
44
|
+
* out in one place and forgotten in another.
|
|
45
|
+
*
|
|
46
|
+
* A border side is width, style, and colour together, or none — an incomplete
|
|
47
|
+
* literal side is a definition error the traversal reports, which no type can
|
|
48
|
+
* state. Padding is not flow spacing: `spaceBefore` / `spaceAfter` are blank
|
|
49
|
+
* space *between* items, and live on the vocabulary below.
|
|
50
|
+
*/
|
|
51
|
+
export type BoxDeclarations = {
|
|
52
|
+
/** Inset on that side, in points (>= 0). */
|
|
53
|
+
[S in Side as `padding${S}`]?: ExpressionValue<number>;
|
|
54
|
+
} & {
|
|
55
|
+
/** Stroke width on that side, in points (>= 0). `0` is a complete side that draws nothing. */
|
|
56
|
+
[S in Side as `border${S}Width`]?: ExpressionValue<number>;
|
|
57
|
+
} & {
|
|
58
|
+
/** Line style of that side. */
|
|
59
|
+
[S in Side as `border${S}Style`]?: ExpressionValue<LineStyle>;
|
|
60
|
+
} & {
|
|
61
|
+
/** Stroke color of that side, `#rgb`/`#rrggbb`. */
|
|
62
|
+
[S in Side as `border${S}Color`]?: ExpressionValue<string>;
|
|
63
|
+
};
|
|
64
|
+
|
|
31
65
|
/**
|
|
32
66
|
* The closed, target-neutral style vocabulary. Literal values are validated
|
|
33
67
|
* strictly (unknown names and mistyped literals are definition errors);
|
|
@@ -36,7 +70,7 @@ export type JsonValue =
|
|
|
36
70
|
* its own formatting model (the HTML target to inline CSS, the PDF target to
|
|
37
71
|
* faces, points, and rects).
|
|
38
72
|
*/
|
|
39
|
-
export interface StyleDeclarations {
|
|
73
|
+
export interface StyleDeclarations extends BoxDeclarations {
|
|
40
74
|
/** `'sans'` | `'serif'` | `'mono'`, or an embedded font family name. */
|
|
41
75
|
family?: ExpressionValue<string>;
|
|
42
76
|
/** Font size in points. */
|
|
@@ -87,14 +121,15 @@ export interface TextItem extends Cell {
|
|
|
87
121
|
export type ImageFit = "natural" | "width";
|
|
88
122
|
|
|
89
123
|
/**
|
|
90
|
-
* The declarations an image item accepts
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* own, so the
|
|
124
|
+
* The declarations an image item accepts: the box, plus the four names that
|
|
125
|
+
* are not about text. The rest of the vocabulary describes text, which an
|
|
126
|
+
* image does not have, so any other name on one is a definition error. A
|
|
127
|
+
* narrowing of the one vocabulary rather than a second list of its own, so the
|
|
128
|
+
* two cannot drift.
|
|
94
129
|
*/
|
|
95
130
|
export type ImageStyleDeclarations = Pick<
|
|
96
131
|
StyleDeclarations,
|
|
97
|
-
"background" | "align" | "spaceBefore" | "spaceAfter"
|
|
132
|
+
"background" | "align" | "spaceBefore" | "spaceAfter" | keyof BoxDeclarations
|
|
98
133
|
>;
|
|
99
134
|
|
|
100
135
|
/**
|
|
@@ -169,7 +204,17 @@ export interface TotalRow {
|
|
|
169
204
|
style?: StyleDeclarations;
|
|
170
205
|
}
|
|
171
206
|
|
|
207
|
+
/**
|
|
208
|
+
* The header row's own box — distinct from `TableHeader`, which is one
|
|
209
|
+
* column's header cell. This is the box the row wears, replayed with the
|
|
210
|
+
* header cells on every page continuation.
|
|
211
|
+
*/
|
|
212
|
+
export interface TableHeaderBox {
|
|
213
|
+
style?: StyleDeclarations;
|
|
214
|
+
}
|
|
215
|
+
|
|
172
216
|
export interface TableDetail {
|
|
217
|
+
header?: TableHeaderBox;
|
|
173
218
|
row?: TableRow;
|
|
174
219
|
columns: [TableColumn, ...TableColumn[]];
|
|
175
220
|
total?: [TotalRow, ...TotalRow[]];
|
|
@@ -657,8 +702,16 @@ export function format(
|
|
|
657
702
|
* The typed-cell seam: exactly one value token holding a finite number, a
|
|
658
703
|
* boolean, or a valid Date keeps its pre-stringify value; anything else —
|
|
659
704
|
* including a lone null — reports `undefined` and joins to display text.
|
|
705
|
+
*
|
|
706
|
+
* Passing the cell's resolved `format` kind opts into the seam's one
|
|
707
|
+
* coercion: under `"date"`, a string in either accepted RFC 3339 form —
|
|
708
|
+
* `YYYY-MM-DD`, or a timestamp naming its offset — revives to the Date it
|
|
709
|
+
* names. Omitting the argument leaves the seam as it was.
|
|
660
710
|
*/
|
|
661
|
-
export function typed(
|
|
711
|
+
export function typed(
|
|
712
|
+
tokens: readonly Token[],
|
|
713
|
+
kind?: unknown,
|
|
714
|
+
): number | boolean | Date | undefined;
|
|
662
715
|
|
|
663
716
|
/**
|
|
664
717
|
* Whether a band item's `role` names one of the report's own bands — its
|
package/lib/license.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// 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-03"; // x-release-please-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/stream.js
CHANGED
|
@@ -24,6 +24,61 @@ let asTyped = (value) => {
|
|
|
24
24
|
if (finiteDate(value)) return value;
|
|
25
25
|
return undefined;
|
|
26
26
|
};
|
|
27
|
+
// The seam's one coercion, kept behind the declared kind (ADR 0041).
|
|
28
|
+
/** @type {(value: any, kind: unknown) => Date | undefined} */
|
|
29
|
+
let asDeclared = (value, kind) => (kind === "date" ? reviveDate(value) : undefined);
|
|
30
|
+
|
|
31
|
+
// The two RFC 3339 forms a JSON document conventionally carries a date in:
|
|
32
|
+
// a calendar date, and a timestamp that names its offset. A zoneless
|
|
33
|
+
// `2026-08-14T00:00:00` is deliberately absent — ECMAScript reads it as local
|
|
34
|
+
// time, so the same document would present a different day per machine, which
|
|
35
|
+
// ADR 0025 rules out. Uppercase `T`/`Z` only, for the same reason: the lower
|
|
36
|
+
// case RFC 3339 permits is outside the ECMAScript format and parses at the
|
|
37
|
+
// engine's discretion.
|
|
38
|
+
let RFC3339 =
|
|
39
|
+
/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2})))?$/;
|
|
40
|
+
|
|
41
|
+
/** @type {(year: string, month: string) => number} */
|
|
42
|
+
let monthLength = (year, month) => new Date(Date.UTC(+year, +month, 0)).getUTCDate();
|
|
43
|
+
// An absent group is the timestamp half of an optional match, not a breach.
|
|
44
|
+
/** @type {(bound: [string | undefined, number, number]) => boolean} */
|
|
45
|
+
let within = ([n, lo, hi]) => n === undefined || (+n >= lo && +n <= hi);
|
|
46
|
+
// A non-string never matches: `exec` would coerce one, and a single-element
|
|
47
|
+
// array stringifies to its element, which would revive an array as a date.
|
|
48
|
+
/** @type {(value: any) => RegExpExecArray | null} */
|
|
49
|
+
let rfc3339 = (value) => (typeof value === "string" ? RFC3339.exec(value) : null);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Revive a date string the way a host would have revived it — the accepted
|
|
53
|
+
* forms are exactly those `new Date(string)` reads identically on every
|
|
54
|
+
* machine, so declaring `format: "date"` and injecting `Date` values into the
|
|
55
|
+
* render data are two spellings of one document (ADR 0041).
|
|
56
|
+
*
|
|
57
|
+
* The components are range-checked here rather than left to the parser:
|
|
58
|
+
* ECMAScript rolls an out-of-range day over (`2026-02-30` becomes 2 March)
|
|
59
|
+
* where a date that does not exist should revive nothing at all. A leap
|
|
60
|
+
* second (`:60`) is refused on the same ground — RFC 3339 admits it and
|
|
61
|
+
* ECMAScript does not, so it would be a second rollover.
|
|
62
|
+
*
|
|
63
|
+
* @type {(value: any) => Date | undefined}
|
|
64
|
+
*/
|
|
65
|
+
export let reviveDate = (value) => {
|
|
66
|
+
let parts = rfc3339(value);
|
|
67
|
+
if (!parts) return undefined;
|
|
68
|
+
let [, year, month, day, hour, minute, second, offsetHour, offsetMinute] = parts;
|
|
69
|
+
/** @type {[string | undefined, number, number][]} */
|
|
70
|
+
let bounds = [
|
|
71
|
+
[month, 1, 12],
|
|
72
|
+
[day, 1, monthLength(year, month)],
|
|
73
|
+
[hour, 0, 23],
|
|
74
|
+
[minute, 0, 59],
|
|
75
|
+
[second, 0, 59],
|
|
76
|
+
[offsetHour, 0, 23],
|
|
77
|
+
[offsetMinute, 0, 59],
|
|
78
|
+
];
|
|
79
|
+
let date = bounds.every(within) ? new Date(value) : new Date(NaN);
|
|
80
|
+
return finiteDate(date) ? date : undefined;
|
|
81
|
+
};
|
|
27
82
|
|
|
28
83
|
/**
|
|
29
84
|
* The typed-cell seam: exactly one value token holding a finite number, a
|
|
@@ -31,13 +86,23 @@ let asTyped = (value) => {
|
|
|
31
86
|
* including a lone null, matching the display-text treatment everywhere
|
|
32
87
|
* else — reports `undefined` and joins to text.
|
|
33
88
|
*
|
|
89
|
+
* Passing the cell's declared `format` kind opts into the one coercion the
|
|
90
|
+
* seam performs: under `"date"`, a string in either accepted RFC 3339 form
|
|
91
|
+
* revives to the `Date` it names. The gate is the declaration, and the kind
|
|
92
|
+
* is `date` alone, because a date is the one type a JSON document cannot
|
|
93
|
+
* carry — a number arrives as a number, so no other kind has anything to
|
|
94
|
+
* revive (ADR 0041). Callers that read no style omit the argument and see
|
|
95
|
+
* the seam exactly as it was.
|
|
96
|
+
*
|
|
34
97
|
* @param {any[]} tokens A cell's tokens.
|
|
98
|
+
* @param {unknown} [kind] The cell's resolved `format` declaration.
|
|
35
99
|
* @returns {number | boolean | Date | undefined} The native value, if any.
|
|
36
100
|
*/
|
|
37
|
-
export function typed(tokens) {
|
|
101
|
+
export function typed(tokens, kind) {
|
|
38
102
|
let [token] = tokens;
|
|
39
103
|
if (tokens.length !== 1 || !("value" in token)) return undefined;
|
|
40
|
-
|
|
104
|
+
// `??` and not `||`: a boolean cell's own `false` is a typed value.
|
|
105
|
+
return asTyped(token.value) ?? asDeclared(token.value, kind);
|
|
41
106
|
}
|
|
42
107
|
|
|
43
108
|
// The report's own bands, by the role their items wear (CONTEXT.md, "Band").
|