@wdprlib/render 2.1.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 +2344 -1668
- package/dist/index.d.cts +15 -13
- package/dist/index.d.ts +15 -13
- package/dist/index.js +2375 -1699
- package/package.json +1 -1
- 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/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/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.ts → date/index.ts} +4 -29
- 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.ts → include/index.ts} +5 -13
- package/src/elements/include/missing.ts +15 -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 +2 -1
- package/src/elements/module/categories.ts +2 -2
- package/src/elements/module/empty-container.ts +10 -0
- package/src/elements/module/index.ts +2 -4
- package/src/elements/module/join-markup.ts +10 -0
- package/src/elements/module/join.ts +2 -7
- package/src/elements/module/listpages.ts +2 -2
- package/src/elements/module/listusers.ts +2 -2
- package/src/elements/module/page-tree.ts +2 -2
- package/src/elements/module/rate-markup.ts +10 -0
- package/src/elements/module/rate.ts +4 -13
- 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/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/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 +6 -2
- package/src/context.ts +0 -422
- package/src/elements/bibliography.ts +0 -123
- package/src/elements/code.ts +0 -49
- package/src/elements/collapsible.ts +0 -105
- package/src/elements/container.ts +0 -302
- package/src/elements/embed-block.ts +0 -327
- package/src/elements/embed.ts +0 -166
- package/src/elements/expr.ts +0 -102
- package/src/elements/footnote.ts +0 -76
- package/src/elements/html.ts +0 -79
- package/src/elements/iframe.ts +0 -44
- package/src/elements/iftags.ts +0 -118
- package/src/elements/image.ts +0 -154
- package/src/elements/link.ts +0 -201
- package/src/elements/list.ts +0 -241
- package/src/elements/math.ts +0 -177
- package/src/elements/tab-view.ts +0 -75
- package/src/elements/table.ts +0 -101
- package/src/elements/text.ts +0 -57
- package/src/elements/toc.ts +0 -147
- package/src/elements/user.ts +0 -79
- package/src/escape.ts +0 -829
- package/src/libs/highlighter/engine.ts +0 -352
- package/src/render.ts +0 -231
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CodeBlockData } from "@wdprlib/ast";
|
|
2
|
+
import { escapeHtml } from "../../escape";
|
|
3
|
+
import { highlight } from "../../libs/highlighter";
|
|
4
|
+
|
|
5
|
+
export function renderCodeContents(data: CodeBlockData): string {
|
|
6
|
+
if (data.language) {
|
|
7
|
+
const highlighted = highlight(data.contents, data.language);
|
|
8
|
+
if (highlighted) {
|
|
9
|
+
return highlighted;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return renderPlainCode(data.contents);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function renderPlainCode(contents: string): string {
|
|
17
|
+
return `<pre><code>${escapeHtml(contents)}</code></pre>`;
|
|
18
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* Renderer for `[[code]]...[[/code]]` blocks in Wikidot markup.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CodeBlockData } from "@wdprlib/ast";
|
|
9
|
+
import type { RenderContext } from "../../context";
|
|
10
|
+
import { renderCodeContents } from "./contents";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Render a `[[code]]` block.
|
|
14
|
+
*
|
|
15
|
+
* The block is wrapped in `<div class="code">`. If the block is empty,
|
|
16
|
+
* the div is closed immediately with no inner content.
|
|
17
|
+
*
|
|
18
|
+
* @param ctx - The current render context.
|
|
19
|
+
* @param data - Code block data containing contents and optional language.
|
|
20
|
+
*/
|
|
21
|
+
export function renderCode(ctx: RenderContext, data: CodeBlockData): void {
|
|
22
|
+
ctx.push(`<div class="code">`);
|
|
23
|
+
|
|
24
|
+
if (data.contents !== "") {
|
|
25
|
+
ctx.push(renderCodeContents(data));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
ctx.push("</div>");
|
|
29
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* Renderer for `[[collapsible]]...[[/collapsible]]` blocks.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CollapsibleData } from "@wdprlib/ast";
|
|
9
|
+
import type { RenderContext } from "../../context";
|
|
10
|
+
import { getCollapsibleLabels } from "./labels";
|
|
11
|
+
import { renderFoldedSection, renderUnfoldedSection } from "./sections";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Render a `[[collapsible]]` block with Wikidot-compatible HTML structure.
|
|
15
|
+
*
|
|
16
|
+
* The output contains both folded and unfolded states. Spaces in
|
|
17
|
+
* show/hide labels are encoded as ` ` to match Wikidot's behavior.
|
|
18
|
+
*
|
|
19
|
+
* @param ctx - The current render context.
|
|
20
|
+
* @param data - Collapsible block data with show/hide text, start-open
|
|
21
|
+
* flag, and top/bottom link placement options.
|
|
22
|
+
*/
|
|
23
|
+
export function renderCollapsible(ctx: RenderContext, data: CollapsibleData): void {
|
|
24
|
+
const startOpen = data["start-open"];
|
|
25
|
+
const labels = getCollapsibleLabels(data);
|
|
26
|
+
|
|
27
|
+
ctx.push(`<div class="collapsible-block">`);
|
|
28
|
+
renderFoldedSection(ctx, startOpen, labels);
|
|
29
|
+
renderUnfoldedSection(ctx, data, labels);
|
|
30
|
+
ctx.push("</div>");
|
|
31
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { CollapsibleData } from "@wdprlib/ast";
|
|
2
|
+
import { escapeHtml } from "../../escape";
|
|
3
|
+
|
|
4
|
+
export interface CollapsibleLabels {
|
|
5
|
+
show: string;
|
|
6
|
+
hide: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getCollapsibleLabels(data: CollapsibleData): CollapsibleLabels {
|
|
10
|
+
return {
|
|
11
|
+
show: data["show-text"]
|
|
12
|
+
? formatLabelText(data["show-text"])
|
|
13
|
+
: formatCollapsibleText("+", "show block"),
|
|
14
|
+
hide: data["hide-text"]
|
|
15
|
+
? formatLabelText(data["hide-text"])
|
|
16
|
+
: formatCollapsibleText("\u2013", "hide block"),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Format a default collapsible link label by prepending a prefix symbol
|
|
22
|
+
* (e.g. "+" or en-dash) with ` ` encoding for spaces.
|
|
23
|
+
*/
|
|
24
|
+
function formatCollapsibleText(prefix: string, text: string): string {
|
|
25
|
+
const encoded = escapeHtml(text).replace(/ /g, " ");
|
|
26
|
+
return `${prefix} ${encoded}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Format a custom collapsible link label by escaping HTML and
|
|
31
|
+
* replacing spaces with ` ` (matching Wikidot behavior).
|
|
32
|
+
*/
|
|
33
|
+
function formatLabelText(text: string): string {
|
|
34
|
+
return escapeHtml(text).replace(/ /g, " ");
|
|
35
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { RenderContext } from "../../context";
|
|
2
|
+
|
|
3
|
+
export function renderCollapsibleLink(ctx: RenderContext, label: string): void {
|
|
4
|
+
ctx.push(`<a class="collapsible-block-link" href="javascript:;">${label}</a>`);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function renderHideLink(ctx: RenderContext, label: string): void {
|
|
8
|
+
ctx.push(`<div class="collapsible-block-unfolded-link">`);
|
|
9
|
+
renderCollapsibleLink(ctx, label);
|
|
10
|
+
ctx.push("</div>");
|
|
11
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { CollapsibleData } from "@wdprlib/ast";
|
|
2
|
+
import type { RenderContext } from "../../context";
|
|
3
|
+
import { renderElements } from "../../render";
|
|
4
|
+
import type { CollapsibleLabels } from "./labels";
|
|
5
|
+
import { renderCollapsibleLink, renderHideLink } from "./link";
|
|
6
|
+
|
|
7
|
+
export function renderFoldedSection(
|
|
8
|
+
ctx: RenderContext,
|
|
9
|
+
startOpen: boolean,
|
|
10
|
+
labels: CollapsibleLabels,
|
|
11
|
+
): void {
|
|
12
|
+
const foldedStyle = startOpen ? ` style="display:none"` : "";
|
|
13
|
+
ctx.push(`<div class="collapsible-block-folded"${foldedStyle}>`);
|
|
14
|
+
renderCollapsibleLink(ctx, labels.show);
|
|
15
|
+
ctx.push("</div>");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function renderUnfoldedSection(
|
|
19
|
+
ctx: RenderContext,
|
|
20
|
+
data: CollapsibleData,
|
|
21
|
+
labels: CollapsibleLabels,
|
|
22
|
+
): void {
|
|
23
|
+
const unfoldedStyle = data["start-open"] ? "" : ` style="display:none"`;
|
|
24
|
+
ctx.push(`<div class="collapsible-block-unfolded"${unfoldedStyle}>`);
|
|
25
|
+
|
|
26
|
+
if (data["show-top"] || (!data["show-top"] && !data["show-bottom"])) {
|
|
27
|
+
renderHideLink(ctx, labels.hide);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
ctx.push(`<div class="collapsible-block-content">`);
|
|
31
|
+
renderElements(ctx, data.elements);
|
|
32
|
+
ctx.push("</div>");
|
|
33
|
+
|
|
34
|
+
if (data["show-bottom"]) {
|
|
35
|
+
renderHideLink(ctx, labels.hide);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
ctx.push("</div>");
|
|
39
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { escapeAttr, sanitizeAttributes } from "../../escape";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sanitize and format an attribute map into an HTML attribute string.
|
|
5
|
+
*/
|
|
6
|
+
export function renderContainerAttrs(attributes: Record<string, string>): string {
|
|
7
|
+
if (!hasAttributes(attributes)) {
|
|
8
|
+
return "";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const safe = sanitizeAttributes(attributes);
|
|
12
|
+
let result = "";
|
|
13
|
+
for (const key in safe) {
|
|
14
|
+
if (key.startsWith("_")) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
const value = safe[key]!;
|
|
18
|
+
result += value !== "" ? ` ${key}="${escapeAttr(value)}"` : ` ${key}=""`;
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function hasAttributes(attributes: Record<string, string>): boolean {
|
|
24
|
+
for (const _ in attributes) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Element } from "@wdprlib/ast";
|
|
2
|
+
import type { RenderContext } from "../../context";
|
|
3
|
+
import { renderElements } from "../../render";
|
|
4
|
+
import { renderContainerAttrs } from "./attributes";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Render a heading element (`h1`..`h6`).
|
|
8
|
+
*/
|
|
9
|
+
export function renderHeader(
|
|
10
|
+
ctx: RenderContext,
|
|
11
|
+
level: number,
|
|
12
|
+
hasToc: boolean,
|
|
13
|
+
attributes: Record<string, string>,
|
|
14
|
+
elements: Element[],
|
|
15
|
+
): void {
|
|
16
|
+
const tag = `h${level}`;
|
|
17
|
+
if (hasToc) {
|
|
18
|
+
const tocId = ctx.generateId("toc", ctx.nextTocIndex());
|
|
19
|
+
ctx.push(`<${tag} id="${tocId}"${renderContainerAttrs(attributes)}>`);
|
|
20
|
+
} else {
|
|
21
|
+
ctx.push(`<${tag}${renderContainerAttrs(attributes)}>`);
|
|
22
|
+
}
|
|
23
|
+
ctx.push("<span>");
|
|
24
|
+
renderElements(ctx, elements);
|
|
25
|
+
ctx.push("</span>");
|
|
26
|
+
ctx.push(`</${tag}>`);
|
|
27
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderer for general container AST elements.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ContainerData } from "@wdprlib/ast";
|
|
8
|
+
import { isAlignType, isHeaderType, isStringContainerType } from "@wdprlib/ast";
|
|
9
|
+
import type { RenderContext } from "../../context";
|
|
10
|
+
import { renderElements } from "../../render";
|
|
11
|
+
import { renderHeader } from "./header";
|
|
12
|
+
import { renderStringContainer } from "./string-container";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Render a container element by dispatching on its `type` discriminant.
|
|
16
|
+
*/
|
|
17
|
+
export function renderContainer(ctx: RenderContext, data: ContainerData): void {
|
|
18
|
+
const { type, attributes, elements } = data;
|
|
19
|
+
|
|
20
|
+
if (isHeaderType(type)) {
|
|
21
|
+
renderHeader(ctx, type.header.level, type.header["has-toc"], attributes, elements);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (isAlignType(type)) {
|
|
26
|
+
ctx.push(`<div style="text-align: ${type.align};">`);
|
|
27
|
+
renderElements(ctx, elements);
|
|
28
|
+
ctx.push("</div>");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (isStringContainerType(type)) {
|
|
33
|
+
renderStringContainer(ctx, type, attributes, elements);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Element } from "@wdprlib/ast";
|
|
2
|
+
import type { RenderContext } from "../../context";
|
|
3
|
+
import { renderElements } from "../../render";
|
|
4
|
+
import { hasAttributes } from "./attributes";
|
|
5
|
+
import { getStringContainerRendering, isContentsOnlyStringContainer } from "./string-types";
|
|
6
|
+
import { renderPlainWrapped, renderStyledSpan, renderWrapped } from "./wrappers";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Render a container whose type is a plain string identifier.
|
|
10
|
+
*/
|
|
11
|
+
export function renderStringContainer(
|
|
12
|
+
ctx: RenderContext,
|
|
13
|
+
type: string,
|
|
14
|
+
attributes: Record<string, string>,
|
|
15
|
+
elements: Element[],
|
|
16
|
+
): void {
|
|
17
|
+
if (type === "div" && elements.length === 0 && !hasAttributes(attributes)) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (isContentsOnlyStringContainer(type)) {
|
|
22
|
+
renderElements(ctx, elements);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const rendering = getStringContainerRendering(type);
|
|
27
|
+
switch (rendering.kind) {
|
|
28
|
+
case "wrapped":
|
|
29
|
+
renderWrapped(ctx, rendering.tag, attributes, elements);
|
|
30
|
+
return;
|
|
31
|
+
case "styled-span":
|
|
32
|
+
renderStyledSpan(ctx, rendering.style, attributes, elements);
|
|
33
|
+
return;
|
|
34
|
+
case "plain-wrapped":
|
|
35
|
+
renderPlainWrapped(ctx, rendering.tag, elements);
|
|
36
|
+
return;
|
|
37
|
+
case "contents":
|
|
38
|
+
renderElements(ctx, elements);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
type StringContainerRendering =
|
|
2
|
+
| { kind: "wrapped"; tag: string }
|
|
3
|
+
| { kind: "styled-span"; style: string }
|
|
4
|
+
| { kind: "plain-wrapped"; tag: string }
|
|
5
|
+
| { kind: "contents" };
|
|
6
|
+
|
|
7
|
+
const WRAPPED_TAGS: Readonly<Record<string, string>> = {
|
|
8
|
+
paragraph: "p",
|
|
9
|
+
bold: "strong",
|
|
10
|
+
italics: "em",
|
|
11
|
+
superscript: "sup",
|
|
12
|
+
subscript: "sub",
|
|
13
|
+
monospace: "tt",
|
|
14
|
+
span: "span",
|
|
15
|
+
div: "div",
|
|
16
|
+
blockquote: "blockquote",
|
|
17
|
+
mark: "mark",
|
|
18
|
+
insertion: "ins",
|
|
19
|
+
deletion: "del",
|
|
20
|
+
size: "span",
|
|
21
|
+
ruby: "ruby",
|
|
22
|
+
"ruby-text": "rt",
|
|
23
|
+
"table-row": "tr",
|
|
24
|
+
"table-cell": "td",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const STYLED_SPANS: Readonly<Record<string, string>> = {
|
|
28
|
+
underline: "text-decoration: underline;",
|
|
29
|
+
strikethrough: "text-decoration: line-through;",
|
|
30
|
+
hidden: "display: none",
|
|
31
|
+
invisible: "visibility: hidden",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const PLAIN_WRAPPED_TAGS: Readonly<Record<string, string>> = {
|
|
35
|
+
"definition-list": "dl",
|
|
36
|
+
"definition-list-key": "dt",
|
|
37
|
+
"definition-list-value": "dd",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const CONTENTS_ONLY_TYPES = new Set(["heading", "collapsible", "definition-list-item"]);
|
|
41
|
+
|
|
42
|
+
export function getStringContainerRendering(type: string): StringContainerRendering {
|
|
43
|
+
const wrappedTag = WRAPPED_TAGS[type];
|
|
44
|
+
if (wrappedTag) {
|
|
45
|
+
return { kind: "wrapped", tag: wrappedTag };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const style = STYLED_SPANS[type];
|
|
49
|
+
if (style) {
|
|
50
|
+
return { kind: "styled-span", style };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const plainTag = PLAIN_WRAPPED_TAGS[type];
|
|
54
|
+
if (plainTag) {
|
|
55
|
+
return { kind: "plain-wrapped", tag: plainTag };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { kind: "contents" };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function isContentsOnlyStringContainer(type: string): boolean {
|
|
62
|
+
return CONTENTS_ONLY_TYPES.has(type);
|
|
63
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Element } from "@wdprlib/ast";
|
|
2
|
+
import type { RenderContext } from "../../context";
|
|
3
|
+
import { renderElements } from "../../render";
|
|
4
|
+
import { renderContainerAttrs } from "./attributes";
|
|
5
|
+
|
|
6
|
+
export function renderWrapped(
|
|
7
|
+
ctx: RenderContext,
|
|
8
|
+
tag: string,
|
|
9
|
+
attributes: Record<string, string>,
|
|
10
|
+
elements: Element[],
|
|
11
|
+
): void {
|
|
12
|
+
ctx.push(`<${tag}${renderContainerAttrs(attributes)}>`);
|
|
13
|
+
renderElements(ctx, elements);
|
|
14
|
+
ctx.push(`</${tag}>`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function renderPlainWrapped(ctx: RenderContext, tag: string, elements: Element[]): void {
|
|
18
|
+
ctx.push(`<${tag}>`);
|
|
19
|
+
renderElements(ctx, elements);
|
|
20
|
+
ctx.push(`</${tag}>`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function renderStyledSpan(
|
|
24
|
+
ctx: RenderContext,
|
|
25
|
+
style: string,
|
|
26
|
+
attributes: Record<string, string>,
|
|
27
|
+
elements: Element[],
|
|
28
|
+
): void {
|
|
29
|
+
ctx.push(`<span style="${style}"${renderContainerAttrs(attributes)}>`);
|
|
30
|
+
renderElements(ctx, elements);
|
|
31
|
+
ctx.push("</span>");
|
|
32
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format a `Date` using a subset of `strftime` specifiers.
|
|
3
|
+
*
|
|
4
|
+
* Supported specifiers: `%Y` (4-digit year), `%m` (zero-padded month),
|
|
5
|
+
* `%d` (zero-padded day), `%H` (zero-padded hours), `%M` (zero-padded
|
|
6
|
+
* minutes), `%S` (zero-padded seconds).
|
|
7
|
+
*
|
|
8
|
+
* @param date - The date to format.
|
|
9
|
+
* @param format - A strftime-compatible format string.
|
|
10
|
+
* @returns The formatted date string.
|
|
11
|
+
*/
|
|
12
|
+
export function formatDate(date: Date, format: string): string {
|
|
13
|
+
return format
|
|
14
|
+
.replace(/%Y/g, String(date.getFullYear()))
|
|
15
|
+
.replace(/%m/g, String(date.getMonth() + 1).padStart(2, "0"))
|
|
16
|
+
.replace(/%d/g, String(date.getDate()).padStart(2, "0"))
|
|
17
|
+
.replace(/%H/g, String(date.getHours()).padStart(2, "0"))
|
|
18
|
+
.replace(/%M/g, String(date.getMinutes()).padStart(2, "0"))
|
|
19
|
+
.replace(/%S/g, String(date.getSeconds()).padStart(2, "0"));
|
|
20
|
+
}
|
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { DateData } from "@wdprlib/ast";
|
|
15
|
-
import type { RenderContext } from "
|
|
16
|
-
import {
|
|
15
|
+
import type { RenderContext } from "../../context";
|
|
16
|
+
import { formatDate } from "./format";
|
|
17
|
+
import { renderDateOutput } from "./output";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Render a date element, optionally wrapping in an `odate` span for
|
|
@@ -29,31 +30,5 @@ import { escapeHtml } from "../escape";
|
|
|
29
30
|
export function renderDate(ctx: RenderContext, data: DateData): void {
|
|
30
31
|
const date = new Date(data.value.timestamp * 1000);
|
|
31
32
|
const formatted = data.format ? formatDate(date, data.format) : date.toLocaleString();
|
|
32
|
-
|
|
33
|
-
if (data.hover) {
|
|
34
|
-
ctx.push(`<span class="odate">${escapeHtml(formatted)}</span>`);
|
|
35
|
-
} else {
|
|
36
|
-
ctx.push(escapeHtml(formatted));
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Format a `Date` using a subset of `strftime` specifiers.
|
|
42
|
-
*
|
|
43
|
-
* Supported specifiers: `%Y` (4-digit year), `%m` (zero-padded month),
|
|
44
|
-
* `%d` (zero-padded day), `%H` (zero-padded hours), `%M` (zero-padded
|
|
45
|
-
* minutes), `%S` (zero-padded seconds).
|
|
46
|
-
*
|
|
47
|
-
* @param date - The date to format.
|
|
48
|
-
* @param format - A strftime-compatible format string.
|
|
49
|
-
* @returns The formatted date string.
|
|
50
|
-
*/
|
|
51
|
-
function formatDate(date: Date, format: string): string {
|
|
52
|
-
return format
|
|
53
|
-
.replace(/%Y/g, String(date.getFullYear()))
|
|
54
|
-
.replace(/%m/g, String(date.getMonth() + 1).padStart(2, "0"))
|
|
55
|
-
.replace(/%d/g, String(date.getDate()).padStart(2, "0"))
|
|
56
|
-
.replace(/%H/g, String(date.getHours()).padStart(2, "0"))
|
|
57
|
-
.replace(/%M/g, String(date.getMinutes()).padStart(2, "0"))
|
|
58
|
-
.replace(/%S/g, String(date.getSeconds()).padStart(2, "0"));
|
|
33
|
+
ctx.push(renderDateOutput(formatted, data.hover));
|
|
59
34
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { RenderContext } from "../../context";
|
|
2
|
+
import { escapeAttr } from "../../escape";
|
|
3
|
+
|
|
4
|
+
export function renderEmbedIframe(ctx: RenderContext, className: string, src: string): void {
|
|
5
|
+
ctx.push(`<div class="${className}">`);
|
|
6
|
+
ctx.push(`<iframe src="${escapeAttr(src)}" frameborder="0" allowfullscreen></iframe>`);
|
|
7
|
+
ctx.push("</div>");
|
|
8
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* Renderer for inline embed elements (`[[embed]]`) that reference
|
|
4
|
+
* third-party content providers.
|
|
5
|
+
*
|
|
6
|
+
* @module
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Embed } from "@wdprlib/ast";
|
|
10
|
+
import type { RenderContext } from "../../context";
|
|
11
|
+
import { renderGithubGist, renderGitlabSnippet, renderVimeo, renderYoutube } from "./providers";
|
|
12
|
+
|
|
13
|
+
export function renderEmbed(ctx: RenderContext, data: Embed): void {
|
|
14
|
+
switch (data.embed) {
|
|
15
|
+
case "youtube":
|
|
16
|
+
renderYoutube(ctx, data.data["video-id"]);
|
|
17
|
+
break;
|
|
18
|
+
case "vimeo":
|
|
19
|
+
renderVimeo(ctx, data.data["video-id"]);
|
|
20
|
+
break;
|
|
21
|
+
case "github-gist":
|
|
22
|
+
renderGithubGist(ctx, data.data.username, data.data.hash);
|
|
23
|
+
break;
|
|
24
|
+
case "gitlab-snippet":
|
|
25
|
+
renderGitlabSnippet(ctx, data.data["snippet-id"]);
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { RenderContext } from "../../context";
|
|
2
|
+
import { escapeAttr } from "../../escape";
|
|
3
|
+
import { renderEmbedIframe } from "./iframe";
|
|
4
|
+
import {
|
|
5
|
+
isValidGithubUsername,
|
|
6
|
+
isValidGistHash,
|
|
7
|
+
isValidGitlabSnippetId,
|
|
8
|
+
isValidVideoId,
|
|
9
|
+
} from "./validation";
|
|
10
|
+
|
|
11
|
+
export function renderYoutube(ctx: RenderContext, videoId: string): void {
|
|
12
|
+
if (!isValidVideoId(videoId)) {
|
|
13
|
+
ctx.push(`<!-- Invalid YouTube video ID -->`);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
renderEmbedIframe(ctx, "embed-youtube", `https://www.youtube.com/embed/${videoId}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function renderVimeo(ctx: RenderContext, videoId: string): void {
|
|
20
|
+
if (!isValidVideoId(videoId)) {
|
|
21
|
+
ctx.push(`<!-- Invalid Vimeo video ID -->`);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
renderEmbedIframe(ctx, "embed-vimeo", `https://player.vimeo.com/video/${videoId}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function renderGithubGist(ctx: RenderContext, username: string, hash: string): void {
|
|
28
|
+
if (!isValidGithubUsername(username) || !isValidGistHash(hash)) {
|
|
29
|
+
ctx.push(`<!-- Invalid GitHub Gist parameters -->`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
ctx.push(
|
|
33
|
+
`<script src="https://gist.github.com/${escapeAttr(username)}/${escapeAttr(hash)}.js"></script>`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function renderGitlabSnippet(ctx: RenderContext, snippetId: string): void {
|
|
38
|
+
if (!isValidGitlabSnippetId(snippetId)) {
|
|
39
|
+
ctx.push(`<!-- Invalid GitLab snippet ID -->`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
ctx.push(`<script src="https://gitlab.com/snippets/${escapeAttr(snippetId)}.js"></script>`);
|
|
43
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function isValidVideoId(id: string): boolean {
|
|
2
|
+
return /^[a-zA-Z0-9_-]+$/.test(id);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function isValidGithubUsername(username: string): boolean {
|
|
6
|
+
return /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(username);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isValidGistHash(hash: string): boolean {
|
|
10
|
+
return /^[a-f0-9]+$/.test(hash);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isValidGitlabSnippetId(id: string): boolean {
|
|
14
|
+
return /^[0-9]+$/.test(id);
|
|
15
|
+
}
|
|
@@ -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
|
+
}
|