Skip to content
Closed
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
12 changes: 7 additions & 5 deletions docs/bundler/css.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -131,20 +131,22 @@ Relative color syntax modifies individual components of an existing color. Adjus

```css title="styles.css" icon="file-code"
.theme-color {
/* Start with a base color and increase lightness by 15% */
--accent: lch(from purple calc(l + 15%) c h);
/* Start with a base color and add 15 to its lightness, which runs from 0 to 100 */
--accent: lch(from purple calc(l + 15) c h);

/* Take our brand blue and make a desaturated version */
--subtle-blue: oklch(from var(--brand-blue) l calc(c * 0.8) h);
}
```

Bun's CSS bundler computes these relative color modifications at build time (when not using CSS variables) and generates static color values for browser compatibility:
The channel keywords (`l`, `c`, `h`, `r`, `alpha`, ...) are numbers in the range the color function itself uses: `rgb()` channels run from 0 to 255, `hsl()` saturation and lightness and `lab()`/`lch()` lightness from 0 to 100, `oklab()`/`oklch()` lightness and `alpha` from 0 to 1.

Bun's CSS bundler computes these relative color modifications at build time when it can. It leaves a color as written for the browser when a channel depends on a CSS variable, or when the result is outside the range that a static `rgb()`, `hsl()` or `hwb()` value can hold (for example, an `rgb()` channel above 255):

```css
.theme-color {
--accent: lch(69.32% 58.34 328.37);
--subtle-blue: oklch(60.92% 0.112 240.01);
--accent: lch(44.6915% 66.8257 327.105);
--subtle-blue: oklch(from var(--brand-blue) l calc(c * 0.8) h);
}
```

Expand Down
61 changes: 58 additions & 3 deletions src/css/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ use crate::css_parser as css;

use css::css_rules::media::MediaRule;

use crate::generics::DeepClone;
use css::css_properties::custom::UnparsedProperty;
use css::css_values::color::ColorFallbackKind;
use css::media_query::{MediaCondition, MediaFeature, MediaFeatureId, MediaList, MediaQuery};

use bun_alloc::{Arena as Bump, ArenaPtr};
Expand All @@ -12,6 +14,8 @@ pub struct SupportsEntry {
pub(crate) condition: css::SupportsCondition,
pub(crate) declarations: Vec<css::Property>,
pub(crate) important_declarations: Vec<css::Property>,
/// Each (property, important) pair that the lists hold, once. A follow is tested against this, so the test stays linear.
properties: Vec<(css::PropertyId, bool)>,
}

// No explicit deinit — all fields own their storage and drop automatically.
Expand Down Expand Up @@ -276,6 +280,7 @@ impl<'a> PropertyHandlerContext<'a> {
return;
}

