@cube-drone/marquee-html-renderer 0.9.1 → 0.9.3
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/README.md +17 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +4 -3
- package/dist/profile.d.ts +11 -0
- package/dist/render.d.ts +12 -1
- package/dist/render.js +72 -38
- package/package.json +6 -5
- package/src/index.ts +6 -5
- package/src/profile.ts +11 -0
- package/src/render.ts +100 -41
package/README.md
CHANGED
|
@@ -22,6 +22,23 @@ children. Pair the output with
|
|
|
22
22
|
class contract this renderer targets) and optionally
|
|
23
23
|
[`@cube-drone/marquee-fonts`](https://www.npmjs.com/package/@cube-drone/marquee-fonts).
|
|
24
24
|
|
|
25
|
+
`linkAllowed` decides *whether* a target links; the optional `linkTarget` decides *where* it
|
|
26
|
+
points — return another address (a note's chapter inside an ePub, a file inside an export) or
|
|
27
|
+
`null` to keep the target as written. It's asked only after `linkAllowed` says yes, for every
|
|
28
|
+
anchor drawn from an author target (links, turbolinks, an embed's fallback link), and the
|
|
29
|
+
visible text stays the author's. The React renderer honors it too.
|
|
30
|
+
|
|
31
|
+
For a page that must be well-formed XML — an ePub chapter, any XHTML document — pass
|
|
32
|
+
`{ output: "xhtml" }`: void elements close themselves (`<br/>`, `<img …/>`), boolean
|
|
33
|
+
attributes carry values (`controls="controls"`), the only entities are XML's own five, and
|
|
34
|
+
characters XML forbids become U+FFFD. Same elements, classes, and words. Hook output is placed
|
|
35
|
+
as given, so a profile used this way must return XHTML too.
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
render(parse(source), bookProfile, { output: "xhtml" });
|
|
39
|
+
renderMarquee(source, bookProfile, { output: "xhtml" }); // same, from source
|
|
40
|
+
```
|
|
41
|
+
|
|
25
42
|
The renderer's obligations (never eat content, comments render nothing, unknown vocabulary
|
|
26
43
|
shrugs, invalid constructs render inert placeholders, author bytes never become markup) are
|
|
27
44
|
enforced by its behavioral test suite. You probably want
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { type RenderOptions } from "./render.ts";
|
|
1
2
|
import type { Profile } from "./profile.ts";
|
|
2
|
-
export { render, escapeText, escapeAttr, FONTS, usedFontTokens, containerLook } from "./render.ts";
|
|
3
|
+
export { render, type RenderOptions, escapeText, escapeAttr, FONTS, usedFontTokens, containerLook } from "./render.ts";
|
|
3
4
|
export { bareWebProfile } from "./profile.ts";
|
|
4
5
|
export { EFFECT_NAMES, effectLook, splitPieces, type EffectLook, type EffectUnit, type SplitPiece } from "./effects.ts";
|
|
5
6
|
export type { EmojiResolution, Profile, MediaResolution, TurbolinkLevel } from "./profile.ts";
|
|
6
7
|
/** Parse and render in one step. Throws UnsupportedVersionError for unknown
|
|
7
|
-
* dialect versions, exactly as the parser does.
|
|
8
|
-
|
|
8
|
+
* dialect versions, exactly as the parser does. `{ output: "xhtml" }` for a
|
|
9
|
+
* page that must be well-formed XML, like an ePub's. */
|
|
10
|
+
export declare function renderMarquee(source: string, profile?: Profile, options?: RenderOptions): string;
|
package/dist/index.js
CHANGED
|
@@ -10,7 +10,8 @@ export { render, escapeText, escapeAttr, FONTS, usedFontTokens, containerLook }
|
|
|
10
10
|
export { bareWebProfile } from "./profile.js";
|
|
11
11
|
export { EFFECT_NAMES, effectLook, splitPieces } from "./effects.js";
|
|
12
12
|
/** Parse and render in one step. Throws UnsupportedVersionError for unknown
|
|
13
|
-
* dialect versions, exactly as the parser does.
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
* dialect versions, exactly as the parser does. `{ output: "xhtml" }` for a
|
|
14
|
+
* page that must be well-formed XML, like an ePub's. */
|
|
15
|
+
export function renderMarquee(source, profile, options) {
|
|
16
|
+
return render(parse(source), profile, options);
|
|
16
17
|
}
|
package/dist/profile.d.ts
CHANGED
|
@@ -17,6 +17,14 @@ export interface Profile {
|
|
|
17
17
|
/** May this target become a hyperlink? Disallowed links render their
|
|
18
18
|
* children without an anchor (content survives, capability doesn't). */
|
|
19
19
|
linkAllowed(target: string): boolean;
|
|
20
|
+
/** Where an allowed link points: null (or no method) keeps the target as
|
|
21
|
+
* written; a string is the address the anchor uses instead (a note's
|
|
22
|
+
* chapter inside an ePub, a file's path inside an export). Asked only after
|
|
23
|
+
* `linkAllowed` says yes - a refused link stays refused - and for every
|
|
24
|
+
* anchor the renderer draws from an author target: links, turbolinks, and
|
|
25
|
+
* the fallback link of an embed. The visible text stays the author's;
|
|
26
|
+
* `turbolink` and `media` still see the original target. */
|
|
27
|
+
linkTarget?(target: string): string | null;
|
|
20
28
|
/** Resolve an embed target to a media kind, or null for the inert
|
|
21
29
|
* fallback. The kind is resolved at render time (SPEC.md, "Media"). */
|
|
22
30
|
media(target: string): MediaResolution | null;
|
|
@@ -48,6 +56,9 @@ export interface Profile {
|
|
|
48
56
|
* - Place the children: a claiming answer MUST include `renderedChildren`
|
|
49
57
|
* (already-escaped HTML) or a known substitute for them - the shrug's
|
|
50
58
|
* never-eat-content rule binds embedder vocabulary too.
|
|
59
|
+
* - Match the output: a profile rendered with `{ output: "xhtml" }` must
|
|
60
|
+
* return well-formed XHTML (here and in `span` and `turbolink`) - the
|
|
61
|
+
* renderer places embedder HTML as given and can't rewrite it.
|
|
51
62
|
* Embedder-first: a claim on a name the language defines wins. */
|
|
52
63
|
directive(name: string, attrs: Attrs, renderedChildren: string): string | null;
|
|
53
64
|
/** Embedder span vocabulary - the inline twin of `directive`
|
package/dist/render.d.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import type { Attrs, Node } from "@cube-drone/marquee-parser";
|
|
2
2
|
import { type Profile } from "./profile.ts";
|
|
3
|
-
export
|
|
3
|
+
export interface RenderOptions {
|
|
4
|
+
/** Which serialization to write. "html" (the default) is HTML5 for a web
|
|
5
|
+
* page: void elements unclosed (`<br>`), boolean attributes bare
|
|
6
|
+
* (`controls`). "xhtml" is well-formed XML for an ePub page or any XHTML
|
|
7
|
+
* document: void elements close themselves (`<br/>`), every attribute
|
|
8
|
+
* carries a quoted value, the only entities are XML's own, and characters
|
|
9
|
+
* XML forbids become U+FFFD. Same elements, same classes, same words.
|
|
10
|
+
* Embedder vocabulary is placed as given, so a profile used with "xhtml"
|
|
11
|
+
* must return XHTML too (see `Profile.directive`). */
|
|
12
|
+
output?: "html" | "xhtml";
|
|
13
|
+
}
|
|
14
|
+
export declare function render(node: Node, profile?: Profile, options?: RenderOptions): string;
|
|
4
15
|
/** The font vocabulary (closed, two tiers): four standard stacks that need
|
|
5
16
|
* no files, and the grab bag - SIL OFL faces the embedder serves itself
|
|
6
17
|
* (never a third-party CDN: fonts are a tracking vector, care-modes apply).
|
package/dist/render.js
CHANGED
|
@@ -5,8 +5,21 @@
|
|
|
5
5
|
// nothing; invalid constructs render inert placeholders.
|
|
6
6
|
import { bareWebProfile } from "./profile.js";
|
|
7
7
|
import { effectLook, splitPieces } from "./effects.js";
|
|
8
|
-
export function render(node, profile = bareWebProfile) {
|
|
9
|
-
|
|
8
|
+
export function render(node, profile = bareWebProfile, options = {}) {
|
|
9
|
+
const xhtml = options.output === "xhtml";
|
|
10
|
+
const ctx = {
|
|
11
|
+
profile,
|
|
12
|
+
text: xhtml ? escapeTextXml : escapeText,
|
|
13
|
+
attr: xhtml ? escapeAttrXml : escapeAttr,
|
|
14
|
+
void: xhtml ? "/>" : ">",
|
|
15
|
+
flag: xhtml ? (name) => ` ${name}="${name}"` : (name) => ` ${name}`,
|
|
16
|
+
note: { n: 0, pending: [] },
|
|
17
|
+
};
|
|
18
|
+
return renderNode(node, ctx);
|
|
19
|
+
}
|
|
20
|
+
/** The href an allowed link wears: the profile's rewrite, or the target. */
|
|
21
|
+
function href(target, ctx) {
|
|
22
|
+
return ctx.attr(ctx.profile.linkTarget?.(target) ?? target);
|
|
10
23
|
}
|
|
11
24
|
/** Asides render just below the paragraph (or heading) that triggered
|
|
12
25
|
* them - part of regular flow, no floats, no popups. */
|
|
@@ -35,8 +48,8 @@ function renderNode(node, ctx) {
|
|
|
35
48
|
}
|
|
36
49
|
case "code_block": {
|
|
37
50
|
const lang = infoToken(node.info);
|
|
38
|
-
const cls = lang === null ? "" : ` class="language-${
|
|
39
|
-
const text = node.text === "" ? "" : `${
|
|
51
|
+
const cls = lang === null ? "" : ` class="language-${ctx.attr(lang)}"`;
|
|
52
|
+
const text = node.text === "" ? "" : `${ctx.text(node.text)}\n`;
|
|
40
53
|
return `<pre class="mq-code"><code${cls}>${text}</code></pre>`;
|
|
41
54
|
}
|
|
42
55
|
case "blockquote":
|
|
@@ -48,15 +61,15 @@ function renderNode(node, ctx) {
|
|
|
48
61
|
case "list_item":
|
|
49
62
|
return `<li>${children(node.children, ctx)}</li>`;
|
|
50
63
|
case "thematic_break":
|
|
51
|
-
return
|
|
64
|
+
return `<hr${ctx.void}`;
|
|
52
65
|
case "directive":
|
|
53
66
|
return directive(node.name, node.attrs, node.children, ctx);
|
|
54
67
|
case "invalid_directive":
|
|
55
|
-
return `<div class="mq-invalid" data-reason="${
|
|
68
|
+
return `<div class="mq-invalid" data-reason="${ctx.attr(node.reason)}"></div>`;
|
|
56
69
|
case "comment":
|
|
57
70
|
return ""; // the anti-shrug: correct rendering is absence
|
|
58
71
|
case "text":
|
|
59
|
-
return
|
|
72
|
+
return ctx.text(node.value);
|
|
60
73
|
case "emphasis":
|
|
61
74
|
return `<em>${children(node.children, ctx)}</em>`;
|
|
62
75
|
case "strong":
|
|
@@ -64,29 +77,29 @@ function renderNode(node, ctx) {
|
|
|
64
77
|
case "strikethrough":
|
|
65
78
|
return `<del>${children(node.children, ctx)}</del>`;
|
|
66
79
|
case "code_span":
|
|
67
|
-
return `<code>${
|
|
80
|
+
return `<code>${ctx.text(node.text)}</code>`;
|
|
68
81
|
case "link": {
|
|
69
82
|
const inner = children(node.children, ctx);
|
|
70
83
|
return ctx.profile.linkAllowed(node.target)
|
|
71
|
-
? `<a href="${
|
|
84
|
+
? `<a href="${href(node.target, ctx)}">${inner}</a>`
|
|
72
85
|
: `<span class="mq-blocked">${inner}</span>`;
|
|
73
86
|
}
|
|
74
87
|
case "embed":
|
|
75
|
-
return embed(node.target, node.alt, ctx
|
|
88
|
+
return embed(node.target, node.alt, ctx);
|
|
76
89
|
case "turbolink":
|
|
77
|
-
return turbolink(node.target, undefined, ctx
|
|
90
|
+
return turbolink(node.target, undefined, ctx);
|
|
78
91
|
case "span":
|
|
79
92
|
return span(node.name, node.attrs, node.children, ctx);
|
|
80
93
|
case "emoji": {
|
|
81
94
|
const resolved = ctx.profile.emoji(node.slug);
|
|
82
95
|
if (resolved !== null && typeof resolved === "object") {
|
|
83
96
|
const alt = resolved.alt ?? `:${node.slug}:`;
|
|
84
|
-
return `<img class="mq-emoji" src="${
|
|
97
|
+
return `<img class="mq-emoji" src="${ctx.attr(resolved.image)}" alt="${ctx.attr(alt)}" loading="lazy"${ctx.void}`;
|
|
85
98
|
}
|
|
86
|
-
return
|
|
99
|
+
return ctx.text(resolved ?? `:${node.slug}:`);
|
|
87
100
|
}
|
|
88
101
|
case "hard_break":
|
|
89
|
-
return
|
|
102
|
+
return `<br${ctx.void}`;
|
|
90
103
|
}
|
|
91
104
|
}
|
|
92
105
|
function children(nodes, ctx) {
|
|
@@ -212,32 +225,35 @@ function infoToken(info) {
|
|
|
212
225
|
return /^[A-Za-z0-9_+.#-]{1,64}$/.test(first) ? first : null;
|
|
213
226
|
}
|
|
214
227
|
// -- constructs
|
|
215
|
-
function embed(target, alt,
|
|
216
|
-
const media = profile.media(target);
|
|
228
|
+
function embed(target, alt, ctx) {
|
|
229
|
+
const media = ctx.profile.media(target);
|
|
217
230
|
if (media !== null) {
|
|
218
|
-
const url =
|
|
231
|
+
const url = ctx.attr(media.url);
|
|
232
|
+
const label = ctx.attr(alt);
|
|
233
|
+
const flag = ctx.flag;
|
|
219
234
|
switch (media.kind) {
|
|
220
235
|
case "image":
|
|
221
|
-
return `<img class="mq-embed" src="${url}" alt="${
|
|
236
|
+
return `<img class="mq-embed" src="${url}" alt="${label}" loading="lazy"${ctx.void}`;
|
|
222
237
|
case "audio":
|
|
223
|
-
return `<audio class="mq-embed"
|
|
238
|
+
return `<audio class="mq-embed"${flag("controls")} src="${url}" aria-label="${label}"></audio>`;
|
|
224
239
|
case "video":
|
|
225
240
|
return media.loop === true
|
|
226
|
-
? `<video class="mq-embed"
|
|
227
|
-
: `<video class="mq-embed"
|
|
241
|
+
? `<video class="mq-embed"${flag("autoplay")}${flag("loop")}${flag("muted")}${flag("playsinline")} src="${url}" aria-label="${label}"></video>`
|
|
242
|
+
: `<video class="mq-embed"${flag("controls")} src="${url}" aria-label="${label}"></video>`;
|
|
228
243
|
}
|
|
229
244
|
}
|
|
230
245
|
// The contractual shrug applied to media: degrade to a labeled link, or
|
|
231
246
|
// to inert text when the scheme is out of policy.
|
|
232
|
-
const label =
|
|
233
|
-
return profile.linkAllowed(target)
|
|
234
|
-
? `<a class="mq-embed-fallback" href="${
|
|
247
|
+
const label = ctx.text(`[${alt === "" ? target : alt}]`);
|
|
248
|
+
return ctx.profile.linkAllowed(target)
|
|
249
|
+
? `<a class="mq-embed-fallback" href="${href(target, ctx)}">${label}</a>`
|
|
235
250
|
: `<span class="mq-embed-fallback">${label}</span>`;
|
|
236
251
|
}
|
|
237
252
|
const TURBOLINK_LEVELS = new Set(["full", "title", "bare"]);
|
|
238
|
-
function turbolink(target, levelAttr,
|
|
253
|
+
function turbolink(target, levelAttr, ctx) {
|
|
254
|
+
const { profile } = ctx;
|
|
239
255
|
if (!profile.linkAllowed(target)) {
|
|
240
|
-
return `<p class="mq-turbolink">${
|
|
256
|
+
return `<p class="mq-turbolink">${ctx.text(target)}</p>`;
|
|
241
257
|
}
|
|
242
258
|
const level = levelAttr !== undefined && TURBOLINK_LEVELS.has(levelAttr)
|
|
243
259
|
? levelAttr
|
|
@@ -247,11 +263,11 @@ function turbolink(target, levelAttr, profile) {
|
|
|
247
263
|
if (rich !== null) {
|
|
248
264
|
// Enrichment augments, never replaces: the wrapper itself carries the
|
|
249
265
|
// original link, so no plugin can eat it - not even by accident.
|
|
250
|
-
return `<div class="mq-turbolink mq-turbolink-rich">${rich}<a class="mq-turbolink-source" href="${
|
|
266
|
+
return `<div class="mq-turbolink mq-turbolink-rich">${rich}<a class="mq-turbolink-source" href="${href(target, ctx)}">${ctx.text(target)}</a></div>`;
|
|
251
267
|
}
|
|
252
268
|
}
|
|
253
269
|
// The contractual floor: a plain link, always reachable.
|
|
254
|
-
return `<p class="mq-turbolink"><a href="${
|
|
270
|
+
return `<p class="mq-turbolink"><a href="${href(target, ctx)}">${ctx.text(target)}</a></p>`;
|
|
255
271
|
}
|
|
256
272
|
/** A resolved URL made safe for a CSS url("...") token: encodeURI handles
|
|
257
273
|
* whitespace, controls, backslashes, and double quotes; quotes and parens
|
|
@@ -344,7 +360,7 @@ function directive(name, attrs, nodes, ctx) {
|
|
|
344
360
|
case "turbolink": {
|
|
345
361
|
const target = attrs["target"];
|
|
346
362
|
if (target !== undefined) {
|
|
347
|
-
return turbolink(target, attrs["level"],
|
|
363
|
+
return turbolink(target, attrs["level"], ctx);
|
|
348
364
|
}
|
|
349
365
|
break; // malformed use: fall through to the placeholder
|
|
350
366
|
}
|
|
@@ -397,10 +413,10 @@ function directive(name, attrs, nodes, ctx) {
|
|
|
397
413
|
// differential-rendering hazard); role=base marks the common ancestor.
|
|
398
414
|
const head = [];
|
|
399
415
|
if (attrs["label"] !== undefined) {
|
|
400
|
-
head.push(`<span class="mq-variant-label">${
|
|
416
|
+
head.push(`<span class="mq-variant-label">${ctx.text(attrs["label"])}</span>`);
|
|
401
417
|
}
|
|
402
418
|
if (attrs["when"] !== undefined) {
|
|
403
|
-
head.push(`<span class="mq-variant-when">${
|
|
419
|
+
head.push(`<span class="mq-variant-when">${ctx.text(attrs["when"])}</span>`);
|
|
404
420
|
}
|
|
405
421
|
const cls = attrs["role"] === "base" ? "mq-variant mq-variant-base" : "mq-variant";
|
|
406
422
|
const headHtml = head.length === 0 ? "" : `<div class="mq-variant-head">${head.join(" ")}</div>`;
|
|
@@ -411,8 +427,8 @@ function directive(name, attrs, nodes, ctx) {
|
|
|
411
427
|
// that something wrapped them; a leaf renders the inert placeholder.
|
|
412
428
|
// Never eat authored content.
|
|
413
429
|
return nodes.length > 0
|
|
414
|
-
? `<div class="mq-unknown" data-directive="${
|
|
415
|
-
: `<div class="mq-placeholder" data-directive="${
|
|
430
|
+
? `<div class="mq-unknown" data-directive="${ctx.attr(name)}">${inner}</div>`
|
|
431
|
+
: `<div class="mq-placeholder" data-directive="${ctx.attr(name)}"></div>`;
|
|
416
432
|
}
|
|
417
433
|
/** :::table (SPEC.md, "Tables"): each paragraph child is a row; a row's
|
|
418
434
|
* cells are its top-level `[c]` spans, and loose inline content between
|
|
@@ -562,7 +578,7 @@ function span(name, attrs, nodes, ctx) {
|
|
|
562
578
|
const value = attrs["font"];
|
|
563
579
|
const face = value !== undefined ? FONTS[value] : undefined;
|
|
564
580
|
if (face !== undefined) {
|
|
565
|
-
return `<font class="mq-font-${value}" face="${
|
|
581
|
+
return `<font class="mq-font-${value}" face="${ctx.attr(face)}">${inner}</font>`;
|
|
566
582
|
}
|
|
567
583
|
return inner; // not on the list: the words survive in their own clothes
|
|
568
584
|
}
|
|
@@ -600,8 +616,8 @@ function span(name, attrs, nodes, ctx) {
|
|
|
600
616
|
// -- effects: the look (classes, knobs, per-unit split) comes from
|
|
601
617
|
// effects.ts, shared with every other surface that draws effects.
|
|
602
618
|
function renderEffect(name, look, nodes, inner, ctx) {
|
|
603
|
-
const data = look.data.map(([k, v]) => ` data-${k}="${
|
|
604
|
-
const style = look.vars.length === 0 ? "" : ` style="${look.vars.map(([k, v]) => `${k}:${
|
|
619
|
+
const data = look.data.map(([k, v]) => ` data-${k}="${ctx.attr(v)}"`).join("");
|
|
620
|
+
const style = look.vars.length === 0 ? "" : ` style="${look.vars.map(([k, v]) => `${k}:${ctx.attr(v)}`).join(";")}"`;
|
|
605
621
|
if (name === "marquee") {
|
|
606
622
|
return `<span class="${look.className}"${data}${style}><span class="mq-marquee-inner">${inner}</span></span>`;
|
|
607
623
|
}
|
|
@@ -614,9 +630,9 @@ function renderEffect(name, look, nodes, inner, ctx) {
|
|
|
614
630
|
function splitRender(nodes, ctx, look) {
|
|
615
631
|
const pieces = (list) => list
|
|
616
632
|
.map((p) => p.kind === "text"
|
|
617
|
-
?
|
|
633
|
+
? ctx.text(p.text)
|
|
618
634
|
: p.kind === "unit"
|
|
619
|
-
? `<span class="mq-l" style="--mq-o:${p.offset}">${
|
|
635
|
+
? `<span class="mq-l" style="--mq-o:${p.offset}">${ctx.text(p.text)}</span>`
|
|
620
636
|
: `<span class="mq-w">${pieces(p.pieces)}</span>`)
|
|
621
637
|
.join("");
|
|
622
638
|
let out = "";
|
|
@@ -646,3 +662,21 @@ export function escapeText(s) {
|
|
|
646
662
|
export function escapeAttr(s) {
|
|
647
663
|
return escapeText(s).replaceAll('"', """);
|
|
648
664
|
}
|
|
665
|
+
/** Characters XML 1.0 forbids outright - even as a numeric reference: the C0
|
|
666
|
+
* controls bar tab, newline, and return, the noncharacters U+FFFE/U+FFFF,
|
|
667
|
+
* and (a JS string can hold one) a lone surrogate. HTML parsers shrug these
|
|
668
|
+
* off; an XML parser refuses the whole page, so XHTML output carries U+FFFD
|
|
669
|
+
* in their place. */
|
|
670
|
+
const XML_FORBIDDEN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
671
|
+
function escapeTextXml(s) {
|
|
672
|
+
return escapeText(s).replace(XML_FORBIDDEN, "\uFFFD");
|
|
673
|
+
}
|
|
674
|
+
/** An XML parser normalizes whitespace in an attribute value to spaces, so a
|
|
675
|
+
* newline in alt text survives only as a character reference. */
|
|
676
|
+
function escapeAttrXml(s) {
|
|
677
|
+
return escapeTextXml(s)
|
|
678
|
+
.replaceAll('"', """)
|
|
679
|
+
.replaceAll("\t", "	")
|
|
680
|
+
.replaceAll("\n", " ")
|
|
681
|
+
.replaceAll("\r", " ");
|
|
682
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cube-drone/marquee-html-renderer",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
4
4
|
"description": "Reference static HTML renderer for the Marquee markup language",
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -35,13 +35,14 @@
|
|
|
35
35
|
"prepack": "npm run build"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@cube-drone/marquee-parser": "^0.9.
|
|
38
|
+
"@cube-drone/marquee-parser": "^0.9.3"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@cube-drone/marquee-css": "^0.9.
|
|
42
|
-
"@cube-drone/marquee-fonts": "^0.9.
|
|
43
|
-
"@cube-drone/marquee-turbolink": "^0.9.
|
|
41
|
+
"@cube-drone/marquee-css": "^0.9.3",
|
|
42
|
+
"@cube-drone/marquee-fonts": "^0.9.3",
|
|
43
|
+
"@cube-drone/marquee-turbolink": "^0.9.3",
|
|
44
44
|
"@types/node": "^24.0.0",
|
|
45
|
+
"saxes": "^6.0.0",
|
|
45
46
|
"typescript": "^5.9.0"
|
|
46
47
|
}
|
|
47
48
|
}
|
package/src/index.ts
CHANGED
|
@@ -6,16 +6,17 @@
|
|
|
6
6
|
// tap-to-skip) belong to the interactive renderer.
|
|
7
7
|
|
|
8
8
|
import { parse } from "@cube-drone/marquee-parser";
|
|
9
|
-
import { render } from "./render.ts";
|
|
9
|
+
import { render, type RenderOptions } from "./render.ts";
|
|
10
10
|
import type { Profile } from "./profile.ts";
|
|
11
11
|
|
|
12
|
-
export { render, escapeText, escapeAttr, FONTS, usedFontTokens, containerLook } from "./render.ts";
|
|
12
|
+
export { render, type RenderOptions, escapeText, escapeAttr, FONTS, usedFontTokens, containerLook } from "./render.ts";
|
|
13
13
|
export { bareWebProfile } from "./profile.ts";
|
|
14
14
|
export { EFFECT_NAMES, effectLook, splitPieces, type EffectLook, type EffectUnit, type SplitPiece } from "./effects.ts";
|
|
15
15
|
export type { EmojiResolution, Profile, MediaResolution, TurbolinkLevel } from "./profile.ts";
|
|
16
16
|
|
|
17
17
|
/** Parse and render in one step. Throws UnsupportedVersionError for unknown
|
|
18
|
-
* dialect versions, exactly as the parser does.
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
* dialect versions, exactly as the parser does. `{ output: "xhtml" }` for a
|
|
19
|
+
* page that must be well-formed XML, like an ePub's. */
|
|
20
|
+
export function renderMarquee(source: string, profile?: Profile, options?: RenderOptions): string {
|
|
21
|
+
return render(parse(source), profile, options);
|
|
21
22
|
}
|
package/src/profile.ts
CHANGED
|
@@ -23,6 +23,14 @@ export interface Profile {
|
|
|
23
23
|
/** May this target become a hyperlink? Disallowed links render their
|
|
24
24
|
* children without an anchor (content survives, capability doesn't). */
|
|
25
25
|
linkAllowed(target: string): boolean;
|
|
26
|
+
/** Where an allowed link points: null (or no method) keeps the target as
|
|
27
|
+
* written; a string is the address the anchor uses instead (a note's
|
|
28
|
+
* chapter inside an ePub, a file's path inside an export). Asked only after
|
|
29
|
+
* `linkAllowed` says yes - a refused link stays refused - and for every
|
|
30
|
+
* anchor the renderer draws from an author target: links, turbolinks, and
|
|
31
|
+
* the fallback link of an embed. The visible text stays the author's;
|
|
32
|
+
* `turbolink` and `media` still see the original target. */
|
|
33
|
+
linkTarget?(target: string): string | null;
|
|
26
34
|
/** Resolve an embed target to a media kind, or null for the inert
|
|
27
35
|
* fallback. The kind is resolved at render time (SPEC.md, "Media"). */
|
|
28
36
|
media(target: string): MediaResolution | null;
|
|
@@ -54,6 +62,9 @@ export interface Profile {
|
|
|
54
62
|
* - Place the children: a claiming answer MUST include `renderedChildren`
|
|
55
63
|
* (already-escaped HTML) or a known substitute for them - the shrug's
|
|
56
64
|
* never-eat-content rule binds embedder vocabulary too.
|
|
65
|
+
* - Match the output: a profile rendered with `{ output: "xhtml" }` must
|
|
66
|
+
* return well-formed XHTML (here and in `span` and `turbolink`) - the
|
|
67
|
+
* renderer places embedder HTML as given and can't rewrite it.
|
|
57
68
|
* Embedder-first: a claim on a name the language defines wins. */
|
|
58
69
|
directive(name: string, attrs: Attrs, renderedChildren: string): string | null;
|
|
59
70
|
/** Embedder span vocabulary - the inline twin of `directive`
|
package/src/render.ts
CHANGED
|
@@ -8,16 +8,51 @@ import type { Attrs, Node } from "@cube-drone/marquee-parser";
|
|
|
8
8
|
import { bareWebProfile, type Profile, type TurbolinkLevel } from "./profile.ts";
|
|
9
9
|
import { effectLook, splitPieces, type EffectLook, type SplitPiece } from "./effects.ts";
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
export interface RenderOptions {
|
|
12
|
+
/** Which serialization to write. "html" (the default) is HTML5 for a web
|
|
13
|
+
* page: void elements unclosed (`<br>`), boolean attributes bare
|
|
14
|
+
* (`controls`). "xhtml" is well-formed XML for an ePub page or any XHTML
|
|
15
|
+
* document: void elements close themselves (`<br/>`), every attribute
|
|
16
|
+
* carries a quoted value, the only entities are XML's own, and characters
|
|
17
|
+
* XML forbids become U+FFFD. Same elements, same classes, same words.
|
|
18
|
+
* Embedder vocabulary is placed as given, so a profile used with "xhtml"
|
|
19
|
+
* must return XHTML too (see `Profile.directive`). */
|
|
20
|
+
output?: "html" | "xhtml";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Render state: the profile and the output spelling, plus the one piece of
|
|
24
|
+
* cross-block coordination the renderer owns - aside numbering (sequential
|
|
25
|
+
* through the document) and the pending notes that flush after the
|
|
26
|
+
* triggering block. */
|
|
14
27
|
interface Ctx {
|
|
15
28
|
profile: Profile;
|
|
29
|
+
/** Escape author text / an attribute value for the output spelling. */
|
|
30
|
+
text: (s: string) => string;
|
|
31
|
+
attr: (s: string) => string;
|
|
32
|
+
/** The end of a void element's start tag. */
|
|
33
|
+
void: string;
|
|
34
|
+
/** A boolean attribute with its leading space: bare in HTML, `name="name"`
|
|
35
|
+
* in XHTML (XML has no attribute without a value). */
|
|
36
|
+
flag: (name: string) => string;
|
|
16
37
|
note: { n: number; pending: string[] };
|
|
17
38
|
}
|
|
18
39
|
|
|
19
|
-
export function render(node: Node, profile: Profile = bareWebProfile): string {
|
|
20
|
-
|
|
40
|
+
export function render(node: Node, profile: Profile = bareWebProfile, options: RenderOptions = {}): string {
|
|
41
|
+
const xhtml = options.output === "xhtml";
|
|
42
|
+
const ctx: Ctx = {
|
|
43
|
+
profile,
|
|
44
|
+
text: xhtml ? escapeTextXml : escapeText,
|
|
45
|
+
attr: xhtml ? escapeAttrXml : escapeAttr,
|
|
46
|
+
void: xhtml ? "/>" : ">",
|
|
47
|
+
flag: xhtml ? (name) => ` ${name}="${name}"` : (name) => ` ${name}`,
|
|
48
|
+
note: { n: 0, pending: [] },
|
|
49
|
+
};
|
|
50
|
+
return renderNode(node, ctx);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The href an allowed link wears: the profile's rewrite, or the target. */
|
|
54
|
+
function href(target: string, ctx: Ctx): string {
|
|
55
|
+
return ctx.attr(ctx.profile.linkTarget?.(target) ?? target);
|
|
21
56
|
}
|
|
22
57
|
|
|
23
58
|
/** Asides render just below the paragraph (or heading) that triggered
|
|
@@ -49,8 +84,8 @@ function renderNode(node: Node, ctx: Ctx): string {
|
|
|
49
84
|
}
|
|
50
85
|
case "code_block": {
|
|
51
86
|
const lang = infoToken(node.info);
|
|
52
|
-
const cls = lang === null ? "" : ` class="language-${
|
|
53
|
-
const text = node.text === "" ? "" : `${
|
|
87
|
+
const cls = lang === null ? "" : ` class="language-${ctx.attr(lang)}"`;
|
|
88
|
+
const text = node.text === "" ? "" : `${ctx.text(node.text)}\n`;
|
|
54
89
|
return `<pre class="mq-code"><code${cls}>${text}</code></pre>`;
|
|
55
90
|
}
|
|
56
91
|
case "blockquote":
|
|
@@ -62,15 +97,15 @@ function renderNode(node: Node, ctx: Ctx): string {
|
|
|
62
97
|
case "list_item":
|
|
63
98
|
return `<li>${children(node.children, ctx)}</li>`;
|
|
64
99
|
case "thematic_break":
|
|
65
|
-
return
|
|
100
|
+
return `<hr${ctx.void}`;
|
|
66
101
|
case "directive":
|
|
67
102
|
return directive(node.name, node.attrs, node.children, ctx);
|
|
68
103
|
case "invalid_directive":
|
|
69
|
-
return `<div class="mq-invalid" data-reason="${
|
|
104
|
+
return `<div class="mq-invalid" data-reason="${ctx.attr(node.reason)}"></div>`;
|
|
70
105
|
case "comment":
|
|
71
106
|
return ""; // the anti-shrug: correct rendering is absence
|
|
72
107
|
case "text":
|
|
73
|
-
return
|
|
108
|
+
return ctx.text(node.value);
|
|
74
109
|
case "emphasis":
|
|
75
110
|
return `<em>${children(node.children, ctx)}</em>`;
|
|
76
111
|
case "strong":
|
|
@@ -78,29 +113,29 @@ function renderNode(node: Node, ctx: Ctx): string {
|
|
|
78
113
|
case "strikethrough":
|
|
79
114
|
return `<del>${children(node.children, ctx)}</del>`;
|
|
80
115
|
case "code_span":
|
|
81
|
-
return `<code>${
|
|
116
|
+
return `<code>${ctx.text(node.text)}</code>`;
|
|
82
117
|
case "link": {
|
|
83
118
|
const inner = children(node.children, ctx);
|
|
84
119
|
return ctx.profile.linkAllowed(node.target)
|
|
85
|
-
? `<a href="${
|
|
120
|
+
? `<a href="${href(node.target, ctx)}">${inner}</a>`
|
|
86
121
|
: `<span class="mq-blocked">${inner}</span>`;
|
|
87
122
|
}
|
|
88
123
|
case "embed":
|
|
89
|
-
return embed(node.target, node.alt, ctx
|
|
124
|
+
return embed(node.target, node.alt, ctx);
|
|
90
125
|
case "turbolink":
|
|
91
|
-
return turbolink(node.target, undefined, ctx
|
|
126
|
+
return turbolink(node.target, undefined, ctx);
|
|
92
127
|
case "span":
|
|
93
128
|
return span(node.name, node.attrs, node.children, ctx);
|
|
94
129
|
case "emoji": {
|
|
95
130
|
const resolved = ctx.profile.emoji(node.slug);
|
|
96
131
|
if (resolved !== null && typeof resolved === "object") {
|
|
97
132
|
const alt = resolved.alt ?? `:${node.slug}:`;
|
|
98
|
-
return `<img class="mq-emoji" src="${
|
|
133
|
+
return `<img class="mq-emoji" src="${ctx.attr(resolved.image)}" alt="${ctx.attr(alt)}" loading="lazy"${ctx.void}`;
|
|
99
134
|
}
|
|
100
|
-
return
|
|
135
|
+
return ctx.text(resolved ?? `:${node.slug}:`);
|
|
101
136
|
}
|
|
102
137
|
case "hard_break":
|
|
103
|
-
return
|
|
138
|
+
return `<br${ctx.void}`;
|
|
104
139
|
}
|
|
105
140
|
}
|
|
106
141
|
|
|
@@ -240,34 +275,37 @@ function infoToken(info: string | undefined): string | null {
|
|
|
240
275
|
|
|
241
276
|
// -- constructs
|
|
242
277
|
|
|
243
|
-
function embed(target: string, alt: string,
|
|
244
|
-
const media = profile.media(target);
|
|
278
|
+
function embed(target: string, alt: string, ctx: Ctx): string {
|
|
279
|
+
const media = ctx.profile.media(target);
|
|
245
280
|
if (media !== null) {
|
|
246
|
-
const url =
|
|
281
|
+
const url = ctx.attr(media.url);
|
|
282
|
+
const label = ctx.attr(alt);
|
|
283
|
+
const flag = ctx.flag;
|
|
247
284
|
switch (media.kind) {
|
|
248
285
|
case "image":
|
|
249
|
-
return `<img class="mq-embed" src="${url}" alt="${
|
|
286
|
+
return `<img class="mq-embed" src="${url}" alt="${label}" loading="lazy"${ctx.void}`;
|
|
250
287
|
case "audio":
|
|
251
|
-
return `<audio class="mq-embed"
|
|
288
|
+
return `<audio class="mq-embed"${flag("controls")} src="${url}" aria-label="${label}"></audio>`;
|
|
252
289
|
case "video":
|
|
253
290
|
return media.loop === true
|
|
254
|
-
? `<video class="mq-embed"
|
|
255
|
-
: `<video class="mq-embed"
|
|
291
|
+
? `<video class="mq-embed"${flag("autoplay")}${flag("loop")}${flag("muted")}${flag("playsinline")} src="${url}" aria-label="${label}"></video>`
|
|
292
|
+
: `<video class="mq-embed"${flag("controls")} src="${url}" aria-label="${label}"></video>`;
|
|
256
293
|
}
|
|
257
294
|
}
|
|
258
295
|
// The contractual shrug applied to media: degrade to a labeled link, or
|
|
259
296
|
// to inert text when the scheme is out of policy.
|
|
260
|
-
const label =
|
|
261
|
-
return profile.linkAllowed(target)
|
|
262
|
-
? `<a class="mq-embed-fallback" href="${
|
|
297
|
+
const label = ctx.text(`[${alt === "" ? target : alt}]`);
|
|
298
|
+
return ctx.profile.linkAllowed(target)
|
|
299
|
+
? `<a class="mq-embed-fallback" href="${href(target, ctx)}">${label}</a>`
|
|
263
300
|
: `<span class="mq-embed-fallback">${label}</span>`;
|
|
264
301
|
}
|
|
265
302
|
|
|
266
303
|
const TURBOLINK_LEVELS = new Set<TurbolinkLevel>(["full", "title", "bare"]);
|
|
267
304
|
|
|
268
|
-
function turbolink(target: string, levelAttr: string | undefined,
|
|
305
|
+
function turbolink(target: string, levelAttr: string | undefined, ctx: Ctx): string {
|
|
306
|
+
const { profile } = ctx;
|
|
269
307
|
if (!profile.linkAllowed(target)) {
|
|
270
|
-
return `<p class="mq-turbolink">${
|
|
308
|
+
return `<p class="mq-turbolink">${ctx.text(target)}</p>`;
|
|
271
309
|
}
|
|
272
310
|
const level =
|
|
273
311
|
levelAttr !== undefined && TURBOLINK_LEVELS.has(levelAttr as TurbolinkLevel)
|
|
@@ -278,11 +316,11 @@ function turbolink(target: string, levelAttr: string | undefined, profile: Profi
|
|
|
278
316
|
if (rich !== null) {
|
|
279
317
|
// Enrichment augments, never replaces: the wrapper itself carries the
|
|
280
318
|
// original link, so no plugin can eat it - not even by accident.
|
|
281
|
-
return `<div class="mq-turbolink mq-turbolink-rich">${rich}<a class="mq-turbolink-source" href="${
|
|
319
|
+
return `<div class="mq-turbolink mq-turbolink-rich">${rich}<a class="mq-turbolink-source" href="${href(target, ctx)}">${ctx.text(target)}</a></div>`;
|
|
282
320
|
}
|
|
283
321
|
}
|
|
284
322
|
// The contractual floor: a plain link, always reachable.
|
|
285
|
-
return `<p class="mq-turbolink"><a href="${
|
|
323
|
+
return `<p class="mq-turbolink"><a href="${href(target, ctx)}">${ctx.text(target)}</a></p>`;
|
|
286
324
|
}
|
|
287
325
|
|
|
288
326
|
/** A resolved URL made safe for a CSS url("...") token: encodeURI handles
|
|
@@ -381,7 +419,7 @@ function directive(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string
|
|
|
381
419
|
case "turbolink": {
|
|
382
420
|
const target = attrs["target"];
|
|
383
421
|
if (target !== undefined) {
|
|
384
|
-
return turbolink(target, attrs["level"],
|
|
422
|
+
return turbolink(target, attrs["level"], ctx);
|
|
385
423
|
}
|
|
386
424
|
break; // malformed use: fall through to the placeholder
|
|
387
425
|
}
|
|
@@ -434,10 +472,10 @@ function directive(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string
|
|
|
434
472
|
// differential-rendering hazard); role=base marks the common ancestor.
|
|
435
473
|
const head: string[] = [];
|
|
436
474
|
if (attrs["label"] !== undefined) {
|
|
437
|
-
head.push(`<span class="mq-variant-label">${
|
|
475
|
+
head.push(`<span class="mq-variant-label">${ctx.text(attrs["label"])}</span>`);
|
|
438
476
|
}
|
|
439
477
|
if (attrs["when"] !== undefined) {
|
|
440
|
-
head.push(`<span class="mq-variant-when">${
|
|
478
|
+
head.push(`<span class="mq-variant-when">${ctx.text(attrs["when"])}</span>`);
|
|
441
479
|
}
|
|
442
480
|
const cls = attrs["role"] === "base" ? "mq-variant mq-variant-base" : "mq-variant";
|
|
443
481
|
const headHtml = head.length === 0 ? "" : `<div class="mq-variant-head">${head.join(" ")}</div>`;
|
|
@@ -448,8 +486,8 @@ function directive(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string
|
|
|
448
486
|
// that something wrapped them; a leaf renders the inert placeholder.
|
|
449
487
|
// Never eat authored content.
|
|
450
488
|
return nodes.length > 0
|
|
451
|
-
? `<div class="mq-unknown" data-directive="${
|
|
452
|
-
: `<div class="mq-placeholder" data-directive="${
|
|
489
|
+
? `<div class="mq-unknown" data-directive="${ctx.attr(name)}">${inner}</div>`
|
|
490
|
+
: `<div class="mq-placeholder" data-directive="${ctx.attr(name)}"></div>`;
|
|
453
491
|
}
|
|
454
492
|
|
|
455
493
|
/** :::table (SPEC.md, "Tables"): each paragraph child is a row; a row's
|
|
@@ -607,7 +645,7 @@ function span(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string {
|
|
|
607
645
|
const value = attrs["font"];
|
|
608
646
|
const face = value !== undefined ? FONTS[value] : undefined;
|
|
609
647
|
if (face !== undefined) {
|
|
610
|
-
return `<font class="mq-font-${value}" face="${
|
|
648
|
+
return `<font class="mq-font-${value}" face="${ctx.attr(face)}">${inner}</font>`;
|
|
611
649
|
}
|
|
612
650
|
return inner; // not on the list: the words survive in their own clothes
|
|
613
651
|
}
|
|
@@ -647,8 +685,8 @@ function span(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string {
|
|
|
647
685
|
// effects.ts, shared with every other surface that draws effects.
|
|
648
686
|
|
|
649
687
|
function renderEffect(name: string, look: EffectLook, nodes: Node[], inner: string, ctx: Ctx): string {
|
|
650
|
-
const data = look.data.map(([k, v]) => ` data-${k}="${
|
|
651
|
-
const style = look.vars.length === 0 ? "" : ` style="${look.vars.map(([k, v]) => `${k}:${
|
|
688
|
+
const data = look.data.map(([k, v]) => ` data-${k}="${ctx.attr(v)}"`).join("");
|
|
689
|
+
const style = look.vars.length === 0 ? "" : ` style="${look.vars.map(([k, v]) => `${k}:${ctx.attr(v)}`).join(";")}"`;
|
|
652
690
|
if (name === "marquee") {
|
|
653
691
|
return `<span class="${look.className}"${data}${style}><span class="mq-marquee-inner">${inner}</span></span>`;
|
|
654
692
|
}
|
|
@@ -664,9 +702,9 @@ function splitRender(nodes: Node[], ctx: Ctx, look: EffectLook): string {
|
|
|
664
702
|
list
|
|
665
703
|
.map((p) =>
|
|
666
704
|
p.kind === "text"
|
|
667
|
-
?
|
|
705
|
+
? ctx.text(p.text)
|
|
668
706
|
: p.kind === "unit"
|
|
669
|
-
? `<span class="mq-l" style="--mq-o:${p.offset}">${
|
|
707
|
+
? `<span class="mq-l" style="--mq-o:${p.offset}">${ctx.text(p.text)}</span>`
|
|
670
708
|
: `<span class="mq-w">${pieces(p.pieces)}</span>`,
|
|
671
709
|
)
|
|
672
710
|
.join("");
|
|
@@ -696,3 +734,24 @@ export function escapeText(s: string): string {
|
|
|
696
734
|
export function escapeAttr(s: string): string {
|
|
697
735
|
return escapeText(s).replaceAll('"', """);
|
|
698
736
|
}
|
|
737
|
+
|
|
738
|
+
/** Characters XML 1.0 forbids outright - even as a numeric reference: the C0
|
|
739
|
+
* controls bar tab, newline, and return, the noncharacters U+FFFE/U+FFFF,
|
|
740
|
+
* and (a JS string can hold one) a lone surrogate. HTML parsers shrug these
|
|
741
|
+
* off; an XML parser refuses the whole page, so XHTML output carries U+FFFD
|
|
742
|
+
* in their place. */
|
|
743
|
+
const XML_FORBIDDEN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
744
|
+
|
|
745
|
+
function escapeTextXml(s: string): string {
|
|
746
|
+
return escapeText(s).replace(XML_FORBIDDEN, "\uFFFD");
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** An XML parser normalizes whitespace in an attribute value to spaces, so a
|
|
750
|
+
* newline in alt text survives only as a character reference. */
|
|
751
|
+
function escapeAttrXml(s: string): string {
|
|
752
|
+
return escapeTextXml(s)
|
|
753
|
+
.replaceAll('"', """)
|
|
754
|
+
.replaceAll("\t", "	")
|
|
755
|
+
.replaceAll("\n", " ")
|
|
756
|
+
.replaceAll("\r", " ");
|
|
757
|
+
}
|