@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.
Files changed (173) hide show
  1. package/dist/index.cjs +2344 -1668
  2. package/dist/index.d.cts +15 -13
  3. package/dist/index.d.ts +15 -13
  4. package/dist/index.js +2375 -1699
  5. package/package.json +1 -1
  6. package/src/context/attributes.ts +14 -0
  7. package/src/context/bibliography.ts +109 -0
  8. package/src/context/counters.ts +51 -0
  9. package/src/context/image-urls.ts +31 -0
  10. package/src/context/index.ts +285 -0
  11. package/src/context/output.ts +17 -0
  12. package/src/context/page-urls.ts +81 -0
  13. package/src/context/style-slots.ts +29 -0
  14. package/src/context/urls.ts +2 -0
  15. package/src/elements/bibliography/block.ts +27 -0
  16. package/src/elements/bibliography/cite.ts +23 -0
  17. package/src/elements/bibliography/ids.ts +9 -0
  18. package/src/elements/bibliography/index.ts +9 -0
  19. package/src/elements/code/contents.ts +18 -0
  20. package/src/elements/code/index.ts +29 -0
  21. package/src/elements/collapsible/index.ts +31 -0
  22. package/src/elements/collapsible/labels.ts +35 -0
  23. package/src/elements/collapsible/link.ts +11 -0
  24. package/src/elements/collapsible/sections.ts +39 -0
  25. package/src/elements/container/attributes.ts +28 -0
  26. package/src/elements/container/header.ts +27 -0
  27. package/src/elements/container/index.ts +35 -0
  28. package/src/elements/container/string-container.ts +40 -0
  29. package/src/elements/container/string-types.ts +63 -0
  30. package/src/elements/container/wrappers.ts +32 -0
  31. package/src/elements/date/format.ts +20 -0
  32. package/src/elements/{date.ts → date/index.ts} +4 -29
  33. package/src/elements/date/output.ts +6 -0
  34. package/src/elements/embed/iframe.ts +8 -0
  35. package/src/elements/embed/index.ts +28 -0
  36. package/src/elements/embed/providers.ts +43 -0
  37. package/src/elements/embed/validation.ts +15 -0
  38. package/src/elements/embed-block/allowlist.ts +60 -0
  39. package/src/elements/embed-block/boolean-attributes.ts +38 -0
  40. package/src/elements/embed-block/iframe.ts +33 -0
  41. package/src/elements/embed-block/index.ts +31 -0
  42. package/src/elements/embed-block/sanitize-config.ts +22 -0
  43. package/src/elements/embed-block/sanitize.ts +44 -0
  44. package/src/elements/expr/branch.ts +29 -0
  45. package/src/elements/expr/index.ts +63 -0
  46. package/src/elements/expr/result.ts +19 -0
  47. package/src/elements/footnote/body.ts +11 -0
  48. package/src/elements/footnote/index.ts +35 -0
  49. package/src/elements/footnote/ref.ts +16 -0
  50. package/src/elements/html/attributes.ts +24 -0
  51. package/src/elements/html/index.ts +39 -0
  52. package/src/elements/html/url.ts +19 -0
  53. package/src/elements/iframe/attributes.ts +28 -0
  54. package/src/elements/iframe/index.ts +22 -0
  55. package/src/elements/iftags/condition.ts +42 -0
  56. package/src/elements/iftags/index.ts +39 -0
  57. package/src/elements/iftags/style-slot.ts +23 -0
  58. package/src/elements/iftags/tokens.ts +36 -0
  59. package/src/elements/image/alignment.ts +44 -0
  60. package/src/elements/image/attributes.ts +10 -0
  61. package/src/elements/image/img-attributes.ts +26 -0
  62. package/src/elements/image/index.ts +36 -0
  63. package/src/elements/image/link-href.ts +24 -0
  64. package/src/elements/image/link.ts +13 -0
  65. package/src/elements/image/source.ts +16 -0
  66. package/src/elements/{include.ts → include/index.ts} +5 -13
  67. package/src/elements/include/missing.ts +15 -0
  68. package/src/elements/link/anchor-name.ts +6 -0
  69. package/src/elements/link/anchor.ts +27 -0
  70. package/src/elements/link/attributes.ts +47 -0
  71. package/src/elements/link/index.ts +26 -0
  72. package/src/elements/link/label.ts +23 -0
  73. package/src/elements/link/target.ts +20 -0
  74. package/src/elements/list/attributes.ts +19 -0
  75. package/src/elements/list/definition-list.ts +16 -0
  76. package/src/elements/list/index.ts +48 -0
  77. package/src/elements/list/item-rendering.ts +38 -0
  78. package/src/elements/list/items.ts +61 -0
  79. package/src/elements/list/no-marker.ts +53 -0
  80. package/src/elements/list/paragraphs.ts +34 -0
  81. package/src/elements/list/trim.ts +38 -0
  82. package/src/elements/math/block.ts +29 -0
  83. package/src/elements/math/equation-ref.ts +12 -0
  84. package/src/elements/math/index.ts +14 -0
  85. package/src/elements/math/inline.ts +19 -0
  86. package/src/elements/math/latex.ts +27 -0
  87. package/src/elements/math/source.ts +18 -0
  88. package/src/elements/module/backlinks.ts +2 -1
  89. package/src/elements/module/categories.ts +2 -2
  90. package/src/elements/module/empty-container.ts +10 -0
  91. package/src/elements/module/index.ts +2 -4
  92. package/src/elements/module/join-markup.ts +10 -0
  93. package/src/elements/module/join.ts +2 -7
  94. package/src/elements/module/listpages.ts +2 -2
  95. package/src/elements/module/listusers.ts +2 -2
  96. package/src/elements/module/page-tree.ts +2 -2
  97. package/src/elements/module/rate-markup.ts +10 -0
  98. package/src/elements/module/rate.ts +4 -13
  99. package/src/elements/module/unknown.ts +11 -0
  100. package/src/elements/tab-view/ids.ts +16 -0
  101. package/src/elements/tab-view/index.ts +31 -0
  102. package/src/elements/tab-view/navigation.ts +15 -0
  103. package/src/elements/tab-view/panels.ts +16 -0
  104. package/src/elements/table/attributes.ts +23 -0
  105. package/src/elements/table/cell-attributes.ts +62 -0
  106. package/src/elements/table/cell.ts +13 -0
  107. package/src/elements/table/index.ts +27 -0
  108. package/src/elements/text/email.ts +20 -0
  109. package/src/elements/text/index.ts +11 -0
  110. package/src/elements/text/plain.ts +11 -0
  111. package/src/elements/text/raw.ts +20 -0
  112. package/src/elements/toc/body.ts +12 -0
  113. package/src/elements/toc/entries.ts +34 -0
  114. package/src/elements/toc/frame.ts +27 -0
  115. package/src/elements/toc/index.ts +17 -0
  116. package/src/elements/toc/link.ts +26 -0
  117. package/src/elements/user/index.ts +40 -0
  118. package/src/elements/user/markup.ts +34 -0
  119. package/src/elements/user/resolve.ts +6 -0
  120. package/src/escape/attribute-allowlists.ts +101 -0
  121. package/src/escape/attributes.ts +62 -0
  122. package/src/escape/css-color-functions.ts +18 -0
  123. package/src/escape/css-colors.ts +183 -0
  124. package/src/escape/css-danger.ts +22 -0
  125. package/src/escape/css-normalize.ts +54 -0
  126. package/src/escape/css-style.ts +78 -0
  127. package/src/escape/css-urls.ts +76 -0
  128. package/src/escape/css.ts +4 -0
  129. package/src/escape/email.ts +22 -0
  130. package/src/escape/html.ts +68 -0
  131. package/src/escape/index.ts +15 -0
  132. package/src/escape/url.ts +18 -0
  133. package/src/libs/highlighter/engine/end-pattern.ts +26 -0
  134. package/src/libs/highlighter/engine/html.ts +19 -0
  135. package/src/libs/highlighter/engine/index.ts +3 -0
  136. package/src/libs/highlighter/engine/keywords.ts +22 -0
  137. package/src/libs/highlighter/engine/parts.ts +36 -0
  138. package/src/libs/highlighter/engine/preprocess.ts +10 -0
  139. package/src/libs/highlighter/engine/render.ts +31 -0
  140. package/src/libs/highlighter/engine/token.ts +7 -0
  141. package/src/libs/highlighter/engine/tokenizer.ts +266 -0
  142. package/src/libs/highlighter/engine/utils.ts +38 -0
  143. package/src/render/collected-styles.ts +22 -0
  144. package/src/render/dispatch.ts +181 -0
  145. package/src/render/index.ts +28 -0
  146. package/src/render/primitives.ts +17 -0
  147. package/src/render/style-tag.ts +6 -0
  148. package/src/render/style.ts +15 -0
  149. package/src/types.ts +6 -2
  150. package/src/context.ts +0 -422
  151. package/src/elements/bibliography.ts +0 -123
  152. package/src/elements/code.ts +0 -49
  153. package/src/elements/collapsible.ts +0 -105
  154. package/src/elements/container.ts +0 -302
  155. package/src/elements/embed-block.ts +0 -327
  156. package/src/elements/embed.ts +0 -166
  157. package/src/elements/expr.ts +0 -102
  158. package/src/elements/footnote.ts +0 -76
  159. package/src/elements/html.ts +0 -79
  160. package/src/elements/iframe.ts +0 -44
  161. package/src/elements/iftags.ts +0 -118
  162. package/src/elements/image.ts +0 -154
  163. package/src/elements/link.ts +0 -201
  164. package/src/elements/list.ts +0 -241
  165. package/src/elements/math.ts +0 -177
  166. package/src/elements/tab-view.ts +0 -75
  167. package/src/elements/table.ts +0 -101
  168. package/src/elements/text.ts +0 -57
  169. package/src/elements/toc.ts +0 -147
  170. package/src/elements/user.ts +0 -79
  171. package/src/escape.ts +0 -829
  172. package/src/libs/highlighter/engine.ts +0 -352
  173. package/src/render.ts +0 -231
