Skip to main content

ssg/plugins/seo/
canonical.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Canonical URL injection plugin.
5
6use 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/// Injects `<link rel="canonical">` tags into HTML files.
16///
17/// For each HTML file missing a canonical link, this plugin computes
18/// the canonical URL from the base URL and the file's relative path,
19/// then injects the tag before `</head>`.
20///
21/// The plugin is idempotent — it will not add a duplicate canonical
22/// link if one already exists.
23///
24/// # Example
25///
26/// ```rust
27/// use ssg::plugin::PluginManager;
28/// use ssg::seo::CanonicalPlugin;
29///
30/// let mut pm = PluginManager::new();
31/// pm.register(CanonicalPlugin::new("https://example.com"));
32/// ```
33#[derive(Debug, Clone)]
34pub struct CanonicalPlugin {
35    base_url: String,
36}
37
38impl CanonicalPlugin {
39    /// Creates a new `CanonicalPlugin` with the given base URL.
40    ///
41    /// # Examples
42    ///
43    /// ```rust
44    /// use ssg::seo::CanonicalPlugin;
45    /// use ssg::plugin::Plugin;
46    ///
47    /// let p = CanonicalPlugin::new("https://example.com");
48    /// assert_eq!(p.name(), "canonical");
49    /// ```
50    #[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
88/// Builds a `<link rel="canonical">` tag for the given base URL and path.
89///
90/// URL derivation is delegated to [`crate::urls::derive_page_url`] —
91/// the single code path shared with the content stager's `permalink:`
92/// injection — so canonical `<link>`, feed `<link>`, and injected
93/// permalinks always agree (spec A2/B1, plan §2 item 1.2, issue #586).
94/// Notably, `index.html` pages collapse to the pretty directory URL
95/// (`{base}/foo/`), matching the Atom feed entry convention.
96fn 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        // spec A2/B1 (plan §2 item 1.2, issue #586): canonical URLs
151        // share `urls::derive_page_url` with the stager's permalink
152        // injection and the Atom feed link convention — `index.html`
153        // collapses to the pretty directory URL.
154        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        // Root index.html → bare base URL with trailing slash.
161        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        // & in href must be escaped to &amp; (what escape_attr does)
172        assert!(
173            tag.contains("&amp;"),
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}