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}