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.
- package/index.ts +8 -0
- package/package.json +80 -0
- package/src/constants.ts +52 -0
- package/src/index.ts +150 -0
- package/src/pipeline/index.ts +38 -0
- package/src/pipeline/orchestrator.test.ts +324 -0
- package/src/pipeline/orchestrator.ts +121 -0
- package/src/pipeline/pipe.test.ts +251 -0
- package/src/pipeline/pipe.ts +70 -0
- package/src/pipeline/types.ts +59 -0
- package/src/plugins.test.ts +274 -0
- package/src/presets/index.ts +225 -0
- package/src/transforms/blocks-to-jsx.test.ts +590 -0
- package/src/transforms/blocks-to-jsx.ts +617 -0
- package/src/transforms/expressive-code.test.ts +274 -0
- package/src/transforms/expressive-code.ts +147 -0
- package/src/transforms/index.test.ts +143 -0
- package/src/transforms/index.ts +100 -0
- package/src/transforms/inject-components.test.ts +406 -0
- package/src/transforms/inject-components.ts +184 -0
- package/src/transforms/shiki.test.ts +289 -0
- package/src/transforms/shiki.ts +312 -0
- package/src/types.ts +92 -0
- package/src/utils/config.test.ts +252 -0
- package/src/utils/config.ts +146 -0
- package/src/utils/frontmatter.ts +33 -0
- package/src/utils/imports.test.ts +518 -0
- package/src/utils/imports.ts +201 -0
- package/src/utils/mdx-detection.test.ts +41 -0
- package/src/utils/mdx-detection.ts +209 -0
- package/src/utils/paths.test.ts +206 -0
- package/src/utils/paths.ts +92 -0
- package/src/utils/validation.test.ts +60 -0
- package/src/utils/validation.ts +15 -0
- package/src/vite-plugin/binding-loader.ts +81 -0
- package/src/vite-plugin/directive-rewriter.test.ts +331 -0
- package/src/vite-plugin/directive-rewriter.ts +272 -0
- package/src/vite-plugin/esbuild-pool.ts +173 -0
- package/src/vite-plugin/index.ts +37 -0
- package/src/vite-plugin/jsx-module.ts +106 -0
- package/src/vite-plugin/mdx-wrapper.ts +328 -0
- package/src/vite-plugin/normalize-config.test.ts +78 -0
- package/src/vite-plugin/normalize-config.ts +29 -0
- package/src/vite-plugin/shiki-highlighter.ts +46 -0
- package/src/vite-plugin/shiki-manager.test.ts +175 -0
- package/src/vite-plugin/shiki-manager.ts +53 -0
- package/src/vite-plugin/types.ts +189 -0
- 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
|
+
});
|