@wdprlib/parser 4.0.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.
Files changed (26) hide show
  1. package/README.md +9 -1
  2. package/dist/index.cjs +345 -64
  3. package/dist/index.d.cts +121 -2
  4. package/dist/index.d.ts +121 -2
  5. package/dist/index.js +317 -36
  6. package/package.json +2 -2
  7. package/src/index.ts +9 -0
  8. package/src/parser/rules/block/module/index.ts +10 -0
  9. package/src/parser/rules/block/module/listpages/extract.ts +9 -0
  10. package/src/parser/rules/block/module/listpages/extraction/tagcloud.ts +25 -0
  11. package/src/parser/rules/block/module/listpages/types/data-requirements.ts +2 -0
  12. package/src/parser/rules/block/module/listpages/url-resolution/fields.ts +4 -4
  13. package/src/parser/rules/block/module/listpages/url-resolution/params.ts +12 -1
  14. package/src/parser/rules/block/module/listpages/url-resolution/resolve.ts +10 -1
  15. package/src/parser/rules/block/module/listpages/url-resolution/value.ts +13 -4
  16. package/src/parser/rules/block/module/mapping.ts +2 -0
  17. package/src/parser/rules/block/module/resolution/contexts.ts +29 -1
  18. package/src/parser/rules/block/module/resolution/data-maps.ts +17 -0
  19. package/src/parser/rules/block/module/resolution/dynamic-modules.ts +29 -2
  20. package/src/parser/rules/block/module/resolution/walk-resolve.ts +28 -4
  21. package/src/parser/rules/block/module/resolve.ts +11 -1
  22. package/src/parser/rules/block/module/tagcloud/index.ts +15 -0
  23. package/src/parser/rules/block/module/tagcloud/parser.ts +127 -0
  24. package/src/parser/rules/block/module/tagcloud/resolve.ts +173 -0
  25. package/src/parser/rules/block/module/tagcloud/types.ts +85 -0
  26. package/src/parser/rules/block/module/types-common.ts +15 -0
@@ -1,9 +1,9 @@
1
1
  import type { ListPagesQuery } from "../types";
2
2
 
3
3
  export type UrlResolvableField =
4
- | { attr: string; queryKey: StringUrlQueryKey; type: "string" }
5
- | { attr: string; queryKey: NumberUrlQueryKey; type: "number" }
6
- | { attr: string; queryKey: BooleanUrlQueryKey; type: "boolean" };
4
+ | { attr: string; queryKey: StringUrlQueryKey; type: "string"; urlAttrs?: readonly string[] }
5
+ | { attr: string; queryKey: NumberUrlQueryKey; type: "number"; urlAttrs?: readonly string[] }
6
+ | { attr: string; queryKey: BooleanUrlQueryKey; type: "boolean"; urlAttrs?: readonly string[] };
7
7
 
8
8
  type StringUrlQueryKey = Extract<
9
9
  keyof ListPagesQuery,
