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::fs;
71use std::path::{Path, PathBuf};
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
193/// Appends every `.md` / `.markdown` file under `dir`, sorted, to `out`.
194///
195/// Delegates to the shared walker, which bounds the depth at
196/// [`crate::MAX_DIR_DEPTH`] and enters each real directory once, so a
197/// symlink loop in the content tree cannot repeat or multiply entries.
198fn walk_markdown(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
199    out.extend(crate::walk::collect_files_matching(
200        dir,
201        crate::MAX_DIR_DEPTH,
202        |p| {
203            p.extension().is_some_and(|e| {
204                e.eq_ignore_ascii_case("md")
205                    || e.eq_ignore_ascii_case("markdown")
206            })
207        },
208    )?);
209    Ok(())
210}
211
212fn load_entry<T: DeserializeOwned>(path: &Path) -> Result<Option<Entry<T>>> {
213    let raw = fs::read_to_string(path)
214        .with_context(|| format!("read {}", path.display()))?;
215    let Ok((fm, body)) = frontmatter_gen::extract(&raw) else {
216        return Ok(None); // no frontmatter — not part of collection
217    };
218    let json_map = crate::frontmatter::frontmatter_into_json(fm);
219    let json_value = serde_json::Value::Object(json_map.into_iter().collect());
220    let data: T = serde_json::from_value(json_value).with_context(|| {
221        format!("deserialize frontmatter from {}", path.display())
222    })?;
223    let dir_anchor = path.parent().unwrap_or(path);
224    Ok(Some(Entry {
225        data,
226        body: body.to_string(),
227        slug: derive_slug(path, dir_anchor),
228        path: path.to_path_buf(),
229    }))
230}
231
232/// Derives the URL-style slug from a file path:
233///
234/// - `posts/hello-world.md` → `hello-world`
235/// - `posts/about/index.md` → `about` (parent dir name)
236/// - `posts/index.md` → `index`
237fn derive_slug(path: &Path, _dir: &Path) -> String {
238    let stem = path
239        .file_stem()
240        .map(|s| s.to_string_lossy().to_string())
241        .unwrap_or_default();
242    if stem == "index" {
243        if let Some(parent) = path.parent().and_then(Path::file_name) {
244            return parent.to_string_lossy().to_string();
245        }
246    }
247    stem
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use serde::Deserialize;
254    use tempfile::tempdir;
255
256    #[derive(Debug, Deserialize, PartialEq, Eq)]
257    struct Post {
258        title: String,
259        date: String,
260        #[serde(default)]
261        tags: Vec<String>,
262    }
263
264    fn write_post(dir: &Path, name: &str, body: &str) {
265        let path = dir.join(name);
266        if let Some(parent) = path.parent() {
267            fs::create_dir_all(parent).unwrap();
268        }
269        fs::write(path, body).unwrap();
270    }
271
272    #[test]
273    fn derive_slug_uses_file_stem() {
274        let p = PathBuf::from("posts/hello-world.md");
275        assert_eq!(derive_slug(&p, Path::new("posts")), "hello-world");
276    }
277
278    #[test]
279    fn derive_slug_index_uses_parent_dir() {
280        let p = PathBuf::from("posts/about/index.md");
281        assert_eq!(derive_slug(&p, Path::new("posts")), "about");
282    }
283
284    #[test]
285    fn get_collection_loads_typed_entries() {
286        let dir = tempdir().unwrap();
287        // Inline YAML list — frontmatter-gen 0.0.5 doesn't support
288        // the multi-line `- item` form for nested lists. Inline form
289        // (`[rust, ssg]`) is the canonical short syntax it accepts.
290        write_post(
291            dir.path(),
292            "first.md",
293            "---\ntitle: First\ndate: 2026-01-01\ntags: [rust, ssg]\n---\nBody one.\n",
294        );
295        write_post(
296            dir.path(),
297            "second.md",
298            "---\ntitle: Second\ndate: 2026-01-02\n---\nBody two.\n",
299        );
300
301        let posts: Vec<Entry<Post>> = get_collection(dir.path()).unwrap();
302        assert_eq!(posts.len(), 2);
303        // Sorted by slug.
304        assert_eq!(posts[0].slug, "first");
305        assert_eq!(posts[1].slug, "second");
306        assert_eq!(posts[0].data.title, "First");
307        assert!(posts[0].body.starts_with("Body one"));
308    }
309
310    #[test]
311    fn get_collection_skips_files_without_frontmatter() {
312        let dir = tempdir().unwrap();
313        write_post(dir.path(), "naked.md", "# No frontmatter\n");
314        write_post(
315            dir.path(),
316            "ok.md",
317            "---\ntitle: x\ndate: 2026-01-01\n---\n",
318        );
319        let posts: Vec<Entry<Post>> = get_collection(dir.path()).unwrap();
320        assert_eq!(posts.len(), 1);
321        assert_eq!(posts[0].slug, "ok");
322    }
323
324    #[test]
325    fn get_collection_recurses_into_subdirectories() {
326        let dir = tempdir().unwrap();
327        write_post(
328            dir.path(),
329            "a.md",
330            "---\ntitle: A\ndate: 2026-01-01\n---\n",
331        );
332        write_post(
333            dir.path(),
334            "nested/b.md",
335            "---\ntitle: B\ndate: 2026-01-02\n---\n",
336        );
337        let posts: Vec<Entry<Post>> = get_collection(dir.path()).unwrap();
338        assert_eq!(posts.len(), 2);
339    }
340
341    #[test]
342    fn get_collection_returns_error_with_path_context_on_bad_yaml() {
343        let dir = tempdir().unwrap();
344        write_post(
345            dir.path(),
346            "broken.md",
347            "---\ntitle: 12\ndate: 2026-01-01\n---\n",
348        );
349        // `title` is required to be a String; passing 12 deserialises
350        // ok actually because serde-yml coerces. Make a real type
351        // mismatch:
352        write_post(
353            dir.path(),
354            "bad.md",
355            "---\ntitle:\n  - a list\ndate: 2026-01-01\n---\n",
356        );
357        let err = get_collection::<Post>(dir.path()).unwrap_err();
358        let chain: String = err
359            .chain()
360            .map(|c| c.to_string())
361            .collect::<Vec<_>>()
362            .join("\n");
363        assert!(
364            chain.contains("bad.md") || chain.contains("broken.md"),
365            "expected file path in error chain, got: {chain}"
366        );
367    }
368
369    #[test]
370    fn get_entry_finds_by_slug() {
371        let dir = tempdir().unwrap();
372        write_post(
373            dir.path(),
374            "hello.md",
375            "---\ntitle: H\ndate: 2026-01-01\n---\nbody\n",
376        );
377        let post: Option<Entry<Post>> = get_entry(dir.path(), "hello").unwrap();
378        assert!(post.is_some());
379        assert_eq!(post.unwrap().data.title, "H");
380    }
381
382    #[test]
383    fn get_entry_returns_none_for_unknown_slug() {
384        let dir = tempdir().unwrap();
385        write_post(
386            dir.path(),
387            "exists.md",
388            "---\ntitle: E\ndate: 2026-01-01\n---\n",
389        );
390        let post: Option<Entry<Post>> =
391            get_entry(dir.path(), "missing").unwrap();
392        assert!(post.is_none());
393    }
394
395    #[test]
396    fn get_collection_empty_dir_returns_empty_vec() {
397        let dir = tempdir().unwrap();
398        let posts: Vec<Entry<Post>> = get_collection(dir.path()).unwrap();
399        assert!(posts.is_empty());
400    }
401
402    #[test]
403    fn get_collection_missing_dir_returns_empty_vec() {
404        let posts: Vec<Entry<Post>> =
405            get_collection("/nonexistent/path/here").unwrap();
406        assert!(posts.is_empty());
407    }
408
409    #[test]
410    fn derive_slug_root_index_falls_back_to_stem() {
411        // "index.md" at the tree root: parent().file_name() is None,
412        // so the parent-name branch is skipped and the stem is used.
413        let p = PathBuf::from("index.md");
414        assert_eq!(derive_slug(&p, Path::new("")), "index");
415    }
416
417    #[test]
418    fn walk_markdown_accepts_markdown_extension_and_skips_others() {
419        let dir = tempdir().unwrap();
420        write_post(
421            dir.path(),
422            "long.markdown",
423            "---\ntitle: L\ndate: 2026-01-01\n---\nBody\n",
424        );
425        write_post(dir.path(), "notes.txt", "not collected");
426
427        let posts: Vec<Entry<Post>> = get_collection(dir.path()).unwrap();
428        assert_eq!(posts.len(), 1);
429        assert_eq!(posts[0].slug, "long");
430    }
431
432    /// Lists what `walk_markdown` finds under `root`, relative to it.
433    #[cfg(unix)]
434    fn walk_relative(root: &Path) -> Vec<PathBuf> {
435        let mut files = Vec::new();
436        walk_markdown(root, &mut files).unwrap();
437        files
438            .iter()
439            .map(|p| p.strip_prefix(root).unwrap().to_path_buf())
440            .collect()
441    }
442
443    #[cfg(unix)]
444    #[test]
445    fn walk_markdown_does_not_follow_a_symlink_loop() {
446        use std::os::unix::fs::symlink;
447
448        let dir = tempdir().unwrap();
449        let root = dir.path();
450        fs::create_dir_all(root.join("sub/deep")).unwrap();
451        for rel in ["zeta.md", "alpha.md", "sub/deep/beta.markdown"] {
452            fs::write(root.join(rel), "x").unwrap();
453        }
454        // `sub/loop` points back at the content root.
455        symlink(root, root.join("sub/loop")).unwrap();
456
457        assert_eq!(
458            walk_relative(root),
459            ["alpha.md", "sub/deep/beta.markdown", "zeta.md"]
460                .map(PathBuf::from),
461            "each file once, sorted, nothing reached through the loop"
462        );
463    }
464
465    #[cfg(unix)]
466    #[test]
467    fn walk_markdown_terminates_on_branching_symlink_loops() {
468        use std::os::unix::fs::symlink;
469        use std::sync::mpsc;
470        use std::time::Duration;
471
472        // Two links back to the root in one directory make the
473        // followed tree branch at every level: 2^n paths at depth n.
474        let dir = tempdir().unwrap();
475        let root = dir.path().to_path_buf();
476        fs::write(root.join("a.md"), "x").unwrap();
477        symlink(&root, root.join("l1")).unwrap();
478        symlink(&root, root.join("l2")).unwrap();
479
480        let (tx, rx) = mpsc::channel();
481        let walk_root = root.clone();
482        let _ = std::thread::spawn(move || {
483            let _ = tx.send(walk_relative(&walk_root));
484        });
485        let found = rx
486            .recv_timeout(Duration::from_secs(10))
487            .expect("walk did not finish within 10 s");
488        assert_eq!(found, [PathBuf::from("a.md")]);
489    }
490
491    #[test]
492    fn load_entry_read_failure_carries_path_context() {
493        let err = load_entry::<Post>(Path::new(
494            "/nonexistent-ssg-collections/missing.md",
495        ))
496        .unwrap_err();
497        let msg = format!("{err:?}");
498        assert!(
499            msg.contains("read /nonexistent-ssg-collections"),
500            "got: {msg}"
501        );
502    }
503
504    #[cfg(unix)]
505    #[test]
506    fn get_collection_propagates_unreadable_root_dir_error() {
507        use std::os::unix::fs::PermissionsExt;
508
509        let dir = tempdir().unwrap();
510        let content = dir.path().join("locked");
511        fs::create_dir_all(&content).unwrap();
512        fs::set_permissions(&content, fs::Permissions::from_mode(0o000))
513            .unwrap();
514
515        let result = get_collection::<Post>(&content);
516
517        fs::set_permissions(&content, fs::Permissions::from_mode(0o755))
518            .unwrap();
519        assert!(result.is_err(), "unreadable dir must error");
520    }
521
522    #[cfg(unix)]
523    #[test]
524    fn get_collection_propagates_nested_unreadable_dir_error() {
525        use std::os::unix::fs::PermissionsExt;
526
527        // Drives the recursive walk_markdown `?` error path.
528        let dir = tempdir().unwrap();
529        let nested = dir.path().join("sub");
530        fs::create_dir_all(&nested).unwrap();
531        fs::set_permissions(&nested, fs::Permissions::from_mode(0o000))
532            .unwrap();
533
534        let result = get_collection::<Post>(dir.path());
535
536        fs::set_permissions(&nested, fs::Permissions::from_mode(0o755))
537            .unwrap();
538        assert!(result.is_err(), "nested unreadable dir must error");
539    }
540
541    #[cfg(unix)]
542    #[test]
543    fn get_entry_propagates_unreadable_root_dir_error() {
544        // `get_entry` has its own `walk_markdown(dir, &mut files)?`
545        // call site, distinct from `get_collection`'s. Region coverage
546        // is tracked per call site, so the error arm here must be
547        // exercised independently of the `get_collection` equivalent.
548        use std::os::unix::fs::PermissionsExt;
549
550        let dir = tempdir().unwrap();
551        let content = dir.path().join("locked");
552        fs::create_dir_all(&content).unwrap();
553        fs::set_permissions(&content, fs::Permissions::from_mode(0o000))
554            .unwrap();
555
556        let result = get_entry::<Post>(&content, "whatever");
557
558        fs::set_permissions(&content, fs::Permissions::from_mode(0o755))
559            .unwrap();
560        assert!(result.is_err(), "unreadable dir must error");
561    }
562
563    #[test]
564    fn get_entry_propagates_frontmatter_type_error() {
565        let dir = tempdir().unwrap();
566        write_post(
567            dir.path(),
568            "bad.md",
569            "---\ntitle:\n  - a list\ndate: 2026-01-01\n---\n",
570        );
571        let result = get_entry::<Post>(dir.path(), "bad");
572        assert!(result.is_err(), "type mismatch must propagate");
573    }
574}