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/_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, } 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 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.
diff --git a/great_docs/cli.py b/great_docs/cli.py
index d088963..2d2e6ee 100644
--- a/great_docs/cli.py
+++ b/great_docs/cli.py
@@ -1003,6 +1003,189 @@ 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, 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-timings.json"
+    if candidate.exists():
+        return candidate
+    # Single-version or build dir fallback
+    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-timings.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.",
+)
+@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-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 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()
+    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-timings.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(timings)
+
+
 @click.command(name="setup-github-pages")
 @click.option(
     "--project-path",
diff --git a/great_docs/core.py b/great_docs/core.py
index ef48899..7ddc682 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-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
+        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-timings.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-timings.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-timings.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
diff --git a/tests/test_build_timing.py b/tests/test_build_timing.py
new file mode 100644
index 0000000..c934a04
--- /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-timings.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)
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", "", [])
 
 
 # ---------------------------------------------------------------------------
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
diff --git a/user_guide/13-building.qmd b/user_guide/13-building.qmd
index e7e2a7c..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,113 @@ 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.
+
+### 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 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