diff --git a/Cargo.lock b/Cargo.lock index 6848c788cd9..5218513501b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1610,12 +1610,10 @@ dependencies = [ [[package]] name = "crates_io_og_image" -version = "0.0.0" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0c70f3c2fa620cc0b1478c31c1ccfb7e5de2134c6d0377f4f2ffe23c7533813" dependencies = [ - "anyhow", - "crates_io_env_vars", - "insta", - "mockito", "reqwest", "serde", "serde_json", @@ -1623,7 +1621,6 @@ dependencies = [ "thiserror 2.0.12", "tokio", "tracing", - "tracing-subscriber", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 86ade68d932..333e4253c2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,7 +71,7 @@ crates_io_env_vars = { path = "crates/crates_io_env_vars" } crates_io_github = { path = "crates/crates_io_github" } crates_io_index = { path = "crates/crates_io_index" } crates_io_markdown = { path = "crates/crates_io_markdown" } -crates_io_og_image = { path = "crates/crates_io_og_image" } +crates_io_og_image = "=0.1.1" crates_io_pagerduty = { path = "crates/crates_io_pagerduty" } crates_io_session = { path = "crates/crates_io_session" } crates_io_tarball = { path = "crates/crates_io_tarball" } diff --git a/crates/crates_io_og_image/Cargo.toml b/crates/crates_io_og_image/Cargo.toml deleted file mode 100644 index 73cba1eabf5..00000000000 --- a/crates/crates_io_og_image/Cargo.toml +++ /dev/null @@ -1,26 +0,0 @@ -[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" -crates_io_env_vars = { path = "../crates_io_env_vars" } -reqwest = "=0.12.22" -serde = { version = "=1.0.219", features = ["derive"] } -serde_json = "=1.0.140" -tempfile = "=3.20.0" -thiserror = "=2.0.12" -tokio = { version = "=1.46.1", features = ["process", "fs"] } -tracing = "=0.1.41" - -[dev-dependencies] -insta = "=1.43.1" -mockito = "=1.7.0" -tokio = { version = "=1.46.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 deleted file mode 100644 index 3197e9f584e..00000000000 --- a/crates/crates_io_og_image/README.md +++ /dev/null @@ -1,94 +0,0 @@ -# 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: "1.2.3", - description: Some("An example crate for testing OpenGraph image generation"), - license: Some("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 deleted file mode 100644 index 729ecce87a9..00000000000 --- a/crates/crates_io_og_image/examples/test_generator.rs +++ /dev/null @@ -1,61 +0,0 @@ -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: "1.2.3", - description: Some("An example crate for testing OpenGraph image generation"), - license: Some("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", - ), - OgImageAuthorData::with_url( - "carols10cents", - "https://avatars.githubusercontent.com/u/193874", - ), - ], - 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(error) => { - println!("Failed to generate image: {error}"); - 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 deleted file mode 100644 index 0c8e15f2964..00000000000 --- a/crates/crates_io_og_image/src/error.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! 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 deleted file mode 100644 index daf0fe446ba..00000000000 --- a/crates/crates_io_og_image/src/formatting.rs +++ /dev/null @@ -1,177 +0,0 @@ -//! 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, KiB and MiB -/// - Switches from B to KiB at 1500 bytes -/// - Switches from KiB to MiB 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", "KiB", "MiB"]; - - 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 KiB and MiB, 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 KiB, 9.99 MiB - } else if value < 100.0 { - format!("{value:.1} {unit}") // e.g., 10.5 KiB, 99.9 MiB - } else { - format!("{value:.0} {unit}") // e.g., 100 KiB, 999 MiB - } -} - -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 KiB"); - assert_eq!(format_bytes(2048), "2.00 KiB"); - assert_eq!(format_bytes(5120), "5.00 KiB"); - assert_eq!(format_bytes(10240), "10.0 KiB"); - assert_eq!(format_bytes(51200), "50.0 KiB"); - assert_eq!(format_bytes(102400), "100 KiB"); - assert_eq!(format_bytes(512000), "500 KiB"); - assert_eq!(format_bytes(1048575), "1024 KiB"); - - // Test megabytes format (above 1500 * 1024 bytes) - assert_eq!(format_bytes(1536000), "1.46 MiB"); - assert_eq!(format_bytes(2097152), "2.00 MiB"); - assert_eq!(format_bytes(5242880), "5.00 MiB"); - assert_eq!(format_bytes(10485760), "10.0 MiB"); - assert_eq!(format_bytes(52428800), "50.0 MiB"); - assert_eq!(format_bytes(104857600), "100 MiB"); - assert_eq!(format_bytes(1073741824), "1024 MiB"); - } - - #[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 deleted file mode 100644 index f37e5cc134a..00000000000 --- a/crates/crates_io_og_image/src/lib.rs +++ /dev/null @@ -1,892 +0,0 @@ -#![doc = include_str!("../README.md")] - -mod error; -mod formatting; - -pub use error::OgImageError; - -use crate::formatting::{serialize_bytes, serialize_number, serialize_optional_number}; -use crates_io_env_vars::var; -use reqwest::StatusCode; -use serde::Serialize; -use std::borrow::Cow; -use std::collections::HashMap; -use std::path::{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., "1.0.210") - pub version: &'a str, - /// Crate description text - pub description: Option<&'a str>, - /// License information (e.g., "MIT/Apache-2.0") - pub license: Option<&'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 URL - pub avatar: Option>, -} - -impl<'a> OgImageAuthorData<'a> { - /// Creates a new `OgImageAuthorData` with the specified name and optional avatar. - pub const fn new(name: &'a str, avatar: Option>) -> Self { - Self { name, avatar } - } - - /// Creates a new `OgImageAuthorData` with a URL-based avatar. - pub fn with_url(name: &'a str, url: impl Into>) -> Self { - Self::new(name, Some(url.into())) - } -} - -/// 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, - oxipng_binary_path: PathBuf, -} - -impl OgImageGenerator { - /// Creates a new `OgImageGenerator` with default binary paths. - /// - /// Uses "typst" and "oxipng" as default binary paths, assuming they are - /// available in PATH. Use [`with_typst_path()`](Self::with_typst_path) and - /// [`with_oxipng_path()`](Self::with_oxipng_path) to customize the - /// binary paths. - /// - /// # Examples - /// - /// ``` - /// use crates_io_og_image::OgImageGenerator; - /// - /// let generator = OgImageGenerator::new(); - /// ``` - pub fn new() -> Self { - Self::default() - } - - /// Detects the image format from the first few bytes using magic numbers. - /// - /// Returns the appropriate file extension for supported formats: - /// - PNG: returns "png" - /// - JPEG: returns "jpg" - /// - Unsupported formats: returns None - fn detect_image_format(bytes: &[u8]) -> Option<&'static str> { - // PNG magic number: 89 50 4E 47 0D 0A 1A 0A - if bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) { - return Some("png"); - } - - // JPEG magic number: FF D8 FF - if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { - return Some("jpg"); - } - - 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 oxipng_path = var("OXIPNG_PATH").map_err(OgImageError::EnvVarError)?; - - let mut generator = OgImageGenerator::default(); - - if let Some(ref path) = typst_path { - debug!(typst_path = %path, "Using custom Typst binary path from environment"); - generator.typst_binary_path = PathBuf::from(path); - } else { - debug!("Using default Typst binary path (assumes 'typst' in PATH)"); - }; - - if let Some(ref font_path) = font_path { - debug!(font_path = %font_path, "Setting custom font path from environment"); - generator.typst_font_path = Some(PathBuf::from(font_path)); - } else { - debug!("No custom font path specified, using Typst default font discovery"); - } - - if let Some(ref path) = oxipng_path { - debug!(oxipng_path = %path, "Using custom oxipng binary path from environment"); - generator.oxipng_binary_path = PathBuf::from(path); - } else { - debug!("OXIPNG_PATH not set, defaulting to 'oxipng' in PATH"); - }; - - Ok(generator) - } - - /// Sets the Typst binary path for the generator. - /// - /// This allows specifying a custom path to the Typst binary. - /// If not set, defaults to "typst" which assumes the binary is available in PATH. - /// - /// # Examples - /// - /// ``` - /// use std::path::PathBuf; - /// use crates_io_og_image::OgImageGenerator; - /// - /// let generator = OgImageGenerator::default() - /// .with_typst_path(PathBuf::from("/usr/local/bin/typst")); - /// ``` - pub fn with_typst_path(mut self, typst_path: PathBuf) -> Self { - self.typst_binary_path = typst_path; - self - } - - /// 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 - } - - /// Sets the oxipng binary path for PNG optimization. - /// - /// This allows specifying a custom path to the oxipng binary for PNG optimization. - /// If not set, defaults to "oxipng" which assumes the binary is available in PATH. - /// - /// # Examples - /// - /// ``` - /// use std::path::PathBuf; - /// use crates_io_og_image::OgImageGenerator; - /// - /// let generator = OgImageGenerator::default() - /// .with_oxipng_path(PathBuf::from("/usr/local/bin/oxipng")); - /// ``` - pub fn with_oxipng_path(mut self, oxipng_path: PathBuf) -> Self { - self.oxipng_binary_path = oxipng_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(krate.name = %data.name))] - async fn process_avatars<'a>( - &self, - data: &'a OgImageData<'_>, - assets_dir: &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 { - debug!( - author_name = %author.name, - avatar_url = %avatar, - "Processing avatar for author {}", author.name - ); - - // Download the avatar from the URL - debug!(url = %avatar, "Downloading avatar from URL: {avatar}"); - let response = client.get(avatar.as_ref()).send().await.map_err(|err| { - OgImageError::AvatarDownloadError { - url: avatar.to_string(), - source: err, - } - })?; - - let status = response.status(); - if status == StatusCode::NOT_FOUND { - warn!(url = %avatar, "Avatar URL returned 404 Not Found"); - continue; // Skip this avatar if not found - } - - if let Err(err) = response.error_for_status_ref() { - return 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"); - - // Detect the image format and determine the appropriate file extension - let Some(extension) = Self::detect_image_format(&bytes) else { - // Format not supported, log warning with first 20 bytes for debugging - let debug_bytes = &bytes[..bytes.len().min(20)]; - let hex_bytes = debug_bytes - .iter() - .map(|b| format!("{b:02x}")) - .collect::>() - .join(" "); - - warn!("Unsupported avatar format at {avatar}, first 20 bytes: {hex_bytes}"); - - // Skip this avatar and continue with the next one - continue; - }; - - let filename = format!("avatar_{index}.{extension}"); - let avatar_path = assets_dir.join(&filename); - - debug!( - author_name = %author.name, - avatar_url = %avatar, - avatar_path = %avatar_path.display(), - "Writing avatar file with detected format" - ); - - // 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.as_ref(), 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: "1.0.0", - /// description: Some("A sample crate"), - /// license: Some("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" - ); - - // After successful Typst compilation, optimize the PNG - self.optimize_png(output_file.path()).await; - - let duration = start_time.elapsed(); - info!( - duration_ms = duration.as_millis(), - output_size_bytes, "OpenGraph image generation completed successfully" - ); - Ok(output_file) - } - - /// Optimizes a PNG file using oxipng. - /// - /// This method attempts to reduce the file size of a PNG using lossless compression. - /// All errors are handled internally and logged as warnings. The method never fails - /// to ensure PNG optimization is truly optional. - async fn optimize_png(&self, png_file: &Path) { - debug!( - input_file = %png_file.display(), - oxipng_path = %self.oxipng_binary_path.display(), - "Starting PNG optimization" - ); - - let start_time = std::time::Instant::now(); - - let mut command = Command::new(&self.oxipng_binary_path); - - // Default optimization level for speed/compression balance - command.arg("--opt").arg("2"); - - // Remove safe-to-remove metadata - command.arg("--strip").arg("safe"); - - // Overwrite the input PNG file - command.arg(png_file); - - // Clear environment variables to avoid leaking sensitive data - command.env_clear(); - - // Preserve environment variables needed for running oxipng - if let Ok(path) = std::env::var("PATH") { - command.env("PATH", path); - } - - let output = command.output().await; - let duration = start_time.elapsed(); - - match output { - Ok(output) if output.status.success() => { - debug!( - duration_ms = duration.as_millis(), - "PNG optimization completed successfully" - ); - } - Ok(output) => { - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - warn!( - exit_code = ?output.status.code(), - stderr = %stderr, - stdout = %stdout, - duration_ms = duration.as_millis(), - input_file = %png_file.display(), - "PNG optimization failed, continuing with unoptimized image" - ); - } - Err(err) => { - warn!( - error = %err, - input_file = %png_file.display(), - oxipng_path = %self.oxipng_binary_path.display(), - "Failed to execute oxipng, continuing with unoptimized image" - ); - } - } - } -} - -impl Default for OgImageGenerator { - /// Creates a default `OgImageGenerator` with default binary paths. - /// - /// Uses "typst" and "oxipng" as default binary paths, assuming they are available in PATH. - fn default() -> Self { - Self { - typst_binary_path: PathBuf::from("typst"), - typst_font_path: None, - oxipng_binary_path: PathBuf::from("oxipng"), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use mockito::{Server, ServerGuard}; - 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) - } - - async fn create_mock_avatar_server() -> ServerGuard { - let mut server = Server::new_async().await; - - // Mock for successful PNG avatar download - server - .mock("GET", "/test-avatar.png") - .with_status(200) - .with_header("content-type", "image/png") - .with_body(include_bytes!("../template/assets/test-avatar.png")) - .create(); - - // Mock for JPEG avatar download - server - .mock("GET", "/test-avatar.jpg") - .with_status(200) - .with_header("content-type", "image/jpeg") - .with_body(include_bytes!("../template/assets/test-avatar.jpg")) - .create(); - - // Mock for unsupported file type (WebP) - server - .mock("GET", "/test-avatar.webp") - .with_status(200) - .with_header("content-type", "image/webp") - .with_body(include_bytes!("../template/assets/test-avatar.webp")) - .create(); - - // Mock for 404 avatar download - server - .mock("GET", "/missing-avatar.png") - .with_status(404) - .with_header("content-type", "text/plain") - .with_body("Not Found") - .create(); - - server - } - - const fn author(name: &str) -> OgImageAuthorData<'_> { - OgImageAuthorData::new(name, None) - } - - fn author_with_avatar(name: &str, url: String) -> OgImageAuthorData<'_> { - OgImageAuthorData::with_url(name, url) - } - - fn create_minimal_test_data() -> OgImageData<'static> { - static AUTHORS: &[OgImageAuthorData<'_>] = &[author("author")]; - - OgImageData { - name: "minimal-crate", - version: "1.0.0", - description: None, - license: None, - tags: &[], - authors: AUTHORS, - lines_of_code: None, - crate_size: 10000, - releases: 1, - } - } - - fn create_escaping_authors(server_url: &str) -> Vec> { - vec![ - author_with_avatar( - "author \"with quotes\"", - format!("{server_url}/test-avatar.png"), - ), - author("author\\with\\backslashes"), - author("author#with#hashes"), - ] - } - - fn create_escaping_test_data<'a>(authors: &'a [OgImageAuthorData<'a>]) -> OgImageData<'a> { - OgImageData { - name: "crate-with-\"quotes\"", - version: "1.0.0-\"beta\"", - description: Some( - "A crate with \"quotes\", \\ backslashes, and other special chars: #[]{}()", - ), - license: Some("MIT OR \"Apache-2.0\""), - tags: &[ - "tag-with-\"quotes\"", - "tag\\with\\backslashes", - "tag#with#symbols", - ], - authors, - lines_of_code: Some(42), - crate_size: 256256, - releases: 5, - } - } - - fn create_overflow_authors(server_url: &str) -> Vec> { - vec![ - author_with_avatar("alice-wonderland", format!("{server_url}/test-avatar.png")), - author("bob-the-builder"), - author_with_avatar("charlie-brown", format!("{server_url}/test-avatar.jpg")), - author("diana-prince"), - author_with_avatar( - "edward-scissorhands", - format!("{server_url}/test-avatar.png"), - ), - author("fiona-apple"), - author_with_avatar( - "george-washington", - format!("{server_url}/test-avatar.webp"), - ), - author_with_avatar("helen-keller", format!("{server_url}/test-avatar.jpg")), - author("isaac-newton"), - author("jane-doe"), - ] - } - - fn create_overflow_test_data<'a>(authors: &'a [OgImageAuthorData<'a>]) -> OgImageData<'a> { - OgImageData { - name: "super-long-crate-name-for-testing-overflow-behavior", - version: "2.1.0-beta.1+build.12345", - description: Some( - "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: Some("MIT/Apache-2.0/ISC/BSD-3-Clause"), - tags: &[ - "web-framework", - "async-runtime", - "database-orm", - "serialization", - "networking", - ], - 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: "1.0.0", - description: Some("A test crate for OpenGraph image generation"), - license: Some("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 server = create_mock_avatar_server().await; - let server_url = server.url(); - - let authors = create_overflow_authors(&server_url); - let data = create_overflow_test_data(&authors); - - 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 server = create_mock_avatar_server().await; - let server_url = server.url(); - - let authors = create_escaping_authors(&server_url); - let data = create_escaping_test_data(&authors); - - if let Some(image_data) = generate_image(data).await { - insta::assert_binary_snapshot!("generated_og_image_escaping.png", image_data); - } - } - - #[tokio::test] - async fn test_generate_og_image_with_404_avatar() { - let _guard = init_tracing(); - - let server = create_mock_avatar_server().await; - let server_url = server.url(); - - // Create test data with a 404 avatar URL - should skip the avatar gracefully - let authors = vec![author_with_avatar( - "test-user", - format!("{server_url}/missing-avatar.png"), - )]; - let data = OgImageData { - name: "test-crate-404", - version: "1.0.0", - description: Some("A test crate with 404 avatar"), - license: Some("MIT"), - tags: &["testing"], - authors: &authors, - lines_of_code: Some(1000), - crate_size: 42012, - releases: 1, - }; - - if let Some(image_data) = generate_image(data).await { - insta::assert_binary_snapshot!("404-avatar.png", image_data); - } - } - - #[tokio::test] - async fn test_generate_og_image_unicode_truncation() { - let _guard = init_tracing(); - - // Test case that reproduces the Unicode truncation bug from issue #11524 - // Uses the exact description from "adder-codec-rs" crate which contains - // multibyte Unicode characters (Δ) that cause string slicing to fail - static AUTHORS: &[OgImageAuthorData<'_>] = &[author("adder-codec-rs-author")]; - - let data = OgImageData { - name: "adder-codec-rs", - version: "1.0.0", - description: Some( - "Encoder/transcoder/decoder for raw and compressed ADΔER (Address, Decimation, Δt Event Representation) streams. Includes a transcoder for casting either framed or event video into an ADΔER representation in a manner which preserves the temporal resolution of the source. This is a very long description that should trigger text truncation to test the Unicode character boundary issue when the text is too long to fit in the available space. Adding even more text with Unicode characters like ADΔER and Δt to ensure we hit the problematic slice operation at character boundaries.", - ), - license: Some("MIT"), - tags: &["codec", "adder", "event-representation"], - authors: AUTHORS, - lines_of_code: Some(5000), - crate_size: 128000, - releases: 3, - }; - - if let Some(image_data) = generate_image(data).await { - insta::assert_binary_snapshot!("unicode-truncation.png", image_data); - } - } -} diff --git a/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__404-avatar.snap b/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__404-avatar.snap deleted file mode 100644 index 3ec714b7080..00000000000 --- a/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__404-avatar.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -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__404-avatar.snap.png b/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__404-avatar.snap.png deleted file mode 100644 index e497790ec5d..00000000000 Binary files a/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__404-avatar.snap.png and /dev/null differ 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 deleted file mode 100644 index 3ec714b7080..00000000000 --- a/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -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 deleted file mode 100644 index f92cc024142..00000000000 Binary files a/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image.snap.png and /dev/null 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 deleted file mode 100644 index 3ec714b7080..00000000000 --- a/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image_escaping.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -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 deleted file mode 100644 index 9e683090ee2..00000000000 Binary files a/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image_escaping.snap.png and /dev/null 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 deleted file mode 100644 index 3ec714b7080..00000000000 --- a/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image_minimal.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -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 deleted file mode 100644 index 856972dda11..00000000000 Binary files a/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image_minimal.snap.png and /dev/null 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 deleted file mode 100644 index 3ec714b7080..00000000000 --- a/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image_overflow.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -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 deleted file mode 100644 index 5ebb74a5349..00000000000 Binary files a/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__generated_og_image_overflow.snap.png and /dev/null differ diff --git a/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__unicode-truncation.snap b/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__unicode-truncation.snap deleted file mode 100644 index 3ec714b7080..00000000000 --- a/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__unicode-truncation.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -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__unicode-truncation.snap.png b/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__unicode-truncation.snap.png deleted file mode 100644 index bca2a58d42d..00000000000 Binary files a/crates/crates_io_og_image/src/snapshots/crates_io_og_image__tests__unicode-truncation.snap.png and /dev/null differ diff --git a/crates/crates_io_og_image/template/assets/cargo.png b/crates/crates_io_og_image/template/assets/cargo.png deleted file mode 100644 index eaa250e634c..00000000000 Binary files a/crates/crates_io_og_image/template/assets/cargo.png and /dev/null 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 deleted file mode 100644 index f4707e89091..00000000000 --- a/crates/crates_io_og_image/template/assets/code-branch.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/crates_io_og_image/template/assets/code.svg b/crates/crates_io_og_image/template/assets/code.svg deleted file mode 100644 index e1a73651cab..00000000000 --- a/crates/crates_io_og_image/template/assets/code.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/crates_io_og_image/template/assets/rust-logo.svg b/crates/crates_io_og_image/template/assets/rust-logo.svg deleted file mode 100644 index 0b7ffc1c12c..00000000000 --- a/crates/crates_io_og_image/template/assets/rust-logo.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/crates/crates_io_og_image/template/assets/scale-balanced.svg b/crates/crates_io_og_image/template/assets/scale-balanced.svg deleted file mode 100644 index 735f1f52587..00000000000 --- a/crates/crates_io_og_image/template/assets/scale-balanced.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/crates_io_og_image/template/assets/tag.svg b/crates/crates_io_og_image/template/assets/tag.svg deleted file mode 100644 index 29cbbfce4b9..00000000000 --- a/crates/crates_io_og_image/template/assets/tag.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/crates_io_og_image/template/assets/test-avatar.jpg b/crates/crates_io_og_image/template/assets/test-avatar.jpg deleted file mode 100644 index b50e531461a..00000000000 Binary files a/crates/crates_io_og_image/template/assets/test-avatar.jpg and /dev/null differ diff --git a/crates/crates_io_og_image/template/assets/test-avatar.png b/crates/crates_io_og_image/template/assets/test-avatar.png deleted file mode 100644 index d866bbe59b8..00000000000 Binary files a/crates/crates_io_og_image/template/assets/test-avatar.png and /dev/null differ diff --git a/crates/crates_io_og_image/template/assets/test-avatar.webp b/crates/crates_io_og_image/template/assets/test-avatar.webp deleted file mode 100644 index f845c32b93a..00000000000 Binary files a/crates/crates_io_og_image/template/assets/test-avatar.webp and /dev/null 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 deleted file mode 100644 index 827f869e329..00000000000 --- a/crates/crates_io_og_image/template/assets/weight-hanging.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/crates_io_og_image/template/og-image.typ b/crates/crates_io_og_image/template/og-image.typ deleted file mode 100644 index 65f15cbb90f..00000000000 --- a/crates/crates_io_og_image/template/og-image.typ +++ /dev/null @@ -1,342 +0,0 @@ -// ============================================================================= -// 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), - rust-overlay: oklch(36%, 0.07, 144deg, 20%), - 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 { - // Use character-based slicing instead of byte-based to handle Unicode correctly - let chars = text.clusters() - if chars.len() == 0 { - break - } - text = chars.slice(0, chars.len() - 1).join().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 { - // Use character-based slicing instead of byte-based to handle Unicode correctly - let chars = text.clusters() - if chars.len() == 0 { - break - } - text = chars.slice(0, chars.len() - 1).join().trim() - } - return text + "…" - } - }) -} - -// ============================================================================= -// IMAGE UTILITIES -// ============================================================================= -// Functions for loading and processing images - -// Loads an SVG icon and replaces currentColor with the specified color -// @param icon-name: The name of the SVG file (without .svg extension) -// @param color: The color to replace currentColor with -// @param width: The width of the image (default: auto) -// @param height: The height of the image (default: auto) -#let colored-image(path, color, width: auto, height: auto) = { - let svg = read(path).replace("currentColor", color.to-hex()) - image(bytes(svg), width: width, height: height) -} - -// ============================================================================= -// 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) = { - let icon-path = "assets/" + icon-name + ".svg" - - box(inset: (right: 20pt), - grid(columns: (auto, auto), rows: (auto, auto), column-gutter: .75em, row-gutter: .5em, - grid.cell(rowspan: 2, align: horizon, colored-image(icon-path, colors.primary, 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, - colored-image("assets/rust-logo.svg", colors.rust-overlay, 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 - if data.at("description", default: none) != none { - block(text(size: 14pt, weight: "regular", truncate_to_height(data.description, 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 { - let avatar_path = avatar_map.at(author.avatar, default: none) - if avatar_path != none { - avatar = "assets/" + avatar_path - } - } - (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("v" + 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") - } - }) - ) - }) -)