@wdprlib/parser 5.2.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 (106) hide show
  1. package/dist/index.cjs +2211 -1182
  2. package/dist/index.d.cts +15 -0
  3. package/dist/index.d.ts +15 -0
  4. package/dist/index.js +2211 -1182
  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/module/listpages/resolution/items.ts +2 -2
  38. package/src/parser/rules/block/module/listpages/resolution/pager.ts +43 -0
  39. package/src/parser/rules/block/module/listpages/resolution/wrapper.ts +6 -1
  40. package/src/parser/rules/block/module/listpages/resolve.ts +1 -1
  41. package/src/parser/rules/block/module/listpages/types/external-data.ts +13 -0
  42. package/src/parser/rules/block/module/listpages/url-resolution/params.ts +5 -4
  43. package/src/parser/rules/block/module/resolution/data-maps.ts +75 -3
  44. package/src/parser/rules/block/module/resolution/resolve-async.ts +12 -3
  45. package/src/parser/rules/block/note/boundary.ts +88 -0
  46. package/src/parser/rules/block/note/index.ts +37 -0
  47. package/src/parser/rules/block/paragraph/index.ts +12 -4
  48. package/src/parser/rules/block/paragraph/normalize.ts +6 -1
  49. package/src/parser/rules/block/parsing/block-item.ts +1 -1
  50. package/src/parser/rules/block/parsing/content.ts +1 -1
  51. package/src/parser/rules/block/parsing/inline-content.ts +6 -3
  52. package/src/parser/rules/block/table/pipe/cell.ts +3 -1
  53. package/src/parser/rules/block/table-block/cell-content/index.ts +1 -1
  54. package/src/parser/rules/block/table-block/cell-content/segments.ts +5 -2
  55. package/src/parser/rules/block/table-block/cell-newline.ts +2 -1
  56. package/src/parser/rules/contracts/rule.ts +4 -2
  57. package/src/parser/rules/contracts/scope.ts +2 -0
  58. package/src/parser/rules/inline/anchor/child.ts +8 -2
  59. package/src/parser/rules/inline/anchor/content.ts +3 -1
  60. package/src/parser/rules/inline/anchor/index.ts +4 -1
  61. package/src/parser/rules/inline/anchor/newline.ts +2 -1
  62. package/src/parser/rules/inline/autolink.ts +153 -0
  63. package/src/parser/rules/inline/button/attributes.ts +17 -0
  64. package/src/parser/rules/inline/button/index.ts +17 -0
  65. package/src/parser/rules/inline/button/syntax.ts +56 -0
  66. package/src/parser/rules/inline/date/index.ts +17 -0
  67. package/src/parser/rules/inline/date/syntax.ts +46 -0
  68. package/src/parser/rules/inline/email/candidates.ts +134 -0
  69. package/src/parser/rules/inline/email/index.ts +36 -0
  70. package/src/parser/rules/inline/email/scan.ts +76 -0
  71. package/src/parser/rules/inline/expr/branch.ts +3 -1
  72. package/src/parser/rules/inline/footnote/boundary.ts +56 -0
  73. package/src/parser/rules/inline/footnote/content.ts +29 -41
  74. package/src/parser/rules/inline/footnote/elements.ts +9 -34
  75. package/src/parser/rules/inline/footnote/index.ts +4 -1
  76. package/src/parser/rules/inline/formatting/close.ts +12 -0
  77. package/src/parser/rules/inline/index.ts +14 -0
  78. package/src/parser/rules/inline/line-break/newline.ts +8 -1
  79. package/src/parser/rules/inline/link-bracket/direct-url.ts +11 -3
  80. package/src/parser/rules/inline/link-bracket/parsed.ts +9 -4
  81. package/src/parser/rules/inline/link-bracket/parts.ts +14 -36
  82. package/src/parser/rules/inline/link-bracket/special-target.ts +9 -0
  83. package/src/parser/rules/inline/link-single.ts +9 -7
  84. package/src/parser/rules/inline/link-triple/index.ts +1 -0
  85. package/src/parser/rules/inline/link-triple/label.ts +7 -1
  86. package/src/parser/rules/inline/parsing/automatic-line-break.ts +35 -0
  87. package/src/parser/rules/inline/parsing/block-boundary.ts +2 -0
  88. package/src/parser/rules/inline/parsing/block-start-predicates.ts +10 -0
  89. package/src/parser/rules/inline/parsing/inline-content.ts +36 -6
  90. package/src/parser/rules/inline/parsing/plain-text.ts +7 -2
  91. package/src/parser/rules/inline/parsing/preserved-line-break.ts +13 -0
  92. package/src/parser/rules/inline/parsing/raw-tag.ts +19 -0
  93. package/src/parser/rules/inline/raw/angle.ts +2 -1
  94. package/src/parser/rules/inline/raw/end.ts +21 -1
  95. package/src/parser/rules/inline/size/content.ts +1 -1
  96. package/src/parser/rules/inline/size/value.ts +11 -0
  97. package/src/parser/rules/inline/social/index.ts +17 -0
  98. package/src/parser/rules/inline/social/syntax.ts +40 -0
  99. package/src/parser/rules/inline/span/content.ts +3 -1
  100. package/src/parser/rules/inline/span/newline.ts +2 -1
  101. package/src/parser/rules/opaque-probe.ts +58 -0
  102. package/src/pipeline/process.ts +8 -1
  103. package/src/parser/rules/block/math/content.ts +0 -54
  104. package/src/parser/rules/block/math/name.ts +0 -35
  105. package/src/parser/rules/inline/footnote/child.ts +0 -22
  106. package/src/parser/rules/inline/footnote/newline.ts +0 -27
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wdprlib/parser",
3
- "version": "5.2.0",
3
+ "version": "5.4.0",
4
4
  "description": "Parser for Wikidot markup",
