From 0b5a0cb0d8a6817b7e5eccaa570a38def7ab0ab7 Mon Sep 17 00:00:00 2001
From: yradex <11014207+Yradex@users.noreply.github.com>
Date: Fri, 24 Apr 2026 13:59:39 +0800
Subject: [PATCH 1/3] feat(et): add element template transform
---
.changeset/social-nails-retire.md | 3 +
Cargo.lock | 59 ++
packages/react/transform/Cargo.toml | 3 +-
.../react/transform/__test__/fixture.spec.js | 90 +++
.../swc_plugin_element_template/Cargo.toml | 29 +
.../swc_plugin_element_template/asset.rs | 64 ++
.../swc_plugin_element_template/attr_name.rs | 83 +++
.../swc_plugin_element_template/extractor.rs | 487 +++++++++++++
.../crates/swc_plugin_element_template/lib.rs | 434 +++++++++++
.../swc_plugin_element_template/lowering.rs | 170 +++++
.../swc_plugin_element_template/napi.rs | 223 ++++++
.../swc_plugin_element_template/slot.rs | 99 +++
.../template_attribute.rs | 31 +
.../template_definition.rs | 373 ++++++++++
.../template_slot.rs | 81 +++
...ttribute_slots_for_dynamic_attributes.snap | 87 +++
...dle_background_conditional_attributes.snap | 47 ++
..._handle_boolean_and_number_attributes.snap | 67 ++
.../should_handle_complex_text_structure.snap | 127 ++++
.../should_handle_dataset_attributes.snap | 67 ++
..._handle_deeply_nested_user_components.snap | 56 ++
...hould_handle_dynamic_class_attributes.snap | 61 ++
.../should_handle_events_js.snap | 61 ++
.../should_handle_events_lepus.snap | 61 ++
.../should_handle_id_attributes.snap | 61 ++
.../should_handle_inline_styles.snap | 88 +++
...le_interpolated_text_with_siblings_js.snap | 88 +++
...interpolated_text_with_siblings_lepus.snap | 88 +++
.../should_handle_mixed_content.snap | 86 +++
..._nested_structure_and_dynamic_content.snap | 151 ++++
.../should_handle_refs_js.snap | 55 ++
.../should_handle_refs_lepus.snap | 55 ++
...should_handle_sibling_user_components.snap | 72 ++
.../should_handle_spread_attributes.snap | 60 ++
.../should_handle_user_component.snap | 56 ++
..._arrays_with_element_slot_placeholder.snap | 58 ++
...te_attribute_slots_in_sync_for_spread.snap | 72 ++
..._output_element_template_simple_lepus.snap | 55 ++
...utput_template_with_static_attributes.snap | 67 ++
...uld_verify_template_structure_complex.snap | 74 ++
...y_text_attribute_and_child_text_slots.snap | 73 ++
.../tests/element_template.rs | 684 ++++++++++++++++++
.../tests/element_template_contract.rs | 365 ++++++++++
packages/react/transform/index.d.ts | 32 +-
packages/react/transform/src/lib.rs | 274 +++++--
.../transform/swc-plugin-reactlynx/Cargo.toml | 1 +
.../transform/swc-plugin-reactlynx/index.d.ts | 19 +
.../transform/swc-plugin-reactlynx/src/lib.rs | 115 ++-
48 files changed, 5543 insertions(+), 69 deletions(-)
create mode 100644 .changeset/social-nails-retire.md
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/Cargo.toml
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/asset.rs
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/attr_name.rs
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/extractor.rs
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/lib.rs
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/lowering.rs
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/napi.rs
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/slot.rs
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/template_attribute.rs
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/template_definition.rs
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/template_slot.rs
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_generate_attribute_slots_for_dynamic_attributes.snap
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_background_conditional_attributes.snap
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_boolean_and_number_attributes.snap
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_complex_text_structure.snap
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_dataset_attributes.snap
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_deeply_nested_user_components.snap
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_dynamic_class_attributes.snap
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_events_js.snap
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_events_lepus.snap
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_id_attributes.snap
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_inline_styles.snap
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_interpolated_text_with_siblings_js.snap
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_interpolated_text_with_siblings_lepus.snap
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_mixed_content.snap
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_nested_structure_and_dynamic_content.snap
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_refs_js.snap
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_refs_lepus.snap
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_sibling_user_components.snap
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_spread_attributes.snap
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_handle_user_component.snap
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_isolate_arrays_with_element_slot_placeholder.snap
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_keep_code_and_template_attribute_slots_in_sync_for_spread.snap
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_output_element_template_simple_lepus.snap
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_output_template_with_static_attributes.snap
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_verify_template_structure_complex.snap
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/__combined_snapshots__/should_verify_text_attribute_and_child_text_slots.snap
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/element_template.rs
create mode 100644 packages/react/transform/crates/swc_plugin_element_template/tests/element_template_contract.rs
diff --git a/.changeset/social-nails-retire.md b/.changeset/social-nails-retire.md
new file mode 100644
index 0000000000..853d812bb3
--- /dev/null
+++ b/.changeset/social-nails-retire.md
@@ -0,0 +1,3 @@
+---
+
+---
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..52b08d5b96 100644
--- a/packages/react/transform/__test__/fixture.spec.js
+++ b/packages/react/transform/__test__/fixture.spec.js
@@ -164,6 +164,96 @@ 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 export template ids for element template uiSourceMapRecords', 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',
+ enableUiSourceMap: true,
+ },
+ jsx: true,
+ directiveDCE: false,
+ defineDCE: false,
+ shake: false,
+ compat: true,
+ worklet: false,
+ refresh: false,
+ });
+
+ expect(result.uiSourceMapRecords).toHaveLength(1);
+ expect(result.uiSourceMapRecords[0]).toMatchObject({
+ filename: 'test.js',
+ templateId: expect.stringMatching(/^_et_/),
+ });
+ expect(result.uiSourceMapRecords[0].snapshotId).toBeUndefined();
+ });
+
+ 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..da40b3c053
--- /dev/null
+++ b/packages/react/transform/crates/swc_plugin_element_template/attr_name.rs
@@ -0,0 +1,83 @@
+use regex::Regex;
+
+use swc_core::ecma::ast::*;
+
+#[derive(Debug, Clone)]
+pub enum AttrName {
+ Attr,
+ Dataset,
+ Event,
+ WorkletEvent,
+ Style,
+ Class,
+ ID,
+ Ref,
+ TimingFlag,
+ WorkletRef,
+ Gesture,
+}
+
+impl From for AttrName {
+ fn from(name: String) -> Self {
+ if name.strip_prefix("data-").is_some() {
+ AttrName::Dataset
+ } 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 get_event_type_and_name(name.as_str()).is_some() {
+ AttrName::Event
+ } else {
+ AttrName::Attr
+ }
+ }
+}
+
+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 name_str = name.sym.as_ref().to_string();
+ if name_str == "ref" {
+ AttrName::WorkletRef
+ } else if get_event_type_and_name(name_str.as_str()).is_some() {
+ AttrName::WorkletEvent
+ } else if name_str == "gesture" {
+ AttrName::Gesture
+ } 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/extractor.rs b/packages/react/transform/crates/swc_plugin_element_template/extractor.rs
new file mode 100644
index 0000000000..62353dc80a
--- /dev/null
+++ b/packages/react/transform/crates/swc_plugin_element_template/extractor.rs
@@ -0,0 +1,487 @@
+use once_cell::sync::Lazy;
+use std::collections::HashSet;
+
+use swc_core::{
+ common::{util::take::Take, Span, DUMMY_SP},
+ ecma::{
+ ast::{JSXExpr, *},
+ utils::is_literal,
+ visit::{VisitMut, VisitMutWith},
+ },
+ quote_expr,
+};
+use swc_plugins_shared::{
+ css::get_string_inline_style_from_literal,
+ jsx_helpers::{
+ jsx_attr_name, jsx_attr_value, jsx_children_to_expr, jsx_has_dynamic_key,
+ jsx_is_children_full_dynamic, jsx_is_custom, jsx_is_list, jsx_text_to_str,
+ },
+};
+
+use super::attr_name::AttrName;
+use super::template_attribute::{
+ template_attribute_key, template_namespaced_attribute_descriptor_key, TemplateAttributeSlot,
+};
+use super::template_slot::{is_slot_placeholder, slot_placeholder_node};
+
+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(super) enum DynamicAttributePart {
+ Attr {
+ value: Expr,
+ attr_name: AttrName,
+ slot_index: i32,
+ },
+ Spread {
+ value: Expr,
+ slot_index: i32,
+ },
+}
+
+#[derive(Debug)]
+pub(super) enum DynamicElementPart {
+ Slot(Expr, i32),
+ ListSlot(Expr, i32),
+}
+
+pub(super) struct ExtractedTemplateParts {
+ pub key: Option,
+ pub dynamic_attrs: Vec,
+ pub dynamic_attr_slots: Vec,
+ pub dynamic_children: Vec,
+}
+
+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,
+ })))),
+ })
+}
+
+pub(super) struct ElementTemplateExtractor<'a, V, F>
+where
+ V: VisitMut,
+ F: Fn(Span) -> Expr,
+{
+ parent_element: bool,
+ dynamic_attrs: Vec,
+ dynamic_attr_slots: Vec,
+ dynamic_children: Vec,
+ dynamic_part_visitor: &'a mut V,
+ pub(super) key: Option,
+ attr_slot_counter: i32,
+ element_slot_counter: i32,
+ enable_ui_source_map: bool,
+ node_index_fn: F,
+}
+
+impl<'a, V, F> ElementTemplateExtractor<'a, V, F>
+where
+ V: VisitMut,
+ F: Fn(Span) -> Expr,
+{
+ pub(super) fn new(
+ dynamic_part_visitor: &'a mut V,
+ enable_ui_source_map: bool,
+ node_index_fn: F,
+ ) -> Self {
+ Self {
+ parent_element: false,
+ dynamic_attrs: vec![],
+ dynamic_attr_slots: vec![],
+ dynamic_children: vec![],
+ dynamic_part_visitor,
+ key: None,
+ attr_slot_counter: 0,
+ element_slot_counter: 0,
+ enable_ui_source_map,
+ node_index_fn,
+ }
+ }
+
+ fn record_node_index(&self, span: Span) {
+ if self.enable_ui_source_map {
+ let _ = (self.node_index_fn)(span);
+ }
+ }
+
+ fn next_attr_slot_index(&mut self) -> i32 {
+ let idx = self.attr_slot_counter;
+ self.attr_slot_counter += 1;
+ idx
+ }
+
+ fn next_children_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, key: &str) {
+ let slot_index = self.next_attr_slot_index();
+ self.dynamic_attr_slots.push(TemplateAttributeSlot::Attr {
+ key: key.to_string(),
+ slot_index,
+ });
+ self.dynamic_attrs.push(DynamicAttributePart::Attr {
+ value,
+ attr_name,
+ slot_index,
+ });
+ }
+
+ fn push_dynamic_spread(&mut self, value: Expr) {
+ let slot_index = self.next_attr_slot_index();
+ self
+ .dynamic_attr_slots
+ .push(TemplateAttributeSlot::Spread { slot_index });
+ self
+ .dynamic_attrs
+ .push(DynamicAttributePart::Spread { value, slot_index });
+ }
+
+ fn normalize_inline_styles_if_static(&self, value: &mut Option) {
+ 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,
+ }));
+ }
+ }
+
+ fn push_dynamic_jsx_attr(
+ &mut self,
+ attr_name: AttrName,
+ key: &str,
+ value: &mut Option,
+ preserve_literal_expr: bool,
+ ) {
+ match &attr_name {
+ AttrName::Attr | AttrName::Dataset | AttrName::Class | AttrName::ID => {
+ if let Some(JSXAttrValue::JSXExprContainer(JSXExprContainer {
+ expr: JSXExpr::Expr(expr),
+ ..
+ })) = value
+ {
+ if !(preserve_literal_expr && matches!(&**expr, Expr::Lit(_))) {
+ self.push_dynamic_attr(*expr.clone(), attr_name, key);
+ }
+ }
+ }
+ AttrName::Style => {
+ self.normalize_inline_styles_if_static(value);
+ if let Some(JSXAttrValue::JSXExprContainer(JSXExprContainer {
+ expr: JSXExpr::Expr(expr),
+ ..
+ })) = value
+ {
+ self.push_dynamic_attr(*expr.clone(), attr_name, key);
+ }
+ }
+ AttrName::Event | AttrName::Ref => {
+ self.push_dynamic_attr(*jsx_attr_value((*value).clone()), attr_name, key);
+ }
+ AttrName::TimingFlag => {
+ self.push_dynamic_attr(
+ *quote_expr!("{__ltf: $flag}", flag: Expr = *jsx_attr_value((*value).clone())),
+ attr_name,
+ key,
+ );
+ }
+ AttrName::WorkletEvent | AttrName::WorkletRef | AttrName::Gesture => {
+ self.push_dynamic_attr(*jsx_attr_value((*value).clone()), attr_name, key);
+ }
+ }
+ }
+
+ fn ensure_flatten_attr(&self, n: &mut JSXElement) {
+ 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);
+ }
+ }
+ }
+
+ fn push_dynamic_jsx_attr_by_name(
+ &mut self,
+ name: &JSXAttrName,
+ value: &mut Option,
+ ) {
+ match name {
+ JSXAttrName::Ident(ident_name) => {
+ let key = template_attribute_key(ident_name.sym.as_ref()).to_string();
+ let attr_name = AttrName::from(>::into(ident_name.clone()));
+ self.push_dynamic_jsx_attr(attr_name, &key, value, true);
+ }
+ JSXAttrName::JSXNamespacedName(JSXNamespacedName { ns, name, .. }) => {
+ let key = template_namespaced_attribute_descriptor_key(ns, name);
+ let attr_name = AttrName::from_ns(ns.clone().into(), name.clone().into());
+ match attr_name {
+ AttrName::WorkletEvent | AttrName::WorkletRef | AttrName::Gesture => {
+ self.push_dynamic_jsx_attr(attr_name, &key, value, false);
+ }
+ _ => todo!(),
+ }
+ }
+ }
+ }
+
+ fn push_dynamic_jsx_attr_or_spread(&mut self, attr_or_spread: &mut JSXAttrOrSpread) {
+ match attr_or_spread {
+ JSXAttrOrSpread::SpreadElement(spread) => {
+ self.push_dynamic_spread(*spread.expr.clone());
+ }
+ JSXAttrOrSpread::JSXAttr(JSXAttr { name, value, .. }) => {
+ let name = name.clone();
+ self.push_dynamic_jsx_attr_by_name(&name, value);
+ }
+ }
+ }
+
+ pub(super) fn into_extracted_template_parts(self) -> ExtractedTemplateParts {
+ ExtractedTemplateParts {
+ key: self.key,
+ dynamic_attrs: self.dynamic_attrs,
+ dynamic_attr_slots: self.dynamic_attr_slots,
+ dynamic_children: self.dynamic_children,
+ }
+ }
+}
+
+impl VisitMut for ElementTemplateExtractor<'_, 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;
+ }
+
+ let mut merged_children: Vec = vec![];
+ let mut current_chunk: Vec = vec![];
+
+ for mut child in n.take() {
+ let should_merge = match child {
+ JSXElementChild::JSXText(ref text) => {
+ if jsx_text_to_str(&text.value).is_empty() {
+ current_chunk.is_empty()
+ } else {
+ true
+ }
+ }
+ JSXElementChild::JSXElement(ref element) => !jsx_is_custom(element),
+ JSXElementChild::JSXExprContainer(JSXExprContainer {
+ expr: JSXExpr::Expr(_),
+ ..
+ }) => false,
+ JSXElementChild::JSXFragment(_)
+ | JSXElementChild::JSXExprContainer(JSXExprContainer {
+ expr: JSXExpr::JSXEmptyExpr(_),
+ ..
+ }) => 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_children.push(DynamicElementPart::Slot(
+ jsx_children_to_expr(current_chunk.take()),
+ slot_index,
+ ));
+
+ let mut child =
+ JSXElementChild::JSXElement(Box::new(slot_placeholder_node(slot_index, false)));
+ 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_children.push(DynamicElementPart::Slot(
+ jsx_children_to_expr(current_chunk.take()),
+ slot_index,
+ ));
+
+ let mut child =
+ JSXElementChild::JSXElement(Box::new(slot_placeholder_node(slot_index, false)));
+ child.visit_mut_with(self);
+ merged_children.push(child);
+ }
+
+ *n = merged_children;
+ }
+
+ fn visit_mut_jsx_element(&mut self, n: &mut JSXElement) {
+ if is_slot_placeholder(n) {
+ return;
+ }
+
+ if self.parent_element && jsx_is_list(n) {
+ let slot_index = self.next_children_slot_index();
+ n.visit_mut_with(self.dynamic_part_visitor);
+ self.dynamic_children.push(DynamicElementPart::ListSlot(
+ Expr::JSXElement(Box::new(n.take())),
+ slot_index,
+ ));
+
+ *n = slot_placeholder_node(slot_index, true);
+ n.visit_mut_with(self);
+ return;
+ }
+
+ if !jsx_is_custom(n) {
+ self.record_node_index(n.span);
+
+ if jsx_has_dynamic_key(n) && self.parent_element {
+ 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_children
+ .push(DynamicElementPart::ListSlot(expr, slot_index));
+ } else {
+ self
+ .dynamic_children
+ .push(DynamicElementPart::Slot(expr, slot_index));
+ }
+
+ *n = slot_placeholder_node(slot_index, false);
+ }
+
+ self.ensure_flatten_attr(n);
+
+ 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 {
+ self.key = value.take();
+ }
+ false
+ }
+ _ => true,
+ },
+ JSXAttrName::JSXNamespacedName(_) => true,
+ },
+ });
+
+ for attr_or_spread in &mut n.opening.attrs {
+ self.push_dynamic_jsx_attr_or_spread(attr_or_spread);
+ }
+
+ if !jsx_is_children_full_dynamic(n) {
+ let previous_parent_element = self.parent_element;
+ self.parent_element = true;
+ n.visit_mut_children_with(self);
+ self.parent_element = previous_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_children
+ .push(DynamicElementPart::ListSlot(children_expr, slot_index));
+ } else {
+ self
+ .dynamic_children
+ .push(DynamicElementPart::Slot(children_expr, slot_index));
+ }
+ n.children = vec![JSXElementChild::JSXElement(Box::new(
+ slot_placeholder_node(slot_index, true),
+ ))];
+ }
+ } else {
+ n.visit_mut_children_with(self.dynamic_part_visitor);
+
+ if self.parent_element {
+ let slot_index = self.next_children_slot_index();
+ self.dynamic_children.push(DynamicElementPart::Slot(
+ Expr::JSXElement(Box::new(n.take())),
+ slot_index,
+ ));
+
+ *n = slot_placeholder_node(slot_index, true);
+ n.visit_mut_with(self);
+ }
+ }
+ }
+
+ fn visit_mut_jsx_text(&mut self, n: &mut JSXText) {
+ if !jsx_text_to_str(&n.value).is_empty() {
+ self.record_node_index(n.span);
+ }
+ }
+}
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..5d7e03825e
--- /dev/null
+++ b/packages/react/transform/crates/swc_plugin_element_template/lib.rs
@@ -0,0 +1,434 @@
+use once_cell::sync::Lazy;
+use serde::Deserialize;
+use std::{cell::RefCell, rc::Rc};
+use swc_core::{
+ common::{
+ comments::{CommentKind, Comments},
+ errors::HANDLER,
+ sync::Lrc,
+ util::take::Take,
+ Mark, SourceMap, Span, Spanned, SyntaxContext, DUMMY_SP,
+ },
+ ecma::{
+ ast::*,
+ utils::{prepend_stmt, private_ident},
+ visit::{VisitMut, VisitMutWith},
+ },
+ quote,
+};
+
+mod asset;
+mod attr_name;
+mod extractor;
+mod lowering;
+mod slot;
+mod template_attribute;
+mod template_definition;
+mod template_slot;
+
+pub use self::asset::ElementTemplateAsset;
+use self::extractor::{ElementTemplateExtractor, ExtractedTemplateParts};
+use self::lowering::LoweredRuntimeJsx;
+use self::template_slot::ET_SLOT_PLACEHOLDER_TAG;
+
+#[derive(Clone, Debug, Deserialize)]
+pub struct UISourceMapRecord {
+ pub ui_source_map: i32,
+ pub line_number: u32,
+ pub column_number: u32,
+ pub template_id: String,
+}
+
+pub type ElementTemplateTransformerConfig = JSXTransformerConfig;
+pub type ElementTemplateTransformer = JSXTransformer;
+pub type ElementTemplateUISourceMapRecord = UISourceMapRecord;
+
+#[cfg(feature = "napi")]
+pub mod napi;
+
+use swc_plugins_shared::{
+ jsx_helpers::jsx_name,
+ target::TransformTarget,
+ transform_mode::TransformMode,
+ utils::{calc_hash, calc_hash_number},
+};
+
+pub fn i32_to_expr(i: &i32) -> Expr {
+ Expr::Lit(Lit::Num(Number {
+ span: DUMMY_SP,
+ value: *i as f64,
+ raw: None,
+ }))
+}
+
+/// @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,
+}
+
+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),
+ }
+ }
+}
+
+pub struct JSXTransformer
+where
+ C: Comments + Clone,
+{
+ // react_transformer: Box,
+ cfg: JSXTransformerConfig,
+ filename_hash: String,
+ pub content_hash: String,
+ runtime_id: Lazy,
+ pub element_templates: Option>>>,
+ template_counter: u32,
+ current_template_defs: Vec,
+ 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")))
+ }
+ },
+ element_templates,
+ cfg,
+ template_counter: 0,
+ current_template_defs: vec![],
+ comments,
+ slot_ident: private_ident!("__etSlot"),
+ used_slot: false,
+ ui_source_map_records: Rc::new(RefCell::new(vec![])),
+ source_map,
+ }
+ }
+
+ fn validate_directives(&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 {
+ css_id.parse::().expect("should have numeric cssId");
+ }
+ }
+ }
+ }
+ }
+ });
+ }
+}
+
+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 == ET_SLOT_PLACEHOLDER_TAG {
+ return node.visit_mut_children_with(self);
+ }
+ if tag_str == "page" || tag_str == "component" {
+ HANDLER.with(|handler| {
+ handler
+ .struct_span_err(
+ node.opening.name.span(),
+ &format!("<{tag_str} /> is not supported"),
+ )
+ .emit()
+ });
+ }
+ }
+ }
+ _ => {
+ return node.visit_mut_children_with(self);
+ }
+ }
+
+ self.template_counter += 1;
+ let template_uid = format!(
+ "{}_{}_{}_{}",
+ "_et", self.filename_hash, self.content_hash, self.template_counter
+ );
+ let template_ident = Ident::new(
+ template_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 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 template_uid_for_captured = template_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,
+ template_id: template_uid_for_captured.clone(),
+ });
+
+ Expr::Lit(Lit::Num(Number {
+ span: DUMMY_SP,
+ value: ui_source_map as f64,
+ raw: None,
+ }))
+ };
+ let ExtractedTemplateParts {
+ key,
+ dynamic_attrs,
+ dynamic_attr_slots,
+ dynamic_children,
+ } = {
+ let mut extractor =
+ ElementTemplateExtractor::new(self, self.cfg.enable_ui_source_map, node_index_fn);
+
+ node.visit_mut_with(&mut extractor);
+ extractor.into_extracted_template_parts()
+ };
+
+ let LoweredRuntimeJsx {
+ attrs: rendered_attrs,
+ children: rendered_children,
+ } = self.lower_runtime_jsx(
+ target,
+ runtime_id.clone(),
+ key,
+ dynamic_attrs,
+ dynamic_children,
+ );
+
+ let mut entry_template_uid = quote!("$template_uid" as Expr, template_uid: Expr = Expr::Lit(Lit::Str(template_uid.clone().into())));
+ if matches!(self.cfg.is_dynamic_component, Some(true)) {
+ entry_template_uid = quote!("`${globDynamicComponentEntry}:${$template_uid}`" as Expr, template_uid: Expr = Expr::Lit(Lit::Str(template_uid.clone().into())));
+ }
+
+ let entry_template_uid_def = ModuleItem::Stmt(quote!(
+ r#"const $template_ident = $entry_template_uid"#
+ as Stmt,
+ template_ident = template_ident.clone(),
+ entry_template_uid: Expr = entry_template_uid.clone(),
+ ));
+ self.current_template_defs.push(entry_template_uid_def);
+
+ let mut dynamic_attr_slot_cursor: usize = 0;
+ let mut element_slot_index: i32 = 0;
+ // Attribute slot indices come from ElementTemplateExtractor so runtime
+ // values and Template Definition descriptors share one compile-time source.
+ let template_expr = self.element_template_from_jsx_element(
+ node,
+ &dynamic_attr_slots,
+ &mut dynamic_attr_slot_cursor,
+ &mut element_slot_index,
+ );
+ assert_eq!(
+ dynamic_attr_slot_cursor,
+ dynamic_attr_slots.len(),
+ "Template Definition must consume every ET attr slot produced by extractor"
+ );
+ 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: template_uid.clone(),
+ compiled_template,
+ source_file: self.cfg.filename.clone(),
+ });
+ }
+
+ let rendered_children_is_empty = rendered_children.is_empty();
+
+ *node = JSXElement {
+ span: node.span(),
+ opening: JSXOpeningElement {
+ name: JSXElementName::Ident(template_ident.clone()),
+ span: node.span,
+ attrs: rendered_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(template_ident.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_template_defs.take());
+ new_items.push(item.take());
+ }
+
+ *n = new_items;
+ }
+
+ fn visit_mut_module(&mut self, n: &mut Module) {
+ self.validate_directives(n.span);
+ for item in &n.body {
+ let span = item.span();
+ self.validate_directives(span);
+ }
+
+ n.visit_mut_children_with(self);
+ 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.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,
+ })),
+ );
+ }
+ }
+}
diff --git a/packages/react/transform/crates/swc_plugin_element_template/lowering.rs b/packages/react/transform/crates/swc_plugin_element_template/lowering.rs
new file mode 100644
index 0000000000..6acec37881
--- /dev/null
+++ b/packages/react/transform/crates/swc_plugin_element_template/lowering.rs
@@ -0,0 +1,170 @@
+use swc_core::{
+ common::{comments::Comments, DUMMY_SP},
+ ecma::ast::*,
+ quote,
+};
+use swc_plugins_shared::{jsx_helpers::jsx_children_to_expr, target::TransformTarget};
+
+use super::{
+ attr_name::AttrName,
+ extractor::{DynamicAttributePart, DynamicElementPart},
+ slot::{expr_to_jsx_child, lower_lepus_et_children_expr, wrap_in_slot},
+ JSXTransformer,
+};
+
+pub(super) struct LoweredRuntimeJsx {
+ pub attrs: Vec,
+ pub children: Vec,
+}
+
+impl JSXTransformer
+where
+ C: Comments + Clone,
+{
+ pub(super) fn lower_runtime_jsx(
+ &mut self,
+ target: TransformTarget,
+ runtime_id: Expr,
+ key: Option,
+ dynamic_attrs: Vec,
+ dynamic_children: Vec,
+ ) -> LoweredRuntimeJsx {
+ let mut rendered_slot_children: Vec = vec![];
+ let mut attr_slot_values: Vec