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.
- package/index.ts +8 -0
- package/package.json +80 -0
- package/src/constants.ts +52 -0
- package/src/index.ts +150 -0
- package/src/pipeline/index.ts +38 -0
- package/src/pipeline/orchestrator.test.ts +324 -0
- package/src/pipeline/orchestrator.ts +121 -0
- package/src/pipeline/pipe.test.ts +251 -0
- package/src/pipeline/pipe.ts +70 -0
- package/src/pipeline/types.ts +59 -0
- package/src/plugins.test.ts +274 -0
- package/src/presets/index.ts +225 -0
- package/src/transforms/blocks-to-jsx.test.ts +590 -0
- package/src/transforms/blocks-to-jsx.ts +617 -0
- package/src/transforms/expressive-code.test.ts +274 -0
- package/src/transforms/expressive-code.ts +147 -0
- package/src/transforms/index.test.ts +143 -0
- package/src/transforms/index.ts +100 -0
- package/src/transforms/inject-components.test.ts +406 -0
- package/src/transforms/inject-components.ts +184 -0
- package/src/transforms/shiki.test.ts +289 -0
- package/src/transforms/shiki.ts +312 -0
- package/src/types.ts +92 -0
- package/src/utils/config.test.ts +252 -0
- package/src/utils/config.ts +146 -0
- package/src/utils/frontmatter.ts +33 -0
- package/src/utils/imports.test.ts +518 -0
- package/src/utils/imports.ts +201 -0
- package/src/utils/mdx-detection.test.ts +41 -0
- package/src/utils/mdx-detection.ts +209 -0
- package/src/utils/paths.test.ts +206 -0
- package/src/utils/paths.ts +92 -0
- package/src/utils/validation.test.ts +60 -0
- package/src/utils/validation.ts +15 -0
- package/src/vite-plugin/binding-loader.ts +81 -0
- package/src/vite-plugin/directive-rewriter.test.ts +331 -0
- package/src/vite-plugin/directive-rewriter.ts +272 -0
- package/src/vite-plugin/esbuild-pool.ts +173 -0
- package/src/vite-plugin/index.ts +37 -0
- package/src/vite-plugin/jsx-module.ts +106 -0
- package/src/vite-plugin/mdx-wrapper.ts +328 -0
- package/src/vite-plugin/normalize-config.test.ts +78 -0
- package/src/vite-plugin/normalize-config.ts +29 -0
- package/src/vite-plugin/shiki-highlighter.ts +46 -0
- package/src/vite-plugin/shiki-manager.test.ts +175 -0
- package/src/vite-plugin/shiki-manager.ts +53 -0
- package/src/vite-plugin/types.ts +189 -0
- package/src/vite-plugin.ts +1342 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
decodeHtmlEntities,
|
|
4
|
+
rewriteExpressiveCodeBlocks,
|
|
5
|
+
rewriteSetHtmlCodeBlocks,
|
|
6
|
+
injectExpressiveCodeComponent,
|
|
7
|
+
} from './expressive-code.js';
|
|
8
|
+
|
|
9
|
+
describe('decodeHtmlEntities', () => {
|
|
10
|
+
test('returns value as-is when empty', () => {
|
|
11
|
+
expect(decodeHtmlEntities('')).toBe('');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('returns value as-is when no entities present', () => {
|
|
15
|
+
expect(decodeHtmlEntities('hello world')).toBe('hello world');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('decodes hex entities', () => {
|
|
19
|
+
expect(decodeHtmlEntities('A')).toBe('A');
|
|
20
|
+
expect(decodeHtmlEntities('<div>')).toBe('<div>');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('decodes decimal entities', () => {
|
|
24
|
+
expect(decodeHtmlEntities('A')).toBe('A');
|
|
25
|
+
expect(decodeHtmlEntities('<div>')).toBe('<div>');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('decodes named entities', () => {
|
|
29
|
+
expect(decodeHtmlEntities('"hello"')).toBe('"hello"');
|
|
30
|
+
expect(decodeHtmlEntities("'world'")).toBe("'world'");
|
|
31
|
+
expect(decodeHtmlEntities('<div>')).toBe('<div>');
|
|
32
|
+
expect(decodeHtmlEntities('&')).toBe('&');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('decodes mixed entities', () => {
|
|
36
|
+
expect(decodeHtmlEntities('<div>A&"')).toBe(
|
|
37
|
+
'<div>A&"'
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('decodes multiple occurrences', () => {
|
|
42
|
+
expect(decodeHtmlEntities('<<<')).toBe('<<<');
|
|
43
|
+
expect(decodeHtmlEntities('ABC')).toBe('ABC');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('handles ampersand correctly (decoded last)', () => {
|
|
47
|
+
expect(decodeHtmlEntities('&lt;')).toBe('<');
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('rewriteExpressiveCodeBlocks', () => {
|
|
52
|
+
test('returns unchanged code when no code blocks', () => {
|
|
53
|
+
const code = '# Hello\n\nSome text';
|
|
54
|
+
const result = rewriteExpressiveCodeBlocks(code, 'Code');
|
|
55
|
+
expect(result.code).toBe(code);
|
|
56
|
+
expect(result.changed).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('rewrites simple code block without language', () => {
|
|
60
|
+
const code = '<pre><code>const x = 1;</code></pre>';
|
|
61
|
+
const result = rewriteExpressiveCodeBlocks(code, 'Code');
|
|
62
|
+
expect(result.code).toBe('<Code code={"const x = 1;"} />');
|
|
63
|
+
expect(result.changed).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('rewrites code block with language', () => {
|
|
67
|
+
const code = '<pre><code class="language-javascript">const x = 1;</code></pre>';
|
|
68
|
+
const result = rewriteExpressiveCodeBlocks(code, 'Code');
|
|
69
|
+
expect(result.code).toBe(
|
|
70
|
+
'<Code code={"const x = 1;"} lang="javascript" />'
|
|
71
|
+
);
|
|
72
|
+
expect(result.changed).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('rewrites multiple code blocks', () => {
|
|
76
|
+
const code =
|
|
77
|
+
'<pre><code class="language-js">let a = 1;</code></pre>\n\n<pre><code class="language-ts">let b: number = 2;</code></pre>';
|
|
78
|
+
const result = rewriteExpressiveCodeBlocks(code, 'Code');
|
|
79
|
+
expect(result.code).toBe(
|
|
80
|
+
'<Code code={"let a = 1;"} lang="js" />\n\n<Code code={"let b: number = 2;"} lang="ts" />'
|
|
81
|
+
);
|
|
82
|
+
expect(result.changed).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('decodes HTML entities in code content', () => {
|
|
86
|
+
const code = '<pre><code><div>&</div></code></pre>';
|
|
87
|
+
const result = rewriteExpressiveCodeBlocks(code, 'Code');
|
|
88
|
+
expect(result.code).toBe('<Code code={"<div>&</div>"} />');
|
|
89
|
+
expect(result.changed).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('uses custom component name', () => {
|
|
93
|
+
const code = '<pre><code>hello</code></pre>';
|
|
94
|
+
const result = rewriteExpressiveCodeBlocks(code, 'MyCode');
|
|
95
|
+
expect(result.code).toBe('<MyCode code={"hello"} />');
|
|
96
|
+
expect(result.changed).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('handles multiline code', () => {
|
|
100
|
+
const code = '<pre><code>line 1\nline 2\nline 3</code></pre>';
|
|
101
|
+
const result = rewriteExpressiveCodeBlocks(code, 'Code');
|
|
102
|
+
expect(result.code).toBe('<Code code={"line 1\\nline 2\\nline 3"} />');
|
|
103
|
+
expect(result.changed).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('preserves code with special characters', () => {
|
|
107
|
+
const code = '<pre><code>const str = "hello";</code></pre>';
|
|
108
|
+
const result = rewriteExpressiveCodeBlocks(code, 'Code');
|
|
109
|
+
expect(result.code).toBe('<Code code={"const str = \\"hello\\";"} />');
|
|
110
|
+
expect(result.changed).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('handles pre tag with attributes', () => {
|
|
114
|
+
const code = '<pre class="astro-code" tabindex="0"><code class="language-sh"># create a new project\nnpm create astro@latest</code></pre>';
|
|
115
|
+
const result = rewriteExpressiveCodeBlocks(code, 'Code');
|
|
116
|
+
expect(result.code).toBe('<Code code={"# create a new project\\nnpm create astro@latest"} lang="sh" />');
|
|
117
|
+
expect(result.changed).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('handles pre tag with single attribute', () => {
|
|
121
|
+
const code = '<pre tabindex="0"><code>simple code</code></pre>';
|
|
122
|
+
const result = rewriteExpressiveCodeBlocks(code, 'Code');
|
|
123
|
+
expect(result.code).toBe('<Code code={"simple code"} />');
|
|
124
|
+
expect(result.changed).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('rewriteSetHtmlCodeBlocks', () => {
|
|
129
|
+
test('returns unchanged when no Fragment marker found', () => {
|
|
130
|
+
const code = '<div><p>Hello</p></div>';
|
|
131
|
+
const result = rewriteSetHtmlCodeBlocks(code, 'Code');
|
|
132
|
+
expect(result.code).toBe(code);
|
|
133
|
+
expect(result.changed).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('returns unchanged when no code blocks inside Fragment', () => {
|
|
137
|
+
const code = '<_Fragment set:html={"<p>Hello world</p>"} />';
|
|
138
|
+
const result = rewriteSetHtmlCodeBlocks(code, 'Code');
|
|
139
|
+
expect(result.code).toBe(code);
|
|
140
|
+
expect(result.changed).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('rewrites code block inside Fragment', () => {
|
|
144
|
+
const code = '<_Fragment set:html={"<pre><code>const x = 1;</code></pre>"} />';
|
|
145
|
+
const result = rewriteSetHtmlCodeBlocks(code, 'Code');
|
|
146
|
+
expect(result.code).toBe('<Code code={"const x = 1;"} />');
|
|
147
|
+
expect(result.changed).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('rewrites code block with language inside Fragment', () => {
|
|
151
|
+
const html = '<pre><code class="language-js">let a = 1;</code></pre>';
|
|
152
|
+
const code = `<_Fragment set:html={${JSON.stringify(html)}} />`;
|
|
153
|
+
const result = rewriteSetHtmlCodeBlocks(code, 'Code');
|
|
154
|
+
expect(result.code).toBe('<Code code={"let a = 1;"} lang="js" />');
|
|
155
|
+
expect(result.changed).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('processes multiple Fragment occurrences', () => {
|
|
159
|
+
const code = `
|
|
160
|
+
<_Fragment set:html={"<pre><code>first</code></pre>"} />
|
|
161
|
+
<_Fragment set:html={"<pre><code>second</code></pre>"} />
|
|
162
|
+
`;
|
|
163
|
+
const result = rewriteSetHtmlCodeBlocks(code, 'Code');
|
|
164
|
+
expect(result.code).toContain('<Code code={"first"} />');
|
|
165
|
+
expect(result.code).toContain('<Code code={"second"} />');
|
|
166
|
+
expect(result.changed).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('preserves surrounding JSX', () => {
|
|
170
|
+
const code = `
|
|
171
|
+
<SplitCard>
|
|
172
|
+
<_Fragment set:html={"<pre><code>npm install</code></pre>"} />
|
|
173
|
+
</SplitCard>
|
|
174
|
+
`;
|
|
175
|
+
const result = rewriteSetHtmlCodeBlocks(code, 'Code');
|
|
176
|
+
expect(result.code).toContain('<SplitCard>');
|
|
177
|
+
expect(result.code).toContain('</SplitCard>');
|
|
178
|
+
expect(result.code).toContain('<Code code={"npm install"} />');
|
|
179
|
+
expect(result.changed).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('uses custom component name', () => {
|
|
183
|
+
const code = '<_Fragment set:html={"<pre><code>test</code></pre>"} />';
|
|
184
|
+
const result = rewriteSetHtmlCodeBlocks(code, 'MyCodeBlock');
|
|
185
|
+
expect(result.code).toBe('<MyCodeBlock code={"test"} />');
|
|
186
|
+
expect(result.changed).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('handles JSON with escaped quotes', () => {
|
|
190
|
+
const html = '<pre><code>const str = "hello";</code></pre>';
|
|
191
|
+
const code = `<_Fragment set:html={${JSON.stringify(html)}} />`;
|
|
192
|
+
const result = rewriteSetHtmlCodeBlocks(code, 'Code');
|
|
193
|
+
expect(result.code).toBe('<Code code={"const str = \\"hello\\";"} />');
|
|
194
|
+
expect(result.changed).toBe(true);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('skips invalid JSON', () => {
|
|
198
|
+
const code = '<_Fragment set:html={not valid json} />';
|
|
199
|
+
const result = rewriteSetHtmlCodeBlocks(code, 'Code');
|
|
200
|
+
expect(result.code).toBe(code);
|
|
201
|
+
expect(result.changed).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('handles mixed HTML and code - keeps Fragment for mixed content', () => {
|
|
205
|
+
const html = '<p>text</p><pre><code>hello</code></pre>';
|
|
206
|
+
const code = `<_Fragment set:html={${JSON.stringify(html)}} />`;
|
|
207
|
+
const result = rewriteSetHtmlCodeBlocks(code, 'Code');
|
|
208
|
+
// When there's mixed content, the code block is replaced inline
|
|
209
|
+
expect(result.code).toContain('<Code code={"hello"} />');
|
|
210
|
+
expect(result.changed).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe('injectExpressiveCodeComponent', () => {
|
|
215
|
+
test('does not inject if already imported', () => {
|
|
216
|
+
const code = `import { Code } from 'astro-expressive-code';\n\n# Hello`;
|
|
217
|
+
const config = { component: 'Code', moduleId: 'astro-expressive-code' };
|
|
218
|
+
const result = injectExpressiveCodeComponent(code, config);
|
|
219
|
+
expect(result).toBe(code);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('injects default Code import', () => {
|
|
223
|
+
const code = `import { useState } from 'react';\n\n# Hello`;
|
|
224
|
+
const config = {
|
|
225
|
+
component: 'Code',
|
|
226
|
+
moduleId: 'astro-expressive-code/components',
|
|
227
|
+
};
|
|
228
|
+
const result = injectExpressiveCodeComponent(code, config);
|
|
229
|
+
expect(result).toContain(
|
|
230
|
+
`import { Code } from 'astro-expressive-code/components';`
|
|
231
|
+
);
|
|
232
|
+
expect(result).toContain(`import { useState } from 'react';`);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('injects aliased Code import for custom component name', () => {
|
|
236
|
+
const code = `import { useState } from 'react';\n\n# Hello`;
|
|
237
|
+
const config = {
|
|
238
|
+
component: 'MyCode',
|
|
239
|
+
moduleId: 'astro-expressive-code/components',
|
|
240
|
+
};
|
|
241
|
+
const result = injectExpressiveCodeComponent(code, config);
|
|
242
|
+
expect(result).toContain(
|
|
243
|
+
`import { Code as MyCode } from 'astro-expressive-code/components';`
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('inserts after existing imports', () => {
|
|
248
|
+
const code = `import { foo } from 'bar';\nimport { baz } from 'qux';\n\n# Content`;
|
|
249
|
+
const config = { component: 'Code', moduleId: 'expressive-code' };
|
|
250
|
+
const result = injectExpressiveCodeComponent(code, config);
|
|
251
|
+
const lines = result.split('\n');
|
|
252
|
+
const codeImportIndex = lines.findIndex((line) =>
|
|
253
|
+
line.includes('import { Code }')
|
|
254
|
+
);
|
|
255
|
+
const lastImportIndex = lines.findIndex((line) =>
|
|
256
|
+
line.includes('import { baz }')
|
|
257
|
+
);
|
|
258
|
+
expect(codeImportIndex).toBeGreaterThan(lastImportIndex);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('inserts at beginning when no imports exist', () => {
|
|
262
|
+
const code = `# Hello\n\nSome content`;
|
|
263
|
+
const config = { component: 'Code', moduleId: 'expressive-code' };
|
|
264
|
+
const result = injectExpressiveCodeComponent(code, config);
|
|
265
|
+
expect(result).toMatch(/^import { Code } from 'expressive-code';/);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('does not inject if custom component name already imported', () => {
|
|
269
|
+
const code = `import { Code as MyCode } from 'somewhere';\n\n# Hello`;
|
|
270
|
+
const config = { component: 'MyCode', moduleId: 'expressive-code' };
|
|
271
|
+
const result = injectExpressiveCodeComponent(code, config);
|
|
272
|
+
expect(result).toBe(code);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ExpressiveCode component injection and rewriting transforms
|
|
3
|
+
* @module transforms/expressive-code
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { collectImportedNames, insertAfterImports } from '../utils/imports.js';
|
|
7
|
+
import type { ExpressiveCodeConfig } from '../utils/config.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Decodes HTML entities in a string.
|
|
11
|
+
*/
|
|
12
|
+
export function decodeHtmlEntities(value: string): string {
|
|
13
|
+
if (!value || !value.includes('&')) return value;
|
|
14
|
+
return value
|
|
15
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex: string) =>
|
|
16
|
+
String.fromCodePoint(Number.parseInt(hex, 16))
|
|
17
|
+
)
|
|
18
|
+
.replace(/&#([0-9]+);/g, (_, num: string) =>
|
|
19
|
+
String.fromCodePoint(Number.parseInt(num, 10))
|
|
20
|
+
)
|
|
21
|
+
.replace(/"/g, '"')
|
|
22
|
+
.replace(/'/g, "'")
|
|
23
|
+
.replace(/</g, '<')
|
|
24
|
+
.replace(/>/g, '>')
|
|
25
|
+
.replace(/&/g, '&');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Result of rewriting ExpressiveCode blocks.
|
|
30
|
+
*/
|
|
31
|
+
export interface RewriteResult {
|
|
32
|
+
/** The transformed code */
|
|
33
|
+
code: string;
|
|
34
|
+
/** Whether any changes were made */
|
|
35
|
+
changed: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Rewrites <pre><code> blocks to ExpressiveCode components.
|
|
40
|
+
*/
|
|
41
|
+
export function rewriteExpressiveCodeBlocks(
|
|
42
|
+
code: string,
|
|
43
|
+
componentName: string
|
|
44
|
+
): RewriteResult {
|
|
45
|
+
if (!code || typeof code !== 'string') {
|
|
46
|
+
return { code, changed: false };
|
|
47
|
+
}
|
|
48
|
+
// Pattern matches <pre> with optional attributes (class, tabindex, etc.)
|
|
49
|
+
// followed by <code> with optional language class
|
|
50
|
+
const pattern =
|
|
51
|
+
/<pre[^>]*><code(?: class="language-([^"]+)")?>([\s\S]*?)<\/code><\/pre>/g;
|
|
52
|
+
let changed = false;
|
|
53
|
+
const next = code.replace(pattern, (_match, lang: string | undefined, raw: string) => {
|
|
54
|
+
changed = true;
|
|
55
|
+
const decoded = decodeHtmlEntities(raw);
|
|
56
|
+
const props = [`code={${JSON.stringify(decoded)}}`];
|
|
57
|
+
if (lang) {
|
|
58
|
+
props.push(`lang="${lang}"`);
|
|
59
|
+
}
|
|
60
|
+
return `<${componentName} ${props.join(' ')} />`;
|
|
61
|
+
});
|
|
62
|
+
return { code: next, changed };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Rewrites code blocks inside Fragment set:html JSON strings.
|
|
67
|
+
*
|
|
68
|
+
* Handles: <_Fragment set:html={"<pre><code>...</code></pre>"} />
|
|
69
|
+
*
|
|
70
|
+
* When a code block inside a slot is transformed to ExpressiveCode component,
|
|
71
|
+
* the entire Fragment wrapper is replaced with the component directly.
|
|
72
|
+
*/
|
|
73
|
+
export function rewriteSetHtmlCodeBlocks(
|
|
74
|
+
code: string,
|
|
75
|
+
componentName: string
|
|
76
|
+
): RewriteResult {
|
|
77
|
+
if (!code || typeof code !== 'string') {
|
|
78
|
+
return { code, changed: false };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const marker = '<_Fragment set:html={';
|
|
82
|
+
let result = code;
|
|
83
|
+
let changed = false;
|
|
84
|
+
let searchStart = 0;
|
|
85
|
+
|
|
86
|
+
while (true) {
|
|
87
|
+
const idx = result.indexOf(marker, searchStart);
|
|
88
|
+
if (idx === -1) break;
|
|
89
|
+
|
|
90
|
+
const start = idx + marker.length;
|
|
91
|
+
const end = result.indexOf('} />', start);
|
|
92
|
+
if (end === -1) break;
|
|
93
|
+
|
|
94
|
+
const literal = result.slice(start, end).trim();
|
|
95
|
+
let html: string;
|
|
96
|
+
try {
|
|
97
|
+
html = JSON.parse(literal) as string;
|
|
98
|
+
} catch {
|
|
99
|
+
searchStart = end;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Apply ExpressiveCode rewrite to the HTML content
|
|
104
|
+
const rewritten = rewriteExpressiveCodeBlocks(html, componentName);
|
|
105
|
+
if (rewritten.changed) {
|
|
106
|
+
changed = true;
|
|
107
|
+
// If the rewritten content now has components, embed directly
|
|
108
|
+
// (replace the entire Fragment set:html wrapper)
|
|
109
|
+
if (/<[A-Z]/.test(rewritten.code)) {
|
|
110
|
+
// Replace Fragment set:html with direct content
|
|
111
|
+
result = result.slice(0, idx) + rewritten.code + result.slice(end + 4);
|
|
112
|
+
searchStart = idx + rewritten.code.length;
|
|
113
|
+
} else {
|
|
114
|
+
// Still pure HTML, re-encode
|
|
115
|
+
const encoded = JSON.stringify(rewritten.code);
|
|
116
|
+
result = result.slice(0, start) + encoded + result.slice(end);
|
|
117
|
+
searchStart = start + encoded.length + 4;
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
searchStart = end + 4;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { code: result, changed };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Injects ExpressiveCode component import if needed.
|
|
129
|
+
*/
|
|
130
|
+
export function injectExpressiveCodeComponent(
|
|
131
|
+
code: string,
|
|
132
|
+
config: ExpressiveCodeConfig
|
|
133
|
+
): string {
|
|
134
|
+
if (!code || typeof code !== 'string') {
|
|
135
|
+
return code;
|
|
136
|
+
}
|
|
137
|
+
const importName = config.component;
|
|
138
|
+
const imported = collectImportedNames(code);
|
|
139
|
+
if (imported.has(importName)) {
|
|
140
|
+
return code;
|
|
141
|
+
}
|
|
142
|
+
const importLine =
|
|
143
|
+
importName === 'Code'
|
|
144
|
+
? `import { Code } from '${config.moduleId}';`
|
|
145
|
+
: `import { Code as ${importName} } from '${config.moduleId}';`;
|
|
146
|
+
return insertAfterImports(code, importLine);
|
|
147
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for context-aware transform wrappers
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import {
|
|
7
|
+
transformExpressiveCode,
|
|
8
|
+
transformShikiHighlight,
|
|
9
|
+
} from './index.js';
|
|
10
|
+
import type { TransformContext } from '../types.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Helper to create a minimal transform context
|
|
14
|
+
*/
|
|
15
|
+
function createContext(overrides: Partial<TransformContext> = {}): TransformContext {
|
|
16
|
+
return {
|
|
17
|
+
code: '',
|
|
18
|
+
source: '# Hello World',
|
|
19
|
+
filename: '/test/file.md',
|
|
20
|
+
frontmatter: {},
|
|
21
|
+
headings: [],
|
|
22
|
+
config: {
|
|
23
|
+
expressiveCode: null,
|
|
24
|
+
starlightComponents: false,
|
|
25
|
+
shiki: null,
|
|
26
|
+
},
|
|
27
|
+
...overrides,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('transformExpressiveCode', () => {
|
|
32
|
+
test('returns context unchanged when expressiveCode is null', () => {
|
|
33
|
+
const ctx = createContext({
|
|
34
|
+
code: '<pre><code>const x = 1;</code></pre>',
|
|
35
|
+
});
|
|
36
|
+
const result = transformExpressiveCode(ctx);
|
|
37
|
+
expect(result.code).toBe('<pre><code>const x = 1;</code></pre>');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('rewrites code blocks when expressiveCode is configured', () => {
|
|
41
|
+
const ctx = createContext({
|
|
42
|
+
code: '<pre><code>const x = 1;</code></pre>',
|
|
43
|
+
config: {
|
|
44
|
+
expressiveCode: { component: 'Code', moduleId: 'astro-expressive-code/components' },
|
|
45
|
+
starlightComponents: false,
|
|
46
|
+
shiki: null,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
const result = transformExpressiveCode(ctx);
|
|
50
|
+
expect(result.code).toContain('<Code');
|
|
51
|
+
expect(result.code).toContain('code={');
|
|
52
|
+
expect(result.code).toContain("import { Code } from 'astro-expressive-code/components'");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('rewrites code blocks with language', () => {
|
|
56
|
+
const ctx = createContext({
|
|
57
|
+
code: '<pre><code class="language-javascript">const x = 1;</code></pre>',
|
|
58
|
+
config: {
|
|
59
|
+
expressiveCode: { component: 'Code', moduleId: 'astro-expressive-code/components' },
|
|
60
|
+
starlightComponents: false,
|
|
61
|
+
shiki: null,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
const result = transformExpressiveCode(ctx);
|
|
65
|
+
expect(result.code).toContain('<Code code={"const x = 1;"} lang="javascript" />');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('uses custom component name', () => {
|
|
69
|
+
const ctx = createContext({
|
|
70
|
+
code: '<pre><code>const x = 1;</code></pre>',
|
|
71
|
+
config: {
|
|
72
|
+
expressiveCode: { component: 'MyCode', moduleId: 'my-code-module' },
|
|
73
|
+
starlightComponents: false,
|
|
74
|
+
shiki: null,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
const result = transformExpressiveCode(ctx);
|
|
78
|
+
expect(result.code).toContain('<MyCode');
|
|
79
|
+
expect(result.code).toContain("import { Code as MyCode } from 'my-code-module'");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('transformShikiHighlight', () => {
|
|
84
|
+
test('returns context unchanged when shiki is null', async () => {
|
|
85
|
+
const ctx = createContext({
|
|
86
|
+
code: '<_Fragment set:html={"<pre><code>test</code></pre>"} />',
|
|
87
|
+
});
|
|
88
|
+
const result = await transformShikiHighlight(ctx);
|
|
89
|
+
expect(result.code).toBe('<_Fragment set:html={"<pre><code>test</code></pre>"} />');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('applies shiki highlighting when enabled', async () => {
|
|
93
|
+
const mockHighlight = async (_code: string, _lang?: string): Promise<string> => {
|
|
94
|
+
return `<pre class="shiki"><code>${_code}</code></pre>`;
|
|
95
|
+
};
|
|
96
|
+
const ctx = createContext({
|
|
97
|
+
code: '<_Fragment set:html={"<pre><code>const x = 1;</code></pre>"} />',
|
|
98
|
+
config: {
|
|
99
|
+
expressiveCode: null,
|
|
100
|
+
starlightComponents: false,
|
|
101
|
+
shiki: mockHighlight,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
const result = await transformShikiHighlight(ctx);
|
|
105
|
+
expect(result.code).toContain('shiki');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('context immutability', () => {
|
|
110
|
+
test('transforms return new context objects', () => {
|
|
111
|
+
const original = createContext({
|
|
112
|
+
code: '<pre><code>test</code></pre>',
|
|
113
|
+
config: {
|
|
114
|
+
expressiveCode: { component: 'Code', moduleId: 'astro-expressive-code/components' },
|
|
115
|
+
starlightComponents: false,
|
|
116
|
+
shiki: null,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
const result = transformExpressiveCode(original);
|
|
120
|
+
expect(result).not.toBe(original);
|
|
121
|
+
expect(result.code).not.toBe(original.code);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('transforms preserve other context properties', () => {
|
|
125
|
+
const ctx = createContext({
|
|
126
|
+
code: '<pre><code>test</code></pre>',
|
|
127
|
+
source: '```\ntest\n```',
|
|
128
|
+
filename: '/path/to/file.md',
|
|
129
|
+
frontmatter: { title: 'Test' },
|
|
130
|
+
headings: [{ text: 'Heading', depth: 1, slug: 'heading' }],
|
|
131
|
+
config: {
|
|
132
|
+
expressiveCode: { component: 'Code', moduleId: 'astro-expressive-code/components' },
|
|
133
|
+
starlightComponents: false,
|
|
134
|
+
shiki: null,
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
const result = transformExpressiveCode(ctx);
|
|
138
|
+
expect(result.source).toBe(ctx.source);
|
|
139
|
+
expect(result.filename).toBe(ctx.filename);
|
|
140
|
+
expect(result.frontmatter).toEqual(ctx.frontmatter);
|
|
141
|
+
expect(result.headings).toEqual(ctx.headings);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context-aware transform wrappers for pipeline composition
|
|
3
|
+
* @module transforms
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
rewriteExpressiveCodeBlocks,
|
|
8
|
+
rewriteSetHtmlCodeBlocks,
|
|
9
|
+
injectExpressiveCodeComponent,
|
|
10
|
+
} from './expressive-code.js';
|
|
11
|
+
import {
|
|
12
|
+
injectAstroComponents,
|
|
13
|
+
injectStarlightComponents,
|
|
14
|
+
injectComponentImportsFromRegistry,
|
|
15
|
+
} from './inject-components.js';
|
|
16
|
+
import { rewriteAstroSetHtml, highlightJsxCodeBlocks } from './shiki.js';
|
|
17
|
+
import type { TransformContext } from '../types.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Transform that rewrites <pre><code> blocks to ExpressiveCode components.
|
|
21
|
+
* Also handles code blocks inside set:html JSON strings (component slots).
|
|
22
|
+
* Only runs if expressiveCode is configured.
|
|
23
|
+
*/
|
|
24
|
+
export function transformExpressiveCode(ctx: TransformContext): TransformContext {
|
|
25
|
+
if (!ctx.config.expressiveCode || !ctx.code) {
|
|
26
|
+
return ctx;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const componentName = ctx.config.expressiveCode.component;
|
|
30
|
+
|
|
31
|
+
// First, rewrite code blocks inside set:html JSON strings (must run before
|
|
32
|
+
// the loose pattern, which would otherwise match <pre> inside JSON strings
|
|
33
|
+
// and corrupt the set:html wrapper)
|
|
34
|
+
let { code, changed } = rewriteSetHtmlCodeBlocks(ctx.code, componentName);
|
|
35
|
+
|
|
36
|
+
// Then, rewrite any remaining loose <pre><code> blocks
|
|
37
|
+
const looseResult = rewriteExpressiveCodeBlocks(code, componentName);
|
|
38
|
+
code = looseResult.code;
|
|
39
|
+
changed = changed || looseResult.changed;
|
|
40
|
+
|
|
41
|
+
if (changed) {
|
|
42
|
+
return {
|
|
43
|
+
...ctx,
|
|
44
|
+
code: injectExpressiveCodeComponent(code, ctx.config.expressiveCode),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return { ...ctx, code };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Transform that applies Shiki syntax highlighting.
|
|
52
|
+
* Only runs if shiki highlighter is available.
|
|
53
|
+
*
|
|
54
|
+
* Processes code blocks in two passes:
|
|
55
|
+
* 1. rewriteAstroSetHtml: Handles code in <_Fragment set:html={...} /> patterns
|
|
56
|
+
* 2. highlightJsxCodeBlocks: Handles code in direct JSX <pre><code> elements
|
|
57
|
+
* (when slot content with components bypasses set:html)
|
|
58
|
+
*/
|
|
59
|
+
export async function transformShikiHighlight(
|
|
60
|
+
ctx: TransformContext
|
|
61
|
+
): Promise<TransformContext> {
|
|
62
|
+
if (!ctx.config.shiki || !ctx.code) {
|
|
63
|
+
return ctx;
|
|
64
|
+
}
|
|
65
|
+
// First pass: highlight code blocks in set:html fragments
|
|
66
|
+
let code = await rewriteAstroSetHtml(ctx.code, ctx.config.shiki);
|
|
67
|
+
// Second pass: highlight code blocks in direct JSX (mixed slots with components)
|
|
68
|
+
code = await highlightJsxCodeBlocks(code, ctx.config.shiki);
|
|
69
|
+
return { ...ctx, code };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Transform that injects component imports from the registry.
|
|
74
|
+
* Unified replacement for transformInjectAstroComponents and transformInjectStarlightComponents.
|
|
75
|
+
* Uses the registry from context to find all component modules and inject missing imports.
|
|
76
|
+
*/
|
|
77
|
+
export function transformInjectComponentsFromRegistry(ctx: TransformContext): TransformContext {
|
|
78
|
+
if (!ctx.code || !ctx.registry) {
|
|
79
|
+
return ctx;
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
...ctx,
|
|
83
|
+
code: injectComponentImportsFromRegistry(ctx.code, ctx.registry),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Re-export from sub-modules
|
|
88
|
+
export {
|
|
89
|
+
rewriteExpressiveCodeBlocks,
|
|
90
|
+
rewriteSetHtmlCodeBlocks,
|
|
91
|
+
injectExpressiveCodeComponent,
|
|
92
|
+
} from './expressive-code.js';
|
|
93
|
+
export {
|
|
94
|
+
injectAstroComponents,
|
|
95
|
+
injectStarlightComponents,
|
|
96
|
+
injectComponentImports,
|
|
97
|
+
injectComponentImportsFromRegistry,
|
|
98
|
+
} from './inject-components.js';
|
|
99
|
+
export { rewriteAstroSetHtml, highlightHtmlBlocks, highlightJsxCodeBlocks } from './shiki.js';
|
|
100
|
+
export { blocksToJsx } from './blocks-to-jsx.js';
|