@wdprlib/parser 5.1.6 → 5.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 (52) hide show
  1. package/dist/index.cjs +655 -435
  2. package/dist/index.d.cts +11 -4
  3. package/dist/index.d.ts +11 -4
  4. package/dist/index.js +653 -435
  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/paragraph/index.ts +41 -10
  26. package/src/parser/rules/block/parsing/content.ts +18 -1
  27. package/src/parser/rules/block/table/pipe/cell.ts +36 -1
  28. package/src/parser/rules/block/table/pipe/row.ts +21 -1
  29. package/src/parser/rules/block/toc/element.ts +2 -2
  30. package/src/parser/rules/block/toc/index.ts +2 -2
  31. package/src/parser/rules/block/toc/open.ts +5 -18
  32. package/src/parser/rules/contracts/scope.ts +4 -0
  33. package/src/parser/rules/inline/bold.ts +5 -5
  34. package/src/parser/rules/inline/color/syntax.ts +5 -8
  35. package/src/parser/rules/inline/formatting/close.ts +39 -0
  36. package/src/parser/rules/inline/formatting/container.ts +7 -4
  37. package/src/parser/rules/inline/index.ts +2 -0
  38. package/src/parser/rules/inline/italic.ts +5 -5
  39. package/src/parser/rules/inline/monospace.ts +5 -5
  40. package/src/parser/rules/inline/parsing/block-start-predicates.ts +2 -0
  41. package/src/parser/rules/inline/parsing/inline-content.ts +58 -4
  42. package/src/parser/rules/inline/raw/end.ts +28 -0
  43. package/src/parser/rules/inline/span/content.ts +32 -1
  44. package/src/parser/rules/inline/strikethrough/index.ts +1 -1
  45. package/src/parser/rules/inline/strikethrough/parse.ts +2 -9
  46. package/src/parser/rules/inline/strikethrough/syntax.ts +3 -20
  47. package/src/parser/rules/inline/subscript.ts +5 -5
  48. package/src/parser/rules/inline/superscript.ts +5 -5
  49. package/src/parser/rules/inline/underline/index.ts +5 -78
  50. package/src/parser/rules/tokens.ts +6 -15
  51. package/src/parser/rules/inline/underline/child.ts +0 -26
  52. 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
 
@@ -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
  },
@@ -1,8 +1,10 @@
1
1
  import type { Alignment } from "@wdprlib/ast";
2
2
  import type { ParseContext } from "../../types";
3
+ import { parseAttributes } from "../utils";
3
4
 
4
5
  export interface TocOpenResult {
5
6
  align: Alignment | null;
7
+ title: string | undefined;
6
8
  consumed: number;
7
9
  }
8
10
 
@@ -48,7 +50,8 @@ export function parseTocOpen(ctx: ParseContext, startPos: number): TocOpenResult
48
50
  return null;
49
51
  }
50
52
 
51
- pos = skipUntilClose(ctx, pos);
53
+ const attributes = parseAttributes(ctx, pos);
54
+ pos += attributes.consumed;
52
55
 
53
56
  if (ctx.tokens[pos]?.type !== "BLOCK_CLOSE") {
54
57
  return null;
@@ -56,27 +59,11 @@ export function parseTocOpen(ctx: ParseContext, startPos: number): TocOpenResult
56
59
 
57
60
  return {
58
61
  align,
62
+ title: attributes.attrs.title,
59
63
  consumed: pos + 1 - startPos,
60
64
  };
61
65
  }
62
66
 
