diff --git a/.changeset/gorgeous-peas-dress.md b/.changeset/gorgeous-peas-dress.md new file mode 100644 index 00000000..5c4f5c17 --- /dev/null +++ b/.changeset/gorgeous-peas-dress.md @@ -0,0 +1,7 @@ +--- +"@devup-ui/webpack-plugin": patch +"@devup-ui/wasm": patch +"@devup-ui/next-plugin": patch +--- + +Fix hmr issue diff --git a/Cargo.lock b/Cargo.lock index d8961a3f..73586d23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,6 +116,7 @@ name = "css" version = "0.1.0" dependencies = [ "once_cell", + "serde", "serial_test", ] @@ -124,9 +125,13 @@ name = "devup-ui-wasm" version = "0.1.0" dependencies = [ "console_error_panic_hook", + "css", "extractor", + "insta", "js-sys", "once_cell", + "serde-wasm-bindgen", + "serde_json", "serial_test", "sheet", "wasm-bindgen", @@ -173,12 +178,6 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" -[[package]] -name = "foldhash" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" - [[package]] name = "futures" version = "0.3.31" @@ -263,8 +262,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" dependencies = [ "allocator-api2", - "equivalent", - "foldhash", ] [[package]] @@ -279,13 +276,14 @@ dependencies = [ [[package]] name = "insta" -version = "1.42.0" +version = "1.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6513e4067e16e69ed1db5ab56048ed65db32d10ba5fc1217f5393f8f17d8b5a5" +checksum = "71c1b125e30d93896b365e156c33dadfffab45ee8400afcbba4752f59de08a86" dependencies = [ "console", "linked-hash-map", "once_cell", + "pin-project", "similar", ] @@ -437,9 +435,9 @@ dependencies = [ [[package]] name = "oxc_allocator" -version = "0.47.1" +version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f5b38027b943889e914774968782a2b67df6ba50f6d4e265eaa7a933a8e3075" +checksum = "dd69a857285f6519c595cfba74b91649b22b715169f11e2b4355f32b36c780c2" dependencies = [ "allocator-api2", "bumpalo", @@ -450,9 +448,9 @@ dependencies = [ [[package]] name = "oxc_ast" -version = "0.47.1" +version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb78951e98af99dfdac1e530f5a36af114d9d8a03e967b5ea68be271509183da" +checksum = "aeac0e9cfa9b40f04feb51d723a71279a985088c3df18d04e92e8a2d96385017" dependencies = [ "bitflags", "cow-utils", @@ -468,9 +466,9 @@ dependencies = [ [[package]] name = "oxc_ast_macros" -version = "0.47.1" +version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17b1dc89732a6969c6dcb274035c737db46fe1f378ce86fb6c4674db201f5ca" +checksum = "4f3dfdfd34a28dd0258717a0a6e40c1f33bcf096c11f0b29a0930aca48b34b81" dependencies = [ "proc-macro2", "quote", @@ -479,9 +477,9 @@ dependencies = [ [[package]] name = "oxc_cfg" -version = "0.47.1" +version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bc62e52879170ba6e818ec93f8ed2c32e38afa3de0aee64fb28555e91615843" +checksum = "8828ecb52029aeec2cc96039e407e9e64c3c343cb85a3ed7d7e17908f3013c10" dependencies = [ "bitflags", "itertools", @@ -494,9 +492,9 @@ dependencies = [ [[package]] name = "oxc_codegen" -version = "0.47.1" +version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64866a6b9761c75da095ef24a93925fe7be309373125c4bf52ebc959b3254035" +checksum = "317584c2cea0053b4882901ac5f7caa6036d39285881767467d7ade6959a1816" dependencies = [ "assert-unchecked", "bitflags", @@ -504,6 +502,7 @@ dependencies = [ "nonmax", "oxc_allocator", "oxc_ast", + "oxc_data_structures", "oxc_index", "oxc_mangler", "oxc_sourcemap", @@ -514,9 +513,9 @@ dependencies = [ [[package]] name = "oxc_data_structures" -version = "0.47.1" +version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24075d02645a88bc56ea5b82bddf685eeb58284df2079ac357d26222bbe382be" +checksum = "150f668753d100a1709f2962f4000405e85b34feac675aec71c9cf158676a26a" dependencies = [ "assert-unchecked", "ropey", @@ -524,18 +523,18 @@ dependencies = [ [[package]] name = "oxc_diagnostics" -version = "0.47.1" +version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5f7392025441f29ae145b21bc0e656530f2443fb197195b6ba39ac0d613b8b" +checksum = "cc6d24b258ff0a030abaf64b0dcf66b7e0edd7a7a250982bee221a00518517b0" dependencies = [ "oxc-miette", ] [[package]] name = "oxc_ecmascript" -version = "0.47.1" +version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5993579c5c04491bfcfc9a675c9939caa9f8f5faceecc5e4d281c432bbfd4c6e" +checksum = "2436fe122e749f834e8a8d9efe68f5d3b64e3c757424db0a897a289e173dfea1" dependencies = [ "num-bigint", "num-traits", @@ -546,9 +545,9 @@ dependencies = [ [[package]] name = "oxc_estree" -version = "0.47.1" +version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b216e48759b14e08652c925aa024f6b2d318a795793bbe51482718829ac9bc4" +checksum = "ae587120a6203d483ff0e3f36aa7b062cdffba829e9ebbfecc9e434f83a7db19" [[package]] name = "oxc_index" @@ -558,10 +557,11 @@ checksum = "5eca5d9726cd0a6e433debe003b7bc88b2ecad0bb6109f0cef7c55e692139a34" [[package]] name = "oxc_mangler" -version = "0.47.1" +version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7241cb3b72c30d77117fc07066bfa7530bb52c0c769996186ea6779d287f37c3" +checksum = "439e7592d6ba4177e5422c06a8c59b860303c7dda5046140733f6cb76ef5623c" dependencies = [ + "fixedbitset", "itertools", "oxc_allocator", "oxc_ast", @@ -573,9 +573,9 @@ dependencies = [ [[package]] name = "oxc_parser" -version = "0.47.1" +version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08e83d2a08991efc1a79c3b42fcc77344f3de58d27c53cd9b184d17b1ed4ace3" +checksum = "3012efb4a88efc6b24977a45254ef5f4e18490d47421bbefa11503198399bfa2" dependencies = [ "assert-unchecked", "bitflags", @@ -596,9 +596,9 @@ dependencies = [ [[package]] name = "oxc_regular_expression" -version = "0.47.1" +version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d495804e1bf588e5e1a2d6fe760ff1ba53ece5cbf04e4dce531da64014869fea" +checksum = "f207437d0911f8f9e77efb680b7fc5a7000073fcf72072139cf95cac18e89f6c" dependencies = [ "oxc_allocator", "oxc_ast_macros", @@ -612,9 +612,9 @@ dependencies = [ [[package]] name = "oxc_semantic" -version = "0.47.1" +version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93df6d4abf46480c5479eba2a367e9fb837aece6f908d4d4271c2e5ae1bf126" +checksum = "3adfa6378abdef12ee82535f4c53018b46773e5ed91c4f0ca632e6e369fae89b" dependencies = [ "assert-unchecked", "itertools", @@ -648,9 +648,9 @@ dependencies = [ [[package]] name = "oxc_span" -version = "0.47.1" +version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a50160bfd66b0ef0acff1181c6860c61cf59d6c0d5bf05b589f1fc6dcdbafc2" +checksum = "8457acf582fa6139d91b6e28d839baf67fe8f0f1f427a3da15cb50e71b82e1f8" dependencies = [ "compact_str", "oxc-miette", @@ -661,9 +661,9 @@ dependencies = [ [[package]] name = "oxc_syntax" -version = "0.47.1" +version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aa0a2543f375feb387b0b94a6d72ec5b0cc6a2349518d48704189b23d6c4070" +checksum = "56135335adaf1c90d23851da0afb9960f191ef2f3fcd6f16cf16da2e6cfc339e" dependencies = [ "assert-unchecked", "bitflags", @@ -755,6 +755,26 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e2ec53ad785f4d35dac0adea7f7dc6f1bb277ad84a680c7afefeae05d1f5916" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56a66c0c55993aa927429d0f8a0abfd74f084e4d9c192cffed01e418d83eefb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -894,6 +914,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_derive" version = "1.0.217" @@ -907,9 +938,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.134" +version = "1.0.138" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d00f4175c42ee48b15416f6193a959ba3a0d67fc699a0db9ad12df9f83991c7d" +checksum = "d434192e7da787e94a6ea7e9670b26a036d0ca41e0b7efb2676dd32bae872949" dependencies = [ "itoa", "memchr", @@ -948,6 +979,8 @@ version = "0.1.0" dependencies = [ "css", "insta", + "serde", + "serde_json", ] [[package]] diff --git a/apps/landing/package.json b/apps/landing/package.json index c721048b..d16eeaa5 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -4,7 +4,7 @@ "type": "module", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev --turbo", "build": "next build", "start": "next start", "lint": "eslint" diff --git a/bindings/devup-ui-wasm/Cargo.toml b/bindings/devup-ui-wasm/Cargo.toml index 79797c55..fbce4050 100644 --- a/bindings/devup-ui-wasm/Cargo.toml +++ b/bindings/devup-ui-wasm/Cargo.toml @@ -14,6 +14,7 @@ default = ["console_error_panic_hook"] wasm-bindgen = "0.2.100" extractor = { path = "../../libs/extractor" } sheet = { path = "../../libs/sheet" } +css = { path = "../../libs/css" } # The `console_error_panic_hook` crate provides better debugging of panics by # logging them with `console.error`. This is great for development, but requires @@ -22,7 +23,10 @@ sheet = { path = "../../libs/sheet" } console_error_panic_hook = { version = "0.1.7", optional = true } once_cell = "1.20.2" js-sys = "0.3.76" +serde_json = "1.0.138" +serde-wasm-bindgen = "0.6.5" [dev-dependencies] wasm-bindgen-test = "0.3.50" serial_test = "3.2.0" +insta = "1.42.1" diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index a962bc77..bcad86ec 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -1,8 +1,7 @@ +use css::{get_class_map, set_class_map}; use extractor::extract_style::ExtractStyleValue; use extractor::{extract, ExtractOption, StyleProperty}; -use js_sys::{Object, Reflect}; use once_cell::sync::Lazy; -use sheet::theme::{ColorTheme, Theme, Typography}; use sheet::StyleSheet; use std::collections::HashSet; use std::sync::Mutex; @@ -86,6 +85,34 @@ impl Output { } } +#[wasm_bindgen(js_name = "importSheet")] +pub fn import_sheet(sheet_object: JsValue) -> Result<(), JsValue> { + let mut sheet = GLOBAL_STYLE_SHEET.lock().unwrap(); + *sheet = serde_wasm_bindgen::from_value(sheet_object) + .map_err(|e| JsValue::from_str(e.to_string().as_str()))?; + Ok(()) +} + +#[wasm_bindgen(js_name = "exportSheet")] +pub fn export_sheet() -> Result { + let sheet = GLOBAL_STYLE_SHEET.lock().unwrap(); + serde_json::to_string(&*sheet).map_err(|e| JsValue::from_str(e.to_string().as_str())) +} + +#[wasm_bindgen(js_name = "importClassMap")] +pub fn import_class_map(sheet_object: JsValue) -> Result<(), JsValue> { + set_class_map( + serde_wasm_bindgen::from_value(sheet_object) + .map_err(|e| JsValue::from_str(e.to_string().as_str()))?, + ); + Ok(()) +} + +#[wasm_bindgen(js_name = "exportClassMap")] +pub fn export_class_map() -> Result { + serde_json::to_string(&get_class_map()).map_err(|e| JsValue::from_str(e.to_string().as_str())) +} + #[wasm_bindgen(js_name = "codeExtract")] pub fn code_extract( filename: &str, @@ -108,121 +135,13 @@ pub fn code_extract( Err(error) => Err(JsValue::from_str(error.to_string().as_str())), } } -pub fn object_to_typography(obj: Object, level: u8) -> Result { - Ok(Typography::new( - Reflect::get(&obj, &JsValue::from_str("fontFamily")) - .as_ref() - .map(js_value_to_string) - .unwrap_or(None), - Reflect::get(&obj, &JsValue::from_str("fontSize")) - .as_ref() - .map(js_value_to_string) - .unwrap_or(None), - Reflect::get(&obj, &JsValue::from_str("fontWeight")) - .as_ref() - .map(js_value_to_string) - .unwrap_or(None), - Reflect::get(&obj, &JsValue::from_str("lineHeight")) - .as_ref() - .map(js_value_to_string) - .unwrap_or(None), - Reflect::get(&obj, &JsValue::from_str("letterSpacing")) - .as_ref() - .map(js_value_to_string) - .unwrap_or(None), - level, - )) -} -pub fn js_value_to_string(js_value: &JsValue) -> Option { - js_value - .as_string() - .or_else(|| js_value.as_f64().map(|v| v.to_string())) -} - -fn theme_object_to_hashmap(js_value: JsValue) -> Result { - let mut theme = Theme::default(); - - if let Ok(obj) = js_value.dyn_into::() { - // get colors - if let Some(colors_obj) = Reflect::get(&obj, &JsValue::from_str("colors")) - .ok() - .and_then(|v| v.dyn_into::().ok()) - { - for entry in Object::entries(&colors_obj).into_iter() { - if let (Ok(key), Ok(value)) = ( - Reflect::get(&entry, &JsValue::from_f64(0f64)), - Reflect::get(&entry, &JsValue::from_f64(1f64)), - ) { - if let (Some(key_str), Some(theme_value)) = - (key.as_string(), value.dyn_into::().ok()) - { - let mut color_theme = ColorTheme::default(); - for var_entry in Object::entries(&theme_value).into_iter() { - if let (Ok(var_key), Ok(var_value)) = ( - Reflect::get(&var_entry, &JsValue::from_f64(0f64)), - Reflect::get(&var_entry, &JsValue::from_f64(1f64)), - ) { - if let (Some(var_key_str), Some(var_value_str)) = - (var_key.as_string(), var_value.as_string()) - { - color_theme.add_color(&var_key_str, &var_value_str); - } else { - return Err(JsValue::from_str( - "Failed to get key and value from the theme object", - )); - } - } - } - theme.colors.add_theme(&key_str, color_theme); - } - } - } - } - - if let Some(typography_obj) = Reflect::get(&obj, &JsValue::from_str("typography")) - .ok() - .and_then(|v| v.dyn_into::().ok()) - { - for entry in Object::entries(&typography_obj).into_iter() { - if let (Ok(key), Ok(value)) = ( - Reflect::get(&entry, &JsValue::from_f64(0f64)), - Reflect::get(&entry, &JsValue::from_f64(1f64)), - ) { - if let (Some(key_str), Some(typo_value)) = - (key.as_string(), value.dyn_into::().ok()) - { - let mut typo_vec = vec![]; - if typo_value.is_array() { - if let Ok(typo_arr) = typo_value.dyn_into::() { - for i in 0..typo_arr.length() { - if let Ok(typo_obj) = typo_arr.get(i).dyn_into::() { - typo_vec.push(object_to_typography(typo_obj, i as u8)?); - } - } - } - } else if typo_value.is_object() && !typo_value.is_null() { - if let Ok(typo_obj) = typo_value.dyn_into::() { - typo_vec.push(object_to_typography(typo_obj, 0)?); - } - } - theme.typography.insert(key_str, typo_vec); - } - } - } - } - } else { - return Err(JsValue::from_str( - "Failed to convert the provided object to a hashmap", - )); - } - Ok(theme) -} #[wasm_bindgen(js_name = "registerTheme")] pub fn register_theme(theme_object: JsValue) -> Result<(), JsValue> { - let theme_object = theme_object_to_hashmap(theme_object)?; - let mut sheet = GLOBAL_STYLE_SHEET.lock().unwrap(); - sheet.set_theme(theme_object); + GLOBAL_STYLE_SHEET.lock().unwrap().set_theme( + serde_wasm_bindgen::from_value(theme_object) + .map_err(|e| JsValue::from_str(e.to_string().as_str()))?, + ); Ok(()) } @@ -243,8 +162,8 @@ pub fn get_theme_interface( let mut color_keys = HashSet::new(); let mut typography_keys = HashSet::new(); let mut theme_keys = HashSet::new(); - for color_theme in sheet.theme.colors.themes.values() { - color_theme.keys().for_each(|key| { + for color_theme in sheet.theme.colors.values() { + color_theme.0.keys().for_each(|key| { color_keys.insert(key.clone()); }); } @@ -252,7 +171,7 @@ pub fn get_theme_interface( typography_keys.insert(key.clone()); }); - sheet.theme.colors.themes.keys().for_each(|key| { + sheet.theme.colors.keys().for_each(|key| { theme_keys.insert(key.clone()); }); @@ -288,7 +207,9 @@ pub fn get_theme_interface( #[cfg(test)] mod tests { use super::*; + use insta::assert_debug_snapshot; use serial_test::serial; + use sheet::theme::{ColorTheme, Theme}; #[test] #[serial] @@ -304,11 +225,11 @@ mod tests { let mut theme = Theme::default(); let mut color_theme = ColorTheme::default(); color_theme.add_color("primary", "#000"); - theme.colors.add_theme("dark", color_theme); + theme.add_color_theme("dark", color_theme); let mut color_theme = ColorTheme::default(); color_theme.add_color("primary", "#FFF"); - theme.colors.add_theme("default", color_theme); + theme.add_color_theme("default", color_theme); sheet.set_theme(theme); } @@ -340,7 +261,7 @@ mod tests { let mut theme = Theme::default(); let mut color_theme = ColorTheme::default(); color_theme.add_color("primary", "#000"); - theme.colors.add_theme("dark", color_theme); + theme.add_color_theme("dark", color_theme); sheet.set_theme(theme); } assert_eq!( @@ -353,4 +274,85 @@ mod tests { "import \"package\";declare module \"package\"{interface ColorInterface{$primary:null;}interface TypographyInterface{}interface ThemeInterface{dark:null;}}" ); } + + #[test] + fn deserialize_theme() { + { + let theme: Theme = serde_json::from_str( + r##"{ + "colors": { + "default": { + "primary": "#000" + }, + "dark": { + "primary": "#fff" + } + }, + "typography": { + "default": [ + { + "fontFamily": "Arial", + "fontSize": "16px", + "fontWeight": 400, + "lineHeight": "1.5", + "letterSpacing": "0.5em" + }, + { + "fontFamily": "Arial", + "fontSize": "24px", + "fontWeight": "400", + "lineHeight": "1.5", + "letterSpacing": "0.5em" + }, + { + "fontFamily": "Arial", + "fontSize": "24px", + "lineHeight": "1.5", + "letterSpacing": "0.5em" + } + ] + } + }"##, + ) + .unwrap(); + assert_eq!(theme.break_points, vec![0, 480, 768, 992, 1280]); + assert_debug_snapshot!(theme.to_css()); + } + { + let theme: Theme = serde_json::from_str( + r##"{ + "colors": { + "default": { + "primary": "#000" + }, + "dark": { + "primary": "#fff" + } + }, + "typography": { + "default": + { + "fontFamily": "Arial", + "fontSize": "16px", + "fontWeight": "400", + "lineHeight": "1.5", + "letterSpacing": "0.5em" + } + } + }"##, + ) + .unwrap(); + assert_debug_snapshot!(theme); + } + + { + let theme: Theme = serde_json::from_str( + r##"{ +"typography":{"noticeButton":{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":500,"fontSize":"16px","lineHeight":1.2,"letterSpacing":"-0.02em"},"button":[{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":500,"fontSize":"16px","lineHeight":1.2,"letterSpacing":"-0.02em"},null,null,null,{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":500,"fontSize":"18px","lineHeight":1.2,"letterSpacing":"-0.02em"}],"title":[{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":700,"fontSize":"16px","lineHeight":1.2,"letterSpacing":"-0.01em"},null,null,null,{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":700,"fontSize":"20px","lineHeight":1.2,"letterSpacing":"-0.01em"}],"text":[{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":400,"fontSize":"15px","lineHeight":1.2,"letterSpacing":"-0.01em"},null,null,null,{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":400,"fontSize":"16px","lineHeight":1.2,"letterSpacing":"-0.01em"}],"caption":[{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":600,"fontSize":"12px","lineHeight":1.2,"letterSpacing":"-0.01em"},null,null,null,{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":600,"fontSize":"14px","lineHeight":1.2,"letterSpacing":"-0.01em"}],"noticeTitle":[{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":600,"fontSize":"15px","lineHeight":1.2,"letterSpacing":"-0.02em"},null,null,null,{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":600,"fontSize":"18px","lineHeight":1.2,"letterSpacing":"-0.02em"}],"noticeText":[{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":400,"fontSize":"14px","lineHeight":1.5,"letterSpacing":"-0.02em"},null,null,null,{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":400,"fontSize":"16px","lineHeight":1.5,"letterSpacing":"-0.02em"}],"h3":[{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":600,"fontSize":"18px","lineHeight":1.2,"letterSpacing":"-0.02em"},null,null,null,{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":600,"fontSize":"24px","lineHeight":1.2,"letterSpacing":"-0.02em"}],"h1":[{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":700,"fontSize":"28px","lineHeight":1.2,"letterSpacing":"-0.02em"},null,null,null,{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":700,"fontSize":"36px","lineHeight":1.2,"letterSpacing":"-0.02em"}],"body":[{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":400,"fontSize":"16px","lineHeight":1.2,"letterSpacing":"-0.02em"},null,null,null,{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":400,"fontSize":"20px","lineHeight":1.2,"letterSpacing":"-0.02em"}],"noticeBold":[{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":700,"fontSize":"14px","lineHeight":1.2,"letterSpacing":"-0.01em"},null,null,null,{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":700,"fontSize":"18px","lineHeight":1.2,"letterSpacing":"-0.01em"}],"notice":[{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":400,"fontSize":"13px","lineHeight":1.2,"letterSpacing":"-0.02em"},null,null,null,{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":400,"fontSize":"18px","lineHeight":1.2,"letterSpacing":"-0.02em"}],"h2":[{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":700,"fontSize":"20px","lineHeight":1.2,"letterSpacing":"-0.01em"},null,null,null,{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":700,"fontSize":"28px","lineHeight":1.2,"letterSpacing":"-0.01em"}],"result":[{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":700,"fontSize":"24px","lineHeight":1.2,"letterSpacing":"-0.02em"},null,null,null,{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":700,"fontSize":"32px","lineHeight":1.2,"letterSpacing":"-0.02em"}],"resultPoint":[{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":800,"fontSize":"24px","lineHeight":1.4,"letterSpacing":"-0.01em"},null,null,null,{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":800,"fontSize":"28px","lineHeight":1.4,"letterSpacing":"-0.01em"}],"resultText":[{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":600,"fontSize":"18px","lineHeight":1.4,"letterSpacing":"-0.01em"},null,null,null,{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":600,"fontSize":"22px","lineHeight":1.4,"letterSpacing":"-0.01em"}],"resultList":[{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":500,"fontSize":"16px","lineHeight":1.4,"letterSpacing":"-0.01em"},null,null,null,{"fontFamily":"Pretendard","fontStyle":"normal","fontWeight":500,"fontSize":"20px","lineHeight":1.4,"letterSpacing":"-0.01em"}]} + }"##, + ) + .unwrap(); + assert_debug_snapshot!(theme); + } + } } diff --git a/bindings/devup-ui-wasm/src/snapshots/devup_ui_wasm__tests__deserialize_theme-2.snap b/bindings/devup-ui-wasm/src/snapshots/devup_ui_wasm__tests__deserialize_theme-2.snap new file mode 100644 index 00000000..9bca570c --- /dev/null +++ b/bindings/devup-ui-wasm/src/snapshots/devup_ui_wasm__tests__deserialize_theme-2.snap @@ -0,0 +1,50 @@ +--- +source: bindings/devup-ui-wasm/src/lib.rs +expression: theme +--- +Theme { + colors: { + "dark": ColorTheme( + { + "primary": "#fff", + }, + ), + "default": ColorTheme( + { + "primary": "#000", + }, + ), + }, + break_points: [ + 0, + 480, + 768, + 992, + 1280, + ], + typography: { + "default": Typographies( + [ + Some( + Typography { + font_family: Some( + "Arial", + ), + font_size: Some( + "16px", + ), + font_weight: Some( + "400", + ), + line_height: Some( + "1.5", + ), + letter_spacing: Some( + "0.5em", + ), + }, + ), + ], + ), + }, +} diff --git a/bindings/devup-ui-wasm/src/snapshots/devup_ui_wasm__tests__deserialize_theme-3.snap b/bindings/devup-ui-wasm/src/snapshots/devup_ui_wasm__tests__deserialize_theme-3.snap new file mode 100644 index 00000000..8f09df48 --- /dev/null +++ b/bindings/devup-ui-wasm/src/snapshots/devup_ui_wasm__tests__deserialize_theme-3.snap @@ -0,0 +1,759 @@ +--- +source: bindings/devup-ui-wasm/src/lib.rs +expression: theme +--- +Theme { + colors: {}, + break_points: [ + 0, + 480, + 768, + 992, + 1280, + ], + typography: { + "body": Typographies( + [ + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "16px", + ), + font_weight: Some( + "400", + ), + line_height: Some( + "1.2", + ), + letter_spacing: Some( + "-0.02em", + ), + }, + ), + None, + None, + None, + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "20px", + ), + font_weight: Some( + "400", + ), + line_height: Some( + "1.2", + ), + letter_spacing: Some( + "-0.02em", + ), + }, + ), + ], + ), + "button": Typographies( + [ + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "16px", + ), + font_weight: Some( + "500", + ), + line_height: Some( + "1.2", + ), + letter_spacing: Some( + "-0.02em", + ), + }, + ), + None, + None, + None, + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "18px", + ), + font_weight: Some( + "500", + ), + line_height: Some( + "1.2", + ), + letter_spacing: Some( + "-0.02em", + ), + }, + ), + ], + ), + "caption": Typographies( + [ + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "12px", + ), + font_weight: Some( + "600", + ), + line_height: Some( + "1.2", + ), + letter_spacing: Some( + "-0.01em", + ), + }, + ), + None, + None, + None, + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "14px", + ), + font_weight: Some( + "600", + ), + line_height: Some( + "1.2", + ), + letter_spacing: Some( + "-0.01em", + ), + }, + ), + ], + ), + "h1": Typographies( + [ + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "28px", + ), + font_weight: Some( + "700", + ), + line_height: Some( + "1.2", + ), + letter_spacing: Some( + "-0.02em", + ), + }, + ), + None, + None, + None, + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "36px", + ), + font_weight: Some( + "700", + ), + line_height: Some( + "1.2", + ), + letter_spacing: Some( + "-0.02em", + ), + }, + ), + ], + ), + "h2": Typographies( + [ + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "20px", + ), + font_weight: Some( + "700", + ), + line_height: Some( + "1.2", + ), + letter_spacing: Some( + "-0.01em", + ), + }, + ), + None, + None, + None, + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "28px", + ), + font_weight: Some( + "700", + ), + line_height: Some( + "1.2", + ), + letter_spacing: Some( + "-0.01em", + ), + }, + ), + ], + ), + "h3": Typographies( + [ + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "18px", + ), + font_weight: Some( + "600", + ), + line_height: Some( + "1.2", + ), + letter_spacing: Some( + "-0.02em", + ), + }, + ), + None, + None, + None, + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "24px", + ), + font_weight: Some( + "600", + ), + line_height: Some( + "1.2", + ), + letter_spacing: Some( + "-0.02em", + ), + }, + ), + ], + ), + "notice": Typographies( + [ + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "13px", + ), + font_weight: Some( + "400", + ), + line_height: Some( + "1.2", + ), + letter_spacing: Some( + "-0.02em", + ), + }, + ), + None, + None, + None, + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "18px", + ), + font_weight: Some( + "400", + ), + line_height: Some( + "1.2", + ), + letter_spacing: Some( + "-0.02em", + ), + }, + ), + ], + ), + "noticeBold": Typographies( + [ + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "14px", + ), + font_weight: Some( + "700", + ), + line_height: Some( + "1.2", + ), + letter_spacing: Some( + "-0.01em", + ), + }, + ), + None, + None, + None, + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "18px", + ), + font_weight: Some( + "700", + ), + line_height: Some( + "1.2", + ), + letter_spacing: Some( + "-0.01em", + ), + }, + ), + ], + ), + "noticeButton": Typographies( + [ + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "16px", + ), + font_weight: Some( + "500", + ), + line_height: Some( + "1.2", + ), + letter_spacing: Some( + "-0.02em", + ), + }, + ), + ], + ), + "noticeText": Typographies( + [ + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "14px", + ), + font_weight: Some( + "400", + ), + line_height: Some( + "1.5", + ), + letter_spacing: Some( + "-0.02em", + ), + }, + ), + None, + None, + None, + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "16px", + ), + font_weight: Some( + "400", + ), + line_height: Some( + "1.5", + ), + letter_spacing: Some( + "-0.02em", + ), + }, + ), + ], + ), + "noticeTitle": Typographies( + [ + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "15px", + ), + font_weight: Some( + "600", + ), + line_height: Some( + "1.2", + ), + letter_spacing: Some( + "-0.02em", + ), + }, + ), + None, + None, + None, + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "18px", + ), + font_weight: Some( + "600", + ), + line_height: Some( + "1.2", + ), + letter_spacing: Some( + "-0.02em", + ), + }, + ), + ], + ), + "result": Typographies( + [ + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "24px", + ), + font_weight: Some( + "700", + ), + line_height: Some( + "1.2", + ), + letter_spacing: Some( + "-0.02em", + ), + }, + ), + None, + None, + None, + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "32px", + ), + font_weight: Some( + "700", + ), + line_height: Some( + "1.2", + ), + letter_spacing: Some( + "-0.02em", + ), + }, + ), + ], + ), + "resultList": Typographies( + [ + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "16px", + ), + font_weight: Some( + "500", + ), + line_height: Some( + "1.4", + ), + letter_spacing: Some( + "-0.01em", + ), + }, + ), + None, + None, + None, + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "20px", + ), + font_weight: Some( + "500", + ), + line_height: Some( + "1.4", + ), + letter_spacing: Some( + "-0.01em", + ), + }, + ), + ], + ), + "resultPoint": Typographies( + [ + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "24px", + ), + font_weight: Some( + "800", + ), + line_height: Some( + "1.4", + ), + letter_spacing: Some( + "-0.01em", + ), + }, + ), + None, + None, + None, + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "28px", + ), + font_weight: Some( + "800", + ), + line_height: Some( + "1.4", + ), + letter_spacing: Some( + "-0.01em", + ), + }, + ), + ], + ), + "resultText": Typographies( + [ + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "18px", + ), + font_weight: Some( + "600", + ), + line_height: Some( + "1.4", + ), + letter_spacing: Some( + "-0.01em", + ), + }, + ), + None, + None, + None, + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "22px", + ), + font_weight: Some( + "600", + ), + line_height: Some( + "1.4", + ), + letter_spacing: Some( + "-0.01em", + ), + }, + ), + ], + ), + "text": Typographies( + [ + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "15px", + ), + font_weight: Some( + "400", + ), + line_height: Some( + "1.2", + ), + letter_spacing: Some( + "-0.01em", + ), + }, + ), + None, + None, + None, + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "16px", + ), + font_weight: Some( + "400", + ), + line_height: Some( + "1.2", + ), + letter_spacing: Some( + "-0.01em", + ), + }, + ), + ], + ), + "title": Typographies( + [ + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "16px", + ), + font_weight: Some( + "700", + ), + line_height: Some( + "1.2", + ), + letter_spacing: Some( + "-0.01em", + ), + }, + ), + None, + None, + None, + Some( + Typography { + font_family: Some( + "Pretendard", + ), + font_size: Some( + "20px", + ), + font_weight: Some( + "700", + ), + line_height: Some( + "1.2", + ), + letter_spacing: Some( + "-0.01em", + ), + }, + ), + ], + ), + }, +} diff --git a/bindings/devup-ui-wasm/src/snapshots/devup_ui_wasm__tests__deserialize_theme.snap b/bindings/devup-ui-wasm/src/snapshots/devup_ui_wasm__tests__deserialize_theme.snap new file mode 100644 index 00000000..2f9fdfdb --- /dev/null +++ b/bindings/devup-ui-wasm/src/snapshots/devup_ui_wasm__tests__deserialize_theme.snap @@ -0,0 +1,5 @@ +--- +source: bindings/devup-ui-wasm/src/lib.rs +expression: theme.to_css() +--- +":root{--primary:#000;}\n:root[data-theme=dark]{--primary:#fff;}\n.typo-default{font-family:Arial;font-size:16px;font-weight:400;line-height:1.5;letter-spacing:0.5em}\n@media (min-width:480px){.typo-default{font-family:Arial;font-size:24px;font-weight:400;line-height:1.5;letter-spacing:0.5em}}\n@media (min-width:768px){.typo-default{font-family:Arial;font-size:24px;line-height:1.5;letter-spacing:0.5em}}" diff --git a/bindings/devup-ui-wasm/tests/wasm.rs b/bindings/devup-ui-wasm/tests/wasm.rs index aacebfca..2b401d87 100644 --- a/bindings/devup-ui-wasm/tests/wasm.rs +++ b/bindings/devup-ui-wasm/tests/wasm.rs @@ -1,5 +1,5 @@ -use devup_ui_wasm::object_to_typography; use js_sys::{Object, Reflect}; +use sheet::theme::Typography; use wasm_bindgen::JsValue; use wasm_bindgen_test::*; @@ -37,11 +37,10 @@ fn test_object_to_typography() { &JsValue::from_str("1px"), ) .unwrap(); - let typography = object_to_typography(obj, 0).unwrap(); + let typography: Typography = serde_wasm_bindgen::from_value(JsValue::from(obj)).unwrap(); assert_eq!(typography.font_family.unwrap(), "Arial"); assert_eq!(typography.font_size.unwrap(), "12px"); assert_eq!(typography.font_weight.unwrap(), "bold"); assert_eq!(typography.line_height.unwrap(), "1.5"); assert_eq!(typography.letter_spacing.unwrap(), "1px"); - assert_eq!(typography.level, 0); } diff --git a/libs/css/Cargo.toml b/libs/css/Cargo.toml index c6008976..2b23bb12 100644 --- a/libs/css/Cargo.toml +++ b/libs/css/Cargo.toml @@ -6,3 +6,4 @@ edition = "2021" [dependencies] once_cell = "1.20.2" serial_test = "3.2.0" +serde = { version = "1.0.217", features = ["derive"] } diff --git a/libs/css/src/lib.rs b/libs/css/src/lib.rs index 04387686..663550c4 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -1,5 +1,6 @@ use crate::StyleSelector::{Dual, Postfix, Prefix}; use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::fmt; use std::fmt::{Display, Formatter}; @@ -15,7 +16,7 @@ static SELECTOR_ORDER_MAP: Lazy> = Lazy::new(|| { map }); -#[derive(Debug, PartialEq, Clone, Hash, Eq)] +#[derive(Debug, PartialEq, Clone, Hash, Eq, Serialize, Deserialize)] pub enum StyleSelector { Postfix(String), Prefix(String), @@ -219,6 +220,15 @@ pub fn reset_class_map() { map.clear(); } +pub fn set_class_map(map: HashMap) { + let mut global_map = GLOBAL_CLASS_MAP.lock().unwrap(); + *global_map = map; +} + +pub fn get_class_map() -> HashMap { + GLOBAL_CLASS_MAP.lock().unwrap().clone() +} + pub fn to_kebab_case(value: &str) -> String { value .chars() diff --git a/libs/extractor/Cargo.toml b/libs/extractor/Cargo.toml index 4652bf4d..8d8a245c 100644 --- a/libs/extractor/Cargo.toml +++ b/libs/extractor/Cargo.toml @@ -4,15 +4,15 @@ version = "0.1.0" edition = "2021" [dependencies] -oxc_parser = "0.47.1" -oxc_syntax = "0.47.1" -oxc_span = "0.47.1" -oxc_allocator = "0.47.1" -oxc_ast = "0.47.1" -oxc_codegen = "0.47.1" +oxc_parser = "0.48.1" +oxc_syntax = "0.48.1" +oxc_span = "0.48.1" +oxc_allocator = "0.48.1" +oxc_ast = "0.48.1" +oxc_codegen = "0.48.1" css = { path = "../css" } once_cell = "1.20.2" [dev-dependencies] -insta = "1.42.0" +insta = "1.42.1" serial_test = "3.2.0" \ No newline at end of file diff --git a/libs/sheet/Cargo.toml b/libs/sheet/Cargo.toml index f0673f57..58e79dac 100644 --- a/libs/sheet/Cargo.toml +++ b/libs/sheet/Cargo.toml @@ -5,6 +5,8 @@ edition = "2021" [dependencies] css = { path = "../css" } +serde = { version = "1.0.217", features = ["derive"] } [dev-dependencies] -insta = "1.42.0" +insta = "1.42.1" +serde_json = "1.0.138" diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index ca88ef6d..56d9e646 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -2,6 +2,8 @@ pub mod theme; use crate::theme::Theme; use css::{convert_property, merge_selector, PropertyType, StyleSelector}; +use serde::de::Error; +use serde::{Deserialize, Deserializer, Serialize}; use std::cmp::Ordering::{Greater, Less}; use std::collections::{BTreeMap, HashSet}; @@ -9,7 +11,7 @@ trait ExtractStyle { fn extract(&self) -> String; } -#[derive(Debug, Hash, Eq, PartialEq)] +#[derive(Debug, Hash, Eq, PartialEq, Deserialize, Serialize)] pub struct StyleSheetProperty { pub class_name: String, pub property: String, @@ -50,7 +52,7 @@ fn convert_theme_variable_value(value: &String) -> String { } } -#[derive(Debug, Hash, Eq, PartialEq)] +#[derive(Debug, Hash, Eq, PartialEq, Deserialize, Serialize)] pub struct StyleSheetCss { pub class_name: String, pub css: String, @@ -62,13 +64,30 @@ impl ExtractStyle for StyleSheetCss { } } -#[derive(Default)] +fn deserialize_btree_map_u8<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let map: BTreeMap> = + Deserialize::deserialize(deserializer)?; + let mut result = BTreeMap::new(); + + for (key, value) in map { + let key: u8 = key.parse().map_err(Error::custom)?; + result.insert(key, value); + } + + Ok(result) +} +#[derive(Default, Deserialize, Serialize, Debug)] pub struct StyleSheet { - /// level -> properties + #[serde(deserialize_with = "deserialize_btree_map_u8")] pub properties: BTreeMap>, pub css: HashSet, + #[serde(skip)] pub theme: Theme, - theme_declaration: String, } impl StyleSheet { @@ -81,33 +100,31 @@ impl StyleSheet { selector: Option<&StyleSelector>, basic: bool, ) -> bool { - let prop = StyleSheetProperty { - class_name: class_name.to_string(), - property: property.to_string(), - value: value.to_string(), - selector: selector.cloned(), - basic, - }; - self.properties.entry(level).or_default().insert(prop) + self.properties + .entry(level) + .or_default() + .insert(StyleSheetProperty { + class_name: class_name.to_string(), + property: property.to_string(), + value: value.to_string(), + selector: selector.cloned(), + basic, + }) } pub fn add_css(&mut self, class_name: &str, css: &str) -> bool { - let prop = StyleSheetCss { + self.css.insert(StyleSheetCss { class_name: class_name.to_string(), css: css.to_string(), - }; - self.css.insert(prop) + }) } pub fn set_theme(&mut self, theme: Theme) { - let mut theme_declaration = String::new(); - theme_declaration.push_str(theme.to_css().as_str()); self.theme = theme; - self.theme_declaration = theme_declaration; } pub fn create_css(&self) -> String { - let mut css = self.theme_declaration.clone(); + let mut css = self.theme.to_css(); for (level, props) in self.properties.iter() { let mut sorted_props = props.iter().collect::>(); sorted_props.sort_by(|a, b| { @@ -231,11 +248,6 @@ mod tests { sheet.add_property("test", "background-color", 1, "red", None, false); sheet.add_property("test", "background", 1, "some", None, false); assert_debug_snapshot!(sheet.create_css()); - // - // let mut sheet = StyleSheet::default(); - // sheet.add_property("test", "background-color", 1, "red", Some(&StyleSelector::Postfix("hover".to_string())), false); - // sheet.add_property("test", "background", 1, "some", Some(&StyleSelector::Postfix("focus".to_string())), false); - // assert_debug_snapshot!(sheet.create_css()); } #[test] fn test_create_css_with_basic_sort_test() { @@ -327,4 +339,74 @@ mod tests { sheet.add_property("test", "mx", 0, "42px", None, false); assert_debug_snapshot!(sheet.create_css()); } + + #[test] + fn test_deserialize() { + { + let sheet: StyleSheet = serde_json::from_str( + r##"{ + "properties": { + "0": [ + { + "class_name": "test", + "property": "mx", + "value": "40px", + "selector": null, + "basic": false + } + ] + }, + "css": [], + "theme": { + "breakPoints": [ + 640, + 768, + 1024, + 1280 + ], + "colors": { + "black": "#000", + "white": "#fff" + }, + "typography": {} + } + }"##, + ) + .unwrap(); + assert_debug_snapshot!(sheet); + } + + { + let sheet: Result = serde_json::from_str( + r##"{ + "properties": { + "wrong": [ + { + "class_name": "test", + "property": "mx", + "value": "40px", + "selector": null, + "basic": false + } + ] + }, + "css": [], + "theme": { + "breakPoints": [ + 640, + 768, + 1024, + 1280 + ], + "colors": { + "black": "#000", + "white": "#fff" + }, + "typography": {} + } + }"##, + ); + assert!(sheet.is_err()); + } + } } diff --git a/libs/sheet/src/snapshots/sheet__tests__deserialize.snap b/libs/sheet/src/snapshots/sheet__tests__deserialize.snap new file mode 100644 index 00000000..8fd58812 --- /dev/null +++ b/libs/sheet/src/snapshots/sheet__tests__deserialize.snap @@ -0,0 +1,29 @@ +--- +source: libs/sheet/src/lib.rs +expression: sheet +--- +StyleSheet { + properties: { + 0: { + StyleSheetProperty { + class_name: "test", + property: "mx", + value: "40px", + selector: None, + basic: false, + }, + }, + }, + css: {}, + theme: Theme { + colors: {}, + break_points: [ + 0, + 480, + 768, + 992, + 1280, + ], + typography: {}, + }, +} diff --git a/libs/sheet/src/theme.rs b/libs/sheet/src/theme.rs index 481d2d14..a97190f5 100644 --- a/libs/sheet/src/theme.rs +++ b/libs/sheet/src/theme.rs @@ -1,75 +1,45 @@ +use serde::{Deserialize, Deserializer, Serialize}; use std::collections::{BTreeMap, HashMap}; -#[derive(Default)] -pub struct ColorTheme { - data: HashMap, -} +#[derive(Default, Serialize, Deserialize, Debug)] +pub struct ColorTheme(pub HashMap); impl ColorTheme { pub fn add_color(&mut self, name: &str, value: &str) { - self.data.insert(name.to_string(), value.to_string()); - } - - pub fn iter(&self) -> impl Iterator { - self.data.iter() + self.0.insert(name.to_string(), value.to_string()); } - pub fn keys(&self) -> impl Iterator { - self.data.keys() - } -} - -#[derive(Default)] -pub struct Color { - pub themes: HashMap, } -impl Color { - pub fn add_theme(&mut self, name: &str, theme: ColorTheme) { - self.themes.insert(name.to_string(), theme); +pub fn deserialize_string_from_number<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrNumber { + String(String), + Number(i64), + Float(f64), } - pub fn to_css(&self) -> String { - let mut theme_declaration = String::new(); - let default_theme_key = self - .themes - .keys() - .find(|k| *k == "default") - .map(Some) - .unwrap_or_else(|| self.themes.keys().next()); - if let Some(default_theme_key) = default_theme_key { - let mut entries: Vec<_> = self.themes.iter().collect(); - entries.sort_by_key(|(k, _)| *k); - entries.reverse(); - for (theme_name, theme_properties) in entries { - let theme_key = if *theme_name == *default_theme_key { - None - } else { - Some(theme_name) - }; - if let Some(theme_key) = theme_key { - theme_declaration - .push_str(format!(":root[data-theme={}]{{", theme_key).as_str()); - } else { - theme_declaration.push_str(":root{"); - } - for (prop, value) in theme_properties.iter() { - theme_declaration.push_str(format!("--{}:{};", prop, value).as_str()); - } - theme_declaration.push_str("}\n"); - } - } - theme_declaration + match StringOrNumber::deserialize(deserializer)? { + StringOrNumber::String(s) => Ok(Some(s)), + StringOrNumber::Number(n) => Ok(Some(n.to_string())), + StringOrNumber::Float(n) => Ok(Some(n.to_string())), } } +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] pub struct Typography { pub font_family: Option, pub font_size: Option, + + #[serde(deserialize_with = "deserialize_string_from_number", default)] pub font_weight: Option, + #[serde(deserialize_with = "deserialize_string_from_number", default)] pub line_height: Option, pub letter_spacing: Option, - pub level: u8, } - impl Typography { pub fn new( font_family: Option, @@ -77,7 +47,6 @@ impl Typography { font_weight: Option, line_height: Option, letter_spacing: Option, - level: u8, ) -> Self { Self { font_family, @@ -85,21 +54,54 @@ impl Typography { font_weight, line_height, letter_spacing, - level, } } } +#[derive(Serialize, Debug)] +pub struct Typographies(Vec>); + +impl From>> for Typographies { + fn from(v: Vec>) -> Self { + Self(v) + } +} +impl<'de> Deserialize<'de> for Typographies { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum ArrayOrSingle { + Array(Vec>), + Single(Typography), + } + match ArrayOrSingle::deserialize(deserializer)? { + ArrayOrSingle::Array(v) => Ok(Self(v)), + ArrayOrSingle::Single(v) => Ok(Self(vec![Some(v)])), + } + } +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] pub struct Theme { - pub colors: Color, + #[serde(default)] + pub colors: BTreeMap, + #[serde(default = "default_break_points")] pub break_points: Vec, - pub typography: BTreeMap>, + pub typography: BTreeMap, +} + +fn default_break_points() -> Vec { + vec![0, 480, 768, 992, 1280] } impl Default for Theme { fn default() -> Self { Self { - colors: Color::default(), + colors: Default::default(), break_points: vec![0, 480, 768, 992, 1280], typography: BTreeMap::new(), } @@ -119,51 +121,82 @@ impl Theme { } pub fn add_color_theme(&mut self, name: &str, theme: ColorTheme) { - self.colors.add_theme(name, theme); + self.colors.insert(name.to_string(), theme); } - pub fn add_typography(&mut self, name: &str, typography: Vec) { - self.typography.insert(name.to_string(), typography); + pub fn add_typography(&mut self, name: &str, typography: Vec>) { + self.typography.insert(name.to_string(), typography.into()); } pub fn to_css(&self) -> String { - let mut css = self.colors.to_css(); + let mut theme_declaration = String::new(); + let default_theme_key = self + .colors + .keys() + .find(|k| *k == "default") + .map(Some) + .unwrap_or_else(|| self.colors.keys().next()); + if let Some(default_theme_key) = default_theme_key { + let mut entries: Vec<_> = self.colors.iter().collect(); + entries.sort_by_key(|(k, _)| *k); + entries.reverse(); + for (theme_name, theme_properties) in entries { + let theme_key = if *theme_name == *default_theme_key { + None + } else { + Some(theme_name) + }; + if let Some(theme_key) = theme_key { + theme_declaration + .push_str(format!(":root[data-theme={}]{{", theme_key).as_str()); + } else { + theme_declaration.push_str(":root{"); + } + for (prop, value) in theme_properties.0.iter() { + theme_declaration.push_str(format!("--{}:{};", prop, value).as_str()); + } + theme_declaration.push_str("}\n"); + } + } + let mut css = theme_declaration; let mut level_map = BTreeMap::>::new(); for ty in self.typography.iter() { - for t in ty.1.iter() { - let css_content = format!( - "{}{}{}{}{}", - t.font_family - .clone() - .map(|v| format!("font-family:{};", v)) - .unwrap_or("".to_string()), - t.font_size - .clone() - .map(|v| format!("font-size:{};", v)) - .unwrap_or("".to_string()), - t.font_weight - .clone() - .map(|v| format!("font-weight:{};", v)) - .unwrap_or("".to_string()), - t.line_height - .clone() - .map(|v| format!("line-height:{};", v)) - .unwrap_or("".to_string()), - t.letter_spacing - .clone() - .map(|v| format!("letter-spacing:{}", v)) - .unwrap_or("".to_string()) - ); - if css_content.is_empty() { - continue; + for (idx, t) in ty.1 .0.iter().enumerate() { + if let Some(t) = t { + let css_content = format!( + "{}{}{}{}{}", + t.font_family + .clone() + .map(|v| format!("font-family:{};", v)) + .unwrap_or("".to_string()), + t.font_size + .clone() + .map(|v| format!("font-size:{};", v)) + .unwrap_or("".to_string()), + t.font_weight + .clone() + .map(|v| format!("font-weight:{};", v)) + .unwrap_or("".to_string()), + t.line_height + .clone() + .map(|v| format!("line-height:{};", v)) + .unwrap_or("".to_string()), + t.letter_spacing + .clone() + .map(|v| format!("letter-spacing:{}", v)) + .unwrap_or("".to_string()) + ); + if css_content.is_empty() { + continue; + } + let typo_css = format!(".typo-{}{{{}}}", ty.0, css_content); + level_map + .get_mut(&(idx as u8)) + .map(|v| v.push(typo_css.clone())) + .unwrap_or_else(|| { + level_map.insert(idx as u8, vec![typo_css.clone()]); + }); } - let typo_css = format!(".typo-{}{{{}}}", ty.0, css_content); - level_map - .get_mut(&t.level) - .map(|v| v.push(typo_css.clone())) - .unwrap_or_else(|| { - level_map.insert(t.level, vec![typo_css.clone()]); - }); } } for (level, css_vec) in level_map { @@ -193,7 +226,7 @@ mod tests { let mut color_theme = ColorTheme::default(); color_theme.add_color("primary", "#000"); - assert_eq!(color_theme.keys().count(), 1); + assert_eq!(color_theme.0.keys().count(), 1); theme.add_color_theme("default", color_theme); let mut color_theme = ColorTheme::default(); @@ -202,35 +235,35 @@ mod tests { theme.add_typography( "default", vec![ - Typography::new( + Some(Typography::new( Some("Arial".to_string()), Some("16px".to_string()), Some("400".to_string()), Some("1.5".to_string()), Some("0.5".to_string()), - 0, - ), - Typography::new( + )), + Some(Typography::new( Some("Arial".to_string()), Some("24px".to_string()), Some("400".to_string()), Some("1.5".to_string()), Some("0.5".to_string()), - 1, - ), + )), ], ); theme.add_typography( "default1", - vec![Typography::new( - Some("Arial".to_string()), - Some("24px".to_string()), - Some("400".to_string()), - Some("1.5".to_string()), - Some("0.5".to_string()), - 1, - )], + vec![ + None, + Some(Typography::new( + Some("Arial".to_string()), + Some("24px".to_string()), + Some("400".to_string()), + Some("1.5".to_string()), + Some("0.5".to_string()), + )), + ], ); let css = theme.to_css(); assert_eq!( @@ -242,7 +275,7 @@ mod tests { let mut theme = Theme::default(); theme.add_typography( "default", - vec![Typography::new(None, None, None, None, None, 0)], + vec![Some(Typography::new(None, None, None, None, None))], ); assert_eq!(theme.to_css(), ""); } diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index 4b0fee0f..a5e90a46 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -1,49 +1,128 @@ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' + import { DevupUIWebpackPlugin } from '@devup-ui/webpack-plugin' import { DevupUI } from '../plugin' vi.mock('@devup-ui/webpack-plugin') +vi.mock('node:fs') describe('plugin', () => { - it('should apply webpack plugin', async () => { - const ret = DevupUI({}) + describe('webpack', () => { + it('should apply webpack plugin', async () => { + const ret = DevupUI({}) - ret.webpack!({ plugins: [] }, {} as any) + ret.webpack!({ plugins: [] }, {} as any) - expect(DevupUIWebpackPlugin).toHaveBeenCalledWith({}) - }) + expect(DevupUIWebpackPlugin).toHaveBeenCalledWith({}) + }) - it('should apply webpack plugin with config', async () => { - const ret = DevupUI( - {}, - { - package: 'new-package', - }, - ) + it('should apply webpack plugin with config', async () => { + const ret = DevupUI( + {}, + { + package: 'new-package', + }, + ) - ret.webpack!({ plugins: [] }, {} as any) + ret.webpack!({ plugins: [] }, {} as any) - expect(DevupUIWebpackPlugin).toHaveBeenCalledWith({ - package: 'new-package', + expect(DevupUIWebpackPlugin).toHaveBeenCalledWith({ + package: 'new-package', + }) }) - }) - it('should apply webpack plugin with webpack obj', async () => { - const webpack = vi.fn() - const ret = DevupUI( - { - webpack, - }, - { + it('should apply webpack plugin with webpack obj', async () => { + const webpack = vi.fn() + const ret = DevupUI( + { + webpack, + }, + { + package: 'new-package', + }, + ) + + ret.webpack!({ plugins: [] }, {} as any) + + expect(DevupUIWebpackPlugin).toHaveBeenCalledWith({ package: 'new-package', - }, - ) + }) + expect(webpack).toHaveBeenCalled() + }) + }) + describe('turbo', () => { + it('should apply turbo config', async () => { + vi.stubEnv('TURBOPACK', '1') + vi.mocked(existsSync).mockReturnValue(true) + const ret = DevupUI({}) - ret.webpack!({ plugins: [] }, {} as any) + expect(ret.experimental).toEqual({ + turbo: { + rules: { + 'devup-ui.css': [ + { + loader: '@devup-ui/webpack-plugin/css-loader', + options: { + watch: false, + }, + }, + ], + '*.{tsx,ts,js,mjs}': [ + { + loader: '@devup-ui/webpack-plugin/loader', + options: { + package: '@devup-ui/react', + cssFile: resolve('.df', 'devup-ui.css'), + sheetFile: join('.df', 'sheet.json'), + classMapFile: join('.df', 'classMap.json'), + watch: false, + }, + }, + ], + }, + }, + }) + }) + it('should apply turbo config with create .df', async () => { + vi.stubEnv('TURBOPACK', '1') + vi.mocked(existsSync).mockReturnValue(false) + vi.mocked(mkdirSync).mockReturnValue('') + vi.mocked(writeFileSync).mockReturnValue() + const ret = DevupUI({}) - expect(DevupUIWebpackPlugin).toHaveBeenCalledWith({ - package: 'new-package', + expect(ret.experimental).toEqual({ + turbo: { + rules: { + 'devup-ui.css': [ + { + loader: '@devup-ui/webpack-plugin/css-loader', + options: { + watch: false, + }, + }, + ], + '*.{tsx,ts,js,mjs}': [ + { + loader: '@devup-ui/webpack-plugin/loader', + options: { + package: '@devup-ui/react', + cssFile: resolve('.df', 'devup-ui.css'), + sheetFile: join('.df', 'sheet.json'), + classMapFile: join('.df', 'classMap.json'), + watch: false, + }, + }, + ], + }, + }, + }) + expect(mkdirSync).toHaveBeenCalledWith('.df') + expect(writeFileSync).toHaveBeenCalledWith( + resolve('.df', 'devup-ui.css'), + '/* devup-ui */', + ) }) - expect(webpack).toHaveBeenCalled() }) }) diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index 8df7deda..52899147 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -1,10 +1,16 @@ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { basename, join, resolve } from 'node:path' + import { DevupUIWebpackPlugin, type DevupUIWebpackPluginOptions, } from '@devup-ui/webpack-plugin' import { type NextConfig } from 'next' -type DevupUiNextPluginOptions = Partial +type DevupUiNextPluginOptions = Omit< + Partial, + 'watch' +> /** * Devup UI Next Plugin @@ -16,9 +22,55 @@ export function DevupUI( config: NextConfig, options: DevupUiNextPluginOptions = {}, ): NextConfig { + const isTurbo = process.env.TURBOPACK === '1' + if (isTurbo) { + config.experimental ??= {} + config.experimental.turbo ??= {} + config.experimental.turbo.rules ??= {} + const { + package: libPackage = '@devup-ui/react', + interfacePath = '.df', + cssFile = resolve(interfacePath, 'devup-ui.css'), + } = options + + const sheetFile = join(interfacePath, 'sheet.json') + const classMapFile = join(interfacePath, 'classMap.json') + if (!existsSync(interfacePath)) mkdirSync(interfacePath) + if (!existsSync(cssFile)) writeFileSync(cssFile, '/* devup-ui */') + const rules: NonNullable = { + [basename(cssFile)]: [ + { + loader: '@devup-ui/webpack-plugin/css-loader', + options: { + watch: process.env.NODE_ENV === 'development', + }, + }, + ], + '*.{tsx,ts,js,mjs}': [ + { + loader: '@devup-ui/webpack-plugin/loader', + options: { + package: libPackage, + cssFile: cssFile, + sheetFile, + classMapFile, + watch: process.env.NODE_ENV === 'development', + }, + }, + ], + } + Object.assign(config.experimental.turbo.rules, rules) + return config + } + const { webpack } = config config.webpack = (config, _options) => { - config.plugins.push(new DevupUIWebpackPlugin(options)) + config.plugins.push( + new DevupUIWebpackPlugin({ + ...options, + watch: _options.dev, + }), + ) if (typeof webpack === 'function') return webpack(config, _options) return config } diff --git a/packages/webpack-plugin/src/__tests__/css-loader.test.ts b/packages/webpack-plugin/src/__tests__/css-loader.test.ts index 8b6c3fc4..09288a5e 100644 --- a/packages/webpack-plugin/src/__tests__/css-loader.test.ts +++ b/packages/webpack-plugin/src/__tests__/css-loader.test.ts @@ -1,19 +1,62 @@ import { resolve } from 'node:path' +import { getCss } from '@devup-ui/wasm' + import devupUICssLoader from '../css-loader' vi.mock('node:path') +vi.mock('@devup-ui/wasm') + +beforeEach(() => { + vi.resetAllMocks() +}) describe('devupUICssLoader', () => { - it('should invoke callback', () => { + it('should return css on no watch', () => { + const callback = vi.fn() + const addContextDependency = vi.fn() + vi.mocked(resolve).mockReturnValue('resolved') + vi.mocked(getCss).mockReturnValue('get css') + devupUICssLoader.bind({ + callback, + addContextDependency, + getOptions: () => ({ watch: false }), + } as any)(Buffer.from('data'), '') + expect(callback).toBeCalledWith(null, 'get css') + }) + + it('should return _compiler hit css on watch', () => { const callback = vi.fn() const addContextDependency = vi.fn() vi.mocked(resolve).mockReturnValue('resolved') + vi.mocked(getCss).mockReturnValue('get css') devupUICssLoader.bind({ callback, addContextDependency, + getOptions: () => ({ watch: true }), } as any)(Buffer.from('data'), '') - expect(callback).toBeCalledWith(null, Buffer.from('data')) - expect(addContextDependency).toBeCalledWith('resolved') + expect(callback).toBeCalledWith(null, 'get css') + expect(getCss).toBeCalledTimes(1) + vi.mocked(getCss).mockReset() + devupUICssLoader.bind({ + callback, + addContextDependency, + getOptions: () => ({ watch: true }), + } as any)(Buffer.from('data'), '') + + expect(getCss).toBeCalledTimes(0) + + vi.mocked(getCss).mockReset() + + devupUICssLoader.bind({ + callback, + addContextDependency, + _compiler: { + __DEVUP_CACHE: 'data', + }, + getOptions: () => ({ watch: true }), + } as any)(Buffer.from(''), '') + + expect(getCss).toBeCalledTimes(0) }) }) diff --git a/packages/webpack-plugin/src/__tests__/loader.test.ts b/packages/webpack-plugin/src/__tests__/loader.test.ts index a7b82433..ea2bf9bc 100644 --- a/packages/webpack-plugin/src/__tests__/loader.test.ts +++ b/packages/webpack-plugin/src/__tests__/loader.test.ts @@ -1,11 +1,11 @@ -import { writeFileSync } from 'node:fs' +import { writeFile } from 'node:fs/promises' -import { codeExtract } from '@devup-ui/wasm' +import { codeExtract, exportClassMap, exportSheet } from '@devup-ui/wasm' import devupUILoader from '../loader' vi.mock('@devup-ui/wasm') -vi.mock('node:fs') +vi.mock('node:fs/promises') beforeEach(() => { vi.resetAllMocks() @@ -16,12 +16,9 @@ describe('devupUILoader', () => { it('should ignore lib files', () => { const t = { getOptions: () => ({ - plugin: { - options: { - package: 'package', - cssFile: 'cssFile', - }, - }, + package: 'package', + cssFile: 'cssFile', + watch: false, }), addDependency: vi.fn(), async: vi.fn().mockReturnValue(vi.fn()), @@ -39,12 +36,9 @@ describe('devupUILoader', () => { it('should ignore wrong files', () => { const t = { getOptions: () => ({ - plugin: { - options: { - package: 'package', - cssFile: 'cssFile', - }, - }, + package: 'package', + cssFile: 'cssFile', + watch: false, }), async: vi.fn().mockReturnValue(vi.fn()), resourcePath: 'node_modules/package/index.css', @@ -59,20 +53,26 @@ describe('devupUILoader', () => { expect(t.async()).toHaveBeenCalledWith(null, Buffer.from('code')) }) - it('should extract code with css', () => { + it('should extract code with css', async () => { + const _compiler = { + __DEVUP_CACHE: '', + } const t = { getOptions: () => ({ - plugin: { - options: { - package: 'package', - cssFile: 'cssFile', - }, - }, + package: 'package', + cssFile: 'cssFile', + sheetFile: 'sheetFile', + classMapFile: 'classMapFile', + watch: true, }), async: vi.fn().mockReturnValue(vi.fn()), resourcePath: 'index.tsx', addDependency: vi.fn(), + _compiler, } + vi.mocked(exportSheet).mockReturnValue('sheet') + vi.mocked(exportClassMap).mockReturnValue('classMap') + vi.mocked(codeExtract).mockReturnValue({ code: 'code', css: 'css', @@ -87,21 +87,22 @@ describe('devupUILoader', () => { 'package', 'cssFile', ) - expect(t.async()).toHaveBeenCalledWith(null, 'code') - expect(writeFileSync).toHaveBeenCalledWith('cssFile', 'css', { - encoding: 'utf-8', + await vi.waitFor(() => { + expect(t.async()).toHaveBeenCalledWith(null, 'code') }) + expect(writeFile).toHaveBeenCalledWith('cssFile', '/* index.tsx 0 */') + expect(writeFile).toHaveBeenCalledWith('sheetFile', 'sheet') + expect(writeFile).toHaveBeenCalledWith('classMapFile', 'classMap') + + expect(t._compiler.__DEVUP_CACHE).toBe('index.tsx 0') }) it('should extract code without css', () => { const t = { getOptions: () => ({ - plugin: { - options: { - package: 'package', - cssFile: 'cssFile', - }, - }, + package: 'package', + cssFile: 'cssFile', + watch: false, }), async: vi.fn().mockReturnValue(vi.fn()), resourcePath: 'index.tsx', @@ -122,7 +123,7 @@ describe('devupUILoader', () => { 'cssFile', ) expect(t.async()).toHaveBeenCalledWith(null, 'code') - expect(writeFileSync).not.toHaveBeenCalledWith('cssFile', 'css', { + expect(writeFile).not.toHaveBeenCalledWith('cssFile', 'css', { encoding: 'utf-8', }) }) @@ -130,12 +131,9 @@ describe('devupUILoader', () => { it('should handle error', () => { const t = { getOptions: () => ({ - plugin: { - options: { - package: 'package', - cssFile: 'cssFile', - }, - }, + package: 'package', + cssFile: 'cssFile', + watch: false, }), async: vi.fn().mockReturnValue(vi.fn()), resourcePath: 'index.tsx', @@ -153,13 +151,9 @@ describe('devupUILoader', () => { it('should load with date now on watch', () => { const t = { getOptions: () => ({ - plugin: { - options: { - package: 'package', - cssFile: 'cssFile', - }, - watch: true, - }, + package: 'package', + cssFile: 'cssFile', + watch: true, }), async: vi.fn().mockReturnValue(vi.fn()), resourcePath: 'index.tsx', diff --git a/packages/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts index 252bbd9e..47b498d8 100644 --- a/packages/webpack-plugin/src/__tests__/plugin.test.ts +++ b/packages/webpack-plugin/src/__tests__/plugin.test.ts @@ -5,204 +5,263 @@ import { stat, writeFileSync, } from 'node:fs' -import { dirname, join, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' +import { join, resolve } from 'node:path' import { getCss, getThemeInterface, registerTheme } from '@devup-ui/wasm' +import { describe } from 'vitest' import { DevupUIWebpackPlugin } from '../plugin' vi.mock('@devup-ui/wasm') vi.mock('node:fs') -const _filename = fileURLToPath(import.meta.url) -const _dirname = resolve(dirname(_filename), '..') beforeEach(() => { vi.resetAllMocks() }) +afterAll(() => { + vi.restoreAllMocks() +}) describe('devupUIPlugin', () => { - it('should apply default options', () => { - import.meta.resolve = vi.fn().mockReturnValue('resolved') - expect(new DevupUIWebpackPlugin({}).options).toEqual({ - package: '@devup-ui/react', - cssFile: join(_dirname, 'devup-ui.css'), - devupPath: 'devup.json', - interfacePath: '.df', + console.error = vi.fn() + describe('no watch', () => { + it('should apply default options', () => { + import.meta.resolve = vi.fn().mockReturnValue('resolved') + expect(new DevupUIWebpackPlugin({}).options).toEqual({ + package: '@devup-ui/react', + cssFile: resolve('.df', 'devup-ui.css'), + devupPath: 'devup.json', + interfacePath: '.df', + watch: false, + }) }) - }) - it('should apply custom options', () => { - import.meta.resolve = vi.fn().mockReturnValue('resolved') - expect( - new DevupUIWebpackPlugin({ + it('should apply custom options', () => { + import.meta.resolve = vi.fn().mockReturnValue('resolved') + expect( + new DevupUIWebpackPlugin({ + package: 'new-package', + cssFile: 'new-css-file', + devupPath: 'new-devup-path', + interfacePath: 'new-interface-path', + watch: false, + }).options, + ).toEqual({ package: 'new-package', cssFile: 'new-css-file', devupPath: 'new-devup-path', interfacePath: 'new-interface-path', - }).options, - ).toEqual({ - package: 'new-package', - cssFile: 'new-css-file', - devupPath: 'new-devup-path', - interfacePath: 'new-interface-path', + watch: false, + }) }) - }) - it('should write data files', () => { - vi.mocked(readFileSync).mockReturnValue('{"theme": "theme"}') - vi.mocked(getThemeInterface).mockReturnValue('interfaceCode') - vi.mocked(getCss).mockReturnValue('css') - vi.mocked(existsSync).mockReturnValue(false) - vi.mocked(writeFileSync).mockReturnValue() - vi.mocked(mkdirSync) - - const plugin = new DevupUIWebpackPlugin({}) - plugin.writeDataFiles() - - expect(readFileSync).toHaveBeenCalledWith('devup.json', 'utf-8') - expect(registerTheme).toHaveBeenCalledWith('theme') - expect(getThemeInterface).toHaveBeenCalledWith( - '@devup-ui/react', - 'DevupThemeColors', - 'DevupThemeTypography', - 'DevupTheme', - ) - expect(mkdirSync).toHaveBeenCalledWith('.df') - expect(writeFileSync).toHaveBeenCalledWith( - join('.df', 'theme.d.ts'), - 'interfaceCode', - { - encoding: 'utf-8', - }, - ) - expect(writeFileSync).toHaveBeenCalledWith( - join(_dirname, 'devup-ui.css'), - 'css', - { - encoding: 'utf-8', - }, - ) - }) - it('should watch devup.json', () => { - vi.mocked(readFileSync).mockReturnValue('{"theme": "theme"}') - vi.mocked(existsSync).mockReturnValue(true) - const plugin = new DevupUIWebpackPlugin({}) - const compiler = { - options: { - module: { - rules: [], - }, - }, - hooks: { - afterCompile: { - tap: vi.fn(), - }, - watchRun: { - tapAsync: vi.fn(), - }, - }, - } - plugin.apply(compiler as any) - expect(compiler.hooks.afterCompile.tap).toHaveBeenCalled() - expect(compiler.hooks.watchRun.tapAsync).toHaveBeenCalled() - }) + it('should write data files', () => { + vi.mocked(readFileSync).mockReturnValue('{"theme": "theme"}') + vi.mocked(getThemeInterface).mockReturnValue('interfaceCode') + vi.mocked(getCss).mockReturnValue('css') + vi.mocked(existsSync).mockReturnValue(false) + vi.mocked(writeFileSync).mockReturnValue() + vi.mocked(mkdirSync) - it('should catch error', () => { - vi.mocked(existsSync).mockReturnValue(true) - vi.mocked(stat).mockImplementation((_, callback) => { - ;(callback as any)(new Error('error'), null) - }) - vi.mocked(readFileSync).mockImplementation(() => { - throw new Error('error') - }) - console.error = vi.fn() - const plugin = new DevupUIWebpackPlugin({ - devupPath: 'custom-devup.json', + const plugin = new DevupUIWebpackPlugin({}) + plugin.writeDataFiles() + + expect(readFileSync).toHaveBeenCalledWith('devup.json', 'utf-8') + expect(registerTheme).toHaveBeenCalledWith('theme') + expect(getThemeInterface).toHaveBeenCalledWith( + '@devup-ui/react', + 'DevupThemeColors', + 'DevupThemeTypography', + 'DevupTheme', + ) + expect(mkdirSync).toHaveBeenCalledWith('.df') + expect(writeFileSync).toHaveBeenCalledWith( + join('.df', 'theme.d.ts'), + 'interfaceCode', + { + encoding: 'utf-8', + }, + ) }) - const compiler = { - options: { - module: { - rules: [], + + it('should catch error', () => { + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(stat).mockImplementation((_, callback) => { + ;(callback as any)(new Error('error'), null) + }) + vi.mocked(readFileSync).mockImplementation(() => { + throw new Error('error') + }) + const plugin = new DevupUIWebpackPlugin({ + devupPath: 'custom-devup.json', + }) + const compiler = { + options: { + module: { + rules: [], + }, }, - }, - hooks: { - afterCompile: { - tap: vi.fn(), + hooks: { + afterCompile: { + tap: vi.fn(), + }, + watchRun: { + tapAsync: vi.fn(), + }, }, - watchRun: { - tapAsync: vi.fn(), + } + plugin.apply(compiler as any) + // asyncCompile + const add = vi.fn() + vi.mocked(compiler.hooks.afterCompile.tap).mock.calls[0][1]({ + fileDependencies: { + add, }, - }, - } - plugin.apply(compiler as any) - expect(console.error).toHaveBeenCalledWith(new Error('error')) - // asyncCompile - const add = vi.fn() - vi.mocked(compiler.hooks.afterCompile.tap).mock.calls[0][1]({ - fileDependencies: { - add, - }, + }) + expect(add).toHaveBeenCalledWith(resolve('custom-devup.json')) }) - expect(add).toHaveBeenCalledWith(resolve('custom-devup.json')) - // watchRun - const callback = vi.fn() - vi.mocked(compiler.hooks.watchRun.tapAsync).mock.calls[0][1](null, callback) - expect(callback).toHaveBeenCalled() - expect(registerTheme).toBeCalledTimes(0) + it('should skip writing css file', () => { + vi.mocked(readFileSync).mockReturnValue('{"theme": "theme"}') + vi.mocked(getThemeInterface).mockReturnValue('interfaceCode') + vi.mocked(getCss).mockReturnValue('css') + vi.mocked(existsSync).mockReturnValue(false) + vi.mocked(writeFileSync).mockReturnValue() + vi.mocked(mkdirSync) - vi.mocked(stat).mockImplementation((_, callback) => { - ;(callback as any)(null, { mtimeMs: 1 }) - }) - vi.mocked(readFileSync).mockReturnValue('{"theme": "theme"}') - vi.mocked(writeFileSync).mockReturnValue() - vi.mocked(registerTheme).mockReturnValue() - vi.mocked(stat).mockImplementation((_, callback) => { - ;(callback as any)(null, { mtimeMs: 2 }) - }) - vi.mocked(console.error).mockReturnValue() - plugin.apply(compiler as any) + const plugin = new DevupUIWebpackPlugin({ + cssFile: 'css', + }) + plugin.apply({ + options: { + module: { + rules: [], + }, + }, + hooks: { + afterCompile: { + tap: vi.fn(), + }, + watchRun: { + tapAsync: vi.fn(), + }, + }, + } as any) - vi.mocked(compiler.hooks.watchRun.tapAsync).mock.calls[0][1](null, callback) - expect(registerTheme).toHaveBeenCalled() - vi.mocked(stat).mockImplementation((_, callback) => { - ;(callback as any)(null, { mtimeMs: 3 }) + expect(writeFileSync).toHaveBeenCalledWith('css', '', { + encoding: 'utf-8', + }) }) - expect(registerTheme).toBeCalledTimes(1) - vi.mocked(compiler.hooks.watchRun.tapAsync).mock.calls[0][1](null, callback) - expect(registerTheme).toBeCalledTimes(2) }) + describe('watch', () => { + it('should write css file', () => { + vi.mocked(readFileSync).mockReturnValue('{"theme": "theme"}') + vi.mocked(getThemeInterface).mockReturnValue('interfaceCode') + vi.mocked(getCss).mockReturnValue('css') + vi.mocked(writeFileSync).mockReturnValue() + vi.mocked(mkdirSync) - it('should skip writing css file', () => { - vi.mocked(readFileSync).mockReturnValue('{"theme": "theme"}') - vi.mocked(getThemeInterface).mockReturnValue('interfaceCode') - vi.mocked(getCss).mockReturnValue('css') - vi.mocked(existsSync).mockReturnValue(false) - vi.mocked(writeFileSync).mockReturnValue() - vi.mocked(mkdirSync) + const plugin = new DevupUIWebpackPlugin({ + watch: true, + }) + const compiler = { + options: { + module: { + rules: [], + }, + }, + hooks: { + afterCompile: { + tap: vi.fn(), + }, + watchRun: { + tapAsync: vi.fn(), + }, + }, + } as any + plugin.apply(compiler) - const plugin = new DevupUIWebpackPlugin({ - cssFile: 'css', - }) - plugin.apply({ - options: { - module: { - rules: [], + expect(writeFileSync).toHaveBeenCalledWith( + resolve('.df', 'devup-ui.css'), + '', + { + encoding: 'utf-8', }, - }, - hooks: { - afterCompile: { - tap: vi.fn(), + ) + }) + it('should register devup watch', () => { + const plugin = new DevupUIWebpackPlugin({ + watch: true, + }) + const compiler = { + options: { + module: { + rules: [], + }, }, - watchRun: { - tapAsync: vi.fn(), + hooks: { + afterCompile: { + tap: vi.fn(), + }, + watchRun: { + tapAsync: vi.fn(), + }, }, - }, - } as any) + } as any + vi.mocked(existsSync).mockReturnValue(true) + plugin.apply(compiler) + // watchRun + const callback = vi.fn() + vi.mocked(compiler.hooks.watchRun.tapAsync).mock.calls[0][1]( + null, + callback, + ) + expect(callback).toHaveBeenCalled() + expect(registerTheme).toBeCalledTimes(0) + + vi.mocked(stat).mockImplementation((_, callback) => { + ;(callback as any)(null, { mtimeMs: 1 }) + }) + vi.mocked(readFileSync).mockReturnValue('{"theme": "theme"}') + vi.mocked(writeFileSync).mockReturnValue() + vi.mocked(registerTheme).mockReturnValue() + vi.mocked(stat).mockImplementation((_, callback) => { + ;(callback as any)(null, { mtimeMs: 2 }) + }) + vi.mocked(console.error).mockReturnValue() + + plugin.apply(compiler as any) + + vi.mocked(compiler.hooks.watchRun.tapAsync).mock.calls[0][1]( + null, + callback, + ) + expect(registerTheme).toHaveBeenCalled() + vi.mocked(stat).mockImplementation((_, callback) => { + ;(callback as any)(null, { mtimeMs: 3 }) + }) + expect(registerTheme).toBeCalledTimes(1) + vi.mocked(compiler.hooks.watchRun.tapAsync).mock.calls[0][1]( + null, + callback, + ) + expect(registerTheme).toBeCalledTimes(2) + + vi.mocked(stat).mockImplementation((_, callback) => { + ;(callback as any)(1) + }) + + plugin.apply(compiler as any) + + vi.mocked(compiler.hooks.watchRun.tapAsync).mock.calls[0][1]( + null, + callback, + ) - expect(writeFileSync).toHaveBeenCalledWith('css', '', { - encoding: 'utf-8', + expect(console.error).toHaveBeenCalledWith( + 'Error checking devup.json:', + 1, + ) }) }) }) diff --git a/packages/webpack-plugin/src/css-loader.ts b/packages/webpack-plugin/src/css-loader.ts index dc892194..1420f5a4 100644 --- a/packages/webpack-plugin/src/css-loader.ts +++ b/packages/webpack-plugin/src/css-loader.ts @@ -1,9 +1,22 @@ -import { resolve } from 'node:path' - +import { getCss } from '@devup-ui/wasm' import type { RawLoaderDefinitionFunction } from 'webpack' -const devupUICssLoader: RawLoaderDefinitionFunction = function (a) { - this.addContextDependency(resolve(this.rootContext, 'src')) - this.callback(null, a) +let prevData = '' +let prevTime = '' + +const devupUICssLoader: RawLoaderDefinitionFunction<{ + watch: boolean +}> = function (source) { + const { watch } = this.getOptions() + if (!watch) return this.callback(null, getCss()) + const stringSource = + (this._compiler as any)?.__DEVUP_CACHE || source.toString() + + if (prevTime === stringSource) { + this.callback(null, prevData) + return + } + prevTime = stringSource + this.callback(null, (prevData = getCss())) } export default devupUICssLoader diff --git a/packages/webpack-plugin/src/loader.ts b/packages/webpack-plugin/src/loader.ts index 3277832a..0655cad8 100644 --- a/packages/webpack-plugin/src/loader.ts +++ b/packages/webpack-plugin/src/loader.ts @@ -1,18 +1,26 @@ -import { writeFileSync } from 'node:fs' +import { writeFile } from 'node:fs/promises' +import { dirname, relative } from 'node:path' -import { codeExtract } from '@devup-ui/wasm' +import { codeExtract, exportClassMap, exportSheet } from '@devup-ui/wasm' import type { RawLoaderDefinitionFunction } from 'webpack' -import { type DevupUIWebpackPlugin } from './plugin' - export interface DevupUILoaderOptions { - plugin: DevupUIWebpackPlugin + package: string + cssFile: string + sheetFile: string + classMapFile: string + watch: boolean } const devupUILoader: RawLoaderDefinitionFunction = function (source) { - const { plugin } = this.getOptions() - const { package: libPackage, cssFile } = plugin.options + const { + watch, + package: libPackage, + cssFile, + sheetFile, + classMapFile, + } = this.getOptions() const callback = this.async() const id = this.resourcePath if ( @@ -29,15 +37,21 @@ const devupUILoader: RawLoaderDefinitionFunction = id, source.toString(), libPackage, - cssFile, + relative(dirname(this.resourcePath), cssFile).replaceAll('\\', '/'), ) - if (css) { + if (css && watch) { + const content = `${this.resourcePath} ${Date.now()}` + if (this._compiler) (this._compiler as any).__DEVUP_CACHE = content // should be reset css - writeFileSync(cssFile, css, { - encoding: 'utf-8', - }) + Promise.all([ + writeFile(cssFile, `/* ${content} */`), + writeFile(sheetFile, exportSheet()), + writeFile(classMapFile, exportClassMap()), + ]) + .catch(console.error) + .finally(() => callback(null, code)) + return } - callback(null, code) } catch (error) { callback(error as Error) diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index 3eb329b9..ea56eff6 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -6,20 +6,22 @@ import { writeFileSync, } from 'node:fs' import { createRequire } from 'node:module' -import { dirname, join, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' +import { join, resolve } from 'node:path' -import { getCss, getThemeInterface, registerTheme } from '@devup-ui/wasm' +import { + getThemeInterface, + importClassMap, + importSheet, + registerTheme, +} from '@devup-ui/wasm' import { type Compiler } from 'webpack' -const _filename = fileURLToPath(import.meta.url) -const _dirname = dirname(_filename) - export interface DevupUIWebpackPluginOptions { package: string cssFile: string devupPath: string interfacePath: string + watch: boolean } export class DevupUIWebpackPlugin { @@ -27,15 +29,17 @@ export class DevupUIWebpackPlugin { constructor({ package: libPackage = '@devup-ui/react', - cssFile = join(_dirname, 'devup-ui.css'), devupPath = 'devup.json', interfacePath = '.df', + cssFile = resolve(interfacePath, 'devup-ui.css'), + watch = false, }: Partial = {}) { this.options = { package: libPackage, cssFile, devupPath, interfacePath, + watch, } } @@ -60,48 +64,63 @@ export class DevupUIWebpackPlugin { }, ) } - writeFileSync(this.options.cssFile, getCss(), { - encoding: 'utf-8', - }) + if (this.options.watch) { + writeFileSync(this.options.cssFile, `/* ${Date.now()} */`, { + encoding: 'utf-8', + }) + } } apply(compiler: Compiler) { // read devup.json const existsDevup = existsSync(this.options.devupPath) + + const sheetFile = join(this.options.interfacePath, 'sheet.json') + const classMapFile = join(this.options.interfacePath, 'classMap.json') + if (this.options.watch) { + try { + // load sheet + if (existsSync(sheetFile)) + importSheet(JSON.parse(readFileSync(sheetFile, 'utf-8'))) + if (existsSync(classMapFile)) + importClassMap(JSON.parse(readFileSync(classMapFile, 'utf-8'))) + } catch (error) { + console.error(error) + } + let lastModifiedTime: number | null = null + compiler.hooks.watchRun.tapAsync( + 'DevupUIWebpackPlugin', + (_, callback) => { + if (existsDevup) + stat(this.options.devupPath, (err, stats) => { + if (err) { + console.error(`Error checking ${this.options.devupPath}:`, err) + return + } + + const modifiedTime = stats.mtimeMs + if (lastModifiedTime && lastModifiedTime !== modifiedTime) + this.writeDataFiles() + + lastModifiedTime = modifiedTime + }) + callback() + }, + ) + } if (existsDevup) { try { this.writeDataFiles() } catch (error) { console.error(error) } - compiler.hooks.afterCompile.tap('DevupUIWebpackPlugin', (compilation) => { compilation.fileDependencies.add(resolve(this.options.devupPath)) }) } - - let lastModifiedTime: number | null = null - compiler.hooks.watchRun.tapAsync('DevupUIWebpackPlugin', (_, callback) => { - if (existsDevup) - stat(this.options.devupPath, (err, stats) => { - if (err) { - console.error(`Error checking ${this.options.devupPath}:`, err) - return - } - - const modifiedTime = stats.mtimeMs - if (lastModifiedTime && lastModifiedTime !== modifiedTime) { - this.writeDataFiles() - } - - lastModifiedTime = modifiedTime - }) - callback() - }) // Create an empty CSS file - if (!existsSync(this.options.cssFile)) { + if (!existsSync(this.options.cssFile)) writeFileSync(this.options.cssFile, '', { encoding: 'utf-8' }) - } compiler.options.module.rules.push( { @@ -114,20 +133,26 @@ export class DevupUIWebpackPlugin { '@devup-ui/webpack-plugin/loader', ), options: { - plugin: this, + package: this.options.package, + cssFile: this.options.cssFile, + sheetFile, + classMapFile, + watch: this.options.watch, }, }, ], }, { test: this.options.cssFile, - enforce: 'post', - + enforce: 'pre', use: [ { loader: createRequire(import.meta.url).resolve( '@devup-ui/webpack-plugin/css-loader', ), + options: { + watch: this.options.watch, + }, }, ], },