#!/usr/bin/env python3
# FILE-SIZE-OK: single coherent generator (parse → inject → render); splitting harms locality.
"""
Universal site-map navigator builder for static HTML mini-sites.

The filesystem layout IS the navigation structure. Walks a site root,
parses each page's <title> + first <h1>, then:

  1. Generates <root>/site-map.html (master tree view).
  2. Writes <root>/auto-nav.css and <root>/auto-nav.js (rail assets).
  3. Injects a fixed-position nav rail into every HTML page using HTML
     comment markers <!-- AUTO-NAV-START --> ... <!-- AUTO-NAV-END -->.
     Idempotent: safe to re-run; only modifies content between markers.

Site layout (any directory works — no hardcoded names):

    <root>/
      index.html              ← site home (recommended)
      auto-nav.css            ← generated
      auto-nav.js             ← generated
      site-map.html           ← generated
      <section-1>/            ← any subdir containing *.html is a section
        index.html
        page-a.html
        page-b.html
      <section-2>/
        ...

Section ordering: if a section name matches "YYYY-MM-DD-<slug>", the
date prefix is parsed and sections sort newest-first. Otherwise sections
sort alphabetically. Pages within a section sort: index.html first,
then alphabetical.

Usage:
    python build-nav.py [--root <dir>] [--asset-version <N>]

Defaults: --root=. and --asset-version=1.

Idempotency contract:
    Re-running this script with no source changes produces a no-op for
    every page. Markers preserve injection points; cache-bust strings
    are stable for a given --asset-version.

Pure stdlib. Works on file:// URLs. Pure HTML+CSS rail rendering with a
small optional JS for drag-to-resize.

Requires Python 3.10+. On 3.12+ uses Path.relative_to(walk_up=True) for
relative-path resolution; falls back to os.path.relpath on 3.10 / 3.11.
"""
from __future__ import annotations

import argparse
import html
import os
import re
import sys
import tempfile
from datetime import datetime
from pathlib import Path


def atomic_write_text(path: Path, text: str, encoding: str = "utf-8") -> None:
    """Write text to `path` atomically: write to a sibling tmp file in the
    same directory, fsync, then rename into place.

    Why this matters: `path.write_text()` on POSIX is open(WRONLY) →
    truncate → write → close. A SIGKILL, power loss, or disk-full event
    mid-write leaves the file empty or torn. Other readers (a webserver
    serving the page, a concurrent build script) see broken content. By
    writing to `<name>.tmp.<pid>` and using os.replace (atomic on POSIX
    for same-filesystem renames), readers always see either the old
    file in full or the new file in full — never a torn state.
    """
    parent = path.parent
    parent.mkdir(parents=True, exist_ok=True)
    fd, tmp_name = tempfile.mkstemp(
        prefix=f".{path.name}.", suffix=".tmp", dir=parent
    )
    try:
        with os.fdopen(fd, "w", encoding=encoding) as f:
            f.write(text)
            f.flush()
            os.fsync(f.fileno())
        os.replace(tmp_name, path)
    except Exception:
        # Best-effort cleanup of the tmp file on any failure path.
        try:
            os.unlink(tmp_name)
        except OSError:
            pass
        raise

# Marker convention — do not change; existing pages depend on these.
NAV_START = "<!-- AUTO-NAV-START -->"
NAV_END = "<!-- AUTO-NAV-END -->"
LEGACY_FOOTER_START = "<!-- AUTO-NAV-FOOTER-START -->"
LEGACY_FOOTER_END = "<!-- AUTO-NAV-FOOTER-END -->"

# Asset filenames written into the site root.
AUTO_NAV_CSS_NAME = "auto-nav.css"
AUTO_NAV_JS_NAME = "auto-nav.js"
SITE_MAP_NAME = "site-map.html"

# Pagefind static-search assets. The CLI tool `pagefind --site <root>`
# generates these into <root>/pagefind/. We always inject the asset tags
# so a single pagefind run flips search live across every page; if the
# index hasn't been built yet the tags 404 harmlessly and the search box
# just stays inert (mountSearch in auto-nav.js short-circuits when
# window.PagefindUI is undefined).
PAGEFIND_DIR_NAME = "pagefind"
PAGEFIND_UI_CSS_REL = f"{PAGEFIND_DIR_NAME}/pagefind-ui.css"
PAGEFIND_UI_JS_REL = f"{PAGEFIND_DIR_NAME}/pagefind-ui.js"

# Files generated by this script — never inject the rail into them as
# "ordinary pages". The site-map gets its own custom rail render.
GENERATED_PAGES = {SITE_MAP_NAME}


# ----------------------------------------------------------------------
# PARSING
# ----------------------------------------------------------------------

TITLE_RE = re.compile(r"<title[^>]*>([^<]+)</title>", re.IGNORECASE | re.DOTALL)
H1_RE = re.compile(r"<h1[^>]*>(.*?)</h1>", re.IGNORECASE | re.DOTALL)
TAG_STRIP_RE = re.compile(r"<[^>]+>")
DATE_PREFIX_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})-(.+)$")


def _strip_tags(s: str) -> str:
    return TAG_STRIP_RE.sub("", s).strip()


def parse_html(path: Path) -> dict:
    """Extract title + first h1 + filesystem metadata.

    Uses utf-8-sig so files saved with a UTF-8 BOM (common for Excel/
    Windows exports) parse cleanly. errors='replace' avoids hard-failing
    on the rare ISO-8859-1 / Windows-1252 file in the wild — the page
    still ships, with a question-mark glyph for any unrecoverable byte.
    """
    text = path.read_text(encoding="utf-8-sig", errors="replace")
    title = TITLE_RE.search(text)
    h1 = H1_RE.search(text)
    return {
        "path": path,
        "filename": path.name,
        "title": html.unescape(_strip_tags(title.group(1))) if title else path.stem,
        "h1": html.unescape(_strip_tags(h1.group(1))) if h1 else None,
        "size_bytes": path.stat().st_size,
        "mtime": datetime.fromtimestamp(path.stat().st_mtime),
    }


