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