let key = (property.property_id(), self.is_important);
let found = 'brk: {
for supp in self.supports.iter_mut() {
if condition.eql(&supp.condition) {
Expand All @@ -286,6 +291,9 @@ impl<'a> PropertyHandlerContext<'a> {
};

if let Some(entry) = found {
if !entry.properties.contains(&key) {
entry.properties.push(key);
}
if self.is_important {
entry.important_declarations.push(property);
} else {
Expand All @@ -303,25 +311,71 @@ impl<'a> PropertyHandlerContext<'a> {
condition,
declarations,
important_declarations,
properties: vec![key],
});
}
}

/// A later declaration follows an earlier one of its property into each `@supports` rule, or the earlier one wins there.
pub(crate) fn follow_earlier_declarations(
&mut self,
property_id: css::PropertyId,
mut follower: impl FnMut(&css::SupportsCondition) -> Option<css::Property>,
) {
// Merged rules are minified again without this context, while the entries of another rule are staged.
if self.context != DeclarationContext::StyleRule {
return;
}
let key = (property_id, self.is_important);
for entry in self.supports.iter_mut() {
if entry.properties.contains(&key)
Comment thread
robobun marked this conversation as resolved.
Resolved
&& let Some(property) = follower(&entry.condition)
{
if key.1 {
entry.important_declarations.push(property);
} else {
entry.declarations.push(property);
}
}
}
}

/// Returns false for a value that needs relative colors: a browser with none drops it, so it is no fallback.
pub(crate) fn add_unparsed_fallbacks(
&mut self,
bump: &bun_alloc::Arena,
unparsed: &mut UnparsedProperty,
) {
) -> bool {
let is_fallback = !unparsed.value.needs_relative_colors();
if self.context != DeclarationContext::StyleRule
&& self.context != DeclarationContext::StyleAttribute
{
return;
return is_fallback;
}

let fallbacks = unparsed.value.get_fallbacks(bump, &self.targets);
// `SmallList`
// has no `IntoIterator`, so spill to a Vec to preserve P3-before-LAB order.
for condition_and_fallback in fallbacks.to_owned_slice().into_vec() {
let fallbacks = fallbacks.to_owned_slice().into_vec();
let property_id = unparsed.property_id;
let lowest = &unparsed.value;
self.follow_earlier_declarations(property_id, |condition| {
// The rule of a fallback of this declaration gets that fallback below.
if fallbacks.iter().any(|f| f.0.eql(condition)) {
return None;
}
// The lab() rule, printed last for `bun build` targets, takes the highest fallback. The display-p3 rule takes the lowest, for a browser with no lab().
let p3 = ColorFallbackKind::P3.supports_condition();
let value = match fallbacks.last() {
Some(highest) if !condition.eql(&p3) => &highest.1,
_ => lowest,
};
Comment thread
robobun marked this conversation as resolved.
Resolved
Some(css::Property::Unparsed(UnparsedProperty {
property_id,
value: value.deep_clone(bump),
}))
});
for condition_and_fallback in fallbacks {
self.add_conditional_property(
condition_and_fallback.0,
css::Property::Unparsed(UnparsedProperty {
Expand All @@ -331,5 +385,6 @@ impl<'a> PropertyHandlerContext<'a> {
}),
);
}
is_fallback
}
}
138 changes: 138 additions & 0 deletions src/css/css_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2999,6 +2999,10 @@ bitflags::bitflags! {
const CSS_MODULES = 0b1;
/// Makes `TokenList::parse` keep `url()` as a raw token instead of creating an import record.
const RAW_URL_TOKENS = 0b10;
/// Turns off each rule that leaves a relative color for the browser. `Bun.color()` has no form for an unresolved value.
const MUST_FOLD = 0b100;
/// Set inside the origin of a relative color and the operands of `color-mix()`: a color that is not painted as it is.
const INNER_COLOR = 0b1000;
}
}
impl ParserOpts {
Expand All @@ -3011,6 +3015,16 @@ impl ParserOpts {
pub(crate) fn raw_url_tokens(self) -> bool {
self.contains(Self::RAW_URL_TOKENS)
}

#[inline]
pub(crate) fn must_fold(self) -> bool {
self.contains(Self::MUST_FOLD)
}

#[inline]
pub(crate) fn inner_color(self) -> bool {
self.contains(Self::INNER_COLOR)
}
}

pub struct Parser<'a> {
Expand Down Expand Up @@ -5389,6 +5403,130 @@ impl Token {
.map_err(|_| PrintErr::CSSPrintError),
}
}

pub(crate) fn serialization_type(&self) -> TokenSerializationType {
use TokenSerializationType::*;
match self {
Token::Ident(_) => Ident,
Token::AtKeyword(_) | Token::UnrestrictedHash(_) | Token::IdHash(_) => AtKeywordOrHash,
Token::UnquotedUrl(_) | Token::BadUrl(_) => UrlOrBadUrl,
Token::Function(_) => Function,
Token::Number(_) => Number,
Token::Percentage { .. } => Percentage,
Token::Dimension(_) => Dimension,
Token::Whitespace(_) => WhiteSpace,
Token::Cdc => Cdc,
Token::SubstringMatch => SubstringMatch,
Token::OpenParen => OpenParen,
Token::Delim(c) => match u8::try_from(*c).unwrap_or(0) {
b'#' => DelimHash,
b'@' => DelimAt,
b'.' => DelimDot,
b'+' => DelimPlus,
b'-' => DelimMinus,
b'$' | b'^' | b'~' | b'|' => DelimAssorted,
b'%' => DelimPercent,
b'=' => DelimEquals,
b'/' => DelimSlash,
b'*' => DelimAsterisk,
_ => Other,
},
_ => Other,
}
}
}

