1use crate::error::{PathErrorExt, SsgError};
50use crate::plugin::{Plugin, PluginContext};
51use crate::plugins_group::topic_clusters::{TopicCluster, TopicClusters};
52use std::{
53 collections::{BTreeMap, HashMap, HashSet},
54 fs,
55 path::{Path, PathBuf},
56};
57
58#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, Default)]
67pub struct PageRef {
68 pub title: String,
70 pub url: String,
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub description: Option<String>,
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub date: Option<String>,
78 #[serde(skip_serializing_if = "Option::is_none")]
80 pub banner: Option<String>,
81}
82
83impl PageRef {
84 #[must_use]
91 pub fn new(title: impl Into<String>, url: impl Into<String>) -> Self {
92 Self {
93 title: title.into(),
94 url: url.into(),
95 ..Self::default()
96 }
97 }
98}
99
100type TaxonomyMap = HashMap<String, Vec<PageRef>>;
102
103type MergedTerm = (String, String, Vec<PageRef>);
106
107#[derive(Debug, Clone)]
109pub struct TaxonomyTerm {
110 pub name: String,
112 pub slug: String,
114 pub pages: Vec<PageRef>,
116}
117
118#[cfg(feature = "templates")]
130const BUILTIN_TAG_HTML: &str = include_str!("builtin_templates/tag.html");
131#[cfg(feature = "templates")]
133const BUILTIN_CATEGORY_HTML: &str =
134 include_str!("builtin_templates/category.html");
135#[cfg(feature = "templates")]
137const BUILTIN_ARCHIVE_HTML: &str =
138 include_str!("builtin_templates/archive.html");
139#[cfg(feature = "templates")]
141const BUILTIN_TAXONOMY_INDEX_HTML: &str =
142 include_str!("builtin_templates/taxonomy_index.html");
143#[cfg(feature = "templates")]
146const BUILTIN_BASE_HTML: &str = include_str!("builtin_templates/base.html");
147
148#[derive(Debug, Clone, Copy)]
161pub struct TaxonomyPlugin;
162
163impl Plugin for TaxonomyPlugin {
164 fn name(&self) -> &'static str {
165 "taxonomy"
166 }
167
168 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
169 if ctx.config.as_ref().is_some_and(|c| c.no_taxonomy_pages) {
174 return Ok(());
175 }
176
177 let sidecar_dir = {
183 let build_meta = ctx.build_dir.join(".meta");
184 if build_meta.exists() {
185 build_meta
186 } else {
187 ctx.site_dir.join(".meta")
188 }
189 };
190 if !sidecar_dir.exists() {
191 return Ok(());
192 }
193
194 let url_prefix = ctx.config.as_ref().map_or_else(String::new, |c| {
195 crate::plugins_group::csp::base_url_path_prefix(&c.base_url)
196 });
197 let (tags, categories, topics) =
198 collect_taxonomy_entries(&sidecar_dir, &ctx.site_dir, &url_prefix)?;
199
200 let renderer = TaxonomyRenderer::new(ctx);
203
204 let clusters =
208 crate::plugins_group::topic_clusters::load(&ctx.content_dir);
209 if !clusters.is_empty() {
210 let known: Vec<String> = topics.keys().cloned().collect();
211 crate::plugins_group::topic_clusters::warn_unknown(
212 &clusters, &known,
213 );
214 }
215
216 let (locales, default_locale) = ctx.config.as_ref().map_or_else(
221 || (Vec::new(), String::new()),
222 |c| {
223 (
224 c.i18n_locales(),
225 c.i18n_default_locale().unwrap_or_default(),
226 )
227 },
228 );
229 let multi_locale = locales.len() > 1;
230
231 for (name, title, map, kind) in [
232 ("tags", "Tags", &tags, TaxonomyKind::Tag),
233 (
234 "categories",
235 "Categories",
236 &categories,
237 TaxonomyKind::Category,
238 ),
239 ("topics", "Topics", &topics, TaxonomyKind::Archive),
240 ] {
241 if map.is_empty() {
242 continue;
243 }
244 if multi_locale {
245 let by_locale = split_map_by_locale(
246 map,
247 &locales,
248 &default_locale,
249 &url_prefix,
250 );
251 for (locale, scoped) in &by_locale {
252 let dir = if *locale == default_locale {
255 ctx.site_dir.join(name)
256 } else {
257 ctx.site_dir.join(locale).join(name)
258 };
259 let is_default = *locale == default_locale;
263 let locale_prefix = if is_default {
264 url_prefix.clone()
265 } else {
266 format!("{url_prefix}/{locale}")
267 };
268 let segment = if is_default {
269 String::new()
270 } else {
271 format!("/{locale}")
272 };
273 let scoped_renderer = renderer.for_locale(
274 if is_default {
275 None
276 } else {
277 Some(locale.as_str())
278 },
279 &locale_prefix,
280 &segment,
281 );
282 generate_taxonomy_pages_at(
283 &dir,
284 name,
285 title,
286 scoped,
287 kind,
288 &scoped_renderer,
289 topic_clusters_for(name, &clusters),
290 )?;
291 log::info!(
292 "[taxonomy] Generated {} {name} page(s) for {locale}",
293 scoped.len()
294 );
295 }
296 } else {
297 generate_taxonomy_pages(
298 &ctx.site_dir,
299 name,
300 title,
301 map,
302 kind,
303 &renderer,
304 topic_clusters_for(name, &clusters),
305 )?;
306 log::info!("[taxonomy] Generated {} {name} page(s)", map.len());
307 }
308 }
309
310 Ok(())
311 }
312}
313
314#[derive(Debug, Clone, Copy)]
316enum TaxonomyKind {
317 Tag,
318 Category,
319 Archive,
320}
321
322#[cfg(feature = "templates")]
327impl TaxonomyKind {
328 const fn template_name(self) -> &'static str {
331 match self {
332 Self::Tag => "tag.html",
333 Self::Category => "category.html",
334 Self::Archive => "archive.html",
335 }
336 }
337
338 const fn term_var(self) -> &'static str {
341 match self {
342 Self::Tag => "tag",
343 Self::Category => "category",
344 Self::Archive => "term",
345 }
346 }
347}
348
349struct TaxonomyRenderer<'a> {
359 #[cfg(feature = "templates")]
360 env: minijinja::Environment<'static>,
361 ctx: &'a PluginContext,
362 locale: Option<(Option<String>, String, String)>,
376}
377
378#[cfg(feature = "templates")]
379impl<'a> TaxonomyRenderer<'a> {
380 fn new(ctx: &'a PluginContext) -> Self {
381 let user_dir = resolve_user_template_dir(ctx);
382
383 let mut env = minijinja::Environment::new();
384 env.set_loader(
385 move |name| -> Result<Option<String>, minijinja::Error> {
386 if let Some(dir) = user_dir.as_ref() {
388 let candidate = dir.join(name);
389 match fs::read_to_string(&candidate) {
390 Ok(s) => return Ok(Some(s)),
391 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
392 Err(e) => {
393 return Err(minijinja::Error::new(
394 minijinja::ErrorKind::InvalidOperation,
395 format!(
396 "failed to read user template {}: {e}",
397 candidate.display()
398 ),
399 ))
400 }
401 }
402 }
403 Ok(match name {
405 "base.html" => Some(BUILTIN_BASE_HTML.to_string()),
406 "tag.html" => Some(BUILTIN_TAG_HTML.to_string()),
407 "category.html" => Some(BUILTIN_CATEGORY_HTML.to_string()),
408 "archive.html" => Some(BUILTIN_ARCHIVE_HTML.to_string()),
409 "taxonomy_index.html" => {
410 Some(BUILTIN_TAXONOMY_INDEX_HTML.to_string())
411 }
412 _ => None,
413 })
414 },
415 );
416
417 Self {
418 env,
419 ctx,
420 locale: None,
421 }
422 }
423
424 fn for_locale(
427 &self,
428 language: Option<&str>,
429 url_prefix: &str,
430 path_segment: &str,
431 ) -> Self {
432 Self {
433 env: self.env.clone(),
434 ctx: self.ctx,
435 locale: Some((
436 language.map(str::to_string),
437 url_prefix.to_string(),
438 path_segment.to_string(),
439 )),
440 }
441 }
442
443 fn render_term_page(
445 &self,
446 kind: TaxonomyKind,
447 taxonomy_name: &str,
448 taxonomy_title: &str,
449 term: &str,
450 slug: &str,
451 pages: &[PageRef],
452 cluster: Option<&TopicCluster>,
453 ) -> Result<String, SsgError> {
454 let tmpl =
455 self.env.get_template(kind.template_name()).map_err(|e| {
456 SsgError::Io {
457 path: PathBuf::from(kind.template_name()),
458 source: std::io::Error::other(e.to_string()),
459 }
460 })?;
461
462 let mut ctx_map = self.base_context();
463 let _ = ctx_map.insert(
464 kind.term_var().to_string(),
465 serde_json::Value::String(term.to_string()),
466 );
467 let _ = ctx_map.insert(
469 "term".to_string(),
470 serde_json::Value::String(term.to_string()),
471 );
472 let _ = ctx_map.insert(
473 "slug".to_string(),
474 serde_json::Value::String(slug.to_string()),
475 );
476 if taxonomy_name == "topics" {
480 let base_url =
481 self.ctx.config.as_ref().map_or("", |c| c.base_url.as_str());
482 let page_url = format!(
483 "{}/{taxonomy_name}/{slug}/",
484 self.locale_path_segment()
485 );
486 let jsonld = topic_jsonld(
487 base_url,
488 taxonomy_title,
489 term,
490 &page_url,
491 cluster.and_then(|c| c.lede.as_deref()),
492 pages,
493 );
494 let _ = ctx_map.insert(
495 "jsonld".to_string(),
496 serde_json::Value::String(jsonld.to_string()),
497 );
498 }
499
500 if let Some(lede) = cluster.and_then(|c| c.lede.as_deref()) {
504 let _ = ctx_map.insert(
505 "lede".to_string(),
506 serde_json::Value::String(lede.to_string()),
507 );
508 }
509 if let Some(banner) = cluster.and_then(|c| c.banner.as_deref()) {
510 let _ = ctx_map.insert(
511 "banner".to_string(),
512 serde_json::Value::String(banner.to_string()),
513 );
514 }
515 let _ = ctx_map.insert(
516 "taxonomy_name".to_string(),
517 serde_json::Value::String(taxonomy_name.to_string()),
518 );
519 let _ = ctx_map.insert(
520 "taxonomy_title".to_string(),
521 serde_json::Value::String(taxonomy_title.to_string()),
522 );
523 let _ = ctx_map.insert(
524 "page_url".to_string(),
525 serde_json::Value::String(format!(
526 "{}/{taxonomy_name}/{slug}/",
527 self.locale_path_segment()
528 )),
529 );
530 let _ = ctx_map.insert(
531 "posts".to_string(),
532 serde_json::Value::Array(pages_to_json(pages)),
533 );
534 let _ = ctx_map.insert(
537 "page_title".to_string(),
538 serde_json::Value::String(format!("{taxonomy_title}: {term}")),
539 );
540 let _ = ctx_map.insert(
541 "page_description".to_string(),
542 serde_json::Value::String(format!(
543 "{} page(s) under {taxonomy_title}: {term}.",
544 pages.len()
545 )),
546 );
547
548 tmpl.render(serde_json::Value::Object(ctx_map))
549 .map(|mut s| {
550 if !s.ends_with('\n') {
551 s.push('\n');
552 }
553 s
554 })
555 .map_err(|e| SsgError::Io {
556 path: PathBuf::from(kind.template_name()),
557 source: std::io::Error::other(e.to_string()),
558 })
559 }
560
561 fn render_index_page(
563 &self,
564 taxonomy_name: &str,
565 taxonomy_title: &str,
566 sorted_terms: &[(&String, &Vec<PageRef>)],
567 clusters: Option<&TopicClusters>,
568 ) -> Result<String, SsgError> {
569 let tmpl =
570 self.env.get_template("taxonomy_index.html").map_err(|e| {
571 SsgError::Io {
572 path: PathBuf::from("taxonomy_index.html"),
573 source: std::io::Error::other(e.to_string()),
574 }
575 })?;
576
577 let mut ctx_map = self.base_context();
578 let _ = ctx_map.insert(
579 "taxonomy_name".to_string(),
580 serde_json::Value::String(taxonomy_name.to_string()),
581 );
582 let _ = ctx_map.insert(
583 "taxonomy_title".to_string(),
584 serde_json::Value::String(taxonomy_title.to_string()),
585 );
586 let _ = ctx_map.insert(
587 "page_url".to_string(),
588 serde_json::Value::String(format!(
589 "{}/{taxonomy_name}/",
590 self.locale_path_segment()
591 )),
592 );
593 let _ = ctx_map.insert(
595 "page_title".to_string(),
596 serde_json::Value::String(taxonomy_title.to_string()),
597 );
598 let _ = ctx_map.insert(
599 "page_description".to_string(),
600 serde_json::Value::String(format!(
601 "All {} term(s): browse pages by {taxonomy_title}.",
602 sorted_terms.len()
603 )),
604 );
605
606 let term_entries: Vec<serde_json::Value> = sorted_terms
607 .iter()
608 .map(|(term, pages)| {
609 let mut obj = serde_json::Map::new();
610 let _ = obj.insert(
611 "name".to_string(),
612 serde_json::Value::String((*term).clone()),
613 );
614 let _ = obj.insert(
615 "slug".to_string(),
616 serde_json::Value::String(slugify(term)),
617 );
618 let _ = obj.insert(
619 "count".to_string(),
620 serde_json::Value::Number(serde_json::Number::from(
621 pages.len(),
622 )),
623 );
624 let cluster =
628 clusters.and_then(|c| c.get(slugify(term).as_str()));
629 for (key, value) in [
630 ("title", cluster.and_then(|c| c.title.as_deref())),
631 ("lede", cluster.and_then(|c| c.lede.as_deref())),
632 ("banner", cluster.and_then(|c| c.banner.as_deref())),
633 ] {
634 if let Some(value) = value {
635 let _ = obj.insert(
636 key.to_string(),
637 serde_json::Value::String(value.to_string()),
638 );
639 }
640 }
641 serde_json::Value::Object(obj)
642 })
643 .collect();
644 let _ = ctx_map.insert(
645 "terms".to_string(),
646 serde_json::Value::Array(term_entries),
647 );
648
649 tmpl.render(serde_json::Value::Object(ctx_map))
650 .map(|mut s| {
651 if !s.ends_with('\n') {
652 s.push('\n');
653 }
654 s
655 })
656 .map_err(|e| SsgError::Io {
657 path: PathBuf::from("taxonomy_index.html"),
658 source: std::io::Error::other(e.to_string()),
659 })
660 }
661
662 fn locale_path_segment(&self) -> String {
665 self.locale
666 .as_ref()
667 .map_or_else(String::new, |(_, _, seg)| seg.clone())
668 }
669
670 fn base_context(&self) -> serde_json::Map<String, serde_json::Value> {
673 let url_prefix =
676 self.ctx.config.as_ref().map_or_else(String::new, |c| {
677 crate::plugins_group::csp::base_url_path_prefix(&c.base_url)
678 });
679 let mut site = serde_json::Map::new();
680 if let Some(cfg) = self.ctx.config.as_ref() {
681 let _ = site.insert(
682 "name".to_string(),
683 serde_json::Value::String(cfg.site_name.clone()),
684 );
685 let _ = site.insert(
686 "title".to_string(),
687 serde_json::Value::String(cfg.site_title.clone()),
688 );
689 let _ = site.insert(
690 "description".to_string(),
691 serde_json::Value::String(cfg.site_description.clone()),
692 );
693 let _ = site.insert(
694 "base_url".to_string(),
695 serde_json::Value::String(cfg.base_url.clone()),
696 );
697 let _ = site.insert(
698 "language".to_string(),
699 serde_json::Value::String(cfg.language.clone()),
700 );
701 if let Some(og_image) = cfg.og_image.as_ref() {
704 let _ = site.insert(
705 "og_image".to_string(),
706 serde_json::Value::String(og_image.clone()),
707 );
708 }
709 } else {
710 let _ = site.insert(
713 "language".to_string(),
714 serde_json::Value::String("en".to_string()),
715 );
716 }
717
718 let site_prefix = url_prefix.clone();
719 let mut url_prefix = url_prefix;
720 if let Some((language, locale_prefix, _)) = self.locale.as_ref() {
721 if let Some(language) = language {
722 let _ = site.insert(
723 "language".to_string(),
724 serde_json::Value::String(language.clone()),
725 );
726 }
727 url_prefix.clone_from(locale_prefix);
728 }
729
730 let mut ctx_map = serde_json::Map::new();
731 let _ =
732 ctx_map.insert("site".to_string(), serde_json::Value::Object(site));
733 let _ =
739 ctx_map.insert("url_prefix".to_string(), url_prefix.clone().into());
740 let _ = ctx_map
741 .insert("site_prefix".to_string(), site_prefix.clone().into());
742 ctx_map
743 }
744}
745
746#[cfg(not(feature = "templates"))]
751impl<'a> TaxonomyRenderer<'a> {
752 const fn new(ctx: &'a PluginContext) -> Self {
753 Self { ctx, locale: None }
754 }
755
756 fn for_locale(
758 &self,
759 language: Option<&str>,
760 url_prefix: &str,
761 path_segment: &str,
762 ) -> Self {
763 Self {
764 ctx: self.ctx,
765 locale: Some((
766 language.map(str::to_string),
767 url_prefix.to_string(),
768 path_segment.to_string(),
769 )),
770 }
771 }
772
773 fn site_title_suffix(&self) -> String {
780 self.ctx.config.as_ref().map_or_else(String::new, |cfg| {
781 if cfg.site_title.is_empty() {
782 String::new()
783 } else {
784 format!(" \u{2014} {}", cfg.site_title)
785 }
786 })
787 }
788
789 fn render_term_page(
790 &self,
791 _kind: TaxonomyKind,
792 taxonomy_name: &str,
793 taxonomy_title: &str,
794 term: &str,
795 slug: &str,
796 pages: &[PageRef],
797 _cluster: Option<&TopicCluster>,
798 ) -> Result<String, SsgError> {
799 let lang = self.lang();
800 let canonical = self.canonical(&format!("/{taxonomy_name}/{slug}/"));
801 let og_image = self.og_image_tag();
802 let description =
803 format!("{} page(s) under {taxonomy_title}: {term}.", pages.len());
804 let suffix = self.site_title_suffix();
805 let mut out = format!(
806 "<!DOCTYPE html>\n<html lang=\"{lang}\">\n<head>\
807 <meta charset=\"utf-8\">{canonical}\
808 <meta name=\"generator\" content=\"ssg-taxonomy\">\
809 <meta name=\"description\" content=\"{description}\">\
810 <meta property=\"og:title\" content=\"{taxonomy_title}: {term}\">\
811 <meta property=\"og:type\" content=\"website\">\
812 {og_image}\
813 <meta name=\"twitter:card\" content=\"summary\">\
814 <title>{taxonomy_title}: {term}{suffix}</title></head>\n\
815 <body>\n<main>\n<h1>{taxonomy_title}: {term}</h1>\n<ul>\n"
816 );
817 for page in pages {
818 let (title, url) = (&page.title, &page.url);
819 out.push_str(&format!("<li><a href=\"{url}\">{title}</a></li>\n"));
820 }
821 out.push_str("</ul>\n</main>\n</body>\n</html>\n");
822 Ok(out)
823 }
824
825 fn render_index_page(
826 &self,
827 taxonomy_name: &str,
828 taxonomy_title: &str,
829 sorted_terms: &[(&String, &Vec<PageRef>)],
830 clusters: Option<&TopicClusters>,
831 ) -> Result<String, SsgError> {
832 let lang = self.lang();
833 let canonical = self.canonical(&format!("/{taxonomy_name}/"));
834 let og_image = self.og_image_tag();
835 let description = format!(
836 "All {} term(s): browse pages by {taxonomy_title}.",
837 sorted_terms.len()
838 );
839 let suffix = self.site_title_suffix();
840 let mut out = format!(
841 "<!DOCTYPE html>\n<html lang=\"{lang}\">\n<head>\
842 <meta charset=\"utf-8\">{canonical}\
843 <meta name=\"generator\" content=\"ssg-taxonomy\">\
844 <meta name=\"description\" content=\"{description}\">\
845 <meta property=\"og:title\" content=\"{taxonomy_title}\">\
846 <meta property=\"og:type\" content=\"website\">\
847 {og_image}\
848 <meta name=\"twitter:card\" content=\"summary\">\
849 <title>{taxonomy_title}{suffix}</title></head>\n\
850 <body>\n<main>\n<h1>{taxonomy_title}</h1>\n<ul>\n"
851 );
852 for (term, pages) in sorted_terms {
853 let slug = slugify(term);
854 let label = clusters
857 .and_then(|c| c.get(slug.as_str()))
858 .and_then(|c| c.title.as_deref())
859 .unwrap_or(term.as_str());
860 out.push_str(&format!(
861 "<li><a href=\"/{taxonomy_name}/{slug}/\">{label}</a> ({})</li>\n",
862 pages.len()
863 ));
864 }
865 out.push_str("</ul>\n</main>\n</body>\n</html>\n");
866 Ok(out)
867 }
868
869 fn locale_path_segment(&self) -> String {
873 self.locale
874 .as_ref()
875 .map_or_else(String::new, |(_, _, seg)| seg.clone())
876 }
877
878 fn lang(&self) -> String {
886 if let Some((Some(language), _, _)) = self.locale.as_ref() {
887 return language.clone();
888 }
889 self.ctx
890 .config
891 .as_ref()
892 .map_or_else(|| "en".to_string(), |c| c.language.clone())
893 }
894
895 fn canonical(&self, page_url: &str) -> String {
902 let segment = self.locale_path_segment();
903 self.ctx
904 .config
905 .as_ref()
906 .map(|c| c.base_url.trim_end_matches('/').to_string())
907 .filter(|b| !b.is_empty())
908 .map(|b| {
909 format!(
910 "<link rel=\"canonical\" href=\"{b}{segment}{page_url}\">"
911 )
912 })
913 .unwrap_or_default()
914 }
915
916 fn og_image_tag(&self) -> String {
920 self.ctx
921 .config
922 .as_ref()
923 .and_then(|c| c.og_image.as_ref())
924 .map(|image| {
925 format!("<meta property=\"og:image\" content=\"{image}\">")
926 })
927 .unwrap_or_default()
928 }
929}
930
931#[cfg(feature = "templates")]
935fn resolve_user_template_dir(ctx: &PluginContext) -> Option<PathBuf> {
936 let tera = ctx.template_dir.join("tera");
950 tera.is_dir().then_some(tera)
951}
952
953#[cfg(feature = "templates")]
956fn topic_jsonld(
970 base_url: &str,
971 taxonomy_title: &str,
972 term: &str,
973 page_url: &str,
974 lede: Option<&str>,
975 pages: &[PageRef],
976) -> serde_json::Value {
977 let base = base_url.trim_end_matches('/');
978 let abs = |path: &str| -> String {
979 if base.is_empty() || path.starts_with("http") {
980 path.to_string()
981 } else {
982 format!("{base}{path}")
983 }
984 };
985
986 let items: Vec<serde_json::Value> = pages
987 .iter()
988 .enumerate()
989 .map(|(i, p)| {
990 serde_json::json!({
991 "@type": "ListItem",
992 "position": i + 1,
993 "url": abs(&p.url),
994 "name": p.title,
995 })
996 })
997 .collect();
998
999 let mut collection = serde_json::json!({
1000 "@type": "CollectionPage",
1001 "name": term,
1002 "url": abs(page_url),
1003 "mainEntity": {
1004 "@type": "ItemList",
1005 "numberOfItems": pages.len(),
1006 "itemListElement": items,
1007 },
1008 });
1009 if let Some(lede) = lede {
1010 if let Some(obj) = collection.as_object_mut() {
1011 let _ = obj.insert(
1012 "description".to_string(),
1013 serde_json::Value::String(lede.to_string()),
1014 );
1015 }
1016 }
1017
1018 let hub = page_url.trim_end_matches('/');
1021 let hub = hub.rsplit_once('/').map_or("/", |(head, _)| head);
1022 let breadcrumbs = serde_json::json!({
1023 "@type": "BreadcrumbList",
1024 "itemListElement": [
1025 {"@type": "ListItem", "position": 1, "name": "Home", "item": abs("/")},
1026 {"@type": "ListItem", "position": 2, "name": taxonomy_title, "item": abs(&format!("{hub}/"))},
1027 {"@type": "ListItem", "position": 3, "name": term, "item": abs(page_url)},
1028 ],
1029 });
1030
1031 serde_json::json!({
1032 "@context": "https://schema.org",
1033 "@graph": [collection, breadcrumbs],
1034 })
1035}
1036
1037#[cfg(feature = "templates")]
1040fn pages_to_json(pages: &[PageRef]) -> Vec<serde_json::Value> {
1041 pages
1042 .iter()
1043 .map(|p| serde_json::to_value(p).unwrap_or(serde_json::Value::Null))
1044 .collect()
1045}
1046
1047fn extract_terms_from_value(
1049 value: &serde_json::Value,
1050 map: &mut TaxonomyMap,
1051 page: &PageRef,
1052 allow_string: bool,
1053) {
1054 if let Some(arr) = value.as_array() {
1055 for item in arr {
1056 if let Some(s) = item.as_str() {
1057 for term in ssg_core::split_terms(s) {
1058 map.entry(term).or_default().push(page.clone());
1059 }
1060 }
1061 }
1062 } else if allow_string {
1063 if let Some(s) = value.as_str() {
1064 for term in ssg_core::split_terms(s) {
1065 map.entry(term).or_default().push(page.clone());
1066 }
1067 }
1068 }
1069}
1070
1071fn split_map_by_locale(
1092 map: &TaxonomyMap,
1093 locales: &[String],
1094 default_locale: &str,
1095 url_prefix: &str,
1096) -> BTreeMap<String, TaxonomyMap> {
1097 let mut out: BTreeMap<String, TaxonomyMap> = BTreeMap::new();
1098 for (term, entries) in map {
1099 for entry in entries {
1100 let url = entry.url.as_str();
1101 let rest = url.strip_prefix(url_prefix).unwrap_or(url);
1102 let seg =
1103 rest.trim_start_matches('/').split('/').next().unwrap_or("");
1104 let locale = locales
1105 .iter()
1106 .find(|l| l.as_str() == seg && l.as_str() != default_locale)
1107 .map_or(default_locale, String::as_str);
1108 out.entry(locale.to_string())
1109 .or_default()
1110 .entry(term.clone())
1111 .or_default()
1112 .push(entry.clone());
1113 }
1114 }
1115 out
1116}
1117
1118fn collect_taxonomy_entries(
1119 sidecar_dir: &Path,
1120 site_dir: &Path,
1121 url_prefix: &str,
1122) -> Result<(TaxonomyMap, TaxonomyMap, TaxonomyMap), SsgError> {
1123 let sidecars = collect_json_files(sidecar_dir)?;
1124 let mut tags: TaxonomyMap = HashMap::new();
1125 let mut categories: TaxonomyMap = HashMap::new();
1126 let mut topics: TaxonomyMap = HashMap::new();
1127
1128 for sidecar_path in &sidecars {
1129 let content =
1130 fs::read_to_string(sidecar_path).with_path(sidecar_path)?;
1131 let meta: HashMap<String, serde_json::Value> =
1132 match serde_json::from_str(&content) {
1133 Ok(m) => m,
1134 Err(_) => continue,
1135 };
1136
1137 let title = meta
1138 .get("title")
1139 .and_then(|v| v.as_str())
1140 .unwrap_or("Untitled")
1141 .to_string();
1142
1143 let rel_stem = sidecar_path
1144 .strip_prefix(sidecar_dir)
1145 .unwrap_or(sidecar_path)
1146 .with_extension("")
1147 .with_extension("");
1148 let stem = rel_stem.to_string_lossy().replace('\\', "/");
1149 let url = if site_dir.join(&rel_stem).join("index.html").exists() {
1154 format!("{url_prefix}/{stem}/")
1155 } else {
1156 format!("{url_prefix}/{stem}.html")
1157 };
1158
1159 let field = |key: &str| -> Option<String> {
1163 meta.get(key)
1164 .and_then(serde_json::Value::as_str)
1165 .map(str::trim)
1166 .filter(|s| !s.is_empty())
1167 .map(ToOwned::to_owned)
1168 };
1169 let page = PageRef {
1170 title: title.clone(),
1171 url: url.clone(),
1172 description: field("description"),
1173 date: field("date"),
1174 banner: field("banner"),
1175 };
1176
1177 if let Some(tag_arr) = meta.get("tags") {
1181 extract_terms_from_value(tag_arr, &mut tags, &page, true);
1182 }
1183 if let Some(cat_arr) = meta.get("categories") {
1184 extract_terms_from_value(cat_arr, &mut categories, &page, true);
1185 }
1186 if let Some(topic_arr) = meta.get("topic_clusters") {
1187 extract_terms_from_value(topic_arr, &mut topics, &page, true);
1188 }
1189 }
1190
1191 Ok((tags, categories, topics))
1192}
1193
1194const TAXONOMY_MARKER: &str = "ssg-taxonomy";
1200
1201fn write_taxonomy_page(out_file: &Path, html: &str) -> Result<(), SsgError> {
1206 if let Ok(existing) = fs::read_to_string(out_file) {
1207 if !existing.contains(TAXONOMY_MARKER) {
1208 log::debug!(
1209 "[taxonomy] Keeping author-authored page at {}",
1210 out_file.display()
1211 );
1212 return Ok(());
1213 }
1214 }
1215 fs::write(out_file, html).with_path(out_file)
1216}
1217
1218fn merge_terms_by_slug(terms: &TaxonomyMap) -> Vec<MergedTerm> {
1239 let mut ordered: Vec<_> = terms.iter().collect();
1240 ordered.sort_by(|(a, _), (b, _)| {
1241 a.to_lowercase()
1242 .cmp(&b.to_lowercase())
1243 .then_with(|| a.cmp(b))
1244 });
1245
1246 let mut out: Vec<MergedTerm> = Vec::new();
1247 let mut index: BTreeMap<String, usize> = BTreeMap::new();
1248 let mut members: Vec<HashSet<PageRef>> = Vec::new();
1251
1252 for (term, pages) in ordered {
1253 let slug = slugify(term);
1254 if let Some(&i) = index.get(&slug) {
1255 for page in pages {
1256 if members[i].insert(page.clone()) {
1257 out[i].2.push(page.clone());
1258 }
1259 }
1260 } else {
1261 let _ = index.insert(slug.clone(), out.len());
1262 members.push(pages.iter().cloned().collect());
1263 out.push((term.clone(), slug, pages.clone()));
1264 }
1265 }
1266
1267 for entry in &mut out {
1280 entry.2.sort_by(|a, b| {
1281 a.url.cmp(&b.url).then_with(|| a.title.cmp(&b.title))
1282 });
1283 }
1284
1285 out
1286}
1287
1288fn generate_taxonomy_pages(
1290 site_dir: &Path,
1291 taxonomy_name: &str,
1292 taxonomy_title: &str,
1293 terms: &TaxonomyMap,
1294 kind: TaxonomyKind,
1295 renderer: &TaxonomyRenderer<'_>,
1296 clusters: Option<&TopicClusters>,
1297) -> Result<(), SsgError> {
1298 generate_taxonomy_pages_at(
1299 &site_dir.join(taxonomy_name),
1300 taxonomy_name,
1301 taxonomy_title,
1302 terms,
1303 kind,
1304 renderer,
1305 clusters,
1306 )
1307}
1308
1309fn topic_clusters_for<'a>(
1318 taxonomy_name: &str,
1319 clusters: &'a TopicClusters,
1320) -> Option<&'a TopicClusters> {
1321 (taxonomy_name == "topics" && !clusters.is_empty()).then_some(clusters)
1322}
1323
1324fn generate_taxonomy_pages_at(
1325 tax_dir: &Path,
1326 taxonomy_name: &str,
1327 taxonomy_title: &str,
1328 terms: &TaxonomyMap,
1329 kind: TaxonomyKind,
1330 renderer: &TaxonomyRenderer<'_>,
1331 clusters: Option<&TopicClusters>,
1332) -> Result<(), SsgError> {
1333 let tax_dir = tax_dir.to_path_buf();
1334 fs::create_dir_all(&tax_dir).with_path(&tax_dir)?;
1335
1336 let merged = merge_terms_by_slug(terms);
1337
1338 for (term, slug, pages) in &merged {
1340 let term_dir = tax_dir.join(slug);
1341 fs::create_dir_all(&term_dir).with_path(&term_dir)?;
1342
1343 let cluster = clusters.and_then(|c| c.get(slug.as_str()));
1347
1348 let reordered;
1351 let pages = match cluster {
1352 Some(c) if !c.order.is_empty() => {
1353 let mut owned = pages.clone();
1354 crate::plugins_group::topic_clusters::apply_order(
1355 &c.order,
1356 &mut owned,
1357 |p| p.url.as_str(),
1358 );
1359 reordered = owned;
1360 &reordered
1361 }
1362 _ => pages,
1363 };
1364
1365 let display_term = cluster
1368 .and_then(|c| c.title.as_deref())
1369 .unwrap_or(term.as_str());
1370
1371 let term_html = renderer.render_term_page(
1372 kind,
1373 taxonomy_name,
1374 taxonomy_title,
1375 display_term,
1376 slug,
1377 pages,
1378 cluster,
1379 )?;
1380 let out_file = term_dir.join("index.html");
1381 write_taxonomy_page(&out_file, &term_html)?;
1382 }
1383
1384 let sorted_terms: Vec<(&String, &Vec<PageRef>)> = merged
1386 .iter()
1387 .map(|(term, _, pages)| (term, pages))
1388 .collect();
1389 let index_html = renderer.render_index_page(
1390 taxonomy_name,
1391 taxonomy_title,
1392 &sorted_terms,
1393 clusters,
1394 )?;
1395 let out_index = tax_dir.join("index.html");
1396 write_taxonomy_page(&out_index, &index_html)?;
1397
1398 Ok(())
1399}
1400
1401fn slugify(s: &str) -> String {
1405 ssg_core::slugify(s)
1406}
1407
1408#[cfg(test)]
1409fn capitalize(s: &str) -> String {
1410 let mut c = s.chars();
1411 match c.next() {
1412 None => String::new(),
1413 Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
1414 }
1415}
1416
1417fn collect_json_files(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
1418 crate::walk::walk_files(dir, "json")
1419}
1420
1421#[cfg(test)]
1422mod tests {
1423 use super::*;
1424 use crate::test_support::init_logger;
1425 use std::path::PathBuf;
1426 use tempfile::{tempdir, TempDir};
1427
1428 fn make_layout() -> (TempDir, PathBuf, PathBuf, PluginContext) {
1435 init_logger();
1436 let dir = tempdir().expect("create tempdir");
1437 let site = dir.path().join("site");
1438 let build = dir.path().join("build");
1439 let meta = build.join(".meta");
1440 fs::create_dir_all(&site).expect("mkdir site");
1441 fs::create_dir_all(&meta).expect("mkdir meta");
1442 let ctx = PluginContext::new(dir.path(), &build, &site, dir.path());
1443 (dir, site, meta, ctx)
1444 }
1445
1446 #[test]
1451 fn slugify_table_driven_inputs_produce_expected_slugs() {
1452 let cases: &[(&str, &str)] = &[
1453 ("Rust Programming", "rust-programming"),
1455 ("C++", "c"),
1457 ("hello world!", "hello-world"),
1458 ("a !! b", "a-b"),
1460 ("a___b", "a-b"),
1461 ("---rust---", "rust"),
1463 ("!!!hello!!!", "hello"),
1464 ("café", "café"),
1466 ("!!!", ""),
1468 ("rust-web", "rust-web"),
1470 ("Rust 2024", "rust-2024"),
1472 ("", ""),
1474 ];
1475 for &(input, expected) in cases {
1476 assert_eq!(
1477 slugify(input),
1478 expected,
1479 "slugify({input:?}) should be {expected:?}"
1480 );
1481 }
1482 }
1483
1484 #[test]
1485 fn slugify_lowercases_uppercase_input() {
1486 assert_eq!(slugify("RUST"), "rust");
1487 assert_eq!(slugify("CamelCase"), "camelcase");
1488 }
1489
1490 #[test]
1495 fn capitalize_table_driven_inputs_produce_expected_output() {
1496 let cases: &[(&str, &str)] = &[
1497 ("", ""),
1498 ("a", "A"),
1499 ("tags", "Tags"),
1500 ("categories", "Categories"),
1501 ("Tags", "Tags"),
1502 ("1", "1"),
1503 ];
1504 for &(input, expected) in cases {
1505 assert_eq!(
1506 capitalize(input),
1507 expected,
1508 "capitalize({input:?}) should be {expected:?}"
1509 );
1510 }
1511 }
1512
1513 #[test]
1518 fn taxonomy_plugin_is_copy_after_move() {
1519 let plugin = TaxonomyPlugin;
1520 let _copy = plugin;
1521 assert_eq!(plugin.name(), "taxonomy");
1522 }
1523
1524 #[test]
1525 fn name_returns_static_taxonomy_identifier() {
1526 assert_eq!(TaxonomyPlugin.name(), "taxonomy");
1527 }
1528
1529 #[test]
1534 fn after_compile_missing_meta_dir_returns_ok_without_writing() {
1535 let dir = tempdir().expect("tempdir");
1536 let site = dir.path().join("site");
1537 let build = dir.path().join("build");
1538 fs::create_dir_all(&site).expect("mkdir site");
1539 fs::create_dir_all(&build).expect("mkdir build");
1540 let ctx = PluginContext::new(dir.path(), &build, &site, dir.path());
1541
1542 TaxonomyPlugin
1543 .after_compile(&ctx)
1544 .expect("missing meta is fine");
1545 assert!(!site.join("tags").exists());
1546 assert!(!site.join("categories").exists());
1547 }
1548
1549 #[test]
1550 fn after_compile_empty_meta_dir_returns_ok_without_writing() {
1551 let (_tmp, site, _meta, ctx) = make_layout();
1552 TaxonomyPlugin
1553 .after_compile(&ctx)
1554 .expect("empty meta is fine");
1555 assert!(!site.join("tags").exists());
1556 assert!(!site.join("categories").exists());
1557 }
1558
1559 #[test]
1560 fn after_compile_pages_without_taxonomies_emit_no_output() {
1561 let (_tmp, site, meta, ctx) = make_layout();
1562 fs::write(meta.join("about.meta.json"), r#"{"title": "About"}"#)
1563 .unwrap();
1564
1565 TaxonomyPlugin.after_compile(&ctx).unwrap();
1566 assert!(!site.join("tags").exists());
1567 assert!(!site.join("categories").exists());
1568 }
1569
1570 fn ctx_with_opt_out(base: &PluginContext, opt_out: bool) -> PluginContext {
1576 let mut cfg = crate::cmd::SsgConfig::builder()
1577 .site_name("Example".to_string())
1578 .base_url("https://example.com".to_string())
1579 .build()
1580 .expect("config");
1581 cfg.no_taxonomy_pages = opt_out;
1582 PluginContext::with_config(
1583 &base.content_dir,
1584 &base.build_dir,
1585 &base.site_dir,
1586 &base.template_dir,
1587 cfg,
1588 )
1589 }
1590
1591 #[test]
1592 fn no_taxonomy_pages_suppresses_generation() {
1593 let (_tmp, site, meta, base) = make_layout();
1594 fs::write(
1595 meta.join("p.meta.json"),
1596 r#"{"title": "P", "tags": ["rust", "web"], "categories": ["coding"]}"#,
1597 )
1598 .unwrap();
1599
1600 TaxonomyPlugin
1601 .after_compile(&ctx_with_opt_out(&base, true))
1602 .unwrap();
1603
1604 assert!(
1605 !site.join("tags").exists(),
1606 "tags tree written despite opt-out"
1607 );
1608 assert!(!site.join("categories").exists());
1609 }
1610
1611 #[test]
1612 fn taxonomy_pages_are_generated_by_default() {
1613 let (_tmp, site, meta, base) = make_layout();
1617 fs::write(
1618 meta.join("p.meta.json"),
1619 r#"{"title": "P", "tags": ["rust"]}"#,
1620 )
1621 .unwrap();
1622
1623 TaxonomyPlugin
1624 .after_compile(&ctx_with_opt_out(&base, false))
1625 .unwrap();
1626
1627 assert!(
1628 site.join("tags/rust/index.html").exists(),
1629 "default must still generate taxonomy pages"
1630 );
1631 }
1632
1633 #[test]
1638 fn after_compile_skips_invalid_json_sidecars() {
1639 let (_tmp, site, meta, ctx) = make_layout();
1640 fs::write(meta.join("broken.meta.json"), "{not valid").unwrap();
1641 fs::write(
1642 meta.join("good.meta.json"),
1643 r#"{"title": "Good", "tags": ["rust"]}"#,
1644 )
1645 .unwrap();
1646
1647 TaxonomyPlugin.after_compile(&ctx).unwrap();
1648 assert!(site.join("tags/rust/index.html").exists());
1649 }
1650
1651 #[test]
1652 fn after_compile_missing_title_falls_back_to_untitled() {
1653 let (_tmp, site, meta, ctx) = make_layout();
1654 fs::write(meta.join("notitle.meta.json"), r#"{"tags": ["rust"]}"#)
1655 .unwrap();
1656
1657 TaxonomyPlugin.after_compile(&ctx).unwrap();
1658 let html =
1659 fs::read_to_string(site.join("tags/rust/index.html")).unwrap();
1660 assert!(html.contains("Untitled"));
1661 }
1662
1663 #[test]
1681 fn tag_page_never_renders_a_placeholder_site_title() {
1682 let (_tmp, site, meta, base) = make_layout();
1683 fs::write(
1684 meta.join("p.meta.json"),
1685 r#"{"title": "P", "tags": ["rust"]}"#,
1686 )
1687 .unwrap();
1688
1689 let cfg = crate::cmd::SsgConfig::builder()
1692 .site_name("Example".to_string())
1693 .base_url("https://example.com".to_string())
1694 .build()
1695 .expect("config");
1696 let ctx = PluginContext::with_config(
1697 &base.content_dir,
1698 &base.build_dir,
1699 &base.site_dir,
1700 &base.template_dir,
1701 cfg,
1702 );
1703
1704 TaxonomyPlugin.after_compile(&ctx).unwrap();
1705 let html =
1706 fs::read_to_string(site.join("tags/rust/index.html")).unwrap();
1707
1708 for placeholder in ["My SSG Site", "MySsgSite", "A site built with SSG"]
1709 {
1710 assert!(
1711 !html.contains(placeholder),
1712 "generated tag page rendered the placeholder {placeholder:?}; \
1713 an unconfigured site must brand nothing.\nRendered:\n{html}"
1714 );
1715 }
1716 }
1717
1718 #[test]
1724 fn tag_page_renders_a_configured_site_title() {
1725 let (_tmp, site, meta, base) = make_layout();
1726 fs::write(
1727 meta.join("p.meta.json"),
1728 r#"{"title": "P", "tags": ["rust"]}"#,
1729 )
1730 .unwrap();
1731
1732 let cfg = crate::cmd::SsgConfig::builder()
1733 .site_name("Example".to_string())
1734 .site_title("Sebastien Rousseau".to_string())
1735 .base_url("https://example.com".to_string())
1736 .build()
1737 .expect("config");
1738 let ctx = PluginContext::with_config(
1739 &base.content_dir,
1740 &base.build_dir,
1741 &base.site_dir,
1742 &base.template_dir,
1743 cfg,
1744 );
1745
1746 TaxonomyPlugin.after_compile(&ctx).unwrap();
1747 let html =
1748 fs::read_to_string(site.join("tags/rust/index.html")).unwrap();
1749 assert!(
1750 html.contains("Sebastien Rousseau"),
1751 "a configured site_title must still reach the tag page"
1752 );
1753 }
1754
1755 #[test]
1762 fn taxonomy_index_renders_a_configured_site_title() {
1763 let (_tmp, site, meta, base) = make_layout();
1764 fs::write(
1765 meta.join("p.meta.json"),
1766 r#"{"title": "P", "tags": ["rust"]}"#,
1767 )
1768 .unwrap();
1769
1770 let cfg = crate::cmd::SsgConfig::builder()
1771 .site_name("Example".to_string())
1772 .site_title("Sebastien Rousseau".to_string())
1773 .base_url("https://example.com".to_string())
1774 .build()
1775 .expect("config");
1776 let ctx = PluginContext::with_config(
1777 &base.content_dir,
1778 &base.build_dir,
1779 &base.site_dir,
1780 &base.template_dir,
1781 cfg,
1782 );
1783
1784 TaxonomyPlugin.after_compile(&ctx).unwrap();
1785 let html = fs::read_to_string(site.join("tags/index.html")).unwrap();
1786 assert!(
1787 html.contains("Sebastien Rousseau"),
1788 "a configured site_title must reach the taxonomy index too"
1789 );
1790 }
1791
1792 #[test]
1795 fn taxonomy_pages_omit_the_separator_without_a_site_title() {
1796 let (_tmp, site, meta, ctx) = make_layout();
1797 fs::write(
1798 meta.join("p.meta.json"),
1799 r#"{"title": "P", "tags": ["rust"]}"#,
1800 )
1801 .unwrap();
1802
1803 TaxonomyPlugin.after_compile(&ctx).unwrap();
1804
1805 for rel in ["tags/index.html", "tags/rust/index.html"] {
1806 let html = fs::read_to_string(site.join(rel)).unwrap();
1807 let title = html
1808 .split("<title>")
1809 .nth(1)
1810 .and_then(|t| t.split("</title>").next())
1811 .unwrap_or_default();
1812 assert!(
1813 !title.contains('\u{2014}'),
1814 "{rel}: an unset site_title must not leave a separator, \
1815 got <title>{title}</title>"
1816 );
1817 }
1818 }
1819
1820 #[test]
1827 fn tag_lists_split_on_the_comma_of_their_own_script() {
1828 let (_tmp, site, meta, ctx) = make_layout();
1829 fs::write(
1830 meta.join("intl.meta.json"),
1831 r#"{"title": "Intl", "tags": "\u0627\u0644\u0645\u0635\u0631\u0641\u064a\u0629\u060c \u0627\u0644\u0645\u062f\u0641\u0648\u0639\u0627\u062a"}"#,
1832 )
1833 .unwrap();
1834
1835 TaxonomyPlugin.after_compile(&ctx).unwrap();
1836
1837 let tags = site.join("tags");
1838 let count = fs::read_dir(&tags).map_or(0, Iterator::count);
1839 assert!(
1840 count >= 2,
1841 "an Arabic-comma tag list must split into separate terms, got \
1842 {count} director(y|ies) under {}",
1843 tags.display()
1844 );
1845 for entry in fs::read_dir(&tags).unwrap().flatten() {
1846 let name = entry.file_name();
1847 let name = name.to_string_lossy();
1848 assert!(
1849 name.len() <= 200,
1850 "slug {name:?} is {} bytes; path components are capped at 255 \
1851 bytes on ext4 and slugify caps at 200",
1852 name.len()
1853 );
1854 }
1855 }
1856
1857 #[test]
1858 fn after_compile_ignores_non_string_tag_values() {
1859 let (_tmp, site, meta, ctx) = make_layout();
1860 fs::write(
1861 meta.join("mixed.meta.json"),
1862 r#"{"title": "Mixed", "tags": ["rust", 42, null, "web", {"x":1}]}"#,
1863 )
1864 .unwrap();
1865
1866 TaxonomyPlugin.after_compile(&ctx).unwrap();
1867 assert!(site.join("tags/rust/index.html").exists());
1868 assert!(site.join("tags/web/index.html").exists());
1869 }
1870
1871 #[test]
1872 fn after_compile_accepts_comma_separated_categories_string() {
1873 let (_tmp, site, meta, ctx) = make_layout();
1876 fs::write(
1877 meta.join("strcats.meta.json"),
1878 r#"{"title": "StrCats", "categories": "guides, how-to"}"#,
1879 )
1880 .unwrap();
1881
1882 TaxonomyPlugin.after_compile(&ctx).unwrap();
1883 assert!(site.join("categories/guides/index.html").exists());
1884 assert!(site.join("categories/how-to/index.html").exists());
1885 }
1886
1887 #[test]
1888 fn after_compile_ignores_non_string_category_values() {
1889 let (_tmp, site, meta, ctx) = make_layout();
1890 fs::write(
1891 meta.join("mixed-cats.meta.json"),
1892 r#"{"title": "Mixed", "categories": ["blog", 42, null, {"x":1}]}"#,
1893 )
1894 .unwrap();
1895
1896 TaxonomyPlugin.after_compile(&ctx).unwrap();
1897 assert!(site.join("categories/blog/index.html").exists());
1898 }
1899
1900 #[test]
1901 fn after_compile_accepts_comma_separated_tags_string() {
1902 let (_tmp, site, _meta_dir, ctx) = make_layout();
1905 let meta_dir = ctx.build_dir.join(".meta");
1906 fs::write(
1907 meta_dir.join("strtags.meta.json"),
1908 r#"{"title": "StrTags", "tags": "rust, web"}"#,
1909 )
1910 .unwrap();
1911
1912 TaxonomyPlugin.after_compile(&ctx).unwrap();
1913 assert!(site.join("tags/rust/index.html").exists());
1914 assert!(site.join("tags/web/index.html").exists());
1915 let html =
1916 fs::read_to_string(site.join("tags/rust/index.html")).unwrap();
1917 assert!(html.contains("StrTags"));
1918 }
1919
1920 #[test]
1921 fn after_compile_ignores_non_string_non_array_tags_field() {
1922 let (_tmp, site, _meta_dir, ctx) = make_layout();
1924 let meta_dir = ctx.build_dir.join(".meta");
1925 fs::write(
1926 meta_dir.join("badtype.meta.json"),
1927 r#"{"title": "BadType", "tags": 42}"#,
1928 )
1929 .unwrap();
1930
1931 TaxonomyPlugin.after_compile(&ctx).unwrap();
1932 assert!(!site.join("tags").exists());
1933 }
1934
1935 #[test]
1936 fn slug_colliding_terms_merge_into_one_page() {
1937 let (_tmp, site, meta, ctx) = make_layout();
1945 fs::write(
1946 meta.join("a.meta.json"),
1947 r#"{"title": "Upper", "tags": ["SWIFT"]}"#,
1948 )
1949 .unwrap();
1950 fs::write(
1951 meta.join("b.meta.json"),
1952 r#"{"title": "Mixed", "tags": ["Swift"]}"#,
1953 )
1954 .unwrap();
1955
1956 TaxonomyPlugin.after_compile(&ctx).unwrap();
1957
1958 let page =
1959 fs::read_to_string(site.join("tags/swift/index.html")).unwrap();
1960 assert!(
1961 page.contains("Upper") && page.contains("Mixed"),
1962 "both spellings share one URL, so one page must list both \
1963 members; neither may be silently dropped. Got:\n{page}"
1964 );
1965 }
1966
1967 #[test]
1968 fn slug_colliding_terms_appear_once_in_the_index() {
1969 let (_tmp, site, meta, ctx) = make_layout();
1970 fs::write(
1971 meta.join("a.meta.json"),
1972 r#"{"title": "Upper", "tags": ["SWIFT"]}"#,
1973 )
1974 .unwrap();
1975 fs::write(
1976 meta.join("b.meta.json"),
1977 r#"{"title": "Mixed", "tags": ["Swift"]}"#,
1978 )
1979 .unwrap();
1980
1981 TaxonomyPlugin.after_compile(&ctx).unwrap();
1982
1983 let index = fs::read_to_string(site.join("tags/index.html")).unwrap();
1984 assert_eq!(
1985 index.matches("/tags/swift/").count(),
1986 1,
1987 "one slug must be listed once, not once per spelling:\n{index}"
1988 );
1989 }
1990
1991 #[test]
1992 fn taxonomy_output_is_byte_identical_across_runs() {
1993 fn build() -> Vec<(String, String)> {
2001 let (tmp, site, meta, ctx) = make_layout();
2002 for (i, tags) in [
2003 r#"["SWIFT", "Governance"]"#,
2004 r#"["Swift", "governance"]"#,
2005 r#"["CBPR", "Open Source"]"#,
2006 r#"["CBPR+", "open source"]"#,
2007 r#"["ISO 20022", "Payments"]"#,
2008 r#"["ISO-20022", "payments"]"#,
2009 ]
2010 .iter()
2011 .enumerate()
2012 {
2013 fs::write(
2014 meta.join(format!("p{i}.meta.json")),
2015 format!(r#"{{"title": "P{i}", "tags": {tags}}}"#),
2016 )
2017 .unwrap();
2018 }
2019
2020 TaxonomyPlugin.after_compile(&ctx).unwrap();
2021
2022 let mut out = Vec::new();
2023 let tags_dir = site.join("tags");
2024 let mut files = crate::walk::walk_files(&tags_dir, "html").unwrap();
2025 files.sort();
2026 for f in files {
2027 let rel = f
2028 .strip_prefix(&tags_dir)
2029 .unwrap()
2030 .to_string_lossy()
2031 .into_owned();
2032 out.push((rel, fs::read_to_string(&f).unwrap()));
2033 }
2034 drop(tmp);
2035 out
2036 }
2037
2038 let first = build();
2039 let second = build();
2040 assert!(!first.is_empty(), "fixture produced no taxonomy pages");
2041 assert_eq!(
2042 first, second,
2043 "two runs over identical input must produce identical pages"
2044 );
2045 }
2046
2047 #[test]
2048 fn after_compile_preserves_author_authored_hub_page() {
2049 let (_tmp, site, meta, ctx) = make_layout();
2053 fs::write(
2054 meta.join("p.meta.json"),
2055 r#"{"title": "P", "tags": ["rust"]}"#,
2056 )
2057 .unwrap();
2058 let tags_dir = site.join("tags");
2059 fs::create_dir_all(&tags_dir).unwrap();
2060 let authored = "<!DOCTYPE html><html><head><title>My topics</title>\
2061 </head><body>hand-written</body></html>";
2062 fs::write(tags_dir.join("index.html"), authored).unwrap();
2063
2064 TaxonomyPlugin.after_compile(&ctx).unwrap();
2065
2066 let hub = fs::read_to_string(tags_dir.join("index.html")).unwrap();
2067 assert_eq!(hub, authored, "author page must be preserved");
2068 assert!(site.join("tags/rust/index.html").exists());
2070 }
2071
2072 #[test]
2073 fn after_compile_refreshes_its_own_previous_output() {
2074 let (_tmp, site, meta, ctx) = make_layout();
2075 fs::write(
2076 meta.join("p.meta.json"),
2077 r#"{"title": "P", "tags": ["rust"]}"#,
2078 )
2079 .unwrap();
2080 TaxonomyPlugin.after_compile(&ctx).unwrap();
2081 let first = fs::read_to_string(site.join("tags/index.html")).unwrap();
2082 assert!(
2083 first.contains(TAXONOMY_MARKER),
2084 "generated pages carry the marker:\n{first}"
2085 );
2086
2087 fs::write(
2089 meta.join("q.meta.json"),
2090 r#"{"title": "Q", "tags": ["rust", "web"]}"#,
2091 )
2092 .unwrap();
2093 TaxonomyPlugin.after_compile(&ctx).unwrap();
2094 let second = fs::read_to_string(site.join("tags/index.html")).unwrap();
2095 assert!(second.contains("web"), "refreshed hub lists new term");
2096 }
2097
2098 #[test]
2099 fn after_compile_falls_back_to_site_meta_sidecars() {
2100 let dir = tempdir().expect("tempdir");
2103 let site = dir.path().join("site");
2104 let build = dir.path().join("build");
2105 fs::create_dir_all(site.join(".meta")).unwrap();
2106 fs::create_dir_all(site.join("hello")).unwrap();
2107 fs::create_dir_all(&build).unwrap();
2108 fs::write(
2109 site.join(".meta/hello.meta.json"),
2110 r#"{"title": "Hello", "tags": "rust"}"#,
2111 )
2112 .unwrap();
2113 fs::write(site.join("hello/index.html"), "<html></html>").unwrap();
2114 let ctx = PluginContext::new(dir.path(), &build, &site, dir.path());
2115
2116 TaxonomyPlugin.after_compile(&ctx).unwrap();
2117 let term =
2118 fs::read_to_string(site.join("tags/rust/index.html")).unwrap();
2119 assert!(
2121 term.contains(r#"href="/hello/""#),
2122 "pretty member URL:\n{term}"
2123 );
2124 }
2125
2126 #[test]
2127 fn generated_pages_carry_essential_meta() {
2128 let (_tmp, site, meta, ctx) = make_layout();
2129 fs::write(
2130 meta.join("p.meta.json"),
2131 r#"{"title": "P", "tags": ["rust"]}"#,
2132 )
2133 .unwrap();
2134 TaxonomyPlugin.after_compile(&ctx).unwrap();
2135 let html =
2136 fs::read_to_string(site.join("tags/rust/index.html")).unwrap();
2137 assert!(html.contains("name=\"description\""), "{html}");
2138 assert!(html.contains("property=\"og:title\""), "{html}");
2139 assert!(html.contains("property=\"og:type\""), "{html}");
2140 assert!(html.contains("name=\"twitter:card\""), "{html}");
2141 assert!(html.contains(TAXONOMY_MARKER), "{html}");
2142 }
2143
2144 #[test]
2145 fn term_pages_inline_canonical_and_lang_with_config() {
2146 let (_tmp, site, meta, base_ctx) = make_layout();
2151 fs::write(
2152 meta.join("p.meta.json"),
2153 r#"{"title": "P", "tags": "rust"}"#,
2154 )
2155 .unwrap();
2156 let cfg = crate::cmd::SsgConfig::builder()
2157 .site_name("Example".to_string())
2158 .base_url("https://example.com".to_string())
2159 .build()
2160 .expect("config");
2161 let ctx = PluginContext::with_config(
2162 &base_ctx.content_dir,
2163 &base_ctx.build_dir,
2164 &base_ctx.site_dir,
2165 &base_ctx.template_dir,
2166 cfg,
2167 );
2168
2169 TaxonomyPlugin.after_compile(&ctx).unwrap();
2170 let html =
2171 fs::read_to_string(site.join("tags/rust/index.html")).unwrap();
2172 assert!(html.contains("<!DOCTYPE html>"), "doctype:\n{html}");
2173 assert!(html.contains("<html lang="), "lang attr:\n{html}");
2174 #[cfg(feature = "templates")]
2175 assert!(
2176 html.contains(
2177 r#"<link rel="canonical" href="https://example.com/tags/rust/">"#
2178 ),
2179 "canonical:\n{html}"
2180 );
2181 }
2182
2183 #[test]
2184 fn term_pages_include_og_image_when_configured() {
2185 let (_tmp, site, meta, base_ctx) = make_layout();
2186 fs::write(
2187 meta.join("p.meta.json"),
2188 r#"{"title": "P", "tags": "rust"}"#,
2189 )
2190 .unwrap();
2191 let cfg = crate::cmd::SsgConfig::builder()
2192 .site_name("Example".to_string())
2193 .og_image(Some("/social/default.png".to_string()))
2194 .build()
2195 .expect("config");
2196 let ctx = PluginContext::with_config(
2197 &base_ctx.content_dir,
2198 &base_ctx.build_dir,
2199 &base_ctx.site_dir,
2200 &base_ctx.template_dir,
2201 cfg,
2202 );
2203
2204 TaxonomyPlugin.after_compile(&ctx).unwrap();
2205 let term_html =
2206 fs::read_to_string(site.join("tags/rust/index.html")).unwrap();
2207 assert!(
2208 term_html.contains(
2209 r#"<meta property="og:image" content="/social/default.png">"#
2210 ),
2211 "term page missing og:image:\n{term_html}"
2212 );
2213 let index_html =
2214 fs::read_to_string(site.join("tags/index.html")).unwrap();
2215 assert!(
2216 index_html.contains(
2217 r#"<meta property="og:image" content="/social/default.png">"#
2218 ),
2219 "index page missing og:image:\n{index_html}"
2220 );
2221 }
2222
2223 #[cfg(feature = "templates")]
2234 #[test]
2235 fn curated_topic_metadata_reaches_the_pillar_page() {
2236 let (tmp, site, meta, ctx) = make_layout();
2237 for (name, title) in [("a", "Alpha"), ("b", "Bravo"), ("c", "Charlie")]
2238 {
2239 fs::write(
2240 meta.join(format!("{name}.meta.json")),
2241 format!(
2242 r#"{{"title": "{title}", "topic_clusters": "payments", "permalink": "/posts/{name}/"}}"#
2243 ),
2244 )
2245 .unwrap();
2246 }
2247 let data = tmp.path().join("_data");
2248 fs::create_dir_all(&data).unwrap();
2249 fs::write(
2250 data.join("topics.toml"),
2251 concat!(
2252 "[payments]\n",
2253 "title = \"Payments, end to end\"\n",
2254 "lede = \"What moves money and what it costs.\"\n",
2255 "order = [\"c\"]\n",
2256 ),
2257 )
2258 .unwrap();
2259
2260 TaxonomyPlugin.after_compile(&ctx).unwrap();
2261
2262 let page = fs::read_to_string(site.join("topics/payments/index.html"))
2263 .expect("the pillar page was written");
2264
2265 assert!(
2266 page.contains("Payments, end to end"),
2267 "curated title is missing:\n{page}"
2268 );
2269 assert!(
2270 page.contains("What moves money and what it costs."),
2271 "curated lede is missing:\n{page}"
2272 );
2273
2274 let c = page.find("Charlie").expect("Charlie listed");
2276 let a = page.find("Alpha").expect("Alpha listed");
2277 let b = page.find("Bravo").expect("Bravo listed");
2278 assert!(c < a && a < b, "curated order not applied:\n{page}");
2279 }
2280
2281 #[cfg(feature = "templates")]
2290 #[test]
2291 fn pages_with_card_data_render_as_cards() {
2292 let (_tmp, site, meta, ctx) = make_layout();
2293 fs::write(
2294 meta.join("rich.meta.json"),
2295 r#"{"title": "Rich", "topic_clusters": "payments",
2296 "description": "What moves money.",
2297 "date": "2026-04-01",
2298 "banner": "/img/rich.webp"}"#,
2299 )
2300 .unwrap();
2301 fs::write(
2302 meta.join("bare.meta.json"),
2303 r#"{"title": "Bare", "topic_clusters": "payments"}"#,
2304 )
2305 .unwrap();
2306
2307 TaxonomyPlugin.after_compile(&ctx).unwrap();
2308 let page = fs::read_to_string(site.join("topics/payments/index.html"))
2309 .expect("page written");
2310
2311 assert!(page.contains("What moves money."), "description: {page}");
2312 assert!(page.contains("/img/rich.webp"), "banner: {page}");
2313 assert!(
2314 page.contains(r#"<time datetime="2026-04-01">"#),
2315 "date: {page}"
2316 );
2317 assert!(page.contains(">Bare</a>"), "bare page still listed: {page}");
2319 }
2320
2321 #[cfg(feature = "templates")]
2327 #[test]
2328 fn topic_pages_emit_structured_data() {
2329 let (_tmp, site, meta, ctx) = make_layout();
2330 fs::write(
2331 meta.join("p.meta.json"),
2332 r#"{"title": "P", "topic_clusters": "payments"}"#,
2333 )
2334 .unwrap();
2335
2336 TaxonomyPlugin.after_compile(&ctx).unwrap();
2337 let page = fs::read_to_string(site.join("topics/payments/index.html"))
2338 .expect("page written");
2339
2340 let start = page
2341 .find(r#"<script type="application/ld+json">"#)
2342 .expect("a JSON-LD block");
2343 let body = &page[start..];
2344 let json = &body[body.find('{').expect("json starts")
2345 ..=body.rfind('}').expect("json ends")];
2346 let parsed: serde_json::Value =
2347 serde_json::from_str(json).expect("JSON-LD must parse");
2348
2349 let graph = parsed["@graph"].as_array().expect("a @graph");
2350 let types: Vec<&str> =
2351 graph.iter().filter_map(|n| n["@type"].as_str()).collect();
2352 assert!(types.contains(&"CollectionPage"), "{types:?}");
2353 assert!(types.contains(&"BreadcrumbList"), "{types:?}");
2354 assert_eq!(graph[0]["mainEntity"]["@type"].as_str(), Some("ItemList"));
2355 assert_eq!(graph[0]["mainEntity"]["numberOfItems"], 1);
2356 }
2357
2358 #[test]
2360 fn tag_pages_carry_no_structured_data() {
2361 let (_tmp, site, meta, ctx) = make_layout();
2362 fs::write(
2363 meta.join("p.meta.json"),
2364 r#"{"title": "P", "tags": "rust"}"#,
2365 )
2366 .unwrap();
2367
2368 TaxonomyPlugin.after_compile(&ctx).unwrap();
2369 let page = fs::read_to_string(site.join("tags/rust/index.html"))
2370 .expect("page written");
2371 assert!(
2372 !page.contains("application/ld+json"),
2373 "tag pages stay as they were: {page}"
2374 );
2375 }
2376
2377 #[cfg(feature = "templates")]
2382 #[test]
2383 fn the_hub_renders_curated_topics_as_cards() {
2384 let (tmp, site, meta, ctx) = make_layout();
2385 fs::write(
2386 meta.join("p.meta.json"),
2387 r#"{"title": "P", "topic_clusters": "payments"}"#,
2388 )
2389 .unwrap();
2390 let data = tmp.path().join("_data");
2391 fs::create_dir_all(&data).unwrap();
2392 fs::write(
2393 data.join("topics.toml"),
2394 concat!(
2395 "[payments]\n",
2396 "title = \"Payments, end to end\"\n",
2397 "lede = \"What moves money and what it costs.\"\n",
2398 ),
2399 )
2400 .unwrap();
2401
2402 TaxonomyPlugin.after_compile(&ctx).unwrap();
2403 let hub = fs::read_to_string(site.join("topics/index.html"))
2404 .expect("hub written");
2405
2406 assert!(hub.contains("Payments, end to end"), "title: {hub}");
2407 assert!(
2408 hub.contains("What moves money and what it costs."),
2409 "lede: {hub}"
2410 );
2411 assert!(hub.contains("taxonomy-card"), "rendered as a card: {hub}");
2412 }
2413
2414 #[cfg(feature = "templates")]
2419 #[test]
2420 fn topics_without_curation_render_exactly_as_before() {
2421 let (_tmp, site, meta, ctx) = make_layout();
2422 fs::write(
2423 meta.join("p.meta.json"),
2424 r#"{"title": "P", "topic_clusters": "payments"}"#,
2425 )
2426 .unwrap();
2427
2428 TaxonomyPlugin.after_compile(&ctx).unwrap();
2429
2430 let page = fs::read_to_string(site.join("topics/payments/index.html"))
2431 .expect("page written");
2432 assert!(
2433 page.contains(r#"<span class="term-name">payments</span>"#),
2434 "the term renders as written, with no curated title: {page}"
2435 );
2436 assert!(!page.contains("class=\"lede\""), "no lede: {page}");
2437 assert!(!page.contains("taxonomy-banner"), "no banner: {page}");
2438 }
2439
2440 #[test]
2441 fn term_pages_omit_og_image_when_not_configured() {
2442 let (_tmp, site, meta, ctx) = make_layout();
2445 fs::write(
2446 meta.join("p.meta.json"),
2447 r#"{"title": "P", "tags": "rust"}"#,
2448 )
2449 .unwrap();
2450
2451 TaxonomyPlugin.after_compile(&ctx).unwrap();
2452 let term_html =
2453 fs::read_to_string(site.join("tags/rust/index.html")).unwrap();
2454 assert!(
2455 !term_html.contains("og:image"),
2456 "term page should not carry og:image:\n{term_html}"
2457 );
2458 let index_html =
2459 fs::read_to_string(site.join("tags/index.html")).unwrap();
2460 assert!(
2461 !index_html.contains("og:image"),
2462 "index page should not carry og:image:\n{index_html}"
2463 );
2464 }
2465
2466 #[test]
2471 fn after_compile_generates_index_and_term_pages_for_tags() {
2472 let (_tmp, site, meta, ctx) = make_layout();
2473 fs::write(
2474 meta.join("p1.meta.json"),
2475 r#"{"title": "P1", "tags": ["rust", "web"]}"#,
2476 )
2477 .unwrap();
2478 fs::write(
2479 meta.join("p2.meta.json"),
2480 r#"{"title": "P2", "tags": ["rust"]}"#,
2481 )
2482 .unwrap();
2483
2484 TaxonomyPlugin.after_compile(&ctx).unwrap();
2485
2486 assert!(site.join("tags/index.html").exists());
2487 assert!(site.join("tags/rust/index.html").exists());
2488 assert!(site.join("tags/web/index.html").exists());
2489
2490 let rust =
2491 fs::read_to_string(site.join("tags/rust/index.html")).unwrap();
2492 assert!(rust.contains("P1"));
2493 assert!(rust.contains("P2"));
2494
2495 let web = fs::read_to_string(site.join("tags/web/index.html")).unwrap();
2496 assert!(web.contains("P1"));
2497 assert!(!web.contains("P2"));
2498 }
2499
2500 #[test]
2501 fn after_compile_generates_index_and_term_pages_for_categories() {
2502 let (_tmp, site, meta, ctx) = make_layout();
2503 fs::write(
2504 meta.join("p1.meta.json"),
2505 r#"{"title": "P1", "categories": ["tutorials"]}"#,
2506 )
2507 .unwrap();
2508
2509 TaxonomyPlugin.after_compile(&ctx).unwrap();
2510 assert!(site.join("categories/index.html").exists());
2511 assert!(site.join("categories/tutorials/index.html").exists());
2512 }
2513
2514 #[test]
2515 fn after_compile_generates_index_and_term_pages_for_topics() {
2516 let (_tmp, site, meta, ctx) = make_layout();
2517 fs::write(
2518 meta.join("p1.meta.json"),
2519 r#"{"title": "P1", "topic_clusters": "cloud-native-banking"}"#,
2520 )
2521 .unwrap();
2522
2523 TaxonomyPlugin.after_compile(&ctx).unwrap();
2524 assert!(site.join("topics/index.html").exists());
2525 assert!(site.join("topics/cloud-native-banking/index.html").exists());
2526 }
2527
2528 #[test]
2529 fn after_compile_index_shows_page_count_per_term() {
2530 let (_tmp, site, meta, ctx) = make_layout();
2531 fs::write(
2532 meta.join("a.meta.json"),
2533 r#"{"title": "A", "tags": ["rust"]}"#,
2534 )
2535 .unwrap();
2536 fs::write(
2537 meta.join("b.meta.json"),
2538 r#"{"title": "B", "tags": ["rust"]}"#,
2539 )
2540 .unwrap();
2541 fs::write(
2542 meta.join("c.meta.json"),
2543 r#"{"title": "C", "tags": ["rust", "web"]}"#,
2544 )
2545 .unwrap();
2546
2547 TaxonomyPlugin.after_compile(&ctx).unwrap();
2548 let index = fs::read_to_string(site.join("tags/index.html")).unwrap();
2549 assert!(index.contains("(3)"), "rust should have 3 posts:\n{index}");
2550 assert!(index.contains("(1)"), "web should have 1 post:\n{index}");
2551 }
2552
2553 #[test]
2554 fn after_compile_index_lists_terms_alphabetically_case_insensitive() {
2555 let (_tmp, site, meta, ctx) = make_layout();
2556 fs::write(
2557 meta.join("p.meta.json"),
2558 r#"{"title": "P", "tags": ["banana", "Apple", "cherry"]}"#,
2559 )
2560 .unwrap();
2561
2562 TaxonomyPlugin.after_compile(&ctx).unwrap();
2563 let index = fs::read_to_string(site.join("tags/index.html")).unwrap();
2564 let apple = index.find("Apple").expect("Apple in index");
2565 let banana = index.find("banana").expect("banana in index");
2566 let cherry = index.find("cherry").expect("cherry in index");
2567 assert!(apple < banana, "Apple should sort before banana");
2568 assert!(banana < cherry, "banana should sort before cherry");
2569 }
2570
2571 #[test]
2572 fn after_compile_tags_and_categories_coexist_independently() {
2573 let (_tmp, site, meta, ctx) = make_layout();
2574 fs::write(
2575 meta.join("p.meta.json"),
2576 r#"{"title": "P", "tags": ["rust"], "categories": ["tutorials"]}"#,
2577 )
2578 .unwrap();
2579
2580 TaxonomyPlugin.after_compile(&ctx).unwrap();
2581 assert!(site.join("tags/rust/index.html").exists());
2582 assert!(site.join("categories/tutorials/index.html").exists());
2583 }
2584
2585 #[test]
2586 fn after_compile_idempotent_overwrites_existing_pages() {
2587 let (_tmp, site, meta, ctx) = make_layout();
2588 fs::write(
2589 meta.join("p.meta.json"),
2590 r#"{"title": "P", "tags": ["rust"]}"#,
2591 )
2592 .unwrap();
2593
2594 TaxonomyPlugin.after_compile(&ctx).expect("first run");
2595 TaxonomyPlugin.after_compile(&ctx).expect("second run");
2596 assert!(site.join("tags/rust/index.html").exists());
2597 }
2598
2599 #[test]
2600 fn after_compile_emits_doctype_lang_charset_in_index() {
2601 let (_tmp, site, meta, ctx) = make_layout();
2602 fs::write(
2603 meta.join("p.meta.json"),
2604 r#"{"title": "P", "tags": ["rust"]}"#,
2605 )
2606 .unwrap();
2607
2608 TaxonomyPlugin.after_compile(&ctx).unwrap();
2609 let html = fs::read_to_string(site.join("tags/index.html")).unwrap();
2610 assert!(html.contains("<!DOCTYPE html>"));
2611 assert!(html.contains("<html lang=\"en\">"));
2613 assert!(html.contains("<meta charset=\"utf-8\">"));
2614 assert!(html.contains("Tags"));
2615 }
2616
2617 #[test]
2618 fn after_compile_term_page_links_back_to_source_url() {
2619 let (_tmp, site, meta, ctx) = make_layout();
2620 fs::write(
2621 meta.join("hello.meta.json"),
2622 r#"{"title": "Hello", "tags": ["rust"]}"#,
2623 )
2624 .unwrap();
2625
2626 TaxonomyPlugin.after_compile(&ctx).unwrap();
2627 let html =
2628 fs::read_to_string(site.join("tags/rust/index.html")).unwrap();
2629 assert!(
2630 html.contains(r#"href="/hello.html""#),
2631 "term page should link back to /hello.html:\n{html}"
2632 );
2633 }
2634
2635 #[cfg(feature = "templates")]
2646 #[test]
2647 fn user_templates_come_only_from_the_tera_subdirectory() {
2648 let dir = tempdir().expect("tempdir");
2649 let templates = dir.path().join("templates");
2650 fs::create_dir_all(&templates).unwrap();
2651 fs::write(
2653 templates.join("base.html"),
2654 "{{#extends \"base\"}}{{#block \"main\"}}{{!content}}{{/block}}",
2655 )
2656 .unwrap();
2657
2658 let ctx =
2659 PluginContext::new(dir.path(), dir.path(), dir.path(), &templates);
2660 assert_eq!(
2661 resolve_user_template_dir(&ctx),
2662 None,
2663 "layouts dir must not be offered to MiniJinja"
2664 );
2665
2666 let tera = templates.join("tera");
2668 fs::create_dir_all(&tera).unwrap();
2669 assert_eq!(resolve_user_template_dir(&ctx), Some(tera));
2670 }
2671
2672 #[test]
2673 fn collect_json_files_returns_empty_for_missing_directory() {
2674 let dir = tempdir().expect("tempdir");
2675 let result = collect_json_files(&dir.path().join("missing")).unwrap();
2676 assert!(result.is_empty());
2677 }
2678
2679 #[test]
2680 fn collect_json_files_filters_non_json_extensions() {
2681 let dir = tempdir().expect("tempdir");
2682 fs::write(dir.path().join("a.json"), "{}").unwrap();
2683 fs::write(dir.path().join("b.txt"), "x").unwrap();
2684 fs::write(dir.path().join("c"), "x").unwrap();
2685
2686 let result = collect_json_files(dir.path()).unwrap();
2687 assert_eq!(result.len(), 1);
2688 }
2689
2690 #[test]
2691 fn collect_json_files_recurses_into_nested_subdirectories() {
2692 let dir = tempdir().expect("tempdir");
2693 let nested = dir.path().join("a").join("b");
2694 fs::create_dir_all(&nested).unwrap();
2695 fs::write(dir.path().join("top.json"), "{}").unwrap();
2696 fs::write(nested.join("deep.json"), "{}").unwrap();
2697
2698 let result = collect_json_files(dir.path()).unwrap();
2699 assert_eq!(result.len(), 2);
2700 }
2701
2702 #[test]
2703 fn collect_json_files_returns_results_sorted() {
2704 let dir = tempdir().expect("tempdir");
2705 for name in ["zebra.json", "apple.json", "mango.json"] {
2706 fs::write(dir.path().join(name), "{}").unwrap();
2707 }
2708 let result = collect_json_files(dir.path()).unwrap();
2709 let names: Vec<_> = result
2710 .iter()
2711 .map(|p| p.file_name().unwrap().to_str().unwrap())
2712 .collect();
2713 assert_eq!(names, vec!["apple.json", "mango.json", "zebra.json"]);
2714 }
2715
2716 #[test]
2721 fn taxonomy_term_can_be_constructed_and_cloned() {
2722 let term = TaxonomyTerm {
2723 name: "Rust".to_string(),
2724 slug: "rust".to_string(),
2725 pages: vec![PageRef::new("Hello", "/hello.html")],
2726 };
2727 let copy = term;
2728 assert_eq!(copy.name, "Rust");
2729 assert_eq!(copy.slug, "rust");
2730 assert_eq!(copy.pages.len(), 1);
2731 }
2732
2733 #[test]
2734 fn test_generate_taxonomy_pages_invalid_dir_returns_io_error() {
2735 let tmp = tempdir().unwrap();
2736 let file_path = tmp.path().join("file");
2737 fs::write(&file_path, "").unwrap();
2738
2739 let mut terms = HashMap::new();
2740 let _ = terms.insert(
2741 "rust".to_string(),
2742 vec![PageRef::new("Title", "/hello.html")],
2743 );
2744
2745 let ctx =
2746 PluginContext::new(tmp.path(), tmp.path(), tmp.path(), tmp.path());
2747 let renderer = TaxonomyRenderer::new(&ctx);
2748 let res = generate_taxonomy_pages(
2749 &file_path,
2750 "tags",
2751 "Tags",
2752 &terms,
2753 TaxonomyKind::Tag,
2754 &renderer,
2755 None,
2756 );
2757 assert!(res.is_err());
2758 let err = res.unwrap_err();
2759 assert!(format!("{err:?}").contains("Io"));
2762 }
2763
2764 #[test]
2769 #[cfg(feature = "templates")]
2770 fn user_templates_in_tera_dir_override_builtins() {
2771 let (tmp, site, meta, ctx) = make_layout();
2772 fs::write(
2773 meta.join("a.meta.json"),
2774 r#"{"title": "A", "tags": ["rust"]}"#,
2775 )
2776 .unwrap();
2777 let tera = tmp.path().join("tera");
2780 fs::create_dir_all(&tera).unwrap();
2781 fs::write(
2782 tera.join("tag.html"),
2783 "<html>ssg-taxonomy CUSTOMTERM {{ tag }}</html>\n",
2784 )
2785 .unwrap();
2786 fs::write(
2787 tera.join("taxonomy_index.html"),
2788 "<html>ssg-taxonomy CUSTOMINDEX</html>\n",
2789 )
2790 .unwrap();
2791
2792 TaxonomyPlugin.after_compile(&ctx).unwrap();
2793
2794 let term =
2795 fs::read_to_string(site.join("tags/rust/index.html")).unwrap();
2796 assert!(term.contains("CUSTOMTERM"));
2797 assert!(term.ends_with('\n'));
2798 let index = fs::read_to_string(site.join("tags/index.html")).unwrap();
2799 assert!(index.contains("CUSTOMINDEX"));
2800 assert!(index.ends_with('\n'));
2801 }
2802
2803 #[test]
2807 fn split_map_by_locale_groups_pages_by_their_url_segment() {
2808 let mut map: TaxonomyMap = HashMap::new();
2809 let _ = map.insert(
2810 "editorial".to_string(),
2811 vec![
2812 PageRef::new("About", "/atlas/about/"),
2813 PageRef::new("À propos", "/atlas/fr/a-propos/"),
2814 ],
2815 );
2816 let locales = vec!["en".to_string(), "fr".to_string()];
2817 let out = split_map_by_locale(&map, &locales, "en", "/atlas");
2818
2819 assert_eq!(out.len(), 2, "one map per locale: {out:?}");
2820 assert_eq!(out["en"]["editorial"].len(), 1);
2821 assert_eq!(out["en"]["editorial"][0].title, "About");
2822 assert_eq!(out["fr"]["editorial"].len(), 1);
2823 assert_eq!(out["fr"]["editorial"][0].title, "À propos");
2824 }
2825
2826 #[test]
2829 fn split_map_by_locale_assigns_unprefixed_pages_to_the_default() {
2830 let mut map: TaxonomyMap = HashMap::new();
2831 let _ = map.insert(
2832 "method".to_string(),
2833 vec![PageRef::new("Papers", "/atlas/papers/")],
2834 );
2835 let locales = vec!["en".to_string(), "fr".to_string()];
2836 let out = split_map_by_locale(&map, &locales, "en", "/atlas");
2837
2838 assert_eq!(out.keys().collect::<Vec<_>>(), vec!["en"], "{out:?}");
2839 }
2840
2841 #[test]
2844 fn split_map_by_locale_does_not_treat_the_default_locale_as_a_prefix() {
2845 let mut map: TaxonomyMap = HashMap::new();
2846 let _ = map.insert(
2847 "t".to_string(),
2848 vec![PageRef::new("EN dir", "/atlas/en/thing/")],
2849 );
2850 let locales = vec!["en".to_string(), "fr".to_string()];
2851 let out = split_map_by_locale(&map, &locales, "en", "/atlas");
2852
2853 assert_eq!(out.keys().collect::<Vec<_>>(), vec!["en"], "{out:?}");
2854 }
2855
2856 #[test]
2857 #[cfg(feature = "templates")]
2858 fn render_term_page_appends_newline_when_template_output_lacks_one() {
2859 let (tmp, site, meta, ctx) = make_layout();
2867 fs::write(
2868 meta.join("a.meta.json"),
2869 r#"{"title": "A", "tags": ["rust"]}"#,
2870 )
2871 .unwrap();
2872 let tera = tmp.path().join("tera");
2873 fs::create_dir_all(&tera).unwrap();
2874 fs::write(
2875 tera.join("tag.html"),
2876 "<html>ssg-taxonomy NO-TRAILING-NEWLINE {{ tag }}</html>",
2877 )
2878 .unwrap();
2879
2880 TaxonomyPlugin.after_compile(&ctx).unwrap();
2881
2882 let term =
2883 fs::read_to_string(site.join("tags/rust/index.html")).unwrap();
2884 assert!(term.contains("NO-TRAILING-NEWLINE"));
2885 assert!(
2886 term.ends_with('\n'),
2887 "render_term_page must append the missing trailing newline"
2888 );
2889 }
2890
2891 #[test]
2892 #[cfg(feature = "templates")]
2893 fn render_index_page_appends_newline_when_template_output_lacks_one() {
2894 let (tmp, site, meta, ctx) = make_layout();
2897 fs::write(
2898 meta.join("a.meta.json"),
2899 r#"{"title": "A", "tags": ["rust"]}"#,
2900 )
2901 .unwrap();
2902 let tera = tmp.path().join("tera");
2903 fs::create_dir_all(&tera).unwrap();
2904 fs::write(
2905 tera.join("taxonomy_index.html"),
2906 "<html>ssg-taxonomy NO-TRAILING-NEWLINE-INDEX</html>",
2907 )
2908 .unwrap();
2909
2910 TaxonomyPlugin.after_compile(&ctx).unwrap();
2911
2912 let index = fs::read_to_string(site.join("tags/index.html")).unwrap();
2913 assert!(index.contains("NO-TRAILING-NEWLINE-INDEX"));
2914 assert!(
2915 index.ends_with('\n'),
2916 "render_index_page must append the missing trailing newline"
2917 );
2918 }
2919
2920 #[test]
2921 #[cfg(feature = "templates")]
2922 fn nonexistent_template_dir_falls_back_to_builtins() {
2923 let dir = tempdir().unwrap();
2926 let site = dir.path().join("site");
2927 let build = dir.path().join("build");
2928 let meta = build.join(".meta");
2929 fs::create_dir_all(&site).unwrap();
2930 fs::create_dir_all(&meta).unwrap();
2931 fs::write(
2932 meta.join("a.meta.json"),
2933 r#"{"title": "A", "tags": ["rust"]}"#,
2934 )
2935 .unwrap();
2936 let ctx = PluginContext::new(
2937 dir.path(),
2938 &build,
2939 &site,
2940 &dir.path().join("no-such-templates"),
2941 );
2942
2943 TaxonomyPlugin.after_compile(&ctx).unwrap();
2944 assert!(site.join("tags/rust/index.html").exists());
2945 }
2946
2947 #[test]
2948 #[cfg(all(unix, feature = "templates"))]
2949 fn unreadable_user_term_template_fails_tag_generation() {
2950 use std::os::unix::fs::PermissionsExt;
2951 let (tmp, _site, meta, ctx) = make_layout();
2952 fs::write(
2953 meta.join("a.meta.json"),
2954 r#"{"title": "A", "tags": ["rust"]}"#,
2955 )
2956 .unwrap();
2957 let tpl = tmp.path().join("tag.html");
2958 fs::write(&tpl, "x").unwrap();
2959 fs::set_permissions(&tpl, fs::Permissions::from_mode(0o000)).unwrap();
2960
2961 let res = TaxonomyPlugin.after_compile(&ctx);
2962
2963 let _ = fs::set_permissions(&tpl, fs::Permissions::from_mode(0o644));
2964 if let Err(e) = res {
2966 assert!(!format!("{e}").is_empty());
2967 }
2968 }
2969
2970 #[test]
2971 #[cfg(all(unix, feature = "templates"))]
2972 fn unreadable_user_category_template_fails_category_generation() {
2973 use std::os::unix::fs::PermissionsExt;
2974 let (tmp, _site, meta, ctx) = make_layout();
2975 fs::write(
2976 meta.join("a.meta.json"),
2977 r#"{"title": "A", "categories": ["guides"]}"#,
2978 )
2979 .unwrap();
2980 let tpl = tmp.path().join("category.html");
2981 fs::write(&tpl, "x").unwrap();
2982 fs::set_permissions(&tpl, fs::Permissions::from_mode(0o000)).unwrap();
2983
2984 let res = TaxonomyPlugin.after_compile(&ctx);
2985
2986 let _ = fs::set_permissions(&tpl, fs::Permissions::from_mode(0o644));
2987 if let Err(e) = res {
2988 assert!(!format!("{e}").is_empty());
2989 }
2990 }
2991
2992 #[test]
2993 #[cfg(all(unix, feature = "templates"))]
2994 fn unreadable_user_archive_template_fails_topic_generation() {
2995 use std::os::unix::fs::PermissionsExt;
2996 let (tmp, _site, meta, ctx) = make_layout();
2997 fs::write(
2998 meta.join("a.meta.json"),
2999 r#"{"title": "A", "topic_clusters": ["wasm"]}"#,
3000 )
3001 .unwrap();
3002 let tpl = tmp.path().join("archive.html");
3003 fs::write(&tpl, "x").unwrap();
3004 fs::set_permissions(&tpl, fs::Permissions::from_mode(0o000)).unwrap();
3005
3006 let res = TaxonomyPlugin.after_compile(&ctx);
3007
3008 let _ = fs::set_permissions(&tpl, fs::Permissions::from_mode(0o644));
3009 if let Err(e) = res {
3010 assert!(!format!("{e}").is_empty());
3011 }
3012 }
3013
3014 #[test]
3015 #[cfg(all(unix, feature = "templates"))]
3016 fn unreadable_user_index_template_fails_index_generation() {
3017 use std::os::unix::fs::PermissionsExt;
3018 let (tmp, _site, meta, ctx) = make_layout();
3019 fs::write(
3020 meta.join("a.meta.json"),
3021 r#"{"title": "A", "tags": ["rust"]}"#,
3022 )
3023 .unwrap();
3024 let tpl = tmp.path().join("taxonomy_index.html");
3025 fs::write(&tpl, "x").unwrap();
3026 fs::set_permissions(&tpl, fs::Permissions::from_mode(0o000)).unwrap();
3027
3028 let res = TaxonomyPlugin.after_compile(&ctx);
3029
3030 let _ = fs::set_permissions(&tpl, fs::Permissions::from_mode(0o644));
3031 if let Err(e) = res {
3032 assert!(!format!("{e}").is_empty());
3033 }
3034 }
3035
3036 #[test]
3037 #[cfg(feature = "templates")]
3038 fn user_term_template_extending_missing_base_fails_render() {
3039 let (tmp, _site, meta, ctx) = make_layout();
3043 fs::write(
3044 meta.join("a.meta.json"),
3045 r#"{"title": "A", "tags": ["rust"]}"#,
3046 )
3047 .unwrap();
3048 let tera = tmp.path().join("tera");
3053 fs::create_dir_all(&tera).unwrap();
3054 fs::write(tera.join("tag.html"), "{% extends \"missing.html\" %}")
3055 .unwrap();
3056
3057 let err = TaxonomyPlugin.after_compile(&ctx).unwrap_err();
3058 assert!(!format!("{err}").is_empty());
3059 }
3060
3061 #[test]
3062 #[cfg(feature = "templates")]
3063 fn user_index_template_extending_missing_base_fails_render() {
3064 let (tmp, _site, meta, ctx) = make_layout();
3065 fs::write(
3066 meta.join("a.meta.json"),
3067 r#"{"title": "A", "tags": ["rust"]}"#,
3068 )
3069 .unwrap();
3070 let tera = tmp.path().join("tera");
3075 fs::create_dir_all(&tera).unwrap();
3076 fs::write(
3077 tera.join("taxonomy_index.html"),
3078 "{% extends \"missing.html\" %}",
3079 )
3080 .unwrap();
3081
3082 let err = TaxonomyPlugin.after_compile(&ctx).unwrap_err();
3083 assert!(!format!("{err}").is_empty());
3084 }
3085
3086 #[test]
3091 #[cfg(unix)]
3092 fn unreadable_sidecar_file_fails_collection() {
3093 use std::os::unix::fs::PermissionsExt;
3094 let (_tmp, _site, meta, ctx) = make_layout();
3095 let sidecar = meta.join("locked.meta.json");
3096 fs::write(&sidecar, r#"{"title": "L"}"#).unwrap();
3097 fs::set_permissions(&sidecar, fs::Permissions::from_mode(0o000))
3098 .unwrap();
3099
3100 let res = TaxonomyPlugin.after_compile(&ctx);
3101
3102 let _ =
3103 fs::set_permissions(&sidecar, fs::Permissions::from_mode(0o644));
3104 if let Err(e) = res {
3105 assert!(!format!("{e}").is_empty());
3106 }
3107 }
3108
3109 #[test]
3110 #[cfg(unix)]
3111 fn unreadable_meta_subdir_fails_collection() {
3112 use std::os::unix::fs::PermissionsExt;
3113 let (_tmp, _site, meta, ctx) = make_layout();
3114 let sub = meta.join("locked");
3115 fs::create_dir_all(&sub).unwrap();
3116 fs::set_permissions(&sub, fs::Permissions::from_mode(0o000)).unwrap();
3117
3118 let res = TaxonomyPlugin.after_compile(&ctx);
3119
3120 let _ = fs::set_permissions(&sub, fs::Permissions::from_mode(0o755));
3121 if let Err(e) = res {
3122 assert!(!format!("{e}").is_empty());
3123 }
3124 }
3125
3126 #[test]
3131 fn term_dir_squatted_by_file_fails_generation() {
3132 let (_tmp, site, meta, ctx) = make_layout();
3133 fs::write(
3134 meta.join("a.meta.json"),
3135 r#"{"title": "A", "tags": ["rust"]}"#,
3136 )
3137 .unwrap();
3138 fs::create_dir_all(site.join("tags")).unwrap();
3139 fs::write(site.join("tags/rust"), "not a dir").unwrap();
3140
3141 let err = TaxonomyPlugin.after_compile(&ctx).unwrap_err();
3142 assert!(!format!("{err}").is_empty());
3143 }
3144
3145 #[test]
3146 fn term_index_squatted_by_dir_fails_write() {
3147 let (_tmp, site, meta, ctx) = make_layout();
3148 fs::write(
3149 meta.join("a.meta.json"),
3150 r#"{"title": "A", "tags": ["rust"]}"#,
3151 )
3152 .unwrap();
3153 fs::create_dir_all(site.join("tags/rust/index.html")).unwrap();
3154
3155 let err = TaxonomyPlugin.after_compile(&ctx).unwrap_err();
3156 assert!(!format!("{err}").is_empty());
3157 }
3158
3159 #[test]
3160 fn taxonomy_index_squatted_by_dir_fails_write() {
3161 let (_tmp, site, meta, ctx) = make_layout();
3162 fs::write(
3163 meta.join("a.meta.json"),
3164 r#"{"title": "A", "tags": ["rust"]}"#,
3165 )
3166 .unwrap();
3167 fs::create_dir_all(site.join("tags/index.html")).unwrap();
3168
3169 let err = TaxonomyPlugin.after_compile(&ctx).unwrap_err();
3170 assert!(!format!("{err}").is_empty());
3171 }
3172
3173 #[test]
3174 fn write_taxonomy_page_logs_when_keeping_author_page() {
3175 init_logger();
3178 let dir = tempdir().unwrap();
3179 let page = dir.path().join("index.html");
3180 fs::write(&page, "<html>hand-written</html>").unwrap();
3181
3182 write_taxonomy_page(&page, "<html>ssg-taxonomy</html>").unwrap();
3183 let kept = fs::read_to_string(&page).unwrap();
3184 assert!(kept.contains("hand-written"));
3185 }
3186
3187 #[test]
3192 fn extract_terms_string_ignored_when_strings_disallowed() {
3193 let mut map: TaxonomyMap = HashMap::new();
3194 let value = serde_json::json!("rust, web");
3195 let page = PageRef::new("T", "/t.html");
3196 extract_terms_from_value(&value, &mut map, &page, false);
3197 assert!(map.is_empty());
3198 }
3199
3200 #[test]
3201 fn extract_terms_array_skips_whitespace_only_parts() {
3202 let mut map: TaxonomyMap = HashMap::new();
3203 let value = serde_json::json!(["ok", " , "]);
3204 let page = PageRef::new("T", "/t.html");
3205 extract_terms_from_value(&value, &mut map, &page, true);
3206 assert_eq!(map.len(), 1);
3207 assert!(map.contains_key("ok"));
3208 }
3209
3210 #[test]
3211 fn extract_terms_string_skips_empty_parts() {
3212 let mut map: TaxonomyMap = HashMap::new();
3213 let value = serde_json::json!("a,,b");
3214 let page = PageRef::new("T", "/t.html");
3215 extract_terms_from_value(&value, &mut map, &page, true);
3216 assert_eq!(map.len(), 2);
3217 }
3218
3219 #[cfg(not(feature = "templates"))]
3232 mod no_templates_renderer {
3233 use super::*;
3234
3235 fn cfg_ctx(base: &PluginContext) -> PluginContext {
3236 let cfg = crate::cmd::SsgConfig::builder()
3237 .site_name("Example".to_string())
3238 .base_url("https://example.com".to_string())
3239 .build()
3240 .expect("config");
3241 PluginContext::with_config(
3242 &base.content_dir,
3243 &base.build_dir,
3244 &base.site_dir,
3245 &base.template_dir,
3246 cfg,
3247 )
3248 }
3249
3250 #[test]
3251 fn default_locale_keeps_the_site_language_and_bare_canonical() {
3252 let (_tmp, _site, _meta, base) = make_layout();
3253 let ctx = cfg_ctx(&base);
3254 let renderer = TaxonomyRenderer::new(&ctx);
3255
3256 assert_eq!(renderer.locale_path_segment(), "");
3257 assert_eq!(
3258 renderer.canonical("/tags/rust/"),
3259 r#"<link rel="canonical" href="https://example.com/tags/rust/">"#
3260 );
3261 }
3262
3263 #[test]
3264 fn scoped_locale_overrides_language_and_prefixes_canonical() {
3265 let (_tmp, _site, _meta, base) = make_layout();
3266 let ctx = cfg_ctx(&base);
3267 let renderer = TaxonomyRenderer::new(&ctx).for_locale(
3268 Some("fr"),
3269 "/fr",
3270 "/fr",
3271 );
3272
3273 assert_eq!(renderer.lang(), "fr");
3274 assert_eq!(renderer.locale_path_segment(), "/fr");
3275 assert_eq!(
3276 renderer.canonical("/tags/rust/"),
3277 r#"<link rel="canonical" href="https://example.com/fr/tags/rust/">"#,
3278 "canonical must point at the file actually written"
3279 );
3280 }
3281
3282 #[test]
3283 fn locale_without_a_language_keeps_the_site_tag() {
3284 let (_tmp, _site, _meta, base) = make_layout();
3287 let cfg = crate::cmd::SsgConfig::builder()
3288 .site_name("Example".to_string())
3289 .base_url("https://example.com".to_string())
3290 .language("en-GB".to_string())
3291 .build()
3292 .expect("config");
3293 let ctx = PluginContext::with_config(
3294 &base.content_dir,
3295 &base.build_dir,
3296 &base.site_dir,
3297 &base.template_dir,
3298 cfg,
3299 );
3300 let renderer = TaxonomyRenderer::new(&ctx).for_locale(None, "", "");
3301
3302 assert_eq!(renderer.lang(), "en-GB");
3303 }
3304
3305 #[test]
3306 fn lang_falls_back_to_en_without_a_config() {
3307 let (_tmp, _site, _meta, base) = make_layout();
3308 let renderer = TaxonomyRenderer::new(&base);
3309 assert_eq!(renderer.lang(), "en");
3310 assert_eq!(renderer.canonical("/tags/rust/"), "");
3311 }
3312
3313 #[test]
3318 fn term_page_links_every_page_by_title_and_url() {
3319 let (_tmp, _site, _meta, base) = make_layout();
3320 let ctx = cfg_ctx(&base);
3321 let renderer = TaxonomyRenderer::new(&ctx);
3322 let pages = vec![
3323 PageRef::new("First Post", "/posts/first/"),
3324 PageRef::new("Second Post", "/posts/second/"),
3325 ];
3326
3327 let html = renderer
3328 .render_term_page(
3329 TaxonomyKind::Tag,
3330 "tags",
3331 "Tags",
3332 "rust",
3333 "rust",
3334 &pages,
3335 None,
3336 )
3337 .expect("term page renders");
3338
3339 for page in &pages {
3340 assert!(
3341 html.contains(&format!(
3342 "<a href=\"{}\">{}</a>",
3343 page.url, page.title
3344 )),
3345 "{} missing from:\n{html}",
3346 page.title
3347 );
3348 }
3349 }
3350
3351 #[test]
3355 fn index_page_prefers_a_curated_title_over_the_raw_term() {
3356 let (_tmp, _site, _meta, base) = make_layout();
3357 let ctx = cfg_ctx(&base);
3358 let renderer = TaxonomyRenderer::new(&ctx);
3359 let pages = vec![PageRef::new("A Post", "/posts/a/")];
3360 let term = "post-quantum-cryptography".to_string();
3361 let sorted = vec![(&term, &pages)];
3362
3363 let mut clusters = TopicClusters::new();
3364 let _ = clusters.insert(
3365 "post-quantum-cryptography".to_string(),
3366 TopicCluster {
3367 title: Some("Post-Quantum Cryptography".to_string()),
3368 ..TopicCluster::default()
3369 },
3370 );
3371
3372 let curated = renderer
3373 .render_index_page("topics", "Topics", &sorted, Some(&clusters))
3374 .expect("index renders");
3375 assert!(
3376 curated.contains(">Post-Quantum Cryptography</a>"),
3377 "curated title missing from:\n{curated}"
3378 );
3379
3380 assert!(
3383 curated.contains("/topics/post-quantum-cryptography/"),
3384 "curation moved the URL:\n{curated}"
3385 );
3386
3387 let bare = renderer
3389 .render_index_page("topics", "Topics", &sorted, None)
3390 .expect("index renders");
3391 assert!(
3392 bare.contains(">post-quantum-cryptography</a>"),
3393 "raw term missing from:\n{bare}"
3394 );
3395 }
3396 }
3397}
3398
3399#[cfg(test)]
3400mod proptests {
3401 use super::*;
3402 use proptest::prelude::*;
3403
3404 proptest! {
3405 #![proptest_config(ProptestConfig::with_cases(1000))]
3406
3407 #[test]
3414 fn slugify_valid_chars(input in "\\PC*") {
3415 let slug = slugify(&input);
3416 for ch in slug.chars() {
3417 prop_assert!(
3418 ch.is_alphanumeric() || ch == '-',
3419 "unexpected char {:?} in slug {:?}", ch, slug,
3420 );
3421 }
3422 prop_assert!(
3423 !slug.starts_with('-'),
3424 "slug must not start with hyphen: {:?}", slug,
3425 );
3426 prop_assert!(
3427 !slug.ends_with('-'),
3428 "slug must not end with hyphen: {:?}", slug,
3429 );
3430 prop_assert!(
3431 !slug.contains("--"),
3432 "slug must not contain consecutive hyphens: {:?}", slug,
3433 );
3434 }
3435 }
3436}