@cube-drone/marquee-html-renderer 0.9.2 → 0.10.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/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/profile.js CHANGED
@@ -4,14 +4,19 @@
4
4
  // guesses trust, never widens a scheme allowlist.
5
5
  // Mirror of the Rust renderer's table (rust/html_renderer/src/profile.rs). `apng` and
6
6
  // `opus` joined 2026-09-03: ringtome mints both and they rendered as the placeholder.
7
- const MEDIA_KINDS = {
7
+ const MEDIA_KINDS = Object.assign(Object.create(null), {
8
8
  png: "image", jpg: "image", jpeg: "image", gif: "image", webp: "image",
9
9
  avif: "image", apng: "image", svg: "image",
10
10
  mp3: "audio", ogg: "audio", opus: "audio", wav: "audio", flac: "audio", m4a: "audio",
11
11
  mp4: "video", webm: "video",
12
- };
12
+ });
13
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
14
+ // Read the scheme the way a browser will: the URL parser strips leading C0
15
+ // controls and spaces and drops tabs/newlines anywhere, so
16
+ // "\x01javascript:" and "java\tscript:" are javascript: URLs. Targets with
17
+ // controls are refused outright (see linkAllowed); leading spaces are skipped.
13
18
  function scheme(target) {
14
- const m = /^([A-Za-z][A-Za-z0-9+.-]*):/.exec(target);
19
+ const m = /^ *([A-Za-z][A-Za-z0-9+.-]*):/.exec(target);
15
20
  return m ? m[1].toLowerCase() : null;
16
21
  }
17
22
  /** The bare-web default: https links, extension-sniffed media, no widgets,
@@ -19,6 +24,11 @@ function scheme(target) {
19
24
  * degrades inert. */
20
25
  export const bareWebProfile = {
21
26
  linkAllowed(target) {
27
+ // A lone surrogate isn't a URL anyone can write; refusing it here keeps
28
+ // every anchor and media path well-formed.
29
+ if (/[\x00-\x1f\x7f]/.test(target) || LONE_SURROGATE.test(target)) {
30
+ return false;
31
+ }
22
32
  const s = scheme(target);
23
33
  return s === null || s === "http" || s === "https" || s === "mailto";
24
34
  },
package/dist/render.d.ts CHANGED
@@ -1,11 +1,24 @@
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).
7
18
  * Values are the `face` presentational attribute - the no-stylesheet floor;
8
- * the mq-font-* class is the ceiling, where the real @font-face lives. */
19
+ * the mq-font-* class is the ceiling, where the real @font-face lives.
20
+ * Author-keyed tables like this one have no prototype, so `font=constructor`
21
+ * finds nothing instead of Object's own properties. */
9
22
  export declare const FONTS: Record<string, string>;