def walk_site(root: Path) -> tuple[dict | None, list[dict]]:
    """Walk site root, recursing into each section's subtree.

    Returns (root_index_page or None, sections-list). A "section" is any
    subdirectory of root that contains at least one *.html file (at any
    depth). The walker uses rglob so deeply nested pages (e.g.,
    <section>/topic/page.html) are picked up too — they're tracked with
    `section_relpath` (relative to the section dir) and `depth` so the
    rail and site-map can render them indented.

    Excluded: dot- and underscore-prefixed dirs, and pages this script
    generates (site-map.html).
    """
    root_page = None
    root_idx = root / "index.html"
    if root_idx.exists():
        root_page = parse_html(root_idx)

    sections: list[dict] = []
    # root.resolve() is the authoritative inode-resolved root path. We
    # compare every candidate file's resolved path against this anchor
    # so that symlinks pointing outside the tree (or in a circular loop
    # back to an ancestor) are detected and skipped.
    root_resolved = root.resolve()
    for section_dir in sorted(root.iterdir()):
        if not section_dir.is_dir():
            continue
        if section_dir.name.startswith(".") or section_dir.name.startswith("_"):
            continue
        # Skip Pagefind's generated index dir at any depth.
        if section_dir.name == "pagefind":
            continue
        # Refuse to recurse into a section dir that's itself a symlink
        # — too easy to accidentally nest two sites' content via a link.
        if section_dir.is_symlink():
            print(f"  skipping symlinked section: {section_dir.name}", file=sys.stderr)
            continue
        pages: list[dict] = []
        seen_inodes: set[tuple[int, int]] = set()
        for p in sorted(section_dir.rglob("*.html")):
            if p.name in GENERATED_PAGES:
                continue
            # Skip pagefind-generated subdirs at any depth, not just the
            # section root (e.g., section-a/sub/pagefind/...).
            if "pagefind" in p.relative_to(section_dir).parts:
                continue
            # Loop guard: resolve to an inode tuple and skip if seen, or
            # if the resolved path escapes the site root.
            try:
                resolved = p.resolve()
                if not resolved.is_relative_to(root_resolved):
                    print(f"  skipping symlink that escapes root: {p}", file=sys.stderr)
                    continue
                stat = resolved.stat()
                inode_key = (stat.st_dev, stat.st_ino)
                if inode_key in seen_inodes:
                    continue
                seen_inodes.add(inode_key)
            except (OSError, ValueError):
                # Broken symlink, racing deletion, etc. — skip silently.
                continue
            page = parse_html(p)
            # section_relpath: "page.html" for top-level, "topic/page.html"
            # for nested. depth: 0 = top of section, 1 = inside one subdir.
            page["section_relpath"] = str(p.relative_to(section_dir))
            page["depth"] = len(p.relative_to(section_dir).parts) - 1
            pages.append(page)
        if not pages:
            continue
        # Sort: top-level index.html first, then top-level alphabetical,
        # then nested pages grouped by subdir (index-first within each).
        def _sort_key(pg: dict) -> tuple:
            rel = pg["section_relpath"]
            parts = rel.split("/")
            is_top = len(parts) == 1
            is_index = pg["filename"] == "index.html"
            if is_top:
                return (0 if is_index else 1, "", pg["filename"])
            subdir = parts[0]
            return (2, subdir, 0 if is_index else 1, pg["filename"])
        pages.sort(key=_sort_key)
        slug = section_dir.name
        m = DATE_PREFIX_RE.match(slug)
        date = ""
        if m:
            # Validate the calendar shape — `2026-13-99-foo` matches the
            # regex but isn't a real date. Reject invalid dates and treat
            # the slug as undated rather than mis-sorting.
            try:
                datetime.strptime(m.group(1), "%Y-%m-%d")
                date = m.group(1)
            except ValueError:
                m = None
        human = (m.group(2).replace("-", " ").title() if m else
                 slug.replace("-", " ").replace("_", " ").title())
        sections.append({"slug": slug, "date": date, "human": human, "pages": pages})

    # Newest-first by date if dates are present; else alphabetical by slug.
    if any(s["date"] for s in sections):
        sections.sort(key=lambda s: (s["date"] or "", s["slug"]), reverse=True)
    else:
        sections.sort(key=lambda s: s["slug"])
    return root_page, sections


# ----------------------------------------------------------------------
# RAIL ASSETS — auto-nav.css and auto-nav.js bodies
# ----------------------------------------------------------------------

