@wdprlib/parser 5.3.0 → 6.0.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.
- package/dist/index.cjs +2429 -1199
- package/dist/index.d.cts +95 -8
- package/dist/index.d.ts +95 -8
- package/dist/index.js +2385 -1152
- package/package.json +3 -2
- package/src/build-info.generated.ts +2 -2
- package/src/index.ts +3 -0
- package/src/lexer/lexer.ts +34 -5
- package/src/lexer/quoted-string.ts +4 -4
- package/src/lexer/syntax-actions.ts +6 -1
- package/src/lexer/text-actions.ts +15 -1
- package/src/lexer/url-schemes.ts +78 -0
- package/src/parser/constants.ts +4 -0
- package/src/parser/parse/block.ts +1 -1
- package/src/parser/postprocess/divAdjacentParagraph.ts +23 -21
- package/src/parser/postprocess/spanStrip/merge.ts +8 -2
- package/src/parser/preprocess/typography.ts +25 -3
- package/src/parser/preprocess/utils/raw-regions.ts +59 -14
- package/src/parser/preprocess/whitespace/index.ts +8 -1
- package/src/parser/rules/block/bibliography/entry-content.ts +1 -1
- package/src/parser/rules/block/block-list/bare-content.ts +3 -1
- package/src/parser/rules/block/block-list/bare-paragraph.ts +7 -2
- package/src/parser/rules/block/block-list/item-content.ts +7 -3
- package/src/parser/rules/block/block-list/li-content.ts +8 -3
- package/src/parser/rules/block/block-list/li-item.ts +1 -1
- package/src/parser/rules/block/blockquote/build.ts +1 -1
- package/src/parser/rules/block/code/boundary.ts +76 -0
- package/src/parser/rules/block/code/content.ts +11 -40
- package/src/parser/rules/block/code/index.ts +8 -31
- package/src/parser/rules/block/code/open.ts +46 -0
- package/src/parser/rules/block/definition-list/item-key.ts +1 -1
- package/src/parser/rules/block/definition-list/item-value.ts +1 -1
- package/src/parser/rules/block/div/failed.ts +2 -0
- package/src/parser/rules/block/index.ts +3 -0
- package/src/parser/rules/block/list/line.ts +6 -3
- package/src/parser/rules/block/math/boundary.ts +104 -0
- package/src/parser/rules/block/math/index.ts +17 -57
- package/src/parser/rules/block/module/index.ts +3 -2
- package/src/parser/rules/block/module/listpages/extraction/listpages.ts +5 -0
- package/src/parser/rules/block/module/listpages/extraction/query.ts +1 -0
- package/src/parser/rules/block/module/listpages/extraction/template.ts +16 -0
- package/src/parser/rules/block/module/listpages/extraction/variables.ts +1 -0
- package/src/parser/rules/block/module/listpages/index.ts +1 -0
- package/src/parser/rules/block/module/listpages/normalization/order-parent.ts +9 -1
- package/src/parser/rules/block/module/listpages/normalize.ts +1 -0
- package/src/parser/rules/block/module/listpages/parser.ts +1 -0
- package/src/parser/rules/block/module/listpages/resolve.ts +4 -2
- package/src/parser/rules/block/module/listpages/template/excerpt.ts +63 -0
- package/src/parser/rules/block/module/listpages/template/format/content.ts +0 -32
- package/src/parser/rules/block/module/listpages/template/format/index.ts +1 -1
- package/src/parser/rules/block/module/listpages/template/getters/index.ts +14 -0
- package/src/parser/rules/block/module/listpages/template/getters/parameterized.ts +3 -1
- package/src/parser/rules/block/module/listpages/template/getters/registered.ts +52 -0
- package/src/parser/rules/block/module/listpages/template/getters/simple.ts +15 -5
- package/src/parser/rules/block/module/listpages/template/literal.ts +6 -0
- package/src/parser/rules/block/module/listpages/template/syntax.ts +10 -1
- package/src/parser/rules/block/module/listpages/types/data-requirements.ts +6 -0
- package/src/parser/rules/block/module/listpages/types/external-data.ts +36 -2
- package/src/parser/rules/block/module/listpages/types/normalized-query.ts +14 -4
- package/src/parser/rules/block/module/listpages/types/query.ts +2 -0
- package/src/parser/rules/block/module/listpages/types/variables.ts +5 -0
- package/src/parser/rules/block/module/listpages/url-resolution/fields.ts +2 -0
- package/src/parser/rules/block/module/listusers/resolve.ts +2 -1
- package/src/parser/rules/block/module/mapping.ts +2 -1
- package/src/parser/rules/block/module/rate/index.ts +16 -19
- package/src/parser/rules/block/module/rate/resolve.ts +108 -0
- package/src/parser/rules/block/module/rate/types.ts +6 -16
- package/src/parser/rules/block/module/resolution/resolve-async.ts +2 -1
- package/src/parser/rules/block/module/resolve.ts +10 -8
- package/src/parser/rules/block/module/types-common.ts +4 -0
- package/src/parser/rules/block/note/boundary.ts +88 -0
- package/src/parser/rules/block/note/index.ts +37 -0
- package/src/parser/rules/block/paragraph/index.ts +12 -4
- package/src/parser/rules/block/paragraph/normalize.ts +6 -1
- package/src/parser/rules/block/parsing/block-item.ts +1 -1
- package/src/parser/rules/block/parsing/content.ts +1 -1
- package/src/parser/rules/block/parsing/inline-content.ts +6 -3
- package/src/parser/rules/block/table/pipe/cell.ts +3 -1
- package/src/parser/rules/block/table-block/cell-content/index.ts +1 -1
- package/src/parser/rules/block/table-block/cell-content/segments.ts +5 -2
- package/src/parser/rules/block/table-block/cell-newline.ts +2 -1
- package/src/parser/rules/contracts/rule.ts +4 -2
- package/src/parser/rules/contracts/scope.ts +2 -0
- package/src/parser/rules/inline/anchor/child.ts +8 -2
- package/src/parser/rules/inline/anchor/content.ts +3 -1
- package/src/parser/rules/inline/anchor/index.ts +4 -1
- package/src/parser/rules/inline/anchor/newline.ts +2 -1
- package/src/parser/rules/inline/autolink.ts +153 -0
- package/src/parser/rules/inline/button/attributes.ts +17 -0
- package/src/parser/rules/inline/button/index.ts +17 -0
- package/src/parser/rules/inline/button/syntax.ts +56 -0
- package/src/parser/rules/inline/date/index.ts +17 -0
- package/src/parser/rules/inline/date/syntax.ts +46 -0
- package/src/parser/rules/inline/email/candidates.ts +134 -0
- package/src/parser/rules/inline/email/index.ts +36 -0
- package/src/parser/rules/inline/email/scan.ts +76 -0
- package/src/parser/rules/inline/expr/branch.ts +3 -1
- package/src/parser/rules/inline/footnote/boundary.ts +56 -0
- package/src/parser/rules/inline/footnote/content.ts +29 -41
- package/src/parser/rules/inline/footnote/elements.ts +9 -34
- package/src/parser/rules/inline/footnote/index.ts +4 -1
- package/src/parser/rules/inline/formatting/close.ts +12 -0
- package/src/parser/rules/inline/index.ts +14 -0
- package/src/parser/rules/inline/line-break/newline.ts +8 -1
- package/src/parser/rules/inline/link-bracket/direct-url.ts +11 -3
- package/src/parser/rules/inline/link-bracket/parsed.ts +9 -4
- package/src/parser/rules/inline/link-bracket/parts.ts +14 -36
- package/src/parser/rules/inline/link-bracket/special-target.ts +9 -0
- package/src/parser/rules/inline/link-single.ts +9 -7
- package/src/parser/rules/inline/link-triple/index.ts +1 -0
- package/src/parser/rules/inline/link-triple/label.ts +7 -1
- package/src/parser/rules/inline/parsing/automatic-line-break.ts +35 -0
- package/src/parser/rules/inline/parsing/block-boundary.ts +2 -0
- package/src/parser/rules/inline/parsing/block-start-predicates.ts +10 -0
- package/src/parser/rules/inline/parsing/inline-content.ts +46 -7
- package/src/parser/rules/inline/parsing/plain-text.ts +7 -2
- package/src/parser/rules/inline/parsing/preserved-line-break.ts +13 -0
- package/src/parser/rules/inline/parsing/raw-tag.ts +19 -0
- package/src/parser/rules/inline/raw/angle.ts +2 -1
- package/src/parser/rules/inline/raw/end.ts +21 -1
- package/src/parser/rules/inline/size/content.ts +32 -5
- package/src/parser/rules/inline/size/value.ts +11 -0
- package/src/parser/rules/inline/social/index.ts +17 -0
- package/src/parser/rules/inline/social/syntax.ts +40 -0
- package/src/parser/rules/inline/span/content.ts +3 -1
- package/src/parser/rules/inline/span/newline.ts +2 -1
- package/src/parser/rules/opaque-probe.ts +58 -0
- package/src/pipeline/process.ts +13 -0
- package/src/pipeline/types.ts +19 -0
- package/src/parser/rules/block/math/content.ts +0 -54
- package/src/parser/rules/block/math/name.ts +0 -35
- package/src/parser/rules/inline/footnote/child.ts +0 -22
- package/src/parser/rules/inline/footnote/newline.ts +0 -27
|
@@ -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
|
|
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(
|
|
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
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { PageButtonData } from "@wdprlib/ast";
|
|
2
|
+
import { findRawTagClose } from "../parsing/raw-tag";
|
|
3
|
+
import type { ParseContext } from "../../types";
|
|
4
|
+
import { parseButtonAttributes } from "./attributes";
|
|
5
|
+
|
|
6
|
+
interface ButtonSyntax {
|
|
7
|
+
data: PageButtonData;
|
|
8
|
+
end: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Read a complete standalone button, including permitted separator newlines. */
|
|
12
|
+
export function parseButtonSyntax(
|
|
13
|
+
ctx: ParseContext,
|
|
14
|
+
start: number,
|
|
15
|
+
end: number,
|
|
16
|
+
): ButtonSyntax | null {
|
|
17
|
+
const tokens = ctx.tokens;
|
|
18
|
+
if (tokens[start]?.type !== "BLOCK_OPEN" || tokens[start + 1]?.value.toLowerCase() !== "button")
|
|
19
|
+
return null;
|
|
20
|
+
let pos = start + 2;
|
|
21
|
+
const skipSpace = () => {
|
|
22
|
+
const before = pos;
|
|
23
|
+
while (pos < end && /^\s+$/.test(tokens[pos]?.value ?? "")) pos++;
|
|
24
|
+
return pos > before;
|
|
25
|
+
};
|
|
26
|
+
if (!skipSpace()) return null;
|
|
27
|
+
let action = "";
|
|
28
|
+
while (
|
|
29
|
+
pos < end &&
|
|
30
|
+
tokens[pos]?.type !== "BLOCK_CLOSE" &&
|
|
31
|
+
!/^\s+$/.test(tokens[pos]?.value ?? "")
|
|
32
|
+
) {
|
|
33
|
+
const part = tokens[pos]?.value ?? "";
|
|
34
|
+
if (!/^[a-z0-9_-]+$/i.test(part)) return null;
|
|
35
|
+
action += part;
|
|
36
|
+
pos++;
|
|
37
|
+
}
|
|
38
|
+
if (!action) return null;
|
|
39
|
+
action = action.replaceAll("_", "-");
|
|
40
|
+
skipSpace();
|
|
41
|
+
const close = findRawTagClose(tokens, pos, end);
|
|
42
|
+
if (close === null) return null;
|
|
43
|
+
const attrs = parseButtonAttributes(
|
|
44
|
+
tokens
|
|
45
|
+
.slice(pos, close)
|
|
46
|
+
.map((token) => token.value)
|
|
47
|
+
.join(""),
|
|
48
|
+
);
|
|
49
|
+
const value = (key: string) => (attrs[key] && attrs[key] !== "0" ? attrs[key]! : null);
|
|
50
|
+
const attributes: Record<string, string> = {};
|
|
51
|
+
for (const name of ["class", "style"]) {
|
|
52
|
+
const attr = value(name);
|
|
53
|
+
if (attr !== null) attributes[name] = attr;
|
|
54
|
+
}
|
|
55
|
+
return { data: { action, text: value("text"), attributes }, end: close + 1 };
|
|
56
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { InlineRule } from "../../types";
|
|
2
|
+
import { parseDateSyntax } from "./syntax";
|
|
3
|
+
|
|
4
|
+
export const dateRule: InlineRule = {
|
|
5
|
+
name: "date",
|
|
6
|
+
startTokens: ["BLOCK_OPEN"],
|
|
7
|
+
parse(ctx) {
|
|
8
|
+
const parsed = parseDateSyntax(ctx, ctx.pos, ctx.scope.inlineEnd ?? ctx.tokens.length);
|
|
9
|
+
return parsed
|
|
10
|
+
? {
|
|
11
|
+
success: true,
|
|
12
|
+
consumed: parsed.end - ctx.pos,
|
|
13
|
+
elements: [{ element: "date", data: parsed.data }],
|
|
14
|
+
}
|
|
15
|
+
: { success: false };
|
|
16
|
+
},
|
|
17
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { DateData } from "@wdprlib/ast";
|
|
2
|
+
import type { ParseContext } from "../../types";
|
|
3
|
+
import { parseAttributesRaw } from "../../block/parsing/attributes";
|
|
4
|
+
|
|
5
|
+
interface DateSyntax {
|
|
6
|
+
data: DateData;
|
|
7
|
+
end: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Read a complete date without registering any parser side effects. */
|
|
11
|
+
export function parseDateSyntax(ctx: ParseContext, start: number, end: number): DateSyntax | null {
|
|
12
|
+
const tokens = ctx.tokens;
|
|
13
|
+
if (tokens[start]?.type !== "BLOCK_OPEN" || tokens[start + 1]?.value !== "date") return null;
|
|
14
|
+
let pos = start + 2;
|
|
15
|
+
const skipSpace = () => {
|
|
16
|
+
const before = pos;
|
|
17
|
+
while (pos < end && /^(?:\s+)$/.test(tokens[pos]?.value ?? "")) pos++;
|
|
18
|
+
return pos > before;
|
|
19
|
+
};
|
|
20
|
+
if (!skipSpace()) return null;
|
|
21
|
+
const value = tokens[pos]?.value ?? "";
|
|
22
|
+
if (pos >= end || !/^\d+$/.test(value)) return null;
|
|
23
|
+
const timestamp = Number(value);
|
|
24
|
+
if (!Number.isSafeInteger(timestamp) || timestamp > 8_640_000_000_000) return null;
|
|
25
|
+
pos++;
|
|
26
|
+
const separator = skipSpace();
|
|
27
|
+
if (tokens[pos]?.type !== "BLOCK_CLOSE" && !separator) return null;
|
|
28
|
+
let close = pos;
|
|
29
|
+
for (; close < end; close++) {
|
|
30
|
+
const token = tokens[close];
|
|
31
|
+
if (!token || token.type === "EOF" || token.type === "BLOCK_OPEN" || /[\r\n]/.test(token.value))
|
|
32
|
+
return null;
|
|
33
|
+
if (token.type === "BLOCK_CLOSE") break;
|
|
34
|
+
}
|
|
35
|
+
if (close >= end) return null;
|
|
36
|
+
const attributes = parseAttributesRaw(ctx, pos).attrs;
|
|
37
|
+
const format = attributes.format || null;
|
|
38
|
+
return {
|
|
39
|
+
data: {
|
|
40
|
+
value: { timestamp, timezone: "UTC" },
|
|
41
|
+
format,
|
|
42
|
+
hover: format?.split("|").slice(1).includes("agohover") ?? false,
|
|
43
|
+
},
|
|
44
|
+
end: close + 1,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { Token, TokenType } from "../../../../lexer";
|
|
2
|
+
import { isLocalChar, scanEmail } from "./scan";
|
|
3
|
+
|
|
4
|
+
export interface EmailCandidate {
|
|
5
|
+
start: number;
|
|
6
|
+
end: number;
|
|
7
|
+
endToken: number;
|
|
8
|
+
address: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface EmailCache {
|
|
12
|
+
source: string;
|
|
13
|
+
offsets: number[];
|
|
14
|
+
candidates: Map<number, EmailCandidate | null>;
|
|
15
|
+
commentEnds: Map<number, number>;
|
|
16
|
+
unclosedComment: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const caches = new WeakMap<readonly Token[], EmailCache>();
|
|
20
|
+
export const EMAIL_START_TOKENS: TokenType[] = [
|
|
21
|
+
"TEXT",
|
|
22
|
+
"IDENTIFIER",
|
|
23
|
+
"UNDERSCORE",
|
|
24
|
+
"UNDERLINE_MARKER",
|
|
25
|
+
"STRIKE_MARKER",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function getCache(tokens: readonly Token[]): EmailCache {
|
|
29
|
+
let cache = caches.get(tokens);
|
|
30
|
+
if (cache) return cache;
|
|
31
|
+
const offsets = [0];
|
|
32
|
+
const values: string[] = [];
|
|
33
|
+
for (const token of tokens) {
|
|
34
|
+
values.push(token.value);
|
|
35
|
+
offsets.push(offsets.at(-1)! + token.value.length);
|
|
36
|
+
}
|
|
37
|
+
cache = {
|
|
38
|
+
source: values.join(""),
|
|
39
|
+
offsets,
|
|
40
|
+
candidates: new Map(),
|
|
41
|
+
commentEnds: new Map(),
|
|
42
|
+
unclosedComment: Infinity,
|
|
43
|
+
};
|
|
44
|
+
caches.set(tokens, cache);
|
|
45
|
+
return cache;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function commentEnd(cache: EmailCache, pos: number): number {
|
|
49
|
+
const opener = cache.source[pos] === "\n" ? pos + 1 : pos;
|
|
50
|
+
if (!cache.source.startsWith("[!--", opener)) return pos;
|
|
51
|
+
const cached = cache.commentEnds.get(pos);
|
|
52
|
+
if (cached !== undefined) return cached;
|
|
53
|
+
const close = opener >= cache.unclosedComment ? -1 : cache.source.indexOf("--]", opener + 4);
|
|
54
|
+
if (close === -1) cache.unclosedComment = Math.min(cache.unclosedComment, opener);
|
|
55
|
+
const end = close === -1 ? pos : close + 3;
|
|
56
|
+
cache.commentEnds.set(pos, end);
|
|
57
|
+
return end;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getEmailCandidate(tokens: readonly Token[], index: number): EmailCandidate | null {
|
|
61
|
+
const token = tokens[index];
|
|
62
|
+
if (!token || !EMAIL_START_TOKENS.includes(token.type)) return null;
|
|
63
|
+
const cache = getCache(tokens);
|
|
64
|
+
if (cache.candidates.has(index)) return cache.candidates.get(index)!;
|
|
65
|
+
const value = tokens[index]?.value ?? "";
|
|
66
|
+
// Compact TEXT may include a prefix before the last possible local-part run.
|
|
67
|
+
let suffix = value.length;
|
|
68
|
+
while (suffix > 0 && (isLocalChar(value.charCodeAt(suffix - 1)) || value[suffix - 1] === "."))
|
|
69
|
+
suffix--;
|
|
70
|
+
const doubled = value.lastIndexOf("..");
|
|
71
|
+
if (doubled >= suffix) suffix = doubled + 2;
|
|
72
|
+
while (value[suffix] === ".") suffix++;
|
|
73
|
+
if (suffix >= value.length || !isLocalChar(value.charCodeAt(suffix))) {
|
|
74
|
+
cache.candidates.set(index, null);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const start = cache.offsets[index]! + suffix;
|
|
78
|
+
const scanned = scanEmail(cache.source, start, (pos) => commentEnd(cache, pos));
|
|
79
|
+
if (scanned.end === undefined) {
|
|
80
|
+
cache.candidates.set(index, null);
|
|
81
|
+
let comment = 0;
|
|
82
|
+
// A failed local run cannot produce an address from one of its suffix tokens.
|
|
83
|
+
// Do not poison positions inside skipped comments: another parse scope may see them as raw text.
|
|
84
|
+
for (let i = index + 1; i < tokens.length && cache.offsets[i + 1]! <= scanned.localEnd; i++) {
|
|
85
|
+
const offset = cache.offsets[i]!;
|
|
86
|
+
while (scanned.comments[comment] && scanned.comments[comment]!.end <= offset) comment++;
|
|
87
|
+
const region = scanned.comments[comment];
|
|
88
|
+
if (!region || offset < region.start) cache.candidates.set(i, null);
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
let endToken = index;
|
|
93
|
+
while (cache.offsets[endToken + 1]! < scanned.end) endToken++;
|
|
94
|
+
const candidate = { start, end: scanned.end, endToken, address: scanned.address! };
|
|
95
|
+
cache.candidates.set(index, candidate);
|
|
96
|
+
return candidate;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface EmailGroup {
|
|
100
|
+
candidates: EmailCandidate[];
|
|
101
|
+
source: string;
|
|
102
|
+
start: number;
|
|
103
|
+
end: number;
|
|
104
|
+
endToken: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function getEmailGroup(
|
|
108
|
+
tokens: readonly Token[],
|
|
109
|
+
index: number,
|
|
110
|
+
limit: number,
|
|
111
|
+
): EmailGroup | null {
|
|
112
|
+
const first = getEmailCandidate(tokens, index);
|
|
113
|
+
if (!first || first.endToken >= limit) return null;
|
|
114
|
+
const candidates = [first];
|
|
115
|
+
let last = first;
|
|
116
|
+
while (last.endToken > index) {
|
|
117
|
+
const next = getEmailCandidate(tokens, last.endToken);
|
|
118
|
+
if (!next || next.start < last.end || next.endToken >= limit) break;
|
|
119
|
+
candidates.push(next);
|
|
120
|
+
last = next;
|
|
121
|
+
}
|
|
122
|
+
const cache = getCache(tokens);
|
|
123
|
+
return {
|
|
124
|
+
candidates,
|
|
125
|
+
source: cache.source,
|
|
126
|
+
start: cache.offsets[index]!,
|
|
127
|
+
end: cache.offsets[last.endToken + 1]!,
|
|
128
|
+
endToken: last.endToken + 1,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function emailRegionEnd(tokens: readonly Token[], index: number, limit: number): number {
|
|
133
|
+
return getEmailGroup(tokens, index, limit)?.endToken ?? index;
|
|
134
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Element } from "@wdprlib/ast";
|
|
2
|
+
import type { InlineRule } from "../../types";
|
|
3
|
+
import { EMAIL_START_TOKENS, getEmailGroup } from "./candidates";
|
|
4
|
+
|
|
5
|
+
export const emailRule: InlineRule = {
|
|
6
|
+
name: "email",
|
|
7
|
+
startTokens: EMAIL_START_TOKENS,
|
|
8
|
+
parse(ctx) {
|
|
9
|
+
const group = getEmailGroup(ctx.tokens, ctx.pos, ctx.scope.inlineEnd ?? ctx.tokens.length);
|
|
10
|
+
if (!group) return { success: false };
|
|
11
|
+
const elements: Element[] = [];
|
|
12
|
+
let copied = group.start;
|
|
13
|
+
for (const candidate of group.candidates) {
|
|
14
|
+
if (copied < candidate.start)
|
|
15
|
+
elements.push({ element: "text", data: group.source.slice(copied, candidate.start) });
|
|
16
|
+
elements.push(
|
|
17
|
+
ctx.scope.suppressEmailLinks
|
|
18
|
+
? { element: "text", data: candidate.address }
|
|
19
|
+
: {
|
|
20
|
+
element: "link",
|
|
21
|
+
data: {
|
|
22
|
+
type: "direct",
|
|
23
|
+
link: `mailto:${candidate.address}`,
|
|
24
|
+
label: { text: candidate.address },
|
|
25
|
+
target: null,
|
|
26
|
+
extra: null,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
copied = candidate.end;
|
|
31
|
+
}
|
|
32
|
+
if (copied < group.end)
|
|
33
|
+
elements.push({ element: "text", data: group.source.slice(copied, group.end) });
|
|
34
|
+
return { success: true, elements, consumed: group.endToken - ctx.pos };
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export interface EmailScan {
|
|
2
|
+
end?: number;
|
|
3
|
+
address?: string;
|
|
4
|
+
localEnd: number;
|
|
5
|
+
comments: Array<{ start: number; end: number }>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function isDomainChar(code: number): boolean {
|
|
9
|
+
return (
|
|
10
|
+
(code >= 48 && code <= 57) ||
|
|
11
|
+
(code >= 65 && code <= 90) ||
|
|
12
|
+
(code >= 97 && code <= 122) ||
|
|
13
|
+
code === 45
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isLocalChar(code: number): boolean {
|
|
18
|
+
return isDomainChar(code) || code === 95;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Read Email.php's grammar from one possible start, ignoring complete comments. */
|
|
22
|
+
export function scanEmail(
|
|
23
|
+
source: string,
|
|
24
|
+
start: number,
|
|
25
|
+
commentEnd: (pos: number) => number,
|
|
26
|
+
): EmailScan {
|
|
27
|
+
const comments: EmailScan["comments"] = [];
|
|
28
|
+
let pos = start;
|
|
29
|
+
const skipComments = () => {
|
|
30
|
+
let end = commentEnd(pos);
|
|
31
|
+
while (end > pos) {
|
|
32
|
+
comments.push({ start: pos, end });
|
|
33
|
+
pos = end;
|
|
34
|
+
end = commentEnd(pos);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
let localPart = false;
|
|
38
|
+
while (pos < source.length) {
|
|
39
|
+
skipComments();
|
|
40
|
+
if (isLocalChar(source.charCodeAt(pos))) {
|
|
41
|
+
localPart = true;
|
|
42
|
+
pos++;
|
|
43
|
+
} else if (source[pos] === "." && localPart) {
|
|
44
|
+
localPart = false;
|
|
45
|
+
pos++;
|
|
46
|
+
} else break;
|
|
47
|
+
}
|
|
48
|
+
const localEnd = pos;
|
|
49
|
+
if (!localPart || source[pos] !== "@") return { localEnd, comments };
|
|
50
|
+
pos++;
|
|
51
|
+
let domainPart = false;
|
|
52
|
+
let dots = 0;
|
|
53
|
+
let end = -1;
|
|
54
|
+
while (pos < source.length) {
|
|
55
|
+
skipComments();
|
|
56
|
+
if (isDomainChar(source.charCodeAt(pos))) {
|
|
57
|
+
domainPart = true;
|
|
58
|
+
pos++;
|
|
59
|
+
if (dots > 0) end = pos;
|
|
60
|
+
} else if (source[pos] === "." && domainPart) {
|
|
61
|
+
domainPart = false;
|
|
62
|
+
dots++;
|
|
63
|
+
pos++;
|
|
64
|
+
} else break;
|
|
65
|
+
}
|
|
66
|
+
if (end === -1) return { localEnd, comments };
|
|
67
|
+
const parts: string[] = [];
|
|
68
|
+
let copied = start;
|
|
69
|
+
for (const comment of comments) {
|
|
70
|
+
if (comment.start >= end) break;
|
|
71
|
+
parts.push(source.slice(copied, comment.start));
|
|
72
|
+
copied = comment.end;
|
|
73
|
+
}
|
|
74
|
+
parts.push(source.slice(copied, end));
|
|
75
|
+
return { end, address: parts.join(""), localEnd, comments };
|
|
76
|
+
}
|
|
@@ -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 { getCandidateInlineRules } from "../utils";
|
|
@@ -52,7 +53,8 @@ export function parseInlineBranch(ctx: ParseContext, startPos: number): BranchPa
|
|
|
52
53
|
}
|
|
53
54
|
const result = rule.parse(inlineCtx);
|
|
54
55
|
if (result.success) {
|
|
55
|
-
elements
|
|
56
|
+
stripAutomaticLineBreak(elements, result.stripLeadingLineBreak);
|
|
57
|
+
for (const element of result.elements) elements.push(element);
|
|
56
58
|
consumed += result.consumed;
|
|
57
59
|
pos += result.consumed;
|
|
58
60
|
matched = true;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createOpaqueProbe, opaqueRuleEnd } from "../../opaque-probe";
|
|
2
|
+
import type { ParseContext } from "../../types";
|
|
3
|
+
import { codeBlockRule } from "../../block/code";
|
|
4
|
+
import { htmlBlockRule } from "../../block/html";
|
|
5
|
+
import { mathBlockRule } from "../../block/math";
|
|
6
|
+
import { mathInlineRule } from "../math-inline";
|
|
7
|
+
import { linkTripleRule } from "../link-triple";
|
|
8
|
+
import { linkSingleRule } from "../link-single";
|
|
9
|
+
import { linkStarRule } from "../link-star";
|
|
10
|
+
import { linkAnchorRule } from "../link-anchor";
|
|
11
|
+
import { protectedInlineRegionEnd } from "../raw/end";
|
|
12
|
+
import { tryConsumeFootnoteClose } from "./close";
|
|
13
|
+
import { parseFootnoteOpen } from "./open";
|
|
14
|
+
|
|
15
|
+
const opaqueRules = [
|
|
16
|
+
codeBlockRule,
|
|
17
|
+
htmlBlockRule,
|
|
18
|
+
mathBlockRule,
|
|
19
|
+
mathInlineRule,
|
|
20
|
+
linkTripleRule,
|
|
21
|
+
linkSingleRule,
|
|
22
|
+
linkStarRule,
|
|
23
|
+
linkAnchorRule,
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/** Locate the enclosing close without parsing code, HTML or math as footnote syntax. */
|
|
27
|
+
export function findFootnoteEnd(ctx: ParseContext, start: number): number {
|
|
28
|
+
const probe = createOpaqueProbe(ctx);
|
|
29
|
+
let depth = 0;
|
|
30
|
+
for (let pos = start; pos < ctx.tokens.length; pos++) {
|
|
31
|
+
if (ctx.tokens[pos]?.type === "EOF") return pos;
|
|
32
|
+
const protectedEnd = protectedInlineRegionEnd(ctx.tokens, pos, ctx.tokens.length);
|
|
33
|
+
if (protectedEnd > pos) {
|
|
34
|
+
pos = protectedEnd - 1;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
probe.pos = pos;
|
|
39
|
+
const close = tryConsumeFootnoteClose(probe, pos);
|
|
40
|
+
if (close) {
|
|
41
|
+
if (depth === 0) return pos;
|
|
42
|
+
depth--;
|
|
43
|
+
pos += close.consumed - 1;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const open = parseFootnoteOpen(probe);
|
|
47
|
+
if (open) {
|
|
48
|
+
depth++;
|
|
49
|
+
pos += open.consumed - 1;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const opaqueEnd = opaqueRuleEnd(probe, pos, opaqueRules);
|
|
53
|
+
if (opaqueEnd > pos) pos = opaqueEnd - 1;
|
|
54
|
+
}
|
|
55
|
+
return ctx.tokens.length;
|
|
56
|
+
}
|