From 79e2333a1895e46129991148ae6b0853dd219c14 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 25 Jul 2026 14:53:30 +0200 Subject: [PATCH 1/4] Let dwind express keyframes, pseudo-elements and arbitrary declarations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auditing every raw CSS string in the example site produced one finding: raw CSS was reached for because of the *selector* or the *at-rule*, never because of a property. Three additions close that gap. Everything is additive. dwkeyframes! ------------ A utility class is a single declaration block, so a @keyframes could never be one — the CSS binding pipeline has no at-rule support at all. Every crate worked around that with a hand-written &str blob pushed through stylesheet_raw: three of them, with no deduplication, no collision detection, and every keyframe paying its cost whether the page used it or not. dwkeyframes! { #[animation("900ms ease-out both")] fade_up { "from" => "opacity: 0; transform: translateY(14px);", "to" => "opacity: 1; transform: translateY(0);", } } This emits the at-rule *and* a compile-time-checked animate-* utility, so the class name is still resolved by rustc. The rule is injected the first time the class or the handle's Display is used, and never twice — which also makes format!("{FADE_UP_KEYFRAMES} 600ms {delay}ms") safe for shorthands composed at runtime. Names are namespaced by the consuming crate via env!("CARGO_CRATE_NAME"), so two crates declaring fade_up do not collide, and registering one name with two bodies now panics in debug rather than silently winning. Every CSS fragment is a string literal on purpose: Rust's lexer splits 0%, --sx and .35 in ways that do not round-trip through TokenStream::to_string(). dwind's spin/ping/pulse/bounce and dwui's five dwui-* keyframes now use it, with names pinned and injection timing unchanged. Arbitrary declarations ---------------------- .dwclass!("[mask-composite:exclude] [--sx:50%] hover:[color:red]") Unambiguous against the variant syntax because a variant's ] is always followed by :. This is provably non-breaking: a bracket group *without* a trailing colon previously failed every parser and was discarded silently along with every class after it, so dwclass!("foo [a:b] bar") yielded one class rather than three. That truncation is fixed, and a bracket group with no colon is now a compile error with a message. Pseudo-elements --------------- [&::before]: variants already parsed, but a ::before with no content never generates a box, so the utility did nothing. dwind now emits content: "" when a variant's last compound targets ::before/::after. DomBuilder::raw appends rather than replaces, so a user's own content declaration later in the class still wins — none of Tailwind's --tw-content indirection is needed. Also fixes render_generator, which ignored the bracketed variant entirely: [&::before]:bg-color-[red] compiled and styled the element itself. The one behaviour change: before:/after: previously rendered as legacy single-colon :before. They now render ::before — equivalent in every engine — and gain generated content. Tests ----- dwind-macros had no tests at all. It now has 29, covering the grammar and for the first time codegen, with the pre-existing shapes pinned first so the parser change could be shown not to move them. New browser suite in crates/dwui/tests/styling.rs asserts lazy injection, deduplication, that ::before actually materialises, and that arbitrary declarations reach getComputedStyle. All 32 existing dwui browser tests still pass, and the example site renders identically: 19 routes clean, palette and pointer effects intact. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 96 ++++++ Cargo.lock | 8 +- crates/dwind-base/Cargo.toml | 2 +- crates/dwind-base/src/keyframes.rs | 175 ++++++++++ crates/dwind-base/src/lib.rs | 1 + crates/dwind-macros/Cargo.toml | 4 +- crates/dwind-macros/src/codegen/mod.rs | 316 ++++++++++++++++- crates/dwind-macros/src/grammar/mod.rs | 237 ++++++++++++- crates/dwind-macros/src/keyframes/codegen.rs | 156 +++++++++ crates/dwind-macros/src/keyframes/mod.rs | 201 +++++++++++ crates/dwind-macros/src/lib.rs | 76 +++++ crates/dwind/Cargo.toml | 6 +- crates/dwind/resources/css/effects.css | 15 +- crates/dwind/resources/css/interactivity.css | 13 +- crates/dwind/resources/css/layout.css | 5 + crates/dwind/resources/css/transition.css | 13 +- crates/dwind/resources/css/typography.css | 40 ++- crates/dwind/src/modules/animations.rs | 82 +++-- crates/dwui/Cargo.toml | 13 +- crates/dwui/src/theme/mod.rs | 75 ++-- crates/dwui/tests/styling.rs | 342 +++++++++++++++++++ examples/webpage/Cargo.toml | 6 +- 22 files changed, 1775 insertions(+), 107 deletions(-) create mode 100644 crates/dwind-base/src/keyframes.rs create mode 100644 crates/dwind-macros/src/keyframes/codegen.rs create mode 100644 crates/dwind-macros/src/keyframes/mod.rs create mode 100644 crates/dwui/tests/styling.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 99246e1..a092881 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,101 @@ # dwind changelog +## dwind 0.8.0 / dwind-macros 0.5.0 / dwind-base 0.1.2 / dwui 0.9.1 - 2026-07-25 + +Everything here is additive. The theme: raw CSS in an application was almost +always needed because of a *selector* or an *at-rule*, never because of a +property. These three additions close that gap. + +### `dwkeyframes!` — declare animations without a raw stylesheet + +Utility classes are single declaration blocks, so a `@keyframes` could never be +one; the CSS binding pipeline has no at-rule support at all. Every crate worked +around it with a hand-written `&str` blob pushed through `stylesheet_raw` — no +deduplication, no collision detection, and every keyframe paying its cost whether +the page used it or not. + +```rust +dwkeyframes! { + #[animation("900ms cubic-bezier(0.16, 1, 0.3, 1) both")] + fade_up { + "from" => "opacity: 0; transform: translateY(14px);", + "to" => "opacity: 1; transform: translateY(0);", + } +} + +html!("div", { .dwclass!("animate-fade-up") }) +``` + +- Emits the at-rule *and* a compile-time-checked `animate-*` utility, so a typo + in the class name is still a build error. +- The rule is injected the first time the class — or the handle's `Display` — + is used, and never twice. `format!("{FADE_UP_KEYFRAMES} 600ms {delay}ms")` + registers as a side effect, for shorthands composed at runtime. +- Names are namespaced by the consuming crate by default (`#![prefix = "..."]` + to override, `#[name = "..."]` to pin an exact name). Registering one name with + two different bodies now panics in debug builds instead of silently winning. +- New `dwind::prelude::keyframes` module (`Keyframes`, `register`, + `is_registered`, `registered_css`) backs it. +- dwind's own `spin`/`ping`/`pulse`/`bounce` and dwui's five `dwui-*` keyframes + now use it. Names and injection timing are unchanged; + `append_animation_keyframe_style()` keeps its signature, and + `dwui::theme::apply_style_sheet` is now idempotent. + +### Arbitrary declarations — `[property:value]` + +The escape hatch for properties with no utility, matching Tailwind: + +```rust +.dwclass!("[mask-composite:exclude] [--sx:50%] hover:[color:red]") +``` + +Underscores in the value become spaces (`[transition:opacity_650ms_ease]`), since +a class string is space-separated. The property side is left alone so custom +properties keep their underscores. + +Unambiguous against the variant syntax because a variant's `]` is always followed +by `:`. Previously a bracket group *without* a trailing colon failed every parser +and was silently discarded **along with every class after it** — so +`dwclass!("foo [a:b] bar")` yielded one class, not three. That truncation is +fixed, and a bracket group with no colon at all is now a compile error with a +message instead of silence. + +### Pseudo-elements that actually render + +`[&::before]:` variants already parsed, but a `::before` with no `content` never +generates a box, so the utility did nothing on its own. dwind now emits +`content: ""` for any variant whose last compound targets `::before`/`::after`. +Because `DomBuilder::raw` appends rather than replaces, your own `content-[...]` +later in the same class still wins — no `--tw-content` indirection needed. + +Added shorthands: `before:`, `after:`, `placeholder:`, `marker:`, `selection:`, +`backdrop:`, `first-letter:`, `first-line:`. + +**Behaviour change:** `before:`/`after:` previously rendered as the legacy +single-colon `:before`. They now render `::before` (equivalent in every engine) +*and* gain generated content. If you wrote `before:` and supplied `content` +elsewhere, check it. This is the only currently-working input whose meaning +changes. + +**Bug fix:** `render_generator` ignored the bracketed variant entirely, so +`[&::before]:bg-color-[red]` compiled and silently styled the element itself. +Generators now honour variants. + +### New utilities + +`delay-0`…`delay-1000`, `underline` / `overline` / `line-through` / +`no-underline`, `tracking-tighter`…`tracking-widest`, `whitespace-*`, +`list-none` / `list-disc` / `list-decimal`, `content-empty` / `content-none`, +`font-inherit`, `isolate` / `isolation-auto`, `mix-blend-*`, `will-change-*`, +`outline-none` / `outline-hidden`. + +### Tests + +`dwind-macros` had no tests at all; it now has 29 covering the grammar and, for +the first time, codegen. New browser suite at `crates/dwui/tests/styling.rs` +asserts lazy injection, deduplication, that `::before` materialises, and that +arbitrary declarations reach `getComputedStyle`. + ## dwui 0.9.0 - 2026-06-10 ### Heavy components diff --git a/Cargo.lock b/Cargo.lock index de45e8e..c58571e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -230,7 +230,7 @@ dependencies = [ [[package]] name = "dwind" -version = "0.7.0" +version = "0.8.0" dependencies = [ "const_format", "dominator", @@ -247,7 +247,7 @@ dependencies = [ [[package]] name = "dwind-base" -version = "0.1.1" +version = "0.1.2" dependencies = [ "dominator", "futures-signals", @@ -263,7 +263,7 @@ dependencies = [ [[package]] name = "dwind-macros" -version = "0.4.0" +version = "0.5.0" dependencies = [ "const_format", "dominator", @@ -279,7 +279,7 @@ dependencies = [ [[package]] name = "dwui" -version = "0.9.0" +version = "0.9.1" dependencies = [ "const_format", "dominator", diff --git a/crates/dwind-base/Cargo.toml b/crates/dwind-base/Cargo.toml index c5f1c71..1f0bbd4 100644 --- a/crates/dwind-base/Cargo.toml +++ b/crates/dwind-base/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dwind-base" -version = "0.1.1" +version = "0.1.2" edition = "2021" description = "DWIND base library used by the dwind generated code" homepage = "https://github.com/JedimEmO/dwind" diff --git a/crates/dwind-base/src/keyframes.rs b/crates/dwind-base/src/keyframes.rs new file mode 100644 index 0000000..3242f15 --- /dev/null +++ b/crates/dwind-base/src/keyframes.rs @@ -0,0 +1,175 @@ +//! Runtime registry for `@keyframes` rules. +//! +//! dwind's utility classes are generated as single declaration blocks, and the +//! CSS binding pipeline has no notion of at-rules — so a `@keyframes` cannot be +//! expressed as a utility. Historically every crate worked around that by +//! hand-writing a `&str` blob and pushing it through +//! [`dominator::stylesheet_raw`] at startup, which meant no deduplication, no +//! collision detection, and every keyframe paying its cost whether or not the +//! page used it. +//! +//! This module is the shared alternative. A [`Keyframes`] is a `const`- +//! constructible handle that injects its rule the first time anything asks for +//! its name, and never again. +//! +//! Prefer declaring these with the `dwkeyframes!` macro rather than by hand — +//! it mints the matching `animate-*` utility class at the same time, so the +//! class name stays checked by rustc. + +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; + +/// Every keyframe name injected so far, mapped to the body it was injected +/// with. Both constructors are `const`, so this needs no lazy-init machinery. +static REGISTRY: Mutex> = Mutex::new(BTreeMap::new()); + +/// A declared `@keyframes` rule. +/// +/// The rule is injected lazily — constructing a `Keyframes` costs nothing, and +/// the stylesheet is only touched once something calls [`Keyframes::name`], +/// [`Keyframes::ensure`], or formats the handle with `{}`. +/// +/// ```ignore +/// static FADE: Keyframes = Keyframes::new("app-fade", "from{opacity:0;}to{opacity:1;}"); +/// +/// // `Display` registers, so composed shorthands work without ceremony: +/// el.style("animation", &format!("{FADE} 600ms ease-out both")); +/// ``` +pub struct Keyframes { + name: &'static str, + body: &'static str, + injected: AtomicBool, +} + +impl Keyframes { + pub const fn new(name: &'static str, body: &'static str) -> Self { + Self { + name, + body, + injected: AtomicBool::new(false), + } + } + + /// Injects the rule if it has not been injected yet, then returns the CSS + /// keyframe name for use in an `animation` shorthand. + pub fn name(&self) -> &'static str { + self.ensure(); + self.name + } + + /// The keyframe name *without* injecting the rule. + /// + /// Only useful in `const` contexts. If you are building an `animation` + /// value at runtime, use [`Keyframes::name`] or `{}` formatting instead so + /// the rule actually reaches the document. + pub const fn name_unregistered(&self) -> &'static str { + self.name + } + + pub const fn body(&self) -> &'static str { + self.body + } + + /// Injects the rule. Idempotent, and after the first call this is a single + /// relaxed atomic load. + pub fn ensure(&self) { + if self.injected.load(Ordering::Relaxed) { + return; + } + + register(self.name, self.body); + self.injected.store(true, Ordering::Relaxed); + } + + /// The full rule text, for server-side rendering or debugging. Does not + /// inject anything. + pub fn css(&self) -> String { + format!("@keyframes {} {{ {} }}", self.name, self.body) + } +} + +impl std::fmt::Display for Keyframes { + /// Writes the keyframe name — and registers the rule as a side effect, so + /// that `format!("{KEYFRAMES} 1s linear")` cannot produce a shorthand + /// pointing at a rule that was never injected. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.name()) + } +} + +/// Injects `@keyframes {name} { {body} }` unless `name` is already registered. +/// +/// Registering the same name twice with different bodies is a bug — two crates +/// have picked the same keyframe name and one is silently winning. In debug +/// builds that panics; in release the first registration stands. +pub fn register(name: &'static str, body: &'static str) { + let mut registry = match REGISTRY.lock() { + Ok(registry) => registry, + // A poisoned lock means an earlier registration panicked. Injecting + // styles is not worth propagating that. + Err(poisoned) => poisoned.into_inner(), + }; + + if let Some(existing) = registry.get(name) { + debug_assert!( + *existing == body, + "@keyframes `{name}` was registered twice with different bodies. \ + Give one of them a distinct name — dwkeyframes! namespaces by crate \ + unless you override it with #[name = \"...\"]." + ); + + return; + } + + registry.insert(name, body); + dominator::stylesheet_raw(format!("@keyframes {name} {{ {body} }}")); +} + +/// Whether `name` has already been injected. +pub fn is_registered(name: &str) -> bool { + match REGISTRY.lock() { + Ok(registry) => registry.contains_key(name), + Err(poisoned) => poisoned.into_inner().contains_key(name), + } +} + +/// Every rule injected so far, concatenated. Intended for prerendering. +pub fn registered_css() -> String { + let registry = match REGISTRY.lock() { + Ok(registry) => registry, + Err(poisoned) => poisoned.into_inner(), + }; + + registry + .iter() + .map(|(name, body)| format!("@keyframes {name} {{ {body} }}")) + .collect::>() + .join("\n") +} + +#[cfg(test)] +mod test { + use super::*; + + // These exercise the bookkeeping only. `register` reaches into the DOM, so + // the injection itself is covered by the browser tests in `dwui`. + + #[test] + fn css_renders_a_complete_rule() { + let kf = Keyframes::new("test-fade", "from{opacity:0;}to{opacity:1;}"); + + assert_eq!( + kf.css(), + "@keyframes test-fade { from{opacity:0;}to{opacity:1;} }" + ); + } + + #[test] + fn name_unregistered_does_not_register() { + let kf = Keyframes::new("test-untouched", "from{opacity:0;}"); + + assert_eq!(kf.name_unregistered(), "test-untouched"); + assert!(!is_registered("test-untouched")); + } +} diff --git a/crates/dwind-base/src/lib.rs b/crates/dwind-base/src/lib.rs index 05124c7..7bb60e7 100644 --- a/crates/dwind-base/src/lib.rs +++ b/crates/dwind-base/src/lib.rs @@ -1 +1,2 @@ +pub mod keyframes; pub mod media_queries; diff --git a/crates/dwind-macros/Cargo.toml b/crates/dwind-macros/Cargo.toml index 069562b..e9caf6c 100644 --- a/crates/dwind-macros/Cargo.toml +++ b/crates/dwind-macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dwind-macros" -version = "0.4.0" +version = "0.5.0" edition = "2021" description = "Macros used by the dwind crate for applying dominator classes to components using a custom language" homepage = "https://github.com/JedimEmO/dwind" @@ -13,7 +13,7 @@ proc-macro = true [dependencies] dominator = { workspace = true } -dwind-base = { path = "../dwind-base", version = "0.1.1" } +dwind-base = { path = "../dwind-base", version = "0.1.2" } nom = { workspace = true } proc-macro2 = { workspace = true } quote = { workspace = true } diff --git a/crates/dwind-macros/src/codegen/mod.rs b/crates/dwind-macros/src/codegen/mod.rs index bc1458b..6ff2a00 100644 --- a/crates/dwind-macros/src/codegen/mod.rs +++ b/crates/dwind-macros/src/codegen/mod.rs @@ -17,7 +17,88 @@ pub fn render_classes( .collect::>() } +/// Shorthands for the pseudo-*elements*. +/// +/// Pseudo-classes need no table — they already pass through verbatim. These are +/// listed because a pseudo-element needs the `::` form, and writing +/// `[&::before]:` for something this common is a lot of punctuation. +/// +/// Each entry maps to the selector with a *leading* colon, because the caller +/// joins with `:` and so contributes the other one. +fn pseudo_element_alias(name: &str) -> Option<&'static str> { + Some(match name { + "before" => ":before", + "after" => ":after", + "placeholder" => ":placeholder", + "marker" => ":marker", + "selection" => ":selection", + "backdrop" => ":backdrop", + "first-letter" | "first_letter" => ":first-letter", + "first-line" | "first_line" => ":first-line", + _ => return None, + }) +} + +/// Builds the selector handed to `dominator::pseudo!` from a bracketed variant +/// and any pseudo-class prefixes. +fn build_pseudo_selector(variant: &Option, pseudo_classes: &[String]) -> String { + let variant = variant.clone().unwrap_or_default(); + + if pseudo_classes.is_empty() { + return variant; + } + + let pseudo_classes = pseudo_classes + .iter() + .map(|name| { + pseudo_element_alias(name) + .map(str::to_string) + .unwrap_or_else(|| name.clone()) + }) + .collect::>() + .join(":"); + + format!("{variant}:{pseudo_classes}") +} + +/// Whether the selector's last compound targets a pseudo-element that has no +/// content of its own, and therefore will not render without one. +/// +/// Checks the *last* compound so that `::before:hover` — which is how +/// `[&::before]:hover:…` renders — is still caught. +fn needs_generated_content(selector: &str) -> bool { + let last_compound = selector + .rsplit(|c: char| c.is_whitespace() || c == '>' || c == '+' || c == '~') + .next() + .unwrap_or(selector); + + last_compound.contains("::before") || last_compound.contains("::after") +} + +/// `::before` and `::after` do not render without a `content`. dwind emits an +/// empty one so the utility is enough on its own. +/// +/// This lands *before* the class body, and `DomBuilder::raw` appends rather than +/// replaces, so a user's own `content` declaration later in the same class still +/// wins. That is why this needs none of Tailwind's `--tw-content` indirection — +/// Tailwind needs it because its variants are static stylesheet rules with fixed +/// source order. +fn generated_content(selector: &str) -> TokenStream { + if needs_generated_content(selector) { + quote! { .raw("content: \"\";") } + } else { + quote! {} + } +} + pub fn render_generate_dwind_class(class_name: String, class: DwindClassSelector) -> TokenStream { + assert!( + !class.is_arbitrary(), + "dwgenerate! cannot name an arbitrary declaration — it has no reusable \ + class to alias. Write the declaration inline with dwclass!, or add a \ + generator macro and use `{class_name}-[value]`." + ); + let ident = Ident::new( class_name_to_struct_identifier(&class_name).as_str(), Span::call_site(), @@ -53,11 +134,83 @@ pub fn render_generate_dwind_class(class_name: String, class: DwindClassSelector } } +/// Turns `mask-composite:exclude` into `mask-composite: exclude;`. +/// +/// Underscores in the *value* become spaces, the way Tailwind handles arbitrary +/// values — a class string is space-separated, so a literal space cannot appear +/// there. The property is left alone, since custom properties such as +/// `--my_var` legitimately contain underscores. +fn normalise_declaration(declaration: &str) -> String { + let Some((property, value)) = declaration.split_once(':') else { + panic!( + "`[{declaration}]` is not a CSS declaration — expected `[property:value]`, \ + for example `[mask-composite:exclude]`. If you meant a variant selector, \ + it needs a class after it: `[{declaration}]:some-class`." + ); + }; + + let property = property.trim(); + let value = value.trim().replace('_', " "); + + if property.is_empty() || value.is_empty() { + panic!("`[{declaration}]` has an empty property or value"); + } + + format!("{property}: {value};") +} + +/// A readable, identifier-safe prefix for the generated class name. Cosmetic — +/// it only ever shows up in devtools. +fn declaration_prefix(declaration: &str) -> String { + let slug = declaration + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect::(); + + let slug = slug.trim_matches('_').to_string(); + let slug: String = slug.chars().take(40).collect(); + + if slug.is_empty() { + "arbitrary".to_string() + } else { + slug + } +} + pub fn render_dwind_class( class: DwindClassSelector, ) -> (TokenStream, Option, bool) { let breakpoint = class.get_breakpoint(); + if let Some(declaration) = &class.arbitrary { + let css = normalise_declaration(declaration); + let class_prefix = declaration_prefix(declaration); + + let tokens = if class.pseudo_classes.is_empty() && class.variant.is_none() { + quote! { + dominator::class! { + # ! [prefix=#class_prefix] + .raw(#css) + } + } + } else { + let pseudo_selector = build_pseudo_selector(&class.variant, &class.pseudo_classes); + let content = generated_content(&pseudo_selector); + + quote! { + dominator::class! { + # ! [prefix=#class_prefix] + .dominator::pseudo!(#pseudo_selector, { + #content + .raw(#css) + }) + } + } + }; + + return (tokens, breakpoint, true); + } + if class.is_generator() { return (render_generator(class), breakpoint, true); } @@ -70,15 +223,8 @@ pub fn render_dwind_class( (quote! { &* #class_ident }, breakpoint, false) } else { - let pseudo_selector = if class.pseudo_classes.is_empty() { - class.variant.unwrap_or("".to_string()) - } else { - format!( - "{}:{}", - class.variant.clone().unwrap_or("".to_string()), - class.pseudo_classes.join(":") - ) - }; + let pseudo_selector = build_pseudo_selector(&class.variant, &class.pseudo_classes); + let content = generated_content(&pseudo_selector); let class_raw_ident = Ident::new( &class_name_to_raw_identifier(&class.class_name), @@ -92,6 +238,7 @@ pub fn render_dwind_class( dominator::class! { # ! [prefix=#class_prefix] .dominator::pseudo!(#pseudo_selector, { + #content .raw(&* #class_raw_ident) }) } @@ -109,7 +256,10 @@ pub fn render_generator(class: DwindClassSelector) -> TokenStream { let generator_classname = format!("{}{}", class.class_name, class.generator_params.join("")); let generator_call = render_generator_call(&class); - if class.pseudo_classes.is_empty() { + // Both a bracketed variant and pseudo-class prefixes have to reach the + // selector here. Only checking `pseudo_classes` used to drop the variant + // silently, so `[&::before]:bg-color-[red]` styled the element itself. + if class.pseudo_classes.is_empty() && class.variant.is_none() { let class_prefix = sanitize_class_prefix(&generator_name); quote! { dominator::class! { @@ -117,13 +267,15 @@ pub fn render_generator(class: DwindClassSelector) -> TokenStream { .raw(#generator_call) }} } else { - let pseudo_selector = format!(":{}", class.pseudo_classes.join(":")); + let pseudo_selector = build_pseudo_selector(&class.variant, &class.pseudo_classes); + let content = generated_content(&pseudo_selector); let class_prefix = sanitize_class_prefix(&generator_classname); quote! { dominator::class! { # ! [prefix=#class_prefix] .dominator::pseudo!(#pseudo_selector, { + #content .raw( #generator_call ) }) } @@ -148,3 +300,145 @@ pub struct BreakpointInfo { pub modifier: Option, pub is_media_query: bool, } + +#[cfg(test)] +mod test { + use super::*; + use crate::grammar::parse_class_string; + + /// Renders the first class of a `dwclass!` string to a token string. + /// + /// Assertions below use `contains` rather than whole-token snapshots on + /// purpose: `TokenStream::to_string()` spacing is not a stable contract and + /// snapshots would churn on every `quote!` tweak. + fn render(input: &str) -> String { + let mut classes = parse_class_string(input).unwrap(); + + assert_eq!(classes.len(), 1, "expected exactly one class in {input:?}"); + + render_dwind_class(classes.remove(0)).0.to_string() + } + + #[test] + fn pseudo_elements_get_generated_content() { + let rendered = render("[&::before]:opacity-0"); + + assert!(rendered.contains("\"::before\""), "{rendered}"); + assert!(rendered.contains("content"), "{rendered}"); + } + + #[test] + fn pseudo_classes_do_not_get_generated_content() { + let rendered = render("hover:opacity-0"); + + assert!(rendered.contains("\":hover\""), "{rendered}"); + assert!(!rendered.contains("content"), "{rendered}"); + } + + #[test] + fn content_follows_the_last_compound_not_the_first() { + // `[&::before]:hover:x` renders as `::before:hover` — the pseudo-element + // is still the thing being generated, so content is still required. + let rendered = render("[&::before]:hover:opacity-0"); + assert!(rendered.contains("content"), "{rendered}"); + + // A descendant of a pseudo-element cannot exist, but a pseudo-element + // mentioned in an *earlier* compound must not trigger injection. + assert!(!needs_generated_content("::before p")); + assert!(!needs_generated_content("::before > span")); + assert!(needs_generated_content("::after")); + } + + #[test] + fn before_and_after_aliases_expand_to_pseudo_elements() { + let rendered = render("before:opacity-0"); + + assert!(rendered.contains("\"::before\""), "{rendered}"); + assert!(rendered.contains("content"), "{rendered}"); + + let rendered = render("after:opacity-0"); + assert!(rendered.contains("\"::after\""), "{rendered}"); + } + + #[test] + fn placeholder_alias_expands_but_needs_no_content() { + let rendered = render("placeholder:opacity-0"); + + assert!(rendered.contains("\"::placeholder\""), "{rendered}"); + assert!(!rendered.contains("content"), "{rendered}"); + } + + #[test] + fn generators_keep_their_variant() { + // Regression: `render_generator` used to consider only `pseudo_classes`, + // so the bracketed variant was dropped and the style landed on the + // element itself. + let rendered = render("[&::before]:padding-[4px]"); + + assert!(rendered.contains("pseudo"), "{rendered}"); + assert!(rendered.contains("\"::before\""), "{rendered}"); + assert!(rendered.contains("content"), "{rendered}"); + } + + #[test] + fn plain_classes_stay_a_bare_reference() { + let rendered = render("opacity-0"); + + assert!(rendered.contains("OPACITY_0"), "{rendered}"); + assert!(!rendered.contains("pseudo"), "{rendered}"); + } + + #[test] + fn arbitrary_declarations_are_normalised() { + let rendered = render("[mask-composite:exclude]"); + + assert!( + rendered.contains("\"mask-composite: exclude;\""), + "{rendered}" + ); + assert!(!rendered.contains("pseudo"), "{rendered}"); + } + + #[test] + fn arbitrary_declaration_underscores_become_spaces_in_the_value_only() { + // A class string is space-separated, so a literal space cannot appear + // in one; `_` is the Tailwind-compatible stand-in. + let rendered = render("[transition:opacity_650ms_ease]"); + assert!( + rendered.contains("\"transition: opacity 650ms ease;\""), + "{rendered}" + ); + + // Custom properties legitimately contain underscores, so the property + // side is left alone. + let rendered = render("[--my_var:red]"); + assert!(rendered.contains("\"--my_var: red;\""), "{rendered}"); + } + + #[test] + fn arbitrary_declarations_honour_modifiers() { + let rendered = render("hover:[color:red]"); + assert!(rendered.contains("\":hover\""), "{rendered}"); + assert!(rendered.contains("\"color: red;\""), "{rendered}"); + + let rendered = render("[&::after]:[mask-composite:exclude]"); + assert!(rendered.contains("\"::after\""), "{rendered}"); + assert!(rendered.contains("content"), "{rendered}"); + } + + #[test] + #[should_panic(expected = "is not a CSS declaration")] + fn a_bracket_group_with_no_colon_is_a_clear_error() { + // Previously this shape was silently dropped along with everything + // after it. Now it says what is wrong. + render("[nonsense]"); + } + + #[test] + fn child_variants_are_unchanged() { + let rendered = render("[& > *]:opacity-0"); + + assert!(rendered.contains("\" > *\""), "{rendered}"); + assert!(!rendered.contains("content"), "{rendered}"); + } +} diff --git a/crates/dwind-macros/src/grammar/mod.rs b/crates/dwind-macros/src/grammar/mod.rs index a35d0c5..fb57b43 100644 --- a/crates/dwind-macros/src/grammar/mod.rs +++ b/crates/dwind-macros/src/grammar/mod.rs @@ -18,6 +18,12 @@ pub struct DwindClassSelector { /// Variants are the first pseudo selector, bracketed with [] /// [& > *]:nth-child(2):bg-red-500 pub variant: Option, + /// An arbitrary CSS declaration, written in place of a class name: + /// `[mask-composite:exclude]`, `[--sx:50%]`. + /// + /// The escape hatch for properties with no utility. Unambiguous against + /// the variant syntax because a variant's `]` is always followed by `:`. + pub arbitrary: Option, } impl DwindClassSelector { @@ -25,6 +31,10 @@ impl DwindClassSelector { !self.generator_params.is_empty() } + pub fn is_arbitrary(&self) -> bool { + self.arbitrary.is_some() + } + pub fn get_breakpoint(&self) -> Option { let breakpoints = self .conditionals @@ -73,7 +83,7 @@ pub fn parse_class_string(input: &str) -> Result, ()> { Ok(classes .into_iter() - .map(|(variant, prefixes, class_name, generator_params)| { + .map(|(variant, prefixes, body, generator_params)| { let pseudo_classes: Vec = prefixes .clone() .into_iter() @@ -93,26 +103,59 @@ pub fn parse_class_string(input: &str) -> Result, ()> { .map(|v| v.to_string()) .collect(); + let (class_name, arbitrary) = match body { + ClassBody::Name(name) => (name.to_string().replace('-', "_"), None), + ClassBody::Arbitrary(decl) => (String::new(), Some(decl)), + }; + DwindClassSelector { - class_name: class_name.to_string().replace('-', "_"), + class_name, pseudo_classes, conditionals, generator_params, variant, + arbitrary, } }) .collect()) } +/// What sits in the class-name position: either a utility name, or an arbitrary +/// declaration written inline. +#[derive(Debug)] +pub enum ClassBody<'a> { + Name(&'a str), + Arbitrary(String), +} + +/// Tried in the class-name position, *after* `variant_selector` and any +/// pseudo-class prefixes have been consumed. That ordering is what makes the +/// two bracket syntaxes unambiguous: a variant's `]` is always followed by `:`, +/// so anything still bracketed at this point is a declaration. +fn class_body(input: &str) -> IResult<&str, ClassBody<'_>> { + alt(( + |v| arbitrary_declaration(v).map(|(rest, decl)| (rest, ClassBody::Arbitrary(decl))), + |v| css_identifier(v).map(|(rest, name)| (rest, ClassBody::Name(name))), + ))(input) +} + fn selectors( input: &str, -) -> IResult<&str, Vec<(Option, Vec, &str, Option>)>> { +) -> IResult< + &str, + Vec<( + Option, + Vec, + ClassBody<'_>, + Option>, + )>, +> { let prefixes = many0(pseudo_selector); let parser = terminated( nom::sequence::tuple(( variant_selector, prefixes, - css_identifier, + class_body, opt(generator_parameters), )), opt(tag(" ")), @@ -123,7 +166,7 @@ fn selectors( pub fn parse_selector(input: &str) -> IResult<&str, DwindClassSelector> { let (input, variant) = variant_selector(input)?; let (input, prefixes) = many0(pseudo_selector)(input)?; - let (input, identifier) = css_identifier(input)?; + let (input, body) = class_body(input)?; let generator_params = if let Ok((_input, generator_params)) = generator_parameters(input) { generator_params @@ -148,10 +191,15 @@ pub fn parse_selector(input: &str) -> IResult<&str, DwindClassSelector> { .map(|v| v.to_string()) .collect(); + let (class_name, arbitrary) = match body { + ClassBody::Name(name) => (name.to_string().replace('-', "_"), None), + ClassBody::Arbitrary(decl) => (String::new(), Some(decl)), + }; + Ok(( input, DwindClassSelector { - class_name: identifier.to_string().replace('-', "_"), + class_name, pseudo_classes, conditionals, generator_params: generator_params @@ -159,6 +207,7 @@ pub fn parse_selector(input: &str) -> IResult<&str, DwindClassSelector> { .map(|v| v.to_string()) .collect(), variant, + arbitrary, }, )) } @@ -196,6 +245,32 @@ const CHARS_EXT: [char; 13] = [ '_', '-', '@', ',', '<', '>', '*', ' ', '.', ' ', ':', '#', '&', ]; +/// Characters permitted inside an arbitrary declaration, `[prop:value]`. +/// +/// Deliberately a separate set from [`CHARS_EXT`]: selectors never need `%`, +/// `/`, `+`, `=`, quotes or `;`, and declaration values need all of them. +const DECL_CHARS: [char; 22] = [ + '_', '-', '.', '#', '%', '/', '+', '=', '"', '\'', ',', ':', ';', '@', '*', '<', '>', '&', '$', + '!', '~', ' ', +]; + +fn declaration_body<'a>(input: &'a str) -> IResult<&'a str, String> { + many0(alt(( + bracketed("(", ")", declaration_body), + |v: &'a str| { + take_while1(is_extended_alphanumeric(DECL_CHARS.to_vec()))(v) + .map(move |v| (v.0, v.1.to_string())) + }, + )))(input) + .map(|r| (r.0, r.1.join(""))) +} + +/// `[mask-composite:exclude]`, `[--sx:50%]`, +/// `[grid-template-columns:repeat(2,minmax(0,1fr))]`. +fn arbitrary_declaration(input: &str) -> IResult<&str, String> { + delimited(tag("["), declaration_body, tag("]"))(input) +} + fn bracketed<'a>( bracket: &'a str, bracket_end: &'a str, @@ -292,6 +367,7 @@ mod test { conditionals: vec!["@sm".to_string(), "@is[dark]".to_string()], generator_params: vec![], variant: None, + ..Default::default() }] ); } @@ -305,6 +381,7 @@ mod test { conditionals: vec![], generator_params: vec!["5px".to_string()], variant: None, + ..Default::default() }] ); @@ -316,6 +393,7 @@ mod test { conditionals: vec![], generator_params: vec![], variant: None, + ..Default::default() }] ); @@ -327,6 +405,7 @@ mod test { conditionals: vec![], generator_params: vec!["1/2".to_string()], variant: None, + ..Default::default() }] ); } @@ -381,4 +460,150 @@ mod test { let parsed = parse_class_string("[& > *:is(span):hover]:is(p):b").unwrap(); assert_eq!(parsed[0].variant, Some(" > *:is(span):hover".to_string())); } + + // ----------------------------------------------------------------------- + // Regression locks. + // + // These pin the shapes that appear in the docs and in the example app, so + // that changes to the bracket handling cannot quietly alter what an + // existing `dwclass!` string means. + // ----------------------------------------------------------------------- + + #[test] + fn pins_documented_variant_forms() { + // From the Pseudoclasses docs page. + let parsed = parse_class_string("[& > *]:nth-child(2):bg-candlelight-500").unwrap(); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].variant, Some(" > *".to_string())); + assert_eq!(parsed[0].pseudo_classes, vec!["nth-child(2)".to_string()]); + assert_eq!(parsed[0].class_name, "bg_candlelight_500"); + + // A variant with no leading `&`. + let parsed = parse_class_string("[> span]:text-apple-300").unwrap(); + assert_eq!(parsed[0].variant, Some("> span".to_string())); + assert_eq!(parsed[0].class_name, "text_apple_300"); + } + + #[test] + fn pins_pseudo_element_variants() { + // Already supported today: `CHARS_EXT` includes `:`, so a `::`-prefixed + // variant parses and reaches `dominator::pseudo!` unchanged. + let parsed = parse_class_string("[&::before]:opacity-0").unwrap(); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].variant, Some("::before".to_string())); + assert_eq!(parsed[0].class_name, "opacity_0"); + + // Parent-state driven child selector, as used by the scroll reveal. + let parsed = parse_class_string("[&.reveal-in > *]:opacity-100").unwrap(); + assert_eq!(parsed[0].variant, Some(".reveal-in > *".to_string())); + } + + #[test] + fn pins_breakpoint_forms() { + let parsed = parse_class_string("@sm:flex-row").unwrap(); + assert_eq!(parsed[0].conditionals, vec!["@sm".to_string()]); + + let parsed = parse_class_string("@ *]:opacity-0").unwrap(); + assert_eq!(parsed[0].variant, Some(" > *".to_string())); + assert_eq!(parsed[0].arbitrary, None); + assert_eq!(parsed[0].class_name, "opacity_0"); + } + + #[test] + fn arbitrary_declarations_cover_the_awkward_characters() { + // Custom properties, percentages, and a leading double dash. + let parsed = parse_class_string("[--sx:50%]").unwrap(); + assert_eq!(parsed[0].arbitrary, Some("--sx:50%".to_string())); + + // Nested parens and commas. + let parsed = parse_class_string("[grid-template-columns:repeat(2,minmax(0,1fr))]").unwrap(); + assert_eq!( + parsed[0].arbitrary, + Some("grid-template-columns:repeat(2,minmax(0,1fr))".to_string()) + ); + + // Quotes. + let parsed = parse_class_string("[content:\"x\"]").unwrap(); + assert_eq!(parsed[0].arbitrary, Some("content:\"x\"".to_string())); + } + + #[test] + fn arbitrary_declarations_compose_with_modifiers() { + let parsed = parse_class_string("hover:[color:red]").unwrap(); + assert_eq!(parsed[0].pseudo_classes, vec!["hover".to_string()]); + assert_eq!(parsed[0].arbitrary, Some("color:red".to_string())); + + let parsed = parse_class_string("[&::before]:[mask-composite:exclude]").unwrap(); + assert_eq!(parsed[0].variant, Some("::before".to_string())); + assert_eq!( + parsed[0].arbitrary, + Some("mask-composite:exclude".to_string()) + ); + + let parsed = parse_class_string("@sm:[color:red]").unwrap(); + assert_eq!(parsed[0].conditionals, vec!["@sm".to_string()]); + assert_eq!(parsed[0].arbitrary, Some("color:red".to_string())); + } + + #[test] + fn arbitrary_declarations_no_longer_truncate_the_class_list() { + // Before the escape hatch existed this yielded ONE class: the bracket + // group failed every parser, `many0` stopped, and `bar` was discarded + // along with it. Silent truncation, no diagnostic. + let parsed = parse_class_string("foo [mask-composite:exclude] bar").unwrap(); + + assert_eq!(parsed.len(), 3); + assert_eq!(parsed[0].class_name, "foo"); + assert_eq!( + parsed[1].arbitrary, + Some("mask-composite:exclude".to_string()) + ); + assert_eq!(parsed[2].class_name, "bar"); + } + + #[test] + fn pins_generator_forms() { + let parsed = parse_class_string("padding-[20px]").unwrap(); + assert_eq!(parsed[0].class_name, "padding_"); + assert_eq!(parsed[0].generator_params, vec!["20px".to_string()]); + assert!(parsed[0].is_generator()); + } } diff --git a/crates/dwind-macros/src/keyframes/codegen.rs b/crates/dwind-macros/src/keyframes/codegen.rs new file mode 100644 index 0000000..a7d9520 --- /dev/null +++ b/crates/dwind-macros/src/keyframes/codegen.rs @@ -0,0 +1,156 @@ +//! Emission for [`crate::dwkeyframes`]. +//! +//! The generated `ANIMATE_*` pair is deliberately the same shape that +//! `dominator-css-bindgen` emits for a CSS-file utility — a `&'static str` body +//! plus a `Lazy` class — so `dwclass!` resolves it with no changes to +//! the macro at all. The only addition is the `ensure()` call that injects the +//! `@keyframes` the first time the class is instantiated. + +use crate::codegen::string_rendering::class_name_to_struct_identifier; +use crate::keyframes::{DwKeyframesInput, KeyframesBody, KeyframesEntry}; +use proc_macro2::{Ident, TokenStream}; +use quote::quote; +use syn::Path; + +pub fn render(input: DwKeyframesInput) -> TokenStream { + let path: Path = input + .path + .clone() + .unwrap_or_else(|| syn::parse_quote!(dwind::prelude::keyframes)); + + let items = input + .entries + .iter() + .map(|entry| render_entry(entry, input.prefix.as_deref(), &path)) + .collect::>(); + + let register_fn = input.register_fn.as_ref().map(|fn_ident| { + let handles = input + .entries + .iter() + .map(|entry| handle_ident(entry)) + .collect::>(); + + let doc = format!( + "Eagerly injects the {} `@keyframes` rule(s) declared in this module.", + handles.len() + ); + + quote! { + #[doc = #doc] + pub fn #fn_ident() { + #( #handles.ensure(); )* + } + } + }); + + quote! { + #( #items )* + #register_fn + } +} + +/// `fade_up` -> `FADE_UP_KEYFRAMES` +fn handle_ident(entry: &KeyframesEntry) -> Ident { + Ident::new( + &format!("{}_KEYFRAMES", entry.ident.to_string().to_uppercase()), + entry.ident.span(), + ) +} + +/// `fade_up` -> `fade-up`, the CSS-facing spelling. +fn kebab(ident: &Ident) -> String { + ident.to_string().replace('_', "-") +} + +/// The `@keyframes` body, as one string. +fn body_literal(entry: &KeyframesEntry) -> String { + match &entry.body { + KeyframesBody::Raw(raw) => raw.value(), + KeyframesBody::Stops(stops) => stops + .iter() + .map(|stop| { + format!( + "{} {{ {} }}", + stop.selector.value(), + stop.declarations.value() + ) + }) + .collect::>() + .join(" "), + } +} + +fn render_entry(entry: &KeyframesEntry, prefix: Option<&str>, path: &Path) -> TokenStream { + let handle = handle_ident(entry); + let name_ident = Ident::new(&format!("{handle}_NAME"), entry.ident.span()); + let body_ident = Ident::new(&format!("{handle}_BODY"), entry.ident.span()); + + // The CSS keyframe name, as an expression that is still a literal after + // expansion so it can be `concat!`ed into the animation shorthand. + // + // `env!` expands in the *consuming* crate, so two crates that both declare + // `fade_up` get distinct names without having to coordinate. + let name_expr: TokenStream = match (&entry.name_override, prefix) { + (Some(exact), _) => quote! { #exact }, + (None, Some(prefix)) => { + let full = format!("{}-{}", prefix, kebab(&entry.ident)); + quote! { #full } + } + (None, None) => { + let suffix = format!("-{}", kebab(&entry.ident)); + quote! { concat!(env!("CARGO_CRATE_NAME"), #suffix) } + } + }; + + let body = body_literal(entry); + let docs = &entry.docs; + + let handle_doc = format!( + "`@keyframes` handle. `.name()`, `.ensure()` or `{{}}` formatting injects the rule.\n\n\ + ```css\n@keyframes … {{ {body} }}\n```" + ); + + let animation = entry.animation.as_ref().map(|shorthand| { + let class_name = format!("animate-{}", kebab(&entry.ident)); + let class_ident = Ident::new( + &class_name_to_struct_identifier(&class_name), + entry.ident.span(), + ); + let raw_ident = Ident::new(&format!("{class_ident}_RAW"), entry.ident.span()); + let class_prefix = class_name.replace('-', "_"); + let shorthand = format!(" {shorthand};"); + + let class_doc = format!( + "Utility class.\n\n# Example\n```rust,ignore\nhtml!(\"div\", {{ .dwclass!(\"{class_name}\") }});\n```" + ); + + quote! { + #[doc(hidden)] + pub static #raw_ident: &str = concat!("animation: ", #name_expr, #shorthand); + + #[doc = #class_doc] + pub static #class_ident: once_cell::sync::Lazy = + once_cell::sync::Lazy::new(|| { + #handle.ensure(); + dominator::class! { + # ! [prefix = #class_prefix] + .raw(#raw_ident) + } + }); + } + }); + + quote! { + #[doc(hidden)] + pub static #name_ident: &str = #name_expr; + #[doc(hidden)] + pub static #body_ident: &str = #body; + + #( #docs )* + #[doc = #handle_doc] + pub static #handle: #path::Keyframes = #path::Keyframes::new(#name_ident, #body_ident); + + #animation + } +} diff --git a/crates/dwind-macros/src/keyframes/mod.rs b/crates/dwind-macros/src/keyframes/mod.rs new file mode 100644 index 0000000..1a28cf1 --- /dev/null +++ b/crates/dwind-macros/src/keyframes/mod.rs @@ -0,0 +1,201 @@ +//! Input parsing for [`crate::dwkeyframes`]. +//! +//! Every CSS fragment is a string literal, deliberately. Accepting bare CSS +//! tokens and reconstructing them with `TokenStream::to_string()` cannot work +//! reliably: Rust's lexer splits `0%` into two tokens, `--sx` into two puncts +//! and `.35` into a float or a dot-plus-int depending on context, and +//! `to_string()` re-inserts whitespace by its own rules. Two quote characters +//! buy exact fidelity. + +pub mod codegen; + +use syn::parse::{Parse, ParseStream}; +use syn::{braced, Attribute, Expr, ExprLit, Ident, Lit, LitStr, Meta, Path, Token}; + +/// One `selector => declarations` pair inside a keyframes block. +pub struct Stop { + pub selector: LitStr, + pub declarations: LitStr, +} + +impl Parse for Stop { + fn parse(input: ParseStream) -> syn::Result { + let selector = input.parse::()?; + input.parse::]>()?; + let declarations = input.parse::()?; + + Ok(Self { + selector, + declarations, + }) + } +} + +pub enum KeyframesBody { + /// `name { "from" => "…", "to" => "…" }` + Stops(Vec), + /// `name = "from { … } to { … }";` — verbatim, for pasting existing CSS. + Raw(LitStr), +} + +pub struct KeyframesEntry { + /// `#[doc = "…"]` attributes, forwarded to the generated items. + pub docs: Vec, + /// `#[name = "spin"]` — pins the exact CSS name, skipping the prefix. + pub name_override: Option, + /// `#[animation("1s linear infinite")]` — also mint an `animate-` + /// utility class with this shorthand. + pub animation: Option, + pub ident: Ident, + pub body: KeyframesBody, +} + +pub struct DwKeyframesInput { + /// `#![prefix = "app"]` — defaults to the consuming crate's name. + pub prefix: Option, + /// `#![register_fn = "app_keyframes"]` — emit a fn that eagerly injects all + /// of them, for callers who want the old unconditional behaviour. + pub register_fn: Option, + /// `#![path = dwind_base::keyframes]` — where the runtime lives. Defaults to + /// `dwind::prelude::keyframes`, which is correct for anyone depending on + /// dwind; the dwind and dwind-base crates themselves override it. + pub path: Option, + pub entries: Vec, +} + +fn name_value_string(attr: &Attribute) -> syn::Result { + match &attr.meta { + Meta::NameValue(nv) => match &nv.value { + Expr::Lit(ExprLit { + lit: Lit::Str(s), .. + }) => Ok(s.value()), + other => Err(syn::Error::new_spanned(other, "expected a string literal")), + }, + other => Err(syn::Error::new_spanned( + other, + "expected `name = \"value\"`", + )), + } +} + +impl Parse for DwKeyframesInput { + fn parse(input: ParseStream) -> syn::Result { + let mut prefix = None; + let mut register_fn = None; + let mut path = None; + + for attr in Attribute::parse_inner(input)? { + let ident = attr.path().get_ident().map(|i| i.to_string()); + + match ident.as_deref() { + Some("prefix") => prefix = Some(name_value_string(&attr)?), + Some("register_fn") => { + let raw = name_value_string(&attr)?; + register_fn = Some(Ident::new(&raw, attr.span_of_value())); + } + Some("path") => match &attr.meta { + Meta::NameValue(nv) => match &nv.value { + Expr::Path(p) => path = Some(p.path.clone()), + Expr::Lit(ExprLit { + lit: Lit::Str(s), .. + }) => path = Some(s.parse::()?), + other => { + return Err(syn::Error::new_spanned(other, "expected a module path")) + } + }, + other => return Err(syn::Error::new_spanned(other, "expected `path = ...`")), + }, + _ => { + return Err(syn::Error::new_spanned( + &attr, + "unknown dwkeyframes! option; expected `prefix`, `register_fn` or `path`", + )) + } + } + } + + let mut entries = vec![]; + + while !input.is_empty() { + entries.push(parse_entry(input)?); + } + + Ok(Self { + prefix, + register_fn, + path, + entries, + }) + } +} + +fn parse_entry(input: ParseStream) -> syn::Result { + let mut docs = vec![]; + let mut name_override = None; + let mut animation = None; + + for attr in Attribute::parse_outer(input)? { + let ident = attr.path().get_ident().map(|i| i.to_string()); + + match ident.as_deref() { + Some("doc") => docs.push(attr), + Some("name") => name_override = Some(name_value_string(&attr)?), + Some("animation") => animation = Some(attr.parse_args::()?.value()), + _ => { + return Err(syn::Error::new_spanned( + &attr, + "unknown keyframes attribute; expected `name` or `animation`", + )) + } + } + } + + let ident = input.parse::()?; + + let body = if input.peek(Token![=]) { + input.parse::()?; + let raw = input.parse::()?; + input.parse::()?; + + KeyframesBody::Raw(raw) + } else { + let content; + braced!(content in input); + + let stops = content + .parse_terminated(Stop::parse, Token![,])? + .into_iter() + .collect::>(); + + if stops.is_empty() { + return Err(syn::Error::new( + ident.span(), + "a keyframes block needs at least one stop", + )); + } + + KeyframesBody::Stops(stops) + }; + + Ok(KeyframesEntry { + docs, + name_override, + animation, + ident, + body, + }) +} + +/// `syn::Attribute` has no direct accessor for the span of a name-value's +/// value, and pointing at the whole attribute is good enough for diagnostics. +trait AttrSpan { + fn span_of_value(&self) -> proc_macro2::Span; +} + +impl AttrSpan for Attribute { + fn span_of_value(&self) -> proc_macro2::Span { + use syn::spanned::Spanned; + + self.span() + } +} diff --git a/crates/dwind-macros/src/lib.rs b/crates/dwind-macros/src/lib.rs index 93684d8..ffb8961 100644 --- a/crates/dwind-macros/src/lib.rs +++ b/crates/dwind-macros/src/lib.rs @@ -1,5 +1,6 @@ mod codegen; pub(crate) mod grammar; +mod keyframes; mod macro_inputs; use crate::codegen::string_rendering::class_name_to_struct_identifier; use crate::codegen::{render_classes, render_generate_dwind_class}; @@ -214,6 +215,81 @@ pub fn dwclass_signal(input: TokenStream) -> TokenStream { .into() } +/// Declares `@keyframes` rules and, optionally, the `animate-*` utility class +/// that drives them. +/// +/// dwind utility classes are single declaration blocks, so a `@keyframes` can +/// never be one. This macro is the way to declare animations without dropping +/// to a raw CSS string: it emits the at-rule *and* a compile-time-checked class, +/// and injects the rule lazily the first time either is used. +/// +/// # Example +/// +/// ```rust,ignore +/// use dwind_macros::{dwclass, dwkeyframes}; +/// +/// dwkeyframes! { +/// /// Rises and fades in. +/// #[animation("900ms cubic-bezier(0.16, 1, 0.3, 1) both")] +/// fade_up { +/// "from" => "opacity: 0; transform: translateY(14px);", +/// "to" => "opacity: 1; transform: translateY(0);", +/// } +/// +/// aurora { +/// "0%, 100%" => "transform: translate3d(0, 0, 0) scale(1);", +/// "50%" => "transform: translate3d(6%, -8%, 0) scale(1.15);", +/// } +/// } +/// +/// dominator::html!("div", { +/// // the utility, minted by `#[animation(...)]` +/// .dwclass!("animate-fade-up") +/// // or compose the shorthand yourself; `Display` injects the rule +/// .style("animation", &format!("{AURORA_KEYFRAMES} 22s ease-in-out infinite")) +/// }); +/// ``` +/// +/// Every CSS fragment is a string literal. Bare CSS tokens cannot survive Rust's +/// lexer intact — `0%`, `--sx` and `.35` all tokenise in ways that do not +/// round-trip. +/// +/// # Options +/// +/// Inner attributes configure the whole block: +/// +/// - `#![prefix = "app"]` — namespace for the generated CSS names. Defaults to +/// the consuming crate's name, so two crates declaring `fade_up` do not +/// collide. +/// - `#![register_fn = "app_keyframes"]` — also emit a function that injects +/// every rule eagerly, for callers who want the rules present regardless of +/// which classes get instantiated. +/// - `#![path = dwind_base::keyframes]` — where the runtime lives. Defaults to +/// `dwind::prelude::keyframes`. +/// +/// Outer attributes configure one entry: +/// +/// - `#[name = "spin"]` — pin the exact CSS name, skipping the prefix. Use this +/// when an existing stylesheet already references the name. +/// - `#[animation("1s linear infinite")]` — mint an `animate-` utility +/// with this shorthand. Omit it if you only want the handle. +/// +/// # Lazy injection has one hole +/// +/// A rule is injected when its class or its [`Display`](std::fmt::Display) is +/// first used. Reaching for the generated `*_RAW` constant directly — which is +/// what `dwclass!("[&::before]:animate-fade-up")` does internally — bypasses +/// that. Call `.ensure()` or use `#![register_fn]` if you need the guarantee. +#[proc_macro] +pub fn dwkeyframes(input: TokenStream) -> TokenStream { + let input = match syn::parse::(input) { + Ok(input) => input, + Err(err) => return err.to_compile_error().into(), + }; + + keyframes::codegen::render(input).into() +} + /// Generates a dwind class that can later be used by the 'dwclass!()' macro. /// /// Using this will create a lazy static in the scope from which the macro is invoked, so it can be used to create diff --git a/crates/dwind/Cargo.toml b/crates/dwind/Cargo.toml index 6e4bf53..261beb6 100644 --- a/crates/dwind/Cargo.toml +++ b/crates/dwind/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dwind" -version = "0.7.0" +version = "0.8.0" edition = "2021" description = "Style your DOMINATOR applications using a tailwind-like syntax and utility class collection!" homepage = "https://github.com/JedimEmO/dwind" @@ -12,8 +12,8 @@ keywords = ["web", "wasm", "css", "style"] const_format = { workspace = true } dominator = { workspace = true } dominator-css-bindgen = { path = "../dominator-css-bindgen", version = "0.2.0" } -dwind-base = { path = "../dwind-base", version = "0.1.1" } -dwind-macros = { path = "../dwind-macros", version = "0.4.0" } +dwind-base = { path = "../dwind-base", version = "0.1.2" } +dwind-macros = { path = "../dwind-macros", version = "0.5.0" } futures-signals = { workspace = true } modern-normalize-cssys = { path = "../modern-normalize-cssys", version = "0.2.1" } once_cell = { workspace = true } diff --git a/crates/dwind/resources/css/effects.css b/crates/dwind/resources/css/effects.css index 2ed0a19..01f3eba 100644 --- a/crates/dwind/resources/css/effects.css +++ b/crates/dwind/resources/css/effects.css @@ -52,4 +52,17 @@ .opacity-100 { opacity: 1; -} \ No newline at end of file +} +/* mix-blend-mode ----------------------------------------------------------- */ + +.mix-blend-normal { mix-blend-mode: normal; } +.mix-blend-multiply { mix-blend-mode: multiply; } +.mix-blend-screen { mix-blend-mode: screen; } +.mix-blend-overlay { mix-blend-mode: overlay; } +.mix-blend-darken { mix-blend-mode: darken; } +.mix-blend-lighten { mix-blend-mode: lighten; } +.mix-blend-color-dodge { mix-blend-mode: color-dodge; } +.mix-blend-color-burn { mix-blend-mode: color-burn; } +.mix-blend-difference { mix-blend-mode: difference; } +.mix-blend-exclusion { mix-blend-mode: exclusion; } +.mix-blend-luminosity { mix-blend-mode: luminosity; } diff --git a/crates/dwind/resources/css/interactivity.css b/crates/dwind/resources/css/interactivity.css index 447ea06..b4bb39e 100644 --- a/crates/dwind/resources/css/interactivity.css +++ b/crates/dwind/resources/css/interactivity.css @@ -173,4 +173,15 @@ .select-auto { user-select: auto; -} \ No newline at end of file +} +/* will-change -------------------------------------------------------------- */ + +.will-change-auto { will-change: auto; } +.will-change-scroll { will-change: scroll-position; } +.will-change-contents { will-change: contents; } +.will-change-transform { will-change: transform; } + +/* outline ------------------------------------------------------------------ */ + +.outline-none { outline: 2px solid transparent; outline-offset: 2px; } +.outline-hidden { outline: none; } diff --git a/crates/dwind/resources/css/layout.css b/crates/dwind/resources/css/layout.css index deb30f0..7833bf4 100644 --- a/crates/dwind/resources/css/layout.css +++ b/crates/dwind/resources/css/layout.css @@ -149,3 +149,8 @@ .aspect-video { aspect-ratio: 16/9; } + +/* isolation ---------------------------------------------------------------- */ + +.isolate { isolation: isolate; } +.isolation-auto { isolation: auto; } diff --git a/crates/dwind/resources/css/transition.css b/crates/dwind/resources/css/transition.css index e1a0ee1..fa43d43 100644 --- a/crates/dwind/resources/css/transition.css +++ b/crates/dwind/resources/css/transition.css @@ -88,4 +88,15 @@ .ease-in-out { transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); -} \ No newline at end of file +} +/* transition-delay --------------------------------------------------------- */ + +.delay-0 { transition-delay: 0s; } +.delay-75 { transition-delay: 75ms; } +.delay-100 { transition-delay: 100ms; } +.delay-150 { transition-delay: 150ms; } +.delay-200 { transition-delay: 200ms; } +.delay-300 { transition-delay: 300ms; } +.delay-500 { transition-delay: 500ms; } +.delay-700 { transition-delay: 700ms; } +.delay-1000 { transition-delay: 1000ms; } diff --git a/crates/dwind/resources/css/typography.css b/crates/dwind/resources/css/typography.css index d86b5b2..98c7659 100644 --- a/crates/dwind/resources/css/typography.css +++ b/crates/dwind/resources/css/typography.css @@ -216,4 +216,42 @@ .text-clip { text-overflow: clip; -} \ No newline at end of file +} +/* text-decoration ---------------------------------------------------------- */ + +.underline { text-decoration-line: underline; } +.overline { text-decoration-line: overline; } +.line-through { text-decoration-line: line-through; } +.no-underline { text-decoration-line: none; } + +/* letter-spacing ----------------------------------------------------------- */ + +.tracking-tighter { letter-spacing: -0.05em; } +.tracking-tight { letter-spacing: -0.025em; } +.tracking-normal { letter-spacing: 0; } +.tracking-wide { letter-spacing: 0.025em; } +.tracking-wider { letter-spacing: 0.05em; } +.tracking-widest { letter-spacing: 0.1em; } + +/* white-space -------------------------------------------------------------- */ + +.whitespace-normal { white-space: normal; } +.whitespace-nowrap { white-space: nowrap; } +.whitespace-pre { white-space: pre; } +.whitespace-pre-line { white-space: pre-line; } +.whitespace-pre-wrap { white-space: pre-wrap; } + +/* list-style --------------------------------------------------------------- */ + +.list-none { list-style-type: none; } +.list-disc { list-style-type: disc; } +.list-decimal { list-style-type: decimal; } + +/* generated content -------------------------------------------------------- */ + +.content-empty { content: ""; } +.content-none { content: none; } + +/* inheritance -------------------------------------------------------------- */ + +.font-inherit { font: inherit; } diff --git a/crates/dwind/src/modules/animations.rs b/crates/dwind/src/modules/animations.rs index 1dfa925..2d54955 100644 --- a/crates/dwind/src/modules/animations.rs +++ b/crates/dwind/src/modules/animations.rs @@ -1,44 +1,66 @@ -use dominator::html; +use dwind_macros::dwkeyframes; include!(concat!(env!("OUT_DIR"), "/animations.rs")); -const ANIMATION_KEYFRAMES: &str = r#" -@keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); +// The `.animate-*` utility classes are generated from `resources/css/animations.css` +// and reference these keyframes by bare name, so the names are pinned with +// `#[name = ...]` rather than namespaced. That also keeps them stable for anyone +// who wrote the shorthand by hand. +// +// No `#[animation(...)]` here for the same reason: the classes already come from +// the CSS file, and minting a second set would shadow them. +dwkeyframes! { + #![path = dwind_base::keyframes] + #![register_fn = "append_animation_keyframe_style"] + + #[name = "spin"] + spin { + "from" => "transform: rotate(0deg);", + "to" => "transform: rotate(360deg);", } -} -@keyframes ping { - 75%, 100% { - transform: scale(2); - opacity: 0; + #[name = "ping"] + ping { + "75%, 100%" => "transform: scale(2); opacity: 0;", } -} -@keyframes pulse { - 0%, 100% { - opacity: 1; + #[name = "pulse"] + pulse { + "0%, 100%" => "opacity: 1;", + "50%" => "opacity: .5;", } - 50% { - opacity: .5; + + #[name = "bounce"] + bounce { + "0%, 100%" => "transform: translateY(-25%); animation-timing-function: cubic-bezier(0.8, 0, 1, 1);", + "50%" => "transform: translateY(0); animation-timing-function: cubic-bezier(0, 0, 0.2, 1);", } } -@keyframes bounce { - 0%, 100% { - transform: translateY(-25%); - animation-timing-function: cubic-bezier(0.8, 0, 1, 1); - } - 50% { - transform: translateY(0); - animation-timing-function: cubic-bezier(0, 0, 0.2, 1); +#[cfg(test)] +mod test { + use super::*; + + /// The names are load-bearing: `resources/css/animations.css` writes + /// `animation: spin …` by hand, so a namespaced name would silently break + /// every `animate-*` class. + #[test] + fn keyframe_names_stay_unprefixed() { + assert_eq!(SPIN_KEYFRAMES.name_unregistered(), "spin"); + assert_eq!(PING_KEYFRAMES.name_unregistered(), "ping"); + assert_eq!(PULSE_KEYFRAMES.name_unregistered(), "pulse"); + assert_eq!(BOUNCE_KEYFRAMES.name_unregistered(), "bounce"); } -}"#; -pub fn append_animation_keyframe_style() { - dominator::stylesheet_raw(ANIMATION_KEYFRAMES); + #[test] + fn bodies_match_the_css_they_replaced() { + assert_eq!( + SPIN_KEYFRAMES.css(), + "@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }" + ); + assert_eq!( + PULSE_KEYFRAMES.css(), + "@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .5; } }" + ); + } } diff --git a/crates/dwui/Cargo.toml b/crates/dwui/Cargo.toml index e08c5f7..47981f6 100644 --- a/crates/dwui/Cargo.toml +++ b/crates/dwui/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dwui" -version = "0.9.0" +version = "0.9.1" edition = "2021" description = "UI Component library built on the DWIND style crate!" homepage = "https://github.com/JedimEmO/dwind" @@ -11,8 +11,8 @@ keywords = ["web", "wasm", "css", "style"] [dependencies] const_format = { workspace = true } dominator = { workspace = true } -dwind = { path = "../dwind", version = "0.7.0" } -dwind-macros = { path = "../dwind-macros", version = "0.4.0" } +dwind = { path = "../dwind", version = "0.8.0" } +dwind-macros = { path = "../dwind-macros", version = "0.5.0" } futures-signals = { workspace = true } futures-signals-component-macro = { workspace = true } once_cell = { workspace = true } @@ -23,10 +23,17 @@ futures = { workspace = true } wasm-bindgen-futures = { workspace = true } wasm-bindgen-test = { workspace = true } web-sys = { workspace = true, features = [ + "CssKeyframesRule", + "CssRule", + "CssRuleList", + "CssStyleDeclaration", + "CssStyleSheet", "Document", "HtmlElement", "HtmlInputElement", "NodeList", + "StyleSheet", + "StyleSheetList", "console", ] } diff --git a/crates/dwui/src/theme/mod.rs b/crates/dwui/src/theme/mod.rs index b500950..ded1b9a 100644 --- a/crates/dwui/src/theme/mod.rs +++ b/crates/dwui/src/theme/mod.rs @@ -1,59 +1,54 @@ use dominator::stylesheet; - -const DWUI_KEYFRAMES: &str = r#" -@keyframes dwui-modal-in { - from { - opacity: 0; - transform: scale(0.95) translateY(0.5rem); - } - to { - opacity: 1; - transform: scale(1) translateY(0); +use dwind_macros::dwkeyframes; + +// Components reference these by name from inline `.style("animation", …)` calls +// (see `widgets/spinner.rs`, `widgets/progress.rs`, `content/skeleton.rs`, +// `widgets/modal.rs`, `input/date_picker.rs`), so the names are pinned rather +// than namespaced. +// +// `apply_style_sheet` still injects them all eagerly, so nothing about the +// timing changes; the registry just makes repeat calls idempotent and turns a +// name clash into a diagnostic instead of a silent override. +dwkeyframes! { + #![register_fn = "apply_keyframes"] + + #[name = "dwui-modal-in"] + modal_in { + "from" => "opacity: 0; transform: scale(0.95) translateY(0.5rem);", + "to" => "opacity: 1; transform: scale(1) translateY(0);", } -} -@keyframes dwui-fade-in { - from { - opacity: 0; + #[name = "dwui-fade-in"] + fade_in { + "from" => "opacity: 0;", + "to" => "opacity: 1;", } - to { - opacity: 1; - } -} -@keyframes dwui-progress-indeterminate { - 0% { - margin-left: -40%; - } - 100% { - margin-left: 100%; + #[name = "dwui-progress-indeterminate"] + progress_indeterminate { + "0%" => "margin-left: -40%;", + "100%" => "margin-left: 100%;", } -} -@keyframes dwui-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); + #[name = "dwui-spin"] + spin { + "from" => "transform: rotate(0deg);", + "to" => "transform: rotate(360deg);", } -} -@keyframes dwui-skeleton-pulse { - 0%, 100% { - opacity: 1; + #[name = "dwui-skeleton-pulse"] + skeleton_pulse { + "0%, 100%" => "opacity: 1;", + "50%" => "opacity: 0.45;", } - 50% { - opacity: 0.45; - } -}"#; +} pub fn apply_style_sheet(colors: Option) { stylesheet!(":root", { .raw(colors.unwrap_or_default().to_style_sheet_raw()) }); - dominator::stylesheet_raw(DWUI_KEYFRAMES); + apply_keyframes(); base::apply_base_stylesheet(); colors::apply_colors_stylesheet(); diff --git a/crates/dwui/tests/styling.rs b/crates/dwui/tests/styling.rs new file mode 100644 index 0000000..5813ba7 --- /dev/null +++ b/crates/dwui/tests/styling.rs @@ -0,0 +1,342 @@ +//! Browser tests for the styling layer: `dwkeyframes!` injection, pseudo-element +//! variants, and arbitrary declarations. +//! +//! These live in dwui rather than dwind because dwui is the crate already wired +//! for `wasm-pack test`, and because the last test here guards dwui's own +//! keyframe migration. +//! +//! Run with: `wasm-pack test --headless --firefox crates/dwui` + +#![cfg(target_arch = "wasm32")] + +use dominator::html; +use dwind::prelude::*; +use dwind_macros::{dwclass, dwkeyframes}; +use wasm_bindgen_test::*; +use web_sys::js_sys; +use web_sys::wasm_bindgen::JsCast; + +wasm_bindgen_test_configure!(run_in_browser); + +dwkeyframes! { + #![prefix = "dwuitest"] + + #[animation("1s linear infinite")] + slide_probe { + "from" => "transform: translateX(0);", + "to" => "transform: translateX(10px);", + } + + /// Declared but never used, to prove injection is lazy. + unused_probe { + "from" => "opacity: 0;", + "to" => "opacity: 1;", + } +} + +struct TestContainer { + element: web_sys::Element, +} + +impl TestContainer { + fn new() -> Self { + let doc = web_sys::window().unwrap().document().unwrap(); + let el = doc.create_element("div").unwrap(); + el.set_attribute( + "style", + "position:absolute;left:0;top:0;width:800px;height:600px", + ) + .unwrap(); + doc.body().unwrap().append_child(&el).unwrap(); + Self { element: el } + } + + fn dom_element(&self) -> web_sys::HtmlElement { + self.element.clone().dyn_into().unwrap() + } +} + +impl Drop for TestContainer { + fn drop(&mut self) { + self.element.remove(); + } +} + +async fn wait_frame() { + let promise = js_sys::Promise::new(&mut |resolve, _| { + web_sys::window() + .unwrap() + .request_animation_frame(&resolve) + .unwrap(); + }); + wasm_bindgen_futures::JsFuture::from(promise).await.unwrap(); +} + +/// Counts `@keyframes` rules with this name across every stylesheet in the +/// document. More than one means the registry failed to deduplicate. +fn count_keyframes(name: &str) -> usize { + let doc = web_sys::window().unwrap().document().unwrap(); + let sheets = doc.style_sheets(); + let mut found = 0; + + for i in 0..sheets.length() { + let Some(sheet) = sheets.item(i) else { + continue; + }; + let Ok(sheet) = sheet.dyn_into::() else { + continue; + }; + // A cross-origin sheet throws on access; skip it. + let Ok(rules) = sheet.css_rules() else { + continue; + }; + + for r in 0..rules.length() { + let Some(rule) = rules.item(r) else { continue }; + + if let Ok(keyframes) = rule.dyn_into::() { + if keyframes.name() == name { + found += 1; + } + } + } + } + + found +} + +fn computed_pseudo(element: &web_sys::Element, pseudo: &str, property: &str) -> String { + web_sys::window() + .unwrap() + .get_computed_style_with_pseudo_elt(element, pseudo) + .unwrap() + .unwrap() + .get_property_value(property) + .unwrap() +} + +// --------------------------------------------------------------------------- +// dwkeyframes! +// --------------------------------------------------------------------------- + +#[wasm_bindgen_test] +async fn keyframes_are_injected_lazily_and_only_once() { + // Nothing has touched `unused_probe`, so its rule must not be in the + // document. This is the property the old eager `&str` blob could not offer. + assert_eq!( + count_keyframes("dwuitest-unused-probe"), + 0, + "an unused keyframe was injected" + ); + + let tc = TestContainer::new(); + + // Instantiate the class twice: the registry must inject exactly one rule. + dominator::append_dom( + &tc.dom_element(), + html!("div", { + .dwclass!("animate-slide-probe") + .child(html!("span", { .dwclass!("animate-slide-probe") })) + }), + ); + wait_frame().await; + + assert_eq!( + count_keyframes("dwuitest-slide-probe"), + 1, + "expected exactly one @keyframes rule after two instantiations" + ); +} + +#[wasm_bindgen_test] +async fn keyframe_handles_register_when_formatted() { + // `Display` registers, so a hand-composed shorthand cannot reference a rule + // that was never injected. + let shorthand = format!("{UNUSED_PROBE_KEYFRAMES} 1s linear"); + + assert!( + shorthand.starts_with("dwuitest-unused-probe"), + "{shorthand}" + ); + assert_eq!(count_keyframes("dwuitest-unused-probe"), 1); +} + +#[wasm_bindgen_test] +async fn dwui_keyframes_survived_the_migration() { + dwui::theme::apply_style_sheet(None); + // Idempotent: calling twice must not duplicate the rules. + dwui::theme::apply_style_sheet(None); + + for name in [ + "dwui-modal-in", + "dwui-fade-in", + "dwui-progress-indeterminate", + "dwui-spin", + "dwui-skeleton-pulse", + ] { + assert_eq!(count_keyframes(name), 1, "{name} should be injected once"); + } +} + +// --------------------------------------------------------------------------- +// Pseudo-element variants +// --------------------------------------------------------------------------- + +#[wasm_bindgen_test] +async fn pseudo_element_variants_materialise_without_a_content_utility() { + let tc = TestContainer::new(); + + dominator::append_dom( + &tc.dom_element(), + html!("div", { + .attr("id", "probe-before") + .dwclass!("relative [&::before]:absolute [&::before]:opacity-100") + }), + ); + wait_frame().await; + + let doc = web_sys::window().unwrap().document().unwrap(); + let el = doc.get_element_by_id("probe-before").unwrap(); + + // Without a `content`, a ::before never generates a box, so this is the + // property that decides whether the utility is usable on its own. + let content = computed_pseudo(&el, "::before", "content"); + assert!( + content != "none", + "::before had no generated content (got {content:?})" + ); + assert_eq!(computed_pseudo(&el, "::before", "position"), "absolute"); +} + +#[wasm_bindgen_test] +async fn before_shorthand_matches_the_bracket_form() { + let tc = TestContainer::new(); + + dominator::append_dom( + &tc.dom_element(), + html!("div", { + .attr("id", "probe-alias") + .dwclass!("relative before:absolute") + }), + ); + wait_frame().await; + + let doc = web_sys::window().unwrap().document().unwrap(); + let el = doc.get_element_by_id("probe-alias").unwrap(); + + assert!(computed_pseudo(&el, "::before", "content") != "none"); + assert_eq!(computed_pseudo(&el, "::before", "position"), "absolute"); +} + +#[wasm_bindgen_test] +async fn pseudo_classes_do_not_gain_content() { + let tc = TestContainer::new(); + + dominator::append_dom( + &tc.dom_element(), + html!("div", { + .attr("id", "probe-hover") + .dwclass!("hover:opacity-50") + }), + ); + wait_frame().await; + + let doc = web_sys::window().unwrap().document().unwrap(); + let el = doc.get_element_by_id("probe-hover").unwrap(); + + // The element itself is unaffected; only `::before`/`::after` get content. + assert_eq!(computed_pseudo(&el, "::before", "content"), "none"); +} + +// --------------------------------------------------------------------------- +// Arbitrary declarations +// --------------------------------------------------------------------------- + +#[wasm_bindgen_test] +async fn arbitrary_declarations_reach_the_element() { + let tc = TestContainer::new(); + + dominator::append_dom( + &tc.dom_element(), + html!("div", { + .attr("id", "probe-arbitrary") + .dwclass!("[mix-blend-mode:overlay] [--sx:42%] [letter-spacing:0.5px]") + }), + ); + wait_frame().await; + + let doc = web_sys::window().unwrap().document().unwrap(); + let el = doc.get_element_by_id("probe-arbitrary").unwrap(); + let style = web_sys::window() + .unwrap() + .get_computed_style(&el) + .unwrap() + .unwrap(); + + assert_eq!( + style.get_property_value("mix-blend-mode").unwrap(), + "overlay" + ); + assert_eq!(style.get_property_value("letter-spacing").unwrap(), "0.5px"); + // Custom properties round-trip too, which is what the pointer-tracking + // effects in the example app need. + assert_eq!(style.get_property_value("--sx").unwrap().trim(), "42%"); +} + +#[wasm_bindgen_test] +async fn arbitrary_declarations_compose_with_pseudo_elements() { + let tc = TestContainer::new(); + + dominator::append_dom( + &tc.dom_element(), + html!("div", { + .attr("id", "probe-arb-before") + .dwclass!("relative [&::before]:[mix-blend-mode:screen]") + }), + ); + wait_frame().await; + + let doc = web_sys::window().unwrap().document().unwrap(); + let el = doc.get_element_by_id("probe-arb-before").unwrap(); + + assert!(computed_pseudo(&el, "::before", "content") != "none"); + assert_eq!(computed_pseudo(&el, "::before", "mix-blend-mode"), "screen"); +} + +// --------------------------------------------------------------------------- +// New utilities +// --------------------------------------------------------------------------- + +#[wasm_bindgen_test] +async fn newly_added_utilities_apply() { + let tc = TestContainer::new(); + + dominator::append_dom( + &tc.dom_element(), + html!("div", { + .attr("id", "probe-utils") + .dwclass!("absolute inset-0 isolate no-underline whitespace-nowrap will-change-transform delay-150 tracking-tight") + }), + ); + wait_frame().await; + + let doc = web_sys::window().unwrap().document().unwrap(); + let el = doc.get_element_by_id("probe-utils").unwrap(); + let style = web_sys::window() + .unwrap() + .get_computed_style(&el) + .unwrap() + .unwrap(); + + assert_eq!(style.get_property_value("isolation").unwrap(), "isolate"); + assert_eq!(style.get_property_value("white-space").unwrap(), "nowrap"); + assert_eq!( + style.get_property_value("transition-delay").unwrap(), + "0.15s" + ); + assert_eq!(style.get_property_value("top").unwrap(), "0px"); + assert_eq!( + style.get_property_value("text-decoration-line").unwrap(), + "none" + ); +} diff --git a/examples/webpage/Cargo.toml b/examples/webpage/Cargo.toml index 375c6ff..fdc37ae 100644 --- a/examples/webpage/Cargo.toml +++ b/examples/webpage/Cargo.toml @@ -14,9 +14,9 @@ crate-type = ["cdylib"] [dependencies] const_format = { workspace = true } dominator = { workspace = true } -dwind = { path = "../../crates/dwind", version = "0.7.0" } -dwind-macros = { path = "../../crates/dwind-macros", version = "0.4.0" } -dwui = { path = "../../crates/dwui", version = "0.9.0" } +dwind = { path = "../../crates/dwind", version = "0.8.0" } +dwind-macros = { path = "../../crates/dwind-macros", version = "0.5.0" } +dwui = { path = "../../crates/dwui", version = "0.9.1" } example-html-highlight-macro = { path = "../../crates/example-html-macro", version = "0.1.1" } futures = { workspace = true } futures-signals = { workspace = true } From 2ba788b8473e93e0ab9a2f7b3816aaea16cce6c7 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 25 Jul 2026 15:37:48 +0200 Subject: [PATCH 2/4] Fix three correctness holes in the styling additions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From review of #22. All three were real; two of them undercut the exact guarantee the feature exists to provide. Animation variants did not register their keyframes --------------------------------------------------- dwkeyframes! called ensure() from the generated class, but a modified form — hover:animate-x, [&::before]:animate-x — compiles the declaration *text* into a fresh class and never touches that class. Those animations referenced a @keyframes rule that was never injected. Registration now hangs off the declaration text: the emitted *_RAW is an AnimationDecl whose Deref registers, rather than a plain &str. Codegen is unchanged — `.raw(&*IDENT)` derefs either one to `&str` — so every path dwclass! can take is covered. I had documented this as a known hole; it was too central to leave. dwclass! silently discarded what it could not parse -------------------------------------------------- Two bugs compounded here. `many0` stops at the first unparseable class and reports success with the remainder untouched, and parse_class_string ignored that remainder. One malformed class therefore deleted itself *and every class after it*, with no diagnostic — the precise failure mode dwclass! exists to prevent. It now refuses, naming the offending text. And the declaration-body parser used a character allow-list built on nom's `is_alphanumeric`, which takes a u8 — so `c as u8` truncated every multi-byte character and `[content:'→']` could not parse. Combined with the above, PR #23's own docs example silently compiled to zero classes. The body is now a deny-list of the four bracket delimiters, since a CSS value can hold any character. Also dropped the `_`-means-space rewrite. Spaces already work inside the brackets, so it bought nothing and corrupted `var(--brand_color)`. Composed pseudo-element utilities clobbered explicit content ------------------------------------------------------------ Every before:/after: utility has to emit a content, or nothing renders. But each utility is its own class with its own rule, so the literal `content: ""` from `before:absolute` won by source order over the `content: 'x'` from `before:[content:'x']`. I claimed raw()'s append semantics made Tailwind's --tw-content indirection unnecessary. That was wrong: appending only orders declarations *within* one class, not across composed utilities. Content now goes through --dw-content, so the content declaration is identical in every class and only the value varies. content-empty / content-none set the property for the same reason. Tests ----- 42 native (up from 29), including the dwkeyframes! parser and codegen, which previously had none. Browser suite is 12 (up from 9); the three new cases are the exact probes from the review, each verified failing before the fix. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 43 ++++-- crates/dwind-base/src/keyframes.rs | 36 +++++ crates/dwind-macros/src/codegen/mod.rs | 97 ++++++++++--- crates/dwind-macros/src/grammar/mod.rs | 110 +++++++++++---- crates/dwind-macros/src/keyframes/codegen.rs | 140 +++++++++++++++++-- crates/dwind-macros/src/lib.rs | 16 ++- crates/dwind/resources/css/typography.css | 10 +- crates/dwui/tests/styling.rs | 94 +++++++++++++ 8 files changed, 469 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a092881..ce2148b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,9 +28,12 @@ html!("div", { .dwclass!("animate-fade-up") }) - Emits the at-rule *and* a compile-time-checked `animate-*` utility, so a typo in the class name is still a build error. -- The rule is injected the first time the class — or the handle's `Display` — - is used, and never twice. `format!("{FADE_UP_KEYFRAMES} 600ms {delay}ms")` - registers as a side effect, for shorthands composed at runtime. +- The rule is injected the first time anything reads the declaration or the name, + and never twice. That includes modified forms — `hover:animate-fade-up`, + `[&::before]:animate-fade-up` — which compile the declaration text into a fresh + class and never touch the generated one; the emitted `*_RAW` is an + `AnimationDecl` whose `Deref` registers, so those paths are covered too. + `format!("{FADE_UP_KEYFRAMES} 600ms {delay}ms")` registers as well. - Names are namespaced by the consuming crate by default (`#![prefix = "..."]` to override, `#[name = "..."]` to pin an exact name). Registering one name with two different bodies now panics in debug builds instead of silently winning. @@ -49,24 +52,36 @@ The escape hatch for properties with no utility, matching Tailwind: .dwclass!("[mask-composite:exclude] [--sx:50%] hover:[color:red]") ``` -Underscores in the value become spaces (`[transition:opacity_650ms_ease]`), since -a class string is space-separated. The property side is left alone so custom -properties keep their underscores. +Values pass through verbatim. Spaces are legal inside the brackets — the bracket +delimits the class, not the space — so there is no `_`-means-space convention, +which also means `[color:var(--brand_color)]` keeps its underscore. Any character +is allowed: `[content:'→']` works. Unambiguous against the variant syntax because a variant's `]` is always followed -by `:`. Previously a bracket group *without* a trailing colon failed every parser -and was silently discarded **along with every class after it** — so -`dwclass!("foo [a:b] bar")` yielded one class, not three. That truncation is -fixed, and a bracket group with no colon at all is now a compile error with a -message instead of silence. +by `:`. + +**`dwclass!` no longer discards what it cannot parse.** `many0` stops at the first +unparseable class and reports success with the remainder untouched, and that +remainder was ignored — so one malformed class silently deleted itself *and every +class after it*. `dwclass!("foo [a:b] bar")` yielded one class, not three. The +parser now rejects a non-whitespace remainder with a message naming the offending +text, and classes may be separated by any whitespace, so multi-line class strings +parse instead of truncating. ### Pseudo-elements that actually render `[&::before]:` variants already parsed, but a `::before` with no `content` never generates a box, so the utility did nothing on its own. dwind now emits -`content: ""` for any variant whose last compound targets `::before`/`::after`. -Because `DomBuilder::raw` appends rather than replaces, your own `content-[...]` -later in the same class still wins — no `--tw-content` indirection needed. +`content: var(--dw-content, "")` for any variant whose last compound targets +`::before`/`::after`, and redirects a `content` declaration written under such a +variant to that property. + +The indirection is load-bearing: each utility compiles to its own class with its +own rule, so a literal `content: ""` from `before:absolute` would win by source +order over the `content: 'x'` from `before:[content:'x']`. Going through the +property means the `content` declaration is identical everywhere and only the +value varies, so `before:[content:'x'] before:absolute` composes. The +`content-empty` / `content-none` utilities set the property for the same reason. Added shorthands: `before:`, `after:`, `placeholder:`, `marker:`, `selection:`, `backdrop:`, `first-letter:`, `first-line:`. diff --git a/crates/dwind-base/src/keyframes.rs b/crates/dwind-base/src/keyframes.rs index 3242f15..903b68e 100644 --- a/crates/dwind-base/src/keyframes.rs +++ b/crates/dwind-base/src/keyframes.rs @@ -98,6 +98,42 @@ impl std::fmt::Display for Keyframes { } } +/// The declaration body of a generated `animate-*` utility. +/// +/// This exists because registration has to hang off the *declaration text* +/// rather than off the utility class. `dwclass!` compiles a bare +/// `animate-fade-up` into a reference to the class, but a modified +/// `hover:animate-fade-up` or `[&::before]:animate-fade-up` builds a fresh class +/// out of the declaration text alone and never touches the original. Attaching +/// the side effect here is what makes every one of those paths inject the rule. +/// +/// Reading the value — which `.raw(&*DECL)` does — registers. +pub struct AnimationDecl { + keyframes: &'static Keyframes, + css: &'static str, +} + +impl AnimationDecl { + pub const fn new(keyframes: &'static Keyframes, css: &'static str) -> Self { + Self { keyframes, css } + } +} + +impl std::ops::Deref for AnimationDecl { + type Target = str; + + fn deref(&self) -> &str { + self.keyframes.ensure(); + self.css + } +} + +impl std::fmt::Display for AnimationDecl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self) + } +} + /// Injects `@keyframes {name} { {body} }` unless `name` is already registered. /// /// Registering the same name twice with different bodies is a bug — two crates diff --git a/crates/dwind-macros/src/codegen/mod.rs b/crates/dwind-macros/src/codegen/mod.rs index 6ff2a00..91519ea 100644 --- a/crates/dwind-macros/src/codegen/mod.rs +++ b/crates/dwind-macros/src/codegen/mod.rs @@ -75,17 +75,23 @@ fn needs_generated_content(selector: &str) -> bool { last_compound.contains("::before") || last_compound.contains("::after") } -/// `::before` and `::after` do not render without a `content`. dwind emits an -/// empty one so the utility is enough on its own. +/// The custom property that carries a pseudo-element's generated content. /// -/// This lands *before* the class body, and `DomBuilder::raw` appends rather than -/// replaces, so a user's own `content` declaration later in the same class still -/// wins. That is why this needs none of Tailwind's `--tw-content` indirection — -/// Tailwind needs it because its variants are static stylesheet rules with fixed -/// source order. +/// Every `before:`/`after:` utility has to emit a `content`, or the +/// pseudo-element never renders. But each utility compiles to its own class with +/// its own rule, so a plain `content: ""` from `before:absolute` would win by +/// source order over the `content: "x"` from `before:[content:'x']` — composing +/// two pseudo-element utilities would clobber the author's content. +/// +/// Routing through a custom property removes the ordering question entirely: the +/// `content` declaration is identical in every class, and the *value* is set +/// once by whichever utility the author wrote. This is what Tailwind's +/// `--tw-content` does, and the reason it is needed here too. +pub(crate) const CONTENT_VAR: &str = "--dw-content"; + fn generated_content(selector: &str) -> TokenStream { if needs_generated_content(selector) { - quote! { .raw("content: \"\";") } + quote! { .raw("content: var(--dw-content, \"\");") } } else { quote! {} } @@ -136,11 +142,15 @@ pub fn render_generate_dwind_class(class_name: String, class: DwindClassSelector /// Turns `mask-composite:exclude` into `mask-composite: exclude;`. /// -/// Underscores in the *value* become spaces, the way Tailwind handles arbitrary -/// values — a class string is space-separated, so a literal space cannot appear -/// there. The property is left alone, since custom properties such as -/// `--my_var` legitimately contain underscores. -fn normalise_declaration(declaration: &str) -> String { +/// Values are passed through verbatim. Spaces are legal inside the brackets — the +/// bracket is what delimits the class, not the space — so there is no need for +/// Tailwind's `_`-means-space convention, and rewriting underscores would corrupt +/// legitimate identifiers like `var(--brand_color)`. +/// +/// Under a pseudo-element target, a `content` declaration is redirected to +/// [`CONTENT_VAR`] so it composes with the generated default. See +/// [`generated_content`]. +fn normalise_declaration(declaration: &str, pseudo_element: bool) -> String { let Some((property, value)) = declaration.split_once(':') else { panic!( "`[{declaration}]` is not a CSS declaration — expected `[property:value]`, \ @@ -150,12 +160,18 @@ fn normalise_declaration(declaration: &str) -> String { }; let property = property.trim(); - let value = value.trim().replace('_', " "); + let value = value.trim(); if property.is_empty() || value.is_empty() { panic!("`[{declaration}]` has an empty property or value"); } + let property = if pseudo_element && property == "content" { + CONTENT_VAR + } else { + property + }; + format!("{property}: {value};") } @@ -183,10 +199,11 @@ pub fn render_dwind_class( let breakpoint = class.get_breakpoint(); if let Some(declaration) = &class.arbitrary { - let css = normalise_declaration(declaration); let class_prefix = declaration_prefix(declaration); let tokens = if class.pseudo_classes.is_empty() && class.variant.is_none() { + let css = normalise_declaration(declaration, false); + quote! { dominator::class! { # ! [prefix=#class_prefix] @@ -196,6 +213,7 @@ pub fn render_dwind_class( } else { let pseudo_selector = build_pseudo_selector(&class.variant, &class.pseudo_classes); let content = generated_content(&pseudo_selector); + let css = normalise_declaration(declaration, needs_generated_content(&pseudo_selector)); quote! { dominator::class! { @@ -400,19 +418,52 @@ mod test { } #[test] - fn arbitrary_declaration_underscores_become_spaces_in_the_value_only() { - // A class string is space-separated, so a literal space cannot appear - // in one; `_` is the Tailwind-compatible stand-in. - let rendered = render("[transition:opacity_650ms_ease]"); + fn arbitrary_declaration_values_pass_through_verbatim() { + // Spaces are legal inside the brackets — the bracket delimits the class, + // not the space — so there is no `_`-means-space convention to apply. + let rendered = render("[transition:opacity 650ms ease]"); assert!( rendered.contains("\"transition: opacity 650ms ease;\""), "{rendered}" ); - // Custom properties legitimately contain underscores, so the property - // side is left alone. - let rendered = render("[--my_var:red]"); - assert!(rendered.contains("\"--my_var: red;\""), "{rendered}"); + // And rewriting underscores would corrupt legitimate identifiers: this + // must not become `var(--brand color)`. + let rendered = render("[color:var(--brand_color)]"); + assert!( + rendered.contains("\"color: var(--brand_color);\""), + "{rendered}" + ); + } + + #[test] + fn arbitrary_declarations_accept_non_ascii_values() { + // A character-class allow-list silently truncated here, because `nom`'s + // `is_alphanumeric` takes a `u8`. A CSS value can hold any character. + let rendered = render("[content:'→']"); + assert!(rendered.contains('→'), "{rendered}"); + + let rendered = render("[transform:rotate(45deg)]"); + assert!( + rendered.contains("\"transform: rotate(45deg);\""), + "{rendered}" + ); + } + + #[test] + fn pseudo_element_content_goes_through_the_custom_property() { + // Composition is the point: `before:[content:'x'] before:absolute` has to + // keep the author's content, and since each utility is its own class a + // literal `content` would be decided by rule order instead. + let rendered = render("before:[content:'x']"); + assert!(rendered.contains("--dw-content: 'x';"), "{rendered}"); + assert!(rendered.contains("content: var(--dw-content"), "{rendered}"); + + // Outside a pseudo-element there is nothing to compose with, so the + // property is left exactly as written. + let rendered = render("[content:'x']"); + assert!(rendered.contains("\"content: 'x';\""), "{rendered}"); + assert!(!rendered.contains("--dw-content"), "{rendered}"); } #[test] diff --git a/crates/dwind-macros/src/grammar/mod.rs b/crates/dwind-macros/src/grammar/mod.rs index fb57b43..6472b22 100644 --- a/crates/dwind-macros/src/grammar/mod.rs +++ b/crates/dwind-macros/src/grammar/mod.rs @@ -79,7 +79,19 @@ impl DwindClassSelector { } pub fn parse_class_string(input: &str) -> Result, ()> { - let (_, classes) = selectors(input).unwrap(); + let (rest, classes) = selectors(input).unwrap(); + + // `many0` stops at the first thing it cannot parse and reports success with + // the remainder untouched. Ignoring that remainder meant a single malformed + // class silently discarded itself *and every class after it* — the exact + // failure mode dwclass! exists to prevent. Refuse instead. + if !rest.trim().is_empty() { + panic!( + "dwclass!: could not parse {rest:?} in {input:?}.\n\ + Classes are separated by whitespace. An arbitrary declaration needs \ + a property and a value in square brackets, like `[mask-composite:exclude]`." + ); + } Ok(classes .into_iter() @@ -120,6 +132,12 @@ pub fn parse_class_string(input: &str) -> Result, ()> { .collect()) } +/// Any run of whitespace between classes. Not `tag(" ")`, so that a class string +/// broken over several source lines parses the same as a single-spaced one. +fn whitespace(input: &str) -> IResult<&str, &str> { + nom::bytes::complete::take_while(|c: char| c.is_whitespace())(input) +} + /// What sits in the class-name position: either a utility name, or an arbitrary /// declaration written inline. #[derive(Debug)] @@ -139,26 +157,25 @@ fn class_body(input: &str) -> IResult<&str, ClassBody<'_>> { ))(input) } -fn selectors( - input: &str, -) -> IResult< - &str, - Vec<( - Option, - Vec, - ClassBody<'_>, - Option>, - )>, -> { +/// One parsed selector: `(variant, prefixes, class body, generator params)`. +type ParsedSelector<'a> = ( + Option, + Vec, + ClassBody<'a>, + Option>, +); + +fn selectors(input: &str) -> IResult<&str, Vec>> { let prefixes = many0(pseudo_selector); - let parser = terminated( + let parser = nom::sequence::delimited( + whitespace, nom::sequence::tuple(( variant_selector, prefixes, class_body, opt(generator_parameters), )), - opt(tag(" ")), + whitespace, ); many0(parser)(input) } @@ -245,22 +262,21 @@ const CHARS_EXT: [char; 13] = [ '_', '-', '@', ',', '<', '>', '*', ' ', '.', ' ', ':', '#', '&', ]; -/// Characters permitted inside an arbitrary declaration, `[prop:value]`. +/// Any character that is not structural to the bracket grammar. /// -/// Deliberately a separate set from [`CHARS_EXT`]: selectors never need `%`, -/// `/`, `+`, `=`, quotes or `;`, and declaration values need all of them. -const DECL_CHARS: [char; 22] = [ - '_', '-', '.', '#', '%', '/', '+', '=', '"', '\'', ',', ':', ';', '@', '*', '<', '>', '&', '$', - '!', '~', ' ', -]; +/// A CSS value can contain essentially anything — `→` in a `content`, a `°` in a +/// gradient angle, a `字` in a font stack. So this is a deny-list of the four +/// delimiters the parser needs to track, not an allow-list of what CSS is +/// permitted. An allow-list here also silently truncated at any non-ASCII byte, +/// because `nom`'s `is_alphanumeric` takes a `u8`. +fn is_declaration_char(c: char) -> bool { + !matches!(c, '[' | ']' | '(' | ')') +} fn declaration_body<'a>(input: &'a str) -> IResult<&'a str, String> { many0(alt(( bracketed("(", ")", declaration_body), - |v: &'a str| { - take_while1(is_extended_alphanumeric(DECL_CHARS.to_vec()))(v) - .map(move |v| (v.0, v.1.to_string())) - }, + |v: &'a str| take_while1(is_declaration_char)(v).map(move |v| (v.0, v.1.to_string())), )))(input) .map(|r| (r.0, r.1.join(""))) } @@ -599,6 +615,50 @@ mod test { assert_eq!(parsed[2].class_name, "bar"); } + #[test] + fn unparseable_input_is_rejected_rather_than_dropped() { + // `many0` succeeds with an untouched remainder, so anything the grammar + // cannot handle used to discard itself *and every class after it*. + let err = std::panic::catch_unwind(|| parse_class_string("foo ((bad)) bar")); + assert!(err.is_err(), "malformed input should not parse silently"); + } + + #[test] + fn classes_may_be_separated_by_any_whitespace() { + // Multi-line `dwclass!` strings and double spaces used to hit the silent + // truncation path above. + let parsed = parse_class_string("foo bar\n baz\tqux").unwrap(); + let names = parsed.into_iter().map(|v| v.class_name).collect::>(); + + assert_eq!(names, ["foo", "bar", "baz", "qux"]); + } + + #[test] + fn arbitrary_declarations_accept_any_css_value() { + // Non-ASCII: the old allow-list truncated at the first multi-byte char, + // because `nom`'s `is_alphanumeric` takes a `u8`. + let parsed = parse_class_string("before:[content:'→'] before:m-r-2").unwrap(); + assert_eq!(parsed.len(), 2, "{parsed:?}"); + assert_eq!(parsed[0].arbitrary, Some("content:'→'".to_string())); + assert_eq!(parsed[1].class_name, "m_r_2"); + + // Underscores are not touched — rewriting them would corrupt this. + let parsed = parse_class_string("[color:var(--brand_color)]").unwrap(); + assert_eq!( + parsed[0].arbitrary, + Some("color:var(--brand_color)".to_string()) + ); + + // Real spaces work inside the brackets. + let parsed = parse_class_string("[transition:opacity 650ms ease] flex").unwrap(); + assert_eq!(parsed.len(), 2); + assert_eq!( + parsed[0].arbitrary, + Some("transition:opacity 650ms ease".to_string()) + ); + assert_eq!(parsed[1].class_name, "flex"); + } + #[test] fn pins_generator_forms() { let parsed = parse_class_string("padding-[20px]").unwrap(); diff --git a/crates/dwind-macros/src/keyframes/codegen.rs b/crates/dwind-macros/src/keyframes/codegen.rs index a7d9520..40b07fd 100644 --- a/crates/dwind-macros/src/keyframes/codegen.rs +++ b/crates/dwind-macros/src/keyframes/codegen.rs @@ -25,11 +25,7 @@ pub fn render(input: DwKeyframesInput) -> TokenStream { .collect::>(); let register_fn = input.register_fn.as_ref().map(|fn_ident| { - let handles = input - .entries - .iter() - .map(|entry| handle_ident(entry)) - .collect::>(); + let handles = input.entries.iter().map(handle_ident).collect::>(); let doc = format!( "Eagerly injects the {} `@keyframes` rule(s) declared in this module.", @@ -126,16 +122,20 @@ fn render_entry(entry: &KeyframesEntry, prefix: Option<&str>, path: &Path) -> To ); quote! { + // An `AnimationDecl` rather than a plain `&str`, so that reading the + // declaration registers the `@keyframes`. `dwclass!` reads this + // directly for any modified form — `hover:animate-x`, + // `[&::before]:animate-x` — which never touches the class below. #[doc(hidden)] - pub static #raw_ident: &str = concat!("animation: ", #name_expr, #shorthand); + pub static #raw_ident: #path::AnimationDecl = + #path::AnimationDecl::new(&#handle, concat!("animation: ", #name_expr, #shorthand)); #[doc = #class_doc] pub static #class_ident: once_cell::sync::Lazy = once_cell::sync::Lazy::new(|| { - #handle.ensure(); dominator::class! { # ! [prefix = #class_prefix] - .raw(#raw_ident) + .raw(&* #raw_ident) } }); } @@ -154,3 +154,127 @@ fn render_entry(entry: &KeyframesEntry, prefix: Option<&str>, path: &Path) -> To #animation } } + +#[cfg(test)] +mod test { + use super::*; + use crate::keyframes::DwKeyframesInput; + + fn render_str(input: &str) -> String { + let parsed: DwKeyframesInput = syn::parse_str(input).expect("failed to parse"); + + render(parsed).to_string() + } + + #[test] + fn stops_become_one_keyframes_body() { + let out = render_str( + r#" + fade_up { + "from" => "opacity: 0;", + "to" => "opacity: 1;", + } + "#, + ); + + assert!( + out.contains(r#""from { opacity: 0; } to { opacity: 1; }""#), + "{out}" + ); + assert!(out.contains("FADE_UP_KEYFRAMES"), "{out}"); + // No `#[animation(...)]`, so no utility class is minted. + assert!(!out.contains("ANIMATE_FADE_UP"), "{out}"); + } + + #[test] + fn comma_separated_percentage_stops_survive_verbatim() { + // The reason every fragment is a string literal: `0%` and `-8%` do not + // round-trip through Rust's lexer. + let out = render_str( + r#" + aurora { + "0%, 100%" => "transform: translate3d(0, 0, 0) scale(1);", + "33%" => "transform: translate3d(6%, -8%, 0) scale(1.15);", + } + "#, + ); + + assert!(out.contains("0%, 100% {"), "{out}"); + assert!(out.contains("translate3d(6%, -8%, 0) scale(1.15)"), "{out}"); + } + + #[test] + fn animation_attribute_mints_a_utility_class() { + let out = render_str( + r#" + #[animation("900ms ease-out both")] + fade_up { "from" => "opacity: 0;" } + "#, + ); + + assert!(out.contains("ANIMATE_FADE_UP_RAW"), "{out}"); + assert!(out.contains("ANIMATE_FADE_UP :"), "{out}"); + assert!(out.contains(r#"" 900ms ease-out both;""#), "{out}"); + // The declaration is an AnimationDecl, not a `&str`, so that reading it + // from a variant registers the keyframes. + assert!(out.contains("AnimationDecl"), "{out}"); + } + + #[test] + fn names_are_namespaced_by_default_and_pinnable() { + let out = render_str(r#"fade_up { "from" => "opacity: 0;" }"#); + assert!(out.contains("CARGO_CRATE_NAME"), "{out}"); + assert!(out.contains(r#""-fade-up""#), "{out}"); + + let out = render_str(r#"#![prefix = "app"] fade_up { "from" => "opacity: 0;" }"#); + assert!(out.contains(r#""app-fade-up""#), "{out}"); + assert!(!out.contains("CARGO_CRATE_NAME"), "{out}"); + + // `#[name]` pins the exact CSS name, which is how dwind keeps `spin`. + let out = render_str(r#"#[name = "spin"] spin { "from" => "opacity: 0;" }"#); + assert!(out.contains(r#""spin""#), "{out}"); + assert!(!out.contains("CARGO_CRATE_NAME"), "{out}"); + } + + #[test] + fn register_fn_ensures_every_declared_rule() { + let out = render_str( + r#" + #![register_fn = "app_keyframes"] + a { "from" => "opacity: 0;" } + b { "from" => "opacity: 0;" } + "#, + ); + + assert!(out.contains("fn app_keyframes"), "{out}"); + assert!(out.contains("A_KEYFRAMES . ensure ()"), "{out}"); + assert!(out.contains("B_KEYFRAMES . ensure ()"), "{out}"); + } + + #[test] + fn raw_bodies_pass_through_untouched() { + let out = render_str(r#"marquee = "from { left: 0; } to { left: -50%; }";"#); + + assert!( + out.contains(r#""from { left: 0; } to { left: -50%; }""#), + "{out}" + ); + } + + #[test] + fn an_empty_block_is_rejected() { + assert!(syn::parse_str::("empty { }").is_err()); + } + + #[test] + fn unknown_options_are_rejected() { + assert!(syn::parse_str::( + r#"#![nonsense = "x"] a { "from" => "opacity: 0;" }"# + ) + .is_err()); + assert!(syn::parse_str::( + r#"#[nonsense = "x"] a { "from" => "opacity: 0;" }"# + ) + .is_err()); + } +} diff --git a/crates/dwind-macros/src/lib.rs b/crates/dwind-macros/src/lib.rs index ffb8961..cc80230 100644 --- a/crates/dwind-macros/src/lib.rs +++ b/crates/dwind-macros/src/lib.rs @@ -274,12 +274,18 @@ pub fn dwclass_signal(input: TokenStream) -> TokenStream { /// - `#[animation("1s linear infinite")]` — mint an `animate-` utility /// with this shorthand. Omit it if you only want the handle. /// -/// # Lazy injection has one hole +/// # Injection is lazy, but not conditional /// -/// A rule is injected when its class or its [`Display`](std::fmt::Display) is -/// first used. Reaching for the generated `*_RAW` constant directly — which is -/// what `dwclass!("[&::before]:animate-fade-up")` does internally — bypasses -/// that. Call `.ensure()` or use `#![register_fn]` if you need the guarantee. +/// A rule reaches the document the first time anything reads its declaration or +/// its name: the generated class, a modified form of it +/// (`hover:animate-fade-up`, `[&::before]:animate-fade-up`), or the handle's +/// [`Display`](std::fmt::Display). That covers every path `dwclass!` can take, +/// because the generated `*_RAW` value is an `AnimationDecl` whose `Deref` +/// registers rather than a plain `&str`. +/// +/// Use `#![register_fn]` if you want the rules present regardless of which +/// classes get instantiated — dwind and dwui both do, to keep their existing +/// eager behaviour. #[proc_macro] pub fn dwkeyframes(input: TokenStream) -> TokenStream { let input = match syn::parse::(input) { diff --git a/crates/dwind/resources/css/typography.css b/crates/dwind/resources/css/typography.css index 98c7659..f70399e 100644 --- a/crates/dwind/resources/css/typography.css +++ b/crates/dwind/resources/css/typography.css @@ -248,9 +248,15 @@ .list-decimal { list-style-type: decimal; } /* generated content -------------------------------------------------------- */ +/* + These set --dw-content rather than content, because a `before:`/`after:` + variant emits `content: var(--dw-content, "")`. Going through the property + is what lets two pseudo-element utilities compose without one clobbering the + other's content. See `generated_content` in dwind-macros. +*/ -.content-empty { content: ""; } -.content-none { content: none; } +.content-empty { --dw-content: ""; } +.content-none { --dw-content: none; } /* inheritance -------------------------------------------------------------- */ diff --git a/crates/dwui/tests/styling.rs b/crates/dwui/tests/styling.rs index 5813ba7..2926362 100644 --- a/crates/dwui/tests/styling.rs +++ b/crates/dwui/tests/styling.rs @@ -32,6 +32,20 @@ dwkeyframes! { "from" => "opacity: 0;", "to" => "opacity: 1;", } + + /// Only ever reached through a `hover:` variant. + #[animation("1s linear infinite")] + hover_probe { + "from" => "opacity: 0.4;", + "to" => "opacity: 1;", + } + + /// Only ever reached through a `[&::before]:` variant. + #[animation("1s linear infinite")] + before_probe { + "from" => "opacity: 0.4;", + "to" => "opacity: 1;", + } } struct TestContainer { @@ -178,6 +192,36 @@ async fn dwui_keyframes_survived_the_migration() { } } +#[wasm_bindgen_test] +async fn modified_animation_utilities_still_register_their_keyframes() { + // A variant compiles the declaration text into a fresh class and never + // touches the generated utility, so registration has to hang off the text. + assert_eq!(count_keyframes("dwuitest-hover-probe"), 0); + assert_eq!(count_keyframes("dwuitest-before-probe"), 0); + + let tc = TestContainer::new(); + + dominator::append_dom( + &tc.dom_element(), + html!("div", { + .dwclass!("hover:animate-hover-probe") + .child(html!("span", { .dwclass!("relative [&::before]:animate-before-probe") })) + }), + ); + wait_frame().await; + + assert_eq!( + count_keyframes("dwuitest-hover-probe"), + 1, + "hover:animate-* did not inject its @keyframes" + ); + assert_eq!( + count_keyframes("dwuitest-before-probe"), + 1, + "[&::before]:animate-* did not inject its @keyframes" + ); +} + // --------------------------------------------------------------------------- // Pseudo-element variants // --------------------------------------------------------------------------- @@ -228,6 +272,56 @@ async fn before_shorthand_matches_the_bracket_form() { assert_eq!(computed_pseudo(&el, "::before", "position"), "absolute"); } +#[wasm_bindgen_test] +async fn explicit_pseudo_content_survives_composition() { + // Each utility is its own class, so a literal `content: ""` from + // `before:absolute` would be decided against `before:[content:'x']` by rule + // order. Routing through --dw-content removes the ordering question. + let tc = TestContainer::new(); + + dominator::append_dom( + &tc.dom_element(), + html!("div", { + .attr("id", "probe-compose") + .dwclass!("relative before:[content:'x'] before:absolute before:opacity-100") + }), + ); + wait_frame().await; + + let doc = web_sys::window().unwrap().document().unwrap(); + let el = doc.get_element_by_id("probe-compose").unwrap(); + + let content = computed_pseudo(&el, "::before", "content"); + assert!( + content.contains('x'), + "composed pseudo-element lost its content (got {content:?})" + ); + assert_eq!(computed_pseudo(&el, "::before", "position"), "absolute"); +} + +#[wasm_bindgen_test] +async fn arbitrary_values_may_be_non_ascii() { + let tc = TestContainer::new(); + + dominator::append_dom( + &tc.dom_element(), + html!("div", { + .attr("id", "probe-unicode") + .dwclass!("relative before:[content:'→'] before:m-r-2") + }), + ); + wait_frame().await; + + let doc = web_sys::window().unwrap().document().unwrap(); + let el = doc.get_element_by_id("probe-unicode").unwrap(); + + let content = computed_pseudo(&el, "::before", "content"); + assert!(content.contains('→'), "got {content:?}"); + // The class after the Unicode one must survive too — an unparsed remainder + // used to discard it silently. + assert_eq!(computed_pseudo(&el, "::before", "margin-right"), "8px"); +} + #[wasm_bindgen_test] async fn pseudo_classes_do_not_gain_content() { let tc = TestContainer::new(); From c89557f36793577f2bcb7ef0eda113ae9aac28c9 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 25 Jul 2026 15:52:01 +0200 Subject: [PATCH 3/4] Measure the content-composition claims instead of reasoning about them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --dw-content is order-independent by construction, but that is the entire claim behind the fix, so assert it: `before:[content:'x'] before:absolute` and the reverse order both keep the author's content. Also covers `before:content-none`, which has to suppress the generated default or a utility that only wants to hide a pseudo-element cannot. Probing dwgenerate! turned up one regression worth recording: aliasing a dwkeyframes!-generated utility under a new name no longer compiles, since the generated *_RAW is an AnimationDecl rather than a &str. It fails loudly at the offending line, and the alternative reintroduces the silent "variant references a keyframe that was never injected" bug, so this is the better trade — noted in the changelog. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 9 ++++++ crates/dwui/tests/styling.rs | 53 ++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce2148b..071ba6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,15 @@ changes. `[&::before]:bg-color-[red]` compiled and silently styled the element itself. Generators now honour variants. +### Known rough edge + +`dwgenerate!("my-anim", "animate-fade-up")` — aliasing a `dwkeyframes!`-generated +utility under a new name — no longer compiles, because the generated `*_RAW` is +an `AnimationDecl` rather than a `&'static str` and `dwgenerate!` copies it into a +`&str` static. It fails loudly at the offending line rather than silently, and the +alternative (keeping `*_RAW` a plain `&str`) reintroduces the far worse bug of +variants referencing keyframes that were never injected. Use the class directly. + ### New utilities `delay-0`…`delay-1000`, `underline` / `overline` / `line-through` / diff --git a/crates/dwui/tests/styling.rs b/crates/dwui/tests/styling.rs index 2926362..27cda77 100644 --- a/crates/dwui/tests/styling.rs +++ b/crates/dwui/tests/styling.rs @@ -434,3 +434,56 @@ async fn newly_added_utilities_apply() { "none" ); } + +#[wasm_bindgen_test] +async fn pseudo_content_composes_in_either_order() { + // --dw-content is order-independent by construction, but that is the whole + // claim, so measure it rather than reason about it. + let tc = TestContainer::new(); + + dominator::append_dom( + &tc.dom_element(), + html!("div", { + .child(html!("div", { + .attr("id", "order-a") + .dwclass!("relative before:[content:'a'] before:absolute") + })) + .child(html!("div", { + .attr("id", "order-b") + .dwclass!("relative before:absolute before:[content:'b']") + })) + }), + ); + wait_frame().await; + + let doc = web_sys::window().unwrap().document().unwrap(); + + for (id, want) in [("order-a", 'a'), ("order-b", 'b')] { + let el = doc.get_element_by_id(id).unwrap(); + let content = computed_pseudo(&el, "::before", "content"); + + assert!(content.contains(want), "{id}: got {content:?}"); + assert_eq!(computed_pseudo(&el, "::before", "position"), "absolute"); + } +} + +#[wasm_bindgen_test] +async fn content_utilities_reach_the_pseudo_element() { + // `content-none` has to suppress the generated default, or a utility that + // only wants to hide a pseudo-element cannot. + let tc = TestContainer::new(); + + dominator::append_dom( + &tc.dom_element(), + html!("div", { + .attr("id", "probe-content-none") + .dwclass!("relative before:absolute before:content-none") + }), + ); + wait_frame().await; + + let doc = web_sys::window().unwrap().document().unwrap(); + let el = doc.get_element_by_id("probe-content-none").unwrap(); + + assert_eq!(computed_pseudo(&el, "::before", "content"), "none"); +} From d6360d2a9923e431d0e488faafc99bc4a979fbbc Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 25 Jul 2026 16:10:13 +0200 Subject: [PATCH 4/4] Make arbitrary values quote-aware, and let dwgenerate! alias any class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From re-review of #22. The changelog claimed "any character is allowed" in an arbitrary value. That was an overclaim: the parser tracked `[ ] ( )` as structural everywhere, so `[content:'[']` — valid CSS — failed to parse. Strings are now consumed whole, with backslash escapes, so a bracket inside a quoted value is content rather than a delimiter. An unterminated string is a parse error rather than a silent swallow of the rest of the class list. Separately, probing the reported dwgenerate! limitation showed it was not animation-specific and not new: `dwgenerate!("my-flex", "flex")` never compiled either, because the generated static tried to hold a `&String` in a `String`. Only generator-based selectors ever worked. Both are fixed — the alias case promotes the copied body to a `Lazy`, which also accommodates the `AnimationDecl` a dwkeyframes! utility emits — so an aliased animation still registers its keyframes. Browser suite is 16. The grammar test was checked by removing the quote-handling branch and confirming it fails. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 18 +++---- crates/dwind-macros/src/codegen/mod.rs | 42 +++++++++++++--- crates/dwind-macros/src/grammar/mod.rs | 70 ++++++++++++++++++++++++-- crates/dwui/tests/styling.rs | 64 +++++++++++++++++++++++ 4 files changed, 174 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 071ba6f..9001c1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,8 +54,10 @@ The escape hatch for properties with no utility, matching Tailwind: Values pass through verbatim. Spaces are legal inside the brackets — the bracket delimits the class, not the space — so there is no `_`-means-space convention, -which also means `[color:var(--brand_color)]` keeps its underscore. Any character -is allowed: `[content:'→']` works. +which also means `[color:var(--brand_color)]` keeps its underscore. Parsing is +quote-aware, so a value may contain any character — `[content:'→']` and +`[content:'[']` both work, since a bracket inside a CSS string is content rather +than a delimiter. Unambiguous against the variant syntax because a variant's `]` is always followed by `:`. @@ -96,14 +98,12 @@ changes. `[&::before]:bg-color-[red]` compiled and silently styled the element itself. Generators now honour variants. -### Known rough edge +### `dwgenerate!` can alias any class -`dwgenerate!("my-anim", "animate-fade-up")` — aliasing a `dwkeyframes!`-generated -utility under a new name — no longer compiles, because the generated `*_RAW` is -an `AnimationDecl` rather than a `&'static str` and `dwgenerate!` copies it into a -`&str` static. It fails loudly at the offending line rather than silently, and the -alternative (keeping `*_RAW` a plain `&str`) reintroduces the far worse bug of -variants referencing keyframes that were never injected. Use the class directly. +`dwgenerate!("my-flex", "flex")` never compiled — the generated static tried to +hold a `&String` in a `String`, so only generator-based selectors worked. Aliasing +now works for a plain utility and for a `dwkeyframes!` animation alike, and an +aliased animation still registers its keyframes. ### New utilities diff --git a/crates/dwind-macros/src/codegen/mod.rs b/crates/dwind-macros/src/codegen/mod.rs index 91519ea..cd917ce 100644 --- a/crates/dwind-macros/src/codegen/mod.rs +++ b/crates/dwind-macros/src/codegen/mod.rs @@ -118,24 +118,50 @@ pub fn render_generate_dwind_class(class_name: String, class: DwindClassSelector Span::call_site(), ); - let raw = if class.is_generator() { + let is_generator = class.is_generator(); + + // A generator's body is a `const_format!` call, so it stays a `&'static str`. + // An alias copies another class's body, and that body is only `&'static str` + // some of the time — a `dwkeyframes!` utility emits an `AnimationDecl` so + // that reading it registers the rule. Deref covers both, but not in a const + // initialiser, so the alias case becomes a `Lazy`. + let raw_decl = if is_generator { let generator_call = render_generator_call(&class); - quote! { #generator_call } + quote! { + #[doc(hidden)] + pub static #raw_ident: &str = #generator_call; + } } else { - quote! { #raw_inner_ident } + quote! { + #[doc(hidden)] + pub static #raw_ident: once_cell::sync::Lazy = + once_cell::sync::Lazy::new(|| (&* #raw_inner_ident).to_string()); + } }; - let doc_str = format!("generator call: `{}`", raw); + let doc_str = format!("dwgenerate: `{class_name}`"); + + // `render_dwind_class` returns a bare reference for a plain class and a + // built `class!` for anything with a variant, pseudo-class or generator. + // Only the former needs promoting to an owned `String`. + let (rendered_class, builds_own_class) = { + let rendered = render_dwind_class(class); - let rendered_class = render_dwind_class(class).0; + (rendered.0, rendered.2) + }; + + let class_body = if builds_own_class { + rendered_class + } else { + quote! { (#rendered_class).clone() } + }; quote! { - #[doc(hidden)] - pub static #raw_ident: &str = #raw; + #raw_decl #[doc = #doc_str] pub static #ident: once_cell::sync::Lazy = once_cell::sync::Lazy::new(|| { - #rendered_class + #class_body }); } } diff --git a/crates/dwind-macros/src/grammar/mod.rs b/crates/dwind-macros/src/grammar/mod.rs index 6472b22..1d14e86 100644 --- a/crates/dwind-macros/src/grammar/mod.rs +++ b/crates/dwind-macros/src/grammar/mod.rs @@ -265,16 +265,63 @@ const CHARS_EXT: [char; 13] = [ /// Any character that is not structural to the bracket grammar. /// /// A CSS value can contain essentially anything — `→` in a `content`, a `°` in a -/// gradient angle, a `字` in a font stack. So this is a deny-list of the four -/// delimiters the parser needs to track, not an allow-list of what CSS is +/// gradient angle, a `字` in a font stack. So this is a deny-list of the +/// delimiters the parser has to track, not an allow-list of what CSS is /// permitted. An allow-list here also silently truncated at any non-ASCII byte, /// because `nom`'s `is_alphanumeric` takes a `u8`. +/// +/// Quotes stop a run so that [`quoted_string`] can take over; brackets inside a +/// string are content, not structure. fn is_declaration_char(c: char) -> bool { - !matches!(c, '[' | ']' | '(' | ')') + !matches!(c, '[' | ']' | '(' | ')' | '\'' | '"') +} + +/// A CSS string, consumed whole so that anything inside it — `[`, `)`, a quote +/// of the other kind — is treated as content. +/// +/// Without this, `[content:'[']` is valid CSS that the parser could not read. +fn quoted_string(input: &str) -> IResult<&str, String> { + let quote = match input.chars().next() { + Some(c @ ('\'' | '"')) => c, + _ => { + return Err(nom::Err::Error(nom::error::Error::new( + input, + nom::error::ErrorKind::Tag, + ))) + } + }; + + let mut escaped = false; + + for (i, c) in input.char_indices().skip(1) { + if escaped { + escaped = false; + continue; + } + + match c { + // A CSS escape, `\'` or `\\`. + '\\' => escaped = true, + c if c == quote => { + let end = i + c.len_utf8(); + + return Ok((&input[end..], input[..end].to_string())); + } + _ => {} + } + } + + // Unterminated — let the caller report the whole declaration as unparseable + // rather than silently swallowing the rest of the class string. + Err(nom::Err::Error(nom::error::Error::new( + input, + nom::error::ErrorKind::Tag, + ))) } fn declaration_body<'a>(input: &'a str) -> IResult<&'a str, String> { many0(alt(( + quoted_string, bracketed("(", ")", declaration_body), |v: &'a str| take_while1(is_declaration_char)(v).map(move |v| (v.0, v.1.to_string())), )))(input) @@ -649,6 +696,23 @@ mod test { Some("color:var(--brand_color)".to_string()) ); + // Brackets and parens inside a CSS string are content, not structure. + let parsed = parse_class_string("before:[content:'['] flex").unwrap(); + assert_eq!(parsed.len(), 2, "{parsed:?}"); + assert_eq!(parsed[0].arbitrary, Some("content:'['".to_string())); + assert_eq!(parsed[1].class_name, "flex"); + + let parsed = parse_class_string(r#"[content:"a]b(c"]"#).unwrap(); + assert_eq!(parsed[0].arbitrary, Some(r#"content:"a]b(c""#.to_string())); + + // An escaped quote does not end the string. + let parsed = parse_class_string(r"[content:'it\'s']").unwrap(); + assert_eq!(parsed[0].arbitrary, Some(r"content:'it\'s'".to_string())); + + // An unterminated string is a parse failure, not a silent swallow of + // everything after it. + assert!(std::panic::catch_unwind(|| parse_class_string("[content:'oops] flex")).is_err()); + // Real spaces work inside the brackets. let parsed = parse_class_string("[transition:opacity 650ms ease] flex").unwrap(); assert_eq!(parsed.len(), 2); diff --git a/crates/dwui/tests/styling.rs b/crates/dwui/tests/styling.rs index 27cda77..847cf50 100644 --- a/crates/dwui/tests/styling.rs +++ b/crates/dwui/tests/styling.rs @@ -322,6 +322,29 @@ async fn arbitrary_values_may_be_non_ascii() { assert_eq!(computed_pseudo(&el, "::before", "margin-right"), "8px"); } +#[wasm_bindgen_test] +async fn quoted_values_may_contain_brackets() { + // `[content:'[']` is valid CSS. The parser has to know that a bracket inside + // a string is content rather than a delimiter. + let tc = TestContainer::new(); + + dominator::append_dom( + &tc.dom_element(), + html!("div", { + .attr("id", "probe-bracket") + .dwclass!("relative before:[content:'['] before:absolute") + }), + ); + wait_frame().await; + + let doc = web_sys::window().unwrap().document().unwrap(); + let el = doc.get_element_by_id("probe-bracket").unwrap(); + + let content = computed_pseudo(&el, "::before", "content"); + assert!(content.contains('['), "got {content:?}"); + assert_eq!(computed_pseudo(&el, "::before", "position"), "absolute"); +} + #[wasm_bindgen_test] async fn pseudo_classes_do_not_gain_content() { let tc = TestContainer::new(); @@ -487,3 +510,44 @@ async fn content_utilities_reach_the_pseudo_element() { assert_eq!(computed_pseudo(&el, "::before", "content"), "none"); } + +// dwgenerate! must be able to alias any class — a plain utility, and one minted +// by dwkeyframes!. Aliasing a bare class never worked before (the generated +// static tried to hold a `&String` in a `String`), and the AnimationDecl change +// added a second error on the same path. +dwind_macros::dwgenerate!("aliased-flex", "flex"); +dwind_macros::dwgenerate!("aliased-anim", "animate-slide-probe"); + +#[wasm_bindgen_test] +async fn dwgenerate_can_alias_a_plain_class_and_an_animation() { + let tc = TestContainer::new(); + + dominator::append_dom( + &tc.dom_element(), + html!("div", { + .attr("id", "probe-alias") + .dwclass!("aliased-flex aliased-anim") + }), + ); + wait_frame().await; + + let doc = web_sys::window().unwrap().document().unwrap(); + let el = doc.get_element_by_id("probe-alias").unwrap(); + let style = web_sys::window() + .unwrap() + .get_computed_style(&el) + .unwrap() + .unwrap(); + + assert_eq!(style.get_property_value("display").unwrap(), "flex"); + // Aliasing the animation must still register its keyframes. + assert!( + style + .get_property_value("animation-name") + .unwrap() + .contains("slide-probe"), + "{:?}", + style.get_property_value("animation-name") + ); + assert_eq!(count_keyframes("dwuitest-slide-probe"), 1); +}