Skip to content
This repository was archived by the owner on Apr 27, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 22 additions & 89 deletions crates/core/src/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ pub struct DirectiveMappingResult {
/// content: "<p>Hello</p>".to_string(),
/// }];
///
/// // Without directive mapping
/// // Without directive mapping - HTML blocks use Fragment with set:html
/// let jsx = blocks_to_jsx_string(&blocks, None::<fn(&str) -> Option<DirectiveMappingResult>>);
/// assert_eq!(jsx, "<p>Hello</p>");
/// assert_eq!(jsx, "<Fragment set:html={\"<p>Hello</p>\"} />");
/// ```
pub fn blocks_to_jsx_string<F>(blocks: &[RenderBlock], directive_mapper: Option<F>) -> String
where
Expand Down Expand Up @@ -105,8 +105,11 @@ where
for block in blocks {
match block {
RenderBlock::Html { content } => {
let escaped = sanitize_html_block_for_jsx(content);
result.push_str(&escaped);
// Use set:html to avoid HTML entity parsing issues with esbuild
// JSON.stringify handles all escaping; Astro parses the HTML at runtime
result.push_str("<Fragment set:html={");
result.push_str(&js_string_literal(content));
result.push_str("} />");
}
RenderBlock::Component {
name,
Expand All @@ -115,8 +118,6 @@ where
} => {
// Apply slot normalization based on registry configuration
let slot_html = normalize_slot_by_registry(name, slot_html, registry);
// Escape raw braces in slot HTML to avoid JSX expression parsing
let slot_html = slot_html.replace('{', "&#123;").replace('}', "&#125;");

// Apply directive mapping if provided
let (tag_name, type_prop) = if let Some(ref mapper) = directive_mapper {
Expand Down Expand Up @@ -170,90 +171,18 @@ where
result.push_str("}}");
}

result.push('>');
result.push_str(&slot_html);
result.push_str("</");
result.push_str(&tag_name);
result.push('>');
}
}
}
result
}

/// Escapes JSX-sensitive characters inside raw HTML blocks.
///
/// - Escapes `{` and `}` globally so they are not parsed as JSX expressions.
/// - For backtick-delimited code spans (`, ```, etc.), wraps the contents in `<code>`
/// and escapes `<`, `>`, `&`, and braces so code examples remain literal.
/// - For `<script>` / `<style>` blocks we only escape braces to avoid breaking
/// the embedded code while still keeping JSX safe.
fn sanitize_html_block_for_jsx(content: &str) -> String {
let lower = content.to_ascii_lowercase();
let is_script_or_style =
lower.contains("<script") || lower.contains("<style") || lower.contains("</script>");

// For script/style we must keep the exact JS/CSS text (including braces) because
// escaping them to entities breaks the embedded code. These tags are wrapped in JSX
// as children text, which is allowed even when containing braces.
if is_script_or_style {
return content.to_string();
}

let mut out = String::with_capacity(content.len());
let mut chars = content.chars().peekable();

while let Some(ch) = chars.next() {
if ch == '`' {
// Count how many backticks start the span (supports ``` as well).
let mut tick_count = 1;
while let Some('`') = chars.peek() {
tick_count += 1;
chars.next();
}

let mut code = String::new();
while let Some(next) = chars.next() {
if next == '`' {
let mut end_ticks = 1;
while let Some('`') = chars.peek() {
end_ticks += 1;
chars.next();
}
if end_ticks == tick_count {
break;
} else {
code.push_str(&"`".repeat(end_ticks));
continue;
}
// Use set:html for slot content to avoid HTML entity parsing issues
if slot_html.is_empty() {
result.push_str(" />");
} else {
code.push(next);
result.push_str(" set:html={");
result.push_str(&js_string_literal(&slot_html));
result.push_str("} />");
}
}

// Escape code span contents
out.push_str("<code>");
for c in code.chars() {
match c {
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'&' => out.push_str("&amp;"),
'{' => out.push_str("&#123;"),
'}' => out.push_str("&#125;"),
_ => out.push(c),
}
}
out.push_str("</code>");
} else {
match ch {
'{' => out.push_str("&#123;"),
'}' => out.push_str("&#125;"),
_ => out.push(ch),
}
}
}

out
result
}

/// Applies slot normalization based on registry configuration.
Expand Down Expand Up @@ -548,9 +477,10 @@ mod tests {
slot_html: "<p>Content</p>".to_string(),
}];
let jsx = blocks_to_jsx_string(&blocks, None::<fn(&str) -> Option<DirectiveMappingResult>>);
// Component slot content uses set:html
assert_eq!(
jsx,
"<Card {...{\"title\": \"中文\\\"引用\\\"标题\"}}><p>Content</p></Card>"
"<Card {...{\"title\": \"中文\\\"引用\\\"标题\"}} set:html={\"<p>Content</p>\"} />"
);
}

