@wdprlib/render 2.0.0 → 3.0.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 +2332 -2032
- package/dist/index.d.cts +15 -13
- package/dist/index.d.ts +15 -13
- package/dist/index.js +2336 -2036
- package/package.json +5 -3
- package/src/context/attributes.ts +14 -0
- package/src/context/bibliography.ts +109 -0
- package/src/context/counters.ts +51 -0
- package/src/context/image-urls.ts +31 -0
- package/src/context/index.ts +285 -0
- package/src/context/output.ts +17 -0
- package/src/context/page-urls.ts +81 -0
- package/src/context/style-slots.ts +29 -0
- package/src/context/urls.ts +2 -0
- package/src/elements/bibliography/block.ts +27 -0
- package/src/elements/bibliography/cite.ts +23 -0
- package/src/elements/bibliography/ids.ts +9 -0
- package/src/elements/bibliography/index.ts +9 -0
- package/src/elements/clear-float.ts +27 -0
- package/src/elements/code/contents.ts +18 -0
- package/src/elements/code/index.ts +29 -0
- package/src/elements/collapsible/index.ts +31 -0
- package/src/elements/collapsible/labels.ts +35 -0
- package/src/elements/collapsible/link.ts +11 -0
- package/src/elements/collapsible/sections.ts +39 -0
- package/src/elements/color.ts +32 -0
- package/src/elements/container/attributes.ts +28 -0
- package/src/elements/container/header.ts +27 -0
- package/src/elements/container/index.ts +35 -0
- package/src/elements/container/string-container.ts +40 -0
- package/src/elements/container/string-types.ts +63 -0
- package/src/elements/container/wrappers.ts +32 -0
- package/src/elements/date/format.ts +20 -0
- package/src/elements/date/index.ts +34 -0
- package/src/elements/date/output.ts +6 -0
- package/src/elements/embed/iframe.ts +8 -0
- package/src/elements/embed/index.ts +28 -0
- package/src/elements/embed/providers.ts +43 -0
- package/src/elements/embed/validation.ts +15 -0
- package/src/elements/embed-block/allowlist.ts +60 -0
- package/src/elements/embed-block/boolean-attributes.ts +38 -0
- package/src/elements/embed-block/iframe.ts +33 -0
- package/src/elements/embed-block/index.ts +31 -0
- package/src/elements/embed-block/sanitize-config.ts +22 -0
- package/src/elements/embed-block/sanitize.ts +44 -0
- package/src/elements/expr/branch.ts +29 -0
- package/src/elements/expr/index.ts +63 -0
- package/src/elements/expr/result.ts +19 -0
- package/src/elements/footnote/body.ts +11 -0
- package/src/elements/footnote/index.ts +35 -0
- package/src/elements/footnote/ref.ts +16 -0
- package/src/elements/html/attributes.ts +24 -0
- package/src/elements/html/index.ts +39 -0
- package/src/elements/html/url.ts +19 -0
- package/src/elements/iframe/attributes.ts +28 -0
- package/src/elements/iframe/index.ts +22 -0
- package/src/elements/iftags/condition.ts +42 -0
- package/src/elements/iftags/index.ts +39 -0
- package/src/elements/iftags/style-slot.ts +23 -0
- package/src/elements/iftags/tokens.ts +36 -0
- package/src/elements/image/alignment.ts +44 -0
- package/src/elements/image/attributes.ts +10 -0
- package/src/elements/image/img-attributes.ts +26 -0
- package/src/elements/image/index.ts +36 -0
- package/src/elements/image/link-href.ts +24 -0
- package/src/elements/image/link.ts +13 -0
- package/src/elements/image/source.ts +16 -0
- package/src/elements/include/index.ts +35 -0
- package/src/elements/include/missing.ts +15 -0
- package/src/elements/index.ts +35 -0
- package/src/elements/line-break.ts +22 -0
- package/src/elements/link/anchor-name.ts +6 -0
- package/src/elements/link/anchor.ts +27 -0
- package/src/elements/link/attributes.ts +47 -0
- package/src/elements/link/index.ts +26 -0
- package/src/elements/link/label.ts +23 -0
- package/src/elements/link/target.ts +20 -0
- package/src/elements/list/attributes.ts +19 -0
- package/src/elements/list/definition-list.ts +16 -0
- package/src/elements/list/index.ts +48 -0
- package/src/elements/list/item-rendering.ts +38 -0
- package/src/elements/list/items.ts +61 -0
- package/src/elements/list/no-marker.ts +53 -0
- package/src/elements/list/paragraphs.ts +34 -0
- package/src/elements/list/trim.ts +38 -0
- package/src/elements/math/block.ts +29 -0
- package/src/elements/math/equation-ref.ts +12 -0
- package/src/elements/math/index.ts +14 -0
- package/src/elements/math/inline.ts +19 -0
- package/src/elements/math/latex.ts +27 -0
- package/src/elements/math/source.ts +18 -0
- package/src/elements/module/backlinks.ts +29 -0
- package/src/elements/module/categories.ts +27 -0
- package/src/elements/module/empty-container.ts +10 -0
- package/src/elements/module/index.ts +65 -0
- package/src/elements/module/join-markup.ts +10 -0
- package/src/elements/module/join.ts +28 -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-markup.ts +10 -0
- package/src/elements/module/rate.ts +35 -0
- package/src/elements/module/unknown.ts +11 -0
- package/src/elements/tab-view/ids.ts +16 -0
- package/src/elements/tab-view/index.ts +31 -0
- package/src/elements/tab-view/navigation.ts +15 -0
- package/src/elements/tab-view/panels.ts +16 -0
- package/src/elements/table/attributes.ts +23 -0
- package/src/elements/table/cell-attributes.ts +62 -0
- package/src/elements/table/cell.ts +13 -0
- package/src/elements/table/index.ts +27 -0
- package/src/elements/text/email.ts +20 -0
- package/src/elements/text/index.ts +11 -0
- package/src/elements/text/plain.ts +11 -0
- package/src/elements/text/raw.ts +20 -0
- package/src/elements/toc/body.ts +12 -0
- package/src/elements/toc/entries.ts +34 -0
- package/src/elements/toc/frame.ts +27 -0
- package/src/elements/toc/index.ts +17 -0
- package/src/elements/toc/link.ts +26 -0
- package/src/elements/user/index.ts +40 -0
- package/src/elements/user/markup.ts +34 -0
- package/src/elements/user/resolve.ts +6 -0
- package/src/escape/attribute-allowlists.ts +101 -0
- package/src/escape/attributes.ts +62 -0
- package/src/escape/css-color-functions.ts +18 -0
- package/src/escape/css-colors.ts +183 -0
- package/src/escape/css-danger.ts +22 -0
- package/src/escape/css-normalize.ts +54 -0
- package/src/escape/css-style.ts +78 -0
- package/src/escape/css-urls.ts +76 -0
- package/src/escape/css.ts +4 -0
- package/src/escape/email.ts +22 -0
- package/src/escape/html.ts +68 -0
- package/src/escape/index.ts +15 -0
- package/src/escape/url.ts +18 -0
- package/src/hash.ts +62 -0
- package/src/index.ts +26 -0
- package/src/libs/highlighter/engine/end-pattern.ts +26 -0
- package/src/libs/highlighter/engine/html.ts +19 -0
- package/src/libs/highlighter/engine/index.ts +3 -0
- package/src/libs/highlighter/engine/keywords.ts +22 -0
- package/src/libs/highlighter/engine/parts.ts +36 -0
- package/src/libs/highlighter/engine/preprocess.ts +10 -0
- package/src/libs/highlighter/engine/render.ts +31 -0
- package/src/libs/highlighter/engine/token.ts +7 -0
- package/src/libs/highlighter/engine/tokenizer.ts +266 -0
- package/src/libs/highlighter/engine/utils.ts +38 -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/collected-styles.ts +22 -0
- package/src/render/dispatch.ts +181 -0
- package/src/render/index.ts +28 -0
- package/src/render/primitives.ts +17 -0
- package/src/render/style-tag.ts +6 -0
- package/src/render/style.ts +15 -0
- package/src/types.ts +144 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Allowlist entry for embed content validation.
|
|
3
|
+
*/
|
|
4
|
+
export interface EmbedAllowlistEntry {
|
|
5
|
+
/** Host pattern. Supports wildcard prefix `*.` such as `*.youtube.com`. */
|
|
6
|
+
host: string;
|
|
7
|
+
/** Optional path prefix that must match, such as `/embed/`. */
|
|
8
|
+
pathPrefix?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Default allowlist for embed content.
|
|
13
|
+
*
|
|
14
|
+
* Set the render option to `null` to allow any HTTP(S) iframe while still using
|
|
15
|
+
* sanitizer and scheme validation.
|
|
16
|
+
*/
|
|
17
|
+
export const DEFAULT_EMBED_ALLOWLIST: EmbedAllowlistEntry[] | null = [
|
|
18
|
+
{ host: "*.youtube.com", pathPrefix: "/embed/" },
|
|
19
|
+
{ host: "*.youtube-nocookie.com", pathPrefix: "/embed/" },
|
|
20
|
+
{ host: "player.vimeo.com", pathPrefix: "/video/" },
|
|
21
|
+
{ host: "*.google.com", pathPrefix: "/maps/embed" },
|
|
22
|
+
{ host: "calendar.google.com", pathPrefix: "/calendar/embed" },
|
|
23
|
+
{ host: "open.spotify.com", pathPrefix: "/embed/" },
|
|
24
|
+
{ host: "w.soundcloud.com", pathPrefix: "/player/" },
|
|
25
|
+
{ host: "codepen.io" },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check whether a URL matches an allowlist entry's host and optional path prefix.
|
|
30
|
+
*/
|
|
31
|
+
export function matchesAllowlistEntry(url: URL, entry: EmbedAllowlistEntry): boolean {
|
|
32
|
+
if (!matchesHostPattern(url.hostname, entry.host)) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
if (entry.pathPrefix) {
|
|
36
|
+
const pathLower = url.pathname.toLowerCase();
|
|
37
|
+
const prefixLower = entry.pathPrefix.toLowerCase();
|
|
38
|
+
if (!pathLower.startsWith(prefixLower)) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
if (!prefixLower.endsWith("/")) {
|
|
42
|
+
const remainder = pathLower.slice(prefixLower.length);
|
|
43
|
+
if (remainder && !/^[/?#]/.test(remainder)) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function matchesHostPattern(hostname: string, pattern: string): boolean {
|
|
52
|
+
const lowerHostname = hostname.toLowerCase();
|
|
53
|
+
const lowerPattern = pattern.toLowerCase();
|
|
54
|
+
|
|
55
|
+
if (lowerPattern.startsWith("*.")) {
|
|
56
|
+
const base = lowerPattern.slice(2);
|
|
57
|
+
return lowerHostname === base || lowerHostname.endsWith("." + base);
|
|
58
|
+
}
|
|
59
|
+
return lowerHostname === lowerPattern;
|
|
60
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const BOOLEAN_ATTRIBUTES = [
|
|
2
|
+
"allowfullscreen",
|
|
3
|
+
"async",
|
|
4
|
+
"autofocus",
|
|
5
|
+
"autoplay",
|
|
6
|
+
"checked",
|
|
7
|
+
"controls",
|
|
8
|
+
"default",
|
|
9
|
+
"defer",
|
|
10
|
+
"disabled",
|
|
11
|
+
"formnovalidate",
|
|
12
|
+
"hidden",
|
|
13
|
+
"ismap",
|
|
14
|
+
"loop",
|
|
15
|
+
"multiple",
|
|
16
|
+
"muted",
|
|
17
|
+
"novalidate",
|
|
18
|
+
"open",
|
|
19
|
+
"readonly",
|
|
20
|
+
"required",
|
|
21
|
+
"reversed",
|
|
22
|
+
"selected",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Normalize HTML boolean attributes to Wikidot's `attr="attr"` format.
|
|
27
|
+
*/
|
|
28
|
+
export function normalizeBooleanAttributes(html: string): string {
|
|
29
|
+
let result = html;
|
|
30
|
+
for (const attr of BOOLEAN_ATTRIBUTES) {
|
|
31
|
+
const standalonePattern = new RegExp(`\\s${attr}(?=\\s|>|/>)`, "gi");
|
|
32
|
+
result = result.replace(standalonePattern, ` ${attr}="${attr}"`);
|
|
33
|
+
|
|
34
|
+
const emptyValuePattern = new RegExp(`\\s${attr}=""`, "gi");
|
|
35
|
+
result = result.replace(emptyValuePattern, ` ${attr}="${attr}"`);
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Element } from "domhandler";
|
|
2
|
+
import { parseDocument } from "htmlparser2";
|
|
3
|
+
|
|
4
|
+
export function findIframes(html: string): Element[] {
|
|
5
|
+
const doc = parseDocument(html);
|
|
6
|
+
const iframes: Element[] = [];
|
|
7
|
+
function walk(nodes: typeof doc.children): void {
|
|
8
|
+
for (const node of nodes) {
|
|
9
|
+
if (node.type !== "tag") {
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
if (node.name === "iframe") {
|
|
13
|
+
iframes.push(node);
|
|
14
|
+
}
|
|
15
|
+
if (node.children) {
|
|
16
|
+
walk(node.children);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
walk(doc.children);
|
|
21
|
+
return iframes;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function parseIframeUrl(src: string, baseUrl?: string): URL | null {
|
|
25
|
+
try {
|
|
26
|
+
if (src.startsWith("//")) {
|
|
27
|
+
return new URL(src, baseUrl ?? "https://localhost");
|
|
28
|
+
}
|
|
29
|
+
return new URL(src);
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderer for `[[embed]]...[[/embed]]` block-level embeds.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { EmbedBlockData } from "@wdprlib/ast";
|
|
8
|
+
import type { RenderContext } from "../../context";
|
|
9
|
+
import { DEFAULT_EMBED_ALLOWLIST, type EmbedAllowlistEntry } from "./allowlist";
|
|
10
|
+
import { normalizeBooleanAttributes, validateAndSanitizeEmbed } from "./sanitize";
|
|
11
|
+
|
|
12
|
+
export { DEFAULT_EMBED_ALLOWLIST, type EmbedAllowlistEntry } from "./allowlist";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Render an `[[embed]]...[[/embed]]` block element.
|
|
16
|
+
*
|
|
17
|
+
* The raw HTML content is validated and sanitized through the full pipeline. On
|
|
18
|
+
* failure, a Wikidot-compatible error block is shown.
|
|
19
|
+
*/
|
|
20
|
+
export function renderEmbedBlock(ctx: RenderContext, data: EmbedBlockData): void {
|
|
21
|
+
const allowlist: EmbedAllowlistEntry[] | null =
|
|
22
|
+
ctx.options.embedAllowlist !== undefined ? ctx.options.embedAllowlist : DEFAULT_EMBED_ALLOWLIST;
|
|
23
|
+
|
|
24
|
+
const sanitized = validateAndSanitizeEmbed(data.contents, allowlist, ctx.options.baseUrl);
|
|
25
|
+
if (sanitized === null) {
|
|
26
|
+
ctx.push('<div class="error-block">Sorry, no match for the embedded content.</div>');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
ctx.push(normalizeBooleanAttributes(sanitized));
|
|
31
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import sanitizeHtml from "sanitize-html";
|
|
2
|
+
|
|
3
|
+
export const SANITIZE_CONFIG: sanitizeHtml.IOptions = {
|
|
4
|
+
allowedTags: ["iframe"],
|
|
5
|
+
allowedAttributes: {
|
|
6
|
+
iframe: [
|
|
7
|
+
"class",
|
|
8
|
+
"src",
|
|
9
|
+
"style",
|
|
10
|
+
"allow",
|
|
11
|
+
"allowfullscreen",
|
|
12
|
+
"frameborder",
|
|
13
|
+
"height",
|
|
14
|
+
"loading",
|
|
15
|
+
"referrerpolicy",
|
|
16
|
+
"sandbox",
|
|
17
|
+
"title",
|
|
18
|
+
"width",
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
allowedSchemes: ["https", "http"],
|
|
22
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import sanitizeHtml from "sanitize-html";
|
|
2
|
+
import type { EmbedAllowlistEntry } from "./allowlist";
|
|
3
|
+
import { matchesAllowlistEntry } from "./allowlist";
|
|
4
|
+
import { findIframes, parseIframeUrl } from "./iframe";
|
|
5
|
+
import { SANITIZE_CONFIG } from "./sanitize-config";
|
|
6
|
+
|
|
7
|
+
export { normalizeBooleanAttributes } from "./boolean-attributes";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Validate and sanitize embed block content through a multi-step iframe pipeline.
|
|
11
|
+
*/
|
|
12
|
+
export function validateAndSanitizeEmbed(
|
|
13
|
+
content: string,
|
|
14
|
+
allowlist: EmbedAllowlistEntry[] | null,
|
|
15
|
+
baseUrl?: string,
|
|
16
|
+
): string | null {
|
|
17
|
+
const sanitized = sanitizeHtml(content.trim(), SANITIZE_CONFIG);
|
|
18
|
+
if (!sanitized.trim()) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const iframes = findIframes(sanitized);
|
|
23
|
+
if (iframes.length !== 1) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const src = iframes[0]!.attribs.src?.trim();
|
|
28
|
+
if (!src) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const url = parseIframeUrl(src, baseUrl);
|
|
33
|
+
if (url === null) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
if (allowlist !== null && !allowlist.some((entry) => matchesAllowlistEntry(url, entry))) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return sanitized;
|
|
44
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Element } from "@wdprlib/ast";
|
|
2
|
+
import type { RenderContext } from "../../context";
|
|
3
|
+
import { renderElements } from "../../render";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Render a branch's elements, trimming trailing whitespace-only text nodes.
|
|
7
|
+
*
|
|
8
|
+
* Wikidot strips trailing whitespace from `#if` / `#ifexpr` branch output.
|
|
9
|
+
*
|
|
10
|
+
* @param ctx - The current render context.
|
|
11
|
+
* @param elements - The branch's element array.
|
|
12
|
+
*/
|
|
13
|
+
export function renderBranchElements(ctx: RenderContext, elements: Element[]): void {
|
|
14
|
+
renderElements(ctx, elements.slice(0, findBranchRenderLength(elements)));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function findBranchRenderLength(elements: Element[]): number {
|
|
18
|
+
let lastIdx = elements.length - 1;
|
|
19
|
+
while (lastIdx >= 0 && isWhitespaceText(elements[lastIdx]!)) {
|
|
20
|
+
lastIdx--;
|
|
21
|
+
}
|
|
22
|
+
return lastIdx + 1;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isWhitespaceText(element: Element): boolean {
|
|
26
|
+
return (
|
|
27
|
+
element.element === "text" && typeof element.data === "string" && element.data.trim() === ""
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* Renderers for Wikidot's expression and conditional constructs.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExprData, IfCondData, IfExprData } from "@wdprlib/ast";
|
|
9
|
+
import { isTruthy } from "@wdprlib/ast";
|
|
10
|
+
import type { RenderContext } from "../../context";
|
|
11
|
+
import { renderBranchElements } from "./branch";
|
|
12
|
+
import { evaluateExpressionOutput, evaluateIfExpressionValue } from "./result";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Render a `[[#expr]]` element.
|
|
16
|
+
*
|
|
17
|
+
* Evaluates the mathematical expression and outputs the formatted numeric
|
|
18
|
+
* result. On evaluation error, a Wikidot-compatible error message is
|
|
19
|
+
* displayed. Empty expressions produce no output.
|
|
20
|
+
*
|
|
21
|
+
* @param ctx - The current render context.
|
|
22
|
+
* @param data - Expression data containing the expression string.
|
|
23
|
+
*/
|
|
24
|
+
export function renderExpr(ctx: RenderContext, data: ExprData): void {
|
|
25
|
+
const output = evaluateExpressionOutput(data.expression);
|
|
26
|
+
if (output !== null) {
|
|
27
|
+
ctx.pushEscaped(output);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Render a `[[#if]]` conditional element.
|
|
33
|
+
*
|
|
34
|
+
* The condition is treated as a string: values `"false"`, `"null"`,
|
|
35
|
+
* `""`, and `"0"` are falsy; everything else is truthy.
|
|
36
|
+
*
|
|
37
|
+
* @param ctx - The current render context.
|
|
38
|
+
* @param data - If-condition data with condition string and then/else branches.
|
|
39
|
+
*/
|
|
40
|
+
export function renderIf(ctx: RenderContext, data: IfCondData): void {
|
|
41
|
+
renderBranchElements(ctx, isTruthy(data.condition) ? data.then : data.else);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Render a `[[#ifexpr]]` conditional expression element.
|
|
46
|
+
*
|
|
47
|
+
* Evaluates the mathematical expression; a result of 0 selects the
|
|
48
|
+
* `else` branch, any non-zero result selects the `then` branch.
|
|
49
|
+
* On evaluation error, a Wikidot-compatible error message is displayed
|
|
50
|
+
* and neither branch is rendered.
|
|
51
|
+
*
|
|
52
|
+
* @param ctx - The current render context.
|
|
53
|
+
* @param data - If-expression data with expression string and then/else branches.
|
|
54
|
+
*/
|
|
55
|
+
export function renderIfExpr(ctx: RenderContext, data: IfExprData): void {
|
|
56
|
+
const value = evaluateIfExpressionValue(data.expression);
|
|
57
|
+
if (typeof value === "string") {
|
|
58
|
+
ctx.pushEscaped(value);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
renderBranchElements(ctx, value !== 0 ? data.then : data.else);
|
|
63
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { evaluateExpression, formatExprValue } from "@wdprlib/ast";
|
|
2
|
+
|
|
3
|
+
export function evaluateExpressionOutput(expression: string): string | null {
|
|
4
|
+
const result = evaluateExpression(expression);
|
|
5
|
+
if (result.success) {
|
|
6
|
+
return formatExprValue(result.value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
if (result.error === "empty expression") {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return `run-time error: ${result.error}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function evaluateIfExpressionValue(expression: string): number | string {
|
|
17
|
+
const result = evaluateExpression(expression);
|
|
18
|
+
return result.success ? result.value : `run-time error: ${result.error}`;
|
|
19
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Element } from "@wdprlib/ast";
|
|
2
|
+
import type { RenderContext } from "../../context";
|
|
3
|
+
import { renderElements } from "../../render";
|
|
4
|
+
|
|
5
|
+
export function renderFootnoteBody(ctx: RenderContext, index: number, elements: Element[]): void {
|
|
6
|
+
const fnId = ctx.generateId("footnote-", index);
|
|
7
|
+
ctx.push(`<div class="footnote-footer" id="${fnId}">`);
|
|
8
|
+
ctx.push(`<a href="javascript:;">${index}</a>. `);
|
|
9
|
+
renderElements(ctx, elements);
|
|
10
|
+
ctx.push("</div>");
|
|
11
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* Renderers for Wikidot footnote markup.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { FootnoteBlockData } from "@wdprlib/ast";
|
|
9
|
+
import type { RenderContext } from "../../context";
|
|
10
|
+
import { escapeHtml } from "../../escape";
|
|
11
|
+
import { renderFootnoteBody } from "./body";
|
|
12
|
+
|
|
13
|
+
export { renderFootnoteRef } from "./ref";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Render a `[[footnoteblock]]` element that lists all footnote bodies.
|
|
17
|
+
*
|
|
18
|
+
* If there are no footnotes, the block is not rendered at all.
|
|
19
|
+
*
|
|
20
|
+
* @param ctx - The current render context.
|
|
21
|
+
* @param data - Footnote block data with optional custom title.
|
|
22
|
+
*/
|
|
23
|
+
export function renderFootnoteBlock(ctx: RenderContext, data: FootnoteBlockData): void {
|
|
24
|
+
if (ctx.footnotes.length === 0) return;
|
|
25
|
+
const title = data.title ?? "Footnotes";
|
|
26
|
+
|
|
27
|
+
ctx.push(`<div class="footnotes-footer">`);
|
|
28
|
+
ctx.push(`<div class="title">${escapeHtml(title)}</div>`);
|
|
29
|
+
|
|
30
|
+
for (let i = 0; i < ctx.footnotes.length; i++) {
|
|
31
|
+
renderFootnoteBody(ctx, i + 1, ctx.footnotes[i] ?? []);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
ctx.push("</div>");
|
|
35
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { RenderContext } from "../../context";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Render an inline footnote reference as a superscript link.
|
|
5
|
+
*
|
|
6
|
+
* Produces `<sup class="footnoteref"><a id="footnoteref-N" ...>N</a></sup>`.
|
|
7
|
+
*
|
|
8
|
+
* @param ctx - The current render context.
|
|
9
|
+
* @param index - The 1-based footnote number.
|
|
10
|
+
*/
|
|
11
|
+
export function renderFootnoteRef(ctx: RenderContext, index: number): void {
|
|
12
|
+
const id = ctx.generateId("footnoteref-", index);
|
|
13
|
+
ctx.push(`<sup class="footnoteref">`);
|
|
14
|
+
ctx.push(`<a id="${id}" href="javascript:;" class="footnoteref">${index}</a>`);
|
|
15
|
+
ctx.push("</sup>");
|
|
16
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { HtmlData } from "@wdprlib/ast";
|
|
2
|
+
import type { RenderContext } from "../../context";
|
|
3
|
+
import { escapeAttr, sanitizeStyleValue } from "../../escape";
|
|
4
|
+
|
|
5
|
+
export interface HtmlBlockAttributes {
|
|
6
|
+
sandbox: string;
|
|
7
|
+
style: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getHtmlBlockAttributes(ctx: RenderContext, data: HtmlData): HtmlBlockAttributes {
|
|
11
|
+
return {
|
|
12
|
+
sandbox: getSandboxAttribute(ctx),
|
|
13
|
+
style: getStyleAttribute(data),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getSandboxAttribute(ctx: RenderContext): string {
|
|
18
|
+
const sandbox = ctx.options.htmlBlockSandbox;
|
|
19
|
+
return sandbox ? ` sandbox="${escapeAttr(sandbox)}"` : "";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getStyleAttribute(data: HtmlData): string {
|
|
23
|
+
return data.style ? ` style="${escapeAttr(sanitizeStyleValue(data.style))}"` : "";
|
|
24
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* Renderer for `[[html]]...[[/html]]` blocks in Wikidot markup.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { HtmlData } from "@wdprlib/ast";
|
|
9
|
+
import type { RenderContext } from "../../context";
|
|
10
|
+
import { escapeAttr } from "../../escape";
|
|
11
|
+
import { getHtmlBlockAttributes } from "./attributes";
|
|
12
|
+
import { resolveHtmlBlockUrl } from "./url";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Render a `[[html]]` block as an iframe wrapped in a `<p>` element.
|
|
16
|
+
*
|
|
17
|
+
* The iframe uses `class="html-block-iframe"` so the runtime can
|
|
18
|
+
* identify it for auto-resize. An optional `sandbox` attribute and
|
|
19
|
+
* custom `style` attribute (from `[[html style="..."]]`) are applied.
|
|
20
|
+
*
|
|
21
|
+
* @param ctx - The current render context.
|
|
22
|
+
* @param data - HTML block data containing the raw HTML contents and optional style.
|
|
23
|
+
*/
|
|
24
|
+
export function renderHtmlBlock(ctx: RenderContext, data: HtmlData): void {
|
|
25
|
+
// Settings-level enforcement boundary: skip rendering entirely when
|
|
26
|
+
// `[[html]]` is disabled. Placed before any counter advance or
|
|
27
|
+
// resolver invocation so disabled AST blocks have no observable effect.
|
|
28
|
+
if (ctx.settings.allowHtmlBlocks === false) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const index = ctx.nextHtmlBlockIndex();
|
|
33
|
+
const src = resolveHtmlBlockUrl(ctx, data.contents, index);
|
|
34
|
+
const attrs = getHtmlBlockAttributes(ctx, data);
|
|
35
|
+
|
|
36
|
+
ctx.push(
|
|
37
|
+
`<p><iframe src="${escapeAttr(src)}"${attrs.sandbox} allowtransparency="true" frameborder="0" class="html-block-iframe"${attrs.style}></iframe></p>`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { RenderContext } from "../../context";
|
|
2
|
+
import { syncHashSha1 } from "../../hash";
|
|
3
|
+
|
|
4
|
+
export function resolveHtmlBlockUrl(ctx: RenderContext, contents: string, index: number): string {
|
|
5
|
+
const callbackUrl = ctx.options.resolvers?.htmlBlockUrl?.(index);
|
|
6
|
+
return callbackUrl || generateDefaultHtmlBlockUrl(ctx.page?.pageName ?? "", contents);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generate a default iframe `src` URL for an HTML block when no
|
|
11
|
+
* resolver callback is provided.
|
|
12
|
+
*
|
|
13
|
+
* The URL follows Wikidot's pattern: `/{pageName}/html/{hash}-{nonce}`.
|
|
14
|
+
*/
|
|
15
|
+
function generateDefaultHtmlBlockUrl(pageName: string, contents: string): string {
|
|
16
|
+
const hash = syncHashSha1(contents);
|
|
17
|
+
const nonce = BigInt(contents.length) * 1000000000n + BigInt(hash.charCodeAt(0)) * 100000000n;
|
|
18
|
+
return pageName ? `/${pageName}/html/${hash}-${nonce}` : `/html/${hash}-${nonce}`;
|
|
19
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { IframeData } from "@wdprlib/ast";
|
|
2
|
+
import { escapeAttr, isDangerousUrl, sanitizeStyleValue } from "../../escape";
|
|
3
|
+
|
|
4
|
+
const IFRAME_ATTRIBUTES = [
|
|
5
|
+
"align",
|
|
6
|
+
"frameborder",
|
|
7
|
+
"height",
|
|
8
|
+
"scrolling",
|
|
9
|
+
"width",
|
|
10
|
+
"class",
|
|
11
|
+
"style",
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export function getIframeAttributes(data: IframeData): string[] {
|
|
15
|
+
const url = isDangerousUrl(data.url) ? "#invalid-url" : data.url;
|
|
16
|
+
const attrs = [`src="${escapeAttr(url)}"`];
|
|
17
|
+
|
|
18
|
+
for (const attr of IFRAME_ATTRIBUTES) {
|
|
19
|
+
attrs.push(`${attr}="${escapeAttr(getIframeAttributeValue(data, attr))}"`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return attrs;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getIframeAttributeValue(data: IframeData, attr: string): string {
|
|
26
|
+
const value = data.attributes[attr] ?? "";
|
|
27
|
+
return attr === "style" ? sanitizeStyleValue(value) : value;
|
|
28
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* Renderer for `[[iframe URL]]` inline iframe elements.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { IframeData } from "@wdprlib/ast";
|
|
9
|
+
import type { RenderContext } from "../../context";
|
|
10
|
+
import { getIframeAttributes } from "./attributes";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Render an `[[iframe URL]]` element.
|
|
14
|
+
*
|
|
15
|
+
* The iframe is wrapped in a `<p>` element to match Wikidot's output.
|
|
16
|
+
*
|
|
17
|
+
* @param ctx - The current render context.
|
|
18
|
+
* @param data - Iframe data containing the URL and attribute map.
|
|
19
|
+
*/
|
|
20
|
+
export function renderIframe(ctx: RenderContext, data: IframeData): void {
|
|
21
|
+
ctx.push(`<p><iframe ${getIframeAttributes(data).join(" ")}></iframe></p>`);
|
|
22
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { hasAnyIfTagsConditionToken, parseIfTagsConditionTokens } from "./tokens";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Evaluate an iftags condition string against a list of page tags.
|
|
5
|
+
*
|
|
6
|
+
* The condition is a space-separated list of tokens. All required tags
|
|
7
|
+
* (`+tag`) must be present, all excluded tags (`-tag`) must be absent,
|
|
8
|
+
* and at least one optional tag (bare `tag`) must be present (if any
|
|
9
|
+
* optional tags are specified).
|
|
10
|
+
*
|
|
11
|
+
* An empty condition always evaluates to `false`.
|
|
12
|
+
*
|
|
13
|
+
* @param condition - The condition string (e.g. `"+scp -joke tale"`).
|
|
14
|
+
* @param pageTags - Array of tags currently assigned to the page.
|
|
15
|
+
* @returns `true` if the condition is satisfied.
|
|
16
|
+
*/
|
|
17
|
+
export function evaluateIfTagsCondition(condition: string, pageTags: string[]): boolean {
|
|
18
|
+
const pageTagSet = new Set(pageTags.map((tag) => tag.toLowerCase()));
|
|
19
|
+
const tokens = parseIfTagsConditionTokens(condition);
|
|
20
|
+
|
|
21
|
+
if (!hasAnyIfTagsConditionToken(tokens)) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
hasRequiredTags(tokens.required, pageTagSet) &&
|
|
27
|
+
hasNoExcludedTags(tokens.excluded, pageTagSet) &&
|
|
28
|
+
hasOptionalTagIfNeeded(tokens.optional, pageTagSet)
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function hasRequiredTags(required: string[], pageTags: ReadonlySet<string>): boolean {
|
|
33
|
+
return required.every((tag) => pageTags.has(tag));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hasNoExcludedTags(excluded: string[], pageTags: ReadonlySet<string>): boolean {
|
|
37
|
+
return excluded.every((tag) => !pageTags.has(tag));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function hasOptionalTagIfNeeded(optional: string[], pageTags: ReadonlySet<string>): boolean {
|
|
41
|
+
return optional.length === 0 || optional.some((tag) => pageTags.has(tag));
|
|
42
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* Renderer for `[[iftags]]...[[/iftags]]` conditional blocks.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { IfTagsData } from "@wdprlib/ast";
|
|
9
|
+
import type { RenderContext } from "../../context";
|
|
10
|
+
import { renderElements } from "../../render";
|
|
11
|
+
import { evaluateIfTagsCondition } from "./condition";
|
|
12
|
+
import { withIfTagsStyleSlot } from "./style-slot";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Render an `[[iftags]]` block.
|
|
16
|
+
*
|
|
17
|
+
* Evaluates the condition against the page's tags (from `ctx.page.tags`).
|
|
18
|
+
* If no page tags are available, an empty array is used (all conditions
|
|
19
|
+
* requiring present tags will fail).
|
|
20
|
+
*
|
|
21
|
+
* @param ctx - The current render context.
|
|
22
|
+
* @param data - IfTags data with condition string and child elements.
|
|
23
|
+
*/
|
|
24
|
+
export function renderIfTags(ctx: RenderContext, data: IfTagsData): void {
|
|
25
|
+
const pageTags = ctx.page?.tags ?? [];
|
|
26
|
+
|
|
27
|
+
if (!evaluateIfTagsCondition(data.condition, pageTags)) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const prev = ctx.renderInlineStyles;
|
|
32
|
+
ctx.renderInlineStyles = true;
|
|
33
|
+
|
|
34
|
+
withIfTagsStyleSlot(ctx, data, () => {
|
|
35
|
+
renderElements(ctx, data.elements);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
ctx.renderInlineStyles = prev;
|
|
39
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { IfTagsData } from "@wdprlib/ast";
|
|
2
|
+
import type { RenderContext } from "../../context";
|
|
3
|
+
|
|
4
|
+
interface StyleSlotIfTagsData extends IfTagsData {
|
|
5
|
+
_styleSlot?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function withIfTagsStyleSlot(
|
|
9
|
+
ctx: RenderContext,
|
|
10
|
+
data: IfTagsData,
|
|
11
|
+
render: () => void,
|
|
12
|
+
): void {
|
|
13
|
+
const slotId = (data as StyleSlotIfTagsData)._styleSlot;
|
|
14
|
+
if (slotId !== undefined) {
|
|
15
|
+
ctx.enterStyleSlot(slotId);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
render();
|
|
19
|
+
|
|
20
|
+
if (slotId !== undefined) {
|
|
21
|
+
ctx.exitStyleSlot();
|
|
22
|
+
}
|
|
23
|
+
}
|