@wdprlib/render 1.4.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +126 -393
- package/dist/index.js +117 -384
- package/package.json +5 -3
- package/src/context.ts +422 -0
- package/src/elements/bibliography.ts +123 -0
- package/src/elements/clear-float.ts +27 -0
- package/src/elements/code.ts +49 -0
- package/src/elements/collapsible.ts +105 -0
- package/src/elements/color.ts +32 -0
- package/src/elements/container.ts +302 -0
- package/src/elements/date.ts +59 -0
- package/src/elements/embed-block.ts +327 -0
- package/src/elements/embed.ts +166 -0
- package/src/elements/expr.ts +102 -0
- package/src/elements/footnote.ts +76 -0
- package/src/elements/html.ts +79 -0
- package/src/elements/iframe.ts +44 -0
- package/src/elements/iftags.ts +118 -0
- package/src/elements/image.ts +154 -0
- package/src/elements/include.ts +43 -0
- package/src/elements/index.ts +35 -0
- package/src/elements/line-break.ts +22 -0
- package/src/elements/link.ts +201 -0
- package/src/elements/list.ts +241 -0
- package/src/elements/math.ts +177 -0
- package/src/elements/module/backlinks.ts +28 -0
- package/src/elements/module/categories.ts +27 -0
- package/src/elements/module/index.ts +67 -0
- package/src/elements/module/join.ts +33 -0
- package/src/elements/module/listpages.ts +27 -0
- package/src/elements/module/listusers.ts +27 -0
- package/src/elements/module/page-tree.ts +27 -0
- package/src/elements/module/rate.ts +44 -0
- package/src/elements/tab-view.ts +75 -0
- package/src/elements/table.ts +101 -0
- package/src/elements/text.ts +57 -0
- package/src/elements/toc.ts +147 -0
- package/src/elements/user.ts +79 -0
- package/src/escape.ts +829 -0
- package/src/hash.ts +62 -0
- package/src/index.ts +26 -0
- package/src/libs/highlighter/engine.ts +352 -0
- package/src/libs/highlighter/index.ts +70 -0
- package/src/libs/highlighter/languages/cpp.ts +345 -0
- package/src/libs/highlighter/languages/css.ts +104 -0
- package/src/libs/highlighter/languages/diff.ts +154 -0
- package/src/libs/highlighter/languages/dtd.ts +99 -0
- package/src/libs/highlighter/languages/html.ts +59 -0
- package/src/libs/highlighter/languages/java.ts +251 -0
- package/src/libs/highlighter/languages/javascript.ts +213 -0
- package/src/libs/highlighter/languages/php.ts +433 -0
- package/src/libs/highlighter/languages/python.ts +308 -0
- package/src/libs/highlighter/languages/ruby.ts +360 -0
- package/src/libs/highlighter/languages/sql.ts +125 -0
- package/src/libs/highlighter/languages/xml.ts +68 -0
- package/src/libs/highlighter/types.ts +44 -0
- package/src/render.ts +231 -0
- package/src/types.ts +140 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* Renderer for `[[embed]]...[[/embed]]` block-level embeds.
|
|
4
|
+
*
|
|
5
|
+
* Unlike inline embeds (which target specific providers like YouTube),
|
|
6
|
+
* embed blocks contain raw HTML that the user provides. This module
|
|
7
|
+
* validates and sanitizes that HTML through a multi-layer pipeline:
|
|
8
|
+
*
|
|
9
|
+
* 1. `sanitize-html` strips everything except a single `<iframe>` with
|
|
10
|
+
* a limited set of safe attributes.
|
|
11
|
+
* 2. The iframe's `src` URL must use HTTP or HTTPS.
|
|
12
|
+
* 3. The hostname and path must match the configured allowlist (or the
|
|
13
|
+
* allowlist can be set to `null` for Wikidot's "anyiframe" mode).
|
|
14
|
+
*
|
|
15
|
+
* If any validation step fails, a Wikidot-compatible error block is
|
|
16
|
+
* rendered instead.
|
|
17
|
+
*
|
|
18
|
+
* @module
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { EmbedBlockData } from "@wdprlib/ast";
|
|
22
|
+
import type { Element } from "domhandler";
|
|
23
|
+
import { parseDocument } from "htmlparser2";
|
|
24
|
+
import sanitizeHtml from "sanitize-html";
|
|
25
|
+
import type { RenderContext } from "../context";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Boolean attributes that should be normalized to attr="attr" format
|
|
29
|
+
* (Wikidot normalizes these attributes in its output)
|
|
30
|
+
*/
|
|
31
|
+
const BOOLEAN_ATTRIBUTES = [
|
|
32
|
+
"allowfullscreen",
|
|
33
|
+
"async",
|
|
34
|
+
"autofocus",
|
|
35
|
+
"autoplay",
|
|
36
|
+
"checked",
|
|
37
|
+
"controls",
|
|
38
|
+
"default",
|
|
39
|
+
"defer",
|
|
40
|
+
"disabled",
|
|
41
|
+
"formnovalidate",
|
|
42
|
+
"hidden",
|
|
43
|
+
"ismap",
|
|
44
|
+
"loop",
|
|
45
|
+
"multiple",
|
|
46
|
+
"muted",
|
|
47
|
+
"novalidate",
|
|
48
|
+
"open",
|
|
49
|
+
"readonly",
|
|
50
|
+
"required",
|
|
51
|
+
"reversed",
|
|
52
|
+
"selected",
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Allowlist entry for embed content validation
|
|
57
|
+
* Each entry specifies a host pattern and optional path prefix
|
|
58
|
+
*/
|
|
59
|
+
export interface EmbedAllowlistEntry {
|
|
60
|
+
/** Host pattern. Supports wildcard prefix '*.' (e.g., '*.youtube.com') */
|
|
61
|
+
host: string;
|
|
62
|
+
/** Optional path prefix that must match (e.g., '/embed/') */
|
|
63
|
+
pathPrefix?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Default allowlist for embed content (ported from Wikidot's default.php)
|
|
68
|
+
* Only iframes with src matching these host+path patterns will be rendered.
|
|
69
|
+
*
|
|
70
|
+
* Note: Set to null to allow any HTTPS iframe (Wikidot's 'anyiframe' behavior).
|
|
71
|
+
* sanitize-html still enforces HTTPS-only and blocks dangerous attributes.
|
|
72
|
+
*/
|
|
73
|
+
export const DEFAULT_EMBED_ALLOWLIST: EmbedAllowlistEntry[] | null = [
|
|
74
|
+
// YouTube
|
|
75
|
+
{ host: "*.youtube.com", pathPrefix: "/embed/" },
|
|
76
|
+
{ host: "*.youtube-nocookie.com", pathPrefix: "/embed/" },
|
|
77
|
+
// Vimeo
|
|
78
|
+
{ host: "player.vimeo.com", pathPrefix: "/video/" },
|
|
79
|
+
// Google Maps
|
|
80
|
+
{ host: "*.google.com", pathPrefix: "/maps/embed" },
|
|
81
|
+
// Google Calendar
|
|
82
|
+
{ host: "calendar.google.com", pathPrefix: "/calendar/embed" },
|
|
83
|
+
// Spotify
|
|
84
|
+
{ host: "open.spotify.com", pathPrefix: "/embed/" },
|
|
85
|
+
// SoundCloud
|
|
86
|
+
{ host: "w.soundcloud.com", pathPrefix: "/player/" },
|
|
87
|
+
// CodePen
|
|
88
|
+
{ host: "codepen.io" },
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* sanitize-html configuration for embed content.
|
|
93
|
+
* Only allows iframe elements with safe attributes, HTTPS scheme only.
|
|
94
|
+
*/
|
|
95
|
+
const SANITIZE_CONFIG: sanitizeHtml.IOptions = {
|
|
96
|
+
allowedTags: ["iframe"],
|
|
97
|
+
allowedAttributes: {
|
|
98
|
+
iframe: [
|
|
99
|
+
"class",
|
|
100
|
+
"src",
|
|
101
|
+
"style",
|
|
102
|
+
"allow",
|
|
103
|
+
"allowfullscreen",
|
|
104
|
+
"frameborder",
|
|
105
|
+
"height",
|
|
106
|
+
"loading",
|
|
107
|
+
"referrerpolicy",
|
|
108
|
+
"sandbox",
|
|
109
|
+
"title",
|
|
110
|
+
"width",
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
allowedSchemes: ["https", "http"],
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Parse HTML and recursively find all `<iframe>` elements.
|
|
118
|
+
*
|
|
119
|
+
* Recursion is needed because `sanitize-html` might leave nested
|
|
120
|
+
* structures intact, and we need to ensure exactly one iframe exists
|
|
121
|
+
* at any nesting level.
|
|
122
|
+
*
|
|
123
|
+
* @param html - Sanitized HTML string.
|
|
124
|
+
* @returns Array of found iframe DOM elements.
|
|
125
|
+
*/
|
|
126
|
+
function findIframes(html: string): Element[] {
|
|
127
|
+
const doc = parseDocument(html);
|
|
128
|
+
const iframes: Element[] = [];
|
|
129
|
+
function walk(nodes: typeof doc.children): void {
|
|
130
|
+
for (const node of nodes) {
|
|
131
|
+
if (node.type === "tag") {
|
|
132
|
+
if (node.name === "iframe") {
|
|
133
|
+
iframes.push(node);
|
|
134
|
+
}
|
|
135
|
+
if (node.children) {
|
|
136
|
+
walk(node.children);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
walk(doc.children);
|
|
142
|
+
return iframes;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Check whether a hostname matches a host pattern.
|
|
147
|
+
*
|
|
148
|
+
* Supports wildcard prefix `*.` (e.g., `*.youtube.com` matches both
|
|
149
|
+
* `youtube.com` and `www.youtube.com` but not `evil-youtube.com`).
|
|
150
|
+
* Non-wildcard patterns require an exact match.
|
|
151
|
+
*
|
|
152
|
+
* @param hostname - The actual hostname from the iframe `src` URL.
|
|
153
|
+
* @param pattern - The allowlist host pattern to match against.
|
|
154
|
+
* @returns `true` if the hostname matches the pattern.
|
|
155
|
+
*/
|
|
156
|
+
function matchesHostPattern(hostname: string, pattern: string): boolean {
|
|
157
|
+
const lowerHostname = hostname.toLowerCase();
|
|
158
|
+
const lowerPattern = pattern.toLowerCase();
|
|
159
|
+
|
|
160
|
+
if (lowerPattern.startsWith("*.")) {
|
|
161
|
+
// Wildcard match: *.example.com matches example.com and sub.example.com
|
|
162
|
+
// But not evil-example.com (must be exact or have dot boundary)
|
|
163
|
+
const base = lowerPattern.slice(2); // Remove '*.'
|
|
164
|
+
return lowerHostname === base || lowerHostname.endsWith("." + base);
|
|
165
|
+
}
|
|
166
|
+
// Exact match
|
|
167
|
+
return lowerHostname === lowerPattern;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Check whether a URL matches an allowlist entry's host and optional path prefix.
|
|
172
|
+
*
|
|
173
|
+
* The path prefix must match at a boundary: it must be followed by `/`, `?`,
|
|
174
|
+
* `#`, or end of string to prevent partial matches (e.g., `/embed` must not
|
|
175
|
+
* match `/embedX`).
|
|
176
|
+
*
|
|
177
|
+
* @param url - Parsed URL from the iframe `src` attribute.
|
|
178
|
+
* @param entry - Allowlist entry with host pattern and optional path prefix.
|
|
179
|
+
* @returns `true` if both host and path conditions are satisfied.
|
|
180
|
+
*/
|
|
181
|
+
function matchesAllowlistEntry(url: URL, entry: EmbedAllowlistEntry): boolean {
|
|
182
|
+
if (!matchesHostPattern(url.hostname, entry.host)) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
if (entry.pathPrefix) {
|
|
186
|
+
const pathLower = url.pathname.toLowerCase();
|
|
187
|
+
const prefixLower = entry.pathPrefix.toLowerCase();
|
|
188
|
+
if (!pathLower.startsWith(prefixLower)) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
// If prefix ends with /, boundary check is already satisfied
|
|
192
|
+
// Otherwise ensure prefix matches at a boundary (not partial, e.g., /embed vs /embedX)
|
|
193
|
+
if (!prefixLower.endsWith("/")) {
|
|
194
|
+
const remainder = pathLower.slice(prefixLower.length);
|
|
195
|
+
if (remainder && !/^[/?#]/.test(remainder)) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Validate and sanitize embed block content through a multi-step pipeline.
|
|
205
|
+
*
|
|
206
|
+
* Steps:
|
|
207
|
+
* 1. Strip all elements except `<iframe>` with safe attributes via `sanitize-html`.
|
|
208
|
+
* 2. Verify exactly one iframe element exists.
|
|
209
|
+
* 3. Parse the iframe `src` URL and enforce HTTP/HTTPS scheme.
|
|
210
|
+
* 4. Match the URL against the allowlist (unless `null` for anyiframe mode).
|
|
211
|
+
*
|
|
212
|
+
* @param content - Raw HTML content from the `[[embed]]` block.
|
|
213
|
+
* @param allowlist - Host/path allowlist entries, or `null` for anyiframe mode.
|
|
214
|
+
* @param baseUrl - Optional base URL for resolving protocol-relative `src` values.
|
|
215
|
+
* @returns Sanitized HTML string, or `null` if validation fails.
|
|
216
|
+
*/
|
|
217
|
+
function validateAndSanitizeEmbed(
|
|
218
|
+
content: string,
|
|
219
|
+
allowlist: EmbedAllowlistEntry[] | null,
|
|
220
|
+
baseUrl?: string,
|
|
221
|
+
): string | null {
|
|
222
|
+
// Sanitize with sanitize-html to remove dangerous content
|
|
223
|
+
const sanitized = sanitizeHtml(content.trim(), SANITIZE_CONFIG);
|
|
224
|
+
|
|
225
|
+
if (!sanitized.trim()) {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Parse sanitized content to find iframes
|
|
230
|
+
const iframes = findIframes(sanitized);
|
|
231
|
+
|
|
232
|
+
// Must have exactly one iframe
|
|
233
|
+
if (iframes.length !== 1) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const iframe = iframes[0]!;
|
|
238
|
+
const src = iframe.attribs.src?.trim();
|
|
239
|
+
if (!src) {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Parse URL (protocol-relative URLs are resolved against baseUrl)
|
|
244
|
+
let url: URL;
|
|
245
|
+
try {
|
|
246
|
+
if (src.startsWith("//")) {
|
|
247
|
+
// Protocol-relative URL: resolve against baseUrl, defaulting to https:
|
|
248
|
+
const base = baseUrl ?? "https://localhost";
|
|
249
|
+
url = new URL(src, base);
|
|
250
|
+
} else {
|
|
251
|
+
url = new URL(src);
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Only allow HTTP and HTTPS
|
|
258
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// If allowlist is null, allow any HTTP(S) iframe (Wikidot's 'anyiframe' behavior)
|
|
263
|
+
if (allowlist !== null) {
|
|
264
|
+
// Check if URL matches any allowlist entry
|
|
265
|
+
const matched = allowlist.some((entry) => matchesAllowlistEntry(url, entry));
|
|
266
|
+
if (!matched) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return sanitized;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Normalize HTML boolean attributes to Wikidot's format.
|
|
276
|
+
*
|
|
277
|
+
* Wikidot outputs boolean attributes as `attr="attr"` rather than the
|
|
278
|
+
* minimized form (`attr`) or empty form (`attr=""`). This function
|
|
279
|
+
* rewrites both forms to match.
|
|
280
|
+
*
|
|
281
|
+
* @param html - HTML string potentially containing boolean attributes.
|
|
282
|
+
* @returns HTML with boolean attributes in `attr="attr"` format.
|
|
283
|
+
*/
|
|
284
|
+
function normalizeBooleanAttributes(html: string): string {
|
|
285
|
+
let result = html;
|
|
286
|
+
for (const attr of BOOLEAN_ATTRIBUTES) {
|
|
287
|
+
// Match standalone boolean attribute (not already having a value)
|
|
288
|
+
// Pattern: attr followed by whitespace, > or />
|
|
289
|
+
const standalonePattern = new RegExp(`\\s${attr}(?=\\s|>|/>)`, "gi");
|
|
290
|
+
result = result.replace(standalonePattern, ` ${attr}="${attr}"`);
|
|
291
|
+
|
|
292
|
+
// Match attr="" (empty value, sanitize-html output)
|
|
293
|
+
const emptyValuePattern = new RegExp(`\\s${attr}=""`, "gi");
|
|
294
|
+
result = result.replace(emptyValuePattern, ` ${attr}="${attr}"`);
|
|
295
|
+
}
|
|
296
|
+
return result;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Render an `[[embed]]...[[/embed]]` block element.
|
|
301
|
+
*
|
|
302
|
+
* The raw HTML content is validated and sanitized through the full
|
|
303
|
+
* pipeline. On failure, a Wikidot-compatible error block is shown:
|
|
304
|
+
* `<div class="error-block">Sorry, no match for the embedded content.</div>`.
|
|
305
|
+
*
|
|
306
|
+
* The allowlist is taken from `ctx.options.embedAllowlist`, falling back
|
|
307
|
+
* to {@link DEFAULT_EMBED_ALLOWLIST} when not specified. Setting it to
|
|
308
|
+
* `null` enables Wikidot's "anyiframe" mode (any HTTPS iframe allowed).
|
|
309
|
+
*
|
|
310
|
+
* @param ctx - The current render context.
|
|
311
|
+
* @param data - Embed block data containing the raw HTML contents.
|
|
312
|
+
*/
|
|
313
|
+
export function renderEmbedBlock(ctx: RenderContext, data: EmbedBlockData): void {
|
|
314
|
+
// Use explicit undefined check to allow null (anyiframe mode)
|
|
315
|
+
const allowlist =
|
|
316
|
+
ctx.options.embedAllowlist !== undefined ? ctx.options.embedAllowlist : DEFAULT_EMBED_ALLOWLIST;
|
|
317
|
+
|
|
318
|
+
const sanitized = validateAndSanitizeEmbed(data.contents, allowlist, ctx.options.baseUrl);
|
|
319
|
+
if (sanitized === null) {
|
|
320
|
+
ctx.push('<div class="error-block">Sorry, no match for the embedded content.</div>');
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Normalize boolean attributes and output
|
|
325
|
+
const normalized = normalizeBooleanAttributes(sanitized);
|
|
326
|
+
ctx.push(normalized);
|
|
327
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* Renderer for inline embed elements (`[[embed]]`) that reference
|
|
4
|
+
* third-party content providers.
|
|
5
|
+
*
|
|
6
|
+
* Supported providers:
|
|
7
|
+
* - YouTube (`[[embedvideo youtube:VIDEO_ID]]`)
|
|
8
|
+
* - Vimeo (`[[embedvideo vimeo:VIDEO_ID]]`)
|
|
9
|
+
* - GitHub Gist (`[[embed github-gist:USER/HASH]]`)
|
|
10
|
+
* - GitLab Snippet (`[[embed gitlab-snippet:ID]]`)
|
|
11
|
+
*
|
|
12
|
+
* Each provider has a strict ID validation function to prevent path
|
|
13
|
+
* traversal, injection, and other attacks via embed parameters.
|
|
14
|
+
*
|
|
15
|
+
* @module
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Embed } from "@wdprlib/ast";
|
|
19
|
+
import type { RenderContext } from "../context";
|
|
20
|
+
import { escapeAttr } from "../escape";
|
|
21
|
+
|
|
22
|
+
// =============================================================================
|
|
23
|
+
// ID Validation
|
|
24
|
+
// Prevents path traversal and injection via embed parameters
|
|
25
|
+
// =============================================================================
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Validate a YouTube or Vimeo video ID.
|
|
29
|
+
* Only alphanumeric characters, underscores, and hyphens are allowed.
|
|
30
|
+
*
|
|
31
|
+
* @param id - The video ID string to validate.
|
|
32
|
+
* @returns `true` if the ID contains only safe characters.
|
|
33
|
+
*/
|
|
34
|
+
function isValidVideoId(id: string): boolean {
|
|
35
|
+
return /^[a-zA-Z0-9_-]+$/.test(id);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Validate a GitHub username (alphanumeric + hyphen, 1-39 characters).
|
|
40
|
+
*
|
|
41
|
+
* @param username - The GitHub username to validate.
|
|
42
|
+
* @returns `true` if the username matches GitHub's format rules.
|
|
43
|
+
*/
|
|
44
|
+
function isValidGithubUsername(username: string): boolean {
|
|
45
|
+
return /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(username);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Validate a GitHub Gist hash (lowercase hex characters only).
|
|
50
|
+
*
|
|
51
|
+
* @param hash - The gist hash string to validate.
|
|
52
|
+
* @returns `true` if the hash contains only hex characters.
|
|
53
|
+
*/
|
|
54
|
+
function isValidGistHash(hash: string): boolean {
|
|
55
|
+
return /^[a-f0-9]+$/.test(hash);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Validate a GitLab snippet ID (numeric digits only).
|
|
60
|
+
*
|
|
61
|
+
* @param id - The snippet ID string to validate.
|
|
62
|
+
* @returns `true` if the ID is numeric.
|
|
63
|
+
*/
|
|
64
|
+
function isValidGitlabSnippetId(id: string): boolean {
|
|
65
|
+
return /^[0-9]+$/.test(id);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// =============================================================================
|
|
69
|
+
// Render Functions
|
|
70
|
+
// =============================================================================
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Render an inline embed element by dispatching to the appropriate
|
|
74
|
+
* provider-specific renderer.
|
|
75
|
+
*
|
|
76
|
+
* Invalid provider parameters (e.g. a video ID containing path traversal
|
|
77
|
+
* characters) result in an HTML comment instead of the embed.
|
|
78
|
+
*
|
|
79
|
+
* @param ctx - The current render context.
|
|
80
|
+
* @param data - Embed data with provider type and provider-specific fields.
|
|
81
|
+
*/
|
|
82
|
+
export function renderEmbed(ctx: RenderContext, data: Embed): void {
|
|
83
|
+
switch (data.embed) {
|
|
84
|
+
case "youtube":
|
|
85
|
+
renderYoutube(ctx, data.data["video-id"]);
|
|
86
|
+
break;
|
|
87
|
+
case "vimeo":
|
|
88
|
+
renderVimeo(ctx, data.data["video-id"]);
|
|
89
|
+
break;
|
|
90
|
+
case "github-gist":
|
|
91
|
+
renderGithubGist(ctx, data.data.username, data.data.hash);
|
|
92
|
+
break;
|
|
93
|
+
case "gitlab-snippet":
|
|
94
|
+
renderGitlabSnippet(ctx, data.data["snippet-id"]);
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Render a YouTube embed as a responsive iframe.
|
|
101
|
+
*
|
|
102
|
+
* @param ctx - The current render context.
|
|
103
|
+
* @param videoId - YouTube video ID (validated before use).
|
|
104
|
+
*/
|
|
105
|
+
function renderYoutube(ctx: RenderContext, videoId: string): void {
|
|
106
|
+
if (!isValidVideoId(videoId)) {
|
|
107
|
+
ctx.push(`<!-- Invalid YouTube video ID -->`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
ctx.push(`<div class="embed-youtube">`);
|
|
111
|
+
ctx.push(
|
|
112
|
+
`<iframe src="https://www.youtube.com/embed/${escapeAttr(videoId)}" ` +
|
|
113
|
+
`frameborder="0" allowfullscreen></iframe>`,
|
|
114
|
+
);
|
|
115
|
+
ctx.push("</div>");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Render a Vimeo embed as a responsive iframe.
|
|
120
|
+
*
|
|
121
|
+
* @param ctx - The current render context.
|
|
122
|
+
* @param videoId - Vimeo video ID (validated before use).
|
|
123
|
+
*/
|
|
124
|
+
function renderVimeo(ctx: RenderContext, videoId: string): void {
|
|
125
|
+
if (!isValidVideoId(videoId)) {
|
|
126
|
+
ctx.push(`<!-- Invalid Vimeo video ID -->`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
ctx.push(`<div class="embed-vimeo">`);
|
|
130
|
+
ctx.push(
|
|
131
|
+
`<iframe src="https://player.vimeo.com/video/${escapeAttr(videoId)}" ` +
|
|
132
|
+
`frameborder="0" allowfullscreen></iframe>`,
|
|
133
|
+
);
|
|
134
|
+
ctx.push("</div>");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Render a GitHub Gist embed as a `<script>` tag.
|
|
139
|
+
*
|
|
140
|
+
* @param ctx - The current render context.
|
|
141
|
+
* @param username - GitHub username owning the gist (validated before use).
|
|
142
|
+
* @param hash - Gist hash identifier (validated before use).
|
|
143
|
+
*/
|
|
144
|
+
function renderGithubGist(ctx: RenderContext, username: string, hash: string): void {
|
|
145
|
+
if (!isValidGithubUsername(username) || !isValidGistHash(hash)) {
|
|
146
|
+
ctx.push(`<!-- Invalid GitHub Gist parameters -->`);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
ctx.push(
|
|
150
|
+
`<script src="https://gist.github.com/${escapeAttr(username)}/${escapeAttr(hash)}.js"></script>`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Render a GitLab Snippet embed as a `<script>` tag.
|
|
156
|
+
*
|
|
157
|
+
* @param ctx - The current render context.
|
|
158
|
+
* @param snippetId - GitLab snippet ID (validated before use).
|
|
159
|
+
*/
|
|
160
|
+
function renderGitlabSnippet(ctx: RenderContext, snippetId: string): void {
|
|
161
|
+
if (!isValidGitlabSnippetId(snippetId)) {
|
|
162
|
+
ctx.push(`<!-- Invalid GitLab snippet ID -->`);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
ctx.push(`<script src="https://gitlab.com/snippets/${escapeAttr(snippetId)}.js"></script>`);
|
|
166
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* Renderers for Wikidot's expression and conditional constructs:
|
|
4
|
+
*
|
|
5
|
+
* - `[[#expr EXPRESSION]]` -- evaluate a mathematical expression and
|
|
6
|
+
* display the numeric result.
|
|
7
|
+
* - `[[#if VALUE | THEN | ELSE]]` -- simple string-based truthiness check.
|
|
8
|
+
* - `[[#ifexpr EXPRESSION | THEN | ELSE]]` -- evaluate a math expression
|
|
9
|
+
* and branch on the numeric result (0 = false, non-zero = true).
|
|
10
|
+
*
|
|
11
|
+
* All error messages match Wikidot's format (`"run-time error: ..."`).
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Element, ExprData, IfCondData, IfExprData } from "@wdprlib/ast";
|
|
17
|
+
import type { RenderContext } from "../context";
|
|
18
|
+
import { renderElements } from "../render";
|
|
19
|
+
import { evaluateExpression, formatExprValue, isTruthy } from "@wdprlib/ast";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Render a `[[#expr]]` element.
|
|
23
|
+
*
|
|
24
|
+
* Evaluates the mathematical expression and outputs the formatted numeric
|
|
25
|
+
* result. On evaluation error, a Wikidot-compatible error message is
|
|
26
|
+
* displayed. Empty expressions produce no output.
|
|
27
|
+
*
|
|
28
|
+
* @param ctx - The current render context.
|
|
29
|
+
* @param data - Expression data containing the expression string.
|
|
30
|
+
*/
|
|
31
|
+
export function renderExpr(ctx: RenderContext, data: ExprData): void {
|
|
32
|
+
const result = evaluateExpression(data.expression);
|
|
33
|
+
if (result.success) {
|
|
34
|
+
ctx.pushEscaped(formatExprValue(result.value));
|
|
35
|
+
} else if (result.error !== "empty expression") {
|
|
36
|
+
ctx.pushEscaped(`run-time error: ${result.error}`);
|
|
37
|
+
}
|
|
38
|
+
// Empty expression outputs nothing
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Render a `[[#if]]` conditional element.
|
|
43
|
+
*
|
|
44
|
+
* The condition is treated as a string: values `"false"`, `"null"`,
|
|
45
|
+
* `""`, and `"0"` are falsy; everything else is truthy. The selected
|
|
46
|
+
* branch's elements are rendered with trailing whitespace trimmed.
|
|
47
|
+
*
|
|
48
|
+
* @param ctx - The current render context.
|
|
49
|
+
* @param data - If-condition data with condition string and then/else branches.
|
|
50
|
+
*/
|
|
51
|
+
export function renderIf(ctx: RenderContext, data: IfCondData): void {
|
|
52
|
+
const elements = isTruthy(data.condition) ? data.then : data.else;
|
|
53
|
+
renderBranchElements(ctx, elements);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Render a `[[#ifexpr]]` conditional expression element.
|
|
58
|
+
*
|
|
59
|
+
* Evaluates the mathematical expression; a result of 0 selects the
|
|
60
|
+
* `else` branch, any non-zero result selects the `then` branch.
|
|
61
|
+
* On evaluation error, a Wikidot-compatible error message is displayed
|
|
62
|
+
* and neither branch is rendered.
|
|
63
|
+
*
|
|
64
|
+
* @param ctx - The current render context.
|
|
65
|
+
* @param data - If-expression data with expression string and then/else branches.
|
|
66
|
+
*/
|
|
67
|
+
export function renderIfExpr(ctx: RenderContext, data: IfExprData): void {
|
|
68
|
+
const result = evaluateExpression(data.expression);
|
|
69
|
+
if (!result.success) {
|
|
70
|
+
// ifexpr: error outputs error message (Wikidot-compatible)
|
|
71
|
+
ctx.pushEscaped(`run-time error: ${result.error}`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// 0 selects else branch, non-zero selects then branch
|
|
75
|
+
const elements = result.value !== 0 ? data.then : data.else;
|
|
76
|
+
renderBranchElements(ctx, elements);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Render a branch's elements, trimming trailing whitespace-only text nodes.
|
|
81
|
+
*
|
|
82
|
+
* Wikidot strips trailing whitespace from `#if` / `#ifexpr` branch output.
|
|
83
|
+
* This function finds the last non-whitespace element and renders only
|
|
84
|
+
* up to that point.
|
|
85
|
+
*
|
|
86
|
+
* @param ctx - The current render context.
|
|
87
|
+
* @param elements - The branch's element array.
|
|
88
|
+
*/
|
|
89
|
+
function renderBranchElements(ctx: RenderContext, elements: Element[]): void {
|
|
90
|
+
// Find the last non-whitespace element
|
|
91
|
+
let lastIdx = elements.length - 1;
|
|
92
|
+
while (lastIdx >= 0) {
|
|
93
|
+
const el = elements[lastIdx]!;
|
|
94
|
+
if (el.element === "text" && typeof el.data === "string" && el.data.trim() === "") {
|
|
95
|
+
lastIdx--;
|
|
96
|
+
} else {
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Render only up to the last non-whitespace element
|
|
101
|
+
renderElements(ctx, elements.slice(0, lastIdx + 1));
|
|
102
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* Renderers for Wikidot footnote markup.
|
|
4
|
+
*
|
|
5
|
+
* - `[[footnote]]...[[/footnote]]` -- inline footnote reference that renders
|
|
6
|
+
* as a superscript number linking to the footnote body.
|
|
7
|
+
* - `[[footnoteblock]]` -- block element that lists all footnote bodies
|
|
8
|
+
* collected during the render pass.
|
|
9
|
+
*
|
|
10
|
+
* The runtime `footnote` module adds hover tooltips and click-to-scroll
|
|
11
|
+
* behavior to these elements.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { FootnoteBlockData } from "@wdprlib/ast";
|
|
17
|
+
import type { RenderContext } from "../context";
|
|
18
|
+
import { escapeHtml } from "../escape";
|
|
19
|
+
import { renderElements } from "../render";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Render an inline footnote reference as a superscript link.
|
|
23
|
+
*
|
|
24
|
+
* Produces `<sup class="footnoteref"><a id="footnoteref-N" ...>N</a></sup>`.
|
|
25
|
+
* The ID is used by the runtime module for bidirectional scroll navigation
|
|
26
|
+
* between the reference and its footnote body.
|
|
27
|
+
*
|
|
28
|
+
* @param ctx - The current render context.
|
|
29
|
+
* @param index - The 1-based footnote number.
|
|
30
|
+
*/
|
|
31
|
+
export function renderFootnoteRef(ctx: RenderContext, index: number): void {
|
|
32
|
+
const id = ctx.generateId("footnoteref-", index);
|
|
33
|
+
ctx.push(`<sup class="footnoteref">`);
|
|
34
|
+
ctx.push(`<a id="${id}" href="javascript:;" class="footnoteref">${index}</a>`);
|
|
35
|
+
ctx.push("</sup>");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Render a `[[footnoteblock]]` element that lists all footnote bodies.
|
|
40
|
+
*
|
|
41
|
+
* Produces a Wikidot-compatible structure:
|
|
42
|
+
* ```html
|
|
43
|
+
* <div class="footnotes-footer">
|
|
44
|
+
* <div class="title">Footnotes</div>
|
|
45
|
+
* <div class="footnote-footer" id="footnote-1">
|
|
46
|
+
* <a href="javascript:;">1</a>. ...content...
|
|
47
|
+
* </div>
|
|
48
|
+
* </div>
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* If there are no footnotes, the block is not rendered at all.
|
|
52
|
+
*
|
|
53
|
+
* @param ctx - The current render context.
|
|
54
|
+
* @param data - Footnote block data with optional custom title.
|
|
55
|
+
*/
|
|
56
|
+
export function renderFootnoteBlock(ctx: RenderContext, data: FootnoteBlockData): void {
|
|
57
|
+
if (ctx.footnotes.length === 0) return;
|
|
58
|
+
const title = data.title ?? "Footnotes";
|
|
59
|
+
|
|
60
|
+
ctx.push(`<div class="footnotes-footer">`);
|
|
61
|
+
ctx.push(`<div class="title">${escapeHtml(title)}</div>`);
|
|
62
|
+
|
|
63
|
+
// Render each footnote
|
|
64
|
+
for (let i = 0; i < ctx.footnotes.length; i++) {
|
|
65
|
+
const index = i + 1;
|
|
66
|
+
const elements = ctx.footnotes[i] ?? [];
|
|
67
|
+
|
|
68
|
+
const fnId = ctx.generateId("footnote-", index);
|
|
69
|
+
ctx.push(`<div class="footnote-footer" id="${fnId}">`);
|
|
70
|
+
ctx.push(`<a href="javascript:;">${index}</a>. `);
|
|
71
|
+
renderElements(ctx, elements);
|
|
72
|
+
ctx.push("</div>");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
ctx.push("</div>");
|
|
76
|
+
}
|