@wdprlib/parser 4.1.0 → 4.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.
@@ -1,19 +1,23 @@
1
1
  import type { Element } from "@wdprlib/ast";
2
2
  import { isListPagesModule, resolveListPages } from "../listpages/resolve";
3
3
  import { isListUsersModule, resolveListUsers } from "../listusers/resolve";
4
+ import { isTagCloudModule, resolveTagCloud } from "../tagcloud/resolve";
4
5
  import { walkElements } from "../walk";
5
- import type { ListPagesContext, ListUsersContext } from "./contexts";
6
+ import type { ListPagesContext, ListUsersContext, TagCloudContext } from "./contexts";
6
7
 
7
8
  export interface DynamicModuleContext {
8
9
  listPages: ListPagesContext | null;
9
10
  listUsers: ListUsersContext | null;
11
+ tagCloud: TagCloudContext | null;
10
12
  fetchListPagesProvided: boolean;
11
13
  fetchListUsersProvided: boolean;
14
+ fetchTagCloudProvided: boolean;
12
15
  }
13
16
 
14
17
  export interface DynamicModuleIds {
15
18
  listPagesId: number;
16
19
  listUsersId: number;
20
+ tagCloudId: number;
17
21
  }
18
22
 
19
23
  export interface DynamicModuleResolution {
@@ -75,11 +79,32 @@ export function resolveDynamicModuleElement(
75
79
  };
76
80
  }
77
81
 
82
+ if (isTagCloudModule(element.data)) {
83
+ const elements: Element[] = [];
84
+ const tagCloudId = ids.tagCloudId;
85
+
86
+ if (ctx.tagCloud) {
87
+ const moduleData = ctx.tagCloud.dataMap.get(tagCloudId);
88
+
89
+ if (moduleData) {
90
+ elements.push(...resolveTagCloud(element.data, moduleData));
91
+ }
92
+ } else if (!ctx.fetchTagCloudProvided) {
93
+ elements.push(element);
94
+ }
95
+
96
+ return {
97
+ handled: true,
98
+ elements,
99
+ ids: { ...ids, tagCloudId: tagCloudId + 1 },
100
+ };
101
+ }
102
+
78
103
  return { handled: false, elements: [element], ids };
79
104
  }
80
105
 
81
106
  export function countDynamicModules(elements: Element[]): DynamicModuleIds {
82
- const counts: DynamicModuleIds = { listPagesId: 0, listUsersId: 0 };
107
+ const counts: DynamicModuleIds = { listPagesId: 0, listUsersId: 0, tagCloudId: 0 };
83
108
  walkElements(elements, (element) => {
84
109
  if (element.element !== "module") return;
85
110
 
@@ -87,6 +112,8 @@ export function countDynamicModules(elements: Element[]): DynamicModuleIds {
87
112
  counts.listPagesId++;
88
113
  } else if (isListUsersModule(element.data)) {
89
114
  counts.listUsersId++;
115
+ } else if (isTagCloudModule(element.data)) {
116
+ counts.tagCloudId++;
90
117
  }
91
118
  });
92
119
  return counts;
@@ -1,7 +1,7 @@
1
1
  import type { Element } from "@wdprlib/ast";
2
2
  import { mapElementChildrenWithState } from "../walk";
3
3
  import { isIfTagsElement, resolveIfTags, type IfTagsData } from "../iftags/resolve";
4
- import type { ListPagesContext, ListUsersContext } from "./contexts";
4
+ import type { ListPagesContext, ListUsersContext, TagCloudContext } from "./contexts";
5
5
  import { countDynamicModules, resolveDynamicModuleElement } from "./dynamic-modules";
6
6
 
7
7
  /**
@@ -10,19 +10,24 @@ import { countDynamicModules, resolveDynamicModuleElement } from "./dynamic-modu
10
10
  export interface WalkContext {
11
11
  listPages: ListPagesContext | null;
12
12
  listUsers: ListUsersContext | null;
13
+ tagCloud: TagCloudContext | null;
13
14
  /** Whether fetchListPages callback was provided, even if no data returned. */
14
15
  fetchListPagesProvided: boolean;
15
16
  /** Whether fetchListUsers callback was provided, even if no data returned. */
16
17
  fetchListUsersProvided: boolean;
18
+ /** Whether fetchTagCloud callback was provided, even if no data returned. */
19
+ fetchTagCloudProvided: boolean;
17
20
  pageTags: string[] | null;
18
21
  listPagesIdCounter: number;
19
22
  listUsersIdCounter: number;
23
+ tagCloudIdCounter: number;
20
24
  }
