Skip to main content

ssg/plugins/
topic_clusters.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Curated metadata for the `topics` taxonomy (#587).
5//!
6//! ssg already derives `/topics/{slug}/` from each page's `topics:`
7//! front matter. What it cannot derive is editorial judgement: which
8//! topic deserves a title that is not just its slug title-cased, what
9//! the topic is *about*, and which of its pages should lead.
10//!
11//! A pillar page is that judgement written down. `_data/topics.toml`
12//! supplies it, and every field is optional:
13//!
14//! ```toml
15//! [post-quantum-cryptography]
16//! title = "Post-Quantum Cryptography"
17//! lede  = "Lattice-based cryptography, NIST PQC standards, and the \
18//!          harvest-now-decrypt-later threat."
19//! banner = "/images/pqc.webp"
20//! order = [
21//!   "quantum-safe-banking-index",
22//!   "securing-the-ledger",
23//! ]
24//! ```
25//!
26//! A file that is absent, empty or unreadable leaves the taxonomy
27//! exactly as it is today — this can only add to a build, never change
28//! one that does not opt in.
29//!
30//! `order` names pages that should lead; anything not named keeps the
31//! order the taxonomy already produced, appended after them. Naming a
32//! page that is not in the topic is not an error: curation and content
33//! drift apart, and failing a build over a stale slug in a data file
34//! helps nobody. The same goes for a `[section]` naming a topic that no
35//! page carries — it is reported once, on stderr, and skipped.
36
37use std::collections::HashMap;
38use std::path::Path;
39
40/// Editorial metadata for one topic.
41#[derive(Debug, Clone, Default, serde::Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct TopicCluster {
44    /// Display title, in place of the slug title-cased.
45    #[serde(default)]
46    pub title: Option<String>,
47    /// One-paragraph introduction shown above the page list.
48    #[serde(default)]
49    pub lede: Option<String>,
50    /// Banner image URL for the pillar page.
51    #[serde(default)]
52    pub banner: Option<String>,
53    /// Page slugs that should lead, in this order.
54    #[serde(default)]
55    pub order: Vec<String>,
56}
57
58/// Topic slug → curated metadata.
59pub type TopicClusters = HashMap<String, TopicCluster>;
60
61/// Relative location of the curated data file.
62const DATA_PATH: [&str; 2] = ["_data", "topics.toml"];
63
64/// Loads `_data/topics.toml`, or an empty map.
65///
66/// Looked for beside `content/` first — `_data/` is a sibling of the
67/// content tree, not part of it, so it is not mistaken for a page — and
68/// then inside it, which is where a project that keeps everything under
69/// one directory will have put it.
70///
71/// Absence is the normal case and is silent. A file that exists but does
72/// not parse is reported on stderr and then ignored: a typo in curation
73/// data should not take a site build down with it.
74#[must_use]
75pub fn load(content_dir: &Path) -> TopicClusters {
76    let mut candidates = Vec::new();
77    if let Some(parent) = content_dir.parent() {
78        candidates.push(parent.join(DATA_PATH[0]).join(DATA_PATH[1]));
79    }
80    candidates.push(content_dir.join(DATA_PATH[0]).join(DATA_PATH[1]));
81
82    let Some((path, text)) = candidates
83        .into_iter()
84        .find_map(|p| std::fs::read_to_string(&p).ok().map(|t| (p, t)))
85    else {
86        return TopicClusters::new();
87    };
88    match toml::from_str::<TopicClusters>(&text) {
89        Ok(clusters) => clusters,
90        Err(e) => {
91            eprintln!(
92                "[topics] {} could not be parsed, ignoring it: {e}",
93                path.display()
94            );
95            TopicClusters::new()
96        }
97    }
98}
99
100/// Reports `[section]`s naming a topic no page carries.
101///
102/// Curation drifts: a topic is renamed, its last page is unpublished, a
103/// slug is mistyped. None of that should fail a build, but a silent
104/// no-op is how a pillar page goes missing without anyone noticing.
105pub fn warn_unknown(clusters: &TopicClusters, known: &[String]) {
106    let mut unknown: Vec<&str> = clusters
107        .keys()
108        .filter(|k| !known.iter().any(|t| t == *k))
109        .map(String::as_str)
110        .collect();
111    unknown.sort_unstable();
112    for key in unknown {
113        eprintln!(
114            "[topics] _data/topics.toml describes '{key}', which no page \
115             lists under `topics:`; skipping it."
116        );
117    }
118}
119
120/// Moves the pages named in `order` to the front, in that order.
121///
122/// Everything else keeps the order it already had. A named slug that is
123/// not present is skipped rather than inserted, so a stale entry costs
124/// nothing.
125pub fn apply_order<T>(
126    order: &[String],
127    pages: &mut Vec<T>,
128    url_of: impl Fn(&T) -> &str,
129) {
130    if order.is_empty() {
131        return;
132    }
133    let mut leading: Vec<T> = Vec::new();
134    for want in order {
135        if let Some(pos) = pages
136            .iter()
137            .position(|p| url_slug(url_of(p)) == want.as_str())
138        {
139            leading.push(pages.remove(pos));
140        }
141    }
142    if leading.is_empty() {
143        return;
144    }
145    leading.append(pages);
146    *pages = leading;
147}
148
149/// The slug a page URL ends in, in any of the shapes ssg emits.
150///
151/// `/posts/c/`, `/posts/c/index.html` and `/c.html` all name the page `c`,
152/// which is what an author writes in `order`. Matching the raw final
153/// segment would make the data file depend on which permalink style the
154/// site happens to use.
155fn url_slug(url: &str) -> &str {
156    let trimmed = url.trim_end_matches('/');
157    let last = trimmed.rsplit('/').next().unwrap_or(trimmed);
158    if last.eq_ignore_ascii_case("index.html") {
159        // `/posts/c/index.html` — the slug is the directory above.
160        return trimmed
161            .rsplit('/')
162            .nth(1)
163            .unwrap_or(last)
164            .trim_end_matches(".html");
165    }
166    last.strip_suffix(".html").unwrap_or(last)
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    /// `content/` is passed in; the data file sits beside it.
174    fn content_dir_with(toml: &str) -> tempfile::TempDir {
175        let dir = tempfile::tempdir().expect("tempdir");
176        std::fs::create_dir_all(dir.path().join("content")).expect("mkdir");
177        let data = dir.path().join("_data");
178        std::fs::create_dir_all(&data).expect("mkdir");
179        std::fs::write(data.join("topics.toml"), toml).expect("write");
180        dir
181    }
182
183    #[test]
184    fn missing_file_is_not_an_error() {
185        let dir = tempfile::tempdir().expect("tempdir");
186        assert!(load(&dir.path().join("content")).is_empty());
187    }
188
189    #[test]
190    fn found_beside_the_content_directory() {
191        let dir = content_dir_with("[payments]\ntitle = \"Payments\"\n");
192        let clusters = load(&dir.path().join("content"));
193        assert_eq!(
194            clusters.get("payments").and_then(|c| c.title.as_deref()),
195            Some("Payments")
196        );
197    }
198
199    #[test]
200    fn unparseable_file_is_ignored_rather_than_fatal() {
201        let dir = tempfile::tempdir().expect("tempdir");
202        let data = dir.path().join("_data");
203        std::fs::create_dir_all(&data).expect("mkdir");
204        std::fs::write(data.join("topics.toml"), "this is not toml [[[")
205            .expect("write");
206        assert!(load(&dir.path().join("content")).is_empty());
207    }
208
209    #[test]
210    fn every_field_is_optional() {
211        let dir = tempfile::tempdir().expect("tempdir");
212        let data = dir.path().join("_data");
213        std::fs::create_dir_all(&data).expect("mkdir");
214        std::fs::write(data.join("topics.toml"), "[payments]\n")
215            .expect("write");
216        let clusters = load(&dir.path().join("content"));
217        let payments = clusters.get("payments").expect("section loaded");
218        assert!(payments.title.is_none());
219        assert!(payments.lede.is_none());
220        assert!(payments.order.is_empty());
221    }
222
223    #[test]
224    fn order_moves_named_pages_to_the_front() {
225        let mut pages = vec![
226            ("A".to_string(), "/posts/a/".to_string()),
227            ("B".to_string(), "/posts/b/".to_string()),
228            ("C".to_string(), "/posts/c/".to_string()),
229        ];
230        apply_order(&["c".to_string(), "a".to_string()], &mut pages, |p| &p.1);
231        let order: Vec<&str> = pages.iter().map(|(t, _)| t.as_str()).collect();
232        assert_eq!(order, ["C", "A", "B"]);
233    }
234
235    /// Curation and content drift apart; a stale slug must not reorder
236    /// anything or panic.
237    #[test]
238    fn order_ignores_a_slug_that_is_not_in_the_topic() {
239        let mut pages = vec![
240            ("A".to_string(), "/posts/a/".to_string()),
241            ("B".to_string(), "/posts/b/".to_string()),
242        ];
243        apply_order(&["gone".to_string(), "b".to_string()], &mut pages, |p| {
244            &p.1
245        });
246        let order: Vec<&str> = pages.iter().map(|(t, _)| t.as_str()).collect();
247        assert_eq!(order, ["B", "A"]);
248    }
249
250    /// The same page is named `c` whichever permalink style a site uses.
251    #[test]
252    fn order_matches_every_url_shape_ssg_emits() {
253        for url in ["/posts/c/", "/posts/c/index.html", "/c.html", "/c"] {
254            let mut pages = vec![
255                ("A".to_string(), "/a.html".to_string()),
256                ("C".to_string(), url.to_string()),
257            ];
258            apply_order(&["c".to_string()], &mut pages, |p| &p.1);
259            assert_eq!(
260                pages.first().map(|(t, _)| t.as_str()),
261                Some("C"),
262                "{url} did not match the slug `c`"
263            );
264        }
265    }
266
267    #[test]
268    fn empty_order_leaves_the_taxonomy_order_alone() {
269        let mut pages = vec![
270            ("A".to_string(), "/posts/a/".to_string()),
271            ("B".to_string(), "/posts/b/".to_string()),
272        ];
273        apply_order(&[], &mut pages, |p| &p.1);
274        let order: Vec<&str> = pages.iter().map(|(t, _)| t.as_str()).collect();
275        assert_eq!(order, ["A", "B"]);
276    }
277}