/// A category of token for `needs_separator_when_before`, as in rust-cssparser.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum TokenSerializationType {
/// The start of a list.
Nothing,
WhiteSpace,
AtKeywordOrHash,
/// The end of a number, and the start of a number that is printed with a digit first.
Number,
/// The start of a number or a dimension that is printed with its decimal point first, as `.5`. Not in rust-cssparser.
LeadingDot,
Dimension,
Percentage,
UrlOrBadUrl,
Function,
Ident,
Cdc,
SubstringMatch,
OpenParen,
DelimHash,
DelimAt,
DelimDot,
DelimPlus,
/// A `-`, also as the start of a negative number.
DelimMinus,
/// `$`, `^`, `~` or `|`: each makes one token with a `=` after it.
DelimAssorted,
DelimEquals,
DelimSlash,
DelimAsterisk,
DelimPercent,
Other,
}

impl TokenSerializationType {
/// Whether `self` printed directly before `other` reads back as other tokens: https://drafts.csswg.org/css-syntax/#serialization
pub(crate) fn needs_separator_when_before(self, other: Self) -> bool {
use TokenSerializationType::*;
match self {
Ident => matches!(
other,
Ident
| Function
| UrlOrBadUrl
| DelimMinus
| Number
| Percentage
| Dimension
| Cdc
| OpenParen
),
AtKeywordOrHash | Dimension | DelimHash => matches!(
other,
Ident | Function | UrlOrBadUrl | DelimMinus | Number | Percentage | Dimension | Cdc
),
DelimMinus => matches!(
other,
Ident
| Function
| UrlOrBadUrl
| DelimMinus
| Number
| Percentage
| Dimension
| Cdc
| LeadingDot
),
// `1` before `.5` reads back as `1.5`. `1.2` before `.5` does not: `TokenList::to_css_after` looks at the number.
Number => matches!(
other,
Ident
| Function
| UrlOrBadUrl
| Number
| Percentage
| Dimension
| Cdc
| DelimPercent
| LeadingDot
),
DelimAt => matches!(other, Ident | Function | UrlOrBadUrl | DelimMinus | Cdc),
DelimDot => matches!(other, Number | Percentage | Dimension),
DelimPlus => matches!(other, Number | Percentage | Dimension | LeadingDot),
DelimAssorted => matches!(other, DelimEquals),
// `*` before `/` reads back the same. A separator keeps `*/` out of the output.
DelimAsterisk => matches!(other, DelimEquals | DelimSlash),
DelimSlash => matches!(other, DelimAsterisk | SubstringMatch),
Nothing | WhiteSpace | LeadingDot | Percentage | UrlOrBadUrl | Function | Cdc
| OpenParen | SubstringMatch | DelimEquals | DelimPercent | Other => false,
}
}
}