@@ -33,7 +33,7 @@ export const URL_RESOLVABLE_FIELDS: readonly UrlResolvableField[] = [
33
33
  { attr: "limit", queryKey: "limit", type: "number" },
34
34
  { attr: "per-page", queryKey: "perPage", type: "number" },
35
35
  { attr: "order", queryKey: "order", type: "string" },
36
- { attr: "tags", queryKey: "tags", type: "string" },
36
+ { attr: "tags", queryKey: "tags", type: "string", urlAttrs: ["tag"] },
37
37
  { attr: "category", queryKey: "category", type: "string" },
38
38
  { attr: "parent", queryKey: "parent", type: "string" },
39
39
  { attr: "range", queryKey: "range", type: "string" },
@@ -1,3 +1,5 @@
1
+ import { URL_RESOLVABLE_FIELDS } from "./fields";
2
+
1
3
  /**
2
4
  * Parse URL path parameters like /offset/1/page2_limit/1.
3
5
  * Returns a map of parameter name -> value.
@@ -5,9 +7,10 @@
5
7
  export function parseUrlParams(url: string): Map<string, string> {
6
8
  const params = new Map<string, string>();
7
9
  const parts = url.split("/").filter(Boolean);
10
+ const pairStart = isUrlParameter(parts[0]) ? 0 : 1;
8
11
 
9
12
  // Skip the page name (first part), parse key/value pairs.
10
- for (let i = 1; i < parts.length - 1; i += 2) {
13
+ for (let i = pairStart; i < parts.length - 1; i += 2) {
11
14
  const key = parts[i];
12
15
  const value = parts[i + 1];
13
16
  if (key && value) {
@@ -17,3 +20,11 @@ export function parseUrlParams(url: string): Map<string, string> {
17
20
 
18
21
  return params;
19
22
  }
23
+
24
+ const URL_PARAMETER_NAMES = new Set(
25
+ URL_RESOLVABLE_FIELDS.flatMap((field) => [field.attr, ...(field.urlAttrs ?? [])]),
26
+ );
27
+
28
+ function isUrlParameter(param: string | undefined): boolean {
29
+ return param !== undefined && URL_PARAMETER_NAMES.has(param);
30
+ }
@@ -25,7 +25,12 @@ export function resolveQuery(
25
25
  const rawValue = rawAttributes[field.attr];
26
26
  if (!rawValue) continue;
27
27
 
28
- const resolvedValue = resolveUrlValue(rawValue, field.attr, urlParams, urlAttrPrefix);
28
+ const resolvedValue = resolveUrlValue(
29
+ rawValue,
30
+ getUrlParamNames(field),
31
+ urlParams,
32
+ urlAttrPrefix,
33
+ );
29
34
  if (resolvedValue === undefined) continue;
30
35
 
31
36
  assignResolvedUrlField(resolved, field, resolvedValue);
@@ -34,6 +39,10 @@ export function resolveQuery(
34
39
  return resolved;
35
40
  }
36
41
 
42
+ function getUrlParamNames(field: (typeof URL_RESOLVABLE_FIELDS)[number]): readonly string[] {
43
+ return field.urlAttrs ? [field.attr, ...field.urlAttrs] : [field.attr];
44
+ }
45
+
37
46
  /**
38
47
  * Resolve all `@URL` parameters and normalize the query.
39
48
  *
@@ -2,14 +2,14 @@
2
2
  * Resolve @URL|default format with actual URL parameters.
3
3
  *
4
4
  * @param rawValue - The raw attribute value (e.g., "@URL|0")
5
- * @param paramName - The parameter name (e.g., "offset")
5
+ * @param paramNames - The parameter name or names (e.g., "offset")
6
6
  * @param urlParams - Parsed URL parameters
7
7
  * @param prefix - Optional URL attribute prefix (e.g., "page2")
8
8
  * @returns Resolved value
9
9
  */
10
10
  export function resolveUrlValue(
11
11
  rawValue: string | undefined,
12
- paramName: string,
12
+ paramNames: string | readonly string[],
13
13
  urlParams: Map<string, string>,
14
14
  prefix?: string,
15
15
  ): string | undefined {
@@ -20,6 +20,15 @@ export function resolveUrlValue(
20
20
  }
21
21
 
22
22
  const defaultValue = rawValue.includes("|") ? rawValue.split("|")[1] : undefined;
23
- const actualParamName = prefix ? `${prefix}_${paramName}` : paramName;
24
- return urlParams.get(actualParamName) ?? defaultValue;
23
+ for (const paramName of toParamNames(paramNames)) {
24
+ const actualParamName = prefix ? `${prefix}_${paramName}` : paramName;
25
+ const value = urlParams.get(actualParamName);
26
+ if (value !== undefined) return value;
27
+ }
28
+
29
+ return defaultValue;
30
+ }
31
+
32
+ function toParamNames(paramNames: string | readonly string[]): readonly string[] {
33
+ return typeof paramNames === "string" ? [paramNames] : paramNames;
25
34
  }
@@ -19,6 +19,7 @@ import { joinModuleRule } from "./join/index";
19
19
  import { pageTreeModuleRule } from "./page-tree/index";
20
20
  import { listPagesModuleRule } from "./listpages/parser";
21
21
  import { listUsersModuleRule } from "./listusers/parser";
22
+ import { tagCloudModuleRule } from "./tagcloud/parser";
22
23
 
23
24
  /**
24
25
  * Complete list of all registered module rules.
@@ -35,6 +36,7 @@ export const MODULE_RULES: ModuleRule[] = [
35
36
  pageTreeModuleRule,
36
37
  listPagesModuleRule,
37
38
  listUsersModuleRule,
39
+ tagCloudModuleRule,
38
40
  ];
39
41
 
40
42
  /**
@@ -9,8 +9,9 @@ import type {
9
9
  ListUsersDataRequirement,
10
10
  ListUsersExternalData,
11
11
  } from "../listusers/types";
12
+ import type { TagCloudDataRequirement, TagCloudExternalData } from "../tagcloud/types";
12
13
  import type { ParseFunction } from "../listpages/resolve";
13
- import { buildListPagesDataMap, buildListUsersDataMap } from "./data-maps";
14
+ import { buildListPagesDataMap, buildListUsersDataMap, buildTagCloudDataMap } from "./data-maps";
14
15
 
15
16
  /**
16
17
  * Context for ListPages resolution.
@@ -54,6 +55,33 @@ export async function buildListPagesContext(
54
55
  };
55
56
  }
56
57
 
58
+ /**
59
+ * Context for TagCloud resolution.
60
+ *
61
+ * TagCloud has no template body, so unlike ListPages/ListUsers the context
62
+ * carries only the fetched data.
63
+ */
64
+ export interface TagCloudContext {
65
+ dataMap: Map<number, TagCloudExternalData>;
66
+ }
67
+
68
+ export async function buildTagCloudContext(
69
+ dataProvider: DataProvider,
70
+ requirements: TagCloudDataRequirement[],
71
+ ): Promise<TagCloudContext | null> {
72
+ if (requirements.length === 0 || !dataProvider.fetchTagCloud) {
73
+ return null;
74
+ }
75
+
76
+ const dataMap = await buildTagCloudDataMap(dataProvider, requirements);
77
+
78
+ if (dataMap.size === 0) {
79
+ return null;
80
+ }
81
+
82
+ return { dataMap };
83
+ }
84
+
57
85
  export async function buildListUsersContext(
58
86
  dataProvider: DataProvider,
59
87
  requirements: ListUsersDataRequirement[],
@@ -1,6 +1,7 @@
1
1
  import type { DataProvider } from "../types-common";
2
2
  import type { ListPagesDataRequirement, ListPagesExternalData } from "../listpages/types";
3
3
  import type { ListUsersDataRequirement, ListUsersExternalData } from "../listusers/types";
4
+ import type { TagCloudDataRequirement, TagCloudExternalData } from "../tagcloud/types";
4
5
  import { parseUrlParams, resolveAndNormalizeQuery } from "../listpages/url-resolver";
5
6
 
6
7
  export async function buildListPagesDataMap(
@@ -37,3 +38,19 @@ export async function buildListUsersDataMap(
37
38
 
38
39
  return dataMap;
39
40
  }
41
+
42
+ export async function buildTagCloudDataMap(
43
+ dataProvider: DataProvider,
44
+ requirements: TagCloudDataRequirement[],
45
+ ): Promise<Map<number, TagCloudExternalData>> {
46
+ const dataMap = new Map<number, TagCloudExternalData>();
47
+
48
+ for (const req of requirements) {
49
+ const data = await dataProvider.fetchTagCloud?.(req);
50
+ if (data) {
51
+ dataMap.set(req.id, data);
52
+ }
53
+ }
54
+
55
+ return dataMap;
56
+ }
@@ -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
+ };