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,617 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transforms compiled blocks into JSX code
|
|
3
|
+
* @module transforms/blocks-to-jsx
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { HeadingEntry } from 'xmdx';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Minimal registry interface consumed by blocksToJsx.
|
|
10
|
+
* Avoids coupling to the full Registry type.
|
|
11
|
+
*/
|
|
12
|
+
export interface BlocksRegistry {
|
|
13
|
+
getSupportedDirectives(): string[];
|
|
14
|
+
getDirectiveMapping(directive: string): { component: string; injectProps?: Record<string, { source: string; value?: string }> } | undefined;
|
|
15
|
+
getSlotNormalization(component: string): { strategy: 'wrap_in_ol' | 'wrap_in_ul' } | undefined;
|
|
16
|
+
getComponent(name: string): { modulePath: string; exportType: string } | undefined;
|
|
17
|
+
}
|
|
18
|
+
import { htmlEntitiesToJsx, hasPascalCaseTag } from 'xmdx-napi';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Prop value from the Rust compiler.
|
|
22
|
+
*/
|
|
23
|
+
export interface PropValue {
|
|
24
|
+
type: 'literal' | 'expression';
|
|
25
|
+
value: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Block from the Rust compiler.
|
|
30
|
+
*/
|
|
31
|
+
export interface Block {
|
|
32
|
+
type: 'html' | 'component' | 'code';
|
|
33
|
+
content?: string;
|
|
34
|
+
name?: string;
|
|
35
|
+
props?: Record<string, PropValue | string | unknown>;
|
|
36
|
+
slotChildren?: Block[];
|
|
37
|
+
/** Code content (for type="code") */
|
|
38
|
+
code?: string;
|
|
39
|
+
/** Code language (for type="code") */
|
|
40
|
+
lang?: string;
|
|
41
|
+
/** Code meta string (for type="code") */
|
|
42
|
+
meta?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Escapes HTML special characters for safe embedding in HTML content.
|
|
47
|
+
*/
|
|
48
|
+
function escapeHtml(s: string): string {
|
|
49
|
+
return s
|
|
50
|
+
.replace(/&/g, '&')
|
|
51
|
+
.replace(/</g, '<')
|
|
52
|
+
.replace(/>/g, '>')
|
|
53
|
+
.replace(/`/g, '`')
|
|
54
|
+
.replace(/\{/g, '{')
|
|
55
|
+
.replace(/\}/g, '}')
|
|
56
|
+
.replace(/\n/g, ' ');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Converts structured slot children blocks to an HTML string.
|
|
61
|
+
* Used to process slot content that needs to be embedded as HTML.
|
|
62
|
+
*/
|
|
63
|
+
function slotChildrenToHtml(
|
|
64
|
+
blocks: Block[],
|
|
65
|
+
componentImports?: Map<string, { modulePath: string; exportType: string }>,
|
|
66
|
+
registry?: BlocksRegistry,
|
|
67
|
+
userImportedNames?: Set<string>,
|
|
68
|
+
): string {
|
|
69
|
+
let result = '';
|
|
70
|
+
for (const block of blocks) {
|
|
71
|
+
if (block.type === 'html') {
|
|
72
|
+
// Escape braces so JSX text does not become expressions
|
|
73
|
+
result += (block.content ?? '').replace(/\{/g, '{').replace(/\}/g, '}');
|
|
74
|
+
} else if (block.type === 'code') {
|
|
75
|
+
// Always render as HTML <pre><code>; ExpressiveCode rewriting happens in pipeline
|
|
76
|
+
const langAttr = block.lang ? ` class="language-${escapeHtml(block.lang)}"` : '';
|
|
77
|
+
result += `<pre class="astro-code" tabindex="0"><code${langAttr}>${escapeHtml(block.code ?? '')}</code></pre>`;
|
|
78
|
+
} else if (block.type === 'component') {
|
|
79
|
+
const innerHtml = slotChildrenToHtml(block.slotChildren ?? [], componentImports, registry, userImportedNames);
|
|
80
|
+
|
|
81
|
+
// Fragment-with-slot: render as <span style="display:contents" slot="name">
|
|
82
|
+
// so Astro's slot distribution works (Fragment VNodes are unwrapped,
|
|
83
|
+
// losing the slot prop).
|
|
84
|
+
const slotProp = block.props?.slot;
|
|
85
|
+
const slotName =
|
|
86
|
+
typeof slotProp === 'object' && slotProp !== null && 'type' in slotProp && 'value' in slotProp
|
|
87
|
+
? (slotProp as PropValue).value
|
|
88
|
+
: typeof slotProp === 'string'
|
|
89
|
+
? slotProp
|
|
90
|
+
: undefined;
|
|
91
|
+
const isFragmentSlot = block.name === 'Fragment' && slotName !== undefined;
|
|
92
|
+
const tag = isFragmentSlot ? 'span' : (block.name ?? '');
|
|
93
|
+
|
|
94
|
+
result += `<${tag}`;
|
|
95
|
+
if (isFragmentSlot) {
|
|
96
|
+
result += ' style="display:contents"';
|
|
97
|
+
}
|
|
98
|
+
if (block.props) {
|
|
99
|
+
for (const [key, value] of Object.entries(block.props)) {
|
|
100
|
+
if (typeof value === 'object' && value !== null && 'type' in value && 'value' in value) {
|
|
101
|
+
const pv = value as PropValue;
|
|
102
|
+
if (isFragmentSlot && key === 'slot') {
|
|
103
|
+
result += ` slot="${escapeJsString(pv.value)}"`;
|
|
104
|
+
} else if (pv.type === 'literal') {
|
|
105
|
+
result += ` ${key}="${escapeJsString(pv.value)}"`;
|
|
106
|
+
} else {
|
|
107
|
+
result += ` ${key}={${pv.value}}`;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
result += `>${innerHtml}</${tag}>`;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Escapes a string value for use in JSX prop.
|
|
120
|
+
* Uses JSON.stringify for proper JS string escaping.
|
|
121
|
+
*/
|
|
122
|
+
function escapeJsString(value: string): string {
|
|
123
|
+
// Use JSON.stringify which handles all JS escaping, then remove the outer quotes
|
|
124
|
+
return JSON.stringify(String(value)).slice(1, -1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const VOID_HTML_TAGS = [
|
|
128
|
+
'area',
|
|
129
|
+
'base',
|
|
130
|
+
'br',
|
|
131
|
+
'col',
|
|
132
|
+
'embed',
|
|
133
|
+
'hr',
|
|
134
|
+
'img',
|
|
135
|
+
'input',
|
|
136
|
+
'link',
|
|
137
|
+
'meta',
|
|
138
|
+
'param',
|
|
139
|
+
'source',
|
|
140
|
+
'track',
|
|
141
|
+
'wbr',
|
|
142
|
+
];
|
|
143
|
+
const VOID_TAG_PATTERN = new RegExp(`<(${VOID_HTML_TAGS.join('|')})\\b([^<>]*?)?>`, 'gi');
|
|
144
|
+
const PRE_BLOCK_PATTERN = /<pre\b[^>]*>[\s\S]*?<\/pre>/gi;
|
|
145
|
+
|
|
146
|
+
function selfCloseVoidTags(html: string): string {
|
|
147
|
+
return html.replace(VOID_TAG_PATTERN, (match, tag, attrs = '') => {
|
|
148
|
+
if (match.endsWith('/>')) {
|
|
149
|
+
return match;
|
|
150
|
+
}
|
|
151
|
+
return `<${tag}${attrs} />`;
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function escapeBracesInPre(html: string): string {
|
|
156
|
+
return html.replace(PRE_BLOCK_PATTERN, (match) => {
|
|
157
|
+
const openTagMatch = match.match(/^<pre\b[^>]*>/i);
|
|
158
|
+
const closeTagMatch = match.match(/<\/pre>$/i);
|
|
159
|
+
if (!openTagMatch || !closeTagMatch) {
|
|
160
|
+
return match;
|
|
161
|
+
}
|
|
162
|
+
const openTag = openTagMatch[0];
|
|
163
|
+
const closeTag = closeTagMatch[0];
|
|
164
|
+
const inner = match.slice(openTag.length, match.length - closeTag.length);
|
|
165
|
+
const escaped = inner.replace(/\{/g, '{').replace(/\}/g, '}');
|
|
166
|
+
return `${openTag}${escaped}${closeTag}`;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function normalizeHtmlForJsx(html: string): string {
|
|
171
|
+
// Ensure JSX-safe output when embedding raw HTML alongside components.
|
|
172
|
+
return escapeBracesInPre(selfCloseVoidTags(html));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Strips `<p>` wrappers from Fragment elements with slot attributes.
|
|
177
|
+
*
|
|
178
|
+
* markdown-rs sometimes wraps `<Fragment slot="...">` in paragraph tags,
|
|
179
|
+
* which breaks Astro's slot system because the slot attribute is on Fragment,
|
|
180
|
+
* not on the wrapping `<p>`.
|
|
181
|
+
*
|
|
182
|
+
* Before: `<p><Fragment slot="foo">content</Fragment></p>`
|
|
183
|
+
* After: `<Fragment slot="foo">content</Fragment>`
|
|
184
|
+
*/
|
|
185
|
+
function stripParagraphFragmentWrappers(input: string): string {
|
|
186
|
+
// Pattern: <p><Fragment slot="...">...</Fragment></p>
|
|
187
|
+
// We need to handle nested content, so we can't use a simple regex
|
|
188
|
+
let result = '';
|
|
189
|
+
let cursor = 0;
|
|
190
|
+
|
|
191
|
+
while (cursor < input.length) {
|
|
192
|
+
const matchStart = input.indexOf('<p><Fragment slot=', cursor);
|
|
193
|
+
if (matchStart === -1) {
|
|
194
|
+
result += input.slice(cursor);
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Push everything before this match
|
|
199
|
+
result += input.slice(cursor, matchStart);
|
|
200
|
+
|
|
201
|
+
// Find the matching </Fragment></p>
|
|
202
|
+
const fragmentStart = matchStart + 3; // Skip "<p>"
|
|
203
|
+
const fragmentEndTag = input.indexOf('</Fragment>', fragmentStart);
|
|
204
|
+
|
|
205
|
+
if (fragmentEndTag !== -1) {
|
|
206
|
+
const fragmentEnd = fragmentEndTag + '</Fragment>'.length;
|
|
207
|
+
const afterFragment = input.slice(fragmentEnd);
|
|
208
|
+
|
|
209
|
+
if (afterFragment.startsWith('</p>')) {
|
|
210
|
+
// Extract just the Fragment element (without <p> wrapper)
|
|
211
|
+
result += input.slice(fragmentStart, fragmentEnd);
|
|
212
|
+
cursor = fragmentEnd + 4; // Skip "</p>"
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// No match found, just push the "<p>" and continue
|
|
218
|
+
result += '<p>';
|
|
219
|
+
cursor = matchStart + 3;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Normalizes slot content based on a slot normalization strategy.
|
|
227
|
+
* Ensures content is wrapped in the appropriate list structure.
|
|
228
|
+
*
|
|
229
|
+
* @param slot - The slot HTML content to normalize
|
|
230
|
+
* @param strategy - The normalization strategy ('wrap_in_ol' or 'wrap_in_ul')
|
|
231
|
+
* @returns Normalized slot content with proper list wrapping
|
|
232
|
+
*/
|
|
233
|
+
function normalizeSlotByStrategy(slot: string, strategy: 'wrap_in_ol' | 'wrap_in_ul'): string {
|
|
234
|
+
const tag = strategy === 'wrap_in_ol' ? 'ol' : 'ul';
|
|
235
|
+
const trimmed = slot.trim();
|
|
236
|
+
|
|
237
|
+
// Empty content: create minimal valid structure
|
|
238
|
+
if (!trimmed) {
|
|
239
|
+
return `<${tag}><li></li></${tag}>`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Check if content already has the correct wrapper
|
|
243
|
+
if (trimmed.startsWith(`<${tag}`) && trimmed.endsWith(`</${tag}>`)) {
|
|
244
|
+
// If already wrapped but missing <li>, add one
|
|
245
|
+
return /<li[\s>]/i.test(trimmed)
|
|
246
|
+
? slot
|
|
247
|
+
: trimmed.replace(`</${tag}>`, `<li></li></${tag}>`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Content needs wrapping
|
|
251
|
+
return /<li[\s>]/i.test(trimmed)
|
|
252
|
+
? `<${tag}>${slot}</${tag}>`
|
|
253
|
+
: `<${tag}><li>${slot}</li></${tag}>`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Builder for constructing Astro module code.
|
|
258
|
+
* Encapsulates the assembly of imports, exports, and content.
|
|
259
|
+
*/
|
|
260
|
+
export class AstroModuleBuilder {
|
|
261
|
+
private imports: string[] = [];
|
|
262
|
+
private frontmatterData: Record<string, unknown> = {};
|
|
263
|
+
private headingsData: HeadingEntry[] = [];
|
|
264
|
+
private jsxContentStr = '';
|
|
265
|
+
private moduleIdValue?: string;
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Adds standard Astro runtime imports.
|
|
269
|
+
*/
|
|
270
|
+
withRuntimeImports(): this {
|
|
271
|
+
this.imports.push(
|
|
272
|
+
`import { createComponent, renderJSX } from 'astro/runtime/server/index.js';`,
|
|
273
|
+
`import { Fragment, Fragment as _Fragment, jsx as _jsx } from 'astro/jsx-runtime';`,
|
|
274
|
+
);
|
|
275
|
+
return this;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Adds a single import statement.
|
|
280
|
+
*/
|
|
281
|
+
addImport(line: string): this {
|
|
282
|
+
if (line) {
|
|
283
|
+
this.imports.push(line);
|
|
284
|
+
}
|
|
285
|
+
return this;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Adds multiple import statements.
|
|
290
|
+
*/
|
|
291
|
+
addImports(lines: string[]): this {
|
|
292
|
+
for (const line of lines) {
|
|
293
|
+
this.addImport(line);
|
|
294
|
+
}
|
|
295
|
+
return this;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Sets the frontmatter data to export.
|
|
300
|
+
*/
|
|
301
|
+
withFrontmatter(data: Record<string, unknown>): this {
|
|
302
|
+
this.frontmatterData = data;
|
|
303
|
+
return this;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Sets the headings data to export.
|
|
308
|
+
*/
|
|
309
|
+
withHeadings(headings: HeadingEntry[]): this {
|
|
310
|
+
this.headingsData = headings;
|
|
311
|
+
return this;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Sets the JSX content for the component.
|
|
316
|
+
*/
|
|
317
|
+
withJsxContent(jsx: string): this {
|
|
318
|
+
this.jsxContentStr = jsx;
|
|
319
|
+
return this;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Sets the module ID (filename) for the component.
|
|
324
|
+
*/
|
|
325
|
+
withModuleId(filename?: string): this {
|
|
326
|
+
this.moduleIdValue = filename;
|
|
327
|
+
return this;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Builds the complete Astro module code.
|
|
332
|
+
*/
|
|
333
|
+
build(): string {
|
|
334
|
+
const allImports = this.imports.filter(Boolean).join('\n');
|
|
335
|
+
const frontmatterJson = JSON.stringify(this.frontmatterData);
|
|
336
|
+
const headingsJson = JSON.stringify(this.headingsData);
|
|
337
|
+
const moduleId = this.moduleIdValue ? JSON.stringify(this.moduleIdValue) : 'undefined';
|
|
338
|
+
|
|
339
|
+
return `${allImports}
|
|
340
|
+
export const frontmatter = ${frontmatterJson};
|
|
341
|
+
export function getHeadings() { return ${headingsJson}; }
|
|
342
|
+
function _Content() {
|
|
343
|
+
return (
|
|
344
|
+
<_Fragment>
|
|
345
|
+
${this.jsxContentStr}
|
|
346
|
+
</_Fragment>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
const XmdxContent = createComponent(
|
|
350
|
+
(result, props, _slots) => renderJSX(result, _jsx(_Content, { ...props })),
|
|
351
|
+
${moduleId}
|
|
352
|
+
);
|
|
353
|
+
export const Content = XmdxContent;
|
|
354
|
+
export default XmdxContent;
|
|
355
|
+
`;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Extract imported names from a list of import statements.
|
|
361
|
+
* Handles default imports, namespace imports, and named imports.
|
|
362
|
+
*/
|
|
363
|
+
function extractNamesFromImports(imports: string[]): Set<string> {
|
|
364
|
+
const names = new Set<string>();
|
|
365
|
+
for (const imp of imports) {
|
|
366
|
+
// Default import: import Foo from 'module'
|
|
367
|
+
const defaultMatch = imp.match(/^import\s+([A-Za-z$_][\w$]*)\s*(?:,|\s+from\s)/);
|
|
368
|
+
if (defaultMatch?.[1]) {
|
|
369
|
+
names.add(defaultMatch[1]);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Namespace import: import * as Foo from 'module'
|
|
373
|
+
const namespaceMatch = imp.match(/^import\s+\*\s+as\s+([A-Za-z$_][\w$]*)\s+from/);
|
|
374
|
+
if (namespaceMatch?.[1]) {
|
|
375
|
+
names.add(namespaceMatch[1]);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Named imports: import { Foo, Bar as Baz } from 'module'
|
|
379
|
+
// Also handles: import Default, { Foo, Bar } from 'module'
|
|
380
|
+
const namedMatch = imp.match(/import\s+(?:[A-Za-z$_][\w$]*\s*,\s*)?{([^}]+)}\s+from/);
|
|
381
|
+
if (namedMatch?.[1]) {
|
|
382
|
+
const parts = namedMatch[1].split(',');
|
|
383
|
+
for (const part of parts) {
|
|
384
|
+
const item = part.trim();
|
|
385
|
+
if (!item) continue;
|
|
386
|
+
const segments = item.split(/\s+as\s+/);
|
|
387
|
+
const name = segments[1] ?? segments[0];
|
|
388
|
+
if (name) {
|
|
389
|
+
names.add(name.trim());
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return names;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Converts blocks array from Rust compiler into JSX code with component imports and exports.
|
|
399
|
+
*
|
|
400
|
+
* @param blocks - Array of blocks from compiler
|
|
401
|
+
* @param frontmatter - Frontmatter object to export
|
|
402
|
+
* @param headings - Headings array to export
|
|
403
|
+
* @param registry - Component registry for import resolution
|
|
404
|
+
* @param filename - Optional filename for module ID
|
|
405
|
+
* @param userImports - User import statements to preserve (these take precedence over registry)
|
|
406
|
+
* @returns Complete JSX module code with imports, exports, and default component
|
|
407
|
+
*/
|
|
408
|
+
export function blocksToJsx(
|
|
409
|
+
blocks: Block[],
|
|
410
|
+
frontmatter: Record<string, unknown> = {},
|
|
411
|
+
headings: HeadingEntry[] = [],
|
|
412
|
+
registry: BlocksRegistry | null = null,
|
|
413
|
+
filename?: string,
|
|
414
|
+
userImports: string[] = [],
|
|
415
|
+
): string {
|
|
416
|
+
const fragments: string[] = [];
|
|
417
|
+
const componentImports = new Map<string, { modulePath: string; exportType: string }>();
|
|
418
|
+
|
|
419
|
+
// Extract names from user imports to avoid generating duplicate imports
|
|
420
|
+
const userImportedNames = extractNamesFromImports(userImports);
|
|
421
|
+
|
|
422
|
+
// Get supported directives from registry if available
|
|
423
|
+
const supportedDirectives = registry?.getSupportedDirectives() ?? [];
|
|
424
|
+
|
|
425
|
+
for (const block of blocks) {
|
|
426
|
+
if (block.type === 'html') {
|
|
427
|
+
// Use set:html to avoid HTML entity parsing issues with esbuild
|
|
428
|
+
// JSON.stringify handles all escaping; Astro parses the HTML at runtime
|
|
429
|
+
fragments.push(`<_Fragment set:html={${JSON.stringify(block.content ?? '')}} />`);
|
|
430
|
+
} else if (block.type === 'code') {
|
|
431
|
+
// Always render as HTML <pre><code>; ExpressiveCode rewriting happens in pipeline
|
|
432
|
+
const langAttr = block.lang ? ` class="language-${escapeHtml(block.lang)}"` : '';
|
|
433
|
+
const html = `<pre class="astro-code" tabindex="0"><code${langAttr}>${escapeHtml(block.code ?? '')}</code></pre>`;
|
|
434
|
+
fragments.push(`<_Fragment set:html={${JSON.stringify(html)}} />`);
|
|
435
|
+
} else if (block.type === 'component') {
|
|
436
|
+
// Handle directive components using registry
|
|
437
|
+
const isDirective = block.name ? supportedDirectives.includes(block.name) : false;
|
|
438
|
+
let componentName = block.name ?? '';
|
|
439
|
+
let effectiveProps = block.props;
|
|
440
|
+
|
|
441
|
+
// Separate Fragment-with-slot children from regular children.
|
|
442
|
+
// Fragment VNodes with `slot` props are unwrapped by Astro's renderJSX
|
|
443
|
+
// before slot distribution, losing the slot assignment. We render them
|
|
444
|
+
// as <span style="display:contents" slot="name"> instead.
|
|
445
|
+
const allChildren = block.slotChildren ?? [];
|
|
446
|
+
const regularChildren: Block[] = [];
|
|
447
|
+
const fragmentSlotChildren: { slotName: string; inner: Block[] }[] = [];
|
|
448
|
+
|
|
449
|
+
for (const child of allChildren) {
|
|
450
|
+
if (
|
|
451
|
+
child.type === 'component' &&
|
|
452
|
+
child.name === 'Fragment' &&
|
|
453
|
+
child.props
|
|
454
|
+
) {
|
|
455
|
+
const slotProp = child.props.slot;
|
|
456
|
+
const slotName =
|
|
457
|
+
typeof slotProp === 'object' && slotProp !== null && 'type' in slotProp && 'value' in slotProp
|
|
458
|
+
? (slotProp as PropValue).value
|
|
459
|
+
: typeof slotProp === 'string'
|
|
460
|
+
? slotProp
|
|
461
|
+
: undefined;
|
|
462
|
+
if (slotName) {
|
|
463
|
+
fragmentSlotChildren.push({ slotName, inner: child.slotChildren ?? [] });
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
regularChildren.push(child);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Convert regular (non-fragment-slot) children to HTML string for slot processing
|
|
471
|
+
let effectiveSlot = stripParagraphFragmentWrappers(
|
|
472
|
+
slotChildrenToHtml(regularChildren, componentImports, registry ?? undefined, userImportedNames)
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
if (isDirective && registry && block.name) {
|
|
476
|
+
const mapping = registry.getDirectiveMapping(block.name);
|
|
477
|
+
if (mapping) {
|
|
478
|
+
componentName = mapping.component;
|
|
479
|
+
// Apply injected props from mapping
|
|
480
|
+
if (mapping.injectProps) {
|
|
481
|
+
const injectedProps: Record<string, PropValue> = {};
|
|
482
|
+
for (const [propKey, propSource] of Object.entries(mapping.injectProps)) {
|
|
483
|
+
if (propSource.source === 'directive_name') {
|
|
484
|
+
injectedProps[propKey] = { type: 'literal', value: block.name };
|
|
485
|
+
} else if (propSource.source === 'literal' && propSource.value) {
|
|
486
|
+
injectedProps[propKey] = { type: 'literal', value: propSource.value };
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
effectiveProps = { ...block.props, ...injectedProps };
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Apply slot normalization from registry (e.g., Steps → wrap_in_ol, FileTree → wrap_in_ul)
|
|
495
|
+
const slotNorm = registry?.getSlotNormalization(componentName);
|
|
496
|
+
if (slotNorm) {
|
|
497
|
+
effectiveSlot = normalizeSlotByStrategy(effectiveSlot, slotNorm.strategy);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Skip Fragment (built-in) and user-imported components
|
|
501
|
+
if (componentName !== 'Fragment' && !userImportedNames.has(componentName)) {
|
|
502
|
+
const componentDef = registry?.getComponent(componentName);
|
|
503
|
+
const modulePath = componentDef?.modulePath ?? '@astrojs/starlight/components';
|
|
504
|
+
const exportType = componentDef?.exportType ?? 'default';
|
|
505
|
+
componentImports.set(componentName, { modulePath, exportType });
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const propsStr = effectiveProps
|
|
509
|
+
? Object.entries(effectiveProps)
|
|
510
|
+
.map(([key, value]) => {
|
|
511
|
+
// Handle PropValue enum from Rust: { type: "literal"|"expression", value: string }
|
|
512
|
+
if (typeof value === 'object' && value !== null && 'type' in value && 'value' in value) {
|
|
513
|
+
const propValue = value as PropValue;
|
|
514
|
+
if (propValue.type === 'literal') {
|
|
515
|
+
return `${key}="${escapeJsString(propValue.value)}"`;
|
|
516
|
+
} else if (propValue.type === 'expression') {
|
|
517
|
+
return `${key}={${propValue.value}}`;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (typeof value === 'string') {
|
|
521
|
+
return `${key}="${escapeJsString(value)}"`;
|
|
522
|
+
}
|
|
523
|
+
return `${key}={${JSON.stringify(value)}}`;
|
|
524
|
+
})
|
|
525
|
+
.join(' ')
|
|
526
|
+
: '';
|
|
527
|
+
|
|
528
|
+
// Handle slot content: use set:html for pure HTML, but embed JSX directly for nested components
|
|
529
|
+
const hasAnyContent = effectiveSlot || fragmentSlotChildren.length > 0;
|
|
530
|
+
if (hasAnyContent) {
|
|
531
|
+
const propsAttr = propsStr ? ` ${propsStr}` : '';
|
|
532
|
+
let children = '';
|
|
533
|
+
|
|
534
|
+
// Default slot content (regular children)
|
|
535
|
+
if (effectiveSlot) {
|
|
536
|
+
// Check if slot contains JSX components (true PascalCase tags like <Card, <Aside, etc.)
|
|
537
|
+
// These need to be embedded directly so Astro processes them as components
|
|
538
|
+
// Uses Rust implementation for consistency with codegen
|
|
539
|
+
const hasNestedComponents = hasPascalCaseTag(effectiveSlot);
|
|
540
|
+
|
|
541
|
+
// Fragment components should NEVER use set:html wrapper
|
|
542
|
+
// The Fragment itself is the slot container, content should be direct children
|
|
543
|
+
if (componentName === 'Fragment' || hasNestedComponents) {
|
|
544
|
+
// Embed JSX directly so Astro processes slot content correctly
|
|
545
|
+
// Convert HTML entities to JSX expressions so they render as text, not markup
|
|
546
|
+
const normalizedSlot = normalizeHtmlForJsx(effectiveSlot);
|
|
547
|
+
children += htmlEntitiesToJsx(normalizedSlot);
|
|
548
|
+
} else {
|
|
549
|
+
// Pure HTML content - use set:html for non-Fragment components
|
|
550
|
+
children += `<_Fragment set:html={${JSON.stringify(effectiveSlot)}} />`;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Named slot children: render as <span style="display:contents" slot="name">
|
|
555
|
+
// Using a real HTML element (not Fragment) so Astro's slot distribution
|
|
556
|
+
// correctly assigns the content to the named slot.
|
|
557
|
+
for (const { slotName, inner } of fragmentSlotChildren) {
|
|
558
|
+
const innerHtml = slotChildrenToHtml(inner, componentImports, registry ?? undefined, userImportedNames);
|
|
559
|
+
children += `<span style="display:contents" slot="${escapeJsString(slotName)}">`;
|
|
560
|
+
if (innerHtml) {
|
|
561
|
+
if (hasPascalCaseTag(innerHtml)) {
|
|
562
|
+
const normalizedInnerHtml = normalizeHtmlForJsx(innerHtml);
|
|
563
|
+
children += htmlEntitiesToJsx(normalizedInnerHtml);
|
|
564
|
+
} else {
|
|
565
|
+
children += `<_Fragment set:html={${JSON.stringify(innerHtml)}} />`;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
children += '</span>';
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
fragments.push(`<${componentName}${propsAttr}>${children}</${componentName}>`);
|
|
572
|
+
} else {
|
|
573
|
+
fragments.push(propsStr ? `<${componentName} ${propsStr} />` : `<${componentName} />`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Generate imports grouped by module path
|
|
579
|
+
const importsByModule = new Map<string, { named: string[]; default: string[] }>();
|
|
580
|
+
for (const [name, { modulePath, exportType }] of componentImports) {
|
|
581
|
+
if (!importsByModule.has(modulePath)) {
|
|
582
|
+
importsByModule.set(modulePath, { named: [], default: [] });
|
|
583
|
+
}
|
|
584
|
+
const entry = importsByModule.get(modulePath)!;
|
|
585
|
+
if (exportType === 'named') {
|
|
586
|
+
entry.named.push(name);
|
|
587
|
+
} else {
|
|
588
|
+
entry.default.push(name);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const componentImportLines = Array.from(importsByModule.entries())
|
|
593
|
+
.map(([modulePath, { named, default: defaults }]) => {
|
|
594
|
+
const lines: string[] = [];
|
|
595
|
+
if (named.length > 0) {
|
|
596
|
+
lines.push(`import { ${named.join(', ')} } from '${modulePath}';`);
|
|
597
|
+
}
|
|
598
|
+
for (const name of defaults) {
|
|
599
|
+
lines.push(`import ${name} from '${modulePath}/${name}.astro';`);
|
|
600
|
+
}
|
|
601
|
+
return lines.join('\n');
|
|
602
|
+
})
|
|
603
|
+
.filter(Boolean)
|
|
604
|
+
.join('\n');
|
|
605
|
+
|
|
606
|
+
const jsxContent = fragments.join('\n');
|
|
607
|
+
|
|
608
|
+
return new AstroModuleBuilder()
|
|
609
|
+
.withRuntimeImports()
|
|
610
|
+
.addImports(userImports)
|
|
611
|
+
.addImports(componentImportLines.split('\n'))
|
|
612
|
+
.withFrontmatter(frontmatter)
|
|
613
|
+
.withHeadings(headings)
|
|
614
|
+
.withJsxContent(jsxContent)
|
|
615
|
+
.withModuleId(filename)
|
|
616
|
+
.build();
|
|
617
|
+
}
|