@wdprlib/parser 5.1.6 → 5.3.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 (61) hide show
  1. package/dist/index.cjs +764 -452
  2. package/dist/index.d.cts +24 -4
  3. package/dist/index.d.ts +24 -4
  4. package/dist/index.js +762 -452
  5. package/package.json +2 -2
  6. package/src/build-info.generated.ts +11 -0
  7. package/src/index.ts +2 -0
  8. package/src/lexer/lexer.ts +6 -4
  9. package/src/lexer/punctuation.ts +2 -1
  10. package/src/lexer/spacing-actions.ts +15 -0
  11. package/src/lexer/state.ts +30 -4
  12. package/src/lexer/token-factory.ts +15 -2
  13. package/src/lexer/tokens.ts +4 -3
  14. package/src/parser/rules/block/blockquote/build.ts +81 -37
  15. package/src/parser/rules/block/blockquote/index.ts +5 -6
  16. package/src/parser/rules/block/blockquote/line.ts +11 -10
  17. package/src/parser/rules/block/blockquote/lines.ts +102 -4
  18. package/src/parser/rules/block/code/content.ts +0 -3
  19. package/src/parser/rules/block/code/index.ts +14 -10
  20. package/src/parser/rules/block/definition-list/index.ts +4 -10
  21. package/src/parser/rules/block/definition-list/item-value.ts +2 -11
  22. package/src/parser/rules/block/definition-list/items.ts +1 -2
  23. package/src/parser/rules/block/embed-block/index.ts +30 -30
  24. package/src/parser/rules/block/heading/index.ts +7 -0
  25. package/src/parser/rules/block/module/listpages/resolution/items.ts +2 -2
  26. package/src/parser/rules/block/module/listpages/resolution/pager.ts +43 -0
  27. package/src/parser/rules/block/module/listpages/resolution/wrapper.ts +6 -1
  28. package/src/parser/rules/block/module/listpages/resolve.ts +1 -1
  29. package/src/parser/rules/block/module/listpages/types/external-data.ts +13 -0
  30. package/src/parser/rules/block/module/listpages/url-resolution/params.ts +5 -4
  31. package/src/parser/rules/block/module/resolution/data-maps.ts +75 -3
  32. package/src/parser/rules/block/module/resolution/resolve-async.ts +12 -3
  33. package/src/parser/rules/block/paragraph/index.ts +41 -10
  34. package/src/parser/rules/block/parsing/content.ts +18 -1
  35. package/src/parser/rules/block/table/pipe/cell.ts +36 -1
  36. package/src/parser/rules/block/table/pipe/row.ts +21 -1
  37. package/src/parser/rules/block/toc/element.ts +2 -2
  38. package/src/parser/rules/block/toc/index.ts +2 -2
  39. package/src/parser/rules/block/toc/open.ts +5 -18
  40. package/src/parser/rules/contracts/scope.ts +4 -0
  41. package/src/parser/rules/inline/bold.ts +5 -5
  42. package/src/parser/rules/inline/color/syntax.ts +5 -8
  43. package/src/parser/rules/inline/formatting/close.ts +39 -0
  44. package/src/parser/rules/inline/formatting/container.ts +7 -4
  45. package/src/parser/rules/inline/index.ts +2 -0
  46. package/src/parser/rules/inline/italic.ts +5 -5
  47. package/src/parser/rules/inline/monospace.ts +5 -5
  48. package/src/parser/rules/inline/parsing/block-start-predicates.ts +2 -0
  49. package/src/parser/rules/inline/parsing/inline-content.ts +58 -4
  50. package/src/parser/rules/inline/raw/end.ts +28 -0
  51. package/src/parser/rules/inline/span/content.ts +32 -1
  52. package/src/parser/rules/inline/strikethrough/index.ts +1 -1
  53. package/src/parser/rules/inline/strikethrough/parse.ts +2 -9
  54. package/src/parser/rules/inline/strikethrough/syntax.ts +3 -20
  55. package/src/parser/rules/inline/subscript.ts +5 -5
  56. package/src/parser/rules/inline/superscript.ts +5 -5
  57. package/src/parser/rules/inline/underline/index.ts +5 -78
  58. package/src/parser/rules/tokens.ts +6 -15
  59. package/src/pipeline/process.ts +8 -1
  60. package/src/parser/rules/inline/underline/child.ts +0 -26
  61. package/src/parser/rules/inline/underline/content.ts +0 -29
