@cube-drone/marquee-html-renderer 0.9.2 → 0.10.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/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/profile.js +13 -3
- package/dist/render.d.ts +15 -2
- package/dist/render.js +108 -74
- package/package.json +6 -5
- package/src/index.ts +6 -5
- package/src/profile.ts +25 -3
- package/src/render.ts +137 -77
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/profile.js
CHANGED
|
@@ -4,14 +4,19 @@
|
|
|
4
4
|
// guesses trust, never widens a scheme allowlist.
|
|
5
5
|
// Mirror of the Rust renderer's table (rust/html_renderer/src/profile.rs). `apng` and
|
|
6
6
|
// `opus` joined 2026-09-03: ringtome mints both and they rendered as the placeholder.
|
|
7
|
-
const MEDIA_KINDS = {
|
|
7
|
+
const MEDIA_KINDS = Object.assign(Object.create(null), {
|
|
8
8
|
png: "image", jpg: "image", jpeg: "image", gif: "image", webp: "image",
|
|
9
9
|
avif: "image", apng: "image", svg: "image",
|
|
10
10
|
mp3: "audio", ogg: "audio", opus: "audio", wav: "audio", flac: "audio", m4a: "audio",
|
|
11
11
|
mp4: "video", webm: "video",
|
|
12
|
-
};
|
|
12
|
+
});
|
|
13
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
14
|
+
// Read the scheme the way a browser will: the URL parser strips leading C0
|
|
15
|
+
// controls and spaces and drops tabs/newlines anywhere, so
|
|
16
|
+
// "\x01javascript:" and "java\tscript:" are javascript: URLs. Targets with
|
|
17
|
+
// controls are refused outright (see linkAllowed); leading spaces are skipped.
|
|
13
18
|
function scheme(target) {
|
|
14
|
-
const m = /^([A-Za-z][A-Za-z0-9+.-]*):/.exec(target);
|
|
19
|
+
const m = /^ *([A-Za-z][A-Za-z0-9+.-]*):/.exec(target);
|
|
15
20
|
return m ? m[1].toLowerCase() : null;
|
|
16
21
|
}
|
|
17
22
|
/** The bare-web default: https links, extension-sniffed media, no widgets,
|
|
@@ -19,6 +24,11 @@ function scheme(target) {
|
|
|
19
24
|
* degrades inert. */
|
|
20
25
|
export const bareWebProfile = {
|
|
21
26
|
linkAllowed(target) {
|
|
27
|
+
// A lone surrogate isn't a URL anyone can write; refusing it here keeps
|
|
28
|
+
// every anchor and media path well-formed.
|
|
29
|
+
if (/[\x00-\x1f\x7f]/.test(target) || LONE_SURROGATE.test(target)) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
22
32
|
const s = scheme(target);
|
|
23
33
|
return s === null || s === "http" || s === "https" || s === "mailto";
|
|
24
34
|
},
|
package/dist/render.d.ts
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
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).
|
|
7
18
|
* Values are the `face` presentational attribute - the no-stylesheet floor;
|
|
8
|
-
* the mq-font-* class is the ceiling, where the real @font-face lives.
|
|
19
|
+
* the mq-font-* class is the ceiling, where the real @font-face lives.
|
|
20
|
+
* Author-keyed tables like this one have no prototype, so `font=constructor`
|
|
21
|
+
* finds nothing instead of Object's own properties. */
|
|
9
22
|
export declare const FONTS: Record<string, string>;
|
|
10
23
|
/** Which grab-bag faces does this rendered HTML actually wear? Pure string
|
|
11
24
|
* scan of the mq-font-* class contract - feed the result to
|
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) {
|
|
@@ -106,8 +119,10 @@ function isToken(v) {
|
|
|
106
119
|
* no files, and the grab bag - SIL OFL faces the embedder serves itself
|
|
107
120
|
* (never a third-party CDN: fonts are a tracking vector, care-modes apply).
|
|
108
121
|
* Values are the `face` presentational attribute - the no-stylesheet floor;
|
|
109
|
-
* the mq-font-* class is the ceiling, where the real @font-face lives.
|
|
110
|
-
|
|
122
|
+
* the mq-font-* class is the ceiling, where the real @font-face lives.
|
|
123
|
+
* Author-keyed tables like this one have no prototype, so `font=constructor`
|
|
124
|
+
* finds nothing instead of Object's own properties. */
|
|
125
|
+
export const FONTS = Object.assign(Object.create(null), {
|
|
111
126
|
// standard stacks
|
|
112
127
|
sans: "sans-serif",
|
|
113
128
|
serif: "serif",
|
|
@@ -145,7 +160,7 @@ export const FONTS = {
|
|
|
145
160
|
rye: "Rye",
|
|
146
161
|
bitcount: "Bitcount",
|
|
147
162
|
quicksand: "Quicksand",
|
|
148
|
-
};
|
|
163
|
+
});
|
|
149
164
|
/** Which grab-bag faces does this rendered HTML actually wear? Pure string
|
|
150
165
|
* scan of the mq-font-* class contract - feed the result to
|
|
151
166
|
* @cube-drone/marquee-fonts (externalFontFaces / inlineFontFaces) to deliver
|
|
@@ -180,12 +195,12 @@ const SCHEME_FONTS = [
|
|
|
180
195
|
function sizeRung(value, inner) {
|
|
181
196
|
return `<font class="mq-size-${value}" size="${value}">${inner}</font>`;
|
|
182
197
|
}
|
|
183
|
-
const MEDIA_SIZE_TOKENS = {
|
|
198
|
+
const MEDIA_SIZE_TOKENS = Object.assign(Object.create(null), {
|
|
184
199
|
small: "10rem",
|
|
185
200
|
medium: "20rem",
|
|
186
201
|
large: "32rem",
|
|
187
202
|
full: "100%",
|
|
188
|
-
};
|
|
203
|
+
});
|
|
189
204
|
/** Media width/height: a size token or a capped integer of pixels (SPEC.md,
|
|
190
205
|
* "Media"). Anything else degrades to natural sizing. */
|
|
191
206
|
function mediaSize(v) {
|
|
@@ -212,32 +227,35 @@ function infoToken(info) {
|
|
|
212
227
|
return /^[A-Za-z0-9_+.#-]{1,64}$/.test(first) ? first : null;
|
|
213
228
|
}
|
|
214
229
|
// -- constructs
|
|
215
|
-
function embed(target, alt,
|
|
216
|
-
const media = profile.media(target);
|
|
230
|
+
function embed(target, alt, ctx) {
|
|
231
|
+
const media = ctx.profile.media(target);
|
|
217
232
|
if (media !== null) {
|
|
218
|
-
const url =
|
|
233
|
+
const url = ctx.attr(media.url);
|
|
234
|
+
const label = ctx.attr(alt);
|
|
235
|
+
const flag = ctx.flag;
|
|
219
236
|
switch (media.kind) {
|
|
220
237
|
case "image":
|
|
221
|
-
return `<img class="mq-embed" src="${url}" alt="${
|
|
238
|
+
return `<img class="mq-embed" src="${url}" alt="${label}" loading="lazy"${ctx.void}`;
|
|
222
239
|
case "audio":
|
|
223
|
-
return `<audio class="mq-embed"
|
|
240
|
+
return `<audio class="mq-embed"${flag("controls")} src="${url}" aria-label="${label}"></audio>`;
|
|
224
241
|
case "video":
|
|
225
242
|
return media.loop === true
|
|
226
|
-
? `<video class="mq-embed"
|
|
227
|
-
: `<video class="mq-embed"
|
|
243
|
+
? `<video class="mq-embed"${flag("autoplay")}${flag("loop")}${flag("muted")}${flag("playsinline")} src="${url}" aria-label="${label}"></video>`
|
|
244
|
+
: `<video class="mq-embed"${flag("controls")} src="${url}" aria-label="${label}"></video>`;
|
|
228
245
|
}
|
|
229
246
|
}
|
|
230
247
|
// The contractual shrug applied to media: degrade to a labeled link, or
|
|
231
248
|
// 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="${
|
|
249
|
+
const label = ctx.text(`[${alt === "" ? target : alt}]`);
|
|
250
|
+
return ctx.profile.linkAllowed(target)
|
|
251
|
+
? `<a class="mq-embed-fallback" href="${href(target, ctx)}">${label}</a>`
|
|
235
252
|
: `<span class="mq-embed-fallback">${label}</span>`;
|
|
236
253
|
}
|
|
237
254
|
const TURBOLINK_LEVELS = new Set(["full", "title", "bare"]);
|
|
238
|
-
function turbolink(target, levelAttr,
|
|
255
|
+
function turbolink(target, levelAttr, ctx) {
|
|
256
|
+
const { profile } = ctx;
|
|
239
257
|
if (!profile.linkAllowed(target)) {
|
|
240
|
-
return `<p class="mq-turbolink">${
|
|
258
|
+
return `<p class="mq-turbolink">${ctx.text(target)}</p>`;
|
|
241
259
|
}
|
|
242
260
|
const level = levelAttr !== undefined && TURBOLINK_LEVELS.has(levelAttr)
|
|
243
261
|
? levelAttr
|
|
@@ -247,19 +265,21 @@ function turbolink(target, levelAttr, profile) {
|
|
|
247
265
|
if (rich !== null) {
|
|
248
266
|
// Enrichment augments, never replaces: the wrapper itself carries the
|
|
249
267
|
// 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="${
|
|
268
|
+
return `<div class="mq-turbolink mq-turbolink-rich">${rich}<a class="mq-turbolink-source" href="${href(target, ctx)}">${ctx.text(target)}</a></div>`;
|
|
251
269
|
}
|
|
252
270
|
}
|
|
253
271
|
// The contractual floor: a plain link, always reachable.
|
|
254
|
-
return `<p class="mq-turbolink"><a href="${
|
|
272
|
+
return `<p class="mq-turbolink"><a href="${href(target, ctx)}">${ctx.text(target)}</a></p>`;
|
|
255
273
|
}
|
|
256
274
|
/** A resolved URL made safe for a CSS url("...") token: encodeURI handles
|
|
257
275
|
* whitespace, controls, backslashes, and double quotes; quotes and parens
|
|
258
276
|
* that encodeURI deliberately leaves are percent-encoded on top, so the
|
|
259
277
|
* value can never terminate the url() or the declaration - author bytes
|
|
260
278
|
* must not write CSS, even inside their own style attribute. */
|
|
279
|
+
// encodeURI throws on a lone surrogate; an embedder's media() may pass one.
|
|
280
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
261
281
|
function cssUrl(url) {
|
|
262
|
-
return encodeURI(url).replaceAll("'", "%27").replaceAll("(", "%28").replaceAll(")", "%29");
|
|
282
|
+
return encodeURI(url.replace(LONE_SURROGATE, "\uFFFD")).replaceAll("'", "%27").replaceAll("(", "%28").replaceAll(")", "%29");
|
|
263
283
|
}
|
|
264
284
|
/** Style knobs on a block node: validated values into --mq-* slots; the
|
|
265
285
|
* stylesheet owns which CSS property each slot feeds. `background` takes a
|
|
@@ -290,9 +310,12 @@ function styleVarList(attrs, profile) {
|
|
|
290
310
|
}
|
|
291
311
|
return vars.join(";");
|
|
292
312
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
313
|
+
/** The style attribute, attribute-escaped like any other: the browser
|
|
314
|
+
* decodes character references before CSS sees the value, so a raw `'`
|
|
315
|
+
* in a tile URL would otherwise close the url() and write CSS. */
|
|
316
|
+
function styleVars(attrs, ctx) {
|
|
317
|
+
const list = styleVarList(attrs, ctx.profile);
|
|
318
|
+
return list === "" ? "" : ` style="${ctx.attr(list)}"`;
|
|
296
319
|
}
|
|
297
320
|
/** The visual identity a layout container (:::page / :::section) wears -
|
|
298
321
|
* its scheme/font classes and validated style knobs - as bare class/style
|
|
@@ -315,14 +338,13 @@ function fontClass(attrs) {
|
|
|
315
338
|
}
|
|
316
339
|
function directive(name, attrs, nodes, ctx) {
|
|
317
340
|
const { profile } = ctx;
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
ctx
|
|
324
|
-
|
|
325
|
-
};
|
|
341
|
+
// Built-ins that lay their children out themselves (table, stack) probe
|
|
342
|
+
// the hook (it claims on name and attrs alone) instead of rendering the
|
|
343
|
+
// children for it: rendering them twice per level is exponential in
|
|
344
|
+
// nesting, and a note inside would count twice.
|
|
345
|
+
if ((name === "table" || name === "stack") && profile.directive(name, attrs, "") === null) {
|
|
346
|
+
return name === "table" ? renderTable(attrs, nodes, ctx) : renderStack(attrs, nodes, ctx);
|
|
347
|
+
}
|
|
326
348
|
const inner = children(nodes, ctx);
|
|
327
349
|
const custom = profile.directive(name, attrs, inner);
|
|
328
350
|
if (custom !== null) {
|
|
@@ -335,16 +357,16 @@ function directive(name, attrs, nodes, ctx) {
|
|
|
335
357
|
return inner;
|
|
336
358
|
case "page": {
|
|
337
359
|
const layout = isToken(attrs["layout"]) ? ` mq-layout-${attrs["layout"]}` : "";
|
|
338
|
-
return `<div class="mq-page${layout}${schemeClass(attrs)}${fontClass(attrs)}"${styleVars(attrs,
|
|
360
|
+
return `<div class="mq-page${layout}${schemeClass(attrs)}${fontClass(attrs)}"${styleVars(attrs, ctx)}>${inner}</div>`;
|
|
339
361
|
}
|
|
340
362
|
case "section": {
|
|
341
363
|
const slot = isToken(attrs["slot"]) ? ` data-slot="${attrs["slot"]}"` : "";
|
|
342
|
-
return `<section class="mq-section${schemeClass(attrs)}${fontClass(attrs)}"${slot}${styleVars(attrs,
|
|
364
|
+
return `<section class="mq-section${schemeClass(attrs)}${fontClass(attrs)}"${slot}${styleVars(attrs, ctx)}>${inner}</section>`;
|
|
343
365
|
}
|
|
344
366
|
case "turbolink": {
|
|
345
367
|
const target = attrs["target"];
|
|
346
368
|
if (target !== undefined) {
|
|
347
|
-
return turbolink(target, attrs["level"],
|
|
369
|
+
return turbolink(target, attrs["level"], ctx);
|
|
348
370
|
}
|
|
349
371
|
break; // malformed use: fall through to the placeholder
|
|
350
372
|
}
|
|
@@ -361,16 +383,10 @@ function directive(name, attrs, nodes, ctx) {
|
|
|
361
383
|
const style = vars.length === 0 ? "" : ` style="${vars.join(";")}"`;
|
|
362
384
|
return `<div class="mq-media"${style}>${inner}</div>`;
|
|
363
385
|
}
|
|
364
|
-
case "table":
|
|
365
|
-
rewind();
|
|
366
|
-
return renderTable(attrs, nodes, ctx);
|
|
367
|
-
case "stack":
|
|
368
|
-
rewind();
|
|
369
|
-
return renderStack(attrs, nodes, ctx);
|
|
370
386
|
case "layer":
|
|
371
387
|
// Outside a stack a layer is just a section-like container: its style
|
|
372
388
|
// knobs apply, place/backing mean nothing.
|
|
373
|
-
return `<div class="mq-layer${schemeClass(attrs)}${fontClass(attrs)}"${styleVars(attrs,
|
|
389
|
+
return `<div class="mq-layer${schemeClass(attrs)}${fontClass(attrs)}"${styleVars(attrs, ctx)}>${inner}</div>`;
|
|
374
390
|
case "center":
|
|
375
391
|
case "right":
|
|
376
392
|
case "left":
|
|
@@ -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
|
|
@@ -466,13 +482,13 @@ const PLACES = new Set([
|
|
|
466
482
|
"fill", "top-left", "top", "top-right", "left", "center", "right", "bottom-left", "bottom", "bottom-right",
|
|
467
483
|
]);
|
|
468
484
|
/** The `aspect` vocabulary as CSS aspect-ratio values. */
|
|
469
|
-
const STACK_ASPECTS = {
|
|
485
|
+
const STACK_ASPECTS = Object.assign(Object.create(null), {
|
|
470
486
|
"16:9": "16/9",
|
|
471
487
|
"4:3": "4/3",
|
|
472
488
|
"3:2": "3/2",
|
|
473
489
|
"1:1": "1/1",
|
|
474
490
|
card: "5/7",
|
|
475
|
-
};
|
|
491
|
+
});
|
|
476
492
|
/** :::stack (SPEC.md, "Stacks"): every direct child is a layer, source order
|
|
477
493
|
* is z-order. The stylesheet puts every layer in one grid cell, so the box
|
|
478
494
|
* is as big as its biggest layer needs and paint order is DOM order: no
|
|
@@ -512,7 +528,7 @@ function renderLayer(node, ctx) {
|
|
|
512
528
|
? " mq-layer-cover"
|
|
513
529
|
: "";
|
|
514
530
|
const inner = children(content, ctx);
|
|
515
|
-
return `<div class="mq-layer mq-place-${place}${backing}${cover}${schemeClass(attrs)}${fontClass(attrs)}"${styleVars(attrs, ctx
|
|
531
|
+
return `<div class="mq-layer mq-place-${place}${backing}${cover}${schemeClass(attrs)}${fontClass(attrs)}"${styleVars(attrs, ctx)}>${inner}</div>`;
|
|
516
532
|
}
|
|
517
533
|
/** Effect/typographic spans. The `<font>` tag is deliberate: presentational
|
|
518
534
|
* attributes are outside CSP's jurisdiction and are implemented by the
|
|
@@ -520,19 +536,19 @@ function renderLayer(node, ctx) {
|
|
|
520
536
|
* attributes - the floor of the degradation ladder. The --mq-color slot is
|
|
521
537
|
* the stylesheet-era ceiling on the same element. */
|
|
522
538
|
function span(name, attrs, nodes, ctx) {
|
|
523
|
-
|
|
539
|
+
// Probe first (the hook claims on name and attrs alone): a split renders
|
|
540
|
+
// its children itself, and rendering them here as well doubled the work at
|
|
541
|
+
// every nesting level - exponential in a document anyone can sync.
|
|
542
|
+
const look = ctx.profile.span(name, attrs, "") === null ? effectLook(name, attrs, nodes) : null;
|
|
543
|
+
if (look !== null && look.units !== null) {
|
|
544
|
+
return renderEffect(name, look, nodes, "", ctx);
|
|
545
|
+
}
|
|
524
546
|
const inner = children(nodes, ctx);
|
|
525
547
|
const custom = ctx.profile.span(name, attrs, inner);
|
|
526
548
|
if (custom !== null) {
|
|
527
549
|
return custom;
|
|
528
550
|
}
|
|
529
|
-
const look = effectLook(name, attrs, nodes);
|
|
530
551
|
if (look !== null) {
|
|
531
|
-
if (look.units !== null) {
|
|
532
|
-
// A split renders its children again: rewind the aside numbering.
|
|
533
|
-
ctx.note.n = saved.n;
|
|
534
|
-
ctx.note.pending = [...saved.pending];
|
|
535
|
-
}
|
|
536
552
|
return renderEffect(name, look, nodes, inner, ctx);
|
|
537
553
|
}
|
|
538
554
|
switch (name) {
|
|
@@ -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.
|
|
3
|
+
"version": "0.10.0",
|
|
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.
|
|
38
|
+
"@cube-drone/marquee-parser": "^0.10.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@cube-drone/marquee-css": "^0.
|
|
42
|
-
"@cube-drone/marquee-fonts": "^0.
|
|
43
|
-
"@cube-drone/marquee-turbolink": "^0.
|
|
41
|
+
"@cube-drone/marquee-css": "^0.10.0",
|
|
42
|
+
"@cube-drone/marquee-fonts": "^0.10.0",
|
|
43
|
+
"@cube-drone/marquee-turbolink": "^0.10.0",
|
|
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`
|
|
@@ -65,15 +76,21 @@ export interface Profile {
|
|
|
65
76
|
|
|
66
77
|
// Mirror of the Rust renderer's table (rust/html_renderer/src/profile.rs). `apng` and
|
|
67
78
|
// `opus` joined 2026-09-03: ringtome mints both and they rendered as the placeholder.
|
|
68
|
-
const MEDIA_KINDS: Record<string, MediaResolution["kind"]> = {
|
|
79
|
+
const MEDIA_KINDS: Record<string, MediaResolution["kind"]> = Object.assign(Object.create(null), {
|
|
69
80
|
png: "image", jpg: "image", jpeg: "image", gif: "image", webp: "image",
|
|
70
81
|
avif: "image", apng: "image", svg: "image",
|
|
71
82
|
mp3: "audio", ogg: "audio", opus: "audio", wav: "audio", flac: "audio", m4a: "audio",
|
|
72
83
|
mp4: "video", webm: "video",
|
|
73
|
-
};
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
74
87
|
|
|
88
|
+
// Read the scheme the way a browser will: the URL parser strips leading C0
|
|
89
|
+
// controls and spaces and drops tabs/newlines anywhere, so
|
|
90
|
+
// "\x01javascript:" and "java\tscript:" are javascript: URLs. Targets with
|
|
91
|
+
// controls are refused outright (see linkAllowed); leading spaces are skipped.
|
|
75
92
|
function scheme(target: string): string | null {
|
|
76
|
-
const m = /^([A-Za-z][A-Za-z0-9+.-]*):/.exec(target);
|
|
93
|
+
const m = /^ *([A-Za-z][A-Za-z0-9+.-]*):/.exec(target);
|
|
77
94
|
return m ? m[1]!.toLowerCase() : null;
|
|
78
95
|
}
|
|
79
96
|
|
|
@@ -82,6 +99,11 @@ function scheme(target: string): string | null {
|
|
|
82
99
|
* degrades inert. */
|
|
83
100
|
export const bareWebProfile: Profile = {
|
|
84
101
|
linkAllowed(target) {
|
|
102
|
+
// A lone surrogate isn't a URL anyone can write; refusing it here keeps
|
|
103
|
+
// every anchor and media path well-formed.
|
|
104
|
+
if (/[\x00-\x1f\x7f]/.test(target) || LONE_SURROGATE.test(target)) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
85
107
|
const s = scheme(target);
|
|
86
108
|
return s === null || s === "http" || s === "https" || s === "mailto";
|
|
87
109
|
},
|
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
|
|
|
@@ -126,8 +161,10 @@ function isToken(v: string | undefined): v is string {
|
|
|
126
161
|
* no files, and the grab bag - SIL OFL faces the embedder serves itself
|
|
127
162
|
* (never a third-party CDN: fonts are a tracking vector, care-modes apply).
|
|
128
163
|
* Values are the `face` presentational attribute - the no-stylesheet floor;
|
|
129
|
-
* the mq-font-* class is the ceiling, where the real @font-face lives.
|
|
130
|
-
|
|
164
|
+
* the mq-font-* class is the ceiling, where the real @font-face lives.
|
|
165
|
+
* Author-keyed tables like this one have no prototype, so `font=constructor`
|
|
166
|
+
* finds nothing instead of Object's own properties. */
|
|
167
|
+
export const FONTS: Record<string, string> = Object.assign(Object.create(null), {
|
|
131
168
|
// standard stacks
|
|
132
169
|
sans: "sans-serif",
|
|
133
170
|
serif: "serif",
|
|
@@ -165,7 +202,7 @@ export const FONTS: Record<string, string> = {
|
|
|
165
202
|
rye: "Rye",
|
|
166
203
|
bitcount: "Bitcount",
|
|
167
204
|
quicksand: "Quicksand",
|
|
168
|
-
};
|
|
205
|
+
});
|
|
169
206
|
|
|
170
207
|
/** Which grab-bag faces does this rendered HTML actually wear? Pure string
|
|
171
208
|
* scan of the mq-font-* class contract - feed the result to
|
|
@@ -204,12 +241,12 @@ function sizeRung(value: string, inner: string): string {
|
|
|
204
241
|
return `<font class="mq-size-${value}" size="${value}">${inner}</font>`;
|
|
205
242
|
}
|
|
206
243
|
|
|
207
|
-
const MEDIA_SIZE_TOKENS: Record<string, string> = {
|
|
244
|
+
const MEDIA_SIZE_TOKENS: Record<string, string> = Object.assign(Object.create(null), {
|
|
208
245
|
small: "10rem",
|
|
209
246
|
medium: "20rem",
|
|
210
247
|
large: "32rem",
|
|
211
248
|
full: "100%",
|
|
212
|
-
};
|
|
249
|
+
});
|
|
213
250
|
|
|
214
251
|
/** Media width/height: a size token or a capped integer of pixels (SPEC.md,
|
|
215
252
|
* "Media"). Anything else degrades to natural sizing. */
|
|
@@ -240,34 +277,37 @@ function infoToken(info: string | undefined): string | null {
|
|
|
240
277
|
|
|
241
278
|
// -- constructs
|
|
242
279
|
|
|
243
|
-
function embed(target: string, alt: string,
|
|
244
|
-
const media = profile.media(target);
|
|
280
|
+
function embed(target: string, alt: string, ctx: Ctx): string {
|
|
281
|
+
const media = ctx.profile.media(target);
|
|
245
282
|
if (media !== null) {
|
|
246
|
-
const url =
|
|
283
|
+
const url = ctx.attr(media.url);
|
|
284
|
+
const label = ctx.attr(alt);
|
|
285
|
+
const flag = ctx.flag;
|
|
247
286
|
switch (media.kind) {
|
|
248
287
|
case "image":
|
|
249
|
-
return `<img class="mq-embed" src="${url}" alt="${
|
|
288
|
+
return `<img class="mq-embed" src="${url}" alt="${label}" loading="lazy"${ctx.void}`;
|
|
250
289
|
case "audio":
|
|
251
|
-
return `<audio class="mq-embed"
|
|
290
|
+
return `<audio class="mq-embed"${flag("controls")} src="${url}" aria-label="${label}"></audio>`;
|
|
252
291
|
case "video":
|
|
253
292
|
return media.loop === true
|
|
254
|
-
? `<video class="mq-embed"
|
|
255
|
-
: `<video class="mq-embed"
|
|
293
|
+
? `<video class="mq-embed"${flag("autoplay")}${flag("loop")}${flag("muted")}${flag("playsinline")} src="${url}" aria-label="${label}"></video>`
|
|
294
|
+
: `<video class="mq-embed"${flag("controls")} src="${url}" aria-label="${label}"></video>`;
|
|
256
295
|
}
|
|
257
296
|
}
|
|
258
297
|
// The contractual shrug applied to media: degrade to a labeled link, or
|
|
259
298
|
// 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="${
|
|
299
|
+
const label = ctx.text(`[${alt === "" ? target : alt}]`);
|
|
300
|
+
return ctx.profile.linkAllowed(target)
|
|
301
|
+
? `<a class="mq-embed-fallback" href="${href(target, ctx)}">${label}</a>`
|
|
263
302
|
: `<span class="mq-embed-fallback">${label}</span>`;
|
|
264
303
|
}
|
|
265
304
|
|
|
266
305
|
const TURBOLINK_LEVELS = new Set<TurbolinkLevel>(["full", "title", "bare"]);
|
|
267
306
|
|
|
268
|
-
function turbolink(target: string, levelAttr: string | undefined,
|
|
307
|
+
function turbolink(target: string, levelAttr: string | undefined, ctx: Ctx): string {
|
|
308
|
+
const { profile } = ctx;
|
|
269
309
|
if (!profile.linkAllowed(target)) {
|
|
270
|
-
return `<p class="mq-turbolink">${
|
|
310
|
+
return `<p class="mq-turbolink">${ctx.text(target)}</p>`;
|
|
271
311
|
}
|
|
272
312
|
const level =
|
|
273
313
|
levelAttr !== undefined && TURBOLINK_LEVELS.has(levelAttr as TurbolinkLevel)
|
|
@@ -278,11 +318,11 @@ function turbolink(target: string, levelAttr: string | undefined, profile: Profi
|
|
|
278
318
|
if (rich !== null) {
|
|
279
319
|
// Enrichment augments, never replaces: the wrapper itself carries the
|
|
280
320
|
// 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="${
|
|
321
|
+
return `<div class="mq-turbolink mq-turbolink-rich">${rich}<a class="mq-turbolink-source" href="${href(target, ctx)}">${ctx.text(target)}</a></div>`;
|
|
282
322
|
}
|
|
283
323
|
}
|
|
284
324
|
// The contractual floor: a plain link, always reachable.
|
|
285
|
-
return `<p class="mq-turbolink"><a href="${
|
|
325
|
+
return `<p class="mq-turbolink"><a href="${href(target, ctx)}">${ctx.text(target)}</a></p>`;
|
|
286
326
|
}
|
|
287
327
|
|
|
288
328
|
/** A resolved URL made safe for a CSS url("...") token: encodeURI handles
|
|
@@ -290,8 +330,11 @@ function turbolink(target: string, levelAttr: string | undefined, profile: Profi
|
|
|
290
330
|
* that encodeURI deliberately leaves are percent-encoded on top, so the
|
|
291
331
|
* value can never terminate the url() or the declaration - author bytes
|
|
292
332
|
* must not write CSS, even inside their own style attribute. */
|
|
333
|
+
// encodeURI throws on a lone surrogate; an embedder's media() may pass one.
|
|
334
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
335
|
+
|
|
293
336
|
function cssUrl(url: string): string {
|
|
294
|
-
return encodeURI(url).replaceAll("'", "%27").replaceAll("(", "%28").replaceAll(")", "%29");
|
|
337
|
+
return encodeURI(url.replace(LONE_SURROGATE, "\uFFFD")).replaceAll("'", "%27").replaceAll("(", "%28").replaceAll(")", "%29");
|
|
295
338
|
}
|
|
296
339
|
|
|
297
340
|
/** Style knobs on a block node: validated values into --mq-* slots; the
|
|
@@ -323,9 +366,12 @@ function styleVarList(attrs: Attrs, profile: Profile): string {
|
|
|
323
366
|
return vars.join(";");
|
|
324
367
|
}
|
|
325
368
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
369
|
+
/** The style attribute, attribute-escaped like any other: the browser
|
|
370
|
+
* decodes character references before CSS sees the value, so a raw `'`
|
|
371
|
+
* in a tile URL would otherwise close the url() and write CSS. */
|
|
372
|
+
function styleVars(attrs: Attrs, ctx: Ctx): string {
|
|
373
|
+
const list = styleVarList(attrs, ctx.profile);
|
|
374
|
+
return list === "" ? "" : ` style="${ctx.attr(list)}"`;
|
|
329
375
|
}
|
|
330
376
|
|
|
331
377
|
/** The visual identity a layout container (:::page / :::section) wears -
|
|
@@ -352,14 +398,13 @@ function fontClass(attrs: Attrs): string {
|
|
|
352
398
|
|
|
353
399
|
function directive(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string {
|
|
354
400
|
const { profile } = ctx;
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
ctx
|
|
361
|
-
|
|
362
|
-
};
|
|
401
|
+
// Built-ins that lay their children out themselves (table, stack) probe
|
|
402
|
+
// the hook (it claims on name and attrs alone) instead of rendering the
|
|
403
|
+
// children for it: rendering them twice per level is exponential in
|
|
404
|
+
// nesting, and a note inside would count twice.
|
|
405
|
+
if ((name === "table" || name === "stack") && profile.directive(name, attrs, "") === null) {
|
|
406
|
+
return name === "table" ? renderTable(attrs, nodes, ctx) : renderStack(attrs, nodes, ctx);
|
|
407
|
+
}
|
|
363
408
|
const inner = children(nodes, ctx);
|
|
364
409
|
const custom = profile.directive(name, attrs, inner);
|
|
365
410
|
if (custom !== null) {
|
|
@@ -372,16 +417,16 @@ function directive(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string
|
|
|
372
417
|
return inner;
|
|
373
418
|
case "page": {
|
|
374
419
|
const layout = isToken(attrs["layout"]) ? ` mq-layout-${attrs["layout"]}` : "";
|
|
375
|
-
return `<div class="mq-page${layout}${schemeClass(attrs)}${fontClass(attrs)}"${styleVars(attrs,
|
|
420
|
+
return `<div class="mq-page${layout}${schemeClass(attrs)}${fontClass(attrs)}"${styleVars(attrs, ctx)}>${inner}</div>`;
|
|
376
421
|
}
|
|
377
422
|
case "section": {
|
|
378
423
|
const slot = isToken(attrs["slot"]) ? ` data-slot="${attrs["slot"]}"` : "";
|
|
379
|
-
return `<section class="mq-section${schemeClass(attrs)}${fontClass(attrs)}"${slot}${styleVars(attrs,
|
|
424
|
+
return `<section class="mq-section${schemeClass(attrs)}${fontClass(attrs)}"${slot}${styleVars(attrs, ctx)}>${inner}</section>`;
|
|
380
425
|
}
|
|
381
426
|
case "turbolink": {
|
|
382
427
|
const target = attrs["target"];
|
|
383
428
|
if (target !== undefined) {
|
|
384
|
-
return turbolink(target, attrs["level"],
|
|
429
|
+
return turbolink(target, attrs["level"], ctx);
|
|
385
430
|
}
|
|
386
431
|
break; // malformed use: fall through to the placeholder
|
|
387
432
|
}
|
|
@@ -398,16 +443,10 @@ function directive(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string
|
|
|
398
443
|
const style = vars.length === 0 ? "" : ` style="${vars.join(";")}"`;
|
|
399
444
|
return `<div class="mq-media"${style}>${inner}</div>`;
|
|
400
445
|
}
|
|
401
|
-
case "table":
|
|
402
|
-
rewind();
|
|
403
|
-
return renderTable(attrs, nodes, ctx);
|
|
404
|
-
case "stack":
|
|
405
|
-
rewind();
|
|
406
|
-
return renderStack(attrs, nodes, ctx);
|
|
407
446
|
case "layer":
|
|
408
447
|
// Outside a stack a layer is just a section-like container: its style
|
|
409
448
|
// knobs apply, place/backing mean nothing.
|
|
410
|
-
return `<div class="mq-layer${schemeClass(attrs)}${fontClass(attrs)}"${styleVars(attrs,
|
|
449
|
+
return `<div class="mq-layer${schemeClass(attrs)}${fontClass(attrs)}"${styleVars(attrs, ctx)}>${inner}</div>`;
|
|
411
450
|
case "center":
|
|
412
451
|
case "right":
|
|
413
452
|
case "left":
|
|
@@ -434,10 +473,10 @@ function directive(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string
|
|
|
434
473
|
// differential-rendering hazard); role=base marks the common ancestor.
|
|
435
474
|
const head: string[] = [];
|
|
436
475
|
if (attrs["label"] !== undefined) {
|
|
437
|
-
head.push(`<span class="mq-variant-label">${
|
|
476
|
+
head.push(`<span class="mq-variant-label">${ctx.text(attrs["label"])}</span>`);
|
|
438
477
|
}
|
|
439
478
|
if (attrs["when"] !== undefined) {
|
|
440
|
-
head.push(`<span class="mq-variant-when">${
|
|
479
|
+
head.push(`<span class="mq-variant-when">${ctx.text(attrs["when"])}</span>`);
|
|
441
480
|
}
|
|
442
481
|
const cls = attrs["role"] === "base" ? "mq-variant mq-variant-base" : "mq-variant";
|
|
443
482
|
const headHtml = head.length === 0 ? "" : `<div class="mq-variant-head">${head.join(" ")}</div>`;
|
|
@@ -448,8 +487,8 @@ function directive(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string
|
|
|
448
487
|
// that something wrapped them; a leaf renders the inert placeholder.
|
|
449
488
|
// Never eat authored content.
|
|
450
489
|
return nodes.length > 0
|
|
451
|
-
? `<div class="mq-unknown" data-directive="${
|
|
452
|
-
: `<div class="mq-placeholder" data-directive="${
|
|
490
|
+
? `<div class="mq-unknown" data-directive="${ctx.attr(name)}">${inner}</div>`
|
|
491
|
+
: `<div class="mq-placeholder" data-directive="${ctx.attr(name)}"></div>`;
|
|
453
492
|
}
|
|
454
493
|
|
|
455
494
|
/** :::table (SPEC.md, "Tables"): each paragraph child is a row; a row's
|
|
@@ -507,13 +546,13 @@ const PLACES = new Set([
|
|
|
507
546
|
]);
|
|
508
547
|
|
|
509
548
|
/** The `aspect` vocabulary as CSS aspect-ratio values. */
|
|
510
|
-
const STACK_ASPECTS: Record<string, string> = {
|
|
549
|
+
const STACK_ASPECTS: Record<string, string> = Object.assign(Object.create(null), {
|
|
511
550
|
"16:9": "16/9",
|
|
512
551
|
"4:3": "4/3",
|
|
513
552
|
"3:2": "3/2",
|
|
514
553
|
"1:1": "1/1",
|
|
515
554
|
card: "5/7",
|
|
516
|
-
};
|
|
555
|
+
});
|
|
517
556
|
|
|
518
557
|
/** :::stack (SPEC.md, "Stacks"): every direct child is a layer, source order
|
|
519
558
|
* is z-order. The stylesheet puts every layer in one grid cell, so the box
|
|
@@ -556,7 +595,7 @@ function renderLayer(node: Node, ctx: Ctx): string {
|
|
|
556
595
|
? " mq-layer-cover"
|
|
557
596
|
: "";
|
|
558
597
|
const inner = children(content, ctx);
|
|
559
|
-
return `<div class="mq-layer mq-place-${place}${backing}${cover}${schemeClass(attrs)}${fontClass(attrs)}"${styleVars(attrs, ctx
|
|
598
|
+
return `<div class="mq-layer mq-place-${place}${backing}${cover}${schemeClass(attrs)}${fontClass(attrs)}"${styleVars(attrs, ctx)}>${inner}</div>`;
|
|
560
599
|
}
|
|
561
600
|
|
|
562
601
|
/** Effect/typographic spans. The `<font>` tag is deliberate: presentational
|
|
@@ -565,19 +604,19 @@ function renderLayer(node: Node, ctx: Ctx): string {
|
|
|
565
604
|
* attributes - the floor of the degradation ladder. The --mq-color slot is
|
|
566
605
|
* the stylesheet-era ceiling on the same element. */
|
|
567
606
|
function span(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string {
|
|
568
|
-
|
|
607
|
+
// Probe first (the hook claims on name and attrs alone): a split renders
|
|
608
|
+
// its children itself, and rendering them here as well doubled the work at
|
|
609
|
+
// every nesting level - exponential in a document anyone can sync.
|
|
610
|
+
const look = ctx.profile.span(name, attrs, "") === null ? effectLook(name, attrs, nodes) : null;
|
|
611
|
+
if (look !== null && look.units !== null) {
|
|
612
|
+
return renderEffect(name, look, nodes, "", ctx);
|
|
613
|
+
}
|
|
569
614
|
const inner = children(nodes, ctx);
|
|
570
615
|
const custom = ctx.profile.span(name, attrs, inner);
|
|
571
616
|
if (custom !== null) {
|
|
572
617
|
return custom;
|
|
573
618
|
}
|
|
574
|
-
const look = effectLook(name, attrs, nodes);
|
|
575
619
|
if (look !== null) {
|
|
576
|
-
if (look.units !== null) {
|
|
577
|
-
// A split renders its children again: rewind the aside numbering.
|
|
578
|
-
ctx.note.n = saved.n;
|
|
579
|
-
ctx.note.pending = [...saved.pending];
|
|
580
|
-
}
|
|
581
620
|
return renderEffect(name, look, nodes, inner, ctx);
|
|
582
621
|
}
|
|
583
622
|
switch (name) {
|
|
@@ -607,7 +646,7 @@ function span(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string {
|
|
|
607
646
|
const value = attrs["font"];
|
|
608
647
|
const face = value !== undefined ? FONTS[value] : undefined;
|
|
609
648
|
if (face !== undefined) {
|
|
610
|
-
return `<font class="mq-font-${value}" face="${
|
|
649
|
+
return `<font class="mq-font-${value}" face="${ctx.attr(face)}">${inner}</font>`;
|
|
611
650
|
}
|
|
612
651
|
return inner; // not on the list: the words survive in their own clothes
|
|
613
652
|
}
|
|
@@ -647,8 +686,8 @@ function span(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string {
|
|
|
647
686
|
// effects.ts, shared with every other surface that draws effects.
|
|
648
687
|
|
|
649
688
|
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}:${
|
|
689
|
+
const data = look.data.map(([k, v]) => ` data-${k}="${ctx.attr(v)}"`).join("");
|
|
690
|
+
const style = look.vars.length === 0 ? "" : ` style="${look.vars.map(([k, v]) => `${k}:${ctx.attr(v)}`).join(";")}"`;
|
|
652
691
|
if (name === "marquee") {
|
|
653
692
|
return `<span class="${look.className}"${data}${style}><span class="mq-marquee-inner">${inner}</span></span>`;
|
|
654
693
|
}
|
|
@@ -664,9 +703,9 @@ function splitRender(nodes: Node[], ctx: Ctx, look: EffectLook): string {
|
|
|
664
703
|
list
|
|
665
704
|
.map((p) =>
|
|
666
705
|
p.kind === "text"
|
|
667
|
-
?
|
|
706
|
+
? ctx.text(p.text)
|
|
668
707
|
: p.kind === "unit"
|
|
669
|
-
? `<span class="mq-l" style="--mq-o:${p.offset}">${
|
|
708
|
+
? `<span class="mq-l" style="--mq-o:${p.offset}">${ctx.text(p.text)}</span>`
|
|
670
709
|
: `<span class="mq-w">${pieces(p.pieces)}</span>`,
|
|
671
710
|
)
|
|
672
711
|
.join("");
|
|
@@ -696,3 +735,24 @@ export function escapeText(s: string): string {
|
|
|
696
735
|
export function escapeAttr(s: string): string {
|
|
697
736
|
return escapeText(s).replaceAll('"', """);
|
|
698
737
|
}
|
|
738
|
+
|
|
739
|
+
/** Characters XML 1.0 forbids outright - even as a numeric reference: the C0
|
|
740
|
+
* controls bar tab, newline, and return, the noncharacters U+FFFE/U+FFFF,
|
|
741
|
+
* and (a JS string can hold one) a lone surrogate. HTML parsers shrug these
|
|
742
|
+
* off; an XML parser refuses the whole page, so XHTML output carries U+FFFD
|
|
743
|
+
* in their place. */
|
|
744
|
+
const XML_FORBIDDEN = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
745
|
+
|
|
746
|
+
function escapeTextXml(s: string): string {
|
|
747
|
+
return escapeText(s).replace(XML_FORBIDDEN, "\uFFFD");
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** An XML parser normalizes whitespace in an attribute value to spaces, so a
|
|
751
|
+
* newline in alt text survives only as a character reference. */
|
|
752
|
+
function escapeAttrXml(s: string): string {
|
|
753
|
+
return escapeTextXml(s)
|
|
754
|
+
.replaceAll('"', """)
|
|
755
|
+
.replaceAll("\t", "	")
|
|
756
|
+
.replaceAll("\n", " ")
|
|
757
|
+
.replaceAll("\r", " ");
|
|
758
|
+
}
|