@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,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* Dispatcher for `[[module ModuleName]]` elements.
|
|
4
|
+
*
|
|
5
|
+
* Wikidot modules are server-side components that generate dynamic content.
|
|
6
|
+
* This renderer dispatches to the appropriate module-specific renderer based
|
|
7
|
+
* on the module name. Supported modules include Rate, Join, Backlinks,
|
|
8
|
+
* Categories, PageTree, ListPages, and ListUsers.
|
|
9
|
+
*
|
|
10
|
+
* Unknown module names produce a Wikidot-compatible error block with a
|
|
11
|
+
* link to the modules documentation page.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Module } from "@wdprlib/ast";
|
|
17
|
+
import type { RenderContext } from "../../context";
|
|
18
|
+
import { renderBacklinks } from "./backlinks";
|
|
19
|
+
import { renderCategories } from "./categories";
|
|
20
|
+
import { renderJoin } from "./join";
|
|
21
|
+
import { renderPageTree } from "./page-tree";
|
|
22
|
+
import { renderRate } from "./rate";
|
|
23
|
+
import { renderListUsers } from "./listusers";
|
|
24
|
+
import { renderListPages } from "./listpages";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Render a `[[module]]` element by dispatching on the module name.
|
|
28
|
+
*
|
|
29
|
+
* Each module outputs a container `<div>` with a module-specific CSS class.
|
|
30
|
+
* Some modules (like Rate and Join) render interactive UI elements; others
|
|
31
|
+
* (like Backlinks and ListPages) render empty containers that can be
|
|
32
|
+
* populated at runtime.
|
|
33
|
+
*
|
|
34
|
+
* @param ctx - The current render context.
|
|
35
|
+
* @param data - Module data with discriminated module type.
|
|
36
|
+
*/
|
|
37
|
+
export function renderModule(ctx: RenderContext, data: Module): void {
|
|
38
|
+
switch (data.module) {
|
|
39
|
+
case "unknown":
|
|
40
|
+
// Render error block for unknown modules
|
|
41
|
+
ctx.push(
|
|
42
|
+
`<div class="error-block">[[module <em>${data.name}</em>]] No such module, please <a href="https://www.wikidot.com/doc:modules" target="_blank" rel="noopener noreferrer">check available modules</a> and fix this page.</div>`,
|
|
43
|
+
);
|
|
44
|
+
break;
|
|
45
|
+
case "backlinks":
|
|
46
|
+
renderBacklinks(ctx, data);
|
|
47
|
+
break;
|
|
48
|
+
case "categories":
|
|
49
|
+
renderCategories(ctx, data);
|
|
50
|
+
break;
|
|
51
|
+
case "join":
|
|
52
|
+
renderJoin(ctx, data);
|
|
53
|
+
break;
|
|
54
|
+
case "page-tree":
|
|
55
|
+
renderPageTree(ctx, data);
|
|
56
|
+
break;
|
|
57
|
+
case "rate":
|
|
58
|
+
renderRate(ctx);
|
|
59
|
+
break;
|
|
60
|
+
case "list-users":
|
|
61
|
+
renderListUsers(ctx, data);
|
|
62
|
+
break;
|
|
63
|
+
case "list-pages":
|
|
64
|
+
renderListPages(ctx, data);
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* Renderer for `[[module Join]]`.
|
|
4
|
+
*
|
|
5
|
+
* The Join module displays a button that allows users to request
|
|
6
|
+
* membership in the wiki site. The button text can be customized
|
|
7
|
+
* via the `button-text` attribute; it defaults to "Join".
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Module } from "@wdprlib/ast";
|
|
13
|
+
import type { RenderContext } from "../../context";
|
|
14
|
+
import { escapeAttr, escapeHtml } from "../../escape";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Render a `[[module Join]]` element with a clickable join button.
|
|
18
|
+
*
|
|
19
|
+
* The button is wrapped in a `<div>` with a configurable CSS class
|
|
20
|
+
* (defaults to `"join-box"`). The runtime `join` module attaches
|
|
21
|
+
* click handling via the `onJoin` callback.
|
|
22
|
+
*
|
|
23
|
+
* @param ctx - The current render context.
|
|
24
|
+
* @param data - Join module data with optional `button-text` and CSS class.
|
|
25
|
+
*/
|
|
26
|
+
export function renderJoin(ctx: RenderContext, data: Extract<Module, { module: "join" }>): void {
|
|
27
|
+
const buttonText = data["button-text"] ?? "Join";
|
|
28
|
+
const attrs = data.attributes ?? {};
|
|
29
|
+
const className = attrs.class ?? "join-box";
|
|
30
|
+
ctx.push(`<div class="${escapeAttr(className)}">`);
|
|
31
|
+
ctx.push(`<a href="javascript:;">${escapeHtml(buttonText)}</a>`);
|
|
32
|
+
ctx.push("</div>");
|
|
33
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* Renderer for `[[module ListPages]]`.
|
|
4
|
+
*
|
|
5
|
+
* The ListPages module queries and displays a filtered list of wiki pages.
|
|
6
|
+
* Because the query results require server-side data, the renderer outputs
|
|
7
|
+
* an empty container div that can be populated at runtime.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Module } from "@wdprlib/ast";
|
|
13
|
+
import type { RenderContext } from "../../context";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Render a `[[module ListPages]]` element as an empty container.
|
|
17
|
+
*
|
|
18
|
+
* @param ctx - The current render context.
|
|
19
|
+
* @param _data - ListPages module data (unused; the container is always empty).
|
|
20
|
+
*/
|
|
21
|
+
export function renderListPages(
|
|
22
|
+
ctx: RenderContext,
|
|
23
|
+
_data: Extract<Module, { module: "list-pages" }>,
|
|
24
|
+
): void {
|
|
25
|
+
ctx.push(`<div class="list-pages-box">`);
|
|
26
|
+
ctx.push("</div>");
|
|
27
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* Renderer for `[[module ListUsers]]`.
|
|
4
|
+
*
|
|
5
|
+
* The ListUsers module displays a filtered list of site members.
|
|
6
|
+
* The renderer outputs an empty container div that can be 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 ListUsers]]` element as an empty container.
|
|
17
|
+
*
|
|
18
|
+
* @param ctx - The current render context.
|
|
19
|
+
* @param _data - ListUsers module data (unused; the container is always empty).
|
|
20
|
+
*/
|
|
21
|
+
export function renderListUsers(
|
|
22
|
+
ctx: RenderContext,
|
|
23
|
+
_data: Extract<Module, { module: "list-users" }>,
|
|
24
|
+
): void {
|
|
25
|
+
ctx.push(`<div class="list-users-module-box">`);
|
|
26
|
+
ctx.push("</div>");
|
|
27
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* Renderer for `[[module PageTree]]`.
|
|
4
|
+
*
|
|
5
|
+
* The PageTree module displays a hierarchical tree of child pages.
|
|
6
|
+
* The renderer outputs an empty container div; the tree structure
|
|
7
|
+
* is populated 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 PageTree]]` element as an empty container.
|
|
17
|
+
*
|
|
18
|
+
* @param ctx - The current render context.
|
|
19
|
+
* @param _data - PageTree module data (unused; the container is always empty).
|
|
20
|
+
*/
|
|
21
|
+
export function renderPageTree(
|
|
22
|
+
ctx: RenderContext,
|
|
23
|
+
_data: Extract<Module, { module: "page-tree" }>,
|
|
24
|
+
): void {
|
|
25
|
+
ctx.push(`<div class="page-tree-module-box">`);
|
|
26
|
+
ctx.push("</div>");
|
|
27
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* Renderer for `[[module Rate]]`.
|
|
4
|
+
*
|
|
5
|
+
* The Rate module displays a page rating widget with upvote, downvote,
|
|
6
|
+
* and cancel buttons. The initial score is rendered as 0; the runtime
|
|
7
|
+
* `rate` module handles click events and updates the display via the
|
|
8
|
+
* `onRate` callback.
|
|
9
|
+
*
|
|
10
|
+
* The widget HTML structure matches Wikidot's original output, using
|
|
11
|
+
* Bootstrap-style `btn btn-default` classes alongside Wikidot-specific
|
|
12
|
+
* class names (`rateup`, `ratedown`, `cancel`, `rate-points`).
|
|
13
|
+
*
|
|
14
|
+
* @module
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { RenderContext } from "../../context";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Render a `[[module Rate]]` page rating widget.
|
|
21
|
+
*
|
|
22
|
+
* Outputs a `<div class="page-rate-widget-box">` containing:
|
|
23
|
+
* - A score display span (`.rate-points > .number`)
|
|
24
|
+
* - An upvote button (`.rateup`)
|
|
25
|
+
* - A downvote button (`.ratedown`) using an en-dash character
|
|
26
|
+
* - A cancel button (`.cancel`)
|
|
27
|
+
*
|
|
28
|
+
* @param ctx - The current render context.
|
|
29
|
+
*/
|
|
30
|
+
export function renderRate(ctx: RenderContext): void {
|
|
31
|
+
ctx.push(`<div class="page-rate-widget-box">`);
|
|
32
|
+
ctx.push(`<span class="rate-points">rating: <span class="number prw54353">0</span></span>`);
|
|
33
|
+
ctx.push(
|
|
34
|
+
`<span class="rateup btn btn-default"><a title="I like it" href="javascript:;">+</a></span>`,
|
|
35
|
+
);
|
|
36
|
+
// – is en-dash
|
|
37
|
+
ctx.push(
|
|
38
|
+
`<span class="ratedown btn btn-default"><a title="I don't like it" href="javascript:;">–</a></span>`,
|
|
39
|
+
);
|
|
40
|
+
ctx.push(
|
|
41
|
+
`<span class="cancel btn btn-default"><a title="Cancel my vote" href="javascript:;">x</a></span>`,
|
|
42
|
+
);
|
|
43
|
+
ctx.push("</div>");
|
|
44
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* Renderer for `[[tabview]]...[[/tabview]]` tab containers.
|
|
4
|
+
*
|
|
5
|
+
* Wikidot uses a YUI-compatible tabview widget. The rendered HTML follows
|
|
6
|
+
* the YUI class naming convention (`yui-navset`, `yui-nav`, `yui-content`)
|
|
7
|
+
* and uses inline `display` styles for tab visibility. Tab switching is
|
|
8
|
+
* handled at runtime by the `tabview` runtime module.
|
|
9
|
+
*
|
|
10
|
+
* A deterministic widget ID is generated from an MD5-length hash of the
|
|
11
|
+
* concatenated tab labels, ensuring stable IDs across renders.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { TabData } from "@wdprlib/ast";
|
|
17
|
+
import type { RenderContext } from "../context";
|
|
18
|
+
import { escapeHtml } from "../escape";
|
|
19
|
+
import { syncHashMd5 } from "../hash";
|
|
20
|
+
import { renderElements } from "../render";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Render a `[[tabview]]` element with YUI-compatible HTML structure.
|
|
24
|
+
*
|
|
25
|
+
* The first tab is selected by default (visible, with the `selected` class
|
|
26
|
+
* on its nav item). All other tabs have `display:none` on their content divs.
|
|
27
|
+
*
|
|
28
|
+
* @param ctx - The current render context.
|
|
29
|
+
* @param tabs - Array of tab data, each with a label and child elements.
|
|
30
|
+
*/
|
|
31
|
+
export function renderTabView(ctx: RenderContext, tabs: TabData[]): void {
|
|
32
|
+
// Generate MD5 hash from tab labels
|
|
33
|
+
const labelString = tabs.map((t) => t.label).join("");
|
|
34
|
+
const hash = md5Hash(labelString);
|
|
35
|
+
|
|
36
|
+
const widgetId = ctx.generateFixedId(`wiki-tabview-${hash}`);
|
|
37
|
+
|
|
38
|
+
// Container
|
|
39
|
+
ctx.push(`<div id="${widgetId}" class="yui-navset">`);
|
|
40
|
+
|
|
41
|
+
// Navigation tabs
|
|
42
|
+
ctx.push(`<ul class="yui-nav">`);
|
|
43
|
+
for (let i = 0; i < tabs.length; i++) {
|
|
44
|
+
const tab = tabs[i]!;
|
|
45
|
+
const selectedClass = i === 0 ? ` class="selected"` : "";
|
|
46
|
+
ctx.push(`<li${selectedClass}>`);
|
|
47
|
+
ctx.push(`<a href="javascript:;"><em>${escapeHtml(tab.label)}</em></a>`);
|
|
48
|
+
ctx.push("</li>");
|
|
49
|
+
}
|
|
50
|
+
ctx.push("</ul>");
|
|
51
|
+
|
|
52
|
+
// Content panels
|
|
53
|
+
ctx.push(`<div class="yui-content">`);
|
|
54
|
+
for (let i = 0; i < tabs.length; i++) {
|
|
55
|
+
const tab = tabs[i]!;
|
|
56
|
+
const displayStyle = i === 0 ? "" : ` style="display:none"`;
|
|
57
|
+
const tabId = ctx.generateId("wiki-tab-0-", i);
|
|
58
|
+
ctx.push(`<div id="${tabId}"${displayStyle}>`);
|
|
59
|
+
renderElements(ctx, tab.elements);
|
|
60
|
+
ctx.push("</div>");
|
|
61
|
+
}
|
|
62
|
+
ctx.push("</div>");
|
|
63
|
+
|
|
64
|
+
ctx.push("</div>"); // close yui-navset
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Compute an MD5-length hash of the input string for widget ID generation.
|
|
69
|
+
*
|
|
70
|
+
* @param input - String to hash (typically concatenated tab labels).
|
|
71
|
+
* @returns A 32-character hex hash string.
|
|
72
|
+
*/
|
|
73
|
+
function md5Hash(input: string): string {
|
|
74
|
+
return syncHashMd5(input);
|
|
75
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* Renderer for Wikidot table elements.
|
|
4
|
+
*
|
|
5
|
+
* Wikidot supports two table syntaxes:
|
|
6
|
+
* - Pipe syntax (`||cell||cell||`) -- adds `class="wiki-content-table"`
|
|
7
|
+
* - Block syntax (`[[table]]...[[/table]]`) -- no default class
|
|
8
|
+
*
|
|
9
|
+
* Both syntaxes support cell alignment (via tildes), column/row spans,
|
|
10
|
+
* header cells (marked with `~`), and custom attributes on rows and cells.
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { TableData } from "@wdprlib/ast";
|
|
16
|
+
import type { RenderContext } from "../context";
|
|
17
|
+
import { escapeAttr, sanitizeAttributes } from "../escape";
|
|
18
|
+
import { renderElements } from "../render";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Render a table element.
|
|
22
|
+
*
|
|
23
|
+
* Pipe-syntax tables receive `class="wiki-content-table"` on the `<table>`
|
|
24
|
+
* element. Cell alignment is rendered as inline `text-align` styles.
|
|
25
|
+
* Header cells use `<th>` instead of `<td>`. Column and row spans are
|
|
26
|
+
* applied from the AST data and attributes, respectively.
|
|
27
|
+
*
|
|
28
|
+
* @param ctx - The current render context.
|
|
29
|
+
* @param data - Table data with rows, cells, attributes, and source type.
|
|
30
|
+
*/
|
|
31
|
+
export function renderTable(ctx: RenderContext, data: TableData): void {
|
|
32
|
+
// Only add wiki-content-table class for pipe syntax tables
|
|
33
|
+
const isPipeTable = data.attributes._source === "pipe";
|
|
34
|
+
const classAttr = isPipeTable ? ' class="wiki-content-table"' : "";
|
|
35
|
+
ctx.push(`<table${classAttr}${renderTableAttrs(data.attributes)}>`);
|
|
36
|
+
|
|
37
|
+
for (const row of data.rows) {
|
|
38
|
+
ctx.push(`<tr${renderTableAttrs(row.attributes)}>`);
|
|
39
|
+
|
|
40
|
+
for (const cell of row.cells) {
|
|
41
|
+
const tag = cell.header ? "th" : "td";
|
|
42
|
+
const attrs: string[] = [];
|
|
43
|
+
const safeCellAttrs = sanitizeAttributes(cell.attributes);
|
|
44
|
+
|
|
45
|
+
if (cell["column-span"] > 1) {
|
|
46
|
+
attrs.push(`colspan="${cell["column-span"]}"`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Handle rowspan from attributes
|
|
50
|
+
if (safeCellAttrs.rowspan) {
|
|
51
|
+
const rowspan = parseInt(safeCellAttrs.rowspan, 10);
|
|
52
|
+
if (rowspan > 1) {
|
|
53
|
+
attrs.push(`rowspan="${rowspan}"`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (cell.align) {
|
|
58
|
+
const existingStyle = safeCellAttrs.style ?? "";
|
|
59
|
+
const alignStyle = `text-align: ${cell.align};`;
|
|
60
|
+
if (existingStyle) {
|
|
61
|
+
attrs.push(`style="${escapeAttr(existingStyle + "; " + alignStyle)}"`);
|
|
62
|
+
} else {
|
|
63
|
+
attrs.push(`style="${alignStyle}"`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Additional cell attributes
|
|
68
|
+
for (const [key, value] of Object.entries(safeCellAttrs)) {
|
|
69
|
+
if (key === "style" && cell.align) continue; // Already handled
|
|
70
|
+
if (key === "rowspan") continue; // Already handled
|
|
71
|
+
attrs.push(`${key}="${escapeAttr(value)}"`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const attrStr = attrs.length > 0 ? " " + attrs.join(" ") : "";
|
|
75
|
+
ctx.push(`<${tag}${attrStr}>`);
|
|
76
|
+
renderElements(ctx, cell.elements);
|
|
77
|
+
ctx.push(`</${tag}>`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
ctx.push("</tr>");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
ctx.push("</table>");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Sanitize and render table-level or row-level attributes, excluding
|
|
88
|
+
* internal `_`-prefixed keys.
|
|
89
|
+
*
|
|
90
|
+
* @param attributes - Raw attribute map from the AST.
|
|
91
|
+
* @returns An HTML attribute string with leading space, or `""` if empty.
|
|
92
|
+
*/
|
|
93
|
+
function renderTableAttrs(attributes: Record<string, string>): string {
|
|
94
|
+
const safe = sanitizeAttributes(attributes);
|
|
95
|
+
let result = "";
|
|
96
|
+
for (const [key, value] of Object.entries(safe)) {
|
|
97
|
+
if (key.startsWith("_")) continue;
|
|
98
|
+
result += ` ${key}="${escapeAttr(value)}"`;
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* Renderers for text-level AST nodes: plain text, raw/literal text,
|
|
4
|
+
* and email addresses.
|
|
5
|
+
*
|
|
6
|
+
* @module
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { RenderContext } from "../context";
|
|
10
|
+
import { escapeAttr, escapeHtml, isValidEmail } from "../escape";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Render a plain text node by HTML-escaping and appending to the output.
|
|
14
|
+
*
|
|
15
|
+
* @param ctx - The current render context.
|
|
16
|
+
* @param data - The raw text content.
|
|
17
|
+
*/
|
|
18
|
+
export function renderText(ctx: RenderContext, data: string): void {
|
|
19
|
+
ctx.pushEscaped(data);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Render raw/literal text (Wikidot `@@...@@` syntax).
|
|
24
|
+
*
|
|
25
|
+
* Raw text is rendered inside a `<span style="white-space: pre-wrap;">` with
|
|
26
|
+
* spaces encoded as ` ` to preserve Wikidot's exact formatting. Empty
|
|
27
|
+
* strings produce no output.
|
|
28
|
+
*
|
|
29
|
+
* @param ctx - The current render context.
|
|
30
|
+
* @param data - The raw text content.
|
|
31
|
+
*/
|
|
32
|
+
export function renderRaw(ctx: RenderContext, data: string): void {
|
|
33
|
+
if (data === "") return;
|
|
34
|
+
ctx.push(`<span style="white-space: pre-wrap;">`);
|
|
35
|
+
// Wikidot encodes spaces as   in raw content
|
|
36
|
+
ctx.push(escapeHtml(data).replace(/ /g, " "));
|
|
37
|
+
ctx.push("</span>");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Render an email address element as a `mailto:` link.
|
|
42
|
+
*
|
|
43
|
+
* The email is validated before creating the link. Invalid email addresses
|
|
44
|
+
* are rendered as plain escaped text to prevent `mailto:` injection.
|
|
45
|
+
*
|
|
46
|
+
* @param ctx - The current render context.
|
|
47
|
+
* @param email - The email address string.
|
|
48
|
+
*/
|
|
49
|
+
export function renderEmail(ctx: RenderContext, email: string): void {
|
|
50
|
+
// Validate email format before creating link
|
|
51
|
+
if (!isValidEmail(email)) {
|
|
52
|
+
// Invalid email: render as plain text
|
|
53
|
+
ctx.pushEscaped(email);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
ctx.push(`<a href="mailto:${escapeAttr(email)}">${escapeHtml(email)}</a>`);
|
|
57
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* Renderer for `[[toc]]` (Table of Contents) elements.
|
|
4
|
+
*
|
|
5
|
+
* The table of contents is built from the pre-collected `tocElements`
|
|
6
|
+
* in the render context (populated from the `table-of-contents` field
|
|
7
|
+
* of the syntax tree). Each entry is rendered as a `<div>` with
|
|
8
|
+
* `margin-left` indentation based on heading depth, matching Wikidot's
|
|
9
|
+
* flat-div TOC format.
|
|
10
|
+
*
|
|
11
|
+
* The TOC supports fold/unfold toggling via a `#toc-action-bar` with
|
|
12
|
+
* Fold/Unfold links, handled at runtime by the `toc` runtime module.
|
|
13
|
+
* Alignment options (`left`/`right`) produce a floated container.
|
|
14
|
+
*
|
|
15
|
+
* @module
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Element, ListData, ListItem, TableOfContentsData } from "@wdprlib/ast";
|
|
19
|
+
import type { RenderContext } from "../context";
|
|
20
|
+
import { escapeAttr, escapeHtml } from "../escape";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Extract text content and href from a link element for TOC rendering.
|
|
24
|
+
*
|
|
25
|
+
* @param element - An AST element (expected to be a link).
|
|
26
|
+
* @returns An object with `href` and `text`, or `null` if not a link.
|
|
27
|
+
*/
|
|
28
|
+
function extractLinkText(element: Element): { href: string; text: string } | null {
|
|
29
|
+
if (element.element !== "link") return null;
|
|
30
|
+
const label = element.data.label;
|
|
31
|
+
let text = "";
|
|
32
|
+
if (typeof label === "object" && label !== null && "text" in label) {
|
|
33
|
+
text = label.text;
|
|
34
|
+
}
|
|
35
|
+
const href = typeof element.data.link === "string" ? element.data.link : "";
|
|
36
|
+
return { href, text };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Render TOC entries as flat `<div>` elements with `margin-left` indentation.
|
|
41
|
+
*
|
|
42
|
+
* @param ctx - The current render context.
|
|
43
|
+
* @param elements - Top-level TOC elements (expected to contain list elements).
|
|
44
|
+
*/
|
|
45
|
+
function renderTocEntries(ctx: RenderContext, elements: Element[]): void {
|
|
46
|
+
for (const element of elements) {
|
|
47
|
+
if (element.element === "list") {
|
|
48
|
+
renderTocList(ctx, element.data, 1);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Recursively render a TOC list at a given nesting depth.
|
|
55
|
+
*
|
|
56
|
+
* @param ctx - The current render context.
|
|
57
|
+
* @param listData - The list data representing this level of the TOC.
|
|
58
|
+
* @param depth - Current nesting depth (1-based), used for `margin-left` calculation.
|
|
59
|
+
*/
|
|
60
|
+
function renderTocList(ctx: RenderContext, listData: ListData, depth: number): void {
|
|
61
|
+
for (const item of listData.items) {
|
|
62
|
+
renderTocItem(ctx, item, depth);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Rewrite a TOC anchor `href` (e.g., `"#toc0"`) so that it matches
|
|
68
|
+
* the rendered heading's actual ID.
|
|
69
|
+
*
|
|
70
|
+
* When `useTrueIds` is false, heading IDs have a random suffix appended
|
|
71
|
+
* (e.g., `toc0-a1b2c3`). This function regenerates the ID through the
|
|
72
|
+
* context to ensure the TOC link targets the correct heading.
|
|
73
|
+
*
|
|
74
|
+
* @param ctx - The current render context.
|
|
75
|
+
* @param href - The original href from the TOC link (e.g., `"#toc0"`).
|
|
76
|
+
* @returns The rewritten href with the correct ID.
|
|
77
|
+
*/
|
|
78
|
+
function rewriteTocAnchor(ctx: RenderContext, href: string): string {
|
|
79
|
+
const match = /^#toc(\d+)$/.exec(href);
|
|
80
|
+
if (!match) return href;
|
|
81
|
+
return `#${ctx.generateId("toc", Number(match[1]))}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Render a single TOC list item as a `<div>` with indented margin.
|
|
86
|
+
*
|
|
87
|
+
* @param ctx - The current render context.
|
|
88
|
+
* @param item - The list item (elements or sub-list).
|
|
89
|
+
* @param depth - Current nesting depth for margin calculation.
|
|
90
|
+
*/
|
|
91
|
+
function renderTocItem(ctx: RenderContext, item: ListItem, depth: number): void {
|
|
92
|
+
if (item["item-type"] === "elements") {
|
|
93
|
+
for (const el of item.elements) {
|
|
94
|
+
const link = extractLinkText(el);
|
|
95
|
+
if (link) {
|
|
96
|
+
const href = rewriteTocAnchor(ctx, link.href);
|
|
97
|
+
ctx.push(
|
|
98
|
+
`<div style="margin-left: ${depth}em;"><a href="${escapeAttr(href)}">${escapeHtml(link.text)}</a></div>`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} else if (item["item-type"] === "sub-list") {
|
|
103
|
+
renderTocList(ctx, item.data, depth + 1);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Render a `[[toc]]` table of contents block.
|
|
109
|
+
*
|
|
110
|
+
* Non-floating TOC is wrapped in a `<table>` for Wikidot layout compatibility.
|
|
111
|
+
* Floating TOC (align `left` or `right`) uses a `<div>` with a float class.
|
|
112
|
+
*
|
|
113
|
+
* The TOC container uses fixed IDs (`#toc`, `#toc-action-bar`, `#toc-list`)
|
|
114
|
+
* that the runtime `toc` module queries for fold/unfold toggling.
|
|
115
|
+
*
|
|
116
|
+
* @param ctx - The current render context.
|
|
117
|
+
* @param data - TOC configuration data with optional alignment.
|
|
118
|
+
*/
|
|
119
|
+
export function renderTableOfContents(ctx: RenderContext, data: TableOfContentsData): void {
|
|
120
|
+
const isFloat = data.align === "left" || data.align === "right";
|
|
121
|
+
|
|
122
|
+
// Non-float: wrap in table (Wikidot behavior)
|
|
123
|
+
if (!isFloat) {
|
|
124
|
+
ctx.push(`<table style="margin:0; padding:0"><tr><td style="margin:0; padding:0">`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// TOC container IDs are fixed — the runtime queries them by ID (#toc, #toc-action-bar, #toc-list)
|
|
128
|
+
if (isFloat) {
|
|
129
|
+
const floatClass = data.align === "left" ? "floatleft" : "floatright";
|
|
130
|
+
ctx.push(`<div id="toc" class="${floatClass}">`);
|
|
131
|
+
} else {
|
|
132
|
+
ctx.push(`<div id="toc">`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
ctx.push(
|
|
136
|
+
`<div id="toc-action-bar"><a href="javascript:;">Fold</a><a style="display: none" href="javascript:;">Unfold</a></div>`,
|
|
137
|
+
);
|
|
138
|
+
ctx.push(`<div class="title">Table of Contents</div>`);
|
|
139
|
+
ctx.push(`<div id="toc-list">`);
|
|
140
|
+
renderTocEntries(ctx, ctx.tocElements);
|
|
141
|
+
ctx.push("</div>");
|
|
142
|
+
ctx.push("</div>");
|
|
143
|
+
|
|
144
|
+
if (!isFloat) {
|
|
145
|
+
ctx.push(`</td></tr></table>`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* Renderer for `[[user username]]` elements.
|
|
4
|
+
*
|
|
5
|
+
* User elements display a username with an optional avatar image and
|
|
6
|
+
* karma badge. The user profile data is resolved via the
|
|
7
|
+
* `resolvers.user` callback; when no resolver is provided or the user
|
|
8
|
+
* is not found, the raw username is rendered as plain text.
|
|
9
|
+
*
|
|
10
|
+
* The special username `"anonymous"` is always rendered as the literal
|
|
11
|
+
* text "Anonymous" without any link or avatar.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { UserData } from "@wdprlib/ast";
|
|
17
|
+
import type { RenderContext } from "../context";
|
|
18
|
+
import { escapeHtml, escapeAttr } from "../escape";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Render a `[[user username]]` element.
|
|
22
|
+
*
|
|
23
|
+
* Rendering modes:
|
|
24
|
+
* - "anonymous" username: plain text "Anonymous"
|
|
25
|
+
* - Unresolved user: plain escaped username text
|
|
26
|
+
* - Resolved without avatar: `<span class="printuser"><a>name</a></span>`
|
|
27
|
+
* - Resolved with avatar: `<span class="printuser avatarhover">` with
|
|
28
|
+
* avatar image, optional karma badge, and linked display name
|
|
29
|
+
*
|
|
30
|
+
* @param ctx - The current render context.
|
|
31
|
+
* @param data - User element data with username and show-avatar flag.
|
|
32
|
+
*/
|
|
33
|
+
export function renderUser(ctx: RenderContext, data: UserData): void {
|
|
34
|
+
const normalized = data.name.toLowerCase().trim();
|
|
35
|
+
|
|
36
|
+
// Special case: "anonymous" renders as "Anonymous" text only
|
|
37
|
+
if (normalized === "anonymous") {
|
|
38
|
+
ctx.push("Anonymous");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const resolved = ctx.options.resolvers?.user?.(data.name) ?? null;
|
|
43
|
+
|
|
44
|
+
if (resolved === null) {
|
|
45
|
+
// User not resolved - render as simple text
|
|
46
|
+
ctx.push(escapeHtml(data.name));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const displayName = resolved.name ?? data.name;
|
|
51
|
+
const hrefAttr = resolved.url ? ` href="${escapeAttr(resolved.url)}"` : "";
|
|
52
|
+
|
|
53
|
+
// Avatar only shown when both url and avatarUrl are provided
|
|
54
|
+
const showAvatar = data["show-avatar"] && resolved.url && resolved.avatarUrl;
|
|
55
|
+
|
|
56
|
+
if (showAvatar) {
|
|
57
|
+
// With avatar
|
|
58
|
+
const styleAttr = resolved.karmaUrl
|
|
59
|
+
? ` style="background-image:url(${escapeAttr(resolved.karmaUrl)})"`
|
|
60
|
+
: "";
|
|
61
|
+
ctx.push(`<span class="printuser avatarhover">`);
|
|
62
|
+
ctx.push(`<a${hrefAttr}>`);
|
|
63
|
+
ctx.push(
|
|
64
|
+
`<img class="small" src="${escapeAttr(resolved.avatarUrl!)}" alt="${escapeAttr(displayName)}"${styleAttr} />`,
|
|
65
|
+
);
|
|
66
|
+
ctx.push("</a>");
|
|
67
|
+
ctx.push(`<a${hrefAttr}>`);
|
|
68
|
+
ctx.push(escapeHtml(displayName));
|
|
69
|
+
ctx.push("</a>");
|
|
70
|
+
ctx.push("</span>");
|
|
71
|
+
} else {
|
|
72
|
+
// Without avatar
|
|
73
|
+
ctx.push(`<span class="printuser">`);
|
|
74
|
+
ctx.push(`<a${hrefAttr}>`);
|
|
75
|
+
ctx.push(escapeHtml(displayName));
|
|
76
|
+
ctx.push("</a>");
|
|
77
|
+
ctx.push("</span>");
|
|
78
|
+
}
|
|
79
|
+
}
|