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
package/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Astro integration for Xmdx - high-performance MDX compiler.
3
+ * Main entry point - re-exports from src/index.ts
4
+ * @module astro-xmdx
5
+ */
6
+
7
+ export { default } from './src/index.js';
8
+ export * from './src/index.js';
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "astro-xmdx",
3
+ "version": "0.0.2",
4
+ "description": "Astro integration for xmdx - high-performance MDX compiler",
5
+ "type": "module",
6
+ "main": "./index.ts",
7
+ "types": "./index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./index.ts",
11
+ "import": "./index.ts",
12
+ "default": "./index.ts"
13
+ },
14
+ "./presets": {
15
+ "types": "./src/presets/index.ts",
16
+ "import": "./src/presets/index.ts",
17
+ "default": "./src/presets/index.ts"
18
+ },
19
+ "./vite-plugin": {
20
+ "types": "./src/vite-plugin.ts",
21
+ "import": "./src/vite-plugin.ts",
22
+ "default": "./src/vite-plugin.ts"
23
+ },
24
+ "./pipeline": {
25
+ "types": "./src/pipeline/index.ts",
26
+ "import": "./src/pipeline/index.ts",
27
+ "default": "./src/pipeline/index.ts"
28
+ },
29
+ "./transforms": {
30
+ "types": "./src/transforms/index.ts",
31
+ "import": "./src/transforms/index.ts",
32
+ "default": "./src/transforms/index.ts"
33
+ }
34
+ },
35
+ "files": [
36
+ "index.ts",
37
+ "src"
38
+ ],
39
+ "scripts": {
40
+ "build": "tsc",
41
+ "test": "bun test",
42
+ "test:watch": "bun test --watch",
43
+ "typecheck": "tsc --noEmit"
44
+ },
45
+ "keywords": [
46
+ "astro",
47
+ "astro-integration",
48
+ "mdx",
49
+ "markdown",
50
+ "xmdx",
51
+ "vite"
52
+ ],
53
+ "author": "jp-knj",
54
+ "license": "MIT",
55
+ "repository": {
56
+ "type": "git",
57
+ "url": "https://github.com/jp-knj/xmdx"
58
+ },
59
+ "peerDependencies": {
60
+ "astro": ">=4.0.0",
61
+ "vite": ">=5.0.0"
62
+ },
63
+ "dependencies": {
64
+ "@mdx-js/mdx": "^3.1.1",
65
+ "glob": "^11.0.0",
66
+ "xmdx": "0.0.2",
67
+ "xmdx-napi": "0.0.2",
68
+ "parse5": "^7.1.2",
69
+ "remark-directive": "^3.0.0",
70
+ "remark-gfm": "^4.0.0",
71
+ "shiki": "^3.20.0"
72
+ },
73
+ "devDependencies": {
74
+ "@types/node": "^22.0.0",
75
+ "esbuild": ">=0.21.0",
76
+ "magic-string": "^0.30.0",
77
+ "rollup": ">=4.0.0",
78
+ "typescript": "^5.7.0"
79
+ }
80
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Shared constants for astro-xmdx
3
+ * @module constants
4
+ */
5
+
6
+ /**
7
+ * Virtual module prefix for Vite module resolution.
8
+ * The null byte prefix ensures these are treated as virtual modules.
9
+ */
10
+ export const VIRTUAL_MODULE_PREFIX = '\0xmdx:';
11
+
12
+ /**
13
+ * File extension for compiled xmdx JSX output.
14
+ */
15
+ export const OUTPUT_EXTENSION = '.xmdx.jsx';
16
+
17
+ /**
18
+ * esbuild configuration for JSX transformation.
19
+ * Used to transform JSX syntax into function calls compatible with Astro's runtime.
20
+ */
21
+ export const ESBUILD_JSX_CONFIG = {
22
+ loader: 'jsx' as const,
23
+ jsx: 'transform' as const,
24
+ jsxFactory: '_jsx',
25
+ jsxFragment: '_Fragment',
26
+ target: 'es2020' as const,
27
+ minify: false,
28
+ } as const;
29
+
30
+ /**
31
+ * Shiki syntax highlighting theme configuration.
32
+ * Uses CSS variables for theming compatibility with Astro.
33
+ */
34
+ export const SHIKI_THEME = {
35
+ /** Theme name used by shiki */
36
+ name: 'astro-code',
37
+ /** CSS variable prefix for syntax highlighting colors */
38
+ variablePrefix: '--astro-code-',
39
+ /** CSS class name added to highlighted code blocks */
40
+ className: 'astro-code',
41
+ } as const;
42
+
43
+ /**
44
+ * Default glob patterns to ignore when scanning for markdown files.
45
+ */
46
+ export const DEFAULT_IGNORE_PATTERNS = ['node_modules/**', 'dist/**'] as const;
47
+
48
+ /**
49
+ * CSS @layer order declaration for Starlight.
50
+ * Injected as inline <style> in dev mode, Head.astro overlay in build.
51
+ */
52
+ export const STARLIGHT_LAYER_ORDER = '@layer starlight.base, starlight.reset, starlight.core, starlight.content, starlight.components, starlight.utils;';
package/src/index.ts ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Astro integration for Xmdx - high-performance MDX compiler.
3
+ * @module astro-xmdx
4
+ */
5
+
6
+ import type { AstroIntegration } from 'astro';
7
+ import type { ComponentLibrary } from 'xmdx/registry';
8
+ import { xmdxPlugin } from './vite-plugin.js';
9
+ import { mergePresets, STARLIGHT_DEFAULT_ALLOW_IMPORTS, type PresetConfig } from './presets/index.js';
10
+ import type { XmdxPlugin, MdxImportHandlingOptions } from './types.js';
11
+
12
+ /**
13
+ * Options for the Xmdx integration.
14
+ */
15
+ export interface XmdxOptions {
16
+ /**
17
+ * File filter function. Defaults to .md and .mdx files.
18
+ */
19
+ include?: (id: string) => boolean;
20
+
21
+ /**
22
+ * Component libraries to register.
23
+ */
24
+ libraries?: ComponentLibrary[];
25
+
26
+ /**
27
+ * Presets to apply. Presets are merged in order.
28
+ */
29
+ presets?: PresetConfig[];
30
+
31
+ /**
32
+ * Enable Starlight component injection.
33
+ */
34
+ starlightComponents?: boolean | {
35
+ enabled: boolean;
36
+ importSource?: string;
37
+ };
38
+
39
+ /**
40
+ * Enable ExpressiveCode block rewriting.
41
+ */
42
+ expressiveCode?: boolean | {
43
+ enabled: boolean;
44
+ componentName?: string;
45
+ importSource?: string;
46
+ };
47
+
48
+ /**
49
+ * Compiler configuration.
50
+ */
51
+ compiler?: {
52
+ jsx?: {
53
+ code_sample_components?: string[];
54
+ };
55
+ };
56
+
57
+ /**
58
+ * Xmdx plugins for transform hooks.
59
+ */
60
+ plugins?: XmdxPlugin[];
61
+
62
+ /**
63
+ * MDX import handling configuration.
64
+ * Controls which imports are allowed vs trigger fallback to @mdx-js/mdx.
65
+ */
66
+ mdx?: MdxImportHandlingOptions;
67
+ }
68
+
69
+ /**
70
+ * Astro integration for Xmdx.
71
+ *
72
+ * @example
73
+ * ```js
74
+ * // astro.config.mjs
75
+ * import { defineConfig } from 'astro/config';
76
+ * import xmdx from 'astro-xmdx';
77
+ *
78
+ * export default defineConfig({
79
+ * integrations: [xmdx()],
80
+ * });
81
+ * ```
82
+ *
83
+ * @example
84
+ * ```js
85
+ * // With presets
86
+ * import xmdx from 'astro-xmdx';
87
+ * import { starlightPreset } from 'astro-xmdx/presets';
88
+ *
89
+ * export default defineConfig({
90
+ * integrations: [
91
+ * xmdx({
92
+ * presets: [starlightPreset()],
93
+ * })
94
+ * ],
95
+ * });
96
+ * ```
97
+ */
98
+ export default function xmdx(options: XmdxOptions = {}): AstroIntegration {
99
+ // Handle presets if provided
100
+ let resolvedOptions = { ...options };
101
+
102
+ if (Array.isArray(options.presets) && options.presets.length > 0) {
103
+ const presetConfig = mergePresets(options.presets);
104
+
105
+ // Apply preset config (user options override preset defaults)
106
+ resolvedOptions = {
107
+ libraries: options.libraries ?? presetConfig.libraries,
108
+ starlightComponents: options.starlightComponents ?? presetConfig.starlightComponents,
109
+ expressiveCode: options.expressiveCode ?? presetConfig.expressiveCode,
110
+ mdx: options.mdx ?? presetConfig.mdx,
111
+ ...options,
112
+ };
113
+
114
+ // Remove presets from final options (not needed by vite plugin)
115
+ delete (resolvedOptions as Record<string, unknown>).presets;
116
+ }
117
+
118
+ // Auto-apply Starlight default allowImports when starlightComponents is enabled
119
+ // This ensures imports like @astrojs/starlight/components don't trigger fallback
120
+ const hasStarlightComponents = resolvedOptions.starlightComponents === true ||
121
+ (typeof resolvedOptions.starlightComponents === 'object' && resolvedOptions.starlightComponents.enabled);
122
+ const hasAllowImports = resolvedOptions.mdx?.allowImports && resolvedOptions.mdx.allowImports.length > 0;
123
+
124
+ if (hasStarlightComponents && !hasAllowImports) {
125
+ resolvedOptions.mdx = {
126
+ ...resolvedOptions.mdx,
127
+ allowImports: [...STARLIGHT_DEFAULT_ALLOW_IMPORTS],
128
+ ignoreCodeFences: resolvedOptions.mdx?.ignoreCodeFences ?? true,
129
+ };
130
+ }
131
+
132
+ return {
133
+ name: 'astro-xmdx',
134
+ hooks: {
135
+ 'astro:config:setup': ({ updateConfig }) => {
136
+ updateConfig({
137
+ vite: {
138
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
139
+ plugins: [xmdxPlugin(resolvedOptions) as any],
140
+ },
141
+ });
142
+ },
143
+ },
144
+ };
145
+ }
146
+
147
+ // Re-export presets for convenience
148
+ export { starlightPreset, expressiveCodePreset, astroPreset, mergePresets } from './presets/index.js';
149
+ export type { PresetConfig } from './presets/index.js';
150
+ export type { XmdxPlugin, TransformContext, PluginHooks, MdxImportHandlingOptions } from './types.js';
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Xmdx transform pipeline - public API
3
+ *
4
+ * This module provides tools for creating and composing transform pipelines.
5
+ * It can be used standalone (without Vite) for processing markdown/MDX files.
6
+ *
7
+ * @module pipeline
8
+ *
9
+ * @example
10
+ * // Standalone usage (without Vite)
11
+ * import { createPipeline, createContext } from 'astro-xmdx/pipeline';
12
+ * import { compile } from 'some-markdown-compiler';
13
+ *
14
+ * const pipeline = createPipeline({
15
+ * afterParse: [myCustomTransform],
16
+ * });
17
+ *
18
+ * const compiled = compile(markdownSource);
19
+ * const ctx = createContext({
20
+ * code: compiled.code,
21
+ * source: markdownSource,
22
+ * filename: '/path/to/file.md',
23
+ * frontmatter: compiled.frontmatter,
24
+ * headings: compiled.headings,
25
+ * });
26
+ *
27
+ * const result = await pipeline(ctx);
28
+ * console.log(result.code);
29
+ */
30
+
31
+ // Pipe utilities for composing transforms
32
+ export { pipe, when, tap } from './pipe.js';
33
+
34
+ // Pipeline orchestrator for creating standard and custom pipelines
35
+ export { createPipeline, createCustomPipeline, createContext } from './orchestrator.js';
36
+
37
+ // Type exports
38
+ export type { TransformContext, TransformConfig, Transform, PipelineOptions } from './types.js';
@@ -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
+ });