kitgit

tirbofish/kitgit

main / src / og.rs · 13135 bytes

//! Open Graph / social card helpers derived from repository settings.

use crate::db::models::Repository;
use flate2::write::ZlibEncoder;
use flate2::Compression;
use std::io::Write;

pub const SITE_NAME: &str = "kitgit";
pub const SITE_TAGLINE: &str = "self-hosted git. templates, not a product page.";

const OG_W: u32 = 1200;
const OG_H: u32 = 630;

/// View-model fields for meta tags in templates.
#[derive(Debug, Clone)]
pub struct SocialMeta {
    pub title: String,
    pub description: String,
    pub image_url: String,
    pub url: String,
    pub site_name: String,
}

fn truncate_chars(s: &str, max: usize) -> String {
    if s.chars().count() <= max {
        return s.to_string();
    }
    let mut out = String::new();
    for (i, c) in s.chars().enumerate() {
        if i + 1 >= max {
            out.push('\u{2026}');
            break;
        }
        out.push(c);
    }
    out
}

fn fallback_description(repo: &Repository) -> String {
    let desc = repo.description.trim();
    if desc.is_empty() {
        format!("Git repository on {SITE_NAME}.")
    } else {
        truncate_chars(desc, 200)
    }
}

/// Page title / og:title / twitter:title - aim for ~50-60 SERP chars.
pub fn repo_social_title(owner: &str, repo: &Repository) -> String {
    let base = format!("{}/{}", owner, repo.name);
    let desc = repo.description.trim();
    if desc.is_empty() {
        format!("{base} - Git repository on {SITE_NAME}")
    } else {
        truncate_chars(&format!("{base} - {desc}"), 60)
    }
}

pub fn repo_social_description(repo: &Repository) -> String {
    fallback_description(repo)
}

pub fn absolute_url(public_url: &str, path: &str) -> String {
    let base = public_url.trim_end_matches('/');
    let path = if path.starts_with('/') {
        path.to_string()
    } else {
        format!("/{path}")
    };
    format!("{base}{path}")
}

pub fn repo_social_meta(public_url: &str, owner: &str, repo: &Repository) -> SocialMeta {
    let path = format!("/{}/{}", owner, repo.name);
    SocialMeta {
        title: repo_social_title(owner, repo),
        description: repo_social_description(repo),
        image_url: absolute_url(public_url, &format!("{path}/og.png")),
        url: absolute_url(public_url, &path),
        site_name: SITE_NAME.to_string(),
    }
}

pub fn site_social_meta(public_url: &str, path: &str, title: &str, description: &str) -> SocialMeta {
    SocialMeta {
        title: title.to_string(),
        description: description.to_string(),
        image_url: absolute_url(public_url, "/og.png"),
        url: absolute_url(public_url, path),
        site_name: SITE_NAME.to_string(),
    }
}

// PNG card (1200x630)

