Skip to content

Commit c531b93

Browse files
committed
og_image: Use Typst's --input instead of minijinja to pass in data
This makes the crate more secure while maintaining the same functionality, and it simplifies the implementation by removing the intermediate template rendering step.
1 parent 249cd95 commit c531b93

13 files changed

+91
-969
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ workspace = true
1212
anyhow = "=1.0.98"
1313
bytes = "=1.10.1"
1414
crates_io_env_vars = { path = "../crates_io_env_vars" }
15-
minijinja = { version = "=2.10.2", features = ["builtins"] }
1615
reqwest = "=0.12.20"
1716
serde = { version = "=1.0.219", features = ["derive"] }
17+
serde_json = "=1.0.140"
1818
tempfile = "=3.20.0"
1919
thiserror = "=2.0.12"
2020
tokio = { version = "=1.45.1", features = ["process", "fs"] }

crates/crates_io_og_image/src/error.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ pub enum OgImageError {
3030
source: std::io::Error,
3131
},
3232

33-
/// Template rendering error.
34-
#[error("Template rendering error: {0}")]
35-
TemplateError(#[from] minijinja::Error),
33+
/// JSON serialization error.
34+
#[error("JSON serialization error: {0}")]
35+
JsonSerializationError(#[source] serde_json::Error),
3636

3737
/// Typst compilation failed.
3838
#[error("Typst compilation failed: {stderr}")]

crates/crates_io_og_image/src/formatting.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
//! This module contains utility functions for formatting numbers in various ways,
44
//! such as human-readable byte sizes.
55
6+
use serde::Serializer;
7+
68
/// Formats a byte size value into a human-readable string.
79
///
810
/// The function follows these rules:
@@ -50,6 +52,10 @@ pub fn format_bytes(bytes: u32) -> String {
5052
}
5153
}
5254

55+
pub fn serialize_bytes<S: Serializer>(bytes: &u32, serializer: S) -> Result<S::Ok, S::Error> {
56+
serializer.serialize_str(&format_bytes(*bytes))
57+
}
58+
5359
/// Formats a number with "k" and "M" suffixes for thousands and millions.
5460
///
5561
/// The function follows these rules:
@@ -95,6 +101,20 @@ pub fn format_number(number: u32) -> String {
95101
}
96102
}
97103

104+
pub fn serialize_number<S: Serializer>(number: &u32, serializer: S) -> Result<S::Ok, S::Error> {
105+
serializer.serialize_str(&format_number(*number))
106+
}
107+
108+
pub fn serialize_optional_number<S: Serializer>(
109+
opt_number: &Option<u32>,
110+
serializer: S,
111+
) -> Result<S::Ok, S::Error> {
112+
match opt_number {
113+
Some(number) => serializer.serialize_str(&format_number(*number)),
114+
None => serializer.serialize_none(),
115+
}
116+
}
117+
98118
#[cfg(test)]
99119
mod tests {
100120
use super::*;

crates/crates_io_og_image/src/lib.rs

Lines changed: 19 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -5,40 +5,16 @@ mod formatting;
55

66
pub use error::OgImageError;
77

8-
use crate::formatting::{format_bytes, format_number};
8+
use crate::formatting::{serialize_bytes, serialize_number, serialize_optional_number};
99
use bytes::Bytes;
1010
use crates_io_env_vars::var;
11-
use minijinja::{Environment, context};
1211
use serde::Serialize;
1312
use std::collections::HashMap;
1413
use std::path::PathBuf;
15-
use std::sync::LazyLock;
1614
use tempfile::NamedTempFile;
1715
use tokio::fs;
1816
use tokio::process::Command;
1917

20-
static TEMPLATE_ENV: LazyLock<Environment<'_>> = LazyLock::new(|| {
21-
let mut env = Environment::new();
22-
23-
// Add custom filter for escaping Typst special characters
24-
env.add_filter("typst_escape", |value: String| -> String {
25-
value
26-
.replace('\\', "\\\\") // Escape backslashes first
27-
.replace('"', "\\\"") // Escape double quotes
28-
// Note: No need to escape # characters when inside double-quoted strings
29-
});
30-
31-
// Add custom filter for formatting byte sizes
32-
env.add_filter("format_bytes", format_bytes);
33-
34-
// Add custom filter for formatting numbers with k/M suffixes
35-
env.add_filter("format_number", format_number);
36-
37-
let template_str = include_str!("../templates/og-image.typ.j2");
38-
env.add_template("og-image.typ", template_str).unwrap();
39-
env
40-
});
41-
4218
/// Data structure containing information needed to generate an OpenGraph image
4319
/// for a crates.io crate.
4420
#[derive(Debug, Clone, Serialize)]
@@ -56,10 +32,13 @@ pub struct OgImageData<'a> {
5632
/// Author information
5733
pub authors: &'a [OgImageAuthorData<'a>],
5834
/// Source lines of code count (optional)
35+
#[serde(serialize_with = "serialize_optional_number")]
5936
pub lines_of_code: Option<u32>,
6037
/// Package size in bytes
38+
#[serde(serialize_with = "serialize_bytes")]
6139
pub crate_size: u32,
6240
/// Total number of releases
41+
#[serde(serialize_with = "serialize_number")]
6342
pub releases: u32,
6443
}
6544

