@meowdown/markdown 0.63.1 → 0.64.1

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