@wdprlib/render 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/index.cjs +11 -387
  2. package/dist/index.js +2 -378
  3. package/package.json +5 -3
  4. package/src/context.ts +422 -0
  5. package/src/elements/bibliography.ts +123 -0
  6. package/src/elements/clear-float.ts +27 -0
  7. package/src/elements/code.ts +49 -0
  8. package/src/elements/collapsible.ts +105 -0
  9. package/src/elements/color.ts +32 -0
  10. package/src/elements/container.ts +302 -0
  11. package/src/elements/date.ts +59 -0
  12. package/src/elements/embed-block.ts +327 -0
  13. package/src/elements/embed.ts +166 -0
  14. package/src/elements/expr.ts +102 -0
  15. package/src/elements/footnote.ts +76 -0
  16. package/src/elements/html.ts +79 -0
  17. package/src/elements/iframe.ts +44 -0
  18. package/src/elements/iftags.ts +118 -0
  19. package/src/elements/image.ts +154 -0
  20. package/src/elements/include.ts +43 -0
  21. package/src/elements/index.ts +35 -0
  22. package/src/elements/line-break.ts +22 -0
  23. package/src/elements/link.ts +201 -0
  24. package/src/elements/list.ts +241 -0
  25. package/src/elements/math.ts +177 -0
  26. package/src/elements/module/backlinks.ts +28 -0
  27. package/src/elements/module/categories.ts +27 -0
  28. package/src/elements/module/index.ts +67 -0
  29. package/src/elements/module/join.ts +33 -0
  30. package/src/elements/module/listpages.ts +27 -0
  31. package/src/elements/module/listusers.ts +27 -0
  32. package/src/elements/module/page-tree.ts +27 -0
  33. package/src/elements/module/rate.ts +44 -0
  34. package/src/elements/tab-view.ts +75 -0
  35. package/src/elements/table.ts +101 -0
  36. package/src/elements/text.ts +57 -0
  37. package/src/elements/toc.ts +147 -0
  38. package/src/elements/user.ts +79 -0
  39. package/src/escape.ts +829 -0
  40. package/src/hash.ts +62 -0
  41. package/src/index.ts +26 -0
  42. package/src/libs/highlighter/engine.ts +352 -0
  43. package/src/libs/highlighter/index.ts +70 -0
  44. package/src/libs/highlighter/languages/cpp.ts +345 -0
  45. package/src/libs/highlighter/languages/css.ts +104 -0
  46. package/src/libs/highlighter/languages/diff.ts +154 -0
  47. package/src/libs/highlighter/languages/dtd.ts +99 -0
  48. package/src/libs/highlighter/languages/html.ts +59 -0
  49. package/src/libs/highlighter/languages/java.ts +251 -0
  50. package/src/libs/highlighter/languages/javascript.ts +213 -0
  51. package/src/libs/highlighter/languages/php.ts +433 -0
  52. package/src/libs/highlighter/languages/python.ts +308 -0
  53. package/src/libs/highlighter/languages/ruby.ts +360 -0
  54. package/src/libs/highlighter/languages/sql.ts +125 -0
  55. package/src/libs/highlighter/languages/xml.ts +68 -0
  56. package/src/libs/highlighter/types.ts +44 -0
  57. package/src/render.ts +231 -0
  58. package/src/types.ts +140 -0
