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,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path manipulation and URL derivation utilities
|
|
3
|
+
* @module utils/paths
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Default file extensions that should be compiled
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_EXTENSIONS = new Set(['.md', '.mdx']);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Removes query string from a file ID/path
|
|
15
|
+
*/
|
|
16
|
+
export function stripQuery(id: string): string {
|
|
17
|
+
if (!id) return id;
|
|
18
|
+
const queryIndex = id.indexOf('?');
|
|
19
|
+
return queryIndex >= 0 ? id.slice(0, queryIndex) : id;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Normalizes path separators to forward slashes (Unix-style)
|
|
24
|
+
*/
|
|
25
|
+
export function normalizePath(value: string): string {
|
|
26
|
+
return value.split(path.sep).join('/');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Derives the Astro page URL from a file path.
|
|
31
|
+
* Converts file system paths to web URLs following Astro's routing conventions.
|
|
32
|
+
*/
|
|
33
|
+
export function deriveAstroUrl(filePath: string, rootDir?: string): string | undefined {
|
|
34
|
+
if (!filePath) return undefined;
|
|
35
|
+
const normalizedFile = normalizePath(filePath);
|
|
36
|
+
const root = rootDir ?? process.cwd();
|
|
37
|
+
const pagesDir = normalizePath(path.join(root, 'src', 'pages'));
|
|
38
|
+
if (!normalizedFile.startsWith(pagesDir)) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
let relative = normalizedFile.slice(pagesDir.length);
|
|
42
|
+
if (relative.startsWith('/')) {
|
|
43
|
+
relative = relative.slice(1);
|
|
44
|
+
}
|
|
45
|
+
if (!relative) {
|
|
46
|
+
return '/';
|
|
47
|
+
}
|
|
48
|
+
if (relative.endsWith('.md') || relative.endsWith('.mdx')) {
|
|
49
|
+
relative = relative.replace(/\.mdx?$/, '');
|
|
50
|
+
}
|
|
51
|
+
if (relative === '' || relative === 'index') {
|
|
52
|
+
return '/';
|
|
53
|
+
}
|
|
54
|
+
if (relative.endsWith('/index')) {
|
|
55
|
+
relative = relative.slice(0, -'/index'.length);
|
|
56
|
+
}
|
|
57
|
+
return `/${relative}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* File options derived from a module ID.
|
|
62
|
+
*/
|
|
63
|
+
export interface FileOptions {
|
|
64
|
+
/** Absolute file path */
|
|
65
|
+
file: string;
|
|
66
|
+
/** Derived URL (if in pages directory) */
|
|
67
|
+
url?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Derives file options (absolute path and URL) from a Vite module ID.
|
|
72
|
+
*/
|
|
73
|
+
export function deriveFileOptions(id: string, rootDir?: string): FileOptions {
|
|
74
|
+
const sourcePath = stripQuery(id);
|
|
75
|
+
let absolutePath = sourcePath;
|
|
76
|
+
if (rootDir && !path.isAbsolute(sourcePath)) {
|
|
77
|
+
absolutePath = path.resolve(rootDir, sourcePath);
|
|
78
|
+
}
|
|
79
|
+
const url = deriveAstroUrl(absolutePath, rootDir);
|
|
80
|
+
const options: FileOptions = { file: absolutePath };
|
|
81
|
+
if (url) {
|
|
82
|
+
options.url = url;
|
|
83
|
+
}
|
|
84
|
+
return options;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Checks if a file should be compiled based on its extension.
|
|
89
|
+
*/
|
|
90
|
+
export function shouldCompile(id: string): boolean {
|
|
91
|
+
return DEFAULT_EXTENSIONS.has(path.extname(stripQuery(id)));
|
|
92
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import { stripHeadingsMeta } from './validation.js';
|
|
3
|
+
|
|
4
|
+
describe('stripHeadingsMeta', () => {
|
|
5
|
+
test('returns code unchanged when no headings metadata', () => {
|
|
6
|
+
const code = `import React from 'react';\n\nexport default () => <div>Hello</div>;`;
|
|
7
|
+
expect(stripHeadingsMeta(code)).toBe(code);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test('removes export const headings', () => {
|
|
11
|
+
const code = `export const headings = [{depth: 1, text: "Title"}];\n\nContent`;
|
|
12
|
+
const result = stripHeadingsMeta(code);
|
|
13
|
+
expect(result).not.toContain('export const headings');
|
|
14
|
+
expect(result).toContain('Content');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('removes export function getHeadings', () => {
|
|
18
|
+
const code = `export function getHeadings() { return []; }\n\nContent`;
|
|
19
|
+
const result = stripHeadingsMeta(code);
|
|
20
|
+
expect(result).not.toContain('export function getHeadings');
|
|
21
|
+
expect(result).toContain('Content');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('removes both headings exports', () => {
|
|
25
|
+
const code = `export const headings = [];\nexport function getHeadings() { return headings; }\n\nContent`;
|
|
26
|
+
const result = stripHeadingsMeta(code);
|
|
27
|
+
expect(result).not.toContain('export const headings');
|
|
28
|
+
expect(result).not.toContain('export function getHeadings');
|
|
29
|
+
expect(result).toContain('Content');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('handles multiline headings array', () => {
|
|
33
|
+
const code = `export const headings = [\n {depth: 1, text: "A"},\n {depth: 2, text: "B"}\n];\n\nContent`;
|
|
34
|
+
const result = stripHeadingsMeta(code);
|
|
35
|
+
expect(result).not.toContain('export const headings');
|
|
36
|
+
expect(result).toContain('Content');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('handles multiline getHeadings function', () => {
|
|
40
|
+
const code = `export function getHeadings() {\n return [\n {depth: 1}\n ];\n}\n\nContent`;
|
|
41
|
+
const result = stripHeadingsMeta(code);
|
|
42
|
+
expect(result).not.toContain('export function getHeadings');
|
|
43
|
+
expect(result).toContain('Content');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('preserves other exports', () => {
|
|
47
|
+
const code = `export const frontmatter = {};\nexport const headings = [];\nexport function MyComponent() {}`;
|
|
48
|
+
const result = stripHeadingsMeta(code);
|
|
49
|
+
expect(result).toContain('export const frontmatter');
|
|
50
|
+
expect(result).not.toContain('export const headings');
|
|
51
|
+
expect(result).toContain('export function MyComponent');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('handles Windows line endings (CRLF)', () => {
|
|
55
|
+
const code = `export const headings = [];\r\n\r\nContent`;
|
|
56
|
+
const result = stripHeadingsMeta(code);
|
|
57
|
+
expect(result).not.toContain('export const headings');
|
|
58
|
+
expect(result).toContain('Content');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source validation utilities
|
|
3
|
+
* @module utils/validation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Strips headings metadata from code for component scanning.
|
|
8
|
+
* Removes export const headings and export function getHeadings
|
|
9
|
+
* to avoid false positive component matches in metadata.
|
|
10
|
+
*/
|
|
11
|
+
export function stripHeadingsMeta(code: string): string {
|
|
12
|
+
return code
|
|
13
|
+
.replace(/export const headings\s*=\s*\[[\s\S]*?\];\r?\n/g, '')
|
|
14
|
+
.replace(/export function getHeadings\(\)\s*\{[\s\S]*?\}\r?\n/g, '');
|
|
15
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native binding loader for Xmdx
|
|
3
|
+
* @module vite-plugin/binding-loader
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import type { XmdxBinding } from './types.js';
|
|
9
|
+
|
|
10
|
+
let bindingPromise: Promise<XmdxBinding> | undefined;
|
|
11
|
+
const DEBUG_BINDING = process.env.XMDX_DEBUG_BINDING === '1';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Environment flags for enabling optional features.
|
|
15
|
+
*/
|
|
16
|
+
export const ENABLE_SHIKI = process.env.XMDX_SHIKI === '1';
|
|
17
|
+
export const IS_MDAST = process.env.XMDX_PIPELINE === 'mdast';
|
|
18
|
+
|
|
19
|
+
const logBindingSource = (source: string): void => {
|
|
20
|
+
if (!DEBUG_BINDING) return;
|
|
21
|
+
console.info(`[xmdx] binding source: ${source}`);
|
|
22
|
+
const nativePath = process.env.NAPI_RS_NATIVE_LIBRARY_PATH;
|
|
23
|
+
if (nativePath) {
|
|
24
|
+
console.info(`[xmdx] NAPI_RS_NATIVE_LIBRARY_PATH=${nativePath}`);
|
|
25
|
+
} else {
|
|
26
|
+
console.info('[xmdx] NAPI_RS_NATIVE_LIBRARY_PATH is not set');
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Loads the native Xmdx binding.
|
|
32
|
+
* Uses require() directly on the .node binary to bypass Vite SSR runner.
|
|
33
|
+
*/
|
|
34
|
+
export async function loadXmdxBinding(): Promise<XmdxBinding> {
|
|
35
|
+
if (!bindingPromise) {
|
|
36
|
+
bindingPromise = (async () => {
|
|
37
|
+
const require = createRequire(import.meta.url);
|
|
38
|
+
const pkgRoot = path.dirname(require.resolve('xmdx-napi/package.json'));
|
|
39
|
+
|
|
40
|
+
const guessBinaryName = () => {
|
|
41
|
+
const triplet = `${process.platform}-${process.arch}`;
|
|
42
|
+
return [
|
|
43
|
+
`xmdx.${triplet}.node`,
|
|
44
|
+
`xmdx-${triplet}.node`,
|
|
45
|
+
`xmdx.${process.platform}-${process.arch}.node`,
|
|
46
|
+
];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const findBinaryPath = (): string => {
|
|
50
|
+
const candidates = guessBinaryName().map((name) =>
|
|
51
|
+
path.resolve(pkgRoot, name)
|
|
52
|
+
);
|
|
53
|
+
for (const candidate of candidates) {
|
|
54
|
+
if (require('node:fs').existsSync(candidate)) {
|
|
55
|
+
return candidate;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Fallback: first .node in package root
|
|
59
|
+
const entries = require('node:fs').readdirSync(pkgRoot);
|
|
60
|
+
const nodeFile = entries.find((f: string) => f.endsWith('.node'));
|
|
61
|
+
if (nodeFile) {
|
|
62
|
+
return path.resolve(pkgRoot, nodeFile);
|
|
63
|
+
}
|
|
64
|
+
throw new Error('xmdx-napi native binary not found');
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const binaryPath = findBinaryPath();
|
|
68
|
+
const binding = require(binaryPath) as XmdxBinding;
|
|
69
|
+
logBindingSource(binaryPath);
|
|
70
|
+
return binding;
|
|
71
|
+
})();
|
|
72
|
+
}
|
|
73
|
+
return bindingPromise;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resets the binding promise (useful for testing).
|
|
78
|
+
*/
|
|
79
|
+
export function resetBindingPromise(): void {
|
|
80
|
+
bindingPromise = undefined;
|
|
81
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import { createRegistry, starlightLibrary, astroLibrary } from 'xmdx/registry';
|
|
3
|
+
import { rewriteFallbackDirectives, injectFallbackImports } from './directive-rewriter.js';
|
|
4
|
+
|
|
5
|
+
const testRegistry = createRegistry([starlightLibrary, astroLibrary]);
|
|
6
|
+
|
|
7
|
+
describe('rewriteFallbackDirectives', () => {
|
|
8
|
+
describe('core functionality', () => {
|
|
9
|
+
test('converts :::note to <Aside type="note">', () => {
|
|
10
|
+
const source = `:::note
|
|
11
|
+
This is a note.
|
|
12
|
+
:::`;
|
|
13
|
+
const result = rewriteFallbackDirectives(source, null, true);
|
|
14
|
+
expect(result.changed).toBe(true);
|
|
15
|
+
expect(result.code).toContain('<Aside');
|
|
16
|
+
expect(result.code).toContain('type="note"');
|
|
17
|
+
expect(result.code).toContain('</Aside>');
|
|
18
|
+
expect(result.usedComponents.has('Aside')).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('converts :::tip to <Aside type="tip">', () => {
|
|
22
|
+
const source = `:::tip
|
|
23
|
+
This is a tip.
|
|
24
|
+
:::`;
|
|
25
|
+
const result = rewriteFallbackDirectives(source, null, true);
|
|
26
|
+
expect(result.changed).toBe(true);
|
|
27
|
+
expect(result.code).toContain('<Aside');
|
|
28
|
+
expect(result.code).toContain('type="tip"');
|
|
29
|
+
expect(result.code).toContain('</Aside>');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('converts :::caution to <Aside type="caution">', () => {
|
|
33
|
+
const source = `:::caution
|
|
34
|
+
Be careful!
|
|
35
|
+
:::`;
|
|
36
|
+
const result = rewriteFallbackDirectives(source, null, true);
|
|
37
|
+
expect(result.changed).toBe(true);
|
|
38
|
+
expect(result.code).toContain('type="caution"');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('converts :::danger to <Aside type="danger">', () => {
|
|
42
|
+
const source = `:::danger
|
|
43
|
+
This is dangerous!
|
|
44
|
+
:::`;
|
|
45
|
+
const result = rewriteFallbackDirectives(source, null, true);
|
|
46
|
+
expect(result.changed).toBe(true);
|
|
47
|
+
expect(result.code).toContain('type="danger"');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('extracts bracket title: :::note[Custom Title]', () => {
|
|
51
|
+
const source = `:::note[Custom Title]
|
|
52
|
+
This is a note with a title.
|
|
53
|
+
:::`;
|
|
54
|
+
const result = rewriteFallbackDirectives(source, null, true);
|
|
55
|
+
expect(result.changed).toBe(true);
|
|
56
|
+
expect(result.code).toContain('title="Custom Title"');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('handles multiple directives in sequence', () => {
|
|
60
|
+
const source = `:::note
|
|
61
|
+
First note.
|
|
62
|
+
:::
|
|
63
|
+
|
|
64
|
+
:::tip
|
|
65
|
+
A tip here.
|
|
66
|
+
:::`;
|
|
67
|
+
const result = rewriteFallbackDirectives(source, null, true);
|
|
68
|
+
expect(result.changed).toBe(true);
|
|
69
|
+
expect(result.code).toContain('type="note"');
|
|
70
|
+
expect(result.code).toContain('type="tip"');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('code fence handling', () => {
|
|
75
|
+
test('ignores directives inside code fences', () => {
|
|
76
|
+
const source = `\`\`\`md
|
|
77
|
+
:::note
|
|
78
|
+
This is inside a code fence.
|
|
79
|
+
:::
|
|
80
|
+
\`\`\``;
|
|
81
|
+
const result = rewriteFallbackDirectives(source, null, true);
|
|
82
|
+
expect(result.changed).toBe(false);
|
|
83
|
+
expect(result.code).toBe(source);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('ignores directives inside triple tilde fences', () => {
|
|
87
|
+
const source = `~~~md
|
|
88
|
+
:::note
|
|
89
|
+
This is inside a tilde fence.
|
|
90
|
+
:::
|
|
91
|
+
~~~`;
|
|
92
|
+
const result = rewriteFallbackDirectives(source, null, true);
|
|
93
|
+
expect(result.changed).toBe(false);
|
|
94
|
+
expect(result.code).toBe(source);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('processes directives after code fence closes', () => {
|
|
98
|
+
const source = `\`\`\`js
|
|
99
|
+
const x = 1;
|
|
100
|
+
\`\`\`
|
|
101
|
+
|
|
102
|
+
:::note
|
|
103
|
+
Real note.
|
|
104
|
+
:::`;
|
|
105
|
+
const result = rewriteFallbackDirectives(source, null, true);
|
|
106
|
+
expect(result.changed).toBe(true);
|
|
107
|
+
expect(result.code).toContain('<Aside');
|
|
108
|
+
expect(result.code).toContain('const x = 1;');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('nesting and blockquotes', () => {
|
|
113
|
+
test('handles directives in blockquotes', () => {
|
|
114
|
+
const source = `> :::note
|
|
115
|
+
> This is a note in a blockquote.
|
|
116
|
+
> :::`;
|
|
117
|
+
const result = rewriteFallbackDirectives(source, null, true);
|
|
118
|
+
expect(result.changed).toBe(true);
|
|
119
|
+
expect(result.code).toContain('<Aside');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('preserves indentation prefix', () => {
|
|
123
|
+
const source = ` :::note
|
|
124
|
+
Indented content.
|
|
125
|
+
:::`;
|
|
126
|
+
const result = rewriteFallbackDirectives(source, null, true);
|
|
127
|
+
expect(result.changed).toBe(true);
|
|
128
|
+
expect(result.code).toContain(' <Aside');
|
|
129
|
+
expect(result.code).toContain(' </Aside>');
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('registry integration', () => {
|
|
134
|
+
test('uses registry directive mappings when available', () => {
|
|
135
|
+
const source = `:::note
|
|
136
|
+
Content here.
|
|
137
|
+
:::`;
|
|
138
|
+
const result = rewriteFallbackDirectives(source, testRegistry, false);
|
|
139
|
+
expect(result.changed).toBe(true);
|
|
140
|
+
expect(result.code).toContain('<Aside');
|
|
141
|
+
expect(result.usedComponents.has('Aside')).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('returns unchanged when registry has no matching directive', () => {
|
|
145
|
+
const source = `:::unknown
|
|
146
|
+
Unknown directive.
|
|
147
|
+
:::`;
|
|
148
|
+
const result = rewriteFallbackDirectives(source, testRegistry, false);
|
|
149
|
+
expect(result.changed).toBe(false);
|
|
150
|
+
expect(result.code).toBe(source);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('edge cases', () => {
|
|
155
|
+
test('handles empty source', () => {
|
|
156
|
+
const result = rewriteFallbackDirectives('', null, true);
|
|
157
|
+
expect(result.changed).toBe(false);
|
|
158
|
+
expect(result.code).toBe('');
|
|
159
|
+
expect(result.usedComponents.size).toBe(0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('handles source with no directives', () => {
|
|
163
|
+
const source = `# Hello World
|
|
164
|
+
|
|
165
|
+
This is regular markdown.`;
|
|
166
|
+
const result = rewriteFallbackDirectives(source, null, true);
|
|
167
|
+
expect(result.changed).toBe(false);
|
|
168
|
+
expect(result.code).toBe(source);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('handles unclosed directives by auto-closing', () => {
|
|
172
|
+
const source = `:::note
|
|
173
|
+
This directive is never closed.`;
|
|
174
|
+
const result = rewriteFallbackDirectives(source, null, true);
|
|
175
|
+
expect(result.changed).toBe(true);
|
|
176
|
+
expect(result.code).toContain('<Aside');
|
|
177
|
+
expect(result.code).toContain('</Aside>');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('handles directive with extra attributes', () => {
|
|
181
|
+
const source = `:::note{id="my-note" class="custom"}
|
|
182
|
+
Content with attributes.
|
|
183
|
+
:::`;
|
|
184
|
+
const result = rewriteFallbackDirectives(source, null, true);
|
|
185
|
+
expect(result.changed).toBe(true);
|
|
186
|
+
expect(result.code).toContain('id="my-note"');
|
|
187
|
+
expect(result.code).toContain('class="custom"');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('filters out type attribute from extras', () => {
|
|
191
|
+
const source = `:::note{type="custom"}
|
|
192
|
+
Should not duplicate type.
|
|
193
|
+
:::`;
|
|
194
|
+
const result = rewriteFallbackDirectives(source, null, true);
|
|
195
|
+
expect(result.changed).toBe(true);
|
|
196
|
+
// Type should appear exactly once (from directive name, not from attrs)
|
|
197
|
+
const matches = result.code.match(/type="/g);
|
|
198
|
+
expect(matches?.length).toBe(1);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('filters out title attribute when bracket title exists', () => {
|
|
202
|
+
const source = `:::note[Bracket Title]{title="Attr Title"}
|
|
203
|
+
Should use bracket title.
|
|
204
|
+
:::`;
|
|
205
|
+
const result = rewriteFallbackDirectives(source, null, true);
|
|
206
|
+
expect(result.changed).toBe(true);
|
|
207
|
+
expect(result.code).toContain('title="Bracket Title"');
|
|
208
|
+
// Should not contain the attribute title
|
|
209
|
+
expect(result.code).not.toContain('title="Attr Title"');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('does not rewrite when hasStarlightConfigured is false and no registry', () => {
|
|
213
|
+
const source = `:::note
|
|
214
|
+
Not configured.
|
|
215
|
+
:::`;
|
|
216
|
+
const result = rewriteFallbackDirectives(source, null, false);
|
|
217
|
+
expect(result.changed).toBe(false);
|
|
218
|
+
expect(result.code).toBe(source);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('adds data-mf-source attribute', () => {
|
|
222
|
+
const source = `:::note
|
|
223
|
+
Content.
|
|
224
|
+
:::`;
|
|
225
|
+
const result = rewriteFallbackDirectives(source, null, true);
|
|
226
|
+
expect(result.code).toContain('data-mf-source="directive"');
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe('injectFallbackImports', () => {
|
|
232
|
+
test('injects import for used component not already imported', () => {
|
|
233
|
+
const source = `# Hello
|
|
234
|
+
|
|
235
|
+
<Aside type="note">Content</Aside>`;
|
|
236
|
+
const usedComponents = new Set(['Aside']);
|
|
237
|
+
const result = injectFallbackImports(source, usedComponents, null, true);
|
|
238
|
+
expect(result).toContain("import { Aside } from '@astrojs/starlight/components';");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('skips already imported components', () => {
|
|
242
|
+
const source = `import { Aside } from '@astrojs/starlight/components';
|
|
243
|
+
|
|
244
|
+
<Aside type="note">Content</Aside>`;
|
|
245
|
+
const usedComponents = new Set(['Aside']);
|
|
246
|
+
const result = injectFallbackImports(source, usedComponents, null, true);
|
|
247
|
+
// Should not duplicate the import
|
|
248
|
+
const importCount = (result.match(/import.*Aside/g) || []).length;
|
|
249
|
+
expect(importCount).toBe(1);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('handles named exports from registry', () => {
|
|
253
|
+
const source = `# Content
|
|
254
|
+
|
|
255
|
+
<Aside type="note">Content</Aside>`;
|
|
256
|
+
const usedComponents = new Set(['Aside']);
|
|
257
|
+
const result = injectFallbackImports(source, usedComponents, testRegistry, false);
|
|
258
|
+
expect(result).toContain("import { Aside }");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('returns source unchanged when no components used', () => {
|
|
262
|
+
const source = `# Hello World`;
|
|
263
|
+
const usedComponents = new Set<string>();
|
|
264
|
+
const result = injectFallbackImports(source, usedComponents, null, true);
|
|
265
|
+
expect(result).toBe(source);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('returns source unchanged when source is empty', () => {
|
|
269
|
+
const result = injectFallbackImports('', new Set(['Aside']), null, true);
|
|
270
|
+
expect(result).toBe('');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('does not inject Aside import when hasStarlightConfigured is false and no registry', () => {
|
|
274
|
+
const source = `# Content
|
|
275
|
+
|
|
276
|
+
<Aside type="note">Content</Aside>`;
|
|
277
|
+
const usedComponents = new Set(['Aside']);
|
|
278
|
+
const result = injectFallbackImports(source, usedComponents, null, false);
|
|
279
|
+
// Should not add any import since Starlight is not configured
|
|
280
|
+
expect(result).not.toContain("import { Aside }");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('injects imports after existing imports', () => {
|
|
284
|
+
const source = `import React from 'react';
|
|
285
|
+
|
|
286
|
+
# Content
|
|
287
|
+
|
|
288
|
+
<Aside type="note">Content</Aside>`;
|
|
289
|
+
const usedComponents = new Set(['Aside']);
|
|
290
|
+
const result = injectFallbackImports(source, usedComponents, null, true);
|
|
291
|
+
const lines = result.split('\n');
|
|
292
|
+
const reactIndex = lines.findIndex((l) => l.includes('React'));
|
|
293
|
+
const asideIndex = lines.findIndex((l) => l.includes('Aside'));
|
|
294
|
+
expect(asideIndex).toBeGreaterThan(reactIndex);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('handles multiple used components', () => {
|
|
298
|
+
const source = `# Content
|
|
299
|
+
|
|
300
|
+
<Aside>Note</Aside>
|
|
301
|
+
<Card>Info</Card>`;
|
|
302
|
+
const usedComponents = new Set(['Aside', 'Card']);
|
|
303
|
+
const result = injectFallbackImports(source, usedComponents, testRegistry, false);
|
|
304
|
+
expect(result).toContain('Aside');
|
|
305
|
+
// Card may or may not be in registry, but Aside should be imported
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe('integration', () => {
|
|
310
|
+
test('rewrite + inject works together', () => {
|
|
311
|
+
const source = `import { something } from 'somewhere';
|
|
312
|
+
|
|
313
|
+
:::note[My Note]
|
|
314
|
+
This is my note content.
|
|
315
|
+
:::`;
|
|
316
|
+
const rewriteResult = rewriteFallbackDirectives(source, null, true);
|
|
317
|
+
expect(rewriteResult.changed).toBe(true);
|
|
318
|
+
|
|
319
|
+
const finalCode = injectFallbackImports(
|
|
320
|
+
rewriteResult.code,
|
|
321
|
+
rewriteResult.usedComponents,
|
|
322
|
+
null,
|
|
323
|
+
true
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
expect(finalCode).toContain('<Aside');
|
|
327
|
+
expect(finalCode).toContain('title="My Note"');
|
|
328
|
+
expect(finalCode).toContain("import { Aside } from '@astrojs/starlight/components';");
|
|
329
|
+
expect(finalCode).toContain("import { something } from 'somewhere';");
|
|
330
|
+
});
|
|
331
|
+
});
|