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
80 changes: 79 additions & 1 deletion src/css/properties/animation.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
use crate as css;
use crate::CSSString;
use crate::css_properties::custom::{TokenList, TokenOrValue, UnparsedProperty};
use crate::css_properties::{Property, PropertyId};
use crate::css_values::easing::EasingFunction;
use crate::css_values::ident::{CustomIdent, DashedIdent, is_reserved_custom_ident};
use crate::css_values::number::{CSSNumber, CSSNumberFns};
use crate::css_values::time::Time;
use crate::generics::CssEql;
use crate::{Parser, PrintErr, Printer};
use crate::{Parser, PrintErr, Printer, Token};
use bun_core::strings;

/// A value for the [animation](https://drafts.csswg.org/css-animations/#animation) shorthand property.
Expand Down Expand Up @@ -374,6 +376,82 @@ impl AnimationName {
}
}

/// The raw-token fallback of `animation` and `animation-name`. In a CSS module it tags the keyframes names.
#[cold]
pub(crate) fn parse_unparsed(
property_id: PropertyId,
input: &mut Parser,
options: &css::ParserOptions,
) -> css::Result<Property> {
let mut unparsed = UnparsedProperty::parse(property_id, input, options)?;
if input.flags.css_modules() {
tag_unparsed_names(
&mut unparsed.value,
matches!(property_id, PropertyId::Animation(_)),
);
}
Ok(Property::Unparsed(unparsed))
}

/// Whether `ident` is a keyword of an `animation` sub-property other than `animation-name`.
fn is_single_animation_keyword(ident: &[u8]) -> bool {
EasingFunction::is_ident(ident)
|| <AnimationDirection as css::EnumProperty>::from_ascii_case_insensitive(ident).is_some()
|| <AnimationFillMode as css::EnumProperty>::from_ascii_case_insensitive(ident).is_some()
|| <AnimationPlayState as css::EnumProperty>::from_ascii_case_insensitive(ident).is_some()
|| strings::eql_any_case_insensitive_ascii(ident, &[b"infinite", b"auto"])
}

/// Whether `token` is a space or a comma.
fn is_separator(token: &TokenOrValue) -> bool {
matches!(
token,
TokenOrValue::Token(Token::Whitespace(_) | Token::Comma)
)
}

/// Tags each keyframes name in the tokens of an `animation` (`shorthand`) or `animation-name` value.
fn tag_unparsed_names(list: &mut TokenList, shorthand: bool) {
let tokens = &mut list.v;
for i in 0..tokens.len() {
let name = match &tokens[i] {
TokenOrValue::Token(Token::Ident(ident)) => {
let keyword = if shorthand {
is_single_animation_keyword(ident)
} else {
strings::eql_case_insensitive_ascii_check_length(ident, b"none")
};
// `animation: inherit` has no typed form either.
if keyword || is_reserved_custom_ident(ident) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
continue;
}
AnimationName::Ident(CustomIdent {
v: std::ptr::from_ref::<[u8]>(ident),
})
}
// In the shorthand a dashed ident can also be a timeline.
TokenOrValue::DashedIdent(ident) if !shorthand => {
AnimationName::Ident(CustomIdent { v: ident.v })
}
TokenOrValue::Token(Token::QuotedString(string)) => {
// The name prints as an ident, which joins any token that touches it except a `)` before it.
let joins_previous = i > 0
&& !is_separator(&tokens[i - 1])
&& !matches!(
&tokens[i - 1],
TokenOrValue::Var(_) | TokenOrValue::Env(_) | TokenOrValue::Function(_)
);
if joins_previous || tokens.get(i + 1).is_some_and(|next| !is_separator(next)) {
continue;
}
AnimationName::String(std::ptr::from_ref::<[u8]>(string))
}
_ => continue,
Comment thread
robobun marked this conversation as resolved.
Resolved
};
tokens[i] = TokenOrValue::AnimationName(name);
}
}

