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,201 @@
1
+ /**
2
+ * Import statement manipulation utilities
3
+ * @module utils/imports
4
+ */
5
+
6
+ import { stripCodeFences } from './mdx-detection.js';
7
+
8
+ /**
9
+ * Collect all imported names from JavaScript/JSX code.
10
+ * Handles default imports, namespace imports, and named imports.
11
+ *
12
+ * @example
13
+ * const code = `
14
+ * import React from 'react';
15
+ * import { useState, useEffect } from 'react';
16
+ * import * as utils from './utils';
17
+ * `;
18
+ * const names = collectImportedNames(code);
19
+ * // Set { 'React', 'useState', 'useEffect', 'utils' }
20
+ */
21
+ export function collectImportedNames(code: string): Set<string> {
22
+ const imported = new Set<string>();
23
+ if (!code || typeof code !== 'string') {
24
+ return imported;
25
+ }
26
+ // Strip code fences to avoid false positives from code examples
27
+ const codeWithoutFences = stripCodeFences(code);
28
+ const lines = codeWithoutFences.split(/\r?\n/);
29
+ for (const line of lines) {
30
+ const trimmed = line.trim();
31
+ if (!trimmed.startsWith('import ') || trimmed.startsWith('import(')) {
32
+ continue;
33
+ }
34
+
35
+ // Default import: import Foo from 'module'
36
+ const defaultMatch = trimmed.match(
37
+ /^import\s+([A-Za-z$_][\w$]*)\s*(?:,|\s+from\s)/
38
+ );
39
+ if (defaultMatch?.[1]) {
40
+ imported.add(defaultMatch[1]);
41
+ }
42
+
43
+ // Namespace import: import * as Foo from 'module'
44
+ const namespaceMatch = trimmed.match(
45
+ /^import\s+\*\s+as\s+([A-Za-z$_][\w$]*)\s+from/
46
+ );
47
+ if (namespaceMatch?.[1]) {
48
+ imported.add(namespaceMatch[1]);
49
+ }
50
+
51
+ // Named imports: import { Foo, Bar as Baz } from 'module'
52
+ const namedMatch = trimmed.match(/import\s+{([^}]+)}\s+from/);
53
+ if (namedMatch?.[1]) {
54
+ const parts = namedMatch[1].split(',');
55
+ for (const part of parts) {
56
+ const item = part.trim();
57
+ if (!item) continue;
58
+ const segments = item.split(/\s+as\s+/);
59
+ const name = segments[1] ?? segments[0];
60
+ if (name) {
61
+ imported.add(name.trim());
62
+ }
63
+ }
64
+ }
65
+ }
66
+ return imported;
67
+ }
68
+
69
+ /**
70
+ * Insert import statement after existing imports in code.
71
+ * Finds the position after the last import statement and inserts the new import.
72
+ *
73
+ * @example
74
+ * const code = `
75
+ * import React from 'react';
76
+ *
77
+ * export default function App() {}
78
+ * `;
79
+ * const result = insertAfterImports(code, "import { Aside } from '@astrojs/starlight/components';");
80
+ * // Inserts after the React import
81
+ */
82
+ export function insertAfterImports(code: string, importLine: string): string {
83
+ if (!code || typeof code !== 'string') {
84
+ return importLine;
85
+ }
86
+ const lines = code.split(/\r?\n/);
87
+ let idx = 0;
88
+ while (idx < lines.length) {
89
+ const trimmed = lines[idx]?.trim() ?? '';
90
+ if (!trimmed) {
91
+ idx += 1;
92
+ continue;
93
+ }
94
+ if (trimmed.startsWith('//') || trimmed.startsWith('/*')) {
95
+ idx += 1;
96
+ continue;
97
+ }
98
+ if (trimmed.startsWith('import ')) {
99
+ idx += 1;
100
+ continue;
101
+ }
102
+ break;
103
+ }
104
+
105
+ // MDX requires a blank line between imports and markdown content.
106
+ // Only add blank line if the next line is markdown content, not JavaScript.
107
+ const nextLine = lines[idx]?.trim() ?? '';
108
+ const isJavaScript = nextLine.startsWith('export ') ||
109
+ nextLine.startsWith('const ') ||
110
+ nextLine.startsWith('let ') ||
111
+ nextLine.startsWith('var ') ||
112
+ nextLine.startsWith('function ') ||
113
+ nextLine.startsWith('class ') ||
114
+ nextLine.startsWith('import ') ||
115
+ nextLine.startsWith('//') ||
116
+ nextLine.startsWith('/*');
117
+
118
+ if (nextLine && !isJavaScript) {
119
+ // There's markdown content after the insertion point; add blank line after the import
120
+ lines.splice(idx, 0, importLine, '');
121
+ } else {
122
+ lines.splice(idx, 0, importLine);
123
+ }
124
+ return lines.join('\n');
125
+ }
126
+
127
+ /**
128
+ * Extract import statements from MDX/JSX code.
129
+ * Returns the full import statement strings, preserving their original form.
130
+ *
131
+ * Import statements end when:
132
+ * 1. A semicolon is found
133
+ * 2. The import completes (has 'from' + module path)
134
+ * 3. The next line starts with a different statement type
135
+ *
136
+ * @example
137
+ * const code = `
138
+ * import Card from '~/components/Landing/Card.astro'
139
+ * import { useState } from 'react';
140
+ *
141
+ * # Hello World
142
+ * `;
143
+ * const imports = extractImportStatements(code);
144
+ * // ["import Card from '~/components/Landing/Card.astro'", "import { useState } from 'react';"]
145
+ */
146
+ export function extractImportStatements(code: string): string[] {
147
+ const imports: string[] = [];
148
+ if (!code || typeof code !== 'string') {
149
+ return imports;
150
+ }
151
+ // Strip code fences to avoid false positives from code examples
152
+ const codeWithoutFences = stripCodeFences(code);
153
+ const lines = codeWithoutFences.split(/\r?\n/);
154
+
155
+ let currentImport = '';
156
+ let inImport = false;
157
+
158
+ for (const line of lines) {
159
+ const trimmed = line.trim();
160
+
161
+ if (!inImport) {
162
+ // Start of new import statement
163
+ if (trimmed.startsWith('import ') && !trimmed.startsWith('import(')) {
164
+ // Check if import is complete on this line
165
+ // Side-effect import: import './file.js' or import "styles.css" (no 'from' keyword)
166
+ const isSideEffectImport = /^import\s+['"][^'"]+['"]/.test(trimmed);
167
+ // Complete import has: import ... from '...' or import ... from "..."
168
+ const hasFromClause = /from\s+['"][^'"]+['"]/.test(trimmed);
169
+ if (isSideEffectImport || hasFromClause || trimmed.includes(';')) {
170
+ // Complete single-line import (with or without semicolon)
171
+ imports.push(trimmed);
172
+ } else {
173
+ // Start of multi-line import (e.g., multi-line named imports)
174
+ inImport = true;
175
+ currentImport = trimmed;
176
+ }
177
+ }
178
+ } else {
179
+ // Continue accumulating multi-line import
180
+ // Strip inline // comments, but only outside of quoted strings
181
+ const lineWithoutComment = trimmed.replace(/\s*\/\/.*$/, (match, offset) => {
182
+ const before = trimmed.slice(0, offset);
183
+ const singleQuotes = (before.match(/'/g) || []).length;
184
+ const doubleQuotes = (before.match(/"/g) || []).length;
185
+ // If inside a string (odd number of unescaped quotes), keep the match
186
+ if (singleQuotes % 2 !== 0 || doubleQuotes % 2 !== 0) return match;
187
+ return '';
188
+ });
189
+ currentImport += ' ' + lineWithoutComment;
190
+ const hasFromClause = /from\s+['"][^'"]+['"]/.test(currentImport);
191
+ if (hasFromClause || trimmed.includes(';')) {
192
+ // End of multi-line import
193
+ imports.push(currentImport);
194
+ currentImport = '';
195
+ inImport = false;
196
+ }
197
+ }
198
+ }
199
+
200
+ return imports;
201
+ }
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect } from 'bun:test';
2
+ import { stripCodeFences } from './mdx-detection.js';
3
+
4
+ describe('stripCodeFences', () => {
5
+ it('should strip basic 3-backtick fence', () => {
6
+ const input = 'before\n```js\nconst x = 1;\n```\nafter';
7
+ const result = stripCodeFences(input);
8
+ expect(result).toBe('before\nafter');
9
+ });
10
+
11
+ it('should strip tilde fences', () => {
12
+ const input = 'before\n~~~\ncode\n~~~\nafter';
13
+ const result = stripCodeFences(input);
14
+ expect(result).toBe('before\nafter');
15
+ });
16
+
17
+ it('should handle 4-backtick fence containing 3-backtick content', () => {
18
+ const input = 'before\n````md\n```\nnested\n```\n````\nafter';
19
+ const result = stripCodeFences(input);
20
+ expect(result).toBe('before\nafter');
21
+ });
22
+
23
+ it('should not close fence with shorter marker', () => {
24
+ const input = 'before\n````\n```\nstill inside\n````\nafter';
25
+ const result = stripCodeFences(input);
26
+ expect(result).toBe('before\nafter');
27
+ });
28
+
29
+ it('should not close fence with different marker type', () => {
30
+ const input = 'before\n```\n~~~\nstill inside\n```\nafter';
31
+ const result = stripCodeFences(input);
32
+ expect(result).toBe('before\nafter');
33
+ });
34
+
35
+ it('should not treat closing fence with info string as closer', () => {
36
+ const input = 'before\n```\n```js\nstill inside\n```\nafter';
37
+ const result = stripCodeFences(input);
38
+ // ```js has an info string so it's not a valid closer; the next ``` closes it
39
+ expect(result).toBe('before\nafter');
40
+ });
41
+ });
@@ -0,0 +1,209 @@
1
+ /**
2
+ * MDX pattern detection utilities.
3
+ * @module utils/mdx-detection
4
+ */
5
+
6
+ import { stripFrontmatter } from './frontmatter.js';
7
+ import type { MdxImportHandlingOptions } from '../types.js';
8
+
9
+ /**
10
+ * Detailed result from pattern detection.
11
+ */
12
+ export interface MdxPatternDetectionResult {
13
+ /** Whether problematic patterns were found */
14
+ hasProblematicPatterns: boolean;
15
+ /** Human-readable reason for fallback */
16
+ reason?: string;
17
+ /** Disallowed import sources found (if any) */
18
+ disallowedImports?: string[];
19
+ /** All import sources found */
20
+ allImports?: string[];
21
+ }
22
+
23
+ /**
24
+ * Strip code fences from content to avoid false positives.
25
+ * Uses efficient single-pass character scanning instead of split() and per-line regex.
26
+ */
27
+ export function stripCodeFences(content: string): string {
28
+ let result = '';
29
+ let pos = 0;
30
+ let inFence = false;
31
+ let fenceMarker = '';
32
+ let fenceLen = 0;
33
+
34
+ while (pos < content.length) {
35
+ // Find end of current line
36
+ let lineEnd = content.indexOf('\n', pos);
37
+ if (lineEnd === -1) lineEnd = content.length;
38
+
39
+ const line = content.slice(pos, lineEnd);
40
+
41
+ // Check for fence at start of line (after trimming leading whitespace)
42
+ let i = 0;
43
+ while (i < line.length && (line[i] === ' ' || line[i] === '\t')) i++;
44
+
45
+ const char = line[i];
46
+ if (char === '`' || char === '~') {
47
+ let len = 0;
48
+ const marker = char;
49
+ while (line[i] === marker) {
50
+ len++;
51
+ i++;
52
+ }
53
+
54
+ if (len >= 3) {
55
+ if (!inFence) {
56
+ // Opening fence
57
+ inFence = true;
58
+ fenceMarker = marker;
59
+ fenceLen = len;
60
+ pos = lineEnd + 1;
61
+ continue;
62
+ } else if (marker === fenceMarker && len >= fenceLen) {
63
+ // Check if rest of line is only whitespace (valid closer)
64
+ const rest = line.slice(i).trim();
65
+ if (rest === '') {
66
+ inFence = false;
67
+ pos = lineEnd + 1;
68
+ continue;
69
+ }
70
+ }
71
+ }
72
+ }
73
+
74
+ if (!inFence) {
75
+ result += line;
76
+ if (lineEnd < content.length) result += '\n';
77
+ }
78
+
79
+ pos = lineEnd + 1;
80
+ }
81
+
82
+ return result;
83
+ }
84
+
85
+ /**
86
+ * Convert a glob-like pattern to a regex.
87
+ * Supports * as wildcard.
88
+ */
89
+ function patternToRegex(pattern: string): RegExp {
90
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
91
+ const withWildcard = escaped.replace(/\*/g, '.*');
92
+ return new RegExp(`^${withWildcard}$`);
93
+ }
94
+
95
+ /**
96
+ * Check if an import source matches any of the allowed patterns.
97
+ */
98
+ function isAllowedImport(importSource: string, allowImports: string[]): boolean {
99
+ return allowImports.some((pattern) => {
100
+ const regex = patternToRegex(pattern);
101
+ return regex.test(importSource);
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Extract import sources from content.
107
+ */
108
+ function extractImportSources(content: string): string[] {
109
+ const sources: string[] = [];
110
+ // Match: import ... from 'source' or import 'source'
111
+ const importRegex = /^import\s+(?:(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+)?['"]([^'"]+)['"]/gm;
112
+ let match;
113
+ while ((match = importRegex.exec(content)) !== null) {
114
+ if (match[1]) {
115
+ sources.push(match[1]);
116
+ }
117
+ }
118
+ return sources;
119
+ }
120
+
121
+ /**
122
+ * Detect MDX patterns that markdown-rs cannot parse correctly with detailed results.
123
+ * This includes:
124
+ * - MDX import statements (import ... from '...')
125
+ *
126
+ * Note: Export statements are now handled by Rust (hoisted_exports) and no longer
127
+ * trigger fallback. This significantly reduces fallback rate.
128
+ *
129
+ * @param source - The markdown/MDX source content
130
+ * @param options - MDX handling options
131
+ * @returns Detailed detection result with reasons
132
+ */
133
+ export function detectProblematicMdxPatterns(
134
+ source: string,
135
+ options?: MdxImportHandlingOptions
136
+ ): MdxPatternDetectionResult {
137
+ // Skip frontmatter when checking for imports
138
+ let content = stripFrontmatter(source);
139
+
140
+ // Optionally strip code fences (default: true when options provided)
141
+ const ignoreCodeFences = options?.ignoreCodeFences ?? true;
142
+ if (ignoreCodeFences) {
143
+ content = stripCodeFences(content);
144
+ }
145
+
146
+ // Note: Export statements are now handled by Rust and no longer trigger fallback
147
+ // The Rust compiler extracts exports via collect_root_statements() and includes
148
+ // them in hoisted_exports, which TypeScript then injects into the JSX module.
149
+
150
+ // Check for import statements
151
+ const importPatterns = [
152
+ /^import\s+(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+['"][^'"]+['"]/m,
153
+ /^import\s+['"][^'"]+['"]/m,
154
+ ];
155
+
156
+ const hasImports = importPatterns.some((pattern) => pattern.test(content));
157
+ if (!hasImports) {
158
+ return { hasProblematicPatterns: false };
159
+ }
160
+
161
+ const allImports = extractImportSources(content);
162
+
163
+ // If no allowed imports configured, any import is problematic
164
+ const allowImports = options?.allowImports;
165
+ if (!allowImports || allowImports.length === 0) {
166
+ return {
167
+ hasProblematicPatterns: true,
168
+ reason: `Contains imports with no allowImports configured: ${allImports.slice(0, 3).join(', ')}${allImports.length > 3 ? ` (+${allImports.length - 3} more)` : ''}`,
169
+ allImports,
170
+ disallowedImports: allImports,
171
+ };
172
+ }
173
+
174
+ // Check if all imports are from allowed sources
175
+ const disallowedImports = allImports.filter(
176
+ (src) => !isAllowedImport(src, allowImports)
177
+ );
178
+
179
+ if (disallowedImports.length > 0) {
180
+ return {
181
+ hasProblematicPatterns: true,
182
+ reason: `Contains disallowed imports: ${disallowedImports.slice(0, 3).join(', ')}${disallowedImports.length > 3 ? ` (+${disallowedImports.length - 3} more)` : ''}`,
183
+ allImports,
184
+ disallowedImports,
185
+ };
186
+ }
187
+
188
+ return {
189
+ hasProblematicPatterns: false,
190
+ allImports,
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Detect MDX patterns that markdown-rs cannot parse correctly.
196
+ * This includes:
197
+ * - MDX import statements (import ... from '...')
198
+ * - MDX export statements (export const ..., export default ...)
199
+ * These are JavaScript constructs that the MDAST pipeline cannot handle.
200
+ *
201
+ * @param source - The markdown/MDX source content
202
+ * @param options - MDX handling options
203
+ */
204
+ export function hasProblematicMdxPatterns(
205
+ source: string,
206
+ options?: MdxImportHandlingOptions
207
+ ): boolean {
208
+ return detectProblematicMdxPatterns(source, options).hasProblematicPatterns;
209
+ }
@@ -0,0 +1,206 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import path from 'node:path';
3
+ import {
4
+ stripQuery,
5
+ normalizePath,
6
+ deriveAstroUrl,
7
+ deriveFileOptions,
8
+ shouldCompile,
9
+ } from './paths.js';
10
+
11
+ describe('stripQuery', () => {
12
+ test('returns path unchanged when no query string', () => {
13
+ expect(stripQuery('/path/to/file.md')).toBe('/path/to/file.md');
14
+ });
15
+
16
+ test('removes query string from path', () => {
17
+ expect(stripQuery('/path/to/file.md?foo=bar')).toBe('/path/to/file.md');
18
+ });
19
+
20
+ test('removes empty query string', () => {
21
+ expect(stripQuery('/path/to/file.md?')).toBe('/path/to/file.md');
22
+ });
23
+
24
+ test('handles multiple query parameters', () => {
25
+ expect(stripQuery('/path/to/file.md?foo=bar&baz=qux')).toBe(
26
+ '/path/to/file.md'
27
+ );
28
+ });
29
+
30
+ test('handles query string with special characters', () => {
31
+ expect(stripQuery('/file.md?import&raw')).toBe('/file.md');
32
+ });
33
+ });
34
+
35
+ describe('normalizePath', () => {
36
+ test('returns Unix-style path unchanged', () => {
37
+ expect(normalizePath('path/to/file.md')).toBe('path/to/file.md');
38
+ });
39
+
40
+ test('converts Windows-style path to Unix-style', () => {
41
+ // Simulate Windows path separator
42
+ const windowsPath = ['path', 'to', 'file.md'].join('\\');
43
+ const sep = path.sep;
44
+ // Mock path.sep temporarily
45
+ Object.defineProperty(path, 'sep', { value: '\\', writable: true });
46
+ expect(normalizePath(windowsPath)).toBe('path/to/file.md');
47
+ // Restore
48
+ Object.defineProperty(path, 'sep', { value: sep, writable: true });
49
+ });
50
+
51
+ test('handles absolute paths', () => {
52
+ expect(normalizePath('/absolute/path/file.md')).toBe(
53
+ '/absolute/path/file.md'
54
+ );
55
+ });
56
+
57
+ test('handles empty path', () => {
58
+ expect(normalizePath('')).toBe('');
59
+ });
60
+ });
61
+
62
+ describe('deriveAstroUrl', () => {
63
+ test('returns undefined for empty filePath', () => {
64
+ expect(deriveAstroUrl('')).toBe(undefined);
65
+ });
66
+
67
+ test('returns undefined for files outside pages directory', () => {
68
+ const filePath = '/project/src/components/Button.astro';
69
+ const rootDir = '/project';
70
+ expect(deriveAstroUrl(filePath, rootDir)).toBe(undefined);
71
+ });
72
+
73
+ test('returns / for index.md at pages root', () => {
74
+ const filePath = '/project/src/pages/index.md';
75
+ const rootDir = '/project';
76
+ expect(deriveAstroUrl(filePath, rootDir)).toBe('/');
77
+ });
78
+
79
+ test('returns / for empty relative path after pages', () => {
80
+ const filePath = '/project/src/pages/';
81
+ const rootDir = '/project';
82
+ // Normalized path ends at pages, empty relative
83
+ expect(deriveAstroUrl(filePath, rootDir)).toBe('/');
84
+ });
85
+
86
+ test('derives URL for file in pages subdirectory', () => {
87
+ const filePath = '/project/src/pages/blog/post.md';
88
+ const rootDir = '/project';
89
+ expect(deriveAstroUrl(filePath, rootDir)).toBe('/blog/post');
90
+ });
91
+
92
+ test('removes .md extension from URL', () => {
93
+ const filePath = '/project/src/pages/about.md';
94
+ const rootDir = '/project';
95
+ expect(deriveAstroUrl(filePath, rootDir)).toBe('/about');
96
+ });
97
+
98
+ test('removes .mdx extension from URL', () => {
99
+ const filePath = '/project/src/pages/docs/intro.mdx';
100
+ const rootDir = '/project';
101
+ expect(deriveAstroUrl(filePath, rootDir)).toBe('/docs/intro');
102
+ });
103
+
104
+ test('handles nested index files', () => {
105
+ const filePath = '/project/src/pages/blog/index.md';
106
+ const rootDir = '/project';
107
+ expect(deriveAstroUrl(filePath, rootDir)).toBe('/blog');
108
+ });
109
+
110
+ test('handles deeply nested paths', () => {
111
+ const filePath = '/project/src/pages/docs/guides/advanced/config.md';
112
+ const rootDir = '/project';
113
+ expect(deriveAstroUrl(filePath, rootDir)).toBe(
114
+ '/docs/guides/advanced/config'
115
+ );
116
+ });
117
+
118
+ test('handles paths on current platform', () => {
119
+ // Path separators are normalized based on platform
120
+ const filePath = path.join('/project', 'src', 'pages', 'about.md');
121
+ const rootDir = '/project';
122
+ expect(deriveAstroUrl(filePath, rootDir)).toBe('/about');
123
+ });
124
+ });
125
+
126
+ describe('deriveFileOptions', () => {
127
+ test('derives options for absolute path', () => {
128
+ const id = '/project/src/pages/index.md';
129
+ const rootDir = '/project';
130
+ const options = deriveFileOptions(id, rootDir);
131
+ expect(options.file).toBe('/project/src/pages/index.md');
132
+ expect(options.url).toBe('/');
133
+ });
134
+
135
+ test('resolves relative path to absolute', () => {
136
+ const id = 'src/pages/about.md';
137
+ const rootDir = '/project';
138
+ const options = deriveFileOptions(id, rootDir);
139
+ expect(options.file).toBe('/project/src/pages/about.md');
140
+ expect(options.url).toBe('/about');
141
+ });
142
+
143
+ test('strips query string before processing', () => {
144
+ const id = '/project/src/pages/blog.md?raw';
145
+ const rootDir = '/project';
146
+ const options = deriveFileOptions(id, rootDir);
147
+ expect(options.file).toBe('/project/src/pages/blog.md');
148
+ expect(options.url).toBe('/blog');
149
+ });
150
+
151
+ test('omits url when file is outside pages directory', () => {
152
+ const id = '/project/src/components/Button.astro';
153
+ const rootDir = '/project';
154
+ const options = deriveFileOptions(id, rootDir);
155
+ expect(options.file).toBe('/project/src/components/Button.astro');
156
+ expect(options.url).toBe(undefined);
157
+ });
158
+
159
+ test('includes url for nested page files', () => {
160
+ const id = '/project/src/pages/docs/api.mdx';
161
+ const rootDir = '/project';
162
+ const options = deriveFileOptions(id, rootDir);
163
+ expect(options.file).toBe('/project/src/pages/docs/api.mdx');
164
+ expect(options.url).toBe('/docs/api');
165
+ });
166
+
167
+ test('handles path without rootDir', () => {
168
+ const id = '/absolute/path/file.md';
169
+ const options = deriveFileOptions(id);
170
+ expect(options.file).toBe('/absolute/path/file.md');
171
+ // url may or may not be present depending on actual file system
172
+ });
173
+ });
174
+
175
+ describe('shouldCompile', () => {
176
+ test('returns true for .md files', () => {
177
+ expect(shouldCompile('/path/to/file.md')).toBe(true);
178
+ });
179
+
180
+ test('returns true for .mdx files', () => {
181
+ expect(shouldCompile('/path/to/file.mdx')).toBe(true);
182
+ });
183
+
184
+ test('returns false for .js files', () => {
185
+ expect(shouldCompile('/path/to/file.js')).toBe(false);
186
+ });
187
+
188
+ test('returns false for .astro files', () => {
189
+ expect(shouldCompile('/path/to/file.astro')).toBe(false);
190
+ });
191
+
192
+ test('strips query string before checking extension', () => {
193
+ expect(shouldCompile('/path/to/file.md?raw')).toBe(true);
194
+ expect(shouldCompile('/path/to/file.mdx?import')).toBe(true);
195
+ expect(shouldCompile('/path/to/file.js?raw')).toBe(false);
196
+ });
197
+
198
+ test('returns false for paths with no extension', () => {
199
+ expect(shouldCompile('/path/to/README')).toBe(false);
200
+ });
201
+
202
+ test('handles uppercase extensions', () => {
203
+ expect(shouldCompile('/path/to/file.MD')).toBe(false); // Case-sensitive
204
+ expect(shouldCompile('/path/to/file.MDX')).toBe(false);
205
+ });
206
+ });