Expand All @@ -560,7 +490,8 @@ mod tests {
content: "<p>Hello</p>".to_string(),
}];
let jsx = blocks_to_jsx_string(&blocks, None::<fn(&str) -> Option<DirectiveMappingResult>>);
assert_eq!(jsx, "<p>Hello</p>");
// HTML blocks are now wrapped in Fragment with set:html
assert_eq!(jsx, "<Fragment set:html={\"<p>Hello</p>\"} />");
}

#[test]
Expand All @@ -578,9 +509,10 @@ mod tests {
slot_html: "<p>Content</p>".to_string(),
}];
let jsx = blocks_to_jsx_string(&blocks, None::<fn(&str) -> Option<DirectiveMappingResult>>);
// Component slot content uses set:html
assert_eq!(
jsx,
"<Card {...{\"title\": \"Hello\"}}><p>Content</p></Card>"
"<Card {...{\"title\": \"Hello\"}} set:html={\"<p>Content</p>\"} />"
);
}

Expand Down Expand Up @@ -610,9 +542,10 @@ mod tests {
};

let jsx = blocks_to_jsx_string(&blocks, Some(mapper));
// Component slot content uses set:html
assert_eq!(
jsx,
"<Aside type=\"note\" {...{\"title\": \"Important\"}}><p>Content</p></Aside>"
"<Aside type=\"note\" {...{\"title\": \"Important\"}} set:html={\"<p>Content</p>\"} />"
);
}

Expand Down
11 changes: 7 additions & 4 deletions crates/core/src/renderer/mdast/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -863,8 +863,9 @@ export const authClient = createAuthClient();
let jsx =
crate::codegen::blocks_to_jsx_string(&result.blocks, None::<fn(&str) -> Option<_>>);
assert!(jsx.contains("createAuthClient"));
assert!(jsx.contains("&#123;") && jsx.contains("&#125;"));
assert!(!jsx.contains("{ createAuthClient }"));
// With set:html, braces are preserved as-is in the JSON string (escaped by JSON.stringify)
// The important thing is they're inside a JSON string, not interpreted as JSX expressions
assert!(jsx.contains("set:html="));
}

