@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.
Files changed (58) hide show
  1. package/dist/index.cjs +126 -393
  2. package/dist/index.js +117 -384
  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,201 @@
1
+ /**
2
+ *
3
+ * Renderers for Wikidot link elements.
4
+ *
5
+ * Wikidot supports several link syntaxes:
6
+ * - `[[[page-name]]]` -- page link with automatic label
7
+ * - `[[[page-name | label]]]` -- page link with custom label
8
+ * - `[# label]` -- anchor-type link (JavaScript void)
9
+ * - `[http://url label]` -- external URL link
10
+ * - `[[a]]...[[/a]]` -- HTML anchor element with attributes
11
+ * - `[[#anchor-name]]` -- named anchor (bookmark target)
12
+ *
13
+ * All link types are checked for dangerous URL schemes. Page links
14
+ * may receive a `class="newpage"` attribute when the target page does
15
+ * not exist (the standard Wikidot "red link" convention). External
16
+ * links opened in new tabs automatically receive `rel="noopener noreferrer"`
17
+ * to prevent tabnabbing.
18
+ *
19
+ * @module
20
+ */
21
+
22
+ import type { LinkData, AnchorData } from "@wdprlib/ast";
23
+ import type { RenderContext } from "../context";
24
+ import { escapeAttr, isDangerousUrl, sanitizeAttributes } from "../escape";
25
+ import { renderElements } from "../render";
26
+
27
+ /**
28
+ * Render a link element (`[[[page]]]`, `[url label]`, `[# label]`).
29
+ *
30
+ * The link's `href` is resolved via `ctx.resolvePageLink()`, with an
31
+ * optional `extra` suffix (anchor fragment) appended. Anchor-type links
32
+ * with `javascript:;` are allowed as a special case for Wikidot
33
+ * compatibility; all other dangerous URL schemes are blocked.
34
+ *
35
+ * For page-type links, a `class="newpage"` is added when the
36
+ * `pageExists` resolver indicates the target page does not exist.
37
+ *
38
+ * @param ctx - The current render context.
39
+ * @param data - Link data with link target, label, type, target window, and extra suffix.
40
+ */
41
+ export function renderLink(ctx: RenderContext, data: LinkData): void {
42
+ let href = ctx.resolvePageLink(data.link);
43
+
44
+ // Append extra (anchor suffix)
45
+ if (data.extra) {
46
+ href += data.extra;
47
+ }
48
+
49
+ // Validate URL scheme
50
+ // Exception: anchor-type links with "javascript:;" are valid Wikidot syntax ([# label])
51
+ const isAnchorJsVoid = data.type === "anchor" && href === "javascript:;";
52
+ if (!isAnchorJsVoid && isDangerousUrl(href)) {
53
+ href = "#invalid-url";
54
+ }
55
+
56
+ // Build <a> tag
57
+ const attrs: string[] = [`href="${escapeAttr(href)}"`];
58
+
59
+ // Add "newpage" class for page links that don't exist
60
+ // Only for page-type links (not direct URLs, anchors, etc.)
61
+ if (data.type === "page" && typeof data.link === "object") {
62
+ const page = data.link.page;
63
+ // Skip newpage class for special pages:
64
+ // - //path (protocol-relative or special routing)
65
+ // - paths with #/ (hash routing like MAIN/#/page)
66
+ // category-prefixed pages (`category:name`) are NOT skipped — pageExists is
67
+ // expected to handle them (e.g. share:<ULID>, private:<ULID>, system:Recent).
68
+ const isSpecialPage = page.startsWith("//") || page.includes("#/");
69
+ if (!isSpecialPage) {
70
+ // For anchor links (page#anchor), check if the page part exists
71
+ const hashIdx = page.indexOf("#");
72
+ const pageToCheck = hashIdx !== -1 ? page.slice(0, hashIdx) : page;
73
+ const pageExists = ctx.page?.pageExists;
74
+ // If pageExists is not provided, assume page doesn't exist (show newpage class)
75
+ const exists = pageExists ? pageExists(pageToCheck) : false;
76
+ if (!exists) {
77
+ attrs.push(`class="newpage"`);
78
+ }
79
+ }
80
+ }
81
+
82
+ // Target attribute
83
+ if (data.target) {
84
+ const targetMap: Record<string, string> = {
85
+ "new-tab": "_blank",
86
+ parent: "_parent",
87
+ top: "_top",
88
+ same: "_self",
89
+ };
90
+ const targetValue = targetMap[data.target] ?? "_blank";
91
+ attrs.push(`target="${targetValue}"`);
92
+ // Prevent tabnabbing for _blank targets
93
+ if (targetValue === "_blank") {
94
+ attrs.push(`rel="noopener noreferrer"`);
95
+ }
96
+ }
97
+
98
+ ctx.push(`<a ${attrs.join(" ")}>`);
99
+
100
+ // Render label
101
+ renderLinkLabel(ctx, data);
102
+
103
+ ctx.push("</a>");
104
+ }
105
+
106
+ /**
107
+ * Render the label content of a link element.
108
+ *
109
+ * Label types:
110
+ * - `"page"` -- use the page name as the label text
111
+ * - `{ text: string }` -- use a custom text label
112
+ * - `{ url: string }` -- use the URL itself as the label
113
+ *
114
+ * @param ctx - The current render context.
115
+ * @param data - Link data containing the label descriptor.
116
+ */
117
+ function renderLinkLabel(ctx: RenderContext, data: LinkData): void {
118
+ if (data.label === "page") {
119
+ // Use page name as label
120
+ if (typeof data.link === "string") {
121
+ ctx.pushEscaped(data.link);
122
+ } else {
123
+ ctx.pushEscaped(data.link.page);
124
+ }
125
+ return;
126
+ }
127
+
128
+ if ("text" in data.label) {
129
+ ctx.pushEscaped(data.label.text);
130
+ return;
131
+ }
132
+
133
+ if ("url" in data.label) {
134
+ // Use the URL itself as label
135
+ const href = ctx.resolvePageLink(data.link);
136
+ ctx.pushEscaped(data.label.url ?? href);
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Render a `[[a]]...[[/a]]` anchor element with full attribute support.
142
+ *
143
+ * Unlike `renderLink`, this handles the block-level anchor syntax where
144
+ * arbitrary attributes can be specified. Dangerous `href` values are
145
+ * replaced with `#invalid-url`. The `target` attribute is mapped from
146
+ * Wikidot's abstract values to HTML values, with tabnabbing protection.
147
+ *
148
+ * @param ctx - The current render context.
149
+ * @param data - Anchor data with attributes, target, and child elements.
150
+ */
151
+ export function renderAnchor(ctx: RenderContext, data: AnchorData): void {
152
+ const safe = sanitizeAttributes(data.attributes);
153
+ const attrs: string[] = [];
154
+
155
+ // Validate href for dangerous URLs
156
+ if (safe.href && isDangerousUrl(safe.href)) {
157
+ safe.href = "#invalid-url";
158
+ }
159
+
160
+ // Always include href attribute
161
+ const href = safe.href ?? "";
162
+ attrs.push(`href="${escapeAttr(href)}"`);
163
+
164
+ // Handle target attribute from AST data
165
+ if (data.target) {
166
+ const targetMap: Record<string, string> = {
167
+ "new-tab": "_blank",
168
+ parent: "_parent",
169
+ top: "_top",
170
+ same: "_self",
171
+ };
172
+ const targetValue = targetMap[data.target] ?? "_blank";
173
+ attrs.push(`target="${targetValue}"`);
174
+ // Prevent tabnabbing for _blank targets
175
+ if (targetValue === "_blank") {
176
+ attrs.push(`rel="noopener noreferrer"`);
177
+ }
178
+ }
179
+
180
+ for (const [key, value] of Object.entries(safe)) {
181
+ if (key === "href" || key === "target") continue; // already handled above
182
+ attrs.push(`${key}="${escapeAttr(value)}"`);
183
+ }
184
+
185
+ ctx.push(`<a ${attrs.join(" ")}>`);
186
+ renderElements(ctx, data.elements);
187
+ ctx.push("</a>");
188
+ }
189
+
190
+ /**
191
+ * Render a named anchor (bookmark target) element: `[[#anchor-name]]`.
192
+ *
193
+ * Produces `<a name="anchor-name"></a>` which serves as a link target
194
+ * for `#anchor-name` URL fragments.
195
+ *
196
+ * @param ctx - The current render context.
197
+ * @param name - The anchor name (fragment identifier).
198
+ */
199
+ export function renderAnchorName(ctx: RenderContext, name: string): void {
200
+ ctx.push(`<a name="${escapeAttr(name)}"></a>`);
201
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ *
3
+ * Renderers for Wikidot ordered/unordered lists and definition lists.
4
+ *
5
+ * Wikidot list syntax uses `*` (unordered) and `#` (ordered) prefixes
6
+ * with indentation controlling nesting depth. The parser produces a
7
+ * recursive `ListData` structure with items that can be either
8
+ * "elements" (content) or "sub-list" (nested list).
9
+ *
10
+ * Special behaviors replicated from Wikidot:
11
+ * - Empty lists are silently dropped (no output at all).
12
+ * - Items with `_noMarker` have `list-style: none` and the first
13
+ * paragraph is unwrapped (no `<p>` tags).
14
+ * - Sub-lists without a preceding content item get an inline hidden `<li>`.
15
+ * - Leading/trailing whitespace-only text nodes are trimmed from items.
16
+ *
17
+ * @module
18
+ */
19
+
20
+ import type { ListData, DefinitionListItem, Element, ContainerData } from "@wdprlib/ast";
21
+ import type { RenderContext } from "../context";
22
+ import { escapeAttr, sanitizeAttributes } from "../escape";
23
+ import { renderElements, renderElement } from "../render";
24
+
25
+ /**
26
+ * Trim leading and trailing whitespace-only text elements from an array.
27
+ *
28
+ * @param elements - Array of AST elements.
29
+ * @returns A slice of the array with whitespace-only text nodes removed
30
+ * from both ends.
31
+ */
32
+ function trimTextElements(elements: Element[]): Element[] {
33
+ if (elements.length === 0) return elements;
34
+
35
+ let start = 0;
36
+ let end = elements.length;
37
+
38
+ // Trim leading whitespace-only text elements
39
+ while (start < end) {
40
+ const el = elements[start]!;
41
+ if (el.element === "text" && typeof el.data === "string" && el.data.trim() === "") {
42
+ start++;
43
+ } else {
44
+ break;
45
+ }
46
+ }
47
+
48
+ // Trim trailing whitespace-only text elements
49
+ while (end > start) {
50
+ const el = elements[end - 1]!;
51
+ if (el.element === "text" && typeof el.data === "string" && el.data.trim() === "") {
52
+ end--;
53
+ } else {
54
+ break;
55
+ }
56
+ }
57
+
58
+ return elements.slice(start, end);
59
+ }
60
+
61
+ /**
62
+ * Check whether a paragraph element contains only the text `[[/li]]`.
63
+ *
64
+ * The parser sometimes wraps stray `[[/li]]` closing tags in a paragraph.
65
+ * When found as the last paragraph in a `_noMarker` item, the paragraph
66
+ * wrapper is removed to match Wikidot output.
67
+ *
68
+ * @param el - An AST element to check.
69
+ * @returns `true` if the element is a paragraph containing only `[[/li]]`.
70
+ */
71
+ function isLiCloseTextParagraph(el: Element): boolean {
72
+ if (el.element !== "container") return false;
73
+ const data = el.data as ContainerData;
74
+ if (data.type !== "paragraph") return false;
75
+ // Check if content is just [[/li]] (possibly with whitespace)
76
+ const texts = data.elements
77
+ .filter((e): e is { element: "text"; data: string } => e.element === "text")
78
+ .map((e) => e.data);
79
+ const combined = texts.join("").trim();
80
+ return combined === "[[/li]]";
81
+ }
82
+
83
+ /**
84
+ * Render elements for `_noMarker` list items with special paragraph handling.
85
+ *
86
+ * Wikidot treats bare content (without `[[li]]`) differently:
87
+ * - The first paragraph is unwrapped (children rendered without `<p>` tags).
88
+ * - Middle paragraphs retain their `<p>` wrappers.
89
+ * - The last paragraph is unwrapped if it contains only `[[/li]]` text.
90
+ *
91
+ * @param ctx - The current render context.
92
+ * @param elements - The list item's child elements.
93
+ */
94
+ function renderNoMarkerElements(ctx: RenderContext, elements: Element[]): void {
95
+ const trimmed = trimTextElements(elements);
96
+ if (trimmed.length === 0) return;
97
+
98
+ // Find paragraph indices
99
+ const paragraphIndices: number[] = [];
100
+ for (let i = 0; i < trimmed.length; i++) {
101
+ const el = trimmed[i]!;
102
+ if (el.element === "container" && (el.data as ContainerData).type === "paragraph") {
103
+ paragraphIndices.push(i);
104
+ }
105
+ }
106
+
107
+ // If no paragraphs, render normally
108
+ if (paragraphIndices.length === 0) {
109
+ renderElements(ctx, trimmed);
110
+ return;
111
+ }
112
+
113
+ const firstParagraphIdx = paragraphIndices[0]!;
114
+ const lastParagraphIdx = paragraphIndices[paragraphIndices.length - 1]!;
115
+
116
+ for (let i = 0; i < trimmed.length; i++) {
117
+ const el = trimmed[i]!;
118
+ if (el.element === "container" && (el.data as ContainerData).type === "paragraph") {
119
+ const data = el.data as ContainerData;
120
+ // First paragraph: unwrap
121
+ if (i === firstParagraphIdx) {
122
+ renderElements(ctx, data.elements);
123
+ }
124
+ // Last paragraph if it's [[/li]]: unwrap
125
+ else if (i === lastParagraphIdx && isLiCloseTextParagraph(el)) {
126
+ renderElements(ctx, data.elements);
127
+ }
128
+ // Other paragraphs: keep <p> tags
129
+ else {
130
+ ctx.push("<p>");
131
+ renderElements(ctx, data.elements);
132
+ ctx.push("</p>");
133
+ }
134
+ } else {
135
+ renderElement(ctx, el);
136
+ }
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Render an ordered or unordered list.
142
+ *
143
+ * Wikidot drops empty lists entirely (no HTML output). Sub-lists
144
+ * following a content item are rendered inside the same `<li>`.
145
+ * Sub-lists without a preceding content item get a hidden `<li>` wrapper.
146
+ *
147
+ * @param ctx - The current render context.
148
+ * @param data - List data with type (numbered/bulleted), items, and attributes.
149
+ */
150
+ export function renderList(ctx: RenderContext, data: ListData): void {
151
+ // Wikidot behavior: empty lists or lists with only empty items are ignored
152
+ // and converted to <br />
153
+ const hasContent = data.items.some((item) => {
154
+ if (item["item-type"] === "sub-list") return true;
155
+ if (item["item-type"] === "elements") {
156
+ const trimmed = trimTextElements(item.elements);
157
+ return trimmed.length > 0;
158
+ }
159
+ return false;
160
+ });
161
+
162
+ if (!hasContent) {
163
+ // Empty list - Wikidot outputs nothing (just whitespace)
164
+ return;
165
+ }
166
+
167
+ const tag = data.type === "numbered" ? "ol" : "ul";
168
+ ctx.push(`<${tag}${renderListAttrs(data.attributes)}>`);
169
+
170
+ const items = data.items;
171
+ let i = 0;
172
+ while (i < items.length) {
173
+ const item = items[i]!;
174
+ if (item["item-type"] === "elements") {
175
+ // Check for _noMarker flag (bare content without [[li]])
176
+ const hasNoMarker = item.attributes._noMarker === "true";
177
+ const styleAttr = hasNoMarker ? ' style="list-style: none"' : "";
178
+ ctx.push(`<li${renderListAttrs(item.attributes)}${styleAttr}>`);
179
+ // Trim leading/trailing whitespace from li content (Wikidot behavior)
180
+ if (hasNoMarker) {
181
+ // Special handling for bare content paragraphs
182
+ renderNoMarkerElements(ctx, item.elements);
183
+ } else {
184
+ renderElements(ctx, trimTextElements(item.elements));
185
+ }
186
+ // Consume following sub-lists inside this <li>
187
+ while (i + 1 < items.length && items[i + 1]!["item-type"] === "sub-list") {
188
+ i++;
189
+ const subItem = items[i] as { "item-type": "sub-list"; data: ListData };
190
+ renderList(ctx, subItem.data);
191
+ }
192
+ ctx.push("</li>");
193
+ } else {
194
+ // Sub-list without preceding elements item - hide bullet/number
195
+ const subItem = item as { "item-type": "sub-list"; data: ListData };
196
+ ctx.push(`<li style="list-style: none; display: inline">`);
197
+ renderList(ctx, subItem.data);
198
+ ctx.push("</li>");
199
+ }
200
+ i++;
201
+ }
202
+
203
+ ctx.push(`</${tag}>`);
204
+ }
205
+
206
+ /**
207
+ * Sanitize and render list-specific attributes, excluding internal `_`-prefixed keys.
208
+ *
209
+ * @param attributes - Raw attribute map from the AST.
210
+ * @returns An HTML attribute string with leading space, or `""` if empty.
211
+ */
212
+ function renderListAttrs(attributes: Record<string, string>): string {
213
+ const safe = sanitizeAttributes(attributes);
214
+ let result = "";
215
+ for (const [key, value] of Object.entries(safe)) {
216
+ if (key.startsWith("_")) continue;
217
+ result += ` ${key}="${escapeAttr(value)}"`;
218
+ }
219
+ return result;
220
+ }
221
+
222
+ /**
223
+ * Render a definition list (`:`-prefixed items in Wikidot markup).
224
+ *
225
+ * Produces `<dl>` with `<dt>`/`<dd>` pairs for each definition item.
226
+ *
227
+ * @param ctx - The current render context.
228
+ * @param items - Array of definition list items, each with key and value elements.
229
+ */
230
+ export function renderDefinitionList(ctx: RenderContext, items: DefinitionListItem[]): void {
231
+ ctx.push("<dl>");
232
+ for (const item of items) {
233
+ ctx.push("<dt>");
234
+ renderElements(ctx, item.key);
235
+ ctx.push("</dt>");
236
+ ctx.push("<dd>");
237
+ renderElements(ctx, item.value);
238
+ ctx.push("</dd>");
239
+ }
240
+ ctx.push("</dl>");
241
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ *
3
+ * Renderers for Wikidot mathematical notation elements.
4
+ *
5
+ * - `[[math]]...[[/math]]` -- display-mode (block) math
6
+ * - `[[$ ... $]]` -- inline math
7
+ * - `[[eref name]]` -- equation reference (link to named equation)
8
+ *
9
+ * LaTeX source is converted to MathML using the `temml` library at
10
+ * render time. A hidden `<code class="math-source">` element preserves
11
+ * the original LaTeX for use by the runtime `math` module's SVG polyfill
12
+ * (for browsers without MathML support).
13
+ *
14
+ * Named equations receive an `(N)` equation number and can be
15
+ * cross-referenced via `[[eref]]`.
16
+ *
17
+ * @module
18
+ */
19
+
20
+ import type { MathData, MathInlineData } from "@wdprlib/ast";
21
+ import type { RenderContext } from "../context";
22
+ import { escapeAttr, escapeHtml } from "../escape";
23
+ import temml from "temml";
24
+
25
+ /**
26
+ * Determine whether a LaTeX string needs to be wrapped in an `aligned`
27
+ * environment.
28
+ *
29
+ * Wikidot-style multi-line equations use `&` as alignment markers without
30
+ * explicitly declaring an `aligned` environment. If the LaTeX contains
31
+ * unescaped `&` characters but no `\begin{...}` environment declaration,
32
+ * an `aligned` wrapper is added to make the alignment work correctly.
33
+ *
34
+ * @param latex - Raw LaTeX source string.
35
+ * @returns `true` if the LaTeX needs an `aligned` environment wrapper.
36
+ */
37
+ function needsAlignedWrapper(latex: string): boolean {
38
+ // Already has an environment
39
+ if (/\\begin\s*\{/.test(latex)) {
40
+ return false;
41
+ }
42
+ // Has alignment marker (&) but not escaped (\&) - needs aligned environment
43
+ // Remove escaped ampersands first, then check for unescaped ones
44
+ const withoutEscaped = latex.replace(/\\&/g, "");
45
+ return withoutEscaped.includes("&");
46
+ }
47
+
48
+ /**
49
+ * Render a LaTeX string to MathML using the `temml` library.
50
+ *
51
+ * For display-mode equations with alignment markers, the LaTeX is
52
+ * automatically wrapped in an `aligned` environment.
53
+ *
54
+ * @param latex - LaTeX source string.
55
+ * @param displayMode - Whether to render in display mode (block) or inline.
56
+ * @returns MathML string, or `""` if rendering fails.
57
+ */
58
+ function renderLatexToMathML(latex: string, displayMode: boolean): string {
59
+ try {
60
+ // Wrap in aligned environment if needed for Wikidot-style alignment
61
+ let processedLatex = latex;
62
+ if (displayMode && needsAlignedWrapper(latex)) {
63
+ processedLatex = `\\begin{aligned}\n${latex}\n\\end{aligned}`;
64
+ }
65
+ return temml.renderToString(processedLatex, {
66
+ displayMode,
67
+ throwOnError: false,
68
+ annotate: false,
69
+ });
70
+ } catch {
71
+ return "";
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Render a `[[math]]` display-mode block equation.
77
+ *
78
+ * Produces a `<div class="math-block">` containing:
79
+ * - An optional equation number `<span class="equation-number">` for named equations
80
+ * - A hidden `<code class="math-source">` with the raw LaTeX (for polyfill use)
81
+ * - A `<span class="math-render">` with the MathML output (or error fallback)
82
+ *
83
+ * @param ctx - The current render context.
84
+ * @param data - Math block data with LaTeX source and optional equation name.
85
+ */
86
+ export function renderMath(ctx: RenderContext, data: MathData): void {
87
+ const index = ctx.nextEquationIndex() + 1;
88
+ const latex = data["latex-source"];
89
+ const mathml = renderLatexToMathML(latex, true);
90
+
91
+ const id = data.name
92
+ ? ctx.generateId("equation-", data.name)
93
+ : ctx.generateId("equation-", index);
94
+ const dataName = data.name ? ` data-name="${escapeAttr(data.name)}"` : "";
95
+
96
+ ctx.push(`<div class="math-block" id="${escapeAttr(id)}"${dataName}>`);
97
+
98
+ // Equation number (only for named equations)
99
+ if (data.name) {
100
+ ctx.push(`<span class="equation-number">(${index})</span>`);
101
+ }
102
+
103
+ // Hidden LaTeX source (for polyfill)
104
+ ctx.push(`<code class="math-source" hidden aria-hidden="true">`);
105
+ ctx.push(escapeHtml(latex));
106
+ ctx.push(`</code>`);
107
+
108
+ // MathML output
109
+ ctx.push(`<span class="math-render">`);
110
+ if (mathml) {
111
+ ctx.push(mathml);
112
+ } else {
113
+ // Fallback: display error
114
+ ctx.push(`<span class="math-error">`);
115
+ ctx.push(escapeHtml(latex));
116
+ ctx.push(`</span>`);
117
+ }
118
+ ctx.push(`</span>`);
119
+
120
+ ctx.push("</div>");
121
+ }
122
+
123
+ /**
124
+ * Render an inline math element (`[[$...$]]`).
125
+ *
126
+ * Produces a `<span class="math-inline">` containing:
127
+ * - A hidden `<code class="math-source">` with the raw LaTeX
128
+ * - A `<span class="math-render">` with the MathML output (or `$...$` error fallback)
129
+ *
130
+ * @param ctx - The current render context.
131
+ * @param data - Inline math data with LaTeX source.
132
+ */
133
+ export function renderMathInline(ctx: RenderContext, data: MathInlineData): void {
134
+ const latex = data["latex-source"];
135
+ const mathml = renderLatexToMathML(latex, false);
136
+
137
+ ctx.push(`<span class="math-inline">`);
138
+
139
+ // Hidden LaTeX source (for polyfill)
140
+ ctx.push(`<code class="math-source" hidden aria-hidden="true">`);
141
+ ctx.push(escapeHtml(latex));
142
+ ctx.push(`</code>`);
143
+
144
+ // MathML output
145
+ ctx.push(`<span class="math-render">`);
146
+ if (mathml) {
147
+ ctx.push(mathml);
148
+ } else {
149
+ // Fallback: display with $ delimiters
150
+ ctx.push(`<span class="math-error">$`);
151
+ ctx.push(escapeHtml(latex));
152
+ ctx.push(`$</span>`);
153
+ }
154
+ ctx.push(`</span>`);
155
+
156
+ ctx.push("</span>");
157
+ }
158
+
159
+ /**
160
+ * Render an equation reference (`[[eref name]]`) that links to a named equation.
161
+ *
162
+ * Produces a `<span class="eref">` containing a link to the equation's
163
+ * `#equation-{name}` ID and an empty tooltip span that the runtime
164
+ * `math` module populates on hover with a preview of the equation.
165
+ *
166
+ * @param ctx - The current render context.
167
+ * @param name - The equation name to reference.
168
+ */
169
+ export function renderEquationRef(ctx: RenderContext, name: string): void {
170
+ const id = ctx.generateId("equation-", name);
171
+ ctx.push(`<span class="eref" data-target="${escapeAttr(id)}">`);
172
+ ctx.push(`<a class="eref-link" href="#${escapeAttr(id)}">`);
173
+ ctx.push(escapeHtml(name));
174
+ ctx.push(`</a>`);
175
+ ctx.push(`<span class="eref-tooltip" aria-hidden="true"></span>`);
176
+ ctx.push("</span>");
177
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ *
3
+ * Renderer for `[[module Backlinks]]`.
4
+ *
5
+ * The backlinks module lists all pages that link to the current page.
6
+ * Because backlink data requires server-side queries, the renderer only
7
+ * outputs an empty container div. The actual content is populated at
8
+ * runtime or via server-side rendering.
9
+ *
10
+ * @module
11
+ */
12
+
13
+ import type { Module } from "@wdprlib/ast";
14
+ import type { RenderContext } from "../../context";
15
+
16
+ /**
17
+ * Render a `[[module Backlinks]]` element as an empty container.
18
+ *
19
+ * @param ctx - The current render context.
20
+ * @param _data - Backlinks module data (unused; the container is always empty).
21
+ */
22
+ export function renderBacklinks(
23
+ ctx: RenderContext,
24
+ _data: Extract<Module, { module: "backlinks" }>,
25
+ ): void {
26
+ // Wikidot outputs just the container div (backlinks are populated at runtime)
27
+ ctx.push(`<div class="backlinks-module-box">\n\t</div>`);
28
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ *
3
+ * Renderer for `[[module Categories]]`.
4
+ *
5
+ * The categories module displays the site's page categories. The
6
+ * renderer outputs an empty container div; category data is populated
7
+ * at runtime or via server-side rendering.
8
+ *
9
+ * @module
10
+ */
11
+
12
+ import type { Module } from "@wdprlib/ast";
13
+ import type { RenderContext } from "../../context";
14
+
15
+ /**
16
+ * Render a `[[module Categories]]` element as an empty container.
17
+ *
18
+ * @param ctx - The current render context.
19
+ * @param _data - Categories module data (unused; the container is always empty).
20
+ */
21
+ export function renderCategories(
22
+ ctx: RenderContext,
23
+ _data: Extract<Module, { module: "categories" }>,
24
+ ): void {
25
+ ctx.push(`<div class="categories-module-box">`);
26
+ ctx.push("</div>");
27
+ }