package/src/context.ts ADDED
@@ -0,0 +1,422 @@
1
+ /**
2
+ *
3
+ * Central rendering context that tracks state during a single HTML render pass.
4
+ *
5
+ * Every render invocation creates one {@link RenderContext} instance, which serves
6
+ * as both an output buffer and a registry for sequential counters (footnotes,
7
+ * TOC headings, equations, HTML blocks, bibliography citations). The context
8
+ * also exposes helpers for resolving image sources, page links, and
9
+ * HTML attribute maps -- operations that depend on the current
10
+ * {@link WikitextSettings} and {@link PageContext}.
11
+ *
12
+ * @module
13
+ */
14
+
15
+ import type {
16
+ Element,
17
+ ImageSource,
18
+ LinkLocation,
19
+ SyntaxTree,
20
+ BibliographyBlockData,
21
+ DefinitionListItem,
22
+ WikitextSettings,
23
+ } from "@wdprlib/ast";
24
+ import { DEFAULT_SETTINGS } from "@wdprlib/ast";
25
+ import type { RenderOptions, PageContext } from "./types";
26
+ import { escapeHtml, escapeAttr, sanitizeAttributes } from "./escape";
27
+
28
+ /**
29
+ * Manages rendering state and accumulates HTML output for a single render pass.
30
+ *
31
+ * The context is created once per call to `renderToHtml` and threaded through
32
+ * every element renderer. It provides:
33
+ *
34
+ * - An HTML output buffer ({@link push}, {@link pushEscaped}, {@link getOutput})
35
+ * - Sequential counters for footnotes, TOC entries, equations, etc.
36
+ * - ID generation that optionally appends a random suffix to avoid collisions
37
+ * when multiple rendered fragments coexist on the same page
38
+ * - Resolution of {@link ImageSource} and {@link LinkLocation} values into
39
+ * concrete URLs, applying Wikidot page-name normalization rules
40
+ * - Attribute rendering with XSS sanitization
41
+ * - A bibliography map built by scanning the AST for `bibliography-block`
42
+ * elements, assigning continuous 1-indexed citation numbers across blocks
43
+ */
44
+ export class RenderContext {
45
+ /** Accumulated HTML fragments; joined by {@link getOutput}. */
46
+ private chunks: string[] = [];
47
+ /**
48
+ * When true, style elements in the AST are rendered rather than
49
+ * silently skipped. Set while rendering children of unresolved
50
+ * `[[iftags]]` blocks whose styles were not collected during resolve.
51
+ */
52
+ renderInlineStyles = false;
53
+ /**
54
+ * Active style slot ID. When set, style content is collected into
55
+ * the slot (via {@link pushToStyleSlot}) instead of being rendered
56
+ * inline, preserving source-order relative to other collected styles.
57
+ */
58
+ private _styleSlotId: number | null = null;
59
+ /** Collected CSS strings per style slot, keyed by slot ID. */
60
+ private _styleSlotContents = new Map<number, string[]>();
61
+ /** Auto-incrementing counter for table-of-contents heading IDs. */
62
+ private _tocIndex = 0;
63
+ /** Auto-incrementing counter for footnote reference/body IDs. */
64
+ private _footnoteIndex = 0;
65
+ /** Auto-incrementing counter for equation numbering. */
66
+ private _equationIndex = 0;
67
+ /** Auto-incrementing counter for `[[html]]` block iframe indices. */
68
+ private _htmlBlockIndex = 0;
69
+ /** Auto-incrementing counter for unique bibliography citation IDs. */
70
+ private _bibciteCounter = 0;
71
+ /**
72
+ * Random hex suffix appended to element IDs when `useTrueIds` is false.
73
+ * Prevents ID collisions when multiple rendered fragments appear on one page.
74
+ * `null` when `useTrueIds` is true (IDs are deterministic).
75
+ */
76
+ private _idSuffix: string | null;
77
+
78
+ /** Merged wikitext settings (page-mode defaults when omitted). */
79
+ readonly settings: WikitextSettings;
80
+ /** Full render options supplied by the caller. */
81
+ readonly options: RenderOptions;
82
+ /** Footnote element arrays collected from the syntax tree. */
83
+ readonly footnotes: Element[][];
84
+ /** CSS `<style>` blocks extracted from the syntax tree. */
85
+ readonly styles: string[];
86
+ /** Raw HTML strings for `[[html]]` blocks, indexed by insertion order. */
87
+ readonly htmlBlocks: string[];
88
+ /** Pre-built TOC element tree for `[[toc]]` rendering. */
89
+ readonly tocElements: Element[];
90
+ /** Map from bibliography label to its 1-indexed citation number. */
91
+ readonly bibliographyMap: Map<string, number>;
92
+ /** Ordered bibliography definition-list entries from `[[bibliography]]` blocks. */
93
+ readonly bibliographyEntries: DefinitionListItem[];
94
+
95
+ /**
96
+ * Create a new render context from a parsed syntax tree.
97
+ *
98
+ * @param tree - The syntax tree produced by the parser. Footnotes,
99
+ * styles, html-blocks, and table-of-contents data are extracted from
100
+ * the tree and stored for later use by element renderers.
101
+ * @param options - Caller-supplied render configuration. Missing fields
102
+ * fall back to safe defaults.
103
+ */
104
+ constructor(tree: SyntaxTree, options: RenderOptions = {}) {
105
+ this.settings = options.settings ?? DEFAULT_SETTINGS;
106
+ // When useTrueIds is false, generate a per-context random suffix for all IDs
107
+ this._idSuffix = this.settings.useTrueIds ? null : Math.random().toString(16).slice(2, 8);
108
+ this.options = options;
109
+ this.footnotes = options.footnotes ?? tree.footnotes ?? [];
110
+ this.styles = tree.styles ?? [];
111
+ this.htmlBlocks = tree["html-blocks"] ?? [];
112
+ this.tocElements = tree["table-of-contents"] ?? [];
113
+
114
+ // Build bibliography map from tree elements
115
+ this.bibliographyMap = new Map();
116
+ this.bibliographyEntries = [];
117
+ this.buildBibliographyMap(tree.elements);
118
+ }
119
+
120
+ /**
121
+ * Recursively scan the AST for `bibliography-block` elements and assign
122
+ * continuous 1-indexed citation numbers to each unique label.
123
+ *
124
+ * Duplicate labels across multiple bibliography blocks receive the same
125
+ * number, preserving the order of first occurrence.
126
+ *
127
+ * @param elements - Array of AST elements to scan (may contain nested children).
128
+ */
129
+ private buildBibliographyMap(elements: Element[]): void {
130
+ for (const el of elements) {
131
+ if (el.element === "bibliography-block") {
132
+ const data = el.data as BibliographyBlockData;
133
+ for (const entry of data.entries) {
134
+ if (!this.bibliographyMap.has(entry.key_string)) {
135
+ // Use continuous numbering across all bibliography blocks
136
+ const index = this.bibliographyMap.size + 1;
137
+ this.bibliographyMap.set(entry.key_string, index);
138
+ this.bibliographyEntries.push(entry);
139
+ }
140
+ }
141
+ }
142
+ // Recursively check nested elements
143
+ if ("data" in el && el.data && typeof el.data === "object") {
144
+ const data = el.data as Record<string, unknown>;
145
+ if ("elements" in data && Array.isArray(data.elements)) {
146
+ this.buildBibliographyMap(data.elements as Element[]);
147
+ }
148
+ }
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Append a raw HTML string to the output buffer without escaping.
154
+ *
155
+ * @param html - Trusted HTML fragment to append.
156
+ */
157
+ push(html: string): void {
158
+ this.chunks.push(html);
159
+ }
160
+
161
+ /**
162
+ * HTML-escape the given text and append it to the output buffer.
163
+ *
164
+ * @param text - Untrusted text content (will be entity-escaped).
165
+ */
166
+ pushEscaped(text: string): void {
167
+ this.chunks.push(escapeHtml(text));
168
+ }
169
+
170
+ /**
171
+ * Join all buffered HTML fragments and return the final HTML string.
172
+ *
173
+ * @returns The complete rendered HTML output.
174
+ */
175
+ getOutput(): string {
176
+ return this.chunks.join("");
177
+ }
178
+
179
+ /** Enter a style slot: subsequent {@link pushToStyleSlot} calls collect into this slot. */
180
+ enterStyleSlot(slotId: number): void {
181
+ this._styleSlotId = slotId;
182
+ if (!this._styleSlotContents.has(slotId)) {
183
+ this._styleSlotContents.set(slotId, []);
184
+ }
185
+ }
186
+
187
+ /** Exit the current style slot. */
188
+ exitStyleSlot(): void {
189
+ this._styleSlotId = null;
190
+ }
191
+
192
+ /** Whether a style slot is currently active. */
193
+ hasActiveStyleSlot(): boolean {
194
+ return this._styleSlotId !== null;
195
+ }
196
+
197
+ /** Push a CSS string into the active style slot. */
198
+ pushToStyleSlot(css: string): void {
199
+ if (this._styleSlotId !== null) {
200
+ this._styleSlotContents.get(this._styleSlotId)!.push(css);
201
+ }
202
+ }
203
+
204
+ /** Retrieve collected CSS strings for a given style slot. */
205
+ getStyleSlotContents(slotId: number): string[] {
206
+ return this._styleSlotContents.get(slotId) ?? [];
207
+ }
208
+
209
+ /**
210
+ * Return the current TOC heading index and advance the counter.
211
+ *
212
+ * @returns The index before incrementing (0-based).
213
+ */
214
+ nextTocIndex(): number {
215
+ return this._tocIndex++;
216
+ }
217
+
218
+ /**
219
+ * Return the current footnote index and advance the counter.
220
+ *
221
+ * @returns The index before incrementing (0-based).
222
+ */
223
+ nextFootnoteIndex(): number {
224
+ return this._footnoteIndex++;
225
+ }
226
+
227
+ /**
228
+ * Return the current equation index and advance the counter.
229
+ *
230
+ * @returns The index before incrementing (0-based).
231
+ */
232
+ nextEquationIndex(): number {
233
+ return this._equationIndex++;
234
+ }
235
+
236
+ /**
237
+ * Return the current HTML block index and advance the counter.
238
+ *
239
+ * @returns The index before incrementing (0-based).
240
+ */
241
+ nextHtmlBlockIndex(): number {
242
+ return this._htmlBlockIndex++;
243
+ }
244
+
245
+ /**
246
+ * Advance the bibliography citation counter and return the new value.
247
+ * Used to generate unique `bibcite-N-XXXXX` element IDs.
248
+ *
249
+ * @returns The counter value after incrementing (1-based).
250
+ */
251
+ nextBibciteCounter(): number {
252
+ return ++this._bibciteCounter;
253
+ }
254
+
255
+ /**
256
+ * Generate an element ID.
257
+ * When useTrueIds is true, returns `${prefix}${index}`.
258
+ * When false, appends a random suffix to prevent collisions across fragments.
259
+ */
260
+ generateId(prefix: string, index: number | string): string {
261
+ if (this._idSuffix === null) {
262
+ return `${prefix}${index}`;
263
+ }
264
+ return `${prefix}${index}-${this._idSuffix}`;
265
+ }
266
+
267
+ /**
268
+ * Generate a fixed element ID (no index).
269
+ * When useTrueIds is false, appends a random suffix.
270
+ */
271
+ generateFixedId(name: string): string {
272
+ if (this._idSuffix === null) {
273
+ return name;
274
+ }
275
+ return `${name}-${this._idSuffix}`;
276
+ }
277
+
278
+ /**
279
+ * The page context for the current render, if provided.
280
+ * Contains page name, site, tags, and a page-existence checker.
281
+ */
282
+ get page(): PageContext | undefined {
283
+ return this.options.page;
284
+ }
285
+
286
+ /**
287
+ * Resolve an {@link ImageSource} to a concrete `src` URL string.
288
+ *
289
+ * Wikidot supports several image source forms:
290
+ * - `url` -- a direct URL or local path
291
+ * - `file1` -- a file attached to the current page (`/local--files/{page}/{file}`)
292
+ * - `file2` -- a file attached to a named page
293
+ * - `file3` -- a file on a named site and page
294
+ *
295
+ * Local paths (starting with `/` but not `//`) and file-type sources are
296
+ * blocked when `allowLocalPaths` is false in the settings.
297
+ *
298
+ * @param source - The image source descriptor from the AST.
299
+ * @returns The resolved URL, or `null` if the source is blocked by settings.
300
+ */
301
+ resolveImageSource(source: ImageSource): string | null {
302
+ const pageName = this.page?.pageName;
303
+ switch (source.type) {
304
+ case "url": {
305
+ const url = source.data;
306
+ // Local path (e.g., /local-file.png) — blocked when allowLocalPaths is false
307
+ if (url.startsWith("/") && !url.startsWith("//")) {
308
+ if (!this.settings.allowLocalPaths) return null;
309
+ return `/local--files${url}`;
310
+ }
311
+ return url;
312
+ }
313
+ case "file1":
314
+ if (!this.settings.allowLocalPaths) return null;
315
+ return pageName
316
+ ? `/local--files/${pageName}/${source.data.file}`
317
+ : `/local--files/${source.data.file}`;
318
+ case "file2":
319
+ if (!this.settings.allowLocalPaths) return null;
320
+ return `/local--files/${source.data.page}/${source.data.file}`;
321
+ case "file3":
322
+ if (!this.settings.allowLocalPaths) return null;
323
+ return `/local--files/${source.data.site}/${source.data.page}/${source.data.file}`;
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Resolve a {@link LinkLocation} to an `href` string.
329
+ *
330
+ * Handles plain URL strings and structured `PageRef` objects. For page
331
+ * references the page name is normalized to lowercase, spaces are replaced
332
+ * with hyphens, and slashes become hyphens (Wikidot URL convention).
333
+ * Anchors (`#`) and cross-site references (`site` field) are handled.
334
+ *
335
+ * @param location - A raw URL string or a `PageRef` object from the AST.
336
+ * @returns The resolved href string, always starting with `/` for local
337
+ * pages or `https://` for cross-site links.
338
+ */
339
+ resolvePageLink(location: LinkLocation): string {
340
+ if (typeof location === "string") {
341
+ return location;
342
+ }
343
+ // PageRef - Wikidot normalizes page names
344
+ const page = location.page;
345
+
346
+ // Handle special cases first
347
+ // //path - protocol-relative or special routing
348
+ if (page.startsWith("//")) {
349
+ return page.toLowerCase();
350
+ }
351
+
352
+ // Handle # in page name (anchor routing like scp-series#001 or MAIN/#/page)
353
+ // The # and everything after should be preserved as-is
354
+ const hashIdx = page.indexOf("#");
355
+ if (hashIdx !== -1) {
356
+ let pagePart = page.slice(0, hashIdx);
357
+ const anchor = page.slice(hashIdx);
358
+ // Remove trailing slash before # (MAIN/ -> MAIN for MAIN/#/page)
359
+ if (pagePart.endsWith("/")) {
360
+ pagePart = pagePart.slice(0, -1);
361
+ }
362
+ // Don't apply slash-to-hyphen conversion for page part before #
363
+ return `/${pagePart.toLowerCase()}${anchor.toLowerCase()}`;
364
+ }
365
+
366
+ const normalizedPage = this.normalizePageName(page);
367
+ // Remove leading slash to prevent protocol-relative URLs (//...)
368
+ const safePage = normalizedPage.startsWith("/") ? normalizedPage.slice(1) : normalizedPage;
369
+
370
+ if (location.site) {
371
+ return `https://${location.site}.wikidot.com/${safePage}`;
372
+ }
373
+ return `/${safePage}`;
374
+ }
375
+
376
+ /**
377
+ * Normalize a page name following Wikidot URL conventions.
378
+ *
379
+ * Rules applied in order: lowercase, strip spaces after category colon,
380
+ * replace remaining spaces with hyphens, replace slashes with hyphens
381
+ * (unless the name starts with `/`).
382
+ *
383
+ * @param page - Raw page name from the AST.
384
+ * @returns Normalized page name suitable for URL paths.
385
+ */
386
+ private normalizePageName(page: string): string {
387
+ // Lowercase
388
+ let normalized = page.toLowerCase();
389
+ // Remove space after category separator (system: Recent -> system:Recent)
390
+ normalized = normalized.replace(/:\s+/g, ":");
391
+ // Replace spaces with hyphens (Wikidot URL normalization)
392
+ normalized = normalized.replace(/\s+/g, "-").trim();
393
+ // Replace / with - (except at start)
394
+ if (!normalized.startsWith("/")) {
395
+ normalized = normalized.replace(/\//g, "-");
396
+ }
397
+ return normalized;
398
+ }
399
+
400
+ /**
401
+ * Sanitize and render an attribute map to an HTML attribute string.
402
+ *
403
+ * Dangerous attributes (event handlers, unsafe URLs) are stripped by
404
+ * {@link sanitizeAttributes}. Each surviving key-value pair is escaped
405
+ * and formatted as ` key="value"`.
406
+ *
407
+ * @param attributes - Raw attribute map from the AST.
408
+ * @returns A string of HTML attributes with a leading space, or `""` if empty.
409
+ */
410
+ renderAttributes(attributes: Record<string, string>): string {
411
+ const safe = sanitizeAttributes(attributes);
412
+ let result = "";
413
+ for (const [key, value] of Object.entries(safe)) {
414
+ if (value !== "") {
415
+ result += ` ${key}="${escapeAttr(value)}"`;
416
+ } else {
417
+ result += ` ${key}=""`;
418
+ }
419
+ }
420
+ return result;
421
+ }
422
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ *
3
+ * Renderers for Wikidot bibliography markup.
4
+ *
5
+ * Wikidot provides two related constructs:
6
+ * - `((bibcite label))` -- an inline citation reference that links to a
7
+ * bibliography entry and displays a superscript citation number.
8
+ * - `[[bibliography]]...[[/bibliography]]` -- a block that lists all
9
+ * bibliography entries as a numbered definition list.
10
+ *
11
+ * Citation numbers are assigned globally (across all bibliography blocks)
12
+ * by the {@link RenderContext} during construction. If a label is unknown,
13
+ * the citation is rendered as plain text.
14
+ *
15
+ * @module
16
+ */
17
+
18
+ import type { BibliographyCiteData, BibliographyBlockData, Element } from "@wdprlib/ast";
19
+ import type { RenderContext } from "../context";
20
+ import { escapeAttr, escapeHtml } from "../escape";
21
+
22
+ /**
23
+ * Generate a short FNV-1a-based hex suffix for unique bibliography IDs.
24
+ *
25
+ * Wikidot uses random-ish suffixes on `bibcite` element IDs to avoid
26
+ * collisions when the same label is cited multiple times on one page.
27
+ *
28
+ * @param label - The bibliography label string.
29
+ * @param counter - A monotonically increasing counter from the context.
30
+ * @returns A 6-character lowercase hex string.
31
+ */
32
+ function generateIdSuffix(label: string, counter: number): string {
33
+ // Simple hash based on label and counter
34
+ let h = 0x811c9dc5;
35
+ const input = label + counter;
36
+ for (let i = 0; i < input.length; i++) {
37
+ h ^= input.charCodeAt(i);
38
+ h = Math.imul(h, 0x01000193);
39
+ }
40
+ return (h >>> 0).toString(16).slice(0, 6);
41
+ }
42
+
43
+ /**
44
+ * Render an inline bibliography citation reference: `((bibcite label))`.
45
+ *
46
+ * Produces a clickable superscript link that scrolls to the corresponding
47
+ * bibliography entry. The rendered HTML structure matches Wikidot output:
48
+ *
49
+ * ```html
50
+ * <a href="javascript:;" class="bibcite" id="bibcite-N-XXXXX"
51
+ * onclick="WIKIDOT.page.utils.scrollToReference('bibitem-N')">N</a>
52
+ * ```
53
+ *
54
+ * If the label does not match any bibliography entry, the raw label text
55
+ * is rendered instead.
56
+ *
57
+ * @param ctx - The current render context.
58
+ * @param data - Citation data containing the bibliography label.
59
+ */
60
+ export function renderBibliographyCite(ctx: RenderContext, data: BibliographyCiteData): void {
61
+ const number = ctx.bibliographyMap.get(data.label);
62
+ const counter = ctx.nextBibciteCounter();
63
+
64
+ if (number === undefined) {
65
+ // Unknown label - render as text
66
+ ctx.push(escapeHtml(data.label));
67
+ return;
68
+ }
69
+
70
+ const idSuffix = generateIdSuffix(data.label, counter);
71
+ const id = ctx.generateId(`bibcite-${number}-`, idSuffix);
72
+ const bibitemId = ctx.generateId("bibitem-", number);
73
+ const onclick = `WIKIDOT.page.utils.scrollToReference('${bibitemId}')`;
74
+
75
+ ctx.push(`<a href="javascript:;" class="bibcite" id="${id}" onclick="${escapeAttr(onclick)}">`);
76
+ ctx.push(String(number));
77
+ ctx.push("</a>");
78
+ }
79
+
80
+ /**
81
+ * Render a bibliography block: `[[bibliography]]...[[/bibliography]]`.
82
+ *
83
+ * Produces a Wikidot-compatible numbered list of bibliography entries:
84
+ *
85
+ * ```html
86
+ * <div class="bibitems">
87
+ * <div class="title">Bibliography</div>
88
+ * <div class="bibitem" id="bibitem-1">1. Content...</div>
89
+ * ...
90
+ * </div>
91
+ * ```
92
+ *
93
+ * If `data.hide` is true, the block is suppressed (entries still participate
94
+ * in citation numbering but are not rendered visually).
95
+ *
96
+ * @param ctx - The current render context.
97
+ * @param data - Bibliography block data containing entries and optional title/hide flags.
98
+ * @param renderElements - Callback to render child elements within each entry.
99
+ */
100
+ export function renderBibliographyBlock(
101
+ ctx: RenderContext,
102
+ data: BibliographyBlockData,
103
+ renderElements: (ctx: RenderContext, elements: Element[]) => void,
104
+ ): void {
105
+ if (data.hide) return;
106
+
107
+ const title = data.title ?? "Bibliography";
108
+
109
+ ctx.push(`<div class="bibitems">`);
110
+ ctx.push(`<div class="title">${escapeHtml(title)}</div>`);
111
+
112
+ let index = 1;
113
+ for (const entry of data.entries) {
114
+ const itemId = ctx.generateId("bibitem-", index);
115
+ ctx.push(`<div class="bibitem" id="${itemId}">`);
116
+ ctx.push(`${index}. `);
117
+ renderElements(ctx, entry.value);
118
+ ctx.push("</div>");
119
+ index++;
120
+ }
121
+
122
+ ctx.push("</div>");
123
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ *
3
+ * Renderer for the Wikidot `~~~~~` (clear-float) markup.
4
+ *
5
+ * Wikidot uses `~~~~~` (five tildes) to insert a CSS float-clearing
6
+ * `<div>`. The direction (`left`, `right`, or `both`) is determined
7
+ * by the number and placement of tildes in the source markup.
8
+ *
9
+ * @module
10
+ */
11
+
12
+ import type { ClearFloat } from "@wdprlib/ast";
13
+ import type { RenderContext } from "../context";
14
+
15
+ /**
16
+ * Render a clear-float element as an invisible `<div>` with the
17
+ * appropriate CSS `clear` property.
18
+ *
19
+ * The output matches Wikidot's rendering: a zero-height div with
20
+ * `font-size: 1px` to prevent layout collapse in some browsers.
21
+ *
22
+ * @param ctx - The current render context.
23
+ * @param direction - CSS clear direction (`"left"`, `"right"`, or `"both"`).
24
+ */
25
+ export function renderClearFloat(ctx: RenderContext, direction: ClearFloat): void {
26
+ ctx.push(`<div style="clear:${direction}; height: 0px; font-size: 1px"></div>`);
27
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ *
3
+ * Renderer for `[[code]]...[[/code]]` blocks in Wikidot markup.
4
+ *
5
+ * When a `language` attribute is specified and the language is supported
6
+ * by the built-in highlighter (a TypeScript port of PEAR Text_Highlighter),
7
+ * the code is syntax-highlighted with `hl-*` CSS class spans. Otherwise,
8
+ * the code is rendered as plain escaped text inside `<pre><code>`.
9
+ *
10
+ * @module
11
+ */
12
+
13
+ import type { CodeBlockData } from "@wdprlib/ast";
14
+ import type { RenderContext } from "../context";
15
+ import { escapeHtml } from "../escape";
16
+ import { highlight } from "../libs/highlighter";
17
+
18
+ /**
19
+ * Render a `[[code]]` block.
20
+ *
21
+ * The block is wrapped in `<div class="code">`. If the block is empty,
22
+ * the div is closed immediately with no inner content. When a language
23
+ * is specified and supported, the highlighter produces
24
+ * `<div class="hl-main"><pre>...</pre></div>` with token-level spans.
25
+ *
26
+ * @param ctx - The current render context.
27
+ * @param data - Code block data containing contents and optional language.
28
+ */
29
+ export function renderCode(ctx: RenderContext, data: CodeBlockData): void {
30
+ ctx.push(`<div class="code">`);
31
+
32
+ if (data.contents === "") {
33
+ ctx.push("</div>");
34
+ return;
35
+ }
36
+
37
+ if (data.language) {
38
+ const highlighted = highlight(data.contents, data.language);
39
+ if (highlighted) {
40
+ ctx.push(highlighted);
41
+ } else {
42
+ ctx.push(`<pre><code>${escapeHtml(data.contents)}</code></pre>`);
43
+ }
44
+ } else {
45
+ ctx.push(`<pre><code>${escapeHtml(data.contents)}</code></pre>`);
46
+ }
47
+
48
+ ctx.push("</div>");
49
+ }