diff --git a/.changepacks/changepack_log_dead_css_property_types.json b/.changepacks/changepack_log_dead_css_property_types.json new file mode 100644 index 000000000..b63675779 --- /dev/null +++ b/.changepacks/changepack_log_dead_css_property_types.json @@ -0,0 +1,7 @@ +{ + "changes": { + "packages/react/package.json": "Patch" + }, + "note": "Reject the 18 unprefixed legacy flexbox and scroll-snap properties rejected by the compiler in component and StyleX property types. Keep vendor-prefixed compatibility properties, stroke-color and ime-mode.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_eslint_composition_arrays.json b/.changepacks/changepack_log_eslint_composition_arrays.json new file mode 100644 index 000000000..c17f4db47 --- /dev/null +++ b/.changepacks/changepack_log_eslint_composition_arrays.json @@ -0,0 +1,7 @@ +{ + "changes": { + "packages/eslint-plugin/package.json": "Patch" + }, + "note": "Do not treat composition arrays as responsive property values in the duplicate, trailing-null or single-value array rules. Preserve composition arrays and their nested responsive property diagnostics.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_shorthand_target_validation.json b/.changepacks/changepack_log_shorthand_target_validation.json new file mode 100644 index 000000000..5bae9f6be --- /dev/null +++ b/.changepacks/changepack_log_shorthand_target_validation.json @@ -0,0 +1,13 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/plugin-utils/package.json": "Patch", + "packages/vite-plugin/package.json": "Patch", + "packages/next-plugin/package.json": "Patch", + "packages/webpack-plugin/package.json": "Patch", + "packages/rsbuild-plugin/package.json": "Patch", + "packages/bun-plugin/package.json": "Patch" + }, + "note": "Custom shorthand targets are constrained to supported camelCase/kebab-case CSS properties, built-in aliases and valid custom properties. All five build adapters normalize options at creation before registration or extraction, including Next.js production cache reuse. Unsupported targets fail registration atomically with the original shorthand name, target and zero-based index; previously registered shorthands remain unchanged. New devup-ui option errors expose structured logical shorthands paths without inventing source coordinates. Eighteen dead unprefixed property names are rejected.", + "date": "2026-10-05T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_style_order_contract.json b/.changepacks/changepack_log_style_order_contract.json new file mode 100644 index 000000000..cee54744f --- /dev/null +++ b/.changepacks/changepack_log_style_order_contract.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/eslint-plugin/package.json": "Patch" + }, + "note": "Validate explicit static styleOrder as an integer from 1 to 254 or its canonical decimal digit string, consistently in extraction and lint, including composed Emotion css props. Keep order metadata out of library px conversion. Invalid static values and conditional branches now produce located build errors instead of silently changing cascade order.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_stylex_public_contracts.json b/.changepacks/changepack_log_stylex_public_contracts.json new file mode 100644 index 000000000..8953d6e55 --- /dev/null +++ b/.changepacks/changepack_log_stylex_public_contracts.json @@ -0,0 +1,7 @@ +{ + "changes": { + "packages/react/package.json": "Patch" + }, + "note": "Type every compiled StyleX API with opaque styles, dynamic function arguments, conditional variables, partial themes and static null/string theme contract placeholders. Unsupported runtime shapes are rejected by TypeScript; no new build errors in this change.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/bindings/devup-ui-wasm/src/css_output_edge_tests.rs b/bindings/devup-ui-wasm/src/css_output_edge_tests.rs index 625a21918..4f141f5c3 100644 --- a/bindings/devup-ui-wasm/src/css_output_edge_tests.rs +++ b/bindings/devup-ui-wasm/src/css_output_edge_tests.rs @@ -176,13 +176,16 @@ fn escaped_string_css_dead_names_fail_before_emission( #[serial] fn configured_shorthand_names_keep_their_valid_targets() -> Result<(), String> { let _scope = super::TestStateGuard::new(); - css::set_custom_shorthands(BTreeMap::from([( - "boxAlign".to_string(), - vec!["align-items".to_string()], - )])); + assert_eq!( + css::set_custom_shorthands(BTreeMap::from([( + "boxAlign".to_string(), + vec!["align-items".to_string()], + )])), + Ok(()) + ); let source = "import { Box } from '@devup-ui/react';\nconst e = ;"; let output = compile(source); - css::set_custom_shorthands(BTreeMap::new()); + assert_eq!(css::set_custom_shorthands(BTreeMap::new()), Ok(())); let css = output?.css().ok_or("expected emitted CSS")?; assert!(css.contains("{align-items:center}"), "{css}"); assert!(!css.contains("{box-align:"), "{css}"); diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 36c4bf5e0..74ee572c1 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -678,16 +678,17 @@ pub fn register_theme(theme_object: JsValue) -> Result<(), JsValue> { } /// Internal function to register custom style-property shorthands. -pub fn register_shorthands_internal(shorthands: BTreeMap>) { - css::set_custom_shorthands(shorthands); +pub fn register_shorthands_internal( + shorthands: BTreeMap>, +) -> Result<(), css::shorthand::InvalidShorthandTarget> { + css::set_custom_shorthands(shorthands) } #[wasm_bindgen(js_name = "registerShorthands")] #[cfg(not(tarpaulin_include))] pub fn register_shorthands(shorthands: JsValue) -> Result<(), JsValue> { let shorthands = serde_wasm_bindgen::from_value(shorthands).map_err(js_error)?; - register_shorthands_internal(shorthands); - Ok(()) + register_shorthands_internal(shorthands).map_err(js_error) } #[wasm_bindgen(js_name = "getDefaultTheme")] @@ -1209,7 +1210,7 @@ mod tests { #[serial] fn test_get_theme_interface() { let _state = TestStateGuard::new(); - register_shorthands_internal(BTreeMap::new()); + assert_eq!(register_shorthands_internal(BTreeMap::new()), Ok(())); let sheet = StyleSheet::default(); assert_eq!( sheet.create_interface( @@ -1231,7 +1232,8 @@ mod tests { register_shorthands_internal(BTreeMap::from([( "insetX".to_string(), vec!["left".to_string(), "right".to_string()], - )])); + )])) + .unwrap(); assert_eq!( get_theme_interface( "package", @@ -1245,7 +1247,7 @@ mod tests { ); // test wrong case - register_shorthands_internal(BTreeMap::new()); + register_shorthands_internal(BTreeMap::new()).unwrap(); let mut sheet = StyleSheet::default(); let mut theme = Theme::default(); let mut color_theme = ColorTheme::default(); @@ -1904,7 +1906,7 @@ mod tests { )]); register_theme_internal(theme); - register_shorthands_internal(shorthands); + register_shorthands_internal(shorthands).unwrap(); // Verify the theme was registered let default_theme = GLOBAL_STYLE_SHEET.lock().unwrap().theme.get_default_theme(); @@ -1913,6 +1915,45 @@ mod tests { css::disassemble_property("insetX").collect::>(), ["left", "right"] ); - register_shorthands_internal(BTreeMap::new()); + register_shorthands_internal(BTreeMap::new()).unwrap(); + } + + #[test] + #[serial] + fn test_register_shorthands_internal_rejects_targets_atomically() { + register_shorthands_internal(BTreeMap::from([("old".into(), vec!["py".into()])])).unwrap(); + let error = register_shorthands_internal(BTreeMap::from([ + ("aValid".into(), vec!["width".into()]), + ("bad".into(), vec!["height".into(), "widht".into()]), + ])) + .unwrap_err(); + assert_eq!(error.alias, "bad"); + assert_eq!(error.target, "widht"); + assert_eq!(error.index, 1); + assert_eq!(css::get_custom_shorthand_names(), ["old"]); + assert_eq!( + css::disassemble_property("old").collect::>(), + ["padding-top", "padding-bottom"] + ); + register_shorthands_internal(BTreeMap::new()).unwrap(); + } + + #[test] + #[serial] + fn test_register_shorthands_internal_normalizes_vendor_targets() { + register_shorthands_internal(BTreeMap::from([( + "maskAlias".into(), + vec![ + "WebkitMaskImage".into(), + "-webkit-mask-image".into(), + "--Mask".into(), + ], + )])) + .unwrap(); + assert_eq!( + css::disassemble_property("maskAlias").collect::>(), + ["-webkit-mask-image", "-webkit-mask-image", "--Mask"] + ); + register_shorthands_internal(BTreeMap::new()).unwrap(); } } diff --git a/bindings/devup-ui-wasm/src/reservation/tests.rs b/bindings/devup-ui-wasm/src/reservation/tests.rs index a0d7344f2..b0e15379c 100644 --- a/bindings/devup-ui-wasm/src/reservation/tests.rs +++ b/bindings/devup-ui-wasm/src/reservation/tests.rs @@ -51,7 +51,7 @@ pub(super) fn transform( ) } -const A: &str = "import {Box,keyframes,globalCss} from '@devup-ui/react'; const spin=keyframes({from:{opacity:0},to:{opacity:1}}); globalCss({body:{margin:0}}); export const A=(value:string)=>;"; +const A: &str = "import {Box,keyframes,globalCss} from '@devup-ui/react'; const spin=keyframes({from:{opacity:0},to:{opacity:1}}); globalCss({body:{margin:0}}); export const A=(value:string)=>;"; const B: &str = "import {Box,keyframes} from '@devup-ui/react'; const fade=keyframes({from:{opacity:1},to:{opacity:0}}); export const B=(value:string)=>;"; #[derive(Debug, PartialEq, Eq)] @@ -123,7 +123,7 @@ fn reservation_preserves_nonempty_sheet_theme_and_unchanged_registries() -> Test )?; theme.tailwind_css = "@theme { --color-custom: #123; }".into(); register_theme_internal(theme); - css::set_custom_shorthands(BTreeMap::from([("customGap".into(), vec!["gap".into()])])); + css::set_custom_shorthands(BTreeMap::from([("customGap".into(), vec!["gap".into()])]))?; css::set_prefix(Some("du-".into())); transform(&input("live.tsx", A), &option)?; with_style_sheet_mut(|sheet| { diff --git a/bindings/devup-ui-wasm/src/state/tests.rs b/bindings/devup-ui-wasm/src/state/tests.rs index 69620b84f..d9b2bb1d9 100644 --- a/bindings/devup-ui-wasm/src/state/tests.rs +++ b/bindings/devup-ui-wasm/src/state/tests.rs @@ -27,7 +27,10 @@ fn reset_returns_fresh_extraction_when_engine_is_dirty() { set_prefix(Some("dirty".into())); set_debug(true); set_atom_hoist(Some(2)); - register_shorthands_internal(BTreeMap::from([("alias".into(), vec!["left".into()])])); + assert_eq!( + register_shorthands_internal(BTreeMap::from([("alias".into(), vec!["left".into()])])), + Ok(()) + ); register_theme_internal( serde_json::from_str( r#"{ @@ -47,10 +50,7 @@ fn reset_returns_fresh_extraction_when_engine_is_dirty() { )])); extract_fixture(); reset_state_for_testing(); - assert_eq!( - export_sheet_internal().unwrap(), - empty_sheet - ); + assert_eq!(export_sheet_internal().unwrap(), empty_sheet); assert_eq!(get_default_theme().unwrap(), None); assert!(MODULE_RESOLVER.with_borrow(Option::is_none)); assert_eq!(get_prefix(), None); diff --git a/libs/css/src/lib.rs b/libs/css/src/lib.rs index ccb989a0c..8d736f8a7 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -12,9 +12,11 @@ pub mod is_special_property; mod num_to_nm_base; pub mod optimize_multi_css_value; pub mod optimize_value; +mod property_names; pub mod quoted; pub mod rm_css_comment; mod selector_separator; +pub mod shorthand; pub mod slot_reservation; pub mod style_selector; pub mod tailwind_definitions; @@ -268,26 +270,18 @@ pub fn reset_custom_shorthands() { } /// Replace the custom shorthand registry used by style extraction. -pub fn set_custom_shorthands(shorthands: BTreeMap>) { - if let Ok(mut registry) = CUSTOM_SHORTHANDS.write() { - let shorthands: BTreeMap> = shorthands - .into_iter() - .map(|(name, properties)| { - let properties = properties - .into_iter() - .flat_map(|property| { - GLOBAL_STYLE_PROPERTY.get(property.as_str()).map_or_else( - || vec![to_kebab_case(&property).into_owned()], - |mapped| mapped.iter().map(|value| (*value).to_string()).collect(), - ) - }) - .collect(); - (name, properties) - }) - .collect(); - HAS_CUSTOM_SHORTHANDS.store(!shorthands.is_empty(), Ordering::Relaxed); - *registry = shorthands; - } +pub fn set_custom_shorthands( + shorthands: BTreeMap>, +) -> Result<(), shorthand::InvalidShorthandTarget> { + let shorthands = shorthand::normalize_shorthands(shorthands)?; + let mut registry = CUSTOM_SHORTHANDS.write().unwrap_or_else(|poisoned| { + CUSTOM_SHORTHANDS.clear_poison(); + poisoned.into_inner() + }); + HAS_CUSTOM_SHORTHANDS.store(!shorthands.is_empty(), Ordering::Relaxed); + *registry = shorthands; + drop(registry); + Ok(()) } #[must_use] @@ -1567,7 +1561,7 @@ mod tests { #[test] #[serial] - fn test_custom_shorthand() { + fn test_custom_shorthand() -> Result<(), shorthand::InvalidShorthandTarget> { let _state = test_state::TestStateGuard::new(); set_custom_shorthands(BTreeMap::from([( "insetX".to_string(), @@ -1576,17 +1570,18 @@ mod tests { "marginRight".to_string(), "py".to_string(), ], - )])); + )]))?; assert_eq!( disassemble_property("insetX").collect::>(), ["left", "margin-right", "padding-top", "padding-bottom"] ); - set_custom_shorthands(BTreeMap::new()); + set_custom_shorthands(BTreeMap::new())?; assert_eq!( disassemble_property("insetX").collect::>(), ["inset-x"] ); + Ok(()) } } diff --git a/libs/css/src/property_names.rs b/libs/css/src/property_names.rs new file mode 100644 index 000000000..4b220880e --- /dev/null +++ b/libs/css/src/property_names.rs @@ -0,0 +1,174 @@ +//! Generated from csstype-extra 0.1.37 Properties; excludes the 18 unprefixed dead names. +//! Source SHA-256: ed9937009a84b96e5699789a3d437e06b00972ea2fb05b7900dd076983946ade + +pub(super) static CSS_PROPERTY_NAMES: phf::Set<&str> = phf::phf_set! { + "-moz-appearance", "-moz-binding", "-moz-border-bottom-colors", "-moz-border-left-colors", + "-moz-border-right-colors", "-moz-border-top-colors", "-moz-box-align", "-moz-box-direction", + "-moz-box-flex", "-moz-box-flex-group", "-moz-box-lines", "-moz-box-ordinal-group", + "-moz-box-orient", "-moz-box-pack", "-moz-context-properties", "-moz-float-edge", + "-moz-force-broken-image-icon", "-moz-orient", "-moz-outline-radius", "-moz-outline-radius-bottomleft", + "-moz-outline-radius-bottomright", "-moz-outline-radius-topleft", "-moz-outline-radius-topright", "-moz-overflow-clip-box", + "-moz-stack-sizing", "-moz-text-blink", "-moz-user-focus", "-moz-user-input", + "-moz-user-modify", "-moz-window-dragging", "-moz-window-shadow", "-ms-accelerator", + "-ms-block-progression", "-ms-content-zoom-chaining", "-ms-content-zoom-limit", "-ms-content-zoom-limit-max", + "-ms-content-zoom-limit-min", "-ms-content-zoom-snap", "-ms-content-zoom-snap-points", "-ms-content-zoom-snap-type", + "-ms-content-zooming", "-ms-filter", "-ms-flow-from", "-ms-flow-into", + "-ms-grid-columns", "-ms-grid-rows", "-ms-high-contrast-adjust", "-ms-hyphenate-limit-chars", + "-ms-hyphenate-limit-lines", "-ms-hyphenate-limit-zone", "-ms-ime-align", "-ms-overflow-style", + "-ms-scroll-chaining", "-ms-scroll-limit", "-ms-scroll-limit-x-max", "-ms-scroll-limit-x-min", + "-ms-scroll-limit-y-max", "-ms-scroll-limit-y-min", "-ms-scroll-rails", "-ms-scroll-snap-points-x", + "-ms-scroll-snap-points-y", "-ms-scroll-snap-type", "-ms-scroll-snap-x", "-ms-scroll-snap-y", + "-ms-scroll-translation", "-ms-scrollbar-arrow-color", "-ms-scrollbar-base-color", "-ms-scrollbar-darkshadow-color", + "-ms-scrollbar-face-color", "-ms-scrollbar-highlight-color", "-ms-scrollbar-shadow-color", "-ms-scrollbar-track-color", + "-ms-scrollbar3dlight-color", "-ms-text-autospace", "-ms-touch-select", "-ms-user-select", + "-ms-wrap-flow", "-ms-wrap-margin", "-ms-wrap-through", "-webkit-appearance", + "-webkit-border-after", "-webkit-border-after-color", "-webkit-border-after-style", "-webkit-border-after-width", + "-webkit-border-before", "-webkit-border-before-color", "-webkit-border-before-style", "-webkit-border-before-width", + "-webkit-border-end", "-webkit-border-end-color", "-webkit-border-end-style", "-webkit-border-end-width", + "-webkit-border-start", "-webkit-border-start-color", "-webkit-border-start-style", "-webkit-border-start-width", + "-webkit-box-align", "-webkit-box-direction", "-webkit-box-flex", "-webkit-box-flex-group", + "-webkit-box-lines", "-webkit-box-ordinal-group", "-webkit-box-orient", "-webkit-box-pack", + "-webkit-box-reflect", "-webkit-line-clamp", "-webkit-mask", "-webkit-mask-attachment", + "-webkit-mask-clip", "-webkit-mask-composite", "-webkit-mask-image", "-webkit-mask-origin", + "-webkit-mask-position", "-webkit-mask-position-x", "-webkit-mask-position-y", "-webkit-mask-repeat", + "-webkit-mask-repeat-x", "-webkit-mask-repeat-y", "-webkit-mask-size", "-webkit-overflow-scrolling", + "-webkit-tap-highlight-color", "-webkit-text-fill-color", "-webkit-text-stroke", "-webkit-text-stroke-color", + "-webkit-text-stroke-width", "-webkit-touch-callout", "-webkit-user-modify", "-webkit-user-select", + "accent-color", "align-content", "align-items", "align-self", + "align-tracks", "alignment-baseline", "all", "anchor-name", + "anchor-scope", "animation", "animation-composition", "animation-delay", + "animation-direction", "animation-duration", "animation-fill-mode", "animation-iteration-count", + "animation-name", "animation-play-state", "animation-range", "animation-range-end", + "animation-range-start", "animation-timeline", "animation-timing-function", "animation-trigger", + "appearance", "aspect-ratio", "backdrop-filter", "backface-visibility", + "background", "background-attachment", "background-blend-mode", "background-clip", + "background-color", "background-image", "background-origin", "background-position", + "background-position-x", "background-position-y", "background-repeat", "background-size", + "baseline-shift", "baseline-source", "block-size", "border", + "border-block", "border-block-color", "border-block-end", "border-block-end-color", + "border-block-end-style", "border-block-end-width", "border-block-start", "border-block-start-color", + "border-block-start-style", "border-block-start-width", "border-block-style", "border-block-width", + "border-bottom", "border-bottom-color", "border-bottom-left-radius", "border-bottom-right-radius", + "border-bottom-style", "border-bottom-width", "border-collapse", "border-color", + "border-end-end-radius", "border-end-start-radius", "border-image", "border-image-outset", + "border-image-repeat", "border-image-slice", "border-image-source", "border-image-width", + "border-inline", "border-inline-color", "border-inline-end", "border-inline-end-color", + "border-inline-end-style", "border-inline-end-width", "border-inline-start", "border-inline-start-color", + "border-inline-start-style", "border-inline-start-width", "border-inline-style", "border-inline-width", + "border-left", "border-left-color", "border-left-style", "border-left-width", + "border-radius", "border-right", "border-right-color", "border-right-style", + "border-right-width", "border-shape", "border-spacing", "border-start-end-radius", + "border-start-start-radius", "border-style", "border-top", "border-top-color", + "border-top-left-radius", "border-top-right-radius", "border-top-style", "border-top-width", + "border-width", "bottom", "box-decoration-break", "box-shadow", + "box-sizing", "break-after", "break-before", "break-inside", + "caption-side", "caret", "caret-animation", "caret-color", + "caret-shape", "clear", "clip", "clip-path", + "clip-rule", "color", "color-interpolation-filters", "color-scheme", + "column-count", "column-fill", "column-gap", "column-height", + "column-rule", "column-rule-color", "column-rule-style", "column-rule-width", + "column-span", "column-width", "column-wrap", "columns", + "contain", "contain-intrinsic-block-size", "contain-intrinsic-height", "contain-intrinsic-inline-size", + "contain-intrinsic-size", "contain-intrinsic-width", "container", "container-name", + "container-type", "content", "content-visibility", "corner-block-end-shape", + "corner-block-start-shape", "corner-bottom-left-shape", "corner-bottom-right-shape", "corner-bottom-shape", + "corner-end-end-shape", "corner-end-start-shape", "corner-inline-end-shape", "corner-inline-start-shape", + "corner-left-shape", "corner-right-shape", "corner-shape", "corner-start-end-shape", + "corner-start-start-shape", "corner-top-left-shape", "corner-top-right-shape", "corner-top-shape", + "counter-increment", "counter-reset", "counter-set", "cursor", + "cx", "cy", "d", "direction", + "display", "dominant-baseline", "dynamic-range-limit", "empty-cells", + "field-sizing", "fill", "fill-opacity", "fill-rule", + "filter", "flex", "flex-basis", "flex-direction", + "flex-flow", "flex-grow", "flex-line-count", "flex-shrink", + "flex-wrap", "float", "flood-color", "flood-opacity", + "font", "font-family", "font-feature-settings", "font-kerning", + "font-language-override", "font-optical-sizing", "font-palette", "font-size", + "font-size-adjust", "font-smooth", "font-stretch", "font-style", + "font-synthesis", "font-synthesis-position", "font-synthesis-small-caps", "font-synthesis-style", + "font-synthesis-weight", "font-variant", "font-variant-alternates", "font-variant-caps", + "font-variant-east-asian", "font-variant-emoji", "font-variant-ligatures", "font-variant-numeric", + "font-variant-position", "font-variation-settings", "font-weight", "font-width", + "forced-color-adjust", "frame-sizing", "gap", "grid", + "grid-area", "grid-auto-columns", "grid-auto-flow", "grid-auto-rows", + "grid-column", "grid-column-end", "grid-column-gap", "grid-column-start", + "grid-gap", "grid-row", "grid-row-end", "grid-row-gap", + "grid-row-start", "grid-template", "grid-template-areas", "grid-template-columns", + "grid-template-rows", "hanging-punctuation", "height", "hyphenate-character", + "hyphenate-limit-chars", "hyphens", "image-orientation", "image-rendering", + "image-resolution", "ime-mode", "initial-letter", "initial-letter-align", + "inline-size", "inset", "inset-block", "inset-block-end", + "inset-block-start", "inset-inline", "inset-inline-end", "inset-inline-start", + "interactivity", "interest-delay", "interest-delay-end", "interest-delay-start", + "interpolate-size", "isolation", "justify-content", "justify-items", + "justify-self", "justify-tracks", "left", "letter-spacing", + "lighting-color", "line-break", "line-clamp", "line-height", + "line-height-step", "link-parameters", "list-style", "list-style-image", + "list-style-position", "list-style-type", "margin", "margin-block", + "margin-block-end", "margin-block-start", "margin-bottom", "margin-inline", + "margin-inline-end", "margin-inline-start", "margin-left", "margin-right", + "margin-top", "margin-trim", "marker", "marker-end", + "marker-mid", "marker-start", "mask", "mask-border", + "mask-border-mode", "mask-border-outset", "mask-border-repeat", "mask-border-slice", + "mask-border-source", "mask-border-width", "mask-clip", "mask-composite", + "mask-image", "mask-mode", "mask-origin", "mask-position", + "mask-repeat", "mask-size", "mask-type", "masonry-auto-flow", + "math-depth", "math-shift", "math-style", "max-block-size", + "max-height", "max-inline-size", "max-lines", "max-width", + "min-block-size", "min-height", "min-inline-size", "min-width", + "mix-blend-mode", "object-fit", "object-position", "object-view-box", + "offset", "offset-anchor", "offset-distance", "offset-path", + "offset-position", "offset-rotate", "opacity", "order", + "orphans", "outline", "outline-color", "outline-offset", + "outline-style", "outline-width", "overflow", "overflow-anchor", + "overflow-block", "overflow-clip-box", "overflow-clip-margin", "overflow-inline", + "overflow-wrap", "overflow-x", "overflow-y", "overlay", + "overscroll-behavior", "overscroll-behavior-block", "overscroll-behavior-inline", "overscroll-behavior-x", + "overscroll-behavior-y", "padding", "padding-block", "padding-block-end", + "padding-block-start", "padding-bottom", "padding-inline", "padding-inline-end", + "padding-inline-start", "padding-left", "padding-right", "padding-top", + "page", "page-break-after", "page-break-before", "page-break-inside", + "paint-order", "path-length", "perspective", "perspective-origin", + "place-content", "place-items", "place-self", "pointer-events", + "position", "position-anchor", "position-area", "position-try", + "position-try-fallbacks", "position-try-order", "position-visibility", "print-color-adjust", + "quotes", "r", "reading-flow", "reading-order", + "resize", "right", "rotate", "row-gap", + "ruby-align", "ruby-merge", "ruby-overhang", "ruby-position", + "rx", "ry", "scale", "scroll-axis-lock", + "scroll-behavior", "scroll-initial-target", "scroll-margin", "scroll-margin-block", + "scroll-margin-block-end", "scroll-margin-block-start", "scroll-margin-bottom", "scroll-margin-inline", + "scroll-margin-inline-end", "scroll-margin-inline-start", "scroll-margin-left", "scroll-margin-right", + "scroll-margin-top", "scroll-marker-group", "scroll-padding", "scroll-padding-block", + "scroll-padding-block-end", "scroll-padding-block-start", "scroll-padding-bottom", "scroll-padding-inline", + "scroll-padding-inline-end", "scroll-padding-inline-start", "scroll-padding-left", "scroll-padding-right", + "scroll-padding-top", "scroll-snap-align", "scroll-snap-stop", "scroll-snap-type", + "scroll-target-group", "scroll-timeline", "scroll-timeline-axis", "scroll-timeline-name", + "scrollbar-color", "scrollbar-gutter", "scrollbar-width", "shape-image-threshold", + "shape-margin", "shape-outside", "shape-rendering", "speak-as", + "stop-color", "stop-opacity", "stroke", "stroke-color", + "stroke-dasharray", "stroke-dashoffset", "stroke-linecap", "stroke-linejoin", + "stroke-miterlimit", "stroke-opacity", "stroke-width", "tab-size", + "table-layout", "text-align", "text-align-last", "text-anchor", + "text-autospace", "text-box", "text-box-edge", "text-box-trim", + "text-combine-upright", "text-decoration", "text-decoration-color", "text-decoration-inset", + "text-decoration-line", "text-decoration-skip", "text-decoration-skip-ink", "text-decoration-style", + "text-decoration-thickness", "text-emphasis", "text-emphasis-color", "text-emphasis-position", + "text-emphasis-style", "text-fit", "text-indent", "text-justify", + "text-orientation", "text-overflow", "text-rendering", "text-shadow", + "text-size-adjust", "text-spacing-trim", "text-transform", "text-underline-offset", + "text-underline-position", "text-wrap", "text-wrap-mode", "text-wrap-style", + "timeline-scope", "timeline-trigger", "timeline-trigger-activation-range", "timeline-trigger-activation-range-end", + "timeline-trigger-activation-range-start", "timeline-trigger-active-range", "timeline-trigger-active-range-end", "timeline-trigger-active-range-start", + "timeline-trigger-name", "timeline-trigger-source", "top", "touch-action", + "transform", "transform-box", "transform-origin", "transform-style", + "transition", "transition-behavior", "transition-delay", "transition-duration", + "transition-property", "transition-timing-function", "translate", "trigger-scope", + "unicode-bidi", "user-select", "vector-effect", "vertical-align", + "view-timeline", "view-timeline-axis", "view-timeline-inset", "view-timeline-name", + "view-transition-class", "view-transition-name", "view-transition-scope", "visibility", + "white-space", "white-space-collapse", "widows", "width", + "will-change", "window-drag", "word-break", "word-spacing", + "word-wrap", "writing-mode", "x", "y", + "z-index", "zoom", +}; diff --git a/libs/css/src/shorthand.rs b/libs/css/src/shorthand.rs new file mode 100644 index 000000000..a77f6f6bd --- /dev/null +++ b/libs/css/src/shorthand.rs @@ -0,0 +1,237 @@ +use std::{collections::BTreeMap, fmt}; + +use crate::{ + constant::GLOBAL_STYLE_PROPERTY, + property_names::CSS_PROPERTY_NAMES, + utils::{to_camel_case, to_kebab_case}, +}; + +/// A rejected target and its logical position in shorthand configuration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InvalidShorthandTarget { + /// The configured shorthand name. + pub alias: String, + /// The original target spelling, before normalization. + pub target: String, + /// The zero-based position in the target list. + pub index: usize, +} + +impl fmt::Display for InvalidShorthandTarget { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "shorthands[{:?}][{}]: shorthand `{}` cannot use `{}` at build time: its targets must be supported CSS properties, built-in aliases or custom properties", + self.alias, self.index, self.alias, self.target + ) + } +} + +impl std::error::Error for InvalidShorthandTarget {} + +fn is_custom_property(property: &str) -> bool { + property.strip_prefix("--").is_some_and(|name| { + !name.is_empty() + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-') || c >= '\u{80}') + }) +} + +fn normalize_property(property: &str) -> Option { + if is_custom_property(property) || CSS_PROPERTY_NAMES.contains(property) { + return Some(property.to_string()); + } + let kebab = to_kebab_case(property); + let canonical = if ["Webkit", "Moz", "ms", "O"].iter().any(|prefix| { + property.strip_prefix(prefix).is_some_and(|suffix| { + suffix + .as_bytes() + .first() + .is_some_and(u8::is_ascii_uppercase) + }) + }) { + format!("-{kebab}") + } else { + kebab.into_owned() + }; + if !CSS_PROPERTY_NAMES.contains(canonical.as_str()) { + return None; + } + let camel = to_camel_case(&canonical); + let expected = camel + .strip_prefix("Ms") + .map_or_else(|| camel.to_string(), |suffix| format!("ms{suffix}")); + (property == expected).then_some(canonical) +} + +/// Validate every target before producing a normalized registry. +/// +/// # Errors +/// Returns the original alias, target and index of the first unsupported target. +pub fn normalize_shorthands( + shorthands: BTreeMap>, +) -> Result>, InvalidShorthandTarget> { + shorthands + .into_iter() + .map(|(alias, targets)| { + let mut normalized = Vec::new(); + for (index, target) in targets.into_iter().enumerate() { + if let Some(properties) = GLOBAL_STYLE_PROPERTY.get(target.as_str()) { + normalized.extend(properties.iter().map(|property| (*property).to_string())); + } else { + normalized.push(normalize_property(&target).ok_or_else(|| { + InvalidShorthandTarget { + alias: alias.clone(), + target, + index, + } + })?); + } + } + Ok((alias, normalized)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + use serial_test::serial; + + #[rstest] + #[case("marginRight", Some("margin-right"))] + #[case("margin-right", Some("margin-right"))] + #[case("left", Some("left"))] + #[case("WebkitMaskImage", Some("-webkit-mask-image"))] + #[case("-webkit-mask-image", Some("-webkit-mask-image"))] + #[case("MozAppearance", Some("-moz-appearance"))] + #[case("msUserSelect", Some("-ms-user-select"))] + #[case("-ms-user-select", Some("-ms-user-select"))] + #[case("OTransform", None)] + #[case("--My-color", Some("--My-color"))] + #[case("--1", Some("--1"))] + #[case("--색", Some("--색"))] + #[case("--", None)] + #[case("--bad name", None)] + #[case("width ", None)] + #[case("Width", None)] + #[case("widht", None)] + #[case("scrollMargnLeft", None)] + #[case("webkitMaskImage", None)] + #[case("MsUserSelect", None)] + #[case("styleOrder", None)] + #[case("_hover", None)] + #[case("positioning", None)] + #[case("", None)] + fn validates_spelling(#[case] property: &str, #[case] expected: Option<&str>) { + assert_eq!(normalize_property(property).as_deref(), expected); + } + + #[test] + fn rejects_unprefixed_dead_properties() { + for property in [ + "box-align", + "box-pack", + "box-flex", + "box-flex-group", + "box-orient", + "box-ordinal-group", + "box-direction", + "box-lines", + "flex-order", + "flex-positive", + "flex-negative", + "flex-preferred-size", + "scroll-snap-coordinate", + "scroll-snap-destination", + "scroll-snap-points-x", + "scroll-snap-points-y", + "scroll-snap-type-x", + "scroll-snap-type-y", + ] { + assert_eq!(normalize_property(property), None); + assert_eq!(normalize_property(&to_camel_case(property)), None); + } + } + + #[test] + fn preserves_builtin_order_and_empty_lists() -> Result<(), InvalidShorthandTarget> { + let normalized = normalize_shorthands(BTreeMap::from([ + ( + "edges".into(), + vec![ + "left".into(), + "py".into(), + "margin-right".into(), + "--Gap".into(), + ], + ), + ("empty".into(), vec![]), + ]))?; + assert_eq!( + normalized["edges"], + [ + "left", + "padding-top", + "padding-bottom", + "margin-right", + "--Gap" + ] + ); + assert_eq!(normalized["empty"], Vec::::new()); + Ok(()) + } + + #[test] + #[serial] + fn invalid_map_preserves_previous_registry() -> Result<(), InvalidShorthandTarget> { + crate::set_custom_shorthands(BTreeMap::from([("old".into(), vec!["left".into()])]))?; + let error = match crate::set_custom_shorthands(BTreeMap::from([ + ("aValid".into(), vec!["right".into()]), + ("bad".into(), vec!["width".into(), "widht".into()]), + ])) { + Err(error) => error, + Ok(()) => panic!("expected invalid shorthand registration to fail"), + }; + assert_eq!( + error, + InvalidShorthandTarget { + alias: "bad".into(), + target: "widht".into(), + index: 1 + } + ); + assert_eq!( + error.to_string(), + "shorthands[\"bad\"][1]: shorthand `bad` cannot use `widht` at build time: its targets must be supported CSS properties, built-in aliases or custom properties" + ); + assert_eq!(crate::get_custom_shorthand_names(), ["old"]); + assert_eq!( + crate::disassemble_property("old").collect::>(), + ["left"] + ); + crate::set_custom_shorthands(BTreeMap::new())?; + Ok(()) + } + + #[test] + #[serial] + fn registration_recovers_a_poisoned_registry() -> Result<(), InvalidShorthandTarget> { + match std::panic::catch_unwind(|| -> Result<(), Box> { + let _registry = crate::CUSTOM_SHORTHANDS.write()?; + panic!("poison the shorthand registry"); + }) { + Err(_) => {} + Ok(_) => panic!("expected the registry poisoning closure to panic"), + } + crate::set_custom_shorthands(BTreeMap::from([("recovered".into(), vec!["right".into()])]))?; + assert_eq!( + crate::disassemble_property("recovered").collect::>(), + ["right"] + ); + crate::set_custom_shorthands(BTreeMap::new())?; + Ok(()) + } +} diff --git a/libs/css/src/test_state/tests.rs b/libs/css/src/test_state/tests.rs index f786a0ce5..82057e0f9 100644 --- a/libs/css/src/test_state/tests.rs +++ b/libs/css/src/test_state/tests.rs @@ -12,7 +12,10 @@ fn seed() { let _file_num = file_map::get_file_num_by_filename("outer.tsx"); file_map::set_canonical_map(HashMap::from([("member".into(), "root".into())])); file_routes::set_file_routes(HashMap::from([("outer.tsx".into(), HashSet::from([2]))])); - crate::set_custom_shorthands(BTreeMap::from([("insetX".into(), vec!["left".into()])])); + assert_eq!( + crate::set_custom_shorthands(BTreeMap::from([("insetX".into(), vec!["left".into()])])), + Ok(()) + ); theme_tokens::set_theme_token_levels( BTreeMap::from([("space".into(), vec![0, 2])]), BTreeMap::from([("card".into(), vec![0, 3])]), diff --git a/libs/css/src/theme_tokens.rs b/libs/css/src/theme_tokens.rs index 87ce05d85..272685194 100644 --- a/libs/css/src/theme_tokens.rs +++ b/libs/css/src/theme_tokens.rs @@ -289,7 +289,10 @@ mod tests { fn scope_restores_registries_when_private_locks_are_poisoned() { let _state = crate::test_state::TestStateGuard::new(); set_typography_keys(vec!["outer".into()]); - crate::set_custom_shorthands(BTreeMap::from([("alias".into(), vec!["left".into()])])); + assert_eq!( + crate::set_custom_shorthands(BTreeMap::from([("alias".into(), vec!["left".into()])])), + Ok(()) + ); let nested = crate::test_state::TestStateGuard::new(); for poison_tokens in [false, true] { let result = std::panic::catch_unwind(|| { diff --git a/libs/extractor/src/ci_factory_alias_native_tests.rs b/libs/extractor/src/ci_factory_alias_native_tests.rs index 07a021e2c..6707f4785 100644 --- a/libs/extractor/src/ci_factory_alias_native_tests.rs +++ b/libs/extractor/src/ci_factory_alias_native_tests.rs @@ -39,7 +39,7 @@ impl Shape { Self::Array => "className: ['flex', OWN].join(' ')", Self::Object => "className: clsx({ 'flex': OWN })", Self::TailwindTemplate => "className: `flex ${OWN}`", - Self::StyleOrder => "className: OWN, color: 'red', styleOrder: h ? 1 : 0", + Self::StyleOrder => "className: OWN, color: 'red', styleOrder: h ? 1 : 2", } } } diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index 74b558f80..e907dd326 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -8,8 +8,7 @@ use crate::{ }, extractor::{ExtractResult, extract_style_from_member_expression::extract_member}, utils::{ - CSS_TEXT, RESPONSIVE_ARRAY, SELECTOR_NAME, expression_to_code, - get_number_by_literal_expression, get_str_by_property_key, + CSS_TEXT, RESPONSIVE_ARRAY, SELECTOR_NAME, expression_to_code, get_str_by_property_key, get_string_by_literal_expression, get_string_by_property_key, is_same_expression, readable_code, unwrap_syntax_only, unwrap_syntax_only_mut, }, @@ -323,8 +322,7 @@ pub(super) fn extract_projected<'a, const LOCATE: bool>( while let Some(disassembled) = properties.next() { let disassembled: &str = &disassembled; if name == "styleOrder" { - style_order = get_number_by_literal_expression(&prop.value) - .map(|v| v as u8); + style_order = crate::style_order::static_order(&prop.value); } else if name == "styleVars" { style_vars = Some(prop.value.clone_in_with_semantic_ids( diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 648e1aaf8..7f6ce3821 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -228,6 +228,9 @@ impl LibraryNumbers { } fn pixelify_value(&mut self, key: &str, value: &Expression) { + if key == "styleOrder" { + return; + } if let Some(number) = js_number_literal(value) { if number != 0.0 && !keeps_bare_number(key) { let span = value.span(); diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 533dc4364..fb0daaeed 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -63,8 +63,18 @@ mod retained_render_support; mod scope; #[cfg(test)] mod selector_array_units_tests; +#[cfg(test)] +mod shorthand_tests; mod source_map; mod style_collector; +mod style_order; +#[cfg(test)] +mod style_order_css_prop_tests; +#[cfg(test)] +mod style_order_styled_tests; +#[cfg(test)] +mod style_order_tests; +mod style_order_validation; mod style_values; mod styled_reads; #[cfg(test)] @@ -7586,7 +7596,7 @@ export { reset_class_map(); reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( + assert_debug_snapshot!( extract( "test.jsx", r#"import {Box, css} from '@devup-ui/core' @@ -7600,8 +7610,9 @@ export { import_aliases: HashMap::new() } ) - .unwrap() - )); + .unwrap_err() + .to_string() + ); } #[test] diff --git a/libs/extractor/src/shorthand_tests.rs b/libs/extractor/src/shorthand_tests.rs new file mode 100644 index 000000000..91842b2d7 --- /dev/null +++ b/libs/extractor/src/shorthand_tests.rs @@ -0,0 +1,169 @@ +use std::collections::BTreeMap; + +use css::{class_map::reset_class_map, file_map::reset_file_map, set_custom_shorthands}; +use serial_test::serial; + +use crate::{ + ExtractOption, ExtractOutput, extract, extract_style::extract_style_value::ExtractStyleValue, +}; + +fn extract_element(element: &str) -> Result> { + reset_class_map(); + reset_file_map(); + extract( + "shorthand.tsx", + &format!("import {{ Box }} from '@devup-ui/react'; export const element = {element};"), + ExtractOption { + package: "@devup-ui/react".into(), + single_css: true, + import_main_css: false, + ..ExtractOption::default() + }, + ) +} + +fn static_properties(output: &ExtractOutput) -> Vec<(String, String, u8)> { + let mut styles: Vec<_> = output + .styles + .iter() + .map(|value| { + let ExtractStyleValue::Static(style) = value else { + panic!("expected static shorthand styles") + }; + (style.property.clone(), style.value.clone(), style.level) + }) + .collect(); + styles.sort(); + styles +} + +#[test] +#[serial] +fn shorthand_expands_builtin_and_camel_targets() -> Result<(), Box> { + set_custom_shorthands(BTreeMap::from([( + "insetX".into(), + vec!["left".into(), "marginRight".into(), "py".into()], + )]))?; + let output = extract_element("")?; + assert_eq!( + static_properties(&output), + vec![ + ("left".into(), "7px".into(), 0), + ("margin-right".into(), "7px".into(), 0), + ("padding-bottom".into(), "7px".into(), 0), + ("padding-top".into(), "7px".into(), 0), + ] + ); + assert!(!output.code.contains("insetX=")); + assert!(output.code.contains("className=")); + set_custom_shorthands(BTreeMap::new())?; + Ok(()) +} + +#[test] +#[serial] +fn shorthand_preserves_responsive_levels_and_hover() -> Result<(), Box> { + set_custom_shorthands(BTreeMap::from([( + "insetX".into(), + vec!["left".into(), "right".into()], + )]))?; + let output = extract_element("")?; + assert_eq!( + static_properties(&output), + vec![ + ("left".into(), "1px".into(), 0), + ("left".into(), "3px".into(), 2), + ("right".into(), "1px".into(), 0), + ("right".into(), "3px".into(), 2), + ] + ); + for value in &output.styles { + let ExtractStyleValue::Static(style) = value else { + panic!("expected static hover styles") + }; + assert_eq!( + style + .selector + .as_ref() + .map(|selector| selector.as_class_str().into_owned()), + Some("&:hover".into()) + ); + } + set_custom_shorthands(BTreeMap::new())?; + Ok(()) +} + +#[test] +#[serial] +fn shorthand_yields_to_later_explicit_target() -> Result<(), Box> { + set_custom_shorthands(BTreeMap::from([( + "insetX".into(), + vec!["left".into(), "right".into()], + )]))?; + let output = extract_element("")?; + assert_eq!( + static_properties(&output), + vec![ + ("left".into(), "9px".into(), 0), + ("right".into(), "1px".into(), 0) + ] + ); + set_custom_shorthands(BTreeMap::new())?; + Ok(()) +} + +#[test] +#[serial] +fn shorthand_dynamic_values_stay_on_the_element() -> Result<(), Box> { + set_custom_shorthands(BTreeMap::from([( + "insetX".into(), + vec!["left".into(), "right".into()], + )]))?; + let output = extract_element("")?; + let mut properties: Vec<_> = output + .styles + .iter() + .map(|value| { + let ExtractStyleValue::Dynamic(style) = value else { + panic!("expected dynamic shorthand styles") + }; + assert_eq!(style.identifier(), "position"); + style.property().to_string() + }) + .collect(); + properties.sort(); + assert_eq!(properties, ["left", "right"]); + assert!(output.code.contains("style=")); + assert!(!output.code.contains("insetX=")); + set_custom_shorthands(BTreeMap::new())?; + Ok(()) +} + +#[test] +#[serial] +fn rejected_registration_cannot_change_extraction() -> Result<(), Box> { + set_custom_shorthands(BTreeMap::from([( + "insetX".into(), + vec!["left".into(), "right".into()], + )]))?; + let error = match set_custom_shorthands(BTreeMap::from([( + "insetX".into(), + vec!["width".into(), "widht".into()], + )])) { + Err(error) => error, + Ok(()) => panic!("expected invalid shorthand registration to fail"), + }; + assert_eq!(error.alias, "insetX"); + assert_eq!(error.target, "widht"); + assert_eq!(error.index, 1); + let output = extract_element("")?; + assert_eq!( + static_properties(&output), + vec![ + ("left".into(), "4px".into(), 0), + ("right".into(), "4px".into(), 0) + ] + ); + set_custom_shorthands(BTreeMap::new())?; + Ok(()) +} diff --git a/libs/extractor/src/snapshots/extractor__tests__style_order-8.snap b/libs/extractor/src/snapshots/extractor__tests__style_order-8.snap index ed029a473..e7a55d1a4 100644 --- a/libs/extractor/src/snapshots/extractor__tests__style_order-8.snap +++ b/libs/extractor/src/snapshots/extractor__tests__style_order-8.snap @@ -1,19 +1,5 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr#\"import {Box, css} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "extract(\"test.jsx\",\nr#\"import {Box, css} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap_err().to_string()" --- -ToBTreeSet { - styles: { - Static( - ExtractStaticStyle { - property: "color", - value: "white", - level: 0, - selector: None, - style_order: None, - layer: None, - }, - ), - }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", -} +"test.jsx:2:55: `styleOrder()` cannot use `null` at build time: an explicit styleOrder must be an integer from 1 to 254; a string must be canonical decimal digits without a sign, spaces or leading zeros: use a numeric literal such as 1 or a canonical decimal string such as \"1\"" diff --git a/libs/extractor/src/style_order.rs b/libs/extractor/src/style_order.rs new file mode 100644 index 000000000..5a05d7527 --- /dev/null +++ b/libs/extractor/src/style_order.rs @@ -0,0 +1,118 @@ +//! Parsing reserved cascade orders without changing general numeric coercion. + +use boa_engine::JsString; +use oxc_allocator::{Allocator, CloneIn}; +use oxc_ast::ast::{Expression, JSXAttributeValue}; +use oxc_syntax::operator::{LogicalOperator, UnaryOperator}; + +use crate::utils::{ParsedStyleOrder, build_time_error, unwrap_syntax_only}; + +/// Canonical decimal digits naming one of the user-addressable layers. +pub(crate) fn string_order(value: &str) -> Option { + if !value + .as_bytes() + .first() + .copied() + .is_some_and(|byte| matches!(byte, b'1'..=b'9')) + || !value.bytes().all(|byte| byte.is_ascii_digit()) + { + return None; + } + value + .parse::() + .ok() + .filter(|order| (1..=254).contains(order)) +} + +/// A finite integer order or a canonical decimal string, never a truncated cast. +pub(crate) fn static_order(value: &Expression<'_>) -> Option { + let value = unwrap_syntax_only(value); + match value { + Expression::StringLiteral(literal) => string_order(literal.value.as_str()), + Expression::TemplateLiteral(template) if template.expressions.is_empty() => template + .quasis + .first() + .and_then(|quasi| quasi.value.cooked.as_ref()) + .and_then(|cooked| string_order(cooked.as_str())), + Expression::NumericLiteral(_) | Expression::UnaryExpression(_) => number_value(value) + .and_then(|number| number.to_string().parse::().ok()) + .filter(|order| (1..=254).contains(order)), + _ => None, + } +} + +/// Preserve literal numeric coercion for unary orders, never template interpolations. +fn number_value(value: &Expression<'_>) -> Option { + match unwrap_syntax_only(value) { + Expression::NumericLiteral(literal) => Some(literal.value), + Expression::StringLiteral(literal) => { + Some(JsString::from(literal.value.as_str()).to_number()) + } + Expression::BooleanLiteral(literal) => Some(f64::from(u8::from(literal.value))), + Expression::NullLiteral(_) => Some(0.0), + Expression::TemplateLiteral(template) if template.expressions.is_empty() => template + .quasis + .first() + .and_then(|quasi| quasi.value.cooked.as_ref()) + .map(|cooked| JsString::from(cooked.as_str()).to_number()), + Expression::UnaryExpression(unary) => match unary.operator { + UnaryOperator::UnaryNegation => number_value(&unary.argument).map(|number| -number), + UnaryOperator::UnaryPlus => number_value(&unary.argument), + UnaryOperator::LogicalNot + | UnaryOperator::BitwiseNot + | UnaryOperator::Typeof + | UnaryOperator::Void + | UnaryOperator::Delete => None, + }, + _ => None, + } +} + +/// Preserve the base's conditional representation and unresolved runtime fallback. +pub(crate) fn expression_order<'a>( + value: &Expression<'a>, + allocator: &'a Allocator, +) -> ParsedStyleOrder<'a> { + match unwrap_syntax_only(value) { + Expression::ConditionalExpression(conditional) => ParsedStyleOrder::Conditional { + condition: conditional.test.clone_in(allocator), + consequent: static_order(&conditional.consequent), + alternate: static_order(&conditional.alternate), + }, + Expression::LogicalExpression(logical) if logical.operator == LogicalOperator::And => { + ParsedStyleOrder::Conditional { + condition: logical.left.clone_in(allocator), + consequent: static_order(&logical.right), + alternate: None, + } + } + value => static_order(value).map_or(ParsedStyleOrder::None, ParsedStyleOrder::Static), + } +} + +/// Parse JSX strings as strings, not through the shared floating-point reader. +pub(crate) fn attribute_order<'a>( + value: &JSXAttributeValue<'a>, + allocator: &'a Allocator, +) -> ParsedStyleOrder<'a> { + match value { + JSXAttributeValue::StringLiteral(literal) => string_order(literal.value.as_str()) + .map_or(ParsedStyleOrder::None, ParsedStyleOrder::Static), + JSXAttributeValue::ExpressionContainer(container) => container + .expression + .as_expression() + .map_or(ParsedStyleOrder::None, |value| { + expression_order(value, allocator) + }), + JSXAttributeValue::Element(_) | JSXAttributeValue::Fragment(_) => ParsedStyleOrder::None, + } +} + +/// Explain an invalid explicit order through the existing build-time error family. +pub(crate) fn invalid_order(code: &str) -> String { + build_time_error( + "styleOrder", + code, + "an explicit styleOrder must be an integer from 1 to 254; a string must be canonical decimal digits without a sign, spaces or leading zeros: use a numeric literal such as 1 or a canonical decimal string such as \"1\"", + ) +} diff --git a/libs/extractor/src/style_order_css_prop_tests.rs b/libs/extractor/src/style_order_css_prop_tests.rs new file mode 100644 index 000000000..aeb5c4e10 --- /dev/null +++ b/libs/extractor/src/style_order_css_prop_tests.rs @@ -0,0 +1,208 @@ +use std::{collections::HashMap, error::Error}; + +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +use crate::{ + ExtractOption, ImportAlias, extract, extract_style::extract_style_value::ExtractStyleValue, +}; + +const FIXTURES: &str = include_str!("../../../test-fixtures/style-order.json"); + +fn emotion_option() -> ExtractOption { + ExtractOption { + import_aliases: HashMap::from([ + ("@emotion/react".to_string(), ImportAlias::NamedToNamed), + ( + "@emotion/react/jsx-runtime".to_string(), + ImportAlias::NamedToNamed, + ), + ]), + ..ExtractOption::default() + } +} + +fn source_for(runtime: bool, value: &str) -> String { + if runtime { + format!( + "import {{css}} from '@emotion/react';\nimport {{jsx as render}} from '@emotion/react/jsx-runtime';\nexport const App=()=> render('div', {{css: {value}}});" + ) + } else { + format!( + "import {{css}} from '@emotion/react';\nexport const App=()=>
;" + ) + } +} + +fn location(source: &str, leaf: &str) -> Result> { + let offset = source.rfind(leaf).ok_or("missing offending value")?; + let before = source.get(..offset).ok_or("invalid fixture offset")?; + let line = before.bytes().filter(|byte| *byte == b'\n').count() + 1; + let column = before + .rsplit('\n') + .next() + .ok_or("missing line")? + .chars() + .count() + + 1; + Ok(format!("css-order.tsx:{line}:{column}:")) +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn css_prop_rejects_original_quoted_value_when_css_call_is_consumed( + #[case] runtime: bool, +) -> Result<(), Box> { + // Given + let source = if runtime { + source_for(true, "css({color:'red',styleOrder:'100px'})") + } else { + "import {css} from '@emotion/react'; export const App=()=>
".to_string() + }; + reset_class_map(); + reset_file_map(); + let expected = location(&source, "'100px'")?; + // When + let error = extract("css-order.tsx", &source, emotion_option()) + .err() + .ok_or("malformed order unexpectedly compiled")? + .to_string(); + // Then + assert!( + error.lines().any(|line| line.starts_with(&expected)), + "{error}" + ); + assert!(error.contains("`'100px'`"), "{error}"); + Ok(()) +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn css_prop_reports_original_branch_when_nested_composition_is_consumed( + #[case] runtime: bool, +) -> Result<(), Box> { + // Given + let source = source_for( + runtime, + "css({margin:0}, [css({color:'red',styleOrder:on ? 2 : '\\u0030\\u0031'})])", + ); + reset_class_map(); + reset_file_map(); + let expected = location(&source, "'\\u0030\\u0031'")?; + // When + let error = extract("css-order.tsx", &source, emotion_option()) + .err() + .ok_or("malformed branch unexpectedly compiled")? + .to_string(); + // Then + assert!( + error.lines().any(|line| line.starts_with(&expected)), + "{error}" + ); + assert!(error.contains("`'\\u0030\\u0031'`"), "{error}"); + Ok(()) +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn css_prop_follows_shared_fixtures_when_rules_are_consumed( + #[case] runtime: bool, + #[values( + "RULES", + "css(RULES)", + "css({margin:0}, [css(RULES)])", + "[{margin:0}, [css(RULES)]]" + )] + shape: &str, +) -> Result<(), Box> { + let fixtures: serde_json::Value = serde_json::from_str(FIXTURES)?; + for fixture in fixtures.as_array().ok_or("expected fixture array")? { + // Given + let expression = fixture["expression"].as_str().ok_or("missing expression")?; + let valid = fixture["valid"].as_bool().ok_or("missing validity")?; + let rules = format!("{{color:'red',styleOrder:{expression}}}"); + let source = source_for(runtime, &shape.replace("RULES", &rules)); + reset_class_map(); + reset_file_map(); + // When + let result = extract("css-order.tsx", &source, emotion_option()); + // Then + if valid { + result.map_err(|error| format!("{source}\n{error}"))?; + } else { + let error = result + .err() + .ok_or_else(|| format!("invalid order compiled: {source}"))? + .to_string(); + let leaf = expression + .rsplit_once(" : ") + .or_else(|| expression.rsplit_once(" && ")) + .map_or(expression, |(_, leaf)| leaf); + let expected = location(&source, leaf)?; + assert!( + error.lines().any(|line| line.starts_with(&expected)), + "{source}\n{error}" + ); + assert!(error.contains("`styleOrder()` cannot use"), "{error}"); + } + } + Ok(()) +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn css_prop_selects_expected_layer_when_static_order_is_valid( + #[case] runtime: bool, + #[values( + "RULES", + "css(RULES)", + "css({margin:0}, [css(RULES)])", + "[{margin:0}, [css(RULES)]]" + )] + shape: &str, +) -> Result<(), Box> { + for (expression, expected) in [ + ("1", 1), + ("254", 254), + ("100.0", 100), + ("1e2", 100), + ("'100'", 100), + ("`100`", 100), + ("+'01'", 1), + ("+'0x10'", 16), + ("+'1e2'", 100), + ("+'1.0'", 1), + ("+true", 1), + ("-'-1'", 1), + ] { + // Given + let rules = format!("{{color:'red',styleOrder:{expression}}}"); + let source = source_for(runtime, &shape.replace("RULES", &rules)); + reset_class_map(); + reset_file_map(); + // When + let output = extract("css-order.tsx", &source, emotion_option())?; + // Then + let orders: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) if style.property == "color" => { + Some(style.style_order) + } + _ => None, + }) + .collect(); + assert_eq!(orders, vec![Some(expected)], "{source}"); + } + Ok(()) +} diff --git a/libs/extractor/src/style_order_styled_tests.rs b/libs/extractor/src/style_order_styled_tests.rs new file mode 100644 index 000000000..c355fd5fd --- /dev/null +++ b/libs/extractor/src/style_order_styled_tests.rs @@ -0,0 +1,37 @@ +use crate::{ExtractOption, extract}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("styled.div([{ color: 'red', styleOrder: '01' }, { color: 'blue' }])")] +#[case("styled('div', [{ color: 'red', styleOrder: '01' }, { color: 'blue' }])")] +#[case("styled('div')([flag && { color: 'red', styleOrder: '01' }, { color: 'blue' }])")] +#[case("styled.div([flag ? { color: 'red', styleOrder: '01' } : { color: 'blue' }])")] +#[serial] +fn invalid_order_is_rejected_when_styled_rules_compose( + #[case] expression: &str, +) -> Result<(), Box> { + // Given + let source = + format!("import {{ styled }} from '@devup-ui/react';\nexport const Card = {expression};"); + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + // When + let result = extract("styled-order.tsx", &source, ExtractOption::default()); + // Then + let error = result + .err() + .ok_or("invalid composed order must not compile")? + .to_string(); + let column = "export const Card = ".len() + expression.find("'01'").ok_or("missing order")? + 1; + assert!( + error.contains(&format!("styled-order.tsx:2:{column}:")), + "{error}" + ); + assert!( + error.contains("`styleOrder()` cannot use `'01'`"), + "{error}" + ); + assert!(error.contains("an integer from 1 to 254"), "{error}"); + Ok(()) +} diff --git a/libs/extractor/src/style_order_tests.rs b/libs/extractor/src/style_order_tests.rs new file mode 100644 index 000000000..b9b32644f --- /dev/null +++ b/libs/extractor/src/style_order_tests.rs @@ -0,0 +1,268 @@ +use std::error::Error; + +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use oxc_allocator::Allocator; +use oxc_parser::Parser; +use oxc_span::SourceType; +use serial_test::serial; + +use crate::style_order::static_order; +use crate::utils::ParsedStyleOrder; +use crate::{ExtractOption, extract}; + +const FIXTURES: &str = include_str!("../../../test-fixtures/style-order.json"); +const IMPORTS: &str = "import { Box, css, styled, keyframes } from '@devup-ui/react';\nimport { jsx as render } from 'react/jsx-runtime';\n"; + +fn expected_location(source: &str, expression: &str) -> Result> { + let leaf = expression + .rsplit_once(" : ") + .or_else(|| expression.rsplit_once(" && ")) + .map_or(expression, |(_, leaf)| leaf); + let root = source + .rfind(expression) + .ok_or("missing fixture expression")?; + let relative = expression.rfind(leaf).ok_or("missing fixture branch")?; + let before = source + .get(..root + relative) + .ok_or("invalid fixture offset")?; + let line = before.bytes().filter(|byte| *byte == b'\n').count() + 1; + let column = before + .rsplit('\n') + .next() + .ok_or("missing fixture line")? + .chars() + .count() + + 1; + Ok(format!("order.tsx:{line}:{column}:")) +} + +fn check_case(source: &str, expression: &str, valid: bool) -> Result<(), Box> { + reset_class_map(); + reset_file_map(); + let output = extract("order.tsx", source, ExtractOption::default()); + if valid { + assert!(output.is_ok(), "{expression}: {output:?}"); + } else { + let error = output + .err() + .ok_or_else(|| format!("invalid styleOrder unexpectedly compiled: {source}"))? + .to_string(); + let expected = expected_location(source, expression)?; + assert!( + error.lines().any(|line| line.starts_with(&expected)), + "{expected}\n{error}" + ); + assert!(error.contains("`styleOrder()` cannot use"), "{error}"); + assert!(error.contains("an integer from 1 to 254"), "{error}"); + assert!(error.contains("use a numeric literal"), "{error}"); + } + Ok(()) +} + +#[test] +#[serial] +fn consumed_orders_follow_shared_lint_fixtures() -> Result<(), Box> { + let fixtures: serde_json::Value = serde_json::from_str(FIXTURES)?; + for fixture in fixtures + .as_array() + .ok_or("styleOrder fixtures must be an array")? + { + let expression = fixture["expression"].as_str().ok_or("missing expression")?; + let valid = fixture["valid"].as_bool().ok_or("missing validity")?; + for body in [ + format!("const view = ;"), + format!("const view = render(Box, {{ bg: 'red', styleOrder: {expression} }});"), + format!("const rule = css({{ color: 'red', styleOrder: {expression} }});"), + format!("const Card = styled('div', {{ color: 'red', styleOrder: {expression} }});"), + format!( + "const fade = keyframes({{ from: {{ opacity: 0, styleOrder: {expression} }} }});" + ), + ] { + let source = format!("{IMPORTS}{body}"); + check_case(&source, expression, valid)?; + } + if expression.starts_with('\'') { + let source = format!("{IMPORTS}const view = ;"); + check_case(&source, expression, valid)?; + } + } + Ok(()) +} + +#[test] +fn scalar_orders_distinguish_number_values_and_cooked_strings() -> Result<(), Box> { + let allocator = Allocator::default(); + for (source, expected) in [ + ("1", Some(1)), + ("254", Some(254)), + ("'1'", Some(1)), + ("'254'", Some(254)), + ("100.0", Some(100)), + ("1e2", Some(100)), + ("+100", Some(100)), + ("-(-1)", Some(1)), + ("+\"1\"", Some(1)), + ("+\"01\"", Some(1)), + ("-\"-1\"", Some(1)), + ("+`01`", Some(1)), + ("+`\\u0031`", Some(1)), + ("+(\"1\" as const)", Some(1)), + ("+\"1.5\"", None), + ("+\"255\"", None), + ("+\"nope\"", None), + ("!1", None), + ("~1", None), + ("typeof 1", None), + ("'100.0'", None), + ("'1e2'", None), + ("'01'", None), + ("`\\u0031`", Some(1)), + ("`\\u0031\\u0030`", Some(10)), + ("`\\u0030\\u0031`", None), + ("`\\u0020\\u0031`", None), + ("0", None), + ("255", None), + ("1.5", None), + ("-1", None), + ("1e999", None), + ("`1${runtime}`", None), + ("+`1${runtime}`", None), + ("-(+`1${runtime}`)", None), + ("+runtime", None), + ] { + let expression = Parser::new(&allocator, source, SourceType::ts()) + .parse_expression() + .map_err(|error| format!("{error:?}"))?; + assert_eq!(static_order(&expression), expected, "{source}"); + } + Ok(()) +} + +#[test] +fn conditional_orders_keep_the_implicit_false_branch_absent() -> Result<(), Box> { + let allocator = Allocator::default(); + let expression = Parser::new(&allocator, "on && 2", SourceType::ts()) + .parse_expression() + .map_err(|error| format!("{error:?}"))?; + let order = crate::style_order::expression_order(&expression, &allocator); + let ParsedStyleOrder::Conditional { + consequent, + alternate, + .. + } = order + else { + panic!("logical styleOrder must retain its condition"); + }; + assert_eq!((consequent, alternate), (Some(2), None)); + Ok(()) +} + +#[test] +#[serial] +fn order_validation_keeps_pass_through_data_and_runtime_authority_unchanged() { + let source = format!("{IMPORTS} +const data = {{ styleOrder: '100px' }}; +const native =
; +const view = ; +const styledData = styled.div.attrs({{ styleOrder: '100px' }})({{ color: 'red' }}); +export const Runtime = (order) => ; +export const Shadowed = (undefined) => ;"); + reset_class_map(); + reset_file_map(); + assert!(extract("order.tsx", &source, ExtractOption::default()).is_ok()); +} + +#[test] +#[serial] +fn invalid_order_reports_the_written_branch_and_value_location() -> Result<(), Box> { + let expression = "on ? 1 : '100px'"; + let source = format!("{IMPORTS}const view = ;"); + reset_class_map(); + reset_file_map(); + let error = extract("order.tsx", &source, ExtractOption::default()) + .err() + .ok_or("invalid branch unexpectedly compiled")? + .to_string(); + let expected = expected_location(&source, expression)?; + assert!(error.contains("`'100px'`"), "{error}"); + assert!( + error.lines().any(|line| line.starts_with(&expected)), + "{error}" + ); + Ok(()) +} + +#[test] +fn style_order_validation_checks_only_consumed_roots() -> Result<(), Box> { + let allocator = Allocator::default(); + let bindings = crate::style_values::StyleValues::default(); + for (source, count) in [ + ("({styleOrder: 1n})", 1), + ("({styleOrder: /x/})", 1), + ("({styleOrder: []})", 1), + ("({styleOrder: {}})", 1), + ("({styleOrder: class {}})", 1), + ("({styleOrder: () => 1})", 1), + ("({styleOrder: function() {}})", 1), + ("({styleOrder: NaN})", 1), + ("({styleOrder: Infinity})", 1), + ("({styleOrder: void effect()})", 0), + ("({styleOrder: !runtime})", 0), + ("({styleOrder: ~runtime})", 0), + ("({styleOrder: typeof runtime})", 0), + ("({styleOrder: +`1${runtime}`})", 0), + ("({styleOrder: +\"nope\"})", 1), + ("({styleOrder: !1})", 1), + ("({styleOrder: ~1})", 1), + ("({styleOrder: typeof 1})", 1), + ("({styleOrder: !!1})", 1), + ("({styleOrder: delete 1})", 1), + ("({styleOrder: `1${runtime}`})", 0), + ("({...{styleOrder: 0}})", 1), + ("[{styleOrder: 0}, , ...runtime]", 1), + ("on ? {styleOrder: 0} : {styleOrder: 255}", 2), + ("on && {styleOrder: 0}", 1), + ("runtime", 0), + ("({color: {styleOrder: 0}})", 0), + ] { + let value = Parser::new(&allocator, source, SourceType::tsx()) + .parse_expression() + .map_err(|error| format!("{error:?}"))?; + let mut errors = Vec::new(); + crate::style_order_validation::validate_rules(&value, &bindings, &mut errors); + assert_eq!(errors.len(), count, "{source}: {errors:?}"); + } + Ok(()) +} + +#[test] +#[serial] +fn style_order_jsx_shapes_report_written_values() -> Result<(), Box> { + for (attribute, value) in [ + ("styleOrder", "styleOrder"), + ("styleOrder=", ""), + ("styleOrder=<>", "<>"), + ("styleOrder={}", ""), + ("styleOrder={<>}", "<>"), + ("styleOrder={on ? '01' : 2}", "'01'"), + ("styleOrder={!1}", "!1"), + ("styleOrder={~1}", "~1"), + ("styleOrder={typeof 1}", "typeof 1"), + ("styleOrder={on ? 2 : '\\u0030\\u0031'}", "'\\u0030\\u0031'"), + ] { + let source = format!("{IMPORTS}const view = ;"); + reset_class_map(); + reset_file_map(); + let error = extract("order.tsx", &source, ExtractOption::default()) + .err() + .ok_or("invalid JSX order compiled")? + .to_string(); + assert!(error.contains(&format!("`{value}`")), "{error}"); + let expected = expected_location(&source, value)?; + assert!( + error.lines().any(|line| line.starts_with(&expected)), + "{error}" + ); + } + Ok(()) +} diff --git a/libs/extractor/src/style_order_validation.rs b/libs/extractor/src/style_order_validation.rs new file mode 100644 index 000000000..5401944f3 --- /dev/null +++ b/libs/extractor/src/style_order_validation.rs @@ -0,0 +1,256 @@ +//! Validate only the root order fields consumed by style extraction. + +use oxc_allocator::{Allocator, CloneIn}; +use oxc_ast::ast::{Expression, JSXAttributeValue, ObjectPropertyKind}; +use oxc_span::GetSpan; +use oxc_syntax::operator::{LogicalOperator, UnaryOperator}; + +use crate::style_order::{invalid_order, static_order, string_order}; +use crate::style_values::StyleValues; +use crate::utils::{get_str_by_property_key, is_pure, readable_code, unwrap_syntax_only}; + +struct Validation<'s, 'a> { + bindings: &'s StyleValues<'a>, + errors: &'s mut Vec<(u32, String)>, +} + +impl Validation<'_, '_> { + fn value(&mut self, value: &Expression<'_>) { + let value = unwrap_syntax_only(value); + if static_order(value).is_some() { + return; + } + match value { + Expression::ConditionalExpression(conditional) => { + self.value(&conditional.consequent); + self.value(&conditional.alternate); + } + Expression::LogicalExpression(logical) if logical.operator == LogicalOperator::And => { + self.value(&logical.right); + } + value if self.static_invalid(value) => { + let code = match value { + Expression::StringLiteral(literal) => literal + .raw + .as_ref() + .map_or_else(|| readable_code(value), ToString::to_string), + _ => readable_code(value), + }; + self.errors.push((value.span().start, invalid_order(&code))); + } + _ => {} + } + } + + fn static_invalid(&self, value: &Expression<'_>) -> bool { + match value { + Expression::NumericLiteral(_) + | Expression::StringLiteral(_) + | Expression::BooleanLiteral(_) + | Expression::NullLiteral(_) + | Expression::BigIntLiteral(_) + | Expression::RegExpLiteral(_) + | Expression::ArrayExpression(_) + | Expression::ObjectExpression(_) + | Expression::ClassExpression(_) + | Expression::JSXElement(_) + | Expression::JSXFragment(_) + | Expression::ArrowFunctionExpression(_) + | Expression::FunctionExpression(_) => true, + Expression::TemplateLiteral(template) => template.expressions.is_empty(), + Expression::Identifier(identifier) => { + matches!(identifier.name.as_str(), "undefined" | "NaN" | "Infinity") + && self.bindings.symbol(value).is_none() + } + Expression::UnaryExpression(unary) => match unary.operator { + UnaryOperator::Void => is_pure(&unary.argument), + UnaryOperator::UnaryNegation + | UnaryOperator::UnaryPlus + | UnaryOperator::LogicalNot + | UnaryOperator::BitwiseNot + | UnaryOperator::Typeof + | UnaryOperator::Delete => { + let argument = unwrap_syntax_only(&unary.argument); + static_order(argument).is_some() || self.static_invalid(argument) + } + }, + _ => false, + } + } + + fn rules(&mut self, value: &Expression<'_>) { + match unwrap_syntax_only(value) { + Expression::ObjectExpression(object) => { + for property in &object.properties { + match property { + ObjectPropertyKind::ObjectProperty(property) + if get_str_by_property_key(&property.key).as_deref() + == Some("styleOrder") => + { + self.value(&property.value); + } + ObjectPropertyKind::SpreadProperty(spread) => self.rules(&spread.argument), + ObjectPropertyKind::ObjectProperty(_) => {} + } + } + } + Expression::ArrayExpression(array) => { + for element in &array.elements { + if let Some(value) = element.as_expression() { + self.rules(value); + } + } + } + Expression::ConditionalExpression(conditional) => { + self.rules(&conditional.consequent); + self.rules(&conditional.alternate); + } + Expression::LogicalExpression(logical) if logical.operator == LogicalOperator::And => { + self.rules(&logical.right); + } + _ => {} + } + } +} + +/// Check declaration roots and composition parts, never arbitrary child data. +pub(crate) fn validate_rules( + value: &Expression<'_>, + bindings: &StyleValues, + errors: &mut Vec<(u32, String)>, +) { + Validation { bindings, errors }.rules(value); +} + +/// Frame declaration roots use the shared object-style extractor. +pub(crate) fn validate_frames( + value: &Expression<'_>, + bindings: &StyleValues, + errors: &mut Vec<(u32, String)>, +) { + if let Expression::ObjectExpression(object) = unwrap_syntax_only(value) { + let mut validation = Validation { bindings, errors }; + for property in &object.properties { + if let ObjectPropertyKind::ObjectProperty(property) = property { + validation.rules(&property.value); + } + } + } +} + +/// Reject invalid explicit JSX values at their own span before they are consumed. +pub(crate) fn validate_attribute( + value: &JSXAttributeValue<'_>, + bindings: &StyleValues, + errors: &mut Vec<(u32, String)>, +) { + match value { + JSXAttributeValue::ExpressionContainer(container) => { + if let Some(value) = container.expression.as_expression() { + Validation { bindings, errors }.value(value); + } + } + JSXAttributeValue::StringLiteral(literal) => { + if string_order(literal.value.as_str()).is_none() { + let code = literal + .raw + .as_ref() + .map_or_else(|| format!("{:?}", literal.value), ToString::to_string); + errors.push((literal.span.start, invalid_order(&code))); + } + } + JSXAttributeValue::Element(element) => { + let allocator = Allocator::default(); + let value = Expression::JSXElement(element.clone_in(&allocator)); + errors.push((element.span.start, invalid_order(&readable_code(&value)))); + } + JSXAttributeValue::Fragment(fragment) => { + let allocator = Allocator::default(); + let value = Expression::JSXFragment(fragment.clone_in(&allocator)); + errors.push((fragment.span.start, invalid_order(&readable_code(&value)))); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use oxc_parser::Parser; + use oxc_span::SourceType; + + #[test] + fn style_order_runtime_reads_are_not_mistaken_for_static_values() + -> Result<(), Box> { + let allocator = Allocator::default(); + let bindings = StyleValues::default(); + for source in ["state.order", "readOrder()"] { + let value = Parser::new(&allocator, source, SourceType::ts()) + .parse_expression() + .map_err(|error| format!("{error:?}"))?; + let mut errors = Vec::new(); + Validation { + bindings: &bindings, + errors: &mut errors, + } + .value(&value); + assert_eq!(errors, vec![], "{source}"); + assert_eq!(crate::style_order::static_order(&value), None, "{source}"); + } + Ok(()) + } + + #[test] + fn style_order_generated_literals_use_readable_fallbacks() { + let allocator = Allocator::default(); + let builder = oxc_ast::builder::AstBuilder::new(&allocator); + let bindings = StyleValues::default(); + let mut errors = Vec::new(); + let value = Expression::new_string_literal(oxc_span::SPAN, "01", None, &builder); + Validation { + bindings: &bindings, + errors: &mut errors, + } + .value(&value); + let attribute = JSXAttributeValue::new_string_literal(oxc_span::SPAN, "01", None, &builder); + validate_attribute(&attribute, &bindings, &mut errors); + assert_eq!(errors.len(), 2); + assert!(errors.iter().all(|(_, error)| error.contains("`\"01\"`"))); + } + + #[test] + fn style_order_empty_jsx_and_non_frame_values_remain_absent() { + let allocator = Allocator::default(); + let bindings = StyleValues::default(); + let parsed = Parser::new( + &allocator, + "", + SourceType::tsx(), + ) + .parse(); + assert_eq!(parsed.diagnostics.len(), 1); + let oxc_ast::ast::Statement::ExpressionStatement(statement) = &parsed.program.body[0] + else { + panic!("expected expression statement") + }; + let value = &statement.expression; + let Expression::JSXElement(element) = value else { + panic!("expected JSX") + }; + let oxc_ast::ast::JSXAttributeItem::Attribute(attribute) = + &element.opening_element.attributes[0] + else { + panic!("expected attribute") + }; + let Some(attribute) = attribute.value.as_ref() else { + panic!("expected attribute value") + }; + let mut errors = Vec::new(); + validate_attribute(attribute, &bindings, &mut errors); + validate_frames(value, &bindings, &mut errors); + assert_eq!(errors, vec![]); + assert!(matches!( + crate::style_order::attribute_order(attribute, &allocator), + crate::utils::ParsedStyleOrder::None + )); + } +} diff --git a/libs/extractor/src/utils.rs b/libs/extractor/src/utils.rs index 6aba9e1b5..9993796d6 100644 --- a/libs/extractor/src/utils.rs +++ b/libs/extractor/src/utils.rs @@ -33,10 +33,10 @@ pub(super) fn is_unitless_key(key: &str) -> bool { } /// Whether a number on `key` stays bare in a library whose numbers mean pixels: -/// unitless keys, and Devup UI shorthands (`p`, `bg`, `mx`, ...), which keep +/// order metadata, unitless keys, and Devup UI shorthands (`p`, `bg`, `mx`, ...), which keep /// Devup UI's spacing scale because no such library defines them. pub(super) fn keeps_bare_number(key: &str) -> bool { - if is_unitless_key(key) { + if key == "styleOrder" || is_unitless_key(key) { return true; } let mut properties = css::disassemble_property(key); @@ -215,17 +215,7 @@ pub(super) fn jsx_expression_to_style_order<'a>( expr: &JSXAttributeValue<'a>, allocator: &'a Allocator, ) -> ParsedStyleOrder<'a> { - match expr { - JSXAttributeValue::ExpressionContainer(ec) => ec - .expression - .as_expression() - .map_or(ParsedStyleOrder::None, |e| { - expression_to_style_order(e, allocator) - }), - _ => jsx_expression_to_number(expr).map_or(ParsedStyleOrder::None, |n| { - ParsedStyleOrder::Static(n as u8) - }), - } + crate::style_order::attribute_order(expr, allocator) } /// Parse styleOrder from an Expression (for call expression / object path), supporting conditionals @@ -233,46 +223,7 @@ pub(super) fn expression_to_style_order<'a>( expr: &Expression<'a>, allocator: &'a Allocator, ) -> ParsedStyleOrder<'a> { - // Inspect `expr` ONCE. A numeric-literal probe (`get_number_by_literal_expression`) - // never matches a conditional/logical node, so folding it into the default arm is - // behavior-identical to the former "static probe first, then re-match" flow while - // avoiding the redundant second inspection of `expr`. - match expr { - // Conditional: `cond ? a : b` → Conditional with both branches probed. - Expression::ConditionalExpression(cond) => { - let consequent = get_number_by_literal_expression(&cond.consequent).map(|n| n as u8); - let alternate = get_number_by_literal_expression(&cond.alternate).map(|n| n as u8); - ParsedStyleOrder::Conditional { - condition: cond.test.clone_in(allocator), - consequent, - alternate, - } - } - // Logical &&: `a === 1 && 5` → truthy → right side (number), falsy → None. - Expression::LogicalExpression(logical) if logical.operator == LogicalOperator::And => { - let consequent = get_number_by_literal_expression(&logical.right).map(|n| n as u8); - ParsedStyleOrder::Conditional { - condition: logical.left.clone_in(allocator), - consequent, - alternate: None, - } - } - // Otherwise fall back to static numeric-literal resolution. - _ => get_number_by_literal_expression(expr).map_or(ParsedStyleOrder::None, |n| { - ParsedStyleOrder::Static(n as u8) - }), - } -} - -pub(super) fn jsx_expression_to_number(expr: &JSXAttributeValue) -> Option { - match expr { - JSXAttributeValue::StringLiteral(sl) => sl.value.parse::().ok(), - JSXAttributeValue::ExpressionContainer(ec) => ec - .expression - .as_expression() - .and_then(get_number_by_literal_expression), - _ => None, - } + crate::style_order::expression_order(expr, allocator) } pub(super) fn get_number_by_literal_expression(expr: &Expression) -> Option { @@ -1742,7 +1693,7 @@ mod tests { let allocator = Allocator::default(); let builder = oxc_ast::builder::AstBuilder::new(&allocator); assert_eq!( - jsx_expression_to_number( + jsx_expression_to_style_order( JSXAttribute::new( SPAN, JSXAttributeName::new_identifier(SPAN, "styleOrder", &builder), @@ -1753,13 +1704,16 @@ mod tests { ) .value .as_ref() - .unwrap() - ), + .unwrap(), + &allocator, + ) + .as_static() + .map(f64::from), Some(1.0) ); assert_eq!( - jsx_expression_to_number( + jsx_expression_to_style_order( JSXAttribute::new( SPAN, JSXAttributeName::new_identifier(SPAN, "styleOrder", &builder), @@ -1788,18 +1742,26 @@ mod tests { ) .value .as_ref() - .unwrap() - ), + .unwrap(), + &allocator, + ) + .as_static() + .map(f64::from), None ); assert_eq!( - jsx_expression_to_number(&JSXAttributeValue::new_expression_container( - SPAN, - Expression::new_numeric_literal(SPAN, 2.0, None, NumberBase::Decimal, &builder) - .into(), - &builder, - )), + jsx_expression_to_style_order( + &JSXAttributeValue::new_expression_container( + SPAN, + Expression::new_numeric_literal(SPAN, 2.0, None, NumberBase::Decimal, &builder) + .into(), + &builder, + ), + &allocator, + ) + .as_static() + .map(f64::from), Some(2.0) ); } diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 713795674..b552543f0 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -1081,6 +1081,11 @@ impl<'a> DevupVisitor<'a> { props.extend(values.into_iter().map(ExtractStyleProp::Static)); } KnownStyles::Rules(mut rules) => { + crate::style_order_validation::validate_rules( + &rules, + &self.style_values, + &mut self.errors, + ); self.style_values.read_in(&self.ast, &mut rules); let (result, authored) = crate::extractor::style_order_projection::extract_with_order_policy::( @@ -3383,6 +3388,19 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { if self.is_styled_call(it) { self.style_values.read_in_styled(&self.ast, it); if let Expression::CallExpression(call) = &*it { + let first_rule = usize::from(matches!( + unwrap_syntax_only(&call.callee), + Expression::Identifier(_) + )); + for argument in call.arguments.iter().skip(first_rule) { + if let Some(value) = argument.as_expression() { + crate::style_order_validation::validate_rules( + value, + &self.style_values, + &mut self.errors, + ); + } + } self.unknown_arguments("styled", &call.arguments); self.changed_arguments("styled", &call.arguments); self.local_arguments("styled", &call.arguments); @@ -3825,6 +3843,11 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { && let [arg] = call.arguments.as_mut_slice() && let Some(arg @ Expression::ObjectExpression(_)) = arg.as_expression_mut() { + crate::style_order_validation::validate_frames( + arg, + &self.style_values, + &mut self.errors, + ); let ( KeyframesExtractResult { keyframes, @@ -3938,6 +3961,23 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { )); } + if let Expression::CallExpression(call) = &*it + && self + .util_type(&call.callee) + .is_some_and(|util| matches!(util.as_ref(), UtilType::Css)) + { + for argument in &call.arguments { + let value = match argument { + Argument::SpreadElement(spread) => &spread.argument, + argument => argument.to_expression(), + }; + crate::style_order_validation::validate_rules( + value, + &self.style_values, + &mut self.errors, + ); + } + } if let Expression::CallExpression(call) = it && self .util_type(&call.callee) @@ -4076,17 +4116,24 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } } } else if matches!(r, UtilType::Keyframes) { - let (KeyframesExtractResult { - keyframes, - runtime_value, - }, runtime_offset) = crate::extractor::extract_keyframes_from_expression::extract_keyframes_with_location( - &self.ast, - if let Argument::SpreadElement(spread) = &mut call.arguments[0] { - &mut spread.argument - } else { - call.arguments[0].to_expression_mut() - }, + let frames = if let Argument::SpreadElement(spread) = &mut call.arguments[0] + { + &mut spread.argument + } else { + call.arguments[0].to_expression_mut() + }; + crate::style_order_validation::validate_frames( + frames, + &self.style_values, + &mut self.errors, ); + let ( + KeyframesExtractResult { + keyframes, + runtime_value, + }, + runtime_offset, + ) = extract_keyframes_with_location(&self.ast, frames); if let Some(value) = runtime_value { self.errors.push(( runtime_offset @@ -4421,6 +4468,11 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { { // Pre-scan: detect conditional styleOrder before extract_style_from_expression // consumes the property (which only handles static values) + crate::style_order_validation::validate_rules( + it.arguments[1].to_expression(), + &self.style_values, + &mut self.errors, + ); let parsed_style_order = if let Expression::ObjectExpression(obj) = it.arguments[1].to_expression() { obj.properties.iter().find_map(|prop| { @@ -4492,7 +4544,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { // Use pre-scanned ParsedStyleOrder, falling back to extract_style_from_expression's // static result for backward compat. - // Note: pre-scan and extract_style_from_expression both use get_number_by_literal_expression + // Note: pre-scan and extract_style_from_expression both use style_order::static_order // on the same value, so style_order is always None when parsed_style_order is None. let parsed_style_order = match parsed_style_order { ParsedStyleOrder::None => { @@ -4983,8 +5035,18 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { if !duplicate_set.contains(&disassembled) { if property_name == "styleOrder" { if let Some(value) = attr.value.as_ref() { + crate::style_order_validation::validate_attribute( + value, + &self.style_values, + &mut self.errors, + ); parsed_style_order = jsx_expression_to_style_order(value, self.ast.allocator()); + } else { + self.errors.push(( + attr.span.start, + crate::style_order::invalid_order("styleOrder"), + )); } } else if property_name == "props" { if let Some(value) = attr.value.as_ref() @@ -5049,6 +5111,11 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { duplicate_set.extend(given.into_iter().map(Cow::Owned)); } // Extract styles from spread attributes (e.g., {...{"@media": {...}}}) + crate::style_order_validation::validate_rules( + &spread.argument, + &self.style_values, + &mut self.errors, + ); let ExtractResult { styles, .. } = extract_style_from_expression( &self.ast, None, diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index e4b0e533b..d4c890f0a 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -4372,9 +4372,9 @@ mod tests { reset_class_map(); reset_file_map(); let mut sheet = StyleSheet::default(); - let output = extract( + let mut output = extract( "test.tsx", - "import {Box} from '@devup-ui/core'\n", + "import {Box} from '@devup-ui/core'\n", ExtractOption { package: "@devup-ui/core".to_string(), css_dir: "@devup-ui/core".to_string(), @@ -4384,6 +4384,14 @@ mod tests { }, ) .unwrap(); + output.styles = output + .styles + .into_iter() + .map(|mut style| { + style.set_style_order(0); + style + }) + .collect(); assert_eq!( sheet.update_styles(&output.styles, "test.tsx", true), (true, true) diff --git a/packages/bun-plugin/src/__tests__/plugin.test.ts b/packages/bun-plugin/src/__tests__/plugin.test.ts index 2dbb83469..485d6b7dd 100644 --- a/packages/bun-plugin/src/__tests__/plugin.test.ts +++ b/packages/bun-plugin/src/__tests__/plugin.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import * as nodePath from 'node:path' import { join } from 'node:path' +import { ShorthandConfigError } from '@devup-ui/plugin-utils' import * as wasm from '@devup-ui/wasm' import { afterAll, @@ -18,6 +19,44 @@ import { bunRegistration } from '../../../../bun.setup' import { DevupUI, register } from '../plugin' import { captureBunHooks } from './captured-hooks.test-support' +describe('shorthand creation boundary', () => { + it('registers canonical targets when the plugin is registered', async () => { + const registration = spyOn(wasm, 'registerShorthands').mockReturnValue( + undefined, + ) + try { + await register({ + shorthands: { bgx: ['backgroundColor', 'py', '--Gap'] }, + }) + expect(registration.mock.calls).toEqual([ + [ + { + bgx: ['background-color', 'padding-top', 'padding-bottom', '--Gap'], + }, + ], + ]) + } finally { + registration.mockRestore() + } + }) + + it('rejects a typo before registration when runtime options bypass types', () => { + const registration = spyOn(wasm, 'registerShorthands').mockReturnValue( + undefined, + ) + try { + expect(() => + Reflect.apply(register, undefined, [ + { shorthands: { bgx: ['backgroundColour'] } }, + ]), + ).toThrow(ShorthandConfigError) + expect(registration).not.toHaveBeenCalled() + } finally { + registration.mockRestore() + } + }) +}) + const root = fs.mkdtempSync(join(tmpdir(), 'devup-ui-bun-hooks-')) const scopedRoot = join(root, '@devup-ui/fixture') fs.mkdirSync(scopedRoot, { recursive: true }) diff --git a/packages/bun-plugin/src/plugin.ts b/packages/bun-plugin/src/plugin.ts index 6aceeacaa..b425d8469 100644 --- a/packages/bun-plugin/src/plugin.ts +++ b/packages/bun-plugin/src/plugin.ts @@ -13,6 +13,7 @@ import { isProjectJsx, loadDevupConfig, mergeImportAliases, + normalizeShorthands, readJsxImportSource, seedFileNumbers, tailwindCssFiles, @@ -90,8 +91,8 @@ async function writeDataFiles(): Promise { const watchTailwindCss = createTailwindWatcher(writeDataFiles) -async function initialize({ shorthands }: DevupUIBunPluginOptions = {}) { - registerShorthands(shorthands ?? {}) +async function initialize(shorthands: Record) { + registerShorthands(shorthands) setPrefix(null) setModuleResolver(createModuleResolver()) if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) @@ -167,6 +168,7 @@ async function loadSourceFile(filePath: string, bundling: boolean) { * Bun runtime ({@link register}). */ function DevupUI(options: DevupUIBunPluginOptions = {}) { + const shorthands = normalizeShorthands(options.shorthands ?? {}) return { name: 'devup-ui', @@ -177,7 +179,7 @@ function DevupUI(options: DevupUIBunPluginOptions = {}) { // this process left in the engine const endBuild = beginBuild({ resetBuildState }) build.onEnd?.(endBuild) - await initialize(options) + await initialize(shorthands) const debug = options.debug ?? !bundling setDebug(debug) const slots = createSharedSlotReservation({ reserveClassSlots }) diff --git a/packages/eslint-plugin/src/configs/__tests__/__snapshots__/recommended.test.ts.snap b/packages/eslint-plugin/src/configs/__tests__/__snapshots__/recommended.test.ts.snap index e4e932193..e2940d14f 100644 --- a/packages/eslint-plugin/src/configs/__tests__/__snapshots__/recommended.test.ts.snap +++ b/packages/eslint-plugin/src/configs/__tests__/__snapshots__/recommended.test.ts.snap @@ -133,7 +133,7 @@ exports[`recommended export recommended config 1`] = ` "url": "https://github.com/dev-five-git/devup-ui/tree/main/packages/eslint-plugin/src/rules/style-order-range", }, "messages": { - "styleOrderRange": "styleOrder prop must be a number greater than 0 and less than 255.", + "styleOrderRange": "styleOrder must be an integer from 1 to 254 or its decimal digit string, with no signs, spaces or leading zeros.", "wrongType": "styleOrder prop must be a number or a string representing a number.", }, "schema": [], diff --git a/packages/eslint-plugin/src/rules/no-duplicate-value/index.ts b/packages/eslint-plugin/src/rules/no-duplicate-value/index.ts index 1e7e70c99..730fe4fae 100644 --- a/packages/eslint-plugin/src/rules/no-duplicate-value/index.ts +++ b/packages/eslint-plugin/src/rules/no-duplicate-value/index.ts @@ -6,7 +6,7 @@ import { import type { RuleContext } from '@typescript-eslint/utils/ts-eslint' import { ImportStorage } from '../../utils/import-storage' -import { styleValueRoot } from '../../utils/style-position' +import { responsiveValueSite } from '../../utils/responsive-position' const createRule = ESLintUtils.RuleCreator( (name) => @@ -76,7 +76,7 @@ export const noDuplicateValue = createRule({ importStorage.addImportByDeclaration(node) }, ArrayExpression(node) { - if (styleValueRoot(node, importStorage)) + if (responsiveValueSite(node, importStorage)) checkDuplicateValue(node, context) }, } diff --git a/packages/eslint-plugin/src/rules/no-useless-responsive/__tests__/index.test.ts b/packages/eslint-plugin/src/rules/no-useless-responsive/__tests__/index.test.ts index ccdfba6af..52f160ea7 100644 --- a/packages/eslint-plugin/src/rules/no-useless-responsive/__tests__/index.test.ts +++ b/packages/eslint-plugin/src/rules/no-useless-responsive/__tests__/index.test.ts @@ -16,6 +16,14 @@ describe('no-useless-responsive rule', () => { }) ruleTester.run('no-useless-responsive rule', noUselessResponsive, { valid: [ + { + code: 'import { Box } from "@devup-ui/react";\n', + filename: 'src/app/page.tsx', + }, + { + code: 'import { css } from "@devup-ui/react";\ncss({ w: active && [1] })', + filename: 'src/app/page.tsx', + }, { code: 'import { Box } from "@devup-ui/react";\n', filename: 'src/app/page.tsx', @@ -102,6 +110,13 @@ describe('no-useless-responsive rule', () => { })), ], invalid: [ + { + code: 'import { Box } from "@devup-ui/react";\n', + output: + 'import { Box } from "@devup-ui/react";\n', + filename: 'src/app/page.tsx', + errors: [{ messageId: 'uselessResponsive' }], + }, { code: 'import { Box } from "@devup-ui/react";\n} m={[2]} />', output: diff --git a/packages/eslint-plugin/src/rules/no-useless-responsive/index.ts b/packages/eslint-plugin/src/rules/no-useless-responsive/index.ts index 343fd0c05..d3d1a27cd 100644 --- a/packages/eslint-plugin/src/rules/no-useless-responsive/index.ts +++ b/packages/eslint-plugin/src/rules/no-useless-responsive/index.ts @@ -6,7 +6,7 @@ import { import type { RuleContext } from '@typescript-eslint/utils/ts-eslint' import { ImportStorage } from '../../utils/import-storage' -import { styleValueSite } from '../../utils/style-position' +import { responsiveValueSite } from '../../utils/responsive-position' const createRule = ESLintUtils.RuleCreator( (name) => @@ -32,6 +32,7 @@ function checkUselessResponsive>( case AST_NODE_TYPES.JSXOpeningElement: case AST_NODE_TYPES.CallExpression: case AST_NODE_TYPES.ObjectExpression: + case AST_NODE_TYPES.ArrayExpression: case AST_NODE_TYPES.JSXAttribute: break default: @@ -69,7 +70,7 @@ export const noUselessResponsive = createRule({ importStorage.addImportByDeclaration(node) }, ArrayExpression(node) { - const site = styleValueSite(node, importStorage) + const site = responsiveValueSite(node, importStorage) if (site) checkUselessResponsive( node, diff --git a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/index.ts b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/index.ts index d16f185fb..ef44e6dc5 100644 --- a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/index.ts +++ b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/index.ts @@ -7,7 +7,7 @@ import { import type { RuleContext } from '@typescript-eslint/utils/ts-eslint' import { ImportStorage } from '../../utils/import-storage' -import { styleValueRoot } from '../../utils/style-position' +import { responsiveValueSite } from '../../utils/responsive-position' const createRule = ESLintUtils.RuleCreator( (name) => @@ -85,7 +85,7 @@ export const noUselessTailingNulls = createRule({ importStorage.addImportByDeclaration(node) }, ArrayExpression(node) { - if (styleValueRoot(node, importStorage)) + if (responsiveValueSite(node, importStorage)) checkUselessTailingNulls(node, context) }, } diff --git a/packages/eslint-plugin/src/rules/style-order-range/README.md b/packages/eslint-plugin/src/rules/style-order-range/README.md index 6058580dc..69d0e1630 100644 --- a/packages/eslint-plugin/src/rules/style-order-range/README.md +++ b/packages/eslint-plugin/src/rules/style-order-range/README.md @@ -4,47 +4,58 @@ Ensures `styleOrder` prop is within valid range (0 < value < 255). ## Rule Details -This rule enforces that the `styleOrder` prop must be a number greater than 0 and less than 255. +An explicit `styleOrder` must be an integer from 1 to 254, or the canonical +decimal string for that integer. Strings must not contain signs, whitespace, +leading zeros, decimal points, exponents or suffixes. Numeric expressions such +as `100.0` and `1e2` are integers and are accepted. The extractor reserves order +0 for its internal global layer and 255 for unlayered styles. ### Examples of **incorrect** code for this rule: ```jsx +import { Box } from '@devup-ui/react' + // Zero and negative values -
-
-
+ + + // Values greater than or equal to 255 -
-
-
+ + + // Non-numeric values -
-
+ + + + + + ``` ### Examples of **correct** code for this rule: ```jsx +import { Box } from '@devup-ui/react' + // Valid range values (0 < value < 255) -
-
-
-
-
-
+ + + + + + ``` ## Where it applies -The rule checks `styleOrder` where the build reads it: a prop of a Devup UI component and a top-level key of a style object passed to `css`, `globalCss` or `keyframes`. A `styleOrder` in nested data (`data-*` props, `props`, an element passed as a prop, nested style objects) is not checked. +The rule checks Devup UI component props and top-level style-object keys of the +recognized utilities and styled factories, including their supported import +aliases. It does not inspect `.attrs()` data, pass-through props, native elements +or vanilla-extract stylesheets (`.css.ts`, `.css.js`). Conditional branches use +the same value contract. A native `
` is not checked. ## When Not To Use It If you don't use `styleOrder` props or want to allow any value range, you can disable this rule. - - -## Where it applies - -`styleOrder` is read as a prop of a Devup UI component and as a top-level key of a style object passed to `css`, `globalCss`, `keyframes` or `createGlobalStyle`, through the same import aliases the build compiles by default (`@emotion/react`, `@emotion/styled`, `styled-components`, `@vanilla-extract/css`), and in the rules of `styled` (`styled.div({ styleOrder: 1 })`, `styled('div', { styleOrder: 1 })`). It is not read in `.attrs()` or in vanilla-extract stylesheets (`.css.ts`, `.css.js`). diff --git a/packages/eslint-plugin/src/rules/style-order-range/__tests__/contract.test.ts b/packages/eslint-plugin/src/rules/style-order-range/__tests__/contract.test.ts new file mode 100644 index 000000000..064516362 --- /dev/null +++ b/packages/eslint-plugin/src/rules/style-order-range/__tests__/contract.test.ts @@ -0,0 +1,65 @@ +import { readFileSync } from 'node:fs' + +import { RuleTester } from '@typescript-eslint/rule-tester' +import { describe } from 'bun:test' + +import { styleOrderRange } from '../index' + +const cases: { expression: string; valid: boolean }[] = JSON.parse( + readFileSync( + new URL( + '../../../../../../test-fixtures/style-order.json', + import.meta.url, + ), + 'utf8', + ), +) + +describe('styleOrder matches the extractor contract', () => { + const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 'latest', + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + }) + const fixtures = cases.flatMap(({ expression, valid }) => + [ + `import { Box } from '@devup-ui/react'; `, + `import { css } from '@devup-ui/react'; css({ p: 1, styleOrder: ${expression} })`, + ].map((code) => ({ code, valid, filename: 'contract.tsx' })), + ) + ruleTester.run('style-order-range contract', styleOrderRange, { + valid: fixtures + .filter(({ valid }) => valid) + .map(({ code, filename }) => ({ code, filename })) + .concat( + { + code: `import { css } from '@devup-ui/react'; css({ styleOrder: 1 })`, + filename: 'contract.ts', + }, + { + code: `import { css } from '@devup-ui/react'; css({ styleOrder: +(1) })`, + filename: 'contract.ts', + }, + ), + invalid: fixtures + .filter(({ valid }) => !valid) + .map(({ code, filename }) => ({ + code, + filename, + errors: [{ messageId: 'styleOrderRange' }], + })) + .concat([ + { + code: `import { Box } from '@devup-ui/react'; `, + filename: 'contract.tsx', + errors: [{ messageId: 'styleOrderRange' }], + }, + { + code: `import { css } from '@devup-ui/react'; css({ 'styleOrder': '100px' })`, + filename: 'contract.tsx', + errors: [{ messageId: 'styleOrderRange' }], + }, + ]), + }) +}) diff --git a/packages/eslint-plugin/src/rules/style-order-range/__tests__/numeric-value.test.ts b/packages/eslint-plugin/src/rules/style-order-range/__tests__/numeric-value.test.ts new file mode 100644 index 000000000..99faf1474 --- /dev/null +++ b/packages/eslint-plugin/src/rules/style-order-range/__tests__/numeric-value.test.ts @@ -0,0 +1,40 @@ +import { RuleTester } from '@typescript-eslint/rule-tester' +import { describe } from 'bun:test' + +import { styleOrderRange } from '../index' + +describe('style-order-range unary coercion', () => { + const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 'latest', + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + }) + + ruleTester.run('style-order-range unary coercion', styleOrderRange, { + valid: [ + { + code: 'import { Box } from "@devup-ui/react";\n', + filename: 'src/app/page.tsx', + }, + { + code: 'import { css } from "@devup-ui/react";\ncss({ styleOrder: +true })', + filename: 'src/app/page.tsx', + }, + ], + invalid: [ + ...['+1n', '+/2/', '+`${2}`'].map((value) => ({ + code: `import { Box } from "@devup-ui/react";\n`, + filename: 'src/app/page.tsx', + output: null, + errors: [{ messageId: 'styleOrderRange' }], + })), + ...['-1n', '-/2/', '-`${2}`'].map((value) => ({ + code: `import { css } from "@devup-ui/react";\ncss({ styleOrder: ${value} })`, + filename: 'src/app/page.tsx', + output: null, + errors: [{ messageId: 'styleOrderRange' }], + })), + ], + }) +}) diff --git a/packages/eslint-plugin/src/rules/style-order-range/index.ts b/packages/eslint-plugin/src/rules/style-order-range/index.ts index b5154caa9..95f3efd5f 100644 --- a/packages/eslint-plugin/src/rules/style-order-range/index.ts +++ b/packages/eslint-plugin/src/rules/style-order-range/index.ts @@ -13,60 +13,93 @@ const createRule = ESLintUtils.RuleCreator( `https://github.com/dev-five-git/devup-ui/tree/main/packages/eslint-plugin/src/rules/${name}`, ) -function checkStyleOrderRange>( - expression: TSESTree.Expression, - context: T, -) { - let value: number | null = null +function isStyleOrder(value: number | string): boolean { + const number = Number(value) + return ( + Number.isInteger(number) && + number >= 1 && + number <= 254 && + (typeof value === 'number' || value === String(number)) + ) +} - if (expression.type === AST_NODE_TYPES.Literal) { - if (typeof expression.value === 'number') { - value = expression.value - } else if (typeof expression.value === 'string') { - const parsed = parseInt(expression.value, 10) - if (!Number.isNaN(parsed)) { - value = parsed - } +function numericValue(expression: TSESTree.Expression): number | null { + switch (expression.type) { + case AST_NODE_TYPES.Literal: + return typeof expression.value === 'number' + ? expression.value + : typeof expression.value === 'string' || + typeof expression.value === 'boolean' || + expression.value === null + ? Number(expression.value) + : null + case AST_NODE_TYPES.TemplateLiteral: + return expression.expressions.length === 0 && + expression.quasis[0].value.cooked !== null + ? Number(expression.quasis[0].value.cooked) + : null + case AST_NODE_TYPES.UnaryExpression: { + if (expression.operator !== '+' && expression.operator !== '-') + return null + const value = numericValue(expression.argument) + return value === null + ? null + : expression.operator === '-' + ? -value + : value } - } else if (expression.type === AST_NODE_TYPES.UnaryExpression) { - if ( - expression.argument.type === AST_NODE_TYPES.Literal && - typeof expression.argument.value === 'number' && - (expression.operator === '-' || expression.operator === '+') - ) { - value = - expression.operator === '-' - ? -expression.argument.value - : expression.argument.value - } else { - context.report({ - node: expression, - messageId: 'styleOrderRange', - }) - return - } - } else if (expression.type === AST_NODE_TYPES.TemplateLiteral) { - if (expression.expressions.length > 0) { - // error report - context.report({ - node: expression, - messageId: 'styleOrderRange', - }) - return - } else { - value = parseInt(expression.quasis[0].value.raw, 10) - if (Number.isNaN(value)) { - // error report - context.report({ - node: expression, - messageId: 'styleOrderRange', - }) - return - } + case AST_NODE_TYPES.TSAsExpression: + case AST_NODE_TYPES.TSSatisfiesExpression: + case AST_NODE_TYPES.TSNonNullExpression: + case AST_NODE_TYPES.TSTypeAssertion: + return numericValue(expression.expression) + default: + return null + } +} + +function isStyleOrderExpression(expression: TSESTree.Expression): boolean { + switch (expression.type) { + case AST_NODE_TYPES.Literal: + return ( + (typeof expression.value === 'number' || + typeof expression.value === 'string') && + isStyleOrder(expression.value) + ) + case AST_NODE_TYPES.UnaryExpression: { + const value = numericValue(expression) + return value !== null && isStyleOrder(value) } + case AST_NODE_TYPES.TemplateLiteral: + return ( + expression.expressions.length === 0 && + expression.quasis[0].value.cooked !== null && + isStyleOrder(expression.quasis[0].value.cooked) + ) + case AST_NODE_TYPES.ConditionalExpression: + return ( + isStyleOrderExpression(expression.consequent) && + isStyleOrderExpression(expression.alternate) + ) + case AST_NODE_TYPES.LogicalExpression: + return ( + expression.operator === '&&' && isStyleOrderExpression(expression.right) + ) + case AST_NODE_TYPES.TSAsExpression: + case AST_NODE_TYPES.TSSatisfiesExpression: + case AST_NODE_TYPES.TSNonNullExpression: + case AST_NODE_TYPES.TSTypeAssertion: + return isStyleOrderExpression(expression.expression) + default: + return false } +} - if (value === null || value < 1 || value > 254) { +function checkStyleOrderRange>( + expression: TSESTree.Expression, + context: T, +) { + if (!isStyleOrderExpression(expression)) { context.report({ node: expression, messageId: 'styleOrderRange', @@ -81,7 +114,7 @@ export const styleOrderRange = createRule({ schema: [], messages: { styleOrderRange: - 'styleOrder prop must be a number greater than 0 and less than 255.', + 'styleOrder must be an integer from 1 to 254 or its decimal digit string, with no signs, spaces or leading zeros.', wrongType: 'styleOrder prop must be a number or a string representing a number.', }, @@ -103,9 +136,11 @@ export const styleOrderRange = createRule({ const object = node.parent const call = object.parent if ( - node.key.type === AST_NODE_TYPES.Identifier && !node.computed && - node.key.name === 'styleOrder' && + ((node.key.type === AST_NODE_TYPES.Identifier && + node.key.name === 'styleOrder') || + (node.key.type === AST_NODE_TYPES.Literal && + node.key.value === 'styleOrder')) && node.value.type !== AST_NODE_TYPES.AssignmentPattern && node.value.type !== AST_NODE_TYPES.TSEmptyBodyFunctionExpression && object.type === AST_NODE_TYPES.ObjectExpression && @@ -120,7 +155,6 @@ export const styleOrderRange = createRule({ if ( node.name.type !== AST_NODE_TYPES.JSXIdentifier || node.name.name !== 'styleOrder' || - !node.value || !STYLE_COMPONENTS.has( componentName(node.parent.name, importStorage) ?? '', ) @@ -128,6 +162,11 @@ export const styleOrderRange = createRule({ return } + if (!node.value) { + context.report({ node, messageId: 'styleOrderRange' }) + return + } + if ( node.value.type === AST_NODE_TYPES.JSXExpressionContainer && node.value.expression.type !== AST_NODE_TYPES.JSXEmptyExpression diff --git a/packages/eslint-plugin/src/utils/__tests__/composition-arrays.test.ts b/packages/eslint-plugin/src/utils/__tests__/composition-arrays.test.ts new file mode 100644 index 000000000..fa295db41 --- /dev/null +++ b/packages/eslint-plugin/src/utils/__tests__/composition-arrays.test.ts @@ -0,0 +1,52 @@ +import { RuleTester } from '@typescript-eslint/rule-tester' +import { describe } from 'bun:test' + +import { noDuplicateValue } from '../../rules/no-duplicate-value' +import { noUselessResponsive } from '../../rules/no-useless-responsive' +import { noUselessTailingNulls } from '../../rules/no-useless-tailing-nulls' + +describe('composition arrays are not responsive values', () => { + const tester = new RuleTester({ + languageOptions: { + ecmaVersion: 'latest', + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + }) + const valid = [ + `import { css } from '@emotion/react'; css(base, 'extra', [{ p: 1 }], null)`, + `import { css } from '@emotion/react'; export const result = css(base, 'extra', [{ p: 1 }], null)`, + `import { style } from '@vanilla-extract/css'; export const result = style([[{ color: 'red' }], false && { margin: 0 }])`, + `import { css } from '@devup-ui/react'; css(['base', 'base', null])`, + `import { css } from '@devup-ui/react'; const parts = ['base', 'base', null]; css(parts)`, + `import { css } from '@devup-ui/react'; const parts = ['base']; const alias = parts; css(alias)`, + `import { Global } from '@emotion/react'; `, + `import styled from 'styled-components'; styled.div([{ color: 'red' }])`, + `import { css, Box } from '@devup-ui/react'; const parts = ['base']; css(parts); `, + `import { Box } from '@devup-ui/react'; `, + ].map((code) => ({ code, filename: 'composition.tsx' })) + tester.run('no-useless-responsive composition', noUselessResponsive, { + valid, + invalid: [ + { + code: `import { css } from '@devup-ui/react'; css([{ p: [1] }])`, + output: `import { css } from '@devup-ui/react'; css([{ p: 1 }])`, + filename: 'composition.tsx', + errors: [{ messageId: 'uselessResponsive' }], + }, + { + code: `import { Box } from '@devup-ui/react'; const value = [1]; `, + output: `import { Box } from '@devup-ui/react'; const value = 1; `, + filename: 'composition.tsx', + errors: [{ messageId: 'uselessResponsive' }], + }, + ], + }) + tester.run('no-duplicate-value composition', noDuplicateValue, { + valid, + invalid: [], + }) + tester.run('no-useless-tailing-nulls composition', noUselessTailingNulls, { + valid, + invalid: [], + }) +}) diff --git a/packages/eslint-plugin/src/utils/responsive-position.ts b/packages/eslint-plugin/src/utils/responsive-position.ts new file mode 100644 index 000000000..a891c96ba --- /dev/null +++ b/packages/eslint-plugin/src/utils/responsive-position.ts @@ -0,0 +1,36 @@ +import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils' + +import type { ImportStorage } from './import-storage' +import { componentName, type StyleSite, styleValueSite } from './style-position' + +/** Responsive values belong to a property, not to a composition argument. */ +export function responsiveValueSite( + node: TSESTree.Node, + imports: ImportStorage, +): StyleSite | null { + const site = styleValueSite(node, imports) + if (!site) return null + let child = node + let nestedArray = false + while (child !== site.start && child.parent) { + const parent = child.parent + if (parent.type === AST_NODE_TYPES.ArrayExpression) nestedArray = true + if (parent.type === AST_NODE_TYPES.Property) + return nestedArray ? null : site + if (parent.type === AST_NODE_TYPES.JSXAttribute) + return nestedArray || + componentName(parent.parent.name, imports) === 'Global' + ? null + : site + child = parent + } + if (site.start === site.root) return null + const declarator = site.start.parent + if (declarator?.type !== AST_NODE_TYPES.VariableDeclarator) return null + const [variable] = imports.declaredVariables(declarator) + return variable.references + .filter((reference) => !reference.init) + .every((reference) => responsiveValueSite(reference.identifier, imports)) + ? site + : null +} diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index dd439be50..a9c769d6b 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -193,6 +193,60 @@ afterEach(() => { }) describe('DevupUINextPlugin', () => { + it.each(['1', 'auto', undefined])( + 'rejects typos at creation before setup in mode %s', + (mode) => { + if (mode === undefined) delete process.env.TURBOPACK + else process.env.TURBOPACK = mode + expect(() => + Reflect.apply(DevupUI, undefined, [ + {}, + { shorthands: { bgx: ['backgroundColour'] } }, + ]), + ).toThrow(importGraphModule.ShorthandConfigError) + expect(writeFileSyncSpy).not.toHaveBeenCalled() + expect(startCoordinatorSpy).not.toHaveBeenCalled() + expect(devupUIWebpackPluginSpy).not.toHaveBeenCalled() + }, + ) + + it('registers canonical targets when a Turbopack plugin is created', () => { + process.env.TURBOPACK = '1' + const registration = spyOn(wasm, 'registerShorthands').mockReturnValue( + undefined, + ) + try { + DevupUI({}, { shorthands: { bgx: ['backgroundColor', 'py', '--Gap'] } }) + expect(registration.mock.calls).toEqual([ + [ + { + bgx: ['background-color', 'padding-top', 'padding-bottom', '--Gap'], + }, + ], + ]) + } finally { + registration.mockRestore() + } + }) + + it('rejects a typo before reading an available production handoff', () => { + process.env.TURBOPACK = '1' + setNodeEnv('production') + DevupUI({}, { shorthands: { bgx: ['backgroundColor'] } }) + reloadTurboSetupModuleForTesting() + readFileSyncSpy.mockClear() + const shorthands = { bgx: ['backgroundColour'] } + Object.defineProperty(shorthands, 'toJSON', { + value: () => ({ bgx: ['background-color'] }), + }) + + expect(() => + Reflect.apply(DevupUI, undefined, [{}, { shorthands }]), + ).toThrow(importGraphModule.ShorthandConfigError) + expect(readFileSyncSpy).not.toHaveBeenCalled() + expect(startCoordinatorSpy).toHaveBeenCalledTimes(1) + }) + describe('webpack', () => { it.each([ ['omitted', {}], @@ -279,6 +333,68 @@ describe('DevupUINextPlugin', () => { }) }) describe('turbo', () => { + it('reuses production setup across Next config module reloads', () => { + setNodeEnv('production') + process.env.TURBOPACK = '1' + process.env.DEVUP_UI_PROFILE = '1' + getDefaultThemeSpy.mockReturnValue('dark') + const filename = resolve('src/app/page.tsx') + const graphSpy = spyOn( + importGraphModule, + 'buildStaticImportGraph', + ).mockReturnValue(createSingleFileGraph(filename)) + const compiledSpy = spyOn( + importGraphModule, + 'computeCompiledFiles', + ).mockReturnValue(['src/app/page.tsx']) + const profileSpy = spyOn(console, 'info').mockImplementation(() => {}) + const normalizeSpy = spyOn(importGraphModule, 'normalizeShorthands') + const handoffFile = join('df', 'setup.bin') + let handoff: Parameters[1] | undefined + writeFileSyncSpy.mockImplementation((path, data) => { + if (String(path) === handoffFile) handoff = data + }) + readFileSyncSpy.mockImplementation((path) => { + if (String(path) === handoffFile && handoff !== undefined) { + return handoff as never + } + return '{}' as never + }) + unlinkSyncSpy.mockImplementation((path) => { + if (String(path) === handoffFile) throw new Error('cleanup failed') + }) + + try { + const first = DevupUI( + {}, + { singleCss: true, shorthands: { bgx: ['backgroundColor'] } }, + ) + reloadTurboSetupModuleForTesting() + const second = DevupUI( + { env: { EXISTING: 'value' } }, + { singleCss: true, shorthands: { bgx: ['background-color'] } }, + ) + + expect(second.turbopack?.rules).toEqual(first.turbopack?.rules) + expect(second.env).toEqual({ + DEVUP_UI_DEFAULT_THEME: 'dark', + EXISTING: 'value', + }) + expect(graphSpy).toHaveBeenCalledTimes(1) + expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledTimes(1) + expect(startCoordinatorSpy).toHaveBeenCalledTimes(1) + expect(normalizeSpy).toHaveBeenCalledTimes(2) + expect(unlinkSyncSpy).toHaveBeenCalledWith(handoffFile) + expect(profileSpy).toHaveBeenCalledWith( + expect.stringContaining('"cacheHit":true'), + ) + } finally { + normalizeSpy.mockRestore() + profileSpy.mockRestore() + compiledSpy.mockRestore() + graphSpy.mockRestore() + } + }) it.each([true, false])( 'W66 availability: reuses production setup and warns once across config reloads (singleCss %s)', (singleCss) => { diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index 435c73c2a..18a83d497 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -20,6 +20,7 @@ import { extractedNeedles, loadDevupConfigSync, mergeImportAliases, + normalizeShorthands, readJsxImportSource, seedFileNumbers, type StaticImportGraph, @@ -134,6 +135,7 @@ export function DevupUI( config: NextConfig, options: DevupUINextPluginOptions = {}, ): NextConfig { + const normalizedShorthands = normalizeShorthands(options.shorthands ?? {}) const pluginStartedAt = profileStart() const isTurbo = process.env.TURBOPACK === '1' || process.env.TURBOPACK === 'auto' @@ -150,7 +152,6 @@ export function DevupUI( devupFile = 'devup.json', include = [], prefix, - shorthands, atomHoist, importAliases: userImportAliases, } = options @@ -189,7 +190,7 @@ export function DevupUI( include, libPackage, prefix, - shorthands, + shorthands: normalizedShorthands, singleCss, sourceMap, }) @@ -249,7 +250,7 @@ export function DevupUI( setPrefix, } = wasm - registerShorthands(shorthands ?? {}) + registerShorthands(normalizedShorthands) setPrefix(prefix ?? null) diff --git a/packages/plugin-utils/package.json b/packages/plugin-utils/package.json index a83918294..1ef59ee54 100644 --- a/packages/plugin-utils/package.json +++ b/packages/plugin-utils/package.json @@ -18,7 +18,8 @@ "version": "1.0.17", "scripts": { "lint": "eslint", - "build": "bun ../../node_modules/@typescript/native/bin/tsc && bun build --target node src/index.ts --production --env=disable --outfile dist/index.cjs --format cjs --packages external && bun build --target node src/index.ts --production --env=disable --outfile dist/index.mjs --format esm --packages external" + "test:types": "bun ../../node_modules/@typescript/native/bin/tsc --ignoreConfig --noEmit --strict --skipLibCheck --moduleResolution bundler --module esnext --target esnext --types bun src/__tests__/shorthands.test.ts", + "build": "bun run test:types && bun ../../node_modules/@typescript/native/bin/tsc && bun build --target node src/index.ts --production --env=disable --outfile dist/index.cjs --format cjs --packages external && bun build --target node src/index.ts --production --env=disable --outfile dist/index.mjs --format esm --packages external" }, "publishConfig": { "access": "public" diff --git a/packages/plugin-utils/src/__tests__/shorthands.test.ts b/packages/plugin-utils/src/__tests__/shorthands.test.ts new file mode 100644 index 000000000..bdf90e617 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/shorthands.test.ts @@ -0,0 +1,190 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'bun:test' + +import { + BUILTIN_SHORTHAND_TARGETS, + CSS_PROPERTY_NAMES, +} from '../property-names' +import { + type CustomShorthandTarget, + isCustomShorthandTarget, + normalizeShorthands, + ShorthandConfigError, +} from '../shorthands' +import type { CustomShorthands } from '../types' + +type Accepted = T extends CustomShorthandTarget ? true : false +type ConfigAccepted = T extends CustomShorthands ? true : false +const configProof: [ + ConfigAccepted<{ bgx: readonly ['backgroundColour'] }>, + ConfigAccepted<{ bgx: readonly ['backgroundColor'] }>, + ConfigAccepted<{ bgx: readonly ['background-color', 'py'] }>, +] = [false, true, true] +const typeProof: [ + Accepted<'marginRight'>, + Accepted<'margin-right'>, + Accepted<'py'>, + Accepted<'-webkit-mask-image'>, + Accepted<'--Gap'>, + Accepted<'widht'>, + Accepted<'backgroundColour'>, + Accepted<'styleOrder'>, + Accepted<'_hover'>, + Accepted<'boxAlign'>, + Accepted<'box-align'>, +] = [true, true, true, true, true, false, false, false, false, false, false] + +describe('shorthand targets', () => { + it('constrains configuration target types', () => { + const config = { + edges: ['left', 'py', 'margin-right', 'WebkitMaskImage', '--Gap'], + } as const satisfies CustomShorthands + expect(normalizeShorthands(config)).toEqual({ + edges: [ + 'left', + 'padding-top', + 'padding-bottom', + 'margin-right', + '-webkit-mask-image', + '--Gap', + ], + }) + expect(typeProof).toEqual([ + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + ]) + expect(configProof).toEqual([false, true, true]) + }) + + it('preserves readonly input, custom property case and empty lists', () => { + const input = Object.freeze({ + z: Object.freeze(['--My-color', '--1', '--색']), + empty: Object.freeze([]), + }) + expect(normalizeShorthands(input)).toEqual({ + empty: [], + z: ['--My-color', '--1', '--색'], + }) + expect(Object.keys(normalizeShorthands(input))).toEqual(['empty', 'z']) + }) + + it.each([ + 'marginRight', + 'margin-right', + 'WebkitMaskImage', + '-webkit-mask-image', + 'MozAppearance', + '-moz-appearance', + 'msUserSelect', + '-ms-user-select', + 'py', + ])('accepts the exact supported spelling %s', (target) => { + expect(isCustomShorthandTarget(target)).toBe(true) + }) + + it.each([ + 'widht', + 'scrollMargnLeft', + 'Width', + 'webkitMaskImage', + 'MsUserSelect', + 'OTransform', + '_hover', + 'styleOrder', + 'positioning', + 'otherAlias', + '', + ' width', + '--', + '--bad name', + 1, + null, + ])('rejects an unsupported target %s', (target) => { + expect(isCustomShorthandTarget(target)).toBe(false) + expect(() => normalizeShorthands({ bad: ['width', target] })).toThrow( + ShorthandConfigError, + ) + }) + + it('retains original target and logical configuration location', () => { + const input = { aValid: ['right'], insetX: ['left', 'widht'] } + try { + normalizeShorthands(input) + throw new Error('invalid target was accepted') + } catch (error) { + if (!(error instanceof ShorthandConfigError)) throw error + expect(error.alias).toBe('insetX') + expect(error.index).toBe(1) + expect(error.target).toBe('widht') + expect(error.path).toBe('shorthands["insetX"][1]') + expect(error.name).toBe('ShorthandConfigError') + expect(error.message).toBe( + 'devup-ui option shorthands["insetX"][1]: shorthand "insetX" cannot use "widht" at build time: its targets must be supported CSS properties, built-in aliases or custom properties', + ) + } + expect(input).toEqual({ aValid: ['right'], insetX: ['left', 'widht'] }) + }) + + it.each([[null], [[]], ['bad'], [1]])( + 'rejects a malformed map %s', + (input) => { + expect(() => normalizeShorthands(input)).toThrow(ShorthandConfigError) + }, + ) + + it('rejects nonarray target lists', () => { + expect(() => normalizeShorthands({ insetX: 'left' })).toThrow( + ShorthandConfigError, + ) + }) + + it('keeps Rust canonical names and builtin expansions consistent', () => { + const rust = readFileSync( + new URL('../../../../libs/css/src/property_names.rs', import.meta.url), + 'utf8', + ) + const canonical = [...rust.matchAll(/"([^"]+)"/g)] + .map((match) => match[1]) + .sort() + const normalized = normalizeShorthands({ names: CSS_PROPERTY_NAMES }) + expect([...normalized.names].sort()).toEqual(canonical) + const aliases = readFileSync( + new URL('../../../../libs/css/src/constant.rs', import.meta.url), + 'utf8', + ).split('pub(super) static GLOBAL_ENUM_STYLE_PROPERTY')[0] + const expansions = Object.fromEntries( + [...aliases.matchAll(/"([^"]+)" => &\[([^\]]+)\]/g)].map((match) => [ + match[1], + [...match[2].matchAll(/"([^"]+)"/g)].map((value) => value[1]), + ]), + ) + const builtinTargets: Record = + BUILTIN_SHORTHAND_TARGETS + expect(builtinTargets).toEqual(expansions) + }) + + it('rejects all eighteen unprefixed dead names', () => { + for (const target of 'box-align,box-pack,box-flex,box-flex-group,box-orient,box-ordinal-group,box-direction,box-lines,flex-order,flex-positive,flex-negative,flex-preferred-size,scroll-snap-coordinate,scroll-snap-destination,scroll-snap-points-x,scroll-snap-points-y,scroll-snap-type-x,scroll-snap-type-y'.split( + ',', + )) { + expect(isCustomShorthandTarget(target)).toBe(false) + expect( + isCustomShorthandTarget( + target.replace(/-([a-z])/g, (_, letter: string) => + letter.toUpperCase(), + ), + ), + ).toBe(false) + } + }) +}) diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index df3a2237e..0157f5a73 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -58,6 +58,13 @@ export { type ReservationRow, type SharedSlotReservation, } from './shared-slots' +export { + type CustomShorthandTarget, + isCustomShorthandTarget, + normalizeShorthands, + ShorthandConfigError, + type ShorthandErrorLocation, +} from './shorthands' export { createStateWriter, type StateWriter, diff --git a/packages/plugin-utils/src/property-names.ts b/packages/plugin-utils/src/property-names.ts new file mode 100644 index 000000000..57b4c6e3b --- /dev/null +++ b/packages/plugin-utils/src/property-names.ts @@ -0,0 +1,733 @@ +// Generated from csstype-extra 0.1.37 Properties; excludes the 18 unprefixed dead names. +// Source SHA-256: ed9937009a84b96e5699789a3d437e06b00972ea2fb05b7900dd076983946ade +export const CSS_PROPERTY_NAMES = [ + 'MozAppearance', + 'MozBinding', + 'MozBorderBottomColors', + 'MozBorderLeftColors', + 'MozBorderRightColors', + 'MozBorderTopColors', + 'MozBoxAlign', + 'MozBoxDirection', + 'MozBoxFlex', + 'MozBoxFlexGroup', + 'MozBoxLines', + 'MozBoxOrdinalGroup', + 'MozBoxOrient', + 'MozBoxPack', + 'MozContextProperties', + 'MozFloatEdge', + 'MozForceBrokenImageIcon', + 'MozOrient', + 'MozOutlineRadius', + 'MozOutlineRadiusBottomleft', + 'MozOutlineRadiusBottomright', + 'MozOutlineRadiusTopleft', + 'MozOutlineRadiusTopright', + 'MozOverflowClipBox', + 'MozStackSizing', + 'MozTextBlink', + 'MozUserFocus', + 'MozUserInput', + 'MozUserModify', + 'MozWindowDragging', + 'MozWindowShadow', + 'WebkitAppearance', + 'WebkitBorderAfter', + 'WebkitBorderAfterColor', + 'WebkitBorderAfterStyle', + 'WebkitBorderAfterWidth', + 'WebkitBorderBefore', + 'WebkitBorderBeforeColor', + 'WebkitBorderBeforeStyle', + 'WebkitBorderBeforeWidth', + 'WebkitBorderEnd', + 'WebkitBorderEndColor', + 'WebkitBorderEndStyle', + 'WebkitBorderEndWidth', + 'WebkitBorderStart', + 'WebkitBorderStartColor', + 'WebkitBorderStartStyle', + 'WebkitBorderStartWidth', + 'WebkitBoxAlign', + 'WebkitBoxDirection', + 'WebkitBoxFlex', + 'WebkitBoxFlexGroup', + 'WebkitBoxLines', + 'WebkitBoxOrdinalGroup', + 'WebkitBoxOrient', + 'WebkitBoxPack', + 'WebkitBoxReflect', + 'WebkitLineClamp', + 'WebkitMask', + 'WebkitMaskAttachment', + 'WebkitMaskClip', + 'WebkitMaskComposite', + 'WebkitMaskImage', + 'WebkitMaskOrigin', + 'WebkitMaskPosition', + 'WebkitMaskPositionX', + 'WebkitMaskPositionY', + 'WebkitMaskRepeat', + 'WebkitMaskRepeatX', + 'WebkitMaskRepeatY', + 'WebkitMaskSize', + 'WebkitOverflowScrolling', + 'WebkitTapHighlightColor', + 'WebkitTextFillColor', + 'WebkitTextStroke', + 'WebkitTextStrokeColor', + 'WebkitTextStrokeWidth', + 'WebkitTouchCallout', + 'WebkitUserModify', + 'WebkitUserSelect', + 'accentColor', + 'alignContent', + 'alignItems', + 'alignSelf', + 'alignTracks', + 'alignmentBaseline', + 'all', + 'anchorName', + 'anchorScope', + 'animation', + 'animationComposition', + 'animationDelay', + 'animationDirection', + 'animationDuration', + 'animationFillMode', + 'animationIterationCount', + 'animationName', + 'animationPlayState', + 'animationRange', + 'animationRangeEnd', + 'animationRangeStart', + 'animationTimeline', + 'animationTimingFunction', + 'animationTrigger', + 'appearance', + 'aspectRatio', + 'backdropFilter', + 'backfaceVisibility', + 'background', + 'backgroundAttachment', + 'backgroundBlendMode', + 'backgroundClip', + 'backgroundColor', + 'backgroundImage', + 'backgroundOrigin', + 'backgroundPosition', + 'backgroundPositionX', + 'backgroundPositionY', + 'backgroundRepeat', + 'backgroundSize', + 'baselineShift', + 'baselineSource', + 'blockSize', + 'border', + 'borderBlock', + 'borderBlockColor', + 'borderBlockEnd', + 'borderBlockEndColor', + 'borderBlockEndStyle', + 'borderBlockEndWidth', + 'borderBlockStart', + 'borderBlockStartColor', + 'borderBlockStartStyle', + 'borderBlockStartWidth', + 'borderBlockStyle', + 'borderBlockWidth', + 'borderBottom', + 'borderBottomColor', + 'borderBottomLeftRadius', + 'borderBottomRightRadius', + 'borderBottomStyle', + 'borderBottomWidth', + 'borderCollapse', + 'borderColor', + 'borderEndEndRadius', + 'borderEndStartRadius', + 'borderImage', + 'borderImageOutset', + 'borderImageRepeat', + 'borderImageSlice', + 'borderImageSource', + 'borderImageWidth', + 'borderInline', + 'borderInlineColor', + 'borderInlineEnd', + 'borderInlineEndColor', + 'borderInlineEndStyle', + 'borderInlineEndWidth', + 'borderInlineStart', + 'borderInlineStartColor', + 'borderInlineStartStyle', + 'borderInlineStartWidth', + 'borderInlineStyle', + 'borderInlineWidth', + 'borderLeft', + 'borderLeftColor', + 'borderLeftStyle', + 'borderLeftWidth', + 'borderRadius', + 'borderRight', + 'borderRightColor', + 'borderRightStyle', + 'borderRightWidth', + 'borderShape', + 'borderSpacing', + 'borderStartEndRadius', + 'borderStartStartRadius', + 'borderStyle', + 'borderTop', + 'borderTopColor', + 'borderTopLeftRadius', + 'borderTopRightRadius', + 'borderTopStyle', + 'borderTopWidth', + 'borderWidth', + 'bottom', + 'boxDecorationBreak', + 'boxShadow', + 'boxSizing', + 'breakAfter', + 'breakBefore', + 'breakInside', + 'captionSide', + 'caret', + 'caretAnimation', + 'caretColor', + 'caretShape', + 'clear', + 'clip', + 'clipPath', + 'clipRule', + 'color', + 'colorInterpolationFilters', + 'colorScheme', + 'columnCount', + 'columnFill', + 'columnGap', + 'columnHeight', + 'columnRule', + 'columnRuleColor', + 'columnRuleStyle', + 'columnRuleWidth', + 'columnSpan', + 'columnWidth', + 'columnWrap', + 'columns', + 'contain', + 'containIntrinsicBlockSize', + 'containIntrinsicHeight', + 'containIntrinsicInlineSize', + 'containIntrinsicSize', + 'containIntrinsicWidth', + 'container', + 'containerName', + 'containerType', + 'content', + 'contentVisibility', + 'cornerBlockEndShape', + 'cornerBlockStartShape', + 'cornerBottomLeftShape', + 'cornerBottomRightShape', + 'cornerBottomShape', + 'cornerEndEndShape', + 'cornerEndStartShape', + 'cornerInlineEndShape', + 'cornerInlineStartShape', + 'cornerLeftShape', + 'cornerRightShape', + 'cornerShape', + 'cornerStartEndShape', + 'cornerStartStartShape', + 'cornerTopLeftShape', + 'cornerTopRightShape', + 'cornerTopShape', + 'counterIncrement', + 'counterReset', + 'counterSet', + 'cursor', + 'cx', + 'cy', + 'd', + 'direction', + 'display', + 'dominantBaseline', + 'dynamicRangeLimit', + 'emptyCells', + 'fieldSizing', + 'fill', + 'fillOpacity', + 'fillRule', + 'filter', + 'flex', + 'flexBasis', + 'flexDirection', + 'flexFlow', + 'flexGrow', + 'flexLineCount', + 'flexShrink', + 'flexWrap', + 'float', + 'floodColor', + 'floodOpacity', + 'font', + 'fontFamily', + 'fontFeatureSettings', + 'fontKerning', + 'fontLanguageOverride', + 'fontOpticalSizing', + 'fontPalette', + 'fontSize', + 'fontSizeAdjust', + 'fontSmooth', + 'fontStretch', + 'fontStyle', + 'fontSynthesis', + 'fontSynthesisPosition', + 'fontSynthesisSmallCaps', + 'fontSynthesisStyle', + 'fontSynthesisWeight', + 'fontVariant', + 'fontVariantAlternates', + 'fontVariantCaps', + 'fontVariantEastAsian', + 'fontVariantEmoji', + 'fontVariantLigatures', + 'fontVariantNumeric', + 'fontVariantPosition', + 'fontVariationSettings', + 'fontWeight', + 'fontWidth', + 'forcedColorAdjust', + 'frameSizing', + 'gap', + 'grid', + 'gridArea', + 'gridAutoColumns', + 'gridAutoFlow', + 'gridAutoRows', + 'gridColumn', + 'gridColumnEnd', + 'gridColumnGap', + 'gridColumnStart', + 'gridGap', + 'gridRow', + 'gridRowEnd', + 'gridRowGap', + 'gridRowStart', + 'gridTemplate', + 'gridTemplateAreas', + 'gridTemplateColumns', + 'gridTemplateRows', + 'hangingPunctuation', + 'height', + 'hyphenateCharacter', + 'hyphenateLimitChars', + 'hyphens', + 'imageOrientation', + 'imageRendering', + 'imageResolution', + 'imeMode', + 'initialLetter', + 'initialLetterAlign', + 'inlineSize', + 'inset', + 'insetBlock', + 'insetBlockEnd', + 'insetBlockStart', + 'insetInline', + 'insetInlineEnd', + 'insetInlineStart', + 'interactivity', + 'interestDelay', + 'interestDelayEnd', + 'interestDelayStart', + 'interpolateSize', + 'isolation', + 'justifyContent', + 'justifyItems', + 'justifySelf', + 'justifyTracks', + 'left', + 'letterSpacing', + 'lightingColor', + 'lineBreak', + 'lineClamp', + 'lineHeight', + 'lineHeightStep', + 'linkParameters', + 'listStyle', + 'listStyleImage', + 'listStylePosition', + 'listStyleType', + 'margin', + 'marginBlock', + 'marginBlockEnd', + 'marginBlockStart', + 'marginBottom', + 'marginInline', + 'marginInlineEnd', + 'marginInlineStart', + 'marginLeft', + 'marginRight', + 'marginTop', + 'marginTrim', + 'marker', + 'markerEnd', + 'markerMid', + 'markerStart', + 'mask', + 'maskBorder', + 'maskBorderMode', + 'maskBorderOutset', + 'maskBorderRepeat', + 'maskBorderSlice', + 'maskBorderSource', + 'maskBorderWidth', + 'maskClip', + 'maskComposite', + 'maskImage', + 'maskMode', + 'maskOrigin', + 'maskPosition', + 'maskRepeat', + 'maskSize', + 'maskType', + 'masonryAutoFlow', + 'mathDepth', + 'mathShift', + 'mathStyle', + 'maxBlockSize', + 'maxHeight', + 'maxInlineSize', + 'maxLines', + 'maxWidth', + 'minBlockSize', + 'minHeight', + 'minInlineSize', + 'minWidth', + 'mixBlendMode', + 'msAccelerator', + 'msBlockProgression', + 'msContentZoomChaining', + 'msContentZoomLimit', + 'msContentZoomLimitMax', + 'msContentZoomLimitMin', + 'msContentZoomSnap', + 'msContentZoomSnapPoints', + 'msContentZoomSnapType', + 'msContentZooming', + 'msFilter', + 'msFlowFrom', + 'msFlowInto', + 'msGridColumns', + 'msGridRows', + 'msHighContrastAdjust', + 'msHyphenateLimitChars', + 'msHyphenateLimitLines', + 'msHyphenateLimitZone', + 'msImeAlign', + 'msOverflowStyle', + 'msScrollChaining', + 'msScrollLimit', + 'msScrollLimitXMax', + 'msScrollLimitXMin', + 'msScrollLimitYMax', + 'msScrollLimitYMin', + 'msScrollRails', + 'msScrollSnapPointsX', + 'msScrollSnapPointsY', + 'msScrollSnapType', + 'msScrollSnapX', + 'msScrollSnapY', + 'msScrollTranslation', + 'msScrollbar3dlightColor', + 'msScrollbarArrowColor', + 'msScrollbarBaseColor', + 'msScrollbarDarkshadowColor', + 'msScrollbarFaceColor', + 'msScrollbarHighlightColor', + 'msScrollbarShadowColor', + 'msScrollbarTrackColor', + 'msTextAutospace', + 'msTouchSelect', + 'msUserSelect', + 'msWrapFlow', + 'msWrapMargin', + 'msWrapThrough', + 'objectFit', + 'objectPosition', + 'objectViewBox', + 'offset', + 'offsetAnchor', + 'offsetDistance', + 'offsetPath', + 'offsetPosition', + 'offsetRotate', + 'opacity', + 'order', + 'orphans', + 'outline', + 'outlineColor', + 'outlineOffset', + 'outlineStyle', + 'outlineWidth', + 'overflow', + 'overflowAnchor', + 'overflowBlock', + 'overflowClipBox', + 'overflowClipMargin', + 'overflowInline', + 'overflowWrap', + 'overflowX', + 'overflowY', + 'overlay', + 'overscrollBehavior', + 'overscrollBehaviorBlock', + 'overscrollBehaviorInline', + 'overscrollBehaviorX', + 'overscrollBehaviorY', + 'padding', + 'paddingBlock', + 'paddingBlockEnd', + 'paddingBlockStart', + 'paddingBottom', + 'paddingInline', + 'paddingInlineEnd', + 'paddingInlineStart', + 'paddingLeft', + 'paddingRight', + 'paddingTop', + 'page', + 'pageBreakAfter', + 'pageBreakBefore', + 'pageBreakInside', + 'paintOrder', + 'pathLength', + 'perspective', + 'perspectiveOrigin', + 'placeContent', + 'placeItems', + 'placeSelf', + 'pointerEvents', + 'position', + 'positionAnchor', + 'positionArea', + 'positionTry', + 'positionTryFallbacks', + 'positionTryOrder', + 'positionVisibility', + 'printColorAdjust', + 'quotes', + 'r', + 'readingFlow', + 'readingOrder', + 'resize', + 'right', + 'rotate', + 'rowGap', + 'rubyAlign', + 'rubyMerge', + 'rubyOverhang', + 'rubyPosition', + 'rx', + 'ry', + 'scale', + 'scrollAxisLock', + 'scrollBehavior', + 'scrollInitialTarget', + 'scrollMargin', + 'scrollMarginBlock', + 'scrollMarginBlockEnd', + 'scrollMarginBlockStart', + 'scrollMarginBottom', + 'scrollMarginInline', + 'scrollMarginInlineEnd', + 'scrollMarginInlineStart', + 'scrollMarginLeft', + 'scrollMarginRight', + 'scrollMarginTop', + 'scrollMarkerGroup', + 'scrollPadding', + 'scrollPaddingBlock', + 'scrollPaddingBlockEnd', + 'scrollPaddingBlockStart', + 'scrollPaddingBottom', + 'scrollPaddingInline', + 'scrollPaddingInlineEnd', + 'scrollPaddingInlineStart', + 'scrollPaddingLeft', + 'scrollPaddingRight', + 'scrollPaddingTop', + 'scrollSnapAlign', + 'scrollSnapStop', + 'scrollSnapType', + 'scrollTargetGroup', + 'scrollTimeline', + 'scrollTimelineAxis', + 'scrollTimelineName', + 'scrollbarColor', + 'scrollbarGutter', + 'scrollbarWidth', + 'shapeImageThreshold', + 'shapeMargin', + 'shapeOutside', + 'shapeRendering', + 'speakAs', + 'stopColor', + 'stopOpacity', + 'stroke', + 'strokeColor', + 'strokeDasharray', + 'strokeDashoffset', + 'strokeLinecap', + 'strokeLinejoin', + 'strokeMiterlimit', + 'strokeOpacity', + 'strokeWidth', + 'tabSize', + 'tableLayout', + 'textAlign', + 'textAlignLast', + 'textAnchor', + 'textAutospace', + 'textBox', + 'textBoxEdge', + 'textBoxTrim', + 'textCombineUpright', + 'textDecoration', + 'textDecorationColor', + 'textDecorationInset', + 'textDecorationLine', + 'textDecorationSkip', + 'textDecorationSkipInk', + 'textDecorationStyle', + 'textDecorationThickness', + 'textEmphasis', + 'textEmphasisColor', + 'textEmphasisPosition', + 'textEmphasisStyle', + 'textFit', + 'textIndent', + 'textJustify', + 'textOrientation', + 'textOverflow', + 'textRendering', + 'textShadow', + 'textSizeAdjust', + 'textSpacingTrim', + 'textTransform', + 'textUnderlineOffset', + 'textUnderlinePosition', + 'textWrap', + 'textWrapMode', + 'textWrapStyle', + 'timelineScope', + 'timelineTrigger', + 'timelineTriggerActivationRange', + 'timelineTriggerActivationRangeEnd', + 'timelineTriggerActivationRangeStart', + 'timelineTriggerActiveRange', + 'timelineTriggerActiveRangeEnd', + 'timelineTriggerActiveRangeStart', + 'timelineTriggerName', + 'timelineTriggerSource', + 'top', + 'touchAction', + 'transform', + 'transformBox', + 'transformOrigin', + 'transformStyle', + 'transition', + 'transitionBehavior', + 'transitionDelay', + 'transitionDuration', + 'transitionProperty', + 'transitionTimingFunction', + 'translate', + 'triggerScope', + 'unicodeBidi', + 'userSelect', + 'vectorEffect', + 'verticalAlign', + 'viewTimeline', + 'viewTimelineAxis', + 'viewTimelineInset', + 'viewTimelineName', + 'viewTransitionClass', + 'viewTransitionName', + 'viewTransitionScope', + 'visibility', + 'whiteSpace', + 'whiteSpaceCollapse', + 'widows', + 'width', + 'willChange', + 'windowDrag', + 'wordBreak', + 'wordSpacing', + 'wordWrap', + 'writingMode', + 'x', + 'y', + 'zIndex', + 'zoom', +] as const + +export const BUILTIN_SHORTHAND_TARGETS = { + bg: ['background'], + bgAttachment: ['background-attachment'], + bgClip: ['background-clip'], + bgColor: ['background-color'], + bgImage: ['background-image'], + bgImg: ['background-image'], + bgOrigin: ['background-origin'], + bgPosition: ['background-position'], + bgPositionX: ['background-position-x'], + bgPositionY: ['background-position-y'], + bgPos: ['background-position'], + bgPosX: ['background-position-x'], + bgPosY: ['background-position-y'], + bgRepeat: ['background-repeat'], + bgSize: ['background-size'], + bgBlendMode: ['background-blend-mode'], + backgroundImg: ['background-image'], + animationDir: ['animation-direction'], + flexDir: ['flex-direction'], + pos: ['position'], + m: ['margin'], + mt: ['margin-top'], + mr: ['margin-right'], + mb: ['margin-bottom'], + ml: ['margin-left'], + p: ['padding'], + pt: ['padding-top'], + pr: ['padding-right'], + pb: ['padding-bottom'], + pl: ['padding-left'], + w: ['width'], + h: ['height'], + minW: ['min-width'], + minH: ['min-height'], + maxW: ['max-width'], + maxH: ['max-height'], + mx: ['margin-left', 'margin-right'], + my: ['margin-top', 'margin-bottom'], + px: ['padding-left', 'padding-right'], + py: ['padding-top', 'padding-bottom'], + boxSize: ['width', 'height'], + borderBottomRadius: [ + 'border-bottom-left-radius', + 'border-bottom-right-radius', + ], + borderTopRadius: ['border-top-left-radius', 'border-top-right-radius'], + borderLeftRadius: ['border-top-left-radius', 'border-bottom-left-radius'], + borderRightRadius: ['border-top-right-radius', 'border-bottom-right-radius'], + objectPos: ['object-position'], + offsetPos: ['offset-position'], + maskPos: ['mask-position'], + maskImg: ['mask-image'], +} as const diff --git a/packages/plugin-utils/src/shorthands.ts b/packages/plugin-utils/src/shorthands.ts new file mode 100644 index 000000000..14446301e --- /dev/null +++ b/packages/plugin-utils/src/shorthands.ts @@ -0,0 +1,121 @@ +import { BUILTIN_SHORTHAND_TARGETS, CSS_PROPERTY_NAMES } from './property-names' + +type KebabTail = S extends `${infer Head}${infer Tail}` + ? `${Head extends Lowercase ? Head : `-${Lowercase}`}${KebabTail}` + : S +type Kebab = + KebabTail extends `-${infer Rest}` ? Rest : KebabTail +type CssName = (typeof CSS_PROPERTY_NAMES)[number] +type CssKebabName = S extends + `Webkit${string}` | `Moz${string}` | `ms${string}` | `O${string}` + ? `-${Kebab}` + : Kebab + +export type CustomShorthandTarget = + | CssName + | CssKebabName + | keyof typeof BUILTIN_SHORTHAND_TARGETS + | `--${string}` + +export interface ShorthandErrorLocation { + readonly alias: string | null + readonly index: number | null + readonly target: unknown +} + +export class ShorthandConfigError extends Error { + readonly alias: string | null + readonly index: number | null + readonly target: unknown + readonly path: string + + constructor(location: ShorthandErrorLocation, requirement: string) { + const { alias, index, target } = location + const path = `shorthands${alias === null ? '' : `[${JSON.stringify(alias)}]`}${index === null ? '' : `[${index}]`}` + super( + `devup-ui option ${path}: shorthand ${JSON.stringify(alias)} cannot use ${typeof target === 'string' ? JSON.stringify(target) : typeof target} at build time: ${requirement}`, + ) + this.name = 'ShorthandConfigError' + this.alias = alias + this.index = index + this.target = target + this.path = path + } +} + +function kebabName(name: string): string { + const prefix = /^(Webkit|Moz|ms|O)[A-Z]/.test(name) ? '-' : '' + return ( + prefix + + name.replace( + /[A-Z]/g, + (letter, index: number) => + `${index === 0 ? '' : '-'}${letter.toLowerCase()}`, + ) + ) +} + +const TARGETS = new Map( + CSS_PROPERTY_NAMES.flatMap((name) => { + const canonical = kebabName(name) + return [ + [name, [canonical]], + [canonical, [canonical]], + ] satisfies [string, readonly string[]][] + }), +) +for (const [alias, targets] of Object.entries(BUILTIN_SHORTHAND_TARGETS)) { + TARGETS.set(alias, targets) +} + +export function isCustomShorthandTarget( + target: unknown, +): target is CustomShorthandTarget { + return ( + typeof target === 'string' && + (TARGETS.has(target) || + /^--[A-Za-z0-9_\-\u0080-\u{10FFFF}]+$/u.test(target)) + ) +} + +function isRecord(input: unknown): input is Record { + return typeof input === 'object' && input !== null && !Array.isArray(input) +} + +function isTargets(targets: unknown): targets is readonly unknown[] { + return Array.isArray(targets) +} + +export function normalizeShorthands( + input: unknown, +): Record { + if (!isRecord(input)) { + throw new ShorthandConfigError( + { alias: null, index: null, target: input }, + 'shorthands must be an object', + ) + } + const entries: [string, readonly string[]][] = [] + for (const [alias, targets] of Object.entries(input).sort(([a], [b]) => + a < b ? -1 : a > b ? 1 : 0, + )) { + if (!isTargets(targets)) { + throw new ShorthandConfigError( + { alias, index: null, target: targets }, + 'its targets must be an array', + ) + } + const normalized: string[] = [] + for (const [index, target] of targets.entries()) { + if (!isCustomShorthandTarget(target)) { + throw new ShorthandConfigError( + { alias, index, target }, + 'its targets must be supported CSS properties, built-in aliases or custom properties', + ) + } + normalized.push(...(TARGETS.get(target) ?? [target])) + } + entries.push([alias, normalized]) + } + return Object.fromEntries(entries) +} diff --git a/packages/plugin-utils/src/types.ts b/packages/plugin-utils/src/types.ts index f0c251b4b..bc8d5d65c 100644 --- a/packages/plugin-utils/src/types.ts +++ b/packages/plugin-utils/src/types.ts @@ -1,3 +1,5 @@ +import type { CustomShorthandTarget } from './shorthands' + /** * Typography definition for a single breakpoint or non-responsive typography */ @@ -64,7 +66,7 @@ export interface DevupTheme { } /** Custom style prop aliases mapped to one or more CSS property names. */ -export type CustomShorthands = Record +export type CustomShorthands = Record /** * Devup configuration file structure (devup.json) diff --git a/packages/react/src/compat/stylex.d.ts b/packages/react/src/compat/stylex.d.ts index 955c3c59f..f6e585b71 100644 --- a/packages/react/src/compat/stylex.d.ts +++ b/packages/react/src/compat/stylex.d.ts @@ -1,21 +1,39 @@ declare module '@stylexjs/stylex' { import { stylex } from '@devup-ui/react' - export { - attrs, - create, - createTheme, - createThemeContract, - defineConsts, - defineVars, - firstThatWorks, - include, - keyframes, - positionTry, - props, - types, - viewTransitionClass, - } from '@devup-ui/react/stylex' + export const attrs: typeof stylex.attrs + export const create: typeof stylex.create + export const createTheme: typeof stylex.createTheme + export const createThemeContract: typeof stylex.createThemeContract + export const defineConsts: typeof stylex.defineConsts + export const defineVars: typeof stylex.defineVars + export const firstThatWorks: typeof stylex.firstThatWorks + export const include: typeof stylex.include + export const keyframes: typeof stylex.keyframes + export const positionTry: typeof stylex.positionTry + export const props: typeof stylex.props + export const types: typeof stylex.types + export const viewTransitionClass: typeof stylex.viewTransitionClass + + export type StyleXAttrs = stylex.StyleXAttrs + export type StyleXCompiledStyle

