@cube-drone/marquee-html-renderer 0.9.2 → 0.9.3

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/README.md CHANGED
@@ -22,6 +22,23 @@ children. Pair the output with
22
22
  class contract this renderer targets) and optionally
23
23
  [`@cube-drone/marquee-fonts`](https://www.npmjs.com/package/@cube-drone/marquee-fonts).
24
24
 
25
+ `linkAllowed` decides *whether* a target links; the optional `linkTarget` decides *where* it
26
+ points — return another address (a note's chapter inside an ePub, a file inside an export) or
27
+ `null` to keep the target as written. It's asked only after `linkAllowed` says yes, for every
28
+ anchor drawn from an author target (links, turbolinks, an embed's fallback link), and the
29
+ visible text stays the author's. The React renderer honors it too.
30
+
31
+ For a page that must be well-formed XML — an ePub chapter, any XHTML document — pass
32
+ `{ output: "xhtml" }`: void elements close themselves (`<br/>`, `<img …/>`), boolean
33
+ attributes carry values (`controls="controls"`), the only entities are XML's own five, and
34
+ characters XML forbids become U+FFFD. Same elements, classes, and words. Hook output is placed
35
+ as given, so a profile used this way must return XHTML too.
36
+
37
+ ```ts
38
+ render(parse(source), bookProfile, { output: "xhtml" });
39
+ renderMarquee(source, bookProfile, { output: "xhtml" }); // same, from source
40
+ ```
41
+
25
42
  The renderer's obligations (never eat content, comments render nothing, unknown vocabulary
26
43
  shrugs, invalid constructs render inert placeholders, author bytes never become markup) are
27
44
  enforced by its behavioral test suite. You probably want
package/dist/index.d.ts CHANGED
@@ -1,8 +1,10 @@
1
+ import { type RenderOptions } from "./render.ts";
1
2
  import type { Profile } from "./profile.ts";
2
- export { render, escapeText, escapeAttr, FONTS, usedFontTokens, containerLook } from "./render.ts";
3
+ export { render, type RenderOptions, escapeText, escapeAttr, FONTS, usedFontTokens, containerLook } from "./render.ts";
3
4
  export { bareWebProfile } from "./profile.ts";
4
5
  export { EFFECT_NAMES, effectLook, splitPieces, type EffectLook, type EffectUnit, type SplitPiece } from "./effects.ts";
5
6
  export type { EmojiResolution, Profile, MediaResolution, TurbolinkLevel } from "./profile.ts";
6
7
  /** Parse and render in one step. Throws UnsupportedVersionError for unknown
7
- * dialect versions, exactly as the parser does. */
8
- export declare function renderMarquee(source: string, profile?: Profile): string;
8
+ * dialect versions, exactly as the parser does. `{ output: "xhtml" }` for a
9
+ * page that must be well-formed XML, like an ePub's. */
10
+ export declare function renderMarquee(source: string, profile?: Profile, options?: RenderOptions): string;
package/dist/index.js CHANGED
@@ -10,7 +10,8 @@ export { render, escapeText, escapeAttr, FONTS, usedFontTokens, containerLook }
10
10
  export { bareWebProfile } from "./profile.js";
11
11
  export { EFFECT_NAMES, effectLook, splitPieces } from "./effects.js";
12
12
  /** Parse and render in one step. Throws UnsupportedVersionError for unknown
13
- * dialect versions, exactly as the parser does. */
14
- export function renderMarquee(source, profile) {
15
- return render(parse(source), profile);
13
+ * dialect versions, exactly as the parser does. `{ output: "xhtml" }` for a
14
+ * page that must be well-formed XML, like an ePub's. */
15
+ export function renderMarquee(source, profile, options) {
16
+ return render(parse(source), profile, options);
16
17
  }
package/dist/profile.d.ts CHANGED
@@ -17,6 +17,14 @@ export interface Profile {
17
17
  /** May this target become a hyperlink? Disallowed links render their
18
18
  * children without an anchor (content survives, capability doesn't). */
19
19
  linkAllowed(target: string): boolean;
20
+ /** Where an allowed link points: null (or no method) keeps the target as
21
+ * written; a string is the address the anchor uses instead (a note's
22
+ * chapter inside an ePub, a file's path inside an export). Asked only after
23
+ * `linkAllowed` says yes - a refused link stays refused - and for every
24
+ * anchor the renderer draws from an author target: links, turbolinks, and
25
+ * the fallback link of an embed. The visible text stays the author's;
26
+ * `turbolink` and `media` still see the original target. */
27
+ linkTarget?(target: string): string | null;
20
28
  /** Resolve an embed target to a media kind, or null for the inert
21
29
  * fallback. The kind is resolved at render time (SPEC.md, "Media"). */
22
30
  media(target: string): MediaResolution | null;
@@ -48,6 +56,9 @@ export interface Profile {
48
56
  * - Place the children: a claiming answer MUST include `renderedChildren`
49
57
  * (already-escaped HTML) or a known substitute for them - the shrug's
50
58
  * never-eat-content rule binds embedder vocabulary too.
59
+ * - Match the output: a profile rendered with `{ output: "xhtml" }` must
60
+ * return well-formed XHTML (here and in `span` and `turbolink`) - the
61
+ * renderer places embedder HTML as given and can't rewrite it.
51
62
  * Embedder-first: a claim on a name the language defines wins. */
52
63
  directive(name: string, attrs: Attrs, renderedChildren: string): string | null;
53
64
  /** Embedder span vocabulary - the inline twin of `directive`
package/dist/render.d.ts CHANGED
@@ -1,6 +1,17 @@
1
1
  import type { Attrs, Node } from "@cube-drone/marquee-parser";
2
2
  import { type Profile } from "./profile.ts";
3
- export declare function render(node: Node, profile?: Profile): string;
3
+ export interface RenderOptions {
4
+ /** Which serialization to write. "html" (the default) is HTML5 for a web
5
+ * page: void elements unclosed (`<br>`), boolean attributes bare
6
+ * (`controls`). "xhtml" is well-formed XML for an ePub page or any XHTML
7
+ * document: void elements close themselves (`<br/>`), every attribute
8
+ * carries a quoted value, the only entities are XML's own, and characters
9
+ * XML forbids become U+FFFD. Same elements, same classes, same words.
10
+ * Embedder vocabulary is placed as given, so a profile used with "xhtml"
11
+ * must return XHTML too (see `Profile.directive`). */
12
+ output?: "html" | "xhtml";
13
+ }
14
+ export declare function render(node: Node, profile?: Profile, options?: RenderOptions): string;
4
15
  /** The font vocabulary (closed, two tiers): four standard stacks that need
5
16
  * no files, and the grab bag - SIL OFL faces the embedder serves itself
6
17
  * (never a third-party CDN: fonts are a tracking vector, care-modes apply).
package/dist/render.js CHANGED
@@ -5,8 +5,21 @@
5
5
  // nothing; invalid constructs render inert placeholders.
6
6
  import { bareWebProfile } from "./profile.js";
7
7
  import { effectLook, splitPieces } from "./effects.js";
8
- export function render(node, profile = bareWebProfile) {
9
- return renderNode(node, { profile, note: { n: 0, pending: [] } });
8
+ export function render(node, profile = bareWebProfile, options = {}) {
9
+ const xhtml = options.output === "xhtml";
10
+ const ctx = {
11
+ profile,
12
+ text: xhtml ? escapeTextXml : escapeText,
13
+ attr: xhtml ? escapeAttrXml : escapeAttr,
14
+ void: xhtml ? "/>" : ">",
15
+ flag: xhtml ? (name) => ` ${name}="${name}"` : (name) => ` ${name}`,
16
+ note: { n: 0, pending: [] },
17
+ };
18
+ return renderNode(node, ctx);
19
+ }
20
+ /** The href an allowed link wears: the profile's rewrite, or the target. */
21
+ function href(target, ctx) {
22
+ return ctx.attr(ctx.profile.linkTarget?.(target) ?? target);
10
23
  }
11
24
  /** Asides render just below the paragraph (or heading) that triggered
12
25
  * them - part of regular flow, no floats, no popups. */
@@ -35,8 +48,8 @@ function renderNode(node, ctx) {
35
48
  }
36
49
  case "code_block": {
37
50
  const lang = infoToken(node.info);
38
- const cls = lang === null ? "" : ` class="language-${escapeAttr(lang)}"`;
39
- const text = node.text === "" ? "" : `${escapeText(node.text)}\n`;
51
+ const cls = lang === null ? "" : ` class="language-${ctx.attr(lang)}"`;
52
+ const text = node.text === "" ? "" : `${ctx.text(node.text)}\n`;
40
53
  return `<pre class="mq-code"><code${cls}>${text}</code></pre>`;
41
54
  }
42
55
  case "blockquote":
@@ -48,15 +61,15 @@ function renderNode(node, ctx) {
48
61
  case "list_item":
49
62
  return `<li>${children(node.children, ctx)}</li>`;
50
63
  case "thematic_break":
51
- return "<hr>";
64
+ return `<hr${ctx.void}`;
52
65
  case "directive":
53
66
  return directive(node.name, node.attrs, node.children, ctx);
54
67
  case "invalid_directive":
55
- return `<div class="mq-invalid" data-reason="${escapeAttr(node.reason)}"></div>`;
68
+ return `<div class="mq-invalid" data-reason="${ctx.attr(node.reason)}"></div>`;
56
69
  case "comment":
57
70
  return ""; // the anti-shrug: correct rendering is absence
58
71
  case "text":
59
- return escapeText(node.value);
72
+ return ctx.text(node.value);
60
73
  case "emphasis":
61
74
  return `<em>${children(node.children, ctx)}</em>`;
62
75
  case "strong":
@@ -64,29 +77,29 @@ function renderNode(node, ctx) {
64
77
  case "strikethrough":
65
78
  return `<del>${children(node.children, ctx)}</del>`;
66
79
  case "code_span":
67
- return `<code>${escapeText(node.text)}</code>`;
80
+ return `<code>${ctx.text(node.text)}</code>`;
68
81
  case "link": {
69
82
  const inner = children(node.children, ctx);
70
83
  return ctx.profile.linkAllowed(node.target)
71
- ? `<a href="${escapeAttr(node.target)}">${inner}</a>`
84
+ ? `<a href="${href(node.target, ctx)}">${inner}</a>`
72
85
  : `<span class="mq-blocked">${inner}</span>`;
73
86
  }
74
87
  case "embed":
75
- return embed(node.target, node.alt, ctx.profile);
88
+ return embed(node.target, node.alt, ctx);
76
89
  case "turbolink":
77
- return turbolink(node.target, undefined, ctx.profile);
90
+ return turbolink(node.target, undefined, ctx);
78
91
  case "span":
79
92
  return span(node.name, node.attrs, node.children, ctx);
80
93
  case "emoji": {
81
94
  const resolved = ctx.profile.emoji(node.slug);
82
95
  if (resolved !== null && typeof resolved === "object") {
83
96
  const alt = resolved.alt ?? `:${node.slug}:`;
84
- return `<img class="mq-emoji" src="${escapeAttr(resolved.image)}" alt="${escapeAttr(alt)}" loading="lazy">`;
97
+ return `<img class="mq-emoji" src="${ctx.attr(resolved.image)}" alt="${ctx.attr(alt)}" loading="lazy"${ctx.void}`;
85
98
  }
86
- return escapeText(resolved ?? `:${node.slug}:`);
99
+ return ctx.text(resolved ?? `:${node.slug}:`);
87
100
  }
88
101
  case "hard_break":
89
- return "<br>";
102
+ return `<br${ctx.void}`;
90
103
  }
91
104
  }
92
105
  function children(nodes, ctx) {
@@ -212,32 +225,35 @@ function infoToken(info) {
212
225
  return /^[A-Za-z0-9_+.#-]{1,64}$/.test(first) ? first : null;
213
226
  }
214
227
  // -- constructs
215
- function embed(target, alt, profile) {
216
- const media = profile.media(target);
228
+ function embed(target, alt, ctx) {
229
+ const media = ctx.profile.media(target);
217
230
  if (media !== null) {
218
- const url = escapeAttr(media.url);
231
+ const url = ctx.attr(media.url);
232
+ const label = ctx.attr(alt);
233
+ const flag = ctx.flag;
219
234
  switch (media.kind) {
220
235
  case "image":
221
- return `<img class="mq-embed" src="${url}" alt="${escapeAttr(alt)}" loading="lazy">`;
236
+ return `<img class="mq-embed" src="${url}" alt="${label}" loading="lazy"${ctx.void}`;
222
237
  case "audio":
223
- return `<audio class="mq-embed" controls src="${url}" aria-label="${escapeAttr(alt)}"></audio>`;
238
+ return `<audio class="mq-embed"${flag("controls")} src="${url}" aria-label="${label}"></audio>`;
224
239
  case "video":
225
240
  return media.loop === true
226
- ? `<video class="mq-embed" autoplay loop muted playsinline src="${url}" aria-label="${escapeAttr(alt)}"></video>`
227
- : `<video class="mq-embed" controls src="${url}" aria-label="${escapeAttr(alt)}"></video>`;
241
+ ? `<video class="mq-embed"${flag("autoplay")}${flag("loop")}${flag("muted")}${flag("playsinline")} src="${url}" aria-label="${label}"></video>`
242
+ : `<video class="mq-embed"${flag("controls")} src="${url}" aria-label="${label}"></video>`;
228
243
  }
229
244
  }
230
245
  // The contractual shrug applied to media: degrade to a labeled link, or
231
246
  // to inert text when the scheme is out of policy.
232
- const label = escapeText(`[${alt === "" ? target : alt}]`);
233
- return profile.linkAllowed(target)
234
- ? `<a class="mq-embed-fallback" href="${escapeAttr(target)}">${label}</a>`
247
+ const label = ctx.text(`[${alt === "" ? target : alt}]`);
248
+ return ctx.profile.linkAllowed(target)
249
+ ? `<a class="mq-embed-fallback" href="${href(target, ctx)}">${label}</a>`
235
250
  : `<span class="mq-embed-fallback">${label}</span>`;
236
251
  }
237
252
  const TURBOLINK_LEVELS = new Set(["full", "title", "bare"]);
238
- function turbolink(target, levelAttr, profile) {
253
+ function turbolink(target, levelAttr, ctx) {
254
+ const { profile } = ctx;
239
255
  if (!profile.linkAllowed(target)) {
240
- return `<p class="mq-turbolink">${escapeText(target)}</p>`;
256
+ return `<p class="mq-turbolink">${ctx.text(target)}</p>`;
241
257
  }
242
258
  const level = levelAttr !== undefined && TURBOLINK_LEVELS.has(levelAttr)
243
259
  ? levelAttr
@@ -247,11 +263,11 @@ function turbolink(target, levelAttr, profile) {
247
263
  if (rich !== null) {
248
264
  // Enrichment augments, never replaces: the wrapper itself carries the
249
265
  // original link, so no plugin can eat it - not even by accident.
250
- return `<div class="mq-turbolink mq-turbolink-rich">${rich}<a class="mq-turbolink-source" href="${escapeAttr(target)}">${escapeText(target)}</a></div>`;
266
+ return `<div class="mq-turbolink mq-turbolink-rich">${rich}<a class="mq-turbolink-source" href="${href(target, ctx)}">${ctx.text(target)}</a></div>`;
251
267
  }
252
268
  }
253
269
  // The contractual floor: a plain link, always reachable.
254
- return `<p class="mq-turbolink"><a href="${escapeAttr(target)}">${escapeText(target)}</a></p>`;
270
+ return `<p class="mq-turbolink"><a href="${href(target, ctx)}">${ctx.text(target)}</a></p>`;
255
271
  }
256
272
  /** A resolved URL made safe for a CSS url("...") token: encodeURI handles
257
273
  * whitespace, controls, backslashes, and double quotes; quotes and parens
@@ -344,7 +360,7 @@ function directive(name, attrs, nodes, ctx) {
344
360
  case "turbolink": {
345
361
  const target = attrs["target"];
346
362
  if (target !== undefined) {
347
- return turbolink(target, attrs["level"], profile);
363
+ return turbolink(target, attrs["level"], ctx);
348
364
  }
349
365
  break; // malformed use: fall through to the placeholder
350
366
  }
@@ -397,10 +413,10 @@ function directive(name, attrs, nodes, ctx) {
397
413
  // differential-rendering hazard); role=base marks the common ancestor.
398
414
  const head = [];
399
415
  if (attrs["label"] !== undefined) {
400
- head.push(`<span class="mq-variant-label">${escapeText(attrs["label"])}</span>`);
416
+ head.push(`<span class="mq-variant-label">${ctx.text(attrs["label"])}</span>`);
401
417
  }
402
418
  if (attrs["when"] !== undefined) {
403
- head.push(`<span class="mq-variant-when">${escapeText(attrs["when"])}</span>`);
419
+ head.push(`<span class="mq-variant-when">${ctx.text(attrs["when"])}</span>`);
404
420
  }
405
421
  const cls = attrs["role"] === "base" ? "mq-variant mq-variant-base" : "mq-variant";
406
422
  const headHtml = head.length === 0 ? "" : `<div class="mq-variant-head">${head.join(" ")}</div>`;
@@ -411,8 +427,8 @@ function directive(name, attrs, nodes, ctx) {
411
427
  // that something wrapped them; a leaf renders the inert placeholder.
412
428
  // Never eat authored content.
413
429
  return nodes.length > 0
414
- ? `<div class="mq-unknown" data-directive="${escapeAttr(name)}">${inner}</div>`
415
- : `<div class="mq-placeholder" data-directive="${escapeAttr(name)}"></div>`;
430
+ ? `<div class="mq-unknown" data-directive="${ctx.attr(name)}">${inner}</div>`
431
+ : `<div class="mq-placeholder" data-directive="${ctx.attr(name)}"></div>`;
416
432
  }
417
433
  /** :::table (SPEC.md, "Tables"): each paragraph child is a row; a row's
418
434
  * cells are its top-level `[c]` spans, and loose inline content between
@@ -562,7 +578,7 @@ function span(name, attrs, nodes, ctx) {
562
578
  const value = attrs["font"];
563
579
  const face = value !== undefined ? FONTS[value] : undefined;
564
580
  if (face !== undefined) {
565
- return `<font class="mq-font-${value}" face="${escapeAttr(face)}">${inner}</font>`;
581
+ return `<font class="mq-font-${value}" face="${ctx.attr(face)}">${inner}</font>`;
566
582
  }
567
583
  return inner; // not on the list: the words survive in their own clothes
568
584
  }
@@ -600,8 +616,8 @@ function span(name, attrs, nodes, ctx) {
600
616
  // -- effects: the look (classes, knobs, per-unit split) comes from
601
617
  // effects.ts, shared with every other surface that draws effects.
602
618
  function renderEffect(name, look, nodes, inner, ctx) {
603
- const data = look.data.map(([k, v]) => ` data-${k}="${escapeAttr(v)}"`).join("");
604
- const style = look.vars.length === 0 ? "" : ` style="${look.vars.map(([k, v]) => `${k}:${escapeAttr(v)}`).join(";")}"`;
619
+ const data = look.data.map(([k, v]) => ` data-${k}="${ctx.attr(v)}"`).join("");
620
+ const style = look.vars.length === 0 ? "" : ` style="${look.vars.map(([k, v]) => `${k}:${ctx.attr(v)}`).join(";")}"`;
605
621
  if (name === "marquee") {
606
622
  return `<span class="${look.className}"${data}${style}><span class="mq-marquee-inner">${inner}</span></span>`;
607
623
  }
@@ -614,9 +630,9 @@ function renderEffect(name, look, nodes, inner, ctx) {
614
630
  function splitRender(nodes, ctx, look) {
615
631
  const pieces = (list) => list
616
632
  .map((p) => p.kind === "text"
617
- ? escapeText(p.text)
633
+ ? ctx.text(p.text)
618
634
  : p.kind === "unit"
619
- ? `<span class="mq-l" style="--mq-o:${p.offset}">${escapeText(p.text)}</span>`
635
+ ? `<span class="mq-l" style="--mq-o:${p.offset}">${ctx.text(p.text)}</span>`
620
636
  : `<span class="mq-w">${pieces(p.pieces)}</span>`)
621
637
  .join("");
622
638
  let out = "";
@@ -646,3 +662,21 @@ export function escapeText(s) {
646
662
  export function escapeAttr(s) {
647
663
  return escapeText(s).replaceAll('"', "&quot;");
648
664
  }
665
+ /** Characters XML 1.0 forbids outright - even as a numeric reference: the C0
666
+ * controls bar tab, newline, and return, the noncharacters U+FFFE/U+FFFF,
667
+ * and (a JS string can hold one) a lone surrogate. HTML parsers shrug these
668
+ * off; an XML parser refuses the whole page, so XHTML output carries U+FFFD
669
+ * in their place. */
670
+ const XML_FORBIDDEN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
671
+ function escapeTextXml(s) {
672
+ return escapeText(s).replace(XML_FORBIDDEN, "\uFFFD");
673
+ }
674
+ /** An XML parser normalizes whitespace in an attribute value to spaces, so a
675
+ * newline in alt text survives only as a character reference. */
676
+ function escapeAttrXml(s) {
677
+ return escapeTextXml(s)
678
+ .replaceAll('"', "&quot;")
679
+ .replaceAll("\t", "&#9;")
680
+ .replaceAll("\n", "&#10;")
681
+ .replaceAll("\r", "&#13;");
682
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cube-drone/marquee-html-renderer",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "Reference static HTML renderer for the Marquee markup language",
5
5
  "license": "MPL-2.0",
6
6
  "repository": {
@@ -35,13 +35,14 @@
35
35
  "prepack": "npm run build"
36
36
  },
37
37
  "dependencies": {
38
- "@cube-drone/marquee-parser": "^0.9.2"
38
+ "@cube-drone/marquee-parser": "^0.9.3"
39
39
  },
40
40
  "devDependencies": {
41
- "@cube-drone/marquee-css": "^0.9.2",
42
- "@cube-drone/marquee-fonts": "^0.9.2",
43
- "@cube-drone/marquee-turbolink": "^0.9.2",
41
+ "@cube-drone/marquee-css": "^0.9.3",
42
+ "@cube-drone/marquee-fonts": "^0.9.3",
43
+ "@cube-drone/marquee-turbolink": "^0.9.3",
44
44
  "@types/node": "^24.0.0",
45
+ "saxes": "^6.0.0",
45
46
  "typescript": "^5.9.0"
46
47
  }
47
48
  }
package/src/index.ts CHANGED
@@ -6,16 +6,17 @@
6
6
  // tap-to-skip) belong to the interactive renderer.
7
7
 
8
8
  import { parse } from "@cube-drone/marquee-parser";
9
- import { render } from "./render.ts";
9
+ import { render, type RenderOptions } from "./render.ts";
10
10
  import type { Profile } from "./profile.ts";
11
11
 
12
- export { render, escapeText, escapeAttr, FONTS, usedFontTokens, containerLook } from "./render.ts";
12
+ export { render, type RenderOptions, escapeText, escapeAttr, FONTS, usedFontTokens, containerLook } from "./render.ts";
13
13
  export { bareWebProfile } from "./profile.ts";
14
14
  export { EFFECT_NAMES, effectLook, splitPieces, type EffectLook, type EffectUnit, type SplitPiece } from "./effects.ts";
15
15
  export type { EmojiResolution, Profile, MediaResolution, TurbolinkLevel } from "./profile.ts";
16
16
 
17
17
  /** Parse and render in one step. Throws UnsupportedVersionError for unknown
18
- * dialect versions, exactly as the parser does. */
19
- export function renderMarquee(source: string, profile?: Profile): string {
20
- return render(parse(source), profile);
18
+ * dialect versions, exactly as the parser does. `{ output: "xhtml" }` for a
19
+ * page that must be well-formed XML, like an ePub's. */
20
+ export function renderMarquee(source: string, profile?: Profile, options?: RenderOptions): string {
21
+ return render(parse(source), profile, options);
21
22
  }
package/src/profile.ts CHANGED
@@ -23,6 +23,14 @@ export interface Profile {
23
23
  /** May this target become a hyperlink? Disallowed links render their
24
24
  * children without an anchor (content survives, capability doesn't). */
25
25
  linkAllowed(target: string): boolean;
26
+ /** Where an allowed link points: null (or no method) keeps the target as
27
+ * written; a string is the address the anchor uses instead (a note's
28
+ * chapter inside an ePub, a file's path inside an export). Asked only after
29
+ * `linkAllowed` says yes - a refused link stays refused - and for every
30
+ * anchor the renderer draws from an author target: links, turbolinks, and
31
+ * the fallback link of an embed. The visible text stays the author's;
32
+ * `turbolink` and `media` still see the original target. */
33
+ linkTarget?(target: string): string | null;
26
34
  /** Resolve an embed target to a media kind, or null for the inert
27
35
  * fallback. The kind is resolved at render time (SPEC.md, "Media"). */
28
36
  media(target: string): MediaResolution | null;
@@ -54,6 +62,9 @@ export interface Profile {
54
62
  * - Place the children: a claiming answer MUST include `renderedChildren`
55
63
  * (already-escaped HTML) or a known substitute for them - the shrug's
56
64
  * never-eat-content rule binds embedder vocabulary too.
65
+ * - Match the output: a profile rendered with `{ output: "xhtml" }` must
66
+ * return well-formed XHTML (here and in `span` and `turbolink`) - the
67
+ * renderer places embedder HTML as given and can't rewrite it.
57
68
  * Embedder-first: a claim on a name the language defines wins. */
58
69
  directive(name: string, attrs: Attrs, renderedChildren: string): string | null;
59
70
  /** Embedder span vocabulary - the inline twin of `directive`
package/src/render.ts CHANGED
@@ -8,16 +8,51 @@ import type { Attrs, Node } from "@cube-drone/marquee-parser";
8
8
  import { bareWebProfile, type Profile, type TurbolinkLevel } from "./profile.ts";
9
9
  import { effectLook, splitPieces, type EffectLook, type SplitPiece } from "./effects.ts";
10
10
 
11
- /** Render state: the profile, plus the one piece of cross-block
12
- * coordination the renderer owns - aside numbering (sequential through the
13
- * document) and the pending notes that flush after the triggering block. */
11
+ export interface RenderOptions {
12
+ /** Which serialization to write. "html" (the default) is HTML5 for a web
13
+ * page: void elements unclosed (`<br>`), boolean attributes bare
14
+ * (`controls`). "xhtml" is well-formed XML for an ePub page or any XHTML
15
+ * document: void elements close themselves (`<br/>`), every attribute
16
+ * carries a quoted value, the only entities are XML's own, and characters
17
+ * XML forbids become U+FFFD. Same elements, same classes, same words.
18
+ * Embedder vocabulary is placed as given, so a profile used with "xhtml"
19
+ * must return XHTML too (see `Profile.directive`). */
20
+ output?: "html" | "xhtml";
21
+ }
22
+
23
+ /** Render state: the profile and the output spelling, plus the one piece of
24
+ * cross-block coordination the renderer owns - aside numbering (sequential
25
+ * through the document) and the pending notes that flush after the
26
+ * triggering block. */
14
27
  interface Ctx {
15
28
  profile: Profile;
29
+ /** Escape author text / an attribute value for the output spelling. */
30
+ text: (s: string) => string;
31
+ attr: (s: string) => string;
32
+ /** The end of a void element's start tag. */
33
+ void: string;
34
+ /** A boolean attribute with its leading space: bare in HTML, `name="name"`
35
+ * in XHTML (XML has no attribute without a value). */
36
+ flag: (name: string) => string;
16
37
  note: { n: number; pending: string[] };
17
38
  }
18
39
 
19
- export function render(node: Node, profile: Profile = bareWebProfile): string {
20
- return renderNode(node, { profile, note: { n: 0, pending: [] } });
40
+ export function render(node: Node, profile: Profile = bareWebProfile, options: RenderOptions = {}): string {
41
+ const xhtml = options.output === "xhtml";
42
+ const ctx: Ctx = {
43
+ profile,
44
+ text: xhtml ? escapeTextXml : escapeText,
45
+ attr: xhtml ? escapeAttrXml : escapeAttr,
46
+ void: xhtml ? "/>" : ">",
47
+ flag: xhtml ? (name) => ` ${name}="${name}"` : (name) => ` ${name}`,
48
+ note: { n: 0, pending: [] },
49
+ };
50
+ return renderNode(node, ctx);
51
+ }
52
+
53
+ /** The href an allowed link wears: the profile's rewrite, or the target. */
54
+ function href(target: string, ctx: Ctx): string {
55
+ return ctx.attr(ctx.profile.linkTarget?.(target) ?? target);
21
56
  }
22
57
 
23
58
  /** Asides render just below the paragraph (or heading) that triggered
@@ -49,8 +84,8 @@ function renderNode(node: Node, ctx: Ctx): string {
49
84
  }
50
85
  case "code_block": {
51
86
  const lang = infoToken(node.info);
52
- const cls = lang === null ? "" : ` class="language-${escapeAttr(lang)}"`;
53
- const text = node.text === "" ? "" : `${escapeText(node.text)}\n`;
87
+ const cls = lang === null ? "" : ` class="language-${ctx.attr(lang)}"`;
88
+ const text = node.text === "" ? "" : `${ctx.text(node.text)}\n`;
54
89
  return `<pre class="mq-code"><code${cls}>${text}</code></pre>`;
55
90
  }
56
91
  case "blockquote":
@@ -62,15 +97,15 @@ function renderNode(node: Node, ctx: Ctx): string {
62
97
  case "list_item":
63
98
  return `<li>${children(node.children, ctx)}</li>`;
64
99
  case "thematic_break":
65
- return "<hr>";
100
+ return `<hr${ctx.void}`;
66
101
  case "directive":
67
102
  return directive(node.name, node.attrs, node.children, ctx);
68
103
  case "invalid_directive":
69
- return `<div class="mq-invalid" data-reason="${escapeAttr(node.reason)}"></div>`;
104
+ return `<div class="mq-invalid" data-reason="${ctx.attr(node.reason)}"></div>`;
70
105
  case "comment":
71
106
  return ""; // the anti-shrug: correct rendering is absence
72
107
  case "text":
73
- return escapeText(node.value);
108
+ return ctx.text(node.value);
74
109
  case "emphasis":
75
110
  return `<em>${children(node.children, ctx)}</em>`;
76
111
  case "strong":
@@ -78,29 +113,29 @@ function renderNode(node: Node, ctx: Ctx): string {
78
113
  case "strikethrough":
79
114
  return `<del>${children(node.children, ctx)}</del>`;
80
115
  case "code_span":
81
- return `<code>${escapeText(node.text)}</code>`;
116
+ return `<code>${ctx.text(node.text)}</code>`;
82
117
  case "link": {
83
118
  const inner = children(node.children, ctx);
84
119
  return ctx.profile.linkAllowed(node.target)
85
- ? `<a href="${escapeAttr(node.target)}">${inner}</a>`
120
+ ? `<a href="${href(node.target, ctx)}">${inner}</a>`
86
121
  : `<span class="mq-blocked">${inner}</span>`;
87
122
  }
88
123
  case "embed":
89
- return embed(node.target, node.alt, ctx.profile);
124
+ return embed(node.target, node.alt, ctx);
90
125
  case "turbolink":
91
- return turbolink(node.target, undefined, ctx.profile);
126
+ return turbolink(node.target, undefined, ctx);
92
127
  case "span":
93
128
  return span(node.name, node.attrs, node.children, ctx);
94
129
  case "emoji": {
95
130
  const resolved = ctx.profile.emoji(node.slug);
96
131
  if (resolved !== null && typeof resolved === "object") {
97
132
  const alt = resolved.alt ?? `:${node.slug}:`;
98
- return `<img class="mq-emoji" src="${escapeAttr(resolved.image)}" alt="${escapeAttr(alt)}" loading="lazy">`;
133
+ return `<img class="mq-emoji" src="${ctx.attr(resolved.image)}" alt="${ctx.attr(alt)}" loading="lazy"${ctx.void}`;
99
134
  }
100
- return escapeText(resolved ?? `:${node.slug}:`);
135
+ return ctx.text(resolved ?? `:${node.slug}:`);
101
136
  }
102
137
  case "hard_break":
103
- return "<br>";
138
+ return `<br${ctx.void}`;
104
139
  }
105
140
  }
106
141
 
@@ -240,34 +275,37 @@ function infoToken(info: string | undefined): string | null {
240
275
 
241
276
  // -- constructs
242
277
 
243
- function embed(target: string, alt: string, profile: Profile): string {
244
- const media = profile.media(target);
278
+ function embed(target: string, alt: string, ctx: Ctx): string {
279
+ const media = ctx.profile.media(target);
245
280
  if (media !== null) {
246
- const url = escapeAttr(media.url);
281
+ const url = ctx.attr(media.url);
282
+ const label = ctx.attr(alt);
283
+ const flag = ctx.flag;
247
284
  switch (media.kind) {
248
285
  case "image":
249
- return `<img class="mq-embed" src="${url}" alt="${escapeAttr(alt)}" loading="lazy">`;
286
+ return `<img class="mq-embed" src="${url}" alt="${label}" loading="lazy"${ctx.void}`;
250
287
  case "audio":
251
- return `<audio class="mq-embed" controls src="${url}" aria-label="${escapeAttr(alt)}"></audio>`;
288
+ return `<audio class="mq-embed"${flag("controls")} src="${url}" aria-label="${label}"></audio>`;
252
289
  case "video":
253
290
  return media.loop === true
254
- ? `<video class="mq-embed" autoplay loop muted playsinline src="${url}" aria-label="${escapeAttr(alt)}"></video>`
255
- : `<video class="mq-embed" controls src="${url}" aria-label="${escapeAttr(alt)}"></video>`;
291
+ ? `<video class="mq-embed"${flag("autoplay")}${flag("loop")}${flag("muted")}${flag("playsinline")} src="${url}" aria-label="${label}"></video>`
292
+ : `<video class="mq-embed"${flag("controls")} src="${url}" aria-label="${label}"></video>`;
256
293
  }
257
294
  }
258
295
  // The contractual shrug applied to media: degrade to a labeled link, or
259
296
  // to inert text when the scheme is out of policy.
260
- const label = escapeText(`[${alt === "" ? target : alt}]`);
261
- return profile.linkAllowed(target)
262
- ? `<a class="mq-embed-fallback" href="${escapeAttr(target)}">${label}</a>`
297
+ const label = ctx.text(`[${alt === "" ? target : alt}]`);
298
+ return ctx.profile.linkAllowed(target)
299
+ ? `<a class="mq-embed-fallback" href="${href(target, ctx)}">${label}</a>`
263
300
  : `<span class="mq-embed-fallback">${label}</span>`;
264
301
  }
265
302
 
266
303
  const TURBOLINK_LEVELS = new Set<TurbolinkLevel>(["full", "title", "bare"]);
267
304
 
268
- function turbolink(target: string, levelAttr: string | undefined, profile: Profile): string {
305
+ function turbolink(target: string, levelAttr: string | undefined, ctx: Ctx): string {
306
+ const { profile } = ctx;
269
307
  if (!profile.linkAllowed(target)) {
270
- return `<p class="mq-turbolink">${escapeText(target)}</p>`;
308
+ return `<p class="mq-turbolink">${ctx.text(target)}</p>`;
271
309
  }
272
310
  const level =
273
311
  levelAttr !== undefined && TURBOLINK_LEVELS.has(levelAttr as TurbolinkLevel)
@@ -278,11 +316,11 @@ function turbolink(target: string, levelAttr: string | undefined, profile: Profi
278
316
  if (rich !== null) {
279
317
  // Enrichment augments, never replaces: the wrapper itself carries the
280
318
  // original link, so no plugin can eat it - not even by accident.
281
- return `<div class="mq-turbolink mq-turbolink-rich">${rich}<a class="mq-turbolink-source" href="${escapeAttr(target)}">${escapeText(target)}</a></div>`;
319
+ return `<div class="mq-turbolink mq-turbolink-rich">${rich}<a class="mq-turbolink-source" href="${href(target, ctx)}">${ctx.text(target)}</a></div>`;
282
320
  }
283
321
  }
284
322
  // The contractual floor: a plain link, always reachable.
285
- return `<p class="mq-turbolink"><a href="${escapeAttr(target)}">${escapeText(target)}</a></p>`;
323
+ return `<p class="mq-turbolink"><a href="${href(target, ctx)}">${ctx.text(target)}</a></p>`;
286
324
  }
287
325
 
288
326
  /** A resolved URL made safe for a CSS url("...") token: encodeURI handles
@@ -381,7 +419,7 @@ function directive(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string
381
419
  case "turbolink": {
382
420
  const target = attrs["target"];
383
421
  if (target !== undefined) {
384
- return turbolink(target, attrs["level"], profile);
422
+ return turbolink(target, attrs["level"], ctx);
385
423
  }
386
424
  break; // malformed use: fall through to the placeholder
387
425
  }
@@ -434,10 +472,10 @@ function directive(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string
434
472
  // differential-rendering hazard); role=base marks the common ancestor.
435
473
  const head: string[] = [];
436
474
  if (attrs["label"] !== undefined) {
437
- head.push(`<span class="mq-variant-label">${escapeText(attrs["label"])}</span>`);
475
+ head.push(`<span class="mq-variant-label">${ctx.text(attrs["label"])}</span>`);
438
476
  }
439
477
  if (attrs["when"] !== undefined) {
440
- head.push(`<span class="mq-variant-when">${escapeText(attrs["when"])}</span>`);
478
+ head.push(`<span class="mq-variant-when">${ctx.text(attrs["when"])}</span>`);
441
479
  }
442
480
  const cls = attrs["role"] === "base" ? "mq-variant mq-variant-base" : "mq-variant";
443
481
  const headHtml = head.length === 0 ? "" : `<div class="mq-variant-head">${head.join(" ")}</div>`;
@@ -448,8 +486,8 @@ function directive(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string
448
486
  // that something wrapped them; a leaf renders the inert placeholder.
449
487
  // Never eat authored content.
450
488
  return nodes.length > 0
451
- ? `<div class="mq-unknown" data-directive="${escapeAttr(name)}">${inner}</div>`
452
- : `<div class="mq-placeholder" data-directive="${escapeAttr(name)}"></div>`;
489
+ ? `<div class="mq-unknown" data-directive="${ctx.attr(name)}">${inner}</div>`
490
+ : `<div class="mq-placeholder" data-directive="${ctx.attr(name)}"></div>`;
453
491
  }
454
492
 
455
493
  /** :::table (SPEC.md, "Tables"): each paragraph child is a row; a row's
@@ -607,7 +645,7 @@ function span(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string {
607
645
  const value = attrs["font"];
608
646
  const face = value !== undefined ? FONTS[value] : undefined;
609
647
  if (face !== undefined) {
610
- return `<font class="mq-font-${value}" face="${escapeAttr(face)}">${inner}</font>`;
648
+ return `<font class="mq-font-${value}" face="${ctx.attr(face)}">${inner}</font>`;
611
649
  }
612
650
  return inner; // not on the list: the words survive in their own clothes
613
651
  }
@@ -647,8 +685,8 @@ function span(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string {
647
685
  // effects.ts, shared with every other surface that draws effects.
648
686
 
649
687
  function renderEffect(name: string, look: EffectLook, nodes: Node[], inner: string, ctx: Ctx): string {
650
- const data = look.data.map(([k, v]) => ` data-${k}="${escapeAttr(v)}"`).join("");
651
- const style = look.vars.length === 0 ? "" : ` style="${look.vars.map(([k, v]) => `${k}:${escapeAttr(v)}`).join(";")}"`;
688
+ const data = look.data.map(([k, v]) => ` data-${k}="${ctx.attr(v)}"`).join("");
689
+ const style = look.vars.length === 0 ? "" : ` style="${look.vars.map(([k, v]) => `${k}:${ctx.attr(v)}`).join(";")}"`;
652
690
  if (name === "marquee") {
653
691
  return `<span class="${look.className}"${data}${style}><span class="mq-marquee-inner">${inner}</span></span>`;
654
692
  }
@@ -664,9 +702,9 @@ function splitRender(nodes: Node[], ctx: Ctx, look: EffectLook): string {
664
702
  list
665
703
  .map((p) =>
666
704
  p.kind === "text"
667
- ? escapeText(p.text)
705
+ ? ctx.text(p.text)
668
706
  : p.kind === "unit"
669
- ? `<span class="mq-l" style="--mq-o:${p.offset}">${escapeText(p.text)}</span>`
707
+ ? `<span class="mq-l" style="--mq-o:${p.offset}">${ctx.text(p.text)}</span>`
670
708
  : `<span class="mq-w">${pieces(p.pieces)}</span>`,
671
709
  )
672
710
  .join("");
@@ -696,3 +734,24 @@ export function escapeText(s: string): string {
696
734
  export function escapeAttr(s: string): string {
697
735
  return escapeText(s).replaceAll('"', "&quot;");
698
736
  }
737
+
738
+ /** Characters XML 1.0 forbids outright - even as a numeric reference: the C0
739
+ * controls bar tab, newline, and return, the noncharacters U+FFFE/U+FFFF,
740
+ * and (a JS string can hold one) a lone surrogate. HTML parsers shrug these
741
+ * off; an XML parser refuses the whole page, so XHTML output carries U+FFFD
742
+ * in their place. */
743
+ const XML_FORBIDDEN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
744
+
745
+ function escapeTextXml(s: string): string {
746
+ return escapeText(s).replace(XML_FORBIDDEN, "\uFFFD");
747
+ }
748
+
749
+ /** An XML parser normalizes whitespace in an attribute value to spaces, so a
750
+ * newline in alt text survives only as a character reference. */
751
+ function escapeAttrXml(s: string): string {
752
+ return escapeTextXml(s)
753
+ .replaceAll('"', "&quot;")
754
+ .replaceAll("\t", "&#9;")
755
+ .replaceAll("\n", "&#10;")
756
+ .replaceAll("\r", "&#13;");
757
+ }