#[test]
Expand Down Expand Up @@ -917,7 +918,9 @@ export const authClient = createAuthClient();
let result = to_blocks(input, &options).unwrap();
let jsx =
crate::codegen::blocks_to_jsx_string(&result.blocks, None::<fn(&str) -> Option<_>>);
assert!(jsx.contains("&lt;PreactBanner client:load /&gt;"));
assert!(!jsx.contains("<PreactBanner"));
// With set:html, HTML is inside a JSON string - the important thing is
// that the raw component tag is not interpreted as JSX
assert!(jsx.contains("set:html="));
assert!(jsx.contains("PreactBanner"));
}
}
6 changes: 4 additions & 2 deletions crates/napi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -435,8 +435,9 @@ mod tests {
"fenced import should not be hoisted: {}",
result.code
);
// HTML blocks are now wrapped with Fragment set:html, so quotes are escaped in JSON
assert!(
result.code.contains(r#"<pre class="astro-code""#)
result.code.contains(r#"<pre class=\"astro-code\""#)
&& result.code.contains("import Y from './y'"),
"fenced import should stay in rendered JSX: {}",
result.code
Expand Down Expand Up @@ -539,8 +540,9 @@ mod tests {
"fenced export should stay in JSX body: {}",
result.code
);
// HTML is now inside a JSON string literal via set:html, so quotes are escaped
assert!(
result.code.contains(r#"<pre class="astro-code""#)
result.code.contains(r#"<pre class=\"astro-code\""#)
&& result.code.contains("export const no = true"),
"fenced export should stay in rendered JSX: {}",
result.code
Expand Down
2 changes: 1 addition & 1 deletion crates/napi/tests/auto_import.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ test('compile converts directive to Aside component', (t) => {
const source = ':::note\nBody\n:::';
const result = compiler.compile(source, '/virtual.mdx');

// Directive should be converted to Aside component
// Directive should be converted to Aside component with type in spread props
t.true(result.code.includes('<Aside'));
t.true(result.code.includes('"type": "note"'));
});
Expand Down
11 changes: 7 additions & 4 deletions crates/napi/tests/parse.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ test('compileIr() handles images', (t) => {
const input = '![Alt text](image.png)';
const result = compileIr(input, '/virtual.md');

t.true(result.html.includes('alt="Alt text"'));
t.true(result.html.includes('src="image.png"'));
// HTML is now inside a JSON string (via set:html), so quotes are escaped
t.true(result.html.includes('alt=\\"Alt text\\"'));
t.true(result.html.includes('src=\\"image.png\\"'));
});

test('compileIr() handles lists', (t) => {
Expand All @@ -50,14 +51,16 @@ test('compileIr() assigns heading ids', (t) => {
const input = '# Hello Heading';
const result = compileIr(input, '/virtual.md');

t.true(result.html.includes('id="hello-heading"'));
// HTML is now inside a JSON string (via set:html), so quotes are escaped
t.true(result.html.includes('id=\\"hello-heading\\"'));
});

test('compileIr() handles links', (t) => {
const input = '[Link text](https://example.com)';
const result = compileIr(input, '/virtual.md');

t.true(result.html.includes('<a href="https://example.com">'));
// HTML is now inside a JSON string (via set:html), so quotes are escaped
t.true(result.html.includes('<a href=\\"https://example.com\\">'));
t.true(result.html.includes('Link text'));
t.true(result.html.includes('</a>'));
});
Expand Down
5 changes: 3 additions & 2 deletions crates/napi/tests/parseWithOptions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ test('compileIr() handles images', (t) => {
const input = '![alt](image.png)';
const result = compileIr(input, '/virtual.md');

t.true(result.html.includes('alt="alt"'));
t.true(result.html.includes('src="image.png"'));
// HTML is now inside a JSON string (via set:html), so quotes are escaped
t.true(result.html.includes('alt=\\"alt\\"'));
t.true(result.html.includes('src=\\"image.png\\"'));
});

test('compileIr() handles raw HTML img tags', (t) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const compiler = createCompiler()
const source = readFileSync(directivesPath, 'utf8')
const result = compiler.compile(source, directivesPath)

// The compiler generates JSX with spread syntax: <Aside {...{"type": "note"}}>
// The compiler generates JSX with type in spread props: <Aside {...{"type": "note"}} ...>
assert(result.code.includes('<Aside'), 'Compiled code should contain Aside component')
assert(result.code.includes('"type": "note"'), 'Compiled code should contain type="note" prop')

Expand Down
25 changes: 6 additions & 19 deletions fixtures/integration/withastro-docs/harness-summary.json
Original file line number Diff line number Diff line change
@@ -1,21 +1,8 @@
{
"timestamp": "2026-01-24T12:09:28.063Z",
"baselineMs": 120287,
"markflowMs": 116543,
"mode": "semantic",
"semantic": {
"compared": 12,
"skipped": 0,
"differences": 1,
"samples": [
{
"file": "/en/reference/astro-syntax/",
"reason": "content-diff",
"diffIndex": 26069,
"previewA": "essions. JSX-like Expressions Section titled “JSX-like Expressions” You can define local JavaScript variables ",
"previewB": "essions. JSX-like Expressions You can define local JavaScript variables inside of the frontmatter component sc"
}
]
},
"note": "semantic diff: pass if differences=0"
"timestamp": "2026-01-25T07:26:38.164Z",
"baselineMs": 141582,
"markflowMs": 133174,
"mode": "time",
"semantic": null,
"note": "build-only; no HTML diff"
}
29 changes: 14 additions & 15 deletions packages/astro-markflow/src/transforms/blocks-to-jsx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,6 @@ function escapeJsString(value: string): string {
return JSON.stringify(String(value)).slice(1, -1);
}

/**
* Sanitizes raw HTML content for JSX embedding.
* Escapes { and } to prevent esbuild from interpreting them as JSX expressions.
*/
function sanitizeHtmlForJsx(content: string): string {
return content.replaceAll('{', '&#123;').replaceAll('}', '&#125;');
}

/**
* Normalizes slot content based on a slot normalization strategy.
* Ensures content is wrapped in the appropriate list structure.
Expand Down Expand Up @@ -97,7 +89,9 @@ export function blocksToJsx(

for (const block of blocks) {
if (block.type === 'html') {
fragments.push(sanitizeHtmlForJsx(block.content ?? ''));
// Use set:html to avoid HTML entity parsing issues with esbuild
// JSON.stringify handles all escaping; Astro parses the HTML at runtime
fragments.push(`<_Fragment set:html={${JSON.stringify(block.content ?? '')}} />`);
} else if (block.type === 'component') {
// Handle directive components using registry
const isDirective = block.name ? supportedDirectives.includes(block.name) : false;
Expand Down Expand Up @@ -129,8 +123,6 @@ export function blocksToJsx(
if (slotNorm) {
effectiveSlot = normalizeSlotByStrategy(effectiveSlot, slotNorm.strategy);
}
// Escape raw JSX braces inside slot HTML to prevent expression evaluation
effectiveSlot = sanitizeHtmlForJsx(effectiveSlot);

// Skip Fragment - it's a built-in Astro component
if (componentName !== 'Fragment') {
Expand Down Expand Up @@ -159,10 +151,17 @@ export function blocksToJsx(
})
.join(' ')
: '';
const openTag = propsStr
? `<${componentName} ${propsStr}>`
: `<${componentName}>`;
fragments.push(`${openTag}${effectiveSlot}</${componentName}>`);

// Use set:html for slot content to avoid HTML entity parsing issues with esbuild
// JSON.stringify handles all escaping; Astro parses the HTML at runtime
if (effectiveSlot) {
const allProps = propsStr
? `${propsStr} set:html={${JSON.stringify(effectiveSlot)}}`
: `set:html={${JSON.stringify(effectiveSlot)}}`;
fragments.push(`<${componentName} ${allProps} />`);
} else {
fragments.push(propsStr ? `<${componentName} ${propsStr} />` : `<${componentName} />`);
}
}
}

Expand Down
Loading