diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abc01553117..d5b2314d7a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,8 @@ env: PNPM_VERSION: 10.12.3 # renovate: datasource=docker depName=postgres POSTGRES_VERSION: 16 + # renovate: datasource=github-releases depName=typst/typst versioning=semver + TYPST_VERSION: 0.13.1 # renovate: datasource=pypi depName=zizmor ZIZMOR_VERSION: 1.10.0 @@ -167,6 +169,19 @@ jobs: # Remove the Android SDK to free up space - run: sudo rm -rf /usr/local/lib/android + - name: Install Typst + run: | + wget -q "https://github.com/typst/typst/releases/download/v${TYPST_VERSION}/typst-x86_64-unknown-linux-musl.tar.xz" + tar -xf "typst-x86_64-unknown-linux-musl.tar.xz" + sudo mv "typst-x86_64-unknown-linux-musl/typst" /usr/local/bin/ + rm -rf "typst-x86_64-unknown-linux-musl" "typst-x86_64-unknown-linux-musl.tar.xz" + typst --version + + - name: Download Fira Sans font + run: | + wget -q "https://github.com/mozilla/Fira/archive/4.202.zip" + unzip -q "4.202.zip" + - uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 with: save-if: ${{ github.ref == 'refs/heads/main' }} @@ -178,6 +193,10 @@ jobs: - run: cargo build --tests --workspace - run: cargo test --workspace + env: + # Set the path to the Fira Sans font for Typst. + # The path is relative to the `crates_io_og_image` crate root. + TYPST_FONT_PATH: ../../Fira-4.202/otf frontend-lint: name: Frontend / Lint diff --git a/Cargo.lock b/Cargo.lock index dc3f670549b..089fbf26aa2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1594,6 +1594,24 @@ dependencies = [ "url", ] +[[package]] +name = "crates_io_og_image" +version = "0.0.0" +dependencies = [ + "anyhow", + "bytes", + "crates_io_env_vars", + "insta", + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.12", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "crates_io_pagerduty" version = "0.0.0" diff --git a/crates/crates_io_og_image/Cargo.toml b/crates/crates_io_og_image/Cargo.toml new file mode 100644 index 00000000000..d116a9359ab --- /dev/null +++ b/crates/crates_io_og_image/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "crates_io_og_image" +version = "0.0.0" +edition = "2024" +license = "MIT OR Apache-2.0" +description = "OpenGraph image generation for crates.io" + +[lints] +workspace = true + +[dependencies] +anyhow = "=1.0.98" +bytes = "=1.10.1" +crates_io_env_vars = { path = "../crates_io_env_vars" } +reqwest = "=0.12.20" +serde = { version = "=1.0.219", features = ["derive"] } +serde_json = "=1.0.140" +tempfile = "=3.20.0" +thiserror = "=2.0.12" +tokio = { version = "=1.45.1", features = ["process", "fs"] } +tracing = "=0.1.41" + +[dev-dependencies] +insta = "=1.43.1" +tokio = { version = "=1.45.1", features = ["macros", "rt-multi-thread"] } +tracing-subscriber = { version = "=0.3.19", features = ["env-filter", "fmt"] } diff --git a/crates/crates_io_og_image/README.md b/crates/crates_io_og_image/README.md new file mode 100644 index 00000000000..7eaf577afec --- /dev/null +++ b/crates/crates_io_og_image/README.md @@ -0,0 +1,94 @@ +# crates_io_og_image + +A Rust crate for generating Open Graph images for crates.io packages. + +![Example OG Image](src/snapshots/crates_io_og_image__tests__generated_og_image.snap.png) + +## Overview + +`crates_io_og_image` is a specialized library for generating visually appealing Open Graph images for Rust crates. These images are designed to be displayed when crates.io links are shared on social media platforms, providing rich visual context about the crate including its name, description, authors, and key metrics. + +The generated images include: + +- Crate name and description +- Tags/keywords +- Author information with avatars (when available) +- Key metrics (releases, latest version, license, lines of code, size) +- Consistent crates.io branding + +## Requirements + +- The [Typst](https://typst.app/) CLI must be installed and available in your `PATH` + +## Usage + +### Basic Example + +```rust +use crates_io_og_image::{OgImageData, OgImageGenerator, OgImageAuthorData, OgImageError}; + +#[tokio::main] +async fn main() -> Result<(), OgImageError> { + // Create a generator instance + let generator = OgImageGenerator::default(); + + // Define the crate data + let data = OgImageData { + name: "example-crate", + version: "v1.2.3", + description: "An example crate for testing OpenGraph image generation", + license: "MIT/Apache-2.0", + tags: &["example", "testing", "og-image"], + authors: &[ + OgImageAuthorData::with_url( + "Turbo87", + "https://avatars.githubusercontent.com/u/141300", + ), + ], + lines_of_code: Some(2000), + crate_size: 75, + releases: 5, + }; + + // Generate the image + let temp_file = generator.generate(data).await?; + + // The temp_file contains the path to the generated PNG image + println!("Image generated at: {}", temp_file.path().display()); + + Ok(()) +} +``` + +## Configuration + +The path to the Typst CLI can be configured through the `TYPST_PATH` environment variables. + +## Development + +### Running Tests + +```bash +cargo test +``` + +Note that some tests require Typst to be installed and will be skipped if it's not available. + +### Example + +The crate includes an example that demonstrates how to generate an image: + +```bash +cargo run --example test_generator +``` + +This will generate a test image in the current directory. This will also test the avatar fetching functionality, which requires network access and isn't run as part of the automated tests. + +## License + +Licensed under either of: + +- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or ) +- MIT license ([LICENSE-MIT](LICENSE-MIT) or ) + +at your option. diff --git a/crates/crates_io_og_image/examples/test_generator.rs b/crates/crates_io_og_image/examples/test_generator.rs new file mode 100644 index 00000000000..bd2365d7eb6 --- /dev/null +++ b/crates/crates_io_og_image/examples/test_generator.rs @@ -0,0 +1,57 @@ +use crates_io_og_image::{OgImageAuthorData, OgImageData, OgImageGenerator}; +use tracing::level_filters::LevelFilter; +use tracing_subscriber::{EnvFilter, fmt}; + +fn init_tracing() { + let env_filter = EnvFilter::builder() + .with_default_directive(LevelFilter::DEBUG.into()) + .from_env_lossy(); + + fmt().compact().with_env_filter(env_filter).init(); +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + init_tracing(); + + println!("Testing OgImageGenerator..."); + + let generator = OgImageGenerator::from_environment()?; + println!("Created generator from environment"); + + // Test generating an image + let data = OgImageData { + name: "example-crate", + version: "v1.2.3", + description: "An example crate for testing OpenGraph image generation", + license: "MIT/Apache-2.0", + tags: &["example", "testing", "og-image"], + authors: &[ + OgImageAuthorData::new("example-user", None), + OgImageAuthorData::with_url( + "Turbo87", + "https://avatars.githubusercontent.com/u/141300", + ), + ], + lines_of_code: Some(2000), + crate_size: 75, + releases: 5, + }; + match generator.generate(data).await { + Ok(temp_file) => { + let output_path = "test_og_image.png"; + std::fs::copy(temp_file.path(), output_path)?; + println!("Successfully generated image at: {output_path}"); + println!( + "Image file size: {} bytes", + std::fs::metadata(output_path)?.len() + ); + } + Err(e) => { + println!("Failed to generate image: {e}"); + println!("Make sure typst is installed and available in PATH"); + } + } + + Ok(()) +} diff --git a/crates/crates_io_og_image/src/error.rs b/crates/crates_io_og_image/src/error.rs new file mode 100644 index 00000000000..0c8e15f2964 --- /dev/null +++ b/crates/crates_io_og_image/src/error.rs @@ -0,0 +1,56 @@ +//! Error types for the crates_io_og_image crate. + +use std::path::PathBuf; +use thiserror::Error; + +/// Errors that can occur when generating OpenGraph images. +#[derive(Debug, Error)] +pub enum OgImageError { + /// Failed to find or execute the Typst binary. + #[error("Failed to find or execute Typst binary: {0}")] + TypstNotFound(#[source] std::io::Error), + + /// Environment variable error. + #[error("Environment variable error: {0}")] + EnvVarError(anyhow::Error), + + /// Failed to download avatar from URL. + #[error("Failed to download avatar from URL '{url}': {source}")] + AvatarDownloadError { + url: String, + #[source] + source: reqwest::Error, + }, + + /// Failed to write avatar to file. + #[error("Failed to write avatar to file at {path:?}: {source}")] + AvatarWriteError { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + /// JSON serialization error. + #[error("JSON serialization error: {0}")] + JsonSerializationError(#[source] serde_json::Error), + + /// Typst compilation failed. + #[error("Typst compilation failed: {stderr}")] + TypstCompilationError { + stderr: String, + stdout: String, + exit_code: Option, + }, + + /// I/O error. + #[error("I/O error: {0}")] + IoError(#[from] std::io::Error), + + /// Temporary file creation error. + #[error("Failed to create temporary file: {0}")] + TempFileError(std::io::Error), + + /// Temporary directory creation error. + #[error("Failed to create temporary directory: {0}")] + TempDirError(std::io::Error), +} diff --git a/crates/crates_io_og_image/src/formatting.rs b/crates/crates_io_og_image/src/formatting.rs new file mode 100644 index 00000000000..662ab92e472 --- /dev/null +++ b/crates/crates_io_og_image/src/formatting.rs @@ -0,0 +1,177 @@ +//! Module for number formatting functions. +//! +//! This module contains utility functions for formatting numbers in various ways, +//! such as human-readable byte sizes. + +use serde::Serializer; + +/// Formats a byte size value into a human-readable string. +/// +/// The function follows these rules: +/// - Uses units: B, kB and MB +/// - Switches from B to kB at 1500 bytes +/// - Switches from kB to MB at 1500 * 1024 bytes +/// - Limits the number to a maximum of 4 characters by adjusting decimal places +/// +/// # Arguments +/// +/// * `bytes` - The size in bytes to format +/// +/// # Returns +/// +/// A formatted string representing the size with appropriate units +pub fn format_bytes(bytes: u32) -> String { + const THRESHOLD: f64 = 1500.; + const UNITS: &[&str] = &["B", "kB", "MB"]; + + let mut value = bytes as f64; + let mut unit_index = 0; + + // Keep dividing by 1024 until value is below threshold or we've reached the last unit + while value >= THRESHOLD && unit_index < UNITS.len() - 1 { + value /= 1024.0; + unit_index += 1; + } + + let unit = UNITS[unit_index]; + + // Special case for bytes - no decimal places + if unit_index == 0 { + return format!("{bytes} {unit}"); + } + + // For kB and MB, format with appropriate decimal places + + // Determine number of decimal places to keep number under 4 chars + if value < 10.0 { + format!("{value:.2} {unit}") // e.g., 1.50 kB, 9.99 MB + } else if value < 100.0 { + format!("{value:.1} {unit}") // e.g., 10.5 kB, 99.9 MB + } else { + format!("{value:.0} {unit}") // e.g., 100 kB, 999 MB + } +} + +pub fn serialize_bytes(bytes: &u32, serializer: S) -> Result { + serializer.serialize_str(&format_bytes(*bytes)) +} + +/// Formats a number with "k" and "M" suffixes for thousands and millions. +/// +/// The function follows these rules: +/// - Uses suffixes: none, k, and M +/// - Switches from no suffix to k at 1500 +/// - Switches from k to M at 1500 * 1000 +/// - Limits the number to a maximum of 4 characters by adjusting decimal places +/// +/// # Arguments +/// +/// * `number` - The number to format +/// +/// # Returns +/// +/// A formatted string representing the number with appropriate suffixes +pub fn format_number(number: u32) -> String { + const THRESHOLD: f64 = 1500.; + const UNITS: &[&str] = &["", "k", "M"]; + + let mut value = number as f64; + let mut unit_index = 0; + + // Keep dividing by 1000 until value is below threshold or we've reached the last unit + while value >= THRESHOLD && unit_index < UNITS.len() - 1 { + value /= 1000.0; + unit_index += 1; + } + + let unit = UNITS[unit_index]; + + // Special case for numbers without suffix - no decimal places + if unit_index == 0 { + return format!("{number}"); + } + + // For k and M, format with appropriate decimal places + + // Determine number of decimal places to keep number under 4 chars + if value < 10.0 { + format!("{value:.1}{unit}") + } else { + format!("{value:.0}{unit}") + } +} + +pub fn serialize_number(number: &u32, serializer: S) -> Result { + serializer.serialize_str(&format_number(*number)) +} + +pub fn serialize_optional_number( + opt_number: &Option, + serializer: S, +) -> Result { + match opt_number { + Some(number) => serializer.serialize_str(&format_number(*number)), + None => serializer.serialize_none(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_bytes() { + // Test bytes format (below 1500 bytes) + assert_eq!(format_bytes(0), "0 B"); + assert_eq!(format_bytes(1), "1 B"); + assert_eq!(format_bytes(1000), "1000 B"); + assert_eq!(format_bytes(1499), "1499 B"); + + // Test kilobytes format (1500 bytes to 1500 * 1024 bytes) + assert_eq!(format_bytes(1500), "1.46 kB"); + assert_eq!(format_bytes(2048), "2.00 kB"); + assert_eq!(format_bytes(5120), "5.00 kB"); + assert_eq!(format_bytes(10240), "10.0 kB"); + assert_eq!(format_bytes(51200), "50.0 kB"); + assert_eq!(format_bytes(102400), "100 kB"); + assert_eq!(format_bytes(512000), "500 kB"); + assert_eq!(format_bytes(1048575), "1024 kB"); + + // Test megabytes format (above 1500 * 1024 bytes) + assert_eq!(format_bytes(1536000), "1.46 MB"); + assert_eq!(format_bytes(2097152), "2.00 MB"); + assert_eq!(format_bytes(5242880), "5.00 MB"); + assert_eq!(format_bytes(10485760), "10.0 MB"); + assert_eq!(format_bytes(52428800), "50.0 MB"); + assert_eq!(format_bytes(104857600), "100 MB"); + assert_eq!(format_bytes(1073741824), "1024 MB"); + } + + #[test] + fn test_format_number() { + // Test numbers without suffix (below 1500) + assert_eq!(format_number(0), "0"); + assert_eq!(format_number(1), "1"); + assert_eq!(format_number(1000), "1000"); + assert_eq!(format_number(1499), "1499"); + + // Test numbers with k suffix (1500 to 1500 * 1000) + assert_eq!(format_number(1500), "1.5k"); + assert_eq!(format_number(2000), "2.0k"); + assert_eq!(format_number(5000), "5.0k"); + assert_eq!(format_number(10000), "10k"); + assert_eq!(format_number(50000), "50k"); + assert_eq!(format_number(100000), "100k"); + assert_eq!(format_number(500000), "500k"); + assert_eq!(format_number(999999), "1000k"); + + // Test numbers with M suffix (above 1500 * 1000) + assert_eq!(format_number(1500000), "1.5M"); + assert_eq!(format_number(2000000), "2.0M"); + assert_eq!(format_number(5000000), "5.0M"); + assert_eq!(format_number(10000000), "10M"); + assert_eq!(format_number(50000000), "50M"); + assert_eq!(format_number(100000000), "100M"); + assert_eq!(format_number(1000000000), "1000M"); + } +} diff --git a/crates/crates_io_og_image/src/lib.rs b/crates/crates_io_og_image/src/lib.rs new file mode 100644 index 00000000000..7d7db5608dc --- /dev/null +++ b/crates/crates_io_og_image/src/lib.rs @@ -0,0 +1,613 @@ +#![doc = include_str!("../README.md")] + +mod error; +mod formatting; + +pub use error::OgImageError; + +use crate::formatting::{serialize_bytes, serialize_number, serialize_optional_number}; +use bytes::Bytes; +use crates_io_env_vars::var; +use serde::Serialize; +use std::collections::HashMap; +use std::path::PathBuf; +use tempfile::NamedTempFile; +use tokio::fs; +use tokio::process::Command; +use tracing::{debug, error, info, instrument, warn}; + +/// Data structure containing information needed to generate an OpenGraph image +/// for a crates.io crate. +#[derive(Debug, Clone, Serialize)] +pub struct OgImageData<'a> { + /// The crate name + pub name: &'a str, + /// Latest version string (e.g., "v1.0.210") + pub version: &'a str, + /// Crate description text + pub description: &'a str, + /// License information (e.g., "MIT/Apache-2.0") + pub license: &'a str, + /// Keywords/categories for the crate + pub tags: &'a [&'a str], + /// Author information + pub authors: &'a [OgImageAuthorData<'a>], + /// Source lines of code count (optional) + #[serde(serialize_with = "serialize_optional_number")] + pub lines_of_code: Option, + /// Package size in bytes + #[serde(serialize_with = "serialize_bytes")] + pub crate_size: u32, + /// Total number of releases + #[serde(serialize_with = "serialize_number")] + pub releases: u32, +} + +/// Author information for OpenGraph image generation +#[derive(Debug, Clone, Serialize)] +pub struct OgImageAuthorData<'a> { + /// Author username/name + pub name: &'a str, + /// Optional avatar - either "test-avatar" for the test avatar or a URL + pub avatar: Option<&'a str>, +} + +impl<'a> OgImageAuthorData<'a> { + /// Creates a new `OgImageAuthorData` with the specified name and optional avatar. + pub const fn new(name: &'a str, avatar: Option<&'a str>) -> Self { + Self { name, avatar } + } + + /// Creates a new `OgImageAuthorData` with a URL-based avatar. + pub fn with_url(name: &'a str, url: &'a str) -> Self { + Self::new(name, Some(url)) + } + + /// Creates a new `OgImageAuthorData` with the test avatar. + pub fn with_test_avatar(name: &'a str) -> Self { + Self::with_url(name, "test-avatar") + } +} + +/// Generator for creating OpenGraph images using the Typst typesetting system. +/// +/// This struct manages the path to the Typst binary and provides methods for +/// generating PNG images from a Typst template. +pub struct OgImageGenerator { + typst_binary_path: PathBuf, + typst_font_path: Option, +} + +impl OgImageGenerator { + /// Creates a new `OgImageGenerator` with the specified path to the Typst binary. + /// + /// # Examples + /// + /// ``` + /// use std::path::PathBuf; + /// use crates_io_og_image::OgImageGenerator; + /// + /// let generator = OgImageGenerator::new(PathBuf::from("/usr/local/bin/typst")); + /// ``` + pub fn new(typst_binary_path: PathBuf) -> Self { + Self { + typst_binary_path, + typst_font_path: None, + } + } + + /// Creates a new `OgImageGenerator` using the `TYPST_PATH` environment variable. + /// + /// If the `TYPST_PATH` environment variable is set, uses that path. + /// Otherwise, falls back to the default behavior (assumes "typst" is in PATH). + /// + /// # Examples + /// + /// ``` + /// use crates_io_og_image::OgImageGenerator; + /// + /// let generator = OgImageGenerator::from_environment()?; + /// # Ok::<(), crates_io_og_image::OgImageError>(()) + /// ``` + #[instrument] + pub fn from_environment() -> Result { + let typst_path = var("TYPST_PATH").map_err(OgImageError::EnvVarError)?; + let font_path = var("TYPST_FONT_PATH").map_err(OgImageError::EnvVarError)?; + + let mut generator = if let Some(ref path) = typst_path { + debug!(typst_path = %path, "Using custom Typst binary path from environment"); + Self::new(PathBuf::from(path)) + } else { + debug!("Using default Typst binary path (assumes 'typst' in PATH)"); + Self::default() + }; + + if let Some(ref font_path) = font_path { + debug!(font_path = %font_path, "Setting custom font path from environment"); + let current_dir = std::env::current_dir()?; + let font_path = current_dir.join(font_path).canonicalize()?; + debug!(resolved_font_path = %font_path.display(), "Resolved font path"); + generator = generator.with_font_path(font_path); + } else { + debug!("No custom font path specified, using Typst default font discovery"); + } + + Ok(generator) + } + + /// Sets the font path for the Typst compiler. + /// + /// This allows specifying a custom directory where Typst will look for fonts + /// during compilation. Setting a custom font directory implies using the + /// `--ignore-system-fonts` flag of the Typst CLI. If not set, Typst will + /// use its default font discovery. + /// + /// # Examples + /// + /// ``` + /// use std::path::PathBuf; + /// use crates_io_og_image::OgImageGenerator; + /// + /// let generator = OgImageGenerator::default() + /// .with_font_path(PathBuf::from("/usr/share/fonts")); + /// ``` + pub fn with_font_path(mut self, font_path: PathBuf) -> Self { + self.typst_font_path = Some(font_path); + self + } + + /// Processes avatars by downloading URLs and copying assets to the assets directory. + /// + /// This method handles both asset-based avatars (which are copied from the bundled assets) + /// and URL-based avatars (which are downloaded from the internet). + /// Returns a mapping from avatar source to the local filename. + #[instrument(skip(self, data), fields(crate.name = %data.name))] + async fn process_avatars<'a>( + &self, + data: &'a OgImageData<'_>, + assets_dir: &std::path::Path, + ) -> Result, OgImageError> { + let mut avatar_map = HashMap::new(); + + let client = reqwest::Client::new(); + for (index, author) in data.authors.iter().enumerate() { + if let Some(avatar) = &author.avatar { + let filename = format!("avatar_{index}.png"); + let avatar_path = assets_dir.join(&filename); + + debug!( + author_name = %author.name, + avatar_url = %avatar, + avatar_path = %avatar_path.display(), + "Processing avatar for author {}", author.name + ); + + // Get the bytes either from the included asset or download from URL + let bytes = if *avatar == "test-avatar" { + debug!("Using bundled test avatar"); + // Copy directly from included bytes + Bytes::from_static(include_bytes!("../template/assets/test-avatar.png")) + } else { + debug!(url = %avatar, "Downloading avatar from URL: {avatar}"); + // Download the avatar from the URL + let response = client + .get(*avatar) + .send() + .await + .map_err(|err| OgImageError::AvatarDownloadError { + url: avatar.to_string(), + source: err, + })? + .error_for_status() + .map_err(|err| OgImageError::AvatarDownloadError { + url: avatar.to_string(), + source: err, + })?; + + let content_length = response.content_length(); + debug!( + url = %avatar, + content_length = ?content_length, + status = %response.status(), + "Avatar download response received" + ); + + let bytes = response.bytes().await; + let bytes = bytes.map_err(|err| { + error!(url = %avatar, error = %err, "Failed to read avatar response bytes"); + OgImageError::AvatarDownloadError { + url: (*avatar).to_string(), + source: err, + } + })?; + + debug!(url = %avatar, size_bytes = bytes.len(), "Avatar downloaded successfully"); + bytes + }; + + // Write the bytes to the avatar file + fs::write(&avatar_path, &bytes).await.map_err(|err| { + OgImageError::AvatarWriteError { + path: avatar_path.clone(), + source: err, + } + })?; + + debug!( + author_name = %author.name, + path = %avatar_path.display(), + size_bytes = bytes.len(), + "Avatar processed and written successfully" + ); + + // Store the mapping from the avatar source to the numbered filename + avatar_map.insert(*avatar, filename); + } + } + + Ok(avatar_map) + } + + /// Generates an OpenGraph image using the provided data. + /// + /// This method creates a temporary directory with all the necessary files + /// to create the OpenGraph image, compiles it to PNG using the Typst + /// binary, and returns the resulting image as a `NamedTempFile`. + /// + /// # Examples + /// + /// ```no_run + /// use crates_io_og_image::{OgImageGenerator, OgImageData, OgImageAuthorData, OgImageError}; + /// + /// # #[tokio::main] + /// # async fn main() -> Result<(), OgImageError> { + /// let generator = OgImageGenerator::default(); + /// let data = OgImageData { + /// name: "my-crate", + /// version: "v1.0.0", + /// description: "A sample crate", + /// license: "MIT", + /// tags: &["web", "api"], + /// authors: &[OgImageAuthorData { name: "user", avatar: None }], + /// lines_of_code: Some(5000), + /// crate_size: 100, + /// releases: 10, + /// }; + /// let image_file = generator.generate(data).await?; + /// println!("Generated image at: {:?}", image_file.path()); + /// # Ok(()) + /// # } + /// ``` + #[instrument(skip(self, data), fields( + crate.name = %data.name, + crate.version = %data.version, + author_count = data.authors.len(), + ))] + pub async fn generate(&self, data: OgImageData<'_>) -> Result { + let start_time = std::time::Instant::now(); + info!("Starting OpenGraph image generation"); + + // Create a temporary folder + let temp_dir = tempfile::tempdir().map_err(OgImageError::TempDirError)?; + debug!(temp_dir = %temp_dir.path().display(), "Created temporary directory"); + + // Create assets directory and copy logo and icons + let assets_dir = temp_dir.path().join("assets"); + debug!(assets_dir = %assets_dir.display(), "Creating assets directory"); + fs::create_dir(&assets_dir).await?; + + debug!("Copying bundled assets to temporary directory"); + let cargo_logo = include_bytes!("../template/assets/cargo.png"); + fs::write(assets_dir.join("cargo.png"), cargo_logo).await?; + let rust_logo_svg = include_bytes!("../template/assets/rust-logo.svg"); + fs::write(assets_dir.join("rust-logo.svg"), rust_logo_svg).await?; + + // Copy SVG icons + debug!("Copying SVG icon assets"); + let code_branch_svg = include_bytes!("../template/assets/code-branch.svg"); + fs::write(assets_dir.join("code-branch.svg"), code_branch_svg).await?; + let code_svg = include_bytes!("../template/assets/code.svg"); + fs::write(assets_dir.join("code.svg"), code_svg).await?; + let scale_balanced_svg = include_bytes!("../template/assets/scale-balanced.svg"); + fs::write(assets_dir.join("scale-balanced.svg"), scale_balanced_svg).await?; + let tag_svg = include_bytes!("../template/assets/tag.svg"); + fs::write(assets_dir.join("tag.svg"), tag_svg).await?; + let weight_hanging_svg = include_bytes!("../template/assets/weight-hanging.svg"); + fs::write(assets_dir.join("weight-hanging.svg"), weight_hanging_svg).await?; + + // Process avatars - download URLs and copy assets + let avatar_start_time = std::time::Instant::now(); + info!("Processing avatars"); + let avatar_map = self.process_avatars(&data, &assets_dir).await?; + let avatar_duration = avatar_start_time.elapsed(); + info!( + avatar_count = avatar_map.len(), + duration_ms = avatar_duration.as_millis(), + "Avatar processing completed" + ); + + // Copy the static Typst template file + let template_content = include_str!("../template/og-image.typ"); + let typ_file_path = temp_dir.path().join("og-image.typ"); + debug!(template_path = %typ_file_path.display(), "Copying Typst template"); + fs::write(&typ_file_path, template_content).await?; + + // Create a named temp file for the output PNG + let output_file = NamedTempFile::new().map_err(OgImageError::TempFileError)?; + debug!(output_path = %output_file.path().display(), "Created output file"); + + // Serialize data and avatar_map to JSON + debug!("Serializing data and avatar map to JSON"); + let json_data = + serde_json::to_string(&data).map_err(OgImageError::JsonSerializationError)?; + + let json_avatar_map = + serde_json::to_string(&avatar_map).map_err(OgImageError::JsonSerializationError)?; + + // Run typst compile command with input data + info!("Running Typst compilation command"); + let mut command = Command::new(&self.typst_binary_path); + command.arg("compile").arg("--format").arg("png"); + + // Pass in the data and avatar map as JSON inputs + let input = format!("data={json_data}"); + command.arg("--input").arg(input); + let input = format!("avatar_map={json_avatar_map}"); + command.arg("--input").arg(input); + + // Pass in the font path if specified + if let Some(font_path) = &self.typst_font_path { + debug!(font_path = %font_path.display(), "Using custom font path"); + command.arg("--font-path").arg(font_path); + command.arg("--ignore-system-fonts"); + } else { + debug!("Using system font discovery"); + } + + // Pass input and output file paths + command.arg(&typ_file_path).arg(output_file.path()); + + // Clear environment variables to avoid leaking sensitive data + command.env_clear(); + + // Preserve environment variables needed for font discovery + if let Ok(path) = std::env::var("PATH") { + command.env("PATH", path); + } + if let Ok(home) = std::env::var("HOME") { + command.env("HOME", home); + } + + let compilation_start_time = std::time::Instant::now(); + let output = command.output().await; + let output = output.map_err(OgImageError::TypstNotFound)?; + let compilation_duration = compilation_start_time.elapsed(); + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + error!( + exit_code = ?output.status.code(), + stderr = %stderr, + stdout = %stdout, + duration_ms = compilation_duration.as_millis(), + "Typst compilation failed" + ); + return Err(OgImageError::TypstCompilationError { + stderr, + stdout, + exit_code: output.status.code(), + }); + } + + let output_size_bytes = fs::metadata(output_file.path()).await; + let output_size_bytes = output_size_bytes.map(|m| m.len()).unwrap_or(0); + + debug!( + duration_ms = compilation_duration.as_millis(), + output_size_bytes, "Typst compilation completed successfully" + ); + + let duration = start_time.elapsed(); + info!( + duration_ms = duration.as_millis(), + output_size_bytes, "OpenGraph image generation completed successfully" + ); + Ok(output_file) + } +} + +impl Default for OgImageGenerator { + /// Creates a default `OgImageGenerator` that assumes the Typst binary is available + /// as "typst" in the system PATH. + fn default() -> Self { + Self::new(PathBuf::from("typst")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tracing::dispatcher::DefaultGuard; + use tracing::{Level, subscriber}; + use tracing_subscriber::fmt; + + fn init_tracing() -> DefaultGuard { + let subscriber = fmt() + .compact() + .with_max_level(Level::DEBUG) + .with_test_writer() + .finish(); + + subscriber::set_default(subscriber) + } + + const fn author(name: &str) -> OgImageAuthorData<'_> { + OgImageAuthorData::new(name, None) + } + + const fn author_with_avatar(name: &str) -> OgImageAuthorData<'_> { + OgImageAuthorData::new(name, Some("test-avatar")) + } + + fn create_minimal_test_data() -> OgImageData<'static> { + static AUTHORS: &[OgImageAuthorData<'_>] = &[author("author")]; + + OgImageData { + name: "minimal-crate", + version: "v1.0.0", + description: "A minimal crate", + license: "MIT", + tags: &[], + authors: AUTHORS, + lines_of_code: None, + crate_size: 10000, + releases: 1, + } + } + + fn create_escaping_test_data() -> OgImageData<'static> { + static AUTHORS: &[OgImageAuthorData<'_>] = &[ + author_with_avatar("author \"with quotes\""), + author("author\\with\\backslashes"), + author("author#with#hashes"), + ]; + + OgImageData { + name: "crate-with-\"quotes\"", + version: "v1.0.0-\"beta\"", + description: "A crate with \"quotes\", \\ backslashes, and other special chars: #[]{}()", + license: "MIT OR \"Apache-2.0\"", + tags: &[ + "tag-with-\"quotes\"", + "tag\\with\\backslashes", + "tag#with#symbols", + ], + authors: AUTHORS, + lines_of_code: Some(42), + crate_size: 256256, + releases: 5, + } + } + + fn create_overflow_test_data() -> OgImageData<'static> { + static AUTHORS: &[OgImageAuthorData<'_>] = &[ + author_with_avatar("alice-wonderland"), + author("bob-the-builder"), + author_with_avatar("charlie-brown"), + author("diana-prince"), + author_with_avatar("edward-scissorhands"), + author("fiona-apple"), + author("george-washington"), + author_with_avatar("helen-keller"), + author("isaac-newton"), + author("jane-doe"), + ]; + + OgImageData { + name: "super-long-crate-name-for-testing-overflow-behavior", + version: "v2.1.0-beta.1+build.12345", + description: "This is an extremely long description that tests how the layout handles descriptions that might wrap to multiple lines or overflow the available space in the OpenGraph image template design. This is an extremely long description that tests how the layout handles descriptions that might wrap to multiple lines or overflow the available space in the OpenGraph image template design.", + license: "MIT/Apache-2.0/ISC/BSD-3-Clause", + tags: &[ + "web-framework", + "async-runtime", + "database-orm", + "serialization", + "networking", + ], + authors: AUTHORS, + lines_of_code: Some(147000), + crate_size: 2847123, + releases: 1432, + } + } + + fn create_simple_test_data() -> OgImageData<'static> { + static AUTHORS: &[OgImageAuthorData<'_>] = &[author("test-user")]; + + OgImageData { + name: "test-crate", + version: "v1.0.0", + description: "A test crate for OpenGraph image generation", + license: "MIT/Apache-2.0", + tags: &["testing", "og-image"], + authors: AUTHORS, + lines_of_code: Some(1000), + crate_size: 42012, + releases: 1, + } + } + + fn skip_if_typst_unavailable() -> bool { + if matches!(var("CI"), Ok(Some(_))) { + // Do not skip tests in CI environments, even if Typst is unavailable. + // We want the test to fail instead of silently skipping. + return false; + } + + std::process::Command::new("typst") + .arg("--version") + .output() + .inspect_err(|_| { + eprintln!("Skipping test: typst binary not found in PATH"); + }) + .is_err() + } + + async fn generate_image(data: OgImageData<'_>) -> Option> { + if skip_if_typst_unavailable() { + return None; + } + + let generator = + OgImageGenerator::from_environment().expect("Failed to create OgImageGenerator"); + + let temp_file = generator + .generate(data) + .await + .expect("Failed to generate image"); + + Some(std::fs::read(temp_file.path()).expect("Failed to read generated image")) + } + + #[tokio::test] + async fn test_generate_og_image_snapshot() { + let _guard = init_tracing(); + let data = create_simple_test_data(); + + if let Some(image_data) = generate_image(data).await { + insta::assert_binary_snapshot!("generated_og_image.png", image_data); + } + } + + #[tokio::test] + async fn test_generate_og_image_overflow_snapshot() { + let _guard = init_tracing(); + let data = create_overflow_test_data(); + + if let Some(image_data) = generate_image(data).await { + insta::assert_binary_snapshot!("generated_og_image_overflow.png", image_data); + } + } + + #[tokio::test] + async fn test_generate_og_image_minimal_snapshot() { + let _guard = init_tracing(); + let data = create_minimal_test_data(); + + if let Some(image_data) = generate_image(data).await { + insta::assert_binary_snapshot!("generated_og_image_minimal.png", image_data); + } + } + + #[tokio::test] + async fn test_generate_og_image_escaping_snapshot() { + let _guard = init_tracing(); + let data = create_escaping_test_data(); + + if let Some(image_data) = generate_image(data).await { + insta::assert_binary_snapshot!("generated_og_image_escaping.png", image_data); + } + } +} diff --git a/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image.snap b/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image.snap new file mode 100644 index 00000000000..3ec714b7080 --- /dev/null +++ b/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image.snap @@ -0,0 +1,6 @@ +--- +source: crates/crates_io_og_image/src/lib.rs +expression: image_data +extension: png +snapshot_kind: binary +--- diff --git a/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image.snap.png b/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image.snap.png new file mode 100644 index 00000000000..0f5cf71f116 Binary files /dev/null and b/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image.snap.png differ diff --git a/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image_escaping.snap b/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image_escaping.snap new file mode 100644 index 00000000000..3ec714b7080 --- /dev/null +++ b/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image_escaping.snap @@ -0,0 +1,6 @@ +--- +source: crates/crates_io_og_image/src/lib.rs +expression: image_data +extension: png +snapshot_kind: binary +--- diff --git a/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image_escaping.snap.png b/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image_escaping.snap.png new file mode 100644 index 00000000000..36d0d55b9fb Binary files /dev/null and b/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image_escaping.snap.png differ diff --git a/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image_minimal.snap b/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image_minimal.snap new file mode 100644 index 00000000000..3ec714b7080 --- /dev/null +++ b/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image_minimal.snap @@ -0,0 +1,6 @@ +--- +source: crates/crates_io_og_image/src/lib.rs +expression: image_data +extension: png +snapshot_kind: binary +--- diff --git a/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image_minimal.snap.png b/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image_minimal.snap.png new file mode 100644 index 00000000000..a85cd57ac18 Binary files /dev/null and b/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image_minimal.snap.png differ diff --git a/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image_overflow.snap b/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image_overflow.snap new file mode 100644 index 00000000000..3ec714b7080 --- /dev/null +++ b/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image_overflow.snap @@ -0,0 +1,6 @@ +--- +source: crates/crates_io_og_image/src/lib.rs +expression: image_data +extension: png +snapshot_kind: binary +--- diff --git a/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image_overflow.snap.png b/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image_overflow.snap.png new file mode 100644 index 00000000000..3ba99a11fa4 Binary files /dev/null and b/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image_overflow.snap.png differ diff --git a/crates/crates_io_og_image/template/assets/cargo.png b/crates/crates_io_og_image/template/assets/cargo.png new file mode 100644 index 00000000000..eaa250e634c Binary files /dev/null and b/crates/crates_io_og_image/template/assets/cargo.png differ diff --git a/crates/crates_io_og_image/template/assets/code-branch.svg b/crates/crates_io_og_image/template/assets/code-branch.svg new file mode 100644 index 00000000000..706d1da23d7 --- /dev/null +++ b/crates/crates_io_og_image/template/assets/code-branch.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/crates_io_og_image/template/assets/code.svg b/crates/crates_io_og_image/template/assets/code.svg new file mode 100644 index 00000000000..dd6c997e3a2 --- /dev/null +++ b/crates/crates_io_og_image/template/assets/code.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/crates_io_og_image/template/assets/rust-logo.svg b/crates/crates_io_og_image/template/assets/rust-logo.svg new file mode 100644 index 00000000000..2981e5e5d19 --- /dev/null +++ b/crates/crates_io_og_image/template/assets/rust-logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/crates_io_og_image/template/assets/scale-balanced.svg b/crates/crates_io_og_image/template/assets/scale-balanced.svg new file mode 100644 index 00000000000..2820331073a --- /dev/null +++ b/crates/crates_io_og_image/template/assets/scale-balanced.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/crates_io_og_image/template/assets/tag.svg b/crates/crates_io_og_image/template/assets/tag.svg new file mode 100644 index 00000000000..cae05beda3c --- /dev/null +++ b/crates/crates_io_og_image/template/assets/tag.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/crates_io_og_image/template/assets/test-avatar.png b/crates/crates_io_og_image/template/assets/test-avatar.png new file mode 100644 index 00000000000..d866bbe59b8 Binary files /dev/null and b/crates/crates_io_og_image/template/assets/test-avatar.png differ diff --git a/crates/crates_io_og_image/template/assets/weight-hanging.svg b/crates/crates_io_og_image/template/assets/weight-hanging.svg new file mode 100644 index 00000000000..6dd049b1733 --- /dev/null +++ b/crates/crates_io_og_image/template/assets/weight-hanging.svg @@ -0,0 +1 @@ + diff --git a/crates/crates_io_og_image/template/og-image.typ b/crates/crates_io_og_image/template/og-image.typ new file mode 100644 index 00000000000..4f833c136a8 --- /dev/null +++ b/crates/crates_io_og_image/template/og-image.typ @@ -0,0 +1,309 @@ +// ============================================================================= +// CRATES.IO OG-IMAGE TEMPLATE +// ============================================================================= +// This template generates Open Graph images for crates.io crate. + +// ============================================================================= +// COLOR PALETTE +// ============================================================================= + +#let colors = ( + bg: oklch(97%, 0.0147, 98deg), + header-bg: oklch(36%, 0.07, 144deg), + header-text: oklch(100%, 0, 0deg), + primary: oklch(36%, 0.07, 144deg), + text: oklch(51%, 0.05, 144deg), + text-light: oklch(60%, 0.05, 144deg), + avatar-bg: oklch(100%, 0, 0deg), + avatar-border: oklch(87%, 0.01, 98deg), + tag-bg: oklch(36%, 0.07, 144deg), + tag-text: oklch(100%, 0, 0deg), +) + +// ============================================================================= +// LAYOUT CONSTANTS +// ============================================================================= + +#let header-height = 60pt +#let footer-height = 4pt + +// ============================================================================= +// TEXT TRUNCATION UTILITIES +// ============================================================================= +// These functions handle text overflow by adding ellipsis when content +// exceeds specified dimensions + +// Truncates text to fit within a maximum height +// @param text: The text content to truncate +// @param maxHeight: Maximum height constraint (optional, defaults to single line height) +#let truncate_to_height(text, maxHeight: none) = { + layout(size => { + let text = text + + let maxHeight = if maxHeight != none { + maxHeight + } else { + measure(text).height + } + + if measure(width: size.width, text).height <= maxHeight { + return text + } else { + while measure(width: size.width, text + "…").height > maxHeight { + text = text.slice(0, text.len() - 1).trim() + } + return text + "…" + } + }) +} + +// Truncates text to fit within a maximum width +// @param text: The text content to truncate +// @param maxWidth: Maximum width constraint (optional, defaults to container width) +#let truncate_to_width(text, maxWidth: none) = { + layout(size => { + let text = text + + let maxWidth = if maxWidth != none { + maxWidth + } else { + size.width + } + + if measure(text).width <= maxWidth { + return text + } else { + while measure(text + "…").width > maxWidth { + text = text.slice(0, text.len() - 1).trim() + } + return text + "…" + } + }) +} + +// ============================================================================= +// AVATAR RENDERING +// ============================================================================= +// Functions for rendering circular avatar images + +// Renders a circular avatar image with border +// @param avatar-path: Path to the avatar image file +// @param size: Size of the avatar (default: 1em) +#let render-avatar(avatar-path, size: 1em) = { + box(clip: true, fill: colors.avatar-bg, stroke: 0.5pt + colors.avatar-border, + radius: 50%, inset: 1pt, + box(clip: true, radius: 50%, image(avatar-path, width: size)) + ) +} + +// ============================================================================= +// AUTHOR HANDLING +// ============================================================================= +// Complex logic for displaying multiple authors with proper grammar + +// Renders an author with optional avatar and name +// @param author: Object with 'name' and optional 'avatar' properties +#let render-author(author) = { + if author.avatar != none { + h(0.2em) + box(baseline: 30%, [#render-avatar(author.avatar, size: 1.5em)]) + h(0.2em) + } + author.name +} + +// Generates grammatically correct author list text +#let generate-authors-text(authors, maxVisible: none) = { + if authors.len() == 0 { + return "" + } + + let prefix = "by " + let visible = if maxVisible != none { + calc.min(maxVisible, authors.len()) + } else { + authors.len() + } + + if authors.len() == 1 { + return prefix + render-author(authors.at(0)) + } + + // Build the visible authors list + let authors-text = "" + for i in range(visible) { + if i == 0 { + authors-text += render-author(authors.at(i)) + } else if i == visible - 1 and visible == authors.len() { + // Last author and we're showing all authors + authors-text += " and " + render-author(authors.at(i)) + } else { + // Not the last author, or we're truncating + authors-text += ", " + render-author(authors.at(i)) + } + } + + // Add "and X others" suffix if truncated + if visible < authors.len() { + let remaining = authors.len() - visible + let suffix = " and " + str(remaining) + " other" + if remaining > 1 { + suffix += "s" + } + authors-text += suffix + } + + return prefix + authors-text +} + +// Renders authors list with intelligent truncation based on available width +#let render-authors-list(authors, maxWidth: none) = { + layout(size => { + let maxWidth = if maxWidth != none { + maxWidth + } else { + size.width + } + + if authors.len() == 0 { + return "" + } + + // Try showing all authors first + let full-text = generate-authors-text(authors) + if measure(full-text).width <= maxWidth { + return full-text + } + + // Reduce maxVisible until text fits + let maxVisible = authors.len() - 1 + while maxVisible >= 1 { + let truncated-text = generate-authors-text(authors, maxVisible: maxVisible) + if measure(truncated-text).width <= maxWidth { + return truncated-text + } + maxVisible -= 1 + } + + // Fallback: just show first author and "and X others" + return generate-authors-text(authors, maxVisible: 1) + }) +} + +// ============================================================================= +// VISUAL COMPONENTS +// ============================================================================= +// Reusable components for consistent styling + +#let render-header = { + rect(width: 100%, height: header-height, fill: colors.header-bg, { + place(left + horizon, dx: 30pt, { + box(baseline: 30%, image("assets/cargo.png", width: 35pt)) + h(10pt) + text(size: 22pt, fill: colors.header-text, weight: "semibold")[crates.io] + }) + }) +} + +// Renders a tag/keyword with consistent styling +#let render-tag(content) = { + set text(fill: colors.tag-text) + box(fill: colors.tag-bg, radius: .15em, inset: (x: .4em, y: .25em), + content + ) +} + +// Renders a metadata item with icon, title, and content +#let render-metadata(title, content, icon-name) = { + box(inset: (right: 20pt), + grid(columns: (auto, auto), rows: (auto, auto), column-gutter: .75em, row-gutter: .5em, + grid.cell(rowspan: 2, align: horizon, image("assets/" + icon-name + ".svg", height: 1.2em)), + text(size: 8pt, fill: colors.text-light, upper(title)), + text(size: 12pt, fill: colors.primary, content) + ) + ) +} + +// ============================================================================= +// DATA LOADING +// ============================================================================= +// Load data from sys.inputs + +#let data = json(bytes(sys.inputs.data)) +#let avatar_map = json(bytes(sys.inputs.at("avatar_map", default: "{}"))) + +// ============================================================================= +// MAIN DOCUMENT +// ============================================================================= + +#set page(width: 600pt, height: 315pt, margin: 0pt, fill: colors.bg) +#set text(font: "Fira Sans", fill: colors.text) + +// Header with crates.io branding +#render-header + +// Bottom border accent +#place(bottom, + rect(width: 100%, height: footer-height, fill: colors.header-bg) +) + +// Rust logo overlay (20% opacity watermark) +#place(bottom + right, dx: 200pt, dy: 100pt, + image("assets/rust-logo.svg", width: 300pt) +) + +// Main content area +#place( + left + top, + dy: 60pt, + block(height: 100% - header-height - footer-height, inset: 35pt, clip: true, { + // Crate name + block(text(size: 36pt, weight: "semibold", fill: colors.primary, truncate_to_width(data.name))) + + // Tags + if data.at("tags", default: ()).len() > 0 { + block( + for (i, tag) in data.tags.enumerate() { + if i > 0 { + h(3pt) + } + render-tag(text(size: 8pt, weight: "medium", "#" + tag)) + } + ) + } + + // Description + block(text(size: 14pt, weight: "regular", truncate_to_height(data.at("description", default: ""), maxHeight: 60pt))) + + // Authors + if data.at("authors", default: ()).len() > 0 { + set text(size: 10pt, fill: colors.text-light) + let authors-with-avatars = data.authors.map(author => { + let avatar = none + if author.avatar != none { + avatar = "assets/" + avatar_map.at(author.avatar) + } + (name: author.name, avatar: avatar) + }) + block(render-authors-list(authors-with-avatars)) + } + + place(bottom + left, float: true, + stack(dir: ltr, { + if data.at("releases", default: none) != none { + render-metadata("Releases", data.releases, "tag") + } + render-metadata("Latest", truncate_to_width(data.version, maxWidth: 80pt), "code-branch") + if data.at("license", default: none) != none { + render-metadata("License", truncate_to_width(data.license, maxWidth: 100pt), "scale-balanced") + } + if data.at("lines_of_code", default: none) != none { + render-metadata("SLoC", data.lines_of_code, "code") + } + if data.at("crate_size", default: none) != none { + render-metadata("Size", data.crate_size, "weight-hanging") + } + }) + ) + }) +)