@wdprlib/parser 5.3.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/dist/index.cjs +2429 -1199
  2. package/dist/index.d.cts +95 -8
  3. package/dist/index.d.ts +95 -8
  4. package/dist/index.js +2385 -1152
  5. package/package.json +3 -2
  6. package/src/build-info.generated.ts +2 -2
  7. package/src/index.ts +3 -0
  8. package/src/lexer/lexer.ts +34 -5
  9. package/src/lexer/quoted-string.ts +4 -4
  10. package/src/lexer/syntax-actions.ts +6 -1
  11. package/src/lexer/text-actions.ts +15 -1
  12. package/src/lexer/url-schemes.ts +78 -0
  13. package/src/parser/constants.ts +4 -0
  14. package/src/parser/parse/block.ts +1 -1
  15. package/src/parser/postprocess/divAdjacentParagraph.ts +23 -21
  16. package/src/parser/postprocess/spanStrip/merge.ts +8 -2
  17. package/src/parser/preprocess/typography.ts +25 -3
  18. package/src/parser/preprocess/utils/raw-regions.ts +59 -14
  19. package/src/parser/preprocess/whitespace/index.ts +8 -1
  20. package/src/parser/rules/block/bibliography/entry-content.ts +1 -1
  21. package/src/parser/rules/block/block-list/bare-content.ts +3 -1
  22. package/src/parser/rules/block/block-list/bare-paragraph.ts +7 -2
  23. package/src/parser/rules/block/block-list/item-content.ts +7 -3
  24. package/src/parser/rules/block/block-list/li-content.ts +8 -3
  25. package/src/parser/rules/block/block-list/li-item.ts +1 -1
  26. package/src/parser/rules/block/blockquote/build.ts +1 -1
  27. package/src/parser/rules/block/code/boundary.ts +76 -0
  28. package/src/parser/rules/block/code/content.ts +11 -40
  29. package/src/parser/rules/block/code/index.ts +8 -31
  30. package/src/parser/rules/block/code/open.ts +46 -0
  31. package/src/parser/rules/block/definition-list/item-key.ts +1 -1
  32. package/src/parser/rules/block/definition-list/item-value.ts +1 -1
  33. package/src/parser/rules/block/div/failed.ts +2 -0
  34. package/src/parser/rules/block/index.ts +3 -0
  35. package/src/parser/rules/block/list/line.ts +6 -3
  36. package/src/parser/rules/block/math/boundary.ts +104 -0
  37. package/src/parser/rules/block/math/index.ts +17 -57
  38. package/src/parser/rules/block/module/index.ts +3 -2
  39. package/src/parser/rules/block/module/listpages/extraction/listpages.ts +5 -0
  40. package/src/parser/rules/block/module/listpages/extraction/query.ts +1 -0
  41. package/src/parser/rules/block/module/listpages/extraction/template.ts +16 -0
  42. package/src/parser/rules/block/module/listpages/extraction/variables.ts +1 -0
  43. package/src/parser/rules/block/module/listpages/index.ts +1 -0
  44. package/src/parser/rules/block/module/listpages/normalization/order-parent.ts +9 -1
  45. package/src/parser/rules/block/module/listpages/normalize.ts +1 -0
  46. package/src/parser/rules/block/module/listpages/parser.ts +1 -0
  47. package/src/parser/rules/block/module/listpages/resolve.ts +4 -2
  48. package/src/parser/rules/block/module/listpages/template/excerpt.ts +63 -0
  49. package/src/parser/rules/block/module/listpages/template/format/content.ts +0 -32
  50. package/src/parser/rules/block/module/listpages/template/format/index.ts +1 -1
  51. package/src/parser/rules/block/module/listpages/template/getters/index.ts +14 -0
  52. package/src/parser/rules/block/module/listpages/template/getters/parameterized.ts +3 -1
  53. package/src/parser/rules/block/module/listpages/template/getters/registered.ts +52 -0
  54. package/src/parser/rules/block/module/listpages/template/getters/simple.ts +15 -5
  55. package/src/parser/rules/block/module/listpages/template/literal.ts +6 -0
  56. package/src/parser/rules/block/module/listpages/template/syntax.ts +10 -1
  57. package/src/parser/rules/block/module/listpages/types/data-requirements.ts +6 -0
  58. package/src/parser/rules/block/module/listpages/types/external-data.ts +36 -2
  59. package/src/parser/rules/block/module/listpages/types/normalized-query.ts +14 -4
  60. package/src/parser/rules/block/module/listpages/types/query.ts +2 -0
  61. package/src/parser/rules/block/module/listpages/types/variables.ts +5 -0
  62. package/src/parser/rules/block/module/listpages/url-resolution/fields.ts +2 -0
  63. package/src/parser/rules/block/module/listusers/resolve.ts +2 -1
  64. package/src/parser/rules/block/module/mapping.ts +2 -1
  65. package/src/parser/rules/block/module/rate/index.ts +16 -19
  66. package/src/parser/rules/block/module/rate/resolve.ts +108 -0
  67. package/src/parser/rules/block/module/rate/types.ts +6 -16
  68. package/src/parser/rules/block/module/resolution/resolve-async.ts +2 -1
  69. package/src/parser/rules/block/module/resolve.ts +10 -8
  70. package/src/parser/rules/block/module/types-common.ts +4 -0
  71. package/src/parser/rules/block/note/boundary.ts +88 -0
  72. package/src/parser/rules/block/note/index.ts +37 -0
  73. package/src/parser/rules/block/paragraph/index.ts +12 -4
  74. package/src/parser/rules/block/paragraph/normalize.ts +6 -1
  75. package/src/parser/rules/block/parsing/block-item.ts +1 -1
  76. package/src/parser/rules/block/parsing/content.ts +1 -1
  77. package/src/parser/rules/block/parsing/inline-content.ts +6 -3
  78. package/src/parser/rules/block/table/pipe/cell.ts +3 -1
  79. package/src/parser/rules/block/table-block/cell-content/index.ts +1 -1
  80. package/src/parser/rules/block/table-block/cell-content/segments.ts +5 -2
  81. package/src/parser/rules/block/table-block/cell-newline.ts +2 -1
  82. package/src/parser/rules/contracts/rule.ts +4 -2
  83. package/src/parser/rules/contracts/scope.ts +2 -0
  84. package/src/parser/rules/inline/anchor/child.ts +8 -2
  85. package/src/parser/rules/inline/anchor/content.ts +3 -1
  86. package/src/parser/rules/inline/anchor/index.ts +4 -1
  87. package/src/parser/rules/inline/anchor/newline.ts +2 -1
  88. package/src/parser/rules/inline/autolink.ts +153 -0
  89. package/src/parser/rules/inline/button/attributes.ts +17 -0
  90. package/src/parser/rules/inline/button/index.ts +17 -0
  91. package/src/parser/rules/inline/button/syntax.ts +56 -0
  92. package/src/parser/rules/inline/date/index.ts +17 -0
  93. package/src/parser/rules/inline/date/syntax.ts +46 -0
  94. package/src/parser/rules/inline/email/candidates.ts +134 -0
  95. package/src/parser/rules/inline/email/index.ts +36 -0
  96. package/src/parser/rules/inline/email/scan.ts +76 -0
  97. package/src/parser/rules/inline/expr/branch.ts +3 -1
  98. package/src/parser/rules/inline/footnote/boundary.ts +56 -0
  99. package/src/parser/rules/inline/footnote/content.ts +29 -41
  100. package/src/parser/rules/inline/footnote/elements.ts +9 -34
  101. package/src/parser/rules/inline/footnote/index.ts +4 -1
  102. package/src/parser/rules/inline/formatting/close.ts +12 -0
  103. package/src/parser/rules/inline/index.ts +14 -0
  104. package/src/parser/rules/inline/line-break/newline.ts +8 -1
  105. package/src/parser/rules/inline/link-bracket/direct-url.ts +11 -3
  106. package/src/parser/rules/inline/link-bracket/parsed.ts +9 -4
  107. package/src/parser/rules/inline/link-bracket/parts.ts +14 -36
  108. package/src/parser/rules/inline/link-bracket/special-target.ts +9 -0
  109. package/src/parser/rules/inline/link-single.ts +9 -7
  110. package/src/parser/rules/inline/link-triple/index.ts +1 -0
  111. package/src/parser/rules/inline/link-triple/label.ts +7 -1
  112. package/src/parser/rules/inline/parsing/automatic-line-break.ts +35 -0
  113. package/src/parser/rules/inline/parsing/block-boundary.ts +2 -0
  114. package/src/parser/rules/inline/parsing/block-start-predicates.ts +10 -0
  115. package/src/parser/rules/inline/parsing/inline-content.ts +46 -7
  116. package/src/parser/rules/inline/parsing/plain-text.ts +7 -2
  117. package/src/parser/rules/inline/parsing/preserved-line-break.ts +13 -0
  118. package/src/parser/rules/inline/parsing/raw-tag.ts +19 -0
  119. package/src/parser/rules/inline/raw/angle.ts +2 -1
  120. package/src/parser/rules/inline/raw/end.ts +21 -1
  121. package/src/parser/rules/inline/size/content.ts +32 -5
  122. package/src/parser/rules/inline/size/value.ts +11 -0
  123. package/src/parser/rules/inline/social/index.ts +17 -0
  124. package/src/parser/rules/inline/social/syntax.ts +40 -0
  125. package/src/parser/rules/inline/span/content.ts +3 -1
  126. package/src/parser/rules/inline/span/newline.ts +2 -1
  127. package/src/parser/rules/opaque-probe.ts +58 -0
  128. package/src/pipeline/process.ts +13 -0
  129. package/src/pipeline/types.ts +19 -0
  130. package/src/parser/rules/block/math/content.ts +0 -54
  131. package/src/parser/rules/block/math/name.ts +0 -35
  132. package/src/parser/rules/inline/footnote/child.ts +0 -22
  133. package/src/parser/rules/inline/footnote/newline.ts +0 -27
