From d8108af898b06b6fb2887160925a35078f9e4a2a Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 6 May 2026 21:47:04 -0400 Subject: [PATCH 01/12] Record per-page build timings and write JSON --- great_docs/core.py | 122 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 115 insertions(+), 7 deletions(-) diff --git a/great_docs/core.py b/great_docs/core.py index ef48899..849f997 100644 --- a/great_docs/core.py +++ b/great_docs/core.py @@ -14,8 +14,8 @@ def _patch_griffe(): """Ensure griffe has CyclicAliasError and AliasResolutionError at top level. - Older griffe versions don't re-export these from the top-level package. - This patches them in so `griffe.CyclicAliasError` etc. work everywhere. + Older griffe versions don't re-export these from the top-level package. This patches them in so + `griffe.CyclicAliasError` etc. work everywhere. """ import griffe @@ -12552,6 +12552,79 @@ def _generate_seo_files(self) -> None: self._generate_sitemap_xml() self._generate_robots_txt() + def _write_build_timing( + self, + page_timings: list[dict[str, object]] | None = None, + timings_by_version: dict[str, list[dict[str, object]]] | None = None, + ) -> Path | None: + """ + Write `_site/build-timing.json` with per-page render durations. + + For single-version builds, *page_timings* is a flat list. For multi-version builds, + *timings_by_version* is a `{tag: [timings]}` dict. Returns the path written, or `None` if + there was nothing to write. + """ + import json + from datetime import datetime, timezone + + site_dir = self.project_path / "_site" + if not site_dir.exists(): + return None # pragma: no cover + + # Discover which pages had freeze cache entries. + # Check both the project root (where users persist _freeze/) and the + # build/project path (where it's restored during rendering). + frozen_stems: set[str] = set() + for freeze_dir in (self.project_root / "_freeze", self.project_path / "_freeze"): + if freeze_dir.is_dir(): + for html_json in freeze_dir.rglob("execute-results/html.json"): + # _freeze/user-guide/benchmarks/execute-results/html.json + # → relative stem is "user-guide/benchmarks" + rel = html_json.relative_to(freeze_dir) + # Drop the last two parts (execute-results/html.json) + stem = str(rel.parent.parent) + frozen_stems.add(stem) + + def _annotate(pages: list[dict[str, object]]) -> list[dict[str, object]]: + """Add 'frozen' boolean to each page entry.""" + for p in pages: + page_str = str(p["page"]) + # Strip .qmd/.html extension to get the stem + stem = page_str.rsplit(".", 1)[0] if "." in page_str else page_str + p["frozen"] = stem in frozen_stems + return pages + + payload: dict[str, object] = { + "build_time": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + } + + if timings_by_version: + versions_payload: dict[str, object] = {} + total_seconds = 0.0 + for tag, timings in timings_by_version.items(): + ver_total = round(sum(t["seconds"] for t in timings), 3) + total_seconds += ver_total + versions_payload[tag] = { + "seconds": ver_total, + "pages": sorted( + _annotate(timings), key=lambda t: t["seconds"], reverse=True + ), + } + payload["total_seconds"] = round(total_seconds, 3) + payload["versions"] = versions_payload + elif page_timings: + total_seconds = round(sum(t["seconds"] for t in page_timings), 3) + payload["total_seconds"] = total_seconds + payload["pages"] = sorted( + _annotate(page_timings), key=lambda t: t["seconds"], reverse=True + ) + else: + return None # pragma: no cover + + out_path = site_dir / "build-timing.json" + out_path.write_text(json.dumps(payload, indent=2) + "\n") + return out_path + def _get_cli_help_text_for_llms(self) -> str: """ Get CLI help text formatted for llms-full.txt. @@ -12732,11 +12805,11 @@ def uninstall(self) -> None: print("✅ Great-docs uninstalled successfully!") def _prepare_for_freeze(self) -> None: - """Run the build preparation steps (1–14 + normalization) without rendering. + """Run the build preparation steps (1-14 + normalization) without rendering. - This ensures the build directory contains files in exactly the same state - as a full ``build()`` would produce, so that freeze cache hashes remain valid - across subsequent full builds. + This ensures the build directory contains files in exactly the same state as a full + `build()` would produce, so that freeze cache hashes remain valid across subsequent full + builds. """ import os from contextlib import redirect_stdout @@ -13030,7 +13103,13 @@ def run_streaming( Lines starting with `##GD:PASS:` are collected into `result.passes` (a list of label strings). If *on_pass* is provided it is called with each label as it arrives. If *on_bar_done* is provided it is called once when the progress bar reaches 100%. + + Page-level timing is recorded automatically: each `[i/N] page.qmd` progress line from + Quarto is timestamped so that per-page render durations can be computed after the + process exits. """ + import time as _time_mod + process = subprocess.Popen( cmd, stdout=subprocess.PIPE, @@ -13044,7 +13123,9 @@ def run_streaming( pass_labels: list[str] = [] quarto_warnings: list[str] = [] bar_finished = [False] # mutable flag for closure - page_re = _re_build.compile(r"\[\s*(\d+)/(\d+)\]") + page_re = _re_build.compile(r"\[\s*(\d+)/(\d+)\]\s+(.+)") + # Each entry: (page_path, timestamp) + _page_timestamps: list[tuple[str, float]] = [] def _finish_bar(): """Finish the progress bar, emit warnings, and notify caller.""" @@ -13069,6 +13150,8 @@ def _handle_line(stripped): m = page_re.search(stripped) if m: cur, tot = int(m.group(1)), int(m.group(2)) + page_path = _ansi_re.sub("", m.group(3)).strip() + _page_timestamps.append((page_path, _time_mod.monotonic())) if tot != progress_bar.total: progress_bar.total = tot progress_bar.update(cur) @@ -13104,6 +13187,16 @@ def read_stderr(): process.wait() stderr_thread.join(timeout=5) + # Compute per-page durations from consecutive timestamps + page_timings: list[dict[str, Any]] = [] + for i, (page_path, ts) in enumerate(_page_timestamps): + if i + 1 < len(_page_timestamps): + duration = _page_timestamps[i + 1][1] - ts + else: + # Last page: measure until process exit + duration = _time_mod.monotonic() - ts + page_timings.append({"page": page_path, "seconds": round(duration, 3)}) + class Result: pass @@ -13112,6 +13205,7 @@ class Result: r.stderr = "".join(stderr_lines) r.stdout = "" r.passes = pass_labels + r.page_timings = page_timings return r # ── Step 1: Prepare build directory ──────────────────────────── @@ -13483,6 +13577,13 @@ def _on_renders_done() -> None: log.warn(f"Error generating SEO files: {e}") log.step_done("SEO files had issues") + # ── Write build-timing.json ──────────────────────── + timing_path = self._write_build_timing( + timings_by_version=vb_result.get("timings_by_version"), + ) + if timing_path: + log.detail(f"Wrote {timing_path.name}") + # ── Auto-save API snapshot (Strategy C) ──────────── try: self._auto_save_snapshot() @@ -13565,6 +13666,13 @@ def _on_pass(label): log.warn(f"Error generating SEO files: {e}") # pragma: no cover log.step_done("SEO files had issues") # pragma: no cover + # ── Write build-timing.json ──────────────────────── + timing_path = self._write_build_timing( # pragma: no cover + page_timings=result.page_timings, # pragma: no cover + ) # pragma: no cover + if timing_path: # pragma: no cover + log.detail(f"Wrote {timing_path.name}") # pragma: no cover + # ── Auto-save API snapshot (Strategy C) ──────────── if self._config.has_versions: # pragma: no cover try: # pragma: no cover From dd1aecde14c7defc4f6858748ccda6f5415b3abf Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 6 May 2026 21:47:53 -0400 Subject: [PATCH 02/12] Add timing command to show build timings --- great_docs/cli.py | 169 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/great_docs/cli.py b/great_docs/cli.py index d088963..1c4cf8a 100644 --- a/great_docs/cli.py +++ b/great_docs/cli.py @@ -1003,6 +1003,175 @@ def freeze( cli.add_command(freeze) +# --------------------------------------------------------------------------- +# great-docs timing +# --------------------------------------------------------------------------- + + +def _format_seconds(s: float) -> str: + """Format seconds as a human-readable duration string.""" + if s < 60: + return f"{s:.1f}s" + m = int(s) // 60 + sec = s - m * 60 + return f"{m}m {sec:.1f}s" + + +def _find_build_timing(project_path: Path) -> Path | None: + """Locate build-timing.json in the site output directory.""" + # Multi-version: built into great-docs/_site/ + candidate = project_path / "great-docs" / "_site" / "build-timing.json" + if candidate.exists(): + return candidate + # Single-version or build dir fallback + candidate = project_path / "_site" / "build-timing.json" + if candidate.exists(): + return candidate + return None + + +def _print_timing_table(data: dict, top: int | None, version_filter: str | None) -> None: + """Print an ASCII table from build-timing.json data.""" + + build_time = data.get("build_time", "unknown") + total = data.get("total_seconds", 0) + + click.echo() + click.echo(f" Build time: {build_time}") + click.echo(f" Total: {_format_seconds(total)}") + click.echo() + + if "versions" in data: + versions = data["versions"] + if version_filter: + if version_filter not in versions: + click.echo(f" Version '{version_filter}' not found.", err=True) + click.echo(f" Available: {', '.join(sorted(versions.keys()))}", err=True) + sys.exit(1) + versions = {version_filter: versions[version_filter]} + + for tag, vdata in versions.items(): + pages = vdata["pages"] + if top: + pages = pages[:top] + click.echo(f" Version: {tag} ({_format_seconds(vdata['seconds'])})") + _print_page_table(pages) + click.echo() + elif "pages" in data: + pages = data["pages"] + if top: + pages = pages[:top] + _print_page_table(pages) + click.echo() + + +def _print_page_table(pages: list[dict]) -> None: + """Print a page timing table.""" + if not pages: + click.echo(" No page timings recorded.") + return + + # Check if any pages have freeze info + has_frozen = any(p.get("frozen") for p in pages) + + # Compute column widths + max_page = max(len(p["page"]) for p in pages) + max_page = max(max_page, 4) # minimum width for "Page" header + time_col = 10 # width for time column + bar_col = 20 # width for bar chart + + slowest = pages[0]["seconds"] if pages else 1 + + # Header + if has_frozen: + header = f" {'Page':<{max_page}} {'Time':>{time_col}} Bar" + click.echo(header) + click.echo(f" {'─' * max_page} {'─' * time_col} {'─' * (bar_col + 2)}") + else: + header = f" {'Page':<{max_page}} {'Time':>{time_col}} Bar" + click.echo(header) + click.echo(f" {'─' * max_page} {'─' * time_col} {'─' * bar_col}") + + # Rows + for p in pages: + page = p["page"] + secs = p["seconds"] + time_str = _format_seconds(secs) + bar_len = int((secs / slowest) * bar_col) if slowest > 0 else 0 + bar = "█" * bar_len + if has_frozen: + marker = "❄" if p.get("frozen") else " " + click.echo(f" {page:<{max_page}} {time_str:>{time_col}} {marker} {bar}") + else: + click.echo(f" {page:<{max_page}} {time_str:>{time_col}} {bar}") + + if has_frozen: + click.echo() + click.echo(" ❄ = served from freeze cache") + + +@click.command() +@click.option( + "--project-path", + type=click.Path(exists=True, file_okay=False, dir_okay=True), + help="Path to your project root directory (default: current directory)", +) +@click.option( + "--top", + type=int, + default=None, + help="Show only the N slowest pages.", +) +@click.option( + "--version", + "version_filter", + type=str, + default=None, + help="Show timings for a specific version only (multi-version builds).", +) +@click.option( + "--json", + "output_json", + is_flag=True, + help="Output raw JSON instead of a table.", +) +def timing(project_path, top, version_filter, output_json): + """Show page-level build timings from the last build. + + Reads the build-timing.json artifact generated during 'great-docs build' and + displays per-page render durations as a sorted table. Pages are listed + slowest-first to help identify bottlenecks. + + Run 'great-docs build' first to generate the timing data. + + Examples: + great-docs timing + great-docs timing --top 10 + great-docs timing --version 0.10 + great-docs timing --json + """ + import json + + project_root = Path(project_path) if project_path else Path.cwd() + timing_path = _find_build_timing(project_root) + + if not timing_path: + click.echo("No build-timing.json found.", err=True) + click.echo("Run 'great-docs build' first to generate timing data.", err=True) + sys.exit(1) + + data = json.loads(timing_path.read_text()) + + if output_json: + click.echo(json.dumps(data, indent=2)) + return + + _print_timing_table(data, top=top, version_filter=version_filter) + + +cli.add_command(timing) + + @click.command(name="setup-github-pages") @click.option( "--project-path", From 0029fa7af18299d6928bdc7d71a939cb5a88d1c9 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 6 May 2026 21:48:47 -0400 Subject: [PATCH 03/12] Collect per-page Quarto render timings --- great_docs/_versioned_build.py | 67 +++++++++++++++++++++++----------- 1 file changed, 46 insertions(+), 21 deletions(-) diff --git a/great_docs/_versioned_build.py b/great_docs/_versioned_build.py index e846509..a01be36 100644 --- a/great_docs/_versioned_build.py +++ b/great_docs/_versioned_build.py @@ -1080,8 +1080,8 @@ def expand_version_badges( expiry: "BadgeExpiry | None" = None, ) -> str: """ - Expand `[version-badge new]` and `[version-badge changed 0.3]` inline markers into HTML - `` badges. + Expand `[version-badge new]` and `[version-badge changed 0.3]` inline markers into HTML `` + badges. If no version is specified in the marker, the current entry's label is used. @@ -1303,7 +1303,7 @@ def _rewrite_quarto_yml_for_version( # Stage 2: Parallel Quarto renders # --------------------------------------------------------------------------- -_PAGE_RE = re.compile(r"\[\s*(\d+)/(\d+)\]") +_PAGE_RE = re.compile(r"\[\s*(\d+)/(\d+)\]\s+(.+)") def _render_single_version( @@ -1340,14 +1340,16 @@ def _render_single_version_streaming( build_dir: str, env_vars: dict[str, str] | None, on_progress: Callable[[int, int], None] | None = None, -) -> tuple[str, int, str, str]: +) -> tuple[str, int, str, str, list[dict[str, Any]]]: """ Render a single version with streaming progress. - Like :func:`_render_single_version` but streams stderr to parse Quarto `[cur/total]` progress - lines and calls *on_progress(current, total)* for each update. Returns the same - `(build_dir, returncode, stdout, stderr)` tuple. + Like :func:`_render_single_version` but streams stderr to parse Quarto `[cur/total] page` + progress lines and calls *on_progress(current, total)* for each update. Returns + `(build_dir, returncode, stdout, stderr, page_timings)`. """ + import time as _time_mod + env = os.environ.copy() if env_vars: env.update(env_vars) @@ -1363,16 +1365,21 @@ def _render_single_version_streaming( bufsize=1, ) except Exception as e: - return (build_dir, -1, "", str(e)) + return (build_dir, -1, "", str(e), []) stderr_lines: list[str] = [] + # Each entry: (page_path, timestamp) + _page_timestamps: list[tuple[str, float]] = [] + _ansi_re = re.compile(r"\033\[[0-9;]*m") def _read_stderr(): for line in proc.stderr: # type: ignore[union-attr] stderr_lines.append(line) - if on_progress: - m = _PAGE_RE.search(line) - if m: + m = _PAGE_RE.search(line) + if m: + page_path = _ansi_re.sub("", m.group(3)).strip() + _page_timestamps.append((page_path, _time_mod.monotonic())) + if on_progress: on_progress(int(m.group(1)), int(m.group(2))) stderr_thread = threading.Thread(target=_read_stderr, daemon=True) @@ -1382,7 +1389,16 @@ def _read_stderr(): proc.wait() stderr_thread.join(timeout=10) - return (build_dir, proc.returncode, stdout_data, "".join(stderr_lines)) + # Compute per-page durations from consecutive timestamps + page_timings: list[dict[str, Any]] = [] + for i, (page_path, ts) in enumerate(_page_timestamps): + if i + 1 < len(_page_timestamps): + duration = _page_timestamps[i + 1][1] - ts + else: + duration = _time_mod.monotonic() - ts + page_timings.append({"page": page_path, "seconds": round(duration, 3)}) + + return (build_dir, proc.returncode, stdout_data, "".join(stderr_lines), page_timings) def render_versions_parallel( @@ -1390,7 +1406,7 @@ def render_versions_parallel( env_vars: dict[str, str] | None = None, max_workers: int | None = None, progress_callback: Callable[[int, int, int], None] | None = None, -) -> list[tuple[str, int, str, str]]: +) -> list[tuple[str, int, str, str, list[dict[str, Any]]]]: """ Run `quarto render` in parallel for each version build directory. @@ -1408,32 +1424,36 @@ def render_versions_parallel( Returns ------- - list[tuple[str, int, str, str]] - List of `(build_dir, returncode, stdout, stderr)` tuples in the same order as `build_dirs`. + list[tuple[str, int, str, str, list[dict[str, Any]]]] + List of `(build_dir, returncode, stdout, stderr, page_timings)` tuples in the same order + as `build_dirs`. """ if max_workers is None: max_workers = min(os.cpu_count() or 4, 4) if progress_callback is None: # Original fire-and-forget mode (ProcessPoolExecutor) - results: list[tuple[str, int, str, str]] = [] + results: list[tuple[str, int, str, str, list[dict[str, Any]]]] = [] if len(build_dirs) == 1: r = _render_single_version(str(build_dirs[0]), env_vars) - results.append(r) + # Non-streaming mode has no page timings + results.append((*r, [])) return results with ProcessPoolExecutor(max_workers=max_workers) as pool: futures = {pool.submit(_render_single_version, str(d), env_vars): d for d in build_dirs} for future in as_completed(futures): - results.append(future.result()) + results.append((*future.result(), [])) return results # Streaming mode: use threads so callbacks can update the parent process. dir_to_idx = {str(d): i for i, d in enumerate(build_dirs)} - ordered_results: list[tuple[str, int, str, str] | None] = [None] * len(build_dirs) + ordered_results: list[tuple[str, int, str, str, list[dict[str, Any]]] | None] = [None] * len( + build_dirs + ) - def _run(build_dir: Path) -> tuple[str, int, str, str]: + def _run(build_dir: Path) -> tuple[str, int, str, str, list[dict[str, Any]]]: idx = dir_to_idx[str(build_dir)] def _on_progress(current: int, total: int) -> None: @@ -1712,14 +1732,17 @@ def run_versioned_build( errors: list[str] = [] versions_built: list[str] = [] + timings_by_version: dict[str, list[dict[str, Any]]] = {} # Map build dir back to version tag dir_to_tag = {str(_version_build_dir(build_root, e, latest_tag)): e.tag for e in targets} - for build_dir, returncode, stdout, stderr in render_results: + for build_dir, returncode, stdout, stderr, page_timings in render_results: tag = dir_to_tag.get(build_dir, build_dir) if returncode == 0: versions_built.append(tag) + if page_timings: + timings_by_version[tag] = page_timings else: errors.append(f"Version {tag}: Quarto render failed (exit {returncode})\n{stderr}") @@ -1732,6 +1755,7 @@ def run_versioned_build( "success": False, "versions_built": [], "pages_by_version": pages_by_version, + "timings_by_version": {}, "errors": errors, } @@ -1752,6 +1776,7 @@ def run_versioned_build( "success": len(errors) == 0, "versions_built": versions_built, "pages_by_version": pages_by_version, + "timings_by_version": timings_by_version, "errors": errors, } From 599641d153f061129df2d54ba8c90f515a85ef6e Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 6 May 2026 21:49:18 -0400 Subject: [PATCH 04/12] Update post-render.py --- great_docs/assets/post-render.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/great_docs/assets/post-render.py b/great_docs/assets/post-render.py index ffdf4fd..10870a1 100644 --- a/great_docs/assets/post-render.py +++ b/great_docs/assets/post-render.py @@ -1518,13 +1518,13 @@ def translate_sphinx_roles(html_content): """ Convert Sphinx cross-reference roles into clean HTML. - The renderer sometimes passes through Sphinx-style roles verbatim. The most - common rendered patterns are: + The renderer sometimes passes through Sphinx-style roles verbatim. The most common rendered + patterns are: * `:py:exc:ValueError` -> `ValueError` * `:class:Foo` -> `Foo` * `:func:bar` -> `bar()` - * `:func:`bar``` (inside `
`)  ->  `bar()`
+    * `:func:`bar``  (inside `
`)   ->  `bar()`
 
     For *function* and *method* roles the name gets trailing `()` so the reader can tell it is
     callable.

From fed5df508aee04c329d270de031646da53f453ef Mon Sep 17 00:00:00 2001
From: Richard Iannone 
Date: Wed, 6 May 2026 21:49:44 -0400
Subject: [PATCH 05/12] Create test_build_timing.py

---
 tests/test_build_timing.py | 315 +++++++++++++++++++++++++++++++++++++
 1 file changed, 315 insertions(+)
 create mode 100644 tests/test_build_timing.py

diff --git a/tests/test_build_timing.py b/tests/test_build_timing.py
new file mode 100644
index 0000000..346e161
--- /dev/null
+++ b/tests/test_build_timing.py
@@ -0,0 +1,315 @@
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+
+# ---------------------------------------------------------------------------
+# Regex: matches Quarto progress lines and captures page path
+# ---------------------------------------------------------------------------
+
+_PAGE_RE = re.compile(r"\[\s*(\d+)/(\d+)\]\s+(.+)")
+
+
+class TestPageRegex:
+    """Ensure the regex captures page path from Quarto progress lines."""
+
+    def test_basic_line(self):
+        line = "[  1/42] user-guide/overview.html"
+        m = _PAGE_RE.search(line)
+        assert m
+        assert m.group(1) == "1"
+        assert m.group(2) == "42"
+        assert m.group(3).strip() == "user-guide/overview.html"
+
+    def test_double_digit(self):
+        line = "[ 12/42] reference/GT.html"
+        m = _PAGE_RE.search(line)
+        assert m
+        assert m.group(1) == "12"
+        assert m.group(3).strip() == "reference/GT.html"
+
+    def test_no_leading_space(self):
+        line = "[3/5] recipes/freeze-demo.html"
+        m = _PAGE_RE.search(line)
+        assert m
+        assert m.group(1) == "3"
+        assert m.group(2) == "5"
+        assert m.group(3).strip() == "recipes/freeze-demo.html"
+
+    def test_non_matching_line(self):
+        line = "WARN: something went wrong"
+        m = _PAGE_RE.search(line)
+        assert m is None
+
+    def test_line_with_ansi(self):
+        # Quarto sometimes emits ANSI color codes around the line
+        line = "\x1b[32m[  5/10] user-guide/config.html\x1b[0m"
+        m = _PAGE_RE.search(line)
+        assert m
+        assert m.group(1) == "5"
+        assert m.group(2) == "10"
+
+
+# ---------------------------------------------------------------------------
+# _write_build_timing
+# ---------------------------------------------------------------------------
+
+
+class TestWriteBuildTiming:
+    """Test the _write_build_timing method writes correct JSON."""
+
+    def _make_gd(self, tmp_path: Path):
+        """Create a minimal GreatDocs instance with a project_path pointing to tmp_path."""
+        from great_docs.core import GreatDocs
+
+        # Create a minimal great-docs.yml so Config doesn't fail
+        (tmp_path / "great-docs.yml").write_text("name: test-pkg\n")
+        gd = GreatDocs.__new__(GreatDocs)
+        gd.project_path = tmp_path
+        gd.project_root = tmp_path
+        return gd
+
+    def test_single_version_flat(self, tmp_path: Path):
+        gd = self._make_gd(tmp_path)
+        site_dir = tmp_path / "_site"
+        site_dir.mkdir()
+
+        timings = [
+            {"page": "user-guide/overview.html", "seconds": 1.2},
+            {"page": "user-guide/benchmarks.html", "seconds": 28.4},
+            {"page": "reference/GT.html", "seconds": 2.1},
+        ]
+
+        result = gd._write_build_timing(page_timings=timings)
+        assert result is not None
+        assert result.name == "build-timing.json"
+
+        data = json.loads(result.read_text())
+        assert "build_time" in data
+        assert data["total_seconds"] == pytest.approx(31.7, abs=0.01)
+        # Pages should be sorted by seconds descending
+        assert data["pages"][0]["page"] == "user-guide/benchmarks.html"
+        assert data["pages"][-1]["page"] == "user-guide/overview.html"
+        assert len(data["pages"]) == 3
+        # No versions key for single-version
+        assert "versions" not in data
+
+    def test_multi_version(self, tmp_path: Path):
+        gd = self._make_gd(tmp_path)
+        site_dir = tmp_path / "_site"
+        site_dir.mkdir()
+
+        timings_by_version = {
+            "0.10": [
+                {"page": "user-guide/overview.html", "seconds": 1.2},
+                {"page": "user-guide/benchmarks.html", "seconds": 28.4},
+            ],
+            "0.9": [
+                {"page": "user-guide/overview.html", "seconds": 0.9},
+                {"page": "reference/GT.html", "seconds": 1.8},
+            ],
+        }
+
+        result = gd._write_build_timing(timings_by_version=timings_by_version)
+        assert result is not None
+
+        data = json.loads(result.read_text())
+        assert "build_time" in data
+        assert data["total_seconds"] == pytest.approx(32.3, abs=0.01)
+        assert "versions" in data
+        assert set(data["versions"].keys()) == {"0.10", "0.9"}
+
+        v010 = data["versions"]["0.10"]
+        assert v010["seconds"] == pytest.approx(29.6, abs=0.01)
+        assert len(v010["pages"]) == 2
+        # Sorted descending by seconds
+        assert v010["pages"][0]["page"] == "user-guide/benchmarks.html"
+
+        v09 = data["versions"]["0.9"]
+        assert v09["seconds"] == pytest.approx(2.7, abs=0.01)
+        assert len(v09["pages"]) == 2
+
+    def test_no_site_dir_returns_none(self, tmp_path: Path):
+        gd = self._make_gd(tmp_path)
+        # No _site/ directory
+        result = gd._write_build_timing(page_timings=[{"page": "x.html", "seconds": 1.0}])
+        assert result is None
+
+    def test_no_timings_returns_none(self, tmp_path: Path):
+        gd = self._make_gd(tmp_path)
+        site_dir = tmp_path / "_site"
+        site_dir.mkdir()
+        result = gd._write_build_timing()
+        assert result is None
+
+    def test_empty_timings_returns_none(self, tmp_path: Path):
+        gd = self._make_gd(tmp_path)
+        site_dir = tmp_path / "_site"
+        site_dir.mkdir()
+        result = gd._write_build_timing(page_timings=[])
+        assert result is None
+
+    def test_build_time_is_utc_iso(self, tmp_path: Path):
+        gd = self._make_gd(tmp_path)
+        site_dir = tmp_path / "_site"
+        site_dir.mkdir()
+
+        timings = [{"page": "index.html", "seconds": 0.5}]
+        result = gd._write_build_timing(page_timings=timings)
+        data = json.loads(result.read_text())
+
+        # Should be ISO 8601 UTC format
+        bt = data["build_time"]
+        assert bt.endswith("Z")
+        assert "T" in bt
+
+    def test_frozen_annotation_single_version(self, tmp_path: Path):
+        gd = self._make_gd(tmp_path)
+        site_dir = tmp_path / "_site"
+        site_dir.mkdir()
+
+        # Create a _freeze/ entry for one page
+        freeze_entry = tmp_path / "_freeze" / "recipes" / "freeze-demo" / "execute-results"
+        freeze_entry.mkdir(parents=True)
+        (freeze_entry / "html.json").write_text("{}")
+
+        timings = [
+            {"page": "recipes/freeze-demo.qmd", "seconds": 0.3},
+            {"page": "user-guide/overview.qmd", "seconds": 1.2},
+        ]
+
+        result = gd._write_build_timing(page_timings=timings)
+        data = json.loads(result.read_text())
+
+        pages_by_name = {p["page"]: p for p in data["pages"]}
+        assert pages_by_name["recipes/freeze-demo.qmd"]["frozen"] is True
+        assert pages_by_name["user-guide/overview.qmd"]["frozen"] is False
+
+    def test_frozen_annotation_multi_version(self, tmp_path: Path):
+        gd = self._make_gd(tmp_path)
+        site_dir = tmp_path / "_site"
+        site_dir.mkdir()
+
+        # Create a _freeze/ entry
+        freeze_entry = tmp_path / "_freeze" / "user-guide" / "benchmarks" / "execute-results"
+        freeze_entry.mkdir(parents=True)
+        (freeze_entry / "html.json").write_text("{}")
+
+        timings_by_version = {
+            "0.10": [
+                {"page": "user-guide/benchmarks.qmd", "seconds": 0.5},
+                {"page": "user-guide/overview.qmd", "seconds": 1.2},
+            ],
+        }
+
+        result = gd._write_build_timing(timings_by_version=timings_by_version)
+        data = json.loads(result.read_text())
+
+        pages = data["versions"]["0.10"]["pages"]
+        pages_by_name = {p["page"]: p for p in pages}
+        assert pages_by_name["user-guide/benchmarks.qmd"]["frozen"] is True
+        assert pages_by_name["user-guide/overview.qmd"]["frozen"] is False
+
+    def test_no_freeze_dir_all_false(self, tmp_path: Path):
+        gd = self._make_gd(tmp_path)
+        site_dir = tmp_path / "_site"
+        site_dir.mkdir()
+
+        timings = [{"page": "index.qmd", "seconds": 0.5}]
+        result = gd._write_build_timing(page_timings=timings)
+        data = json.loads(result.read_text())
+        assert data["pages"][0]["frozen"] is False
+
+
+# ---------------------------------------------------------------------------
+# Timing computation from timestamps (unit logic)
+# ---------------------------------------------------------------------------
+
+
+class TestTimingComputation:
+    """Test the delta-computation logic used in both paths."""
+
+    def test_consecutive_timestamps_give_deltas(self):
+        """Simulate the timing computation from _page_timestamps."""
+        import time
+
+        # Simulate: 3 pages at known intervals
+        base = time.monotonic()
+        _page_timestamps = [
+            ("page-a.html", base),
+            ("page-b.html", base + 1.5),
+            ("page-c.html", base + 3.0),
+        ]
+
+        page_timings = []
+        # Use a fixed "end" time for the last page
+        end_time = base + 4.2
+        for i, (page_path, ts) in enumerate(_page_timestamps):
+            if i + 1 < len(_page_timestamps):
+                duration = _page_timestamps[i + 1][1] - ts
+            else:
+                duration = end_time - ts
+            page_timings.append({"page": page_path, "seconds": round(duration, 3)})
+
+        assert page_timings[0] == {"page": "page-a.html", "seconds": 1.5}
+        assert page_timings[1] == {"page": "page-b.html", "seconds": 1.5}
+        assert page_timings[2] == {"page": "page-c.html", "seconds": 1.2}
+
+    def test_single_page(self):
+        """A single page gets the full elapsed time."""
+        import time
+
+        base = time.monotonic()
+        _page_timestamps = [("only-page.html", base)]
+        end_time = base + 5.0
+
+        page_timings = []
+        for i, (page_path, ts) in enumerate(_page_timestamps):
+            if i + 1 < len(_page_timestamps):
+                duration = _page_timestamps[i + 1][1] - ts
+            else:
+                duration = end_time - ts
+            page_timings.append({"page": page_path, "seconds": round(duration, 3)})
+
+        assert page_timings == [{"page": "only-page.html", "seconds": 5.0}]
+
+
+# ---------------------------------------------------------------------------
+# Versioned build: page_timings in result tuple
+# ---------------------------------------------------------------------------
+
+
+class TestVersionedBuildTimings:
+    """Verify render_versions_parallel returns page_timings in result tuples."""
+
+    def test_non_streaming_returns_empty_timings(self, tmp_path: Path):
+        """Non-streaming (ProcessPoolExecutor) mode returns empty page_timings list."""
+        from great_docs._versioned_build import _render_single_version
+
+        # Create a fake build dir that will fail (no quarto project)
+        fake_dir = tmp_path / "fake"
+        fake_dir.mkdir()
+        (fake_dir / "_quarto.yml").write_text("project:\n  type: website\n")
+
+        # This will fail since there's nothing to render, but we can check the tuple length
+        result = _render_single_version(str(fake_dir), None)
+        # Non-streaming returns 4-tuple (no timings)
+        assert len(result) == 4
+
+    def test_streaming_returns_5_tuple(self, tmp_path: Path):
+        """Streaming mode returns 5-tuple with page_timings as last element."""
+        from great_docs._versioned_build import _render_single_version_streaming
+
+        fake_dir = tmp_path / "fake"
+        fake_dir.mkdir()
+        (fake_dir / "_quarto.yml").write_text("project:\n  type: website\n")
+
+        result = _render_single_version_streaming(str(fake_dir), None)
+        assert len(result) == 5
+        # Last element is page_timings (list)
+        assert isinstance(result[4], list)

From 2b7013f8ad2d39c0b9a98ff5f6b7be089efb08a1 Mon Sep 17 00:00:00 2001
From: Richard Iannone 
Date: Wed, 6 May 2026 21:50:09 -0400
Subject: [PATCH 06/12] Update test_versioned_build.py

---
 tests/test_versioned_build.py | 14 +++++++-------
 1 file changed, 7 insertions(+), 7 deletions(-)

diff --git a/tests/test_versioned_build.py b/tests/test_versioned_build.py
index abc3aa8..7b0628f 100644
--- a/tests/test_versioned_build.py
+++ b/tests/test_versioned_build.py
@@ -2620,7 +2620,7 @@ def mock_render(build_dirs, env_vars=None, max_workers=None, progress_callback=N
                 site_dir = d / "_site"
                 site_dir.mkdir(parents=True, exist_ok=True)
                 (site_dir / "index.html").write_text("hi")
-                results.append((str(d), 0, "", ""))
+                results.append((str(d), 0, "", "", []))
             return results
 
         with patch(
@@ -2654,7 +2654,7 @@ def mock_render(build_dirs, **kwargs):
                 site_dir = d / "_site"
                 site_dir.mkdir(parents=True, exist_ok=True)
                 (site_dir / "index.html").write_text("")
-                results.append((str(d), 0, "", ""))
+                results.append((str(d), 0, "", "", []))
             return results
 
         with patch(
@@ -2686,7 +2686,7 @@ def mock_render(build_dirs, **kwargs):
                 site_dir = d / "_site"
                 site_dir.mkdir(parents=True, exist_ok=True)
                 (site_dir / "index.html").write_text("")
-                results.append((str(d), 0, "", ""))
+                results.append((str(d), 0, "", "", []))
             return results
 
         with patch(
@@ -2714,7 +2714,7 @@ def test_render_failure_reported(self, tmp_path: Path):
 
         def mock_render(build_dirs, **kwargs):
             # All versions fail
-            return [(str(d), 1, "", "ERROR: something broke") for d in build_dirs]
+            return [(str(d), 1, "", "ERROR: something broke", []) for d in build_dirs]
 
         with patch(
             "great_docs._versioned_build.render_versions_parallel",
@@ -2748,7 +2748,7 @@ def mock_render(build_dirs, **kwargs):
                 site_dir = d / "_site"
                 site_dir.mkdir(parents=True, exist_ok=True)
                 (site_dir / "index.html").write_text("")
-                results.append((str(d), 0, "", ""))
+                results.append((str(d), 0, "", "", []))
             return results
 
         with patch(
@@ -2782,7 +2782,7 @@ def mock_render(build_dirs, **kwargs):
                 site_dir = d / "_site"
                 site_dir.mkdir(parents=True, exist_ok=True)
                 (site_dir / "index.html").write_text("")
-                results.append((str(d), 0, "", ""))
+                results.append((str(d), 0, "", "", []))
             return results
 
         with patch(
@@ -3263,7 +3263,7 @@ def test_single_dir_no_callback(self, tmp_path: Path):
             results = render_versions_parallel([d1])  # No callback, single dir
 
         assert len(results) == 1
-        assert results[0] == (str(d1), 0, "output", "")
+        assert results[0] == (str(d1), 0, "output", "", [])
 
 
 # ---------------------------------------------------------------------------

From 2eef58bc6c7f9f33499fe1ac7c656188b63c42d1 Mon Sep 17 00:00:00 2001
From: Richard Iannone 
Date: Thu, 7 May 2026 00:02:15 -0400
Subject: [PATCH 07/12] Use build-timings.json and add CLI output-dir

---
 great_docs/cli.py  | 42 ++++++++++++++++++++++++++++--------------
 great_docs/core.py |  8 ++++----
 2 files changed, 32 insertions(+), 18 deletions(-)

diff --git a/great_docs/cli.py b/great_docs/cli.py
index 1c4cf8a..2d2e6ee 100644
--- a/great_docs/cli.py
+++ b/great_docs/cli.py
@@ -1017,21 +1017,26 @@ def _format_seconds(s: float) -> str:
     return f"{m}m {sec:.1f}s"
 
 
-def _find_build_timing(project_path: Path) -> Path | None:
-    """Locate build-timing.json in the site output directory."""
+def _find_build_timing(project_path: Path, output_dir: Path | None = None) -> Path | None:
+    """Locate build-timings.json in the site output directory."""
+    # Explicit output-dir takes priority
+    if output_dir is not None:
+        candidate = output_dir / "build-timings.json"
+        if candidate.exists():
+            return candidate
     # Multi-version: built into great-docs/_site/
-    candidate = project_path / "great-docs" / "_site" / "build-timing.json"
+    candidate = project_path / "great-docs" / "_site" / "build-timings.json"
     if candidate.exists():
         return candidate
     # Single-version or build dir fallback
-    candidate = project_path / "_site" / "build-timing.json"
+    candidate = project_path / "_site" / "build-timings.json"
     if candidate.exists():
         return candidate
     return None
 
 
 def _print_timing_table(data: dict, top: int | None, version_filter: str | None) -> None:
-    """Print an ASCII table from build-timing.json data."""
+    """Print an ASCII table from build-timings.json data."""
 
     build_time = data.get("build_time", "unknown")
     total = data.get("total_seconds", 0)
@@ -1135,28 +1140,37 @@ def _print_page_table(pages: list[dict]) -> None:
     is_flag=True,
     help="Output raw JSON instead of a table.",
 )
-def timing(project_path, top, version_filter, output_json):
+@click.option(
+    "--output-dir",
+    type=click.Path(exists=True, file_okay=False, dir_okay=True),
+    default=None,
+    help="Path to the build output directory (if different from default _site).",
+)
+def timings(project_path, top, version_filter, output_json, output_dir):
     """Show page-level build timings from the last build.
 
-    Reads the build-timing.json artifact generated during 'great-docs build' and
+    Reads the build-timings.json artifact generated during 'great-docs build' and
     displays per-page render durations as a sorted table. Pages are listed
     slowest-first to help identify bottlenecks.
 
     Run 'great-docs build' first to generate the timing data.
 
+    \b
     Examples:
-      great-docs timing
-      great-docs timing --top 10
-      great-docs timing --version 0.10
-      great-docs timing --json
+      great-docs timings
+      great-docs timings --top 10
+      great-docs timings --version 0.10
+      great-docs timings --output-dir ./public
+      great-docs timings --json
     """
     import json
 
     project_root = Path(project_path) if project_path else Path.cwd()
-    timing_path = _find_build_timing(project_root)
+    out_dir = Path(output_dir) if output_dir else None
+    timing_path = _find_build_timing(project_root, output_dir=out_dir)
 
     if not timing_path:
-        click.echo("No build-timing.json found.", err=True)
+        click.echo("No build-timings.json found.", err=True)
         click.echo("Run 'great-docs build' first to generate timing data.", err=True)
         sys.exit(1)
 
@@ -1169,7 +1183,7 @@ def timing(project_path, top, version_filter, output_json):
     _print_timing_table(data, top=top, version_filter=version_filter)
 
 
-cli.add_command(timing)
+cli.add_command(timings)
 
 
 @click.command(name="setup-github-pages")
diff --git a/great_docs/core.py b/great_docs/core.py
index 849f997..7ddc682 100644
--- a/great_docs/core.py
+++ b/great_docs/core.py
@@ -12558,7 +12558,7 @@ def _write_build_timing(
         timings_by_version: dict[str, list[dict[str, object]]] | None = None,
     ) -> Path | None:
         """
-        Write `_site/build-timing.json` with per-page render durations.
+        Write `_site/build-timings.json` with per-page render durations.
 
         For single-version builds, *page_timings* is a flat list. For multi-version builds,
         *timings_by_version* is a `{tag: [timings]}` dict. Returns the path written, or `None` if
@@ -12621,7 +12621,7 @@ def _annotate(pages: list[dict[str, object]]) -> list[dict[str, object]]:
         else:
             return None  # pragma: no cover
 
-        out_path = site_dir / "build-timing.json"
+        out_path = site_dir / "build-timings.json"
         out_path.write_text(json.dumps(payload, indent=2) + "\n")
         return out_path
 
@@ -13577,7 +13577,7 @@ def _on_renders_done() -> None:
                     log.warn(f"Error generating SEO files: {e}")
                     log.step_done("SEO files had issues")
 
-                # ── Write build-timing.json ────────────────────────
+                # ── Write build-timings.json ───────────────────────
                 timing_path = self._write_build_timing(
                     timings_by_version=vb_result.get("timings_by_version"),
                 )
@@ -13666,7 +13666,7 @@ def _on_pass(label):
                         log.warn(f"Error generating SEO files: {e}")  # pragma: no cover
                         log.step_done("SEO files had issues")  # pragma: no cover
 
-                    # ── Write build-timing.json ────────────────────────
+                    # ── Write build-timings.json ───────────────────────
                     timing_path = self._write_build_timing(  # pragma: no cover
                         page_timings=result.page_timings,  # pragma: no cover
                     )  # pragma: no cover

From 314adb482990335f128a81245595d3d318f9ad89 Mon Sep 17 00:00:00 2001
From: Richard Iannone 
Date: Thu, 7 May 2026 00:02:23 -0400
Subject: [PATCH 08/12] Update test_build_timing.py

---
 tests/test_build_timing.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tests/test_build_timing.py b/tests/test_build_timing.py
index 346e161..c934a04 100644
--- a/tests/test_build_timing.py
+++ b/tests/test_build_timing.py
@@ -87,7 +87,7 @@ def test_single_version_flat(self, tmp_path: Path):
 
         result = gd._write_build_timing(page_timings=timings)
         assert result is not None
-        assert result.name == "build-timing.json"
+        assert result.name == "build-timings.json"
 
         data = json.loads(result.read_text())
         assert "build_time" in data

From 31acaa6dad7771a842618cf896f2cf7548dd6f82 Mon Sep 17 00:00:00 2001
From: Richard Iannone 
Date: Thu, 7 May 2026 00:02:27 -0400
Subject: [PATCH 09/12] Update 04-writing-docstrings.qmd

---
 user_guide/04-writing-docstrings.qmd | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/user_guide/04-writing-docstrings.qmd b/user_guide/04-writing-docstrings.qmd
index 237c431..04bea17 100644
--- a/user_guide/04-writing-docstrings.qmd
+++ b/user_guide/04-writing-docstrings.qmd
@@ -251,7 +251,7 @@ guidelines help you get the most from them while keeping your builds fast and re
   configuration, use a hidden code cell at the top of the Examples section. Mark it with
   `#| echo: false` and `#| output: false` so readers see only the meaningful examples:
 