10
23
  /** Which grab-bag faces does this rendered HTML actually wear? Pure string
11
24
  * scan of the mq-font-* class contract - feed the result to
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) {
@@ -106,8 +119,10 @@ function isToken(v) {
106
119
  * no files, and the grab bag - SIL OFL faces the embedder serves itself
107
120
  * (never a third-party CDN: fonts are a tracking vector, care-modes apply).
108
121
  * Values are the `face` presentational attribute - the no-stylesheet floor;
109
- * the mq-font-* class is the ceiling, where the real @font-face lives. */
110
- export const FONTS = {
122
+ * the mq-font-* class is the ceiling, where the real @font-face lives.
123
+ * Author-keyed tables like this one have no prototype, so `font=constructor`
124
+ * finds nothing instead of Object's own properties. */
125
+ export const FONTS = Object.assign(Object.create(null), {
111
126
  // standard stacks
112
127
  sans: "sans-serif",
113
128
  serif: "serif",
@@ -145,7 +160,7 @@ export const FONTS = {
145
160
  rye: "Rye",
146
161
  bitcount: "Bitcount",
147
162
  quicksand: "Quicksand",
148
- };
163
+ });
149
164
  /** Which grab-bag faces does this rendered HTML actually wear? Pure string
150
165
  * scan of the mq-font-* class contract - feed the result to
151
166
  * @cube-drone/marquee-fonts (externalFontFaces / inlineFontFaces) to deliver
@@ -180,12 +195,12 @@ const SCHEME_FONTS = [
180
195
  function sizeRung(value, inner) {
181
196
  return `<font class="mq-size-${value}" size="${value}">${inner}</font>`;
182
197
  }
183
- const MEDIA_SIZE_TOKENS = {
198
+ const MEDIA_SIZE_TOKENS = Object.assign(Object.create(null), {
184
199
  small: "10rem",
185
200
  medium: "20rem",
186
201
  large: "32rem",
187
202
  full: "100%",
188
- };
203
+ });
189
204
  /** Media width/height: a size token or a capped integer of pixels (SPEC.md,
190
205
  * "Media"). Anything else degrades to natural sizing. */
191
206
  function mediaSize(v) {
@@ -212,32 +227,35 @@ function infoToken(info) {
212
227
  return /^[A-Za-z0-9_+.#-]{1,64}$/.test(first) ? first : null;
213
228
  }
214
229
  // -- constructs
215
- function embed(target, alt, profile) {
216
- const media = profile.media(target);
230
+ function embed(target, alt, ctx) {
231
+ const media = ctx.profile.media(target);
217
232
  if (media !== null) {
218
- const url = escapeAttr(media.url);
233
+ const url = ctx.attr(media.url);
234
+ const label = ctx.attr(alt);
235
+ const flag = ctx.flag;
219
236
  switch (media.kind) {
220
237
  case "image":
221
- return `<img class="mq-embed" src="${url}" alt="${escapeAttr(alt)}" loading="lazy">`;
238
+ return `<img class="mq-embed" src="${url}" alt="${label}" loading="lazy"${ctx.void}`;
222
239
  case "audio":
223
- return `<audio class="mq-embed" controls src="${url}" aria-label="${escapeAttr(alt)}"></audio>`;
240
+ return `<audio class="mq-embed"${flag("controls")} src="${url}" aria-label="${label}"></audio>`;
224
241
  case "video":
225
242
  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>`;
243
+ ? `<video class="mq-embed"${flag("autoplay")}${flag("loop")}${flag("muted")}${flag("playsinline")} src="${url}" aria-label="${label}"></video>`
244
+ : `<video class="mq-embed"${flag("controls")} src="${url}" aria-label="${label}"></video>`;
228
245
  }
229
246
  }
230
247
  // The contractual shrug applied to media: degrade to a labeled link, or
231
248
  // 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>`
249
+ const label = ctx.text(`[${alt === "" ? target : alt}]`);
250
+ return ctx.profile.linkAllowed(target)
251
+ ? `<a class="mq-embed-fallback" href="${href(target, ctx)}">${label}</a>`
235
252
  : `<span class="mq-embed-fallback">${label}</span>`;
236
253
  }
237
254
  const TURBOLINK_LEVELS = new Set(["full", "title", "bare"]);
238
- function turbolink(target, levelAttr, profile) {
255
+ function turbolink(target, levelAttr, ctx) {
256
+ const { profile } = ctx;
239
257
  if (!profile.linkAllowed(target)) {
240
- return `<p class="mq-turbolink">${escapeText(target)}</p>`;
258
+ return `<p class="mq-turbolink">${ctx.text(target)}</p>`;
241
259
  }
242
260
  const level = levelAttr !== undefined && TURBOLINK_LEVELS.has(levelAttr)
243
261
  ? levelAttr
@@ -247,19 +265,21 @@ function turbolink(target, levelAttr, profile) {
247
265
  if (rich !== null) {
248
266
  // Enrichment augments, never replaces: the wrapper itself carries the
249
267
  // 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>`;
268
+ return `<div class="mq-turbolink mq-turbolink-rich">${rich}<a class="mq-turbolink-source" href="${href(target, ctx)}">${ctx.text(target)}</a></div>`;
251
269
  }
252
270
  }
253
271
  // The contractual floor: a plain link, always reachable.
254
- return `<p class="mq-turbolink"><a href="${escapeAttr(target)}">${escapeText(target)}</a></p>`;
272
+ return `<p class="mq-turbolink"><a href="${href(target, ctx)}">${ctx.text(target)}</a></p>`;
255
273
  }
256
274
  /** A resolved URL made safe for a CSS url("...") token: encodeURI handles
257
275
  * whitespace, controls, backslashes, and double quotes; quotes and parens
258
276
  * that encodeURI deliberately leaves are percent-encoded on top, so the
259
277
  * value can never terminate the url() or the declaration - author bytes
260
278
  * must not write CSS, even inside their own style attribute. */
279
+ // encodeURI throws on a lone surrogate; an embedder's media() may pass one.
280
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
261
281
  function cssUrl(url) {
262
- return encodeURI(url).replaceAll("'", "%27").replaceAll("(", "%28").replaceAll(")", "%29");
282
+ return encodeURI(url.replace(LONE_SURROGATE, "\uFFFD")).replaceAll("'", "%27").replaceAll("(", "%28").replaceAll(")", "%29");
263
283
  }
264
284
  /** Style knobs on a block node: validated values into --mq-* slots; the
265
285
  * stylesheet owns which CSS property each slot feeds. `background` takes a
@@ -290,9 +310,12 @@ function styleVarList(attrs, profile) {
290
310
  }
291
311
  return vars.join(";");
292
312
  }
293
- function styleVars(attrs, profile) {
294
- const list = styleVarList(attrs, profile);
295
- return list === "" ? "" : ` style="${list}"`;
313
+ /** The style attribute, attribute-escaped like any other: the browser
314
+ * decodes character references before CSS sees the value, so a raw `&apos;`
315
+ * in a tile URL would otherwise close the url() and write CSS. */
316
+ function styleVars(attrs, ctx) {
317
+ const list = styleVarList(attrs, ctx.profile);
318
+ return list === "" ? "" : ` style="${ctx.attr(list)}"`;
296
319
  }
297
320
  /** The visual identity a layout container (:::page / :::section) wears -
298
321
  * its scheme/font classes and validated style knobs - as bare class/style
@@ -315,14 +338,13 @@ function fontClass(attrs) {
315
338
  }
316
339
  function directive(name, attrs, nodes, ctx) {
317
340
  const { profile } = ctx;
318
- // The profile hook sees the children rendered; built-ins that lay their
319
- // children out themselves (table, stack) render them again, so rewind the
320
- // aside numbering first - or a note inside counts twice.
321
- const saved = { n: ctx.note.n, pending: [...ctx.note.pending] };
322
- const rewind = () => {
323
- ctx.note.n = saved.n;
324
- ctx.note.pending = [...saved.pending];
325
- };
341
+ // Built-ins that lay their children out themselves (table, stack) probe
342
+ // the hook (it claims on name and attrs alone) instead of rendering the
343
+ // children for it: rendering them twice per level is exponential in
344
+ // nesting, and a note inside would count twice.
345
+ if ((name === "table" || name === "stack") && profile.directive(name, attrs, "") === null) {
346
+ return name === "table" ? renderTable(attrs, nodes, ctx) : renderStack(attrs, nodes, ctx);
347
+ }
326
348
  const inner = children(nodes, ctx);
327
349
  const custom = profile.directive(name, attrs, inner);
328
350
  if (custom !== null) {
@@ -335,16 +357,16 @@ function directive(name, attrs, nodes, ctx) {
335
357
  return inner;
336
358
  case "page": {
337
359
  const layout = isToken(attrs["layout"]) ? ` mq-layout-${attrs["layout"]}` : "";
338
- return `<div class="mq-page${layout}${schemeClass(attrs)}${fontClass(attrs)}"${styleVars(attrs, profile)}>${inner}</div>`;
360
+ return `<div class="mq-page${layout}${schemeClass(attrs)}${fontClass(attrs)}"${styleVars(attrs, ctx)}>${inner}</div>`;
339
361
  }
340
362
  case "section": {
341
363
  const slot = isToken(attrs["slot"]) ? ` data-slot="${attrs["slot"]}"` : "";
342
- return `<section class="mq-section${schemeClass(attrs)}${fontClass(attrs)}"${slot}${styleVars(attrs, profile)}>${inner}</section>`;
364
+ return `<section class="mq-section${schemeClass(attrs)}${fontClass(attrs)}"${slot}${styleVars(attrs, ctx)}>${inner}</section>`;
343
365
  }
344
366
  case "turbolink": {
345
367
  const target = attrs["target"];
346
368
  if (target !== undefined) {
347
- return turbolink(target, attrs["level"], profile);
369
+ return turbolink(target, attrs["level"], ctx);
348
370
  }
349
371
  break; // malformed use: fall through to the placeholder
350
372
  }
@@ -361,16 +383,10 @@ function directive(name, attrs, nodes, ctx) {
361
383
  const style = vars.length === 0 ? "" : ` style="${vars.join(";")}"`;
362
384
  return `<div class="mq-media"${style}>${inner}</div>`;
363
385
  }
364
- case "table":
365
- rewind();
366
- return renderTable(attrs, nodes, ctx);
367
- case "stack":
368
- rewind();
369
- return renderStack(attrs, nodes, ctx);
370
386
  case "layer":
371
387
  // Outside a stack a layer is just a section-like container: its style
372
388
  // knobs apply, place/backing mean nothing.
373
- return `<div class="mq-layer${schemeClass(attrs)}${fontClass(attrs)}"${styleVars(attrs, profile)}>${inner}</div>`;
389
+ return `<div class="mq-layer${schemeClass(attrs)}${fontClass(attrs)}"${styleVars(attrs, ctx)}>${inner}</div>`;
374
390
  case "center":
375
391
  case "right":
376
392
  case "left":
@@ -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
@@ -466,13 +482,13 @@ const PLACES = new Set([
466
482
  "fill", "top-left", "top", "top-right", "left", "center", "right", "bottom-left", "bottom", "bottom-right",
467
483
  ]);
468
484
  /** The `aspect` vocabulary as CSS aspect-ratio values. */
469
- const STACK_ASPECTS = {
485
+ const STACK_ASPECTS = Object.assign(Object.create(null), {
470
486
  "16:9": "16/9",
471
487
  "4:3": "4/3",
472
488
  "3:2": "3/2",
473
489
  "1:1": "1/1",
474
490
  card: "5/7",
475
- };
491
+ });
476
492
  /** :::stack (SPEC.md, "Stacks"): every direct child is a layer, source order
477
493
  * is z-order. The stylesheet puts every layer in one grid cell, so the box
478
494
  * is as big as its biggest layer needs and paint order is DOM order: no
@@ -512,7 +528,7 @@ function renderLayer(node, ctx) {
512
528
  ? " mq-layer-cover"
513
529
  : "";
514
530
  const inner = children(content, ctx);
515
- return `<div class="mq-layer mq-place-${place}${backing}${cover}${schemeClass(attrs)}${fontClass(attrs)}"${styleVars(attrs, ctx.profile)}>${inner}</div>`;
531
+ return `<div class="mq-layer mq-place-${place}${backing}${cover}${schemeClass(attrs)}${fontClass(attrs)}"${styleVars(attrs, ctx)}>${inner}</div>`;
516
532
  }
517
533
  /** Effect/typographic spans. The `<font>` tag is deliberate: presentational
518
534
  * attributes are outside CSP's jurisdiction and are implemented by the
@@ -520,19 +536,19 @@ function renderLayer(node, ctx) {
520
536
  * attributes - the floor of the degradation ladder. The --mq-color slot is
521
537
  * the stylesheet-era ceiling on the same element. */
522
538
  function span(name, attrs, nodes, ctx) {
523
- const saved = { n: ctx.note.n, pending: [...ctx.note.pending] };
539
+ // Probe first (the hook claims on name and attrs alone): a split renders
540
+ // its children itself, and rendering them here as well doubled the work at
541
+ // every nesting level - exponential in a document anyone can sync.
542
+ const look = ctx.profile.span(name, attrs, "") === null ? effectLook(name, attrs, nodes) : null;
543
+ if (look !== null && look.units !== null) {
544
+ return renderEffect(name, look, nodes, "", ctx);
545
+ }
524
546
  const inner = children(nodes, ctx);
525
547
  const custom = ctx.profile.span(name, attrs, inner);
526
548
  if (custom !== null) {
527
549
  return custom;
528
550
  }
529
- const look = effectLook(name, attrs, nodes);
530
551
  if (look !== null) {
531
- if (look.units !== null) {
532
- // A split renders its children again: rewind the aside numbering.
533
- ctx.note.n = saved.n;
534
- ctx.note.pending = [...saved.pending];
535
- }
536
552
  return renderEffect(name, look, nodes, inner, ctx);
537
553
  }
538
554
  switch (name) {
@@ -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.10.0",
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.10.0"
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.10.0",
42
+ "@cube-drone/marquee-fonts": "^0.10.0",
43
+ "@cube-drone/marquee-turbolink": "^0.10.0",
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`
@@ -65,15 +76,21 @@ export interface Profile {
65
76
 
66
77
  // Mirror of the Rust renderer's table (rust/html_renderer/src/profile.rs). `apng` and
67
78
  // `opus` joined 2026-09-03: ringtome mints both and they rendered as the placeholder.
68
- const MEDIA_KINDS: Record<string, MediaResolution["kind"]> = {
79
+ const MEDIA_KINDS: Record<string, MediaResolution["kind"]> = Object.assign(Object.create(null), {
69
80
  png: "image", jpg: "image", jpeg: "image", gif: "image", webp: "image",
70
81
  avif: "image", apng: "image", svg: "image",
71
82
  mp3: "audio", ogg: "audio", opus: "audio", wav: "audio", flac: "audio", m4a: "audio",
72
83
  mp4: "video", webm: "video",
73
- };
84
+ });
85
+
86
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
74
87
 
88
+ // Read the scheme the way a browser will: the URL parser strips leading C0
89
+ // controls and spaces and drops tabs/newlines anywhere, so
90
+ // "\x01javascript:" and "java\tscript:" are javascript: URLs. Targets with
91
+ // controls are refused outright (see linkAllowed); leading spaces are skipped.
75
92
  function scheme(target: string): string | null {
76
- const m = /^([A-Za-z][A-Za-z0-9+.-]*):/.exec(target);
93
+ const m = /^ *([A-Za-z][A-Za-z0-9+.-]*):/.exec(target);
77
94
  return m ? m[1]!.toLowerCase() : null;
78
95
  }
79
96
 
@@ -82,6 +99,11 @@ function scheme(target: string): string | null {
82
99
  * degrades inert. */
83
100
  export const bareWebProfile: Profile = {
84
101
  linkAllowed(target) {
102
+ // A lone surrogate isn't a URL anyone can write; refusing it here keeps
103
+ // every anchor and media path well-formed.
104
+ if (/[\x00-\x1f\x7f]/.test(target) || LONE_SURROGATE.test(target)) {
105
+ return false;
106
+ }
85
107
  const s = scheme(target);
86
108
  return s === null || s === "http" || s === "https" || s === "mailto";
87
109
  },
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
 
@@ -126,8 +161,10 @@ function isToken(v: string | undefined): v is string {
126
161
  * no files, and the grab bag - SIL OFL faces the embedder serves itself
127
162
  * (never a third-party CDN: fonts are a tracking vector, care-modes apply).
128
163
  * Values are the `face` presentational attribute - the no-stylesheet floor;
129
- * the mq-font-* class is the ceiling, where the real @font-face lives. */
130
- export const FONTS: Record<string, string> = {
164
+ * the mq-font-* class is the ceiling, where the real @font-face lives.
165
+ * Author-keyed tables like this one have no prototype, so `font=constructor`
166
+ * finds nothing instead of Object's own properties. */
167
+ export const FONTS: Record<string, string> = Object.assign(Object.create(null), {
131
168
  // standard stacks
132
169
  sans: "sans-serif",
133
170
  serif: "serif",
@@ -165,7 +202,7 @@ export const FONTS: Record<string, string> = {
165
202
  rye: "Rye",
166
203
  bitcount: "Bitcount",
167
204
  quicksand: "Quicksand",
168
- };
205
+ });
169
206
 
170
207
  /** Which grab-bag faces does this rendered HTML actually wear? Pure string
171
208
  * scan of the mq-font-* class contract - feed the result to
@@ -204,12 +241,12 @@ function sizeRung(value: string, inner: string): string {
204
241
  return `<font class="mq-size-${value}" size="${value}">${inner}</font>`;
205
242
  }
206
243
 
207
- const MEDIA_SIZE_TOKENS: Record<string, string> = {
244
+ const MEDIA_SIZE_TOKENS: Record<string, string> = Object.assign(Object.create(null), {
208
245
  small: "10rem",
209
246
  medium: "20rem",
210
247
  large: "32rem",
211
248
  full: "100%",
212
- };
249
+ });
213
250
 
214
251
  /** Media width/height: a size token or a capped integer of pixels (SPEC.md,
215
252
  * "Media"). Anything else degrades to natural sizing. */
@@ -240,34 +277,37 @@ function infoToken(info: string | undefined): string | null {
240
277
 
241
278
  // -- constructs
242
279
 
243
- function embed(target: string, alt: string, profile: Profile): string {
244
- const media = profile.media(target);
280
+ function embed(target: string, alt: string, ctx: Ctx): string {
281
+ const media = ctx.profile.media(target);
245
282
  if (media !== null) {
246
- const url = escapeAttr(media.url);
283
+ const url = ctx.attr(media.url);
284
+ const label = ctx.attr(alt);
285
+ const flag = ctx.flag;
247
286
  switch (media.kind) {
248
287
  case "image":
249
- return `<img class="mq-embed" src="${url}" alt="${escapeAttr(alt)}" loading="lazy">`;
288
+ return `<img class="mq-embed" src="${url}" alt="${label}" loading="lazy"${ctx.void}`;
250
289
  case "audio":
251
- return `<audio class="mq-embed" controls src="${url}" aria-label="${escapeAttr(alt)}"></audio>`;
290
+ return `<audio class="mq-embed"${flag("controls")} src="${url}" aria-label="${label}"></audio>`;
252
291
  case "video":
253
292
  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>`;
293
+ ? `<video class="mq-embed"${flag("autoplay")}${flag("loop")}${flag("muted")}${flag("playsinline")} src="${url}" aria-label="${label}"></video>`
294
+ : `<video class="mq-embed"${flag("controls")} src="${url}" aria-label="${label}"></video>`;
256
295
  }
257
296
  }
258
297
  // The contractual shrug applied to media: degrade to a labeled link, or
259
298
  // 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>`
299
+ const label = ctx.text(`[${alt === "" ? target : alt}]`);
300
+ return ctx.profile.linkAllowed(target)
301
+ ? `<a class="mq-embed-fallback" href="${href(target, ctx)}">${label}</a>`
263
302
  : `<span class="mq-embed-fallback">${label}</span>`;
264
303
  }
265
304
 
266
305
  const TURBOLINK_LEVELS = new Set<TurbolinkLevel>(["full", "title", "bare"]);
267
306
 
268
- function turbolink(target: string, levelAttr: string | undefined, profile: Profile): string {
307
+ function turbolink(target: string, levelAttr: string | undefined, ctx: Ctx): string {
308
+ const { profile } = ctx;
269
309
  if (!profile.linkAllowed(target)) {
270
- return `<p class="mq-turbolink">${escapeText(target)}</p>`;
310
+ return `<p class="mq-turbolink">${ctx.text(target)}</p>`;
271
311
  }
272
312
  const level =
273
313
  levelAttr !== undefined && TURBOLINK_LEVELS.has(levelAttr as TurbolinkLevel)
@@ -278,11 +318,11 @@ function turbolink(target: string, levelAttr: string | undefined, profile: Profi
278
318
  if (rich !== null) {
279
319
  // Enrichment augments, never replaces: the wrapper itself carries the
280
320
  // 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>`;
321
+ return `<div class="mq-turbolink mq-turbolink-rich">${rich}<a class="mq-turbolink-source" href="${href(target, ctx)}">${ctx.text(target)}</a></div>`;
282
322
  }
283
323
  }
284
324
  // The contractual floor: a plain link, always reachable.
285
- return `<p class="mq-turbolink"><a href="${escapeAttr(target)}">${escapeText(target)}</a></p>`;
325
+ return `<p class="mq-turbolink"><a href="${href(target, ctx)}">${ctx.text(target)}</a></p>`;
286
326
  }
287
327
 
288
328
  /** A resolved URL made safe for a CSS url("...") token: encodeURI handles
@@ -290,8 +330,11 @@ function turbolink(target: string, levelAttr: string | undefined, profile: Profi
290
330
  * that encodeURI deliberately leaves are percent-encoded on top, so the
291
331
  * value can never terminate the url() or the declaration - author bytes
292
332
  * must not write CSS, even inside their own style attribute. */
333
+ // encodeURI throws on a lone surrogate; an embedder's media() may pass one.
334
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
335
+
293
336
  function cssUrl(url: string): string {
294
- return encodeURI(url).replaceAll("'", "%27").replaceAll("(", "%28").replaceAll(")", "%29");
337
+ return encodeURI(url.replace(LONE_SURROGATE, "\uFFFD")).replaceAll("'", "%27").replaceAll("(", "%28").replaceAll(")", "%29");
295
338
  }
296
339
 
297
340
  /** Style knobs on a block node: validated values into --mq-* slots; the
@@ -323,9 +366,12 @@ function styleVarList(attrs: Attrs, profile: Profile): string {
323
366
  return vars.join(";");
324
367
  }
325
368
 
326
- function styleVars(attrs: Attrs, profile: Profile): string {
327
- const list = styleVarList(attrs, profile);
328
- return list === "" ? "" : ` style="${list}"`;
369
+ /** The style attribute, attribute-escaped like any other: the browser
370
+ * decodes character references before CSS sees the value, so a raw `&apos;`
371
+ * in a tile URL would otherwise close the url() and write CSS. */
372
+ function styleVars(attrs: Attrs, ctx: Ctx): string {
373
+ const list = styleVarList(attrs, ctx.profile);
374
+ return list === "" ? "" : ` style="${ctx.attr(list)}"`;
329
375
  }
330
376
 
331
377
  /** The visual identity a layout container (:::page / :::section) wears -
@@ -352,14 +398,13 @@ function fontClass(attrs: Attrs): string {
352
398
 
353
399
  function directive(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string {
354
400
  const { profile } = ctx;
355
- // The profile hook sees the children rendered; built-ins that lay their
356
- // children out themselves (table, stack) render them again, so rewind the
357
- // aside numbering first - or a note inside counts twice.
358
- const saved = { n: ctx.note.n, pending: [...ctx.note.pending] };
359
- const rewind = () => {
360
- ctx.note.n = saved.n;
361
- ctx.note.pending = [...saved.pending];
362
- };
401
+ // Built-ins that lay their children out themselves (table, stack) probe
402
+ // the hook (it claims on name and attrs alone) instead of rendering the
403
+ // children for it: rendering them twice per level is exponential in
404
+ // nesting, and a note inside would count twice.
405
+ if ((name === "table" || name === "stack") && profile.directive(name, attrs, "") === null) {
406
+ return name === "table" ? renderTable(attrs, nodes, ctx) : renderStack(attrs, nodes, ctx);
407
+ }
363
408
  const inner = children(nodes, ctx);
364
409
  const custom = profile.directive(name, attrs, inner);
365
410
  if (custom !== null) {
@@ -372,16 +417,16 @@ function directive(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string
372
417
  return inner;
373
418
  case "page": {
374
419
  const layout = isToken(attrs["layout"]) ? ` mq-layout-${attrs["layout"]}` : "";
375
- return `<div class="mq-page${layout}${schemeClass(attrs)}${fontClass(attrs)}"${styleVars(attrs, profile)}>${inner}</div>`;
420
+ return `<div class="mq-page${layout}${schemeClass(attrs)}${fontClass(attrs)}"${styleVars(attrs, ctx)}>${inner}</div>`;
376
421
  }
377
422
  case "section": {
378
423
  const slot = isToken(attrs["slot"]) ? ` data-slot="${attrs["slot"]}"` : "";
379
- return `<section class="mq-section${schemeClass(attrs)}${fontClass(attrs)}"${slot}${styleVars(attrs, profile)}>${inner}</section>`;
424
+ return `<section class="mq-section${schemeClass(attrs)}${fontClass(attrs)}"${slot}${styleVars(attrs, ctx)}>${inner}</section>`;
380
425
  }
381
426
  case "turbolink": {
382
427
  const target = attrs["target"];
383
428
  if (target !== undefined) {
384
- return turbolink(target, attrs["level"], profile);
429
+ return turbolink(target, attrs["level"], ctx);
385
430
  }
386
431
  break; // malformed use: fall through to the placeholder
387
432
  }
@@ -398,16 +443,10 @@ function directive(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string
398
443
  const style = vars.length === 0 ? "" : ` style="${vars.join(";")}"`;
399
444
  return `<div class="mq-media"${style}>${inner}</div>`;
400
445
  }
401
- case "table":
402
- rewind();
403
- return renderTable(attrs, nodes, ctx);
404
- case "stack":
405
- rewind();
406
- return renderStack(attrs, nodes, ctx);
407
446
  case "layer":
408
447
  // Outside a stack a layer is just a section-like container: its style
409
448
  // knobs apply, place/backing mean nothing.
410
- return `<div class="mq-layer${schemeClass(attrs)}${fontClass(attrs)}"${styleVars(attrs, profile)}>${inner}</div>`;
449
+ return `<div class="mq-layer${schemeClass(attrs)}${fontClass(attrs)}"${styleVars(attrs, ctx)}>${inner}</div>`;
411
450
  case "center":
412
451
  case "right":
413
452
  case "left":
@@ -434,10 +473,10 @@ function directive(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string
434
473
  // differential-rendering hazard); role=base marks the common ancestor.
435
474
  const head: string[] = [];
436
475
  if (attrs["label"] !== undefined) {
437
- head.push(`<span class="mq-variant-label">${escapeText(attrs["label"])}</span>`);
476
+ head.push(`<span class="mq-variant-label">${ctx.text(attrs["label"])}</span>`);
438
477
  }
439
478
  if (attrs["when"] !== undefined) {
440
- head.push(`<span class="mq-variant-when">${escapeText(attrs["when"])}</span>`);
479
+ head.push(`<span class="mq-variant-when">${ctx.text(attrs["when"])}</span>`);
441
480
  }
442
481
  const cls = attrs["role"] === "base" ? "mq-variant mq-variant-base" : "mq-variant";
443
482
  const headHtml = head.length === 0 ? "" : `<div class="mq-variant-head">${head.join(" ")}</div>`;
@@ -448,8 +487,8 @@ function directive(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string
448
487
  // that something wrapped them; a leaf renders the inert placeholder.
449
488
  // Never eat authored content.
450
489
  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>`;
490
+ ? `<div class="mq-unknown" data-directive="${ctx.attr(name)}">${inner}</div>`
491
+ : `<div class="mq-placeholder" data-directive="${ctx.attr(name)}"></div>`;
453
492
  }
454
493
 
455
494
  /** :::table (SPEC.md, "Tables"): each paragraph child is a row; a row's
@@ -507,13 +546,13 @@ const PLACES = new Set([
507
546
  ]);
508
547
 
509
548
  /** The `aspect` vocabulary as CSS aspect-ratio values. */
510
- const STACK_ASPECTS: Record<string, string> = {
549
+ const STACK_ASPECTS: Record<string, string> = Object.assign(Object.create(null), {
511
550
  "16:9": "16/9",
512
551
  "4:3": "4/3",
513
552
  "3:2": "3/2",
514
553
  "1:1": "1/1",
515
554
  card: "5/7",
516
- };
555
+ });
517
556
 
518
557
  /** :::stack (SPEC.md, "Stacks"): every direct child is a layer, source order
519
558
  * is z-order. The stylesheet puts every layer in one grid cell, so the box
@@ -556,7 +595,7 @@ function renderLayer(node: Node, ctx: Ctx): string {
556
595
  ? " mq-layer-cover"
557
596
  : "";
558
597
  const inner = children(content, ctx);
559
- return `<div class="mq-layer mq-place-${place}${backing}${cover}${schemeClass(attrs)}${fontClass(attrs)}"${styleVars(attrs, ctx.profile)}>${inner}</div>`;
598
+ return `<div class="mq-layer mq-place-${place}${backing}${cover}${schemeClass(attrs)}${fontClass(attrs)}"${styleVars(attrs, ctx)}>${inner}</div>`;
560
599
  }
561
600
 
562
601
  /** Effect/typographic spans. The `<font>` tag is deliberate: presentational
@@ -565,19 +604,19 @@ function renderLayer(node: Node, ctx: Ctx): string {
565
604
  * attributes - the floor of the degradation ladder. The --mq-color slot is
566
605
  * the stylesheet-era ceiling on the same element. */
567
606
  function span(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string {
568
- const saved = { n: ctx.note.n, pending: [...ctx.note.pending] };
607
+ // Probe first (the hook claims on name and attrs alone): a split renders
608
+ // its children itself, and rendering them here as well doubled the work at
609
+ // every nesting level - exponential in a document anyone can sync.
610
+ const look = ctx.profile.span(name, attrs, "") === null ? effectLook(name, attrs, nodes) : null;
611
+ if (look !== null && look.units !== null) {
612
+ return renderEffect(name, look, nodes, "", ctx);
613
+ }
569
614
  const inner = children(nodes, ctx);
570
615
  const custom = ctx.profile.span(name, attrs, inner);
571
616
  if (custom !== null) {
572
617
  return custom;
573
618
  }
574
- const look = effectLook(name, attrs, nodes);
575
619
  if (look !== null) {
576
- if (look.units !== null) {
577
- // A split renders its children again: rewind the aside numbering.
578
- ctx.note.n = saved.n;
579
- ctx.note.pending = [...saved.pending];
580
- }
581
620
  return renderEffect(name, look, nodes, inner, ctx);
582
621
  }
583
622
  switch (name) {
@@ -607,7 +646,7 @@ function span(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string {
607
646
  const value = attrs["font"];
608
647
  const face = value !== undefined ? FONTS[value] : undefined;
609
648
  if (face !== undefined) {
610
- return `<font class="mq-font-${value}" face="${escapeAttr(face)}">${inner}</font>`;
649
+ return `<font class="mq-font-${value}" face="${ctx.attr(face)}">${inner}</font>`;
611
650
  }
612
651
  return inner; // not on the list: the words survive in their own clothes
613
652
  }
@@ -647,8 +686,8 @@ function span(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string {
647
686
  // effects.ts, shared with every other surface that draws effects.
648
687
 
649
688
  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(";")}"`;
689
+ const data = look.data.map(([k, v]) => ` data-${k}="${ctx.attr(v)}"`).join("");
690
+ const style = look.vars.length === 0 ? "" : ` style="${look.vars.map(([k, v]) => `${k}:${ctx.attr(v)}`).join(";")}"`;
652
691
  if (name === "marquee") {
653
692
  return `<span class="${look.className}"${data}${style}><span class="mq-marquee-inner">${inner}</span></span>`;
654
693
  }
@@ -664,9 +703,9 @@ function splitRender(nodes: Node[], ctx: Ctx, look: EffectLook): string {
664
703
  list
665
704
  .map((p) =>
666
705
  p.kind === "text"
667
- ? escapeText(p.text)
706
+ ? ctx.text(p.text)
668
707
  : p.kind === "unit"
669
- ? `<span class="mq-l" style="--mq-o:${p.offset}">${escapeText(p.text)}</span>`
708
+ ? `<span class="mq-l" style="--mq-o:${p.offset}">${ctx.text(p.text)}</span>`
670
709
  : `<span class="mq-w">${pieces(p.pieces)}</span>`,
671
710
  )
672
711
  .join("");
@@ -696,3 +735,24 @@ export function escapeText(s: string): string {
696
735
  export function escapeAttr(s: string): string {
697
736
  return escapeText(s).replaceAll('"', "&quot;");
698
737
  }
738
+
739
+ /** Characters XML 1.0 forbids outright - even as a numeric reference: the C0
740
+ * controls bar tab, newline, and return, the noncharacters U+FFFE/U+FFFF,
741
+ * and (a JS string can hold one) a lone surrogate. HTML parsers shrug these
742
+ * off; an XML parser refuses the whole page, so XHTML output carries U+FFFD
743
+ * in their place. */
744
+ const XML_FORBIDDEN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
745
+
746
+ function escapeTextXml(s: string): string {
747
+ return escapeText(s).replace(XML_FORBIDDEN, "\uFFFD");
748
+ }
749
+
750
+ /** An XML parser normalizes whitespace in an attribute value to spaces, so a
751
+ * newline in alt text survives only as a character reference. */
752
+ function escapeAttrXml(s: string): string {
753
+ return escapeTextXml(s)
754
+ .replaceAll('"', "&quot;")
755
+ .replaceAll("\t", "&#9;")
756
+ .replaceAll("\n", "&#10;")
757
+ .replaceAll("\r", "&#13;");
758
+ }