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,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MDX wrapper module for Astro component integration.
|
|
3
|
+
* Wraps mdxjs-rs compiled output in Astro-compatible component format.
|
|
4
|
+
* @module vite-plugin/mdx-wrapper
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Registry } from 'xmdx/registry';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Options for wrapping MDX module output.
|
|
11
|
+
*/
|
|
12
|
+
export interface WrapMdxOptions {
|
|
13
|
+
/** Frontmatter extracted from the MDX file */
|
|
14
|
+
frontmatter: Record<string, unknown>;
|
|
15
|
+
/** Headings extracted from the MDX file */
|
|
16
|
+
headings: Array<{ depth: number; slug: string; text: string }>;
|
|
17
|
+
/** Component registry for import generation */
|
|
18
|
+
registry: Registry;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Wraps mdxjs-rs compiled JavaScript output in an Astro-compatible module.
|
|
23
|
+
*
|
|
24
|
+
* The mdxjs-rs output is a complete JavaScript module with an MDXContent function
|
|
25
|
+
* that accepts a `components` prop for runtime component resolution. This wrapper:
|
|
26
|
+
*
|
|
27
|
+
* 1. Imports the compiled MDX content via a virtual module
|
|
28
|
+
* 2. Generates imports for components used in the document
|
|
29
|
+
* 3. Creates an Astro component that injects components at render time
|
|
30
|
+
* 4. Exports frontmatter, getHeadings, and Content for Astro compatibility
|
|
31
|
+
*
|
|
32
|
+
* @param mdxCode - The compiled JavaScript code from mdxjs-rs
|
|
33
|
+
* @param options - Wrapper options including frontmatter, headings, and registry
|
|
34
|
+
* @param filename - The original MDX file path
|
|
35
|
+
* @returns Astro-compatible module code
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```typescript
|
|
39
|
+
* const wrappedModule = wrapMdxModule(compiledCode, {
|
|
40
|
+
* frontmatter: { title: 'Hello' },
|
|
41
|
+
* headings: [{ depth: 1, slug: 'hello', text: 'Hello' }],
|
|
42
|
+
* registry,
|
|
43
|
+
* }, 'page.mdx');
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export function wrapMdxModule(
|
|
47
|
+
mdxCode: string,
|
|
48
|
+
options: WrapMdxOptions,
|
|
49
|
+
filename: string
|
|
50
|
+
): string {
|
|
51
|
+
const { frontmatter, headings, registry } = options;
|
|
52
|
+
const frontmatterJson = JSON.stringify(frontmatter);
|
|
53
|
+
const headingsJson = JSON.stringify(headings);
|
|
54
|
+
|
|
55
|
+
// Analyze the MDX code to find components that need to be injected
|
|
56
|
+
const usedComponents = detectUsedComponents(mdxCode, registry);
|
|
57
|
+
|
|
58
|
+
// Generate import statements for used components
|
|
59
|
+
const componentImports = generateComponentImports(usedComponents, registry);
|
|
60
|
+
|
|
61
|
+
// Generate the components object for injection
|
|
62
|
+
// Always include Fragment for MDX compatibility
|
|
63
|
+
const componentNames = usedComponents.map(c => c.name);
|
|
64
|
+
const allComponents = ['Fragment', ...componentNames];
|
|
65
|
+
const componentsObject = `{ ${allComponents.join(', ')}, ...(props?.components ?? {}) }`;
|
|
66
|
+
|
|
67
|
+
// The mdxjs-rs output needs to be normalized to work with our wrapper.
|
|
68
|
+
// It exports `default` as the MDXContent function.
|
|
69
|
+
// We need to handle both:
|
|
70
|
+
// 1. Direct function: `export default function MDXContent(props) { ... }`
|
|
71
|
+
// 2. Function reference: `function _createMdxContent(props) { ... } export default _createMdxContent;`
|
|
72
|
+
const normalizedMdxCode = normalizeMdxExport(mdxCode);
|
|
73
|
+
|
|
74
|
+
return `import { createComponent, renderJSX } from 'astro/runtime/server/index.js';
|
|
75
|
+
import { Fragment } from 'astro/jsx-runtime';
|
|
76
|
+
${componentImports}
|
|
77
|
+
|
|
78
|
+
// MDX compiled content
|
|
79
|
+
${normalizedMdxCode}
|
|
80
|
+
|
|
81
|
+
// Astro exports
|
|
82
|
+
export const frontmatter = ${frontmatterJson};
|
|
83
|
+
export function getHeadings() { return ${headingsJson}; }
|
|
84
|
+
export const file = ${JSON.stringify(filename)};
|
|
85
|
+
export const url = undefined;
|
|
86
|
+
|
|
87
|
+
// Wrap MDXContent in Astro component with component injection
|
|
88
|
+
const XmdxContent = createComponent(
|
|
89
|
+
(result, props, _slots) =>
|
|
90
|
+
renderJSX(
|
|
91
|
+
result,
|
|
92
|
+
MDXContent({
|
|
93
|
+
...(props ?? {}),
|
|
94
|
+
components: ${componentsObject},
|
|
95
|
+
})
|
|
96
|
+
),
|
|
97
|
+
${JSON.stringify(filename)}
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
export const Content = XmdxContent;
|
|
101
|
+
export default XmdxContent;
|
|
102
|
+
`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Component usage information.
|
|
107
|
+
*/
|
|
108
|
+
interface UsedComponent {
|
|
109
|
+
name: string;
|
|
110
|
+
modulePath: string;
|
|
111
|
+
exportType: 'named' | 'default';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Extracts already-imported component names from the mdxjs-rs output.
|
|
116
|
+
* This prevents duplicate imports when we add our component injections.
|
|
117
|
+
*
|
|
118
|
+
* Only scans top-level import statements by processing lines at the start
|
|
119
|
+
* of the file, stopping when we hit actual code (function definitions, etc.).
|
|
120
|
+
* This avoids matching import statements inside code strings/samples.
|
|
121
|
+
*/
|
|
122
|
+
function extractExistingImports(code: string): Set<string> {
|
|
123
|
+
const imported = new Set<string>();
|
|
124
|
+
const lines = code.split('\n');
|
|
125
|
+
|
|
126
|
+
for (const line of lines) {
|
|
127
|
+
const trimmed = line.trim();
|
|
128
|
+
|
|
129
|
+
// Skip empty lines and comments at the top
|
|
130
|
+
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*')) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Stop scanning when we hit non-import code
|
|
135
|
+
if (!trimmed.startsWith('import ') && !trimmed.startsWith('import{')) {
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Process this import line
|
|
140
|
+
const importPattern = /import\s+([\s\S]*?)\s+from\s+['"][^'"]+['"]/;
|
|
141
|
+
const match = importPattern.exec(trimmed);
|
|
142
|
+
if (!match) continue;
|
|
143
|
+
|
|
144
|
+
if (/^import\s+type\s/.test(trimmed)) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let clause = match[1]?.trim() ?? '';
|
|
149
|
+
if (clause.startsWith('type ')) {
|
|
150
|
+
clause = clause.slice('type '.length).trim();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Default import: import Foo from 'module' or import Foo, { Bar } from 'module'
|
|
154
|
+
const defaultMatch = clause.match(/^([A-Za-z$_][\w$]*)\s*(?:,|$)/);
|
|
155
|
+
if (defaultMatch?.[1]) {
|
|
156
|
+
imported.add(defaultMatch[1]);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Namespace import: import * as Foo from 'module'
|
|
160
|
+
const namespaceMatch = clause.match(/\*\s+as\s+([A-Za-z$_][\w$]*)/);
|
|
161
|
+
if (namespaceMatch?.[1]) {
|
|
162
|
+
imported.add(namespaceMatch[1]);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Named imports: import { Foo, Bar as Baz } from 'module'
|
|
166
|
+
// Also handles: import Default, { Foo, Bar } from 'module'
|
|
167
|
+
const namedMatch = clause.match(/\{([^}]+)\}/);
|
|
168
|
+
if (namedMatch?.[1]) {
|
|
169
|
+
const parts = namedMatch[1].split(',');
|
|
170
|
+
for (const part of parts) {
|
|
171
|
+
const item = part.trim();
|
|
172
|
+
if (!item) continue;
|
|
173
|
+
const withoutType = item.replace(/^type\s+/, '');
|
|
174
|
+
const segments = withoutType.split(/\s+as\s+/);
|
|
175
|
+
const name = segments[1] ?? segments[0];
|
|
176
|
+
if (name) {
|
|
177
|
+
imported.add(name.trim());
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return imported;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Extracts locally declared component names from the MDX module.
|
|
188
|
+
* This prevents injecting registry imports that would conflict with local declarations.
|
|
189
|
+
*
|
|
190
|
+
* Matches patterns like:
|
|
191
|
+
* - const Name = ...
|
|
192
|
+
* - let Name = ...
|
|
193
|
+
* - function Name(...
|
|
194
|
+
* - class Name ...
|
|
195
|
+
* - export const Name = ...
|
|
196
|
+
* - export function Name(...
|
|
197
|
+
* - export class Name ...
|
|
198
|
+
*
|
|
199
|
+
* Only matches PascalCase names (starting with uppercase) since those are component names.
|
|
200
|
+
*/
|
|
201
|
+
function extractLocalDeclarations(code: string): Set<string> {
|
|
202
|
+
const declarations = new Set<string>();
|
|
203
|
+
|
|
204
|
+
// Match: const/let/var NAME, function NAME, class NAME
|
|
205
|
+
// Also match export variants
|
|
206
|
+
const patterns = [
|
|
207
|
+
/(?:export\s+)?(?:const|let|var)\s+([A-Z][a-zA-Z0-9]*)\s*=/g,
|
|
208
|
+
/(?:export\s+)?function\s+([A-Z][a-zA-Z0-9]*)\s*\(/g,
|
|
209
|
+
/(?:export\s+)?class\s+([A-Z][a-zA-Z0-9]*)\s*[{<]/g,
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
for (const pattern of patterns) {
|
|
213
|
+
let match;
|
|
214
|
+
while ((match = pattern.exec(code)) !== null) {
|
|
215
|
+
if (match[1]) declarations.add(match[1]);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return declarations;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Detects which components from the registry are used in the MDX code.
|
|
224
|
+
* Looks for PascalCase JSX tags in the compiled output.
|
|
225
|
+
* Excludes components that are already imported or locally declared in the mdxjs-rs output.
|
|
226
|
+
*/
|
|
227
|
+
function detectUsedComponents(code: string, registry: Registry): UsedComponent[] {
|
|
228
|
+
const usedComponents: UsedComponent[] = [];
|
|
229
|
+
const seenNames = new Set<string>();
|
|
230
|
+
|
|
231
|
+
// Get components already imported in the mdxjs-rs output
|
|
232
|
+
const existingImports = extractExistingImports(code);
|
|
233
|
+
// Get locally declared components (const Foo = ..., function Foo, etc.)
|
|
234
|
+
const localDeclarations = extractLocalDeclarations(code);
|
|
235
|
+
|
|
236
|
+
// Match potential component references in JSX
|
|
237
|
+
// This includes both direct usage like <Tabs> and indirect like jsx(Tabs, ...)
|
|
238
|
+
const componentPattern = /\b([A-Z][a-zA-Z0-9]*)\b/g;
|
|
239
|
+
let match;
|
|
240
|
+
|
|
241
|
+
while ((match = componentPattern.exec(code)) !== null) {
|
|
242
|
+
const name = match[1]!;
|
|
243
|
+
|
|
244
|
+
// Skip common JSX/React internals and already seen components
|
|
245
|
+
if (seenNames.has(name)) continue;
|
|
246
|
+
if (['Fragment', 'Component', 'MDXContent', 'React', 'Props'].includes(name)) continue;
|
|
247
|
+
|
|
248
|
+
// Skip if already imported or locally declared in the mdxjs-rs output
|
|
249
|
+
if (existingImports.has(name) || localDeclarations.has(name)) continue;
|
|
250
|
+
|
|
251
|
+
// Check if this component is in the registry
|
|
252
|
+
const definition = registry.getComponent(name);
|
|
253
|
+
if (definition) {
|
|
254
|
+
seenNames.add(name);
|
|
255
|
+
usedComponents.push({
|
|
256
|
+
name: definition.name,
|
|
257
|
+
modulePath: definition.modulePath,
|
|
258
|
+
exportType: definition.exportType as 'named' | 'default',
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return usedComponents;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Generates import statements for the used components.
|
|
268
|
+
* For default exports, uses the convention: ${modulePath}/${name}.astro
|
|
269
|
+
* to match the rest of the codebase (blocks-to-jsx, inject-components, directive-rewriter).
|
|
270
|
+
*/
|
|
271
|
+
function generateComponentImports(components: UsedComponent[], registry: Registry): string {
|
|
272
|
+
// Group components by module path for cleaner imports (named exports only)
|
|
273
|
+
const byModule = new Map<string, UsedComponent[]>();
|
|
274
|
+
const defaultExports: UsedComponent[] = [];
|
|
275
|
+
|
|
276
|
+
for (const component of components) {
|
|
277
|
+
if (component.exportType === 'default') {
|
|
278
|
+
// Default exports use individual imports with full path
|
|
279
|
+
defaultExports.push(component);
|
|
280
|
+
} else {
|
|
281
|
+
const existing = byModule.get(component.modulePath) ?? [];
|
|
282
|
+
existing.push(component);
|
|
283
|
+
byModule.set(component.modulePath, existing);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const imports: string[] = [];
|
|
288
|
+
|
|
289
|
+
// Generate named imports grouped by module
|
|
290
|
+
for (const [modulePath, moduleComponents] of byModule) {
|
|
291
|
+
const namedImports = moduleComponents.map(c => c.name);
|
|
292
|
+
if (namedImports.length > 0) {
|
|
293
|
+
imports.push(`import { ${namedImports.join(', ')} } from '${modulePath}';`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Generate individual default imports with full path convention
|
|
298
|
+
for (const comp of defaultExports) {
|
|
299
|
+
imports.push(`import ${comp.name} from '${comp.modulePath}/${comp.name}.astro';`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return imports.join('\n');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Normalizes the mdxjs-rs export to ensure MDXContent is available.
|
|
307
|
+
* Handles both direct exports and function reference exports.
|
|
308
|
+
*/
|
|
309
|
+
function normalizeMdxExport(code: string): string {
|
|
310
|
+
// Remove the default export line(s) - we'll create our own wrapper
|
|
311
|
+
let normalized = code
|
|
312
|
+
// Remove: export default function MDXContent
|
|
313
|
+
.replace(/export\s+default\s+function\s+MDXContent/g, 'function MDXContent')
|
|
314
|
+
// Remove: export default MDXContent;
|
|
315
|
+
.replace(/export\s+default\s+MDXContent\s*;?/g, '')
|
|
316
|
+
// Remove: export { MDXContent as default };
|
|
317
|
+
.replace(/export\s*\{\s*MDXContent\s+as\s+default\s*\}\s*;?/g, '')
|
|
318
|
+
// Remove: export default _createMdxContent;
|
|
319
|
+
.replace(/export\s+default\s+_createMdxContent\s*;?/g, '');
|
|
320
|
+
|
|
321
|
+
// If there's a _createMdxContent function that was the default export,
|
|
322
|
+
// alias it to MDXContent for consistency
|
|
323
|
+
if (normalized.includes('function _createMdxContent') && !normalized.includes('function MDXContent')) {
|
|
324
|
+
normalized += '\nconst MDXContent = _createMdxContent;';
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return normalized;
|
|
328
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import { normalizeStarlightComponents } from './normalize-config.js';
|
|
3
|
+
|
|
4
|
+
describe('normalizeStarlightComponents', () => {
|
|
5
|
+
test('returns true for boolean true input', () => {
|
|
6
|
+
const result = normalizeStarlightComponents(true);
|
|
7
|
+
expect(result).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test('returns false for boolean false input', () => {
|
|
11
|
+
const result = normalizeStarlightComponents(false);
|
|
12
|
+
expect(result).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('returns false when enabled is explicitly false', () => {
|
|
16
|
+
const result = normalizeStarlightComponents({ enabled: false });
|
|
17
|
+
expect(result).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('returns false when enabled is false with other properties', () => {
|
|
21
|
+
const result = normalizeStarlightComponents({
|
|
22
|
+
enabled: false,
|
|
23
|
+
components: ['Aside', 'Tabs'],
|
|
24
|
+
module: '@custom/module',
|
|
25
|
+
});
|
|
26
|
+
expect(result).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('strips enabled property and preserves components when enabled is true', () => {
|
|
30
|
+
const result = normalizeStarlightComponents({
|
|
31
|
+
enabled: true,
|
|
32
|
+
components: ['Aside', 'Tabs'],
|
|
33
|
+
});
|
|
34
|
+
expect(result).toEqual({ components: ['Aside', 'Tabs'], module: undefined });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('strips enabled property and preserves module', () => {
|
|
38
|
+
const result = normalizeStarlightComponents({
|
|
39
|
+
enabled: true,
|
|
40
|
+
module: '@custom/starlight/components',
|
|
41
|
+
});
|
|
42
|
+
expect(result).toEqual({ components: undefined, module: '@custom/starlight/components' });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('preserves both components and module', () => {
|
|
46
|
+
const result = normalizeStarlightComponents({
|
|
47
|
+
components: ['Aside', 'Card'],
|
|
48
|
+
module: '@astrojs/starlight/components',
|
|
49
|
+
});
|
|
50
|
+
expect(result).toEqual({
|
|
51
|
+
components: ['Aside', 'Card'],
|
|
52
|
+
module: '@astrojs/starlight/components',
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('handles object without enabled property', () => {
|
|
57
|
+
const result = normalizeStarlightComponents({
|
|
58
|
+
components: ['Aside'],
|
|
59
|
+
});
|
|
60
|
+
expect(result).toEqual({ components: ['Aside'], module: undefined });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('handles empty object', () => {
|
|
64
|
+
const result = normalizeStarlightComponents({});
|
|
65
|
+
expect(result).toEqual({ components: undefined, module: undefined });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('handles null input by returning false', () => {
|
|
69
|
+
// Cast to expected type since null is a valid falsy value
|
|
70
|
+
const result = normalizeStarlightComponents(null as unknown as boolean);
|
|
71
|
+
expect(result).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('handles undefined input by returning false', () => {
|
|
75
|
+
const result = normalizeStarlightComponents(undefined as unknown as boolean);
|
|
76
|
+
expect(result).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration normalization utilities for Xmdx Vite plugin
|
|
3
|
+
* @module vite-plugin/normalize-config
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Normalized starlightComponents configuration.
|
|
8
|
+
* Strips the `enabled` property which is only used for initial boolean check.
|
|
9
|
+
*/
|
|
10
|
+
export type NormalizedStarlightComponents = boolean | { components?: string[]; module?: string };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Normalizes starlightComponents configuration from plugin options.
|
|
14
|
+
* Converts `{ enabled?: boolean; components?: string[]; module?: string }` to `boolean | { components?; module? }`.
|
|
15
|
+
*
|
|
16
|
+
* @param value - The raw starlightComponents option value
|
|
17
|
+
* @returns Normalized configuration suitable for TransformContext
|
|
18
|
+
*/
|
|
19
|
+
export function normalizeStarlightComponents(
|
|
20
|
+
value: boolean | { enabled?: boolean; components?: string[]; module?: string }
|
|
21
|
+
): NormalizedStarlightComponents {
|
|
22
|
+
if (typeof value === 'object' && value !== null) {
|
|
23
|
+
if (value.enabled === false) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
return { components: value.components, module: value.module };
|
|
27
|
+
}
|
|
28
|
+
return Boolean(value);
|
|
29
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shiki highlighter creation utilities
|
|
3
|
+
* @module vite-plugin/shiki-highlighter
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { codeToHtml, createCssVariablesTheme } from 'shiki';
|
|
7
|
+
import { SHIKI_THEME } from '../constants.js';
|
|
8
|
+
import type { ShikiHighlighter } from '../transforms/shiki.js';
|
|
9
|
+
|
|
10
|
+
// Re-export for convenience
|
|
11
|
+
export type { ShikiHighlighter } from '../transforms/shiki.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Creates a Shiki highlighter with CSS variables theme.
|
|
15
|
+
* The highlighter is configured to use Xmdx's CSS variable theme for styling.
|
|
16
|
+
*
|
|
17
|
+
* @returns A function that highlights code and returns HTML
|
|
18
|
+
*/
|
|
19
|
+
export async function createShikiHighlighter(): Promise<ShikiHighlighter> {
|
|
20
|
+
const theme = createCssVariablesTheme({
|
|
21
|
+
name: SHIKI_THEME.name,
|
|
22
|
+
variablePrefix: SHIKI_THEME.variablePrefix,
|
|
23
|
+
});
|
|
24
|
+
const cache = new Map<string, { lang: string }>();
|
|
25
|
+
|
|
26
|
+
return async (code: string, lang?: string): Promise<string> => {
|
|
27
|
+
const key = `${lang || 'text'}`;
|
|
28
|
+
let cached = cache.get(key);
|
|
29
|
+
if (!cached) {
|
|
30
|
+
cached = { lang: lang || 'text' };
|
|
31
|
+
cache.set(key, cached);
|
|
32
|
+
}
|
|
33
|
+
const html = await codeToHtml(code, {
|
|
34
|
+
lang: cached.lang,
|
|
35
|
+
theme,
|
|
36
|
+
});
|
|
37
|
+
return html.replace(/<pre class="([^"]*)"/, (_match, classes: string) => {
|
|
38
|
+
const normalized = classes
|
|
39
|
+
.split(/\s+/)
|
|
40
|
+
.filter((value) => value && value !== 'shiki')
|
|
41
|
+
.join(' ');
|
|
42
|
+
const next = normalized ? `${SHIKI_THEME.className} ${normalized}` : SHIKI_THEME.className;
|
|
43
|
+
return `<pre class="${next}"`;
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test';
|
|
2
|
+
import { ShikiManager } from './shiki-manager.js';
|
|
3
|
+
|
|
4
|
+
// Mock the shiki-highlighter module
|
|
5
|
+
const mockHighlighter = mock(async (code: string, lang?: string) => {
|
|
6
|
+
return `<pre class="shiki"><code class="language-${lang || 'text'}">${code}</code></pre>`;
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
let createShikiHighlighterMock: ReturnType<typeof mock>;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
createShikiHighlighterMock = mock(() => Promise.resolve(mockHighlighter));
|
|
13
|
+
mock.module('./shiki-highlighter.js', () => ({
|
|
14
|
+
createShikiHighlighter: createShikiHighlighterMock,
|
|
15
|
+
}));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
mock.restore();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('ShikiManager', () => {
|
|
23
|
+
describe('constructor', () => {
|
|
24
|
+
test('creates manager with enabled=true', () => {
|
|
25
|
+
const manager = new ShikiManager(true);
|
|
26
|
+
expect(manager).toBeInstanceOf(ShikiManager);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('creates manager with enabled=false', () => {
|
|
30
|
+
const manager = new ShikiManager(false);
|
|
31
|
+
expect(manager).toBeInstanceOf(ShikiManager);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('getFor', () => {
|
|
36
|
+
test('returns null when disabled', async () => {
|
|
37
|
+
const manager = new ShikiManager(false);
|
|
38
|
+
const result = await manager.getFor('<pre><code>const x = 1;</code></pre>');
|
|
39
|
+
expect(result).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('returns null when code has no <pre> tags', async () => {
|
|
43
|
+
const manager = new ShikiManager(true);
|
|
44
|
+
const result = await manager.getFor('<div>No code blocks here</div>');
|
|
45
|
+
expect(result).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('returns highlighter when enabled and code has <pre>', async () => {
|
|
49
|
+
const manager = new ShikiManager(true);
|
|
50
|
+
const result = await manager.getFor('<pre><code>const x = 1;</code></pre>');
|
|
51
|
+
expect(result).not.toBeNull();
|
|
52
|
+
expect(typeof result).toBe('function');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('initializes highlighter lazily on first call', async () => {
|
|
56
|
+
const manager = new ShikiManager(true);
|
|
57
|
+
|
|
58
|
+
// First call with code that has <pre>
|
|
59
|
+
const result1 = await manager.getFor('<pre><code>first</code></pre>');
|
|
60
|
+
expect(result1).not.toBeNull();
|
|
61
|
+
|
|
62
|
+
// Second call should return same instance
|
|
63
|
+
const result2 = await manager.getFor('<pre><code>second</code></pre>');
|
|
64
|
+
expect(result2).toBe(result1);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('handles highlighter initialization failure gracefully', async () => {
|
|
68
|
+
// Create a manager where initialization will fail
|
|
69
|
+
mock.module('./shiki-highlighter.js', () => ({
|
|
70
|
+
createShikiHighlighter: () => Promise.reject(new Error('Shiki init failed')),
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
const manager = new ShikiManager(true);
|
|
74
|
+
const result = await manager.getFor('<pre><code>code</code></pre>');
|
|
75
|
+
expect(result).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('init', () => {
|
|
80
|
+
test('returns null when disabled', async () => {
|
|
81
|
+
const manager = new ShikiManager(false);
|
|
82
|
+
const result = await manager.init();
|
|
83
|
+
expect(result).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('returns highlighter when enabled', async () => {
|
|
87
|
+
const manager = new ShikiManager(true);
|
|
88
|
+
const result = await manager.init();
|
|
89
|
+
expect(result).not.toBeNull();
|
|
90
|
+
expect(typeof result).toBe('function');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('initializes only once on multiple calls', async () => {
|
|
94
|
+
const manager = new ShikiManager(true);
|
|
95
|
+
|
|
96
|
+
const result1 = await manager.init();
|
|
97
|
+
const result2 = await manager.init();
|
|
98
|
+
|
|
99
|
+
expect(result1).toBe(result2);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('handles initialization failure gracefully', async () => {
|
|
103
|
+
mock.module('./shiki-highlighter.js', () => ({
|
|
104
|
+
createShikiHighlighter: () => Promise.reject(new Error('Init failed')),
|
|
105
|
+
}));
|
|
106
|
+
|
|
107
|
+
const manager = new ShikiManager(true);
|
|
108
|
+
const result = await manager.init();
|
|
109
|
+
expect(result).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('forCode', () => {
|
|
114
|
+
test('returns null when disabled', () => {
|
|
115
|
+
const manager = new ShikiManager(false);
|
|
116
|
+
const resolved = mockHighlighter as unknown as Awaited<ReturnType<typeof mockHighlighter>>;
|
|
117
|
+
const result = manager.forCode('<pre><code>code</code></pre>', resolved);
|
|
118
|
+
expect(result).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('returns null when resolved is null', () => {
|
|
122
|
+
const manager = new ShikiManager(true);
|
|
123
|
+
const result = manager.forCode('<pre><code>code</code></pre>', null);
|
|
124
|
+
expect(result).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('returns null when code has no <pre> tags', () => {
|
|
128
|
+
const manager = new ShikiManager(true);
|
|
129
|
+
const resolved = mockHighlighter as unknown as Awaited<ReturnType<typeof mockHighlighter>>;
|
|
130
|
+
const result = manager.forCode('<div>no pre</div>', resolved);
|
|
131
|
+
expect(result).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('returns resolved highlighter when all conditions met', () => {
|
|
135
|
+
const manager = new ShikiManager(true);
|
|
136
|
+
const resolved = mockHighlighter as unknown as Awaited<ReturnType<typeof mockHighlighter>>;
|
|
137
|
+
const result = manager.forCode('<pre><code>code</code></pre>', resolved);
|
|
138
|
+
expect(result).toBe(resolved);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('hasCodeBlocks (static)', () => {
|
|
143
|
+
test('returns true for code with <pre>', () => {
|
|
144
|
+
expect(ShikiManager.hasCodeBlocks('<pre><code>code</code></pre>')).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('returns true for code with <pre and space', () => {
|
|
148
|
+
expect(ShikiManager.hasCodeBlocks('<pre class="foo"><code>code</code></pre>')).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('returns true for code with <pre>', () => {
|
|
152
|
+
expect(ShikiManager.hasCodeBlocks('<pre>')).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('returns false for code without <pre', () => {
|
|
156
|
+
expect(ShikiManager.hasCodeBlocks('<div>no pre here</div>')).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('returns false for empty string', () => {
|
|
160
|
+
expect(ShikiManager.hasCodeBlocks('')).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('returns false for text containing "pre" without tag', () => {
|
|
164
|
+
expect(ShikiManager.hasCodeBlocks('This is a preview of the content')).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('returns true for pre tag at start of string', () => {
|
|
168
|
+
expect(ShikiManager.hasCodeBlocks('<pre>code</pre>')).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('returns true for pre tag with attributes', () => {
|
|
172
|
+
expect(ShikiManager.hasCodeBlocks('<pre data-language="js"><code>code</code></pre>')).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|