the front page draws in the web fonts [index.fonts] names, preloading the faces it uses, and keeps the system monospace without them
Signed-off-by: dawn <90008@klbr.net>
1367 unmodified linesExpand all136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081367 unmodified linesExpand all shared > 0 && changed > 0, "{shared} shared, {changed} changed" );}
#[test]fn index_fonts_in_the_config_build_a_page_that_loads_them() { let env = setup_test_env(); assert!(knot_static(&env.config, ["generate"]).status.success()); let plain_dir = get_only_generation_dir(&env.output); let plain = std::fs::read_to_string(plain_dir.join("www/index.html")).unwrap(); assert!(!plain.contains("@font-face") && !plain.contains("rel=\"preload\""));
let mut config = std::fs::read_to_string(&env.config).unwrap(); config.push_str( "\n[index.fonts]\nsans = \"Outfit\"\n\ [[index.fonts.faces]]\nfamily = \"Outfit\"\nurl = \"/fonts/outfit-400.woff2\"\n\ [[index.fonts.faces]]\nfamily = \"Outfit\"\nweight = 700\nurl = \"/fonts/outfit-700.woff2\"\n", ); std::fs::write(&env.config, config).unwrap(); let seen = generation_dirs(&env.output); assert!(knot_static(&env.config, ["generate"]).status.success()); let fonts_dir = generation_dirs(&env.output) .into_iter() .find(|dir| !seen.contains(dir)) .expect("the fonts are a new generation"); let html = std::fs::read_to_string(fonts_dir.join("www/index.html")).unwrap(); for face in ["/fonts/outfit-400.woff2", "/fonts/outfit-700.woff2"] { assert!( html.contains(&format!( "<link rel=\"preload\" href=\"{face}\" as=\"font\" type=\"font/woff2\" crossorigin>" )), "{html}" ); } assert_eq!(html.matches("@font-face").count(), 2, "{html}"); assert!( html.contains("body{font-family:\"Outfit\",ui-sans-serif"), "{html}" );}159 unmodified linesExpand all160161162163164165166167168169170171172173174175176177178200 unmodified linesExpand all159 unmodified linesExpand all ] { hash_fields::text(&mut hasher, name.as_bytes(), value); } hash_fields::field(&mut hasher, b"index.og_alt", index.og_alt.as_deref()); let fonts = index.fonts.as_ref().map(|fonts| { let mut text = format!("{:?}\n{:?}", fonts.sans, fonts.mono); for face in &fonts.faces { text.push_str(&format!( "\n{:?} {} {} {:?}", face.family, face.weight, face.italic, face.url )); } text }); hash_fields::field(&mut hasher, b"index.fonts", fonts.as_deref()); crate::cas::Digest::from_raw(sha2::Digest::finalize(hasher).into()).hex() }
pub fn load(path: &Path) -> anyhow::Result<Self> {200 unmodified linesExpand all298 unmodified linesExpand all299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361298 unmodified linesExpand all .err() .expect("only http(s) links"); assert!(format!("{error:#}").contains("index.links"), "{error:#}");}
#[test]fn index_fonts_parse_validate_and_change_the_digest() { let fonts = "[index.fonts]\nsans = \"Outfit\"\nmono = \"Maple Mono\"\n\ [[index.fonts.faces]]\nfamily = \"Outfit\"\nurl = \"/fonts/outfit-400.woff2\"\n\ [[index.fonts.faces]]\nfamily = \"Outfit\"\nweight = 700\nurl = \"/fonts/outfit-700.woff2\"\n\ [[index.fonts.faces]]\nfamily = \"Maple Mono\"\nitalic = true\nurl = \"https://cdn.test/maple-400i.woff2\"\n"; let config = load_with(fonts); let parsed = config.index.fonts.as_ref().expect("fonts"); assert_eq!(parsed.sans.as_deref(), Some("Outfit")); assert_eq!(parsed.faces.len(), 3); assert_eq!( (parsed.faces[0].weight, parsed.faces[0].italic), (400, false) ); assert_eq!( (parsed.faces[2].weight, parsed.faces[2].italic), (400, true) ); assert_eq!(load_with("").index.fonts, None); assert_ne!(config.config_digest(), load_with("").config_digest()); let heavier = fonts.replace("weight = 700", "weight = 800"); assert_ne!(config.config_digest(), load_with(&heavier).config_digest());
for (bad, why) in [ ( fonts.replace("/fonts/outfit-700.woff2", "//cdn.test/a.woff2"), "url", ), ( fonts.replace("/fonts/outfit-700.woff2", "/fonts/a\\\").woff2"), "url", ), ( fonts.replace("/fonts/outfit-700.woff2", "javascript:x"), "url", ), ( fonts.replace("mono = \"Maple Mono\"", "mono = \"Iosevka\""), "no face for", ), ( fonts.replace("sans = \"Outfit\"\n", ""), "neither the sans nor the mono", ), (fonts.replace("weight = 700", "weight = 0"), "outside"), ( fonts.replace("sans = \"Outfit\"", "sans = \"Out;fit\""), "plain name", ), ( "[index.fonts]\nfaces = []".to_string(), "neither sans nor mono", ), ] { let error = load_result(&bad).err().expect(why); assert!(format!("{error:#}").contains(why), "{why}: {error:#}"); }}224 unmodified linesExpand all2252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622636 unmodified lines2702712722732742752762772788 unmodified lines28728828929029129229329429525 unmodified lines321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394239 unmodified linesExpand all224 unmodified linesExpand all /// derived from the og_* lines when unset pub og_alt: Option<String>, /// where owners and repos link to, as `<links>/<owner>/<name>` pub links: String, /// web fonts for the page; without them it draws in the system's monospace pub fonts: Option<IndexFonts>,}
#[derive(Debug, Clone, PartialEq, Eq)]pub struct IndexFonts { /// the page's text; without it the page stays monospace, in `mono` pub sans: Option<String>, /// dids, and everything when there's no `sans` pub mono: Option<String>, pub faces: Vec<FontFace>,}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]#[serde(deny_unknown_fields)]pub struct FontFace { pub family: String, #[serde(default = "FontFace::regular")] pub weight: u16, #[serde(default)] pub italic: bool, /// a same-origin path such as `/fonts/outfit-400.woff2`, or an http(s) url pub url: String,}
impl FontFace { fn regular() -> u16 { 400 }}
pub const MOST_OG_DESCRIPTION_LINES: usize = 3;
impl Default for IndexConfig { fn default() -> Self {6 unmodified lines og_subtitle: "a static knot for *tangled*".to_string(), og_description: "*{repos}* repos across *{users}* owners\nserved straight from nginx *{kaomoji}*".to_string(), og_alt: None, links: "https://tangled.org".to_string(), fonts: None, } }}
8 unmodified lines og_subtitle: raw.og_subtitle.unwrap_or(defaults.og_subtitle), og_description: raw.og_description.unwrap_or(defaults.og_description), og_alt: raw.og_alt, links: raw.links.unwrap_or(defaults.links), fonts: raw.fonts.map(IndexFonts::from_raw).transpose()?, }; anyhow::ensure!( index.links.starts_with("https://") || index.links.starts_with("http://"), "index.links {:?} is not an http(s) url",25 unmodified lines #[serde(default)] pub(crate) og_alt: Option<String>, #[serde(default)] pub(crate) links: Option<String>, #[serde(default)] pub(crate) fonts: Option<RawFonts>,}
#[derive(Deserialize)]#[serde(deny_unknown_fields)]pub(crate) struct RawFonts { #[serde(default)] pub(crate) sans: Option<String>, #[serde(default)] pub(crate) mono: Option<String>, #[serde(default)] pub(crate) faces: Vec<FontFace>,}
impl IndexFonts { fn from_raw(raw: RawFonts) -> anyhow::Result<Self> { // names and urls go into the page's css as quoted strings let plain = |text: &str| { !text.is_empty() && !text .chars() .any(|c| c.is_control() || "\"'\\<>(){};".contains(c)) }; let families: Vec<&String> = raw.sans.iter().chain(&raw.mono).collect(); anyhow::ensure!( !families.is_empty(), "index.fonts names neither sans nor mono" ); for family in &families { anyhow::ensure!( plain(family), "index.fonts family {family:?} is not a plain name" ); anyhow::ensure!( raw.faces.iter().any(|face| &&face.family == family), "index.fonts has no face for {family:?}" ); } for face in &raw.faces { anyhow::ensure!( families.contains(&&face.family), "index.fonts face {:?} is neither the sans nor the mono family", face.family ); anyhow::ensure!( (1..=1000).contains(&face.weight), "index.fonts face weight {} is outside 1..=1000", face.weight ); let path = face.url.starts_with('/') && !face.url.starts_with("//"); let http = face.url.starts_with("https://") || face.url.starts_with("http://"); anyhow::ensure!( (path || http) && plain(&face.url) && !face.url.contains(char::is_whitespace), "index.fonts face url {:?} is not a same-origin path or an http(s) url", face.url ); } Ok(Self { sans: raw.sans, mono: raw.mono, faces: raw.faces, }) }}
#[derive(Debug, Clone, Default)]pub struct WritesConfig { pub enabled: bool, pub fastcgi_socket: Option<String>,239 unmodified linesExpand all123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104use crate::config::{FontFace, IndexFonts};
use super::escape;
const SANS_FALLBACK: &str = "ui-sans-serif,system-ui,sans-serif";const MONO_FALLBACK: &str = r#"ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace"#;
/// which of the page's faces its first screen draws#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub(super) struct Drawn { /// a person's header names a did pub(super) did_heads: bool, /// a commons row names a did pub(super) did_rows: bool,}
/// preloads and css for the head, empty without fontspub(super) fn head(fonts: Option<&IndexFonts>, drawn: Drawn) -> String { let Some(fonts) = fonts else { return String::new(); }; let text = fonts.sans.as_deref().or(fonts.mono.as_deref()); let did = fonts.mono.as_deref(); let mut wanted = vec![(text, 400), (text, 700)]; if drawn.did_heads { wanted.push((did, 700)); } if drawn.did_rows { wanted.push((did, 400)); } let mut preloads: Vec<&FontFace> = Vec::new(); for (family, weight) in wanted { if let Some(face) = family.and_then(|family| matched(&fonts.faces, family, weight)) && !preloads.contains(&face) { preloads.push(face); } }
let mut out = String::new(); for face in preloads { out.push_str(&format!( "\n<link rel=\"preload\" href=\"{}\" as=\"font\" type=\"font/woff2\" crossorigin>", escape(&face.url) )); } out.push_str("\n<style>"); for face in &fonts.faces { let format = if face.url.ends_with(".woff2") { " format(\"woff2\")" } else { "" }; let style = if face.italic { "italic" } else { "normal" }; out.push_str(&format!( "@font-face{{font-family:\"{}\";src:url(\"{}\"){format};font-weight:{};font-style:{style};font-display:block}}\n", face.family, face.url, face.weight )); } let mono = fonts .mono .as_ref() .map_or(MONO_FALLBACK.to_string(), |mono| { format!("\"{mono}\",{MONO_FALLBACK}") }); let body = fonts .sans .as_ref() .map_or(mono.clone(), |sans| format!("\"{sans}\",{SANS_FALLBACK}")); // one x-height whichever face draws, and digits that hold their columns out.push_str(&format!( "html{{font-size-adjust:ex-height .5}}\nbody{{font-family:{body};font-variant-numeric:tabular-nums}}\n.did{{font-family:{mono}}}</style>" )); out}
/// the face css font matching picks for `weight` in `family`fn matched<'a>(faces: &'a [FontFace], family: &str, weight: u16) -> Option<&'a FontFace> { let upright: Vec<&FontFace> = faces .iter() .filter(|face| face.family == family && !face.italic) .collect(); let lighter = |face: &&&FontFace| face.weight <= weight; let heavier = |face: &&&FontFace| face.weight >= weight; let below = upright .iter() .filter(lighter) .max_by_key(|face| face.weight); let above = upright .iter() .filter(heavier) .min_by_key(|face| face.weight); let found = match weight { 400..=500 => upright .iter() .filter(|face| (weight..=500).contains(&face.weight)) .min_by_key(|face| face.weight) .or(below) .or(above), ..400 => below.or(above), _ => above.or(below), }; found.copied()}1 unmodified line2345667891013 unmodified lines242526272829303132116 unmodified linesExpand all14915015115215315415515615721 unmodified lines179180181182183184185186187282 unmodified linesExpand all47047147247347447547647747847247948048148248348448548648748848936 unmodified lines52652752852953051953153253353453599 unmodified lines63563663763862763964064164264364439 unmodified lines68468568668768868969069169269369469569669769869921 unmodified lines72172272372470472572672772872915 unmodified lines1 unmodified lineuse std::path::Path;
use anyhow::Context as _;
use crate::config::{IndexConfig, ValidatedConfig};use crate::config::{IndexConfig, IndexFonts, ValidatedConfig};use crate::manifest::{GenerationManifest, RepoEntry};
pub const INDEX_FILE: &str = "index.html";pub const FAVICON_SVG_FILE: &str = "favicon.svg";13 unmodified linesconst POPOVER_CHAR_THRESHOLD: usize = 45;
mod actor_profile;mod avatar_sizes;mod fonts;mod handles;pub mod og;mod pulse;#[cfg(test)]116 unmodified linesExpand all pub og_subtitle: String, pub og_description: String, pub og_alt: String, pub links: String, pub fonts: Option<IndexFonts>,}
impl Page { pub fn new(index: &IndexConfig, vars: &Vars) -> Self {21 unmodified lines og_subtitle, og_description, og_alt, links: vars.expand(&index.links).trim_end_matches('/').to_string(), fonts: index.fonts.clone(), } }}
282 unmodified linesExpand all out}
/// a label is a handle, or the did when the handle didn't verifyfn is_did(label: &str) -> bool { label.starts_with("did:")}
fn handle_mark(label: &str) -> &'static str { if label.starts_with("did:") { "" } else { "@" } if is_did(label) { "" } else { "@" }}
fn did_class(label: &str) -> &'static str { if is_did(label) { " class=\"did\"" } else { "" }}
struct Rows<'a> { links: &'a str, base: &'a str, now: i64,36 unmodified lines "\n <li{class} data-q=\"{query}\"{line}><div class=\"row\">" )); if owner.is_some() { let at = handle_mark(label); let class = if is_did(label) { "own did" } else { "own" }; out.push_str(&format!("<span class=\"own\">{at}{label_esc}/</span>")); out.push_str(&format!("<span class=\"{class}\">{at}{label_esc}/</span>")); } out.push_str(&format!( "<a href=\"{}/{label_esc}/{name}\" data-t=\"{name}\">{name}</a>", self.links99 unmodified lines hue(§ion.did) )); Rows::repos_open(&mut people_html, &id, count); people_html.push_str(&format!( "\n <header class=\"ph\"><a href=\"{links_esc}/{owner}\">{at}{owner}</a><span>{count}</span></header>" "\n <header class=\"ph\"><a{} href=\"{links_esc}/{owner}\">{at}{owner}</a><span>{count}</span></header>", did_class(§ion.label) )); Rows::list_open(&mut people_html, count); for (row_idx, repo) in section.repos.iter().enumerate() { rows.row(&mut people_html, §ion.label, repo, row_idx, count, None);39 unmodified lines Rows::fold(&mut commons_html, "c", count); commons_html.push_str("\n </section>"); }
let fonts_head = fonts::head( page.fonts.as_ref(), fonts::Drawn { did_heads: own.iter().any(|section| is_did(§ion.label)), did_rows: shared.iter().any(|section| is_did(§ion.label)), }, );
let faces_js: String = KAOMOJI .iter() .map(|face| serde_json::to_string(face).expect("serialize kaomoji")) .collect::<Vec<_>>()21 unmodified lines<meta property="og:image:alt" content="{og_image_alt_esc}"><meta name="twitter:card" content="summary_large_image"><meta name="theme-color" content="{THEME_COLOR_LIGHT}" media="(prefers-color-scheme: light)"><meta name="theme-color" content="{THEME_COLOR_DARK}" media="(prefers-color-scheme: dark)"><style>{STYLE}</style><style>{STYLE}</style>{fonts_head}<noscript><style>.search,.clone{{display:none}}.repos li:hover .age{{visibility:visible}}</style></noscript><script>document.documentElement.classList.add("js")</script></head><body>15 unmodified lines1 unmodified line234566789101558 unmodified linesExpand all1569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211 unmodified line AUTHOR_REVERIFY_SECS, FailedPublish, Fetched, Named, StoredHandle, profile_of, published_description, refresh_handles_with, sweep_avatars, verify_unseen_with,};use super::*;use crate::config::IndexConfig;use crate::config::{FontFace, IndexConfig, IndexFonts};use crate::datastore::{Datastore, schema};use knot_atproto::Atproto;use knot_record::manifest::{MANIFEST_COLLECTION, MANIFEST_RKEY, RepoDescription};use knot_types::{AccountDid, RepoDid};1558 unmodified linesExpand all html.contains("<i style=\"height:16px\" title=\"nov 10: 3 pushes\"></i>"), "{html}" );}
fn klbr_fonts() -> IndexFonts { let face = |family: &str, weight, file: &str| FontFace { family: family.to_string(), weight, italic: false, url: format!("/fonts/{file}.woff2"), }; IndexFonts { sans: Some("Outfit".to_string()), mono: Some("Maple Mono".to_string()), faces: vec![ face("Outfit", 400, "outfit-400"), face("Outfit", 500, "outfit-500"), face("Outfit", 700, "outfit-700"), face("Maple Mono", 400, "maple-400"), face("Maple Mono", 700, "maple-700"), ], }}
fn draw_with_fonts(sections: &[PersonSection], fonts: Option<IndexFonts>) -> String { let index = IndexConfig { fonts, ..IndexConfig::default() }; render( &test_url("git.klbr.net"), "git.klbr.net", "gen-1", sections, &page_with(&index, "git.klbr.net", "gen-1", sections), &Moment::default(), )}
fn preloads(html: &str) -> Vec<&str> { html.split("<link rel=\"preload\" href=\"") .skip(1) .map(|rest| &rest[..rest.find('"').unwrap()]) .collect()}
#[test]fn a_knot_without_fonts_keeps_the_system_monospace() { let html = draw_with_fonts(&[person("ptr.pet", "did:plc:a", 3)], None); assert!(html.contains("font:14px/1.6 ui-monospace,")); assert!(preloads(&html).is_empty(), "{html}"); for absent in ["@font-face", "font-size-adjust", "tabular-nums"] { assert!(!html.contains(absent), "{absent}"); }}
#[test]fn configured_fonts_preload_only_the_faces_the_page_draws() { let handles = [ person("ptr.pet", "did:plc:a", 3), person("dawn.pet", "did:plc:b", 1), ]; let html = draw_with_fonts(&handles, Some(klbr_fonts())); assert_eq!( preloads(&html), ["/fonts/outfit-400.woff2", "/fonts/outfit-700.woff2"] ); assert_eq!(html.matches("@font-face").count(), 5); assert!(html.contains( "@font-face{font-family:\"Maple Mono\";src:url(\"/fonts/maple-700.woff2\") format(\"woff2\");font-weight:700;font-style:normal;font-display:block}" )); assert!(html.contains("html{font-size-adjust:ex-height .5}")); assert!(html.contains( "body{font-family:\"Outfit\",ui-sans-serif,system-ui,sans-serif;font-variant-numeric:tabular-nums}" )); assert!(html.contains(".did{font-family:\"Maple Mono\",ui-monospace,")); assert!(!html.contains("class=\"did\"") && !html.contains("own did"));
// a did names a header in bold, and a commons row in regular let did_head = [ person("did:plc:a", "did:plc:a", 3), person("dawn.pet", "did:plc:b", 1), ]; let html = draw_with_fonts(&did_head, Some(klbr_fonts())); assert!(html.contains("<a class=\"did\" href=\"https://tangled.org/did:plc:a\">did:plc:a</a>")); assert_eq!( preloads(&html), [ "/fonts/outfit-400.woff2", "/fonts/outfit-700.woff2", "/fonts/maple-700.woff2" ] ); let did_row = [ person("ptr.pet", "did:plc:a", 3), person("did:plc:b", "did:plc:b", 1), ]; let html = draw_with_fonts(&did_row, Some(klbr_fonts())); assert!(html.contains("<span class=\"own did\">did:plc:b/</span>")); assert_eq!( preloads(&html), [ "/fonts/outfit-400.woff2", "/fonts/outfit-700.woff2", "/fonts/maple-400.woff2" ] );}
#[test]fn a_mono_only_knot_draws_everything_in_its_mono() { let fonts = IndexFonts { sans: None, ..klbr_fonts() }; let html = draw_with_fonts(&[person("did:plc:a", "did:plc:a", 3)], Some(fonts)); assert_eq!( preloads(&html), ["/fonts/maple-400.woff2", "/fonts/maple-700.woff2"] ); assert!(html.contains("body{font-family:\"Maple Mono\",ui-monospace,"));}
#[test]fn a_missing_weight_preloads_the_face_css_would_pick() { // 400 and 500 look upward to 500 first, lighter weights look down, heavier look up let mut fonts = klbr_fonts(); fonts .faces .retain(|face| face.weight != 400 || face.family != "Outfit"); fonts .faces .retain(|face| face.weight != 700 || face.family != "Outfit"); fonts.faces.push(FontFace { family: "Outfit".to_string(), weight: 300, italic: false, url: "/fonts/outfit-300.woff2".to_string(), }); fonts.faces.push(FontFace { family: "Outfit".to_string(), weight: 800, italic: true, url: "/fonts/outfit-800i.woff2".to_string(), }); let html = draw_with_fonts(&[person("ptr.pet", "did:plc:a", 3)], Some(fonts)); assert_eq!( preloads(&html), ["/fonts/outfit-500.woff2"], "400 takes 500, 700 takes 500 since the only heavier face is italic" );}