diff --git a/src/lib.rs b/src/lib.rs index 6c6ad57..48b406b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,21 @@ use tokio::fs; use tokio::process::Command; use tracing::{debug, error, info, instrument, warn}; +/// Theme options for OpenGraph image generation +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum Theme { + /// Default crates.io theme + CratesIo, + /// docs.rs theme with black and white colors and docs.rs logo + DocsRs, +} + +impl Default for Theme { + fn default() -> Self { + Self::CratesIo + } +} + /// Data structure containing information needed to generate an OpenGraph image /// for a crates.io crate. #[derive(Debug, Clone, Serialize)] @@ -74,6 +89,7 @@ pub struct OgImageGenerator { typst_binary_path: PathBuf, typst_font_path: Option, oxipng_binary_path: PathBuf, + theme: Theme, } impl OgImageGenerator { @@ -219,6 +235,24 @@ impl OgImageGenerator { self } + /// Sets the theme for OpenGraph image generation. + /// + /// This allows specifying which visual theme to use when generating images. + /// Defaults to `Theme::CratesIo` if not explicitly set. + /// + /// # Examples + /// + /// ``` + /// use crates_io_og_image::{OgImageGenerator, Theme}; + /// + /// let generator = OgImageGenerator::default() + /// .with_theme(Theme::DocsRs); + /// ``` + pub fn with_theme(mut self, theme: Theme) -> Self { + self.theme = theme; + 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) @@ -380,11 +414,17 @@ impl OgImageGenerator { 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?; + if self.theme == Theme::DocsRs { + let docs_rs_logo_white_svg = include_bytes!("../template/assets/docs-rs-logo.svg"); + fs::write(assets_dir.join("docs-rs-logo.svg"), docs_rs_logo_white_svg).await?; + } else { + let cargo_logo = include_bytes!("../template/assets/cargo.png"); + fs::write(assets_dir.join("cargo.png"), cargo_logo).await?; + } + // Copy SVG icons debug!("Copying SVG icon assets"); let code_branch_svg = include_bytes!("../template/assets/code-branch.svg"); @@ -419,24 +459,29 @@ impl OgImageGenerator { 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"); + // Serialize data, avatar_map, and theme to JSON + debug!("Serializing data, avatar map, and theme 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)?; + let json_theme = + serde_json::to_string(&self.theme).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 + // Pass in the data, avatar map, and theme 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); + let input = format!("theme={json_theme}"); + command.arg("--input").arg(input); // Pass in the font path if specified if let Some(font_path) = &self.typst_font_path { @@ -578,6 +623,7 @@ impl Default for OgImageGenerator { typst_binary_path: PathBuf::from("typst"), typst_font_path: None, oxipng_binary_path: PathBuf::from("oxipng"), + theme: Theme::default(), } } } @@ -752,9 +798,10 @@ mod tests { } } - async fn generate_image(data: OgImageData<'_>) -> Option> { - let generator = - OgImageGenerator::from_environment().expect("Failed to create OgImageGenerator"); + async fn generate_image(data: OgImageData<'_>, theme: Theme) -> Option> { + let generator = OgImageGenerator::from_environment() + .expect("Failed to create OgImageGenerator") + .with_theme(theme); let temp_file = generator .generate(data) @@ -769,7 +816,7 @@ mod tests { let _guard = init_tracing(); let data = create_simple_test_data(); - if let Some(image_data) = generate_image(data).await { + if let Some(image_data) = generate_image(data, Theme::CratesIo).await { insta::assert_binary_snapshot!("generated_og_image.png", image_data); } } @@ -784,7 +831,7 @@ mod tests { let authors = create_overflow_authors(&server_url); let data = create_overflow_test_data(&authors); - if let Some(image_data) = generate_image(data).await { + if let Some(image_data) = generate_image(data, Theme::CratesIo).await { insta::assert_binary_snapshot!("generated_og_image_overflow.png", image_data); } } @@ -794,7 +841,7 @@ mod tests { let _guard = init_tracing(); let data = create_minimal_test_data(); - if let Some(image_data) = generate_image(data).await { + if let Some(image_data) = generate_image(data, Theme::CratesIo).await { insta::assert_binary_snapshot!("generated_og_image_minimal.png", image_data); } } @@ -809,7 +856,7 @@ mod tests { let authors = create_escaping_authors(&server_url); let data = create_escaping_test_data(&authors); - if let Some(image_data) = generate_image(data).await { + if let Some(image_data) = generate_image(data, Theme::CratesIo).await { insta::assert_binary_snapshot!("generated_og_image_escaping.png", image_data); } } @@ -838,7 +885,7 @@ mod tests { releases: 1, }; - if let Some(image_data) = generate_image(data).await { + if let Some(image_data) = generate_image(data, Theme::CratesIo).await { insta::assert_binary_snapshot!("404-avatar.png", image_data); } } @@ -866,8 +913,18 @@ mod tests { releases: 3, }; - if let Some(image_data) = generate_image(data).await { + if let Some(image_data) = generate_image(data, Theme::CratesIo).await { insta::assert_binary_snapshot!("unicode-truncation.png", image_data); } } + + #[tokio::test] + async fn test_generate_og_image_docs_rs_theme() { + let _guard = init_tracing(); + let data = create_simple_test_data(); + + if let Some(image_data) = generate_image(data, Theme::DocsRs).await { + insta::assert_binary_snapshot!("docs_rs_theme.png", image_data); + } + } } diff --git a/src/snapshots/crates_io_og_image__tests__docs_rs_theme.snap b/src/snapshots/crates_io_og_image__tests__docs_rs_theme.snap new file mode 100644 index 0000000..60d2fbd --- /dev/null +++ b/src/snapshots/crates_io_og_image__tests__docs_rs_theme.snap @@ -0,0 +1,6 @@ +--- +source: src/lib.rs +expression: image_data +extension: png +snapshot_kind: binary +--- diff --git a/src/snapshots/crates_io_og_image__tests__docs_rs_theme.snap.png b/src/snapshots/crates_io_og_image__tests__docs_rs_theme.snap.png new file mode 100644 index 0000000..13c7ab0 Binary files /dev/null and b/src/snapshots/crates_io_og_image__tests__docs_rs_theme.snap.png differ diff --git a/template/assets/docs-rs-logo.svg b/template/assets/docs-rs-logo.svg new file mode 100644 index 0000000..af71ff8 --- /dev/null +++ b/template/assets/docs-rs-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/template/og-image.typ b/template/og-image.typ index 65f15cb..8a31c4f 100644 --- a/template/og-image.typ +++ b/template/og-image.typ @@ -4,10 +4,19 @@ // This template generates Open Graph images for crates.io crate. // ============================================================================= -// COLOR PALETTE +// DATA LOADING // ============================================================================= +// Load data from sys.inputs first so we can determine the theme + +#let data = json(bytes(sys.inputs.data)) +#let avatar_map = json(bytes(sys.inputs.at("avatar_map", default: "{}"))) +#let theme = json(bytes(sys.inputs.at("theme", default: "\"CratesIo\""))) -#let colors = ( +// ============================================================================= +// COLOR PALETTES +// ============================================================================= + +#let colors-crates-io = ( bg: oklch(97%, 0.0147, 98deg), rust-overlay: oklch(36%, 0.07, 144deg, 20%), header-bg: oklch(36%, 0.07, 144deg), @@ -21,6 +30,27 @@ tag-text: oklch(100%, 0, 0deg), ) +#let colors-docs-rs = ( + bg: oklch(100%, 0, 0deg), + rust-overlay: oklch(0%, 0, 0deg, 20%), + header-bg: oklch(0%, 0, 0deg), + header-text: oklch(100%, 0, 0deg), + primary: oklch(0%, 0, 0deg), + text: oklch(20%, 0, 0deg), + text-light: oklch(40%, 0, 0deg), + avatar-bg: oklch(100%, 0, 0deg), + avatar-border: oklch(80%, 0, 0deg), + tag-bg: oklch(0%, 0, 0deg), + tag-text: oklch(100%, 0, 0deg), +) + +// Set colors based on theme +#let colors = if theme == "DocsRs" { + colors-docs-rs +} else { + colors-crates-io +} + // ============================================================================= // LAYOUT CONSTANTS // ============================================================================= @@ -221,12 +251,19 @@ // ============================================================================= // Reusable components for consistent styling -#let render-header = { +#let render-header(theme) = { 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] + if theme == "DocsRs" { + // Use white version of the docs.rs logo on dark background + box(baseline: 30%, image("assets/docs-rs-logo.svg", width: 35pt)) + h(10pt) + text(size: 22pt, fill: colors.header-text, weight: "semibold")[docs.rs] + } else { + box(baseline: 30%, image("assets/cargo.png", width: 35pt)) + h(10pt) + text(size: 22pt, fill: colors.header-text, weight: "semibold")[crates.io] + } }) }) } @@ -252,14 +289,6 @@ ) } -// ============================================================================= -// 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 // ============================================================================= @@ -267,8 +296,8 @@ #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 +// Header with theme-based branding +#render-header(theme) // Bottom border accent #place(bottom,