astro-xmdx 0.0.2

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 (48) hide show
  1. package/index.ts +8 -0
  2. package/package.json +80 -0
  3. package/src/constants.ts +52 -0
  4. package/src/index.ts +150 -0
  5. package/src/pipeline/index.ts +38 -0
  6. package/src/pipeline/orchestrator.test.ts +324 -0
  7. package/src/pipeline/orchestrator.ts +121 -0
  8. package/src/pipeline/pipe.test.ts +251 -0
  9. package/src/pipeline/pipe.ts +70 -0
  10. package/src/pipeline/types.ts +59 -0
  11. package/src/plugins.test.ts +274 -0
  12. package/src/presets/index.ts +225 -0
  13. package/src/transforms/blocks-to-jsx.test.ts +590 -0
  14. package/src/transforms/blocks-to-jsx.ts +617 -0
  15. package/src/transforms/expressive-code.test.ts +274 -0
  16. package/src/transforms/expressive-code.ts +147 -0
  17. package/src/transforms/index.test.ts +143 -0
  18. package/src/transforms/index.ts +100 -0
  19. package/src/transforms/inject-components.test.ts +406 -0
  20. package/src/transforms/inject-components.ts +184 -0
  21. package/src/transforms/shiki.test.ts +289 -0
  22. package/src/transforms/shiki.ts +312 -0
  23. package/src/types.ts +92 -0
  24. package/src/utils/config.test.ts +252 -0
  25. package/src/utils/config.ts +146 -0
  26. package/src/utils/frontmatter.ts +33 -0
  27. package/src/utils/imports.test.ts +518 -0
  28. package/src/utils/imports.ts +201 -0
  29. package/src/utils/mdx-detection.test.ts +41 -0
  30. package/src/utils/mdx-detection.ts +209 -0
  31. package/src/utils/paths.test.ts +206 -0
  32. package/src/utils/paths.ts +92 -0
  33. package/src/utils/validation.test.ts +60 -0
  34. package/src/utils/validation.ts +15 -0
  35. package/src/vite-plugin/binding-loader.ts +81 -0
  36. package/src/vite-plugin/directive-rewriter.test.ts +331 -0
  37. package/src/vite-plugin/directive-rewriter.ts +272 -0
  38. package/src/vite-plugin/esbuild-pool.ts +173 -0
  39. package/src/vite-plugin/index.ts +37 -0
  40. package/src/vite-plugin/jsx-module.ts +106 -0
  41. package/src/vite-plugin/mdx-wrapper.ts +328 -0
  42. package/src/vite-plugin/normalize-config.test.ts +78 -0
  43. package/src/vite-plugin/normalize-config.ts +29 -0
  44. package/src/vite-plugin/shiki-highlighter.ts +46 -0
  45. package/src/vite-plugin/shiki-manager.test.ts +175 -0
  46. package/src/vite-plugin/shiki-manager.ts +53 -0
  47. package/src/vite-plugin/types.ts +189 -0
  48. package/src/vite-plugin.ts +1342 -0
