Skip to content

Commit deb8dc5

Browse files
authored
Merge pull request #11436 from Turbo87/og-image
Add OpenGraph image generation crate
2 parents 721b46e + 820445f commit deb8dc5

25 files changed

+1538
-0
lines changed

.github/workflows/ci.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,14 @@ env:
2323
CARGO_DENY_VERSION: 0.18.3
2424
# renovate: datasource=crate depName=cargo-machete versioning=semver
2525
CARGO_MACHETE_VERSION: 0.8.0
26+
# renovate: datasource=github-releases depName=shssoichiro/oxipng versioning=semver
27+
OXIPNG_VERSION: 9.1.5
2628
# renovate: datasource=npm depName=pnpm
2729
PNPM_VERSION: 10.12.4
2830
# renovate: datasource=docker depName=postgres
2931
POSTGRES_VERSION: 16
32+
# renovate: datasource=github-releases depName=typst/typst versioning=semver
33+
TYPST_VERSION: 0.13.1
3034
# renovate: datasource=pypi depName=zizmor
3135
ZIZMOR_VERSION: 1.11.0
3236

@@ -172,6 +176,27 @@ jobs:
172176
# Remove the Android SDK to free up space
173177
- run: sudo rm -rf /usr/local/lib/android
174178

179+
- name: Install Typst
180+
run: |
181+
wget -q "https://github.com/typst/typst/releases/download/v${TYPST_VERSION}/typst-x86_64-unknown-linux-musl.tar.xz"
182+
tar -xf "typst-x86_64-unknown-linux-musl.tar.xz"
183+
sudo mv "typst-x86_64-unknown-linux-musl/typst" /usr/local/bin/
184+
rm -rf "typst-x86_64-unknown-linux-musl" "typst-x86_64-unknown-linux-musl.tar.xz"
185+
typst --version
186+
187+
- name: Install oxipng
188+
run: |
189+
wget -q "https://github.com/shssoichiro/oxipng/releases/download/v${OXIPNG_VERSION}/oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-musl.tar.gz"
190+
tar -xf "oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-musl.tar.gz"
191+
sudo mv "oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-musl/oxipng" /usr/local/bin/
192+
rm -rf "oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-musl" "oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-musl.tar.gz"
193+
oxipng --version
194+
195+
- name: Download Fira Sans font
196+
run: |
197+
wget -q "https://github.com/mozilla/Fira/archive/4.202.zip"
198+
unzip -q "4.202.zip"
199+
175200
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
176201
with:
177202
save-if: ${{ github.ref == 'refs/heads/main' }}
@@ -183,6 +208,10 @@ jobs:
183208

184209
- run: cargo build --tests --workspace
185210
- run: cargo test --workspace
211+
env:
212+
# Set the path to the Fira Sans font for Typst.
213+
# The path is relative to the `crates_io_og_image` crate root.
214+
TYPST_FONT_PATH: ../../Fira-4.202/otf
186215

187216
frontend-lint:
188217
name: Frontend / Lint

Cargo.lock

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/crates_io_og_image/Cargo.toml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
[package]
2+
name = "crates_io_og_image"
3+
version = "0.0.0"
4+
edition = "2024"
5+
license = "MIT OR Apache-2.0"
6+
description = "OpenGraph image generation for crates.io"
7+
8+
[lints]
9+
workspace = true
10+
11+
[dependencies]
12+
anyhow = "=1.0.98"
13+
bytes = "=1.10.1"
14+
crates_io_env_vars = { path = "../crates_io_env_vars" }
15+
reqwest = "=0.12.21"
16+
serde = { version = "=1.0.219", features = ["derive"] }
17+
serde_json = "=1.0.140"
18+
tempfile = "=3.20.0"
19+
thiserror = "=2.0.12"
20+
tokio = { version = "=1.45.1", features = ["process", "fs"] }
21+
tracing = "=0.1.41"
22+
23+
[dev-dependencies]
24+
insta = "=1.43.1"
25+
tokio = { version = "=1.45.1", features = ["macros", "rt-multi-thread"] }
26+
tracing-subscriber = { version = "=0.3.19", features = ["env-filter", "fmt"] }

