@wdprlib/render 2.0.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.
Files changed (58) hide show
  1. package/dist/index.cjs +11 -387
  2. package/dist/index.js +2 -378
  3. package/package.json +5 -3
  4. package/src/context.ts +422 -0
  5. package/src/elements/bibliography.ts +123 -0
  6. package/src/elements/clear-float.ts +27 -0
  7. package/src/elements/code.ts +49 -0
  8. package/src/elements/collapsible.ts +105 -0
  9. package/src/elements/color.ts +32 -0
  10. package/src/elements/container.ts +302 -0
  11. package/src/elements/date.ts +59 -0
  12. package/src/elements/embed-block.ts +327 -0
  13. package/src/elements/embed.ts +166 -0
  14. package/src/elements/expr.ts +102 -0
  15. package/src/elements/footnote.ts +76 -0
  16. package/src/elements/html.ts +79 -0
  17. package/src/elements/iframe.ts +44 -0
  18. package/src/elements/iftags.ts +118 -0
  19. package/src/elements/image.ts +154 -0
  20. package/src/elements/include.ts +43 -0
  21. package/src/elements/index.ts +35 -0
  22. package/src/elements/line-break.ts +22 -0
  23. package/src/elements/link.ts +201 -0
  24. package/src/elements/list.ts +241 -0
  25. package/src/elements/math.ts +177 -0
  26. package/src/elements/module/backlinks.ts +28 -0
  27. package/src/elements/module/categories.ts +27 -0
  28. package/src/elements/module/index.ts +67 -0
  29. package/src/elements/module/join.ts +33 -0
  30. package/src/elements/module/listpages.ts +27 -0
  31. package/src/elements/module/listusers.ts +27 -0
  32. package/src/elements/module/page-tree.ts +27 -0
  33. package/src/elements/module/rate.ts +44 -0
  34. package/src/elements/tab-view.ts +75 -0
  35. package/src/elements/table.ts +101 -0
  36. package/src/elements/text.ts +57 -0
  37. package/src/elements/toc.ts +147 -0
  38. package/src/elements/user.ts +79 -0
  39. package/src/escape.ts +829 -0
  40. package/src/hash.ts +62 -0
  41. package/src/index.ts +26 -0
  42. package/src/libs/highlighter/engine.ts +352 -0
  43. package/src/libs/highlighter/index.ts +70 -0
  44. package/src/libs/highlighter/languages/cpp.ts +345 -0
  45. package/src/libs/highlighter/languages/css.ts +104 -0
  46. package/src/libs/highlighter/languages/diff.ts +154 -0
  47. package/src/libs/highlighter/languages/dtd.ts +99 -0
  48. package/src/libs/highlighter/languages/html.ts +59 -0
  49. package/src/libs/highlighter/languages/java.ts +251 -0
  50. package/src/libs/highlighter/languages/javascript.ts +213 -0
  51. package/src/libs/highlighter/languages/php.ts +433 -0
  52. package/src/libs/highlighter/languages/python.ts +308 -0
  53. package/src/libs/highlighter/languages/ruby.ts +360 -0
  54. package/src/libs/highlighter/languages/sql.ts +125 -0
  55. package/src/libs/highlighter/languages/xml.ts +68 -0
  56. package/src/libs/highlighter/types.ts +44 -0
  57. package/src/render.ts +231 -0
  58. 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
+ }