@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/src/render.ts CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type { Attrs, Node } from "@cube-drone/marquee-parser";
8
8
  import { bareWebProfile, type Profile, type TurbolinkLevel } from "./profile.ts";
9
+ import { effectLook, splitPieces, type EffectLook, type SplitPiece } from "./effects.ts";
9
10
 
10
11
  /** Render state: the profile, plus the one piece of cross-block
11
12
  * coordination the renderer owns - aside numbering (sequential through the
@@ -175,9 +176,26 @@ export function usedFontTokens(html: string): string[] {
175
176
  for (const m of html.matchAll(/mq-font-([a-z0-9-]+)/g)) {
176
177
  used.add(m[1]!);
177
178
  }
179
+ // A scheme that wears a grab-bag face of its own needs that face shipped.
180
+ for (const m of html.matchAll(/mq-scheme-([a-z0-9-]+)/g)) {
181
+ for (const [scheme, token] of SCHEME_FONTS) {
182
+ if (m[1] === scheme || m[1]!.startsWith(`${scheme}-`)) {
183
+ used.add(token);
184
+ }
185
+ }
186
+ }
178
187
  return [...used].sort();
179
188
  }
180
189
 
190
+ /** Schemes (and their `-flavor` variants) that wear a grab-bag face by
191
+ * default. Kept in lockstep with marquee.css and the Rust renderer. */
192
+ const SCHEME_FONTS: [string, string][] = [
193
+ ["earthbound", "press-start"],
194
+ ["crosscode", "vt323"],
195
+ ["ff6", "vt323"],
196
+ ["chronotrigger", "vt323"],
197
+ ];
198
+
181
199
  /** One rung of the font-element seven-step dial: presentational floor
182
200
  * (works with no stylesheet, under any CSP), stylesheet class as ceiling.
183
201
  * The named rungs - teeny, tiny, huge, enormous - are unnecessary
@@ -297,6 +315,11 @@ function styleVarList(attrs: Attrs, profile: Profile): string {
297
315
  } else if (isColorValue(bg)) {
298
316
  vars.push(`--mq-bg:${bg}`);
299
317
  }
318
+ // A scheme's signature color - the thing its game let you customize (an
319
+ // EarthBound frame, an FF6 window). Schemes without one ignore it.
320
+ if (isColorValue(attrs["accent"])) {
321
+ vars.push(`--mq-accent:${attrs["accent"]}`);
322
+ }
300
323
  return vars.join(";");
301
324
  }
302
325
 
@@ -329,6 +352,14 @@ function fontClass(attrs: Attrs): string {
329
352
 
330
353
  function directive(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string {
331
354
  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
+ };
332
363
  const inner = children(nodes, ctx);
333
364
  const custom = profile.directive(name, attrs, inner);
334
365
  if (custom !== null) {
@@ -368,7 +399,15 @@ function directive(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string
368
399
  return `<div class="mq-media"${style}>${inner}</div>`;
369
400
  }
370
401
  case "table":
402
+ rewind();
371
403
  return renderTable(attrs, nodes, ctx);
404
+ case "stack":
405
+ rewind();
406
+ return renderStack(attrs, nodes, ctx);
407
+ case "layer":
408
+ // Outside a stack a layer is just a section-like container: its style
409
+ // knobs apply, place/backing mean nothing.
410
+ return `<div class="mq-layer${schemeClass(attrs)}${fontClass(attrs)}"${styleVars(attrs, profile)}>${inner}</div>`;
372
411
  case "center":
373
412
  case "right":
374
413
  case "left":
@@ -460,17 +499,87 @@ function renderTable(attrs: Attrs, nodes: Node[], ctx: Ctx): string {
460
499
  return flushNotes(ctx, `<table class="mq-table">${rows.join("")}</table>`);
461
500
  }
462
501
 
502
+ /** A stack paints at most this many layers; the rest flow below the box. */
503
+ const MAX_LAYERS = 8;
504
+
505
+ const PLACES = new Set([
506
+ "fill", "top-left", "top", "top-right", "left", "center", "right", "bottom-left", "bottom", "bottom-right",
507
+ ]);
508
+
509
+ /** The `aspect` vocabulary as CSS aspect-ratio values. */
510
+ const STACK_ASPECTS: Record<string, string> = {
511
+ "16:9": "16/9",
512
+ "4:3": "4/3",
513
+ "3:2": "3/2",
514
+ "1:1": "1/1",
515
+ card: "5/7",
516
+ };
517
+
518
+ /** :::stack (SPEC.md, "Stacks"): every direct child is a layer, source order
519
+ * is z-order. The stylesheet puts every layer in one grid cell, so the box
520
+ * is as big as its biggest layer needs and paint order is DOM order: no
521
+ * z-index, no absolute positioning, nothing clipped. Layers past the cap
522
+ * render in flow below the box. */
523
+ function renderStack(attrs: Attrs, nodes: Node[], ctx: Ctx): string {
524
+ const vars: string[] = [];
525
+ const aspect = Object.hasOwn(STACK_ASPECTS, attrs["aspect"] ?? "") ? STACK_ASPECTS[attrs["aspect"]!] : undefined;
526
+ if (aspect !== undefined) {
527
+ vars.push(`--mq-stack-aspect:${aspect}`);
528
+ }
529
+ const w = mediaSize(attrs["width"]);
530
+ if (w !== null) {
531
+ vars.push(`--mq-stack-w:${w}`);
532
+ }
533
+ const style = vars.length === 0 ? "" : ` style="${vars.join(";")}"`;
534
+ const layers = nodes.slice(0, MAX_LAYERS).map((n) => renderLayer(n, ctx)).join("");
535
+ const rest = nodes.slice(MAX_LAYERS);
536
+ const restHtml = rest.length === 0 ? "" : `<div class="mq-stack-rest">${children(rest, ctx)}</div>`;
537
+ // The class tells the stylesheet a cover base fills the box rather than
538
+ // sizing it.
539
+ const cls = aspect === undefined ? "mq-stack" : "mq-stack mq-stack-aspect";
540
+ return `<div class="${cls}"${style}>${layers}</div>${restHtml}`;
541
+ }
542
+
543
+ /** One layer of a stack: a `:::layer` child carries knobs; any other child
544
+ * block is a layer with the defaults (`fill`, no backing). */
545
+ function renderLayer(node: Node, ctx: Ctx): string {
546
+ const isLayer = node.type === "directive" && node.name === "layer";
547
+ const attrs: Attrs = isLayer ? node.attrs : {};
548
+ const content: Node[] = isLayer ? node.children : [node];
549
+ const place = PLACES.has(attrs["place"] ?? "") ? attrs["place"]! : "fill";
550
+ const backing = attrs["backing"] === "box" || attrs["backing"] === "outline" ? ` mq-backing-${attrs["backing"]}` : "";
551
+ // A fill layer that is exactly one embed is that media covering the box -
552
+ // the one crop the spec permits, of pictures, never of words.
553
+ const only = content.length === 1 ? content[0]! : undefined;
554
+ const cover =
555
+ place === "fill" && only?.type === "paragraph" && only.children.length === 1 && only.children[0]!.type === "embed"
556
+ ? " mq-layer-cover"
557
+ : "";
558
+ 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>`;
560
+ }
561
+
463
562
  /** Effect/typographic spans. The `<font>` tag is deliberate: presentational
464
563
  * attributes are outside CSP's jurisdiction and are implemented by the
465
564
  * browser itself, so color survives with no stylesheet and no style
466
565
  * attributes - the floor of the degradation ladder. The --mq-color slot is
467
566
  * the stylesheet-era ceiling on the same element. */
468
567
  function span(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string {
568
+ const saved = { n: ctx.note.n, pending: [...ctx.note.pending] };
469
569
  const inner = children(nodes, ctx);
470
570
  const custom = ctx.profile.span(name, attrs, inner);
471
571
  if (custom !== null) {
472
572
  return custom;
473
573
  }
574
+ const look = effectLook(name, attrs, nodes);
575
+ 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
+ return renderEffect(name, look, nodes, inner, ctx);
582
+ }
474
583
  switch (name) {
475
584
  case "sup":
476
585
  return `<sup>${inner}</sup>`;
@@ -530,225 +639,47 @@ function span(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string {
530
639
  ctx.note.pending.push(`<span class="mq-note-num">${ctx.note.n}</span>${inner}`);
531
640
  return `<sup class="mq-noteref">${ctx.note.n}</sup>`;
532
641
  }
533
- case "marquee": {
534
- const dir = isToken(attrs["direction"]) ? ` data-direction="${attrs["direction"]}"` : "";
535
- const speed = attrs["speed"] !== undefined && COUNT.test(attrs["speed"])
536
- ? ` style="--mq-speed:${attrs["speed"]}"`
537
- : "";
538
- return `<span class="mq-marquee"${dir}${speed}><span class="mq-marquee-inner">${inner}</span></span>`;
539
- }
540
- case "blink": {
541
- const rate = attrs["rate"] !== undefined && COUNT.test(attrs["rate"])
542
- ? ` style="--mq-rate:${attrs["rate"]}"`
543
- : "";
544
- if (attrs["by"] === "letter" || attrs["by"] === "word") {
545
- // Split blink: ramp is theater-marquee chase lights, scatter is
546
- // twinkle. The rate var rides the container; units inherit it.
547
- return bySegments(name, attrs["by"], attrs["phase"], nodes, ctx, rate);
548
- }
549
- return `<span class="mq-blink"${rate}>${inner}</span>`;
550
- }
551
- case "rainbow":
552
- case "bounce":
553
- case "jitter":
554
- case "wave":
555
- case "rubber":
556
- if (attrs["by"] === "letter" || attrs["by"] === "word") {
557
- return bySegments(name, attrs["by"], attrs["phase"], nodes, ctx);
558
- }
559
- return `<span class="mq-${name}">${inner}</span>`;
560
- case "typewriter": {
561
- // Inherently per-unit: the reveal IS a by=letter effect (by=word for
562
- // word-at-a-time). speed= is units per second; the container carries
563
- // the per-unit delay step, each unit its ordinal in --mq-o.
564
- const by = attrs["by"] === "word" ? "word" : "letter";
565
- const step = revealStep(attrs["speed"], 14);
566
- return bySegments(name, by, attrs["phase"], nodes, ctx, ` style="--mq-tw-step:${step}s"`);
567
- }
568
- case "fadein": {
569
- // The ghostly reveal. Bare [fadein] fades the whole run in once;
570
- // by=letter / by=word drift units in on staggered starts (same
571
- // one-shot family as typewriter: sequential ordinals, high cap);
572
- // phase=scatter is apparition weather.
573
- if (attrs["by"] === "letter" || attrs["by"] === "word") {
574
- const step = revealStep(attrs["speed"], 16);
575
- return bySegments(name, attrs["by"], attrs["phase"], nodes, ctx, ` style="--mq-fi-step:${step}s"`);
576
- }
577
- return `<span class="mq-fadein">${inner}</span>`;
578
- }
579
642
  }
580
643
  return inner; // unknown span: pure shrug, children as plain content
581
644
  }
582
645
 
583
- // -- per-unit effects (by=letter / by=word): each unit in its own span with
584
- // a phase offset in --mq-o; the stylesheet replays the effect's keyframes
585
- // through a negative animation-delay. Pure markup - the document stays a
586
- // document. Locale pinned so segmentation (and thus output) is stable.
587
-
588
- const SEGMENTERS = {
589
- letter: new Intl.Segmenter("en", { granularity: "grapheme" }),
590
- word: new Intl.Segmenter("en", { granularity: "word" }),
591
- };
592
-
593
- type SplitBy = keyof typeof SEGMENTERS;
594
-
595
- /** DOM-weight discipline: past this many units, the run animates whole.
596
- * Loopers pay a live animation per element forever, so they cap low;
597
- * typewriter's units are 1ms one-shots (finished animations cost nothing)
598
- * and its natural material is long text, so it caps high. */
599
- const MAX_SPLIT_UNITS = 400;
600
- const MAX_REVEAL_UNITS = 2000;
601
-
602
- type Phase = "ramp" | "scatter";
603
-
604
- interface SplitState {
605
- effect: string;
606
- by: SplitBy;
607
- phase: Phase;
608
- i: number;
609
- total: number;
610
- }
646
+ // -- effects: the look (classes, knobs, per-unit split) comes from
647
+ // effects.ts, shared with every other surface that draws effects.
611
648
 
612
- function bySegments(
613
- effect: string,
614
- by: SplitBy,
615
- phaseAttr: string | undefined,
616
- nodes: Node[],
617
- ctx: Ctx,
618
- containerStyle = "",
619
- ): string {
620
- // Each effect has a natural phase order (jitter scatters, the rest sweep);
621
- // the knob overrides it either way. Invalid values degrade to the default.
622
- const phase: Phase =
623
- phaseAttr === "scatter" || phaseAttr === "ramp"
624
- ? phaseAttr
625
- : effect === "jitter"
626
- ? "scatter"
627
- : "ramp";
628
- const state: SplitState = { effect, by, phase, i: 0, total: 0 };
629
- state.total = countUnits(nodes, by);
630
- const cap = isReveal(effect) ? MAX_REVEAL_UNITS : MAX_SPLIT_UNITS;
631
- if (state.total === 0 || state.total > cap) {
632
- return `<span class="mq-${effect}">${children(nodes, ctx)}</span>`;
649
+ 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(";")}"`;
652
+ if (name === "marquee") {
653
+ return `<span class="${look.className}"${data}${style}><span class="mq-marquee-inner">${inner}</span></span>`;
633
654
  }
634
- return `<span class="mq-${effect} mq-split"${containerStyle}>${splitRender(nodes, ctx, state)}</span>`;
635
- }
636
-
637
- /** A segment gets wrapped if it's animatable: for words, word-like segments
638
- * (spaces and bare punctuation ride along); for letters, anything that
639
- * isn't whitespace. */
640
- function isUnit(seg: Intl.SegmentData, by: SplitBy): boolean {
641
- return by === "word" ? seg.isWordLike === true : !/^\s+$/.test(seg.segment);
642
- }
643
-
644
- /** Offsets are deterministic (goldens exist; a document renders the same
645
- * twice) in both phase orders: ramp sweeps, scatter scrambles by a fixed
646
- * integer hash - randomness-shaped, never random. */
647
- function unitOffset(state: SplitState): string {
648
- // Reveal offsets (typewriter, fadein) are sequential INTEGERS (the
649
- // ordinal; delay = ordinal x step), unlike the cyclic 0..1 fractions the
650
- // looping effects replay. phase=scatter reveals in a
651
- // scrambled-but-deterministic order: a stride at the run's golden-ratio
652
- // point, nudged coprime with the total, walks a well-spread permutation
653
- // at EVERY length. (A fixed prime stride is a trap: 7919 mod 40 = 39 =
654
- // -1, so forty-unit runs typed in backwards.)
655
- if (isReveal(state.effect)) {
656
- const o =
657
- state.phase === "scatter"
658
- ? (state.i * scatterStride(state.total)) % state.total
659
- : state.i;
660
- return String(o);
661
- }
662
- let o: number;
663
- if (state.phase === "scatter") {
664
- o = ((state.i * 7919) % 101) / 101;
665
- } else {
666
- switch (state.effect) {
667
- case "rainbow":
668
- o = state.i / state.total; // gradient across the whole run
669
- break;
670
- case "wave":
671
- o = (state.i % 8) / 8; // fixed ripple wavelength
672
- break;
673
- case "bounce":
674
- o = (state.i % 6) / 6;
675
- break;
676
- default:
677
- o = (state.i % 8) / 8; // jitter in ramp mode: a rippling shudder
678
- break;
679
- }
680
- }
681
- return String(Math.round(o * 1000) / 1000);
682
- }
683
-
684
- /** speed= (units per second, a COUNT) into a per-unit delay step in
685
- * seconds; invalid or absent falls to the effect's default rate. */
686
- function revealStep(speedAttr: string | undefined, dflt: number): number {
687
- const speed =
688
- speedAttr !== undefined && COUNT.test(speedAttr) && Number(speedAttr) > 0
689
- ? Number(speedAttr)
690
- : dflt;
691
- return Math.round(1000 / speed) / 1000;
692
- }
693
-
694
- /** The one-shot reveals: sequential ordinals, the high unit cap. */
695
- function isReveal(effect: string): boolean {
696
- return effect === "typewriter" || effect === "fadein";
697
- }
698
-
699
- function gcd(a: number, b: number): number {
700
- return b === 0 ? a : gcd(b, a % b);
701
- }
702
-
703
- /** The smallest stride >= ~61.8% of total that's coprime with it (falls
704
- * back to 1 for degenerate totals - a 1- or 2-unit "scramble" is fate). */
705
- function scatterStride(total: number): number {
706
- let stride = Math.max(1, Math.round(total * 0.618));
707
- while (stride < total && gcd(stride, total) !== 1) {
708
- stride += 1;
709
- }
710
- return gcd(stride, total) === 1 ? stride : 1;
711
- }
712
-
713
- function countUnits(nodes: Node[], by: SplitBy): number {
714
- let n = 0;
715
- for (const node of nodes) {
716
- if (node.type === "text") {
717
- for (const seg of SEGMENTERS[by].segment(node.value)) {
718
- if (isUnit(seg, by)) {
719
- n += 1;
720
- }
721
- }
722
- } else if (
723
- node.type === "emphasis" ||
724
- node.type === "strong" ||
725
- node.type === "strikethrough"
726
- ) {
727
- n += countUnits(node.children, by);
728
- }
729
- }
730
- return n;
731
- }
732
-
733
- function splitRender(nodes: Node[], ctx: Ctx, state: SplitState): string {
655
+ const body = look.units === null ? inner : splitRender(nodes, ctx, look);
656
+ return `<span class="${look.className}"${data}${style}>${body}</span>`;
657
+ }
658
+
659
+ /** Each unit in its own span with its phase offset; spaces and punctuation
660
+ * between units ride along as plain text; each word's units sit in a group
661
+ * that wraps as one. */
662
+ function splitRender(nodes: Node[], ctx: Ctx, look: EffectLook): string {
663
+ const pieces = (list: SplitPiece[]): string =>
664
+ list
665
+ .map((p) =>
666
+ p.kind === "text"
667
+ ? escapeText(p.text)
668
+ : p.kind === "unit"
669
+ ? `<span class="mq-l" style="--mq-o:${p.offset}">${escapeText(p.text)}</span>`
670
+ : `<span class="mq-w">${pieces(p.pieces)}</span>`,
671
+ )
672
+ .join("");
734
673
  let out = "";
735
674
  for (const node of nodes) {
736
675
  if (node.type === "text") {
737
- for (const seg of SEGMENTERS[state.by].segment(node.value)) {
738
- if (!isUnit(seg, state.by)) {
739
- out += escapeText(seg.segment); // spaces/punctuation ride along
740
- continue;
741
- }
742
- const o = unitOffset(state);
743
- state.i += 1;
744
- out += `<span class="mq-l" style="--mq-o:${o}">${escapeText(seg.segment)}</span>`;
745
- }
676
+ out += pieces(splitPieces(node, look));
746
677
  } else if (node.type === "emphasis") {
747
- out += `<em>${splitRender(node.children, ctx, state)}</em>`;
678
+ out += `<em>${splitRender(node.children, ctx, look)}</em>`;
748
679
  } else if (node.type === "strong") {
749
- out += `<strong>${splitRender(node.children, ctx, state)}</strong>`;
680
+ out += `<strong>${splitRender(node.children, ctx, look)}</strong>`;
750
681
  } else if (node.type === "strikethrough") {
751
- out += `<del>${splitRender(node.children, ctx, state)}</del>`;
682
+ out += `<del>${splitRender(node.children, ctx, look)}</del>`;
752
683
  } else {
753
684
  out += renderNode(node, ctx); // anything else renders whole, un-split
754
685
  }