@dogsbay/markdown-it-mdx-jsx 0.2.0-beta.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.d.ts +42 -0
- package/dist/index.js +541 -0
- package/package.json +36 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* markdown-it-mdx-jsx — MDX/JSX component support for markdown-it.
|
|
3
|
+
*
|
|
4
|
+
* Dual-context parser following the micromark-extension-mdx-jsx architecture:
|
|
5
|
+
* - Block rule (flow): handles JSX tags at the start of lines, loops to process
|
|
6
|
+
* multiple tags per line, recursively tokenizes inner content
|
|
7
|
+
* - Inline rule (text): handles JSX tags within prose (self-closing, open, close)
|
|
8
|
+
*
|
|
9
|
+
* Components are identified by uppercase first letter (React convention).
|
|
10
|
+
* Emits `jsx_open`, `jsx_close`, `jsx_self_closing` tokens at block level,
|
|
11
|
+
* and `jsx_inline_open`, `jsx_inline_close`, `jsx_inline_self_closing` at inline level.
|
|
12
|
+
*/
|
|
13
|
+
import type MarkdownIt from "markdown-it";
|
|
14
|
+
export interface MdxJsxOptions {
|
|
15
|
+
/** Recognized component names. If omitted, any uppercase tag is recognized. */
|
|
16
|
+
components?: string[];
|
|
17
|
+
/** Tags that are always self-closing even without `/>`. */
|
|
18
|
+
selfClosing?: string[];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Parse JSX-style attributes from a string.
|
|
22
|
+
* Handles: key="value", key='value', key={expr}, key={{obj}}, bare key.
|
|
23
|
+
* Uses brace-depth tracking for JSX expressions.
|
|
24
|
+
*/
|
|
25
|
+
export declare function parseJsxAttrs(str: string): Record<string, string>;
|
|
26
|
+
/**
|
|
27
|
+
* Normalize JSX source for line-based parsing.
|
|
28
|
+
*
|
|
29
|
+
* markdown-it processes blocks line-by-line. JSX patterns with multiple
|
|
30
|
+
* tags on one line (common in Cloudflare/Starlight source) need to be
|
|
31
|
+
* split so each tag starts on its own line.
|
|
32
|
+
*
|
|
33
|
+
* Patterns handled:
|
|
34
|
+
* - `</TabItem> <TabItem>` → two lines
|
|
35
|
+
* - `<Tabs> <TabItem>` → two lines
|
|
36
|
+
* - `</TabItem> </Tabs>` → two lines
|
|
37
|
+
*
|
|
38
|
+
* Exported so consumers can call it before md.parse() if needed.
|
|
39
|
+
*/
|
|
40
|
+
export declare function normalizeJsxLines(source: string): string;
|
|
41
|
+
export default function mdxJsxPlugin(md: MarkdownIt, options?: MdxJsxOptions): void;
|
|
42
|
+
export { mdxJsxPlugin };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* markdown-it-mdx-jsx — MDX/JSX component support for markdown-it.
|
|
3
|
+
*
|
|
4
|
+
* Dual-context parser following the micromark-extension-mdx-jsx architecture:
|
|
5
|
+
* - Block rule (flow): handles JSX tags at the start of lines, loops to process
|
|
6
|
+
* multiple tags per line, recursively tokenizes inner content
|
|
7
|
+
* - Inline rule (text): handles JSX tags within prose (self-closing, open, close)
|
|
8
|
+
*
|
|
9
|
+
* Components are identified by uppercase first letter (React convention).
|
|
10
|
+
* Emits `jsx_open`, `jsx_close`, `jsx_self_closing` tokens at block level,
|
|
11
|
+
* and `jsx_inline_open`, `jsx_inline_close`, `jsx_inline_self_closing` at inline level.
|
|
12
|
+
*/
|
|
13
|
+
// ── Attribute parsing ──────────────────────────────────────
|
|
14
|
+
/**
|
|
15
|
+
* Parse JSX-style attributes from a string.
|
|
16
|
+
* Handles: key="value", key='value', key={expr}, key={{obj}}, bare key.
|
|
17
|
+
* Uses brace-depth tracking for JSX expressions.
|
|
18
|
+
*/
|
|
19
|
+
export function parseJsxAttrs(str) {
|
|
20
|
+
const attrs = {};
|
|
21
|
+
let i = 0;
|
|
22
|
+
const len = str.length;
|
|
23
|
+
while (i < len) {
|
|
24
|
+
while (i < len && /\s/.test(str[i]))
|
|
25
|
+
i++;
|
|
26
|
+
if (i >= len)
|
|
27
|
+
break;
|
|
28
|
+
const keyStart = i;
|
|
29
|
+
while (i < len && /[\w\-.:]/.test(str[i]))
|
|
30
|
+
i++;
|
|
31
|
+
const key = str.slice(keyStart, i);
|
|
32
|
+
if (!key) {
|
|
33
|
+
i++;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
while (i < len && /\s/.test(str[i]))
|
|
37
|
+
i++;
|
|
38
|
+
if (i >= len || str[i] !== "=") {
|
|
39
|
+
attrs[key] = "true";
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
i++; // skip =
|
|
43
|
+
while (i < len && /\s/.test(str[i]))
|
|
44
|
+
i++;
|
|
45
|
+
if (i >= len) {
|
|
46
|
+
attrs[key] = "";
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const ch = str[i];
|
|
50
|
+
if (ch === '"' || ch === "'") {
|
|
51
|
+
const quote = ch;
|
|
52
|
+
i++;
|
|
53
|
+
const valStart = i;
|
|
54
|
+
while (i < len && str[i] !== quote) {
|
|
55
|
+
if (str[i] === "\\")
|
|
56
|
+
i++;
|
|
57
|
+
i++;
|
|
58
|
+
}
|
|
59
|
+
attrs[key] = str.slice(valStart, i);
|
|
60
|
+
if (i < len)
|
|
61
|
+
i++;
|
|
62
|
+
}
|
|
63
|
+
else if (ch === "{") {
|
|
64
|
+
const valStart = i;
|
|
65
|
+
let depth = 0;
|
|
66
|
+
while (i < len) {
|
|
67
|
+
if (str[i] === "{")
|
|
68
|
+
depth++;
|
|
69
|
+
else if (str[i] === "}") {
|
|
70
|
+
depth--;
|
|
71
|
+
if (depth === 0) {
|
|
72
|
+
i++;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else if (str[i] === '"' || str[i] === "'") {
|
|
77
|
+
const q = str[i];
|
|
78
|
+
i++;
|
|
79
|
+
while (i < len && str[i] !== q) {
|
|
80
|
+
if (str[i] === "\\")
|
|
81
|
+
i++;
|
|
82
|
+
i++;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
i++;
|
|
86
|
+
}
|
|
87
|
+
attrs[key] = str.slice(valStart, i);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
const valStart = i;
|
|
91
|
+
while (i < len && !/\s/.test(str[i]) && str[i] !== ">" && str[i] !== "/")
|
|
92
|
+
i++;
|
|
93
|
+
attrs[key] = str.slice(valStart, i);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return attrs;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Parse a single JSX tag from a string starting at position `pos`.
|
|
100
|
+
* Returns null if no valid JSX tag found.
|
|
101
|
+
* Handles multi-line by accepting the full remaining source.
|
|
102
|
+
*/
|
|
103
|
+
function parseTag(src, pos) {
|
|
104
|
+
if (src.charCodeAt(pos) !== 0x3c /* < */)
|
|
105
|
+
return null;
|
|
106
|
+
let i = pos + 1;
|
|
107
|
+
const len = src.length;
|
|
108
|
+
// Skip whitespace after <
|
|
109
|
+
while (i < len && /\s/.test(src[i]))
|
|
110
|
+
i++;
|
|
111
|
+
// Check for closing tag: </Tag>
|
|
112
|
+
const isClose = src[i] === "/";
|
|
113
|
+
if (isClose)
|
|
114
|
+
i++;
|
|
115
|
+
// Must start with uppercase letter (JSX convention)
|
|
116
|
+
if (i >= len || !/[A-Z]/.test(src[i]))
|
|
117
|
+
return null;
|
|
118
|
+
// Read tag name
|
|
119
|
+
const nameStart = i;
|
|
120
|
+
while (i < len && /[\w.]/.test(src[i]))
|
|
121
|
+
i++;
|
|
122
|
+
const tag = src.slice(nameStart, i);
|
|
123
|
+
if (isClose) {
|
|
124
|
+
// Closing tag: skip whitespace, expect >
|
|
125
|
+
while (i < len && /\s/.test(src[i]))
|
|
126
|
+
i++;
|
|
127
|
+
if (i < len && src[i] === ">") {
|
|
128
|
+
return { tag, attrs: {}, selfClosing: false, isClose: true, length: i - pos + 1 };
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
// Opening tag — parse attributes until > or />
|
|
133
|
+
// Track brace depth for JSX expression attributes
|
|
134
|
+
let braceDepth = 0;
|
|
135
|
+
let inQuote = null;
|
|
136
|
+
const attrStart = i;
|
|
137
|
+
while (i < len) {
|
|
138
|
+
const ch = src[i];
|
|
139
|
+
if (inQuote) {
|
|
140
|
+
if (ch === "\\") {
|
|
141
|
+
i += 2;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (ch === inQuote)
|
|
145
|
+
inQuote = null;
|
|
146
|
+
i++;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (ch === '"' || ch === "'") {
|
|
150
|
+
inQuote = ch;
|
|
151
|
+
i++;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (ch === "{") {
|
|
155
|
+
braceDepth++;
|
|
156
|
+
i++;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (ch === "}") {
|
|
160
|
+
braceDepth--;
|
|
161
|
+
i++;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
// Only check for tag end when not inside braces or quotes
|
|
165
|
+
if (braceDepth === 0) {
|
|
166
|
+
if (ch === "/" && i + 1 < len && src[i + 1] === ">") {
|
|
167
|
+
const attrsStr = src.slice(attrStart, i).trim();
|
|
168
|
+
return {
|
|
169
|
+
tag,
|
|
170
|
+
attrs: parseJsxAttrs(attrsStr),
|
|
171
|
+
selfClosing: true,
|
|
172
|
+
isClose: false,
|
|
173
|
+
length: i - pos + 2,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
if (ch === ">") {
|
|
177
|
+
const attrsStr = src.slice(attrStart, i).trim();
|
|
178
|
+
return {
|
|
179
|
+
tag,
|
|
180
|
+
attrs: parseJsxAttrs(attrsStr),
|
|
181
|
+
selfClosing: false,
|
|
182
|
+
isClose: false,
|
|
183
|
+
length: i - pos + 1,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
i++;
|
|
188
|
+
}
|
|
189
|
+
return null; // Never found closing >
|
|
190
|
+
}
|
|
191
|
+
// ── Block rule helpers ─────────────────────────────────────
|
|
192
|
+
function getLine(state, line) {
|
|
193
|
+
const pos = state.bMarks[line] + state.tShift[line];
|
|
194
|
+
const max = state.eMarks[line];
|
|
195
|
+
return state.src.slice(pos, max);
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Find the matching closing tag, tracking nesting depth.
|
|
199
|
+
* Skips fenced code blocks.
|
|
200
|
+
*/
|
|
201
|
+
function findCloseTag(state, startLine, endLine, tag) {
|
|
202
|
+
let depth = 1;
|
|
203
|
+
let inFence = false;
|
|
204
|
+
let fenceChar = "";
|
|
205
|
+
let fenceLen = 0;
|
|
206
|
+
for (let line = startLine; line < endLine; line++) {
|
|
207
|
+
const text = getLine(state, line);
|
|
208
|
+
const trimmed = text.trim();
|
|
209
|
+
if (!inFence) {
|
|
210
|
+
const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
|
|
211
|
+
if (fenceMatch) {
|
|
212
|
+
inFence = true;
|
|
213
|
+
fenceChar = fenceMatch[1][0];
|
|
214
|
+
fenceLen = fenceMatch[1].length;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
const re = new RegExp(`^${fenceChar === "~" ? "~" : "\`"}{${fenceLen},}\\s*$`);
|
|
220
|
+
if (re.test(trimmed))
|
|
221
|
+
inFence = false;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
// Scan the line for opening/closing tags of the same name
|
|
225
|
+
// This handles multiple tags per line (e.g., </TabItem> <TabItem>)
|
|
226
|
+
let pos = 0;
|
|
227
|
+
while (pos < trimmed.length) {
|
|
228
|
+
const idx = trimmed.indexOf("<", pos);
|
|
229
|
+
if (idx === -1)
|
|
230
|
+
break;
|
|
231
|
+
const parsed = parseTag(trimmed, idx);
|
|
232
|
+
if (parsed && parsed.tag === tag) {
|
|
233
|
+
if (parsed.isClose) {
|
|
234
|
+
depth--;
|
|
235
|
+
if (depth === 0)
|
|
236
|
+
return line;
|
|
237
|
+
}
|
|
238
|
+
else if (!parsed.selfClosing) {
|
|
239
|
+
depth++;
|
|
240
|
+
}
|
|
241
|
+
pos = idx + parsed.length;
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
pos = idx + 1;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return endLine;
|
|
249
|
+
}
|
|
250
|
+
// ── Block-level tag parsing (handles multi-line tags) ──────
|
|
251
|
+
/**
|
|
252
|
+
* Parse a JSX tag from block state, starting at `startLine`.
|
|
253
|
+
* Concatenates lines as needed for multi-line tags.
|
|
254
|
+
* Returns null if no valid tag found.
|
|
255
|
+
*/
|
|
256
|
+
function parseBlockTag(state, startLine, endLine, offset = 0) {
|
|
257
|
+
const linePos = state.bMarks[startLine] + state.tShift[startLine];
|
|
258
|
+
const lineEnd = state.eMarks[startLine];
|
|
259
|
+
const firstLineText = state.src.slice(linePos, lineEnd);
|
|
260
|
+
// Try parsing on the current line first
|
|
261
|
+
const single = parseTag(firstLineText, offset);
|
|
262
|
+
if (single) {
|
|
263
|
+
return { ...single, endLine: startLine };
|
|
264
|
+
}
|
|
265
|
+
// Check if this looks like the start of a multi-line tag
|
|
266
|
+
const remaining = firstLineText.slice(offset);
|
|
267
|
+
const tagStart = remaining.match(/^<\/?([A-Z]\w*)/);
|
|
268
|
+
if (!tagStart)
|
|
269
|
+
return null;
|
|
270
|
+
// Concatenate lines until we find > or />
|
|
271
|
+
let combined = remaining;
|
|
272
|
+
let currentLine = startLine + 1;
|
|
273
|
+
while (currentLine < endLine) {
|
|
274
|
+
const nextLine = getLine(state, currentLine);
|
|
275
|
+
combined += "\n" + nextLine;
|
|
276
|
+
const parsed = parseTag(combined, 0);
|
|
277
|
+
if (parsed) {
|
|
278
|
+
return { ...parsed, endLine: currentLine };
|
|
279
|
+
}
|
|
280
|
+
currentLine++;
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
// ── Block rule ─────────────────────────────────────────────
|
|
285
|
+
function createJsxBlockRule(options) {
|
|
286
|
+
const componentSet = options.components ? new Set(options.components) : null;
|
|
287
|
+
const selfClosingSet = new Set(options.selfClosing || []);
|
|
288
|
+
return function jsxBlock(state, startLine, endLine, silent) {
|
|
289
|
+
const pos = state.bMarks[startLine] + state.tShift[startLine];
|
|
290
|
+
if (state.src.charCodeAt(pos) !== 0x3c /* < */)
|
|
291
|
+
return false;
|
|
292
|
+
// Note: we intentionally do NOT check for 4-space code block indent.
|
|
293
|
+
// Standard markdown treats 4+ indent as code, but JSX components
|
|
294
|
+
// should be recognized at any indent level — they're explicit tags,
|
|
295
|
+
// not accidental indentation. This is critical for components nested
|
|
296
|
+
// inside other components where inner content is heavily indented.
|
|
297
|
+
// Try to parse a tag (may span multiple lines)
|
|
298
|
+
const parsed = parseBlockTag(state, startLine, endLine);
|
|
299
|
+
if (!parsed)
|
|
300
|
+
return false;
|
|
301
|
+
if (componentSet && !componentSet.has(parsed.tag))
|
|
302
|
+
return false;
|
|
303
|
+
if (silent)
|
|
304
|
+
return true;
|
|
305
|
+
const lineEnd = state.eMarks[startLine];
|
|
306
|
+
const lineText = state.src.slice(pos, lineEnd);
|
|
307
|
+
// Process this tag and any subsequent tags on the same line
|
|
308
|
+
return processFlowTags(state, startLine, endLine, pos, lineText, componentSet, selfClosingSet);
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Process JSX tag(s) starting at `startLine`.
|
|
313
|
+
*
|
|
314
|
+
* Handles:
|
|
315
|
+
* - Single tags on a line
|
|
316
|
+
* - Multi-line tags (e.g., <Render\n file="..."\n/>)
|
|
317
|
+
* - Multiple tags per line (e.g., <Tabs> <TabItem>, </TabItem> <TabItem>)
|
|
318
|
+
* - Container tags with inner content on the same line as the opening tag
|
|
319
|
+
*
|
|
320
|
+
* Following micromark's flow tag approach: after processing one tag,
|
|
321
|
+
* if there's another `<` on the same line, loop to process it.
|
|
322
|
+
*/
|
|
323
|
+
function processFlowTags(state, startLine, endLine, linePos, lineText, componentSet, selfClosingSet) {
|
|
324
|
+
// Parse the first tag (may span multiple lines)
|
|
325
|
+
const parsed = parseBlockTag(state, startLine, endLine, 0);
|
|
326
|
+
if (!parsed)
|
|
327
|
+
return false;
|
|
328
|
+
if (componentSet && !componentSet.has(parsed.tag))
|
|
329
|
+
return false;
|
|
330
|
+
const isSelfClosing = parsed.selfClosing || selfClosingSet.has(parsed.tag);
|
|
331
|
+
// ── Closing tag ──────────────────────────────────────
|
|
332
|
+
if (parsed.isClose) {
|
|
333
|
+
const token = state.push("jsx_close", parsed.tag, -1);
|
|
334
|
+
token.map = [startLine, startLine + 1];
|
|
335
|
+
state.line = startLine + 1;
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
// ── Self-closing tag ─────────────────────────────────
|
|
339
|
+
if (isSelfClosing) {
|
|
340
|
+
const token = state.push("jsx_self_closing", parsed.tag, 0);
|
|
341
|
+
token.meta = { attrs: parsed.attrs };
|
|
342
|
+
token.map = [startLine, parsed.endLine + 1];
|
|
343
|
+
state.line = parsed.endLine + 1;
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
// ── Container tag ────────────────────────────────────
|
|
347
|
+
// Check for single-line container: <Tag>content</Tag> on one line
|
|
348
|
+
const closeOnSameLine = lineText.indexOf(`</${parsed.tag}>`, parsed.length);
|
|
349
|
+
if (closeOnSameLine > 0) {
|
|
350
|
+
const innerText = lineText.slice(parsed.length, closeOnSameLine).trim();
|
|
351
|
+
const openToken = state.push("jsx_open", parsed.tag, 1);
|
|
352
|
+
openToken.meta = { attrs: parsed.attrs };
|
|
353
|
+
openToken.map = [startLine, startLine + 1];
|
|
354
|
+
if (innerText) {
|
|
355
|
+
// Emit inline content as a paragraph
|
|
356
|
+
const pOpen = state.push("paragraph_open", "p", 1);
|
|
357
|
+
pOpen.map = [startLine, startLine + 1];
|
|
358
|
+
const inline = state.push("inline", "", 0);
|
|
359
|
+
inline.content = innerText;
|
|
360
|
+
inline.map = [startLine, startLine + 1];
|
|
361
|
+
inline.children = [];
|
|
362
|
+
state.push("paragraph_close", "p", -1);
|
|
363
|
+
}
|
|
364
|
+
const closeToken = state.push("jsx_close", parsed.tag, -1);
|
|
365
|
+
closeToken.markup = `</${parsed.tag}>`;
|
|
366
|
+
state.line = startLine + 1;
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
// Multi-line container — inner content starts on the next line
|
|
370
|
+
const innerStart = parsed.endLine + 1;
|
|
371
|
+
const closeLine = findCloseTag(state, innerStart, endLine, parsed.tag);
|
|
372
|
+
// Emit open token
|
|
373
|
+
const openToken = state.push("jsx_open", parsed.tag, 1);
|
|
374
|
+
openToken.meta = { attrs: parsed.attrs };
|
|
375
|
+
openToken.map = [startLine, closeLine + 1];
|
|
376
|
+
openToken.markup = `<${parsed.tag}>`;
|
|
377
|
+
// Adjust blkIndent for indented inner content
|
|
378
|
+
const oldParentType = state.parentType;
|
|
379
|
+
const oldLineMax = state.lineMax;
|
|
380
|
+
const oldBlkIndent = state.blkIndent;
|
|
381
|
+
let minIndent = Infinity;
|
|
382
|
+
for (let ln = innerStart; ln < closeLine; ln++) {
|
|
383
|
+
const lineLen = state.eMarks[ln] - state.bMarks[ln];
|
|
384
|
+
if (lineLen === 0)
|
|
385
|
+
continue;
|
|
386
|
+
const indent = state.sCount[ln];
|
|
387
|
+
if (indent < minIndent)
|
|
388
|
+
minIndent = indent;
|
|
389
|
+
}
|
|
390
|
+
if (minIndent === Infinity)
|
|
391
|
+
minIndent = 0;
|
|
392
|
+
state.parentType = "jsx";
|
|
393
|
+
state.lineMax = closeLine;
|
|
394
|
+
if (minIndent > oldBlkIndent)
|
|
395
|
+
state.blkIndent = minIndent;
|
|
396
|
+
// Tokenize inner content
|
|
397
|
+
state.md.block.tokenize(state, innerStart, closeLine);
|
|
398
|
+
// Restore state
|
|
399
|
+
state.parentType = oldParentType;
|
|
400
|
+
state.lineMax = oldLineMax;
|
|
401
|
+
state.blkIndent = oldBlkIndent;
|
|
402
|
+
// Emit close token
|
|
403
|
+
const closeToken = state.push("jsx_close", parsed.tag, -1);
|
|
404
|
+
closeToken.markup = `</${parsed.tag}>`;
|
|
405
|
+
state.line = closeLine < endLine ? closeLine + 1 : endLine;
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
// ── Inline rule ────────────────────────────────────────────
|
|
409
|
+
function createJsxInlineRule(options) {
|
|
410
|
+
const componentSet = options.components ? new Set(options.components) : null;
|
|
411
|
+
const selfClosingSet = new Set(options.selfClosing || []);
|
|
412
|
+
return function jsxInline(state, silent) {
|
|
413
|
+
const pos = state.pos;
|
|
414
|
+
if (state.src.charCodeAt(pos) !== 0x3c /* < */)
|
|
415
|
+
return false;
|
|
416
|
+
const parsed = parseTag(state.src, pos);
|
|
417
|
+
if (!parsed)
|
|
418
|
+
return false;
|
|
419
|
+
if (componentSet && !componentSet.has(parsed.tag))
|
|
420
|
+
return false;
|
|
421
|
+
if (silent)
|
|
422
|
+
return true;
|
|
423
|
+
const isSelfClosing = parsed.selfClosing || selfClosingSet.has(parsed.tag);
|
|
424
|
+
if (parsed.isClose) {
|
|
425
|
+
const token = state.push("jsx_inline_close", parsed.tag, -1);
|
|
426
|
+
token.markup = `</${parsed.tag}>`;
|
|
427
|
+
}
|
|
428
|
+
else if (isSelfClosing) {
|
|
429
|
+
const token = state.push("jsx_inline_self_closing", parsed.tag, 0);
|
|
430
|
+
token.meta = { attrs: parsed.attrs };
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
const token = state.push("jsx_inline_open", parsed.tag, 1);
|
|
434
|
+
token.meta = { attrs: parsed.attrs };
|
|
435
|
+
token.markup = `<${parsed.tag}>`;
|
|
436
|
+
}
|
|
437
|
+
state.pos += parsed.length;
|
|
438
|
+
return true;
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
// ── JSX comment stripping (block rule) ─────────────────────
|
|
442
|
+
function createJsxCommentRule() {
|
|
443
|
+
return function jsxComment(state, startLine, endLine, silent) {
|
|
444
|
+
const pos = state.bMarks[startLine] + state.tShift[startLine];
|
|
445
|
+
const max = state.eMarks[startLine];
|
|
446
|
+
const text = state.src.slice(pos, max).trim();
|
|
447
|
+
if (text.match(/^\{\/\*.*\*\/\}\s*$/)) {
|
|
448
|
+
if (silent)
|
|
449
|
+
return true;
|
|
450
|
+
state.line = startLine + 1;
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
if (text.match(/^\{\/\*/)) {
|
|
454
|
+
if (silent)
|
|
455
|
+
return true;
|
|
456
|
+
let line = startLine;
|
|
457
|
+
while (line < endLine) {
|
|
458
|
+
const lineText = getLine(state, line).trim();
|
|
459
|
+
if (lineText.match(/\*\/\}\s*$/)) {
|
|
460
|
+
state.line = line + 1;
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
line++;
|
|
464
|
+
}
|
|
465
|
+
state.line = endLine;
|
|
466
|
+
return true;
|
|
467
|
+
}
|
|
468
|
+
return false;
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
// ── Import stripping (block rule) ──────────────────────────
|
|
472
|
+
function createImportStripRule() {
|
|
473
|
+
return function importStrip(state, startLine, endLine, silent) {
|
|
474
|
+
const pos = state.bMarks[startLine] + state.tShift[startLine];
|
|
475
|
+
const max = state.eMarks[startLine];
|
|
476
|
+
const text = state.src.slice(pos, max);
|
|
477
|
+
if (text.match(/^import\s+.*from\s+["'][^"']*["']\s*;?\s*$/)) {
|
|
478
|
+
if (silent)
|
|
479
|
+
return true;
|
|
480
|
+
state.line = startLine + 1;
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
if (text.match(/^import\s*\{/)) {
|
|
484
|
+
if (silent)
|
|
485
|
+
return true;
|
|
486
|
+
let line = startLine;
|
|
487
|
+
while (line < endLine) {
|
|
488
|
+
const lineText = getLine(state, line);
|
|
489
|
+
if (lineText.match(/from\s+["'][^"']*["']\s*;?\s*$/)) {
|
|
490
|
+
state.line = line + 1;
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
line++;
|
|
494
|
+
}
|
|
495
|
+
state.line = startLine + 1;
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
return false;
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
// ── Source normalization ────────────────────────────────────
|
|
502
|
+
/**
|
|
503
|
+
* Normalize JSX source for line-based parsing.
|
|
504
|
+
*
|
|
505
|
+
* markdown-it processes blocks line-by-line. JSX patterns with multiple
|
|
506
|
+
* tags on one line (common in Cloudflare/Starlight source) need to be
|
|
507
|
+
* split so each tag starts on its own line.
|
|
508
|
+
*
|
|
509
|
+
* Patterns handled:
|
|
510
|
+
* - `</TabItem> <TabItem>` → two lines
|
|
511
|
+
* - `<Tabs> <TabItem>` → two lines
|
|
512
|
+
* - `</TabItem> </Tabs>` → two lines
|
|
513
|
+
*
|
|
514
|
+
* Exported so consumers can call it before md.parse() if needed.
|
|
515
|
+
*/
|
|
516
|
+
export function normalizeJsxLines(source) {
|
|
517
|
+
// Split close tag + open/close tag: </X> <Y or </X> </Y
|
|
518
|
+
let result = source.replace(/(<\/[A-Z]\w*\s*>)([ \t]+)(<\/?[A-Z])/g, "$1\n$3");
|
|
519
|
+
// Split open tag end + next tag: ...> <Y (for <Tabs> <TabItem> pattern)
|
|
520
|
+
result = result.replace(/(>)([ \t]+)(<\/?[A-Z]\w*[\s>])/g, "$1\n$3");
|
|
521
|
+
return result;
|
|
522
|
+
}
|
|
523
|
+
// ── Plugin entry point ─────────────────────────────────────
|
|
524
|
+
export default function mdxJsxPlugin(md, options = {}) {
|
|
525
|
+
// Block rules — before code rule to catch JSX tags before they're
|
|
526
|
+
// treated as indented code blocks (4+ spaces). This is critical for
|
|
527
|
+
// components nested inside other components where inner content is
|
|
528
|
+
// heavily indented (e.g. <Steps> inside <TabItem> inside <Tabs>).
|
|
529
|
+
md.block.ruler.before("code", "jsx_import", createImportStripRule(), {
|
|
530
|
+
alt: ["paragraph", "reference", "blockquote", "list"],
|
|
531
|
+
});
|
|
532
|
+
md.block.ruler.before("code", "jsx_comment", createJsxCommentRule(), {
|
|
533
|
+
alt: ["paragraph", "reference", "blockquote", "list"],
|
|
534
|
+
});
|
|
535
|
+
md.block.ruler.before("code", "jsx_block", createJsxBlockRule(options), {
|
|
536
|
+
alt: ["paragraph", "reference", "blockquote", "list"],
|
|
537
|
+
});
|
|
538
|
+
// Inline rule — before html_inline to catch JSX tags in prose
|
|
539
|
+
md.inline.ruler.before("html_inline", "jsx_inline", createJsxInlineRule(options));
|
|
540
|
+
}
|
|
541
|
+
export { mdxJsxPlugin };
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dogsbay/markdown-it-mdx-jsx",
|
|
3
|
+
"version": "0.2.0-beta.0",
|
|
4
|
+
"description": "markdown-it plugin that tokenizes JSX-style component tags inside Markdown — used by @dogsbay/format-starlight",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"markdown-it": "^14.1.0"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@types/node": "^22.0.0",
|
|
18
|
+
"@types/markdown-it": "^14.1.2",
|
|
19
|
+
"typescript": "^5.8.0",
|
|
20
|
+
"vitest": "^3.2.0"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "https://github.com/dogsbay/dogsbay.git",
|
|
26
|
+
"directory": "packages/markdown-it-mdx-jsx"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/dogsbay/dogsbay/tree/main/packages/markdown-it-mdx-jsx",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/dogsbay/dogsbay/issues"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsc",
|
|
34
|
+
"test": "vitest run"
|
|
35
|
+
}
|
|
36
|
+
}
|