diff --git a/packages/next-swc/Cargo.lock b/packages/next-swc/Cargo.lock index dd6333b29e4f..eaaed4fb7b5d 100644 --- a/packages/next-swc/Cargo.lock +++ b/packages/next-swc/Cargo.lock @@ -796,12 +796,14 @@ checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54" name = "next-swc" version = "0.0.0" dependencies = [ + "byteorder", "chrono", "easy-error", "either", "fxhash", "once_cell", "pathdiff", + "radix_fmt", "regex", "serde", "serde_json", diff --git a/packages/next-swc/crates/core/Cargo.toml b/packages/next-swc/crates/core/Cargo.toml index 063a0129010a..d4a0984d3546 100644 --- a/packages/next-swc/crates/core/Cargo.toml +++ b/packages/next-swc/crates/core/Cargo.toml @@ -7,25 +7,27 @@ version = "0.0.0" crate-type = ["cdylib", "rlib"] [dependencies] +byteorder = "1" chrono = "0.4" -once_cell = "1.8.0" easy-error = "1.0.0" either = "1" fxhash = "0.2.1" +once_cell = "1.8.0" pathdiff = "0.2.0" +radix_fmt = "1" +regex = "1.5" serde = "1" serde_json = "1" styled_components = "0.14.0" swc = "0.126.2" swc_atoms = "0.2.7" -swc_common = { version = "0.17.0", features = ["concurrent", "sourcemap"] } +swc_common = {version = "0.17.0", features = ["concurrent", "sourcemap"]} swc_css = "0.87.0" -swc_ecma_loader = { version = "0.28.0", features = ["node", "lru"] } -swc_ecmascript = { version = "0.114.2", features = ["codegen", "minifier", "optimization", "parser", "react", "transforms", "typescript", "utils", "visit"] } +swc_ecma_loader = {version = "0.28.0", features = ["node", "lru"]} +swc_ecmascript = {version = "0.114.2", features = ["codegen", "minifier", "optimization", "parser", "react", "transforms", "typescript", "utils", "visit"]} swc_node_base = "0.5.1" swc_stylis = "0.83.0" tracing = {version = "0.1.28", features = ["release_max_level_off"]} -regex = "1.5" [dev-dependencies] swc_ecma_transforms_testing = "0.60.0" diff --git a/packages/next-swc/crates/core/src/emotion/global_parent_cache.rs b/packages/next-swc/crates/core/src/emotion/global_parent_cache.rs new file mode 100644 index 000000000000..ccc616e65627 --- /dev/null +++ b/packages/next-swc/crates/core/src/emotion/global_parent_cache.rs @@ -0,0 +1,62 @@ +use std::path::{Path, PathBuf}; + +use fxhash::FxHashMap; +use once_cell::sync::Lazy; +use serde::Deserialize; +use serde_json::from_reader; +use swc_common::sync::RwLock; + +pub(crate) static GLOBAL_PARENT_CACHE: Lazy = Lazy::new(GlobalParentCache::new); + +#[derive(Deserialize, Debug, Clone)] +struct PackageJson { + name: String, +} + +#[derive(Clone, Debug)] +#[non_exhaustive] +pub(crate) struct RootPathInfo { + pub(crate) package_name: String, + pub(crate) root_path: PathBuf, +} + +impl RootPathInfo { + pub(crate) fn new(package_name: String, root_path: PathBuf) -> Self { + Self { + package_name, + root_path, + } + } +} + +pub(crate) struct GlobalParentCache { + cache: RwLock>, +} + +impl GlobalParentCache { + fn new() -> Self { + Self { + cache: RwLock::new(FxHashMap::default()), + } + } +} + +impl GlobalParentCache { + pub(crate) fn get(&self, p: &Path) -> Option { + let guard = self.cache.read(); + guard.get(p).cloned() + } + + pub(crate) fn insert(&self, p: PathBuf, parent: PathBuf) -> RootPathInfo { + let mut write_lock = self.cache.borrow_mut(); + // Safe to unwrap, because `existed` is true + let file = std::fs::File::open(parent.join("package.json")).unwrap(); + let package_json: PackageJson = from_reader(file).unwrap(); + let info = RootPathInfo { + package_name: package_json.name, + root_path: parent, + }; + write_lock.insert(p, info.clone()); + info + } +} diff --git a/packages/next-swc/crates/core/src/emotion/hash.rs b/packages/next-swc/crates/core/src/emotion/hash.rs new file mode 100644 index 000000000000..7cff0066e1ed --- /dev/null +++ b/packages/next-swc/crates/core/src/emotion/hash.rs @@ -0,0 +1,59 @@ +// Ported from https://github.com/aappleby/smhasher/blob/61a0530f28277f2e850bfc39600ce61d02b518de/src/MurmurHash2.cpp#L37-L86 + +use byteorder::{ByteOrder, LittleEndian}; + +const M: u32 = 0x5bd1_e995; + +pub(crate) fn murmurhash2(key: &[u8]) -> String { + let mut h: u32 = 0; + + let mut four_bytes_chunks = key.chunks_exact(4); + for chunk in four_bytes_chunks.by_ref() { + let mut k: u32 = LittleEndian::read_u32(chunk); + k = k.wrapping_mul(M); + k ^= k >> 24; + h = k.wrapping_mul(M) ^ h.wrapping_mul(M); + } + let remainder = four_bytes_chunks.remainder(); + + // Handle the last few bytes of the input array + match remainder.len() { + 3 => { + h ^= u32::from(remainder[2]) << 16; + } + 2 => { + h ^= u32::from(remainder[1]) << 8; + } + 1 => { + h ^= u32::from(remainder[0]); + h = h.wrapping_mul(M); + } + _ => {} + } + h ^= h >> 13; + h = h.wrapping_mul(M); + format!("{}", radix_fmt::radix_36(h ^ (h >> 15))) +} + +#[cfg(test)] +mod test { + + use super::murmurhash2; + + #[test] + fn test_murmur2() { + let s1 = "abcdef"; + let s2 = "abcdeg"; + for i in 0..5 { + assert_eq!( + murmurhash2(&s1[i..5].as_bytes()), + murmurhash2(&s2[i..5].as_bytes()) + ); + } + } + + #[test] + fn verify_hash() { + assert_eq!(murmurhash2("something".as_bytes()), "crsxd7".to_owned()); + } +} diff --git a/packages/next-swc/crates/core/src/emotion/mod.rs b/packages/next-swc/crates/core/src/emotion/mod.rs new file mode 100644 index 000000000000..d63525c55489 --- /dev/null +++ b/packages/next-swc/crates/core/src/emotion/mod.rs @@ -0,0 +1,272 @@ +use std::path::Path; +use std::sync::Arc; + +use fxhash::FxHashMap; +use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; +use swc_common::{FileName, SourceFile, DUMMY_SP}; +use swc_ecmascript::ast::{ + ExprOrSpread, Ident, KeyValueProp, Lit, ObjectLit, Prop, PropName, PropOrSpread, +}; +use swc_ecmascript::{ + ast::{Callee, Expr, ImportDecl, ImportSpecifier}, + visit::{swc_ecma_ast::CallExpr, Fold}, +}; + +use self::global_parent_cache::RootPathInfo; + +mod global_parent_cache; +mod hash; + +static EMOTION_OFFICIAL_LIBRARIES: Lazy> = Lazy::new(|| { + vec![ + EmotionModuleConfig { + module_name: "@emotion/styled".to_owned(), + exported_names: vec!["styled".to_owned()], + has_default_export: Some(true), + ..Default::default() + }, + EmotionModuleConfig { + module_name: "@emotion/react".to_owned(), + exported_names: vec!["css".to_owned()], + ..Default::default() + }, + ] +}); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EmotionOptions { + enabled: Option, + sourcemap: Option, + auto_label: Option, + label_format: Option, + auto_inject: Option, + custom_modules: Option>, + jsx_factory: Option, + jsx_import_source: Option, +} + +impl Default for EmotionOptions { + fn default() -> Self { + EmotionOptions { + enabled: Some(false), + sourcemap: Some(true), + auto_label: Some(true), + label_format: Some("[local]".to_owned()), + auto_inject: Some(true), + custom_modules: None, + jsx_import_source: Some("@emotion/react".to_owned()), + jsx_factory: None, + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct EmotionModuleConfig { + module_name: String, + exported_names: Vec, + include_sub_path: Option, + has_default_export: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +enum ImportType { + Named, + Namespace, + Default, +} + +impl Default for ImportType { + fn default() -> Self { + ImportType::Named + } +} + +#[derive(Debug)] +struct PackageMeta { + _type: ImportType, +} + +#[derive(Debug)] +pub struct EmotionTransformer { + pub options: EmotionOptions, + source_file: Arc, + _react_jsx_runtime: bool, + _es_module_interop: bool, + custom_modules: Vec, + import_packages: FxHashMap, + emotion_target_class_name_count: usize, +} + +impl EmotionTransformer { + pub fn new( + options: EmotionOptions, + source_file: Arc, + react_jsx_runtime: bool, + es_module_interop: bool, + ) -> Self { + EmotionTransformer { + custom_modules: options.custom_modules.clone().unwrap_or_default(), + options, + source_file, + _react_jsx_runtime: react_jsx_runtime, + import_packages: FxHashMap::default(), + _es_module_interop: es_module_interop, + emotion_target_class_name_count: 0, + } + } + + // Find the imported name from modules + // These import statements are supported: + // import styled from '@emotion/styled' + // import { default as whateverStyled } from '@emotion/styled' + // import * as styled from '@emotion/styled' // with `no_interop: true` + // import { css } from '@emotion/react' + // import emotionCss from '@emotion/react' + // import * as emotionCss from '@emotion/react' // with `no_interop: true` + fn generate_import_info(&mut self, expr: &ImportDecl) { + for c in EMOTION_OFFICIAL_LIBRARIES + .iter() + .chain(self.custom_modules.iter()) + { + if expr.src.value == c.module_name { + for specifier in expr.specifiers.iter() { + match specifier { + ImportSpecifier::Named(named) => { + for export_name in c.exported_names.iter() { + if named.local.as_ref() == export_name { + self.import_packages.insert( + named.local.to_string(), + PackageMeta { + _type: ImportType::Named, + }, + ); + } + } + } + ImportSpecifier::Default(default) => { + if c.has_default_export.unwrap_or(false) { + self.import_packages.insert( + default.local.to_string(), + PackageMeta { + _type: ImportType::Default, + }, + ); + } + } + ImportSpecifier::Namespace(namespace) => { + self.import_packages.insert( + namespace.local.to_string(), + PackageMeta { + _type: ImportType::Namespace, + }, + ); + } + } + } + } + } + } +} + +impl Fold for EmotionTransformer { + // Collect import modules that indicator if this file need to be transformed + fn fold_import_decl(&mut self, expr: ImportDecl) -> ImportDecl { + if expr.type_only { + return expr; + } + self.generate_import_info(&expr); + expr + } + + fn fold_call_expr(&mut self, mut expr: CallExpr) -> CallExpr { + // If no package that we care about is imported, skip the following + // transformation logic. + if self.import_packages.is_empty() { + return expr; + } + if let Callee::Expr(e) = &mut expr.callee { + match e.as_ref() { + // css({}) + Expr::Ident(i) => { + if self.import_packages.get(i.as_ref()).is_some() && !expr.args.is_empty() { + if let FileName::Real(filename) = &self.source_file.name { + let root_info = find_root(filename).unwrap_or_else(|| { + RootPathInfo::new("".to_owned(), filename.to_path_buf()) + }); + let final_path = if &root_info.root_path == filename { + "root" + } else { + root_info + .root_path + .to_str() + .and_then(|root| { + filename + .to_str() + .map(|filename| filename.trim_start_matches(root)) + }) + .unwrap_or_else(|| self.source_file.src.as_str()) + }; + let stable_class_name = format!( + "e{}{}", + hash::murmurhash2( + format!("{}{}", &root_info.package_name, final_path).as_bytes() + ), + self.emotion_target_class_name_count + ); + self.emotion_target_class_name_count += 1; + let target_assignment = + PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp { + key: PropName::Ident(Ident::new("target".into(), DUMMY_SP)), + value: Box::new(Expr::Lit(Lit::Str(stable_class_name.into()))), + }))); + match expr.args.len() { + 1 => { + expr.args.push(ExprOrSpread { + spread: None, + expr: Box::new(Expr::Object(ObjectLit { + span: DUMMY_SP, + props: vec![target_assignment], + })), + }); + } + 2 => { + if let Expr::Object(lit) = expr.args[1].expr.as_mut() { + lit.props.push(target_assignment); + } + } + _ => {} + } + } + } + } + // styled('div')({}) + Expr::Call(_c) => {} + // styled.div({}) + // customEmotionReact.css({}) + Expr::Member(_m) => {} + _ => {} + } + } + expr + } +} + +fn find_root(p: &Path) -> Option { + if let Some(parent) = p.parent() { + let parent = parent.to_path_buf(); + if let Some(p) = global_parent_cache::GLOBAL_PARENT_CACHE.get(&parent) { + return Some(p); + } + if parent.exists() { + if parent.join("package.json").exists() { + return Some( + global_parent_cache::GLOBAL_PARENT_CACHE.insert(parent.clone(), parent), + ); + } else { + return find_root(&parent); + } + } + } + None +} diff --git a/packages/next-swc/crates/core/src/lib.rs b/packages/next-swc/crates/core/src/lib.rs index 6e788b3cd000..83404871bb70 100644 --- a/packages/next-swc/crates/core/src/lib.rs +++ b/packages/next-swc/crates/core/src/lib.rs @@ -40,6 +40,7 @@ use swc_common::{self, chain, pass::Optional}; use swc_common::{SourceFile, SourceMap}; use swc_ecmascript::ast::EsVersion; use swc_ecmascript::transforms::pass::noop; +use swc_ecmascript::transforms::react::Runtime; use swc_ecmascript::{ parser::{lexer::Lexer, Parser, StringInput}, visit::Fold, @@ -48,6 +49,7 @@ use swc_ecmascript::{ pub mod amp_attributes; mod auto_cjs; pub mod disallow_re_export_all_in_page; +pub mod emotion; pub mod hook_optimizer; pub mod next_dynamic; pub mod next_ssg; @@ -99,6 +101,9 @@ pub struct TransformOptions { #[serde(default)] pub shake_exports: Option, + + #[serde(default)] + pub emotion: Option, } pub fn custom_before_pass( @@ -166,6 +171,39 @@ pub fn custom_before_pass( match &opts.shake_exports { Some(config) => Either::Left(shake_exports::shake_exports(config.clone())), None => Either::Right(noop()), + }, + match &opts.emotion { + Some(config) => { + let is_react_jsx_runtime = opts + .swc + .config + .jsc + .transform + .as_ref() + .and_then(|t| t.react.runtime) + .map(|r| matches!(r, Runtime::Automatic)) + .unwrap_or(false); + let es_module_interop = opts + .swc + .config + .module + .as_ref() + .map(|m| { + if let ModuleConfig::CommonJs(c) = m { + !c.no_interop + } else { + true + } + }) + .unwrap_or(true); + Either::Left(emotion::EmotionTransformer::new( + config.clone(), + file.clone(), + is_react_jsx_runtime, + es_module_interop, + )) + } + None => Either::Right(noop()), } ) } diff --git a/packages/next-swc/crates/core/tests/full.rs b/packages/next-swc/crates/core/tests/full.rs index f915188aace8..068008351674 100644 --- a/packages/next-swc/crates/core/tests/full.rs +++ b/packages/next-swc/crates/core/tests/full.rs @@ -61,6 +61,7 @@ fn test(input: &Path, minify: bool) { react_remove_properties: None, relay: None, shake_exports: None, + emotion: Some(assert_json("{}")), }; let options = options.patch(&fm);