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,1342 @@
1
+ /**
2
+ * Xmdx Vite plugin for MDX compilation.
3
+ * @module vite-plugin
4
+ */
5
+
6
+ import { existsSync } from 'node:fs';
7
+ import { readFile, writeFile } from 'node:fs/promises';
8
+ import { createRequire } from 'node:module';
9
+ import path from 'node:path';
10
+ import { transformWithEsbuild, type ResolvedConfig, type Plugin } from 'vite';
11
+ import MagicString from 'magic-string';
12
+ import { build as esbuildBuild, type BuildResult } from 'esbuild';
13
+ import type { SourceMapInput } from 'rollup';
14
+ import { runParallelEsbuild } from './vite-plugin/esbuild-pool.js';
15
+ import {
16
+ createRegistry,
17
+ starlightLibrary,
18
+ astroLibrary,
19
+ expressiveCodeLibrary,
20
+ type ComponentLibrary,
21
+ type Registry,
22
+ } from 'xmdx/registry';
23
+ import { createPipeline } from './pipeline/index.js';
24
+ import { blocksToJsx } from './transforms/blocks-to-jsx.js';
25
+ import { resolveExpressiveCodeConfig } from './utils/config.js';
26
+ import { stripFrontmatter } from './utils/frontmatter.js';
27
+ import { hasProblematicMdxPatterns, detectProblematicMdxPatterns } from './utils/mdx-detection.js';
28
+ import { extractImportStatements } from './utils/imports.js';
29
+ import { stripQuery, deriveFileOptions, shouldCompile } from './utils/paths.js';
30
+ import {
31
+ VIRTUAL_MODULE_PREFIX,
32
+ OUTPUT_EXTENSION,
33
+ ESBUILD_JSX_CONFIG,
34
+ DEFAULT_IGNORE_PATTERNS,
35
+ STARLIGHT_LAYER_ORDER,
36
+ } from './constants.js';
37
+ import type { XmdxPlugin, PluginHooks, TransformContext } from './types.js';
38
+
39
+ // Debug timing utilities
40
+ const DEBUG_TIMING = process.env.XMDX_DEBUG_TIMING === '1';
41
+
42
+ function debugTime(label: string): void {
43
+ if (DEBUG_TIMING) console.time(`[xmdx:timing] ${label}`);
44
+ }
45
+
46
+ function debugTimeEnd(label: string): void {
47
+ if (DEBUG_TIMING) console.timeEnd(`[xmdx:timing] ${label}`);
48
+ }
49
+
50
+ function debugLog(message: string): void {
51
+ if (DEBUG_TIMING) console.log(`[xmdx:timing] ${message}`);
52
+ }
53
+
54
+ // Load hook profiler — activated by XMDX_LOAD_PROFILE=1
55
+ const LOAD_PROFILE = process.env.XMDX_LOAD_PROFILE === '1';
56
+ const LOAD_PROFILE_TOP = Number(process.env.XMDX_LOAD_PROFILE_TOP) || 10;
57
+
58
+ type PhaseStats = { totalMs: number; count: number; maxMs: number };
59
+
60
+ class LoadProfiler {
61
+ phases = new Map<string, PhaseStats>();
62
+ cacheHits = 0;
63
+ esbuildCacheHits = 0;
64
+ cacheMisses = 0;
65
+ callCount = 0;
66
+ totalMs = 0;
67
+ slowest: Array<{ file: string; ms: number }> = [];
68
+ private dumped = false;
69
+ private rootFallback = '';
70
+
71
+ constructor() {
72
+ process.on('exit', () => {
73
+ if (!this.dumped) this.dump(this.rootFallback);
74
+ });
75
+ }
76
+
77
+ setRoot(root: string): void {
78
+ this.rootFallback = root;
79
+ }
80
+
81
+ private ensure(phase: string): PhaseStats {
82
+ let s = this.phases.get(phase);
83
+ if (!s) { s = { totalMs: 0, count: 0, maxMs: 0 }; this.phases.set(phase, s); }
84
+ return s;
85
+ }
86
+
87
+ record(phase: string, ms: number): void {
88
+ const s = this.ensure(phase);
89
+ s.totalMs += ms;
90
+ s.count++;
91
+ if (ms > s.maxMs) s.maxMs = ms;
92
+ }
93
+
94
+ recordFile(file: string, ms: number): void {
95
+ this.callCount++;
96
+ this.totalMs += ms;
97
+ // Keep top-N slowest
98
+ if (this.slowest.length < LOAD_PROFILE_TOP || ms > this.slowest[this.slowest.length - 1]!.ms) {
99
+ this.slowest.push({ file, ms });
100
+ this.slowest.sort((a, b) => b.ms - a.ms);
101
+ if (this.slowest.length > LOAD_PROFILE_TOP) this.slowest.length = LOAD_PROFILE_TOP;
102
+ }
103
+ }
104
+
105
+ dump(root: string): void {
106
+ if (this.dumped) return;
107
+ this.dumped = true;
108
+ const p = (label: string) => `[xmdx:load-profiler] ${label}`;
109
+ console.info(p(`calls=${this.callCount} total=${this.totalMs.toFixed(0)}ms`));
110
+ console.info(p(`esbuild-cache-hit=${this.esbuildCacheHits} compilation-cache-hit=${this.cacheHits} cache-miss=${this.cacheMisses}`));
111
+ for (const [phase, s] of this.phases) {
112
+ console.info(p(`${phase} total=${s.totalMs.toFixed(0)}ms avg=${s.count ? (s.totalMs / s.count).toFixed(2) : 0}ms max=${s.maxMs.toFixed(2)}ms count=${s.count}`));
113
+ }
114
+ const overhead = this.totalMs - [...this.phases.values()].reduce((a, s) => a + s.totalMs, 0);
115
+ console.info(p(`overhead total=${overhead.toFixed(0)}ms avg=${this.callCount ? (overhead / this.callCount).toFixed(2) : 0}ms`));
116
+ if (this.slowest.length > 0) {
117
+ console.info(p(`top ${this.slowest.length} slowest files:`));
118
+ for (const { file, ms } of this.slowest) {
119
+ console.info(p(` ${ms.toFixed(0)}ms ${file.replace(root, '')}`));
120
+ }
121
+ }
122
+ }
123
+ }
124
+
125
+ const loadProfiler = LOAD_PROFILE ? new LoadProfiler() : null;
126
+
127
+ // Import from extracted vite-plugin modules
128
+ import type {
129
+ XmdxBinding,
130
+ XmdxCompiler,
131
+ CompileResult,
132
+ MdxBatchCompileResult,
133
+ XmdxPluginOptions,
134
+ } from './vite-plugin/types.js';
135
+ import { loadXmdxBinding, ENABLE_SHIKI, IS_MDAST } from './vite-plugin/binding-loader.js';
136
+ import { compileFallbackModule } from './vite-plugin/jsx-module.js';
137
+ import { wrapMdxModule } from './vite-plugin/mdx-wrapper.js';
138
+ import { normalizeStarlightComponents } from './vite-plugin/normalize-config.js';
139
+ import { ShikiManager } from './vite-plugin/shiki-manager.js';
140
+
141
+ /**
142
+ * Resolves library configuration from options.
143
+ * Supports both new `libraries` API and legacy `starlightComponents` option.
144
+ */
145
+ export function resolveLibraries(options: XmdxPluginOptions): {
146
+ libraries: ComponentLibrary[];
147
+ registry: Registry;
148
+ } {
149
+ // New API: explicit libraries array
150
+ if (Array.isArray(options.libraries)) {
151
+ const registry = createRegistry(options.libraries);
152
+ return { libraries: options.libraries, registry };
153
+ }
154
+
155
+ // Legacy API: derive libraries from starlightComponents option
156
+ const libraries: ComponentLibrary[] = [astroLibrary];
157
+
158
+ if (options.starlightComponents) {
159
+ libraries.push(starlightLibrary);
160
+ }
161
+
162
+ if (options.expressiveCode) {
163
+ libraries.push(expressiveCodeLibrary);
164
+ }
165
+
166
+ const registry = createRegistry(libraries);
167
+ return { libraries, registry };
168
+ }
169
+
170
+ // require() for CJS interop with glob package
171
+ const require = createRequire(import.meta.url);
172
+
173
+ /**
174
+ * Collects hooks from an array of plugins, organizing them by hook type.
175
+ */
176
+ function collectHooks(plugins: XmdxPlugin[]): PluginHooks {
177
+ const hooks: PluginHooks = {
178
+ afterParse: [],
179
+ beforeInject: [],
180
+ beforeOutput: [],
181
+ preprocess: [],
182
+ };
183
+
184
+ // Sort plugins: 'pre' first, then undefined, then 'post'
185
+ const sorted = [...plugins].sort((a, b) => {
186
+ const order: Record<string, number> = { pre: 0, undefined: 1, post: 2 };
187
+ const aOrder = order[a.enforce ?? 'undefined'] ?? 1;
188
+ const bOrder = order[b.enforce ?? 'undefined'] ?? 1;
189
+ return aOrder - bOrder;
190
+ });
191
+
192
+ for (const plugin of sorted) {
193
+ if (plugin.afterParse) hooks.afterParse.push(plugin.afterParse);
194
+ if (plugin.beforeInject) hooks.beforeInject.push(plugin.beforeInject);
195
+ if (plugin.beforeOutput) hooks.beforeOutput.push(plugin.beforeOutput);
196
+ if (plugin.preprocess) hooks.preprocess.push(plugin.preprocess);
197
+ }
198
+
199
+ return hooks;
200
+ }
201
+
202
+ /**
203
+ * Creates the Xmdx Vite plugin that intercepts `.md`/`.mdx` files
204
+ * before `@astrojs/mdx` runs.
205
+ */
206
+ export function xmdxPlugin(userOptions: XmdxPluginOptions = {}): Plugin {
207
+ let compiler: XmdxCompiler | undefined;
208
+ let resolvedConfig: ResolvedConfig | undefined;
209
+ const sourceLookup = new Map<string, string>();
210
+ type CachedModuleResult = NonNullable<import('./vite-plugin/types.js').ModuleBatchCompileResult['results'][number]['result']> & {
211
+ originalSource?: string;
212
+ processedSource?: string;
213
+ };
214
+ type CachedMdxResult = NonNullable<MdxBatchCompileResult['results'][number]['result']> & {
215
+ originalSource?: string;
216
+ processedSource?: string;
217
+ };
218
+ const originalSourceCache = new Map<string, string>(); // Raw markdown before preprocess hooks
219
+ const processedSourceCache = new Map<string, string>(); // Preprocessed markdown fed to compiler
220
+ const moduleCompilationCache = new Map<string, CachedModuleResult>(); // MD files compiled to modules via Rust
221
+ const mdxCompilationCache = new Map<string, CachedMdxResult>(); // MDX files compiled via mdxjs-rs
222
+ const esbuildCache = new Map<string, { code: string; map?: SourceMapInput }>(); // Pre-compiled esbuild results
223
+ const fallbackFiles = new Set<string>();
224
+
225
+ // Persistent cache for SSR/Client 2-pass builds
226
+ // These survive between buildStart calls, avoiding redundant recompilation
227
+ let buildPassCount = 0;
228
+ const persistentCache = {
229
+ esbuild: new Map<string, { code: string; map?: SourceMapInput }>(),
230
+ moduleCompilation: new Map<string, CachedModuleResult>(),
231
+ mdxCompilation: new Map<string, CachedMdxResult>(),
232
+ fallbackFiles: new Set<string>(),
233
+ fallbackReasons: new Map<string, string>(),
234
+ };
235
+ const fallbackReasons = new Map<string, string>();
236
+ const processedFiles = new Set<string>();
237
+ let totalProcessingTimeMs = 0;
238
+
239
+ const providedBinding = userOptions.binding ?? null;
240
+
241
+ // Collect hooks from plugins
242
+ const plugins = userOptions.plugins ?? [];
243
+ const hooks = collectHooks(plugins);
244
+
245
+ // Create pipeline once (shared across buildStart and load hooks)
246
+ const transformPipeline = createPipeline({
247
+ afterParse: hooks.afterParse,
248
+ beforeInject: hooks.beforeInject,
249
+ beforeOutput: hooks.beforeOutput,
250
+ });
251
+
252
+ // Build compiler options with default code_sample_components
253
+ const compilerOptions = {
254
+ ...(userOptions.compiler ?? {}),
255
+ jsx: {
256
+ ...(userOptions.compiler?.jsx ?? {}),
257
+ code_sample_components:
258
+ userOptions.compiler?.jsx?.code_sample_components ?? ['Code', 'Prism'],
259
+ },
260
+ };
261
+
262
+ const include = userOptions.include ?? shouldCompile;
263
+ const starlightComponents = userOptions.starlightComponents ?? false;
264
+ const expressiveCode = resolveExpressiveCodeConfig(
265
+ userOptions.expressiveCode ?? false
266
+ );
267
+
268
+ // Resolve libraries and create registry
269
+ const { registry } = resolveLibraries(userOptions);
270
+
271
+ // Track whether Starlight is configured for gating default directive handling
272
+ const hasStarlightConfigured = Boolean(userOptions.starlightComponents) ||
273
+ (Array.isArray(userOptions.libraries) &&
274
+ userOptions.libraries.some(lib => lib === starlightLibrary));
275
+
276
+ // MDX import handling options
277
+ const mdxOptions = userOptions.mdx;
278
+
279
+ const unwrapVirtual = (value: string | undefined): string | undefined =>
280
+ value && value.startsWith(VIRTUAL_MODULE_PREFIX)
281
+ ? value.slice(VIRTUAL_MODULE_PREFIX.length)
282
+ : value;
283
+
284
+ // Enable Shiki when:
285
+ // 1. XMDX_SHIKI=1 env var is set, OR
286
+ // 2. ExpressiveCode is explicitly disabled (fallback highlighting)
287
+ const shikiManager = new ShikiManager(ENABLE_SHIKI || !expressiveCode);
288
+
289
+ // Lazy compiler initialization to avoid Vite module runner timing issues
290
+ const getCompiler = async (): Promise<XmdxCompiler> => {
291
+ if (!compiler) {
292
+ const binding = providedBinding ?? (await loadXmdxBinding());
293
+ const createCompiler = binding.createCompiler
294
+ ? binding.createCompiler
295
+ : (cfg: Record<string, unknown>) => new binding.XmdxCompiler!(cfg);
296
+ compiler = createCompiler(compilerOptions);
297
+ }
298
+ return compiler;
299
+ };
300
+
301
+ return {
302
+ name: 'vite-plugin-xmdx',
303
+ enforce: 'pre',
304
+
305
+ configResolved(config) {
306
+ resolvedConfig = config;
307
+ loadProfiler?.setRoot(config.root);
308
+ if (config.esbuild == null) {
309
+ (config as { esbuild: object }).esbuild = {
310
+ jsx: 'automatic',
311
+ jsxImportSource: 'astro',
312
+ };
313
+ } else if (config.esbuild !== false) {
314
+ const esbuildConfig = config.esbuild as Record<string, unknown>;
315
+ if (esbuildConfig.jsx == null) {
316
+ esbuildConfig.jsx = 'automatic';
317
+ }
318
+ if (esbuildConfig.jsxImportSource == null) {
319
+ esbuildConfig.jsxImportSource = 'astro';
320
+ }
321
+ }
322
+ // Ensure native binding is treated as external to avoid Vite SSR runner involvement
323
+ const optimizeDeps = (config as Record<string, any>).optimizeDeps ?? {};
324
+ const exclude: string[] = optimizeDeps.exclude ?? [];
325
+ if (!exclude.includes('xmdx-napi')) {
326
+ exclude.push('xmdx-napi');
327
+ }
328
+ optimizeDeps.exclude = exclude;
329
+ (config as Record<string, any>).optimizeDeps = optimizeDeps;
330
+
331
+ const ssr = (config as Record<string, any>).ssr ?? {};
332
+ const ssrExternal: string[] = ssr.external ?? [];
333
+ if (!ssrExternal.includes('xmdx-napi')) {
334
+ ssrExternal.push('xmdx-napi');
335
+ }
336
+ ssr.external = ssrExternal;
337
+ (config as Record<string, any>).ssr = ssr;
338
+ // Note: Binding/compiler initialization deferred to buildStart/load hooks
339
+ // to avoid Vite module runner timing issues with async imports
340
+ },
341
+
342
+ transform(code, id) {
343
+ // Dev mode only — build uses Head.astro overlay for layer ordering.
344
+ if (resolvedConfig?.command !== 'serve' || !hasStarlightConfigured) return;
345
+ // Target .astro files containing <head> (root layouts like Page.astro)
346
+ if (!id.endsWith('.astro') || !code.includes('<head>')) return;
347
+
348
+ const ms = new MagicString(code, { filename: id });
349
+ ms.replace('<head>', `<head><style is:inline>${STARLIGHT_LAYER_ORDER}</style>`);
350
+ return {
351
+ code: ms.toString(),
352
+ map: ms.generateMap({ hires: 'boundary' }),
353
+ };
354
+ },
355
+
356
+ async buildStart() {
357
+ // Only batch compile in build mode (not dev/serve)
358
+ if (resolvedConfig?.command !== 'build') return;
359
+
360
+ buildPassCount++;
361
+ debugLog(`Build pass ${buildPassCount}`);
362
+
363
+ // Pass 2+: Reuse cached results from previous build pass (SSR → Client)
364
+ if (buildPassCount > 1 && persistentCache.esbuild.size > 0) {
365
+ debugTime('buildStart:total');
366
+ debugLog(`Reusing ${persistentCache.esbuild.size} cached esbuild results from pass ${buildPassCount - 1}`);
367
+
368
+ // Restore all caches from persistent storage
369
+ for (const [k, v] of persistentCache.esbuild) {
370
+ esbuildCache.set(k, v);
371
+ }
372
+ for (const [k, v] of persistentCache.moduleCompilation) {
373
+ moduleCompilationCache.set(k, v);
374
+ }
375
+ for (const [k, v] of persistentCache.mdxCompilation) {
376
+ mdxCompilationCache.set(k, v);
377
+ }
378
+ for (const file of persistentCache.fallbackFiles) {
379
+ fallbackFiles.add(file);
380
+ }
381
+ for (const [k, v] of persistentCache.fallbackReasons) {
382
+ fallbackReasons.set(k, v);
383
+ }
384
+
385
+ console.info(
386
+ `[xmdx] Build pass ${buildPassCount}: Reusing ${persistentCache.esbuild.size} cached results`
387
+ );
388
+ debugTimeEnd('buildStart:total');
389
+ return;
390
+ }
391
+
392
+ // Check for potential cache inconsistency
393
+ const root = resolvedConfig.root;
394
+ const astroDir = path.join(root, '.astro');
395
+ const distDir = path.join(root, 'dist');
396
+ if (existsSync(astroDir) && !existsSync(distDir)) {
397
+ console.warn('[xmdx] Stale cache detected (.astro exists but dist does not). Consider running `rm -rf .astro` if you encounter module resolution errors.');
398
+ }
399
+
400
+ debugTime('buildStart:total');
401
+ debugTime('buildStart:glob');
402
+
403
+ // Find all MD/MDX files (use CJS require to avoid Vite's module runner)
404
+ const { glob } = require('glob') as {
405
+ glob: (
406
+ pattern: string,
407
+ options: { cwd: string; ignore: string[]; absolute: boolean }
408
+ ) => Promise<string[]>;
409
+ };
410
+ const files = await glob('**/*.{md,mdx}', {
411
+ cwd: resolvedConfig.root,
412
+ ignore: [...DEFAULT_IGNORE_PATTERNS],
413
+ absolute: true,
414
+ });
415
+
416
+ debugTimeEnd('buildStart:glob');
417
+ debugLog(`Found ${files.length} markdown files`);
418
+
419
+ if (files.length === 0) {
420
+ debugTimeEnd('buildStart:total');
421
+ return;
422
+ }
423
+
424
+ debugTime('buildStart:readFiles');
425
+
426
+ // Track fallback pattern statistics
427
+ const fallbackStats = {
428
+ disallowedImports: 0,
429
+ noAllowImports: 0,
430
+ };
431
+ const disallowedImportSources = new Map<string, number>();
432
+
433
+ // Read all files in parallel and prepare batch inputs
434
+ const inputsOrNull = await Promise.all(
435
+ files.map(async (file) => {
436
+ const rawSource = await readFile(file, 'utf8');
437
+ let processedSource = rawSource;
438
+
439
+ // Apply preprocess hooks (same as load hook does)
440
+ for (const preprocessHook of hooks.preprocess) {
441
+ processedSource = preprocessHook(processedSource, file);
442
+ }
443
+
444
+ // Pre-detect problematic patterns - these files will be handled by Astro's MDX plugin
445
+ const detection = detectProblematicMdxPatterns(processedSource, mdxOptions);
446
+ if (detection.hasProblematicPatterns) {
447
+ fallbackFiles.add(file);
448
+ fallbackReasons.set(file, detection.reason ?? 'Unknown pattern');
449
+
450
+ // Track statistics
451
+ if (detection.disallowedImports && detection.disallowedImports.length > 0) {
452
+ fallbackStats.disallowedImports++;
453
+ for (const src of detection.disallowedImports) {
454
+ disallowedImportSources.set(src, (disallowedImportSources.get(src) ?? 0) + 1);
455
+ }
456
+ } else if (detection.allImports && detection.allImports.length > 0) {
457
+ fallbackStats.noAllowImports++;
458
+ }
459
+
460
+ return null;
461
+ }
462
+
463
+ originalSourceCache.set(file, rawSource); // For TransformContext.source
464
+ processedSourceCache.set(file, processedSource); // For potential reuse in cache fast path
465
+ return { id: file, source: processedSource, filepath: file };
466
+ })
467
+ );
468
+
469
+ debugTimeEnd('buildStart:readFiles');
470
+
471
+ const inputs = inputsOrNull.filter(
472
+ (i): i is NonNullable<typeof i> => i !== null
473
+ );
474
+
475
+ if (fallbackFiles.size > 0) {
476
+ const breakdown: string[] = [];
477
+ if (fallbackStats.disallowedImports > 0) {
478
+ breakdown.push(`${fallbackStats.disallowedImports} with disallowed imports`);
479
+ }
480
+
481
+ console.info(
482
+ `[xmdx] Pre-detected ${fallbackFiles.size} files with patterns incompatible with markdown-rs (delegating to Astro MDX)` +
483
+ (breakdown.length > 0 ? ` [${breakdown.join(', ')}]` : '')
484
+ );
485
+
486
+ // Log top disallowed import sources for debugging when many files fallback
487
+ if (disallowedImportSources.size > 0 && fallbackFiles.size >= 10) {
488
+ const topSources = Array.from(disallowedImportSources.entries())
489
+ .sort((a, b) => b[1] - a[1])
490
+ .slice(0, 5)
491
+ .map(([src, count]) => `${src} (${count})`);
492
+ console.info(
493
+ `[xmdx] Top disallowed import sources: ${topSources.join(', ')}`
494
+ );
495
+ console.info(
496
+ `[xmdx] Tip: Add these to your preset's allowImports to reduce fallback rate`
497
+ );
498
+ }
499
+ }
500
+
501
+ if (inputs.length === 0) {
502
+ debugTimeEnd('buildStart:total');
503
+ return;
504
+ }
505
+
506
+ try {
507
+ debugTime('buildStart:batchCompile');
508
+ debugTime('buildStart:shikiInit');
509
+
510
+ // Start Shiki init in parallel with batch compile (Shiki doesn't depend on compile results)
511
+ const shikiPromise = shikiManager.init();
512
+
513
+ // Separate MD and MDX files for different compilation paths
514
+ const mdInputs = inputs.filter(i => !i.filepath?.endsWith('.mdx'));
515
+ const mdxInputs = inputs.filter(i => i.filepath?.endsWith('.mdx'));
516
+
517
+ debugLog(`Separated: ${mdInputs.length} MD files, ${mdxInputs.length} MDX files`);
518
+
519
+ // Batch compile with parallel processing
520
+ const binding = providedBinding ?? (await loadXmdxBinding());
521
+
522
+ // Compile MD files to complete Astro modules via Rust (no TypeScript wrapping needed)
523
+ let mdStats = { succeeded: 0, total: 0, failed: 0, processingTimeMs: 0 };
524
+ if (mdInputs.length > 0) {
525
+ const mdBatchResult = binding.compileBatchToModule(mdInputs, {
526
+ continueOnError: true,
527
+ config: compilerOptions,
528
+ });
529
+ mdStats = mdBatchResult.stats;
530
+
531
+ // Cache MD module results
532
+ for (const result of mdBatchResult.results) {
533
+ if (result.result) {
534
+ moduleCompilationCache.set(result.id, {
535
+ ...result.result,
536
+ originalSource: originalSourceCache.get(result.id),
537
+ processedSource: processedSourceCache.get(result.id),
538
+ });
539
+ } else if (result.error) {
540
+ // Track compilation failures for fallback
541
+ fallbackFiles.add(result.id);
542
+ fallbackReasons.set(result.id, result.error);
543
+ }
544
+ }
545
+ }
546
+
547
+ // Compile MDX files with mdxjs-rs
548
+ let mdxStats = { succeeded: 0, total: 0, failed: 0, processingTimeMs: 0 };
549
+ if (mdxInputs.length > 0) {
550
+ const mdxBatchResult = binding.compileMdxBatch(mdxInputs, {
551
+ continueOnError: true,
552
+ config: compilerOptions,
553
+ });
554
+ mdxStats = mdxBatchResult.stats;
555
+
556
+ // Cache MDX results
557
+ for (const result of mdxBatchResult.results) {
558
+ if (result.result) {
559
+ mdxCompilationCache.set(result.id, {
560
+ ...result.result,
561
+ originalSource: originalSourceCache.get(result.id),
562
+ processedSource: processedSourceCache.get(result.id),
563
+ });
564
+ } else if (result.error) {
565
+ // Track MDX compilation failures for fallback
566
+ fallbackFiles.add(result.id);
567
+ fallbackReasons.set(result.id, result.error);
568
+ }
569
+ }
570
+ }
571
+
572
+ debugTimeEnd('buildStart:batchCompile');
573
+
574
+ const totalSucceeded = mdStats.succeeded + mdxStats.succeeded;
575
+ const totalFiles = mdStats.total + mdxStats.total;
576
+ const totalTime = mdStats.processingTimeMs + mdxStats.processingTimeMs;
577
+
578
+ console.info(
579
+ `[xmdx] Batch compiled ${totalSucceeded}/${totalFiles} files in ${totalTime.toFixed(0)}ms` +
580
+ (mdxInputs.length > 0 ? ` (${mdxStats.succeeded} MDX via mdxjs-rs)` : '')
581
+ );
582
+
583
+ // Batch esbuild transformation for fast-path eligible files
584
+ const esbuildStartTime = performance.now();
585
+ const jsxInputs: Array<{ id: string; virtualId: string; jsx: string }> = [];
586
+
587
+ // Wait for Shiki initialization (started in parallel with batch compile)
588
+ const resolvedShiki = await shikiPromise;
589
+ debugTimeEnd('buildStart:shikiInit');
590
+
591
+ // Normalize starlightComponents for TransformContext
592
+ const normalizedStarlightComponents = normalizeStarlightComponents(starlightComponents);
593
+
594
+ debugTime('buildStart:pipelineProcessing');
595
+
596
+ // Collect all compiled entries for batch esbuild processing
597
+ // MD files: complete modules from Rust (no TypeScript wrapping)
598
+ // MDX files: wrapped via wrapMdxModule (mdxjs-rs output)
599
+ const mdModuleEntries: Array<[string, CachedModuleResult]> = [];
600
+ for (const [filename, cached] of moduleCompilationCache) {
601
+ mdModuleEntries.push([filename, cached]);
602
+ }
603
+
604
+ // MDX files use the mdxjs-rs output directly
605
+ const mdxFastPathEntries: Array<[string, CachedMdxResult]> = [];
606
+ for (const [filename, cached] of mdxCompilationCache) {
607
+ mdxFastPathEntries.push([filename, cached]);
608
+ }
609
+
610
+ // Process MD module entries in chunks (complete modules from Rust)
611
+ const PIPELINE_CHUNK_SIZE = 50;
612
+ for (let i = 0; i < mdModuleEntries.length; i += PIPELINE_CHUNK_SIZE) {
613
+ const chunk = mdModuleEntries.slice(i, i + PIPELINE_CHUNK_SIZE);
614
+ const chunkResults = await Promise.all(
615
+ chunk.map(async ([filename, cached]) => {
616
+ // Parse frontmatter
617
+ let frontmatter: Record<string, unknown> = {};
618
+ if (cached.frontmatterJson) {
619
+ try {
620
+ frontmatter = JSON.parse(cached.frontmatterJson) as Record<string, unknown>;
621
+ } catch {
622
+ frontmatter = {};
623
+ }
624
+ }
625
+ const headings = cached.headings || [];
626
+
627
+ // Use complete module code from Rust (no wrapHtmlInJsxModule needed)
628
+ const jsxCode = cached.code;
629
+
630
+ // Get source for hooks
631
+ const sourceForHooks =
632
+ originalSourceCache.get(filename) ??
633
+ cached.originalSource ??
634
+ processedSourceCache.get(filename) ??
635
+ cached.processedSource ??
636
+ '';
637
+
638
+ // Create transform context and run pipeline
639
+ const ctx: TransformContext = {
640
+ code: jsxCode,
641
+ source: sourceForHooks,
642
+ filename,
643
+ frontmatter,
644
+ headings,
645
+ registry,
646
+ config: {
647
+ expressiveCode,
648
+ starlightComponents: normalizedStarlightComponents,
649
+ shiki: shikiManager.forCode(jsxCode, resolvedShiki),
650
+ },
651
+ };
652
+
653
+ const transformed = await transformPipeline(ctx);
654
+ const virtualId = `${VIRTUAL_MODULE_PREFIX}${filename}${OUTPUT_EXTENSION}`;
655
+ return { id: filename, virtualId, jsx: transformed.code };
656
+ })
657
+ );
658
+ jsxInputs.push(...chunkResults);
659
+ }
660
+
661
+ // Process MDX files in chunks (mdxjs-rs output is already JavaScript)
662
+ for (let i = 0; i < mdxFastPathEntries.length; i += PIPELINE_CHUNK_SIZE) {
663
+ const chunk = mdxFastPathEntries.slice(i, i + PIPELINE_CHUNK_SIZE);
664
+ const chunkResults = await Promise.all(
665
+ chunk.map(async ([filename, cached]) => {
666
+ // Parse frontmatter
667
+ let frontmatter: Record<string, unknown> = {};
668
+ if (cached.frontmatterJson) {
669
+ try {
670
+ frontmatter = JSON.parse(cached.frontmatterJson) as Record<string, unknown>;
671
+ } catch {
672
+ frontmatter = {};
673
+ }
674
+ }
675
+ const headings = cached.headings || [];
676
+
677
+ // Wrap MDX output in Astro component format
678
+ const jsxCode = wrapMdxModule(cached.code, {
679
+ frontmatter,
680
+ headings,
681
+ registry,
682
+ }, filename);
683
+
684
+ // Get source for hooks
685
+ const sourceForHooks =
686
+ originalSourceCache.get(filename) ??
687
+ cached.originalSource ??
688
+ processedSourceCache.get(filename) ??
689
+ cached.processedSource ??
690
+ '';
691
+
692
+ // Create transform context and run pipeline
693
+ const ctx: TransformContext = {
694
+ code: jsxCode,
695
+ source: sourceForHooks,
696
+ filename,
697
+ frontmatter,
698
+ headings,
699
+ registry,
700
+ config: {
701
+ expressiveCode,
702
+ starlightComponents: normalizedStarlightComponents,
703
+ shiki: shikiManager.forCode(jsxCode, resolvedShiki),
704
+ },
705
+ };
706
+
707
+ const transformed = await transformPipeline(ctx);
708
+ const virtualId = `${VIRTUAL_MODULE_PREFIX}${filename}${OUTPUT_EXTENSION}`;
709
+ return { id: filename, virtualId, jsx: transformed.code };
710
+ })
711
+ );
712
+ jsxInputs.push(...chunkResults);
713
+ }
714
+
715
+ debugTimeEnd('buildStart:pipelineProcessing');
716
+ debugLog(`Pipeline processed ${jsxInputs.length} files for esbuild batch (${mdModuleEntries.length} MD modules, ${mdxFastPathEntries.length} MDX)`);
717
+
718
+ if (jsxInputs.length > 0) {
719
+ debugTime('buildStart:esbuild');
720
+
721
+ // Batch transform all JSX through esbuild
722
+ // Use parallel workers for large batches (>= 100 files)
723
+ try {
724
+ const useParallel = jsxInputs.length >= 100;
725
+
726
+ let usedParallel = false;
727
+ if (useParallel) {
728
+ // Parallel worker-based esbuild for large batches
729
+ try {
730
+ debugLog(`Using parallel esbuild workers for ${jsxInputs.length} files`);
731
+ const parallelResults = await runParallelEsbuild(
732
+ jsxInputs.map((input) => ({ id: input.id, jsx: input.jsx }))
733
+ );
734
+ for (const [id, result] of parallelResults) {
735
+ esbuildCache.set(id, { code: result.code, map: result.map as SourceMapInput });
736
+ }
737
+ usedParallel = true;
738
+ } catch (workerErr) {
739
+ // Workers failed - fall through to single-threaded mode
740
+ debugLog(`Worker esbuild failed, falling back to single-threaded: ${workerErr}`);
741
+ }
742
+ }
743
+
744
+ if (!usedParallel) {
745
+ // Single-threaded esbuild for small batches (lower overhead)
746
+ const entryMap = new Map<string, { id: string; jsx: string }>();
747
+ for (let i = 0; i < jsxInputs.length; i++) {
748
+ const entry = `entry${i}.jsx`;
749
+ const input = jsxInputs[i]!;
750
+ entryMap.set(entry, { id: input.id, jsx: input.jsx });
751
+ }
752
+
753
+ const result: BuildResult = await esbuildBuild({
754
+ write: false,
755
+ bundle: false,
756
+ format: 'esm',
757
+ sourcemap: 'external',
758
+ loader: { '.jsx': 'jsx' },
759
+ jsx: 'transform',
760
+ jsxFactory: '_jsx',
761
+ jsxFragment: '_Fragment',
762
+ entryPoints: Array.from(entryMap.keys()),
763
+ outdir: 'out',
764
+ plugins: [
765
+ {
766
+ name: 'xmdx-virtual-jsx',
767
+ setup(build) {
768
+ build.onResolve({ filter: /^entry\d+\.jsx$/ }, (args) => {
769
+ return { path: args.path, namespace: 'xmdx-jsx' };
770
+ });
771
+ build.onResolve({ filter: /.*/ }, (args) => {
772
+ return { path: args.path, external: true };
773
+ });
774
+ build.onLoad({ filter: /.*/, namespace: 'xmdx-jsx' }, (args) => {
775
+ const entry = entryMap.get(args.path);
776
+ return entry ? { contents: entry.jsx, loader: 'jsx' } : null;
777
+ });
778
+ },
779
+ },
780
+ ],
781
+ });
782
+
783
+ for (const output of result.outputFiles || []) {
784
+ const basename = path.basename(output.path);
785
+ if (basename.endsWith('.map')) continue;
786
+ const entryName = basename.replace(/\.js$/, '.jsx');
787
+ const entry = entryMap.get(entryName);
788
+ if (entry) {
789
+ const mapOutput = result.outputFiles?.find((o) => o.path === output.path + '.map');
790
+ esbuildCache.set(entry.id, {
791
+ code: output.text,
792
+ map: mapOutput?.text as SourceMapInput | undefined,
793
+ });
794
+ }
795
+ }
796
+ }
797
+
798
+ const esbuildEndTime = performance.now();
799
+ console.info(
800
+ `[xmdx] Batch esbuild transformed ${esbuildCache.size} files in ${(esbuildEndTime - esbuildStartTime).toFixed(0)}ms` +
801
+ (usedParallel ? ' (parallel workers)' : '')
802
+ );
803
+
804
+ debugTimeEnd('buildStart:esbuild');
805
+ } catch (esbuildErr) {
806
+ debugTimeEnd('buildStart:esbuild');
807
+ this.warn(
808
+ `[xmdx] Batch esbuild failed, will use individual transforms: ${esbuildErr}`
809
+ );
810
+ }
811
+ }
812
+
813
+ // Persist caches for subsequent build passes (SSR → Client)
814
+ for (const [k, v] of esbuildCache) {
815
+ persistentCache.esbuild.set(k, v);
816
+ }
817
+ for (const [k, v] of moduleCompilationCache) {
818
+ persistentCache.moduleCompilation.set(k, v);
819
+ }
820
+ for (const [k, v] of mdxCompilationCache) {
821
+ persistentCache.mdxCompilation.set(k, v);
822
+ }
823
+ for (const file of fallbackFiles) {
824
+ persistentCache.fallbackFiles.add(file);
825
+ }
826
+ for (const [k, v] of fallbackReasons) {
827
+ persistentCache.fallbackReasons.set(k, v);
828
+ }
829
+ debugLog(`Persisted ${persistentCache.esbuild.size} esbuild results for subsequent passes`);
830
+
831
+ debugTimeEnd('buildStart:total');
832
+ } catch (err) {
833
+ debugTimeEnd('buildStart:total');
834
+ this.warn(
835
+ `[xmdx] Batch compile skipped due to binding load failure: ${err}`
836
+ );
837
+ }
838
+ },
839
+
840
+ async resolveId(sourceId, importer) {
841
+ if (sourceId.startsWith(VIRTUAL_MODULE_PREFIX)) {
842
+ return sourceId;
843
+ }
844
+ const normalizedImporter = stripQuery(unwrapVirtual(importer) ?? '');
845
+ const normalizedSource = unwrapVirtual(sourceId) ?? sourceId;
846
+ const cleanId = stripQuery(normalizedSource);
847
+ if (!include(cleanId)) {
848
+ if (
849
+ importer?.startsWith(VIRTUAL_MODULE_PREFIX) &&
850
+ normalizedImporter &&
851
+ !path.isAbsolute(sourceId) &&
852
+ sourceId.startsWith('.')
853
+ ) {
854
+ return path.resolve(path.dirname(normalizedImporter), sourceId);
855
+ }
856
+ return null;
857
+ }
858
+ const resolved = await this.resolve(cleanId, normalizedImporter, {
859
+ skipSelf: true,
860
+ });
861
+ const fallback = (): string => {
862
+ if (path.isAbsolute(cleanId)) {
863
+ return cleanId;
864
+ }
865
+ if (normalizedImporter) {
866
+ return path.resolve(path.dirname(normalizedImporter), cleanId);
867
+ }
868
+ return cleanId;
869
+ };
870
+ const resolvedId =
871
+ resolved && resolved.id
872
+ ? stripQuery(unwrapVirtual(resolved.id) ?? resolved.id)
873
+ : fallback();
874
+
875
+ // Pre-detected fallback files should be handled by Astro's MDX plugin
876
+ // which has proper remark-directive support and user-configured plugins
877
+ if (fallbackFiles.has(resolvedId)) {
878
+ return null;
879
+ }
880
+
881
+ // Dev mode pre-detection: check if file needs fallback before returning virtualId
882
+ // This ensures dev mode delegates problematic files to Astro MDX just like build mode does
883
+ if (resolvedConfig?.command !== 'build') {
884
+ try {
885
+ const source = await readFile(resolvedId, 'utf8');
886
+ let processedSource = source;
887
+ for (const preprocessHook of hooks.preprocess) {
888
+ processedSource = preprocessHook(processedSource, resolvedId);
889
+ }
890
+ const detection = detectProblematicMdxPatterns(processedSource, mdxOptions);
891
+ if (detection.hasProblematicPatterns) {
892
+ fallbackFiles.add(resolvedId);
893
+ fallbackReasons.set(resolvedId, detection.reason ?? 'Pre-detected problematic MDX patterns (dev mode)');
894
+ return null; // Delegate to Astro's MDX plugin
895
+ }
896
+ } catch {
897
+ // File read failed, let normal path handle it
898
+ }
899
+ }
900
+
901
+ const virtualId = `${VIRTUAL_MODULE_PREFIX}${resolvedId}${OUTPUT_EXTENSION}`;
902
+ sourceLookup.set(virtualId, resolvedId);
903
+ return virtualId;
904
+ },
905
+
906
+ async load(id) {
907
+ if (!id.startsWith(VIRTUAL_MODULE_PREFIX)) {
908
+ return null;
909
+ }
910
+ const filename =
911
+ sourceLookup.get(id) ??
912
+ stripQuery(id.slice(VIRTUAL_MODULE_PREFIX.length).replace(new RegExp(`${OUTPUT_EXTENSION.replace('.', '\\.')}$`), ''));
913
+
914
+ try {
915
+ // FASTEST PATH: Check esbuild cache first (O(1) lookup, populated in buildStart)
916
+ const loadStart = LOAD_PROFILE ? performance.now() : 0;
917
+ const cachedEsbuildResult = esbuildCache.get(filename);
918
+ if (cachedEsbuildResult) {
919
+ processedFiles.add(filename);
920
+ if (loadProfiler) {
921
+ const elapsed = performance.now() - loadStart;
922
+ loadProfiler.esbuildCacheHits++;
923
+ loadProfiler.recordFile(filename, elapsed);
924
+ }
925
+ return cachedEsbuildResult;
926
+ }
927
+
928
+ // Check cache FIRST, before any file I/O (populated in build mode by buildStart)
929
+ const cachedModule = moduleCompilationCache.get(filename);
930
+ const cachedMdx = mdxCompilationCache.get(filename);
931
+ const isMdx = filename.endsWith('.mdx');
932
+
933
+ // FAST PATH: MD files with complete modules from Rust
934
+ if (cachedModule && !isMdx) {
935
+ const startTime = performance.now();
936
+ let frontmatter: Record<string, unknown> = {};
937
+ if (cachedModule.frontmatterJson) {
938
+ try {
939
+ frontmatter = JSON.parse(cachedModule.frontmatterJson) as Record<string, unknown>;
940
+ } catch {
941
+ frontmatter = {};
942
+ }
943
+ }
944
+ const headings = cachedModule.headings || [];
945
+
946
+ // Use complete module code from Rust (no wrapHtmlInJsxModule needed)
947
+ const result: CompileResult = {
948
+ code: cachedModule.code,
949
+ map: null,
950
+ frontmatter_json: cachedModule.frontmatterJson,
951
+ headings,
952
+ imports: [],
953
+ };
954
+
955
+ const endTime = performance.now();
956
+ totalProcessingTimeMs += endTime - startTime;
957
+ processedFiles.add(filename);
958
+
959
+ const normalizedStarlightComponents = normalizeStarlightComponents(starlightComponents);
960
+ const sourceForHooks =
961
+ originalSourceCache.get(filename) ??
962
+ cachedModule.originalSource ??
963
+ processedSourceCache.get(filename) ??
964
+ cachedModule.processedSource ??
965
+ (await readFile(filename, 'utf8'));
966
+ const ctx: TransformContext = {
967
+ code: result.code,
968
+ source: sourceForHooks,
969
+ filename,
970
+ frontmatter,
971
+ headings,
972
+ registry,
973
+ config: {
974
+ expressiveCode,
975
+ starlightComponents: normalizedStarlightComponents,
976
+ shiki: await shikiManager.getFor(result.code),
977
+ },
978
+ };
979
+
980
+ const tpStart = LOAD_PROFILE ? performance.now() : 0;
981
+ const transformed = await transformPipeline(ctx);
982
+ result.code = transformed.code;
983
+ if (loadProfiler) loadProfiler.record('transform-pipeline', performance.now() - tpStart);
984
+
985
+ const esStart = LOAD_PROFILE ? performance.now() : 0;
986
+ const esbuildResult = await transformWithEsbuild(result.code, id, ESBUILD_JSX_CONFIG);
987
+ if (loadProfiler) loadProfiler.record('esbuild', performance.now() - esStart);
988
+
989
+ if (loadProfiler) {
990
+ const elapsed = performance.now() - loadStart;
991
+ loadProfiler.cacheHits++;
992
+ loadProfiler.recordFile(filename, elapsed);
993
+ }
994
+
995
+ return {
996
+ code: esbuildResult.code,
997
+ map: esbuildResult.map ?? result.map ?? undefined,
998
+ };
999
+ }
1000
+
1001
+ // FAST PATH: MDX files compiled via mdxjs-rs
1002
+ if (cachedMdx && isMdx) {
1003
+ const startTime = performance.now();
1004
+ let frontmatter: Record<string, unknown> = {};
1005
+ if (cachedMdx.frontmatterJson) {
1006
+ try {
1007
+ frontmatter = JSON.parse(cachedMdx.frontmatterJson) as Record<string, unknown>;
1008
+ } catch {
1009
+ frontmatter = {};
1010
+ }
1011
+ }
1012
+ const headings = cachedMdx.headings || [];
1013
+
1014
+ // Wrap MDX output in Astro component format
1015
+ const jsxCode = wrapMdxModule(cachedMdx.code, {
1016
+ frontmatter,
1017
+ headings,
1018
+ registry,
1019
+ }, filename);
1020
+
1021
+ const result: CompileResult = {
1022
+ code: jsxCode,
1023
+ map: null,
1024
+ frontmatter_json: cachedMdx.frontmatterJson,
1025
+ headings,
1026
+ imports: [],
1027
+ };
1028
+
1029
+ const endTime = performance.now();
1030
+ totalProcessingTimeMs += endTime - startTime;
1031
+ processedFiles.add(filename);
1032
+
1033
+ const normalizedStarlightComponents = normalizeStarlightComponents(starlightComponents);
1034
+ const sourceForHooks =
1035
+ originalSourceCache.get(filename) ??
1036
+ cachedMdx.originalSource ??
1037
+ processedSourceCache.get(filename) ??
1038
+ cachedMdx.processedSource ??
1039
+ (await readFile(filename, 'utf8'));
1040
+ const ctx: TransformContext = {
1041
+ code: result.code,
1042
+ source: sourceForHooks,
1043
+ filename,
1044
+ frontmatter,
1045
+ headings,
1046
+ registry,
1047
+ config: {
1048
+ expressiveCode,
1049
+ starlightComponents: normalizedStarlightComponents,
1050
+ shiki: await shikiManager.getFor(result.code),
1051
+ },
1052
+ };
1053
+
1054
+ const tpStart = LOAD_PROFILE ? performance.now() : 0;
1055
+ const transformed = await transformPipeline(ctx);
1056
+ result.code = transformed.code;
1057
+ if (loadProfiler) loadProfiler.record('transform-pipeline', performance.now() - tpStart);
1058
+
1059
+ const esStart = LOAD_PROFILE ? performance.now() : 0;
1060
+ const esbuildResult = await transformWithEsbuild(result.code, id, ESBUILD_JSX_CONFIG);
1061
+ if (loadProfiler) loadProfiler.record('esbuild', performance.now() - esStart);
1062
+
1063
+ if (loadProfiler) {
1064
+ const elapsed = performance.now() - loadStart;
1065
+ loadProfiler.cacheHits++;
1066
+ loadProfiler.recordFile(filename, elapsed);
1067
+ }
1068
+
1069
+ return {
1070
+ code: esbuildResult.code,
1071
+ map: esbuildResult.map ?? result.map ?? undefined,
1072
+ };
1073
+ }
1074
+
1075
+ // Lazy initialize compiler on first use (only needed for cache miss path)
1076
+ if (loadProfiler) loadProfiler.cacheMisses++;
1077
+ const currentCompiler = await getCompiler();
1078
+
1079
+ // Only read file if cache miss
1080
+ const source = await readFile(filename, 'utf8');
1081
+ originalSourceCache.set(filename, source);
1082
+
1083
+ // Apply preprocess hooks
1084
+ let processedSource = source;
1085
+ for (const preprocessHook of hooks.preprocess) {
1086
+ processedSource = preprocessHook(processedSource, filename);
1087
+ }
1088
+ processedSourceCache.set(filename, processedSource);
1089
+
1090
+ // Early detection of problematic patterns - skip to fallback
1091
+ // Note: Pre-detected files from buildStart are handled by resolveId returning null
1092
+ // This catches files that weren't pre-detected (e.g., preprocess hooks revealed the pattern)
1093
+ const detection = detectProblematicMdxPatterns(processedSource, mdxOptions);
1094
+ if (detection.hasProblematicPatterns) {
1095
+ this.warn(
1096
+ `[xmdx] Skipping ${filename}: ${detection.reason ?? 'contains patterns incompatible with markdown-rs'}`
1097
+ );
1098
+ fallbackFiles.add(filename);
1099
+ fallbackReasons.set(filename, detection.reason ?? 'Detected problematic MDX patterns');
1100
+ // Use @mdx-js/mdx as fallback compiler for runtime-detected files
1101
+ return compileFallbackModule(filename, processedSource, id, registry, hasStarlightConfigured);
1102
+ }
1103
+
1104
+ const startTime = performance.now();
1105
+ const compileStart = LOAD_PROFILE ? performance.now() : 0;
1106
+ let result: CompileResult;
1107
+ let frontmatter: Record<string, unknown> = {};
1108
+ let headings: Array<{ depth: number; slug: string; text: string }> = [];
1109
+
1110
+ // MDX files: Use mdxjs-rs for full MDX support (JSX, ESM imports, etc.)
1111
+ if (isMdx) {
1112
+ const binding = await loadXmdxBinding();
1113
+
1114
+ // Compile single MDX file with mdxjs-rs
1115
+ const mdxBatchResult = binding.compileMdxBatch(
1116
+ [{ id: filename, source: processedSource }],
1117
+ { continueOnError: false, config: compilerOptions }
1118
+ );
1119
+
1120
+ const mdxResult = mdxBatchResult.results[0];
1121
+ if (mdxResult?.error) {
1122
+ throw new Error(`MDX compilation failed: ${mdxResult.error}`);
1123
+ }
1124
+ if (!mdxResult?.result) {
1125
+ throw new Error(`MDX compilation returned no result for ${filename}`);
1126
+ }
1127
+
1128
+ // Parse frontmatter and headings
1129
+ if (mdxResult.result.frontmatterJson) {
1130
+ try {
1131
+ frontmatter = JSON.parse(mdxResult.result.frontmatterJson) as Record<string, unknown>;
1132
+ } catch {
1133
+ frontmatter = {};
1134
+ }
1135
+ }
1136
+ headings = mdxResult.result.headings || [];
1137
+
1138
+ // Wrap MDX output in Astro component format
1139
+ const jsxCode = wrapMdxModule(mdxResult.result.code, {
1140
+ frontmatter,
1141
+ headings,
1142
+ registry,
1143
+ }, filename);
1144
+
1145
+ result = {
1146
+ code: jsxCode,
1147
+ map: null,
1148
+ frontmatter_json: mdxResult.result.frontmatterJson ?? '',
1149
+ headings,
1150
+ imports: [],
1151
+ };
1152
+ } else if (IS_MDAST) {
1153
+ // MD files: Use markdown-rs via parseBlocks
1154
+ const binding = await loadXmdxBinding();
1155
+
1156
+ // Extract user imports BEFORE processing (user imports take precedence over registry)
1157
+ const userImports = extractImportStatements(processedSource);
1158
+
1159
+ // Strip frontmatter before passing to parseBlocks
1160
+ // Otherwise the mdast pipeline renders YAML as regular text
1161
+ const contentSource = stripFrontmatter(processedSource);
1162
+
1163
+ const parseResult = binding.parseBlocks(contentSource, {
1164
+ enable_directives: true,
1165
+ });
1166
+ headings = parseResult.headings;
1167
+
1168
+ // Extract frontmatter from original source (before stripping)
1169
+ const frontmatterResult = binding.parseFrontmatter(processedSource);
1170
+ frontmatter = frontmatterResult.frontmatter || {};
1171
+
1172
+ result = {
1173
+ code: blocksToJsx(parseResult.blocks, frontmatter, headings, registry, filename, userImports),
1174
+ map: null,
1175
+ frontmatter_json: JSON.stringify(frontmatter),
1176
+ headings,
1177
+ imports: [],
1178
+ };
1179
+ } else {
1180
+ const fileOptions = deriveFileOptions(filename, resolvedConfig?.root);
1181
+ result = currentCompiler.compile(processedSource, filename, fileOptions);
1182
+ if (result.frontmatter_json) {
1183
+ try {
1184
+ frontmatter = JSON.parse(result.frontmatter_json) as Record<string, unknown>;
1185
+ } catch {
1186
+ frontmatter = {};
1187
+ }
1188
+ }
1189
+ headings = result.headings || [];
1190
+ }
1191
+
1192
+ if (loadProfiler) loadProfiler.record('compile', performance.now() - compileStart);
1193
+ const endTime = performance.now();
1194
+ totalProcessingTimeMs += endTime - startTime;
1195
+ processedFiles.add(filename);
1196
+
1197
+ if (result.code == null || typeof result.code !== 'string') {
1198
+ throw new Error(`Compiler returned undefined or invalid code for ${filename}`);
1199
+ }
1200
+
1201
+ if (result.diagnostics?.warnings?.length) {
1202
+ for (const warning of result.diagnostics.warnings) {
1203
+ this.warn(`[xmdx] ${filename}:${warning.line}: ${warning.message}`);
1204
+ }
1205
+ }
1206
+
1207
+ const normalizedStarlightComponents = normalizeStarlightComponents(starlightComponents);
1208
+ const ctx: TransformContext = {
1209
+ code: result.code,
1210
+ source,
1211
+ filename,
1212
+ frontmatter,
1213
+ headings,
1214
+ registry,
1215
+ config: {
1216
+ expressiveCode,
1217
+ starlightComponents: normalizedStarlightComponents,
1218
+ shiki: await shikiManager.getFor(result.code),
1219
+ },
1220
+ };
1221
+
1222
+ const tpStart2 = LOAD_PROFILE ? performance.now() : 0;
1223
+ const transformed = await transformPipeline(ctx);
1224
+ result.code = transformed.code;
1225
+ if (loadProfiler) loadProfiler.record('transform-pipeline', performance.now() - tpStart2);
1226
+
1227
+ if (Array.isArray(result?.imports)) {
1228
+ for (const dep of result.imports) {
1229
+ if (dep?.path) {
1230
+ this.addWatchFile(dep.path);
1231
+ }
1232
+ }
1233
+ }
1234
+
1235
+ const esStart2 = LOAD_PROFILE ? performance.now() : 0;
1236
+ const esbuildResult = await transformWithEsbuild(result.code, id, ESBUILD_JSX_CONFIG);
1237
+ if (loadProfiler) loadProfiler.record('esbuild', performance.now() - esStart2);
1238
+
1239
+ if (loadProfiler) {
1240
+ const elapsed = performance.now() - loadStart;
1241
+ loadProfiler.recordFile(filename, elapsed);
1242
+ }
1243
+
1244
+ return {
1245
+ code: esbuildResult.code,
1246
+ map: esbuildResult.map ?? result.map ?? undefined,
1247
+ };
1248
+ } catch (error) {
1249
+ const message = (error as Error)?.message || String(error);
1250
+ const shouldFallback =
1251
+ message.includes('Vite module runner has been closed') ||
1252
+ message.includes('Markdown parser error') ||
1253
+ message.includes('Markdown parse error') ||
1254
+ message.includes('Transform failed') ||
1255
+ message.includes('Compiler returned undefined') ||
1256
+ message.includes('Cannot read properties of undefined') ||
1257
+ message.includes('Cannot read properties of null');
1258
+
1259
+ if (shouldFallback) {
1260
+ fallbackFiles.add(filename);
1261
+ fallbackReasons.set(filename, message);
1262
+ this.warn(`[xmdx] Falling back to @mdx-js/mdx for ${filename}: ${message}`);
1263
+
1264
+ // Try to invalidate the module in dev server mode
1265
+ const config = resolvedConfig as unknown as {
1266
+ server?: {
1267
+ moduleGraph?: {
1268
+ getModuleById: (id: string) => object | null;
1269
+ invalidateModule: (mod: object) => void;
1270
+ };
1271
+ };
1272
+ };
1273
+ if (config?.server?.moduleGraph) {
1274
+ const mod = config.server.moduleGraph.getModuleById(id);
1275
+ if (mod) {
1276
+ config.server.moduleGraph.invalidateModule(mod);
1277
+ }
1278
+ }
1279
+
1280
+ // Re-read and process the file for fallback compilation
1281
+ const fallbackSource = await readFile(filename, 'utf8');
1282
+ let processedFallbackSource = fallbackSource;
1283
+ for (const preprocessHook of hooks.preprocess) {
1284
+ processedFallbackSource = preprocessHook(processedFallbackSource, filename);
1285
+ }
1286
+ return compileFallbackModule(filename, processedFallbackSource, id, registry, hasStarlightConfigured);
1287
+ }
1288
+ throw new Error(`[xmdx] Compile failed for ${filename}: ${message}`);
1289
+ }
1290
+ },
1291
+
1292
+ async buildEnd() {
1293
+ if (loadProfiler) loadProfiler.dump(resolvedConfig?.root ?? '');
1294
+
1295
+ if (process.env.XMDX_STATS !== '1') return;
1296
+
1297
+ const totalFiles = processedFiles.size + fallbackFiles.size;
1298
+
1299
+ const stats = {
1300
+ timestamp: new Date().toISOString(),
1301
+ totalFiles,
1302
+ processedByXmdx: processedFiles.size,
1303
+ handledByAstro: fallbackFiles.size,
1304
+ handledByAstroRate:
1305
+ totalFiles > 0
1306
+ ? `${((fallbackFiles.size / totalFiles) * 100).toFixed(2)}%`
1307
+ : '0%',
1308
+ preValidationSkips: {
1309
+ count: 0,
1310
+ files: [] as string[],
1311
+ },
1312
+ runtimeFallbacks: {
1313
+ count: fallbackFiles.size,
1314
+ files: Array.from(fallbackFiles).map((file) => ({
1315
+ file: file.replace(resolvedConfig?.root ?? '', ''),
1316
+ reason: fallbackReasons.get(file) ?? 'unknown',
1317
+ })),
1318
+ },
1319
+ fallbacks: fallbackFiles.size,
1320
+ fallbackRate:
1321
+ totalFiles > 0
1322
+ ? `${((fallbackFiles.size / totalFiles) * 100).toFixed(2)}%`
1323
+ : '0%',
1324
+ fallbackFiles: Array.from(fallbackFiles).map((file) => ({
1325
+ file: file.replace(resolvedConfig?.root ?? '', ''),
1326
+ reason: fallbackReasons.get(file) ?? 'unknown',
1327
+ })),
1328
+ performance: {
1329
+ totalProcessingTimeMs: Math.round(totalProcessingTimeMs * 100) / 100,
1330
+ averageFileTimeMs:
1331
+ processedFiles.size > 0
1332
+ ? Math.round((totalProcessingTimeMs / processedFiles.size) * 100) / 100
1333
+ : 0,
1334
+ },
1335
+ };
1336
+
1337
+ const outputPath = path.join(resolvedConfig?.root ?? '.', 'xmdx-stats.json');
1338
+ await writeFile(outputPath, JSON.stringify(stats, null, 2));
1339
+ console.info(`[xmdx] Stats written to ${outputPath}`);
1340
+ },
1341
+ };
1342
+ }