= stylex.StyleXCompiledStyle

+ export type StyleXDeclarations = stylex.StyleXDeclarations + export type StyleXDynamicDeclarations = stylex.StyleXDynamicDeclarations + export type StyleXFlatDeclarations = stylex.StyleXFlatDeclarations + export type StyleXInclude

= stylex.StyleXInclude

+ export type StyleXInlineStyle = stylex.StyleXInlineStyle + export type StyleXProps = stylex.StyleXProps + export type StyleXRawStyles = stylex.StyleXRawStyles + export type StyleXStyleInput

= stylex.StyleXStyleInput

+ export type StyleXStyles

= stylex.StyleXStyles

+ export type StyleXThemeOverrides = + stylex.StyleXThemeOverrides + export type StyleXTypes = stylex.StyleXTypes + export type StyleXValue = stylex.StyleXValue + export type StyleXVariableValue = + stylex.StyleXVariableValue + export type StyleXVarGroup = + stylex.StyleXVarGroup export default stylex } diff --git a/packages/react/src/types/props/css-properties.ts b/packages/react/src/types/props/css-properties.ts index 3796749ba..9499ef0d4 100644 --- a/packages/react/src/types/props/css-properties.ts +++ b/packages/react/src/types/props/css-properties.ts @@ -4,6 +4,28 @@ import type { ResponsiveValue } from '../responsive-value' type Responsive = { [K in keyof T]?: ResponsiveValue } +type UnsupportedProperties = + | 'boxAlign' + | 'boxPack' + | 'boxFlex' + | 'boxFlexGroup' + | 'boxOrient' + | 'boxOrdinalGroup' + | 'boxDirection' + | 'boxLines' + | 'flexOrder' + | 'flexPositive' + | 'flexNegative' + | 'flexPreferredSize' + | 'scrollSnapCoordinate' + | 'scrollSnapDestination' + | 'scrollSnapPointsX' + | 'scrollSnapPointsY' + | 'scrollSnapTypeX' + | 'scrollSnapTypeY' + +export type SupportedCssProperties = Omit + // Keep an interface boundary so consumers can reuse cached type relationships. // eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface DevupCssProperties extends Responsive {} +export interface DevupCssProperties extends Responsive {} diff --git a/packages/react/src/types/stylex-values.ts b/packages/react/src/types/stylex-values.ts new file mode 100644 index 000000000..e260a8da3 --- /dev/null +++ b/packages/react/src/types/stylex-values.ts @@ -0,0 +1,95 @@ +import type { SupportedCssProperties } from './props/css-properties' +export type StyleXScalar = string | number +export type StyleXAtRule = + `@media ${string}` | `@supports ${string}` | `@container ${string}` + +/** Variable conditions may nest at-rules, but cannot depend on an element. */ +export type StyleXVariableValue = + | T + | null + | { + readonly default?: StyleXVariableValue + readonly [condition: StyleXAtRule]: StyleXVariableValue + } + +export type StyleXValue = + | T + | null + | { + readonly default?: StyleXValue + readonly [condition: StyleXAtRule | `:${string}`]: StyleXValue + } + +export type StyleXCheckedValue = V extends object + ? { + readonly [K in keyof V]: K extends C ? StyleXCheckedValue : never + } + : V + +export type StyleXCheckedVariableValue = StyleXCheckedValue< + V, + 'default' | StyleXAtRule +> + +type CssProperties = SupportedCssProperties + +export type StyleXDeclarations = { + readonly [K in keyof CssProperties]?: StyleXValue +} & { + readonly [property: `--${string}`]: StyleXValue +} + +/** Dynamic namespace bodies and named declaration blocks are flat. */ +export type StyleXFlatDeclarations = { + readonly [K in keyof CssProperties]?: CssProperties[K] +} & { + readonly [property: `--${string}`]: StyleXScalar +} + +export type StyleXDynamicDeclarations = { + readonly [K in keyof CssProperties]?: CssProperties[K] | null +} & { + readonly [property: `--${string}`]: StyleXScalar | null +} + +export interface StyleXTypes { + angle>( + value: T & StyleXCheckedVariableValue, + ): T + color>( + value: T & StyleXCheckedVariableValue, + ): T + image>( + value: T & StyleXCheckedVariableValue, + ): T + integer>( + value: T & StyleXCheckedVariableValue, + ): T + length>( + value: T & StyleXCheckedVariableValue, + ): T + lengthPercentage>( + value: T & StyleXCheckedVariableValue, + ): T + number>( + value: T & StyleXCheckedVariableValue, + ): T + percentage>( + value: T & StyleXCheckedVariableValue, + ): T + resolution>( + value: T & StyleXCheckedVariableValue, + ): T + time>( + value: T & StyleXCheckedVariableValue, + ): T + transformFunction>( + value: T & StyleXCheckedVariableValue, + ): T + transformList>( + value: T & StyleXCheckedVariableValue, + ): T + url>( + value: T & StyleXCheckedVariableValue, + ): T +} diff --git a/packages/react/src/types/stylex.ts b/packages/react/src/types/stylex.ts new file mode 100644 index 000000000..dc3e590f0 --- /dev/null +++ b/packages/react/src/types/stylex.ts @@ -0,0 +1,112 @@ +import type { + StyleXAtRule, + StyleXCheckedValue, + StyleXCheckedVariableValue, + StyleXDeclarations, + StyleXDynamicDeclarations, + StyleXVariableValue, +} from './stylex-values' + +declare const compiledStyle: unique symbol +declare const variableGroup: unique symbol +declare const includedStyle: unique symbol + +/** Opaque build output, not a raw CSS object or a user-provided class string. */ +export type StyleXCompiledStyle

