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
41 changes: 39 additions & 2 deletions crates/core/src/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ pub fn has_pascal_case_tag(s: &str) -> bool {
pub fn html_entities_to_jsx(s: &str) -> String {
let mut result = String::with_capacity(s.len() * 2);
let mut i = 0;
let mut pre_depth: usize = 0;

// First pass: Handle curly braces in JSX expression attribute contexts
// ={ → ={ and }> → }> etc.
Expand All @@ -143,8 +144,21 @@ pub fn html_entities_to_jsx(s: &str) -> String {
if bytes[i] == b'<' {
// Find the end of the tag
if let Some(tag_end) = find_tag_end(&bytes[i..]) {
// Copy tag as-is (don't convert entities in attributes)
let tag_slice = &preprocessed[i..i + tag_end + 1];

// Track <pre> depth
let tag_lower = tag_slice.to_ascii_lowercase();
if tag_lower.starts_with("<pre")
&& (tag_slice.len() == 5 || !tag_slice.as_bytes()[4].is_ascii_alphanumeric())
{
pre_depth += 1;
} else if tag_lower.starts_with("</pre")
&& (tag_slice.len() == 6 || !tag_slice.as_bytes()[5].is_ascii_alphanumeric())
{
pre_depth = pre_depth.saturating_sub(1);
}

// Copy tag as-is (don't convert entities in attributes)
result.push_str(tag_slice);
i += tag_end + 1;
continue;
Expand All @@ -162,7 +176,12 @@ pub fn html_entities_to_jsx(s: &str) -> String {
.position(|&b| b == b'<')
.unwrap_or(len - i);
let text_slice = &preprocessed[i..i + text_end];
result.push_str(&convert_entities_in_text(text_slice));
if pre_depth > 0 {
// Inside <pre>, preserve entities as-is for correct browser rendering
result.push_str(text_slice);
} else {
result.push_str(&convert_entities_in_text(text_slice));
}
i += text_end;
}

Expand Down Expand Up @@ -1141,6 +1160,24 @@ mod tests {
html_entities_to_jsx("<p>こんにちは &lt;world&gt;</p>"),
"<p>こんにちは {\"<\"}world{\">\"}</p>"
);

// Entities inside <pre> should be preserved (not converted to JSX)
assert_eq!(
html_entities_to_jsx("<Comp><pre><code>&lt;html&gt;</code></pre></Comp>"),
"<Comp><pre><code>&lt;html&gt;</code></pre></Comp>"
);

// Entities outside <pre> still converted, inside preserved
assert_eq!(
html_entities_to_jsx("<p>&lt;b&gt;</p><pre>&lt;b&gt;</pre><p>&lt;b&gt;</p>"),
"<p>{\"<\"}b{\">\"}</p><pre>&lt;b&gt;</pre><p>{\"<\"}b{\">\"}</p>"
);

// Nested <pre> tags
assert_eq!(
html_entities_to_jsx("<pre><pre>&amp;</pre></pre>"),
"<pre><pre>&amp;</pre></pre>"
);
}

#[test]
Expand Down
17 changes: 16 additions & 1 deletion crates/core/src/renderer/mdast/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,22 @@ impl<'a> Context<'a> {
/// Used to determine if JSX components should be rendered inline
/// to avoid fragmenting list structures.
pub fn is_in_list(&self) -> bool {
self.stack.iter().any(|scope| matches!(scope, Scope::List))
self.stack
.iter()
.any(|scope| matches!(scope, Scope::List { .. }))
}

/// Returns true if inside a tight (non-spread) list.
///
/// Used to suppress `<p>` wrappers around list item content when the
/// list is tight, matching the CommonMark distinction between tight
/// and loose lists.
pub fn is_in_tight_list(&self) -> bool {
self.stack
.iter()
.rev()
.find(|scope| matches!(scope, Scope::List { .. }))
.is_some_and(|scope| matches!(scope, Scope::List { spread: false }))
}

/// Returns true if any scope in the stack is within a table structure.
Expand Down
39 changes: 13 additions & 26 deletions crates/core/src/renderer/mdast/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ fn extract_text_from_node(node: &Node, buffer: &mut String) {
fn render_list(list: &markdown::mdast::List, ctx: &mut Context) {
let tag = if list.ordered { "ol" } else { "ul" };
ctx.push_raw(&format!("<{}>", tag));
ctx.enter(Scope::List);
ctx.enter(Scope::List {
spread: list.spread,
});

for child in &list.children {
render_node(child, ctx);
Expand Down Expand Up @@ -196,27 +198,7 @@ fn render_jsx(
return;
}

// 3. Handle FileTree: apply slot normalization if configured in registry
// Extract normalization info before borrowing ctx mutably
let ul_normalization = ctx
.registry()
.get_slot_normalization(tag_name)
.filter(|n| n.strategy == "wrap_in_ul")
.map(|n| n.wrapper_class.clone());

if let Some(wrapper_class) = ul_normalization {
let slot_html = ctx.render_children_to_html(children);
let class_attr = wrapper_class
.as_ref()
.map(|c| format!(" class=\"{}\"", c))
.unwrap_or_default();
ctx.push_raw(&format!("<ul{}>", class_attr));
ctx.push_raw(&slot_html);
ctx.push_raw("</ul>");
return;
}

// 4. Extract props from JSX attributes
// 3. Extract props from JSX attributes
let mut props = HashMap::new();
for attr in attributes {
match attr {
Expand Down Expand Up @@ -273,15 +255,20 @@ pub fn render_node(node: &Node, ctx: &mut Context) {
}

Node::Paragraph(para) => {
ctx.push_raw("<p>");
ctx.enter(Scope::Paragraph);
let in_tight_list = ctx.is_in_tight_list();
if !in_tight_list {
ctx.push_raw("<p>");
ctx.enter(Scope::Paragraph);
}

for child in &para.children {
render_node(child, ctx);
}

ctx.exit();
ctx.push_raw("</p>");
if !in_tight_list {
ctx.exit();
ctx.push_raw("</p>");
}
}

Node::Link(link) => {
Expand Down
2 changes: 1 addition & 1 deletion crates/core/src/renderer/mdast/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ pub enum Scope {
/// Inside a paragraph element (`<p>`).
Paragraph,
/// Inside a list element (`<ul>` or `<ol>`).
List,
List { spread: bool },
/// Inside a table element (`<table>`).
Table,
/// Inside a table row element (`<tr>`).
Expand Down
31 changes: 28 additions & 3 deletions crates/core/src/transform/jsx_normalize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -505,12 +505,19 @@ fn find_list_continuation_indent(lines: &[&str], i: usize) -> Option<usize> {
// Ordered list: digits followed by ". " or ") "
let rest = trimmed.trim_start_matches(|c: char| c.is_ascii_digit());
if rest.len() < trimmed.len() && (rest.starts_with(". ") || rest.starts_with(") ")) {
let marker_width = trimmed.len() - rest.len() + 2;
return Some(leading + marker_width);
let digits = trimmed.len() - rest.len();
// 1 for delimiter char ('.' or ')'), then count actual spaces
let after_delim = &rest[1..]; // skip '.' or ')'
let spaces = after_delim.len() - after_delim.trim_start().len();
let spaces = spaces.max(1); // at least 1 space
return Some(leading + digits + 1 + spaces);
}
// Unordered list: "- ", "* ", "+ "
if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
return Some(leading + 2);
let after_marker = &trimmed[1..]; // skip marker char
let spaces = after_marker.len() - after_marker.trim_start().len();
let spaces = spaces.max(1);
return Some(leading + 1 + spaces);
}
if idx == 0 {
break;
Expand Down Expand Up @@ -896,6 +903,24 @@ mod tests {
);
}

#[test]
fn test_normalize_list_jsx_multi_space_marker() {
// "1. Item" has two spaces after the dot; continuation indent = 1 digit + '.' + 2 spaces = 4
let input = "1. Item\n\t<FileTree>\n\t- src/\n\t</FileTree>\n";
let result = normalize_list_jsx_components(input);
// The FileTree should be indented to 4 spaces (1 digit + '.' + 2 spaces)
assert!(
result.contains(" <FileTree>"),
"FileTree should be indented to 4 spaces for '1. ' marker. Got:\n{}",
result
);
assert!(
!result.contains('\t'),
"Tabs should be converted to spaces. Got:\n{}",
result
);
}

#[test]
fn test_normalize_list_jsx_tab_indent_with_attributes() {
// Tab-indented FileTree with attributes
Expand Down
76 changes: 38 additions & 38 deletions fixtures/integration/withastro-docs/harness-summary.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"timestamp": "2026-01-28T14:56:11.101Z",
"baselineMs": 124550,
"markflowMs": 62129,
"timestamp": "2026-01-31T01:05:07.616Z",
"baselineMs": 125837,
"markflowMs": 121195,
"mode": "semantic",
"semantic": {
"compared": 12,
Expand All @@ -11,57 +11,57 @@
{
"file": "/en/basics/astro-components/",
"reason": "content-diff",
"diffIndex": 10204,
"previewA": "al Astro.props object. ---\n// Usage: <GreetingHeadline greeting=\"Howdy\" name=\"Partner\" />\nconst { greeting, name } = Astro.props;\n---\n<h2>{greeting}, ",
"previewB": "al Astro.props object. ---\n// src/components/GreetingHeadline.astro\n// Usage: <GreetingHeadline greeting=\"Howdy\" name=\"Partner\" />\nconst { greeting, n",
"hexAtDiffA": "U(0x55) s(0x73) a(0x61) g(0x67) e(0x65) :(0x3a) (0x20) <(0x3c) G(0x47) r(0x72) e(0x65) e(0x65) t(0x74) i(0x69) n(0x6e) g(0x67) H(0x48) e(0x65) a(0x61) d(0x64)",
"hexAtDiffB": "s(0x73) r(0x72) c(0x63) /(0x2f) c(0x63) o(0x6f) m(0x6d) p(0x70) o(0x6f) n(0x6e) e(0x65) n(0x6e) t(0x74) s(0x73) /(0x2f) G(0x47) r(0x72) e(0x65) e(0x65) t(0x74)",
"lengthA": 18068,
"lengthB": 18431
"diffIndex": 4789,
"previewA": ", like a collection of common tags that make SEO easy to work with. Components can be reusable UI elements, like a header or a profile card. Astro co",
"previewB": ", like a collection of common <meta> tags that make SEO easy to work with. Components can be reusable UI elements, like a header or a profile card. As",
"hexAtDiffA": " (0x20) t(0x74) a(0x61) g(0x67) s(0x73) (0x20) t(0x74) h(0x68) a(0x61) t(0x74) (0x20) m(0x6d) a(0x61) k(0x6b) e(0x65) (0x20) S(0x53) E(0x45) O(0x4f) (0x20)",
"hexAtDiffB": "<(0x3c) m(0x6d) e(0x65) t(0x74) a(0x61) >(0x3e) (0x20) t(0x74) a(0x61) g(0x67) s(0x73) (0x20) t(0x74) h(0x68) a(0x61) t(0x74) (0x20) m(0x6d) a(0x61) k(0x6b)",
"lengthA": 16680,
"lengthB": 16860
},
{
"file": "/en/basics/project-structure/",
"reason": "structure-diff",
"diffIndex": 5792,
"previewA": "ss\" rel=\"stylesheet\"> <style>.action:where(.astro-vnzlvqnm){gap:.5em;align-items:center;border-radius:999rem;padding:.5rem 1.125rem;color:var(--sl-col",
"previewB": "ss\" rel=\"stylesheet\"> <style>.neutral-badge{border-color:var(--sl-color-gray-5);background-color:var(--sl-color-gray-6);color:var(--sl-color-text)}\n.a",
"hexAtDiffA": "a(0x61) c(0x63) t(0x74) i(0x69) o(0x6f) n(0x6e) :(0x3a) w(0x77) h(0x68) e(0x65) r(0x72) e(0x65) ((0x28) .(0x2e) a(0x61) s(0x73) t(0x74) r(0x72) o(0x6f) -(0x2d)",
"hexAtDiffB": "n(0x6e) e(0x65) u(0x75) t(0x74) r(0x72) a(0x61) l(0x6c) -(0x2d) b(0x62) a(0x61) d(0x64) g(0x67) e(0x65) {(0x7b) b(0x62) o(0x6f) r(0x72) d(0x64) e(0x65) r(0x72)",
"lengthA": 166542,
"lengthB": 141353
"diffIndex": 55775,
"previewA": "tory might look like this:</p> <starlight-file-tree class data-pagefind-ignore=\"true\"><ul><li class=\"directory\"><details open><summary><span class=\"tr",
"previewB": "tory might look like this:</p><starlight-file-tree class data-pagefind-ignore=\"true\"><ul><ul><li class=\"directory\"><details open><summary><span class=",
"hexAtDiffA": " (0x20) <(0x3c) s(0x73) t(0x74) a(0x61) r(0x72) l(0x6c) i(0x69) g(0x67) h(0x68) t(0x74) -(0x2d) f(0x66) i(0x69) l(0x6c) e(0x65) -(0x2d) t(0x74) r(0x72) e(0x65)",
"hexAtDiffB": "<(0x3c) s(0x73) t(0x74) a(0x61) r(0x72) l(0x6c) i(0x69) g(0x67) h(0x68) t(0x74) -(0x2d) f(0x66) i(0x69) l(0x6c) e(0x65) -(0x2d) t(0x74) r(0x72) e(0x65) e(0x65)",
"lengthA": 67632,
"lengthB": 67642
},
{
"file": "/en/guides/markdown-content/",
"reason": "content-diff",
"diffIndex": 11895,
"previewA": "eConfig } from 'astro/config';import { rehypeHeadingIds } from '@astrojs/markdown-remark';\nimport { otherPluginThatReliesOnHeadingIDs } from 'some/plu",
"previewB": "eConfig } from 'astro/config';\nimport { rehypeHeadingIds } from '@astrojs/markdown-remark';\nimport { otherPluginThatReliesOnHeadingIDs } from 'some/pl",
"hexAtDiffA": "i(0x69) m(0x6d) p(0x70) o(0x6f) r(0x72) t(0x74) (0x20) {(0x7b) (0x20) r(0x72) e(0x65) h(0x68) y(0x79) p(0x70) e(0x65) H(0x48) e(0x65) a(0x61) d(0x64) i(0x69)",
"hexAtDiffB": "\n(0xa) i(0x69) m(0x6d) p(0x70) o(0x6f) r(0x72) t(0x74) (0x20) {(0x7b) (0x20) r(0x72) e(0x65) h(0x68) y(0x79) p(0x70) e(0x65) H(0x48) e(0x65) a(0x61) d(0x64)",
"lengthA": 20630,
"lengthB": 20585
"diffIndex": 8329,
"previewA": "s YAML (or TOML) frontmatter. - A component that returns the full, rendered contents of the file. rawContent() - A function that returns the raw Mark",
"previewB": "s YAML (or TOML) frontmatter. <Content /> - A component that returns the full, rendered contents of the file. rawContent() - A function that returns t",
"hexAtDiffA": " (0x20) -(0x2d) (0x20) A(0x41) (0x20) c(0x63) o(0x6f) m(0x6d) p(0x70) o(0x6f) n(0x6e) e(0x65) n(0x6e) t(0x74) (0x20) t(0x74) h(0x68) a(0x61) t(0x74) (0x20)",
"hexAtDiffB": "<(0x3c) C(0x43) o(0x6f) n(0x6e) t(0x74) e(0x65) n(0x6e) t(0x74) (0x20) /(0x2f) >(0x3e) (0x20) -(0x2d) (0x20) A(0x41) (0x20) c(0x63) o(0x6f) m(0x6d) p(0x70)",
"lengthA": 20225,
"lengthB": 20409
},
{
"file": "/en/guides/routing/",
"reason": "content-diff",
"diffIndex": 4910,
"previewA": "/\">reference</a> section!</p> Static routes .astro page components as well as Markdown and MDX Files (.md, .mdx) within the src/pages/ directory autom",
"previewB": "/\">reference</a> section!</p> Static routes .astro page components as well as Markdown and MDX Files (.md, .mdx) within the src/pages/ directory auto",
"hexAtDiffA": "S(0x53) t(0x74) a(0x61) t(0x74) i(0x69) c(0x63) (0x20) r(0x72) o(0x6f) u(0x75) t(0x74) e(0x65) s(0x73) (0x20) .(0x2e) a(0x61) s(0x73) t(0x74) r(0x72) o(0x6f)",
"hexAtDiffB": " (0x20) S(0x53) t(0x74) a(0x61) t(0x74) i(0x69) c(0x63) (0x20) r(0x72) o(0x6f) u(0x75) t(0x74) e(0x65) s(0x73) (0x20) .(0x2e) a(0x61) s(0x73) t(0x74) r(0x72)",
"lengthA": 24345,
"lengthB": 24403
"diffIndex": 4643,
"previewA": "ages Astro uses standard HTML elements to navigate between routes. There is no framework-specific component provided. p>Read more a href=\"/about/\">a",
"previewB": "ages Astro uses standard HTML <a> elements to navigate between routes. There is no framework-specific <Link> component provided. p>Read more a href=\"/",
"hexAtDiffA": " (0x20) e(0x65) l(0x6c) e(0x65) m(0x6d) e(0x65) n(0x6e) t(0x74) s(0x73) (0x20) t(0x74) o(0x6f) (0x20) n(0x6e) a(0x61) v(0x76) i(0x69) g(0x67) a(0x61) t(0x74)",
"hexAtDiffB": "<(0x3c) a(0x61) >(0x3e) (0x20) e(0x65) l(0x6c) e(0x65) m(0x6d) e(0x65) n(0x6e) t(0x74) s(0x73) (0x20) t(0x74) o(0x6f) (0x20) n(0x6e) a(0x61) v(0x76) i(0x69)",
"lengthA": 23865,
"lengthB": 23874
},
{
"file": "/en/guides/content-collections/",
"reason": "content-diff",
"diffIndex": 6291,
"previewA": "ML, TOML, or JSON files: src/ ... newsletter/ the \"newsletter\" collection week-1.md a collection entry week-2.md a collection entry week-3.md a collec",
"previewB": "ML, TOML, or JSON files: src/ newsletter/ the \"newsletter\" collection week-1.md a collection entry week-2.md a collection entry week-3.md a collection",
"hexAtDiffA": ".(0x2e) .(0x2e) .(0x2e) (0x20) n(0x6e) e(0x65) w(0x77) s(0x73) l(0x6c) e(0x65) t(0x74) t(0x74) e(0x65) r(0x72) /(0x2f) (0x20) t(0x74) h(0x68) e(0x65) (0x20)",
"hexAtDiffB": "n(0x6e) e(0x65) w(0x77) s(0x73) l(0x6c) e(0x65) t(0x74) t(0x74) e(0x65) r(0x72) /(0x2f) (0x20) t(0x74) h(0x68) e(0x65) (0x20) \"(0x22) n(0x6e) e(0x65) w(0x77)",
"lengthA": 31711,
"lengthB": 31727
"diffIndex": 20403,
"previewA": "TML content, including both a component and a list of all rendered headings. ---import { getEntry, render } from 'astro:content';const entry = await ",
"previewB": "TML content, including both a <Content /> component and a list of all rendered headings. ---import { getEntry, render } from 'astro:content';const ent",
"hexAtDiffA": " (0x20) c(0x63) o(0x6f) m(0x6d) p(0x70) o(0x6f) n(0x6e) e(0x65) n(0x6e) t(0x74) (0x20) a(0x61) n(0x6e) d(0x64) (0x20) a(0x61) (0x20) l(0x6c) i(0x69) s(0x73)",
"hexAtDiffB": "<(0x3c) C(0x43) o(0x6f) n(0x6e) t(0x74) e(0x65) n(0x6e) t(0x74) (0x20) /(0x2f) >(0x3e) (0x20) c(0x63) o(0x6f) m(0x6d) p(0x70) o(0x6f) n(0x6e) e(0x65) n(0x6e)",
"lengthA": 31428,
"lengthB": 31450
}
]
},
Expand Down
Loading
Loading