@wdprlib/parser 5.3.0 → 5.4.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 (97) hide show
  1. package/dist/index.cjs +2074 -1137
  2. package/dist/index.d.cts +2 -0
  3. package/dist/index.d.ts +2 -0
  4. package/dist/index.js +2074 -1137
  5. package/package.json +3 -2
  6. package/src/build-info.generated.ts +2 -2
  7. package/src/lexer/lexer.ts +34 -5
  8. package/src/lexer/quoted-string.ts +4 -4
  9. package/src/lexer/syntax-actions.ts +6 -1
  10. package/src/lexer/text-actions.ts +15 -1
  11. package/src/lexer/url-schemes.ts +78 -0
  12. package/src/parser/constants.ts +4 -0
  13. package/src/parser/parse/block.ts +1 -1
  14. package/src/parser/postprocess/divAdjacentParagraph.ts +23 -21
  15. package/src/parser/postprocess/spanStrip/merge.ts +8 -2
  16. package/src/parser/preprocess/typography.ts +25 -3
  17. package/src/parser/preprocess/utils/raw-regions.ts +59 -14
  18. package/src/parser/preprocess/whitespace/index.ts +8 -1
  19. package/src/parser/rules/block/bibliography/entry-content.ts +1 -1
  20. package/src/parser/rules/block/block-list/bare-content.ts +3 -1
  21. package/src/parser/rules/block/block-list/bare-paragraph.ts +7 -2
  22. package/src/parser/rules/block/block-list/item-content.ts +7 -3
  23. package/src/parser/rules/block/block-list/li-content.ts +8 -3
  24. package/src/parser/rules/block/block-list/li-item.ts +1 -1
  25. package/src/parser/rules/block/blockquote/build.ts +1 -1
  26. package/src/parser/rules/block/code/boundary.ts +76 -0
  27. package/src/parser/rules/block/code/content.ts +11 -40
  28. package/src/parser/rules/block/code/index.ts +8 -31
  29. package/src/parser/rules/block/code/open.ts +46 -0
  30. package/src/parser/rules/block/definition-list/item-key.ts +1 -1
  31. package/src/parser/rules/block/definition-list/item-value.ts +1 -1
  32. package/src/parser/rules/block/div/failed.ts +2 -0
  33. package/src/parser/rules/block/index.ts +3 -0
  34. package/src/parser/rules/block/list/line.ts +6 -3
  35. package/src/parser/rules/block/math/boundary.ts +104 -0
  36. package/src/parser/rules/block/math/index.ts +17 -57
  37. package/src/parser/rules/block/note/boundary.ts +88 -0
  38. package/src/parser/rules/block/note/index.ts +37 -0
  39. package/src/parser/rules/block/paragraph/index.ts +12 -4
  40. package/src/parser/rules/block/paragraph/normalize.ts +6 -1
  41. package/src/parser/rules/block/parsing/block-item.ts +1 -1
  42. package/src/parser/rules/block/parsing/content.ts +1 -1
  43. package/src/parser/rules/block/parsing/inline-content.ts +6 -3
  44. package/src/parser/rules/block/table/pipe/cell.ts +3 -1
  45. package/src/parser/rules/block/table-block/cell-content/index.ts +1 -1
  46. package/src/parser/rules/block/table-block/cell-content/segments.ts +5 -2
  47. package/src/parser/rules/block/table-block/cell-newline.ts +2 -1
  48. package/src/parser/rules/contracts/rule.ts +4 -2
  49. package/src/parser/rules/contracts/scope.ts +2 -0
  50. package/src/parser/rules/inline/anchor/child.ts +8 -2
  51. package/src/parser/rules/inline/anchor/content.ts +3 -1
  52. package/src/parser/rules/inline/anchor/index.ts +4 -1
  53. package/src/parser/rules/inline/anchor/newline.ts +2 -1
  54. package/src/parser/rules/inline/autolink.ts +153 -0
  55. package/src/parser/rules/inline/button/attributes.ts +17 -0
  56. package/src/parser/rules/inline/button/index.ts +17 -0
  57. package/src/parser/rules/inline/button/syntax.ts +56 -0
  58. package/src/parser/rules/inline/date/index.ts +17 -0
  59. package/src/parser/rules/inline/date/syntax.ts +46 -0
  60. package/src/parser/rules/inline/email/candidates.ts +134 -0
  61. package/src/parser/rules/inline/email/index.ts +36 -0
  62. package/src/parser/rules/inline/email/scan.ts +76 -0
  63. package/src/parser/rules/inline/expr/branch.ts +3 -1
  64. package/src/parser/rules/inline/footnote/boundary.ts +56 -0
  65. package/src/parser/rules/inline/footnote/content.ts +29 -41
  66. package/src/parser/rules/inline/footnote/elements.ts +9 -34
  67. package/src/parser/rules/inline/footnote/index.ts +4 -1
  68. package/src/parser/rules/inline/formatting/close.ts +12 -0
  69. package/src/parser/rules/inline/index.ts +14 -0
  70. package/src/parser/rules/inline/line-break/newline.ts +8 -1
  71. package/src/parser/rules/inline/link-bracket/direct-url.ts +11 -3
  72. package/src/parser/rules/inline/link-bracket/parsed.ts +9 -4
  73. package/src/parser/rules/inline/link-bracket/parts.ts +14 -36
  74. package/src/parser/rules/inline/link-bracket/special-target.ts +9 -0
  75. package/src/parser/rules/inline/link-single.ts +9 -7
  76. package/src/parser/rules/inline/link-triple/index.ts +1 -0
  77. package/src/parser/rules/inline/link-triple/label.ts +7 -1
  78. package/src/parser/rules/inline/parsing/automatic-line-break.ts +35 -0
  79. package/src/parser/rules/inline/parsing/block-boundary.ts +2 -0
  80. package/src/parser/rules/inline/parsing/block-start-predicates.ts +10 -0
  81. package/src/parser/rules/inline/parsing/inline-content.ts +36 -6
  82. package/src/parser/rules/inline/parsing/plain-text.ts +7 -2
  83. package/src/parser/rules/inline/parsing/preserved-line-break.ts +13 -0
  84. package/src/parser/rules/inline/parsing/raw-tag.ts +19 -0
  85. package/src/parser/rules/inline/raw/angle.ts +2 -1
  86. package/src/parser/rules/inline/raw/end.ts +21 -1
  87. package/src/parser/rules/inline/size/content.ts +1 -1
  88. package/src/parser/rules/inline/size/value.ts +11 -0
  89. package/src/parser/rules/inline/social/index.ts +17 -0
  90. package/src/parser/rules/inline/social/syntax.ts +40 -0
  91. package/src/parser/rules/inline/span/content.ts +3 -1
  92. package/src/parser/rules/inline/span/newline.ts +2 -1
  93. package/src/parser/rules/opaque-probe.ts +58 -0
  94. package/src/parser/rules/block/math/content.ts +0 -54
  95. package/src/parser/rules/block/math/name.ts +0 -35
  96. package/src/parser/rules/inline/footnote/child.ts +0 -22
  97. package/src/parser/rules/inline/footnote/newline.ts +0 -27