@@ -27,17 +27,8 @@ export function parseDefinitionItemValue(
27
27
  }
28
28
 
29
29
  if (token.type === "NEWLINE") {
30
- const nextToken = ctx.tokens[pos + 1];
31
- if (nextToken?.type === "COLON" && nextToken.lineStart) {
32
- pos++;
33
- consumed++;
34
- break;
35
- }
36
- if (nextToken?.type === "NEWLINE" || !nextToken || nextToken.type === "EOF") {
37
- pos++;
38
- consumed++;
39
- break;
40
- }
30
+ consumed++;
31
+ break;
41
32
  }
42
33
 
43
34
  const inlineCtx: ParseContext = { ...ctx, pos };
@@ -21,8 +21,7 @@ export interface ParsedDefinitionItem {
21
21
  *
22
22
  * The function expects `startPos` to point at a line-start COLON token.
23
23
  * It consumes the first colon, mandatory whitespace, key tokens up to
24
- * the second colon, then value tokens until a double newline, a new entry,
25
- * or end of input.
24
+ * the second colon, then value tokens until a newline or end of input.
26
25
  */
27
26
  export function parseDefinitionItem(
28
27
  ctx: ParseContext,
@@ -8,9 +8,6 @@
8
8
  * between the tags is stored verbatim as an `embed-block` element. Validation
9
9
  * and sanitisation are expected to happen at rendering time or on the server.
10
10
  *
11
- * The embed block is wrapped in a paragraph container in the AST, matching
12
- * Wikidot's rendering behaviour where embeds sit inside `<p>` tags.
13
- *
14
11
  * If no closing tag is found, the rule fails to prevent consuming the rest
15
12
  * of the document.
16
13
  *
@@ -18,6 +15,8 @@
18
15
  */
19
16
  import type { Element } from "@wdprlib/ast";
20
17
  import type { BlockRule, ParseContext, RuleResult } from "../../types";
18
+ import { parseInlineUntil } from "../../inline/utils";
19
+ import { normalizeParagraphElements } from "../paragraph/normalize";
21
20
  import { currentToken } from "../../types";
22
21
  import { collectEmbedContent, consumeEmbedClose } from "./content";
23
22
  import { parseEmbedBlockOpen } from "./open";
@@ -34,6 +33,8 @@ export const embedBlockRule: BlockRule = {
34
33
  name: "embed-block",
35
34
  startTokens: ["BLOCK_OPEN"],
36
35
  requiresLineStart: false,
36
+ preservesPrecedingLineBreak: true,
37
+ isStartPattern: (ctx, pos) => parseEmbedBlockOpen(ctx, pos) !== null,
37
38
 
38
39
  parse(ctx: ParseContext): RuleResult<Element> {
39
40
  const openToken = currentToken(ctx);
@@ -53,12 +54,17 @@ export const embedBlockRule: BlockRule = {
53
54
  consumed += contentResult.consumed;
54
55
 
55
56
  if (!contentResult.foundClose) {
56
- ctx.diagnostics.push({
57
- severity: "warning",
58
- code: "unclosed-block",
59
- message: `Missing closing tag [[/${openResult.blockName}]] for [[${openResult.blockName}]]`,
60
- position: openToken.position,
61
- });
57
+ if (
58
+ !ctx.diagnostics.some(
59
+ (d) => d.code === "unclosed-block" && d.position === openToken.position,
60
+ )
61
+ )
62
+ ctx.diagnostics.push({
63
+ severity: "warning",
64
+ code: "unclosed-block",
65
+ message: `Missing closing tag [[/${openResult.blockName}]] for [[${openResult.blockName}]]`,
66
+ position: openToken.position,
67
+ });
62
68
  return { success: false };
63
69
  }
64
70
 
@@ -66,26 +72,20 @@ export const embedBlockRule: BlockRule = {
66
72
  pos += closeConsumed;
67
73
  consumed += closeConsumed;
68
74
 
69
- return {
70
- success: true,
71
- elements: [
72
- {
73
- element: "container",
74
- data: {
75
- type: "paragraph",
76
- attributes: {},
77
- elements: [
78
- {
79
- element: "embed-block",
80
- data: {
81
- contents: contentResult.contents.trim(),
82
- },
83
- },
84
- ],
85
- },
86
- },
87
- ],
88
- consumed,
89
- };
75
+ const elements: Element[] = [
76
+ { element: "embed-block", data: { contents: contentResult.contents.trim() } },
77
+ ];
78
+ if (
79
+ ctx.scope.inlineEnd === undefined &&
80
+ ctx.tokens[pos]?.type !== "NEWLINE" &&
81
+ ctx.tokens[pos]?.type !== "EOF"
82
+ ) {
83
+ const after = parseInlineUntil({ ...ctx, pos }, "PARAGRAPH_BREAK");
84
+ const tail = normalizeParagraphElements(after.elements);
85
+ if (tail[0]?.element === "text") tail[0].data = tail[0].data.trimStart();
86
+ elements.push(...tail);
87
+ consumed += after.consumed;
88
+ }
89
+ return { success: true, elements, consumed };
90
90
  },
91
91
  };
@@ -55,6 +55,13 @@ export const headingRule: BlockRule = {
55
55
  const inlineCtx: ParseContext = { ...ctx, pos };
56
56
  const inlineResult = parseInlineUntil(inlineCtx, "NEWLINE");
57
57
  const children: Element[] = inlineResult.elements;
58
+ while (children.at(-1)?.element === "text") {
59
+ const last = children.at(-1)!;
60
+ if (last.element !== "text") break;
61
+ last.data = last.data.trimEnd();
62
+ if (last.data) break;
63
+ children.pop();
64
+ }
58
65
  consumed += inlineResult.consumed;
59
66
  pos += inlineResult.consumed;
60
67
 
@@ -17,9 +17,9 @@ export function renderListPagesItems(
17
17
 
18
18
  const ctx: VariableContext = {
19
19
  page,
20
- index: i + 1,
20
+ index: (data.pagination?.offset ?? 0) + i + 1,
21
21
  total: data.totalCount,
22
- limit: module.limit,
22
+ limit: data.pagination ? data.pagination.limit : module.limit,
23
23
  site: data.site,
24
24
  };
25
25
 
@@ -0,0 +1,43 @@
1
+ import type { Element } from "@wdprlib/ast";
2
+ import type { ListPagesExternalData } from "../types";
3
+
4
+ export function createListPagesPager(data: ListPagesExternalData): Element[] {
5
+ const pagination = data.pagination;
6
+ if (!pagination || pagination.totalPages <= 1) return [];
7
+ const { currentPage, totalPages, urlPath, parameter } = pagination;
8
+ const pages = new Set([1, totalPages]);
9
+ for (
10
+ let page = Math.max(1, currentPage - 2);
11
+ page <= Math.min(totalPages, currentPage + 2);
12
+ page++
13
+ ) {
14
+ pages.add(page);
15
+ }
16
+ return [
17
+ {
18
+ element: "pager",
19
+ data: {
20
+ currentPage,
21
+ totalPages,
22
+ pages: [...pages]
23
+ .sort((a, b) => a - b)
24
+ .map((page) => ({
25
+ page,
26
+ href: pageUrl(urlPath, parameter, page),
27
+ })),
28
+ },
29
+ },
30
+ ];
31
+ }
32
+
33
+ function pageUrl(urlPath: string, parameter: string, page: number): string {
34
+ const suffixStart = urlPath.search(/[?#]/);
35
+ const path = suffixStart === -1 ? urlPath : urlPath.slice(0, suffixStart);
36
+ const suffix = suffixStart === -1 ? "" : urlPath.slice(suffixStart);
37
+ const parts = path.split("/");
38
+ const result = parts.slice(0, 2);
39
+ for (let index = 2; index < parts.length; index += 2) {
40
+ if (parts[index] !== parameter) result.push(...parts.slice(index, index + 2));
41
+ }
42
+ return `${result.join("/").replace(/\/$/, "")}/${parameter}/${page}${suffix}`;
43
+ }
@@ -1,13 +1,16 @@
1
1
  import type { Element } from "@wdprlib/ast";
2
2
  import { getModuleParseAst, type ParseFunction } from "../../types";
3
3
  import type { ListPagesModuleData } from "../resolve";
4
+ import type { ListPagesExternalData } from "../types";
5
+ import { createListPagesPager } from "./pager";
4
6
 
5
7
  export function wrapListPagesResult(
6
8
  module: ListPagesModuleData,
7
9
  items: Element[],
8
10
  parse: ParseFunction,
11
+ data?: ListPagesExternalData,
9
12
  ): Element[] {
10
- if (items.length === 0) {
13
+ if (items.length === 0 && (!data || data.pages.length === 0)) {
11
14
  return [];
12
15
  }
13
16
 
@@ -25,6 +28,8 @@ export function wrapListPagesResult(
25
28
  result.push(...appendAst.elements);
26
29
  }
27
30
 
31
+ if (data) result.push(...createListPagesPager(data));
32
+
28
33
  if (module.wrapper) {
29
34
  return [
30
35
  {
@@ -56,5 +56,5 @@ export function resolveListPages(
56
56
  parse: ParseFunction,
57
57
  ): Element[] {
58
58
  const items = renderListPagesItems(module, data, compiledTemplate, parse);
59
- return wrapListPagesResult(module, items, parse);
59
+ return wrapListPagesResult(module, items, parse, data);
60
60
  }
@@ -94,6 +94,19 @@ export interface SiteContext {
94
94
  */
95
95
  export interface ListPagesExternalData {
96
96
  pages: PageData[];
97
+ /** Matching items before offset and limit; use the same visibility filters as pages. */
97
98
  totalCount: number;
98
99
  site: SiteContext;
100
+ /** Filled by module resolution when a page URL is available. */
101
+ pagination?: {
102
+ currentPage: number;
103
+ perPage: number;
104
+ totalPages: number;
105
+ /** Actual fetch offset, including the module's starting offset. */
106
+ offset: number;
107
+ /** Total item limit after URL resolution, not the fetch batch size. */
108
+ limit?: number;
109
+ urlPath: string;
110
+ parameter: string;
111
+ };
99
112
  }
@@ -2,12 +2,13 @@ import { URL_RESOLVABLE_FIELDS } from "./fields";
2
2
 
3
3
  /**
4
4
  * Parse URL path parameters like /offset/1/page2_limit/1.
5
- * Returns a map of parameter name -> value.
5
+ * Returns a map of parameter name -> value. Set hasPageName for a full page path;
6
+ * otherwise the legacy parameter-only-path heuristic is used.
6
7
  */
7
- export function parseUrlParams(url: string): Map<string, string> {
8
+ export function parseUrlParams(url: string, hasPageName = false): Map<string, string> {
8
9
  const params = new Map<string, string>();
9
- const parts = url.split("/").filter(Boolean);
10
- const pairStart = isUrlParameter(parts[0]) ? 0 : 1;
10
+ const parts = url.split(/[?#]/, 1)[0]!.split("/").filter(Boolean);
11
+ const pairStart = !hasPageName && isUrlParameter(parts[0]) ? 0 : 1;
11
12
 
12
13
  // Skip the page name (first part), parse key/value pairs.
13
14
  for (let i = pairStart; i < parts.length - 1; i += 2) {
@@ -4,25 +4,97 @@ import type { ListUsersDataRequirement, ListUsersExternalData } from "../listuse
4
4
  import type { TagCloudDataRequirement, TagCloudExternalData } from "../tagcloud/types";
5
5
  import { parseUrlParams, resolveAndNormalizeQuery } from "../listpages/url-resolver";
6
6
 
7
+ export interface ListPagesPaginationState {
8
+ nextUnprefixed: number;
9
+ }
10
+
7
11
  export async function buildListPagesDataMap(
8
12
  dataProvider: DataProvider,
9
13
  requirements: ListPagesDataRequirement[],
10
14
  urlPath: string | undefined,
15
+ paginationState: ListPagesPaginationState = { nextUnprefixed: 1 },
11
16
  ): Promise<Map<number, ListPagesExternalData>> {
12
17
  const dataMap = new Map<number, ListPagesExternalData>();
13
- const urlParams = parseUrlParams(urlPath ?? "");
18
+ const urlParams = parseUrlParams(urlPath ?? "", true);
14
19
 
15
20
  for (const req of requirements) {
16
21
  const normalizedQuery = resolveAndNormalizeQuery(req, urlParams);
17
- const data = await dataProvider.fetchListPages?.(normalizedQuery, req);
22
+ if (!urlPath || !urlPath.startsWith("/") || urlPath.startsWith("//")) {
23
+ const data = await dataProvider.fetchListPages?.(normalizedQuery, req);
24
+ if (data) dataMap.set(req.id, data);
25
+ continue;
26
+ }
27
+ const ordinal = req.urlAttrPrefix ? 0 : paginationState.nextUnprefixed++;
28
+ const parameter = req.urlAttrPrefix
29
+ ? `${encodeURIComponent(req.urlAttrPrefix)}_p`
30
+ : ordinal === 1
31
+ ? "p"
32
+ : `p${ordinal}`;
33
+ const perPage = Math.min(250, positiveInteger(normalizedQuery.perPage, 20));
34
+ const baseOffset = positiveInteger(normalizedQuery.offset, 0);
35
+ const limit =
36
+ normalizedQuery.limit !== undefined && normalizedQuery.limit >= 0
37
+ ? Math.floor(normalizedQuery.limit)
38
+ : undefined;
39
+ const rawPage = urlParams.get(parameter) ?? "1";
40
+ let currentPage = /^\d+$/.test(rawPage) ? positiveInteger(Number(rawPage), 1) : 1;
41
+ currentPage = Math.min(
42
+ currentPage,
43
+ Math.floor((Number.MAX_SAFE_INTEGER - baseOffset) / perPage) + 1,
44
+ );
45
+ if (limit !== undefined)
46
+ currentPage = Math.min(currentPage, Math.max(1, Math.ceil(limit / perPage)));
47
+ const fetchPage = () => {
48
+ const relativeOffset = (currentPage - 1) * perPage;
49
+ return dataProvider.fetchListPages?.(
50
+ {
51
+ ...normalizedQuery,
52
+ offset: baseOffset + relativeOffset,
53
+ limit: Math.min(
54
+ perPage,
55
+ limit === undefined ? perPage : Math.max(0, limit - relativeOffset),
56
+ ),
57
+ perPage,
58
+ },
59
+ req,
60
+ );
61
+ };
62
+ let data = await fetchPage();
18
63
  if (data) {
19
- dataMap.set(req.id, data);
64
+ const count = Math.min(Math.max(0, data.totalCount - baseOffset), limit ?? Infinity);
65
+ const totalPages = Math.ceil(count / perPage);
66
+ const lastPage = Math.max(1, totalPages);
67
+ if (currentPage > lastPage) {
68
+ currentPage = lastPage;
69
+ data = await fetchPage();
70
+ }
71
+ if (data)
72
+ dataMap.set(req.id, {
73
+ ...data,
74
+ pages: data.pages.slice(
75
+ 0,
76
+ Math.min(perPage, Math.max(0, count - (currentPage - 1) * perPage)),
77
+ ),
78
+ pagination: {
79
+ currentPage,
80
+ perPage,
81
+ totalPages,
82
+ offset: baseOffset + (currentPage - 1) * perPage,
83
+ limit,
84
+ urlPath,
85
+ parameter,
86
+ },
87
+ });
20
88
  }
21
89
  }
22
90
 
23
91
  return dataMap;
24
92
  }
25
93
 
94
+ function positiveInteger(value: number | undefined, fallback: number): number {
95
+ return value !== undefined && Number.isSafeInteger(value) && value > 0 ? value : fallback;
96
+ }
97
+
26
98
  export async function buildListUsersDataMap(
27
99
  dataProvider: DataProvider,
28
100
  requirements: ListUsersDataRequirement[],
@@ -26,6 +26,8 @@ import {
26
26
  import { buildListPagesDataMap, buildListUsersDataMap, buildTagCloudDataMap } from "./data-maps";
27
27
  import { ModuleDocumentRegistry } from "./document";
28
28
  import { collectStyles, mergeCollectedStyles } from "./styles";
29
+ import { createListPagesPager } from "../listpages/resolution/pager";
30
+ import type { ListPagesPaginationState } from "./data-maps";
29
31
 
30
32
  export type AsyncModuleParseFunction = (source: string) => Promise<ModuleParseResult>;
31
33
 
@@ -40,6 +42,7 @@ export interface ResolveModulesWithAsyncParseOptions {
40
42
  };
41
43
  urlPath?: string;
42
44
  pageTags: string[];
45
+ paginationState?: ListPagesPaginationState;
43
46
  }
44
47
 
45
48
  export interface AsyncModuleResolutionResult {
@@ -73,7 +76,12 @@ export async function resolveModulesWithAsyncParse(
73
76
  registry.register(await options.parse(source));
74
77
 
75
78
  const [listPagesData, listUsersData, tagCloudData] = await Promise.all([
76
- buildListPagesDataMap(dataProvider, options.requirements.listPages ?? [], options.urlPath),
79
+ buildListPagesDataMap(
80
+ dataProvider,
81
+ options.requirements.listPages ?? [],
82
+ options.urlPath,
83
+ options.paginationState,
84
+ ),
77
85
  buildListUsersDataMap(dataProvider, options.requirements.listUsers ?? []),
78
86
  buildTagCloudDataMap(dataProvider, options.requirements.tagCloud ?? []),
79
87
  ]);
@@ -175,9 +183,9 @@ async function resolveListPagesAsync(
175
183
  if (!page) continue;
176
184
  const variableContext: VariableContext = {
177
185
  page,
178
- index: i + 1,
186
+ index: (data.pagination?.offset ?? 0) + i + 1,
179
187
  total: data.totalCount,
180
- limit: module.limit,
188
+ limit: data.pagination ? data.pagination.limit : module.limit,
181
189
  site: data.site,
182
190
  };
183
191
  const parsed = await parse(template(variableContext));
@@ -197,6 +205,7 @@ async function resolveListPagesAsync(
197
205
  if (module["append-line"] && !module.separate) {
198
206
  result.push(...(await parse(module["append-line"])).elements);
199
207
  }
208
+ result.push(...createListPagesPager(data));
200
209
 
201
210
  return module.wrapper
202
211
  ? [
@@ -44,17 +44,48 @@ export const paragraphRule: BlockRule = {
44
44
 
45
45
  return {
46
46
  success: true,
47
- elements: [
48
- {
49
- element: "container",
50
- data: {
51
- type: "paragraph",
52
- attributes: {},
53
- elements,
54
- },
55
- },
56
- ],
47
+ elements: wrapParagraphElements(elements),
57
48
  consumed: result.consumed,
58
49
  };
59
50
  },
60
51
  };
52
+
53
+ /** Block images split paragraphs; ordinary images suppress the surrounding p. */
54
+ export function wrapParagraphElements(elements: Element[]): Element[] {
55
+ const output: Element[] = [];
56
+ let group: Element[] = [];
57
+ let bare = false;
58
+ const flush = (trimBreaks = false) => {
59
+ const content = trimBreaks ? normalizeParagraphElements(group) : group;
60
+ while (content[0]?.element === "line-break") content.shift();
61
+ while (content.length) {
62
+ const last = content.at(-1)!;
63
+ if (last.element !== "text" || last.data.trim() !== "") break;
64
+ content.pop();
65
+ }
66
+ while (content[0]?.element === "text" && content[0].data.trim() === "") content.shift();
67
+ if (content[0]?.element === "text")
68
+ content[0] = { element: "text", data: content[0].data.trimStart() };
69
+ if (content.length)
70
+ output.push(
71
+ ...(bare || content.some((el) => el.element === "image")
72
+ ? content
73
+ : [
74
+ {
75
+ element: "container" as const,
76
+ data: { type: "paragraph" as const, attributes: {}, elements: content },
77
+ },
78
+ ]),
79
+ );
80
+ group = [];
81
+ };
82
+ for (const el of elements) {
83
+ if ((el.element === "image" && el.data.alignment !== null) || el.element === "embed-block") {
84
+ flush(el.element === "image");
85
+ output.push(el);
86
+ bare = el.element === "embed-block";
87
+ } else group.push(el);
88
+ }
89
+ flush();
90
+ return output;
91
+ }
@@ -52,7 +52,10 @@ export function parseBlocksUntil(
52
52
  let consumed = 0;
53
53
  let pos = ctx.pos;
54
54
 
55
- const excluded = options?.excludedBlockNames;
55
+ const excluded = mergeExcludedBlockNames(
56
+ ctx.scope.excludedBlockNames,
57
+ options?.excludedBlockNames,
58
+ );
56
59
  const blockRules = excluded ? getExcludedBlockRules(ctx.blockRules, excluded) : ctx.blockRules;
57
60
  const blockScope = {
58
61
  ...ctx.scope,
@@ -106,6 +109,20 @@ export function parseBlocksUntil(
106
109
  return { elements, consumed };
107
110
  }
108
111
 
112
+ /**
113
+ * An enclosing container's exclusions stay in force in its body: Wikidot keeps
114
+ * a `[[collapsible]]` nested in a div literal when the div itself sits in a
115
+ * collapsible.
116
+ */
117
+ function mergeExcludedBlockNames(
118
+ inherited: ReadonlySet<string> | undefined,
119
+ added: ReadonlySet<string> | undefined,
120
+ ): ReadonlySet<string> | undefined {
121
+ if (!inherited?.size) return added;
122
+ if (!added?.size) return inherited;
123
+ return new Set([...inherited, ...added]);
124
+ }
125
+
109
126
  function getExcludedBlockRules(
110
127
  blockRules: ParseContext["blockRules"],
111
128
  excluded: ReadonlySet<string>,
@@ -1,3 +1,4 @@
1
+ import { protectedInlineRegionEnd } from "../../../inline/raw/end";
1
2
  import type { Element, TableCell } from "@wdprlib/ast";
2
3
  import type { ParseContext } from "../../../types";
3
4
  import { getCandidateInlineRules } from "../../../inline/utils";
@@ -20,7 +21,35 @@ export function parseTableCell(
20
21
  }
21
22
 
22
23
  const { inlineRules } = ctx;
23
- const inlineCtx: ParseContext = { ...ctx, pos };
24
+ let inlineEnd = pos;
25
+ while (inlineEnd < ctx.tokens.length) {
26
+ const rawEnd = protectedInlineRegionEnd(ctx.tokens, inlineEnd, ctx.tokens.length);
27
+ if (rawEnd > inlineEnd) {
28
+ inlineEnd = rawEnd;
29
+ continue;
30
+ }
31
+ const token = ctx.tokens[inlineEnd];
32
+ if (!token || token.type === "EOF" || token.type === "NEWLINE" || isPipeTableToken(token.type))
33
+ break;
34
+ if (
35
+ token.type === "WHITESPACE" &&
36
+ ctx.tokens[inlineEnd + 1]?.type === "UNDERSCORE" &&
37
+ ctx.tokens[inlineEnd + 2]?.type === "NEWLINE"
38
+ )
39
+ inlineEnd += 3;
40
+ else inlineEnd++;
41
+ }
42
+ const inlineCtx: ParseContext = {
43
+ ...ctx,
44
+ pos,
45
+ scope: {
46
+ ...ctx.scope,
47
+ inlineEnd,
48
+ tableFormatting: isPipeTableToken(ctx.tokens[inlineEnd]?.type ?? "EOF")
49
+ ? ctx.scope.tableFormatting
50
+ : undefined,
51
+ },
52
+ };
24
53
 
25
54
  while (pos < ctx.tokens.length) {
26
55
  const token = ctx.tokens[pos];
@@ -31,6 +60,12 @@ export function parseTableCell(
31
60
  break;
32
61
  }
33
62
 
63
+ if (ctx.scope.tableFormatting?.suppressedClosers.has(pos)) {
64
+ pos++;
65
+ consumed++;
66
+ continue;
67
+ }
68
+
34
69
  if (tryConsumeUnderscoreLineBreak(ctx, pos, children)) {
35
70
  pos += 3;
36
71
  consumed += 3;
@@ -1,3 +1,4 @@
1
+ import { protectedInlineRegionEnd } from "../../../inline/raw/end";
1
2
  import type { TableCell, TableRow } from "@wdprlib/ast";
2
3
  import type { ParseContext } from "../../../types";
3
4
  import { parseCellStart } from "./cell-start";
@@ -8,6 +9,25 @@ export function parsePipeTableRows(
8
9
  ctx: ParseContext,
9
10
  startPos: number,
10
11
  ): { rows: TableRow[]; consumed: number } {
12
+ let end = startPos;
13
+ while (end < ctx.tokens.length && ctx.tokens[end]?.type !== "EOF") {
14
+ const protectedEnd = protectedInlineRegionEnd(ctx.tokens, end, ctx.tokens.length);
15
+ if (protectedEnd > end) {
16
+ end = protectedEnd;
17
+ continue;
18
+ }
19
+ if (
20
+ ctx.tokens[end]?.type === "NEWLINE" &&
21
+ !isPipeTableToken(ctx.tokens[end + 1]?.type ?? "EOF") &&
22
+ !(ctx.tokens[end - 1]?.type === "UNDERSCORE" && ctx.tokens[end - 2]?.type === "WHITESPACE")
23
+ )
24
+ break;
25
+ end++;
26
+ }
27
+ const tableCtx: ParseContext = {
28
+ ...ctx,
29
+ scope: { ...ctx.scope, tableFormatting: { end, suppressedClosers: new Set() } },
30
+ };
11
31
  const rows: TableRow[] = [];
12
32
  let pos = startPos;
13
33
  let consumed = 0;
@@ -19,7 +39,7 @@ export function parsePipeTableRows(
19
39
  break;
20
40
  }
21
41
 
22
- const rowResult = parseTableRow(ctx, pos);
42
+ const rowResult = parseTableRow(tableCtx, pos);
23
43
  rows.push(rowResult.row);
24
44
  pos += rowResult.consumed;
25
45
  consumed += rowResult.consumed;
@@ -1,10 +1,10 @@
1
1
  import type { Alignment, Element } from "@wdprlib/ast";
2
2
 
3
- export function createTocElement(align: Alignment | null): Element {
3
+ export function createTocElement(align: Alignment | null, title: string | undefined): Element {
4
4
  return {
5
5
  element: "table-of-contents",
6
6
  data: {
7
- attributes: {},
7
+ attributes: title === undefined ? {} : { title },
8
8
  align,
9
9
  },
10
10
  };
@@ -6,7 +6,7 @@
6
6
  * - [[f<toc ...]] - float left
7
7
  * - [[f>toc ...]] - float right
8
8
  *
9
- * Note: Wikidot ignores attributes on [[toc]] (class, style, id are not applied)
9
+ * The title attribute overrides the heading; class, style, and id are ignored.
10
10
  * [[>toc]] and [[<toc]] are invalid in Wikidot and not supported.
11
11
  */
12
12
  import type { Element } from "@wdprlib/ast";
@@ -37,7 +37,7 @@ export const tocRule: BlockRule = {
37
37
 
38
38
  return {
39
39
  success: true,
40
- elements: [createTocElement(openResult.align)],
40
+ elements: [createTocElement(openResult.align, openResult.title)],
41
41
  consumed: openResult.consumed,
42
42
  };
43
43
  },