render: real-world CSS rendering parity

Colors: oklch()/oklab() and color-mix(); linear-gradient backgrounds.
Grid: named-line placement, repeat() expansion, drop whitespace-only
grid items; upgrade taffy 0.7 -> 0.12 so aspect-ratio images in
auto-height rows no longer collapse to slivers.
CSS: @layer, nesting, @property, :is()/:where(), clamp(), @media not all;
source/track/param/area and svg desc/metadata as display:none.
Images: webp + intrinsic sizing, srcset/picture, browser UA + Accept for
CDN fetches, grey placeholder on failure; width/height HTML attributes
rank below author CSS with aspect-ratio preserved for auto axes.
This commit is contained in:
SGavrl committed 2026-07-11 19:52:36 +00:00
1 parent 88313178b2
commit 2652ee64a4
17 files changed
+4482 -368

No files matched your search

+521 -12
View File
@@ -27,6 +27,17 @@ pub struct Stylesheet {
by_class: HashMap<String, Vec<usize>>,
by_local: HashMap<String, Vec<usize>>,
universal: Vec<usize>,
/// `sel::before{content:"..."}` / `sel::after{...}` rules with a plain
/// string-literal `content`, matched separately from the main cascade:
/// our selector engine has no pseudo-element matching machinery, but a
/// `::before`/`::after` selector's *base* (everything before the
/// pseudo-element) is an ordinary selector we can already compile and
/// match. Extremely common on Wikipedia (`.hlist`/`.cslist` render as
/// comma-separated lists purely via `li::before{content:", "}`); without
/// this, adjacent list items with no real whitespace between them in the
/// DOM run together with no separator at all.
before_content: Vec<(CompiledSelector, String)>,
after_content: Vec<(CompiledSelector, String)>,
}
impl Stylesheet {
@@ -39,10 +50,25 @@ impl Stylesheet {
by_class: HashMap::new(),
by_local: HashMap::new(),
universal: Vec::new(),
before_content: Vec::new(),
after_content: Vec::new(),
};
let mut order = 0usize;
for src in sources {
for (selector, decls) in parse_stylesheet(src) {
let sel_trim = selector.trim();
if let Some(base) = strip_pseudo_element(sel_trim, "before") {
if let (Some(content), Some(sel)) = (extract_string_content(&decls), tree.compile_rule_selector(base)) {
sheet.before_content.push((sel, content));
}
continue;
}
if let Some(base) = strip_pseudo_element(sel_trim, "after") {
if let (Some(content), Some(sel)) = (extract_string_content(&decls), tree.compile_rule_selector(base)) {
sheet.after_content.push((sel, content));
}
continue;
}
let Some(sel) = tree.compile_rule_selector(&selector) else { continue };
let idx = sheet.rules.len();
match sel.key() {
@@ -58,6 +84,28 @@ impl Stylesheet {
sheet
}
/// The literal text (if any) that `::before`/`::after` rules inject for
/// `nid`, as `(before, after)`. Last matching rule wins, approximating
/// cascade order without tracking specificity for this small side table.
pub fn pseudo_content(&self, tree: &DomTree, matcher: &mut Matcher, nid: NodeId) -> (Option<String>, Option<String>) {
if self.before_content.is_empty() && self.after_content.is_empty() {
return (None, None);
}
let mut before = None;
for (sel, content) in &self.before_content {
if matcher.matches(tree, nid, sel) {
before = Some(content.clone());
}
}
let mut after = None;
for (sel, content) in &self.after_content {
if matcher.matches(tree, nid, sel) {
after = Some(content.clone());
}
}
(before, after)
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
@@ -70,6 +118,10 @@ impl Stylesheet {
/// Apply every author rule that matches `nid` to `style`, in cascade order
/// (ascending specificity, then source order, so the winner is applied last).
/// `id`, `classes`, and `local` are the element's precomputed keys.
/// `parent_props` is the element's inherited custom-property map. Returns
/// `Some(map)` (parent + this element's own `--x` declarations) when this
/// element declares any custom properties, so the caller can thread the
/// richer map to descendants; `None` means "reuse the parent's map".
pub fn apply(
&self,
tree: &DomTree,
@@ -79,7 +131,8 @@ impl Stylesheet {
classes: &[String],
local: &str,
style: &mut LayoutStyle,
) {
parent_props: &HashMap<String, String>,
) -> Option<HashMap<String, String>> {
// (specificity, order, rule index) for each matching rule.
let mut matched: Vec<(u32, usize, usize)> = Vec::new();
let mut consider = |bucket: Option<&Vec<usize>>, matched: &mut Vec<(u32, usize, usize)>| {
@@ -105,18 +158,185 @@ impl Stylesheet {
}
if matched.is_empty() {
return;
return None;
}
matched.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
// Pass 1: collect this element's own custom properties (`--x: value`),
// in cascade order (last wins), layered over the inherited map. Custom
// properties cascade fully before any `var()` is substituted.
let mut own: Vec<(String, String)> = Vec::new();
for &(_, _, i) in &matched {
for decl in crate::style::split_declarations(&self.rules[i].decls) {
if let Some((name, val)) = decl.split_once(':') {
let name = name.trim();
if name.starts_with("--") && name.len() > 2 {
own.push((name.to_string(), val.trim().to_string()));
}
}
}
}
let effective = if own.is_empty() {
None
} else {
let mut m = parent_props.clone();
for (k, v) in own {
m.insert(k, v);
}
Some(m)
};
let props = effective.as_ref().unwrap_or(parent_props);
// Pass 2: apply normal declarations with `var()` substituted against
// the resolved custom-property map.
for (_, _, i) in matched {
crate::style::apply_inline(style, &self.rules[i].decls);
let expanded = substitute_vars(&self.rules[i].decls, props, 0);
crate::style::apply_inline(style, &expanded);
}
effective
}
}
/// Substitute every `var(--name, fallback?)` in `input` with its resolved
/// value from `props` (recursively, so a token that itself references another
/// token resolves; a depth cap guards against reference cycles). A missing
/// variable with no fallback expands to empty, which makes the declaration
/// invalid and dropped, exactly as CSS specifies.
pub(crate) fn substitute_vars(input: &str, props: &HashMap<String, String>, depth: u8) -> String {
if depth > 16 || !input.contains("var(") {
return input.to_string();
}
let mut out = String::new();
let mut rest = input;
while let Some(pos) = rest.find("var(") {
out.push_str(&rest[..pos]);
let after = &rest[pos + 4..];
// Matching close paren, respecting nesting.
let mut d = 1i32;
let mut end = None;
for (i, ch) in after.char_indices() {
match ch {
'(' => d += 1,
')' => {
d -= 1;
if d == 0 {
end = Some(i);
break;
}
}
_ => {}
}
}
let Some(end) = end else {
out.push_str(&rest[pos..]);
return out;
};
let inner = &after[..end];
let (name, fallback) = match inner.split_once(',') {
Some((n, f)) => (n.trim(), Some(f.trim())),
None => (inner.trim(), None),
};
let replacement = match props.get(name) {
Some(v) => substitute_vars(v, props, depth + 1),
None => fallback.map(|f| substitute_vars(f, props, depth + 1)).unwrap_or_default(),
};
out.push_str(&replacement);
rest = &after[end + 1..];
}
out.push_str(rest);
out
}
/// If `selector`'s rightmost compound is the pseudo-element `which`
/// (`"before"` or `"after"`, matching either the modern `::` or legacy `:`
/// form), return everything before it, trimmed. Only a trailing
/// pseudo-element is handled: `li::before` strips to `li`, but a selector
/// that uses `::before` as anything other than the final component (not
/// valid CSS in the first place) is left alone.
fn strip_pseudo_element<'a>(selector: &'a str, which: &str) -> Option<&'a str> {
for prefix in ["::", ":"] {
let suffix = format!("{prefix}{which}");
if let Some(base) = selector.strip_suffix(&suffix) {
if !base.is_empty() {
return Some(base.trim());
}
}
}
None
}
/// Pull a plain quoted-string `content` value out of a declaration list
/// (`content: ", "` -> `Some(", ")`). Deliberately narrow: real `content`
/// also accepts `counter()`, `attr()`, `url()` and concatenations of these,
/// none of which apply here (no counters or `attr()`-driven content on the
/// pages this targets), so anything other than a single quoted string is
/// left unhandled rather than guessed at. When `content` is declared more
/// than once (the `content: X; content: X / Y` accessible-alt-text pattern),
/// the last one wins, matching normal CSS declaration order.
fn extract_string_content(decls: &str) -> Option<String> {
let mut result = None;
let mut search_from = 0;
while let Some(rel_idx) = decls[search_from..].find("content") {
let idx = search_from + rel_idx;
search_from = idx + "content".len();
let after = decls[search_from..].trim_start();
let Some(after_colon) = after.strip_prefix(':') else { continue };
let after_colon = after_colon.trim_start();
let Some(quote) = after_colon.chars().next() else { continue };
if quote != '"' && quote != '\'' {
continue;
}
let rest = &after_colon[quote.len_utf8()..];
let Some(end) = rest.find(quote) else { continue };
result = Some(unescape_css_string(&rest[..end]));
}
result
}
/// Decode CSS string escapes: `\` followed by 1-6 hex digits is a Unicode
/// code point (`\200B` -> U+200B ZERO WIDTH SPACE, ubiquitous in generated
/// `content` for accessible section-edit-link brackets and similar), with a
/// single trailing whitespace character consumed as the escape's own
/// terminator per the CSS spec rather than treated as literal content;
/// anything else after a backslash (`\"`, `\\`) is a literal escaped
/// character. Without this, a hex escape prints as its own literal digits.
fn unescape_css_string(s: &str) -> String {
let mut out = String::new();
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
let mut hex = String::new();
while hex.len() < 6 {
match chars.peek() {
Some(h) if h.is_ascii_hexdigit() => {
hex.push(*h);
chars.next();
}
_ => break,
}
}
if !hex.is_empty() {
if matches!(chars.peek(), Some(next) if next.is_whitespace()) {
chars.next();
}
if let Some(ch) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
out.push(ch);
continue;
}
}
if let Some(next) = chars.next() {
out.push(next);
}
}
out
}
/// Split a stylesheet into `(selector, declarations)` rules. Handles nested
/// braces, `/* comments */`, and the at-rules that carry ordinary rules inside
/// (`@media`, `@supports`); other at-rules (`@font-face`, `@keyframes`, ...) are
/// (`@media`, `@supports`, `@layer`); other at-rules (`@font-face`, `@keyframes`, ...) are
/// skipped since they do not contribute layout-relevant declarations here.
pub fn parse_stylesheet(css: &str) -> Vec<(String, String)> {
let mut rules = Vec::new();
@@ -145,6 +365,12 @@ pub fn parse_stylesheet(css: &str) -> Vec<(String, String)> {
current_decls.push(c);
}
block_depth += 1;
} else if c == '}' && block_depth == 0 {
// Stray top-level close brace (unbalanced author CSS; remoteok.com
// ships one mid-sheet). Browsers error-recover and keep parsing;
// without this block_depth goes negative and the state machine
// inverts, scrambling and losing every rule in the rest of the sheet.
current_selector.clear();
} else if c == '}' {
block_depth -= 1;
if block_depth == 0 {
@@ -153,18 +379,23 @@ pub fn parse_stylesheet(css: &str) -> Vec<(String, String)> {
if let Some(at) = sel.strip_prefix('@') {
flush_at_rule(at, sel, decls, &mut rules);
} else {
for s in sel.split(',') {
let s = s.trim();
if !s.is_empty() {
rules.push((s.to_string(), decls.to_string()));
}
}
// The body may contain nested rules (CSS Nesting, ubiquitous
// in Tailwind v4 / modern frameworks: `.a{ &:hover{} .b{} }`).
// Flatten them against this selector; denest also handles the
// no-nesting case (just emits the rule's own declarations).
denest(sel, decls, &mut rules);
}
current_selector.clear();
current_decls.clear();
} else {
current_decls.push(c);
}
} else if c == ';' && block_depth == 0 {
// A `;` at the top level terminates an at-statement that carries no
// rules (`@layer a, b;`, `@import ...;`, `@charset ...;`). Discard
// the buffered text so it cannot bleed into the next rule's selector
// and take a real rule down with it.
current_selector.clear();
} else if block_depth > 0 {
current_decls.push(c);
} else {
@@ -183,6 +414,33 @@ fn flush_at_rule(at: &str, sel: &str, inner: &str, rules: &mut Vec<(String, Stri
}
} else if at.starts_with("supports") {
rules.extend(parse_stylesheet(inner));
} else if at.starts_with("property") {
// `@property --x { syntax:..; initial-value: V }` declares a custom
// property and its default. Register the initial-value as a `:root`
// declaration so `var(--x)` resolves when nothing else sets it. Tailwind
// v4 and modern frameworks define theme tokens this way; dropping them
// left those `var()`s (page/section backgrounds, colors) unresolved.
let name = at["property".len()..].trim();
if name.starts_with("--") {
for decl in inner.split(';') {
if let Some((k, v)) = decl.split_once(':') {
if k.trim().eq_ignore_ascii_case("initial-value") && !v.trim().is_empty() {
rules.push((":root".to_string(), format!("{}: {}", name, v.trim())));
}
}
}
}
} else if at.starts_with("layer") {
// Cascade layers: `@layer name { ... }` wraps ordinary rules. We do not
// model layer priority (real CSS ranks unlayered above layered and
// later layers above earlier); just flatten the body in source order so
// the (specificity, source-order) cascade applies it. Tailwind/UnoCSS,
// Nuxt UI and similar wrap nearly all their CSS, including the `:root`
// design tokens and background/color utilities, in `@layer`; dropping it
// left whole pages unstyled (white backgrounds, collapsed layout). The
// `@layer a, b;` ordering-statement form has no block and is discarded
// by parse_stylesheet's top-level `;` handling.
rules.extend(parse_stylesheet(inner));
}
// Other at-rules (@font-face, @keyframes, @import, ...) carry no
// layout-relevant rules for us, so drop them.
@@ -195,12 +453,40 @@ fn flush_at_rule(at: &str, sel: &str, inner: &str, rules: &mut Vec<(String, Stri
/// strips whitespace before scanning: CSS gives no semantic meaning to spaces
/// inside `(feature: value)`, so it's safe to discard them wholesale rather
/// than special-case every formatting variant a site might use.
fn media_query_applies(query: &str) -> bool {
pub(crate) fn media_query_applies(query: &str) -> bool {
const VIEWPORT_W: f32 = 1280.0;
if query.contains("print") {
return false;
}
let compact: String = query.chars().filter(|c| !c.is_whitespace()).collect();
let compact: String = query.chars().filter(|c| !c.is_whitespace()).flat_map(|c| c.to_lowercase()).collect();
// A `not all` / `not all and (...)` query is a browser-targeting hack (most
// commonly the Safari-only flex-gap fallback `@media not all and
// (min-resolution:.001dpcm)`, which flips containers from flex to grid); it
// does not apply in a Chromium-like context.
if compact.starts_with("notall") {
return false;
}
// Color-scheme: we render the light (default) context. A site's
// `@media (prefers-color-scheme: dark)` block must NOT apply on top of its
// light defaults (that is what was leaking dark backgrounds, e.g. near
// black inline <code>); a `:light` block should apply.
if compact.contains("prefers-color-scheme:dark") {
return false;
}
// Reduced-motion / high-contrast / inverted: default (no preference).
if compact.contains("prefers-reduced-motion:reduce")
|| compact.contains("prefers-contrast:more")
|| compact.contains("prefers-contrast:less")
|| compact.contains("inverted-colors:inverted")
|| compact.contains("forced-colors:active")
{
return false;
}
// Width constraints, both `min-width:`/`max-width:` and the modern range
// forms `width>=Npx` / `(Npx<=width)`.
if let Some(px) = extract_px(&compact, "max-width:") {
if VIEWPORT_W > px {
return false;
@@ -211,9 +497,232 @@ fn media_query_applies(query: &str) -> bool {
return false;
}
}
if let Some(px) = extract_px(&compact, "width<=") {
if VIEWPORT_W > px {
return false;
}
}
if let Some(px) = extract_px(&compact, "width>=") {
if VIEWPORT_W < px {
return false;
}
}
if let Some(px) = extract_px(&compact, "width>") {
if VIEWPORT_W <= px {
return false;
}
}
if let Some(px) = extract_px(&compact, "width<") {
if VIEWPORT_W >= px {
return false;
}
}
true
}
/// Combine a nested selector `child` with its enclosing `parent` (CSS Nesting).
/// `&` in the child is replaced by the parent; a child with no `&` becomes a
/// descendant (`parent child`). Both may be comma lists, so the result is the
/// cartesian product. `parent` is None only at the stylesheet top level, where
/// the child is returned unchanged.
fn combine_selectors(parent: &str, child: &str) -> String {
let pparts = split_selector_list(parent);
let cparts = split_selector_list(child);
let mut out: Vec<String> = Vec::new();
for c in &cparts {
let c = c.trim();
if c.is_empty() {
continue;
}
for p in &pparts {
let p = p.trim();
if c.contains('&') {
out.push(c.replace('&', p));
} else {
out.push(format!("{} {}", p, c));
}
}
}
out.join(", ")
}
/// Flatten a rule body that may contain nested rules (CSS Nesting) into flat
/// `(selector, declarations)` pairs. The parser hands us the whole body of a
/// rule; here we separate its own declarations from nested `sel { ... }` blocks
/// (and nested `@media`/`@supports`/`@layer` at-rules, which keep the parent's
/// selector), emit `(sel, own-declarations)`, and recurse into each nested rule
/// with the combined selector. Without this, Tailwind v4 / modern-framework CSS
/// (which nests almost everything) loses the nested utility rules entirely.
fn denest(sel: &str, body: &str, rules: &mut Vec<(String, String)>) {
let chars: Vec<char> = body.chars().collect();
let n = chars.len();
let mut i = 0;
let mut seg = 0; // start of the current declaration / nested prelude
let mut own = String::new();
let mut quote: Option<char> = None;
let mut comment = false;
let mut paren = 0i32;
while i < n {
let c = chars[i];
if comment {
if c == '*' && chars.get(i + 1) == Some(&'/') {
comment = false;
i += 2;
continue;
}
i += 1;
continue;
}
if let Some(q) = quote {
if c == q {
quote = None;
}
i += 1;
continue;
}
if c == '/' && chars.get(i + 1) == Some(&'*') {
comment = true;
i += 2;
continue;
}
match c {
'\'' | '"' => quote = Some(c),
'(' => paren += 1,
')' => paren = (paren - 1).max(0),
'{' if paren == 0 => {
let prelude: String = chars[seg..i].iter().collect();
// Find the matching close brace (quote/comment aware).
let mut depth = 1;
let mut j = i + 1;
let (mut q2, mut cm2) = (None::<char>, false);
while j < n && depth > 0 {
let cj = chars[j];
if cm2 {
if cj == '*' && chars.get(j + 1) == Some(&'/') {
cm2 = false;
j += 2;
continue;
}
} else if let Some(qq) = q2 {
if cj == qq {
q2 = None;
}
} else if cj == '/' && chars.get(j + 1) == Some(&'*') {
cm2 = true;
j += 2;
continue;
} else if cj == '\'' || cj == '"' {
q2 = Some(cj);
} else if cj == '{' {
depth += 1;
} else if cj == '}' {
depth -= 1;
}
j += 1;
}
let inner: String = chars[i + 1..j.saturating_sub(1).max(i + 1)].iter().collect();
let pre = prelude.trim();
if let Some(at) = pre.strip_prefix('@') {
// A nested at-rule keeps the enclosing selector for its body.
if at.starts_with("media") {
if media_query_applies(pre) {
denest(sel, &inner, rules);
}
} else if at.starts_with("supports") || at.starts_with("layer") {
denest(sel, &inner, rules);
}
} else if !pre.is_empty() {
let full = combine_selectors(sel, pre);
denest(&full, &inner, rules);
}
i = j;
seg = i;
continue;
}
';' if paren == 0 => {
let d: String = chars[seg..i].iter().collect();
let d = d.trim();
if !d.is_empty() {
own.push_str(d);
own.push(';');
}
i += 1;
seg = i;
continue;
}
_ => {}
}
i += 1;
}
let tail: String = chars[seg..].iter().collect();
let tail = tail.trim();
if !tail.is_empty() && !tail.contains('{') {
own.push_str(tail);
own.push(';');
}
if !own.trim().is_empty() {
for s in split_selector_list(sel) {
let s = s.trim();
if !s.is_empty() {
rules.push((s.to_string(), own.clone()));
}
}
}
}
/// Split a CSS selector list at top-level commas only, leaving commas inside
/// `()` (e.g. `:is(a, b)`, `:not(.x, .y)`) and `[]` (`[attr="a,b"]`) and quoted
/// strings intact. A naive `split(',')` shatters those grouped selectors into
/// fragments that fail to compile, dropping the whole rule.
fn split_selector_list(sel: &str) -> Vec<String> {
let mut out = Vec::new();
let mut depth_paren = 0i32;
let mut depth_brack = 0i32;
let mut quote: Option<char> = None;
let mut cur = String::new();
let mut chars = sel.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\\' => {
cur.push(c);
if let Some(n) = chars.next() {
cur.push(n);
}
}
_ if quote == Some(c) => {
quote = None;
cur.push(c);
}
'"' | '\'' if quote.is_none() => {
quote = Some(c);
cur.push(c);
}
'(' if quote.is_none() => {
depth_paren += 1;
cur.push(c);
}
')' if quote.is_none() => {
depth_paren -= 1;
cur.push(c);
}
'[' if quote.is_none() => {
depth_brack += 1;
cur.push(c);
}
']' if quote.is_none() => {
depth_brack -= 1;
cur.push(c);
}
',' if quote.is_none() && depth_paren == 0 && depth_brack == 0 => {
out.push(std::mem::take(&mut cur));
}
_ => cur.push(c),
}
}
out.push(cur);
out
}
/// Read the number immediately following `prop` in `s`. Callers pass a
/// whitespace-stripped `s`, so the first non-digit character always ends the
/// number. Handles a bare px value (`750px`) and the extremely common