Skip to content
Merged
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_quoted_css_boundaries.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"bindings/devup-ui-wasm/package.json": "Patch"
},
"note": "Preserve quoted CSS text during selector substitution, media-query folding and template interpolation placement, and resume optimization after bad-string newlines without changing EOF string contents. No new styling runtime or build-error kind is added.",
"date": "2026-10-20T00:00:00.000Z"
}
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_quoted_css_strings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"bindings/devup-ui-wasm/package.json": "Patch"
},
"note": "Keep quoted strings in CSS values, template blocks and selectors byte for byte: a number, color, space, comma, semicolon, brace, parenthesis or comment marker inside quotes is data, so content: '0.5' no longer becomes '.5'. The text around a string is still optimized, with no styling runtime and no new build errors.",
"date": "2026-10-20T00:00:00.000Z"
}
45 changes: 24 additions & 21 deletions libs/css/src/at_rule.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! At-rule preludes: media shorthands, query normalization, media query
//! combination, and the deterministic order at-rule blocks are emitted in.

use crate::quoted::{string_end, trim_css, unquoted_bytes};
use crate::style_selector::AtRuleKind;
use crate::utils::to_kebab_case;

Expand Down Expand Up @@ -33,7 +34,7 @@ pub fn media_shorthand_query(name: &str) -> Option<&'static str> {
/// Split an at-rule key such as `@media print` into its kind and prelude.
#[must_use]
pub fn split_at_rule_key(key: &str) -> Option<(AtRuleKind, &str)> {
let rest = key.trim().strip_prefix('@')?;
let rest = trim_css(key).strip_prefix('@')?;
[
("media", AtRuleKind::Media),
("supports", AtRuleKind::Supports),
Expand All @@ -42,7 +43,8 @@ pub fn split_at_rule_key(key: &str) -> Option<(AtRuleKind, &str)> {
.into_iter()
.find_map(|(name, kind)| {
let query = rest.strip_prefix(name)?;
(query.starts_with([' ', '(']) && !query.trim().is_empty()).then_some((kind, query.trim()))
(query.starts_with([' ', '(']) && !trim_css(query).is_empty())
.then_some((kind, trim_css(query)))
})
}

Expand Down Expand Up @@ -109,8 +111,8 @@ pub fn normalize_query(query: &str) -> String {
// arguments can contain significant whitespace.
let mut parens: Vec<bool> = Vec::new();
let mut pending_space = false;
let mut chars = query.chars();
while let Some(c) = chars.next() {
let mut chars = query.char_indices().peekable();
while let Some((index, c)) = chars.next() {
if c.is_whitespace() {
pending_space = true;
continue;
Expand All @@ -129,21 +131,20 @@ pub fn normalize_query(query: &str) -> String {
pending_space = false;
out.push(c);
match c {
'\\' => {
if let Some((_, escaped)) = chars.next() {
out.push(escaped);
}
}
'(' => parens.push(function_paren),
')' => {
parens.pop();
}
'"' | '\'' => {
let mut escaped = false;
for inner in chars.by_ref() {
out.push(inner);
if escaped {
escaped = false;
} else if inner == '\\' {
escaped = true;
} else if inner == c {
break;
}
let end = string_end(query.as_bytes(), index, query.as_bytes()[index]);
out.push_str(&query[index + 1..end]);
while chars.peek().is_some_and(|&(index, _)| index < end) {
chars.next();
}
}
_ => {}
Expand Down Expand Up @@ -174,18 +175,18 @@ fn split_list(query: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut depth = 0usize;
let mut start = 0;
for (index, c) in query.char_indices() {
for (index, c) in unquoted_bytes(query) {
match c {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
',' if depth == 0 => {
parts.push(query[start..index].trim());
b'(' => depth += 1,
b')' => depth = depth.saturating_sub(1),
b',' if depth == 0 => {
parts.push(trim_css(&query[start..index]));
start = index + 1;
}
_ => {}
}
}
parts.push(query[start..].trim());
parts.push(trim_css(&query[start..]));
parts
}

Expand Down Expand Up @@ -261,11 +262,13 @@ const fn resolve_negation(query: MediaQuery<'_>) -> Result<MediaQuery<'_>, Media
fn has_top_level_or(condition: &str) -> bool {
let mut depth = 0usize;
let mut previous = ' ';
for (index, c) in condition.char_indices() {
for (index, byte) in unquoted_bytes(condition) {
let c = char::from(byte);
match c {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
_ if depth == 0
&& matches!(byte, b'o' | b'O')
&& !is_word_char(previous)
&& leading_word(&condition[index..]).eq_ignore_ascii_case("or") =>
{
Expand Down
38 changes: 28 additions & 10 deletions libs/css/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub mod is_special_property;
mod num_to_nm_base;
pub mod optimize_multi_css_value;
pub mod optimize_value;
pub mod quoted;
pub mod rm_css_comment;
mod selector_separator;
pub mod slot_reservation;
Expand Down Expand Up @@ -83,13 +84,9 @@ mod prefix_state {
use prefix_state::with_prefix;
pub use prefix_state::{get_prefix, set_prefix};

/// Write `value` into `out`, replacing every `&` with `".<class_name>"` in place.
///
/// Equivalent to `out.push_str(&value.replace('&', &format!(".{class_name}")))`
/// but avoids both the throwaway `".<class>"` String and the intermediate
/// `str::replace` result String.
/// Expand nesting tokens without copying or modifying quoted/escaped text.
fn write_ampersand_expansion(out: &mut String, value: &str, class_name: &str) {
for (i, seg) in value.split('&').enumerate() {
for (i, seg) in quoted::split_unquoted(value, b'&').enumerate() {
if i > 0 {
out.push('.');
out.push_str(class_name);
Expand Down Expand Up @@ -314,7 +311,12 @@ fn params_position(selector: &str) -> Option<usize> {
let mut depth = 0usize;
let mut found = None;
let mut index = 0;
let mut quotes = quoted::QuoteState::default();
while let Some(&byte) = bytes.get(index) {
if !quotes.is_syntax(byte) {
index += 1;
continue;
}
match byte {
b'(' | b'[' => depth += 1,
b')' | b']' => depth = depth.saturating_sub(1),
Expand Down Expand Up @@ -610,7 +612,7 @@ pub fn sheet_to_classname(
);
}
if is_debug() {
let selector = selector.unwrap_or_default().trim();
let selector = quoted::trim_css(selector.unwrap_or_default());
let encoded = if selector.is_empty() {
String::new()
} else {
Expand Down Expand Up @@ -643,7 +645,7 @@ pub fn sheet_to_classname(
result
})
} else {
let trimmed_selector = selector.unwrap_or_default().trim();
let trimmed_selector = quoted::trim_css(selector.unwrap_or_default());
let order = style_order.unwrap_or(255);
let file_num_str = filename.map(|f| num_to_nm_base(get_file_num_by_filename(f)));
let trimmed_prop = property.trim();
Expand Down Expand Up @@ -710,7 +712,7 @@ pub fn sheet_to_variable_name(property: &str, level: u8, selector: Option<&str>)
return format!("--{}", atom_name::counter_name(&identity, None));
}
if is_debug() {
let selector = selector.unwrap_or_default().trim();
let selector = quoted::trim_css(selector.unwrap_or_default());
let encoded = if selector.is_empty() {
String::new()
} else {
Expand All @@ -729,7 +731,7 @@ pub fn sheet_to_variable_name(property: &str, level: u8, selector: Option<&str>)
result
})
} else {
let trimmed_selector = selector.unwrap_or_default().trim();
let trimmed_selector = quoted::trim_css(selector.unwrap_or_default());
let base_name = class_num_for_key("", |key| {
key.reserve(property.len() + 4 + trimmed_selector.len());
key.push_str(property);
Expand Down Expand Up @@ -868,6 +870,22 @@ mod tests {
);
}

#[test]
#[serial]
fn quoted_strings_keep_their_own_class() {
let _state = test_state::TestStateGuard::new();
set_debug(false);
reset_class_map();
let class = |value: &str| sheet_to_classname("content", 0, Some(value), None, None, None);
let exact = class("'0.5'");
assert_ne!(exact, class("'.5'"));
assert_ne!(class("'#ff0000'"), class("'#F00'"));
assert_ne!(class("'a, b'"), class("'a,b'"));
assert_ne!(class("'a;'"), class("'a'"));
assert_eq!(exact, class(" '0.5' "));
assert_eq!(class("0.5"), class(".5"));
}

#[test]
#[serial]
fn test_component_marker() {
Expand Down
Loading
Loading