use crate::{
FONT_MONO, FONT_SANS, HOUSE_MONO_FAMILY, HOUSE_SANS_FAMILY, HOUSE_WEIGHT_RANGE,
WEBFONT_MONO_FILE, WEBFONT_SANS_FILE, font_face_css,
};
#[allow(unused_imports)]
use crate::typography_css_vars;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FontSlot {
Mono,
Sans,
Display,
}
impl FontSlot {
pub const ALL: [FontSlot; 3] = [FontSlot::Mono, FontSlot::Sans, FontSlot::Display];
pub fn token(self) -> &'static str {
match self {
FontSlot::Mono => "--font-mono",
FontSlot::Sans => "--font-sans",
FontSlot::Display => "--font-display",
}
}
pub fn house_default(self) -> Option<&'static str> {
match self {
FontSlot::Mono => Some(FONT_MONO),
FontSlot::Sans => Some(FONT_SANS),
FontSlot::Display => None,
}
}
pub fn house_face(self) -> Option<FontFace> {
let (family, file) = match self {
FontSlot::Mono => (HOUSE_MONO_FAMILY, WEBFONT_MONO_FILE),
FontSlot::Sans => (HOUSE_SANS_FAMILY, WEBFONT_SANS_FILE),
FontSlot::Display => return None,
};
Some(
FontFace::new(family, [file])
.with_weight(HOUSE_WEIGHT_RANGE)
.with_style("normal"),
)
}
}
#[derive(Debug, Clone)]
pub struct FontFace {
family: String,
sources: Vec<String>,
weight: Option<String>,
style: Option<String>,
}
impl FontFace {
pub fn new<S: Into<String>>(
family: impl Into<String>,
sources: impl IntoIterator<Item = S>,
) -> Self {
Self {
family: family.into(),
sources: sources.into_iter().map(Into::into).collect(),
weight: None,
style: None,
}
}
#[must_use]
pub fn with_weight(mut self, weight: impl Into<String>) -> Self {
self.weight = Some(weight.into());
self
}
#[must_use]
pub fn with_style(mut self, style: impl Into<String>) -> Self {
self.style = Some(style.into());
self
}
pub fn weight(&self) -> Option<&str> {
self.weight.as_deref()
}
pub fn style(&self) -> Option<&str> {
self.style.as_deref()
}
pub fn family(&self) -> &str {
&self.family
}
pub fn sources(&self) -> &[String] {
&self.sources
}
pub(crate) fn css(&self, base: &str) -> String {
use std::fmt::Write as _;
let src = self
.sources
.iter()
.map(|s| {
let url = if s.starts_with('/') || s.contains("://") {
s.clone()
} else {
format!("{base}/{s}")
};
match font_format(s) {
Some(fmt) => format!("url(\"{url}\") format(\"{fmt}\")"),
None => format!("url(\"{url}\")"),
}
})
.collect::<Vec<_>>()
.join(",\n ");
let mut out = format!(
"@font-face {{\n font-family: \"{}\";\n src: {src};\n",
self.family
);
if let Some(w) = &self.weight {
let _ = writeln!(out, " font-weight: {w};");
}
if let Some(s) = &self.style {
let _ = writeln!(out, " font-style: {s};");
}
out.push_str(" font-display: swap;\n}\n\n");
out
}
}
fn font_format(source: &str) -> Option<&'static str> {
match source.rsplit('.').next()?.to_ascii_lowercase().as_str() {
"woff2" => Some("woff2"),
"woff" => Some("woff"),
"ttf" => Some("truetype"),
"otf" => Some("opentype"),
_ => None,
}
}
#[derive(Debug, Clone)]
pub struct FontOverride {
slot: FontSlot,
stack: String,
faces: Vec<FontFace>,
}
impl FontOverride {
pub fn new(slot: FontSlot, stack: impl Into<String>) -> Self {
Self {
slot,
stack: stack.into(),
faces: Vec::new(),
}
}
#[must_use]
pub fn with_face(mut self, face: FontFace) -> Self {
self.faces.push(face);
self
}
pub fn slot(&self) -> FontSlot {
self.slot
}
pub fn stack(&self) -> &str {
&self.stack
}
pub fn faces(&self) -> &[FontFace] {
&self.faces
}
}
#[derive(Debug, Clone)]
pub struct Typography {
base_url: String,
overrides: Vec<FontOverride>,
}
impl Typography {
pub fn house(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
overrides: Vec::new(),
}
}
#[must_use]
pub fn with_override(mut self, ov: FontOverride) -> Self {
assert!(
!self.overrides.iter().any(|o| o.slot == ov.slot),
"{} is overridden twice; one declaration per product per slot",
ov.slot.token()
);
self.overrides.push(ov);
self
}
pub fn resolve(&self, slot: FontSlot) -> Option<&str> {
self.overrides
.iter()
.find(|o| o.slot == slot)
.map(|o| o.stack.as_str())
.or_else(|| slot.house_default())
}
pub fn faces(&self, slot: FontSlot) -> &[FontFace] {
self.overrides
.iter()
.find(|o| o.slot == slot)
.map_or(&[], |o| o.faces())
}
pub fn font_face_css(&self) -> String {
let base = self.base_url.trim_end_matches('/');
let mut out = font_face_css(base);
for ov in &self.overrides {
for face in &ov.faces {
out.push_str(&face.css(base));
}
}
out
}
pub fn css_declarations(&self) -> String {
use std::fmt::Write as _;
let mut out = String::new();
for slot in FontSlot::ALL {
if let Some(stack) = self.resolve(slot) {
let _ = writeln!(out, " {}: {stack};", slot.token());
}
}
out
}
pub fn css_vars(&self) -> String {
format!(":root {{\n{}}}\n", self.css_declarations())
}
pub fn css(&self) -> String {
format!("{}{}", self.font_face_css(), self.css_vars())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn young_serif() -> FontOverride {
FontOverride::new(FontSlot::Display, "\"Young Serif\", serif")
.with_face(FontFace::new("Young Serif", ["ysrf.woff2", "ysrf.ttf"]))
}
#[test]
fn the_house_layer_alone_is_exactly_what_the_free_functions_emit() {
let t = Typography::house("/static/fonts");
assert_eq!(t.font_face_css(), font_face_css("/static/fonts"));
assert_eq!(t.css_vars(), typography_css_vars());
}
#[test]
fn an_unoverridden_display_slot_defines_no_token_at_all() {
let t = Typography::house("fonts");
assert!(!t.css_vars().contains("--font-display"));
assert_eq!(t.resolve(FontSlot::Display), None);
assert_eq!(t.css_vars().matches("--font-").count(), 2);
}
#[test]
fn an_override_adds_its_token_and_its_face_without_touching_the_house_two() {
let t = Typography::house("/static/fonts").with_override(young_serif());
assert!(
t.css_vars()
.contains(" --font-display: \"Young Serif\", serif;\n")
);
assert!(
t.css_vars()
.contains(" --font-mono: \"Quasi Mono\", monospace;\n")
);
assert!(
t.css_vars()
.contains(" --font-sans: \"Quasi Body\", sans-serif;\n")
);
assert_eq!(t.resolve(FontSlot::Display), Some("\"Young Serif\", serif"));
let faces = t.font_face_css();
assert_eq!(faces.matches("@font-face").count(), 3);
assert!(faces.contains("font-family: \"Young Serif\";"));
assert!(faces.contains("url(\"/static/fonts/ysrf.woff2\") format(\"woff2\")"));
assert!(faces.contains("url(\"/static/fonts/ysrf.ttf\") format(\"truetype\")"));
assert!(faces.find("Quasi Mono").unwrap() < faces.find("Young Serif").unwrap());
}
#[test]
fn overriding_mono_or_sans_replaces_the_house_stack_rather_than_adding_to_it() {
let t = Typography::house("fonts").with_override(FontOverride::new(
FontSlot::Mono,
"\"Departure Mono\", monospace",
));
assert!(
t.css_vars()
.contains(" --font-mono: \"Departure Mono\", monospace;\n")
);
assert!(!t.css_vars().contains("Quasi Mono"));
assert_eq!(t.css_vars().matches("--font-").count(), 2);
}
#[test]
#[should_panic(expected = "--font-display is overridden twice")]
fn a_second_override_of_one_slot_is_a_vocabulary_bug_and_says_so() {
let _ = Typography::house("fonts")
.with_override(young_serif())
.with_override(FontOverride::new(FontSlot::Display, "\"Reglo\", serif"));
}
#[test]
fn an_absolute_source_is_taken_as_written_and_a_relative_one_joins_the_base() {
let t = Typography::house("/static/fonts").with_override(
FontOverride::new(FontSlot::Display, "\"Reglo\", serif").with_face(
FontFace::new(
"Reglo",
["Reglo-Bold.woff2", "https://cdn.example/reglo.woff2"],
)
.with_weight("700"),
),
);
let faces = t.font_face_css();
assert!(faces.contains("url(\"/static/fonts/Reglo-Bold.woff2\")"));
assert!(faces.contains("url(\"https://cdn.example/reglo.woff2\")"));
assert!(faces.contains(" font-weight: 700;\n"));
}
#[test]
fn the_house_tier_renders_byte_for_byte_what_the_format_string_wrote() {
let expected = concat!(
"@font-face {\n",
" font-family: \"Quasi Mono\";\n",
" src: url(\"/static/fonts/QuasiMono.woff2\") format(\"woff2\");\n",
" font-weight: 200 800;\n",
" font-style: normal;\n",
" font-display: swap;\n",
"}\n\n",
"@font-face {\n",
" font-family: \"Quasi Body\";\n",
" src: url(\"/static/fonts/QuasiBody.woff2\") format(\"woff2\");\n",
" font-weight: 200 800;\n",
" font-style: normal;\n",
" font-display: swap;\n",
"}\n\n",
);
assert_eq!(font_face_css("/static/fonts"), expected);
}
#[test]
fn a_house_slot_names_the_same_family_in_its_stack_and_in_its_face() {
for (slot, family) in [
(FontSlot::Mono, HOUSE_MONO_FAMILY),
(FontSlot::Sans, HOUSE_SANS_FAMILY),
] {
let face = slot.house_face().expect("a house slot has a house face");
assert_eq!(face.family(), family);
assert!(
slot.house_default()
.unwrap()
.starts_with(&format!("\"{family}\""))
);
}
}
#[test]
fn the_brand_tier_has_no_house_face_the_way_it_has_no_house_stack() {
assert!(FontSlot::Display.house_face().is_none());
assert!(FontSlot::Display.house_default().is_none());
}
#[test]
fn a_face_loading_renderer_reads_the_family_and_the_source_off_the_layer() {
let t = Typography::house("fonts").with_override(
FontOverride::new(FontSlot::Display, "\"RecursiveMono\", monospace").with_face(
FontFace::new("RecursiveMono", ["RecursiveMonoLnrSt-Bold.ttf"]).with_weight("700"),
),
);
let [face] = t.faces(FontSlot::Display) else {
panic!("the display slot ships exactly one face");
};
assert_eq!(face.family(), "RecursiveMono");
assert_eq!(face.sources(), ["RecursiveMonoLnrSt-Bold.ttf"]);
assert!(
t.resolve(FontSlot::Display)
.unwrap()
.contains(face.family())
);
}
#[test]
fn a_weight_and_style_are_readable_now_that_the_builders_are_not_using_the_names() {
let bold = FontFace::new("Reglo", ["Reglo-Bold.woff2"]).with_weight("700");
assert_eq!(bold.weight(), Some("700"));
assert_eq!(
bold.style(),
None,
"unset means normal, not a stated normal"
);
let italic = FontFace::new("Odd", ["odd.woff2"]).with_style("italic");
assert_eq!(italic.weight(), None);
assert_eq!(italic.style(), Some("italic"));
}
#[test]
fn the_house_faces_state_the_variable_range_a_direct_loader_has_to_name() {
for slot in [FontSlot::Mono, FontSlot::Sans] {
let face = slot.house_face().unwrap();
assert_eq!(face.weight(), Some(HOUSE_WEIGHT_RANGE));
assert_eq!(face.style(), Some("normal"));
}
}
#[test]
fn a_source_is_read_back_unresolved_because_only_the_css_wants_a_url() {
let t = Typography::house("/static/fonts").with_override(young_serif());
assert_eq!(
t.faces(FontSlot::Display)[0].sources(),
["ysrf.woff2", "ysrf.ttf"]
);
assert!(
t.font_face_css()
.contains("url(\"/static/fonts/ysrf.woff2\")")
);
}
#[test]
fn a_slot_nobody_overrode_ships_no_faces_including_the_house_two() {
let t = Typography::house("fonts").with_override(young_serif());
assert!(t.faces(FontSlot::Mono).is_empty());
assert!(t.faces(FontSlot::Sans).is_empty());
assert_eq!(t.faces(FontSlot::Display).len(), 1);
}
#[test]
fn an_unrecognised_extension_gets_no_format_hint_rather_than_a_guessed_one() {
let t = Typography::house("fonts").with_override(
FontOverride::new(FontSlot::Display, "\"Odd\", serif")
.with_face(FontFace::new("Odd", ["odd.eot"])),
);
assert!(t.font_face_css().contains("url(\"fonts/odd.eot\");"));
assert!(!t.font_face_css().contains("format(\"eot\")"));
}
#[test]
fn css_puts_the_faces_before_the_tokens_that_name_them() {
let t = Typography::house("fonts").with_override(young_serif());
let css = t.css();
assert!(css.starts_with("@font-face"));
assert!(css.find("@font-face").unwrap() < css.find(":root").unwrap());
}
}