mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
Sampled contrast: bound data URIs, resolve sheet urls, reset shorthand
Review fixes for the sampled-contrast path (#560): - A base64 data URI is refused before anything is copied or decoded when its payload would exceed the 24 MiB budget a file gets, the cache keys it by length and hash instead of holding the URI, and a payload without whitespace is decoded in place. - A linked stylesheet from another directory has its relative url()s rewritten to page-relative form when it is inlined, so the sampler resolves the image the winning declaration named against one base instead of guessing across every sheet directory. - Every `background` shorthand resets backgroundRepeat and backgroundSize to what it names, the defaults when it names nothing, so `background: #fff` no longer leaves a stale no-repeat behind for a later background-image; CSS-wide keywords pass through and a bare var() value is left alone the way the expansion leaves it. Tests cover the bound, the rewrite (relative, parent, quoted, query, root-relative, remote, data, fragment, whitespace), and the reset order. Goldens are unchanged. AI assistance: Claude Code (Claude Fable 5.1), on maintainer instruction. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
a82233736c
commit
66945a79da
@@ -97,9 +97,8 @@ pub fn collect_static_css_text(
|
||||
profile: Option<&dyn ProfileSink>,
|
||||
file_path: &str,
|
||||
warn: Option<&dyn Fn(&str)>,
|
||||
) -> (String, Vec<String>) {
|
||||
) -> String {
|
||||
let mut style_texts: Vec<String> = Vec::new();
|
||||
let mut sheet_dirs: Vec<String> = Vec::new();
|
||||
let mut warned_missing_stylesheets: std::collections::HashSet<String> =
|
||||
std::collections::HashSet::new();
|
||||
for style_el in doc.query_selector_all("style") {
|
||||
@@ -120,11 +119,13 @@ pub fn collect_static_css_text(
|
||||
);
|
||||
match read {
|
||||
Ok(bytes) => {
|
||||
style_texts.push(String::from_utf8_lossy(&bytes).into_owned());
|
||||
let dir = jsp::dirname(&css_path);
|
||||
if !sheet_dirs.contains(&dir) {
|
||||
sheet_dirs.push(dir);
|
||||
}
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
let sheet_dir = jsp::dirname(&css_path);
|
||||
style_texts.push(if sheet_dir == file_dir_str {
|
||||
text.into_owned()
|
||||
} else {
|
||||
rewrite_sheet_urls(&text, &sheet_dir, &file_dir_str)
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
if warned_missing_stylesheets.insert(css_path.clone()) {
|
||||
@@ -137,7 +138,57 @@ pub fn collect_static_css_text(
|
||||
}
|
||||
}
|
||||
}
|
||||
(style_texts.join("\n"), sheet_dirs)
|
||||
style_texts.join("\n")
|
||||
}
|
||||
|
||||
static CSS_URL_RE: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(r#"(?i)url\(\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|([^)"']*?))\s*\)"#)
|
||||
.expect("CSS_URL_RE")
|
||||
});
|
||||
static URL_SCHEME_RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"^[A-Za-z][A-Za-z0-9+.-]*:").expect("URL_SCHEME_RE"));
|
||||
|
||||
/// A relative `url()` in a stylesheet is relative to the sheet, and the
|
||||
/// cascade sees one concatenated text, so a sheet inlined from another
|
||||
/// directory has its relative urls rewritten to page-relative form here.
|
||||
/// This is what lets the sampled-contrast path (#560) resolve the image the
|
||||
/// winning declaration named. Root-relative, remote, `data:`, fragment, and
|
||||
/// escaped urls are left as they are.
|
||||
pub fn rewrite_sheet_urls(css: &str, sheet_dir: &str, page_dir: &str) -> String {
|
||||
CSS_URL_RE
|
||||
.replace_all(css, |caps: ®ex::Captures| {
|
||||
let whole = caps.get(0).map(|m| m.as_str()).unwrap_or("");
|
||||
let (target, quote) = match (caps.get(1), caps.get(2), caps.get(3)) {
|
||||
(Some(m), _, _) => (m.as_str(), "\""),
|
||||
(_, Some(m), _) => (m.as_str(), "'"),
|
||||
(_, _, Some(m)) => (js::trim(m.as_str()), ""),
|
||||
_ => return whole.to_string(),
|
||||
};
|
||||
let lower = js::to_lower_case(target);
|
||||
if target.is_empty()
|
||||
|| target.contains('\\')
|
||||
|| target.starts_with('#')
|
||||
|| target.starts_with('/')
|
||||
|| lower.starts_with("data:")
|
||||
|| URL_SCHEME_RE.is_match(target)
|
||||
{
|
||||
return whole.to_string();
|
||||
}
|
||||
let cut = target.find(['?', '#']).unwrap_or(target.len());
|
||||
let (path, suffix) = target.split_at(cut);
|
||||
let absolute = jsp::resolve("/", &[sheet_dir, path]);
|
||||
let relative = jsp::to_posix(&jsp::relative("/", page_dir, &absolute));
|
||||
if relative.is_empty() {
|
||||
return whole.to_string();
|
||||
}
|
||||
let needs_quotes = quote.is_empty()
|
||||
&& relative
|
||||
.chars()
|
||||
.any(|c| c.is_whitespace() || matches!(c, '(' | ')' | '"' | '\''));
|
||||
let quote = if needs_quotes { "\"" } else { quote };
|
||||
format!("url({quote}{relative}{suffix}{quote})")
|
||||
})
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
static PSEUDO_RULE_RE: Lazy<Regex> = Lazy::new(|| {
|
||||
|
||||
@@ -257,12 +257,22 @@ fn parse_static_background_layers(value: &str) -> (String, String) {
|
||||
/// part of `expand_static_declaration`, whose output the recorded vectors
|
||||
/// pin; the cascade stores these beside it (`apply_static_longhand`) so the
|
||||
/// sampled-contrast path (#560) can tell a tiled or cover image from a
|
||||
/// no-repeat icon. A `background` shorthand with an image resets both to
|
||||
/// what it names, as in CSS.
|
||||
/// no-repeat icon. Every `background` shorthand resets both to what it
|
||||
/// names, the defaults when it names nothing, as in CSS; a CSS-wide keyword
|
||||
/// passes through, and a bare `var()` value is left alone the way the
|
||||
/// expansion leaves it.
|
||||
pub fn background_longhands(prop: &str, value: &str) -> Vec<Expanded> {
|
||||
let v = js::trim(value);
|
||||
match js::to_lower_case(prop).as_str() {
|
||||
"background" if BG_IMAGE_RE.is_match(v) => {
|
||||
"background" if VAR_ANYWHERE_RE.is_match(v) && !BG_IMAGE_RE.is_match(v) => Vec::new(),
|
||||
"background" if CSS_WIDE_KEYWORD_RE.is_match(v) => {
|
||||
let keyword = js::to_lower_case(v);
|
||||
vec![
|
||||
("backgroundRepeat".into(), keyword.clone()),
|
||||
("backgroundSize".into(), keyword),
|
||||
]
|
||||
}
|
||||
"background" => {
|
||||
let (repeat, size) = parse_static_background_layers(v);
|
||||
vec![
|
||||
("backgroundRepeat".into(), repeat),
|
||||
@@ -275,6 +285,9 @@ pub fn background_longhands(prop: &str, value: &str) -> Vec<Expanded> {
|
||||
}
|
||||
}
|
||||
|
||||
static CSS_WIDE_KEYWORD_RE: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(r"(?i)^(?:inherit|initial|unset|revert|revert-layer)$").expect("CSS_WIDE_KEYWORD_RE")
|
||||
});
|
||||
static BG_IMAGE_RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"(?i)gradient|url\(").expect("BG_IMAGE_RE"));
|
||||
static BG_IMAGE_SPLIT_RE: Lazy<Regex> = Lazy::new(|| {
|
||||
|
||||
@@ -213,11 +213,10 @@ pub fn detect_html_source(
|
||||
Meta::new("parse-html", "parse-document", fp),
|
||||
|| StaticDocument::parse(html),
|
||||
);
|
||||
let (css_text, sheet_dirs) =
|
||||
collect_static_css_text(&doc, &file_dir, profile, fp, options.warn);
|
||||
let css_text = collect_static_css_text(&doc, &file_dir, profile, fp, options.warn);
|
||||
build_static_style_map(&mut doc, css_text.as_str(), profile, fp);
|
||||
let doc = doc;
|
||||
let images = ImageSampler::new(&file_dir.to_string_lossy(), &sheet_dirs);
|
||||
let images = ImageSampler::new(&file_dir.to_string_lossy());
|
||||
|
||||
let mut findings: Vec<Finding> = Vec::new();
|
||||
let mk = |id: &str, snippet: &str| try_finding(id, fp, snippet, 0.0);
|
||||
@@ -402,7 +401,7 @@ pub fn unsupported_selectors(html: &str, file_path: &Path) -> Vec<String> {
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_default();
|
||||
let mut doc = StaticDocument::parse(html);
|
||||
let (css_text, _) = collect_static_css_text(&doc, &file_dir, None, &file_str, None);
|
||||
let css_text = collect_static_css_text(&doc, &file_dir, None, &file_str, None);
|
||||
build_static_style_map(&mut doc, &css_text, None, &file_str);
|
||||
doc.unsupported_selectors()
|
||||
}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
//! The pixels behind image-backed text for the static engine (#560). A
|
||||
//! `url()` resolves to bytes from a local file next to the markup or from a
|
||||
//! base64 data URI, never from the network; the pure-Rust decoders turn them
|
||||
//! into a raster no larger than the browser overlay's 640px canvas; and the
|
||||
//! raster is cached for the rest of the process, so a directory scan decodes
|
||||
//! each hero once. Anything unreadable, remote, oversized, or undecodable is
|
||||
//! `None`, and the caller keeps today's skip.
|
||||
//! `url()` resolves to bytes from a local file relative to the page (linked
|
||||
//! stylesheets have their urls rewritten to page-relative form when they are
|
||||
//! inlined, see `rewrite_sheet_urls`) or from a base64 data URI, never from
|
||||
//! the network; the pure-Rust decoders turn them into a raster no larger than
|
||||
//! the browser overlay's 640px canvas; and the raster is cached for the rest
|
||||
//! of the process, so a directory scan decodes each hero once. Anything
|
||||
//! unreadable, remote, oversized, or undecodable is `None`, and the caller
|
||||
//! keeps today's skip. The same byte budget bounds a file on disk and a data
|
||||
//! URI's payload, checked before anything is copied or decoded.
|
||||
|
||||
use crate::cascade::resolve_linked_css_path;
|
||||
use base64::Engine;
|
||||
use impeccable_common::jsp;
|
||||
use impeccable_core::color::Rgba;
|
||||
use std::borrow::Cow;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::io::Cursor;
|
||||
use std::rc::Rc;
|
||||
|
||||
@@ -20,6 +25,9 @@ const MAX_RASTER_SIDE: u32 = 640;
|
||||
/// Files above this are not read: a hero is a few megabytes, and the hook
|
||||
/// runs on every edit.
|
||||
const MAX_FILE_BYTES: u64 = 24 * 1024 * 1024;
|
||||
/// The base64 payload length that decodes to [`MAX_FILE_BYTES`], so a data
|
||||
/// URI is refused before its payload is copied or decoded.
|
||||
const MAX_DATA_URI_CHARS: usize = (MAX_FILE_BYTES as usize / 3) * 4 + 4;
|
||||
const MAX_IMAGE_SIDE: u32 = 8192;
|
||||
const MAX_DECODE_BYTES: u64 = 128 * 1024 * 1024;
|
||||
/// Decoded rasters kept per process; the map is cleared when full.
|
||||
@@ -49,22 +57,18 @@ thread_local! {
|
||||
static RASTERS: RefCell<HashMap<String, Option<Rc<Raster>>>> = RefCell::new(HashMap::new());
|
||||
}
|
||||
|
||||
/// Resolves and decodes the `url()` grounds of one document. The bases are
|
||||
/// the document's directory followed by every linked stylesheet's, since a
|
||||
/// relative `url()` inside a sheet is relative to the sheet.
|
||||
/// Resolves and decodes the `url()` grounds of one document, relative to
|
||||
/// the document's directory. A url from a linked stylesheet reaches the
|
||||
/// cascade already rewritten to page-relative form.
|
||||
pub struct ImageSampler {
|
||||
bases: Vec<String>,
|
||||
base: String,
|
||||
}
|
||||
|
||||
impl ImageSampler {
|
||||
pub fn new(html_dir: &str, stylesheet_dirs: &[String]) -> Self {
|
||||
let mut bases = vec![html_dir.to_string()];
|
||||
for dir in stylesheet_dirs {
|
||||
if !bases.contains(dir) {
|
||||
bases.push(dir.clone());
|
||||
}
|
||||
pub fn new(html_dir: &str) -> Self {
|
||||
ImageSampler {
|
||||
base: html_dir.to_string(),
|
||||
}
|
||||
ImageSampler { bases }
|
||||
}
|
||||
|
||||
/// The raster behind a `url()` argument, or `None` when it cannot be
|
||||
@@ -76,7 +80,12 @@ impl ImageSampler {
|
||||
return None;
|
||||
}
|
||||
let key = if is_data_uri(url) {
|
||||
url.to_string()
|
||||
// Refused before anything is allocated: a project file can carry
|
||||
// any size of data URI, and the hook scans on every edit.
|
||||
if url.len() > MAX_DATA_URI_CHARS + 256 {
|
||||
return None;
|
||||
}
|
||||
data_uri_key(url)
|
||||
} else {
|
||||
self.resolve_file(url)?
|
||||
};
|
||||
@@ -103,14 +112,11 @@ impl ImageSampler {
|
||||
if url.starts_with("//") || url.contains("://") {
|
||||
return None;
|
||||
}
|
||||
self.bases
|
||||
.iter()
|
||||
.map(|base| resolve_linked_css_path(base, url))
|
||||
.find(|path| {
|
||||
std::fs::metadata(path)
|
||||
.map(|m| m.is_file())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
let path = resolve_linked_css_path(&self.base, url);
|
||||
std::fs::metadata(&path)
|
||||
.map(|m| m.is_file())
|
||||
.unwrap_or(false)
|
||||
.then_some(path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +124,14 @@ fn is_data_uri(url: &str) -> bool {
|
||||
url.len() > 5 && url[..5].eq_ignore_ascii_case("data:")
|
||||
}
|
||||
|
||||
/// The cache key of a data URI: its length and a hash, so the cache never
|
||||
/// holds a copy of the URI itself.
|
||||
fn data_uri_key(url: &str) -> String {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
url.hash(&mut hasher);
|
||||
format!("data:{}:{:016x}", url.len(), hasher.finish())
|
||||
}
|
||||
|
||||
/// The name a finding gives the image: the file name, or `data:<mime>`.
|
||||
pub fn ground_label(url: &str) -> String {
|
||||
let url = url.trim();
|
||||
@@ -147,10 +161,17 @@ fn data_uri_bytes(url: &str) -> Option<Vec<u8>> {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let compact: String = payload.chars().filter(|c| !c.is_whitespace()).collect();
|
||||
if payload.len() > MAX_DATA_URI_CHARS {
|
||||
return None;
|
||||
}
|
||||
let compact: Cow<str> = if payload.chars().any(char::is_whitespace) {
|
||||
Cow::Owned(payload.chars().filter(|c| !c.is_whitespace()).collect())
|
||||
} else {
|
||||
Cow::Borrowed(payload)
|
||||
};
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(&compact)
|
||||
.or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&compact))
|
||||
.decode(compact.as_ref())
|
||||
.or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(compact.as_ref()))
|
||||
.ok()
|
||||
}
|
||||
|
||||
@@ -212,7 +233,7 @@ mod tests {
|
||||
"data:image/png;base64,{}",
|
||||
base64::engine::general_purpose::STANDARD.encode(&bytes)
|
||||
);
|
||||
let sampler = ImageSampler::new("/nonexistent", &[]);
|
||||
let sampler = ImageSampler::new("/nonexistent");
|
||||
let raster = sampler.load(&uri).expect("png data uri decodes");
|
||||
assert_eq!((raster.width, raster.height), (8, 8));
|
||||
assert_eq!(raster.pixel(3, 3).r, 240.0);
|
||||
@@ -221,24 +242,29 @@ mod tests {
|
||||
assert!(sampler.load("https://example.com/hero.jpg").is_none());
|
||||
assert!(sampler.load("//cdn.example.com/hero.jpg").is_none());
|
||||
assert_eq!(ground_label("img/hero.jpg?v=3"), "hero.jpg");
|
||||
// A payload past the byte budget is refused before it is decoded.
|
||||
let huge = format!(
|
||||
"data:image/png;base64,{}",
|
||||
"A".repeat(MAX_DATA_URI_CHARS + 1)
|
||||
);
|
||||
assert!(sampler.load(&huge).is_none());
|
||||
assert_ne!(data_uri_key(&uri), data_uri_key(&huge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_resolve_against_every_base_and_downscale() {
|
||||
fn files_resolve_against_the_page_dir_and_downscale() {
|
||||
let dir = std::env::temp_dir().join(format!("impeccable-sampler-{}", std::process::id()));
|
||||
let sheet_dir = dir.join("css");
|
||||
std::fs::create_dir_all(&sheet_dir).unwrap();
|
||||
std::fs::create_dir_all(dir.join("img")).unwrap();
|
||||
std::fs::write(
|
||||
sheet_dir.join("wide.png"),
|
||||
dir.join("img").join("wide.png"),
|
||||
png_bytes(1280, 320, [20, 20, 20, 255]),
|
||||
)
|
||||
.unwrap();
|
||||
let html_dir = dir.to_string_lossy().into_owned();
|
||||
let sampler = ImageSampler::new(&html_dir, &[sheet_dir.to_string_lossy().into_owned()]);
|
||||
assert!(sampler.load("missing.png").is_none());
|
||||
let sampler = ImageSampler::new(&dir.to_string_lossy());
|
||||
assert!(sampler.load("wide.png").is_none());
|
||||
let raster = sampler
|
||||
.load("wide.png")
|
||||
.expect("resolves against the sheet dir");
|
||||
.load("img/wide.png")
|
||||
.expect("resolves against the page dir");
|
||||
assert_eq!((raster.width, raster.height), (640, 160));
|
||||
assert_eq!(
|
||||
(raster.intrinsic_width, raster.intrinsic_height),
|
||||
@@ -246,7 +272,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(raster.pixel(639, 159).g, 20.0);
|
||||
// The second load is the cached raster.
|
||||
assert!(Rc::ptr_eq(&raster, &sampler.load("wide.png").unwrap()));
|
||||
assert!(Rc::ptr_eq(&raster, &sampler.load("img/wide.png").unwrap()));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,30 +249,48 @@ fn background_longhands_ride_beside_the_expansion() {
|
||||
background_longhands("Background-Size", "100% 32px"),
|
||||
vec![("backgroundSize".to_string(), "100% 32px".to_string())]
|
||||
);
|
||||
assert!(background_longhands("background", "#fff").is_empty());
|
||||
// A color-only shorthand still resets both longhands, as in CSS.
|
||||
assert_eq!(
|
||||
background_longhands("background", "#fff"),
|
||||
vec![
|
||||
("backgroundRepeat".to_string(), "repeat".to_string()),
|
||||
("backgroundSize".to_string(), "auto".to_string()),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
background_longhands("background", "Inherit"),
|
||||
vec![
|
||||
("backgroundRepeat".to_string(), "inherit".to_string()),
|
||||
("backgroundSize".to_string(), "inherit".to_string()),
|
||||
]
|
||||
);
|
||||
assert!(background_longhands("background", "var(--surface)").is_empty());
|
||||
assert!(background_longhands("color", "red").is_empty());
|
||||
|
||||
// A later shorthand with an image resets an earlier longhand, and a
|
||||
// later longhand overrides a shorthand, under the cascade's priority.
|
||||
let mut specified: SpecifiedStore<&str> = SpecifiedStore::new();
|
||||
let node = "n1";
|
||||
let mut apply = |prop: &str, value: &str, m: DeclMeta| {
|
||||
apply_static_declaration(&mut specified, node, prop, value, &m);
|
||||
let apply = |specified: &mut SpecifiedStore<&str>, prop: &str, value: &str, m: DeclMeta| {
|
||||
apply_static_declaration(specified, node, prop, value, &m);
|
||||
for (p, v) in background_longhands(prop, value) {
|
||||
apply_static_longhand(&mut specified, node, &p, &v, &m);
|
||||
apply_static_longhand(specified, node, &p, &v, &m);
|
||||
}
|
||||
};
|
||||
apply(
|
||||
&mut specified,
|
||||
"background-repeat",
|
||||
"no-repeat",
|
||||
meta(false, [0, 1, 0], 0, false),
|
||||
);
|
||||
apply(
|
||||
&mut specified,
|
||||
"background",
|
||||
"url(hero.jpg) center / cover",
|
||||
meta(false, [0, 1, 0], 1, false),
|
||||
);
|
||||
apply(
|
||||
&mut specified,
|
||||
"background-size",
|
||||
"contain",
|
||||
meta(false, [0, 1, 0], 2, false),
|
||||
@@ -290,4 +308,65 @@ fn background_longhands_ride_beside_the_expansion() {
|
||||
map.get("backgroundImage").map(|d| d.value.as_str()),
|
||||
Some("url(hero.jpg) center / cover")
|
||||
);
|
||||
// `background: #fff` after a longhand resets it, so a later
|
||||
// `background-image` is classified with the defaults, not a stale value.
|
||||
apply(
|
||||
&mut specified,
|
||||
"background-repeat",
|
||||
"no-repeat",
|
||||
meta(false, [0, 1, 0], 3, false),
|
||||
);
|
||||
apply(
|
||||
&mut specified,
|
||||
"background",
|
||||
"#fff",
|
||||
meta(false, [0, 1, 0], 4, false),
|
||||
);
|
||||
let map = specified.get(&node).expect("node entry");
|
||||
assert_eq!(
|
||||
map.get("backgroundRepeat").map(|d| d.value.as_str()),
|
||||
Some("repeat")
|
||||
);
|
||||
assert_eq!(
|
||||
map.get("backgroundSize").map(|d| d.value.as_str()),
|
||||
Some("auto")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_sheet_urls_are_rewritten_page_relative() {
|
||||
use impeccable_html::cascade::build::rewrite_sheet_urls;
|
||||
|
||||
let css = concat!(
|
||||
".a { background: url(light.png) }\n",
|
||||
".b { background: url(\"../img/hero.jpg?v=3\") no-repeat }\n",
|
||||
".c { background-image: url('./x.webp'), url(/root.png), url(data:image/png;base64,AAAA) }\n",
|
||||
".d { background: url(https://cdn.example.com/a.png) }\n",
|
||||
".e { mask: url(#clip) }\n",
|
||||
".f { background: URL( a b.png ) }\n",
|
||||
);
|
||||
let out = rewrite_sheet_urls(css, "/site/css", "/site");
|
||||
assert!(
|
||||
out.contains(".a { background: url(css/light.png) }"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains(".b { background: url(\"img/hero.jpg?v=3\") no-repeat }"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("url('css/x.webp'), url(/root.png), url(data:image/png;base64,AAAA)"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(out.contains("url(https://cdn.example.com/a.png)"), "{out}");
|
||||
assert!(out.contains("url(#clip)"), "{out}");
|
||||
assert!(out.contains("url(\"css/a b.png\")"), "{out}");
|
||||
// A sheet beside the page keeps every path where it was (the engine
|
||||
// does not even call the rewrite for that case).
|
||||
let same = rewrite_sheet_urls(css, "/site", "/site");
|
||||
assert!(same.contains(".a { background: url(light.png) }"), "{same}");
|
||||
assert!(same.contains("url(\"../img/hero.jpg?v=3\")"), "{same}");
|
||||
// A sheet above the page walks back up.
|
||||
let up = rewrite_sheet_urls(".a { background: url(light.png) }", "/site", "/site/pages");
|
||||
assert_eq!(up, ".a { background: url(../light.png) }");
|
||||
}
|
||||
|
||||
@@ -332,7 +332,7 @@ Optional keys added later by engines (appended after the above): `ignoreValue` (
|
||||
#### Static and regex engines (only what affects the contract)
|
||||
|
||||
- `detectHtml`: reads file, imports `htmlparser2`, `css-select`, `css-tree`, `domutils`; on import failure prints once to stderr `impeccable detect: DEGRADED - HTML parser modules unavailable (htmlparser2, css-select, css-tree, domutils).\nFalling back to regex matching. Custom properties, selector matching and computed contrast are NOT evaluated; findings are an undercount, not a clean bill of health.\n` and falls back to `detectText`. Inlines `<link rel=stylesheet href>` that are local (not `/^(https?:)?\/\//i`), query/hash stripped. Runs element rules, design-system rules (`checkSourceDesignSystem` + `collectStaticDesignSystemFindings`, merged), then page rules only when `isFullPage(html)` (`/<!doctype\s|<html[\s>]|<head[\s>]/i` after stripping comments), plus text-content analyzers; ends with inline-ignore filtering.
|
||||
- **Sampled contrast (#560, engine only, no JS ancestor)**: when `resolveBackgroundInfo` ends at a `url()` layer (the case the JS skipped), the static engine reads that image and measures the text against its pixels. The url resolves to a local file (relative to the page, then to each linked stylesheet's directory; root-relative paths walk up to the project root the way linked stylesheets do) or a base64 `data:` URI; a remote URL, a missing or unreadable file, a file over 24 MiB, an image over 8192px on a side, and SVG keep the skip. Decoders: PNG, JPEG, GIF, WebP. The image is scaled to at most 640px on a side and read on a fixed 6x6 grid. Each sample composites a translucent pixel over the element's own color and its parent's resolved ground (white at the root; unknown when another layer sits beneath the image), then under every translucent surface and every uniform gradient wash between the text and the image; a gradient whose stops differ is a scrim placed on purpose and keeps the skip, as does an opaque gradient or an unparseable color anywhere in the chain. A layer whose `background-repeat` leaves an axis unrepeated and whose painted extent on that axis (`background-size` in px, else the intrinsic size) is under 160px is decoration and keeps the skip; `cover`, `contain`, and percentage sizes always paint. The cascade carries `backgroundRepeat` / `backgroundSize` for this from the longhands and from the `background` shorthand (per layer, defaults `repeat` / `auto`). A verdict needs 27 of the 36 samples; the finding fires when the 90th-percentile ratio is under the WCAG threshold (same large-text rule as `checkColors`, text alpha blended over the sample): `{id:'low-contrast', snippet:`sampled (coarse) ${p90}:1 (need ${threshold}:1) — text ${hex} on ${file name | data:<mime>}; p90 of ${n} samples, median ${median}:1`}` (ratio label to 2 decimals when its 1-decimal form equals the threshold, as in `checkColors`). It is emitted in the `color-rules` pass right after `checkColors`'s hits for that element, obeys the same `SAFE_TAGS` gate, `data-impeccable-ignore`, and inline ignores, and never produces `gray-on-color`. Fixture: `sampled-image-contrast.html` (+ `sampled-images/`).
|
||||
- **Sampled contrast (#560, engine only, no JS ancestor)**: when `resolveBackgroundInfo` ends at a `url()` layer (the case the JS skipped), the static engine reads that image and measures the text against its pixels. The url resolves to a local file relative to the page (a linked stylesheet from another directory has its relative urls rewritten to page-relative form when it is inlined, so a url resolves against the sheet that declared it; root-relative paths walk up to the project root the way linked stylesheets do) or a base64 `data:` URI; a remote URL, a missing or unreadable file, a file over 24 MiB, a data URI whose payload decodes to more than that, an image over 8192px on a side, and SVG keep the skip. Decoders: PNG, JPEG, GIF, WebP. The image is scaled to at most 640px on a side and read on a fixed 6x6 grid. Each sample composites a translucent pixel over the element's own color and its parent's resolved ground (white at the root; unknown when another layer sits beneath the image), then under every translucent surface and every uniform gradient wash between the text and the image; a gradient whose stops differ is a scrim placed on purpose and keeps the skip, as does an opaque gradient or an unparseable color anywhere in the chain. A layer whose `background-repeat` leaves an axis unrepeated and whose painted extent on that axis (`background-size` in px, else the intrinsic size) is under 160px is decoration and keeps the skip; `cover`, `contain`, and percentage sizes always paint. The cascade carries `backgroundRepeat` / `backgroundSize` for this from the longhands and from the `background` shorthand (per layer, defaults `repeat` / `auto`). A verdict needs 27 of the 36 samples; the finding fires when the 90th-percentile ratio is under the WCAG threshold (same large-text rule as `checkColors`, text alpha blended over the sample): `{id:'low-contrast', snippet:`sampled (coarse) ${p90}:1 (need ${threshold}:1) — text ${hex} on ${file name | data:<mime>}; p90 of ${n} samples, median ${median}:1`}` (ratio label to 2 decimals when its 1-decimal form equals the threshold, as in `checkColors`). It is emitted in the `color-rules` pass right after `checkColors`'s hits for that element, obeys the same `SAFE_TAGS` gate, `data-impeccable-ignore`, and inline ignores, and never produces `gray-on-color`. Fixture: `sampled-image-contrast.html` (+ `sampled-images/`).
|
||||
- `detectText`: regex line matchers (ids: side-tab, border-accent-on-rounded, overused-font, gradient-text, ai-color-palette, gray-on-color, bounce-easing, layout-transition, broken-image), inset-stripe/pseudo-stripe CSS scans, `codex-grid-background`, `<style>` blocks (Astro/Vue/Svelte), CSS-in-JS templates, design-system source checks; dedupe (same antipattern+snippet within 2 lines); page analyzers only when `isFullPage` and ext ∈ `{'.html','.htm','.astro','.vue','.svelte'}` or no ext (`<stdin>`): flat-type-hierarchy, monotonous-spacing, em-dash-overuse, marketing-buzzword, aphoristic-cadence, dark-glow (+ radial-halo, marquee); inline ignores last.
|
||||
|
||||
#### Profiler (`cli/engine/profile/profiler.mjs`)
|
||||
|
||||
Reference in New Issue
Block a user