@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.
Files changed (3) hide show
  1. package/README.md +8 -1
  2. package/dist/index.js +482 -1
  3. 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 as e,parser as t}from"@lezer/markdown";const n=new Set([`com`,`br`,`net`,`jp`,`org`,`in`,`de`,`ru`,`it`,`fr`]),r=/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i;function i(e){let t=e.indexOf(`/`);return t===-1?e:e.slice(0,t)}function a(e){let t=e.split(`.`);if(t.length<2)return!1;let i=t[t.length-1].toLowerCase();if(!n.has(i)||t[t.length-2].length<3)return!1;for(let e of t)if(e.length>63||!r.test(e))return!1;return!0}function o(e){if(/^[a-z][a-z0-9+.-]*:/i.test(e))return e;if(/^[^\s@]+@[^\s@]+$/.test(e))return`mailto:${e}`;if(/^www\./i.test(e)||a(i(e)))return`https://${e}`}function s(e){return e===32||e===9||e===10||e===13}const c=/^[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:\/[^\s<]*)?/i,l=/[\s(*_~]/;function u(e){return e>=48&&e<=57||e>=65&&e<=90||e>=97&&e<=122||e===45}function d(e,t,n){let r=0;for(let i=0;i<t;i++)e[i]===n&&r++;return r}function f(e){let t=e.length;for(;;){let n=e[t-1];if(/[?!.,:*_~]/.test(n)||n===`)`&&d(e,t,`)`)>d(e,t,`(`))t--;else if(n===`;`){let n=/&(?:#\d+|#x[a-f\d]+|\w+);$/.exec(e.slice(0,t));if(!n)break;t=n.index}else break}return t}const p={parseInline:[{name:`BareAutolink`,before:`Link`,parse(e,t,n){if(!u(t)||e.hasOpenLink)return-1;let r=e.slice(n-1,n);if(r!==``&&!l.test(r))return-1;let o=c.exec(e.slice(n,e.end));if(!o)return-1;let s=f(o[0]);return s===0||!a(i(o[0].slice(0,s)))?-1:e.addElement(e.elt(`URL`,n,n+s))}}]};function m(e){return e>=48&&e<=57||e>=65&&e<=90||e>=97&&e<=122||e===45||e===95||e>127&&/[\p{L}\p{N}]/u.test(String.fromCharCode(e))}function h(e){return e>=65&&e<=90||e>=97&&e<=122||e>127&&/\p{L}/u.test(String.fromCharCode(e))}const g={defineNodes:[{name:`Hashtag`}],parseInline:[{name:`Hashtag`,parse(e,t,n){if(t!==35||!/\s|^$/.test(e.slice(n-1,n)))return-1;let r=n+1,i=!1;for(;r<e.end;){let t=e.char(r);if(!m(t))break;i||=h(t),r++}return i?e.addElement(e.elt(`Hashtag`,n,r)):-1}}]},_={resolve:`Highlight`,mark:`HighlightMark`},v=/[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~\u{A1}\u{2010}-\u{2027}]/u,y={defineNodes:[{name:`Highlight`},{name:`HighlightMark`}],parseInline:[{name:`Highlight`,after:`Emphasis`,parse(e,t,n){if(t!==61||e.char(n+1)!==61||e.char(n+2)===61)return-1;let r=e.slice(n-1,n),i=e.slice(n+2,n+3),a=/\s|^$/.test(r),o=/\s|^$/.test(i),s=v.test(r),c=v.test(i);return e.addDelimiter(_,n,n+2,!o&&(!c||a||s),!a&&(!s||o||c))}}]};function b(e){return e>=48&&e<=57}function x(e){return e.next!==36||e.text.charCodeAt(e.pos+1)!==36||e.text.charCodeAt(e.pos+2)===36?!1:e.skipSpace(e.pos+2)===e.text.length}function S(e){let t=e.depth;return typeof t==`number`?t:2**53-1}const C={defineNodes:[{name:`InlineMath`},{name:`InlineMathMark`},{name:`BlockMath`,block:!0},{name:`BlockMathMark`}],parseBlock:[{name:`BlockMath`,before:`FencedCode`,parse(e,t){if(!x(t))return!1;let n=e.lineStart+t.pos,r=[e.elt(`BlockMathMark`,n,n+2)];for(let n=!0,i=!0,a=!1;!(!e.nextLine()||S(t)<e.depth);n=!1){if(x(t)){i&&a&&r.push(e.elt(`CodeText`,e.lineStart-1,e.lineStart)),r.push(e.elt(`BlockMathMark`,e.lineStart+t.pos,e.lineStart+t.pos+2)),e.nextLine();break}a=!0,n||(r.push(e.elt(`CodeText`,e.lineStart-1,e.lineStart)),i=!1);let o=e.lineStart+t.basePos,s=e.lineStart+t.text.length;o<s&&(r.push(e.elt(`CodeText`,o,s)),i=!1)}return e.addElement(e.elt(`BlockMath`,n,e.prevLineEnd(),r)),!0},endLeaf(e,t){return x(t)}}],parseInline:[{name:`InlineMath`,after:`InlineCode`,parse(e,t,n){if(t!==36||e.char(n-1)===36)return-1;let r=e.char(n+1)===36?2:1;if(e.char(n+r)===36)return-1;let i=n+r;if(s(e.char(i)))return-1;for(let t=i;t<e.end;t++){let a=e.char(t);if(a===10)return-1;if(a===92){t++;continue}if(a!==36)continue;let o=1;for(;e.char(t+o)===36;)o++;if(o!==r||s(e.char(t-1))||b(e.char(t+o)))return-1;let c=t+o;return e.addElement(e.elt(`InlineMath`,n,c,[e.elt(`InlineMathMark`,n,i),e.elt(`InlineMathMark`,t,c)]))}return-1}}]},w=/^[a-z][a-z0-9+.-]*:\/\/[^\s<]+/i;function T(e){return e>=65&&e<=90||e>=97&&e<=122}const E={parseInline:[{name:`SchemeAutolink`,after:`Autolink`,parse(e,t,n){if(!T(t)||e.hasOpenLink)return-1;let r=e.slice(n-1,n);if(r!==``&&!l.test(r))return-1;let i=w.exec(e.slice(n,e.end));if(!i)return-1;let a=f(i[0]);return a<=i[0].indexOf(`://`)+3?-1:e.addElement(e.elt(`URL`,n,n+a))}}]},D={defineNodes:[{name:`WikiEmbed`},{name:`WikiEmbedMark`}],parseInline:[{name:`WikiEmbed`,before:`Link`,parse(e,t,n){if(t!==33||e.char(n+1)!==91||e.char(n+2)!==91)return-1;let r=!1;for(let t=n+3;t<e.end-1;t++){let i=e.char(t);if(i===93){if(!r||e.char(t+1)!==93)return-1;let i=t+2;return e.addElement(e.elt(`WikiEmbed`,n,i,[e.elt(`WikiEmbedMark`,n,n+3),e.elt(`WikiEmbedMark`,t,i)]))}if(i===91||i===10)return-1;i!==32&&i!==9&&(r=!0)}return-1}}]},O={defineNodes:[{name:`Wikilink`},{name:`WikilinkMark`}],parseInline:[{name:`Wikilink`,before:`Link`,parse(e,t,n){if(t!==91||e.char(n+1)!==91)return-1;let r=!1;for(let t=n+2;t<e.end-1;t++){let i=e.char(t);if(i===93){if(!r||e.char(t+1)!==93)return-1;let i=t+2;return e.addElement(e.elt(`Wikilink`,n,i,[e.elt(`WikilinkMark`,n,n+2),e.elt(`WikilinkMark`,t,i)]))}if(i===91||i===10)return-1;i!==32&&i!==9&&(r=!0)}return-1}}]};function k(e){return e.end}const A=t.configure([e,g,D,O,p,E,y,C]),j=A.configure({parseInline:[{name:`SkipInline`,before:`Escape`,parse:k}]});function M(e){return A.parseInline(e,0)}function N(e,t,n=[]){for(let r of e)t(r)&&n.push(r),N(r.children,t,n);return n}function P(e){let t={};for(let n of e.nodeSet.types)t[n.name]=n.id;return t}const F=P(A);export{F as LEZER_NODE_IDS,N as collectInlineElements,o as getAutolinkHref,j as gfmBlockOnlyParser,A as gfmParser,s as isSpaceChar,M as parseInline};
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.63.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.22.14",
26
+ "tsdown": "^0.23.0-beta.2",
27
27
  "vitest": "^4.1.10",
28
28
  "@meowdown/vitest": "0.0.0"
29
29
  },