@@ -0,0 +1,88 @@
1
+ import type { Token } from "../../../../lexer";
2
+ import type { ParseContext } from "../../types";
3
+ import { createOpaqueProbe, opaqueRuleEnd } from "../../opaque-probe";
4
+ import { codeBlockRule } from "../code";
5
+ import { mathBlockRule } from "../math";
6
+ import { linkTripleRule } from "../../inline/link-triple";
7
+ import { protectedInlineRegionEnd } from "../../inline/raw/end";
8
+
9
+ // These constructs are replaced before Note in Wikidot's processing order.
10
+ const opaqueRules = [codeBlockRule, mathBlockRule, linkTripleRule];
11
+ interface NoteBounds {
12
+ bodyStart: number;
13
+ close: number;
14
+ end: number;
15
+ }
16
+ const boundsCache = new WeakMap<readonly Token[], Map<number, NoteBounds | null>>();
17
+ const closeCache = new WeakMap<readonly Token[], Map<string, Map<number, number>>>();
18
+
19
+ function tagEnd(tokens: readonly Token[], pos: number, name: string, close = false): number {
20
+ return tokens[pos]?.type === (close ? "BLOCK_END_OPEN" : "BLOCK_OPEN") &&
21
+ tokens[pos + 1]?.value.toLowerCase() === name &&
22
+ tokens[pos + 2]?.type === "BLOCK_CLOSE"
23
+ ? pos + 3
24
+ : pos;
25
+ }
26
+
27
+ function findClose(probe: ParseContext, start: number, name: string): number {
28
+ let byName = closeCache.get(probe.tokens);
29
+ if (!byName) {
30
+ byName = new Map();
31
+ closeCache.set(probe.tokens, byName);
32
+ }
33
+ let cache = byName.get(name);
34
+ if (!cache) {
35
+ cache = new Map();
36
+ byName.set(name, cache);
37
+ }
38
+ if (cache.has(start)) return cache.get(start)!;
39
+ const visited: number[] = [];
40
+ let result = -1;
41
+ for (let pos = start; pos < probe.tokens.length; pos++) {
42
+ if (cache.has(pos)) {
43
+ result = cache.get(pos)!;
44
+ break;
45
+ }
46
+ visited.push(pos);
47
+ const protectedEnd = protectedInlineRegionEnd(probe.tokens, pos, probe.tokens.length);
48
+ if (protectedEnd > pos) {
49
+ pos = protectedEnd - 1;
50
+ continue;
51
+ }
52
+ if (tagEnd(probe.tokens, pos, name, true) > pos) {
53
+ result = pos;
54
+ break;
55
+ }
56
+ const opaqueEnd = opaqueRuleEnd(probe, pos, opaqueRules, true);
57
+ if (opaqueEnd > pos) {
58
+ pos = opaqueEnd - 1;
59
+ continue;
60
+ }
61
+ // Footnote precedes Note and uses its first complete close, without nesting.
62
+ if (name === "note") {
63
+ const footnoteStart = tagEnd(probe.tokens, pos, "footnote");
64
+ if (footnoteStart > pos) {
65
+ const end = findClose(probe, footnoteStart, "footnote");
66
+ if (end >= 0) pos = end + 2;
67
+ }
68
+ }
69
+ }
70
+ // Only visited positions are reusable: opaque interiors have different boundaries.
71
+ for (const pos of visited) cache.set(pos, result);
72
+ return result;
73
+ }
74
+
75
+ export function findNoteBounds(ctx: ParseContext, start: number = ctx.pos): NoteBounds | null {
76
+ const openEnd = tagEnd(ctx.tokens, start, "note");
77
+ if (openEnd === start || ctx.tokens[openEnd]?.type !== "NEWLINE") return null;
78
+ let cache = boundsCache.get(ctx.tokens);
79
+ if (!cache) {
80
+ cache = new Map();
81
+ boundsCache.set(ctx.tokens, cache);
82
+ }
83
+ if (cache.has(start)) return cache.get(start)!;
84
+ const close = findClose(createOpaqueProbe(ctx), openEnd + 1, "note");
85
+ const result = close < 0 ? null : { bodyStart: openEnd + 1, close, end: close + 3 };
86
+ cache.set(start, result);
87
+ return result;
88
+ }
@@ -0,0 +1,37 @@
1
+ import type { BlockRule } from "../../types";
2
+ import { parseBlocksUntil } from "../parsing/content";
3
+ import { findNoteBounds } from "./boundary";
4
+
5
+ const excludedBlockNames = new Set(["note"]);
6
+
7
+ export const noteRule: BlockRule = {
8
+ name: "note",
9
+ startTokens: ["BLOCK_OPEN"],
10
+ requiresLineStart: false,
11
+ parse(ctx) {
12
+ const bounds = findNoteBounds(ctx);
13
+ if (!bounds) return { success: false };
14
+ const body = parseBlocksUntil(
15
+ {
16
+ ...ctx,
17
+ tokens: ctx.tokens.slice(bounds.bodyStart, bounds.close),
18
+ pos: 0,
19
+ scope: {
20
+ ...ctx.scope,
21
+ inlineEnd: undefined,
22
+ tableFormatting: undefined,
23
+ blockCloseCondition: undefined,
24
+ },
25
+ },
26
+ () => false,
27
+ { excludedBlockNames },
28
+ );
29
+ return {
30
+ success: true,
31
+ consumed: bounds.end - ctx.pos,
32
+ elements: [
33
+ { element: "container", data: { type: "note", attributes: {}, elements: body.elements } },
34
+ ],
35
+ };
36
+ },
37
+ };
@@ -1,3 +1,5 @@
1
+ import { markUnparsedDivStart } from "../../../postprocess/divAdjacentParagraph";
2
+ import { isDivClose } from "../div/close";
1
3
  /**
2
4
  * Paragraph rule
3
5
  *
@@ -8,6 +10,7 @@ import type { Element } from "@wdprlib/ast";
8
10
  import type { BlockRule, ParseContext, RuleResult } from "../../types";
9
11
  import { parseInlineContent } from "./content";
10
12
  import { normalizeParagraphElements } from "./normalize";
13
+ import { isPreservedLeadingLineBreak } from "../../inline/parsing/preserved-line-break";
11
14
 
12
15
  /**
13
16
  * Paragraph is the fallback block rule.
@@ -23,18 +26,19 @@ export const paragraphRule: BlockRule = {
23
26
 
24
27
  parse(ctx: ParseContext): RuleResult<Element> {
25
28
  const result = parseInlineContent(ctx);
26
- if (result.elements.length === 0) {
29
+ if (result.consumed === 0) {
27
30
  return { success: false };
28
31
  }
29
32
 
30
33
  const elements = normalizeParagraphElements(result.elements);
31
34
  if (elements.length === 0) {
32
- return { success: false };
35
+ return { success: true, elements: [], consumed: result.consumed };
33
36
  }
34
37
 
35
38
  const nextPos = ctx.pos + result.consumed;
36
39
  const nextToken = ctx.tokens[nextPos];
37
40
  if (nextToken?.type === "COLON" && nextToken.lineStart) {
41
+ if (isPreservedLeadingLineBreak(elements[0])) elements[0] = { element: "line-break" };
38
42
  return {
39
43
  success: true,
40
44
  elements: [...elements, { element: "line-break" }],
@@ -42,9 +46,11 @@ export const paragraphRule: BlockRule = {
42
46
  };
43
47
  }
44
48
 
49
+ const wrapped = wrapParagraphElements(elements);
50
+ if (isDivClose(ctx)) markUnparsedDivStart(wrapped);
45
51
  return {
46
52
  success: true,
47
- elements: wrapParagraphElements(elements),
53
+ elements: wrapped,
48
54
  consumed: result.consumed,
49
55
  };
50
56
  },
@@ -57,7 +63,8 @@ export function wrapParagraphElements(elements: Element[]): Element[] {
57
63
  let bare = false;
58
64
  const flush = (trimBreaks = false) => {
59
65
  const content = trimBreaks ? normalizeParagraphElements(group) : group;
60
- while (content[0]?.element === "line-break") content.shift();
66
+ while (content[0]?.element === "line-break" && !isPreservedLeadingLineBreak(content[0]))
67
+ content.shift();
61
68
  while (content.length) {
62
69
  const last = content.at(-1)!;
63
70
  if (last.element !== "text" || last.data.trim() !== "") break;
@@ -66,6 +73,7 @@ export function wrapParagraphElements(elements: Element[]): Element[] {
66
73
  while (content[0]?.element === "text" && content[0].data.trim() === "") content.shift();
67
74
  if (content[0]?.element === "text")
68
75
  content[0] = { element: "text", data: content[0].data.trimStart() };
76
+ if (isPreservedLeadingLineBreak(content[0])) content[0] = { element: "line-break" };
69
77
  if (content.length)
70
78
  output.push(
71
79
  ...(bare || content.some((el) => el.element === "image")
@@ -1,5 +1,6 @@
1
1
  import type { Element } from "@wdprlib/ast";
2
2
  import { processCloseSpanMarkers } from "./span-markers";
3
+ import { isPreservedLeadingLineBreak } from "../../inline/parsing/preserved-line-break";
3
4
 
4
5
  type PreservedLineBreak = Extract<Element, { element: "line-break" }> & {
5
6
  _preservedTrailingBreak?: boolean;
@@ -44,7 +45,11 @@ function removeTrailingWhitespaceText(elements: Element[]): void {
44
45
 
45
46
  function removeLeadingLineBreaks(elements: Element[]): Element[] {
46
47
  let first = 0;
47
- while (first < elements.length && elements[first]?.element === "line-break") {
48
+ while (
49
+ first < elements.length &&
50
+ elements[first]?.element === "line-break" &&
51
+ !isPreservedLeadingLineBreak(elements[first])
52
+ ) {
48
53
  first++;
49
54
  }
50
55
 
@@ -21,7 +21,7 @@ export function parseBlockItem(ctx: ParseContext): BlockItemResult {
21
21
  }
22
22
 
23
23
  const fallback = ctx.blockFallbackRule.parse(ctx);
24
- if (fallback.success && fallback.elements.length > 0) {
24
+ if (fallback.success) {
25
25
  return { elements: fallback.elements, consumed: fallback.consumed };
26
26
  }
27
27
 
@@ -101,7 +101,7 @@ export function parseBlocksUntil(
101
101
  blockCtx.scope = blockScope;
102
102
 
103
103
  const result = parseBlockItem(blockCtx);
104
- elements.push(...result.elements);
104
+ for (const element of result.elements) elements.push(element);
105
105
  consumed += result.consumed;
106
106
  pos += result.consumed;
107
107
  }
@@ -1,3 +1,5 @@
1
+ import { stripAutomaticLineBreak } from "../../inline/parsing/automatic-line-break";
2
+ import { createAutomaticLineBreak } from "../../inline/parsing/automatic-line-break";
1
3
  import type { Element } from "@wdprlib/ast";
2
4
  import type { ParseContext } from "../../types";
3
5
  import { getCandidateInlineRules } from "../../inline/utils";
@@ -58,7 +60,7 @@ export function parseInlineContentUntil(
58
60
  consumed += newlineResult.consumed;
59
61
  pos += newlineResult.consumed;
60
62
  if (newlineResult.addLineBreak) {
61
- elements.push({ element: "line-break" });
63
+ elements.push(createAutomaticLineBreak(token));
62
64
  }
63
65
  continue;
64
66
  }
@@ -69,7 +71,7 @@ export function parseInlineContentUntil(
69
71
  for (const rule of getCandidateBlockRules(blockRules, token)) {
70
72
  const result = rule.parse(blockCtx);
71
73
  if (result.success) {
72
- elements.push(...result.elements);
74
+ for (const element of result.elements) elements.push(element);
73
75
  consumed += result.consumed;
74
76
  pos += result.consumed;
75
77
  matched = true;
@@ -84,7 +86,8 @@ export function parseInlineContentUntil(
84
86
  for (const rule of getCandidateInlineRules(inlineRules, token.type)) {
85
87
  const result = rule.parse(inlineCtx);
86
88
  if (result.success) {
87
- elements.push(...result.elements);
89
+ stripAutomaticLineBreak(elements, result.stripLeadingLineBreak);
90
+ for (const element of result.elements) elements.push(element);
88
91
  consumed += result.consumed;
89
92
  pos += result.consumed;
90
93
  matched = true;
@@ -1,3 +1,4 @@
1
+ import { stripAutomaticLineBreak } from "../../../inline/parsing/automatic-line-break";
1
2
  import { protectedInlineRegionEnd } from "../../../inline/raw/end";
2
3
  import type { Element, TableCell } from "@wdprlib/ast";
3
4
  import type { ParseContext } from "../../../types";
@@ -85,7 +86,8 @@ export function parseTableCell(
85
86
  for (const rule of getCandidateInlineRules(inlineRules, token.type)) {
86
87
  const result = rule.parse(inlineCtx);
87
88
  if (result.success) {
88
- children.push(...result.elements);
89
+ stripAutomaticLineBreak(children, result.stripLeadingLineBreak);
90
+ for (const element of result.elements) children.push(element);
89
91
  consumed += result.consumed;
90
92
  pos += result.consumed;
91
93
  matched = true;
@@ -69,7 +69,7 @@ export function parseCellContent(
69
69
  for (const rule of getCandidateInlineRules(ctx.inlineRules, token.type)) {
70
70
  const result = rule.parse(inlineCtx);
71
71
  if (result.success) {
72
- content.addInlineElements(result.elements);
72
+ content.addInlineElements(result.elements, result.stripLeadingLineBreak);
73
73
  consumed += result.consumed;
74
74
  pos += result.consumed;
75
75
  matched = true;
@@ -1,8 +1,10 @@
1
+ import type { Token } from "../../../../../lexer";
2
+ import { stripAutomaticLineBreak } from "../../../inline/parsing/automatic-line-break";
1
3
  import type { Element } from "@wdprlib/ast";
2
4
 
3
5
  export interface CellContentAccumulator {
4
6
  addInline(element: Element): void;
5
- addInlineElements(elements: Element[]): void;
7
+ addInlineElements(elements: Element[], stripLeadingLineBreak?: Token): void;
6
8
  addBlockElements(elements: Element[]): void;
7
9
  addParagraphBreak(): void;
8
10
  closeInlineSegmentBeforeBlock(): void;
@@ -40,7 +42,8 @@ export function createCellContentAccumulator(): CellContentAccumulator {
40
42
  addInline(element: Element) {
41
43
  currentSegment.push(element);
42
44
  },
43
- addInlineElements(nextElements: Element[]) {
45
+ addInlineElements(nextElements: Element[], stripLeadingLineBreak?: Token) {
46
+ stripAutomaticLineBreak(currentSegment, stripLeadingLineBreak);
44
47
  currentSegment.push(...nextElements);
45
48
  },
46
49
  addBlockElements(blockElements: Element[]) {
@@ -1,3 +1,4 @@
1
+ import { createAutomaticLineBreak } from "../../inline/parsing/automatic-line-break";
1
2
  import type { ParseContext } from "../../types";
2
3
  import type { CellContentAccumulator } from "./cell-content/segments";
3
4
 
@@ -40,7 +41,7 @@ export function consumeCellContentNewline(
40
41
  }
41
42
 
42
43
  if (!content.isEmpty()) {
43
- content.addInline({ element: "line-break" });
44
+ content.addInline(createAutomaticLineBreak(ctx.tokens[startPos]!));
44
45
  }
45
46
 
46
47
  return { consumed };
@@ -1,11 +1,13 @@
1
1
  import type { Element } from "@wdprlib/ast";
2
- import type { TokenType } from "../../../lexer";
2
+ import type { Token, TokenType } from "../../../lexer";
3
3
  import type { ParseContext } from "./parse-context";
4
4
 
5
5
  /**
6
6
  * Result of a rule attempt.
7
7
  */
