@cube-drone/marquee-html-renderer 0.9.0 → 0.9.1

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/render.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // vocabulary shrugs (children survive, effect doesn't); comments render
5
5
  // nothing; invalid constructs render inert placeholders.
6
6
  import { bareWebProfile } from "./profile.js";
7
+ import { effectLook, splitPieces } from "./effects.js";
7
8
  export function render(node, profile = bareWebProfile) {
8
9
  return renderNode(node, { profile, note: { n: 0, pending: [] } });
9
10
  }
@@ -154,8 +155,24 @@ export function usedFontTokens(html) {
154
155
  for (const m of html.matchAll(/mq-font-([a-z0-9-]+)/g)) {
155
156
  used.add(m[1]);
156
157
  }
158
+ // A scheme that wears a grab-bag face of its own needs that face shipped.
159
+ for (const m of html.matchAll(/mq-scheme-([a-z0-9-]+)/g)) {
160
+ for (const [scheme, token] of SCHEME_FONTS) {
161
+ if (m[1] === scheme || m[1].startsWith(`${scheme}-`)) {
162
+ used.add(token);
163
+ }
164
+ }
165
+ }
157
166
  return [...used].sort();
158
167
  }
168
+ /** Schemes (and their `-flavor` variants) that wear a grab-bag face by
169
+ * default. Kept in lockstep with marquee.css and the Rust renderer. */
170
+ const SCHEME_FONTS = [
171
+ ["earthbound", "press-start"],
172
+ ["crosscode", "vt323"],
173
+ ["ff6", "vt323"],
174
+ ["chronotrigger", "vt323"],
175
+ ];
159
176
  /** One rung of the font-element seven-step dial: presentational floor
160
177
  * (works with no stylesheet, under any CSP), stylesheet class as ceiling.
161
178
  * The named rungs - teeny, tiny, huge, enormous - are unnecessary
@@ -266,6 +283,11 @@ function styleVarList(attrs, profile) {
266
283
  else if (isColorValue(bg)) {
267
284
  vars.push(`--mq-bg:${bg}`);
268
285
  }
286
+ // A scheme's signature color - the thing its game let you customize (an
287
+ // EarthBound frame, an FF6 window). Schemes without one ignore it.
288
+ if (isColorValue(attrs["accent"])) {
289
+ vars.push(`--mq-accent:${attrs["accent"]}`);
290
+ }
269
291
  return vars.join(";");
270
292
  }
271
293
  function styleVars(attrs, profile) {
@@ -293,6 +315,14 @@ function fontClass(attrs) {
293
315
  }
294
316
  function directive(name, attrs, nodes, ctx) {
295
317
  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
+ };
296
326
  const inner = children(nodes, ctx);
297
327
  const custom = profile.directive(name, attrs, inner);
298
328
  if (custom !== null) {
@@ -332,7 +362,15 @@ function directive(name, attrs, nodes, ctx) {
332
362
  return `<div class="mq-media"${style}>${inner}</div>`;
333
363
  }
334
364
  case "table":
365
+ rewind();
335
366
  return renderTable(attrs, nodes, ctx);
367
+ case "stack":
368
+ rewind();
369
+ return renderStack(attrs, nodes, ctx);
370
+ case "layer":
371
+ // Outside a stack a layer is just a section-like container: its style
372
+ // knobs apply, place/backing mean nothing.
373
+ return `<div class="mq-layer${schemeClass(attrs)}${fontClass(attrs)}"${styleVars(attrs, profile)}>${inner}</div>`;
336
374
  case "center":
337
375
  case "right":
338
376
  case "left":
@@ -422,17 +460,81 @@ function renderTable(attrs, nodes, ctx) {
422
460
  // Cell sidenotes land just below the table, like a paragraph's would.
423
461
  return flushNotes(ctx, `<table class="mq-table">${rows.join("")}</table>`);
424
462
  }
463
+ /** A stack paints at most this many layers; the rest flow below the box. */
464
+ const MAX_LAYERS = 8;
465
+ const PLACES = new Set([
466
+ "fill", "top-left", "top", "top-right", "left", "center", "right", "bottom-left", "bottom", "bottom-right",
467
+ ]);
468
+ /** The `aspect` vocabulary as CSS aspect-ratio values. */
469
+ const STACK_ASPECTS = {
470
+ "16:9": "16/9",
471
+ "4:3": "4/3",
472
+ "3:2": "3/2",
473
+ "1:1": "1/1",
474
+ card: "5/7",
475
+ };
476
+ /** :::stack (SPEC.md, "Stacks"): every direct child is a layer, source order
477
+ * is z-order. The stylesheet puts every layer in one grid cell, so the box
478
+ * is as big as its biggest layer needs and paint order is DOM order: no
479
+ * z-index, no absolute positioning, nothing clipped. Layers past the cap
480
+ * render in flow below the box. */
481
+ function renderStack(attrs, nodes, ctx) {
482
+ const vars = [];
483
+ const aspect = Object.hasOwn(STACK_ASPECTS, attrs["aspect"] ?? "") ? STACK_ASPECTS[attrs["aspect"]] : undefined;
484
+ if (aspect !== undefined) {
485
+ vars.push(`--mq-stack-aspect:${aspect}`);
486
+ }
487
+ const w = mediaSize(attrs["width"]);
488
+ if (w !== null) {
489
+ vars.push(`--mq-stack-w:${w}`);
490
+ }
491
+ const style = vars.length === 0 ? "" : ` style="${vars.join(";")}"`;
492
+ const layers = nodes.slice(0, MAX_LAYERS).map((n) => renderLayer(n, ctx)).join("");
493
+ const rest = nodes.slice(MAX_LAYERS);
494
+ const restHtml = rest.length === 0 ? "" : `<div class="mq-stack-rest">${children(rest, ctx)}</div>`;
495
+ // The class tells the stylesheet a cover base fills the box rather than
496
+ // sizing it.
497
+ const cls = aspect === undefined ? "mq-stack" : "mq-stack mq-stack-aspect";
498
+ return `<div class="${cls}"${style}>${layers}</div>${restHtml}`;
499
+ }
500
+ /** One layer of a stack: a `:::layer` child carries knobs; any other child
501
+ * block is a layer with the defaults (`fill`, no backing). */
502
+ function renderLayer(node, ctx) {
503
+ const isLayer = node.type === "directive" && node.name === "layer";
504
+ const attrs = isLayer ? node.attrs : {};
505
+ const content = isLayer ? node.children : [node];
506
+ const place = PLACES.has(attrs["place"] ?? "") ? attrs["place"] : "fill";
507
+ const backing = attrs["backing"] === "box" || attrs["backing"] === "outline" ? ` mq-backing-${attrs["backing"]}` : "";
508
+ // A fill layer that is exactly one embed is that media covering the box -
509
+ // the one crop the spec permits, of pictures, never of words.
510
+ const only = content.length === 1 ? content[0] : undefined;
511
+ const cover = place === "fill" && only?.type === "paragraph" && only.children.length === 1 && only.children[0].type === "embed"
512
+ ? " mq-layer-cover"
513
+ : "";
514
+ 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>`;
516
+ }
425
517
  /** Effect/typographic spans. The `<font>` tag is deliberate: presentational
426
518
  * attributes are outside CSP's jurisdiction and are implemented by the
427
519
  * browser itself, so color survives with no stylesheet and no style
428
520
  * attributes - the floor of the degradation ladder. The --mq-color slot is
429
521
  * the stylesheet-era ceiling on the same element. */
430
522
  function span(name, attrs, nodes, ctx) {
523
+ const saved = { n: ctx.note.n, pending: [...ctx.note.pending] };
431
524
  const inner = children(nodes, ctx);
432
525
  const custom = ctx.profile.span(name, attrs, inner);
433
526
  if (custom !== null) {
434
527
  return custom;
435
528
  }
529
+ const look = effectLook(name, attrs, nodes);
530
+ 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
+ return renderEffect(name, look, nodes, inner, ctx);
537
+ }
436
538
  switch (name) {
437
539
  case "sup":
438
540
  return `<sup>${inner}</sup>`;
@@ -492,194 +594,44 @@ function span(name, attrs, nodes, ctx) {
492
594
  ctx.note.pending.push(`<span class="mq-note-num">${ctx.note.n}</span>${inner}`);
493
595
  return `<sup class="mq-noteref">${ctx.note.n}</sup>`;
494
596
  }
495
- case "marquee": {
496
- const dir = isToken(attrs["direction"]) ? ` data-direction="${attrs["direction"]}"` : "";
497
- const speed = attrs["speed"] !== undefined && COUNT.test(attrs["speed"])
498
- ? ` style="--mq-speed:${attrs["speed"]}"`
499
- : "";
500
- return `<span class="mq-marquee"${dir}${speed}><span class="mq-marquee-inner">${inner}</span></span>`;
501
- }
502
- case "blink": {
503
- const rate = attrs["rate"] !== undefined && COUNT.test(attrs["rate"])
504
- ? ` style="--mq-rate:${attrs["rate"]}"`
505
- : "";
506
- if (attrs["by"] === "letter" || attrs["by"] === "word") {
507
- // Split blink: ramp is theater-marquee chase lights, scatter is
508
- // twinkle. The rate var rides the container; units inherit it.
509
- return bySegments(name, attrs["by"], attrs["phase"], nodes, ctx, rate);
510
- }
511
- return `<span class="mq-blink"${rate}>${inner}</span>`;
512
- }
513
- case "rainbow":
514
- case "bounce":
515
- case "jitter":
516
- case "wave":
517
- case "rubber":
518
- if (attrs["by"] === "letter" || attrs["by"] === "word") {
519
- return bySegments(name, attrs["by"], attrs["phase"], nodes, ctx);
520
- }
521
- return `<span class="mq-${name}">${inner}</span>`;
522
- case "typewriter": {
523
- // Inherently per-unit: the reveal IS a by=letter effect (by=word for
524
- // word-at-a-time). speed= is units per second; the container carries
525
- // the per-unit delay step, each unit its ordinal in --mq-o.
526
- const by = attrs["by"] === "word" ? "word" : "letter";
527
- const step = revealStep(attrs["speed"], 14);
528
- return bySegments(name, by, attrs["phase"], nodes, ctx, ` style="--mq-tw-step:${step}s"`);
529
- }
530
- case "fadein": {
531
- // The ghostly reveal. Bare [fadein] fades the whole run in once;
532
- // by=letter / by=word drift units in on staggered starts (same
533
- // one-shot family as typewriter: sequential ordinals, high cap);
534
- // phase=scatter is apparition weather.
535
- if (attrs["by"] === "letter" || attrs["by"] === "word") {
536
- const step = revealStep(attrs["speed"], 16);
537
- return bySegments(name, attrs["by"], attrs["phase"], nodes, ctx, ` style="--mq-fi-step:${step}s"`);
538
- }
539
- return `<span class="mq-fadein">${inner}</span>`;
540
- }
541
597
  }
542
598
  return inner; // unknown span: pure shrug, children as plain content
543
599
  }
544
- // -- per-unit effects (by=letter / by=word): each unit in its own span with
545
- // a phase offset in --mq-o; the stylesheet replays the effect's keyframes
546
- // through a negative animation-delay. Pure markup - the document stays a
547
- // document. Locale pinned so segmentation (and thus output) is stable.
548
- const SEGMENTERS = {
549
- letter: new Intl.Segmenter("en", { granularity: "grapheme" }),
550
- word: new Intl.Segmenter("en", { granularity: "word" }),
551
- };
552
- /** DOM-weight discipline: past this many units, the run animates whole.
553
- * Loopers pay a live animation per element forever, so they cap low;
554
- * typewriter's units are 1ms one-shots (finished animations cost nothing)
555
- * and its natural material is long text, so it caps high. */
556
- const MAX_SPLIT_UNITS = 400;
557
- const MAX_REVEAL_UNITS = 2000;
558
- function bySegments(effect, by, phaseAttr, nodes, ctx, containerStyle = "") {
559
- // Each effect has a natural phase order (jitter scatters, the rest sweep);
560
- // the knob overrides it either way. Invalid values degrade to the default.
561
- const phase = phaseAttr === "scatter" || phaseAttr === "ramp"
562
- ? phaseAttr
563
- : effect === "jitter"
564
- ? "scatter"
565
- : "ramp";
566
- const state = { effect, by, phase, i: 0, total: 0 };
567
- state.total = countUnits(nodes, by);
568
- const cap = isReveal(effect) ? MAX_REVEAL_UNITS : MAX_SPLIT_UNITS;
569
- if (state.total === 0 || state.total > cap) {
570
- return `<span class="mq-${effect}">${children(nodes, ctx)}</span>`;
600
+ // -- effects: the look (classes, knobs, per-unit split) comes from
601
+ // effects.ts, shared with every other surface that draws effects.
602
+ 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(";")}"`;
605
+ if (name === "marquee") {
606
+ return `<span class="${look.className}"${data}${style}><span class="mq-marquee-inner">${inner}</span></span>`;
571
607
  }
572
- return `<span class="mq-${effect} mq-split"${containerStyle}>${splitRender(nodes, ctx, state)}</span>`;
573
- }
574
- /** A segment gets wrapped if it's animatable: for words, word-like segments
575
- * (spaces and bare punctuation ride along); for letters, anything that
576
- * isn't whitespace. */
577
- function isUnit(seg, by) {
578
- return by === "word" ? seg.isWordLike === true : !/^\s+$/.test(seg.segment);
579
- }
580
- /** Offsets are deterministic (goldens exist; a document renders the same
581
- * twice) in both phase orders: ramp sweeps, scatter scrambles by a fixed
582
- * integer hash - randomness-shaped, never random. */
583
- function unitOffset(state) {
584
- // Reveal offsets (typewriter, fadein) are sequential INTEGERS (the
585
- // ordinal; delay = ordinal x step), unlike the cyclic 0..1 fractions the
586
- // looping effects replay. phase=scatter reveals in a
587
- // scrambled-but-deterministic order: a stride at the run's golden-ratio
588
- // point, nudged coprime with the total, walks a well-spread permutation
589
- // at EVERY length. (A fixed prime stride is a trap: 7919 mod 40 = 39 =
590
- // -1, so forty-unit runs typed in backwards.)
591
- if (isReveal(state.effect)) {
592
- const o = state.phase === "scatter"
593
- ? (state.i * scatterStride(state.total)) % state.total
594
- : state.i;
595
- return String(o);
596
- }
597
- let o;
598
- if (state.phase === "scatter") {
599
- o = ((state.i * 7919) % 101) / 101;
600
- }
601
- else {
602
- switch (state.effect) {
603
- case "rainbow":
604
- o = state.i / state.total; // gradient across the whole run
605
- break;
606
- case "wave":
607
- o = (state.i % 8) / 8; // fixed ripple wavelength
608
- break;
609
- case "bounce":
610
- o = (state.i % 6) / 6;
611
- break;
612
- default:
613
- o = (state.i % 8) / 8; // jitter in ramp mode: a rippling shudder
614
- break;
615
- }
616
- }
617
- return String(Math.round(o * 1000) / 1000);
618
- }
619
- /** speed= (units per second, a COUNT) into a per-unit delay step in
620
- * seconds; invalid or absent falls to the effect's default rate. */
621
- function revealStep(speedAttr, dflt) {
622
- const speed = speedAttr !== undefined && COUNT.test(speedAttr) && Number(speedAttr) > 0
623
- ? Number(speedAttr)
624
- : dflt;
625
- return Math.round(1000 / speed) / 1000;
626
- }
627
- /** The one-shot reveals: sequential ordinals, the high unit cap. */
628
- function isReveal(effect) {
629
- return effect === "typewriter" || effect === "fadein";
630
- }
631
- function gcd(a, b) {
632
- return b === 0 ? a : gcd(b, a % b);
633
- }
634
- /** The smallest stride >= ~61.8% of total that's coprime with it (falls
635
- * back to 1 for degenerate totals - a 1- or 2-unit "scramble" is fate). */
636
- function scatterStride(total) {
637
- let stride = Math.max(1, Math.round(total * 0.618));
638
- while (stride < total && gcd(stride, total) !== 1) {
639
- stride += 1;
640
- }
641
- return gcd(stride, total) === 1 ? stride : 1;
642
- }
643
- function countUnits(nodes, by) {
644
- let n = 0;
645
- for (const node of nodes) {
646
- if (node.type === "text") {
647
- for (const seg of SEGMENTERS[by].segment(node.value)) {
648
- if (isUnit(seg, by)) {
649
- n += 1;
650
- }
651
- }
652
- }
653
- else if (node.type === "emphasis" ||
654
- node.type === "strong" ||
655
- node.type === "strikethrough") {
656
- n += countUnits(node.children, by);
657
- }
658
- }
659
- return n;
660
- }
661
- function splitRender(nodes, ctx, state) {
608
+ const body = look.units === null ? inner : splitRender(nodes, ctx, look);
609
+ return `<span class="${look.className}"${data}${style}>${body}</span>`;
610
+ }
611
+ /** Each unit in its own span with its phase offset; spaces and punctuation
612
+ * between units ride along as plain text; each word's units sit in a group
613
+ * that wraps as one. */
614
+ function splitRender(nodes, ctx, look) {
615
+ const pieces = (list) => list
616
+ .map((p) => p.kind === "text"
617
+ ? escapeText(p.text)
618
+ : p.kind === "unit"
619
+ ? `<span class="mq-l" style="--mq-o:${p.offset}">${escapeText(p.text)}</span>`
620
+ : `<span class="mq-w">${pieces(p.pieces)}</span>`)
621
+ .join("");
662
622
  let out = "";
663
623
  for (const node of nodes) {
664
624
  if (node.type === "text") {
665
- for (const seg of SEGMENTERS[state.by].segment(node.value)) {
666
- if (!isUnit(seg, state.by)) {
667
- out += escapeText(seg.segment); // spaces/punctuation ride along
668
- continue;
669
- }
670
- const o = unitOffset(state);
671
- state.i += 1;
672
- out += `<span class="mq-l" style="--mq-o:${o}">${escapeText(seg.segment)}</span>`;
673
- }
625
+ out += pieces(splitPieces(node, look));
674
626
  }
675
627
  else if (node.type === "emphasis") {
676
- out += `<em>${splitRender(node.children, ctx, state)}</em>`;
628
+ out += `<em>${splitRender(node.children, ctx, look)}</em>`;
677
629
  }
678
630
  else if (node.type === "strong") {
679
- out += `<strong>${splitRender(node.children, ctx, state)}</strong>`;
631
+ out += `<strong>${splitRender(node.children, ctx, look)}</strong>`;
680
632
  }
681
633
  else if (node.type === "strikethrough") {
682
- out += `<del>${splitRender(node.children, ctx, state)}</del>`;
634
+ out += `<del>${splitRender(node.children, ctx, look)}</del>`;
683
635
  }
684
636
  else {
685
637
  out += renderNode(node, ctx); // anything else renders whole, un-split
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cube-drone/marquee-html-renderer",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
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.0"
38
+ "@cube-drone/marquee-parser": "^0.9.1"
39
39
  },
40
40
  "devDependencies": {
41
- "@cube-drone/marquee-css": "^0.9.0",
42
- "@cube-drone/marquee-fonts": "^0.9.0",
43
- "@cube-drone/marquee-turbolink": "^0.9.0",
41
+ "@cube-drone/marquee-css": "^0.9.1",
42
+ "@cube-drone/marquee-fonts": "^0.9.1",
43
+ "@cube-drone/marquee-turbolink": "^0.9.1",
44
44
  "@types/node": "^24.0.0",
45
45
  "typescript": "^5.9.0"
46
46
  }