/// Byte-writer trait for `serializer` and `to_css_generic`.
Expand Down
7 changes: 7 additions & 0 deletions src/css/declaration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,13 @@ impl<'bump> DeclarationHandler<'bump> {
property: &css::Property,
context: &mut css::PropertyHandlerContext,
) -> bool {
// An unparsed declaration follows in `add_unparsed_fallbacks`, with its own fallbacks.
if !context.supports.is_empty() && !matches!(property, css::Property::Unparsed(_)) {
let arena = self.decls.bump();
context.follow_earlier_declarations(property.property_id(), |_| {
Some(property.deep_clone(arena))
});
}
// return this.background.handleProperty(property, &this.decls, context);
self.background
.handle_property(property, &mut self.decls, context)
Expand Down
6 changes: 4 additions & 2 deletions src/css/properties/background.rs
Original file line number Diff line number Diff line change
Expand Up @@ -787,8 +787,10 @@ impl BackgroundHandler {
self.flush(dest, context);
let arena = dest.bump();
let mut unparsed = val.deep_clone(arena);
context.add_unparsed_fallbacks(arena, &mut unparsed);
if let Some(prop) = BackgroundProperty::try_from_property_id(val.property_id) {
let is_fallback = context.add_unparsed_fallbacks(arena, &mut unparsed);
if let Some(prop) = BackgroundProperty::try_from_property_id(val.property_id)
&& is_fallback
{
self.flushed_properties.insert(prop);
}

Expand Down
25 changes: 15 additions & 10 deletions src/css/properties/border.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1593,9 +1593,11 @@ mod border_handler_body {
let logical_supported = !context.should_compile_logical(Feature::LogicalBorders);
if logical_supported {
let mut up = unparsed.deep_clone(arena);
context.add_unparsed_fallbacks(arena, &mut up);
self.flushed_properties
.insert(BorderProperty::try_from_property_id(up.property_id.tag()).unwrap());
if context.add_unparsed_fallbacks(arena, &mut up) {
self.flushed_properties.insert(
BorderProperty::try_from_property_id(up.property_id.tag()).unwrap(),
);
}
dest.push(Property::Unparsed(up));
return;
}
Expand All @@ -1604,9 +1606,11 @@ mod border_handler_body {
($id:ident) => {{
let _ = &dest; // autofix
let mut upppppppppp = unparsed.with_property_id(arena, PropertyId::$id);
context.add_unparsed_fallbacks(arena, &mut upppppppppp);
self.flushed_properties
.insert(BorderProperty::try_from_property_id(PropertyIdTag::$id).unwrap());
if context.add_unparsed_fallbacks(arena, &mut upppppppppp) {
self.flushed_properties.insert(
BorderProperty::try_from_property_id(PropertyIdTag::$id).unwrap(),
);
}
// Intentionally not pushed to dest (preserves existing behavior; likely a bug).
}};
}
Expand Down Expand Up @@ -1651,10 +1655,11 @@ mod border_handler_body {
PropertyIdTag::BorderBlockEndStyle => prop!(BorderBottomStyle),
_ => {
let mut up = unparsed.deep_clone(arena);
context.add_unparsed_fallbacks(arena, &mut up);
self.flushed_properties.insert(
BorderProperty::try_from_property_id(up.property_id.tag()).unwrap(),
);
if context.add_unparsed_fallbacks(arena, &mut up) {
self.flushed_properties.insert(
BorderProperty::try_from_property_id(up.property_id.tag()).unwrap(),
);
}
dest.push(Property::Unparsed(up));
}
}
Expand Down
11 changes: 7 additions & 4 deletions src/css/properties/border_image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -550,11 +550,14 @@ impl BorderImageHandler {
unparsed.deep_clone(arena)
};

context.add_unparsed_fallbacks(arena, &mut unparsed_clone);
self.flushed_properties.insert(
BorderImageProperty::try_from_property_id(unparsed_clone.property_id.tag())
if context.add_unparsed_fallbacks(arena, &mut unparsed_clone) {
self.flushed_properties.insert(
BorderImageProperty::try_from_property_id(
unparsed_clone.property_id.tag(),
)
.unwrap(),
);
);
}
dest.push(Property::Unparsed(unparsed_clone));
} else {
return false;
Expand Down
Loading
Loading