# Light-mode rail that pairs with the showcase kernel (color-scheme: light).
# Self-contained — does not depend on showcase.css tokens, so a repo can
# adopt the rail without adopting the kernel.
AUTO_NAV_CSS_BODY = """\
/* ----------------------------------------------------------------------
 * auto-nav.css — fixed-position navigation rail.
 *
 * Generated by build-nav.py. Edit the source string in build-nav.py and
 * re-run, OR edit this file directly knowing it will be overwritten on
 * the next run. Pure CSS — works without JS at default width.
 *
 * Pages get this rail injected automatically; the rail discovers
 * structure from the filesystem layout. No per-page configuration.
 * ---------------------------------------------------------------------- */

/* ----- Reset for the rail itself (does not affect host page) ----- */
.auto-nav-rail,
.auto-nav-rail * {
  box-sizing: border-box;
}

/* ----- Rail container (collapsed default; expanded when [open]) ----- */
.auto-nav-rail {
  position: fixed;
  left: 0;
  top: 0;
  bottom: 0;
  width: 56px;
  background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
  border-right: 1px solid #e2e8f0;
  overflow: hidden;
  z-index: 9999;
  font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
  font-size: 0.9rem;
  color: #334155;
  line-height: 1.45;
  transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1),
              box-shadow 0.25s ease;
  color-scheme: light;
}
.auto-nav-rail[open] {
  width: var(--rail-width, 320px);
  overflow-y: auto;
  box-shadow: 4px 0 24px rgba(15, 23, 42, 0.08);
}

/* ----- Body content shift via :has() — dynamic, no JS ----- *
 * Use margin-left, not padding-left, so 100%-width children measure
 * against the post-rail viewport instead of the full viewport.
 */
body {
  margin-left: 56px !important;
  padding-left: 0 !important;
  transition: margin-left 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
body:has(.auto-nav-rail[open]) {
  margin-left: var(--rail-width, 320px) !important;
}

/* ----- Drag handle for user-resizable width (auto-nav.js wires it up) -----
 * 14px hit zone for easy dragging + double-click targeting. An always-
 * visible 2px indicator line tells users where to grab; it expands and
 * brightens on hover, with a tooltip explaining the dual gesture
 * (drag = resize, double-click = re-autofit).
 */
.rail-resize-handle {
  position: absolute;
  top: 0;
  right: -7px;
  bottom: 0;
  width: 14px;
  cursor: ew-resize;
  z-index: 10;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: background 0.12s ease;
}
.rail-resize-handle::before {
  content: "";
  width: 2px;
  height: 36px;
  background: #94a3b8;
  border-radius: 2px;
  opacity: 0.55;
  transition: background 0.12s ease, opacity 0.12s ease, height 0.15s ease;
}
.rail-resize-handle:hover::before,
.rail-resize-handle.dragging::before {
  background: #2563eb;
  opacity: 1;
  height: 60px;
}
.rail-resize-handle:hover {
  background: rgba(37, 99, 235, 0.10);
}
.rail-resize-handle.dragging {
  background: rgba(37, 99, 235, 0.18);
}
/* Tooltip on hover — explains the dual gesture. */
.rail-resize-handle:hover::after {
  content: "Drag to resize · Double-click to auto-fit";
  position: absolute;
  left: calc(100% + 8px);
  top: 50%;
  transform: translateY(-50%);
  background: #ffffff;
  color: #1e293b;
  padding: 6px 10px;
  border-radius: 6px;
  font-size: 0.75rem;
  white-space: nowrap;
  border: 1px solid #cbd5e1;
  pointer-events: none;
  box-shadow: 0 4px 12px rgba(15, 23, 42, 0.12);
}
.auto-nav-rail:not([open]) .rail-resize-handle { display: none; }
.rail-resizing,
.rail-resizing * {
  user-select: none !important;
  cursor: ew-resize !important;
}
.rail-resizing .auto-nav-rail,
.rail-resizing body {
  transition: none !important;
}

/* ----- Toggle (summary) — always visible at top-left ----- */
.auto-nav-rail summary {
  display: flex;
  align-items: center;
  gap: 10px;
  height: 56px;
  padding: 0 18px;
  cursor: pointer;
  user-select: none;
  border-bottom: 1px solid #e2e8f0;
  background: #ffffff;
  position: sticky;
  top: 0;
  z-index: 1;
  list-style: none;
}
.auto-nav-rail summary::-webkit-details-marker { display: none; }
.auto-nav-rail .rail-toggle-icon {
  font-size: 1.25rem;
  transition: transform 0.25s ease;
  display: inline-block;
  width: 20px;
  text-align: center;
  color: #475569;
}
.auto-nav-rail[open] .rail-toggle-icon { transform: rotate(90deg); }
.auto-nav-rail .rail-toggle-label {
  font-weight: 600;
  font-size: 0.78rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: #475569;
  white-space: nowrap;
  opacity: 0;
  transition: opacity 0.15s ease 0.1s;
}
.auto-nav-rail[open] .rail-toggle-label { opacity: 1; }
.auto-nav-rail summary:hover { background: #f1f5f9; }
.auto-nav-rail summary:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: -2px;
}

/* ----- Rail body (sections, links) ----- */
.rail-body {
  padding: 8px 14px 24px 14px;
  opacity: 0;
  transition: opacity 0.2s ease 0.05s;
}
.auto-nav-rail[open] .rail-body { opacity: 1; }

.rail-section {
  padding: 12px 0;
  border-bottom: 1px solid #f1f5f9;
}
.rail-section:last-child { border-bottom: none; }

.rail-h {
  margin: 0 0 8px 4px;
  font-size: 0.7rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: #64748b;
}
.rail-date {
  font-family: 'JetBrains Mono', 'Menlo', monospace;
  font-size: 0.7rem;
  color: #94a3b8;
  font-weight: 500;
  letter-spacing: 0;
  text-transform: none;
  margin-left: 6px;
}

/* ----- Links — long titles wrap, no truncation ----- */
.rail-link {
  display: block;
  padding: 7px 10px;
  margin: 1px 0;
  color: #1d4ed8;
  text-decoration: none;
  border-radius: 6px;
  word-wrap: break-word;
  overflow-wrap: anywhere;
  hyphens: auto;
  font-size: 0.86rem;
  line-height: 1.4;
  border-left: 2px solid transparent;
  transition: background 0.12s ease,
              border-left-color 0.12s ease,
              transform 0.12s ease;
}
.rail-link:hover {
  background: rgba(37, 99, 235, 0.08);
  color: #1e3a8a;
  border-left-color: #2563eb;
  transform: translateX(2px);
}
.rail-link.rail-current {
  background: #2563eb;
  color: #ffffff;
  font-weight: 600;
  border-left-color: #1e3a8a;
}
.rail-link.rail-current:hover {
  background: #1d4ed8;
  transform: none;
}
.rail-link .rail-emoji {
  display: inline-block;
  width: 22px;
  text-align: center;
  margin-right: 4px;
}

.rail-list {
  list-style: none;
  padding: 0;
  margin: 0;
}
.rail-list li { margin: 0; }

/* ----- Pagefind UI — light-themed to match the showcase kernel -----
 * The Pagefind UI is mounted into <div id="auto-nav-search"> by
 * auto-nav.js (mountSearch). Pagefind exposes ~10 CSS custom properties
 * for theming; we override the few that matter for our light palette.
 * If pagefind/pagefind-ui.css hasn't been generated yet, these rules
 * have nothing to bind to and are harmless.
 */
.auto-nav-rail #auto-nav-search {
  --pagefind-ui-scale: 0.9;
  --pagefind-ui-primary: #2563eb;
  --pagefind-ui-text: #1e293b;
  --pagefind-ui-background: #ffffff;
  --pagefind-ui-border: #cbd5e1;
  --pagefind-ui-tag: #f1f5f9;
  --pagefind-ui-border-width: 1px;
  --pagefind-ui-border-radius: 6px;
  --pagefind-ui-image-border-radius: 6px;
  --pagefind-ui-image-box-ratio: 3 / 2;
  --pagefind-ui-font: inherit;
}
/* When Pagefind hasn't been built yet (no pagefind/ dir), the host
 * div is empty. Show an inert placeholder so users see "search needs
 * a build step" instead of a confusing empty box. mountSearch()
 * removes this state once PagefindUI fills the div with children. */
.auto-nav-rail #auto-nav-search:empty::before {
  content: "Search index pending. Run scripts/site.sh nav <site> to enable.";
  display: block;
  padding: 8px 10px;
  background: #f8fafc;
  border: 1px dashed #cbd5e1;
  border-radius: 6px;
  color: #64748b;
  font-size: 0.78rem;
  font-style: italic;
  line-height: 1.4;
}
.auto-nav-rail .pagefind-ui__search-input {
  background: #f8fafc !important;
  color: #1e293b !important;
  border-color: #cbd5e1 !important;
}
.auto-nav-rail .pagefind-ui__search-input::placeholder {
  color: #94a3b8 !important;
}
.auto-nav-rail .pagefind-ui__result {
  border-color: #e2e8f0 !important;
}
.auto-nav-rail .pagefind-ui__result-link {
  color: #1d4ed8 !important;
}
.auto-nav-rail .pagefind-ui__result-excerpt mark {
  background: rgba(37, 99, 235, 0.18) !important;
  color: inherit !important;
}

/* ----- Mobile: rail becomes a static top-of-page accordion ----- */
@media (max-width: 900px) {
  .auto-nav-rail,
  .auto-nav-rail[open] {
    position: static;
    width: 100% !important;
    height: auto;
    max-height: none;
    overflow: visible;
    border-right: none;
    border-bottom: 1px solid #e2e8f0;
    box-shadow: none;
    margin-bottom: 16px;
  }
  body,
  body:has(.auto-nav-rail[open]) {
    margin-left: 0 !important;
    padding-left: 0 !important;
  }
  .auto-nav-rail summary {
    position: static;
    height: 48px;
  }
  .auto-nav-rail .rail-toggle-label { opacity: 1; }
  .auto-nav-rail[open] { overflow: visible; }
  .rail-resize-handle { display: none !important; }
}
"""


