@cube-drone/marquee-html-renderer 0.1.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/src/render.ts ADDED
@@ -0,0 +1,628 @@
1
+ // AST -> HTML string. The safety contract: author bytes reach the output
2
+ // only through escapeText/escapeAttr, targets only through the profile's
3
+ // allowlist, and attribute *names* are never author-controlled. Unknown
4
+ // vocabulary shrugs (children survive, effect doesn't); comments render
5
+ // nothing; invalid constructs render inert placeholders.
6
+
7
+ import type { Attrs, Node } from "@cube-drone/marquee-parser";
8
+ import { bareWebProfile, type Profile, type TurbolinkLevel } from "./profile.ts";
9
+
10
+ /** Render state: the profile, plus the one piece of cross-block
11
+ * coordination the renderer owns - aside numbering (sequential through the
12
+ * document) and the pending notes that flush after the triggering block. */
13
+ interface Ctx {
14
+ profile: Profile;
15
+ note: { n: number; pending: string[] };
16
+ }
17
+
18
+ export function render(node: Node, profile: Profile = bareWebProfile): string {
19
+ return renderNode(node, { profile, note: { n: 0, pending: [] } });
20
+ }
21
+
22
+ /** Asides render just below the paragraph (or heading) that triggered
23
+ * them - part of regular flow, no floats, no popups. */
24
+ function flushNotes(ctx: Ctx, html: string): string {
25
+ if (ctx.note.pending.length === 0) {
26
+ return html;
27
+ }
28
+ const notes = ctx.note.pending.map((n) => `<p class="mq-note">${n}</p>`).join("");
29
+ ctx.note.pending = [];
30
+ return `${html}<aside class="mq-notes">${notes}</aside>`;
31
+ }
32
+
33
+ function renderNode(node: Node, ctx: Ctx): string {
34
+ switch (node.type) {
35
+ case "document":
36
+ return `<div class="mq-doc">${children(node.children, ctx)}</div>`;
37
+ case "paragraph":
38
+ return flushNotes(ctx, `<p>${children(node.children, ctx)}</p>`);
39
+ case "heading": {
40
+ // HTML's ladder stops at h6; levels 7-8 (the grammar allows 1-8) keep
41
+ // real heading semantics via ARIA on a styled block.
42
+ const inner = children(node.children, ctx);
43
+ const html =
44
+ node.level <= 6
45
+ ? `<h${node.level}>${inner}</h${node.level}>`
46
+ : `<p class="mq-h${node.level}" role="heading" aria-level="${node.level}">${inner}</p>`;
47
+ return flushNotes(ctx, html);
48
+ }
49
+ case "code_block": {
50
+ const lang = infoToken(node.info);
51
+ const cls = lang === null ? "" : ` class="language-${escapeAttr(lang)}"`;
52
+ const text = node.text === "" ? "" : `${escapeText(node.text)}\n`;
53
+ return `<pre class="mq-code"><code${cls}>${text}</code></pre>`;
54
+ }
55
+ case "blockquote":
56
+ return `<blockquote>${children(node.children, ctx)}</blockquote>`;
57
+ case "list": {
58
+ const tag = node.ordered ? "ol" : "ul";
59
+ return `<${tag}>${children(node.children, ctx)}</${tag}>`;
60
+ }
61
+ case "list_item":
62
+ return `<li>${children(node.children, ctx)}</li>`;
63
+ case "thematic_break":
64
+ return "<hr>";
65
+ case "directive":
66
+ return directive(node.name, node.attrs, node.children, ctx);
67
+ case "invalid_directive":
68
+ return `<div class="mq-invalid" data-reason="${escapeAttr(node.reason)}"></div>`;
69
+ case "comment":
70
+ return ""; // the anti-shrug: correct rendering is absence
71
+ case "text":
72
+ return escapeText(node.value);
73
+ case "emphasis":
74
+ return `<em>${children(node.children, ctx)}</em>`;
75
+ case "strong":
76
+ return `<strong>${children(node.children, ctx)}</strong>`;
77
+ case "strikethrough":
78
+ return `<del>${children(node.children, ctx)}</del>`;
79
+ case "code_span":
80
+ return `<code>${escapeText(node.text)}</code>`;
81
+ case "link": {
82
+ const inner = children(node.children, ctx);
83
+ return ctx.profile.linkAllowed(node.target)
84
+ ? `<a href="${escapeAttr(node.target)}">${inner}</a>`
85
+ : `<span class="mq-blocked">${inner}</span>`;
86
+ }
87
+ case "embed":
88
+ return embed(node.target, node.alt, ctx.profile);
89
+ case "turbolink":
90
+ return turbolink(node.target, undefined, ctx.profile);
91
+ case "span":
92
+ return span(node.name, node.attrs, node.children, ctx);
93
+ case "emoji": {
94
+ const resolved = ctx.profile.emoji(node.slug);
95
+ if (resolved !== null && typeof resolved === "object") {
96
+ const alt = resolved.alt ?? `:${node.slug}:`;
97
+ return `<img class="mq-emoji" src="${escapeAttr(resolved.image)}" alt="${escapeAttr(alt)}" loading="lazy">`;
98
+ }
99
+ return escapeText(resolved ?? `:${node.slug}:`);
100
+ }
101
+ case "hard_break":
102
+ return "<br>";
103
+ }
104
+ }
105
+
106
+ function children(nodes: Node[], ctx: Ctx): string {
107
+ return nodes.map((n) => renderNode(n, ctx)).join("");
108
+ }
109
+
110
+ // -- validation gates (closed value grammars; failures degrade, never emit)
111
+
112
+ const HEX_OR_TOKEN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$|^[a-z][a-z0-9-]{0,31}$/;
113
+ const TOKEN = /^[a-z][a-z0-9-]{0,31}$/;
114
+ const COUNT = /^[0-9]{1,4}$/;
115
+
116
+ function isColorValue(v: string | undefined): v is string {
117
+ return v !== undefined && HEX_OR_TOKEN.test(v);
118
+ }
119
+
120
+ function isToken(v: string | undefined): v is string {
121
+ return v !== undefined && TOKEN.test(v);
122
+ }
123
+
124
+ /** The font vocabulary (closed, two tiers): four standard stacks that need
125
+ * no files, and the grab bag - SIL OFL faces the embedder serves itself
126
+ * (never a third-party CDN: fonts are a tracking vector, care-modes apply).
127
+ * Values are the `face` presentational attribute - the no-stylesheet floor;
128
+ * the mq-font-* class is the ceiling, where the real @font-face lives. */
129
+ export const FONTS: Record<string, string> = {
130
+ // standard stacks
131
+ sans: "sans-serif",
132
+ serif: "serif",
133
+ mono: "monospace",
134
+ comic: "Comic Sans MS",
135
+ // the grab bag
136
+ "radio-canada": "Radio Canada",
137
+ "atkinson-hyperlegible": "Atkinson Hyperlegible",
138
+ lexend: "Lexend",
139
+ "zilla-slab": "Zilla Slab",
140
+ "playfair-display": "Playfair Display",
141
+ cormorant: "Cormorant",
142
+ "im-fell-english": "IM Fell English",
143
+ "uncial-antiqua": "Uncial Antiqua",
144
+ unifraktur: "UnifrakturMaguntia",
145
+ "jetbrains-mono": "JetBrains Mono",
146
+ vt323: "VT323",
147
+ "press-start": "Press Start 2P",
148
+ silkscreen: "Silkscreen",
149
+ "major-mono": "Major Mono Display",
150
+ orbitron: "Orbitron",
151
+ bungee: "Bungee",
152
+ monoton: "Monoton",
153
+ creepster: "Creepster",
154
+ "special-elite": "Special Elite",
155
+ fredericka: "Fredericka the Great",
156
+ lobster: "Lobster",
157
+ pacifico: "Pacifico",
158
+ caveat: "Caveat",
159
+ "comic-neue": "Comic Neue",
160
+ audiowide: "Audiowide",
161
+ kablammo: "Kablammo",
162
+ "henny-penny": "Henny Penny",
163
+ oi: "Oi",
164
+ rye: "Rye",
165
+ bitcount: "Bitcount",
166
+ quicksand: "Quicksand",
167
+ };
168
+
169
+ /** Which grab-bag faces does this rendered HTML actually wear? Pure string
170
+ * scan of the mq-font-* class contract - feed the result to
171
+ * @cube-drone/marquee-fonts (externalFontFaces / inlineFontFaces) to deliver
172
+ * exactly those faces and not one byte more. */
173
+ export function usedFontTokens(html: string): string[] {
174
+ const used = new Set<string>();
175
+ for (const m of html.matchAll(/mq-font-([a-z0-9-]+)/g)) {
176
+ used.add(m[1]!);
177
+ }
178
+ return [...used].sort();
179
+ }
180
+
181
+ /** One rung of the font-element seven-step dial: presentational floor
182
+ * (works with no stylesheet, under any CSP), stylesheet class as ceiling.
183
+ * The named rungs - miniscule, tiny, huge, enormous - are unnecessary
184
+ * given the dial, and yet. */
185
+ function sizeRung(value: string, inner: string): string {
186
+ return `<font class="mq-size-${value}" size="${value}">${inner}</font>`;
187
+ }
188
+
189
+ const MEDIA_SIZE_TOKENS: Record<string, string> = {
190
+ small: "10rem",
191
+ medium: "20rem",
192
+ large: "32rem",
193
+ full: "100%",
194
+ };
195
+
196
+ /** Media width/height: a size token or a capped integer of pixels (SPEC.md,
197
+ * "Media"). Anything else degrades to natural sizing. */
198
+ function mediaSize(v: string | undefined): string | null {
199
+ if (v === undefined) {
200
+ return null;
201
+ }
202
+ const token = MEDIA_SIZE_TOKENS[v];
203
+ if (token !== undefined) {
204
+ return token;
205
+ }
206
+ if (/^[0-9]{1,4}$/.test(v)) {
207
+ const n = Number(v);
208
+ if (n >= 1 && n <= 4096) {
209
+ return `${n}px`;
210
+ }
211
+ }
212
+ return null;
213
+ }
214
+
215
+ function infoToken(info: string | undefined): string | null {
216
+ if (info === undefined) {
217
+ return null;
218
+ }
219
+ const first = info.split(/[ \t]/, 1)[0]!;
220
+ return /^[A-Za-z0-9_+.#-]{1,64}$/.test(first) ? first : null;
221
+ }
222
+
223
+ // -- constructs
224
+
225
+ function embed(target: string, alt: string, profile: Profile): string {
226
+ const media = profile.media(target);
227
+ if (media !== null) {
228
+ const url = escapeAttr(media.url);
229
+ switch (media.kind) {
230
+ case "image":
231
+ return `<img class="mq-embed" src="${url}" alt="${escapeAttr(alt)}" loading="lazy">`;
232
+ case "audio":
233
+ return `<audio class="mq-embed" controls src="${url}" aria-label="${escapeAttr(alt)}"></audio>`;
234
+ case "video":
235
+ return `<video class="mq-embed" controls src="${url}" aria-label="${escapeAttr(alt)}"></video>`;
236
+ }
237
+ }
238
+ // The contractual shrug applied to media: degrade to a labeled link, or
239
+ // to inert text when the scheme is out of policy.
240
+ const label = escapeText(`[${alt === "" ? target : alt}]`);
241
+ return profile.linkAllowed(target)
242
+ ? `<a class="mq-embed-fallback" href="${escapeAttr(target)}">${label}</a>`
243
+ : `<span class="mq-embed-fallback">${label}</span>`;
244
+ }
245
+
246
+ const TURBOLINK_LEVELS = new Set<TurbolinkLevel>(["full", "title", "bare"]);
247
+
248
+ function turbolink(target: string, levelAttr: string | undefined, profile: Profile): string {
249
+ if (!profile.linkAllowed(target)) {
250
+ return `<p class="mq-turbolink">${escapeText(target)}</p>`;
251
+ }
252
+ const level =
253
+ levelAttr !== undefined && TURBOLINK_LEVELS.has(levelAttr as TurbolinkLevel)
254
+ ? (levelAttr as TurbolinkLevel)
255
+ : profile.turbolinkLevel(target);
256
+ if (level !== "bare") {
257
+ const rich = profile.turbolink(target, level);
258
+ if (rich !== null) {
259
+ // Enrichment augments, never replaces: the wrapper itself carries the
260
+ // original link, so no plugin can eat it - not even by accident.
261
+ return `<div class="mq-turbolink mq-turbolink-rich">${rich}<a class="mq-turbolink-source" href="${escapeAttr(target)}">${escapeText(target)}</a></div>`;
262
+ }
263
+ }
264
+ // The contractual floor: a plain link, always reachable.
265
+ return `<p class="mq-turbolink"><a href="${escapeAttr(target)}">${escapeText(target)}</a></p>`;
266
+ }
267
+
268
+ /** A resolved URL made safe for a CSS url("...") token: encodeURI handles
269
+ * whitespace, controls, backslashes, and double quotes; quotes and parens
270
+ * that encodeURI deliberately leaves are percent-encoded on top, so the
271
+ * value can never terminate the url() or the declaration - author bytes
272
+ * must not write CSS, even inside their own style attribute. */
273
+ function cssUrl(url: string): string {
274
+ return encodeURI(url).replaceAll("'", "%27").replaceAll("(", "%28").replaceAll(")", "%29");
275
+ }
276
+
277
+ /** Style knobs on a block node: validated values into --mq-* slots; the
278
+ * stylesheet owns which CSS property each slot feeds. `background` takes a
279
+ * color, or `tile:<target>` - a tiled background image, resolved through
280
+ * the embedder's media policy exactly as an embed (a background fetch is a
281
+ * fetch): out-of-policy or non-image targets degrade to no background. */
282
+ function styleVars(attrs: Attrs, profile: Profile): string {
283
+ const vars: string[] = [];
284
+ if (isColorValue(attrs["color"])) {
285
+ vars.push(`--mq-color:${attrs["color"]}`);
286
+ }
287
+ const bg = attrs["background"];
288
+ if (bg !== undefined && bg.startsWith("tile:")) {
289
+ const media = profile.media(bg.slice("tile:".length));
290
+ if (media !== null && media.kind === "image") {
291
+ // Single-quoted url token: the style attribute itself is
292
+ // double-quoted, and cssUrl percent-encodes single quotes.
293
+ vars.push(`--mq-bg-tile:url('${cssUrl(media.url)}')`);
294
+ }
295
+ } else if (isColorValue(bg)) {
296
+ vars.push(`--mq-bg:${bg}`);
297
+ }
298
+ return vars.length === 0 ? "" : ` style="${vars.join(";")}"`;
299
+ }
300
+
301
+ function schemeClass(attrs: Attrs): string {
302
+ return isToken(attrs["scheme"]) ? ` mq-scheme-${attrs["scheme"]}` : "";
303
+ }
304
+
305
+ function fontClass(attrs: Attrs): string {
306
+ const value = attrs["font"];
307
+ return value !== undefined && FONTS[value] !== undefined ? ` mq-font-${value}` : "";
308
+ }
309
+
310
+ function directive(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string {
311
+ const { profile } = ctx;
312
+ const inner = children(nodes, ctx);
313
+ const custom = profile.directive(name, attrs, inner);
314
+ if (custom !== null) {
315
+ return custom;
316
+ }
317
+ switch (name) {
318
+ case "meta":
319
+ // Carries metadata, renders nothing by default - but never eats an
320
+ // (unconventional) body.
321
+ return inner;
322
+ case "page": {
323
+ const layout = isToken(attrs["layout"]) ? ` mq-layout-${attrs["layout"]}` : "";
324
+ return `<div class="mq-page${layout}${schemeClass(attrs)}${fontClass(attrs)}"${styleVars(attrs, profile)}>${inner}</div>`;
325
+ }
326
+ case "section": {
327
+ const slot = isToken(attrs["slot"]) ? ` data-slot="${attrs["slot"]}"` : "";
328
+ return `<section class="mq-section${schemeClass(attrs)}${fontClass(attrs)}"${slot}${styleVars(attrs, profile)}>${inner}</section>`;
329
+ }
330
+ case "turbolink": {
331
+ const target = attrs["target"];
332
+ if (target !== undefined) {
333
+ return turbolink(target, attrs["level"], profile);
334
+ }
335
+ break; // malformed use: fall through to the placeholder
336
+ }
337
+ case "media": {
338
+ const vars: string[] = [];
339
+ const w = mediaSize(attrs["width"]);
340
+ const h = mediaSize(attrs["height"]);
341
+ if (w !== null) {
342
+ vars.push(`--mq-media-w:${w}`);
343
+ }
344
+ if (h !== null) {
345
+ vars.push(`--mq-media-h:${h}`);
346
+ }
347
+ const style = vars.length === 0 ? "" : ` style="${vars.join(";")}"`;
348
+ return `<div class="mq-media"${style}>${inner}</div>`;
349
+ }
350
+ case "table":
351
+ return renderTable(attrs, nodes, ctx);
352
+ }
353
+ // Unknown vocabulary: a container renders its children with an affordance
354
+ // that something wrapped them; a leaf renders the inert placeholder.
355
+ // Never eat authored content.
356
+ return nodes.length > 0
357
+ ? `<div class="mq-unknown" data-directive="${escapeAttr(name)}">${inner}</div>`
358
+ : `<div class="mq-placeholder" data-directive="${escapeAttr(name)}"></div>`;
359
+ }
360
+
361
+ /** :::table (SPEC.md, "Tables"): each paragraph child is a row; a row's
362
+ * cells are its top-level `[c]` spans, and loose inline content between
363
+ * cells coalesces into implicit cells (never eaten). A non-paragraph block
364
+ * child is a full-width single-cell row. `header=row|column|both` promotes
365
+ * the first row / first column to <th> with scope - header association is
366
+ * the accessibility half of tables, hoisted onto the one attr. */
367
+ function renderTable(attrs: Attrs, nodes: Node[], ctx: Ctx): string {
368
+ const header = attrs["header"];
369
+ const headRow = header === "row" || header === "both";
370
+ const headCol = header === "column" || header === "both";
371
+ const rows: string[] = [];
372
+ for (const node of nodes) {
373
+ const cells: string[] = [];
374
+ if (node.type === "paragraph") {
375
+ let loose: Node[] = [];
376
+ const flushLoose = () => {
377
+ if (loose.some((n) => n.type !== "text" || n.value.trim() !== "")) {
378
+ cells.push(children(loose, ctx));
379
+ }
380
+ loose = [];
381
+ };
382
+ for (const child of node.children) {
383
+ if (child.type === "span" && child.name === "c") {
384
+ flushLoose();
385
+ cells.push(children(child.children, ctx));
386
+ } else {
387
+ loose.push(child);
388
+ }
389
+ }
390
+ flushLoose();
391
+ } else {
392
+ cells.push(renderNode(node, ctx));
393
+ }
394
+ const isHeadRow = headRow && rows.length === 0;
395
+ const cellsHtml = cells
396
+ .map((cell, i) =>
397
+ isHeadRow || (headCol && i === 0)
398
+ ? `<th scope="${isHeadRow ? "col" : "row"}">${cell}</th>`
399
+ : `<td>${cell}</td>`,
400
+ )
401
+ .join("");
402
+ rows.push(`<tr>${cellsHtml}</tr>`);
403
+ }
404
+ // Cell sidenotes land just below the table, like a paragraph's would.
405
+ return flushNotes(ctx, `<table class="mq-table">${rows.join("")}</table>`);
406
+ }
407
+
408
+ /** Effect/typographic spans. The `<font>` tag is deliberate: presentational
409
+ * attributes are outside CSP's jurisdiction and are implemented by the
410
+ * browser itself, so color survives with no stylesheet and no style
411
+ * attributes - the floor of the degradation ladder. The --mq-color slot is
412
+ * the stylesheet-era ceiling on the same element. */
413
+ function span(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string {
414
+ const inner = children(nodes, ctx);
415
+ const custom = ctx.profile.span(name, attrs, inner);
416
+ if (custom !== null) {
417
+ return custom;
418
+ }
419
+ switch (name) {
420
+ case "sup":
421
+ return `<sup>${inner}</sup>`;
422
+ case "sub":
423
+ return `<sub>${inner}</sub>`;
424
+ case "small":
425
+ return `<small>${inner}</small>`;
426
+ case "big":
427
+ return `<big>${inner}</big>`; // obsolete and eternal, like <font>
428
+ case "size": {
429
+ const value = attrs["size"];
430
+ if (value !== undefined && /^[1-7]$/.test(value)) {
431
+ return sizeRung(value, inner);
432
+ }
433
+ return inner; // off the dial: the effect degrades, the words survive
434
+ }
435
+ case "font": {
436
+ const value = attrs["font"];
437
+ const face = value !== undefined ? FONTS[value] : undefined;
438
+ if (face !== undefined) {
439
+ return `<font class="mq-font-${value}" face="${escapeAttr(face)}">${inner}</font>`;
440
+ }
441
+ return inner; // not on the list: the words survive in their own clothes
442
+ }
443
+ case "miniscule":
444
+ return sizeRung("1", inner);
445
+ case "tiny":
446
+ return sizeRung("2", inner);
447
+ case "huge":
448
+ return sizeRung("6", inner);
449
+ case "enormous":
450
+ return sizeRung("7", inner);
451
+ case "color": {
452
+ const value = attrs["color"];
453
+ if (isColorValue(value)) {
454
+ return `<font class="mq-color" color="${value}" style="--mq-color:${value}">${inner}</font>`;
455
+ }
456
+ return inner; // invalid value: the effect degrades, the words survive
457
+ }
458
+ case "sidenote": {
459
+ // A numbered mark in the flow; the note itself flushes just below the
460
+ // triggering paragraph (see flushNotes). Numbering runs sequentially
461
+ // through the whole document.
462
+ ctx.note.n += 1;
463
+ ctx.note.pending.push(`<span class="mq-note-num">${ctx.note.n}</span>${inner}`);
464
+ return `<sup class="mq-noteref">${ctx.note.n}</sup>`;
465
+ }
466
+ case "marquee": {
467
+ const dir = isToken(attrs["direction"]) ? ` data-direction="${attrs["direction"]}"` : "";
468
+ const speed = attrs["speed"] !== undefined && COUNT.test(attrs["speed"])
469
+ ? ` style="--mq-speed:${attrs["speed"]}"`
470
+ : "";
471
+ return `<span class="mq-marquee"${dir}${speed}><span class="mq-marquee-inner">${inner}</span></span>`;
472
+ }
473
+ case "blink": {
474
+ const rate = attrs["rate"] !== undefined && COUNT.test(attrs["rate"])
475
+ ? ` style="--mq-rate:${attrs["rate"]}"`
476
+ : "";
477
+ return `<span class="mq-blink"${rate}>${inner}</span>`;
478
+ }
479
+ case "rainbow":
480
+ case "bounce":
481
+ case "jitter":
482
+ case "wave":
483
+ if (attrs["by"] === "letter" || attrs["by"] === "word") {
484
+ return bySegments(name, attrs["by"], attrs["phase"], nodes, ctx);
485
+ }
486
+ return `<span class="mq-${name}">${inner}</span>`;
487
+ case "typewriter":
488
+ return `<span class="mq-${name}">${inner}</span>`;
489
+ }
490
+ return inner; // unknown span: pure shrug, children as plain content
491
+ }
492
+
493
+ // -- per-unit effects (by=letter / by=word): each unit in its own span with
494
+ // a phase offset in --mq-o; the stylesheet replays the effect's keyframes
495
+ // through a negative animation-delay. Pure markup - the document stays a
496
+ // document. Locale pinned so segmentation (and thus output) is stable.
497
+
498
+ const SEGMENTERS = {
499
+ letter: new Intl.Segmenter("en", { granularity: "grapheme" }),
500
+ word: new Intl.Segmenter("en", { granularity: "word" }),
501
+ };
502
+
503
+ type SplitBy = keyof typeof SEGMENTERS;
504
+
505
+ /** DOM-weight discipline: past this many units, the run animates whole. */
506
+ const MAX_SPLIT_UNITS = 400;
507
+
508
+ type Phase = "ramp" | "scatter";
509
+
510
+ interface SplitState {
511
+ effect: string;
512
+ by: SplitBy;
513
+ phase: Phase;
514
+ i: number;
515
+ total: number;
516
+ }
517
+
518
+ function bySegments(
519
+ effect: string,
520
+ by: SplitBy,
521
+ phaseAttr: string | undefined,
522
+ nodes: Node[],
523
+ ctx: Ctx,
524
+ ): string {
525
+ // Each effect has a natural phase order (jitter scatters, the rest sweep);
526
+ // the knob overrides it either way. Invalid values degrade to the default.
527
+ const phase: Phase =
528
+ phaseAttr === "scatter" || phaseAttr === "ramp"
529
+ ? phaseAttr
530
+ : effect === "jitter"
531
+ ? "scatter"
532
+ : "ramp";
533
+ const state: SplitState = { effect, by, phase, i: 0, total: 0 };
534
+ state.total = countUnits(nodes, by);
535
+ if (state.total === 0 || state.total > MAX_SPLIT_UNITS) {
536
+ return `<span class="mq-${effect}">${children(nodes, ctx)}</span>`;
537
+ }
538
+ return `<span class="mq-${effect} mq-split">${splitRender(nodes, ctx, state)}</span>`;
539
+ }
540
+
541
+ /** A segment gets wrapped if it's animatable: for words, word-like segments
542
+ * (spaces and bare punctuation ride along); for letters, anything that
543
+ * isn't whitespace. */
544
+ function isUnit(seg: Intl.SegmentData, by: SplitBy): boolean {
545
+ return by === "word" ? seg.isWordLike === true : !/^\s+$/.test(seg.segment);
546
+ }
547
+
548
+ /** Offsets are deterministic (goldens exist; a document renders the same
549
+ * twice) in both phase orders: ramp sweeps, scatter scrambles by a fixed
550
+ * integer hash - randomness-shaped, never random. */
551
+ function unitOffset(state: SplitState): string {
552
+ let o: number;
553
+ if (state.phase === "scatter") {
554
+ o = ((state.i * 7919) % 101) / 101;
555
+ } else {
556
+ switch (state.effect) {
557
+ case "rainbow":
558
+ o = state.i / state.total; // gradient across the whole run
559
+ break;
560
+ case "wave":
561
+ o = (state.i % 8) / 8; // fixed ripple wavelength
562
+ break;
563
+ case "bounce":
564
+ o = (state.i % 6) / 6;
565
+ break;
566
+ default:
567
+ o = (state.i % 8) / 8; // jitter in ramp mode: a rippling shudder
568
+ break;
569
+ }
570
+ }
571
+ return String(Math.round(o * 1000) / 1000);
572
+ }
573
+
574
+ function countUnits(nodes: Node[], by: SplitBy): number {
575
+ let n = 0;
576
+ for (const node of nodes) {
577
+ if (node.type === "text") {
578
+ for (const seg of SEGMENTERS[by].segment(node.value)) {
579
+ if (isUnit(seg, by)) {
580
+ n += 1;
581
+ }
582
+ }
583
+ } else if (
584
+ node.type === "emphasis" ||
585
+ node.type === "strong" ||
586
+ node.type === "strikethrough"
587
+ ) {
588
+ n += countUnits(node.children, by);
589
+ }
590
+ }
591
+ return n;
592
+ }
593
+
594
+ function splitRender(nodes: Node[], ctx: Ctx, state: SplitState): string {
595
+ let out = "";
596
+ for (const node of nodes) {
597
+ if (node.type === "text") {
598
+ for (const seg of SEGMENTERS[state.by].segment(node.value)) {
599
+ if (!isUnit(seg, state.by)) {
600
+ out += escapeText(seg.segment); // spaces/punctuation ride along
601
+ continue;
602
+ }
603
+ const o = unitOffset(state);
604
+ state.i += 1;
605
+ out += `<span class="mq-l" style="--mq-o:${o}">${escapeText(seg.segment)}</span>`;
606
+ }
607
+ } else if (node.type === "emphasis") {
608
+ out += `<em>${splitRender(node.children, ctx, state)}</em>`;
609
+ } else if (node.type === "strong") {
610
+ out += `<strong>${splitRender(node.children, ctx, state)}</strong>`;
611
+ } else if (node.type === "strikethrough") {
612
+ out += `<del>${splitRender(node.children, ctx, state)}</del>`;
613
+ } else {
614
+ out += renderNode(node, ctx); // anything else renders whole, un-split
615
+ }
616
+ }
617
+ return out;
618
+ }
619
+
620
+ // -- escaping (the only paths author bytes may take into markup)
621
+
622
+ export function escapeText(s: string): string {
623
+ return s.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
624
+ }
625
+
626
+ export function escapeAttr(s: string): string {
627
+ return escapeText(s).replaceAll('"', "&quot;");
628
+ }