Skip to main content

dioxus_docs_kit/components/blog/
blog_meta.rs

1use dioxus::prelude::*;
2
3use crate::BlogContext;
4use crate::blog::registry::BlogRegistry;
5use crate::components::seo::{join_site_url, jsonld_to_string};
6
7/// Build a schema.org Article JSON-LD string, with `</` escaped to `<\/` so the
8/// payload cannot break out of its `<script>` container.
9fn build_article_jsonld(
10    title: &str,
11    description: &str,
12    url: Option<&str>,
13    date: &str,
14    author_name: &str,
15    image: Option<&str>,
16) -> String {
17    let mut payload = serde_json::json!({
18        "@context": "https://schema.org",
19        "@type": "Article",
20        "headline": title,
21        "description": description,
22        "datePublished": date,
23    });
24
25    if let Some(url) = url {
26        payload["mainEntityOfPage"] = serde_json::json!({
27            "@type": "WebPage",
28            "@id": url,
29        });
30    }
31    if !author_name.is_empty() {
32        payload["author"] = serde_json::json!({
33            "@type": "Person",
34            "name": author_name,
35        });
36    }
37    if let Some(image) = image {
38        payload["image"] = serde_json::Value::String(image.to_string());
39    }
40
41    jsonld_to_string(&payload)
42}
43
44/// Injects Open Graph / SEO meta tags and document title for a single blog post.
45///
46/// Reads `auto_meta` and `site_url` from [`BlogContext`]. When `auto_meta` is
47/// off, emits nothing. Otherwise emits title/description, Open Graph, Twitter
48/// Card, article metadata, and schema.org Article JSON-LD from frontmatter.
49/// Canonical, `og:url`, and the JSON-LD `@id` are only emitted when `site_url`
50/// is also set.
51#[component]
52pub fn BlogPostMeta(slug: String) -> Element {
53    let registry = use_context::<&'static BlogRegistry>();
54    let ctx = use_context::<BlogContext>();
55
56    if !ctx.auto_meta {
57        return rsx! {};
58    }
59
60    let post = match registry.get_post(&slug) {
61        Some(p) => p,
62        None => return rsx! {},
63    };
64
65    let title = &post.frontmatter.title;
66    let description = post.frontmatter.description.as_deref().unwrap_or("");
67    let canonical = ctx
68        .site_url
69        .as_deref()
70        .map(|origin| join_site_url(origin, &ctx.base_path, &slug));
71    // Root-relative `<base_path>/<slug>.md`; `join_site_url` with an empty origin
72    // yields the path portion only.
73    let markdown_href = ctx
74        .markdown_alternate
75        .then(|| format!("{}.md", join_site_url("", &ctx.base_path, &slug)));
76    let date = &post.frontmatter.date;
77    let author_name = registry
78        .get_author(&post.frontmatter.author)
79        .map(|a| a.name.as_str())
80        .unwrap_or("");
81
82    let json_ld = build_article_jsonld(
83        title,
84        description,
85        canonical.as_deref(),
86        date,
87        author_name,
88        post.frontmatter.cover_image.as_deref(),
89    );
90
91    rsx! {
92        document::Title { "{title}" }
93        document::Meta { name: "description", content: "{description}" }
94        if let Some(ref url) = canonical {
95            document::Link { rel: "canonical", href: "{url}" }
96        }
97        if let Some(ref href) = markdown_href {
98            document::Link { rel: "alternate", r#type: "text/markdown", href: "{href}" }
99        }
100
101        // Open Graph
102        document::Meta { property: "og:title", content: "{title}" }
103        document::Meta { property: "og:description", content: "{description}" }
104        document::Meta { property: "og:type", content: "article" }
105        if let Some(ref url) = canonical {
106            document::Meta { property: "og:url", content: "{url}" }
107        }
108        if let Some(ref cover) = post.frontmatter.cover_image {
109            document::Meta { property: "og:image", content: "{cover}" }
110        }
111
112        // Twitter Card
113        document::Meta { name: "twitter:card", content: "summary_large_image" }
114        document::Meta { name: "twitter:title", content: "{title}" }
115        document::Meta { name: "twitter:description", content: "{description}" }
116        if let Some(ref cover) = post.frontmatter.cover_image {
117            document::Meta { name: "twitter:image", content: "{cover}" }
118        }
119
120        // Article metadata
121        document::Meta { property: "article:published_time", content: "{date}" }
122        if !author_name.is_empty() {
123            document::Meta { property: "article:author", content: "{author_name}" }
124        }
125        for tag in post.frontmatter.tags.iter() {
126            document::Meta { property: "article:tag", content: "{tag}" }
127        }
128
129        // schema.org Article JSON-LD for rich-result eligibility.
130        document::Script { r#type: "application/ld+json", "{json_ld}" }
131    }
132}
133
134/// Injects basic SEO meta tags for the blog index/listing page.
135///
136/// Reads `auto_meta` and `site_url` from [`BlogContext`]. When `auto_meta` is
137/// off, emits nothing. Canonical and `og:url` only emit when `site_url` is set.
138#[component]
139pub fn BlogIndexMeta(title: String, description: String) -> Element {
140    let ctx = use_context::<BlogContext>();
141
142    if !ctx.auto_meta {
143        return rsx! {};
144    }
145
146    let canonical = ctx
147        .site_url
148        .as_deref()
149        .map(|origin| join_site_url(origin, &ctx.base_path, ""));
150
151    rsx! {
152        document::Title { "{title}" }
153        document::Meta { name: "description", content: "{description}" }
154        if let Some(ref url) = canonical {
155            document::Link { rel: "canonical", href: "{url}" }
156        }
157        document::Meta { property: "og:title", content: "{title}" }
158        document::Meta { property: "og:description", content: "{description}" }
159        document::Meta { property: "og:type", content: "website" }
160        if let Some(ref url) = canonical {
161            document::Meta { property: "og:url", content: "{url}" }
162        }
163        document::Meta { name: "twitter:card", content: "summary" }
164        document::Meta { name: "twitter:title", content: "{title}" }
165        document::Meta { name: "twitter:description", content: "{description}" }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::build_article_jsonld;
172
173    #[test]
174    fn jsonld_includes_required_fields() {
175        let out = build_article_jsonld(
176            "Hello",
177            "A post",
178            Some("https://example.com/blog/hello"),
179            "2026-05-21",
180            "Jane",
181            Some("https://example.com/cover.png"),
182        );
183        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
184        assert_eq!(parsed["@context"], "https://schema.org");
185        assert_eq!(parsed["@type"], "Article");
186        assert_eq!(parsed["headline"], "Hello");
187        assert_eq!(parsed["description"], "A post");
188        assert_eq!(parsed["datePublished"], "2026-05-21");
189        assert_eq!(parsed["author"]["@type"], "Person");
190        assert_eq!(parsed["author"]["name"], "Jane");
191        assert_eq!(parsed["image"], "https://example.com/cover.png");
192        assert_eq!(
193            parsed["mainEntityOfPage"]["@id"],
194            "https://example.com/blog/hello"
195        );
196    }
197
198    #[test]
199    fn jsonld_omits_author_and_image_when_missing() {
200        let out = build_article_jsonld("Hello", "A post", None, "2026-05-21", "", None);
201        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
202        assert!(parsed.get("author").is_none());
203        assert!(parsed.get("image").is_none());
204        assert!(parsed.get("mainEntityOfPage").is_none());
205    }
206
207    #[test]
208    fn jsonld_escapes_script_close_sequence() {
209        // A title containing `</script>` must not break out of the <script> tag.
210        let out = build_article_jsonld(
211            "evil </script><script>alert(1)</script>",
212            "",
213            Some("https://example.com/"),
214            "2026-05-21",
215            "",
216            None,
217        );
218        assert!(
219            !out.contains("</script"),
220            "expected </ sequences to be escaped, got: {out}"
221        );
222        assert!(out.contains("<\\/script"));
223    }
224}