Skip to main content

ssg/plugins/
taxonomy.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Taxonomy generation plugin.
5//!
6//! Reads `tags` and `categories` from frontmatter sidecars and
7//! generates index pages for each taxonomy term, rendered through
8//! the same template engine (`MiniJinja`) that drives normal page
9//! rendering. Built-in fallback templates extend `base.html` so the
10//! pages share the site's layout, CSS, nav, and footer (#542).
11//!
12//! ## Per-term landing pages (issue #586, port 5 of 5)
13//!
14//! Besides the `/tags/index.html` hub, every term gets its own
15//! landing page (`/tags/<slug>/index.html`, and likewise for
16//! categories and topics) listing its member posts. Terms may be
17//! declared either as frontmatter arrays (`tags: [a, b]`) or as the
18//! comma-separated string form the bundled examples use
19//! (`tags: "a, b, c"`). Slugs come from [`ssg_core::slugify`];
20//! term ordering is case-insensitive alphabetical, so output is
21//! deterministic across rebuilds.
22//!
23//! ## Lifecycle caveat — the `transform_html` bypass
24//!
25//! This plugin writes pages in `after_compile`, but the pipeline
26//! snapshots the HTML file list *before* `after_compile` runs
27//! (`pipeline.rs`: `cache_html_files()` precedes
28//! `run_after_compile()`), so the fused `transform_html` pass never
29//! sees taxonomy pages — canonical/JSON-LD/a11y transform plugins
30//! skip them (the ROADMAP-documented plugin-lifecycle-phase trap;
31//! see #586). Mitigation: the built-in templates (and the
32//! non-`templates` fallback renderer) inline the essential head
33//! elements themselves — `<!DOCTYPE html>`, `<html lang>`,
34//! `<meta charset>`, `<title>`, a `<link rel="canonical">` derived
35//! from `site.base_url` + the term's directory URL, plus the SEO
36//! meta the audit gates probe (`description`, `og:title`,
37//! `og:type`, `og:description`, `og:url`, `twitter:card`). Pages
38//! generated here therefore do not depend on the transform chain
39//! for correctness; richer per-page structured data (JSON-LD,
40//! og:image) stays with the full taxonomy engine planned for
41//! 0.0.48 (#587).
42//!
43//! Two further real-pipeline behaviours: sidecars are read from
44//! `<build>/.meta/` with a fallback to the staged `<site>/.meta/`
45//! copy, and author-authored pages (e.g. a hand-written
46//! `/tags/index.html` compiled from `tags.md` — anything without
47//! the `ssg-taxonomy` generator marker) are never overwritten.
48
49use 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/// One page as a taxonomy lists it.
59///
60/// This was a `(title, url)` pair, which is all a bulleted list needs. A
61/// card needs more — a date to sort and show, a description to read, an
62/// image to look at — and none of it can be recovered at render time,
63/// because the sidecar that held it has already been walked past. The
64/// fields beyond `title` and `url` are all optional: a page that
65/// declares none still lists, exactly as it did before.
66#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, Default)]
67pub struct PageRef {
68    /// Display title.
69    pub title: String,
70    /// Resolved, prefix-aware URL.
71    pub url: String,
72    /// Front-matter `description`, for the card body and JSON-LD.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub description: Option<String>,
75    /// Front-matter `date`, as authored.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub date: Option<String>,
78    /// Front-matter `banner`, the card's image.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub banner: Option<String>,
81}
82
83impl PageRef {
84    /// A page with nothing but a title and a URL — what every taxonomy
85    /// carried before cards existed.
86    ///
87    /// The collector fills every field from the sidecar; this is for
88    /// callers building a [`TaxonomyTerm`] by hand, where the card fields
89    /// are not available and are not wanted.
90    #[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
100/// A mapping from taxonomy term to the pages carrying it.
101type TaxonomyMap = HashMap<String, Vec<PageRef>>;
102
103/// One taxonomy term after slug merging: display spelling, URL slug and the
104/// member pages listed on it.
105type MergedTerm = (String, String, Vec<PageRef>);
106
107/// A taxonomy term with its associated pages.
108#[derive(Debug, Clone)]
109pub struct TaxonomyTerm {
110    /// The term name (e.g. "rust", "web").
111    pub name: String,
112    /// The URL slug (e.g. "rust", "web").
113    pub slug: String,
114    /// Pages carrying this term.
115    pub pages: Vec<PageRef>,
116}
117
118// =====================================================================
119// Built-in templates (embedded so the binary works without scaffold)
120//
121// The constants below are only loaded by the MiniJinja loader inside
122// `cfg(feature = "templates")` (line ~174). The non-templates fallback
123// impl renders without referencing them, so we gate each constant
124// behind the feature so `cargo check --no-default-features` does not
125// trip on `dead_code = "deny"` (see workspace [lints.rust]).
126// =====================================================================
127
128/// Built-in tag term-page template (#542).
129#[cfg(feature = "templates")]
130const BUILTIN_TAG_HTML: &str = include_str!("builtin_templates/tag.html");
131/// Built-in category term-page template (#542).
132#[cfg(feature = "templates")]
133const BUILTIN_CATEGORY_HTML: &str =
134    include_str!("builtin_templates/category.html");
135/// Built-in archive/topic term-page template (#542).
136#[cfg(feature = "templates")]
137const BUILTIN_ARCHIVE_HTML: &str =
138    include_str!("builtin_templates/archive.html");
139/// Built-in taxonomy index-page template (lists all terms) (#542).
140#[cfg(feature = "templates")]
141const BUILTIN_TAXONOMY_INDEX_HTML: &str =
142    include_str!("builtin_templates/taxonomy_index.html");
143/// Built-in minimal `base.html` for sites that ship none of their own.
144/// User-provided `base.html` is preferred via the path loader (#542).
145#[cfg(feature = "templates")]
146const BUILTIN_BASE_HTML: &str = include_str!("builtin_templates/base.html");
147
148/// Plugin that generates taxonomy index pages for tags and categories.
149///
150/// Runs in `after_compile`. Reads `.meta.json` sidecars to find
151/// `tags` and `categories` arrays, then generates:
152/// - `/tags/index.html` — list of all tags with page counts
153/// - `/tags/{slug}/index.html` — list of pages for each tag
154/// - `/categories/index.html` and `/categories/{slug}/index.html`
155/// - `/topics/index.html` and `/topics/{slug}/index.html`
156///
157/// All pages render through the site's `MiniJinja` template engine so
158/// they share the site's `base.html`, CSS, nav, footer, and lang
159/// attribute (issue #542).
160#[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        // Opt-out for sites that own their own taxonomy (`--no-tag-pages`
170        // / `SSG_NO_TAG_PAGES`). Checked before any work so the build does
171        // not pay for output it is about to discard. Absent ⇒ generate, so
172        // this cannot change an existing build.
173        if ctx.config.as_ref().is_some_and(|c| c.no_taxonomy_pages) {
174            return Ok(());
175        }
176
177        // Sidecar roots in priority order: `<build>/.meta/` (the
178        // emit_sidecars convention) and `<site>/.meta/` — the staged
179        // copy the real pipeline leaves in the output directory
180        // (staticdatagen layout, what the audited demo site has once
181        // the build staging dir is cleaned). (#586 port 5)
182        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        // Lazily build the template engine once per run; reused across
201        // tags, categories, and topics.
202        let renderer = TaxonomyRenderer::new(ctx);
203
204        // Curated pillar-page metadata for the `topics` taxonomy (#587).
205        // Empty unless the site ships `_data/topics.toml`, and a site that
206        // does not builds exactly as before.
207        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        // A multi-locale site gets one taxonomy tree per locale. Without
217        // this every locale's pages share a single index, so an
218        // English-language tag page lists French pages beside English ones
219        // and a French reader has no tag index at all.
220        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                    // The default locale keeps the root path so existing
253                    // links and sitemaps do not move.
254                    let dir = if *locale == default_locale {
255                        ctx.site_dir.join(name)
256                    } else {
257                        ctx.site_dir.join(locale).join(name)
258                    };
259                    // The default locale keeps the bare prefix; others
260                    // get their own, so Home and term links stay inside
261                    // the locale a reader is already in.
262                    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/// Which built-in template family to use for a taxonomy.
315#[derive(Debug, Clone, Copy)]
316enum TaxonomyKind {
317    Tag,
318    Category,
319    Archive,
320}
321
322// These helpers only feed the MiniJinja-driven renderer; the
323// non-templates fallback emits literal HTML and never asks for the
324// template filename or term variable. Gated to suppress dead_code
325// under `--no-default-features`.
326#[cfg(feature = "templates")]
327impl TaxonomyKind {
328    /// User-overridable template filename (looked up in the user's
329    /// `templates/tera/` directory first).
330    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    /// Variable name the template uses to address the current term
339    /// (`tag`, `category`, `term`).
340    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
349// =====================================================================
350// Template engine integration
351// =====================================================================
352
353/// Encapsulates `MiniJinja` rendering for taxonomy pages.
354///
355/// Holds a `MiniJinja` environment whose loader prefers the user's
356/// `templates/tera/` files and falls back to embedded built-in sources
357/// so pages always extend a real `base.html` (issue #542).
358struct TaxonomyRenderer<'a> {
359    #[cfg(feature = "templates")]
360    env: minijinja::Environment<'static>,
361    ctx: &'a PluginContext,
362    /// Locale of the tree currently being rendered, as
363    /// `(language, url_prefix, path_segment)`. `None` on a single-locale
364    /// site.
365    ///
366    /// `language` is `None` for the default locale, which keeps the
367    /// site's configured tag — overriding it with the bare locale code
368    /// turned `en-GB` into `en`. `path_segment` is `""` for the default
369    /// locale and `/fr` for others, and prefixes `page_url` so the
370    /// canonical points at the page that was actually written.
371    ///
372    /// Without any of this every tree inherited the site's default
373    /// language, so the French tag index declared `lang="en-GB"` and
374    /// linked Home to the English home page.
375    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                // 1) Try the user's templates/tera/<name> if present.
387                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                // 2) Embedded fallbacks for the templates this plugin owns.
404                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    /// Returns a renderer scoped to one locale, so its pages declare that
425    /// language and link within that locale's URL prefix.
426    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    /// Renders a term page (e.g. `/tags/rust/index.html`).
444    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        // Always expose `term` as well so generic templates can use it.
468        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        // Structured data, for topics only. Tags and categories are
477        // keyword indexes; a topic page is a curated collection, which is
478        // what `CollectionPage` and `ItemList` exist to describe.
479        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        // Curated pillar-page copy (#587). Both are absent unless
501        // `_data/topics.toml` describes this term, and a template that
502        // does not mention them renders exactly as it did before.
503        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        // Essential head metadata — these pages bypass the transform
535        // chain, so the SEO plugins never decorate them (#586 port 5).
536        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    /// Renders the taxonomy index page (lists all terms).
562    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        // Essential head metadata (see render_term_page).
594        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                // Curated copy for the hub card, when the topic has any
625                // (#587). A hub of bare slugs tells a reader nothing about
626                // which topic is worth opening.
627                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    /// The locale's URL segment (`""` for the default locale, `/fr`
663    /// otherwise), used to prefix `page_url`.
664    fn locale_path_segment(&self) -> String {
665        self.locale
666            .as_ref()
667            .map_or_else(String::new, |(_, _, seg)| seg.clone())
668    }
669
670    /// Builds the `{ site: { name, title, language, ... } }` context
671    /// shared by every taxonomy page.
672    fn base_context(&self) -> serde_json::Map<String, serde_json::Value> {
673        // Same sub-path prefix the term-page URLs use, so the index links
674        // to pages that actually exist on a project-site deployment.
675        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            // Site-wide og:image fallback for pages with no image of
702            // their own (#587 precursor — see SsgConfig::og_image doc).
703            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            // Sensible defaults when the plugin runs without a config
711            // (tests, ad-hoc invocations).
712            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        // `url_prefix` is locale-scoped, for links that should keep a
734        // reader inside their locale. Assets are not per-locale, so a
735        // template linking a stylesheet needs the unscoped prefix too —
736        // otherwise a French page asks for /atlas/fr/styles.css, which
737        // does not exist.
738        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/// Fallback shim so the module still compiles when the `templates`
747/// feature is disabled. The `MiniJinja` crate is gated on that feature
748/// in `Cargo.toml`; the shim falls back to a minimal escaped HTML
749/// renderer that still respects `site.language` and per-page metadata.
750#[cfg(not(feature = "templates"))]
751impl<'a> TaxonomyRenderer<'a> {
752    const fn new(ctx: &'a PluginContext) -> Self {
753        Self { ctx, locale: None }
754    }
755
756    /// Locale-scoped renderer; see the `templates` implementation.
757    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    /// ` — <site title>` when one is configured, else empty.
774    ///
775    /// Mirrors `{% if site.title %} — {{ site.title }}{% endif %}` in the
776    /// bundled templates. Without this the no-templates build silently
777    /// dropped a configured title, so the same config produced differently
778    /// branded pages depending on which features the binary was built with.
779    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            // Mirror the `templates` path: a curated title replaces the
855            // term where it is displayed, without moving the URL (#587).
856            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    /// The locale's URL segment (`""` for the default locale, `/fr`
870    /// otherwise), used to prefix `page_url`. Mirrors the `templates`
871    /// implementation so both paths address the same page.
872    fn locale_path_segment(&self) -> String {
873        self.locale
874            .as_ref()
875            .map_or_else(String::new, |(_, _, seg)| seg.clone())
876    }
877
878    /// The tree's language.
879    ///
880    /// A locale-scoped tree carries its own language and it wins over
881    /// the site default — that override is the whole point of
882    /// splitting the trees. `None` means the default locale, which
883    /// deliberately keeps the site's configured tag: replacing it with
884    /// the bare locale code turned `en-GB` into `en`.
885    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    /// Inline canonical link — taxonomy pages bypass the transform
896    /// chain, so the `CanonicalPlugin` never sees them (#586 port 5).
897    ///
898    /// `page_url` is locale-prefixed here rather than by the caller, so
899    /// the canonical points at the file that was actually written
900    /// (`/fr/tags/rust/`) instead of the default locale's copy.
901    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    /// Inline `og:image` fallback — see `SsgConfig::og_image` doc.
917    /// Absent config or unset field ⇒ empty string, so the meta tag
918    /// is omitted entirely rather than emitted with a blank `content`.
919    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/// Resolves the user's template directory, preferring
932/// `<template_dir>/tera/` (the canonical layout) but falling back to
933/// `<template_dir>/` if `tera/` is absent.
934#[cfg(feature = "templates")]
935fn resolve_user_template_dir(ctx: &PluginContext) -> Option<PathBuf> {
936    // Only `<template_dir>/tera`, never `<template_dir>` itself.
937    //
938    // Taxonomy pages render through MiniJinja, but page layouts render
939    // through StaticWeaver — two engines, and historically one directory.
940    // Falling back to the layouts directory therefore fed a StaticWeaver
941    // `base.html` to MiniJinja, which failed to parse it and aborted the
942    // *whole build* with `syntax error: unexpected character (in
943    // base.html:26)` while naming `tag.html`, a file the author never
944    // wrote. Taxonomy was unusable for any theme using the default engine.
945    //
946    // `tera/` is the documented home for MiniJinja templates. A theme that
947    // wants to restyle its taxonomy pages puts them there; a theme that
948    // does not gets the built-in fallbacks and a site that builds.
949    let tera = ctx.template_dir.join("tera");
950    tera.is_dir().then_some(tera)
951}
952
953/// Converts a list of (title, url) pairs into JSON page objects with
954/// `title` and `url` keys, suitable for template iteration.
955#[cfg(feature = "templates")]
956/// Structured data for a topic page: what it is, what it lists, and where
957/// it sits.
958///
959/// Three types, because they answer three different questions and search
960/// engines read them separately. `CollectionPage` says this page is a
961/// collection rather than an article; `ItemList` says what is in it and in
962/// what order, which is the whole point of a curated topic; and
963/// `BreadcrumbList` says where it sits, so a result can be shown as
964/// Home → Topics → Payments instead of a bare URL.
965///
966/// URLs are absolute where a `base_url` is configured, because consumers of
967/// structured data cannot resolve a site-relative path. Without one the
968/// relative form is emitted rather than a fabricated origin.
969fn 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    // The topics hub is the parent of every topic page; `page_url` ends in
1019    // the term slug, so trimming one segment reaches it.
1020    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// Only the `templates` renderer builds a JSON context; the fallback
1038// shim writes HTML directly.
1039#[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
1047/// Extracts string terms from a JSON value (array of strings or comma-separated string) into the given map.
1048fn 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
1071/// Collects taxonomy entries (tags, categories, topics) from sidecar JSON files.
1072///
1073/// `site_dir` is consulted to prefer pretty (directory-shaped) member
1074/// URLs — when `<site>/<stem>/index.html` exists the member link is
1075/// `/<stem>/`, otherwise the flat `/<stem>.html` form is used, so
1076/// term pages never link to paths that 404 (#586 port 5).
1077/// Splits a taxonomy map into one map per locale.
1078///
1079/// A page's locale is the first path segment of its staged stem when that
1080/// segment is a configured locale (`fr/a-propos` -> `fr`); everything else
1081/// belongs to the default locale. The stem is not available here, so the
1082/// URL is used instead — it carries the same segment after `url_prefix`.
1083///
1084/// ## Why this exists
1085///
1086/// Taxonomy was locale-blind. Every locale's pages landed in one index at
1087/// `tags/`, so an English-language tag page listed French pages beside
1088/// English ones, and a French reader had no tag index at all. Grouping by
1089/// locale means each index lists only pages a reader of that locale can
1090/// actually read.
1091fn 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        // Prefixed from `base_url`, like the extracted `_csp/` assets and
1150        // the islands loader: a site published under a sub-path resolves a
1151        // bare `/articles/` against the domain root, so every link on every
1152        // taxonomy page would 404.
1153        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        // Everything a card needs, read once. A field the page does not
1160        // declare stays `None` and the template omits it; nothing here is
1161        // required, and a page with only a title still lists.
1162        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        // Both the array (`tags: [a, b]`) and comma-separated string
1178        // (`tags: "a, b"`) frontmatter shapes are accepted — the
1179        // bundled examples use the string form (#586 port 5).
1180        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
1194/// Marker every taxonomy-generated page carries (the
1195/// `<meta name="generator" content="ssg-taxonomy">` tag). Pages
1196/// *without* it are author-authored content (e.g. a hand-written
1197/// `/tags/index.html` compiled from `tags.md`) and are never
1198/// overwritten (#586 port 5).
1199const TAXONOMY_MARKER: &str = "ssg-taxonomy";
1200
1201/// Writes a taxonomy page unless an author-authored page already
1202/// occupies the path. Our own previous output (identified by
1203/// [`TAXONOMY_MARKER`]) is refreshed as usual, keeping rebuilds
1204/// idempotent.
1205fn 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
1218/// Groups terms that share a URL slug into a single term.
1219///
1220/// Distinct spellings routinely slugify to one path — `SWIFT` and `Swift`
1221/// both give `swift`, as do `CBPR` and `CBPR+`. Rendering them separately
1222/// wrote two different pages to the same `index.html`, so the second write
1223/// silently discarded the first, and *which* one survived depended on
1224/// `HashMap` iteration order. Rust randomises that per process, which is why
1225/// building the same content twice produced different sites and failed the
1226/// byte-identical-rebuild gate.
1227///
1228/// Merging fixes both halves: the build is reproducible, and no member is
1229/// dropped just because an author capitalised a tag differently. One URL now
1230/// lists every page that carries any spelling of the term.
1231///
1232/// Ordering is a total order — lowercase first, then the term itself as the
1233/// tiebreak — so the result never depends on iteration order. Groups appear
1234/// in that order (preserving the previous by-lowercase listing), and the
1235/// first term in a group supplies the display spelling.
1236///
1237/// Returns `(display term, slug, member pages)` per group.
1238fn 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    // Membership only — dedupe across merged spellings without changing the
1249    // order members are listed in.
1250    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    // Member pages arrive in whatever order the content walk produced,
1268    // which is the filesystem's, not ours. Terms were already given a
1269    // total order above for exactly this reason; members were not, and
1270    // the omission was invisible on one machine: two builds on the same
1271    // filesystem agree, so `determinism.yml` -- which compares two runs
1272    // on one runner -- could never see it.
1273    //
1274    // It surfaced when the golden suite compared a macOS-seeded run
1275    // against Linux: APFS and ext4 enumerate a directory differently, so
1276    // the tag index listed the same posts in a different order. Sorting
1277    // by (url, title) is a total order over a set already deduplicated by
1278    // that pair, so the result is stable everywhere.
1279    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
1288/// Generates index and term pages for a taxonomy via the template engine.
1289fn 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
1309/// As [`generate_taxonomy_pages`], but writes into an explicit directory
1310/// so a multi-locale site can place each locale's tree under its own
1311/// prefix (`fr/tags/`) instead of sharing one at the site root.
1312/// Curated metadata applies to the `topics` taxonomy and nothing else.
1313///
1314/// Tags and categories are derived vocabulary — there is no editorial
1315/// copy to attach to them, and a `[tags]` section in the data file would
1316/// be a mistake rather than a feature.
1317fn 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    // Per-term pages.
1339    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        // Curated metadata for this term, when `_data/topics.toml`
1344        // describes it (#587). Absent for every other taxonomy, and for
1345        // any topic nobody has written a pillar page for.
1346        let cluster = clusters.and_then(|c| c.get(slug.as_str()));
1347
1348        // A curated `order` leads; everything else keeps the order the
1349        // taxonomy produced. Only cloned when there is an order to apply.
1350        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        // A curated title replaces the term as displayed, not as slugged,
1366        // so URLs do not move when someone edits the copy.
1367        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    // Taxonomy index page.
1385    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
1401/// Term → URL slug. Delegates to [`ssg_core::slugify`] (#586 port 5)
1402/// so taxonomy URLs share the canonical slug rules with the rest of
1403/// the toolchain.
1404fn 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    // -------------------------------------------------------------------
1429    // Test fixtures
1430    // -------------------------------------------------------------------
1431
1432    /// Builds a fresh temp dir layout: `<root>/site`, `<root>/build/.meta`
1433    /// and a `PluginContext`.
1434    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    // -------------------------------------------------------------------
1447    // slugify — table-driven coverage of the character classes
1448    // -------------------------------------------------------------------
1449
1450    #[test]
1451    fn slugify_table_driven_inputs_produce_expected_slugs() {
1452        let cases: &[(&str, &str)] = &[
1453            // basic — alphanumeric + space
1454            ("Rust Programming", "rust-programming"),
1455            // punctuation collapsing
1456            ("C++", "c"),
1457            ("hello world!", "hello-world"),
1458            // multiple consecutive non-alphanumerics collapse to one dash
1459            ("a !! b", "a-b"),
1460            ("a___b", "a-b"),
1461            // leading and trailing punctuation are stripped
1462            ("---rust---", "rust"),
1463            ("!!!hello!!!", "hello"),
1464            // unicode letters survive (alphanumeric)
1465            ("café", "café"),
1466            // pure punctuation collapses to empty
1467            ("!!!", ""),
1468            // already-slug stays the same
1469            ("rust-web", "rust-web"),
1470            // mixed digits and letters
1471            ("Rust 2024", "rust-2024"),
1472            // empty input
1473            ("", ""),
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    // -------------------------------------------------------------------
1491    // capitalize — table-driven (covers None/Some(_) match arms)
1492    // -------------------------------------------------------------------
1493
1494    #[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    // -------------------------------------------------------------------
1514    // TaxonomyPlugin — derive surface
1515    // -------------------------------------------------------------------
1516
1517    #[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    // -------------------------------------------------------------------
1530    // after_compile — early-return paths
1531    // -------------------------------------------------------------------
1532
1533    #[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    // -------------------------------------------------------------------
1571    // after_compile — opt-out (`--no-tag-pages` / SSG_NO_TAG_PAGES)
1572    // -------------------------------------------------------------------
1573
1574    /// Builds a context whose config carries `no_taxonomy_pages`.
1575    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        // The opt-out must be exactly that: absent config, or config with the
1614        // flag unset, keeps the pre-existing behaviour. A site that upgrades
1615        // without asking for anything sees no change.
1616        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    // -------------------------------------------------------------------
1634    // after_compile — sidecar parsing fallbacks
1635    // -------------------------------------------------------------------
1636
1637    #[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    // -------------------------------------------------------------------
1664    // Placeholder defaults must never reach a rendered page
1665    // -------------------------------------------------------------------
1666
1667    /// A config that leaves `site_title` unset must not brand the tag pages.
1668    ///
1669    /// This is the bug: `builtin_templates/tag.html` renders
1670    /// `Tag: {{ tag }}{% if site.title %} — {{ site.title }}{% endif %}`, and
1671    /// `DEFAULT_SITE_TITLE` used to be `"My SSG Site"`. Any site built without
1672    /// an explicit `site_title` therefore titled every generated tag page
1673    /// `Tag: <term> — My SSG Site`. On sebastienrousseau.com that shipped
1674    /// 7,189 such pages, live and indexable.
1675    ///
1676    /// Nothing caught it because the defaults were only ever asserted equal to
1677    /// their own constant — `assert_eq!(props["site_title"]["default"], "My
1678    /// SSG Site")` — which pins the placeholder as correct rather than asking
1679    /// whether it escapes into output.
1680    #[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        // Exactly the bug condition: a valid config that never sets
1690        // site_title, so whatever DEFAULT_SITE_TITLE holds is rendered.
1691        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    /// The positive control: an explicitly configured title *is* rendered.
1719    ///
1720    /// Without this, emptying the default would "pass" even if the suffix had
1721    /// been deleted from the template outright, which would silently drop a
1722    /// feature configured sites rely on.
1723    #[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    /// The index page carries the same branding as the term pages.
1756    ///
1757    /// Both renderers build the `<title>` independently, so covering only the
1758    /// term page let the index drift. This runs in both feature configs, which
1759    /// is the point: the `templates` build reads the suffix from the bundled
1760    /// template and the fallback builds it by hand, and the two must agree.
1761    #[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    /// The negative control for the suffix: no configured title means no
1793    /// dangling separator left behind in the `<title>`.
1794    #[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    /// Terms separated by a non-ASCII comma must split into separate tags.
1821    ///
1822    /// Splitting on ASCII `,` alone collapsed an Arabic or Japanese tag list
1823    /// into one enormous term, which slugified into a single path component
1824    /// long enough to hit ENAMETOOLONG on ext4 while succeeding on APFS —
1825    /// a Linux-only build failure no contributor could reproduce locally.
1826    #[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        // #586 port 5: the string form `categories: "a, b"` is a
1874        // first-class frontmatter shape (the bundled examples use it).
1875        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        // #586 port 5: `tags: "rust, web"` generates a landing page
1903        // per term, exactly like the array form.
1904        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        // Numbers / objects still don't produce terms.
1923        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        // `SWIFT` and `Swift` slugify to the same `swift`, so both used to
1938        // render to tags/swift/index.html and the second write silently
1939        // discarded the first. Whichever won depended on HashMap iteration
1940        // order, which is randomised per process — so the same input built
1941        // twice produced different sites. The real corpus has 595 such
1942        // collisions across 34 locales (SWIFT/Swift, CBPR/CBPR+,
1943        // Governance/governance).
1944        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        // The regression the byte-identical-rebuild CI gate caught. Ties in
1994        // the term ordering were broken by HashMap iteration order, so this
1995        // compares two independent runs over a corpus dense with ties.
1996        //
1997        // Both runs build their own HashMaps, and Rust seeds each instance
1998        // differently, so an ordering that depends on iteration order will
1999        // diverge here with overwhelming probability rather than by luck.
2000        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        // #586 port 5: a hand-written /tags/index.html (compiled from
2050        // the site's own tags.md, no ssg-taxonomy marker) must never
2051        // be clobbered by the generated hub.
2052        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        // Term pages are still generated alongside it.
2069        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        // Add a second tagged page; the hub must pick it up.
2088        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        // Real-pipeline layout: sidecars staged at <site>/.meta/ and
2101        // pretty (directory-shaped) pages on disk.
2102        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        // Member link uses the pretty URL because hello/index.html exists.
2120        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        // #586 port 5: pages generated in after_compile bypass the
2147        // fused transform chain (canonical/JSON-LD/a11y plugins never
2148        // see them), so the essential head elements must be inlined
2149        // by the taxonomy templates themselves.
2150        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    /// #587: curated pillar-page metadata reaches the rendered page.
2224    ///
2225    /// ssg already derives `/topics/{slug}/` from front matter. What it
2226    /// cannot derive is the title a human would choose, the paragraph
2227    /// saying what the topic is, or which page should lead — so this
2228    /// asserts all three arrive, and that the pages the curation does not
2229    /// name keep the order they had.
2230    // Cards, ledes and JSON-LD come from the MiniJinja renderer; the
2231    // `not(templates)` shim emits a plain list, so these assert a
2232    // configuration that only exists with the feature on.
2233    #[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        // Charlie is curated to lead; Alpha and Bravo keep their order.
2275        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    /// #587: a page that carries card data is rendered as a card.
2282    ///
2283    /// The taxonomy used to carry `(title, url)` and nothing else, so a
2284    /// card was impossible however the template was written — the data had
2285    /// already been walked past by render time.
2286    // Cards, ledes and JSON-LD come from the MiniJinja renderer; the
2287    // `not(templates)` shim emits a plain list, so these assert a
2288    // configuration that only exists with the feature on.
2289    #[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        // A page with none of it is still listed, as a plain link.
2318        assert!(page.contains(">Bare</a>"), "bare page still listed: {page}");
2319    }
2320
2321    /// #587: topic pages carry `CollectionPage`, `ItemList` and
2322    /// `BreadcrumbList`.
2323    // Cards, ledes and JSON-LD come from the MiniJinja renderer; the
2324    // `not(templates)` shim emits a plain list, so these assert a
2325    // configuration that only exists with the feature on.
2326    #[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    /// Tags and categories are keyword indexes, not collections.
2359    #[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    /// #587: the hub shows what a topic is, not just its slug.
2378    // Cards, ledes and JSON-LD come from the MiniJinja renderer; the
2379    // `not(templates)` shim emits a plain list, so these assert a
2380    // configuration that only exists with the feature on.
2381    #[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    /// Without a data file nothing changes — the feature can only add.
2415    // Cards, ledes and JSON-LD come from the MiniJinja renderer; the
2416    // `not(templates)` shim emits a plain list, so these assert a
2417    // configuration that only exists with the feature on.
2418    #[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        // Default config has `og_image: None` — the tag/index gates
2443        // must not emit an empty/broken `og:image` meta tag.
2444        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    // -------------------------------------------------------------------
2467    // after_compile — tags and categories generation (built-in templates)
2468    // -------------------------------------------------------------------
2469
2470    #[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        // Built-in base.html renders `lang="en"` when no config supplies one.
2612        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    // -------------------------------------------------------------------
2636    // collect_json_files — recursion + filtering
2637    // -------------------------------------------------------------------
2638
2639    /// Regression: a theme whose page layouts are `StaticWeaver` (the
2640    /// default engine) put a `base.html` in `template_dir` that `MiniJinja`
2641    /// cannot parse. Falling back to that directory aborted the entire
2642    /// build with `syntax error: unexpected character (in base.html:26)`,
2643    /// attributed to `tag.html` — a file the author never wrote.
2644    // `resolve_user_template_dir` only exists with `templates` on.
2645    #[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        // A StaticWeaver layout, which is not valid MiniJinja.
2652        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        // A real `tera/` directory is still honoured.
2667        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    // -------------------------------------------------------------------
2717    // TaxonomyTerm — public type smoke test
2718    // -------------------------------------------------------------------
2719
2720    #[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        // Branch-free variant check (a `matches!` here would leave its
2760        // never-taken `_ => false` arm as an uncovered region).
2761        assert!(format!("{err:?}").contains("Io"));
2762    }
2763
2764    // -------------------------------------------------------------------
2765    // Template loader — user overrides + error branches
2766    // -------------------------------------------------------------------
2767
2768    #[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        // Custom templates in <template_dir>/tera/ — both end with a
2778        // newline so the "already ends with \n" branch is taken.
2779        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    /// Taxonomy was locale-blind: every locale's pages landed in one
2804    /// index, so an English tag page listed French pages beside English
2805    /// ones and a French reader had no tag index at all.
2806    #[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    /// A page whose first segment is not a locale belongs to the default
2827    /// locale, not to a phantom one named after the segment.
2828    #[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    /// The default locale keeps the bare prefix, so a directory sharing
2842    /// its name is not mistaken for a locale-prefixed tree.
2843    #[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        // Counterpart to `user_templates_in_tera_dir_override_builtins`:
2860        // this custom `tag.html` does NOT end in `\n`, so
2861        // `render_term_page`'s `if !s.ends_with('\n') { s.push('\n'); }`
2862        // branch actually fires (previously only the "already has a
2863        // trailing newline" branch was exercised, since both the
2864        // built-in templates and the other override test's fixtures
2865        // happen to already end in `\n`).
2866        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        // Same as above but for `render_index_page`'s identical
2895        // `ends_with('\n')` guard.
2896        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        // resolve_user_template_dir returns None; the loader skips the
2924        // user-dir probe entirely.
2925        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        // Root CI runners bypass perms; only assert when it errored.
2965        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        // `{% extends "missing.html" %}` compiles but fails at render
3040        // time, exercising the render map_err and the loader's
3041        // unknown-name `None` fallback.
3042        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        // User templates now live in `tera/` only — a StaticWeaver
3049        // layout sitting in the flat template dir must never reach
3050        // MiniJinja. The intent of this test is unchanged: a *user*
3051        // template whose parent is missing still fails the render.
3052        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        // User templates now live in `tera/` only — a StaticWeaver
3071        // layout sitting in the flat template dir must never reach
3072        // MiniJinja. The intent of this test is unchanged: a *user*
3073        // template whose parent is missing still fails the render.
3074        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    // -------------------------------------------------------------------
3087    // Sidecar collection — IO error branches
3088    // -------------------------------------------------------------------
3089
3090    #[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    // -------------------------------------------------------------------
3127    // generate_taxonomy_pages — write error branches
3128    // -------------------------------------------------------------------
3129
3130    #[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 raises the level so the log::debug! format
3176        // argument region executes.
3177        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    // -------------------------------------------------------------------
3188    // extract_terms_from_value — remaining branches
3189    // -------------------------------------------------------------------
3190
3191    #[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    // -------------------------------------------------------------------
3220    // Fallback renderer — locale handling without the `templates` feature
3221    // -------------------------------------------------------------------
3222    //
3223    // These only compile with default features off, which is the whole
3224    // point: that renderer is a separate implementation and used to
3225    // ignore `locale` entirely, so a French tree emitted the site's
3226    // default language and an un-prefixed canonical. Nothing caught it,
3227    // because nothing tested this configuration.
3228    //
3229    // The coverage job runs a second `--no-default-features` pass so
3230    // these are measured rather than counted as dead lines on a diff.
3231    #[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            // `None` marks the default locale. Substituting the bare
3285            // locale code here is what turned `en-GB` into `en`.
3286            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        /// The per-page type went from `(String, String)` to [`PageRef`]
3314        /// for #587. This renderer destructured the tuple, so it stopped
3315        /// compiling — under `--all-features` nothing noticed, because
3316        /// this whole `impl` is gated out.
3317        #[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        /// Curated titles (#587) reached the hub only through the
3352        /// `templates` renderer; this one took `clusters` and ignored it,
3353        /// so a no-default-features build silently published raw terms.
3354        #[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            // The URL is keyed on the slug, not the display title, so
3381            // curation must not move the page.
3382            assert!(
3383                curated.contains("/topics/post-quantum-cryptography/"),
3384                "curation moved the URL:\n{curated}"
3385            );
3386
3387            // Without a cluster the raw term is still what shows.
3388            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        /// `slugify` output must contain only (Unicode) alphanumerics and
3408        /// hyphens, with no leading/trailing/consecutive hyphens.
3409        ///
3410        /// NOTE: proptest discovered that `slugify` preserves Unicode
3411        /// alphanumeric characters (e.g. `𐞀`). This is intentional —
3412        /// the existing test suite asserts `"café"` -> `"café"`.
3413        #[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}