astro-xmdx 0.0.5 → 0.0.6

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 (38) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +14 -20
  3. package/src/constants.ts +0 -12
  4. package/src/index.test.ts +96 -0
  5. package/src/pipeline/orchestrator.test.ts +324 -0
  6. package/src/pipeline/pipe.test.ts +251 -0
  7. package/src/plugins.test.ts +274 -0
  8. package/src/transforms/blocks-to-jsx.test.ts +726 -0
  9. package/src/transforms/expressive-code.test.ts +326 -0
  10. package/src/transforms/expressive-code.ts +9 -49
  11. package/src/transforms/index.test.ts +143 -0
  12. package/src/transforms/index.ts +0 -1
  13. package/src/transforms/inject-components.test.ts +448 -0
  14. package/src/transforms/shiki.test.ts +289 -0
  15. package/src/utils/config.test.ts +284 -0
  16. package/src/utils/config.ts +0 -1
  17. package/src/utils/imports.test.ts +518 -0
  18. package/src/utils/mdx-detection.test.ts +127 -0
  19. package/src/utils/paths.test.ts +206 -0
  20. package/src/utils/starlight-detection.test.ts +281 -0
  21. package/src/utils/validation.test.ts +60 -0
  22. package/src/vite-plugin/batch-compiler.ts +11 -11
  23. package/src/vite-plugin/binding-loader.test.ts +86 -0
  24. package/src/vite-plugin/cache/disk-cache.test.ts +191 -0
  25. package/src/vite-plugin/fallback/directive-rewriter.test.ts +412 -0
  26. package/src/vite-plugin/fallback/rehype-heading-ids.test.ts +518 -0
  27. package/src/vite-plugin/fallback/rehype-tasklist.test.ts +132 -0
  28. package/src/vite-plugin/highlighting/shiki-highlighter.ts +5 -0
  29. package/src/vite-plugin/highlighting/shiki-manager.test.ts +175 -0
  30. package/src/vite-plugin/index.ts +30 -0
  31. package/src/vite-plugin/jsx-transform.ts +29 -24
  32. package/src/vite-plugin/load-handler.ts +4 -4
  33. package/src/vite-plugin/mdx-wrapper/component-detection.test.ts +109 -0
  34. package/src/vite-plugin/mdx-wrapper/component-imports.test.ts +109 -0
  35. package/src/vite-plugin/mdx-wrapper/mdx-wrapper.test.ts +539 -0
  36. package/src/vite-plugin/normalize-config.test.ts +78 -0
  37. package/src/vite-plugin/types.ts +16 -24
  38. package/src/vite-plugin.ts +12 -35
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 knj
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-xmdx",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "description": "Astro integration for xmdx - high-performance MDX compiler",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -40,16 +40,8 @@
40
40
  "files": [
41
41
  "index.ts",
42
42
  "src",
43
- "!src/**/*.test.ts",
44
- "!src/tests/",
45
43
  "template"
46
44
  ],
47
- "scripts": {
48
- "build": "tsc",
49
- "test": "bun test",
50
- "test:watch": "bun test --watch",
51
- "typecheck": "tsc --noEmit"
52
- },
53
45
  "keywords": [
54
46
  "astro",
55
47
  "astro-integration",
@@ -64,10 +56,6 @@
64
56
  "type": "git",
65
57
  "url": "https://github.com/jp-knj/xmdx"
66
58
  },
