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 fail_point!("pagination::read-sidecar");
299 let content = fs::read_to_string(sidecar_path).ok()?;
300 let meta: HashMap<String, serde_json::Value> =
301 serde_json::from_str(&content).ok()?;
302
303 let title = meta
304 .get("title")
305 .and_then(|v| v.as_str())
306 .unwrap_or("Untitled")
307 .to_string();
308 let date = meta
309 .get("date")
310 .and_then(|v| v.as_str())
311 .unwrap_or("")
312 .to_string();
313
314 if date.is_empty() {
315 return None;
316 }
317
318 let rel = sidecar_path
319 .strip_prefix(sidecar_dir)
320 .unwrap_or(sidecar_path)
321 .with_extension("")
322 .with_extension("html");
323 let url = format!("/{}", rel.to_string_lossy().replace('\\', "/"));
324
325 let terms = |key: &str| -> Vec<String> {
328 meta.get(key).map_or_else(Vec::new, |v| {
329 v.as_array().map_or_else(
330 || {
331 v.as_str()
332 .map_or_else(Vec::new, |s| ssg_core::split_terms(s))
333 },
334 |arr| {
335 arr.iter()
336 .filter_map(serde_json::Value::as_str)
337 .flat_map(ssg_core::split_terms)
338 .collect()
339 },
340 )
341 })
342 };
343
344 Some(PageEntry {
345 title,
346 url,
347 date,
348 tags: terms("tags"),
349 categories: terms("categories"),
350 topics: terms("topic_clusters"),
351 language: meta
352 .get("language")
353 .and_then(serde_json::Value::as_str)
354 .map(ToOwned::to_owned),
355 })
356}
357
358fn write_pagination_page(
360 page_dir: &Path,
361 page_num: usize,
362 total_pages: usize,
363 page_entries: &[PageEntry],
364) -> Result<(), SsgError> {
365 let dir = page_dir.join(page_num.to_string());
366 fs::create_dir_all(&dir).with_path(&dir)?;
367
368 let prev_url = if page_num == 2 {
369 "/".to_string()
370 } else {
371 format!("/page/{}/", page_num - 1)
372 };
373 let next_url = if page_num < total_pages {
374 Some(format!("/page/{}/", page_num + 1))
375 } else {
376 None
377 };
378
379 let mut html = format!(
380 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\
381 <meta charset=\"utf-8\">\
382 <title>Page {page_num} of {total_pages}</title></head>\n\
383 <body>\n<main>\n\
384 <h1>Page {page_num} of {total_pages}</h1>\n<ul>\n",
385 );
386
387 for entry in page_entries {
388 html.push_str(&format!(
389 "<li><a href=\"{}\">{}</a> <time>{}</time></li>\n",
390 entry.url, entry.title, entry.date
391 ));
392 }
393
394 html.push_str("</ul>\n<nav aria-label=\"Pagination\">\n");
395 html.push_str(&format!(
396 "<a href=\"{prev_url}\" rel=\"prev\">← Previous</a>\n"
397 ));
398 if let Some(next) = &next_url {
399 html.push_str(&format!(
400 "<a href=\"{next}\" rel=\"next\">Next →</a>\n"
401 ));
402 }
403 html.push_str("</nav>\n</main>\n</body>\n</html>\n");
404
405 let out_file = dir.join("index.html");
406 fs::write(&out_file, html).with_path(&out_file)?;
407 Ok(())
408}
409
410fn write_listing_page(
416 dir: &Path,
417 listing: &ListingConfig,
418 page_num: usize,
419 total_pages: usize,
420 entries: &[PageEntry],
421) -> Result<(), SsgError> {
422 fs::create_dir_all(dir).with_path(dir)?;
423
424 let base = format!("/{}", listing.name);
425 let page_url = |n: usize| -> String {
426 if n == 1 {
427 format!("{base}/")
428 } else {
429 format!("{base}/page/{n}/")
430 }
431 };
432
433 let title = escape_html(listing.display_title());
434 let heading = if total_pages > 1 {
435 format!("{title} — page {page_num} of {total_pages}")
436 } else {
437 title.clone()
438 };
439
440 let mut html = format!(
441 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\
442 <meta charset=\"utf-8\">\
443 <title>{heading}</title></head>\n\
444 <body>\n<main>\n\
445 <h1>{heading}</h1>\n<ul>\n",
446 );
447 for entry in entries {
448 html.push_str(&format!(
449 "<li><a href=\"{}\">{}</a> <time datetime=\"{}\">{}</time></li>\n",
450 escape_html(&entry.url),
451 escape_html(&entry.title),
452 escape_html(&entry.date),
453 escape_html(&entry.date),
454 ));
455 }
456 html.push_str("</ul>\n");
457
458 if total_pages > 1 {
459 html.push_str("<nav aria-label=\"Pagination\">\n");
460 if page_num > 1 {
461 html.push_str(&format!(
462 "<a href=\"{}\" rel=\"prev\">← Previous</a>\n",
463 page_url(page_num - 1)
464 ));
465 }
466 if page_num < total_pages {
467 html.push_str(&format!(
468 "<a href=\"{}\" rel=\"next\">Next →</a>\n",
469 page_url(page_num + 1)
470 ));
471 }
472 html.push_str("</nav>\n");
473 }
474 html.push_str("</main>\n</body>\n</html>\n");
475
476 let out_file = dir.join("index.html");
477 fs::write(&out_file, html).with_path(&out_file)?;
478 Ok(())
479}
480
481fn escape_html(s: &str) -> String {
483 let mut out = String::with_capacity(s.len());
484 for c in s.chars() {
485 match c {
486 '&' => out.push_str("&"),
487 '<' => out.push_str("<"),
488 '>' => out.push_str(">"),
489 '"' => out.push_str("""),
490 '\'' => out.push_str("'"),
491 _ => out.push(c),
492 }
493 }
494 out
495}
496
497fn collect_json_files(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
498 crate::walk::walk_files(dir, "json")
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504 use crate::test_support::init_logger;
505 use std::path::PathBuf;
506 use tempfile::{tempdir, TempDir};
507
508 fn make_layout() -> (TempDir, PathBuf, PathBuf, PluginContext) {
517 init_logger();
518 let dir = tempdir().expect("create tempdir");
519 let site = dir.path().join("site");
520 let build = dir.path().join("build");
521 let meta = build.join(".meta");
522 fs::create_dir_all(&site).expect("mkdir site");
523 fs::create_dir_all(&meta).expect("mkdir meta");
524 let ctx = PluginContext::new(dir.path(), &build, &site, dir.path());
525 (dir, site, meta, ctx)
526 }
527
528 fn layout_with_listings(
530 listings: Vec<ListingConfig>,
531 ) -> (TempDir, PathBuf, PathBuf, PluginContext) {
532 init_logger();
533 let dir = tempdir().expect("create tempdir");
534 let site = dir.path().join("site");
535 let build = dir.path().join("build");
536 let meta = build.join(".meta");
537 fs::create_dir_all(&site).expect("mkdir site");
538 fs::create_dir_all(&meta).expect("mkdir meta");
539 let mut cfg = crate::cmd::default_config().as_ref().clone();
540 cfg.listings = listings;
541 let ctx = PluginContext::with_config(
542 dir.path(),
543 &build,
544 &site,
545 dir.path(),
546 cfg,
547 );
548 (dir, site, meta, ctx)
549 }
550
551 fn write_rich_sidecar(
553 meta: &Path,
554 name: &str,
555 title: &str,
556 date: &str,
557 extra: &str,
558 ) {
559 let json = format!(
560 r#"{{"title": "{title}", "date": "{date}"{}{extra}}}"#,
561 if extra.is_empty() { "" } else { ", " }
562 );
563 fs::write(meta.join(format!("{name}.meta.json")), json)
564 .expect("write sidecar");
565 }
566
567 #[test]
568 fn a_named_listing_writes_page_one_at_its_own_path() {
569 let (_d, site, meta, ctx) = layout_with_listings(vec![ListingConfig {
570 name: "archive".to_string(),
571 title: Some("Archive".to_string()),
572 ..ListingConfig::default()
573 }]);
574 write_sidecar(&meta, "a", "Alpha", "2026-01-01");
575
576 PaginationPlugin::default().after_compile(&ctx).unwrap();
577
578 let page = fs::read_to_string(site.join("archive/index.html"))
579 .expect("page 1 is written at the listing root");
580 assert!(page.contains("Archive"), "{page}");
581 assert!(page.contains("Alpha"), "{page}");
582 }
583
584 #[test]
585 fn a_listing_paginates_at_its_own_per_page() {
586 let (_d, site, meta, ctx) = layout_with_listings(vec![ListingConfig {
587 name: "archive".to_string(),
588 per_page: Some(2),
589 ..ListingConfig::default()
590 }]);
591 for i in 1..=5 {
592 write_sidecar(
593 &meta,
594 &format!("p{i}"),
595 &format!("P{i}"),
596 &format!("2026-01-0{i}"),
597 );
598 }
599
600 PaginationPlugin::default().after_compile(&ctx).unwrap();
601
602 assert!(site.join("archive/index.html").exists());
603 assert!(site.join("archive/page/2/index.html").exists());
604 assert!(site.join("archive/page/3/index.html").exists());
605 assert!(
606 !site.join("archive/page/4/index.html").exists(),
607 "5 entries at 2 per page is 3 pages"
608 );
609 let p2 =
610 fs::read_to_string(site.join("archive/page/2/index.html")).unwrap();
611 assert!(
612 p2.contains(r#"href="/archive/""#),
613 "prev goes to page 1: {p2}"
614 );
615 assert!(p2.contains(r#"href="/archive/page/3/""#), "next: {p2}");
616 }
617
618 #[test]
619 fn filters_select_the_pages_and_combine_with_and() {
620 let (_d, site, meta, ctx) = layout_with_listings(vec![ListingConfig {
621 name: "rust-2026".to_string(),
622 tag: Some("rust".to_string()),
623 after: Some("2026-01-01".to_string()),
624 ..ListingConfig::default()
625 }]);
626 write_rich_sidecar(
627 &meta,
628 "a",
629 "Match",
630 "2026-06-01",
631 r#""tags": "rust""#,
632 );
633 write_rich_sidecar(
634 &meta,
635 "b",
636 "WrongTag",
637 "2026-06-01",
638 r#""tags": "go""#,
639 );
640 write_rich_sidecar(
641 &meta,
642 "c",
643 "TooOld",
644 "2025-06-01",
645 r#""tags": "rust""#,
646 );
647
648 PaginationPlugin::default().after_compile(&ctx).unwrap();
649
650 let page =
651 fs::read_to_string(site.join("rust-2026/index.html")).unwrap();
652 assert!(page.contains("Match"), "{page}");
653 assert!(!page.contains("WrongTag"), "tag filter: {page}");
654 assert!(!page.contains("TooOld"), "date filter: {page}");
655 }
656
657 #[test]
659 fn term_filters_ignore_case() {
660 let (_d, site, meta, ctx) = layout_with_listings(vec![ListingConfig {
661 name: "r".to_string(),
662 tag: Some("Rust".to_string()),
663 ..ListingConfig::default()
664 }]);
665 write_rich_sidecar(
666 &meta,
667 "a",
668 "Alpha",
669 "2026-01-01",
670 r#""tags": "rust""#,
671 );
672
673 PaginationPlugin::default().after_compile(&ctx).unwrap();
674 assert!(fs::read_to_string(site.join("r/index.html"))
675 .unwrap()
676 .contains("Alpha"));
677 }
678
679 #[test]
680 fn by_year_writes_one_archive_per_year() {
681 let (_d, site, meta, ctx) = layout_with_listings(vec![ListingConfig {
682 name: "archive".to_string(),
683 by_year: true,
684 ..ListingConfig::default()
685 }]);
686 write_sidecar(&meta, "a", "Old", "2025-03-01");
687 write_sidecar(&meta, "b", "New", "2026-03-01");
688
689 PaginationPlugin::default().after_compile(&ctx).unwrap();
690
691 let y2025 =
692 fs::read_to_string(site.join("archive/2025/index.html")).unwrap();
693 assert!(y2025.contains("Old") && !y2025.contains("New"), "{y2025}");
694 let y2026 =
695 fs::read_to_string(site.join("archive/2026/index.html")).unwrap();
696 assert!(y2026.contains("New") && !y2026.contains("Old"), "{y2026}");
697 }
698
699 #[test]
701 fn a_listing_matching_nothing_writes_no_directory() {
702 let (_d, site, meta, ctx) = layout_with_listings(vec![ListingConfig {
703 name: "ghost".to_string(),
704 tag: Some("nonexistent".to_string()),
705 ..ListingConfig::default()
706 }]);
707 write_sidecar(&meta, "a", "Alpha", "2026-01-01");
708
709 PaginationPlugin::default().after_compile(&ctx).unwrap();
710 assert!(!site.join("ghost").exists(), "no empty listing directory");
711 }
712
713 #[test]
715 fn titles_are_escaped_in_generated_listings() {
716 let (_d, site, meta, ctx) = layout_with_listings(vec![ListingConfig {
717 name: "archive".to_string(),
718 ..ListingConfig::default()
719 }]);
720 write_sidecar(&meta, "a", "A <b>bold</b> title", "2026-01-01");
721
722 PaginationPlugin::default().after_compile(&ctx).unwrap();
723 let page = fs::read_to_string(site.join("archive/index.html")).unwrap();
724 assert!(page.contains("<b>bold</b>"), "{page}");
725 assert!(!page.contains("<b>bold</b>"), "{page}");
726 }
727
728 #[test]
730 fn no_listings_configured_writes_no_listing_directories() {
731 let (_d, site, meta, ctx) = make_layout();
732 write_n_dated_posts(&meta, 25);
733
734 PaginationPlugin::default().after_compile(&ctx).unwrap();
735
736 assert!(
737 site.join("page/2/index.html").exists(),
738 "site-wide unchanged"
739 );
740 let dirs: Vec<_> = fs::read_dir(&site)
741 .unwrap()
742 .filter_map(Result::ok)
743 .map(|e| e.file_name().to_string_lossy().to_string())
744 .collect();
745 assert_eq!(dirs, vec!["page".to_string()], "only /page/: {dirs:?}");
746 }
747
748 fn write_sidecar(meta: &Path, name: &str, title: &str, date: &str) {
750 let json = if date.is_empty() {
751 format!(r#"{{"title": "{title}"}}"#)
752 } else {
753 format!(r#"{{"title": "{title}", "date": "{date}"}}"#)
754 };
755 fs::write(meta.join(format!("{name}.meta.json")), json)
756 .expect("write sidecar");
757 }
758
759 fn write_n_dated_posts(meta: &Path, n: usize) {
762 for i in 1..=n {
763 write_sidecar(
764 meta,
765 &format!("post{i:03}"),
766 &format!("Post {i}"),
767 &format!("2026-01-{i:02}"),
768 );
769 }
770 }
771
772 #[test]
777 fn default_uses_default_per_page_constant() {
778 let plugin = PaginationPlugin::default();
782 assert_eq!(plugin.per_page, DEFAULT_PER_PAGE);
783 }
784
785 #[test]
786 fn with_per_page_stores_supplied_value() {
787 let plugin = PaginationPlugin::with_per_page(7);
788 assert_eq!(plugin.per_page, 7);
789 }
790
791 #[test]
792 fn with_per_page_zero_clamps_to_one() {
793 let plugin = PaginationPlugin::with_per_page(0);
796 assert_eq!(plugin.per_page, 1);
797 }
798
799 #[test]
800 fn with_per_page_one_is_valid_lower_bound() {
801 let plugin = PaginationPlugin::with_per_page(1);
802 assert_eq!(plugin.per_page, 1);
803 }
804
805 #[test]
806 fn with_per_page_table_driven_values() {
807 let cases: &[(usize, usize)] = &[
809 (1, 1),
810 (5, 5),
811 (10, 10),
812 (100, 100),
813 (usize::MAX, usize::MAX),
814 ];
815 for &(input, expected) in cases {
816 let plugin = PaginationPlugin::with_per_page(input);
817 assert_eq!(
818 plugin.per_page, expected,
819 "with_per_page({input}) should store {expected}"
820 );
821 }
822 }
823
824 #[test]
825 fn pagination_plugin_is_copy_after_move() {
826 let plugin = PaginationPlugin::with_per_page(3);
828 let _copy = plugin;
829 assert_eq!(plugin.per_page, 3);
830 }
831
832 #[test]
833 fn name_returns_static_pagination_identifier() {
834 let plugin = PaginationPlugin::default();
835 assert_eq!(plugin.name(), "pagination");
836 }
837
838 #[test]
843 fn after_compile_missing_meta_dir_returns_ok_without_writing() {
844 let dir = tempdir().expect("tempdir");
847 let site = dir.path().join("site");
848 let build = dir.path().join("build");
849 fs::create_dir_all(&site).expect("mkdir site");
850 fs::create_dir_all(&build).expect("mkdir build");
851 let ctx = PluginContext::new(dir.path(), &build, &site, dir.path());
852
853 PaginationPlugin::default()
854 .after_compile(&ctx)
855 .expect("missing meta dir is not an error");
856
857 assert!(!site.join("page").exists());
858 }
859
860 #[test]
861 fn after_compile_empty_meta_dir_returns_ok_without_writing() {
862 let (_tmp, site, _meta, ctx) = make_layout();
863 PaginationPlugin::default()
864 .after_compile(&ctx)
865 .expect("empty meta is fine");
866 assert!(!site.join("page").exists());
867 }
868
869 #[test]
870 fn after_compile_only_undated_pages_returns_ok_without_writing() {
871 let (_tmp, site, meta, ctx) = make_layout();
874 write_sidecar(&meta, "about", "About", "");
875 write_sidecar(&meta, "contact", "Contact", "");
876
877 PaginationPlugin::default().after_compile(&ctx).unwrap();
878 assert!(!site.join("page").exists());
879 }
880
881 #[test]
882 fn after_compile_single_page_skips_pagination() {
883 let (_tmp, site, meta, ctx) = make_layout();
886 write_n_dated_posts(&meta, 5);
887
888 PaginationPlugin::default().after_compile(&ctx).unwrap();
889 assert!(!site.join("page").exists());
890 }
891
892 #[test]
897 fn after_compile_skips_invalid_json_sidecars() {
898 let (_tmp, site, meta, ctx) = make_layout();
902 fs::write(meta.join("broken.meta.json"), "{not valid json").unwrap();
903 write_n_dated_posts(&meta, 11);
906
907 PaginationPlugin::default()
908 .after_compile(&ctx)
909 .expect("broken sidecar must not error");
910 assert!(site.join("page/2/index.html").exists());
911 }
912
913 #[test]
914 fn after_compile_missing_title_defaults_to_untitled() {
915 let (_tmp, site, meta, ctx) = make_layout();
918 for i in 1..=11 {
920 fs::write(
921 meta.join(format!("post{i}.meta.json")),
922 format!(r#"{{"date": "2026-01-{i:02}"}}"#),
923 )
924 .unwrap();
925 }
926
927 PaginationPlugin::default().after_compile(&ctx).unwrap();
928 let page2 = fs::read_to_string(site.join("page/2/index.html")).unwrap();
929 assert!(
930 page2.contains("Untitled"),
931 "missing title must fall back to \"Untitled\":\n{page2}"
932 );
933 }
934
935 #[test]
936 fn after_compile_skips_pages_with_empty_date_string() {
937 let (_tmp, site, meta, ctx) = make_layout();
940 write_sidecar(&meta, "draft", "Draft", ""); write_n_dated_posts(&meta, 11);
942
943 PaginationPlugin::default().after_compile(&ctx).unwrap();
944 assert!(site.join("page/2/index.html").exists());
947 assert!(!site.join("page/3/index.html").exists());
948 }
949
950 #[test]
955 fn after_compile_exact_multiple_yields_full_pages() {
956 let (_tmp, site, meta, ctx) = make_layout();
958 write_n_dated_posts(&meta, 10);
959
960 PaginationPlugin::with_per_page(5)
961 .after_compile(&ctx)
962 .unwrap();
963
964 let page2 = fs::read_to_string(site.join("page/2/index.html")).unwrap();
965 let li_count = page2.matches("<li>").count();
967 assert_eq!(li_count, 5, "page 2 should have 5 entries:\n{page2}");
968 assert!(!site.join("page/3/index.html").exists());
969 }
970
971 #[test]
972 fn after_compile_non_multiple_yields_partial_last_page() {
973 let (_tmp, site, meta, ctx) = make_layout();
975 write_n_dated_posts(&meta, 11);
976
977 PaginationPlugin::with_per_page(5)
978 .after_compile(&ctx)
979 .unwrap();
980
981 assert!(site.join("page/2/index.html").exists());
982 assert!(site.join("page/3/index.html").exists());
983 assert!(!site.join("page/4/index.html").exists());
984
985 let page3 = fs::read_to_string(site.join("page/3/index.html")).unwrap();
986 let li_count = page3.matches("<li>").count();
987 assert_eq!(li_count, 1, "last page should have 1 entry:\n{page3}");
988 }
989
990 #[test]
991 fn after_compile_per_page_one_yields_one_page_per_post() {
992 let (_tmp, site, meta, ctx) = make_layout();
994 write_n_dated_posts(&meta, 5);
995
996 PaginationPlugin::with_per_page(1)
997 .after_compile(&ctx)
998 .unwrap();
999
1000 for n in 2..=5 {
1001 assert!(
1002 site.join(format!("page/{n}/index.html")).exists(),
1003 "page/{n}/ should exist"
1004 );
1005 }
1006 assert!(!site.join("page/6/index.html").exists());
1007 }
1008
1009 #[test]
1014 fn after_compile_sorts_entries_by_date_descending() {
1015 let (_tmp, site, meta, ctx) = make_layout();
1019 let dates = [
1023 ("a", "2026-01-01"),
1024 ("m", "2026-01-05"),
1025 ("z", "2026-01-11"),
1026 ("b", "2026-01-02"),
1027 ("y", "2026-01-10"),
1028 ("c", "2026-01-03"),
1029 ("x", "2026-01-09"),
1030 ("d", "2026-01-04"),
1031 ("w", "2026-01-08"),
1032 ("e", "2026-01-06"),
1033 ("f", "2026-01-07"),
1034 ];
1035 for (name, date) in dates {
1036 write_sidecar(&meta, name, &format!("Post {name}"), date);
1037 }
1038
1039 PaginationPlugin::with_per_page(10)
1040 .after_compile(&ctx)
1041 .unwrap();
1042
1043 let page2 = fs::read_to_string(site.join("page/2/index.html")).unwrap();
1045 assert!(
1046 page2.contains("2026-01-01"),
1047 "page 2 should contain the oldest entry:\n{page2}"
1048 );
1049 assert!(
1050 !page2.contains("2026-01-11"),
1051 "page 2 should NOT contain the newest entry:\n{page2}"
1052 );
1053 }
1054
1055 #[test]
1060 fn after_compile_emits_doctype_lang_and_charset() {
1061 let (_tmp, site, meta, ctx) = make_layout();
1062 write_n_dated_posts(&meta, 11);
1063 PaginationPlugin::default().after_compile(&ctx).unwrap();
1064
1065 let html = fs::read_to_string(site.join("page/2/index.html")).unwrap();
1066 assert!(html.starts_with("<!DOCTYPE html>"));
1067 assert!(html.contains("<html lang=\"en\">"));
1068 assert!(html.contains("<meta charset=\"utf-8\">"));
1069 }
1070
1071 #[test]
1072 fn after_compile_emits_pagination_nav_landmark() {
1073 let (_tmp, site, meta, ctx) = make_layout();
1074 write_n_dated_posts(&meta, 11);
1075 PaginationPlugin::default().after_compile(&ctx).unwrap();
1076
1077 let html = fs::read_to_string(site.join("page/2/index.html")).unwrap();
1078 assert!(html.contains("<nav aria-label=\"Pagination\">"));
1079 }
1080
1081 #[test]
1082 fn after_compile_page_two_prev_link_points_at_root() {
1083 let (_tmp, site, meta, ctx) = make_layout();
1086 write_n_dated_posts(&meta, 11);
1087 PaginationPlugin::default().after_compile(&ctx).unwrap();
1088
1089 let html = fs::read_to_string(site.join("page/2/index.html")).unwrap();
1090 assert!(
1091 html.contains(r#"<a href="/" rel="prev">"#),
1092 "page 2's prev should point to root:\n{html}"
1093 );
1094 }
1095
1096 #[test]
1097 fn after_compile_page_three_prev_link_points_at_page_two() {
1098 let (_tmp, site, meta, ctx) = make_layout();
1100 write_n_dated_posts(&meta, 11);
1101 PaginationPlugin::with_per_page(5)
1102 .after_compile(&ctx)
1103 .unwrap();
1104
1105 let html = fs::read_to_string(site.join("page/3/index.html")).unwrap();
1106 assert!(
1107 html.contains(r#"<a href="/page/2/" rel="prev">"#),
1108 "page 3's prev should point to /page/2/:\n{html}"
1109 );
1110 }
1111
1112 #[test]
1113 fn after_compile_last_page_has_no_next_link() {
1114 let (_tmp, site, meta, ctx) = make_layout();
1117 write_n_dated_posts(&meta, 11);
1118 PaginationPlugin::with_per_page(5)
1119 .after_compile(&ctx)
1120 .unwrap();
1121
1122 let last = fs::read_to_string(site.join("page/3/index.html")).unwrap();
1123 assert!(
1124 !last.contains(r#"rel="next""#),
1125 "last page must not emit a Next link:\n{last}"
1126 );
1127 }
1128
1129 #[test]
1130 fn after_compile_middle_page_has_both_prev_and_next() {
1131 let (_tmp, site, meta, ctx) = make_layout();
1134 write_n_dated_posts(&meta, 16);
1135 PaginationPlugin::with_per_page(5)
1136 .after_compile(&ctx)
1137 .unwrap();
1138
1139 let page3 = fs::read_to_string(site.join("page/3/index.html")).unwrap();
1140 assert!(page3.contains(r#"rel="prev""#));
1141 assert!(page3.contains(r#"rel="next""#));
1142 }
1143
1144 #[test]
1145 fn after_compile_renders_time_element_per_entry() {
1146 let (_tmp, site, meta, ctx) = make_layout();
1147 write_n_dated_posts(&meta, 11);
1148 PaginationPlugin::default().after_compile(&ctx).unwrap();
1149
1150 let html = fs::read_to_string(site.join("page/2/index.html")).unwrap();
1151 assert!(
1152 html.contains("<time>2026-01-01</time>"),
1153 "page 2 should render a <time> element:\n{html}"
1154 );
1155 }
1156
1157 #[test]
1158 fn after_compile_idempotent_overwrites_existing_pages() {
1159 let (_tmp, site, meta, ctx) = make_layout();
1162 write_n_dated_posts(&meta, 11);
1163 let plugin = PaginationPlugin::default();
1164 plugin.after_compile(&ctx).expect("first run");
1165 plugin.after_compile(&ctx).expect("second run");
1166 assert!(site.join("page/2/index.html").exists());
1167 }
1168
1169 #[test]
1174 fn collect_json_files_returns_empty_for_missing_directory() {
1175 let dir = tempdir().expect("tempdir");
1178 let result =
1179 collect_json_files(&dir.path().join("does-not-exist")).unwrap();
1180 assert!(result.is_empty(), "{:?}", result);
1181 }
1182
1183 #[test]
1184 fn collect_json_files_returns_empty_for_empty_directory() {
1185 let dir = tempdir().expect("tempdir");
1186 let result = collect_json_files(dir.path()).unwrap();
1187 assert!(result.is_empty(), "{:?}", result);
1188 }
1189
1190 #[test]
1191 fn collect_json_files_filters_non_json_extensions() {
1192 let dir = tempdir().expect("tempdir");
1195 fs::write(dir.path().join("a.json"), "{}").unwrap();
1196 fs::write(dir.path().join("b.txt"), "x").unwrap();
1197 fs::write(dir.path().join("c.md"), "x").unwrap();
1198 fs::write(dir.path().join("noext"), "x").unwrap();
1199
1200 let result = collect_json_files(dir.path()).unwrap();
1201 assert_eq!(result.len(), 1);
1202 assert_eq!(
1203 result[0].file_name().unwrap(),
1204 std::ffi::OsStr::new("a.json")
1205 );
1206 }
1207
1208 #[test]
1209 fn collect_json_files_recurses_into_subdirectories() {
1210 let dir = tempdir().expect("tempdir");
1213 let nested = dir.path().join("a").join("b").join("c");
1214 fs::create_dir_all(&nested).unwrap();
1215 fs::write(dir.path().join("top.json"), "{}").unwrap();
1216 fs::write(dir.path().join("a").join("mid.json"), "{}").unwrap();
1217 fs::write(nested.join("deep.json"), "{}").unwrap();
1218
1219 let result = collect_json_files(dir.path()).unwrap();
1220 assert_eq!(result.len(), 3);
1221 }
1222
1223 #[test]
1224 fn collect_json_files_returns_results_sorted() {
1225 let dir = tempdir().expect("tempdir");
1227 for name in ["zebra.json", "apple.json", "mango.json"] {
1228 fs::write(dir.path().join(name), "{}").unwrap();
1229 }
1230 let result = collect_json_files(dir.path()).unwrap();
1231 let names: Vec<&str> = result
1232 .iter()
1233 .map(|p| p.file_name().unwrap().to_str().unwrap())
1234 .collect();
1235 assert_eq!(names, vec!["apple.json", "mango.json", "zebra.json"]);
1236 }
1237
1238 #[test]
1243 #[cfg(unix)]
1244 fn after_compile_propagates_walk_error_from_unreadable_meta_subdir() {
1245 use std::os::unix::fs::PermissionsExt;
1246
1247 let (_tmp, _site, meta, ctx) = make_layout();
1248 let locked = meta.join("locked");
1249 fs::create_dir_all(&locked).unwrap();
1250 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
1251 .unwrap();
1252
1253 let result = PaginationPlugin::default().after_compile(&ctx);
1254 fs::set_permissions(&locked, fs::Permissions::from_mode(0o755))
1255 .unwrap();
1256 assert!(result.is_err(), "unreadable meta subdir must be an Err");
1257 }
1258
1259 #[test]
1260 #[cfg(unix)]
1261 fn after_compile_skips_unreadable_sidecar_file() {
1262 use std::os::unix::fs::PermissionsExt;
1263
1264 let (_tmp, site, meta, ctx) = make_layout();
1268 write_n_dated_posts(&meta, 11);
1269 let locked = meta.join("locked.meta.json");
1270 fs::write(&locked, r#"{"title": "L", "date": "2026-02-01"}"#).unwrap();
1271 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
1272 .unwrap();
1273
1274 let result = PaginationPlugin::default().after_compile(&ctx);
1275 fs::set_permissions(&locked, fs::Permissions::from_mode(0o644))
1276 .unwrap();
1277 result.expect("unreadable sidecar must be skipped, not fatal");
1278 assert!(site.join("page/2/index.html").exists());
1279 assert!(!site.join("page/3/index.html").exists());
1280 }
1281
1282 #[test]
1283 fn after_compile_create_dir_failure_when_page_is_a_file() {
1284 let (_tmp, site, meta, ctx) = make_layout();
1287 write_n_dated_posts(&meta, 11);
1288 fs::write(site.join("page"), "not a directory").unwrap();
1289
1290 let err = PaginationPlugin::default()
1291 .after_compile(&ctx)
1292 .expect_err("create_dir_all over a file must fail");
1293 let msg = format!("{err:?}");
1294 assert!(msg.contains("Io"), "expected Io error, got: {msg}");
1295 }
1296
1297 #[test]
1298 fn after_compile_write_failure_when_index_html_is_a_directory() {
1299 let (_tmp, site, meta, ctx) = make_layout();
1302 write_n_dated_posts(&meta, 11);
1303 fs::create_dir_all(site.join("page/2/index.html")).unwrap();
1304
1305 let err = PaginationPlugin::default()
1306 .after_compile(&ctx)
1307 .expect_err("write over a directory must fail");
1308 let msg = format!("{err:?}");
1309 assert!(msg.contains("index.html"), "path context expected: {msg}");
1310 }
1311}