@quario/html 0.2.0 → 0.3.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,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-09-02
11
+
12
+ ### Added
13
+
14
+ - **`format` stringifies at the markup edge.** The kind is presented through
15
+ the engine's `format()` helper from the instance locale, then escaped like
16
+ any other interpolated value.
17
+
18
+ - **`page.margin` becomes `@page{margin:<n>pt}`.** Report-header `height`
19
+ is unread: a CSS height would pin in the body box, not from the page top.
20
+
21
+ - **`spaceBefore` / `spaceAfter` map to `margin-top` / `margin-bottom`.**
22
+ Report and group containers are flex columns so adjacent authored gaps add
23
+ rather than collapse.
24
+
25
+ - **Padding and complete border sides map to inline CSS.**
26
+ `box-sizing:border-box` rides the same attribute. A side that does not
27
+ resolve all three names emits nothing, so a missing colour never becomes
28
+ the browser's solid black. Header-row, row, and total-row boxes are
29
+ withdrawn from `<tr>` — CSS does not box a table row — while other row
30
+ styles still inherit.
31
+
32
+ ### Changed
33
+
34
+ - **`<tfoot>` holds every total row.** Extra rows are extra `<tr>`; the
35
+ section still closes at `table-end`.
36
+
37
+ - **A visible text item occupies a line, and a literal newline is a break.**
38
+ Before, an empty item collapsed and a newline became a space. The honor
39
+ rules now hang off `.q-item` and `.q-table th, td` in the reference
40
+ stylesheet, the same pattern as `.q-break` — class is the hook, the rule
41
+ is overridable. Empty table cells still collapse. Host CSS still owns
42
+ leading; this is not `line-height: 1.4`.
43
+
44
+ ### Fixed
45
+
46
+ - **A declared `false` flag now emits its CSS off-value.** `bold: false` used
47
+ to emit nothing, so the reference stylesheet's band-role `font-weight: bold`
48
+ still won on screen while PDF rendered regular. Exact `false` now writes
49
+ `font-weight:normal` (and the matching off-value for italic, uppercase, and
50
+ the decorations), which beats the sheet. Omit and a non-boolean result still
51
+ emit nothing.
52
+
10
53
  ## [0.2.0] - 2026-09-01
11
54
 
12
55
  ### Added
package/README.md CHANGED
@@ -109,15 +109,16 @@ A column `width` becomes an inline `width:<n>%` on its `<col>`. The classes abov
109
109
  target. The schema never speaks in CSS. Displaying a fragment that contains images under a Content
110
110
  Security Policy needs `img-src data:`.
111
111
 