fn glyph(c: char) -> [u8; 7] {
    const FONT: [[u8; 7]; 95] = [
        [0, 0, 0, 0, 0, 0, 0],
        [0x04, 0x04, 0x04, 0x04, 0x00, 0x04, 0x00],
        [0x0A, 0x0A, 0x00, 0x00, 0x00, 0x00, 0x00],
        [0x0A, 0x1F, 0x0A, 0x1F, 0x0A, 0x00, 0x00],
        [0x04, 0x0F, 0x14, 0x0E, 0x05, 0x1E, 0x04],
        [0x18, 0x19, 0x02, 0x04, 0x08, 0x13, 0x03],
        [0x08, 0x14, 0x08, 0x15, 0x12, 0x0D, 0x00],
        [0x04, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00],
        [0x02, 0x04, 0x04, 0x04, 0x04, 0x02, 0x00],
        [0x08, 0x04, 0x04, 0x04, 0x04, 0x08, 0x00],
        [0x00, 0x0A, 0x04, 0x1F, 0x04, 0x0A, 0x00],
        [0x00, 0x04, 0x04, 0x1F, 0x04, 0x04, 0x00],
        [0x00, 0x00, 0x00, 0x00, 0x04, 0x04, 0x08],
        [0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00],
        [0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00],
        [0x01, 0x02, 0x04, 0x08, 0x10, 0x00, 0x00],
        [0x0E, 0x11, 0x13, 0x15, 0x19, 0x0E, 0x00],
        [0x04, 0x0C, 0x04, 0x04, 0x04, 0x0E, 0x00],
        [0x0E, 0x11, 0x01, 0x06, 0x08, 0x1F, 0x00],
        [0x1F, 0x02, 0x04, 0x02, 0x11, 0x0E, 0x00],
        [0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x00],
        [0x1F, 0x10, 0x1E, 0x01, 0x11, 0x0E, 0x00],
        [0x06, 0x08, 0x1E, 0x11, 0x11, 0x0E, 0x00],
        [0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x00],
        [0x0E, 0x11, 0x0E, 0x11, 0x11, 0x0E, 0x00],
        [0x0E, 0x11, 0x11, 0x0F, 0x01, 0x0C, 0x00],
        [0x00, 0x04, 0x00, 0x00, 0x04, 0x00, 0x00],
        [0x00, 0x04, 0x00, 0x00, 0x04, 0x04, 0x08],
        [0x02, 0x04, 0x08, 0x10, 0x08, 0x04, 0x02],
        [0x00, 0x00, 0x1F, 0x00, 0x1F, 0x00, 0x00],
        [0x08, 0x04, 0x02, 0x01, 0x02, 0x04, 0x08],
        [0x0E, 0x11, 0x01, 0x02, 0x00, 0x02, 0x00],
        [0x0E, 0x11, 0x17, 0x15, 0x17, 0x10, 0x0E],
        [0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x00],
        [0x1E, 0x11, 0x1E, 0x11, 0x11, 0x1E, 0x00],
        [0x0E, 0x11, 0x10, 0x10, 0x11, 0x0E, 0x00],
        [0x1E, 0x11, 0x11, 0x11, 0x11, 0x1E, 0x00],
        [0x1F, 0x10, 0x1E, 0x10, 0x10, 0x1F, 0x00],
        [0x1F, 0x10, 0x1E, 0x10, 0x10, 0x10, 0x00],
        [0x0E, 0x11, 0x10, 0x13, 0x11, 0x0F, 0x00],
        [0x11, 0x11, 0x1F, 0x11, 0x11, 0x11, 0x00],
        [0x0E, 0x04, 0x04, 0x04, 0x04, 0x0E, 0x00],
        [0x01, 0x01, 0x01, 0x01, 0x11, 0x0E, 0x00],
        [0x11, 0x12, 0x1C, 0x12, 0x11, 0x11, 0x00],
        [0x10, 0x10, 0x10, 0x10, 0x10, 0x1F, 0x00],
        [0x11, 0x1B, 0x15, 0x11, 0x11, 0x11, 0x00],
        [0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x00],
        [0x0E, 0x11, 0x11, 0x11, 0x11, 0x0E, 0x00],
        [0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x00],
        [0x0E, 0x11, 0x11, 0x15, 0x12, 0x0D, 0x00],
        [0x1E, 0x11, 0x11, 0x1E, 0x12, 0x11, 0x00],
        [0x0F, 0x10, 0x0E, 0x01, 0x01, 0x1E, 0x00],
        [0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x00],
        [0x11, 0x11, 0x11, 0x11, 0x11, 0x0E, 0x00],
        [0x11, 0x11, 0x11, 0x11, 0x0A, 0x04, 0x00],
        [0x11, 0x11, 0x11, 0x15, 0x1B, 0x11, 0x00],
        [0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x00],
        [0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x00],
        [0x1F, 0x01, 0x02, 0x04, 0x08, 0x1F, 0x00],
        [0x0E, 0x08, 0x08, 0x08, 0x08, 0x0E, 0x00],
        [0x10, 0x08, 0x04, 0x02, 0x01, 0x00, 0x00],
        [0x0E, 0x02, 0x02, 0x02, 0x02, 0x0E, 0x00],
        [0x04, 0x0A, 0x11, 0x00, 0x00, 0x00, 0x00],
        [0x00, 0x00, 0x00, 0x00, 0x00, 0x1F, 0x00],
        [0x08, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00],
        [0x00, 0x00, 0x0E, 0x01, 0x0F, 0x11, 0x0F],
        [0x10, 0x10, 0x1E, 0x11, 0x11, 0x1E, 0x00],
        [0x00, 0x00, 0x0E, 0x10, 0x10, 0x11, 0x0E],
        [0x01, 0x01, 0x0F, 0x11, 0x11, 0x0F, 0x00],
        [0x00, 0x00, 0x0E, 0x11, 0x1F, 0x10, 0x0E],
        [0x06, 0x08, 0x1C, 0x08, 0x08, 0x08, 0x00],
        [0x00, 0x0F, 0x11, 0x0F, 0x01, 0x0E, 0x00],
        [0x10, 0x10, 0x1E, 0x11, 0x11, 0x11, 0x00],
        [0x04, 0x00, 0x0C, 0x04, 0x04, 0x0E, 0x00],
        [0x02, 0x00, 0x06, 0x02, 0x02, 0x12, 0x0C],
        [0x10, 0x10, 0x12, 0x1C, 0x12, 0x11, 0x00],
        [0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E, 0x00],
        [0x00, 0x00, 0x1A, 0x15, 0x15, 0x11, 0x00],
        [0x00, 0x00, 0x1E, 0x11, 0x11, 0x11, 0x00],
        [0x00, 0x00, 0x0E, 0x11, 0x11, 0x11, 0x0E],
        [0x00, 0x00, 0x1E, 0x11, 0x1E, 0x10, 0x10],
        [0x00, 0x00, 0x0F, 0x11, 0x0F, 0x01, 0x01],
        [0x00, 0x00, 0x16, 0x19, 0x10, 0x10, 0x00],
        [0x00, 0x00, 0x0F, 0x10, 0x0E, 0x01, 0x1E],
        [0x08, 0x08, 0x1C, 0x08, 0x08, 0x06, 0x00],
        [0x00, 0x00, 0x11, 0x11, 0x11, 0x13, 0x0D],
        [0x00, 0x00, 0x11, 0x11, 0x11, 0x0A, 0x04],
        [0x00, 0x00, 0x11, 0x11, 0x15, 0x15, 0x0A],
        [0x00, 0x00, 0x11, 0x0A, 0x04, 0x0A, 0x11],
        [0x00, 0x00, 0x11, 0x11, 0x0F, 0x01, 0x0E],
        [0x00, 0x00, 0x1F, 0x02, 0x04, 0x08, 0x1F],
        [0x02, 0x04, 0x04, 0x08, 0x04, 0x04, 0x02],
        [0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x00],
        [0x08, 0x04, 0x04, 0x02, 0x04, 0x04, 0x08],
        [0x00, 0x00, 0x08, 0x15, 0x02, 0x00, 0x00],
    ];
    let idx = if c.is_ascii() && (0x20..=0x7E).contains(&(c as u8)) {
        (c as u8 - 0x20) as usize
    } else {
        (b'?' - 0x20) as usize
    };
    FONT[idx]
}

