makeover 5.1.0

Shared theme loading for the make-family apps: TOML theme files parsed into intent-based color tokens, with perceptual derivations and WCAG contrast.
Documentation
//! Writing caller-supplied text into CSS.
//!
//! A theme file is user-writable, so a family name, a source file or a theme id
//! can hold anything. Each reaches the stylesheet through one of these, which is
//! the only place that decides how.

/// `text` as the body of a double-quoted CSS string.
///
/// `"` and `\` are escaped, `<` is escaped as `\3c ` so caller text cannot close
/// an inline stylesheet tag, and every control character, newline included, is
/// written as a hex escape: a raw newline ends a CSS string, which is a way out.
pub(crate) fn string_body(text: &str) -> String {
    use std::fmt::Write as _;

    let mut out = String::with_capacity(text.len());
    for c in text.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '<' => out.push_str("\\3c "),
            c if c.is_control() => {
                // The trailing space ends the escape, so a hex digit after it
                // is read as itself.
                let _ = write!(out, "\\{:x} ", u32::from(c));
            }
            c => out.push(c),
        }
    }
    out
}

/// `value` if it is a font-family list — comma-separated families, each a
/// double-quoted string or a bare identifier — and `None` for anything that
/// could end the declaration or open another.
///
/// A stack is emitted as the bare value of `--font-…`, so unlike a quoted
/// string it has no escape to fall back on: validation is the only guard, and
/// the reason a stack from a build script cannot be written through verbatim.
pub(crate) fn font_stack(value: &str) -> Option<&str> {
    let families: Vec<&str> = value.split(',').map(str::trim).collect();
    (!families.is_empty() && families.iter().all(|f| is_font_family(f))).then_some(value)
}

/// One family in a font-family list: a quoted name, or a bare identifier.
fn is_font_family(family: &str) -> bool {
    if let Some(quoted) = family.strip_prefix('"').and_then(|f| f.strip_suffix('"')) {
        // A quoted name may hold anything but the byte that would close it,
        // the escape that would make that byte legit again, or a `<` that could
        // close an inline style tag; a control character ends the string.
        !quoted.is_empty()
            && !quoted
                .chars()
                .any(|c| c.is_control() || matches!(c, '"' | '\\' | '<'))
    } else {
        let mut chars = family.chars();
        matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '-')
            && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '-' | '_'))
    }
}

/// `value` if it is a bare CSS keyword or number list (`700`, `100 900`,
/// `italic`, `oblique 10deg`), and `None` for anything that could end the
/// declaration or open another.
pub(crate) fn keyword(value: &str) -> Option<&str> {
    let bare = !value.trim().is_empty()
        && value
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '-' | '.' | '%'));
    bare.then_some(value)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_string_body_cannot_close_its_string() {
        assert_eq!(string_body(r#"Quasi "Mono""#), r#"Quasi \"Mono\""#);
        assert_eq!(string_body(r"a\b"), r"a\\b");
        assert_eq!(string_body("a\nb"), "a\\a b");
        assert_eq!(string_body("plain name"), "plain name");
        assert_eq!(string_body("</style>"), r"\3c /style>");
        assert_eq!(string_body("</style><script>"), r"\3c /style>\3c script>");
    }

    #[test]
    fn only_a_font_family_list_is_a_font_stack() {
        for good in [
            r#""Young Serif", serif"#,
            r#""Departure Mono", monospace"#,
            "serif",
            "sans-serif",
            "system-ui",
            r#""Segoe UI", sans-serif"#,
            "monospace",
        ] {
            assert_eq!(font_stack(good), Some(good), "{good:?}");
        }
        for hostile in [
            r#""Young Serif", serif; } body { x: y"#,
            "monospace}",
            r#"", serif"#,
            r#""unclosed, serif"#,
            r#""a\"b", serif"#,
            r#""a<b", serif"#,
            "",
            "   ",
            "serif, ",
        ] {
            assert_eq!(font_stack(hostile), None, "{hostile:?}");
        }
    }

    #[test]
    fn only_a_bare_keyword_is_a_keyword() {
        assert_eq!(keyword("700"), Some("700"));
        assert_eq!(keyword("100 900"), Some("100 900"));
        assert_eq!(keyword("oblique 10deg"), Some("oblique 10deg"));
        for hostile in ["700; } body { x: y", "normal}", "", "  ", "a/*"] {
            assert_eq!(keyword(hostile), None, "{hostile:?}");
        }
    }
}