= { + readonly [compiledStyle]: P +} + +export type StyleXStyles

= + StyleXCompiledStyle

| false | null | undefined | readonly StyleXStyles

[] + +export type StyleXStyleInput

= + StyleXStyles

| string | readonly StyleXStyleInput

[] + +export type StyleXInclude

= { + readonly [includedStyle]: P +} + +export type StyleXRawStyles = StyleXDeclarations & { + readonly [pseudo: `:${string}`]: StyleXDeclarations +} & Partial + +export type StyleXNamespace = + StyleXRawStyles | null | ((...args: never[]) => StyleXDynamicDeclarations) + +type StyleLeaves = T extends object ? StyleLeaves : T +type DeclaredStyleShape = { + readonly [ + K in keyof S as K extends keyof StyleXDeclarations ? K : never + ]: StyleLeaves +} + +type StyleShape = + S extends StyleXInclude + ? Omit> & DeclaredStyleShape + : DeclaredStyleShape + +type CheckedDeclarations = { + readonly [K in keyof S]: K extends keyof StyleXDeclarations + ? StyleXCheckedValue + : never +} + +type CheckedRawStyles = { + readonly [K in keyof S]: K extends keyof StyleXDeclarations + ? StyleXCheckedValue + : K extends `:${string}` + ? CheckedDeclarations + : K extends typeof includedStyle + ? S[K] + : never +} + +export type StyleXCompiledNamespaces = { + readonly [K in keyof S]: S[K] extends (...args: infer A) => infer R + ? (...args: A) => StyleXCompiledStyle> + : StyleXCompiledStyle> +} + +export type StyleXCheckedNamespaces = { + readonly [K in keyof S]: S[K] extends null + ? null + : S[K] extends (...args: infer A) => infer R + ? ( + ...args: A + ) => R & + Record, never> + : S[K] & CheckedRawStyles +} + +export type StyleXVarGroup = { + readonly [variableGroup]: K +} & Readonly> + +export type StyleXThemeOverrides = Readonly< + Partial> +> + +export type StyleXCheckedVariables = { + readonly [K in keyof V]: StyleXCheckedVariableValue +} + +export type StyleXConstants = { + readonly [K in keyof C]: C[K] extends string | number | boolean + ? `${C[K]}` + : never +} + +export type StyleXInlineStyle = Readonly< + Record +> + +export type StyleXProps = { + readonly className: string + readonly style?: StyleXInlineStyle +} + +/** HTML attributes use style text; React props keep an inline style object. */ +export type StyleXAttrs = { + readonly class: string + readonly style?: string +} diff --git a/packages/react/src/utils/__tests__/stylex.test.ts b/packages/react/src/utils/__tests__/stylex.test.ts index 6d5b4a953..4814939c5 100644 --- a/packages/react/src/utils/__tests__/stylex.test.ts +++ b/packages/react/src/utils/__tests__/stylex.test.ts @@ -67,9 +67,9 @@ describe('stylex', () => { }) it('include should throw at runtime', () => { - expect(() => include({ color: 'red' })).toThrowError( - 'Cannot run on the runtime', - ) + expect(() => + Reflect.apply(include, undefined, [{ color: 'red' }]), + ).toThrowError('Cannot run on the runtime') }) it('defineVars should throw at runtime', () => { @@ -80,7 +80,10 @@ describe('stylex', () => { it('createTheme should throw at runtime', () => { expect(() => - createTheme({ primary: '--x' }, { primary: 'blue' }), + Reflect.apply(createTheme, undefined, [ + { primary: '--x' }, + { primary: 'blue' }, + ]), ).toThrowError('Cannot run on the runtime') }) }) diff --git a/packages/react/src/utils/stylex.ts b/packages/react/src/utils/stylex.ts index e0f051acd..3e8fedbd8 100644 --- a/packages/react/src/utils/stylex.ts +++ b/packages/react/src/utils/stylex.ts @@ -1,84 +1,118 @@ -type StyleValue = string | number | null | undefined - -type StyleProperties = Record> - -interface StyleXTypes { - angle(value: T): T - color(value: T): T - image(value: T): T - integer(value: T): T - length(value: T): T - lengthPercentage(value: T): T - number(value: T): T - percentage(value: T): T - resolution(value: T): T - time(value: T): T - transformFunction(value: T): T - transformList(value: T): T - url(value: T): T -} - -export function create>( - _styles: S, -): { readonly [K in keyof S]: S[K] } { +import type { DevupCommonProps } from '../types/props' +import type { + StyleXAttrs, + StyleXCheckedNamespaces, + StyleXCheckedVariables, + StyleXCompiledNamespaces, + StyleXCompiledStyle, + StyleXConstants, + StyleXInclude, + StyleXNamespace, + StyleXProps, + StyleXStyleInput, + StyleXThemeOverrides, + StyleXVarGroup, +} from '../types/stylex' +import type { + StyleXFlatDeclarations, + StyleXScalar, + StyleXTypes, + StyleXVariableValue, +} from '../types/stylex-values' + +export type { + StyleXAttrs, + StyleXCompiledStyle, + StyleXInclude, + StyleXInlineStyle, + StyleXProps, + StyleXRawStyles, + StyleXStyleInput, + StyleXStyles, + StyleXThemeOverrides, + StyleXVarGroup, +} from '../types/stylex' +export type { + StyleXDeclarations, + StyleXDynamicDeclarations, + StyleXFlatDeclarations, + StyleXTypes, + StyleXValue, + StyleXVariableValue, +} from '../types/stylex-values' + +export function create< + const S extends Readonly>, +>(_styles: S & StyleXCheckedNamespaces): StyleXCompiledNamespaces { throw new Error('Cannot run on the runtime') } -export function props( - ..._styles: ReadonlyArray -): { className?: string; style?: Record } { +export function props(..._styles: readonly StyleXStyleInput[]): StyleXProps { throw new Error('Cannot run on the runtime') } -export function attrs( - ..._styles: ReadonlyArray -): { class?: string; style?: string } { +export function attrs(..._styles: readonly StyleXStyleInput[]): StyleXAttrs { throw new Error('Cannot run on the runtime') } -export function keyframes(_frames: Record): string { +export function keyframes( + _frames: Readonly>, +): string { throw new Error('Cannot run on the runtime') } -export function firstThatWorks(..._values: T[]): T { +export function firstThatWorks( + ..._values: V +): V[number] { throw new Error('Cannot run on the runtime') } -export function include(_style: S): S { +export function include

(_style: StyleXCompiledStyle

): StyleXInclude

{ throw new Error('Cannot run on the runtime') } -export function defineVars>( - _vars: V, -): { readonly [K in keyof V]: string } { +export function defineVars< + const V extends Readonly>, +>( + _vars: V & StyleXCheckedVariables, +): StyleXVarGroup> { throw new Error('Cannot run on the runtime') } -export function createTheme>( - _vars: V, - - _overrides: { readonly [K in keyof V]: StyleValue }, -): Record { +export function createTheme< + K extends string, + const O extends Readonly>, +>( + _vars: StyleXVarGroup, + _overrides: O & + StyleXCheckedVariables & + StyleXThemeOverrides> & + Record>, never>, +): StyleXCompiledStyle { throw new Error('Cannot run on the runtime') } -export function createThemeContract>( - _vars: V, -): { readonly [K in keyof V]: string } { +export function createThemeContract< + const V extends Readonly>, +>(_vars: V): StyleXVarGroup> { throw new Error('Cannot run on the runtime') } -export function defineConsts>( - _consts: V, -): { readonly [K in keyof V]: V[K] } { +export function defineConsts< + const V extends Readonly>, +>(_consts: V): StyleXConstants { throw new Error('Cannot run on the runtime') } -export function positionTry(_fallback: StyleProperties): string { +export function positionTry( + _fallback: D & Record, never>, +): string { throw new Error('Cannot run on the runtime') } -export function viewTransitionClass(_styles: StyleProperties): string { +export function viewTransitionClass( + _styles: D & Record, never>, +): string { throw new Error('Cannot run on the runtime') } diff --git a/packages/react/type-tests/dead-css-properties.ts b/packages/react/type-tests/dead-css-properties.ts new file mode 100644 index 000000000..d6f0e817c --- /dev/null +++ b/packages/react/type-tests/dead-css-properties.ts @@ -0,0 +1,41 @@ +import type { DevupProps } from '../src' +import type { StyleXRawStyles } from '../src/utils/stylex' + +type Assert = T +type DeadCssProperty = + | 'boxAlign' + | 'boxPack' + | 'boxFlex' + | 'boxFlexGroup' + | 'boxOrient' + | 'boxOrdinalGroup' + | 'boxDirection' + | 'boxLines' + | 'flexOrder' + | 'flexPositive' + | 'flexNegative' + | 'flexPreferredSize' + | 'scrollSnapCoordinate' + | 'scrollSnapDestination' + | 'scrollSnapPointsX' + | 'scrollSnapPointsY' + | 'scrollSnapTypeX' + | 'scrollSnapTypeY' + +export type DeadPropertiesAreRejected = Assert< + Extract extends never ? true : false +> + +export type DeadStyleXPropertiesAreRejected = Assert< + Extract extends never ? true : false +> + +export const compatibilityProperties = { + WebkitBoxAlign: 'center', + WebkitBoxFlex: 2, + WebkitBoxOrient: 'vertical', + strokeColor: 'red', + imeMode: 'active', + scrollSnapType: 'x mandatory', + _hover: { WebkitBoxFlex: 3 }, +} satisfies DevupProps diff --git a/packages/react/type-tests/stylex.ts b/packages/react/type-tests/stylex.ts new file mode 100644 index 000000000..48c751df6 --- /dev/null +++ b/packages/react/type-tests/stylex.ts @@ -0,0 +1,219 @@ +import stylex, { + create as namedCreate, + defineVars as namedDefineVars, + props as namedProps, + type StyleXCompiledStyle, + type StyleXDynamicDeclarations, + type StyleXFlatDeclarations, + type StyleXRawStyles, + type StyleXStyles, + type StyleXVarGroup, +} from '@stylexjs/stylex' + +import * as direct from '../src/utils/stylex' + +type Assert = T +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false +type Accepts = [A] extends [B] ? true : false +type Rejects = Accepts extends false ? true : false +type InvalidVariableCondition = { + readonly default: 'red' + readonly ':hover': 'blue' +} +type InvalidVariableGroup = { readonly group: { readonly color: 'red' } } +type OfficialTransitionOptions = { readonly new: { readonly opacity: 0 } } +type ContractInput = Parameters[0] +type ConstantsInput = Parameters[0] +type ExtraThemeInput = Parameters< + typeof direct.createTheme<'text', { missing: 'red' }> +>[1] +type InvalidConditionInput = Parameters< + typeof direct.defineVars<{ text: InvalidVariableCondition }> +>[0] +type InvalidGroupInput = Parameters< + typeof direct.defineVars +>[0] +type OfficialTransitionInput = Parameters< + typeof direct.viewTransitionClass +>[0] + +const colors = namedDefineVars({ + text: { default: 'black', '@media print': 'gray' }, + accent: stylex.types.color({ + default: 'blue', + '@supports (color: oklch(0 0 0))': { + default: 'oklch(0.6 0.2 250)', + '@container sidebar (min-width: 400px)': 'red', + }, + }), + blank: null, +}) +const contract = stylex.createThemeContract({ + primary: null, + size: 'placeholder', +}) +const partialTheme = stylex.createTheme(colors, { text: 'navy' }) +const conditionTheme = stylex.createTheme(contract, { + primary: { default: 'red', '@media print': null }, +}) +const emptyTheme = stylex.createTheme(colors, {}) +const _consts = stylex.defineConsts({ + gap: 8, + flag: true, + color: 'red', + zero: -0, +}) +const animation = stylex.keyframes({ from: { opacity: 0 }, to: { opacity: 1 } }) +const fallback = stylex.firstThatWorks(4, 'auto') +const _emptyFallback = stylex.firstThatWorks() +const base = namedCreate({ + root: { + color: colors.text, + width: { default: fallback, ':hover': { '@media print': '50%' } }, + ':focus': { outlineColor: 'red' }, + animationName: animation, + '--local': 1, + }, + empty: null, + dynamic: (height: number, color: string | null) => ({ height, color }), +}) +const included = stylex.include(base.root) +const styles = stylex.create({ root: { ...included, color: 'blue' } }) +const _inherited = stylex.create({ root: { ...included, opacity: 1 } }) +declare const enabled: boolean +declare const external: StyleXStyles +declare const externalClass: string +const composition = [ + base.root, + [enabled && styles.root, null, undefined, false], +] as const +export const reactProps = namedProps( + partialTheme, + conditionTheme, + emptyTheme, + base.empty, + base.dynamic(20, null), + composition, + external, + 'external', + [externalClass, ['nested-external']], +) +export const domAttrs = stylex.attrs(composition, base.dynamic(10, 'red')) +export const tryName = stylex.positionTry({ top: 0, insetBlockEnd: 'auto' }) +export const transitionName = stylex.viewTransitionClass({ + animationDuration: '1s', +}) +export const scalarWrappers = [ + stylex.types.angle('1deg'), + stylex.types.color('red'), + stylex.types.image('url(image.png)'), + stylex.types.integer(1), + stylex.types.length(2), + stylex.types.lengthPercentage('50%'), + stylex.types.number(0.5), + stylex.types.percentage('20%'), + stylex.types.resolution('2dppx'), + stylex.types.time('1s'), + stylex.types.transformFunction('scale(2)'), + stylex.types.transformList('scale(2) translateX(1px)'), + stylex.types.url('url(image.png)'), +] as const +export const conditionalWrappers = [ + stylex.types.angle({ default: 1, '@media print': '0deg' }), + stylex.types.color({ default: 'red', '@media print': null }), + stylex.types.image({ default: 'none', '@media print': 'url(print.png)' }), + stylex.types.integer({ default: 1, '@media print': 2 }), + stylex.types.length({ default: 2, '@media print': '1px' }), + stylex.types.lengthPercentage({ default: 0, '@media print': '50%' }), + stylex.types.number({ default: 1, '@media print': 0 }), + stylex.types.percentage({ default: 0, '@media print': '20%' }), + stylex.types.resolution({ default: '1dppx', '@media print': '2dppx' }), + stylex.types.time({ default: '1s', '@media print': '0s' }), + stylex.types.transformFunction({ default: 'scale(2)', '@media print': null }), + stylex.types.transformList({ default: 'none', '@media print': null }), + stylex.types.url({ default: 'url(image.png)', '@media print': null }), +] as const + +export type StyleXContractChecks = [ + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert< + Equal< + Parameters, + [height: number, color: string | null] + > + >, + Assert, StyleXCompiledStyle>>, + Assert>>, + Assert>>, + Assert>>, + Assert>, + Assert>>, + Assert>>, + Assert[number]>>, + Assert>, + Assert>, + Assert>, + Assert< + Accepts + >, + Assert[0]>>, + Assert>, + Assert>>, + Assert>>, + Assert>>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert[0]>>, + Assert[0]>>, + Assert>, + Assert>, + Assert< + Equal< + keyof typeof direct.types, + | 'angle' + | 'color' + | 'image' + | 'integer' + | 'length' + | 'lengthPercentage' + | 'number' + | 'percentage' + | 'resolution' + | 'time' + | 'transformFunction' + | 'transformList' + | 'url' + > + >, + Assert, never>>, + Assert, never>>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>>, + Assert< + Accepts<{ '--n': 1; '--empty': null }, NonNullable> + >, +] diff --git a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts index 3ad496a24..4f823cc04 100644 --- a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts @@ -18,6 +18,42 @@ import { import { DevupUI } from '../plugin' +describe('shorthand creation boundary', () => { + it('registers canonical targets when the plugin is created', () => { + const registration = spyOn(wasm, 'registerShorthands').mockReturnValue( + undefined, + ) + try { + DevupUI({ shorthands: { bgx: ['backgroundColor', 'py', '--Gap'] } }) + expect(registration.mock.calls).toEqual([ + [ + { + bgx: ['background-color', 'padding-top', 'padding-bottom', '--Gap'], + }, + ], + ]) + } finally { + registration.mockRestore() + } + }) + + it('rejects a typo before registration when runtime options bypass types', () => { + const registration = spyOn(wasm, 'registerShorthands').mockReturnValue( + undefined, + ) + try { + expect(() => + Reflect.apply(DevupUI, undefined, [ + { shorthands: { bgx: ['backgroundColour'] } }, + ]), + ).toThrow(pluginUtils.ShorthandConfigError) + expect(registration).not.toHaveBeenCalled() + } finally { + registration.mockRestore() + } + }) +}) + type CodeExtractResult = ReturnType type RsbuildPlugin = ReturnType type RsbuildSetupContext = Parameters[0] diff --git a/packages/rsbuild-plugin/src/__tests__/reservation-fixture.cjs b/packages/rsbuild-plugin/src/__tests__/reservation-fixture.cjs index f4a3fe273..40bd44d41 100644 --- a/packages/rsbuild-plugin/src/__tests__/reservation-fixture.cjs +++ b/packages/rsbuild-plugin/src/__tests__/reservation-fixture.cjs @@ -43,7 +43,7 @@ function prepare(root, watch = false) { put( root, 'app/Dynamic.jsx', - 'import {Box} from "@devup-ui/react"; export const dynamic = (color) => ', + 'import {Box} from "@devup-ui/react"; export const dynamic = (color) => ', ) if (!watch) addPair(root) } diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index 8bf710f2e..41223de1e 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -17,6 +17,7 @@ import { type ImportAliases, loadDevupConfig, mergeImportAliases, + normalizeShorthands, planAtomHoist, readJsxImportSource, tailwindCssFiles, @@ -141,7 +142,7 @@ export const DevupUI = ({ atomHoist = 2, importAliases: userImportAliases, }: Partial = {}): RsbuildPlugin => { - registerShorthands(shorthands ?? {}) + registerShorthands(normalizeShorthands(shorthands ?? {})) const importAliases = mergeImportAliases( userImportAliases, readJsxImportSource(), diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index c066c11d0..97b4bfaaa 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -18,6 +18,42 @@ import type { Plugin } from 'vite' import { DevupUI } from '../plugin' +describe('shorthand creation boundary', () => { + it('registers canonical targets when the plugin is created', () => { + const registration = spyOn(wasm, 'registerShorthands').mockReturnValue( + undefined, + ) + try { + createPlugin({ shorthands: { bgx: ['backgroundColor', 'py', '--Gap'] } }) + expect(registration.mock.calls).toEqual([ + [ + { + bgx: ['background-color', 'padding-top', 'padding-bottom', '--Gap'], + }, + ], + ]) + } finally { + registration.mockRestore() + } + }) + + it('rejects a typo before registration when runtime options bypass types', () => { + const registration = spyOn(wasm, 'registerShorthands').mockReturnValue( + undefined, + ) + try { + expect(() => + Reflect.apply(DevupUI, undefined, [ + { shorthands: { bgx: ['backgroundColour'] } }, + ]), + ).toThrow(pluginUtils.ShorthandConfigError) + expect(registration).not.toHaveBeenCalled() + } finally { + registration.mockRestore() + } + }) +}) + type CodeExtractResult = ReturnType interface ConfigHookMeta { viteVersion?: string diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index c00efad46..0cd279737 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -17,6 +17,7 @@ import { type ImportAliases, loadDevupConfig, mergeImportAliases, + normalizeShorthands, readJsxImportSource, seedFileNumbers, tailwindCssFiles, @@ -327,6 +328,7 @@ export function DevupUI({ atomHoist = 2, importAliases: userImportAliases, }: Partial = {}): PluginOption { + const normalizedShorthands = normalizeShorthands(shorthands ?? {}) // A build starts from its own options: whatever an earlier build in this // process left in the engine (prefix, hoisting, routes, buckets, numbers, // styles) is gone unless another build is still running. @@ -362,7 +364,7 @@ export function DevupUI({ } } function configureEngine() { - registerShorthands(shorthands ?? {}) + registerShorthands(normalizedShorthands) setDebug(debug) setPrefix(prefix ?? null) } diff --git a/packages/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts index 3507e4c2e..411831f9f 100644 --- a/packages/webpack-plugin/src/__tests__/plugin.test.ts +++ b/packages/webpack-plugin/src/__tests__/plugin.test.ts @@ -20,6 +20,72 @@ import type { Compiler } from 'webpack' import { DevupUIWebpackPlugin } from '../plugin' import { servedCss } from '../served-css' +describe('shorthand creation boundary', () => { + it('registers canonical targets when the plugin is constructed', () => { + const registration = spyOn(wasm, 'registerShorthands').mockReturnValue( + undefined, + ) + try { + new DevupUIWebpackPlugin({ + shorthands: { bgx: ['backgroundColor', 'py', '--Gap'] }, + }) + expect(registration.mock.calls).toEqual([ + [ + { + bgx: ['background-color', 'padding-top', 'padding-bottom', '--Gap'], + }, + ], + ]) + } finally { + registration.mockRestore() + } + }) + + it('rejects a typo before registration when runtime options bypass types', () => { + const registration = spyOn(wasm, 'registerShorthands').mockReturnValue( + undefined, + ) + try { + expect(() => + Reflect.construct(DevupUIWebpackPlugin, [ + { shorthands: { bgx: ['backgroundColour'] } }, + ]), + ).toThrow(pluginUtils.ShorthandConfigError) + expect(registration).not.toHaveBeenCalled() + } finally { + registration.mockRestore() + } + }) + + it('reuses canonical targets when the compiler initializes', () => { + const registration = spyOn(wasm, 'registerShorthands').mockReturnValue( + undefined, + ) + const compiler = createCompiler() + try { + // Given + const plugin = new DevupUIWebpackPlugin({ + shorthands: { bgx: ['backgroundColor', 'py', '--Gap'] }, + }) + registration.mockClear() + // When + plugin.apply(asCompiler(compiler)) + // Then + expect(registration.mock.calls).toEqual([ + [ + { + bgx: ['background-color', 'padding-top', 'padding-bottom', '--Gap'], + }, + ], + ]) + } finally { + for (const [, endBuild] of compiler.hooks.shutdown.tap.mock.calls) + endBuild() + registration.mockRestore() + } + }) +}) + type CodeExtractResult = ReturnType interface MockCompiler { options: { diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index 2961faed6..14e9cfd85 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -19,6 +19,7 @@ import { type ImportAliases, loadDevupConfigSync, mergeImportAliases, + normalizeShorthands, planAtomHoist, readJsxImportSource, seedFileNumbers, @@ -90,6 +91,7 @@ export class DevupUIWebpackPlugin { classMapFile: string fileMapFile: string private importAliases: WasmImportAliases + private readonly normalizedShorthands: Record constructor({ package: libPackage = '@devup-ui/react', @@ -106,7 +108,8 @@ export class DevupUIWebpackPlugin { productionPrewarmFiles, importAliases: userImportAliases, }: Partial = {}) { - registerShorthands(shorthands ?? {}) + this.normalizedShorthands = normalizeShorthands(shorthands ?? {}) + registerShorthands(this.normalizedShorthands) this.importAliases = mergeImportAliases( userImportAliases, readJsxImportSource(), @@ -252,7 +255,7 @@ export class DevupUIWebpackPlugin { private initialize(compiler: Compiler) { setDebug(this.options.debug) setPrefix(this.options.prefix ?? null) - registerShorthands(this.options.shorthands ?? {}) + registerShorthands(this.normalizedShorthands) const existsDevup = existsSync(this.options.devupFile) // read devup.json if (!existsSync(this.options.distDir)) diff --git a/test-fixtures/style-order.json b/test-fixtures/style-order.json new file mode 100644 index 000000000..182c67a81 --- /dev/null +++ b/test-fixtures/style-order.json @@ -0,0 +1,71 @@ +[ + { "expression": "1", "valid": true }, + { "expression": "254", "valid": true }, + { "expression": "100.0", "valid": true }, + { "expression": "1e2", "valid": true }, + { "expression": "+100", "valid": true }, + { "expression": "1 as const", "valid": true }, + { "expression": "1 satisfies number", "valid": true }, + { "expression": "1!", "valid": true }, + { "expression": "-(-1)", "valid": true }, + { "expression": "+'1'", "valid": true }, + { "expression": "+'01'", "valid": true }, + { "expression": "-'-1'", "valid": true }, + { "expression": "+`1`", "valid": true }, + { "expression": "+`\\u0031`", "valid": true }, + { "expression": "+(1 as const)", "valid": true }, + { "expression": "+(1 satisfies number)", "valid": true }, + { "expression": "+(1!)", "valid": true }, + { "expression": "'1'", "valid": true }, + { "expression": "'254'", "valid": true }, + { "expression": "`100`", "valid": true }, + { "expression": "`\\u0031`", "valid": true }, + { "expression": "on ? 1 : 2", "valid": true }, + { "expression": "on && 2", "valid": true }, + { "expression": "0", "valid": false }, + { "expression": "255", "valid": false }, + { "expression": "256", "valid": false }, + { "expression": "-1", "valid": false }, + { "expression": "1.5", "valid": false }, + { "expression": "'100px'", "valid": false }, + { "expression": "'1foo'", "valid": false }, + { "expression": "'1e2'", "valid": false }, + { "expression": "'100.0'", "valid": false }, + { "expression": "'+100'", "valid": false }, + { "expression": "'01'", "valid": false }, + { "expression": "' 1'", "valid": false }, + { "expression": "'1 '", "valid": false }, + { "expression": "'1\\n'", "valid": false }, + { "expression": "''", "valid": false }, + { "expression": "' '", "valid": false }, + { "expression": "'NaN'", "valid": false }, + { "expression": "'Infinity'", "valid": false }, + { "expression": "null", "valid": false }, + { "expression": "true", "valid": false }, + { "expression": "false", "valid": false }, + { "expression": "undefined", "valid": false }, + { "expression": "void 0", "valid": false }, + { "expression": "!1", "valid": false }, + { "expression": "~1", "valid": false }, + { "expression": "typeof 1", "valid": false }, + { "expression": "+'no-number'", "valid": false }, + { "expression": "+'1\\n'", "valid": true }, + { "expression": "+'0x10'", "valid": true }, + { "expression": "+'1e2'", "valid": true }, + { "expression": "+'1.0'", "valid": true }, + { "expression": "+true", "valid": true }, + { "expression": "+'1.5'", "valid": false }, + { "expression": "+''", "valid": false }, + { "expression": "+' '", "valid": false }, + { "expression": "+'0'", "valid": false }, + { "expression": "+'255'", "valid": false }, + { "expression": "-'1'", "valid": false }, + { "expression": "+'Infinity'", "valid": false }, + { "expression": "+'abc'", "valid": false }, + { "expression": "+null", "valid": false }, + { "expression": "+false", "valid": false }, + { "expression": "on ? 1 : '100px'", "valid": false }, + { "expression": "on && '01'", "valid": false }, + { "expression": "`100px`", "valid": false }, + { "expression": "`01`", "valid": false } +]