Skip to main content

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}