/// A value for the [animation-iteration-count](https://drafts.csswg.org/css-animations/#animation-iteration-count) property.
#[derive(Clone, Copy, PartialEq)]
pub enum AnimationIterationCount {
Expand Down
4 changes: 4 additions & 0 deletions src/css/properties/properties_generated.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3606,13 +3606,17 @@ impl Property {
if let Some(c) = parse_value::<SmallList<animation::Animation, 1>>(input, options) {
return Ok(Property::Animation((c, pre)));
}
input.reset(&state);
return animation::parse_unparsed(property_id, input, options);
}
PropertyId::AnimationName(pre) => {
if let Some(c) =
parse_value::<SmallList<animation::AnimationName, 1>>(input, options)
{
return Ok(Property::AnimationName((c, pre)));
}
input.reset(&state);
return animation::parse_unparsed(property_id, input, options);
}
PropertyId::Transform(pre) => {
if let Some(c) = parse_value::<transform::TransformList>(input, options) {
Expand Down
258 changes: 256 additions & 2 deletions test/bundler/css/css-modules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,223 @@ describe("css", () => {
},
});

// An `animation` or `animation-name` value with no typed form (it holds a
// `var()`, an `env()`, a `linear()`, or an `auto` before the name) is kept
// as raw tokens. Each keyframes name in it must get the hash of the
// `@keyframes` rule, and every other token must print as written.
// Rows are [declarations as written, declarations as printed]. `#` stands
// for the hash of the module.
const rawTokenAnimations: [written: string, printed: string][] = [
// The typed form, for comparison.
["animation: 1s reveal", "animation: 1s reveal_#"],

// var(), env(), linear(), and an `auto` before the name.
["animation: reveal var(--d) linear both", "animation: reveal_# var(--d) linear both"],
["animation: var(--d) reveal", "animation: var(--d) reveal_#"],
["animation: env(--x) reveal", "animation: env(--x) reveal_#"],
["animation: 1s linear(0, 0.5 50%, 1) reveal", "animation: 1s linear(0, .5 50%, 1) reveal_#"],
["animation: auto linear both reveal", "animation: auto linear both reveal_#"],
["animation: 1s reveal, var(--other)", "animation: 1s reveal_#, var(--other)"],
["animation: 1s reveal, auto linear both reveal", "animation: 1s reveal_#, auto linear both reveal_#"],
["animation: reveal var(--d) !important", "animation: reveal_# var(--d) !important"],
["-webkit-animation: reveal var(--d)", "-webkit-animation: reveal_# var(--d)"],
// From `src/style.module.css` of react-day-picker 9.14.0.
[
"animation: rdp-slide_in_left var(--rdp-animation_duration) var(--rdp-animation_timing) forwards",
"animation: rdp-slide_in_left_# var(--rdp-animation_duration) var(--rdp-animation_timing) forwards",
],

// A quoted name prints as a hashed ident.
['animation: "reveal" var(--d)', "animation: reveal_# var(--d)"],
['animation: var(--d) "reveal"', "animation: var(--d) reveal_#"],
['animation: env(--x) "reveal"', "animation: env(--x) reveal_#"],
['animation: var(--d) steps(4) "reveal"', "animation: var(--d) steps(4) reveal_#"],
['animation: "reveal", var(--other)', "animation: reveal_#, var(--other)"],
// With no space beside it, the ident would join the next token, so the
// quoted name stays as written.
['animation: "reveal"var(--d)', 'animation: "reveal"var(--d)'],
['animation: 1s"reveal" var(--d)', 'animation: 1s"reveal" var(--d)'],

// A dashed ident in the shorthand stays as written. It can be the name or
// a timeline, and no typed form is there to tell.
["animation: --reveal var(--d)", "animation: --reveal var(--d)"],
["animation: reveal var(--d) --timeline", "animation: reveal_# var(--d) --timeline"],
["animation: var(--name) 1s --timeline", "animation: var(--name) 1s --timeline"],

// The `animation-name` longhand. There, only `none` is a keyword.
["animation-name: reveal, var(--x)", "animation-name: reveal_#, var(--x)"],
['animation-name: var(--x), "reveal"', "animation-name: var(--x), reveal_#"],
["animation-name: --reveal, var(--x)", "animation-name: --reveal_#, var(--x)"],
["animation-name: linear, none, var(--x)", "animation-name: linear_#, none, var(--x)"],
["-webkit-animation-name: var(--x), reveal", "-webkit-animation-name: var(--x), reveal_#"],
// A name in the fallback of a `var()` stays as written, like the value of
// the custom property that replaces it.
["animation-name: var(--n, reveal)", "animation-name: var(--n, reveal)"],
["animation: var(--n, reveal) 1s", "animation: var(--n, reveal) 1s"],

// The order of the declarations in a block stays as written.
["animation: 1s reveal; animation: reveal var(--d)", "animation: 1s reveal_#; animation: reveal_# var(--d)"],

// Keywords of the other sub-properties are not names, in any case.
[
"animation: var(--d) LINEAR Ease-In Infinite Alternate BOTH Paused AUTO reveal",
"animation: var(--d) LINEAR Ease-In Infinite Alternate BOTH Paused AUTO reveal_#",
],
["animation: none var(--d)", "animation: none var(--d)"],
["animation: var(--whole)", "animation: var(--whole)"],
// A typed value takes the second `linear` as the name. Here the `var()`
// can hold a comma or an easing, so a keyword is never taken as a name.
["animation: 1s linear linear", "animation: 1s linear linear_#"],
["animation: linear var(--d) linear", "animation: linear var(--d) linear"],

// CSS-wide keywords (and `default`) are not names.
["animation: inherit", "animation: inherit"],
["animation: initial", "animation: initial"],
["animation: unset", "animation: unset"],
["animation: revert", "animation: revert"],
["animation: revert-layer", "animation: revert-layer"],
["animation: default var(--d)", "animation: default var(--d)"],
["animation-name: initial", "animation-name: initial"],

// The arguments of a function are not in a name position.
["animation: reveal 1s scroll(root block)", "animation: reveal_# 1s scroll(root block)"],
["animation: var(--d) steps(4, jump-none) reveal", "animation: var(--d) steps(4, jump-none) reveal_#"],
// A bare block is not valid in `animation`. An ident in it is tagged like
// the others.
["animation: [reveal] var(--d)", "animation: [reveal_#] var(--d)"],
];

/** The declarations of each `.r<index>` rule of `css`, keyed by the row as written. */
function printedRows(css: string, rows: [written: string, printed: string][]) {
const printed: Record<string, string> = {};
// Adjacent rules with equal declarations are merged into one selector list.
for (const [, selectors, body] of css.matchAll(/^(\.r\d[^{]*) \{\n([^}]*);\n\}/gm)) {
for (const [, index] of selectors.matchAll(/\.r(\d+)/g)) {
printed[rows[+index][0]] = body.trim().replaceAll(/;\n\s*/g, "; ");
}
}
return printed;
}

itBundled("css-module/AnimationNameScopedInRawTokens", {
files: {
"/entry.js": `
import styles from './styles.module.css';
console.log(styles);
`,
"/styles.module.css":
rawTokenAnimations.map(([written], index) => `.r${index} { ${written} }`).join("\n") +
"\n@keyframes reveal { to { opacity: 1 } }",
},
entryPoints: ["/entry.js"],
outdir: "/out",
onAfterBundle(api) {
const css = api.readFile("/out/entry.css");
const hash = css.match(/@keyframes reveal_([A-Za-z0-9_-]+) \{/)?.[1];
expect(hash, "@keyframes reveal should be scoped").toBeDefined();

expect(printedRows(css, rawTokenAnimations)).toEqual(
Object.fromEntries(rawTokenAnimations.map(([written, printed]) => [written, printed.replaceAll("#", hash!)])),
);
},
});

// The same tokens under `--minify-whitespace`.
itBundled("css-module/AnimationNameScopedInRawTokensMinified", {
files: {
"/entry.js": `
import styles from './styles.module.css';
console.log(styles);
`,
"/styles.module.css": `
.a { animation: 1s reveal var(--d) }
.b { animation: infinite "reveal" var(--d) }
.c { -webkit-animation: reveal var(--d) }
@keyframes reveal { to { opacity: 1 } }
`,
},
entryPoints: ["/entry.js"],
outdir: "/out",
minifyWhitespace: true,
onAfterBundle(api) {
const css = api.readFile("/out/entry.css");
const hash = css.match(/@keyframes reveal_([A-Za-z0-9_-]+)\{/)?.[1];
expect(hash, "@keyframes reveal should be scoped").toBeDefined();

expect(css.trim()).toBe(
[
`.a_${hash}{animation:1s reveal_${hash} var(--d)}`,
`.b_${hash}{animation:infinite reveal_${hash} var(--d)}`,
`.c_${hash}{-webkit-animation:reveal_${hash} var(--d)}`,
`@keyframes reveal_${hash}{to{opacity:1}}`,
].join(""),
);
},
});

// Every kind of block that holds declarations scopes the name. The minify
// pass does not visit `@page`, `style()` and keyframe blocks.
itBundled("css-module/AnimationNameScopedInRawTokensInEveryBlock", {
files: {
"/entry.js": `
import styles from './styles.module.css';
console.log(styles);
`,
"/styles.module.css": `
.card { & .inner { animation: reveal var(--d) } }
@media (min-width: 1px) { .a { animation: reveal var(--d) } }
@supports (display: grid) { .b { animation: reveal var(--d) } }
@layer base { .c { animation: reveal var(--d) } }
@container (min-width: 1px) { .d { animation: reveal var(--d) } }
@scope (.e) { .f { animation: reveal var(--d) } }
@starting-style { .g { animation: reveal var(--d) } }
@page { animation: reveal var(--d) }
@container style(animation: reveal var(--d)) { .h { color: red } }
@keyframes reveal { to { opacity: 1 } }
@keyframes other { to { animation: reveal var(--d) } }
`,
},
entryPoints: ["/entry.js"],
outdir: "/out",
onAfterBundle(api) {
const css = api.readFile("/out/entry.css");
const hash = css.match(/@keyframes reveal_([A-Za-z0-9_-]+) \{/)?.[1];
expect(hash, "@keyframes reveal should be scoped").toBeDefined();

const names = [...css.matchAll(/animation: (\S+) var\(--d\)/g)].map(([, name]) => name);
expect(names).toEqual(Array.from({ length: 10 }, () => `reveal_${hash}`));
},
});

// A file that is not a CSS module has no names to scope. Its raw tokens,
// the quotes of a name included, print as written.
const plainAnimations = [
"animation: reveal var(--d) linear both",
'animation: "reveal" var(--d)',
"animation: --reveal var(--d)",
"animation: auto linear both reveal",
'animation-name: "reveal", var(--x)',
"animation-name: --reveal, var(--x)",
"animation: inherit",
];
itBundled("css/AnimationRawTokensAsWritten", {
files: {
"/entry.js": `import './styles.css';`,
"/styles.css":
plainAnimations.map((written, index) => `.r${index} { ${written} }`).join("\n") +
"\n@keyframes reveal { to { opacity: 1 } }",
},
entryPoints: ["/entry.js"],
outdir: "/out",
onAfterBundle(api) {
const css = api.readFile("/out/entry.css");
expect(css).toContain("@keyframes reveal {");

const rows = plainAnimations.map((written): [string, string] => [written, written]);
expect(printedRows(css, rows)).toEqual(Object.fromEntries(rows));
},
});

// The parser dedupes repeated class/id names through a borrowed lookup
// (`add_symbol_for_name`); many references to the same names must all map
// to a single hashed symbol each.
Expand Down Expand Up @@ -529,8 +746,10 @@ describe("css", () => {
},
});

// Values the grammar rejects stay untouched, so a future keyword or a
// var() reference is not hashed as if it were a name.
// A `view-transition-*` value the grammar rejects stays untouched, so a
// future keyword or a var() reference is not hashed as if it were a name.
// (`animation` and `animation-name` do hash the names in such a value, see
// `css-module/AnimationNameScopedInRawTokens`.)
itBundled("css-module/ViewTransitionUnparsedValuesNotScoped", {
files: {
"/entry.js": `
Expand Down Expand Up @@ -630,6 +849,41 @@ test("css-module/NoBundleHashesClassSymbols", async () => {
expect(exitCode).toBe(0);
});

// `bun build --no-bundle` parses a module file through the transpiler, not
// the bundler. A keyframes name in raw tokens is scoped there too.
test("css-module/NoBundleAnimationNameScopedInRawTokens", async () => {
using dir = tempDir("css-module-no-bundle-animation", {
"styles.module.css": `
.card { animation: reveal var(--d); animation-name: var(--x), reveal }
@keyframes reveal { to { opacity: 1 } }
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "build", "--no-bundle", "styles.module.css"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const hash = stdout.match(/@keyframes reveal_([A-Za-z0-9_-]+) \{/)?.[1];
expect(hash, "@keyframes reveal should be scoped").toBeDefined();
expect(stdout).toEqualIgnoringWhitespace(`
.card_${hash} {
animation: reveal_${hash} var(--d);
animation-name: var(--x), reveal_${hash};
}

@keyframes reveal_${hash} {
to {
opacity: 1;
}
}
`);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});

// The hash comes from the path relative to the project root, as in a bundled
// build, so two module files with the same basename get different names.
test("css-module/NoBundleHashesByRelativePath", async () => {
Expand Down
Loading
Loading