ssg/plugins/seo/
canonical.rs1use super::helpers::escape_attr;
7use crate::error::SsgError;
8use crate::plugin::{Plugin, PluginContext};
9#[cfg(test)]
10use crate::util::head_dom::remove_canonical_links;
11use crate::util::head_dom::replace_canonical_link;
12use anyhow::Result;
13use std::path::Path;
14
15#[derive(Debug, Clone)]
34pub struct CanonicalPlugin {
35 base_url: String,
36}
37
38impl CanonicalPlugin {
39 #[must_use]
51 pub fn new(base_url: impl Into<String>) -> Self {
52 Self {
53 base_url: base_url.into(),
54 }
55 }
56}
57
58impl Plugin for CanonicalPlugin {
59 fn name(&self) -> &'static str {
60 "canonical"
61 }
62
63 fn has_transform(&self) -> bool {
64 true
65 }
66
67 fn transform_html(
68 &self,
69 html: &str,
70 path: &Path,
71 ctx: &PluginContext,
72 ) -> Result<String, SsgError> {
73 let rel_path = path
74 .strip_prefix(&ctx.site_dir)
75 .unwrap_or(path)
76 .to_string_lossy()
77 .replace('\\', "/");
78
79 let tag = build_canonical_tag(&self.base_url, &rel_path);
80 Ok(replace_canonical_link(html, &tag))
81 }
82
83 fn after_compile(&self, _ctx: &PluginContext) -> Result<(), SsgError> {
84 Ok(())
85 }
86}
87
88fn build_canonical_tag(base: &str, rel_path: &str) -> String {
97 let canonical_url = crate::urls::derive_page_url(base, rel_path);
98 format!(
99 "<link rel=\"canonical\" href=\"{}\">",
100 escape_attr(&canonical_url)
101 )
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107 use crate::plugin::PluginContext;
108 use std::path::Path;
109 use tempfile::tempdir;
110
111 fn ctx(site: &Path) -> PluginContext {
112 PluginContext::new(
113 Path::new("content"),
114 Path::new("build"),
115 site,
116 Path::new("templates"),
117 )
118 }
119
120 #[test]
121 fn name_is_stable() {
122 assert_eq!(CanonicalPlugin::new("https://x").name(), "canonical");
123 }
124
125 #[test]
126 fn new_accepts_string_or_str() {
127 let _ = CanonicalPlugin::new("https://a");
128 let _ = CanonicalPlugin::new(String::from("https://b"));
129 }
130
131 #[test]
132 fn no_op_when_site_dir_missing() {
133 let dir = tempdir().unwrap();
134 CanonicalPlugin::new("https://x")
135 .after_compile(&ctx(&dir.path().join("nope")))
136 .unwrap();
137 }
138
139 #[test]
140 fn build_canonical_tag_joins_base_and_rel_path() {
141 let tag = build_canonical_tag("https://example.com", "blog/post.html");
142 assert_eq!(
143 tag,
144 r#"<link rel="canonical" href="https://example.com/blog/post.html">"#
145 );
146 }
147
148 #[test]
149 fn build_canonical_tag_collapses_index_html_to_directory_url() {
150 let tag =
155 build_canonical_tag("https://example.com", "blog/post/index.html");
156 assert_eq!(
157 tag,
158 r#"<link rel="canonical" href="https://example.com/blog/post/">"#
159 );
160 let root = build_canonical_tag("https://example.com", "index.html");
162 assert_eq!(
163 root,
164 r#"<link rel="canonical" href="https://example.com/">"#
165 );
166 }
167
168 #[test]
169 fn build_canonical_tag_escapes_href_attribute_value() {
170 let tag = build_canonical_tag("https://example.com", "x?a=1&b=2");
171 assert!(
173 tag.contains("&"),
174 "ampersand in URL must be HTML-escaped: {tag}"
175 );
176 }
177
178 #[test]
179 fn remove_existing_canonicals_no_op_when_none_present() {
180 let html = "<head><title>x</title></head>";
181 assert_eq!(remove_canonical_links(html), html);
182 }
183
184 #[test]
185 fn remove_existing_canonicals_strips_double_quoted() {
186 let html = r#"<head><link rel="canonical" href="/old"><title>x</title></head>"#;
187 let out = remove_canonical_links(html);
188 assert!(!out.contains("rel=\"canonical\""));
189 assert!(out.contains("<title>x</title>"));
190 }
191
192 #[test]
193 fn remove_existing_canonicals_strips_single_quoted() {
194 let html = "<head><link rel='canonical' href='/old'></head>";
195 let out = remove_canonical_links(html);
196 assert!(!out.contains("canonical"));
197 }
198
199 #[test]
200 fn remove_existing_canonicals_strips_unquoted() {
201 let html = "<head><link rel=canonical href=/old></head>";
202 let out = remove_canonical_links(html);
203 assert!(!out.contains("canonical"));
204 }
205
206 #[test]
207 fn remove_existing_canonicals_strips_multiple() {
208 let html = r#"<head>
209 <link rel="canonical" href="/a">
210 <link rel="canonical" href="/b">
211 </head>"#;
212 let out = remove_canonical_links(html);
213 assert!(!out.contains("rel=\"canonical\""));
214 }
215
216 #[test]
217 fn transform_html_injects_canonical() {
218 let dir = tempdir().unwrap();
219 let c = ctx(dir.path());
220 let html = "<html><head></head><body></body></html>";
221 let page_path = dir.path().join("page.html");
222 let after = CanonicalPlugin::new("https://example.com")
223 .transform_html(html, &page_path, &c)
224 .unwrap();
225 assert!(
226 after.contains(r#"<link rel="canonical""#),
227 "canonical link should be injected: {after}"
228 );
229 }
230
231 #[test]
232 fn transform_html_replaces_existing_canonical_with_correct_one() {
233 let dir = tempdir().unwrap();
234 let c = ctx(dir.path());
235 let html =
236 r#"<html><head><link rel="canonical" href="/wrong"></head></html>"#;
237 let page_path = dir.path().join("page.html");
238 let after = CanonicalPlugin::new("https://example.com")
239 .transform_html(html, &page_path, &c)
240 .unwrap();
241 assert!(
242 after.contains("https://example.com"),
243 "wrong canonical replaced with correct: {after}"
244 );
245 assert!(
246 !after.contains("/wrong"),
247 "old canonical should be gone: {after}"
248 );
249 }
250
251 #[test]
252 fn transform_html_trims_trailing_slash_on_base_url() {
253 let dir = tempdir().unwrap();
254 let c = ctx(dir.path());
255 let html = "<html><head></head></html>";
256 let page_path = dir.path().join("page.html");
257 let after = CanonicalPlugin::new("https://example.com/")
258 .transform_html(html, &page_path, &c)
259 .unwrap();
260 assert!(
261 !after.contains("com//page.html"),
262 "no double-slash after trim: {after}"
263 );
264 }
265
266 #[test]
267 fn transform_html_handles_html_without_head_tag() {
268 let dir = tempdir().unwrap();
269 let c = ctx(dir.path());
270 let raw = "<!doctype html><html><body>only</body></html>";
271 let page_path = dir.path().join("frag.html");
272 let after = CanonicalPlugin::new("https://example.com")
273 .transform_html(raw, &page_path, &c)
274 .unwrap();
275 assert_eq!(after, raw);
276 }
277}