@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,79 @@
1
+ /**
2
+ *
3
+ * Renderer for `[[html]]...[[/html]]` blocks in Wikidot markup.
4
+ *
5
+ * HTML blocks are rendered as sandboxed iframes. The iframe `src` URL
6
+ * can be provided by a resolver callback; when absent, a deterministic
7
+ * default URL is generated from the content hash.
8
+ *
9
+ * The actual HTML content is not inlined into the page -- it is served
10
+ * separately at the iframe URL. The runtime `html-block` module handles
11
+ * auto-resizing of the iframe via `postMessage`.
12
+ *
13
+ * @module
14
+ */
15
+
16
+ import type { HtmlData } from "@wdprlib/ast";
17
+ import type { RenderContext } from "../context";
18
+ import { escapeAttr, sanitizeStyleValue } from "../escape";
19
+ import { syncHashSha1 } from "../hash";
20
+
21
+ /**
22
+ * Generate a default iframe `src` URL for an HTML block when no
23
+ * resolver callback is provided.
24
+ *
25
+ * The URL follows Wikidot's pattern: `/{pageName}/html/{hash}-{nonce}`.
26
+ * The hash is a SHA-1-length FNV hash of the content, and the nonce
27
+ * is derived from the content length and first hash character to
28
+ * provide additional uniqueness.
29
+ *
30
+ * @param pageName - The current page name (may be empty).
31
+ * @param contents - Raw HTML content to hash.
32
+ * @returns A URL path string, always starting with `/`.
33
+ */
34
+ function generateDefaultUrl(pageName: string, contents: string): string {
35
+ const hash = syncHashSha1(contents);
36
+ const nonce = BigInt(contents.length) * 1000000000n + BigInt(hash.charCodeAt(0)) * 100000000n;
37
+ // Ensure leading slash even when pageName is empty (avoid protocol-relative URL)
38
+ const path = pageName ? `/${pageName}/html/${hash}-${nonce}` : `/html/${hash}-${nonce}`;
39
+ return path;
40
+ }
41
+
42
+ /**
43
+ * Render a `[[html]]` block as an iframe wrapped in a `<p>` element.
44
+ *
45
+ * The iframe uses `class="html-block-iframe"` so the runtime can
46
+ * identify it for auto-resize. An optional `sandbox` attribute and
47
+ * custom `style` attribute (from `[[html style="..."]]`) are applied.
48
+ *
49
+ * @param ctx - The current render context.
50
+ * @param data - HTML block data containing the raw HTML contents and optional style.
51
+ */
52
+ export function renderHtmlBlock(ctx: RenderContext, data: HtmlData): void {
53
+ // Settings-level enforcement boundary: skip rendering entirely when
54
+ // `[[html]]` is disabled. Placed before any counter advance or
55
+ // resolver invocation so a disabled-but-still-in-AST block (manually
56
+ // built trees, cached ASTs, foreign parsers) has no observable effect.
57
+ if (ctx.settings.allowHtmlBlocks === false) {
58
+ return;
59
+ }
60
+
61
+ const index = ctx.nextHtmlBlockIndex();
62
+ const pageName = ctx.page?.pageName ?? "";
63
+
64
+ // Use callback URL if provided, otherwise generate default URL
65
+ const callbackUrl = ctx.options.resolvers?.htmlBlockUrl?.(index);
66
+ const src = callbackUrl || generateDefaultUrl(pageName, data.contents);
67
+
68
+ // Build sandbox attribute (null/undefined = no sandbox, Wikidot compatible)
69
+ const sandbox = ctx.options.htmlBlockSandbox;
70
+ const sandboxAttr = sandbox ? ` sandbox="${escapeAttr(sandbox)}"` : "";
71
+
72
+ // Build style attribute (from [[html style="..."]]) with CSS injection protection
73
+ const styleAttr = data.style ? ` style="${escapeAttr(sanitizeStyleValue(data.style))}"` : "";
74
+
75
+ // Wikidot wraps html block iframe in a paragraph
76
+ ctx.push(
77
+ `<p><iframe src="${escapeAttr(src)}"${sandboxAttr} allowtransparency="true" frameborder="0" class="html-block-iframe"${styleAttr}></iframe></p>`,
78
+ );
79
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ *
3
+ * Renderer for `[[iframe URL]]` inline iframe elements.
4
+ *
5
+ * Unlike `[[embed]]` blocks, `[[iframe]]` directly references a URL.
6
+ * The URL is checked for dangerous schemes, and standard iframe
7
+ * attributes (align, frameborder, height, etc.) are extracted from the
8
+ * AST's attribute map and rendered with proper escaping.
9
+ *
10
+ * @module
11
+ */
12
+
13
+ import type { IframeData } from "@wdprlib/ast";
14
+ import type { RenderContext } from "../context";
15
+ import { escapeAttr, isDangerousUrl, sanitizeStyleValue } from "../escape";
16
+
17
+ /**
18
+ * Render an `[[iframe URL]]` element.
19
+ *
20
+ * The iframe is wrapped in a `<p>` element to match Wikidot's output.
21
+ * Standard iframe attributes are rendered from the AST's attribute map,
22
+ * with the `style` attribute sanitized against CSS injection. Dangerous
23
+ * URL schemes are replaced with `#invalid-url`.
24
+ *
25
+ * @param ctx - The current render context.
26
+ * @param data - Iframe data containing the URL and attribute map.
27
+ */
28
+ export function renderIframe(ctx: RenderContext, data: IframeData): void {
29
+ const url = isDangerousUrl(data.url) ? "#invalid-url" : data.url;
30
+ const attrs: string[] = [`src="${escapeAttr(url)}"`];
31
+
32
+ // Standard iframe attributes from the attributes map
33
+ const iframeAttrs = ["align", "frameborder", "height", "scrolling", "width", "class", "style"];
34
+ for (const attr of iframeAttrs) {
35
+ let value = data.attributes[attr] ?? "";
36
+ // Sanitize style attribute to prevent CSS injection
37
+ if (attr === "style") {
38
+ value = sanitizeStyleValue(value);
39
+ }
40
+ attrs.push(`${attr}="${escapeAttr(value)}"`);
41
+ }
42
+
43
+ ctx.push(`<p><iframe ${attrs.join(" ")}></iframe></p>`);
44
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ *
3
+ * Renderer for `[[iftags]]...[[/iftags]]` conditional blocks.
4
+ *
5
+ * Wikidot's `iftags` construct conditionally renders content based on
6
+ * whether the current page's tags match a condition string. The condition
7
+ * supports three kinds of tag tokens:
8
+ *
9
+ * - `+tag` -- required: the tag must be present
10
+ * - `-tag` -- excluded: the tag must NOT be present
11
+ * - `tag` (no prefix) -- optional group: at least one unprefixed tag must be present
12
+ *
13
+ * All three categories must independently be satisfied for the condition
14
+ * to evaluate to true.
15
+ *
16
+ * @module
17
+ */
18
+
19
+ import type { IfTagsData } from "@wdprlib/ast";
20
+ import type { RenderContext } from "../context";
21
+ import { renderElements } from "../render";
22
+
23
+ /**
24
+ * Evaluate an iftags condition string against a list of page tags.
25
+ *
26
+ * The condition is a space-separated list of tokens. All required tags
27
+ * (`+tag`) must be present, all excluded tags (`-tag`) must be absent,
28
+ * and at least one optional tag (bare `tag`) must be present (if any
29
+ * optional tags are specified).
30
+ *
31
+ * An empty condition always evaluates to `false`.
32
+ *
33
+ * @param condition - The condition string (e.g. `"+scp -joke tale"`).
34
+ * @param pageTags - Array of tags currently assigned to the page.
35
+ * @returns `true` if the condition is satisfied.
36
+ */
37
+ function evaluateIfTagsCondition(condition: string, pageTags: string[]): boolean {
38
+ const pageTagSet = new Set(pageTags.map((t) => t.toLowerCase()));
39
+ const tokens = condition.split(/\s+/).filter(Boolean);
40
+
41
+ // Empty condition = never show
42
+ if (tokens.length === 0) {
43
+ return false;
44
+ }
45
+
46
+ const required: string[] = [];
47
+ const excluded: string[] = [];
48
+ const optional: string[] = [];
49
+
50
+ for (const token of tokens) {
51
+ if (token.startsWith("+")) {
52
+ const tag = token.slice(1).toLowerCase();
53
+ if (tag) required.push(tag);
54
+ } else if (token.startsWith("-")) {
55
+ const tag = token.slice(1).toLowerCase();
56
+ if (tag) excluded.push(tag);
57
+ } else {
58
+ optional.push(token.toLowerCase());
59
+ }
60
+ }
61
+
62
+ // If all tokens had empty tag names (e.g. "+" or "-"), treat as empty condition
63
+ if (required.length === 0 && excluded.length === 0 && optional.length === 0) {
64
+ return false;
65
+ }
66
+
67
+ // All required tags must be present
68
+ for (const tag of required) {
69
+ if (!pageTagSet.has(tag)) return false;
70
+ }
71
+
72
+ // All excluded tags must NOT be present
73
+ for (const tag of excluded) {
74
+ if (pageTagSet.has(tag)) return false;
75
+ }
76
+
77
+ // If there are optional tags, at least one must be present
78
+ if (optional.length > 0) {
79
+ const hasAnyOptional = optional.some((tag) => pageTagSet.has(tag));
80
+ if (!hasAnyOptional) return false;
81
+ }
82
+
83
+ return true;
84
+ }
85
+
86
+ /**
87
+ * Render an `[[iftags]]` block.
88
+ *
89
+ * Evaluates the condition against the page's tags (from `ctx.page.tags`).
90
+ * If no page tags are available, an empty array is used (all conditions
91
+ * requiring present tags will fail).
92
+ *
93
+ * @param ctx - The current render context.
94
+ * @param data - IfTags data with condition string and child elements.
95
+ */
96
+ export function renderIfTags(ctx: RenderContext, data: IfTagsData): void {
97
+ const pageTags = ctx.page?.tags ?? [];
98
+
99
+ if (evaluateIfTagsCondition(data.condition, pageTags)) {
100
+ const prev = ctx.renderInlineStyles;
101
+ ctx.renderInlineStyles = true;
102
+
103
+ // If a style slot was assigned during resolve, collect styles into
104
+ // it so they appear at the correct source-order position in the
105
+ // final output. Otherwise fall back to inline rendering.
106
+ const slotId = (data as IfTagsData & { _styleSlot?: number })._styleSlot;
107
+ if (slotId !== undefined) {
108
+ ctx.enterStyleSlot(slotId);
109
+ }
110
+
111
+ renderElements(ctx, data.elements);
112
+
113
+ if (slotId !== undefined) {
114
+ ctx.exitStyleSlot();
115
+ }
116
+ ctx.renderInlineStyles = prev;
117
+ }
118
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ *
3
+ * Renderer for Wikidot image elements (`[[image source]]` and `[[f<image source]]`).
4
+ *
5
+ * Images can be sourced from URLs, page-attached files, or cross-site
6
+ * files. The renderer resolves the source to a URL, sanitizes all
7
+ * attributes, optionally wraps the image in a link (`link` attribute),
8
+ * and optionally wraps everything in an alignment container div.
9
+ *
10
+ * @module
11
+ */
12
+
13
+ import type { ImageSource, ImageData } from "@wdprlib/ast";
14
+ import type { RenderContext } from "../context";
15
+ import { escapeAttr, isDangerousUrl, sanitizeAttributes } from "../escape";
16
+
17
+ /**
18
+ * Render an image element with optional link wrapper and alignment container.
19
+ *
20
+ * Processing steps:
21
+ * 1. Resolve the image source to a URL via `ctx.resolveImageSource()`.
22
+ * 2. Sanitize user-supplied attributes.
23
+ * 3. Build the `<img>` tag with safe attributes.
24
+ * 4. Optionally wrap in an `<a>` tag if a link target is specified.
25
+ * 5. Optionally wrap in a `<div class="image-container ...">` for alignment.
26
+ *
27
+ * Dangerous URLs are replaced with `#invalid-url`. Local paths blocked
28
+ * by settings cause the entire image to be silently dropped.
29
+ *
30
+ * @param ctx - The current render context.
31
+ * @param data - Image element data with source, attributes, optional link, and alignment.
32
+ */
33
+ export function renderImage(ctx: RenderContext, data: ImageData): void {
34
+ let src = ctx.resolveImageSource(data.source);
35
+ if (src === null) return; // Local path blocked by settings
36
+ if (isDangerousUrl(src)) {
37
+ src = "#invalid-url";
38
+ }
39
+ const safeAttrs = sanitizeAttributes(data.attributes);
40
+ const alt = safeAttrs.alt ?? getFilenameFromSource(data.source);
41
+ const className = safeAttrs.class ?? "image";
42
+
43
+ // Build img attributes
44
+ const imgAttrs: string[] = [`src="${escapeAttr(src)}"`];
45
+
46
+ // Custom attributes (title, style, etc.) before alt/class
47
+ // Skip src/srcset to prevent override of resolved source
48
+ for (const [key, value] of Object.entries(safeAttrs)) {
49
+ if (key === "alt" || key === "class" || key === "src" || key === "srcset") continue;
50
+ imgAttrs.push(`${key}="${escapeAttr(value)}"`);
51
+ }
52
+
53
+ imgAttrs.push(`alt="${escapeAttr(alt)}"`);
54
+
55
+ // Only override class if not custom
56
+ if (!safeAttrs.class) {
57
+ imgAttrs.push(`class="${escapeAttr(className)}"`);
58
+ } else {
59
+ imgAttrs.push(`class="${escapeAttr(safeAttrs.class)}"`);
60
+ }
61
+
62
+ const imgTag = `<img ${imgAttrs.join(" ")} />`;
63
+
64
+ // Wrap in link if needed
65
+ let output = imgTag;
66
+ if (data.link) {
67
+ let href: string;
68
+ if (typeof data.link === "string") {
69
+ // Add leading slash for page links (not URLs or anchors)
70
+ if (
71
+ !data.link.startsWith("/") &&
72
+ !data.link.startsWith("#") &&
73
+ !data.link.startsWith("http://") &&
74
+ !data.link.startsWith("https://")
75
+ ) {
76
+ href = `/${data.link}`;
77
+ } else {
78
+ href = data.link;
79
+ }
80
+ } else {
81
+ href = `/${data.link.page}`;
82
+ }
83
+ if (isDangerousUrl(href)) {
84
+ href = "#invalid-url";
85
+ }
86
+ output = `<a href="${escapeAttr(href)}">${imgTag}</a>`;
87
+ }
88
+
89
+ // Wrap in alignment container if needed
90
+ if (data.alignment) {
91
+ const alignClass = getAlignmentClass(data.alignment.align, data.alignment.float);
92
+ ctx.push(`<div class="image-container ${alignClass}">`);
93
+ ctx.push(output);
94
+ ctx.push("</div>");
95
+ } else {
96
+ ctx.push(output);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Map an alignment direction and float flag to a Wikidot CSS class name.
102
+ *
103
+ * @param align - Alignment direction (`"left"`, `"right"`, `"center"`).
104
+ * @param isFloat - Whether the image uses float positioning.
105
+ * @returns CSS class name (e.g. `"floatleft"`, `"aligncenter"`).
106
+ */
107
+ function getAlignmentClass(align: string, isFloat: boolean): string {
108
+ if (isFloat) {
109
+ switch (align) {
110
+ case "left":
111
+ return "floatleft";
112
+ case "right":
113
+ return "floatright";
114
+ case "center":
115
+ return "floatcenter";
116
+ default:
117
+ return `float${align}`;
118
+ }
119
+ }
120
+ switch (align) {
121
+ case "left":
122
+ return "alignleft";
123
+ case "right":
124
+ return "alignright";
125
+ case "center":
126
+ return "aligncenter";
127
+ default:
128
+ return `align${align}`;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Extract a filename from an image source for use as the default `alt` text.
134
+ *
135
+ * For URL sources, the last path segment is returned. For file-type sources,
136
+ * the file name field is returned directly.
137
+ *
138
+ * @param source - The image source descriptor.
139
+ * @returns The extracted filename string.
140
+ */
141
+ function getFilenameFromSource(source: ImageSource): string {
142
+ switch (source.type) {
143
+ case "url": {
144
+ const parts = source.data.split("/");
145
+ return parts[parts.length - 1] ?? source.data;
146
+ }
147
+ case "file1":
148
+ return source.data.file;
149
+ case "file2":
150
+ return source.data.file;
151
+ case "file3":
152
+ return source.data.file;
153
+ }
154
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ *
3
+ * Renderer for `[[include page-name]]` transclusion elements.
4
+ *
5
+ * Includes are resolved by the parser before rendering: if the target
6
+ * page exists, its parsed elements are injected into the AST. At render
7
+ * time, the renderer either outputs the pre-resolved elements or shows
8
+ * a Wikidot-compatible error message with a "create it now" link.
9
+ *
10
+ * @module
11
+ */
12
+
13
+ import type { IncludeData } from "@wdprlib/ast";
14
+ import type { RenderContext } from "../context";
15
+ import { escapeAttr, escapeHtml } from "../escape";
16
+ import { renderElements } from "../render";
17
+
18
+ /**
19
+ * Render an `[[include]]` element.
20
+ *
21
+ * If the include was resolved by the parser (i.e., `data.elements` is
22
+ * non-empty), the resolved elements are rendered directly. Otherwise,
23
+ * a Wikidot-compatible error block with a "create it now" link is shown.
24
+ *
25
+ * @param ctx - The current render context.
26
+ * @param data - Include data with the target page location and resolved elements.
27
+ */
28
+ export function renderInclude(ctx: RenderContext, data: IncludeData): void {
29
+ // If elements is empty, the include was not resolved - show error
30
+ if (data.elements.length === 0) {
31
+ // Wikidot normalizes page names to lowercase
32
+ const pageName = data.location.page.toLowerCase();
33
+ // Encode page name for URL path (/ should not be encoded, but special chars should)
34
+ const encodedPageName = pageName.replace(/[^a-z0-9\-_:/]/g, (c) => encodeURIComponent(c));
35
+ // Prevent protocol-relative URLs
36
+ const safePath = encodedPageName.startsWith("/") ? encodedPageName.slice(1) : encodedPageName;
37
+ ctx.push(
38
+ `<div class="error-block"><p>Included page "${escapeHtml(pageName)}" does not exist (<a href="/${escapeAttr(safePath)}/edit/true">create it now</a>)</p></div>`,
39
+ );
40
+ return;
41
+ }
42
+ renderElements(ctx, data.elements);
43
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ *
3
+ * Barrel export for all Wikidot element renderers.
4
+ *
5
+ * Each renderer function accepts a `RenderContext` and the
6
+ * element-specific AST data, then pushes the resulting HTML fragments
7
+ * into the context's output buffer.
8
+ *
9
+ * @module
10
+ */
11
+
12
+ export { renderContainer } from "./container";
13
+ export { renderText, renderRaw, renderEmail } from "./text";
14
+ export { renderLink, renderAnchor, renderAnchorName } from "./link";
15
+ export { renderImage } from "./image";
16
+ export { renderList, renderDefinitionList } from "./list";
17
+ export { renderTable } from "./table";
18
+ export { renderCollapsible } from "./collapsible";
19
+ export { renderCode } from "./code";
20
+ export { renderTabView } from "./tab-view";
21
+ export { renderFootnoteRef, renderFootnoteBlock } from "./footnote";
22
+ export { renderMath, renderMathInline } from "./math";
23
+ export { renderModule } from "./module/index";
24
+ export { renderEmbed } from "./embed";
25
+ export { renderUser } from "./user";
26
+ export { renderBibliographyCite, renderBibliographyBlock } from "./bibliography";
27
+ export { renderTableOfContents } from "./toc";
28
+ export { renderLineBreaks } from "./line-break";
29
+ export { renderClearFloat } from "./clear-float";
30
+ export { renderIframe } from "./iframe";
31
+ export { renderHtmlBlock } from "./html";
32
+ export { renderInclude } from "./include";
33
+ export { renderIfTags } from "./iftags";
34
+ export { renderColor } from "./color";
35
+ export { renderDate } from "./date";
@@ -0,0 +1,22 @@
1
+ /**
2
+ *
3
+ * Renderer for explicit line breaks (`_` at end of line in Wikidot markup).
4
+ *
5
+ * Multiple consecutive line-break elements produce multiple `<br />` tags.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ import type { RenderContext } from "../context";
11
+
12
+ /**
13
+ * Render one or more consecutive line breaks as `<br />` tags.
14
+ *
15
+ * @param ctx - The current render context.
16
+ * @param count - Number of `<br />` tags to emit.
17
+ */
18
+ export function renderLineBreaks(ctx: RenderContext, count: number): void {
19
+ for (let i = 0; i < count; i++) {
20
+ ctx.push("<br />");
21
+ }
22
+ }