-    ```python
+    ````python
     """
     Examples
     --------
@@ -269,7 +269,7 @@ guidelines help you get the most from them while keeping your builds fast and re
     result
     ```
     """
-    ```
+    ````
 
 - **Add prose between code cells.** Docstring examples don't have to be just code. Intersperse
   explanatory paragraphs that guide readers through what each example demonstrates and what the

From cf4b4a78fc4cfe22c91254ee501071ca7ceb8753 Mon Sep 17 00:00:00 2001
From: Richard Iannone 
Date: Thu, 7 May 2026 00:23:48 -0400
Subject: [PATCH 10/12] Upload build-timings artifact in docs workflow

---
 .github/workflows/docs.yml                     | 6 ++++++
 great_docs/assets/github-workflow-template.yml | 6 ++++++
 2 files changed, 12 insertions(+)

diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 76b5494..efcdc1b 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -37,6 +37,12 @@ jobs:
           name: docs-html
           path: great-docs/_site
 
+      - name: Upload build timings
+        uses: actions/upload-artifact@v7
+        with:
+          name: build-timings
+          path: great-docs/_site/build-timings.json
+
   publish-docs:
     name: "Publish Docs"
     runs-on: ubuntu-latest
diff --git a/great_docs/assets/github-workflow-template.yml b/great_docs/assets/github-workflow-template.yml
index 472de9f..4989b9e 100644
--- a/great_docs/assets/github-workflow-template.yml
+++ b/great_docs/assets/github-workflow-template.yml
@@ -35,6 +35,12 @@ jobs:
           name: docs-html
           path: great-docs/_site
 
+      - name: Upload build timings
+        uses: actions/upload-artifact@v7
+        with:
+          name: build-timings
+          path: great-docs/_site/build-timings.json
+
   publish-docs:
     name: "Publish Docs"
     runs-on: ubuntu-latest

From 30a583650c903e31a814495bf03a57f42b3ac1ba Mon Sep 17 00:00:00 2001
From: Richard Iannone 
Date: Thu, 7 May 2026 00:27:21 -0400
Subject: [PATCH 11/12] Add Build Timings section to building guide

---
 user_guide/13-building.qmd | 67 ++++++++++++++++++++++++++++++++++++++
 1 file changed, 67 insertions(+)

diff --git a/user_guide/13-building.qmd b/user_guide/13-building.qmd
index e7e2a7c..50d6360 100644
--- a/user_guide/13-building.qmd
+++ b/user_guide/13-building.qmd
@@ -201,6 +201,73 @@ If Great Docs reports an error during dynamic introspection, it will automatical
 
 If your site looks correct in some places but outdated in others, the most reliable fix is a clean rebuild: delete the `great-docs/` directory and run `great-docs build` again. The output directory is fully regenerated each time, so there is no risk in deleting it.
 
+## Build Timings [version-badge new 0.12] {#build-timings}
+
+Every time `great-docs build` runs, it records how long each page takes to render and writes the results to `great-docs/_site/build-timings.json`. This helps you identify slow pages that are bottlenecks in your build.
+
+### Viewing Timings
+
+Use the `great-docs timings` command to display a sorted table (slowest pages first):
+
+```{.bash filename="Terminal"}
+great-docs timings
+```
+
+```{.default}
+  Build time: 2026-05-06 14:32:01
+  Total: 47.2s across 38 pages
+
+  Page                              Time
+  ─────────────────────────────────────────────────
+  reference/GT.qmd                  12.4s  ████████████
+  reference/GT.tab_style.qmd         6.1s  ██████
+  user-guide/theming.qmd             4.8s  █████
+  reference/GT.fmt_number.qmd        3.9s  ████
+  ...
+```
+
+Pages served from the [Quarto freeze cache](freeze.qmd) are marked with a ❄ indicator. This view makes it easy to spot which pages are worth optimizing or splitting up.
+
+### Filtering Results
+
+Show only the top N slowest pages:
+
+```{.bash filename="Terminal"}
+great-docs timings --top 5
+```
+
+For multi-version builds, filter by version:
+
+```{.bash filename="Terminal"}
+great-docs timings --version 0.10
+```
+
+These filters are useful when you only care about a specific slice of the build.
+
+### Custom Output Directories
+
+If you built with a custom `--output-dir`, pass the same path:
+
+```{.bash filename="Terminal"}
+great-docs timings --output-dir ./public
+```
+
+This ensures the command can locate `build-timings.json` regardless of where the site was rendered.
+
+### JSON Output
+
+For scripting or CI integration, use `--json` to get the raw data:
+
+```{.bash filename="Terminal"}
+great-docs timings --json
+```
+
+The JSON format is convenient for piping into other tools like `jq` or for building custom dashboards.
+
+### CI Integration
+
+The `great-docs setup-github-pages` workflow automatically uploads `build-timings.json` as a separate artifact named **build-timings** in each CI run. You can download it from the workflow run's Artifacts section on GitHub to compare build performance over time. This makes it straightforward to track regressions and monitor improvements across commits.
+
 ## Next Steps
 
 The build pipeline is designed to be fast and predictable. For most projects, `great-docs build` is all you need. When something goes wrong, the troubleshooting tips above cover the most common causes.

From c9f8c8959d394d14f028c081518c3ce9168e6411 Mon Sep 17 00:00:00 2001
From: Richard Iannone 
Date: Thu, 7 May 2026 00:30:22 -0400
Subject: [PATCH 12/12] Update 13-building.qmd

---
 user_guide/13-building.qmd | 145 ++++++++++++++++++++++++++-----------
 1 file changed, 103 insertions(+), 42 deletions(-)

diff --git a/user_guide/13-building.qmd b/user_guide/13-building.qmd
index 50d6360..bc09b63 100644
--- a/user_guide/13-building.qmd
+++ b/user_guide/13-building.qmd
@@ -7,37 +7,58 @@ tags: [Build]
 
 # Building & Previewing
 
-The `great-docs build` command is the main way you interact with Great Docs on a day-to-day basis. It reads your `great-docs.yml` configuration, discovers your package's API, generates Quarto source files, and renders everything into a static HTML site. This page explains what happens during a build, how to preview your site locally, and how to troubleshoot common issues.
+The `great-docs build` command is the main way you interact with Great Docs on a day-to-day basis.
+It reads your `great-docs.yml` configuration, discovers your package's API, generates Quarto source
+files, and renders everything into a static HTML site. This page explains what happens during a
+build, how to preview your site locally, and how to troubleshoot common issues.
 
 ## The Build Pipeline
 
 When you run `great-docs build`, the following steps execute in order:
 
-1. The `great-docs/` output directory is created (or refreshed) with all required assets: stylesheets, JavaScript files for dark mode toggling, sidebar filtering, the GitHub stars widget, and other interactive features.
+1. The `great-docs/` output directory is created (or refreshed) with all required assets:
+stylesheets, JavaScript files for dark mode toggling, sidebar filtering, the GitHub stars widget,
+and other interactive features.
 
-2. Your `great-docs.yml` is read. A `_quarto.yml` file is generated (or updated) in the output directory with the Quarto project configuration, including navbar links, sidebar structure, and theme settings.
+2. Your `great-docs.yml` is read. A `_quarto.yml` file is generated (or updated) in the output
+directory with the Quarto project configuration, including navbar links, sidebar structure, and
+theme settings.
 
-3. A landing page (`index.qmd`) is generated from your project's `README.md`. If you have a logo configured, a hero section with the logo, package name, tagline, and badges is added automatically.
+3. A landing page (`index.qmd`) is generated from your project's `README.md`. If you have a logo
+configured, a hero section with the logo, package name, tagline, and badges is added automatically.
 
-4. If a user guide directory exists (by default `user_guide/`), all `.qmd` files are copied into the output directory with numeric prefixes stripped from filenames. The sidebar is organized by `guide-section` frontmatter metadata.
+4. If a user guide directory exists (by default `user_guide/`), all `.qmd` files are copied into
+the output directory with numeric prefixes stripped from filenames. The sidebar is organized by
+`guide-section` frontmatter metadata.
 
-5. If Click CLI documentation is enabled, Great Docs discovers your CLI commands and generates a reference page for each one.
+5. If Click CLI documentation is enabled, Great Docs discovers your CLI commands and generates a
+reference page for each one.
 
-6. Custom sections defined in `great-docs.yml` (examples, tutorials, blog posts, etc.) are processed and copied to the output directory.
+6. Custom sections defined in `great-docs.yml` (examples, tutorials, blog posts, etc.) are processed
+and copied to the output directory.
 
 ::: {.version-only versions=">=0.6"}
-7. If `custom_pages` is configured, or if the fallback `custom/` directory exists, custom HTML pages are discovered. Passthrough pages are converted into generated `.qmd` files and raw pages are copied through unchanged.
+7. If `custom_pages` is configured, or if the fallback `custom/` directory exists, custom HTML pages
+are discovered. Passthrough pages are converted into generated `.qmd` files and raw pages are copied
+through unchanged.
 :::
 
-8. If the changelog is enabled and a GitHub repository URL exists in `pyproject.toml`, GitHub Releases are fetched and a `changelog.qmd` file is generated.
+8. If the changelog is enabled and a GitHub repository URL exists in `pyproject.toml`, GitHub
+Releases are fetched and a `changelog.qmd` file is generated.
 
-9. The Agent Skills file (`skill.md`) is generated or copied. If you have a curated `SKILL.md` in `skills//`, it is used directly. Otherwise, a skill file is auto-generated from your package metadata.
+9. The Agent Skills file (`skill.md`) is generated or copied. If you have a curated `SKILL.md` in
+`skills//`, it is used directly. Otherwise, a skill file is auto-generated from your
+package metadata.
 
-10. `llms.txt` and `llms-full.txt` files are generated. These provide AI-friendly summaries of your package documentation.
+10. `llms.txt` and `llms-full.txt` files are generated. These provide AI-friendly summaries of your
+package documentation.
 
-11. Source link metadata (`_source_links.json`) is generated, mapping each documented symbol to its file and line numbers on GitHub.
+11. Source link metadata (`_source_links.json`) is generated, mapping each documented symbol to its
+file and line numbers on GitHub.
 
-12. Quarto renders all the source files into HTML. A post-render script runs to apply final transformations: injecting source links, processing cross-references (GDLS), cleaning up Sphinx/RST artifacts, and generating companion Markdown (`.md`) files for each page.
+12. Quarto renders all the source files into HTML. A post-render script runs to apply final
+transformations: injecting source links, processing cross-references (GDLS), cleaning up Sphinx/RST
+artifacts, and generating companion Markdown (`.md`) files for each page.
 
 After all steps complete, the finished site is in `great-docs/_site/`.
 
@@ -49,9 +70,12 @@ To view your site locally with live reload:
 great-docs preview
 ```
 
-This starts a local development server and opens your default browser. When you edit source files (user guide pages, docstrings, configuration), the site rebuilds automatically and the browser refreshes to show your changes.
+This starts a local development server and opens your default browser. When you edit source files
+(user guide pages, docstrings, configuration), the site rebuilds automatically and the browser
+refreshes to show your changes.
 
-Preview mode is ideal during the writing process because it lets you see how content will look in the final rendered site without committing or deploying anything.
+Preview mode is ideal during the writing process because it lets you see how content will look in
+the final rendered site without committing or deploying anything.
 
 ## Build Options
 
@@ -59,23 +83,27 @@ The `great-docs build` command accepts several options that control its behavior
 
 ### Watch Mode
 
-Watch mode keeps the build process running and automatically rebuilds when files change. This is similar to preview mode but without starting a local server:
+Watch mode keeps the build process running and automatically rebuilds when files change. This is
+similar to preview mode but without starting a local server:
 
 ```{.bash filename="Terminal"}
 great-docs build --watch
 ```
 
-This is useful when you want to rebuild continuously but view the output in a different way (for example, opening the HTML files directly or using a separate static file server).
+This is useful when you want to rebuild continuously but view the output in a different way (for
+example, opening the HTML files directly or using a separate static file server).
 
 ### Clean Build
 
-If you suspect stale files in the output directory are causing issues, you can force a completely fresh build. Delete the `great-docs/` directory and rebuild:
+If you suspect stale files in the output directory are causing issues, you can force a completely
+fresh build. Delete the `great-docs/` directory and rebuild:
 
 ```{.bash filename="Terminal"}
 rm -rf great-docs/ && great-docs build
 ```
 
-Since the `great-docs/` directory is ephemeral and fully generated from `great-docs.yml` plus your source files, deleting it is always safe.
+Since the `great-docs/` directory is ephemeral and fully generated from `great-docs.yml` plus your
+source files, deleting it is always safe.
 
 ## Build Output Structure
 
@@ -104,19 +132,25 @@ great-docs/
     └── ...
 ```
 
-The `_site/` subdirectory contains the final HTML output. This is the directory you deploy to your hosting service (GitHub Pages, Netlify, Vercel, etc.).
+The `_site/` subdirectory contains the final HTML output. This is the directory you deploy to your
+hosting service (GitHub Pages, Netlify, Vercel, etc.).
 
-Everything outside of `_site/` is intermediate Quarto source. You generally do not need to inspect these files, but they can be useful for debugging rendering issues.
+Everything outside of `_site/` is intermediate Quarto source. You generally do not need to inspect
+these files, but they can be useful for debugging rendering issues.
 
 ## Building from a Remote Repository [version-badge new 0.11] {#from-repo}
 
-The `--from-repo` flag lets you build documentation for any Git-hosted package without cloning it yourself. Great Docs handles the entire workflow: cloning the repository, creating an isolated virtual environment, installing the package and its dependencies, running the full build pipeline, and copying the finished site to a local directory.
+The `--from-repo` flag lets you build documentation for any Git-hosted package without cloning it
+yourself. Great Docs handles the entire workflow: cloning the repository, creating an isolated
+virtual environment, installing the package and its dependencies, running the full build pipeline,
+and copying the finished site to a local directory.
 
 ```{.bash filename="Terminal"}
 great-docs build --from-repo https://github.com/owner/package.git
 ```
 
-The built site is copied to `./great-docs/_site/` by default. Use `--output-dir` to put it somewhere else:
+The built site is copied to `./great-docs/_site/` by default. Use `--output-dir` to put it somewhere
+else:
 
 ```{.bash filename="Terminal"}
 great-docs build --from-repo https://github.com/owner/package.git --output-dir /tmp/my-site
@@ -124,7 +158,8 @@ great-docs build --from-repo https://github.com/owner/package.git --output-dir /
 
 ### Branch or Tag
 
-By default the repository's default branch is cloned. Use `--branch` to check out a specific branch or tag:
+By default the repository's default branch is cloned. Use `--branch` to check out a specific branch
+or tag:
 
 ```{.bash filename="Terminal"}
 great-docs build --from-repo https://github.com/owner/package.git --branch v2.0.0
@@ -132,9 +167,12 @@ great-docs build --from-repo https://github.com/owner/package.git --branch v2.0.
 
 ### Clone Depth
 
-Great Docs inspects the target project's `great-docs.yml` to decide how much Git history to fetch. If the project uses multi-version docs or page dates, a full clone is performed automatically. Otherwise a lightweight tag-only clone is used.
+Great Docs inspects the target project's `great-docs.yml` to decide how much Git history to fetch.
+If the project uses multi-version docs or page dates, a full clone is performed automatically.
+Otherwise a lightweight tag-only clone is used.
 
-Use `--shallow` to force a minimal `--depth 1` clone. This is the fastest option but disables versioned documentation and page dates:
+Use `--shallow` to force a minimal `--depth 1` clone. This is the fastest option but disables
+versioned documentation and page dates:
 
 ```{.bash filename="Terminal"}
 great-docs build --from-repo https://github.com/owner/package.git --shallow
@@ -142,7 +180,8 @@ great-docs build --from-repo https://github.com/owner/package.git --shallow
 
 ### Previewing After Build
 
-Add `--preview` to start a local server and open the site in your browser as soon as the build finishes:
+Add `--preview` to start a local server and open the site in your browser as soon as the build
+finishes:
 
 ```{.bash filename="Terminal"}
 great-docs build --from-repo https://github.com/owner/package.git --preview
@@ -150,13 +189,15 @@ great-docs build --from-repo https://github.com/owner/package.git --preview
 
 ### Previewing a Previously Built Site
 
-If you have already built a site with `--from-repo` (or received a site directory from someone else), use `great-docs preview --site-dir` to serve it without any project context:
+If you have already built a site with `--from-repo` (or received a site directory from someone
+else), use `great-docs preview --site-dir` to serve it without any project context:
 
 ```{.bash filename="Terminal"}
 great-docs preview --site-dir /tmp/my-site
 ```
 
-This starts the same local HTTP server and opens your browser, just like the regular `great-docs preview` command.
+This starts the same local HTTP server and opens your browser, just like the regular
+`great-docs preview` command.
 
 ## Using the Python API
 
@@ -171,7 +212,8 @@ docs.build()     # Run the full build pipeline
 docs.preview()   # Start the preview server
 ```
 
-The `GreatDocs` class accepts a `project_path` argument if your working directory is not the project root:
+The `GreatDocs` class accepts a `project_path` argument if your working directory is not the project
+root:
 
 ```{.python filename="Python"}
 docs = GreatDocs(project_path="/path/to/my-project")
@@ -183,27 +225,39 @@ docs.build()
 
 ### Quarto Errors
 
-If the build fails during the Quarto rendering step, the error output will include Quarto's own messages. Common causes include:
+If the build fails during the Quarto rendering step, the error output will include Quarto's own
+messages. Common causes include:
 
-- A `.qmd` file with invalid YAML frontmatter. Check that all frontmatter blocks are enclosed between `---` markers and that the YAML is well-formed.
-- A reference to a file that does not exist. If you renamed or deleted a user guide page, make sure the `guide-section` frontmatter in other files does not depend on it.
-- A Quarto version incompatibility. Great Docs is tested with Quarto 1.4 and later. Run `quarto --version` to check your installed version.
+- A `.qmd` file with invalid YAML frontmatter. Check that all frontmatter blocks are enclosed
+between `---` markers and that the YAML is well-formed.
+- A reference to a file that does not exist. If you renamed or deleted a user guide page, make
+sure the `guide-section` frontmatter in other files does not depend on it.
+- A Quarto version incompatibility. Great Docs is tested with Quarto 1.4 and later. Run
+`quarto --version` to check your installed version.
 
 ### Missing API Reference Pages
 
-If some symbols are missing from the rendered API reference, run `great-docs scan` to see what Great Docs discovers. Items that appear in the scan output but not in the reference may be excluded by the `exclude` list in `great-docs.yml` or may not be listed in the `reference` config sections.
+If some symbols are missing from the rendered API reference, run `great-docs scan` to see what Great
+Docs discovers. Items that appear in the scan output but not in the reference may be excluded by the
+`exclude` list in `great-docs.yml` or may not be listed in the `reference` config sections.
 
 ### Dynamic Introspection Failures
 
-If Great Docs reports an error during dynamic introspection, it will automatically retry with static analysis. If you see this retry message frequently, you can set `dynamic: false` in `great-docs.yml` to skip the dynamic pass entirely. See [Configuration](configuration.qmd) for details.
+If Great Docs reports an error during dynamic introspection, it will automatically retry with static
+analysis. If you see this retry message frequently, you can set `dynamic: false` in `great-docs.yml`
+to skip the dynamic pass entirely. See [Configuration](configuration.qmd) for details.
 
 ### Stale Output
 
-If your site looks correct in some places but outdated in others, the most reliable fix is a clean rebuild: delete the `great-docs/` directory and run `great-docs build` again. The output directory is fully regenerated each time, so there is no risk in deleting it.
+If your site looks correct in some places but outdated in others, the most reliable fix is a clean
+rebuild: delete the `great-docs/` directory and run `great-docs build` again. The output directory
+is fully regenerated each time, so there is no risk in deleting it.
 
 ## Build Timings [version-badge new 0.12] {#build-timings}
 
-Every time `great-docs build` runs, it records how long each page takes to render and writes the results to `great-docs/_site/build-timings.json`. This helps you identify slow pages that are bottlenecks in your build.
+Every time `great-docs build` runs, it records how long each page takes to render and writes the
+results to `great-docs/_site/build-timings.json`. This helps you identify slow pages that are
+bottlenecks in your build.
 
 ### Viewing Timings
 
@@ -226,7 +280,8 @@ great-docs timings
   ...
 ```
 
-Pages served from the [Quarto freeze cache](freeze.qmd) are marked with a ❄ indicator. This view makes it easy to spot which pages are worth optimizing or splitting up.
+Pages served from the [Quarto freeze cache](freeze.qmd) are marked with a ❄ indicator. This view
+makes it easy to spot which pages are worth optimizing or splitting up.
 
 ### Filtering Results
 
@@ -262,15 +317,21 @@ For scripting or CI integration, use `--json` to get the raw data:
 great-docs timings --json
 ```
 
-The JSON format is convenient for piping into other tools like `jq` or for building custom dashboards.
+The JSON format is convenient for piping into other tools like `jq` or for building custom
+dashboards.
 
 ### CI Integration
 
-The `great-docs setup-github-pages` workflow automatically uploads `build-timings.json` as a separate artifact named **build-timings** in each CI run. You can download it from the workflow run's Artifacts section on GitHub to compare build performance over time. This makes it straightforward to track regressions and monitor improvements across commits.
+The `great-docs setup-github-pages` workflow automatically uploads `build-timings.json` as a
+separate artifact named **build-timings** in each CI run. You can download it from the workflow
+run's Artifacts section on GitHub to compare build performance over time. This makes it easier to
+track regressions and monitor improvements across commits.
 
 ## Next Steps
 
-The build pipeline is designed to be fast and predictable. For most projects, `great-docs build` is all you need. When something goes wrong, the troubleshooting tips above cover the most common causes.
+The build pipeline is designed to be fast and predictable. For most projects, `great-docs build` is
+really all you need. When something goes wrong, the troubleshooting tips above probably cover the
+most common causes.
 
 - [Deployment](deployment.qmd) covers publishing your built site to GitHub Pages
 - [Configuration](configuration.qmd) covers all `great-docs.yml` options