Skip to main content

dioxus_docs_kit/components/
docs_meta.rs

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