Skip to main content

dioxus_docs_kit/components/blog/
blog_meta.rs

1use crate::components::managed_head::{HeadTag, ManagedPageHead};
2use dioxus::prelude::*;
3
4use crate::BlogContext;
5use crate::blog::registry::BlogRegistry;
6use crate::components::seo::{join_site_url, jsonld_to_string};
7
8/// Build a schema.org Article JSON-LD string, with `</` escaped to `<\/` so the
9/// payload cannot break out of its `<script>` container.
10fn build_article_jsonld(
11    title: &str,
12    description: &str,
13    url: Option<&str>,
14    date: &str,
15    author_name: &str,
16    image: Option<&str>,
17) -> String {
18    let mut payload = serde_json::json!({
19        "@context": "https://schema.org",
20        "@type": "Article",
21        "headline": title,
22        "description": description,
23        "datePublished": date,
24    });
25
26    if let Some(url) = url {
27        payload["mainEntityOfPage"] = serde_json::json!({
28            "@type": "WebPage",
29            "@id": url,
30        });
31    }
32    if !author_name.is_empty() {
33        payload["author"] = serde_json::json!({
34            "@type": "Person",
35            "name": author_name,
36        });
37    }
38    if let Some(image) = image {
39        payload["image"] = serde_json::Value::String(image.to_string());
40    }
41
42    jsonld_to_string(&payload)
43}
44
45/// Injects Open Graph / SEO meta tags and document title for a single blog post.
46///
47/// Reads `auto_meta` and `site_url` from [`BlogContext`]. When `auto_meta` is
48/// off, emits nothing. Otherwise emits title/description, Open Graph, Twitter
49/// Card, article metadata, and schema.org Article JSON-LD from frontmatter.
50/// Canonical, `og:url`, and the JSON-LD `@id` are only emitted when `site_url`
51/// is also set.
52#[component]
53pub fn BlogPostMeta(slug: String) -> Element {
54    let registry = use_context::<&'static BlogRegistry>();
55    let ctx = use_context::<BlogContext>();
56
57    if !ctx.auto_meta {
58        return rsx! {};
59    }
60
61    let post = match registry.get_post(&slug) {
62        Some(p) => p,
63        None => return rsx! {},
64    };
65
66    let title = &post.frontmatter.title;
67    let description = post.frontmatter.description.as_deref().unwrap_or("");
68    let canonical = ctx
69        .site_url
70        .as_deref()
71        .map(|origin| join_site_url(origin, &ctx.base_path, &slug));
72    // Root-relative `<base_path>/<slug>.md`; `join_site_url` with an empty origin
73    // yields the path portion only.
74    let markdown_href = ctx
75        .markdown_alternate
76        .then(|| format!("{}.md", join_site_url("", &ctx.base_path, &slug)));
77    let date = &post.frontmatter.date;
78    let author_name = registry
79        .get_author(&post.frontmatter.author)
80        .map(|a| a.name.as_str())
81        .unwrap_or("");
82
83    let json_ld = build_article_jsonld(
84        title,
85        description,
86        canonical.as_deref(),
87        date,
88        author_name,
89        post.frontmatter.cover_image.as_deref(),
90    );
91
92    let mut tags = listing_tags(
93        title,
94        description,
95        canonical.as_deref(),
96        post.frontmatter.cover_image.as_deref(),
97    );
98    tags.retain(|tag| tag != &HeadTag::meta("property", "og:type", "website"));
99    tags.push(HeadTag::meta("property", "og:type", "article"));
100    tags.push(HeadTag::meta("property", "article:published_time", date));
101    if !author_name.is_empty() {
102        tags.push(HeadTag::meta("property", "article:author", author_name));
103    }
104    for tag in &post.frontmatter.tags {
105        tags.push(HeadTag::meta("property", "article:tag", tag));
106    }
107    if let Some(href) = markdown_href {
108        tags.push(HeadTag::link("alternate", &href, Some("text/markdown")));
109    }
110    tags.push(HeadTag::jsonld(json_ld));
111    rsx! { ManagedPageHead { title: title.clone(), tags } }
112}
113
114/// Injects basic SEO meta tags for the blog index/listing page.
115///
116/// Reads `auto_meta` and `site_url` from [`BlogContext`]. When `auto_meta` is
117/// off, emits nothing. Canonical and `og:url` only emit when `site_url` is set.
118#[component]
119pub fn BlogIndexMeta(title: String, description: String) -> Element {
120    let ctx = use_context::<BlogContext>();
121
122    if !ctx.auto_meta {
123        return rsx! {};
124    }
125
126    let canonical = ctx
127        .site_url
128        .as_deref()
129        .map(|origin| join_site_url(origin, &ctx.base_path, ""));
130
131    let tags = listing_tags(&title, &description, canonical.as_deref(), None);
132    rsx! { ManagedPageHead { title, tags } }
133}
134
135pub(super) fn listing_tags(
136    title: &str,
137    description: &str,
138    canonical: Option<&str>,
139    image: Option<&str>,
140) -> Vec<HeadTag> {
141    let mut tags = vec![
142        HeadTag::meta("name", "description", description),
143        HeadTag::meta("property", "og:title", title),
144        HeadTag::meta("property", "og:description", description),
145        HeadTag::meta("property", "og:type", "website"),
146        HeadTag::meta(
147            "name",
148            "twitter:card",
149            if image.is_some() {
150                "summary_large_image"
151            } else {
152                "summary"
153            },
154        ),
155        HeadTag::meta("name", "twitter:title", title),
156        HeadTag::meta("name", "twitter:description", description),
157    ];
158    if let Some(url) = canonical {
159        tags.push(HeadTag::link("canonical", url, None));
160        tags.push(HeadTag::meta("property", "og:url", url));
161    }
162    if let Some(image) = image {
163        tags.push(HeadTag::meta("property", "og:image", image));
164        tags.push(HeadTag::meta("name", "twitter:image", image));
165    }
166    tags
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}