'
+ ]) {
+ const output = projectTsrxForTypecheck(source, { filename: "incomplete.tsrx" });
+
+ expect(typecheck(output.code)).toEqual([]);
+ expect(output.code).not.toContain("@{");
+ for (const mapping of output.mappings) {
+ expect(source.slice(mapping.sourceStart, mapping.sourceStart + mapping.sourceLength)).toBe(
+ output.code.slice(
+ mapping.generatedStart,
+ mapping.generatedStart + mapping.generatedLength
+ )
+ );
+ }
+ if (source.endsWith("
")) {
+ const generatedClosingName = output.code.indexOf("
") + 2;
+ expect(
+ output.mappings.some(
+ mapping =>
+ mapping.generatedStart <= generatedClosingName &&
+ mapping.generatedStart + mapping.generatedLength >= generatedClosingName + 3
+ )
+ ).toBe(false);
+ }
+ }
+ });
+
+ test("rejects authored lazy destructuring", () => {
+ expect(() =>
+ projectTsrxForTypecheck(
+ "export function Card(model) @{ const &{ title } = model;
{title}
}",
+ { filename: "authored-lazy.tsrx" }
+ )
+ ).toThrow(/Solid's TSRX frontend does not support authored lazy destructuring/);
+ });
+
+ test("supports diagnostics, completion, navigation, and rename through exact mappings", () => {
+ const source = `type Row = { name: string };
+export function Rows({ rows }: { rows: Row[] }) @{
+ @for (const row of rows) {
+
{row.name}:{row.missing}
+ }
+}`;
+ const output = projectTsrxForTypecheck(source, { filename: "editor.tsrx" });
+ const { filename, service } = createLanguageService(output.code);
+ const generatedUse = output.code.indexOf("row.name");
+ const generatedMissing = output.code.indexOf("missing");
+ const generatedName = generatedUse + "row.".length;
+
+ expect(
+ service
+ .getCompletionsAtPosition(filename, generatedUse + "row.".length, {})
+ ?.entries.some(entry => entry.name === "name")
+ ).toBe(true);
+ expect(service.getQuickInfoAtPosition(filename, generatedName + 1)).toBeDefined();
+
+ const definition = service.getDefinitionAtPosition(filename, generatedName + 1);
+ expect(definition?.length).toBeGreaterThan(0);
+ expect(
+ definition?.some(entry =>
+ mapGeneratedRange(output, entry.textSpan.start, entry.textSpan.length)
+ )
+ ).toBe(true);
+
+ const rename = service.findRenameLocations(filename, generatedName + 1, false, false, true);
+ expect(rename?.length).toBeGreaterThanOrEqual(2);
+ expect(
+ rename?.every(entry =>
+ Boolean(mapGeneratedRange(output, entry.textSpan.start, entry.textSpan.length))
+ )
+ ).toBe(true);
+
+ const missingDiagnostic = service
+ .getSemanticDiagnostics(filename)
+ .find(diagnostic => diagnostic.start === generatedMissing);
+ expect(missingDiagnostic).toBeDefined();
+ if (!missingDiagnostic) throw new Error("missing property diagnostic");
+ expect(mapGeneratedRange(output, missingDiagnostic.start, missingDiagnostic.length)).toEqual({
+ start: source.indexOf("missing"),
+ length: "missing".length
+ });
+ });
+});
diff --git a/packages/compiler/index.js b/packages/compiler/index.js
index e4a913f5b..d706441f8 100644
--- a/packages/compiler/index.js
+++ b/packages/compiler/index.js
@@ -12,16 +12,60 @@ function transform(code, options) {
const nativeOptions = validateOptions(code, options);
const result = native.transform(code, nativeOptions);
- return {
+ const output = {
code: result.code,
map: result.map ?? null
};
+ // Preserve the established JSX result shape. Native TSRX transforms always
+ // return a CSS string (including `""` when no styles are present), which
+ // makes the sidecar fields a route-specific extension.
+ if (result.css != null) {
+ output.css = result.css;
+ output.cssHash = result.cssHash ?? null;
+ }
+ return output;
}
function transformAsync(code, options) {
return Promise.resolve().then(() => transform(code, options));
}
+function projectTsrxForTypecheck(code, options) {
+ if (typeof code !== "string") {
+ throw new TypeError(
+ "@solidjs/compiler projectTsrxForTypecheck() expects source code as a string"
+ );
+ }
+ const nativeOptions = validateTypecheckProjectionOptions(options);
+ const result = native.projectTsrxForTypecheck(code, nativeOptions);
+ return {
+ code: result.code,
+ map: result.map,
+ mappings: result.mappings,
+ css: result.css,
+ cssHash: result.cssHash ?? null,
+ embeddedRegions: result.embeddedRegions
+ };
+}
+
+function validateTypecheckProjectionOptions(options) {
+ if (options == null) return options;
+ if (typeof options !== "object" || Array.isArray(options)) {
+ throw new TypeError(
+ "@solidjs/compiler projectTsrxForTypecheck() expects options to be an object"
+ );
+ }
+ for (const key of Object.keys(options)) {
+ if (key !== "filename") {
+ throw new Error(`@solidjs/compiler received unknown option \`${key}\``);
+ }
+ }
+ if (options.filename !== undefined && typeof options.filename !== "string") {
+ throw new TypeError("@solidjs/compiler `filename` option must be a string");
+ }
+ return options;
+}
+
function transformDirectives(code, options) {
if (typeof code !== "string") {
throw new TypeError("@solidjs/compiler transformDirectives() expects source code as a string");
@@ -427,6 +471,7 @@ function isMissingPackage(error, packageName) {
module.exports = {
transform,
transformAsync,
+ projectTsrxForTypecheck,
transformDirectives,
transformDirectivesAsync,
transformLazy,
diff --git a/packages/compiler/package.json b/packages/compiler/package.json
index 7363b7bc3..3b6e5bc3a 100644
--- a/packages/compiler/package.json
+++ b/packages/compiler/package.json
@@ -30,7 +30,7 @@
"bench": "pnpm run build && node scripts/bench.mjs",
"lint": "cargo clippy --manifest-path ./Cargo.toml -- -D warnings",
"test": "pnpm run test:rust && pnpm run build:debug && vitest run",
- "test:rust": "cargo test --manifest-path ./Cargo.toml && cargo test --manifest-path ./Cargo.toml --no-default-features",
+ "test:rust": "cargo test --manifest-path ./Cargo.toml && cargo test --manifest-path ./Cargo.toml --no-default-features && cargo test --manifest-path ./Cargo.toml --no-default-features --features tsrx",
"artifacts": "napi artifacts",
"napi:version": "napi version && node ./sync-optional-deps.mjs",
"create-npm-dirs": "napi create-npm-dirs"
diff --git a/packages/compiler/src/compiler.rs b/packages/compiler/src/compiler.rs
index 56f006a26..cd428290a 100644
--- a/packages/compiler/src/compiler.rs
+++ b/packages/compiler/src/compiler.rs
@@ -21,6 +21,19 @@ pub enum Generate {
Dynamic,
}
+/// Source syntax selection, mirroring the Babel plugin's `syntax` option.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub enum Syntax {
+ /// Route `.tsrx` filenames through the TSRX frontend, everything else
+ /// through standard JSX.
+ #[default]
+ Auto,
+ /// Never use the TSRX frontend.
+ Jsx,
+ /// Force the TSRX frontend for every file.
+ Tsrx,
+}
+
/// A wrapper import setting without any Node-API representation in its interface.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub enum Wrapper {
@@ -62,6 +75,9 @@ pub(crate) fn default_built_ins() -> Vec
{
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CompileOptions {
pub filename: Option,
+ /// Source syntax routing (Babel's `syntax`): `Auto` sends `.tsrx`
+ /// filenames through the TSRX frontend (requires the `tsrx` feature).
+ pub syntax: Syntax,
pub module_name: String,
pub generate: Generate,
pub hydratable: bool,
@@ -92,6 +108,7 @@ impl Default for CompileOptions {
fn default() -> Self {
Self {
filename: None,
+ syntax: Syntax::default(),
module_name: DEFAULT_MODULE_NAME.into(),
generate: Generate::Dom,
hydratable: false,
@@ -124,6 +141,10 @@ impl Default for CompileOptions {
pub struct CompileOutput {
pub code: String,
pub source_map: Option,
+ /// Extracted TSRX stylesheet output. `None` for the JSX route.
+ pub css: Option,
+ /// Space-separated TSRX scope hashes, matching `@tsrx/core`.
+ pub css_hash: Option,
}
/// Compile one JavaScript or TypeScript module containing JSX.
@@ -150,33 +171,85 @@ pub(crate) fn compile_for_node_adapter(
}
fn compile_inner(source: &str, options: &CompileOptions) -> Result {
- let source_type = source_type_for_filename(options.filename.as_deref())?;
- let allocator = Allocator::default();
- // Babel has no ParenthesizedExpression node (parens are trivia), so the
- // transform's expression matchers must never see one either. Preserving
- // parens here can hide logical expressions from conditional wrapping and
- // desynchronize generated output from Babel.
- let parsed = Parser::new(&allocator, source, source_type)
- .with_options(ParseOptions {
- preserve_parens: false,
- ..ParseOptions::default()
- })
- .parse();
+ let authored_source = source;
+ let tsrx_route = match options.syntax {
+ Syntax::Jsx => false,
+ Syntax::Tsrx => true,
+ Syntax::Auto => options
+ .filename
+ .as_deref()
+ .is_some_and(|filename| filename.ends_with(".tsrx")),
+ };
- if let Some(error) = crate::shared::parser::first_parser_error(parsed.diagnostics) {
- return Err(CompileError::parse(error));
+ #[cfg(not(feature = "tsrx"))]
+ if tsrx_route {
+ return Err(CompileError::configuration(
+ "TSRX sources require a @solidjs/compiler build with the `tsrx` feature",
+ ));
}
+ let allocator = Allocator::default();
+ #[cfg(feature = "tsrx")]
+ let (mut direct_program, direct_artifacts, direct_css, direct_css_hash) = if tsrx_route {
+ let lowered =
+ crate::tsrx::run_compiler_frontend(&allocator, source, options.filename.as_deref())?;
+ (
+ Some(lowered.program),
+ Some(lowered.artifacts),
+ Some(lowered.css),
+ lowered.css_hash,
+ )
+ } else {
+ (None, None, None, None)
+ };
+
+ let source_type = if tsrx_route {
+ // TSRX leaves and generated nodes are represented as a TSX program.
+ SourceType::tsx()
+ } else {
+ source_type_for_filename(options.filename.as_deref())?
+ };
+ #[cfg(feature = "tsrx")]
+ let mut program = if let Some(program) = direct_program.take() {
+ program
+ } else {
+ parse_program(&allocator, source, source_type)?
+ };
+ #[cfg(not(feature = "tsrx"))]
+ let mut program = parse_program(&allocator, source, source_type)?;
+
if let Some(lib) = options.require_import_source.as_deref()
- && !has_jsx_import_source(&parsed.program, source, lib)
+ && !has_jsx_import_source(&program, source, lib)
{
+ #[cfg(feature = "tsrx")]
+ let (css, css_hash) = if tsrx_route {
+ (direct_css.clone(), direct_css_hash.clone())
+ } else {
+ (None, None)
+ };
+ #[cfg(not(feature = "tsrx"))]
+ let (css, css_hash) = (None, None);
return Ok(CompileOutput {
- code: source.to_string(),
+ // Babel's requireImportSource gate skips the transform, so callers
+ // receive exactly what they authored. Style metadata was already
+ // extracted and remains available to pipeline integrations.
+ code: authored_source.to_string(),
source_map: None,
+ css,
+ css_hash,
});
}
- let mut program = parsed.program;
+ #[cfg(feature = "tsrx")]
+ if let Some(artifacts) = direct_artifacts.as_ref() {
+ crate::tsrx::apply_direct_rewrites(
+ &allocator,
+ &mut program,
+ artifacts,
+ options.source_map,
+ )?;
+ }
+
match options.generate {
Generate::Dom => {
let mut transform = AstDomTransform::new(
@@ -264,18 +337,59 @@ fn compile_inner(source: &str, options: &CompileOptions) -> Result(
+ allocator: &'a Allocator,
+ source: &'a str,
+ source_type: SourceType,
+) -> Result, CompileError> {
+ // Babel has no ParenthesizedExpression node (parens are trivia), so the
+ // transform's expression matchers must never see one either. Preserving
+ // parens here can hide logical expressions from conditional wrapping and
+ // desynchronize generated output from Babel.
+ let parsed = Parser::new(allocator, source, source_type)
+ .with_options(ParseOptions {
+ preserve_parens: false,
+ ..ParseOptions::default()
+ })
+ .parse();
+ if let Some(error) = crate::shared::parser::first_parser_error(parsed.diagnostics) {
+ return Err(CompileError::parse(error));
+ }
+ Ok(parsed.program)
+}
+
pub(crate) fn has_jsx_import_source(
program: &oxc_ast::ast::Program<'_>,
source: &str,
@@ -407,4 +521,321 @@ mod tests {
let configuration = compile("const view =
;", &options).unwrap_err();
assert_eq!(configuration.kind(), crate::CompileErrorKind::Configuration);
}
+
+ #[cfg(feature = "tsrx")]
+ fn compile_tsrx(source: &str, filename: &str) -> CompileOutput {
+ compile(
+ source,
+ &CompileOptions {
+ filename: Some(filename.into()),
+ syntax: Syntax::Tsrx,
+ ..CompileOptions::default()
+ },
+ )
+ .expect("compile TSRX")
+ }
+
+ #[cfg(feature = "tsrx")]
+ #[test]
+ fn extracts_and_scopes_tsrx_styles_without_a_runtime_helper() {
+ let output = compile_tsrx(
+ r#"export function View({ value, Tag }) @{
+ <>
+
+
+
+
+ <{Tag} />
+ >
+}"#,
+ "/exact/style-scope.tsrx",
+ );
+ let hash = output.css_hash.as_deref().expect("scope hash");
+ let css = output.css.as_deref().expect("TSRX CSS result");
+ assert!(css.contains(&format!(".used.{hash}")));
+ assert!(css.contains(&format!("span.{hash}")));
+ assert!(!output.code.contains(";",
+ "expression.tsrx",
+ );
+ let hash = expression.css_hash.as_deref().expect("expression hash");
+ assert!(
+ expression
+ .code
+ .contains(&format!("\"foo\": \"{hash} foo\"")),
+ "{}",
+ expression.code
+ );
+ assert!(
+ expression
+ .css
+ .as_deref()
+ .unwrap()
+ .contains("/* (unused) div")
+ );
+
+ let runtime = compile_tsrx(
+ r#"export function View() @{
+ let styles;
+ <>
+
+
+ >
+}"#,
+ "ref.tsrx",
+ );
+ let hash = runtime.css_hash.as_deref().expect("runtime hash");
+ assert!(
+ runtime
+ .code
+ .contains(&format!("styles = {{ \"foo\": \"{hash} foo\" }}")),
+ "{}",
+ runtime.code
+ );
+ assert!(runtime.code.contains(&format!("foo {hash}")));
+
+ let refs = compile_tsrx(
+ r#"let styles;
+const holder = {};
+const callback = value => value;
+const getRef = () => holder;
+export const view = <>
+
+
+>;"#,
+ "ref-forms.tsrx",
+ );
+ assert!(refs.code.contains("styles = {"), "{}", refs.code);
+ assert!(refs.code.contains("holder.value = {"), "{}", refs.code);
+ assert!(refs.code.contains("callback(value)"), "{}", refs.code);
+ assert!(
+ refs.code.contains("let _tsrx_style_ref_1 = getRef()"),
+ "{}",
+ refs.code
+ );
+ }
+
+ #[cfg(feature = "tsrx")]
+ #[test]
+ fn rejects_multiple_runtime_styles_per_fragment() {
+ let result = compile(
+ "const view = <>
>;",
+ &CompileOptions {
+ filename: Some("duplicate.tsrx".into()),
+ syntax: Syntax::Tsrx,
+ ..CompileOptions::default()
+ },
+ );
+ let error = result.expect_err("multiple runtime styles must fail");
+ assert!(
+ error
+ .to_string()
+ .contains("TSRX fragments can only have one style tag")
+ );
+ }
+
+ #[cfg(feature = "tsrx")]
+ #[test]
+ fn reports_empty_css_only_on_the_tsrx_route() {
+ let tsrx = compile_tsrx("export const view =
;", "empty.tsrx");
+ assert_eq!(tsrx.css.as_deref(), Some(""));
+ assert_eq!(tsrx.css_hash, None);
+
+ let jsx = compile("export const view =
;", &CompileOptions::default()).unwrap();
+ assert_eq!(jsx.css, None);
+ assert_eq!(jsx.css_hash, None);
+ }
+
+ #[cfg(feature = "tsrx")]
+ #[test]
+ fn scopes_control_flow_elements_and_annotates_the_whole_owner() {
+ let output = compile_tsrx(
+ r#"const Component = props => props.children;
+export const View = ({ visible, items, Tag }) => <>
+
+ @if (visible) { }
+ @for (const item of items) { }
+ <{Tag} />
+
+>;"#,
+ "control-style.tsrx",
+ );
+ let hash = output.css_hash.as_deref().expect("owner hash");
+ let css = output.css.as_deref().expect("owner CSS");
+ assert!(css.contains(&format!("span.{hash}")), "{css}");
+ assert!(!css.contains("/* (unused) span"), "{css}");
+ assert!(output.code.contains(&format!("")));
+ assert!(output.code.contains(&format!("")));
+ assert!(output.code.contains(&format!("class: \"{hash}\"")));
+ assert!(output.code.contains(&format!("")));
+ }
+
+ #[cfg(feature = "tsrx")]
+ #[test]
+ fn excludes_for_and_try_pending_styles_from_owner_collection() {
+ let output = compile_tsrx(
+ r#"export const View = ({ items }) => <>
+
+ @for (const item of items) { <> > }
+ @try { } @pending { <> > }
+>;"#,
+ "style-boundaries.tsrx",
+ );
+ let hash = output.css_hash.as_deref().expect("outer style scope");
+ assert!(!hash.contains(' '), "{:?}", output.css_hash);
+ let css = output.css.as_deref().unwrap();
+ assert!(css.contains(".outer"), "{css}");
+ assert!(!css.contains(".loop"), "{css}");
+ assert!(!css.contains(".pending"), "{css}");
+ assert_eq!(output.code.matches(" > };",
+ "for-style-boundary.tsrx",
+ );
+ assert_eq!(for_only.css.as_deref(), Some(""));
+ assert_eq!(for_only.css_hash, None);
+
+ let pending_only = compile_tsrx(
+ "export const view = () => @try { } @pending { <> > };",
+ "pending-style-boundary.tsrx",
+ );
+ assert_eq!(pending_only.css.as_deref(), Some(""));
+ assert_eq!(pending_only.css_hash, None);
+ }
+
+ #[cfg(feature = "tsrx")]
+ #[test]
+ fn style_refs_export_classes_and_expression_refs_are_ignored() {
+ let runtime = compile_tsrx(
+ r#"let styles;
+const holder = {};
+const callback = value => value;
+const getRef = () => ({ current: null });
+export const view = <>
+
+
+>;"#,
+ "ref-export.tsrx",
+ );
+ let hash = runtime.css_hash.as_deref().expect("runtime hash");
+ let css = runtime.css.as_deref().unwrap();
+ assert!(css.contains(&format!(".foo.{hash}")), "{css}");
+ assert!(!css.contains("/* (unused) .foo"), "{css}");
+ assert!(runtime.code.contains(&format!("\"foo\": \"{hash} foo\"")));
+ assert!(runtime.code.contains(&format!("")));
+ assert!(runtime.code.contains("styles = {"), "{}", runtime.code);
+ assert!(
+ runtime.code.contains("holder.value = {"),
+ "{}",
+ runtime.code
+ );
+ assert!(runtime.code.contains("callback(value)"), "{}", runtime.code);
+ assert!(
+ runtime.code.matches("_tsrx_style_ref_").count() >= 2,
+ "{}",
+ runtime.code
+ );
+
+ let expression = compile_tsrx(
+ "const ignored = () => {}; export const styles = ;",
+ "expression-ref.tsrx",
+ );
+ assert!(
+ expression.code.contains(&format!(
+ "\"foo\": \"{} foo\"",
+ expression.css_hash.unwrap()
+ )),
+ "{}",
+ expression.code
+ );
+ }
+
+ #[cfg(feature = "tsrx")]
+ #[test]
+ fn leaves_unowned_styles_unextracted_and_keeps_owner_visitor_order() {
+ let unowned = compile_tsrx(
+ ";",
+ "unowned-style.tsrx",
+ );
+ assert_eq!(unowned.css.as_deref(), Some(""));
+ assert_eq!(unowned.css_hash, None);
+
+ let ordered = compile_tsrx(
+ r#"export function View() @{
+ const early = ;
+ <>
+
+
+ >
+}"#,
+ "style-owner-order.tsrx",
+ );
+ let css = ordered.css.as_deref().unwrap();
+ assert!(
+ css.find(".owner").unwrap() < css.find(".early").unwrap(),
+ "{css}"
+ );
+ }
+
+ #[cfg(feature = "tsrx")]
+ #[test]
+ fn import_source_skip_preserves_authored_tsrx_while_compiled_tsrx_emits_maps() {
+ let source = "export const view = <>
>;";
+ let skipped = compile(
+ source,
+ &CompileOptions {
+ filename: Some("skip.tsrx".into()),
+ syntax: Syntax::Tsrx,
+ require_import_source: Some("solid-js".into()),
+ source_map: true,
+ ..CompileOptions::default()
+ },
+ )
+ .unwrap();
+ assert_eq!(skipped.code, source);
+ assert!(skipped.css.as_deref().is_some_and(|css| !css.is_empty()));
+ assert!(skipped.css_hash.is_some());
+ assert_eq!(skipped.source_map, None);
+
+ let tsrx = compile(
+ source,
+ &CompileOptions {
+ filename: Some("mapped.tsrx".into()),
+ syntax: Syntax::Tsrx,
+ source_map: true,
+ ..CompileOptions::default()
+ },
+ )
+ .unwrap();
+ let map = tsrx
+ .source_map
+ .as_deref()
+ .expect("compiled TSRX source map");
+ assert!(map.contains("\"sources\":[\"mapped.tsrx\"]"), "{map}");
+ assert!(map.contains("\"sourcesContent\""), "{map}");
+
+ let jsx = compile(
+ "export const view =
;",
+ &CompileOptions {
+ filename: Some("mapped.tsx".into()),
+ source_map: true,
+ ..CompileOptions::default()
+ },
+ )
+ .unwrap();
+ assert!(jsx.source_map.is_some());
+ }
}
diff --git a/packages/compiler/src/config.rs b/packages/compiler/src/config.rs
index 402474ad9..872d56671 100644
--- a/packages/compiler/src/config.rs
+++ b/packages/compiler/src/config.rs
@@ -14,6 +14,10 @@ pub struct RendererOption {
#[derive(Default)]
pub struct TransformOptions {
pub filename: Option
,
+ /// Source syntax routing, matching `@solidjs/babel-plugin`'s `syntax`:
+ /// `"auto"` (default) compiles `.tsrx` filenames with the TSRX frontend,
+ /// `"jsx"` never does, `"tsrx"` forces TSRX for every file.
+ pub syntax: Option,
/// Runtime module compiled output imports helpers from.
/// Default `"@solidjs/web"`.
pub module_name: Option,
@@ -64,9 +68,18 @@ pub struct TransformOptions {
pub struct TransformResult {
pub code: String,
pub map: Option,
+ /// Extracted TSRX stylesheet output. Absent for ordinary JSX transforms.
+ pub css: Option,
+ /// Space-separated TSRX scope hashes. Absent when no stylesheet was emitted.
+ pub css_hash: Option,
}
pub(crate) fn source_type_for_filename(filename: Option<&str>) -> Result {
+ if filename.is_some_and(|filename| filename.ends_with(".tsrx")) {
+ // Secondary passes receive already-projected ordinary code but retain
+ // the authored filename for stable path-derived metadata.
+ return Ok(SourceType::tsx());
+ }
filename
.map(SourceType::from_path)
.transpose()
diff --git a/packages/compiler/src/dom/children.rs b/packages/compiler/src/dom/children.rs
index 2c0da97eb..986cc3748 100644
--- a/packages/compiler/src/dom/children.rs
+++ b/packages/compiler/src/dom/children.rs
@@ -111,10 +111,15 @@ impl<'a> AstDomTransform<'a, '_> {
// (`detectExpressions`), even if nothing ends up
// referencing it. Nodes without ids don't consume
// a walk position (Babel's `i` counts ids only).
- if filtered_index(child).is_some_and(|position| {
- self.detect_expressions(&filtered, position)
- }) {
- let name = self.next_element_id();
+ // A preceding dynamic slot may have already
+ // claimed this node's id as its insert marker.
+ let claimed = self.pending_child_walk.take();
+ if claimed.is_some()
+ || filtered_index(child).is_some_and(|position| {
+ self.detect_expressions(&filtered, position)
+ })
+ {
+ let name = claimed.unwrap_or_else(|| self.next_element_id());
let lookup = self.child_walk_expression(
element.span,
element_id,
@@ -178,11 +183,14 @@ impl<'a> AstDomTransform<'a, '_> {
// position; the walk is emitted even though unused.
// In dev hydratable mode the walk validates the tag
// (`getFirstChild`/`getNextSibling`).
- if filtered_index(&element.children[index])
- .is_some_and(|position| self.detect_expressions(&filtered, position))
+ let claimed = self.pending_child_walk.take();
+ if claimed.is_some()
+ || filtered_index(&element.children[index]).is_some_and(|position| {
+ self.detect_expressions(&filtered, position)
+ })
{
let child_tag = element_name(&child.opening_element.name)?;
- let name = self.next_element_id();
+ let name = claimed.unwrap_or_else(|| self.next_element_id());
let lookup = self.child_element_expression(
child.span,
element_id,
@@ -230,10 +238,13 @@ impl<'a> AstDomTransform<'a, '_> {
if let Some(value) = self.static_jsx_expression_value(&container.expression) {
template.push_both(&escape_html_text_expression(&value));
if !in_text_run {
- if filtered_index(child).is_some_and(|position| {
- self.detect_expressions(&filtered, position)
- }) {
- let name = self.next_element_id();
+ let claimed = self.pending_child_walk.take();
+ if claimed.is_some()
+ || filtered_index(child).is_some_and(|position| {
+ self.detect_expressions(&filtered, position)
+ })
+ {
+ let name = claimed.unwrap_or_else(|| self.next_element_id());
let lookup = self.child_walk_expression(
element.span,
element_id,
@@ -616,9 +627,11 @@ impl<'a> AstDomTransform<'a, '_> {
initial: None,
});
}
- if has_following_static_content(&children[following_start..]) {
+ // Babel's `nextChild(childNodes, index)`: the marker is the first
+ // following sibling's positional id when one will exist.
+ if let Some(marker) = self.claim_following_walk(children, following_start, span) {
return Some(InsertMarker {
- marker: self.child_walk_expression(span, element_id, *child_node_index),
+ marker,
initial: None,
});
}
@@ -692,10 +705,12 @@ impl<'a> AstDomTransform<'a, '_> {
template: &mut crate::dom::template::TemplateHtml,
declarations: &mut std::vec::Vec>,
) -> Expression<'a> {
+ // Babel: `exprId = childNodes[index + 1].id` — ride the immediately
+ // following sibling's positional id when it will exist.
if !self.slot_boxed_by_text(children, index)
- && self.next_child_is_template_node(children, index)
+ && let Some(marker) = self.claim_following_walk(children, index + 1, span)
{
- return self.child_walk_expression(span, element_id, *child_node_index);
+ return marker;
}
self.dedicated_slot_placeholder(span, element_id, child_node_index, template, declarations)
}
@@ -760,12 +775,23 @@ impl<'a> AstDomTransform<'a, '_> {
false
}
- /// Whether the immediately following retained child contributes a template
- /// node (non-empty text, static expression, or native element) that can
- /// serve as this slot's marker.
- fn next_child_is_template_node(&self, children: &[JSXChild<'a>], index: usize) -> bool {
- for child in &children[index + 1..] {
- return match child {
+ /// Babel rides the immediately following sibling's positional id as the
+ /// slot's marker (`childNodes[index + 1].id` per-slot, `nextChild` in the
+ /// multi branch) — the id its own transform declares. When the first
+ /// retained child at/after `start` is a template node (non-empty text,
+ /// static expression, native element) that will receive a positional id
+ /// (its lowering guard mirrors `detectExpressions`), allocate that name
+ /// now, park it in `pending_child_walk` for the child's lowering to
+ /// declare, and return a reference to it.
+ fn claim_following_walk(
+ &mut self,
+ children: &[JSXChild<'a>],
+ start: usize,
+ span: oxc_span::Span,
+ ) -> Option> {
+ let mut following: Option<&JSXChild<'a>> = None;
+ for child in &children[start..] {
+ let is_template_node = match child {
JSXChild::Text(text) => {
if trim_jsx_text(&text.value).is_empty() {
continue;
@@ -782,8 +808,35 @@ impl<'a> AstDomTransform<'a, '_> {
JSXChild::Element(child) => !is_component_name(&child.opening_element.name),
_ => false,
};
+ if is_template_node {
+ following = Some(child);
+ }
+ break;
}
- false
+ let following = following?;
+ // The sibling's walk declaration is guarded by `detectExpressions`
+ // over the filtered child list; only claim an id the sibling will
+ // actually declare. (Dynamic native elements always declare, but a
+ // dynamic slot immediately before them makes the detect true anyway.)
+ let filtered: std::vec::Vec<&JSXChild<'a>> = children
+ .iter()
+ .filter(|child| match child {
+ JSXChild::Text(text) => !trim_jsx_text(&text.value).is_empty(),
+ JSXChild::ExpressionContainer(container) => {
+ !matches!(container.expression, JSXExpression::EmptyExpression(_))
+ }
+ _ => true,
+ })
+ .collect();
+ let position = filtered
+ .iter()
+ .position(|candidate| std::ptr::eq(*candidate, following))?;
+ if !self.detect_expressions(&filtered, position) {
+ return None;
+ }
+ let name = self.next_element_id();
+ self.pending_child_walk = Some(name.clone());
+ Some(self.identifier_expression(span, &name))
}
/// `getNextMatch(.nextSibling | .firstChild, "")`
@@ -845,7 +898,12 @@ impl<'a> AstDomTransform<'a, '_> {
child
};
let mut child_template = crate::dom::template::TemplateHtml::open_tag(&tag_name);
- let child_id = self.next_element_id();
+ // A preceding dynamic slot may have claimed this element's id as its
+ // insert marker (Babel's `childNodes[index + 1].id`).
+ let child_id = self
+ .pending_child_walk
+ .take()
+ .unwrap_or_else(|| self.next_element_id());
let mut child_declarations = std::vec::Vec::new();
let mut child_operations = std::vec::Vec::new();
@@ -989,18 +1047,6 @@ fn spread_child_expression<'a>(
}
}
-fn has_following_static_content(children: &[JSXChild<'_>]) -> bool {
- children.iter().any(|child| match child {
- JSXChild::Text(text) => !trim_jsx_text(&text.value).is_empty(),
- JSXChild::ExpressionContainer(container) => {
- !matches!(container.expression, JSXExpression::EmptyExpression(_))
- && static_jsx_expression(&container.expression, None).is_some()
- }
- JSXChild::Element(child) => !is_component_name(&child.opening_element.name),
- _ => false,
- })
-}
-
fn has_previous_static_text(children: &[JSXChild<'_>]) -> bool {
children.iter().rev().any(|child| match child {
JSXChild::Text(text) => !trim_jsx_text(&text.value).is_empty(),
diff --git a/packages/compiler/src/dom/element.rs b/packages/compiler/src/dom/element.rs
index 895c77d2e..f65d6c713 100644
--- a/packages/compiler/src/dom/element.rs
+++ b/packages/compiler/src/dom/element.rs
@@ -86,6 +86,12 @@ pub(crate) struct AstDomTransform<'a, 'source> {
/// (`getFirstChild`/`getNextSibling`) chain from it by name — the plain
/// member walks re-derive from the root instead (equalized by traversal).
pub(crate) last_child_walk: Option<(String, usize)>,
+ /// A positional walk name pre-allocated by a dynamic slot's marker for
+ /// the immediately following template child (Babel rides
+ /// `childNodes[index + 1].id` / `nextChild`, which the sibling's own
+ /// transform declares). The next retained child's lowering must consume
+ /// this instead of allocating a fresh id.
+ pub(crate) pending_child_walk: Option,
/// Whether the current template root saw a delegated event handler or a
/// spread (which may carry one); consumed at the root to emit a single
/// `runHydrationEvents()` after setup.
@@ -185,6 +191,7 @@ impl<'a, 'source> AstDomTransform<'a, 'source> {
skip_xmlns_attribute: false,
hydration_walk_anchor: None,
last_child_walk: None,
+ pending_child_walk: None,
has_hydratable_event: false,
element_index: 0,
this_index: 0,
diff --git a/packages/compiler/src/lazy.rs b/packages/compiler/src/lazy.rs
index 332dd5bd4..23694da4f 100644
--- a/packages/compiler/src/lazy.rs
+++ b/packages/compiler/src/lazy.rs
@@ -50,7 +50,12 @@ pub fn transform_lazy(
) -> Result {
let options = options.unwrap_or_default();
let Some(filename) = options.filename.as_deref() else {
- return Ok(TransformResult { code, map: None });
+ return Ok(TransformResult {
+ code,
+ map: None,
+ css: None,
+ css_hash: None,
+ });
};
let source_type = source_type_for_filename(Some(filename))?;
@@ -71,7 +76,12 @@ pub fn transform_lazy(
// Nothing matched: hand back the input untouched instead of a
// reprint (the Babel support pass reprints regardless, but callers
// only care about the placeholder injection).
- return Ok(TransformResult { code, map: None });
+ return Ok(TransformResult {
+ code,
+ map: None,
+ css: None,
+ css_hash: None,
+ });
}
let mut rewriter = Rewriter {
@@ -93,6 +103,8 @@ pub fn transform_lazy(
Ok(TransformResult {
code: build.code,
map: build.map.map(|map| map.to_json_string()),
+ css: None,
+ css_hash: None,
})
}
diff --git a/packages/compiler/src/lib.rs b/packages/compiler/src/lib.rs
index a20fb5694..cb47d8479 100644
--- a/packages/compiler/src/lib.rs
+++ b/packages/compiler/src/lib.rs
@@ -29,10 +29,17 @@ mod node_adapter;
mod refresh;
mod shared;
mod ssr;
+#[cfg(feature = "tsrx")]
+mod tsrx;
mod universal;
-pub use compiler::{CompileOptions, CompileOutput, Generate, Renderer, Wrapper, compile};
+pub use compiler::{CompileOptions, CompileOutput, Generate, Renderer, Syntax, Wrapper, compile};
pub use error::{CompileError, CompileErrorKind};
+#[cfg(feature = "tsrx")]
+pub use tsrx::{
+ TsrxEmbeddedRegion, TsrxEmbeddedRegionKind, TsrxTypecheckMapping, TsrxTypecheckProjection,
+ TsrxTypecheckProjectionOptions, project_tsrx_for_typecheck,
+};
#[cfg(feature = "node")]
pub use node_adapter::*;
diff --git a/packages/compiler/src/node_adapter.rs b/packages/compiler/src/node_adapter.rs
index 9407a4e67..915f24b6b 100644
--- a/packages/compiler/src/node_adapter.rs
+++ b/packages/compiler/src/node_adapter.rs
@@ -11,11 +11,160 @@ pub use crate::directives::{
};
pub use crate::lazy::TransformLazyOptions;
pub use crate::refresh::TransformRefreshOptions;
-use crate::{CompileOptions, Generate, Renderer, Wrapper};
+use crate::{CompileOptions, Generate, Renderer, Syntax, Wrapper};
const UNSUPPORTED_GENERATE: &str =
"The @solidjs/compiler backend implements DOM, SSR, universal, and dynamic modes only";
+#[cfg(feature = "tsrx")]
+#[napi(object)]
+#[derive(Default)]
+pub struct ProjectTsrxForTypecheckOptions {
+ pub filename: Option,
+}
+
+#[cfg(feature = "tsrx")]
+#[napi(object)]
+pub struct TsrxTypecheckEmbeddedRegion {
+ pub kind: String,
+ /// Authored JavaScript string offset in UTF-16 code units.
+ pub start: u32,
+ /// Authored JavaScript string offset in UTF-16 code units.
+ pub end: u32,
+ pub content: String,
+}
+
+#[cfg(feature = "tsrx")]
+#[napi(object)]
+pub struct TsrxTypecheckMapping {
+ /// Authored JavaScript string offset in UTF-16 code units.
+ pub source_start: u32,
+ /// Generated JavaScript string offset in UTF-16 code units.
+ pub generated_start: u32,
+ pub source_length: u32,
+ pub generated_length: u32,
+}
+
+#[cfg(feature = "tsrx")]
+#[napi(object)]
+pub struct TsrxTypecheckProjectionResult {
+ pub code: String,
+ pub map: String,
+ pub mappings: Vec,
+ pub css: String,
+ pub css_hash: Option,
+ pub embedded_regions: Vec,
+}
+
+/// Experimental host-independent TSRX projection for typechecking tools.
+#[cfg(feature = "tsrx")]
+#[napi]
+pub fn project_tsrx_for_typecheck(
+ code: String,
+ options: Option,
+) -> Result {
+ let options = options.unwrap_or_default();
+ let output = crate::tsrx::project_tsrx_for_typecheck(
+ &code,
+ &crate::tsrx::TsrxTypecheckProjectionOptions {
+ filename: options.filename,
+ },
+ )
+ .map_err(|error| Error::from_reason(error.to_string()))?;
+ let source_mapping_endpoints = output
+ .mappings
+ .iter()
+ .flat_map(|mapping| [mapping.source_start, mapping.source_start + mapping.length])
+ .collect::>();
+ let generated_mapping_endpoints = output
+ .mappings
+ .iter()
+ .flat_map(|mapping| {
+ [
+ mapping.generated_start,
+ mapping.generated_start + mapping.length,
+ ]
+ })
+ .collect::>();
+ let source_mapping_utf16 = utf16_offsets(&code, &source_mapping_endpoints)?;
+ let generated_mapping_utf16 = utf16_offsets(&output.code, &generated_mapping_endpoints)?;
+ let mappings = source_mapping_utf16
+ .chunks_exact(2)
+ .zip(generated_mapping_utf16.chunks_exact(2))
+ .map(|(source, generated)| TsrxTypecheckMapping {
+ source_start: source[0],
+ generated_start: generated[0],
+ source_length: source[1] - source[0],
+ generated_length: generated[1] - generated[0],
+ })
+ .collect();
+ let endpoints = output
+ .embedded_regions
+ .iter()
+ .flat_map(|region| [region.start, region.end])
+ .collect::>();
+ let utf16_endpoints = utf16_offsets(&code, &endpoints)?;
+ let embedded_regions = output
+ .embedded_regions
+ .into_iter()
+ .zip(utf16_endpoints.chunks_exact(2))
+ .map(|(region, offsets)| {
+ let kind = match region.kind {
+ crate::tsrx::TsrxEmbeddedRegionKind::Css => "css",
+ crate::tsrx::TsrxEmbeddedRegionKind::Script => "script",
+ };
+ TsrxTypecheckEmbeddedRegion {
+ kind: kind.into(),
+ start: offsets[0],
+ end: offsets[1],
+ content: region.content,
+ }
+ })
+ .collect();
+ Ok(TsrxTypecheckProjectionResult {
+ code: output.code,
+ map: output.source_map,
+ mappings,
+ css: output.css,
+ css_hash: output.css_hash,
+ embedded_regions,
+ })
+}
+
+#[cfg(feature = "tsrx")]
+fn utf16_offsets(source: &str, byte_offsets: &[u32]) -> Result> {
+ let mut indexed = byte_offsets.iter().copied().enumerate().collect::>();
+ indexed.sort_unstable_by_key(|(_, offset)| *offset);
+ let mut converted = vec![0; byte_offsets.len()];
+ let mut byte = 0usize;
+ let mut utf16 = 0usize;
+ for (index, target) in indexed {
+ let target = target as usize;
+ if target > source.len() {
+ return Err(Error::from_reason(
+ "TSRX embedded region exceeds the source length",
+ ));
+ }
+ while byte < target {
+ let character = source[byte..]
+ .chars()
+ .next()
+ .ok_or_else(|| Error::from_reason("TSRX embedded region exceeds the source"))?;
+ byte += character.len_utf8();
+ utf16 += character.len_utf16();
+ }
+ if byte != target {
+ return Err(Error::from_reason(
+ "TSRX embedded region is not on a UTF-8 boundary",
+ ));
+ }
+ converted[index] = u32::try_from(utf16).map_err(|_| {
+ Error::from_reason("TSRX embedded region exceeds the N-API offset range")
+ })?;
+ }
+ Ok(converted)
+}
+
/// The `"use server"` directive pass — a second, independent transform over
/// the same parse infrastructure as the JSX pass. Applies to plain
/// `.js`/`.ts` modules as well as JSX/TSX.
@@ -66,6 +215,8 @@ pub fn transform(code: String, options: Option) -> Result Result {
"dynamic" => Generate::Dynamic,
_ => return Err(Error::from_reason(UNSUPPORTED_GENERATE)),
};
+ // Same fallthrough as the Babel plugin's `isTsrxSource`: any value other
+ // than "tsrx"/"jsx" behaves as "auto".
+ let syntax = match options.syntax.as_deref() {
+ Some("tsrx") => Syntax::Tsrx,
+ Some("jsx") => Syntax::Jsx,
+ _ => Syntax::Auto,
+ };
Ok(CompileOptions {
filename: options.filename,
+ syntax,
module_name,
generate,
hydratable: options.hydratable.unwrap_or(false),
@@ -149,6 +308,8 @@ fn legacy_preflight(
return Ok(TransformResult {
code: code.to_owned(),
map: None,
+ css: None,
+ css_hash: None,
});
}
Err(Error::from_reason(validation_error))
@@ -268,4 +429,27 @@ mod tests {
)
.expect("next accepts an explicitly empty moduleName");
}
+
+ #[cfg(feature = "tsrx")]
+ #[test]
+ fn typecheck_projection_converts_all_embedded_offsets_in_one_utf16_pass() {
+ let source = "const marker = \"🚀\"; export const C = () => <>>;";
+ let output = project_tsrx_for_typecheck(
+ source.into(),
+ Some(ProjectTsrxForTypecheckOptions {
+ filename: Some("offsets.tsrx".into()),
+ }),
+ )
+ .expect("TSRX typecheck projection");
+ assert_eq!(output.embedded_regions.len(), 2);
+ for region in output.embedded_regions {
+ let byte_start = source.find(®ion.content).expect("embedded content");
+ let byte_end = byte_start + region.content.len();
+ assert_eq!(
+ region.start,
+ source[..byte_start].encode_utf16().count() as u32
+ );
+ assert_eq!(region.end, source[..byte_end].encode_utf16().count() as u32);
+ }
+ }
}
diff --git a/packages/compiler/src/refresh/mod.rs b/packages/compiler/src/refresh/mod.rs
index 61022fb3b..8a12665a6 100644
--- a/packages/compiler/src/refresh/mod.rs
+++ b/packages/compiler/src/refresh/mod.rs
@@ -112,7 +112,12 @@ pub fn transform_refresh(
if !changed {
// Skipped modules (`@refresh skip`) and modules with nothing to
// register come back untouched.
- return Ok(TransformResult { code, map: None });
+ return Ok(TransformResult {
+ code,
+ map: None,
+ css: None,
+ css_hash: None,
+ });
}
let build = Codegen::new()
@@ -127,5 +132,7 @@ pub fn transform_refresh(
Ok(TransformResult {
code: build.code,
map: build.map.map(|map| map.to_json_string()),
+ css: None,
+ css_hash: None,
})
}
diff --git a/packages/compiler/src/shared/ast_builder.rs b/packages/compiler/src/shared/ast_builder.rs
index f616494ab..986e7b204 100644
--- a/packages/compiler/src/shared/ast_builder.rs
+++ b/packages/compiler/src/shared/ast_builder.rs
@@ -12,7 +12,9 @@ use oxc_span::Span;
use oxc_str::{Ident, Str};
use oxc_syntax::{
number::NumberBase,
- operator::{AssignmentOperator, BinaryOperator, LogicalOperator, UnaryOperator},
+ operator::{
+ AssignmentOperator, BinaryOperator, LogicalOperator, UnaryOperator, UpdateOperator,
+ },
};
#[derive(Clone, Copy)]
@@ -215,6 +217,16 @@ impl<'a> AstBuilder<'a> {
Expression::new_unary_expression(span, operator, argument, &self.inner())
}
+ pub(crate) fn expression_update(
+ &self,
+ span: Span,
+ operator: UpdateOperator,
+ prefix: bool,
+ argument: SimpleAssignmentTarget<'a>,
+ ) -> Expression<'a> {
+ Expression::new_update_expression(span, operator, prefix, argument, &self.inner())
+ }
+
#[allow(clippy::too_many_arguments)]
pub(crate) fn expression_function(
&self,
@@ -686,6 +698,99 @@ impl<'a> AstBuilder<'a> {
JSXExpressionContainer::new(span, expression, &self.inner())
}
+ pub(crate) fn jsx_attribute_item_expression(
+ &self,
+ span: Span,
+ name: &str,
+ expression: Expression<'a>,
+ ) -> JSXAttributeItem<'a> {
+ JSXAttributeItem::Attribute(JSXAttribute::boxed(
+ span,
+ JSXAttributeName::Identifier(JSXIdentifier::boxed(span, self.str(name), &self.inner())),
+ Some(JSXAttributeValue::ExpressionContainer(
+ JSXExpressionContainer::boxed(span, expression.into(), &self.inner()),
+ )),
+ &self.inner(),
+ ))
+ }
+
+ pub(crate) fn jsx_attribute_item_string(
+ &self,
+ span: Span,
+ name: &str,
+ value: impl Into>,
+ ) -> JSXAttributeItem<'a> {
+ JSXAttributeItem::Attribute(JSXAttribute::boxed(
+ span,
+ JSXAttributeName::Identifier(JSXIdentifier::boxed(span, self.str(name), &self.inner())),
+ Some(JSXAttributeValue::StringLiteral(
+ self.alloc_string_literal(span, value, None),
+ )),
+ &self.inner(),
+ ))
+ }
+
+ pub(crate) fn jsx_identifier(&self, span: Span, name: impl Into>) -> JSXIdentifier<'a> {
+ JSXIdentifier::new(span, name, &self.inner())
+ }
+
+ pub(crate) fn expression_jsx_element(
+ &self,
+ span: Span,
+ name: &str,
+ attributes: ArenaVec<'a, JSXAttributeItem<'a>>,
+ children: ArenaVec<'a, JSXChild<'a>>,
+ ) -> Expression<'a> {
+ let opening_name = JSXElementName::IdentifierReference(
+ self.alloc_identifier_reference(span, self.ident(name)),
+ );
+ let closing_name = JSXElementName::IdentifierReference(
+ self.alloc_identifier_reference(span, self.ident(name)),
+ );
+ Expression::JSXElement(JSXElement::boxed(
+ span,
+ JSXOpeningElement::boxed(span, opening_name, None, attributes, &self.inner()),
+ children,
+ Some(JSXClosingElement::boxed(span, closing_name, &self.inner())),
+ &self.inner(),
+ ))
+ }
+
+ pub(crate) fn jsx_child_expression(
+ &self,
+ span: Span,
+ expression: Expression<'a>,
+ ) -> JSXChild<'a> {
+ match expression {
+ Expression::JSXElement(element) => JSXChild::Element(element),
+ Expression::JSXFragment(fragment) => JSXChild::Fragment(fragment),
+ expression => self.jsx_child_expression_container(span, expression.into()),
+ }
+ }
+
+ pub(crate) fn expression_jsx_fragment(
+ &self,
+ span: Span,
+ children: ArenaVec<'a, JSXChild<'a>>,
+ ) -> Expression<'a> {
+ Expression::JSXFragment(JSXFragment::boxed(
+ span,
+ JSXOpeningFragment::new(span, &self.inner()),
+ children,
+ JSXClosingFragment::new(span, &self.inner()),
+ &self.inner(),
+ ))
+ }
+
+ pub(crate) fn alloc_jsx_member_expression(
+ &self,
+ span: Span,
+ object: JSXMemberExpressionObject<'a>,
+ property: JSXIdentifier<'a>,
+ ) -> ArenaBox<'a, JSXMemberExpression<'a>> {
+ JSXMemberExpression::boxed(span, object, property, &self.inner())
+ }
+
pub(crate) fn template_element_with_lone_surrogates(
&self,
span: Span,
diff --git a/packages/compiler/src/ssr/transform.rs b/packages/compiler/src/ssr/transform.rs
index 265579ae8..8a67154fa 100644
--- a/packages/compiler/src/ssr/transform.rs
+++ b/packages/compiler/src/ssr/transform.rs
@@ -92,6 +92,9 @@ pub(crate) struct AstSsrTransform<'a, 'source> {
/// Spans of JSX elements sitting in statement position (`return `,
/// `const x = `) for the statement currently being processed.
statement_jsx_spans: std::vec::Vec,
+ /// Direct component children are deferred values even when the generated
+ /// getter eventually places them in a return statement.
+ component_child_depth: usize,
/// Scope stack for bare `var` hoisting, mirroring Babel's `Scope.push`
/// targeting rules: the nearest block parent normally, the function
/// parent from switch statements, and the scope *outside* the enclosing
@@ -232,6 +235,7 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> {
hoisted_var_names: std::vec::Vec::new(),
pending_statements: std::vec::Vec::new(),
statement_jsx_spans: std::vec::Vec::new(),
+ component_child_depth: 0,
var_scope_stack: std::vec::Vec::new(),
wont_escape_spans: std::vec::Vec::new(),
jsx_root_span: None,
@@ -684,7 +688,7 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> {
return Ok(ssr_call);
}
- if self.statement_jsx_spans.contains(&element.span) {
+ if self.component_child_depth == 0 && self.statement_jsx_spans.contains(&element.span) {
// Statement position: one combined `var _v$ = init1, _v$2 = …;`
// declaration before the parent statement (Babel's
// `insertBefore` in `ssr/template.ts`).
@@ -1096,16 +1100,26 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> {
});
}
}
- JSXChild::Element(element) => values.push(ChildValue {
- value: self.lower_element(element)?,
- dynamic: false,
- expression_source: false,
- }),
- JSXChild::Fragment(fragment) => values.push(ChildValue {
- value: self.lower_fragment(fragment)?,
- dynamic: false,
- expression_source: false,
- }),
+ JSXChild::Element(element) => {
+ self.component_child_depth += 1;
+ let value = self.lower_element(element);
+ self.component_child_depth -= 1;
+ values.push(ChildValue {
+ value: value?,
+ dynamic: false,
+ expression_source: false,
+ });
+ }
+ JSXChild::Fragment(fragment) => {
+ self.component_child_depth += 1;
+ let value = self.lower_fragment(fragment);
+ self.component_child_depth -= 1;
+ values.push(ChildValue {
+ value: value?,
+ dynamic: false,
+ expression_source: false,
+ });
+ }
JSXChild::ExpressionContainer(container) => {
let dynamic = container.expression.as_expression().is_some_and(|raw| {
self.classify()
diff --git a/packages/compiler/src/tsrx/leaf.rs b/packages/compiler/src/tsrx/leaf.rs
new file mode 100644
index 000000000..cfa7a3a23
--- /dev/null
+++ b/packages/compiler/src/tsrx/leaf.rs
@@ -0,0 +1,327 @@
+//! Load ordinary JavaScript, TypeScript, and JSX leaves from the parser's
+//! legal-TSX scaffold without deserializing `FlatTape`.
+
+use oxc_allocator::{Allocator, CloneIn};
+use oxc_ast::ast::{BindingPattern, Expression, Program, Statement};
+use oxc_ast_visit::{Visit, VisitMut, walk, walk_mut};
+use oxc_span::{GetSpan, SourceType, Span};
+use tsrx_syntax::{ControlContext, ProjectionSegment, project_for_parser, scan_for_parser};
+
+use super::semantic::AuthoredSpan;
+use crate::error::CompileError;
+
+/// An Oxc program containing parser scaffolds plus all authored standard-
+/// language leaves. The scaffold is temporary; direct semantic lowering
+/// replaces it before the shared JSX transforms run.
+pub(super) struct LeafProgram<'a> {
+ pub program: Program<'a>,
+ pub(super) map: LeafMap,
+ marker_prefix: String,
+ control_contexts: Vec,
+}
+
+impl<'a> LeafProgram<'a> {
+ pub fn parse(allocator: &'a Allocator, source: &str) -> Result {
+ let overlay = scan_for_parser(source)
+ .map_err(|error| CompileError::parse(format!("TSRX scan failed: {error:?}")))?;
+ let control_contexts = overlay
+ .view()
+ .nodes
+ .iter()
+ .map(|node| node.context)
+ .collect();
+ let projection = project_for_parser(source, &overlay)
+ .map_err(|error| CompileError::parse(format!("TSRX projection failed: {error:?}")))?;
+ let marker_prefix = projection
+ .parser_marker_prefix()
+ .ok_or_else(|| CompileError::parse("TSRX parser projection has no marker prefix"))?
+ .to_string();
+ let projected = allocator.alloc_str(projection.source());
+ let parsed = oxc_parser::Parser::new(allocator, projected, SourceType::tsx())
+ .with_options(oxc_parser::ParseOptions {
+ preserve_parens: false,
+ ..oxc_parser::ParseOptions::default()
+ })
+ .parse();
+ if let Some(error) = crate::shared::parser::first_parser_error(parsed.diagnostics) {
+ return Err(CompileError::parse(error));
+ }
+ Ok(Self {
+ program: parsed.program,
+ map: LeafMap::new(projection.view().segments),
+ marker_prefix,
+ control_contexts,
+ })
+ }
+
+ pub fn control_contexts(&self) -> &[ControlContext] {
+ &self.control_contexts
+ }
+
+ pub fn wrapper_name(&self, index: usize) -> String {
+ format!("{}W{index}_", self.marker_prefix)
+ }
+
+ pub fn marker_prefix(&self) -> &str {
+ &self.marker_prefix
+ }
+
+ pub fn rebase(&mut self) {
+ SpanRebaser { map: &self.map }.visit_program(&mut self.program);
+ }
+
+ pub fn finish(mut self, authored_source: &'a str) -> Program<'a> {
+ self.program.source_text = authored_source;
+ self.program
+ }
+
+ /// Clone the smallest expression whose unchanged projected span exactly
+ /// corresponds to `authored`.
+ pub fn expression(
+ &self,
+ allocator: &'a Allocator,
+ authored: AuthoredSpan,
+ ) -> Option> {
+ let mut finder = ExpressionFinder {
+ allocator,
+ map: &self.map,
+ target: authored,
+ found: None,
+ };
+ finder.visit_program(&self.program);
+ finder.found
+ }
+
+ pub fn binding_pattern(
+ &self,
+ allocator: &'a Allocator,
+ authored: AuthoredSpan,
+ ) -> Option> {
+ let mut finder = BindingPatternFinder {
+ allocator,
+ map: &self.map,
+ target: authored,
+ found: None,
+ };
+ finder.visit_program(&self.program);
+ finder.found
+ }
+
+ pub fn statement(
+ &self,
+ allocator: &'a Allocator,
+ authored: AuthoredSpan,
+ ) -> Option> {
+ let mut finder = StatementFinder {
+ allocator,
+ map: &self.map,
+ target: authored,
+ found: None,
+ };
+ finder.visit_program(&self.program);
+ finder.found
+ }
+}
+
+#[derive(Clone, Copy)]
+struct LeafSegment {
+ projected: Span,
+ authored_start: u32,
+}
+
+pub(super) struct LeafMap {
+ segments: Vec,
+}
+
+impl LeafMap {
+ fn new(segments: &[ProjectionSegment]) -> Self {
+ Self {
+ segments: segments
+ .iter()
+ .map(|segment| LeafSegment {
+ projected: Span::new(segment.projected.start, segment.projected.end),
+ authored_start: segment.original_start,
+ })
+ .collect(),
+ }
+ }
+
+ fn authored_span(&self, projected: Span) -> Option {
+ let mut index = self
+ .segments
+ .partition_point(|segment| segment.projected.start <= projected.start)
+ .checked_sub(1)?;
+ let first = self.segments.get(index)?;
+ if projected.start < first.projected.start || projected.start > first.projected.end {
+ return None;
+ }
+ let start = first.authored_start + projected.start - first.projected.start;
+ let mut projected_cursor = projected.start;
+ let mut authored_cursor = start;
+ while projected_cursor < projected.end {
+ let segment = self.segments.get(index)?;
+ if projected_cursor < segment.projected.start
+ || projected_cursor >= segment.projected.end
+ || segment.authored_start + projected_cursor - segment.projected.start
+ != authored_cursor
+ {
+ return None;
+ }
+ let end = projected.end.min(segment.projected.end);
+ authored_cursor += end - projected_cursor;
+ projected_cursor = end;
+ index += 1;
+ }
+ Some(AuthoredSpan {
+ start,
+ end: authored_cursor,
+ })
+ }
+
+ pub(super) fn authored_extent(&self, projected: Span) -> Option {
+ let start = self.authored_endpoint(projected.start, true)?;
+ let end = self.authored_endpoint(projected.end, false)?;
+ (start <= end).then_some(AuthoredSpan { start, end })
+ }
+
+ pub(super) fn authored_start(&self, projected: Span) -> Option {
+ self.authored_endpoint(projected.start, true)
+ }
+
+ fn authored_endpoint(&self, offset: u32, start: bool) -> Option {
+ let index = if start {
+ self.segments
+ .partition_point(|segment| segment.projected.start <= offset)
+ } else {
+ self.segments
+ .partition_point(|segment| segment.projected.start < offset)
+ }
+ .checked_sub(1)?;
+ let segment = self.segments.get(index)?;
+ if offset < segment.projected.start || offset > segment.projected.end {
+ return None;
+ }
+ Some(segment.authored_start + offset - segment.projected.start)
+ }
+}
+
+struct ExpressionFinder<'a, 'm> {
+ allocator: &'a Allocator,
+ map: &'m LeafMap,
+ target: AuthoredSpan,
+ found: Option>,
+}
+
+impl<'a> Visit<'a> for ExpressionFinder<'a, '_> {
+ fn visit_expression(&mut self, expression: &Expression<'a>) {
+ if self.found.is_some() {
+ return;
+ }
+ let span = expression.span();
+ let exact = self.map.authored_extent(span) == Some(self.target);
+ let template_root = matches!(
+ expression,
+ Expression::JSXElement(_) | Expression::JSXFragment(_)
+ ) && self.map.authored_endpoint(span.start, true)
+ == Some(self.target.start);
+ if exact || template_root {
+ let mut expression = expression.clone_in(self.allocator);
+ SpanRebaser { map: self.map }.visit_expression(&mut expression);
+ self.found = Some(expression);
+ return;
+ }
+ walk::walk_expression(self, expression);
+ }
+}
+
+struct BindingPatternFinder<'a, 'm> {
+ allocator: &'a Allocator,
+ map: &'m LeafMap,
+ target: AuthoredSpan,
+ found: Option>,
+}
+
+impl<'a> Visit<'a> for BindingPatternFinder<'a, '_> {
+ fn visit_binding_pattern(&mut self, pattern: &BindingPattern<'a>) {
+ if self.found.is_some() {
+ return;
+ }
+ if self.map.authored_span(pattern.span()) == Some(self.target) {
+ let mut pattern = pattern.clone_in(self.allocator);
+ SpanRebaser { map: self.map }.visit_binding_pattern(&mut pattern);
+ self.found = Some(pattern);
+ return;
+ }
+ walk::walk_binding_pattern(self, pattern);
+ }
+}
+
+struct StatementFinder<'a, 'm> {
+ allocator: &'a Allocator,
+ map: &'m LeafMap,
+ target: AuthoredSpan,
+ found: Option>,
+}
+
+impl<'a> Visit<'a> for StatementFinder<'a, '_> {
+ fn visit_statement(&mut self, statement: &Statement<'a>) {
+ if self.found.is_some() {
+ return;
+ }
+ if self.map.authored_extent(statement.span()) == Some(self.target) {
+ let mut statement = statement.clone_in(self.allocator);
+ SpanRebaser { map: self.map }.visit_statement(&mut statement);
+ self.found = Some(statement);
+ return;
+ }
+ walk::walk_statement(self, statement);
+ }
+}
+
+struct SpanRebaser<'m> {
+ map: &'m LeafMap,
+}
+
+impl<'a> VisitMut<'a> for SpanRebaser<'_> {
+ fn visit_span(&mut self, span: &mut Span) {
+ if span.is_unspanned() {
+ return;
+ }
+ *span = self
+ .map
+ .authored_span(*span)
+ .map_or(Span::default(), |authored| {
+ Span::new(authored.start, authored.end)
+ });
+ walk_mut::walk_span(self, span);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn loads_authored_expressions_from_parser_scaffolds() {
+ let source = "export function View({ ready, value }: Props) @{ const local = 1; @if (ready) { } }";
+ let allocator = Allocator::default();
+ let leaves = LeafProgram::parse(&allocator, source).expect("parser scaffold");
+ for (authored, start) in [
+ ("ready", source.find("@if (ready)").expect("condition") + 5),
+ (
+ "value + local",
+ source.find("value + local").expect("child expression"),
+ ),
+ ] {
+ let start = start as u32;
+ let span = AuthoredSpan {
+ start,
+ end: start + authored.len() as u32,
+ };
+ assert!(
+ leaves.expression(&allocator, span).is_some(),
+ "missing {authored}"
+ );
+ }
+ }
+}
diff --git a/packages/compiler/src/tsrx/lower.rs b/packages/compiler/src/tsrx/lower.rs
new file mode 100644
index 000000000..b153f5545
--- /dev/null
+++ b/packages/compiler/src/tsrx/lower.rs
@@ -0,0 +1,2007 @@
+//! Direct lowering from compiler-owned TSRX semantics to Oxc AST.
+
+use std::collections::{HashMap, HashSet};
+
+use oxc_allocator::Allocator;
+use oxc_ast::ast::{
+ Argument, AssignmentTarget, Expression, FormalParameterKind, FunctionBody, JSXAttributeItem,
+ JSXAttributeName, JSXAttributeValue, JSXChild, JSXElement, JSXElementName, Program,
+ PropertyKey, PropertyKind, Statement, TemplateElementValue, VariableDeclarationKind,
+};
+use oxc_ast_visit::{Visit, VisitMut, walk, walk_mut};
+use oxc_span::{GetSpan, GetSpanMut, Span};
+use oxc_syntax::operator::{AssignmentOperator, BinaryOperator, LogicalOperator, UnaryOperator};
+use tsrx_syntax::ControlContext;
+
+use super::{
+ leaf::LeafProgram,
+ semantic::{
+ AuthoredSpan, CatchBinding, CodeBlock, ControlFlow, ForLoop, IfChain, SolidTsrxModule,
+ TemplateBlock, TemplateSite, Try as SemanticTry,
+ },
+ style::ClassMapEntry,
+ style_projection::{
+ RefSetup, StyleAction, StyleProjection, decode_json_string, is_callback_ref,
+ is_direct_ref_target,
+ },
+ tape::Node,
+};
+use crate::{error::CompileError, shared::ast_builder::AstBuilder};
+
+pub(crate) struct DirectLowered<'a> {
+ pub program: Program<'a>,
+ pub artifacts: super::rewrite::RewriteArtifacts,
+ pub css: String,
+ pub css_hash: Option,
+}
+
+pub(super) fn lower<'a>(
+ allocator: &'a Allocator,
+ source: &str,
+ semantic: &SolidTsrxModule<'_>,
+ styles: &StyleProjection<'_>,
+) -> Result, CompileError> {
+ if !is_supported(semantic, styles) {
+ return Err(direct_invariant(
+ "semantic control-flow validation admitted an unsupported block",
+ ));
+ }
+ if semantic.lazy_patterns.iter().any(|pattern| {
+ semantic.is_authored_lazy_pattern(pattern.origin.tape)
+ && pattern
+ .origin
+ .span
+ .start
+ .checked_sub(1)
+ .and_then(|start| source.as_bytes().get(start as usize))
+ != Some(&b'&')
+ }) {
+ return Err(CompileError::parse(
+ "Unexpected token in lazy binding pattern",
+ ));
+ }
+ let mut leaves = LeafProgram::parse(allocator, source)?;
+ let Some(style_owner_setups) =
+ build_style_owner_setups(allocator, AstBuilder::new(allocator), &leaves, styles)
+ else {
+ return Err(direct_invariant(
+ "a scoped style ref target could not be loaded",
+ ));
+ };
+ let controls = semantic
+ .control_flow
+ .iter()
+ .filter(|control| !matches!(control, ControlFlow::CodeBlock(_)))
+ .collect::>();
+ if leaves.control_contexts().len() != controls.len() {
+ return Err(direct_invariant(
+ "parser and semantic control-flow counts do not match",
+ ));
+ }
+
+ let mut lazy_assignments = LazyAssignmentScaffoldNormalizer {
+ map: &leaves.map,
+ patterns: semantic
+ .lazy_assignments
+ .iter()
+ .filter_map(|assignment| {
+ assignment
+ .pattern
+ .span()
+ .map(|(start, end)| AuthoredSpan { start, end })
+ })
+ .collect(),
+ };
+ lazy_assignments.visit_program(&mut leaves.program);
+ if !lazy_assignments.patterns.is_empty() {
+ return Err(direct_invariant(
+ "a lazy assignment scaffold could not be normalized",
+ ));
+ }
+
+ let marker_prefix = leaves.marker_prefix().to_string();
+ let mut templates = TemplateScaffoldReplacer::new(
+ AstBuilder::new(allocator),
+ &leaves.map,
+ &marker_prefix,
+ semantic,
+ source,
+ );
+ templates.visit_program(&mut leaves.program);
+ if !templates.complete() {
+ return Err(direct_invariant(
+ "a template scaffold could not be replaced",
+ ));
+ }
+ let mut style_lowerer =
+ StyleScaffoldLowerer::new(AstBuilder::new(allocator), &leaves.map, styles);
+ style_lowerer.visit_program(&mut leaves.program);
+ if !style_lowerer.complete() {
+ return Err(direct_invariant(
+ "a scoped style action could not be applied",
+ ));
+ }
+
+ let mut lowerer = Lowerer {
+ allocator,
+ ast: AstBuilder::new(allocator),
+ semantic,
+ leaves: &leaves,
+ artifacts: super::rewrite::RewriteArtifacts {
+ lazy_patterns: semantic
+ .lazy_patterns
+ .iter()
+ .map(|pattern| {
+ (
+ pattern.origin.span.start,
+ String::new(),
+ pattern.source_accessor,
+ )
+ })
+ .collect(),
+ accessor_arrows: Vec::new(),
+ },
+ };
+ let mut expression_replacements = HashMap::new();
+ let mut statement_replacements = HashMap::new();
+ for (index, (control, context)) in controls
+ .into_iter()
+ .zip(leaves.control_contexts())
+ .enumerate()
+ {
+ let expression = lowerer.control(control)?;
+ if *context == ControlContext::Statement {
+ let Some(anchor) = control_anchor(control) else {
+ return Err(direct_invariant(
+ "a statement control-flow construct is missing its anchor",
+ ));
+ };
+ statement_replacements.insert(anchor, expression);
+ } else {
+ expression_replacements.insert(leaves.wrapper_name(index), expression);
+ }
+ }
+ let mut code_block_replacements = HashMap::new();
+ let mut code_block_origins = HashMap::new();
+ for control in &semantic.control_flow {
+ let ControlFlow::CodeBlock(block) = control else {
+ continue;
+ };
+ let Some(render) = block.render else {
+ return Err(direct_invariant(
+ "a code block is missing its render expression",
+ ));
+ };
+ let Some((start, _)) = render.span() else {
+ return Err(direct_invariant(
+ "a code block render expression is missing its authored span",
+ ));
+ };
+ code_block_replacements.insert(start, lowerer.code_block(block)?);
+ code_block_origins.insert(start, ast_span(block.origin.span));
+ }
+ let artifacts = lowerer.artifacts;
+ let function_style_owners = code_block_origins
+ .iter()
+ .map(|(render, origin)| (*render, origin.start))
+ .collect();
+ leaves.rebase();
+ let mut replacer = ScaffoldReplacer {
+ ast: AstBuilder::new(allocator),
+ expression_replacements,
+ statement_replacements,
+ code_block_replacements,
+ };
+ replacer.visit_program(&mut leaves.program);
+ let mut scaffold_finder = ParserScaffoldFinder {
+ prefix: &marker_prefix,
+ found: false,
+ };
+ scaffold_finder.visit_program(&leaves.program);
+ if scaffold_finder.found {
+ return Err(direct_invariant(&format!(
+ "control-flow parser scaffolds could not be replaced; expressions {:?}, statements {:?}",
+ replacer.expression_replacements.keys().collect::>(),
+ replacer.statement_replacements.keys().collect::>()
+ )));
+ }
+ let dynamic_origins = semantic
+ .template_sites
+ .iter()
+ .filter_map(|site| match site {
+ TemplateSite::DynamicElement { origin } => Some(origin.span),
+ _ => None,
+ })
+ .collect::>();
+ DynamicElementAnchorer {
+ origins: &dynamic_origins,
+ }
+ .visit_program(&mut leaves.program);
+
+ let mut code_blocks = replacer
+ .code_block_replacements
+ .keys()
+ .filter_map(|render| {
+ code_block_origins
+ .get(render)
+ .copied()
+ .map(|origin| (*render, origin))
+ })
+ .collect::>();
+ FunctionCodeBlockFinalizer {
+ ast: AstBuilder::new(allocator),
+ code_blocks: &mut code_blocks,
+ }
+ .visit_program(&mut leaves.program);
+ if !code_blocks.is_empty() {
+ return Err(direct_invariant(
+ "a function code block could not be finalized",
+ ));
+ }
+ let mut style_owners = StyleOwnerLowerer::new(
+ AstBuilder::new(allocator),
+ style_owner_setups,
+ function_style_owners,
+ );
+ style_owners.visit_program(&mut leaves.program);
+ if !style_owners.complete() {
+ return Err(direct_invariant(&format!(
+ "scoped style owners could not be located; missing {:?}, observed {:?}",
+ style_owners.setups.keys().collect::>(),
+ style_owners.observed
+ )));
+ }
+ AuthoredJsxTextSanitizer { source }.visit_program(&mut leaves.program);
+ let mut lazy_patterns = LazyPatternRootAligner {
+ expected: semantic
+ .lazy_patterns
+ .iter()
+ .map(|pattern| pattern.origin.span.start)
+ .collect(),
+ };
+ lazy_patterns.visit_program(&mut leaves.program);
+ if !lazy_patterns.expected.is_empty() {
+ return Err(direct_invariant(&format!(
+ "lazy binding patterns could not be aligned; missing {:?}",
+ lazy_patterns.expected
+ )));
+ }
+ let authored = allocator.alloc_str(source);
+ Ok(DirectLowered {
+ program: leaves.finish(authored),
+ artifacts,
+ css: styles.css.clone(),
+ css_hash: styles.css_hash.clone(),
+ })
+}
+
+fn direct_invariant(message: &str) -> CompileError {
+ CompileError::transform(format!("TSRX direct lowering invariant failed: {message}"))
+}
+
+fn is_supported(semantic: &SolidTsrxModule<'_>, _styles: &StyleProjection<'_>) -> bool {
+ semantic.control_flow.iter().all(|control| match control {
+ ControlFlow::If(chain) => supported_if(chain),
+ ControlFlow::Switch(switch) => switch.arms.iter().all(|arm| supported_block(arm.block())),
+ ControlFlow::For(loop_) => {
+ supported_block(&loop_.body) && loop_.empty.as_ref().is_none_or(supported_block)
+ }
+ ControlFlow::Try(try_) => {
+ supported_block(&try_.body)
+ && try_.pending.as_ref().is_none_or(supported_block)
+ && try_
+ .catch
+ .as_ref()
+ .is_none_or(|catch| supported_block(&catch.body))
+ }
+ ControlFlow::CodeBlock(_) => true,
+ })
+}
+
+fn supported_if(chain: &IfChain<'_>) -> bool {
+ chain
+ .branches
+ .iter()
+ .all(|branch| supported_block(&branch.body))
+ && chain.fallback.as_ref().is_none_or(supported_block)
+}
+
+fn supported_block(block: &TemplateBlock<'_>) -> bool {
+ block.setup.is_empty() || !block.renders.is_empty()
+}
+
+struct Lowerer<'a, 's, 't> {
+ allocator: &'a Allocator,
+ ast: AstBuilder<'a>,
+ semantic: &'s SolidTsrxModule<'t>,
+ leaves: &'s LeafProgram<'a>,
+ artifacts: super::rewrite::RewriteArtifacts,
+}
+
+impl<'a> Lowerer<'a, '_, '_> {
+ fn control(&mut self, control: &ControlFlow<'_>) -> Result, CompileError> {
+ match control {
+ ControlFlow::CodeBlock(block) => self.code_block(block),
+ ControlFlow::If(chain) => self.if_chain(chain),
+ ControlFlow::For(loop_) => self.for_loop(loop_),
+ ControlFlow::Switch(switch) => self.switch(switch),
+ ControlFlow::Try(try_) => self.try_expression(try_),
+ }
+ }
+
+ fn code_block(&mut self, block: &CodeBlock<'_>) -> Result, CompileError> {
+ let span = generated_span(block.origin.span);
+ let mut statements = self.ast.vec_with_capacity(block.setup.len() + 1);
+ for setup in &block.setup {
+ statements.push(self.statement(*setup)?);
+ }
+ let render = block
+ .render
+ .ok_or_else(|| direct_invariant("a code block is missing its render expression"))
+ .and_then(|render| self.render_expression(render))?;
+ statements.push(self.ast.statement_return(span, Some(render)));
+ let parameters = self.ast.formal_parameters(
+ span,
+ FormalParameterKind::ArrowFormalParameters,
+ self.ast.vec(),
+ None,
+ );
+ let body = self.ast.function_body(span, self.ast.vec(), statements);
+ let arrow = self
+ .ast
+ .expression_arrow_function(span, false, false, None, parameters, None, body);
+ Ok(self
+ .ast
+ .expression_call(span, arrow, None, self.ast.vec(), false))
+ }
+
+ fn try_expression(&mut self, try_: &SemanticTry<'_>) -> Result, CompileError> {
+ let span = generated_span(try_.origin.span);
+ let mut inner = self.block_expression(&try_.body)?.ok_or_else(|| {
+ CompileError::transform("A TSRX @try block must end with rendered output")
+ })?;
+ if let Some(pending) = try_.pending.as_ref() {
+ let mut attributes = self.ast.vec();
+ if let Some(fallback) = self.block_expression(pending)? {
+ attributes.push(
+ self.ast
+ .jsx_attribute_item_expression(span, "fallback", fallback),
+ );
+ }
+ let children = self.ast.vec1(self.jsx_child(span, inner));
+ inner = self
+ .ast
+ .expression_jsx_element(span, "Loading", attributes, children);
+ }
+ if let Some(catch) = try_.catch.as_ref() {
+ let mut patterns = Vec::new();
+ let mut accessor_names = Vec::new();
+ match &catch.binding {
+ Some(CatchBinding::Identifier { name }) => {
+ let parameter =
+ catch.origin.tape.node_field("param").ok_or_else(|| {
+ CompileError::transform("TSRX @catch binding is missing")
+ })?;
+ patterns.push(self.binding_pattern(parameter)?);
+ accessor_names.push((*name).to_string());
+ }
+ Some(CatchBinding::Pattern(pattern)) => {
+ patterns.push(self.binding_pattern(*pattern)?);
+ }
+ None => patterns.push(
+ self.ast
+ .binding_pattern_binding_identifier(span, self.ast.ident("_e")),
+ ),
+ }
+ if let Some(reset) = catch.reset {
+ patterns.push(self.binding_pattern(reset)?);
+ }
+ let callback_span = ast_span(catch.origin.span);
+ let callback = self.arrow_with_block(callback_span, patterns, &catch.body)?;
+ if !accessor_names.is_empty() {
+ self.artifacts
+ .accessor_arrows
+ .push((callback_span.start, accessor_names));
+ }
+ let attributes = self.ast.vec1(
+ self.ast
+ .jsx_attribute_item_expression(span, "fallback", callback),
+ );
+ let children = self.ast.vec1(self.jsx_child(span, inner));
+ inner = self
+ .ast
+ .expression_jsx_element(span, "Errored", attributes, children);
+ }
+ Ok(inner)
+ }
+
+ fn for_loop(&mut self, loop_: &ForLoop<'_>) -> Result, CompileError> {
+ let span = generated_span(loop_.origin.span);
+ let mut attributes = self.ast.vec();
+ attributes.push(self.ast.jsx_attribute_item_expression(
+ span,
+ "each",
+ self.expression(loop_.iterable)?,
+ ));
+ let fallback = loop_
+ .empty
+ .as_ref()
+ .map(|empty| self.block_expression(empty))
+ .transpose()?
+ .flatten();
+
+ if let Some(key) = loop_.key {
+ let key_expression = self.expression(key)?;
+ let mut key_callback =
+ self.arrow_from_patterns(ast_span_of(key), &[loop_.pattern], key_expression)?;
+ if let Expression::ArrowFunctionExpression(callback) = &mut key_callback {
+ for parameter in &mut callback.params.items {
+ GeneratedSubtreeUnspanner.visit_binding_pattern(&mut parameter.pattern);
+ }
+ }
+ attributes.push(
+ self.ast
+ .jsx_attribute_item_expression(span, "keyed", key_callback),
+ );
+ } else if loop_.callback_mode.emits_non_keyed_intent() {
+ attributes.push(self.ast.jsx_attribute_item_expression(
+ span,
+ "keyed",
+ self.ast.expression_boolean_literal(span, false),
+ ));
+ }
+ if let Some(fallback) = fallback {
+ attributes.push(
+ self.ast
+ .jsx_attribute_item_expression(span, "fallback", fallback),
+ );
+ }
+ let mut patterns = vec![loop_.pattern];
+ if let Some(index) = loop_.index {
+ patterns.push(index);
+ }
+ let callback_patterns = patterns
+ .iter()
+ .map(|pattern| self.binding_pattern(*pattern))
+ .collect::, _>>()?;
+ let callback = self.arrow_with_block(span, callback_patterns, &loop_.body)?;
+ let mut accessor_names = Vec::new();
+ if loop_.callback_mode.item_is_accessor()
+ && let Some(name) = identifier_name(loop_.pattern)
+ {
+ accessor_names.push(name.to_string());
+ }
+ if loop_.callback_mode.index_is_accessor()
+ && let Some(index) = loop_.index
+ && let Some(name) = identifier_name(index)
+ {
+ accessor_names.push(name.to_string());
+ }
+ if !accessor_names.is_empty() {
+ self.artifacts
+ .accessor_arrows
+ .push((span.start, accessor_names));
+ }
+ let children = self.ast.vec1(self.ast.jsx_child_expression(span, callback));
+ Ok(self
+ .ast
+ .expression_jsx_element(span, "For", attributes, children))
+ }
+
+ fn switch(
+ &mut self,
+ switch: &super::semantic::Switch<'_>,
+ ) -> Result, CompileError> {
+ let span = generated_span(switch.origin.span);
+ let mut attributes = self.ast.vec();
+ if let Some(default) = switch.default_arm()
+ && let Some(expression) = self.block_expression(default.block())?
+ {
+ attributes.push(
+ self.ast
+ .jsx_attribute_item_expression(span, "fallback", expression),
+ );
+ }
+ let mut children = self.ast.vec();
+ for arm in &switch.arms {
+ let super::semantic::SwitchArm::Case { test, block, .. } = arm else {
+ continue;
+ };
+ let arm_span = ast_span(block.node_span());
+ let condition = self.ast.expression_binary(
+ arm_span,
+ self.expression(switch.discriminant)?,
+ BinaryOperator::StrictEquality,
+ self.expression(*test)?,
+ );
+ let mut match_attributes = self.ast.vec();
+ match_attributes.push(
+ self.ast
+ .jsx_attribute_item_expression(arm_span, "when", condition),
+ );
+ let match_children = self.block_children(block)?;
+ let match_ = self.ast.expression_jsx_element(
+ arm_span,
+ "Match",
+ match_attributes,
+ match_children,
+ );
+ children.push(self.jsx_child(arm_span, match_));
+ }
+ Ok(self
+ .ast
+ .expression_jsx_element(span, "Switch", attributes, children))
+ }
+
+ fn if_chain(&mut self, chain: &IfChain<'_>) -> Result, CompileError> {
+ let span = generated_span(chain.origin.span);
+ if let [branch] = chain.branches.as_slice() {
+ let mut attributes = self.ast.vec();
+ attributes.push(self.ast.jsx_attribute_item_expression(
+ span,
+ "when",
+ self.expression(branch.test)?,
+ ));
+ if let Some(fallback) = chain.fallback.as_ref()
+ && let Some(expression) = self.block_expression(fallback)?
+ {
+ attributes.push(
+ self.ast
+ .jsx_attribute_item_expression(span, "fallback", expression),
+ );
+ }
+ let children = self.block_children(&branch.body)?;
+ return Ok(self
+ .ast
+ .expression_jsx_element(span, "Show", attributes, children));
+ }
+
+ let mut attributes = self.ast.vec();
+ if let Some(fallback) = chain.fallback.as_ref()
+ && let Some(expression) = self.block_expression(fallback)?
+ {
+ attributes.push(
+ self.ast
+ .jsx_attribute_item_expression(span, "fallback", expression),
+ );
+ }
+ let mut children = self.ast.vec();
+ for branch in &chain.branches {
+ let branch_span = ast_span(branch.body.node_span());
+ let mut match_attributes = self.ast.vec();
+ match_attributes.push(self.ast.jsx_attribute_item_expression(
+ branch_span,
+ "when",
+ self.expression(branch.test)?,
+ ));
+ let match_children = self.block_children(&branch.body)?;
+ let match_ = self.ast.expression_jsx_element(
+ branch_span,
+ "Match",
+ match_attributes,
+ match_children,
+ );
+ children.push(self.jsx_child(branch_span, match_));
+ }
+ Ok(self
+ .ast
+ .expression_jsx_element(span, "Switch", attributes, children))
+ }
+
+ fn block_expression(
+ &mut self,
+ block: &TemplateBlock<'_>,
+ ) -> Result>, CompileError> {
+ let render = self.block_render_expression(block)?;
+ if block.setup.is_empty() {
+ return Ok(render);
+ }
+ let Some(render) = render else {
+ return Ok(None);
+ };
+ let span = Span::default();
+ let mut statements = self.ast.vec_with_capacity(block.setup.len() + 1);
+ for setup in &block.setup {
+ statements.push(self.statement(*setup)?);
+ }
+ statements.push(self.ast.statement_return(span, Some(render)));
+ let parameters = self.ast.formal_parameters(
+ span,
+ FormalParameterKind::ArrowFormalParameters,
+ self.ast.vec(),
+ None,
+ );
+ let body = self.ast.function_body(span, self.ast.vec(), statements);
+ let arrow = self
+ .ast
+ .expression_arrow_function(span, false, false, None, parameters, None, body);
+ Ok(Some(self.ast.expression_call(
+ span,
+ arrow,
+ None,
+ self.ast.vec(),
+ false,
+ )))
+ }
+
+ fn block_render_expression(
+ &mut self,
+ block: &TemplateBlock<'_>,
+ ) -> Result >, CompileError> {
+ let render = match block.renders.as_slice() {
+ [] => Ok(None),
+ [only] => self.render_expression(*only).map(Some),
+ many => {
+ let span = Span::default();
+ let mut children = self.ast.vec_with_capacity(many.len());
+ for render in many {
+ let expression = self.render_expression(*render)?;
+ children.push(self.jsx_child(Span::default(), expression));
+ }
+ Ok(Some(self.ast.expression_jsx_fragment(span, children)))
+ }
+ }?;
+ Ok(render)
+ }
+
+ fn block_children(
+ &mut self,
+ block: &TemplateBlock<'_>,
+ ) -> Result>, CompileError> {
+ if !block.setup.is_empty() {
+ let expression = self.block_expression(block)?.ok_or_else(|| {
+ CompileError::transform(
+ "A TSRX control-flow block with setup statements must end with rendered output",
+ )
+ })?;
+ return Ok(self.ast.vec1(self.jsx_child(Span::default(), expression)));
+ }
+ let mut children = self.ast.vec_with_capacity(block.renders.len());
+ for render in &block.renders {
+ let expression = self.render_expression(*render)?;
+ children.push(self.jsx_child(Span::default(), expression));
+ }
+ Ok(children)
+ }
+
+ fn jsx_child(&self, span: Span, expression: Expression<'a>) -> JSXChild<'a> {
+ match expression {
+ Expression::JSXElement(element) => JSXChild::Element(element),
+ Expression::JSXFragment(fragment) => JSXChild::Fragment(fragment),
+ expression => self.ast.jsx_child_expression(span, expression),
+ }
+ }
+
+ fn entry(&mut self, node: Node<'_>) -> Result, CompileError> {
+ if let Some(control) = self.semantic.control_for(node) {
+ return self.control(control);
+ }
+ self.expression(node)
+ }
+
+ fn render_expression(&mut self, node: Node<'_>) -> Result, CompileError> {
+ if node.ty() == "JSXText" {
+ let value = node
+ .str_field("value")
+ .and_then(decode_json_string)
+ .ok_or_else(|| CompileError::transform("A TSRX JSX text node is invalid"))?;
+ return Ok(self.ast.expression_string_literal(
+ ast_span_of(node),
+ self.ast.str(&value),
+ None,
+ ));
+ }
+ self.entry(node)
+ }
+
+ fn expression(&self, node: Node<'_>) -> Result, CompileError> {
+ let authored = node
+ .span()
+ .map(|(start, end)| AuthoredSpan { start, end })
+ .ok_or_else(|| CompileError::transform("TSRX leaf is missing its authored span"))?;
+ self.leaves
+ .expression(self.allocator, authored)
+ .ok_or_else(|| {
+ CompileError::transform(format!(
+ "Unable to load authored TSRX expression at {}..{}",
+ authored.start, authored.end
+ ))
+ })
+ }
+
+ fn binding_pattern(
+ &self,
+ node: Node<'_>,
+ ) -> Result, CompileError> {
+ let authored = node
+ .span()
+ .map(|(start, end)| AuthoredSpan { start, end })
+ .ok_or_else(|| CompileError::transform("TSRX binding is missing its authored span"))?;
+ if let Some(name) = identifier_name(node) {
+ return Ok(self
+ .ast
+ .binding_pattern_binding_identifier(ast_span(authored), self.ast.ident(name)));
+ }
+ self.leaves
+ .binding_pattern(self.allocator, authored)
+ .ok_or_else(|| {
+ CompileError::transform(format!(
+ "Unable to load authored TSRX binding at {}..{}",
+ authored.start, authored.end
+ ))
+ })
+ }
+
+ fn statement(&self, node: Node<'_>) -> Result, CompileError> {
+ let authored = node
+ .span()
+ .map(|(start, end)| AuthoredSpan { start, end })
+ .ok_or_else(|| {
+ CompileError::transform("TSRX statement is missing its authored span")
+ })?;
+ self.leaves
+ .statement(self.allocator, authored)
+ .ok_or_else(|| {
+ CompileError::transform(format!(
+ "Unable to load authored TSRX statement at {}..{}",
+ authored.start, authored.end
+ ))
+ })
+ }
+
+ fn arrow_from_patterns(
+ &self,
+ span: Span,
+ patterns: &[Node<'_>],
+ expression: Expression<'a>,
+ ) -> Result, CompileError> {
+ let mut bindings = Vec::with_capacity(patterns.len());
+ for pattern in patterns {
+ bindings.push(self.binding_pattern(*pattern)?);
+ }
+ Ok(self.arrow(span, bindings, expression))
+ }
+
+ fn arrow(
+ &self,
+ span: Span,
+ patterns: Vec>,
+ expression: Expression<'a>,
+ ) -> Expression<'a> {
+ let parameters = self.ast.formal_parameters(
+ span,
+ FormalParameterKind::ArrowFormalParameters,
+ self.ast.vec_from_iter(patterns.into_iter().map(|pattern| {
+ self.ast.formal_parameter(
+ span,
+ self.ast.vec(),
+ pattern,
+ None,
+ None,
+ false,
+ None,
+ false,
+ false,
+ )
+ })),
+ None,
+ );
+ let body = self.ast.function_body(
+ span,
+ self.ast.vec(),
+ self.ast
+ .vec1(self.ast.statement_expression(span, expression)),
+ );
+ self.ast
+ .expression_arrow_function(span, true, false, None, parameters, None, body)
+ }
+
+ fn arrow_with_block(
+ &mut self,
+ span: Span,
+ patterns: Vec>,
+ block: &TemplateBlock<'_>,
+ ) -> Result, CompileError> {
+ let render = self.block_render_expression(block)?.ok_or_else(|| {
+ CompileError::transform("A TSRX callback block must end with rendered output")
+ })?;
+ if block.setup.is_empty() {
+ return Ok(self.arrow(span, patterns, render));
+ }
+ let parameters = self.ast.formal_parameters(
+ span,
+ FormalParameterKind::ArrowFormalParameters,
+ self.ast.vec_from_iter(patterns.into_iter().map(|pattern| {
+ self.ast.formal_parameter(
+ span,
+ self.ast.vec(),
+ pattern,
+ None,
+ None,
+ false,
+ None,
+ false,
+ false,
+ )
+ })),
+ None,
+ );
+ let mut statements = self.ast.vec_with_capacity(block.setup.len() + 1);
+ for setup in &block.setup {
+ statements.push(self.statement(*setup)?);
+ }
+ statements.push(self.ast.statement_return(span, Some(render)));
+ let body = self.ast.function_body(span, self.ast.vec(), statements);
+ Ok(self
+ .ast
+ .expression_arrow_function(span, false, false, None, parameters, None, body))
+ }
+}
+
+fn control_anchor(control: &ControlFlow<'_>) -> Option {
+ let node = match control {
+ ControlFlow::If(chain) => chain.branches.first()?.test,
+ ControlFlow::For(loop_) => loop_.iterable,
+ ControlFlow::Switch(switch) => switch.discriminant,
+ ControlFlow::Try(try_) => *try_
+ .body
+ .renders
+ .iter()
+ .find(|render| render.ty() != "JSXText")?,
+ _ => return None,
+ };
+ node.span().map(|(start, _)| start)
+}
+
+trait TemplateBlockSpan {
+ fn node_span(&self) -> AuthoredSpan;
+}
+
+impl TemplateBlockSpan for TemplateBlock<'_> {
+ fn node_span(&self) -> AuthoredSpan {
+ let (start, end) = self.node.span().unwrap_or_default();
+ AuthoredSpan { start, end }
+ }
+}
+
+fn ast_span(span: AuthoredSpan) -> Span {
+ Span::new(span.start, span.end)
+}
+
+fn generated_span(span: AuthoredSpan) -> Span {
+ Span::new(span.start, span.start)
+}
+
+fn ast_span_of(node: Node<'_>) -> Span {
+ node.span()
+ .map_or(Span::default(), |(start, end)| Span::new(start, end))
+}
+
+fn identifier_name(node: Node<'_>) -> Option<&str> {
+ (node.ty() == "Identifier")
+ .then(|| node.str_field("name"))
+ .flatten()
+}
+
+fn build_style_owner_setups<'a>(
+ allocator: &'a Allocator,
+ ast: AstBuilder<'a>,
+ leaves: &LeafProgram<'a>,
+ styles: &StyleProjection<'_>,
+) -> Option>>> {
+ let mut owners = HashMap::new();
+ for (owner, setups) in &styles.owner_setups {
+ let mut statements = Vec::new();
+ for setup in setups {
+ statements.extend(build_ref_setup(allocator, ast, leaves, setup)?);
+ }
+ owners.insert(*owner, statements);
+ }
+ Some(owners)
+}
+
+fn build_ref_setup<'a>(
+ allocator: &'a Allocator,
+ ast: AstBuilder<'a>,
+ leaves: &LeafProgram<'a>,
+ setup: &RefSetup<'_>,
+) -> Option>> {
+ let (start, end) = setup.target.span()?;
+ let target = leaves.expression(allocator, AuthoredSpan { start, end })?;
+ let span = Span::default();
+ if is_direct_ref_target(setup.target) {
+ let target = match target {
+ Expression::Identifier(target) => AssignmentTarget::AssignmentTargetIdentifier(target),
+ Expression::ComputedMemberExpression(target) => {
+ AssignmentTarget::ComputedMemberExpression(target)
+ }
+ Expression::StaticMemberExpression(target) => {
+ AssignmentTarget::StaticMemberExpression(target)
+ }
+ Expression::PrivateFieldExpression(target) => {
+ AssignmentTarget::PrivateFieldExpression(target)
+ }
+ _ => return None,
+ };
+ let value = style_class_map(ast, &setup.class_map);
+ return Some(vec![ast.statement_expression(
+ span,
+ ast.expression_assignment(span, AssignmentOperator::Assign, target, value),
+ )]);
+ }
+ if is_callback_ref(setup.target) {
+ let value = style_class_map(ast, &setup.class_map);
+ return Some(vec![ast.statement_expression(
+ span,
+ ast.expression_call(span, target, None, ast.vec1(Argument::from(value)), false),
+ )]);
+ }
+
+ let temp = setup.temp_name.as_deref()?;
+ let declaration = Statement::VariableDeclaration(ast.alloc_variable_declaration(
+ span,
+ VariableDeclarationKind::Let,
+ ast.vec1(ast.variable_declarator(
+ span,
+ VariableDeclarationKind::Let,
+ ast.binding_pattern_binding_identifier(span, ast.str(temp)),
+ None,
+ Some(target),
+ false,
+ )),
+ false,
+ ));
+ let function_test = ast.expression_binary(
+ span,
+ ast.expression_unary(
+ span,
+ UnaryOperator::Typeof,
+ ast.expression_identifier(span, ast.str(temp)),
+ ),
+ BinaryOperator::StrictEquality,
+ ast.expression_string_literal(span, "function", None),
+ );
+ let call = ast.statement_expression(
+ span,
+ ast.expression_call(
+ span,
+ ast.expression_identifier(span, ast.str(temp)),
+ None,
+ ast.vec1(Argument::from(style_class_map(ast, &setup.class_map))),
+ false,
+ ),
+ );
+ let object_test = ast.expression_logical(
+ span,
+ ast.expression_identifier(span, ast.str(temp)),
+ LogicalOperator::And,
+ ast.expression_binary(
+ span,
+ ast.expression_unary(
+ span,
+ UnaryOperator::Typeof,
+ ast.expression_identifier(span, ast.str(temp)),
+ ),
+ BinaryOperator::StrictEquality,
+ ast.expression_string_literal(span, "object", None),
+ ),
+ );
+ let current_test = ast.expression_binary(
+ span,
+ ast.expression_string_literal(span, "current", None),
+ BinaryOperator::In,
+ ast.expression_identifier(span, ast.str(temp)),
+ );
+ let current_assignment = style_ref_member_assignment(ast, span, temp, "current", setup);
+ let value_test = ast.expression_binary(
+ span,
+ ast.expression_string_literal(span, "value", None),
+ BinaryOperator::In,
+ ast.expression_identifier(span, ast.str(temp)),
+ );
+ let value_assignment = style_ref_member_assignment(ast, span, temp, "value", setup);
+ let object_body = ast.statement_block(
+ span,
+ ast.vec1(ast.statement_if(
+ span,
+ current_test,
+ current_assignment,
+ Some(ast.statement_if(span, value_test, value_assignment, None)),
+ )),
+ );
+ Some(vec![
+ declaration,
+ ast.statement_if(
+ span,
+ function_test,
+ ast.statement_block(span, ast.vec1(call)),
+ Some(ast.statement_if(span, object_test, object_body, None)),
+ ),
+ ])
+}
+
+fn style_ref_member_assignment<'a>(
+ ast: AstBuilder<'a>,
+ span: Span,
+ temp: &str,
+ property: &str,
+ setup: &RefSetup<'_>,
+) -> Statement<'a> {
+ let target = AssignmentTarget::StaticMemberExpression(ast.alloc_static_member_expression(
+ span,
+ ast.expression_identifier(span, ast.str(temp)),
+ ast.identifier_name(span, ast.str(property)),
+ false,
+ ));
+ ast.statement_expression(
+ span,
+ ast.expression_assignment(
+ span,
+ AssignmentOperator::Assign,
+ target,
+ style_class_map(ast, &setup.class_map),
+ ),
+ )
+}
+
+fn style_class_map<'a>(ast: AstBuilder<'a>, entries: &[ClassMapEntry]) -> Expression<'a> {
+ let properties = ast.vec_from_iter(entries.iter().map(|entry| {
+ let span = Span::default();
+ ast.object_property_kind_object_property(
+ span,
+ PropertyKind::Init,
+ PropertyKey::StringLiteral(ast.alloc_string_literal(
+ span,
+ ast.str(&entry.class_name),
+ None,
+ )),
+ ast.expression_string_literal(span, ast.str(&entry.value), None),
+ false,
+ false,
+ false,
+ )
+ }));
+ ast.expression_object(Span::default(), properties)
+}
+
+struct StyleOwnerLowerer<'a> {
+ ast: AstBuilder<'a>,
+ setups: HashMap>>,
+ function_owners: HashMap,
+ observed: Vec,
+}
+
+impl<'a> StyleOwnerLowerer<'a> {
+ fn new(
+ ast: AstBuilder<'a>,
+ setups: HashMap>>,
+ function_owners: HashMap,
+ ) -> Self {
+ Self {
+ ast,
+ setups,
+ function_owners,
+ observed: Vec::new(),
+ }
+ }
+
+ fn complete(&self) -> bool {
+ self.setups.is_empty()
+ }
+
+ fn take(&mut self, start: u32) -> Option>> {
+ if let Some(setups) = self.setups.remove(&start) {
+ return Some(setups);
+ }
+ let owner = self
+ .setups
+ .keys()
+ .copied()
+ .filter(|owner| owner.abs_diff(start) <= 2)
+ .min_by_key(|owner| owner.abs_diff(start))?;
+ self.setups.remove(&owner)
+ }
+
+ fn wrap(
+ &self,
+ span: Span,
+ expression: Expression<'a>,
+ setups: Vec>,
+ ) -> Expression<'a> {
+ let mut statements = self.ast.vec_from_iter(setups);
+ statements.push(self.ast.statement_return(span, Some(expression)));
+ let parameters = self.ast.formal_parameters(
+ span,
+ FormalParameterKind::ArrowFormalParameters,
+ self.ast.vec(),
+ None,
+ );
+ let body = self.ast.function_body(span, self.ast.vec(), statements);
+ let arrow = self
+ .ast
+ .expression_arrow_function(span, false, false, None, parameters, None, body);
+ self.ast
+ .expression_call(span, arrow, None, self.ast.vec(), false)
+ }
+}
+
+impl<'a> VisitMut<'a> for StyleOwnerLowerer<'a> {
+ fn visit_expression(&mut self, expression: &mut Expression<'a>) {
+ let start = match expression {
+ Expression::JSXElement(element) => Some(jsx_element_start(element)),
+ Expression::JSXFragment(fragment) => Some(jsx_fragment_start(fragment)),
+ _ => None,
+ };
+ self.observed.extend(start);
+ if let Some(setups) = start.and_then(|start| self.take(start)) {
+ let span = expression.span();
+ let owned = std::mem::replace(
+ expression,
+ self.ast.expression_null_literal(Span::default()),
+ );
+ *expression = self.wrap(span, owned, setups);
+ }
+ walk_mut::walk_expression(self, expression);
+ }
+
+ fn visit_jsx_children(&mut self, children: &mut oxc_allocator::Vec<'a, JSXChild<'a>>) {
+ let old = std::mem::replace(children, self.ast.vec());
+ for child in old {
+ let start = match &child {
+ JSXChild::Element(element) => Some(jsx_element_start(element)),
+ JSXChild::Fragment(fragment) => Some(jsx_fragment_start(fragment)),
+ _ => None,
+ };
+ self.observed.extend(start);
+ if let Some(setups) = start.and_then(|start| self.take(start)) {
+ let span = child.span();
+ let expression = match child {
+ JSXChild::Element(element) => Expression::JSXElement(element),
+ JSXChild::Fragment(fragment) => Expression::JSXFragment(fragment),
+ _ => unreachable!("only JSX owners are wrapped"),
+ };
+ children.push(
+ self.ast
+ .jsx_child_expression(span, self.wrap(span, expression, setups)),
+ );
+ } else {
+ children.push(child);
+ }
+ }
+ walk_mut::walk_jsx_children(self, children);
+ }
+
+ fn visit_function_body(&mut self, body: &mut FunctionBody<'a>) {
+ self.observed.push(body.span.start);
+ let direct = self.take(body.span.start);
+ let function_owner = body
+ .statements
+ .last()
+ .and_then(|statement| match statement {
+ Statement::ReturnStatement(statement) => statement.argument.as_ref(),
+ _ => None,
+ })
+ .map(|expression| match expression {
+ Expression::JSXElement(element) => jsx_element_start(element),
+ Expression::JSXFragment(fragment) => jsx_fragment_start(fragment),
+ expression => first_authored_start(expression).unwrap_or(expression.span().start),
+ })
+ .and_then(|render| self.function_owners.get(&render).copied())
+ .and_then(|owner| self.setups.remove(&owner));
+ if let Some(setups) = direct.or(function_owner) {
+ let insert_at = body.statements.len().saturating_sub(1);
+ for (offset, setup) in setups.into_iter().enumerate() {
+ body.statements.insert(insert_at + offset, setup);
+ }
+ }
+ walk_mut::walk_function_body(self, body);
+ }
+}
+
+struct LazyPatternRootAligner {
+ expected: HashSet,
+}
+
+impl LazyPatternRootAligner {
+ fn align(&mut self, pattern: &mut oxc_ast::ast::BindingPattern<'_>, owner: Span) {
+ if !matches!(
+ pattern,
+ oxc_ast::ast::BindingPattern::ObjectPattern(_)
+ | oxc_ast::ast::BindingPattern::ArrayPattern(_)
+ ) {
+ return;
+ }
+ let pattern_start = if pattern.span() == Span::default() {
+ let mut finder = AuthoredStartFinder { start: None };
+ finder.visit_binding_pattern(pattern);
+ finder.start.unwrap_or_default()
+ } else {
+ pattern.span().start
+ };
+ let expected = self.expected.iter().copied().find(|expected| {
+ (owner.start <= *expected && *expected <= owner.end)
+ || expected.abs_diff(pattern_start) <= 2
+ });
+ if let Some(expected) = expected {
+ self.expected.remove(&expected);
+ pattern.span_mut().start = expected;
+ }
+ }
+}
+
+impl<'a> VisitMut<'a> for LazyPatternRootAligner {
+ fn visit_function(
+ &mut self,
+ function: &mut oxc_ast::ast::Function<'a>,
+ flags: oxc_syntax::scope::ScopeFlags,
+ ) {
+ for parameter in &mut function.params.items {
+ self.align(&mut parameter.pattern, parameter.span);
+ }
+ walk_mut::walk_function(self, function, flags);
+ }
+
+ fn visit_arrow_function_expression(
+ &mut self,
+ arrow: &mut oxc_ast::ast::ArrowFunctionExpression<'a>,
+ ) {
+ for parameter in &mut arrow.params.items {
+ self.align(&mut parameter.pattern, parameter.span);
+ }
+ walk_mut::walk_arrow_function_expression(self, arrow);
+ }
+
+ fn visit_formal_parameter(&mut self, parameter: &mut oxc_ast::ast::FormalParameter<'a>) {
+ self.align(&mut parameter.pattern, parameter.span);
+ walk_mut::walk_formal_parameter(self, parameter);
+ }
+
+ fn visit_variable_declarator(&mut self, declarator: &mut oxc_ast::ast::VariableDeclarator<'a>) {
+ self.align(&mut declarator.id, declarator.span);
+ walk_mut::walk_variable_declarator(self, declarator);
+ }
+
+ fn visit_catch_parameter(&mut self, parameter: &mut oxc_ast::ast::CatchParameter<'a>) {
+ self.align(&mut parameter.pattern, parameter.span);
+ walk_mut::walk_catch_parameter(self, parameter);
+ }
+}
+
+struct AuthoredJsxTextSanitizer<'s> {
+ source: &'s str,
+}
+
+struct GeneratedSubtreeUnspanner;
+
+impl<'a> VisitMut<'a> for GeneratedSubtreeUnspanner {
+ fn visit_span(&mut self, span: &mut Span) {
+ *span = Span::default();
+ walk_mut::walk_span(self, span);
+ }
+}
+
+impl<'a> VisitMut<'a> for AuthoredJsxTextSanitizer<'_> {
+ fn visit_jsx_text(&mut self, text: &mut oxc_ast::ast::JSXText<'a>) {
+ let authored = self
+ .source
+ .get(text.span.start as usize..text.span.end as usize);
+ let parsed = text.raw.unwrap_or(text.value);
+ if authored != Some(parsed.as_str()) {
+ if let Some((start, _)) = self
+ .source
+ .match_indices(parsed.as_str())
+ .min_by_key(|(start, _)| (*start as u32).abs_diff(text.span.start))
+ {
+ text.span = Span::new(start as u32, (start + parsed.len()) as u32);
+ } else {
+ text.span = Span::default();
+ text.raw = None;
+ }
+ }
+ }
+}
+
+struct StyleScaffoldLowerer<'a, 'm> {
+ ast: AstBuilder<'a>,
+ map: &'m super::leaf::LeafMap,
+ actions: HashMap,
+ hashes: HashMap>,
+}
+
+impl<'a, 'm> StyleScaffoldLowerer<'a, 'm> {
+ fn new(
+ ast: AstBuilder<'a>,
+ map: &'m super::leaf::LeafMap,
+ styles: &StyleProjection<'_>,
+ ) -> Self {
+ Self {
+ ast,
+ map,
+ actions: styles
+ .actions
+ .iter()
+ .map(|(start, action)| (*start, action.clone()))
+ .collect(),
+ hashes: styles
+ .element_hashes
+ .iter()
+ .map(|(start, hashes)| (*start, hashes.clone()))
+ .collect(),
+ }
+ }
+
+ fn complete(&self) -> bool {
+ self.actions.is_empty() && self.hashes.is_empty()
+ }
+
+ fn action_for(&mut self, element: &JSXElement<'_>) -> Option {
+ self.map
+ .authored_start(element.span)
+ .and_then(|start| self.actions.remove(&start))
+ }
+
+ fn empty_style(element: &mut JSXElement<'a>) {
+ element.children.clear();
+ element.closing_element = None;
+ }
+
+ fn class_map(&self, entries: &[ClassMapEntry]) -> Expression<'a> {
+ let properties = self.ast.vec_from_iter(entries.iter().map(|entry| {
+ let span = Span::default();
+ self.ast.object_property_kind_object_property(
+ span,
+ PropertyKind::Init,
+ PropertyKey::StringLiteral(self.ast.alloc_string_literal(
+ span,
+ self.ast.str(&entry.class_name),
+ None,
+ )),
+ self.ast
+ .expression_string_literal(span, self.ast.str(&entry.value), None),
+ false,
+ false,
+ false,
+ )
+ }));
+ self.ast.expression_object(Span::default(), properties)
+ }
+
+ fn inject_hash(&self, element: &mut JSXElement<'a>, hash: &str) {
+ let class = element
+ .opening_element
+ .attributes
+ .iter_mut()
+ .find_map(|attribute| match attribute {
+ JSXAttributeItem::Attribute(attribute)
+ if matches!(
+ jsx_attribute_name(&attribute.name),
+ Some("class" | "className")
+ ) =>
+ {
+ Some(attribute)
+ }
+ _ => None,
+ });
+ let Some(class) = class else {
+ element
+ .opening_element
+ .attributes
+ .push(self.ast.jsx_attribute_item_string(
+ Span::default(),
+ "class",
+ self.ast.str(hash),
+ ));
+ return;
+ };
+ match class.value.as_mut() {
+ None => {
+ class.value = Some(JSXAttributeValue::StringLiteral(
+ self.ast
+ .alloc_string_literal(Span::default(), self.ast.str(hash), None),
+ ));
+ }
+ Some(JSXAttributeValue::StringLiteral(value)) => {
+ value.value = self.ast.str(&format!("{} {hash}", value.value));
+ value.raw = None;
+ }
+ Some(JSXAttributeValue::ExpressionContainer(container)) => {
+ let Some(expression) = container.expression.as_expression_mut() else {
+ return;
+ };
+ let expression = std::mem::replace(
+ expression,
+ self.ast.expression_null_literal(Span::default()),
+ );
+ let empty = self.ast.str("");
+ let suffix = self.ast.str(&format!(" {hash}"));
+ let quasis = self.ast.vec_from_array([
+ self.ast.template_element_with_lone_surrogates(
+ Span::default(),
+ TemplateElementValue {
+ raw: empty,
+ cooked: Some(empty),
+ },
+ false,
+ false,
+ ),
+ self.ast.template_element_with_lone_surrogates(
+ Span::default(),
+ TemplateElementValue {
+ raw: suffix,
+ cooked: Some(suffix),
+ },
+ true,
+ false,
+ ),
+ ]);
+ container.expression = self
+ .ast
+ .expression_template_literal(Span::default(), quasis, self.ast.vec1(expression))
+ .into();
+ }
+ _ => {}
+ }
+ }
+}
+
+impl<'a> VisitMut<'a> for StyleScaffoldLowerer<'a, '_> {
+ fn visit_expression(&mut self, expression: &mut Expression<'a>) {
+ let action = match expression {
+ Expression::JSXElement(element) => self.action_for(element),
+ _ => None,
+ };
+ match action {
+ Some(StyleAction::Remove) => {
+ *expression = self.ast.expression_null_literal(Span::default());
+ return;
+ }
+ Some(StyleAction::ClassMap(entries)) => {
+ *expression = self.class_map(&entries);
+ return;
+ }
+ Some(StyleAction::EmptyElement) => {
+ let Expression::JSXElement(element) = expression else {
+ unreachable!("style action was selected from a JSX element");
+ };
+ Self::empty_style(element);
+ }
+ None => {}
+ }
+ walk_mut::walk_expression(self, expression);
+ }
+
+ fn visit_jsx_children(&mut self, children: &mut oxc_allocator::Vec<'a, JSXChild<'a>>) {
+ let old = std::mem::replace(children, self.ast.vec());
+ for mut child in old {
+ let action = match &child {
+ JSXChild::Element(element) => self.action_for(element),
+ _ => None,
+ };
+ match action {
+ Some(StyleAction::Remove) => continue,
+ Some(StyleAction::ClassMap(entries)) => {
+ let expression = self.class_map(&entries);
+ children.push(self.ast.jsx_child_expression(Span::default(), expression));
+ }
+ Some(StyleAction::EmptyElement) => {
+ let JSXChild::Element(element) = &mut child else {
+ unreachable!("style action was selected from a JSX element");
+ };
+ Self::empty_style(element);
+ children.push(child);
+ }
+ None => children.push(child),
+ }
+ }
+ walk_mut::walk_jsx_children(self, children);
+ }
+
+ fn visit_jsx_element(&mut self, element: &mut JSXElement<'a>) {
+ if let Some(start) = self.map.authored_start(element.span)
+ && let Some(hashes) = self.hashes.remove(&start)
+ {
+ self.inject_hash(element, &hashes.join(" "));
+ }
+ walk_mut::walk_jsx_element(self, element);
+ }
+}
+
+struct LazyAssignmentScaffoldNormalizer<'m> {
+ map: &'m super::leaf::LeafMap,
+ patterns: HashSet,
+}
+
+impl<'a> VisitMut<'a> for LazyAssignmentScaffoldNormalizer<'_> {
+ fn visit_statement(&mut self, statement: &mut Statement<'a>) {
+ if let Statement::VariableDeclaration(declaration) = statement
+ && declaration.declarations.len() == 1
+ {
+ let span = declaration.declarations[0].id.span();
+ if let Some(authored) = self.map.authored_extent(span)
+ && self.patterns.remove(&authored)
+ {
+ declaration.kind = VariableDeclarationKind::Const;
+ }
+ }
+ walk_mut::walk_statement(self, statement);
+ }
+}
+
+struct TemplateScaffoldReplacer<'a, 'm, 's> {
+ ast: AstBuilder<'a>,
+ map: &'m super::leaf::LeafMap,
+ marker_prefix: &'m str,
+ shorthand_names: Vec<&'s str>,
+ raw_scripts: HashMap,
+ dynamic_spans: Vec,
+ seen_dynamic: HashSet,
+ seen_shorthand: HashSet,
+ seen_raw: HashSet,
+ source: &'s str,
+}
+
+impl<'a, 'm, 's> TemplateScaffoldReplacer<'a, 'm, 's> {
+ fn new(
+ ast: AstBuilder<'a>,
+ map: &'m super::leaf::LeafMap,
+ marker_prefix: &'m str,
+ semantic: &'s SolidTsrxModule<'s>,
+ source: &'s str,
+ ) -> Self {
+ let mut shorthand_names = Vec::new();
+ let mut raw_scripts = HashMap::new();
+ let mut dynamic_spans = Vec::new();
+ for site in &semantic.template_sites {
+ match site {
+ TemplateSite::DynamicElement { origin, .. } => dynamic_spans.push(origin.span),
+ TemplateSite::ShorthandAttribute { name, .. } => shorthand_names.push(*name),
+ TemplateSite::RawTextScript(script) => {
+ raw_scripts.insert(script.origin.span, script.payload);
+ }
+ TemplateSite::StyleElement { .. } => {}
+ }
+ }
+ Self {
+ ast,
+ map,
+ marker_prefix,
+ shorthand_names,
+ raw_scripts,
+ dynamic_spans,
+ seen_dynamic: HashSet::new(),
+ seen_shorthand: HashSet::new(),
+ seen_raw: HashSet::new(),
+ source,
+ }
+ }
+
+ fn complete(&self) -> bool {
+ self.seen_dynamic.len() == self.dynamic_spans.len()
+ && self.seen_shorthand.len() == self.shorthand_names.len()
+ && self.seen_raw.len() == self.raw_scripts.len()
+ }
+}
+
+impl<'a> VisitMut<'a> for TemplateScaffoldReplacer<'a, '_, '_> {
+ fn visit_jsx_element(&mut self, element: &mut JSXElement<'a>) {
+ let authored = self.map.authored_extent(element.span);
+ if let Some(payload) = authored.and_then(|span| self.raw_scripts.get(&span).copied()) {
+ let content = &self.source[payload.start as usize..payload.end as usize];
+ for child in &mut element.children {
+ if let JSXChild::ExpressionContainer(container) = child {
+ container.expression = self
+ .ast
+ .expression_string_literal(Span::default(), self.ast.str(content), None)
+ .into();
+ self.seen_raw.insert(
+ authored.expect("raw script was selected from an authored element span"),
+ );
+ break;
+ }
+ }
+ }
+
+ for attribute in &mut element.opening_element.attributes {
+ let JSXAttributeItem::Attribute(attribute) = attribute else {
+ continue;
+ };
+ let Some(name) = jsx_attribute_name(&attribute.name) else {
+ continue;
+ };
+ let Some(index) = marker_index(name, self.marker_prefix, "S", true) else {
+ continue;
+ };
+ let Some(authored_name) = self.shorthand_names.get(index) else {
+ continue;
+ };
+ attribute.name = JSXAttributeName::Identifier(
+ self.ast.alloc(
+ self.ast
+ .jsx_identifier(Span::default(), self.ast.str(authored_name)),
+ ),
+ );
+ self.seen_shorthand.insert(index);
+ }
+
+ let dynamic = jsx_element_name(&element.opening_element.name)
+ .and_then(|name| marker_index(name, self.marker_prefix, "D", false));
+ if let Some(index) = dynamic {
+ let dynamic_name = || {
+ JSXElementName::IdentifierReference(
+ self.ast
+ .alloc_identifier_reference(Span::default(), self.ast.ident("Dynamic")),
+ )
+ };
+ element.opening_element.name = dynamic_name();
+ if let Some(closing) = &mut element.closing_element {
+ closing.name = dynamic_name();
+ }
+ for attribute in &mut element.opening_element.attributes {
+ let JSXAttributeItem::Attribute(attribute) = attribute else {
+ continue;
+ };
+ let Some(name) = jsx_attribute_name(&attribute.name) else {
+ continue;
+ };
+ if marker_index(name, self.marker_prefix, "A", true) == Some(index) {
+ attribute.name = JSXAttributeName::Identifier(
+ self.ast.alloc(
+ self.ast
+ .jsx_identifier(Span::default(), self.ast.str("component")),
+ ),
+ );
+ }
+ }
+ element.opening_element.attributes.retain(|attribute| {
+ let JSXAttributeItem::Attribute(attribute) = attribute else {
+ return true;
+ };
+ jsx_attribute_name(&attribute.name)
+ .and_then(|name| marker_index(name, self.marker_prefix, "Z", true))
+ != Some(index)
+ });
+ element.children.retain(|child| {
+ let JSXChild::ExpressionContainer(container) = child else {
+ return true;
+ };
+ let Some(expression) = container.expression.as_expression() else {
+ return true;
+ };
+ let Expression::CallExpression(call) = expression else {
+ return true;
+ };
+ call.callee.get_identifier_reference().and_then(|callee| {
+ marker_index(callee.name.as_str(), self.marker_prefix, "C", true)
+ }) != Some(index)
+ });
+ self.seen_dynamic.insert(index);
+ }
+
+ walk_mut::walk_jsx_element(self, element);
+ }
+}
+
+fn marker_index(name: &str, prefix: &str, tag: &str, suffix: bool) -> Option {
+ let digits = name.strip_prefix(prefix)?.strip_prefix(tag)?;
+ let digits = if suffix {
+ digits.strip_suffix('_')?
+ } else {
+ digits
+ };
+ (!digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()))
+ .then(|| digits.parse().ok())
+ .flatten()
+}
+
+fn jsx_element_name<'a>(name: &'a JSXElementName<'_>) -> Option<&'a str> {
+ match name {
+ JSXElementName::Identifier(identifier) => Some(identifier.name.as_str()),
+ JSXElementName::IdentifierReference(identifier) => Some(identifier.name.as_str()),
+ _ => None,
+ }
+}
+
+fn jsx_attribute_name<'a>(name: &'a JSXAttributeName<'_>) -> Option<&'a str> {
+ match name {
+ JSXAttributeName::Identifier(identifier) => Some(identifier.name.as_str()),
+ _ => None,
+ }
+}
+
+struct ScaffoldReplacer<'a> {
+ ast: AstBuilder<'a>,
+ expression_replacements: HashMap>,
+ statement_replacements: HashMap>,
+ code_block_replacements: HashMap>,
+}
+
+impl<'a> ScaffoldReplacer<'a> {
+ fn take_expression_replacement(
+ &mut self,
+ expression: &Expression<'a>,
+ ) -> Option> {
+ code_block_anchor(expression)
+ .and_then(|anchor| self.code_block_replacements.remove(&anchor))
+ .or_else(|| match expression {
+ Expression::CallExpression(call) => try_scaffold_anchor(call)
+ .and_then(|anchor| remove_near(&mut self.statement_replacements, anchor))
+ .or_else(|| {
+ call.callee.get_identifier_reference().and_then(|callee| {
+ self.expression_replacements.remove(callee.name.as_str())
+ })
+ }),
+ _ => None,
+ })
+ }
+}
+
+impl<'a> VisitMut<'a> for ScaffoldReplacer<'a> {
+ fn visit_statement(&mut self, statement: &mut Statement<'a>) {
+ let anchor = match statement {
+ Statement::IfStatement(statement) => Some(statement.test.span()),
+ Statement::ForOfStatement(statement) => Some(statement.right.span()),
+ Statement::SwitchStatement(statement) => Some(statement.discriminant.span()),
+ Statement::TryStatement(statement) => {
+ statement
+ .block
+ .body
+ .last()
+ .and_then(|statement| match statement {
+ Statement::ExpressionStatement(statement) => {
+ Some(statement.expression.span())
+ }
+ _ => None,
+ })
+ }
+ _ => None,
+ }
+ .map(|span| span.start);
+ if let Some(replacement) =
+ anchor.and_then(|anchor| remove_near(&mut self.statement_replacements, anchor))
+ {
+ let span = replacement.span();
+ *statement = self.ast.statement_expression(span, replacement);
+ return;
+ }
+ walk_mut::walk_statement(self, statement);
+ }
+
+ fn visit_expression(&mut self, expression: &mut Expression<'a>) {
+ if let Some(replacement) = self.take_expression_replacement(expression) {
+ *expression = replacement;
+ walk_mut::walk_expression(self, expression);
+ return;
+ }
+ walk_mut::walk_expression(self, expression);
+ }
+
+ fn visit_jsx_children(&mut self, children: &mut oxc_allocator::Vec<'a, JSXChild<'a>>) {
+ for child in children.iter_mut() {
+ let span = child.span();
+ let replacement = match child {
+ JSXChild::ExpressionContainer(container) => container
+ .expression
+ .as_expression()
+ .and_then(|expression| self.take_expression_replacement(expression)),
+ _ => None,
+ };
+ if let Some(replacement) = replacement {
+ *child = match replacement {
+ Expression::JSXElement(element) => JSXChild::Element(element),
+ Expression::JSXFragment(fragment) => JSXChild::Fragment(fragment),
+ replacement => self.ast.jsx_child_expression(span, replacement),
+ };
+ }
+ }
+ walk_mut::walk_jsx_children(self, children);
+ }
+}
+
+fn remove_near(values: &mut HashMap, start: u32) -> Option {
+ if let Some(value) = values.remove(&start) {
+ return Some(value);
+ }
+ let key = values
+ .keys()
+ .copied()
+ .filter(|candidate| candidate.abs_diff(start) <= 2)
+ .min_by_key(|candidate| candidate.abs_diff(start))?;
+ values.remove(&key)
+}
+
+struct ParserScaffoldFinder<'s> {
+ prefix: &'s str,
+ found: bool,
+}
+
+impl<'a> Visit<'a> for ParserScaffoldFinder<'_> {
+ fn visit_identifier_reference(&mut self, identifier: &oxc_ast::ast::IdentifierReference<'a>) {
+ self.found |= identifier.name.as_str().starts_with(self.prefix);
+ }
+
+ fn visit_identifier_name(&mut self, identifier: &oxc_ast::ast::IdentifierName<'a>) {
+ self.found |= identifier.name.as_str().starts_with(self.prefix);
+ }
+
+ fn visit_jsx_identifier(&mut self, identifier: &oxc_ast::ast::JSXIdentifier<'a>) {
+ self.found |= identifier.name.as_str().starts_with(self.prefix);
+ }
+}
+
+fn try_scaffold_anchor(call: &oxc_ast::ast::CallExpression<'_>) -> Option {
+ let Expression::ObjectExpression(object) = call.arguments.first()?.as_expression()? else {
+ return None;
+ };
+ let body = object.properties.iter().find_map(|property| {
+ let oxc_ast::ast::ObjectPropertyKind::ObjectProperty(property) = property else {
+ return None;
+ };
+ let Expression::FunctionExpression(function) = &property.value else {
+ return None;
+ };
+ function.body.as_ref()
+ })?;
+ let Statement::ExpressionStatement(statement) = body.statements.last()? else {
+ return None;
+ };
+ Some(statement.expression.span().start)
+}
+
+fn code_block_anchor(expression: &Expression<'_>) -> Option {
+ let function = match expression {
+ Expression::UnaryExpression(unary) => match &unary.argument {
+ Expression::FunctionExpression(function) => function,
+ _ => return None,
+ },
+ Expression::FunctionExpression(function) => function,
+ _ => return None,
+ };
+ if !function.r#async || !function.generator {
+ return None;
+ }
+ let statement = function.body.as_ref()?.statements.last()?;
+ let Statement::ExpressionStatement(statement) = statement else {
+ return None;
+ };
+ let span = statement.expression.span();
+ Some(span.start)
+}
+
+struct DynamicElementAnchorer<'s> {
+ origins: &'s [AuthoredSpan],
+}
+
+impl<'a> VisitMut<'a> for DynamicElementAnchorer<'_> {
+ fn visit_jsx_element(&mut self, element: &mut JSXElement<'a>) {
+ if element.span == Span::default()
+ && jsx_element_name(&element.opening_element.name) == Some("Dynamic")
+ {
+ let component_start = element
+ .opening_element
+ .attributes
+ .iter()
+ .find_map(|attribute| {
+ let JSXAttributeItem::Attribute(attribute) = attribute else {
+ return None;
+ };
+ if jsx_attribute_name(&attribute.name) != Some("component") {
+ return None;
+ }
+ let Some(JSXAttributeValue::ExpressionContainer(container)) =
+ attribute.value.as_ref()
+ else {
+ return None;
+ };
+ container
+ .expression
+ .as_expression()
+ .and_then(first_authored_start)
+ });
+ if let Some(origin) = component_start.and_then(|start| {
+ self.origins
+ .iter()
+ .find(|origin| origin.start <= start && start <= origin.end)
+ }) {
+ element.span = generated_span(*origin);
+ }
+ }
+ walk_mut::walk_jsx_element(self, element);
+ }
+}
+
+struct FunctionCodeBlockFinalizer<'a, 'c> {
+ ast: AstBuilder<'a>,
+ code_blocks: &'c mut HashMap,
+}
+
+impl<'a> VisitMut<'a> for FunctionCodeBlockFinalizer<'a, '_> {
+ fn visit_function_body(&mut self, body: &mut FunctionBody<'a>) {
+ walk_mut::walk_function_body(self, body);
+ let Some(Statement::ExpressionStatement(statement)) = body.statements.last_mut() else {
+ return;
+ };
+ let start = match &statement.expression {
+ Expression::JSXElement(element) => {
+ let start = jsx_element_start(element);
+ (start != 0)
+ .then_some(start)
+ .or_else(|| first_authored_start(&statement.expression))
+ .unwrap_or_default()
+ }
+ Expression::JSXFragment(fragment) => {
+ let start = jsx_fragment_start(fragment);
+ (start != 0)
+ .then_some(start)
+ .or_else(|| first_authored_start(&statement.expression))
+ .unwrap_or_default()
+ }
+ expression => first_authored_start(expression).unwrap_or(expression.span().start),
+ };
+ let Some(span) = remove_near(self.code_blocks, start) else {
+ return;
+ };
+ let expression = std::mem::replace(
+ &mut statement.expression,
+ self.ast.expression_null_literal(Span::default()),
+ );
+ *body.statements.last_mut().expect("last statement exists") =
+ self.ast.statement_return(span, Some(expression));
+ }
+}
+
+fn jsx_element_start(element: &JSXElement<'_>) -> u32 {
+ if element.span != Span::default() {
+ element.span.start
+ } else {
+ element.opening_element.span.start
+ }
+}
+
+fn jsx_fragment_start(fragment: &oxc_ast::ast::JSXFragment<'_>) -> u32 {
+ if fragment.span != Span::default() {
+ fragment.span.start
+ } else {
+ fragment.opening_fragment.span.start
+ }
+}
+
+fn first_authored_start(expression: &Expression<'_>) -> Option {
+ let mut finder = AuthoredStartFinder { start: None };
+ finder.visit_expression(expression);
+ finder.start
+}
+
+struct AuthoredStartFinder {
+ start: Option,
+}
+
+impl<'a> Visit<'a> for AuthoredStartFinder {
+ fn visit_span(&mut self, span: &Span) {
+ if *span != Span::default() {
+ self.start = Some(self.start.map_or(span.start, |start| start.min(span.start)));
+ }
+ walk::walk_span(self, span);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use oxc_codegen::Codegen;
+
+ use super::*;
+
+ #[test]
+ fn directly_lowers_expression_if_without_parser_scaffolds() {
+ let source =
+ "export const View = ({ ready }) => @if (ready) { yes
} @else { no
};";
+ let tape = super::super::parse_tape(source, "view.tsrx").expect("TSRX tape");
+ let semantic = super::super::lower_semantic(source, &tape).expect("semantic IR");
+ let styles = super::super::style_projection::plan(source, "view.tsrx", &semantic)
+ .unwrap_or_else(|error| panic!("styles: {}", error.message));
+ let allocator = Allocator::default();
+ let lowered = lower(&allocator, source, &semantic, &styles).expect("direct lowering");
+ let code = Codegen::new().build(&lowered.program).code;
+ assert!(code.contains(" Result {
+ let (tape, recovered) = parse_tooling_tape(source, filename)?;
+ let semantic = if recovered {
+ lower_recovered_semantic(source, &tape)?
+ } else {
+ lower_semantic(source, &tape)?
+ };
+ project_semantic(source, filename, &semantic, source_maps)
+}
+
+pub(crate) fn run_compiler_frontend<'a>(
+ allocator: &'a oxc_allocator::Allocator,
+ source: &str,
+ filename: Option<&str>,
+) -> Result, CompileError> {
+ let filename = filename.unwrap_or("input.tsrx");
+ let tape = parse_tape(source, filename)?;
+ let semantic = lower_semantic(source, &tape)?;
+ let styles = style_projection::plan(source, filename, &semantic)
+ .map_err(|error| project_error(source, error))?;
+ lower::lower(allocator, source, &semantic, &styles)
+}
+
+fn parse_tape(source: &str, filename: &str) -> Result {
+ let options = TsrxParseOptions {
+ filename,
+ include_ts_fields: true,
+ ..TsrxParseOptions::default()
+ };
+
+ take_parse_tape(source, parse_source(source, options)?, false)
+}
+
+fn parse_tooling_tape(
+ source: &str,
+ filename: &str,
+) -> Result<(tsrx_tape_schema::FlatTape, bool), CompileError> {
+ let options = TsrxParseOptions {
+ filename,
+ include_ts_fields: true,
+ recovery: TsrxParseRecovery::Editor,
+ ..TsrxParseOptions::default()
+ };
+ let result = parse_source(source, options)?;
+ let recovered = result.status == ParseCompleteness::Recovered;
+ Ok((take_parse_tape(source, result, true)?, recovered))
+}
+
+fn take_parse_tape(
+ source: &str,
+ result: TsrxParseResult,
+ allow_recovered: bool,
+) -> Result {
+ if result.status != ParseCompleteness::Complete
+ && (!allow_recovered || result.status != ParseCompleteness::Recovered)
+ {
+ return Err(first_diagnostic_error(source, &result));
+ }
+ let mut tape = result
+ .program
+ .ok_or_else(|| CompileError::parse("TSRX parse returned no program"))?;
+
+ if result.coordinate_domain == CoordinateDomain::OriginalUtf16Units {
+ rebase_utf16_spans(source, &mut tape).map_err(CompileError::parse)?;
+ }
+ Ok(tape)
+}
+
+fn lower_semantic<'t>(
+ source: &str,
+ tape: &'t tsrx_tape_schema::FlatTape,
+) -> Result, CompileError> {
+ let root = tape::Node::root(tape)
+ .ok_or_else(|| CompileError::parse("TSRX parse produced no program"))?;
+ semantic::lower(root).map_err(|error| {
+ let (line, column) = line_column(source, error.start);
+ CompileError::parse(format!("{} ({line}:{column})", error.message))
+ })
+}
+
+fn lower_recovered_semantic<'t>(
+ source: &str,
+ tape: &'t tsrx_tape_schema::FlatTape,
+) -> Result, CompileError> {
+ let root = tape::Node::root(tape)
+ .ok_or_else(|| CompileError::parse("TSRX parse produced no program"))?;
+ semantic::lower_recovered(root).map_err(|error| {
+ let (line, column) = line_column(source, error.start);
+ CompileError::parse(format!("{} ({line}:{column})", error.message))
+ })
+}
+
+fn project_semantic(
+ source: &str,
+ filename: &str,
+ semantic: &semantic::SolidTsrxModule<'_>,
+ source_maps: bool,
+) -> Result {
+ project::project(source, filename, semantic, source_maps)
+ .map_err(|error| project_error(source, error))
+}
+
+fn project_error(source: &str, error: project::ProjectError) -> CompileError {
+ let (line, column) = line_column(source, error.start);
+ CompileError::parse(format!("{} ({line}:{column})", error.message))
+}
+
+/// Apply lazy/accessor rewrites to the explicit tooling projection.
+pub fn apply_rewrites<'a>(
+ allocator: &'a oxc_allocator::Allocator,
+ program: &mut oxc_ast::ast::Program<'a>,
+ projection: &Projection,
+ source_maps: bool,
+) -> Result<(), CompileError> {
+ rewrite::apply(allocator, program, projection, source_maps).map_err(CompileError::transform)
+}
+
+pub fn apply_direct_rewrites<'a>(
+ allocator: &'a oxc_allocator::Allocator,
+ program: &mut oxc_ast::ast::Program<'a>,
+ artifacts: &rewrite::RewriteArtifacts,
+ source_maps: bool,
+) -> Result<(), CompileError> {
+ rewrite::apply_artifacts(allocator, program, artifacts, source_maps)
+ .map_err(CompileError::transform)?;
+ rewrite::clear_generated_spans(program, artifacts, source_maps);
+ Ok(())
+}
+
+/// Parse compiler-projected TSX for the explicit tooling path.
+pub(crate) fn parse_projected_tsx<'a>(
+ allocator: &'a oxc_allocator::Allocator,
+ projection: &'a Projection,
+) -> Result, CompileError> {
+ let parsed = oxc_parser::Parser::new(allocator, &projection.text, oxc_span::SourceType::tsx())
+ .with_options(oxc_parser::ParseOptions {
+ preserve_parens: false,
+ ..oxc_parser::ParseOptions::default()
+ })
+ .parse();
+ if let Some(error) = crate::shared::parser::first_parser_error(parsed.diagnostics) {
+ return Err(CompileError::parse(error));
+ }
+ Ok(parsed.program)
+}
+
+/// Compose codegen's projected-TSX source map back to authored TSRX.
+pub fn compose_source_map(
+ intermediate: &oxc_sourcemap::SourceMap<'_>,
+ projection: &Projection,
+ authored_source: &str,
+ filename: &str,
+) -> String {
+ source_map::compose(
+ intermediate,
+ &projection.source_map,
+ &projection.text,
+ authored_source,
+ filename,
+ )
+}
+
+fn parse_source(
+ source: &str,
+ options: TsrxParseOptions<'_>,
+) -> Result {
+ if source.is_ascii() {
+ return parse_tsrx_with_options(&TsrxParseRequest { source }, options)
+ .map_err(|error| CompileError::parse(format!("TSRX parse failed: {error:?}")));
+ }
+ // The canonical route is ASCII-only; non-ASCII sources go through the
+ // exact-UTF-16 route and their spans are rebased to UTF-8 bytes below.
+ let units: Vec = source.encode_utf16().collect();
+ parse_tsrx_utf16_with_options(&TsrxUtf16ParseRequest { source: &units }, options)
+ .map_err(|error| CompileError::parse(format!("TSRX parse failed: {error:?}")))
+}
+
+/// Rewrite every `start`/`end` field in the tape from UTF-16 code units to
+/// UTF-8 byte offsets, so the projection can splice authored bytes directly.
+fn rebase_utf16_spans(source: &str, tape: &mut tsrx_tape_schema::FlatTape) -> Result<(), String> {
+ // Prefix table: UTF-16 unit index → UTF-8 byte offset.
+ let mut unit_to_byte: Vec = Vec::new();
+ for (byte_offset, ch) in source.char_indices() {
+ for _ in 0..ch.len_utf16() {
+ unit_to_byte.push(byte_offset as u32);
+ }
+ }
+ unit_to_byte.push(source.len() as u32);
+
+ let convert = |units: u32| -> Result {
+ unit_to_byte
+ .get(units as usize)
+ .copied()
+ .ok_or_else(|| format!("TSRX span offset {units} exceeds the source length"))
+ };
+
+ let object_count = tape.object_count() as u32;
+ for object in 0..object_count {
+ let object = RecordIndex::new(object);
+ for name in ["start", "end"] {
+ let Some(field) = tape.field_index(object, name) else {
+ continue;
+ };
+ let Some(value) = tape.field_value(field) else {
+ continue;
+ };
+ let Some(units) = tape.scalar_u32(value) else {
+ continue;
+ };
+ tape.set_field_value(field, ValueRef::inline_u32(convert(units)?))
+ .map_err(|error| error.to_string())?;
+ }
+ }
+ Ok(())
+}
+
+fn first_diagnostic_error(source: &str, result: &TsrxParseResult) -> CompileError {
+ let table = &result.errors;
+ let Some(record) = table.records().first() else {
+ return CompileError::parse("TSRX parse failed without diagnostics");
+ };
+ let message = table
+ .string(record.message)
+ .unwrap_or("TSRX parse failed")
+ .to_string();
+ let start = table
+ .labels(record.labels)
+ .and_then(|labels| {
+ labels
+ .iter()
+ .find(|label| label.primary)
+ .or_else(|| labels.first())
+ })
+ .map(|label| label.span.start);
+ match start {
+ Some(start) => {
+ // Failed parses report in the authored domain of the used route;
+ // for the UTF-16 route the offset is in code units, close enough
+ // for a line/column computed over chars.
+ let (line, column) = if result.coordinate_domain == CoordinateDomain::OriginalUtf16Units
+ {
+ line_column_utf16(source, start)
+ } else {
+ line_column(source, start)
+ };
+ CompileError::parse(format!("{message} ({line}:{column})"))
+ }
+ None => CompileError::parse(message),
+ }
+}
+
+/// 1-based line and 0-based column for a UTF-8 byte offset (ESTree `loc`
+/// convention, matching the Babel frontend's error suffix).
+fn line_column(source: &str, offset: u32) -> (u32, u32) {
+ let offset = (offset as usize).min(source.len());
+ let before = &source.as_bytes()[..offset];
+ let line = 1 + before.iter().filter(|byte| **byte == b'\n').count() as u32;
+ let line_start = before
+ .iter()
+ .rposition(|byte| *byte == b'\n')
+ .map(|position| position + 1)
+ .unwrap_or(0);
+ let column = source[line_start..offset].encode_utf16().count() as u32;
+ (line, column)
+}
+
+fn line_column_utf16(source: &str, offset_units: u32) -> (u32, u32) {
+ let mut units = 0u32;
+ for (byte_offset, ch) in source.char_indices() {
+ if units >= offset_units {
+ return line_column(source, byte_offset as u32);
+ }
+ units += ch.len_utf16() as u32;
+ }
+ line_column(source, source.len() as u32)
+}
diff --git a/packages/compiler/src/tsrx/names.rs b/packages/compiler/src/tsrx/names.rs
new file mode 100644
index 000000000..c6fe47835
--- /dev/null
+++ b/packages/compiler/src/tsrx/names.rs
@@ -0,0 +1,42 @@
+use std::collections::{HashMap, HashSet};
+
+use oxc_semantic::Semantic;
+
+#[derive(Default)]
+pub(super) struct Names {
+ used: HashSet,
+ next: HashMap<&'static str, u32>,
+}
+
+impl Names {
+ pub fn from_semantic(semantic: &Semantic<'_>) -> Self {
+ let mut names = Self::default();
+ for node in semantic.nodes() {
+ match node.kind() {
+ oxc_ast::AstKind::BindingIdentifier(ident) => {
+ names.used.insert(ident.name.to_string());
+ }
+ oxc_ast::AstKind::IdentifierReference(ident) => {
+ names.used.insert(ident.name.to_string());
+ }
+ oxc_ast::AstKind::JSXIdentifier(ident) => {
+ names.used.insert(ident.name.to_string());
+ }
+ _ => {}
+ }
+ }
+ names
+ }
+
+ pub fn allocate(&mut self, prefix: &'static str) -> String {
+ let mut index = *self.next.get(prefix).unwrap_or(&0);
+ loop {
+ let name = format!("{prefix}{index}");
+ index += 1;
+ if self.used.insert(name.clone()) {
+ self.next.insert(prefix, index);
+ return name;
+ }
+ }
+ }
+}
diff --git a/packages/compiler/src/tsrx/project.rs b/packages/compiler/src/tsrx/project.rs
new file mode 100644
index 000000000..4b1974051
--- /dev/null
+++ b/packages/compiler/src/tsrx/project.rs
@@ -0,0 +1,1325 @@
+//! TSRX → Solid JSX desugaring, as authored-text projection.
+//!
+//! Mirrors `@solidjs/babel-plugin`'s `src/tsrx/desugar.ts` (the frozen
+//! contract) construct-for-construct, but in the text domain: typed nodes from
+//! [`super::semantic`] identify Solid semantics and each construct extent is
+//! replaced with the desugared Solid-JSX source form. Authored bytes outside
+//! constructs are copied verbatim. The projected text reparses with the
+//! crate's own oxc and produces the same AST the Babel frontend hands its
+//! pipeline — generated parentheses are trivia (`preserve_parens: false`)
+//! and generated JSX carries no stray whitespace children.
+//!
+//! Lazy `&` patterns are only *stripped* here (each pattern keeps its
+//! authored binding names, so the reparsed program has real,
+//! scope-resolvable bindings); the `__lazyN` renames and accessor-call
+//! rewrites happen after the reparse in [`crate::tsrx::rewrite`], driven by
+//! the anchors recorded in [`Projection`].
+//!
+//! Emission is strictly append-only (no detached buffers), so anchor offsets
+//! are final as they are recorded. Where output order differs from authored
+//! order (a `@switch` `@default` case becomes the leading `fallback`
+//! attribute), blocks are validated in the Babel frontend's order first and
+//! re-analyzed cheaply during emission.
+
+use super::{
+ semantic::{
+ self, CatchBinding, CodeBlock, ControlFlow, ForLoop, IfChain, RenderShape as Shape,
+ Switch as SemanticSwitch, SwitchArm, TemplateSite, Try as SemanticTry,
+ },
+ source_map::ProjectionMap,
+ style_projection::{
+ self, RefSetup, StyleAction, StyleProjection, class_attribute, decode_json_string,
+ is_callback_ref, is_class_attribute, is_direct_ref_target, push_class_map, push_js_string,
+ },
+ tape::{self, Node},
+};
+
+/// Result of projecting one TSRX module to plain TSX.
+pub struct Projection {
+ pub text: String,
+ /// Concatenated extracted stylesheets in owner-visitor order.
+ pub css: String,
+ /// Space-separated scope hashes, or `None` when no styles were present.
+ pub css_hash: Option,
+ /// Parser-authored embedded CSS and raw-text script bodies.
+ pub(super) embedded_regions: Vec,
+ /// Exact authored ranges copied into `text`, used to compose codegen maps.
+ pub(super) source_map: ProjectionMap,
+ /// Projected offset of each lazy pattern's opening bracket, with its
+ /// preallocated `__lazyN` name.
+ pub lazy_patterns: Vec<(u32, String, bool)>,
+ /// Projected offset of a generated arrow (its parameter `(`), with the
+ /// binding names whose reads must become zero-argument calls (RC accessor
+ /// semantics for non-default `For` items, custom-key `For` indexes, and
+ /// `@catch` errors).
+ pub accessor_arrows: Vec<(u32, Vec)>,
+}
+
+/// A structured frontend diagnostic in authored coordinates.
+pub struct ProjectError {
+ pub message: String,
+ /// Authored byte offset the diagnostic points at.
+ pub start: u32,
+}
+
+impl ProjectError {
+ fn new(message: impl Into, node: Node<'_>) -> Self {
+ Self {
+ message: message.into(),
+ start: node.span().map(|(start, _)| start).unwrap_or(0),
+ }
+ }
+}
+
+type Result = std::result::Result;
+
+/// Where a node sits, which decides its rendered form.
+#[derive(Clone, Copy, PartialEq)]
+enum Position {
+ /// The `@{}` body of a function: statements plus `return render;`.
+ FunctionBody,
+ /// Expression slot (arrow body, attribute container, argument, …).
+ Expression,
+ /// JSX child slot: non-JSX results need `{…}` wrapping.
+ JsxChild,
+}
+
+const FUNCTION_TYPES: [&str; 3] = [
+ "FunctionDeclaration",
+ "FunctionExpression",
+ "ArrowFunctionExpression",
+];
+
+fn is_function(ty: &str) -> bool {
+ FUNCTION_TYPES.contains(&ty)
+}
+
+fn contains_pattern_default(node: Node<'_>) -> bool {
+ let mut found = false;
+ tape::walk(node, &mut |child| {
+ if child.ty() == "AssignmentPattern" {
+ found = true;
+ return false;
+ }
+ true
+ });
+ found
+}
+
+pub fn project(
+ source: &str,
+ filename: &str,
+ semantic: &semantic::SolidTsrxModule<'_>,
+ source_maps: bool,
+) -> Result {
+ let styles = style_projection::plan(source, filename, semantic)?;
+ project_with_styles(source, semantic, styles, source_maps)
+}
+
+pub fn project_with_styles(
+ source: &str,
+ semantic: &semantic::SolidTsrxModule<'_>,
+ styles: StyleProjection<'_>,
+ source_maps: bool,
+) -> Result {
+ let root = semantic.root;
+ let css = styles.css.clone();
+ let css_hash = styles.css_hash.clone();
+ let embedded_regions = semantic.embedded_regions.clone();
+ let mut renderer = Renderer {
+ source,
+ out: String::with_capacity(source.len() + source.len() / 4),
+ semantic,
+ styles,
+ lazy_ids: collect_lazy_ids(semantic),
+ lazy_patterns: Vec::new(),
+ accessor_arrows: Vec::new(),
+ source_map: ProjectionMap::new(source_maps),
+ };
+
+ renderer.emit_verbatim_with_specials(root, 0, source.len() as u32, Position::Expression)?;
+
+ Ok(Projection {
+ text: renderer.out,
+ css,
+ css_hash,
+ embedded_regions,
+ source_map: renderer.source_map,
+ lazy_patterns: renderer.lazy_patterns,
+ accessor_arrows: renderer.accessor_arrows,
+ })
+}
+
+/// Preallocate `__lazyN` names for every lazy pattern in document order,
+/// mirroring `@tsrx/core`'s `preallocateLazyIds`. Keyed by pattern span.
+fn collect_lazy_ids(semantic: &semantic::SolidTsrxModule<'_>) -> Vec<(u32, u32)> {
+ semantic
+ .lazy_patterns
+ .iter()
+ .map(|pattern| (pattern.origin.span.start, pattern.origin.span.end))
+ .collect()
+}
+
+// ---------------------------------------------------------------------------
+// Special-node collection (for verbatim regions)
+// ---------------------------------------------------------------------------
+
+struct Special<'t> {
+ node: Node<'t>,
+ /// Replacement span in authored bytes (differs from the node span only
+ /// for lazy patterns, which also consume the preceding `&` sigil).
+ span: (u32, u32),
+ position: Position,
+}
+
+fn has_synthetic_closing_element(node: Node<'_>) -> bool {
+ node.node_field("closingElement")
+ .and_then(Node::span)
+ .is_some_and(|(start, end)| start == end)
+}
+
+fn is_synthetic_undefined(node: Node<'_>) -> bool {
+ node.ty() == "Identifier"
+ && node.str_field("name") == Some("undefined")
+ && node.span().is_some_and(|(start, end)| start == end)
+}
+
+/// Find the outermost nodes within `node`'s subtree that need re-rendering,
+/// in document order. Does not descend into found specials: their renderers
+/// re-collect within themselves. `position` classifies `node` itself when it
+/// is special.
+fn collect_specials<'t>(
+ node: Node<'t>,
+ position: Position,
+ styles: &StyleProjection<'t>,
+ semantic: &semantic::SolidTsrxModule<'t>,
+ out: &mut Vec>,
+) {
+ let ty = node.ty();
+ let start = node.span().map_or(u32::MAX, |span| span.0);
+
+ let special_span = if semantic.lazy_assignment_for(node).is_some() {
+ node.span()
+ } else if let Some(control) = semantic.control_for(node) {
+ let extent = control.origin().extent;
+ Some((extent.start, extent.end))
+ } else if semantic.template_site_for(node).is_some()
+ || (ty == "JSXElement"
+ && (styles.element_hashes.contains_key(&start)
+ || styles.owner_setups.contains_key(&start)
+ || has_synthetic_closing_element(node)))
+ || (ty == "JSXFragment" && styles.owner_setups.contains_key(&start))
+ {
+ node.span()
+ } else if semantic.is_authored_lazy_pattern(node) {
+ // The `&` sigil sits immediately before the pattern's bracket.
+ node.span()
+ .map(|(start, end)| (start.saturating_sub(1), end))
+ } else if is_synthetic_undefined(node) {
+ node.span()
+ } else {
+ None
+ };
+
+ if let Some(span) = special_span {
+ out.push(Special {
+ node,
+ span,
+ position,
+ });
+ return;
+ }
+
+ collect_children(node, styles, semantic, out);
+}
+
+fn collect_children<'t>(
+ node: Node<'t>,
+ styles: &StyleProjection<'t>,
+ semantic: &semantic::SolidTsrxModule<'t>,
+ out: &mut Vec>,
+) {
+ let ty = node.ty();
+ for (key, value) in node.fields() {
+ if matches!(key, "type" | "start" | "end" | "metadata" | "loc" | "range") {
+ continue;
+ }
+ let child_position = match (ty, key) {
+ (parent, "body") if is_function(parent) => Position::FunctionBody,
+ ("JSXElement" | "JSXFragment", "children") => Position::JsxChild,
+ _ => Position::Expression,
+ };
+ match value.kind() {
+ tsrx_tape_schema::ValueKind::Object => {
+ if let Some(child) = Node::from_value(node.tape(), value) {
+ collect_specials(child, child_position, styles, semantic, out);
+ }
+ }
+ tsrx_tape_schema::ValueKind::List => {
+ if let Some(list) = value.as_list() {
+ let mut next = node.tape().list_first_value(list);
+ while let Some(entry) = next.filter(|entry| !entry.is_none()) {
+ if let Some(item) = node.tape().list_value(entry)
+ && let Some(child) = Node::from_value(node.tape(), item)
+ {
+ collect_specials(child, child_position, styles, semantic, out);
+ }
+ next = node.tape().list_value_next(entry);
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Rendering
+// ---------------------------------------------------------------------------
+
+struct Renderer<'s, 'm, 't> {
+ source: &'s str,
+ out: String,
+ semantic: &'m semantic::SolidTsrxModule<'t>,
+ styles: StyleProjection<'t>,
+ /// Document-ordered lazy pattern spans; index = lazy id.
+ lazy_ids: Vec<(u32, u32)>,
+ lazy_patterns: Vec<(u32, String, bool)>,
+ accessor_arrows: Vec<(u32, Vec)>,
+ source_map: ProjectionMap,
+}
+
+impl<'s, 'm, 't> Renderer<'s, 'm, 't> {
+ fn push_verbatim(&mut self, start: u32, end: u32) {
+ if end <= start {
+ return;
+ }
+ self.source_map
+ .record_verbatim(self.out.len() as u32, start, end);
+ self.out
+ .push_str(&self.source[start as usize..end as usize]);
+ }
+
+ fn push(&mut self, text: &str) {
+ self.out.push_str(text);
+ }
+
+ /// Emit `[start, end)` of authored source, re-rendering the specials
+ /// found within `scope`'s subtree.
+ fn emit_verbatim_with_specials(
+ &mut self,
+ scope: Node<'_>,
+ start: u32,
+ end: u32,
+ position: Position,
+ ) -> Result<()> {
+ let mut specials = Vec::new();
+ collect_specials(scope, position, &self.styles, self.semantic, &mut specials);
+ self.emit_region(start, end, &mut specials)
+ }
+
+ /// Emit an authored region, splicing in the specials whose replacement
+ /// span lies within `[start, end)`.
+ fn emit_region(&mut self, start: u32, end: u32, specials: &mut Vec>) -> Result<()> {
+ // Tape field order is not source order (e.g. `children` can precede
+ // `openingElement`): splice in document order.
+ specials.sort_by_key(|special| special.span.0);
+ let mut cursor = start;
+ for special in specials.iter() {
+ let (s_start, s_end) = special.span;
+ if s_start < cursor || s_end > end {
+ continue;
+ }
+ self.push_verbatim(cursor, s_start);
+ self.render_special(special.node, special.position)?;
+ cursor = s_end;
+ }
+ self.push_verbatim(cursor, end);
+ Ok(())
+ }
+
+ /// Emit one authored node in the given position: dispatches specials
+ /// directly, copies everything else verbatim with nested specials.
+ fn emit_node(&mut self, node: Node<'_>, position: Position) -> Result<()> {
+ let ty = node.ty();
+ let start = node.span().map_or(u32::MAX, |span| span.0);
+ if self.semantic.control_for(node).is_some()
+ || self.semantic.template_site_for(node).is_some()
+ || (ty == "JSXElement"
+ && (self.styles.element_hashes.contains_key(&start)
+ || self.styles.owner_setups.contains_key(&start)
+ || has_synthetic_closing_element(node)))
+ || (ty == "JSXFragment" && self.styles.owner_setups.contains_key(&start))
+ || self.semantic.lazy_assignment_for(node).is_some()
+ || self.semantic.is_authored_lazy_pattern(node)
+ || self.semantic.lazy_pattern_for(node).is_some()
+ || is_synthetic_undefined(node)
+ {
+ return self.render_special(node, position);
+ }
+ let (start, end) = span_of(node)?;
+ let mut specials = Vec::new();
+ collect_children(node, &self.styles, self.semantic, &mut specials);
+ self.emit_region(start, end, &mut specials)
+ }
+
+ fn render_special(&mut self, node: Node<'_>, position: Position) -> Result<()> {
+ if let Some(control) = self.semantic.control_for(node) {
+ return match control {
+ ControlFlow::CodeBlock(code) => self.render_code_block(code, position),
+ ControlFlow::If(chain) => self.render_if(chain),
+ ControlFlow::For(loop_) => self.render_for(loop_),
+ ControlFlow::Switch(switch) => self.render_switch(switch),
+ ControlFlow::Try(try_) => self.render_try(try_, position),
+ };
+ }
+ if let Some(site) = self.semantic.template_site_for(node) {
+ return match site {
+ TemplateSite::StyleElement { .. } => self.render_style(node),
+ TemplateSite::ShorthandAttribute { name, .. } => self.render_shorthand_attr(name),
+ TemplateSite::RawTextScript(_) => self.render_raw_text_script(node, position),
+ TemplateSite::DynamicElement { .. } => self.render_scoped_element(node, position),
+ };
+ }
+ if let Some(assignment) = self.semantic.lazy_assignment_for(node) {
+ return self.render_lazy_assignment(assignment);
+ }
+ match node.ty() {
+ "JSXFragment" => self.render_scoped_fragment(node, position),
+ "JSXElement" => self.render_scoped_element(node, position),
+ "ArrayPattern" | "ObjectPattern" if self.semantic.lazy_pattern_for(node).is_some() => {
+ self.render_lazy_pattern(node)
+ }
+ "ArrayPattern" | "ObjectPattern" => self.emit_eager_pattern(node),
+ "Identifier" if is_synthetic_undefined(node) => {
+ self.push("undefined");
+ Ok(())
+ }
+ other => Err(ProjectError::new(
+ format!("Unsupported TSRX construct `{other}`"),
+ node,
+ )),
+ }
+ }
+
+ // -- @{} statement containers ---------------------------------------------
+
+ fn render_code_block(&mut self, code: &CodeBlock<'t>, position: Position) -> Result<()> {
+ let node = code.origin.tape;
+ let render = code.render;
+ let setup = &code.setup;
+ let style_setups = self
+ .styles
+ .owner_setups
+ .get(&span_of(node)?.0)
+ .cloned()
+ .unwrap_or_default();
+
+ match position {
+ Position::FunctionBody => {
+ self.push("{\n");
+ self.emit_statements(setup)?;
+ for setup in &style_setups {
+ self.emit_ref_setup(setup)?;
+ }
+ self.push("return ");
+ if let Some(render) = render {
+ self.render_entry_expression(render)?;
+ } else {
+ self.push("null");
+ }
+ self.push(";\n}");
+ }
+ Position::Expression | Position::JsxChild => {
+ let wrap = position == Position::JsxChild
+ && (if setup.is_empty() && style_setups.is_empty() {
+ render.is_none_or(|render| {
+ semantic::predict_entry_shape(render) == Shape::Expr
+ })
+ } else {
+ true
+ });
+ if wrap {
+ self.push("{");
+ }
+ if setup.is_empty() && style_setups.is_empty() {
+ if let Some(render) = render {
+ self.render_entry_expression(render)?;
+ } else {
+ self.push("null");
+ }
+ } else {
+ self.push("(() => {\n");
+ self.emit_statements(setup)?;
+ for setup in &style_setups {
+ self.emit_ref_setup(setup)?;
+ }
+ self.push("return ");
+ if let Some(render) = render {
+ self.render_entry_expression(render)?;
+ } else {
+ self.push("null");
+ }
+ self.push(";\n})()");
+ }
+ if wrap {
+ self.push("}");
+ }
+ }
+ }
+ Ok(())
+ }
+
+ // -- @if — Show / Switch+Match ---------------------------------------------
+
+ fn render_if(&mut self, chain: &IfChain<'t>) -> Result<()> {
+ let has_fallback = chain
+ .fallback
+ .as_ref()
+ .is_some_and(|fallback| !fallback.is_empty());
+
+ if let [branch] = chain.branches.as_slice() {
+ self.push("");
+ }
+
+ self.push("");
+ for branch in &chain.branches {
+ self.push("")?;
+ }
+ self.push(" ");
+ Ok(())
+ }
+
+ /// Emit a construct's block as its JSX children (or self-close when the
+ /// block renders nothing), then the closing tag.
+ fn emit_construct_children(
+ &mut self,
+ block: &semantic::TemplateBlock<'_>,
+ construct: &str,
+ closing: &str,
+ ) -> Result<()> {
+ if block.is_empty() {
+ self.push("/>");
+ return Ok(());
+ }
+ self.push(">");
+ if block.shape == Shape::Jsx {
+ self.emit_template_block_expression(block, construct)?;
+ } else {
+ self.push("{");
+ self.emit_template_block_expression(block, construct)?;
+ self.push("}");
+ }
+ self.push(closing);
+ Ok(())
+ }
+
+ // -- @for — For --------------------------------------------------------------
+
+ fn render_for(&mut self, loop_: &ForLoop<'t>) -> Result<()> {
+ let node = loop_.origin.tape;
+ let pattern = loop_.pattern;
+ let each = loop_.iterable;
+ let index = loop_.index;
+ let key = loop_.key;
+ let mode = loop_.callback_mode;
+ let body = &loop_.body;
+ if body.renders.is_empty() {
+ return Err(ProjectError::new(
+ "A TSRX @for body must end with rendered output",
+ node,
+ ));
+ }
+
+ self.push(" (");
+ self.emit_node(key, Position::Expression)?;
+ self.push(")}");
+ } else if mode.emits_non_keyed_intent() {
+ self.push(" keyed={false}");
+ }
+ if let Some(empty) = &loop_.empty
+ && !empty.is_empty()
+ {
+ self.push(" fallback={");
+ self.emit_template_block_expression(empty, "@empty")?;
+ self.push("}");
+ }
+ self.push(">{");
+
+ // RC `For` callback shape:
+ // - default keyed mode: raw item, accessor index (there is no TSRX index)
+ // - keyed={false}: accessor item, raw index
+ // - custom key: accessor item, accessor index
+ // The post-reparse pass rewrites accessor reads at this arrow.
+ let mut accessor_names = Vec::new();
+ if mode.item_is_accessor()
+ && let Some(name) = ident_name(pattern)
+ {
+ accessor_names.push(name.to_string());
+ }
+ if mode.index_is_accessor()
+ && let Some(index) = index
+ && let Some(name) = ident_name(index)
+ {
+ accessor_names.push(name.to_string());
+ }
+ if !accessor_names.is_empty() {
+ self.accessor_arrows
+ .push((self.out.len() as u32, accessor_names));
+ }
+
+ self.push("(");
+ if mode.item_is_accessor() && pattern.ty() != "Identifier" {
+ self.render_lazy_pattern(pattern)?;
+ } else {
+ self.emit_node(pattern, Position::Expression)?;
+ }
+ if let Some(index) = index {
+ self.push(", ");
+ self.emit_node(index, Position::Expression)?;
+ }
+ self.push(") => ");
+ if body.setup.is_empty() {
+ self.push("(");
+ self.emit_renders_expression(&body.renders)?;
+ self.push(")");
+ } else {
+ self.push("{\n");
+ self.emit_statements(&body.setup)?;
+ self.push("return ");
+ self.emit_renders_expression(&body.renders)?;
+ self.push(";\n}");
+ }
+ self.push("} ");
+ Ok(())
+ }
+
+ // -- @switch — Switch / Match --------------------------------------------------
+
+ fn render_switch(&mut self, switch: &SemanticSwitch<'t>) -> Result<()> {
+ // Validate every case in authored order first (the @default case is
+ // emitted out of order, as the leading `fallback` attribute).
+ for arm in &switch.arms {
+ let block = arm.block();
+ if !block.setup.is_empty() && block.renders.is_empty() {
+ return Err(ProjectError::new(
+ "A TSRX @case block with setup statements must end with rendered output",
+ arm.origin().tape,
+ ));
+ }
+ }
+
+ let default_arm = switch.default_arm();
+ let has_fallback = default_arm.is_some_and(|arm| !arm.block().is_empty());
+
+ self.push("");
+ for arm in &switch.arms {
+ let SwitchArm::Case { test, block, .. } = arm else {
+ continue;
+ };
+ self.push(" ");
+ continue;
+ }
+ self.push(">");
+ let shape = if block.setup.is_empty() {
+ match block.renders.as_slice() {
+ [only] => semantic::predict_entry_shape(*only),
+ _ => Shape::Jsx,
+ }
+ } else {
+ Shape::Expr
+ };
+ if shape == Shape::Jsx {
+ self.emit_template_block_expression(block, "@case")?;
+ } else {
+ self.push("{");
+ self.emit_template_block_expression(block, "@case")?;
+ self.push("}");
+ }
+ self.push("");
+ }
+ self.push(" ");
+ Ok(())
+ }
+
+ // -- @try / @pending / @catch — Errored / Loading -------------------------------
+
+ fn render_try(&mut self, try_: &SemanticTry<'t>, position: Position) -> Result<()> {
+ let node = try_.origin.tape;
+ let block = &try_.body;
+ if block.renders.is_empty() {
+ // Setup-only blocks get blockToExpression's message; fully empty
+ // ones get the @try-specific message, matching the Babel frontend.
+ return Err(if block.setup.is_empty() {
+ ProjectError::new("A TSRX @try block must end with rendered output", node)
+ } else {
+ ProjectError::new(
+ "A TSRX @try block with setup statements must end with rendered output",
+ block.node,
+ )
+ });
+ }
+ let pending = try_.pending.as_ref();
+ let handler = try_.catch.as_ref();
+ let mut error_name = String::from("_e");
+ let mut reset_name: Option = None;
+ let mut has_error_param = false;
+ let mut error_pattern = None;
+ if let Some(handler) = handler {
+ if let Some(binding) = &handler.binding {
+ match binding {
+ CatchBinding::Identifier { name, .. } => {
+ error_name = (*name).to_string();
+ has_error_param = true;
+ }
+ CatchBinding::Pattern(pattern) => error_pattern = Some(*pattern),
+ }
+ }
+ if let Some(reset) = handler.reset.and_then(ident_name) {
+ reset_name = Some(reset.to_string());
+ }
+ if handler.body.renders.is_empty() {
+ return Err(if handler.body.setup.is_empty() {
+ ProjectError::new(
+ "A TSRX @catch block must end with rendered output",
+ handler.origin.tape,
+ )
+ } else {
+ ProjectError::new(
+ "A TSRX @catch block with setup statements must end with rendered output",
+ handler.body.node,
+ )
+ });
+ }
+ }
+
+ let inner_shape = if pending.is_some() {
+ Shape::Jsx
+ } else {
+ block.shape
+ };
+ let result_shape = if handler.is_some() {
+ Shape::Jsx
+ } else {
+ inner_shape
+ };
+
+ let wrap = position == Position::JsxChild && result_shape != Shape::Jsx;
+ if wrap {
+ self.push("{");
+ }
+
+ if let Some(handler) = handler {
+ self.push(" (");
+ self.emit_template_block_expression(&handler.body, "@catch")?;
+ self.push(")}>");
+ }
+
+ if let Some(pending) = pending {
+ self.push("");
+ let content_shape = block.shape;
+ if content_shape == Shape::Jsx {
+ self.emit_template_block_expression(block, "@try")?;
+ } else {
+ self.push("{");
+ self.emit_template_block_expression(block, "@try")?;
+ self.push("}");
+ }
+ self.push(" ");
+ } else if handler.is_some() {
+ let content_shape = block.shape;
+ if content_shape == Shape::Jsx {
+ self.emit_template_block_expression(block, "@try")?;
+ } else {
+ self.push("{");
+ self.emit_template_block_expression(block, "@try")?;
+ self.push("}");
+ }
+ } else {
+ self.emit_template_block_expression(block, "@try")?;
+ }
+
+ if handler.is_some() {
+ self.push(" ");
+ }
+ if wrap {
+ self.push("}");
+ }
+ Ok(())
+ }
+
+ // -- Dynamic tags and shorthand props -----------------------------------------
+
+ fn render_style(&mut self, node: Node<'_>) -> Result<()> {
+ let start = span_of(node)?.0;
+ match self.styles.actions.get(&start).cloned() {
+ Some(StyleAction::Remove) => Ok(()),
+ Some(StyleAction::ClassMap(entries)) => {
+ push_class_map(&mut self.out, &entries);
+ Ok(())
+ }
+ Some(StyleAction::EmptyElement) => {
+ let opening = node.node_field("openingElement").ok_or_else(|| {
+ ProjectError::new("A TSRX >\n\
+ }";
+ let tape = parse(source);
+ let module = lower(Node::root(&tape).unwrap()).expect("semantic IR");
+ let script = module
+ .template_sites
+ .iter()
+ .find_map(|site| match site {
+ TemplateSite::RawTextScript(script) => Some(script),
+ _ => None,
+ })
+ .expect("raw script");
+ assert_eq!(
+ &source[script.payload.start as usize..script.payload.end as usize],
+ "{\"ok\":true}"
+ );
+ assert_eq!(
+ module
+ .raw_text_script_for(script.origin.tape)
+ .map(|script| script.payload),
+ Some(script.payload)
+ );
+ assert_eq!(
+ module
+ .embedded_regions
+ .iter()
+ .map(|region| region.kind)
+ .collect::>(),
+ [EmbeddedKind::Css, EmbeddedKind::Script]
+ );
+ }
+
+ #[test]
+ fn owns_template_and_lazy_site_semantics() {
+ let source = "export function C({ Tag, rows, model }) @{\n\
+ const &{ title } = model;\n\
+ &{ current } = model;\n\
+ <>\n\
+ \n\
+ <{Tag} />
\n\
+ @for (const { name } of rows; index index) { {name}:{index}
}\n\
+ @try { } @catch ({ message }) { {message}
}\n\
+ >\n\
+ }";
+ let tape = parse(source);
+ let module =
+ lower_with_options(Node::root(&tape).unwrap(), false, AuthoredLazyPolicy::Allow)
+ .expect("semantic IR");
+
+ assert_eq!(
+ module
+ .template_sites
+ .iter()
+ .filter(|site| matches!(site, TemplateSite::StyleElement { .. }))
+ .count(),
+ 1
+ );
+ assert_eq!(
+ module
+ .template_sites
+ .iter()
+ .filter(|site| matches!(site, TemplateSite::DynamicElement { .. }))
+ .count(),
+ 1
+ );
+ assert_eq!(
+ module
+ .template_sites
+ .iter()
+ .filter(|site| matches!(site, TemplateSite::ShorthandAttribute { .. }))
+ .count(),
+ 1
+ );
+ assert_eq!(module.lazy_assignments.len(), 1);
+ assert_eq!(module.lazy_patterns.len(), 4);
+ assert_eq!(
+ module
+ .lazy_patterns
+ .iter()
+ .filter(|pattern| pattern.source_accessor)
+ .count(),
+ 2
+ );
+ for pattern in &module.lazy_patterns {
+ assert_eq!(
+ module
+ .lazy_pattern_for(pattern.origin.tape)
+ .map(|indexed| indexed.origin.span),
+ Some(pattern.origin.span)
+ );
+ }
+ }
+
+ #[test]
+ fn lowers_if_chain_and_fallback() {
+ let source = "export const C = ({ a, b }) => @if (a) { } @else if (b) { } @else { };";
+ let tape = parse(source);
+ let module = lower(Node::root(&tape).unwrap()).expect("semantic IR");
+ let ControlFlow::If(chain) = at(&module, source, "@if") else {
+ panic!("expected if chain");
+ };
+ assert_eq!(
+ module
+ .control_flow
+ .iter()
+ .filter(|control| matches!(control, ControlFlow::If(_)))
+ .count(),
+ 1
+ );
+ assert_eq!(chain.branches.len(), 2);
+ assert!(chain.fallback.is_some());
+ assert_eq!(chain.origin.span.start, source.find("@if").unwrap() as u32);
+ assert_eq!(
+ chain.origin.extent.end,
+ source.rfind('}').unwrap() as u32 + 1
+ );
+ }
+
+ #[test]
+ fn computes_for_callback_mode_matrix() {
+ let source = "export function C({ xs }) @{\n\
+ \n\
+ @for (const a of xs) {
}\n\
+ @for (const b of xs; index i) {
}\n\
+ @for (const c of xs; key c.id) {
}\n\
+ @for (const d of xs; index j; key d.id) {
}\n\
+
\n\
+ }";
+ let tape = parse(source);
+ let module = lower(Node::root(&tape).unwrap()).expect("semantic IR");
+ let modes: Vec<_> = module
+ .control_flow
+ .iter()
+ .filter_map(|control| match control {
+ ControlFlow::For(loop_) => Some(loop_.callback_mode),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(
+ modes,
+ [
+ ForCallbackMode::Default,
+ ForCallbackMode::Indexed,
+ ForCallbackMode::Keyed,
+ ForCallbackMode::KeyedIndexed,
+ ]
+ );
+ assert!(!modes[0].item_is_accessor());
+ assert!(modes[1].emits_non_keyed_intent());
+ assert!(!modes[1].index_is_accessor());
+ assert!(modes[3].index_is_accessor());
+ }
+
+ #[test]
+ fn lowers_switch_default_and_try_clauses() {
+ let source = "export function C({ x }) @{\n\
+ <>\n\
+ @switch (x) { @case 1: { } @default: { } }\n\
+ @try { } @pending { } @catch (error, reset) { }\n\
+ >\n\
+ }";
+ let tape = parse(source);
+ let module = lower(Node::root(&tape).unwrap()).expect("semantic IR");
+ let ControlFlow::Switch(switch) = at(&module, source, "@switch") else {
+ panic!("expected switch");
+ };
+ assert_eq!(switch.arms.len(), 2);
+ assert!(switch.default_arm().is_some());
+ let ControlFlow::Try(try_) = at(&module, source, "@try") else {
+ panic!("expected try");
+ };
+ assert!(try_.pending.is_some());
+ let catch = try_.catch.as_ref().expect("catch");
+ assert!(matches!(
+ catch.binding,
+ Some(CatchBinding::Identifier { name: "error", .. })
+ ));
+ assert_eq!(
+ catch.reset.and_then(|node| node.str_field("name")),
+ Some("reset")
+ );
+ }
+
+ #[test]
+ fn preserves_utf8_authored_spans_after_utf16_rebase() {
+ let source = "const marker = \"🚀\";\nexport const C = ({ ok }) => @if (ok) { };";
+ let tape = parse(source);
+ let module = lower(Node::root(&tape).unwrap()).expect("semantic IR");
+ let control = at(&module, source, "@if");
+ assert_eq!(
+ control.origin().span.start,
+ source.find("@if").unwrap() as u32
+ );
+ assert_eq!(
+ &source[control.origin().span.start as usize..control.origin().span.end as usize],
+ "@if (ok) { }"
+ );
+ }
+
+ #[test]
+ fn rejects_missing_required_fields_at_the_construct_origin() {
+ let source = "export const C = ({ ok }) => @if (ok) { };";
+ let mut tape = parse(source);
+ let mut target = None;
+ tape::walk(Node::root(&tape).unwrap(), &mut |node| {
+ if node.ty() == "JSXIfExpression" {
+ target = Some(node.object());
+ return false;
+ }
+ true
+ });
+ let object = target.expect("if record");
+ let field = tape.field_index(object, "test").expect("test field");
+ tape.set_field_value(field, ValueRef::MISSING)
+ .expect("remove test");
+ let error = match lower(Node::root(&tape).unwrap()) {
+ Ok(_) => panic!("malformed IR must fail"),
+ Err(error) => error,
+ };
+ assert_eq!(error.message, "TSRX @if is missing its condition");
+ assert_eq!(error.start, source.find("@if").unwrap() as u32);
+ }
+}
diff --git a/packages/compiler/src/tsrx/source_map.rs b/packages/compiler/src/tsrx/source_map.rs
new file mode 100644
index 000000000..c1fa6b690
--- /dev/null
+++ b/packages/compiler/src/tsrx/source_map.rs
@@ -0,0 +1,415 @@
+//! Source-map support for the authored-text TSRX projection.
+//!
+//! Oxc codegen maps generated JavaScript back to the projected TSX. This
+//! module records the authored bytes copied into that projection and composes
+//! codegen's map through those exact ranges. Generated projection gaps remain
+//! explicitly unmapped instead of being attributed to nearby TSRX syntax.
+
+use oxc_sourcemap::{SourceMap, SourceMapBuilder};
+use oxc_syntax::identifier::is_identifier_name;
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+struct ProjectionSegment {
+ projected_start: u32,
+ projected_end: u32,
+ authored_start: u32,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub(super) struct ExactMapping {
+ pub authored_start: u32,
+ pub generated_start: u32,
+ pub length: u32,
+}
+
+/// Exact affine ranges copied from authored TSRX into projected TSX.
+#[derive(Debug)]
+pub(super) struct ProjectionMap {
+ enabled: bool,
+ segments: Vec,
+}
+
+impl ProjectionMap {
+ pub fn new(enabled: bool) -> Self {
+ Self {
+ enabled,
+ segments: Vec::new(),
+ }
+ }
+
+ pub fn record_verbatim(
+ &mut self,
+ projected_start: u32,
+ authored_start: u32,
+ authored_end: u32,
+ ) {
+ if !self.enabled || authored_end <= authored_start {
+ return;
+ }
+ let len = authored_end - authored_start;
+ let projected_end = projected_start + len;
+ if let Some(previous) = self.segments.last_mut()
+ && previous.projected_end == projected_start
+ && previous.authored_start + (previous.projected_end - previous.projected_start)
+ == authored_start
+ {
+ previous.projected_end = projected_end;
+ return;
+ }
+ self.segments.push(ProjectionSegment {
+ projected_start,
+ projected_end,
+ authored_start,
+ });
+ }
+
+ pub(super) fn authored_offset(&self, projected_offset: u32) -> Option {
+ let index = self
+ .segments
+ .partition_point(|segment| segment.projected_start <= projected_offset);
+ let segment = self.segments.get(index.checked_sub(1)?)?;
+ (projected_offset < segment.projected_end)
+ .then(|| segment.authored_start + projected_offset - segment.projected_start)
+ }
+
+ fn authored_run(&self, projected_offset: u32) -> Option<(u32, u32)> {
+ let index = self
+ .segments
+ .partition_point(|segment| segment.projected_start <= projected_offset);
+ let segment = self.segments.get(index.checked_sub(1)?)?;
+ (projected_offset < segment.projected_end).then(|| {
+ (
+ segment.authored_start + projected_offset - segment.projected_start,
+ segment.projected_end - projected_offset,
+ )
+ })
+ }
+}
+
+/// Return exact equal-text runs between generated TSX and authored TSRX.
+///
+/// Source-map points delimit generated token runs, while [`ProjectionMap`]
+/// limits each candidate to one affine authored range. Comparing the bytes
+/// before publishing a run keeps the editor mapping fail-closed across
+/// compiler-created helpers, reordered controls, and normalized trivia.
+pub(super) fn exact_mappings(
+ intermediate: &SourceMap<'_>,
+ projection: &ProjectionMap,
+ projected_source: &str,
+ authored_source: &str,
+ generated_source: &str,
+) -> Vec {
+ #[derive(Clone, Copy)]
+ struct Point {
+ generated: u32,
+ projected: Option,
+ }
+
+ let generated_lines = LineOffsets::new(generated_source);
+ let projected_lines = LineOffsets::new(projected_source);
+ let mut points = Vec::new();
+ for token in intermediate.get_tokens() {
+ let Some(generated) =
+ generated_lines.byte_offset(token.get_dst_line(), token.get_dst_col())
+ else {
+ continue;
+ };
+ let projected = token
+ .get_source_id()
+ .and_then(|_| projected_lines.byte_offset(token.get_src_line(), token.get_src_col()));
+ points.push(Point {
+ generated,
+ projected,
+ });
+ }
+
+ let mut mappings: Vec = Vec::new();
+ for (index, point) in points.iter().enumerate() {
+ let Some(projected) = point.projected else {
+ continue;
+ };
+ let Some((authored_start, authored_run)) = projection.authored_run(projected) else {
+ continue;
+ };
+ let generated_end = points
+ .get(index + 1)
+ .map_or(generated_source.len() as u32, |next| next.generated);
+ if generated_end <= point.generated {
+ continue;
+ }
+ let maximum = authored_run
+ .min(generated_end - point.generated)
+ .min(authored_source.len() as u32 - authored_start)
+ .min(generated_source.len() as u32 - point.generated);
+ let authored = &authored_source.as_bytes()
+ [authored_start as usize..(authored_start + maximum) as usize];
+ let generated = &generated_source.as_bytes()
+ [point.generated as usize..(point.generated + maximum) as usize];
+ let mut length = authored
+ .iter()
+ .zip(generated)
+ .take_while(|(authored, generated)| authored == generated)
+ .count() as u32;
+ while length > 0
+ && (!authored_source.is_char_boundary((authored_start + length) as usize)
+ || !generated_source.is_char_boundary((point.generated + length) as usize))
+ {
+ length -= 1;
+ }
+ if length == 0 {
+ continue;
+ }
+ if let Some(previous) = mappings.last_mut()
+ && previous.authored_start + previous.length == authored_start
+ && previous.generated_start + previous.length == point.generated
+ {
+ previous.length += length;
+ } else {
+ mappings.push(ExactMapping {
+ authored_start,
+ generated_start: point.generated,
+ length,
+ });
+ }
+ }
+ mappings
+}
+
+/// Compose an Oxc JavaScript → projected-TSX map into a JavaScript → authored-
+/// TSRX map. Tokens landing in generated projection gaps are retained as
+/// source-less mappings so a preceding authored mapping cannot bleed across
+/// generated code.
+pub(super) fn compose(
+ intermediate: &SourceMap<'_>,
+ projection: &ProjectionMap,
+ projected_source: &str,
+ authored_source: &str,
+ filename: &str,
+) -> String {
+ let projected_lines = LineOffsets::new(projected_source);
+ let authored_lines = LineOffsets::new(authored_source);
+ let mut builder = SourceMapBuilder::default();
+ let source_id = builder.set_source_and_content(filename, authored_source);
+ if let Some(file) = intermediate.get_file() {
+ builder.set_file(file);
+ }
+
+ for token in intermediate.get_tokens() {
+ let mapped = token
+ .get_source_id()
+ .and_then(|_| projected_lines.byte_offset(token.get_src_line(), token.get_src_col()))
+ .and_then(|offset| projection.authored_offset(offset))
+ .and_then(|offset| authored_lines.line_column(offset));
+
+ if let Some((line, column)) = mapped {
+ let name_id = token
+ .get_name_id()
+ .and_then(|id| intermediate.get_name(id))
+ // Oxc derives names by slicing a node's source span. Projected
+ // wrapper and whole-pattern spans can therefore yield strings
+ // such as `{ name }`, which are not source-map symbol names.
+ .filter(|name| is_identifier_name(name))
+ .map(|name| builder.add_name(name));
+ builder.add_token(
+ token.get_dst_line(),
+ token.get_dst_col(),
+ line,
+ column,
+ Some(source_id),
+ name_id,
+ );
+ } else {
+ builder.add_token(token.get_dst_line(), token.get_dst_col(), 0, 0, None, None);
+ }
+ }
+
+ builder.into_sourcemap().to_json_string()
+}
+
+/// Converts between UTF-8 byte offsets and source-map line/UTF-16-column
+/// coordinates. JavaScript source maps count lines from zero.
+struct LineOffsets<'a> {
+ source: &'a str,
+ starts: Vec,
+ /// Per-line `(relative UTF-8 byte, UTF-16 column)` boundaries.
+ columns: Vec>,
+}
+
+impl<'a> LineOffsets<'a> {
+ fn new(source: &'a str) -> Self {
+ let mut starts = vec![0];
+ let mut chars = source.char_indices().peekable();
+ while let Some((offset, ch)) = chars.next() {
+ let next = match ch {
+ '\r' => {
+ if chars.peek().is_some_and(|(_, next)| *next == '\n') {
+ let (next_offset, next) = chars.next().expect("peeked line feed");
+ next_offset + next.len_utf8()
+ } else {
+ offset + ch.len_utf8()
+ }
+ }
+ '\n' | '\u{2028}' | '\u{2029}' => offset + ch.len_utf8(),
+ _ => continue,
+ };
+ starts.push(next as u32);
+ }
+ let columns = starts
+ .iter()
+ .enumerate()
+ .map(|(line, start)| {
+ let start = *start as usize;
+ let end = starts
+ .get(line + 1)
+ .copied()
+ .map_or(source.len(), |offset| offset as usize);
+ let line = &source[start..end];
+ let mut utf16 = 0u32;
+ let mut boundaries = Vec::with_capacity(line.chars().count() + 1);
+ for (relative, character) in line.char_indices() {
+ boundaries.push((relative as u32, utf16));
+ utf16 += character.len_utf16() as u32;
+ }
+ boundaries.push((line.len() as u32, utf16));
+ boundaries
+ })
+ .collect();
+ Self {
+ source,
+ starts,
+ columns,
+ }
+ }
+
+ fn byte_offset(&self, line: u32, utf16_column: u32) -> Option {
+ let line = line as usize;
+ let start = *self.starts.get(line)?;
+ let boundaries = self.columns.get(line)?;
+ let index = boundaries
+ .binary_search_by_key(&utf16_column, |(_, column)| *column)
+ .ok()?;
+ Some(start + boundaries[index].0)
+ }
+
+ fn line_column(&self, byte_offset: u32) -> Option<(u32, u32)> {
+ let byte_offset = byte_offset as usize;
+ if byte_offset > self.source.len() {
+ return None;
+ }
+ let line = self
+ .starts
+ .partition_point(|start| *start as usize <= byte_offset)
+ .checked_sub(1)?;
+ let start = self.starts[line] as usize;
+ let relative = (byte_offset - start) as u32;
+ let boundaries = &self.columns[line];
+ let index = boundaries
+ .binary_search_by_key(&relative, |(byte, _)| *byte)
+ .ok()?;
+ let column = boundaries[index].1;
+ Some((line as u32, column))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn projection_map_resolves_only_exact_verbatim_ranges() {
+ let mut map = ProjectionMap::new(true);
+ map.record_verbatim(3, 10, 14);
+ map.record_verbatim(7, 14, 16);
+ map.record_verbatim(12, 30, 32);
+
+ assert_eq!(map.authored_offset(2), None);
+ assert_eq!(map.authored_offset(3), Some(10));
+ assert_eq!(map.authored_offset(8), Some(15));
+ assert_eq!(map.authored_offset(9), None);
+ assert_eq!(map.authored_offset(12), Some(30));
+ assert_eq!(map.authored_offset(14), None);
+ }
+
+ #[test]
+ fn line_offsets_use_utf16_columns_and_javascript_line_breaks() {
+ let lines = LineOffsets::new("🚀a\r\n中\u{2028}z");
+ assert_eq!(lines.byte_offset(0, 0), Some(0));
+ assert_eq!(lines.byte_offset(0, 2), Some(4));
+ assert_eq!(lines.byte_offset(0, 1), None);
+ assert_eq!(lines.byte_offset(1, 1), Some(10));
+ assert_eq!(lines.byte_offset(2, 0), Some(13));
+ assert_eq!(lines.line_column(4), Some((0, 2)));
+ assert_eq!(lines.line_column(7), Some((1, 0)));
+ assert_eq!(lines.line_column(13), Some((2, 0)));
+ }
+
+ #[test]
+ fn composition_preserves_generated_gaps_as_unmapped_tokens() {
+ let projected = "xxname yy";
+ let authored = "before name after";
+ let mut projection = ProjectionMap::new(true);
+ projection.record_verbatim(2, 7, 11);
+
+ let mut intermediate = SourceMapBuilder::default();
+ let projected_id = intermediate.set_source_and_content("input.tsrx", projected);
+ let invalid_name = intermediate.add_name("{ name }");
+ let valid_name = intermediate.add_name("name");
+ intermediate.add_token(0, 0, 0, 0, Some(projected_id), None);
+ intermediate.add_token(0, 2, 0, 2, Some(projected_id), Some(invalid_name));
+ intermediate.add_token(0, 3, 0, 3, Some(projected_id), Some(valid_name));
+ intermediate.add_token(0, 6, 0, 6, Some(projected_id), None);
+ let intermediate = intermediate.into_sourcemap();
+
+ let json = compose(
+ &intermediate,
+ &projection,
+ projected,
+ authored,
+ "input.tsrx",
+ );
+ let composed = SourceMap::from_json_string(&json).expect("valid composed map");
+ let tokens = composed.get_tokens().collect::>();
+ assert_eq!(tokens[0].get_source_id(), None);
+ assert_eq!(tokens[1].get_source_id(), Some(0));
+ assert_eq!((tokens[1].get_src_line(), tokens[1].get_src_col()), (0, 7));
+ assert_eq!(tokens[1].get_name_id(), None);
+ assert_eq!(tokens[2].get_source_id(), Some(0));
+ assert_eq!(tokens[2].get_name_id(), Some(0));
+ assert_eq!(tokens[3].get_source_id(), None);
+ assert_eq!(composed.get_names().collect::>(), vec!["name"]);
+ assert_eq!(composed.get_source_content(0), Some(authored));
+ }
+
+ #[test]
+ fn exact_mappings_publish_only_equal_affine_runs() {
+ let projected = "name GENERATED value";
+ let authored = "name @{ value";
+ let generated = "name;\nhelper();\nvalue";
+ let mut projection = ProjectionMap::new(true);
+ projection.record_verbatim(0, 0, 5);
+ projection.record_verbatim(15, 8, 13);
+
+ let mut intermediate = SourceMapBuilder::default();
+ let projected_id = intermediate.set_source_and_content("input.tsrx", projected);
+ intermediate.add_token(0, 0, 0, 0, Some(projected_id), None);
+ intermediate.add_token(0, 4, 0, 5, None, None);
+ intermediate.add_token(2, 0, 0, 15, Some(projected_id), None);
+ let intermediate = intermediate.into_sourcemap();
+
+ assert_eq!(
+ exact_mappings(&intermediate, &projection, projected, authored, generated),
+ vec![
+ ExactMapping {
+ authored_start: 0,
+ generated_start: 0,
+ length: 4,
+ },
+ ExactMapping {
+ authored_start: 8,
+ generated_start: 16,
+ length: 5,
+ },
+ ]
+ );
+ }
+}
diff --git a/packages/compiler/src/tsrx/style/analysis.rs b/packages/compiler/src/tsrx/style/analysis.rs
new file mode 100644
index 000000000..412841ea9
--- /dev/null
+++ b/packages/compiler/src/tsrx/style/analysis.rs
@@ -0,0 +1,680 @@
+use super::render::{strip_prefix, unescape_ident};
+use super::*;
+
+pub(super) fn analyze_items(items: &mut [Item], parent: Option) -> Result<(), StyleError> {
+ analyze_items_inner(items, parent)
+}
+
+fn analyze_items_inner(items: &mut [Item], parent: Option) -> Result<(), StyleError> {
+ for item in items {
+ match item {
+ Item::Rule(rule) => {
+ rule.parent = parent;
+ for complex in &mut rule.selectors.selectors {
+ let mut after_global = false;
+ for (index, relative) in complex.parts.iter_mut().enumerate() {
+ if after_global {
+ relative.global_like = true;
+ }
+ let global_index = relative.simple.iter().position(is_bare_global);
+ if index == 0 && global_index == Some(0) {
+ rule.global_block = true;
+ }
+ if global_index == Some(0) {
+ after_global = true;
+ }
+ relative.global = relative_is_global(relative);
+ relative.global_like |= relative.simple.iter().all(|simple| {
+ matches!(simple, Simple::Pseudo(_) | Simple::PseudoElement(..))
+ && matches!(
+ simple,
+ Simple::Pseudo(Pseudo { name, .. }) if name == "root" || name == "host"
+ )
+ });
+ analyze_pseudos(&mut relative.simple)?;
+ }
+ complex.used = complex.parts.iter().all(|p| p.global || p.global_like);
+ if let Some(index) = complex.parts.iter().position(relative_is_global)
+ && index != 0
+ && index + 1 != complex.parts.len()
+ && complex.parts[index + 1..]
+ .iter()
+ .any(|part| !relative_is_global(part))
+ {
+ return Err(StyleError {
+ message: ":global(...) can be at the start or end of a selector sequence, but not in the middle.".into(),
+ offset: complex.parts[index].start,
+ });
+ }
+ }
+ let ptr = rule as *const Rule as usize;
+ analyze_items_inner(&mut rule.block.items, Some(ptr))?;
+ }
+ Item::At(at) => {
+ if let Some(block) = &mut at.block {
+ analyze_items_inner(&mut block.items, parent)?;
+ }
+ }
+ Item::Decl(_) => {}
+ }
+ }
+ Ok(())
+}
+
+fn analyze_pseudos(simple: &mut [Simple]) -> Result<(), StyleError> {
+ for selector in simple {
+ let Simple::Pseudo(pseudo) = selector else {
+ continue;
+ };
+ if let Some(args) = &mut pseudo.args {
+ for complex in &mut args.selectors {
+ for relative in &mut complex.parts {
+ relative.global = relative_is_global(relative);
+ relative.global_like = relative.simple.iter().all(|simple| {
+ matches!(
+ simple,
+ Simple::Pseudo(Pseudo { name, .. }) if name == "root" || name == "host"
+ )
+ });
+ analyze_pseudos(&mut relative.simple)?;
+ }
+ complex.used = complex.parts.iter().all(|x| x.global || x.global_like);
+ }
+ if pseudo.name == "global"
+ && args.selectors.iter().any(|complex| {
+ complex
+ .parts
+ .iter()
+ .any(|part| part.simple.iter().any(is_bare_global))
+ })
+ {
+ return Err(StyleError {
+ message: "A :global selector cannot be inside a pseudoclass.".into(),
+ offset: pseudo.start,
+ });
+ }
+ }
+ }
+ Ok(())
+}
+
+fn is_bare_global(simple: &Simple) -> bool {
+ matches!(simple, Simple::Pseudo(Pseudo { name, args: None, .. }) if name == "global")
+}
+
+fn relative_is_global(relative: &Relative) -> bool {
+ let Some(Simple::Pseudo(first)) = relative.simple.first() else {
+ return false;
+ };
+ first.name == "global"
+ && (first.args.is_none()
+ || relative
+ .simple
+ .iter()
+ .all(|x| matches!(x, Simple::Pseudo(_) | Simple::PseudoElement(..))))
+}
+
+pub(super) fn collect_class_map(
+ items: &mut [Item],
+ entries: &mut BTreeMap,
+) {
+ for item in items {
+ match item {
+ Item::Rule(rule) => {
+ for complex in &mut rule.selectors.selectors {
+ collect_complex_classes(complex, entries);
+ }
+ collect_class_map(&mut rule.block.items, entries);
+ }
+ Item::At(at) => {
+ if let Some(block) = &mut at.block {
+ collect_class_map(&mut block.items, entries);
+ }
+ }
+ Item::Decl(_) => {}
+ }
+ }
+}
+
+fn collect_complex_classes(
+ complex: &mut Complex,
+ entries: &mut BTreeMap,
+) -> bool {
+ let mut contains_class_map_selector = false;
+ if complex.parts.len() == 1 {
+ let relative = &complex.parts[0];
+ if !relative.global
+ && !relative.global_like
+ && relative.simple.len() == 1
+ && let Simple::Class(name, start, end) = &relative.simple[0]
+ {
+ contains_class_map_selector = true;
+ entries
+ .entry(unescape_ident(name))
+ .or_insert((*start, *end));
+ }
+ }
+ for part in &mut complex.parts {
+ for simple in &mut part.simple {
+ if let Simple::Pseudo(Pseudo {
+ args: Some(args), ..
+ }) = simple
+ {
+ for child in &mut args.selectors {
+ contains_class_map_selector |= collect_complex_classes(child, entries);
+ }
+ }
+ }
+ }
+ complex.class_map |= contains_class_map_selector;
+ contains_class_map_selector
+}
+
+pub(super) fn prepare_expression(items: &mut [Item]) {
+ fn walk(items: &mut [Item], nested_rule: bool) {
+ for item in items {
+ match item {
+ Item::Rule(rule) => {
+ for complex in &mut rule.selectors.selectors {
+ complex.used = nested_rule || complex.class_map || rule.global_block;
+ mark_scoped(complex);
+ }
+ walk(&mut rule.block.items, true);
+ }
+ Item::At(at) => {
+ if let Some(block) = &mut at.block {
+ walk(&mut block.items, nested_rule);
+ }
+ }
+ Item::Decl(_) => {}
+ }
+ }
+ }
+ walk(items, false);
+}
+
+pub(super) fn preserve_class_map(items: &mut [Item]) {
+ for item in items {
+ match item {
+ Item::Rule(rule) => {
+ for complex in &mut rule.selectors.selectors {
+ if complex.class_map {
+ complex.used = true;
+ mark_scoped(complex);
+ }
+ }
+ preserve_class_map(&mut rule.block.items);
+ }
+ Item::At(at) => {
+ if let Some(block) = &mut at.block {
+ preserve_class_map(&mut block.items);
+ }
+ }
+ Item::Decl(_) => {}
+ }
+ }
+}
+
+fn mark_scoped(complex: &mut Complex) {
+ for part in &mut complex.parts {
+ if !part.global {
+ part.scoped = true;
+ }
+ for simple in &mut part.simple {
+ if let Simple::Pseudo(Pseudo {
+ args: Some(args),
+ name,
+ ..
+ }) = simple
+ && matches!(name.as_str(), "is" | "where" | "has" | "not" | "global")
+ {
+ for child in &mut args.selectors {
+ mark_scoped(child);
+ }
+ }
+ }
+ }
+}
+
+#[derive(Clone)]
+struct FlatElement<'a> {
+ element: &'a Element,
+ parent: Option,
+ children: Vec,
+ siblings: Vec,
+ position: usize,
+}
+
+pub(super) struct Arena<'a> {
+ nodes: Vec>,
+}
+
+impl<'a> Arena<'a> {
+ pub(super) fn from_roots(roots: &'a [Element]) -> Self {
+ let mut arena = Self { nodes: Vec::new() };
+ let root_indexes: Vec<_> = roots
+ .iter()
+ .map(|element| arena.add(element, None))
+ .collect();
+ for (position, index) in root_indexes.iter().copied().enumerate() {
+ arena.nodes[index].siblings = root_indexes.clone();
+ arena.nodes[index].position = position;
+ }
+ arena
+ }
+
+ pub(super) fn len(&self) -> usize {
+ self.nodes.len()
+ }
+
+ fn add(&mut self, element: &'a Element, parent: Option) -> usize {
+ let index = self.nodes.len();
+ self.nodes.push(FlatElement {
+ element,
+ parent,
+ children: Vec::new(),
+ siblings: Vec::new(),
+ position: 0,
+ });
+ let child_indexes: Vec<_> = element
+ .children
+ .iter()
+ .filter_map(|child| match child {
+ ElementChild::Element(child) => Some(self.add(child, Some(index))),
+ ElementChild::Dynamic => None,
+ })
+ .collect();
+ for (position, child) in child_indexes.iter().copied().enumerate() {
+ self.nodes[child].siblings = child_indexes.clone();
+ self.nodes[child].position = position;
+ }
+ self.nodes[index].children = child_indexes;
+ index
+ }
+}
+
+pub(super) fn prune_items(
+ items: &mut [Item],
+ arena: &Arena<'_>,
+ element: usize,
+ scoped: &mut BTreeSet,
+ parent_selectors: Option>,
+) {
+ for item in items {
+ match item {
+ Item::Rule(rule) => {
+ let mut effective = Vec::new();
+ let has_animation = rule_has_animation(rule);
+ for complex in &mut rule.selectors.selectors {
+ let matched = matches_complex(complex, arena, element, &parent_selectors);
+ if matched || has_animation {
+ complex.used = true;
+ scope_matching_parts(complex, scoped, arena.nodes[element].element.id);
+ }
+ effective.push(complex.clone());
+ }
+ prune_items(
+ &mut rule.block.items,
+ arena,
+ element,
+ scoped,
+ Some(effective),
+ );
+ }
+ Item::At(at) => {
+ if let Some(block) = &mut at.block {
+ prune_items(
+ block.items.as_mut_slice(),
+ arena,
+ element,
+ scoped,
+ parent_selectors.clone(),
+ );
+ }
+ }
+ Item::Decl(_) => {}
+ }
+ }
+}
+
+fn rule_has_animation(rule: &Rule) -> bool {
+ rule.block.items.iter().any(|item| {
+ matches!(
+ item,
+ Item::Decl(Declaration { property, .. })
+ if matches!(strip_prefix(property).as_str(), "animation" | "animation-name")
+ )
+ })
+}
+
+fn scope_matching_parts(complex: &mut Complex, scoped: &mut BTreeSet, id: u32) {
+ for part in &mut complex.parts {
+ if part.scoped {
+ scoped.insert(id);
+ }
+ for simple in &mut part.simple {
+ if let Simple::Pseudo(Pseudo {
+ args: Some(args),
+ name,
+ ..
+ }) = simple
+ && matches!(name.as_str(), "is" | "where" | "has")
+ {
+ for child in &mut args.selectors {
+ if child.used {
+ scope_matching_parts(child, scoped, id);
+ }
+ }
+ }
+ }
+ }
+}
+
+fn matches_complex(
+ complex: &mut Complex,
+ arena: &Arena<'_>,
+ element: usize,
+ parent: &Option>,
+) -> bool {
+ let Some(last_local) = complex
+ .parts
+ .iter()
+ .rposition(|part| !part.global && !part.global_like)
+ else {
+ return true;
+ };
+ match_at(complex, last_local, arena, element, parent)
+}
+
+fn match_at(
+ complex: &mut Complex,
+ part: usize,
+ arena: &Arena<'_>,
+ element: usize,
+ parent: &Option>,
+) -> bool {
+ if !compound_matches(&mut complex.parts[part], arena, element, parent) {
+ return false;
+ }
+ let matched = if part == 0 {
+ true
+ } else {
+ let combinator = complex.parts[part]
+ .combinator
+ .as_ref()
+ .map(|x| x.0.as_str())
+ .unwrap_or(" ")
+ .to_string();
+ match combinator.as_str() {
+ ">" => arena.nodes[element]
+ .parent
+ .is_some_and(|parent_el| match_at(complex, part - 1, arena, parent_el, parent)),
+ " " => {
+ let mut ancestor = arena.nodes[element].parent;
+ let mut found = false;
+ while let Some(index) = ancestor {
+ if match_at(complex, part - 1, arena, index, parent) {
+ found = true;
+ break;
+ }
+ ancestor = arena.nodes[index].parent;
+ }
+ found
+ || complex.parts[..part]
+ .iter()
+ .all(|part| part.global || part.global_like)
+ }
+ "+" => {
+ let node = &arena.nodes[element];
+ node.position > 0
+ && match_at(
+ complex,
+ part - 1,
+ arena,
+ node.siblings[node.position - 1],
+ parent,
+ )
+ }
+ "~" => {
+ let node = &arena.nodes[element];
+ node.siblings[..node.position]
+ .iter()
+ .copied()
+ .any(|sibling| match_at(complex, part - 1, arena, sibling, parent))
+ }
+ // `@tsrx/core` deliberately treats unknown combinators, including
+ // the column combinator, as a possible match without traversing
+ // the selector on its other side.
+ _ => true,
+ }
+ };
+ if matched && !complex.parts[part].global && !complex.parts[part].global_like {
+ complex.parts[part].scoped = true;
+ }
+ matched
+}
+
+fn compound_matches(
+ relative: &mut Relative,
+ arena: &Arena<'_>,
+ index: usize,
+ parent: &Option>,
+) -> bool {
+ if relative.global || relative.global_like {
+ return true;
+ }
+ let element = arena.nodes[index].element;
+ for simple in &mut relative.simple {
+ let matches = match simple {
+ Simple::Type(name, ..) => match &element.kind {
+ ElementKind::Native(tag) => name == "*" || tag.eq_ignore_ascii_case(name),
+ ElementKind::Dynamic => true,
+ ElementKind::Component => name == "*",
+ },
+ Simple::Class(name, ..) => attr_matches(
+ element,
+ "class",
+ Some(&unescape_ident(name)),
+ Some("~="),
+ false,
+ ),
+ Simple::Id(name, ..) => {
+ attr_matches(element, "id", Some(&unescape_ident(name)), Some("="), false)
+ }
+ Simple::Attr(attr) => {
+ whitelisted_attr(element, &attr.name)
+ || attr_matches(
+ element,
+ &attr.name,
+ attr.value.as_deref(),
+ attr.op.as_deref(),
+ attr.insensitive,
+ )
+ }
+ Simple::Pseudo(pseudo) if pseudo.name == "root" || pseudo.name == "host" => false,
+ Simple::Pseudo(pseudo) if pseudo.name == "global" => {
+ if let Some(args) = &mut pseudo.args {
+ args.selectors
+ .iter_mut()
+ .any(|complex| matches_complex(complex, arena, index, parent))
+ } else {
+ true
+ }
+ }
+ Simple::Pseudo(pseudo)
+ if matches!(pseudo.name.as_str(), "is" | "where" | "has" | "not") =>
+ {
+ let Some(args) = &mut pseudo.args else {
+ continue;
+ };
+ if pseudo.name == "has" {
+ let descendants = descendants(arena, index);
+ args.selectors.iter_mut().any(|complex| {
+ descendants.iter().copied().any(|descendant| {
+ let matched = matches_complex(complex, arena, descendant, parent);
+ complex.used |= matched;
+ matched
+ })
+ })
+ } else if pseudo.name == "not" {
+ for complex in &mut args.selectors {
+ complex.used = true;
+ }
+ true
+ } else {
+ args.selectors.iter_mut().any(|complex| {
+ let matched = matches_complex(complex, arena, index, parent);
+ complex.used |= matched;
+ matched
+ })
+ }
+ }
+ Simple::Nest(..) => parent.as_ref().is_none_or(|parents| {
+ parents
+ .iter()
+ .cloned()
+ .any(|mut complex| matches_complex(&mut complex, arena, index, &None))
+ }),
+ _ => true,
+ };
+ if !matches {
+ return false;
+ }
+ }
+ true
+}
+
+fn descendants(arena: &Arena<'_>, root: usize) -> Vec {
+ let mut out = Vec::new();
+ let mut stack = arena.nodes[root].children.clone();
+ while let Some(index) = stack.pop() {
+ out.push(index);
+ stack.extend(arena.nodes[index].children.iter().copied());
+ }
+ out
+}
+
+fn attr_matches(
+ element: &Element,
+ name: &str,
+ expected: Option<&str>,
+ op: Option<&str>,
+ insensitive: bool,
+) -> bool {
+ if element.has_spread {
+ return true;
+ }
+ for attr in &element.attributes {
+ let accepted = attr.name.eq_ignore_ascii_case(name)
+ || attr.name.eq_ignore_ascii_case(&format!("${name}"))
+ || (name.eq_ignore_ascii_case("class") && attr.name.eq_ignore_ascii_case("className"));
+ if !accepted {
+ continue;
+ }
+ let Some(expected) = expected else {
+ return true;
+ };
+ let Some(AttributeValue::Static(actual)) = &attr.value else {
+ return true;
+ };
+ let (mut expected, mut actual) = (expected.to_string(), actual.to_string());
+ if insensitive {
+ expected.make_ascii_lowercase();
+ actual.make_ascii_lowercase();
+ }
+ return match op {
+ Some("=") => actual == expected,
+ Some("~=") => actual.split_whitespace().any(|x| x == expected),
+ Some("|=") => actual == expected || actual.starts_with(&format!("{expected}-")),
+ Some("^=") => actual.starts_with(&expected),
+ Some("$=") => actual.ends_with(&expected),
+ Some("*=") => actual.contains(&expected),
+ _ => true,
+ };
+ }
+ false
+}
+
+fn whitelisted_attr(element: &Element, attr: &str) -> bool {
+ let ElementKind::Native(tag) = &element.kind else {
+ return false;
+ };
+ matches!(
+ (
+ tag.to_ascii_lowercase().as_str(),
+ attr.to_ascii_lowercase().as_str()
+ ),
+ ("details" | "dialog", "open")
+ | ("form", "novalidate")
+ | (
+ "iframe",
+ "allow" | "allowfullscreen" | "allowpaymentrequest" | "loading" | "referrerpolicy"
+ )
+ | ("img", "loading")
+ | (
+ "input",
+ "accept"
+ | "autocomplete"
+ | "capture"
+ | "checked"
+ | "disabled"
+ | "max"
+ | "maxlength"
+ | "min"
+ | "minlength"
+ | "multiple"
+ | "pattern"
+ | "placeholder"
+ | "readonly"
+ | "required"
+ | "size"
+ | "step"
+ )
+ | ("object", "typemustmatch")
+ | ("ol", "reversed" | "start" | "type")
+ | ("optgroup", "disabled")
+ | ("option", "selected" | "disabled")
+ | ("script", "async" | "defer" | "nomodule" | "type")
+ | ("select", "disabled" | "required" | "multiple" | "size")
+ | (
+ "textarea",
+ "autocomplete"
+ | "disabled"
+ | "maxlength"
+ | "minlength"
+ | "placeholder"
+ | "readonly"
+ | "required"
+ | "rows"
+ | "wrap"
+ )
+ | (
+ "video",
+ "autoplay" | "controls" | "loop" | "muted" | "playsinline"
+ )
+ )
+}
+
+pub(super) fn collect_keyframes(items: &[Item]) -> BTreeSet {
+ fn walk(items: &[Item], global: bool, out: &mut BTreeSet) {
+ for item in items {
+ match item {
+ Item::At(at) if strip_prefix(&at.name) == "keyframes" => {
+ if !global && !at.prelude.starts_with("-global-") {
+ out.insert(at.prelude.clone());
+ }
+ }
+ Item::At(at) => {
+ if let Some(block) = &at.block {
+ walk(&block.items, global, out);
+ }
+ }
+ Item::Rule(rule) => walk(&rule.block.items, global || rule.global_block, out),
+ Item::Decl(_) => {}
+ }
+ }
+ }
+ let mut out = BTreeSet::new();
+ walk(items, false, &mut out);
+ out
+}
diff --git a/packages/compiler/src/tsrx/style/hash.rs b/packages/compiler/src/tsrx/style/hash.rs
new file mode 100644
index 000000000..22e8e1949
--- /dev/null
+++ b/packages/compiler/src/tsrx/style/hash.rs
@@ -0,0 +1,70 @@
+pub(super) fn sha256(input: &[u8]) -> String {
+ const K: [u32; 64] = [
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
+ 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
+ 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
+ 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
+ 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
+ 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
+ 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
+ 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
+ 0xc67178f2,
+ ];
+ let mut data = input.to_vec();
+ let bits = (data.len() as u64) * 8;
+ data.push(0x80);
+ while data.len() % 64 != 56 {
+ data.push(0);
+ }
+ data.extend_from_slice(&bits.to_be_bytes());
+ let mut h = [
+ 0x6a09e667u32,
+ 0xbb67ae85,
+ 0x3c6ef372,
+ 0xa54ff53a,
+ 0x510e527f,
+ 0x9b05688c,
+ 0x1f83d9ab,
+ 0x5be0cd19,
+ ];
+ for chunk in data.chunks_exact(64) {
+ let mut w = [0u32; 64];
+ for (i, word) in w[..16].iter_mut().enumerate() {
+ *word = u32::from_be_bytes(chunk[i * 4..i * 4 + 4].try_into().unwrap());
+ }
+ for i in 16..64 {
+ let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
+ let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
+ w[i] = w[i - 16]
+ .wrapping_add(s0)
+ .wrapping_add(w[i - 7])
+ .wrapping_add(s1);
+ }
+ let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh] = h;
+ for i in 0..64 {
+ let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
+ let ch = (e & f) ^ (!e & g);
+ let t1 = hh
+ .wrapping_add(s1)
+ .wrapping_add(ch)
+ .wrapping_add(K[i])
+ .wrapping_add(w[i]);
+ let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
+ let maj = (a & b) ^ (a & c) ^ (b & c);
+ let t2 = s0.wrapping_add(maj);
+ hh = g;
+ g = f;
+ f = e;
+ e = d.wrapping_add(t1);
+ d = c;
+ c = b;
+ b = a;
+ a = t1.wrapping_add(t2);
+ }
+ for (x, y) in h.iter_mut().zip([a, b, c, d, e, f, g, hh]) {
+ *x = x.wrapping_add(y);
+ }
+ }
+ h.iter().map(|x| format!("{x:08x}")).collect()
+}
diff --git a/packages/compiler/src/tsrx/style/mod.rs b/packages/compiler/src/tsrx/style/mod.rs
new file mode 100644
index 000000000..ac2ca5fab
--- /dev/null
+++ b/packages/compiler/src/tsrx/style/mod.rs
@@ -0,0 +1,334 @@
+//! Self-contained TSRX scoped-CSS semantics.
+//!
+//! This module deliberately has no dependency on the TSRX/Oxc frontend. The
+//! frontend only needs to translate its template into [`Element`] values.
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::fmt;
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct StyleLocation<'a> {
+ pub filename: &'a str,
+ pub line: u32,
+ pub column: u32,
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum StyleKind {
+ #[default]
+ Block,
+ Expression,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum ElementKind {
+ Native(String),
+ Dynamic,
+ Component,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum AttributeValue {
+ Static(String),
+ Dynamic,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Attribute {
+ pub name: String,
+ pub value: Option,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum ElementChild {
+ Element(Element),
+ /// An expression or control-flow boundary which can produce arbitrary
+ /// elements. Matching across it must be conservative.
+ Dynamic,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Element {
+ /// Stable caller-owned identity, returned in `scoped_elements`.
+ pub id: u32,
+ pub kind: ElementKind,
+ pub attributes: Vec,
+ pub has_spread: bool,
+ pub children: Vec,
+}
+
+#[cfg(test)]
+impl Element {
+ pub fn native(id: u32, tag: impl Into) -> Self {
+ Self {
+ id,
+ kind: ElementKind::Native(tag.into()),
+ attributes: Vec::new(),
+ has_spread: false,
+ children: Vec::new(),
+ }
+ }
+
+ pub fn with_static_attr(mut self, name: impl Into, value: impl Into) -> Self {
+ self.attributes.push(Attribute {
+ name: name.into(),
+ value: Some(AttributeValue::Static(value.into())),
+ });
+ self
+ }
+}
+
+#[derive(Clone, Copy, Debug)]
+pub struct StyleInput<'a> {
+ pub css: &'a str,
+ pub location: StyleLocation<'a>,
+ pub elements: &'a [Element],
+ pub kind: StyleKind,
+ pub minify: bool,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct ClassMapEntry {
+ pub class_name: String,
+ pub value: String,
+ pub start: usize,
+ pub end: usize,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct StyleOutput {
+ pub css: String,
+ pub hash: String,
+ /// Lexicographically sorted, matching `build_style_class_map`.
+ pub class_map: Vec,
+ /// Native/dynamic element ids that need the hash class.
+ pub scoped_elements: BTreeSet,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct StyleError {
+ pub message: String,
+ pub offset: usize,
+}
+
+impl fmt::Display for StyleError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{} at CSS byte {}", self.message, self.offset)
+ }
+}
+
+impl std::error::Error for StyleError {}
+
+pub fn compile_style(input: StyleInput<'_>) -> Result {
+ compile_style_with_class_map_selectors(input, false)
+}
+
+/// Compile while preserving every standalone class selector exposed by the
+/// returned class map. A frontend should set this for a free-standing style
+/// block with a `ref`, because the referenced map can apply those classes
+/// outside the statically visible element tree.
+pub fn compile_style_with_class_map_selectors(
+ input: StyleInput<'_>,
+ preserve_class_map_selectors: bool,
+) -> Result {
+ let hash_source = format!(
+ "{}:{}:{}:{}",
+ input.location.filename, input.location.line, input.location.column, input.css
+ )
+ .replace('\r', "");
+ let hash = format!("tsrx-{}", &sha256(hash_source.as_bytes())[..8]);
+ let mut sheet = Parser::new(input.css).parse()?;
+ analyze_items(&mut sheet.items, None)?;
+
+ let mut class_entries = BTreeMap::new();
+ collect_class_map(&mut sheet.items, &mut class_entries);
+ let arena = Arena::from_roots(input.elements);
+ let mut scoped_elements = BTreeSet::new();
+
+ match input.kind {
+ StyleKind::Expression => prepare_expression(&mut sheet.items),
+ StyleKind::Block => {
+ for index in 0..arena.len() {
+ prune_items(&mut sheet.items, &arena, index, &mut scoped_elements, None);
+ }
+ if preserve_class_map_selectors {
+ preserve_class_map(&mut sheet.items);
+ }
+ }
+ }
+
+ let local_keyframes = collect_keyframes(&sheet.items);
+ let mut render = Render {
+ source: input.css,
+ hash: &hash,
+ minify: input.minify,
+ local_keyframes: &local_keyframes,
+ };
+ let css = if let (Some(first), Some(last)) = (sheet.items.first(), sheet.items.last()) {
+ format!(
+ "{}{}{}",
+ &input.css[..first.span().0],
+ render.items(&sheet.items, false),
+ &input.css[last.span().1..]
+ )
+ } else {
+ input.css.to_string()
+ };
+ let class_map = class_entries
+ .into_iter()
+ .map(|(class_name, (start, end))| ClassMapEntry {
+ value: format!("{hash} {class_name}"),
+ class_name,
+ start,
+ end,
+ })
+ .collect();
+ Ok(StyleOutput {
+ css,
+ hash,
+ class_map,
+ scoped_elements,
+ })
+}
+
+#[derive(Clone, Debug)]
+struct Sheet {
+ items: Vec- ,
+}
+
+#[derive(Clone, Debug)]
+enum Item {
+ Rule(Rule),
+ At(AtRule),
+ Decl(Declaration),
+}
+
+impl Item {
+ fn span(&self) -> (usize, usize) {
+ match self {
+ Self::Rule(x) => (x.start, x.end),
+ Self::At(x) => (x.start, x.end),
+ Self::Decl(x) => (x.start, x.end_with_semicolon),
+ }
+ }
+}
+
+#[derive(Clone, Debug)]
+struct Rule {
+ start: usize,
+ end: usize,
+ selectors: SelectorList,
+ block: Block,
+ parent: Option
,
+ global_block: bool,
+}
+
+#[derive(Clone, Debug)]
+struct AtRule {
+ start: usize,
+ end: usize,
+ name: String,
+ prelude: String,
+ prelude_start: usize,
+ block: Option,
+}
+
+#[derive(Clone, Debug)]
+struct Declaration {
+ start: usize,
+ end_with_semicolon: usize,
+ property: String,
+ value: String,
+ value_start: usize,
+}
+
+#[derive(Clone, Debug)]
+struct Block {
+ start: usize,
+ end: usize,
+ items: Vec- ,
+}
+
+#[derive(Clone, Debug)]
+struct SelectorList {
+ start: usize,
+ end: usize,
+ selectors: Vec
,
+}
+
+#[derive(Clone, Debug)]
+struct Complex {
+ start: usize,
+ end: usize,
+ parts: Vec,
+ used: bool,
+ class_map: bool,
+}
+
+#[derive(Clone, Debug)]
+struct Relative {
+ start: usize,
+ combinator: Option<(String, usize, usize)>,
+ simple: Vec,
+ global: bool,
+ global_like: bool,
+ scoped: bool,
+}
+
+#[derive(Clone, Debug)]
+enum Simple {
+ Type(String, usize, usize),
+ Class(String, usize, usize),
+ Id(String, usize, usize),
+ Attr(AttrSelector),
+ Pseudo(Pseudo),
+ PseudoElement(usize, usize),
+ Nest(usize, usize),
+ Other(usize, usize),
+}
+
+impl Simple {
+ fn span(&self) -> (usize, usize) {
+ match self {
+ Self::Type(_, a, b)
+ | Self::Class(_, a, b)
+ | Self::Id(_, a, b)
+ | Self::PseudoElement(a, b)
+ | Self::Nest(a, b)
+ | Self::Other(a, b) => (*a, *b),
+ Self::Attr(x) => (x.start, x.end),
+ Self::Pseudo(x) => (x.start, x.end),
+ }
+ }
+}
+
+#[derive(Clone, Debug)]
+struct AttrSelector {
+ start: usize,
+ end: usize,
+ name: String,
+ op: Option,
+ value: Option,
+ insensitive: bool,
+}
+
+#[derive(Clone, Debug)]
+struct Pseudo {
+ start: usize,
+ end: usize,
+ name: String,
+ args: Option,
+}
+
+mod analysis;
+mod hash;
+mod parser;
+mod render;
+
+#[cfg(test)]
+mod tests;
+
+use analysis::*;
+use hash::sha256;
+use parser::Parser;
+use render::Render;
diff --git a/packages/compiler/src/tsrx/style/parser.rs b/packages/compiler/src/tsrx/style/parser.rs
new file mode 100644
index 000000000..22184b537
--- /dev/null
+++ b/packages/compiler/src/tsrx/style/parser.rs
@@ -0,0 +1,541 @@
+use super::*;
+
+pub(super) struct Parser<'a> {
+ src: &'a str,
+ i: usize,
+}
+
+impl<'a> Parser<'a> {
+ pub(super) fn new(src: &'a str) -> Self {
+ Self { src, i: 0 }
+ }
+
+ pub(super) fn parse(mut self) -> Result {
+ let items = self.body(None)?;
+ Ok(Sheet { items })
+ }
+
+ fn err(&self, message: impl Into) -> Result {
+ Err(StyleError {
+ message: message.into(),
+ offset: self.i,
+ })
+ }
+
+ fn body(&mut self, close: Option) -> Result, StyleError> {
+ let mut out = Vec::new();
+ loop {
+ self.trivia()?;
+ if self.i >= self.src.len() || close.is_some_and(|c| self.byte() == c) {
+ return Ok(out);
+ }
+ out.push(if self.byte() == b'@' {
+ Item::At(self.at_rule()?)
+ } else if close.is_some() {
+ self.block_item()?
+ } else {
+ Item::Rule(self.rule()?)
+ });
+ }
+ }
+
+ fn trivia(&mut self) -> Result<(), StyleError> {
+ loop {
+ while self.i < self.src.len() && self.byte().is_ascii_whitespace() {
+ self.i += 1;
+ }
+ if self.rest().starts_with("/*") {
+ let Some(n) = self.rest()[2..].find("*/") else {
+ return self.err("Unclosed CSS comment");
+ };
+ self.i += n + 4;
+ } else if self.rest().starts_with("") else {
+ return self.err("Unclosed HTML comment");
+ };
+ self.i += n + 7;
+ } else {
+ return Ok(());
+ }
+ }
+ }
+
+ fn block_item(&mut self) -> Result- {
+ if self.byte() == b'@' {
+ return Ok(Item::At(self.at_rule()?));
+ }
+ let save = self.i;
+ let (_, term) = self.value_until()?;
+ self.i = save;
+ if term == b'{' {
+ Ok(Item::Rule(self.rule()?))
+ } else {
+ Ok(Item::Decl(self.declaration()?))
+ }
+ }
+
+ fn rule(&mut self) -> Result
{
+ let start = self.i;
+ let selectors = self.selector_list(false)?;
+ let block = self.block()?;
+ Ok(Rule {
+ start,
+ end: block.end,
+ selectors,
+ block,
+ parent: None,
+ global_block: false,
+ })
+ }
+
+ fn at_rule(&mut self) -> Result {
+ let start = self.i;
+ self.i += 1;
+ let name = self.ident()?;
+ let prelude_start = self.i;
+ let (prelude, term) = self.value_until()?;
+ let block = if term == b'{' {
+ Some(self.block()?)
+ } else {
+ self.i += 1;
+ None
+ };
+ let end = block.as_ref().map_or(self.i, |x| x.end);
+ Ok(AtRule {
+ start,
+ end,
+ name,
+ prelude,
+ prelude_start,
+ block,
+ })
+ }
+
+ fn block(&mut self) -> Result {
+ if self.i >= self.src.len() || self.byte() != b'{' {
+ return self.err("Expected `{`");
+ }
+ let start = self.i;
+ self.i += 1;
+ let items = self.body(Some(b'}'))?;
+ if self.i >= self.src.len() {
+ return self.err("Expected `}`");
+ }
+ self.i += 1;
+ Ok(Block {
+ start,
+ end: self.i,
+ items,
+ })
+ }
+
+ fn declaration(&mut self) -> Result {
+ let start = self.i;
+ while self.i < self.src.len() && !self.byte().is_ascii_whitespace() && self.byte() != b':' {
+ self.i += 1;
+ }
+ let property = self.src[start..self.i].to_string();
+ self.ws();
+ if self.i < self.src.len() && self.byte() == b':' {
+ self.i += 1;
+ }
+ self.ws();
+ let value_start = self.i;
+ let (value, term) = self.value_until()?;
+ if value.is_empty() && !property.starts_with("--") {
+ return self.err("CSS Declaration cannot be empty");
+ }
+ if term == b';' {
+ self.i += 1;
+ }
+ Ok(Declaration {
+ start,
+ end_with_semicolon: self.i,
+ property,
+ value,
+ value_start,
+ })
+ }
+
+ fn value_until(&mut self) -> Result<(String, u8), StyleError> {
+ let start = self.i;
+ let mut quote = 0;
+ let mut escaped = false;
+ let mut parens = 0usize;
+ while self.i < self.src.len() {
+ let b = self.byte();
+ if escaped {
+ escaped = false;
+ } else if b == b'\\' {
+ escaped = true;
+ } else if quote != 0 {
+ if b == quote {
+ quote = 0;
+ }
+ } else if b == b'\'' || b == b'"' {
+ quote = b;
+ } else if b == b'(' {
+ parens += 1;
+ } else if b == b')' && parens > 0 {
+ parens -= 1;
+ } else if parens == 0 && matches!(b, b';' | b'{' | b'}') {
+ return Ok((self.src[start..self.i].trim().to_string(), b));
+ }
+ self.i += 1;
+ }
+ self.err("Unexpected end of CSS")
+ }
+
+ fn selector_list(&mut self, in_pseudo: bool) -> Result {
+ self.trivia()?;
+ let start = self.i;
+ let mut selectors = Vec::new();
+ loop {
+ selectors.push(self.complex(in_pseudo)?);
+ let end = self.i;
+ self.trivia()?;
+ let terminal = if in_pseudo { b')' } else { b'{' };
+ if self.i < self.src.len() && self.byte() == terminal {
+ return Ok(SelectorList {
+ start,
+ end,
+ selectors,
+ });
+ }
+ if self.i >= self.src.len() || self.byte() != b',' {
+ return self.err("Expected `,` in selector list");
+ }
+ self.i += 1;
+ self.trivia()?;
+ }
+ }
+
+ fn complex(&mut self, in_pseudo: bool) -> Result {
+ let start = self.i;
+ let mut parts = Vec::new();
+ let mut combinator = None;
+ loop {
+ let part_start = combinator
+ .as_ref()
+ .map_or(self.i, |x: &(String, usize, usize)| x.1);
+ let mut simple = Vec::new();
+ loop {
+ if self.i >= self.src.len() {
+ return self.err("Unexpected end of selector");
+ }
+ let b = self.byte();
+ if b == b','
+ || b == b'{'
+ || (in_pseudo && b == b')')
+ || self.rest().starts_with("||")
+ || b.is_ascii_whitespace()
+ || matches!(b, b'>' | b'+' | b'~')
+ {
+ break;
+ }
+ if in_pseudo && let Some(end) = self.nth_prefix_end() {
+ simple.push(Simple::Other(self.i, end));
+ self.i = end;
+ } else {
+ simple.push(self.simple()?);
+ }
+ }
+ let part_end = self.i;
+ if !simple.is_empty() {
+ parts.push(Relative {
+ start: part_start,
+ combinator: combinator.take(),
+ simple,
+ global: false,
+ global_like: false,
+ scoped: false,
+ });
+ }
+ let before_ws = self.i;
+ self.ws();
+ if self.i >= self.src.len()
+ || self.byte() == b','
+ || self.byte() == b'{'
+ || (in_pseudo && self.byte() == b')')
+ {
+ return Ok(Complex {
+ start,
+ end: part_end,
+ parts,
+ used: false,
+ class_map: false,
+ });
+ }
+ let (name, a, b) = if self.rest().starts_with("||") {
+ let a = self.i;
+ self.i += 2;
+ let b = self.i;
+ self.ws();
+ ("||".into(), a, b)
+ } else if matches!(self.byte(), b'>' | b'+' | b'~') {
+ let a = self.i;
+ let name = (self.byte() as char).to_string();
+ self.i += 1;
+ let b = self.i;
+ self.ws();
+ (name, a, b)
+ } else if self.i > before_ws {
+ (" ".into(), before_ws, self.i)
+ } else {
+ return self.err("Invalid selector");
+ };
+ combinator = Some((name, a, b));
+ }
+ }
+
+ fn simple(&mut self) -> Result {
+ let start = self.i;
+ match self.byte() {
+ b'&' => {
+ self.i += 1;
+ Ok(Simple::Nest(start, self.i))
+ }
+ b'.' | b'#' => {
+ let kind = self.byte();
+ self.i += 1;
+ let name = self.ident()?;
+ Ok(if kind == b'.' {
+ Simple::Class(name, start, self.i)
+ } else {
+ Simple::Id(name, start, self.i)
+ })
+ }
+ b'[' => self.attribute(),
+ b':' => self.pseudo(),
+ b'*' => {
+ self.i += 1;
+ Ok(Simple::Type("*".into(), start, self.i))
+ }
+ b if b.is_ascii_digit() => {
+ while self.i < self.src.len()
+ && (self.byte().is_ascii_digit() || self.byte() == b'.' || self.byte() == b'%')
+ {
+ self.i += 1;
+ }
+ Ok(Simple::Other(start, self.i))
+ }
+ _ => {
+ let mut name = self.ident()?;
+ if self.i < self.src.len() && self.byte() == b'|' {
+ self.i += 1;
+ name = self.ident()?;
+ }
+ Ok(Simple::Type(name, start, self.i))
+ }
+ }
+ }
+
+ /// End of the grammar's `Nth` token. It includes the whitespace after
+ /// `of`, exactly like `REGEX_NTH_OF` in `@tsrx/core`.
+ fn nth_prefix_end(&self) -> Option {
+ let rest = self.rest();
+ let lower = rest.to_ascii_lowercase();
+ for keyword in ["even", "odd"] {
+ if lower.starts_with(keyword) {
+ let end = self.i + keyword.len();
+ return nth_suffix_end(self.src, end);
+ }
+ }
+ let bytes = self.src.as_bytes();
+ let mut at = self.i;
+ if at < bytes.len() && matches!(bytes[at], b'+' | b'-') {
+ at += 1;
+ }
+ let formula_start = at;
+ let mut saw_digit_or_n = false;
+ while at < bytes.len() {
+ let byte = bytes[at];
+ if byte.is_ascii_digit() || byte.eq_ignore_ascii_case(&b'n') {
+ saw_digit_or_n = true;
+ at += 1;
+ } else if matches!(byte, b'+' | b'-') || byte.is_ascii_whitespace() {
+ at += 1;
+ } else {
+ break;
+ }
+ }
+ if at == formula_start || !saw_digit_or_n {
+ return None;
+ }
+ nth_suffix_end(self.src, at)
+ }
+
+ fn pseudo(&mut self) -> Result {
+ let start = self.i;
+ self.i += 1;
+ if self.i < self.src.len() && self.byte() == b':' {
+ self.i += 1;
+ self.ident()?;
+ if self.i < self.src.len() && self.byte() == b'(' {
+ self.skip_balanced()?;
+ }
+ return Ok(Simple::PseudoElement(start, self.i));
+ }
+ let name = self.ident()?;
+ let args = if self.i < self.src.len() && self.byte() == b'(' {
+ self.i += 1;
+ let args = self.selector_list(true)?;
+ if self.i >= self.src.len() || self.byte() != b')' {
+ return self.err("Expected `)`");
+ }
+ self.i += 1;
+ Some(args)
+ } else {
+ None
+ };
+ Ok(Simple::Pseudo(Pseudo {
+ start,
+ end: self.i,
+ name,
+ args,
+ }))
+ }
+
+ fn attribute(&mut self) -> Result {
+ let start = self.i;
+ self.i += 1;
+ self.ws();
+ let name = self.ident()?;
+ self.ws();
+ let op = ["~=", "^=", "$=", "*=", "|=", "="]
+ .into_iter()
+ .find(|op| self.rest().starts_with(op))
+ .map(str::to_string);
+ if let Some(op) = &op {
+ self.i += op.len();
+ }
+ self.ws();
+ let value = if op.is_some() {
+ let quote = if self.i < self.src.len() && matches!(self.byte(), b'\'' | b'"') {
+ let q = self.byte();
+ self.i += 1;
+ Some(q)
+ } else {
+ None
+ };
+ let a = self.i;
+ while self.i < self.src.len()
+ && quote.map_or(
+ !self.byte().is_ascii_whitespace() && self.byte() != b']',
+ |q| self.byte() != q,
+ )
+ {
+ if self.byte() == b'\\' && self.i + 1 < self.src.len() {
+ self.i += 1;
+ }
+ self.i += 1;
+ }
+ let v = self.src[a..self.i].to_string();
+ if quote.is_some() {
+ self.i += 1;
+ }
+ Some(v)
+ } else {
+ None
+ };
+ self.ws();
+ let flag_start = self.i;
+ while self.i < self.src.len() && self.byte().is_ascii_alphabetic() {
+ self.i += 1;
+ }
+ let insensitive = self.src[flag_start..self.i].contains('i');
+ self.ws();
+ if self.i >= self.src.len() || self.byte() != b']' {
+ return self.err("Expected `]`");
+ }
+ self.i += 1;
+ Ok(Simple::Attr(AttrSelector {
+ start,
+ end: self.i,
+ name,
+ op,
+ value,
+ insensitive,
+ }))
+ }
+
+ fn skip_balanced(&mut self) -> Result<(), StyleError> {
+ let mut depth = 0;
+ while self.i < self.src.len() {
+ match self.byte() {
+ b'(' => depth += 1,
+ b')' => {
+ depth -= 1;
+ if depth == 0 {
+ self.i += 1;
+ return Ok(());
+ }
+ }
+ b'\\' => self.i += 1,
+ _ => {}
+ }
+ self.i += 1;
+ }
+ self.err("Expected `)`")
+ }
+
+ fn ident(&mut self) -> Result {
+ let start = self.i;
+ if self.i < self.src.len()
+ && (self.byte().is_ascii_digit()
+ || (self.byte() == b'-'
+ && self.i + 1 < self.src.len()
+ && self.src.as_bytes()[self.i + 1].is_ascii_digit()))
+ {
+ return self.err("Unexpected CSS identifier");
+ }
+ while self.i < self.src.len() {
+ let b = self.byte();
+ if b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-') || b >= 0x80 {
+ self.i += 1;
+ } else if b == b'\\' && self.i + 1 < self.src.len() {
+ self.i += 2;
+ } else {
+ break;
+ }
+ }
+ if self.i == start {
+ return self.err("Expected identifier");
+ }
+ Ok(self.src[start..self.i].to_string())
+ }
+
+ fn ws(&mut self) {
+ while self.i < self.src.len() && self.byte().is_ascii_whitespace() {
+ self.i += 1;
+ }
+ }
+ fn byte(&self) -> u8 {
+ self.src.as_bytes()[self.i]
+ }
+ fn rest(&self) -> &str {
+ &self.src[self.i..]
+ }
+}
+
+fn nth_suffix_end(source: &str, mut end: usize) -> Option {
+ let bytes = source.as_bytes();
+ let formula_end = end;
+ while end < bytes.len() && bytes[end].is_ascii_whitespace() {
+ end += 1;
+ }
+ if source[end..].starts_with("of")
+ && end + 2 < bytes.len()
+ && bytes[end + 2].is_ascii_whitespace()
+ {
+ end += 2;
+ while end < bytes.len() && bytes[end].is_ascii_whitespace() {
+ end += 1;
+ }
+ return Some(end);
+ }
+ if end < bytes.len() && matches!(bytes[end], b',' | b')') {
+ return Some(formula_end);
+ }
+ None
+}
diff --git a/packages/compiler/src/tsrx/style/render.rs b/packages/compiler/src/tsrx/style/render.rs
new file mode 100644
index 000000000..291392c32
--- /dev/null
+++ b/packages/compiler/src/tsrx/style/render.rs
@@ -0,0 +1,398 @@
+use super::*;
+
+pub(super) struct Render<'a> {
+ pub(super) source: &'a str,
+ pub(super) hash: &'a str,
+ pub(super) minify: bool,
+ pub(super) local_keyframes: &'a BTreeSet,
+}
+
+impl Render<'_> {
+ pub(super) fn items(&mut self, items: &[Item], global: bool) -> String {
+ if items.is_empty() {
+ return String::new();
+ }
+ let start = items.first().unwrap().span().0;
+ let end = items.last().unwrap().span().1;
+ let mut out = String::new();
+ let mut cursor = start;
+ for item in items {
+ let (a, b) = item.span();
+ out.push_str(&self.source[cursor..a]);
+ out.push_str(&self.item(item, global));
+ cursor = b;
+ }
+ out.push_str(&self.source[cursor..end]);
+ out
+ }
+
+ fn item(&mut self, item: &Item, global: bool) -> String {
+ match item {
+ Item::Rule(rule) => self.rule(rule, global),
+ Item::At(at) => self.at_rule(at, global),
+ Item::Decl(decl) => self.declaration(decl),
+ }
+ }
+
+ fn rule(&mut self, rule: &Rule, in_global: bool) -> String {
+ let used = rule.selectors.selectors.iter().any(|x| x.used);
+ let empty = self.rule_empty(rule);
+ if empty || (!used && !in_global) {
+ let label = if empty { "empty" } else { "unused" };
+ return format!(
+ "/* ({label}) {}*/",
+ escape_comment_close(&self.source[rule.start..rule.end])
+ );
+ }
+ if rule.global_block
+ && rule.selectors.selectors.len() == 1
+ && rule.selectors.selectors[0].parts.len() == 1
+ && rule.selectors.selectors[0].parts[0].simple.len() == 1
+ {
+ let inside = self.block_contents(&rule.block, true);
+ return if self.minify {
+ inside
+ } else {
+ format!(
+ "/* {}*/{}/*{}*/",
+ &self.source[rule.start..=rule.block.start],
+ inside,
+ &self.source[rule.block.end - 1..rule.end]
+ )
+ };
+ }
+ // `@tsrx/core` creates a fresh, unbumped specificity state for every
+ // rule selector list. Its ancestor check is keyed by
+ // `has_local_selectors`, which remains false in the analyzer; nested
+ // implicit selectors therefore receive `.hash`, not `:where(.hash)`.
+ let mut specificity = false;
+ let selectors = self.selector_list(
+ &rule.selectors,
+ in_global,
+ &mut specificity,
+ rule.parent.is_some(),
+ );
+ let body = self.block_contents(&rule.block, in_global || rule.global_block);
+ format!(
+ "{}{}{}",
+ selectors,
+ &self.source[rule.selectors.end..=rule.block.start],
+ body + &self.source[rule.block.end - 1..rule.end]
+ )
+ }
+
+ fn rule_empty(&self, rule: &Rule) -> bool {
+ if rule.global_block {
+ return rule.block.items.is_empty();
+ }
+ rule.block.items.iter().all(|item| match item {
+ Item::Decl(_) => false,
+ Item::Rule(child) => {
+ !child.selectors.selectors.iter().any(|x| x.used) || self.rule_empty(child)
+ }
+ Item::At(at) => at.block.as_ref().is_some_and(|x| x.items.is_empty()),
+ })
+ }
+
+ fn block_contents(&mut self, block: &Block, global: bool) -> String {
+ if block.items.is_empty() {
+ return self.source[block.start + 1..block.end - 1].to_string();
+ }
+ let mut out = String::new();
+ let mut cursor = block.start + 1;
+ for item in &block.items {
+ let (a, b) = item.span();
+ out.push_str(&self.source[cursor..a]);
+ out.push_str(&self.item(item, global));
+ cursor = b;
+ }
+ out.push_str(&self.source[cursor..block.end - 1]);
+ out
+ }
+
+ fn at_rule(&mut self, at: &AtRule, global: bool) -> String {
+ if strip_prefix(&at.name) == "keyframes" {
+ let raw = &self.source[at.start..at.end];
+ let name_start = at.prelude_start
+ + self.source[at.prelude_start..]
+ .find(|c: char| !c.is_whitespace())
+ .unwrap_or(0);
+ let authored = at.prelude.trim();
+ let rendered = if let Some(rendered) = authored.strip_prefix("-global-") {
+ rendered
+ } else if !global {
+ return replace_range(
+ raw,
+ name_start - at.start,
+ name_start - at.start,
+ &format!("{}-", self.hash),
+ );
+ } else {
+ authored
+ };
+ return replace_range(
+ raw,
+ name_start - at.start,
+ name_start - at.start + authored.len(),
+ rendered,
+ );
+ }
+ let Some(block) = &at.block else {
+ return self.source[at.start..at.end].to_string();
+ };
+ format!(
+ "{}{}{}",
+ &self.source[at.start..=block.start],
+ self.block_contents(block, global),
+ &self.source[block.end - 1..at.end]
+ )
+ }
+
+ fn declaration(&self, decl: &Declaration) -> String {
+ let mut raw = self.source[decl.start..decl.end_with_semicolon].to_string();
+ if matches!(
+ strip_prefix(&decl.property).as_str(),
+ "animation" | "animation-name"
+ ) {
+ let value_offset = decl.value_start - decl.start;
+ let rewritten = rewrite_animation(&decl.value, self.hash, self.local_keyframes);
+ raw = replace_range(
+ &raw,
+ value_offset,
+ value_offset + decl.value.len(),
+ &rewritten,
+ );
+ }
+ raw
+ }
+
+ fn selector_list(
+ &mut self,
+ list: &SelectorList,
+ global: bool,
+ specificity: &mut bool,
+ nested_rule: bool,
+ ) -> String {
+ let mut out = String::new();
+ let mut cursor = list.start;
+ let mut pruning = false;
+ for (index, complex) in list.selectors.iter().enumerate() {
+ let used = complex.used || global;
+ if !used && !pruning {
+ if self.minify {
+ // The run is omitted below, including one separator.
+ } else if index == 0 {
+ out.push_str(&self.source[cursor..complex.start]);
+ out.push_str("/* (unused) ");
+ } else {
+ out.push_str(" /* (unused) ");
+ }
+ cursor = complex.start;
+ pruning = true;
+ } else if used && pruning {
+ let separator = &self.source[cursor..complex.start];
+ let comma = separator.rfind(',').unwrap_or(0);
+ if !self.minify {
+ out.push_str(&self.source[cursor..cursor + comma]);
+ out.push_str("*/");
+ out.push_str(&separator[comma..]);
+ } else if index > 0 && !out.trim_end().ends_with(',') {
+ out.push(',');
+ }
+ cursor = complex.start;
+ pruning = false;
+ }
+ if used {
+ out.push_str(&self.source[cursor..complex.start]);
+ let mut complex_specificity = *specificity;
+ out.push_str(&self.complex(complex, global, &mut complex_specificity, nested_rule));
+ cursor = complex.end;
+ } else if !self.minify {
+ out.push_str(&escape_comment_close(&self.source[cursor..complex.end]));
+ cursor = complex.end;
+ } else {
+ cursor = complex.end;
+ }
+ }
+ if pruning {
+ if !self.minify {
+ out.push_str("*/");
+ }
+ } else {
+ out.push_str(&self.source[cursor..list.end]);
+ }
+ out
+ }
+
+ fn complex(
+ &mut self,
+ complex: &Complex,
+ global: bool,
+ specificity: &mut bool,
+ nested_rule: bool,
+ ) -> String {
+ let mut edits: Vec<(usize, usize, String)> = Vec::new();
+ for relative in &complex.parts {
+ for simple in &relative.simple {
+ self.simple_edits(
+ simple,
+ relative,
+ global,
+ specificity,
+ nested_rule,
+ &mut edits,
+ );
+ }
+ if relative.scoped && !global && !relative.global && !relative.global_like {
+ let modifier = if *specificity {
+ format!(":where(.{})", self.hash)
+ } else {
+ *specificity = true;
+ format!(".{}", self.hash)
+ };
+ if !(relative
+ .simple
+ .iter()
+ .any(|x| matches!(x, Simple::Nest(..)))
+ || relative.simple.len() == 1
+ && matches!(
+ &relative.simple[0],
+ Simple::Pseudo(Pseudo { name, .. }) if name == "is" || name == "where"
+ ))
+ {
+ if let Some(target) = relative
+ .simple
+ .iter()
+ .rev()
+ .find(|x| !matches!(x, Simple::Pseudo(_) | Simple::PseudoElement(..)))
+ {
+ let (a, b) = target.span();
+ if matches!(target, Simple::Type(name, ..) if name == "*") {
+ edits.push((a, b, modifier));
+ } else {
+ edits.push((b, b, modifier));
+ }
+ } else if let Some(first) = relative.simple.first() {
+ edits.push((first.span().0, first.span().0, modifier));
+ }
+ }
+ }
+ }
+ apply_edits(self.source, complex.start, complex.end, edits)
+ }
+
+ fn simple_edits(
+ &mut self,
+ simple: &Simple,
+ relative: &Relative,
+ global: bool,
+ specificity: &mut bool,
+ nested_rule: bool,
+ edits: &mut Vec<(usize, usize, String)>,
+ ) {
+ let Simple::Pseudo(pseudo) = simple else {
+ return;
+ };
+ if pseudo.name == "global" {
+ if let Some(args) = &pseudo.args {
+ edits.push((pseudo.start, args.start, String::new()));
+ edits.push((args.end, pseudo.end, String::new()));
+ } else {
+ let start = relative
+ .combinator
+ .as_ref()
+ .filter(|(name, _, _)| name == " ")
+ .map_or(pseudo.start, |(_, start, _)| *start);
+ let replacement = if nested_rule && relative.combinator.is_none() && relative.global
+ {
+ "&"
+ } else {
+ ""
+ };
+ edits.push((start, pseudo.end, replacement.into()));
+ }
+ return;
+ }
+ if matches!(pseudo.name.as_str(), "is" | "where" | "has" | "not")
+ && let Some(args) = &pseudo.args
+ {
+ let rendered = self.selector_list(args, global || relative.global, specificity, false);
+ edits.push((args.start, args.end, rendered));
+ }
+ }
+}
+
+fn rewrite_animation(value: &str, hash: &str, names: &BTreeSet) -> String {
+ let mut out = String::with_capacity(value.len());
+ let mut token = String::new();
+ for ch in value.chars().chain(std::iter::once(';')) {
+ if ch.is_whitespace() || matches!(ch, ',' | ';' | '}') {
+ if names.contains(&token) {
+ out.push_str(hash);
+ out.push('-');
+ }
+ out.push_str(&token);
+ token.clear();
+ if ch != ';' {
+ out.push(ch);
+ }
+ } else {
+ token.push(ch);
+ }
+ }
+ out
+}
+
+pub(super) fn strip_prefix(name: &str) -> String {
+ for prefix in ["-webkit-", "-moz-", "-o-", "-ms-"] {
+ if let Some(rest) = name.to_ascii_lowercase().strip_prefix(prefix) {
+ return rest.to_string();
+ }
+ }
+ name.to_ascii_lowercase()
+}
+
+pub(super) fn unescape_ident(name: &str) -> String {
+ let mut out = String::new();
+ let mut escaped = false;
+ for ch in name.chars() {
+ if escaped {
+ out.push(ch);
+ escaped = false;
+ } else if ch == '\\' {
+ escaped = true;
+ } else {
+ out.push(ch);
+ }
+ }
+ out
+}
+
+fn escape_comment_close(source: &str) -> String {
+ source.replace("*/", "*\\/")
+}
+
+fn replace_range(source: &str, start: usize, end: usize, replacement: &str) -> String {
+ format!("{}{}{}", &source[..start], replacement, &source[end..])
+}
+
+fn apply_edits(
+ source: &str,
+ start: usize,
+ end: usize,
+ mut edits: Vec<(usize, usize, String)>,
+) -> String {
+ edits.sort_by_key(|x| (x.0, x.1));
+ let mut out = String::new();
+ let mut cursor = start;
+ for (a, b, replacement) in edits {
+ if a < cursor {
+ continue;
+ }
+ out.push_str(&source[cursor..a]);
+ out.push_str(&replacement);
+ cursor = b;
+ }
+ out.push_str(&source[cursor..end]);
+ out
+}
diff --git a/packages/compiler/src/tsrx/style/tests.rs b/packages/compiler/src/tsrx/style/tests.rs
new file mode 100644
index 000000000..9ba0e8b31
--- /dev/null
+++ b/packages/compiler/src/tsrx/style/tests.rs
@@ -0,0 +1,343 @@
+use super::*;
+
+fn input<'a>(css: &'a str, elements: &'a [Element], kind: StyleKind) -> StyleInput<'a> {
+ StyleInput {
+ css,
+ location: StyleLocation {
+ filename: "App.tsrx",
+ line: 3,
+ column: 4,
+ },
+ elements,
+ kind,
+ minify: false,
+ }
+}
+
+#[test]
+fn matches_sha256_hash_and_strips_carriage_returns() {
+ let output = compile_style(input(
+ ".a .b:hover { color:red }",
+ &[],
+ StyleKind::Expression,
+ ))
+ .unwrap();
+ assert_eq!(output.hash, "tsrx-f5ea950f");
+ let cr = compile_style(StyleInput {
+ css: ".a\r {x:y}",
+ location: StyleLocation {
+ filename: "A\r.tsrx",
+ line: 1,
+ column: 0,
+ },
+ elements: &[],
+ kind: StyleKind::Expression,
+ minify: false,
+ })
+ .unwrap();
+ let plain = compile_style(StyleInput {
+ css: ".a {x:y}",
+ location: StyleLocation {
+ filename: "A.tsrx",
+ line: 1,
+ column: 0,
+ },
+ elements: &[],
+ kind: StyleKind::Expression,
+ minify: false,
+ })
+ .unwrap();
+ assert_eq!(cr.hash, plain.hash);
+}
+
+#[test]
+fn scopes_first_selector_then_uses_where() {
+ let mut parent = Element::native(1, "div").with_static_attr("class", "a");
+ parent.children.push(ElementChild::Element(
+ Element::native(2, "span").with_static_attr("class", "b"),
+ ));
+ let roots = [parent];
+ let output =
+ compile_style(input(".a .b:hover { color:red }", &roots, StyleKind::Block)).unwrap();
+ assert_eq!(
+ output.css,
+ ".a.tsrx-f5ea950f .b:where(.tsrx-f5ea950f):hover { color:red }"
+ );
+}
+
+#[test]
+fn expression_map_is_sorted_and_prunes_unreachable_rules() {
+ let css =
+ "div { color: red; }.z {a:b}.a {c:d}:global(.badge) {e:f}:global(body) { margin: 0; }";
+ let output = compile_style(input(css, &[], StyleKind::Expression)).unwrap();
+ assert_eq!(
+ output
+ .class_map
+ .iter()
+ .map(|x| x.class_name.as_str())
+ .collect::>(),
+ ["a", "badge", "z"]
+ );
+ assert!(output.css.starts_with("/* (unused) div { color: red; }*/"));
+ assert!(output.css.contains(".badge {e:f}"));
+ assert!(
+ output
+ .css
+ .contains("/* (unused) :global(body) { margin: 0; }*/")
+ );
+}
+
+#[test]
+fn rewrites_keyframes_and_animation_names() {
+ let css = "@keyframes spin {from{x:y}to{x:z}} .a {animation: 1s spin, none}";
+ let output = compile_style(input(css, &[], StyleKind::Expression)).unwrap();
+ assert_eq!(output.hash, "tsrx-6209913b");
+ assert_eq!(
+ output.css,
+ "@keyframes tsrx-6209913b-spin {from{x:y}to{x:z}} .a.tsrx-6209913b {animation: 1s tsrx-6209913b-spin, none}"
+ );
+}
+
+#[test]
+fn preserves_global_blocks_with_parity_comments() {
+ let css = ":global { body { margin: 0 } }";
+ let output = compile_style(input(css, &[], StyleKind::Block)).unwrap();
+ assert_eq!(output.hash, "tsrx-24bf0053");
+ assert_eq!(output.css, "/* :global {*/ body { margin: 0 } /*}*/");
+}
+
+#[test]
+fn prunes_against_element_tree_and_tracks_scoped_ids() {
+ let mut section = Element::native(1, "section").with_static_attr("class", "card");
+ section
+ .children
+ .push(ElementChild::Element(Element::native(2, "h2")));
+ let roots = [section];
+ let css = ".card {x:y}.card h2 {a:b}.card ol {c:d}";
+ let output = compile_style(input(css, &roots, StyleKind::Block)).unwrap();
+ assert!(output.css.contains(".card."));
+ assert!(output.css.contains("h2:where("));
+ assert!(output.css.contains("/* (unused) .card ol {c:d}*/"));
+ assert_eq!(output.scoped_elements, BTreeSet::from([1, 2]));
+
+ // Dynamic tags, attributes, and child regions are explicit
+ // conservative boundaries in the frontend model.
+ let dynamic = Element {
+ id: 3,
+ kind: ElementKind::Dynamic,
+ attributes: vec![Attribute {
+ name: "class".into(),
+ value: Some(AttributeValue::Dynamic),
+ }],
+ has_spread: false,
+ children: vec![ElementChild::Dynamic],
+ };
+ let component = Element {
+ id: 4,
+ kind: ElementKind::Component,
+ attributes: Vec::new(),
+ has_spread: false,
+ children: Vec::new(),
+ };
+ let dynamic_roots = [dynamic, component];
+ let conservative = compile_style(input(
+ ".runtime-class {x:y}",
+ &dynamic_roots,
+ StyleKind::Block,
+ ))
+ .unwrap();
+ assert!(!conservative.css.contains("(unused)"));
+ assert!(conservative.css.contains(".runtime-class.tsrx-"));
+ assert!(conservative.scoped_elements.contains(&3));
+}
+
+#[test]
+fn supports_nested_rules_and_global_keyframes() {
+ let css = ".card { color:green; span {color:red} &:hover {color:blue} } @keyframes -global-pulse {from{x:y}}";
+ let output = compile_style(input(css, &[], StyleKind::Expression)).unwrap();
+ assert_eq!(output.hash, "tsrx-25e005fa");
+ assert_eq!(
+ output.css,
+ ".card.tsrx-25e005fa { color:green; span.tsrx-25e005fa {color:red} &:hover {color:blue} } @keyframes pulse {from{x:y}}"
+ );
+}
+
+#[test]
+fn preserves_outer_trivia_and_comments_unused_selector_runs() {
+ let roots = [
+ Element::native(1, "div").with_static_attr("class", "a"),
+ Element::native(2, "div").with_static_attr("class", "c"),
+ ];
+ let css = " .a, .b, .c {x:y}\n ";
+ let output = compile_style(input(css, &roots, StyleKind::Block)).unwrap();
+ assert_eq!(
+ output.css,
+ " .a.tsrx-99f3dd6b /* (unused) .b*/, .c.tsrx-99f3dd6b {x:y}\n "
+ );
+}
+
+#[test]
+fn parses_and_conservatively_keeps_column_combinators() {
+ let roots = [Element::native(1, "div").with_static_attr("class", "b")];
+ let css = ".a || .b {x:y}";
+ let output = compile_style(input(css, &roots, StyleKind::Block)).unwrap();
+ assert_eq!(output.hash, "tsrx-f8aa311b");
+ assert_eq!(output.css, ".a || .b.tsrx-f8aa311b {x:y}");
+}
+
+#[test]
+fn preserves_nth_of_selector_lists_while_pruning_conservatively() {
+ let roots = [Element::native(1, "li")];
+ let css = "li:nth-child(2n + 1 of .featured, :global(.external)) {x:y}";
+ let output = compile_style(input(css, &roots, StyleKind::Block)).unwrap();
+ assert_eq!(output.hash, "tsrx-10770a85");
+ assert_eq!(
+ output.css,
+ "li.tsrx-10770a85:nth-child(2n + 1 of .featured, :global(.external)) {x:y}"
+ );
+}
+
+#[test]
+fn preserves_nth_last_child_formulas() {
+ let roots = [Element::native(1, "li")];
+ let css = "li:nth-last-child(odd) {x:y}";
+ let output = compile_style(input(css, &roots, StyleKind::Block)).unwrap();
+ assert_eq!(output.hash, "tsrx-0d1d03e4");
+ assert_eq!(output.css, "li.tsrx-0d1d03e4:nth-last-child(odd) {x:y}");
+}
+
+#[test]
+fn removes_global_modifier_whitespace_and_adds_nested_ampersand() {
+ let roots = [Element::native(1, "div")];
+ let outer = compile_style(input("div :global.x {a:b}", &roots, StyleKind::Block)).unwrap();
+ assert_eq!(outer.css, "div.tsrx-a070b2d0.x {a:b}");
+
+ let nested = compile_style(input("div { :global.x {a:b} }", &roots, StyleKind::Block)).unwrap();
+ assert_eq!(nested.css, "div.tsrx-eec6818f { &.x {a:b} }");
+}
+
+#[test]
+fn components_do_not_match_native_type_selectors() {
+ let roots = [Element {
+ id: 1,
+ kind: ElementKind::Component,
+ attributes: Vec::new(),
+ has_spread: false,
+ children: Vec::new(),
+ }];
+ let output = compile_style(input("div {x:y}", &roots, StyleKind::Block)).unwrap();
+ assert_eq!(output.hash, "tsrx-3853b06d");
+ assert_eq!(output.css, "/* (unused) div {x:y}*/");
+}
+
+#[test]
+fn matches_the_complete_dynamic_attribute_whitelist() {
+ let whitelist: &[(&str, &[&str])] = &[
+ ("details", &["open"]),
+ ("dialog", &["open"]),
+ ("form", &["novalidate"]),
+ (
+ "iframe",
+ &[
+ "allow",
+ "allowfullscreen",
+ "allowpaymentrequest",
+ "loading",
+ "referrerpolicy",
+ ],
+ ),
+ ("img", &["loading"]),
+ (
+ "input",
+ &[
+ "accept",
+ "autocomplete",
+ "capture",
+ "checked",
+ "disabled",
+ "max",
+ "maxlength",
+ "min",
+ "minlength",
+ "multiple",
+ "pattern",
+ "placeholder",
+ "readonly",
+ "required",
+ "size",
+ "step",
+ ],
+ ),
+ ("object", &["typemustmatch"]),
+ ("ol", &["reversed", "start", "type"]),
+ ("optgroup", &["disabled"]),
+ ("option", &["disabled", "selected"]),
+ ("script", &["async", "defer", "nomodule", "type"]),
+ ("select", &["disabled", "multiple", "required", "size"]),
+ (
+ "textarea",
+ &[
+ "autocomplete",
+ "disabled",
+ "maxlength",
+ "minlength",
+ "placeholder",
+ "readonly",
+ "required",
+ "rows",
+ "wrap",
+ ],
+ ),
+ (
+ "video",
+ &["autoplay", "controls", "loop", "muted", "playsinline"],
+ ),
+ ];
+ for &(tag, attributes) in whitelist {
+ let roots = [Element::native(1, tag)];
+ for &attribute in attributes {
+ let css = format!("{tag}[{attribute}]{{x:y}}");
+ let output = compile_style(input(&css, &roots, StyleKind::Block)).unwrap();
+ assert!(
+ !output.css.contains("(unused)"),
+ "{tag}[{attribute}] was pruned"
+ );
+ }
+ }
+
+ let roots = [
+ Element::native(1, "input"),
+ Element::native(2, "iframe"),
+ Element::native(3, "form"),
+ ];
+ let css =
+ "input[placeholder] {x:y} iframe[loading] {a:b} form[novalidate]{c:d} input[notreal]{d:e}";
+ let output = compile_style(input(css, &roots, StyleKind::Block)).unwrap();
+ assert_eq!(output.hash, "tsrx-f5567425");
+ assert_eq!(
+ output.css,
+ "input[placeholder].tsrx-f5567425 {x:y} iframe[loading].tsrx-f5567425 {a:b} form[novalidate].tsrx-f5567425{c:d} /* (unused) input[notreal]{d:e}*/"
+ );
+}
+
+#[test]
+fn optionally_preserves_class_map_selectors_for_style_refs() {
+ let output = compile_style_with_class_map_selectors(
+ input(".kept {x:y} div {a:b}", &[], StyleKind::Block),
+ true,
+ )
+ .unwrap();
+ assert_eq!(output.hash, "tsrx-6c055f88");
+ assert_eq!(
+ output.css,
+ ".kept.tsrx-6c055f88 {x:y} /* (unused) div {a:b}*/"
+ );
+ assert_eq!(output.class_map[0].class_name, "kept");
+}
+
+#[test]
+fn rejects_global_in_the_middle() {
+ let error =
+ compile_style(input(".a :global(.x) .b {x:y}", &[], StyleKind::Expression)).unwrap_err();
+ assert!(error.message.contains("not in the middle"));
+}
diff --git a/packages/compiler/src/tsrx/style_projection.rs b/packages/compiler/src/tsrx/style_projection.rs
new file mode 100644
index 000000000..611b4f53f
--- /dev/null
+++ b/packages/compiler/src/tsrx/style_projection.rs
@@ -0,0 +1,745 @@
+//! Frontend planning for TSRX scoped styles.
+//!
+//! This module translates parser tape into the CSS engine's element model,
+//! assigns style actions to authored nodes, and prepares renderer-facing hash
+//! and ref metadata. It intentionally owns no general TSRX construct emission.
+
+use std::collections::{BTreeMap, BTreeSet};
+
+use super::project::ProjectError;
+use super::{
+ semantic::SolidTsrxModule,
+ style::{
+ self, Attribute, AttributeValue, ClassMapEntry, Element, ElementChild, ElementKind,
+ StyleInput, StyleKind, StyleLocation,
+ },
+ tape::{self, Node},
+};
+
+#[derive(Clone)]
+pub(super) enum StyleAction {
+ Remove,
+ ClassMap(Vec),
+ EmptyElement,
+}
+
+#[derive(Clone)]
+pub(super) struct RefSetup<'t> {
+ pub(super) target: Node<'t>,
+ pub(super) class_map: Vec,
+ pub(super) temp_name: Option,
+}
+
+pub(super) struct StyleProjection<'t> {
+ pub(super) actions: BTreeMap,
+ pub(super) element_hashes: BTreeMap>,
+ pub(super) owner_setups: BTreeMap>>,
+ pub(super) css: String,
+ pub(super) css_hash: Option,
+}
+
+pub(super) fn plan<'s, 't>(
+ source: &'s str,
+ filename: &'s str,
+ module: &SolidTsrxModule<'t>,
+) -> Result, ProjectError> {
+ StyleProcessor::process(source, filename, module)
+}
+
+struct StyleProcessor<'s, 't> {
+ source: &'s str,
+ filename: &'s str,
+ module: &'s SolidTsrxModule<'t>,
+ consumed: BTreeSet,
+ actions: BTreeMap,
+ element_hashes: BTreeMap>,
+ owner_setups: BTreeMap>>,
+ stylesheets: Vec<(String, String)>,
+ identifiers: BTreeSet,
+ next_temp: usize,
+}
+
+impl<'s, 't> StyleProcessor<'s, 't> {
+ fn process(
+ source: &'s str,
+ filename: &'s str,
+ module: &'s SolidTsrxModule<'t>,
+ ) -> Result, ProjectError> {
+ let root = module.root;
+ let mut identifiers = BTreeSet::new();
+ tape::walk(root, &mut |node| {
+ if node.ty() == "Identifier"
+ && let Some(name) = node.str_field("name")
+ {
+ identifiers.insert(name.to_string());
+ }
+ true
+ });
+ let mut processor = Self {
+ source,
+ filename,
+ module,
+ consumed: BTreeSet::new(),
+ actions: BTreeMap::new(),
+ element_hashes: BTreeMap::new(),
+ owner_setups: BTreeMap::new(),
+ stylesheets: Vec::new(),
+ identifiers,
+ next_temp: 0,
+ };
+ processor.visit(root, None)?;
+ let css = processor
+ .stylesheets
+ .iter()
+ .map(|(css, _)| css.as_str())
+ .collect();
+ let hashes: Vec<_> = processor
+ .stylesheets
+ .iter()
+ .map(|(_, hash)| hash.as_str())
+ .collect();
+ Ok(StyleProjection {
+ actions: processor.actions,
+ element_hashes: processor.element_hashes,
+ owner_setups: processor.owner_setups,
+ css,
+ css_hash: (!hashes.is_empty()).then(|| hashes.join(" ")),
+ })
+ }
+
+ fn visit(&mut self, node: Node<'t>, parent: Option>) -> Result<(), ProjectError> {
+ if self.module.is_style_element(node) {
+ let start = span_of(node)?.0;
+ if !self.consumed.contains(&start) {
+ if is_style_expression_position(parent) {
+ self.compile_expression_style(node)?;
+ } else {
+ self.actions.insert(start, StyleAction::EmptyElement);
+ }
+ }
+ return Ok(());
+ }
+
+ if node.ty() == "JSXCodeBlock"
+ && let Some(render) = node.node_field("render")
+ && is_native_render_root(render)
+ {
+ self.prepare_runtime_scope(node, render)?;
+ } else if matches!(node.ty(), "JSXElement" | "JSXFragment") {
+ self.prepare_runtime_scope(node, node)?;
+ }
+
+ if node.ty() == "JSXForExpression" {
+ self.mark_unowned_styles(node);
+ return Ok(());
+ }
+ let skipped_pending = (node.ty() == "JSXTryExpression")
+ .then(|| node.node_field("pending"))
+ .flatten();
+ for child in semantic_children(node) {
+ if skipped_pending.is_some_and(|pending| pending.span() == child.span()) {
+ self.mark_unowned_styles(child);
+ } else {
+ self.visit(child, Some(node))?;
+ }
+ }
+ Ok(())
+ }
+
+ fn mark_unowned_styles(&mut self, node: Node<'t>) {
+ if is_function_or_class_boundary(node) {
+ return;
+ }
+ if self.module.is_style_element(node) {
+ if let Some((start, _)) = node.span()
+ && !self.consumed.contains(&start)
+ {
+ self.actions.insert(start, StyleAction::EmptyElement);
+ }
+ return;
+ }
+ for child in structural_children(node, StructuralMode::Runtime) {
+ self.mark_unowned_styles(child);
+ }
+ }
+
+ fn consume_runtime_styles(&mut self, node: Node<'t>) {
+ if is_function_or_class_boundary(node) {
+ return;
+ }
+ if self.module.is_style_element(node) {
+ if let Some((start, _)) = node.span() {
+ self.consumed.insert(start);
+ self.actions.insert(start, StyleAction::Remove);
+ }
+ return;
+ }
+ for child in structural_children(node, StructuralMode::Runtime) {
+ self.consume_runtime_styles(child);
+ }
+ }
+
+ fn prepare_runtime_scope(
+ &mut self,
+ setup_owner: Node<'t>,
+ render_owner: Node<'t>,
+ ) -> Result<(), ProjectError> {
+ let render_children = structural_children(render_owner, StructuralMode::Runtime);
+ let mut styles = Vec::new();
+ for child in &render_children {
+ collect_runtime_styles(*child, self.module, &self.consumed, &mut styles);
+ }
+ if styles.len() > 1 {
+ return Err(error(
+ "TSRX fragments can only have one style tag",
+ styles[1],
+ ));
+ }
+ let Some(style_node) = styles.first().copied() else {
+ return Ok(());
+ };
+ let style_start = span_of(style_node)?.0;
+ if self.consumed.contains(&style_start) {
+ return Ok(());
+ }
+
+ let (css, location) = self.style_source(style_node)?;
+ let roots = build_style_elements(&render_children, self.module);
+ let mut annotatable = BTreeSet::new();
+ collect_annotatable_ids(&roots, &mut annotatable);
+ let refs = style_ref_targets(style_node);
+ let output = style::compile_style_with_class_map_selectors(
+ StyleInput {
+ css,
+ location,
+ elements: &roots,
+ kind: StyleKind::Block,
+ minify: false,
+ },
+ !refs.is_empty(),
+ )
+ .map_err(|style_error| self.style_error(style_node, style_error))?;
+ if !refs.is_empty() {
+ let owner_start = span_of(setup_owner)?.0;
+ for target in refs {
+ self.add_ref_setup(owner_start, target, output.class_map.clone());
+ }
+ }
+ for id in annotatable {
+ self.element_hashes
+ .entry(id)
+ .or_default()
+ .push(output.hash.clone());
+ }
+ self.consume_runtime_styles(render_owner);
+ self.stylesheets.push((output.css, output.hash));
+ Ok(())
+ }
+
+ fn compile_expression_style(&mut self, style_node: Node<'t>) -> Result<(), ProjectError> {
+ let start = span_of(style_node)?.0;
+ let (css, location) = self.style_source(style_node)?;
+ let output = style::compile_style(StyleInput {
+ css,
+ location,
+ elements: &[],
+ kind: StyleKind::Expression,
+ minify: false,
+ })
+ .map_err(|style_error| self.style_error(style_node, style_error))?;
+ self.actions
+ .insert(start, StyleAction::ClassMap(output.class_map));
+ self.stylesheets.push((output.css, output.hash));
+ Ok(())
+ }
+
+ fn style_source(&self, node: Node<'t>) -> Result<(&'s str, StyleLocation<'s>), ProjectError> {
+ let css = node.str_field("css").ok_or_else(|| {
+ error(
+ "A TSRX \n
\n >\n}\n";
+ let filename = "/exact/components/card.tsrx";
+ let outputs: Vec<_> = [Generate::Dom, Generate::Ssr, Generate::Universal]
+ .into_iter()
+ .map(|generate| {
+ compile(
+ source,
+ &CompileOptions {
+ filename: Some(filename.into()),
+ ..fixture_options(generate)
+ },
+ )
+ .expect("scoped styles compile")
+ })
+ .collect();
+ let expected_css = outputs[0].css.as_deref().expect("TSRX CSS result");
+ let expected_hash = outputs[0].css_hash.as_deref().expect("scope hash");
+ assert!(expected_css.contains(&format!(".used.{expected_hash}")));
+ assert!(expected_css.contains("(unused)"), "{expected_css}");
+ for output in &outputs {
+ assert_eq!(output.css.as_deref(), Some(expected_css));
+ assert_eq!(output.css_hash.as_deref(), Some(expected_hash));
+ assert!(!output.code.contains("\n \n
\n>;",
+ );
+ assert!(
+ message.contains("TSRX fragments can only have one style tag (3:2)"),
+ "style diagnostic: {message}"
+ );
+}
+
+#[test]
+fn rejects_return_inside_an_if_branch() {
+ let message = compile_error(
+ "export function C({ ok }) @{\n \n @if (ok) {\n return
no
;\n }\n
\n}\n",
+ );
+ assert!(
+ message.contains("Return statements are not allowed"),
+ "@if return diagnostic: {message}"
+ );
+}
+
+#[test]
+fn rejects_control_flow_and_structural_early_errors() {
+ let cases = [
+ (
+ "return escaping @for",
+ "export function C({ xs }) @{ @for (const x of xs) { return {x} ; } }",
+ "Return statements are not allowed",
+ ),
+ (
+ "continue escaping @for",
+ "export function C({ xs }) @{ @for (const x of xs) { continue; {x} } }",
+ "Continue statements are not allowed",
+ ),
+ (
+ "break escaping @switch",
+ "export const C = ({ x }) => @switch (x) { @case 1: { break;
} };",
+ "Break statements are not allowed",
+ ),
+ (
+ "for-await",
+ "export function C({ xs }) @{ @for await (const x of xs) { {x} } }",
+ "`for await` is not supported",
+ ),
+ (
+ "for-in",
+ "export function C({ obj }) @{ @for (const key in obj) { {key} } }",
+ "@for must iterate with for...of",
+ ),
+ (
+ "statement after output",
+ "export function C() @{
; const x = 1; }",
+ "render expression precedes another statement",
+ ),
+ (
+ "multiple output nodes",
+ "export function C() @{
; }",
+ "render expression precedes another statement",
+ ),
+ (
+ "@finally",
+ "export const C = () => @try {
} @finally {
};",
+ "expected an `@pending` or `@catch` clause",
+ ),
+ (
+ "spaced statement-container sigil",
+ "export function C() @ {
}",
+ "Expected a semicolon",
+ ),
+ (
+ "spaced lazy-pattern sigil",
+ "export function C({ x }) @{ const & { value } = x; {value}
}",
+ "does not support authored lazy destructuring",
+ ),
+ ];
+
+ for (name, source, expected) in cases {
+ let message = compile_error(source);
+ assert!(
+ message.contains(expected),
+ "{name} diagnostic must contain {expected:?}: {message}"
+ );
+ }
+}
+
+#[test]
+fn unicode_offsets_preserve_authored_diagnostic_coordinates() {
+ let message = compile_error(
+ "const emoji = \"🚀\";\nexport function C() @{\n \n @if (true) { return
; }\n
\n}\n",
+ );
+ assert!(
+ message.ends_with("(4:17)"),
+ "UTF-16 spans must rebase to authored line/column coordinates: {message}"
+ );
+
+ let source =
+ "const emoji = \"🚀\"; export function C({ obj }) @{ @for (const key in obj) {
} }";
+ let expected_column = source[..source.find("@for").expect("@for")]
+ .encode_utf16()
+ .count();
+ let message = compile_error(source);
+ assert!(
+ message.ends_with(&format!("(1:{expected_column})")),
+ "same-line astral characters count as two UTF-16 units: {message}"
+ );
+}
+
+#[test]
+fn rejects_statement_containers_without_rendered_output() {
+ let message = compile_error("export function C() @{\n const x = 1;\n}\n");
+ assert!(
+ message.contains("A TSRX statement container is missing its rendered output node"),
+ "renderless container diagnostic: {message}"
+ );
+}
+
+#[test]
+fn parse_errors_carry_authored_line_and_column() {
+ let error = compile(
+ "const broken = TsrxTypecheckProjection {
+ project_tsrx_for_typecheck(
+ source,
+ &TsrxTypecheckProjectionOptions {
+ filename: Some("typecheck.tsrx".into()),
+ },
+ )
+ .expect("typecheck projection")
+}
+
+fn line_column(source: &str, byte_offset: usize) -> (u32, u32) {
+ let line = source[..byte_offset]
+ .bytes()
+ .filter(|byte| *byte == b'\n')
+ .count() as u32;
+ let line_start = source[..byte_offset]
+ .rfind('\n')
+ .map_or(0, |offset| offset + 1);
+ (
+ line,
+ source[line_start..byte_offset].encode_utf16().count() as u32,
+ )
+}
+
+#[test]
+fn projects_identifier_and_destructured_callback_modes() {
+ let source = r#"export function Rows({ rows }) @{
+ <>
+ @for (const plain of rows) {
{plain.name}
}
+ @for (const indexed of rows; index index) {
{indexed.name}:{index}
}
+ @for (const keyed of rows; key keyed.id) {
{keyed.name}
}
+ @for (const both of rows; index position; key both.id) {
{both.name}:{position}
}
+ @for (const { name = "missing", ...rest } of rows; index offset) {
+
{name}:{rest.extra}:{offset}
+ }
+ @try {
} @catch (error) {
{error.message}
}
+ >
+}"#;
+ let output = project(source);
+
+ assert!(output.code.contains("from \"solid-js\""));
+ assert!(output.code.contains("<__tsrx_For0"));
+ assert!(output.code.contains("<__tsrx_Errored0"));
+ assert!(output.code.contains("plain.name"));
+ assert!(!output.code.contains("plain().name"));
+ assert!(output.code.contains("indexed().name"));
+ assert!(output.code.contains("keyed().name"));
+ assert!(output.code.contains("both().name"));
+ assert!(output.code.contains("position()"));
+ assert!(output.code.contains("keyed={false}"));
+ assert!(output.code.contains("__lazy"));
+ assert!(output.code.contains(".name"));
+ assert!(output.code.contains(".extra"));
+ assert!(output.code.contains("error().message"));
+
+ let runtime = compile(
+ source,
+ &CompileOptions {
+ filename: Some("typecheck.tsrx".into()),
+ syntax: Syntax::Tsrx,
+ ..CompileOptions::default()
+ },
+ )
+ .expect("runtime projection");
+ for shared_semantic_read in ["indexed().name", "error().message"] {
+ assert!(
+ runtime.code.contains(shared_semantic_read),
+ "runtime and tooling must share {shared_semantic_read}: {}",
+ runtime.code
+ );
+ }
+}
+
+#[test]
+fn typecheck_helper_aliases_do_not_capture_authored_bindings_or_elements() {
+ let source = r#"const __tsrx_For0 = "taken";
+const For = (props: { children?: unknown }) => props.children;
+export function Rows({ rows }: { rows: { name: string }[] }) @{
+ <>
+
authored
+ @for (const row of rows; index index) {
{row.name}:{index}
}
+ >
+}"#;
+ let output = project(source);
+
+ assert!(
+ output.code.contains("For as __tsrx_For1"),
+ "{}",
+ output.code
+ );
+ assert!(
+ output.code.contains("
authored "),
+ "{}",
+ output.code
+ );
+ assert!(output.code.contains("<__tsrx_For1"), "{}", output.code);
+}
+
+#[test]
+fn tooling_recovers_incomplete_editor_snapshots_without_loosening_compilation() {
+ for source in [
+ "export function View() @{",
+ "export function View() @{ const value = ",
+ "export function View() @{ @ }",
+ "export function View() @{\n
",
+ ] {
+ assert!(
+ compile(
+ source,
+ &CompileOptions {
+ filename: Some("incomplete.tsrx".into()),
+ syntax: Syntax::Tsrx,
+ ..CompileOptions::default()
+ },
+ )
+ .is_err(),
+ "{source}"
+ );
+
+ let output = project_tsrx_for_typecheck(
+ source,
+ &TsrxTypecheckProjectionOptions {
+ filename: Some("incomplete.tsrx".into()),
+ },
+ )
+ .unwrap_or_else(|error| panic!("{source}: {error}"));
+ assert!(!output.code.is_empty(), "{source}");
+ }
+}
+
+#[test]
+fn tooling_rejects_authored_lazy_destructuring() {
+ let error = project_tsrx_for_typecheck(
+ "export function Card(model) @{ const &{ title } = model;
{title}
}",
+ &TsrxTypecheckProjectionOptions {
+ filename: Some("authored-lazy.tsrx".into()),
+ },
+ )
+ .expect_err("authored lazy destructuring must be rejected");
+ assert!(
+ error
+ .message()
+ .contains("Solid's TSRX frontend does not support authored lazy destructuring"),
+ "{error}"
+ );
+}
+
+#[test]
+fn projects_dynamic_tags_and_scoped_styles() {
+ let source = r#"export function Card({ model, Tag }: Props) @{
+ <>
+
+
{model.title ?? "untitled"}:{model.nested.count ?? 0}
+ <{Tag} class="card" />
+ >
+}"#;
+ let output = project(source);
+
+ assert!(output.code.contains("model.title"));
+ assert!(output.code.contains("model.nested.count"));
+ assert!(
+ output.code.contains("<__tsrx_Dynamic0 component={Tag}"),
+ "{}",
+ output.code
+ );
+ assert!(output.code.contains("from \"@solidjs/web\""));
+ assert!(!output.code.contains("
+ "}
+ >
+}"#;
+ let output = project(source);
+ assert!(output.code.contains("
> }"#,
+ r#"export function Assets() @{ <>
> }"#,
+ r#"export function Assets() @{ <>
> }"#,
+ ] {
+ let runtime = compile(
+ source,
+ &CompileOptions {
+ filename: Some("assets.tsrx".into()),
+ syntax: Syntax::Tsrx,
+ ..CompileOptions::default()
+ },
+ )
+ .expect("runtime projection accepts authored embed order");
+ assert!(runtime.code.contains("script"));
+
+ let tooling = project(source);
+ assert_eq!(
+ tooling
+ .embedded_regions
+ .iter()
+ .filter(|region| region.kind == TsrxEmbeddedRegionKind::Css)
+ .count(),
+ 1
+ );
+ assert_eq!(
+ tooling
+ .embedded_regions
+ .iter()
+ .filter(|region| region.kind == TsrxEmbeddedRegionKind::Script)
+ .count(),
+ source.matches("