diff --git a/.changepacks/changepack_log_css_output_edges.json b/.changepacks/changepack_log_css_output_edges.json
new file mode 100644
index 00000000..5d5645a5
--- /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/.changepacks/changepack_log_selectors.json b/.changepacks/changepack_log_selectors.json
new file mode 100644
index 00000000..4b6510b6
--- /dev/null
+++ b/.changepacks/changepack_log_selectors.json
@@ -0,0 +1,7 @@
+{
+ "changes": {
+ "bindings/devup-ui-wasm/package.json": "Patch"
+ },
+ "note": "Selectors: a nested rule without `&` in a styled or css template is scoped to the component instead of becoming a global rule (`.child {}` and `> p {}` select descendants, `:hover {}` and `::before {}` the component), and a rule nested in a selector list applies under each of its selectors; `content` keeps the quotes of its strings (`content: 'x'`, `content: \"\"`); `params` go on the pseudo-class that takes them, so `_groupNthChild` gives `:is(...):nth-child(2n) .a` also when nested; a bare `selectors` key is a pseudo-class name, an attribute of the element or a descendant selector (`'div p'`, `'> p'`), under a parent too, and keeps the case of class names; `::part()`, `::slotted()`, `::highlight()`, `::picker()` and the view-transition pseudo-elements get two colons. New build errors: a `_name` prop or selector key that names nothing (`_notASelector`, `selectors={{ div: ... }}`), and a string under a selector that is not CSS text (`_hover=\"external-class\"`)",
+ "date": "2026-09-30T00: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 a81f55be..b5c25c83 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 a79e01c5..a17d1504 100644
--- a/apps/landing/src/app/(detail)/docs/api/button/page.mdx
+++ b/apps/landing/src/app/(detail)/docs/api/button/page.mdx
@@ -67,10 +67,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 0ac65ae8..ca80b77b 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 01c06c30..4f9a5b44 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/selector/page.mdx b/apps/landing/src/app/(detail)/docs/api/selector/page.mdx
index ce48b544..cf21bfc8 100644
--- a/apps/landing/src/app/(detail)/docs/api/selector/page.mdx
+++ b/apps/landing/src/app/(detail)/docs/api/selector/page.mdx
@@ -183,6 +183,49 @@ The attributes defined in Pseudo-Class correspond to those defined in Selector,
+Any other pseudo-class or pseudo-element works the same way, as `_nthChild` for `:nth-child` or `_placeholder` for `::placeholder`. A name that selects nothing, such as `_notASelector`, is a build error rather than CSS that matches nothing.
+
+Pseudo-classes that take arguments get them from `params`, also in their group form:
+
+```tsx
+const example = (
+
+)
+// :is([role=group],[data-group]):nth-child(odd) .a{color:red}
+// .b:nth-child(2n){background:gray}
+```
+
+A selector takes styles: an object, or CSS text such as `_hover="color: red"`. Any other string, such as a class name, is a build error.
+
+## `selectors`
+
+`selectors` takes any selector. A key with `&` says where the element goes; a key without `&` is relative to the element:
+
+- a pseudo-class name, as `hover` or `focusVisible`, is that pseudo-class of the element; a name that selects nothing, such as `div`, is a build error
+- a key starting with `:`, `::` or `[` applies to the element itself, as `':hover'` or `'[aria-busy]'`
+- any other selector selects descendants, as `'.child'`, `'> p'` or `'div p'`
+
+```tsx
+const example = (
+ p': { m: 0 },
+ }}
+ />
+)
+// .a:hover{color:red}
+// .parent .b{color:red}
+// .c[aria-busy]{opacity:.5}
+// .d > p{margin:0}
+```
+
+Nested rules in `styled` and `css` templates follow styled-components and Emotion: `.child { }` and `> p { }` select descendants, `:hover { }` and `::before { }` apply to the component, and a rule nested in a selector list applies under each of its selectors.
+
## Media Shorthands
These props wrap their styles in a fixed media query. They accept responsive arrays and nest in either direction with selectors and other at-rules.
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 fb7b8b52..63a83381 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 00000000..4d667635
--- /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 4560410f..a272e77b 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()));
@@ -1551,7 +1554,7 @@ mod tests {
);
// Test getters
- assert!(!output.code().is_empty());
+ assert_ne!(output.code(), "");
assert_eq!(output.css_file(), Some("devup-ui-0.css".to_string()));
assert_eq!(output.map(), Some("//# sourceMappingURL=test".to_string()));
assert!(output.css().is_some());
@@ -1951,7 +1954,7 @@ mod tests {
assert!(result.is_ok());
let output = result.unwrap();
- assert!(!output.code().is_empty());
+ assert_ne!(output.code(), "");
assert!(output.map().is_some());
}
@@ -1975,7 +1978,7 @@ mod tests {
assert!(result.is_ok());
let output = result.unwrap();
- assert!(!output.code().is_empty());
+ assert_ne!(output.code(), "");
assert!(output.map().is_none());
}
@@ -2000,7 +2003,7 @@ mod tests {
assert!(result.is_err());
if let Err(error) = result {
- assert!(!error.is_empty());
+ assert_ne!(error, "");
}
}
diff --git a/libs/css/src/constant.rs b/libs/css/src/constant.rs
index 370ad77c..521cff71 100644
--- a/libs/css/src/constant.rs
+++ b/libs/css/src/constant.rs
@@ -99,7 +99,6 @@ pub(super) static GLOBAL_ENUM_STYLE_PROPERTY: phf::Map<&str, phf::Map<&str, phf:
pub(super) static OPTIMIZE_MULTI_CSS_VALUE_PROPERTY: phf::Set<&str> = phf_set! {
"font-family",
"src",
- "content",
"animation-name",
};
@@ -115,15 +114,130 @@ pub(super) static DOUBLE_SEPARATOR: phf::Set<&str> = phf_set! {
"first-letter",
"first-line",
"grammar-error",
+ "highlight",
"marker",
+ "part",
+ "picker",
"picker-icon",
"placeholder",
"scroll-marker",
"scroll-marker-group",
"selection",
+ "slotted",
"spelling-error",
"target-text",
- "view-transition"
+ "view-transition",
+ "view-transition-group",
+ "view-transition-image-pair",
+ "view-transition-new",
+ "view-transition-old"
+};
+
+/// The pseudo-classes and pseudo-elements that take arguments, as `params`
+pub(super) static FUNCTIONAL_PSEUDOS: phf::Set<&str> = phf_set! {
+ "active-view-transition-type",
+ "cue",
+ "cue-region",
+ "dir",
+ "has",
+ "highlight",
+ "host",
+ "host-context",
+ "is",
+ "lang",
+ "not",
+ "nth-child",
+ "nth-last-child",
+ "nth-last-of-type",
+ "nth-of-type",
+ "part",
+ "picker",
+ "slotted",
+ "state",
+ "view-transition-group",
+ "view-transition-image-pair",
+ "view-transition-new",
+ "view-transition-old",
+ "where"
+};
+
+/// The pseudo-classes a `_name` key or a bare `selectors` key can name
+pub(super) static PSEUDO_CLASSES: phf::Set<&str> = phf_set! {
+ "active",
+ "active-view-transition",
+ "active-view-transition-type",
+ "any-link",
+ "autofill",
+ "blank",
+ "buffering",
+ "checked",
+ "current",
+ "default",
+ "defined",
+ "dir",
+ "disabled",
+ "empty",
+ "enabled",
+ "first",
+ "first-child",
+ "first-of-type",
+ "focus",
+ "focus-visible",
+ "focus-within",
+ "fullscreen",
+ "future",
+ "has",
+ "has-slotted",
+ "host",
+ "host-context",
+ "hover",
+ "in-range",
+ "indeterminate",
+ "invalid",
+ "is",
+ "lang",
+ "last-child",
+ "last-of-type",
+ "left",
+ "link",
+ "local-link",
+ "modal",
+ "muted",
+ "not",
+ "nth-child",
+ "nth-last-child",
+ "nth-last-of-type",
+ "nth-of-type",
+ "only-child",
+ "only-of-type",
+ "open",
+ "optional",
+ "out-of-range",
+ "past",
+ "paused",
+ "picture-in-picture",
+ "placeholder-shown",
+ "playing",
+ "popover-open",
+ "read-only",
+ "read-write",
+ "required",
+ "right",
+ "root",
+ "scope",
+ "seeking",
+ "stalled",
+ "state",
+ "target",
+ "target-current",
+ "target-within",
+ "user-invalid",
+ "user-valid",
+ "valid",
+ "visited",
+ "volume-locked",
+ "where",
+ "xr-overlay"
};
pub(super) static ZERO_PERCENT_FUNCTION: phf::Set<&str> = phf_set! {
diff --git a/libs/css/src/is_special_property.rs b/libs/css/src/is_special_property.rs
index ea75b98b..17bb6972 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 576ba53b..d9810a36 100644
--- a/libs/css/src/lib.rs
+++ b/libs/css/src/lib.rs
@@ -20,7 +20,7 @@ use std::collections::BTreeMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{LazyLock, RwLock};
-use crate::constant::{GLOBAL_ENUM_STYLE_PROPERTY, GLOBAL_STYLE_PROPERTY};
+use crate::constant::{FUNCTIONAL_PSEUDOS, GLOBAL_ENUM_STYLE_PROPERTY, GLOBAL_STYLE_PROPERTY};
use crate::debug::is_debug;
use crate::file_map::get_file_num_by_filename;
use crate::num_to_nm_base::num_to_nm_base;
@@ -187,9 +187,9 @@ impl ExactSizeIterator for DisassembleProperty {}
#[must_use]
pub fn disassemble_property(property: &str) -> DisassembleProperty {
- // Nested selector keys (`&:hover`, `:focus`, `.parent &`) are not properties;
- // keep them verbatim so class names and case survive.
- if property.starts_with(':') || property.contains('&') {
+ // Nested selector keys (`&:hover`, `:focus`, `.parent &`, `_hover`) are not
+ // properties; keep them verbatim so class names and case survive.
+ if property.starts_with([':', '_']) || property.contains('&') {
return DisassembleProperty::Fallback(Some(property.to_string()));
}
if let Some(properties) = HAS_CUSTOM_SHORTHANDS
@@ -208,10 +208,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())
@@ -220,7 +219,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
@@ -228,8 +230,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() {
@@ -287,12 +289,49 @@ pub fn get_custom_shorthand_names() -> Vec {
)
}
+/// `selector` with `params` on the last pseudo-class or pseudo-element that
+/// takes them and has none yet, wherever nesting put it: a group form reads
+/// `:is(...):nth-child(2n) &`, and a parent's selector may follow it
+fn with_params(selector: &str, params: &str) -> String {
+ let at = params_position(selector).unwrap_or(selector.len());
+ format!("{}({params}){}", &selector[..at], &selector[at..])
+}
+
+fn params_position(selector: &str) -> Option {
+ let bytes = selector.as_bytes();
+ let mut depth = 0usize;
+ let mut found = None;
+ let mut index = 0;
+ while let Some(&byte) = bytes.get(index) {
+ match byte {
+ b'(' | b'[' => depth += 1,
+ b')' | b']' => depth = depth.saturating_sub(1),
+ b':' if depth == 0 => {
+ let start = index + 1 + usize::from(bytes.get(index + 1) == Some(&b':'));
+ let end = selector[start..]
+ .find(|c: char| !(c.is_ascii_alphanumeric() || c == '-'))
+ .map_or(selector.len(), |length| start + length);
+ if FUNCTIONAL_PSEUDOS.contains(&selector[start..end])
+ && bytes.get(end) != Some(&b'(')
+ {
+ found = Some(end);
+ }
+ index = end;
+ continue;
+ }
+ _ => {}
+ }
+ index += 1;
+ }
+ found
+}
+
#[must_use]
pub fn add_selector_params(selector: StyleSelector, params: &str) -> StyleSelector {
match selector {
- StyleSelector::Selector(value) => StyleSelector::Selector(format!("{value}({params})")),
+ StyleSelector::Selector(value) => StyleSelector::Selector(with_params(&value, params)),
StyleSelector::Global(value, file) => {
- StyleSelector::Global(format!("{value}({params})"), file)
+ StyleSelector::Global(with_params(&value, params), file)
}
StyleSelector::At {
kind,
@@ -303,7 +342,7 @@ pub fn add_selector_params(selector: StyleSelector, params: &str) -> StyleSelect
} => StyleSelector::At {
kind,
query,
- selector: selector.map(|s| format!("{s}({params})")),
+ selector: selector.map(|s| with_params(&s, params)),
outer,
file,
},
@@ -1199,6 +1238,25 @@ mod tests {
add_selector_params(StyleSelector::Selector("hover:is".to_string()), "test"),
StyleSelector::Selector("hover:is(test)".to_string())
);
+ for (selector, expected) in [
+ (
+ ":is([role=group],[data-group]):nth-child &",
+ ":is([role=group],[data-group]):nth-child(2n) &",
+ ),
+ (
+ ":is([role=group],[data-group]):nth-child &:hover",
+ ":is([role=group],[data-group]):nth-child(2n) &:hover",
+ ),
+ ("&:not(.x):nth-child", "&:not(.x):nth-child(2n)"),
+ ("&[title=\":not\"]::part", "&[title=\":not\"]::part(2n)"),
+ ("&:hover", "&:hover(2n)"),
+ ] {
+ assert_eq!(
+ add_selector_params(StyleSelector::Selector(selector.to_string()), "2n"),
+ StyleSelector::Selector(expected.to_string()),
+ "{selector}"
+ );
+ }
assert_eq!(
add_selector_params(
StyleSelector::Global("&:is".to_string(), "file.ts".to_string()),
@@ -1342,6 +1400,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/css/src/optimize_multi_css_value.rs b/libs/css/src/optimize_multi_css_value.rs
index c9d8959d..14081e2d 100644
--- a/libs/css/src/optimize_multi_css_value.rs
+++ b/libs/css/src/optimize_multi_css_value.rs
@@ -173,7 +173,7 @@ mod tests {
#[rstest]
#[case("font-family", true)]
#[case("src", true)]
- #[case("content", true)]
+ #[case("content", false)]
#[case("animation-name", true)]
#[case("background", false)]
#[case("color", false)]
diff --git a/libs/css/src/selector_separator.rs b/libs/css/src/selector_separator.rs
index db164cbf..112e9804 100644
--- a/libs/css/src/selector_separator.rs
+++ b/libs/css/src/selector_separator.rs
@@ -62,12 +62,17 @@ mod tests {
"[aria-disabled='true']".into(),
SelectorSeparator::None
));
+
+ assert!(matches!(".child".into(), SelectorSeparator::Space));
+ assert!(matches!("#id".into(), SelectorSeparator::Space));
+ assert!(matches!("*".into(), SelectorSeparator::Space));
}
#[test]
fn test_display() {
assert_eq!(SelectorSeparator::Double.to_string(), "::");
assert_eq!(SelectorSeparator::Single.to_string(), ":");
+ assert_eq!(SelectorSeparator::Space.to_string(), " ");
assert_eq!(SelectorSeparator::None.to_string(), "");
}
}
diff --git a/libs/css/src/style_selector.rs b/libs/css/src/style_selector.rs
index a36bdc0f..fab4dca4 100644
--- a/libs/css/src/style_selector.rs
+++ b/libs/css/src/style_selector.rs
@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
use crate::{
at_rule::{MediaCombination, combine_media_queries, media_shorthand_query, normalize_query},
- constant::SELECTOR_ORDER,
+ constant::{DOUBLE_SEPARATOR, PSEUDO_CLASSES, SELECTOR_ORDER},
selector_separator::SelectorSeparator,
to_kebab_case,
utils::{collapse_whitespace, to_camel_case},
@@ -91,6 +91,57 @@ fn collapse_owned_selector(s: String) -> String {
}
}
+/// Whether `name`, a style key without its `_` in kebab-case, names what a
+/// key can select: a pseudo-class or pseudo-element, its `group-` form, a
+/// `theme-` or a media shorthand
+#[must_use]
+pub fn is_selector_name(name: &str) -> bool {
+ let pseudo = name.strip_prefix("group-").unwrap_or(name);
+ name.starts_with("theme-")
+ || media_shorthand_query(name).is_some()
+ || PSEUDO_CLASSES.contains(pseudo)
+ || DOUBLE_SEPARATOR.contains(pseudo)
+}
+
+/// The selectors of a selector list, leaving the commas inside `:is(a, b)`
+/// and `[title="a,b"]` alone
+#[must_use]
+pub fn split_selector_list(selector: &str) -> Vec<&str> {
+ let mut parts = vec![];
+ let mut depth = 0usize;
+ let mut start = 0;
+ for (index, c) in selector.char_indices() {
+ match c {
+ '(' | '[' => depth += 1,
+ ')' | ']' => depth = depth.saturating_sub(1),
+ ',' if depth == 0 => {
+ parts.push(selector[start..index].trim());
+ start = index + 1;
+ }
+ _ => {}
+ }
+ }
+ if !selector.is_empty() {
+ parts.push(selector[start..].trim());
+ }
+ parts
+}
+
+/// `template` with each selector of `parent` in place of its `&`, a list
+/// parent giving every combination as CSS nesting does
+fn substitute(template: &str, parent: &str) -> String {
+ let parents = split_selector_list(parent);
+ if parents.len() > 1 {
+ parents
+ .iter()
+ .map(|parent| template.replace('&', parent))
+ .collect::>()
+ .join(",")
+ } else {
+ template.replace('&', parent)
+ }
+}
+
#[must_use]
pub fn optimize_selector(selector: StyleSelector) -> StyleSelector {
match selector {
@@ -299,9 +350,9 @@ impl StyleSelector {
pub fn nest_selector(parent: Option<&Self>, template: &str) -> Self {
match parent {
None => Self::Selector(template.to_string()),
- Some(Self::Selector(selector)) => Self::Selector(template.replace('&', selector)),
+ Some(Self::Selector(selector)) => Self::Selector(substitute(template, selector)),
Some(Self::Global(selector, file)) => {
- Self::Global(template.replace('&', selector), file.clone())
+ Self::Global(substitute(template, selector), file.clone())
}
Some(Self::At {
kind,
@@ -315,7 +366,7 @@ impl StyleSelector {
selector: Some(
selector
.as_deref()
- .map_or_else(|| template.to_string(), |s| template.replace('&', s)),
+ .map_or_else(|| template.to_string(), |s| substitute(template, s)),
),
outer: outer.clone(),
file: file.clone(),
@@ -733,6 +784,34 @@ mod tests {
);
}
+ #[rstest]
+ #[case("", vec![])]
+ #[case("&:hover", vec!["&:hover"])]
+ #[case(" a > b , i ", vec!["a > b", "i"])]
+ #[case(":is(a, b), [title=\"a,b\"]", vec![":is(a, b)", "[title=\"a,b\"]"])]
+ fn test_split_selector_list(#[case] selector: &str, #[case] expected: Vec<&str>) {
+ assert_eq!(split_selector_list(selector), expected);
+ }
+
+ #[rstest]
+ #[case("hover", true)]
+ #[case("focus-visible", true)]
+ #[case("nth-child", true)]
+ #[case("before", true)]
+ #[case("part", true)]
+ #[case("group-hover", true)]
+ #[case("group-first-letter", true)]
+ #[case("theme-dark", true)]
+ #[case("motion-reduce", true)]
+ #[case("print", true)]
+ #[case("not-a-selector", false)]
+ #[case("div", false)]
+ #[case("group-print", false)]
+ #[case("webkit-autofill", false)]
+ fn test_is_selector_name(#[case] name: &str, #[case] expected: bool) {
+ assert_eq!(is_selector_name(name), expected);
+ }
+
fn at(kind: AtRuleKind, query: &str, selector: Option<&str>) -> StyleSelector {
StyleSelector::At {
kind,
@@ -765,6 +844,16 @@ mod tests {
"&:hover",
at(AtRuleKind::Media, "print", Some("&:focus:hover"))
)]
+ #[case(
+ Some(StyleSelector::Selector("& h1,&:is(a, b)".to_string())),
+ "& span,&:hover",
+ StyleSelector::Selector("& h1 span,& h1:hover,&:is(a, b) span,&:is(a, b):hover".to_string())
+ )]
+ #[case(
+ Some(StyleSelector::Global("a, b".to_string(), "a.tsx".to_string())),
+ "& i",
+ StyleSelector::Global("a i,b i".to_string(), "a.tsx".to_string())
+ )]
fn test_nest_selector(
#[case] parent: Option,
#[case] template: &str,
diff --git a/libs/css/src/theme_tokens.rs b/libs/css/src/theme_tokens.rs
index d8d8fb02..3b2346ae 100644
--- a/libs/css/src/theme_tokens.rs
+++ b/libs/css/src/theme_tokens.rs
@@ -119,7 +119,7 @@ mod tests {
set_typography_keys(vec!["body".to_string(), "title".to_string()]);
assert_eq!(get_typography_keys(), vec!["body", "title"]);
set_typography_keys(vec![]);
- assert!(get_typography_keys().is_empty());
+ assert_eq!(get_typography_keys(), Vec::::new());
}
#[test]
diff --git a/libs/extractor/src/css_utils.rs b/libs/extractor/src/css_utils.rs
index f0d67200..6bf1f9b5 100644
--- a/libs/extractor/src/css_utils.rs
+++ b/libs/extractor/src/css_utils.rs
@@ -10,7 +10,7 @@ use css::{
at_rule::split_at_rule_key,
optimize_multi_css_value::{check_multi_css_optimize, optimize_multi_css_value},
rm_css_comment::rm_css_comment,
- style_selector::StyleSelector,
+ style_selector::{StyleSelector, split_selector_list},
};
use oxc_allocator::Allocator;
use oxc_span::{GetSpan, SPAN};
@@ -579,29 +579,36 @@ fn collect_css_block(
}
/// The selector a nested block applies to: at-rule preludes wrap `parent`,
-/// `&`-selectors substitute it, and a bare selector under a selector nests as
-/// a descendant. `None` drops the block (an unknown at-rule, or conditions
-/// that can never match together).
+/// `&`-selectors substitute it, and a bare selector is made relative to it
+/// (the component's own class at the top). `None` drops the block (an unknown
+/// at-rule, or conditions that can never match together).
fn nest_prelude(parent: Option<&StyleSelector>, prelude: &str) -> Option {
if prelude.starts_with('@') {
let (kind, query) = split_at_rule_key(prelude)?;
return StyleSelector::nest_at_rule(parent, kind, query);
}
- let parent_selector = match parent {
- Some(StyleSelector::Selector(selector) | StyleSelector::Global(selector, _)) => {
- Some(selector)
- }
- Some(StyleSelector::At { selector, .. }) => selector.as_ref(),
- None => None,
- };
- let template = if prelude.contains('&') || parent_selector.is_none() {
- Cow::Borrowed(prelude)
- } else {
- Cow::Owned(format!("& {prelude}"))
- };
+ let template = split_selector_list(prelude)
+ .into_iter()
+ .map(relative_selector)
+ .collect::>()
+ .join(",");
Some(StyleSelector::nest_selector(parent, &template))
}
+/// A selector of a nested rule made relative to the rule it is in, as
+/// styled-components and Emotion read it: one with `&` says where that rule
+/// goes, a pseudo-class or pseudo-element applies to that rule's element, and
+/// any other selector to its descendants (`> p` to its children)
+fn relative_selector(part: &str) -> Cow<'_, str> {
+ if part.contains('&') {
+ Cow::Borrowed(part)
+ } else if part.starts_with(':') {
+ Cow::Owned(format!("&{part}"))
+ } else {
+ Cow::Owned(format!("& {part}"))
+ }
+}
+
/// Optimize a declaration's value only when its property warrants multi-value
/// optimization, borrowing otherwise. Shared by `css_to_style_block` and
/// `optimize_css_block`, which both made this identical decision inline.
@@ -1171,7 +1178,7 @@ mod tests {
#[case(
"`ul { font-family: 'Roboto Hello', sans-serif; }`",
vec![
- ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("ul".to_string()))),
+ ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("& ul".to_string()))),
]
)]
#[case(
@@ -1610,14 +1617,14 @@ mod tests {
#[case(
"ul { font-family: 'Roboto Hello', sans-serif; }",
vec![
- ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("ul".to_string()))),
+ ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("& ul".to_string()))),
]
)]
#[case(
"div { color: red; ; { background: blue; } }",
vec![
- ("color", "red", Some(StyleSelector::Selector("div".to_string()))),
- ("background", "blue", Some(StyleSelector::Selector("div".to_string()))),
+ ("color", "red", Some(StyleSelector::Selector("& div".to_string()))),
+ ("background", "blue", Some(StyleSelector::Selector("& div".to_string()))),
]
)]
// As in CSS nesting, only the text after the last `;` is the nested rule's
@@ -1626,7 +1633,7 @@ mod tests {
"color:red;background:blue { width: 1px; }",
vec![
("color", "red", None),
- ("width", "1px", Some(StyleSelector::Selector("background:blue".to_string()))),
+ ("width", "1px", Some(StyleSelector::Selector("& background:blue".to_string()))),
]
)]
#[case(
@@ -1634,7 +1641,7 @@ mod tests {
vec![(
"width",
"1px",
- Some(StyleSelector::Selector("color:red".to_string()))
+ Some(StyleSelector::Selector("& color:red".to_string()))
)]
)]
#[case(
diff --git a/libs/extractor/src/dead_properties.rs b/libs/extractor/src/dead_properties.rs
new file mode 100644
index 00000000..c93a8e17
--- /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 00000000..36c146bf
--- /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 00000000..2974e475
--- /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 00000000..5da5637a
--- /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 00000000..1e42d827
--- /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 00000000..02c57acc
--- /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 00000000..3aa2bbe4
--- /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 00000000..069bccd7
--- /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 00000000..f268e25f
--- /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 00000000..014c051f
--- /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 00000000..08904575
--- /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 00000000..fe0d7993
--- /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 00000000..0e227e5b
--- /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 00000000..740c50e3
--- /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 3388d219..ba3088f6 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 1adc78be..7efaa2fd 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 00000000..f8898970
--- /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 a4484b90..b0963606 100644
--- a/libs/extractor/src/extractor/extract_global_style_from_expression.rs
+++ b/libs/extractor/src/extractor/extract_global_style_from_expression.rs
@@ -10,25 +10,27 @@ use crate::{
extractor::{
GlobalExtractResult,
extract_style_from_expression::{
- LiteralHandling, at_rule_record_kind, extract_style_from_expression, place_in_layer,
- unreadable, unreadable_key, yield_typography,
+ LiteralHandling, at_rule_record_kind, extract_style_from_expression, misplaced,
+ place_in_layer, unreadable, unreadable_key, yield_typography,
},
},
utils::{
- get_str_by_property_key, get_string_by_literal_expression, get_string_by_property_key,
- unwrap_syntax_only_mut,
+ SELECTOR_NAME, get_str_by_property_key, get_string_by_literal_expression,
+ get_string_by_property_key, unwrap_syntax_only_mut,
},
};
use css::{
at_rule::{media_shorthand_query, split_at_rule_key},
disassemble_property,
optimize_multi_css_value::{check_multi_css_optimize, optimize_multi_css_value, wrap_url},
- style_selector::{AtRule, AtRuleKind, StyleSelector},
+ style_selector::{AtRule, AtRuleKind, StyleSelector, is_selector_name},
+ utils::to_kebab_case,
};
use oxc_ast::{
ast::{ArrayExpressionElement, Expression, ObjectPropertyKind},
builder::AstBuilder,
};
+use oxc_span::GetSpan;
pub fn extract_global_style_from_expression<'a>(
ast_builder: &AstBuilder<'a>,
@@ -66,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 {
@@ -181,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
@@ -204,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| {
@@ -247,8 +276,19 @@ fn collect_global_styles<'a>(
};
let global = StyleSelector::Global(
- if let Some(name) = name.strip_prefix("_") {
- StyleSelector::from(name).to_string().replace('&', "*")
+ if let Some(pseudo) = name.strip_prefix('_') {
+ let pseudo = to_kebab_case(pseudo);
+ if !is_selector_name(&pseudo) {
+ styles.push(misplaced(
+ o.key.span().start,
+ name,
+ SELECTOR_NAME,
+ ));
+ continue;
+ }
+ StyleSelector::from(pseudo.as_ref())
+ .to_string()
+ .replace('&', "*")
} else {
name
},
diff --git a/libs/extractor/src/extractor/extract_keyframes_from_expression.rs b/libs/extractor/src/extractor/extract_keyframes_from_expression.rs
index ae468ce3..4f4a1161 100644
--- a/libs/extractor/src/extractor/extract_keyframes_from_expression.rs
+++ b/libs/extractor/src/extractor/extract_keyframes_from_expression.rs
@@ -7,44 +7,61 @@ 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,
ObjectPropertyKind::SpreadProperty(spread) => {
- runtime =
- runtime.or_else(|| Some(format!("...{}", readable_code(&spread.argument))));
+ runtime = runtime
+ .or_else(|| Some((format!("...{}", readable_code(&spread.argument)), None)));
continue;
}
};
let Some(name) = get_string_by_property_key(&o.key) else {
runtime = runtime.or_else(|| {
- Some(format!(
- "[{}]",
- o.key
- .as_expression()
- .map_or_else(String::new, readable_code)
+ Some((
+ format!(
+ "[{}]",
+ o.key
+ .as_expression()
+ .map_or_else(String::new, readable_code)
+ ),
+ None,
))
});
continue;
@@ -57,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()
@@ -72,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 4b8f7f27..0f26950c 100644
--- a/libs/extractor/src/extractor/extract_style_from_expression.rs
+++ b/libs/extractor/src/extractor/extract_style_from_expression.rs
@@ -10,7 +10,8 @@ use crate::{
ExtractResult, extract_style_from_member_expression::extract_style_from_member_expression,
},
utils::{
- expression_to_code, get_number_by_literal_expression, get_str_by_property_key,
+ 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,
},
@@ -20,7 +21,10 @@ use css::{
at_rule::{media_shorthand_query, split_at_rule_key},
disassemble_property, get_enum_property_map, get_enum_property_value, is_enum_property,
is_special_property::is_special_property,
- style_selector::{AtRuleKind, StyleSelector, optimize_selector},
+ rm_css_comment::rm_css_comment,
+ style_selector::{
+ AtRuleKind, StyleSelector, is_selector_name, optimize_selector, split_selector_list,
+ },
theme_tokens::{get_responsive_theme_token, get_typography_keys, is_responsive_theme_token},
utils::to_kebab_case,
};
@@ -62,6 +66,7 @@ pub(crate) fn unreadable<'a>(expression: &Expression<'a>) -> ExtractResult<'a> {
offset: expression.span().start,
code: readable_code(expression),
prop: false,
+ requirement: None,
}]
},
..ExtractResult::default()
@@ -146,6 +151,21 @@ pub(crate) fn unreadable_key<'a>(key: &PropertyKey<'_>, prop: bool) -> ExtractSt
key.as_expression().map_or_else(String::new, readable_code)
),
prop,
+ requirement: None,
+ }
+}
+
+/// `code` at `offset`, readable but not what its place takes
+pub(crate) const fn misplaced<'a>(
+ offset: u32,
+ code: String,
+ requirement: &'static str,
+) -> ExtractStyleProp<'a> {
+ ExtractStyleProp::Unreadable {
+ offset,
+ code,
+ prop: false,
+ requirement: Some(requirement),
}
}
@@ -214,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;
@@ -236,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" {
@@ -345,21 +385,18 @@ pub fn extract_style_from_expression<'a>(
if let Some(name) = name
&& is_nested_selector_key(name)
{
+ let offset = expression.span().start;
let mut styles = vec![];
for part in split_selector_list(name) {
- if let Some(nested) = nest_selectors_key(selector.as_ref(), part) {
- styles.extend(
- extract_style_from_expression(
- ast_builder,
- None,
- expression,
- level,
- &Some(nested),
- literal_handling,
- )
- .styles,
- );
- }
+ styles.extend(extract_under_key(
+ ast_builder,
+ part,
+ offset,
+ expression,
+ level,
+ selector,
+ literal_handling,
+ ));
}
return ExtractResult {
styles,
@@ -394,20 +431,17 @@ pub fn extract_style_from_expression<'a>(
props.push(unreadable_key(&o.key, false));
continue;
};
+ let offset = o.key.span().start;
for part in split_selector_list(key_name.trim()) {
- if let Some(child) = nest_selectors_key(selector.as_ref(), part) {
- props.extend(
- extract_style_from_expression(
- ast_builder,
- None,
- &mut o.value,
- level,
- &Some(child),
- literal_handling,
- )
- .styles,
- );
- }
+ props.extend(extract_under_key(
+ ast_builder,
+ part,
+ offset,
+ &mut o.value,
+ level,
+ selector,
+ literal_handling,
+ ));
}
}
}
@@ -474,23 +508,20 @@ pub fn extract_style_from_expression<'a>(
};
}
- let nested = if let Some((kind, query)) = split_at_rule_key(name) {
- Some(StyleSelector::nest_at_rule(selector.as_ref(), kind, query))
- } else {
- name.strip_prefix('_')
- .map(|child| nest_underscore_name(selector.as_ref(), child))
- };
- if let Some(nested) = nested {
- return nested.map_or_else(ExtractResult::default, |nested| {
- extract_style_from_expression(
+ if name.starts_with('_') || split_at_rule_key(name).is_some() {
+ let offset = expression.span().start;
+ return ExtractResult {
+ styles: extract_under_key(
ast_builder,
- None,
+ name,
+ offset,
expression,
level,
- &Some(nested),
+ selector,
literal_handling,
- )
- });
+ ),
+ ..ExtractResult::default()
+ };
}
typo = name == "typography";
}
@@ -540,11 +571,26 @@ pub fn extract_style_from_expression<'a>(
..ExtractResult::default()
}
} else {
+ let styles = css_to_style(&value, level, selector);
ExtractResult {
- styles: css_to_style(&value, level, selector)
- .into_iter()
- .map(|ex| ExtractStyleProp::Static(ExtractStyleValue::Static(ex)))
- .collect(),
+ styles: if styles.is_empty()
+ && !matches!(
+ unwrap_syntax_only(expression),
+ Expression::BooleanLiteral(_)
+ )
+ && !rm_css_comment(&value).trim().is_empty()
+ {
+ vec![misplaced(
+ expression.span().start,
+ readable_code(expression),
+ CSS_TEXT,
+ )]
+ } else {
+ styles
+ .into_iter()
+ .map(|ex| ExtractStyleProp::Static(ExtractStyleValue::Static(ex)))
+ .collect()
+ },
..ExtractResult::default()
}
}
@@ -840,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() {
@@ -977,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(
@@ -1221,36 +1284,57 @@ pub(crate) fn at_rule_record_kind(name: &str) -> Option {
}
}
-fn split_selector_list(key: &str) -> Vec<&str> {
- let mut parts = vec![];
- let mut depth = 0usize;
- let mut start = 0;
- for (index, c) in key.char_indices() {
- match c {
- '(' => depth += 1,
- ')' => depth = depth.saturating_sub(1),
- ',' if depth == 0 => {
- parts.push(key[start..index].trim());
- start = index + 1;
- }
- _ => {}
- }
+enum Nested {
+ Under(StyleSelector),
+ /// Its conditions can never hold together
+ Never,
+ Unknown,
+}
+
+impl From> for Nested {
+ fn from(selector: Option) -> Self {
+ selector.map_or(Self::Never, Self::Under)
}
- if !key.is_empty() {
- parts.push(key[start..].trim());
+}
+
+fn extract_under_key<'a>(
+ ast_builder: &AstBuilder<'a>,
+ key: &str,
+ offset: u32,
+ expression: &mut Expression<'a>,
+ level: u8,
+ selector: &Option,
+ literal_handling: LiteralHandling,
+) -> Vec> {
+ match nest_selectors_key(selector.as_ref(), key) {
+ Nested::Under(nested) => {
+ extract_style_from_expression(
+ ast_builder,
+ None,
+ expression,
+ level,
+ &Some(nested),
+ literal_handling,
+ )
+ .styles
+ }
+ Nested::Never => vec![],
+ Nested::Unknown => vec![misplaced(offset, key.to_string(), SELECTOR_NAME)],
}
- parts
}
/// Resolve a `_name` style key (without the `_`) under `parent`: media
-/// shorthands such as `print` or `motionReduce` wrap it in `@media`, anything
-/// else nests a selector. `None` means the styles can never apply.
-fn nest_underscore_name(parent: Option<&StyleSelector>, name: &str) -> Option {
+/// shorthands such as `print` or `motionReduce` wrap it in `@media`, a
+/// pseudo-class or pseudo-element nests a selector.
+fn nest_underscore_name(parent: Option<&StyleSelector>, name: &str) -> Nested {
let name = to_kebab_case(name);
if let Some(query) = media_shorthand_query(&name) {
- return StyleSelector::nest_at_rule(parent, AtRuleKind::Media, query);
+ return StyleSelector::nest_at_rule(parent, AtRuleKind::Media, query).into();
+ }
+ if !is_selector_name(&name) {
+ return Nested::Unknown;
}
- Some(StyleSelector::nest_selector(
+ Nested::Under(StyleSelector::nest_selector(
parent,
&StyleSelector::from(name.as_ref()).to_string(),
))
@@ -1262,20 +1346,33 @@ fn is_nested_selector_key(key: &str) -> bool {
key.starts_with(':') || key.contains('&')
}
-fn nest_selectors_key(parent: Option<&StyleSelector>, key: &str) -> Option {
+/// A key without `&` is relative to the element: a pseudo-class, a
+/// pseudo-element or an attribute applies to the element itself, a name such
+/// as `hover` is a pseudo-class, and any other selector (`.child`, `> p`)
+/// selects descendants
+fn nest_selectors_key(parent: Option<&StyleSelector>, key: &str) -> Nested {
if let Some((kind, query)) = split_at_rule_key(key) {
- StyleSelector::nest_at_rule(parent, kind, query)
+ StyleSelector::nest_at_rule(parent, kind, query).into()
} else if let Some(name) = key.strip_prefix('_') {
nest_underscore_name(parent, name)
- } else if key.starts_with(':') {
- Some(StyleSelector::nest_selector(parent, &format!("&{key}")))
- } else if parent.is_some() {
- Some(StyleSelector::nest_selector(parent, key))
+ } else if key.contains('&') {
+ Nested::Under(StyleSelector::nest_selector(parent, key))
+ } else if key.starts_with([':', '[']) {
+ Nested::Under(StyleSelector::nest_selector(parent, &format!("&{key}")))
+ } else if is_name(key) {
+ nest_underscore_name(parent, key)
} else {
- Some(StyleSelector::from(key))
+ Nested::Under(StyleSelector::nest_selector(parent, &format!("& {key}")))
}
}
+fn is_name(key: &str) -> bool {
+ key.strip_prefix('-')
+ .unwrap_or(key)
+ .starts_with(|c: char| c.is_ascii_alphabetic())
+ && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
+}
+
fn typography_atom(name: &str, level: u8, selector: &Option) -> ExtractStaticStyle {
ExtractStaticStyle {
property: "typography".to_string(),
diff --git a/libs/extractor/src/extractor/extract_style_from_member_expression.rs b/libs/extractor/src/extractor/extract_style_from_member_expression.rs
index b52e3169..62e5c9f1 100644
--- a/libs/extractor/src/extractor/extract_style_from_member_expression.rs
+++ b/libs/extractor/src/extractor/extract_style_from_member_expression.rs
@@ -242,6 +242,7 @@ fn runtime_member<'a>(
offset,
code: readable_code(&member),
prop: false,
+ requirement: None,
},
}
}
diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs
index 425ed024..b97671fc 100644
--- a/libs/extractor/src/extractor/extract_style_from_styled.rs
+++ b/libs/extractor/src/extractor/extract_style_from_styled.rs
@@ -216,6 +216,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,
@@ -319,11 +339,12 @@ pub fn extract_style_from_styled<'a>(
);
let mut unreadable = Vec::new();
unreadable_styles(&styles, true, &mut unreadable);
- errors.extend(
- unreadable
- .into_iter()
- .map(|(offset, code)| (offset, build_time_error("styled", &code, STYLE_OBJECT))),
- );
+ errors.extend(unreadable.into_iter().map(|(offset, code, requirement)| {
+ (
+ if offset == 0 { call.span.start } else { offset },
+ build_time_error("styled", &code, requirement.unwrap_or(STYLE_OBJECT)),
+ )
+ }));
if let Some(default_class_name) = base.styles.take() {
styles.extend(default_class_name.into_iter().map(ExtractStyleProp::Static));
}
diff --git a/libs/extractor/src/extractor/extract_style_from_stylex.rs b/libs/extractor/src/extractor/extract_style_from_stylex.rs
index 69ff713a..7e2ddeac 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/extractor/mod.rs b/libs/extractor/src/extractor/mod.rs
index 1b47b978..872388de 100644
--- a/libs/extractor/src/extractor/mod.rs
+++ b/libs/extractor/src/extractor/mod.rs
@@ -37,5 +37,5 @@ pub struct GlobalExtractResult<'a> {
pub struct KeyframesExtractResult {
pub keyframes: ExtractKeyframes,
/// A value only known at runtime, which keyframes cannot hold
- pub runtime_value: Option,
+ pub runtime_value: Option,
}
diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs
index 68981f36..e25c9307 100644
--- a/libs/extractor/src/lib.rs
+++ b/libs/extractor/src/lib.rs
@@ -11,6 +11,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;
mod stylex;
@@ -19,6 +21,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};
@@ -69,11 +89,13 @@ pub enum ExtractStyleProp<'a> {
},
/// Styles written where the build cannot read them, reported as an error;
/// `prop` for a computed key among an element's props, which the element
- /// takes as it is at runtime
+ /// takes as it is at runtime; `requirement` for code that is readable but
+ /// not what the place takes, telling what it must be instead
Unreadable {
offset: u32,
code: String,
prop: bool,
+ requirement: Option<&'static str>,
},
}
@@ -113,10 +135,16 @@ impl<'a> ExtractStyleProp<'a> {
expression: expression.clone_in(alloc),
}
}
- ExtractStyleProp::Unreadable { offset, code, prop } => ExtractStyleProp::Unreadable {
+ ExtractStyleProp::Unreadable {
+ offset,
+ code,
+ prop,
+ requirement,
+ } => ExtractStyleProp::Unreadable {
offset: *offset,
code: code.clone(),
prop: *prop,
+ requirement: *requirement,
},
}
}
@@ -326,7 +354,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
@@ -367,6 +395,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) =>
@@ -448,6 +481,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(
@@ -478,7 +515,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());
}
@@ -836,8 +881,8 @@ mod tests {
alternate: None,
};
- assert!(empty.extract().is_empty());
- assert!(empty.into_extract().is_empty());
+ assert_eq!(empty.extract(), vec![]);
+ assert_eq!(empty.into_extract(), vec![]);
}
#[test]
@@ -7996,7 +8041,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"
);
}
@@ -9266,7 +9311,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",
]
);
@@ -13598,7 +13643,7 @@ globalCss({
);
assert!(result.is_ok());
let output = result.unwrap();
- assert!(!output.code.is_empty());
+ assert_ne!(output.code, "");
}
#[test]
@@ -18778,7 +18823,7 @@ export const k = styled('div')({ color: SIZE });",
&memory_resolver(CONSTANT_MODULES),
)
.unwrap();
- assert!(without_imports.dependencies.is_empty());
+ assert_eq!(without_imports.dependencies.len(), 0);
let without_constants = extract_with_modules(
"/src/Handler.tsx",
"import { Box } from '@devup-ui/react';\nimport { handler } from './handler';\nexport const a = ;",
@@ -20648,10 +20693,68 @@ const logical = ;"
fn test_raw_selector_key_without_parent() {
assert_debug_snapshot!(ToBTreeSet::from(extract_tsx(
r"import { Box } from '@devup-ui/react';
-const e = b, i': { color: 'blue' } }} />;"
+const e = b, > i': { color: 'blue' } }} />;"
)));
}
+ /// A selector key naming nothing, or a string that is not CSS text where a
+ /// selector takes styles, would give CSS that selects or declares nothing
+ #[test]
+ #[serial]
+ fn test_selectors_that_select_nothing_are_errors() {
+ let mut errors = vec![];
+ for source in [
+ "import { Box } from '@devup-ui/react';\nconst e = ;",
+ "import { Box } from '@devup-ui/react';\nconst e = ;",
+ "import { Box } from '@devup-ui/react';\nconst e = ;",
+ "import { Box } from '@devup-ui/react';\nconst e = ;",
+ "import { Box } from '@devup-ui/react';\nconst e = ;",
+ "import { css } from '@devup-ui/react';\nconst c = css({ _hover: { ':nope, _nope': { color: 'red' } } });",
+ "import { css } from '@devup-ui/react';\nconst c = css({ _focus: 'red' });",
+ "import { styled } from '@devup-ui/react';\nconst S = styled.div({ selectors: { nope: { color: 'red' } } });",
+ "import { globalCss } from '@devup-ui/react';\nglobalCss({ _nope: { color: 'red' } });",
+ ] {
+ reset_class_map();
+ reset_file_map();
+ errors.push(
+ extract("test.tsx", source, ExtractOption::default())
+ .err()
+ .map(|error| error.to_string())
+ .unwrap_or_default(),
+ );
+ }
+ let name = |location: &str, api: &str, code: &str| {
+ format!(
+ "test.tsx:2:{location}: {api} cannot use `{code}` at build time: {}",
+ utils::SELECTOR_NAME
+ )
+ };
+ let text = |location: &str, api: &str, code: &str| {
+ format!(
+ "test.tsx:2:{location}: {api} cannot use `{code}` at build time: {}",
+ utils::CSS_TEXT
+ )
+ };
+ assert_eq!(
+ errors,
+ [
+ name("31", "``", "_notASelector"),
+ name("29", "``", "definitelyNotASelector"),
+ format!(
+ "{}\n{}",
+ name("29", "``", "div"),
+ name("29", "``", "i")
+ ),
+ text("23", "``", "\"external-class\""),
+ text("43", "``", "\"external-class\""),
+ name("43", "`css()`", "_nope"),
+ text("25", "`css()`", "\"red\""),
+ name("37", "`styled()`", "nope"),
+ name("13", "`globalCss()`", "_nope"),
+ ]
+ );
+ }
+
#[test]
#[serial]
fn test_minus_zero_is_normalized() {
diff --git a/libs/extractor/src/module_loader.rs b/libs/extractor/src/module_loader.rs
index 4082ff39..7cb13f3d 100644
--- a/libs/extractor/src/module_loader.rs
+++ b/libs/extractor/src/module_loader.rs
@@ -185,7 +185,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
@@ -267,7 +270,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 00000000..17241b7b
--- /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 a936a635..605b4206 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__raw_selector_key_without_parent.snap b/libs/extractor/src/snapshots/extractor__tests__raw_selector_key_without_parent.snap
index 3ac2686c..d5615675 100644
--- a/libs/extractor/src/snapshots/extractor__tests__raw_selector_key_without_parent.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__raw_selector_key_without_parent.snap
@@ -1,6 +1,6 @@
---
source: libs/extractor/src/lib.rs
-expression: "ToBTreeSet::from(extract_tsx(r\"import { Box } from '@devup-ui/react';\nconst e = b, i': { color: 'blue' } }} />;\"))"
+expression: "ToBTreeSet::from(extract_tsx(r\"import { Box } from '@devup-ui/react';\nconst e = b, > i': { color: 'blue' } }} />;\"))"
---
ToBTreeSet {
styles: {
@@ -11,7 +11,7 @@ ToBTreeSet {
level: 0,
selector: Some(
Selector(
- "&:a > b",
+ "& > i",
),
),
style_order: None,
@@ -25,7 +25,7 @@ ToBTreeSet {
level: 0,
selector: Some(
Selector(
- "&:i",
+ "& a > b",
),
),
style_order: None,
@@ -39,7 +39,7 @@ ToBTreeSet {
level: 0,
selector: Some(
Selector(
- "&:div p",
+ "& div p",
),
),
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 08b5fc24..ebae17c8 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/tailwind.rs b/libs/extractor/src/tailwind.rs
index 87e9f517..70643aa7 100644
--- a/libs/extractor/src/tailwind.rs
+++ b/libs/extractor/src/tailwind.rs
@@ -284,7 +284,7 @@ pub struct TailwindClass {
/// non-overlapping) but mutates the existing buffer instead of allocating a new
/// `String`. `needle` must be non-empty.
fn remove_all_substr(haystack: &mut String, needle: &str) {
- debug_assert!(!needle.is_empty());
+ debug_assert_ne!(needle, "");
let mut search_from = 0;
while let Some(rel) = haystack[search_from..].find(needle) {
let at = search_from + rel;
@@ -3934,7 +3934,7 @@ mod tests {
#[test]
fn test_empty_string() {
let styles = parse_tailwind_to_styles("");
- assert!(styles.is_empty());
+ assert_eq!(styles, vec![]);
}
#[test]
diff --git a/libs/extractor/src/utils.rs b/libs/extractor/src/utils.rs
index 4fa94f72..15a011a7 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:
@@ -851,21 +851,25 @@ fn string_class<'a>(ast_builder: &AstBuilder<'a>, value: &Expression<'a>) -> Exp
)
}
+/// Code the build cannot use where it is written, with what the place takes
+/// when that is not the usual requirement
+pub(super) type Unused = (String, Option<&'static str>);
+
/// The first value in `props` that is only known at runtime
-pub(super) fn runtime_value(props: &[crate::ExtractStyleProp<'_>]) -> Option {
+pub(super) fn runtime_value(props: &[crate::ExtractStyleProp<'_>]) -> Option {
let mut unreadable = Vec::new();
unreadable_styles(props, true, &mut unreadable);
unreadable
.into_iter()
.next()
- .map(|(_, code)| code)
+ .map(|(_, code, requirement)| (code, requirement))
.or_else(|| {
props
.iter()
.flat_map(crate::ExtractStyleProp::extract)
.find_map(|value| match value {
crate::ExtractStyleValue::Dynamic(style) => {
- Some(style.identifier().to_string())
+ Some((style.identifier().to_string(), None))
}
_ => None,
})
@@ -875,14 +879,14 @@ pub(super) fn runtime_value(props: &[crate::ExtractStyleProp<'_>]) -> Option]) -> Option {
- fn condition(prop: &crate::ExtractStyleProp<'_>) -> Option {
+pub(super) fn fixed_value(props: &[crate::ExtractStyleProp<'_>]) -> Option {
+ fn condition(prop: &crate::ExtractStyleProp<'_>) -> Option {
use crate::ExtractStyleProp;
match prop {
ExtractStyleProp::Conditional { condition, .. }
- | ExtractStyleProp::Enum { condition, .. } => Some(readable_code(condition)),
+ | ExtractStyleProp::Enum { condition, .. } => Some((readable_code(condition), None)),
ExtractStyleProp::MemberExpression { expression, .. } => {
- Some(readable_code(expression))
+ Some((readable_code(expression), None))
}
ExtractStyleProp::StaticArray(props) => props.iter().find_map(condition),
_ => None,
@@ -891,19 +895,24 @@ pub(super) fn fixed_value(props: &[crate::ExtractStyleProp<'_>]) -> Option],
keys: bool,
- found: &mut Vec<(u32, String)>,
+ found: &mut Vec<(u32, String, Option<&'static str>)>,
) {
use crate::ExtractStyleProp;
for prop in props {
match prop {
- ExtractStyleProp::Unreadable { offset, code, prop } => {
+ ExtractStyleProp::Unreadable {
+ offset,
+ code,
+ prop,
+ requirement,
+ } => {
if keys || !prop {
- found.push((*offset, code.clone()));
+ found.push((*offset, code.clone(), *requirement));
}
}
ExtractStyleProp::StaticArray(props) => unreadable_styles(props, keys, found),
@@ -949,6 +958,17 @@ pub(super) fn runtime_value_error(api: &str, value: &str) -> String {
build_time_error(api, value, COMPUTED_VALUE)
}
+/// [`runtime_value_error`] for what [`runtime_value`] finds
+pub(super) fn unused_error(api: &str, (code, requirement): &Unused) -> String {
+ build_time_error(api, code, requirement.unwrap_or(COMPUTED_VALUE))
+}
+
+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 {
format!("`<{component}>` cannot use `{code}` at build time: {requirement}")
}
@@ -1391,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 9e736b09..9d25d480 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,
@@ -281,6 +292,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,
@@ -590,6 +609,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
@@ -607,6 +663,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),
@@ -678,6 +735,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);
@@ -725,6 +787,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
@@ -762,6 +825,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(
@@ -780,6 +848,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()
}
@@ -791,6 +862,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 93846123..ff448e32 100644
--- a/libs/extractor/src/visit.rs
+++ b/libs/extractor/src/visit.rs
@@ -9,7 +9,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,
};
@@ -61,7 +61,7 @@ use crate::utils::{
jsx_expression_to_style_order, key_error, readable_argument, readable_code, reads_directly,
reads_spreads_once, reads_unknown, runtime_classes, runtime_value, runtime_value_error,
spread_error, stays_attribute, style_arguments, uncomposable_error, unplaced_error,
- unreadable_styles, unwrap_syntax_only, unwrap_syntax_only_mut,
+ unreadable_styles, unused_error, unwrap_syntax_only, unwrap_syntax_only_mut,
};
use oxc_ast::builder::AstBuilder;
use oxc_span::{GetSpan, SPAN};
@@ -1514,14 +1514,19 @@ 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,
- runtime_value_error("stylex.keyframes", &value),
+ runtime_offset
+ .filter(|offset| *offset != 0)
+ .unwrap_or(call.span.start),
+ unused_error("stylex.keyframes", &value),
));
}
let name =
@@ -1651,9 +1656,10 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> {
&None,
LiteralHandling::ExpandResponsiveThemeToken,
);
- if let Some(value) = runtime_value(&styles) {
- self.errors
- .push((offset, runtime_value_error("css", &value)));
+ if let Some((offset, value)) =
+ crate::dead_properties::located_value(&styles, offset, false)
+ {
+ self.errors.push((offset, unused_error("css", &value)));
}
if styles.is_empty() {
@@ -1678,10 +1684,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
@@ -1690,8 +1696,12 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> {
},
);
if let Some(value) = runtime_value {
- self.errors
- .push((offset, runtime_value_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(
@@ -1718,9 +1728,11 @@ 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, runtime_value_error("globalCss", &value)));
+ .push((offset, unused_error("globalCss", &value)));
}
// already set style order
let style_order = style_order.unwrap_or(0);
@@ -1774,9 +1786,11 @@ 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, runtime_value_error("globalCss", &value)));
+ .push((offset, unused_error("globalCss", &value)));
}
let style_order = style_order.unwrap_or(0);
self.styles.extend(
@@ -1819,6 +1833,23 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> {
UtilType::Keyframes => "keyframes",
UtilType::GlobalCss | UtilType::GlobalCssComponent => "globalCss",
};
+ 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);
@@ -1837,7 +1868,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> {
.collect::>();
if let Some(value) = runtime_value(&style_props) {
self.errors
- .push((tag.span.start, runtime_value_error(api, &value)));
+ .push((tag.span.start, unused_error(api, &value)));
}
for index in unplaced {
let expression = &tag.quasi.expressions[index];
@@ -2337,8 +2368,10 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> {
style_order,
} = extract_global_style_from_expression(&self.ast, expression, &self.filename);
if let Some(value) = fixed_value(&styles) {
- self.errors
- .push((offset, element_error(&name, &value, RUNTIME_VALUE)));
+ self.errors.push((
+ offset,
+ element_error(&name, &value.0, value.1.unwrap_or(RUNTIME_VALUE)),
+ ));
}
let style_order = style_order.unwrap_or(0);
self.styles.extend(
@@ -2389,6 +2422,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
@@ -2656,10 +2696,14 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> {
.for_each(|style| self.styles.extend(style.into_extract()));
}
- for (offset, code) in unreadable {
+ for (offset, code, requirement) in unreadable {
self.errors.push((
- offset,
- element_error(&elem.opening_element.name.to_string(), &code, STYLE_OBJECT),
+ if offset == 0 { elem.span.start } else { offset },
+ element_error(
+ &elem.opening_element.name.to_string(),
+ &code,
+ requirement.unwrap_or(STYLE_OBJECT),
+ ),
));
}
diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs
index 9a989df8..ed6e7fc1 100644
--- a/libs/sheet/src/lib.rs
+++ b/libs/sheet/src/lib.rs
@@ -3508,6 +3508,45 @@ mod tests {
}
}
+ #[test]
+ #[serial]
+ #[allow(clippy::literal_string_with_formatting_args)]
+ fn test_nested_selector_pipeline() {
+ for (source, expected) in [
+ // Without `&`, a nested rule selects descendants; a pseudo-class or
+ // pseudo-element selects the component itself.
+ (
+ "const X = styled.div`.child { color: red; } > p { color: blue; &:hover { color: green; } } :hover { color: black; } ::before { content: 'x'; } html.test & { display: none; } & + & { margin: 0; }`",
+ ".c0 + .c0{margin:0}.c1:hover{color:black}.c2 .c3{color:red}.c4 > p{color:blue}.c5 > p:hover{color:green}.c6::before{content:'x'}html.c7 .c8{display:none}",
+ ),
+ // Each selector of a list is relative on its own, and a rule nested
+ // in a list applies under every selector of it.
+ (
+ "const X = styled.div`h1, &:after { color: red; } h2, h3 { span { color: blue; } }`",
+ ".c0 h1,.c0:after{color:red}.c1 h2 span,.c1 h3 span{color:blue}",
+ ),
+ // `content` keeps its strings as written.
+ (
+ r#"const X = styled.div`&::before { content: 'hello world'; } &::after { content: "x"; } &.empty::after { content: ""; } &.escaped::after { content: "a\"b"; } &.attr::after { content: attr(data-x); }`"#,
+ r#".c0.c1::after{content:attr(data-x)}.c2.c3::after{content:""}.c4.c5::after{content:"a\"b"}.c6::after{content:"x"}.c7::before{content:'hello world'}"#,
+ ),
+ // `params` go on the pseudo-class that takes them, wherever nesting
+ // put it.
+ (
+ " ",
+ ":is([role=group],[data-group]):nth-child(3n) .c0:hover{color:blue}.c1:not(.c2):nth-child(2n){color:green}:is([role=group],[data-group]):nth-child(2n) .c3{color:red}",
+ ),
+ // A bare `selectors` key is a pseudo-class name, an attribute of the
+ // element, or a selector of its descendants, under a parent too.
+ (
+ " p': { m: 2 }, _focus: { selectors: { focusVisible: { color: 'green' }, '[data-x]': { color: 'black' }, 'b i': { color: 'white' } } } }} _hover=\"color: pink\" _active={false} _focusWithin={[false, null, true]} />",
+ ".c0:hover{color:pink}.c1:hover{color:red}:is([role=group],[data-group]):hover .c2{color:blue}.c3 .c4{margin:4px}.c5 > p{margin:8px}.c6:focus b i{color:white}.c7:focus:focus-visible{color:green}.c8:focus[data-x]{color:black}.c9[aria-busy]{padding:4px}",
+ ),
+ ] {
+ assert_eq!(pipeline_css(Theme::default(), source), expected, "{source}");
+ }
+ }
+
#[test]
#[serial]
#[allow(clippy::literal_string_with_formatting_args)]