dioxus_docs_kit/components/blog/
blog_meta.rs1use 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
8fn 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#[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 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#[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 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}