21
25
 
22
26
  export interface WalkResult {
23
27
  elements: Element[];
24
28
  nextListPagesId: number;
25
29
  nextListUsersId: number;
30
+ nextTagCloudId: number;
26
31
  }
27
32
 
28
33
  /**
@@ -32,13 +37,19 @@ export function walkAndResolve(elements: Element[], ctx: WalkContext): WalkResul
32
37
  const result: Element[] = [];
33
38
  let listPagesId = ctx.listPagesIdCounter;
34
39
  let listUsersId = ctx.listUsersIdCounter;
40
+ let tagCloudId = ctx.tagCloudIdCounter;
35
41
 
36
42
  for (const element of elements) {
37
- const dynamicModule = resolveDynamicModuleElement(element, ctx, { listPagesId, listUsersId });
43
+ const dynamicModule = resolveDynamicModuleElement(element, ctx, {
44
+ listPagesId,
45
+ listUsersId,
46
+ tagCloudId,
47
+ });
38
48
  if (dynamicModule.handled) {
39
49
  result.push(...dynamicModule.elements);
40
50
  listPagesId = dynamicModule.ids.listPagesId;
41
51
  listUsersId = dynamicModule.ids.listUsersId;
52
+ tagCloudId = dynamicModule.ids.tagCloudId;
42
53
  continue;
43
54
  }
44
55
 
@@ -52,20 +63,24 @@ export function walkAndResolve(elements: Element[], ctx: WalkContext): WalkResul
52
63
  ...ctx,
53
64
  listPagesIdCounter: listPagesId,
54
65
  listUsersIdCounter: listUsersId,
66
+ tagCloudIdCounter: tagCloudId,
55
67
  });
56
68
  result.push(...childResult.elements);
57
69
  listPagesId = childResult.nextListPagesId;
58
70
  listUsersId = childResult.nextListUsersId;
71
+ tagCloudId = childResult.nextTagCloudId;
59
72
  } else {
60
73
  const counts = countDynamicModules(ifTagsData.elements);
61
74
  listPagesId += counts.listPagesId;
62
75
  listUsersId += counts.listUsersId;
76
+ tagCloudId += counts.tagCloudId;
63
77
  }
64
78
  } else {
65
79
  const childResult = walkAndResolve(ifTagsData.elements, {
66
80
  ...ctx,
67
81
  listPagesIdCounter: listPagesId,
68
82
  listUsersIdCounter: listUsersId,
83
+ tagCloudIdCounter: tagCloudId,
69
84
  });
70
85
  result.push({
71
86
  element: "if-tags",
@@ -76,24 +91,27 @@ export function walkAndResolve(elements: Element[], ctx: WalkContext): WalkResul
76
91
  });
77
92
  listPagesId = childResult.nextListPagesId;
78
93
  listUsersId = childResult.nextListUsersId;
94
+ tagCloudId = childResult.nextTagCloudId;
79
95
  }
80
96
  continue;
81
97
  }
82
98
 
83
99
  const mapped = mapElementChildrenWithState(
84
100
  element,
85
- { listPagesId, listUsersId },
101
+ { listPagesId, listUsersId, tagCloudId },
86
102
  (children, state) => {
87
103
  const childResult = walkAndResolve(children, {
88
104
  ...ctx,
89
105
  listPagesIdCounter: state.listPagesId,
90
106
  listUsersIdCounter: state.listUsersId,
107
+ tagCloudIdCounter: state.tagCloudId,
91
108
  });
92
109
  return {
93
110
  elements: childResult.elements,
94
111
  state: {
95
112
  listPagesId: childResult.nextListPagesId,
96
113
  listUsersId: childResult.nextListUsersId,
114
+ tagCloudId: childResult.nextTagCloudId,
97
115
  },
98
116
  };
99
117
  },
@@ -101,7 +119,13 @@ export function walkAndResolve(elements: Element[], ctx: WalkContext): WalkResul
101
119
  result.push(mapped.element);
102
120
  listPagesId = mapped.state.listPagesId;
103
121
  listUsersId = mapped.state.listUsersId;
122
+ tagCloudId = mapped.state.tagCloudId;
104
123
  }
105
124
 
106
- return { elements: result, nextListPagesId: listPagesId, nextListUsersId: listUsersId };
125
+ return {
126
+ elements: result,
127
+ nextListPagesId: listPagesId,
128
+ nextListUsersId: listUsersId,
129
+ nextTagCloudId: tagCloudId,
130
+ };
107
131
  }
@@ -21,8 +21,13 @@ import type { DataProvider } from "./types-common";
21
21
  import { resolveIncludes } from "./include";
22
22
  import type { ListPagesDataRequirement, CompiledTemplate } from "./listpages/types";
23
23
  import type { ListUsersDataRequirement, ListUsersCompiledTemplate } from "./listusers/types";
24
+ import type { TagCloudDataRequirement } from "./tagcloud/types";
24
25
  import type { ParseFunction } from "./listpages/resolve";
25
- import { buildListPagesContext, buildListUsersContext } from "./resolution/contexts";
26
+ import {
27
+ buildListPagesContext,
28
+ buildListUsersContext,
29
+ buildTagCloudContext,
30
+ } from "./resolution/contexts";
26
31
  import { walkAndResolve } from "./resolution/walk-resolve";
27
32
  import { collectStyles } from "./resolution/styles";
28
33
 
@@ -71,6 +76,7 @@ export interface ResolveOptions {
71
76
  requirements: {
72
77
  listPages?: ListPagesDataRequirement[];
73
78
  listUsers?: ListUsersDataRequirement[];
79
+ tagCloud?: TagCloudDataRequirement[];
74
80
  };
75
81
 
76
82
  /**
@@ -139,17 +145,21 @@ export async function resolveModules(
139
145
  options.compiledListUsersTemplates,
140
146
  parse,
141
147
  );
148
+ const tagCloudCtx = await buildTagCloudContext(dataProvider, options.requirements.tagCloud ?? []);
142
149
  const pageTags = dataProvider.getPageTags?.() ?? null;
143
150
 
144
151
  // Resolve AST
145
152
  const resolvedElements = walkAndResolve(ast.elements, {
146
153
  listPages: listPagesCtx,
147
154
  listUsers: listUsersCtx,
155
+ tagCloud: tagCloudCtx,
148
156
  fetchListPagesProvided: dataProvider.fetchListPages !== undefined,
149
157
  fetchListUsersProvided: dataProvider.fetchListUsers !== undefined,
158
+ fetchTagCloudProvided: dataProvider.fetchTagCloud !== undefined,
150
159
  pageTags,
151
160
  listPagesIdCounter: 0,
152
161
  listUsersIdCounter: 0,
162
+ tagCloudIdCounter: 0,
153
163
  });
154
164
 
155
165
  // Collect style elements from resolved AST
@@ -0,0 +1,15 @@
1
+ /**
2
+ *
3
+ * Barrel exports for the TagCloud module.
4
+ *
5
+ * @module
6
+ */
7
+
8
+ export { tagCloudModuleRule } from "./parser";
9
+ export { isTagCloudModule, resolveTagCloud, type TagCloudModuleData } from "./resolve";
10
+ export type {
11
+ TagCloudDataFetcher,
12
+ TagCloudDataRequirement,
13
+ TagCloudExternalData,
14
+ TagCloudTagData,
15
+ } from "./types";
@@ -0,0 +1,127 @@
1
+ /**
2
+ *
3
+ * Parser rule for the Wikidot `[[module TagCloud]]` block.
4
+ *
5
+ * Parses font size, color, target, limit, and category attributes into a
6
+ * `tag-cloud` Module AST node, mirroring the validation behavior of Wikidot's
7
+ * `PagesTagCloudModule`. Invalid font size or color formats produce an error
8
+ * block element, matching Wikidot's ProcessException messages.
9
+ *
10
+ * @module
11
+ */
12
+
13
+ import { CSS_LENGTH_UNITS, type CssLengthUnit, type Element, type Module } from "@wdprlib/ast";
14
+ import type { ModuleRule } from "../types";
15
+
16
+ /**
17
+ * Matches a font size value like `300%`, `2em`, or `16px`.
18
+ *
19
+ * Wikidot only accepts `px`, `em`, and `%` here; wdpr deliberately extends
20
+ * this to all CSS length units (see `CSS_LENGTH_UNITS`), case-insensitively.
21
+ */
22
+ const FONT_SIZE_PATTERN = new RegExp(`^([0-9]+)(${CSS_LENGTH_UNITS.join("|")})$`, "i");
23
+
24
+ /** Matches a color value like `64,64,128` (range is not enforced, per Wikidot). */
25
+ const COLOR_PATTERN = /^[0-9]+,[0-9]+,[0-9]+$/;
26
+
27
+ /** Matches a strictly positive integer, mirroring PHP's `is_numeric` + `> 0` gate. */
28
+ const POSITIVE_INT_PATTERN = /^[0-9]+$/;
29
+
30
+ const ERROR_FONT_FORMAT =
31
+ "Unsupported format for font size. Use a number followed by a CSS length unit such as px, em or %.";
32
+ const ERROR_FONT_MISMATCH = "Format for minFontSize and maxFontSize must use the same unit.";
33
+ const ERROR_COLOR_FORMAT =
34
+ 'Unsupported color format. Use "RRR,GGG,BBB" for Red,Green,Blue each within 0-255 range.';
35
+
36
+ /** Build the Wikidot-compatible error block for invalid attribute values. */
37
+ function errorBlock(message: string): Element {
38
+ return {
39
+ element: "container",
40
+ data: {
41
+ type: "div",
42
+ attributes: { class: "error-block" },
43
+ elements: [{ element: "text", data: message }],
44
+ },
45
+ };
46
+ }
47
+
48
+ /** Parse a limit value, falling back to 50 unless it is a safe positive integer. */
49
+ function parseLimit(value: string | undefined): number {
50
+ if (!value || !POSITIVE_INT_PATTERN.test(value)) return 50;
51
+ const num = Number.parseInt(value, 10);
52
+ return Number.isSafeInteger(num) && num > 0 ? num : 50;
53
+ }
54
+
55
+ /** Normalize a target path to `/<path>/tag/`, or the Wikidot default. */
56
+ function normalizeTarget(value: string | undefined): string {
57
+ if (!value) return "/system:page-tags/tag/";
58
+ let target = value;
59
+ if (!target.startsWith("/")) target = `/${target}`;
60
+ if (!target.endsWith("/")) target = `${target}/`;
61
+ return `${target}tag/`;
62
+ }
63
+
64
+ /** Parse a `R,G,B` color string into a tuple (input must match COLOR_PATTERN). */
65
+ function parseColor(value: string): [number, number, number] {
66
+ const [r = 0, g = 0, b = 0] = value.split(",").map((part) => Number.parseInt(part, 10));
67
+ return [r, g, b];
68
+ }
69
+
70
+ /**
71
+ * Module rule for `[[module TagCloud]]`.
72
+ *
73
+ * Displays a weighted cloud of page tags. Font sizes and colors are only
74
+ * honored when both min and max are given (Wikidot behavior); otherwise the
75
+ * defaults (100%-300%, rgb(128,128,192)-rgb(64,64,128)) apply. Tag data is
76
+ * supplied during the resolution phase via `DataProvider.fetchTagCloud`.
77
+ */
78
+ export const tagCloudModuleRule: ModuleRule = {
79
+ name: "module-tagcloud",
80
+ acceptsNames: ["tagcloud"],
81
+ hasBody: false,
82
+
83
+ parse(_ctx, _pos, args): Module | Element {
84
+ const maxFontSize = args.maxfontsize;
85
+ const minFontSize = args.minfontsize;
86
+ const maxColor = args.maxcolor;
87
+ const minColor = args.mincolor;
88
+
89
+ let sizeSmall = 100;
90
+ let sizeBig = 300;
91
+ let fontSizeUnit: CssLengthUnit = "%";
92
+ if (maxFontSize && minFontSize) {
93
+ const maxMatch = FONT_SIZE_PATTERN.exec(maxFontSize);
94
+ if (!maxMatch) return errorBlock(ERROR_FONT_FORMAT);
95
+ const maxUnit = (maxMatch[2] as string).toLowerCase() as CssLengthUnit;
96
+ const minMatch = FONT_SIZE_PATTERN.exec(minFontSize);
97
+ if (!minMatch || (minMatch[2] as string).toLowerCase() !== maxUnit) {
98
+ return errorBlock(ERROR_FONT_MISMATCH);
99
+ }
100
+ sizeBig = Number.parseInt(maxMatch[1] as string, 10);
101
+ sizeSmall = Number.parseInt(minMatch[1] as string, 10);
102
+ fontSizeUnit = maxUnit;
103
+ }
104
+
105
+ let colorSmall: [number, number, number] = [128, 128, 192];
106
+ let colorBig: [number, number, number] = [64, 64, 128];
107
+ if (maxColor && minColor) {
108
+ if (!COLOR_PATTERN.test(maxColor) || !COLOR_PATTERN.test(minColor)) {
109
+ return errorBlock(ERROR_COLOR_FORMAT);
110
+ }
111
+ colorSmall = parseColor(minColor);
112
+ colorBig = parseColor(maxColor);
113
+ }
114
+
115
+ return {
116
+ module: "tag-cloud",
117
+ "min-font-size": sizeSmall,
118
+ "max-font-size": sizeBig,
119
+ "font-size-unit": fontSizeUnit,
120
+ "min-color": colorSmall,
121
+ "max-color": colorBig,
122
+ target: normalizeTarget(args.target),
123
+ limit: parseLimit(args.limit),
124
+ category: args.category || null,
125
+ };
126
+ },
127
+ };
@@ -0,0 +1,173 @@
1
+ /**
2
+ *
3
+ * TagCloud module resolution.
4
+ *
5
+ * After the application has fetched tag data based on the extracted
6
+ * requirements, this module expands the `tag-cloud` AST node into concrete
7
+ * elements: a `div.pages-tag-cloud-box` container with one `a.tag` anchor per
8
+ * tag, styled with linearly interpolated font sizes and colors, matching
9
+ * Wikidot's `PagesTagCloudModule` output.
10
+ *
11
+ * Unlike ListPages/ListUsers there is no template body, so the expansion
12
+ * builds AST elements directly instead of re-parsing wikitext.
13
+ *
14
+ * Note: TagCloud modules appearing inside the expanded output of other
15
+ * dynamic modules (e.g. a ListPages body) are not resolved — the resolver
16
+ * does not re-walk expansion results. This matches the behavior of all
17
+ * dynamic modules.
18
+ *
19
+ * @module
20
+ */
21
+
22
+ import type { Element, Module } from "@wdprlib/ast";
23
+ import type { TagCloudExternalData, TagCloudTagData } from "./types";
24
+
25
+ /**
26
+ * Narrowed type for the tag-cloud variant of the Module discriminated union.
27
+ */
28
+ export type TagCloudModuleData = Extract<Module, { module: "tag-cloud" }>;
29
+
30
+ /**
31
+ * Type guard to check if a Module is a tag-cloud module.
32
+ *
33
+ * @param module - A Module discriminated union value
34
+ * @returns true if the module is a tag-cloud module
35
+ */
36
+ export function isTagCloudModule(module: Module): module is TagCloudModuleData {
37
+ return module.module === "tag-cloud";
38
+ }
39
+
40
+ /** Wikidot's message shown when the site has no tags at all. */
41
+ const NO_TAGS_MESSAGE_PREFIX =
42
+ "It seems you have no tags attached to pages. To attach a tag simply click on the ";
43
+ const NO_TAGS_MESSAGE_SUFFIX = " button at the bottom of any page.";
44
+
45
+ /** Encode a tag for use in a URL path segment, matching PHP's `rawurlencode`. */
46
+ function rawUrlEncode(value: string): string {
47
+ return encodeURIComponent(value).replace(
48
+ /[!'()*]/g,
49
+ (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Select and order tags the way Wikidot's SQL does: weight descending
55
+ * (ties broken by tag name ascending) limited to `limit`, then tag name
56
+ * ascending for display.
57
+ */
58
+ function selectTags(tags: TagCloudTagData[], limit: number): TagCloudTagData[] {
59
+ return tags
60
+ .toSorted((a, b) => b.weight - a.weight || compareTags(a.tag, b.tag))
61
+ .slice(0, limit)
62
+ .toSorted((a, b) => compareTags(a.tag, b.tag));
63
+ }
64
+
65
+ /** Locale-independent code point comparison for deterministic tag ordering. */
66
+ function compareTags(a: string, b: string): number {
67
+ return a < b ? -1 : a > b ? 1 : 0;
68
+ }
69
+
70
+ /**
71
+ * Resolve a single TagCloud module by expanding fetched tag data into a
72
+ * `div.pages-tag-cloud-box` with weighted `a.tag` anchors.
73
+ *
74
+ * Font sizes and colors are interpolated linearly between the module's
75
+ * min/max values based on each tag's weight relative to the weight range of
76
+ * the selected tags (all tags get the minimum when the range is zero).
77
+ *
78
+ * @param module - The tag-cloud module data from the AST
79
+ * @param data - External tag data fetched by the application
80
+ * @returns Array of AST elements replacing the module node
81
+ */
82
+ export function resolveTagCloud(module: TagCloudModuleData, data: TagCloudExternalData): Element[] {
83
+ if (data.status === "category-not-found") {
84
+ return [
85
+ {
86
+ element: "container",
87
+ data: {
88
+ type: "div",
89
+ attributes: { class: "error-block" },
90
+ elements: [{ element: "text", data: `Category "${data.category}" can not be found.` }],
91
+ },
92
+ },
93
+ ];
94
+ }
95
+
96
+ const tags = selectTags(data.tags, module.limit);
97
+ if (tags.length === 0) {
98
+ return [noTagsMessage()];
99
+ }
100
+
101
+ let minWeight = Number.POSITIVE_INFINITY;
102
+ let maxWeight = Number.NEGATIVE_INFINITY;
103
+ for (const tag of tags) {
104
+ if (tag.weight < minWeight) minWeight = tag.weight;
105
+ if (tag.weight > maxWeight) maxWeight = tag.weight;
106
+ }
107
+ const weightRange = maxWeight - minWeight;
108
+
109
+ const categorySuffix = data.category !== null ? `/category/${rawUrlEncode(data.category)}` : "";
110
+
111
+ const anchors: Element[] = [];
112
+ for (const tag of tags) {
113
+ const a = weightRange === 0 ? 0 : (tag.weight - minWeight) / weightRange;
114
+ const fontSize = interpolate(module["min-font-size"], module["max-font-size"], a);
115
+ const [r, g, b] = ([0, 1, 2] as const).map((i) =>
116
+ interpolate(module["min-color"][i], module["max-color"][i], a),
117
+ ) as [number, number, number];
118
+
119
+ anchors.push({ element: "text", data: "\n" });
120
+ anchors.push({
121
+ element: "anchor",
122
+ data: {
123
+ target: null,
124
+ attributes: {
125
+ class: "tag",
126
+ href: `${module.target}${rawUrlEncode(tag.tag)}${categorySuffix}`,
127
+ style: `font-size: ${fontSize}${module["font-size-unit"]}; color: rgb(${r}, ${g}, ${b});`,
128
+ },
129
+ elements: [{ element: "text", data: tag.tag }],
130
+ },
131
+ });
132
+ }
133
+ anchors.push({ element: "text", data: "\n" });
134
+
135
+ return [
136
+ {
137
+ element: "container",
138
+ data: {
139
+ type: "div",
140
+ attributes: { class: "pages-tag-cloud-box" },
141
+ elements: anchors,
142
+ },
143
+ },
144
+ ];
145
+ }
146
+
147
+ /** Linearly interpolate between min and max, rounded like PHP's `round`. */
148
+ function interpolate(min: number, max: number, a: number): number {
149
+ return Math.round(min + (max - min) * a);
150
+ }
151
+
152
+ /** Wikidot's "no tags" paragraph, shown when the fetcher returns zero tags. */
153
+ function noTagsMessage(): Element {
154
+ return {
155
+ element: "container",
156
+ data: {
157
+ type: "paragraph",
158
+ attributes: {},
159
+ elements: [
160
+ { element: "text", data: NO_TAGS_MESSAGE_PREFIX },
161
+ {
162
+ element: "container",
163
+ data: {
164
+ type: "italics",
165
+ attributes: {},
166
+ elements: [{ element: "text", data: "tags" }],
167
+ },
168
+ },
169
+ { element: "text", data: NO_TAGS_MESSAGE_SUFFIX },
170
+ ],
171
+ },
172
+ };
173
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ *
3
+ * Type definitions for the TagCloud module.
4
+ *
5
+ * The `[[module TagCloud]]` block displays a weighted cloud of page tags.
6
+ * Tag data (tag names and page counts) is supplied by the application via
7
+ * `DataProvider.fetchTagCloud` during the resolution phase.
8
+ *
9
+ * @module
10
+ */
11
+
12
+ /**
13
+ * Data requirement for a single TagCloud module instance.
14
+ *
15
+ * Produced by the extraction phase and consumed by the application to
16
+ * determine what data to fetch.
17
+ */
18
+ export interface TagCloudDataRequirement {
19
+ /** Unique identifier for this module instance (sequential, 0-based) */
20
+ id: number;
21
+ /** Category filter from the module attributes, or null for all categories */
22
+ category: string | null;
23
+ /** Maximum number of tags to display (already defaulted to 50) */
24
+ limit: number;
25
+ }
26
+
27
+ /**
28
+ * A single tag entry with its weight.
29
+ */
30
+ export interface TagCloudTagData {
31
+ /** The tag name */
32
+ tag: string;
33
+ /** Number of pages carrying this tag (Wikidot's "weight") */
34
+ weight: number;
35
+ }
36
+
37
+ /**
38
+ * External data provided by the application for a single TagCloud module.
39
+ *
40
+ * On success, `category` must be the **normalized** category name (Wikidot's
41
+ * `toUnixName` form, e.g. `"News Foo"` → `"news-foo"`), or null when no
42
+ * category filter applies; it is used verbatim in generated tag link URLs.
43
+ * When the requested category does not exist, return
44
+ * `{ status: "category-not-found", category }` to produce a Wikidot-compatible
45
+ * error block instead of tag links.
46
+ */
47
+ export type TagCloudExternalData =
48
+ | {
49
+ status: "ok";
50
+ /** Tags to display (see {@link TagCloudDataFetcher} for ordering) */
51
+ tags: TagCloudTagData[];
52
+ /** Normalized category name used in link URLs, or null for all categories */
53
+ category: string | null;
54
+ }
55
+ | {
56
+ status: "category-not-found";
57
+ /** The category name that could not be found, used in the error message */
58
+ category: string;
59
+ };
60
+
61
+ /**
62
+ * Callback to fetch tag data for a TagCloud module.
63
+ *
64
+ * Called during the resolution phase for each TagCloud module in the AST.
65
+ * Like Wikidot, the fetcher should apply the `category` filter and select at
66
+ * most `limit` tags ordered by weight descending (ties broken by tag name
67
+ * ascending) — this can be delegated to the database. The resolver defensively
68
+ * re-applies this ordering and the limit, then sorts the selected tags by tag
69
+ * name ascending for display.
70
+ *
71
+ * Return null/undefined to skip the module (outputs nothing). Exceptions
72
+ * thrown by the fetcher propagate out of `resolveModules()`; return
73
+ * `{ status: "category-not-found" }` (or catch errors yourself) to render an
74
+ * error instead.
75
+ *
76
+ * @security `requirement.category` originates from **untrusted user input**
77
+ * (the module's wikitext attributes). Never interpolate it into SQL — always
78
+ * use parameterised queries or prepared statements.
79
+ *
80
+ * @param requirement - The data requirement describing what data is needed
81
+ * @returns Tag data, null/undefined to skip, or a Promise of the same
82
+ */
83
+ export type TagCloudDataFetcher = (
84
+ requirement: TagCloudDataRequirement,
85
+ ) => TagCloudExternalData | null | undefined | Promise<TagCloudExternalData | null | undefined>;
@@ -14,6 +14,7 @@
14
14
 
15
15
  import type { ListPagesDataFetcher } from "./listpages/types";
16
16
  import type { ListUsersDataFetcher } from "./listusers/types";
17
+ import type { TagCloudDataFetcher } from "./tagcloud/types";
17
18
  import type { IfTagsResolver } from "./iftags/types";
18
19
  import type { IncludeFetcher } from "./include/resolve/types";
19
20
 
@@ -59,6 +60,20 @@ export interface DataProvider {
59
60
  */
60
61
  fetchInclude?: IncludeFetcher;
61
62
 
63
+ /**
64
+ * Fetch tag weights for `[[module TagCloud]]` expansion.
65
+ *
66
+ * Called once per TagCloud instance with its category filter and limit.
67
+ * Unlike {@link DataProvider.getPageTags} (which returns the current
68
+ * page's own tags for `[[iftags]]`), this callback returns site-wide
69
+ * tag statistics: each tag with the number of pages carrying it.
70
+ *
71
+ * @security `requirement.category` originates from **untrusted user
72
+ * input**. Never interpolate it into SQL — always use parameterised
73
+ * queries or prepared statements.
74
+ */
75
+ fetchTagCloud?: TagCloudDataFetcher;
76
+
62
77
  /**
63
78
  * Return the current page's tags for `[[iftags]]` evaluation.
64
79
  *