@quario/html 0.10.0 → 0.11.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
@@ -1,5 +1,21 @@
1
1
  # @quario/html
2
2
 
3
+ ## 0.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - **A `capabilities` descriptor.** The target exports what it makes of each declaration the spec's support matrix names — `resolved`, `approximated`, `withdrawn` or `unread` — beside its factory, so a host can ask before it renders rather than reading the prose. It is keyed by the schema name an author writes (`valign`, `break`, `page.margin`, and `column.width` / `slot.width` where one word carries two declarations), hand-written and gated against that matrix. It carries no version of its own: the package that ships it is the version.
8
+ - **`negative` and `zero` reach the markup edge.** The target stringifies through the engine's `format()`, so a cell declaring either presents there too: a negative in parentheses, a zero as a dash.
9
+ - **A collapsed group does not draw its content.** An instance whose group declares `collapsed` renders as its header and footer, with the rows between them left out — a page has nowhere to put a row a reader could open. Aggregates are unchanged, so a footer still totals them.
10
+ - **A run that links becomes an anchor.** A run carrying `href` is wrapped in an `<a>` inside its own span, so the style the run declared paints the link, and the URL is escaped at the one markup edge like every other interpolated value. A real anchor rather than a styled span, because keyboard reachability comes with it. The engine admitted the URL before it crossed, so this target decides nothing about it.
11
+ - **The report header scales with the size it sits in.** The shipped stylesheet's `.q-item.q-report-header` rule is `font-size: 1.4em` rather than `14pt`. A document declaring `"size": 20` now renders its report header at 28pt where it rendered at 14pt, smaller than its own body. This is visible to every host page: an unstyled report's header is now 1.4× the host's own body type rather than a fixed 14pt, so a host whose body is not 10pt sees the headline move. A host rule on the class still wins, as before.
12
+
13
+ ### Patch Changes
14
+
15
+ - **A cell's own `href` now links its text.** `href` sits on a cell as well as in a run's inline subset, but this target read it only off a run that declared a style of its own, so `{ style: { href } }` on the cell rendered the text with no anchor at all. It now asks the style the run wears rather than the one it declares, which is the cell's wherever the run is silent. A run declaring its own `href` still replaces the cell's rather than nesting inside it.
16
+ - Updated dependencies
17
+ - quario@0.11.0
18
+
3
19
  ## 0.10.0
4
20
 
5
21
  ### Minor Changes
package/README.md CHANGED
@@ -121,6 +121,7 @@ change.
121
121
  | Emits | For |
122
122
  | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
123
123
  | `<div class="q-report">` | The fragment's root, holding every band |
124
+ | `<a href>` | A styled run that declares `href`, inside that run's own span. The URL is escaped like every other interpolated value, and the engine admitted it against the host's scheme allowlist before this target saw it |
124
125
  | `<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` |
125
126
  | `<div class="q-group" data-group="name">` | Each group instance, wrapping its header, nested content, and footer. Adds `q-break` where the group's `break` turns a page before the instance, and `q-break-after` where one turns after it |
126
127
  | `<table class="q-table">` | Each table, with a real `colgroup`, `thead`, `tbody`, and a `tfoot` around the total rows the walk emits. No `tfoot` when `total` is absent or every row of it is hidden. A spanning cell carries `colspan` |
