1use crate::plugin::{Plugin, PluginContext};
10use anyhow::Result;
11use std::{
12 collections::HashMap,
13 fs,
14 path::{Path, PathBuf},
15};
16
17const DEFAULT_PER_PAGE: usize = 10;
19
20#[derive(Debug, Clone)]
22struct PageEntry {
23 title: String,
24 url: String,
25 date: String,
26}
27
28#[derive(Debug, Clone, Copy)]
34pub struct PaginationPlugin {
35 per_page: usize,
36}
37
38impl Default for PaginationPlugin {
39 fn default() -> Self {
40 Self {
41 per_page: DEFAULT_PER_PAGE,
42 }
43 }
44}
45
46impl PaginationPlugin {
47 #[must_use]
49 pub fn with_per_page(per_page: usize) -> Self {
50 Self {
51 per_page: per_page.max(1),
52 }
53 }
54}
55
56impl Plugin for PaginationPlugin {
57 fn name(&self) -> &'static str {
58 "pagination"
59 }
60
61 fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
62 let sidecar_dir = ctx.build_dir.join(".meta");
63 if !sidecar_dir.exists() {
64 return Ok(());
65 }
66
67 let mut entries = collect_page_entries(&sidecar_dir)?;
68 if entries.is_empty() {
69 return Ok(());
70 }
71
72 entries.sort_by(|a, b| b.date.cmp(&a.date));
73
74 let total_pages = entries.len().div_ceil(self.per_page);
75 if total_pages <= 1 {
76 return Ok(());
77 }
78
79 let page_dir = ctx.site_dir.join("page");
80 for page_num in 2..=total_pages {
81 let start = (page_num - 1) * self.per_page;
82 let end = (start + self.per_page).min(entries.len());
83 let page_entries = &entries[start..end];
84
85 write_pagination_page(
86 &page_dir,
87 page_num,
88 total_pages,
89 page_entries,
90 )?;
91 }
92
93 log::info!(
94 "[pagination] Generated {} page(s) ({} entries, {} per page)",
95 total_pages - 1,
96 entries.len(),
97 self.per_page
98 );
99 Ok(())
100 }
101}
102
103fn collect_page_entries(sidecar_dir: &Path) -> Result<Vec<PageEntry>> {
105 let sidecars = collect_json_files(sidecar_dir)?;
106 let mut entries = Vec::new();
107
108 for sidecar_path in &sidecars {
109 if let Some(entry) = parse_page_entry(sidecar_path, sidecar_dir) {
110 entries.push(entry);
111 }
112 }
113
114 Ok(entries)
115}
116
117fn parse_page_entry(
119 sidecar_path: &Path,
120 sidecar_dir: &Path,
121) -> Option<PageEntry> {
122 let content = fs::read_to_string(sidecar_path).ok()?;
123 let meta: HashMap<String, serde_json::Value> =
124 serde_json::from_str(&content).ok()?;
125
126 let title = meta
127 .get("title")
128 .and_then(|v| v.as_str())
129 .unwrap_or("Untitled")
130 .to_string();
131 let date = meta
132 .get("date")
133 .and_then(|v| v.as_str())
134 .unwrap_or("")
135 .to_string();
136
137 if date.is_empty() {
138 return None;
139 }
140
141 let rel = sidecar_path
142 .strip_prefix(sidecar_dir)
143 .unwrap_or(sidecar_path)
144 .with_extension("")
145 .with_extension("html");
146 let url = format!("/{}", rel.to_string_lossy().replace('\\', "/"));
147
148 Some(PageEntry { title, url, date })
149}
150
151fn write_pagination_page(
153 page_dir: &Path,
154 page_num: usize,
155 total_pages: usize,
156 page_entries: &[PageEntry],
157) -> Result<()> {
158 let dir = page_dir.join(page_num.to_string());
159 fs::create_dir_all(&dir)?;
160
161 let prev_url = if page_num == 2 {
162 "/".to_string()
163 } else {
164 format!("/page/{}/", page_num - 1)
165 };
166 let next_url = if page_num < total_pages {
167 Some(format!("/page/{}/", page_num + 1))
168 } else {
169 None
170 };
171
172 let mut html = format!(
173 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\
174 <meta charset=\"utf-8\">\
175 <title>Page {page_num} of {total_pages}</title></head>\n\
176 <body>\n<main>\n\
177 <h1>Page {page_num} of {total_pages}</h1>\n<ul>\n",
178 );
179
180 for entry in page_entries {
181 html.push_str(&format!(
182 "<li><a href=\"{}\">{}</a> <time>{}</time></li>\n",
183 entry.url, entry.title, entry.date
184 ));
185 }
186
187 html.push_str("</ul>\n<nav aria-label=\"Pagination\">\n");
188 html.push_str(&format!(
189 "<a href=\"{prev_url}\" rel=\"prev\">← Previous</a>\n"
190 ));
191 if let Some(next) = &next_url {
192 html.push_str(&format!(
193 "<a href=\"{next}\" rel=\"next\">Next →</a>\n"
194 ));
195 }
196 html.push_str("</nav>\n</main>\n</body>\n</html>\n");
197
198 fs::write(dir.join("index.html"), html)?;
199 Ok(())
200}
201
202fn collect_json_files(dir: &Path) -> Result<Vec<PathBuf>> {
203 crate::walk::walk_files(dir, "json")
204}
205
206#[cfg(test)]
207#[allow(clippy::unwrap_used, clippy::expect_used)]
208mod tests {
209 use super::*;
210 use crate::test_support::init_logger;
211 use std::path::PathBuf;
212 use tempfile::{tempdir, TempDir};
213
214 fn make_layout() -> (TempDir, PathBuf, PathBuf, PluginContext) {
223 init_logger();
224 let dir = tempdir().expect("create tempdir");
225 let site = dir.path().join("site");
226 let build = dir.path().join("build");
227 let meta = build.join(".meta");
228 fs::create_dir_all(&site).expect("mkdir site");
229 fs::create_dir_all(&meta).expect("mkdir meta");
230 let ctx = PluginContext::new(dir.path(), &build, &site, dir.path());
231 (dir, site, meta, ctx)
232 }
233
234 fn write_sidecar(meta: &Path, name: &str, title: &str, date: &str) {
236 let json = if date.is_empty() {
237 format!(r#"{{"title": "{title}"}}"#)
238 } else {
239 format!(r#"{{"title": "{title}", "date": "{date}"}}"#)
240 };
241 fs::write(meta.join(format!("{name}.meta.json")), json)
242 .expect("write sidecar");
243 }
244
245 fn write_n_dated_posts(meta: &Path, n: usize) {
248 for i in 1..=n {
249 write_sidecar(
250 meta,
251 &format!("post{i:03}"),
252 &format!("Post {i}"),
253 &format!("2026-01-{i:02}"),
254 );
255 }
256 }
257
258 #[test]
263 fn default_uses_default_per_page_constant() {
264 let plugin = PaginationPlugin::default();
268 assert_eq!(plugin.per_page, DEFAULT_PER_PAGE);
269 }
270
271 #[test]
272 fn with_per_page_stores_supplied_value() {
273 let plugin = PaginationPlugin::with_per_page(7);
274 assert_eq!(plugin.per_page, 7);
275 }
276
277 #[test]
278 fn with_per_page_zero_clamps_to_one() {
279 let plugin = PaginationPlugin::with_per_page(0);
282 assert_eq!(plugin.per_page, 1);
283 }
284
285 #[test]
286 fn with_per_page_one_is_valid_lower_bound() {
287 let plugin = PaginationPlugin::with_per_page(1);
288 assert_eq!(plugin.per_page, 1);
289 }
290
291 #[test]
292 fn with_per_page_table_driven_values() {
293 let cases: &[(usize, usize)] = &[
295 (1, 1),
296 (5, 5),
297 (10, 10),
298 (100, 100),
299 (usize::MAX, usize::MAX),
300 ];
301 for &(input, expected) in cases {
302 let plugin = PaginationPlugin::with_per_page(input);
303 assert_eq!(
304 plugin.per_page, expected,
305 "with_per_page({input}) should store {expected}"
306 );
307 }
308 }
309
310 #[test]
311 fn pagination_plugin_is_copy_after_move() {
312 let plugin = PaginationPlugin::with_per_page(3);
314 let _copy = plugin;
315 assert_eq!(plugin.per_page, 3);
316 }
317
318 #[test]
319 fn name_returns_static_pagination_identifier() {
320 let plugin = PaginationPlugin::default();
321 assert_eq!(plugin.name(), "pagination");
322 }
323
324 #[test]
329 fn after_compile_missing_meta_dir_returns_ok_without_writing() {
330 let dir = tempdir().expect("tempdir");
333 let site = dir.path().join("site");
334 let build = dir.path().join("build");
335 fs::create_dir_all(&site).expect("mkdir site");
336 fs::create_dir_all(&build).expect("mkdir build");
337 let ctx = PluginContext::new(dir.path(), &build, &site, dir.path());
338
339 PaginationPlugin::default()
340 .after_compile(&ctx)
341 .expect("missing meta dir is not an error");
342
343 assert!(!site.join("page").exists());
344 }
345
346 #[test]
347 fn after_compile_empty_meta_dir_returns_ok_without_writing() {
348 let (_tmp, site, _meta, ctx) = make_layout();
349 PaginationPlugin::default()
350 .after_compile(&ctx)
351 .expect("empty meta is fine");
352 assert!(!site.join("page").exists());
353 }
354
355 #[test]
356 fn after_compile_only_undated_pages_returns_ok_without_writing() {
357 let (_tmp, site, meta, ctx) = make_layout();
360 write_sidecar(&meta, "about", "About", "");
361 write_sidecar(&meta, "contact", "Contact", "");
362
363 PaginationPlugin::default().after_compile(&ctx).unwrap();
364 assert!(!site.join("page").exists());
365 }
366
367 #[test]
368 fn after_compile_single_page_skips_pagination() {
369 let (_tmp, site, meta, ctx) = make_layout();
372 write_n_dated_posts(&meta, 5);
373
374 PaginationPlugin::default().after_compile(&ctx).unwrap();
375 assert!(!site.join("page").exists());
376 }
377
378 #[test]
383 fn after_compile_skips_invalid_json_sidecars() {
384 let (_tmp, site, meta, ctx) = make_layout();
388 fs::write(meta.join("broken.meta.json"), "{not valid json").unwrap();
389 write_n_dated_posts(&meta, 11);
392
393 PaginationPlugin::default()
394 .after_compile(&ctx)
395 .expect("broken sidecar must not error");
396 assert!(site.join("page/2/index.html").exists());
397 }
398
399 #[test]
400 fn after_compile_missing_title_defaults_to_untitled() {
401 let (_tmp, site, meta, ctx) = make_layout();
404 for i in 1..=11 {
406 fs::write(
407 meta.join(format!("post{i}.meta.json")),
408 format!(r#"{{"date": "2026-01-{i:02}"}}"#),
409 )
410 .unwrap();
411 }
412
413 PaginationPlugin::default().after_compile(&ctx).unwrap();
414 let page2 = fs::read_to_string(site.join("page/2/index.html")).unwrap();
415 assert!(
416 page2.contains("Untitled"),
417 "missing title must fall back to \"Untitled\":\n{page2}"
418 );
419 }
420
421 #[test]
422 fn after_compile_skips_pages_with_empty_date_string() {
423 let (_tmp, site, meta, ctx) = make_layout();
426 write_sidecar(&meta, "draft", "Draft", ""); write_n_dated_posts(&meta, 11);
428
429 PaginationPlugin::default().after_compile(&ctx).unwrap();
430 assert!(site.join("page/2/index.html").exists());
433 assert!(!site.join("page/3/index.html").exists());
434 }
435
436 #[test]
441 fn after_compile_exact_multiple_yields_full_pages() {
442 let (_tmp, site, meta, ctx) = make_layout();
444 write_n_dated_posts(&meta, 10);
445
446 PaginationPlugin::with_per_page(5)
447 .after_compile(&ctx)
448 .unwrap();
449
450 let page2 = fs::read_to_string(site.join("page/2/index.html")).unwrap();
451 let li_count = page2.matches("<li>").count();
453 assert_eq!(li_count, 5, "page 2 should have 5 entries:\n{page2}");
454 assert!(!site.join("page/3/index.html").exists());
455 }
456
457 #[test]
458 fn after_compile_non_multiple_yields_partial_last_page() {
459 let (_tmp, site, meta, ctx) = make_layout();
461 write_n_dated_posts(&meta, 11);
462
463 PaginationPlugin::with_per_page(5)
464 .after_compile(&ctx)
465 .unwrap();
466
467 assert!(site.join("page/2/index.html").exists());
468 assert!(site.join("page/3/index.html").exists());
469 assert!(!site.join("page/4/index.html").exists());
470
471 let page3 = fs::read_to_string(site.join("page/3/index.html")).unwrap();
472 let li_count = page3.matches("<li>").count();
473 assert_eq!(li_count, 1, "last page should have 1 entry:\n{page3}");
474 }
475
476 #[test]
477 fn after_compile_per_page_one_yields_one_page_per_post() {
478 let (_tmp, site, meta, ctx) = make_layout();
480 write_n_dated_posts(&meta, 5);
481
482 PaginationPlugin::with_per_page(1)
483 .after_compile(&ctx)
484 .unwrap();
485
486 for n in 2..=5 {
487 assert!(
488 site.join(format!("page/{n}/index.html")).exists(),
489 "page/{n}/ should exist"
490 );
491 }
492 assert!(!site.join("page/6/index.html").exists());
493 }
494
495 #[test]
500 fn after_compile_sorts_entries_by_date_descending() {
501 let (_tmp, site, meta, ctx) = make_layout();
505 let dates = [
509 ("a", "2026-01-01"),
510 ("m", "2026-01-05"),
511 ("z", "2026-01-11"),
512 ("b", "2026-01-02"),
513 ("y", "2026-01-10"),
514 ("c", "2026-01-03"),
515 ("x", "2026-01-09"),
516 ("d", "2026-01-04"),
517 ("w", "2026-01-08"),
518 ("e", "2026-01-06"),
519 ("f", "2026-01-07"),
520 ];
521 for (name, date) in dates {
522 write_sidecar(&meta, name, &format!("Post {name}"), date);
523 }
524
525 PaginationPlugin::with_per_page(10)
526 .after_compile(&ctx)
527 .unwrap();
528
529 let page2 = fs::read_to_string(site.join("page/2/index.html")).unwrap();
531 assert!(
532 page2.contains("2026-01-01"),
533 "page 2 should contain the oldest entry:\n{page2}"
534 );
535 assert!(
536 !page2.contains("2026-01-11"),
537 "page 2 should NOT contain the newest entry:\n{page2}"
538 );
539 }
540
541 #[test]
546 fn after_compile_emits_doctype_lang_and_charset() {
547 let (_tmp, site, meta, ctx) = make_layout();
548 write_n_dated_posts(&meta, 11);
549 PaginationPlugin::default().after_compile(&ctx).unwrap();
550
551 let html = fs::read_to_string(site.join("page/2/index.html")).unwrap();
552 assert!(html.starts_with("<!DOCTYPE html>"));
553 assert!(html.contains("<html lang=\"en\">"));
554 assert!(html.contains("<meta charset=\"utf-8\">"));
555 }
556
557 #[test]
558 fn after_compile_emits_pagination_nav_landmark() {
559 let (_tmp, site, meta, ctx) = make_layout();
560 write_n_dated_posts(&meta, 11);
561 PaginationPlugin::default().after_compile(&ctx).unwrap();
562
563 let html = fs::read_to_string(site.join("page/2/index.html")).unwrap();
564 assert!(html.contains("<nav aria-label=\"Pagination\">"));
565 }
566
567 #[test]
568 fn after_compile_page_two_prev_link_points_at_root() {
569 let (_tmp, site, meta, ctx) = make_layout();
572 write_n_dated_posts(&meta, 11);
573 PaginationPlugin::default().after_compile(&ctx).unwrap();
574
575 let html = fs::read_to_string(site.join("page/2/index.html")).unwrap();
576 assert!(
577 html.contains(r#"<a href="/" rel="prev">"#),
578 "page 2's prev should point to root:\n{html}"
579 );
580 }
581
582 #[test]
583 fn after_compile_page_three_prev_link_points_at_page_two() {
584 let (_tmp, site, meta, ctx) = make_layout();
586 write_n_dated_posts(&meta, 11);
587 PaginationPlugin::with_per_page(5)
588 .after_compile(&ctx)
589 .unwrap();
590
591 let html = fs::read_to_string(site.join("page/3/index.html")).unwrap();
592 assert!(
593 html.contains(r#"<a href="/page/2/" rel="prev">"#),
594 "page 3's prev should point to /page/2/:\n{html}"
595 );
596 }
597
598 #[test]
599 fn after_compile_last_page_has_no_next_link() {
600 let (_tmp, site, meta, ctx) = make_layout();
603 write_n_dated_posts(&meta, 11);
604 PaginationPlugin::with_per_page(5)
605 .after_compile(&ctx)
606 .unwrap();
607
608 let last = fs::read_to_string(site.join("page/3/index.html")).unwrap();
609 assert!(
610 !last.contains(r#"rel="next""#),
611 "last page must not emit a Next link:\n{last}"
612 );
613 }
614
615 #[test]
616 fn after_compile_middle_page_has_both_prev_and_next() {
617 let (_tmp, site, meta, ctx) = make_layout();
620 write_n_dated_posts(&meta, 16);
621 PaginationPlugin::with_per_page(5)
622 .after_compile(&ctx)
623 .unwrap();
624
625 let page3 = fs::read_to_string(site.join("page/3/index.html")).unwrap();
626 assert!(page3.contains(r#"rel="prev""#));
627 assert!(page3.contains(r#"rel="next""#));
628 }
629
630 #[test]
631 fn after_compile_renders_time_element_per_entry() {
632 let (_tmp, site, meta, ctx) = make_layout();
633 write_n_dated_posts(&meta, 11);
634 PaginationPlugin::default().after_compile(&ctx).unwrap();
635
636 let html = fs::read_to_string(site.join("page/2/index.html")).unwrap();
637 assert!(
638 html.contains("<time>2026-01-01</time>"),
639 "page 2 should render a <time> element:\n{html}"
640 );
641 }
642
643 #[test]
644 fn after_compile_idempotent_overwrites_existing_pages() {
645 let (_tmp, site, meta, ctx) = make_layout();
648 write_n_dated_posts(&meta, 11);
649 let plugin = PaginationPlugin::default();
650 plugin.after_compile(&ctx).expect("first run");
651 plugin.after_compile(&ctx).expect("second run");
652 assert!(site.join("page/2/index.html").exists());
653 }
654
655 #[test]
660 fn collect_json_files_returns_empty_for_missing_directory() {
661 let dir = tempdir().expect("tempdir");
664 let result =
665 collect_json_files(&dir.path().join("does-not-exist")).unwrap();
666 assert!(result.is_empty());
667 }
668
669 #[test]
670 fn collect_json_files_returns_empty_for_empty_directory() {
671 let dir = tempdir().expect("tempdir");
672 let result = collect_json_files(dir.path()).unwrap();
673 assert!(result.is_empty());
674 }
675
676 #[test]
677 fn collect_json_files_filters_non_json_extensions() {
678 let dir = tempdir().expect("tempdir");
681 fs::write(dir.path().join("a.json"), "{}").unwrap();
682 fs::write(dir.path().join("b.txt"), "x").unwrap();
683 fs::write(dir.path().join("c.md"), "x").unwrap();
684 fs::write(dir.path().join("noext"), "x").unwrap();
685
686 let result = collect_json_files(dir.path()).unwrap();
687 assert_eq!(result.len(), 1);
688 assert!(result[0].file_name().unwrap() == "a.json");
689 }
690
691 #[test]
692 fn collect_json_files_recurses_into_subdirectories() {
693 let dir = tempdir().expect("tempdir");
696 let nested = dir.path().join("a").join("b").join("c");
697 fs::create_dir_all(&nested).unwrap();
698 fs::write(dir.path().join("top.json"), "{}").unwrap();
699 fs::write(dir.path().join("a").join("mid.json"), "{}").unwrap();
700 fs::write(nested.join("deep.json"), "{}").unwrap();
701
702 let result = collect_json_files(dir.path()).unwrap();
703 assert_eq!(result.len(), 3);
704 }
705
706 #[test]
707 fn collect_json_files_returns_results_sorted() {
708 let dir = tempdir().expect("tempdir");
710 for name in ["zebra.json", "apple.json", "mango.json"] {
711 fs::write(dir.path().join(name), "{}").unwrap();
712 }
713 let result = collect_json_files(dir.path()).unwrap();
714 let names: Vec<&str> = result
715 .iter()
716 .map(|p| p.file_name().unwrap().to_str().unwrap())
717 .collect();
718 assert_eq!(names, vec!["apple.json", "mango.json", "zebra.json"]);
719 }
720}