Skip to content

Commit efed4ed

Browse files
committed
fix: further issues from Astro test suite
1 parent fc06b70 commit efed4ed

28 files changed

Lines changed: 287 additions & 86 deletions

File tree

.changeset/ninety-mammals-unite.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@astrojs/compiler-rs": patch
3+
---
4+
5+
Fixes further issues found in the Astro tests, especially around HTML escaping in set:html

crates/astro_codegen/src/printer/components.rs

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ impl<'a> AstroCodegen<'a> {
339339
// Pre-scan for transition attributes
340340
let mut transition_name: Option<(String, oxc_span::Span)> = None;
341341
let mut transition_animate: Option<(String, oxc_span::Span)> = None;
342-
let mut transition_persist: Option<oxc_span::Span> = None;
342+
let mut transition_persist: Option<(String, oxc_span::Span)> = None;
343343
let mut transition_persist_props: Option<(String, oxc_span::Span)> = None;
344344

345345
for attr in attrs {
@@ -350,7 +350,7 @@ impl<'a> AstroCodegen<'a> {
350350
} else if name == "transition:animate" {
351351
transition_animate = Some((Self::get_attr_value_string(attr), attr.span));
352352
} else if name == "transition:persist" {
353-
transition_persist = Some(attr.span);
353+
transition_persist = Some((Self::get_attr_value_string_or_empty(attr), attr.span));
354354
} else if name == "transition:persist-props" {
355355
transition_persist_props = Some((Self::get_attr_value_string(attr), attr.span));
356356
}
@@ -499,7 +499,7 @@ impl<'a> AstroCodegen<'a> {
499499
} else if let Some((_, span)) = &transition_animate {
500500
self.add_source_mapping_for_span(*span);
501501
}
502-
let name_val = transition_name.map_or_else(|| "\"\"".to_string(), |(v, _)| v);
502+
let name_val = transition_name.as_ref().map_or_else(|| "\"\"".to_string(), |(v, _)| v.clone());
503503
let animate_val = transition_animate.map_or_else(|| "\"\"".to_string(), |(v, _)| v);
504504
let hash = self.generate_transition_hash();
505505
self.print(&format!(
@@ -524,19 +524,31 @@ impl<'a> AstroCodegen<'a> {
524524
));
525525
}
526526

527-
if let Some(persist_span) = transition_persist {
527+
if let Some((ref persist_val, persist_span)) = transition_persist {
528528
if !first {
529529
self.print(",");
530530
}
531531
first = false;
532532
self.add_source_mapping_for_span(persist_span);
533-
let hash = self.generate_transition_hash();
534-
self.print(&format!(
535-
"\"data-astro-transition-persist\":({}({}, \"{}\"))",
536-
runtime::CREATE_TRANSITION_SCOPE,
537-
runtime::RESULT,
538-
hash
539-
));
533+
// Priority order for the persist ID:
534+
// 1. Explicit string value on transition:persist (e.g. transition:persist="form")
535+
// 2. transition:name value
536+
// 3. Generated hash via $$createTransitionScope
537+
let clean_persist = persist_val.trim_matches('"');
538+
if !clean_persist.is_empty() {
539+
self.print(&format!("\"data-astro-transition-persist\":\"{clean_persist}\""));
540+
} else if let Some((ref name_val, _)) = transition_name {
541+
let clean_val = name_val.trim_matches('"');
542+
self.print(&format!("\"data-astro-transition-persist\":\"{clean_val}\""));
543+
} else {
544+
let hash = self.generate_transition_hash();
545+
self.print(&format!(
546+
"\"data-astro-transition-persist\":({}({}, \"{}\"))",
547+
runtime::CREATE_TRANSITION_SCOPE,
548+
runtime::RESULT,
549+
hash
550+
));
551+
}
540552
}
541553

542554
// Add scope identifier as a prop if not already merged into an existing class attribute.

crates/astro_codegen/src/printer/elements.rs

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -308,10 +308,12 @@ impl<'a> AstroCodegen<'a> {
308308
value_str = expr_to_string(e);
309309
is_template_literal = matches!(e, Expression::TemplateLiteral(_));
310310
}
311-
// set:html needs $$unescapeHTML for expressions, but NOT for
312-
// template literals — the Go compiler passes template literals
313-
// through as-is without unescaping.
314-
let needs_unescape = directive_type == "html" && !is_template_literal;
311+
// set:html always needs $$unescapeHTML — the $$render tagged
312+
// template escapes HTML by default, so all expressions (including
313+
// template literals) must be wrapped in $$unescapeHTML to be
314+
// rendered as raw HTML.
315+
let _ = is_template_literal;
316+
let needs_unescape = directive_type == "html";
315317
(value_str, needs_unescape, false)
316318
}
317319
_ => ("void 0".to_string(), false, false),
@@ -378,11 +380,18 @@ impl<'a> AstroCodegen<'a> {
378380
// If both class and class:list exist, merge them
379381
let has_merged_class = static_class.is_some() && class_list_expr.is_some();
380382

381-
// Handle transition:persist — if there's a transition:name, use that for persist value.
382-
// Otherwise use $$createTransitionScope.
383-
if let Some((ref _persist_val, persist_span)) = transition_persist {
383+
// Handle transition:persist — priority order for the persist ID:
384+
// 1. If transition:persist has an explicit string value (e.g. transition:persist="form"),
385+
// use that value directly.
386+
// 2. If transition:name is present, use its value.
387+
// 3. Otherwise generate a unique hash via $$createTransitionScope.
388+
if let Some((ref persist_val, persist_span)) = transition_persist {
384389
self.add_source_mapping_for_span(persist_span);
385-
if let Some((ref name_val, _)) = transition_name {
390+
let clean_persist = persist_val.trim_matches('"');
391+
if !clean_persist.is_empty() {
392+
// Explicit string value on transition:persist — use it directly.
393+
self.print(&format!(" data-astro-transition-persist=\"{clean_persist}\""));
394+
} else if let Some((ref name_val, _)) = transition_name {
386395
let clean_val = name_val.trim_matches('"');
387396
self.print(&format!(" data-astro-transition-persist=\"{clean_val}\""));
388397
} else {

crates/astro_codegen/src/printer/expressions.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,33 @@ impl<'a> AstroCodegen<'a> {
199199
self.print("await ");
200200
self.print_expression(&await_expr.argument);
201201
}
202+
Expression::ArrayExpression(arr) => {
203+
self.add_source_mapping_for_span(expr.span());
204+
// Arrays may contain JSX elements — iterate and transform each element.
205+
self.print("[");
206+
let mut first = true;
207+
for element in &arr.elements {
208+
if !first {
209+
self.print(", ");
210+
}
211+
first = false;
212+
match element {
213+
oxc_ast::ast::ArrayExpressionElement::SpreadElement(spread) => {
214+
self.print("...");
215+
self.print_expression(&spread.argument);
216+
}
217+
oxc_ast::ast::ArrayExpressionElement::Elision(_) => {
218+
// Elision — empty slot, e.g. [1,,3]
219+
}
220+
_ => {
221+
if let Some(e) = element.as_expression() {
222+
self.print_expression(e);
223+
}
224+
}
225+
}
226+
}
227+
self.print("]");
228+
}
202229
_ => {
203230
self.add_source_mapping_for_span(expr.span());
204231
// For all other expressions, use regular codegen

crates/astro_codegen/src/printer/mod.rs

Lines changed: 135 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2122,41 +2122,48 @@ fn try_anchor_supplement(
21222122
// Find the nearest anchor with inter_col <= token_col
21232123
// (the last one in sorted order that doesn't exceed it).
21242124
let nearest = anchors.iter().rev().find(|&&(ic, _, _)| ic <= token_col);
2125-
let &(anchor_ic, anchor_fl, anchor_fc) = nearest?;
2126-
let delta = i64::from(anchor_fc) - i64::from(anchor_ic);
2127-
let candidate_col = u32::try_from((i64::from(token_col) + delta).max(0))
2128-
.expect("candidate col exceeds u32");
2129-
2130-
// Verify: the text at the candidate final position must match the
2131-
// intermediate text at the token position. This confirms we are in
2132-
// a quasi region (not a reformatted expression).
2133-
let i_text = ctx.inter_lines.get(gen_line).copied().unwrap_or("");
2134-
let f_text = ctx
2135-
.final_lines
2136-
.get(anchor_fl as usize)
2137-
.copied()
2138-
.unwrap_or("");
2139-
let tc = token_col as usize;
2140-
let cc = candidate_col as usize;
2141-
// Use a short verification window; 2 bytes is enough to confirm
2142-
// we're at the same quasi text (e.g. "<p", "</", etc.).
2143-
let verify_len = 2;
2144-
let text_matches = tc + verify_len <= i_text.len()
2145-
&& cc + verify_len <= f_text.len()
2146-
&& i_text.as_bytes()[tc..tc + verify_len] == f_text.as_bytes()[cc..cc + verify_len];
21472125

2148-
if text_matches {
2149-
Some((anchor_fl, candidate_col))
2150-
} else {
2151-
None
2126+
if let Some(&(anchor_ic, anchor_fl, anchor_fc)) = nearest {
2127+
let delta = i64::from(anchor_fc) - i64::from(anchor_ic);
2128+
let candidate_col = u32::try_from((i64::from(token_col) + delta).max(0))
2129+
.expect("candidate col exceeds u32");
2130+
2131+
// Verify: the text at the candidate final position must match the
2132+
// intermediate text at the token position. This confirms we are in
2133+
// a quasi region (not a reformatted expression).
2134+
let i_text = ctx.inter_lines.get(gen_line).copied().unwrap_or("");
2135+
let f_text = ctx
2136+
.final_lines
2137+
.get(anchor_fl as usize)
2138+
.copied()
2139+
.unwrap_or("");
2140+
let tc = token_col as usize;
2141+
let cc = candidate_col as usize;
2142+
// Use a short verification window; 2 bytes is enough to confirm
2143+
// we're at the same quasi text (e.g. "<p", "</", etc.).
2144+
let verify_len = 2;
2145+
let text_matches = tc + verify_len <= i_text.len()
2146+
&& cc + verify_len <= f_text.len()
2147+
&& i_text.as_bytes()[tc..tc + verify_len] == f_text.as_bytes()[cc..cc + verify_len];
2148+
2149+
if text_matches {
2150+
return Some((anchor_fl, candidate_col));
2151+
}
21522152
}
2153-
} else {
2154-
// No Phase-2 anchors for this intermediate line.
2153+
// No anchor at or before token_col (or text mismatch): the token is in
2154+
// a quasi text region that starts before all Phase-2 anchors on this
2155+
// line (e.g. `<span>` at col 0 when the JS suffix starts at col 25).
2156+
// Fall through to the window search below.
2157+
}
2158+
2159+
{
2160+
// Either: no Phase-2 anchors exist for this intermediate line (pure
2161+
// quasi text), or all anchors are after the token's column (token is
2162+
// in a quasi region before the first interpolation on this line).
21552163
//
2156-
// This happens for lines that are pure template literal quasi text
2157-
// (no JS expressions). Phase-2 reformatting may shift these lines
2158-
// by inserting extra lines for object literal properties, but the
2159-
// text content remains identical.
2164+
// Phase-2 reformatting may shift these lines by inserting extra lines
2165+
// for object literal properties, but the text content remains
2166+
// identical in the quasi regions.
21602167
//
21612168
// Search a window of final lines around the expected position for
21622169
// a line containing the same text at the token's column.
@@ -4058,22 +4065,20 @@ const html = '<b>bold</b>';
40584065
}
40594066

40604067
#[test]
4061-
fn test_set_html_template_literal_no_unescape_html() {
4062-
// `set:html={`template ${literal}`}` must NOT wrap with $$unescapeHTML.
4063-
// The Go compiler passes template literals through as-is — matching Go behavior.
4068+
fn test_set_html_template_literal_uses_unescape_html() {
4069+
// `set:html={`template ${literal}`}` must wrap with $$unescapeHTML.
4070+
// The $$render tagged template escapes HTML by default, so all set:html
4071+
// expressions — including template literals — need $$unescapeHTML to render
4072+
// as raw HTML.
40644073
let source = r#"---
40654074
const name = 'world';
40664075
---
40674076
<div set:html={`Hello <b>${name}</b>`} />"#;
40684077
let output = compile_astro(source);
40694078

40704079
assert!(
4071-
!output.contains("$$unescapeHTML(`"),
4072-
"set:html with template literal must NOT use $$unescapeHTML: {output}"
4073-
);
4074-
assert!(
4075-
output.contains("`Hello <b>${name}</b>`"),
4076-
"set:html template literal should be passed through as-is: {output}"
4080+
output.contains("$$unescapeHTML(`Hello <b>${name}</b>`)"),
4081+
"set:html with template literal must use $$unescapeHTML: {output}"
40774082
);
40784083
}
40794084

@@ -4230,4 +4235,93 @@ import { CompA, CompB } from 'test';
42304235
"Mixed import must not be suppressed when one specifier is used normally: {output}"
42314236
);
42324237
}
4238+
4239+
// -------------------------------------------------------------------------
4240+
// transition:persist tests
4241+
// -------------------------------------------------------------------------
4242+
4243+
#[test]
4244+
fn test_transition_persist_with_explicit_value_on_html_element() {
4245+
// transition:persist="form" on an HTML element should use the string value
4246+
// directly as data-astro-transition-persist, not a generated hash.
4247+
let source = r#"<form transition:persist="form"><input /></form>"#;
4248+
let output = compile_astro(source);
4249+
4250+
assert!(
4251+
output.contains(r#"data-astro-transition-persist="form""#),
4252+
"transition:persist with explicit value must use that value: {output}"
4253+
);
4254+
// The output must not contain a $$createTransitionScope( call (only the import alias is ok)
4255+
assert!(
4256+
!output.contains("$$createTransitionScope("),
4257+
"transition:persist with explicit value must not call $$createTransitionScope: {output}"
4258+
);
4259+
}
4260+
4261+
#[test]
4262+
fn test_transition_persist_with_transition_name_on_html_element() {
4263+
// transition:persist + transition:name="counter" — persist ID should be "counter"
4264+
let source = r#"<div transition:persist transition:name="counter"></div>"#;
4265+
let output = compile_astro(source);
4266+
4267+
assert!(
4268+
output.contains(r#"data-astro-transition-persist="counter""#),
4269+
"transition:persist must use transition:name value as persist ID: {output}"
4270+
);
4271+
}
4272+
4273+
#[test]
4274+
fn test_transition_persist_with_explicit_value_on_component() {
4275+
// transition:persist="form" on a component should use the string value directly.
4276+
let source = r#"---
4277+
import MyComp from './MyComp.astro';
4278+
---
4279+
<MyComp transition:persist="form" />"#;
4280+
let output = compile_astro(source);
4281+
4282+
assert!(
4283+
output.contains(r#""data-astro-transition-persist": "form""#),
4284+
"component transition:persist with explicit value must use that value: {output}"
4285+
);
4286+
assert!(
4287+
!output.contains("$$createTransitionScope("),
4288+
"component transition:persist with explicit value must not call $$createTransitionScope: {output}"
4289+
);
4290+
}
4291+
4292+
#[test]
4293+
fn test_transition_persist_with_transition_name_on_component() {
4294+
// transition:persist + transition:name="counter" on a component.
4295+
let source = r#"---
4296+
import MyComp from './MyComp.astro';
4297+
---
4298+
<MyComp transition:persist transition:name="counter" />"#;
4299+
let output = compile_astro(source);
4300+
4301+
assert!(
4302+
output.contains(r#""data-astro-transition-persist": "counter""#),
4303+
"component transition:persist must use transition:name value: {output}"
4304+
);
4305+
}
4306+
4307+
#[test]
4308+
fn test_array_expression_in_jsx_transforms_children() {
4309+
// JSX elements inside array expressions must be transformed.
4310+
// Previously they were left as raw JSX in output.
4311+
let source = r#"---
4312+
import Foo from './Foo.astro';
4313+
import Bar from './Bar.astro';
4314+
---
4315+
<div>{[<Foo/>, <Bar/>]}</div>"#;
4316+
let output = compile_astro(source);
4317+
4318+
assert!(
4319+
!output.contains("<Foo/>") && !output.contains("<Bar/>"),
4320+
"JSX elements inside arrays must be transformed: {output}"
4321+
);
4322+
assert!(
4323+
output.contains("$$renderComponent"),
4324+
"JSX elements inside arrays must use $$renderComponent: {output}"
4325+
);
4326+
}
42334327
}

crates/astro_codegen/src/printer/slots.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ impl<'a> AstroCodegen<'a> {
307307
// Also track dynamic slots separately (elements with slot={expr})
308308
let mut dynamic_slots: Vec<(String, oxc_span::Span, Vec<&JSXChild<'a>>)> = Vec::new();
309309

310-
for child in children {
310+
for (i, child) in children.iter().enumerate() {
311311
// Skip HTML comments in slots if configured
312312
if self.options.strip_slot_comments && matches!(child, JSXChild::AstroComment(_)) {
313313
continue;
@@ -350,6 +350,29 @@ impl<'a> AstroCodegen<'a> {
350350
}
351351
}
352352
}
353+
JSXChild::Text(text) => {
354+
// A whitespace-only text node is dropped when it sits
355+
// immediately adjacent to an expression container —
356+
// specifically when it is:
357+
// • the first child and its next sibling is an expression, OR
358+
// • directly after an expression sibling.
359+
// This matches the Go compiler's slot child filtering exactly
360+
// and avoids passing leading/trailing whitespace through to
361+
// framework slot renderers (e.g. Vue's <slot />).
362+
if text.value.trim().is_empty() {
363+
let prev_is_expr =
364+
i > 0 && matches!(children[i - 1], JSXChild::ExpressionContainer(_));
365+
let next_is_expr = i + 1 < children.len()
366+
&& matches!(children[i + 1], JSXChild::ExpressionContainer(_));
367+
let is_first = i == 0;
368+
// Drop if: leading node whose next sibling is an expression,
369+
// or: node immediately following an expression.
370+
if (is_first && next_is_expr) || prev_is_expr {
371+
continue;
372+
}
373+
}
374+
default_children.push(child);
375+
}
353376
_ => {
354377
default_children.push(child);
355378
}

0 commit comments

Comments
 (0)