Skip to main content

ssg/core/
collections.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Typed content collection API (issue #456).
5//!
6//! Mirrors the ergonomics of Astro's `getCollection` / `getEntry`
7//! and Eleventy's collection helpers, but with **compile-time type
8//! safety** via serde. Authors define a struct that derives
9//! `serde::Deserialize`, then load every Markdown file under a
10//! directory as `Vec<Entry<T>>` with one call.
11//!
12//! # Quick start
13//!
14//! ```no_run
15//! use serde::Deserialize;
16//! use ssg::collections::{get_collection, Entry};
17//!
18//! #[derive(Debug, Deserialize)]
19//! struct BlogPost {
20//!     title: String,
21//!     date: String,
22//!     description: Option<String>,
23//!     #[serde(default)]
24//!     tags: Vec<String>,
25//! }
26//!
27//! # fn main() -> anyhow::Result<()> {
28//! let posts: Vec<Entry<BlogPost>> =
29//!     get_collection("content/blog")?;
30//!
31//! for post in posts {
32//!     println!("{} ({})", post.data.title, post.slug);
33//! }
34//! # Ok(())
35//! # }
36//! ```
37//!
38//! # Why typed?
39//!
40//! Hand-rolling frontmatter access via `serde_yml::Value` or string
41//! lookups produces stringly-typed code that fails at runtime when a
42//! field is renamed or its type changes. The typed API surfaces the
43//! mismatch as a compile error or a clean `Result::Err` at load
44//! time, with the file path in the error chain.
45//!
46//! # Loading semantics
47//!
48//! - **Walks recursively** under the given directory.
49//! - **Markdown only** (`.md`, `.markdown`). Other files are skipped.
50//! - **Skips files without frontmatter** silently — they're treated
51//!   as plain pages outside the collection.
52//! - **Returns parse errors with context**: each error carries the
53//!   absolute path of the file that failed.
54//! - **Slug derivation**: the slug is the file's `stem` (filename
55//!   without extension). `index.md` files in subdirectories use the
56//!   subdirectory name as the slug.
57//! - **Deterministic ordering**: entries are returned sorted by
58//!   slug so consumers that hash the result (e.g. for golden tests
59//!   or perf benchmarks) get stable output.
60//!
61//! # Single-entry access
62//!
63//! [`get_entry`](crate::collections::get_entry) loads exactly one file by slug, returning
64//! `Ok(None)` if no matching `.md` is found. Use this when a page
65//! references another by its known slug (sidebar layouts, related
66//! posts).
67
68use anyhow::{Context, Result};
69use serde::de::DeserializeOwned;
70use std::path::{Path, PathBuf};
71use std::{fs, io};
72
73/// One parsed entry from a content collection.
74///
75/// `data` is the typed frontmatter (your struct), `body` is the raw
76/// Markdown body (everything after the closing `---`). `slug` and
77/// `path` give callers enough information to build URLs and
78/// breadcrumbs without re-parsing the filename.
79#[non_exhaustive]
80#[derive(Debug, Clone)]
81pub struct Entry<T> {
82    /// Parsed frontmatter, deserialised into the caller's struct.
83    pub data: T,
84    /// Raw Markdown body (frontmatter delimiters stripped).
85    pub body: String,
86    /// URL-style slug derived from the filename.
87    pub slug: String,
88    /// Absolute path of the source file on disk.
89    pub path: PathBuf,
90}
91
92/// Loads every Markdown file under `dir` whose frontmatter matches
93/// `T`. Returns entries sorted by slug.
94///
95/// # Errors
96///
97/// - Returns the first I/O error encountered while walking the
98///   directory.
99/// - Returns the first frontmatter deserialisation error, with the
100///   failing path in the error chain (`anyhow::Error::context`).
101///
102/// Files without a frontmatter delimiter are silently skipped — the
103/// collection is for *structured* content, and pages without
104/// frontmatter aren't part of the schema.
105///
106/// # Determinism
107///
108/// Output is sorted by `Entry::slug` (lexicographic). Callers that
109/// hash collections for golden tests or fingerprinting benefit
110/// directly.
111///
112/// # Examples
113///
114/// ```rust
115/// use serde::Deserialize;
116/// use ssg::collections::get_collection;
117/// use tempfile::tempdir;
118/// use std::fs;
119///
120/// #[derive(Debug, Deserialize)]
121/// struct Post { title: String }
122///
123/// let dir = tempdir().unwrap();
124/// fs::write(dir.path().join("a.md"), "---\ntitle: Hi\n---\nBody").unwrap();
125/// let posts: Vec<_> = get_collection::<Post>(dir.path()).unwrap();
126/// assert_eq!(posts.len(), 1);
127/// assert_eq!(posts[0].data.title, "Hi");
128/// ```
129pub fn get_collection<T: DeserializeOwned>(
130    dir: impl AsRef<Path>,
131) -> Result<Vec<Entry<T>>> {
132    let dir = dir.as_ref();
133    let mut files = Vec::new();
134    walk_markdown(dir, &mut files)?;
135    files.sort();
136
137    let mut out = Vec::with_capacity(files.len());
138    for path in files {
139        let entry = load_entry::<T>(&path)?;
140        if let Some(e) = entry {
141            out.push(e);
142        }
143    }
144
145    out.sort_by(|a, b| a.slug.cmp(&b.slug));
146    Ok(out)
147}
148
149/// Loads a single entry from `dir` whose slug matches `slug`.
150///
151/// Returns `Ok(None)` when no Markdown file with that slug exists.
152/// Use [`get_collection`] when you need every entry or when you
153/// don't know the slug ahead of time.
154///
155/// # Examples
156///
157/// ```rust
158/// use serde::Deserialize;
159/// use ssg::collections::get_entry;
160/// use tempfile::tempdir;
161/// use std::fs;
162///
163/// #[derive(Debug, Deserialize)]
164/// struct Post { title: String }
165///
166/// let dir = tempdir().unwrap();
167/// fs::write(dir.path().join("hello.md"), "---\ntitle: Hello\n---\nBody").unwrap();
168/// let entry = get_entry::<Post>(dir.path(), "hello").unwrap();
169/// assert!(entry.is_some());
170/// assert!(get_entry::<Post>(dir.path(), "missing").unwrap().is_none());
171/// ```
172///
173/// # Errors
174///
175/// Same as [`get_collection`].
176pub fn get_entry<T: DeserializeOwned>(
177    dir: impl AsRef<Path>,
178    slug: &str,
179) -> Result<Option<Entry<T>>> {
180    let dir = dir.as_ref();
181    let mut files = Vec::new();
182    walk_markdown(dir, &mut files)?;
183
184    for path in files {
185        let candidate = derive_slug(&path, dir);
186        if candidate == slug {
187            return load_entry::<T>(&path);
188        }
189    }
190    Ok(None)
191}
192
193fn walk_markdown(dir: &Path, out: &mut Vec<PathBuf>) -> io::Result<()> {
194    if !dir.is_dir() {
195        return Ok(());
196    }
197    for entry in fs::read_dir(dir)? {
198        let entry = entry?;
199        let path = entry.path();
200        if path.is_dir() {
201            walk_markdown(&path, out)?;
202        } else if path.extension().is_some_and(|e| {
203            e.eq_ignore_ascii_case("md") || e.eq_ignore_ascii_case("markdown")
204        }) {
205            out.push(path);
206        }
207    }
208    Ok(())
209}
210
211fn load_entry<T: DeserializeOwned>(path: &Path) -> Result<Option<Entry<T>>> {
212    let raw = fs::read_to_string(path)
213        .with_context(|| format!("read {}", path.display()))?;
214    let Ok((fm, body)) = frontmatter_gen::extract(&raw) else {
215        return Ok(None); // no frontmatter — not part of collection
216    };
217    let json_map = crate::frontmatter::frontmatter_into_json(fm);
218    let json_value = serde_json::Value::Object(json_map.into_iter().collect());
219    let data: T = serde_json::from_value(json_value).with_context(|| {
220        format!("deserialize frontmatter from {}", path.display())
221    })?;
222    let dir_anchor = path.parent().unwrap_or(path);
223    Ok(Some(Entry {
224        data,
225        body: body.to_string(),
226        slug: derive_slug(path, dir_anchor),
227        path: path.to_path_buf(),
228    }))
229}
230
231/// Derives the URL-style slug from a file path:
232///
233/// - `posts/hello-world.md` → `hello-world`
234/// - `posts/about/index.md` → `about` (parent dir name)
235/// - `posts/index.md` → `index`
236fn derive_slug(path: &Path, _dir: &Path) -> String {
237    let stem = path
238        .file_stem()
239        .map(|s| s.to_string_lossy().to_string())
240        .unwrap_or_default();
241    if stem == "index" {
242        if let Some(parent) = path.parent().and_then(Path::file_name) {
243            return parent.to_string_lossy().to_string();
244        }
245    }
246    stem
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use serde::Deserialize;
253    use tempfile::tempdir;
254
255    #[derive(Debug, Deserialize, PartialEq, Eq)]
256    struct Post {
257        title: String,
258        date: String,
259        #[serde(default)]
260        tags: Vec<String>,
261    }
262
263    fn write_post(dir: &Path, name: &str, body: &str) {
264        let path = dir.join(name);
265        if let Some(parent) = path.parent() {
266            fs::create_dir_all(parent).unwrap();
267        }
268        fs::write(path, body).unwrap();
269    }
270
271    #[test]
272    fn derive_slug_uses_file_stem() {
273        let p = PathBuf::from("posts/hello-world.md");
274        assert_eq!(derive_slug(&p, Path::new("posts")), "hello-world");
275    }
276
277    #[test]
278    fn derive_slug_index_uses_parent_dir() {
279        let p = PathBuf::from("posts/about/index.md");
280        assert_eq!(derive_slug(&p, Path::new("posts")), "about");
281    }
282
283    #[test]
284    fn get_collection_loads_typed_entries() {
285        let dir = tempdir().unwrap();
286        // Inline YAML list — frontmatter-gen 0.0.5 doesn't support
287        // the multi-line `- item` form for nested lists. Inline form
288        // (`[rust, ssg]`) is the canonical short syntax it accepts.
289        write_post(
290            dir.path(),
291            "first.md",
292            "---\ntitle: First\ndate: 2026-01-01\ntags: [rust, ssg]\n---\nBody one.\n",
293        );
294        write_post(
295            dir.path(),
296            "second.md",
297            "---\ntitle: Second\ndate: 2026-01-02\n---\nBody two.\n",
298        );
299
300        let posts: Vec<Entry<Post>> = get_collection(dir.path()).unwrap();
301        assert_eq!(posts.len(), 2);
302        // Sorted by slug.
303        assert_eq!(posts[0].slug, "first");
304        assert_eq!(posts[1].slug, "second");
305        assert_eq!(posts[0].data.title, "First");
306        assert!(posts[0].body.starts_with("Body one"));
307    }
308
309    #[test]
310    fn get_collection_skips_files_without_frontmatter() {
311        let dir = tempdir().unwrap();
312        write_post(dir.path(), "naked.md", "# No frontmatter\n");
313        write_post(
314            dir.path(),
315            "ok.md",
316            "---\ntitle: x\ndate: 2026-01-01\n---\n",
317        );
318        let posts: Vec<Entry<Post>> = get_collection(dir.path()).unwrap();
319        assert_eq!(posts.len(), 1);
320        assert_eq!(posts[0].slug, "ok");
321    }
322
323    #[test]
324    fn get_collection_recurses_into_subdirectories() {
325        let dir = tempdir().unwrap();
326        write_post(
327            dir.path(),
328            "a.md",
329            "---\ntitle: A\ndate: 2026-01-01\n---\n",
330        );
331        write_post(
332            dir.path(),
333            "nested/b.md",
334            "---\ntitle: B\ndate: 2026-01-02\n---\n",
335        );
336        let posts: Vec<Entry<Post>> = get_collection(dir.path()).unwrap();
337        assert_eq!(posts.len(), 2);
338    }
339
340    #[test]
341    fn get_collection_returns_error_with_path_context_on_bad_yaml() {
342        let dir = tempdir().unwrap();
343        write_post(
344            dir.path(),
345            "broken.md",
346            "---\ntitle: 12\ndate: 2026-01-01\n---\n",
347        );
348        // `title` is required to be a String; passing 12 deserialises
349        // ok actually because serde-yml coerces. Make a real type
350        // mismatch:
351        write_post(
352            dir.path(),
353            "bad.md",
354            "---\ntitle:\n  - a list\ndate: 2026-01-01\n---\n",
355        );
356        let err = get_collection::<Post>(dir.path()).unwrap_err();
357        let chain: String = err
358            .chain()
359            .map(|c| c.to_string())
360            .collect::<Vec<_>>()
361            .join("\n");
362        assert!(
363            chain.contains("bad.md") || chain.contains("broken.md"),
364            "expected file path in error chain, got: {chain}"
365        );
366    }
367
368    #[test]
369    fn get_entry_finds_by_slug() {
370        let dir = tempdir().unwrap();
371        write_post(
372            dir.path(),
373            "hello.md",
374            "---\ntitle: H\ndate: 2026-01-01\n---\nbody\n",
375        );
376        let post: Option<Entry<Post>> = get_entry(dir.path(), "hello").unwrap();
377        assert!(post.is_some());
378        assert_eq!(post.unwrap().data.title, "H");
379    }
380
381    #[test]
382    fn get_entry_returns_none_for_unknown_slug() {
383        let dir = tempdir().unwrap();
384        write_post(
385            dir.path(),
386            "exists.md",
387            "---\ntitle: E\ndate: 2026-01-01\n---\n",
388        );
389        let post: Option<Entry<Post>> =
390            get_entry(dir.path(), "missing").unwrap();
391        assert!(post.is_none());
392    }
393
394    #[test]
395    fn get_collection_empty_dir_returns_empty_vec() {
396        let dir = tempdir().unwrap();
397        let posts: Vec<Entry<Post>> = get_collection(dir.path()).unwrap();
398        assert!(posts.is_empty());
399    }
400
401    #[test]
402    fn get_collection_missing_dir_returns_empty_vec() {
403        let posts: Vec<Entry<Post>> =
404            get_collection("/nonexistent/path/here").unwrap();
405        assert!(posts.is_empty());
406    }
407
408    #[test]
409    fn derive_slug_root_index_falls_back_to_stem() {
410        // "index.md" at the tree root: parent().file_name() is None,
411        // so the parent-name branch is skipped and the stem is used.
412        let p = PathBuf::from("index.md");
413        assert_eq!(derive_slug(&p, Path::new("")), "index");
414    }
415
416    #[test]
417    fn walk_markdown_accepts_markdown_extension_and_skips_others() {
418        let dir = tempdir().unwrap();
419        write_post(
420            dir.path(),
421            "long.markdown",
422            "---\ntitle: L\ndate: 2026-01-01\n---\nBody\n",
423        );
424        write_post(dir.path(), "notes.txt", "not collected");
425
426        let posts: Vec<Entry<Post>> = get_collection(dir.path()).unwrap();
427        assert_eq!(posts.len(), 1);
428        assert_eq!(posts[0].slug, "long");
429    }
430
431    #[test]
432    fn load_entry_read_failure_carries_path_context() {
433        let err = load_entry::<Post>(Path::new(
434            "/nonexistent-ssg-collections/missing.md",
435        ))
436        .unwrap_err();
437        let msg = format!("{err:?}");
438        assert!(
439            msg.contains("read /nonexistent-ssg-collections"),
440            "got: {msg}"
441        );
442    }
443
444    #[cfg(unix)]
445    #[test]
446    fn get_collection_propagates_unreadable_root_dir_error() {
447        use std::os::unix::fs::PermissionsExt;
448
449        let dir = tempdir().unwrap();
450        let content = dir.path().join("locked");
451        fs::create_dir_all(&content).unwrap();
452        fs::set_permissions(&content, fs::Permissions::from_mode(0o000))
453            .unwrap();
454
455        let result = get_collection::<Post>(&content);
456
457        fs::set_permissions(&content, fs::Permissions::from_mode(0o755))
458            .unwrap();
459        assert!(result.is_err(), "unreadable dir must error");
460    }
461
462    #[cfg(unix)]
463    #[test]
464    fn get_collection_propagates_nested_unreadable_dir_error() {
465        use std::os::unix::fs::PermissionsExt;
466
467        // Drives the recursive walk_markdown `?` error path.
468        let dir = tempdir().unwrap();
469        let nested = dir.path().join("sub");
470        fs::create_dir_all(&nested).unwrap();
471        fs::set_permissions(&nested, fs::Permissions::from_mode(0o000))
472            .unwrap();
473
474        let result = get_collection::<Post>(dir.path());
475
476        fs::set_permissions(&nested, fs::Permissions::from_mode(0o755))
477            .unwrap();
478        assert!(result.is_err(), "nested unreadable dir must error");
479    }
480
481    #[cfg(unix)]
482    #[test]
483    fn get_entry_propagates_unreadable_root_dir_error() {
484        // `get_entry` has its own `walk_markdown(dir, &mut files)?`
485        // call site, distinct from `get_collection`'s. Region coverage
486        // is tracked per call site, so the error arm here must be
487        // exercised independently of the `get_collection` equivalent.
488        use std::os::unix::fs::PermissionsExt;
489
490        let dir = tempdir().unwrap();
491        let content = dir.path().join("locked");
492        fs::create_dir_all(&content).unwrap();
493        fs::set_permissions(&content, fs::Permissions::from_mode(0o000))
494            .unwrap();
495
496        let result = get_entry::<Post>(&content, "whatever");
497
498        fs::set_permissions(&content, fs::Permissions::from_mode(0o755))
499            .unwrap();
500        assert!(result.is_err(), "unreadable dir must error");
501    }
502
503    #[test]
504    fn get_entry_propagates_frontmatter_type_error() {
505        let dir = tempdir().unwrap();
506        write_post(
507            dir.path(),
508            "bad.md",
509            "---\ntitle:\n  - a list\ndate: 2026-01-01\n---\n",
510        );
511        let result = get_entry::<Post>(dir.path(), "bad");
512        assert!(result.is_err(), "type mismatch must propagate");
513    }
514}