@quario/html 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,69 @@ 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 render as spans.** A cell value written as a list of styled runs
15
+ emits one `<span style="…">` per styled run, carrying that run's whole
16
+ resolved inline style; unstyled runs stay bare, so a cell with no runs emits
17
+ exactly the markup it always did. Every interpolated value is escaped as
18
+ before, and the span's own CSS is escaped like any other attribute value.
19
+
20
+ ### Changed
21
+
22
+ - **A `format` inside a sentence now presents where the engine says it does.** A
23
+ cell mixing literal text and one interpolation under a `format` renders the
24
+ value plainly, as the spreadsheet and CSV targets already did; splitting the
25
+ value into styled runs is how a value inside a sentence is formatted.
26
+
27
+ - **The reference stylesheet no longer paints a look no other target has.**
28
+ `@quario/html/style.css` dropped the hairline under a table's header row, the
29
+ hairline and bold weight on its total rows, and the rule above the report
30
+ footer. A report that declares no borders now renders without them on screen,
31
+ which is what the PDF and the worksheet have always shown for the same
32
+ report — the difference was the stylesheet's alone, and it also surfaced as a
33
+ cell whose computed border colour failed keeping a black stroke here while
34
+ every other target drew nothing. The sheet now carries the band-role defaults,
35
+ the table's structure and the honor and pagination rules, and no look beyond
36
+ them. The classes are unchanged, so a host that wants the old look adds three
37
+ rules to its own sheet after the shipped one:
38
+
39
+ ```css
40
+ .q-table thead th {
41
+ border-bottom: 0.5pt solid #000;
42
+ }
43
+ .q-table tfoot td {
44
+ border-top: 0.5pt solid #000;
45
+ font-weight: bold;
46
+ }
47
+ .q-item.q-report-footer {
48
+ border-top: 1pt solid #000;
49
+ padding-top: 4pt;
50
+ }
51
+ ```
52
+
53
+ ## [0.6.0] - 2026-09-07
54
+
55
+ ### Added
56
+
57
+ - **A cell's own `currency` code is honoured** at the markup edge, ahead of the
58
+ instance's default, so a listing whose rows arrive in different currencies
59
+ presents each in its own. A code the engine could not accept leaves the cell
60
+ as plain display text rather than presenting it in the instance's currency.
61
+
62
+ ### Changed
63
+
64
+ - **Numbers presented through `format` now show a fixed two fraction digits,
65
+ matching every other target.** `format: "number"` on `1000` renders
66
+ `1,000.00` where it rendered `1,000`, and `0.12345` renders `0.12` where it
67
+ rendered `0.123`; `format: "percent"` on `0.21` renders `21.00%` where it
68
+ rendered `21%`. `format: "currency"` follows the currency's own minor units,
69
+ so a JPY amount loses the two decimals it never had. `date` is unchanged, as
70
+ are grouping separators and symbol placement. The digits come from the
71
+ engine, so a cell reads the same here as it does in a workbook.
72
+
10
73
  ## [0.5.0] - 2026-09-05
11
74
 
12
75
  ### Added
package/README.md CHANGED
@@ -61,8 +61,9 @@ const page = await report.render(html(), data);
61
61
  </div>
62
62
  ```
63
63
 
64
- The output is a **fragment**: no `<html>`, no `<head>`, no styles of its own. Wrap it in your own
65
- page shell and stylesheet.
64
+ The output is a **fragment**: no `<html>`, no `<head>`, no stylesheet of its own the one
65
+ `<style>` it writes is an `@page { margin }` rule when the schema declares `page.margin`. Wrap it
66
+ in your own page shell and stylesheet.
66
67
 
67
68
  ## API
68
69
 
@@ -72,9 +73,16 @@ The target factory returns the target you pass to `render`.
72
73
  `report()` compiles once and `report.render(html(), data)` resolves the fragment. Compile at
73
74
  startup and render per request. Definition problems throw at `report()`, at compile time.
74
75
 
75
- One option: `{ paths: true }` stamps `data-q-path="<schema path>"` on each element whose event
76
+ Two options. `{ paths: true }` stamps `data-q-path="<schema path>"` on each element whose event
76
77
  carries one (items, images, group containers, the table and its cells), mapping rendered output
77
- back to the definition behind it. Off by default.
78
+ back to the definition behind it. Off by default. `{ fonts }` maps a declared `family` name to the
79
+ CSS `font-family` value it should emit — a custom property, a font stack, a quoted name —
80
+ consulted before the three built-in generics; names match case-insensitively, and a value holding
81
+ `;` or `}` is rejected when the target is constructed:
82
+
83
+ ```js
84
+ html({ fonts: { "Instrument Sans": "var(--font-instrument-sans)", mono: "var(--font-ibm-plex-mono)" } });
85
+ ```
78
86
 
79
87
  ```js