AUTO_NAV_JS_BODY = """\
/* ----------------------------------------------------------------------
 * auto-nav.js — auto-fit + drag-to-resize for the navigation rail.
 *
 * Generated by build-nav.py. The rail still works without JS at a
 * reasonable default width; this script adds:
 *   - Initial auto-fit to the longest unwrapped link width
 *   - Drag-to-resize via a 14px-wide handle on the rail's right edge
 *   - Double-click on the handle = clear saved width and re-autofit
 *   - Persistence of user-chosen widths in localStorage
 * ---------------------------------------------------------------------- */
(function () {
  "use strict";
  // STORE_KEY versioned: bump suffix whenever the default-width logic
  // changes so users carrying an older saved value pick up the new
  // behaviour.
  var STORE_KEY = "autoNavWidth_universal_v1";
  // MIN_W can be small because short pages (e.g., a site home with just
  // Home + Site map) deserve a snug fit, not a forced 320px floor. The
  // user can still drag wider if they want.
  var MIN_W = 220;
  var MAX_W = 1200;
  // DEFAULT_W is the fallback / autofit-cap. The rail won't exceed this
  // width on first load — user drag can grow it further (up to MAX_W).
  var DEFAULT_W = 760;
  // RAIL_BODY_PAD = padding-left (14) + padding-right (14) of .rail-body.
  // SCROLLBAR_BUF = ~16px reserved for native scrollbar + safety pad.
  var RAIL_BODY_PAD = 28;
  var SCROLLBAR_BUF = 16;

  function applyWidth(px) {
    document.documentElement.style.setProperty("--rail-width", px + "px");
  }

  function loadWidth() {
    try {
      var v = parseInt(localStorage.getItem(STORE_KEY), 10);
      if (!isNaN(v) && v >= MIN_W && v <= MAX_W) {
        applyWidth(v);
        return true;
      }
    } catch (_) { /* localStorage unavailable */ }
    return false;
  }

  function saveWidth(px) {
    try { localStorage.setItem(STORE_KEY, String(px)); } catch (_) { /* */ }
  }

  /**
   * Measure each rail link's TRUE intrinsic unwrapped width and pick a
   * rail width that lets every link fit on a single line — clamped to
   * [MIN_W, DEFAULT_W].
   *
   * Why max-content matters here:
   *   scrollWidth on a block element returns max(clientWidth,
   *   overflow-content-width). When the rail is currently wide, the
   *   link block fills its parent and scrollWidth degenerates to the
   *   parent's content width — NOT the text's intrinsic width. So
   *   measuring with the rail at its current size gives garbage.
   *
   *   Setting `width: max-content` per link forces the element to size
   *   to its actual content width, independent of the parent. Combined
   *   with `white-space: nowrap`, this yields the unwrapped text +
   *   padding width every time, regardless of whether the rail is
   *   currently 220px or 1200px wide.
   *
   * Used by both:
   *   - Initial load (when no saved width is in localStorage)
   *   - Double-click on the resize handle (explicit "fit" gesture)
   */
  function autofitWidth(rail) {
    if (!rail.open) return DEFAULT_W;
    var links = rail.querySelectorAll(".rail-link");
    if (!links.length) return DEFAULT_W;
    var savedWS = [];
    var savedW = [];
    var i;
    for (i = 0; i < links.length; i++) {
      savedWS.push(links[i].style.whiteSpace);
      savedW.push(links[i].style.width);
      links[i].style.whiteSpace = "nowrap";
      links[i].style.width = "max-content";
    }
    // Force layout flush so getBoundingClientRect returns updated values.
    rail.offsetWidth; // eslint-disable-line no-unused-expressions
    var maxLink = 0;
    for (i = 0; i < links.length; i++) {
      // getBoundingClientRect().width is sub-pixel-accurate.
      var w = links[i].getBoundingClientRect().width;
      if (w > maxLink) maxLink = w;
    }
    for (i = 0; i < links.length; i++) {
      links[i].style.whiteSpace = savedWS[i];
      links[i].style.width = savedW[i];
    }
    var fitted = maxLink + RAIL_BODY_PAD + SCROLLBAR_BUF;
    if (fitted < MIN_W) fitted = MIN_W;
    if (fitted > DEFAULT_W) fitted = DEFAULT_W;
    return Math.round(fitted);
  }

  // Mount the Pagefind UI into #auto-nav-search if both are present.
  // Pagefind's <script defer> runs before auto-nav.js (document order),
  // so window.PagefindUI is defined by the time DOMContentLoaded fires.
  // If pagefind hasn't been built yet (PagefindUI undefined), the
  // mount is a no-op and the search box stays empty — harmless.
  function mountSearch() {
    var host = document.getElementById("auto-nav-search");
    if (!host || host.childElementCount > 0) return;
    if (typeof window.PagefindUI !== "function") return;
    try {
      new window.PagefindUI({
        element: "#auto-nav-search",
        showSubResults: true,
        showImages: false,
        resetStyles: false,
        debounceTimeoutMs: 200,
        autofocus: false,
        placeholder: "Search…",
      });
    } catch (e) {
      // Fail-safe: don't break the rail if Pagefind init throws.
      if (window.console && console.warn) console.warn("PagefindUI init failed", e);
    }
  }

  function init() {
    var rail = document.querySelector(".auto-nav-rail");
    if (!rail) return;
    mountSearch();
    if (window.matchMedia && window.matchMedia("(max-width: 900px)").matches) return;

    // Apply saved width first; if none, autofit to longest unwrapped link.
    if (!loadWidth()) {
      applyWidth(autofitWidth(rail));
    }

    var handle = document.createElement("div");
    handle.className = "rail-resize-handle";
    handle.setAttribute("aria-label", "Resize navigation rail");
    handle.setAttribute("role", "separator");
    handle.setAttribute("aria-orientation", "vertical");
    rail.appendChild(handle);

    var dragging = false;
    var startX = 0;
    var startW = 0;

    function onPointerDown(e) {
      if (!rail.open) return;
      dragging = true;
      startX = e.clientX;
      startW = rail.getBoundingClientRect().width;
      handle.classList.add("dragging");
      document.documentElement.classList.add("rail-resizing");
      handle.setPointerCapture(e.pointerId);
      e.preventDefault();
    }

    function onPointerMove(e) {
      if (!dragging) return;
      var w = startW + (e.clientX - startX);
      if (w < MIN_W) w = MIN_W;
      if (w > MAX_W) w = MAX_W;
      applyWidth(Math.round(w));
    }

    function onPointerUp(e) {
      if (!dragging) return;
      dragging = false;
      handle.classList.remove("dragging");
      document.documentElement.classList.remove("rail-resizing");
      try { handle.releasePointerCapture(e.pointerId); } catch (_) { /* */ }
      var current = parseInt(
        getComputedStyle(document.documentElement).getPropertyValue("--rail-width"),
        10
      );
      if (!isNaN(current)) saveWidth(current);
    }

    handle.addEventListener("pointerdown", onPointerDown);
    handle.addEventListener("pointermove", onPointerMove);
    handle.addEventListener("pointerup", onPointerUp);
    handle.addEventListener("pointercancel", onPointerUp);

    // Double-click = clear user override and re-autofit. This matches
    // the user's mental model: "reset to default" should mean "redo the
    // smart auto-fit", not "force the absolute maximum".
    handle.addEventListener("dblclick", function () {
      try { localStorage.removeItem(STORE_KEY); } catch (_) { /* */ }
      applyWidth(autofitWidth(rail));
    });
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init);
  } else {
    init();
  }
})();
"""


