Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion packages/react/transform/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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"] }
Expand Down
59 changes: 59 additions & 0 deletions packages/react/transform/__test__/fixture.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <view className="foo" />;', {
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 = <view className="foo" />;', {
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 = <Foo main-thread:foo={foo} />', {
Expand Down
Original file line number Diff line number Diff line change
@@ -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"] }
Original file line number Diff line number Diff line change
@@ -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<C> JSXTransformer<C>
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());
}
}
Original file line number Diff line number Diff line change
@@ -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<String> 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<Str> for AttrName {
fn from(name: Str) -> Self {
let name = name.value.to_string_lossy().into_owned();
Self::from(name)
}
}

impl From<Ident> 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
}
Loading
Loading