@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
@@ -1,3 +1,4 @@
1
+ import { createAutomaticLineBreak } from "../parsing/automatic-line-break";
1
2
  import type { Element } from "@wdprlib/ast";
2
3
  import type { TokenType } from "../../../../lexer";
3
4
  import type { InlineRule, ParseContext, RuleResult } from "../../types";
@@ -27,7 +28,7 @@ export const newlineLineBreakRule: InlineRule = {
27
28
 
28
29
  return {
29
30
  success: true,
30
- elements: [{ element: "line-break" }],
31
+ elements: [createAutomaticLineBreak(currentTok)],
31
32
  consumed: 1,
32
33
  };
33
34
  },
@@ -53,6 +54,12 @@ function isValidBlockStartAfterNewline(ctx: ParseContext, tokenPos: number): boo
53
54
  return false;
54
55
  }
55
56
 
57
+ if (token.type === "LIST_BULLET" || token.type === "LIST_NUMBER") {
58
+ // リスト構文はマーカー直後の空白が必須(list/line.tsと同じ規則)。
59
+ // 空白なしの行(例: 行頭の `*http://...`)はリストにならないため<br>を抑制しない
60
+ return ctx.tokens[tokenPos + 1]?.type === "WHITESPACE";
61
+ }
62
+
56
63
  if (token.type !== "HEADING_MARKER") {
57
64
  return true;
58
65
  }
@@ -1,5 +1,13 @@
1
+ import { startsWithUrlScheme } from "../../../../lexer/url-schemes";
2
+
3
+ /**
4
+ * Whether `url` is a valid target for a single-bracket link (`[url label]`).
5
+ *
6
+ * Matches Text_Wiki's described-URL rule (regexLiberal): a site-relative path
7
+ * (`/…`) or any recognized URL scheme (http/https/ftp/gopher/news/mms/mailto).
8
+ * Kept in sync with the autolink rule so bracket-described links and bare
9
+ * auto-links accept the same schemes.
10
+ */
1
11
  export function isDirectBracketUrl(url: string): boolean {
2
- return (
3
- url !== "" && (url.startsWith("/") || url.startsWith("http://") || url.startsWith("https://"))
4
- );
12
+ return url !== "" && (url.startsWith("/") || startsWithUrlScheme(url, 0));
5
13
  }
@@ -4,8 +4,10 @@ import { normalizeAnchor } from "./anchor";
4
4
  import { isDirectBracketUrl } from "./direct-url";
5
5
  import { collectBracketLinkParts } from "./parts";
6
6
  import { parseBracketLinkPrefix } from "./prefix";
7
+ import { isBracketEmail, wikipediaPage } from "./special-target";
7
8
 
8
9
  export interface ParsedBracketLink {
10
+ interwiki?: boolean;
9
11
  link: string;
10
12
  labelText: string;
11
13
  target: AnchorTarget | null;
@@ -20,19 +22,22 @@ export function parseSingleBracketLink(ctx: ParseContext): ParsedBracketLink | n
20
22
  }
21
23
 
22
24
  const link = parts.first.trim();
23
- if (!isDirectBracketUrl(link)) {
25
+ const email = isBracketEmail(link);
26
+ const wikiPage = wikipediaPage(link);
27
+ if (!email && wikiPage === null && !isDirectBracketUrl(link)) {
24
28
  return null;
25
29
  }
26
30
 
27
- const labelText = parts.label.trim();
31
+ const labelText = parts.label.trim() || wikiPage;
28
32
  if (!labelText) {
29
33
  return null;
30
34
  }
31
35
 
32
36
  return {
33
- link,
37
+ link: email ? `mailto:${link}` : link,
38
+ interwiki: wikiPage !== null,
34
39
  labelText,
35
- target: prefix.target,
40
+ target: wikiPage !== null ? "new-tab" : prefix.target,
36
41
  consumed: 1 + prefix.consumed + parts.consumed,
37
42
  };
38
43
  }
