@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.
- package/dist/index.cjs +11 -387
- package/dist/index.js +2 -378
- 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,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
|
+
}
|