ssg/plugins/
topic_clusters.rs1use std::collections::HashMap;
38use std::path::Path;
39
40#[derive(Debug, Clone, Default, serde::Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct TopicCluster {
44 #[serde(default)]
46 pub title: Option<String>,
47 #[serde(default)]
49 pub lede: Option<String>,
50 #[serde(default)]
52 pub banner: Option<String>,
53 #[serde(default)]
55 pub order: Vec<String>,
56}
57
58pub type TopicClusters = HashMap<String, TopicCluster>;
60
61const DATA_PATH: [&str; 2] = ["_data", "topics.toml"];
63
64#[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
100pub 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
120pub 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
149fn 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 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 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 #[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 #[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}