@@ -16,49 +16,27 @@ export function collectBracketLinkParts(
16
16
  }
17
17
 
18
18
  let pos = startPos;
19
- let consumed = 0;
20
- let first = "";
21
-
22
- while (pos < ctx.tokens.length) {
23
- const token = ctx.tokens[pos];
24
- if (
25
- !token ||
26
- token.type === "WHITESPACE" ||
27
- token.type === "BRACKET_CLOSE" ||
28
- token.type === "NEWLINE" ||
29
- token.type === "EOF"
30
- ) {
31
- break;
32
- }
33
- first += token.value;
34
- pos++;
35
- consumed++;
36
- }
37
-
38
- while (ctx.tokens[pos]?.type === "WHITESPACE") {
39
- pos++;
40
- consumed++;
41
- }
42
-
43
- let label = "";
44
- while (pos < ctx.tokens.length) {
19
+ const end = Math.min(ctx.scope.inlineEnd ?? ctx.tokens.length, ctx.tokens.length);
20
+ const values: string[] = [];
21
+ while (pos < end) {
45
22
  const token = ctx.tokens[pos];
46
23
  if (
47
24
  !token ||
48
25
  token.type === "BRACKET_CLOSE" ||
49
26
  token.type === "NEWLINE" ||
50
27
  token.type === "EOF"
51
- ) {
28
+ )
52
29
  break;
53
- }
54
- label += token.value;
30
+ values.push(token.value);
55
31
  pos++;
56
- consumed++;
57
32
  }
58
-
59
- if (ctx.tokens[pos]?.type !== "BRACKET_CLOSE") {
60
- return null;
61
- }
62
-
63
- return { first, label, consumed: consumed + 1 };
33
+ if (pos >= end || ctx.tokens[pos]?.type !== "BRACKET_CLOSE") return null;
34
+ // Compact TEXT tokens can contain both the target and the label separator.
35
+ const content = values.join("");
36
+ const separator = content.search(/[ \t]/);
37
+ return {
38
+ first: separator === -1 ? content : content.slice(0, separator),
39
+ label: separator === -1 ? "" : content.slice(separator).trimStart(),
40
+ consumed: pos - startPos + 1,
41
+ };
64
42
  }
