use dioxus::prelude::*;
use crate::BlogContext;
use crate::blog::registry::BlogRegistry;
use crate::components::seo::{join_site_url, jsonld_to_string};
fn build_article_jsonld(
title: &str,
description: &str,
url: Option<&str>,
date: &str,
author_name: &str,
image: Option<&str>,
) -> String {
let mut payload = serde_json::json!({
"@context": "https://schema.org",
"@type": "Article",
"headline": title,
"description": description,
"datePublished": date,
});
if let Some(url) = url {
payload["mainEntityOfPage"] = serde_json::json!({
"@type": "WebPage",
"@id": url,
});
}
if !author_name.is_empty() {
payload["author"] = serde_json::json!({
"@type": "Person",
"name": author_name,
});
}
if let Some(image) = image {
payload["image"] = serde_json::Value::String(image.to_string());
}
jsonld_to_string(&payload)
}
#[component]
pub fn BlogPostMeta(slug: String) -> Element {
let registry = use_context::<&'static BlogRegistry>();
let ctx = use_context::<BlogContext>();
if !ctx.auto_meta {
return rsx! {};
}
let post = match registry.get_post(&slug) {
Some(p) => p,
None => return rsx! {},
};
let title = &post.frontmatter.title;
let description = post.frontmatter.description.as_deref().unwrap_or("");
let canonical = ctx
.site_url
.as_deref()
.map(|origin| join_site_url(origin, &ctx.base_path, &slug));
let markdown_href = ctx
.markdown_alternate
.then(|| format!("{}.md", join_site_url("", &ctx.base_path, &slug)));
let date = &post.frontmatter.date;
let author_name = registry
.get_author(&post.frontmatter.author)
.map(|a| a.name.as_str())
.unwrap_or("");
let json_ld = build_article_jsonld(
title,
description,
canonical.as_deref(),
date,
author_name,
post.frontmatter.cover_image.as_deref(),
);
rsx! {
document::Title { "{title}" }
document::Meta { name: "description", content: "{description}" }
if let Some(ref url) = canonical {
document::Link { rel: "canonical", href: "{url}" }
}
if let Some(ref href) = markdown_href {
document::Link { rel: "alternate", r#type: "text/markdown", href: "{href}" }
}
document::Meta { property: "og:title", content: "{title}" }
document::Meta { property: "og:description", content: "{description}" }
document::Meta { property: "og:type", content: "article" }
if let Some(ref url) = canonical {
document::Meta { property: "og:url", content: "{url}" }
}
if let Some(ref cover) = post.frontmatter.cover_image {
document::Meta { property: "og:image", content: "{cover}" }
}
document::Meta { name: "twitter:card", content: "summary_large_image" }
document::Meta { name: "twitter:title", content: "{title}" }
document::Meta { name: "twitter:description", content: "{description}" }
if let Some(ref cover) = post.frontmatter.cover_image {
document::Meta { name: "twitter:image", content: "{cover}" }
}
document::Meta { property: "article:published_time", content: "{date}" }
if !author_name.is_empty() {
document::Meta { property: "article:author", content: "{author_name}" }
}
for tag in post.frontmatter.tags.iter() {
document::Meta { property: "article:tag", content: "{tag}" }
}
document::Script { r#type: "application/ld+json", "{json_ld}" }
}
}
#[component]
pub fn BlogIndexMeta(title: String, description: String) -> Element {
let ctx = use_context::<BlogContext>();
if !ctx.auto_meta {
return rsx! {};
}
let canonical = ctx
.site_url
.as_deref()
.map(|origin| join_site_url(origin, &ctx.base_path, ""));
rsx! {
document::Title { "{title}" }
document::Meta { name: "description", content: "{description}" }
if let Some(ref url) = canonical {
document::Link { rel: "canonical", href: "{url}" }
}
document::Meta { property: "og:title", content: "{title}" }
document::Meta { property: "og:description", content: "{description}" }
document::Meta { property: "og:type", content: "website" }
if let Some(ref url) = canonical {
document::Meta { property: "og:url", content: "{url}" }
}
document::Meta { name: "twitter:card", content: "summary" }
document::Meta { name: "twitter:title", content: "{title}" }
document::Meta { name: "twitter:description", content: "{description}" }
}
}
#[cfg(test)]
mod tests {
use super::build_article_jsonld;
#[test]
fn jsonld_includes_required_fields() {
let out = build_article_jsonld(
"Hello",
"A post",
Some("https://example.com/blog/hello"),
"2026-05-21",
"Jane",
Some("https://example.com/cover.png"),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(parsed["@context"], "https://schema.org");
assert_eq!(parsed["@type"], "Article");
assert_eq!(parsed["headline"], "Hello");
assert_eq!(parsed["description"], "A post");
assert_eq!(parsed["datePublished"], "2026-05-21");
assert_eq!(parsed["author"]["@type"], "Person");
assert_eq!(parsed["author"]["name"], "Jane");
assert_eq!(parsed["image"], "https://example.com/cover.png");
assert_eq!(
parsed["mainEntityOfPage"]["@id"],
"https://example.com/blog/hello"
);
}
#[test]
fn jsonld_omits_author_and_image_when_missing() {
let out = build_article_jsonld("Hello", "A post", None, "2026-05-21", "", None);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert!(parsed.get("author").is_none());
assert!(parsed.get("image").is_none());
assert!(parsed.get("mainEntityOfPage").is_none());
}
#[test]
fn jsonld_escapes_script_close_sequence() {
let out = build_article_jsonld(
"evil </script><script>alert(1)</script>",
"",
Some("https://example.com/"),
"2026-05-21",
"",
None,
);
assert!(
!out.contains("</script"),
"expected </ sequences to be escaped, got: {out}"
);
assert!(out.contains("<\\/script"));
}
}