67
- "publishConfig": {
68
- "access": "public",
69
- "provenance": true
70
- },
71
59
  "peerDependencies": {
72
60
  "astro": ">=4.0.0",
73
61
  "vite": ">=5.0.0"
@@ -75,19 +63,25 @@
75
63
  "dependencies": {
76
64
  "@mdx-js/mdx": "^3.1.1",
77
65
  "expressive-code": "^0.41.0",
78
- "glob": "^13.0.0",
66
+ "glob": "^11.0.0",
79
67
  "gray-matter": "^4.0.3",
80
- "xmdx": "workspace:*",
81
- "@xmdx/napi": "workspace:*",
82
- "remark-directive": "^4.0.0",
68
+ "remark-directive": "^3.0.0",
83
69
  "remark-gfm": "^4.0.0",
84
- "shiki": "^3.22.0"
70
+ "shiki": "^3.20.0",
71
+ "xmdx": "0.0.5",
72
+ "@xmdx/napi": "0.0.5"
85
73
  },
86
74
  "devDependencies": {
87
75
  "@types/node": "^22.0.0",
88
76
  "esbuild": ">=0.21.0",
89
77
  "magic-string": "^0.30.0",
90
78
  "rollup": ">=4.0.0",
91
- "typescript": "^5.9.0"
79
+ "typescript": "^5.7.0"
80
+ },
81
+ "scripts": {
82
+ "build": "tsc",
83
+ "test": "bun test",
84
+ "test:watch": "bun test --watch",
85
+ "typecheck": "tsc --noEmit"
92
86
  }
93
- }
87
+ }
package/src/constants.ts CHANGED
@@ -66,15 +66,3 @@ export const DEFAULT_IGNORE_PATTERNS = ['node_modules/**', 'dist/**'] as const;
66
66
  * Injected as inline <style> in dev mode, Head.astro overlay in build.
67
67
  */
68
68
  export const STARLIGHT_LAYER_ORDER = '@layer starlight.base, starlight.reset, starlight.core, starlight.content, starlight.components, starlight.utils;';
