ssg/plugins/listings.rs
1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Named, filtered, paginated listings (#587).
5//!
6//! [`crate::pagination`] already paginates: every dated page, newest
7//! first, at `/page/N/`. One sequence, no name, no way to ask for a
8//! subset. A site with a decade of posts, three languages and a dozen
9//! tags cannot browse any of that.
10//!
11//! A listing is a named subset with its own URL space:
12//!
13//! ```toml
14//! [[listings]]
15//! name = "archive" # /archive/ and /archive/page/N/
16//! title = "Archive"
17//! per_page = 20
18//!
19//! [[listings]]
20//! name = "rust"
21//! title = "Writing about Rust"
22//! tag = "rust"
23//! after = "2026-01-01"
24//! by_year = true # also /rust/2026/
25//! ```
26//!
27//! Every filter is optional and they combine with AND — a listing with
28//! no filters is every dated page, which is what `/page/N/` already
29//! gives you under a name of your choosing.
30//!
31//! What this deliberately does not have is the issue's "custom
32//! predicate". A predicate is code, and a config file that grows an
33//! expression language has usually taken a wrong turn; a site needing
34//! one can write a plugin, which is the supported way to run code in a
35//! build.
36
37use serde::{Deserialize, Serialize};
38
39/// One named listing.
40#[derive(Debug, Clone, Default, Serialize, Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct ListingConfig {
43 /// URL segment and directory name: `archive` → `/archive/`.
44 pub name: String,
45 /// Heading for the listing. Defaults to `name` when absent.
46 #[serde(default)]
47 pub title: Option<String>,
48 /// Items per page. Zero or absent means the plugin default.
49 #[serde(default)]
50 pub per_page: Option<usize>,
51 /// Only pages carrying this tag.
52 #[serde(default)]
53 pub tag: Option<String>,
54 /// Only pages carrying this category.
55 #[serde(default)]
56 pub category: Option<String>,
57 /// Only pages carrying this topic.
58 #[serde(default)]
59 pub topic: Option<String>,
60 /// Only pages in this language.
61 #[serde(default)]
62 pub language: Option<String>,
63 /// Only pages dated on or after this (`YYYY-MM-DD`).
64 #[serde(default)]
65 pub after: Option<String>,
66 /// Only pages dated on or before this (`YYYY-MM-DD`).
67 #[serde(default)]
68 pub before: Option<String>,
69 /// Also emit `/{name}/{year}/` for each year present.
70 #[serde(default)]
71 pub by_year: bool,
72}
73
74impl ListingConfig {
75 /// The heading to render, falling back to the name.
76 #[must_use]
77 pub fn display_title(&self) -> &str {
78 self.title.as_deref().unwrap_or(&self.name)
79 }
80
81 /// Whether this listing filters at all.
82 ///
83 /// A listing with no filters is every dated page. That is legitimate
84 /// — it is how you give `/page/N/` a name — but it is worth being
85 /// able to say so.
86 #[must_use]
87 pub const fn is_unfiltered(&self) -> bool {
88 self.tag.is_none()
89 && self.category.is_none()
90 && self.topic.is_none()
91 && self.language.is_none()
92 && self.after.is_none()
93 && self.before.is_none()
94 }
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100
101 #[test]
102 fn title_falls_back_to_the_name() {
103 let l = ListingConfig {
104 name: "archive".to_string(),
105 ..ListingConfig::default()
106 };
107 assert_eq!(l.display_title(), "archive");
108 }
109
110 #[test]
111 fn a_listing_with_no_filters_says_so() {
112 let mut l = ListingConfig {
113 name: "all".to_string(),
114 ..ListingConfig::default()
115 };
116 assert!(l.is_unfiltered());
117 l.tag = Some("rust".to_string());
118 assert!(!l.is_unfiltered());
119 }
120
121 /// A typo in a listing section should be reported, not ignored: a
122 /// silently dropped filter produces a listing that looks right and
123 /// lists the wrong pages.
124 #[test]
125 fn an_unknown_field_is_rejected() {
126 let err = toml::from_str::<ListingConfig>(
127 "name = \"archive\"\ntagg = \"rust\"\n",
128 );
129 assert!(err.is_err(), "unknown field must not be silently dropped");
130 }
131}