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