@@ -0,0 +1,289 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import {
3
+ highlightHtmlBlocks,
4
+ highlightJsxCodeBlocks,
5
+ rewriteAstroSetHtml,
6
+ } from './shiki.js';
7
+
8
+ // Mock highlight function that simulates Shiki output
9
+ const mockHighlight = async (code: string, lang?: string): Promise<string> => {
10
+ const langAttr = lang ? ` class="language-${lang}"` : '';
11
+ return `<pre${langAttr}><code${langAttr}>${code.toUpperCase()}</code></pre>`;
12
+ };
13
+
14
+ describe('highlightHtmlBlocks', () => {
15
+ test('returns empty string for empty HTML', async () => {
16
+ const result = await highlightHtmlBlocks('', mockHighlight);
17
+ expect(result).toBe('');
18
+ });
19
+
20
+ test('returns HTML unchanged when no code blocks', async () => {
21
+ const html = '<div><p>Hello world</p></div>';
22
+ const result = await highlightHtmlBlocks(html, mockHighlight);
23
+ expect(result).toBe('<div><p>Hello world</p></div>');
24
+ });
25
+
26
+ test('highlights single code block without language', async () => {
27
+ const html = '<pre><code>const x = 1;</code></pre>';
28
+ const result = await highlightHtmlBlocks(html, mockHighlight);
29
+ expect(result).toContain('CONST X = 1;');
30
+ expect(result).toContain('<pre');
31
+ expect(result).toContain('<code');
32
+ });
33
+
34
+ test('highlights single code block with language', async () => {
35
+ const html = '<pre><code class="language-javascript">const x = 1;</code></pre>';
36
+ const result = await highlightHtmlBlocks(html, mockHighlight);
37
+ expect(result).toContain('CONST X = 1;');
38
+ expect(result).toContain('class="language-javascript"');
39
+ });
40
+
41
+ test('highlights multiple code blocks', async () => {
42
+ const html = `
43
+ <pre><code class="language-js">let a = 1;</code></pre>
44
+ <p>Some text</p>
45
+ <pre><code class="language-ts">let b: number = 2;</code></pre>
46
+ `;
47
+ const result = await highlightHtmlBlocks(html, mockHighlight);
48
+ expect(result).toContain('LET A = 1;');
49
+ expect(result).toContain('LET B: NUMBER = 2;');
50
+ expect(result).toContain('Some text');
51
+ });
52
+
53
+ test('handles nested HTML structure', async () => {
54
+ const html = `
55
+ <div>
56
+ <section>
57
+ <pre><code>hello</code></pre>
58
+ </section>
59
+ </div>
60
+ `;
61
+ const result = await highlightHtmlBlocks(html, mockHighlight);
62
+ expect(result).toContain('HELLO');
63
+ expect(result).toContain('<div>');
64
+ expect(result).toContain('<section>');
65
+ });
66
+
67
+ test('skips pre tags without code children', async () => {
68
+ const html = '<pre>Just text, no code tag</pre>';
69
+ const result = await highlightHtmlBlocks(html, mockHighlight);
70
+ expect(result).toBe('<pre>Just text, no code tag</pre>');
71
+ });
72
+
73
+ test('skips code blocks with no text content', async () => {
74
+ const html = '<pre><code></code></pre>';
75
+ const result = await highlightHtmlBlocks(html, mockHighlight);
76
+ // Should not call highlight for empty code
77
+ expect(result).toContain('<pre>');
78
+ expect(result).toContain('<code>');
79
+ });
80
+
81
+ test('extracts language from multiple classes', async () => {
82
+ const html = '<pre><code class="foo language-python bar">print("hi")</code></pre>';
83
+ const result = await highlightHtmlBlocks(html, mockHighlight);
84
+ expect(result).toContain('PRINT("HI")');
85
+ expect(result).toContain('class="language-python"');
86
+ });
87
+
88
+ test('trims trailing whitespace from code text', async () => {
89
+ const html = '<pre><code>hello \n\n</code></pre>';
90
+ const result = await highlightHtmlBlocks(html, mockHighlight);
91
+ expect(result).toContain('HELLO');
92
+ // The mock uppercases, so we know trimEnd() worked if there's no trailing whitespace
93
+ });
94
+ });
95
+
96
+ describe('rewriteAstroSetHtml', () => {
97
+ test('returns unchanged when no Fragment marker found', async () => {
98
+ const code = `const x = 1;\nconst y = 2;`;
99
+ const result = await rewriteAstroSetHtml(code, mockHighlight);
100
+ expect(result).toBe(code);
101
+ });
102
+
103
+ test('returns unchanged when marker found but no closing', async () => {
104
+ const code = `<_Fragment set:html={"<div>hello</div>`;
105
+ const result = await rewriteAstroSetHtml(code, mockHighlight);
106
+ expect(result).toBe(code);
107
+ });
108
+
109
+ test('returns unchanged when literal is empty', async () => {
110
+ const code = `<_Fragment set:html={} />`;
111
+ const result = await rewriteAstroSetHtml(code, mockHighlight);
112
+ expect(result).toBe(code);
113
+ });
114
+
115
+ test('returns unchanged when JSON is invalid', async () => {
116
+ const code = `<_Fragment set:html={not valid json} />`;
117
+ const result = await rewriteAstroSetHtml(code, mockHighlight);
118
+ expect(result).toBe(code);
119
+ });
120
+
121
+ test('highlights code blocks in Fragment', async () => {
122
+ const code = `<_Fragment set:html={"<pre><code>const x = 1;</code></pre>"} />`;
123
+ const result = await rewriteAstroSetHtml(code, mockHighlight);
124
+ expect(result).toContain('CONST X = 1;');
125
+ expect(result).toContain('<_Fragment set:html={');
126
+ expect(result).toContain('} />');
127
+ });
128
+
129
+ test('highlights multiple code blocks in Fragment', async () => {
130
+ const html = '<pre><code class="language-js">let a;</code></pre><pre><code class="language-ts">let b;</code></pre>';
131
+ const code = `<_Fragment set:html={${JSON.stringify(html)}} />`;
132
+ const result = await rewriteAstroSetHtml(code, mockHighlight);
133
+ expect(result).toContain('LET A;');
134
+ expect(result).toContain('LET B;');
135
+ });
136
+
137
+ test('preserves surrounding code', async () => {
138
+ const code = `
139
+ import { Fragment } from 'astro';
140
+
141
+ <div>
142
+ <_Fragment set:html={"<pre><code>hello</code></pre>"} />
143
+ </div>
144
+ `;
145
+ const result = await rewriteAstroSetHtml(code, mockHighlight);
146
+ expect(result).toContain("import { Fragment }");
147
+ expect(result).toContain('<div>');
148
+ expect(result).toContain('HELLO');
149
+ expect(result).toContain('</div>');
150
+ });
151
+
152
+ test('handles HTML with no code blocks', async () => {
153
+ const code = `<_Fragment set:html={"<div><p>No code here</p></div>"} />`;
154
+ const result = await rewriteAstroSetHtml(code, mockHighlight);
155
+ // Should still parse and serialize, result may differ slightly
156
+ expect(result).toContain('<_Fragment set:html={');
157
+ expect(result).toContain('No code here');
158
+ });
159
+
160
+ test('handles escaped quotes in JSON', async () => {
161
+ const html = '<pre><code>const str = \\"hello\\";</code></pre>';
162
+ const code = `<_Fragment set:html={${JSON.stringify(html)}} />`;
163
+ const result = await rewriteAstroSetHtml(code, mockHighlight);
164
+ expect(result).toContain('CONST STR');
165
+ });
166
+
167
+ test('processes ALL Fragment occurrences', async () => {
168
+ const code = `
169
+ <_Fragment set:html={"<pre><code>first</code></pre>"} />
170
+ <_Fragment set:html={"<pre><code>second</code></pre>"} />
171
+ `;
172
+ const result = await rewriteAstroSetHtml(code, mockHighlight);
173
+ // Both should be highlighted now
174
+ expect(result).toContain('FIRST');
175
+ expect(result).toContain('SECOND');
176
+ });
177
+ });
178
+
179
+ describe('highlightJsxCodeBlocks', () => {
180
+ test('returns unchanged when no pre tags', async () => {
181
+ const code = `<div>Hello world</div>`;
182
+ const result = await highlightJsxCodeBlocks(code, mockHighlight);
183
+ expect(result).toBe(code);
184
+ });
185
+
186
+ test('returns unchanged for empty code', async () => {
187
+ const result = await highlightJsxCodeBlocks('', mockHighlight);
188
+ expect(result).toBe('');
189
+ });
190
+
191
+ test('highlights JSX code block without language', async () => {
192
+ const code = `<pre><code>const x = 1;</code></pre>`;
193
+ const result = await highlightJsxCodeBlocks(code, mockHighlight);
194
+ expect(result).toContain('CONST X = 1;');
195
+ });
196
+
197
+ test('highlights JSX code block with language', async () => {
198
+ const code = `<pre><code class="language-js">let a = 1;</code></pre>`;
199
+ const result = await highlightJsxCodeBlocks(code, mockHighlight);
200
+ // Result is wrapped in set:html with JSON encoding
201
+ expect(result).toContain('set:html=');
202
+ expect(result).toContain('LET A = 1;');
203
+ expect(result).toContain('language-js');
204
+ });
205
+
206
+ test('decodes JSX string expressions', async () => {
207
+ // After html_entities_to_jsx(), code becomes {"string"} expressions
208
+ const code = `<pre><code class="language-js">{"const x = 1;"}</code></pre>`;
209
+ const result = await highlightJsxCodeBlocks(code, mockHighlight);
210
+ expect(result).toContain('CONST X = 1;');
211
+ });
212
+
213
+ test('decodes JSX expressions with newlines', async () => {
214
+ const code = `<pre><code class="language-js">{"line1"}{"\\n"}{"line2"}</code></pre>`;
215
+ const result = await highlightJsxCodeBlocks(code, mockHighlight);
216
+ // Result is wrapped in set:html to avoid raw { } in JSX context
217
+ expect(result).toContain('set:html=');
218
+ expect(result).toContain('LINE1');
219
+ expect(result).toContain('LINE2');
220
+ });
221
+
222
+ test('decodes HTML entities', async () => {
223
+ const code = `<pre><code>&lt;div&gt;&amp;amp;&lt;/div&gt;</code></pre>`;
224
+ const result = await highlightJsxCodeBlocks(code, mockHighlight);
225
+ expect(result).toContain('<DIV>&AMP;</DIV>');
226
+ });
227
+
228
+ test('skips already highlighted code blocks', async () => {
229
+ const code = `<pre class="shiki"><code>already highlighted</code></pre>`;
230
+ const result = await highlightJsxCodeBlocks(code, mockHighlight);
231
+ expect(result).toBe(code);
232
+ });
233
+
234
+ test('skips code blocks with data-language', async () => {
235
+ const code = `<pre data-language="js"><code>already highlighted</code></pre>`;
236
+ const result = await highlightJsxCodeBlocks(code, mockHighlight);
237
+ expect(result).toBe(code);
238
+ });
239
+
240
+ test('highlights multiple code blocks', async () => {
241
+ const code = `
242
+ <pre><code class="language-js">first</code></pre>
243
+ <p>text</p>
244
+ <pre><code class="language-ts">second</code></pre>
245
+ `;
246
+ const result = await highlightJsxCodeBlocks(code, mockHighlight);
247
+ expect(result).toContain('FIRST');
248
+ expect(result).toContain('SECOND');
249
+ expect(result).toContain('<p>text</p>');
250
+ });
251
+
252
+ test('preserves surrounding JSX', async () => {
253
+ const code = `
254
+ <TabItem>
255
+ <pre><code class="language-js">code here</code></pre>
256
+ </TabItem>
257
+ `;
258
+ const result = await highlightJsxCodeBlocks(code, mockHighlight);
259
+ expect(result).toContain('<TabItem>');
260
+ expect(result).toContain('CODE HERE');
261
+ expect(result).toContain('</TabItem>');
262
+ });
263
+
264
+ test('handles empty code blocks', async () => {
265
+ const code = `<pre><code class="language-js"></code></pre>`;
266
+ const result = await highlightJsxCodeBlocks(code, mockHighlight);
267
+ // Should not crash, should preserve the structure
268
+ expect(result).toContain('<pre>');
269
+ });
270
+
271
+ test('skips pre blocks inside set:html JSON strings', async () => {
272
+ // Simulate a set:html containing a <pre><code> block (already handled by rewriteAstroSetHtml)
273
+ const innerHtml = `<pre class="astro-code"><code class="language-js">const x = 1;</code></pre>`;
274
+ const code = `<_Fragment set:html={${JSON.stringify(innerHtml)}} />`;
275
+ const result = await highlightJsxCodeBlocks(code, mockHighlight);
276
+ // Should NOT be modified — the pre block is inside a JSON string
277
+ expect(result).toBe(code);
278
+ });
279
+
280
+ test('handles pre tag with attributes', async () => {
281
+ const code = `<pre class="astro-code" tabindex="0"><code class="language-sh"># comment</code></pre>`;
282
+ const result = await highlightJsxCodeBlocks(code, mockHighlight);
283
+ // Mock uppercases the code content, showing that highlighting was applied
284
+ // Result is wrapped in set:html with JSON encoding
285
+ expect(result).toContain('set:html=');
286
+ expect(result).toContain('# COMMENT');
287
+ expect(result).toContain('language-sh');
288
+ });
289
+ });
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Shiki syntax highlighting transforms for code blocks
3
+ * @module transforms/shiki
4
+ */
5
+
6
+ import { parseFragment, serialize } from 'parse5';
7
+ import type { DocumentFragment, Node, Element, TextNode } from '../vite-plugin/types.js';
8
+
9
+ /**
10
+ * Shiki highlighter function type.
11
+ */
12
+ export type ShikiHighlighter = (code: string, lang?: string) => Promise<string>;
13
+
14
+ /**
15
+ * Walks an HTML AST tree and applies a visitor function to each node.
16
+ */
17
+ function walk(node: Node, visit: (node: Node) => void): void {
18
+ visit(node);
19
+ if ('childNodes' in node && node.childNodes) {
20
+ for (const child of node.childNodes) {
21
+ walk(child, visit);
22
+ }
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Gets an attribute value from an HTML AST node.
28
+ */
29
+ function getAttr(node: Element, name: string): string | null {
30
+ const attrs = node.attrs || [];
31
+ const found = attrs.find((attr) => attr.name === name);
32
+ return found ? found.value : null;
33
+ }
34
+
35
+ /**
36
+ * Extracts text content from an HTML AST node recursively.
37
+ */
38
+ function getText(node: Node): string {
39
+ if (!('childNodes' in node) || !node.childNodes) return '';
40
+ let text = '';
41
+ for (const child of node.childNodes) {
42
+ if (child.nodeName === '#text') {
43
+ text += (child as TextNode).value || '';
44
+ } else {
45
+ text += getText(child);
46
+ }
47
+ }
48
+ return text;
49
+ }
50
+
51
+ /**
52
+ * Highlights code blocks in HTML using Shiki syntax highlighter.
53
+ */
54
+ export async function highlightHtmlBlocks(
55
+ html: string,
56
+ highlight: ShikiHighlighter
57
+ ): Promise<string> {
58
+ // PERF: Early skip if no <pre> tags exist (avoids expensive parse5 parsing)
59
+ if (!/<pre[\s>]/.test(html)) {
60
+ return html;
61
+ }
62
+
63
+ // Suppress parse5 warnings for JSX components in HTML
64
+ const fragment = parseFragment(html, {
65
+ onParseError: (error) => {
66
+ // Silently ignore end-tag-mismatch errors (typically from JSX in <p> tags)
67
+ if ((error.code as string) === 'end-tag-mismatch') {
68
+ return;
69
+ }
70
+ // Log other parse errors for debugging
71
+ console.warn('[xmdx] parse5 warning:', error);
72
+ },
73
+ }) as DocumentFragment;
74
+
75
+ const tasks: Promise<void>[] = [];
76
+
77
+ walk(fragment, (node) => {
78
+ if (node.nodeName !== 'pre') return;
79
+ const element = node as Element;
80
+ const codeNode = (element.childNodes || []).find(
81
+ (child): child is Element => child.nodeName === 'code'
82
+ );
83
+ if (!codeNode) return;
84
+
85
+ const codeText = getText(codeNode).trimEnd();
86
+ if (!codeText) return;
87
+ const classAttr = getAttr(codeNode, 'class') || '';
88
+ const lang = classAttr
89
+ .split(/\s+/)
90
+ .find((value) => value.startsWith('language-'))
91
+ ?.slice('language-'.length);
92
+
93
+ tasks.push(
94
+ highlight(codeText, lang).then((shikiHtml) => {
95
+ const highlighted = parseFragment(shikiHtml) as DocumentFragment;
96
+ const pre = (highlighted.childNodes || []).find(
97
+ (child): child is Element => child.nodeName === 'pre'
98
+ );
99
+ if (pre) {
100
+ element.nodeName = pre.nodeName;
101
+ element.tagName = pre.tagName;
102
+ element.attrs = pre.attrs;
103
+ element.childNodes = pre.childNodes;
104
+ }
105
+ })
106
+ );
107
+ });
108
+
109
+ if (tasks.length > 0) {
110
+ await Promise.all(tasks);
111
+ }
112
+ return serialize(fragment);
113
+ }
114
+
115
+ /**
116
+ * Checks if a position in the source code is inside a set:html={"..."} JSON string.
117
+ * This prevents double-highlighting of code blocks that were already processed
118
+ * by rewriteAstroSetHtml.
119
+ */
120
+ function isInsideSetHtml(code: string, pos: number): boolean {
121
+ // Search backwards from pos for the nearest set:html={
122
+ const marker = 'set:html={';
123
+ let searchFrom = pos;
124
+ while (searchFrom > 0) {
125
+ const idx = code.lastIndexOf(marker, searchFrom - 1);
126
+ if (idx === -1) return false;
127
+
128
+ // Found a set:html={, now check if pos is before its closing } />
129
+ const jsonStart = idx + marker.length;
130
+ // The JSON string starts with " — find its end by tracking quotes
131
+ if (code[jsonStart] === '"') {
132
+ // Scan for the closing " that ends the JSON string, respecting escapes
133
+ let i = jsonStart + 1;
134
+ while (i < code.length) {
135
+ if (code[i] === '\\') {
136
+ i += 2; // Skip escaped character
137
+ continue;
138
+ }
139
+ if (code[i] === '"') {
140
+ // Found end of JSON string
141
+ const jsonEnd = i + 1; // Position after closing "
142
+ if (pos >= jsonStart && pos < jsonEnd) {
143
+ return true; // pos is inside this JSON string
144
+ }
145
+ break;
146
+ }
147
+ i++;
148
+ }
149
+ }
150
+ // Try searching further back
151
+ searchFrom = idx;
152
+ }
153
+ return false;
154
+ }
155
+
156
+ /**
157
+ * Highlights code blocks that appear directly in JSX (not in set:html).
158
+ * Handles cases where slot content with components is embedded directly,
159
+ * causing code blocks to bypass the set:html path.
160
+ *
161
+ * JSX code blocks appear as: <pre><code class="language-js">{"code"}</code></pre>
162
+ * After html_entities_to_jsx() content may be: {"line1"}{"\n"}{"line2"}
163
+ */
164
+ export async function highlightJsxCodeBlocks(
165
+ code: string,
166
+ highlight: ShikiHighlighter
167
+ ): Promise<string> {
168
+ if (!code || typeof code !== 'string') {
169
+ return code;
170
+ }
171
+
172
+ // Early skip if no <pre> tags in JSX context
173
+ if (!/<pre[\s>]/.test(code)) {
174
+ return code;
175
+ }
176
+
177
+ // Match <pre> with optional attributes followed by <code class="language-xxx">content</code></pre>
178
+ // Content may contain JSX expressions like {"text"} or HTML entities
179
+ const preCodeRegex = /<pre[^>]*><code(?:\s+class="language-([^"]*)")?>([\s\S]*?)<\/code><\/pre>/g;
180
+
181
+ // Phase 1: Collect all code blocks to highlight (no await in loop)
182
+ const toHighlight: Array<{
183
+ fullMatch: string;
184
+ lang: string | undefined;
185
+ codeText: string;
186
+ }> = [];
187
+
188
+ let match;
189
+ while ((match = preCodeRegex.exec(code)) !== null) {
190
+ const [fullMatch, lang, rawContent = ''] = match;
191
+
192
+ // Skip if already processed by Shiki (has shiki class or data-language)
193
+ if (fullMatch.includes('class="shiki') || fullMatch.includes('data-language')) {
194
+ continue;
195
+ }
196
+
197
+ // Skip if this <pre> is inside a set:html JSON string (already handled by rewriteAstroSetHtml)
198
+ if (isInsideSetHtml(code, match.index)) {
199
+ continue;
200
+ }
201
+
202
+ // Skip empty code blocks
203
+ if (!rawContent) {
204
+ continue;
205
+ }
206
+
207
+ // Decode JSX expressions back to plain text
208
+ // Pattern: {"string"} or {"\n"} etc.
209
+ let codeText = rawContent
210
+ // Decode JSX string expressions: {"text"} -> text
211
+ .replace(/\{"([^"]*)"\}/g, (_, str) => {
212
+ // Handle escape sequences
213
+ return str
214
+ .replace(/\\n/g, '\n')
215
+ .replace(/\\t/g, '\t')
216
+ .replace(/\\r/g, '\r')
217
+ .replace(/\\\\/g, '\\')
218
+ .replace(/\\"/g, '"');
219
+ })
220
+ // Decode HTML entities that might remain
221
+ .replace(/&lt;/g, '<')
222
+ .replace(/&gt;/g, '>')
223
+ .replace(/&amp;/g, '&')
224
+ .replace(/&quot;/g, '"')
225
+ .replace(/&#39;/g, "'")
226
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) =>
227
+ String.fromCharCode(parseInt(hex, 16))
228
+ )
229
+ .replace(/&#(\d+);/g, (_, num) =>
230
+ String.fromCharCode(parseInt(num, 10))
231
+ );
232
+
233
+ // Trim trailing whitespace but preserve internal structure
234
+ codeText = codeText.trimEnd();
235
+
236
+ if (!codeText) {
237
+ continue;
238
+ }
239
+
240
+ toHighlight.push({ fullMatch, lang, codeText });
241
+ }
242
+
243
+ if (toHighlight.length === 0) {
244
+ return code;
245
+ }
246
+
247
+ // Phase 2: Highlight all code blocks in parallel
248
+ const highlighted = await Promise.all(
249
+ toHighlight.map(({ codeText, lang }) => highlight(codeText, lang || undefined))
250
+ );
251
+
252
+ // Phase 3: Build replacements and apply them
253
+ let result = code;
254
+ for (let i = 0; i < toHighlight.length; i++) {
255
+ const { fullMatch } = toHighlight[i]!;
256
+ const highlightedHtml = highlighted[i]!;
257
+ // Wrap in set:html to avoid raw { } in JSX context being parsed as expressions
258
+ const replacement = `<_Fragment set:html={${JSON.stringify(highlightedHtml)}} />`;
259
+ result = result.replace(fullMatch, replacement);
260
+ }
261
+
262
+ return result;
263
+ }
264
+
265
+ /**
266
+ * Rewrites Astro set:html fragments with Shiki-highlighted code.
267
+ * Searches for <_Fragment set:html={...} /> patterns and applies syntax highlighting.
268
+ * Processes ALL occurrences in the code, not just the first one.
269
+ */
270
+ export async function rewriteAstroSetHtml(
271
+ code: string,
272
+ highlight: ShikiHighlighter
273
+ ): Promise<string> {
274
+ if (!code || typeof code !== 'string') {
275
+ return code;
276
+ }
277
+
278
+ const marker = '<_Fragment set:html={';
279
+ let result = code;
280
+ let searchStart = 0;
281
+
282
+ // Process ALL occurrences in a loop
283
+ while (true) {
284
+ const idx = result.indexOf(marker, searchStart);
285
+ if (idx === -1) break;
286
+
287
+ const start = idx + marker.length;
288
+ const end = result.indexOf('} />', start);
289
+ if (end === -1) break;
290
+
291
+ const literal = result.slice(start, end).trim();
292
+ if (!literal) {
293
+ searchStart = end;
294
+ continue;
295
+ }
296
+
297
+ let html: string;
298
+ try {
299
+ html = JSON.parse(literal) as string;
300
+ } catch {
301
+ searchStart = end;
302
+ continue;
303
+ }
304
+
305
+ const rewritten = await highlightHtmlBlocks(html, highlight);
306
+ const encoded = JSON.stringify(rewritten);
307
+ result = result.slice(0, start) + encoded + result.slice(end);
308
+ searchStart = start + encoded.length + 4; // Move past this occurrence
309
+ }
310
+
311
+ return result;
312
+ }
package/src/types.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Type definitions for Xmdx plugin system
3
+ * @module types
4
+ */
5
+
6
+ import type { Registry } from 'xmdx/registry';
7
+ import type { ExpressiveCodeConfig, StarlightUserConfig } from './utils/config.js';
8
+ import type { ShikiHighlighter } from './transforms/shiki.js';
9
+
10
+ /**
11
+ * Configuration available to transforms.
12
+ */
13
+ export interface TransformConfig {
14
+ /** ExpressiveCode configuration or null if disabled */
15
+ expressiveCode: ExpressiveCodeConfig | null;
16
+ /** Starlight components configuration */
17
+ starlightComponents: boolean | StarlightUserConfig;
18
+ /** Shiki highlighter function or null if disabled */
19
+ shiki: ShikiHighlighter | null;
20
+ }
21
+
22
+ /**
23
+ * Transform context passed through the pipeline.
24
+ * Contains the current code state and metadata needed by transforms.
25
+ */
26
+ export interface TransformContext {
27
+ /** Current JSX code being transformed */
28
+ code: string;
29
+ /** Original markdown source */
30
+ source: string;
31
+ /** Source file path */
32
+ filename: string;
33
+ /** Parsed frontmatter object */
34
+ frontmatter: Record<string, unknown>;
35
+ /** Extracted headings from the document */
36
+ headings: Array<{ depth: number; slug: string; text: string }>;
37
+ /** Component registry for import resolution */
38
+ registry?: Registry;
39
+ /** Plugin configuration for transforms */
40
+ config: TransformConfig;
41
+ }
42
+
43
+ /**
44
+ * A Xmdx plugin that can hook into the transform pipeline.
45
+ */
46
+ export interface XmdxPlugin {
47
+ /** Plugin identifier for debugging and ordering */
48
+ name: string;
49
+ /** Execution order: 'pre' runs before built-in transforms, 'post' runs after */
50
+ enforce?: 'pre' | 'post';
51
+ /** Hook called after markdown is parsed to JSX, before any transforms */
52
+ afterParse?: (ctx: TransformContext) => TransformContext | Promise<TransformContext>;
53
+ /** Hook called before component injection transforms */
54
+ beforeInject?: (ctx: TransformContext) => TransformContext | Promise<TransformContext>;
55
+ /** Hook called after all transforms, before esbuild */
56
+ beforeOutput?: (ctx: TransformContext) => TransformContext | Promise<TransformContext>;
57
+ /** Hook to preprocess raw markdown source before parsing */
58
+ preprocess?: (source: string, filename: string) => string;
59
+ }
60
+
61
+ /**
62
+ * Collected hooks from plugins, organized by hook type.
63
+ */
64
+ export interface PluginHooks {
65
+ afterParse: Array<(ctx: TransformContext) => TransformContext | Promise<TransformContext>>;
66
+ beforeInject: Array<(ctx: TransformContext) => TransformContext | Promise<TransformContext>>;
67
+ beforeOutput: Array<(ctx: TransformContext) => TransformContext | Promise<TransformContext>>;
68
+ preprocess: Array<(source: string, filename: string) => string>;
69
+ }
70
+
71
+ /**
72
+ * Options for handling MDX import/export statements.
73
+ * Allows fine-grained control over which imports are allowed vs trigger fallback.
74
+ */
75
+ export interface MdxImportHandlingOptions {
76
+ /**
77
+ * Import sources to allow. Files importing only from these sources
78
+ * won't trigger fallback to @mdx-js/mdx.
79
+ * Supports glob patterns (e.g., '~/components/*').
80
+ * @example ['@astrojs/starlight/components', '~/components/*']
81
+ */
82
+ allowImports?: string[];
83
+ /**
84
+ * Ignore import/export patterns inside code fences when detecting fallback.
85
+ * @default true
86
+ */
87
+ ignoreCodeFences?: boolean;
88
+ }
89
+
90
+ // Re-export types from submodules for convenience
91
+ export type { ExpressiveCodeConfig, StarlightUserConfig } from './utils/config.js';
92
+ export type { ShikiHighlighter } from './transforms/shiki.js';