@@ -187,20 +166,6 @@ impl OgImageGenerator {
187166
Ok(avatar_map)
188167
}
189168

190-
/// Generates the Typst template content from the provided data.
191-
///
192-
/// This private method renders the Jinja2 template with the provided data
193-
/// and returns the resulting Typst markup as a string.
194-
fn generate_template(
195-
&self,
196-
data: &OgImageData<'_>,
197-
avatar_map: &HashMap<&str, String>,
198-
) -> Result<String, OgImageError> {
199-
let template = TEMPLATE_ENV.get_template("og-image.typ")?;
200-
let rendered = template.render(context! { data, avatar_map })?;
201-
Ok(rendered)
202-
}
203-
204169
/// Generates an OpenGraph image using the provided data.
205170
///
206171
/// This method creates a temporary directory with all the necessary files
@@ -258,19 +223,30 @@ impl OgImageGenerator {
258223
// Process avatars - download URLs and copy assets
259224
let avatar_map = self.process_avatars(&data, &assets_dir).await?;
260225

261-
// Create og-image.typ file using minijinja template
262-
let rendered = self.generate_template(&data, &avatar_map)?;
226+
// Copy the static Typst template file
227+
let template_content = include_str!("../templates/og-image.typ");
263228
let typ_file_path = temp_dir.path().join("og-image.typ");
264-
fs::write(&typ_file_path, rendered).await?;
229+
fs::write(&typ_file_path, template_content).await?;
265230

266231
// Create a named temp file for the output PNG
267232
let output_file = NamedTempFile::new().map_err(OgImageError::TempFileError)?;
268233

269-
// Run typst compile command
234+
// Serialize data and avatar_map to JSON
235+
let json_data = serde_json::to_string(&data);
236+
let json_data = json_data.map_err(OgImageError::JsonSerializationError)?;
237+
238+
let json_avatar_map = serde_json::to_string(&avatar_map);
239+
let json_avatar_map = json_avatar_map.map_err(OgImageError::JsonSerializationError)?;
240+
241+
// Run typst compile command with input data
270242
let output = Command::new(&self.typst_binary_path)
271243
.arg("compile")
272244
.arg("--format")
273245
.arg("png")
246+
.arg("--input")
247+
.arg(format!("data={}", json_data))
248+
.arg("--input")
249+
.arg(format!("avatar_map={}", json_avatar_map))
274250
.arg(&typ_file_path)
275251
.arg(output_file.path())
276252
.output()
@@ -313,22 +289,6 @@ mod tests {
313289
OgImageAuthorData::new(name, Some("test-avatar"))
314290
}
315291

316-
fn create_standard_test_data() -> OgImageData<'static> {
317-
static AUTHORS: &[OgImageAuthorData<'_>] = &[author_with_avatar("alice"), author("bob")];
318-
319-
OgImageData {
320-
name: "example-crate",
321-
version: "v2.1.0",
322-
description: "A comprehensive example crate showcasing various OpenGraph features",
323-
license: "MIT OR Apache-2.0",
324-
tags: &["web", "api", "async", "json", "http"],
325-
authors: AUTHORS,
326-
lines_of_code: Some(5500),
327-
crate_size: 128000,
328-
releases: 15,
329-
}
330-
}
331-
332292
fn create_minimal_test_data() -> OgImageData<'static> {
333293
static AUTHORS: &[OgImageAuthorData<'_>] = &[author("author")];
334294

@@ -428,12 +388,6 @@ mod tests {
428388
.is_err()
429389
}
430390

431-
fn generate_template(data: OgImageData<'_>, avatar_map: HashMap<&str, String>) -> String {
432-
OgImageGenerator::default()
433-
.generate_template(&data, &avatar_map)
434-
.expect("Failed to generate template")
435-
}
436-
437391
async fn generate_image(data: OgImageData<'_>) -> Option<Vec<u8>> {
438392
if skip_if_typst_unavailable() {
439393
return None;
@@ -449,33 +403,6 @@ mod tests {
449403
Some(std::fs::read(temp_file.path()).expect("Failed to read generated image"))
450404
}
451405

452-
#[test]
453-
fn test_generate_template_snapshot() {
454-
let data = create_standard_test_data();
455-
let avatar_map = HashMap::from([("test-avatar", "avatar_0.png".to_string())]);
456-
457-
let template_content = generate_template(data, avatar_map);
458-
insta::assert_snapshot!("generated_template.typ", template_content);
459-
}
460-
461-
#[test]
462-
fn test_generate_template_minimal_snapshot() {
463-
let data = create_minimal_test_data();
464-
let avatar_map = HashMap::new();
465-
466-
let template_content = generate_template(data, avatar_map);
467-
insta::assert_snapshot!("generated_template_minimal.typ", template_content);
468-
}
469-
470-
#[test]
471-
fn test_generate_template_escaping_snapshot() {
472-
let data = create_escaping_test_data();
473-
let avatar_map = HashMap::from([("test-avatar", "avatar_0.png".to_string())]);
474-
475-
let template_content = generate_template(data, avatar_map);
476-
insta::assert_snapshot!("generated_template_escaping.typ", template_content);
477-
}
478-
479406
#[tokio::test]
480407
async fn test_generate_og_image_snapshot() {
481408
let data = create_simple_test_data();
Loading

0 commit comments

Comments
 (0)