112
- Style declarations map to inline CSS (`bold` → `font-weight:bold`, `size` → `font-size:<n>pt`,
113
- `family: "mono"` → `font-family:monospace`, …). Only those mappings appear in the markup. This
114
- target supplies no defaults in the markup: no weight or size per band role, no leading, no padding,
115
- no spacing between bands, no borders. An unstyled report renders as unstyled markup, and the
116
- classes above are where a stylesheet supplies the look. An inline `style` attribute would beat
117
- yours in the cascade and force `!important` on you; the defaults ship as an ordinary stylesheet
118
- instead, at [`@quario/html/style.css`](#the-reference-stylesheet). The PDF and XLSX targets carry
119
- theirs built in, because their consumers have no stylesheet. An empty row set still emits the
120
- table, its header, and an empty `<tbody>`. A hidden table cell keeps its `<td>`, empty.
112
+ Style declarations map to inline CSS (`bold` → `font-weight:bold` or `font-weight:normal`, `size` → `font-size:<n>pt`,
113
+ `family: "mono"` → `font-family:monospace`, …). Occupy-a-line and newline-as-break are **not** inline: they hang off
114
+ `.q-item` and `.q-table th, td` in [`@quario/html/style.css`](#the-reference-stylesheet), the same way `.q-break`
115
+ honors `break: "page"`. The class is the hook; the rule is a reference default a host overrides on source order.
116
+ A fragment without that sheet still carries the classes and the text, including newlines; it does not occupy or
117
+ break until some stylesheet says so. This target supplies no defaults in the markup: no weight or size per band
118
+ role, no leading, no padding, no spacing between bands, no borders. An inline `style` attribute would beat yours
119
+ in the cascade and force `!important` on you; the defaults and the honor rules ship as an ordinary stylesheet
120
+ instead. The PDF and XLSX targets carry theirs built in, because their consumers have no stylesheet. An empty
121
+ row set still emits the table, its header, and an empty `<tbody>`. A hidden table cell keeps its `<td>`, empty.
121
122
 
122
123
  This target is unpaginated: it ignores schema page bands (page furniture belongs to your print CSS)
123
124
  and maps `break: "page"` and `reset: "page"` to the `q-break` class. Page columns are accepted on
@@ -151,6 +152,7 @@ Host CSS targets these classes:
151
152
  1. Render the fragment and wrap it in a document with two stylesheets inlined, in order:
152
153
  `@quario/html/style.css` below, then your own.
153
154
  2. The shipped sheet already puts band behavior on the emitted classes:
155
+ `.q-item { min-height: 1lh; white-space: pre-line }` occupies a line and breaks on newlines,
154
156
  `.q-group { break-inside: avoid }` keeps a group header with its rows,
155
157
  `.q-break { break-before: page }` honors the schema's own break hint, and `thead` repeats per
156
158
  printed page. Yours adds the page geometry in `@page`, which only you can decide.
@@ -161,8 +163,9 @@ Host CSS targets these classes:
161
163
  ### The reference stylesheet
162
164
 
163
165
  The package ships the default look as a real stylesheet, `@quario/html/style.css`: the band-role
164
- weights and sizes, the table look, and the four pagination rules above. Link it, import it through
165
- a bundler, or read and inline it. Plain Node cannot `import` a `.css` file, so on the server:
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.
168
+ Plain Node cannot `import` a `.css` file, so on the server:
166
169
 
167
170
  ```js
168
171
  import { readFileSync } from "node:fs";
package/lib/index.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * never carries markup-escaped text.
10
10
  */
11
11
 
12
- import { display, isReportBand, text, walk } from "quario";
12
+ import { display, format, isReportBand, text, walk } from "quario";
13
13
 
14
14
  /** @type {Record<string, string>} */
15
15
  let ESC = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
@@ -21,12 +21,18 @@ let esc = (value) => display(value).replace(/[&<>"']/g, (char) => ESC[char]);
21
21
  // exceptions. The engine's own `text` join is the other one -- display text,
22
22
  // where a literal is words rather than markup -- and an attribute value takes
23
23
  // that one, escaped whole.
24
- /** @type {(tokens: any[]) => string} */
25
- let markup = (tokens) => {
24
+ /** @type {(tokens: any[], style?: any, intl?: any) => string} */
25
+ let markup = (tokens, style, intl) => {
26
26
  let out = "";
27
- for (let token of tokens) out += "literal" in token ? token.literal : esc(token.value);
27
+ for (let token of tokens)
28
+ out += "literal" in token ? token.literal : esc(shown(token.value, style, intl));
28
29
  return out;
29
30
  };
31
+ /** @type {(value: any, style: any, intl: any) => any} */
32
+ let shown = (value, style, intl) => {
33
+ let text = format(value, style?.format, intl);
34
+ return text != null ? text : value;
35
+ };
30
36
 
31
37
  // base64 for an image's `data:` URI, without Buffer: this package runs in Node
32
38
  // and in ES2024 browsers off the same `lib/`, and `btoa` is a standard global
@@ -100,6 +106,10 @@ let named = (map, value) => {
100
106
  if (Object.hasOwn(map, key)) return map[key];
101
107
  return NAMEABLE.test(value) ? "'" + value + "'" : "";
102
108
  };
109
+ // Exact true/false only: a computed value of the wrong shape contributes
110
+ // nothing, same as omit (SCHEMA.md, Style declarations).
111
+ /** @type {(on: string, off: string) => (value: any) => string} */
112
+ let flag = (on, off) => (value) => (value === true ? on : value === false ? off : "");
103
113
  /** @type {Record<string, (value: any, map: Record<string, string>) => string>} */
104
114
  let CSS = {
105
115
  family: (value, map) => {
@@ -108,22 +118,68 @@ let CSS = {
108
118
  return name ? "font-family:" + name : "";
109
119
  },
110
120
  size: (value) => (Number.isFinite(value) && value > 0 ? "font-size:" + value + "pt" : ""),
111
- bold: (value) => (value ? "font-weight:bold" : ""),
112
- italic: (value) => (value ? "font-style:italic" : ""),
121
+ bold: flag("font-weight:bold", "font-weight:normal"),
122
+ italic: flag("font-style:italic", "font-style:normal"),
113
123
  color: (value) => (isHex(value) ? "color:" + value : ""),
114
124
  background: (value) => (isHex(value) ? "background-color:" + value : ""),
115
125
  align: (value) => (ALIGNMENTS.includes(value) ? "text-align:" + value : ""),
116
126
  // The text itself is untouched, so selection and screen readers get what
117
127
  // the author wrote; only its rendering is capitalised.
118
- uppercase: (value) => (value ? "text-transform:uppercase" : ""),
128
+ uppercase: flag("text-transform:uppercase", "text-transform:none"),
129
+ };
130
+
131
+ let SIDES = ["Top", "Right", "Bottom", "Left"];
132
+ let LINES = ["solid", "dashed", "dotted"];
133
+ /** @type {(value: any) => boolean} */
134
+ let isPad = (value) => Number.isFinite(value) && value >= 0;
135
+ for (let side of SIDES) {
136
+ let edge = side.toLowerCase();
137
+ CSS["padding" + side] = (value) => (isPad(value) ? "padding-" + edge + ":" + value + "pt" : "");
138
+ }
139
+ CSS.spaceBefore = (value) => (isPad(value) ? "margin-top:" + value + "pt" : "");
140
+ CSS.spaceAfter = (value) => (isPad(value) ? "margin-bottom:" + value + "pt" : "");
141
+
142
+ /** @type {(width: any) => boolean} */
143
+ let isStroke = (width) => Number.isFinite(width) && width > 0;
144
+
145
+ /** @type {(style: any, side: string) => string[]} */
146
+ let borderSide = (style, side) => {
147
+ let width = style["border" + side + "Width"];
148
+ let line = style["border" + side + "Style"];
149
+ let color = style["border" + side + "Color"];
150
+ if (!isStroke(width) || !LINES.includes(line) || !isHex(color)) return [];
151
+ let edge = side.toLowerCase();
152
+ return [
153
+ "border-" + edge + "-width:" + width + "pt",
154
+ "border-" + edge + "-style:" + line,
155
+ "border-" + edge + "-color:" + color,
156
+ ];
157
+ };
158
+
159
+ /** @type {(name: string) => boolean} */
160
+ let isBox = (name) => name.startsWith("padding") || name.startsWith("border");
161
+
162
+ /** @type {(style: any) => any} */
163
+ let unbox = (style) => {
164
+ if (!style) return;
165
+ let names = Object.keys(style).filter((name) => !isBox(name));
166
+ if (!names.length) return;
167
+ return Object.fromEntries(names.map((name) => [name, style[name]]));
168
+ };
169
+
170
+ /** @type {(style: any) => string[]} */
171
+ let decoLine = (style) => {
172
+ let parts = [];
173
+ if (style.underline === true) parts.push("underline");
174
+ if (style.strikethrough === true) parts.push("line-through");
175
+ return parts;
119
176
  };
120
177
 
121
178
  /** @type {(style: any) => string} */
122
179
  let decoration = (style) => {
123
- let parts = [];
124
- if (style.underline) parts.push("underline");
125
- if (style.strikethrough) parts.push("line-through");
126
- return parts.length ? "text-decoration:" + parts.join(" ") : "";
180
+ let parts = decoLine(style);
181
+ if (parts.length) return "text-decoration:" + parts.join(" ");
182
+ return style.underline === false || style.strikethrough === false ? "text-decoration:none" : "";
127
183
  };
128
184
 
129
185
  /** @type {(name: string) => boolean} */
@@ -144,17 +200,40 @@ let pushDecl = (out, name, value, map) => {
144
200
  if (declaration) out.push(declaration);
145
201
  };
146
202
 
203
+ /** @type {(out: string[], style: any, seen: { box: boolean, sizing: boolean }) => void} */
204
+ let pushBox = (out, style, seen) => {
205
+ if (seen.box) return;
206
+ seen.box = true;
207
+ let start = out.length;
208
+ for (let side of SIDES) out.push(...borderSide(style, side));
209
+ if (out.length > start) seen.sizing = true;
210
+ };
211
+
212
+ /** @type {(seen: { sizing: boolean }, name: string, added: boolean) => void} */
213
+ let notePad = (seen, name, added) => {
214
+ if (added && name.startsWith("padding")) seen.sizing = true;
215
+ };
216
+
217
+ /** @type {(out: string[], name: string, style: any, seen: { decoration: boolean, box: boolean, sizing: boolean }, map: Record<string, string>) => void} */
218
+ let emitDecl = (out, name, style, seen, map) => {
219
+ if (isDecoration(name)) return pushDecoration(out, style, seen);
220
+ if (name.startsWith("border")) return pushBox(out, style, seen);
221
+ let start = out.length;
222
+ pushDecl(out, name, style[name], map);
223
+ notePad(seen, name, out.length > start);
224
+ };
225
+
147
226
  // Serialize a resolved style object to inline CSS, in declaration order.
148
227
  // underline and strikethrough share one CSS property, so the first of either
149
- // in authored order emits the composed value and the second is skipped.
228
+ // in authored order emits the composed value and the second is skipped. A
229
+ // border side emits only when width, style and colour all resolve, so a
230
+ // missing name never becomes a browser-default solid black stroke.
150
231
  /** @type {(style: any, map: Record<string, string>) => string} */
151
232
  let css = (style, map) => {
152
233
  let out = /** @type {string[]} */ ([]);
153
- let seen = { decoration: false };
154
- for (let name in style) {
155
- if (isDecoration(name)) pushDecoration(out, style, seen);
156
- else pushDecl(out, name, style[name], map);
157
- }
234
+ let seen = { decoration: false, box: false, sizing: false };
235
+ for (let name in style) emitDecl(out, name, style, seen, map);
236
+ if (seen.sizing) out.push("box-sizing:border-box");
158
237
  return out.join(";");
159
238
  };
160
239
 
@@ -189,10 +268,13 @@ let pathAttr = ({ path }) => (path ? ' data-q-path="' + esc(path) + '"' : "");
189
268
  /** @type {() => string} */
190
269
  let noAttr = () => "";
191
270
 
192
- /** @type {(list: any[], tag: string, attrs: (event: any) => string) => string} */
193
- let cells = (list, tag, attrs) =>
271
+ /** @type {(list: any[], tag: string, attrs: (event: any) => string, intl: any) => string} */
272
+ let cells = (list, tag, attrs, intl) =>
194
273
  list
195
- .map((cell) => "<" + tag + attrs(cell) + ">" + markup(cell.tokens) + "</" + tag + ">")
274
+ .map(
275
+ (cell) =>
276
+ "<" + tag + attrs(cell) + ">" + markup(cell.tokens, cell.style, intl) + "</" + tag + ">",
277
+ )
196
278
  .join("");
197
279
 
198
280
  /**
@@ -240,7 +322,7 @@ export function html(options) {
240
322
  // goes with the render — nothing here outlives it.
241
323
  /** @type {Map<Uint8Array, string>} */
242
324
  let sources = new Map();
243
- // Whether a `total-row` already closed the `<tbody>` — markup
325
+ // Whether a `total-row` already opened `<tfoot>` — markup
244
326
  // bookkeeping that stays at this edge, out of the event contract.
245
327
  let tfoot = false;
246
328
  // Whether `report-start` opened the report root. A stream may start
@@ -264,6 +346,8 @@ export function html(options) {
264
346
  // enough. Null whenever the walk is not inside a bracket.
265
347
  /** @type {{ slots: any[], at: number } | null} */
266
348
  let split = null;
349
+ /** @type {{ locale?: string, currency?: string, timeZone?: string } | null} */
350
+ let intl = null;
267
351
  // Place one item's markup: inside a bracket it goes in the next slot,
268
352
  // and everywhere else it is a band item like any other.
269
353
  /** @type {(event: any, fragment: string) => void} */
@@ -317,7 +401,10 @@ export function html(options) {
317
401
  // edge, so the badge stays childless whatever stream a host compiles
318
402
  // against.
319
403
  "report-start": (event) => {
404
+ intl = { locale: event.locale, currency: event.currency, timeZone: event.timeZone };
320
405
  if (event.marking) out += '<div class="q-unlicensed">' + esc(event.marking) + "</div>";
406
+ if (event.margin != null)
407
+ out += "<style>@page{margin:" + esc(event.margin) + "pt}</style>";
321
408
  // The report root. Always emitted, because the fragment otherwise
322
409
  // has no element for a document-wide declaration to be inherited
323
410
  // from, and the shipped stylesheet's own baseline now hangs off it.
@@ -339,7 +426,7 @@ export function html(options) {
339
426
  styleAttr(event) +
340
427
  mark(event) +
341
428
  ">" +
342
- markup(event.tokens) +
429
+ markup(event.tokens, event.style, intl) +
343
430
  "</div>",
344
431
  );
345
432
  },
@@ -438,19 +525,34 @@ export function html(options) {
438
525
  "<colgroup>" +
439
526
  cols +
440
527
  "</colgroup>" +
441
- "<thead><tr>" +
442
- cells(headers, "th", attrs) +
528
+ "<thead><tr" +
529
+ styleAttr({ style: unbox(event.style) }) +
530
+ ">" +
531
+ cells(headers, "th", attrs, intl) +
443
532
  "</tr></thead><tbody>";
444
533
  },
445
534
  row: (event) => {
446
- out += "<tr" + styleAttr(event) + ">" + cells(event.cells, "td", attrs) + "</tr>";
535
+ out +=
536
+ "<tr" +
537
+ styleAttr({ style: unbox(event.style) }) +
538
+ ">" +
539
+ cells(event.cells, "td", attrs, intl) +
540
+ "</tr>";
447
541
  },
448
542
  "total-row": (event) => {
449
- tfoot = true;
450
- out += "</tbody><tfoot><tr>" + cells(event.cells, "td", attrs) + "</tr></tfoot>";
543
+ if (!tfoot) {
544
+ out += "</tbody><tfoot>";
545
+ tfoot = true;
546
+ }
547
+ out +=
548
+ "<tr" +
549
+ styleAttr({ style: unbox(event.style) }) +
550
+ ">" +
551
+ cells(event.cells, "td", attrs, intl) +
552
+ "</tr>";
451
553
  },
452
554
  "table-end": () => {
453
- out += tfoot ? "</table>" : "</tbody></table>";
555
+ out += tfoot ? "</tfoot></table>" : "</tbody></table>";
454
556
  },
455
557
  });
456
558
  // A root region reaches here open when no full-width footer followed the
package/lib/style.css CHANGED
@@ -36,15 +36,31 @@
36
36
  font-family: sans-serif;
37
37
  }
38
38
 
39
+ .q-report,
40
+ .q-group {
41
+ display: flex;
42
+ flex-direction: column;
43
+ }
44
+
39
45
  .q-table {
40
46
  width: 100%;
41
47
  border-collapse: collapse;
42
48
  }
43
49
 
50
+ /* A visible text item occupies a line, and a literal newline is a break.
51
+ The class is the hook; a host overrides these the ordinary way, as with
52
+ .q-break. Empty table cells stay contentless, so they take only the
53
+ newline mapping. */
54
+ .q-item {
55
+ min-height: 1lh;
56
+ white-space: pre-line;
57
+ }
58
+
44
59
  .q-table th,
45
60
  .q-table td {
46
61
  padding: 2pt 6pt;
47
62
  text-align: left;
63
+ white-space: pre-line;
48
64
  }
49
65
 
50
66
  .q-table thead th {
@@ -95,6 +111,10 @@
95
111
  * Pages are not the only fragmentation context: the columns case lives in the
96
112
  * shared block above, because a sheet with no pages can still have columns.
97
113
  *
114
+ * Occupy and newline-as-break live in the shared block above: a screen needs
115
+ * them, and they follow the same pattern as `.q-break` -- class is the hook,
116
+ * the rule is overridable.
117
+ *
98
118
  * A host overrides any of these the ordinary way; `example/print.css` turns
99
119
  * `break-inside` back to `auto` for one group to let it span pages.
100
120
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quario/html",
3
- "version": "0.2.0",
3
+ "version": "0.3.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,17 +39,17 @@
39
39
  "devDependencies": {
40
40
  "@arethetypeswrong/cli": "^0.18.3",
41
41
  "@size-limit/preset-small-lib": "^13.0.3",
42
- "quario": "^0.2.0",
42
+ "quario": "^0.3.0",
43
43
  "size-limit": "^13.0.3",
44
44
  "typescript": "^7.0.2"
45
45
  },
46
46
  "peerDependencies": {
47
- "quario": "^0.2.0"
47
+ "quario": "^0.3.0"
48
48
  },
49
49
  "size-limit": [
50
50
  {
51
51
  "path": "lib/index.js",
52
- "limit": "2 kB",
52
+ "limit": "2.5 kB",
53
53
  "ignore": [
54
54
  "quario"
55
55
  ]