1//! User profile presentation.2//!3//! A profile's presentation is not a stylesheet. It is a list of assignments to4//! named Pico variables and dev.site layout settings, each with a declared value5//! grammar — so "is this valid?" is a question with a mechanical answer rather6//! than a judgement call.7//!8//! Two things follow from that, and both are the point:9//!10//! - **It cannot break the page.** No selectors, no properties, no at-rules, no11//! `!important`. A theme can only recolour and re-space what the profile12//! template already lays out; it cannot position, hide, or overlay anything.13//! - **It cannot inject.** Every property name is one of the `&'static str`s in14//! [`PROPERTIES`]. Pico values have passed a restricted CSS-value grammar;15//! quoted folder names have passed a bounded JSON-string grammar and never16//! enter the generated `<style>` rule at all.17//!18//! Every `--pico-*` variable below exists in the vendored Pico 2.1.1; every19//! `--devsite-*` setting has an explicit renderer consumer. An unknown name is20//! rejected instead of being stored and quietly doing nothing.2122use serde::Serialize;2324/// Longest accepted source, before parsing. Presentation is a few dozen short lines.25pub const MAX_INPUT: usize = 4096;26/// Longest accepted value. This leaves room for two functional colours inside27/// `light-dark()` without changing the overall [`MAX_INPUT`] bound.28pub const MAX_VALUE: usize = 128;29/// Folder names are labels, not descriptions. Count Unicode scalar values so30/// non-ASCII text remains valid while the stored and rendered label stays small.31pub const MAX_FOLDER_NAME: usize = 40;32/// A layout list remains well below the whole-document limit even when it names33/// many folders. This also bounds the work done by the browser renderer.34const MAX_LAYOUT_FOLDERS: usize = 64;35const MAX_LAYOUT_VALUE: usize = 3072;3637/// One validated assignment. `property` is borrowed from [`PROPERTIES`], so a38/// stored declaration cannot name anything outside the table.3940pub struct Declaration {41 pub property: &'static str,42 pub value: String,43}4445/// The grammar a property's value must satisfy.46enum Kind {47 /// Hex, `rgb()`/`rgba()`/`hsl()`/`hsla()`/`oklch()`, a CSS colour name,48 /// `transparent`, `currentcolor`, or exactly two of those in `light-dark()`.49 Color,50 /// A non-negative number with a unit, or bare `0`.51 Length,52 /// A non-negative unitless number.53 Number,54 /// One of a fixed set of words.55 Keyword(&'static [&'static str]),56 /// One or more JSON-style quoted folder names, separated by commas.57 FolderNames,58}5960/// The one key that is not a Pico variable: it chooses which of Pico's own61/// palettes the profile starts from, by setting `data-theme` on the page.62pub const SCHEME: &str = "--devsite-scheme";63pub const FOLDERS: &str = "--devsite-folders";64pub const OPEN_FOLDERS: &str = "--devsite-open-folders";65pub const FOLDER_ORDER: &str = "--devsite-folder-order";6667/// Every profile presentation property, with the grammar of its value.68///69/// Ordered as documented: dev.site controls, then colour, metrics, and type.70const PROPERTIES: &[(&str, Kind)] = &[71 (SCHEME, Kind::Keyword(&["light", "dark", "auto"])),72 (FOLDERS, Kind::Keyword(&["open", "closed"])),73 (OPEN_FOLDERS, Kind::FolderNames),74 (FOLDER_ORDER, Kind::FolderNames),75 // -- surfaces and text ---------------------------------------------------76 ("--pico-background-color", Kind::Color),77 ("--pico-color", Kind::Color),78 ("--pico-muted-color", Kind::Color),79 ("--pico-muted-border-color", Kind::Color),80 ("--pico-border-color", Kind::Color),81 ("--pico-text-selection-color", Kind::Color),82 // -- accents -------------------------------------------------------------83 ("--pico-primary", Kind::Color),84 ("--pico-primary-background", Kind::Color),85 ("--pico-primary-hover", Kind::Color),86 ("--pico-primary-hover-background", Kind::Color),87 ("--pico-primary-inverse", Kind::Color),88 ("--pico-primary-underline", Kind::Color),89 ("--pico-primary-focus", Kind::Color),90 ("--pico-secondary", Kind::Color),91 ("--pico-secondary-background", Kind::Color),92 ("--pico-secondary-hover", Kind::Color),93 ("--pico-secondary-inverse", Kind::Color),94 ("--pico-contrast", Kind::Color),95 ("--pico-contrast-background", Kind::Color),96 ("--pico-contrast-inverse", Kind::Color),97 // -- headings ------------------------------------------------------------98 ("--pico-h1-color", Kind::Color),99 ("--pico-h2-color", Kind::Color),100 ("--pico-h3-color", Kind::Color),101 ("--pico-h4-color", Kind::Color),102 ("--pico-h5-color", Kind::Color),103 ("--pico-h6-color", Kind::Color),104 // -- blocks --------------------------------------------------------------105 ("--pico-card-background-color", Kind::Color),106 ("--pico-card-border-color", Kind::Color),107 ("--pico-card-sectioning-background-color", Kind::Color),108 ("--pico-code-background-color", Kind::Color),109 ("--pico-code-color", Kind::Color),110 ("--pico-mark-background-color", Kind::Color),111 ("--pico-mark-color", Kind::Color),112 ("--pico-blockquote-border-color", Kind::Color),113 ("--pico-accordion-active-summary-color", Kind::Color),114 ("--pico-accordion-close-summary-color", Kind::Color),115 ("--pico-accordion-open-summary-color", Kind::Color),116 // `ins` and `del` also colour the site's own confirmations and refusals.117 ("--pico-ins-color", Kind::Color),118 ("--pico-del-color", Kind::Color),119 // -- form elements -------------------------------------------------------120 ("--pico-form-element-background-color", Kind::Color),121 ("--pico-form-element-border-color", Kind::Color),122 ("--pico-form-element-color", Kind::Color),123 // -- effects -------------------------------------------------------------124 // A profile may remove Pico's global shadow, but may not supply arbitrary125 // shadow syntax that could visually escape the component it belongs to.126 ("--pico-box-shadow", Kind::Keyword(&["unset"])),127 // -- metrics -------------------------------------------------------------128 ("--pico-border-radius", Kind::Length),129 ("--pico-border-width", Kind::Length),130 ("--pico-outline-width", Kind::Length),131 ("--pico-spacing", Kind::Length),132 ("--pico-block-spacing-vertical", Kind::Length),133 ("--pico-block-spacing-horizontal", Kind::Length),134 ("--pico-typography-spacing-vertical", Kind::Length),135 ("--pico-form-element-spacing-vertical", Kind::Length),136 ("--pico-form-element-spacing-horizontal", Kind::Length),137 ("--pico-nav-element-spacing-vertical", Kind::Length),138 ("--pico-nav-element-spacing-horizontal", Kind::Length),139 ("--pico-text-underline-offset", Kind::Length),140 ("--pico-font-size", Kind::Length),141 ("--pico-line-height", Kind::Number),142 // The typeface is fixed: Open Sans, at one of its two weights. There is no143 // --pico-font-family here, and that is deliberate.144 ("--pico-font-weight", Kind::Keyword(&["400", "700"])),145 (146 "--pico-text-decoration",147 Kind::Keyword(&["none", "underline"]),148 ),149];150151/// Read a theme, or say precisely what is wrong with it.152///153/// The error is written for the person who typed the CSS, because it is shown154/// to them verbatim by both the website and the CLI.155pub fn parse(input: &str) -> Result<Vec<Declaration>, String> {156 if input.len() > MAX_INPUT {157 return Err(format!("a theme may be at most {MAX_INPUT} characters"));158 }159160 let source = strip_comments(input)?;161 let mut declarations: Vec<Declaration> = Vec::new();162163 for chunk in split_declarations(&source) {164 let chunk = chunk.trim();165 if chunk.is_empty() {166 continue;167 }168 if let Some(bad) = find_outside_quotes(chunk, |c| "{}@<>'\\".contains(c)) {169 return Err(format!(170 "`{bad}` is not allowed: a profile is a list of validated \171 `--pico-…` and `--devsite-…` declarations, not a stylesheet"172 ));173 }174 if find_outside_quotes(chunk, |c| c == '!').is_some() {175 return Err(176 "`!important` is not allowed; a theme never has to out-rank anything".into(),177 );178 }179180 let (name, value) = chunk181 .split_once(':')182 .ok_or_else(|| format!("`{chunk}` is missing a `:`"))?;183 let name = name.trim().to_ascii_lowercase();184 let raw_value = value.trim();185186 if raw_value.is_empty() {187 return Err(format!("`{name}` has no value"));188 }189190 let (property, kind) = PROPERTIES191 .iter()192 .find(|(known, _)| *known == name)193 .ok_or_else(|| unknown_property(&name))?;194195 let value = kind196 .canonicalize(raw_value)197 .ok_or_else(|| format!("`{name}: {raw_value}` — expected {}", kind.describe()))?;198199 // Last one wins, as it would in a real rule block, but the theme keeps200 // the order it was written in. Collapsing duplicates here is also what201 // bounds the result: a theme can never hold more declarations than202 // PROPERTIES has entries, however long its source is.203 match declarations.iter_mut().find(|d| d.property == *property) {204 Some(existing) => existing.value = value,205 None => declarations.push(Declaration { property, value }),206 }207 }208209 Ok(declarations)210}211212/// Render declarations back to the canonical text stored in the database.213pub fn to_css(declarations: &[Declaration]) -> String {214 declarations215 .iter()216 .map(|d| format!("{}: {};\n", d.property, d.value))217 .collect()218}219220/// Every profile presentation property, for `devsite theme properties` and the docs.221pub fn properties() -> impl Iterator<Item = (&'static str, String)> {222 PROPERTIES223 .iter()224 .map(|(name, kind)| (*name, kind.describe()))225}226227fn unknown_property(name: &str) -> String {228 // A near miss is nearly always a typo, or a real Pico variable that is not229 // offered here, so point at the most specific thing that is.230 let stem = name231 .trim_start_matches("--pico-")232 .trim_start_matches("--devsite-")233 .trim_start_matches("--");234 let closest = PROPERTIES235 .iter()236 .map(|(known, _)| *known)237 .filter(|known| {238 let known_stem = known239 .trim_start_matches("--pico-")240 .trim_start_matches("--devsite-")241 .trim_start_matches("--");242 stem.len() >= 4 && (stem.contains(known_stem) || known_stem.contains(stem))243 })244 .max_by_key(|known| known.len());245246 match closest {247 Some(known) => format!("`{name}` is not a profile property — did you mean `{known}`?"),248 None => {249 format!(250 "`{name}` is not a profile property; run `devsite theme properties` for the list"251 )252 }253 }254}255256impl Kind {257 fn canonicalize(&self, raw: &str) -> Option<String> {258 if matches!(self, Kind::FolderNames) {259 if raw.len() > MAX_LAYOUT_VALUE {260 return None;261 }262 return parse_folder_names(raw);263 }264265 let value = normalize_whitespace(raw);266 if value.len() > MAX_VALUE {267 return None;268 }269 match self {270 Kind::Color => is_color(&value).then_some(value),271 Kind::Length => is_length(&value).then_some(value),272 Kind::Number => is_number(&value).then_some(value),273 Kind::Keyword(allowed) => {274 let canonical = value.to_ascii_lowercase();275 allowed.contains(&canonical.as_str()).then_some(canonical)276 }277 Kind::FolderNames => unreachable!(),278 }279 }280281 fn describe(&self) -> String {282 match self {283 Kind::Color => "a colour, e.g. `#7b3fe4`, `rgb(123 63 228)`, `rebeccapurple` or `light-dark(#7b3fe4, #a982ff)`".into(),284 Kind::Length => "a length, e.g. `0.5rem`, `12px` or `0`".into(),285 Kind::Number => "a number, e.g. `1.5`".into(),286 Kind::Keyword(allowed) => format!("one of {}", allowed.join(", ")),287 Kind::FolderNames => {288 "one or more quoted folder names, e.g. `\"Profiles\", \"Services\"`".into()289 }290 }291 }292}293294// -- value grammars -----------------------------------------------------------295296fn is_color(value: &str) -> bool {297 let value = value.to_ascii_lowercase();298299 if let Some(args) = value300 .strip_prefix("light-dark(")301 .and_then(|rest| rest.strip_suffix(')'))302 {303 return split_color_pair(args)304 .is_some_and(|(light, dark)| is_flat_color(light) && is_flat_color(dark));305 }306307 is_flat_color(&value)308}309310/// Split the arguments of `light-dark()` at its one top-level comma.311///312/// Commas inside an existing functional colour belong to that colour. Tracking313/// their parentheses here permits `rgba(1, 2, 3, 0.5)` on either side while314/// still rejecting unbalanced input and any third top-level argument.315fn split_color_pair(args: &str) -> Option<(&str, &str)> {316 let mut depth = 0_u8;317 let mut separator = None;318319 for (index, character) in args.char_indices() {320 match character {321 '(' => depth = depth.checked_add(1)?,322 ')' => depth = depth.checked_sub(1)?,323 ',' if depth == 0 && separator.is_some() => return None,324 ',' if depth == 0 => separator = Some(index),325 _ => {}326 }327 }328329 if depth != 0 {330 return None;331 }332333 let separator = separator?;334 let light = args[..separator].trim();335 let dark = args[separator + 1..].trim();336 (!light.is_empty() && !dark.is_empty()).then_some((light, dark))337}338339/// The original, deliberately flat colour grammar. Keeping it separate makes340/// `light-dark()` the only supported nesting layer: neither side can introduce341/// another `light-dark()`, `var()`, `calc()`, `url()`, or arbitrary function.342fn is_flat_color(value: &str) -> bool {343 if value == "transparent" || value == "currentcolor" {344 return true;345 }346 if let Some(hex) = value.strip_prefix('#') {347 return matches!(hex.len(), 3 | 4 | 6 | 8) && hex.bytes().all(|b| b.is_ascii_hexdigit());348 }349 if let Some((function, rest)) = value.split_once('(') {350 let Some(args) = rest.strip_suffix(')') else {351 return false;352 };353 return matches!(function, "rgb" | "rgba" | "hsl" | "hsla" | "oklch")354 && !args.trim().is_empty()355 // No nested functions, no var(), no url(): a flat list of numbers.356 && args357 .bytes()358 .all(|b| b.is_ascii_digit() || b" .,%/+-".contains(&b))359 && args.split([',', ' ', '/']).filter(|a| !a.is_empty()).count() <= 4;360 }361 NAMED_COLORS.binary_search(&value).is_ok()362}363364fn is_length(value: &str) -> bool {365 if value == "0" {366 return true;367 }368 const UNITS: &[&str] = &[369 "px", "rem", "em", "ch", "ex", "vw", "vh", "vmin", "vmax", "%",370 ];371 UNITS.iter().any(|unit| {372 value373 .strip_suffix(unit)374 .is_some_and(|number| !number.is_empty() && is_number(number))375 })376}377378/// Non-negative, and no exponents: `1e9rem` is a length, but not a useful one.379fn is_number(value: &str) -> bool {380 !value.is_empty()381 && value.bytes().all(|b| b.is_ascii_digit() || b == b'.')382 && value.matches('.').count() <= 1383 && value != "."384 && value.len() <= 8385}386387fn parse_folder_names(value: &str) -> Option<String> {388 let names: Vec<String> = serde_json::from_str(&format!("[{value}]")).ok()?;389 if names.is_empty() || names.len() > MAX_LAYOUT_FOLDERS {390 return None;391 }392393 let mut seen = std::collections::HashSet::new();394 for name in &names {395 if name.is_empty()396 || name.trim() != name397 || name.chars().count() > MAX_FOLDER_NAME398 || name.chars().any(char::is_control)399 || !seen.insert(name)400 {401 return None;402 }403 }404405 Some(406 names407 .iter()408 .map(|name| serde_json::to_string(name).expect("a string always serializes"))409 .collect::<Vec<_>>()410 .join(", "),411 )412}413414fn strip_comments(input: &str) -> Result<String, String> {415 let mut out = String::with_capacity(input.len());416 let mut chars = input.chars().peekable();417 let mut quoted = false;418 let mut escaped = false;419420 while let Some(character) = chars.next() {421 if quoted {422 out.push(character);423 if escaped {424 escaped = false;425 } else if character == '\\' {426 escaped = true;427 } else if character == '"' {428 quoted = false;429 }430 continue;431 }432433 if character == '"' {434 quoted = true;435 out.push(character);436 } else if character == '/' && chars.peek() == Some(&'*') {437 chars.next();438 let mut previous = '\0';439 let mut closed = false;440 for comment in chars.by_ref() {441 if previous == '*' && comment == '/' {442 closed = true;443 break;444 }445 previous = comment;446 }447 if !closed {448 return Err("a comment is not closed".into());449 }450 // A comment separates tokens, so it cannot simply vanish.451 out.push(' ');452 } else {453 out.push(character);454 }455 }456457 if quoted {458 return Err("a quoted folder name is not closed".into());459 }460 Ok(out)461}462463fn split_declarations(input: &str) -> Vec<&str> {464 let mut chunks = Vec::new();465 let mut start = 0;466 let mut quoted = false;467 let mut escaped = false;468 for (index, character) in input.char_indices() {469 if quoted {470 if escaped {471 escaped = false;472 } else if character == '\\' {473 escaped = true;474 } else if character == '"' {475 quoted = false;476 }477 } else if character == '"' {478 quoted = true;479 } else if character == ';' {480 chunks.push(&input[start..index]);481 start = index + 1;482 }483 }484 chunks.push(&input[start..]);485 chunks486}487488fn find_outside_quotes(input: &str, predicate: impl Fn(char) -> bool) -> Option<char> {489 let mut quoted = false;490 let mut escaped = false;491 for character in input.chars() {492 if quoted {493 if escaped {494 escaped = false;495 } else if character == '\\' {496 escaped = true;497 } else if character == '"' {498 quoted = false;499 }500 } else if character == '"' {501 quoted = true;502 } else if predicate(character) {503 return Some(character);504 }505 }506 None507}508509fn normalize_whitespace(value: &str) -> String {510 value.split_whitespace().collect::<Vec<_>>().join(" ")511}512513/// The CSS named colours, sorted for `binary_search`.514const NAMED_COLORS: &[&str] = &[515 "aliceblue",516 "antiquewhite",517 "aqua",518 "aquamarine",519 "azure",520 "beige",521 "bisque",522 "black",523 "blanchedalmond",524 "blue",525 "blueviolet",526 "brown",527 "burlywood",528 "cadetblue",529 "chartreuse",530 "chocolate",531 "coral",532 "cornflowerblue",533 "cornsilk",534 "crimson",535 "cyan",536 "darkblue",537 "darkcyan",538 "darkgoldenrod",539 "darkgray",540 "darkgreen",541 "darkgrey",542 "darkkhaki",543 "darkmagenta",544 "darkolivegreen",545 "darkorange",546 "darkorchid",547 "darkred",548 "darksalmon",549 "darkseagreen",550 "darkslateblue",551 "darkslategray",552 "darkslategrey",553 "darkturquoise",554 "darkviolet",555 "deeppink",556 "deepskyblue",557 "dimgray",558 "dimgrey",559 "dodgerblue",560 "firebrick",561 "floralwhite",562 "forestgreen",563 "fuchsia",564 "gainsboro",565 "ghostwhite",566 "gold",567 "goldenrod",568 "gray",569 "green",570 "greenyellow",571 "grey",572 "honeydew",573 "hotpink",574 "indianred",575 "indigo",576 "ivory",577 "khaki",578 "lavender",579 "lavenderblush",580 "lawngreen",581 "lemonchiffon",582 "lightblue",583 "lightcoral",584 "lightcyan",585 "lightgoldenrodyellow",586 "lightgray",587 "lightgreen",588 "lightgrey",589 "lightpink",590 "lightsalmon",591 "lightseagreen",592 "lightskyblue",593 "lightslategray",594 "lightslategrey",595 "lightsteelblue",596 "lightyellow",597 "lime",598 "limegreen",599 "linen",600 "magenta",601 "maroon",602 "mediumaquamarine",603 "mediumblue",604 "mediumorchid",605 "mediumpurple",606 "mediumseagreen",607 "mediumslateblue",608 "mediumspringgreen",609 "mediumturquoise",610 "mediumvioletred",611 "midnightblue",612 "mintcream",613 "mistyrose",614 "moccasin",615 "navajowhite",616 "navy",617 "oldlace",618 "olive",619 "olivedrab",620 "orange",621 "orangered",622 "orchid",623 "palegoldenrod",624 "palegreen",625 "paleturquoise",626 "palevioletred",627 "papayawhip",628 "peachpuff",629 "peru",630 "pink",631 "plum",632 "powderblue",633 "purple",634 "rebeccapurple",635 "red",636 "rosybrown",637 "royalblue",638 "saddlebrown",639 "salmon",640 "sandybrown",641 "seagreen",642 "seashell",643 "sienna",644 "silver",645 "skyblue",646 "slateblue",647 "slategray",648 "slategrey",649 "snow",650 "springgreen",651 "steelblue",652 "tan",653 "teal",654 "thistle",655 "tomato",656 "turquoise",657 "violet",658 "wheat",659 "white",660 "whitesmoke",661 "yellow",662 "yellowgreen",663];664665666mod tests {667 use super::*;668669 fn css(input: &str) -> Result<String, String> {670 parse(input).map(|d| to_css(&d))671 }672673 674 fn named_colors_are_sorted_for_binary_search() {675 assert!(NAMED_COLORS.windows(2).all(|w| w[0] < w[1]));676 }677678 679 fn accepts_a_plain_theme() {680 let theme = css("--pico-primary: #7b3fe4;\n--pico-border-radius: 0.5rem;").unwrap();681 assert_eq!(682 theme,683 "--pico-primary: #7b3fe4;\n--pico-border-radius: 0.5rem;\n"684 );685 }686687 688 fn accepts_and_canonicalizes_profile_folder_layout() {689 let source = r#"690 --devsite-folders: closed;691 --devsite-open-folders: "Profiles";692 --devsite-folder-order: "Profiles","Services", "Games";693 "#;694 let canonical = css(source).unwrap();695 assert_eq!(696 canonical,697 "--devsite-folders: closed;\n\698 --devsite-open-folders: \"Profiles\";\n\699 --devsite-folder-order: \"Profiles\", \"Services\", \"Games\";\n"700 );701 assert_eq!(css(&canonical).unwrap(), canonical);702 }703704 705 fn folder_layout_preserves_bounded_unicode_and_json_escapes() {706 let source = r#"--devsite-open-folders: "日本語 🧰", "say \"hi\" \\ path", "semi; /* literal */", "</style>";"#;707 let canonical = css(source).unwrap();708 assert_eq!(709 canonical,710 r#"--devsite-open-folders: "日本語 🧰", "say \"hi\" \\ path", "semi; /* literal */", "</style>";711"#712 );713 assert_eq!(css(&canonical).unwrap(), canonical);714 }715716 717 fn folder_layout_rejects_invalid_or_unbounded_name_lists() {718 let overlong = "x".repeat(MAX_FOLDER_NAME + 1);719 let too_many = (0..=MAX_LAYOUT_FOLDERS)720 .map(|index| format!("\"folder-{index}\""))721 .collect::<Vec<_>>()722 .join(", ");723 for value in [724 "Profiles".to_string(),725 "\"\"".to_string(),726 "\" Profiles\"".to_string(),727 "\"Profiles \"".to_string(),728 "\"Profiles\", \"Profiles\"".to_string(),729 "\"Profiles\",".to_string(),730 "[\"Profiles\"]".to_string(),731 "42".to_string(),732 "\"two\\nlines\"".to_string(),733 format!("\"{overlong}\""),734 too_many,735 ] {736 assert!(737 parse(&format!("--devsite-open-folders: {value};")).is_err(),738 "{value:?} should be refused"739 );740 }741 }742743 744 fn folder_default_accepts_only_open_or_closed() {745 assert!(parse("--devsite-folders: open;").is_ok());746 assert!(parse("--devsite-folders: closed;").is_ok());747 assert_eq!(748 css("--devsite-folders: CLOSED;").unwrap(),749 "--devsite-folders: closed;\n"750 );751 for value in ["auto", "true", "none", "\"closed\""] {752 assert!(parse(&format!("--devsite-folders: {value};")).is_err());753 }754 }755756 757 fn accepts_every_documented_color_form() {758 for value in [759 "#fff",760 "#ffff",761 "#7b3fe4",762 "#7b3fe4cc",763 "rgb(123 63 228)",764 "rgba(123, 63, 228, 0.5)",765 "hsl(262 76% 57%)",766 "oklch(62.8% 0.258 29.23)",767 "rebeccapurple",768 "transparent",769 "currentcolor",770 ] {771 assert!(772 parse(&format!("--pico-primary: {value};")).is_ok(),773 "{value} should be a colour"774 );775 }776 }777778 779 fn accepts_hex_named_and_functional_color_pairs() {780 for value in [781 "light-dark(#7b3fe4, #a982ff)",782 "light-dark(white, rebeccapurple)",783 "light-dark(transparent, currentcolor)",784 "light-dark(rgb(250 248 255), rgb(24 18 32))",785 "light-dark(rgba(250, 248, 255, 0.9), hsl(267 100% 75%))",786 "light-dark(oklch(98% 0.02 295), oklch(20% 0.04 295))",787 ] {788 assert!(789 parse(&format!("--pico-primary: {value};")).is_ok(),790 "{value} should be a light/dark colour pair"791 );792 }793 }794795 796 fn a_color_pair_has_canonical_whitespace_and_round_trips() {797 let source = "\n --devsite-scheme: auto;\n --pico-primary:\n light-dark( #7b3fe4 , rgb( 169 130 255 ) );\n";798 let canonical = css(source).unwrap();799 assert_eq!(800 canonical,801 "--devsite-scheme: auto;\n--pico-primary: light-dark( #7b3fe4 , rgb( 169 130 255 ) );\n"802 );803 assert_eq!(css(&canonical).unwrap(), canonical);804 }805806 807 fn damis_palette_can_define_both_schemes() {808 let source = "809 --devsite-scheme: auto;810 --pico-background-color: light-dark(#fefae0, #283618);811 --pico-color: light-dark(#283618, #fefae0);812 --pico-primary: light-dark(#bc6c25, #dda15e);813 --pico-accordion-active-summary-color: light-dark(#606c38, #dda15e);814 --pico-accordion-close-summary-color: light-dark(#606c38, #dda15e);815 --pico-accordion-open-summary-color: light-dark(#606c38, #dda15e);816 --pico-box-shadow: unset;817 ";818 assert_eq!(819 css(source).unwrap(),820 "--devsite-scheme: auto;\n\821 --pico-background-color: light-dark(#fefae0, #283618);\n\822 --pico-color: light-dark(#283618, #fefae0);\n\823 --pico-primary: light-dark(#bc6c25, #dda15e);\n\824 --pico-accordion-active-summary-color: light-dark(#606c38, #dda15e);\n\825 --pico-accordion-close-summary-color: light-dark(#606c38, #dda15e);\n\826 --pico-accordion-open-summary-color: light-dark(#606c38, #dda15e);\n\827 --pico-box-shadow: unset;\n"828 );829 }830831 832 fn box_shadow_can_only_be_removed() {833 assert!(parse("--pico-box-shadow: unset;").is_ok());834 for value in ["none", "0 0 1rem red", "var(--shadow)"] {835 assert!(836 parse(&format!("--pico-box-shadow: {value};")).is_err(),837 "{value} should be refused"838 );839 }840 }841842 843 fn color_pairs_are_accepted_with_forced_and_automatic_schemes() {844 for scheme in ["light", "dark", "auto"] {845 let theme = format!(846 "--devsite-scheme: {scheme}; \847 --pico-primary: light-dark(#7b3fe4, #a982ff);"848 );849 assert!(850 parse(&theme).is_ok(),851 "{scheme} should accept a colour pair"852 );853 }854 }855856 857 fn rejects_malformed_color_pairs() {858 for value in [859 "light-dark()",860 "light-dark(red)",861 "light-dark(, blue)",862 "light-dark(red, )",863 "light-dark(red, blue, green)",864 "light-dark(red,, blue)",865 "light-dark(rgb(1 2 3), blue",866 "light-dark(rgb(1 2 3, blue)",867 "light-dark(rgb(1 2 3)), blue)",868 ] {869 assert!(870 parse(&format!("--pico-primary: {value};")).is_err(),871 "{value} should be refused"872 );873 }874 }875876 877 fn rejects_unsupported_functions_inside_color_pairs() {878 for value in [879 "light-dark(var(--pico-color), blue)",880 "light-dark(calc(1 + 1), blue)",881 "light-dark(url(https://example.com/x.png), blue)",882 "light-dark(linear-gradient(red, blue), green)",883 "light-dark(light-dark(red, blue), green)",884 "light-dark(rgb(calc(1 + 1) 0 0), blue)",885 ] {886 assert!(887 parse(&format!("--pico-primary: {value};")).is_err(),888 "{value} should be refused"889 );890 }891 }892893 894 fn rejects_values_that_only_look_like_colors() {895 for value in [896 "url(https://example.com/x.png)",897 "var(--pico-color)",898 "rgb(calc(1 + 1) 0 0)",899 "#12345",900 "#gggggg",901 "notacolour",902 "rgb(1 2 3 4 5)",903 ] {904 assert!(905 parse(&format!("--pico-primary: {value};")).is_err(),906 "{value} should be refused"907 );908 }909 }910911 912 fn rejects_anything_that_is_not_a_declaration() {913 // The whole point of the whitelist: a theme cannot become a stylesheet.914 for source in [915 "body { display: none }",916 "--pico-primary: red } body { position: fixed",917 "@import url(evil.css);",918 "--pico-primary: red !important;",919 "</style><script>alert(1)</script>",920 "--pico-primary: red; /* unterminated",921 "--pico-primary: light-dark(red, blue); } body { display: none",922 "--pico-primary: light-dark(red, </style><script>alert(1)</script>);",923 ] {924 assert!(parse(source).is_err(), "{source:?} should be refused");925 }926 }927928 929 fn reported_color_grammar_includes_color_pairs() {930 let description = properties()931 .find(|(name, _)| *name == "--pico-primary")932 .unwrap()933 .1;934 assert!(description.contains("light-dark(#7b3fe4, #a982ff)"));935 }936937 938 fn rejects_properties_outside_the_table() {939 // Real CSS properties are refused as firmly as invented ones: a theme940 // may only assign the variables the template is built out of.941 for property in ["position", "display", "--pico-icon-close", "--anything"] {942 assert!(943 parse(&format!("{property}: none;")).is_err(),944 "{property} should be refused"945 );946 }947 }948949 950 fn suggests_the_nearest_property_on_a_typo() {951 let error = parse("--pico-primary-color: red;").unwrap_err();952 assert!(error.contains("--pico-primary"), "unhelpful error: {error}");953 }954955 956 fn the_typeface_is_not_negotiable() {957 assert!(parse("--pico-font-family: Comic Sans MS;").is_err());958 assert!(parse("--pico-font-weight: 400;").is_ok());959 assert!(parse("--pico-font-weight: 700;").is_ok());960 // Open Sans is vendored at exactly two weights; anything else would be961 // rounded to one of them anyway, so say so rather than pretend.962 assert!(parse("--pico-font-weight: 600;").is_err());963 }964965 966 fn lengths_need_a_unit_and_numbers_must_not_have_one() {967 assert!(parse("--pico-border-radius: 0;").is_ok());968 assert!(parse("--pico-border-radius: 0.5rem;").is_ok());969 assert!(parse("--pico-border-radius: 8;").is_err());970 assert!(parse("--pico-border-radius: -1rem;").is_err());971 assert!(parse("--pico-line-height: 1.5;").is_ok());972 assert!(parse("--pico-line-height: 1.5rem;").is_err());973 }974975 976 fn comments_and_spacing_survive_normalisation() {977 let theme = css("/* my colours */\n --pico-primary : rgb( 1 , 2 , 3 ) ;\n").unwrap();978 assert_eq!(theme, "--pico-primary: rgb( 1 , 2 , 3 );\n");979 }980981 982 fn a_comment_cannot_glue_two_tokens_together() {983 // `--pico/**/-primary` must not become a valid property name.984 assert!(parse("--pico/**/-primary: red;").is_err());985 }986987 988 fn the_last_assignment_wins_without_reordering() {989 let theme = css("--pico-primary: red; --pico-color: blue; --pico-primary: green;").unwrap();990 assert_eq!(theme, "--pico-primary: green;\n--pico-color: blue;\n");991 }992993 994 fn validated_output_is_safe_to_inline_in_a_style_element() {995 // Not a claim about escaping — a claim about the alphabet. Whatever996 // survives parsing cannot contain a character that ends a style block.997 let source = "--pico-primary: #abc; --pico-spacing: 1rem; --devsite-scheme: dark;";998 for declaration in parse(source).unwrap() {999 assert!(declaration1000 .value1001 .bytes()1002 .all(|b| b.is_ascii_alphanumeric() || b"#%.,/()+- ".contains(&b)));1003 }10041005 // Layout declarations may contain arbitrary printable folder labels,1006 // but are distinguishable by name so the browser can keep them out of1007 // the generated Pico rule.1008 for declaration in parse(1009 r#"--devsite-open-folders: "</style><script>alert(1)</script>"; --pico-primary: red;"#,1010 )1011 .unwrap()1012 {1013 if declaration.property.starts_with("--pico-") {1014 assert!(!declaration.value.contains('<'));1015 assert!(!declaration.value.contains('>'));1016 assert!(!declaration.value.contains('"'));1017 }1018 }1019 }10201021 1022 fn oversized_input_is_refused_before_parsing() {1023 let huge = "--pico-primary: red;".repeat(MAX_INPUT);1024 assert!(parse(&huge).is_err());1025 }10261027 1028 fn an_empty_theme_is_valid_and_empty() {1029 assert_eq!(parse("").unwrap(), vec![]);1030 assert_eq!(parse(" \n /* nothing */ ;;; ").unwrap(), vec![]);1031 }1032}