8
- export type RuleResult<T> = { success: true; elements: T[]; consumed: number } | { success: false };
8
+ export type RuleResult<T> =
9
+ | { success: true; elements: T[]; consumed: number; stripLeadingLineBreak?: Token }
10
+ | { success: false };
9
11
 
10
12
  /**
11
13
  * Block rule interface.
@@ -10,6 +10,8 @@ import type { ParseContext } from "./parse-context";
10
10
  export interface ScopeContext {
11
11
  /** Exclusive token boundary inherited by nested inline rules. */
12
12
  readonly inlineEnd?: number;
13
+ /** Keep bare addresses as text inside an existing anchor. */
14
+ readonly suppressEmailLinks?: boolean;
13
15
  /** Closing delimiters paired across cells of the current pipe table. */
14
16
  readonly tableFormatting?: { end: number; suppressedClosers: Set<number> };
15
17
  /**
@@ -1,3 +1,4 @@
1
+ import type { Token } from "../../../../lexer";
1
2
  import type { Element } from "@wdprlib/ast";
2
3
  import type { ParseContext } from "../../types";
3
4
  import { inlineRules } from "../index";
@@ -6,6 +7,7 @@ import { getCandidateInlineRules } from "../utils";
6
7
  export interface AnchorChildResult {
7
8
  elements: Element[];
8
9
  consumed: number;
10
+ stripLeadingLineBreak?: Token;
9
11
  }
10
12
 
11
13
  export function parseAnchorChild(ctx: ParseContext, pos: number): AnchorChildResult {
@@ -14,11 +16,15 @@ export function parseAnchorChild(ctx: ParseContext, pos: number): AnchorChildRes
14
16
  return { elements: [], consumed: 0 };
15
17
  }
16
18
 
17
- const inlineCtx: ParseContext = { ...ctx, pos };
19
+ const inlineCtx: ParseContext = {
20
+ ...ctx,
21
+ pos,
22
+ scope: { ...ctx.scope, suppressEmailLinks: true },
23
+ };
18
24
  for (const rule of getCandidateInlineRules(inlineRules, token.type)) {
19
25
  const result = rule.parse(inlineCtx);
20
26
  if (result.success) {
21
- return { elements: result.elements, consumed: result.consumed };
27
+ return result;
22
28
  }
23
29
  }
24
30
 
@@ -1,3 +1,4 @@
1
+ import { stripAutomaticLineBreak } from "../parsing/automatic-line-break";
1
2
  import type { Element } from "@wdprlib/ast";
2
3
  import type { ParseContext } from "../../types";
3
4
  import { parseAnchorChild } from "./child";
@@ -50,7 +51,8 @@ export function parseAnchorContent(
50
51
  }
51
52
 
52
53
  const child = parseAnchorChild(ctx, pos);
53
- children.push(...child.elements);
54
+ stripAutomaticLineBreak(children, child.stripLeadingLineBreak);
55
+ for (const element of child.elements) children.push(element);
54
56
  pos += child.consumed;
55
57
  consumed += child.consumed;
56
58
  }
@@ -1,3 +1,4 @@
1
+ import { precedingSingleNewline } from "../parsing/automatic-line-break";
1
2
  /**
2
3
  *
3
4
  * Parses the Wikidot anchor inline block syntax: `[[a]]...[[/a]]`.
@@ -10,7 +11,8 @@
10
11
  * - `[[a_ href="url"]]text[[/a]]` -- paragraph strip mode (trailing underscore)
11
12
  *
12
13
  * Paragraph strip mode (`[[a_]]`) suppresses newlines within the anchor
13
- * body and strips at most one trailing newline after the closing tag
14
+ * body and strips at most one automatic newline before the opening tag
15
+ * and one trailing newline after the closing tag
14
16
  * (preserving double newlines as paragraph breaks). This prevents
15
17
  * unwanted `<br>` elements when consecutive anchor blocks are placed on
16
18
  * separate lines.
@@ -98,6 +100,7 @@ export const anchorRule: InlineRule = {
98
100
  },
99
101
  ],
100
102
  consumed,
103
+ stripLeadingLineBreak: openResult.paragraphStrip ? precedingSingleNewline(ctx) : undefined,
101
104
  };
102
105
  },
103
106
  };
@@ -1,3 +1,4 @@
1
+ import { createAutomaticLineBreak } from "../parsing/automatic-line-break";
1
2
  import type { Element } from "@wdprlib/ast";
2
3
  import type { ParseContext } from "../../types";
3
4
 
@@ -15,7 +16,7 @@ export function consumeAnchorNewline(
15
16
  let nextPos = pos + 1;
16
17
 
17
18
  if (!paragraphStrip) {
18
- children.push({ element: "line-break" });
19
+ children.push(createAutomaticLineBreak(ctx.tokens[pos]!));
19
20
  while (ctx.tokens[nextPos]?.type === "WHITESPACE" && ctx.tokens[nextPos]?.lineStart) {
20
21
  nextPos++;
21
22
  consumed++;
@@ -0,0 +1,153 @@
1
+ /**
2
+ *
3
+ * Parses bare URLs in plain text into links (auto-linking).
4
+ *
5
+ * Wikidot converts bare URLs like `http://example.com/page` into
6
+ * `<a href="URL">URL</a>` without any bracket syntax. A `*` prefix
7
+ * (`*http://...`) opens the link in a new tab.
8
+ *
9
+ * Mirrors Text_Wiki's Url rule (Parse/Default/Url.php):
10
+ * - Recognized schemes: `http://`, `https://`, `ftp://`, `gopher://`,
11
+ * `news://`, `mms://`, and `mailto:`
12
+ * - A URL is only recognized at line start or after a non-alphabetic
13
+ * character (`(^|[^A-Za-z])` in the original regex)
14
+ * - The final character of a URL must be alphanumeric or one of
15
+ * `%/?=&~_`, so trailing punctuation (`.`, `,`, `)` etc.) stays as text
16
+ *
17
+ * Produces a `"link"` AST element with `type: "direct"`.
18
+ *
19
+ * @module
20
+ */
21
+ import type { AnchorTarget, Element } from "@wdprlib/ast";
22
+ import type { TokenType } from "../../../lexer";
23
+ import { URL_PATTERN, URL_SCHEME_NAMES } from "../../../lexer/url-schemes";
24
+ import type { InlineRule, ParseContext, RuleResult } from "../types";
25
+ import { rawRegionEnd } from "./raw/end";
26
+
27
+ /**
28
+ * Structural block/link delimiters that terminate a bare URL. Wikitext
29
+ * constructs like `[[span]]…[[/span]]` and `[[[page]]]` are resolved before
30
+ * URLs in Text_Wiki (their bodies are delimiter-wrapped), so a bare URL must
31
+ * not swallow the enclosing scope's close marker (e.g. the `[[/span]]` in
32
+ * `[[span]]http://x/[[/span]]`).
33
+ */
34
+ const URL_BOUNDARY_TOKENS: ReadonlySet<TokenType> = new Set<TokenType>([
35
+ "WHITESPACE",
36
+ "NEWLINE",
37
+ "EOF",
38
+ "BLOCK_OPEN",
39
+ "BLOCK_END_OPEN",
40
+ "BLOCK_CLOSE",
41
+ "LINK_OPEN",
42
+ "LINK_CLOSE",
43
+ "COMMENT_OPEN",
44
+ "BACKSLASH_BREAK",
45
+ ]);
46
+
47
+ /**
48
+ * Inline rule for auto-linking bare URLs.
49
+ *
50
+ * Triggered by an `IDENTIFIER` token whose value is a URL scheme name,
51
+ * or by a `*` prefix (`STAR` mid-line, `LIST_BULLET` at line start —
52
+ * the list rule requires a space after the marker, so `*http://...`
53
+ * falls through to inline parsing).
54
+ */
55
+ export const autolinkRule: InlineRule = {
56
+ name: "autolink",
57
+ startTokens: ["IDENTIFIER", "STAR", "LIST_BULLET"],
58
+
59
+ parse(ctx: ParseContext): RuleResult<Element> {
60
+ let pos = ctx.pos;
61
+ let target: AnchorTarget | null = null;
62
+
63
+ const first = ctx.tokens[pos];
64
+ if (!first) {
65
+ return { success: false };
66
+ }
67
+
68
+ if (first.type === "STAR" || first.type === "LIST_BULLET") {
69
+ if (first.value !== "*") {
70
+ return { success: false };
71
+ }
72
+ target = "new-tab";
73
+ pos++;
74
+ }
75
+
76
+ const scheme = ctx.tokens[pos];
77
+ if (scheme?.type !== "IDENTIFIER" || !URL_SCHEME_NAMES.has(scheme.value)) {
78
+ return { success: false };
79
+ }
80
+ if (ctx.tokens[pos + 1]?.type !== "COLON") {
81
+ return { success: false };
82
+ }
83
+
84
+ // Text_Wikiの前置条件 `(^|[^A-Za-z])`: 行頭または非英字の直後でのみURLと認識する
85
+ const prev = ctx.tokens[ctx.pos - 1];
86
+ if (prev && !first.lineStart) {
87
+ const lastChar = prev.value[prev.value.length - 1] ?? "";
88
+ if (/[A-Za-z]/.test(lastChar)) {
89
+ return { success: false };
90
+ }
91
+ }
92
+
93
+ // URL候補: 空白・改行・構造デリミタの手前までのトークン列を文字列として連結する。
94
+ // ブロック/リンクの開閉トークン([[ ]] [[/ [[[ ]]])で止めることで、
95
+ // 囲みスコープの閉じマーカー([[/span]]等)をURLに取り込まないようにする
96
+ const values: string[] = [];
97
+ let end = pos;
98
+ const inlineEnd = ctx.scope.inlineEnd ?? ctx.tokens.length;
99
+ while (end < inlineEnd) {
100
+ const token = ctx.tokens[end];
101
+ if (!token || URL_BOUNDARY_TOKENS.has(token.type)) {
102
+ break;
103
+ }
104
+ if (token.type === "RAW_OPEN" || token.type === "RAW_BLOCK_OPEN") {
105
+ if (rawRegionEnd(ctx.tokens, end, inlineEnd) > end) break;
106
+ }
107
+ values.push(token.value);
108
+ end++;
109
+ // Compact TEXT tokens can contain whitespace; do not scan subsequent URLs again.
110
+ if (/[ \t\n\\"']/.test(token.value)) break;
111
+ }
112
+
113
+ const candidate = values.join("");
114
+ const match = URL_PATTERN.exec(candidate);
115
+ if (!match) {
116
+ return { success: false };
117
+ }
118
+
119
+ // マッチ全体を含むところまでトークンを消費する。大きなソースの一括テキスト化では
120
+ // `page. b` のようにURL末尾と後続テキストが同一トークンに融合するため、マッチ終端が
121
+ // トークン中間に落ちた場合は残り部分をテキスト要素として返す
122
+ const url = match[0];
123
+ let length = 0;
124
+ let count = 0;
125
+ while (length < url.length) {
126
+ length += values[count]?.length ?? 0;
127
+ count++;
128
+ }
129
+ const rest = candidate.slice(url.length, length);
130
+
131
+ const elements: Element[] = [
132
+ {
133
+ element: "link",
134
+ data: {
135
+ type: "direct",
136
+ link: url,
137
+ extra: null,
138
+ label: { text: url },
139
+ target,
140
+ },
141
+ },
142
+ ];
143
+ if (rest !== "") {
144
+ elements.push({ element: "text", data: rest });
145
+ }
146
+
147
+ return {
148
+ success: true,
149
+ elements,
150
+ consumed: pos - ctx.pos + count,
151
+ };
152
+ },
153
+ };
@@ -0,0 +1,17 @@
1
+ /** Button's quoted attributes use Text_Wiki's getAttrs splitting rules. */
2
+ export function parseButtonAttributes(source: string): Record<string, string> {
3
+ const sections = source.trim().split('="');
4
+ const attrs: Record<string, string> = {};
5
+ let key = sections[0]!.trim();
6
+ for (const section of sections.slice(1)) {
7
+ const quote = section.lastIndexOf('"');
8
+ if (quote < 0) continue;
9
+ if (key === "text" || key === "class" || key === "style") {
10
+ attrs[key] = section
11
+ .slice(0, quote)
12
+ .replace(/\\([\s\S]|$)/g, (_match, char: string) => (char === "0" ? "\0" : char));
13
+ }
14
+ key = section.slice(quote + 1).trim();
15
+ }
16
+ return attrs;
17
+ }
@@ -0,0 +1,17 @@
1
+ import type { InlineRule } from "../../types";
2
+ import { parseButtonSyntax } from "./syntax";
3
+
4
+ export const buttonRule: InlineRule = {
5
+ name: "button",
6
+ startTokens: ["BLOCK_OPEN"],
7
+ parse(ctx) {
8
+ const result = parseButtonSyntax(ctx, ctx.pos, ctx.scope.inlineEnd ?? ctx.tokens.length);
9
+ return result
10
+ ? {
11
+ success: true,
12
+ consumed: result.end - ctx.pos,
13
+ elements: [{ element: "button", data: result.data }],
14
+ }
15
+ : { success: false };
16
+ },
17
+ };