diff --git a/.changepacks/changepack_log_css_output_edges.json b/.changepacks/changepack_log_css_output_edges.json new file mode 100644 index 000000000..5d5645a5f --- /dev/null +++ b/.changepacks/changepack_log_css_output_edges.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "apps/landing/package.json": "Patch" + }, + "note": "Preserve unitless legacy vendor numbers, classify vendor time values as milliseconds and emit the Opera vendor prefix correctly; reject nested responsive arrays and known dead unprefixed CSS property names with source-located build errors, while preserving supported experimental CSS, unknown properties and documented shorthands.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/apps/landing/src/app/(detail)/docs/api/box/page.mdx b/apps/landing/src/app/(detail)/docs/api/box/page.mdx index a81f55be1..b5c25c831 100644 --- a/apps/landing/src/app/(detail)/docs/api/box/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/box/page.mdx @@ -64,12 +64,12 @@ However, the following properties are exceptions and **are not multiplied by 4** - `grid-column`, `grid-column-start`, `grid-column-end` - `grid-row`, `grid-row-start`, `grid-row-end` - `animation-iteration-count` -- `tab-size`, `moz-tab-size`, `-webkit-line-clamp` +- `tab-size`, `-moz-tab-size`, `-webkit-line-clamp` - `grid-area` - `column-count`, `columns`, `orphans`, `widows`, `zoom` - `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width` - `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width` -- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold` +- `initial-letter`, `max-lines`, `shape-image-threshold` - `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit` ### Rendering as Another Element diff --git a/apps/landing/src/app/(detail)/docs/api/button/page.mdx b/apps/landing/src/app/(detail)/docs/api/button/page.mdx index 1eb82336b..dfe315649 100644 --- a/apps/landing/src/app/(detail)/docs/api/button/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/button/page.mdx @@ -61,10 +61,10 @@ However, the following properties are exceptions and **are not multiplied by 4** - `grid-column`, `grid-column-start`, `grid-column-end` - `grid-row`, `grid-row-start`, `grid-row-end` - `animation-iteration-count` -- `tab-size`, `moz-tab-size`, `-webkit-line-clamp` +- `tab-size`, `-moz-tab-size`, `-webkit-line-clamp` - `grid-area` - `column-count`, `columns`, `orphans`, `widows`, `zoom` - `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width` - `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width` -- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold` +- `initial-letter`, `max-lines`, `shape-image-threshold` - `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit` diff --git a/apps/landing/src/app/(detail)/docs/api/center/page.mdx b/apps/landing/src/app/(detail)/docs/api/center/page.mdx index 0ac65ae80..ca80b77bb 100644 --- a/apps/landing/src/app/(detail)/docs/api/center/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/center/page.mdx @@ -84,12 +84,12 @@ However, the following properties are exceptions and **are not multiplied by 4** - `grid-column`, `grid-column-start`, `grid-column-end` - `grid-row`, `grid-row-start`, `grid-row-end` - `animation-iteration-count` -- `tab-size`, `moz-tab-size`, `-webkit-line-clamp` +- `tab-size`, `-moz-tab-size`, `-webkit-line-clamp` - `grid-area` - `column-count`, `columns`, `orphans`, `widows`, `zoom` - `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width` - `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width` -- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold` +- `initial-letter`, `max-lines`, `shape-image-threshold` - `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit`
diff --git a/apps/landing/src/app/(detail)/docs/api/grid/page.mdx b/apps/landing/src/app/(detail)/docs/api/grid/page.mdx index 01c06c307..4f9a5b446 100644 --- a/apps/landing/src/app/(detail)/docs/api/grid/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/grid/page.mdx @@ -76,12 +76,12 @@ However, the following properties are exceptions and **are not multiplied by 4** - `grid-column`, `grid-column-start`, `grid-column-end` - `grid-row`, `grid-row-start`, `grid-row-end` - `animation-iteration-count` -- `tab-size`, `moz-tab-size`, `-webkit-line-clamp` +- `tab-size`, `-moz-tab-size`, `-webkit-line-clamp` - `grid-area` - `column-count`, `columns`, `orphans`, `widows`, `zoom` - `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width` - `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width` -- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold` +- `initial-letter`, `max-lines`, `shape-image-threshold` - `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit`
diff --git a/apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx b/apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx index fb7b8b524..63a833815 100644 --- a/apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx +++ b/apps/landing/src/app/(detail)/docs/api/v-stack/page.mdx @@ -82,12 +82,12 @@ However, the following properties are exceptions and **are not multiplied by 4** - `grid-column`, `grid-column-start`, `grid-column-end` - `grid-row`, `grid-row-start`, `grid-row-end` - `animation-iteration-count` -- `tab-size`, `moz-tab-size`, `-webkit-line-clamp` +- `tab-size`, `-moz-tab-size`, `-webkit-line-clamp` - `grid-area` - `column-count`, `columns`, `orphans`, `widows`, `zoom` - `border-image`, `border-image-outset`, `border-image-slice`, `border-image-width` - `mask-border`, `mask-border-outset`, `mask-border-slice`, `mask-border-width` -- `box-flex`, `box-flex-group`, `box-ordinal-group`, `initial-letter`, `max-lines`, `shape-image-threshold` +- `initial-letter`, `max-lines`, `shape-image-threshold` - `fill-opacity`, `flood-opacity`, `stop-opacity`, `stroke-opacity`, `stroke-width`, `stroke-dashoffset`, `stroke-miterlimit`
diff --git a/bindings/devup-ui-wasm/src/css_output_edge_tests.rs b/bindings/devup-ui-wasm/src/css_output_edge_tests.rs new file mode 100644 index 000000000..4d6676352 --- /dev/null +++ b/bindings/devup-ui-wasm/src/css_output_edge_tests.rs @@ -0,0 +1,290 @@ +use super::{ + ImportAlias, Output, code_extract_without_source_map_internal, set_debug, with_style_sheet_mut, +}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; +use sheet::StyleSheet; +use std::collections::BTreeMap; +use std::collections::HashMap; + +fn compile(source: &str) -> Result { + compile_file("css-edges.tsx", source) +} + +fn compile_file(filename: &str, source: &str) -> Result { + with_style_sheet_mut(|sheet| *sheet = StyleSheet::default()); + reset_class_map(); + reset_file_map(); + set_debug(true); + let output = code_extract_without_source_map_internal( + filename, + source, + "@devup-ui/react", + "@devup-ui/react".to_string(), + true, + false, + false, + HashMap::from([( + "@vanilla-extract/css".to_string(), + ImportAlias::NamedToNamed, + )]), + ); + set_debug(false); + output +} + +#[rstest] +#[case("flex", "flex", "3")] +#[case("flexGrow", "flex-grow", "3")] +#[case("flexShrink", "flex-shrink", "3")] +#[case("order", "order", "3")] +#[case("msFlex", "-ms-flex", "3")] +#[case("msFlexOrder", "-ms-flex-order", "3")] +#[case("msFlexPositive", "-ms-flex-positive", "3")] +#[case("msFlexNegative", "-ms-flex-negative", "3")] +#[case("WebkitBoxFlex", "-webkit-box-flex", "3")] +#[case("WebkitBoxOrdinalGroup", "-webkit-box-ordinal-group", "3")] +#[case("WebkitLineClamp", "-webkit-line-clamp", "3")] +#[case("MozTabSize", "-moz-tab-size", "3")] +#[case("msGridColumnSpan", "-ms-grid-column-span", "3")] +#[case("WebkitAnimationDuration", "-webkit-animation-duration", "3ms")] +#[case("WebkitTransitionDelay", "-webkit-transition-delay", "3ms")] +#[case("MozAnimationDelay", "-moz-animation-delay", "3ms")] +#[case("msTransitionDuration", "-ms-transition-duration", "3ms")] +#[case("OAnimationDuration", "-o-animation-duration", "3ms")] +#[case("OTransitionDelay", "-o-transition-delay", "3ms")] +#[case("p", "padding", "12px")] +#[case("WebkitBorderRadius", "-webkit-border-radius", "12px")] +#[serial] +fn numeric_edges_emit_css( + #[case] key: &str, + #[case] property: &str, + #[case] value: &str, +) -> Result<(), String> { + let source = + format!("import {{ Box }} from '@devup-ui/react';\nconst e = ;"); + let output = compile(&source)?; + let css = output.css().ok_or("expected emitted CSS")?; + assert!(css.contains(&format!("{{{property}:{value}}}")), "{css}"); + Ok(()) +} + +#[test] +#[serial] +fn flat_responsive_values_emit_css_after_base() -> Result<(), String> { + let source = "import { Box } from '@devup-ui/react';\nconst e = ;"; + let output = compile(source)?; + let css = output.css().ok_or("expected emitted CSS")?; + let expected = format!( + ".padding-0-4px--255{{padding:{base}}}@media(min-width:768px){{.padding-2-8px--255{{padding:{responsive}}}}}", + base = "4px", + responsive = "8px" + ); + assert!(css.ends_with(&expected), "{css}"); + Ok(()) +} + +#[test] +#[serial] +fn selector_escape_hatches_emit_scoped_css() -> Result<(), String> { + let source = "import { Box } from '@devup-ui/react';\nconst e = p': { color: 'blue' } }} />;"; + let output = compile(source)?; + let css = output.css().ok_or("expected emitted CSS")?; + let hover = format!(":hover{{color:{color}}}", color = "red"); + let child = format!(" > p{{color:{color}}}", color = "blue"); + assert!(css.contains(&hover), "{css}"); + assert!(css.contains(&child), "{css}"); + assert!(!css.contains(":body{"), "{css}"); + Ok(()) +} + +#[test] +#[serial] +fn supported_experimental_and_unknown_properties_still_emit_css() -> Result<(), String> { + let source = "import { Box } from '@devup-ui/react';\nconst e = ;"; + let output = compile(source)?; + let css = output.css().ok_or("expected emitted CSS")?; + for declaration in [ + "stroke-color:red", + "ime-mode:active", + "future-property:custom", + "-webkit-box-orient:vertical", + "padding:8px", + ] { + assert!(css.contains(&format!("{{{declaration}}}")), "{css}"); + } + Ok(()) +} + +#[rstest] +#[case("import { Box } from '@devup-ui/react';\nconst e = ;")] +#[case("import { css } from '@devup-ui/react';\nconst c = css({ boxAlign: 'center' });")] +#[case("import { styled } from '@devup-ui/react';\nconst S = styled.div`box-align: center;`;")] +#[case( + "import { globalCss } from '@devup-ui/react';\nglobalCss({ body: { boxAlign: 'center' } });" +)] +#[case( + "import { keyframes } from '@devup-ui/react';\nconst k = keyframes({ from: { boxAlign: 'center' } });" +)] +#[serial] +fn dead_properties_fail_before_css_emission(#[case] source: &str) -> Result<(), String> { + let error = compile(source) + .err() + .ok_or("expected a declaration error")?; + assert!(error.starts_with("css-edges.tsx:2:"), "{error}"); + assert!(error.contains("align-items"), "{error}"); + Ok(()) +} + +#[test] +#[serial] +fn nested_responsive_arrays_fail_before_css_emission() -> Result<(), String> { + let source = "import { Box } from '@devup-ui/react';\nconst e = ;"; + let error = compile(source) + .err() + .ok_or("expected a responsive array error")?; + assert!(error.starts_with("css-edges.tsx:2:24:"), "{error}"); + assert!(error.contains("responsive arrays must be flat"), "{error}"); + Ok(()) +} + +#[rstest] +#[case(r"const c = css('color:red;\nbox-align:center');")] +#[case(r"const c = css('box\u002dalign:center');")] +#[serial] +fn escaped_string_css_dead_names_fail_before_emission( + #[case] statement: &str, +) -> Result<(), String> { + let source = format!("import {{ css }} from '@devup-ui/react';\n{statement}"); + let error = compile(&source) + .err() + .ok_or("expected a declaration error")?; + assert!(error.starts_with("css-edges.tsx:2:"), "{error}"); + assert!(error.contains("align-items"), "{error}"); + Ok(()) +} + +#[test] +#[serial] +fn configured_shorthand_names_keep_their_valid_targets() -> Result<(), String> { + css::set_custom_shorthands(BTreeMap::from([( + "boxAlign".to_string(), + vec!["align-items".to_string()], + )])); + let source = "import { Box } from '@devup-ui/react';\nconst e = ;"; + let output = compile(source); + css::set_custom_shorthands(BTreeMap::new()); + let css = output?.css().ok_or("expected emitted CSS")?; + assert!(css.contains("{align-items:center}"), "{css}"); + assert!(!css.contains("{box-align:"), "{css}"); + Ok(()) +} + +#[rstest] +#[case( + "import { style } from '@vanilla-extract/css';\nconst makeStyle = style;\nexport const invalid = makeStyle({ selectors: { '&:hover': { boxOrient: 'vertical' } } });", + 3 +)] +#[case( + "import * as ve from '@vanilla-extract/css';\nexport const invalid = ve['style']({ boxAlign: 'center' });", + 2 +)] +#[case( + "import { style } from '@vanilla-extract/css';\nconst property = 'boxAlign';\nexport const invalid = style({ [property]: 'center' });", + 3 +)] +#[serial] +fn evaluated_declarations_report_authored_calls( + #[case] source: &str, + #[case] line: usize, +) -> Result<(), String> { + let error = compile_file("css-edges.css.ts", source) + .err() + .ok_or("expected a declaration error")?; + assert!( + error.starts_with(&format!("css-edges.css.ts:{line}:")), + "{error}" + ); + assert!( + error.contains("boxOrient") || error.contains("boxAlign"), + "{error}" + ); + Ok(()) +} + +#[rstest] +#[case( + "import { style } from '@vanilla-extract/css'; const p = 'boxAlign'; export const invalid = style({ [p]: 'center' });", + "style({" +)] +#[case( + "import { style } from '@vanilla-extract/css'; export const invalid = style({ boxAlign: 'center' });", + "boxAlign:" +)] +#[serial] +fn same_line_import_aliases_keep_authored_columns( + #[case] source: &str, + #[case] token: &str, +) -> Result<(), String> { + let column = source.find(token).ok_or("fixture has no expected token")? + 1; + let error = compile_file("css-edges.css.ts", source) + .err() + .ok_or("expected a declaration error")?; + assert!( + error.starts_with(&format!("css-edges.css.ts:1:{column}:")), + "{error}" + ); + Ok(()) +} + +#[test] +#[serial] +fn computed_css_text_reports_its_authored_call() -> Result<(), String> { + let source = "import { css } from '@devup-ui/react';\nconst c = css('box-' + 'align:center');"; + let error = compile(source) + .err() + .ok_or("expected a declaration error")?; + assert!(error.starts_with("css-edges.tsx:2:11:"), "{error}"); + assert!(error.contains("align-items"), "{error}"); + Ok(()) +} + +#[test] +#[serial] +fn stylex_dead_declarations_fail_before_css_emission() -> Result<(), String> { + let statement = "export const c = stylex.create({ x: { boxAlign: 'center' } });"; + let source = format!("import * as stylex from '@stylexjs/stylex';\n{statement}"); + let column = statement.find("boxAlign").ok_or("fixture lacks property")? + 1; + let error = compile(&source) + .err() + .ok_or("expected a declaration error")?; + assert!( + error.starts_with(&format!("css-edges.tsx:2:{column}:")), + "{error}" + ); + assert!(error.contains("align-items"), "{error}"); + Ok(()) +} + +#[rstest] +#[case( + "import { style } from '@vanilla-extract/css';\nconst values = [[1,2],3];\n\n\nexport const bad = style({ p: values });", + "css-edges.css.ts:5:20:" +)] +#[case( + "import { style } from '@vanilla-extract/css';\n\n\nexport const bad = style({ p: [[1,2],3] });", + "css-edges.css.ts:4:32:" +)] +#[serial] +fn stylesheet_nested_arrays_report_authored_locations( + #[case] source: &str, + #[case] location: &str, +) -> Result<(), String> { + let error = compile_file("css-edges.css.ts", source) + .err() + .ok_or("expected a responsive array error")?; + assert!(error.starts_with(location), "{error}"); + assert!(error.contains("responsive arrays must be flat"), "{error}"); + Ok(()) +} diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index c2546d26c..da96d5bd1 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -15,6 +15,9 @@ use std::fmt::Display; use std::sync::{LazyLock, Mutex}; use wasm_bindgen::prelude::*; +#[cfg(test)] +mod css_output_edge_tests; + static GLOBAL_STYLE_SHEET: LazyLock> = LazyLock::new(|| Mutex::new(StyleSheet::default())); diff --git a/libs/css/src/is_special_property.rs b/libs/css/src/is_special_property.rs index ea75b98b9..17bb6972b 100644 --- a/libs/css/src/is_special_property.rs +++ b/libs/css/src/is_special_property.rs @@ -551,15 +551,7 @@ mod tests { "border-top-width", "border-width", "bottom", - "box-align", "box-decoration-break", - "box-direction", - "box-flex", - "box-flex-group", - "box-lines", - "box-ordinal-group", - "box-orient", - "box-pack", "box-shadow", "box-sizing", "break-after", @@ -854,14 +846,8 @@ mod tests { "scroll-padding-right", "scroll-padding-top", "scroll-snap-align", - "scroll-snap-coordinate", - "scroll-snap-destination", - "scroll-snap-points-x", - "scroll-snap-points-y", "scroll-snap-stop", "scroll-snap-type", - "scroll-snap-type-x", - "scroll-snap-type-y", "scroll-timeline", "scroll-timeline-axis", "scroll-timeline-name", diff --git a/libs/css/src/lib.rs b/libs/css/src/lib.rs index 7410ab535..d830648b6 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -209,10 +209,9 @@ pub fn disassemble_property(property: &str) -> DisassembleProperty { GLOBAL_STYLE_PROPERTY.get(property).map_or_else( || { DisassembleProperty::Fallback(Some( - // Gate the three vendor-prefix `starts_with` scans behind a - // single first-byte check: only `W`/`M`/`m` can begin - // `Webkit`/`Moz`/`ms`, so every other property skips all three. - if matches!(property.as_bytes().first(), Some(b'W' | b'M' | b'm')) + // Gate vendor-prefix scans behind a single first-byte check: + // only `W`/`M`/`m`/`O` can begin `Webkit`/`Moz`/`ms`/`O`. + if matches!(property.as_bytes().first(), Some(b'W' | b'M' | b'm' | b'O')) && ((property.starts_with("Webkit") && property.len() > 6 && property.as_bytes()[6].is_ascii_uppercase()) @@ -221,7 +220,10 @@ pub fn disassemble_property(property: &str) -> DisassembleProperty { && property.as_bytes()[3].is_ascii_uppercase()) || (property.starts_with("ms") && property.len() > 2 - && property.as_bytes()[2].is_ascii_uppercase())) + && property.as_bytes()[2].is_ascii_uppercase()) + || (property.starts_with('O') + && property.len() > 1 + && property.as_bytes()[1].is_ascii_uppercase())) { // Build `-` directly into ONE buffer instead of allocating // a `to_kebab_case(property)` String and copying it into a second @@ -229,8 +231,8 @@ pub fn disassemble_property(property: &str) -> DisassembleProperty { // (ASCII-uppercase char → `-` before it when not first, then its // lowercase; other chars copied verbatim) after the leading `-`. // The `i != 0` guard matches `to_kebab_case`, so the vendor - // prefix's uppercase first char (`W`/`M`/`m`→lowercase) gets no - // extra `-`. Output byte-identical, one fewer allocation. + // prefix's first char (`W`/`M`/`m`/`O`→lowercase) gets no + // extra `-`, keeping the conversion in one allocation. let mut s = String::with_capacity(property.len() + 5); s.push('-'); for (i, c) in property.chars().enumerate() { @@ -1399,6 +1401,28 @@ mod tests { assert_ne!(class1, class3); } + #[rstest] + #[case("OAnimationDuration", "-o-animation-duration")] + #[case("OTransitionDelay", "-o-transition-delay")] + #[case("OTransform", "-o-transform")] + #[case("Order", "order")] + #[case("ObjectFit", "object-fit")] + #[case("O", "o")] + #[case("order", "order")] + #[case("objectFit", "object-fit")] + #[case("WebkitAnimationDuration", "-webkit-animation-duration")] + #[case("MozTransitionDelay", "-moz-transition-delay")] + #[case("msTransform", "-ms-transform")] + #[serial] + fn disassemble_property_when_vendor_or_ordinary_name( + #[case] property: &str, + #[case] expected: &str, + ) { + let properties = disassemble_property(property).collect::>(); + + assert_eq!(properties, [expected]); + } + #[test] #[serial] fn test_disassemble_property_size_hint() { diff --git a/libs/extractor/src/dead_properties.rs b/libs/extractor/src/dead_properties.rs new file mode 100644 index 000000000..c93a8e17a --- /dev/null +++ b/libs/extractor/src/dead_properties.rs @@ -0,0 +1,143 @@ +//! Closed compatibility policy for declarations with no unprefixed implementation. + +use crate::{ + ExtractStyleProp, + utils::{Unused, fixed_value, runtime_value, unreadable_styles}, +}; +use css::utils::to_kebab_case; + +mod calls; +mod objects; +mod origin; +mod strings; +pub(crate) use origin::{evaluated_calls, map_evaluated}; +mod text; +pub(crate) use calls::instrument; +pub(crate) use objects::authored_errors; +pub(crate) use objects::{ObjectKind, evaluated_errors}; +pub(crate) const ERROR_CHANNEL: &str = "DEVUP_DEAD_DECLARATION\n"; + +pub(crate) fn terminal_error(message: &str) -> bool { + message + .split_once(" at build time: ") + .is_some_and(|(_, requirement)| { + requirement == crate::utils::RESPONSIVE_ARRAY + || requirement.starts_with("unprefixed ") + || requirement.starts_with("obsolete scroll-snap-") + }) +} +pub(crate) use text::{expression_errors, template_errors}; + +/// The replacement is advice, never an automatic alias or rewrite. +pub(crate) fn requirement(name: &str) -> Option<&'static str> { + let canonical = if name.contains('-') { + std::borrow::Cow::Owned(name.to_ascii_lowercase()) + } else { + to_kebab_case(name) + }; + Some(match canonical.as_ref() { + "box-align" => { + "unprefixed box-align has no browser implementation; use display: flex and align-items" + } + "box-pack" => { + "unprefixed box-pack has no browser implementation; use display: flex and justify-content" + } + "box-flex" => "unprefixed box-flex has no browser implementation; use flex-grow", + "box-flex-group" => { + "unprefixed box-flex-group has no browser implementation; use flex-grow on modern flex items" + } + "box-orient" => "unprefixed box-orient has no browser implementation; use flex-direction", + "box-ordinal-group" => { + "unprefixed box-ordinal-group has no browser implementation; use order" + } + "box-direction" => { + "unprefixed box-direction has no browser implementation; use flex-direction with row-reverse or column-reverse" + } + "box-lines" => "unprefixed box-lines has no browser implementation; use flex-wrap", + "flex-order" => "unprefixed flex-order is an obsolete flexbox draft property; use order", + "flex-positive" => { + "unprefixed flex-positive is an obsolete flexbox draft property; use flex-grow" + } + "flex-negative" => { + "unprefixed flex-negative is an obsolete flexbox draft property; use flex-shrink" + } + "flex-preferred-size" => { + "unprefixed flex-preferred-size is an obsolete flexbox draft property; use flex-basis" + } + "scroll-snap-points-x" => { + "obsolete scroll-snap-points-x is no longer implemented; use scroll-snap-type on the container and scroll-snap-align on its children" + } + "scroll-snap-points-y" => { + "obsolete scroll-snap-points-y is no longer implemented; use scroll-snap-type on the container and scroll-snap-align on its children" + } + "scroll-snap-coordinate" => { + "obsolete scroll-snap-coordinate is no longer implemented; use scroll-snap-align on the snap target" + } + "scroll-snap-destination" => { + "obsolete scroll-snap-destination is no longer implemented; use scroll-padding on the snap container" + } + "scroll-snap-type-x" => { + "obsolete scroll-snap-type-x is no longer implemented; use scroll-snap-type with an x axis" + } + "scroll-snap-type-y" => { + "obsolete scroll-snap-type-y is no longer implemented; use scroll-snap-type with a y axis" + } + _ => return None, + }) +} + +/// Reject a declaration at its authored key, not at its value or containing call. +pub(crate) fn declaration_error<'a>(name: &str, offset: u32) -> Option> { + Some(ExtractStyleProp::Unreadable { + offset, + code: name.to_string(), + prop: false, + requirement: Some(requirement(name)?), + }) +} + +pub(crate) fn authored_requirement(name: &str) -> Option<&'static str> { + match css::disassemble_property(name) { + css::DisassembleProperty::Custom(mut properties) => { + properties.find_map(|property| requirement(&property)) + } + css::DisassembleProperty::Mapped(mut properties) => { + properties.find_map(|property| requirement(property)) + } + css::DisassembleProperty::Fallback(_) => requirement(name), + } +} + +pub(crate) fn authored_declaration_error<'a>( + name: &str, + offset: u32, +) -> Option> { + Some(ExtractStyleProp::Unreadable { + offset, + code: name.to_string(), + prop: false, + requirement: Some(authored_requirement(name)?), + }) +} + +/// Retain the stored location while preserving the existing runtime/fixed-value fallback. +pub(crate) fn located_value( + styles: &[ExtractStyleProp<'_>], + fallback: u32, + fixed: bool, +) -> Option<(u32, Unused)> { + let mut found = Vec::new(); + unreadable_styles(styles, true, &mut found); + match found.into_iter().next() { + Some((offset, code, requirement)) => Some(( + if offset == 0 { fallback } else { offset }, + (code, requirement), + )), + None => if fixed { + fixed_value(styles) + } else { + runtime_value(styles) + } + .map(|value| (fallback, value)), + } +} diff --git a/libs/extractor/src/dead_properties/calls.rs b/libs/extractor/src/dead_properties/calls.rs new file mode 100644 index 000000000..36c146bfb --- /dev/null +++ b/libs/extractor/src/dead_properties/calls.rs @@ -0,0 +1,208 @@ +//! Authored styling-call metadata carried through stylesheet execution. + +use super::objects::{ObjectKind, authored_errors}; +use oxc_ast::ast::{CallExpression, Expression, ImportDeclarationSpecifier, Statement}; +use oxc_ast_visit::{Visit, walk::walk_call_expression}; +use rustc_hash::FxHashMap; + +pub(crate) struct Instrumented { + pub code: String, + pub calls: Vec<(u32, u32)>, +} + +pub(crate) fn instrument( + code: &str, + filename: &str, + package: &str, +) -> Result { + let allocator = oxc_allocator::Allocator::default(); + let parsed = oxc_parser::Parser::new( + &allocator, + code, + oxc_span::SourceType::from_path(filename).unwrap_or_else(|_| oxc_span::SourceType::ts()), + ) + .parse(); + let semantic = oxc_semantic::SemanticBuilder::new() + .build(&parsed.program) + .semantic; + let mut imports = FxHashMap::default(); + for statement in &parsed.program.body { + if let Statement::ImportDeclaration(import) = statement + && (import.source.value == package || import.source.value == "@vanilla-extract/css") + { + for specifier in import.specifiers.iter().flatten() { + let export = match specifier { + ImportDeclarationSpecifier::ImportSpecifier(named) => { + named.imported.name().to_string() + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(_) => "*".to_string(), + ImportDeclarationSpecifier::ImportDefaultSpecifier(_) => continue, + }; + if let Some(symbol) = specifier.local().symbol_id.get() { + imports.insert(symbol, export); + } + } + } + } + let mut visitor = Calls { + code, + filename, + imports, + scoping: semantic.scoping(), + insertions: Vec::new(), + errors: Vec::new(), + ranges: Vec::new(), + }; + loop { + let previous = visitor.imports.len(); + Aliases(&mut visitor).visit_program(&parsed.program); + if visitor.imports.len() == previous { + break; + } + } + visitor.visit_program(&parsed.program); + if !visitor.errors.is_empty() { + return Err(visitor.errors.join("\n")); + } + visitor.insertions.sort_by_key(|(offset, _)| *offset); + let mut output = String::new(); + let mut copied = 0; + for (offset, insertion) in visitor.insertions { + let offset = usize::try_from(offset).map_err(|error| error.to_string())?; + output.push_str(&code[copied..offset]); + output.push_str(&insertion); + copied = offset; + } + output.push_str(&code[copied..]); + Ok(Instrumented { + code: output, + calls: visitor.ranges, + }) +} + +struct Calls<'s> { + code: &'s str, + filename: &'s str, + imports: FxHashMap, + scoping: &'s oxc_semantic::Scoping, + insertions: Vec<(u32, String)>, + errors: Vec, + ranges: Vec<(u32, u32)>, +} + +struct Aliases<'v, 's>(&'v mut Calls<'s>); + +impl<'a> Visit<'a> for Aliases<'_, '_> { + fn visit_variable_declaration(&mut self, declaration: &oxc_ast::ast::VariableDeclaration<'a>) { + if declaration.kind == oxc_ast::ast::VariableDeclarationKind::Const { + for declarator in &declaration.declarations { + if let Some(identifier) = declarator.id.get_binding_identifier() + && let Some(symbol) = identifier.symbol_id.get() + && let Some(initializer) = &declarator.init + && let Some(api) = self.0.api(initializer) + { + self.0.imports.insert(symbol, api); + } + } + } + oxc_ast_visit::walk::walk_variable_declaration(self, declaration); + } +} + +impl Calls<'_> { + fn api(&self, expression: &Expression<'_>) -> Option { + let (identifier, member) = match expression { + Expression::Identifier(identifier) => (identifier.as_ref(), None), + Expression::StaticMemberExpression(member) => match &member.object { + Expression::Identifier(identifier) => { + (identifier.as_ref(), Some(member.property.name.as_str())) + } + _ => return None, + }, + _ => return None, + }; + let symbol = self + .scoping + .get_reference(identifier.reference_id.get()?) + .symbol_id()?; + let imported = self.imports.get(&symbol)?; + match (imported.as_str(), member) { + ("*", Some(member)) => Some(member.to_string()), + ("styled", Some(_)) => Some("styled".to_string()), + (_, None) => Some(imported.clone()), + _ => None, + } + } + + fn location(&self, offset: u32) -> String { + let prefix = &self.code[..usize::try_from(offset).unwrap_or(0)]; + let line = prefix.bytes().filter(|byte| *byte == b'\n').count() + 1; + let column = prefix.rsplit('\n').next().unwrap_or("").chars().count() + 1; + format!("{}:{line}:{column}", self.filename) + } +} + +impl<'a> Visit<'a> for Calls<'_> { + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + if let Some(api) = self.api(&call.callee) { + let shape = match api.as_str() { + "style" | "css" | "fontFace" => Some((0, ObjectKind::Styles)), + "globalStyle" | "globalFontFace" => Some((1, ObjectKind::Styles)), + "keyframes" | "styleVariants" => Some((0, ObjectKind::Records)), + "globalCss" => Some((0, ObjectKind::Globals)), + "styled" => Some((call.arguments.len().saturating_sub(1), ObjectKind::Styles)), + _ => None, + }; + if let Some((argument, kind)) = shape { + self.ranges.push((call.span.start, call.span.end)); + if !(api == "styleVariants" && call.arguments.len() > 1) + && let Some(expression) = call + .arguments + .get(argument) + .and_then(|argument| argument.as_expression()) + { + for (offset, path, requirement) in authored_errors(expression, kind) { + self.errors.push(format!( + "{}: {}", + self.location(offset), + crate::utils::build_time_error(&api, &path, requirement) + )); + } + } + self.insertions.push(( + call.span.start, + format!( + "__vanilla_extract__.__at({:?}, {:?}, () => (", + self.location(call.span.start), + api + ), + )); + walk_call_expression(self, call); + self.insertions.push((call.span.end, "))".to_string())); + return; + } + } + let mut suspends = crate::utils::Suspends::default(); + for argument in &call.arguments { + if let Some(expression) = argument.as_expression() { + Visit::visit_expression(&mut suspends, expression); + } + } + let direct_eval = + matches!(&call.callee, Expression::Identifier(identifier) if identifier.name == "eval"); + if !direct_eval && !suspends.found { + self.insertions.push(( + call.span.start, + format!( + "__vanilla_extract__.__at({:?}, {:?}, () => (", + self.location(call.span.start), + crate::utils::readable_code(&call.callee), + ), + )); + walk_call_expression(self, call); + self.insertions.push((call.span.end, "))".to_string())); + return; + } + walk_call_expression(self, call); + } +} diff --git a/libs/extractor/src/dead_properties/objects.rs b/libs/extractor/src/dead_properties/objects.rs new file mode 100644 index 000000000..2974e4755 --- /dev/null +++ b/libs/extractor/src/dead_properties/objects.rs @@ -0,0 +1,168 @@ +use super::authored_requirement; +use crate::utils::{ + RESPONSIVE_ARRAY, get_string_by_property_key, readable_code, unwrap_syntax_only, +}; +use oxc_ast::ast::{Expression, ObjectPropertyKind}; +use oxc_span::GetSpan; + +#[derive(Clone, Copy)] +pub(crate) enum ObjectKind { + Styles, + Records, + Selectors, + Globals, +} + +pub(crate) fn authored_errors( + expression: &Expression<'_>, + kind: ObjectKind, +) -> Vec<(u32, String, &'static str)> { + let mut declarations = Declarations::default(); + declarations.collect(expression, kind); + declarations.errors +} + +#[derive(Default)] +struct Declarations { + path: Vec, + errors: Vec<(u32, String, &'static str)>, + evaluated: bool, + responsive_entry: bool, +} + +impl Declarations { + fn collect(&mut self, expression: &Expression<'_>, kind: ObjectKind) { + match unwrap_syntax_only(expression) { + Expression::ObjectExpression(object) => { + for property in &object.properties { + match property { + ObjectPropertyKind::ObjectProperty(property) => { + let Some(name) = get_string_by_property_key(&property.key) else { + continue; + }; + self.path.push(name.clone()); + match kind { + ObjectKind::Styles => { + if let Some(requirement) = authored_requirement(&name) { + self.errors.push(( + property.key.span().start, + self.path.join(" -> "), + requirement, + )); + } else if !matches!( + name.as_str(), + "vars" | "props" | "styleVars" | "params" | "as" + ) && !css::is_special_property::is_special_property( + &name, + ) { + let nested = if matches!( + name.as_str(), + "selectors" + | "@media" + | "@supports" + | "@container" + | "_media" + | "_supports" + | "_container" + | "@layer" + ) { + ObjectKind::Selectors + } else { + ObjectKind::Styles + }; + self.responsive(&property.value, nested); + } + } + ObjectKind::Records => { + self.collect(&property.value, ObjectKind::Styles); + } + ObjectKind::Selectors => { + self.responsive(&property.value, ObjectKind::Styles); + } + ObjectKind::Globals => { + if name != "imports" { + self.collect( + &property.value, + if name.starts_with('@') || name.starts_with('_') { + ObjectKind::Globals + } else { + ObjectKind::Styles + }, + ); + } + } + } + self.path.pop(); + } + ObjectPropertyKind::SpreadProperty(spread) => { + self.collect(&spread.argument, kind); + } + } + } + } + Expression::ArrayExpression(array) => { + if self.responsive_entry { + self.responsive(expression, kind); + return; + } + for (index, item) in array.elements.iter().enumerate() { + if let Some(expression) = item.as_expression() { + self.path.push(index.to_string()); + self.collect(expression, kind); + self.path.pop(); + } + } + } + _ => {} + } + } + + fn responsive(&mut self, expression: &Expression<'_>, kind: ObjectKind) { + match unwrap_syntax_only(expression) { + Expression::ArrayExpression(array) if self.responsive_entry => { + self.errors.push(( + array.span.start, + if self.evaluated { + self.path.join(" -> ") + } else { + readable_code(unwrap_syntax_only(expression)) + }, + RESPONSIVE_ARRAY, + )); + } + Expression::ArrayExpression(array) => { + self.responsive_entry = true; + for (index, item) in array.elements.iter().enumerate() { + if let Some(expression) = item.as_expression() { + self.path.push(index.to_string()); + self.responsive(expression, kind); + self.path.pop(); + } + } + self.responsive_entry = false; + } + _ => self.collect(expression, kind), + } + } +} + +pub(crate) fn evaluated_errors(json: &str, kind: ObjectKind) -> Vec<(String, &'static str)> { + let allocator = oxc_allocator::Allocator::default(); + // The evaluator serializes a value, not a program. Parse that expression + // directly instead of checking for an impossible non-expression statement. + oxc_parser::Parser::new(&allocator, json, oxc_span::SourceType::mjs()) + .parse_expression() + .into_iter() + .flat_map(|expression| { + let mut declarations = Declarations { + evaluated: true, + ..Declarations::default() + }; + declarations.collect(&expression, kind); + declarations + .errors + .into_iter() + .map(|(_, path, requirement)| (path, requirement)) + }) + .collect() +} diff --git a/libs/extractor/src/dead_properties/origin.rs b/libs/extractor/src/dead_properties/origin.rs new file mode 100644 index 000000000..5da5637a4 --- /dev/null +++ b/libs/extractor/src/dead_properties/origin.rs @@ -0,0 +1,55 @@ +use crate::{ + ExtractOption, + import_alias_visit::{source_offset, transform_import_aliases_with_edits}, +}; + +pub(crate) fn map_evaluated( + errors: &mut [(u32, String)], + edits: &[&[crate::import_alias_visit::Edit]], + calls: &[(usize, usize)], +) -> Result<(), std::num::TryFromIntError> { + for (offset, message) in errors { + let authored = edits + .iter() + .fold(usize::try_from(*offset)?, |offset, edits| { + source_offset(edits, offset) + }); + let authored = if super::terminal_error(message) { + calls + .iter() + .filter(|(start, end)| *start <= authored && authored <= *end) + .min_by_key(|(start, end)| end - start) + .map_or(authored, |(start, _)| *start) + } else { + authored + }; + *offset = u32::try_from(authored)?; + } + Ok(()) +} + +pub(crate) fn evaluated_calls( + source: &str, + filename: &str, + option: &ExtractOption, +) -> Vec<(usize, usize)> { + let (aliased, edits) = transform_import_aliases_with_edits( + source, + filename, + &option.package, + &option.import_aliases, + ); + match super::instrument(&aliased, filename, &option.package) { + Ok(instrumented) => instrumented + .calls + .into_iter() + .filter_map(|(start, end)| { + Some(( + source_offset(&edits, usize::try_from(start).ok()?), + source_offset(&edits, usize::try_from(end).ok()?), + )) + }) + .collect(), + Err(_) => Vec::new(), + } +} diff --git a/libs/extractor/src/dead_properties/strings.rs b/libs/extractor/src/dead_properties/strings.rs new file mode 100644 index 000000000..1e42d8272 --- /dev/null +++ b/libs/extractor/src/dead_properties/strings.rs @@ -0,0 +1,60 @@ +use oxc_ast::ast::StringLiteral; + +pub(super) fn origins(literal: &StringLiteral<'_>) -> Vec { + let Some(raw) = literal + .raw + .as_ref() + .and_then(|raw| raw.as_str().get(1..raw.len().saturating_sub(1))) + else { + return std::iter::repeat_n(literal.span.start, literal.value.len()).collect(); + }; + let mut origins = Vec::new(); + let mut characters = raw.char_indices().peekable(); + while let Some((start, character)) = characters.next() { + let mut index = start + character.len_utf8(); + let length = if character == '\\' { + characters.next().map_or(0, |(_, escape)| { + index += escape.len_utf8(); + match escape { + '\n' => 0, + '\r' => { + if raw.as_bytes().get(index) == Some(&b'\n') { + index += 1; + } + 0 + } + 'x' => { + index = (index + 2).min(raw.len()); + 1 + } + 'u' => { + let (end, digits) = if raw.as_bytes().get(index) == Some(&b'{') { + let end = raw[index..].find('}').map_or(raw.len(), |end| index + end); + (end.saturating_add(1).min(raw.len()), &raw[index + 1..end]) + } else { + let end = (index + 4).min(raw.len()); + (end, &raw[index..end]) + }; + index = end; + let codepoint = u32::from_str_radix(digits, 16).unwrap_or(0xfffd); + if (0xd800..=0xdbff).contains(&codepoint) && raw[index..].starts_with("\\u") + { + index = (index + 6).min(raw.len()); + 4 + } else { + char::from_u32(codepoint).unwrap_or('\u{fffd}').len_utf8() + } + } + _ => escape.len_utf8(), + } + }) + } else { + character.len_utf8() + }; + while characters.next_if(|(offset, _)| *offset < index).is_some() {} + if let Ok(offset) = u32::try_from(start) { + origins.extend(std::iter::repeat_n(literal.span.start + 1 + offset, length)); + } + } + origins +} diff --git a/libs/extractor/src/dead_properties/text.rs b/libs/extractor/src/dead_properties/text.rs new file mode 100644 index 000000000..02c57acc1 --- /dev/null +++ b/libs/extractor/src/dead_properties/text.rs @@ -0,0 +1,150 @@ +//! Declaration-aware CSS scanning with offsets into the authored text. + +use super::declaration_error; +use crate::{ExtractStyleProp, utils::get_string_by_literal_expression}; +use oxc_ast::ast::{Expression, StringLiteral, TemplateLiteral}; +use oxc_span::GetSpan; + +/// Find declaration names, ignoring selector preludes and balanced value tokens. +fn declarations(text: &str) -> Vec<(usize, std::borrow::Cow<'_, str>)> { + let bytes = text.as_bytes(); + let mut found = Vec::new(); + let mut start = 0; + let mut index = 0; + let mut colon = None; + let mut quote = None; + let mut parentheses = 0_u32; + let mut brackets = 0_u32; + let mut value_braces = 0_u32; + while index < bytes.len() { + let byte = bytes[index]; + if let Some(delimiter) = quote { + if byte == b'\\' { + index += 2; + continue; + } + if byte == delimiter { + quote = None; + } + } else if byte == b'/' && bytes.get(index + 1) == Some(&b'*') { + index += 2; + while index < bytes.len() + && !(bytes[index] == b'*' && bytes.get(index + 1) == Some(&b'/')) + { + index += 1; + } + index += 2; + if colon.is_none() + && text[start..index.min(bytes.len())] + .trim_start() + .starts_with("/*") + { + start = index.min(bytes.len()); + } + continue; + } else { + match byte { + b'\\' => { + index += 2; + continue; + } + b'\'' | b'"' => quote = Some(byte), + b'(' => parentheses += 1, + b')' => parentheses = parentheses.saturating_sub(1), + b'[' => brackets += 1, + b']' => brackets = brackets.saturating_sub(1), + b':' if parentheses == 0 && brackets == 0 && colon.is_none() => colon = Some(index), + b'{' if parentheses == 0 && brackets == 0 => { + if colon.is_some() && text[start..].trim_start().starts_with("--") { + value_braces += 1; + } else { + start = index + 1; + colon = None; + } + } + b'}' if value_braces > 0 => value_braces -= 1, + b';' | b'}' if parentheses == 0 && brackets == 0 && value_braces == 0 => { + record(text, start, colon, &mut found); + start = index + 1; + colon = None; + } + _ => {} + } + } + index += 1; + } + record(text, start, colon, &mut found); + found +} + +fn record<'a>( + text: &'a str, + start: usize, + colon: Option, + found: &mut Vec<(usize, std::borrow::Cow<'a, str>)>, +) { + if let Some(colon) = colon { + let head = &text[start..colon]; + let name = if head.contains("/*") { + std::borrow::Cow::Owned(css::rm_css_comment::rm_css_comment(head).trim().to_string()) + } else { + std::borrow::Cow::Borrowed(head.trim()) + }; + if name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + found.push((start + head.len() - head.trim_start().len(), name)); + } + } +} + +/// Scan decoded string text while retaining authored offsets across JS escapes. +pub(crate) fn literal_errors<'a>(literal: &StringLiteral<'_>) -> Vec> { + let origins = super::strings::origins(literal); + declarations(&literal.value) + .into_iter() + .filter_map(|(index, name)| declaration_error(&name, *origins.get(index)?)) + .collect() +} + +/// Join raw quasis with interpolation origins rather than inventing contiguous offsets. +pub(crate) fn template_errors<'a>(template: &TemplateLiteral<'_>) -> Vec> { + let mut text = String::new(); + let mut origins = Vec::new(); + for (index, quasi) in template.quasis.iter().enumerate() { + text.push_str(&quasi.value.raw); + origins.extend((0..quasi.value.raw.len()).filter_map(|offset| { + u32::try_from(offset) + .ok() + .map(|offset| quasi.span.start + offset) + })); + if let Some(expression) = template.expressions.get(index) { + if let Some(value) = get_string_by_literal_expression(expression) { + text.push_str(&value); + match expression { + Expression::StringLiteral(literal) => { + origins.extend(super::strings::origins(literal)); + } + _ => origins.extend(std::iter::repeat_n(expression.span().start, value.len())), + } + } else { + text.push_str("__value__"); + origins.extend(std::iter::repeat_n(expression.span().start, 9)); + } + } + } + declarations(&text) + .into_iter() + .filter_map(|(index, name)| declaration_error(&name, *origins.get(index)?)) + .collect() +} + +/// Validate CSS text only where an expression declares styles, not inside property values. +pub(crate) fn expression_errors<'a>(expression: &Expression<'_>) -> Vec> { + match expression { + Expression::StringLiteral(literal) => literal_errors(literal), + Expression::TemplateLiteral(template) => template_errors(template), + _ => Vec::new(), + } +} diff --git a/libs/extractor/src/dead_properties_call_boundary_tests.rs b/libs/extractor/src/dead_properties_call_boundary_tests.rs new file mode 100644 index 000000000..3aa2bbe42 --- /dev/null +++ b/libs/extractor/src/dead_properties_call_boundary_tests.rs @@ -0,0 +1,153 @@ +use crate::dead_properties_test_utils::{failure, position}; +use crate::{ExtractOption, ImportAlias, dead_properties, extract}; +use boa_engine::{Context, JsValue, Source}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; +use std::{error::Error, io}; + +fn evaluate_instrumented(code: &str) -> Result> { + let source = format!( + "const __vanilla_extract__ = {{ __at: (location, api, thunk) => thunk() }};\n{code}" + ); + Ok(Context::default() + .eval(Source::from_bytes(&source)) + .map_err(|error| io::Error::other(error.to_string()))?) +} + +#[rstest] +#[case( + "import fallback from '@devup-ui/react';", + "fallback({ boxOrient: 'vertical' })" +)] +#[case( + "import { css } from '@devup-ui/react';", + "css.call(null, { boxOrient: 'vertical' })" +)] +fn ordinary_import_calls_keep_their_behavior_when_not_styling_apis( + #[case] import: &str, + #[case] call: &str, +) -> Result<(), Box> { + // Given: default imports and function members do not declare styling arguments. + let source = format!("{import}\n{call};"); + + // When: instrument actual parsed source, then execute its call with ordinary functions. + let instrumented = dead_properties::instrument(&source, "ordinary.ts", "@devup-ui/react") + .map_err(io::Error::other)?; + let (_, body) = instrumented + .code + .split_once('\n') + .ok_or_else(|| io::Error::other("fixture import must end with a newline"))?; + let result = evaluate_instrumented(&format!( + "const fallback = rules => rules.boxOrient === 'vertical' ? 7 : 0; const css = fallback;\n{body}" + ))?; + + // Then: no styling-call metadata or declaration rejection, and the original result survives. + assert_eq!(instrumented.calls, vec![]); + assert_eq!(result.as_number(), Some(7.0)); + Ok(()) +} + +#[test] +fn direct_eval_keeps_lexical_scope_when_nested_calls_are_instrumented() -> Result<(), Box> +{ + // Given: direct eval declares a variable in the calling function's scope. + let source = "function inspect() { eval(String('var local = 42')); return local; } inspect();"; + + // When: execute the real instrumented program, including the nested String call. + let instrumented = dead_properties::instrument(source, "eval.ts", "@devup-ui/react") + .map_err(io::Error::other)?; + let result = evaluate_instrumented(&instrumented.code)?; + + // Then: the declaration belongs to inspect, not an instrumentation thunk. + assert_eq!(result.as_number(), Some(42.0)); + Ok(()) +} + +#[test] +fn suspending_call_keeps_generator_resume_value() -> Result<(), Box> { + // Given: the outer echo argument yields, but its inner echo call is synchronous. + let source = "const echo = value => value; function* values() { return echo(yield echo(7)); } const iter = values(); iter.next(); iter.next(13).value;"; + + // When: instrument and execute valid generator source. + let instrumented = dead_properties::instrument(source, "yield.ts", "@devup-ui/react") + .map_err(io::Error::other)?; + let result = evaluate_instrumented(&instrumented.code)?; + + // Then: yield remains in the generator body and the resumed argument reaches echo. + assert_eq!(result.as_number(), Some(13.0)); + Ok(()) +} + +#[test] +#[serial] +fn evaluated_call_mapping_has_no_ranges_when_authored_declarations_are_rejected() { + // Given: an aliased, valid source declaration rejected before evaluation. + reset_class_map(); + reset_file_map(); + let source = "import { css } from '@emotion/react';\nconst good = css({ color: 'red' }); const c = css({ boxOrient: 'vertical' });"; + let option = ExtractOption { + import_aliases: std::collections::HashMap::from([( + "@emotion/react".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }; + + // When: request evaluated origins for a source that cannot be evaluated. + let calls = dead_properties::evaluated_calls(source, "rejected.tsx", &option); + + // Then: there are no partial ranges to misattribute the authored diagnostic. + assert_eq!(calls, vec![]); +} + +#[rstest] +#[case(r"const c = css('box-align\\:center; color:red');")] +#[case(r"const c = css('content:box-align\\;center; color:red');")] +#[case(r"const c = css('content:box-align\\}center; color:red');")] +#[case(r"const c = css('');")] +#[serial] +fn css_escaped_delimiters_are_not_dead_declarations(#[case] statement: &str) { + // Given: escaped CSS punctuation is not a declaration boundary. + reset_class_map(); + reset_file_map(); + let source = format!("import {{ css }} from '@devup-ui/react';\n{statement}"); + + // When: extract through the normal public source entrypoint. + let result = extract("escapes.tsx", &source, ExtractOption::default()); + + // Then: text that merely resembles a dead declaration is accepted. + assert!(result.is_ok(), "{result:?}"); +} + +#[rstest] +#[case(r"const c = css('content:é😀; \x62ox-align:center');", r"\x62ox-align")] +#[case( + r"const c = css('content:\u{1F600}é; \u0062ox-align:center');", + r"\u0062ox-align" +)] +#[case( + r"const c = css('content:\uD83D\uDE00é; \u{62}ox-align:center');", + r"\u{62}ox-align" +)] +#[serial] +fn escaped_declaration_names_keep_authored_columns_after_unicode( + #[case] statement: &str, + #[case] key: &str, +) { + // Given: decoded Unicode occupies different byte lengths from the authored escapes. + reset_class_map(); + reset_file_map(); + let source = format!("import {{ css }} from '@devup-ui/react';\n{statement}"); + let column = statement[..position(statement, key)].chars().count() + 1; + + // When: the escaped name decodes to a dead declaration. + let error = failure(extract("unicode.tsx", &source, ExtractOption::default())); + + // Then: report the authored escape, not a byte position in the decoded string. + assert!( + error.starts_with(&format!("unicode.tsx:2:{column}:")), + "{error}" + ); + assert!(error.contains("box-align"), "{error}"); +} diff --git a/libs/extractor/src/dead_properties_declaration_boundary_tests.rs b/libs/extractor/src/dead_properties_declaration_boundary_tests.rs new file mode 100644 index 000000000..069bccd77 --- /dev/null +++ b/libs/extractor/src/dead_properties_declaration_boundary_tests.rs @@ -0,0 +1,233 @@ +use crate::dead_properties_test_utils::{failure, position}; +use crate::utils::{RESPONSIVE_ARRAY, build_time_error}; +use crate::{ExtractOption, ExtractStyleValue, ImportAlias, extract}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("box-align", "boxAlign")] +#[case("box-pack", "boxPack")] +#[case("box-flex", "boxFlex")] +#[case("box-flex-group", "boxFlexGroup")] +#[case("box-orient", "boxOrient")] +#[case("box-ordinal-group", "boxOrdinalGroup")] +#[case("box-direction", "boxDirection")] +#[case("box-lines", "boxLines")] +#[case("flex-order", "flexOrder")] +#[case("flex-positive", "flexPositive")] +#[case("flex-negative", "flexNegative")] +#[case("flex-preferred-size", "flexPreferredSize")] +#[case("scroll-snap-points-x", "scrollSnapPointsX")] +#[case("scroll-snap-points-y", "scrollSnapPointsY")] +#[case("scroll-snap-coordinate", "scrollSnapCoordinate")] +#[case("scroll-snap-destination", "scrollSnapDestination")] +#[case("scroll-snap-type-x", "scrollSnapTypeX")] +#[case("scroll-snap-type-y", "scrollSnapTypeY")] +#[serial] +fn rejects_dead_declarations_when_entering_public_expression_apis( + #[case] canonical: &str, + #[case] camel: &str, + #[values( + "globalString", + "globalTemplate", + "keyframesString", + "hover", + "valueObject", + "positionTry", + "viewTransitionClass" + )] + boundary: &str, +) { + // Given declaration bodies, rather than namespace names or token values. + reset_class_map(); + reset_file_map(); + let statement = match boundary { + "globalString" => format!("globalCss('body {{ color:red; {canonical}:1 }}');"), + "globalTemplate" => format!("globalCss(`body {{ color:red; {canonical}:1 }}`);"), + "keyframesString" => format!("const k = keyframes('from {{ {canonical}:1 }}');"), + "hover" => format!("const e = ;"), + "valueObject" => format!("const e = ;"), + "positionTry" => format!("const p = stylex.positionTry({{ {camel}:'1' }});"), + "viewTransitionClass" => { + format!("const v = stylex.viewTransitionClass({{ {camel}:'1' }});") + } + _ => unreachable!(), + }; + let source = format!( + "import {{ Box, globalCss, keyframes }} from '@devup-ui/react';\nimport * as stylex from '@stylexjs/stylex';\n{statement}" + ); + let name = if statement.contains(camel) { + camel + } else { + canonical + }; + let column = position(&statement, name) + 1; + + // When the public extractor reaches the expression or declaration boundary. + let error = failure(extract("boundary.tsx", &source, ExtractOption::default())); + + // Then the authored key owns the error, including the original API name. + let api = match boundary { + "globalString" | "globalTemplate" => "`globalCss()`", + "keyframesString" => "`keyframes()`", + "hover" | "valueObject" => "``", + "positionTry" => "`stylex.positionTry()`", + "viewTransitionClass" => "`stylex.viewTransitionClass()`", + _ => unreachable!(), + }; + assert!( + error.starts_with(&format!("boundary.tsx:3:{column}:")), + "{error}" + ); + assert!(error.contains(api), "{error}"); + assert!(error.contains(canonical), "{error}"); + let requirement = match crate::dead_properties::requirement(canonical) { + Some(requirement) => requirement, + None => panic!("fixture has no policy for {canonical}"), + }; + assert!(error.ends_with(requirement), "{error}"); +} + +#[rstest] +#[case("'@media print'", "@media print")] +#[case("_print", "_print")] +#[serial] +fn rejects_global_declarations_when_authored_inside_at_rule_maps( + #[case] key: &str, + #[case] path: &str, +) { + // Given a stylesheet with an authored global selector map inside an at-rule. + reset_class_map(); + reset_file_map(); + let statement = format!("globalCss({{ {key}: {{ body: {{ boxAlign:'center' }} }} }});"); + let source = format!("import {{ globalCss }} from '@devup-ui/react';\n{statement}"); + let column = position(&statement, "boxAlign") + 1; + + // When authored stylesheet calls are validated before their evaluation. + let error = failure(extract("global.css.ts", &source, ExtractOption::default())); + + // Then recursion keeps the selector path and the key's source location. + assert_eq!( + error, + format!( + "global.css.ts:2:{column}: {}", + build_time_error( + "globalCss", + &format!("{path} -> body -> boxAlign"), + "unprefixed box-align has no browser implementation; use display: flex and align-items", + ) + ) + ); +} + +#[test] +#[serial] +fn keeps_import_metadata_when_validating_global_selector_maps() +-> Result<(), Box> { + // Given import metadata in a deferred global call alongside an emitted style. + reset_class_map(); + reset_file_map(); + let source = "import { style, globalCss } from '@devup-ui/react';\nfunction loadFonts() { globalCss({ imports:[{url:'box-align.css',boxAlign:'metadata'}] }); }\nexport const card=style({color:'red'});"; + + // When the stylesheet's authored global map and evaluated style are checked. + let output = extract("imports.css.ts", source, ExtractOption::default())?; + + // Then metadata is not mistaken for a declaration, and the style still emits. + assert!(output.styles.iter().any(|style| matches!( + style, + ExtractStyleValue::Static(style) if style.property == "color" && style.value == "red" + ))); + Ok(()) +} + +#[test] +#[serial] +fn keeps_font_face_literals_when_a_descriptor_object_has_an_unreadable_spread() +-> Result<(), Box> { + // Given a spread that has no statically readable font-face descriptors. + reset_class_map(); + reset_file_map(); + let source = "import { globalCss } from '@devup-ui/react';\nglobalCss({ fontFaces:[{...external, fontFamily:'BoundaryFont', src:'local(BoundaryFont)'}] });"; + + // When the global font-face declaration API extracts literal descriptors. + let output = extract("font.tsx", source, ExtractOption::default())?; + + // Then the existing spread-ignore behavior preserves the authored literals. + let faces: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::FontFace(face) => Some(face.properties.clone()), + _ => None, + }) + .collect(); + assert_eq!( + faces, + vec![std::collections::BTreeMap::from([ + ("font-family".to_string(), "BoundaryFont".to_string()), + ("src".to_string(), "local(BoundaryFont)".to_string()), + ])] + ); + Ok(()) +} + +#[rstest] +#[case("{_hover:[{...([1,2] as const)},null]}")] +#[case("{selectors:{'&:hover':[{...[1,2]},null]}}")] +#[case("{'@media':{'(min-width:600px)':[{...[1,2]},null]}}")] +#[serial] +fn rejects_nested_responsive_arrays_when_spread_into_selector_entries(#[case] argument: &str) { + // Given an array spread nested inside a responsive selector entry. + reset_class_map(); + reset_file_map(); + let statement = format!("export const card=style({argument});"); + let source = format!("import {{style}} from '@vanilla-extract/css';\n{statement}"); + let column = position(&statement, "[1,2]") + 1; + let option = ExtractOption { + import_aliases: std::collections::HashMap::from([( + "@vanilla-extract/css".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }; + + // When the authored selector walk follows the object spread. + let error = failure(extract("spread.css.ts", &source, option)); + + // Then the nested array, not its containing object or call, owns the error. + assert_eq!( + error, + format!( + "spread.css.ts:2:{column}: {}", + build_time_error("style", "[1, 2]", RESPONSIVE_ARRAY) + ) + ); +} + +#[rstest] +#[case("{boxAlign:'center',color:'red'}")] +#[case("{...{boxAlign:'center'},boxAlign:'end',color:'red'}")] +#[serial] +fn rejects_root_dead_keys_when_literal_spreads_are_overridden(#[case] argument: &str) { + // Given a root declaration, including a spread key shadowed by a later key. + reset_class_map(); + reset_file_map(); + let statement = format!("const c=css({argument});"); + let source = format!("import {{css}} from '@devup-ui/react';\n{statement}"); + let column = position(&statement, "boxAlign") + 1; + + // When root validation runs before flattening or consuming properties. + let error = failure(extract("root.tsx", &source, ExtractOption::default())); + + // Then the original authored key still owns the policy failure. + assert!( + error.starts_with(&format!("root.tsx:2:{column}:")), + "{error}" + ); + assert!(error.contains("box-align"), "{error}"); + assert!( + error.contains("use display: flex and align-items"), + "{error}" + ); +} diff --git a/libs/extractor/src/dead_properties_origin_tests.rs b/libs/extractor/src/dead_properties_origin_tests.rs new file mode 100644 index 000000000..f268e25f0 --- /dev/null +++ b/libs/extractor/src/dead_properties_origin_tests.rs @@ -0,0 +1,264 @@ +use crate::dead_properties_test_utils::{failure, position}; +use crate::{ExtractOption, ImportAlias, ResolvedModule, extract, extract_with_modules}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +fn options() -> ExtractOption { + ExtractOption { + import_aliases: std::collections::HashMap::from([( + "@vanilla-extract/css".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + } +} + +#[rstest] +#[case( + "export const c = style(rules);", + "style", + "selectors -> &:hover -> boxOrient" +)] +#[case( + "globalStyle('body', rules);", + "globalStyle", + "selectors -> &:hover -> boxOrient" +)] +#[case( + "export const k = keyframes({ from: rules });", + "keyframes", + "from -> selectors -> &:hover -> boxOrient" +)] +#[case( + "export const v = styleVariants({ a: 1 }, () => rules);", + "styleVariants", + "selectors -> &:hover -> boxOrient" +)] +#[case( + "export const f = fontFace(rules);", + "fontFace", + "selectors -> &:hover -> boxOrient" +)] +#[case( + "globalFontFace('Inter', rules);", + "globalFontFace", + "selectors -> &:hover -> boxOrient" +)] +#[serial] +fn evaluated_rules_report_authored_call_and_path( + #[case] statement: &str, + #[case] api: &str, + #[case] path: &str, +) { + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{ style, globalStyle, keyframes, styleVariants, fontFace, globalFontFace }} from '@vanilla-extract/css';\nconst key = 'box' + 'Orient';\nconst rules = {{ selectors: {{ '&:hover': {{ [key]: 'vertical' }} }} }};\n{statement}" + ); + let column = position(statement, &format!("{api}(")) + 1; + + let error = failure(extract("origin.css.ts", &source, options())); + + assert!( + error.starts_with(&format!("origin.css.ts:4:{column}:")), + "{error}" + ); + assert!(error.contains(path), "{error}"); + assert!(error.contains("flex-direction"), "{error}"); +} + +#[rstest] +#[case("export const c = style({ boxOrient: 'vertical' });")] +#[case("export const k = keyframes({ from: { boxOrient: 'vertical' } });")] +#[case("globalStyle('body', { boxOrient: 'vertical' });")] +#[serial] +fn literal_stylesheet_declarations_keep_exact_key_locations(#[case] statement: &str) { + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{ style, globalStyle, keyframes }} from '@vanilla-extract/css';\n{statement}" + ); + let column = position(statement, "boxOrient") + 1; + + let error = failure(extract("literal.css.ts", &source, options())); + + assert!( + error.starts_with(&format!("literal.css.ts:2:{column}:")), + "{error}" + ); +} + +#[test] +#[serial] +fn imported_helper_call_keeps_its_authored_filename() { + reset_class_map(); + reset_file_map(); + let source = "import { style } from '@vanilla-extract/css';\nimport { make } from './helper';\nexport const c = style(make());"; + let resolver = |specifier: &str, _: &str| { + match specifier { + "./helper" => Some(ResolvedModule { + path: "helper.ts".to_string(), + code: "import { style } from '@vanilla-extract/css';\nconst key = 'box' + 'Orient';\nexport const make = () => style({ [key]: 'vertical' });".to_string(), + }), + _ => None, + } + }; + let error = failure(extract_with_modules( + "importer.css.ts", + source, + options(), + false, + &resolver, + )); + + assert!(error.starts_with("helper.ts:3:27:"), "{error}"); + assert!(error.contains("boxOrient"), "{error}"); +} + +#[test] +#[serial] +fn imported_stylesheet_call_keeps_its_authored_filename() { + reset_class_map(); + reset_file_map(); + let source = "import { style } from '@vanilla-extract/css';\nimport { c } from './other.css';\nexport const result = style([c, { color: 'red' }]);"; + let resolver = |specifier: &str, _: &str| { + match specifier { + "./other.css" => Some(ResolvedModule { + path: "other.css.ts".to_string(), + code: "import { style } from '@vanilla-extract/css';\nconst key = 'box' + 'Orient';\nexport const c = style({ [key]: 'vertical' });".to_string(), + }), + _ => None, + } + }; + let error = failure(extract_with_modules( + "importer.css.ts", + source, + options(), + false, + &resolver, + )); + + assert!(error.starts_with("other.css.ts:3:18:"), "{error}"); +} + +#[rstest] +#[case("const c = css(rules);", "css(")] +#[case("const k = keyframes({ from: rules });", "keyframes(")] +#[case("globalCss({ body: rules });", "globalCss(")] +#[case("const S = styled.div(rules);", "styled.div(")] +#[case("const e = ;", " Some(ResolvedModule { + path: "rules.ts".to_string(), + code: "export const rules = { selectors: { '&:hover': { boxOrient: 'vertical' } } };" + .to_string(), + }), + _ => None, + }; + + let error = failure(extract_with_modules( + "imported.tsx", + &source, + ExtractOption::default(), + false, + &resolver, + )); + + assert!( + error.starts_with(&format!( + "imported.tsx:3:{}:", + position(statement, call) + 1 + )), + "{error}" + ); + assert!( + error.contains("selectors -> &:hover -> boxOrient"), + "{error}" + ); +} + +#[test] +#[serial] +fn variant_mapper_data_is_not_a_declaration() { + reset_class_map(); + reset_file_map(); + let source = "import { styleVariants } from '@devup-ui/react';\nexport const c = styleVariants({ a: { boxOrient: 'red' } }, data => ({ color: data.boxOrient }));"; + + let result = extract("variants.css.ts", source, ExtractOption::default()); + + assert!(result.is_ok(), "{result:?}"); +} + +#[test] +#[serial] +fn computed_object_reports_its_authored_css_call() { + reset_class_map(); + reset_file_map(); + let source = "import { css } from '@devup-ui/react';\nfunction makeRules() { return { selectors: { '&:hover': { boxOrient: 'vertical' } } }; }\nconst c = css(makeRules());"; + + let error = failure(extract("computed.tsx", source, ExtractOption::default())); + + assert!(error.starts_with("computed.tsx:3:11:"), "{error}"); + assert!( + error.contains("selectors -> &:hover -> boxOrient"), + "{error}" + ); +} + +#[rstest] +#[case( + "import { style as make } from '@devup-ui/react';", + "const c = make(rules);", + "make(" +)] +#[case( + "import * as ve from '@devup-ui/react';", + "const c = ve.style(rules);", + "ve.style(" +)] +#[case( + "import { style } from '@devup-ui/react'; const make = style;", + "const c = make(rules);", + "make(" +)] +#[case( + "import { style } from '@devup-ui/react'; const fns = { make: style };", + "const c = fns.make(rules);", + "fns.make(" +)] +#[case( + "import { style } from '@devup-ui/react'; const fns = [style]; const i = 0;", + "const c = fns[i](rules);", + "fns[i](" +)] +#[serial] +fn stylesheet_api_aliases_preserve_call_origins( + #[case] imports: &str, + #[case] statement: &str, + #[case] call: &str, +) { + reset_class_map(); + reset_file_map(); + let source = format!( + "{imports}\nconst key = 'box' + 'Orient'; const rules = {{ [key]: 'vertical' }};\n{statement}" + ); + + let error = failure(extract("aliases.css.ts", &source, ExtractOption::default())); + + assert!( + error.starts_with(&format!( + "aliases.css.ts:3:{}:", + position(statement, call) + 1 + )), + "{error}" + ); + assert!(error.contains("boxOrient"), "{error}"); +} diff --git a/libs/extractor/src/dead_properties_responsive_origin_tests.rs b/libs/extractor/src/dead_properties_responsive_origin_tests.rs new file mode 100644 index 000000000..014c051f9 --- /dev/null +++ b/libs/extractor/src/dead_properties_responsive_origin_tests.rs @@ -0,0 +1,221 @@ +use crate::dead_properties_test_utils::{failure, position}; +use crate::utils::{RESPONSIVE_ARRAY, build_time_error}; +use crate::{ExtractOption, ImportAlias, ResolvedModule, extract, extract_with_modules}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +fn options() -> ExtractOption { + ExtractOption { + import_aliases: std::collections::HashMap::from([( + "@vanilla-extract/css".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + } +} + +#[rstest] +#[case("export const bad = style({p:[[1,2],3]});", "style")] +#[case("export const bad = style({p:[([1,2] as const),3]});", "style")] +#[case( + "export const bad = style({selectors:{'&:hover':{p:[[1,2],3]}}});", + "style" +)] +#[case("export const bad = style({_hover:[{p:[1,2]},null]});", "style")] +#[case( + "export const bad = style({selectors:{'&:hover':[[1,2],3]}});", + "style" +)] +#[case("export const bad = style([{color:'red'},[{p:[[1,2],3]}]]);", "style")] +#[case("globalStyle('body',{p:[[1,2],3]});", "globalStyle")] +#[case("export const bad = keyframes({from:{p:[[1,2],3]}});", "keyframes")] +#[case( + "export const bad = styleVariants({small:{p:[[1,2],3]}});", + "styleVariants" +)] +#[serial] +fn literal_nested_arrays_report_the_authored_array(#[case] statement: &str, #[case] api: &str) { + // Given an authored literal whose nested array is not at the call's column. + reset_class_map(); + reset_file_map(); + let source = format!("import {{{api}}} from '@vanilla-extract/css';\n\n\n\n{statement}"); + let column = position(statement, "[1,2]") + 1; + + // When stylesheet declaration validation runs before evaluation. + let error = failure(extract("literal.css.ts", &source, options())); + + // Then the error uses the inner array's authored position and expression. + assert_eq!( + error, + format!( + "literal.css.ts:5:{column}: {}", + build_time_error(api, "[1, 2]", RESPONSIVE_ARRAY) + ) + ); +} + +#[rstest] +#[case("const values=[[1,2],3];", concat!("{", "p:values", "}"), "p -> 0")] +#[case("const values=Array.of([1,2],3);", concat!("{", "p:values", "}"), "p -> 0")] +#[case("const values=() => [[1,2],3];", "{p:values()}", "p -> 0")] +#[case("const key='p';", "{[key]:[[1,2],3]}", "p -> 0")] +#[case("const values={p:[[1,2],3]};", "{...values}", "p -> 0")] +#[case( + "const values=[[1,2],3];", + concat!("{selectors:{'&:hover':", "{", "p:values", "}}}"), + "selectors -> &:hover -> p -> 0" +)] +#[case( + "const values=[[1,2],3];", + "{selectors:{'&:hover':values}}", + "selectors -> &:hover -> 0" +)] +#[case( + "const values=[[1,2],3];", + concat!("{'@media':{'(min-width: 600px)':", "{", "p:values", "}}}"), + "@media -> (min-width: 600px) -> p -> 0" +)] +#[case( + "const values=[{p:[1,2]},null];", + "{_hover:values}", + "_hover -> 0 -> p" +)] +#[serial] +fn evaluated_nested_arrays_report_the_authored_call_and_path( + #[case] declaration: &str, + #[case] argument: &str, + #[case] path: &str, +) { + // Given a value that acquires its responsive shape during evaluation. + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{style}} from '@vanilla-extract/css';\n{declaration}\n\n\nexport const bad = style({argument});" + ); + + // When the evaluated JSON is validated before generated css() lowering. + let error = failure(extract("evaluated.css.ts", &source, options())); + + // Then neither coordinates nor the API name refer to the generated code. + assert_eq!( + error, + format!( + "evaluated.css.ts:5:20: {}", + build_time_error("style", path, RESPONSIVE_ARRAY) + ) + ); +} + +#[rstest] +#[case("values", "[[1,2],3]", concat!("{", "p:values", "}"))] +#[case("rules", "{selectors:{'&:hover':{p:[[1,2],3]}}}", "rules")] +#[serial] +fn imported_nested_arrays_report_the_consuming_stylesheet_call( + #[case] export: &str, + #[case] value: &str, + #[case] argument: &str, +) { + // Given imported data whose declaration is not an authored styling call. + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{style}} from '@vanilla-extract/css';\nimport {{{export}}} from './values';\n\n\nexport const bad = style({argument});" + ); + let module = format!("export const {export}={value};"); + let resolver = move |specifier: &str, _: &str| match specifier { + "./values" => Some(ResolvedModule { + path: "values.ts".to_string(), + code: module.clone(), + }), + _ => None, + }; + + // When the importing stylesheet evaluates the declaration. + let error = failure(extract_with_modules( + "importer.css.ts", + &source, + options(), + false, + &resolver, + )); + + // Then its styling call, not the data module or generated css(), owns it. + let path = match export { + "values" => "p -> 0", + _ => "selectors -> &:hover -> p -> 0", + }; + assert_eq!( + error, + format!( + "importer.css.ts:5:20: {}", + build_time_error("style", path, RESPONSIVE_ARRAY) + ) + ); +} + +#[test] +#[serial] +fn imported_stylesheet_nested_arrays_keep_the_declaring_call_origin() { + // Given an imported stylesheet containing the invalid styling call. + reset_class_map(); + reset_file_map(); + let source = "import {style} from '@vanilla-extract/css';\nimport {bad} from './other.css';\nexport const result=style([bad,{color:'red'}]);"; + let resolver = |specifier: &str, _: &str| { + match specifier { + "./other.css" => Some(ResolvedModule { + path: "other.css.ts".to_string(), + code: concat!("import {style} from '@vanilla-extract/css';\nconst values=[[1,2],3];\n\n\nexport const bad = style(", "{", "p:values", "}", ");").to_string(), + }), + _ => None, + } + }; + + // When its importer requests the exported style. + let error = failure(extract_with_modules( + "outer.css.ts", + source, + options(), + false, + &resolver, + )); + + // Then the authored styling call remains in the imported stylesheet. + assert_eq!( + error, + format!( + "other.css.ts:5:20: {}", + build_time_error("style", "p -> 0", RESPONSIVE_ARRAY) + ) + ); +} + +#[rstest] +#[case("{p:[1,2,3]}")] +#[case("{p:[1,null,,3]}")] +#[case("{_hover:[{p:1},null,{p:3}]}")] +#[case("{p:([[1,2],[3,4]])[0]}")] +#[case("{p:({small:[1,2],large:[3,4]})['small']}")] +#[case("{p:(true ? [1,2] : [[3,4],5])}")] +#[case("{p:[false ? [1,2] : 3,4]}")] +#[case("{p:([1,null,3] as const)}")] +#[case("{selectors:{'&:hover':{p:[1,null,3]}}}")] +#[case("[{p:[1,2]},[{color:'red'}]]")] +#[serial] +fn valid_responsive_values_and_root_compositions_remain_extractable( + #[case] argument: &str, +) -> Result<(), Box> { + // Given flat responsive values or arrays that compose entire styles. + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{style}} from '@vanilla-extract/css';\nexport const valid=style({argument});" + ); + + // When the stylesheet passes authored and evaluated validation. + let output = extract("valid.css.ts", &source, options())?; + + // Then it still produces extracted styles instead of a blanket array error. + assert_ne!(output.styles.len(), 0); + Ok(()) +} diff --git a/libs/extractor/src/dead_properties_stylex_tests.rs b/libs/extractor/src/dead_properties_stylex_tests.rs new file mode 100644 index 000000000..089045750 --- /dev/null +++ b/libs/extractor/src/dead_properties_stylex_tests.rs @@ -0,0 +1,64 @@ +use crate::dead_properties_test_utils::{failure, position}; +use crate::{ExtractOption, extract}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("boxAlign")] +#[case("boxPack")] +#[case("boxFlex")] +#[case("boxFlexGroup")] +#[case("boxOrient")] +#[case("boxOrdinalGroup")] +#[case("boxDirection")] +#[case("boxLines")] +#[case("flexOrder")] +#[case("flexPositive")] +#[case("flexNegative")] +#[case("flexPreferredSize")] +#[case("scrollSnapPointsX")] +#[case("scrollSnapPointsY")] +#[case("scrollSnapCoordinate")] +#[case("scrollSnapDestination")] +#[case("scrollSnapTypeX")] +#[case("scrollSnapTypeY")] +#[serial] +fn stylex_property_boundaries_reject_dead_names( + #[case] property: &str, + #[values("static", "pseudo", "dynamic", "keyframes")] shape: &str, +) { + reset_class_map(); + reset_file_map(); + let statement = match shape { + "static" => format!("const c = stylex.create({{x:{{{property}:'center'}}}});"), + "pseudo" => format!("const c = stylex.create({{x:{{':hover':{{{property}:'center'}}}}}});"), + "dynamic" => format!("const c = stylex.create({{x:(value)=>({{{property}:value}})}});"), + "keyframes" => format!("const c = stylex.keyframes({{from:{{{property}:'center'}}}});"), + _ => unreachable!(), + }; + let source = format!("import * as stylex from '@stylexjs/stylex';\n{statement}"); + let error = failure(extract( + "stylex-dead.tsx", + &source, + ExtractOption::default(), + )); + let column = position(&statement, property) + 1; + assert!( + error.starts_with(&format!("stylex-dead.tsx:2:{column}:")), + "{error}" + ); + assert!(error.contains("use "), "{error}"); +} + +#[test] +#[serial] +fn stylex_namespace_and_token_names_are_not_declarations() -> Result<(), Box> +{ + reset_class_map(); + reset_file_map(); + let source = "import * as stylex from '@stylexjs/stylex';\nconst vars = stylex.defineVars({boxAlign:'red'}); const c = stylex.create({boxAlign:{color:vars.boxAlign,strokeColor:'red',imeMode:'active',WebkitBoxOrient:'vertical',futureDraft:'on'}});"; + let output = extract("stylex-control.tsx", source, ExtractOption::default())?; + assert_ne!(output.styles.len(), 0); + Ok(()) +} diff --git a/libs/extractor/src/dead_properties_test_utils.rs b/libs/extractor/src/dead_properties_test_utils.rs new file mode 100644 index 000000000..fe0d7993c --- /dev/null +++ b/libs/extractor/src/dead_properties_test_utils.rs @@ -0,0 +1,13 @@ +pub(super) fn position(source: &str, needle: &str) -> usize { + match source.find(needle) { + Some(position) => position, + None => panic!("fixture has no {needle:?}"), + } +} + +pub(super) fn failure(result: Result>) -> String { + match result { + Err(error) => error.to_string(), + Ok(output) => panic!("expected declaration error, got {output:?}"), + } +} diff --git a/libs/extractor/src/dead_properties_tests.rs b/libs/extractor/src/dead_properties_tests.rs new file mode 100644 index 000000000..0e227e5ba --- /dev/null +++ b/libs/extractor/src/dead_properties_tests.rs @@ -0,0 +1,135 @@ +use crate::dead_properties_test_utils::{failure, position}; +use crate::{ExtractOption, ExtractStyleValue, extract}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("box-align", "boxAlign")] +#[case("box-pack", "boxPack")] +#[case("box-flex", "boxFlex")] +#[case("box-flex-group", "boxFlexGroup")] +#[case("box-orient", "boxOrient")] +#[case("box-ordinal-group", "boxOrdinalGroup")] +#[case("box-direction", "boxDirection")] +#[case("box-lines", "boxLines")] +#[case("flex-order", "flexOrder")] +#[case("flex-positive", "flexPositive")] +#[case("flex-negative", "flexNegative")] +#[case("flex-preferred-size", "flexPreferredSize")] +#[case("scroll-snap-points-x", "scrollSnapPointsX")] +#[case("scroll-snap-points-y", "scrollSnapPointsY")] +#[case("scroll-snap-coordinate", "scrollSnapCoordinate")] +#[case("scroll-snap-destination", "scrollSnapDestination")] +#[case("scroll-snap-type-x", "scrollSnapTypeX")] +#[case("scroll-snap-type-y", "scrollSnapTypeY")] +#[serial] +fn rejects_closed_property_set_at_exact_keys( + #[case] canonical: &str, + #[case] camel: &str, + #[values( + "jsx", + "css", + "nested", + "styled", + "keyframes", + "global", + "fontFaces", + "text", + "template", + "styledText", + "keyframesText", + "globalText", + "upperText" + )] + api: &str, +) { + reset_class_map(); + reset_file_map(); + let statement = match api { + "jsx" => format!("const e = ;"), + "css" => format!("const c = css({{ {camel}: '1' }});"), + "nested" => format!("const c = css({{ selectors: {{ '&:hover': {{ {camel}: '1' }} }} }});"), + "styled" => format!("const S = styled.div({{ {camel}: '1' }});"), + "keyframes" => format!("const k = keyframes({{ from: {{ {camel}: '1' }} }});"), + "global" => format!("globalCss({{ body: {{ {camel}: '1' }} }});"), + "fontFaces" => format!("globalCss({{ fontFaces: [{{ {camel}: '1' }}] }});"), + "text" => format!("const c = css('color: red; {canonical}: 1');"), + "template" => format!("const c = css`color: red; {canonical}: 1`;"), + "styledText" => format!("const S = styled.div`color: red; {canonical}: 1`;"), + "keyframesText" => format!("const k = keyframes`from {{ {canonical}: 1 }}`;"), + "globalText" => format!("globalCss`body {{ {canonical}: 1 }}`;"), + "upperText" => format!("const c = css`{}: 1`;", canonical.to_ascii_uppercase()), + _ => unreachable!(), + }; + let uppercase = canonical.to_ascii_uppercase(); + let name = if api == "upperText" { + uppercase.as_str() + } else if statement.contains(camel) { + camel + } else { + canonical + }; + let column = position(&statement, name) + 1; + let source = format!( + "import {{ Box, css, styled, keyframes, globalCss }} from '@devup-ui/react';\n{statement}" + ); + let error = failure(extract("dead.tsx", &source, ExtractOption::default())); + assert!( + error.starts_with(&format!("dead.tsx:2:{column}:")), + "{error}" + ); + assert!(error.contains(canonical), "{error}"); + assert!(error.contains("use "), "{error}"); +} + +#[rstest] +#[case( + "const c = css({ strokeColor: 'red', imeMode: 'active', futureDraft: 'on', WebkitBoxOrient: 'vertical', p: 2 });" +)] +#[case( + "const c = css`stroke-color:red;ime-mode:active;future-draft:on;-webkit-box-orient:vertical;padding:8px`; " +)] +#[serial] +fn supported_and_open_ended_declarations_still_emit(#[case] statement: &str) { + reset_class_map(); + reset_file_map(); + let source = format!("import {{ css }} from '@devup-ui/react';\n{statement}"); + let output = match extract("controls.tsx", &source, ExtractOption::default()) { + Ok(output) => output, + Err(error) => panic!("control failed: {error}"), + }; + let declarations: std::collections::BTreeSet<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) => { + Some((style.property.as_str(), style.value.as_str())) + } + _ => None, + }) + .collect(); + assert_eq!( + declarations, + std::collections::BTreeSet::from([ + ("stroke-color", "red"), + ("ime-mode", "active"), + ("future-draft", "on"), + ("-webkit-box-orient", "vertical"), + ("padding", "8px"), + ]) + ); +} + +#[rstest] +#[case( + "const c = css`/* box-align: center */ content:'box-align: center'; background:url(box-align:foo); --data: box-align: center; &:is([box-align='center']) { color:red }`; " +)] +#[case("const c = css`box-align:hover { color:red }`; ")] +#[serial] +fn scanner_ignores_names_outside_declaration_keys(#[case] statement: &str) { + reset_class_map(); + reset_file_map(); + let source = format!("import {{ css }} from '@devup-ui/react';\n{statement}"); + assert!(extract("controls.tsx", &source, ExtractOption::default()).is_ok()); +} diff --git a/libs/extractor/src/dead_properties_text_tests.rs b/libs/extractor/src/dead_properties_text_tests.rs new file mode 100644 index 000000000..740c50e3a --- /dev/null +++ b/libs/extractor/src/dead_properties_text_tests.rs @@ -0,0 +1,113 @@ +use crate::dead_properties_test_utils::{failure, position}; +use crate::{ExtractOption, ImportAlias, extract}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case(r"const c = css('color:red;\nbox-align:center');", "box-align")] +#[case(r"const c = css('content:\x61; box-align:center');", "box-align")] +#[case(r"const c = css('content:\u2192; box-align:center');", "box-align")] +#[case(r"const c = css('content:\u{2192}; box-align:center');", "box-align")] +#[case( + r"const c = css('content:\uD83D\uDE00; box-align:center');", + "box-align" +)] +#[case("const c = css('color:red;\\\n box-align:center');", "box-align")] +#[case("const c = css('color:red;\\\r\n box-align:center');", "box-align")] +#[case( + r#"const c = css('content:"\\u2192"; box-align:center');"#, + "box-align" +)] +#[case("const c = css`color:${value};\nbox-align:center`;", "box-align")] +#[case("const c = css`color:${'red'};\nbox-align:center`;", "box-align")] +#[case("const c = css`${'box-align'}:center`;", "box-align")] +#[case( + "const c = css`color:red; /* comment */ box-align /* gap */:center`;", + "box-align" +)] +#[case( + "globalCss({ fontFaces: [`src:url(a); box-align:center`] });", + "box-align" +)] +#[case("const c = css({ 'BOX-ALIGN': 'center' });", "'BOX-ALIGN'")] +#[serial] +fn text_mapping_keeps_authored_declaration_location(#[case] statement: &str, #[case] key: &str) { + reset_class_map(); + reset_file_map(); + let source = format!("import {{ css, globalCss }} from '@devup-ui/react';\n{statement}"); + let offset = position(&source, key); + let prefix = &source[..offset]; + let line = prefix.bytes().filter(|byte| *byte == b'\n').count() + 1; + let column = prefix.rsplit('\n').next().unwrap_or("").chars().count() + 1; + + let error = failure(extract("text.tsx", &source, ExtractOption::default())); + + assert!( + error.starts_with(&format!("text.tsx:{line}:{column}:")), + "{error}" + ); +} + +#[rstest] +#[case("const c = cAlias({ boxOrient: 'vertical' });", "boxOrient")] +#[case("const c = cAlias`box-orient:vertical`;", "box-orient")] +#[case("const k = kAlias({ from: { boxOrient: 'vertical' } });", "boxOrient")] +#[case("gAlias`body { box-orient:vertical }`;", "box-orient")] +#[serial] +fn import_aliases_use_the_same_declaration_boundary(#[case] statement: &str, #[case] key: &str) { + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{ css as cAlias, keyframes as kAlias }} from '@emotion/react'; import {{ globalCss as gAlias }} from '@devup-ui/react';\n{statement}" + ); + let options = ExtractOption { + import_aliases: std::collections::HashMap::from([( + "@emotion/react".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }; + + let error = failure(extract("alias.tsx", &source, options)); + + assert!( + error.starts_with(&format!("alias.tsx:2:{}:", position(statement, key) + 1)), + "{error}" + ); +} + +#[rstest] +#[case("const c = css`content:'box-align: center';--rules:{box-align:center};color:red`; ")] +#[case("const c = css`&[data-value=\"box-align: center\"] { color:red }`; ")] +#[case("const c = css`/* unterminated box-align:center`; ")] +#[serial] +fn scanner_ignores_balanced_custom_values_and_selector_strings(#[case] statement: &str) { + reset_class_map(); + reset_file_map(); + let source = format!("import {{ css }} from '@devup-ui/react';\n{statement}"); + + let result = extract("controls.tsx", &source, ExtractOption::default()); + + assert!(result.is_ok(), "{result:?}"); +} + +#[rstest] +#[case("keyframes({ from: { p: [[1, 2]] } });")] +#[case("globalCss({ body: { p: [[1, 2]] } });")] +#[serial] +fn unreadable_arrays_keep_the_stored_location(#[case] statement: &str) { + reset_class_map(); + reset_file_map(); + let source = format!("import {{ keyframes, globalCss }} from '@devup-ui/react';\n{statement}"); + + let error = failure(extract("arrays.tsx", &source, ExtractOption::default())); + + assert!( + error.starts_with(&format!( + "arrays.tsx:2:{}:", + position(statement, "[1, 2]") + 1 + )), + "{error}" + ); +} diff --git a/libs/extractor/src/extract_style/constant.rs b/libs/extractor/src/extract_style/constant.rs index 3388d2198..ba3088f6a 100644 --- a/libs/extractor/src/extract_style/constant.rs +++ b/libs/extractor/src/extract_style/constant.rs @@ -21,19 +21,21 @@ pub(crate) static MAINTAIN_VALUE_PROPERTIES: phf::Set<&str> = phf_set! { "aspect-ratio", "flex-grow", "flex-shrink", + "flex-order", + "flex-positive", + "flex-negative", "order", "grid-area", "grid-column", "grid-column-start", "grid-column-end", + "grid-column-span", "grid-row", "grid-row-start", "grid-row-end", + "grid-row-span", "animation-iteration-count", "tab-size", - "moz-tab-size", - "-webkit-line-clamp", - "webkit-line-clamp", "border-image", "border-image-outset", "border-image-slice", @@ -62,6 +64,21 @@ pub(crate) static MAINTAIN_VALUE_PROPERTIES: phf::Set<&str> = phf_set! { "stroke-width" }; +/// Strip one known vendor prefix for classification, preserving unknown names. +pub(crate) fn strip_vendor_prefix(property: &str) -> &str { + let prefixed = property.strip_prefix('-').unwrap_or(property); + ["webkit-", "moz-", "ms-", "o-"] + .into_iter() + .find_map(|prefix| prefixed.strip_prefix(prefix)) + .unwrap_or(property) +} + +/// Classify numeric values independently of vendor spelling, without changing +/// the property name emitted in CSS or deciding whether that name is valid. +pub(crate) fn is_maintain_value_property(property: &str) -> bool { + MAINTAIN_VALUE_PROPERTIES.contains(strip_vendor_prefix(property)) +} + /// Properties taking a time, whose numbers are milliseconds pub(crate) static TIME_PROPERTIES: phf::Set<&str> = phf_set! { "transition-duration", diff --git a/libs/extractor/src/extract_style/extract_static_style.rs b/libs/extractor/src/extract_style/extract_static_style.rs index 1adc78be6..7efaa2fd5 100644 --- a/libs/extractor/src/extract_style/extract_static_style.rs +++ b/libs/extractor/src/extract_style/extract_static_style.rs @@ -11,7 +11,7 @@ use css::{ use crate::{ extract_style::{ ExtractStyleProperty, - constant::{MAINTAIN_VALUE_PROPERTIES, TIME_PROPERTIES}, + constant::{TIME_PROPERTIES, is_maintain_value_property, strip_vendor_prefix}, style_property::StyleProperty, }, utils::{convert_value, gcd}, @@ -68,7 +68,7 @@ impl ExtractStaticStyle { // own result). Only the aspect-ratio reduction and the `convert_value` branch // must own; both already produce owned `String`s. Byte-identical output. let normalized: Cow = - if MAINTAIN_VALUE_PROPERTIES.contains(property) || property.starts_with("--") { + if is_maintain_value_property(property) || property.starts_with("--") { if apply_aspect_ratio && property == "aspect-ratio" && value.contains('/') { if let Some((a, b)) = value.split_once('/').and_then(|(a, b)| { Some((a.trim().parse::().ok()?, b.trim().parse::().ok()?)) @@ -81,7 +81,7 @@ impl ExtractStaticStyle { } else { Cow::Borrowed(value) } - } else if TIME_PROPERTIES.contains(property) { + } else if TIME_PROPERTIES.contains(strip_vendor_prefix(property)) { // A time is written in `ms`, never on the spacing scale value.parse::().map_or(Cow::Borrowed(value), |number| { Cow::Owned(format!("{}ms", crate::utils::js_number_string(number))) @@ -212,6 +212,9 @@ impl ExtractStyleProperty for ExtractStaticStyle { } } +#[cfg(test)] +mod numeric_css_edge_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/libs/extractor/src/extract_style/extract_static_style/numeric_css_edge_tests.rs b/libs/extractor/src/extract_style/extract_static_style/numeric_css_edge_tests.rs new file mode 100644 index 000000000..f88989700 --- /dev/null +++ b/libs/extractor/src/extract_style/extract_static_style/numeric_css_edge_tests.rs @@ -0,0 +1,222 @@ +use super::ExtractStaticStyle; +use crate::extract_style::extract_style_value::ExtractStyleValue; + +#[rstest::rstest] +#[case(("animation-duration", "3ms"))] +#[case(("animation-delay", "3ms"))] +#[case(("transition-duration", "3ms"))] +#[case(("transition-delay", "3ms"))] +#[case(("line-clamp", "3"))] +#[case(("border-radius", "12px"))] +fn numeric_css_08_vendor_categories_when_prefixed( + #[case] declaration: (&str, &str), + #[values("-webkit-", "webkit-", "-moz-", "moz-", "-ms-", "ms-", "-o-", "o-")] prefix: &str, + #[values(false, true)] basic: bool, +) { + let (base, expected) = declaration; + let property = format!("{prefix}{base}"); + let constructor = if basic { + ExtractStaticStyle::new_basic + } else { + ExtractStaticStyle::new + }; + let style = constructor(&property, "3", 0, None); + assert_eq!( + (style.property(), style.value()), + (property.as_str(), expected) + ); +} + +#[test] +#[serial_test::serial] +fn numeric_css_08_inventory_matches_unprefixed_numeric_form() +-> Result<(), Box> { + let inventory = " + WebkitBoxAlign MozBoxAlign WebkitBoxDirection MozBoxDirection + WebkitBoxFlex MozBoxFlex WebkitBoxFlexGroup MozBoxFlexGroup + WebkitBoxLines MozBoxLines WebkitBoxOrdinalGroup MozBoxOrdinalGroup + WebkitBoxOrient MozBoxOrient WebkitBoxPack MozBoxPack MozOverflowClipBox + msAccelerator msBlockProgression msContentZoomChaining msContentZoomLimitMax + msContentZoomLimitMin msContentZoomSnapPoints msContentZoomSnapType msContentZooming + msFilter msFlowFrom msFlowInto msGridColumns msGridRows msHighContrastAdjust + msHyphenateLimitChars msHyphenateLimitLines msHyphenateLimitZone msImeAlign + msOverflowStyle msScrollChaining msScrollLimitXMax msScrollLimitXMin + msScrollLimitYMax msScrollLimitYMin msScrollRails msScrollSnapPointsX + msScrollSnapPointsY msScrollSnapType msScrollTranslation msScrollbar3dlightColor + msScrollbarArrowColor msScrollbarBaseColor msScrollbarDarkshadowColor msScrollbarFaceColor + msScrollbarHighlightColor msScrollbarShadowColor msScrollbarTrackColor msTextAutospace + msTouchSelect msUserSelect msWrapFlow msWrapMargin msWrapThrough + MozAppearance MozBinding MozBorderBottomColors MozBorderLeftColors + MozBorderRightColors MozBorderTopColors MozContextProperties MozFloatEdge + MozForceBrokenImageIcon MozOrient MozOutlineRadiusBottomleft MozOutlineRadiusBottomright + MozOutlineRadiusTopleft MozOutlineRadiusTopright MozStackSizing MozTextBlink + MozUserFocus MozUserInput MozUserModify MozWindowDragging MozWindowShadow + WebkitAppearance WebkitBorderAfterColor WebkitBorderAfterStyle WebkitBorderAfterWidth + WebkitBorderBeforeColor WebkitBorderBeforeStyle WebkitBorderBeforeWidth + WebkitBorderEndColor WebkitBorderEndStyle WebkitBorderEndWidth + WebkitBorderStartColor WebkitBorderStartStyle WebkitBorderStartWidth WebkitBoxReflect + WebkitLineClamp WebkitMaskAttachment WebkitMaskClip WebkitMaskComposite WebkitMaskImage + WebkitMaskOrigin WebkitMaskPosition WebkitMaskPositionX WebkitMaskPositionY + WebkitMaskRepeat WebkitMaskRepeatX WebkitMaskRepeatY WebkitMaskSize + WebkitOverflowScrolling WebkitTapHighlightColor WebkitTextFillColor + WebkitTextStrokeColor WebkitTextStrokeWidth WebkitTouchCallout WebkitUserModify WebkitUserSelect + msContentZoomLimit msContentZoomSnap msScrollLimit msScrollSnapX msScrollSnapY + MozOutlineRadius WebkitBorderAfter WebkitBorderBefore WebkitBorderEnd WebkitBorderStart + WebkitMask WebkitTextStroke + msFlex msFlexOrder msFlexPositive msFlexNegative msGridColumnSpan msGridRowSpan MozTabSize + WebkitAnimationDuration WebkitAnimationDelay WebkitTransitionDuration WebkitTransitionDelay + MozAnimationDuration MozAnimationDelay MozTransitionDuration MozTransitionDelay + msAnimationDuration msAnimationDelay msTransitionDuration msTransitionDelay + OAnimationDuration OAnimationDelay OTransitionDuration OTransitionDelay + "; + for key in inventory.split_whitespace() { + let Some((prefix, suffix)) = [ + ("Webkit", "webkit"), + ("Moz", "moz"), + ("ms", "ms"), + ("O", "o"), + ] + .into_iter() + .find_map(|(prefix, css)| key.strip_prefix(prefix).map(|suffix| (css, suffix))) else { + panic!("inventory entry needs a vendor prefix: {key}"); + }; + let base = css::utils::to_kebab_case(suffix); + let property = format!("-{prefix}-{base}"); + let expected = ExtractStaticStyle::new(&base, "3", 0, None); + let source = + format!("import {{ Box }} from '@devup-ui/react'; const box = ;"); + let output = crate::extract( + "vendor-inventory.tsx", + &source, + crate::ExtractOption::default(), + )?; + assert_eq!(output.styles.len(), 1, "{key}"); + let Some(ExtractStyleValue::Static(style)) = output.styles.iter().next() else { + panic!("expected static vendor declaration: {key}"); + }; + assert_eq!( + (style.property(), style.value()), + (property.as_str(), expected.value()), + "{key}" + ); + } + Ok(()) +} + +#[rstest::rstest] +#[case("-ms-flex")] +#[case("-ms-flex-order")] +#[case("-ms-flex-positive")] +#[case("-ms-flex-negative")] +#[case("-webkit-box-flex")] +#[case("-webkit-box-ordinal-group")] +#[case("-ms-grid-column-span")] +#[case("-ms-grid-row-span")] +#[case("-moz-tab-size")] +#[case("-webkit-line-clamp")] +#[case("-o-opacity")] +#[case("o-opacity")] +fn numeric_css_08_constructors_keep_vendor_numbers(#[case] property: &str) { + let value = "2"; + let styles = [ + ExtractStaticStyle::new(property, value, 0, None), + ExtractStaticStyle::new_basic(property, value, 0, None), + ]; + for style in styles { + assert_eq!(style.value(), value); + assert_eq!(style.property(), property); + } +} + +#[rstest::rstest] +#[case("msFlex", "-ms-flex", "2")] +#[case("msFlexOrder", "-ms-flex-order", "2")] +#[case("msFlexPositive", "-ms-flex-positive", "2")] +#[case("msFlexNegative", "-ms-flex-negative", "2")] +#[case("WebkitBoxFlex", "-webkit-box-flex", "2")] +#[case("WebkitBoxOrdinalGroup", "-webkit-box-ordinal-group", "2")] +#[case("msGridColumnSpan", "-ms-grid-column-span", "2")] +#[case("msGridRowSpan", "-ms-grid-row-span", "2")] +#[case("MozTabSize", "-moz-tab-size", "2")] +#[case("WebkitLineClamp", "-webkit-line-clamp", "2")] +#[case("flex", "flex", "2")] +#[case("flexGrow", "flex-grow", "2")] +#[case("flexShrink", "flex-shrink", "2")] +#[case("flexBasis", "flex-basis", "8px")] +#[case("p", "padding", "8px")] +#[case("m", "margin", "8px")] +#[case("WebkitAnimationDuration", "-webkit-animation-duration", "2ms")] +#[serial_test::serial] +fn numeric_css_08_emits_classified_declarations( + #[case] key: &str, + #[case] property: &str, + #[case] value: &str, +) -> Result<(), Box> { + let source = + format!("import {{ Box }} from '@devup-ui/react'; const box = ;"); + let output = crate::extract( + "numeric-css-08.tsx", + &source, + crate::ExtractOption::default(), + )?; + assert_eq!(output.styles.len(), 1); + let Some(ExtractStyleValue::Static(style)) = output.styles.iter().next() else { + panic!("expected one static declaration"); + }; + assert_eq!((style.property(), style.value()), (property, value)); + Ok(()) +} + +#[rstest::rstest] +#[case( + "import { css } from '@emotion/react'; export const box = css({ msFlex: 2, WebkitBoxFlex: 2, msGridColumnSpan: 2, MozTabSize: 2, WebkitLineClamp: 2 });", + "numbers.ts" +)] +#[case( + "import { style } from '@vanilla-extract/css'; export const box = style({ msFlex: 2, WebkitBoxFlex: 2, msGridColumnSpan: 2, MozTabSize: 2, WebkitLineClamp: 2 });", + "numbers.css.ts" +)] +#[serial_test::serial] +fn numeric_css_08_library_callers_emit_bare_vendor_numbers( + #[case] source: &str, + #[case] filename: &str, +) -> Result<(), Box> { + let option = crate::ExtractOption { + import_aliases: std::collections::HashMap::from([ + ( + "@emotion/react".to_string(), + crate::ImportAlias::NamedToNamed, + ), + ( + "@vanilla-extract/css".to_string(), + crate::ImportAlias::NamedToNamed, + ), + ]), + ..crate::ExtractOption::default() + }; + let output = crate::extract(filename, source, option)?; + let values: std::collections::BTreeSet<_> = output + .styles + .iter() + .map(|style| match style { + ExtractStyleValue::Static(style) => (style.property(), style.value()), + ExtractStyleValue::Typography(_) + | ExtractStyleValue::Dynamic(_) + | ExtractStyleValue::Css(_) + | ExtractStyleValue::Import(_) + | ExtractStyleValue::FontFace(_) + | ExtractStyleValue::Keyframes(_) => panic!("expected static vendor declarations"), + }) + .collect(); + assert_eq!( + values, + std::collections::BTreeSet::from([ + ("-ms-flex", "2"), + ("-webkit-box-flex", "2"), + ("-ms-grid-column-span", "2"), + ("-moz-tab-size", "2"), + ("-webkit-line-clamp", "2"), + ]) + ); + Ok(()) +} diff --git a/libs/extractor/src/extractor/extract_global_style_from_expression.rs b/libs/extractor/src/extractor/extract_global_style_from_expression.rs index 92f9f2d8f..b0963606c 100644 --- a/libs/extractor/src/extractor/extract_global_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_global_style_from_expression.rs @@ -68,6 +68,11 @@ fn collect_global_styles<'a>( styles: &mut Vec>, ) { let expression = unwrap_syntax_only_mut(expression); + let invalid = crate::dead_properties::expression_errors(expression); + if !invalid.is_empty() { + styles.extend(invalid); + return; + } if let Expression::ObjectExpression(obj) = expression { for p in &mut obj.properties { @@ -183,6 +188,23 @@ fn collect_global_styles<'a>( if let Expression::ArrayExpression(arr) = &o.value { for p in &arr.elements { if let ArrayExpressionElement::ObjectExpression(o) = p { + let invalid: Vec<_> = o + .properties + .iter() + .filter_map(|property| match property { + ObjectPropertyKind::ObjectProperty(property) => { + crate::dead_properties::authored_declaration_error( + &get_str_by_property_key(&property.key)?, + property.key.span().start, + ) + } + ObjectPropertyKind::SpreadProperty(_) => None, + }) + .collect(); + if !invalid.is_empty() { + styles.extend(invalid); + continue; + } styles.push(ExtractStyleProp::Static(ExtractStyleValue::FontFace(ExtractFontFace { properties: o .properties @@ -206,6 +228,11 @@ fn collect_global_styles<'a>( file: file.to_string(), }))); } else if let ArrayExpressionElement::TemplateLiteral(t) = p { + let invalid = crate::dead_properties::template_errors(t); + if !invalid.is_empty() { + styles.extend(invalid); + continue; + } let css_styles = css_to_style_literal(t, 0, &None) .into_iter() .filter_map(|ex| { diff --git a/libs/extractor/src/extractor/extract_keyframes_from_expression.rs b/libs/extractor/src/extractor/extract_keyframes_from_expression.rs index f5bb82fc3..4f4a11614 100644 --- a/libs/extractor/src/extractor/extract_keyframes_from_expression.rs +++ b/libs/extractor/src/extractor/extract_keyframes_from_expression.rs @@ -7,28 +7,42 @@ use crate::{ LiteralHandling, extract_style_from_expression, unreadable, }, }, - utils::{ - fixed_value, get_string_by_property_key, readable_code, runtime_value, - unwrap_syntax_only_mut, - }, + utils::{get_string_by_property_key, readable_code, runtime_value, unwrap_syntax_only_mut}, }; use oxc_ast::{ ast::{Expression, ObjectPropertyKind}, builder::AstBuilder, }; +use oxc_span::GetSpan; -pub fn extract_keyframes_from_expression<'a>( +pub(crate) fn extract_keyframes_with_location<'a>( ast_builder: &AstBuilder<'a>, expression: &mut Expression<'a>, -) -> KeyframesExtractResult { +) -> (KeyframesExtractResult, Option) { let mut keyframes = ExtractKeyframes::default(); + let invalid = crate::dead_properties::expression_errors(expression); + if let Some((offset, value)) = + crate::dead_properties::located_value(&invalid, expression.span().start, true) + { + return ( + KeyframesExtractResult { + keyframes, + runtime_value: Some(value), + }, + Some(offset), + ); + } let Expression::ObjectExpression(obj) = unwrap_syntax_only_mut(expression) else { - return KeyframesExtractResult { - keyframes, - runtime_value: runtime_value(&unreadable(expression).styles), - }; + return ( + KeyframesExtractResult { + keyframes, + runtime_value: runtime_value(&unreadable(expression).styles), + }, + Some(expression.span().start), + ); }; let mut runtime = None; + let mut runtime_offset = None; for p in &mut obj.properties { let o = match p { ObjectPropertyKind::ObjectProperty(o) => o, @@ -60,7 +74,13 @@ pub fn extract_keyframes_from_expression<'a>( &None, LiteralHandling::ExpandResponsiveThemeToken, ); - runtime = runtime.or_else(|| fixed_value(&styles)); + if runtime.is_none() + && let Some((offset, value)) = + crate::dead_properties::located_value(&styles, o.value.span().start, true) + { + runtime = Some(value); + runtime_offset = Some(offset); + } let mut styles = styles .into_iter() @@ -75,8 +95,11 @@ pub fn extract_keyframes_from_expression<'a>( styles, ); } - KeyframesExtractResult { - keyframes, - runtime_value: runtime, - } + ( + KeyframesExtractResult { + keyframes, + runtime_value: runtime, + }, + runtime_offset, + ) } diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index 597becedb..0f26950ca 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -10,9 +10,10 @@ use crate::{ ExtractResult, extract_style_from_member_expression::extract_style_from_member_expression, }, utils::{ - CSS_TEXT, SELECTOR_NAME, expression_to_code, get_number_by_literal_expression, - 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, + CSS_TEXT, RESPONSIVE_ARRAY, SELECTOR_NAME, expression_to_code, + get_number_by_literal_expression, 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, }, }; use css::{ @@ -233,6 +234,24 @@ pub fn extract_style_from_expression<'a>( let mut typo = false; let expression = unwrap_syntax_only_mut(expression); + if name.is_none() { + let mut styles = crate::dead_properties::expression_errors(expression); + styles.extend( + crate::dead_properties::authored_errors( + expression, + crate::dead_properties::ObjectKind::Styles, + ) + .into_iter() + .map(|(offset, code, requirement)| misplaced(offset, code, requirement)), + ); + if !styles.is_empty() { + return ExtractResult { + styles, + ..ExtractResult::default() + }; + } + } + if name.is_none() && selector.is_none() { let mut style_order = None; let mut style_vars = None; @@ -255,6 +274,8 @@ pub fn extract_style_from_expression<'a>( if let Some(name) = get_str_by_property_key(&prop.key) && !is_special_property(&name) { + // The upfront authored walk already checks every root key, + // including the literal spreads flattened above. for disassembled in disassemble_property(&name) { let disassembled: &str = &disassembled; if name == "styleOrder" { @@ -865,6 +886,16 @@ pub fn extract_style_from_expression<'a>( } } Expression::ArrayExpression(array) => { + if literal_handling == LiteralHandling::KeepSingleClass { + return ExtractResult { + styles: vec![misplaced( + array.span.start, + readable_code(expression), + RESPONSIVE_ARRAY, + )], + ..ExtractResult::default() + }; + } let mut props = vec![]; for (idx, element) in array.elements.iter_mut().enumerate() { @@ -1002,6 +1033,13 @@ pub fn extract_style_from_expression<'a>( if key_name == "params" { continue; } + if let Some(error) = crate::dead_properties::authored_declaration_error( + &key_name, + o.key.span().start, + ) { + props.push(error); + continue; + } for name in disassemble_property(&key_name) { let name: &str = &name; props.extend( diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index a5c7f5ab0..c8ad9d20d 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -227,6 +227,26 @@ pub fn extract_style_from_styled<'a>( // Check if tag is styled.div or styled(...) // Extract CSS from template literal + let mut invalid = Vec::new(); + unreadable_styles( + &crate::dead_properties::template_errors(&tag.quasi), + true, + &mut invalid, + ); + errors.extend(invalid.into_iter().map(|(offset, code, requirement)| { + ( + offset, + build_time_error("styled", &code, requirement.unwrap_or(STYLE_OBJECT)), + ) + })); + if !errors.is_empty() { + return ( + ExtractResult::default(), + Expression::new_string_literal(SPAN, "", None, ast_builder), + errors, + ); + } + let TemplateStyles { styles, statements, @@ -353,7 +373,7 @@ pub fn extract_style_from_styled<'a>( unreadable_styles(&styles, true, &mut unreadable); errors.extend(unreadable.into_iter().map(|(offset, code, requirement)| { ( - offset, + if offset == 0 { call.span.start } else { offset }, build_time_error("styled", &code, requirement.unwrap_or(STYLE_OBJECT)), ) })); diff --git a/libs/extractor/src/extractor/extract_style_from_stylex.rs b/libs/extractor/src/extractor/extract_style_from_stylex.rs index 69ff713a8..7e2ddeac2 100644 --- a/libs/extractor/src/extractor/extract_style_from_stylex.rs +++ b/libs/extractor/src/extractor/extract_style_from_stylex.rs @@ -60,6 +60,13 @@ pub fn extract_stylex_declarations( continue; }; let name = normalize_stylex_property(name.as_ref()); + if let Some(requirement) = crate::dead_properties::requirement(&name) { + errors.push(( + property.key.span().start, + build_time_error(api, &name, requirement), + )); + continue; + } match stylex_value(&name, &property.value) { Some(value) => declarations.push((name, optimize_value(&value).into_owned())), None => errors.push(( @@ -244,10 +251,18 @@ fn extract_stylex_namespace<'a>( errors.push(key_error("stylex.create", &inner_prop.key)); continue; }; + let property = normalize_stylex_property(inner_name.as_ref()); + if let Some(requirement) = crate::dead_properties::requirement(&property) { + errors.push(( + inner_prop.key.span().start, + build_time_error("stylex.create", &property, requirement), + )); + continue; + } push_decomposed( &mut styles, decompose_value_conditions( - &normalize_stylex_property(inner_name.as_ref()), + &property, &inner_prop.value, &parent_selectors, leaf, @@ -259,6 +274,13 @@ fn extract_stylex_namespace<'a>( } let css_property = normalize_stylex_property(prop_name.as_ref()); + if let Some(requirement) = crate::dead_properties::requirement(&css_property) { + errors.push(( + style_prop.key.span().start, + build_time_error("stylex.create", &css_property, requirement), + )); + continue; + } if SHORTHAND_PROPERTIES.contains(css_property.as_str()) { eprintln!( "[stylex] WARNING: Shorthand property '{css_property}' may cause unexpected specificity issues. Consider using longhand properties (e.g., 'marginTop', 'paddingLeft')." @@ -354,6 +376,13 @@ fn extract_stylex_dynamic_namespace<'a>( continue; }; let css_property = normalize_stylex_property(&prop_name); + if let Some(requirement) = crate::dead_properties::requirement(&css_property) { + errors.push(( + prop.key.span().start, + build_time_error("stylex.create", &css_property, requirement), + )); + continue; + } // Check if value references a parameter (dynamic) let is_dynamic = if prop.shorthand { diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index a5d5afa82..d70e22aca 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -13,6 +13,8 @@ mod imported_constants; mod module_loader; mod mutations; mod prop_modify_utils; +#[cfg(test)] +mod responsive_selector_tests; mod source_map; mod style_values; #[cfg(test)] @@ -29,6 +31,24 @@ mod util_type; mod utils; mod vanilla_extract; mod visit; + +mod dead_properties; +#[cfg(test)] +mod dead_properties_call_boundary_tests; +#[cfg(test)] +mod dead_properties_declaration_boundary_tests; +#[cfg(test)] +mod dead_properties_origin_tests; +#[cfg(test)] +mod dead_properties_responsive_origin_tests; +#[cfg(test)] +mod dead_properties_stylex_tests; +#[cfg(test)] +mod dead_properties_test_utils; +#[cfg(test)] +mod dead_properties_tests; +#[cfg(test)] +mod dead_properties_text_tests; use crate::extract_style::extract_style_value::ExtractStyleValue; use crate::visit::DevupVisitor; use css::file_map::{canonical, get_file_num_by_filename, is_global}; @@ -370,7 +390,7 @@ fn extract_source( // otherwise the untouched `transformed_code` is parsed directly (no copy). let processed_code: Option = if utils::is_vanilla_extract_file(filename) { // Use transformed code (with imports already pointing to @devup-ui/react) - match vanilla_extract::execute_stylesheet(&transformed_code, filename, &option, resolver) { + match vanilla_extract::execute_stylesheet(code, filename, &option, resolver) { Ok((collected, imports)) => { dependencies = imports.dependencies; // Keyframes names are generated, so extract the referenced ones @@ -411,6 +431,11 @@ fn extract_source( // A stylesheet another one imports must give its own values, and an // import cycle read too early fails as it does in ES modules, so both // are reported rather than hidden behind plain extraction + Err(error) if error.starts_with(dead_properties::ERROR_CHANNEL) => { + return Err(error + .trim_start_matches(dead_properties::ERROR_CHANNEL) + .into()); + } Err(error) if module_loader::loading_for_stylesheet() || error.contains(module_loader::IMPORT_CYCLE) => @@ -495,6 +520,10 @@ fn extract_source( // Run the code a value computes, or tell rules the module computes from a // class it composes if (!visitor.errors.is_empty() || visitor.composes_unknown) + && !visitor + .errors + .iter() + .any(|(_, error)| dead_properties::terminal_error(error)) && evaluated.is_none() && !utils::is_vanilla_extract_file(filename) && let Some((computed, value_edits, read)) = build_time_values::evaluate( @@ -540,7 +569,15 @@ fn extract_source( .collect(); visitor.errors.append(&mut visitor.unknown_parts); if !visitor.errors.is_empty() { - let mut message = located_errors(filename, source, &edits, visitor.errors); + let mut errors = visitor.errors; + let error_edits = if evaluated.is_some() { + let calls = dead_properties::evaluated_calls(source, filename, &option); + dead_properties::map_evaluated(&mut errors, &edits, &calls)?; + &[][..] + } else { + edits.as_slice() + }; + let mut message = located_errors(filename, source, error_edits, errors); message += &changed_notes(&message, filename, source, &edits, &inlined.changed); return Err(message.into()); } @@ -8067,7 +8104,7 @@ globalCss() ) .unwrap_err() .to_string(), - "test.tsx:2:1: `globalCss()` cannot use `1` at build time: its values must be literals, theme tokens or constants, or be computed from them" + "test.tsx:2:11: `globalCss()` cannot use `1` at build time: its values must be literals, theme tokens or constants, or be computed from them" ); } @@ -9337,7 +9374,7 @@ export const B = styled.div`${SEL} & { color: ${C}; }`;", [ "src/App.tsx:6:56: `css()` cannot use `x` at build time: its values must be literals, theme tokens or constants, or be computed from them", "src/App.tsx:7:27: `globalCss()` cannot use `y` at build time: its values must be literals, theme tokens or constants, or be computed from them", - "src/App.tsx:8:18: `keyframes()` cannot use `z` at build time: its values must be literals, theme tokens or constants, or be computed from them", + "src/App.tsx:8:34: `keyframes()` cannot use `z` at build time: its values must be literals, theme tokens or constants, or be computed from them", "src/App.tsx:9:18: `css()` cannot use `x` at build time: its values must be literals, theme tokens or constants, or be computed from them", ] ); @@ -21264,10 +21301,10 @@ const e = b, > i': { color: 'b ), text("23", "``", "\"external-class\""), text("43", "``", "\"external-class\""), - name("11", "`css()`", "_nope"), - text("11", "`css()`", "\"red\""), + name("43", "`css()`", "_nope"), + text("25", "`css()`", "\"red\""), name("37", "`styled()`", "nope"), - name("1", "`globalCss()`", "_nope"), + name("13", "`globalCss()`", "_nope"), ] ); } diff --git a/libs/extractor/src/module_loader.rs b/libs/extractor/src/module_loader.rs index f64390a8f..1e49f233c 100644 --- a/libs/extractor/src/module_loader.rs +++ b/libs/extractor/src/module_loader.rs @@ -200,7 +200,10 @@ impl<'r> ModuleLoader<'r> { } else { module.code }; - let script = crate::vanilla_extract::strip_typescript(&code, &module.path); + let instrumented = + crate::dead_properties::instrument(&code, &module.path, &self.option.package) + .map_err(|error| format!("{}{error}", crate::dead_properties::ERROR_CHANNEL))?; + let script = crate::vanilla_extract::strip_typescript(&instrumented.code, &module.path); let module_script = module_script(&script, &module.path, self, false)?; // Live bindings: a read before the binding is initialized fails as it // does in an ES module @@ -282,7 +285,7 @@ pub(crate) fn module_script( } continue; } - let module = if source == package { + let module = if source == package || source == "@vanilla-extract/css" { PACKAGE_BINDING.to_string() } else { loader.load(source, filename, entry)? diff --git a/libs/extractor/src/responsive_selector_tests.rs b/libs/extractor/src/responsive_selector_tests.rs new file mode 100644 index 000000000..17241b7b9 --- /dev/null +++ b/libs/extractor/src/responsive_selector_tests.rs @@ -0,0 +1,291 @@ +use crate::{ExtractOption, extract, extract_style::extract_style_value::ExtractStyleValue}; +use css::{class_map::reset_class_map, file_map::reset_file_map, style_selector::StyleSelector}; +use rstest::rstest; +use serial_test::serial; +use std::collections::BTreeSet; + +#[rstest] +#[case("[[1, 2], 3]", "[1, 2]")] +#[case("[0, [1, 2]]", "[1, 2]")] +#[case("[[[1, 2]], 3]", "[[1, 2]]")] +#[case("[[], 3]", "[]")] +#[case("[([1, 2]), 3]", "[1, 2]")] +#[case("[([1, 2] as const), 3]", "[1, 2]")] +#[case("[([1, 2] satisfies number[]), 3]", "[1, 2]")] +#[case("[([1, 2]!), 3]", "[1, 2]")] +#[case("[on ? [1, 2] : 3, 4]", "[1, 2]")] +#[case("[on ? 3 : ([1, 2] as const), 4]", "[1, 2]")] +#[case("[on && [1, 2], 4]", "[1, 2]")] +#[case("[true ? [1, 2] : 3, 4]", "[1, 2]")] +#[serial] +fn nested_responsive_arrays_are_errors( + #[case] value: &str, + #[case] nested: &str, + #[values("jsx", "css", "styled")] api: &str, +) { + // Given + reset_class_map(); + reset_file_map(); + let statement = match api { + "jsx" => format!("const e = ;"), + "css" => format!("const c = css({{ p: {value} }});"), + "styled" => format!("const S = styled.div({{ p: {value} }});"), + _ => unreachable!(), + }; + let source = format!("import {{ Box, css, styled }} from '@devup-ui/react';\n{statement}"); + let column = crate::dead_properties_test_utils::position(&statement, nested) + 1; + let owner = match api { + "jsx" => "``", + "css" => "`css()`", + "styled" => "`styled()`", + _ => unreachable!(), + }; + // When + let error = crate::dead_properties_test_utils::failure(extract( + "edges.tsx", + &source, + ExtractOption::default(), + )); + // Then + assert_eq!( + error, + format!( + "edges.tsx:2:{column}: {owner} cannot use `{nested}` at build time: responsive arrays must be flat; each entry supplies one breakpoint value or selector style object, not another array" + ) + ); +} + +#[rstest] +#[case("const e = ;")] +#[case("const c = css({ p: [1, null, 2, , 3] });")] +#[case("const S = styled.div({ p: [1, null, 2, , 3] });")] +#[case("const e = ;")] +#[serial] +fn flat_responsive_arrays_preserve_property_and_breakpoint( + #[case] statement: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = format!("import {{ Box, css, styled }} from '@devup-ui/react';\n{statement}"); + // When + let output = extract("edges.tsx", &source, ExtractOption::default())?; + // Then + let styles: BTreeSet<_> = output + .styles + .iter() + .map(|style| match style { + ExtractStyleValue::Static(style) => { + assert_eq!(style.selector, None); + (style.property.as_str(), style.level, style.value.as_str()) + } + other => panic!("expected static responsive style, got {other:?}"), + }) + .collect(); + assert_eq!( + styles, + BTreeSet::from([ + ("padding", 0, "4px"), + ("padding", 2, "8px"), + ("padding", 4, "12px") + ]) + ); + Ok(()) +} + +#[rstest] +#[case("idx", vec![(0, "4px"), (1, "8px"), (2, "12px"), (0, "16px"), (1, "20px"), (2, "24px")])] +#[case("1", vec![(0, "16px"), (1, "20px"), (2, "24px")])] +#[serial] +fn indexed_arrays_select_one_responsive_array( + #[case] index: &str, + #[case] expected: Vec<(u8, &str)>, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{ Box }} from '@devup-ui/react';\nconst e = ;" + ); + // When + let output = extract("edges.tsx", &source, ExtractOption::default())?; + // Then + let styles: BTreeSet<_> = output + .styles + .iter() + .map(|style| match style { + ExtractStyleValue::Static(style) => { + assert_eq!(style.property, "padding"); + assert_eq!(style.selector, None); + (style.level, style.value.as_str()) + } + other => panic!("expected static indexed style, got {other:?}"), + }) + .collect(); + assert_eq!(styles, expected.into_iter().collect()); + Ok(()) +} + +#[rstest] +#[case("")] +#[case("")] +#[case("")] +#[case("")] +#[serial] +fn hover_responsive_styles_are_scoped( + #[case] element: &str, +) -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = format!("import {{ Box }} from '@devup-ui/react';\nconst e = {element};"); + // When + let output = extract("edges.tsx", &source, ExtractOption::default())?; + // Then + let styles: BTreeSet<_> = output + .styles + .iter() + .map(|style| match style { + ExtractStyleValue::Static(style) => { + assert_eq!( + style.selector, + Some(StyleSelector::Selector("&:hover".to_string())) + ); + (style.property.as_str(), style.level, style.value.as_str()) + } + other => panic!("expected scoped static style, got {other:?}"), + }) + .collect(); + assert_eq!( + styles, + BTreeSet::from([("padding", 0, "4px"), ("padding", 2, "8px")]) + ); + Ok(()) +} + +#[test] +#[serial] +fn body_selector_is_a_located_build_error() { + // Given + reset_class_map(); + reset_file_map(); + let source = "import { Box } from '@devup-ui/react';\nconst e = ;"; + // When + let error = crate::dead_properties_test_utils::failure(extract( + "edges.tsx", + source, + ExtractOption::default(), + )); + // Then + assert_eq!( + error, + format!( + "edges.tsx:2:29: `` cannot use `body` at build time: {}", + crate::utils::SELECTOR_NAME + ) + ); +} + +#[test] +#[serial] +fn raw_parent_selector_preserves_scope() -> Result<(), Box> { + // Given + reset_class_map(); + reset_file_map(); + let source = "import { Box } from '@devup-ui/react';\nconst e = p': { color: 'pink' } }} />;"; + // When + let output = extract("edges.tsx", source, ExtractOption::default())?; + // Then + assert_eq!(output.styles.len(), 1); + let style = output + .styles + .iter() + .next() + .ok_or("expected one static style")?; + match style { + ExtractStyleValue::Static(style) => { + assert_eq!(style.property, "color"); + assert_eq!(style.value, "pink"); + assert_eq!(style.level, 0); + assert_eq!( + style.selector, + Some(StyleSelector::Selector("& > p".to_string())) + ); + } + other => panic!("expected scoped child style, got {other:?}"), + } + Ok(()) +} + +#[rstest] +#[case( + "", + "[\"red\", \"blue\"]" +)] +#[case("", "[{ p: 1 }]")] +#[case("", "[1, 2]")] +#[case("", "[1, 2]")] +#[serial] +fn nested_responsive_selector_values_are_errors(#[case] element: &str, #[case] nested: &str) { + // Given + reset_class_map(); + reset_file_map(); + let statement = format!("const e = {element};"); + let source = format!("import {{ Box }} from '@devup-ui/react';\n{statement}"); + let column = crate::dead_properties_test_utils::position(&statement, nested) + 1; + // When + let error = crate::dead_properties_test_utils::failure(extract( + "edges.tsx", + &source, + ExtractOption::default(), + )); + // Then + assert_eq!( + error, + format!( + "edges.tsx:2:{column}: `` cannot use `{nested}` at build time: {}", + crate::utils::RESPONSIVE_ARRAY + ) + ); +} + +#[test] +fn nested_array_diagnostic_retains_its_exact_span() { + use crate::extractor::extract_style_from_expression::{ + LiteralHandling, extract_style_from_expression, + }; + use oxc_allocator::Allocator; + use oxc_ast::{ast::Statement, builder::AstBuilder}; + use oxc_parser::Parser; + use oxc_span::SourceType; + + // Given + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let source = "[0, ([1, 2] as const)];"; + let mut parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let Statement::ExpressionStatement(statement) = &mut parsed.program.body[0] else { + panic!("expected array expression statement"); + }; + // When + let result = extract_style_from_expression( + &builder, + Some("padding"), + &mut statement.expression, + 0, + &None, + LiteralHandling::ExpandResponsiveThemeToken, + ); + // Then + let mut diagnostics = vec![]; + crate::utils::unreadable_styles(&result.styles, false, &mut diagnostics); + assert_eq!( + diagnostics, + vec![( + 5, + "[1, 2]".to_string(), + Some(crate::utils::RESPONSIVE_ARRAY) + )] + ); +} diff --git a/libs/extractor/src/snapshots/extractor__tests__maintain_value.snap b/libs/extractor/src/snapshots/extractor__tests__maintain_value.snap index a936a6352..605b42067 100644 --- a/libs/extractor/src/snapshots/extractor__tests__maintain_value.snap +++ b/libs/extractor/src/snapshots/extractor__tests__maintain_value.snap @@ -1,13 +1,13 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr#\"import {Flex} 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: "ToBTreeSet::from(extract(\"test.jsx\",\nr\"import {Flex} 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())" --- ToBTreeSet { styles: { Static( ExtractStaticStyle { property: "-moz-tab-size", - value: "16px", + value: "4", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/snapshots/extractor__tests__with_prefix.snap b/libs/extractor/src/snapshots/extractor__tests__with_prefix.snap index 08b5fc24d..ebae17c88 100644 --- a/libs/extractor/src/snapshots/extractor__tests__with_prefix.snap +++ b/libs/extractor/src/snapshots/extractor__tests__with_prefix.snap @@ -1,13 +1,13 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr#\"import {Flex} 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: "ToBTreeSet::from(extract(\"test.jsx\",\nr\"import {Flex} 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())" --- ToBTreeSet { styles: { Static( ExtractStaticStyle { property: "-moz-tab-size", - value: "16px", + value: "4", level: 0, selector: None, style_order: None, diff --git a/libs/extractor/src/utils.rs b/libs/extractor/src/utils.rs index 8fe22b482..15a011a7d 100644 --- a/libs/extractor/src/utils.rs +++ b/libs/extractor/src/utils.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; -use crate::extract_style::constant::MAINTAIN_VALUE_PROPERTIES; +use crate::extract_style::constant::is_maintain_value_property; use css::utils::to_kebab_case; use oxc_allocator::{Allocator, CloneIn, GetAllocator}; use oxc_ast::{ @@ -29,7 +29,7 @@ pub(super) fn is_unitless_key(key: &str) -> bool { key.starts_with("--") || key.starts_with("var(") || key.bytes().all(|byte| byte.is_ascii_digit()) - || MAINTAIN_VALUE_PROPERTIES.contains(to_kebab_case(key).as_ref()) + || is_maintain_value_property(to_kebab_case(key).as_ref()) } /// Whether a number on `key` stays bare in a library whose numbers mean pixels: @@ -965,6 +965,8 @@ pub(super) fn unused_error(api: &str, (code, requirement): &Unused) -> String { pub(super) const SELECTOR_NAME: &str = "a selector key names a pseudo-class or pseudo-element, as `_hover` or `hover`, or is a selector, as `&:hover`, `& > p` or `.parent &`"; +pub(super) const RESPONSIVE_ARRAY: &str = "responsive arrays must be flat; each entry supplies one breakpoint value or selector style object, not another array"; + pub(super) const CSS_TEXT: &str = "a selector takes styles, an object such as `{ color: 'red' }` or CSS text such as `color: red`"; pub(super) fn element_error(component: &str, code: &str, requirement: &str) -> String { @@ -1409,6 +1411,46 @@ mod tests { assert!(!keeps_bare_number("WebkitTextStrokeWidth")); } + #[rstest::rstest] + #[case("msFlex")] + #[case("msFlexOrder")] + #[case("msFlexPositive")] + #[case("msFlexNegative")] + #[case("WebkitBoxFlex")] + #[case("WebkitBoxOrdinalGroup")] + #[case("msGridColumnSpan")] + #[case("msGridRowSpan")] + #[case("MozTabSize")] + #[case("WebkitLineClamp")] + fn numeric_css_08_shared_callers_keep_vendor_numbers(#[case] key: &str) { + let kebab = to_kebab_case(key); + for spelling in [key, kebab.as_ref(), &format!("-{kebab}")] { + assert!(is_unitless_key(spelling), "{spelling}"); + assert!(keeps_bare_number(spelling), "{spelling}"); + } + } + + #[rstest::rstest] + #[case("flex", true, true)] + #[case("flexGrow", true, true)] + #[case("flexShrink", true, true)] + #[case("flexBasis", false, false)] + #[case("WebkitFlexBasis", false, false)] + #[case("WebkitTextStrokeWidth", false, false)] + #[case("unknown-flex", false, false)] + #[case("--ms-flex", true, true)] + #[case("p", false, true)] + #[case("mx", false, true)] + #[case("padding", false, false)] + fn numeric_css_08_shared_callers_preserve_existing_units( + #[case] key: &str, + #[case] unitless: bool, + #[case] bare: bool, + ) { + assert_eq!(is_unitless_key(key), unitless); + assert_eq!(keeps_bare_number(key), bare); + } + #[test] fn test_is_pure_and_suspends() { use oxc_ast_visit::Visit; diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 169996fd7..2c0f1e820 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -76,6 +76,8 @@ struct Collector { file_num: usize, placeholders: usize, identifiers: usize, + origin: Option<(String, String)>, + declaration_errors: Vec, } type StyleCollector = Rc>; @@ -241,8 +243,17 @@ pub fn execute_stylesheet( ) -> Result<(CollectedStyles, StylesheetImports), String> { let _evaluating = Evaluating::enter(filename); let mut loader = ModuleLoader::new(resolver, option); + let instrumented = crate::dead_properties::instrument(code, filename, &option.package) + .map_err(|error| format!("{}{error}", crate::dead_properties::ERROR_CHANNEL))?; + let instrumented = crate::import_alias_visit::transform_import_aliases_with_edits( + &instrumented.code, + filename, + &option.package, + &option.import_aliases, + ) + .0; let script = module_script( - &strip_typescript(code, filename), + &strip_typescript(&instrumented, filename), filename, &mut loader, true, @@ -284,6 +295,14 @@ pub fn execute_stylesheet( .eval(Source::from_bytes(run.as_bytes())) .map_err(|e| format!("JS execution error: {e}"))?; + if !collector.borrow().declaration_errors.is_empty() { + return Err(format!( + "{}{}", + crate::dead_properties::ERROR_CHANNEL, + collector.borrow().declaration_errors.join("\n") + )); + } + let mut collected = std::mem::take(&mut collector.borrow_mut().styles); name_entries( &mut collected, @@ -593,6 +612,43 @@ fn strip(code: &str, filename: &str) -> String { type Api = fn(&StyleCollector, &[JsValue], &mut Context) -> JsResult; +fn authored_call( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let location = to_text(args.get_or_undefined(0), context)?; + let api = to_text(args.get_or_undefined(1), context)?; + let previous = collector.borrow_mut().origin.replace((location, api)); + let result = args + .get_or_undefined(2) + .as_callable() + .map_or(Ok(JsValue::undefined()), |thunk| { + thunk.call(&JsValue::undefined(), &[], context) + }); + collector.borrow_mut().origin = previous; + result +} + +fn check_declarations( + collector: &StyleCollector, + json: &str, + kind: crate::dead_properties::ObjectKind, +) { + let errors = crate::dead_properties::evaluated_errors(json, kind); + let mut collector = collector.borrow_mut(); + if let Some((location, api)) = collector.origin.clone() { + collector + .declaration_errors + .extend(errors.into_iter().map(|(path, requirement)| { + format!( + "{location}: {}", + crate::utils::build_time_error(&api, &path, requirement) + ) + })); + } +} + fn api(collector: &StyleCollector, function: Api) -> NativeFunction { let collector = collector.clone(); // SAFETY: the closure captures only an `Rc>`, which holds no @@ -610,6 +666,7 @@ fn register_vanilla_extract_apis( collector: &StyleCollector, ) -> Result<(), String> { let apis = [ + ("__at", api(collector, authored_call), 3), ("style", api(collector, style), 1), ("globalStyle", api(collector, global_style), 2), ("styleVariants", api(collector, style_variants), 1), @@ -681,6 +738,11 @@ fn register_style( let mut rules = Vec::new(); compose(rule, &mut entry, &mut rules, context)?; entry.json = format!("{{{}}}", rules.join(",")); + check_declarations( + collector, + &entry.json, + crate::dead_properties::ObjectKind::Styles, + ); let mut collector = collector.borrow_mut(); let id = collector.placeholder(); collector.styles.styles.insert(id.clone(), entry); @@ -728,6 +790,7 @@ fn global_style( ) -> JsResult { let selector = to_text(args.get_or_undefined(0), context)?; let json = style_to_json(args.get_or_undefined(1), context); + check_declarations(collector, &json, crate::dead_properties::ObjectKind::Styles); collector .borrow_mut() .styles @@ -765,6 +828,11 @@ fn style_variants( fn keyframes(collector: &StyleCollector, args: &[JsValue], context: &mut Context) -> JsValue { let json = style_to_json(args.get_or_undefined(0), context); + check_declarations( + collector, + &json, + crate::dead_properties::ObjectKind::Records, + ); let mut collector = collector.borrow_mut(); let id = collector.placeholder(); collector.styles.keyframes.insert( @@ -783,6 +851,9 @@ fn font_face(collector: &StyleCollector, args: &[JsValue], context: &mut Context .borrow_mut() .identifier(js_str(args.get_or_undefined(1)), "font"); let faces = font_face_rules(&family, args.get_or_undefined(0), context); + for face in &faces { + check_declarations(collector, face, crate::dead_properties::ObjectKind::Styles); + } collector.borrow_mut().styles.font_faces.extend(faces); js_string!(family).into() } @@ -794,6 +865,9 @@ fn global_font_face( ) -> JsResult { let family = to_text(args.get_or_undefined(0), context)?; let faces = font_face_rules(&family, args.get_or_undefined(1), context); + for face in &faces { + check_declarations(collector, face, crate::dead_properties::ObjectKind::Styles); + } collector.borrow_mut().styles.font_faces.extend(faces); Ok(JsValue::undefined()) } diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index ad85355da..bdbdb6ad9 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -10,7 +10,7 @@ use crate::extract_style::extract_css::ExtractCss; use crate::extract_style::extract_keyframes::ExtractKeyframes; use crate::extract_style::style_property::StyleProperty; use crate::extractor::KeyframesExtractResult; -use crate::extractor::extract_keyframes_from_expression::extract_keyframes_from_expression; +use crate::extractor::extract_keyframes_from_expression::extract_keyframes_with_location; use crate::extractor::extract_style_from_stylex::{ extract_stylex_declarations, extract_stylex_namespace_styles, }; @@ -1569,13 +1569,20 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { && let [arg] = call.arguments.as_mut_slice() && let Some(arg @ Expression::ObjectExpression(_)) = arg.as_expression_mut() { - let KeyframesExtractResult { - keyframes, - runtime_value, - } = extract_keyframes_from_expression(&self.ast, arg); + let ( + KeyframesExtractResult { + keyframes, + runtime_value, + }, + runtime_offset, + ) = extract_keyframes_with_location(&self.ast, arg); if let Some(value) = runtime_value { - self.errors - .push((call.span.start, unused_error("stylex.keyframes", &value))); + self.errors.push(( + runtime_offset + .filter(|offset| *offset != 0) + .unwrap_or(call.span.start), + unused_error("stylex.keyframes", &value), + )); } let name = style_property_into_string(keyframes.extract(self.split_filename.as_deref())); @@ -1724,7 +1731,9 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { &None, LiteralHandling::ExpandResponsiveThemeToken, ); - if let Some(value) = runtime_value(&styles) { + if let Some((offset, value)) = + crate::dead_properties::located_value(&styles, offset, false) + { self.errors.push((offset, unused_error(r.api(), &value))); } @@ -1750,10 +1759,10 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } } } else if matches!(r, UtilType::Keyframes) { - let KeyframesExtractResult { + let (KeyframesExtractResult { keyframes, runtime_value, - } = extract_keyframes_from_expression( + }, 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 @@ -1762,8 +1771,12 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { }, ); if let Some(value) = runtime_value { - self.errors - .push((offset, unused_error("keyframes", &value))); + self.errors.push(( + runtime_offset + .filter(|offset| *offset != 0) + .unwrap_or(offset), + unused_error("keyframes", &value), + )); } let name = style_property_into_string( @@ -1790,7 +1803,9 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { }, &self.filename, ); - if let Some(value) = fixed_value(&styles) { + if let Some((offset, value)) = + crate::dead_properties::located_value(&styles, offset, true) + { self.errors .push((offset, unused_error("globalCss", &value))); } @@ -1846,7 +1861,9 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { &mut folded, &self.filename, ); - if let Some(value) = fixed_value(&styles) { + if let Some((offset, value)) = + crate::dead_properties::located_value(&styles, offset, true) + { self.errors .push((offset, unused_error("globalCss", &value))); } @@ -1907,6 +1924,23 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { self.style_values.read_in_text(&self.ast, &mut tag.quasi); let r = css_type.as_ref(); let api = r.api(); + let mut invalid = Vec::new(); + unreadable_styles( + &crate::dead_properties::template_errors(&tag.quasi), + true, + &mut invalid, + ); + if !invalid.is_empty() { + self.errors + .extend(invalid.into_iter().map(|(offset, code, requirement)| { + ( + offset, + build_time_error(api, &code, requirement.unwrap_or(STYLE_OBJECT)), + ) + })); + *it = Expression::new_string_literal(SPAN, "", None, &self.ast); + return; + } let mut build_css_str = || { template_css_text(&tag.quasi, api).unwrap_or_else(|error| { self.errors.push(error); @@ -2479,6 +2513,13 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { && !is_special_property(&name.name) { let property_name = name.name.as_str(); + if let Some(error) = crate::dead_properties::authored_declaration_error( + property_name, + name.span.start, + ) { + props_styles.push(error); + continue; + } for disassembled in disassemble_property(property_name) { // Probe with `contains`, run the body borrowing `&disassembled` // (it has no early exits), then MOVE the value into the set at @@ -2748,7 +2789,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { for (offset, code, requirement) in unreadable { self.errors.push(( - offset, + if offset == 0 { elem.span.start } else { offset }, element_error( &elem.opening_element.name.to_string(), &code,