@wdprlib/parser 3.1.2 → 3.2.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 (124) hide show
  1. package/dist/index.cjs +295 -118
  2. package/dist/index.js +272 -95
  3. package/package.json +5 -3
  4. package/src/index.ts +163 -0
  5. package/src/lexer/index.ts +20 -0
  6. package/src/lexer/lexer.ts +687 -0
  7. package/src/lexer/tokens.ts +141 -0
  8. package/src/parser/constants.ts +173 -0
  9. package/src/parser/depth.ts +251 -0
  10. package/src/parser/index.ts +18 -0
  11. package/src/parser/parse.ts +315 -0
  12. package/src/parser/postprocess/divAdjacentParagraph.ts +76 -0
  13. package/src/parser/postprocess/index.ts +15 -0
  14. package/src/parser/postprocess/spanStrip.ts +697 -0
  15. package/src/parser/preprocess/expr.ts +265 -0
  16. package/src/parser/preprocess/index.ts +38 -0
  17. package/src/parser/preprocess/typography.ts +67 -0
  18. package/src/parser/preprocess/utils.ts +250 -0
  19. package/src/parser/preprocess/whitespace.ts +111 -0
  20. package/src/parser/rules/block/align.ts +282 -0
  21. package/src/parser/rules/block/bibliography.ts +359 -0
  22. package/src/parser/rules/block/block-list.ts +689 -0
  23. package/src/parser/rules/block/blockquote.ts +238 -0
  24. package/src/parser/rules/block/center.ts +87 -0
  25. package/src/parser/rules/block/clear-float.ts +75 -0
  26. package/src/parser/rules/block/code.ts +187 -0
  27. package/src/parser/rules/block/collapsible.ts +337 -0
  28. package/src/parser/rules/block/comment.ts +73 -0
  29. package/src/parser/rules/block/content-separator.ts +79 -0
  30. package/src/parser/rules/block/definition-list.ts +270 -0
  31. package/src/parser/rules/block/div.ts +400 -0
  32. package/src/parser/rules/block/embed-block.ts +153 -0
  33. package/src/parser/rules/block/footnoteblock.ts +200 -0
  34. package/src/parser/rules/block/heading.ts +142 -0
  35. package/src/parser/rules/block/horizontal-rule.ts +61 -0
  36. package/src/parser/rules/block/html.ts +222 -0
  37. package/src/parser/rules/block/iframe.ts +239 -0
  38. package/src/parser/rules/block/iftags.ts +150 -0
  39. package/src/parser/rules/block/include.ts +179 -0
  40. package/src/parser/rules/block/index.ts +127 -0
  41. package/src/parser/rules/block/list.ts +244 -0
  42. package/src/parser/rules/block/math.ts +183 -0
  43. package/src/parser/rules/block/module/backlinks/index.ts +31 -0
  44. package/src/parser/rules/block/module/backlinks/types.ts +21 -0
  45. package/src/parser/rules/block/module/categories/index.ts +34 -0
  46. package/src/parser/rules/block/module/categories/types.ts +21 -0
  47. package/src/parser/rules/block/module/css/index.ts +37 -0
  48. package/src/parser/rules/block/module/iftags/condition.ts +109 -0
  49. package/src/parser/rules/block/module/iftags/index.ts +26 -0
  50. package/src/parser/rules/block/module/iftags/preprocess.ts +140 -0
  51. package/src/parser/rules/block/module/iftags/resolve.ts +73 -0
  52. package/src/parser/rules/block/module/iftags/types.ts +63 -0
  53. package/src/parser/rules/block/module/include/index.ts +20 -0
  54. package/src/parser/rules/block/module/include/resolve.ts +556 -0
  55. package/src/parser/rules/block/module/index.ts +122 -0
  56. package/src/parser/rules/block/module/join/index.ts +34 -0
  57. package/src/parser/rules/block/module/join/types.ts +23 -0
  58. package/src/parser/rules/block/module/listpages/compiler.ts +453 -0
  59. package/src/parser/rules/block/module/listpages/extract.ts +410 -0
  60. package/src/parser/rules/block/module/listpages/index.ts +83 -0
  61. package/src/parser/rules/block/module/listpages/normalize.ts +390 -0
  62. package/src/parser/rules/block/module/listpages/parser.ts +106 -0
  63. package/src/parser/rules/block/module/listpages/resolve.ts +130 -0
  64. package/src/parser/rules/block/module/listpages/types.ts +513 -0
  65. package/src/parser/rules/block/module/listpages/url-resolver.ts +186 -0
  66. package/src/parser/rules/block/module/listusers/compiler.ts +77 -0
  67. package/src/parser/rules/block/module/listusers/extract.ts +45 -0
  68. package/src/parser/rules/block/module/listusers/index.ts +36 -0
  69. package/src/parser/rules/block/module/listusers/parser.ts +54 -0
  70. package/src/parser/rules/block/module/listusers/resolve.ts +58 -0
  71. package/src/parser/rules/block/module/listusers/types.ts +93 -0
  72. package/src/parser/rules/block/module/mapping.ts +61 -0
  73. package/src/parser/rules/block/module/page-tree/index.ts +38 -0
  74. package/src/parser/rules/block/module/page-tree/types.ts +29 -0
  75. package/src/parser/rules/block/module/rate/index.ts +28 -0
  76. package/src/parser/rules/block/module/rate/types.ts +19 -0
  77. package/src/parser/rules/block/module/resolve.ts +411 -0
  78. package/src/parser/rules/block/module/types-common.ts +59 -0
  79. package/src/parser/rules/block/module/types.ts +61 -0
  80. package/src/parser/rules/block/module/utils.ts +43 -0
  81. package/src/parser/rules/block/module/walk.ts +380 -0
  82. package/src/parser/rules/block/module.ts +164 -0
  83. package/src/parser/rules/block/orphan-li.ts +177 -0
  84. package/src/parser/rules/block/paragraph.ts +157 -0
  85. package/src/parser/rules/block/table-block.ts +726 -0
  86. package/src/parser/rules/block/table.ts +441 -0
  87. package/src/parser/rules/block/tabview.ts +331 -0
  88. package/src/parser/rules/block/toc.ts +129 -0
  89. package/src/parser/rules/block/utils.ts +615 -0
  90. package/src/parser/rules/index.ts +49 -0
  91. package/src/parser/rules/inline/anchor-name.ts +154 -0
  92. package/src/parser/rules/inline/anchor.ts +327 -0
  93. package/src/parser/rules/inline/bibcite.ts +153 -0
  94. package/src/parser/rules/inline/bold.ts +86 -0
  95. package/src/parser/rules/inline/color.ts +140 -0
  96. package/src/parser/rules/inline/comment.ts +90 -0
  97. package/src/parser/rules/inline/equation-ref.ts +115 -0
  98. package/src/parser/rules/inline/expr.ts +526 -0
  99. package/src/parser/rules/inline/footnote.ts +223 -0
  100. package/src/parser/rules/inline/guillemet.ts +64 -0
  101. package/src/parser/rules/inline/html.ts +132 -0
  102. package/src/parser/rules/inline/image.ts +328 -0
  103. package/src/parser/rules/inline/index.ts +150 -0
  104. package/src/parser/rules/inline/italic.ts +74 -0
  105. package/src/parser/rules/inline/line-break.ts +326 -0
  106. package/src/parser/rules/inline/link-anchor.ts +147 -0
  107. package/src/parser/rules/inline/link-single.ts +164 -0
  108. package/src/parser/rules/inline/link-star.ts +134 -0
  109. package/src/parser/rules/inline/link-triple.ts +267 -0
  110. package/src/parser/rules/inline/math-inline.ts +126 -0
  111. package/src/parser/rules/inline/monospace.ts +78 -0
  112. package/src/parser/rules/inline/raw.ts +262 -0
  113. package/src/parser/rules/inline/size.ts +244 -0
  114. package/src/parser/rules/inline/span.ts +424 -0
  115. package/src/parser/rules/inline/strikethrough.ts +115 -0
  116. package/src/parser/rules/inline/subscript.ts +84 -0
  117. package/src/parser/rules/inline/superscript.ts +84 -0
  118. package/src/parser/rules/inline/text.ts +84 -0
  119. package/src/parser/rules/inline/underline.ts +127 -0
  120. package/src/parser/rules/inline/user.ts +147 -0
  121. package/src/parser/rules/inline/utils.ts +344 -0
  122. package/src/parser/rules/types.ts +252 -0
  123. package/src/parser/rules/utils.ts +155 -0
  124. package/src/parser/toc.ts +130 -0
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Module system for Wikidot dynamic constructs.
3
+ *
4
+ * Wikidot supports several "module" blocks (`[[module ListPages]]`,
5
+ * `[[module ListUsers]]`, `[[module CSS]]`, etc.) and pseudo-blocks
6
+ * (`[[include]]`, `[[iftags]]`) that require external data to resolve.
7
+ *
8
+ * This barrel module re-exports everything needed to:
9
+ *
10
+ * 1. **Extract** data requirements from a parsed AST
11
+ * ({@link extractDataRequirements})
12
+ * 2. **Compile** body templates that contain `%%variable%%` placeholders
13
+ * ({@link compileTemplate}, {@link compileListUsersTemplate})
14
+ * 3. **Resolve** modules by injecting fetched data back into the AST
15
+ * ({@link resolveModules})
16
+ * 4. **Resolve includes** by fetching and inlining included pages
17
+ * ({@link resolveIncludes})
18
+ *
19
+ * @module
20
+ */
21
+
22
+ // Module rule types and registry
23
+ export type { ModuleRule } from "./types";
24
+ export { MODULE_RULES, getModuleRuleByName } from "./mapping";
25
+
26
+ // Module parsers
27
+ export { rateModuleRule } from "./rate/index";
28
+ export { cssModuleRule } from "./css/index";
29
+ export { backlinksModuleRule } from "./backlinks/index";
30
+ export { categoriesModuleRule } from "./categories/index";
31
+ export { joinModuleRule } from "./join/index";
32
+ export { pageTreeModuleRule } from "./page-tree/index";
33
+ export { listPagesModuleRule } from "./listpages/parser";
34
+ export { listUsersModuleRule } from "./listusers/parser";
35
+
36
+ // Module data types (parser-only modules)
37
+ export type { RateModuleData } from "./rate/types";
38
+ export type { BacklinksModuleData } from "./backlinks/types";
39
+ export type { CategoriesModuleData } from "./categories/types";
40
+ export type { JoinModuleData } from "./join/types";
41
+ export type { PageTreeModuleData } from "./page-tree/types";
42
+
43
+ // Common types
44
+ export type { DataProvider } from "./types-common";
45
+
46
+ // ListPages module
47
+ export type {
48
+ ListPagesQuery,
49
+ ListPagesVariable,
50
+ ListPagesDataRequirement,
51
+ DataRequirements,
52
+ UserInfo,
53
+ PageData,
54
+ SiteContext,
55
+ ListPagesExternalData,
56
+ ListPagesDataFetcher,
57
+ VariableContext,
58
+ CompiledTemplate,
59
+ ExtractionResult,
60
+ ParseFunction,
61
+ ListPagesModuleData,
62
+ // Normalized types
63
+ NormalizedListPagesQuery,
64
+ NormalizedTags,
65
+ NormalizedCategory,
66
+ NormalizedOrder,
67
+ NormalizedParent,
68
+ NormalizedDateSelector,
69
+ NormalizedNumericSelector,
70
+ } from "./listpages";
71
+ export {
72
+ extractDataRequirements,
73
+ isListPagesModule,
74
+ resolveListPages,
75
+ compileTemplate,
76
+ // Query normalization (for advanced use cases)
77
+ normalizeQuery,
78
+ parseTags,
79
+ parseCategory,
80
+ parseOrder,
81
+ parseParent,
82
+ parseDateSelector,
83
+ parseNumericSelector,
84
+ } from "./listpages";
85
+
86
+ // IfTags module
87
+ export type { TagCondition, IfTagsResolver, IfTagsData, IfTagsResolveResult } from "./iftags";
88
+ export {
89
+ parseTagCondition,
90
+ evaluateTagCondition,
91
+ isIfTagsElement,
92
+ resolveIfTags,
93
+ preprocessIftags,
94
+ } from "./iftags";
95
+
96
+ // Include module
97
+ export type { IncludeFetcher, AsyncIncludeFetcher, ResolveIncludesOptions } from "./include";
98
+ export { resolveIncludes, resolveIncludesAsync } from "./include";
99
+
100
+ // ListUsers module
101
+ export type {
102
+ ListUsersVariable,
103
+ ListUsersUserData,
104
+ ListUsersDataRequirement,
105
+ ListUsersExternalData,
106
+ ListUsersDataFetcher,
107
+ ListUsersVariableContext,
108
+ ListUsersCompiledTemplate,
109
+ ListUsersModuleData,
110
+ } from "./listusers";
111
+ export {
112
+ listUsersModuleRule as listUsersRule,
113
+ extractListUsersVariables,
114
+ compileListUsersTemplate,
115
+ isListUsersModule,
116
+ resolveListUsers,
117
+ } from "./listusers";
118
+
119
+ // Module resolver
120
+ export type { ResolveOptions } from "./resolve";
121
+ export { resolveModules } from "./resolve";
122
+ export { STYLE_SLOT_PREFIX } from "@wdprlib/ast";
@@ -0,0 +1,34 @@
1
+ /**
2
+ *
3
+ * Parser rule for the Wikidot `[[module Join]]` block.
4
+ *
5
+ * Renders a "Join this site" button. Accepts an optional `button` attribute
6
+ * to customize the button text.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import type { ModuleRule } from "../types";
12
+ import type { JoinModuleData } from "./types";
13
+
14
+ /**
15
+ * Module rule for `[[module Join]]`.
16
+ *
17
+ * Extracts the `button` attribute as the custom button text. All other
18
+ * attributes are passed through in the `attributes` record for the
19
+ * rendering application to handle.
20
+ */
21
+ export const joinModuleRule: ModuleRule = {
22
+ name: "module-join",
23
+ acceptsNames: ["join"],
24
+ hasBody: false,
25
+
26
+ parse(_ctx, _pos, args): JoinModuleData {
27
+ const { button, ...rest } = args;
28
+ return {
29
+ module: "join",
30
+ "button-text": button ?? null,
31
+ attributes: rest,
32
+ };
33
+ },
34
+ };
@@ -0,0 +1,23 @@
1
+ /**
2
+ *
3
+ * Type definitions for the Join module.
4
+ *
5
+ * The `[[module Join]]` block renders a "Join this site" button that allows
6
+ * visitors to apply for site membership.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ /**
12
+ * AST data for a `[[module Join]]` element.
13
+ *
14
+ * The rendering application should display a membership application button
15
+ * with the specified text.
16
+ */
17
+ export interface JoinModuleData {
18
+ module: "join";
19
+ /** Custom button text, or null to use the default "Join this Site" text */
20
+ "button-text": string | null;
21
+ /** Additional attributes passed to the module */
22
+ attributes: Record<string, string>;
23
+ }
@@ -0,0 +1,453 @@
1
+ /**
2
+ *
3
+ * Template compiler for the ListPages module.
4
+ *
5
+ * Compiles ListPages template strings (e.g., `"%%title%% by %%created_by%%"`)
6
+ * into executable functions that can be called repeatedly with different page
7
+ * data for fast rendering. The compilation step splits the template into static
8
+ * string segments and dynamic getter functions, avoiding repeated regex matching
9
+ * during rendering.
10
+ *
11
+ * Supported variable syntax:
12
+ * - `%%name%%` - Simple variable (e.g., `%%title%%`, `%%rating%%`)
13
+ * - `%%name{param}%%` - Parameterized variable (e.g., `%%content{2}%%`, `%%form_data{color}%%`)
14
+ * - `%%name(param)%%` - Parenthesized parameter (e.g., `%%preview(100)%%`)
15
+ * - `%%name|format%%` - Formatted variable (e.g., `%%created_at|%Y-%m-%d%%`, `%%tags_linked|/tag/%%`)
16
+ *
17
+ * The compiled function is a closure over the parsed template parts, providing
18
+ * O(n) rendering time proportional to the number of template segments.
19
+ *
20
+ * @module
21
+ */
22
+
23
+ import type { CompiledTemplate, VariableContext, PageData, UserInfo } from "./types";
24
+
25
+ /** Default character count for `%%preview%%` when no length is specified (Wikidot default). */
26
+ const DEFAULT_PREVIEW_LENGTH = 200;
27
+
28
+ /**
29
+ * Regex pattern for matching ListPages template variables with all parameter variants.
30
+ *
31
+ * Captures: [1] variable name, [2] brace parameter, [3] paren parameter, [4] format string.
32
+ * The format portion allows single `%` characters (for strftime tokens like `%Y`)
33
+ * but stops at `%%` (which terminates the variable).
34
+ */
35
+ const VARIABLE_REGEX =
36
+ /%%([a-z_]+)(?:\{([^}]+)\})?(?:\((\d+)\))?(?:\|([^%]*(?:%(?!%)[^%]*)*))?%%/gi;
37
+
38
+ /**
39
+ * Compile a ListPages template string into an executable function.
40
+ *
41
+ * The template is split into alternating static strings and dynamic getter
42
+ * functions. The returned function concatenates these parts with the getter
43
+ * functions evaluated against the provided variable context.
44
+ *
45
+ * @param template - The template string containing `%%variable%%` placeholders
46
+ * @returns A compiled function that accepts a `VariableContext` and returns the rendered string
47
+ */
48
+ export function compileTemplate(template: string): CompiledTemplate {
49
+ const parts: (string | ((ctx: VariableContext) => string))[] = [];
50
+ let lastIndex = 0;
51
+
52
+ // Split template into static and dynamic parts
53
+ for (const match of template.matchAll(VARIABLE_REGEX)) {
54
+ // Add static part before this match
55
+ if (match.index !== undefined && match.index > lastIndex) {
56
+ parts.push(template.slice(lastIndex, match.index));
57
+ }
58
+
59
+ // Convert variable to getter function
60
+ const [, varName, braceParam, parenParam, format] = match;
61
+ if (!varName) continue;
62
+ const getter = createVariableGetter(varName.toLowerCase(), braceParam, parenParam, format);
63
+ parts.push(getter);
64
+
65
+ lastIndex = match.index !== undefined ? match.index + match[0].length : lastIndex;
66
+ }
67
+
68
+ // Add remaining static part
69
+ if (lastIndex < template.length) {
70
+ parts.push(template.slice(lastIndex));
71
+ }
72
+
73
+ // Return compiled function
74
+ return (ctx: VariableContext): string => {
75
+ let result = "";
76
+ for (const part of parts) {
77
+ result += typeof part === "string" ? part : part(ctx);
78
+ }
79
+ return result;
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Create a getter function for a specific template variable.
85
+ *
86
+ * Handles all variable variants: parameterized (`{param}`), parenthesized (`(param)`),
87
+ * formatted (`|format`), and simple. Returns a function that extracts the appropriate
88
+ * value from a `VariableContext`.
89
+ *
90
+ * Unknown variable names return a function that always returns an empty string,
91
+ * matching Wikidot's behavior of silently ignoring unknown variables.
92
+ *
93
+ * @param name - Lowercase variable name
94
+ * @param braceParam - Content inside `{...}`, if present
95
+ * @param parenParam - Content inside `(...)`, if present
96
+ * @param format - Content after `|`, if present
97
+ * @returns A function that extracts the variable's value from a VariableContext
98
+ */
99
+ function createVariableGetter(
100
+ name: string,
101
+ braceParam?: string,
102
+ parenParam?: string,
103
+ format?: string,
104
+ ): (ctx: VariableContext) => string {
105
+ // Parameterized variables with {param}
106
+ if (braceParam !== undefined) {
107
+ switch (name) {
108
+ case "content": {
109
+ // %%content{n}%% - n is 1-indexed, split content by ==== on demand
110
+ const idx = Number(braceParam) - 1;
111
+ return (ctx) => {
112
+ if (!ctx.page.content) return "";
113
+ const sections = splitContentSections(ctx.page.content);
114
+ return sections[idx] ?? "";
115
+ };
116
+ }
117
+ case "form_data":
118
+ return (ctx) => ctx.page.formData?.[braceParam] ?? "";
119
+ case "form_raw":
120
+ return (ctx) => ctx.page.formRaw?.[braceParam] ?? "";
121
+ case "form_label":
122
+ return (ctx) => ctx.page.formLabel?.[braceParam] ?? "";
123
+ case "form_hint":
124
+ return (ctx) => ctx.page.formHint?.[braceParam] ?? "";
125
+ }
126
+ }
127
+
128
+ // Parameterized variables with (param)
129
+ if (parenParam !== undefined && name === "preview") {
130
+ const len = Number(parenParam);
131
+ return (ctx) => (ctx.page.content ?? "").slice(0, len);
132
+ }
133
+
134
+ // Date variables with format
135
+ if (format !== undefined) {
136
+ switch (name) {
137
+ case "created_at":
138
+ return (ctx) => formatDate(ctx.page.createdAt, format);
139
+ case "updated_at":
140
+ return (ctx) => formatDate(ctx.page.updatedAt, format);
141
+ case "commented_at":
142
+ return (ctx) => (ctx.page.commentedAt ? formatDate(ctx.page.commentedAt, format) : "");
143
+ }
144
+ }
145
+
146
+ // tags_linked with prefix
147
+ if (name === "tags_linked") {
148
+ const prefix = format ?? "/system:page-tags/tag/";
149
+ return (ctx) => formatTagsLinked(ctx.page.tags, prefix);
150
+ }
151
+ if (name === "_tags_linked") {
152
+ const prefix = format ?? "/system:page-tags/tag/";
153
+ return (ctx) => formatTagsLinked(ctx.page.hiddenTags, prefix);
154
+ }
155
+
156
+ // Simple variables
157
+ const getter = SIMPLE_GETTERS[name];
158
+ if (getter) return getter;
159
+
160
+ // Unknown variable - return empty string
161
+ return () => "";
162
+ }
163
+
164
+ // =============================================================================
165
+ // Simple Variable Getters
166
+ // =============================================================================
167
+
168
+ const SIMPLE_GETTERS: Record<string, (ctx: VariableContext) => string> = {
169
+ // Lifecycle - created
170
+ created_at: (ctx) => formatDate(ctx.page.createdAt),
171
+ created_by: (ctx) => ctx.page.createdBy?.name ?? "Anonymous",
172
+ created_by_unix: (ctx) => ctx.page.createdBy?.unixName ?? "",
173
+ created_by_id: (ctx) => String(ctx.page.createdBy?.id ?? 0),
174
+ created_by_linked: (ctx) => formatUserLinked(ctx.page.createdBy),
175
+
176
+ // Lifecycle - updated
177
+ updated_at: (ctx) => formatDate(ctx.page.updatedAt),
178
+ updated_by: (ctx) => ctx.page.updatedBy?.name ?? "Anonymous",
179
+ updated_by_unix: (ctx) => ctx.page.updatedBy?.unixName ?? "",
180
+ updated_by_id: (ctx) => String(ctx.page.updatedBy?.id ?? 0),
181
+ updated_by_linked: (ctx) => formatUserLinked(ctx.page.updatedBy),
182
+
183
+ // Lifecycle - commented
184
+ commented_at: (ctx) => (ctx.page.commentedAt ? formatDate(ctx.page.commentedAt) : ""),
185
+ commented_by: (ctx) => ctx.page.commentedBy?.name ?? "",
186
+ commented_by_unix: (ctx) => ctx.page.commentedBy?.unixName ?? "",
187
+ commented_by_id: (ctx) => String(ctx.page.commentedBy?.id ?? 0),
188
+ commented_by_linked: (ctx) => formatUserLinked(ctx.page.commentedBy),
189
+
190
+ // Structure - page
191
+ name: (ctx) => ctx.page.name,
192
+ category: (ctx) => ctx.page.category,
193
+ fullname: (ctx) => ctx.page.fullname,
194
+ title: (ctx) => ctx.page.title,
195
+ title_linked: (ctx) => `[[[${ctx.page.fullname} | ${ctx.page.title}]]]`,
196
+ link: (ctx) => `https://${ctx.site.domain}/${ctx.page.fullname}`,
197
+
198
+ // Structure - parent
199
+ parent_name: (ctx) => ctx.page.parentName ?? "",
200
+ parent_category: (ctx) => ctx.page.parentCategory ?? "",
201
+ parent_fullname: (ctx) => ctx.page.parentFullname ?? "",
202
+ parent_title: (ctx) => ctx.page.parentTitle ?? "",
203
+ parent_title_linked: (ctx) =>
204
+ ctx.page.parentFullname ? `[[[${ctx.page.parentFullname} | ${ctx.page.parentTitle}]]]` : "",
205
+
206
+ // Content
207
+ content: (ctx) => ctx.page.content ?? "",
208
+ preview: (ctx) => (ctx.page.content ?? "").slice(0, DEFAULT_PREVIEW_LENGTH),
209
+ summary: (ctx) => getSummary(ctx.page),
210
+ first_paragraph: (ctx) => getFirstParagraph(ctx.page.content),
211
+
212
+ // Tags
213
+ tags: (ctx) => ctx.page.tags.join(" "),
214
+ _tags: (ctx) => ctx.page.hiddenTags.join(" "),
215
+
216
+ // Metrics
217
+ children: (ctx) => String(ctx.page.children),
218
+ comments: (ctx) => String(ctx.page.comments),
219
+ size: (ctx) => String(ctx.page.size),
220
+ rating: (ctx) => String(ctx.page.rating),
221
+ rating_votes: (ctx) => String(ctx.page.ratingVotes),
222
+ rating_percent: (ctx) => String(ctx.page.ratingPercent ?? 0),
223
+ revisions: (ctx) => String(ctx.page.revisions),
224
+
225
+ // Pagination
226
+ index: (ctx) => String(ctx.index),
227
+ total: (ctx) => String(ctx.total),
228
+ limit: (ctx) => (ctx.limit !== undefined ? String(ctx.limit) : ""),
229
+ total_or_limit: (ctx) =>
230
+ String(ctx.limit !== undefined ? Math.min(ctx.total, ctx.limit) : ctx.total),
231
+
232
+ // Site context
233
+ site_title: (ctx) => ctx.site.title,
234
+ site_name: (ctx) => ctx.site.name,
235
+ site_domain: (ctx) => ctx.site.domain,
236
+ };
237
+
238
+ // =============================================================================
239
+ // Helper Functions
240
+ // =============================================================================
241
+
242
+ /**
243
+ * Format a Date object using an optional strftime-like format string.
244
+ *
245
+ * When no format is provided, returns the ISO 8601 string representation.
246
+ *
247
+ * @param date - The date to format
248
+ * @param format - Optional strftime format string (e.g., `"%Y-%m-%d"`)
249
+ * @returns Formatted date string
250
+ */
251
+ function formatDate(date: Date, format?: string): string {
252
+ if (!format) {
253
+ // Default ISO format
254
+ return date.toISOString();
255
+ }
256
+ return strftime(date, format);
257
+ }
258
+
259
+ /**
260
+ * Format a user as Wikidot's `[[*user name]]` inline syntax for linked display.
261
+ *
262
+ * @param user - User info, or undefined for anonymous display
263
+ * @returns Wikidot user link syntax, or "Anonymous" if no user
264
+ */
265
+ function formatUserLinked(user?: UserInfo): string {
266
+ if (!user) return "Anonymous";
267
+ return `[[*user ${user.name}]]`;
268
+ }
269
+
270
+ /**
271
+ * Format an array of tags as space-separated Wikidot link syntax.
272
+ *
273
+ * Each tag becomes `[prefix/tag tag]` where prefix defaults to
274
+ * `/system:page-tags/tag/` unless overridden by the template's pipe format.
275
+ *
276
+ * @param tags - Array of tag names
277
+ * @param prefix - URL prefix for tag links
278
+ * @returns Space-separated string of Wikidot link syntax, or empty string if no tags
279
+ */
280
+ function formatTagsLinked(tags: string[], prefix: string): string {
281
+ if (tags.length === 0) return "";
282
+ return tags.map((tag) => `[${prefix}${tag} ${tag}]`).join(" ");
283
+ }
284
+
285
+ /**
286
+ * Split content by ==== separators
287
+ *
288
+ * wikidot uses preg_split('/^([=]{4,})$/m', $source) to split content
289
+ * %%content{n}%% references sections[n-1] (1-indexed)
290
+ */
291
+ function splitContentSections(content: string): string[] {
292
+ return content.split(/^={4,}$/m).map((section) => section.trim());
293
+ }
294
+
295
+ /**
296
+ * Extract a summary from page content.
297
+ *
298
+ * If the content contains `====` section separators, returns the first section
299
+ * (equivalent to `%%content{1}%%`). Otherwise, returns the first paragraph.
300
+ *
301
+ * @param page - Page data containing content
302
+ * @returns Summary text extracted from the page content
303
+ */
304
+ function getSummary(page: PageData): string {
305
+ if (page.content) {
306
+ const sections = splitContentSections(page.content);
307
+ if (sections.length > 1) {
308
+ return sections[0]?.trim() ?? "";
309
+ }
310
+ }
311
+ return getFirstParagraph(page.content);
312
+ }
313
+
314
+ /**
315
+ * Extract the first paragraph from wikitext content.
316
+ *
317
+ * Strips headings, TOC directives, div blocks, and module blocks before
318
+ * splitting on double newlines to find the first content paragraph.
319
+ *
320
+ * @param content - Raw wikitext content, or undefined
321
+ * @returns The first paragraph text, or empty string if no content
322
+ */
323
+ function getFirstParagraph(content?: string): string {
324
+ if (!content) return "";
325
+
326
+ // Remove headings, TOC, div, module blocks
327
+ const s = content
328
+ .replace(/^(\+{1,6}) (.*)/gm, "")
329
+ .replace(/^\[\[toc(\s[^\]]+)?\]\]/gim, "")
330
+ .replace(/^\[\[\/?div(\s[^\]]+)?\]\]/gim, "")
331
+ .replace(/^\[\[\/?module(\s[^\]]+)?\]\]/gim, "")
332
+ .trim();
333
+
334
+ // Split by double newlines and take first
335
+ const paragraphs = s.split(/\n{2,}/);
336
+ return paragraphs[0]?.trim() ?? "";
337
+ }
338
+
339
+ /** Full month names for strftime `%B` token. Static to avoid per-call allocation. */
340
+ const MONTHS = [
341
+ "January",
342
+ "February",
343
+ "March",
344
+ "April",
345
+ "May",
346
+ "June",
347
+ "July",
348
+ "August",
349
+ "September",
350
+ "October",
351
+ "November",
352
+ "December",
353
+ ];
354
+ /** Abbreviated month names for strftime `%b` token. */
355
+ const MONTHS_SHORT = [
356
+ "Jan",
357
+ "Feb",
358
+ "Mar",
359
+ "Apr",
360
+ "May",
361
+ "Jun",
362
+ "Jul",
363
+ "Aug",
364
+ "Sep",
365
+ "Oct",
366
+ "Nov",
367
+ "Dec",
368
+ ];
369
+ /** Full day names for strftime `%A` token. */
370
+ const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
371
+ /** Abbreviated day names for strftime `%a` token. */
372
+ const DAYS_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
373
+
374
+ /** Pre-compiled regex matching strftime tokens (`%X` where X is a supported letter or `%`). */
375
+ const STRFTIME_REGEX = /%([YymdHeHIMSpbBaAwjZz%])/g;
376
+
377
+ /**
378
+ * Minimal strftime implementation supporting common tokens.
379
+ *
380
+ * Uses a single-pass regex replace for O(n) performance. All dates are
381
+ * treated as UTC to match Wikidot's server-side rendering behavior.
382
+ *
383
+ * Supported tokens: `%Y` (4-digit year), `%y` (2-digit year), `%m` (month),
384
+ * `%d` (zero-padded day), `%e` (day), `%H` (24h hour), `%I` (12h hour),
385
+ * `%M` (minute), `%S` (second), `%p` (AM/PM), `%b`/`%B` (month name),
386
+ * `%a`/`%A` (day name), `%w` (weekday number), `%j` (day of year),
387
+ * `%Z`/`%z` (timezone), `%%` (literal percent).
388
+ *
389
+ * @param date - The date to format
390
+ * @param format - strftime format string
391
+ * @returns Formatted date string
392
+ */
393
+ function strftime(date: Date, format: string): string {
394
+ const pad = (n: number, len = 2) => String(n).padStart(len, "0");
395
+
396
+ return format.replace(STRFTIME_REGEX, (_, token: string) => {
397
+ switch (token) {
398
+ case "Y":
399
+ return String(date.getFullYear());
400
+ case "y":
401
+ return String(date.getFullYear()).slice(-2);
402
+ case "m":
403
+ return pad(date.getMonth() + 1);
404
+ case "d":
405
+ return pad(date.getDate());
406
+ case "e":
407
+ return String(date.getDate());
408
+ case "H":
409
+ return pad(date.getHours());
410
+ case "I":
411
+ return pad(date.getHours() % 12 || 12);
412
+ case "M":
413
+ return pad(date.getMinutes());
414
+ case "S":
415
+ return pad(date.getSeconds());
416
+ case "p":
417
+ return date.getHours() < 12 ? "AM" : "PM";
418
+ case "b":
419
+ return MONTHS_SHORT[date.getMonth()] ?? "";
420
+ case "B":
421
+ return MONTHS[date.getMonth()] ?? "";
422
+ case "a":
423
+ return DAYS_SHORT[date.getDay()] ?? "";
424
+ case "A":
425
+ return DAYS[date.getDay()] ?? "";
426
+ case "w":
427
+ return String(date.getDay());
428
+ case "j":
429
+ return pad(getDayOfYear(date), 3);
430
+ case "Z":
431
+ return "UTC";
432
+ case "z":
433
+ return "+0000";
434
+ case "%":
435
+ return "%";
436
+ default:
437
+ return `%${token}`;
438
+ }
439
+ });
440
+ }
441
+
442
+ /**
443
+ * Calculate the day of year (1-366) for a given date.
444
+ *
445
+ * @param date - The date to calculate for
446
+ * @returns Day of year as an integer (1 = January 1st)
447
+ */
448
+ function getDayOfYear(date: Date): number {
449
+ const start = new Date(date.getFullYear(), 0, 0);
450
+ const diff = date.getTime() - start.getTime();
451
+ const oneDay = 1000 * 60 * 60 * 24;
452
+ return Math.floor(diff / oneDay);
453
+ }