Skip to content

Commit e36a015

Browse files
committed
Add theming support and basic docs.rs theme
1 parent 5483885 commit e36a015

File tree

5 files changed

+123
-30
lines changed

5 files changed

+123
-30
lines changed

src/lib.rs

Lines changed: 71 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,21 @@ use tokio::fs;
1818
use tokio::process::Command;
1919
use tracing::{debug, error, info, instrument, warn};
2020

21+
/// Theme options for OpenGraph image generation
22+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
23+
pub enum Theme {
24+
/// Default crates.io theme
25+
CratesIo,
26+
/// docs.rs theme with black and white colors and docs.rs logo
27+
DocsRs,
28+
}
29+
30+
impl Default for Theme {
31+
fn default() -> Self {
32+
Self::CratesIo
33+
}
34+
}
35+
2136
/// Data structure containing information needed to generate an OpenGraph image
2237
/// for a crates.io crate.
2338
#[derive(Debug, Clone, Serialize)]
@@ -74,6 +89,7 @@ pub struct OgImageGenerator {
7489
typst_binary_path: PathBuf,
7590
typst_font_path: Option<PathBuf>,
7691
oxipng_binary_path: PathBuf,
92+
theme: Theme,
7793
}
7894

7995
impl OgImageGenerator {
@@ -219,6 +235,24 @@ impl OgImageGenerator {
219235
self
220236
}
221237

238+
/// Sets the theme for OpenGraph image generation.
239+
///
240+
/// This allows specifying which visual theme to use when generating images.
241+
/// Defaults to `Theme::CratesIo` if not explicitly set.
242+
///
243+
/// # Examples
244+
///
245+
/// ```
246+
/// use crates_io_og_image::{OgImageGenerator, Theme};
247+
///
248+
/// let generator = OgImageGenerator::default()
249+
/// .with_theme(Theme::DocsRs);
250+
/// ```
251+
pub fn with_theme(mut self, theme: Theme) -> Self {
252+
self.theme = theme;
253+
self
254+
}
255+
222256
/// Processes avatars by downloading URLs and copying assets to the assets directory.
223257
///
224258
/// This method handles both asset-based avatars (which are copied from the bundled assets)
@@ -380,11 +414,17 @@ impl OgImageGenerator {
380414
fs::create_dir(&assets_dir).await?;
381415

382416
debug!("Copying bundled assets to temporary directory");
383-
let cargo_logo = include_bytes!("../template/assets/cargo.png");
384-
fs::write(assets_dir.join("cargo.png"), cargo_logo).await?;
385417
let rust_logo_svg = include_bytes!("../template/assets/rust-logo.svg");
386418
fs::write(assets_dir.join("rust-logo.svg"), rust_logo_svg).await?;
387419

420+
if self.theme == Theme::DocsRs {
421+
let docs_rs_logo_white_svg = include_bytes!("../template/assets/docs-rs-logo.svg");
422+
fs::write(assets_dir.join("docs-rs-logo.svg"), docs_rs_logo_white_svg).await?;
423+
} else {
424+
let cargo_logo = include_bytes!("../template/assets/cargo.png");
425+
fs::write(assets_dir.join("cargo.png"), cargo_logo).await?;
426+
}
427+
388428
// Copy SVG icons
389429
debug!("Copying SVG icon assets");
390430
let code_branch_svg = include_bytes!("../template/assets/code-branch.svg");
@@ -419,24 +459,29 @@ impl OgImageGenerator {
419459
let output_file = NamedTempFile::new().map_err(OgImageError::TempFileError)?;
420460
debug!(output_path = %output_file.path().display(), "Created output file");
421461

422-
// Serialize data and avatar_map to JSON
423-
debug!("Serializing data and avatar map to JSON");
462+
// Serialize data, avatar_map, and theme to JSON
463+
debug!("Serializing data, avatar map, and theme to JSON");
424464
let json_data =
425465
serde_json::to_string(&data).map_err(OgImageError::JsonSerializationError)?;
426466

427467
let json_avatar_map =
428468
serde_json::to_string(&avatar_map).map_err(OgImageError::JsonSerializationError)?;
429469

470+
let json_theme =
471+
serde_json::to_string(&self.theme).map_err(OgImageError::JsonSerializationError)?;
472+
430473
// Run typst compile command with input data
431474
info!("Running Typst compilation command");
432475
let mut command = Command::new(&self.typst_binary_path);
433476
command.arg("compile").arg("--format").arg("png");
434477

435-
// Pass in the data and avatar map as JSON inputs
478+
// Pass in the data, avatar map, and theme as JSON inputs
436479
let input = format!("data={json_data}");
437480
command.arg("--input").arg(input);
438481
let input = format!("avatar_map={json_avatar_map}");
439482
command.arg("--input").arg(input);
483+
let input = format!("theme={json_theme}");
484+
command.arg("--input").arg(input);
440485

441486
// Pass in the font path if specified
442487
if let Some(font_path) = &self.typst_font_path {
@@ -578,6 +623,7 @@ impl Default for OgImageGenerator {
578623
typst_binary_path: PathBuf::from("typst"),
579624
typst_font_path: None,
580625
oxipng_binary_path: PathBuf::from("oxipng"),
626+
theme: Theme::default(),
581627
}
582628
}
583629
}
@@ -752,9 +798,10 @@ mod tests {
752798
}
753799
}
754800

755-
async fn generate_image(data: OgImageData<'_>) -> Option<Vec<u8>> {
756-
let generator =
757-
OgImageGenerator::from_environment().expect("Failed to create OgImageGenerator");
801+
async fn generate_image(data: OgImageData<'_>, theme: Theme) -> Option<Vec<u8>> {
802+
let generator = OgImageGenerator::from_environment()
803+
.expect("Failed to create OgImageGenerator")
804+
.with_theme(theme);
758805

759806
let temp_file = generator
760807
.generate(data)
@@ -769,7 +816,7 @@ mod tests {
769816
let _guard = init_tracing();
770817
let data = create_simple_test_data();
771818

772-
if let Some(image_data) = generate_image(data).await {
819+
if let Some(image_data) = generate_image(data, Theme::CratesIo).await {
773820
insta::assert_binary_snapshot!("generated_og_image.png", image_data);
774821
}
775822
}
@@ -784,7 +831,7 @@ mod tests {
784831
let authors = create_overflow_authors(&server_url);
785832
let data = create_overflow_test_data(&authors);
786833

787-
if let Some(image_data) = generate_image(data).await {
834+
if let Some(image_data) = generate_image(data, Theme::CratesIo).await {
788835
insta::assert_binary_snapshot!("generated_og_image_overflow.png", image_data);
789836
}
790837
}
@@ -794,7 +841,7 @@ mod tests {
794841
let _guard = init_tracing();
795842
let data = create_minimal_test_data();
796843

797-
if let Some(image_data) = generate_image(data).await {
844+
if let Some(image_data) = generate_image(data, Theme::CratesIo).await {
798845
insta::assert_binary_snapshot!("generated_og_image_minimal.png", image_data);
799846
}
800847
}
@@ -809,7 +856,7 @@ mod tests {
809856
let authors = create_escaping_authors(&server_url);
810857
let data = create_escaping_test_data(&authors);
811858

812-
if let Some(image_data) = generate_image(data).await {
859+
if let Some(image_data) = generate_image(data, Theme::CratesIo).await {
813860
insta::assert_binary_snapshot!("generated_og_image_escaping.png", image_data);
814861
}
815862
}
@@ -838,7 +885,7 @@ mod tests {
838885
releases: 1,
839886
};
840887

841-
if let Some(image_data) = generate_image(data).await {
888+
if let Some(image_data) = generate_image(data, Theme::CratesIo).await {
842889
insta::assert_binary_snapshot!("404-avatar.png", image_data);
843890
}
844891
}
@@ -866,8 +913,18 @@ mod tests {
866913
releases: 3,
867914
};
868915

869-
if let Some(image_data) = generate_image(data).await {
916+
if let Some(image_data) = generate_image(data, Theme::CratesIo).await {
870917
insta::assert_binary_snapshot!("unicode-truncation.png", image_data);
871918
}
872919
}
920+
921+
#[tokio::test]
922+
async fn test_generate_og_image_docs_rs_theme() {
923+
let _guard = init_tracing();
924+
let data = create_simple_test_data();
925+
926+
if let Some(image_data) = generate_image(data, Theme::DocsRs).await {
927+
insta::assert_binary_snapshot!("docs_rs_theme.png", image_data);
928+
}
929+
}
873930
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
source: src/lib.rs
3+
expression: image_data
4+
extension: png
5+
snapshot_kind: binary
6+
---
Loading

template/assets/docs-rs-logo.svg

Lines changed: 1 addition & 0 deletions
Loading

template/og-image.typ

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,19 @@
44
// This template generates Open Graph images for crates.io crate.
55

66
// =============================================================================
7-
// COLOR PALETTE
7+
// DATA LOADING
88
// =============================================================================
9+
// Load data from sys.inputs first so we can determine the theme
10+
11+
#let data = json(bytes(sys.inputs.data))
12+
#let avatar_map = json(bytes(sys.inputs.at("avatar_map", default: "{}")))
13+
#let theme = json(bytes(sys.inputs.at("theme", default: "\"CratesIo\"")))
914

10-
#let colors = (
15+
// =============================================================================
16+
// COLOR PALETTES
17+
// =============================================================================
18+
19+
#let colors-crates-io = (
1120
bg: oklch(97%, 0.0147, 98deg),
1221
rust-overlay: oklch(36%, 0.07, 144deg, 20%),
1322
header-bg: oklch(36%, 0.07, 144deg),
@@ -21,6 +30,27 @@
2130
tag-text: oklch(100%, 0, 0deg),
2231
)
2332

33+
#let colors-docs-rs = (
34+
bg: oklch(100%, 0, 0deg),
35+
rust-overlay: oklch(0%, 0, 0deg, 20%),
36+
header-bg: oklch(0%, 0, 0deg),
37+
header-text: oklch(100%, 0, 0deg),
38+
primary: oklch(0%, 0, 0deg),
39+
text: oklch(20%, 0, 0deg),
40+
text-light: oklch(40%, 0, 0deg),
41+
avatar-bg: oklch(100%, 0, 0deg),
42+
avatar-border: oklch(80%, 0, 0deg),
43+
tag-bg: oklch(0%, 0, 0deg),
44+
tag-text: oklch(100%, 0, 0deg),
45+
)
46+
47+
// Set colors based on theme
48+
#let colors = if theme == "DocsRs" {
49+
colors-docs-rs
50+
} else {
51+
colors-crates-io
52+
}
53+
2454
// =============================================================================
2555
// LAYOUT CONSTANTS
2656
// =============================================================================
@@ -221,12 +251,19 @@
221251
// =============================================================================
222252
// Reusable components for consistent styling
223253

224-
#let render-header = {
254+
#let render-header(theme) = {
225255
rect(width: 100%, height: header-height, fill: colors.header-bg, {
226256
place(left + horizon, dx: 30pt, {
227-
box(baseline: 30%, image("assets/cargo.png", width: 35pt))
228-
h(10pt)
229-
text(size: 22pt, fill: colors.header-text, weight: "semibold")[crates.io]
257+
if theme == "DocsRs" {
258+
// Use white version of the docs.rs logo on dark background
259+
box(baseline: 30%, image("assets/docs-rs-logo.svg", width: 35pt))
260+
h(10pt)
261+
text(size: 22pt, fill: colors.header-text, weight: "semibold")[docs.rs]
262+
} else {
263+
box(baseline: 30%, image("assets/cargo.png", width: 35pt))
264+
h(10pt)
265+
text(size: 22pt, fill: colors.header-text, weight: "semibold")[crates.io]
266+
}
230267
})
231268
})
232269
}
@@ -252,23 +289,15 @@
252289
)
253290
}
254291

255-
// =============================================================================
256-
// DATA LOADING
257-
// =============================================================================
258-
// Load data from sys.inputs
259-
260-
#let data = json(bytes(sys.inputs.data))
261-
#let avatar_map = json(bytes(sys.inputs.at("avatar_map", default: "{}")))
262-
263292
// =============================================================================
264293
// MAIN DOCUMENT
265294
// =============================================================================
266295

267296
#set page(width: 600pt, height: 315pt, margin: 0pt, fill: colors.bg)
268297
#set text(font: "Fira Sans", fill: colors.text)
269298

270-
// Header with crates.io branding
271-
#render-header
299+
// Header with theme-based branding
300+
#render-header(theme)
272301

273302
// Bottom border accent
274303
#place(bottom,

0 commit comments

Comments
 (0)