@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,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:&nbsp;<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
+ // &#8211; is en-dash
37
+ ctx.push(
38
+ `<span class="ratedown btn btn-default"><a title="I don't like it" href="javascript:;">&#8211;</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 `&#32;` 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 &#32; in raw content
36
+ ctx.push(escapeHtml(data).replace(/ /g, "&#32;"));
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
+ }