def write_rail_assets(root: Path) -> None:
    atomic_write_text(root / AUTO_NAV_CSS_NAME, AUTO_NAV_CSS_BODY)
    atomic_write_text(root / AUTO_NAV_JS_NAME, AUTO_NAV_JS_BODY)


# ----------------------------------------------------------------------
# RAIL HTML — per-page rendering
# ----------------------------------------------------------------------


def relpath_from(page: Path, target: Path) -> str:
    """Compute relative path from page's dir to target.

    Tries Pathlib's walk_up=True (Python 3.12+) and falls back to
    os.path.relpath on older interpreters or any other shape problem.
    The fallback covers TypeError (3.10/3.11 — kwarg unknown),
    AttributeError (legacy stdlib shapes), and ValueError (siblings on
    different drives, etc.).
    """
    try:
        return str(target.relative_to(page.parent.resolve(), walk_up=True))
    except (ValueError, AttributeError, TypeError):
        from os.path import relpath
        return relpath(target, start=page.parent.resolve())


def render_rail(
    page: dict,
    section: dict | None,
    sections: list[dict],
    root: Path,
) -> str:
    """Vertical expandable rail: home + site-map + section siblings + cross-section."""
    page_path = page["path"]
    home_url = relpath_from(page_path, root / "index.html")
    site_map_url = relpath_from(page_path, root / SITE_MAP_NAME)

    parts: list[str] = []

    # ----- Section 0: search box (mounted by auto-nav.js mountSearch) -----
    # Lives at the top of the rail so it's the first thing a reader sees.
    # Pagefind's UI inserts the input + dropdown into the empty div.
    parts.append(
        '<div class="rail-section">'
        '<h4 class="rail-h">Search</h4>'
        '<div id="auto-nav-search"></div>'
        '</div>'
    )

    # ----- Section 1: top-level shortcuts -----
    parts.append(
        '<div class="rail-section">'
        '<h4 class="rail-h">Site</h4>'
        f'<a class="rail-link" href="{html.escape(home_url)}">'
        '<span class="rail-emoji">🏠</span>Home</a>'
        f'<a class="rail-link" href="{html.escape(site_map_url)}">'
        '<span class="rail-emoji">🗺</span>Site map</a>'
        '</div>'
    )

    # ----- Section 2: current section + sibling pages -----
    if section:
        sib_items: list[str] = []
        for sib in section["pages"]:
            url = relpath_from(page_path, sib["path"])
            label = html.escape(sib["h1"] or sib["title"])
            is_current = sib["path"] == page_path
            cls = "rail-link rail-current" if is_current else "rail-link"
            depth = sib.get("depth", 0)
            # Top-level: 📋 index, 📄 page. Nested: 📑 + 18px indent so
            # the subdir hierarchy is visually obvious.
            if depth == 0:
                badge = "📋" if sib["filename"] == "index.html" else "📄"
                style = ""
            else:
                badge = "📑"
                style = ' style="margin-left: 18px;"'
            sib_items.append(
                f'<li{style}><a class="{cls}" href="{html.escape(url)}">'
                f'<span class="rail-emoji">{badge}</span>{label}</a></li>'
            )
        date_pill = (
            f'<span class="rail-date">{html.escape(section["date"])}</span>'
            if section["date"] else ""
        )
        parts.append(
            '<div class="rail-section">'
            f'<h4 class="rail-h">📂 {html.escape(section["human"])}{date_pill}</h4>'
            f'<ul class="rail-list">{"".join(sib_items)}</ul>'
            '</div>'
        )

    # ----- Section 3: neighbor sections (prev/next in chronological order) -----
    cross_links: list[str] = []
    if section:
        slugs = [s["slug"] for s in sections]
        try:
            idx = slugs.index(section["slug"])
        except ValueError:
            idx = -1
        if idx >= 0:
            prev_section = sections[idx - 1] if idx > 0 else None
            next_section = sections[idx + 1] if idx + 1 < len(sections) else None
            for arrow_label, neighbor in (
                ("⬅ Prev", prev_section), ("Next ➡", next_section),
            ):
                if neighbor and neighbor["pages"]:
                    target = neighbor["pages"][0]["path"]
                    url = relpath_from(page_path, target)
                    arrow = arrow_label.split()[0] if "⬅" in arrow_label else arrow_label.split()[1]
                    direction = "Prev" if "⬅" in arrow_label else "Next"
                    date_str = (
                        f'<span class="rail-date">{html.escape(neighbor["date"])}</span>'
                        if neighbor["date"] else ""
                    )
                    cross_links.append(
                        f'<a class="rail-link" href="{html.escape(url)}">'
                        f'<span class="rail-emoji">{arrow}</span>'
                        f'{direction}: {html.escape(neighbor["human"])} '
                        f'{date_str}'
                        f'</a>'
                    )
    if cross_links:
        parts.append(
            '<div class="rail-section">'
            '<h4 class="rail-h">Other sections</h4>'
            f'{"".join(cross_links)}'
            '</div>'
        )

    return (
        '<details class="auto-nav-rail" open>'
        '<summary>'
        '<span class="rail-toggle-icon">›</span>'
        '<span class="rail-toggle-label">Navigation</span>'
        '</summary>'
        '<nav class="rail-body" aria-label="Site navigation">'
        f'{"".join(parts)}'
        '</nav>'
        '</details>'
    )