5
5
  "keywords": [
6
6
  "ast",
@@ -44,6 +44,7 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "@braintree/sanitize-url": "^7.1.1",
47
- "@wdprlib/ast": "4.1.0"
47
+ "@wdprlib/ast": "4.3.0",
48
+ "entities": "^7.0.1"
48
49
  }
49
50
  }
@@ -5,7 +5,7 @@ export const buildInfo: Readonly<{
5
5
  sha: string | null;
6
6
  dirty: boolean | null;
7
7
  }> = Object.freeze({
8
- version: "5.2.0",
9
- sha: "cc290b0d882de3ed9ea33ec060ddfc8a7b9da59a",
8
+ version: "5.4.0",
9
+ sha: "5e221ddeccc585e64dd1a2eb49b14817c3c1be5d",
10
10
  dirty: false,
11
11
  });
@@ -50,6 +50,8 @@ export class Lexer {
50
50
  * consume content up to the next `"` or newline.
51
51
  */
52
52
  private blockOpenerDepth = 0;
53
+ private rawTagBounds: { source: string; close: number; outerDepth: number } | null = null;
54
+ private rawClosesExhausted = false;
53
55
 
54
56
  constructor(source: string, options: LexerOptions = {}) {
55
57
  this.options = {
@@ -92,8 +94,8 @@ export class Lexer {
92
94
  * When invalid, returns the position of the closing ]] so the lexer can
93
95
  * emit tokens that allow the inner [# text] to be parsed as a described link.
94
96
  */
95
- private findInvalidAnchorNameEnd(): number | null {
96
- return findInvalidAnchorNameEnd(this.state.source, this.state.pos);
97
+ private findInvalidAnchorNameEnd(source: string): number | null {
98
+ return findInvalidAnchorNameEnd(source, this.state.pos);
97
99
  }
98
100
 
99
101
  /**
@@ -117,6 +119,24 @@ export class Lexer {
117
119
  this.state.tokens.push(createLexerToken(this.state, type, value, this.options.trackPositions));
118
120
  this.lastNonWhitespaceType = updateLastNonWhitespaceType(this.lastNonWhitespaceType, type);
119
121
  this.blockOpenerDepth = nextBlockOpenerDepth(this.blockOpenerDepth, type);
122
+ if (
123
+ this.rawTagBounds === null &&
124
+ !this.rawClosesExhausted &&
125
+ (value.toLowerCase() === "button" || value.toLowerCase() === "social") &&
126
+ this.state.tokens.at(-2)?.type === "BLOCK_OPEN" &&
127
+ /\s/.test(this.current())
128
+ ) {
129
+ const close = this.state.source.indexOf("]]", this.state.pos);
130
+ if (close < 0) {
131
+ this.rawClosesExhausted = true;
132
+ } else {
133
+ this.rawTagBounds = {
134
+ source: this.state.source.slice(0, close),
135
+ close,
136
+ outerDepth: this.blockOpenerDepth - 1,
137
+ };
138
+ }
139
+ }
120
140
  }
121
141
 
122
142
  private emitTokenAction(action: TokenAction): void {
@@ -140,7 +160,16 @@ export class Lexer {
140
160
  private scanToken(): void {
141
161
  const char = this.current();
142
162
  const isLineStart = isSyntaxLineStart(this.state);
143
- const src = this.state.source;
163
+ const bounds = this.rawTagBounds;
164
+ const src = bounds?.source ?? this.state.source;
165
+
166
+ // Scanners see a bounded source so no token can consume part of the close.
167
+ if (bounds && this.state.pos === bounds.close) {
168
+ this.emitTokenAction({ type: "BLOCK_CLOSE", value: "]]", length: 2 });
169
+ this.blockOpenerDepth = bounds.outerDepth;
170
+ this.rawTagBounds = null;
171
+ return;
172
+ }
144
173
 
145
174
  const spacingAction = scanSpacingToken(src, this.state.pos);
146
175
  if (spacingAction) {
@@ -155,7 +184,7 @@ export class Lexer {
155
184
  lineStart: isLineStart,
156
185
  physicalLineStart: this.state.lineStart,
157
186
  splitBlockClose: this.splitBlockClosePositions.has(this.state.pos),
158
- findInvalidAnchorNameEnd: () => this.findInvalidAnchorNameEnd(),
187
+ findInvalidAnchorNameEnd: () => this.findInvalidAnchorNameEnd(src),
159
188
  });
160
189
  if (punctuation.handled) {
161
190
  if (punctuation.clearSplitBlockCloseAt !== undefined) {
@@ -176,7 +205,7 @@ export class Lexer {
176
205
  if (char === '"') {
177
206
  const lastNonWs = this.lastNonWhitespaceTokenType();
178
207
  if (this.blockOpenerDepth > 0 && lastNonWs === "EQUALS") {
179
- this.addToken("QUOTED_STRING", scanQuotedString(this.state));
208
+ this.addToken("QUOTED_STRING", scanQuotedString(this.state, src.length));
180
209
  return;
181
210
  }
182
211
  this.advance();
@@ -1,15 +1,15 @@
1
- import { advance, current, isAtEnd, type LexerState } from "./state";
1
+ import { advance, current, type LexerState } from "./state";
2
2
 
3
3
  /**
4
4
  * Scan a quoted block-attribute value, including the opening quote and optional
5
5
  * closing quote. Newline terminates the token without being consumed.
6
6
  */
7
- export function scanQuotedString(state: LexerState): string {
7
+ export function scanQuotedString(state: LexerState, end: number = state.source.length): string {
8
8
  let quoted = advance(state);
9
- while (!isAtEnd(state) && current(state) !== '"' && current(state) !== "\n") {
9
+ while (state.pos < end && current(state) !== '"' && current(state) !== "\n") {
10
10
  quoted += advance(state);
11
11
  }
12
- if (current(state) === '"') {
12
+ if (state.pos < end && current(state) === '"') {
13
13
  quoted += advance(state);
14
14
  }
15
15
  return quoted;
@@ -1,6 +1,7 @@
1
1
  import type { TokenAction } from "./token-actions";
2
2
  import { findRepeatedCharRunEnd } from "./runs";
3
3
  import type { TokenType } from "./tokens";
4
+ import { startsWithUrl } from "./url-schemes";
4
5
 
5
6
  export function scanSimpleSyntaxToken(
6
7
  src: string,
@@ -42,7 +43,11 @@ export function scanSimpleSyntaxToken(
42
43
  }
43
44
 
44
45
  function scanStarToken(src: string, pos: number, isLineStart: boolean): TokenAction {
45
- if (src[pos + 1] === "*") {
46
+ // `**http://x…` は太字にならない。Wikidotは2つ目の`*`をURLの新規タブプレフィックスとして
47
+ // 扱い、開きの`**`ペアが壊れる(`*` リテラル + `*http://x…` autolink + 末尾`**` リテラル)。
48
+ // 直後が有効なURLのときだけBOLD_MARKERに結合せず単一の`*`として切り出す。
49
+ // `**http://**`のようにURL本体が無い場合はautolinkが成立しないため太字のまま扱う
50
+ if (src[pos + 1] === "*" && !startsWithUrl(src, pos + 2)) {
46
51
  return token("BOLD_MARKER", "**");
47
52
  }
48
53
  return isLineStart ? token("LIST_BULLET", "*") : token("STAR", "*");
@@ -5,6 +5,7 @@ import {
5
5
  isAsciiAlphanumericCode,
6
6
  } from "./runs";
7
7
  import type { TokenAction } from "./token-actions";
8
+ import { TRAILING_URL_SCHEME } from "./url-schemes";
8
9
 
9
10
  export function scanTextToken(src: string, pos: number): TokenAction {
10
11
  const char = src[pos] ?? "";
@@ -28,7 +29,20 @@ export function scanTextToken(src: string, pos: number): TokenAction {
28
29
  }
29
30
 
30
31
  export function scanCompactTextToken(src: string, pos: number): TokenAction | null {
31
- const end = findCompactPlainTextRunEnd(src, pos);
32
+ let end = findCompactPlainTextRunEnd(src, pos);
33
+
34
+ // 生URLの自動リンク化はIDENTIFIERトークンのスキーム名から発火する。
35
+ // このcompactモード(大きなソースで連続する平文を1つのTEXTトークンにまとめる高速化)が
36
+ // `see http` のようにスキーム名までTEXTに取り込むとリンク化されなくなるため、
37
+ // 次の文字が`:`でトークンがスキーム名で終わる場合はスキーム名の手前で切り、
38
+ // スキーム名を通常のIDENTIFIERスキャンに委ねる
39
+ if (end > pos && src[end] === ":") {
40
+ const match = TRAILING_URL_SCHEME.exec(src.slice(pos, end));
41
+ if (match) {
42
+ end -= match[1]!.length;
43
+ }
44
+ }
45
+
32
46
  return end > pos ? runToken(src, pos, end, "TEXT") : null;
33
47
  }
34
48
 
@@ -0,0 +1,78 @@
1
+ /**
2
+ *
3
+ * URL schemes recognized for bare-URL auto-linking.
4
+ *
5
+ * Mirrors Text_Wiki's Url rule configuration (http://, https://, ftp://,
6
+ * gopher://, news://, mailto:, mms://). The scheme names are shared between
7
+ * the lexer (compact text-run rewinding) and the autolink inline rule.
8
+ *
9
+ * @module
10
+ */
11
+
12
+ /** Scheme names (without `:`) that can start an auto-linked bare URL. */
13
+ export const URL_SCHEME_NAMES: ReadonlySet<string> = new Set([
14
+ "http",
15
+ "https",
16
+ "ftp",
17
+ "gopher",
18
+ "news",
19
+ "mailto",
20
+ "mms",
21
+ ]);
22
+
23
+ /**
24
+ * Matches a compact text run that ends with a URL scheme name.
25
+ *
26
+ * The boundary is `[^A-Za-z0-9]` (not Text_Wiki's `[^A-Za-z]`) so that runs
27
+ * like `123http` stay fused, matching the non-compact lexer which tokenizes
28
+ * `123http` as a single IDENTIFIER (such URLs are not auto-linked either way).
29
+ */
30
+ export const TRAILING_URL_SCHEME: RegExp = /(?:^|[^A-Za-z0-9])(https?|ftp|gopher|news|mailto|mms)$/;
31
+
32
+ /**
33
+ * Matches the exact scheme prefix the autolink rule consumes:
34
+ * `://` for http/https/ftp/gopher/news/mms and `:` for mailto. Must stay in
35
+ * sync with the autolink rule's URL pattern (a looser `scheme:` test would
36
+ * split `**` markers for inputs like `**http:foo**` that never autolink,
37
+ * turning them into literal `**` instead of bold).
38
+ */
39
+ const URL_SCHEME_PREFIX = /^(?:(?:https?|ftp|gopher|news|mms):\/\/|mailto:)/;
40
+
41
+ /**
42
+ * URL body pattern, ported from Text_Wiki's Url rule regex: scheme + zero or
43
+ * more slash-terminated segments + final segment + one terminating character
44
+ * from `[A-Za-z%0-9/?=&~_]`.
45
+ *
46
+ * Scheme matching is case-sensitive (lowercase only) on purpose: Text_Wiki's
47
+ * inline URL regex has no `i` flag and lists lowercase schemes, so Wikidot does
48
+ * not auto-link `HTTP://…`. Shared by the autolink rule and the lexer's bold
49
+ * marker split.
50
+ */
51
+ export const URL_PATTERN: RegExp =
52
+ /^(?:(?:https?|ftp|gopher|news|mms):\/\/|mailto:)(?:[^ \\/"']*\/)*[^ \t\n\\/"']*[A-Za-z%0-9/?=&~_]/;
53
+
54
+ /**
55
+ * Whether `src` begins a URL scheme prefix (e.g. `http://`) at `pos`.
56
+ *
57
+ * A prefix-only check, used for single-bracket links (`[ftp://x Label]`) whose
58
+ * URL portion is already delimited by the label, so the full URL body does not
59
+ * need to be validated here.
60
+ */
61
+ export function startsWithUrlScheme(src: string, pos: number): boolean {
62
+ return URL_SCHEME_PREFIX.test(src.slice(pos, pos + 10));
63
+ }
64
+
65
+ /**
66
+ * Whether a complete autolinkable URL (not just a scheme prefix) begins at
67
+ * `pos` in `src`.
68
+ *
69
+ * Used by the lexer to decide whether `**` before a scheme is a bold marker or
70
+ * a literal `*` plus a new-tab URL prefix: only split the marker when a valid
71
+ * URL actually follows, so `**http://**` (no URL body) stays bold while
72
+ * `**http://x**` splits into `*` + autolink + `**`.
73
+ */
74
+ export function startsWithUrl(src: string, pos: number): boolean {
75
+ // Scheme + a short body is enough to decide; cap the slice so a very long
76
+ // line does not get copied on every `**`.
77
+ return URL_PATTERN.test(src.slice(pos, pos + 2048));
78
+ }
@@ -28,6 +28,10 @@ export const BLOCK_START_TOKENS: TokenType[] = [
28
28
  "HEADING_MARKER",
29
29
  "HR_MARKER",
30
30
  "TABLE_MARKER",
31
+ "TABLE_HEADER",
32
+ "TABLE_LEFT",
33
+ "TABLE_CENTER",
34
+ "TABLE_RIGHT",
31
35
  "COLON", // Definition list
32
36
  "BLOCK_OPEN", // [[footnoteblock]], [[div]], etc.
33
37
  "BLOCK_END_OPEN", // [[/div]], [[/collapsible]], etc.
@@ -32,7 +32,7 @@ export function parseNextBlock(
32
32
  }
33
33
 
34
34
  const result = ctx.blockFallbackRule.parse(ctx);
35
- if (result.success && result.elements.length > 0) {
35
+ if (result.success) {
36
36
  ctx.pos += result.consumed;
37
37
  return result.elements;
38
38
  }
@@ -1,25 +1,27 @@
1
1
  /**
2
- *
3
- * Post-processing pass: suppress paragraph wrapping adjacent to div containers.
4
- *
5
- * In Wikidot, when a paragraph is a direct sibling of a `<div>` block (no other
6
- * block elements between them), the `<p>` wrapping is removed and the inner
7
- * elements are promoted to the parent level.
8
- *
9
- * When the unwrapped paragraph follows a div, a line-break element is prepended
10
- * to represent the newline between the closing `</div>` and the bare text.
11
- *
12
- * Examples:
13
- * `[[div]]inline[[/div]]\n[[div]]\n[[/div]]` → no `<p>` (adjacent to div)
14
- * `[[div]]inline[[/div]]\n> a\n[[div]]\n[[/div]]` → has `<p>` (blockquote between)
15
- *
16
- * @module
2
+ * Preserve the bare rendering of unparsed div syntax beside a valid div.
3
+ * Ordinary paragraphs retain their wrappers, including beside div containers.
17
4
  */
18
5
  import type { Element, ContainerData } from "@wdprlib/ast";
19
6
 
20
- function isParagraphContainer(el: Element | undefined): boolean {
7
+ // Track the syntax node rather than its paragraph so splitting a paragraph
8
+ // cannot mark a separate fragment containing only ordinary text.
9
+ const unparsedDivStarts = new WeakSet<Element>();
10
+
11
+ export function markUnparsedDivStart(elements: Element[]): void {
12
+ const first = elements[0];
13
+ const text =
14
+ first?.element === "container" && first.data.type === "paragraph"
15
+ ? first.data.elements[0]
16
+ : first;
17
+ if (text?.element === "text") unparsedDivStarts.add(text);
18
+ }
19
+
20
+ function isUnparsedDivParagraph(el: Element | undefined): boolean {
21
21
  if (!el || el.element !== "container") return false;
22
- return (el.data as ContainerData).type === "paragraph";
22
+ return (
23
+ el.data.type === "paragraph" && el.data.elements.some((child) => unparsedDivStarts.has(child))
24
+ );
23
25
  }
24
26
 
25
27
  function isDivContainer(el: Element | undefined): boolean {
@@ -28,8 +30,8 @@ function isDivContainer(el: Element | undefined): boolean {
28
30
  }
29
31
 
30
32
  /**
31
- * At a single nesting level, unwrap paragraph containers that are directly
32
- * adjacent to div containers. A line-break is prepended when the paragraph
33
+ * At a single nesting level, unwrap paragraphs containing unparsed div syntax
34
+ * that are directly adjacent to div containers. A line-break is prepended when the paragraph
33
35
  * follows a div.
34
36
  */
35
37
  function suppressAtLevel(elements: Element[]): Element[] {
@@ -38,7 +40,7 @@ function suppressAtLevel(elements: Element[]): Element[] {
38
40
  const unwrap = Array.from({ length: elements.length }, () => false);
39
41
 
40
42
  for (let i = 0; i < elements.length; i++) {
41
- if (!isParagraphContainer(elements[i])) continue;
43
+ if (!isUnparsedDivParagraph(elements[i])) continue;
42
44
  const prevIsDiv = i > 0 && isDivContainer(elements[i - 1]);
43
45
  const nextIsDiv = i < elements.length - 1 && isDivContainer(elements[i + 1]);
44
46
  if (prevIsDiv || nextIsDiv) {
@@ -66,7 +68,7 @@ function suppressAtLevel(elements: Element[]): Element[] {
66
68
  }
67
69
 
68
70
  /**
69
- * Suppress paragraph wrapping adjacent to div containers.
71
+ * Suppress wrapping of unparsed div syntax adjacent to div containers.
70
72
  *
71
73
  * Applied only at the top level. Inside div containers, paragraphs adjacent
72
74
  * to nested divs retain their `<p>` wrapping (matching Wikidot behavior).
@@ -7,6 +7,8 @@ import {
7
7
  isContainer,
8
8
  isEmptyExpr,
9
9
  isSplitSpan,
10
+ isSpanStripMarker,
11
+ isWhitespaceText,
10
12
  } from "./predicates";
11
13
  import { splitParagraphAtBlankLineSpans, splitParagraphAtEmptyExpr } from "./split";
12
14
 
@@ -40,6 +42,10 @@ export function mergeSpanStripParagraphs(children: Element[]): Element[] {
40
42
  continue;
41
43
  }
42
44
 
45
+ const firstMeaningful = paraData.elements.find(
46
+ (child) => child.element !== "line-break" && !isWhitespaceText(child),
47
+ );
48
+ const keepParagraph = firstMeaningful && !isSpanStripMarker(firstMeaningful);
43
49
  const mergedChildren: Element[] = [...paraData.elements];
44
50
  i++;
45
51
 
@@ -55,7 +61,7 @@ export function mergeSpanStripParagraphs(children: Element[]): Element[] {
55
61
  }
56
62
 
57
63
  const hasSpanStrip = hasParagraphStripSpan(nextPara);
58
- mergedChildren.push(...nextParaData.elements);
64
+ for (const element of nextParaData.elements) mergedChildren.push(element);
59
65
  i++;
60
66
 
61
67
  if (!hasSpanStrip) {
@@ -69,7 +75,7 @@ export function mergeSpanStripParagraphs(children: Element[]): Element[] {
69
75
  const escapedSpans = extractEscapedSpans(mergedChildren);
70
76
  removeLineBreaksAroundSpanStrip(mergedChildren);
71
77
 
72
- if (escapedSpans.length > 0) {
78
+ if (escapedSpans.length > 0 || keepParagraph) {
73
79
  if (mergedChildren.length > 0) {
74
80
  result.push(paragraphElement(mergedChildren));
75
81
  }
@@ -19,6 +19,8 @@
19
19
  * @module
20
20
  */
21
21
 
22
+ import { makeUniqueSentinels, maskRawRegions, restorePlaceholders } from "./utils";
23
+
22
24
  /** Unicode left single quotation mark (U+2018) */
23
25
  const LEFT_SINGLE_QUOTE = "\u2018"; // '
24
26
  /** Unicode right single quotation mark (U+2019) */
@@ -66,14 +68,24 @@ function replaceDelimitedTypography(
66
68
  let searchFrom = 0;
67
69
  let result = "";
68
70
  let lastCopied = 0;
71
+ let closeIndex = -1;
72
+ let newlineIndex = -1;
69
73
 
70
74
  while (searchFrom < text.length) {
71
75
  const openIndex = text.indexOf(opener, searchFrom);
72
76
  if (openIndex === -1) break;
73
77
 
74
78
  const contentStart = openIndex + opener.length;
75
- const closeIndex = text.indexOf(closer, contentStart);
79
+ if (closeIndex < contentStart) closeIndex = text.indexOf(closer, contentStart);
76
80
  if (closeIndex === -1) break;
81
+ if (newlineIndex < contentStart) {
82
+ const nextNewline = text.indexOf("\n", contentStart);
83
+ newlineIndex = nextNewline === -1 ? text.length : nextNewline;
84
+ }
85
+ if (newlineIndex < closeIndex) {
86
+ searchFrom = newlineIndex + 1;
87
+ continue;
88
+ }
77
89
 
78
90
  result += text.slice(lastCopied, openIndex);
79
91
  result += leftQuote;
@@ -99,7 +111,17 @@ function replaceDelimitedTypography(
99
111
  * @returns Text with ASCII typography patterns replaced by Unicode equivalents
100
112
  */
101
113
  export function substitute(text: string): string {
102
- let result = text;
114
+ if (
115
+ !text.includes("`") &&
116
+ !text.includes(",,") &&
117
+ !text.includes("...") &&
118
+ !text.includes(". . .")
119
+ ) {
120
+ return text;
121
+ }
122
+ const sentinels = makeUniqueSentinels(text);
123
+ const { masked, placeholders } = maskRawRegions(text, sentinels);
124
+ let result = masked;
103
125
 
104
126
  // Double quotes: ``...'' -> "..."
105
127
  if (result.includes("``") && result.includes("''")) {
@@ -128,5 +150,5 @@ export function substitute(text: string): string {
128
150
  result = replaceExactEllipsisPattern(result, ". . .");
129
151
  }
130
152
 
131
- return result;
153
+ return restorePlaceholders(result, placeholders, sentinels);
132
154
  }
@@ -1,7 +1,11 @@
1
+ import { tokenize, type Token } from "../../../lexer";
2
+ import { findCodeOpen } from "../../rules/block/code/open";
3
+ import { findCodeBodyBounds } from "../../rules/block/code/boundary";
4
+
1
5
  const BASE_PLACEHOLDER_OPEN = "\uE000";
2
6
  const BASE_PLACEHOLDER_CLOSE = "\uE001";
3
7
 
4
- const RAW_BLOCK_OPEN_PATTERN = /\[\[\s*(code|html)\b[^\]]*\]\]/iy;
8
+ const RAW_BLOCK_OPEN_PATTERN = /\[\[html\b[^\]]*\]\]/iy;
5
9
 
6
10
  /** Unique sentinel characters used to wrap raw-region placeholders. */
7
11
  export interface Sentinels {
@@ -43,11 +47,23 @@ export function maskRawRegions(
43
47
  sentinels: Sentinels,
44
48
  ): { masked: string; placeholders: string[] } {
45
49
  const placeholders: string[] = [];
50
+ let tokens: Token[] | undefined;
51
+ const getTokens = () => (tokens ??= tokenize(source));
46
52
  let masked = "";
47
53
  let i = 0;
48
54
 
49
55
  while (i < source.length) {
50
- const rawBlock = tryMaskRawBlock(source, i, placeholders, sentinels);
56
+ if (source.startsWith("[!--", i)) {
57
+ const close = source.indexOf("--]", i + 4);
58
+ if (close !== -1) {
59
+ const end = close + 3;
60
+ masked += source.slice(i, end);
61
+ i = end;
62
+ continue;
63
+ }
64
+ }
65
+
66
+ const rawBlock = tryMaskRawBlock(source, i, placeholders, sentinels, getTokens);
51
67
  if (rawBlock) {
52
68
  masked += rawBlock.placeholder;
53
69
  i = rawBlock.end;
@@ -86,16 +102,30 @@ function tryMaskRawBlock(
86
102
  pos: number,
87
103
  placeholders: string[],
88
104
  sentinels: Sentinels,
105
+ getTokens: () => Token[],
89
106
  ): { placeholder: string; end: number } | null {
90
107
  if (source[pos] !== "[" || source[pos + 1] !== "[") return null;
91
108
 
109
+ if (source.slice(pos, pos + 6).toLowerCase() === "[[code") {
110
+ const tokens = getTokens();
111
+ const start = tokenAtOffset(tokens, pos);
112
+ const open = findCodeOpen(tokens, start);
113
+ if (!open) return null;
114
+ const bounds = open.closingSwallowed ? null : findCodeBodyBounds(tokens, open.bodyStart);
115
+ const end = open.closingSwallowed
116
+ ? tokens[open.attributesEnd - 1]!.position.end.offset
117
+ : bounds!.foundClose
118
+ ? tokens[bounds!.end - 1]!.position.end.offset
119
+ : source.length;
120
+ return { placeholder: pushPlaceholder(placeholders, source.slice(pos, end), sentinels), end };
121
+ }
122
+
92
123
  RAW_BLOCK_OPEN_PATTERN.lastIndex = pos;
93
124
  const openMatch = RAW_BLOCK_OPEN_PATTERN.exec(source);
94
125
  if (!openMatch) return null;
95
126
 
96
- const name = openMatch[1]!.toLowerCase();
97
127
  const openLen = openMatch[0].length;
98
- const closePattern = new RegExp(`\\[\\[\\/\\s*${name}\\s*\\]\\]`, "ig");
128
+ const closePattern = /\[\[\/\s*html\s*\]\]/gi;
99
129
  closePattern.lastIndex = pos + openLen;
100
130
  const closeMatch = closePattern.exec(source);
101
131
 
@@ -107,12 +137,18 @@ function tryMaskRawBlock(
107
137
  };
108
138
  }
109
139
 
110
- if (name !== "code") return null;
140
+ return null;
141
+ }
111
142
 
112
- return {
113
- placeholder: pushPlaceholder(placeholders, source.slice(pos), sentinels),
114
- end: source.length,
115
- };
143
+ function tokenAtOffset(tokens: readonly Token[], offset: number): number {
144
+ let low = 0;
145
+ let high = tokens.length;
146
+ while (low < high) {
147
+ const middle = Math.floor((low + high) / 2);
148
+ if (tokens[middle]!.position.start.offset < offset) low = middle + 1;
149
+ else high = middle;
150
+ }
151
+ return tokens[low]?.position.start.offset === offset ? low : tokens.length;
116
152
  }
117
153
 
118
154
  function tryMaskRawInline(
@@ -140,17 +176,26 @@ function tryMaskSingleLineRaw(
140
176
  placeholders: string[],
141
177
  sentinels: Sentinels,
142
178
  ): { placeholder: string; end: number } | null {
143
- const closePos = source.indexOf(close, pos + openerLength);
144
- const newline = source.indexOf("\n", pos + openerLength);
145
- if (closePos === -1 || (newline !== -1 && newline < closePos)) return null;
146
-
147
- const end = closePos + close.length;
179
+ const end = singleLineRawEnd(source, pos, openerLength, close);
180
+ if (end === pos) return null;
148
181
  return {
149
182
  placeholder: pushPlaceholder(placeholders, source.slice(pos, end), sentinels),
150
183
  end,
151
184
  };
152
185
  }
153
186
 
187
+ function singleLineRawEnd(
188
+ source: string,
189
+ pos: number,
190
+ openerLength: number,
191
+ close: string,
192
+ ): number {
193
+ const closePos = source.indexOf(close, pos + openerLength);
194
+ const newline = source.indexOf("\n", pos + openerLength);
195
+ if (closePos === -1 || (newline !== -1 && newline < closePos)) return pos;
196
+ return closePos + close.length;
197
+ }
198
+
154
199
  function pushPlaceholder(placeholders: string[], text: string, sentinels: Sentinels): string {
155
200
  const idx = placeholders.length;
156
201
  placeholders.push(text);
@@ -13,6 +13,7 @@
13
13
  import { needsWhitespaceSubstitution, mayContainWhitespaceOnlyLine } from "./detection";
14
14
  import { replaceLeadingSpaces } from "./leading-spaces";
15
15
  import { CONCAT_LINES, DOS_MAC_NEWLINES, NULL_CHARS, TABS, WHITESPACE_ONLY_LINE } from "./patterns";
16
+ import { makeUniqueSentinels, maskRawRegions, restorePlaceholders } from "../utils";
16
17
 
17
18
  /**
18
19
  * Apply all whitespace normalization substitutions to the given text.
@@ -41,7 +42,13 @@ export function substitute(text: string): string {
41
42
  }
42
43
 
43
44
  if (result.indexOf("\\\n") !== -1) {
44
- result = result.replace(CONCAT_LINES, String.fromCharCode(0xe000));
45
+ const sentinels = makeUniqueSentinels(result);
46
+ const { masked, placeholders } = maskRawRegions(result, sentinels);
47
+ result = restorePlaceholders(
48
+ masked.replace(CONCAT_LINES, String.fromCharCode(0xe000)),
49
+ placeholders,
50
+ sentinels,
51
+ );
45
52
  }
46
53
 
47
54
  if (result.indexOf("\t") !== -1) {
@@ -60,7 +60,7 @@ export function parseBibliographyContent(
60
60
  const inlineCtx: ParseContext = { ...ctx, pos };
61
61
  const result = parseInlineUntil(inlineCtx, "NEWLINE");
62
62
  if (result.elements.length > 0) {
63
- content.push(...result.elements);
63
+ for (const element of result.elements) content.push(element);
64
64
  pos += result.consumed;
65
65
  consumed += result.consumed;
66
66
  } else {
@@ -1,3 +1,4 @@
1
+ import { stripAutomaticLineBreak } from "../../inline/parsing/automatic-line-break";
1
2
  import type { ListItem } from "@wdprlib/ast";
2
3
  import type { ParseContext } from "../../types";
3
4
  import { getCandidateInlineRules } from "../../inline/utils";
@@ -52,7 +53,7 @@ export function parseBareListContent(
52
53
  if (consecutiveNewlines >= 2) {
53
54
  flushBareParagraph(paragraphState);
54
55
  } else {
55
- appendBareParagraphLineBreakIfNeeded(paragraphState);
56
+ appendBareParagraphLineBreakIfNeeded(paragraphState, token);
56
57
  }
57
58
  continue;
58
59
  }
@@ -66,6 +67,7 @@ export function parseBareListContent(
66
67
  for (const rule of getCandidateInlineRules(ctx.inlineRules, token.type)) {
67
68
  const result = rule.parse(inlineCtx);
68
69
  if (result.success) {
70
+ stripAutomaticLineBreak(paragraphState.current, result.stripLeadingLineBreak);
69
71
  appendBareParagraphElements(paragraphState, result.elements);
70
72
  consumed += result.consumed;
71
73
  pos += result.consumed;
@@ -1,3 +1,5 @@
1
+ import type { Token } from "../../../../lexer";
2
+ import { createAutomaticLineBreak } from "../../inline/parsing/automatic-line-break";
1
3
  import type { Element } from "@wdprlib/ast";
2
4
 
3
5
  export interface BareParagraphState {
@@ -20,9 +22,12 @@ export function appendBareParagraphText(state: BareParagraphState, text: string)
20
22
  state.current.push({ element: "text", data: text });
21
23
  }
22
24
 
23
- export function appendBareParagraphLineBreakIfNeeded(state: BareParagraphState): void {
25
+ export function appendBareParagraphLineBreakIfNeeded(
26
+ state: BareParagraphState,
27
+ token: Token,
28
+ ): void {
24
29
  if (state.current.length > 0) {
25
- state.current.push({ element: "line-break" });
30
+ state.current.push(createAutomaticLineBreak(token));
26
31
  }
27
32
  }
28
33