@cube-drone/marquee-html-renderer 0.1.1 → 0.3.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/render.js CHANGED
@@ -158,7 +158,7 @@ export function usedFontTokens(html) {
158
158
  }
159
159
  /** One rung of the font-element seven-step dial: presentational floor
160
160
  * (works with no stylesheet, under any CSP), stylesheet class as ceiling.
161
- * The named rungs - miniscule, tiny, huge, enormous - are unnecessary
161
+ * The named rungs - teeny, tiny, huge, enormous - are unnecessary
162
162
  * given the dial, and yet. */
163
163
  function sizeRung(value, inner) {
164
164
  return `<font class="mq-size-${value}" size="${value}">${inner}</font>`;
@@ -315,6 +315,14 @@ function directive(name, attrs, nodes, ctx) {
315
315
  }
316
316
  case "table":
317
317
  return renderTable(attrs, nodes, ctx);
318
+ case "center":
319
+ case "right":
320
+ case "left":
321
+ // The <center> tag, back from the dead in directive clothing - plus
322
+ // its siblings: right for symmetry, left as the un-aligner (useful
323
+ // only inside the other two, which is exactly when you want it).
324
+ // Physical directions, deliberately: predictable beats logical.
325
+ return `<div class="mq-${name}">${inner}</div>`;
318
326
  }
319
327
  // Unknown vocabulary: a container renders its children with an affordance
320
328
  // that something wrapped them; a leaf renders the inert placeholder.
@@ -404,7 +412,7 @@ function span(name, attrs, nodes, ctx) {
404
412
  }
405
413
  return inner; // not on the list: the words survive in their own clothes
406
414
  }
407
- case "miniscule":
415
+ case "teeny":
408
416
  return sizeRung("1", inner);
409
417
  case "tiny":
410
418
  return sizeRung("2", inner);
@@ -419,7 +427,12 @@ function span(name, attrs, nodes, ctx) {
419
427
  }
420
428
  return inner; // invalid value: the effect degrades, the words survive
421
429
  }
422
- case "sidenote": {
430
+ case "sidenote":
431
+ case "aside":
432
+ case "footnote": {
433
+ // Permanent synonyms (SPEC.md): nobody keeps these three words
434
+ // distinct in their head, and an aside that silently isn't an aside
435
+ // is worse than two extra names - the list-marker rule, one layer up.
423
436
  // A numbered mark in the flow; the note itself flushes just below the
424
437
  // triggering paragraph (see flushNotes). Numbering runs sequentially
425
438
  // through the whole document.
@@ -438,18 +451,41 @@ function span(name, attrs, nodes, ctx) {
438
451
  const rate = attrs["rate"] !== undefined && COUNT.test(attrs["rate"])
439
452
  ? ` style="--mq-rate:${attrs["rate"]}"`
440
453
  : "";
454
+ if (attrs["by"] === "letter" || attrs["by"] === "word") {
455
+ // Split blink: ramp is theater-marquee chase lights, scatter is
456
+ // twinkle. The rate var rides the container; units inherit it.
457
+ return bySegments(name, attrs["by"], attrs["phase"], nodes, ctx, rate);
458
+ }
441
459
  return `<span class="mq-blink"${rate}>${inner}</span>`;
442
460
  }
443
461
  case "rainbow":
444
462
  case "bounce":
445
463
  case "jitter":
446
464
  case "wave":
465
+ case "rubber":
447
466
  if (attrs["by"] === "letter" || attrs["by"] === "word") {
448
467
  return bySegments(name, attrs["by"], attrs["phase"], nodes, ctx);
449
468
  }
450
469
  return `<span class="mq-${name}">${inner}</span>`;
451
- case "typewriter":
452
- return `<span class="mq-${name}">${inner}</span>`;
470
+ case "typewriter": {
471
+ // Inherently per-unit: the reveal IS a by=letter effect (by=word for
472
+ // word-at-a-time). speed= is units per second; the container carries
473
+ // the per-unit delay step, each unit its ordinal in --mq-o.
474
+ const by = attrs["by"] === "word" ? "word" : "letter";
475
+ const step = revealStep(attrs["speed"], 14);
476
+ return bySegments(name, by, attrs["phase"], nodes, ctx, ` style="--mq-tw-step:${step}s"`);
477
+ }
478
+ case "fadein": {
479
+ // The ghostly reveal. Bare [fadein] fades the whole run in once;
480
+ // by=letter / by=word drift units in on staggered starts (same
481
+ // one-shot family as typewriter: sequential ordinals, high cap);
482
+ // phase=scatter is apparition weather.
483
+ if (attrs["by"] === "letter" || attrs["by"] === "word") {
484
+ const step = revealStep(attrs["speed"], 16);
485
+ return bySegments(name, attrs["by"], attrs["phase"], nodes, ctx, ` style="--mq-fi-step:${step}s"`);
486
+ }
487
+ return `<span class="mq-fadein">${inner}</span>`;
488
+ }
453
489
  }
454
490
  return inner; // unknown span: pure shrug, children as plain content
455
491
  }