@@ -1,154 +0,0 @@
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
- }
@@ -1,201 +0,0 @@
1
- /**
2
- *
3
- * Renderers for Wikidot link elements.
4
- *
5
- * Wikidot supports several link syntaxes:
6
- * - `[[[page-name]]]` -- page link with automatic label
7
- * - `[[[page-name | label]]]` -- page link with custom label
8
- * - `[# label]` -- anchor-type link (JavaScript void)
9
- * - `[http://url label]` -- external URL link
10
- * - `[[a]]...[[/a]]` -- HTML anchor element with attributes
11
- * - `[[#anchor-name]]` -- named anchor (bookmark target)
12
- *
13
- * All link types are checked for dangerous URL schemes. Page links
14
- * may receive a `class="newpage"` attribute when the target page does
15
- * not exist (the standard Wikidot "red link" convention). External
16
- * links opened in new tabs automatically receive `rel="noopener noreferrer"`
17
- * to prevent tabnabbing.
18
- *
19
- * @module
20
- */
21
-
22
- import type { LinkData, AnchorData } from "@wdprlib/ast";
23
- import type { RenderContext } from "../context";
24
- import { escapeAttr, isDangerousUrl, sanitizeAttributes } from "../escape";
25
- import { renderElements } from "../render";
26
-
27
- /**
28
- * Render a link element (`[[[page]]]`, `[url label]`, `[# label]`).
29
- *
30
- * The link's `href` is resolved via `ctx.resolvePageLink()`, with an
31
- * optional `extra` suffix (anchor fragment) appended. Anchor-type links
32
- * with `javascript:;` are allowed as a special case for Wikidot
33
- * compatibility; all other dangerous URL schemes are blocked.
34
- *
35
- * For page-type links, a `class="newpage"` is added when the
36
- * `pageExists` resolver indicates the target page does not exist.
37
- *
38
- * @param ctx - The current render context.
39
- * @param data - Link data with link target, label, type, target window, and extra suffix.
40
- */
41
- export function renderLink(ctx: RenderContext, data: LinkData): void {
42
- let href = ctx.resolvePageLink(data.link);
43
-
44
- // Append extra (anchor suffix)
45
- if (data.extra) {
46
- href += data.extra;
47
- }
48
-
49
- // Validate URL scheme
50
- // Exception: anchor-type links with "javascript:;" are valid Wikidot syntax ([# label])
51
- const isAnchorJsVoid = data.type === "anchor" && href === "javascript:;";
52
- if (!isAnchorJsVoid && isDangerousUrl(href)) {
53
- href = "#invalid-url";
54
- }
55
-
56
- // Build <a> tag
57
- const attrs: string[] = [`href="${escapeAttr(href)}"`];
58
-
59
- // Add "newpage" class for page links that don't exist
60
- // Only for page-type links (not direct URLs, anchors, etc.)
61
- if (data.type === "page" && typeof data.link === "object") {
62
- const page = data.link.page;
63
- // Skip newpage class for special pages:
64
- // - //path (protocol-relative or special routing)
65
- // - paths with #/ (hash routing like MAIN/#/page)
66
- // category-prefixed pages (`category:name`) are NOT skipped — pageExists is
67
- // expected to handle them (e.g. share:<ULID>, private:<ULID>, system:Recent).
68
- const isSpecialPage = page.startsWith("//") || page.includes("#/");
69
- if (!isSpecialPage) {
70
- // For anchor links (page#anchor), check if the page part exists
71
- const hashIdx = page.indexOf("#");
72
- const pageToCheck = hashIdx !== -1 ? page.slice(0, hashIdx) : page;
73
- const pageExists = ctx.page?.pageExists;
74
- // If pageExists is not provided, assume page doesn't exist (show newpage class)
75
- const exists = pageExists ? pageExists(pageToCheck) : false;
76
- if (!exists) {
77
- attrs.push(`class="newpage"`);
78
- }
79
- }
80
- }
81
-
82
- // Target attribute
83
- if (data.target) {
84
- const targetMap: Record<string, string> = {
85
- "new-tab": "_blank",
86
- parent: "_parent",
87
- top: "_top",
88
- same: "_self",
89
- };
90
- const targetValue = targetMap[data.target] ?? "_blank";
91
- attrs.push(`target="${targetValue}"`);
92
- // Prevent tabnabbing for _blank targets
93
- if (targetValue === "_blank") {
94
- attrs.push(`rel="noopener noreferrer"`);
95
- }
96
- }
97
-
98
- ctx.push(`<a ${attrs.join(" ")}>`);
99
-
100
- // Render label
101
- renderLinkLabel(ctx, data);
102
-
103
- ctx.push("</a>");
104
- }
105
-
106
- /**
107
- * Render the label content of a link element.
108
- *
109
- * Label types:
110
- * - `"page"` -- use the page name as the label text
111
- * - `{ text: string }` -- use a custom text label
112
- * - `{ url: string }` -- use the URL itself as the label
113
- *
114
- * @param ctx - The current render context.
115
- * @param data - Link data containing the label descriptor.
116
- */
117
- function renderLinkLabel(ctx: RenderContext, data: LinkData): void {
118
- if (data.label === "page") {
119
- // Use page name as label
120
- if (typeof data.link === "string") {
121
- ctx.pushEscaped(data.link);
122
- } else {
123
- ctx.pushEscaped(data.link.page);
124
- }
125
- return;
126
- }
127
-
128
- if ("text" in data.label) {
129
- ctx.pushEscaped(data.label.text);
130
- return;
131
- }
132
-
133
- if ("url" in data.label) {
134
- // Use the URL itself as label
135
- const href = ctx.resolvePageLink(data.link);
136
- ctx.pushEscaped(data.label.url ?? href);
137
- }
138
- }
139
-
140
- /**
141
- * Render a `[[a]]...[[/a]]` anchor element with full attribute support.
142
- *
143
- * Unlike `renderLink`, this handles the block-level anchor syntax where
144
- * arbitrary attributes can be specified. Dangerous `href` values are
145
- * replaced with `#invalid-url`. The `target` attribute is mapped from
146
- * Wikidot's abstract values to HTML values, with tabnabbing protection.
147
- *
148
- * @param ctx - The current render context.
149
- * @param data - Anchor data with attributes, target, and child elements.
150
- */
151
- export function renderAnchor(ctx: RenderContext, data: AnchorData): void {
152
- const safe = sanitizeAttributes(data.attributes);
153
- const attrs: string[] = [];
154
-
155
- // Validate href for dangerous URLs
156
- if (safe.href && isDangerousUrl(safe.href)) {
157
- safe.href = "#invalid-url";
158
- }
159
-
160
- // Always include href attribute
161
- const href = safe.href ?? "";
162
- attrs.push(`href="${escapeAttr(href)}"`);
163
-
164
- // Handle target attribute from AST data
165
- if (data.target) {
166
- const targetMap: Record<string, string> = {
167
- "new-tab": "_blank",
168
- parent: "_parent",
169
- top: "_top",
170
- same: "_self",
171
- };
172
- const targetValue = targetMap[data.target] ?? "_blank";
173
- attrs.push(`target="${targetValue}"`);
174
- // Prevent tabnabbing for _blank targets
175
- if (targetValue === "_blank") {
176
- attrs.push(`rel="noopener noreferrer"`);
177
- }
178
- }
179
-
180
- for (const [key, value] of Object.entries(safe)) {
181
- if (key === "href" || key === "target") continue; // already handled above
182
- attrs.push(`${key}="${escapeAttr(value)}"`);
183
- }
184
-
185
- ctx.push(`<a ${attrs.join(" ")}>`);
186
- renderElements(ctx, data.elements);
187
- ctx.push("</a>");
188
- }
189
-
190
- /**
191
- * Render a named anchor (bookmark target) element: `[[#anchor-name]]`.
192
- *
193
- * Produces `<a name="anchor-name"></a>` which serves as a link target
194
- * for `#anchor-name` URL fragments.
195
- *
196
- * @param ctx - The current render context.
197
- * @param name - The anchor name (fragment identifier).
198
- */
199
- export function renderAnchorName(ctx: RenderContext, name: string): void {
200
- ctx.push(`<a name="${escapeAttr(name)}"></a>`);
201
- }
@@ -1,241 +0,0 @@
1
- /**
2
- *
3
- * Renderers for Wikidot ordered/unordered lists and definition lists.
4
- *
5
- * Wikidot list syntax uses `*` (unordered) and `#` (ordered) prefixes
6
- * with indentation controlling nesting depth. The parser produces a
7
- * recursive `ListData` structure with items that can be either
8
- * "elements" (content) or "sub-list" (nested list).
9
- *
10
- * Special behaviors replicated from Wikidot:
11
- * - Empty lists are silently dropped (no output at all).
12
- * - Items with `_noMarker` have `list-style: none` and the first
13
- * paragraph is unwrapped (no `<p>` tags).
14
- * - Sub-lists without a preceding content item get an inline hidden `<li>`.
15
- * - Leading/trailing whitespace-only text nodes are trimmed from items.
16
- *
17
- * @module
18
- */
19
-
20
- import type { ListData, DefinitionListItem, Element, ContainerData } from "@wdprlib/ast";
21
- import type { RenderContext } from "../context";
22
- import { escapeAttr, sanitizeAttributes } from "../escape";
23
- import { renderElements, renderElement } from "../render";
24
-
25
- /**
26
- * Trim leading and trailing whitespace-only text elements from an array.
27
- *
28
- * @param elements - Array of AST elements.
29
- * @returns A slice of the array with whitespace-only text nodes removed
30
- * from both ends.
31
- */
32
- function trimTextElements(elements: Element[]): Element[] {
33
- if (elements.length === 0) return elements;
34
-
35
- let start = 0;
36
- let end = elements.length;
37
-
38
- // Trim leading whitespace-only text elements
39
- while (start < end) {
40
- const el = elements[start]!;
41
- if (el.element === "text" && typeof el.data === "string" && el.data.trim() === "") {
42
- start++;
43
- } else {
44
- break;
45
- }
46
- }
47
-
48
- // Trim trailing whitespace-only text elements
49
- while (end > start) {
50
- const el = elements[end - 1]!;
51
- if (el.element === "text" && typeof el.data === "string" && el.data.trim() === "") {
52
- end--;
53
- } else {
54
- break;
55
- }
56
- }
57
-
58
- return elements.slice(start, end);
59
- }
60
-
61
- /**
62
- * Check whether a paragraph element contains only the text `[[/li]]`.
63
- *
64
- * The parser sometimes wraps stray `[[/li]]` closing tags in a paragraph.
65
- * When found as the last paragraph in a `_noMarker` item, the paragraph
66
- * wrapper is removed to match Wikidot output.
67
- *
68
- * @param el - An AST element to check.
69
- * @returns `true` if the element is a paragraph containing only `[[/li]]`.
70
- */
71
- function isLiCloseTextParagraph(el: Element): boolean {
72
- if (el.element !== "container") return false;
73
- const data = el.data as ContainerData;
74
- if (data.type !== "paragraph") return false;
75
- // Check if content is just [[/li]] (possibly with whitespace)
76
- const texts = data.elements
77
- .filter((e): e is { element: "text"; data: string } => e.element === "text")
78
- .map((e) => e.data);
79
- const combined = texts.join("").trim();
80
- return combined === "[[/li]]";
81
- }
82
-
83
- /**
84
- * Render elements for `_noMarker` list items with special paragraph handling.
85
- *
86
- * Wikidot treats bare content (without `[[li]]`) differently:
87
- * - The first paragraph is unwrapped (children rendered without `<p>` tags).
88
- * - Middle paragraphs retain their `<p>` wrappers.
89
- * - The last paragraph is unwrapped if it contains only `[[/li]]` text.
90
- *
91
- * @param ctx - The current render context.
92
- * @param elements - The list item's child elements.
93
- */
94
- function renderNoMarkerElements(ctx: RenderContext, elements: Element[]): void {
95
- const trimmed = trimTextElements(elements);
96
- if (trimmed.length === 0) return;
97
-
98
- // Find paragraph indices
99
- const paragraphIndices: number[] = [];
100
- for (let i = 0; i < trimmed.length; i++) {
101
- const el = trimmed[i]!;
102
- if (el.element === "container" && (el.data as ContainerData).type === "paragraph") {
103
- paragraphIndices.push(i);
104
- }
105
- }
106
-
107
- // If no paragraphs, render normally
108
- if (paragraphIndices.length === 0) {
109
- renderElements(ctx, trimmed);
110
- return;
111
- }
112
-
113
- const firstParagraphIdx = paragraphIndices[0]!;
114
- const lastParagraphIdx = paragraphIndices[paragraphIndices.length - 1]!;
115
-
116
- for (let i = 0; i < trimmed.length; i++) {
117
- const el = trimmed[i]!;
118
- if (el.element === "container" && (el.data as ContainerData).type === "paragraph") {
119
- const data = el.data as ContainerData;
120
- // First paragraph: unwrap
121
- if (i === firstParagraphIdx) {
122
- renderElements(ctx, data.elements);
123
- }
124
- // Last paragraph if it's [[/li]]: unwrap
125
- else if (i === lastParagraphIdx && isLiCloseTextParagraph(el)) {
126
- renderElements(ctx, data.elements);
127
- }
128
- // Other paragraphs: keep <p> tags
129
- else {
130
- ctx.push("<p>");
131
- renderElements(ctx, data.elements);
132
- ctx.push("</p>");
133
- }
134
- } else {
135
- renderElement(ctx, el);
136
- }
137
- }
138
- }
139
-
140
- /**
141
- * Render an ordered or unordered list.
142
- *
143
- * Wikidot drops empty lists entirely (no HTML output). Sub-lists
144
- * following a content item are rendered inside the same `<li>`.
145
- * Sub-lists without a preceding content item get a hidden `<li>` wrapper.
146
- *
147
- * @param ctx - The current render context.
148
- * @param data - List data with type (numbered/bulleted), items, and attributes.
149
- */
150
- export function renderList(ctx: RenderContext, data: ListData): void {
151
- // Wikidot behavior: empty lists or lists with only empty items are ignored
152
- // and converted to <br />
153
- const hasContent = data.items.some((item) => {
154
- if (item["item-type"] === "sub-list") return true;
155
- if (item["item-type"] === "elements") {
156
- const trimmed = trimTextElements(item.elements);
157
- return trimmed.length > 0;
158
- }
159
- return false;
160
- });
161
-
162
- if (!hasContent) {
163
- // Empty list - Wikidot outputs nothing (just whitespace)
164
- return;
165
- }
166
-
167
- const tag = data.type === "numbered" ? "ol" : "ul";
168
- ctx.push(`<${tag}${renderListAttrs(data.attributes)}>`);
169
-
170
- const items = data.items;
171
- let i = 0;
172
- while (i < items.length) {
173
- const item = items[i]!;
174
- if (item["item-type"] === "elements") {
175
- // Check for _noMarker flag (bare content without [[li]])
176
- const hasNoMarker = item.attributes._noMarker === "true";
177
- const styleAttr = hasNoMarker ? ' style="list-style: none"' : "";
178
- ctx.push(`<li${renderListAttrs(item.attributes)}${styleAttr}>`);
179
- // Trim leading/trailing whitespace from li content (Wikidot behavior)
180
- if (hasNoMarker) {
181
- // Special handling for bare content paragraphs
182
- renderNoMarkerElements(ctx, item.elements);
183
- } else {
184
- renderElements(ctx, trimTextElements(item.elements));
185
- }
186
- // Consume following sub-lists inside this <li>
187
- while (i + 1 < items.length && items[i + 1]!["item-type"] === "sub-list") {
188
- i++;
189
- const subItem = items[i] as { "item-type": "sub-list"; data: ListData };
190
- renderList(ctx, subItem.data);
191
- }
192
- ctx.push("</li>");
193
- } else {
194
- // Sub-list without preceding elements item - hide bullet/number
195
- const subItem = item as { "item-type": "sub-list"; data: ListData };
196
- ctx.push(`<li style="list-style: none; display: inline">`);
197
- renderList(ctx, subItem.data);
198
- ctx.push("</li>");
199
- }
200
- i++;
201
- }
202
-
203
- ctx.push(`</${tag}>`);
204
- }
205
-
206
- /**
207
- * Sanitize and render list-specific attributes, excluding internal `_`-prefixed keys.
208
- *
209
- * @param attributes - Raw attribute map from the AST.
210
- * @returns An HTML attribute string with leading space, or `""` if empty.
211
- */
212
- function renderListAttrs(attributes: Record<string, string>): string {
213
- const safe = sanitizeAttributes(attributes);
214
- let result = "";
215
- for (const [key, value] of Object.entries(safe)) {
216
- if (key.startsWith("_")) continue;
217
- result += ` ${key}="${escapeAttr(value)}"`;
218
- }
219
- return result;
220
- }
221
-
222
- /**
223
- * Render a definition list (`:`-prefixed items in Wikidot markup).
224
- *
225
- * Produces `<dl>` with `<dt>`/`<dd>` pairs for each definition item.
226
- *
227
- * @param ctx - The current render context.
228
- * @param items - Array of definition list items, each with key and value elements.
229
- */
230
- export function renderDefinitionList(ctx: RenderContext, items: DefinitionListItem[]): void {
231
- ctx.push("<dl>");
232
- for (const item of items) {
233
- ctx.push("<dt>");
234
- renderElements(ctx, item.key);
235
- ctx.push("</dt>");
236
- ctx.push("<dd>");
237
- renderElements(ctx, item.value);
238
- ctx.push("</dd>");
239
- }
240
- ctx.push("</dl>");
241
- }