# ----------------------------------------------------------------------
# INJECTION
# ----------------------------------------------------------------------

_LEGACY_FOOTER_RE = re.compile(
    re.escape(LEGACY_FOOTER_START) + r".*?" + re.escape(LEGACY_FOOTER_END) + r"\s*",
    re.DOTALL,
)
_INLINE_NAV_STYLE_RE = re.compile(
    r'\s*<style id="auto-nav-css">.*?</style>', re.DOTALL
)
_NAV_LINK_RE = re.compile(
    r'\s*<link\s[^>]*id="auto-nav-css"[^>]*>', re.IGNORECASE
)
_NAV_SCRIPT_RE = re.compile(
    r'\s*<script\s[^>]*id="auto-nav-js"[^>]*>\s*</script>', re.IGNORECASE
)
_PAGEFIND_LINK_RE = re.compile(
    r'\s*<link\s[^>]*id="pagefind-ui-css"[^>]*>', re.IGNORECASE
)
_PAGEFIND_SCRIPT_RE = re.compile(
    r'\s*<script\s[^>]*id="pagefind-ui-js"[^>]*>\s*</script>', re.IGNORECASE
)


def strip_legacy_footer(text: str, page_path: Path | None = None) -> str:
    """Remove any legacy AUTO-NAV-FOOTER block (cross-section nav now lives
    in the rail).

    Warns to stderr when the strip actually fires — these markers came
    from very-old (pre-v17.7) builds. If a user hand-edited content
    between them before the nav was automated, that content is lost.
    The warning is the user's signal to check git history before the
    next commit.
    """
    new_text, n = _LEGACY_FOOTER_RE.subn("", text)
    if n > 0:
        loc = f"{page_path}: " if page_path else ""
        print(
            f"  WARN: {loc}stripped a legacy AUTO-NAV-FOOTER block "
            "(pre-v17.7 marker). If that block contained hand-written "
            "content, recover it from git before committing.",
            file=sys.stderr,
        )
    return new_text


def ensure_nav_assets(text: str, page_path: Path, root: Path, asset_version: str) -> str:
    """Ensure <head> has all four assets in correct order: pagefind CSS,
    auto-nav CSS, pagefind UI JS, auto-nav JS.

    Strict order matters: Pagefind's <script defer> must run before
    auto-nav.js so window.PagefindUI exists when auto-nav's mountSearch()
    fires. Both scripts use defer so they execute in document order.

    All four prior versions of these tags are stripped first to keep
    re-runs byte-stable.

    Returns the new text. If no </head> tag is present (e.g., truncated
    or non-standard HTML), prints a warning to stderr and returns the
    text unchanged — silent injection failure was the #1 anti-fragility
    finding from the edge-case audit.
    """
    text = _INLINE_NAV_STYLE_RE.sub("", text)
    text = _NAV_LINK_RE.sub("", text)
    text = _NAV_SCRIPT_RE.sub("", text)
    text = _PAGEFIND_LINK_RE.sub("", text)
    text = _PAGEFIND_SCRIPT_RE.sub("", text)

    css_href = (
        f"{relpath_from(page_path, root / AUTO_NAV_CSS_NAME)}?v={asset_version}"
    )
    js_src = (
        f"{relpath_from(page_path, root / AUTO_NAV_JS_NAME)}?v={asset_version}"
    )
    pf_css = relpath_from(page_path, root / PAGEFIND_UI_CSS_REL)
    pf_js = relpath_from(page_path, root / PAGEFIND_UI_JS_REL)

    pf_link = f'<link rel="stylesheet" id="pagefind-ui-css" href="{html.escape(pf_css)}">'
    pf_script = f'<script id="pagefind-ui-js" src="{html.escape(pf_js)}" defer></script>'
    nav_link = f'<link rel="stylesheet" id="auto-nav-css" href="{html.escape(css_href)}">'
    nav_script = f'<script id="auto-nav-js" src="{html.escape(js_src)}" defer></script>'

    # Case-insensitive: matches </head>, </HEAD>, </Head>, etc. so pages
    # that came out of legacy editors don't fail asset injection silently.
    new_text, n = re.subn(
        r"\s*</head\s*>",
        f"\n{pf_link}\n{nav_link}\n{pf_script}\n{nav_script}\n</head>",
        text,
        count=1,
        flags=re.IGNORECASE,
    )
    if n == 0:
        print(
            f"  WARN: {page_path} has no </head> tag — assets NOT injected. "
            "Page will render without nav rail or search.",
            file=sys.stderr,
        )
        return text
    return new_text


