use dioxus::prelude::*;
use super::seo::{join_site_url, jsonld_to_string};
use crate::DocsContext;
use crate::registry::DocsRegistry;
fn build_docs_jsonld(
title: &str,
description: &str,
canonical: Option<&str>,
breadcrumbs: &[(String, Option<String>)],
) -> String {
let mut tech_article = serde_json::json!({
"@type": "TechArticle",
"headline": title,
"description": description,
});
if let Some(url) = canonical {
tech_article["mainEntityOfPage"] = serde_json::json!({
"@type": "WebPage",
"@id": url,
});
}
let mut graph = vec![tech_article];
if !breadcrumbs.is_empty() {
let items: Vec<serde_json::Value> = breadcrumbs
.iter()
.enumerate()
.map(|(i, (name, url))| {
let mut item = serde_json::json!({
"@type": "ListItem",
"position": i + 1,
"name": name,
});
if let Some(url) = url {
item["item"] = serde_json::Value::String(url.clone());
}
item
})
.collect();
graph.push(serde_json::json!({
"@type": "BreadcrumbList",
"itemListElement": items,
}));
}
let payload = serde_json::json!({
"@context": "https://schema.org",
"@graph": graph,
});
jsonld_to_string(&payload)
}
#[component]
pub fn DocsPageMeta(path: String) -> Element {
let registry = use_context::<&'static DocsRegistry>();
let ctx = use_context::<DocsContext>();
if !ctx.auto_meta {
return rsx! {};
}
let (title, description, is_mdx) = if let Some(op) = registry.get_api_operation(&path) {
let title = op
.summary
.clone()
.unwrap_or_else(|| op.slug().replace('-', " "));
(title, op.description.clone().unwrap_or_default(), false)
} else if let Some(doc) = registry.get_parsed_doc(&path) {
(
doc.frontmatter.title.clone(),
doc.frontmatter.description.clone().unwrap_or_default(),
true,
)
} else {
return rsx! {};
};
if title.is_empty() {
return rsx! {};
}
let canonical = ctx
.site_url
.as_deref()
.map(|origin| join_site_url(origin, &ctx.base_path, &path));
let markdown_href = (is_mdx && ctx.markdown_alternate)
.then(|| format!("{}.md", join_site_url("", &ctx.base_path, &path)));
let breadcrumbs: Vec<(String, Option<String>)> = match ctx.site_url.as_deref() {
Some(origin) => {
let root_label = registry
.tab_for_path(&path)
.unwrap_or_else(|| "Docs".to_string());
let mut trail = vec![(root_label, Some(join_site_url(origin, &ctx.base_path, "")))];
if let Some(group) = registry
.nav
.groups
.iter()
.find(|g| g.pages.iter().any(|p| p == &path))
&& let Some(first) = group.pages.first()
{
trail.push((
group.group.clone(),
Some(join_site_url(origin, &ctx.base_path, first)),
));
}
trail.push((title.clone(), canonical.clone()));
trail
}
None => Vec::new(),
};
let json_ld = build_docs_jsonld(&title, &description, canonical.as_deref(), &breadcrumbs);
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}" }
}
document::Meta { name: "twitter:card", content: "summary" }
document::Meta { name: "twitter:title", content: "{title}" }
document::Meta { name: "twitter:description", content: "{description}" }
document::Script { r#type: "application/ld+json", "{json_ld}" }
}
}
#[cfg(test)]
mod tests {
use super::build_docs_jsonld;
#[test]
fn jsonld_emits_techarticle_with_id_when_canonical_present() {
let out = build_docs_jsonld(
"Introduction",
"Get started",
Some("https://example.com/docs/getting-started/intro"),
&[],
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(parsed["@context"], "https://schema.org");
let graph = parsed["@graph"].as_array().unwrap();
assert_eq!(graph.len(), 1);
assert_eq!(graph[0]["@type"], "TechArticle");
assert_eq!(graph[0]["headline"], "Introduction");
assert_eq!(
graph[0]["mainEntityOfPage"]["@id"],
"https://example.com/docs/getting-started/intro"
);
}
#[test]
fn jsonld_omits_id_without_canonical_and_breadcrumb_when_empty() {
let out = build_docs_jsonld("Introduction", "Get started", None, &[]);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let graph = parsed["@graph"].as_array().unwrap();
assert_eq!(graph.len(), 1);
assert!(graph[0].get("mainEntityOfPage").is_none());
}
#[test]
fn jsonld_appends_positioned_breadcrumb_list() {
let crumbs = vec![
(
"Docs".to_string(),
Some("https://example.com/docs".to_string()),
),
(
"Getting Started".to_string(),
Some("https://example.com/docs/getting-started/intro".to_string()),
),
("Introduction".to_string(), None),
];
let out = build_docs_jsonld("Introduction", "Get started", None, &crumbs);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let graph = parsed["@graph"].as_array().unwrap();
assert_eq!(graph.len(), 2);
let bc = &graph[1];
assert_eq!(bc["@type"], "BreadcrumbList");
let items = bc["itemListElement"].as_array().unwrap();
assert_eq!(items.len(), 3);
assert_eq!(items[0]["position"], 1);
assert_eq!(items[1]["name"], "Getting Started");
assert_eq!(items[2]["position"], 3);
assert!(items[2].get("item").is_none());
}
#[test]
fn jsonld_escapes_script_close_sequence() {
let out = build_docs_jsonld(
"evil </script><script>alert(1)</script>",
"",
Some("https://example.com/"),
&[],
);
assert!(
!out.contains("</script"),
"expected </ sequences to be escaped, got: {out}"
);
assert!(out.contains("<\\/script"));
}
}