@cube-drone/marquee-html-renderer 0.9.3 → 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/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
@@ -16,7 +16,9 @@ export declare function render(node: Node, profile?: Profile, options?: RenderOp
16
16
  * no files, and the grab bag - SIL OFL faces the embedder serves itself
17
17
  * (never a third-party CDN: fonts are a tracking vector, care-modes apply).
18
18
  * Values are the `face` presentational attribute - the no-stylesheet floor;
19
- * 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. */
20
22
  export declare const FONTS: Record<string, string>;
21
23
  /** Which grab-bag faces does this rendered HTML actually wear? Pure string
22
24
  * scan of the mq-font-* class contract - feed the result to
package/dist/render.js CHANGED
@@ -119,8 +119,10 @@ function isToken(v) {
119
119
  * no files, and the grab bag - SIL OFL faces the embedder serves itself
120
120
  * (never a third-party CDN: fonts are a tracking vector, care-modes apply).
121
121
  * Values are the `face` presentational attribute - the no-stylesheet floor;
122
- * the mq-font-* class is the ceiling, where the real @font-face lives. */
123
- 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), {
124
126
  // standard stacks
125
127
  sans: "sans-serif",
126
128
  serif: "serif",
@@ -158,7 +160,7 @@ export const FONTS = {
158
160
  rye: "Rye",
159
161
  bitcount: "Bitcount",
160
162
  quicksand: "Quicksand",
161
- };
163
+ });
162
164
  /** Which grab-bag faces does this rendered HTML actually wear? Pure string
163
165
  * scan of the mq-font-* class contract - feed the result to
164
166
  * @cube-drone/marquee-fonts (externalFontFaces / inlineFontFaces) to deliver
@@ -193,12 +195,12 @@ const SCHEME_FONTS = [
193
195
  function sizeRung(value, inner) {
194
196
  return `<font class="mq-size-${value}" size="${value}">${inner}</font>`;
195
197
  }
196
- const MEDIA_SIZE_TOKENS = {
198
+ const MEDIA_SIZE_TOKENS = Object.assign(Object.create(null), {
197
199
  small: "10rem",
198
200
  medium: "20rem",
199
201
  large: "32rem",
200
202
  full: "100%",
201
- };
203
+ });
202
204
  /** Media width/height: a size token or a capped integer of pixels (SPEC.md,
203
205
  * "Media"). Anything else degrades to natural sizing. */