crates/crates_io_og_image/README.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# crates_io_og_image
2+
3+
A Rust crate for generating Open Graph images for crates.io packages.
4+
5+
![Example OG Image](src/snapshots/crates_io_og_image__tests__generated_og_image.snap.png)
6+
7+
## Overview
8+
9+
`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.
10+
11+
The generated images include:
12+
13+
- Crate name and description
14+
- Tags/keywords
15+
- Author information with avatars (when available)
16+
- Key metrics (releases, latest version, license, lines of code, size)
17+
- Consistent crates.io branding
18+
19+
## Requirements
20+
21+
- The [Typst](https://typst.app/) CLI must be installed and available in your `PATH`
22+
23+
## Usage
24+
25+
### Basic Example
26+
27+
```rust
28+
use crates_io_og_image::{OgImageData, OgImageGenerator, OgImageAuthorData, OgImageError};
29+
30+
#[tokio::main]
31+
async fn main() -> Result<(), OgImageError> {
32+
// Create a generator instance
33+
let generator = OgImageGenerator::default();
34+
35+
// Define the crate data
36+
let data = OgImageData {
37+
name: "example-crate",
38+
version: "1.2.3",
39+
description: Some("An example crate for testing OpenGraph image generation"),
40+
license: Some("MIT/Apache-2.0"),
41+
tags: &["example", "testing", "og-image"],
42+
authors: &[
43+
OgImageAuthorData::with_url(
44+
"Turbo87",
45+
"https://avatars.githubusercontent.com/u/141300",
46+
),
47+
],
48+
lines_of_code: Some(2000),
49+
crate_size: 75,
50+
releases: 5,
51+
};
52+
53+
// Generate the image
54+
let temp_file = generator.generate(data).await?;
55+
56+
// The temp_file contains the path to the generated PNG image
57+
println!("Image generated at: {}", temp_file.path().display());
58+
59+
Ok(())
60+
}
61+
```
62+
63+
## Configuration
64+
65+
The path to the Typst CLI can be configured through the `TYPST_PATH` environment variables.
66+
67+
## Development
68+
69+
### Running Tests
70+
71+
```bash
72+
cargo test
73+
```
74+
75+
Note that some tests require Typst to be installed and will be skipped if it's not available.
76+
77+
### Example
78+
79+
The crate includes an example that demonstrates how to generate an image:
80+
81+
```bash
82+
cargo run --example test_generator
83+
```
84+
85+
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.
86+
87+
## License
88+
89+
Licensed under either of:
90+
91+
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or <http://www.apache.org/licenses/LICENSE-2.0>)
92+
- MIT license ([LICENSE-MIT](LICENSE-MIT) or <http://opensource.org/licenses/MIT>)
93+
94+
at your option.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
use crates_io_og_image::{OgImageAuthorData, OgImageData, OgImageGenerator};
2+
use tracing::level_filters::LevelFilter;
3+
use tracing_subscriber::{EnvFilter, fmt};
4+
5+
fn init_tracing() {
6+
let env_filter = EnvFilter::builder()
7+
.with_default_directive(LevelFilter::DEBUG.into())
8+
.from_env_lossy();
9+
10+
fmt().compact().with_env_filter(env_filter).init();
11+
}
12+
13+
#[tokio::main]
14+
async fn main() -> Result<(), Box<dyn std::error::Error>> {
15+
init_tracing();
16+
17+
println!("Testing OgImageGenerator...");
18+
19+
let generator = OgImageGenerator::from_environment()?;
20+
println!("Created generator from environment");
21+
22+
// Test generating an image
23+
let data = OgImageData {
24+
name: "example-crate",
25+
version: "1.2.3",
26+
description: Some("An example crate for testing OpenGraph image generation"),
27+
license: Some("MIT/Apache-2.0"),
28+
tags: &["example", "testing", "og-image"],
29+
authors: &[
30+
OgImageAuthorData::new("example-user", None),
31+
OgImageAuthorData::with_url(
32+
"Turbo87",
33+
"https://avatars.githubusercontent.com/u/141300",
34+
),
35+
],
36+
lines_of_code: Some(2000),
37+
crate_size: 75,
38+
releases: 5,
39+
};
40+
match generator.generate(data).await {
41+
Ok(temp_file) => {
42+
let output_path = "test_og_image.png";
43+
std::fs::copy(temp_file.path(), output_path)?;
44+
println!("Successfully generated image at: {output_path}");
45+
println!(
46+
"Image file size: {} bytes",
47+
std::fs::metadata(output_path)?.len()
48+
);
49+
}
50+
Err(error) => {
51+
println!("Failed to generate image: {error}");
52+
println!("Make sure typst is installed and available in PATH");
53+
}
54+
}
55+
56+
Ok(())
57+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
//! Error types for the crates_io_og_image crate.
2+
3+
use std::path::PathBuf;
4+
use thiserror::Error;
5+
6+
/// Errors that can occur when generating OpenGraph images.
7+
#[derive(Debug, Error)]
8+
pub enum OgImageError {
9+
/// Failed to find or execute the Typst binary.
10+
#[error("Failed to find or execute Typst binary: {0}")]
11+
TypstNotFound(#[source] std::io::Error),
12+
13+
/// Environment variable error.
14+
#[error("Environment variable error: {0}")]
15+
EnvVarError(anyhow::Error),
16+
17+
/// Failed to download avatar from URL.
18+
#[error("Failed to download avatar from URL '{url}': {source}")]
19+
AvatarDownloadError {
20+
url: String,
21+
#[source]
22+
source: reqwest::Error,
23+
},
24+
25+
/// Failed to write avatar to file.
26+
#[error("Failed to write avatar to file at {path:?}: {source}")]
27+
AvatarWriteError {
28+
path: PathBuf,
29+
#[source]
30+
source: std::io::Error,
31+
},
32+
33+
/// JSON serialization error.
34+
#[error("JSON serialization error: {0}")]
35+
JsonSerializationError(#[source] serde_json::Error),
36+
37+
/// Typst compilation failed.
38+
#[error("Typst compilation failed: {stderr}")]
39+
TypstCompilationError {
40+
stderr: String,
41+
stdout: String,
42+
exit_code: Option<i32>,
43+
},
44+
45+
/// I/O error.
46+
#[error("I/O error: {0}")]
47+
IoError(#[from] std::io::Error),
48+
49+
/// Temporary file creation error.
50+
#[error("Failed to create temporary file: {0}")]
51+
TempFileError(std::io::Error),
52+
53+
/// Temporary directory creation error.
54+
#[error("Failed to create temporary directory: {0}")]
55+
TempDirError(std::io::Error),
56+
}

0 commit comments

Comments
 (0)