struct Canvas {
    w: u32,
    h: u32,
    px: Vec<u8>,
}

impl Canvas {
    fn new(w: u32, h: u32, fill: [u8; 3]) -> Self {
        let mut px = vec![0u8; (w * h * 3) as usize];
        for chunk in px.chunks_exact_mut(3) {
            chunk.copy_from_slice(&fill);
        }
        Self { w, h, px }
    }

    fn set(&mut self, x: i32, y: i32, color: [u8; 3]) {
        if x < 0 || y < 0 || x as u32 >= self.w || y as u32 >= self.h {
            return;
        }
        let i = ((y as u32 * self.w + x as u32) * 3) as usize;
        self.px[i..i + 3].copy_from_slice(&color);
    }

    fn fill_rect(&mut self, x: i32, y: i32, w: i32, h: i32, color: [u8; 3]) {
        for yy in y..y + h {
            for xx in x..x + w {
                self.set(xx, yy, color);
            }
        }
    }

    fn text(&mut self, x: i32, y: i32, s: &str, scale: i32, color: [u8; 3]) {
        let mut cx = x;
        for ch in s.chars() {
            let g = glyph(ch);
            for (row, bits) in g.iter().enumerate() {
                for col in 0..5 {
                    if bits & (1 << (4 - col)) != 0 {
                        self.fill_rect(
                            cx + col * scale,
                            y + row as i32 * scale,
                            scale,
                            scale,
                            color,
                        );
                    }
                }
            }
            cx += 6 * scale;
        }
    }

    fn text_clipped(&mut self, x: i32, y: i32, s: &str, scale: i32, color: [u8; 3], max_w: i32) {
        let char_w = 6 * scale;
        let max_chars = (max_w / char_w).max(1) as usize;
        let clipped = if s.chars().count() > max_chars {
            truncate_chars(s, max_chars.saturating_sub(1))
        } else {
            s.to_string()
        };
        self.text(x, y, &clipped, scale, color);
    }

