1use crate::error::{PathErrorExt, SsgError};
10use crate::plugin::{Plugin, PluginContext};
11use crate::plugins_group::listings::ListingConfig;
12use std::{
13 collections::{BTreeMap, HashMap},
14 fs,
15 path::{Path, PathBuf},
16};
17
18const DEFAULT_PER_PAGE: usize = 10;
20
21#[derive(Debug, Clone)]
29struct PageEntry {
30 title: String,
31 url: String,
32 date: String,
33 tags: Vec<String>,
34 categories: Vec<String>,
35 topics: Vec<String>,
36 language: Option<String>,
37}
38
39#[derive(Debug, Clone, Copy)]
45pub struct PaginationPlugin {
46 per_page: usize,
47}
48
49impl Default for PaginationPlugin {
50 fn default() -> Self {
51 Self {
52 per_page: DEFAULT_PER_PAGE,
53 }
54 }
55}
56
57impl PaginationPlugin {
58 #[must_use]
70 pub fn with_per_page(per_page: usize) -> Self {
71 Self {
72 per_page: per_page.max(1),
73 }
74 }
75}
76
77impl Plugin for PaginationPlugin {
78 fn name(&self) -> &'static str {
79 "pagination"
80 }
81
82 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
83 let sidecar_dir = ctx.build_dir.join(".meta");
84 if !sidecar_dir.exists() {
85 return Ok(());
86 }
87
88 let mut entries = collect_page_entries(&sidecar_dir)?;
89 if entries.is_empty() {
90 return Ok(());
91 }
92
93 entries.sort_by(|a, b| {
99 b.date.cmp(&a.date).then_with(|| a.url.cmp(&b.url))
100 });
101
102 let listings = ctx
106 .config
107 .as_ref()
108 .map_or::<&[ListingConfig], _>(&[], |c| c.listings.as_slice());
109 for listing in listings {
110 if listing.name.trim().is_empty() {
111 log::warn!("[listings] a listing with no name was skipped");
112 continue;
113 }
114 let selected: Vec<PageEntry> = entries
115 .iter()
116 .filter(|e| entry_matches(e, listing))
117 .cloned()
118 .collect();
119 if selected.is_empty() {
120 log::warn!(
124 "[listings] '{}' matched no pages; nothing written",
125 listing.name
126 );
127 continue;
128 }
129 let pages = write_listing(
130 &ctx.site_dir,
131 listing,
132 &selected,
133 self.per_page,
134 )?;
135 let years = if listing.by_year {
136 write_year_archives(&ctx.site_dir, listing, &selected)?
137 } else {
138 0
139 };
140 log::info!(
141 "[listings] '{}': {} page(s), {} year archive(s), {} entries",
142 listing.name,
143 pages,
144 years,
145 selected.len()
146 );
147 }
148
149 let total_pages = entries.len().div_ceil(self.per_page);
150 if total_pages <= 1 {
151 return Ok(());
152 }
153
154 let page_dir = ctx.site_dir.join("page");
155 for page_num in 2..=total_pages {
156 let start = (page_num - 1) * self.per_page;
157 let end = (start + self.per_page).min(entries.len());
158 let page_entries = &entries[start..end];
159
160 write_pagination_page(
161 &page_dir,
162 page_num,
163 total_pages,
164 page_entries,
165 )?;
166 }
167
168 log::info!(
169 "[pagination] Generated {} page(s) ({} entries, {} per page)",
170 total_pages - 1,
171 entries.len(),
172 self.per_page
173 );
174 Ok(())
175 }
176}
177
178fn entry_matches(entry: &PageEntry, listing: &ListingConfig) -> bool {
186 let has = |terms: &[String], want: &Option<String>| -> bool {
187 want.as_ref()
188 .is_none_or(|w| terms.iter().any(|t| t.eq_ignore_ascii_case(w)))
189 };
190
191 has(&entry.tags, &listing.tag)
192 && has(&entry.categories, &listing.category)
193 && has(&entry.topics, &listing.topic)
194 && listing.language.as_ref().is_none_or(|want| {
195 entry
196 .language
197 .as_ref()
198 .is_some_and(|l| l.eq_ignore_ascii_case(want))
199 })
200 && listing.after.as_ref().is_none_or(|a| entry.date >= *a)
205 && listing.before.as_ref().is_none_or(|b| entry.date <= *b)
206}
207
208fn write_listing(
214 site_dir: &Path,
215 listing: &ListingConfig,
216 entries: &[PageEntry],
217 default_per_page: usize,
218) -> Result<usize, SsgError> {
219 if entries.is_empty() {
220 return Ok(0);
221 }
222 let per_page = listing
223 .per_page
224 .filter(|n| *n > 0)
225 .unwrap_or(default_per_page);
226 let total_pages = entries.len().div_ceil(per_page);
227 let dir = site_dir.join(&listing.name);
228
229 for page_num in 1..=total_pages {
230 let start = (page_num - 1) * per_page;
231 let end = (start + per_page).min(entries.len());
232 let target = if page_num == 1 {
233 dir.clone()
234 } else {
235 dir.join("page").join(page_num.to_string())
236 };
237 write_listing_page(
238 &target,
239 listing,
240 page_num,
241 total_pages,
242 &entries[start..end],
243 )?;
244 }
245 Ok(total_pages)
246}
247
248fn write_year_archives(
254 site_dir: &Path,
255 listing: &ListingConfig,
256 entries: &[PageEntry],
257) -> Result<usize, SsgError> {
258 let mut by_year: BTreeMap<&str, Vec<PageEntry>> = BTreeMap::new();
259 for entry in entries {
260 if entry.date.len() >= 4 {
261 by_year
262 .entry(&entry.date[..4])
263 .or_default()
264 .push(entry.clone());
265 }
266 }
267 let count = by_year.len();
268 for (year, group) in by_year {
269 let target = site_dir.join(&listing.name).join(year);
270 write_listing_page(&target, listing, 1, 1, &group)?;
271 }
272 Ok(count)
273}
274
275fn collect_page_entries(
277 sidecar_dir: &Path,
278) -> Result<Vec<PageEntry>, SsgError> {
279 let sidecars = collect_json_files(sidecar_dir)?;
280 let mut entries = Vec::new();
281
282 for sidecar_path in &sidecars {
283 if let Some(entry) = parse_page_entry(sidecar_path, sidecar_dir) {
284 entries.push(entry);
285 }
286 }
287
288 Ok(entries)
289}
290
291fn parse_page_entry(
293 sidecar_path: &Path,
294 sidecar_dir: &Path,
295) -> Option<PageEntry> {
296 let content = fs::read_to_string(sidecar_path).ok()?;
297 let meta: HashMap<String, serde_json::Value> =
298 serde_json::from_str(&content).ok()?;
299
300 let title = meta
301 .get("title")
302 .and_then(|v| v.as_str())
303 .unwrap_or("Untitled")
304 .to_string();
305 let date = meta
306 .get("date")
307 .and_then(|v| v.as_str())
308 .unwrap_or("")
309 .to_string();
310
311 if date.is_empty() {
312 return None;
313 }
314
315 let rel = sidecar_path
316 .strip_prefix(sidecar_dir)
317 .unwrap_or(sidecar_path)
318 .with_extension("")
319 .with_extension("html");
320 let url = format!("/{}", rel.to_string_lossy().replace('\\', "/"));
321
322 let terms = |key: &str| -> Vec<String> {
325 meta.get(key).map_or_else(Vec::new, |v| {
326 v.as_array().map_or_else(
327 || {
328 v.as_str()
329 .map_or_else(Vec::new, |s| ssg_core::split_terms(s))
330 },
331 |arr| {
332 arr.iter()
333 .filter_map(serde_json::Value::as_str)
334 .flat_map(ssg_core::split_terms)
335 .collect()
336 },
337 )
338 })
339 };
340
341 Some(PageEntry {
342 title,
343 url,
344 date,
345 tags: terms("tags"),
346 categories: terms("categories"),
347 topics: terms("topic_clusters"),
348 language: meta
349 .get("language")
350 .and_then(serde_json::Value::as_str)
351 .map(ToOwned::to_owned),
352 })
353}
354
355fn write_pagination_page(
357 page_dir: &Path,
358 page_num: usize,
359 total_pages: usize,
360 page_entries: &[PageEntry],
361) -> Result<(), SsgError> {
362 let dir = page_dir.join(page_num.to_string());
363 fs::create_dir_all(&dir).with_path(&dir)?;
364
365 let prev_url = if page_num == 2 {
366 "/".to_string()
367 } else {
368 format!("/page/{}/", page_num - 1)
369 };
370 let next_url = if page_num < total_pages {
371 Some(format!("/page/{}/", page_num + 1))
372 } else {
373 None
374 };
375
376 let mut html = format!(
377 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\
378 <meta charset=\"utf-8\">\
379 <title>Page {page_num} of {total_pages}</title></head>\n\
380 <body>\n<main>\n\
381 <h1>Page {page_num} of {total_pages}</h1>\n<ul>\n",
382 );
383
384 for entry in page_entries {
385 html.push_str(&format!(
386 "<li><a href=\"{}\">{}</a> <time>{}</time></li>\n",
387 entry.url, entry.title, entry.date
388 ));
389 }
390
391 html.push_str("</ul>\n<nav aria-label=\"Pagination\">\n");
392 html.push_str(&format!(
393 "<a href=\"{prev_url}\" rel=\"prev\">← Previous</a>\n"
394 ));
395 if let Some(next) = &next_url {
396 html.push_str(&format!(
397 "<a href=\"{next}\" rel=\"next\">Next →</a>\n"
398 ));
399 }
400 html.push_str("</nav>\n</main>\n</body>\n</html>\n");
401
402 let out_file = dir.join("index.html");
403 fs::write(&out_file, html).with_path(&out_file)?;
404 Ok(())
405}
406
407fn write_listing_page(
413 dir: &Path,
414 listing: &ListingConfig,
415 page_num: usize,
416 total_pages: usize,
417 entries: &[PageEntry],
418) -> Result<(), SsgError> {
419 fs::create_dir_all(dir).with_path(dir)?;
420
421 let base = format!("/{}", listing.name);
422 let page_url = |n: usize| -> String {
423 if n == 1 {
424 format!("{base}/")
425 } else {
426 format!("{base}/page/{n}/")
427 }
428 };
429
430 let title = escape_html(listing.display_title());
431 let heading = if total_pages > 1 {
432 format!("{title} — page {page_num} of {total_pages}")
433 } else {
434 title.clone()
435 };
436
437 let mut html = format!(
438 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\
439 <meta charset=\"utf-8\">\
440 <title>{heading}</title></head>\n\
441 <body>\n<main>\n\
442 <h1>{heading}</h1>\n<ul>\n",
443 );
444 for entry in entries {
445 html.push_str(&format!(
446 "<li><a href=\"{}\">{}</a> <time datetime=\"{}\">{}</time></li>\n",
447 escape_html(&entry.url),
448 escape_html(&entry.title),
449 escape_html(&entry.date),
450 escape_html(&entry.date),
451 ));
452 }
453 html.push_str("</ul>\n");
454
455 if total_pages > 1 {
456 html.push_str("<nav aria-label=\"Pagination\">\n");
457 if page_num > 1 {
458 html.push_str(&format!(
459 "<a href=\"{}\" rel=\"prev\">← Previous</a>\n",
460 page_url(page_num - 1)
461 ));
462 }
463 if page_num < total_pages {
464 html.push_str(&format!(
465 "<a href=\"{}\" rel=\"next\">Next →</a>\n",
466 page_url(page_num + 1)
467 ));
468 }
469 html.push_str("</nav>\n");
470 }
471 html.push_str("</main>\n</body>\n</html>\n");
472
473 let out_file = dir.join("index.html");
474 fs::write(&out_file, html).with_path(&out_file)?;
475 Ok(())
476}
477
478fn escape_html(s: &str) -> String {
480 let mut out = String::with_capacity(s.len());
481 for c in s.chars() {
482 match c {
483 '&' => out.push_str("&"),
484 '<' => out.push_str("<"),
485 '>' => out.push_str(">"),
486 '"' => out.push_str("""),
487 '\'' => out.push_str("'"),
488 _ => out.push(c),
489 }
490 }
491 out
492}
493
494fn collect_json_files(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
495 crate::walk::walk_files(dir, "json")
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501 use crate::test_support::init_logger;
502 use std::path::PathBuf;
503 use tempfile::{tempdir, TempDir};
504
505 fn make_layout() -> (TempDir, PathBuf, PathBuf, PluginContext) {
514 init_logger();
515 let dir = tempdir().expect("create tempdir");
516 let site = dir.path().join("site");
517 let build = dir.path().join("build");
518 let meta = build.join(".meta");
519 fs::create_dir_all(&site).expect("mkdir site");
520 fs::create_dir_all(&meta).expect("mkdir meta");
521 let ctx = PluginContext::new(dir.path(), &build, &site, dir.path());
522 (dir, site, meta, ctx)
523 }
524
525 fn layout_with_listings(
527 listings: Vec<ListingConfig>,
528 ) -> (TempDir, PathBuf, PathBuf, PluginContext) {
529 init_logger();
530 let dir = tempdir().expect("create tempdir");
531 let site = dir.path().join("site");
532 let build = dir.path().join("build");
533 let meta = build.join(".meta");
534 fs::create_dir_all(&site).expect("mkdir site");
535 fs::create_dir_all(&meta).expect("mkdir meta");
536 let mut cfg = crate::cmd::default_config().as_ref().clone();
537 cfg.listings = listings;
538 let ctx = PluginContext::with_config(
539 dir.path(),
540 &build,
541 &site,
542 dir.path(),
543 cfg,
544 );
545 (dir, site, meta, ctx)
546 }
547
548 fn write_rich_sidecar(
550 meta: &Path,
551 name: &str,
552 title: &str,
553 date: &str,
554 extra: &str,
555 ) {
556 let json = format!(
557 r#"{{"title": "{title}", "date": "{date}"{}{extra}}}"#,
558 if extra.is_empty() { "" } else { ", " }
559 );
560 fs::write(meta.join(format!("{name}.meta.json")), json)
561 .expect("write sidecar");
562 }
563
564 #[test]
565 fn a_named_listing_writes_page_one_at_its_own_path() {
566 let (_d, site, meta, ctx) = layout_with_listings(vec![ListingConfig {
567 name: "archive".to_string(),
568 title: Some("Archive".to_string()),
569 ..ListingConfig::default()
570 }]);
571 write_sidecar(&meta, "a", "Alpha", "2026-01-01");
572
573 PaginationPlugin::default().after_compile(&ctx).unwrap();
574
575 let page = fs::read_to_string(site.join("archive/index.html"))
576 .expect("page 1 is written at the listing root");
577 assert!(page.contains("Archive"), "{page}");
578 assert!(page.contains("Alpha"), "{page}");
579 }
580
581 #[test]
582 fn a_listing_paginates_at_its_own_per_page() {
583 let (_d, site, meta, ctx) = layout_with_listings(vec![ListingConfig {
584 name: "archive".to_string(),
585 per_page: Some(2),
586 ..ListingConfig::default()
587 }]);
588 for i in 1..=5 {
589 write_sidecar(
590 &meta,
591 &format!("p{i}"),
592 &format!("P{i}"),
593 &format!("2026-01-0{i}"),
594 );
595 }
596
597 PaginationPlugin::default().after_compile(&ctx).unwrap();
598
599 assert!(site.join("archive/index.html").exists());
600 assert!(site.join("archive/page/2/index.html").exists());
601 assert!(site.join("archive/page/3/index.html").exists());
602 assert!(
603 !site.join("archive/page/4/index.html").exists(),
604 "5 entries at 2 per page is 3 pages"
605 );
606 let p2 =
607 fs::read_to_string(site.join("archive/page/2/index.html")).unwrap();
608 assert!(
609 p2.contains(r#"href="/archive/""#),
610 "prev goes to page 1: {p2}"
611 );
612 assert!(p2.contains(r#"href="/archive/page/3/""#), "next: {p2}");
613 }
614
615 #[test]
616 fn filters_select_the_pages_and_combine_with_and() {
617 let (_d, site, meta, ctx) = layout_with_listings(vec![ListingConfig {
618 name: "rust-2026".to_string(),
619 tag: Some("rust".to_string()),
620 after: Some("2026-01-01".to_string()),
621 ..ListingConfig::default()
622 }]);
623 write_rich_sidecar(
624 &meta,
625 "a",
626 "Match",
627 "2026-06-01",
628 r#""tags": "rust""#,
629 );
630 write_rich_sidecar(
631 &meta,
632 "b",
633 "WrongTag",
634 "2026-06-01",
635 r#""tags": "go""#,
636 );
637 write_rich_sidecar(
638 &meta,
639 "c",
640 "TooOld",
641 "2025-06-01",
642 r#""tags": "rust""#,
643 );
644
645 PaginationPlugin::default().after_compile(&ctx).unwrap();
646
647 let page =
648 fs::read_to_string(site.join("rust-2026/index.html")).unwrap();
649 assert!(page.contains("Match"), "{page}");
650 assert!(!page.contains("WrongTag"), "tag filter: {page}");
651 assert!(!page.contains("TooOld"), "date filter: {page}");
652 }
653
654 #[test]
656 fn term_filters_ignore_case() {
657 let (_d, site, meta, ctx) = layout_with_listings(vec![ListingConfig {
658 name: "r".to_string(),
659 tag: Some("Rust".to_string()),
660 ..ListingConfig::default()
661 }]);
662 write_rich_sidecar(
663 &meta,
664 "a",
665 "Alpha",
666 "2026-01-01",
667 r#""tags": "rust""#,
668 );
669
670 PaginationPlugin::default().after_compile(&ctx).unwrap();
671 assert!(fs::read_to_string(site.join("r/index.html"))
672 .unwrap()
673 .contains("Alpha"));
674 }
675
676 #[test]
677 fn by_year_writes_one_archive_per_year() {
678 let (_d, site, meta, ctx) = layout_with_listings(vec![ListingConfig {
679 name: "archive".to_string(),
680 by_year: true,
681 ..ListingConfig::default()
682 }]);
683 write_sidecar(&meta, "a", "Old", "2025-03-01");
684 write_sidecar(&meta, "b", "New", "2026-03-01");
685
686 PaginationPlugin::default().after_compile(&ctx).unwrap();
687
688 let y2025 =
689 fs::read_to_string(site.join("archive/2025/index.html")).unwrap();
690 assert!(y2025.contains("Old") && !y2025.contains("New"), "{y2025}");
691 let y2026 =
692 fs::read_to_string(site.join("archive/2026/index.html")).unwrap();
693 assert!(y2026.contains("New") && !y2026.contains("Old"), "{y2026}");
694 }
695
696 #[test]
698 fn a_listing_matching_nothing_writes_no_directory() {
699 let (_d, site, meta, ctx) = layout_with_listings(vec![ListingConfig {
700 name: "ghost".to_string(),
701 tag: Some("nonexistent".to_string()),
702 ..ListingConfig::default()
703 }]);
704 write_sidecar(&meta, "a", "Alpha", "2026-01-01");
705
706 PaginationPlugin::default().after_compile(&ctx).unwrap();
707 assert!(!site.join("ghost").exists(), "no empty listing directory");
708 }
709
710 #[test]
712 fn titles_are_escaped_in_generated_listings() {
713 let (_d, site, meta, ctx) = layout_with_listings(vec![ListingConfig {
714 name: "archive".to_string(),
715 ..ListingConfig::default()
716 }]);
717 write_sidecar(&meta, "a", "A <b>bold</b> title", "2026-01-01");
718
719 PaginationPlugin::default().after_compile(&ctx).unwrap();
720 let page = fs::read_to_string(site.join("archive/index.html")).unwrap();
721 assert!(page.contains("<b>bold</b>"), "{page}");
722 assert!(!page.contains("<b>bold</b>"), "{page}");
723 }
724
725 #[test]
727 fn no_listings_configured_writes_no_listing_directories() {
728 let (_d, site, meta, ctx) = make_layout();
729 write_n_dated_posts(&meta, 25);
730
731 PaginationPlugin::default().after_compile(&ctx).unwrap();
732
733 assert!(
734 site.join("page/2/index.html").exists(),
735 "site-wide unchanged"
736 );
737 let dirs: Vec<_> = fs::read_dir(&site)
738 .unwrap()
739 .filter_map(Result::ok)
740 .map(|e| e.file_name().to_string_lossy().to_string())
741 .collect();
742 assert_eq!(dirs, vec!["page".to_string()], "only /page/: {dirs:?}");
743 }
744
745 fn write_sidecar(meta: &Path, name: &str, title: &str, date: &str) {
747 let json = if date.is_empty() {
748 format!(r#"{{"title": "{title}"}}"#)
749 } else {
750 format!(r#"{{"title": "{title}", "date": "{date}"}}"#)
751 };
752 fs::write(meta.join(format!("{name}.meta.json")), json)
753 .expect("write sidecar");
754 }
755
756 fn write_n_dated_posts(meta: &Path, n: usize) {
759 for i in 1..=n {
760 write_sidecar(
761 meta,
762 &format!("post{i:03}"),
763 &format!("Post {i}"),
764 &format!("2026-01-{i:02}"),
765 );
766 }
767 }
768
769 #[test]
774 fn default_uses_default_per_page_constant() {
775 let plugin = PaginationPlugin::default();
779 assert_eq!(plugin.per_page, DEFAULT_PER_PAGE);
780 }
781
782 #[test]
783 fn with_per_page_stores_supplied_value() {
784 let plugin = PaginationPlugin::with_per_page(7);
785 assert_eq!(plugin.per_page, 7);
786 }
787
788 #[test]
789 fn with_per_page_zero_clamps_to_one() {
790 let plugin = PaginationPlugin::with_per_page(0);
793 assert_eq!(plugin.per_page, 1);
794 }
795
796 #[test]
797 fn with_per_page_one_is_valid_lower_bound() {
798 let plugin = PaginationPlugin::with_per_page(1);
799 assert_eq!(plugin.per_page, 1);
800 }
801
802 #[test]
803 fn with_per_page_table_driven_values() {
804 let cases: &[(usize, usize)] = &[
806 (1, 1),
807 (5, 5),
808 (10, 10),
809 (100, 100),
810 (usize::MAX, usize::MAX),
811 ];
812 for &(input, expected) in cases {
813 let plugin = PaginationPlugin::with_per_page(input);
814 assert_eq!(
815 plugin.per_page, expected,
816 "with_per_page({input}) should store {expected}"
817 );
818 }
819 }
820
821 #[test]
822 fn pagination_plugin_is_copy_after_move() {
823 let plugin = PaginationPlugin::with_per_page(3);
825 let _copy = plugin;
826 assert_eq!(plugin.per_page, 3);
827 }
828
829 #[test]
830 fn name_returns_static_pagination_identifier() {
831 let plugin = PaginationPlugin::default();
832 assert_eq!(plugin.name(), "pagination");
833 }
834
835 #[test]
840 fn after_compile_missing_meta_dir_returns_ok_without_writing() {
841 let dir = tempdir().expect("tempdir");
844 let site = dir.path().join("site");
845 let build = dir.path().join("build");
846 fs::create_dir_all(&site).expect("mkdir site");
847 fs::create_dir_all(&build).expect("mkdir build");
848 let ctx = PluginContext::new(dir.path(), &build, &site, dir.path());
849
850 PaginationPlugin::default()
851 .after_compile(&ctx)
852 .expect("missing meta dir is not an error");
853
854 assert!(!site.join("page").exists());
855 }
856
857 #[test]
858 fn after_compile_empty_meta_dir_returns_ok_without_writing() {
859 let (_tmp, site, _meta, ctx) = make_layout();
860 PaginationPlugin::default()
861 .after_compile(&ctx)
862 .expect("empty meta is fine");
863 assert!(!site.join("page").exists());
864 }
865
866 #[test]
867 fn after_compile_only_undated_pages_returns_ok_without_writing() {
868 let (_tmp, site, meta, ctx) = make_layout();
871 write_sidecar(&meta, "about", "About", "");
872 write_sidecar(&meta, "contact", "Contact", "");
873
874 PaginationPlugin::default().after_compile(&ctx).unwrap();
875 assert!(!site.join("page").exists());
876 }
877
878 #[test]
879 fn after_compile_single_page_skips_pagination() {
880 let (_tmp, site, meta, ctx) = make_layout();
883 write_n_dated_posts(&meta, 5);
884
885 PaginationPlugin::default().after_compile(&ctx).unwrap();
886 assert!(!site.join("page").exists());
887 }
888
889 #[test]
894 fn after_compile_skips_invalid_json_sidecars() {
895 let (_tmp, site, meta, ctx) = make_layout();
899 fs::write(meta.join("broken.meta.json"), "{not valid json").unwrap();
900 write_n_dated_posts(&meta, 11);
903
904 PaginationPlugin::default()
905 .after_compile(&ctx)
906 .expect("broken sidecar must not error");
907 assert!(site.join("page/2/index.html").exists());
908 }
909
910 #[test]
911 fn after_compile_missing_title_defaults_to_untitled() {
912 let (_tmp, site, meta, ctx) = make_layout();
915 for i in 1..=11 {
917 fs::write(
918 meta.join(format!("post{i}.meta.json")),
919 format!(r#"{{"date": "2026-01-{i:02}"}}"#),
920 )
921 .unwrap();
922 }
923
924 PaginationPlugin::default().after_compile(&ctx).unwrap();
925 let page2 = fs::read_to_string(site.join("page/2/index.html")).unwrap();
926 assert!(
927 page2.contains("Untitled"),
928 "missing title must fall back to \"Untitled\":\n{page2}"
929 );
930 }
931
932 #[test]
933 fn after_compile_skips_pages_with_empty_date_string() {
934 let (_tmp, site, meta, ctx) = make_layout();
937 write_sidecar(&meta, "draft", "Draft", ""); write_n_dated_posts(&meta, 11);
939
940 PaginationPlugin::default().after_compile(&ctx).unwrap();
941 assert!(site.join("page/2/index.html").exists());
944 assert!(!site.join("page/3/index.html").exists());
945 }
946
947 #[test]
952 fn after_compile_exact_multiple_yields_full_pages() {
953 let (_tmp, site, meta, ctx) = make_layout();
955 write_n_dated_posts(&meta, 10);
956
957 PaginationPlugin::with_per_page(5)
958 .after_compile(&ctx)
959 .unwrap();
960
961 let page2 = fs::read_to_string(site.join("page/2/index.html")).unwrap();
962 let li_count = page2.matches("<li>").count();
964 assert_eq!(li_count, 5, "page 2 should have 5 entries:\n{page2}");
965 assert!(!site.join("page/3/index.html").exists());
966 }
967
968 #[test]
969 fn after_compile_non_multiple_yields_partial_last_page() {
970 let (_tmp, site, meta, ctx) = make_layout();
972 write_n_dated_posts(&meta, 11);
973
974 PaginationPlugin::with_per_page(5)
975 .after_compile(&ctx)
976 .unwrap();
977
978 assert!(site.join("page/2/index.html").exists());
979 assert!(site.join("page/3/index.html").exists());
980 assert!(!site.join("page/4/index.html").exists());
981
982 let page3 = fs::read_to_string(site.join("page/3/index.html")).unwrap();
983 let li_count = page3.matches("<li>").count();
984 assert_eq!(li_count, 1, "last page should have 1 entry:\n{page3}");
985 }
986
987 #[test]
988 fn after_compile_per_page_one_yields_one_page_per_post() {
989 let (_tmp, site, meta, ctx) = make_layout();
991 write_n_dated_posts(&meta, 5);
992
993 PaginationPlugin::with_per_page(1)
994 .after_compile(&ctx)
995 .unwrap();
996
997 for n in 2..=5 {
998 assert!(
999 site.join(format!("page/{n}/index.html")).exists(),
1000 "page/{n}/ should exist"
1001 );
1002 }
1003 assert!(!site.join("page/6/index.html").exists());
1004 }
1005
1006 #[test]
1011 fn after_compile_sorts_entries_by_date_descending() {
1012 let (_tmp, site, meta, ctx) = make_layout();
1016 let dates = [
1020 ("a", "2026-01-01"),
1021 ("m", "2026-01-05"),
1022 ("z", "2026-01-11"),
1023 ("b", "2026-01-02"),
1024 ("y", "2026-01-10"),
1025 ("c", "2026-01-03"),
1026 ("x", "2026-01-09"),
1027 ("d", "2026-01-04"),
1028 ("w", "2026-01-08"),
1029 ("e", "2026-01-06"),
1030 ("f", "2026-01-07"),
1031 ];
1032 for (name, date) in dates {
1033 write_sidecar(&meta, name, &format!("Post {name}"), date);
1034 }
1035
1036 PaginationPlugin::with_per_page(10)
1037 .after_compile(&ctx)
1038 .unwrap();
1039
1040 let page2 = fs::read_to_string(site.join("page/2/index.html")).unwrap();
1042 assert!(
1043 page2.contains("2026-01-01"),
1044 "page 2 should contain the oldest entry:\n{page2}"
1045 );
1046 assert!(
1047 !page2.contains("2026-01-11"),
1048 "page 2 should NOT contain the newest entry:\n{page2}"
1049 );
1050 }
1051
1052 #[test]
1057 fn after_compile_emits_doctype_lang_and_charset() {
1058 let (_tmp, site, meta, ctx) = make_layout();
1059 write_n_dated_posts(&meta, 11);
1060 PaginationPlugin::default().after_compile(&ctx).unwrap();
1061
1062 let html = fs::read_to_string(site.join("page/2/index.html")).unwrap();
1063 assert!(html.starts_with("<!DOCTYPE html>"));
1064 assert!(html.contains("<html lang=\"en\">"));
1065 assert!(html.contains("<meta charset=\"utf-8\">"));
1066 }
1067
1068 #[test]
1069 fn after_compile_emits_pagination_nav_landmark() {
1070 let (_tmp, site, meta, ctx) = make_layout();
1071 write_n_dated_posts(&meta, 11);
1072 PaginationPlugin::default().after_compile(&ctx).unwrap();
1073
1074 let html = fs::read_to_string(site.join("page/2/index.html")).unwrap();
1075 assert!(html.contains("<nav aria-label=\"Pagination\">"));
1076 }
1077
1078 #[test]
1079 fn after_compile_page_two_prev_link_points_at_root() {
1080 let (_tmp, site, meta, ctx) = make_layout();
1083 write_n_dated_posts(&meta, 11);
1084 PaginationPlugin::default().after_compile(&ctx).unwrap();
1085
1086 let html = fs::read_to_string(site.join("page/2/index.html")).unwrap();
1087 assert!(
1088 html.contains(r#"<a href="/" rel="prev">"#),
1089 "page 2's prev should point to root:\n{html}"
1090 );
1091 }
1092
1093 #[test]
1094 fn after_compile_page_three_prev_link_points_at_page_two() {
1095 let (_tmp, site, meta, ctx) = make_layout();
1097 write_n_dated_posts(&meta, 11);
1098 PaginationPlugin::with_per_page(5)
1099 .after_compile(&ctx)
1100 .unwrap();
1101
1102 let html = fs::read_to_string(site.join("page/3/index.html")).unwrap();
1103 assert!(
1104 html.contains(r#"<a href="/page/2/" rel="prev">"#),
1105 "page 3's prev should point to /page/2/:\n{html}"
1106 );
1107 }
1108
1109 #[test]
1110 fn after_compile_last_page_has_no_next_link() {
1111 let (_tmp, site, meta, ctx) = make_layout();
1114 write_n_dated_posts(&meta, 11);
1115 PaginationPlugin::with_per_page(5)
1116 .after_compile(&ctx)
1117 .unwrap();
1118
1119 let last = fs::read_to_string(site.join("page/3/index.html")).unwrap();
1120 assert!(
1121 !last.contains(r#"rel="next""#),
1122 "last page must not emit a Next link:\n{last}"
1123 );
1124 }
1125
1126 #[test]
1127 fn after_compile_middle_page_has_both_prev_and_next() {
1128 let (_tmp, site, meta, ctx) = make_layout();
1131 write_n_dated_posts(&meta, 16);
1132 PaginationPlugin::with_per_page(5)
1133 .after_compile(&ctx)
1134 .unwrap();
1135
1136 let page3 = fs::read_to_string(site.join("page/3/index.html")).unwrap();
1137 assert!(page3.contains(r#"rel="prev""#));
1138 assert!(page3.contains(r#"rel="next""#));
1139 }
1140
1141 #[test]
1142 fn after_compile_renders_time_element_per_entry() {
1143 let (_tmp, site, meta, ctx) = make_layout();
1144 write_n_dated_posts(&meta, 11);
1145 PaginationPlugin::default().after_compile(&ctx).unwrap();
1146
1147 let html = fs::read_to_string(site.join("page/2/index.html")).unwrap();
1148 assert!(
1149 html.contains("<time>2026-01-01</time>"),
1150 "page 2 should render a <time> element:\n{html}"
1151 );
1152 }
1153
1154 #[test]
1155 fn after_compile_idempotent_overwrites_existing_pages() {
1156 let (_tmp, site, meta, ctx) = make_layout();
1159 write_n_dated_posts(&meta, 11);
1160 let plugin = PaginationPlugin::default();
1161 plugin.after_compile(&ctx).expect("first run");
1162 plugin.after_compile(&ctx).expect("second run");
1163 assert!(site.join("page/2/index.html").exists());
1164 }
1165
1166 #[test]
1171 fn collect_json_files_returns_empty_for_missing_directory() {
1172 let dir = tempdir().expect("tempdir");
1175 let result =
1176 collect_json_files(&dir.path().join("does-not-exist")).unwrap();
1177 assert!(result.is_empty());
1178 }
1179
1180 #[test]
1181 fn collect_json_files_returns_empty_for_empty_directory() {
1182 let dir = tempdir().expect("tempdir");
1183 let result = collect_json_files(dir.path()).unwrap();
1184 assert!(result.is_empty());
1185 }
1186
1187 #[test]
1188 fn collect_json_files_filters_non_json_extensions() {
1189 let dir = tempdir().expect("tempdir");
1192 fs::write(dir.path().join("a.json"), "{}").unwrap();
1193 fs::write(dir.path().join("b.txt"), "x").unwrap();
1194 fs::write(dir.path().join("c.md"), "x").unwrap();
1195 fs::write(dir.path().join("noext"), "x").unwrap();
1196
1197 let result = collect_json_files(dir.path()).unwrap();
1198 assert_eq!(result.len(), 1);
1199 assert_eq!(
1200 result[0].file_name().unwrap(),
1201 std::ffi::OsStr::new("a.json")
1202 );
1203 }
1204
1205 #[test]
1206 fn collect_json_files_recurses_into_subdirectories() {
1207 let dir = tempdir().expect("tempdir");
1210 let nested = dir.path().join("a").join("b").join("c");
1211 fs::create_dir_all(&nested).unwrap();
1212 fs::write(dir.path().join("top.json"), "{}").unwrap();
1213 fs::write(dir.path().join("a").join("mid.json"), "{}").unwrap();
1214 fs::write(nested.join("deep.json"), "{}").unwrap();
1215
1216 let result = collect_json_files(dir.path()).unwrap();
1217 assert_eq!(result.len(), 3);
1218 }
1219
1220 #[test]
1221 fn collect_json_files_returns_results_sorted() {
1222 let dir = tempdir().expect("tempdir");
1224 for name in ["zebra.json", "apple.json", "mango.json"] {
1225 fs::write(dir.path().join(name), "{}").unwrap();
1226 }
1227 let result = collect_json_files(dir.path()).unwrap();
1228 let names: Vec<&str> = result
1229 .iter()
1230 .map(|p| p.file_name().unwrap().to_str().unwrap())
1231 .collect();
1232 assert_eq!(names, vec!["apple.json", "mango.json", "zebra.json"]);
1233 }
1234
1235 #[test]
1240 #[cfg(unix)]
1241 fn after_compile_propagates_walk_error_from_unreadable_meta_subdir() {
1242 use std::os::unix::fs::PermissionsExt;
1243
1244 let (_tmp, _site, meta, ctx) = make_layout();
1245 let locked = meta.join("locked");
1246 fs::create_dir_all(&locked).unwrap();
1247 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
1248 .unwrap();
1249
1250 let result = PaginationPlugin::default().after_compile(&ctx);
1251 fs::set_permissions(&locked, fs::Permissions::from_mode(0o755))
1252 .unwrap();
1253 assert!(result.is_err(), "unreadable meta subdir must be an Err");
1254 }
1255
1256 #[test]
1257 #[cfg(unix)]
1258 fn after_compile_skips_unreadable_sidecar_file() {
1259 use std::os::unix::fs::PermissionsExt;
1260
1261 let (_tmp, site, meta, ctx) = make_layout();
1265 write_n_dated_posts(&meta, 11);
1266 let locked = meta.join("locked.meta.json");
1267 fs::write(&locked, r#"{"title": "L", "date": "2026-02-01"}"#).unwrap();
1268 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
1269 .unwrap();
1270
1271 let result = PaginationPlugin::default().after_compile(&ctx);
1272 fs::set_permissions(&locked, fs::Permissions::from_mode(0o644))
1273 .unwrap();
1274 result.expect("unreadable sidecar must be skipped, not fatal");
1275 assert!(site.join("page/2/index.html").exists());
1276 assert!(!site.join("page/3/index.html").exists());
1277 }
1278
1279 #[test]
1280 fn after_compile_create_dir_failure_when_page_is_a_file() {
1281 let (_tmp, site, meta, ctx) = make_layout();
1284 write_n_dated_posts(&meta, 11);
1285 fs::write(site.join("page"), "not a directory").unwrap();
1286
1287 let err = PaginationPlugin::default()
1288 .after_compile(&ctx)
1289 .expect_err("create_dir_all over a file must fail");
1290 let msg = format!("{err:?}");
1291 assert!(msg.contains("Io"), "expected Io error, got: {msg}");
1292 }
1293
1294 #[test]
1295 fn after_compile_write_failure_when_index_html_is_a_directory() {
1296 let (_tmp, site, meta, ctx) = make_layout();
1299 write_n_dated_posts(&meta, 11);
1300 fs::create_dir_all(site.join("page/2/index.html")).unwrap();
1301
1302 let err = PaginationPlugin::default()
1303 .after_compile(&ctx)
1304 .expect_err("write over a directory must fail");
1305 let msg = format!("{err:?}");
1306 assert!(msg.contains("index.html"), "path context expected: {msg}");
1307 }
1308}