63
- function skipUntilClose(ctx: ParseContext, startPos: number): number {
64
- let pos = startPos;
65
- while (pos < ctx.tokens.length) {
66
- const token = ctx.tokens[pos];
67
- if (
68
- !token ||
69
- token.type === "BLOCK_CLOSE" ||
70
- token.type === "NEWLINE" ||
71
- token.type === "EOF"
72
- ) {
73
- break;
74
- }
75
- pos++;
76
- }
77
- return pos;
78
- }
79
-
80
67
  function isNameToken(
81
68
  token: ParseContext["tokens"][number] | undefined,
82
69
  ): token is ParseContext["tokens"][number] & { type: "TEXT" | "IDENTIFIER" } {
@@ -8,6 +8,10 @@ import type { ParseContext } from "./parse-context";
8
8
  * expressed as a replacement: `ctx.scope = { ...ctx.scope, X: ... }`.
9
9
  */
10
10
  export interface ScopeContext {
11
+ /** Exclusive token boundary inherited by nested inline rules. */
12
+ readonly inlineEnd?: number;
13
+ /** Closing delimiters paired across cells of the current pipe table. */
14
+ readonly tableFormatting?: { end: number; suppressedClosers: Set<number> };
11
15
  /**
12
16
  * Close condition for the current block. The paragraph parser calls
13
17
  * it to decide when to stop collecting inline content.
@@ -3,8 +3,8 @@
3
3
  * Parses the Wikidot bold formatting syntax: `**text**`.
4
4
  *
5
5
  * Bold text is delimited by double asterisks. The opening and closing
6
- * markers must appear on the same line; if no closing `**` is found
7
- * before a newline, the opening marker is emitted as literal text.
6
+ * markers must appear within the same paragraph; if no closing `**` is found
7
+ * before a block boundary, the opening marker is emitted as literal text.
8
8
  *
9
9
  * Wikidot behavior for empty bold (`****`): the markers and their
10
10
  * (empty) content are discarded entirely, producing no output.
@@ -18,13 +18,13 @@
18
18
  */
19
19
  import type { Element } from "@wdprlib/ast";
20
20
  import type { InlineRule, ParseContext, RuleResult } from "../types";
21
- import { parseSameLineDelimitedContainer } from "./formatting/container";
21
+ import { parseDelimitedContainer } from "./formatting/container";
22
22
 
23
23
  /**
24
24
  * Inline rule for parsing `**bold**` formatting.
25
25
  *
26
26
  * Triggered by a `BOLD_MARKER` token (`**`). The rule checks for a
27
- * matching closing marker on the same line, then recursively parses
27
+ * matching closing marker within the same paragraph, then recursively parses
28
28
  * inline content between the markers.
29
29
  *
30
30
  * When no closing marker is found, the opening `**` is treated as
@@ -44,6 +44,6 @@ export const boldRule: InlineRule = {
44
44
  * fallback for unmatched markers
45
45
  */
46
46
  parse(ctx: ParseContext): RuleResult<Element> {
47
- return parseSameLineDelimitedContainer(ctx, "BOLD_MARKER", "bold", { discardEmpty: true });
47
+ return parseDelimitedContainer(ctx, "BOLD_MARKER", "bold", { discardEmpty: true });
48
48
  },
49
49
  };
@@ -1,6 +1,6 @@
1
1
  import type { Element } from "@wdprlib/ast";
2
2
  import type { ParseContext } from "../../types";
3
- import { hasClosingMarkerBeforeNewline } from "../../types";
3
+ import { findFormattingClose, consumeFormattingClose } from "../formatting/close";
4
4
  import { parseInlineUntil } from "../utils";
5
5
 
6
6
  export interface ColorContent {
@@ -10,7 +10,8 @@ export interface ColorContent {
10
10
  }
11
11
 
12
12
  export function parseColorContent(ctx: ParseContext): ColorContent | null {
13
- if (!hasClosingMarkerBeforeNewline({ ...ctx, pos: ctx.pos + 1 }, "COLOR_MARKER")) {
13
+ const close = findFormattingClose(ctx, ctx.pos + 1, "COLOR_MARKER");
14
+ if (close === null) {
14
15
  return null;
15
16
  }
16
17
 
@@ -18,7 +19,7 @@ export function parseColorContent(ctx: ParseContext): ColorContent | null {
18
19
  let consumed = 1;
19
20
  let colorSpec = "";
20
21
 
21
- while (pos < ctx.tokens.length) {
22
+ while (pos < (ctx.scope.inlineEnd ?? ctx.tokens.length)) {
22
23
  const token = ctx.tokens[pos];
23
24
  if (
24
25
  !token ||
@@ -44,16 +45,12 @@ export function parseColorContent(ctx: ParseContext): ColorContent | null {
44
45
  pos += contentResult.consumed;
45
46
  consumed += contentResult.consumed;
46
47
 
47
- if (ctx.tokens[pos]?.type !== "COLOR_MARKER") {
48
- return null;
49
- }
50
- consumed++;
51
-
52
48
  const color = colorSpec.trim();
53
49
  if (color === "" || contentResult.elements.length === 0) {
54
50
  return null;
55
51
  }
56
52
 
53
+ consumed += consumeFormattingClose(ctx, close, pos);
57
54
  return {
58
55
  color: hexifyColor(color),
59
56
  elements: contentResult.elements,
@@ -0,0 +1,39 @@
1
+ import { protectedInlineRegionEnd } from "../raw/end";
2
+ import type { TokenType } from "../../../../lexer";
3
+ import type { ParseContext } from "../../types";
4
+ import { getParagraphNewlineBoundary } from "../parsing/paragraph-boundary";
5
+
6
+ export function findFormattingClose(
7
+ ctx: ParseContext,
8
+ start: number,
9
+ marker: TokenType,
10
+ ): number | null {
11
+ const table = ctx.scope.tableFormatting;
12
+ const end = table?.end ?? ctx.scope.inlineEnd ?? ctx.tokens.length;
13
+ for (let pos = start; pos < end; pos++) {
14
+ const token = ctx.tokens[pos];
15
+ if (!token || token.type === "EOF" || ctx.scope.blockCloseCondition?.({ ...ctx, pos }))
16
+ return null;
17
+ if (
18
+ !table &&
19
+ token.type === "NEWLINE" &&
20
+ getParagraphNewlineBoundary(ctx, pos, true).shouldBreak
21
+ )
22
+ return null;
23
+ if (token.type === marker && !table?.suppressedClosers.has(pos)) return pos;
24
+ const protectedEnd = protectedInlineRegionEnd(ctx.tokens, pos, end);
25
+ if (protectedEnd > pos) pos = protectedEnd - 1;
26
+ }
27
+ return null;
28
+ }
29
+
30
+ export function consumeFormattingClose(
31
+ ctx: ParseContext,
32
+ close: number,
33
+ contentEnd: number,
34
+ ): number {
35
+ if (close === contentEnd) return 1;
36
+ // Wikidot closes formatting at the cell edge and suppresses its later delimiter.
37
+ ctx.scope.tableFormatting?.suppressedClosers.add(close);
38
+ return 0;
39
+ }
@@ -1,7 +1,8 @@
1
1
  import type { Element, StringContainerType } from "@wdprlib/ast";
2
2
  import type { TokenType } from "../../../../lexer";
3
3
  import type { ParseContext, RuleResult } from "../../types";
4
- import { currentToken, hasClosingMarkerBeforeNewline } from "../../types";
4
+ import { currentToken } from "../../types";
5
+ import { findFormattingClose, consumeFormattingClose } from "./close";
5
6
  import { parseInlineUntil } from "../utils";
6
7
 
7
8
  export function createInlineContainer(type: StringContainerType, elements: Element[]): Element {
@@ -15,7 +16,7 @@ export function createInlineContainer(type: StringContainerType, elements: Eleme
15
16
  };
16
17
  }
17
18
 
18
- export function parseSameLineDelimitedContainer(
19
+ export function parseDelimitedContainer(
19
20
  ctx: ParseContext,
20
21
  closeToken: TokenType,
21
22
  type: StringContainerType,
@@ -23,7 +24,8 @@ export function parseSameLineDelimitedContainer(
23
24
  ): RuleResult<Element> {
24
25
  const startToken = currentToken(ctx);
25
26
 
26
- if (!hasClosingMarkerBeforeNewline({ ...ctx, pos: ctx.pos + 1 }, closeToken)) {
27
+ const close = findFormattingClose(ctx, ctx.pos + 1, closeToken);
28
+ if (close === null) {
27
29
  return {
28
30
  success: true,
29
31
  elements: [{ element: "text", data: startToken.value }],
@@ -32,7 +34,8 @@ export function parseSameLineDelimitedContainer(
32
34
  }
33
35
 
34
36
  const result = parseInlineUntil({ ...ctx, pos: ctx.pos + 1 }, closeToken);
35
- const consumed = 1 + result.consumed + 1;
37
+ const consumed =
38
+ 1 + result.consumed + consumeFormattingClose(ctx, close, ctx.pos + 1 + result.consumed);
36
39
 
37
40
  if (options.discardEmpty === true && result.elements.length === 0) {
38
41
  return {
@@ -1,3 +1,4 @@
1
+ import { embedBlockRule } from "../block/embed-block";
1
2
  /**
2
3
  *
3
4
  * Central registry and priority-ordered list of all inline parsing rules.
@@ -130,6 +131,7 @@ export const inlineRules: InlineRule[] = [
130
131
  htmlInlineRule,
131
132
  rawRule,
132
133
  imageRule,
134
+ embedBlockRule,
133
135
  sizeRule,
134
136
  footnoteRule,
135
137
  spanRule,
@@ -3,8 +3,8 @@
3
3
  * Parses the Wikidot italic formatting syntax: `//text//`.
4
4
  *
5
5
  * Italic text is delimited by double forward slashes. The opening and
6
- * closing markers must appear on the same line. If no closing `//` is
7
- * found before a newline, the opening marker is emitted as literal text.
6
+ * closing markers must appear within the same paragraph. If no closing `//` is
7
+ * found before a block boundary, the opening marker is emitted as literal text.
8
8
  *
9
9
  * Unlike bold (which discards empty markers), italic markers with empty
10
10
  * content (`////`) still produce an italic container, matching Wikidot's
@@ -18,13 +18,13 @@
18
18
  */
19
19
  import type { Element } from "@wdprlib/ast";
20
20
  import type { InlineRule, ParseContext, RuleResult } from "../types";
21
- import { parseSameLineDelimitedContainer } from "./formatting/container";
21
+ import { parseDelimitedContainer } from "./formatting/container";
22
22
 
23
23
  /**
24
24
  * Inline rule for parsing `//italic//` formatting.
25
25
  *
26
26
  * Triggered by an `ITALIC_MARKER` token (`//`). Checks for a matching
27
- * closing marker on the same line, then recursively parses inline content.
27
+ * closing marker within the same paragraph, then recursively parses inline content.
28
28
  *
29
29
  * When no closing marker is found, the opening `//` is treated as
30
30
  * literal text.
@@ -41,6 +41,6 @@ export const italicRule: InlineRule = {
41
41
  * with `type: "italics"`, or a text fallback for unmatched markers
42
42
  */
43
43
  parse(ctx: ParseContext): RuleResult<Element> {
44
- return parseSameLineDelimitedContainer(ctx, "ITALIC_MARKER", "italics");
44
+ return parseDelimitedContainer(ctx, "ITALIC_MARKER", "italics");
45
45
  },
46
46
  };
@@ -3,8 +3,8 @@
3
3
  * Parses the Wikidot monospace (teletype) formatting syntax: `{{text}}`.
4
4
  *
5
5
  * Monospace text is delimited by double curly braces. The opening and
6
- * closing markers must appear on the same line. If no closing `}}`
7
- * is found before a newline, the opening marker is emitted as literal text.
6
+ * closing markers must appear within the same paragraph. If no closing `}}`
7
+ * is found before a block boundary, the opening marker is emitted as literal text.
8
8
  *
9
9
  * Note: the opening marker is `MONO_MARKER` (`{{`) and the closing marker
10
10
  * is `MONO_CLOSE` (`}}`). These are distinct token types because `{` and
@@ -21,13 +21,13 @@
21
21
  */
22
22
  import type { Element } from "@wdprlib/ast";
23
23
  import type { InlineRule, ParseContext, RuleResult } from "../types";
24
- import { parseSameLineDelimitedContainer } from "./formatting/container";
24
+ import { parseDelimitedContainer } from "./formatting/container";
25
25
 
26
26
  /**
27
27
  * Inline rule for parsing `{{monospace}}` formatting.
28
28
  *
29
29
  * Triggered by a `MONO_MARKER` token (`{{`). Checks for a matching
30
- * `MONO_CLOSE` (`}}`) on the same line, then recursively parses
30
+ * `MONO_CLOSE` (`}}`) within the same paragraph, then recursively parses
31
31
  * inline content between the markers.
32
32
  *
33
33
  * When no closing marker is found, the opening `{{` is treated as
@@ -45,6 +45,6 @@ export const monospaceRule: InlineRule = {
45
45
  * with `type: "monospace"`, or a text fallback for unmatched markers
46
46
  */
47
47
  parse(ctx: ParseContext): RuleResult<Element> {
48
- return parseSameLineDelimitedContainer(ctx, "MONO_CLOSE", "monospace");
48
+ return parseDelimitedContainer(ctx, "MONO_CLOSE", "monospace");
49
49
  },
50
50
  };