a generation draws an og card for each repo, ticket, pull, recent commit and owner, and nginx serves a page's card, else its repo's, else the knot's
Signed-off-by: dawn <dawn@klbr.net>
14 unmodified lines151617181920212223308 unmodified linesExpand all14 unmodified linespub(crate) mod index_page;pub(crate) mod maintenance;pub mod nginx;pub(crate) mod oauth;pub(crate) mod og_card;pub(crate) mod spindle;pub(crate) mod transport;pub(crate) mod xrpc;
308 unmodified linesExpand all3 unmodified lines456788910111112131415153 unmodified linesExpand all3 unmodified linesuse serde::{Deserialize, Serialize};
use crate::cas::Digest;
pub const GENERATOR_PROFILE_VERSION: &str = "knot-static/gen-18";pub const GENERATOR_PROFILE_VERSION: &str = "knot-static/gen-19";
/// bump this when the conf shape changes, or the id won't rotate.pub const NGINX_TEMPLATE_VERSION: &str = "knot-static/nginx-32";pub const NGINX_TEMPLATE_VERSION: &str = "knot-static/nginx-33";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]pub struct RepoEntry { pub repo_did: String,153 unmodified linesExpand all607 unmodified linesExpand all608609610611612613614615616617618619620621622623624625626627628629131 unmodified linesExpand all607 unmodified linesExpand all .map(crate::git_xrpc::seal) .collect::<anyhow::Result<_>>()? }; crate::git_xrpc::emit(&build_dir, &git_xrpc_repos)?; let card_repos = crate::xrpc::social::card_repos(&atproto_repos, &social); let card_users = crate::xrpc::social::card_users(&card_repos, &social); let card_git: Vec<crate::og_card::GitRepo> = atproto_sources .iter() .zip(&git_xrpc_repos) .map(|(source, sealed)| crate::og_card::GitRepo { did: source.did.as_str(), export: &source.export_dir, served: source.served.clone(), seal: &sealed.key, }) .collect(); let cards = crate::og_card::plan(config.hostname.as_str(), card_repos, card_users, &card_git)?; crate::og_card::emit(&output_root, &build_dir, cards).context("emit og cards")?; // a blob write waits on the sweep. the source locks drop right after it drop(_source_locks); // chain heads are generation identity, since a ref flipped back still moves the rev let atproto_heads: BTreeMap<&str, String> = atproto_repos131 unmodified linesExpand all383 unmodified linesExpand all384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419467 unmodified linesExpand all383 unmodified linesExpand all .iter() .all(|(file, &len)| std::fs::metadata(dir.join(file)).is_ok_and(|meta| meta.len() == len))}
/// the languages the seal serves for a commit, biggest firstpub(crate) fn sealed_languages( served: &RepoStore, key: &str, commit: &str,) -> Option<Vec<(String, u64)>> { #[derive(Deserialize)] struct Languages { languages: Vec<wire::LanguageOut>, } let rows = sealed_rows(served, key)?; let file = rows.languages.get(commit)?; let answer: Languages = read_json(&served.pool(POOL_KIND).join(file))?; Some( answer .languages .into_iter() .map(|language| (language.name, language.size)) .collect(), )}
/// insertions, deletions and files changedpub(crate) fn diff_stat(patches: &[knot_git::FilePatch]) -> (i64, i64, i64) { let stat = wire::DiffStatOut::of(patches); (stat.insertions, stat.deletions, stat.files_changed)}
pub fn patch_ranges(cobs: &crate::atproto::CobState) -> Vec<(Oid, Oid)> { let oid = |value: &serde_json::Value, side: &str| { value[side]["oid"] .as_str()467 unmodified linesExpand all396 unmodified linesExpand all397398399400401402403404405406407408409406410411412413414334 unmodified linesExpand all396 unmodified linesExpand all
/// svfe's `authorColour`, so a person has one colour on both sites: fnv-1a from one past its/// offset basis, which keeps the owners on the colours the design settledpub fn hue(did: &str) -> &'static str { HUES[hue_index(did)]}
pub(crate) fn hue_index(did: &str) -> usize { let mut hash: u32 = 0x811c_9dc6; for byte in did.bytes() { hash ^= u32::from(byte); hash = hash.wrapping_mul(0x0100_0193); } HUES[hash as usize % HUES.len()] hash as usize % HUES.len()}
/// the age a row shows, the page's script recounts it from `data-at`pub fn age(now: i64, at: i64) -> String {334 unmodified linesExpand all60 unmodified lines61626364656667686970717273747576777879808182838485868788899091929394292 unmodified linesExpand all387388389390391392393394395222 unmodified linesExpand all60 unmodified lines ), ]}
/// a page's og card, else its repo's, else the knot'sfn og_card_locations(generation_path: &str, limits: &ServeLimits) -> [Block; 2] { let www = format!("{generation_path}/www"); let dir = crate::og_card::WWW_DIR; let location = |head: Block, fallbacks: &[&str]| { let location = cached( head.block(Block::new("types", NONE)), "image/png", REVALIDATE, ) .directive("root", [&www]) .directive( "try_files", std::iter::once("$uri").chain(fallbacks.iter().copied()), ); read_limited(location, limits) }; [ location( Block::new("location", ["~", &format!("^/{dir}/(?<ks_og_repo>[^/]+)/")]), &[&format!("/{dir}/$ks_og_repo.png"), "/og.png"], ), location(Block::new("location", [format!("/{dir}/")]), &["/og.png"]), ]}
fn file_location( path: &str, content_type: &str, cache_control: &str,292 unmodified linesExpand all REVALIDATE, format!("{gen_path}/www/{}", crate::index_page::OG_IMAGE_FILE), limits, )) .blocks(og_card_locations(gen_path, limits)) .block(file_location( "/.well-known/did.json", "application/did+json", "public, max-age=300",222 unmodified linesExpand all123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332//! a flat-colour canvas: shapes by their signed distance, text through fontdue, png out
use std::io::Write as _;use std::sync::OnceLock;
use anyhow::Context as _;
pub(super) const WIDTH: usize = crate::index_page::OG_IMAGE_WIDTH as usize;pub(super) const HEIGHT: usize = crate::index_page::OG_IMAGE_HEIGHT as usize;
pub(super) type Rgb = [u8; 3];pub(super) type Point = (f32, f32);
/// coverage is kept to this many steps, so a card fits a 256-colour paletteconst COVERAGE_STEPS: f32 = 15.0;
#[derive(Clone, Copy)]pub(super) enum Face { Sans, SansBold, Mono, MonoBold, /// the kaomoji's face, and any glyph the others don't have Wide,}
struct Fonts { sans: fontdue::Font, sans_bold: fontdue::Font, mono: fontdue::Font, mono_bold: fontdue::Font, wide: fontdue::Font,}
fn fonts() -> &'static Fonts { static FONTS: OnceLock<Fonts> = OnceLock::new(); FONTS.get_or_init(|| { let load = |bytes: &'static [u8]| { fontdue::Font::from_bytes(bytes, fontdue::FontSettings::default()) .expect("the card fonts are built in") }; Fonts { sans: load(include_bytes!("fonts/host-grotesk-400.ttf")), sans_bold: load(include_bytes!("fonts/host-grotesk-600.ttf")), mono: load(include_bytes!("fonts/kode-mono-400.ttf")), mono_bold: load(include_bytes!("fonts/kode-mono-700.ttf")), wide: load(include_bytes!("../favicon/JetBrainsMonoNL-Medium.ttf")), } })}
impl Face { fn font(self) -> &'static fontdue::Font { let fonts = fonts(); match self { Self::Sans => &fonts.sans, Self::SansBold => &fonts.sans_bold, Self::Mono => &fonts.mono, Self::MonoBold => &fonts.mono_bold, Self::Wide => &fonts.wide, } }
/// the face that draws `ch`: this one, else the wide one fn for_char(self, ch: char) -> &'static fontdue::Font { let font = self.font(); match font.lookup_glyph_index(ch) { 0 => Self::Wide.font(), _ => font, } }
pub(super) fn width(self, size: f32, text: &str) -> f32 { text.chars() .map(|ch| self.for_char(ch).metrics(ch, size).advance_width) .sum() }
/// the baseline of a line box `height` tall whose top is at `top` pub(super) fn baseline(self, size: f32, top: f32, height: f32) -> f32 { let metrics = self .font() .horizontal_line_metrics(size) .expect("the card fonts have horizontal metrics"); top + (height - (metrics.ascent - metrics.descent)) / 2.0 + metrics.ascent }
/// `text` cut to `max` wide, with an ellipsis when it was cut pub(super) fn fit(self, size: f32, max: f32, text: &str) -> String { if self.width(size, text) <= max { return text.to_string(); } let room = max - self.width(size, "…"); let mut out = String::new(); let mut used = 0.0; for ch in text.chars() { let advance = self.for_char(ch).metrics(ch, size).advance_width; if used + advance > room { break; } used += advance; out.push(ch); } format!("{}…", out.trim_end()) }
/// `text` in lines of at most `max` wide, `first` for the first one, the last cut with an /// ellipsis pub(super) fn wrap( self, size: f32, first: f32, max: f32, lines: usize, text: &str, ) -> Vec<String> { let width = |out: &Vec<String>| if out.is_empty() { first } else { max }; let mut out: Vec<String> = Vec::new(); let mut line = String::new(); let words: Vec<&str> = text.split_whitespace().collect(); for (index, word) in words.iter().enumerate() { let candidate = match line.is_empty() { true => word.to_string(), false => format!("{line} {word}"), }; if self.width(size, &candidate) <= width(&out) { line = candidate; continue; } if out.len() + 1 == lines { let rest = words[index..].join(" "); let last = match line.is_empty() { true => rest, false => format!("{line} {rest}"), }; out.push(self.fit(size, width(&out), &last)); return out; } if !line.is_empty() { out.push(std::mem::take(&mut line)); } line = match self.width(size, word) <= width(&out) { true => word.to_string(), false => self.fit(size, width(&out), word), }; } if !line.is_empty() { out.push(line); } out }}
pub(super) struct Canvas { pixels: Vec<Rgb>,}
impl Canvas { pub(super) fn new(background: Rgb) -> Self { Self { pixels: vec![background; WIDTH * HEIGHT], } }
pub(super) fn blend(&mut self, x: i32, y: i32, color: Rgb, alpha: f32) { let alpha = (alpha.min(1.0) * COVERAGE_STEPS).round() / COVERAGE_STEPS; if alpha <= 0.0 || x < 0 || y < 0 || x >= WIDTH as i32 || y >= HEIGHT as i32 { return; } let pixel = &mut self.pixels[y as usize * WIDTH + x as usize]; *pixel = mix(color, *pixel, alpha); }
/// coverage from the distance of each pixel centre, `shape` returns it signed in pixels pub(super) fn fill( &mut self, bounds: (Point, Point), color: Rgb, shape: impl Fn(Point) -> f32, ) { let ((x0, y0), (x1, y1)) = bounds; for y in (y0.floor() as i32 - 1)..=(y1.ceil() as i32 + 1) { for x in (x0.floor() as i32 - 1)..=(x1.ceil() as i32 + 1) { let distance = shape((x as f32 + 0.5, y as f32 + 0.5)); self.blend(x, y, color, (0.5 - distance).clamp(0.0, 1.0)); } } }
pub(super) fn rect(&mut self, x: f32, y: f32, width: f32, height: f32, color: Rgb) { let bounds = ((x, y), (x + width, y + height)); self.fill(bounds, color, |point| boxed(bounds, point)); }
/// a `width` thick line inside the rectangle's edge pub(super) fn outline( &mut self, x: f32, y: f32, width: f32, height: f32, line: f32, color: Rgb, ) { self.rect(x, y, width, line, color); self.rect(x, y + height - line, width, line, color); self.rect(x, y + line, line, height - 2.0 * line, color); self.rect(x + width - line, y + line, line, height - 2.0 * line, color); }
/// draws `text` from `x` and returns where it ends pub(super) fn text( &mut self, face: Face, size: f32, x: f32, baseline: f32, text: &str, color: Rgb, ) -> f32 { let mut pen = x; for ch in text.chars() { let font = face.for_char(ch); let (metrics, coverage) = font.rasterize(ch, size); let left = pen.round() as i32 + metrics.xmin; let top = baseline.round() as i32 - metrics.ymin - metrics.height as i32; for (row, ink) in coverage.chunks(metrics.width.max(1)).enumerate() { for (column, &alpha) in ink.iter().enumerate() { self.blend( left + column as i32, top + row as i32, color, f32::from(alpha) / 255.0, ); } } pen += metrics.advance_width; } pen }
/// an rgb image drawn at its own size from `(x, y)` pub(super) fn image(&mut self, x: i32, y: i32, width: u32, rgb: &[u8]) { for (index, pixel) in rgb.as_chunks::<3>().0.iter().enumerate() { let (column, row) = (index as u32 % width, index as u32 / width); let (px, py) = (x + column as i32, y + row as i32); if px >= 0 && py >= 0 && (px as usize) < WIDTH && (py as usize) < HEIGHT { self.pixels[py as usize * WIDTH + px as usize] = *pixel; } } }
/// indexed when the card fits a palette, which it does unless an avatar brings its own colours pub(super) fn png(&self) -> anyhow::Result<Vec<u8>> { let (colour_type, palette, rows) = match self.palette() { Some((palette, indices)) => (3, Some(palette), indices), None => (2, None, self.pixels.iter().flatten().copied().collect()), }; let stride = rows.len() / HEIGHT; let mut raw = Vec::with_capacity(rows.len() + HEIGHT); for row in rows.chunks(stride) { raw.push(0); raw.extend(row); } let mut zlib = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); zlib.write_all(&raw).context("deflate a card")?; let idat = zlib.finish().context("deflate a card")?;
let mut ihdr = Vec::with_capacity(13); ihdr.extend((WIDTH as u32).to_be_bytes()); ihdr.extend((HEIGHT as u32).to_be_bytes()); // 8-bit indexed or rgb, deflate, no filtering, no interlace ihdr.extend([8, colour_type, 0, 0, 0]);
let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); let mut chunks: Vec<(&[u8; 4], &[u8])> = vec![(b"IHDR", &ihdr)]; if let Some(palette) = &palette { chunks.push((b"PLTE", palette)); } chunks.extend([(b"IDAT", idat.as_slice()), (b"IEND", &[][..])]); for (kind, data) in chunks { png.extend((data.len() as u32).to_be_bytes()); let mut crc = crc32fast::Hasher::new(); crc.update(kind); crc.update(data); png.extend(kind); png.extend(data); png.extend(crc.finalize().to_be_bytes()); } Ok(png) }
/// the palette and each pixel's index into it, if there are 256 colours or fewer fn palette(&self) -> Option<(Vec<u8>, Vec<u8>)> { let mut palette: Vec<Rgb> = Vec::new(); let mut indices = Vec::with_capacity(self.pixels.len()); let mut last: Option<(Rgb, u8)> = None; for &pixel in &self.pixels { let index = match last { Some((colour, index)) if colour == pixel => index, _ => { let index = match palette.iter().position(|&colour| colour == pixel) { Some(index) => index, None if palette.len() == 256 => return None, None => { palette.push(pixel); palette.len() - 1 } } as u8; last = Some((pixel, index)); index } }; indices.push(index); } Some((palette.concat(), indices)) }}
/// `alpha` of `over` on `under`, css's color-mix in srgbpub(super) fn mix(over: Rgb, under: Rgb, alpha: f32) -> Rgb { let mut out = under; for (out, (over, under)) in out.iter_mut().zip(over.into_iter().zip(under)) { *out = (f32::from(under) + (f32::from(over) - f32::from(under)) * alpha).round() as u8; } out}
/// the signed distance to an axis-aligned rectangle, negative insidepub(super) fn boxed(((x0, y0), (x1, y1)): (Point, Point), (x, y): Point) -> f32 { (x0 - x).max(x - x1).max(y0 - y).max(y - y1)}123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454//! open graph cards for a knot's pages: a repo's, its tickets', the commits svfe renders and//! its owners'. each is drawn once for what it shows and kept by that in the output root, a//! generation links the ones it names under `www/og`, and nginx serves the repo's card, then the//! knot's, for a page without its own
use std::collections::BTreeSet;use std::path::{Path, PathBuf};
use anyhow::Context as _;use knot_types::Oid;use rayon::prelude::*;use serde::Serialize;
use crate::cas::{atomic_write, link_or_reflink};
mod canvas;mod draw;mod plan;#[cfg(test)]mod tests;
use canvas::{Rgb, mix};pub(crate) use plan::{GitRepo, SocialPull, SocialRepo, SocialTicket, SocialUser, plan};
/// the drawn cards, by the hash of what they showpub(crate) const CARDS_DIR: &str = "og-cards";/// where a generation serves thempub(crate) const WWW_DIR: &str = "og";/// the commit pages svfe-pages keeps of a repo, newest first in the log's orderpub(crate) const COMMITS: usize = 60;/// bump when a card's look changes, so every card is drawn againconst VERSION: u32 = 1;/// the cards' palette: an unfurl can't follow its reader's theme, so the knot picks oneconst DARK: bool = false;
#[derive(Serialize, Clone)]pub(crate) struct Ribbon { pub(crate) host: String, /// the owner, or `owner / repo` pub(crate) path: String,}
#[derive(Serialize, Clone)]pub(crate) struct Language { pub(crate) name: String, pub(crate) size: u64,}
#[derive(Serialize, Clone)]pub(crate) struct Diff { pub(crate) insertions: u64, pub(crate) deletions: u64, pub(crate) files: u64,}
#[derive(Serialize)]pub(crate) enum Card { Repo { ribbon: Ribbon, name: String, description: String, topics: Vec<String>, open_tickets: usize, open_pulls: usize, branch: String, languages: Vec<Language>, }, Ticket { ribbon: Ribbon, number: String, title: String, state: String, author: String, date: String, body: String, }, Pull { ribbon: Ribbon, number: String, title: String, state: String, author: String, /// the author's colour, svfe's `authorHue` hue: usize, /// what places the punch holes' notches seed: String, date: String, from: String, into: String, body: String, diff: Option<Diff>, }, Commit { ribbon: Ribbon, subject: String, short: String, author: String, date: String, branch: String, body: String, diff: Option<Diff>, }, User { host: String, handle: String, about: String, repos: Vec<String>, repo_count: usize, /// the avatar's pixels at the tile's size, keyed by its cid #[serde(skip)] avatar: Option<Vec<u8>>, },}
/// what a card shows that's read only when it has to be drawnpub(crate) enum Lazy { Ready(Card), /// the repo's languages, as its git reads answer them for `commit` Languages { card: Card, served: crate::store::RepoStore, seal: String, commit: String, }, Commit { export: PathBuf, oid: Oid, ribbon: Ribbon, branch: String, }, PullDiff { card: Card, export: PathBuf, base: Oid, head: Oid, }, Avatar { card: Card, file: Option<PathBuf>, },}
/// a card a generation serves at `www/og/<path>`pub(crate) struct Planned { pub(crate) path: String, pub(crate) key: String, pub(crate) card: Lazy,}
/// what keys a card: everything it shows, or what that is read frompub(crate) fn key(shown: &impl Serialize) -> anyhow::Result<String> { Ok(crate::atproto::short_hash(&serde_json::to_vec(&( VERSION, DARK, shown, ))?))}
pub(crate) struct Theme { pub(crate) bg: Rgb, pub(crate) card: Rgb, pub(crate) text: Rgb, pub(crate) muted: Rgb, pub(crate) accent: Rgb, pub(crate) soft: Rgb, pub(crate) border: Rgb, pub(crate) teal: Rgb, pub(crate) lav: Rgb, pub(crate) add: Rgb, pub(crate) del: Rgb, pub(crate) lane: Rgb, pub(crate) patch: Rgb, pub(crate) pedge: Rgb, pub(crate) rim: Rgb, /// svfe's author colours: accent, lav, teal, amber, sky pub(crate) hues: [Rgb; 5], /// a language bar's colours, biggest first pub(crate) languages: [Rgb; 5],}
/// svfe.css's palettes, with its color-mix shades worked outpub(crate) fn theme() -> Theme { let rgb = |hex: u32| [(hex >> 16) as u8, (hex >> 8) as u8, hex as u8]; match DARK { false => { let (bg, card, text, muted, accent) = ( rgb(0xfdf7fa), rgb(0xffffff), rgb(0x3b2f36), rgb(0x9a8691), rgb(0xd9628f), ); let (soft, lav, teal, amber, sky) = ( rgb(0xfbe3ee), rgb(0x8a6fd0), rgb(0x3f9c8c), rgb(0xc08a2c), rgb(0x4f8fc4), ); let patch = mix(accent, card, 0.07); Theme { bg, card, text, muted, accent, soft, border: rgb(0xf1d5e2), teal, lav, add: rgb(0x3f8f5f), del: rgb(0xc4475f), lane: mix(soft, bg, 0.45), patch, pedge: mix(accent, card, 0.38), rim: mix(accent, patch, 0.42), hues: [accent, lav, teal, amber, sky], languages: [accent, sky, lav, teal, muted], } } true => { let (bg, card, text, muted, accent) = ( rgb(0x1b1519), rgb(0x241c21), rgb(0xf1e4ea), rgb(0xa8919d), rgb(0xf28bb5), ); let (soft, lav, teal, amber, sky) = ( rgb(0x3a2531), rgb(0xb6a4f0), rgb(0x5fbfae), rgb(0xe3b45f), rgb(0x7fb3e0), ); let patch = mix(accent, mix(text, card, 0.07), 0.09); Theme { bg, card, text, muted, accent, soft, border: rgb(0x3d2c36), teal, lav, add: rgb(0x8fd5a6), del: rgb(0xf29aa9), lane: card, patch, pedge: mix(text, patch, 0.16), rim: mix(text, patch, 0.24), hues: [accent, lav, teal, amber, sky], languages: [accent, sky, lav, teal, muted], } } }}
impl Lazy { fn resolve(self) -> anyhow::Result<Card> { match self { Self::Ready(card) => Ok(card), Self::Languages { mut card, served, seal, commit, } => { if let Card::Repo { languages, .. } = &mut card { *languages = crate::git_xrpc::sealed_languages(&served, &seal, &commit) .unwrap_or_default() .into_iter() .map(|(name, size)| Language { name, size }) .collect(); } Ok(card) } Self::Commit { export, oid, ribbon, branch, } => { let repo = knot_git::Repo::open(&export)?; let commit = repo.find_commit(oid)?; let (subject, body) = split_message(&commit.message); Ok(Card::Commit { ribbon, subject, short: oid.to_hex()[..7].to_string(), author: commit.author.name.as_str().to_string(), date: date_of(commit.author.time.get(), commit.author.offset_seconds), branch, body, diff: diff(&repo, commit.parents.first().copied(), oid), }) } Self::PullDiff { mut card, export, base, head, } => { if let Card::Pull { diff: shown, .. } = &mut card { *shown = knot_git::Repo::open(&export) .ok() .and_then(|repo| diff(&repo, Some(base), head)); } Ok(card) } Self::Avatar { mut card, file } => { if let Card::User { avatar, .. } = &mut card { *avatar = file.and_then(|file| tile(&file).ok()); } Ok(card) } } }}
/// the pixels of the user card's avatar tileconst TILE: u32 = 150;
fn tile(file: &Path) -> anyhow::Result<Vec<u8>> { let image = image::ImageReader::open(file)? .with_guessed_format()? .decode()?; let image = image.resize_to_fill(TILE, TILE, image::imageops::FilterType::Triangle); Ok(image.to_rgb8().into_raw())}
fn diff(repo: &knot_git::Repo, base: Option<Oid>, head: Oid) -> Option<Diff> { let patches = repo .commit_patches( knot_git::PatchRange { base, head }, &mut knot_git::BinaryBudget::Omit, ) .ok()?; let (insertions, deletions, files) = crate::git_xrpc::diff_stat(&patches); Some(Diff { insertions: insertions.max(0) as u64, deletions: deletions.max(0) as u64, files: files.max(0) as u64, })}
/// a message's first line, and its next lines as onepub(crate) fn split_message(message: &str) -> (String, String) { let mut lines = message.trim().lines(); let subject = lines.next().unwrap_or_default().trim().to_string(); (subject, prose(&lines.collect::<Vec<_>>().join("\n")))}
/// markdown read as one line of words: no fences, quote marks, list bullets or emphasispub(crate) fn prose(text: &str) -> String { let mut fenced = false; let mut words = Vec::new(); for line in text.lines() { let line = line.trim(); if line.starts_with("```") { fenced = !fenced; continue; } if fenced { continue; } let line = line.trim_start_matches(['#', '>', '-', '*', '+', ' ']); words.extend( line.split_whitespace() .map(|word| word.trim_matches(['*', '_', '`'])), ); } words.retain(|word| !word.is_empty()); words.join(" ")}
/// the day in the author's own offsetpub(crate) fn date_of(seconds: i64, offset: i32) -> String { let offset = chrono::FixedOffset::east_opt(offset) .unwrap_or(chrono::FixedOffset::east_opt(0).expect("zero offset")); chrono::DateTime::from_timestamp(seconds, 0) .map(|at| at.with_timezone(&offset).format("%Y-%m-%d").to_string()) .unwrap_or_default()}
/// the date of an rfc3339 timestamp as writtenpub(crate) fn day(at: &str) -> String { chrono::DateTime::parse_from_rfc3339(at) .map(|at| at.format("%Y-%m-%d").to_string()) .unwrap_or_default()}
/// links each planned card into `build_dir`, drawing the ones no earlier generation drew, and/// drops the drawn cards nothing names any more. a card that can't be read or drawn is left/// out, so its page shows the repo's cardpub(crate) fn emit( output_root: &Path, build_dir: &Path, planned: Vec<Planned>,) -> anyhow::Result<()> { let cards = output_root.join(CARDS_DIR); std::fs::create_dir_all(&cards).with_context(|| format!("create {}", cards.display()))?; let www = build_dir.join("www").join(WWW_DIR); let theme = theme(); let started = std::time::Instant::now(); let planned_count = planned.len(); let drawn = std::sync::atomic::AtomicUsize::new(0); let kept: Vec<Option<String>> = planned .into_par_iter() .map(|plan| { let file = cards.join(format!("{}.png", plan.key)); if !file.is_file() { drawn.fetch_add(1, std::sync::atomic::Ordering::Relaxed); match plan .card .resolve() .and_then(|card| draw::draw(&card, &theme)) { Ok(png) => atomic_write(&file, &png) .with_context(|| format!("write {}", file.display()))?, Err(error) => { tracing::warn!(path = %plan.path, "no og card: {error:#}"); return Ok(None); } } } let target = www.join(&plan.path); if let Some(parent) = target.parent() { std::fs::create_dir_all(parent)?; } link_or_reflink(&file, &target) .with_context(|| format!("link {}", target.display()))?; Ok(Some(plan.key)) }) .collect::<anyhow::Result<_>>()?; let kept: BTreeSet<String> = kept .into_iter() .flatten() .map(|key| format!("{key}.png")) .collect(); tracing::info!( cards = planned_count, drawn = drawn.into_inner(), seconds = started.elapsed().as_secs_f64(), "og cards" ); for entry in std::fs::read_dir(&cards)? { let entry = entry?; if !kept.contains(entry.file_name().to_string_lossy().as_ref()) { std::fs::remove_file(entry.path()) .with_context(|| format!("remove {}", entry.path().display()))?; } } Ok(())}