Skip to main content

ssg/core/
urls.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Canonical page-URL derivation shared by staging, feeds, and SEO
5//! output (spec A2/B1, plan §2 item 1.2, issue #586).
6//!
7//! ## Why this module exists
8//!
9//! `staticdatagen`'s RSS generator hard-fails the whole build when a
10//! page lacks a `permalink:` front-matter key (`rss-gen`:
11//! "channel.link is missing"). ssg stages content through
12//! [`content_stager`](crate::content_stager) before
13//! `staticdatagen::compile` ever sees it, so ssg can guarantee a
14//! permalink is *always* present by deriving one at staging time.
15//! This module is that single derivation code path — the plan's goal
16//! is that canonical `<link>`, feed `<link>`, and injected
17//! `permalink:` all agree because they all call
18//! [`derive_page_url`].
19//!
20//! ## The URL convention
21//!
22//! Published pages use *pretty* directory URLs with a trailing slash,
23//! matching the Atom feed entry convention already shipped in
24//! `src/plugins/postprocess/atom.rs` (`{base_url}/{rel_path}/`) and
25//! the stager's own tags-stub permalink
26//! (`https://example.invalid/tags/`):
27//!
28//! | Relative output path    | Derived URL                     |
29//! | ----------------------- | ------------------------------- |
30//! | `index.html`            | `{base_url}/`                   |
31//! | `foo/index.html`        | `{base_url}/foo/`               |
32//! | `posts/bar/index.html`  | `{base_url}/posts/bar/`         |
33//! | `feed.xml` (non-index)  | `{base_url}/feed.xml`           |
34//!
35//! Windows path separators are normalised to `/`; percent-encoding is
36//! left untouched (paths are already slugified upstream).
37
38/// Joins a site `base_url` and a page's site-relative output path
39/// into the canonical absolute URL for that page.
40///
41/// Conventions (see module docs for the full table):
42///
43/// - a trailing slash on `base_url` is tolerated (no `//` is emitted);
44/// - `index.html` at any depth collapses to the enclosing directory
45///   URL with a trailing slash — the pretty-URL form the Atom feed
46///   (`src/plugins/postprocess/atom.rs`) already publishes;
47/// - `\` separators are normalised to `/` (Windows builds);
48/// - leading `./` / `/` on the relative path are ignored;
49/// - percent-encoding is passed through as-is.
50///
51/// # Examples
52///
53/// ```rust
54/// use ssg::urls::derive_page_url;
55///
56/// // Root page.
57/// assert_eq!(
58///     derive_page_url("https://example.com", "index.html"),
59///     "https://example.com/"
60/// );
61/// // Pretty directory URL for a nested page.
62/// assert_eq!(
63///     derive_page_url("https://example.com/", "posts/foo/index.html"),
64///     "https://example.com/posts/foo/"
65/// );
66/// // Non-index outputs keep their file name.
67/// assert_eq!(
68///     derive_page_url("https://example.com", "feed.xml"),
69///     "https://example.com/feed.xml"
70/// );
71/// ```
72#[must_use]
73pub fn derive_page_url(base_url: &str, relative_output_path: &str) -> String {
74    let base = base_url.trim_end_matches('/');
75    // Normalise Windows separators, then drop any leading `./` or `/`
76    // so callers can pass either bare or rooted relative paths.
77    let normalised = relative_output_path.replace('\\', "/");
78    let mut rel = normalised.as_str();
79    loop {
80        if let Some(stripped) = rel.strip_prefix("./") {
81            rel = stripped;
82        } else if let Some(stripped) = rel.strip_prefix('/') {
83            rel = stripped;
84        } else {
85            break;
86        }
87    }
88
89    if rel.is_empty() || rel == "index.html" {
90        return format!("{base}/");
91    }
92    if let Some(dir) = rel.strip_suffix("/index.html") {
93        return format!("{base}/{dir}/");
94    }
95    format!("{base}/{rel}")
96}
97
98/// Maps a content-relative markdown source path to the site-relative
99/// output path `staticdatagen`'s compiler will write for it.
100///
101/// Mirrors the compiler's convention exactly (locked by tests below):
102/// `staticdatagen 0.0.9` (`src/utilities/write.rs`,
103/// `get_processed_file_name` + `write_files_to_build_directory`)
104/// writes `foo.md` to `<build>/foo/index.html` and `index.md` to the
105/// build root's `index.html`. The same mapping is documented on
106/// `derive_url` in `src/plugins/isr_manifest.rs`
107/// (`posts/foo.md → /posts/foo/index.html`, `index.md → /index.html`,
108/// `about/index.md → /about/index.html`).
109///
110/// Only the `.md` extension is stripped — matching both the compiler
111/// and the ISR manifest — so a `.markdown` file keeps its full name
112/// as the directory segment, exactly as `staticdatagen` would emit it.
113///
114/// # Examples
115///
116/// ```rust
117/// use ssg::urls::derive_output_rel_path;
118///
119/// assert_eq!(derive_output_rel_path("index.md"), "index.html");
120/// assert_eq!(derive_output_rel_path("foo.md"), "foo/index.html");
121/// assert_eq!(
122///     derive_output_rel_path("posts/foo.md"),
123///     "posts/foo/index.html"
124/// );
125/// assert_eq!(
126///     derive_output_rel_path("about/index.md"),
127///     "about/index.html"
128/// );
129/// ```
130#[must_use]
131pub fn derive_output_rel_path(content_rel_source_path: &str) -> String {
132    let normalised = content_rel_source_path.replace('\\', "/");
133    let rel = normalised.trim_start_matches("./").trim_start_matches('/');
134    let stripped = rel.strip_suffix(".md").unwrap_or(rel);
135    if stripped == "index" {
136        return "index.html".to_string();
137    }
138    if let Some(dir) = stripped.strip_suffix("/index") {
139        return format!("{dir}/index.html");
140    }
141    format!("{stripped}/index.html")
142}
143
144/// Derives the canonical permalink for a content-relative markdown
145/// source path: [`derive_output_rel_path`] piped into
146/// [`derive_page_url`].
147///
148/// This is the value the content stager injects as `permalink:` when
149/// a page's front matter carries neither `permalink` nor `url`
150/// (spec A2/B1, plan §2 item 1.2, issue #586).
151///
152/// # Examples
153///
154/// ```rust
155/// use ssg::urls::derive_permalink;
156///
157/// assert_eq!(
158///     derive_permalink("https://example.com", "posts/foo.md"),
159///     "https://example.com/posts/foo/"
160/// );
161/// assert_eq!(
162///     derive_permalink("https://example.com/", "index.md"),
163///     "https://example.com/"
164/// );
165/// ```
166#[must_use]
167pub fn derive_permalink(
168    base_url: &str,
169    content_rel_source_path: &str,
170) -> String {
171    derive_page_url(base_url, &derive_output_rel_path(content_rel_source_path))
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn root_index_becomes_bare_base_with_trailing_slash() {
180        assert_eq!(
181            derive_page_url("https://example.com", "index.html"),
182            "https://example.com/"
183        );
184    }
185
186    #[test]
187    fn trailing_slash_base_url_does_not_double_slash() {
188        assert_eq!(
189            derive_page_url("https://example.com/", "foo/index.html"),
190            "https://example.com/foo/"
191        );
192    }
193
194    #[test]
195    fn nested_index_html_collapses_to_directory_url() {
196        assert_eq!(
197            derive_page_url("https://example.com", "posts/bar/index.html"),
198            "https://example.com/posts/bar/"
199        );
200    }
201
202    #[test]
203    fn non_index_output_keeps_file_name() {
204        assert_eq!(
205            derive_page_url("https://example.com", "rss.xml"),
206            "https://example.com/rss.xml"
207        );
208    }
209
210    #[test]
211    fn windows_separators_are_normalised() {
212        assert_eq!(
213            derive_page_url("https://example.com", "posts\\bar\\index.html"),
214            "https://example.com/posts/bar/"
215        );
216    }
217
218    #[test]
219    fn leading_dot_slash_and_slash_are_ignored() {
220        assert_eq!(
221            derive_page_url("https://example.com", "./a/index.html"),
222            "https://example.com/a/"
223        );
224        assert_eq!(
225            derive_page_url("https://example.com", "/a/index.html"),
226            "https://example.com/a/"
227        );
228    }
229
230    #[test]
231    fn percent_encoding_is_passed_through() {
232        assert_eq!(
233            derive_page_url("https://example.com", "caf%C3%A9/index.html"),
234            "https://example.com/caf%C3%A9/"
235        );
236    }
237
238    #[test]
239    fn empty_rel_path_yields_base_with_trailing_slash() {
240        assert_eq!(
241            derive_page_url("https://example.com", ""),
242            "https://example.com/"
243        );
244    }
245
246    /// Locks [`derive_output_rel_path`] to the compiler's mapping:
247    /// the three documented shapes from `staticdatagen 0.0.9`
248    /// `src/utilities/write.rs` and `isr_manifest::derive_url`
249    /// (`posts/foo.md → /posts/foo/index.html`,
250    /// `index.md → /index.html`,
251    /// `about/index.md → /about/index.html`). If either side changes
252    /// convention this test flags the disagreement.
253    #[test]
254    fn output_rel_path_agrees_with_compiler_convention() {
255        assert_eq!(
256            derive_output_rel_path("posts/foo.md"),
257            "posts/foo/index.html"
258        );
259        assert_eq!(derive_output_rel_path("index.md"), "index.html");
260        assert_eq!(
261            derive_output_rel_path("about/index.md"),
262            "about/index.html"
263        );
264    }
265
266    #[test]
267    fn markdown_extension_is_kept_when_not_md() {
268        // staticdatagen's get_processed_file_name only strips a known
269        // extension list ("md" among them, "markdown" not) — mirror
270        // it exactly rather than being "helpfully" prettier.
271        assert_eq!(
272            derive_output_rel_path("foo.markdown"),
273            "foo.markdown/index.html"
274        );
275    }
276
277    #[test]
278    fn windows_source_separator_is_normalised_in_output_path() {
279        assert_eq!(
280            derive_output_rel_path("posts\\foo.md"),
281            "posts/foo/index.html"
282        );
283    }
284
285    #[test]
286    fn permalink_composes_both_derivations() {
287        assert_eq!(
288            derive_permalink("https://example.com/", "posts/foo.md"),
289            "https://example.com/posts/foo/"
290        );
291        assert_eq!(
292            derive_permalink("https://example.com", "index.md"),
293            "https://example.com/"
294        );
295        assert_eq!(
296            derive_permalink("https://example.com", "about/index.md"),
297            "https://example.com/about/"
298        );
299    }
300}