dioxus_docs_kit/components/blog/
blog_meta.rs1use dioxus::prelude::*;
2
3use crate::BlogContext;
4use crate::blog::registry::BlogRegistry;
5use crate::components::seo::{join_site_url, jsonld_to_string};
6
7fn 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#[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 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 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 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 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 document::Script { r#type: "application/ld+json", "{json_ld}" }
131 }
132}
133
134#[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 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}