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
29 changes: 29 additions & 0 deletions docs/bundler/css.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,7 @@ Bun's bundler also supports CSS modules, with the following features:

- Detecting CSS module files (`.module.css`) with no configuration
- Composition (`composes` property)
- Global class names (`:global()` and `:local()`)
- Importing CSS modules into JSX/TSX
- Warnings/errors for invalid usages of CSS modules

Expand Down Expand Up @@ -932,6 +933,34 @@ This outputs:

The class names are unique to each file, so they don't collide.

### Global class names

Wrap part of a selector in `:global(...)` to keep its class names and ids as written. Bun does not rename them, and the imported object does not contain them.

```css title="styles.module.css" icon="file-code"
.card :global(.is-active) {
color: red;
}
```

```ts title="app.tsx" icon="/icons/typescript.svg"
import styles from "./styles.module.css";

console.log(styles);
```

This outputs:

```ts title="app.tsx" icon="/icons/typescript.svg"
{
card: "card_123";
}
```

The stylesheet contains `.card_123 .is-active`. In your code, use the string `"is-active"` for the global class.

Inside `:global(...)`, wrap a selector in `:local(...)` to scope its names to the file again. For example, `:global(.theme-dark :local(.card))` becomes `.theme-dark .card_123`.

### Composition

CSS modules can compose class selectors together to reuse style rules across multiple classes.
Expand Down
45 changes: 40 additions & 5 deletions src/css/css_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2018,8 +2018,7 @@ mod rule_parsers {
break 'out;
}
let comp = &sel.components[0];
if let Some(r) = comp.as_class() {
let ref_ = r.as_ref().unwrap();
if let Some(ref_) = comp.as_class().and_then(|class| class.as_ref()) {
this.composes_refs.append(ref_);
this.composes_state = ComposesState::Allow(SourceLocation {
line: loc.line,
Expand Down Expand Up @@ -2594,7 +2593,7 @@ mod stylesheet_impl {
&mut input,
import_records,
if options.css_modules.is_some() {
ParserOpts::CSS_MODULES
ParserOpts::CSS_MODULES | ParserOpts::MAKE_LOCAL_SYMBOLS
} else {
ParserOpts::empty()
},
Expand Down Expand Up @@ -2694,7 +2693,7 @@ mod stylesheet_impl {
&mut input,
Some(core::ptr::NonNull::from(import_records)),
if options.css_modules.is_some() {
ParserOpts::CSS_MODULES
ParserOpts::CSS_MODULES | ParserOpts::MAKE_LOCAL_SYMBOLS
} else {
ParserOpts::empty()
},
Expand Down Expand Up @@ -2999,6 +2998,8 @@ 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;
/// Names parsed now are local to the CSS module. `:global(...)` clears it for its argument.
const MAKE_LOCAL_SYMBOLS = 0b100;
}
}
impl ParserOpts {
Expand All @@ -3011,6 +3012,24 @@ impl ParserOpts {
pub(crate) fn raw_url_tokens(self) -> bool {
self.contains(Self::RAW_URL_TOKENS)
}

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

/// Inside `:global(...)` of a CSS module.
#[inline]
pub(crate) fn in_global_scope(self) -> bool {
self.css_modules() && !self.make_local_symbols()
}
}

/// The scope that `:local(...)` or `:global(...)` gives the names in its argument.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum CssModuleScope {
Local,
Global,
}

pub struct Parser<'a> {
Expand All @@ -3028,14 +3047,30 @@ pub struct Parser<'a> {
}

impl<'a> Parser<'a> {
/// Runs `parse` in `scope` and restores the enclosing scope afterwards, also when `parse` fails.
pub(crate) fn with_css_module_scope<T>(
&mut self,
scope: CssModuleScope,
parse: impl FnOnce(&mut Self) -> T,
) -> T {
let enclosing = self.flags;
self.flags.set(
ParserOpts::MAKE_LOCAL_SYMBOLS,
scope == CssModuleScope::Local && enclosing.css_modules(),
);
let result = parse(self);
self.flags = enclosing;
result
}

pub(crate) fn add_symbol_for_name(
&mut self,
name: &[u8],
tag: CssRefTag,
loc: bun_ast::Loc,
) -> bun_ast::Ref {
// don't call this if css modules is not enabled!
debug_assert!(self.flags.css_modules());
debug_assert!(self.flags.css_modules() && self.flags.make_local_symbols());
debug_assert!(self.extra.is_some());
// tag should only have one bit set, or none
debug_assert!(tag.bits().count_ones() <= 1);
Expand Down
47 changes: 13 additions & 34 deletions src/css/printer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,14 +189,6 @@ impl<'a> Printer<'a> {
symbols.get_const(final_ref).unwrap().original_name.slice()
}

pub(crate) fn lookup_ident_or_ref(&self, ident: css_values::ident::IdentOrRef) -> &'a [u8] {
if ident.is_ident() {
// SAFETY: Ident.v is an arena-owned slice packed by IdentOrRef::from_ident.
return unsafe { crate::arena_str(ident.as_ident().unwrap().v) };
}
self.lookup_symbol(ident.as_ref().unwrap())
}

// The `Write` trait exposes `written_len()` directly
// (Vec<u8> / MutableString / counting sinks override it, others panic).
#[inline]
Expand Down Expand Up @@ -516,41 +508,28 @@ impl<'a> Printer<'a> {
str_
}

/// An `Ident` prints as written. A `Ref` is a CSS module local and prints its generated name.
pub(crate) fn write_ident_or_ref(
&mut self,
ident: css_values::ident::IdentOrRef,
handle_css_module: bool,
) -> PrintResult<()> {
if !handle_css_module {
if let Some(identifier) = ident.as_ident() {
return self.serialize_identifier(identifier.v());
} else {
let ref_ = ident.as_ref().unwrap();
let Some(symbol) = self.symbols.get_const(ref_) else {
return Err(self.add_fmt_error());
};
// Copy out the arena slice before re-borrowing &mut self.
let name = symbol.original_name.slice();
return self.serialize_identifier(name);
}
if let Some(identifier) = ident.as_ident() {
return self.serialize_identifier(identifier.v());
}
// Not an `Ident`, so the ref bit is set.
let ref_ = ident.as_ref().unwrap();

if self.local_names.is_none() {
if let Some(ref_) = ident.as_ref() {
let ref_ = self.symbols.follow(ref_);
let Some(symbol) = self.symbols.get_const(ref_) else {
return Err(self.add_fmt_error());
};
let name: &'a [u8] = symbol.original_name.slice();
return self.write_ident(name, true);
}
let ref_ = self.symbols.follow(ref_);
let Some(symbol) = self.symbols.get_const(ref_) else {
return Err(self.add_fmt_error());
};
let name: &'a [u8] = symbol.original_name.slice();
return self.write_ident(name, true);
}

// `lookup_ident_or_ref` returns an `'a`-lifetime slice (arena/symbol-table),
// independent of the `&self` borrow, so no clone is needed before re-borrowing
// `&mut self` for the writer.
let str_: &'a [u8] = self.lookup_ident_or_ref(ident);
self.serialize_identifier(str_)
let name: &'a [u8] = self.lookup_symbol(ref_);
self.serialize_identifier(name)
}

/// Writes a CSS identifier to the underlying destination, escaping it
Expand Down
45 changes: 39 additions & 6 deletions src/css/selectors/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use core::fmt;
use bun_alloc::{Arena as Bump, ArenaPtr};
use bun_core::strings;
use bun_css as css;
use bun_css::css_parser::CssModuleScope;
use bun_css::css_values::ident::{CustomIdent, Ident};
use bun_css::selector::serialize;
use bun_css::{
Expand Down Expand Up @@ -1188,7 +1189,7 @@ impl<'a> SelectorParser<'a> {
raw: Str,
loc: usize,
) -> <impl_::Selectors as SelectorImpl>::LocalIdentifier {
if input.flags.css_modules() {
if input.flags.make_local_symbols() {
return <impl_::Selectors as SelectorImpl>::LocalIdentifier::from_ref(
input.add_symbol_for_name(
raw,
Expand Down Expand Up @@ -1343,6 +1344,7 @@ impl<'a> SelectorParser<'a> {
&mut self,
name: Str,
parser: &mut CssParser,
state: &mut SelectorParsingState,
) -> CResult<PseudoClass> {
let pseudo_class = crate::match_ignore_ascii_case! { name, {
b"lang" => {
Expand All @@ -1364,10 +1366,14 @@ impl<'a> SelectorParser<'a> {
direction: Direction::parse(parser)?,
},
b"local" if self.options.css_modules.is_some() => PseudoClass::Local {
selector: Box::new(Selector::parse(self, parser)?),
selector: Box::new(parser.with_css_module_scope(CssModuleScope::Local, |parser| {
Selector::parse_inner(self, parser, state)
})?),
},
b"global" if self.options.css_modules.is_some() => PseudoClass::Global {
selector: Box::new(Selector::parse(self, parser)?),
selector: Box::new(parser.with_css_module_scope(CssModuleScope::Global, |parser| {
Selector::parse_inner(self, parser, state)
})?),
},
_ => {
if !strings::starts_with_char(name, b'-') {
Expand Down Expand Up @@ -1799,6 +1805,20 @@ impl<Impl: BunSelectorImpl> GenericSelector<Impl> {
parse_selector::<Impl>(parser, input, &mut state, NestingRequirement::None)
}

/// `&` in the parsed selector counts as `&` in the selector that holds it.
pub(crate) fn parse_inner(
parser: &mut SelectorParser,
input: &mut CssParser,
outer_state: &mut SelectorParsingState,
) -> CResult<Self> {
let mut state = SelectorParsingState::empty();
let selector = parse_selector::<Impl>(parser, input, &mut state, NestingRequirement::None)?;
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
if state.contains(SelectorParsingState::AFTER_NESTING) {
outer_state.insert(SelectorParsingState::AFTER_NESTING);
}
Ok(selector)
}

pub(crate) fn append(&mut self, component: GenericComponent<Impl>) {
let index = 'index: {
for (i, comp) in self.components.iter().enumerate() {
Expand Down Expand Up @@ -3631,7 +3651,7 @@ pub(crate) fn parse_functional_pseudo_class<Impl: BunSelectorImpl>(
.new_custom_error(SelectorParseErrorKind::InvalidState.into_default_parser_error()));
}

let result = parser.parse_non_ts_functional_pseudo_class(name, input)?;
let result = parser.parse_non_ts_functional_pseudo_class(name, input, state)?;

Ok(GenericComponent::NonTsPseudoClass(result))
}
Expand Down Expand Up @@ -4119,13 +4139,16 @@ pub enum ViewTransitionPartName {
All,
/// <custom-ident>
Name(CustomIdent),
/// <custom-ident> inside `:global(...)` of a CSS module: printed as written, never hashed.
GlobalName(CustomIdent),
}

impl ViewTransitionPartName {
pub fn to_css(&self, dest: &mut Printer) -> Result<(), PrintErr> {
match self {
Self::All => dest.write_str("*"),
Self::Name(name) => name.to_css(dest),
Self::GlobalName(name) => name.to_css_with_options(dest, false),
}
}

Expand All @@ -4134,13 +4157,19 @@ impl ViewTransitionPartName {
return Ok(Self::All);
}

Ok(Self::Name(CustomIdent::parse(input)?))
let name = CustomIdent::parse(input)?;
Ok(if input.flags.in_global_scope() {
Self::GlobalName(name)
} else {
Self::Name(name)
})
}

pub fn eql(&self, rhs: &Self) -> bool {
match (self, rhs) {
(Self::All, Self::All) => true,
(Self::Name(a), Self::Name(b)) => a.eql(b),
(Self::GlobalName(a), Self::GlobalName(b)) => a.eql(b),
_ => false,
}
}
Expand All @@ -4152,6 +4181,10 @@ impl ViewTransitionPartName {
hasher.update(&1u32.to_ne_bytes());
n.hash(hasher);
}
Self::GlobalName(n) => {
hasher.update(&2u32.to_ne_bytes());
n.hash(hasher);
}
}
}
}
Expand All @@ -4172,7 +4205,7 @@ impl ViewTransitionPartSelector {
}
for class in self.classes.iter() {
dest.write_char(b'.')?;
dest.write_ident_or_ref(*class, dest.css_module.is_some())?;
dest.write_ident_or_ref(*class)?;
}
Ok(())
}
Expand Down
Loading
Loading