69
-
70
- /**
71
- * Public specifier for ExpressiveCode collected CSS styles.
72
- * Used in generated import statements — does NOT include the \0 prefix.
73
- */
74
- export const EC_STYLES_MODULE_ID = 'xmdx:ec-styles.css';
75
-
76
- /**
77
- * Virtual module ID for ExpressiveCode collected CSS styles.
78
- * The \0-prefixed resolved ID used internally by Vite hooks.
79
- */
80
- export const EC_STYLES_VIRTUAL_ID = '\0xmdx:ec-styles.css';
@@ -0,0 +1,96 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import xmdx from './index.js';
3
+
4
+ // Helper to invoke the setup hook with a mocked Astro config
5
+ async function invokeSetup(
6
+ integration: ReturnType<typeof xmdx>,
7
+ configOverrides: Record<string, unknown> = {}
8
+ ) {
9
+ const setupHook = integration.hooks['astro:config:setup'];
10
+ const renderers: Array<{ name: string; serverEntrypoint: string }> = [];
11
+ const updatedConfigs: unknown[] = [];
12
+
13
+ await setupHook?.({
14
+ config: {
15
+ integrations: [],
16
+ ...configOverrides,
17
+ },
18
+ updateConfig: (config: unknown) => {
19
+ updatedConfigs.push(config);
20
+ },
21
+ addRenderer: (renderer: { name: string; serverEntrypoint: string }) => {
22
+ renderers.push(renderer);
23
+ },
24
+ } as unknown as Parameters<NonNullable<typeof setupHook>>[0]);
25
+
26
+ return { renderers, updatedConfigs };
27
+ }
28
+
29
+ describe('xmdx integration setup', () => {
30
+ test('registers renderer using built server file URL entrypoint', async () => {
31
+ const integration = xmdx();
32
+ const { renderers, updatedConfigs } = await invokeSetup(integration);
33
+
34
+ expect(renderers).toHaveLength(1);
35
+ expect(renderers[0]?.name).toBe('astro:jsx');
36
+ expect(renderers[0]?.serverEntrypoint.startsWith('file://')).toBe(true);
37
+ expect(renderers[0]?.serverEntrypoint.endsWith('/server.js')).toBe(true);
38
+ expect(updatedConfigs).toHaveLength(1);
39
+ });
40
+ });
41
+
42
+ describe('Starlight auto-detection', () => {
43
+ test('auto-enables starlightComponents when Starlight is detected', async () => {
44
+ const integration = xmdx();
45
+ const { updatedConfigs } = await invokeSetup(integration, {
46
+ integrations: [{ name: '@astrojs/starlight' }],
47
+ });
48
+
49
+ // The vite plugin receives the resolved options
50
+ const viteConfig = updatedConfigs[0] as { vite: { plugins: Array<{ name: string }> } };
51
+ expect(viteConfig.vite.plugins).toHaveLength(1);
52
+ expect(viteConfig.vite.plugins[0]?.name).toBe('vite-plugin-xmdx');
53
+ });
54
+
55
+ test('auto-registers libraries when Starlight detected and none provided', async () => {
56
+ // Access resolved options via plugin creation
57
+ const integration = xmdx();
58
+
59
+ // Verify that setup completes without errors when Starlight is detected
60
+ const { updatedConfigs } = await invokeSetup(integration, {
61
+ integrations: [{ name: '@astrojs/starlight' }],
62
+ });
63
+ expect(updatedConfigs).toHaveLength(1);
64
+ });
65
+
66
+ test('does not override user-provided libraries', async () => {
67
+ const customLibrary = {
68
+ id: 'custom',
69
+ name: 'Custom',
70
+ defaultModulePath: 'custom/module',
71
+ components: [{ name: 'Custom', modulePath: 'custom/module', exportType: 'named' as const }],
72
+ };
73
+
74
+ const integration = xmdx({ libraries: [customLibrary] });
75
+ const { updatedConfigs } = await invokeSetup(integration, {
76
+ integrations: [{ name: '@astrojs/starlight' }],
77
+ });
78
+ expect(updatedConfigs).toHaveLength(1);
79
+ });
80
+
81
+ test('detects Starlight component overrides from integration config', async () => {
82
+ const integration = xmdx();
83
+ const { updatedConfigs } = await invokeSetup(integration, {
84
+ integrations: [{
85
+ name: '@astrojs/starlight',
86
+ config: {
87
+ components: {
88
+ Aside: './src/components/CustomAside.astro',
89
+ },
90
+ },
91
+ }],
92
+ });
93
+ // Should complete without errors - override detection adjusts import paths
94
+ expect(updatedConfigs).toHaveLength(1);
95
+ });
96
+ });
@@ -0,0 +1,324 @@
1
+ import { describe, it, expect } from 'bun:test';
2
+ import { createPipeline, createCustomPipeline, createContext } from './orchestrator.js';
3
+ import type { TransformContext } from './types.js';
4
+
5
+ describe('createContext', () => {
6
+ it('should create context with default values', () => {
7
+ const ctx = createContext();
8
+
9
+ expect(ctx.code).toBe('');
10
+ expect(ctx.source).toBe('');
11
+ expect(ctx.filename).toBe('');
12
+ expect(ctx.frontmatter).toEqual({});
13
+ expect(ctx.headings).toEqual([]);
14
+ expect(ctx.registry).toBeUndefined();
15
+ expect(ctx.config).toEqual({
16
+ expressiveCode: null,
17
+ starlightComponents: false,
18
+ shiki: null,
19
+ });
20
+ });
21
+
22
+ it('should override default values with provided values', () => {
23
+ const ctx = createContext({
24
+ code: '<h1>Hello</h1>',
25
+ source: '# Hello',
26
+ filename: '/path/to/file.md',
27
+ frontmatter: { title: 'Test' },
28
+ headings: [{ depth: 1, text: 'Hello', slug: 'hello' }],
29
+ });
30
+
31
+ expect(ctx.code).toBe('<h1>Hello</h1>');
32
+ expect(ctx.source).toBe('# Hello');
33
+ expect(ctx.filename).toBe('/path/to/file.md');
34
+ expect(ctx.frontmatter).toEqual({ title: 'Test' });
35
+ expect(ctx.headings).toEqual([{ depth: 1, text: 'Hello', slug: 'hello' }]);
36
+ });
37
+
38
+ it('should merge config with defaults', () => {
39
+ const ctx = createContext({
40
+ config: {
41
+ expressiveCode: { component: 'Code', moduleId: 'test' },
42
+ starlightComponents: false,
43
+ shiki: null,
44
+ },
45
+ });
46
+
47
+ expect(ctx.config.expressiveCode).toEqual({ component: 'Code', moduleId: 'test' });
48
+ expect(ctx.config.starlightComponents).toBe(false);
49
+ expect(ctx.config.shiki).toBeNull();
50
+ });
51
+
52
+ it('should preserve registry when provided', () => {
53
+ const mockRegistry = { lookup: () => null } as unknown as TransformContext['registry'];
54
+ const ctx = createContext({ registry: mockRegistry });
55
+
56
+ expect(ctx.registry).toBe(mockRegistry);
57
+ });
58
+ });
59
+
60
+ describe('createPipeline', () => {
61
+ it('should create pipeline with empty hooks', async () => {
62
+ const pipeline = createPipeline();
63
+ const ctx = createContext({
64
+ code: '<p>test</p>',
65
+ source: 'test',
66
+ filename: 'test.md',
67
+ });
68
+
69
+ const result = await pipeline(ctx);
70
+
71
+ // Pipeline should return context (transforms may or may not modify it)
72
+ expect(result).toBeDefined();
73
+ expect(result.code).toBeDefined();
74
+ });
75
+
76
+ it('should execute afterParse hooks first', async () => {
77
+ const order: string[] = [];
78
+ const afterParseHook = (ctx: TransformContext): TransformContext => {
79
+ order.push('afterParse');
80
+ return { ...ctx, code: ctx.code + ':afterParse' };
81
+ };
82
+
83
+ const pipeline = createPipeline({
84
+ afterParse: [afterParseHook],
85
+ });
86
+
87
+ const ctx = createContext({ code: 'start' });
88
+ const result = await pipeline(ctx);
89
+
90
+ expect(order).toEqual(['afterParse']);
91
+ expect(result.code).toContain('afterParse');
92
+ });
93
+
94
+ it('should execute hooks in correct order', async () => {
95
+ const order: string[] = [];
96
+
97
+ const afterParseHook = (ctx: TransformContext): TransformContext => {
98
+ order.push('afterParse');
99
+ return ctx;
100
+ };
101
+
102
+ const beforeInjectHook = (ctx: TransformContext): TransformContext => {
103
+ order.push('beforeInject');
104
+ return ctx;
105
+ };
106
+
107
+ const beforeOutputHook = (ctx: TransformContext): TransformContext => {
108
+ order.push('beforeOutput');
109
+ return ctx;
110
+ };
111
+
112
+ const pipeline = createPipeline({
113
+ afterParse: [afterParseHook],
114
+ beforeInject: [beforeInjectHook],
115
+ beforeOutput: [beforeOutputHook],
116
+ });
117
+
118
+ const ctx = createContext({ code: 'test' });
119
+ await pipeline(ctx);
120
+
121
+ // Verify hook order: afterParse -> (built-in transforms) -> beforeInject -> (built-in transforms) -> beforeOutput
122
+ expect(order.indexOf('afterParse')).toBeLessThan(order.indexOf('beforeInject'));
123
+ expect(order.indexOf('beforeInject')).toBeLessThan(order.indexOf('beforeOutput'));
124
+ });
125
+
126
+ it('should handle multiple hooks per phase', async () => {
127
+ const order: string[] = [];
128
+
129
+ const pipeline = createPipeline({
130
+ afterParse: [
131
+ (ctx: TransformContext) => { order.push('afterParse1'); return ctx; },
132
+ (ctx: TransformContext) => { order.push('afterParse2'); return ctx; },
133
+ ],
134
+ beforeOutput: [
135
+ (ctx: TransformContext) => { order.push('beforeOutput1'); return ctx; },
136
+ (ctx: TransformContext) => { order.push('beforeOutput2'); return ctx; },
137
+ ],
138
+ });
139
+
140
+ const ctx = createContext({ code: 'test' });
141
+ await pipeline(ctx);
142
+
143
+ // Hooks should run in order within each phase
144
+ expect(order.indexOf('afterParse1')).toBeLessThan(order.indexOf('afterParse2'));
145
+ expect(order.indexOf('beforeOutput1')).toBeLessThan(order.indexOf('beforeOutput2'));
146
+ });
147
+
148
+ it('should handle async hooks', async () => {
149
+ const asyncHook = async (ctx: TransformContext): Promise<TransformContext> => {
150
+ await new Promise((resolve) => setTimeout(resolve, 10));
151
+ return { ...ctx, code: ctx.code + ':async' };
152
+ };
153
+
154
+ const pipeline = createPipeline({
155
+ afterParse: [asyncHook],
156
+ });
157
+
158
+ const ctx = createContext({ code: 'start' });
159
+ const result = await pipeline(ctx);
160
+
161
+ expect(result.code).toContain('async');
162
+ });
163
+
164
+ it('should pass context through all transforms', async () => {
165
+ const addMetadata = (ctx: TransformContext): TransformContext => ({
166
+ ...ctx,
167
+ frontmatter: { ...ctx.frontmatter, transformed: true },
168
+ });
169
+
170
+ const pipeline = createPipeline({
171
+ afterParse: [addMetadata],
172
+ });
173
+
174
+ const ctx = createContext({
175
+ code: 'test',
176
+ frontmatter: { title: 'Original' },
177
+ });
178
+ const result = await pipeline(ctx);
179
+
180
+ expect(result.frontmatter.title).toBe('Original');
181
+ expect(result.frontmatter.transformed).toBe(true);
182
+ });
183
+ });
184
+
185
+ describe('createCustomPipeline', () => {
186
+ it('should create pipeline with only specified transforms', async () => {
187
+ const transform1 = (ctx: TransformContext): TransformContext => ({ ...ctx, code: ctx.code + ':t1' });
188
+ const transform2 = (ctx: TransformContext): TransformContext => ({ ...ctx, code: ctx.code + ':t2' });
189
+
190
+ const pipeline = createCustomPipeline(transform1, transform2);
191
+ const ctx = createContext({ code: 'start' });
192
+ const result = await pipeline(ctx);
193
+
194
+ expect(result.code).toBe('start:t1:t2');
195
+ });
196
+
197
+ it('should not include any built-in transforms', async () => {
198
+ // Create a custom pipeline with only a simple transform
199
+ const simpleTransform = (ctx: TransformContext): TransformContext & { customOnly: boolean } => ({ ...ctx, customOnly: true });
200
+
201
+ const pipeline = createCustomPipeline(simpleTransform);
202
+ const ctx = createContext({ code: '<pre><code>test</code></pre>' });
203
+ const result = await pipeline(ctx);
204
+
205
+ // The code should not be modified by built-in transforms
206
+ expect(result.code).toBe('<pre><code>test</code></pre>');
207
+ expect((result as TransformContext & { customOnly?: boolean }).customOnly).toBe(true);
208
+ });
209
+
210
+ it('should compose async transforms', async () => {
211
+ const asyncTransform1 = async (ctx: TransformContext): Promise<TransformContext> => {
212
+ await new Promise((resolve) => setTimeout(resolve, 5));
213
+ return { ...ctx, code: ctx.code + ':async1' };
214
+ };
215
+
216
+ const asyncTransform2 = async (ctx: TransformContext): Promise<TransformContext> => {
217
+ await new Promise((resolve) => setTimeout(resolve, 5));
218
+ return { ...ctx, code: ctx.code + ':async2' };
219
+ };
220
+
221
+ const pipeline = createCustomPipeline(asyncTransform1, asyncTransform2);
222
+ const ctx = createContext({ code: 'start' });
223
+ const result = await pipeline(ctx);
224
+
225
+ expect(result.code).toBe('start:async1:async2');
226
+ });
227
+
228
+ it('should work with empty transforms', async () => {
229
+ const pipeline = createCustomPipeline();
230
+ const ctx = createContext({ code: 'unchanged' });
231
+ const result = await pipeline(ctx);
232
+
233
+ expect(result.code).toBe('unchanged');
234
+ });
235
+ });
236
+
237
+ describe('standalone usage', () => {
238
+ it('should work without Vite context', async () => {
239
+ // Simulate standalone usage: create context manually and run pipeline
240
+ const ctx = createContext({
241
+ code: `
242
+ import { Fragment } from 'astro/jsx-runtime';
243
+ export default function Content() {
244
+ return <Fragment><h1>Hello</h1><p>World</p></Fragment>;
245
+ }`,
246
+ source: '# Hello\n\nWorld',
247
+ filename: '/standalone/test.md',
248
+ frontmatter: { title: 'Standalone Test' },
249
+ headings: [{ depth: 1, text: 'Hello', slug: 'hello' }],
250
+ });
251
+
252
+ const pipeline = createPipeline();
253
+ const result = await pipeline(ctx);
254
+
255
+ expect(result.code).toBeDefined();
256
+ expect(result.filename).toBe('/standalone/test.md');
257
+ expect(result.frontmatter.title).toBe('Standalone Test');
258
+ });
259
+
260
+ it('should allow custom transforms in standalone mode', async () => {
261
+ const addBanner = (ctx: TransformContext): TransformContext => ({
262
+ ...ctx,
263
+ code: `// Generated by custom pipeline\n${ctx.code}`,
264
+ });
265
+
266
+ const pipeline = createCustomPipeline(addBanner);
267
+
268
+ const ctx = createContext({
269
+ code: 'export default function() {}',
270
+ source: 'test',
271
+ filename: 'test.md',
272
+ });
273
+
274
+ const result = await pipeline(ctx);
275
+
276
+ expect(result.code).toStartWith('// Generated by custom pipeline');
277
+ });
278
+
279
+ it('should support composing multiple custom pipelines', async () => {
280
+ const pipeline1 = createCustomPipeline(
281
+ (ctx: TransformContext) => ({ ...ctx, code: ctx.code + ':p1' })
282
+ );
283
+
284
+ const pipeline2 = createCustomPipeline(
285
+ (ctx: TransformContext) => ({ ...ctx, code: ctx.code + ':p2' })
286
+ );
287
+
288
+ // Compose pipelines manually
289
+ const combinedPipeline = async (ctx: TransformContext): Promise<TransformContext> => {
290
+ const intermediate = await pipeline1(ctx);
291
+ return pipeline2(intermediate);
292
+ };
293
+
294
+ const ctx = createContext({ code: 'start' });
295
+ const result = await combinedPipeline(ctx);
296
+
297
+ expect(result.code).toBe('start:p1:p2');
298
+ });
299
+ });
300
+
301
+ describe('error handling', () => {
302
+ it('should propagate errors from transforms', async () => {
303
+ const failingTransform = (): TransformContext => {
304
+ throw new Error('Transform failed');
305
+ };
306
+
307
+ const pipeline = createCustomPipeline(failingTransform);
308
+ const ctx = createContext({ code: 'test' });
309
+
310
+ await expect(pipeline(ctx)).rejects.toThrow('Transform failed');
311
+ });
312
+
313
+ it('should propagate errors from async transforms', async () => {
314
+ const asyncFailingTransform = async (): Promise<TransformContext> => {
315
+ await new Promise((resolve) => setTimeout(resolve, 5));
316
+ throw new Error('Async transform failed');
317
+ };
318
+
319
+ const pipeline = createCustomPipeline(asyncFailingTransform);
320
+ const ctx = createContext({ code: 'test' });
321
+
322
+ await expect(pipeline(ctx)).rejects.toThrow('Async transform failed');
323
+ });
324
+ });