use std::fmt;
use markdown_compiler::{AuthorName, PostDescription, PostTag, PostTitle};
use maud::{Markup, PreEscaped, html};
use serde::Serialize;
use thiserror::Error;
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use crate::domain::publication::CanonicalSiteUrl;
pub(crate) struct PostHeadMetadataInput<'metadata> {
pub(crate) title: &'metadata PostTitle,
pub(crate) description: &'metadata PostDescription,
pub(crate) tags: &'metadata [PostTag],
pub(crate) authored_at: OffsetDateTime,
pub(crate) updated_at: Option<OffsetDateTime>,
pub(crate) published_at: Option<OffsetDateTime>,
pub(crate) canonical_url: &'metadata CanonicalSiteUrl,
pub(crate) author: &'metadata AuthorName,
pub(crate) image: Option<&'metadata str>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct RenderedPostHeadMetadata {
pub(crate) published_time: Option<Box<str>>,
pub(crate) modified_time: Option<Box<str>>,
json_ld: Box<str>,
}
impl RenderedPostHeadMetadata {
pub(crate) fn json_ld_script(&self) -> Markup {
html! {
script type="application/ld+json" {
(PreEscaped(self.json_ld.to_string()))
}
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum MetadataTimeField {
Authored,
Updated,
Published,
}
impl fmt::Display for MetadataTimeField {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::Authored => "authored_at",
Self::Updated => "updated_at",
Self::Published => "published_at",
})
}
}
#[derive(Debug, Error)]
pub(crate) enum MetadataRenderError {
#[error("{field} cannot be represented as an RFC 3339 metadata timestamp")]
Timestamp {
field: MetadataTimeField,
#[source]
source: time::error::Format,
},
#[error("BlogPosting JSON-LD serialization failed")]
JsonLd(#[source] serde_json::Error),
}
pub(crate) fn render_post_head_metadata(
input: PostHeadMetadataInput<'_>,
) -> Result<RenderedPostHeadMetadata, MetadataRenderError> {
let authored_time = metadata_time(input.authored_at, MetadataTimeField::Authored)?;
let modified_time = input
.updated_at
.map(|value| metadata_time(value, MetadataTimeField::Updated))
.transpose()?;
let published_time = input
.published_at
.map(|value| metadata_time(value, MetadataTimeField::Published))
.transpose()?;
let document = BlogPostingJsonLd {
context: "https://schema.org",
kind: "BlogPosting",
headline: input.title.as_str(),
description: input.description.as_str(),
url: input.canonical_url.as_str(),
main_entity_of_page: input.canonical_url.as_str(),
date_created: &authored_time,
date_published: published_time.as_deref(),
date_modified: modified_time.as_deref(),
author: BlogPostingAuthor {
kind: "Person",
name: input.author.as_str(),
},
keywords: input.tags,
image: input.image,
};
let serialized = serde_json::to_string(&document).map_err(MetadataRenderError::JsonLd)?;
let json_ld = escape_json_for_html_script(&serialized).into_boxed_str();
Ok(RenderedPostHeadMetadata {
published_time: published_time.map(String::into_boxed_str),
modified_time: modified_time.map(String::into_boxed_str),
json_ld,
})
}
fn metadata_time(
value: OffsetDateTime,
field: MetadataTimeField,
) -> Result<String, MetadataRenderError> {
value
.format(&Rfc3339)
.map_err(|source| MetadataRenderError::Timestamp { field, source })
}
fn escape_json_for_html_script(json: &str) -> String {
let mut output = String::with_capacity(json.len());
for character in json.chars() {
match character {
'<' => output.push_str("\\u003c"),
'>' => output.push_str("\\u003e"),
'&' => output.push_str("\\u0026"),
'\u{2028}' => output.push_str("\\u2028"),
'\u{2029}' => output.push_str("\\u2029"),
_ => output.push(character),
}
}
output
}
#[derive(Serialize)]
struct BlogPostingJsonLd<'metadata> {
#[serde(rename = "@context")]
context: &'static str,
#[serde(rename = "@type")]
kind: &'static str,
headline: &'metadata str,
description: &'metadata str,
url: &'metadata str,
#[serde(rename = "mainEntityOfPage")]
main_entity_of_page: &'metadata str,
#[serde(rename = "dateCreated")]
date_created: &'metadata str,
#[serde(rename = "datePublished", skip_serializing_if = "Option::is_none")]
date_published: Option<&'metadata str>,
#[serde(rename = "dateModified", skip_serializing_if = "Option::is_none")]
date_modified: Option<&'metadata str>,
author: BlogPostingAuthor<'metadata>,
keywords: &'metadata [PostTag],
#[serde(skip_serializing_if = "Option::is_none")]
image: Option<&'metadata str>,
}
#[derive(Serialize)]
struct BlogPostingAuthor<'metadata> {
#[serde(rename = "@type")]
kind: &'static str,
name: &'metadata str,
}
#[cfg(test)]
mod tests {
use markdown_compiler::PublicationBaseUrl;
use serde_json::Value;
use time::{Date, Month, Time};
use super::*;
use crate::domain::publication::PublicPagePath;
fn canonical_url() -> CanonicalSiteUrl {
CanonicalSiteUrl::for_path(
&PublicationBaseUrl::parse("https://example.com/").unwrap(),
&PublicPagePath::post(&markdown_compiler::PostSlug::parse("metadata").unwrap()),
)
}
fn timestamp(value: &str) -> OffsetDateTime {
OffsetDateTime::parse(value, &Rfc3339).unwrap()
}
#[test]
fn renders_deterministic_blog_posting_with_exact_rfc3339_times() {
let title = PostTitle::new("Metadata post").unwrap();
let description = PostDescription::new("Structured description.").unwrap();
let tags = [
PostTag::parse("rust").unwrap(),
PostTag::parse("web").unwrap(),
];
let author = AuthorName::new("Maincopy Author").unwrap();
let canonical_url = canonical_url();
let input = || PostHeadMetadataInput {
title: &title,
description: &description,
tags: &tags,
authored_at: timestamp("2026-08-31T10:11:12-04:00"),
updated_at: Some(timestamp("2026-09-01T11:12:13-04:00")),
published_at: Some(timestamp("2026-09-02T15:16:17Z")),
canonical_url: &canonical_url,
image: None,
author: &author,
};
let first = render_post_head_metadata(input()).unwrap();
let second = render_post_head_metadata(input()).unwrap();
assert_eq!(first, second);
assert_eq!(
first.published_time.as_deref(),
Some("2026-09-02T15:16:17Z")
);
assert_eq!(
first.modified_time.as_deref(),
Some("2026-09-01T11:12:13-04:00")
);
let value: Value = serde_json::from_str(&first.json_ld).unwrap();
assert_eq!(value["@context"], "https://schema.org");
assert_eq!(value["@type"], "BlogPosting");
assert_eq!(value["headline"], "Metadata post");
assert_eq!(value["description"], "Structured description.");
assert_eq!(value["url"], "https://example.com/posts/metadata");
assert_eq!(value["mainEntityOfPage"], value["url"]);
assert_eq!(value["dateCreated"], "2026-08-31T10:11:12-04:00");
assert_eq!(value["datePublished"], "2026-09-02T15:16:17Z");
assert_eq!(value["dateModified"], "2026-09-01T11:12:13-04:00");
assert_eq!(value["author"]["@type"], "Person");
assert_eq!(value["author"]["name"], "Maincopy Author");
assert_eq!(value["keywords"], serde_json::json!(["rust", "web"]));
}
#[test]
fn unpublished_metadata_omits_publication_and_modification_times() {
let title = PostTitle::new("Draft metadata").unwrap();
let description = PostDescription::new("Not published yet.").unwrap();
let author = AuthorName::new("Maincopy Author").unwrap();
let canonical_url = canonical_url();
let rendered = render_post_head_metadata(PostHeadMetadataInput {
title: &title,
description: &description,
tags: &[],
authored_at: timestamp("2026-08-31T10:11:12Z"),
updated_at: None,
published_at: None,
canonical_url: &canonical_url,
image: None,
author: &author,
})
.unwrap();
assert_eq!(rendered.published_time, None);
assert_eq!(rendered.modified_time, None);
let value: Value = serde_json::from_str(&rendered.json_ld).unwrap();
assert!(value.get("datePublished").is_none());
assert!(value.get("dateModified").is_none());
assert_eq!(value["keywords"], serde_json::json!([]));
}
#[test]
fn hostile_text_remains_json_data_and_cannot_close_the_script_element() {
let title = PostTitle::new("</ScRiPt><script>alert(\"title\\\\path\")</script>").unwrap();
let description =
PostDescription::new("Ampersand & separators \u{2028} \u{2029} stay data.").unwrap();
let author = AuthorName::new("Author <&>").unwrap();
let canonical_url = canonical_url();
let rendered = render_post_head_metadata(PostHeadMetadataInput {
title: &title,
description: &description,
tags: &[],
authored_at: timestamp("2026-08-31T10:11:12Z"),
updated_at: None,
published_at: None,
canonical_url: &canonical_url,
image: None,
author: &author,
})
.unwrap();
let script = rendered.json_ld_script().into_string();
assert_eq!(
script
.matches("<script type=\"application/ld+json\">")
.count(),
1
);
assert_eq!(script.matches("</script>").count(), 1);
let json = script
.strip_prefix("<script type=\"application/ld+json\">")
.unwrap()
.strip_suffix("</script>")
.unwrap();
assert!(
!json
.chars()
.any(|character| matches!(character, '<' | '>' | '&' | '\u{2028}' | '\u{2029}'))
);
assert!(!json.to_ascii_lowercase().contains("</script"));
let value: Value = serde_json::from_str(json).unwrap();
assert_eq!(value["headline"], title.as_str());
assert_eq!(value["description"], description.as_str());
assert_eq!(value["author"]["name"], author.as_str());
}
#[test]
fn unrepresentable_timestamp_reports_its_exact_field() {
let title = PostTitle::new("Metadata post").unwrap();
let description = PostDescription::new("Structured description.").unwrap();
let author = AuthorName::new("Maincopy Author").unwrap();
let canonical_url = canonical_url();
let error = render_post_head_metadata(PostHeadMetadataInput {
title: &title,
description: &description,
tags: &[],
authored_at: Date::from_calendar_date(-1, Month::January, 1)
.unwrap()
.with_time(Time::MIDNIGHT)
.assume_utc(),
updated_at: None,
published_at: None,
canonical_url: &canonical_url,
image: None,
author: &author,
})
.unwrap_err();
assert!(matches!(
error,
MetadataRenderError::Timestamp {
field: MetadataTimeField::Authored,
..
}
));
}
}