1use crate::plugin::{Plugin, PluginContext};
10use anyhow::Result;
11use std::{
12 collections::HashMap,
13 fs,
14 path::{Path, PathBuf},
15};
16
17type TaxonomyMap = HashMap<String, Vec<(String, String)>>;
19
20#[derive(Debug, Clone)]
22pub struct TaxonomyTerm {
23 pub name: String,
25 pub slug: String,
27 pub pages: Vec<(String, String)>,
29}
30
31#[derive(Debug, Clone, Copy)]
39pub struct TaxonomyPlugin;
40
41impl Plugin for TaxonomyPlugin {
42 fn name(&self) -> &'static str {
43 "taxonomy"
44 }
45
46 fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
47 let sidecar_dir = ctx.build_dir.join(".meta");
48 if !sidecar_dir.exists() {
49 return Ok(());
50 }
51
52 let (tags, categories) = collect_taxonomy_entries(&sidecar_dir)?;
53
54 if !tags.is_empty() {
55 generate_taxonomy_pages(&ctx.site_dir, "tags", &tags)?;
56 log::info!("[taxonomy] Generated {} tag page(s)", tags.len());
57 }
58
59 if !categories.is_empty() {
60 generate_taxonomy_pages(&ctx.site_dir, "categories", &categories)?;
61 log::info!(
62 "[taxonomy] Generated {} category page(s)",
63 categories.len()
64 );
65 }
66
67 Ok(())
68 }
69}
70
71fn extract_terms_from_array(
73 value: &serde_json::Value,
74 map: &mut HashMap<String, Vec<(String, String)>>,
75 title: &str,
76 url: &str,
77) {
78 if let Some(arr) = value.as_array() {
79 for item in arr {
80 if let Some(s) = item.as_str() {
81 map.entry(s.to_string())
82 .or_default()
83 .push((title.to_string(), url.to_string()));
84 }
85 }
86 }
87}
88
89fn collect_taxonomy_entries(
91 sidecar_dir: &Path,
92) -> Result<(TaxonomyMap, TaxonomyMap)> {
93 let sidecars = collect_json_files(sidecar_dir)?;
94 let mut tags: TaxonomyMap = HashMap::new();
95 let mut categories: TaxonomyMap = HashMap::new();
96
97 for sidecar_path in &sidecars {
98 let content = fs::read_to_string(sidecar_path)?;
99 let meta: HashMap<String, serde_json::Value> =
100 match serde_json::from_str(&content) {
101 Ok(m) => m,
102 Err(_) => continue,
103 };
104
105 let title = meta
106 .get("title")
107 .and_then(|v| v.as_str())
108 .unwrap_or("Untitled")
109 .to_string();
110
111 let rel = sidecar_path
112 .strip_prefix(sidecar_dir)
113 .unwrap_or(sidecar_path)
114 .with_extension("")
115 .with_extension("html");
116 let url = format!("/{}", rel.to_string_lossy().replace('\\', "/"));
117
118 if let Some(tag_arr) = meta.get("tags") {
119 extract_terms_from_array(tag_arr, &mut tags, &title, &url);
120 }
121 if let Some(cat_arr) = meta.get("categories") {
122 extract_terms_from_array(cat_arr, &mut categories, &title, &url);
123 }
124 }
125
126 Ok((tags, categories))
127}
128
129fn generate_taxonomy_pages(
131 site_dir: &Path,
132 taxonomy_name: &str,
133 terms: &HashMap<String, Vec<(String, String)>>,
134) -> Result<()> {
135 let tax_dir = site_dir.join(taxonomy_name);
136 fs::create_dir_all(&tax_dir)?;
137
138 let mut index_html = format!(
140 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\
141 <meta charset=\"utf-8\">\
142 <title>{}</title></head>\n<body>\n<main>\n\
143 <h1>{}</h1>\n<ul>\n",
144 capitalize(taxonomy_name),
145 capitalize(taxonomy_name),
146 );
147
148 let mut sorted_terms: Vec<_> = terms.iter().collect();
149 sorted_terms.sort_by_key(|(name, _)| name.to_lowercase());
150
151 for (term, pages) in &sorted_terms {
152 let slug = slugify(term);
153 index_html.push_str(&format!(
154 "<li><a href=\"/{}/{}/\">{}</a> ({})</li>\n",
155 taxonomy_name,
156 slug,
157 term,
158 pages.len()
159 ));
160
161 let term_dir = tax_dir.join(&slug);
163 fs::create_dir_all(&term_dir)?;
164
165 let mut term_html = format!(
166 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\
167 <meta charset=\"utf-8\">\
168 <title>{term}</title></head>\n<body>\n<main>\n\
169 <h1>{term}</h1>\n<ul>\n",
170 );
171 for (title, url) in *pages {
172 term_html
173 .push_str(&format!("<li><a href=\"{url}\">{title}</a></li>\n"));
174 }
175 term_html.push_str("</ul>\n</main>\n</body>\n</html>\n");
176
177 fs::write(term_dir.join("index.html"), term_html)?;
178 }
179
180 index_html.push_str("</ul>\n</main>\n</body>\n</html>\n");
181 fs::write(tax_dir.join("index.html"), index_html)?;
182
183 Ok(())
184}
185
186fn slugify(s: &str) -> String {
187 s.to_lowercase()
188 .chars()
189 .map(|c| if c.is_alphanumeric() { c } else { '-' })
190 .collect::<String>()
191 .split('-')
192 .filter(|s| !s.is_empty())
193 .collect::<Vec<_>>()
194 .join("-")
195}
196
197fn capitalize(s: &str) -> String {
198 let mut c = s.chars();
199 match c.next() {
200 None => String::new(),
201 Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
202 }
203}
204
205fn collect_json_files(dir: &Path) -> Result<Vec<PathBuf>> {
206 crate::walk::walk_files(dir, "json")
207}
208
209#[cfg(test)]
210#[allow(clippy::unwrap_used, clippy::expect_used)]
211mod tests {
212 use super::*;
213 use crate::test_support::init_logger;
214 use std::path::PathBuf;
215 use tempfile::{tempdir, TempDir};
216
217 fn make_layout() -> (TempDir, PathBuf, PathBuf, PluginContext) {
224 init_logger();
225 let dir = tempdir().expect("create tempdir");
226 let site = dir.path().join("site");
227 let build = dir.path().join("build");
228 let meta = build.join(".meta");
229 fs::create_dir_all(&site).expect("mkdir site");
230 fs::create_dir_all(&meta).expect("mkdir meta");
231 let ctx = PluginContext::new(dir.path(), &build, &site, dir.path());
232 (dir, site, meta, ctx)
233 }
234
235 #[test]
240 fn slugify_table_driven_inputs_produce_expected_slugs() {
241 let cases: &[(&str, &str)] = &[
242 ("Rust Programming", "rust-programming"),
244 ("C++", "c"),
246 ("hello world!", "hello-world"),
247 ("a !! b", "a-b"),
249 ("a___b", "a-b"),
250 ("---rust---", "rust"),
252 ("!!!hello!!!", "hello"),
253 ("café", "café"),
255 ("!!!", ""),
257 ("rust-web", "rust-web"),
259 ("Rust 2024", "rust-2024"),
261 ("", ""),
263 ];
264 for &(input, expected) in cases {
265 assert_eq!(
266 slugify(input),
267 expected,
268 "slugify({input:?}) should be {expected:?}"
269 );
270 }
271 }
272
273 #[test]
274 fn slugify_lowercases_uppercase_input() {
275 assert_eq!(slugify("RUST"), "rust");
278 assert_eq!(slugify("CamelCase"), "camelcase");
279 }
280
281 #[test]
286 fn capitalize_table_driven_inputs_produce_expected_output() {
287 let cases: &[(&str, &str)] = &[
288 ("", ""),
290 ("a", "A"),
292 ("tags", "Tags"),
294 ("categories", "Categories"),
295 ("Tags", "Tags"),
297 ("1", "1"),
299 ];
300 for &(input, expected) in cases {
301 assert_eq!(
302 capitalize(input),
303 expected,
304 "capitalize({input:?}) should be {expected:?}"
305 );
306 }
307 }
308
309 #[test]
314 fn taxonomy_plugin_is_copy_after_move() {
315 let plugin = TaxonomyPlugin;
317 let _copy = plugin;
318 assert_eq!(plugin.name(), "taxonomy");
319 }
320
321 #[test]
322 fn name_returns_static_taxonomy_identifier() {
323 assert_eq!(TaxonomyPlugin.name(), "taxonomy");
324 }
325
326 #[test]
331 fn after_compile_missing_meta_dir_returns_ok_without_writing() {
332 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 TaxonomyPlugin
340 .after_compile(&ctx)
341 .expect("missing meta is fine");
342 assert!(!site.join("tags").exists());
343 assert!(!site.join("categories").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 TaxonomyPlugin
350 .after_compile(&ctx)
351 .expect("empty meta is fine");
352 assert!(!site.join("tags").exists());
353 assert!(!site.join("categories").exists());
354 }
355
356 #[test]
357 fn after_compile_pages_without_taxonomies_emit_no_output() {
358 let (_tmp, site, meta, ctx) = make_layout();
362 fs::write(meta.join("about.meta.json"), r#"{"title": "About"}"#)
363 .unwrap();
364
365 TaxonomyPlugin.after_compile(&ctx).unwrap();
366 assert!(!site.join("tags").exists());
367 assert!(!site.join("categories").exists());
368 }
369
370 #[test]
375 fn after_compile_skips_invalid_json_sidecars() {
376 let (_tmp, site, meta, ctx) = make_layout();
380 fs::write(meta.join("broken.meta.json"), "{not valid").unwrap();
381 fs::write(
382 meta.join("good.meta.json"),
383 r#"{"title": "Good", "tags": ["rust"]}"#,
384 )
385 .unwrap();
386
387 TaxonomyPlugin.after_compile(&ctx).unwrap();
388 assert!(site.join("tags/rust/index.html").exists());
389 }
390
391 #[test]
392 fn after_compile_missing_title_falls_back_to_untitled() {
393 let (_tmp, site, meta, ctx) = make_layout();
394 fs::write(meta.join("notitle.meta.json"), r#"{"tags": ["rust"]}"#)
395 .unwrap();
396
397 TaxonomyPlugin.after_compile(&ctx).unwrap();
398 let html =
399 fs::read_to_string(site.join("tags/rust/index.html")).unwrap();
400 assert!(html.contains("Untitled"));
401 }
402
403 #[test]
404 fn after_compile_ignores_non_string_tag_values() {
405 let (_tmp, site, meta, ctx) = make_layout();
408 fs::write(
409 meta.join("mixed.meta.json"),
410 r#"{"title": "Mixed", "tags": ["rust", 42, null, "web", {"x":1}]}"#,
411 )
412 .unwrap();
413
414 TaxonomyPlugin.after_compile(&ctx).unwrap();
415 assert!(site.join("tags/rust/index.html").exists());
417 assert!(site.join("tags/web/index.html").exists());
418 }
419
420 #[test]
421 fn after_compile_ignores_non_array_categories_field() {
422 let (_tmp, site, meta, ctx) = make_layout();
427 fs::write(
428 meta.join("badcats.meta.json"),
429 r#"{"title": "BadCats", "categories": "not-an-array"}"#,
430 )
431 .unwrap();
432
433 TaxonomyPlugin.after_compile(&ctx).unwrap();
434 assert!(!site.join("categories").exists());
435 }
436
437 #[test]
438 fn after_compile_ignores_non_string_category_values() {
439 let (_tmp, site, meta, ctx) = make_layout();
442 fs::write(
443 meta.join("mixed-cats.meta.json"),
444 r#"{"title": "Mixed", "categories": ["blog", 42, null, {"x":1}]}"#,
445 )
446 .unwrap();
447
448 TaxonomyPlugin.after_compile(&ctx).unwrap();
449 assert!(site.join("categories/blog/index.html").exists());
450 }
451
452 #[test]
453 fn after_compile_ignores_non_array_tags_field() {
454 let (_tmp, site, _meta_dir, ctx) = make_layout();
457 let meta_dir = ctx.build_dir.join(".meta");
458 fs::write(
459 meta_dir.join("badtype.meta.json"),
460 r#"{"title": "BadType", "tags": "not-an-array"}"#,
461 )
462 .unwrap();
463
464 TaxonomyPlugin.after_compile(&ctx).unwrap();
465 assert!(!site.join("tags").exists());
466 }
467
468 #[test]
473 fn after_compile_generates_index_and_term_pages_for_tags() {
474 let (_tmp, site, meta, ctx) = make_layout();
475 fs::write(
476 meta.join("p1.meta.json"),
477 r#"{"title": "P1", "tags": ["rust", "web"]}"#,
478 )
479 .unwrap();
480 fs::write(
481 meta.join("p2.meta.json"),
482 r#"{"title": "P2", "tags": ["rust"]}"#,
483 )
484 .unwrap();
485
486 TaxonomyPlugin.after_compile(&ctx).unwrap();
487
488 assert!(site.join("tags/index.html").exists());
489 assert!(site.join("tags/rust/index.html").exists());
490 assert!(site.join("tags/web/index.html").exists());
491
492 let rust =
493 fs::read_to_string(site.join("tags/rust/index.html")).unwrap();
494 assert!(rust.contains("P1"));
495 assert!(rust.contains("P2"));
496
497 let web = fs::read_to_string(site.join("tags/web/index.html")).unwrap();
498 assert!(web.contains("P1"));
499 assert!(!web.contains("P2"));
500 }
501
502 #[test]
503 fn after_compile_generates_index_and_term_pages_for_categories() {
504 let (_tmp, site, meta, ctx) = make_layout();
505 fs::write(
506 meta.join("p1.meta.json"),
507 r#"{"title": "P1", "categories": ["tutorials"]}"#,
508 )
509 .unwrap();
510
511 TaxonomyPlugin.after_compile(&ctx).unwrap();
512 assert!(site.join("categories/index.html").exists());
513 assert!(site.join("categories/tutorials/index.html").exists());
514 }
515
516 #[test]
517 fn after_compile_index_shows_page_count_per_term() {
518 let (_tmp, site, meta, ctx) = make_layout();
521 fs::write(
522 meta.join("a.meta.json"),
523 r#"{"title": "A", "tags": ["rust"]}"#,
524 )
525 .unwrap();
526 fs::write(
527 meta.join("b.meta.json"),
528 r#"{"title": "B", "tags": ["rust"]}"#,
529 )
530 .unwrap();
531 fs::write(
532 meta.join("c.meta.json"),
533 r#"{"title": "C", "tags": ["rust", "web"]}"#,
534 )
535 .unwrap();
536
537 TaxonomyPlugin.after_compile(&ctx).unwrap();
538 let index = fs::read_to_string(site.join("tags/index.html")).unwrap();
539 assert!(index.contains("(3)"), "rust should have 3 posts:\n{index}");
540 assert!(index.contains("(1)"), "web should have 1 post:\n{index}");
541 }
542
543 #[test]
544 fn after_compile_index_lists_terms_alphabetically_case_insensitive() {
545 let (_tmp, site, meta, ctx) = make_layout();
549 fs::write(
550 meta.join("p.meta.json"),
551 r#"{"title": "P", "tags": ["banana", "Apple", "cherry"]}"#,
552 )
553 .unwrap();
554
555 TaxonomyPlugin.after_compile(&ctx).unwrap();
556 let index = fs::read_to_string(site.join("tags/index.html")).unwrap();
557 let apple = index.find("Apple").expect("Apple in index");
558 let banana = index.find("banana").expect("banana in index");
559 let cherry = index.find("cherry").expect("cherry in index");
560 assert!(apple < banana, "Apple should sort before banana");
561 assert!(banana < cherry, "banana should sort before cherry");
562 }
563
564 #[test]
565 fn after_compile_tags_and_categories_coexist_independently() {
566 let (_tmp, site, meta, ctx) = make_layout();
567 fs::write(
568 meta.join("p.meta.json"),
569 r#"{"title": "P", "tags": ["rust"], "categories": ["tutorials"]}"#,
570 )
571 .unwrap();
572
573 TaxonomyPlugin.after_compile(&ctx).unwrap();
574 assert!(site.join("tags/rust/index.html").exists());
575 assert!(site.join("categories/tutorials/index.html").exists());
576 }
577
578 #[test]
579 fn after_compile_idempotent_overwrites_existing_pages() {
580 let (_tmp, site, meta, ctx) = make_layout();
581 fs::write(
582 meta.join("p.meta.json"),
583 r#"{"title": "P", "tags": ["rust"]}"#,
584 )
585 .unwrap();
586
587 TaxonomyPlugin.after_compile(&ctx).expect("first run");
588 TaxonomyPlugin.after_compile(&ctx).expect("second run");
589 assert!(site.join("tags/rust/index.html").exists());
590 }
591
592 #[test]
593 fn after_compile_emits_doctype_lang_charset_in_index() {
594 let (_tmp, site, meta, ctx) = make_layout();
595 fs::write(
596 meta.join("p.meta.json"),
597 r#"{"title": "P", "tags": ["rust"]}"#,
598 )
599 .unwrap();
600
601 TaxonomyPlugin.after_compile(&ctx).unwrap();
602 let html = fs::read_to_string(site.join("tags/index.html")).unwrap();
603 assert!(html.starts_with("<!DOCTYPE html>"));
604 assert!(html.contains("<html lang=\"en\">"));
605 assert!(html.contains("<meta charset=\"utf-8\">"));
606 assert!(html.contains("<h1>Tags</h1>"));
607 }
608
609 #[test]
610 fn after_compile_term_page_links_back_to_source_url() {
611 let (_tmp, site, meta, ctx) = make_layout();
614 fs::write(
615 meta.join("hello.meta.json"),
616 r#"{"title": "Hello", "tags": ["rust"]}"#,
617 )
618 .unwrap();
619
620 TaxonomyPlugin.after_compile(&ctx).unwrap();
621 let html =
622 fs::read_to_string(site.join("tags/rust/index.html")).unwrap();
623 assert!(
624 html.contains(r#"href="/hello.html""#),
625 "term page should link back to /hello.html:\n{html}"
626 );
627 }
628
629 #[test]
634 fn collect_json_files_returns_empty_for_missing_directory() {
635 let dir = tempdir().expect("tempdir");
636 let result = collect_json_files(&dir.path().join("missing")).unwrap();
637 assert!(result.is_empty());
638 }
639
640 #[test]
641 fn collect_json_files_filters_non_json_extensions() {
642 let dir = tempdir().expect("tempdir");
643 fs::write(dir.path().join("a.json"), "{}").unwrap();
644 fs::write(dir.path().join("b.txt"), "x").unwrap();
645 fs::write(dir.path().join("c"), "x").unwrap();
646
647 let result = collect_json_files(dir.path()).unwrap();
648 assert_eq!(result.len(), 1);
649 }
650
651 #[test]
652 fn collect_json_files_recurses_into_nested_subdirectories() {
653 let dir = tempdir().expect("tempdir");
654 let nested = dir.path().join("a").join("b");
655 fs::create_dir_all(&nested).unwrap();
656 fs::write(dir.path().join("top.json"), "{}").unwrap();
657 fs::write(nested.join("deep.json"), "{}").unwrap();
658
659 let result = collect_json_files(dir.path()).unwrap();
660 assert_eq!(result.len(), 2);
661 }
662
663 #[test]
664 fn collect_json_files_returns_results_sorted() {
665 let dir = tempdir().expect("tempdir");
666 for name in ["zebra.json", "apple.json", "mango.json"] {
667 fs::write(dir.path().join(name), "{}").unwrap();
668 }
669 let result = collect_json_files(dir.path()).unwrap();
670 let names: Vec<_> = result
671 .iter()
672 .map(|p| p.file_name().unwrap().to_str().unwrap())
673 .collect();
674 assert_eq!(names, vec!["apple.json", "mango.json", "zebra.json"]);
675 }
676
677 #[test]
682 fn taxonomy_term_can_be_constructed_and_cloned() {
683 let term = TaxonomyTerm {
684 name: "Rust".to_string(),
685 slug: "rust".to_string(),
686 pages: vec![("Hello".to_string(), "/hello.html".to_string())],
687 };
688 let copy = term;
689 assert_eq!(copy.name, "Rust");
690 assert_eq!(copy.slug, "rust");
691 assert_eq!(copy.pages.len(), 1);
692 }
693}
694
695#[cfg(test)]
696#[allow(clippy::unwrap_used, clippy::expect_used)]
697mod proptests {
698 use super::*;
699 use proptest::prelude::*;
700
701 proptest! {
702 #![proptest_config(ProptestConfig::with_cases(1000))]
703
704 #[test]
711 fn slugify_valid_chars(input in "\\PC*") {
712 let slug = slugify(&input);
713 for ch in slug.chars() {
714 prop_assert!(
715 ch.is_alphanumeric() || ch == '-',
716 "unexpected char {:?} in slug {:?}", ch, slug,
717 );
718 }
719 prop_assert!(
720 !slug.starts_with('-'),
721 "slug must not start with hyphen: {:?}", slug,
722 );
723 prop_assert!(
724 !slug.ends_with('-'),
725 "slug must not end with hyphen: {:?}", slug,
726 );
727 prop_assert!(
728 !slug.contains("--"),
729 "slug must not contain consecutive hyphens: {:?}", slug,
730 );
731 }
732 }
733}