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,406 @@
1
+ import { describe, it, expect } from 'bun:test';
2
+ import {
3
+ injectComponentImports,
4
+ injectStarlightComponents,
5
+ injectAstroComponents,
6
+ injectComponentImportsFromRegistry,
7
+ } from './inject-components.js';
8
+ import { createRegistry, starlightLibrary, astroLibrary, type Registry } from 'xmdx/registry';
9
+
10
+ // Create test registry
11
+ const testRegistry = createRegistry([starlightLibrary, astroLibrary]);
12
+
13
+ const ASTRO_COMPONENTS_MODULE = astroLibrary.defaultModulePath;
14
+
15
+ describe('injectComponentImports', () => {
16
+ it('should inject missing component imports', () => {
17
+ const code = `
18
+ export default function Content() {
19
+ return <Aside>Content</Aside>;
20
+ }`;
21
+
22
+ const result = injectComponentImports(code, ['Aside'], '@astrojs/starlight/components');
23
+
24
+ expect(result).toContain("import { Aside } from '@astrojs/starlight/components';");
25
+ expect(result).toContain('export default');
26
+ });
27
+
28
+ it('should not inject imports for unused components', () => {
29
+ const code = `
30
+ export default function Content() {
31
+ return <div>No components</div>;
32
+ }`;
33
+
34
+ const result = injectComponentImports(code, ['Aside', 'Tabs'], '@astrojs/starlight/components');
35
+
36
+ expect(result).toBe(code);
37
+ expect(result).not.toContain('import');
38
+ });
39
+
40
+ it('should not inject already imported components', () => {
41
+ const code = `
42
+ import { Aside } from '@astrojs/starlight/components';
43
+
44
+ export default function Content() {
45
+ return <Aside>Content</Aside>;
46
+ }`;
47
+
48
+ const result = injectComponentImports(code, ['Aside'], '@astrojs/starlight/components');
49
+
50
+ const importCount = (result.match(/import/g) || []).length;
51
+ expect(importCount).toBe(1);
52
+ });
53
+
54
+ it('should inject multiple missing components', () => {
55
+ const code = `
56
+ export default function Content() {
57
+ return (
58
+ <>
59
+ <Aside>Note</Aside>
60
+ <Tabs><TabItem>Tab</TabItem></Tabs>
61
+ </>
62
+ );
63
+ }`;
64
+
65
+ const result = injectComponentImports(
66
+ code,
67
+ ['Aside', 'Tabs', 'TabItem'],
68
+ '@astrojs/starlight/components'
69
+ );
70
+
71
+ expect(result).toContain('import { Aside, Tabs, TabItem }');
72
+ });
73
+
74
+ it('should only inject components that are used and missing', () => {
75
+ const code = `
76
+ import { Aside } from '@astrojs/starlight/components';
77
+
78
+ export default function Content() {
79
+ return (
80
+ <>
81
+ <Aside>Note</Aside>
82
+ <Tabs>Content</Tabs>
83
+ </>
84
+ );
85
+ }`;
86
+
87
+ const result = injectComponentImports(
88
+ code,
89
+ ['Aside', 'Tabs', 'Card'],
90
+ '@astrojs/starlight/components'
91
+ );
92
+
93
+ // Aside already imported, Tabs is used and missing, Card is not used
94
+ expect(result).toContain('import { Tabs }');
95
+ expect(result).not.toContain('Card');
96
+ });
97
+
98
+ it('should detect components with attributes', () => {
99
+ const code = `
100
+ export default function Content() {
101
+ return <Aside type="note">Content</Aside>;
102
+ }`;
103
+
104
+ const result = injectComponentImports(code, ['Aside'], '@astrojs/starlight/components');
105
+
106
+ expect(result).toContain('import { Aside }');
107
+ });
108
+
109
+ it('should detect self-closing components', () => {
110
+ const code = `
111
+ export default function Content() {
112
+ return <Card />;
113
+ }`;
114
+
115
+ const result = injectComponentImports(code, ['Card'], '@astrojs/starlight/components');
116
+
117
+ expect(result).toContain('import { Card }');
118
+ });
119
+
120
+ it('should strip heading metadata before scanning', () => {
121
+ const code = `
122
+ export const headings = [
123
+ { depth: 1, slug: 'aside', text: 'Aside' }
124
+ ];
125
+
126
+ export default function Content() {
127
+ return <Aside>Content</Aside>;
128
+ }`;
129
+
130
+ const result = injectComponentImports(code, ['Aside'], '@astrojs/starlight/components');
131
+
132
+ // Should detect Aside usage in actual content, not in headings
133
+ expect(result).toContain('import { Aside }');
134
+ });
135
+
136
+ it('should handle components in nested structures', () => {
137
+ const code = `
138
+ export default function Content() {
139
+ return (
140
+ <div>
141
+ <Aside>
142
+ <Tabs>
143
+ <TabItem label="One">Content</TabItem>
144
+ </Tabs>
145
+ </Aside>
146
+ </div>
147
+ );
148
+ }`;
149
+
150
+ const result = injectComponentImports(
151
+ code,
152
+ ['Aside', 'Tabs', 'TabItem'],
153
+ '@astrojs/starlight/components'
154
+ );
155
+
156
+ expect(result).toContain('import { Aside, Tabs, TabItem }');
157
+ });
158
+
159
+ it('should not inject if components array is empty', () => {
160
+ const code = `
161
+ export default function Content() {
162
+ return <Aside>Content</Aside>;
163
+ }`;
164
+
165
+ const result = injectComponentImports(code, [], '@astrojs/starlight/components');
166
+
167
+ expect(result).toBe(code);
168
+ });
169
+ });
170
+
171
+ describe('injectStarlightComponents', () => {
172
+ it('should inject Starlight components with true config', () => {
173
+ const code = `
174
+ export default function Content() {
175
+ return <Aside>Note</Aside>;
176
+ }`;
177
+
178
+ const result = injectStarlightComponents(code, true);
179
+
180
+ expect(result).toContain('import { Aside }');
181
+ expect(result).toContain('@astrojs/starlight/components');
182
+ });
183
+
184
+ it('should return code unchanged with false config', () => {
185
+ const code = `
186
+ export default function Content() {
187
+ return <Aside>Note</Aside>;
188
+ }`;
189
+
190
+ const result = injectStarlightComponents(code, false);
191
+
192
+ expect(result).toBe(code);
193
+ });
194
+
195
+ it('should handle custom components config', () => {
196
+ const code = `
197
+ export default function Content() {
198
+ return <CustomAside>Note</CustomAside>;
199
+ }`;
200
+
201
+ const result = injectStarlightComponents(code, {
202
+ components: ['CustomAside'],
203
+ });
204
+
205
+ expect(result).toContain('import { CustomAside }');
206
+ });
207
+
208
+ it('should handle custom module config', () => {
209
+ const code = `
210
+ export default function Content() {
211
+ return <Aside>Note</Aside>;
212
+ }`;
213
+
214
+ const result = injectStarlightComponents(code, {
215
+ module: 'my-custom-module',
216
+ });
217
+
218
+ expect(result).toContain('import { Aside }');
219
+ expect(result).toContain('my-custom-module');
220
+ });
221
+
222
+ it('should inject multiple Starlight components', () => {
223
+ const code = `
224
+ export default function Content() {
225
+ return (
226
+ <>
227
+ <Aside>Note</Aside>
228
+ <Tabs><TabItem>Tab</TabItem></Tabs>
229
+ <Steps>
230
+ <li>Step 1</li>
231
+ </Steps>
232
+ </>
233
+ );
234
+ }`;
235
+
236
+ const result = injectStarlightComponents(code, true);
237
+
238
+ expect(result).toContain('import { Aside, Tabs, TabItem, Steps }');
239
+ });
240
+ });
241
+
242
+ describe('injectAstroComponents', () => {
243
+ it('should inject Code component', () => {
244
+ const code = `
245
+ export default function Content() {
246
+ return <Code lang="js">const x = 1;</Code>;
247
+ }`;
248
+
249
+ const result = injectAstroComponents(code);
250
+
251
+ expect(result).toContain('import { Code }');
252
+ expect(result).toContain(ASTRO_COMPONENTS_MODULE);
253
+ });
254
+
255
+ it('should not inject Prism (not a built-in Astro component)', () => {
256
+ const code = `
257
+ export default function Content() {
258
+ return <Prism lang="js">const x = 1;</Prism>;
259
+ }`;
260
+
261
+ const result = injectAstroComponents(code);
262
+
263
+ expect(result).not.toContain('import { Prism }');
264
+ expect(result).toBe(code);
265
+ });
266
+
267
+ it('should only inject Code when both Code and Prism are used', () => {
268
+ const code = `
269
+ export default function Content() {
270
+ return (
271
+ <>
272
+ <Code lang="js">const x = 1;</Code>
273
+ <Prism lang="python">print("hello")</Prism>
274
+ </>
275
+ );
276
+ }`;
277
+
278
+ const result = injectAstroComponents(code);
279
+
280
+ expect(result).toContain('import { Code }');
281
+ expect(result).not.toContain('import { Code, Prism }');
282
+ });
283
+
284
+ it('should not inject if no Astro components used', () => {
285
+ const code = `
286
+ export default function Content() {
287
+ return <div>No Astro components</div>;
288
+ }`;
289
+
290
+ const result = injectAstroComponents(code);
291
+
292
+ expect(result).toBe(code);
293
+ });
294
+
295
+ it('should not inject if already imported', () => {
296
+ const code = `
297
+ import { Code } from 'astro/components';
298
+
299
+ export default function Content() {
300
+ return <Code lang="js">const x = 1;</Code>;
301
+ }`;
302
+
303
+ const result = injectAstroComponents(code);
304
+
305
+ const importCount = (result.match(/import/g) || []).length;
306
+ expect(importCount).toBe(1);
307
+ });
308
+ });
309
+
310
+ describe('injectComponentImportsFromRegistry', () => {
311
+ it('should inject missing Starlight component imports', () => {
312
+ const code = `
313
+ export default function Content() {
314
+ return <Aside>Content</Aside>;
315
+ }`;
316
+
317
+ const result = injectComponentImportsFromRegistry(code, testRegistry);
318
+
319
+ expect(result).toContain("import { Aside } from '@astrojs/starlight/components';");
320
+ });
321
+
322
+ it('should inject missing Astro component imports', () => {
323
+ const code = `
324
+ export default function Content() {
325
+ return <Code lang="js">const x = 1;</Code>;
326
+ }`;
327
+
328
+ const result = injectComponentImportsFromRegistry(code, testRegistry);
329
+
330
+ expect(result).toContain("import { Code } from 'astro/components';");
331
+ });
332
+
333
+ it('should inject components from multiple modules', () => {
334
+ const code = `
335
+ export default function Content() {
336
+ return (
337
+ <>
338
+ <Aside>Note</Aside>
339
+ <Code lang="js">code</Code>
340
+ </>
341
+ );
342
+ }`;
343
+
344
+ const result = injectComponentImportsFromRegistry(code, testRegistry);
345
+
346
+ expect(result).toContain("import { Aside } from '@astrojs/starlight/components';");
347
+ expect(result).toContain("import { Code } from 'astro/components';");
348
+ });
349
+
350
+ it('should not inject already imported components', () => {
351
+ const code = `
352
+ import { Aside } from '@astrojs/starlight/components';
353
+
354
+ export default function Content() {
355
+ return <Aside>Content</Aside>;
356
+ }`;
357
+
358
+ const result = injectComponentImportsFromRegistry(code, testRegistry);
359
+
360
+ const importCount = (result.match(/import.*Aside/g) || []).length;
361
+ expect(importCount).toBe(1);
362
+ });
363
+
364
+ it('should return code unchanged if no registry provided', () => {
365
+ const code = `
366
+ export default function Content() {
367
+ return <Aside>Content</Aside>;
368
+ }`;
369
+
370
+ const result = injectComponentImportsFromRegistry(code, null as unknown as Registry);
371
+
372
+ expect(result).toBe(code);
373
+ });
374
+
375
+ it('should group multiple components from same module', () => {
376
+ const code = `
377
+ export default function Content() {
378
+ return (
379
+ <>
380
+ <Aside>Note</Aside>
381
+ <Tabs><TabItem>Tab</TabItem></Tabs>
382
+ </>
383
+ );
384
+ }`;
385
+
386
+ const result = injectComponentImportsFromRegistry(code, testRegistry);
387
+
388
+ // Should have grouped import
389
+ expect(result).toContain("import { Aside, Tabs, TabItem } from '@astrojs/starlight/components';");
390
+ });
391
+
392
+ it('should strip heading metadata before scanning', () => {
393
+ const code = `
394
+ export const headings = [
395
+ { depth: 1, slug: 'aside', text: 'Aside' }
396
+ ];
397
+
398
+ export default function Content() {
399
+ return <Aside>Content</Aside>;
400
+ }`;
401
+
402
+ const result = injectComponentImportsFromRegistry(code, testRegistry);
403
+
404
+ expect(result).toContain('import { Aside }');
405
+ });
406
+ });
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Component import injection transformations
3
+ * @module transforms/inject-components
4
+ */
5
+
6
+ import type { Registry } from 'xmdx/registry';
7
+ import { astroLibrary } from 'xmdx/registry';
8
+ import { collectImportedNames, insertAfterImports } from '../utils/imports.js';
9
+ import { resolveStarlightConfig, type StarlightUserConfig } from '../utils/config.js';
10
+ import { stripHeadingsMeta } from '../utils/validation.js';
11
+
12
+ /** Strip set:html={...} string content to avoid false component matches in code blocks */
13
+ function stripSetHtmlContent(code: string): string {
14
+ return code.replace(/set:html=\{("(?:[^"\\]|\\.)*")\}/g, 'set:html={""}');
15
+ }
16
+
17
+ /**
18
+ * Generic component import injection.
19
+ * Scans code for component usage and injects missing imports.
20
+ *
21
+ * @example
22
+ * const code = `
23
+ * export default function Content() {
24
+ * return <Aside>Hello</Aside>;
25
+ * }
26
+ * `;
27
+ * const result = injectComponentImports(code, ['Aside'], '@astrojs/starlight/components');
28
+ * // Adds: import { Aside } from '@astrojs/starlight/components';
29
+ */
30
+ export function injectComponentImports(
31
+ code: string,
32
+ components: string[],
33
+ moduleId: string
34
+ ): string {
35
+ if (!code || typeof code !== 'string' || components.length === 0) {
36
+ return code;
37
+ }
38
+ const scanTarget = stripSetHtmlContent(stripHeadingsMeta(code));
39
+
40
+ // PERF: Use single combined regex instead of per-component regex
41
+ // This reduces from O(n) regex compilations to O(1)
42
+ const combinedPattern = new RegExp(`<(${components.join('|')})\\b`, 'g');
43
+ const matches = scanTarget.match(combinedPattern);
44
+ if (!matches) return code;
45
+
46
+ // Extract unique component names from matches
47
+ const usedSet = new Set<string>();
48
+ for (const match of matches) {
49
+ const name = match.slice(1); // Remove leading '<'
50
+ usedSet.add(name);
51
+ }
52
+ const used = components.filter((name) => usedSet.has(name));
53
+ if (used.length === 0) return code;
54
+
55
+ const imported = collectImportedNames(code);
56
+ const missing = used.filter((name) => !imported.has(name));
57
+ if (missing.length === 0) return code;
58
+
59
+ const importLine = `import { ${missing.join(', ')} } from '${moduleId}';`;
60
+ return insertAfterImports(code, importLine);
61
+ }
62
+
63
+ /**
64
+ * Inject Starlight component imports based on usage.
65
+ * Normalizes config and delegates to injectComponentImports.
66
+ *
67
+ * @example
68
+ * const code = `<Aside>Note</Aside>`;
69
+ * const result = injectStarlightComponents(code, true);
70
+ * // Adds: import { Aside } from '@astrojs/starlight/components';
71
+ */
72
+ export function injectStarlightComponents(
73
+ code: string,
74
+ config: boolean | StarlightUserConfig,
75
+ registry?: Registry
76
+ ): string {
77
+ const resolved = resolveStarlightConfig(config, registry);
78
+ if (!resolved) return code;
79
+
80
+ return injectComponentImports(code, resolved.components, resolved.moduleId);
81
+ }
82
+
83
+ /**
84
+ * Inject Astro component imports based on usage.
85
+ * Checks for Code/Prism component usage and adds imports.
86
+ *
87
+ * @example
88
+ * const code = `<Code lang="js">const x = 1;</Code>`;
89
+ * const result = injectAstroComponents(code);
90
+ * // Adds: import { Code } from 'astro/components';
91
+ */
92
+ export function injectAstroComponents(code: string, registry?: Registry): string {
93
+ // Get components from registry if available, otherwise use library preset
94
+ let components = astroLibrary.components.map((c) => c.name);
95
+ let moduleId = astroLibrary.defaultModulePath;
96
+
97
+ if (registry) {
98
+ const astroComponents = registry.getComponentsByModule(astroLibrary.defaultModulePath);
99
+ if (astroComponents.length > 0 && astroComponents[0]) {
100
+ components = astroComponents.map((c) => c.name);
101
+ moduleId = astroComponents[0].modulePath;
102
+ }
103
+ }
104
+
105
+ return injectComponentImports(code, components, moduleId);
106
+ }
107
+
108
+ /**
109
+ * Inject component imports from registry based on usage.
110
+ * Scans code for component usage and injects missing imports
111
+ * using information from the registry.
112
+ *
113
+ * @example
114
+ * const code = `<Aside>Note</Aside><Code lang="js">x</Code>`;
115
+ * const result = injectComponentImportsFromRegistry(code, registry);
116
+ * // Adds imports for both Aside and Code from their respective modules
117
+ */
118
+ export function injectComponentImportsFromRegistry(
119
+ code: string,
120
+ registry: Registry
121
+ ): string {
122
+ if (!code || typeof code !== 'string' || !registry) {
123
+ return code;
124
+ }
125
+
126
+ const allComponents = registry.getAllComponents();
127
+ if (allComponents.length === 0) {
128
+ return code;
129
+ }
130
+
131
+ const scanTarget = stripSetHtmlContent(stripHeadingsMeta(code));
132
+ const imported = collectImportedNames(code);
133
+
134
+ // PERF: Use single combined regex instead of per-component regex
135
+ // This reduces from O(n) regex compilations to O(1)
136
+ const componentNames = allComponents.map((c) => c.name);
137
+ const combinedPattern = new RegExp(`<(${componentNames.join('|')})\\b`, 'g');
138
+ const matches = scanTarget.match(combinedPattern);
139
+ if (!matches) return code;
140
+
141
+ // Extract unique component names from matches
142
+ const usedNames = new Set<string>();
143
+ for (const match of matches) {
144
+ const name = match.slice(1); // Remove leading '<'
145
+ usedNames.add(name);
146
+ }
147
+
148
+ // Find used components that are missing imports
149
+ const missingByModule = new Map<string, Array<{ name: string; exportType: string }>>();
150
+
151
+ for (const comp of allComponents) {
152
+ if (usedNames.has(comp.name) && !imported.has(comp.name)) {
153
+ const modulePath = comp.modulePath;
154
+ if (!missingByModule.has(modulePath)) {
155
+ missingByModule.set(modulePath, []);
156
+ }
157
+ missingByModule.get(modulePath)!.push({ name: comp.name, exportType: comp.exportType });
158
+ }
159
+ }
160
+
161
+ if (missingByModule.size === 0) {
162
+ return code;
163
+ }
164
+
165
+ // Generate import statements grouped by module
166
+ let result = code;
167
+ for (const [modulePath, components] of missingByModule) {
168
+ // Check if all components use named exports
169
+ const allNamed = components.every((c) => c.exportType === 'named');
170
+ if (allNamed) {
171
+ const names = components.map((c) => c.name).join(', ');
172
+ const importLine = `import { ${names} } from '${modulePath}';`;
173
+ result = insertAfterImports(result, importLine);
174
+ } else {
175
+ // Individual default imports for each component
176
+ for (const comp of components) {
177
+ const importLine = `import ${comp.name} from '${modulePath}/${comp.name}.astro';`;
178
+ result = insertAfterImports(result, importLine);
179
+ }
180
+ }
181
+ }
182
+
183
+ return result;
184
+ }