/**
* Transforms compiled blocks into JSX code
* @module transforms/blocks-to-jsx
*/
import type { HeadingEntry } from 'xmdx';
import { asPropValue } from '../ops/type-narrowing.js';
/**
* Minimal registry interface consumed by blocksToJsx.
* Avoids coupling to the full Registry type.
*/
export interface BlocksRegistry {
getSupportedDirectives(): string[];
getDirectiveMapping(directive: string): { component: string; injectProps?: Record } | undefined;
getSlotNormalization(component: string): { strategy: 'wrap_in_ol' | 'wrap_in_ul' } | undefined;
getComponent(name: string): { modulePath: string; exportType: string } | undefined;
}
import { htmlEntitiesToJsx, hasPascalCaseTag } from '@xmdx/napi';
/**
* Prop value from the Rust compiler.
*/
export interface PropValue {
type: 'literal' | 'expression';
value: string;
}
/**
* Block from the Rust compiler.
*/
export interface Block {
type: 'html' | 'component' | 'code';
content?: string;
name?: string;
props?: Record;
slotChildren?: Block[];
/** Code content (for type="code") */
code?: string;
/** Code language (for type="code") */
lang?: string;
/** Code meta string (for type="code") */
meta?: string;
}
/**
* Escapes HTML special characters for safe embedding in HTML content.
*/
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/`/g, '`')
.replace(/\{/g, '{')
.replace(/\}/g, '}')
.replace(/\n/g, '
');
}
/**
* Converts structured slot children blocks to an HTML string.
* Used to process slot content that needs to be embedded as HTML.
*/
function slotChildrenToHtml(
blocks: Block[],
componentImports?: Map,
registry?: BlocksRegistry,
userImportedNames?: Set,
): string {
let result = '';
for (const block of blocks) {
if (block.type === 'html') {
// Escape braces so JSX text does not become expressions
result += (block.content ?? '').replace(/\{/g, '{').replace(/\}/g, '}');
} else if (block.type === 'code') {
// Always render as HTML ; ExpressiveCode rewriting happens in pipeline
const langAttr = block.lang ? ` class="language-${escapeHtml(block.lang)}"` : '';
result += `${escapeHtml(block.code ?? '')}
`;
} else if (block.type === 'component') {
const innerHtml = slotChildrenToHtml(block.slotChildren ?? [], componentImports, registry, userImportedNames);
// Fragment-with-slot: render as
// so Astro's slot distribution works (Fragment VNodes are unwrapped,
// losing the slot prop).
const slotProp = block.props?.slot;
const slotName =
typeof slotProp === 'object' && slotProp !== null && 'type' in slotProp && 'value' in slotProp
? asPropValue(slotProp).value
: typeof slotProp === 'string'
? slotProp
: undefined;
const isFragmentSlot = block.name === 'Fragment' && slotName !== undefined;
const tag = isFragmentSlot ? 'span' : (block.name ?? '');
result += `<${tag}`;
if (isFragmentSlot) {
result += ' style="display:contents"';
}
if (block.props) {
for (const [key, value] of Object.entries(block.props)) {
if (typeof value === 'object' && value !== null && 'type' in value && 'value' in value) {
const pv = asPropValue(value);
if (isFragmentSlot && key === 'slot') {
result += ` slot="${escapeJsString(pv.value)}"`;
} else if (pv.type === 'literal') {
result += ` ${key}="${escapeJsString(pv.value)}"`;
} else {
result += ` ${key}={${pv.value}}`;
}
}
}
}
result += `>${innerHtml}${tag}>`;
}
}
return result;
}
/**
* Escapes a string value for use in JSX prop.
* Uses JSON.stringify for proper JS string escaping.
*/
function escapeJsString(value: string): string {
// Use JSON.stringify which handles all JS escaping, then remove the outer quotes
return JSON.stringify(String(value)).slice(1, -1);
}
const VOID_HTML_TAGS = [
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'link',
'meta',
'param',
'source',
'track',
'wbr',
];
const VOID_TAG_PATTERN = new RegExp(`<(${VOID_HTML_TAGS.join('|')})\\b([^<>]*?)?>`, 'gi');
const PRE_BLOCK_PATTERN = /]*>[\s\S]*?<\/pre>/gi;
function selfCloseVoidTags(html: string): string {
return html.replace(VOID_TAG_PATTERN, (match, tag, attrs = '') => {
if (match.endsWith('/>')) {
return match;
}
return `<${tag}${attrs} />`;
});
}
function escapeBracesInPre(html: string): string {
return html.replace(PRE_BLOCK_PATTERN, (match) => {
const openTagMatch = match.match(/^]*>/i);
const closeTagMatch = match.match(/<\/pre>$/i);
if (!openTagMatch || !closeTagMatch) {
return match;
}
const openTag = openTagMatch[0];
const closeTag = closeTagMatch[0];
const inner = match.slice(openTag.length, match.length - closeTag.length);
const escaped = inner.replace(/\{/g, '{').replace(/\}/g, '}');
return `${openTag}${escaped}${closeTag}`;
});
}
function normalizeHtmlForJsx(html: string): string {
// Ensure JSX-safe output when embedding raw HTML alongside components.
return escapeBracesInPre(selfCloseVoidTags(html));
}
/**
* Finds the position of `>` that closes a tag, respecting quoted attribute values
* and JSX expression braces.
* Returns -1 if not found.
*/
function findTagEnd(input: string, start: number): number {
let i = start;
let inQuote = false;
let quoteChar = '"';
let braceDepth = 0;
while (i < input.length) {
const ch = input[i];
if (inQuote) {
if (ch === '\\' && i + 1 < input.length) {
i += 2; // Skip escaped character
continue;
}
if (ch === quoteChar) {
inQuote = false;
}
i++;
} else if (braceDepth > 0) {
// Inside JSX expression - track nested braces and strings
if (ch === '{') {
braceDepth++;
} else if (ch === '}') {
braceDepth--;
} else if (ch === '"' || ch === "'") {
inQuote = true;
quoteChar = ch;
}
i++;
} else {
if (ch === '"' || ch === "'") {
inQuote = true;
quoteChar = ch;
i++;
} else if (ch === '{') {
braceDepth = 1;
i++;
} else if (ch === '>') {
return i;
} else {
i++;
}
}
}
return -1;
}
/**
* Checks if a tag ending at position `tagEnd` is self-closing (ends with `/>`)
* respecting quoted attribute values and JSX expression braces.
* Returns true if the `/` before `>` is outside of quotes and braces.
*/
function isSelfClosingTag(input: string, start: number, tagEnd: number): boolean {
// We need to check if the character before tagEnd is `/` AND that it's outside quotes/braces
if (tagEnd < 1 || input[tagEnd - 1] !== '/') {
return false;
}
// Walk from start to tagEnd-1 to verify the `/` is outside quotes and braces
let i = start;
let inQuote = false;
let quoteChar = '"';
let braceDepth = 0;
while (i < tagEnd - 1) {
const ch = input[i];
if (inQuote) {
if (ch === '\\' && i + 1 < input.length) {
i += 2;
continue;
}
if (ch === quoteChar) {
inQuote = false;
}
i++;
} else if (braceDepth > 0) {
// Inside JSX expression - track nested braces and strings
if (ch === '{') {
braceDepth++;
} else if (ch === '}') {
braceDepth--;
} else if (ch === '"' || ch === "'") {
inQuote = true;
quoteChar = ch;
}
i++;
} else {
if (ch === '"' || ch === "'") {
inQuote = true;
quoteChar = ch;
} else if (ch === '{') {
braceDepth = 1;
}
i++;
}
}
// The `/` at tagEnd-1 is outside quotes/braces if we ended outside both
return !inQuote && braceDepth === 0;
}
/**
* Find the next exact ' || afterTag === '/') {
return idx;
}
// Not an exact match (e.g., ` wrappers from Fragment elements with slot attributes.
*
* markdown-rs sometimes wraps `` in paragraph tags,
* which breaks Astro's slot system because the slot attribute is on Fragment,
* not on the wrapping ``.
*
* Before: `
content
`
* After: `content`
*
* Handles edge cases:
* - Self-closing: `
`
* - Nested Fragments: counts depth to find matching closing tag
*/
function stripParagraphFragmentWrappers(input: string): string {
let result = '';
let cursor = 0;
while (cursor < input.length) {
const matchStart = input.indexOf('';
cursor = matchStart + 3;
continue;
}
// Check for self-closing Fragment:
// Must check that /> is outside of quoted attributes
if (isSelfClosingTag(input, fragmentStart, tagEnd)) {
const afterClose = input.slice(tagEnd + 1);
if (afterClose.startsWith('
')) {
// Self-closing:
->
result += input.slice(fragmentStart, tagEnd + 1);
cursor = tagEnd + 1 + 4; // Skip "/>
"
continue;
}
// Self-closing but no follows, preserve the
result += '
';
cursor = matchStart + 3;
continue;
}
// Non-self-closing: count depth to find matching
let depth = 1;
let searchPos = tagEnd + 1;
let fragmentEndPos = -1;
while (searchPos < input.length && depth > 0) {
const nextOpen = findNextFragmentOpen(input, searchPos);
const nextClose = input.indexOf('', searchPos);
if (nextClose === -1) {
// No closing tag found, malformed
break;
}
// Check if there's an opening tag before the closing tag
if (nextOpen !== -1 && nextOpen < nextClose) {
// Find end of this opening tag (respecting quoted attributes)
const openTagEnd = findTagEnd(input, nextOpen);
if (openTagEnd !== -1 && !isSelfClosingTag(input, nextOpen, openTagEnd)) {
// Non-self-closing nested Fragment, increase depth
depth++;
}
searchPos = openTagEnd !== -1 ? openTagEnd + 1 : nextOpen + 9;
} else {
// Closing tag comes first (or no more opening tags)
depth--;
if (depth === 0) {
fragmentEndPos = nextClose;
}
searchPos = nextClose + ''.length;
}
}
if (fragmentEndPos !== -1) {
const fragmentEnd = fragmentEndPos + ''.length;
const afterFragment = input.slice(fragmentEnd);
if (afterFragment.startsWith('
')) {
// Found matching pattern: ...
result += input.slice(fragmentStart, fragmentEnd);
cursor = fragmentEnd + 4; // Skip ""
continue;
}
}
// No match found, just push the "" and continue
result += '
';
cursor = matchStart + 3;
}
return result;
}
/**
* Normalizes slot content based on a slot normalization strategy.
* Ensures content is wrapped in the appropriate list structure.
*
* @param slot - The slot HTML content to normalize
* @param strategy - The normalization strategy ('wrap_in_ol' or 'wrap_in_ul')
* @returns Normalized slot content with proper list wrapping
*/
function normalizeSlotByStrategy(slot: string, strategy: 'wrap_in_ol' | 'wrap_in_ul'): string {
const tag = strategy === 'wrap_in_ol' ? 'ol' : 'ul';
const trimmed = slot.trim();
// Empty content: create minimal valid structure
if (!trimmed) {
return `<${tag}>
${tag}>`;
}
// Check if content already has the correct wrapper
if (trimmed.startsWith(`<${tag}`) && trimmed.endsWith(`${tag}>`)) {
// If already wrapped but missing , add one
return /]/i.test(trimmed)
? slot
: trimmed.replace(`${tag}>`, `${tag}>`);
}
// Content needs wrapping
return /]/i.test(trimmed)
? `<${tag}>${slot}${tag}>`
: `<${tag}>${slot}${tag}>`;
}
/**
* Builder for constructing Astro module code.
* Encapsulates the assembly of imports, exports, and content.
*/
export class AstroModuleBuilder {
private imports: string[] = [];
private frontmatterData: Record = {};
private headingsData: HeadingEntry[] = [];
private jsxContentStr = '';
private moduleIdValue?: string;
/**
* Adds standard Astro runtime imports.
*/
withRuntimeImports(): this {
this.imports.push(
`import { createComponent, renderJSX } from 'astro/runtime/server/index.js';`,
`import { Fragment, Fragment as _Fragment, jsx as _jsx } from 'astro/jsx-runtime';`,
);
return this;
}
/**
* Adds a single import statement.
*/
addImport(line: string): this {
if (line) {
this.imports.push(line);
}
return this;
}
/**
* Adds multiple import statements.
*/
addImports(lines: string[]): this {
for (const line of lines) {
this.addImport(line);
}
return this;
}
/**
* Sets the frontmatter data to export.
*/
withFrontmatter(data: Record): this {
this.frontmatterData = data;
return this;
}
/**
* Sets the headings data to export.
*/
withHeadings(headings: HeadingEntry[]): this {
this.headingsData = headings;
return this;
}
/**
* Sets the JSX content for the component.
*/
withJsxContent(jsx: string): this {
this.jsxContentStr = jsx;
return this;
}
/**
* Sets the module ID (filename) for the component.
*/
withModuleId(filename?: string): this {
this.moduleIdValue = filename;
return this;
}
/**
* Builds the complete Astro module code.
*/
build(): string {
const allImports = this.imports.filter(Boolean).join('\n');
const frontmatterJson = JSON.stringify(this.frontmatterData);
const headingsJson = JSON.stringify(this.headingsData);
const moduleId = this.moduleIdValue ? JSON.stringify(this.moduleIdValue) : 'undefined';
return `${allImports}
export const frontmatter = ${frontmatterJson};
export function getHeadings() { return ${headingsJson}; }
function _Content() {
return (
<_Fragment>
${this.jsxContentStr}
);
}
const XmdxContent = createComponent(
(result, props, _slots) => renderJSX(result, _jsx(_Content, { ...props })),
${moduleId}
);
export const Content = XmdxContent;
export default XmdxContent;
`;
}
}
/**
* Extract imported names from a list of import statements.
* Handles default imports, namespace imports, and named imports.
*/
function extractNamesFromImports(imports: string[]): Set {
const names = new Set();
for (const imp of imports) {
// Default import: import Foo from 'module'
const defaultMatch = imp.match(/^import\s+([A-Za-z$_][\w$]*)\s*(?:,|\s+from\s)/);
if (defaultMatch?.[1]) {
names.add(defaultMatch[1]);
}
// Namespace import: import * as Foo from 'module'
const namespaceMatch = imp.match(/^import\s+\*\s+as\s+([A-Za-z$_][\w$]*)\s+from/);
if (namespaceMatch?.[1]) {
names.add(namespaceMatch[1]);
}
// Named imports: import { Foo, Bar as Baz } from 'module'
// Also handles: import Default, { Foo, Bar } from 'module'
const namedMatch = imp.match(/import\s+(?:[A-Za-z$_][\w$]*\s*,\s*)?{([^}]+)}\s+from/);
if (namedMatch?.[1]) {
const parts = namedMatch[1].split(',');
for (const part of parts) {
const item = part.trim();
if (!item) continue;
const segments = item.split(/\s+as\s+/);
const name = segments[1] ?? segments[0];
if (name) {
names.add(name.trim());
}
}
}
}
return names;
}
/**
* Converts blocks array from Rust compiler into JSX code with component imports and exports.
*
* @param blocks - Array of blocks from compiler
* @param frontmatter - Frontmatter object to export
* @param headings - Headings array to export
* @param registry - Component registry for import resolution
* @param filename - Optional filename for module ID
* @param userImports - User import statements to preserve (these take precedence over registry)
* @returns Complete JSX module code with imports, exports, and default component
*/
export function blocksToJsx(
blocks: Block[],
frontmatter: Record = {},
headings: HeadingEntry[] = [],
registry: BlocksRegistry | null = null,
filename?: string,
userImports: string[] = [],
): string {
const fragments: string[] = [];
const componentImports = new Map();
// Extract names from user imports to avoid generating duplicate imports
const userImportedNames = extractNamesFromImports(userImports);
// Get supported directives from registry if available
const supportedDirectives = registry?.getSupportedDirectives() ?? [];
// Buffer for accumulating consecutive HTML content.
// Instead of creating a Fragment for every html/code block, we accumulate
// consecutive static content and flush to a single Fragment only when we
// encounter a component (which requires its own JSX element).
// This reduces Fragment count from ~50 to ~3-5 per page, significantly
// reducing renderJSX calls during Astro SSG.
let htmlBuffer = '';
/**
* Flushes accumulated HTML buffer to fragments array.
* Called before components and at the end of processing.
*/
const flushHtmlBuffer = () => {
if (htmlBuffer) {
if (hasPascalCaseTag(htmlBuffer) && htmlBuffer.includes('={')) {
fragments.push(htmlEntitiesToJsx(normalizeHtmlForJsx(htmlBuffer)));
} else {
fragments.push(`<_Fragment set:html={${JSON.stringify(htmlBuffer)}} />`);
}
htmlBuffer = '';
}
};
for (const block of blocks) {
if (block.type === 'html') {
// Accumulate HTML content in buffer instead of creating individual Fragments
htmlBuffer += block.content ?? '';
} else if (block.type === 'code') {
// Accumulate code block HTML in buffer
// ExpressiveCode rewriting happens in pipeline before this
const langAttr = block.lang ? ` class="language-${escapeHtml(block.lang)}"` : '';
htmlBuffer += `${escapeHtml(block.code ?? '')}
`;
} else if (block.type === 'component') {
// Flush accumulated HTML before component
flushHtmlBuffer();
// Handle directive components using registry
const isDirective = block.name ? supportedDirectives.includes(block.name) : false;
let componentName = block.name ?? '';
let effectiveProps = block.props;
// Separate Fragment-with-slot children from regular children.
// Fragment VNodes with `slot` props are unwrapped by Astro's renderJSX
// before slot distribution, losing the slot assignment. We render them
// as instead.
const allChildren = block.slotChildren ?? [];
const regularChildren: Block[] = [];
const fragmentSlotChildren: { slotName: string; inner: Block[] }[] = [];
for (const child of allChildren) {
if (
child.type === 'component' &&
child.name === 'Fragment' &&
child.props
) {
const slotProp = child.props.slot;
const slotName =
typeof slotProp === 'object' && slotProp !== null && 'type' in slotProp && 'value' in slotProp
? asPropValue(slotProp).value
: typeof slotProp === 'string'
? slotProp
: undefined;
if (slotName) {
fragmentSlotChildren.push({ slotName, inner: child.slotChildren ?? [] });
continue;
}
}
regularChildren.push(child);
}
// Convert regular (non-fragment-slot) children to HTML string for slot processing
let effectiveSlot = stripParagraphFragmentWrappers(
slotChildrenToHtml(regularChildren, componentImports, registry ?? undefined, userImportedNames)
);
if (isDirective && registry && block.name) {
const mapping = registry.getDirectiveMapping(block.name);
if (mapping) {
componentName = mapping.component;
// Apply injected props from mapping
if (mapping.injectProps) {
const injectedProps: Record = {};
for (const [propKey, propSource] of Object.entries(mapping.injectProps)) {
if (propSource.source === 'directive_name') {
injectedProps[propKey] = { type: 'literal', value: block.name };
} else if (propSource.source === 'literal' && propSource.value) {
injectedProps[propKey] = { type: 'literal', value: propSource.value };
}
}
effectiveProps = { ...block.props, ...injectedProps };
}
}
}
// Apply slot normalization from registry (e.g., Steps → wrap_in_ol, FileTree → wrap_in_ul)
const slotNorm = registry?.getSlotNormalization(componentName);
if (slotNorm) {
effectiveSlot = normalizeSlotByStrategy(effectiveSlot, slotNorm.strategy);
}
// Skip Fragment (built-in) and user-imported components
if (componentName !== 'Fragment' && !userImportedNames.has(componentName)) {
const componentDef = registry?.getComponent(componentName);
const modulePath = componentDef?.modulePath ?? '@astrojs/starlight/components';
const exportType = componentDef?.exportType ?? 'default';
componentImports.set(componentName, { modulePath, exportType });
}
const propsStr = effectiveProps
? Object.entries(effectiveProps)
.map(([key, value]) => {
// Handle PropValue enum from Rust: { type: "literal"|"expression", value: string }
if (typeof value === 'object' && value !== null && 'type' in value && 'value' in value) {
const propValue = asPropValue(value);
if (propValue.type === 'literal') {
return `${key}="${escapeJsString(propValue.value)}"`;
} else if (propValue.type === 'expression') {
return `${key}={${propValue.value}}`;
}
}
if (typeof value === 'string') {
return `${key}="${escapeJsString(value)}"`;
}
return `${key}={${JSON.stringify(value)}}`;
})
.join(' ')
: '';
// Handle slot content: use set:html for pure HTML, but embed JSX directly for nested components
const hasAnyContent = effectiveSlot || fragmentSlotChildren.length > 0;
if (hasAnyContent) {
const propsAttr = propsStr ? ` ${propsStr}` : '';
let children = '';
// Default slot content (regular children)
if (effectiveSlot) {
// Check if slot contains JSX components (true PascalCase tags like `;
}
}
// Named slot children: render as
// Using a real HTML element (not Fragment) so Astro's slot distribution
// correctly assigns the content to the named slot.
for (const { slotName, inner } of fragmentSlotChildren) {
const innerHtml = slotChildrenToHtml(inner, componentImports, registry ?? undefined, userImportedNames);
children += ``;
if (innerHtml) {
if (hasPascalCaseTag(innerHtml)) {
const normalizedInnerHtml = normalizeHtmlForJsx(innerHtml);
children += htmlEntitiesToJsx(normalizedInnerHtml);
} else {
children += `<_Fragment set:html={${JSON.stringify(innerHtml)}} />`;
}
}
children += '';
}
fragments.push(`<${componentName}${propsAttr}>${children}${componentName}>`);
} else {
fragments.push(propsStr ? `<${componentName} ${propsStr} />` : `<${componentName} />`);
}
}
}
// Flush any remaining HTML content after the last block
flushHtmlBuffer();
// Generate imports grouped by module path
const importsByModule = new Map();
for (const [name, { modulePath, exportType }] of componentImports) {
if (!importsByModule.has(modulePath)) {
importsByModule.set(modulePath, { named: [], default: [] });
}
const entry = importsByModule.get(modulePath)!;
if (exportType === 'named') {
entry.named.push(name);
} else {
entry.default.push(name);
}
}
const componentImportLines = Array.from(importsByModule.entries())
.map(([modulePath, { named, default: defaults }]) => {
const lines: string[] = [];
if (named.length > 0) {
lines.push(`import { ${named.join(', ')} } from '${modulePath}';`);
}
for (const name of defaults) {
lines.push(`import ${name} from '${modulePath}/${name}.astro';`);
}
return lines.join('\n');
})
.filter(Boolean)
.join('\n');
const jsxContent = fragments.join('\n');
return new AstroModuleBuilder()
.withRuntimeImports()
.addImports(userImports)
.addImports(componentImportLines.split('\n'))
.withFrontmatter(frontmatter)
.withHeadings(headings)
.withJsxContent(jsxContent)
.withModuleId(filename)
.build();
}