Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_unresolved_global_inputs.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"bindings/devup-ui-wasm/package.json": "Patch"
},
"note": "Reject unresolved imports and font-face values, misplaced font-template expressions and unusable functional-selector arguments with located build errors, preserving static values and genuine optional absence without a styling runtime.",
"date": "2026-10-19T00:00:00.000Z"
}
1 change: 1 addition & 0 deletions libs/css/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub mod optimize_multi_css_value;
pub mod optimize_value;
pub mod quoted;
pub mod rm_css_comment;
pub mod selector_params;
mod selector_separator;
pub mod slot_reservation;
pub mod style_selector;
Expand Down
37 changes: 37 additions & 0 deletions libs/css/src/selector_params.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
use crate::style_selector::StyleSelector;

/// The bare functional pseudo targeted by `add_selector_params`, without allocating.
#[must_use]
pub fn parameter_name(selector: &StyleSelector) -> Option<&str> {
let value = match selector {
StyleSelector::Selector(value)
| StyleSelector::Global(value, _)
| StyleSelector::At {
selector: Some(value),
..
} => value,
StyleSelector::At { selector: None, .. } => return None,
};
let end = crate::params_position(value)?;
value[..end].rsplit(':').next()
}

#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;

#[rstest]
#[case("&:nth-child", Some("nth-child"))]
#[case(":is([role=group],[data-group]):nth-child &:hover", Some("nth-child"))]
#[case("&:nth-child(2):hover", None)]
#[case("&:hover", None)]
#[case("&::slotted", Some("slotted"))]
fn finds_the_same_bare_target_as_the_formatter(
#[case] selector: &str,
#[case] expected: Option<&str>,
) {
let selector = StyleSelector::Selector(selector.to_string());
assert_eq!(parameter_name(&selector), expected);
}
}
106 changes: 105 additions & 1 deletion libs/extractor/src/barrel_pipeline_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ fn resolver(specifier: &str, _: &str) -> Option<ResolvedModule> {
match specifier {
"./ui" => Some(ResolvedModule {
path: "/src/ui.ts".to_string(),
code: "export { css, Box } from '@devup-ui/react';".to_string(),
code: "export { css, Box, globalCss } from '@devup-ui/react';".to_string(),
}),
_ => None,
}
Expand Down Expand Up @@ -159,3 +159,107 @@ fn source_map_and_units_survive_barrel_namespace_alias_and_boa_layers()
assert_eq!((token.get_src_line(), token.get_src_col()), (5, 13));
Ok(())
}

#[rstest]
#[case("import { globalCss } from './ui';", "globalCss")]
#[case("import * as UI from './ui';", "UI.globalCss")]
#[serial]
fn global_imports_evaluate_pure_calls_after_barrel_rewriting(
#[case] import: &str,
#[case] api: &str,
#[values(false, true)] single_css: bool,
) -> Result<(), Box<dyn std::error::Error>> {
// Given: a structural URL needs evaluation after the project import is rewritten.
reset_class_map();
reset_file_map();
let source = format!(
"{import}\n{api}({{imports:[{{url:(() => 'pure.css')()}}]}});\nexport const after = 1;"
);
// When: the real barrel and value-evaluation pipeline extracts the global.
let output = extract_with_modules(
"/src/app.tsx",
&source,
ExtractOption {
single_css,
..ExtractOption::default()
},
true,
&resolver,
)?;
// Then: the exact import, dependency and following authored coordinate survive.
let urls: Vec<_> = output
.styles
.iter()
.map(|style| match style {
crate::ExtractStyleValue::Import(import) => import.url.as_str(),
other => panic!("expected import, got {other:?}"),
})
.collect();
assert_eq!(urls, vec!["\"pure.css\""]);
assert_eq!(output.dependencies, vec!["/src/ui.ts"]);
let map =
oxc_sourcemap::SourceMap::from_json_string(output.map.as_deref().ok_or("missing map")?)?;
assert_eq!(map.get_source_content(0), Some(source.as_str()));
let (line, generated) = output
.code
.lines()
.enumerate()
.find(|(_, line)| line.contains("after"))
.ok_or("missing declaration")?;
let column = position(generated, "after");
let token = map
.get_tokens()
.find(|token| {
usize::try_from(token.get_dst_line()) == Ok(line)
&& usize::try_from(token.get_dst_col()) == Ok(column)
})
.ok_or("missing declaration token")?;
assert_eq!((token.get_src_line(), token.get_src_col()), (2, 13));
Ok(())
}

#[rstest]
#[case(("font-family:${() => \"Face\"};", "() => \"Face\"", "() => \"Face\""))]
#[case(("font-family:${() => \" at build time: \"};", "() => \" at build time: \"", "() => \" at build time: \""))]
#[case(("font-family:${class {}};", "class {}", "(class {})"))]
#[case(("font-family:${function() {}};", "function() {}", "(function() {})"))]
#[case(("${p => p.theme.fonts.body}:Face;", "p => p.theme.fonts.body", "(p) => p.theme.fonts.body"))]
#[case(("${p => p.theme.fonts.body};font-family:Face;", "p => p.theme.fonts.body", "(p) => p.theme.fonts.body"))]
#[serial]
fn font_interpolations_keep_authored_refusals_after_barrel_rewriting(
#[case] fixture: (&str, &str, &str),
#[values(false, true)] namespace: bool,
#[values(false, true)] single_css: bool,
) {
// Given: a rewritten global contains a function or misplaced theme interpolation.
reset_class_map();
reset_file_map();
let (text, marker, rejected) = fixture;
let (import, api) = if namespace {
("import * as UI from './ui';", "UI.globalCss")
} else {
("import { globalCss } from './ui';", "globalCss")
};
let statement = format!("{api}({{fontFaces:[`{text}src:url(face.woff2);`]}});");
let column = position(&statement, marker) + 1;
let source = format!("{import}\n{statement}");
// When: extraction sees the invalid structural leaf through the project barrel.
let error = failure(extract_with_modules(
"/src/app.tsx",
&source,
ExtractOption {
single_css,
..ExtractOption::default()
},
false,
&resolver,
));
// Then: neither evaluation nor rewrite mapping can discard or relocate the refusal.
assert_eq!(
error,
format!(
"/src/app.tsx:2:{column}: `globalCss()` cannot use `{rejected}` at build time: {}",
crate::extractor::global_inputs::FONT_TEMPLATE_INPUT
)
);
}
32 changes: 5 additions & 27 deletions libs/extractor/src/dead_properties_declaration_boundary_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,35 +141,13 @@ fn keeps_import_metadata_when_validating_global_selector_maps()
Ok(())
}

#[test]
#[rstest]
#[serial]
fn keeps_font_face_literals_when_a_descriptor_object_has_an_unreadable_spread()
-> Result<(), Box<dyn std::error::Error>> {
// Given a spread that has no statically readable font-face descriptors.
reset_class_map();
reset_file_map();
fn rejects_font_face_descriptor_objects_when_a_spread_is_unreadable(
#[values(false, true)] single_css: bool,
) {
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(())
crate::unresolved_global_inputs_tests::rejects_at(source, "external", single_css);
}

#[rstest]
Expand Down
Loading
Loading