@@ -461,9 +497,13 @@ const SEGMENTERS = {
461
497
  letter: new Intl.Segmenter("en", { granularity: "grapheme" }),
462
498
  word: new Intl.Segmenter("en", { granularity: "word" }),
463
499
  };
464
- /** DOM-weight discipline: past this many units, the run animates whole. */
500
+ /** DOM-weight discipline: past this many units, the run animates whole.
501
+ * Loopers pay a live animation per element forever, so they cap low;
502
+ * typewriter's units are 1ms one-shots (finished animations cost nothing)
503
+ * and its natural material is long text, so it caps high. */
465
504
  const MAX_SPLIT_UNITS = 400;
466
- function bySegments(effect, by, phaseAttr, nodes, ctx) {
505
+ const MAX_REVEAL_UNITS = 2000;
506
+ function bySegments(effect, by, phaseAttr, nodes, ctx, containerStyle = "") {
467
507
  // Each effect has a natural phase order (jitter scatters, the rest sweep);
468
508
  // the knob overrides it either way. Invalid values degrade to the default.
469
509
  const phase = phaseAttr === "scatter" || phaseAttr === "ramp"
@@ -473,10 +513,11 @@ function bySegments(effect, by, phaseAttr, nodes, ctx) {
473
513
  : "ramp";
474
514
  const state = { effect, by, phase, i: 0, total: 0 };
475
515
  state.total = countUnits(nodes, by);
476
- if (state.total === 0 || state.total > MAX_SPLIT_UNITS) {
516
+ const cap = isReveal(effect) ? MAX_REVEAL_UNITS : MAX_SPLIT_UNITS;
517
+ if (state.total === 0 || state.total > cap) {
477
518
  return `<span class="mq-${effect}">${children(nodes, ctx)}</span>`;
478
519
  }
479
- return `<span class="mq-${effect} mq-split">${splitRender(nodes, ctx, state)}</span>`;
520
+ return `<span class="mq-${effect} mq-split"${containerStyle}>${splitRender(nodes, ctx, state)}</span>`;
480
521
  }
481
522
  /** A segment gets wrapped if it's animatable: for words, word-like segments
482
523
  * (spaces and bare punctuation ride along); for letters, anything that
@@ -488,6 +529,19 @@ function isUnit(seg, by) {
488
529
  * twice) in both phase orders: ramp sweeps, scatter scrambles by a fixed
489
530
  * integer hash - randomness-shaped, never random. */
490
531
  function unitOffset(state) {
532
+ // Reveal offsets (typewriter, fadein) are sequential INTEGERS (the
533
+ // ordinal; delay = ordinal x step), unlike the cyclic 0..1 fractions the
534
+ // looping effects replay. phase=scatter reveals in a
535
+ // scrambled-but-deterministic order: a stride at the run's golden-ratio
536
+ // point, nudged coprime with the total, walks a well-spread permutation
537
+ // at EVERY length. (A fixed prime stride is a trap: 7919 mod 40 = 39 =
538
+ // -1, so forty-unit runs typed in backwards.)
539
+ if (isReveal(state.effect)) {
540
+ const o = state.phase === "scatter"
541
+ ? (state.i * scatterStride(state.total)) % state.total
542
+ : state.i;
543
+ return String(o);
544
+ }
491
545
  let o;
492
546
  if (state.phase === "scatter") {
493
547
  o = ((state.i * 7919) % 101) / 101;
@@ -510,6 +564,30 @@ function unitOffset(state) {
510
564
  }
511
565
  return String(Math.round(o * 1000) / 1000);
512
566
  }
567
+ /** speed= (units per second, a COUNT) into a per-unit delay step in
568
+ * seconds; invalid or absent falls to the effect's default rate. */
569
+ function revealStep(speedAttr, dflt) {
570
+ const speed = speedAttr !== undefined && COUNT.test(speedAttr) && Number(speedAttr) > 0
571
+ ? Number(speedAttr)
572
+ : dflt;
573
+ return Math.round(1000 / speed) / 1000;
574
+ }
575
+ /** The one-shot reveals: sequential ordinals, the high unit cap. */
576
+ function isReveal(effect) {
577
+ return effect === "typewriter" || effect === "fadein";
578
+ }
579
+ function gcd(a, b) {
580
+ return b === 0 ? a : gcd(b, a % b);
581
+ }
582
+ /** The smallest stride >= ~61.8% of total that's coprime with it (falls
583
+ * back to 1 for degenerate totals - a 1- or 2-unit "scramble" is fate). */
584
+ function scatterStride(total) {
585
+ let stride = Math.max(1, Math.round(total * 0.618));
586
+ while (stride < total && gcd(stride, total) !== 1) {
587
+ stride += 1;
588
+ }
589
+ return gcd(stride, total) === 1 ? stride : 1;
590
+ }
513
591
  function countUnits(nodes, by) {
514
592
  let n = 0;
515
593
  for (const node of nodes) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cube-drone/marquee-html-renderer",
3
- "version": "0.1.1",
3
+ "version": "0.3.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.1.1"
38
+ "@cube-drone/marquee-parser": "^0.3.0"
39
39
  },
40
40
  "devDependencies": {
41
- "@cube-drone/marquee-css": "^0.1.1",
42
- "@cube-drone/marquee-fonts": "^0.1.1",
43
- "@cube-drone/marquee-turbolink": "^0.1.1",
41
+ "@cube-drone/marquee-css": "^0.3.0",
42
+ "@cube-drone/marquee-fonts": "^0.3.0",
43
+ "@cube-drone/marquee-turbolink": "^0.3.0",
44
44
  "@types/node": "^24.0.0",
45
45
  "typescript": "^5.9.0"
46
46
  }
package/src/render.ts CHANGED
@@ -180,7 +180,7 @@ export function usedFontTokens(html: string): string[] {
180
180
 
181
181
  /** One rung of the font-element seven-step dial: presentational floor
182
182
  * (works with no stylesheet, under any CSP), stylesheet class as ceiling.
183
- * The named rungs - miniscule, tiny, huge, enormous - are unnecessary
183
+ * The named rungs - teeny, tiny, huge, enormous - are unnecessary
184
184
  * given the dial, and yet. */
185
185
  function sizeRung(value: string, inner: string): string {
186
186
  return `<font class="mq-size-${value}" size="${value}">${inner}</font>`;
@@ -349,6 +349,14 @@ function directive(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string
349
349
  }
350
350
  case "table":
351
351
  return renderTable(attrs, nodes, ctx);
352
+ case "center":
353
+ case "right":
354
+ case "left":
355
+ // The <center> tag, back from the dead in directive clothing - plus
356
+ // its siblings: right for symmetry, left as the un-aligner (useful
357
+ // only inside the other two, which is exactly when you want it).
358
+ // Physical directions, deliberately: predictable beats logical.
359
+ return `<div class="mq-${name}">${inner}</div>`;
352
360
  }
353
361
  // Unknown vocabulary: a container renders its children with an affordance
354
362
  // that something wrapped them; a leaf renders the inert placeholder.
@@ -440,7 +448,7 @@ function span(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string {
440
448
  }
441
449
  return inner; // not on the list: the words survive in their own clothes
442
450
  }
443
- case "miniscule":
451
+ case "teeny":
444
452
  return sizeRung("1", inner);
445
453
  case "tiny":
446
454
  return sizeRung("2", inner);
@@ -455,7 +463,12 @@ function span(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string {
455
463
  }
456
464
  return inner; // invalid value: the effect degrades, the words survive
457
465
  }
458
- case "sidenote": {
466
+ case "sidenote":
467
+ case "aside":
468
+ case "footnote": {
469
+ // Permanent synonyms (SPEC.md): nobody keeps these three words
470
+ // distinct in their head, and an aside that silently isn't an aside
471
+ // is worse than two extra names - the list-marker rule, one layer up.
459
472
  // A numbered mark in the flow; the note itself flushes just below the
460
473
  // triggering paragraph (see flushNotes). Numbering runs sequentially
461
474
  // through the whole document.
@@ -474,18 +487,41 @@ function span(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string {
474
487
  const rate = attrs["rate"] !== undefined && COUNT.test(attrs["rate"])
475
488
  ? ` style="--mq-rate:${attrs["rate"]}"`
476
489
  : "";
490
+ if (attrs["by"] === "letter" || attrs["by"] === "word") {
491
+ // Split blink: ramp is theater-marquee chase lights, scatter is
492
+ // twinkle. The rate var rides the container; units inherit it.
493
+ return bySegments(name, attrs["by"], attrs["phase"], nodes, ctx, rate);
494
+ }
477
495
  return `<span class="mq-blink"${rate}>${inner}</span>`;
478
496
  }
479
497
  case "rainbow":
480
498
  case "bounce":
481
499
  case "jitter":
482
500
  case "wave":
501
+ case "rubber":
483
502
  if (attrs["by"] === "letter" || attrs["by"] === "word") {
484
503
  return bySegments(name, attrs["by"], attrs["phase"], nodes, ctx);
485
504
  }
486
505
  return `<span class="mq-${name}">${inner}</span>`;
487
- case "typewriter":
488
- return `<span class="mq-${name}">${inner}</span>`;
506
+ case "typewriter": {
507
+ // Inherently per-unit: the reveal IS a by=letter effect (by=word for
508
+ // word-at-a-time). speed= is units per second; the container carries
509
+ // the per-unit delay step, each unit its ordinal in --mq-o.
510
+ const by = attrs["by"] === "word" ? "word" : "letter";
511
+ const step = revealStep(attrs["speed"], 14);
512
+ return bySegments(name, by, attrs["phase"], nodes, ctx, ` style="--mq-tw-step:${step}s"`);
513
+ }
514
+ case "fadein": {
515
+ // The ghostly reveal. Bare [fadein] fades the whole run in once;
516
+ // by=letter / by=word drift units in on staggered starts (same
517
+ // one-shot family as typewriter: sequential ordinals, high cap);
518
+ // phase=scatter is apparition weather.
519
+ if (attrs["by"] === "letter" || attrs["by"] === "word") {
520
+ const step = revealStep(attrs["speed"], 16);
521
+ return bySegments(name, attrs["by"], attrs["phase"], nodes, ctx, ` style="--mq-fi-step:${step}s"`);
522
+ }
523
+ return `<span class="mq-fadein">${inner}</span>`;
524
+ }
489
525
  }
490
526
  return inner; // unknown span: pure shrug, children as plain content
491
527
  }
@@ -502,8 +538,12 @@ const SEGMENTERS = {
502
538
 
503
539
  type SplitBy = keyof typeof SEGMENTERS;
504
540
 
505
- /** DOM-weight discipline: past this many units, the run animates whole. */
541
+ /** DOM-weight discipline: past this many units, the run animates whole.
542
+ * Loopers pay a live animation per element forever, so they cap low;
543
+ * typewriter's units are 1ms one-shots (finished animations cost nothing)
544
+ * and its natural material is long text, so it caps high. */
506
545
  const MAX_SPLIT_UNITS = 400;
546
+ const MAX_REVEAL_UNITS = 2000;
507
547
 
508
548
  type Phase = "ramp" | "scatter";
509
549
 
@@ -521,6 +561,7 @@ function bySegments(
521
561
  phaseAttr: string | undefined,
522
562
  nodes: Node[],
523
563
  ctx: Ctx,
564
+ containerStyle = "",
524
565
  ): string {
525
566
  // Each effect has a natural phase order (jitter scatters, the rest sweep);
526
567
  // the knob overrides it either way. Invalid values degrade to the default.
@@ -532,10 +573,11 @@ function bySegments(
532
573
  : "ramp";
533
574
  const state: SplitState = { effect, by, phase, i: 0, total: 0 };
534
575
  state.total = countUnits(nodes, by);
535
- if (state.total === 0 || state.total > MAX_SPLIT_UNITS) {
576
+ const cap = isReveal(effect) ? MAX_REVEAL_UNITS : MAX_SPLIT_UNITS;
577
+ if (state.total === 0 || state.total > cap) {
536
578
  return `<span class="mq-${effect}">${children(nodes, ctx)}</span>`;
537
579
  }
538
- return `<span class="mq-${effect} mq-split">${splitRender(nodes, ctx, state)}</span>`;
580
+ return `<span class="mq-${effect} mq-split"${containerStyle}>${splitRender(nodes, ctx, state)}</span>`;
539
581
  }
540
582
 
541
583
  /** A segment gets wrapped if it's animatable: for words, word-like segments
@@ -549,6 +591,20 @@ function isUnit(seg: Intl.SegmentData, by: SplitBy): boolean {
549
591
  * twice) in both phase orders: ramp sweeps, scatter scrambles by a fixed
550
592
  * integer hash - randomness-shaped, never random. */
551
593
  function unitOffset(state: SplitState): string {
594
+ // Reveal offsets (typewriter, fadein) are sequential INTEGERS (the
595
+ // ordinal; delay = ordinal x step), unlike the cyclic 0..1 fractions the
596
+ // looping effects replay. phase=scatter reveals in a
597
+ // scrambled-but-deterministic order: a stride at the run's golden-ratio
598
+ // point, nudged coprime with the total, walks a well-spread permutation
599
+ // at EVERY length. (A fixed prime stride is a trap: 7919 mod 40 = 39 =
600
+ // -1, so forty-unit runs typed in backwards.)
601
+ if (isReveal(state.effect)) {
602
+ const o =
603
+ state.phase === "scatter"
604
+ ? (state.i * scatterStride(state.total)) % state.total
605
+ : state.i;
606
+ return String(o);
607
+ }
552
608
  let o: number;
553
609
  if (state.phase === "scatter") {
554
610
  o = ((state.i * 7919) % 101) / 101;
@@ -571,6 +627,35 @@ function unitOffset(state: SplitState): string {
571
627
  return String(Math.round(o * 1000) / 1000);
572
628
  }
573
629
 
630
+ /** speed= (units per second, a COUNT) into a per-unit delay step in
631
+ * seconds; invalid or absent falls to the effect's default rate. */
632
+ function revealStep(speedAttr: string | undefined, dflt: number): number {
633
+ const speed =
634
+ speedAttr !== undefined && COUNT.test(speedAttr) && Number(speedAttr) > 0
635
+ ? Number(speedAttr)
636
+ : dflt;
637
+ return Math.round(1000 / speed) / 1000;
638
+ }
639
+
640
+ /** The one-shot reveals: sequential ordinals, the high unit cap. */
641
+ function isReveal(effect: string): boolean {
642
+ return effect === "typewriter" || effect === "fadein";
643
+ }
644
+
645
+ function gcd(a: number, b: number): number {
646
+ return b === 0 ? a : gcd(b, a % b);
647
+ }
648
+
649
+ /** The smallest stride >= ~61.8% of total that's coprime with it (falls
650
+ * back to 1 for degenerate totals - a 1- or 2-unit "scramble" is fate). */
651
+ function scatterStride(total: number): number {
652
+ let stride = Math.max(1, Math.round(total * 0.618));
653
+ while (stride < total && gcd(stride, total) !== 1) {
654
+ stride += 1;
655
+ }
656
+ return gcd(stride, total) === 1 ? stride : 1;
657
+ }
658
+
574
659
  function countUnits(nodes: Node[], by: SplitBy): number {
575
660
  let n = 0;
576
661
  for (const node of nodes) {