def inject_block(
    text: str, marker_start: str, marker_end: str,
    content: str, anchor_pattern: str, anchor_replace: str,
    page_path: Path | None = None,
) -> str:
    """Replace content between markers; if missing, insert via anchor.

    Returns the new text. If the markers are absent AND the anchor
    pattern (e.g., `<body>`) doesn't match either, warns to stderr and
    returns the text unchanged — better a missing rail with a loud log
    than silent corruption of an unfamiliar HTML shape.
    """
    block = f"{marker_start}\n{content}\n{marker_end}"
    pattern = re.compile(
        re.escape(marker_start) + r".*?" + re.escape(marker_end),
        re.DOTALL,
    )
    if pattern.search(text):
        return pattern.sub(block, text)
    # IGNORECASE for the same reason as ensure_nav_assets — uppercase
    # tags like <BODY> shouldn't silently miss the rail injection.
    new_text, n = re.subn(
        anchor_pattern, anchor_replace.format(block=block), text, count=1,
        flags=re.IGNORECASE,
    )
    if n == 0:
        loc = f"{page_path}: " if page_path else ""
        print(
            f"  WARN: {loc}neither {marker_start}/{marker_end} markers nor "
            "the anchor pattern matched — rail NOT injected.",
            file=sys.stderr,
        )
        return text
    return new_text


def inject_into_page(
    page: dict,
    section: dict | None,
    sections: list[dict],
    root: Path,
    asset_version: str,
) -> bool:
    """Inject rail + asset links; strip legacy footer. Return True if changed.

    Reads with utf-8-sig to tolerate BOM-prefixed files; writes UTF-8
    (without BOM) so downstream tools see canonical bytes.
    """
    path = page["path"]
    text = path.read_text(encoding="utf-8-sig", errors="replace")
    new_text = ensure_nav_assets(text, path, root, asset_version)
    new_text = strip_legacy_footer(new_text, page_path=path)

    rail = render_rail(page, section, sections, root)
    new_text = inject_block(
        new_text, NAV_START, NAV_END, rail,
        anchor_pattern=r"(<body[^>]*>)",
        anchor_replace=r"\1\n{block}",
        page_path=path,
    )
    if new_text != text:
        atomic_write_text(path, new_text)
        return True
    return False


# ----------------------------------------------------------------------
# SITE-MAP RENDER
# ----------------------------------------------------------------------


def render_site_map(
    root_page: dict | None,
    sections: list[dict],
    root: Path,
    asset_version: str,
) -> str:
    """Render the master site-map.html — flat list per section + the rail."""
    rows = []
    for section in sections:
        page_links = []
        for p in section["pages"]:
            url = str(p["path"].relative_to(root))
            label = html.escape(p["h1"] or p["title"])
            depth = p.get("depth", 0)
            relpath = p.get("section_relpath", p["filename"])
            # Top-level: 📋 for index, 📄 for others, filename only in meta.
            # Nested: 📑 + 24px indent + show subpath in meta so the
            # hierarchy is visible at a glance.
            if depth == 0:
                badge = "📋" if p["filename"] == "index.html" else "📄"
                meta_path = p["filename"]
                indent_style = ""
            else:
                badge = "📑"
                meta_path = relpath
                indent_style = ' style="margin-left: 24px;"'
            page_links.append(
                f'<li{indent_style}>{badge} <a href="{html.escape(url)}">{label}</a> '
                f'<span class="meta">({meta_path}, {p["size_bytes"] // 1024} KB)</span></li>'
            )
        date_pill = (
            f'<span class="date">{html.escape(section["date"])}</span>'
            if section["date"] else ""
        )
        rows.append(
            '<div class="section">'
            f'<h3>📂 {html.escape(section["human"])} {date_pill}</h3>'
            f'<ul>{"".join(page_links)}</ul>'
            '</div>'
        )

    home_link = ""
    if root_page:
        home_link = (
            '<div class="root"><h3>🏠 Site Home</h3>'
            f'<ul><li>📋 <a href="{root_page["filename"]}">'
            f'{html.escape(root_page["h1"] or root_page["title"])}</a> '
            f'<span class="meta">({root_page["filename"]}, '
            f'{root_page["size_bytes"] // 1024} KB)</span></li></ul>'
            '</div>'
        )

    n_pages = sum(len(s["pages"]) for s in sections) + (1 if root_page else 0)

    # ----- Rail for the site-map page itself: shortcuts + all sections -----
    rail_parts: list[str] = []
    rail_parts.append(
        '<div class="rail-section">'
        '<h4 class="rail-h">Search</h4>'
        '<div id="auto-nav-search"></div>'
        '</div>'
    )
    rail_parts.append(
        '<div class="rail-section">'
        '<h4 class="rail-h">Site</h4>'
        '<a class="rail-link" href="index.html">'
        '<span class="rail-emoji">🏠</span>Home</a>'
        f'<a class="rail-link rail-current" href="{SITE_MAP_NAME}">'
        '<span class="rail-emoji">🗺</span>Site map</a>'
        '</div>'
    )
    section_links: list[str] = []
    for s in sections:
        if not s["pages"]:
            continue
        target = s["pages"][0]
        url = str(target["path"].relative_to(root))
        date_pill = (
            f'<span class="rail-date">{html.escape(s["date"])}</span>'
            if s["date"] else ""
        )
        section_links.append(
            f'<a class="rail-link" href="{html.escape(url)}">'
            f'<span class="rail-emoji">📂</span>{html.escape(s["human"])} '
            f'{date_pill}'
            '</a>'
        )
    if section_links:
        rail_parts.append(
            '<div class="rail-section">'
            f'<h4 class="rail-h">All sections ({len(sections)})</h4>'
            f'{"".join(section_links)}'
            '</div>'
        )

    rail_html = (
        '<details class="auto-nav-rail" open>'
        '<summary>'
        '<span class="rail-toggle-icon">›</span>'
        '<span class="rail-toggle-label">Navigation</span>'
        '</summary>'
        '<nav class="rail-body" aria-label="Site navigation">'
        f'{"".join(rail_parts)}'
        '</nav>'
        '</details>'
    )

    return f'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Site map ({n_pages} pages)</title>
