@wdprlib/parser 4.4.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wdprlib/parser",
3
- "version": "4.4.0",
3
+ "version": "5.1.0",
4
4
  "description": "Parser for Wikidot markup",
5
5
  "keywords": [
6
6
  "ast",
@@ -41,6 +41,6 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@braintree/sanitize-url": "^7.1.1",
44
- "@wdprlib/ast": "2.4.0"
44
+ "@wdprlib/ast": "3.0.0"
45
45
  }
46
46
  }
package/src/index.ts CHANGED
@@ -185,6 +185,8 @@ export {
185
185
  parseParent,
186
186
  parseDateSelector,
187
187
  parseNumericSelector,
188
+ definePageData,
189
+ matchesListPagesSelectors,
188
190
  // ListUsers
189
191
  extractListUsersVariables,
190
192
  compileListUsersTemplate,
@@ -1,9 +1,13 @@
1
- import { DEFAULT_SETTINGS } from "@wdprlib/ast";
1
+ import { DEFAULT_SETTINGS, type PageRef } from "@wdprlib/ast";
2
2
  import type { Token } from "../../lexer";
3
3
  import { blockFallbackRule, blockRules, inlineRules, type ParseContext } from "../rules";
4
4
  import type { ParserOptions } from "./options";
5
5
 
6
- export function createParseContext(tokens: Token[], options: ParserOptions = {}): ParseContext {
6
+ export function createParseContext(
7
+ tokens: Token[],
8
+ options: ParserOptions = {},
9
+ deferInclude?: (location: PageRef) => boolean,
10
+ ): ParseContext {
7
11
  return {
8
12
  tokens,
9
13
  pos: 0,
@@ -11,6 +15,7 @@ export function createParseContext(tokens: Token[], options: ParserOptions = {})
11
15
  trackPositions: options.trackPositions ?? true,
12
16
  settings: options.settings ?? DEFAULT_SETTINGS,
13
17
  appendImplicitFootnoteBlock: options.appendImplicitFootnoteBlock ?? true,
18
+ deferInclude,
14
19
  footnotes: [],
15
20
  tocEntries: [],
16
21
  codeBlocks: [],
@@ -1,6 +1,6 @@
1
- import type { ParseResult } from "@wdprlib/ast";
1
+ import type { PageRef, ParseResult } from "@wdprlib/ast";
2
2
  import { tokenize } from "../../lexer";
3
- import { Parser } from "./parser";
3
+ import { Parser, parseTokensWithIncludeDeferral } from "./parser";
4
4
  import { parseLargePlainTextDocument, parsePlainNonAsciiDocument } from "./plain-non-ascii";
5
5
  import { prepareSourceForParse } from "./source";
6
6
  import type { ParserOptions } from "./options";
@@ -23,6 +23,22 @@ export { Parser } from "./parser";
23
23
  * @since 2.0.0
24
24
  */
25
25
  export function parse(source: string, options?: ParserOptions): ParseResult {
26
+ return parseSource(source, options);
27
+ }
28
+
29
+ export function parseWithIncludeDeferral(
30
+ source: string,
31
+ options: ParserOptions,
32
+ deferInclude: (location: PageRef) => boolean,
33
+ ): ParseResult {
34
+ return parseSource(source, options, deferInclude);
35
+ }
36
+
37
+ function parseSource(
38
+ source: string,
39
+ options?: ParserOptions,
40
+ deferInclude?: (location: PageRef) => boolean,
41
+ ): ParseResult {
26
42
  const plainResult = parsePlainNonAsciiDocument(source);
27
43
  if (plainResult) {
28
44
  return plainResult;
@@ -38,5 +54,7 @@ export function parse(source: string, options?: ParserOptions): ParseResult {
38
54
  trackPositions: options?.trackPositions,
39
55
  compactTextRuns: preprocessed.length >= COMPACT_TEXT_RUN_SOURCE_LENGTH,
40
56
  });
41
- return new Parser(tokens, options).parse();
57
+ return deferInclude
58
+ ? parseTokensWithIncludeDeferral(tokens, options ?? {}, deferInclude)
59
+ : new Parser(tokens, options).parse();
42
60
  }
@@ -1,4 +1,4 @@
1
- import type { Element, ParseResult } from "@wdprlib/ast";
1
+ import type { Element, PageRef, ParseResult } from "@wdprlib/ast";
2
2
  import type { Token } from "../../lexer";
3
3
  import type { ParseContext } from "../rules";
4
4
  import type { ParserOptions } from "./options";
@@ -33,26 +33,35 @@ export class Parser {
33
33
  * @since 2.0.0
34
34
  */
35
35
  parse(): ParseResult {
36
- const children: Element[] = [];
36
+ return parseContext(this.ctx);
37
+ }
38
+ }
37
39
 
38
- while (!this.isAtEnd()) {
39
- const blocks = this.parseBlock();
40
- children.push(...blocks);
41
- }
40
+ export function parseTokensWithIncludeDeferral(
41
+ tokens: Token[],
42
+ options: ParserOptions,
43
+ deferInclude: (location: PageRef) => boolean,
44
+ ): ParseResult {
45
+ return parseContext(createParseContext(tokens, options, deferInclude));
46
+ }
42
47
 
43
- return finalizeParseResult(this.ctx, children);
44
- }
48
+ function parseContext(ctx: ParseContext): ParseResult {
49
+ const children: Element[] = [];
45
50
 
46
- private isAtEnd(): boolean {
47
- return this.ctx.pos >= this.ctx.tokens.length || this.currentToken().type === "EOF";
51
+ while (!isAtEnd(ctx)) {
52
+ children.push(...parseBlock(ctx));
48
53
  }
49
54
 
50
- private currentToken(): Token {
51
- return this.ctx.tokens[this.ctx.pos] ?? this.eofToken();
52
- }
55
+ return finalizeParseResult(ctx, children);
56
+ }
53
57
 
54
- private eofToken(): Token {
55
- return {
58
+ function isAtEnd(ctx: ParseContext): boolean {
59
+ return ctx.pos >= ctx.tokens.length || currentToken(ctx).type === "EOF";
60
+ }
61
+
62
+ function currentToken(ctx: ParseContext): Token {
63
+ return (
64
+ ctx.tokens[ctx.pos] ?? {
56
65
  type: "EOF",
57
66
  value: "",
58
67
  position: {
@@ -60,20 +69,20 @@ export class Parser {
60
69
  end: { line: 0, column: 0, offset: 0 },
61
70
  },
62
71
  lineStart: false,
63
- };
64
- }
65
-
66
- private skipWhitespace(): void {
67
- while (this.currentToken().type === "WHITESPACE") {
68
- this.ctx.pos++;
69
72
  }
70
- }
73
+ );
74
+ }
71
75
 
72
- private parseBlock(): Element[] {
73
- return parseNextBlock(
74
- this.ctx,
75
- () => this.skipWhitespace(),
76
- () => this.isAtEnd(),
77
- );
76
+ function skipWhitespace(ctx: ParseContext): void {
77
+ while (currentToken(ctx).type === "WHITESPACE") {
78
+ ctx.pos++;
78
79
  }
79
80
  }
81
+
82
+ function parseBlock(ctx: ParseContext): Element[] {
83
+ return parseNextBlock(
84
+ ctx,
85
+ () => skipWhitespace(ctx),
86
+ () => isAtEnd(ctx),
87
+ );
88
+ }
@@ -7,8 +7,11 @@
7
7
  */
8
8
 
9
9
  import { makeUniqueSentinels, maskRawRegions, restorePlaceholders } from "../utils";
10
+ import { matchDirectiveKind } from "./kind";
10
11
  import { expandInnermost } from "./scan";
11
12
 
13
+ const MAX_EXPR_NESTING = 64;
14
+
12
15
  /**
13
16
  * Resolve every `[[#if]]` / `[[#ifexpr]]` / `[[#expr]]` that sits inside
14
17
  * another block's opener (depth > 0). Top-level directives are left for
@@ -21,6 +24,7 @@ export function preprocessExpr(source: string): string {
21
24
 
22
25
  const sentinels = makeUniqueSentinels(source);
23
26
  const { masked, placeholders } = maskRawRegions(source, sentinels);
27
+ if (exceedsExprNestingLimit(masked)) return source;
24
28
  const reduced = reduceExpr(masked);
25
29
  return restorePlaceholders(reduced, placeholders, sentinels);
26
30
  }
@@ -43,3 +47,25 @@ function reduceExpr(source: string): string {
43
47
  }
44
48
  return current;
45
49
  }
50
+
51
+ function exceedsExprNestingLimit(source: string): boolean {
52
+ const expressionStack: boolean[] = [];
53
+ let expressionDepth = 0;
54
+
55
+ for (let i = 0; i < source.length; i++) {
56
+ if (source.startsWith("[[", i)) {
57
+ const isExpression = matchDirectiveKind(source, i) !== null;
58
+ expressionStack.push(isExpression);
59
+ if (isExpression && ++expressionDepth > MAX_EXPR_NESTING) return true;
60
+ i++;
61
+ continue;
62
+ }
63
+
64
+ if (source.startsWith("]]", i)) {
65
+ if (expressionStack.pop()) expressionDepth--;
66
+ i++;
67
+ }
68
+ }
69
+
70
+ return false;
71
+ }
@@ -13,16 +13,25 @@ export interface Sentinels {
13
13
  * Choose sentinel strings that are guaranteed not to appear in `source`.
14
14
  * The placeholders we splice into the masked source have the form
15
15
  * `<open><digits><close>`, so the restore pass must not confuse them
16
- * with content. Extends both sentinel characters until neither appears.
16
+ * with content.
17
17
  */
18
18
  export function makeUniqueSentinels(source: string): Sentinels {
19
- let open = BASE_PLACEHOLDER_OPEN;
20
- let close = BASE_PLACEHOLDER_CLOSE;
21
- while (source.includes(open) || source.includes(close)) {
22
- open += BASE_PLACEHOLDER_OPEN;
23
- close += BASE_PLACEHOLDER_CLOSE;
19
+ let openRun = 0;
20
+ let closeRun = 0;
21
+ let longestOpenRun = 0;
22
+ let longestCloseRun = 0;
23
+
24
+ for (const char of source) {
25
+ openRun = char === BASE_PLACEHOLDER_OPEN ? openRun + 1 : 0;
26
+ closeRun = char === BASE_PLACEHOLDER_CLOSE ? closeRun + 1 : 0;
27
+ longestOpenRun = Math.max(longestOpenRun, openRun);
28
+ longestCloseRun = Math.max(longestCloseRun, closeRun);
24
29
  }
25
- return { open, close };
30
+
31
+ return {
32
+ open: BASE_PLACEHOLDER_OPEN.repeat(longestOpenRun + 1),
33
+ close: BASE_PLACEHOLDER_CLOSE.repeat(longestCloseRun + 1),
34
+ };
26
35
  }
27
36
 
28
37
  /**
@@ -1,4 +1,4 @@
1
- import type { Element } from "@wdprlib/ast";
1
+ import { lineBreak, paragraph, text, type Element } from "@wdprlib/ast";
2
2
  import type { BlockRule, ParseContext, RuleResult } from "../../types";
3
3
  import { currentToken } from "../../types";
4
4
  import { parseBlockName } from "../utils";
@@ -46,6 +46,7 @@ export const includeRule: BlockRule = {
46
46
  }
47
47
  pos++;
48
48
  consumed++;
49
+ const directiveEnd = pos;
49
50
 
50
51
  if (ctx.tokens[pos]?.type === "NEWLINE") {
51
52
  pos++;
@@ -56,6 +57,23 @@ export const includeRule: BlockRule = {
56
57
  return { success: false };
57
58
  }
58
59
 
60
+ const location = parsePageRef(args.target);
61
+ if (ctx.deferInclude?.(location)) {
62
+ // Keep Wikidot's parser extent here. Nested block markup in an include
63
+ // value must delay its closing brackets through variable expansion.
64
+ const source = ctx.tokens
65
+ .slice(ctx.pos, directiveEnd)
66
+ .map((token) => token.value)
67
+ .join("");
68
+ const elements: Element[] = [];
69
+ const lines = source.split("\n");
70
+ for (let index = 0; index < lines.length; index++) {
71
+ if (index > 0) elements.push(lineBreak());
72
+ if (lines[index] !== "") elements.push(text(lines[index]!));
73
+ }
74
+ return { success: true, elements: [paragraph(elements)], consumed };
75
+ }
76
+
59
77
  return {
60
78
  success: true,
61
79
  elements: [
@@ -64,7 +82,7 @@ export const includeRule: BlockRule = {
64
82
  data: {
65
83
  "paragraph-safe": false,
66
84
  variables: parseVariables(args.argumentTokens),
67
- location: parsePageRef(args.target),
85
+ location,
68
86
  elements: [],
69
87
  },
70
88
  },
@@ -15,6 +15,7 @@ import {
15
15
  import type {
16
16
  AsyncIncludeFetcher,
17
17
  IncludeFetcher,
18
+ IncludeReference,
18
19
  ResolveIncludesOptions,
19
20
  ResolveIncludesTraceResult,
20
21
  } from "./types";
@@ -113,6 +114,33 @@ export async function resolveIncludesAsyncWithTrace(
113
114
  return expandIterativeAsyncWithTrace(source, cachedFetcher, maxIterations);
114
115
  }
115
116
 
117
+ /**
118
+ * Resolve includes while leaving selected directives untouched for the
119
+ * high-level parser pipeline to render literally.
120
+ *
121
+ * This helper is intentionally not exported from the package barrel so the
122
+ * public low-level resolver contract remains unchanged.
123
+ */
124
+ export async function resolveIncludesAsyncWithTraceSelective(
125
+ source: string,
126
+ fetcher: AsyncIncludeFetcher,
127
+ shouldDefer: (reference: IncludeReference) => boolean,
128
+ options?: ResolveIncludesOptions,
129
+ ): Promise<ResolveIncludesTraceResult> {
130
+ if (options?.settings && !options.settings.enablePageSyntax) {
131
+ return {
132
+ source,
133
+ dependencies: [],
134
+ iterations: [],
135
+ reachedMaxIterations: false,
136
+ };
137
+ }
138
+
139
+ const maxIterations = options?.maxIterations ?? 10;
140
+ const cachedFetcher = createCachedAsyncIncludeFetcher(fetcher, normalizePageKey);
141
+ return expandIterativeAsyncWithTrace(source, cachedFetcher, maxIterations, shouldDefer);
142
+ }
143
+
116
144
  /**
117
145
  * Normalize a PageRef into a consistent string key for cache lookups.
118
146
  *
@@ -107,6 +107,7 @@ export async function expandIterativeAsyncWithTrace(
107
107
  source: string,
108
108
  fetcher: AsyncIncludeFetcher,
109
109
  maxIterations: number,
110
+ shouldDefer?: (reference: IncludeReference) => boolean,
110
111
  ): Promise<ResolveIncludesTraceResult> {
111
112
  let current = source;
112
113
  const replacementCache = new Map<string, Promise<string>>();
@@ -114,7 +115,13 @@ export async function expandIterativeAsyncWithTrace(
114
115
  const iterations: ResolveIncludesTraceResult["iterations"] = [];
115
116
 
116
117
  for (let i = 0; i < maxIterations; i++) {
117
- const expanded = await expandOneIterationAsyncWithTrace(current, fetcher, replacementCache, i);
118
+ const expanded = await expandOneIterationAsyncWithTrace(
119
+ current,
120
+ fetcher,
121
+ replacementCache,
122
+ i,
123
+ shouldDefer,
124
+ );
118
125
  if (expanded === null) break;
119
126
 
120
127
  dependencies.push(...expanded.dependencies);
@@ -133,7 +140,7 @@ export async function expandIterativeAsyncWithTrace(
133
140
  dependencies,
134
141
  iterations,
135
142
  reachedMaxIterations:
136
- iterations.length === maxIterations && scanIncludeDirectives(current).length > 0,
143
+ iterations.length === maxIterations && hasResolvableDirectives(current, shouldDefer),
137
144
  };
138
145
  }
139
146
 
@@ -219,6 +226,7 @@ async function expandOneIterationAsyncWithTrace(
219
226
  fetcher: AsyncIncludeFetcher,
220
227
  replacementCache: Map<string, Promise<string>>,
221
228
  iteration: number,
229
+ shouldDefer?: (reference: IncludeReference) => boolean,
222
230
  ): Promise<{
223
231
  source: string;
224
232
  references: IncludeReference[];
@@ -230,8 +238,13 @@ async function expandOneIterationAsyncWithTrace(
230
238
  if (directives.length === 0) return null;
231
239
 
232
240
  const references = directives.map(createIncludeReference);
241
+ const deferred = references.map((reference) => shouldDefer?.(reference) ?? false);
233
242
  const replacements = await Promise.all(
234
- directives.map(({ inner }) => replaceCachedAsync(inner, fetcher, replacementCache)),
243
+ directives.map((directive, index) =>
244
+ deferred[index]
245
+ ? Promise.resolve(source.slice(directive.start, directive.end))
246
+ : replaceCachedAsync(directive.inner, fetcher, replacementCache),
247
+ ),
235
248
  );
236
249
  const parts: string[] = [];
237
250
  let lastPos = 0;
@@ -246,10 +259,21 @@ async function expandOneIterationAsyncWithTrace(
246
259
  return {
247
260
  source: parts.join(""),
248
261
  references,
249
- dependencies: references.map((reference) => ({ ...reference, iteration })),
262
+ dependencies: references
263
+ .filter((_, index) => !deferred[index])
264
+ .map((reference) => ({ ...reference, iteration })),
250
265
  };
251
266
  }
252
267
 
268
+ function hasResolvableDirectives(
269
+ source: string,
270
+ shouldDefer: ((reference: IncludeReference) => boolean) | undefined,
271
+ ): boolean {
272
+ const directives = scanIncludeDirectives(source);
273
+ if (!shouldDefer) return directives.length > 0;
274
+ return directives.some((directive) => !shouldDefer(createIncludeReference(directive)));
275
+ }
276
+
253
277
  function replaceCached(inner: string, fetcher: IncludeFetcher, cache: Map<string, string>): string {
254
278
  const cached = cache.get(inner);
255
279
  if (cached !== undefined) return cached;
@@ -40,6 +40,8 @@ export function scanIncludeDirectives(source: string): IncludeDirectiveMatch[] {
40
40
 
41
41
  let depth = 0;
42
42
  let linkDepth = 0;
43
+ const nestedQuotes: Array<number | null> = [];
44
+ const nestedQuoteAllowed: boolean[] = [];
43
45
  let i = start;
44
46
  let closeEnd = -1;
45
47
 
@@ -48,7 +50,37 @@ export function scanIncludeDirectives(source: string): IncludeDirectiveMatch[] {
48
50
  const next = source.charCodeAt(i + 1);
49
51
  const nextNext = source.charCodeAt(i + 2);
50
52
 
51
- if (ch === OPEN_BRACKET && next === OPEN_BRACKET && nextNext === OPEN_BRACKET) {
53
+ if (ch === 10 || ch === 13) {
54
+ linkDepth = 0;
55
+ if (depth > 1) {
56
+ depth = 1;
57
+ nestedQuotes.length = 0;
58
+ nestedQuoteAllowed.length = 0;
59
+ }
60
+ i++;
61
+ continue;
62
+ }
63
+
64
+ const nestedQuote = nestedQuotes.at(-1);
65
+ if (nestedQuote !== undefined && nestedQuote !== null) {
66
+ if (ch === 92) {
67
+ i += 2;
68
+ } else {
69
+ if (ch === nestedQuote) {
70
+ nestedQuotes[nestedQuotes.length - 1] = null;
71
+ nestedQuoteAllowed[nestedQuoteAllowed.length - 1] = false;
72
+ }
73
+ i++;
74
+ }
75
+ } else if (
76
+ nestedQuote === null &&
77
+ nestedQuoteAllowed.at(-1) === true &&
78
+ (ch === 34 || ch === 39)
79
+ ) {
80
+ nestedQuotes[nestedQuotes.length - 1] = ch;
81
+ nestedQuoteAllowed[nestedQuoteAllowed.length - 1] = false;
82
+ i++;
83
+ } else if (ch === OPEN_BRACKET && next === OPEN_BRACKET && nextNext === OPEN_BRACKET) {
52
84
  linkDepth++;
53
85
  i += 3;
54
86
  } else if (
@@ -62,10 +94,28 @@ export function scanIncludeDirectives(source: string): IncludeDirectiveMatch[] {
62
94
  } else if (linkDepth > 0) {
63
95
  i++;
64
96
  } else if (ch === OPEN_BRACKET && next === OPEN_BRACKET) {
97
+ if (nestedQuoteAllowed.length > 0) {
98
+ nestedQuoteAllowed[nestedQuoteAllowed.length - 1] = false;
99
+ }
100
+ if (depth > 0) {
101
+ nestedQuotes.push(null);
102
+ nestedQuoteAllowed.push(false);
103
+ }
65
104
  depth++;
66
105
  i += 2;
106
+ } else if (ch === CLOSE_BRACKET && next !== CLOSE_BRACKET && depth > 1) {
107
+ // A lone `]` is the first close of an intentionally incomplete nested
108
+ // token (for example `[[module ...]` passed through an include value).
109
+ depth--;
110
+ nestedQuotes.pop();
111
+ nestedQuoteAllowed.pop();
112
+ i++;
67
113
  } else if (ch === CLOSE_BRACKET && next === CLOSE_BRACKET) {
68
114
  const closeStart = i;
115
+ if (depth > 1) {
116
+ nestedQuotes.pop();
117
+ nestedQuoteAllowed.pop();
118
+ }
69
119
  depth--;
70
120
  i += 2;
71
121
 
@@ -83,6 +133,13 @@ export function scanIncludeDirectives(source: string): IncludeDirectiveMatch[] {
83
133
  }
84
134
  }
85
135
  } else {
136
+ if (nestedQuoteAllowed.length > 0) {
137
+ if (ch === 61) {
138
+ nestedQuoteAllowed[nestedQuoteAllowed.length - 1] = true;
139
+ } else if (ch !== 32 && ch !== 9) {
140
+ nestedQuoteAllowed[nestedQuoteAllowed.length - 1] = false;
141
+ }
142
+ }
86
143
  i++;
87
144
  }
88
145
  }
@@ -82,6 +82,8 @@ export {
82
82
  parseParent,
83
83
  parseDateSelector,
84
84
  parseNumericSelector,
85
+ definePageData,
86
+ matchesListPagesSelectors,
85
87
  } from "./listpages";
86
88
 
87
89
  // IfTags module
@@ -59,6 +59,8 @@ export { extractDataRequirements } from "./extract";
59
59
  // Resolution
60
60
  export type { ParseFunction, ListPagesModuleData } from "./resolve";
61
61
  export { isListPagesModule, resolveListPages } from "./resolve";
62
+ export { definePageData } from "./types/external-data";
63
+ export { matchesListPagesSelectors } from "./selectors";
62
64
 
63
65
  // Compiler
64
66
  export { compileTemplate } from "./compiler";
@@ -0,0 +1,64 @@
1
+ import type { PageData } from "./types/external-data";
2
+ import type {
3
+ NormalizedCategory,
4
+ NormalizedListPagesQuery,
5
+ NormalizedTags,
6
+ } from "./types/normalized-query";
7
+
8
+ type SelectorPage = Pick<PageData, "category" | "tags" | "hiddenTags">;
9
+ type CurrentPage = { category: string; tags: readonly string[] };
10
+
11
+ export function matchesListPagesSelectors(
12
+ page: SelectorPage,
13
+ query: Pick<NormalizedListPagesQuery, "category" | "tags">,
14
+ currentPage: CurrentPage,
15
+ ): boolean {
16
+ return (
17
+ matchesCategory(page.category, query.category, currentPage.category) &&
18
+ matchesTags(page, query.tags, currentPage)
19
+ );
20
+ }
21
+
22
+ function matchesCategory(
23
+ category: string,
24
+ selector: NormalizedCategory | undefined,
25
+ currentCategory: string,
26
+ ): boolean {
27
+ if (!selector) return category === currentCategory;
28
+ if (selector.exclude.includes(category)) return false;
29
+
30
+ const hasPositiveSelector = selector.all || selector.current || selector.include.length > 0;
31
+ return (
32
+ !hasPositiveSelector ||
33
+ selector.all ||
34
+ (selector.current && category === currentCategory) ||
35
+ selector.include.includes(category)
36
+ );
37
+ }
38
+
39
+ function matchesTags(
40
+ page: Pick<SelectorPage, "tags" | "hiddenTags">,
41
+ selector: NormalizedTags | undefined,
42
+ currentPage: Pick<CurrentPage, "tags">,
43
+ ): boolean {
44
+ if (!selector) return true;
45
+
46
+ const allTags = new Set([...page.tags, ...page.hiddenTags]);
47
+ if (selector.all.some((tag) => !allTags.has(tag))) return false;
48
+ if (selector.any.length > 0 && !selector.any.some((tag) => allTags.has(tag))) return false;
49
+ if (selector.none.some((tag) => allTags.has(tag))) return false;
50
+
51
+ if (selector.special === "none") return allTags.size === 0;
52
+ const currentTags = new Set(currentPage.tags.filter((tag) => !tag.startsWith("_")));
53
+ if (selector.special === "same-visible") {
54
+ return page.tags.some((tag) => currentTags.has(tag));
55
+ }
56
+ if (selector.special === "same-all") {
57
+ return setsEqual(new Set(page.tags), currentTags);
58
+ }
59
+ return true;
60
+ }
61
+
62
+ function setsEqual(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
63
+ return left.size === right.size && [...left].every((value) => right.has(value));
64
+ }
@@ -58,6 +58,28 @@ export interface PageData {
58
58
  revisions: number;
59
59
  }
60
60
 
61
+ type PageDataInput = Pick<PageData, "fullname" | "title" | "createdAt" | "updatedAt" | "tags"> &
62
+ Partial<Omit<PageData, "fullname" | "title" | "createdAt" | "updatedAt" | "tags">>;
63
+
64
+ export function definePageData(input: PageDataInput): PageData {
65
+ const separator = input.fullname.indexOf(":");
66
+ const category = separator === -1 ? "_default" : input.fullname.slice(0, separator);
67
+ const name = separator === -1 ? input.fullname : input.fullname.slice(separator + 1);
68
+
69
+ return {
70
+ ...input,
71
+ name: input.name ?? name,
72
+ category: input.category ?? category,
73
+ hiddenTags: input.hiddenTags ?? [],
74
+ children: input.children ?? 0,
75
+ comments: input.comments ?? 0,
76
+ size: input.size ?? 0,
77
+ rating: input.rating ?? 0,
78
+ ratingVotes: input.ratingVotes ?? 0,
79
+ revisions: input.revisions ?? 0,
80
+ };
81
+ }
82
+
61
83
  /**
62
84
  * Site context information.
63
85
  */
@@ -3,6 +3,7 @@ import type {
3
3
  CodeBlockData,
4
4
  Diagnostic,
5
5
  Element,
6
+ PageRef,
6
7
  TocEntry,
7
8
  Version,
8
9
  WikitextSettings,
@@ -26,6 +27,7 @@ export interface ParseContext {
26
27
  trackPositions: boolean;
27
28
  settings: WikitextSettings;
28
29
  appendImplicitFootnoteBlock: boolean;
30
+ deferInclude?: (location: PageRef) => boolean;
29
31
  footnotes: Element[][];
30
32
  tocEntries: TocEntry[];
31
33
  codeBlocks: CodeBlockData[];