package/lib/index.d.ts CHANGED
@@ -29,3 +29,27 @@ export interface HtmlOptions {
29
29
  * described in SCHEMA.md ("The HTML target").
30
30
  */
31
31
  export function html(options?: HtmlOptions): Target<"html", Promise<string>>;
32
+
33
+ /** What a target makes of a declaration: CONTEXT.md's fate vocabulary. */
34
+ export type Fate = "resolved" | "approximated" | "withdrawn" | "unread";
35
+
36
+ /**
37
+ * What this target makes of each declaration SCHEMA.md's support matrix
38
+ * names. Hand-written and gated against that matrix rather than generated
39
+ * from it. It carries no version of its own: the package that ships it is the
40
+ * version.
41
+ *
42
+ * Keyed by the schema name an author writes — `valign`, `break`, `page.margin`
43
+ * — so a host, a `required` marking and a problem message share one
44
+ * vocabulary. Two matrix rows share the bare word `width`, so each is
45
+ * qualified by what carries it: `column.width` and `slot.width`.
46
+ *
47
+ * It covers the declarations every official target states one fate for. An
48
+ * absent key means the matrix states no single fate for that row — a row that
49
+ * summarises more than one declaration, or that describes a behaviour — never
50
+ * that this target has no answer.
51
+ */
52
+ export const capabilities: {
53
+ readonly target: "html";
54
+ readonly declarations: Readonly<Record<string, Fate>>;
55
+ };
package/lib/index.js CHANGED
@@ -9,7 +9,17 @@
9
9
  * never carries markup-escaped text.
10
10
  */
11
11
 
12
- import { display, format, hostOptions, isReportBand, splits, styledRuns, text, walk } from "quario";
12
+ import {
13
+ collapse,
14
+ display,
15
+ format,
16
+ hostOptions,
17
+ isReportBand,
18
+ splits,
19
+ styledRuns,
20
+ text,
21
+ walk,
22
+ } from "quario";
13
23
 
14
24
  /** @type {Record<string, string>} */
15
25
  let ESC = {
@@ -43,19 +53,33 @@ let shown = (value, style, intl) => {
43
53
  // not a difference against the cell's -- the engine already composed the two.
44
54
  // An unstyled run stays bare.
45
55
  /** @type {(styled: { style: any, tokens: any[] }, style: any, intl: any, map: any) => string} */
46
- let piece = (styled, style, intl, map) => {
47
- let body = joined(styled.tokens, styled.style ?? style, intl);
56
+ let styledRun = (styled, style, intl, map) => {
57
+ let worn = styled.style ?? style;
58
+ let body = linked(joined(styled.tokens, worn, intl), worn);
48
59
  if (!styled.style) return body;
49
60
  let inline = css(styled.style, map);
50
61
  return inline ? '<span style="' + esc(inline) + '">' + body + "</span>" : body;
51
62
  };
52
63
 
64
+ // A run that carries an `href` is wrapped in an anchor, inside its own span so
65
+ // the style the run declared paints the link as well. The style asked is the
66
+ // one the run wears rather than the one it declares, because `href` sits on a
67
+ // cell as well as on a run (SCHEMA.md, "Style declarations") and a cell's own
68
+ // links every run that declared no other. The engine admitted the
69
+ // URL against the host's scheme allowlist before it crossed (docs/adr/0085);
70
+ // this target escapes it like every other interpolated value and decides
71
+ // nothing about it. An `<a>` is keyboard-reachable for nothing, which is why
72
+ // this is a real anchor rather than a styled span with a click handler.
73
+ /** @type {(body: string, style: any) => string} */
74
+ let linked = (body, style) =>
75
+ style?.href ? '<a href="' + esc(style.href) + '">' + body + "</a>" : body;
76
+
53
77
  // A cell's markup: its styled runs in order. The grouping is the engine's
54
78
  // `styledRuns`, so this target cannot drift from the others.
55
79
  /** @type {(map: any) => (tokens: any[], style?: any, intl?: any) => string} */
56
80
  let cellMarkup = (map) => (tokens, style, intl) => {
57
81
  let out = "";
58
- for (let styled of styledRuns(tokens)) out += piece(styled, style, intl, map);
82
+ for (let styled of styledRuns(tokens)) out += styledRun(styled, style, intl, map);
59
83
  return out;
60
84
  };
61
85
 
@@ -86,14 +110,12 @@ let NAMEABLE = /^[A-Za-z0-9 _-]+$/;
86
110
  let UNSAFE = /[;}]/;
87
111
  /** @type {(value: any) => boolean} */
88
112
  let isHex = (value) => typeof value === "string" && HEX.test(value);
89
- let ALIGNMENTS = ["left", "center", "right"];
90
113
  // A slot item's `valign` and `align` as grid placement (see `placed`); the
91
114
  // vertical vocabulary is these keys, so the three readers of it agree.
92
115
  /** @type {Record<string, string>} */
93
116
  let ALIGN_CONTENT = { top: "start", middle: "center", bottom: "end" };
94
117
  /** @type {Record<string, string>} */
95
118
  let JUSTIFY_ITEMS = { left: "start", center: "center", right: "end" };
96
- let VALIGNMENTS = Object.keys(ALIGN_CONTENT);
97
119
 
98
120
  // A group instance's own bands. They sit outside a columned container only when
99
121
  // that instance is the node which declared the count — an inner instance's are
@@ -149,27 +171,29 @@ let named = (map, value) => {
149
171
  if (Object.hasOwn(map, key)) return map[key];
150
172
  return NAMEABLE.test(value) ? "'" + value + "'" : "";
151
173
  };
152
- // Exact true/false only: a computed value of the wrong shape contributes
153
- // nothing, same as omit (SCHEMA.md, Style declarations).
174
+ // A declared flag is `true` or `false`: the engine drops every other shape
175
+ // before a target sees it (SCHEMA.md, "Style declarations"), so a target maps
176
+ // the vocabulary rather than checking it a second time.
154
177
  /** @type {(on: string, off: string) => (value: any) => string} */
155
- let flag = (on, off) => (value) => (value === true ? on : value === false ? off : "");
178
+ let flag = (on, off) => (value) => (value ? on : off);
156
179
  /** @type {Record<string, (value: any, map: Record<string, string>) => string>} */
157
180
  let CSS = {
181
+ // A family is the one declaration whose value a host may name, so the map
182
+ // may still answer with nothing; the value itself is a non-empty string.
158
183
  family: (value, map) => {
159
- if (typeof value !== "string" || !value) return "";
160
184
  let name = named(map, value);
161
185
  return name ? "font-family:" + name : "";
162
186
  },
163
- size: (value) => (Number.isFinite(value) && value > 0 ? "font-size:" + value + "pt" : ""),
187
+ size: (value) => "font-size:" + value + "pt",
164
188
  bold: flag("font-weight:bold", "font-weight:normal"),
165
189
  italic: flag("font-style:italic", "font-style:normal"),
166
- color: (value) => (isHex(value) ? "color:" + value : ""),
167
- background: (value) => (isHex(value) ? "background-color:" + value : ""),
168
- align: (value) => (ALIGNMENTS.includes(value) ? "text-align:" + value : ""),
190
+ color: (value) => "color:" + value,
191
+ background: (value) => "background-color:" + value,
192
+ align: (value) => "text-align:" + value,
169
193
  // The table reading, on a `<tr>` or a cell: the browser's own sheet gives
170
194
  // cells `vertical-align: inherit`, so a row's reaches them like `bold` does.
171
195
  // `placed` below lifts the name out before this map sees a slot item's.
172
- valign: (value) => (VALIGNMENTS.includes(value) ? "vertical-align:" + value : ""),
196
+ valign: (value) => "vertical-align:" + value,
173
197
  // The text itself is untouched, so selection and screen readers get what
174
198
  // the author wrote; only its rendering is capitalised.
175
199
  uppercase: flag("text-transform:uppercase", "text-transform:none"),
@@ -177,14 +201,12 @@ let CSS = {
177
201
 
178
202
  let SIDES = ["Top", "Right", "Bottom", "Left"];
179
203
  let LINES = ["solid", "dashed", "dotted"];
180
- /** @type {(value: any) => boolean} */
181
- let isPad = (value) => Number.isFinite(value) && value >= 0;
182
204
  for (let side of SIDES) {
183
205
  let edge = side.toLowerCase();
184
- CSS["padding" + side] = (value) => (isPad(value) ? "padding-" + edge + ":" + value + "pt" : "");
206
+ CSS["padding" + side] = (value) => "padding-" + edge + ":" + value + "pt";
185
207
  }
186
- CSS.spaceBefore = (value) => (isPad(value) ? "margin-top:" + value + "pt" : "");
187
- CSS.spaceAfter = (value) => (isPad(value) ? "margin-bottom:" + value + "pt" : "");
208
+ CSS.spaceBefore = (value) => "margin-top:" + value + "pt";
209
+ CSS.spaceAfter = (value) => "margin-bottom:" + value + "pt";
188
210
 
189
211
  /** @type {(width: any) => boolean} */
190
212
  let isStroke = (width) => Number.isFinite(width) && width > 0;
@@ -211,11 +233,12 @@ let decoLine = (style) => {
211
233
  return parts;
212
234
  };
213
235
 
236
+ // Only called where one of the two names is declared, so a style that draws
237
+ // neither line is a style that turned one off.
214
238
  /** @type {(style: any) => string} */
215
239
  let decoration = (style) => {
216
240
  let parts = decoLine(style);
217
- if (parts.length) return "text-decoration:" + parts.join(" ");
218
- return style.underline === false || style.strikethrough === false ? "text-decoration:none" : "";
241
+ return parts.length ? "text-decoration:" + parts.join(" ") : "text-decoration:none";
219
242
  };
220
243
 
221
244
  /** @type {(name: string) => boolean} */
@@ -494,7 +517,7 @@ export function html(options) {
494
517
  };
495
518
  // Hidden items never reach the stream and a hidden cell arrives with no
496
519
  // tokens, so no visibility checks are needed in any handler below.
497
- await walk(splits(stream(data)), {
520
+ await walk(collapse(splits(stream(data))), {
498
521
  // Nothing unless the render is unlicensed — then the fragment opens
499
522
  // with the marking badge: the engine's wording in this target's
500
523
  // element, escaped, so the badge stays childless whatever stream a
@@ -629,3 +652,53 @@ export function html(options) {
629
652
  },
630
653
  };
631
654
  }
655
+
656
+ /**
657
+ * What this target makes of each declaration the support matrix names: one of
658
+ * the [fates](../../../CONTEXT.md#declaration-and-resolution) — `resolved`,
659
+ * `approximated`, `withdrawn` or `unread` — so a host can ask before it
660
+ * renders rather than reading the prose.
661
+ *
662
+ * **Hand-written, and gated rather than generated.** SCHEMA.md's matrix is the
663
+ * description a person maintains and this is the data a host reads; the repo's
664
+ * `test/capabilities.test.js` holds the two equal, which is the same bargain
665
+ * ADR 0014 makes for the band-role table. It carries no version of its own:
666
+ * the package that ships it is the version, and a second copy could only drift
667
+ * from it.
668
+ *
669
+ * Keyed by the **schema name** an author writes, so a host, a `required`
670
+ * marking and a problem message all index by one vocabulary. Two rows share
671
+ * the bare word `width`, so each is qualified by what carries it.
672
+ *
673
+ * It covers the declarations every official target states one fate for. A
674
+ * matrix row that summarises more than one declaration, or that describes a
675
+ * behaviour rather than a declaration, states no single fate and is absent
676
+ * here — an absent key means the spec does not answer, never that this target
677
+ * has no answer.
678
+ */
679
+ export const capabilities = Object.freeze({
680
+ target: "html",
681
+ declarations: Object.freeze({
682
+ "page.header": "unread",
683
+ "page.footer": "unread",
684
+ break: "approximated",
685
+ reset: "withdrawn",
686
+ "page.margin": "resolved",
687
+ "header.height": "withdrawn",
688
+ groups: "resolved",
689
+ label: "withdrawn",
690
+ collapsed: "resolved",
691
+ "column.width": "approximated",
692
+ "slot.width": "resolved",
693
+ span: "resolved",
694
+ uppercase: "resolved",
695
+ href: "resolved",
696
+ spaceBefore: "resolved",
697
+ spaceAfter: "resolved",
698
+ format: "resolved",
699
+ family: "resolved",
700
+ valign: "resolved",
701
+ runs: "resolved",
702
+ alt: "resolved",
703
+ }),
704
+ });
package/lib/style.css CHANGED
@@ -73,8 +73,10 @@
73
73
  white-space: pre-line;
74
74
  }
75
75
 
76
+ /* 1.4x the size it sits in, so a report default scales its headline with
77
+ its body. Unstyled, that is the host page's own body size. */
76
78
  .q-item.q-report-header {
77
- font-size: 14pt;
79
+ font-size: 1.4em;
78
80
  font-weight: bold;
79
81
  }
80
82
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quario/html",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Tiny, escape-by-default HTML render target for quario. Semantic tables and a stable class contract.",
5
5
  "keywords": [
6
6
  "csp",
@@ -34,24 +34,25 @@
34
34
  "access": "public"
35
35
  },
36
36
  "scripts": {
37
- "check": "npm run size && npm test && npm run test:browser",
37
+ "check": "npm run size && npm test",
38
+ "coverage:check": "c8 report --src lib/ --temp-directory=../../coverage/tmp --reporter=text --check-coverage --100",
38
39
  "size": "size-limit",
39
- "test": "npm run test:unit && npm run test:types",
40
+ "test": "npm run test:unit && npm run test:browser && npm run test:types && npm run coverage:check",
40
41
  "test:browser": "node test/browser/setup.js",
41
42
  "test:types": "tsc && attw --pack . --profile esm-only --exclude-entrypoints style.css",
42
- "test:unit": "node --disallow-code-generation-from-strings --test --test-concurrency=1 test/*.test.js",
43
+ "test:unit": "c8 --clean=false --src lib/ --reporter=none --temp-directory=../../coverage/tmp node --disallow-code-generation-from-strings --test --test-concurrency=1 test/*.test.js",
43
44
  "prepack": "node -e \"require('fs').copyFileSync('../../LICENSE','LICENSE')\"",
44
45
  "postpack": "node -e \"require('fs').rmSync('LICENSE',{force:true})\""
45
46
  },
46
47
  "devDependencies": {
47
48
  "@arethetypeswrong/cli": "^0.18.3",
48
49
  "@size-limit/preset-small-lib": "^13.0.3",
49
- "quario": "^0.10.0",
50
+ "quario": "^0.11.0",
50
51
  "size-limit": "^13.0.3",
51
52
  "typescript": "^7.0.2"
52
53
  },
53
54
  "peerDependencies": {
54
- "quario": "^0.10.0"
55
+ "quario": "^0.11.0"
55
56
  },
56
57
  "size-limit": [
57
58
  {