<link rel="stylesheet" id="pagefind-ui-css" href="{PAGEFIND_UI_CSS_REL}">
<link rel="stylesheet" id="auto-nav-css" href="{AUTO_NAV_CSS_NAME}?v={asset_version}">
<script id="pagefind-ui-js" src="{PAGEFIND_UI_JS_REL}" defer></script>
<script id="auto-nav-js" src="{AUTO_NAV_JS_NAME}?v={asset_version}" defer></script>
<style>
:root {{ color-scheme: light; }}
body {{ font-family: 'Inter', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
       background: #f8fafc; margin: 0; padding: 20px; color: #1e293b; line-height: 1.5; }}
.container {{ max-width: 1100px; margin: 0 auto; background: #ffffff; padding: 36px;
              border-radius: 8px; box-shadow: 0 2px 14px rgba(15, 23, 42, 0.06);
              border: 1px solid #e2e8f0; }}
h1 {{ border-bottom: 3px solid #2563eb; padding-bottom: 12px; color: #0f172a; }}
.subtitle {{ color: #64748b; margin-bottom: 24px; font-style: italic; }}
.section, .root {{ background: #f8fafc; border: 1px solid #e2e8f0;
                    border-left: 4px solid #2563eb;
                    padding: 14px 20px; margin: 12px 0; border-radius: 6px; }}
.root {{ border-left-color: #10b981; background: #ecfdf5; }}
.section h3, .root h3 {{ margin: 0 0 8px 0; color: #0f172a; }}
.section .date {{ color: #94a3b8; font-size: 0.85em; font-weight: normal;
                font-family: 'Menlo', monospace; margin-left: 8px; }}
.section ul, .root ul {{ margin: 6px 0 0 0; padding-left: 22px; }}
.section li, .root li {{ margin: 3px 0; }}
.section a, .root a {{ color: #1d4ed8; text-decoration: none; }}
.section a:hover, .root a:hover {{ color: #1e3a8a; text-decoration: underline; }}
.meta {{ color: #94a3b8; font-size: 0.82em; font-family: 'Menlo', monospace; margin-left: 6px; }}
code {{ background: #f1f5f9; color: #0f172a; padding: 1px 6px; border-radius: 3px;
       font-size: 0.9em; }}
.footer {{ margin-top: 30px; color: #94a3b8; font-size: 0.82em; padding-top: 14px;
           border-top: 1px solid #e2e8f0; }}
</style>
</head>
<body>
{rail_html}
  <div class="container">
    <h1>🗺 Site map</h1>
    <p class="subtitle">{n_pages} pages across {len(sections)} sections. Auto-generated from filesystem layout.</p>
    {home_link}
    {"".join(rows)}
    <div class="footer">
      Generated {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} by <code>build-nav.py</code>.
      Sections sort newest-first when slugs are <code>YYYY-MM-DD-…</code>; otherwise alphabetical.
      Pages within a section: <code>index.html</code> first, then alphabetical.
      All links are relative — works on <code>file://</code> and any static server.
      Drag the rail's right edge to resize; double-click to reset.
    </div>
  </div>
</body>
</html>
'''


# ----------------------------------------------------------------------
# MAIN
# ----------------------------------------------------------------------


def cleanup_stale_tmp_files(root: Path) -> None:
    """Sweep up orphan tmp files left by killed atomic_write_text calls.

    A SIGKILL between mkstemp and os.replace leaves `.<name>.NN.tmp` files
    in the same directory as the target. They're harmless but accumulate
    if the user repeatedly Ctrl+Cs build-nav.py. Clean them at start so
    the working tree stays tidy.
    """
    cleaned = 0
    for tmp in root.rglob(".*.tmp"):
        # Match tempfile.mkstemp's prefix=`.{name}.` suffix=`.tmp` shape.
        if "/pagefind/" in str(tmp):
            continue  # pagefind manages its own dir; don't touch
        try:
            tmp.unlink()
            cleaned += 1
        except OSError:
            pass
    if cleaned:
        print(f"  cleaned {cleaned} orphan .tmp file(s) from prior killed runs")


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Universal site-map navigator builder.",
    )
    parser.add_argument(
        "--root", type=Path, default=Path("."),
        help="Site root directory (default: current dir)",
    )
    parser.add_argument(
        "--asset-version", default="4",
        help="Cache-bust version appended to asset URLs (bump on rail asset changes)",
    )
    args = parser.parse_args()

    root = args.root.resolve()
    if not root.is_dir():
        print(f"FATAL: {root} is not a directory", file=sys.stderr)
        return 2

    cleanup_stale_tmp_files(root)
    write_rail_assets(root)
    print(f"Wrote {AUTO_NAV_CSS_NAME}, {AUTO_NAV_JS_NAME}")

    root_page, sections = walk_site(root)
    n_pages = sum(len(s["pages"]) for s in sections) + (1 if root_page else 0)
    print(f"Found {n_pages} pages across {len(sections)} sections")

    work: list[tuple[dict, dict | None]] = []
    if root_page:
        work.append((root_page, None))
    for section in sections:
        for page in section["pages"]:
            work.append((page, section))

    n_changed = 0
    failed: list[tuple[Path, str]] = []
    for page, section in work:
        try:
            if inject_into_page(page, section, sections, root, args.asset_version):
                n_changed += 1
        except Exception as e:
            # Don't crash the whole site for one bad page — but DO track it
            # so we can surface a final summary. Silent skip-and-continue
            # was the #1 visibility gap from the audit.
            failed.append((page["path"], f"{type(e).__name__}: {e}"))
            print(f"  ERROR injecting nav into {page['path']}: {type(e).__name__}: {e}",
                  file=sys.stderr)

    print(f"Injected nav into {n_changed} page(s) (others unchanged)")

    site_map_html = render_site_map(root_page, sections, root, args.asset_version)
    atomic_write_text(root / SITE_MAP_NAME, site_map_html)
    print(f"Wrote {SITE_MAP_NAME}")

    if failed:
        print(
            f"\n⚠ {len(failed)} page(s) FAILED nav injection — they will render "
            "without the nav rail until the underlying error is fixed:",
            file=sys.stderr,
        )
        for path, err in failed:
            print(f"    {path}\n      → {err}", file=sys.stderr)
        # Exit non-zero so calling shells (site.sh, pre-push hook) see the
        # failure and can decide how loud to make it. site.sh's pipeline
        # propagates this via PIPESTATUS — no silent green-light.
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