@@ -0,0 +1,9 @@
1
+ /** Text_Wiki Email.php described-email grammar; no URI header characters. */
2
+ export function isBracketEmail(target: string): boolean {
3
+ return /^[_a-z0-9-]+(?:\.[_a-z0-9-]+)*@[a-z0-9-]+(?:\.[a-z0-9-]+)+$/i.test(target);
4
+ }
5
+
6
+ /** Single-bracket Interwiki.php grammar, restricted to the verified Wikipedia mapping. */
7
+ export function wikipediaPage(target: string): string | null {
8
+ return /^wikipedia:((?!:)[A-Za-z0-9_/=&~#.:;+-]+)$/.exec(target)?.[1] ?? null;
9
+ }
@@ -8,15 +8,17 @@
8
8
  * Supported URL formats:
9
9
  * - Absolute URLs: `[https://example.com/ Label]`
10
10
  * - Relative paths: `[/some-page Label]`
11
+ * - Email addresses: `[support@example.com Label]`
12
+ * - Wikipedia links: `[wikipedia:Article]` or `[wikipedia:Article Label]`
11
13
  *
12
14
  * An optional `*` prefix on the URL opens the link in a new tab:
13
15
  * `[*https://example.com/ Opens in new tab]`.
14
16
  *
15
17
  * Unlike triple-bracket links (`[[[page]]]`), single-bracket links
16
- * require a full URL (starting with `http://`, `https://`, or `/`).
17
- * The label text is required.
18
+ * require an external target or site-relative path.
19
+ * The label text is required except for Wikipedia links.
18
20
  *
19
- * Produces a `"link"` AST element with `type: "direct"`.
21
+ * Produces a `"link"` AST element with `type: "direct"` or `"interwiki"`.
20
22
  *
21
23
  * @module
22
24
  */
@@ -33,8 +35,8 @@ import { parseSingleBracketLink } from "./link-bracket/parsed";
33
35
  *
34
36
  * Fails if:
35
37
  * - No closing `]` is found on the same line
36
- * - The URL does not start with `http://`, `https://`, or `/`
37
- * - The label text is empty
38
+ * - The target is not a supported URL, email address, or Wikipedia target
39
+ * - The label is empty for a non-Wikipedia target
38
40
  */
39
41
  export const linkSingleRule: InlineRule = {
40
42
  name: "linkSingle",
@@ -44,7 +46,7 @@ export const linkSingleRule: InlineRule = {
44
46
  * Attempts to parse a single-bracket link at the current position.
45
47
  *
46
48
  * @param ctx - Parse context with token stream and current position
47
- * @returns A successful result with a `"link"` element of type `"direct"`,
49
+ * @returns A successful result with a `"link"` element of type `"direct"` or `"interwiki"`,
48
50
  * or `{ success: false }`
49
51
  */
50
52
  parse(ctx: ParseContext): RuleResult<Element> {
@@ -59,7 +61,7 @@ export const linkSingleRule: InlineRule = {
59
61
  {
60
62
  element: "link",
61
63
  data: {
62
- type: "direct",
64
+ type: parsed.interwiki ? "interwiki" : "direct",
63
65
  link: parsed.link,
64
66
  extra: null,
65
67
  label: linkLabel,
@@ -36,6 +36,7 @@ export const linkTripleRule: InlineRule = {
36
36
  const normalized = normalizeTripleLinkTarget(trimmedTarget);
37
37
  const { linkType, link } = determineLinkTypeAndLocation(normalized.target);
38
38
  const label = buildTripleLinkLabel({
39
+ isPage: linkType === "page",
39
40
  foundPipe: parts.foundPipe,
40
41
  labelText: parts.labelText,
41
42
  finalTarget: normalized.target,
@@ -1,13 +1,19 @@
1
1
  import type { LinkLabel } from "@wdprlib/ast";
2
2
 
3
3
  export function buildTripleLinkLabel(args: {
4
+ isPage: boolean;
4
5
  foundPipe: boolean;
5
6
  labelText: string;
6
7
  finalTarget: string;
7
8
  originalTarget: string;
8
9
  }): LinkLabel {
10
+ if (args.isPage && args.foundPipe && !args.labelText.trim()) return "page";
9
11
  return {
10
- text: getTripleLinkDisplayText(args),
12
+ text: getTripleLinkDisplayText({
13
+ ...args,
14
+ originalTarget:
15
+ args.isPage && !args.foundPipe ? args.originalTarget.split("#")[0]! : args.originalTarget,
16
+ }),
11
17
  };
12
18
  }
13
19
 
@@ -0,0 +1,35 @@
1
+ import type { Element } from "@wdprlib/ast";
2
+ import type { Token } from "../../../../lexer";
3
+ import type { ParseContext } from "../../types";
4
+
5
+ // Suppressed newlines must not cause an earlier, unrelated break to be removed.
6
+ const origins = new WeakMap<Element, Token>();
7
+
8
+ export function createAutomaticLineBreak(token: Token): Element {
9
+ const element: Element = { element: "line-break" };
10
+ origins.set(element, token);
11
+ return element;
12
+ }
13
+
14
+ export function precedingSingleNewline(ctx: ParseContext): Token | undefined {
15
+ let pos = ctx.pos - 1;
16
+ while (ctx.tokens[pos]?.type === "WHITESPACE") pos--;
17
+ const newline = ctx.tokens[pos];
18
+ if (newline?.type !== "NEWLINE") return undefined;
19
+ pos--;
20
+ while (ctx.tokens[pos]?.type === "WHITESPACE") pos--;
21
+ if (ctx.tokens[pos]?.type === "NEWLINE") return undefined;
22
+ return newline;
23
+ }
24
+
25
+ export function stripAutomaticLineBreak(elements: Element[], token: Token | undefined): void {
26
+ if (!token) return;
27
+ let index = elements.length - 1;
28
+ while (index >= 0) {
29
+ const element = elements[index];
30
+ if (element?.element !== "text" || !/^[ \t]*$/.test(element.data)) break;
31
+ index--;
32
+ }
33
+ const element = elements[index];
34
+ if (element && origins.get(element) === token) elements.length = index;
35
+ }
@@ -1,3 +1,4 @@
1
+ import { findNoteBounds } from "../../block/note/boundary";
1
2
  import type { ParseContext } from "../../types";
2
3
  import { INDENT_ACCEPTING_BLOCK_NAMES, KNOWN_BLOCK_NAMES } from "../../../constants";
3
4
  import { parseBlockName } from "../../common";
@@ -27,6 +28,7 @@ export function isUnknownBlockToken(ctx: ParseContext, tokenPos: number): boolea
27
28
  }
28
29
  return true;
29
30
  }
31
+ if (nameResult.name === "note") return findNoteBounds(ctx, tokenPos) === null;
30
32
  return !KNOWN_BLOCK_NAMES.has(nameResult.name);
31
33
  }
32
34
 
@@ -32,11 +32,21 @@ export function isParagraphBreakingBlockStart(
32
32
  !isAnchorName(ctx, nextPos) &&
33
33
  !isInvalidBlockOpen(ctx, nextPos) &&
34
34
  !isInvalidHeading(ctx, nextPos) &&
35
+ !isInvalidListMarker(ctx, nextPos) &&
35
36
  !isExcludedBlockStart(ctx, nextPos) &&
36
37
  !isUnknownBlockStart(ctx, nextPos)
37
38
  );
38
39
  }
39
40
 
41
+ function isInvalidListMarker(ctx: ParseContext, markerPos: number): boolean {
42
+ const marker = ctx.tokens[markerPos];
43
+ if (marker?.type !== "LIST_BULLET" && marker?.type !== "LIST_NUMBER") return false;
44
+
45
+ // リスト構文はマーカー直後の空白が必須(list/line.tsと同じ規則)。
46
+ // 空白なしの行(例: 行頭の `*http://...`)はリストにならないため段落境界にもしない
47
+ return ctx.tokens[markerPos + 1]?.type !== "WHITESPACE";
48
+ }
49
+
40
50
  function isOrphanCloseSpan(ctx: ParseContext, blockEndOpenPos: number): boolean {
41
51
  const token = ctx.tokens[blockEndOpenPos];
42
52
  if (token?.type !== "BLOCK_END_OPEN") return false;
@@ -1,3 +1,8 @@
1
+ import { stripAutomaticLineBreak } from "./automatic-line-break";
2
+ import { parseButtonSyntax } from "../button/syntax";
3
+ import { parseSocialSyntax } from "../social/syntax";
4
+ import { parseDateSyntax } from "../date/syntax";
5
+ import { emailRegionEnd } from "../email/candidates";
1
6
  import { protectedInlineRegionEnd } from "../raw/end";
2
7
  import type { Element } from "@wdprlib/ast";
3
8
  import type { ParseContext } from "../../types";
@@ -7,7 +12,10 @@ import {
7
12
  type InlineEndType,
8
13
  } from "./plain-text";
9
14
  import { getParagraphNewlineBoundary } from "./paragraph-boundary";
10
- import { createPreservedTrailingLineBreak } from "./preserved-line-break";
15
+ import {
16
+ createPreservedLeadingLineBreak,
17
+ createPreservedTrailingLineBreak,
18
+ } from "./preserved-line-break";
11
19
  import { getCandidateInlineRules } from "./rules";
12
20
  import { parseSimpleInlineToken } from "./simple-token";
13
21
 
@@ -31,13 +39,20 @@ export function parseInlineUntil(ctx: ParseContext, endType: InlineEndType): Inl
31
39
  const nodes: Element[] = [];
32
40
  let consumed = 0;
33
41
  let pos = ctx.pos;
42
+ let consumedEmptyRaw = false;
34
43
 
35
44
  const paragraphMode = endType === "PARAGRAPH_BREAK";
36
45
  const multiline = paragraphMode || FORMATTING_CLOSE_TOKENS.has(endType);
37
46
  let inlineEnd = ctx.scope.inlineEnd ?? ctx.tokens.length;
38
47
  if (!multiline) {
39
48
  for (let end = ctx.pos; end < inlineEnd; end++) {
40
- const protectedEnd = protectedInlineRegionEnd(ctx.tokens, end, inlineEnd);
49
+ const protectedEnd = Math.max(
50
+ parseButtonSyntax(ctx, end, inlineEnd)?.end ?? end,
51
+ parseSocialSyntax(ctx, end, inlineEnd)?.end ?? end,
52
+ parseDateSyntax(ctx, end, inlineEnd)?.end ?? end,
53
+ emailRegionEnd(ctx.tokens, end, inlineEnd),
54
+ protectedInlineRegionEnd(ctx.tokens, end, inlineEnd),
55
+ );
41
56
  if (protectedEnd > end) {
42
57
  end = protectedEnd - 1;
43
58
  continue;
@@ -97,11 +112,12 @@ export function parseInlineUntil(ctx: ParseContext, endType: InlineEndType): Inl
97
112
  continue;
98
113
  }
99
114
 
100
- if (token.type === endType) {
115
+ const hasEmail = emailRegionEnd(ctx.tokens, pos, inlineEnd) > pos;
116
+ if (token.type === endType && !hasEmail) {
101
117
  break;
102
118
  }
103
119
 
104
- if (canCollectLongPlainTextRuns) {
120
+ if (canCollectLongPlainTextRuns && !hasEmail) {
105
121
  const plainTextRun = collectLongPlainTextRun(ctx, pos, endType);
106
122
  if (plainTextRun) {
107
123
  nodes.push({ element: "text", data: plainTextRun.value });
@@ -112,7 +128,7 @@ export function parseInlineUntil(ctx: ParseContext, endType: InlineEndType): Inl
112
128
  }
113
129
 
114
130
  const simpleToken = parseSimpleInlineToken(token, ctx.tokens[pos + 1]);
115
- if (simpleToken) {
131
+ if (simpleToken && !hasEmail) {
116
132
  nodes.push(simpleToken.element);
117
133
  consumed += simpleToken.consumed;
118
134
  pos += simpleToken.consumed;
@@ -125,6 +141,10 @@ export function parseInlineUntil(ctx: ParseContext, endType: InlineEndType): Inl
125
141
  for (const rule of getCandidateInlineRules(inlineRules, token.type)) {
126
142
  const result = rule.parse(inlineCtx);
127
143
  if (result.success) {
144
+ stripAutomaticLineBreak(nodes, result.stripLeadingLineBreak);
145
+ if (rule.name === "raw" && result.elements.length === 0 && nodes.length === 0) {
146
+ consumedEmptyRaw = true;
147
+ }
128
148
  if (rule.name === "comment") {
129
149
  let after = pos + result.consumed;
130
150
  while (ctx.tokens[after]?.type === "WHITESPACE") after++;
@@ -139,7 +159,17 @@ export function parseInlineUntil(ctx: ParseContext, endType: InlineEndType): Inl
139
159
  if (nodes.at(-1)?.element === "line-break") nodes.pop();
140
160
  }
141
161
  }
142
- nodes.push(...result.elements);
162
+ for (const element of result.elements) {
163
+ nodes.push(
164
+ paragraphMode &&
165
+ consumedEmptyRaw &&
166
+ nodes.length === 0 &&
167
+ token.type === "NEWLINE" &&
168
+ element.element === "line-break"
169
+ ? createPreservedLeadingLineBreak()
170
+ : element,
171
+ );
172
+ }
143
173
  consumed += result.consumed;
144
174
  pos += result.consumed;
145
175
  matched = true;
@@ -1,4 +1,6 @@
1
+ import { getEmailCandidate } from "../email/candidates";
1
2
  import type { TokenType } from "../../../../lexer";
3
+ import { URL_SCHEME_NAMES } from "../../../../lexer/url-schemes";
2
4
  import type { ParseContext } from "../../types";
3
5
 
4
6
  const MIN_INLINE_TEXT_RUN_LENGTH = 32;
@@ -18,6 +20,7 @@ export function collectLongPlainTextRun(
18
20
  ): PlainTextRun | null {
19
21
  const firstToken = ctx.tokens[startPos];
20
22
  if (
23
+ !getEmailCandidate(ctx.tokens, startPos) &&
21
24
  firstToken?.type === "TEXT" &&
22
25
  firstToken.value.length >= MIN_INLINE_TEXT_RUN_LENGTH &&
23
26
  firstToken.value !== "("
@@ -50,10 +53,12 @@ export function collectLongPlainTextRun(
50
53
 
51
54
  function isPlainTextRunToken(ctx: ParseContext, pos: number): boolean {
52
55
  const token = ctx.tokens[pos];
53
- if (!token) return false;
56
+ if (!token || getEmailCandidate(ctx.tokens, pos)) return false;
54
57
 
55
58
  if (token.type === "IDENTIFIER") {
56
- return true;
59
+ // 連続平文の一括テキスト化がURL先頭のスキーム名を取り込むと
60
+ // autolinkルールに到達しなくなるため、スキーム名の手前で止める
61
+ return !(URL_SCHEME_NAMES.has(token.value) && ctx.tokens[pos + 1]?.type === "COLON");
57
62
  }
58
63
 
59
64
  if (token.type === "WHITESPACE") {
@@ -2,6 +2,7 @@ import type { Element } from "@wdprlib/ast";
2
2
 
3
3
  type PreservedLineBreak = Extract<Element, { element: "line-break" }> & {
4
4
  _preservedTrailingBreak?: boolean;
5
+ _preservedLeadingBreak?: boolean;
5
6
  };
6
7
 
7
8
  export function createPreservedTrailingLineBreak(): Element {
@@ -9,3 +10,15 @@ export function createPreservedTrailingLineBreak(): Element {
9
10
  lineBreak._preservedTrailingBreak = true;
10
11
  return lineBreak;
11
12
  }
13
+
14
+ export function createPreservedLeadingLineBreak(): Element {
15
+ const lineBreak: PreservedLineBreak = { element: "line-break", _preservedLeadingBreak: true };
16
+ return lineBreak;
17
+ }
18
+
19
+ export function isPreservedLeadingLineBreak(element: Element | undefined): boolean {
20
+ return (
21
+ element?.element === "line-break" &&
22
+ (element as PreservedLineBreak)._preservedLeadingBreak === true
23
+ );
24
+ }
@@ -0,0 +1,19 @@
1
+ import type { Token } from "../../../../lexer";
2
+
3
+ const unclosedRanges = new WeakMap<readonly Token[], { start: number; end: number }>();
4
+
5
+ /** Raw directives end at the first ]], even inside a quoted value. */
6
+ export function findRawTagClose(
7
+ tokens: readonly Token[],
8
+ start: number,
9
+ end: number,
10
+ ): number | null {
11
+ const unclosed = unclosedRanges.get(tokens);
12
+ if (unclosed && start >= unclosed.start && end <= unclosed.end) return null;
13
+ for (let close = start; close < end; close++) {
14
+ if (tokens[close]?.type === "BLOCK_CLOSE") return close;
15
+ if (!tokens[close] || tokens[close]?.type === "EOF") break;
16
+ }
17
+ unclosedRanges.set(tokens, { start, end });
18
+ return null;
19
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Element } from "@wdprlib/ast";
2
+ import { decodeHTML } from "entities";
2
3
  import type { ParseContext, RuleResult } from "../../types";
3
4
  import { currentToken, hasClosingMarkerBeforeNewline } from "../../types";
4
5
  import { rawElement, textElement } from "./result";
@@ -36,5 +37,5 @@ export function parseAngleRaw(ctx: ParseContext): RuleResult<Element> {
36
37
  consumed++;
37
38
  }
38
39
 
39
- return rawElement(value, consumed);
40
+ return rawElement(decodeHTML(value), consumed);
40
41
  }
@@ -1,15 +1,35 @@
1
1
  import type { Token } from "../../../../lexer";
2
2
 
3
+ // A failed angle-raw search also rules out later openers within the same immutable token range.
4
+ const unclosedAngleRanges = new WeakMap<
5
+ readonly Token[],
6
+ { start: number; end: number; lineEnd: boolean }
7
+ >();
8
+
3
9
  /** Exclusive end of a complete single-line raw region, or the original position. */
4
10
  export function rawRegionEnd(tokens: readonly Token[], start: number, end: number): number {
5
11
  const type = tokens[start]?.type;
6
12
  const close =
7
13
  type === "RAW_OPEN" ? "RAW_OPEN" : type === "RAW_BLOCK_OPEN" ? "RAW_BLOCK_CLOSE" : null;
8
14
  if (!close) return start;
15
+ const cached = type === "RAW_BLOCK_OPEN" ? unclosedAngleRanges.get(tokens) : undefined;
16
+ if (
17
+ cached &&
18
+ start >= cached.start &&
19
+ start < cached.end &&
20
+ (cached.lineEnd || end <= cached.end)
21
+ ) {
22
+ return start;
23
+ }
9
24
  for (let pos = start + 1; pos < end; pos++) {
10
- if (tokens[pos]?.type === "NEWLINE" || tokens[pos]?.type === "EOF") break;
25
+ if (tokens[pos]?.type === "NEWLINE" || tokens[pos]?.type === "EOF") {
26
+ if (type === "RAW_BLOCK_OPEN")
27
+ unclosedAngleRanges.set(tokens, { start, end: pos, lineEnd: true });
28
+ return start;
29
+ }
11
30
  if (tokens[pos]?.type === close) return pos + 1;
12
31
  }
32
+ if (type === "RAW_BLOCK_OPEN") unclosedAngleRanges.set(tokens, { start, end, lineEnd: false });
13
33
  return start;
14
34
  }
15
35
 
@@ -32,7 +32,7 @@ export function parseSizeContent(ctx: ParseContext, startPos: number): SizeConte
32
32
  const inlineCtx: ParseContext = { ...ctx, pos };
33
33
  const inlineResult = parseInlineUntil(inlineCtx, "BLOCK_END_OPEN");
34
34
  if (inlineResult.elements.length > 0) {
35
- children.push(...inlineResult.elements);
35
+ for (const element of inlineResult.elements) children.push(element);
36
36
  pos += inlineResult.consumed;
37
37
  consumed += inlineResult.consumed;
38
38
  } else {
@@ -1,6 +1,16 @@
1
1
  import type { ParseContext } from "../../types";
2
2
 
3
3
  const VALID_SIZE_UNITS = ["px", "em", "rem", "ex", "%", "cm", "mm", "in", "pc"];
4
+ const VALID_SIZE_KEYWORDS = [
5
+ "smaller",
6
+ "larger",
7
+ "xx-small",
8
+ "x-small",
9
+ "small",
10
+ "large",
11
+ "x-large",
12
+ "xx-large",
13
+ ];
4
14
 
5
15
  export function parseSizeValue(
6
16
  ctx: ParseContext,
@@ -40,6 +50,7 @@ export function parseSizeValue(
40
50
  }
41
51
 
42
52
  function isValidSizeValue(size: string): boolean {
53
+ if (VALID_SIZE_KEYWORDS.includes(size)) return true;
43
54
  const unitPattern = VALID_SIZE_UNITS.join("|");
44
55
  return new RegExp(`^(\\d+(?:\\.\\d+)?)(${unitPattern})$`, "i").test(size);
45
56
  }
@@ -0,0 +1,17 @@
1
+ import type { InlineRule } from "../../types";
2
+ import { parseSocialSyntax } from "./syntax";
3
+
4
+ export const socialRule: InlineRule = {
5
+ name: "social",
6
+ startTokens: ["BLOCK_OPEN"],
7
+ parse(ctx) {
8
+ const result = parseSocialSyntax(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: "social", data: result.data }],
14
+ }
15
+ : { success: false };
16
+ },
17
+ };
@@ -0,0 +1,40 @@
1
+ import type { SocialData } from "@wdprlib/ast";
2
+ import type { Token } from "../../../../lexer";
3
+ import type { ParseContext } from "../../types";
4
+ import { findRawTagClose } from "../parsing/raw-tag";
5
+
6
+ const invalidBodies = new WeakMap<readonly Token[], { start: number; bracket: number }>();
7
+
8
+ export function parseSocialSyntax(
9
+ ctx: ParseContext,
10
+ start: number,
11
+ end: number,
12
+ ): { data: SocialData; end: number } | null {
13
+ const tokens = ctx.tokens;
14
+ if (tokens[start]?.type !== "BLOCK_OPEN" || tokens[start + 1]?.value.toLowerCase() !== "social")
15
+ return null;
16
+ const bodyStart = start + 2;
17
+ if (tokens[bodyStart]?.type !== "BLOCK_CLOSE" && !/^\s+$/.test(tokens[bodyStart]?.value ?? ""))
18
+ return null;
19
+ const invalid = invalidBodies.get(tokens);
20
+ if (invalid && bodyStart >= invalid.start && bodyStart <= invalid.bracket) return null;
21
+ const close = findRawTagClose(tokens, bodyStart, end);
22
+ if (close === null) return null;
23
+ for (let pos = bodyStart; pos < close; pos++) {
24
+ if (tokens[pos]!.value.includes("]")) {
25
+ invalidBodies.set(tokens, { start: bodyStart, bracket: pos });
26
+ return null;
27
+ }
28
+ }
29
+ const body = tokens
30
+ .slice(bodyStart, close)
31
+ .map((token) => token.value)
32
+ .join("");
33
+ const sites = body.trim()
34
+ ? body
35
+ .split(",")
36
+ .map((site) => site.trim().toLowerCase())
37
+ .filter(Boolean)
38
+ : null;
39
+ return { data: { sites }, end: close + 1 };
40
+ }
@@ -1,3 +1,4 @@
1
+ import { stripAutomaticLineBreak } from "../parsing/automatic-line-break";
1
2
  import { rawRegionEnd } from "../raw/end";
2
3
  import type { Element } from "@wdprlib/ast";
3
4
  import type { ParseContext } from "../../types";
@@ -118,7 +119,8 @@ function parseOneSpanChild(
118
119
  for (const rule of getCandidateInlineRules(inlineRules, token.type)) {
119
120
  const result = rule.parse(inlineCtx);
120
121
  if (result.success) {
121
- targetChildren.push(...result.elements);
122
+ stripAutomaticLineBreak(targetChildren, result.stripLeadingLineBreak);
123
+ for (const element of result.elements) targetChildren.push(element);
122
124
  return { consumed: result.consumed };
123
125
  }
124
126
  }
@@ -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
 
@@ -38,7 +39,7 @@ export function consumeSpanNewline(
38
39
  }
39
40
 
40
41
  const targetChildren = paragraphStrip && afterBlankLine ? escapedChildren : children;
41
- targetChildren.push({ element: "line-break" });
42
+ targetChildren.push(createAutomaticLineBreak(ctx.tokens[pos]!));
42
43
  let consumed = 1;
43
44
  let nextPos = pos + 1;
44
45
  while (ctx.tokens[nextPos]?.type === "WHITESPACE" && ctx.tokens[nextPos]?.lineStart) {