@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/LICENSE +373 -0
- package/README.md +25 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +15 -0
- package/dist/profile.d.ts +43 -0
- package/dist/profile.js +37 -0
- package/dist/render.d.ts +16 -0
- package/dist/render.js +566 -0
- package/package.json +47 -0
- package/src/index.ts +20 -0
- package/src/profile.ts +81 -0
- package/src/render.ts +628 -0
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("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
563
|
+
}
|
|
564
|
+
export function escapeAttr(s) {
|
|
565
|
+
return escapeText(s).replaceAll('"', """);
|
|
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
|
+
}
|