@meowdown/markdown 0.63.0 → 0.64.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/README.md +8 -1
- package/dist/index.js +482 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @meowdown/markdown
|
|
2
2
|
|
|
3
|
-
The [`@lezer/markdown`](https://github.com/lezer-parser/markdown) grammar layer behind [`@meowdown/core`](https://www.npmjs.com/package/@meowdown/core): GFM plus meowdown's inline syntax (wiki links, wiki embeds, hashtags, `==highlight==`, `$math$`, bare autolinks).
|
|
3
|
+
The [`@lezer/markdown`](https://github.com/lezer-parser/markdown) grammar layer behind [`@meowdown/core`](https://www.npmjs.com/package/@meowdown/core): [GFM](https://github.github.com/gfm/) plus meowdown's inline syntax (wiki links, wiki embeds, hashtags, `==highlight==`, `$math$`, bare autolinks).
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
6
|
npm install @meowdown/markdown
|
|
@@ -11,3 +11,10 @@ import { gfmParser } from '@meowdown/markdown'
|
|
|
11
11
|
|
|
12
12
|
const tree = gfmParser.parse('Meeting with [[Ada Lovelace|Ada]]')
|
|
13
13
|
```
|
|
14
|
+
|
|
15
|
+
## Exports
|
|
16
|
+
|
|
17
|
+
- `gfmParser` / `gfmBlockOnlyParser`: the full and block-only Markdown parsers
|
|
18
|
+
- `parseInline` / `collectInlineElements`: low-level inline syntax parsing
|
|
19
|
+
- `getAutolinkHref`: bare-domain autolink matching against the TLD allowlist
|
|
20
|
+
- `LEZER_NODE_IDS`: the node id table shared with `@meowdown/core`
|
package/dist/index.js
CHANGED
|
@@ -1 +1,482 @@
|
|
|
1
|
-
import{GFM
|
|
1
|
+
import { GFM, parser } from "@lezer/markdown";
|
|
2
|
+
|
|
3
|
+
//#region src/autolink-tld.ts
|
|
4
|
+
/**
|
|
5
|
+
* Allowed TLDs when they appear in a bare domain (no scheme, no `www.`).
|
|
6
|
+
*
|
|
7
|
+
* The 10 most-visited TLDs by real Chrome traffic.
|
|
8
|
+
* Source: Chrome UX Report https://github.com/zakird/crux-top-lists
|
|
9
|
+
*/
|
|
10
|
+
const BARE_AUTOLINK_TLDS = /* @__PURE__ */ new Set([
|
|
11
|
+
"com",
|
|
12
|
+
"br",
|
|
13
|
+
"net",
|
|
14
|
+
"jp",
|
|
15
|
+
"org",
|
|
16
|
+
"in",
|
|
17
|
+
"de",
|
|
18
|
+
"ru",
|
|
19
|
+
"it",
|
|
20
|
+
"fr"
|
|
21
|
+
]);
|
|
22
|
+
const DNS_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i;
|
|
23
|
+
/** The host portion of a bare candidate: everything before the first `/`. */
|
|
24
|
+
function hostFromUrl(text) {
|
|
25
|
+
const slash = text.indexOf("/");
|
|
26
|
+
return slash === -1 ? text : text.slice(0, slash);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* True when `host` (no scheme, no `@`, path already stripped) is a bare domain
|
|
30
|
+
* meowdown links. Rules:
|
|
31
|
+
*
|
|
32
|
+
* - at least two dot-separated labels (host + tld)
|
|
33
|
+
* - the last label is in `BARE_AUTOLINK_TLDS` (matched case-insensitively)
|
|
34
|
+
* - the registrable label (the one before the tld) is at least 3 chars, so
|
|
35
|
+
* `t.co` / `x.io` / `do.so` stay plain text
|
|
36
|
+
* - every label is a valid DNS label (alphanumeric, inner hyphens only, <= 63
|
|
37
|
+
* chars), which also rejects IP-like input such as `1.2.3.4` because its last
|
|
38
|
+
* label is not a known tld
|
|
39
|
+
*/
|
|
40
|
+
function isLinkableBareHost(host) {
|
|
41
|
+
const labels = host.split(".");
|
|
42
|
+
if (labels.length < 2) return false;
|
|
43
|
+
const tld = labels[labels.length - 1].toLowerCase();
|
|
44
|
+
if (!BARE_AUTOLINK_TLDS.has(tld)) return false;
|
|
45
|
+
if (labels[labels.length - 2].length < 3) return false;
|
|
46
|
+
for (const label of labels) if (label.length > 63 || !DNS_LABEL_RE.test(label)) return false;
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Derive the `href` for an autolink from its visible text:
|
|
51
|
+
*
|
|
52
|
+
* - a URL with a scheme is used as-is
|
|
53
|
+
* - an email becomes `mailto:`
|
|
54
|
+
* - a `www.` URL gets an implied `https://`
|
|
55
|
+
* - a bare domain on the curated TLD list gets an implied `https://`
|
|
56
|
+
* - anything else returns `undefined`
|
|
57
|
+
*/
|
|
58
|
+
function getAutolinkHref(urlText) {
|
|
59
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(urlText)) return urlText;
|
|
60
|
+
if (/^[^\s@]+@[^\s@]+$/.test(urlText)) return `mailto:${urlText}`;
|
|
61
|
+
if (/^www\./i.test(urlText)) return `https://${urlText}`;
|
|
62
|
+
if (isLinkableBareHost(hostFromUrl(urlText))) return `https://${urlText}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/unicode.ts
|
|
67
|
+
const CHAR_LINE_FEED = 10;
|
|
68
|
+
const CHAR_CARRIAGE_RETURN = 13;
|
|
69
|
+
const CHAR_TAB = 9;
|
|
70
|
+
const CHAR_SPACE = 32;
|
|
71
|
+
/**
|
|
72
|
+
* Check if a char code is a space character.
|
|
73
|
+
*
|
|
74
|
+
* Ported from https://github.com/lezer-parser/markdown/blob/1.6.3/src/markdown.ts#L233
|
|
75
|
+
*/
|
|
76
|
+
function isSpaceChar(char) {
|
|
77
|
+
return char === 32 || char === 9 || char === 10 || char === CHAR_CARRIAGE_RETURN;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
//#endregion
|
|
81
|
+
//#region src/bare-autolink.ts
|
|
82
|
+
const DOMAIN_RE = /^[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:\/[^\s<]*)?/i;
|
|
83
|
+
const BOUNDARY_BEFORE_RE = /[\s(*_~]/;
|
|
84
|
+
function isDomainStartChar(code) {
|
|
85
|
+
return code >= 48 && code <= 57 || code >= 65 && code <= 90 || code >= 97 && code <= 122 || code === 45;
|
|
86
|
+
}
|
|
87
|
+
function countChar(text, end, ch) {
|
|
88
|
+
let count = 0;
|
|
89
|
+
for (let i = 0; i < end; i++) if (text[i] === ch) count++;
|
|
90
|
+
return count;
|
|
91
|
+
}
|
|
92
|
+
function trimAutolinkEnd(matched) {
|
|
93
|
+
let end = matched.length;
|
|
94
|
+
for (;;) {
|
|
95
|
+
const last = matched[end - 1];
|
|
96
|
+
if (/[?!.,:*_~]/.test(last) || last === ")" && countChar(matched, end, ")") > countChar(matched, end, "(")) end--;
|
|
97
|
+
else if (last === ";") {
|
|
98
|
+
const entity = /&(?:#\d+|#x[a-f\d]+|\w+);$/.exec(matched.slice(0, end));
|
|
99
|
+
if (!entity) break;
|
|
100
|
+
end = entity.index;
|
|
101
|
+
} else break;
|
|
102
|
+
}
|
|
103
|
+
return end;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Inline parser for a bare domain autolink such as `google.com` or
|
|
107
|
+
* `sub.domain.io/path` (no scheme, no `www.`). It runs after GFM's own
|
|
108
|
+
* `Autolink` so `www.`/scheme/email forms are claimed first and never reach
|
|
109
|
+
* here. The domain must pass `isLinkableBareHost` (a curated TLD list plus
|
|
110
|
+
* shape rules), which keeps `node.js`, `README.md`, and `i.e.` plain text. It
|
|
111
|
+
* emits the shared `URL` node, so the existing mark walk renders it like any
|
|
112
|
+
* other autolink.
|
|
113
|
+
*/
|
|
114
|
+
const bareAutolink = { parseInline: [{
|
|
115
|
+
name: "BareAutolink",
|
|
116
|
+
before: "Link",
|
|
117
|
+
parse(cx, next, pos) {
|
|
118
|
+
if (!isDomainStartChar(next) || cx.hasOpenLink) return -1;
|
|
119
|
+
const before = cx.slice(pos - 1, pos);
|
|
120
|
+
if (before !== "" && !BOUNDARY_BEFORE_RE.test(before)) return -1;
|
|
121
|
+
const match = DOMAIN_RE.exec(cx.slice(pos, cx.end));
|
|
122
|
+
if (!match) return -1;
|
|
123
|
+
const length = trimAutolinkEnd(match[0]);
|
|
124
|
+
if (length === 0) return -1;
|
|
125
|
+
const text = match[0].slice(0, length);
|
|
126
|
+
if (!isLinkableBareHost(hostFromUrl(text))) return -1;
|
|
127
|
+
return cx.addElement(cx.elt("URL", pos, pos + length));
|
|
128
|
+
}
|
|
129
|
+
}] };
|
|
130
|
+
|
|
131
|
+
//#endregion
|
|
132
|
+
//#region src/hashtag.ts
|
|
133
|
+
/**
|
|
134
|
+
* Letters, digits, `-`, `_`. Non-ASCII falls back to a Unicode test;
|
|
135
|
+
* surrogate halves fail it, so emoji terminate the tag.
|
|
136
|
+
*/
|
|
137
|
+
function isTagChar(code) {
|
|
138
|
+
return code >= 48 && code <= 57 || code >= 65 && code <= 90 || code >= 97 && code <= 122 || code === 45 || code === 95 || code > 127 && /[\p{L}\p{N}]/u.test(String.fromCharCode(code));
|
|
139
|
+
}
|
|
140
|
+
function isLetter(code) {
|
|
141
|
+
return code >= 65 && code <= 90 || code >= 97 && code <= 122 || code > 127 && /\p{L}/u.test(String.fromCharCode(code));
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Inline parser for `#tag`: `#` followed by tag chars, at least one of
|
|
145
|
+
* them a letter, where the `#` sits at the start of the inline text or
|
|
146
|
+
* after whitespace. Mirrors the tag menu's `(?<!\S)#` trigger in
|
|
147
|
+
* `@meowdown/react`.
|
|
148
|
+
*/
|
|
149
|
+
const hashtag = {
|
|
150
|
+
defineNodes: [{ name: "Hashtag" }],
|
|
151
|
+
parseInline: [{
|
|
152
|
+
name: "Hashtag",
|
|
153
|
+
parse(cx, next, pos) {
|
|
154
|
+
if (next !== 35) return -1;
|
|
155
|
+
if (!/\s|^$/.test(cx.slice(pos - 1, pos))) return -1;
|
|
156
|
+
let end = pos + 1;
|
|
157
|
+
let hasLetter = false;
|
|
158
|
+
while (end < cx.end) {
|
|
159
|
+
const code = cx.char(end);
|
|
160
|
+
if (!isTagChar(code)) break;
|
|
161
|
+
hasLetter ||= isLetter(code);
|
|
162
|
+
end++;
|
|
163
|
+
}
|
|
164
|
+
if (!hasLetter) return -1;
|
|
165
|
+
return cx.addElement(cx.elt("Hashtag", pos, end));
|
|
166
|
+
}
|
|
167
|
+
}]
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
//#endregion
|
|
171
|
+
//#region src/highlight.ts
|
|
172
|
+
const HighlightDelim = {
|
|
173
|
+
resolve: "Highlight",
|
|
174
|
+
mark: "HighlightMark"
|
|
175
|
+
};
|
|
176
|
+
/**
|
|
177
|
+
* CommonMark punctuation class, copied from `@lezer/markdown`'s own
|
|
178
|
+
* `Punctuation` regex so highlight flanking decisions match GFM strikethrough.
|
|
179
|
+
*/
|
|
180
|
+
const PUNCTUATION = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~\u{A1}\u{2010}-\u{2027}]/u;
|
|
181
|
+
/**
|
|
182
|
+
* Inline parser for `==text==` highlight. Emits a `Highlight` node wrapping the
|
|
183
|
+
* content, with `HighlightMark` runs for the `==` delimiters, mirroring GFM
|
|
184
|
+
* `Strikethrough`. It reuses strikethrough's whitespace/punctuation flanking
|
|
185
|
+
* rules so a space-flanked `== ` never opens a highlight (a lone `a == b` stays
|
|
186
|
+
* literal), and refuses a third `=` so `===` runs are not consumed.
|
|
187
|
+
*/
|
|
188
|
+
const highlight = {
|
|
189
|
+
defineNodes: [{ name: "Highlight" }, { name: "HighlightMark" }],
|
|
190
|
+
parseInline: [{
|
|
191
|
+
name: "Highlight",
|
|
192
|
+
after: "Emphasis",
|
|
193
|
+
parse(cx, next, pos) {
|
|
194
|
+
if (next !== 61 || cx.char(pos + 1) !== 61 || cx.char(pos + 2) === 61) return -1;
|
|
195
|
+
const before = cx.slice(pos - 1, pos);
|
|
196
|
+
const after = cx.slice(pos + 2, pos + 3);
|
|
197
|
+
const spaceBefore = /\s|^$/.test(before);
|
|
198
|
+
const spaceAfter = /\s|^$/.test(after);
|
|
199
|
+
const punctBefore = PUNCTUATION.test(before);
|
|
200
|
+
const punctAfter = PUNCTUATION.test(after);
|
|
201
|
+
return cx.addDelimiter(HighlightDelim, pos, pos + 2, !spaceAfter && (!punctAfter || spaceBefore || punctBefore), !spaceBefore && (!punctBefore || spaceAfter || punctAfter));
|
|
202
|
+
}
|
|
203
|
+
}]
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
//#endregion
|
|
207
|
+
//#region src/math.ts
|
|
208
|
+
function isDigit(code) {
|
|
209
|
+
return code >= 48 && code <= 57;
|
|
210
|
+
}
|
|
211
|
+
/** A line whose content is exactly `$$`, allowing trailing whitespace. */
|
|
212
|
+
function isBlockMathFence(line) {
|
|
213
|
+
if (line.next !== 36) return false;
|
|
214
|
+
if (line.text.charCodeAt(line.pos + 1) !== 36) return false;
|
|
215
|
+
if (line.text.charCodeAt(line.pos + 2) === 36) return false;
|
|
216
|
+
return line.skipSpace(line.pos + 2) === line.text.length;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* How many composite contexts (blockquote, list item) the line still sits
|
|
220
|
+
* inside. `Line.depth` is not in the public typings (the FencedCode parser
|
|
221
|
+
* reads it the same way); if a future upgrade drops it, every line counts as
|
|
222
|
+
* still inside, and an unterminated block simply runs longer.
|
|
223
|
+
*/
|
|
224
|
+
function getLineDepth(line) {
|
|
225
|
+
const depth = line.depth;
|
|
226
|
+
return typeof depth === "number" ? depth : Number.MAX_SAFE_INTEGER;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Inline parser for `$x$` and `$$x$$` TeX math, following Pandoc-style
|
|
230
|
+
* delimiter rules: the opening and closing runs must have the same length (1
|
|
231
|
+
* or 2 dollars), the content must not start or end with a space, the closing
|
|
232
|
+
* run must not be followed by a digit (so `$20,000 and $30,000` stays plain
|
|
233
|
+
* text), and the whole expression stays on one line. A backslash-escaped `\$`
|
|
234
|
+
* inside the content does not close. Runs are greedy: an opener preceded by
|
|
235
|
+
* another dollar never starts a new expression, and the first closing
|
|
236
|
+
* candidate decides: if it is invalid the whole expression fails, so an
|
|
237
|
+
* unpaired dollar never scans across the rest of the line. Claims the
|
|
238
|
+
* element eagerly, so the content is atomic: no nested markdown.
|
|
239
|
+
*/
|
|
240
|
+
const math = {
|
|
241
|
+
defineNodes: [
|
|
242
|
+
{ name: "InlineMath" },
|
|
243
|
+
{ name: "InlineMathMark" },
|
|
244
|
+
{
|
|
245
|
+
name: "BlockMath",
|
|
246
|
+
block: true
|
|
247
|
+
},
|
|
248
|
+
{ name: "BlockMathMark" }
|
|
249
|
+
],
|
|
250
|
+
parseBlock: [{
|
|
251
|
+
name: "BlockMath",
|
|
252
|
+
before: "FencedCode",
|
|
253
|
+
parse(cx, line) {
|
|
254
|
+
if (!isBlockMathFence(line)) return false;
|
|
255
|
+
const from = cx.lineStart + line.pos;
|
|
256
|
+
const marks = [cx.elt("BlockMathMark", from, from + 2)];
|
|
257
|
+
for (let first = true, empty = true, hasLine = false;; first = false) {
|
|
258
|
+
if (!cx.nextLine() || getLineDepth(line) < cx.depth) break;
|
|
259
|
+
if (isBlockMathFence(line)) {
|
|
260
|
+
if (empty && hasLine) marks.push(cx.elt("CodeText", cx.lineStart - 1, cx.lineStart));
|
|
261
|
+
marks.push(cx.elt("BlockMathMark", cx.lineStart + line.pos, cx.lineStart + line.pos + 2));
|
|
262
|
+
cx.nextLine();
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
hasLine = true;
|
|
266
|
+
if (!first) {
|
|
267
|
+
marks.push(cx.elt("CodeText", cx.lineStart - 1, cx.lineStart));
|
|
268
|
+
empty = false;
|
|
269
|
+
}
|
|
270
|
+
const textFrom = cx.lineStart + line.basePos;
|
|
271
|
+
const textTo = cx.lineStart + line.text.length;
|
|
272
|
+
if (textFrom < textTo) {
|
|
273
|
+
marks.push(cx.elt("CodeText", textFrom, textTo));
|
|
274
|
+
empty = false;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
cx.addElement(cx.elt("BlockMath", from, cx.prevLineEnd(), marks));
|
|
278
|
+
return true;
|
|
279
|
+
},
|
|
280
|
+
endLeaf(_cx, line) {
|
|
281
|
+
return isBlockMathFence(line);
|
|
282
|
+
}
|
|
283
|
+
}],
|
|
284
|
+
parseInline: [{
|
|
285
|
+
name: "InlineMath",
|
|
286
|
+
after: "InlineCode",
|
|
287
|
+
parse(cx, next, pos) {
|
|
288
|
+
if (next !== 36 || cx.char(pos - 1) === 36) return -1;
|
|
289
|
+
const delimLength = cx.char(pos + 1) === 36 ? 2 : 1;
|
|
290
|
+
if (cx.char(pos + delimLength) === 36) return -1;
|
|
291
|
+
const contentFrom = pos + delimLength;
|
|
292
|
+
if (isSpaceChar(cx.char(contentFrom))) return -1;
|
|
293
|
+
for (let i = contentFrom; i < cx.end; i++) {
|
|
294
|
+
const code = cx.char(i);
|
|
295
|
+
if (code === 10) return -1;
|
|
296
|
+
if (code === 92) {
|
|
297
|
+
i++;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (code !== 36) continue;
|
|
301
|
+
let closeLength = 1;
|
|
302
|
+
while (cx.char(i + closeLength) === 36) closeLength++;
|
|
303
|
+
if (closeLength !== delimLength || isSpaceChar(cx.char(i - 1)) || isDigit(cx.char(i + closeLength))) return -1;
|
|
304
|
+
const end = i + closeLength;
|
|
305
|
+
return cx.addElement(cx.elt("InlineMath", pos, end, [cx.elt("InlineMathMark", pos, contentFrom), cx.elt("InlineMathMark", i, end)]));
|
|
306
|
+
}
|
|
307
|
+
return -1;
|
|
308
|
+
}
|
|
309
|
+
}]
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
//#endregion
|
|
313
|
+
//#region src/scheme-autolink.ts
|
|
314
|
+
const SCHEME_URI_RE = /^[a-z][a-z0-9+.-]*:\/\/[^\s<]+/i;
|
|
315
|
+
function isSchemeStartChar(code) {
|
|
316
|
+
return code >= 65 && code <= 90 || code >= 97 && code <= 122;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Inline parser for a bare custom-scheme URI such as
|
|
320
|
+
* `x-devonthink-item://ABCD-1234` or `obsidian://open?vault=notes`. GFM's own
|
|
321
|
+
* `Autolink` only recognizes `www.`/`http(s)://`/`mailto:`/`xmpp:`/email
|
|
322
|
+
* forms, so an app URI typed or pasted as plain text stayed unlinkified.
|
|
323
|
+
*
|
|
324
|
+
* Registered `after: 'Autolink'` so GFM keeps first claim on the shapes it
|
|
325
|
+
* knows (its `http(s)` domain and end rules stay authoritative); this parser
|
|
326
|
+
* only picks up what GFM declines. It follows `bareAutolink`'s boundary rules
|
|
327
|
+
* and emits the shared `URL` node, so the existing mark walk renders it like
|
|
328
|
+
* any other autolink.
|
|
329
|
+
*/
|
|
330
|
+
const schemeAutolink = { parseInline: [{
|
|
331
|
+
name: "SchemeAutolink",
|
|
332
|
+
after: "Autolink",
|
|
333
|
+
parse(cx, next, pos) {
|
|
334
|
+
if (!isSchemeStartChar(next) || cx.hasOpenLink) return -1;
|
|
335
|
+
const before = cx.slice(pos - 1, pos);
|
|
336
|
+
if (before !== "" && !BOUNDARY_BEFORE_RE.test(before)) return -1;
|
|
337
|
+
const match = SCHEME_URI_RE.exec(cx.slice(pos, cx.end));
|
|
338
|
+
if (!match) return -1;
|
|
339
|
+
const length = trimAutolinkEnd(match[0]);
|
|
340
|
+
if (length <= match[0].indexOf("://") + 3) return -1;
|
|
341
|
+
return cx.addElement(cx.elt("URL", pos, pos + length));
|
|
342
|
+
}
|
|
343
|
+
}] };
|
|
344
|
+
|
|
345
|
+
//#endregion
|
|
346
|
+
//#region src/wiki-embed.ts
|
|
347
|
+
/**
|
|
348
|
+
* Inline parser for Obsidian-style wiki embeds (`![[target]]`). The target is
|
|
349
|
+
* deliberately kept opaque here; classification and optional size parsing
|
|
350
|
+
* happen at the host boundary in `parseWikiEmbed`.
|
|
351
|
+
*/
|
|
352
|
+
const wikiEmbed = {
|
|
353
|
+
defineNodes: [{ name: "WikiEmbed" }, { name: "WikiEmbedMark" }],
|
|
354
|
+
parseInline: [{
|
|
355
|
+
name: "WikiEmbed",
|
|
356
|
+
before: "Link",
|
|
357
|
+
parse(cx, next, pos) {
|
|
358
|
+
if (next !== 33 || cx.char(pos + 1) !== 91 || cx.char(pos + 2) !== 91) return -1;
|
|
359
|
+
let hasContent = false;
|
|
360
|
+
for (let index = pos + 3; index < cx.end - 1; index++) {
|
|
361
|
+
const code = cx.char(index);
|
|
362
|
+
if (code === 93) {
|
|
363
|
+
if (!hasContent || cx.char(index + 1) !== 93) return -1;
|
|
364
|
+
const end = index + 2;
|
|
365
|
+
return cx.addElement(cx.elt("WikiEmbed", pos, end, [cx.elt("WikiEmbedMark", pos, pos + 3), cx.elt("WikiEmbedMark", index, end)]));
|
|
366
|
+
}
|
|
367
|
+
if (code === 91 || code === 10) return -1;
|
|
368
|
+
if (code !== 32 && code !== 9) hasContent = true;
|
|
369
|
+
}
|
|
370
|
+
return -1;
|
|
371
|
+
}
|
|
372
|
+
}]
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
//#endregion
|
|
376
|
+
//#region src/wikilink.ts
|
|
377
|
+
/**
|
|
378
|
+
* Inline parser for `[[target]]`: any chars except `[`, `]` and
|
|
379
|
+
* newline, at least one of them not a space/tab. The first `]` must
|
|
380
|
+
* pair into `]]`. Registered before `Link` and claims the whole
|
|
381
|
+
* element eagerly, so the target is atomic: no nested markdown, no
|
|
382
|
+
* tags.
|
|
383
|
+
*/
|
|
384
|
+
const wikilink = {
|
|
385
|
+
defineNodes: [{ name: "Wikilink" }, { name: "WikilinkMark" }],
|
|
386
|
+
parseInline: [{
|
|
387
|
+
name: "Wikilink",
|
|
388
|
+
before: "Link",
|
|
389
|
+
parse(cx, next, pos) {
|
|
390
|
+
if (next !== 91 || cx.char(pos + 1) !== 91) return -1;
|
|
391
|
+
let hasContent = false;
|
|
392
|
+
for (let i = pos + 2; i < cx.end - 1; i++) {
|
|
393
|
+
const code = cx.char(i);
|
|
394
|
+
if (code === 93) {
|
|
395
|
+
if (!hasContent || cx.char(i + 1) !== 93) return -1;
|
|
396
|
+
const end = i + 2;
|
|
397
|
+
return cx.addElement(cx.elt("Wikilink", pos, end, [cx.elt("WikilinkMark", pos, pos + 2), cx.elt("WikilinkMark", i, end)]));
|
|
398
|
+
}
|
|
399
|
+
if (code === 91 || code === 10) return -1;
|
|
400
|
+
if (code !== 32 && code !== 9) hasContent = true;
|
|
401
|
+
}
|
|
402
|
+
return -1;
|
|
403
|
+
}
|
|
404
|
+
}]
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
//#endregion
|
|
408
|
+
//#region src/parser.ts
|
|
409
|
+
/**
|
|
410
|
+
* Inline-parser entry that immediately claims the entire inline
|
|
411
|
+
* region. Returning `cx.end` makes `MarkdownParser.parseInline` exit
|
|
412
|
+
* its outer loop on the first iteration, so no other inline parser
|
|
413
|
+
* ever runs on a leaf. Used by `gfmBlockOnlyParser` to skip inline
|
|
414
|
+
* parsing entirely while keeping the block phase intact.
|
|
415
|
+
*/
|
|
416
|
+
function consumeAllInline(cx) {
|
|
417
|
+
return cx.end;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* `@lezer/markdown` parser configured with GFM (table, strikethrough,
|
|
421
|
+
* task list, autolink) plus meowdown's `Hashtag`, `Wikilink`, bare
|
|
422
|
+
* domain autolink, bare `scheme://` autolink, `==Highlight==`, and
|
|
423
|
+
* `$math$` inline syntax. Use when both block and inline structure must
|
|
424
|
+
* be recognized.
|
|
425
|
+
*/
|
|
426
|
+
const gfmParser = parser.configure([
|
|
427
|
+
GFM,
|
|
428
|
+
hashtag,
|
|
429
|
+
wikiEmbed,
|
|
430
|
+
wikilink,
|
|
431
|
+
bareAutolink,
|
|
432
|
+
schemeAutolink,
|
|
433
|
+
highlight,
|
|
434
|
+
math
|
|
435
|
+
]);
|
|
436
|
+
/**
|
|
437
|
+
* `@lezer/markdown` parser configured with GFM plus a `SkipInline`
|
|
438
|
+
* parser that short-circuits the inline phase. The block phase still
|
|
439
|
+
* produces all block-level structural marks (HeaderMark, ListMark,
|
|
440
|
+
* QuoteMark, CodeMark, CodeText, …), but no Emphasis / Link /
|
|
441
|
+
* InlineCode etc. nodes are ever created.
|
|
442
|
+
*/
|
|
443
|
+
const gfmBlockOnlyParser = gfmParser.configure({ parseInline: [{
|
|
444
|
+
name: "SkipInline",
|
|
445
|
+
before: "Escape",
|
|
446
|
+
parse: consumeAllInline
|
|
447
|
+
}] });
|
|
448
|
+
|
|
449
|
+
//#endregion
|
|
450
|
+
//#region src/inline.ts
|
|
451
|
+
/**
|
|
452
|
+
* Run `gfmParser`'s inline phase on a string and return the top-level
|
|
453
|
+
* inline elements. Wraps the cast that's needed because Lezer's
|
|
454
|
+
* `parseInline` is typed as returning `Element[]` (with `children`
|
|
455
|
+
* marked `@internal`).
|
|
456
|
+
*/
|
|
457
|
+
function parseInline(text) {
|
|
458
|
+
return gfmParser.parseInline(text, 0);
|
|
459
|
+
}
|
|
460
|
+
/** Depth-first list of every element matching `test`. */
|
|
461
|
+
function collectInlineElements(nodes, test, out = []) {
|
|
462
|
+
for (const node of nodes) {
|
|
463
|
+
if (test(node)) out.push(node);
|
|
464
|
+
collectInlineElements(node.children, test, out);
|
|
465
|
+
}
|
|
466
|
+
return out;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
//#endregion
|
|
470
|
+
//#region src/node-ids.ts
|
|
471
|
+
function lezerNodeIdsByName(parser) {
|
|
472
|
+
const ids = {};
|
|
473
|
+
for (const t of parser.nodeSet.types) ids[t.name] = t.id;
|
|
474
|
+
return ids;
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Cached node name -> node id lookup for the project-wide `gfmParser`.
|
|
478
|
+
*/
|
|
479
|
+
const LEZER_NODE_IDS = lezerNodeIdsByName(gfmParser);
|
|
480
|
+
|
|
481
|
+
//#endregion
|
|
482
|
+
export { LEZER_NODE_IDS, collectInlineElements, getAutolinkHref, gfmBlockOnlyParser, gfmParser, isSpaceChar, parseInline };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meowdown/markdown",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.64.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"@ocavue/tsconfig": "^0.7.1",
|
|
25
25
|
"dedent": "^1.7.2",
|
|
26
|
-
"tsdown": "^0.
|
|
26
|
+
"tsdown": "^0.23.0-beta.2",
|
|
27
27
|
"vitest": "^4.1.10",
|
|
28
28
|
"@meowdown/vitest": "0.0.0"
|
|
29
29
|
},
|