Skip to content

Add theming support and basic docs.rs theme #3

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 71 additions & 14 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -74,6 +89,7 @@ pub struct OgImageGenerator {
typst_binary_path: PathBuf,
typst_font_path: Option<PathBuf>,
oxipng_binary_path: PathBuf,
theme: Theme,
}

impl OgImageGenerator {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(),
}
}
}
Expand Down Expand Up @@ -752,9 +798,10 @@ mod tests {
}
}

async fn generate_image(data: OgImageData<'_>) -> Option<Vec<u8>> {
let generator =
OgImageGenerator::from_environment().expect("Failed to create OgImageGenerator");
async fn generate_image(data: OgImageData<'_>, theme: Theme) -> Option<Vec<u8>> {
let generator = OgImageGenerator::from_environment()
.expect("Failed to create OgImageGenerator")
.with_theme(theme);

let temp_file = generator
.generate(data)
Expand All @@ -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);
}
}
Expand All @@ -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);
}
}
Expand All @@ -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);
}
}
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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);
}
}
}
6 changes: 6 additions & 0 deletions src/snapshots/crates_io_og_image__tests__docs_rs_theme.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
source: src/lib.rs
expression: image_data
extension: png
snapshot_kind: binary
---
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions template/assets/docs-rs-logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
61 changes: 45 additions & 16 deletions template/og-image.typ
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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
// =============================================================================
Expand Down Expand Up @@ -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]
}
})
})
}
Expand All @@ -252,23 +289,15 @@
)
}

// =============================================================================
// 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
// Header with theme-based branding
#render-header(theme)

// Bottom border accent
#place(bottom,
Expand Down
Loading