@@ -32,6 +32,11 @@ export function extractListPagesModule(
32
32
  contentSectionIndices: extraction.contentIndices,
33
33
  previewLengths: extraction.previewLengths,
34
34
  formFields: extraction.formFields,
35
+ metadataKeys: extraction.metadataKeys,
36
+ customRateKeys: extraction.customRateKeys,
37
+ needsReadableText: extraction.variables.some((name) =>
38
+ ["preview", "preview_n", "summary", "first_paragraph", "excerpt"].includes(name),
39
+ ),
35
40
  tagsLinkPrefix: extraction.tagsLinkPrefix,
36
41
  hiddenTagsLinkPrefix: extraction.hiddenTagsLinkPrefix,
37
42
  urlAttrPrefix: listPages["url-attr-prefix"],
@@ -21,6 +21,7 @@ export function buildQuery(module: ListPagesModuleData): ListPagesQuery {
21
21
  updatedAt: module["updated-at"],
22
22
  createdBy: module["created-by"],
23
23
  rating: module.rating,
24
+ ratingAxis: module["rating-axis"],
24
25
  votes: module.votes,
25
26
  name: module.name,
26
27
  fullname: module.fullname,
@@ -19,6 +19,8 @@ export interface TemplateExtraction {
19
19
  contentIndices: number[];
20
20
  previewLengths: number[];
21
21
  formFields: string[];
22
+ metadataKeys: string[];
23
+ customRateKeys: string[];
22
24
  tagsLinkPrefix?: string;
23
25
  hiddenTagsLinkPrefix?: string;
24
26
  }
@@ -31,6 +33,8 @@ export function extractVariablesFromTemplate(template: string): TemplateExtracti
31
33
  const contentIndices = new Set<number>();
32
34
  const previewLengths = new Set<number>();
33
35
  const formFields = new Set<string>();
36
+ const metadataKeys = new Set<string>();
37
+ const customRateKeys = new Set<string>();
34
38
  let tagsLinkPrefix: string | undefined;
35
39
  let hiddenTagsLinkPrefix: string | undefined;
36
40
 
@@ -39,6 +43,16 @@ export function extractVariablesFromTemplate(template: string): TemplateExtracti
39
43
 
40
44
  if (match.braceParam !== undefined) {
41
45
  switch (varName) {
46
+ case "metadata":
47
+ metadataKeys.add(match.braceParam);
48
+ variables.add("metadata");
49
+ continue;
50
+ case "customrate":
51
+ case "customrate_votes":
52
+ case "customrate_percent":
53
+ customRateKeys.add(match.braceParam);
54
+ variables.add(varName);
55
+ continue;
42
56
  case "content":
43
57
  contentIndices.add(Number(match.braceParam));
44
58
  variables.add("content_n");
@@ -90,6 +104,8 @@ export function extractVariablesFromTemplate(template: string): TemplateExtracti
90
104
  contentIndices: Array.from(contentIndices).sort((a, b) => a - b),
91
105
  previewLengths: Array.from(previewLengths).sort((a, b) => a - b),
92
106
  formFields: Array.from(formFields).sort(),
107
+ metadataKeys: [...metadataKeys].sort(),
108
+ customRateKeys: [...customRateKeys].sort(),
93
109
  tagsLinkPrefix,
94
110
  hiddenTagsLinkPrefix,
95
111
  };
@@ -29,6 +29,7 @@ const KNOWN_VARIABLES: ReadonlySet<ListPagesVariable> = new Set([
29
29
  "parent_title_linked",
30
30
  "content",
31
31
  "preview",
32
+ "excerpt",
32
33
  "summary",
33
34
  "first_paragraph",
34
35
  "tags",
@@ -33,6 +33,7 @@ export type {
33
33
  DataRequirements,
34
34
  UserInfo,
35
35
  PageData,
36
+ PageMetadataValue,
36
37
  SiteContext,
37
38
  ListPagesExternalData,
38
39
  ListPagesDataFetcher,
@@ -3,7 +3,7 @@ import type { NormalizedOrder, NormalizedParent, OrderDirection, OrderField } fr
3
3
  /**
4
4
  * Mapping from Wikidot's order field names to normalized OrderField values.
5
5
  */
6
- const ORDER_FIELD_MAP: Record<string, OrderField> = {
6
+ const ORDER_FIELD_MAP: Record<string, Exclude<OrderField, "metadata">> = {
7
7
  datecreated: "created_at",
8
8
  dateedited: "updated_at",
9
9
  title: "title",
@@ -12,6 +12,7 @@ const ORDER_FIELD_MAP: Record<string, OrderField> = {
12
12
  votes: "votes",
13
13
  revisions: "revisions",
14
14
  comments: "comments",
15
+ commented_at: "commented_at",
15
16
  pagelength: "size",
16
17
  size: "size",
17
18
  random: "random",
@@ -23,6 +24,13 @@ const ORDER_FIELD_MAP: Record<string, OrderField> = {
23
24
  * Parse order string into structured format.
24
25
  */
25
26
  export function parseOrder(value: string): NormalizedOrder {
27
+ const metadata = /^metadata\{([^}]*)\}(?:\s+(asc|desc))?$/i.exec(value.trim());
28
+ if (metadata)
29
+ return {
30
+ field: "metadata",
31
+ key: metadata[1]!,
32
+ direction: metadata[2]?.toLowerCase() === "asc" ? "asc" : "desc",
33
+ };
26
34
  const defaultOrder: NormalizedOrder = { field: "created_at", direction: "desc" };
27
35
  const trimmed = value.trim().toLowerCase();
28
36
  if (!trimmed) return defaultOrder;
@@ -36,6 +36,7 @@ export function normalizeQuery(query: ListPagesQuery): NormalizedListPagesQuery
36
36
  if (query.pagetype) result.pagetype = query.pagetype;
37
37
  if (query.linkTo) result.linkTo = query.linkTo;
38
38
  if (query.createdBy) result.createdBy = query.createdBy;
39
+ if (query.ratingAxis !== undefined) result.ratingAxis = query.ratingAxis;
39
40
  if (query.name) result.name = query.name;
40
41
  if (query.fullname) result.fullname = query.fullname;
41
42
  if (query.range) result.range = query.range;
@@ -78,6 +78,7 @@ export const listPagesModuleRule: ModuleRule = {
78
78
  "created-at": createdAt,
79
79
  "updated-at": updatedAt,
80
80
  rating,
81
+ "rating-axis": args["rating-axis"],
81
82
  votes,
82
83
  name,
83
84
  fullname,
@@ -23,6 +23,7 @@ import type { ListPagesExternalData, CompiledTemplate } from "./types";
23
23
  import type { ParseFunction } from "../types";
24
24
  import { renderListPagesItems } from "./resolution/items";
25
25
  import { wrapListPagesResult } from "./resolution/wrapper";
26
+ import { suppressModuleRatings } from "../rate/resolve";
26
27
  export type { ParseFunction };
27
28
 
28
29
  /**
@@ -55,6 +56,7 @@ export function resolveListPages(
55
56
  compiledTemplate: CompiledTemplate,
56
57
  parse: ParseFunction,
57
58
  ): Element[] {
58
- const items = renderListPagesItems(module, data, compiledTemplate, parse);
59
- return wrapListPagesResult(module, items, parse, data);
59
+ const parseFragment: ParseFunction = (source) => suppressModuleRatings(parse(source));
60
+ const items = renderListPagesItems(module, data, compiledTemplate, parseFragment);
61
+ return wrapListPagesResult(module, items, parseFragment, data);
60
62
  }
@@ -0,0 +1,63 @@
1
+ import type { TextExcerptOptions } from "@wdprlib/ast";
2
+
3
+ /** Find }%% outside quoted excerpt arguments, allowing regex quantifiers and literal %%. */
4
+ export function findExcerptEnd(template: string, contentStart: number): number {
5
+ let quote: string | undefined;
6
+ for (let cursor = contentStart + "excerpt{".length; cursor < template.length; cursor++) {
7
+ const char = template[cursor];
8
+ if (quote) {
9
+ if (char === "\\") cursor++;
10
+ else if (char === quote) quote = undefined;
11
+ } else if (char === '"' || char === "'") {
12
+ quote = char;
13
+ } else if (char === "}") {
14
+ return template.startsWith("%%", cursor + 1) ? cursor + 1 : -1;
15
+ }
16
+ }
17
+ return -1;
18
+ }
19
+
20
+ /** Excerpt uses quoted named arguments; regex backslashes stay literal. */
21
+ export function parseExcerptOptions(input: string): TextExcerptOptions | null {
22
+ if (input.length > 8_192) return null;
23
+ const attributes = new Map<string, string>();
24
+ let cursor = 0;
25
+ while (cursor < input.length) {
26
+ while (cursor < input.length && /\s/.test(input[cursor]!)) cursor++;
27
+ if (cursor === input.length) break;
28
+ const start = cursor;
29
+ while (cursor < input.length && /[a-z]/.test(input[cursor]!)) cursor++;
30
+ const name = input.slice(start, cursor);
31
+ if (!["pattern", "flags", "group", "match", "max"].includes(name) || attributes.has(name))
32
+ return null;
33
+ while (cursor < input.length && /\s/.test(input[cursor]!)) cursor++;
34
+ if (input[cursor++] !== "=") return null;
35
+ while (cursor < input.length && /\s/.test(input[cursor]!)) cursor++;
36
+ const quote = input[cursor++];
37
+ if (quote !== '"' && quote !== "'") return null;
38
+ let value = "";
39
+ while (cursor < input.length && input[cursor] !== quote) {
40
+ if (input[cursor] === "\\" && (input[cursor + 1] === quote || input[cursor + 1] === "\\")) {
41
+ value += input[cursor + 1] === quote ? quote : "\\\\";
42
+ cursor += 2;
43
+ } else value += input[cursor++];
44
+ }
45
+ if (input[cursor++] !== quote) return null;
46
+ if (cursor < input.length && !/\s/.test(input[cursor]!)) return null;
47
+ attributes.set(name, value);
48
+ }
49
+ const pattern = attributes.get("pattern");
50
+ if (pattern === undefined) return null;
51
+ const group = attributes.get("group");
52
+ const match = attributes.get("match");
53
+ const max = attributes.get("max");
54
+ if ((match !== undefined && !/^\d+$/.test(match)) || (max !== undefined && !/^\d+$/.test(max)))
55
+ return null;
56
+ return {
57
+ pattern,
58
+ flags: attributes.get("flags"),
59
+ group: group === undefined ? undefined : /^\d+$/.test(group) ? Number(group) : group,
60
+ match: match === undefined ? undefined : Number(match),
61
+ maxLength: max === undefined ? undefined : Number(max),
62
+ };
63
+ }
@@ -1,5 +1,3 @@
1
- import type { PageData } from "../../types";
2
-
3
1
  /**
4
2
  * Split content by ==== separators.
5
3
  *
@@ -9,33 +7,3 @@ import type { PageData } from "../../types";
9
7
  export function splitContentSections(content: string): string[] {
10
8
  return content.split(/^={4,}$/m).map((section) => section.trim());
11
9
  }
12
-
13
- /**
14
- * Extract a summary from page content.
15
- */
16
- export function getSummary(page: PageData): string {
17
- if (page.content) {
18
- const sections = splitContentSections(page.content);
19
- if (sections.length > 1) {
20
- return sections[0]?.trim() ?? "";
21
- }
22
- }
23
- return getFirstParagraph(page.content);
24
- }
25
-
26
- /**
27
- * Extract the first paragraph from wikitext content.
28
- */
29
- export function getFirstParagraph(content?: string): string {
30
- if (!content) return "";
31
-
32
- const stripped = content
33
- .replace(/^(\+{1,6}) (.*)/gm, "")
34
- .replace(/^\[\[toc(\s[^\]]+)?\]\]/gim, "")
35
- .replace(/^\[\[\/?div(\s[^\]]+)?\]\]/gim, "")
36
- .replace(/^\[\[\/?module(\s[^\]]+)?\]\]/gim, "")
37
- .trim();
38
-
39
- const paragraphs = stripped.split(/\n{2,}/);
40
- return paragraphs[0]?.trim() ?? "";
41
- }
@@ -1,4 +1,4 @@
1
1
  export { formatDate } from "./date";
2
- export { getFirstParagraph, getSummary, splitContentSections } from "./content";
2
+ export { splitContentSections } from "./content";
3
3
  export { formatTagsLinked } from "./tags";
4
4
  export { formatUserLinked } from "./user";
@@ -6,6 +6,10 @@ import {
6
6
  } from "./parameterized";
7
7
  import { SIMPLE_GETTERS } from "./simple";
8
8
  import type { VariableGetter } from "./types";
9
+ import { createRegisteredGetter } from "./registered";
10
+ import { compileTextExcerpt } from "@wdprlib/ast";
11
+ import { literalWikitext } from "../literal";
12
+ import { parseExcerptOptions } from "../excerpt";
9
13
 
10
14
  /**
11
15
  * Create a getter function for a specific template variable.
@@ -20,8 +24,18 @@ export function createVariableGetter(
20
24
  parenParam?: string,
21
25
  format?: string,
22
26
  ): VariableGetter {
27
+ if (name === "excerpt") {
28
+ const options = braceParam === undefined ? null : parseExcerptOptions(braceParam);
29
+ if (!options || parenParam !== undefined || format !== undefined) return () => "";
30
+ const extract = compileTextExcerpt(options);
31
+ return (ctx) => literalWikitext(extract(ctx.page.readableText ?? ""));
32
+ }
33
+ const registeredGetter =
34
+ braceParam !== undefined ? createRegisteredGetter(name, braceParam, format) : null;
35
+ if (registeredGetter) return registeredGetter;
23
36
  const braceGetter = braceParam !== undefined ? createBraceParamGetter(name, braceParam) : null;
24
37
  if (braceGetter) return braceGetter;
38
+ if (braceParam !== undefined) return () => "";
25
39
 
26
40
  const parenGetter = parenParam !== undefined ? createParenParamGetter(name, parenParam) : null;
27
41
  if (parenGetter) return parenGetter;
@@ -1,5 +1,7 @@
1
1
  import { formatDate, formatTagsLinked, splitContentSections } from "../format";
2
2
  import type { VariableGetter } from "./types";
3
+ import { excerptText } from "@wdprlib/ast";
4
+ import { literalWikitext } from "../literal";
3
5
 
4
6
  export function createBraceParamGetter(name: string, param: string): VariableGetter | null {
5
7
  switch (name) {
@@ -28,7 +30,7 @@ export function createParenParamGetter(name: string, param: string): VariableGet
28
30
  if (name !== "preview") return null;
29
31
 
30
32
  const len = Number(param);
31
- return (ctx) => (ctx.page.content ?? "").slice(0, len);
33
+ return (ctx) => literalWikitext(excerptText(ctx.page.readableText ?? "", { maxLength: len }));
32
34
  }
33
35
 
34
36
  export function createFormattedGetter(name: string, format: string): VariableGetter | null {
@@ -0,0 +1,52 @@
1
+ import { formatDate } from "../format";
2
+ import { literalWikitext } from "../literal";
3
+ import type { VariableGetter } from "./types";
4
+
5
+ /** Unavailable values stay empty; zero is an available aggregate. */
6
+ export function createRegisteredGetter(
7
+ name: string,
8
+ key: string,
9
+ format?: string,
10
+ ): VariableGetter | null {
11
+ if (name === "metadata")
12
+ return (ctx) => {
13
+ const metadata = ctx.page.metadata;
14
+ const entry = metadata && Object.hasOwn(metadata, key) ? metadata[key] : null;
15
+ if (!entry) return "";
16
+ let value: string;
17
+ switch (entry.type) {
18
+ case "text":
19
+ value = entry.value;
20
+ break;
21
+ case "number":
22
+ value = String(entry.value);
23
+ break;
24
+ case "date":
25
+ value = formatDate(entry.value, format);
26
+ break;
27
+ case "user":
28
+ value =
29
+ format === "id"
30
+ ? String(entry.value.id)
31
+ : format === "unix"
32
+ ? entry.value.unixName
33
+ : entry.value.name;
34
+ break;
35
+ }
36
+ return literalWikitext(value);
37
+ };
38
+ if (name === "customrate" || name === "customrate_votes" || name === "customrate_percent")
39
+ return (ctx) => {
40
+ const ratings = ctx.page.customRates;
41
+ const entry = ratings && Object.hasOwn(ratings, key) ? ratings[key] : null;
42
+ if (!entry) return "";
43
+ return String(
44
+ name === "customrate"
45
+ ? entry.points
46
+ : name === "customrate_votes"
47
+ ? entry.votes
48
+ : entry.percent,
49
+ );
50
+ };
51
+ return null;
52
+ }
@@ -1,4 +1,6 @@
1
- import { formatDate, formatUserLinked, getFirstParagraph, getSummary } from "../format";
1
+ import { formatDate, formatUserLinked } from "../format";
2
+ import { countCharacters, excerptText } from "@wdprlib/ast";
3
+ import { literalWikitext } from "../literal";
2
4
  import type { VariableGetter } from "./types";
3
5
 
4
6
  /** Default character count for `%%preview%%` when no length is specified (Wikidot default). */
@@ -38,16 +40,24 @@ export const SIMPLE_GETTERS: Record<string, VariableGetter> = {
38
40
  ctx.page.parentFullname ? `[[[${ctx.page.parentFullname} | ${ctx.page.parentTitle}]]]` : "",
39
41
 
40
42
  content: (ctx) => ctx.page.content ?? "",
41
- preview: (ctx) => (ctx.page.content ?? "").slice(0, DEFAULT_PREVIEW_LENGTH),
42
- summary: (ctx) => getSummary(ctx.page),
43
- first_paragraph: (ctx) => getFirstParagraph(ctx.page.content),
43
+ preview: (ctx) =>
44
+ literalWikitext(
45
+ excerptText(ctx.page.readableText ?? "", { maxLength: DEFAULT_PREVIEW_LENGTH }),
46
+ ),
47
+ summary: (ctx) => literalWikitext(ctx.page.firstParagraph ?? ""),
48
+ first_paragraph: (ctx) => literalWikitext(ctx.page.firstParagraph ?? ""),
44
49
 
45
50
  tags: (ctx) => ctx.page.tags.join(" "),
46
51
  _tags: (ctx) => ctx.page.hiddenTags.join(" "),
47
52
 
48
53
  children: (ctx) => String(ctx.page.children),
49
54
  comments: (ctx) => String(ctx.page.comments),
50
- size: (ctx) => String(ctx.page.size),
55
+ size: (ctx) =>
56
+ ctx.page.readableText !== undefined
57
+ ? String(countCharacters(ctx.page.readableText))
58
+ : ctx.page.size !== undefined
59
+ ? String(ctx.page.size)
60
+ : "",
51
61
  rating: (ctx) => String(ctx.page.rating),
52
62
  rating_votes: (ctx) => String(ctx.page.ratingVotes),
53
63
  rating_percent: (ctx) => String(ctx.page.ratingPercent ?? 0),
@@ -0,0 +1,6 @@
1
+ /** Protect text before secondary include expansion and wikitext parsing. */
2
+ export function literalWikitext(value: string): string {
3
+ if (value === "") return "";
4
+ // Encode delimiters and newlines too: a value cannot close this raw region.
5
+ return `@<${Array.from(value, (char) => `&#${char.codePointAt(0)};`).join("")}>@`;
6
+ }
@@ -1,3 +1,5 @@
1
+ import { findExcerptEnd } from "./excerpt";
2
+
1
3
  export interface TemplateVariableMatch {
2
4
  raw: string;
3
5
  index: number;
@@ -25,7 +27,10 @@ export function scanTemplateVariables(template: string): TemplateVariableMatch[]
25
27
  if (start === -1) break;
26
28
 
27
29
  const contentStart = start + 2;
28
- const end = template.indexOf("%%", contentStart);
30
+ const end =
31
+ template.slice(contentStart, contentStart + 8).toLowerCase() === "excerpt{"
32
+ ? findExcerptEnd(template, contentStart)
33
+ : template.indexOf("%%", contentStart);
29
34
  if (end === -1) break;
30
35
 
31
36
  const parsed = parseTemplateVariableContent(template.slice(contentStart, end));
@@ -53,6 +58,10 @@ function parseTemplateVariableContent(
53
58
  if (cursor === 0) return null;
54
59
 
55
60
  const name = content.slice(0, cursor);
61
+ if (name.toLowerCase() === "excerpt" && content[cursor] === "{") {
62
+ if (!content.endsWith("}")) return null;
63
+ return { name, braceParam: content.slice(cursor + 1, -1) };
64
+ }
56
65
  let braceParam: string | undefined;
57
66
  let parenParam: string | undefined;
58
67
  let format: string | undefined;
@@ -24,6 +24,12 @@ export interface ListPagesDataRequirement {
24
24
 
25
25
  /** Field names needed for form_data{field} etc. */
26
26
  formFields?: string[];
27
+ /** Exact metadata keys used by display templates. Also validate query.order on the host. */
28
+ metadataKeys?: string[];
29
+ /** Exact custom rating keys needed for display, independent of query.ratingAxis. */
30
+ customRateKeys?: string[];
31
+ /** Supply PageData.readableText and firstParagraph for previews, excerpts, and summaries. */
32
+ needsReadableText?: boolean;
27
33
 
28
34
  /** Prefix for tags_linked|prefix */
29
35
  tagsLinkPrefix?: string;
@@ -1,3 +1,12 @@
1
+ import type { RatingAggregate } from "@wdprlib/ast";
2
+
3
+ /** Values for host-registered metadata keys. Missing entries remain unavailable. */
4
+ export type PageMetadataValue =
5
+ | { type: "text"; value: string }
6
+ | { type: "number"; value: number }
7
+ | { type: "date"; value: Date }
8
+ | { type: "user"; value: UserInfo };
9
+
1
10
  /**
2
11
  * User information.
3
12
  */
@@ -11,6 +20,18 @@ export interface UserInfo {
11
20
  * Page data provided by an external source.
12
21
  */
13
22
  export interface PageData {
23
+ /**
24
+ * `%%metadata{key}%%`: only registered, readable entries; keys are case-sensitive.
25
+ * Values are literal text. Date values accept strftime; user format is name, |id or |unix.
26
+ * Tag change date/editor may be host-defined keys; WDPR reserves no such keys.
27
+ */
28
+ metadata?: Readonly<Record<string, PageMetadataValue | null>>;
29
+ /**
30
+ * `%%customrate{key}%%`, `%%customrate_votes{key}%%`, `%%customrate_percent{key}%%`.
31
+ * Supply readable aggregates for requirement.customRateKeys, just as formFields selects
32
+ * form data. Missing keys expand to empty text; a supplied zero remains zero.
33
+ */
34
+ customRates?: Readonly<Record<string, RatingAggregate | null>>;
14
35
  // Identity
15
36
  name: string;
16
37
  category: string;
@@ -37,6 +58,19 @@ export interface PageData {
37
58
 
38
59
  // Wikitext content split by ==== when resolving %%content{n}%%.
39
60
  content?: string;
61
+ /**
62
+ * Text extracted from this page's resolved AST for preview and
63
+ * `%%excerpt{pattern="..." group="1" match="2" max="200"}%%`.
64
+ * Values stay literal; raw content is never a fallback.
65
+ * The host owns extraction policy, dependency invalidation and recursion limits.
66
+ */
67
+ readableText?: string;
68
+ /**
69
+ * First nonempty paragraph from extractFirstParagraph, for summary and first_paragraph.
70
+ * Uses the same extraction policy as readableText. Empty when no readable paragraph
71
+ * exists; undefined when unavailable. Headings and appended footnotes are excluded.
72
+ */
73
+ firstParagraph?: string;
40
74
 
41
75
  // Tags
42
76
  tags: string[];
@@ -51,7 +85,8 @@ export interface PageData {
51
85
  // Metrics
52
86
  children: number;
53
87
  comments: number;
54
- size: number;
88
+ /** Materialized readable character count, computed with countCharacters. */
89
+ size?: number;
55
90
  rating: number;
56
91
  ratingVotes: number;
57
92
  ratingPercent?: number;
@@ -73,7 +108,6 @@ export function definePageData(input: PageDataInput): PageData {
73
108
  hiddenTags: input.hiddenTags ?? [],
74
109
  children: input.children ?? 0,
75
110
  comments: input.comments ?? 0,
76
- size: input.size ?? 0,
77
111
  rating: input.rating ?? 0,
78
112
  ratingVotes: input.ratingVotes ?? 0,
79
113
  revisions: input.revisions ?? 0,
@@ -32,6 +32,8 @@ export interface NormalizedCategory {
32
32
  export type OrderField =
33
33
  | "created_at"
34
34
  | "updated_at"
35
+ | "commented_at"
36
+ | "metadata"
35
37
  | "title"
36
38
  | "fullname"
37
39
  | "rating"
@@ -48,11 +50,13 @@ export type OrderDirection = "asc" | "desc";
48
50
 
49
51
  /**
50
52
  * Normalized order specification.
53
+ * Providers validate metadata registration, readability and sortability without falling back
54
+ * to another field. Sort before pagination with a stable page ID tie-breaker. For commented_at,
55
+ * put pages without comments last and use the same visible, non-deleted comments as the count.
51
56
  */
52
- export interface NormalizedOrder {
53
- field: OrderField;
54
- direction: OrderDirection;
55
- }
57
+ export type NormalizedOrder =
58
+ | { field: Exclude<OrderField, "metadata">; direction: OrderDirection }
59
+ | { field: "metadata"; key: string; direction: OrderDirection };
56
60
 
57
61
  /**
58
62
  * Normalized parent selector.
@@ -107,6 +111,12 @@ export interface NormalizedListPagesQuery {
107
111
  updatedAt?: NormalizedDateSelector;
108
112
  createdBy?: string;
109
113
  rating?: NormalizedNumericSelector;
114
+ /**
115
+ * `rating-axis` selects the custom aggregate for rating/votes filters and order; omission
116
+ * selects main. Verify registration and read access before filtering, counting or sorting.
117
+ * Unknown keys must not fall back to main. Display variables select their own keys.
118
+ */
119
+ ratingAxis?: string;
110
120
  votes?: NormalizedNumericSelector;
111
121
  name?: string;
112
122
  fullname?: string;
@@ -34,6 +34,8 @@ export interface ListPagesQuery {
34
34
 
35
35
  /** Rating selector. @untrusted User input. */
36
36
  rating?: string;
37
+ /** Exact registered custom key for rating/votes filters and order; omitted means main. */
38
+ ratingAxis?: string;
37
39
 
38
40
  /** Votes selector. @untrusted User input. */
39
41
  votes?: string;
@@ -2,6 +2,10 @@
2
2
  * All supported ListPages template variables.
3
3
  */
4
4
  export type ListPagesVariable =
5
+ | "metadata"
6
+ | "customrate"
7
+ | "customrate_votes"
8
+ | "customrate_percent"
5
9
  // Lifecycle - created
6
10
  | "created_at"
7
11
  | "created_by"
@@ -38,6 +42,7 @@ export type ListPagesVariable =
38
42
  | "content_n"
39
43
  | "preview"
40
44
  | "preview_n"
45
+ | "excerpt"
41
46
  | "summary"
42
47
  | "first_paragraph"
43
48
  // Tags
@@ -36,6 +36,7 @@ type StringUrlQueryKey = Extract<
36
36
  | "updatedAt"
37
37
  | "createdBy"
38
38
  | "rating"
39
+ | "ratingAxis"
39
40
  | "votes"
40
41
  >;
41
42
 
@@ -81,6 +82,7 @@ export const URL_RESOLVABLE_FIELDS: readonly UrlResolvableField[] = [
81
82
  aliases: ["createdby", "created_by"],
82
83
  },
83
84
  { attr: "rating", queryKey: "rating", type: "string" },
85
+ { attr: "rating-axis", queryKey: "ratingAxis", type: "string" },
84
86
  { attr: "votes", queryKey: "votes", type: "string" },
85
87
  { attr: "reverse", queryKey: "reverse", type: "boolean" },
86
88
  ];
@@ -16,6 +16,7 @@ import type {
16
16
  ListUsersVariableContext,
17
17
  } from "./types";
18
18
  import { getModuleParseAst, type ParseFunction } from "../types";
19
+ import { suppressModuleRatings } from "../rate/resolve";
19
20
 
20
21
  /**
21
22
  * Narrowed type for the list-users variant of the Module discriminated union.
@@ -53,6 +54,6 @@ export function resolveListUsers(
53
54
  ): Element[] {
54
55
  const ctx: ListUsersVariableContext = { user: data.user };
55
56
  const substituted = compiledTemplate(ctx);
56
- const itemAst = getModuleParseAst(parse(substituted));
57
+ const itemAst = getModuleParseAst(suppressModuleRatings(parse(substituted)));
57
58
  return itemAst.elements;
58
59
  }
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { ModuleRule } from "./types";
14
- import { rateModuleRule } from "./rate/index";
14
+ import { rateModuleRule, customRateModuleRule } from "./rate/index";
15
15
  import { cssModuleRule } from "./css/index";
16
16
  import { backlinksModuleRule } from "./backlinks/index";
17
17
  import { categoriesModuleRule } from "./categories/index";
@@ -29,6 +29,7 @@ import { tagCloudModuleRule } from "./tagcloud/parser";
29
29
  */
30
30
  export const MODULE_RULES: ModuleRule[] = [
31
31
  rateModuleRule,
32
+ customRateModuleRule,
32
33
  cssModuleRule,
33
34
  backlinksModuleRule,
34
35
  categoriesModuleRule,