Skip to main content

ssg/
drafts.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Draft filtering plugin.
5//!
6//! Removes content files with `draft: true` in their frontmatter
7//! before compilation, unless the `--drafts` flag is set.
8
9use crate::plugin::{Plugin, PluginContext};
10use crate::MAX_DIR_DEPTH;
11use anyhow::Result;
12use std::{
13    fs,
14    path::{Path, PathBuf},
15};
16
17/// Plugin that filters draft content before compilation.
18///
19/// In `before_compile`, scans content files for `draft: true` in
20/// frontmatter and renames them to `.md.draft` so staticdatagen
21/// skips them. In `after_compile`, restores the originals.
22#[derive(Debug, Clone, Copy)]
23pub struct DraftPlugin {
24    include_drafts: bool,
25}
26
27impl DraftPlugin {
28    /// Creates a new `DraftPlugin`.
29    ///
30    /// If `include_drafts` is true, draft files are left in place.
31    #[must_use]
32    pub const fn new(include_drafts: bool) -> Self {
33        Self { include_drafts }
34    }
35}
36
37impl Plugin for DraftPlugin {
38    fn name(&self) -> &'static str {
39        "drafts"
40    }
41
42    fn before_compile(&self, ctx: &PluginContext) -> Result<()> {
43        if self.include_drafts || !ctx.content_dir.exists() {
44            return Ok(());
45        }
46
47        let md_files = collect_md_files(&ctx.content_dir)?;
48        let mut hidden = 0usize;
49
50        for path in &md_files {
51            if is_draft(path)? {
52                let draft_path = path.with_extension("md.draft");
53                fs::rename(path, &draft_path)?;
54                hidden += 1;
55            }
56        }
57
58        if hidden > 0 {
59            log::info!(
60                "[drafts] Hidden {hidden} draft file(s) (use --drafts to include)"
61            );
62        }
63        Ok(())
64    }
65
66    fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
67        if self.include_drafts || !ctx.content_dir.exists() {
68            return Ok(());
69        }
70
71        // Restore hidden drafts
72        let draft_files = collect_draft_files(&ctx.content_dir)?;
73        for draft_path in &draft_files {
74            let original = draft_path.with_extension("");
75            if !original.exists() {
76                fs::rename(draft_path, &original)?;
77            }
78        }
79        Ok(())
80    }
81}
82
83/// Checks if a Markdown file has `draft: true` in its frontmatter.
84fn is_draft(path: &Path) -> Result<bool> {
85    let content = fs::read_to_string(path)?;
86
87    // Quick check: look for draft field in YAML frontmatter
88    if !content.starts_with("---") {
89        return Ok(false);
90    }
91
92    // Find the closing ---
93    if let Some(end) = content[3..].find("---") {
94        let frontmatter = &content[3..3 + end];
95        // Check for draft: true (handles various YAML formats)
96        for line in frontmatter.lines() {
97            let trimmed = line.trim();
98            if trimmed == "draft: true"
99                || trimmed == "draft: True"
100                || trimmed == "draft: TRUE"
101                || trimmed == "draft: yes"
102            {
103                return Ok(true);
104            }
105        }
106    }
107
108    Ok(false)
109}
110
111fn collect_md_files(dir: &Path) -> Result<Vec<PathBuf>> {
112    crate::walk::walk_files_bounded_depth(dir, "md", MAX_DIR_DEPTH)
113}
114
115fn collect_draft_files(dir: &Path) -> Result<Vec<PathBuf>> {
116    crate::walk::walk_files_bounded_depth(dir, "draft", MAX_DIR_DEPTH)
117}
118
119#[cfg(test)]
120#[allow(clippy::unwrap_used, clippy::expect_used)]
121mod tests {
122    use super::*;
123    use crate::test_support::init_logger;
124    use std::path::PathBuf;
125    use tempfile::{tempdir, TempDir};
126
127    // -------------------------------------------------------------------
128    // Test fixtures
129    // -------------------------------------------------------------------
130
131    /// Builds `<root>/content` and returns the temp dir guard, the
132    /// content path, and a `PluginContext` rooted at it.
133    fn make_content_layout() -> (TempDir, PathBuf, PluginContext) {
134        init_logger();
135        let dir = tempdir().expect("create tempdir");
136        let content = dir.path().join("content");
137        fs::create_dir_all(&content).expect("mkdir content");
138        let ctx =
139            PluginContext::new(&content, dir.path(), dir.path(), dir.path());
140        (dir, content, ctx)
141    }
142
143    /// Writes a Markdown file with the given frontmatter draft flag.
144    fn write_md(dir: &Path, name: &str, draft_value: Option<&str>) {
145        let body = match draft_value {
146            Some(v) => format!("---\ntitle: T\ndraft: {v}\n---\nbody"),
147            None => "---\ntitle: T\n---\nbody".to_string(),
148        };
149        fs::write(dir.join(name), body).expect("write md");
150    }
151
152    // -------------------------------------------------------------------
153    // Constructor + derive surface
154    // -------------------------------------------------------------------
155
156    #[test]
157    fn new_table_driven_constructs_plugin_with_supplied_flag() {
158        let cases = [(true, true), (false, false)];
159        for (input, expected) in cases {
160            let plugin = DraftPlugin::new(input);
161            assert_eq!(
162                plugin.include_drafts, expected,
163                "include_drafts({input}) should be {expected}"
164            );
165        }
166    }
167
168    #[test]
169    fn draft_plugin_is_copy_after_move() {
170        // Guards the `Copy` derive added in v0.0.34.
171        let plugin = DraftPlugin::new(false);
172        let _copy = plugin;
173        assert_eq!(plugin.name(), "drafts");
174    }
175
176    #[test]
177    fn name_returns_static_drafts_identifier() {
178        assert_eq!(DraftPlugin::new(false).name(), "drafts");
179        assert_eq!(DraftPlugin::new(true).name(), "drafts");
180    }
181
182    // -------------------------------------------------------------------
183    // is_draft — table-driven over every YAML truthy spelling
184    // -------------------------------------------------------------------
185
186    #[test]
187    fn is_draft_table_driven_truthy_values_return_true() {
188        let cases: &[&str] = &[
189            "---\ntitle: T\ndraft: true\n---\n",
190            "---\ntitle: T\ndraft: True\n---\n",
191            "---\ntitle: T\ndraft: TRUE\n---\n",
192            "---\ntitle: T\ndraft: yes\n---\n",
193            // leading/trailing whitespace on the line is trimmed
194            "---\ntitle: T\n  draft: true  \n---\n",
195        ];
196        let dir = tempdir().expect("tempdir");
197        for (i, body) in cases.iter().enumerate() {
198            let path = dir.path().join(format!("d{i}.md"));
199            fs::write(&path, body).unwrap();
200            assert!(
201                is_draft(&path).unwrap(),
202                "case {i} {body:?} should be detected as draft"
203            );
204        }
205    }
206
207    #[test]
208    fn is_draft_table_driven_falsy_values_return_false() {
209        let cases: &[&str] = &[
210            // explicit false
211            "---\ntitle: T\ndraft: false\n---\n",
212            // unrecognised value
213            "---\ntitle: T\ndraft: maybe\n---\n",
214            // missing field entirely
215            "---\ntitle: T\n---\n",
216            // YAML 1.2 strict — `True` is not the same as `true`
217            // in many parsers; we accept it (covered above) but
218            // `tRue` and `Yes` are NOT in the accepted list.
219            "---\ntitle: T\ndraft: tRue\n---\n",
220            "---\ntitle: T\ndraft: Yes\n---\n",
221        ];
222        let dir = tempdir().expect("tempdir");
223        for (i, body) in cases.iter().enumerate() {
224            let path = dir.path().join(format!("p{i}.md"));
225            fs::write(&path, body).unwrap();
226            assert!(
227                !is_draft(&path).unwrap(),
228                "case {i} {body:?} should NOT be detected as draft"
229            );
230        }
231    }
232
233    #[test]
234    fn is_draft_no_frontmatter_returns_false() {
235        // The `!content.starts_with("---")` early return at line 88.
236        let dir = tempdir().expect("tempdir");
237        let path = dir.path().join("plain.md");
238        fs::write(&path, "# No frontmatter\nJust prose.\n").unwrap();
239        assert!(!is_draft(&path).unwrap());
240    }
241
242    #[test]
243    fn is_draft_unterminated_frontmatter_returns_false() {
244        // The `if let Some(end) = ...find("---")` branch at line 93
245        // must take the implicit `None` path when the closing `---`
246        // is missing — function should return Ok(false), not error.
247        let dir = tempdir().expect("tempdir");
248        let path = dir.path().join("unterminated.md");
249        fs::write(&path, "---\ntitle: T\ndraft: true\nno closing fence here\n")
250            .unwrap();
251        assert!(!is_draft(&path).unwrap());
252    }
253
254    #[test]
255    fn is_draft_empty_file_returns_false() {
256        let dir = tempdir().expect("tempdir");
257        let path = dir.path().join("empty.md");
258        fs::write(&path, "").unwrap();
259        assert!(!is_draft(&path).unwrap());
260    }
261
262    #[test]
263    fn is_draft_missing_file_returns_err() {
264        let dir = tempdir().expect("tempdir");
265        let result = is_draft(&dir.path().join("does-not-exist.md"));
266        assert!(result.is_err());
267    }
268
269    // -------------------------------------------------------------------
270    // before_compile — short-circuit paths
271    // -------------------------------------------------------------------
272
273    #[test]
274    fn before_compile_with_include_drafts_does_not_rename_anything() {
275        let (_tmp, content, ctx) = make_content_layout();
276        write_md(&content, "draft.md", Some("true"));
277        write_md(&content, "published.md", None);
278
279        DraftPlugin::new(true).before_compile(&ctx).unwrap();
280        assert!(content.join("draft.md").exists());
281        assert!(!content.join("draft.md.draft").exists());
282        assert!(content.join("published.md").exists());
283    }
284
285    #[test]
286    fn before_compile_missing_content_dir_returns_ok() {
287        // The `!ctx.content_dir.exists()` short-circuit at line 43.
288        let dir = tempdir().expect("tempdir");
289        let missing = dir.path().join("missing-content");
290        let ctx =
291            PluginContext::new(&missing, dir.path(), dir.path(), dir.path());
292
293        DraftPlugin::new(false)
294            .before_compile(&ctx)
295            .expect("missing content dir is not an error");
296        assert!(!missing.exists());
297    }
298
299    #[test]
300    fn before_compile_renames_only_drafts_leaves_published_intact() {
301        let (_tmp, content, ctx) = make_content_layout();
302        write_md(&content, "draft.md", Some("true"));
303        write_md(&content, "published.md", None);
304
305        DraftPlugin::new(false).before_compile(&ctx).unwrap();
306        assert!(!content.join("draft.md").exists());
307        assert!(content.join("draft.md.draft").exists());
308        assert!(content.join("published.md").exists());
309    }
310
311    #[test]
312    fn before_compile_recurses_into_subdirectories() {
313        // collect_md_files walks the tree — drafts in nested dirs
314        // must also be hidden.
315        let (_tmp, content, ctx) = make_content_layout();
316        let nested = content.join("blog").join("2026");
317        fs::create_dir_all(&nested).unwrap();
318        write_md(&nested, "secret.md", Some("true"));
319        write_md(&content, "live.md", None);
320
321        DraftPlugin::new(false).before_compile(&ctx).unwrap();
322        assert!(nested.join("secret.md.draft").exists());
323        assert!(!nested.join("secret.md").exists());
324        assert!(content.join("live.md").exists());
325    }
326
327    #[test]
328    fn before_compile_no_drafts_yields_no_renames() {
329        let (_tmp, content, ctx) = make_content_layout();
330        write_md(&content, "a.md", None);
331        write_md(&content, "b.md", Some("false"));
332
333        DraftPlugin::new(false).before_compile(&ctx).unwrap();
334        assert!(content.join("a.md").exists());
335        assert!(content.join("b.md").exists());
336    }
337
338    // -------------------------------------------------------------------
339    // after_compile — restoration paths
340    // -------------------------------------------------------------------
341
342    #[test]
343    fn after_compile_with_include_drafts_short_circuits() {
344        // The `self.include_drafts` short-circuit at line 67 must not
345        // attempt to restore anything.
346        let (_tmp, content, ctx) = make_content_layout();
347        // Pre-place a .draft file to prove it is NOT touched.
348        fs::write(content.join("ghost.md.draft"), "---\n---\n").unwrap();
349
350        DraftPlugin::new(true).after_compile(&ctx).unwrap();
351        assert!(content.join("ghost.md.draft").exists());
352        assert!(!content.join("ghost.md").exists());
353    }
354
355    #[test]
356    fn after_compile_missing_content_dir_returns_ok() {
357        let dir = tempdir().expect("tempdir");
358        let missing = dir.path().join("missing");
359        let ctx =
360            PluginContext::new(&missing, dir.path(), dir.path(), dir.path());
361        DraftPlugin::new(false).after_compile(&ctx).unwrap();
362    }
363
364    #[test]
365    fn after_compile_restores_draft_extension_to_md() {
366        let (_tmp, content, ctx) = make_content_layout();
367        fs::write(content.join("post.md.draft"), "---\n---\n").unwrap();
368
369        DraftPlugin::new(false).after_compile(&ctx).unwrap();
370        assert!(content.join("post.md").exists());
371        assert!(!content.join("post.md.draft").exists());
372    }
373
374    #[test]
375    fn after_compile_does_not_overwrite_existing_original() {
376        // The `if !original.exists()` guard at line 75 must skip the
377        // rename when an original-named file is already present.
378        // Otherwise we'd silently clobber user content.
379        let (_tmp, content, ctx) = make_content_layout();
380        fs::write(content.join("post.md"), "USER WROTE THIS").unwrap();
381        fs::write(content.join("post.md.draft"), "STALE DRAFT").unwrap();
382
383        DraftPlugin::new(false).after_compile(&ctx).unwrap();
384        let body = fs::read_to_string(content.join("post.md")).unwrap();
385        assert_eq!(
386            body, "USER WROTE THIS",
387            "existing original must not be clobbered"
388        );
389        assert!(
390            content.join("post.md.draft").exists(),
391            "stale draft is left in place when original exists"
392        );
393    }
394
395    #[test]
396    fn before_and_after_round_trip_restores_original_content() {
397        // End-to-end: hide a draft, then restore it, and prove the
398        // file is byte-identical to before.
399        let (_tmp, content, ctx) = make_content_layout();
400        let payload = "---\ntitle: T\ndraft: true\n---\nDRAFT BODY";
401        fs::write(content.join("d.md"), payload).unwrap();
402
403        let plugin = DraftPlugin::new(false);
404        plugin.before_compile(&ctx).unwrap();
405        plugin.after_compile(&ctx).unwrap();
406
407        let restored = fs::read_to_string(content.join("d.md")).unwrap();
408        assert_eq!(restored, payload);
409    }
410
411    // -------------------------------------------------------------------
412    // collect_md_files / collect_draft_files — recursion + filtering
413    // -------------------------------------------------------------------
414
415    #[test]
416    fn collect_md_files_returns_empty_for_missing_directory() {
417        let dir = tempdir().expect("tempdir");
418        let result = collect_md_files(&dir.path().join("missing")).unwrap();
419        assert!(result.is_empty());
420    }
421
422    #[test]
423    fn collect_md_files_filters_non_md_extensions() {
424        let dir = tempdir().expect("tempdir");
425        fs::write(dir.path().join("a.md"), "").unwrap();
426        fs::write(dir.path().join("b.txt"), "").unwrap();
427        fs::write(dir.path().join("c.html"), "").unwrap();
428
429        let result = collect_md_files(dir.path()).unwrap();
430        assert_eq!(result.len(), 1);
431    }
432
433    #[test]
434    fn collect_md_files_recurses_into_nested_subdirectories() {
435        let dir = tempdir().expect("tempdir");
436        let nested = dir.path().join("a").join("b");
437        fs::create_dir_all(&nested).unwrap();
438        fs::write(dir.path().join("top.md"), "").unwrap();
439        fs::write(nested.join("deep.md"), "").unwrap();
440
441        let result = collect_md_files(dir.path()).unwrap();
442        assert_eq!(result.len(), 2);
443    }
444
445    #[test]
446    fn collect_draft_files_filters_non_draft_extensions() {
447        let dir = tempdir().expect("tempdir");
448        fs::write(dir.path().join("a.md.draft"), "").unwrap();
449        fs::write(dir.path().join("b.md"), "").unwrap();
450
451        let result = collect_draft_files(dir.path()).unwrap();
452        assert_eq!(result.len(), 1);
453    }
454
455    #[test]
456    fn collect_draft_files_respects_max_dir_depth_guard() {
457        // The `depth > MAX_DIR_DEPTH` continue at line 135 is only
458        // reached with a tree deeper than the limit. Closes line 136.
459        let dir = tempdir().expect("tempdir");
460        let mut current = dir.path().to_path_buf();
461        for i in 0..MAX_DIR_DEPTH + 2 {
462            current = current.join(format!("d{i}"));
463            fs::create_dir_all(&current).unwrap();
464            fs::write(current.join("p.md.draft"), "").unwrap();
465        }
466        let result = collect_draft_files(dir.path()).unwrap();
467        // At most MAX_DIR_DEPTH+1 files (depths 0..=MAX) survive.
468        assert!(result.len() <= MAX_DIR_DEPTH + 1);
469    }
470
471    #[test]
472    fn collect_md_files_respects_max_dir_depth_guard() {
473        let dir = tempdir().expect("tempdir");
474        let mut current = dir.path().to_path_buf();
475        for i in 0..MAX_DIR_DEPTH + 2 {
476            current = current.join(format!("d{i}"));
477            fs::create_dir_all(&current).unwrap();
478            fs::write(current.join("p.md"), "").unwrap();
479        }
480        let result = collect_md_files(dir.path()).unwrap();
481        assert!(result.len() <= MAX_DIR_DEPTH + 1);
482    }
483
484    #[test]
485    fn collect_draft_files_recurses_into_nested_subdirectories() {
486        let dir = tempdir().expect("tempdir");
487        let nested = dir.path().join("a");
488        fs::create_dir_all(&nested).unwrap();
489        fs::write(dir.path().join("top.md.draft"), "").unwrap();
490        fs::write(nested.join("nested.md.draft"), "").unwrap();
491
492        let result = collect_draft_files(dir.path()).unwrap();
493        assert_eq!(result.len(), 2);
494    }
495}