204
206
  function mediaSize(v) {
@@ -274,8 +276,10 @@ function turbolink(target, levelAttr, ctx) {
274
276
  * that encodeURI deliberately leaves are percent-encoded on top, so the
275
277
  * value can never terminate the url() or the declaration - author bytes
276
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;
277
281
  function cssUrl(url) {
278
- return encodeURI(url).replaceAll("'", "%27").replaceAll("(", "%28").replaceAll(")", "%29");
282
+ return encodeURI(url.replace(LONE_SURROGATE, "\uFFFD")).replaceAll("'", "%27").replaceAll("(", "%28").replaceAll(")", "%29");
279
283
  }
280
284
  /** Style knobs on a block node: validated values into --mq-* slots; the
281
285
  * stylesheet owns which CSS property each slot feeds. `background` takes a
@@ -306,9 +310,12 @@ function styleVarList(attrs, profile) {
306
310
  }
307
311
  return vars.join(";");
308
312
  }
309
- function styleVars(attrs, profile) {
310
- const list = styleVarList(attrs, profile);
311
- 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)}"`;
312
319
  }
313
320
  /** The visual identity a layout container (:::page / :::section) wears -
314
321
  * its scheme/font classes and validated style knobs - as bare class/style
@@ -331,14 +338,13 @@ function fontClass(attrs) {
331
338
  }
332
339
  function directive(name, attrs, nodes, ctx) {
333
340
  const { profile } = ctx;
334
- // The profile hook sees the children rendered; built-ins that lay their
335
- // children out themselves (table, stack) render them again, so rewind the
336
- // aside numbering first - or a note inside counts twice.
337
- const saved = { n: ctx.note.n, pending: [...ctx.note.pending] };
338
- const rewind = () => {
339
- ctx.note.n = saved.n;
340
- ctx.note.pending = [...saved.pending];
341
- };
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
+ }
342
348
  const inner = children(nodes, ctx);
343
349
  const custom = profile.directive(name, attrs, inner);
344
350
  if (custom !== null) {
@@ -351,11 +357,11 @@ function directive(name, attrs, nodes, ctx) {
351
357
  return inner;
352
358
  case "page": {
353
359
  const layout = isToken(attrs["layout"]) ? ` mq-layout-${attrs["layout"]}` : "";
354
- 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>`;
355
361
  }
356
362
  case "section": {
357
363
  const slot = isToken(attrs["slot"]) ? ` data-slot="${attrs["slot"]}"` : "";
358
- 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>`;
359
365
  }
360
366
  case "turbolink": {
361
367
  const target = attrs["target"];
@@ -377,16 +383,10 @@ function directive(name, attrs, nodes, ctx) {
377
383
  const style = vars.length === 0 ? "" : ` style="${vars.join(";")}"`;
378
384
  return `<div class="mq-media"${style}>${inner}</div>`;
379
385
  }
380
- case "table":
381
- rewind();
382
- return renderTable(attrs, nodes, ctx);
383
- case "stack":
384
- rewind();
385
- return renderStack(attrs, nodes, ctx);
386
386
  case "layer":
387
387
  // Outside a stack a layer is just a section-like container: its style
388
388
  // knobs apply, place/backing mean nothing.
389
- 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>`;
390
390
  case "center":
391
391
  case "right":
392
392
  case "left":
@@ -482,13 +482,13 @@ const PLACES = new Set([
482
482
  "fill", "top-left", "top", "top-right", "left", "center", "right", "bottom-left", "bottom", "bottom-right",
483
483
  ]);
484
484
  /** The `aspect` vocabulary as CSS aspect-ratio values. */
485
- const STACK_ASPECTS = {
485
+ const STACK_ASPECTS = Object.assign(Object.create(null), {
486
486
  "16:9": "16/9",
487
487
  "4:3": "4/3",
488
488
  "3:2": "3/2",
489
489
  "1:1": "1/1",
490
490
  card: "5/7",
491
- };
491
+ });
492
492
  /** :::stack (SPEC.md, "Stacks"): every direct child is a layer, source order
493
493
  * is z-order. The stylesheet puts every layer in one grid cell, so the box
494
494
  * is as big as its biggest layer needs and paint order is DOM order: no
@@ -528,7 +528,7 @@ function renderLayer(node, ctx) {
528
528
  ? " mq-layer-cover"
529
529
  : "";
530
530
  const inner = children(content, ctx);
531
- 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>`;
532
532
  }
533
533
  /** Effect/typographic spans. The `<font>` tag is deliberate: presentational
534
534
  * attributes are outside CSP's jurisdiction and are implemented by the
@@ -536,19 +536,19 @@ function renderLayer(node, ctx) {
536
536
  * attributes - the floor of the degradation ladder. The --mq-color slot is
537
537
  * the stylesheet-era ceiling on the same element. */
538
538
  function span(name, attrs, nodes, ctx) {
539
- 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
+ }
540
546
  const inner = children(nodes, ctx);
541
547
  const custom = ctx.profile.span(name, attrs, inner);
542
548
  if (custom !== null) {
543
549
  return custom;
544
550
  }
545
- const look = effectLook(name, attrs, nodes);
546
551
  if (look !== null) {
547
- if (look.units !== null) {
548
- // A split renders its children again: rewind the aside numbering.
549
- ctx.note.n = saved.n;
550
- ctx.note.pending = [...saved.pending];
551
- }
552
552
  return renderEffect(name, look, nodes, inner, ctx);
553
553
  }
554
554
  switch (name) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cube-drone/marquee-html-renderer",
3
- "version": "0.9.3",
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,12 +35,12 @@
35
35
  "prepack": "npm run build"
36
36
  },
37
37
  "dependencies": {
38
- "@cube-drone/marquee-parser": "^0.9.3"
38
+ "@cube-drone/marquee-parser": "^0.10.0"
39
39
  },
40
40
  "devDependencies": {
41
- "@cube-drone/marquee-css": "^0.9.3",
42
- "@cube-drone/marquee-fonts": "^0.9.3",
43
- "@cube-drone/marquee-turbolink": "^0.9.3",
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
45
  "saxes": "^6.0.0",
46
46
  "typescript": "^5.9.0"
package/src/profile.ts CHANGED
@@ -76,15 +76,21 @@ export interface Profile {
76
76
 
77
77
  // Mirror of the Rust renderer's table (rust/html_renderer/src/profile.rs). `apng` and
78
78
  // `opus` joined 2026-09-03: ringtome mints both and they rendered as the placeholder.
79
- const MEDIA_KINDS: Record<string, MediaResolution["kind"]> = {
79
+ const MEDIA_KINDS: Record<string, MediaResolution["kind"]> = Object.assign(Object.create(null), {
80
80
  png: "image", jpg: "image", jpeg: "image", gif: "image", webp: "image",
81
81
  avif: "image", apng: "image", svg: "image",
82
82
  mp3: "audio", ogg: "audio", opus: "audio", wav: "audio", flac: "audio", m4a: "audio",
83
83
  mp4: "video", webm: "video",
84
- };
84
+ });
85
+
86
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
85
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.
86
92
  function scheme(target: string): string | null {
87
- const m = /^([A-Za-z][A-Za-z0-9+.-]*):/.exec(target);
93
+ const m = /^ *([A-Za-z][A-Za-z0-9+.-]*):/.exec(target);
88
94
  return m ? m[1]!.toLowerCase() : null;
89
95
  }
90
96
 
@@ -93,6 +99,11 @@ function scheme(target: string): string | null {
93
99
  * degrades inert. */
94
100
  export const bareWebProfile: Profile = {
95
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
+ }
96
107
  const s = scheme(target);
97
108
  return s === null || s === "http" || s === "https" || s === "mailto";
98
109
  },
package/src/render.ts CHANGED
@@ -161,8 +161,10 @@ function isToken(v: string | undefined): v is string {
161
161
  * no files, and the grab bag - SIL OFL faces the embedder serves itself
162
162
  * (never a third-party CDN: fonts are a tracking vector, care-modes apply).
163
163
  * Values are the `face` presentational attribute - the no-stylesheet floor;
164
- * the mq-font-* class is the ceiling, where the real @font-face lives. */
165
- 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), {
166
168
  // standard stacks
167
169
  sans: "sans-serif",
168
170
  serif: "serif",
@@ -200,7 +202,7 @@ export const FONTS: Record<string, string> = {
200
202
  rye: "Rye",
201
203
  bitcount: "Bitcount",
202
204
  quicksand: "Quicksand",
203
- };
205
+ });
204
206
 
205
207
  /** Which grab-bag faces does this rendered HTML actually wear? Pure string
206
208
  * scan of the mq-font-* class contract - feed the result to
@@ -239,12 +241,12 @@ function sizeRung(value: string, inner: string): string {
239
241
  return `<font class="mq-size-${value}" size="${value}">${inner}</font>`;
240
242
  }
241
243
 
242
- const MEDIA_SIZE_TOKENS: Record<string, string> = {
244
+ const MEDIA_SIZE_TOKENS: Record<string, string> = Object.assign(Object.create(null), {
243
245
  small: "10rem",
244
246
  medium: "20rem",
245
247
  large: "32rem",
246
248
  full: "100%",
247
- };
249
+ });
248
250
 
249
251
  /** Media width/height: a size token or a capped integer of pixels (SPEC.md,
250
252
  * "Media"). Anything else degrades to natural sizing. */
@@ -328,8 +330,11 @@ function turbolink(target: string, levelAttr: string | undefined, ctx: Ctx): str
328
330
  * that encodeURI deliberately leaves are percent-encoded on top, so the
329
331
  * value can never terminate the url() or the declaration - author bytes
330
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
+
331
336
  function cssUrl(url: string): string {
332
- return encodeURI(url).replaceAll("'", "%27").replaceAll("(", "%28").replaceAll(")", "%29");
337
+ return encodeURI(url.replace(LONE_SURROGATE, "\uFFFD")).replaceAll("'", "%27").replaceAll("(", "%28").replaceAll(")", "%29");
333
338
  }
334
339
 
335
340
  /** Style knobs on a block node: validated values into --mq-* slots; the
@@ -361,9 +366,12 @@ function styleVarList(attrs: Attrs, profile: Profile): string {
361
366
  return vars.join(";");
362
367
  }
363
368
 
364
- function styleVars(attrs: Attrs, profile: Profile): string {
365
- const list = styleVarList(attrs, profile);
366
- 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)}"`;
367
375
  }
368
376
 
369
377
  /** The visual identity a layout container (:::page / :::section) wears -
@@ -390,14 +398,13 @@ function fontClass(attrs: Attrs): string {
390
398
 
391
399
  function directive(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string {
392
400
  const { profile } = ctx;
393
- // The profile hook sees the children rendered; built-ins that lay their
394
- // children out themselves (table, stack) render them again, so rewind the
395
- // aside numbering first - or a note inside counts twice.
396
- const saved = { n: ctx.note.n, pending: [...ctx.note.pending] };
397
- const rewind = () => {
398
- ctx.note.n = saved.n;
399
- ctx.note.pending = [...saved.pending];
400
- };
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
+ }
401
408
  const inner = children(nodes, ctx);
402
409
  const custom = profile.directive(name, attrs, inner);
403
410
  if (custom !== null) {
@@ -410,11 +417,11 @@ function directive(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string
410
417
  return inner;
411
418
  case "page": {
412
419
  const layout = isToken(attrs["layout"]) ? ` mq-layout-${attrs["layout"]}` : "";
413
- 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>`;
414
421
  }
415
422
  case "section": {
416
423
  const slot = isToken(attrs["slot"]) ? ` data-slot="${attrs["slot"]}"` : "";
417
- 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>`;
418
425
  }
419
426
  case "turbolink": {
420
427
  const target = attrs["target"];
@@ -436,16 +443,10 @@ function directive(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string
436
443
  const style = vars.length === 0 ? "" : ` style="${vars.join(";")}"`;
437
444
  return `<div class="mq-media"${style}>${inner}</div>`;
438
445
  }
439
- case "table":
440
- rewind();
441
- return renderTable(attrs, nodes, ctx);
442
- case "stack":
443
- rewind();
444
- return renderStack(attrs, nodes, ctx);
445
446
  case "layer":
446
447
  // Outside a stack a layer is just a section-like container: its style
447
448
  // knobs apply, place/backing mean nothing.
448
- 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>`;
449
450
  case "center":
450
451
  case "right":
451
452
  case "left":
@@ -545,13 +546,13 @@ const PLACES = new Set([
545
546
  ]);
546
547
 
547
548
  /** The `aspect` vocabulary as CSS aspect-ratio values. */
548
- const STACK_ASPECTS: Record<string, string> = {
549
+ const STACK_ASPECTS: Record<string, string> = Object.assign(Object.create(null), {
549
550
  "16:9": "16/9",
550
551
  "4:3": "4/3",
551
552
  "3:2": "3/2",
552
553
  "1:1": "1/1",
553
554
  card: "5/7",
554
- };
555
+ });
555
556
 
556
557
  /** :::stack (SPEC.md, "Stacks"): every direct child is a layer, source order
557
558
  * is z-order. The stylesheet puts every layer in one grid cell, so the box
@@ -594,7 +595,7 @@ function renderLayer(node: Node, ctx: Ctx): string {
594
595
  ? " mq-layer-cover"
595
596
  : "";
596
597
  const inner = children(content, ctx);
597
- 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>`;
598
599
  }
599
600
 
600
601
  /** Effect/typographic spans. The `<font>` tag is deliberate: presentational
@@ -603,19 +604,19 @@ function renderLayer(node: Node, ctx: Ctx): string {
603
604
  * attributes - the floor of the degradation ladder. The --mq-color slot is
604
605
  * the stylesheet-era ceiling on the same element. */
605
606
  function span(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string {
606
- 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
+ }
607
614
  const inner = children(nodes, ctx);
608
615
  const custom = ctx.profile.span(name, attrs, inner);
609
616
  if (custom !== null) {
610
617
  return custom;
611
618
  }
612
- const look = effectLook(name, attrs, nodes);
613
619
  if (look !== null) {
614
- if (look.units !== null) {
615
- // A split renders its children again: rewind the aside numbering.
616
- ctx.note.n = saved.n;
617
- ctx.note.pending = [...saved.pending];
618
- }
619
620
  return renderEffect(name, look, nodes, inner, ctx);
620
621
  }
621
622
  switch (name) {