Skip to main content

dioxus_docs_kit/components/
docs_meta.rs

1use 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
8/// Build a schema.org JSON-LD `@graph` for a docs page: a `TechArticle` plus an
9/// optional `BreadcrumbList`. `</` is escaped to `<\/` so the payload cannot
10/// break out of its `<script>` container.
11///
12/// `breadcrumbs` is an ordered `(name, url)` list, root first; a `None` url
13/// emits a name-only `ListItem` (used for the current page as the last crumb).
14fn 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/// Injects SEO meta tags and document title for a single docs page (MDX or API endpoint).
65///
66/// Reads `auto_meta` and `site_url` from [`DocsContext`]. When `auto_meta` is
67/// off, emits nothing. Otherwise pulls title/description from the registry —
68/// frontmatter for MDX pages, the OpenAPI operation's `summary`/`description`
69/// for API endpoint pages — and emits `<title>`, `<meta name="description">`,
70/// Open Graph, Twitter Card, and schema.org `TechArticle` JSON-LD tags.
71/// Canonical, `og:url`, the JSON-LD `@id`, and a `BreadcrumbList` are only
72/// emitted when `site_url` is also set. Tags update on client navigation and
73/// are removed when leaving the page, including Markdown alternates and JSON-LD.
74#[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    // `is_mdx` gates the raw-Markdown alternate link: OpenAPI endpoint pages are
84    // rendered dynamically and have no `.md` source.
85    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    // Root-relative `<base_path>/<path>.md`; `join_site_url` with an empty origin
111    // yields the path portion only.
112    let markdown_href = (is_mdx && ctx.markdown_alternate)
113        .then(|| format!("{}.md", join_site_url("", &ctx.base_path, &path)));
114
115    // Breadcrumb trail: Docs root → group → current page. Built only when
116    // `site_url` is set (schema.org breadcrumb items need absolute URLs). The
117    // group's URL points at its first page, since groups have no landing page.
118    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        // Last crumb (current page) has a name but no `item` URL.
222        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}