diff --git a/Cargo.lock b/Cargo.lock index b67c641c44..7d8b26c4af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -391,6 +391,17 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "convert_case" version = "0.6.0" @@ -628,6 +639,12 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.1" @@ -1091,6 +1108,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "insta" +version = "1.47.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" +dependencies = [ + "console", + "once_cell", + "serde", + "similar", + "tempfile", +] + [[package]] name = "is-macro" version = "0.3.5" @@ -1302,6 +1332,8 @@ dependencies = [ "ctor", "napi-sys", "once_cell", + "serde", + "serde_json", "thread_local", ] @@ -1746,6 +1778,7 @@ dependencies = [ "swc_plugin_define_dce", "swc_plugin_directive_dce", "swc_plugin_dynamic_import", + "swc_plugin_element_template", "swc_plugin_inject", "swc_plugin_list", "swc_plugin_shake", @@ -2065,6 +2098,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "siphasher" version = "0.3.10" @@ -2341,6 +2380,7 @@ dependencies = [ "swc_common", "swc_ecma_ast", "swc_ecma_codegen", + "swc_ecma_minifier", "swc_ecma_parser", "swc_ecma_quote_macros", "swc_ecma_transforms_base", @@ -3187,6 +3227,24 @@ dependencies = [ "swc_plugins_shared", ] +[[package]] +name = "swc_plugin_element_template" +version = "0.1.0" +dependencies = [ + "convert_case 0.8.0", + "hex", + "insta", + "napi", + "napi-derive", + "once_cell", + "regex", + "serde", + "serde_json", + "sha-1", + "swc_core", + "swc_plugins_shared", +] + [[package]] name = "swc_plugin_inject" version = "0.1.0" @@ -3247,6 +3305,7 @@ dependencies = [ "swc_plugin_define_dce", "swc_plugin_directive_dce", "swc_plugin_dynamic_import", + "swc_plugin_element_template", "swc_plugin_inject", "swc_plugin_list", "swc_plugin_shake", diff --git a/packages/react/transform/Cargo.toml b/packages/react/transform/Cargo.toml index 7317a0f6d2..055904c42e 100644 --- a/packages/react/transform/Cargo.toml +++ b/packages/react/transform/Cargo.toml @@ -11,7 +11,7 @@ crate-type = ["cdylib"] convert_case = { workspace = true } hex = { workspace = true } indexmap = { workspace = true } -napi = { workspace = true } +napi = { workspace = true, features = ["serde-json"] } napi-derive = { workspace = true } once_cell = { workspace = true } regex = { workspace = true } @@ -25,6 +25,7 @@ swc_plugin_css_scope = { path = "./crates/swc_plugin_css_scope", features = ["na swc_plugin_define_dce = { path = "./crates/swc_plugin_define_dce", features = ["napi"] } swc_plugin_directive_dce = { path = "./crates/swc_plugin_directive_dce", features = ["napi"] } swc_plugin_dynamic_import = { path = './crates/swc_plugin_dynamic_import', features = ["napi"] } +swc_plugin_element_template = { path = "./crates/swc_plugin_element_template", features = ["napi"] } swc_plugin_inject = { path = "./crates/swc_plugin_inject", features = ["napi"] } swc_plugin_list = { path = "./crates/swc_plugin_list", features = ["napi"] } swc_plugin_shake = { path = './crates/swc_plugin_shake', features = ["napi"] } diff --git a/packages/react/transform/__test__/fixture.spec.js b/packages/react/transform/__test__/fixture.spec.js index d2deb4c94e..160f5c5802 100644 --- a/packages/react/transform/__test__/fixture.spec.js +++ b/packages/react/transform/__test__/fixture.spec.js @@ -164,6 +164,65 @@ describe('ui source map', () => { }); }); +describe('element template', () => { + it('should export compiled element templates when enabled', async () => { + const result = await transformReactLynx('const node = ;', { + mode: 'test', + pluginName: '', + filename: 'test.js', + sourcemap: false, + cssScope: false, + elementTemplate: { + preserveJsx: false, + runtimePkg: '@lynx-js/react', + filename: 'test.js', + target: 'LEPUS', + }, + jsx: true, + directiveDCE: false, + defineDCE: false, + shake: false, + compat: true, + worklet: false, + refresh: false, + }); + + expect(Array.isArray(result.elementTemplates)).toBe(true); + const template = result.elementTemplates?.[0]; + expect(template?.templateId).toEqual(expect.any(String)); + expect(template?.templateId.length).toBeGreaterThan(0); + expect(template?.compiledTemplate).toEqual(expect.any(Object)); + expect(Array.isArray(template?.compiledTemplate)).toBe(false); + expect(template?.sourceFile).toEqual(expect.any(String)); + }); + + it('should not bridge legacy snapshot ET flags into the new ET plugin path', async () => { + const result = await transformReactLynx('const node = ;', { + mode: 'test', + pluginName: '', + filename: 'test.js', + sourcemap: false, + cssScope: false, + snapshot: { + preserveJsx: false, + runtimePkg: '@lynx-js/react', + filename: 'test.js', + target: 'LEPUS', + enableElementTemplate: true, + }, + jsx: true, + directiveDCE: false, + defineDCE: false, + shake: false, + compat: true, + worklet: false, + refresh: false, + }); + + expect(result.elementTemplates).toBeUndefined(); + }); +}); + describe('jsx', () => { it('should allow JSXNamespace', async () => { const result = await transformReactLynx('const jsx = ', { diff --git a/packages/react/transform/crates/swc_plugin_element_template/Cargo.toml b/packages/react/transform/crates/swc_plugin_element_template/Cargo.toml new file mode 100644 index 0000000000..12500f66c0 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "swc_plugin_element_template" +version = "0.1.0" +edition = "2021" + +[lib] +path = "lib.rs" + +[features] +napi = ["dep:napi", "dep:napi-derive", "swc_plugins_shared/napi"] + +[dependencies] +convert_case = { workspace = true } +hex = { workspace = true } +napi = { workspace = true, optional = true, features = ["serde-json"] } +napi-derive = { workspace = true, optional = true } +once_cell = { workspace = true } +regex = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true, features = ["preserve_order"] } +sha-1 = { workspace = true } +swc_core = { workspace = true, features = ["base", "ecma_codegen", "ecma_parser", "ecma_minifier", "ecma_transforms_typescript", "ecma_utils", "ecma_quote", "ecma_transforms_react", "ecma_transforms_optimization", "__visit", "__testing_transform"] } +swc_plugins_shared = { path = "../swc_plugins_shared" } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(swc_ast_unknown)'] } + +[dev-dependencies] +insta = { version = "1.34", features = ["json"] } diff --git a/packages/react/transform/crates/swc_plugin_element_template/asset.rs b/packages/react/transform/crates/swc_plugin_element_template/asset.rs new file mode 100644 index 0000000000..16c62fcf8a --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/asset.rs @@ -0,0 +1,64 @@ +use serde::Serialize; +use swc_core::common::comments::Comments; + +use super::JSXTransformer; + +#[derive(Serialize, Debug, Clone)] +pub struct ElementTemplateAsset { + // The compiled template is exported out-of-band from the JS module. The JS + // output keeps using a synthetic component tag so existing React/SWC passes can + // continue to own expression lowering and runtime value transport. + pub template_id: String, + pub compiled_template: serde_json::Value, + pub source_file: String, +} + +const BUILTIN_RAW_TEXT_TEMPLATE_ID: &str = "__et_builtin_raw_text__"; + +impl JSXTransformer +where + C: Comments + Clone, +{ + fn builtin_raw_text_template_asset(&self) -> ElementTemplateAsset { + ElementTemplateAsset { + template_id: BUILTIN_RAW_TEXT_TEMPLATE_ID.to_string(), + compiled_template: serde_json::json!({ + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0, + } + ], + "children": [], + }), + source_file: self.cfg.filename.clone(), + } + } + + pub(super) fn ensure_builtin_element_templates(&self) { + let Some(element_templates) = &self.element_templates else { + return; + }; + + let mut templates = element_templates.borrow_mut(); + if templates.is_empty() { + return; + } + + // Raw text can also appear as dynamic element-slot content. Emitting the + // builtin template only when user ET templates exist keeps non-ET transforms + // free of template metadata while giving runtime a stable key for text slots. + if templates + .iter() + .any(|template| template.template_id == BUILTIN_RAW_TEXT_TEMPLATE_ID) + { + return; + } + + templates.push(self.builtin_raw_text_template_asset()); + } +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/attr_name.rs b/packages/react/transform/crates/swc_plugin_element_template/attr_name.rs new file mode 100644 index 0000000000..58cd85edf2 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/attr_name.rs @@ -0,0 +1,89 @@ +use regex::Regex; + +use swc_core::ecma::ast::*; + +#[derive(Debug, Clone)] +pub enum AttrName { + Attr(String), + Dataset(String), + Event(String, String), + WorkletEvent( + /* worklet_type */ String, + /* event_type */ String, + /* event_name */ String, + ), + Style, + Class, + ID, + Ref, + TimingFlag, + WorkletRef(/* worklet_type */ String), + ListItemPlatformInfo, + Gesture(String), +} + +impl From for AttrName { + fn from(name: String) -> Self { + if let Some(stripped_name) = name.strip_prefix("data-") { + AttrName::Dataset(stripped_name.to_string()) + } else if name == "class" || name == "className" { + AttrName::Class + } else if name == "style" { + AttrName::Style + } else if name == "id" { + AttrName::ID + } else if name == "ref" { + AttrName::Ref + } else if name == "__lynx_timing_flag" { + AttrName::TimingFlag + } else if let Some((event_type, event_name)) = get_event_type_and_name(name.as_str()) { + AttrName::Event(event_type, event_name) + } else { + AttrName::Attr(name) + } + } +} + +impl From for AttrName { + fn from(name: Str) -> Self { + let name = name.value.to_string_lossy().into_owned(); + Self::from(name) + } +} + +impl From for AttrName { + fn from(name: Ident) -> Self { + let name = name.sym.as_ref().to_string(); + Self::from(name) + } +} + +impl AttrName { + pub fn from_ns(ns: Ident, name: Ident) -> Self { + let ns_str = ns.sym.as_ref().to_string(); + let name_str = name.sym.as_ref().to_string(); + if name_str == "ref" { + AttrName::WorkletRef(ns_str) + } else if let Some((event_type, event_name)) = get_event_type_and_name(name_str.as_str()) { + AttrName::WorkletEvent(ns_str, event_type, event_name) + } else if name_str == "gesture" { + AttrName::Gesture(ns_str) + } else { + todo!() + } + } +} + +fn get_event_type_and_name(props_key: &str) -> Option<(String, String)> { + let re = Regex::new(r"^(global-bind|bind|catch|capture-bind|capture-catch)([A-Za-z]+)$").unwrap(); + if let Some(captures) = re.captures(props_key) { + let event_type = if captures.get(1).unwrap().as_str().contains("capture") { + captures.get(1).unwrap().as_str().to_string() + } else { + format!("{}Event", captures.get(1).unwrap().as_str()) + }; + let event_name = captures.get(2).unwrap().as_str().to_string(); + return Some((event_type, event_name)); + } + None +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/lib.rs b/packages/react/transform/crates/swc_plugin_element_template/lib.rs new file mode 100644 index 0000000000..f5eb3be324 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/lib.rs @@ -0,0 +1,3234 @@ +use serde::Deserialize; +use std::{ + cell::RefCell, + collections::{HashMap, HashSet}, + rc::Rc, +}; + +use once_cell::sync::Lazy; +use swc_core::{ + common::{ + comments::{CommentKind, Comments}, + errors::HANDLER, + sync::Lrc, + util::take::Take, + Mark, SourceMap, Span, Spanned, SyntaxContext, DUMMY_SP, + }, + ecma::{ + ast::{JSXExpr, *}, + utils::{is_literal, prepend_stmt, private_ident}, + visit::{VisitMut, VisitMutWith}, + }, + quote, quote_expr, +}; + +mod asset; +mod attr_name; +mod slot; +mod template_definition; + +pub use self::asset::ElementTemplateAsset; +use self::slot::{expr_to_jsx_child, lower_lepus_et_children_expr, wrap_in_slot}; + +#[derive(Clone, Debug, Deserialize)] +pub struct UISourceMapRecord { + pub ui_source_map: i32, + pub line_number: u32, + pub column_number: u32, + pub snapshot_id: String, +} + +pub type ElementTemplateTransformerConfig = JSXTransformerConfig; +pub type ElementTemplateTransformer = JSXTransformer; +pub type ElementTemplateUISourceMapRecord = UISourceMapRecord; + +#[cfg(feature = "napi")] +pub mod napi; + +use swc_plugins_shared::{ + css::get_string_inline_style_from_literal, + jsx_helpers::{ + jsx_attr_name, jsx_attr_to_prop, jsx_attr_value, jsx_children_to_expr, jsx_has_dynamic_key, + jsx_is_children_full_dynamic, jsx_is_custom, jsx_is_list, jsx_is_list_item, jsx_name, + jsx_props_to_obj, jsx_text_to_str, transform_jsx_attr_str, + }, + target::TransformTarget, + transform_mode::TransformMode, + utils::{calc_hash, calc_hash_number}, +}; + +use self::attr_name::AttrName; + +// impl From for Expr { +// fn from(value: i32) -> Self { +// Expr::Lit(Lit::Num(Number { +// span: DUMMY_SP, +// value: value as f64, +// raw: None, +// })) +// } +// } + +static WRAPPER_NODE: Lazy = Lazy::new(|| JSXElement { + span: DUMMY_SP, + opening: JSXOpeningElement { + span: DUMMY_SP, + name: JSXElementName::Ident(Ident::new( + "wrapper".into(), + DUMMY_SP, + SyntaxContext::default(), + )), + attrs: vec![], + self_closing: true, + type_args: None, + }, + closing: None, + children: vec![], +}); + +static WRAPPER_NODE_2: Lazy = Lazy::new(|| JSXElement { + span: DUMMY_SP, + opening: JSXOpeningElement { + span: DUMMY_SP, + name: JSXElementName::Ident(Ident::new( + "wrapper".into(), + DUMMY_SP, + SyntaxContext::default(), + )), + attrs: vec![], + self_closing: false, + type_args: None, + }, + closing: Some(JSXClosingElement { + span: DUMMY_SP, + name: JSXElementName::Ident(Ident::new( + "wrapper".into(), + DUMMY_SP, + SyntaxContext::default(), + )), + }), + children: vec![], +}); + +static NO_FLATTEN_ATTRIBUTES: Lazy> = Lazy::new(|| { + HashSet::from([ + "name".to_string(), + "clip-radius".to_string(), + "overlap".to_string(), + "exposure-scene".to_string(), + "exposure-id".to_string(), + ]) +}); + +#[derive(Debug)] +pub enum DynamicPart { + Attr(Expr, i32, AttrName), + Spread(Expr, i32), + Slot(Expr, i32), + ListSlot(Expr, i32), +} + +pub fn i32_to_expr(i: &i32) -> Expr { + Expr::Lit(Lit::Num(Number { + span: DUMMY_SP, + value: *i as f64, + raw: None, + })) +} + +fn bool_jsx_attr(value: bool) -> JSXAttrValue { + JSXAttrValue::JSXExprContainer(JSXExprContainer { + span: DUMMY_SP, + expr: JSXExpr::Expr(Box::new(Expr::Lit(Lit::Bool(Bool { + span: DUMMY_SP, + value, + })))), + }) +} + +impl DynamicPart { + fn to_updater(&self, runtime_id: Expr, target: TransformTarget, exp_index: i32) -> Expr { + match target { + TransformTarget::LEPUS | TransformTarget::MIXED => match self { + DynamicPart::Attr(_, element_index, attr_name) => match attr_name { + AttrName::Attr(name) => quote!( + "function (ctx) { + if (ctx.__elements) { + __SetAttribute(ctx.__elements[$element_index], $name, ctx.__values[$exp_index]); + } + }" as Expr, + name: Expr = Expr::Lit(Lit::Str(name.clone().into())), + element_index: Expr = i32_to_expr(element_index), + exp_index: Expr = i32_to_expr(&exp_index), + ), + AttrName::TimingFlag => quote!( + "function (ctx) { + if (ctx.__elements) { + __SetAttribute(ctx.__elements[$element_index], '__lynx_timing_flag', ctx.__values[$exp_index].__ltf); + } + }" as Expr, + element_index: Expr = i32_to_expr(element_index), + exp_index: Expr = i32_to_expr(&exp_index), + ), + AttrName::Dataset(name) => quote!( + "function (ctx) { + if (ctx.__elements) { + __AddDataset(ctx.__elements[$element_index], $name, ctx.__values[$exp_index]); + } + }" as Expr, + name: Expr = Expr::Lit(Lit::Str(name.clone().into())), + element_index: Expr = i32_to_expr(element_index), + exp_index: Expr = i32_to_expr(&exp_index), + ), + AttrName::Style => quote!( + "function (ctx) { + if (ctx.__elements) { + __SetInlineStyles(ctx.__elements[$element_index], ctx.__values[$exp_index]); + } + }" as Expr, + element_index: Expr = i32_to_expr(element_index), + exp_index: Expr = i32_to_expr(&exp_index), + ), + AttrName::Class => quote!( + "function (ctx) { + if (ctx.__elements) { + __SetClasses(ctx.__elements[$element_index], ctx.__values[$exp_index] || ''); + } + }" as Expr, + element_index: Expr = i32_to_expr(element_index), + exp_index: Expr = i32_to_expr(&exp_index), + ), + AttrName::ID => quote!( + "function (ctx) { + if (ctx.__elements) { + __SetID(ctx.__elements[$element_index], ctx.__values[$exp_index]); + } + }" as Expr, + element_index: Expr = i32_to_expr(element_index), + exp_index: Expr = i32_to_expr(&exp_index), + ), + AttrName::Event(event_type, event_name) => quote!( + "(snapshot, index, oldValue) => $runtime_id.updateEvent(snapshot, index, oldValue, $element_index, $event_type, $event_name, '')" as Expr, + runtime_id: Expr = runtime_id.clone(), + event_type: Expr = Expr::Lit(Lit::Str(event_type.clone().into())), + event_name: Expr = Expr::Lit(Lit::Str(event_name.clone().into())), + element_index: Expr = i32_to_expr(element_index), + ), + AttrName::WorkletEvent(worklet_type, event_type, event_name) => quote!( + "(snapshot, index, oldValue) => $runtime_id.updateWorkletEvent(snapshot, index, oldValue, $element_index, $worklet_type, $event_type, $event_name)" as Expr, + runtime_id: Expr = runtime_id.clone(), + worklet_type: Expr = Expr::Lit(Lit::Str(worklet_type.clone().into())), + event_type: Expr = Expr::Lit(Lit::Str(event_type.clone().into())), + event_name: Expr = Expr::Lit(Lit::Str(event_name.clone().into())), + element_index: Expr = i32_to_expr(element_index), + ), + AttrName::Ref => quote!( + "(snapshot, index, oldValue) => $runtime_id.updateRef(snapshot, index, oldValue, $element_index)" as Expr, + runtime_id: Expr = runtime_id.clone(), + element_index: Expr = i32_to_expr(element_index), + ), + AttrName::WorkletRef(worklet_type) => quote!( + "(snapshot, index, oldValue) => $runtime_id.updateWorkletRef(snapshot, index, oldValue, $element_index, $worklet_type)" as Expr, + runtime_id: Expr = runtime_id.clone(), + element_index: Expr = i32_to_expr(element_index), + worklet_type: Expr = Expr::Lit(Lit::Str(worklet_type.clone().into())), + ), + AttrName::ListItemPlatformInfo => quote!( + "(snapshot, index, oldValue) => $runtime_id.updateListItemPlatformInfo(snapshot, index, oldValue, $element_index)" as Expr, + runtime_id: Expr = runtime_id.clone(), + element_index: Expr = i32_to_expr(element_index), + ), + AttrName::Gesture(ns) => quote!( + "(snapshot, index, oldValue) => $runtime_id.updateGesture(snapshot, index, oldValue, $element_index, $ns)" as Expr, + runtime_id: Expr = runtime_id.clone(), + element_index: Expr = i32_to_expr(element_index), + ns: Expr = Expr::Lit(Lit::Str(ns.clone().into())), + ), + }, + DynamicPart::Spread(_, element_index) => quote!( + "(snapshot, index, oldValue) => $runtime_id.updateSpread(snapshot, index, oldValue, $element_index)" as Expr, + runtime_id: Expr = runtime_id.clone(), + element_index: Expr = i32_to_expr(element_index) + ), + DynamicPart::Slot(_, _) => Expr::Lit(Lit::Null(Null { span: DUMMY_SP })), + DynamicPart::ListSlot(_, _) => Expr::Lit(Lit::Null(Null { span: DUMMY_SP })), + }, + TransformTarget::JS => Expr::Lit(Lit::Null(Null { span: DUMMY_SP })), + } + } +} + +pub struct DynamicPartExtractor<'a, V, F> +where + V: VisitMut, + F: Fn(Span) -> Expr, +{ + page_id: Lazy, + runtime_id: Expr, + parent_element: Option, + element_index: i32, + element_ids: HashMap, + static_stmts: Vec>, + si_id: Lazy, + snapshot_creator: Option, + dynamic_parts: Vec, + dynamic_part_visitor: &'a mut V, + key: Option, + attr_slot_counter: i32, + element_slot_counter: i32, + enable_element_template: bool, + enable_ui_source_map: bool, + node_index_fn: F, +} + +impl<'a, V, F> DynamicPartExtractor<'a, V, F> +where + V: VisitMut, + F: Fn(Span) -> Expr, +{ + fn new( + runtime_id: Expr, + dynamic_part_visitor: &'a mut V, + enable_element_template: bool, + enable_ui_source_map: bool, + node_index_fn: F, + ) -> Self { + DynamicPartExtractor { + page_id: Lazy::new(|| private_ident!("pageId")), + runtime_id, + parent_element: None, + element_index: 0, + element_ids: HashMap::new(), + static_stmts: vec![], + si_id: Lazy::new(|| private_ident!("snapshotInstance")), + snapshot_creator: None, + dynamic_parts: vec![], + dynamic_part_visitor, + key: None, + attr_slot_counter: 0, + element_slot_counter: 0, + enable_element_template, + enable_ui_source_map, + node_index_fn, + } + } + + fn node_index_expr_from_span(&self, span: Span) -> Expr { + (self.node_index_fn)(span) + } + + fn node_index_config_expr(&self, span: Span) -> Expr { + Expr::Object(ObjectLit { + span: DUMMY_SP, + props: vec![PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp { + key: PropName::Ident(IdentName::new("nodeIndex".into(), DUMMY_SP)), + value: Box::new(self.node_index_expr_from_span(span)), + })))], + }) + } + + fn next_attr_slot_index(&mut self) -> i32 { + let idx = self.attr_slot_counter; + self.attr_slot_counter += 1; + idx + } + + fn next_element_slot_index(&mut self) -> i32 { + let idx = self.element_slot_counter; + self.element_slot_counter += 1; + idx + } + + fn push_dynamic_attr(&mut self, value: Expr, attr_name: AttrName) { + // Snapshot updaters address dynamic attrs by element index. ET descriptors + // address the compact attribute slot array, so the two modes must keep + // separate counters even though they share the same extractor. + let index = if self.enable_element_template { + self.next_attr_slot_index() + } else { + self.element_index + }; + self + .dynamic_parts + .push(DynamicPart::Attr(value, index, attr_name)); + } + + fn push_dynamic_spread(&mut self, value: Expr) { + let index = if self.enable_element_template { + self.next_attr_slot_index() + } else { + self.element_index + }; + self.dynamic_parts.push(DynamicPart::Spread(value, index)); + } + + fn next_children_slot_index(&mut self) -> i32 { + if self.enable_element_template { + self.next_element_slot_index() + } else { + self.element_index + } + } + + fn static_stmt_from_jsx_element(&mut self, n: &JSXElement, el: Ident) -> Stmt { + let mut static_stmt: Stmt = Stmt::Empty(EmptyStmt { span: DUMMY_SP }); + + if let Expr::Lit(Lit::Str(str)) = *jsx_name(n.opening.name.clone()) { + let tag = str.value.to_string_lossy(); + match tag.as_ref() { + "view" => { + static_stmt = quote!( + r#"const $element = __CreateView($page_id)"# as Stmt, + element = el.clone(), + page_id = self.page_id.clone(), + ); + } + "scroll-view" => { + static_stmt = quote!( + r#"const $element = __CreateScrollView($page_id)"# as Stmt, + element = el.clone(), + page_id = self.page_id.clone(), + ); + } + "x-scroll-view" => { + static_stmt = quote!( + r#"const $element = __CreateScrollView($page_id, { tag: "x-scroll-view" })"# as Stmt, + element = el.clone(), + page_id = self.page_id.clone(), + ); + } + "image" => { + static_stmt = quote!( + r#"const $element = __CreateImage($page_id)"# as Stmt, + element = el.clone(), + page_id = self.page_id.clone(), + ); + } + "text" => { + static_stmt = quote!( + r#"const $element = __CreateText($page_id)"# as Stmt, + element = el.clone(), + page_id = self.page_id.clone(), + ); + } + "wrapper" => { + static_stmt = quote!( + r#"const $element = __CreateWrapperElement($page_id)"# as Stmt, + element = el.clone(), + page_id = self.page_id.clone(), + ); + } + "list" => { + static_stmt = quote!( + r#"const $element = $runtime_id.snapshotCreateList($page_id, $si_id, $element_index)"# + as Stmt, + element = el.clone(), + runtime_id: Expr = self.runtime_id.clone(), + page_id = self.page_id.clone(), + si_id = self.si_id.clone(), + element_index: Expr = Expr::Lit(Lit::Num(Number { span: DUMMY_SP, value: self.element_index as f64, raw: None })), + ); + } + "frame" => { + static_stmt = quote!( + r#"const $element = __CreateFrame($page_id)"# as Stmt, + element = el.clone(), + page_id = self.page_id.clone(), + ); + } + _ => { + static_stmt = quote!( + r#"const $element = __CreateElement($name, $page_id)"# as Stmt, + element = el.clone(), + name: Expr = Expr::Lit(Lit::Str(str)), + page_id = self.page_id.clone(), + ); + } + }; + } + + if self.enable_ui_source_map { + let node_index_expr = self.node_index_config_expr(n.span); + if let Stmt::Decl(Decl::Var(var_decl)) = &mut static_stmt { + if let Some(VarDeclarator { + init: Some(init), .. + }) = var_decl.decls.get_mut(0) + { + if let Expr::Call(call_expr) = init.as_mut() { + call_expr.args.push(ExprOrSpread { + spread: None, + expr: Box::new(node_index_expr), + }); + } + } + } + } + + static_stmt + } +} + +impl VisitMut for DynamicPartExtractor<'_, V, F> +where + V: VisitMut, + F: Fn(Span) -> Expr, +{ + fn visit_mut_jsx_element_childs(&mut self, n: &mut Vec) { + if n.is_empty() { + return; + } + + // merge dynamic parts together to reduce wrapper node count + + let mut merged_children: Vec = vec![]; + let mut current_chunk: Vec = vec![]; + + for mut child in n.take() { + let should_merge: bool; + match child { + JSXElementChild::JSXText(ref text) => { + if jsx_text_to_str(&text.value).is_empty() { + should_merge = current_chunk.is_empty(); + } else { + should_merge = true; + } + } + JSXElementChild::JSXElement(ref element) => { + should_merge = !jsx_is_custom(element); + } + JSXElementChild::JSXExprContainer(JSXExprContainer { + expr: JSXExpr::Expr(ref _expr), + .. + }) => { + should_merge = false; + } + JSXElementChild::JSXFragment(_) + | JSXElementChild::JSXExprContainer(JSXExprContainer { + expr: JSXExpr::JSXEmptyExpr(_), + .. + }) => { + should_merge = true; + } + JSXElementChild::JSXSpreadChild(_) => { + unreachable!("JSXSpreadChild is not supported yet"); + } + } + + if should_merge { + if !current_chunk.is_empty() { + current_chunk.visit_mut_with(self.dynamic_part_visitor); + let slot_index = self.next_children_slot_index(); + self.dynamic_parts.push(DynamicPart::Slot( + jsx_children_to_expr(current_chunk.take()), + slot_index, + )); + + let mut child = JSXElementChild::JSXElement(Box::new(WRAPPER_NODE_2.clone())); + child.visit_mut_with(self); + merged_children.push(child); + } + + child.visit_mut_with(self); + merged_children.push(child); + } else { + current_chunk.push(child); + } + } + + if !current_chunk.is_empty() { + current_chunk.visit_mut_with(self.dynamic_part_visitor); + let slot_index = self.next_children_slot_index(); + self.dynamic_parts.push(DynamicPart::Slot( + jsx_children_to_expr(current_chunk.take()), + slot_index, + )); + + let mut child = JSXElementChild::JSXElement(Box::new(WRAPPER_NODE_2.clone())); + child.visit_mut_with(self); + merged_children.push(child); + } + + *n = merged_children; + } + + fn visit_mut_jsx_element(&mut self, n: &mut JSXElement) { + if self.enable_element_template && self.parent_element.is_some() && jsx_is_list(n) { + n.visit_mut_with(self.dynamic_part_visitor); + + let element_slot_index = self.next_children_slot_index(); + self.dynamic_parts.push(DynamicPart::ListSlot( + Expr::JSXElement(Box::new(n.take())), + element_slot_index, + )); + + *n = WRAPPER_NODE.clone(); + n.visit_mut_with(self); + return; + } + + if !jsx_is_custom(n) { + match Lazy::::get(&self.page_id) { + Some(_) => {} + None => { + self.static_stmts.push(RefCell::new(quote!( + r#"const $page_id = $runtime_id.__pageId"# as Stmt, + page_id = self.page_id.clone(), + runtime_id: Expr = self.runtime_id.clone(), + ))); + } + } + + let el = private_ident!("el"); + self.element_ids.insert(self.element_index, el.clone()); + + if (jsx_has_dynamic_key(n)) && self.parent_element.is_some() { + let is_list = jsx_is_list(n); + n.visit_mut_with(self.dynamic_part_visitor); + let expr = Expr::JSXElement(Box::new(n.take())); + let slot_index = self.next_children_slot_index(); + + if is_list { + self + .dynamic_parts + .push(DynamicPart::ListSlot(expr, slot_index)); + } else { + self.dynamic_parts.push(DynamicPart::Slot(expr, slot_index)); + } + + *n = WRAPPER_NODE_2.clone(); + } + + let static_stmt = self.static_stmt_from_jsx_element(n, el.clone()); + let static_stmt = RefCell::new(static_stmt); + self.static_stmts.push(static_stmt.clone()); + + { + let mut flatten = None; + for attr in &n.opening.attrs { + if let JSXAttrOrSpread::JSXAttr(attr) = attr { + let name = jsx_attr_name(&attr.name.clone()).to_string(); + if NO_FLATTEN_ATTRIBUTES.contains(&name) { + flatten = Some(JSXAttrOrSpread::JSXAttr(JSXAttr { + span: DUMMY_SP, + name: JSXAttrName::Ident(IdentName::new("flatten".into(), DUMMY_SP)), + value: Some(bool_jsx_attr(false)), + })); + break; + } + } + } + + if let Some(flatten) = flatten { + let mut has_origin_flatten = false; + for attr in &mut n.opening.attrs { + if let JSXAttrOrSpread::JSXAttr(attr) = attr { + let name = jsx_attr_name(&attr.name.clone()).to_string(); + if name == *"flatten" { + attr.value = Some(bool_jsx_attr(false)); + has_origin_flatten = true; + } + } + } + if !has_origin_flatten { + n.opening.attrs.push(flatten); + } + } + } + + let has_spread_element = n + .opening + .attrs + .iter() + .any(|attr_or_spread| match attr_or_spread { + JSXAttrOrSpread::SpreadElement(_) => true, + JSXAttrOrSpread::JSXAttr(_) => false, + }); + + // Snapshot bundles list-item platform attrs into a hidden updater value. + // ET keeps them as normal template attrs; consuming them here would create + // hidden slots before the ET list path can read the descriptors directly. + if jsx_is_list_item(n) && !self.enable_element_template { + if has_spread_element { + } else { + let mut list_item_platform_info: Vec = vec![]; + n.opening.attrs.retain_mut(|attr_or_spread| { + match attr_or_spread { + JSXAttrOrSpread::JSXAttr(attr) => { + if let JSXAttrName::Ident(id) = &attr.name { + match id.sym.to_string().as_str() { + "reuse-identifier" + | "full-span" + | "item-key" + | "sticky-top" + | "sticky-bottom" + | "estimated-height" + | "estimated-height-px" + | "estimated-main-axis-size-px" + | "recyclable" => { + list_item_platform_info.push(attr.clone()); + return false; + } + &_ => {} + } + } + } + JSXAttrOrSpread::SpreadElement(_spread) => { + return false; + } + } + + true + }); + if !list_item_platform_info.is_empty() { + self.push_dynamic_attr( + Expr::Object(ObjectLit { + span: DUMMY_SP, + props: list_item_platform_info + .iter() + .map(jsx_attr_to_prop) + .collect(), + }), + AttrName::ListItemPlatformInfo, + ); + } + } + } + + // pick key from n.opening.attrs + n.opening + .attrs + .retain_mut(|attr_or_spread| match attr_or_spread { + JSXAttrOrSpread::SpreadElement(_) => true, + JSXAttrOrSpread::JSXAttr(JSXAttr { name, value, .. }) => match name { + JSXAttrName::Ident(ident_name) => match ident_name.sym.as_ref() { + "key" => { + if self.parent_element.is_none() { + self.key = value.take(); + } + false + } + _ => true, + }, + JSXAttrName::JSXNamespacedName(_) => true, + }, + }); + + if has_spread_element { + if self.enable_element_template { + for attr_or_spread in &mut n.opening.attrs { + match attr_or_spread { + JSXAttrOrSpread::SpreadElement(spread) => { + self.push_dynamic_spread(*spread.expr.clone()); + } + JSXAttrOrSpread::JSXAttr(JSXAttr { name, value, .. }) => match name { + JSXAttrName::Ident(ident_name) => { + let attr_name = + AttrName::from(>::into(ident_name.clone())); + match &attr_name { + AttrName::Attr(_) | AttrName::Dataset(_) | AttrName::Class | AttrName::ID => { + if let Some(JSXAttrValue::JSXExprContainer(JSXExprContainer { + expr: JSXExpr::Expr(expr), + .. + })) = value + { + if !matches!(&**expr, Expr::Lit(_)) { + self.push_dynamic_attr(*expr.clone(), attr_name.clone()); + } + } + } + AttrName::Style => { + let mut static_style_val = None; + if let Some(JSXAttrValue::JSXExprContainer(JSXExprContainer { + expr: JSXExpr::Expr(expr), + span, + .. + })) = value + { + let expr = &**expr; + if is_literal(expr) { + if let Some(s) = get_string_inline_style_from_literal(expr, span) { + static_style_val = Some((s, *span)); + } + } + } + + if let Some((s_val, span)) = static_style_val { + *value = Some(JSXAttrValue::Str(Str { + span, + value: s_val.into(), + raw: None, + })); + } else if let Some(JSXAttrValue::JSXExprContainer(JSXExprContainer { + expr: JSXExpr::Expr(expr), + .. + })) = value + { + self.push_dynamic_attr(*expr.clone(), attr_name.clone()); + } + } + AttrName::Event(..) | AttrName::Ref => { + self.push_dynamic_attr(*jsx_attr_value((*value).clone()), attr_name.clone()); + } + AttrName::TimingFlag => { + self.push_dynamic_attr( + *quote_expr!("{__ltf: $flag}", flag: Expr = *jsx_attr_value((*value).clone())), + attr_name.clone(), + ); + } + AttrName::ListItemPlatformInfo => unreachable!( + "Unexpected ListItemPlatformInfo attribute in static JSX processing" + ), + AttrName::WorkletEvent(..) | AttrName::WorkletRef(..) => { + unreachable!("A worklet event should have an attribute namespace.") + } + AttrName::Gesture(..) => { + unreachable!("A gesture should have an attribute namespace.") + } + } + } + JSXAttrName::JSXNamespacedName(JSXNamespacedName { ns, name, .. }) => { + let attr_name: AttrName = + AttrName::from_ns(ns.clone().into(), name.clone().into()); + match attr_name { + AttrName::WorkletEvent(..) + | AttrName::WorkletRef(..) + | AttrName::Gesture(..) => { + self.push_dynamic_attr(*jsx_attr_value((*value).clone()), attr_name.clone()); + } + _ => todo!(), + } + } + }, + } + } + } else { + // TODO: avoid clone + let mut spread_obj = jsx_props_to_obj(n).unwrap(); + spread_obj.props.push( + Prop::KeyValue(KeyValueProp { + key: PropName::Ident(IdentName::new("__spread".into(), DUMMY_SP)), + value: Expr::Lit(Lit::Bool(true.into())).into(), + }) + .into(), + ); + self.push_dynamic_spread(Expr::Object(spread_obj)); + } + } else { + let el = Expr::Ident(el.clone()); + + n.opening + .attrs + .iter_mut() + .for_each(|attr_or_spread| match attr_or_spread { + JSXAttrOrSpread::SpreadElement(_) => todo!(), + JSXAttrOrSpread::JSXAttr(JSXAttr { name, value, .. }) => { + match name { + JSXAttrName::Ident(ident_name) => { + let attr_name = AttrName::from(>::into(ident_name.clone())); + match &attr_name { + AttrName::Attr(name) => { + match value { + None => { + let stmt = quote!( + r#"__SetAttribute($element, $name, $value)"# as Stmt, + element: Expr = el.clone(), + name: Expr = name.clone().into(), + value: Expr = Expr::Lit(Lit::Bool(Bool {span: DUMMY_SP, value: true})) + ); + self.static_stmts.push(RefCell::new(stmt)); + } + Some(JSXAttrValue::Str(s)) => { + let value = transform_jsx_attr_str(&s.value); + let stmt = quote!( + r#"__SetAttribute($element, $name, $value)"# as Stmt, + element: Expr = el.clone(), + name: Expr = name.clone().into(), + value: Expr = Expr::Lit(Lit::Str(Str { span: s.span, value: value.into(), raw: None })) + ); + self.static_stmts.push(RefCell::new(stmt)); + } + Some(JSXAttrValue::JSXExprContainer(JSXExprContainer { + expr: JSXExpr::Expr(expr), + .. + })) => { + // expr.map_with_mut(|value| { + // value.fold_with(self.dynamic_part_visitor) + // }); + match &**expr { + Expr::Lit(value) => { + let stmt = quote!( + r#"__SetAttribute($element, $name, $value)"# as Stmt, + element: Expr = el.clone(), + name: Expr = name.clone().into(), + value: Expr = Expr::Lit(value.clone()) + ); + self.static_stmts.push(RefCell::new(stmt)); + } + _ => { + self.push_dynamic_attr(*expr.clone(), attr_name.clone()); + } + } + } + Some(JSXAttrValue::JSXExprContainer(JSXExprContainer { + expr: JSXExpr::JSXEmptyExpr(_), + .. + })) => {} + Some(JSXAttrValue::JSXElement(_)) => unreachable!("Unexpected JSXElement in JSX attribute value - not supported"), + Some(JSXAttrValue::JSXFragment(_)) => unreachable!("Unexpected JSXFragment in JSX attribute value - not supported"), + }; + } + AttrName::Dataset(name) => { + match value { + None => { + let stmt = quote!( + r#"__AddDataset($element, $name, $value)"# as Stmt, + element: Expr = el.clone(), + name: Expr = name.clone().into(), + value: Expr = Expr::Lit(Lit::Bool(Bool {span: DUMMY_SP, value: true})) + ); + self.static_stmts.push(RefCell::new(stmt)); + } + Some(JSXAttrValue::Str(s)) => { + let value = transform_jsx_attr_str(&s.value); + let stmt = quote!( + r#"__AddDataset($element, $name, $value)"# as Stmt, + element: Expr = el.clone(), + name: Expr = name.clone().into(), + value: Expr = Expr::Lit(Lit::Str(Str { span: s.span, value: value.into(), raw: None })) + ); + self.static_stmts.push(RefCell::new(stmt)); + } + Some(JSXAttrValue::JSXExprContainer(JSXExprContainer { + expr: JSXExpr::Expr(expr), + .. + })) => { + if !(self.enable_element_template && matches!(&**expr, Expr::Lit(_))) { + self.push_dynamic_attr(*expr.clone(), attr_name.clone()); + } + } + Some(JSXAttrValue::JSXExprContainer(JSXExprContainer { + expr: JSXExpr::JSXEmptyExpr(_), + .. + })) => {} + Some(JSXAttrValue::JSXElement(_)) => unreachable!("Unexpected JSXElement in JSX attribute value - not supported"), + Some(JSXAttrValue::JSXFragment(_)) => unreachable!("Unexpected JSXFragment in JSX attribute value - not supported"), + }; + } + AttrName::Event(..) | AttrName::Ref => { + self.push_dynamic_attr(*jsx_attr_value((*value).clone()), attr_name.clone()); + } + AttrName::TimingFlag => { + self.push_dynamic_attr( + *quote_expr!("{__ltf: $flag}", flag: Expr = *jsx_attr_value((*value).clone())), + attr_name.clone(), + ); + } + AttrName::Style => { + let mut static_style_val = None; + if let Some(JSXAttrValue::JSXExprContainer(JSXExprContainer { + expr: JSXExpr::Expr(expr), + span, + .. + })) = value + { + let expr = &**expr; + if is_literal(expr) { + if let Some(s) = get_string_inline_style_from_literal(expr, span) { + static_style_val = Some((s, *span)); + } + } + } + + if let Some((s_val, span)) = static_style_val { + if self.enable_element_template { + *value = Some(JSXAttrValue::Str(Str { + span, + value: s_val.into(), + raw: None, + })); + } else { + // ; + // ; + let s = Lit::Str(Str { + span, + value: s_val.into(), + raw: None, + }); + let stmt = quote!( + r#"__SetInlineStyles($element, $value)"# as Stmt, + element: Expr = el.clone(), + value: Expr = Expr::Lit(s) + ); + self.static_stmts.push(RefCell::new(stmt)); + } + } else { + match value { + None => {} + Some(JSXAttrValue::Str(s)) => { + // ; + let value = transform_jsx_attr_str(&s.value); + let stmt = quote!( + r#"__SetInlineStyles($element, $value)"# as Stmt, + element: Expr = el.clone(), + value: Expr = Expr::Lit(Lit::Str(Str { span: s.span, value: value.into(), raw: None })) + ); + self.static_stmts.push(RefCell::new(stmt)); + } + Some(JSXAttrValue::JSXExprContainer(JSXExprContainer { + expr: JSXExpr::Expr(expr), + .. + })) => { + self.push_dynamic_attr(*expr.clone(), attr_name.clone()); + } + Some(JSXAttrValue::JSXExprContainer(JSXExprContainer { + expr: JSXExpr::JSXEmptyExpr(_), + .. + })) => {} + Some(JSXAttrValue::JSXElement(_)) => unreachable!("Unexpected JSXElement in JSX attribute value - not supported"), + Some(JSXAttrValue::JSXFragment(_)) => unreachable!("Unexpected JSXFragment in JSX attribute value - not supported"), + } + } + } + AttrName::Class => { + match value { + None => {} + Some(JSXAttrValue::Str(s)) => { + let value = transform_jsx_attr_str(&s.value); + let stmt = quote!( + r#"__SetClasses($element, $value)"# as Stmt, + element: Expr = el.clone(), + value: Expr = Expr::Lit(Lit::Str(Str { span: s.span, value: value.into(), raw: None })) + ); + self.static_stmts.push(RefCell::new(stmt)); + } + Some(JSXAttrValue::JSXExprContainer(JSXExprContainer { + expr: JSXExpr::Expr(expr), + .. + })) => match &**expr { + Expr::Lit(value) => { + let stmt = quote!( + r#"__SetClasses($element, $value)"# as Stmt, + element: Expr = el.clone(), + value: Expr = Expr::Lit(value.clone()) + ); + self.static_stmts.push(RefCell::new(stmt)); + } + _ => { + self.push_dynamic_attr(*expr.clone(), attr_name.clone()); + } + }, + Some(JSXAttrValue::JSXExprContainer(JSXExprContainer { + expr: JSXExpr::JSXEmptyExpr(_), + .. + })) => {} + Some(JSXAttrValue::JSXElement(_)) => unreachable!("Unexpected JSXElement in JSX attribute value - not supported"), + Some(JSXAttrValue::JSXFragment(_)) => unreachable!("Unexpected JSXFragment in JSX attribute value - not supported"), + }; + } + AttrName::ID => { + match value { + None => {} + Some(JSXAttrValue::Str(s)) => { + let value = transform_jsx_attr_str(&s.value); + let stmt = quote!( + r#"__SetID($element, $value)"# as Stmt, + element: Expr = el.clone(), + value: Expr = Expr::Lit(Lit::Str(Str { span: s.span, value: value.into(), raw: None })) + ); + self.static_stmts.push(RefCell::new(stmt)); + } + Some(JSXAttrValue::JSXExprContainer(JSXExprContainer { + expr: JSXExpr::Expr(expr), + .. + })) => { + if !(self.enable_element_template && matches!(&**expr, Expr::Lit(_))) { + self.push_dynamic_attr(*expr.clone(), attr_name); + } + } + Some(JSXAttrValue::JSXExprContainer(JSXExprContainer { + expr: JSXExpr::JSXEmptyExpr(_), + .. + })) => {} + Some(JSXAttrValue::JSXElement(_)) => unreachable!("Unexpected JSXElement in JSX attribute value - not supported"), + Some(JSXAttrValue::JSXFragment(_)) => unreachable!("Unexpected JSXFragment in JSX attribute value - not supported"), + }; + } + AttrName::ListItemPlatformInfo => unreachable!("Unexpected ListItemPlatformInfo attribute in static JSX processing"), + AttrName::WorkletEvent(..) | AttrName::WorkletRef(..) => { + unreachable!("A worklet event should have an attribute namespace.") + } + AttrName::Gesture(..) => { + unreachable!("A gesture should have an attribute namespace.") + } + } + } + JSXAttrName::JSXNamespacedName(JSXNamespacedName { ns, name, .. }) => { + let attr_name: AttrName = AttrName::from_ns(ns.clone().into(), name.clone().into()); + match attr_name { + AttrName::WorkletEvent(..) | AttrName::WorkletRef(..) => { + self.push_dynamic_attr(*jsx_attr_value((*value).clone()), attr_name.clone()); + } + AttrName::Gesture(..) => { + self.push_dynamic_attr(*jsx_attr_value((*value).clone()), attr_name.clone()); + } + _ => todo!(), + } + } + }; + } + }); + } + + if let Some(parent_el) = &self.parent_element { + self.static_stmts.push(RefCell::new(quote!( + r#"__AppendElement($parent, $child)"# as Stmt, + parent: Ident = parent_el.clone(), + child: Ident = el.clone(), + ))); + }; + + let is_children_full_dynamic = jsx_is_children_full_dynamic(n); + + if !is_children_full_dynamic { + self.element_index += 1; + + let pre_parent_element = self.parent_element.take(); + self.parent_element = Some(el.clone()); + n.visit_mut_children_with(self); + self.parent_element = pre_parent_element; + } else { + n.visit_mut_children_with(self.dynamic_part_visitor); + let children_expr = jsx_children_to_expr(n.children.take()); + let slot_index = self.next_children_slot_index(); + if jsx_is_list(n) { + self + .dynamic_parts + .push(DynamicPart::ListSlot(children_expr, slot_index)); + } else { + self + .dynamic_parts + .push(DynamicPart::Slot(children_expr, slot_index)); + } + + if self.enable_element_template { + n.children = vec![JSXElementChild::JSXElement(Box::new(WRAPPER_NODE.clone()))]; + } + + self.element_index += 1; + } + + if self.parent_element.is_none() { + let elements = Expr::Array(ArrayLit { + span: DUMMY_SP, + elems: (0..self.element_ids.len()) + .step_by(1) + .map(|e| e as i32) + .map(|e| { + Some(ExprOrSpread { + spread: None, + expr: Box::new(Expr::Ident(self.element_ids[&e].clone())), + }) + }) + .collect(), + }); + + self.static_stmts.push(RefCell::new(quote!( + r#"return $elements;"# as Stmt, + elements: Expr = elements, + ))); + + self.snapshot_creator = Some(Function { + ctxt: SyntaxContext::default(), + params: match Lazy::::get(&self.si_id) { + Some(_) => vec![Param { + span: DUMMY_SP, + decorators: vec![], + pat: Pat::Ident(BindingIdent { + id: self.si_id.take(), + type_ann: None, + }), + }], + None => vec![], + }, + decorators: vec![], + span: DUMMY_SP, + body: Some(BlockStmt { + ctxt: SyntaxContext::default(), + span: DUMMY_SP, + stmts: self + .static_stmts + .take() + .into_iter() + .map(|mut stmt| stmt.get_mut().take()) + .collect(), + }), + is_generator: false, + is_async: false, + type_params: None, + return_type: None, + }); + }; + } else { + n.visit_mut_children_with(self.dynamic_part_visitor); + + if self.parent_element.is_some() { + let element_slot_index = self.next_children_slot_index(); + self.dynamic_parts.push(DynamicPart::Slot( + Expr::JSXElement(Box::new(n.take())), + element_slot_index, + )); + + // self.element_index += 1; + *n = WRAPPER_NODE.clone(); + n.visit_mut_with(self); + } + } + } + + fn visit_mut_jsx_text(&mut self, n: &mut JSXText) { + let t = jsx_text_to_str(&n.value); + + if !t.is_empty() { + let el = private_ident!("el"); + self.element_ids.insert(self.element_index, el.clone()); + + self.static_stmts.push(RefCell::new(quote!( + r#"const $element = __CreateRawText($t)"# as Stmt, + element = el.clone(), + t: Expr = t.into(), + ))); + + if let Some(parent_el) = &self.parent_element { + self.static_stmts.push(RefCell::new(quote!( + r#"__AppendElement($parent, $child)"# as Stmt, + parent: Ident = parent_el.clone(), + child: Ident = el.clone(), + ))); + }; + + self.element_index += 1; + } + } +} + +/// @internal +#[derive(Deserialize, PartialEq, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct JSXTransformerConfig { + /// @internal + pub preserve_jsx: bool, + /// @internal + pub runtime_pkg: String, + /// @internal + pub jsx_import_source: Option, + /// @internal + pub filename: String, + /// @internal + pub target: TransformTarget, + /// @internal + #[serde(default)] + pub enable_ui_source_map: bool, + /// @internal + pub is_dynamic_component: Option, + /// @internal + #[serde(default)] + pub enable_element_template: bool, +} + +impl Default for JSXTransformerConfig { + fn default() -> Self { + Self { + preserve_jsx: false, + runtime_pkg: "@lynx-js/react".into(), + jsx_import_source: Some("@lynx-js/react".into()), + filename: Default::default(), + target: TransformTarget::LEPUS, + enable_ui_source_map: false, + is_dynamic_component: Some(false), + enable_element_template: false, + } + } +} + +pub struct JSXTransformer +where + C: Comments + Clone, +{ + // react_transformer: Box, + cfg: JSXTransformerConfig, + filename_hash: String, + pub content_hash: String, + runtime_id: Lazy, + runtime_components_ident: Ident, + runtime_components_module_item: Option, + css_id_value: Option, + has_explicit_css_id: bool, + pub element_templates: Option>>>, + snapshot_counter: u32, + current_snapshot_defs: Vec, + current_snapshot_id: Option, + comments: Option, + slot_ident: Ident, + used_slot: bool, + pub ui_source_map_records: Rc>>, + pub source_map: Option>, +} + +impl JSXTransformer +where + C: Comments + Clone, +{ + pub fn with_content_hash(mut self, content_hash: String) -> Self { + self.content_hash = content_hash; + self + } + + pub fn new( + cfg: JSXTransformerConfig, + comments: Option, + mode: TransformMode, + source_map: Option>, + ) -> Self { + Self::new_with_element_templates(cfg, comments, mode, source_map, None) + } + + pub fn new_with_element_templates( + cfg: JSXTransformerConfig, + comments: Option, + mode: TransformMode, + source_map: Option>, + element_templates: Option>>>, + ) -> Self { + JSXTransformer { + filename_hash: calc_hash(&cfg.filename.clone()), + content_hash: "test".into(), + runtime_id: match mode { + TransformMode::Development => { + // We should find a way to use `cfg.runtime_pkg` + Lazy::new(|| quote!("require('@lynx-js/react/internal')" as Expr)) + } + TransformMode::Production | TransformMode::Test => { + Lazy::new(|| Expr::Ident(private_ident!("ReactLynx"))) + } + }, + runtime_components_ident: private_ident!("ReactLynxRuntimeComponents"), + runtime_components_module_item: None, + element_templates, + cfg, + css_id_value: None, + has_explicit_css_id: false, + snapshot_counter: 0, + current_snapshot_defs: vec![], + current_snapshot_id: None, + comments, + slot_ident: private_ident!("__etSlot"), + used_slot: false, + ui_source_map_records: Rc::new(RefCell::new(vec![])), + source_map, + } + } + + fn parse_directives(&mut self, span: Span) { + self.comments.with_leading(span.lo, |comments| { + for cmt in comments { + if cmt.kind != CommentKind::Block { + continue; + } + for line in cmt.text.lines() { + let mut line = line.trim(); + if line.starts_with('*') { + line = line[1..].trim(); + } + + if !line.starts_with("@jsx") { + continue; + } + + let mut words = line.split_whitespace(); + loop { + let pragma = words.next(); + if pragma.is_none() { + break; + } + let val = words.next(); + if let Some("@jsxCSSId") = pragma { + if let Some(css_id) = val { + self.css_id_value = Some(Expr::Lit(Lit::Num( + css_id + .parse::() + .expect("should have numeric cssId") + .into(), + ))); + self.has_explicit_css_id = true; + } + } + } + } + } + }); + } +} + +impl VisitMut for JSXTransformer +where + C: Comments + Clone, +{ + fn visit_mut_jsx_element(&mut self, node: &mut JSXElement) { + match *jsx_name(node.opening.name.clone()) { + Expr::Lit(lit) => { + if let Lit::Str(s) = &lit { + let tag = s.value.to_string_lossy(); + let tag_str = tag.as_ref(); + if tag_str == "wrapper" { + return node.visit_mut_children_with(self); + } + if tag_str == "page" { + if self.runtime_components_module_item.is_none() { + self.runtime_components_module_item = Some(quote!( + r#"import * as $runtime_components_ident from '@lynx-js/react/runtime-components';"# + as ModuleItem, + runtime_components_ident = self.runtime_components_ident.clone(), + )); + } + + if let JSXElementName::Ident(_ident) = &mut node.opening.name { + node.opening.name = JSXElementName::JSXMemberExpr(JSXMemberExpr { + obj: JSXObject::Ident(self.runtime_components_ident.clone()), + prop: private_ident!("Page").into(), + span: node.opening.span, + }); + + if let Some(JSXClosingElement { name, .. }) = &mut node.closing { + if let JSXElementName::Ident(ident) = name { + *name = JSXElementName::JSXMemberExpr(JSXMemberExpr { + obj: JSXObject::Ident(self.runtime_components_ident.clone()), + prop: private_ident!("Page").into(), + span: ident.span(), + }); + } + } + } + return node.visit_mut_children_with(self); + } + + if tag_str == "component" { + HANDLER.with(|handler| { + handler + .struct_span_err(node.opening.name.span(), " is not supported") + .emit() + }); + } + } + } + _ => { + return node.visit_mut_children_with(self); + } + } + + self.snapshot_counter += 1; + + let use_element_template = self.cfg.enable_element_template && self.element_templates.is_some(); + let snapshot_uid_prefix = if use_element_template { + "_et" + } else { + "__snapshot" + }; + let snapshot_uid = format!( + "{}_{}_{}_{}", + snapshot_uid_prefix, self.filename_hash, self.content_hash, self.snapshot_counter + ); + let snapshot_id = Ident::new( + // format!("__snapshot_{}", snapshot_uid).into(), + snapshot_uid.clone().into(), + DUMMY_SP, + SyntaxContext::default().apply_mark(Mark::fresh(Mark::root())), + ); + + let target = self.cfg.target; + let runtime_id = self.runtime_id.clone(); + let enable_element_template = use_element_template; + let filename_hash = self.filename_hash.clone(); + let content_hash = self.content_hash.clone(); + let ui_source_map_records = self.ui_source_map_records.clone(); + let snapshot_uid_for_captured = snapshot_uid.clone(); + let source_map = self.source_map.clone(); + let node_index_fn = move |span: Span| { + let ui_source_map = + calc_hash_number(&format!("{}:{}:{}", filename_hash, content_hash, span.lo.0)); + + let mut line_number = 0; + let mut column_number = 0; + if span.lo.0 > 0 { + if let Some(cm) = &source_map { + let loc = cm.lookup_char_pos(span.lo); + line_number = loc.line as u32; + column_number = loc.col.0 as u32 + 1; + } + } + + ui_source_map_records.borrow_mut().push(UISourceMapRecord { + ui_source_map, + line_number, + column_number, + snapshot_id: snapshot_uid_for_captured.clone(), + }); + + Expr::Lit(Lit::Num(Number { + span: DUMMY_SP, + value: ui_source_map as f64, + raw: None, + })) + }; + let (key, snapshot_creator_func, (dynamic_part_attr, dynamic_part_children)): ( + Option, + Option, + (Vec<_>, Vec<_>), + ) = { + let mut dynamic_part_extractor = DynamicPartExtractor::new( + self.runtime_id.clone(), + self, + enable_element_template, + self.cfg.enable_ui_source_map, + node_index_fn, + ); + + node.visit_mut_with(&mut dynamic_part_extractor); + + ( + dynamic_part_extractor.key, + dynamic_part_extractor.snapshot_creator, + dynamic_part_extractor.dynamic_parts.into_iter().partition( + |dynamic_part| match dynamic_part { + DynamicPart::Attr(_, _, _) | DynamicPart::Spread(_, _) => true, + DynamicPart::Slot(_, _) | DynamicPart::ListSlot(_, _) => false, + }, + ), + ) + }; + + let mut snapshot_children: Vec = vec![]; + let mut snapshot_slot_values: Vec = vec![]; + let mut snapshot_dynamic_part_def: Vec> = vec![]; + let mut snapshot_refs_and_spread_index: Vec> = vec![]; + let mut snapshot_slot_def: Vec> = vec![]; + let mut snapshot_values: Vec> = vec![]; + let mut snapshot_attrs: Vec = vec![]; + let mut snapshot_values_has_attr = false; + + if let Some(key) = key { + snapshot_attrs.push(JSXAttrOrSpread::JSXAttr(JSXAttr { + span: DUMMY_SP, + name: JSXAttrName::Ident(IdentName::new("key".into(), DUMMY_SP)), + value: Some(key), + })); + } + + dynamic_part_attr.into_iter().for_each(|dynamic_part| { + match &dynamic_part { + DynamicPart::Attr(_, _, _) | DynamicPart::Spread(_, _) => { + if let DynamicPart::Attr(_, _, AttrName::Ref) | DynamicPart::Spread(_, _) = dynamic_part { + snapshot_refs_and_spread_index.push(Some( + Expr::Lit(Lit::Num(snapshot_dynamic_part_def.len().into())).into(), + )); + } + snapshot_dynamic_part_def.push(Some(ExprOrSpread { + spread: None, + expr: Box::new(dynamic_part.to_updater( + runtime_id.clone(), + target, + snapshot_dynamic_part_def.len() as i32, + )), + })); + } + DynamicPart::Slot(_, _) => {} + DynamicPart::ListSlot(_, _) => {} + } + + match dynamic_part { + DynamicPart::Attr(value, _, attr_name) => { + let slot_value = if let AttrName::Event(_, _) = attr_name { + if target == TransformTarget::LEPUS { + quote!("1" as Expr) + } else { + value + } + } else if let AttrName::Ref = attr_name { + if target == TransformTarget::LEPUS { + quote!("1" as Expr) + } else { + quote!( + "$runtime_id.transformRef($value)" as Expr, + runtime_id: Expr = runtime_id.clone(), + value: Expr = value, + ) + } + } else { + value + }; + snapshot_values.push(Some(ExprOrSpread { + spread: None, + expr: Box::new(slot_value), + })); + snapshot_values_has_attr = true; + } + DynamicPart::Spread(value, _) => { + snapshot_values.push(Some(ExprOrSpread { + spread: None, + expr: Box::new(value), + })); + snapshot_values_has_attr = true; + } + DynamicPart::ListSlot(_, _) => {} + DynamicPart::Slot(_, _) => {} + } + }); + + let slot_expr = match (dynamic_part_children.len(), dynamic_part_children.first()) { + (0, _) => Expr::Lit(Lit::Null(Null { span: DUMMY_SP })), + (1, Some(DynamicPart::Slot(expr, 0))) => { + if use_element_template { + let child = expr_to_jsx_child(expr.clone()); + if target != TransformTarget::LEPUS { + self.used_slot = true; + } + snapshot_children.push(wrap_in_slot(&self.slot_ident, 0, vec![child])); + } else { + snapshot_slot_values.push(expr.clone()); + } + + if use_element_template { + quote!( + "$runtime_id.__DynamicPartChildren_0" as Expr, + runtime_id: Expr = runtime_id.clone(), + ) + } else { + quote!( + "$runtime_id.__DynamicPartSlotV2_0" as Expr, + runtime_id: Expr = runtime_id.clone(), + ) + } + } + _ => { + dynamic_part_children + .into_iter() + .for_each(|dynamic_part| match dynamic_part { + DynamicPart::Attr(_, _, _) => {} + DynamicPart::Spread(_, _) => {} + DynamicPart::ListSlot(expr, element_index) => { + if use_element_template { + let child = expr_to_jsx_child(expr); + if target != TransformTarget::LEPUS { + self.used_slot = true; + } + snapshot_children.push(wrap_in_slot(&self.slot_ident, element_index, vec![child])); + } else { + snapshot_slot_values.push(expr); + } + let runtime_slot = if use_element_template { + quote!( + "[$runtime_id.__DynamicPartListChildren, $element_index]" as Expr, + runtime_id: Expr = runtime_id.clone(), + element_index: Expr = i32_to_expr(&element_index), + ) + } else { + quote!( + "[$runtime_id.__DynamicPartListSlotV2, $element_index]" as Expr, + runtime_id: Expr = runtime_id.clone(), + element_index: Expr = i32_to_expr(&element_index), + ) + }; + snapshot_slot_def.push(Some(ExprOrSpread { + spread: None, + expr: Box::new(runtime_slot), + })); + } + DynamicPart::Slot(expr, element_index) => { + if use_element_template { + let child = expr_to_jsx_child(expr); + if target != TransformTarget::LEPUS { + self.used_slot = true; + } + snapshot_children.push(wrap_in_slot(&self.slot_ident, element_index, vec![child])); + } else { + snapshot_slot_values.push(expr); + } + let runtime_slot = if use_element_template { + quote!( + "[$runtime_id.__DynamicPartChildren, $element_index]" as Expr, + runtime_id: Expr = runtime_id.clone(), + element_index: Expr = i32_to_expr(&element_index), + ) + } else { + quote!( + "[$runtime_id.__DynamicPartSlotV2, $element_index]" as Expr, + runtime_id: Expr = runtime_id.clone(), + element_index: Expr = i32_to_expr(&element_index), + ) + }; + snapshot_slot_def.push(Some(ExprOrSpread { + spread: None, + expr: Box::new(runtime_slot), + })); + } + }); + + Expr::Array(ArrayLit { + span: DUMMY_SP, + elems: snapshot_slot_def, + }) + } + }; + + let snapshot_creator = if target == TransformTarget::JS { + Expr::Lit(Lit::Null(Null { span: DUMMY_SP })) + } else { + Expr::Fn(FnExpr { + ident: None, + function: Box::new(snapshot_creator_func.unwrap()), + }) + }; + + let snapshot_create_call = quote!( + r#"$runtime_id.snapshotCreatorMap[$snapshot_id] = ($snapshot_id) => $runtime_id.createSnapshot( + $snapshot_id, + $snapshot_creator, + $snapshot_dynamic_parts_def, + $slot, + $css_id, + globDynamicComponentEntry, + $snapshot_refs_and_spread_index, + true + )"# as Expr, + runtime_id: Expr = self.runtime_id.clone(), + snapshot_id = snapshot_id.clone(), + snapshot_creator: Expr = snapshot_creator, + snapshot_dynamic_parts_def: Expr = match (target, snapshot_dynamic_part_def.len()) { + (TransformTarget::JS, _) | (_, 0) => Expr::Lit(Lit::Null(Null { span: DUMMY_SP })), + _ => Expr::Array(ArrayLit { span: DUMMY_SP, elems: snapshot_dynamic_part_def }), + }, + slot: Expr = slot_expr, + css_id: Expr = match &self.css_id_value { + Some(css_id_expr) => css_id_expr.clone(), + // We use `undefined` here since runtime will skip `__SetCSSId` when `cssId === undefined && entryName === undefined` + None => Expr::Ident("undefined".into()), + }, + snapshot_refs_and_spread_index: Expr = match snapshot_refs_and_spread_index.len() { + 0 => Expr::Lit(Lit::Null(Null { span: DUMMY_SP })), + _ => Expr::Array(ArrayLit { span: DUMMY_SP, elems: snapshot_refs_and_spread_index }), + }, + // has_multi_children: Expr = Expr::Lit(Lit::Num(Number { span: DUMMY_SP, value: wrap_dynamic_part.dynamic_part_count as f64, raw: None })), + ); + + let mut entry_snapshot_uid = quote!("$snapshot_uid" as Expr, snapshot_uid: Expr = Expr::Lit(Lit::Str(snapshot_uid.clone().into()))); + if matches!(self.cfg.is_dynamic_component, Some(true)) { + entry_snapshot_uid = quote!("`${globDynamicComponentEntry}:${$snapshot_uid}`" as Expr, snapshot_uid: Expr = Expr::Lit(Lit::Str(snapshot_uid.clone().into()))); + } + + let entry_snapshot_uid_def = ModuleItem::Stmt(quote!( + r#"const $snapshot_id = $entry_snapshot_uid"# + as Stmt, + snapshot_id = snapshot_id.clone(), + entry_snapshot_uid: Expr = entry_snapshot_uid.clone(), + )); + self.current_snapshot_id = Some(snapshot_id.clone()); + self.current_snapshot_defs.push(entry_snapshot_uid_def); + + if use_element_template { + let mut attr_slot_index: i32 = 0; + let mut element_slot_index: i32 = 0; + // Template Definition slot counters are derived from the original JSX tree, + // while the runtime value arrays below are produced by DynamicPartExtractor. + // Keeping both passes in this function makes slot-order drift obvious in + // contract tests instead of hiding it behind a shared mutable collector. + let template_expr = + self.element_template_from_jsx_element(node, &mut attr_slot_index, &mut element_slot_index); + let compiled_template = self.element_template_to_json(&template_expr); + + // TODO(element-template): reintroduce cssId/entryName metadata once the + // runtime/native contract grows a dedicated replacement channel. + + if let Some(element_templates) = &self.element_templates { + element_templates.borrow_mut().push(ElementTemplateAsset { + template_id: snapshot_uid.clone(), + compiled_template, + source_file: self.cfg.filename.clone(), + }); + } + } else { + let snapshot_def = ModuleItem::Stmt(quote!( + r#"$snapshot_create_call"# + as Stmt, + snapshot_create_call: Expr = snapshot_create_call, + )); + self.current_snapshot_defs.push(snapshot_def); + } + + let inline_children_attr = if use_element_template + && target == TransformTarget::LEPUS + && !snapshot_children.is_empty() + { + let children_expr = jsx_children_to_expr(snapshot_children.clone()); + // LEPUS ET host nodes rely on children being lowered to slot arrays. + // Failing fast here keeps the transform/runtime contract explicit instead + // of silently emitting a shape the main-thread runtime no longer accepts. + let lowered_children_expr = + lower_lepus_et_children_expr(children_expr.clone(), &self.slot_ident) + .expect("LEPUS ET children should already be lowered to slot arrays"); + Some(JSXAttrOrSpread::JSXAttr(JSXAttr { + span: DUMMY_SP, + name: JSXAttrName::Ident(IdentName::new("children".into(), DUMMY_SP)), + value: Some(JSXAttrValue::JSXExprContainer(JSXExprContainer { + span: DUMMY_SP, + expr: JSXExpr::Expr(Box::new(lowered_children_expr)), + })), + })) + } else { + None + }; + + let rendered_children = if !use_element_template || inline_children_attr.is_some() { + vec![] + } else { + snapshot_children + }; + let rendered_children_is_empty = rendered_children.is_empty(); + + *node = JSXElement { + span: node.span(), + opening: JSXOpeningElement { + name: JSXElementName::Ident(snapshot_id.clone()), + span: node.span, + attrs: { + if snapshot_values_has_attr { + if use_element_template { + snapshot_attrs.push(JSXAttrOrSpread::JSXAttr(JSXAttr { + span: DUMMY_SP, + name: JSXAttrName::Ident(IdentName::new("attributeSlots".into(), DUMMY_SP)), + value: Some(JSXAttrValue::JSXExprContainer(JSXExprContainer { + span: DUMMY_SP, + expr: JSXExpr::Expr(Box::new(Expr::Array(ArrayLit { + span: DUMMY_SP, + elems: snapshot_values, + }))), + })), + })); + } else { + snapshot_attrs.push(JSXAttrOrSpread::JSXAttr(JSXAttr { + span: DUMMY_SP, + name: JSXAttrName::Ident(IdentName::new("values".into(), DUMMY_SP)), + value: Some(JSXAttrValue::JSXExprContainer(JSXExprContainer { + span: DUMMY_SP, + expr: JSXExpr::Expr(Box::new(Expr::Array(ArrayLit { + span: DUMMY_SP, + elems: snapshot_values, + }))), + })), + })) + } + }; + if !use_element_template { + snapshot_attrs.extend(snapshot_slot_values.iter_mut().enumerate().map( + |(index, child)| { + JSXAttrOrSpread::JSXAttr(JSXAttr { + span: DUMMY_SP, + name: JSXAttrName::Ident(IdentName::new(format!("${index}").into(), DUMMY_SP)), + value: Some(JSXAttrValue::JSXExprContainer(JSXExprContainer { + span: DUMMY_SP, + expr: JSXExpr::Expr(Box::new(child.take())), + })), + }) + }, + )); + } + if let Some(children_attr) = inline_children_attr { + snapshot_attrs.push(children_attr); + } + snapshot_attrs + }, + self_closing: rendered_children_is_empty, + type_args: None, + }, + children: rendered_children, + closing: if rendered_children_is_empty { + None + } else { + Some(JSXClosingElement { + name: JSXElementName::Ident(snapshot_id.clone()), + span: DUMMY_SP, + }) + }, + }; + } + + fn visit_mut_module_items(&mut self, n: &mut Vec) { + let mut new_items: Vec = vec![]; + for item in n.iter_mut() { + item.visit_mut_with(self); + new_items.extend(self.current_snapshot_defs.take()); + new_items.push(item.take()); + } + + if let Some(module_item) = &self.runtime_components_module_item { + new_items.insert(0, module_item.clone()); + } + + *n = new_items; + } + + fn visit_mut_module(&mut self, n: &mut Module) { + self.parse_directives(n.span); + for item in &n.body { + let span = item.span(); + self.parse_directives(span); + } + + if matches!(self.cfg.is_dynamic_component, Some(true)) && self.css_id_value.is_none() { + self.css_id_value = Some(Expr::Lit(Lit::Num(0.into()))); + } + + n.visit_mut_children_with(self); + if self.cfg.enable_element_template { + self.ensure_builtin_element_templates(); + } + if let Some(Expr::Ident(runtime_id)) = Lazy::::get(&self.runtime_id) { + prepend_stmt( + &mut n.body, + ModuleItem::ModuleDecl(ModuleDecl::Import(ImportDecl { + span: DUMMY_SP, + specifiers: vec![ImportSpecifier::Namespace(ImportStarAsSpecifier { + span: DUMMY_SP, + local: runtime_id.clone(), + })], + src: Box::new(Str { + span: DUMMY_SP, + raw: None, + value: self.cfg.runtime_pkg.clone().into(), + }), + type_only: Default::default(), + // asserts: Default::default(), + with: Default::default(), + phase: ImportPhase::Evaluation, + })), + ); + } + + if self.cfg.enable_element_template && self.used_slot { + prepend_stmt( + &mut n.body, + ModuleItem::ModuleDecl(ModuleDecl::Import(ImportDecl { + span: DUMMY_SP, + specifiers: vec![ImportSpecifier::Named(ImportNamedSpecifier { + span: DUMMY_SP, + local: self.slot_ident.clone(), + imported: Some(ModuleExportName::Ident(Ident::new( + "__etSlot".into(), + DUMMY_SP, + SyntaxContext::default(), + ))), + is_type_only: false, + })], + src: Box::new(Str { + span: DUMMY_SP, + raw: None, + value: self.cfg.runtime_pkg.clone().into(), + }), + type_only: Default::default(), + with: Default::default(), + phase: ImportPhase::Evaluation, + })), + ); + } + } +} + +// #[plugin_transform] +// pub fn process_transform(program: Program, metadata: TransformPluginProgramMetadata) -> Program { +// let filename = metadata +// .get_context(&TransformPluginMetadataContextKind::Filename) +// .unwrap(); + +// program.fold_with(&mut JSXTransformer { +// // filename: "index.js".into(), +// filename, +// snapshot_counter: 0, +// current_snapshot_defs: vec![], +// }) +// } + +#[cfg(test)] +mod tests { + use swc_core::{ + common::{comments::SingleThreadedComments, Mark}, + ecma::{ + parser::{EsSyntax, Syntax}, + transforms::{base::resolver, react, testing::test}, + visit::visit_mut_pass, + }, + }; + + use crate::JSXTransformer; + use swc_plugins_shared::{target::TransformTarget, transform_mode::TransformMode}; + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| { + let unresolved_mark = Mark::new(); + let top_level_mark = Mark::new(); + + ( + resolver(unresolved_mark, top_level_mark, true), + visit_mut_pass(JSXTransformer::new( + super::JSXTransformerConfig { + preserve_jsx: true, + enable_ui_source_map: true, + ..Default::default() + }, + Some(t.comments.clone()), + TransformMode::Test, + Some(t.cm.clone()), + )), + ) + }, + basic_full_static, + // Input codes + r#" + + !!! + + + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| { + let unresolved_mark = Mark::new(); + let top_level_mark = Mark::new(); + + ( + resolver(unresolved_mark, top_level_mark, true), + visit_mut_pass(JSXTransformer::new( + super::JSXTransformerConfig { + preserve_jsx: true, + enable_ui_source_map: true, + ..Default::default() + }, + Some(t.comments.clone()), + TransformMode::Test, + Some(t.cm.clone()), + )), + ) + }, + full_static_children_self_close, + // Input codes + r#" + + + + + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| { + let unresolved_mark = Mark::new(); + let top_level_mark = Mark::new(); + + ( + resolver(unresolved_mark, top_level_mark, true), + visit_mut_pass(JSXTransformer::new( + super::JSXTransformerConfig { + preserve_jsx: true, + enable_ui_source_map: true, + ..Default::default() + }, + Some(t.comments.clone()), + TransformMode::Test, + Some(t.cm.clone()), + )), + ) + }, + full_static_children_new_line, + // Input codes + r#" + + + + + + + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| { + let unresolved_mark = Mark::new(); + let top_level_mark = Mark::new(); + + ( + resolver(unresolved_mark, top_level_mark, true), + visit_mut_pass(JSXTransformer::new( + super::JSXTransformerConfig { + preserve_jsx: true, + ..Default::default() + }, + Some(t.comments.clone()), + TransformMode::Test, + None, + )), + ) + }, + full_static_children_comments, + // Input codes + r#" + + + {/** foo */} + + + {/** bar */} + + + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| { + let unresolved_mark = Mark::new(); + let top_level_mark = Mark::new(); + + ( + resolver(unresolved_mark, top_level_mark, true), + visit_mut_pass(JSXTransformer::new( + super::JSXTransformerConfig { + preserve_jsx: true, + ..Default::default() + }, + Some(t.comments.clone()), + TransformMode::Test, + None, + )), + ) + }, + full_static_children_map_jsx, + // Input codes + r#" + + {[].map(() => null)} + {[].map(() => null)} + + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| { + let unresolved_mark = Mark::new(); + let top_level_mark = Mark::new(); + + ( + resolver(unresolved_mark, top_level_mark, true), + visit_mut_pass(JSXTransformer::new( + super::JSXTransformerConfig { + preserve_jsx: true, + ..Default::default() + }, + Some(t.comments.clone()), + TransformMode::Test, + None, + )), + ) + }, + basic_full_static_snapshot_extract, + // Input codes + r#"let s = __SNAPSHOT__(!!!);"# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| { + let unresolved_mark = Mark::new(); + let top_level_mark = Mark::new(); + + ( + resolver(unresolved_mark, top_level_mark, true), + visit_mut_pass(JSXTransformer::new( + super::JSXTransformerConfig { + preserve_jsx: true, + ..Default::default() + }, + Some(t.comments.clone()), + TransformMode::Test, + None, + )), + ) + }, + basic_full_static_snapshot_extract_it, + // Input codes + r#" + it('basic', async function() { + const run = withEnv(function() { + let s = __SNAPSHOT__(!!!); + }); + await run(); + }); + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| visit_mut_pass(JSXTransformer::new( + super::JSXTransformerConfig { + preserve_jsx: true, + ..Default::default() + }, + Some(t.comments.clone()), + TransformMode::Test, + None + )), + basic_component, + // Input codes + r#" + + + + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| visit_mut_pass(JSXTransformer::new( + super::JSXTransformerConfig { + preserve_jsx: true, + ..Default::default() + }, + Some(t.comments.clone()), + TransformMode::Test, + None + )), + page_component, + // Input codes + r#" + + + + + + + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| visit_mut_pass(JSXTransformer::new( + super::JSXTransformerConfig { + preserve_jsx: true, + ..Default::default() + }, + Some(t.comments.clone()), + TransformMode::Development, + None + )), + page_element_dev, + // Input codes + r#" + + + + + + + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| visit_mut_pass(JSXTransformer::new( + super::JSXTransformerConfig { + preserve_jsx: true, + ..Default::default() + }, + Some(t.comments.clone()), + TransformMode::Test, + None + )), + page_element, + // Input codes + r#" + + + + + + + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| visit_mut_pass(JSXTransformer::new( + super::JSXTransformerConfig { + preserve_jsx: true, + ..Default::default() + }, + Some(t.comments.clone()), + TransformMode::Test, + None + )), + basic_component_with_static_sibling, + // Input codes + r#" + + !!! + + + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| { + let top_level_mark = Mark::new(); + let unresolved_mark = Mark::new(); + ( + visit_mut_pass(JSXTransformer::<&SingleThreadedComments>::new( + super::JSXTransformerConfig { + preserve_jsx: false, + ..Default::default() + }, + None, + TransformMode::Test, + None, + )), + react::react::<&SingleThreadedComments>( + t.cm.clone(), + None, + react::Options { + next: Some(false), + runtime: Some(react::Runtime::Automatic), + import_source: Some("@lynx-js/react".into()), + pragma: None, + pragma_frag: None, + throw_if_namespace: None, + development: Some(false), + refresh: None, + ..Default::default() + }, + top_level_mark, + unresolved_mark, + ), + ) + }, + basic_component_with_static_sibling_jsx, + // Input codes + r#" + + !!! + + + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| { + let top_level_mark = Mark::new(); + let unresolved_mark = Mark::new(); + ( + visit_mut_pass(JSXTransformer::<&SingleThreadedComments>::new( + super::JSXTransformerConfig { + preserve_jsx: false, + ..Default::default() + }, + None, + TransformMode::Test, + None, + )), + react::react::<&SingleThreadedComments>( + t.cm.clone(), + None, + react::Options { + next: Some(false), + runtime: Some(react::Runtime::Automatic), + import_source: Some("@lynx-js/react".into()), + pragma: None, + pragma_frag: None, + throw_if_namespace: None, + development: Some(true), + refresh: None, + ..Default::default() + }, + top_level_mark, + unresolved_mark, + ), + ) + }, + basic_component_with_static_sibling_jsx_dev, + // Input codes + r#" + + !!! + + + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| visit_mut_pass(JSXTransformer::new( + super::JSXTransformerConfig { + preserve_jsx: true, + ..Default::default() + }, + Some(t.comments.clone()), + TransformMode::Test, + None + )), + basic_expr_container, + // Input codes + r#" + + {a} + + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| visit_mut_pass(JSXTransformer::new( + super::JSXTransformerConfig { + preserve_jsx: true, + ..Default::default() + }, + Some(t.comments.clone()), + TransformMode::Test, + None + )), + basic_expr_container_with_static_sibling, + // Input codes + r#" + + !!! + {a} + + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| visit_mut_pass(JSXTransformer::new( + super::JSXTransformerConfig { + preserve_jsx: true, + ..Default::default() + }, + Some(t.comments.clone()), + TransformMode::Test, + None + )), + should_inject_implicit_flatten, + // Input codes + r#" + + + + + + + {desc} + + {unit} + + {unit} + {unit} + + ; + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| visit_mut_pass(JSXTransformer::new( + super::JSXTransformerConfig { + preserve_jsx: true, + ..Default::default() + }, + Some(t.comments.clone()), + TransformMode::Test, + None + )), + should_static_extract_inline_style, + // Input codes + r#" + ; + ; + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| visit_mut_pass(JSXTransformer::new( + super::JSXTransformerConfig { + preserve_jsx: true, + ..Default::default() + }, + Some(t.comments.clone()), + TransformMode::Test, + None + )), + should_static_extract_dynamic_inline_style, + // Input codes + r#" + ; + ; + ; + ; + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| visit_mut_pass(JSXTransformer::new( + super::JSXTransformerConfig { + preserve_jsx: true, + ..Default::default() + }, + Some(t.comments.clone()), + TransformMode::Test, + None + )), + should_extract_css_id_without_css_id, + // Input codes + r#" + ; + ; + ; + ; + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| visit_mut_pass(JSXTransformer::new( + super::JSXTransformerConfig { + preserve_jsx: true, + ..Default::default() + }, + Some(t.comments.clone()), + TransformMode::Test, + None + )), + should_extract_css_id, + // Input codes + r#" +/** + * @jsxCSSId 100 + */ + ; + ; + ; + ; + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| visit_mut_pass(JSXTransformer::new( + super::JSXTransformerConfig { + preserve_jsx: true, + is_dynamic_component: Some(true), + ..Default::default() + }, + Some(t.comments.clone()), + TransformMode::Test, + None + )), + should_extract_css_id_dynamic_component, + // Input codes + r#" +/** + * @jsxCSSId 100 + */ + ; + ; + ; + ; + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| visit_mut_pass(JSXTransformer::new( + super::JSXTransformerConfig { + preserve_jsx: true, + is_dynamic_component: Some(true), + ..Default::default() + }, + Some(t.comments.clone()), + TransformMode::Test, + None + )), + should_extract_css_id_dynamic_component_without_css_id, + // Input codes + r#" + ; + ; + ; + ; + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| { + let top_level_mark = Mark::new(); + let unresolved_mark = Mark::new(); + ( + visit_mut_pass(JSXTransformer::<&SingleThreadedComments>::new( + super::JSXTransformerConfig { + preserve_jsx: false, + ..Default::default() + }, + None, + TransformMode::Test, + None, + )), + react::react::<&SingleThreadedComments>( + t.cm.clone(), + None, + react::Options { + next: Some(false), + runtime: Some(react::Runtime::Automatic), + import_source: Some("@lynx-js/react".into()), + pragma: None, + pragma_frag: None, + throw_if_namespace: None, + development: Some(false), + refresh: None, + ..Default::default() + }, + top_level_mark, + unresolved_mark, + ), + ) + }, + basic_spread, + // Input codes + r#" + + !!! + + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |_| { + visit_mut_pass(JSXTransformer::<&SingleThreadedComments>::new( + super::JSXTransformerConfig { + preserve_jsx: false, + ..Default::default() + }, + None, + TransformMode::Test, + None, + )) + }, + inline_style_literal, + // Input codes + r#" + + + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |_| { + visit_mut_pass(JSXTransformer::<&SingleThreadedComments>::new( + super::JSXTransformerConfig { + preserve_jsx: false, + ..Default::default() + }, + None, + TransformMode::Test, + None, + )) + }, + inline_style_literal_unknown_property, + // Input codes + r#" + + + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |_| { + visit_mut_pass(JSXTransformer::<&SingleThreadedComments>::new( + super::JSXTransformerConfig { + preserve_jsx: false, + ..Default::default() + }, + None, + TransformMode::Test, + None, + )) + }, + empty_module, + // Input codes + r#" + console.log('hello, world') + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |_| { + visit_mut_pass(JSXTransformer::<&SingleThreadedComments>::new( + super::JSXTransformerConfig { + preserve_jsx: false, + runtime_pkg: "@lynx-js/react/internal".into(), + ..Default::default() + }, + None, + TransformMode::Development, + None, + )) + }, + mode_development_spread, + // Input codes + r#" + + + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| { + let top_level_mark = Mark::new(); + let unresolved_mark = Mark::new(); + ( + visit_mut_pass(JSXTransformer::<&SingleThreadedComments>::new( + super::JSXTransformerConfig { + preserve_jsx: false, + target: TransformTarget::MIXED, + ..Default::default() + }, + None, + TransformMode::Development, + None, + )), + react::react::<&SingleThreadedComments>( + t.cm.clone(), + None, + react::Options { + next: Some(false), + runtime: Some(react::Runtime::Automatic), + import_source: Some("@lynx-js/react".into()), + pragma: None, + pragma_frag: None, + throw_if_namespace: None, + development: Some(false), + refresh: None, + ..Default::default() + }, + top_level_mark, + unresolved_mark, + ), + ) + }, + basic_event, + // Input codes + r#" + function Comp() { + const handleTap = () => {} + return ( + + 1 + + ) + } + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| { + let top_level_mark = Mark::new(); + let unresolved_mark = Mark::new(); + ( + visit_mut_pass(JSXTransformer::<&SingleThreadedComments>::new( + super::JSXTransformerConfig { + preserve_jsx: false, + target: TransformTarget::MIXED, + ..Default::default() + }, + None, + TransformMode::Development, + None, + )), + react::react::<&SingleThreadedComments>( + t.cm.clone(), + None, + react::Options { + next: Some(false), + runtime: Some(react::Runtime::Automatic), + import_source: Some("@lynx-js/react".into()), + pragma: None, + pragma_frag: None, + throw_if_namespace: None, + development: Some(false), + refresh: None, + ..Default::default() + }, + top_level_mark, + unresolved_mark, + ), + ) + }, + basic_ref, + // Input codes + r#" + function Comp() { + const handleRef = () => {} + return ( + + 1 + 2 + 3 + + ) + } + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| { + let top_level_mark = Mark::new(); + let unresolved_mark = Mark::new(); + ( + visit_mut_pass(JSXTransformer::<&SingleThreadedComments>::new( + super::JSXTransformerConfig { + preserve_jsx: false, + target: TransformTarget::MIXED, + ..Default::default() + }, + None, + TransformMode::Development, + None, + )), + react::react::<&SingleThreadedComments>( + t.cm.clone(), + None, + react::Options { + next: Some(false), + runtime: Some(react::Runtime::Automatic), + import_source: Some("@lynx-js/react".into()), + pragma: None, + pragma_frag: None, + throw_if_namespace: None, + development: Some(false), + refresh: None, + ..Default::default() + }, + top_level_mark, + unresolved_mark, + ), + ) + }, + worklet, + // Input codes + r#" + function Comp() { + const handleTap = () => {} + const handleRef = () => {} + return ( + + 1 + 1 + + ) + } + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| { + let top_level_mark = Mark::new(); + let unresolved_mark = Mark::new(); + ( + visit_mut_pass(JSXTransformer::<&SingleThreadedComments>::new( + super::JSXTransformerConfig { + preserve_jsx: false, + target: TransformTarget::MIXED, + ..Default::default() + }, + None, + TransformMode::Development, + None, + )), + react::react::<&SingleThreadedComments>( + t.cm.clone(), + None, + react::Options { + next: Some(false), + runtime: Some(react::Runtime::Automatic), + import_source: Some("@lynx-js/react".into()), + pragma: None, + pragma_frag: None, + throw_if_namespace: None, + development: Some(false), + refresh: None, + ..Default::default() + }, + top_level_mark, + unresolved_mark, + ), + ) + }, + gesture, + // Input codes + r#" + function Comp() { + const gesture = {} + return ( + + 1 + + ) + } + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| { + let top_level_mark = Mark::new(); + let unresolved_mark = Mark::new(); + ( + visit_mut_pass(JSXTransformer::<&SingleThreadedComments>::new( + super::JSXTransformerConfig { + preserve_jsx: false, + target: TransformTarget::MIXED, + ..Default::default() + }, + None, + TransformMode::Development, + None, + )), + react::react::<&SingleThreadedComments>( + t.cm.clone(), + None, + react::Options { + next: Some(false), + runtime: Some(react::Runtime::Automatic), + import_source: Some("@lynx-js/react".into()), + pragma: None, + pragma_frag: None, + throw_if_namespace: None, + development: Some(false), + refresh: None, + ..Default::default() + }, + top_level_mark, + unresolved_mark, + ), + ) + }, + basic_timing_flag, + // Input codes + r#" + function Comp() { + return ( + + 1 + + ) + } + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| visit_mut_pass(JSXTransformer::new( + super::JSXTransformerConfig { + preserve_jsx: true, + ..Default::default() + }, + Some(t.comments.clone()), + TransformMode::Test, + None + )), + should_escape_newline_character, + // Input codes + r#" + + + + + + + + + + + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| visit_mut_pass(JSXTransformer::new( + super::JSXTransformerConfig { + preserve_jsx: true, + ..Default::default() + }, + Some(t.comments.clone()), + TransformMode::Test, + None + )), + should_wrap_dynamic_key, + // Input codes + r#" + + Hello, ReactLynx, {hello} + {hello} + {hello} + + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| visit_mut_pass(JSXTransformer::new( + super::JSXTransformerConfig { + preserve_jsx: true, + ..Default::default() + }, + Some(t.comments.clone()), + TransformMode::Test, + None + )), + should_set_attribute_for_text_node, + // Input codes + r#" + + + + + + + + + "# + ); + + test!( + module, + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + |t| visit_mut_pass(JSXTransformer::new( + super::JSXTransformerConfig { + preserve_jsx: true, + ..Default::default() + }, + Some(t.comments.clone()), + TransformMode::Test, + None + )), + should_create_raw_text_node_for_text_node, + // Input codes + r#" + + {hello}, ReactLynx 1 + {hello} + + Hello + + + Hello, ReactLynx 3 + + "# + ); +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/napi.rs b/packages/react/transform/crates/swc_plugin_element_template/napi.rs new file mode 100644 index 0000000000..4e39cd8209 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/napi.rs @@ -0,0 +1,223 @@ +use std::{cell::RefCell, rc::Rc}; + +use napi_derive::napi; +use swc_core::{ + common::{comments::Comments, sync::Lrc, SourceMap}, + ecma::{ast::*, visit::VisitMut}, +}; +use swc_plugins_shared::{target_napi::TransformTarget, transform_mode_napi::TransformMode}; + +use crate::{ + ElementTemplateAsset as CoreElementTemplateAsset, + ElementTemplateTransformer as CoreElementTemplateTransformer, + ElementTemplateTransformerConfig as CoreElementTemplateTransformerConfig, + ElementTemplateUISourceMapRecord as CoreElementTemplateUISourceMapRecord, +}; + +/// @internal +#[napi(object)] +#[derive(Clone, Debug)] +pub struct ElementTemplateAsset { + /// @internal + #[napi(js_name = "templateId")] + pub template_id: String, + /// @internal + #[napi(js_name = "compiledTemplate")] + pub compiled_template: serde_json::Value, + /// @internal + #[napi(js_name = "sourceFile")] + pub source_file: String, +} + +impl From for ElementTemplateAsset { + fn from(val: CoreElementTemplateAsset) -> Self { + Self { + template_id: val.template_id, + compiled_template: val.compiled_template, + source_file: val.source_file, + } + } +} + +/// @internal +#[napi(object)] +#[derive(Clone, Debug)] +pub struct UISourceMapRecord { + pub ui_source_map: i32, + pub filename: String, + pub line_number: u32, + pub column_number: u32, + pub snapshot_id: String, +} + +impl From for CoreElementTemplateUISourceMapRecord { + fn from(val: UISourceMapRecord) -> Self { + Self { + ui_source_map: val.ui_source_map, + line_number: val.line_number, + column_number: val.column_number, + snapshot_id: val.snapshot_id, + } + } +} + +impl From for UISourceMapRecord { + fn from(val: CoreElementTemplateUISourceMapRecord) -> Self { + Self { + ui_source_map: val.ui_source_map, + filename: String::new(), + line_number: val.line_number, + column_number: val.column_number, + snapshot_id: val.snapshot_id, + } + } +} + +/// @internal +#[napi(object)] +#[derive(Clone, Debug)] +pub struct JSXTransformerConfig { + /// @internal + pub preserve_jsx: bool, + /// @internal + pub runtime_pkg: String, + /// @internal + pub jsx_import_source: Option, + /// @internal + pub filename: String, + /// @internal + #[napi(ts_type = "'LEPUS' | 'JS' | 'MIXED'")] + pub target: TransformTarget, + /// @internal + pub enable_ui_source_map: Option, + /// @internal + pub is_dynamic_component: Option, +} + +impl Default for JSXTransformerConfig { + fn default() -> Self { + Self { + preserve_jsx: false, + runtime_pkg: "@lynx-js/react".into(), + jsx_import_source: Some("@lynx-js/react".into()), + filename: Default::default(), + target: TransformTarget::LEPUS, + enable_ui_source_map: Some(false), + is_dynamic_component: Some(false), + } + } +} + +impl From for CoreElementTemplateTransformerConfig { + fn from(val: JSXTransformerConfig) -> Self { + Self { + preserve_jsx: val.preserve_jsx, + runtime_pkg: val.runtime_pkg, + jsx_import_source: val.jsx_import_source, + filename: val.filename, + target: val.target.into(), + enable_ui_source_map: val.enable_ui_source_map.unwrap_or(false), + is_dynamic_component: val.is_dynamic_component, + enable_element_template: true, + } + } +} + +impl From for JSXTransformerConfig { + fn from(val: CoreElementTemplateTransformerConfig) -> Self { + Self { + preserve_jsx: val.preserve_jsx, + runtime_pkg: val.runtime_pkg, + jsx_import_source: val.jsx_import_source, + filename: val.filename, + target: val.target.into(), + enable_ui_source_map: Some(val.enable_ui_source_map), + is_dynamic_component: val.is_dynamic_component, + } + } +} + +pub struct JSXTransformer +where + C: Comments + Clone, +{ + inner: CoreElementTemplateTransformer, + pub ui_source_map_records: Rc>>, + pub element_templates: Rc>>, +} + +impl JSXTransformer +where + C: Comments + Clone, +{ + pub fn new_with_element_templates( + cfg: JSXTransformerConfig, + comments: Option, + mode: TransformMode, + source_map: Option>, + element_templates: Option>>>, + ) -> Self { + let element_templates = element_templates.unwrap_or_else(|| Rc::new(RefCell::new(vec![]))); + let inner = CoreElementTemplateTransformer::new_with_element_templates( + cfg.into(), + comments, + mode.into(), + source_map, + Some(element_templates.clone()), + ); + Self { + ui_source_map_records: inner.ui_source_map_records.clone(), + element_templates, + inner, + } + } + + pub fn with_content_hash(mut self, content_hash: String) -> Self { + self.inner.content_hash = content_hash; + self + } + + pub fn with_ui_source_map_records( + mut self, + ui_source_map_records: Rc>>, + ) -> Self { + self.inner.ui_source_map_records = ui_source_map_records.clone(); + self.ui_source_map_records = ui_source_map_records; + self + } + + pub fn new( + cfg: JSXTransformerConfig, + comments: Option, + mode: TransformMode, + source_map: Option>, + ) -> Self { + // The napi wrapper always keeps the collector side channel alive so callers can + // opt into ET asset export without needing a separate constructor shape. + Self::new_with_element_templates(cfg, comments, mode, source_map, None) + } + + pub fn take_element_templates(&self) -> Vec { + self.element_templates.borrow_mut().drain(..).collect() + } +} + +impl VisitMut for JSXTransformer +where + C: Comments + Clone, +{ + fn visit_mut_jsx_element(&mut self, node: &mut JSXElement) { + self.inner.visit_mut_jsx_element(node) + } + + fn visit_mut_module_items(&mut self, n: &mut Vec) { + self.inner.visit_mut_module_items(n) + } + + fn visit_mut_module(&mut self, n: &mut Module) { + self.inner.visit_mut_module(n) + } +} + +pub type ElementTemplateTransformerConfig = JSXTransformerConfig; +pub type ElementTemplateTransformer = JSXTransformer; diff --git a/packages/react/transform/crates/swc_plugin_element_template/slot.rs b/packages/react/transform/crates/swc_plugin_element_template/slot.rs new file mode 100644 index 0000000000..dcf33cc7c1 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/slot.rs @@ -0,0 +1,99 @@ +use swc_core::{common::DUMMY_SP, ecma::ast::*, quote}; +use swc_plugins_shared::jsx_helpers::jsx_children_to_expr; + +use super::i32_to_expr; + +pub(super) fn wrap_in_slot( + slot_ident: &Ident, + id: i32, + children: Vec, +) -> JSXElementChild { + // ET dynamic children are transported as indexed element slots, not Snapshot + // wrapper nodes. Keeping the slot marker in JSX form lets the later React + // lowering decide the final JS shape without losing slot identity. + let children_expr = jsx_children_to_expr(children); + JSXElementChild::JSXExprContainer(JSXExprContainer { + span: DUMMY_SP, + expr: JSXExpr::Expr(Box::new(quote!( + "$slot_ident($id, $children_expr)" as Expr, + slot_ident: Ident = slot_ident.clone(), + id: Expr = i32_to_expr(&id), + children_expr: Expr = children_expr, + ))), + }) +} + +pub(super) fn expr_to_jsx_child(expr: Expr) -> JSXElementChild { + match expr { + Expr::JSXElement(jsx) => JSXElementChild::JSXElement(jsx), + _ => JSXElementChild::JSXExprContainer(JSXExprContainer { + span: DUMMY_SP, + expr: JSXExpr::Expr(Box::new(expr)), + }), + } +} + +fn unwrap_et_slot_expr(expr: &Expr, slot_ident: &Ident) -> Option<(usize, Expr)> { + let Expr::Call(call_expr) = expr else { + return None; + }; + let Callee::Expr(callee) = &call_expr.callee else { + return None; + }; + let Expr::Ident(ident) = &**callee else { + return None; + }; + if ident.sym != slot_ident.sym { + return None; + } + if call_expr.args.len() != 2 { + return None; + } + + let slot_id = match &*call_expr.args[0].expr { + Expr::Lit(Lit::Num(Number { value, .. })) if *value >= 0.0 => *value as usize, + _ => return None, + }; + + Some((slot_id, *call_expr.args[1].expr.clone())) +} + +fn build_et_slot_array_expr(entries: Vec<(usize, Expr)>) -> Expr { + let mut slots: Vec> = vec![]; + + for (slot_id, child_expr) in entries { + if slots.len() <= slot_id { + slots.resize(slot_id + 1, None); + } + slots[slot_id] = Some(ExprOrSpread { + spread: None, + expr: Box::new(child_expr), + }); + } + + Expr::Array(ArrayLit { + span: DUMMY_SP, + elems: slots, + }) +} + +pub(super) fn lower_lepus_et_children_expr(expr: Expr, slot_ident: &Ident) -> Option { + // LEPUS host components receive children through a plain slot array. JS output + // can keep nested JSX children, but LEPUS needs the marker calls collapsed here + // so the main-thread ET runtime reads the same elementSlotIndex ordering as the + // compiled Template Definition. + let slot_entries = match expr { + Expr::Call(_) => vec![unwrap_et_slot_expr(&expr, slot_ident)?], + Expr::Array(array) => array + .elems + .iter() + .map(|elem| { + let elem = elem.as_ref()?; + unwrap_et_slot_expr(&elem.expr, slot_ident) + }) + .collect::>>()?, + _ => return None, + }; + + Some(build_et_slot_array_expr(slot_entries)) +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/template_definition.rs b/packages/react/transform/crates/swc_plugin_element_template/template_definition.rs new file mode 100644 index 0000000000..7402af0b38 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/template_definition.rs @@ -0,0 +1,337 @@ +use swc_core::{ + common::{comments::Comments, DUMMY_SP}, + ecma::ast::*, + quote, +}; +use swc_plugins_shared::jsx_helpers::{jsx_name, jsx_text_to_str, transform_jsx_attr_str}; + +use super::{i32_to_expr, JSXTransformer}; + +impl JSXTransformer +where + C: Comments + Clone, +{ + // Template Definition owns ET's serialized tree/descriptor contract. + pub(super) fn element_template_to_json(&self, expr: &Expr) -> serde_json::Value { + // Build descriptors with SWC Expr helpers first so snapshot tests can inspect + // the same shape as codegen. Serialization intentionally accepts only the + // literal/object/array subset that belongs in the native Template Definition. + match expr { + Expr::Lit(lit) => match lit { + Lit::Str(s) => serde_json::Value::String(s.value.as_str().unwrap_or("").to_string()), + Lit::Num(n) => serde_json::Number::from_f64(n.value) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null), + Lit::Bool(b) => serde_json::Value::Bool(b.value), + Lit::Null(_) => serde_json::Value::Null, + _ => serde_json::Value::Null, + }, + Expr::Array(arr) => { + let elems: Vec = arr + .elems + .iter() + .map(|elem| { + if let Some(elem) = elem { + self.element_template_to_json(&elem.expr) + } else { + serde_json::Value::Null + } + }) + .collect(); + serde_json::Value::Array(elems) + } + Expr::Object(obj) => { + let mut map = serde_json::Map::new(); + for prop in &obj.props { + if let PropOrSpread::Prop(prop) = prop { + if let Prop::KeyValue(kv) = &**prop { + let key; + if let PropName::Ident(ident) = &kv.key { + key = ident.sym.as_str().to_string(); + } else if let PropName::Str(s) = &kv.key { + key = s.value.as_str().unwrap_or("").to_string(); + } else { + continue; + }; + let value = self.element_template_to_json(&kv.value); + map.insert(key, value); + } + } + } + serde_json::Value::Object(map) + } + _ => serde_json::Value::Null, + } + } + + fn element_template_string_expr(&self, value: &str) -> Expr { + Expr::Lit(Lit::Str(Str { + span: DUMMY_SP, + raw: None, + value: value.into(), + })) + } + + fn element_template_array_expr(&self, items: Vec) -> Expr { + Expr::Array(ArrayLit { + span: DUMMY_SP, + elems: items + .into_iter() + .map(|expr| { + Some(ExprOrSpread { + spread: None, + expr: Box::new(expr), + }) + }) + .collect(), + }) + } + + fn element_template_static_attribute_descriptor(&self, key: &str, value: Expr) -> Expr { + quote!( + r#"{ kind: "attribute", key: $key, binding: "static", value: $value }"# as Expr, + key: Expr = self.element_template_string_expr(key), + value: Expr = value, + ) + } + + fn element_template_attribute_slot_descriptor(&self, key: &str, attr_slot_index: i32) -> Expr { + quote!( + r#"{ kind: "attribute", key: $key, binding: "slot", attrSlotIndex: $attr_slot_index }"# as Expr, + key: Expr = self.element_template_string_expr(key), + attr_slot_index: Expr = i32_to_expr(&attr_slot_index), + ) + } + + fn element_template_spread_slot_descriptor(&self, attr_slot_index: i32) -> Expr { + quote!( + r#"{ kind: "spread", binding: "slot", attrSlotIndex: $attr_slot_index }"# as Expr, + attr_slot_index: Expr = i32_to_expr(&attr_slot_index), + ) + } + + fn element_template_element_slot(&self, element_slot_index: i32) -> Expr { + quote!( + r#"{ kind: "elementSlot", type: "slot", elementSlotIndex: $element_slot_index }"# as Expr, + element_slot_index: Expr = i32_to_expr(&element_slot_index), + ) + } + + fn element_template_element_node( + &self, + tag: &str, + attributes: Vec, + children: Vec, + ) -> Expr { + quote!( + r#"{ kind: "element", type: $tag, attributesArray: $attributes, children: $children }"# as Expr, + tag: Expr = self.element_template_string_expr(tag), + attributes: Expr = self.element_template_array_expr(attributes), + children: Expr = self.element_template_array_expr(children), + ) + } + + fn element_template_attribute_key<'a>(&self, key: &'a str) -> &'a str { + if key == "className" { + "class" + } else { + key + } + } + + fn element_template_attribute_descriptor_key(&self, name: &JSXAttrName) -> String { + match name { + JSXAttrName::Ident(name) => self + .element_template_attribute_key(name.sym.as_ref()) + .to_string(), + JSXAttrName::JSXNamespacedName(JSXNamespacedName { ns, name, .. }) => { + // ReactLynx uses namespaced JSX attrs for backend-coupled props such as + // main-thread worklet events/refs. Runtime support is deferred, but the + // transform still preserves the descriptor key and slot index so later + // prop adapters do not need to change the template ABI. + format!("{}:{}", ns.sym, name.sym) + } + } + } + + fn element_template_from_jsx_children( + &self, + children: &[JSXElementChild], + attr_slot_index: &mut i32, + element_slot_index: &mut i32, + ) -> Vec { + let mut out: Vec = vec![]; + + for child in children { + match child { + JSXElementChild::JSXText(txt) => { + let s = jsx_text_to_str(&txt.value); + if s.trim().is_empty() { + continue; + } + + out.push(self.element_template_element_node( + "raw-text", + vec![self.element_template_static_attribute_descriptor( + "text", + self.element_template_string_expr(s.as_ref()), + )], + vec![], + )); + } + JSXElementChild::JSXElement(el) => out.push(self.element_template_from_jsx_element_impl( + el, + attr_slot_index, + element_slot_index, + )), + JSXElementChild::JSXFragment(frag) => { + out.extend(self.element_template_from_jsx_children( + &frag.children, + attr_slot_index, + element_slot_index, + )); + } + JSXElementChild::JSXExprContainer(JSXExprContainer { + expr: JSXExpr::Expr(_), + .. + }) => { + let idx = *element_slot_index; + *element_slot_index += 1; + out.push(self.element_template_element_slot(idx)); + } + JSXElementChild::JSXExprContainer(JSXExprContainer { + expr: JSXExpr::JSXEmptyExpr(_), + .. + }) => {} + JSXElementChild::JSXSpreadChild(_) => { + let idx = *element_slot_index; + *element_slot_index += 1; + out.push(self.element_template_element_slot(idx)); + } + } + } + + out + } + + pub(super) fn element_template_from_jsx_element( + &self, + n: &JSXElement, + attr_slot_index: &mut i32, + element_slot_index: &mut i32, + ) -> Expr { + self.element_template_from_jsx_element_impl(n, attr_slot_index, element_slot_index) + } + + fn element_template_from_jsx_element_impl( + &self, + n: &JSXElement, + attr_slot_index: &mut i32, + element_slot_index: &mut i32, + ) -> Expr { + let tag_expr = jsx_name(n.opening.name.clone()); + let tag_value = match *tag_expr { + Expr::Lit(Lit::Str(s)) => s.value, + _ => "".into(), + }; + + if tag_value == "wrapper" { + let idx = *element_slot_index; + *element_slot_index += 1; + return self.element_template_element_slot(idx); + } + + let mut attribute_descriptors: Vec = vec![]; + + for attr in &n.opening.attrs { + match attr { + JSXAttrOrSpread::JSXAttr(attr) => { + let key = self.element_template_attribute_descriptor_key(&attr.name); + + if key == "__lynx_part_id" { + continue; + } + + let static_value = match &attr.value { + None => Some(Expr::Lit(Lit::Bool(Bool { + span: DUMMY_SP, + value: true, + }))), + Some(JSXAttrValue::Str(s)) => Some(Expr::Lit(Lit::Str(Str { + span: s.span, + value: transform_jsx_attr_str(&s.value).into(), + raw: None, + }))), + Some(JSXAttrValue::JSXExprContainer(JSXExprContainer { + expr: JSXExpr::Expr(expr), + .. + })) => match &**expr { + Expr::Lit(Lit::Str(s)) => Some(Expr::Lit(Lit::Str(s.clone()))), + Expr::Lit(Lit::Num(n)) => Some(Expr::Lit(Lit::Num(n.clone()))), + Expr::Lit(Lit::Bool(b)) => Some(Expr::Lit(Lit::Bool(*b))), + Expr::Lit(Lit::Null(n)) => Some(Expr::Lit(Lit::Null(*n))), + // TODO: Support complex static values (Object, Array, Template Literal without expressions) + // See ElementTemplate/Todo-StaticAttributesOpts.md + _ => None, + }, + _ => None, + }; + + if let Some(static_value) = static_value { + attribute_descriptors + .push(self.element_template_static_attribute_descriptor(&key, static_value)); + } else { + let idx = *attr_slot_index; + *attr_slot_index += 1; + attribute_descriptors.push(self.element_template_attribute_slot_descriptor(&key, idx)); + } + } + JSXAttrOrSpread::SpreadElement(_) => { + let idx = *attr_slot_index; + *attr_slot_index += 1; + attribute_descriptors.push(self.element_template_spread_slot_descriptor(idx)); + } + } + } + + // Optimization for text tags: + // If (or similar) has only one static text child, use `text` attribute instead of checking children. + let is_text_tag = tag_value == "text" + || tag_value == "raw-text" + || tag_value == "inline-text" + || tag_value == "x-text" + || tag_value == "x-inline-text"; + let mut text_child_optimized = false; + + if is_text_tag { + let valid_children: Vec<&JSXElementChild> = n + .children + .iter() + .filter(|c| match c { + JSXElementChild::JSXText(t) => !jsx_text_to_str(&t.value).trim().is_empty(), + _ => true, + }) + .collect(); + + if valid_children.len() == 1 { + if let JSXElementChild::JSXText(txt) = valid_children[0] { + let s = jsx_text_to_str(&txt.value); + attribute_descriptors.push(self.element_template_static_attribute_descriptor( + "text", + self.element_template_string_expr(s.as_ref()), + )); + text_child_optimized = true; + } + } + } + + let children = if text_child_optimized { + vec![] + } else { + self.element_template_from_jsx_children(&n.children, attr_slot_index, element_slot_index) + }; + + let final_tag = tag_value.to_string_lossy().to_string(); + self.element_template_element_node(&final_tag, attribute_descriptors, children) + } +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_generate_attribute_slots_for_dynamic_attributes.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_generate_attribute_slots_for_dynamic_attributes.snap new file mode 100644 index 0000000000..5749ea8702 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_generate_attribute_slots_for_dynamic_attributes.snap @@ -0,0 +1,87 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 attributeSlots={[\n dynamicId,\n value\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "class", + "binding": "static", + "value": "static" + }, + { + "kind": "attribute", + "key": "id", + "binding": "slot", + "attrSlotIndex": 0.0 + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "data-value", + "binding": "slot", + "attrSlotIndex": 1.0 + }, + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Dynamic Value" + } + ], + "children": [] + }, + { + "kind": "element", + "type": "view", + "attributesArray": [], + "children": [ + { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Static" + } + ], + "children": [] + } + ] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_background_conditional_attributes.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_background_conditional_attributes.snap new file mode 100644 index 0000000000..de83144d57 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_background_conditional_attributes.snap @@ -0,0 +1,47 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\nfunction App() {\n const attrs = __BACKGROUND__ ? {\n 0: {\n id: 'b'\n },\n 2: {\n data: 'extra'\n }\n } : {\n 0: {\n id: 'a'\n },\n 1: {\n title: 'main'\n }\n };\n return (<_et_da39a_test_1 attributeSlots={[\n attrs,\n attrs\n ]}/>);\n}\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "data-a", + "binding": "slot", + "attrSlotIndex": 0.0 + }, + { + "kind": "attribute", + "key": "b", + "binding": "slot", + "attrSlotIndex": 1.0 + } + ], + "children": [] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_boolean_and_number_attributes.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_boolean_and_number_attributes.snap new file mode 100644 index 0000000000..23ea3baeca --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_boolean_and_number_attributes.snap @@ -0,0 +1,67 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "disabled", + "binding": "static", + "value": true + }, + { + "kind": "attribute", + "key": "opacity", + "binding": "static", + "value": 0.5 + }, + { + "kind": "attribute", + "key": "lines", + "binding": "static", + "value": 2.0 + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Attribute Types Test" + } + ], + "children": [] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_complex_text_structure.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_complex_text_structure.snap new file mode 100644 index 0000000000..bb1f9092ed --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_complex_text_structure.snap @@ -0,0 +1,127 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [], + "children": [ + { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Hello" + } + ], + "children": [] + }, + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "World" + } + ], + "children": [] + }, + { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "!" + } + ], + "children": [] + } + ] + }, + { + "kind": "element", + "type": "text", + "attributesArray": [], + "children": [ + { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "First" + } + ], + "children": [] + }, + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Second" + } + ], + "children": [] + }, + { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Third" + } + ], + "children": [] + } + ] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_dataset_attributes.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_dataset_attributes.snap new file mode 100644 index 0000000000..01b4256a29 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_dataset_attributes.snap @@ -0,0 +1,67 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "data-id", + "binding": "static", + "value": "123" + }, + { + "kind": "attribute", + "key": "data-name", + "binding": "static", + "value": "test" + }, + { + "kind": "attribute", + "key": "data-long-name", + "binding": "static", + "value": "long-value" + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Dataset Test" + } + ], + "children": [] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_dynamic_class_attributes.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_dynamic_class_attributes.snap new file mode 100644 index 0000000000..e647d8a548 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_dynamic_class_attributes.snap @@ -0,0 +1,61 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 attributeSlots={[\n dynamicClass\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "class", + "binding": "slot", + "attrSlotIndex": 0.0 + }, + { + "kind": "attribute", + "key": "class", + "binding": "static", + "value": "static-class" + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Dynamic Class Test" + } + ], + "children": [] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_events_js.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_events_js.snap new file mode 100644 index 0000000000..6308c8bd9e --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_events_js.snap @@ -0,0 +1,61 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 attributeSlots={[\n handleTap,\n handleTouch\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "bindtap", + "binding": "slot", + "attrSlotIndex": 0.0 + }, + { + "kind": "attribute", + "key": "catchtouchstart", + "binding": "slot", + "attrSlotIndex": 1.0 + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Event Test" + } + ], + "children": [] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_events_lepus.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_events_lepus.snap new file mode 100644 index 0000000000..1d28976637 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_events_lepus.snap @@ -0,0 +1,61 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 attributeSlots={[\n 1,\n 1\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "bindtap", + "binding": "slot", + "attrSlotIndex": 0.0 + }, + { + "kind": "attribute", + "key": "catchtouchstart", + "binding": "slot", + "attrSlotIndex": 1.0 + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Event Test" + } + ], + "children": [] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_id_attributes.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_id_attributes.snap new file mode 100644 index 0000000000..46cc174846 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_id_attributes.snap @@ -0,0 +1,61 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 attributeSlots={[\n dynamicId\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "id", + "binding": "static", + "value": "static-id" + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "id", + "binding": "slot", + "attrSlotIndex": 0.0 + }, + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "ID Test" + } + ], + "children": [] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_inline_styles.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_inline_styles.snap new file mode 100644 index 0000000000..298ddad22d --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_inline_styles.snap @@ -0,0 +1,88 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 attributeSlots={[\n {\n color: dynamicColor\n }\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "style", + "binding": "static", + "value": "color: red; width: 100px;" + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "style", + "binding": "static", + "value": "font-size:16px;font-weight:bold" + }, + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Static Style" + } + ], + "children": [] + }, + { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "style", + "binding": "slot", + "attrSlotIndex": 0.0 + } + ], + "children": [ + { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Dynamic Style" + } + ], + "children": [] + } + ] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_interpolated_text_with_siblings_js.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_interpolated_text_with_siblings_js.snap new file mode 100644 index 0000000000..57bb5d47f4 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_interpolated_text_with_siblings_js.snap @@ -0,0 +1,88 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import { __etSlot as __etSlot } from \"@lynx-js/react\";\nimport * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_2 = \"_et_da39a_test_2\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1>{__etSlot(0, items)}{__etSlot(1, items.map((item)=><_et_da39a_test_2>{__etSlot(0, item)}))};\n", + "templates": [ + { + "template_id": "_et_da39a_test_2", + "template": { + "kind": "element", + "type": "text", + "attributesArray": [], + "children": [ + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 0.0 + } + ] + } + }, + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [], + "children": [ + { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "id", + "binding": "static", + "value": "1" + } + ], + "children": [ + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 0.0 + } + ] + }, + { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "id", + "binding": "static", + "value": "2" + } + ], + "children": [ + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 1.0 + } + ] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_interpolated_text_with_siblings_lepus.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_interpolated_text_with_siblings_lepus.snap new file mode 100644 index 0000000000..9288586bc3 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_interpolated_text_with_siblings_lepus.snap @@ -0,0 +1,88 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_2 = \"_et_da39a_test_2\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 children={[\n items,\n items.map((item)=><_et_da39a_test_2 children={[\n item\n ]}/>)\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_2", + "template": { + "kind": "element", + "type": "text", + "attributesArray": [], + "children": [ + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 0.0 + } + ] + } + }, + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [], + "children": [ + { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "id", + "binding": "static", + "value": "1" + } + ], + "children": [ + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 0.0 + } + ] + }, + { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "id", + "binding": "static", + "value": "2" + } + ], + "children": [ + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 1.0 + } + ] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_mixed_content.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_mixed_content.snap new file mode 100644 index 0000000000..ae07f349c0 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_mixed_content.snap @@ -0,0 +1,86 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 children={[\n dynamicPart\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Start" + } + ], + "children": [] + }, + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 0.0 + }, + { + "kind": "element", + "type": "view", + "attributesArray": [], + "children": [ + { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Middle" + } + ], + "children": [] + } + ] + }, + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "End" + } + ], + "children": [] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_nested_structure_and_dynamic_content.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_nested_structure_and_dynamic_content.snap new file mode 100644 index 0000000000..de8e043f07 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_nested_structure_and_dynamic_content.snap @@ -0,0 +1,151 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_2 = \"_et_da39a_test_2\";\nconst _et_da39a_test_3 = \"_et_da39a_test_3\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 children={[\n items.map((item)=><_et_da39a_test_2 children={[\n item\n ]}/>),\n showCopyright && <_et_da39a_test_3/>\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_2", + "template": { + "kind": "element", + "type": "text", + "attributesArray": [], + "children": [ + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 0.0 + } + ] + } + }, + { + "template_id": "_et_da39a_test_3", + "template": { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Copyright" + } + ], + "children": [] + } + }, + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "class", + "binding": "static", + "value": "wrapper" + } + ], + "children": [ + { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "class", + "binding": "static", + "value": "header" + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Header" + } + ], + "children": [] + } + ] + }, + { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "class", + "binding": "static", + "value": "content" + } + ], + "children": [ + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 0.0 + } + ] + }, + { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "class", + "binding": "static", + "value": "footer" + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Footer" + } + ], + "children": [] + }, + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 1.0 + } + ] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_page_element.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_page_element.snap new file mode 100644 index 0000000000..04274aca30 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_page_element.snap @@ -0,0 +1,48 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nimport * as ReactLynxRuntimeComponents from '@lynx-js/react/runtime-components';\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n\n <_et_da39a_test_1/>\n ;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [], + "children": [ + { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Page Element Test" + } + ], + "children": [] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_refs_js.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_refs_js.snap new file mode 100644 index 0000000000..cff90c1b7c --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_refs_js.snap @@ -0,0 +1,55 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 attributeSlots={[\n ReactLynx.transformRef(viewRef)\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "ref", + "binding": "slot", + "attrSlotIndex": 0.0 + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Ref Test" + } + ], + "children": [] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_refs_lepus.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_refs_lepus.snap new file mode 100644 index 0000000000..2b8a99f0d0 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_refs_lepus.snap @@ -0,0 +1,55 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 attributeSlots={[\n 1\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "ref", + "binding": "slot", + "attrSlotIndex": 0.0 + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Ref Test" + } + ], + "children": [] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_sibling_user_components.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_sibling_user_components.snap new file mode 100644 index 0000000000..306c891b40 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_sibling_user_components.snap @@ -0,0 +1,72 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_2 = \"_et_da39a_test_2\";\nconst _et_da39a_test_3 = \"_et_da39a_test_3\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 children={[\n [\n \n <_et_da39a_test_2/>\n ,\n \n <_et_da39a_test_3/>\n \n ]\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_2", + "template": { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Slot Content 1" + } + ], + "children": [] + } + }, + { + "template_id": "_et_da39a_test_3", + "template": { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Slot Content 2" + } + ], + "children": [] + } + }, + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [], + "children": [ + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 0.0 + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_spread_attributes.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_spread_attributes.snap new file mode 100644 index 0000000000..74b1059d0c --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_spread_attributes.snap @@ -0,0 +1,60 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 attributeSlots={[\n props\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "spread", + "binding": "slot", + "attrSlotIndex": 0.0 + }, + { + "kind": "attribute", + "key": "data-extra", + "binding": "static", + "value": "value" + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Spread Test" + } + ], + "children": [] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_user_component.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_user_component.snap new file mode 100644 index 0000000000..a9fed63ae3 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_user_component.snap @@ -0,0 +1,56 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_2 = \"_et_da39a_test_2\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 children={[\n \n <_et_da39a_test_2/>\n \n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_2", + "template": { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "hello" + } + ], + "children": [] + } + }, + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [], + "children": [ + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 0.0 + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_isolate_arrays_with_slot_wrapper.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_isolate_arrays_with_slot_wrapper.snap new file mode 100644 index 0000000000..e26c1b9507 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_isolate_arrays_with_slot_wrapper.snap @@ -0,0 +1,58 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 children={[\n [\n \"a\",\n \"b\"\n ],\n [\n \"c\",\n \"d\"\n ]\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [], + "children": [ + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 0.0 + }, + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Static" + } + ], + "children": [] + }, + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 1.0 + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_keep_code_and_template_attribute_slots_in_sync_for_spread.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_keep_code_and_template_attribute_slots_in_sync_for_spread.snap new file mode 100644 index 0000000000..72f9762955 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_keep_code_and_template_attribute_slots_in_sync_for_spread.snap @@ -0,0 +1,72 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 attributeSlots={[\n dynamicId,\n props,\n 1,\n 1\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "id", + "binding": "slot", + "attrSlotIndex": 0.0 + }, + { + "kind": "spread", + "binding": "slot", + "attrSlotIndex": 1.0 + }, + { + "kind": "attribute", + "key": "bindtap", + "binding": "slot", + "attrSlotIndex": 2.0 + }, + { + "kind": "attribute", + "key": "ref", + "binding": "slot", + "attrSlotIndex": 3.0 + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Spread Sync" + } + ], + "children": [] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_output_element_template_simple_lepus.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_output_element_template_simple_lepus.snap new file mode 100644 index 0000000000..e63047ae9b --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_output_element_template_simple_lepus.snap @@ -0,0 +1,55 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "class", + "binding": "static", + "value": "container" + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Hello" + } + ], + "children": [] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_output_template_with_static_attributes.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_output_template_with_static_attributes.snap new file mode 100644 index 0000000000..3c8dff1b48 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_output_template_with_static_attributes.snap @@ -0,0 +1,67 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "class", + "binding": "static", + "value": "container" + }, + { + "kind": "attribute", + "key": "id", + "binding": "static", + "value": "main" + }, + { + "kind": "attribute", + "key": "style", + "binding": "static", + "value": "color: red;" + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Hello" + } + ], + "children": [] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_verify_template_structure_complex.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_verify_template_structure_complex.snap new file mode 100644 index 0000000000..8cee3dc792 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_verify_template_structure_complex.snap @@ -0,0 +1,74 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 attributeSlots={[\n dynamicId,\n url\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "attribute", + "key": "class", + "binding": "static", + "value": "container" + }, + { + "kind": "attribute", + "key": "id", + "binding": "slot", + "attrSlotIndex": 0.0 + } + ], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Static" + } + ], + "children": [] + }, + { + "kind": "element", + "type": "image", + "attributesArray": [ + { + "kind": "attribute", + "key": "src", + "binding": "slot", + "attrSlotIndex": 1.0 + } + ], + "children": [] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_verify_text_attribute_and_child_text_slots.snap b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_verify_text_attribute_and_child_text_slots.snap new file mode 100644 index 0000000000..148c55d58b --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_verify_text_attribute_and_child_text_slots.snap @@ -0,0 +1,73 @@ +--- +source: packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs +expression: "serde_json::json!({\n \"code\": code, \"templates\": template_snapshot_json(&templates),\n})" +--- +{ + "code": "import * as ReactLynx from \"@lynx-js/react\";\nconst _et_da39a_test_1 = \"_et_da39a_test_1\";\n<_et_da39a_test_1 attributeSlots={[\n dynamicText\n]} children={[\n dynamicText2\n]}/>;\n", + "templates": [ + { + "template_id": "_et_da39a_test_1", + "template": { + "kind": "element", + "type": "view", + "attributesArray": [], + "children": [ + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "static", + "value": "Explicit Text Attribute" + } + ], + "children": [] + }, + { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0.0 + } + ], + "children": [] + }, + { + "kind": "element", + "type": "text", + "attributesArray": [], + "children": [ + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 0.0 + } + ] + } + ] + } + }, + { + "template_id": "__et_builtin_raw_text__", + "template": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "attribute", + "key": "text", + "binding": "slot", + "attrSlotIndex": 0 + } + ], + "children": [] + } + } + ] +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs b/packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs new file mode 100644 index 0000000000..55aecb0041 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs @@ -0,0 +1,645 @@ +use swc_core::ecma::parser::{EsSyntax, Syntax}; +use swc_plugin_element_template::{ElementTemplateAsset, JSXTransformer, JSXTransformerConfig}; +use swc_plugins_shared::target::TransformTarget; +use swc_plugins_shared::transform_mode::TransformMode; + +const BUILTIN_RAW_TEXT_TEMPLATE_ID: &str = "__et_builtin_raw_text__"; + +fn assert_has_single_builtin_raw_text_template(templates: &[ElementTemplateAsset]) { + let builtin_count = templates + .iter() + .filter(|template| template.template_id == BUILTIN_RAW_TEXT_TEMPLATE_ID) + .count(); + assert_eq!( + builtin_count, 1, + "Expected exactly one builtin raw-text template, got {}", + builtin_count + ); +} + +fn template_snapshot_json(templates: &[ElementTemplateAsset]) -> Vec { + templates + .iter() + .map(|t| { + serde_json::json!({ + "template_id": t.template_id, + "template": t.compiled_template + }) + }) + .collect() +} + +fn max_attr_slot_index(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::Object(map) => { + let own_slot = map + .get("attrSlotIndex") + .and_then(|slot| slot.as_u64()) + .map(|slot| slot as usize); + map + .values() + .filter_map(max_attr_slot_index) + .chain(own_slot) + .max() + } + serde_json::Value::Array(values) => values.iter().filter_map(max_attr_slot_index).max(), + _ => None, + } +} + +fn first_attribute_slots_len(code: &str) -> Option { + let prefix = "attributeSlots={["; + let start = code.find(prefix)? + prefix.len(); + let mut square_depth = 0usize; + let mut brace_depth = 0usize; + let mut paren_depth = 0usize; + let mut quote: Option = None; + let mut escape = false; + let mut has_content = false; + let mut len = 1usize; + + for ch in code[start..].chars() { + if let Some(quote_ch) = quote { + if escape { + escape = false; + continue; + } + if ch == '\\' { + escape = true; + continue; + } + if ch == quote_ch { + quote = None; + } + continue; + } + + match ch { + '\'' | '"' | '`' => { + has_content = true; + quote = Some(ch); + } + '[' => { + has_content = true; + square_depth += 1; + } + ']' if square_depth == 0 && brace_depth == 0 && paren_depth == 0 => { + return Some(if has_content { len } else { 0 }); + } + ']' => { + if square_depth == 0 { + return None; + } + square_depth -= 1; + } + '{' => { + has_content = true; + brace_depth += 1; + } + '}' => { + if brace_depth == 0 { + return None; + } + brace_depth -= 1; + } + '(' => { + has_content = true; + paren_depth += 1; + } + ')' => { + if paren_depth == 0 { + return None; + } + paren_depth -= 1; + } + ',' if square_depth == 0 && brace_depth == 0 && paren_depth == 0 => len += 1, + ch if !ch.is_whitespace() => has_content = true, + _ => {} + } + } + + None +} + +fn assert_attribute_slots_match_template(code: &str, templates: &[ElementTemplateAsset]) { + let expected = templates + .iter() + .filter(|template| template.template_id != BUILTIN_RAW_TEXT_TEMPLATE_ID) + .filter_map(|template| max_attr_slot_index(&template.compiled_template)) + .max() + .map(|slot| slot + 1); + + let Some(expected) = expected else { + return; + }; + + let actual = first_attribute_slots_len(code).expect("ET output should include attributeSlots"); + assert_eq!( + actual, expected, + "Template Definition attrSlotIndex expects {expected} attribute slot values, but transformed JSX emitted {actual}.\n{code}" + ); +} + +macro_rules! et_snapshot_test { + ($name:ident, $input:expr) => { + #[test] + fn $name() { + verify_code_and_template_json($input, stringify!($name)); + } + }; + ($name:ident, $cfg:expr, $input:expr) => { + #[test] + fn $name() { + verify_code_and_template_json_with_config($input, stringify!($name), $cfg); + } + }; +} + +et_snapshot_test!( + should_output_element_template_simple_lepus, + element_template_config_for_target(TransformTarget::LEPUS), + r#" + + Hello + + "# +); + +et_snapshot_test!( + should_output_template_with_static_attributes, + r#" + + Hello + + "# +); + +et_snapshot_test!( + should_handle_dataset_attributes, + r#" + + Dataset Test + + "# +); + +et_snapshot_test!( + should_handle_nested_structure_and_dynamic_content, + r#" + + + Header + + + {/* Expression should become an elementSlot */} + {items.map(item => {item})} + + + Footer + {/* Another slot */} + {showCopyright && Copyright} + + + "# +); + +et_snapshot_test!( + should_handle_mixed_content, + r#" + + Start + {dynamicPart} + Middle + End + + "# +); + +et_snapshot_test!( + should_handle_boolean_and_number_attributes, + r#" + + Attribute Types Test + + "# +); + +et_snapshot_test!( + should_generate_attribute_slots_for_dynamic_attributes, + r#" + + Dynamic Value + Static + + "# +); + +et_snapshot_test!( + should_handle_complex_text_structure, + r#" + + + Hello + World + ! + + + First + Second + Third + + + "# +); + +et_snapshot_test!( + should_handle_spread_attributes, + r#" + + Spread Test + + "# +); + +et_snapshot_test!( + should_handle_events_lepus, + element_template_config_for_target(TransformTarget::LEPUS), + r#" + + Event Test + + "# +); + +et_snapshot_test!( + should_handle_events_js, + element_template_config_for_target(TransformTarget::JS), + r#" + + Event Test + + "# +); + +et_snapshot_test!( + should_handle_inline_styles, + r#" + + Static Style + Dynamic Style + + "# +); + +et_snapshot_test!( + should_handle_refs_lepus, + element_template_config_for_target(TransformTarget::LEPUS), + r#" + + Ref Test + + "# +); + +et_snapshot_test!( + should_handle_refs_js, + element_template_config_for_target(TransformTarget::JS), + r#" + + Ref Test + + "# +); + +et_snapshot_test!( + should_handle_page_element, + r#" + + Page Element Test + + "# +); + +et_snapshot_test!( + should_handle_dynamic_class_attributes, + r#" + + Dynamic Class Test + + "# +); + +et_snapshot_test!( + should_handle_id_attributes, + r#" + + ID Test + + "# +); + +et_snapshot_test!( + should_isolate_arrays_with_slot_wrapper, + r#" + + {["a", "b"]} + Static + {["c", "d"]} + + "# +); + +et_snapshot_test!( + should_handle_user_component, + r#" + + + hello + + + "# +); + +et_snapshot_test!( + should_handle_interpolated_text_with_siblings_lepus, + element_template_config_for_target(TransformTarget::LEPUS), + r#" + + + {items} + + + {items.map((item) => {item})} + + + "# +); + +et_snapshot_test!( + should_handle_interpolated_text_with_siblings_js, + element_template_config_for_target(TransformTarget::JS), + r#" + + + {items} + + + {items.map((item) => {item})} + + + "# +); + +et_snapshot_test!( + should_handle_sibling_user_components, + r#" + + + Slot Content 1 + + + Slot Content 2 + + + "# +); + +et_snapshot_test!( + should_handle_background_conditional_attributes, + r#" + function App() { + const attrs = __BACKGROUND__ + ? { + 0: { id: 'b' }, + 2: { data: 'extra' }, + } + : { + 0: { id: 'a' }, + 1: { title: 'main' }, + }; + return ( + + ); + } + "# +); + +et_snapshot_test!( + should_verify_template_structure_complex, + r#" + + Static + + + "# +); + +et_snapshot_test!( + should_verify_text_attribute_and_child_text_slots, + r#" + + + + {dynamicText2} + + "# +); + +et_snapshot_test!( + should_keep_code_and_template_attribute_slots_in_sync_for_spread, + r#" + + Spread Sync + + "# +); + +#[track_caller] +fn transform_to_code_and_templates( + input: &str, + cfg: JSXTransformerConfig, +) -> (String, Vec) { + use std::cell::RefCell; + use std::rc::Rc; + use std::sync::Arc; + use swc_core::common::{comments::SingleThreadedComments, FileName, Globals, SourceMap, GLOBALS}; + use swc_core::ecma::codegen::{text_writer::JsWriter, Emitter}; + use swc_core::ecma::parser::{lexer::Lexer, Parser, StringInput}; + use swc_core::ecma::visit::VisitMutWith; + + GLOBALS.set(&Globals::new(), || { + let cm: Arc = Arc::new(SourceMap::default()); + let fm = cm.new_source_file(FileName::Anon.into(), input.to_string()); + + let lexer = Lexer::new( + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + Default::default(), + StringInput::from(&*fm), + None, + ); + + let mut parser = Parser::new_from(lexer); + let module_result = parser.parse_module(); + let mut module = module_result.expect("Failed to parse module"); + + let comments = SingleThreadedComments::default(); + let element_templates = Rc::new(RefCell::new(vec![])); + + let mut transformer = JSXTransformer::new_with_element_templates( + cfg, + Some(comments), + TransformMode::Test, + None, + Some(element_templates.clone()), + ); + + module.visit_mut_with(&mut transformer); + + let mut buf = vec![]; + { + let mut emitter = Emitter { + cfg: swc_core::ecma::codegen::Config::default(), + cm: cm.clone(), + comments: None, + wr: JsWriter::new(cm.clone(), "\n", &mut buf, None), + }; + emitter.emit_module(&module).expect("Failed to emit module"); + } + + let code = String::from_utf8(buf).expect("Codegen output is not valid utf8"); + let templates: Vec<_> = element_templates.borrow_mut().drain(..).collect(); + + (code, templates) + }) +} + +#[track_caller] +fn verify_code_and_template_json( + input: &str, + snapshot_name: &str, +) -> (String, Vec) { + verify_code_and_template_json_with_config(input, snapshot_name, element_template_config()) +} + +fn element_template_config() -> JSXTransformerConfig { + JSXTransformerConfig { + preserve_jsx: true, + enable_element_template: true, + ..Default::default() + } +} + +fn element_template_config_for_target(target: TransformTarget) -> JSXTransformerConfig { + JSXTransformerConfig { + target, + ..element_template_config() + } +} + +#[track_caller] +fn verify_code_and_template_json_with_config( + input: &str, + snapshot_name: &str, + cfg: JSXTransformerConfig, +) -> (String, Vec) { + if std::env::var("UPDATE").as_deref() == Ok("1") { + std::env::set_var("INSTA_UPDATE", "always"); + } + + let (code, templates) = transform_to_code_and_templates(input, cfg); + + assert!(!templates.is_empty(), "Should collect element templates"); + assert_attribute_slots_match_template(&code, &templates); + + insta::with_settings!({ + snapshot_path => "__combined_snapshots__", + prepend_module_to_snapshot => false, + }, { + insta::assert_json_snapshot!(snapshot_name, serde_json::json!({ + "code": code, + "templates": template_snapshot_json(&templates), + })); + }); + + (code, templates) +} + +#[test] +fn should_not_emit_element_template_map_in_element_template_mode() { + let (code, templates) = transform_to_code_and_templates( + r#" + + Hello + + "#, + element_template_config(), + ); + + assert_has_single_builtin_raw_text_template(&templates); + assert!(!code.contains("__elementTemplateMap")); + assert!(!code.contains("__template_")); + assert!(code.contains("const _et_")); + assert!(!code.contains("const __snapshot_")); + + for template in templates { + if template.template_id == BUILTIN_RAW_TEXT_TEMPLATE_ID { + continue; + } + assert!(template.template_id.starts_with("_et_")); + assert!(code.contains(&format!("\"{}\"", template.template_id))); + } +} + +#[test] +fn should_keep_snapshot_prefix_when_element_template_disabled() { + let (code, templates) = transform_to_code_and_templates( + r#" + + Hello + + "#, + JSXTransformerConfig { + preserve_jsx: true, + enable_element_template: false, + ..Default::default() + }, + ); + + assert!(templates.is_empty(), "Should not collect element templates"); + assert!(code.contains("const __snapshot_")); + assert!(code.contains("snapshotCreatorMap")); + assert!(!code.contains("const _et_")); +} + +#[test] +fn should_collect_element_templates_for_dynamic_component_in_element_template_mode() { + let (code, templates) = transform_to_code_and_templates( + r#" + + Hello + + "#, + JSXTransformerConfig { + preserve_jsx: true, + enable_element_template: true, + is_dynamic_component: Some(true), + ..Default::default() + }, + ); + + assert!(!templates.is_empty(), "Should collect element templates"); + assert_has_single_builtin_raw_text_template(&templates); + assert!(!code.contains("__elementTemplateMap")); + assert!(code.contains("globDynamicComponentEntry")); + + for template in templates { + assert!( + template.template_id == BUILTIN_RAW_TEXT_TEMPLATE_ID + || template.template_id.starts_with("_et_") + ); + assert!(!template.template_id.contains(':')); + } +} diff --git a/packages/react/transform/crates/swc_plugin_element_template/tests/element_template_contract.rs b/packages/react/transform/crates/swc_plugin_element_template/tests/element_template_contract.rs new file mode 100644 index 0000000000..5c5d473686 --- /dev/null +++ b/packages/react/transform/crates/swc_plugin_element_template/tests/element_template_contract.rs @@ -0,0 +1,402 @@ +use serde_json::Value; +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; +use swc_core::common::{comments::SingleThreadedComments, FileName, Globals, SourceMap, GLOBALS}; +use swc_core::ecma::codegen::{text_writer::JsWriter, Emitter}; +use swc_core::ecma::parser::{lexer::Lexer, EsSyntax, Parser, StringInput, Syntax}; +use swc_core::ecma::visit::VisitMutWith; +use swc_plugin_element_template::{ElementTemplateAsset, JSXTransformer, JSXTransformerConfig}; +use swc_plugins_shared::transform_mode::TransformMode; + +const BUILTIN_RAW_TEXT_TEMPLATE_ID: &str = "__et_builtin_raw_text__"; + +fn transform_to_templates(input: &str, cfg: JSXTransformerConfig) -> Vec { + let (templates, _) = transform_fixture(input, cfg); + templates +} + +fn transform_to_code_without_template_collector(input: &str, cfg: JSXTransformerConfig) -> String { + GLOBALS.set(&Globals::new(), || { + let cm: Arc = Arc::new(SourceMap::default()); + let fm = cm.new_source_file(FileName::Anon.into(), input.to_string()); + let comments = SingleThreadedComments::default(); + + let lexer = Lexer::new( + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + Default::default(), + StringInput::from(&*fm), + Some(&comments), + ); + + let mut parser = Parser::new_from(lexer); + let mut module = parser.parse_module().expect("Failed to parse module"); + let mut transformer = JSXTransformer::new(cfg, Some(comments), TransformMode::Test, None); + + module.visit_mut_with(&mut transformer); + + let mut sink = vec![]; + let mut emitter = Emitter { + cfg: swc_core::ecma::codegen::Config::default(), + cm: cm.clone(), + comments: None, + wr: JsWriter::new(cm.clone(), "\n", &mut sink, None), + }; + emitter.emit_module(&module).expect("Failed to emit module"); + + String::from_utf8(sink).expect("transform output should be valid utf8") + }) +} + +fn transform_fixture( + input: &str, + cfg: JSXTransformerConfig, +) -> (Vec, String) { + GLOBALS.set(&Globals::new(), || { + let cm: Arc = Arc::new(SourceMap::default()); + let fm = cm.new_source_file(FileName::Anon.into(), input.to_string()); + let comments = SingleThreadedComments::default(); + + let lexer = Lexer::new( + Syntax::Es(EsSyntax { + jsx: true, + ..Default::default() + }), + Default::default(), + StringInput::from(&*fm), + Some(&comments), + ); + + let mut parser = Parser::new_from(lexer); + let mut module = parser.parse_module().expect("Failed to parse module"); + let element_templates = Rc::new(RefCell::new(vec![])); + + let mut transformer = JSXTransformer::new_with_element_templates( + cfg, + Some(comments), + TransformMode::Test, + None, + Some(element_templates.clone()), + ); + + module.visit_mut_with(&mut transformer); + + let mut sink = vec![]; + let mut emitter = Emitter { + cfg: swc_core::ecma::codegen::Config::default(), + cm: cm.clone(), + comments: None, + wr: JsWriter::new(cm.clone(), "\n", &mut sink, None), + }; + emitter.emit_module(&module).expect("Failed to emit module"); + + let templates = element_templates.borrow_mut().drain(..).collect(); + ( + templates, + String::from_utf8(sink).expect("transform output should be valid utf8"), + ) + }) +} + +fn first_user_template_json(input: &str) -> Value { + first_user_template_json_with_cfg( + input, + JSXTransformerConfig { + preserve_jsx: true, + enable_element_template: true, + ..Default::default() + }, + ) +} + +fn first_user_template_json_with_cfg(input: &str, cfg: JSXTransformerConfig) -> Value { + let templates = transform_to_templates(input, cfg); + + templates + .into_iter() + .find(|template| template.template_id != BUILTIN_RAW_TEXT_TEMPLATE_ID) + .map(|template| { + serde_json::to_value(template.compiled_template).expect("compiled template to json") + }) + .expect("should collect a user template") +} + +fn first_user_template_json_with_code(input: &str, cfg: JSXTransformerConfig) -> (String, Value) { + let (templates, code) = transform_fixture(input, cfg); + let template = templates + .into_iter() + .find(|template| template.template_id != BUILTIN_RAW_TEXT_TEMPLATE_ID) + .map(|template| { + serde_json::to_value(template.compiled_template).expect("compiled template to json") + }) + .expect("should collect a user template"); + + (code, template) +} + +fn without_whitespace(value: &str) -> String { + value.chars().filter(|ch| !ch.is_whitespace()).collect() +} + +#[test] +fn should_not_inject_root_css_scope_attrs_for_element_template() { + let (code, template) = first_user_template_json_with_code( + r#" + /** + * @jsxCSSId 100 + */ + + Hello + + "#, + JSXTransformerConfig { + preserve_jsx: true, + enable_element_template: true, + ..Default::default() + }, + ); + assert!( + !code.contains("options={{") && !code.contains("cssId"), + "element template lowering should not emit legacy cssId create metadata, got: {code}" + ); + + let attrs = template["attributesArray"] + .as_array() + .expect("attributesArray"); + assert!( + attrs + .iter() + .all(|attr| attr["key"] != "css-id" && attr["key"] != "entry-name"), + "root element should no longer carry css scope attrs once metadata moves to options", + ); + + let children = template["children"].as_array().expect("children array"); + let first_child_attrs = children[0]["attributesArray"] + .as_array() + .expect("child attributesArray"); + assert!( + first_child_attrs + .iter() + .all(|attr| attr["key"] != "css-id" && attr["key"] != "entry-name"), + "nested static elements should not receive css scope attrs", + ); +} + +#[test] +fn should_not_inject_root_entry_name_attr_for_dynamic_component_element_template() { + let (code, template) = first_user_template_json_with_code( + r#" + /** + * @jsxCSSId 100 + */ + + Hello + + "#, + JSXTransformerConfig { + preserve_jsx: true, + enable_element_template: true, + is_dynamic_component: Some(true), + ..Default::default() + }, + ); + assert!( + !code.contains("entryName") && !code.contains("options={{"), + "dynamic component ET lowering should not emit legacy entry metadata, got: {code}" + ); + + let attrs = template["attributesArray"] + .as_array() + .expect("attributesArray"); + assert!( + attrs + .iter() + .all(|attr| attr["key"] != "entry-name" && attr["key"] != "css-id"), + "dynamic component root should no longer encode css scope metadata in attrs", + ); +} + +#[test] +fn should_keep_static_attribute_values_out_of_et_attribute_slots() { + let (code, template) = first_user_template_json_with_code( + r#" + + "#, + JSXTransformerConfig { + preserve_jsx: true, + enable_element_template: true, + ..Default::default() + }, + ); + + let attrs = template["attributesArray"] + .as_array() + .expect("attributesArray"); + let attr_by_key = |key: &str| { + attrs + .iter() + .find(|attr| attr["key"] == key) + .unwrap_or_else(|| panic!("missing attribute descriptor for {key}: {attrs:?}")) + }; + + assert_eq!(attr_by_key("disabled")["binding"], "static"); + assert_eq!(attr_by_key("disabled")["value"].as_bool(), Some(true)); + assert_eq!(attr_by_key("id")["binding"], "static"); + assert_eq!(attr_by_key("id")["value"].as_f64(), Some(1.0)); + assert_eq!(attr_by_key("data-count")["binding"], "static"); + assert_eq!(attr_by_key("data-count")["value"].as_f64(), Some(2.0)); + assert_eq!(attr_by_key("data-overflow")["binding"], "static"); + assert_eq!(attr_by_key("data-overflow")["value"], Value::Null); + assert_eq!(attr_by_key("class")["binding"], "slot"); + assert_eq!(attr_by_key("class")["attrSlotIndex"].as_f64(), Some(0.0)); + let code = without_whitespace(&code); + assert!( + code.contains("attributeSlots={[cls]}"), + "only dynamic class should be transported as an ET attribute slot, got: {code}" + ); +} + +#[test] +fn should_not_emit_et_jsx_without_template_collector() { + let code = transform_to_code_without_template_collector( + r#" + + Hello + + "#, + JSXTransformerConfig { + preserve_jsx: true, + enable_element_template: true, + ..Default::default() + }, + ); + + assert!(code.contains("snapshotCreatorMap")); + assert!(code.contains("const __snapshot_")); + assert!(!code.contains("const _et_")); +} + +#[test] +fn should_not_consume_hidden_et_slots_for_list_item_platform_attrs() { + let (code, template) = first_user_template_json_with_code( + r#" + + Hello + + "#, + JSXTransformerConfig { + preserve_jsx: true, + enable_element_template: true, + ..Default::default() + }, + ); + + let attrs = template["attributesArray"] + .as_array() + .expect("attributesArray"); + let item_key = attrs + .iter() + .find(|attr| attr["key"] == "item-key") + .expect("item-key descriptor"); + let recyclable = attrs + .iter() + .find(|attr| attr["key"] == "recyclable") + .expect("recyclable descriptor"); + + assert_eq!(item_key["binding"], "slot"); + assert_eq!(item_key["attrSlotIndex"].as_f64(), Some(0.0)); + assert_eq!(recyclable["binding"], "static"); + assert_eq!(recyclable["value"].as_bool(), Some(true)); + let code = without_whitespace(&code); + assert!( + code.contains("attributeSlots={[itemKey]}"), + "snapshot-only list platform info must not consume ET attribute slots, got: {code}" + ); +} + +#[test] +fn should_keep_slot_descriptor_order_for_dynamic_attr_spread_event_and_ref() { + let template = first_user_template_json( + r#" + + "#, + ); + + let attrs = template["attributesArray"] + .as_array() + .expect("attributesArray"); + assert_eq!(attrs.len(), 4); + + assert_eq!(attrs[0]["kind"], "attribute"); + assert_eq!(attrs[0]["key"], "id"); + assert_eq!(attrs[0]["binding"], "slot"); + assert_eq!(attrs[0]["attrSlotIndex"].as_f64(), Some(0.0)); + + assert_eq!(attrs[1]["kind"], "spread"); + assert_eq!(attrs[1]["binding"], "slot"); + assert_eq!(attrs[1]["attrSlotIndex"].as_f64(), Some(1.0)); + + assert_eq!(attrs[2]["kind"], "attribute"); + assert_eq!(attrs[2]["key"], "bindtap"); + assert_eq!(attrs[2]["binding"], "slot"); + assert_eq!(attrs[2]["attrSlotIndex"].as_f64(), Some(2.0)); + + assert_eq!(attrs[3]["kind"], "attribute"); + assert_eq!(attrs[3]["key"], "ref"); + assert_eq!(attrs[3]["binding"], "slot"); + assert_eq!(attrs[3]["attrSlotIndex"].as_f64(), Some(3.0)); +} + +#[test] +fn should_keep_worklet_attr_descriptor_keys_for_namespaced_attrs() { + let template = first_user_template_json( + r#" + + "#, + ); + + let attrs = template["attributesArray"] + .as_array() + .expect("attributesArray"); + assert_eq!(attrs.len(), 2); + + assert_eq!(attrs[0]["kind"], "attribute"); + assert_eq!(attrs[0]["key"], "main-thread:bindtap"); + assert_eq!(attrs[0]["binding"], "slot"); + assert_eq!(attrs[0]["attrSlotIndex"].as_f64(), Some(0.0)); + + assert_eq!(attrs[1]["kind"], "attribute"); + assert_eq!(attrs[1]["key"], "main-thread:ref"); + assert_eq!(attrs[1]["binding"], "slot"); + assert_eq!(attrs[1]["attrSlotIndex"].as_f64(), Some(1.0)); +} + +#[test] +fn should_keep_element_slot_indices_stable_for_mixed_dynamic_children() { + let template = first_user_template_json( + r#" + + static + {first} + + {second} + + "#, + ); + + let children = template["children"].as_array().expect("children array"); + assert_eq!(children[0]["kind"], "element"); + assert_eq!(children[0]["type"], "text"); + assert_eq!(children[1]["kind"], "elementSlot"); + assert_eq!(children[1]["elementSlotIndex"].as_f64(), Some(0.0)); + assert_eq!(children[1]["type"], "slot"); + assert_eq!(children[2]["kind"], "element"); + assert_eq!(children[2]["type"], "image"); + assert_eq!(children[3]["kind"], "elementSlot"); + assert_eq!(children[3]["elementSlotIndex"].as_f64(), Some(1.0)); + assert_eq!(children[3]["type"], "slot"); +} diff --git a/packages/react/transform/crates/swc_plugin_snapshot/Cargo.toml b/packages/react/transform/crates/swc_plugin_snapshot/Cargo.toml index 116a8110d7..7b83469752 100644 --- a/packages/react/transform/crates/swc_plugin_snapshot/Cargo.toml +++ b/packages/react/transform/crates/swc_plugin_snapshot/Cargo.toml @@ -12,14 +12,14 @@ napi = ["dep:napi", "dep:napi-derive", "swc_plugins_shared/napi"] [dependencies] convert_case = { workspace = true } hex = { workspace = true } -napi = { workspace = true, optional = true } +napi = { workspace = true, optional = true, features = ["serde-json"] } napi-derive = { workspace = true, optional = true } once_cell = { workspace = true } regex = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = ["preserve_order"] } sha-1 = { workspace = true } -swc_core = { workspace = true, features = ["ecma_parser", "ecma_utils", "ecma_quote", "ecma_visit", "testing_transform", "ecma_transforms_react"] } +swc_core = { workspace = true, features = ["base", "ecma_codegen", "ecma_parser", "ecma_minifier", "ecma_transforms_typescript", "ecma_utils", "ecma_quote", "ecma_transforms_react", "ecma_transforms_optimization", "__visit", "__testing_transform"] } swc_plugins_shared = { path = "../swc_plugins_shared" } [lints.rust] diff --git a/packages/react/transform/index.d.ts b/packages/react/transform/index.d.ts index c2443a7c52..be72c9486f 100644 --- a/packages/react/transform/index.d.ts +++ b/packages/react/transform/index.d.ts @@ -21,7 +21,7 @@ export interface TransformNodiffBundleOutput { } /** * This is esbuild's PartialMessage definition. - * https://github.com/evanw/esbuild/blob/043ab306c490f692c68e8d254bbf00b6468be87d/lib/shared/types.ts#L421 + * https://github.com/evanw/esbuild/blob/043ab306c490f692c68e8d254bbf00b6468be87d7/lib/shared/types.ts#L421 */ export interface PartialMessage { id?: string @@ -396,7 +396,7 @@ export interface DefineDceVisitorConfig { * defineDCE: { * define: { * __FOO__: 'false', - * 'process.env.PLATFORM': '"lynx"', + * 'process.env.PLATFORM': '\"lynx\"', * }, * }, * }) @@ -599,6 +599,23 @@ export interface JsxTransformerConfig { /** @internal */ isDynamicComponent?: boolean } +/** @internal */ +export interface ElementTemplateConfig { + /** @internal */ + preserveJsx: boolean + /** @internal */ + runtimePkg: string + /** @internal */ + jsxImportSource?: string + /** @internal */ + filename: string + /** @internal */ + target: 'LEPUS' | 'JS' | 'MIXED' + /** @internal */ + enableUiSourceMap?: boolean + /** @internal */ + isDynamicComponent?: boolean +} export interface WorkletVisitorConfig { /** * @public @@ -635,6 +652,7 @@ export interface TransformNodiffOptions { isModule?: boolean | 'unknown' cssScope: boolean | CssScopeVisitorConfig snapshot?: boolean | JsxTransformerConfig + elementTemplate?: boolean | ElementTemplateConfig engineVersion?: string shake: boolean | ShakeVisitorConfig compat: boolean | CompatVisitorConfig @@ -653,6 +671,17 @@ export interface TransformNodiffOutput { errors: Array warnings: Array uiSourceMapRecords: Array + /** @internal */ + elementTemplates?: Array +} +/** @internal */ +export interface ElementTemplateAsset { + /** @internal */ + templateId: string + /** @internal */ + compiledTemplate: Record + /** @internal */ + sourceFile: string } export function transformReactLynxSync(code: string, options?: TransformNodiffOptions | undefined | null): TransformNodiffOutput export function transformReactLynx(code: string, options?: TransformNodiffOptions | undefined | null): Promise diff --git a/packages/react/transform/src/lib.rs b/packages/react/transform/src/lib.rs index 6bd3a2cbdc..05a54d0835 100644 --- a/packages/react/transform/src/lib.rs +++ b/packages/react/transform/src/lib.rs @@ -55,12 +55,19 @@ use swc_plugin_css_scope::napi::{CSSScopeVisitor, CSSScopeVisitorConfig}; use swc_plugin_define_dce::napi::DefineDCEVisitorConfig; use swc_plugin_directive_dce::napi::{DirectiveDCEVisitor, DirectiveDCEVisitorConfig}; use swc_plugin_dynamic_import::napi::{DynamicImportVisitor, DynamicImportVisitorConfig}; +use swc_plugin_element_template::{ + napi::{ElementTemplateAsset, ElementTemplateTransformer, ElementTemplateTransformerConfig}, + ElementTemplateUISourceMapRecord as ElementTemplateCoreUISourceMapRecord, +}; use swc_plugin_inject::napi::{InjectVisitor, InjectVisitorConfig}; use swc_plugin_refresh::{RefreshVisitor, RefreshVisitorConfig}; use swc_plugin_shake::napi::{ShakeVisitor, ShakeVisitorConfig}; use swc_plugin_snapshot::{ - napi::{JSXTransformer, JSXTransformerConfig, UISourceMapRecord as SnapshotUISourceMapRecord}, - UISourceMapRecord as CoreUISourceMapRecord, + napi::{ + JSXTransformer as SnapshotJSXTransformer, JSXTransformerConfig as SnapshotJSXTransformerConfig, + UISourceMapRecord as SnapshotUISourceMapRecord, + }, + UISourceMapRecord as SnapshotCoreUISourceMapRecord, }; use swc_plugin_worklet::napi::{WorkletVisitor, WorkletVisitorConfig}; use swc_plugins_shared::{ @@ -200,7 +207,9 @@ pub struct TransformNodiffOptions { #[napi(ts_type = "boolean | 'unknown'")] pub is_module: Option, pub css_scope: Either, - pub snapshot: Option>, + pub snapshot: Option>, + #[napi(js_name = "elementTemplate")] + pub element_template: Option>, pub engine_version: Option, pub shake: Either, pub compat: Either, @@ -230,6 +239,7 @@ impl Default for TransformNodiffOptions { is_module: Default::default(), css_scope: Either::B(Default::default()), snapshot: Default::default(), + element_template: Default::default(), engine_version: None, shake: Either::A(false), compat: Either::A(false), @@ -254,6 +264,9 @@ pub struct TransformNodiffOutput { // #[napi(ts_type = "Array")] pub warnings: Vec, pub ui_source_map_records: Vec, + /// @internal + #[napi(js_name = "elementTemplates")] + pub element_templates: Option>, } /// A multi emitter that forwards to multiple emitters. @@ -275,8 +288,26 @@ impl Emitter for MultiEmitter { } } -fn clone_ui_source_map_records( - ui_source_map_records: &Rc>>, +fn clone_snapshot_ui_source_map_records( + ui_source_map_records: &Rc>>, + filename: &str, +) -> Vec { + ui_source_map_records + .borrow() + .iter() + .cloned() + .map(|record| SnapshotUISourceMapRecord { + ui_source_map: record.ui_source_map, + filename: filename.to_string(), + line_number: record.line_number, + column_number: record.column_number, + snapshot_id: record.snapshot_id, + }) + .collect() +} + +fn clone_element_template_ui_source_map_records( + ui_source_map_records: &Rc>>, filename: &str, ) -> Vec { ui_source_map_records @@ -318,8 +349,11 @@ fn transform_react_lynx_inner( let emitter = Box::new(MultiEmitter::new(vec![esbuild_emitter])); let handler = Handler::with_emitter(true, false, emitter); - let ui_source_map_records: Rc>> = + let snapshot_ui_source_map_records: Rc>> = Rc::new(RefCell::new(vec![])); + let element_template_ui_source_map_records: Rc< + RefCell>, + > = Rc::new(RefCell::new(vec![])); let result = GLOBALS.set(&Default::default(), || { let program = c.parse_js( @@ -338,10 +372,8 @@ fn transform_react_lynx_inner( map: None, errors: errors.read().unwrap().clone(), warnings: warnings.read().unwrap().clone(), - ui_source_map_records: clone_ui_source_map_records( - &ui_source_map_records, - &options.filename, - ), + ui_source_map_records: vec![], + element_templates: None, }; } }; @@ -415,15 +447,62 @@ fn transform_react_lynx_inner( ), }; - let (snapshot_plugin_config, enabled) = match &options.snapshot.unwrap_or(Either::A(true)) { - Either::A(config) => ( - JSXTransformerConfig { + let (snapshot_plugin_config, snapshot_enabled) = match options.snapshot.as_ref() { + Some(Either::A(config)) => ( + SnapshotJSXTransformerConfig { filename: options.filename.clone(), ..Default::default() }, *config, ), - Either::B(config) => (config.clone(), true), + Some(Either::B(config)) => (config.clone(), true), + None => ( + SnapshotJSXTransformerConfig { + filename: options.filename.clone(), + ..Default::default() + }, + true, + ), + }; + let (element_template_plugin_config, element_template_enabled) = + match options.element_template.as_ref() { + Some(Either::A(config)) => ( + ElementTemplateTransformerConfig { + filename: options.filename.clone(), + ..Default::default() + }, + *config, + ), + Some(Either::B(config)) => (config.clone(), true), + None => ( + ElementTemplateTransformerConfig { + filename: options.filename.clone(), + ..Default::default() + }, + false, + ), + }; + // `elementTemplate` chooses the ET backend directly. Once it is enabled, the + // public transform entrypoint no longer consults Snapshot-specific ET flags. + let use_element_template_plugin = element_template_enabled; + let use_snapshot_plugin = snapshot_enabled && !use_element_template_plugin; + let jsx_backend_enabled = use_snapshot_plugin || use_element_template_plugin; + let active_jsx_import_source = if use_element_template_plugin { + element_template_plugin_config.jsx_import_source.clone() + } else { + snapshot_plugin_config.jsx_import_source.clone() + }; + let preserve_jsx = if use_element_template_plugin { + element_template_plugin_config.preserve_jsx + } else { + snapshot_plugin_config.preserve_jsx + }; + let enable_ui_source_map = if use_element_template_plugin { + element_template_plugin_config + .enable_ui_source_map + .unwrap_or(false) + } else { + snapshot_plugin_config.enable_ui_source_map.unwrap_or(false) }; let react_transformer = Optional::new( @@ -433,10 +512,7 @@ fn transform_react_lynx_inner( react::Options { next: Some(false), runtime: Some(react::Runtime::Automatic), - import_source: snapshot_plugin_config - .jsx_import_source - .clone() - .map(Atom::from), + import_source: active_jsx_import_source.map(Atom::from), pragma: None, pragma_frag: None, // We may want `main-thread:foo={fooMainThreadFunc}` to work @@ -448,39 +524,78 @@ fn transform_react_lynx_inner( top_level_mark, unresolved_mark, ), - enabled && !snapshot_plugin_config.preserve_jsx, + jsx_backend_enabled && !preserve_jsx, ); - let enable_ui_source_map = snapshot_plugin_config.enable_ui_source_map.unwrap_or(false); + let snapshot_plugin = if use_snapshot_plugin { + let transformer = SnapshotJSXTransformer::new( + snapshot_plugin_config.clone(), + Some(&comments), + options.mode.unwrap_or(TransformMode::Production), + Some(cm.clone()), + ) + .with_content_hash(content_hash.clone()); + + let transformer = if enable_ui_source_map { + transformer.with_ui_source_map_records(snapshot_ui_source_map_records.clone()) + } else { + transformer + }; - let snapshot_plugin = Optional::new( - visit_mut_pass({ - let snapshot_plugin = JSXTransformer::new( + Optional::new(visit_mut_pass(transformer), true) + } else { + Optional::new( + visit_mut_pass(SnapshotJSXTransformer::new( snapshot_plugin_config.clone(), Some(&comments), options.mode.unwrap_or(TransformMode::Production), Some(cm.clone()), - ) - .with_content_hash(content_hash.clone()); + )), + false, + ) + }; + let element_templates_collector = + use_element_template_plugin.then(|| Rc::new(RefCell::new(vec![]))); + let element_template_plugin = if use_element_template_plugin { + // ET template assets are a build artifact, not runtime JS. Keep one + // collector per transform invocation so the output contract stays stable. + let transformer = ElementTemplateTransformer::new_with_element_templates( + element_template_plugin_config.clone(), + Some(&comments), + options.mode.unwrap_or(TransformMode::Production), + Some(cm.clone()), + element_templates_collector.clone(), + ) + .with_content_hash(content_hash.clone()); - if enable_ui_source_map { - snapshot_plugin.with_ui_source_map_records(ui_source_map_records.clone()) - } else { - snapshot_plugin - } - }), - enabled, - ); + let transformer = if enable_ui_source_map { + transformer.with_ui_source_map_records(element_template_ui_source_map_records.clone()) + } else { + transformer + }; + + Optional::new(visit_mut_pass(transformer), true) + } else { + Optional::new( + visit_mut_pass(ElementTemplateTransformer::new( + element_template_plugin_config.clone(), + Some(&comments), + options.mode.unwrap_or(TransformMode::Production), + Some(cm.clone()), + )), + false, + ) + }; let list_plugin = Optional::new( visit_mut_pass(swc_plugin_list::ListVisitor::new(Some(&comments))), - enabled, + jsx_backend_enabled, ); let is_ge_3_1: bool = is_engine_version_ge(&options.engine_version, "3.1"); let text_plugin = Optional::new( visit_mut_pass(swc_plugin_text::TextVisitor {}), - enabled && is_ge_3_1, + jsx_backend_enabled && is_ge_3_1, ); let shake_plugin = match options.shake.clone() { @@ -607,7 +722,12 @@ fn transform_react_lynx_inner( compat_plugin, worklet_plugin, css_scope_plugin, - (text_plugin, list_plugin, snapshot_plugin), + ( + text_plugin, + list_plugin, + snapshot_plugin, + element_template_plugin, + ), directive_dce_plugin, define_dce_plugin, simplify_pass_1, // do simplify after DCE above to make shake below works better @@ -664,26 +784,54 @@ fn transform_react_lynx_inner( ); match result { - Ok(result) => TransformNodiffOutput { - code: result.code, - map: result.map, - errors: vec![], - warnings: vec![], - ui_source_map_records: clone_ui_source_map_records( - &ui_source_map_records, - &options.filename, - ), - }, + Ok(result) => { + // Drain after the whole SWC pass finishes: dynamic-component transforms + // can discover multiple template assets while walking one module, and + // the caller expects one stable array per transform invocation. + let element_templates = element_templates_collector.and_then(|collector| { + let templates: Vec = collector + .borrow_mut() + .drain(..) + .map(|template| template.into()) + .collect(); + if templates.is_empty() { + None + } else { + Some(templates) + } + }); + + TransformNodiffOutput { + code: result.code, + map: result.map, + errors: vec![], + warnings: vec![], + ui_source_map_records: if use_element_template_plugin { + clone_element_template_ui_source_map_records( + &element_template_ui_source_map_records, + &options.filename, + ) + } else { + clone_snapshot_ui_source_map_records(&snapshot_ui_source_map_records, &options.filename) + }, + element_templates, + } + } Err(_) => { return TransformNodiffOutput { code: "".into(), map: None, errors: errors.read().unwrap().clone(), warnings: warnings.read().unwrap().clone(), - ui_source_map_records: clone_ui_source_map_records( - &ui_source_map_records, - &options.filename, - ), + ui_source_map_records: if use_element_template_plugin { + clone_element_template_ui_source_map_records( + &element_template_ui_source_map_records, + &options.filename, + ) + } else { + clone_snapshot_ui_source_map_records(&snapshot_ui_source_map_records, &options.filename) + }, + element_templates: None, }; } } @@ -694,7 +842,10 @@ fn transform_react_lynx_inner( map: result.map, errors: errors.read().unwrap().clone(), warnings: warnings.read().unwrap().clone(), - ui_source_map_records: clone_ui_source_map_records(&ui_source_map_records, &options.filename), + ui_source_map_records: result.ui_source_map_records, + // Preserve the element-template assets collected in the successful transform + // path instead of dropping them in the final wrapper object. + element_templates: result.element_templates, }; r diff --git a/packages/react/transform/swc-plugin-reactlynx/Cargo.toml b/packages/react/transform/swc-plugin-reactlynx/Cargo.toml index 0de6645361..ce432ab6bc 100644 --- a/packages/react/transform/swc-plugin-reactlynx/Cargo.toml +++ b/packages/react/transform/swc-plugin-reactlynx/Cargo.toml @@ -16,6 +16,7 @@ swc_plugin_css_scope = { path = "../crates/swc_plugin_css_scope" } swc_plugin_define_dce = { path = "../crates/swc_plugin_define_dce" } swc_plugin_directive_dce = { path = "../crates/swc_plugin_directive_dce" } swc_plugin_dynamic_import = { path = "../crates/swc_plugin_dynamic_import" } +swc_plugin_element_template = { path = "../crates/swc_plugin_element_template" } swc_plugin_inject = { path = "../crates/swc_plugin_inject" } swc_plugin_list = { path = "../crates/swc_plugin_list" } swc_plugin_shake = { path = "../crates/swc_plugin_shake" } diff --git a/packages/react/transform/swc-plugin-reactlynx/index.d.ts b/packages/react/transform/swc-plugin-reactlynx/index.d.ts index 96a72c9d12..cff2b40d60 100644 --- a/packages/react/transform/swc-plugin-reactlynx/index.d.ts +++ b/packages/react/transform/swc-plugin-reactlynx/index.d.ts @@ -27,6 +27,24 @@ export interface JsxTransformerConfig { isDynamicComponent?: boolean; } +/** @internal */ +export interface ElementTemplateConfig { + /** @internal */ + preserveJsx: boolean; + /** @internal */ + runtimePkg: string; + /** @internal */ + jsxImportSource?: string; + /** @internal */ + filename: string; + /** @internal */ + target: 'LEPUS' | 'JS' | 'MIXED'; + /** @internal */ + enableUiSourceMap?: boolean; + /** @internal */ + isDynamicComponent?: boolean; +} + /** * {@inheritdoc PluginReactLynxOptions.shake} * @public @@ -238,6 +256,7 @@ export interface ReactLynxTransformOptions { filename?: string; cssScope: boolean | CssScopeVisitorConfig; snapshot?: boolean | JsxTransformerConfig; + elementTemplate?: boolean | ElementTemplateConfig; engineVersion?: string; shake: boolean | ShakeVisitorConfig; defineDCE: boolean | DefineDceVisitorConfig; diff --git a/packages/react/transform/swc-plugin-reactlynx/src/lib.rs b/packages/react/transform/swc-plugin-reactlynx/src/lib.rs index 4bd6e5c9b9..f31a8d1d53 100644 --- a/packages/react/transform/swc-plugin-reactlynx/src/lib.rs +++ b/packages/react/transform/swc-plugin-reactlynx/src/lib.rs @@ -1,3 +1,5 @@ +use std::{cell::RefCell, rc::Rc}; + use rustc_hash::FxBuildHasher; use serde::Deserialize; use swc_core::{ @@ -24,10 +26,13 @@ use swc_plugin_css_scope::{CSSScopeVisitor, CSSScopeVisitorConfig}; use swc_plugin_define_dce::DefineDCEVisitorConfig; use swc_plugin_directive_dce::{DirectiveDCEVisitor, DirectiveDCEVisitorConfig}; use swc_plugin_dynamic_import::{DynamicImportVisitor, DynamicImportVisitorConfig}; +use swc_plugin_element_template::{ElementTemplateTransformer, ElementTemplateTransformerConfig}; use swc_plugin_inject::{InjectVisitor, InjectVisitorConfig}; use swc_plugin_list::ListVisitor; use swc_plugin_shake::{ShakeVisitor, ShakeVisitorConfig}; -use swc_plugin_snapshot::{JSXTransformer, JSXTransformerConfig}; +use swc_plugin_snapshot::{ + JSXTransformer as SnapshotJSXTransformer, JSXTransformerConfig as SnapshotJSXTransformerConfig, +}; use swc_plugin_text::TextVisitor; use swc_plugin_worklet::{WorkletVisitor, WorkletVisitorConfig}; use swc_plugins_shared::{ @@ -53,7 +58,8 @@ pub struct ReactLynxTransformOptions { pub css_scope: Either, - pub snapshot: Option>, + pub snapshot: Option>, + pub element_template: Option>, pub engine_version: Option, @@ -80,6 +86,7 @@ impl Default for ReactLynxTransformOptions { mode: Some(TransformMode::Production), css_scope: Either::B(Default::default()), snapshot: Default::default(), + element_template: Default::default(), engine_version: None, shake: Either::A(false), define_dce: Either::A(false), @@ -187,9 +194,9 @@ pub fn process_transform(program: Program, metadata: TransformPluginProgramMetad ), }; - let (snapshot_plugin_config, enabled) = match &options.snapshot.unwrap_or(Either::A(true)) { - Either::A(config) => ( - JSXTransformerConfig { + let (snapshot_plugin_config, snapshot_enabled) = match options.snapshot.as_ref() { + Some(Either::A(config)) => ( + SnapshotJSXTransformerConfig { // TODO: Environment-specific filename handling // - Test environment: use `options.filename` // - Production environment: use filename from metadata @@ -197,17 +204,51 @@ pub fn process_transform(program: Program, metadata: TransformPluginProgramMetad // 1. snapshot_plugin // 2. css_scope_plugin // 3. worklet_plugin - filename: options.filename.unwrap_or(filename), + filename: options.filename.clone().unwrap_or(filename.clone()), ..Default::default() }, *config, ), - Either::B(config) => (config.clone(), true), + Some(Either::B(config)) => (config.clone(), true), + None => ( + SnapshotJSXTransformerConfig { + filename: options.filename.clone().unwrap_or(filename.clone()), + ..Default::default() + }, + true, + ), }; + let (element_template_plugin_config, element_template_enabled) = + match options.element_template.as_ref() { + Some(Either::A(config)) => { + let mut resolved = ElementTemplateTransformerConfig { + filename: options.filename.clone().unwrap_or(filename.clone()), + ..Default::default() + }; + resolved.enable_element_template = true; + (resolved, *config) + } + Some(Either::B(config)) => { + let mut resolved = config.clone(); + resolved.enable_element_template = true; + (resolved, true) + } + None => ( + ElementTemplateTransformerConfig { + filename: options.filename.clone().unwrap_or(filename.clone()), + enable_element_template: true, + ..Default::default() + }, + false, + ), + }; + let use_element_template_plugin = element_template_enabled; + let use_snapshot_plugin = snapshot_enabled && !use_element_template_plugin; + let jsx_backend_enabled = use_snapshot_plugin || use_element_template_plugin; let snapshot_plugin = Optional::new( visit_mut_pass( - JSXTransformer::new( + SnapshotJSXTransformer::new( snapshot_plugin_config, Some(&comments), options.mode.unwrap_or(TransformMode::Production), @@ -215,13 +256,32 @@ pub fn process_transform(program: Program, metadata: TransformPluginProgramMetad ) .with_content_hash(content_hash.clone()), ), - enabled, + use_snapshot_plugin, + ); + let element_template_plugin = Optional::new( + visit_mut_pass( + ElementTemplateTransformer::new_with_element_templates( + element_template_plugin_config, + Some(&comments), + options.mode.unwrap_or(TransformMode::Production), + Some(cm.clone()), + Some(Rc::new(RefCell::new(vec![]))), + ) + .with_content_hash(content_hash.clone()), + ), + use_element_template_plugin, ); - let list_plugin = Optional::new(visit_mut_pass(ListVisitor::new(Some(&comments))), enabled); + let list_plugin = Optional::new( + visit_mut_pass(ListVisitor::new(Some(&comments))), + jsx_backend_enabled, + ); let is_ge_3_1: bool = is_engine_version_ge(&options.engine_version, "3.1"); - let text_plugin = Optional::new(visit_mut_pass(TextVisitor {}), enabled && is_ge_3_1); + let text_plugin = Optional::new( + visit_mut_pass(TextVisitor {}), + jsx_backend_enabled && is_ge_3_1, + ); let shake_plugin = match options.shake.clone() { Either::A(config) => Optional::new(visit_mut_pass(ShakeVisitor::default()), config), @@ -292,7 +352,12 @@ pub fn process_transform(program: Program, metadata: TransformPluginProgramMetad dynamic_import_plugin, worklet_plugin, css_scope_plugin, - (text_plugin, list_plugin, snapshot_plugin), + ( + text_plugin, + list_plugin, + snapshot_plugin, + element_template_plugin, + ), directive_dce_plugin, define_dce_plugin, simplify_pass_1, // do simplify after DCE above to make shake below works better @@ -335,6 +400,7 @@ mod tests { assert_eq!(options.mode, Some(TransformMode::Production)); assert_eq!(options.css_scope, Either::A(true)); assert_eq!(options.snapshot, None); + assert_eq!(options.element_template, None); assert_eq!(options.shake, Either::A(true)); assert_eq!(options.define_dce, Either::A(true)); assert_eq!(options.directive_dce, Either::A(true)); @@ -499,6 +565,37 @@ mod tests { } } + #[test] + fn test_element_template() { + let json_data = r#" + { + "elementTemplate": { + "preserveJsx": true, + "runtimePkg": "@lynx-js/react", + "jsxImportSource": "@lynx-js/react", + "filename": "test.js", + "target": "LEPUS", + "isDynamicComponent": false + } + }"#; + + let options: ReactLynxTransformOptions = serde_json::from_str(json_data).unwrap(); + + if let Some(Either::B(element_template)) = options.element_template { + assert!(element_template.preserve_jsx); + assert_eq!(element_template.runtime_pkg, "@lynx-js/react"); + assert_eq!( + element_template.jsx_import_source, + Some("@lynx-js/react".to_string()) + ); + assert_eq!(element_template.filename, "test.js"); + assert_eq!(element_template.target, TransformTarget::LEPUS); + assert_eq!(element_template.is_dynamic_component, Some(false)); + } else { + panic!("Expected element template config, got boolean or None"); + } + } + #[test] fn test_engine_version() { let json_data = r#"