80
88
  const report = quario().report(schema, funcs);
@@ -97,15 +105,17 @@ host. Compilation stays synchronous; render-time failures reject with the same l
97
105
  These classes are the contract host CSS targets. They are stable, and changing them is a breaking
98
106
  change.
99
107
 
100
- | Emits | For |
101
- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
102
- | `<div class="q-item q-<role>">` | Every item. Roles: `q-report-header`, `q-empty`, `q-group-header`, `q-detail`, `q-group-footer`, `q-report-footer` |
103
- | `<div class="q-group" data-group="name">` | Each group instance, wrapping its header, nested content, and footer. Adds `q-break` when the group declares `break: "page"` or `reset: "page"` |
104
- | `<table class="q-table">` | Each table, with a real `colgroup`, `thead`, `tbody`, and a `tfoot` when totals are declared |
105
- | `<div class="q-item q-image q-<role>">` | Each image item, holding one `<img>` whose `src` is a base64 `data:` URI of the event's bytes; the rendered `alt` is escaped |
106
- | `<div class="q-split q-<role>">` | Each split, carrying inline `display:flex`; its slots are `<div class="q-slot">` carrying inline `display:grid` and their share, each holding the slot item's ordinary container |
107
-
108
- A column `width` becomes an inline `width:<n>%` on its `<col>`. The classes above belong to this
108
+ | Emits | For |
109
+ | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
110
+ | `<div class="q-report">` | The fragment's root, holding every band |
111
+ | `<div class="q-item q-<role>">` | Every item. Roles: `q-report-header`, `q-empty`, `q-group-header`, `q-detail`, `q-group-footer`, `q-report-footer` |
112
+ | `<div class="q-group" data-group="name">` | Each group instance, wrapping its header, nested content, and footer. Adds `q-break` when the group declares `break: "page"` or `reset: "page"` |
113
+ | `<table class="q-table">` | Each table, with a real `colgroup`, `thead`, `tbody`, and a `tfoot` around the total rows the walk emits — none when `total` is absent or every row of it is hidden. A spanning cell carries `colspan` |
114
+ | `<div class="q-item q-image q-<role>">` | Each image item, holding one `<img>` whose `src` is a base64 `data:` URI of the event's bytes; `fit` sets `max-width:100%` or `width:100%` on it, and the rendered `alt` is escaped |
115
+ | `<div class="q-columns" style="column-count:<n>">` | The content between a node's own bands when it declares `columns`: on the root, the body between report header and footer; on a group, inside that instance's `q-group` |
116
+ | `<div class="q-split q-<role>">` | Each split, carrying inline `display:flex`; its slots are `<div class="q-slot">` carrying inline `display:grid` and their share (a slot's `valign` as `align-content`), each holding the slot item's ordinary container |
117
+
118
+ A column `width` becomes an inline `width:<n>%` on its `<col>`. A row's `style` lands on its `<tr>`, except the box, which the engine has already resolved onto the cells (each `<td>` carries `box-sizing:border-box`). The classes above belong to this
109
119
  target. The schema never speaks in CSS. Displaying a fragment that contains images under a Content
110
120
  Security Policy needs `img-src data:`.
111
121
 
@@ -121,8 +131,9 @@ instead. The PDF and XLSX targets carry theirs built in, because their consumers
121
131
  row set still emits the table, its header, and an empty `<tbody>`. A hidden table cell keeps its `<td>`, empty.
122
132
 
123
133
  This target is unpaginated: it ignores schema page bands (page furniture belongs to your print CSS)
124
- and maps `break: "page"` and `reset: "page"` to the `q-break` class. Page columns are accepted on
125
- the schema and ignored here until a target honors them.
134
+ and maps `break: "page"` and `reset: "page"` to the `q-break` class. Page columns are laid out:
135
+ a node declaring `columns` wraps the content between its own bands in the `q-columns` container
136
+ above, and the browser flows it.
126
137
 
127
138
  ## Unlicensed marking
128
139
 
@@ -134,8 +145,8 @@ badge unstyled, because a watermark would escape the fragment's box onto the hos
134
145
  ## Escaping
135
146
 
136
147
  **Every interpolated value is escaped.** `{{{ }}}` is a definition error, so no schema syntax can
137
- exempt a data value from escaping. Generated attribute values, including classes and inline
138
- styles built from expressions, are escaped too.
148
+ exempt a data value from escaping. Every generated attribute value is escaped too — inline styles, `data-` attributes, `src`, `alt`
149
+ and `colspan`; class names are constants the target owns and never carry data.
139
150
 
140
151
  Literal template text passes through verbatim as author-controlled markup. A definition is
141
152
  trusted configuration. Its _data_ is untrusted, and data can never reach the document unescaped.
@@ -154,6 +165,7 @@ Host CSS targets these classes:
154
165
  2. The shipped sheet already puts band behavior on the emitted classes:
155
166
  `.q-item { min-height: 1lh; white-space: pre-line }` occupies a line and breaks on newlines,
156
167
  `.q-group { break-inside: avoid }` keeps a group header with its rows,
168
+ `.q-table tr { break-inside: avoid }` keeps a row whole,
157
169
  `.q-break { break-before: page }` honors the schema's own break hint, and `thead` repeats per
158
170
  printed page. Yours adds the page geometry in `@page`, which only you can decide.
159
171
  3. Print with a headless browser (`page.pdf()` in Playwright or Puppeteer, page numbers via the
@@ -163,8 +175,11 @@ Host CSS targets these classes:
163
175
  ### The reference stylesheet
164
176
 
165
177
  The package ships the default look as a real stylesheet, `@quario/html/style.css`: the band-role
166
- weights and sizes, the table look, occupy and newline-as-break on `.q-item` and table cells, and
167
- the four pagination rules above. Link it, import it through a bundler, or read and inline it.
178
+ weights and sizes, the table's structure (full width, collapsed borders, cell padding, an
179
+ unstyled cell pinned to the top of its row), occupy and newline-as-break on `.q-item` and table
180
+ cells, and the four pagination rules above. It carries no look beyond that — no rule under the
181
+ header row, no bold totals, no stroke above the report footer — because no other target has one
182
+ either. Add them to your own sheet if you want them. Link it, import it through a bundler, or read and inline it.
168
183
  Plain Node cannot `import` a `.css` file, so on the server:
169
184
 
170
185
  ```js
package/lib/index.js CHANGED
@@ -9,10 +9,16 @@
9
9
  * never carries markup-escaped text.
10
10
  */
11
11
 
12
- import { display, format, isReportBand, text, walk } from "quario";
12
+ import { display, format, isReportBand, styledRuns, text, walk } from "quario";
13
13
 
14
14
  /** @type {Record<string, string>} */
15
- let ESC = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
15
+ let ESC = {
16
+ "&": "&amp;",
17
+ "<": "&lt;",
18
+ ">": "&gt;",
19
+ '"': "&quot;",
20
+ "'": "&#39;",
21
+ };
16
22
  /** @type {(value: any) => string} */
17
23
  let esc = (value) => display(value).replace(/[&<>"']/g, (char) => ESC[char]);
18
24
 
@@ -21,8 +27,8 @@ let esc = (value) => display(value).replace(/[&<>"']/g, (char) => ESC[char]);
21
27
  // exceptions. The engine's own `text` join is the other one -- display text,
22
28
  // where a literal is words rather than markup -- and an attribute value takes
23
29
  // that one, escaped whole.
24
- /** @type {(tokens: any[], style?: any, intl?: any) => string} */
25
- let markup = (tokens, style, intl) => {
30
+ /** @type {(tokens: any[], style: any, intl: any) => string} */
31
+ let joined = (tokens, style, intl) => {
26
32
  let out = "";
27
33
  for (let token of tokens)
28
34
  out += "literal" in token ? token.literal : esc(shown(token.value, style, intl));
@@ -30,10 +36,33 @@ let markup = (tokens, style, intl) => {
30
36
  };
31
37
  /** @type {(value: any, style: any, intl: any) => any} */
32
38
  let shown = (value, style, intl) => {
33
- let text = format(value, style?.format, intl);
39
+ let text = format(value, style, intl);
34
40
  return text != null ? text : value;
35
41
  };
36
42
 
43
+ // One styled run: a `<span>` carrying that run's whole resolved inline style,
44
+ // rather than a difference against the cell's -- the engine already composed
45
+ // the two, and a delta would have this target merge them back. An unstyled run
46
+ // stays bare, so a cell with no authored runs emits exactly the markup it
47
+ // always did. The span's own CSS is `esc()`d like any other attribute value.
48
+ /** @type {(styled: { style: any, tokens: any[] }, style: any, intl: any, map: any) => string} */
49
+ let piece = (styled, style, intl, map) => {
50
+ let body = joined(styled.tokens, styled.style ?? style, intl);
51
+ if (!styled.style) return body;
52
+ let inline = css(styled.style, map);
53
+ return inline ? '<span style="' + esc(inline) + '">' + body + "</span>" : body;
54
+ };
55
+
56
+ // A cell's markup: its styled runs in order. The grouping is the engine's
57
+ // `styledRuns`, so this target cannot drift from the others (SCHEMA.md, "Event
58
+ // stream").
59
+ /** @type {(map: any) => (tokens: any[], style?: any, intl?: any) => string} */
60
+ let cellMarkup = (map) => (tokens, style, intl) => {
61
+ let out = "";
62
+ for (let styled of styledRuns(tokens)) out += piece(styled, style, intl, map);
63
+ return out;
64
+ };
65
+
37
66
  // base64 for an image's `data:` URI, without Buffer: this package runs in Node
38
67
  // and in ES2024 browsers off the same `lib/`, and `btoa` is a standard global
39
68
  // in both. The walk is chunked because `fromCharCode(...bytes)` spreads one
@@ -243,15 +272,20 @@ let css = (style, map) => {
243
272
  // columns and splits are placement, not the visual defaults this target
244
273
  // refuses to supply, so they ride the same escaped attribute path as
245
274
  // everything else rather than a second one (CLAUDE.md, the markup edge).
246
- /** @type {(fonts: any) => (event: { style?: any }, own?: string) => string} */
275
+ /** @type {(fonts: any) => { attr: (event: { style?: any }, own?: string) => string, map: Record<string, string> }} */
247
276
  let styling = (fonts) => {
248
277
  // The spread is the precedence the option promises: a host mapping is
249
278
  // consulted before the three generics, and may replace one. `mapping`
250
- // lowercased its keys to match the ones already here.
279
+ // lowercased its keys to match the ones already here. The table is handed
280
+ // back beside the attribute writer, because a styled run's span resolves a
281
+ // `family` against the same one.
251
282
  let map = { ...FAMILY, ...mapping(fonts) };
252
- return ({ style }, own = "") => {
253
- let out = [own, style && css(style, map)].filter(Boolean).join(";");
254
- return out ? ' style="' + esc(out) + '"' : "";
283
+ return {
284
+ map,
285
+ attr: ({ style }, own = "") => {
286
+ let out = [own, style && css(style, map)].filter(Boolean).join(";");
287
+ return out ? ' style="' + esc(out) + '"' : "";
288
+ },
255
289
  };
256
290
  };
257
291
 
@@ -296,8 +330,8 @@ let justified = (own, style) =>
296
330
  // event to "" through the same function rather than re-deciding per call site.
297
331
  /** @type {(event: { path?: string }) => string} */
298
332
  let pathAttr = ({ path }) => (path ? ' data-q-path="' + esc(path) + '"' : "");
299
- /** @type {(list: any[], tag: string, attrs: (event: any) => string, intl: any) => string} */
300
- let cells = (list, tag, attrs, intl) =>
333
+ /** @type {(list: any[], tag: string, attrs: (event: any) => string, intl: any, markup: any) => string} */
334
+ let cells = (list, tag, attrs, intl, markup) =>
301
335
  list
302
336
  .map(
303
337
  (cell) =>
@@ -336,7 +370,9 @@ let cells = (list, tag, attrs, intl) =>
336
370
  export function html(options) {
337
371
  let mark = options?.paths ? pathAttr : () => "";
338
372
  // The factory's verdict, like `mark`: the mapping is validated once, here.
339
- let styleAttr = styling(options?.fonts);
373
+ let { attr: styleAttr, map: fontMap } = styling(options?.fonts);
374
+ // A cell's markup, bound to this target's font table once.
375
+ let markup = cellMarkup(fontMap);
340
376
  // The pair every cell carries, bound once rather than threaded as two.
341
377
  /** @type {(cell: any) => string} */
342
378
  // A span covers several columns; `colspan` is what that is on this surface.
@@ -351,7 +387,7 @@ export function html(options) {
351
387
  // the cells, where it arrives as ordinary inline CSS.
352
388
  /** @type {(event: any, list: any[], tag: string, intl: any) => string} */
353
389
  let tr = (event, list, tag, intl) =>
354
- "<tr" + styleAttr(event) + ">" + cells(list, tag, attrs, intl) + "</tr>";
390
+ "<tr" + styleAttr(event) + ">" + cells(list, tag, attrs, intl, markup) + "</tr>";
355
391
  return {
356
392
  name: "html",
357
393
  compile: (stream) => async (data) => {
@@ -441,7 +477,11 @@ export function html(options) {
441
477
  // edge, so the badge stays childless whatever stream a host compiles
442
478
  // against.
443
479
  "report-start": (event) => {
444
- intl = { locale: event.locale, currency: event.currency, timeZone: event.timeZone };
480
+ intl = {
481
+ locale: event.locale,
482
+ currency: event.currency,
483
+ timeZone: event.timeZone,
484
+ };
445
485
  if (event.marking) out += '<div class="q-unlicensed">' + esc(event.marking) + "</div>";
446
486
  if (event.margin != null)
447
487
  out += "<style>@page{margin:" + esc(event.margin) + "pt}</style>";
@@ -557,13 +597,6 @@ export function html(options) {
557
597
  for (let column of /** @type {any[]} */ (event.columns))
558
598
  cols +=
559
599
  "<col" + (column.width ? ' style="width:' + esc(column.width) + '%"' : "") + ">";
560
- // The header row is the cells the columns declared: a column a
561
- // neighbour's span covers has none, and contributes only its `<col>`.
562
- let headers = event.columns
563
- .filter((/** @type {any} */ column) => column.header)
564
- .map((/** @type {any} */ column) =>
565
- Object.assign({}, column.header, { path: column.path }),
566
- );
567
600
  out +=
568
601
  '<table class="q-table"' +
569
602
  mark(event) +
@@ -572,7 +605,9 @@ export function html(options) {
572
605
  cols +
573
606
  "</colgroup>" +
574
607
  "<thead>" +
575
- tr(event, headers, "th", intl) +
608
+ // The header row arrives shaped like a `row` payload, so the same
609
+ // `<tr>` writer takes it whole (docs/adr/0053).
610
+ tr(event.header, event.header.cells, "th", intl) +
576
611
  "</thead><tbody>";
577
612
  },
578
613
  row: (event) => {
package/lib/style.css CHANGED
@@ -73,15 +73,6 @@
73
73
  white-space: pre-line;
74
74
  }
75
75
 
76
- .q-table thead th {
77
- border-bottom: 0.5pt solid #000;
78
- }
79
-
80
- .q-table tfoot td {
81
- border-top: 0.5pt solid #000;
82
- font-weight: bold;
83
- }
84
-
85
76
  .q-item.q-report-header {
86
77
  font-size: 14pt;
87
78
  font-weight: bold;
@@ -98,8 +89,6 @@
98
89
 
99
90
  .q-item.q-report-footer {
100
91
  margin-top: 10pt;
101
- border-top: 1pt solid #000;
102
- padding-top: 4pt;
103
92
  }
104
93
 
105
94
  /* A columned container is a fragmentation context on screen, not only in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quario/html",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "The HTML render target for quario — in the makings, not yet released",
5
5
  "homepage": "https://getquario.com",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -39,12 +39,12 @@
39
39
  "devDependencies": {
40
40
  "@arethetypeswrong/cli": "^0.18.3",
41
41
  "@size-limit/preset-small-lib": "^13.0.3",
42
- "quario": "^0.5.0",
42
+ "quario": "^0.7.0",
43
43
  "size-limit": "^13.0.3",
44
44
  "typescript": "^7.0.2"
45
45
  },
46
46
  "peerDependencies": {
47
- "quario": "^0.5.0"
47
+ "quario": "^0.7.0"
48
48
  },
49
49
  "size-limit": [
50
50
  {