dioxus_docs_kit/components/
docs_meta.rs1use dioxus::prelude::*;
2
3use super::seo::{join_site_url, jsonld_to_string};
4use crate::DocsContext;
5use crate::registry::DocsRegistry;
6
7fn build_docs_jsonld(
14 title: &str,
15 description: &str,
16 canonical: Option<&str>,
17 breadcrumbs: &[(String, Option<String>)],
18) -> String {
19 let mut tech_article = serde_json::json!({
20 "@type": "TechArticle",
21 "headline": title,
22 "description": description,
23 });
24 if let Some(url) = canonical {
25 tech_article["mainEntityOfPage"] = serde_json::json!({
26 "@type": "WebPage",
27 "@id": url,
28 });
29 }
30
31 let mut graph = vec![tech_article];
32
33 if !breadcrumbs.is_empty() {
34 let items: Vec<serde_json::Value> = breadcrumbs
35 .iter()
36 .enumerate()
37 .map(|(i, (name, url))| {
38 let mut item = serde_json::json!({
39 "@type": "ListItem",
40 "position": i + 1,
41 "name": name,
42 });
43 if let Some(url) = url {
44 item["item"] = serde_json::Value::String(url.clone());
45 }
46 item
47 })
48 .collect();
49 graph.push(serde_json::json!({
50 "@type": "BreadcrumbList",
51 "itemListElement": items,
52 }));
53 }
54
55 let payload = serde_json::json!({
56 "@context": "https://schema.org",
57 "@graph": graph,
58 });
59
60 jsonld_to_string(&payload)
61}
62
63#[component]
73pub fn DocsPageMeta(path: String) -> Element {
74 let registry = use_context::<&'static DocsRegistry>();
75 let ctx = use_context::<DocsContext>();
76
77 if !ctx.auto_meta {
78 return rsx! {};
79 }
80
81 let (title, description, is_mdx) = if let Some(op) = registry.get_api_operation(&path) {
84 let title = op
85 .summary
86 .clone()
87 .unwrap_or_else(|| op.slug().replace('-', " "));
88 (title, op.description.clone().unwrap_or_default(), false)
89 } else if let Some(doc) = registry.get_parsed_doc(&path) {
90 (
91 doc.frontmatter.title.clone(),
92 doc.frontmatter.description.clone().unwrap_or_default(),
93 true,
94 )
95 } else {
96 return rsx! {};
97 };
98
99 if title.is_empty() {
100 return rsx! {};
101 }
102
103 let canonical = ctx
104 .site_url
105 .as_deref()
106 .map(|origin| join_site_url(origin, &ctx.base_path, &path));
107
108 let markdown_href = (is_mdx && ctx.markdown_alternate)
111 .then(|| format!("{}.md", join_site_url("", &ctx.base_path, &path)));
112
113 let breadcrumbs: Vec<(String, Option<String>)> = match ctx.site_url.as_deref() {
117 Some(origin) => {
118 let root_label = registry
119 .tab_for_path(&path)
120 .unwrap_or_else(|| "Docs".to_string());
121 let mut trail = vec![(root_label, Some(join_site_url(origin, &ctx.base_path, "")))];
122 if let Some(group) = registry
123 .nav
124 .groups
125 .iter()
126 .find(|g| g.pages.iter().any(|p| p == &path))
127 && let Some(first) = group.pages.first()
128 {
129 trail.push((
130 group.group.clone(),
131 Some(join_site_url(origin, &ctx.base_path, first)),
132 ));
133 }
134 trail.push((title.clone(), canonical.clone()));
135 trail
136 }
137 None => Vec::new(),
138 };
139
140 let json_ld = build_docs_jsonld(&title, &description, canonical.as_deref(), &breadcrumbs);
141
142 rsx! {
143 document::Title { "{title}" }
144 document::Meta { name: "description", content: "{description}" }
145 if let Some(ref url) = canonical {
146 document::Link { rel: "canonical", href: "{url}" }
147 }
148 if let Some(ref href) = markdown_href {
149 document::Link { rel: "alternate", r#type: "text/markdown", href: "{href}" }
150 }
151
152 document::Meta { property: "og:title", content: "{title}" }
154 document::Meta { property: "og:description", content: "{description}" }
155 document::Meta { property: "og:type", content: "article" }
156 if let Some(ref url) = canonical {
157 document::Meta { property: "og:url", content: "{url}" }
158 }
159
160 document::Meta { name: "twitter:card", content: "summary" }
162 document::Meta { name: "twitter:title", content: "{title}" }
163 document::Meta { name: "twitter:description", content: "{description}" }
164
165 document::Script { r#type: "application/ld+json", "{json_ld}" }
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::build_docs_jsonld;
173
174 #[test]
175 fn jsonld_emits_techarticle_with_id_when_canonical_present() {
176 let out = build_docs_jsonld(
177 "Introduction",
178 "Get started",
179 Some("https://example.com/docs/getting-started/intro"),
180 &[],
181 );
182 let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
183 assert_eq!(parsed["@context"], "https://schema.org");
184 let graph = parsed["@graph"].as_array().unwrap();
185 assert_eq!(graph.len(), 1);
186 assert_eq!(graph[0]["@type"], "TechArticle");
187 assert_eq!(graph[0]["headline"], "Introduction");
188 assert_eq!(
189 graph[0]["mainEntityOfPage"]["@id"],
190 "https://example.com/docs/getting-started/intro"
191 );
192 }
193
194 #[test]
195 fn jsonld_omits_id_without_canonical_and_breadcrumb_when_empty() {
196 let out = build_docs_jsonld("Introduction", "Get started", None, &[]);
197 let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
198 let graph = parsed["@graph"].as_array().unwrap();
199 assert_eq!(graph.len(), 1);
200 assert!(graph[0].get("mainEntityOfPage").is_none());
201 }
202
203 #[test]
204 fn jsonld_appends_positioned_breadcrumb_list() {
205 let crumbs = vec![
206 (
207 "Docs".to_string(),
208 Some("https://example.com/docs".to_string()),
209 ),
210 (
211 "Getting Started".to_string(),
212 Some("https://example.com/docs/getting-started/intro".to_string()),
213 ),
214 ("Introduction".to_string(), None),
215 ];
216 let out = build_docs_jsonld("Introduction", "Get started", None, &crumbs);
217 let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
218 let graph = parsed["@graph"].as_array().unwrap();
219 assert_eq!(graph.len(), 2);
220 let bc = &graph[1];
221 assert_eq!(bc["@type"], "BreadcrumbList");
222 let items = bc["itemListElement"].as_array().unwrap();
223 assert_eq!(items.len(), 3);
224 assert_eq!(items[0]["position"], 1);
225 assert_eq!(items[1]["name"], "Getting Started");
226 assert_eq!(items[2]["position"], 3);
227 assert!(items[2].get("item").is_none());
229 }
230
231 #[test]
232 fn jsonld_escapes_script_close_sequence() {
233 let out = build_docs_jsonld(
234 "evil </script><script>alert(1)</script>",
235 "",
236 Some("https://example.com/"),
237 &[],
238 );
239 assert!(
240 !out.contains("</script"),
241 "expected </ sequences to be escaped, got: {out}"
242 );
243 assert!(out.contains("<\\/script"));
244 }
245}