    fn to_png(&self) -> Vec<u8> {
        let mut raw = Vec::with_capacity(((self.w * 3 + 1) * self.h) as usize);
        for y in 0..self.h {
            raw.push(0);
            let start = (y * self.w * 3) as usize;
            let end = start + (self.w * 3) as usize;
            raw.extend_from_slice(&self.px[start..end]);
        }
        let mut enc = ZlibEncoder::new(Vec::new(), Compression::fast());
        enc.write_all(&raw).expect("zlib write");
        let compressed = enc.finish().expect("zlib finish");

        let mut out = Vec::with_capacity(compressed.len() + 128);
        out.extend_from_slice(&[137, 80, 78, 71, 13, 10, 26, 10]);
        write_chunk(&mut out, b"IHDR", &{
            let mut d = Vec::with_capacity(13);
            d.extend_from_slice(&self.w.to_be_bytes());
            d.extend_from_slice(&self.h.to_be_bytes());
            d.extend_from_slice(&[8, 2, 0, 0, 0]);
            d
        });
        write_chunk(&mut out, b"IDAT", &compressed);
        write_chunk(&mut out, b"IEND", &[]);
        out
    }
}

fn write_chunk(out: &mut Vec<u8>, ty: &[u8; 4], data: &[u8]) {
    out.extend_from_slice(&(data.len() as u32).to_be_bytes());
    out.extend_from_slice(ty);
    out.extend_from_slice(data);
    let mut crc = 0xFFFF_FFFFu32;
    crc = crc32_update(crc, ty);
    crc = crc32_update(crc, data);
    out.extend_from_slice(&(!crc).to_be_bytes());
}

fn crc32_update(mut crc: u32, data: &[u8]) -> u32 {
    for &b in data {
        crc ^= u32::from(b);
        for _ in 0..8 {
            let mask = (!(crc & 1)).wrapping_add(1);
            crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
        }
    }
    crc
}

/// Render a social card from repository settings (name + description).
pub fn render_repo_card(owner: &str, repo: &Repository) -> Vec<u8> {
    let ink = [0x0a, 0x0a, 0x0a];
    let muted = [0x6b, 0x6b, 0x6b];
    let paper = [0xff, 0xff, 0xff];
    let line = [0xe5, 0xe5, 0xe5];

    let mut c = Canvas::new(OG_W, OG_H, paper);
    c.fill_rect(0, 0, OG_W as i32, 4, ink);
    c.fill_rect(0, OG_H as i32 - 56, OG_W as i32, 56, ink);

    c.text(64, 48, SITE_NAME, 4, muted);

    let full = format!("{}/{}", owner, repo.name);
    c.text_clipped(64, 160, &full, 8, ink, OG_W as i32 - 128);

    let desc = repo.description.trim();
    if !desc.is_empty() {
        c.text_clipped(64, 280, desc, 4, muted, OG_W as i32 - 128);
        let char_w = 6 * 4;
        let max_chars = ((OG_W as i32 - 128) / char_w).max(1) as usize;
        if desc.chars().count() > max_chars {
            let rest: String = desc.chars().skip(max_chars).collect();
            if !rest.is_empty() {
                c.text_clipped(64, 320, rest.trim_start(), 4, muted, OG_W as i32 - 128);
            }
        }
    } else {
        c.text(64, 280, "Git repository", 4, muted);
    }

    c.fill_rect(64, 400, 120, 2, line);
    c.text(64, OG_H as i32 - 40, SITE_TAGLINE, 3, [0xa3, 0xa3, 0xa3]);

    c.to_png()
}

/// Default site-wide OG image (no repo context).
pub fn render_site_card() -> Vec<u8> {
    let ink = [0x0a, 0x0a, 0x0a];
    let muted = [0x6b, 0x6b, 0x6b];
    let paper = [0xff, 0xff, 0xff];

    let mut c = Canvas::new(OG_W, OG_H, paper);
    c.fill_rect(0, 0, OG_W as i32, 4, ink);
    c.fill_rect(0, OG_H as i32 - 56, OG_W as i32, 56, ink);
    c.text(64, 200, SITE_NAME, 12, ink);
    c.text(64, 340, SITE_TAGLINE, 4, muted);
    c.to_png()
}