import json
import os
import re
import shutil
import sys
from datetime import datetime
from importlib import resources
from pathlib import Path
from typing import Any

from yaml12 import format_yaml, parse_yaml, read_yaml, write_yaml

from ._subprocess import TEXT_MODE_KWARGS
from .config import Config, create_default_config

# Quarto's default input file types, enumerated as render globs. Used to seed
# `project.render` whenever we also add `!` exclusions. A recursive `**` glob
# overpowers any negation that follows it (so `!skill.md` would be ignored),
# whereas these per-extension globs still match recursively while letting the
# exclusions take effect.
_QUARTO_RENDER_GLOBS = [
    "*.qmd",
    "*.md",
    "*.markdown",
    "*.rmd",
    "*.Rmd",
    "*.rmarkdown",
    "*.ipynb",
]


class QuartoNotFoundError(RuntimeError):
    """Raised when the Quarto CLI is not available on the system."""


def _ensure_quarto_installed() -> None:
    if shutil.which("quarto") is not None:
        return
    raise QuartoNotFoundError(
        "Quarto was not found on your system. Quarto is a system-level "
        "dependency of great-docs and is not bundled with the Python package.\n\n"
        "See the troubleshooting guide:\n"
        "  https://posit-dev.github.io/great-docs/recipes/fix-common-build-errors.html\n\n"
        "Or fetch the binary programmatically by using your OS package (brew, yum, winget)."
    )


class GreatDocs:
    """
    GreatDocs class for creating beautiful API documentation sites.

    This class provides methods to install assets and configure Quarto projects with the great-docs
    styling and functionality. It handles package discovery, API reference generation, Quarto
    configuration, and site building with a consistent, polished look.

    Parameters
    ----------
    project_path
        Path to the project root directory. If `None` (the default), the current working directory
        is used.

    Attributes
    ----------
    project_root
        Absolute path to the project root directory.
    docs_dir
        Relative path to the documentation build directory (`great-docs`).
    project_path
        Full absolute path to the documentation build directory (`project_root / docs_dir`).

    Examples
    --------
    Create a documentation site for a Python package:

    ```python
    from great_docs import GreatDocs

    gd = GreatDocs()

    # One-time setup: generates great-docs.yml and discovers exports
    gd.install()

    # Build the documentation site
    gd.build()

    # Preview locally in a browser
    gd.preview()
    ```

    Build from a different directory:

    ```python
    gd = GreatDocs(project_path="/path/to/my-package")
    gd.build()
    ```

    Notes
    -----
    The typical workflow is: `install()` once to scaffold configuration, then `build()` (and
    optionally `preview()`) as you iterate. The `build()` method re-discovers package exports by
    default, so new functions and classes are picked up automatically.
    """

    def __init__(self, project_path: str | None = None):
        """
        Initialize GreatDocs instance.

        Parameters
        ----------
        project_path
            Path to the project root directory. Defaults to current directory.
        """
        self.project_root = Path(project_path or os.getcwd()).resolve()
        # Build directory is always 'great-docs' - created during build, not init
        self.docs_dir = Path("great-docs")
        self.project_path = self.project_root / self.docs_dir
        try:
            # Python 3.9+
            self.package_path = Path(resources.files("great_docs"))
        except AttributeError:  # pragma: no cover
            # Fallback for older Python versions
            import importlib_resources  # type: ignore[import-not-found]

            self.package_path = Path(importlib_resources.files("great_docs"))
        self.assets_path = self.package_path / "assets"

        # Load configuration from great-docs.yml
        self._config = Config(self._find_package_root())

        # Whether API reference was successfully configured (set during build)
        self._has_api_reference = True

        # When True, suppress writes of build artifacts (e.g. `_object_types.json`)
        # so read-only queries like `documented_symbol_names()` leave no files behind.
        self._suppress_artifact_writes = False

        # Whether MCP pages were actually generated (None = not yet determined)
        self._mcp_pages_generated: bool | None = None

        # Per-build caches (reset on each build, shared across steps)
        self._griffe_pkg_cache: dict | None = None  # {normalized_name: griffe Package}
        self._cached_exports: list | None = None
        self._cached_package_name: str | None = None

        # Set environment variables needed by the qrenderer
        _, _, url = self._get_github_repo_info()
        if url:
            os.environ["GITHUB_REPO_URL"] = str(url)
        os.environ["GIT_REF"] = self._detect_git_ref()
        # The repository root, so the renderer can build source-link paths
        # relative to it (preserving `src/` and other non-flat layouts)
        # instead of griffe's package-parent-relative path, which 404s.
        os.environ["PACKAGE_ROOT"] = str(self._find_package_root())
        # Optional explicit source-path override for monorepos (source.path).
        source_path = self._config.source_path
        if source_path:
            os.environ["SOURCE_PATH"] = str(source_path)

    def _get_griffe_package(self, package_name: str):
        """Load a griffe package, using a per-build cache to avoid redundant loads.

        Parameters
        ----------
        package_name
            The normalized (underscored) package name.

        Returns
        -------
        griffe.Object
            The loaded griffe package object.
        """
        import griffe

        if self._griffe_pkg_cache is None:
            self._griffe_pkg_cache = {}

        normalized = package_name.replace("-", "_")
        if normalized not in self._griffe_pkg_cache:
            self._griffe_pkg_cache[normalized] = griffe.load(
                normalized, search_paths=self._griffe_search_paths()
            )
        return self._griffe_pkg_cache[normalized]

    def _get_source_location_from_pkg(self, pkg, item_name: str) -> dict | None:
        """Get source location by navigating an already-loaded griffe package tree.

        Parameters
        ----------
        pkg
            A griffe package object (already loaded).
        item_name
            Dotted name relative to the package (e.g., "ClassName.method_name").

        Returns
        -------
        dict | None
            Dictionary with file, start_line, end_line or None if not found.
        """
        parts = item_name.split(".")
        obj = pkg
        for part in parts:
            if part not in obj.members:
                return None
            obj = obj.members[part]

        if not hasattr(obj, "lineno") or obj.lineno is None:
            return None
        if not hasattr(obj, "filepath") or not obj.filepath:
            return None

        end_lineno = getattr(obj, "endlineno", obj.lineno)
        return {
            "file": str(obj.filepath),
            "start_line": obj.lineno,
            "end_line": end_lineno or obj.lineno,
        }

    def _prepare_build_directory(self) -> None:
        """
        Prepare the great-docs/ build directory with all necessary assets.

        This method creates the great-docs/ directory (if it doesn't exist) and
        copies all CSS, JavaScript, and other assets needed for the build.
        It also creates the _quarto.yml configuration and other necessary files.

        The great-docs/ directory is ephemeral and should not be committed to
        version control. It will be recreated on each build.
        """
        print(f"Preparing build directory: {self.project_path.relative_to(self.project_root)}/")

        # Clean any existing build directory to avoid stale artifacts
        if self.project_path.exists():
            shutil.rmtree(self.project_path)

        # Create the great-docs directory
        self.project_path.mkdir(parents=True, exist_ok=True)

        # Create necessary subdirectories
        scripts_dir = self.project_path / "scripts"
        scripts_dir.mkdir(exist_ok=True)

        reference_dir = self.project_path / "reference"
        reference_dir.mkdir(exist_ok=True)

        # Copy post-render script
        post_render_src = self.assets_path / "post-render.py"
        post_render_dst = scripts_dir / "post-render.py"
        shutil.copy2(post_render_src, post_render_dst)

        # Copy restore-freeze script when freeze is configured OR _freeze/ exists
        has_freeze_cache = (self.project_root / "_freeze").is_dir()
        if self._config.freeze or has_freeze_cache:
            restore_freeze_src = self.assets_path / "restore-freeze.py"
            restore_freeze_dst = scripts_dir / "restore-freeze.py"
            shutil.copy2(restore_freeze_src, restore_freeze_dst)

        # Copy user-provided pre-render scripts
        for script_path in self._config.pre_render:
            src = self.project_root / script_path
            if src.is_file():
                dst = scripts_dir / src.name
                shutil.copy2(src, dst)
            else:
                print(f"Warning: Pre-render script not found: {script_path}")

        # Copy bibliography and CSL files into the build directory so that
        # project-level citations resolve (see _update_quarto_config for wiring)
        for bib_path in self._config.bibliography:
            src = self.project_root / bib_path
            if src.is_file():
                shutil.copy2(src, self.project_path / src.name)
            else:
                print(f"Warning: Bibliography file not found: {bib_path}")
        csl_path = self._config.csl
        if csl_path:
            csl_src = self.project_root / csl_path
            if csl_src.is_file():
                shutil.copy2(csl_src, self.project_path / csl_src.name)
            else:
                print(f"Warning: CSL file not found: {csl_path}")

        # Copy custom CSS files (site.css) into the build directory
        # where _quarto.yml will refer to them by basename
        for css_path in self._config.css:
            css_src = self.project_root / css_path
            if css_src.is_file():
                shutil.copy2(css_src, self.project_path / css_src.name)
            else:
                print(f"Warning: CSS file not found: {css_path}")

        # Copy qrenderer assets
        renderer_src = self.assets_path / "_renderer.py"
        if renderer_src.exists():
            shutil.copy2(renderer_src, self.project_path / "_renderer.py")

        # Copy SCSS theme file (contains all site styling)
        scss_src = self.assets_path / "great-docs.scss"
        shutil.copy2(scss_src, self.project_path / "great-docs.scss")

        # Copy termshow CSS
        tp_css_src = self.assets_path / "termshow.css"
        if tp_css_src.exists():
            shutil.copy2(tp_css_src, self.project_path / "termshow.css")

        # Copy termshow font subsets (JetBrains Mono for box-drawing fidelity)
        for font_file in (
            "JetBrainsMono-Regular.subset.woff2",
            "JetBrainsMono-Bold.subset.woff2",
        ):
            font_src = self.assets_path / font_file
            if font_src.exists():
                shutil.copy2(font_src, self.project_path / font_file)

        # Copy the evolution demo data file
        demo_json_src = self.assets_path / "api-evolution-demo.json"
        if demo_json_src.exists():
            shutil.copy2(demo_json_src, self.project_path / "api-evolution-demo.json")

        # Copy the evolution shortcode extension (auto-discovered by Quarto)
        extensions_src = self.assets_path / "_extensions"
        if extensions_src.exists():
            extensions_dst = self.project_path / "_extensions"
            shutil.copytree(extensions_src, extensions_dst, dirs_exist_ok=True)

        # Copy lightbox assets (JS + CSS live with the extension but also need
        # to be available as top-level resources for the Lua filter's injection)
        lb_ext = self.assets_path / "_extensions" / "gd-lightbox"
        for lb_file in (
            "gd-lightbox.js",
            "gd-lightbox.css",
            "gd-lightbox-compare.js",
            "gd-lightbox-compare.css",
            "gd-lightbox-annotate.js",
            "gd-lightbox-annotate.css",
        ):
            lb_src = lb_ext / lb_file
            if lb_src.exists():
                shutil.copy2(lb_src, self.project_path / lb_file)

        # Copy JavaScript files
        js_files = [
            "github-widget.js",
            "sidebar-filter.js",
            "sidebar-wrap.js",
            "sidebar-float.js",
            "reference-switcher.js",
            "dark-mode-toggle.js",
            "theme-init.js",
            "copy-code.js",
            "tooltips.js",
            "color-swatch.js",
            "mermaid-renderer.js",
            "responsive-tables.js",
            "video-embed.js",
            "navbar-widgets.js",
            "details.js",
        ]
        if self._config.markdown_pages_widget:
            js_files.append("copy-page.js")
        if self._config.announcement:
            js_files.append("announcement-banner.js")
        if self._config.navbar_style:
            js_files.append("navbar-style.js")
        if self._config.content_style is not None:
            js_files.append("content-style.js")
        if self._config.show_dates:
            js_files.append("page-metadata.js")  # pragma: no cover
        if self._config.back_to_top:
            js_files.append("back-to-top.js")
            js_files.append("on-this-page.js")
        if self._config.keyboard_nav:
            js_files.append("keyboard-nav.js")
        if self._config.tags_show_on_pages:
            js_files.append("page-tags.js")  # pragma: no cover
        if self._config.page_status_enabled:
            js_files.append("page-status-badges.js")  # pragma: no cover
        if self._config.has_versions:
            js_files.append("version-selector.js")
        if self._config.hero_starfield:
            js_files.append("starfield.js")
        if self._config.skill_skills and len(self._config.skill_skills) > 1:
            js_files.append("skill-switcher.js")
        # termshow player is always available (lightweight, only activates if shortcode used)
        js_files.append("termshow.js")
        for js_file in js_files:
            js_src = self.assets_path / js_file
            if js_src.exists():
                js_dst = self.project_path / js_file
                shutil.copy2(js_src, js_dst)

        # Create .gitignore for the great-docs directory
        gitignore_content = """# Great Docs build directory
# This directory is ephemeral and regenerated on each build
# Do not commit this directory to version control
*
!.gitignore
"""
        gitignore_path = self.project_path / ".gitignore"
        gitignore_path.write_text(gitignore_content, encoding="utf-8")

        # Create index.qmd from README.md or user_guide files
        self._create_index_from_readme(force_rebuild=True)

        # Note: User guide files are copied by _process_user_guide() during build
        # which handles stripping numeric prefixes for clean URLs

        # Create _quarto.yml configuration
        self._update_quarto_config()

        # Write options JSON for the post-render script
        gd_options = {
            "markdown_pages": self._config.markdown_pages,
            "show_dates": self._config.show_dates,
            "date_format": self._config.date_format,
            "show_author": self._config.show_author,
            "team_author": self._config.team_author,
            "authors": self._config.authors,  # Rich author metadata with images
            "build_timestamp": datetime.now().isoformat(),
            "language": self._config.language,
        }
        # Add i18n translations bundle
        from ._translations import get_translations_bundle, is_rtl

        gd_options["i18n"] = get_translations_bundle(self._config.language)
        gd_options["rtl"] = is_rtl(self._config.language)
        # Add SEO options
        gd_options.update(self._get_seo_options())
        gd_options_path = self.project_path / "_gd_options.json"
        with open(gd_options_path, "w") as f:
            json.dump(gd_options, f)

        # Add API reference configuration
        # (auto-skips if no documentable exports are found)
        self._add_api_reference_config()
        if self._has_api_reference:
            self._update_sidebar_from_sections()
            self._update_reference_index_frontmatter()

    def _copy_user_guide_files(self) -> None:
        """
        Copy user guide files from project root to build directory.

        Resolves the source directory using `_find_user_guide_dir()`.
        When `user_guide` config is a list (explicit ordering), only the directory
        is resolved from conventional locations.

        Copies `.qmd` and `.md` files to `great-docs/user-guide/` directory.
        """
        configured_path = self._config.user_guide_dir  # str or None (ignores list)

        if configured_path is not None:
            source_user_guide = self.project_root / configured_path
        else:
            source_user_guide = self.project_root / "user_guide"
            if not source_user_guide.exists():
                # Also check for 'user-guide' with hyphen
                source_user_guide = self.project_root / "user-guide"

        if source_user_guide.exists() and source_user_guide.is_dir():
            dest_user_guide = self.project_path / "user-guide"
            dest_user_guide.mkdir(exist_ok=True)

            # Copy all .qmd and .md files
            for pattern in ["*.qmd", "*.md"]:
                for file_path in source_user_guide.glob(pattern):
                    shutil.copy2(file_path, dest_user_guide / file_path.name)

    def _copy_assets(self) -> bool:
        """
        Copy assets directory from project root to build directory.

        Looks for assets/ directory in project root and copies all contents
        to great-docs/assets/ directory. This allows pages to reference assets
        at a predictable path (e.g., assets/image.png).

        Returns
        -------
        bool
            True if assets were copied, False if no assets directory found.
        """
        source_assets = self.project_root / "assets"

        if not source_assets.exists() or not source_assets.is_dir():
            return False

        dest_assets = self.project_path / "assets"

        # Remove existing assets directory to ensure clean copy
        if dest_assets.exists():
            shutil.rmtree(dest_assets)

        # Copy entire assets directory
        shutil.copytree(source_assets, dest_assets)

        # Count files for reporting
        file_count = sum(1 for _ in dest_assets.rglob("*") if _.is_file())
        print(f"\n📦 Copied {file_count} asset file(s) to great-docs/assets/")

        return True

    def _copy_readme_images(self, source_file: Path | None) -> int:
        """
        Copy images referenced in README.md (or similar) to the build directory.

        Scans the source file for local image references (Markdown `![](path)` and
        HTML `<img src="path">` syntax) and copies those files to the build directory.
        Skips URLs and paths under `assets/` (which are already handled by `_copy_assets()`).

        Parameters
        ----------
        source_file
            Path to the source file (README.md, index.md, etc.) to scan for images.
            If `None`, no action is taken.

        Returns
        -------
        int
            Number of image files copied.
        """
        if source_file is None or not source_file.exists():
            return 0

        with open(source_file, "r", encoding="utf-8") as f:
            content = f.read()

        # Find all image references:
        # 1. Markdown: ![alt](path) or ![alt](path "title")
        # 2. HTML: <img src="path"> or <img src='path'>
        image_paths: set[str] = set()

        # Markdown image pattern: ![...](path) or ![...](path "title")
        md_pattern = r"!\[[^\]]*\]\(([^)\s\"]+)(?:\s*\"[^\"]*\")?\)"
        for match in re.finditer(md_pattern, content):
            image_paths.add(match.group(1))

        # HTML img pattern: <img ... src="path" ...> or src='path'
        html_pattern = r"<img[^>]+src=[\"']([^\"']+)[\"']"
        for match in re.finditer(html_pattern, content, re.IGNORECASE):
            image_paths.add(match.group(1))

        # Filter to local paths only (exclude URLs and assets/ which is handled separately)
        local_images: list[tuple[str, Path]] = []
        for path_str in image_paths:
            # Skip URLs
            if path_str.startswith(("http://", "https://", "data:", "//")):
                continue
            # Skip assets/ directory (handled by _copy_assets)
            if path_str.startswith("assets/"):
                continue
            # Resolve relative to source file's directory
            img_path = source_file.parent / path_str
            if img_path.exists() and img_path.is_file():
                local_images.append((path_str, img_path))

        if not local_images:
            return 0

        # Copy each image to the build directory, preserving relative paths
        copied = 0
        for rel_path, abs_path in local_images:
            dest_path = self.project_path / rel_path
            # Create parent directories if needed
            dest_path.parent.mkdir(parents=True, exist_ok=True)
            # Copy the file
            shutil.copy2(abs_path, dest_path)
            copied += 1

        if copied > 0:
            print(f"\n🖼️  Copied {copied} image(s) referenced in {source_file.name}")

        return copied

    def install(self, force: bool = False) -> None:
        """
        Initialize great-docs in your project.

        This method creates a great-docs.yml configuration file in the project root
        with discovered exports and sensible defaults. The docs directory and assets
        will be created later during the build process.

        ::: {.callout-note}
        In practice, you would normally use the `great-docs init` CLI command rather
        than calling this method directly. See the
        [CLI reference](cli/init.qmd) for details.
        :::

        Parameters
        ----------
        force
            If `True`, overwrite existing great-docs.yml without prompting. Default is `False`.

        Examples
        --------
        Initialize great-docs in the current directory:

        ```python
        from great_docs import GreatDocs

        docs = GreatDocs()
        docs.install()
        ```

        Initialize in a specific project directory, overwriting existing config:

        ```python
        docs = GreatDocs("/path/to/my/project")
        docs.install(force=True)
        ```
        """
        print("Initializing great-docs...")

        # Generate great-docs.yml with discovered exports
        self._generate_initial_config(force=force)

        # Reload configuration after generating it
        self._config = Config(self._find_package_root())

        # Update project root .gitignore to exclude great-docs/
        self._update_project_gitignore(force=force)

        print("\n✅ Great Docs initialization complete!")
        print("\nNext steps:")
        print("1. Review great-docs.yml to customize your API reference structure")
        print("   (Reorder items, add sections, set 'members: false' to exclude methods)")
        print("2. Run `great-docs build` to generate and build your documentation site")
        print("3. Run `great-docs preview` to view the site locally")
        print("\nOther helpful commands:")
        print("  great-docs scan           # Preview API organization")
        print("  great-docs build --watch  # Watch for changes and rebuild")

    def _update_project_gitignore(self, force: bool = False) -> None:
        """
        Update project root .gitignore to exclude the great-docs/ build directory.

        Prompts the user for permission before modifying .gitignore, unless force=True.
        This ensures the ephemeral build directory is not committed to version control.

        Parameters
        ----------
        force
            If True, skip the prompt and automatically update .gitignore.
        """
        gitignore_path = self.project_root / ".gitignore"

        # Entry to add
        entry = "# Great Docs build directory (ephemeral, do not commit)\ngreat-docs/\n"

        # Versioning build artifacts (added when versions are configured)
        versioning_entries = [
            "_great_docs_build/",
            ".great-docs-build/",
            ".great-docs-cache/",
            ".great-docs/",
        ]

        # Check if already present
        has_main = False
        missing_versioning = list(versioning_entries)
        if gitignore_path.exists():
            with open(gitignore_path, "r", encoding="utf-8") as f:
                content = f.read()

            has_main = "great-docs/" in content
            missing_versioning = [e for e in versioning_entries if e not in content]

            if has_main and not missing_versioning:
                # All entries present, nothing to do
                return

        # If force=True, skip the prompt
        if force:
            if gitignore_path.exists():
                # Append to existing .gitignore
                with open(gitignore_path, "a", encoding="utf-8") as f:
                    if not has_main:
                        f.write("\n" + entry)
                    if missing_versioning:
                        f.write("\n# Great Docs versioned-build artifacts\n")
                        for ve in missing_versioning:
                            f.write(ve + "\n")
                print("✅ Updated .gitignore to exclude great-docs/ directory")
            else:
                # Create new .gitignore
                with open(gitignore_path, "w", encoding="utf-8") as f:
                    f.write(entry)
                    f.write("\n# Great Docs versioned-build artifacts\n")
                    for ve in versioning_entries:
                        f.write(ve + "\n")
                print("✅ Created .gitignore to exclude great-docs/ directory")
            return

        # Ask for permission in interactive mode
        print("\nThe great-docs/ directory is ephemeral and should not be committed to git.")
        response = (
            input("Add 'great-docs/' to .gitignore? [Y/n]: ").strip().lower()
        )  # pragma: no cover

        if response in ("", "y", "yes"):  # pragma: no cover
            if gitignore_path.exists():
                # Append to existing .gitignore
                with open(gitignore_path, "a", encoding="utf-8") as f:
                    if not has_main:
                        f.write("\n" + entry)
                    if missing_versioning:
                        f.write("\n# Great Docs versioned-build artifacts\n")
                        for ve in missing_versioning:
                            f.write(ve + "\n")
                print("✅ Updated .gitignore to exclude great-docs/ directory")
            else:
                # Create new .gitignore
                with open(gitignore_path, "w", encoding="utf-8") as f:
                    f.write(entry)
                    f.write("\n# Great Docs versioned-build artifacts\n")
                    for ve in versioning_entries:
                        f.write(ve + "\n")
                print("✅ Created .gitignore to exclude great-docs/ directory")
        else:
            print(
                "⚠️  Skipped .gitignore update. Remember to exclude great-docs/ from version control."
            )

    def _detect_package_name(self) -> str | None:
        """
        Detect the Python package name from project structure.

        Returns
        -------
        str | None
            The detected package name, or None if not found.
        """
        # Look for pyproject.toml
        pyproject_path = self.project_root / "pyproject.toml"
        if pyproject_path.exists():
            import tomllib

            with open(pyproject_path, "rb") as f:
                try:
                    data = tomllib.load(f)
                    name = data.get("project", {}).get("name")
                    if name:
                        return name
                except Exception:
                    pass

        # Look for setup.cfg
        setup_cfg = self.project_root / "setup.cfg"
        if setup_cfg.exists():
            try:
                import configparser

                cfg = configparser.ConfigParser()
                cfg.read(setup_cfg, encoding="utf-8")
                name = cfg.get("metadata", "name", fallback=None)
                if name:
                    return name
            except Exception:  # pragma: no cover
                pass

        # Look for setup.py
        setup_py = self.project_root / "setup.py"
        if setup_py.exists():
            with open(setup_py, "r") as f:
                content = f.read()
                # Simple regex to find name="..." in setup()
                import re

                match = re.search(r'name\s*=\s*["\']([^"\']+)["\']', content)
                if match:
                    return match.group(1)

        # Look for a single Python package directory
        potential_packages = [
            d
            for d in self.project_root.iterdir()
            if d.is_dir() and (d / "__init__.py").exists() and not d.name.startswith(".")
        ]
        if len(potential_packages) == 1:
            return potential_packages[0].name

        return None

    def _detect_logo(self) -> dict[str, str] | None:
        """
        Scan conventional paths for logo files.

        Looks for logo files in the project root using common naming conventions. If a matching file
        is found, returns a normalized logo dict. Also checks for dark-mode variants automatically.

        Returns
        -------
        dict | None
            A dict with `light` (and optionally `dark`) keys pointing to paths relative to the
            project root, or `None` if no logo file was found.
        """
        package_root = self._find_package_root()
        package_name = self._detect_package_name() or ""
        importable = package_name.replace("-", "_")

        # Candidate paths for the primary (light) logo, in priority order
        candidates = [
            "logo.svg",
            "logo.png",
            "assets/logo.svg",
            "assets/logo.png",
            "docs/assets/logo.svg",
            "docs/assets/logo.png",
        ]
        # Package-name variants
        if package_name:
            candidates.extend(
                [
                    f"{package_name}_logo.svg",
                    f"{package_name}_logo.png",
                    f"assets/{package_name}_logo.svg",
                    f"assets/{package_name}_logo.png",
                ]
            )
        if importable and importable != package_name:
            candidates.extend(
                [
                    f"{importable}_logo.svg",
                    f"{importable}_logo.png",
                    f"assets/{importable}_logo.svg",
                    f"assets/{importable}_logo.png",
                ]
            )
        # Also try logo-light naming convention
        candidates.extend(
            [
                "assets/logo-light.svg",
                "assets/logo-light.png",
            ]
        )

        found_path: str | None = None
        for candidate in candidates:
            full = package_root / candidate
            if full.is_file():
                found_path = candidate
                break

        if found_path is None:
            return None

        # Build the result dict
        result: dict[str, str] = {"light": found_path}

        # Check for a dark variant alongside the found file
        light_p = Path(found_path)
        stem = light_p.stem.replace("-light", "")
        parent = str(light_p.parent) if str(light_p.parent) != "." else ""

        for ext in [light_p.suffix]:  # same extension first
            dark_name = f"{stem}-dark{ext}"
            dark_candidate = f"{parent}/{dark_name}" if parent else dark_name
            if (package_root / dark_candidate).is_file():
                result["dark"] = dark_candidate
                break

        # If no separate dark variant, use the same file for both
        if "dark" not in result:
            result["dark"] = found_path

        return result

    def _detect_hero_logo(self) -> dict[str, str] | None:
        """
        Scan conventional paths for hero-specific logo files.

        Looks for `logo-hero.*` files in the project root and `assets/` directory.  If a light/dark
        pair is found (`logo-hero-light.*` and `logo-hero-dark.*`), both are returned.

        Returns
        -------
        dict | None
            A dict with `light` (and optionally `dark`) keys, or `None` if no hero logo file was
            found.
        """
        package_root = self._find_package_root()

        # Candidate paths in priority order
        candidates = [
            "logo-hero.svg",
            "logo-hero.png",
            "assets/logo-hero.svg",
            "assets/logo-hero.png",
            "logo-hero-light.svg",
            "logo-hero-light.png",
            "assets/logo-hero-light.svg",
            "assets/logo-hero-light.png",
        ]

        found_path: str | None = None
        for candidate in candidates:
            if (package_root / candidate).is_file():
                found_path = candidate
                break

        if found_path is None:
            return None

        result: dict[str, str] = {"light": found_path}

        # Check for a dark variant alongside the found file
        light_p = Path(found_path)
        stem = light_p.stem.replace("-light", "")
        parent = str(light_p.parent) if str(light_p.parent) != "." else ""

        dark_name = f"{stem}-dark{light_p.suffix}"
        dark_candidate = f"{parent}/{dark_name}" if parent else dark_name
        if (package_root / dark_candidate).is_file():
            result["dark"] = dark_candidate

        # If no separate dark variant, use the same file for both
        if "dark" not in result:
            result["dark"] = found_path

        return result

    def _generate_favicons(self, logo_src: Path, dest_dir: Path) -> dict[str, str]:
        """
        Generate favicon files from a source logo image.

        Produces raster favicons from SVG or PNG sources using cairosvg and Pillow. For SVG sources,
        cairosvg rasterizes to PNG first, then Pillow creates the multi-size ICO and
        apple-touch-icon. For PNG sources, Pillow resizes directly.

        Parameters
        ----------
        logo_src
            Absolute path to the source logo file (SVG or PNG).
        dest_dir
            Directory to write generated favicon files into.

        Returns
        -------
        dict[str, str]
            Mapping of purpose to filename (relative to dest_dir). Possible keys: `"icon"` (primary
            favicon), `"icon-svg"`, `"icon-32"`, `"icon-16"`, `"apple-touch-icon"`. Only includes
            files that were successfully generated.
        """
        import io

        from PIL import Image

        try:
            import cairosvg
        except (ImportError, OSError):
            cairosvg = None  # type: ignore[assignment]

        result: dict[str, str] = {}
        suffix = logo_src.suffix.lower()

        print(f"Favicon: generating from {logo_src.name}")

        # --- SVG source ---
        if suffix == ".svg":
            # Always copy the SVG as favicon.svg (modern browsers support it)
            shutil.copy2(logo_src, dest_dir / "favicon.svg")
            result["icon-svg"] = "favicon.svg"
            result["icon"] = "favicon.svg"

            if cairosvg is None:
                print(
                    "Favicon: cairosvg is not installed; skipping raster favicon "
                    "generation from SVG. Install it with:\n"
                    "  pip install 'great-docs[svg]'\n"
                    "On Linux you also need: apt install libcairo2-dev\n"
                    "On macOS (see installation notes): brew install cairo"
                )
                return result

            # Rasterize SVG → PNG preserving aspect ratio, then resize
            png_data = cairosvg.svg2png(url=str(logo_src), scale=4)
            raw = Image.open(io.BytesIO(png_data))

            # Fit into a square canvas with transparent padding
            master = self._fit_to_square(raw, 512)

            # Generate standard favicon sizes
            for size, name in [
                (16, "favicon-16x16.png"),
                (32, "favicon-32x32.png"),
                (180, "apple-touch-icon.png"),
            ]:
                resized = master.resize((size, size), Image.Resampling.LANCZOS)
                resized.save(dest_dir / name, "PNG")
                if size == 16:
                    result["icon-16"] = name
                elif size == 32:
                    result["icon-32"] = name
                elif size == 180:
                    result["apple-touch-icon"] = name

            # Generate ICO with multiple sizes embedded
            master.save(
                dest_dir / "favicon.ico",
                format="ICO",
                sizes=[(16, 16), (32, 32), (48, 48)],
            )
            result["icon"] = "favicon.ico"

        # --- PNG source ---
        elif suffix == ".png":
            raw = Image.open(logo_src)
            # Fit into a square canvas with transparent padding
            master = self._fit_to_square(raw, max(raw.size))

            for size, name in [
                (16, "favicon-16x16.png"),
                (32, "favicon-32x32.png"),
                (180, "apple-touch-icon.png"),
            ]:
                resized = master.resize((size, size), Image.Resampling.LANCZOS)
                resized.save(dest_dir / name, "PNG")
                if size == 16:
                    result["icon-16"] = name
                elif size == 32:
                    result["icon-32"] = name
                elif size == 180:
                    result["apple-touch-icon"] = name

            # Generate ICO with multiple sizes embedded
            master.save(
                dest_dir / "favicon.ico",
                format="ICO",
                sizes=[(16, 16), (32, 32), (48, 48)],
            )
            result["icon"] = "favicon.ico"
            result["icon-32"] = "favicon-32x32.png"

        if result:
            files = ", ".join(dict.fromkeys(result.values()))
            print(f"Favicon: created {files}")

        return result

    @staticmethod
    def _fit_to_square(img: "Image.Image", size: int) -> "Image.Image":
        """Fit an image into a square canvas, preserving aspect ratio.

        The image is scaled to fit within `size x size`, then centered on a transparent canvas of
        exactly `size x size`.
        """
        from PIL import Image

        img = img.convert("RGBA")
        img.thumbnail((size, size), Image.Resampling.LANCZOS)
        canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
        x = (size - img.width) // 2
        y = (size - img.height) // 2
        canvas.paste(img, (x, y), img)
        return canvas

    def _normalize_package_name(self, package_name: str) -> str:
        """
        Convert a package name to its importable form.

        PyPI package names can use hyphens (e.g., 'great-docs') but Python
        imports must use underscores (e.g., 'great_docs'). This method handles
        the conversion.

        Parameters
        ----------
        package_name
            The package name (potentially with hyphens)

        Returns
        -------
        str
            The importable package name (with underscores)
        """
        return package_name.replace("-", "_")

    def _resolve_importable_name(self, package_name: str) -> str:
        """
        Resolve the importable module name for a project.

        Prefers the module name from `_detect_module_name` (an explicit `module:` in great-docs.yml,
        build-backend hints, or auto-discovery), falling back to the normalized PyPI project name
        when detection returns `None`.

        Parameters
        ----------
        package_name
            The PyPI project name to fall back to.

        Returns
        -------
        str
            The importable module name.
        """
        module_name = self._detect_module_name()
        return module_name if module_name else self._normalize_package_name(package_name)

    def _detect_module_name(self) -> str | None:
        """
        Detect the actual importable module name for the package.

        The module name may differ from the project name (e.g., project 'py-yaml12'
        might have module 'yaml12' or 'pyyaml12'). This method tries to find the
        actual module directory or stub file.

        Priority:
        1. Explicit 'module' setting in great-docs.yml
        2. .pyi stub files (for compiled extensions)
        3. pyproject.toml configuration ([tool.maturin], [tool.setuptools], etc.)
        4. Auto-discovery via _find_package_init

        Returns
        -------
        str | None
            The detected module name, or None if not found.
        """
        # First, check for explicit module name in great-docs.yml
        if self._config.module:
            return self._config.module

        package_root = self._find_package_root()

        # Check for .pyi stub files (common for PyO3/Rust/compiled extensions)
        # These indicate the actual module name for compiled packages
        pyi_files = list(package_root.glob("*.pyi"))
        if pyi_files:
            # Use the first .pyi file found (excluding __init__.pyi)
            for pyi_file in pyi_files:
                if pyi_file.stem != "__init__":
                    return pyi_file.stem

        # Check pyproject.toml for explicit package configuration
        pyproject_path = package_root / "pyproject.toml"
        if pyproject_path.exists():
            import tomllib

            try:
                with open(pyproject_path, "rb") as f:
                    data = tomllib.load(f)

                # Check [tool.maturin] for PyO3/Rust projects
                maturin = data.get("tool", {}).get("maturin", {})
                if maturin:
                    # maturin projects: module name is often in module-name or derived from Cargo.toml
                    module_name = maturin.get("module-name")
                    if module_name:
                        # If `module-name` is a dotted path (e.g. "ggsql._ggsql"),
                        # it refers to the compiled extension submodule. The
                        # top-level importable Python package is the prefix —
                        # which is what we want for documentation. When
                        # `python-source` is set, the project also ships a
                        # pure-Python wrapper at that top-level name.
                        if "." in module_name:
                            return module_name.split(".", 1)[0]
                        return module_name

                # Check [tool.setuptools.packages] for explicit package list
                setuptools = data.get("tool", {}).get("setuptools", {})
                if "packages" in setuptools:
                    packages = setuptools["packages"]
                    if packages and isinstance(packages, list):
                        # Return the first package (main package)
                        return packages[0].split("/")[-1]

                # Check [tool.setuptools.packages.find]
                find_config = setuptools.get("packages", {})
                if isinstance(find_config, dict) and "find" in find_config:
                    where = find_config["find"].get("where", ["."])
                    if isinstance(where, str):
                        where = [where]
                    for base_dir in where:
                        base_path = package_root / base_dir
                        if base_path.exists():
                            for item in base_path.iterdir():
                                if item.is_dir() and (item / "__init__.py").exists():
                                    if not item.name.startswith((".", "_", "test")):
                                        return item.name

                # Check [tool.hatch.build.targets.wheel.packages]
                hatch_packages = (
                    data.get("tool", {})
                    .get("hatch", {})
                    .get("build", {})
                    .get("targets", {})
                    .get("wheel", {})
                    .get("packages", [])
                )
                if hatch_packages:
                    pkg = hatch_packages[0]
                    pkg_name = pkg.split("/")[-1]

                    # Resolve the package path on disk to check for namespace packages
                    pkg_path = package_root / pkg
                    init_py = pkg_path / "__init__.py"

                    # If the directory has no __init__.py, it is a namespace root
                    # (e.g., src/firebird/ for the firebird.* namespace).
                    # Derive the subpackage from the project name.
                    if pkg_path.is_dir() and not init_py.exists():
                        project_name = self._detect_package_name()
                        if project_name:
                            normalized_project = project_name.replace("-", "_")
                            # "firebird-base" → "firebird_base"; namespace "firebird"
                            # → subpackage "base"
                            if normalized_project.startswith(pkg_name + "_"):
                                sub_name = normalized_project[len(pkg_name) + 1 :]
                                sub_path = pkg_path / sub_name
                                if sub_path.is_dir() and (sub_path / "__init__.py").exists():
                                    return f"{pkg_name}.{sub_name}"

                    return pkg_name

            except Exception:
                pass

        # Try to find via _find_package_init
        project_name = self._detect_package_name()
        if project_name:
            init_file = self._find_package_init(project_name)
            if init_file:
                # Compute the dotted module name by finding the layout-root
                # that contains this __init__.py (e.g. src/, python/, lib/
                # or the project root itself).
                pkg_dir = init_file.parent
                for layout_dir in [
                    package_root / "src",
                    package_root / "python",
                    package_root / "lib",
                    package_root,
                ]:
                    try:
                        rel = pkg_dir.relative_to(layout_dir)
                    except ValueError:
                        continue
                    parts = rel.parts
                    if parts:
                        return ".".join(parts)
                # Fallback: just use the immediate directory name
                return pkg_dir.name

        return None

    def _is_compiled_extension(self) -> bool:
        """
        Check if this is a compiled extension package (PyO3, Cython, etc.).

        Returns
        -------
        bool
            True if this appears to be a compiled extension package.
        """
        package_root = self._find_package_root()

        # Check for Cargo.toml (Rust/PyO3)
        if (package_root / "Cargo.toml").exists():
            return True

        # Check for .pyi stub files without corresponding .py files
        pyi_files = list(package_root.glob("*.pyi"))
        for pyi_file in pyi_files:
            if pyi_file.stem != "__init__":
                py_file = pyi_file.with_suffix(".py")
                if not py_file.exists():
                    return True

        return False

    def _find_package_root(self) -> Path:
        """
        Find the actual package root directory.

        Searches upward from `project_root` for the first directory that contains a recognized
        project manifest: `pyproject.toml`, `setup.py`, or `go.mod`. This allows great-docs to be
        invoked from within a subdirectory (e.g. the `great-docs/` build output directory) and still
        resolve the true project root, for both Python and Go projects.

        Returns
        -------
        Path
            The package root directory
        """
        if hasattr(self, "_package_root_cache"):
            return self._package_root_cache

        current = self.project_root

        # Search upward from current directory
        for _ in range(5):  # Limit search to 5 levels up
            if (
                (current / "pyproject.toml").exists()
                or (current / "setup.py").exists()
                or (current / "go.mod").exists()
            ):
                self._package_root_cache = current
                return current
            parent = current.parent
            if parent == current:  # pragma: no cover — reached filesystem root
                break
            current = parent

        # Fallback to project_root if we can't find it
        self._package_root_cache = self.project_root
        return self.project_root

    def _detect_go_cli_project(self):
        """Detect whether the project is a Go CLI project.

        Delegates to `great_docs._go_cli.detect_go_cli_project` with the resolved project root. The
        check is purely file-system based (reads `go.mod` and looks for `main.go` entry-points) and
        never invokes the Go compiler.

        Returns
        -------
        GoCliProject | None
            A `~great_docs._go_cli.GoCliProject` instance when the project root contains a `go.mod`
            and at least one main package, or `None` otherwise.
        """
        from great_docs._go_cli import detect_go_cli_project

        return detect_go_cli_project(self._find_package_root())

    def _griffe_search_paths(self) -> list[str | Path]:
        """Return search paths for griffe so it can find src/python/lib layouts.

        Prepends the package root and common sub-layouts (`src/`, `python/`, `lib/`) in front of
        `sys.path` so that griffe can locate packages in non-flat layouts while still falling back
        to the normal import path.
        """
        import sys

        package_root = self._find_package_root()
        extra: list[str | Path] = [package_root]
        for subdir in ("src", "python", "lib"):
            candidate = package_root / subdir
            if candidate.is_dir():
                extra.append(candidate)
        return extra + sys.path

    def _get_quarto_env(self) -> dict[str, str]:
        """
        Get environment variables for running Quarto commands.

        Sets QUARTO_PYTHON to ensure Quarto uses the correct Python environment
        where the package and its dependencies are installed. This is essential
        for notebook execution and API introspection.

        Also sets PYTHONPATH to include the package root directory, ensuring that
        griffe can find the package even if it's not installed.

        The method looks for Python in the following order:

        1. Virtual environment in the project root (.venv/bin/python or .venv/Scripts/python.exe)
        2. The currently running Python interpreter

        Returns
        -------
        dict[str, str]
            Environment variables dictionary with QUARTO_PYTHON and PYTHONPATH set.
        """
        import sys

        env = os.environ.copy()
        package_root = self._find_package_root()

        # Look for a virtual environment in the project
        venv_paths = [
            package_root / ".venv" / "bin" / "python",  # Unix
            package_root / ".venv" / "Scripts" / "python.exe",  # Windows
            package_root / "venv" / "bin" / "python",  # Alternative name
            package_root / "venv" / "Scripts" / "python.exe",  # Windows alternative
        ]

        python_path = None
        for venv_python in venv_paths:
            if venv_python.exists():
                python_path = str(venv_python)
                break

        # Fallback to the currently running Python
        if python_path is None:
            python_path = sys.executable

        env["QUARTO_PYTHON"] = python_path

        # Add package root to PYTHONPATH so griffe can find the package
        # even if it's not installed (e.g., during development)
        pythonpath_dirs = [str(package_root)]

        # Also add src/ directory if it exists (common src-layout)
        src_dir = package_root / "src"
        if src_dir.exists() and src_dir.is_dir():
            pythonpath_dirs.append(str(src_dir))

        # Also add python/ directory if it exists (common for Rust/PyO3 projects)
        python_dir = package_root / "python"
        if python_dir.exists() and python_dir.is_dir():
            pythonpath_dirs.append(str(python_dir))

        existing_pythonpath = env.get("PYTHONPATH", "")
        new_pythonpath = os.pathsep.join(pythonpath_dirs)
        if existing_pythonpath:
            # Prepend our paths to existing PYTHONPATH
            env["PYTHONPATH"] = f"{new_pythonpath}{os.pathsep}{existing_pythonpath}"
        else:
            env["PYTHONPATH"] = new_pythonpath

        # Ensure Python subprocesses (e.g. post-render.py via Quarto) use UTF-8 on Windows.
        env.setdefault("PYTHONUTF8", "1")

        return env

    def _get_package_metadata(self) -> dict:
        """
        Extract package metadata from pyproject.toml and great-docs configuration.

        Reads project metadata (license, authors, URLs, etc.) from pyproject.toml
        and Great Docs configuration from great-docs.yml.

        Returns
        -------
        dict
            Dictionary containing package metadata and great-docs configuration.
        """
        cache = getattr(self, "_package_metadata_cache", None)
        if cache is not None:
            return cache

        metadata = {}
        package_root = self._find_package_root()
        pyproject_path = package_root / "pyproject.toml"

        # Read project metadata from pyproject.toml
        if pyproject_path.exists():
            import tomllib

            try:
                with open(pyproject_path, "rb") as f:
                    data = tomllib.load(f)
                    project = data.get("project", {})

                    # Extract relevant fields from [project] section
                    # License can be a string (PEP 639) or a dict with "text"/"file" keys
                    license_val = project.get("license", "")
                    classifiers = project.get("classifiers", [])

                    # Try to extract license identifier from classifiers first
                    # e.g., "License :: OSI Approved :: MIT License" -> "MIT"
                    license_from_classifiers = ""
                    for classifier in classifiers:
                        if classifier.startswith("License :: OSI Approved ::"):
                            # Extract the license name from the classifier
                            license_name = classifier.split("::")[-1].strip()
                            # Map common patterns to SPDX identifiers
                            license_map = {
                                "MIT License": "MIT",
                                "Apache Software License": "Apache-2.0",
                                "GNU General Public License v3 (GPLv3)": "GPL-3.0",
                                "GNU General Public License v2 (GPLv2)": "GPL-2.0",
                                "BSD License": "BSD-3-Clause",
                                "ISC License (ISCL)": "ISC",
                                "Mozilla Public License 2.0 (MPL 2.0)": "MPL-2.0",
                            }
                            license_from_classifiers = license_map.get(license_name, license_name)
                            break

                    # Determine the license identifier
                    if license_from_classifiers:
                        # Prefer classifier-derived license (cleaner identifier)
                        metadata["license"] = license_from_classifiers
                    elif isinstance(license_val, dict):
                        # If it's a dict with "text", use that; if "file", we need
                        # to fall back to classifiers or leave as-is
                        text_val = license_val.get("text", "")
                        if text_val:
                            metadata["license"] = text_val
                        else:
                            # File reference - not useful for JSON-LD, leave empty
                            # unless we have a better source
                            metadata["license"] = ""
                    else:
                        metadata["license"] = str(license_val) if license_val else ""
                    metadata["authors"] = project.get("authors", [])
                    metadata["maintainers"] = project.get("maintainers", [])
                    metadata["urls"] = project.get("urls", {})
                    metadata["requires_python"] = project.get("requires-python", "")
                    metadata["keywords"] = project.get("keywords", [])
                    metadata["description"] = project.get("description", "")
                    metadata["optional_dependencies"] = project.get("optional-dependencies", {})

                    # Parse declared runtime dependencies (PEP 508 specifiers)
                    raw_deps = project.get("dependencies", [])
                    parsed_deps = []
                    if raw_deps:
                        from packaging.requirements import Requirement

                        for dep_str in raw_deps:
                            try:
                                req = Requirement(dep_str)
                                entry = {"name": req.name, "specifier": str(req.specifier)}
                                if req.marker:
                                    entry["marker"] = str(req.marker)
                                if req.extras:
                                    entry["extras"] = sorted(req.extras)
                                parsed_deps.append(entry)
                            except Exception:
                                parsed_deps.append({"name": dep_str, "specifier": ""})
                    metadata["dependencies"] = parsed_deps

                    # Parse full optional dependency contents (not just group names)
                    opt_deps_full: dict[str, list[dict]] = {}
                    for group, specs in metadata["optional_dependencies"].items():
                        group_deps = []
                        if isinstance(specs, list):
                            from packaging.requirements import Requirement

                            for dep_str in specs:
                                try:
                                    req = Requirement(dep_str)
                                    entry = {
                                        "name": req.name,
                                        "specifier": str(req.specifier),
                                    }
                                    if req.marker:
                                        entry["marker"] = str(req.marker)
                                    if req.extras:
                                        entry["extras"] = sorted(req.extras)
                                    group_deps.append(entry)
                                except Exception:
                                    group_deps.append({"name": dep_str, "specifier": ""})
                        opt_deps_full[group] = group_deps
                    metadata["optional_dependencies_full"] = opt_deps_full

            except Exception:
                pass

        # Fall back to setup.cfg if pyproject.toml didn't provide key metadata
        if not metadata.get("description"):
            setup_cfg = package_root / "setup.cfg"
            if setup_cfg.exists():
                try:
                    import configparser

                    cfg = configparser.ConfigParser()
                    cfg.read(setup_cfg, encoding="utf-8")

                    if not metadata.get("description"):
                        metadata["description"] = cfg.get("metadata", "description", fallback="")

                    if not metadata.get("license"):
                        metadata["license"] = cfg.get("metadata", "license", fallback="")

                    if not metadata.get("requires_python"):
                        metadata["requires_python"] = cfg.get(
                            "options", "python_requires", fallback=""
                        )

                    if not metadata.get("authors"):
                        author = cfg.get("metadata", "author", fallback="")
                        author_email = cfg.get("metadata", "author_email", fallback="")
                        if author:
                            entry = {"name": author}
                            if author_email:
                                entry["email"] = author_email
                            metadata["authors"] = [entry]

                    if not metadata.get("maintainers"):
                        maintainer = cfg.get("metadata", "maintainer", fallback="")
                        maintainer_email = cfg.get("metadata", "maintainer_email", fallback="")
                        if maintainer:
                            entry = {"name": maintainer}
                            if maintainer_email:
                                entry["email"] = maintainer_email
                            metadata["maintainers"] = [entry]

                    if not metadata.get("urls"):
                        urls = {}
                        # Main url field
                        url = cfg.get("metadata", "url", fallback="")
                        if url:
                            urls["Repository"] = url
                        # project_urls is a multi-line value
                        project_urls_raw = cfg.get("metadata", "project_urls", fallback="")
                        if project_urls_raw:
                            for line in project_urls_raw.strip().splitlines():
                                line = line.strip()
                                if "=" in line:
                                    key, val = line.split("=", 1)
                                    urls[key.strip()] = val.strip()
                        if urls:
                            metadata["urls"] = urls

                except Exception:  # pragma: no cover
                    pass

        # Read Great Docs configuration from great-docs.yml
        # Reload config to ensure we have the latest
        self._config = Config(package_root)

        # Map config properties to metadata dict for backward compatibility
        metadata["rich_authors"] = self._config.authors
        metadata["exclude"] = self._config.exclude
        metadata["auto_include"] = self._config.auto_include
        metadata["no_auto_exclude"] = self._config.no_auto_exclude

        # Source link configuration
        metadata["source_link_enabled"] = self._config.source_enabled
        metadata["source_link_branch"] = self._config.source_branch
        metadata["source_link_path"] = self._config.source_path
        metadata["source_link_placement"] = self._config.source_placement

        # GitHub link style
        metadata["github_style"] = self._config.github_style

        # Sidebar filter configuration
        metadata["sidebar_filter_enabled"] = self._config.sidebar_filter_enabled
        metadata["sidebar_filter_min_items"] = self._config.sidebar_filter_min_items

        # CLI documentation configuration
        metadata["cli_enabled"] = self._config.cli_enabled
        metadata["cli_module"] = self._config.cli_module
        metadata["cli_name"] = self._config.cli_name

        # Go CLI documentation configuration
        metadata["go_cli_enabled"] = self._config.go_cli_enabled

        # MCP documentation configuration
        metadata["mcp_enabled"] = self._config.mcp_enabled

        # Dark mode toggle
        metadata["dark_mode_toggle_enabled"] = self._config.dark_mode_toggle

        # Back-to-top button
        metadata["back_to_top_enabled"] = self._config.back_to_top

        # Keyboard navigation
        metadata["keyboard_nav_enabled"] = self._config.keyboard_nav

        # Markdown pages (.md generation + copy-page widget)
        metadata["markdown_pages"] = self._config.markdown_pages
        metadata["markdown_pages_widget"] = self._config.markdown_pages_widget

        # Funding organization
        metadata["funding"] = self._config.funding

        # Logo & favicon configuration
        metadata["logo"] = self._config.logo
        metadata["logo_show_title"] = self._config.logo_show_title
        metadata["favicon"] = self._config.favicon

        if pyproject_path.exists():
            self._package_metadata_cache = metadata
        return metadata

    def _update_navbar_github_link(
        self,
        config: dict,
        owner: str | None,
        repo: str | None,
        repo_url: str | None,
        github_style: str,
    ) -> None:
        """
        Update an existing navbar's GitHub link to use widget or icon style.

        Parameters
        ----------
        config
            The Quarto configuration dictionary.
        owner
            GitHub repository owner.
        repo
            GitHub repository name.
        repo_url
            Full GitHub repository URL.
        github_style
            Either "widget" (with stats dropdown) or "icon" (simple link).
        """
        if not repo_url:
            return

        navbar = config["website"]["navbar"]

        # Ensure right section exists
        if "right" not in navbar:
            navbar["right"] = []

        # Build the new GitHub entry based on style
        if github_style == "widget" and owner and repo:
            stats = self._fetch_github_repo_stats(owner, repo)
            stats_attrs = ""
            if stats:
                stats_attrs = f' data-stars="{stats["stars"]}" data-forks="{stats["forks"]}"'
            new_gh_entry = {
                "text": f'<div id="github-widget" data-owner="{owner}" data-repo="{repo}"{stats_attrs}></div>'
            }
        else:
            new_gh_entry = {"icon": "github", "href": repo_url}

        # Look for existing GitHub entry and replace it
        new_right = []
        found_github = False

        for item in navbar["right"]:
            if isinstance(item, dict):
                # Check for simple GitHub icon
                if item.get("icon") == "github":
                    new_right.append(new_gh_entry)
                    found_github = True
                # Check for existing widget
                elif "github-widget" in str(item.get("text", "")):
                    new_right.append(new_gh_entry)
                    found_github = True
                else:
                    new_right.append(item)
            else:
                new_right.append(item)

        # If no GitHub entry was found, add one
        if not found_github:
            new_right.append(new_gh_entry)

        navbar["right"] = new_right

    def _get_github_repo_info(self) -> tuple[str | None, str | None, str | None]:
        """
        Extract GitHub repository information.

        Checks `repo` in great-docs.yml first (as an override), then falls
        back to `[project.urls]` in pyproject.toml.

        Returns
        -------
        tuple[str | None, str | None, str | None]
            A tuple of (owner, repo_name, base_url) or (None, None, None) if not found.
        """
        metadata = self._get_package_metadata()

        # Check great-docs.yml `repo:` override first
        repo_url = self._config.repo

        # Fall back to pyproject.toml [project.urls]
        if not repo_url:
            urls = metadata.get("urls", {})
            for key in [
                "Repository",
                "repository",
                "Source",
                "source",
                "GitHub",
                "github",
                "Homepage",
                "homepage",
            ]:
                if key in urls:
                    repo_url = urls[key]
                    break

        if not repo_url or "github.com" not in repo_url:
            return None, None, None

        # Parse the GitHub URL to extract owner and repo. Handles https, ssh, and
        # `.git`-suffixed forms; see great_docs._pr_preview.parse_github_url.
        from ._pr_preview import parse_github_url

        parsed = parse_github_url(repo_url)
        if parsed:
            owner, repo = parsed
            base_url = f"https://github.com/{owner}/{repo}"
            return owner, repo, base_url

        return None, None, None  # pragma: no cover

    def _fetch_github_repo_stats(self, owner: str, repo: str) -> dict[str, int]:
        """
        Fetch repository star and fork counts from the GitHub API at build time.

        Uses GITHUB_TOKEN / GH_TOKEN if available for higher rate limits.

        Returns
        -------
        dict[str, int]
            A dict with 'stars' and 'forks' keys, or empty dict on failure.
        """
        import requests

        headers = {
            "Accept": "application/vnd.github+json",
            "X-GitHub-Api-Version": "2022-11-28",
        }

        token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
        if token:
            headers["Authorization"] = f"Bearer {token}"

        url = f"https://api.github.com/repos/{owner}/{repo}"

        try:
            resp = requests.get(url, headers=headers, timeout=15)
            if resp.status_code == 200:
                data = resp.json()
                return {
                    "stars": data.get("stargazers_count", 0),
                    "forks": data.get("forks_count", 0),
                }
        except requests.RequestException:
            pass

        return {}

    # =========================================================================
    # Changelog (GitHub Releases) Methods
    # =========================================================================

    def _fetch_github_releases(
        self,
        owner: str,
        repo: str,
        max_releases: int = 50,
    ) -> list[dict]:
        """
        Fetch releases from the GitHub API.

        Parameters
        ----------
        owner
            GitHub repository owner.
        repo
            GitHub repository name.
        max_releases
            Maximum number of releases to return.

        Returns
        -------
        list[dict]
            List of release dicts with keys: tag_name, name, body, published_at,
            html_url, prerelease, draft.
        """
        import requests

        releases: list[dict] = []
        per_page = min(max_releases, 100)
        page = 1

        headers = {"Accept": "application/vnd.github+json"}

        # Honour GITHUB_TOKEN / GH_TOKEN for higher rate limits
        token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
        if token:
            headers["Authorization"] = f"Bearer {token}"

        while len(releases) < max_releases:
            url = (
                f"https://api.github.com/repos/{owner}/{repo}/releases"
                f"?per_page={per_page}&page={page}"
            )

            try:
                resp = requests.get(url, headers=headers, timeout=30)
            except requests.RequestException as exc:
                print(f"   ⚠️  Failed to fetch GitHub releases: {exc}")
                break

            if resp.status_code == 403:
                # Rate-limited
                print("   ⚠️  GitHub API rate limit reached; changelog may be incomplete")
                break
            if resp.status_code == 404:
                print(f"   ⚠️  GitHub repository {owner}/{repo} not found")
                break
            if resp.status_code != 200:
                print(f"   ⚠️  GitHub API returned status {resp.status_code}")
                break

            data = resp.json()
            if not data:
                break  # No more pages  # pragma: no cover

            for release in data:
                if release.get("draft"):  # pragma: no cover
                    continue  # Skip drafts
                releases.append(
                    {
                        "tag_name": release.get("tag_name", ""),
                        "name": release.get("name", ""),
                        "body": release.get("body", ""),
                        "published_at": release.get("published_at", ""),
                        "html_url": release.get("html_url", ""),
                        "prerelease": release.get("prerelease", False),
                    }
                )
                if len(releases) >= max_releases:
                    break

            if len(data) < per_page:
                break  # Last page
            page += 1

        return releases

    @staticmethod
    def _linkify_github_references(text: str, owner: str, repo: str) -> str:
        """
        Turn GitHub shorthand references into Markdown links.

        Converts bare `#123`, `gh issue #123`, `gh pr #123`, and
        `@username` references into clickable Markdown links that point to
        the correct GitHub URL.

        Parameters
        ----------
        text
            The Markdown body text from a GitHub Release.
        owner
            GitHub repository owner.
        repo
            GitHub repository name.

        Returns
        -------
        str
            Text with shorthand references replaced by Markdown links.
        """
        base = f"https://github.com/{owner}/{repo}"

        # 0.  Remove GitHub's backslash escapes (the API often returns
        #     \@, \#, \', \" which break Markdown rendering and block
        #     the regex patterns below).
        text = re.sub(r"\\([\\@#'\"])", r"\1", text)

        # 1.  "gh issue #NNN" / "gh pr #NNN"  (case-insensitive)
        text = re.sub(
            r"(?i)\bgh\s+(?:issue|pr)\s+#(\d+)",
            rf"[#\1]({base}/issues/\1)",
            text,
        )

        # 2.  Bare "#NNN" not already inside a Markdown link.
        #     Skip when preceded by '[' (link text from step 1) or
        #     when inside a URL (preceded by '/').
        text = re.sub(
            r"(?<!\[)(?<!/)#(\d+)\b",
            rf"[#\1]({base}/issues/\1)",
            text,
        )

        # 3.  "@username": valid GitHub usernames: alphanumeric + hyphens,
        #     1-39 chars, not starting/ending with hyphen.
        #     Negative look-behind avoids matching email addresses.
        text = re.sub(
            r"(?<![\w.])@([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?)",
            r"[@\1](https://github.com/\1)",
            text,
        )

        # 4. Bare GitHub compare URLs (e.g., "Full Changelog: https://...")
        #    are not always auto-linked by downstream Markdown renderers.
        #    Wrap them explicitly as Markdown links when not already linked.
        compare_url_pattern = re.escape(base) + r"/compare/[^\s)]+"
        text = re.sub(
            rf"(?<!\[)(?<!\(){compare_url_pattern}",
            lambda m: f"[{m.group(0)}]({m.group(0)})",
            text,
        )

        return text

    def _generate_changelog_page(self) -> str | None:
        """
        Generate a changelog.qmd page from GitHub Releases.

        Fetches releases via the GitHub API and renders them as a dated
        Markdown changelog. Returns the filename written (`"changelog.qmd"`)
        or `None` if the page could not be generated.

        Returns
        -------
        str | None
            `"changelog.qmd"` on success, `None` otherwise.
        """
        owner, repo, _base_url = self._get_github_repo_info()
        if not owner or not repo:
            return None

        max_releases = self._config.changelog_max_releases
        releases = self._fetch_github_releases(owner, repo, max_releases)
        if not releases:
            return None

        # Build .qmd content
        lines: list[str] = [
            "---",
            'title: "Changelog"',
            "toc: true",
            "toc-depth: 2",
            "---",
            "",
            "This changelog is generated automatically from ",
            f"[GitHub Releases](https://github.com/{owner}/{repo}/releases).",
            "",
        ]

        for rel in releases:
            # --- heading ------------------------------------------------
            tag = rel["tag_name"]
            name = rel["name"] or tag
            published = rel["published_at"][:10] if rel["published_at"] else ""
            gh_url = rel["html_url"]

            heading = f"## {name}  {{.changelog-version}}"  # noqa: F541

            lines.append(heading)
            lines.append("")

            # Date + link line
            meta_parts: list[str] = []
            if published:
                meta_parts.append(f"*{published}*")
            if rel["prerelease"]:
                meta_parts.append("*(pre-release)*")
            meta_parts.append(f"[GitHub]({gh_url})")
            lines.append(" · ".join(meta_parts))
            lines.append("")

            # --- body ---------------------------------------------------
            body = (rel.get("body") or "").strip()
            if body:
                body = self._linkify_github_references(body, owner, repo)

                # Ensure body headings nest under the version ## heading.
                # Bump ##..###### → ###..#######  (i.e., add one # to every heading)
                body = re.sub(r"^(#{2,6})\s", lambda m: m.group(1) + "# ", body, flags=re.MULTILINE)

                lines.append(body)
                lines.append("")

        changelog_path = self.project_path / "changelog.qmd"
        changelog_path.write_text("\n".join(lines), encoding="utf-8")
        print(f"Created {changelog_path}")

        return "changelog.qmd"

    def _add_changelog_to_navbar(self) -> None:
        """Add a *Changelog* link to the navbar (idempotent)."""
        quarto_yml = self.project_path / "_quarto.yml"
        if not quarto_yml.exists():
            return

        with open(quarto_yml, "r") as f:
            config = read_yaml(f) or {}

        navbar = config.get("website", {}).get("navbar")
        if not navbar or "left" not in navbar:
            return

        # Already present?
        if any(
            isinstance(item, dict) and item.get("text") == "Changelog" for item in navbar["left"]
        ):
            return

        # Insert before the last item (usually Reference) or at end
        navbar["left"].append({"text": "Changelog", "href": "changelog.qmd"})

        self._write_quarto_yml(quarto_yml, config)

    # =========================================================================
    # Page Tags Methods
    # =========================================================================

    def _section_build_dir(self, section_cfg: dict[str, object]) -> Path | None:
        """
        Resolve the build-time destination directory for a custom section.

        Mirrors the slug logic in `_process_sections` so that tag and status
        scanning look at the same directory the section files were copied into.
        The build directory is derived from `section_cfg["dir"]` (not the title),
        which is what `_process_sections` uses as the copy destination.

        Parameters
        ----------
        section_cfg
            A single entry from the `sections` config.

        Returns
        -------
        Path | None
            The build directory under `project_path`, or `None` if the section
            has no `dir`.
        """
        src_dir = section_cfg.get("dir")
        if not src_dir or not isinstance(src_dir, str):
            return None
        slug = src_dir.replace("_", "-").replace(" ", "-").lower()
        return self.project_path / slug

    def _collect_page_tags(self) -> dict[str, list[dict[str, str]]]:
        """
        Scan all built .qmd files for tags in frontmatter.

        Collects tags from user-guide, recipe, and custom-section pages.
        Returns a dict mapping normalized tag names to lists of page info dicts.

        Returns
        -------
        dict[str, list[dict[str, str]]]
            Mapping of tag name → list of `{"title": ..., "href": ..., "section": ...}`.
        """
        tag_index: dict[str, list[dict[str, str]]] = {}
        shadow_tags = set(self._config.tags_shadow)

        # Directories to scan for tagged pages
        scan_dirs: list[tuple[Path, str]] = []
        seen_dirs: set[Path] = set()
        ug_dir = self.project_path / "user-guide"
        if ug_dir.is_dir():
            scan_dirs.append((ug_dir, "User Guide"))
            seen_dirs.add(ug_dir.resolve())
        recipes_dir = self.project_path / "recipes"
        if recipes_dir.is_dir():
            scan_dirs.append((recipes_dir, "Recipes"))
            seen_dirs.add(recipes_dir.resolve())
        # Also scan custom sections (skip if already covered above)
        for section_cfg in self._config.sections:
            title = section_cfg.get("title", "")
            section_dir = self._section_build_dir(section_cfg)
            if (
                section_dir is not None
                and section_dir.is_dir()
                and section_dir.resolve() not in seen_dirs
            ):
                scan_dirs.append((section_dir, title))
                seen_dirs.add(section_dir.resolve())

        for scan_dir, section_name in scan_dirs:
            for qmd_file in sorted(scan_dir.rglob("*.qmd")):
                if qmd_file.name == "index.qmd":
                    continue
                try:
                    content = qmd_file.read_text(encoding="utf-8")
                except (OSError, UnicodeDecodeError):  # pragma: no cover
                    continue  # pragma: no cover

                fm, _ = self._split_frontmatter(content)
                raw_tags = fm.get("tags", [])
                if not raw_tags or not isinstance(raw_tags, list):
                    continue

                page_title = fm.get("title", self._derive_page_title(qmd_file))
                page_href = str(qmd_file.relative_to(self.project_path))

                for tag in raw_tags:
                    tag_str = str(tag).strip()
                    if not tag_str:
                        continue  # pragma: no cover
                    # Skip shadow tags from the public index
                    if tag_str in shadow_tags:
                        continue
                    tag_index.setdefault(tag_str, []).append(
                        {"title": page_title, "href": page_href, "section": section_name}
                    )

        return tag_index

    @staticmethod
    def _split_tag_parts(tag: str) -> list[str]:
        r"""Split a tag on unescaped `/` separators.

        A backslash-escaped slash (`\/`) is treated as a literal `/`
        character and does **not** create a hierarchy level.  After splitting,
        each part has `\/` replaced with `/`.
        """
        # Split on "/" that is NOT preceded by "\"
        parts = re.split(r"(?<!\\)/", tag)
        return [p.replace("\\/", "/").strip() for p in parts if p.replace("\\/", "/").strip()]

    def _build_tag_hierarchy(self, tag_index: dict[str, list[dict[str, str]]]) -> dict:
        """
        Build a hierarchical tree from slash-separated tags.

        Parameters
        ----------
        tag_index
            Flat tag-to-pages mapping from `_collect_page_tags()`.

        Returns
        -------
        dict
            Nested dict with `"__pages__"` lists at each node.
        """
        tree: dict = {}
        for tag, pages in sorted(tag_index.items()):
            parts = (
                self._split_tag_parts(tag)
                if self._config.tags_hierarchical
                else [tag.replace("\\/", "/")]
            )
            node = tree
            for part in parts:
                part = part.strip()
                if part:
                    node = node.setdefault(part, {})
            node.setdefault("__pages__", []).extend(pages)
        return tree

    def _generate_tags_index_page(self, tag_index: dict[str, list[dict[str, str]]]) -> str:
        """
        Generate a `tags/index.qmd` page listing all tags and their linked pages.

        Parameters
        ----------
        tag_index
            Tag-to-pages mapping from `_collect_page_tags()`.

        Returns
        -------
        str
            The relative path of the generated file (`tags/index.qmd`).
        """
        from ._translations import get_translation

        lang = self._config.language
        tags_title = get_translation("site_tags", lang)
        tag_icons = self._config.tags_icons

        tags_dir = self.project_path / "tags"
        tags_dir.mkdir(parents=True, exist_ok=True)

        tags_intro = get_translation("tags_intro", lang)

        lines: list[str] = [
            "---",
            f'title: "{tags_title}"',
            "bread-crumbs: false",
            "toc: false",
            "body-classes: gd-tags-index",
            "---",
            "",
            tags_intro,
            "",
        ]

        # Build the tag hierarchy for nested display
        if self._config.tags_hierarchical:
            hierarchy = self._build_tag_hierarchy(tag_index)
            self._render_tag_tree(lines, hierarchy, tag_icons, depth=0, tag_index=tag_index)
        else:
            # Flat listing
            for tag_name in sorted(tag_index.keys(), key=str.lower):
                pages = tag_index[tag_name]
                display_name = tag_name.replace("\\/", "/")
                icon_html = self._get_tag_icon_html(tag_name, tag_icons)
                tooltip = self._tag_tooltip(pages, lang=lang)
                pill_html = self._tag_heading_pill(display_name, icon_html, tooltip=tooltip)
                lines.append(f'<div class="gd-tag-heading">{pill_html}</div>')
                lines.append("")
                lines.append('<div class="gd-tag-pages">')
                for page in pages:
                    section_badge = (
                        f' <span class="gd-tag-section">{page["section"]}</span>'
                        if page.get("section")
                        else ""
                    )
                    lines.append(
                        f'<div class="gd-tag-page-link">[{page["title"]}](../{page["href"]}){section_badge}</div>'
                    )
                lines.append("</div>")
                lines.append("")

        index_path = tags_dir / "index.qmd"
        index_path.write_text("\n".join(lines), encoding="utf-8")
        print(f"Created {index_path.relative_to(self.project_path)}")
        return "tags/index.qmd"

    def _render_tag_tree(
        self,
        lines: list[str],
        tree: dict,
        tag_icons: dict[str, str],
        depth: int,
        prefix: str = "",
        tag_index: dict[str, list[dict[str, str]]] | None = None,
    ) -> None:
        """Recursively render the tag hierarchy into Markdown lines."""
        heading_level = min(depth + 2, 6)  # h2, h3, h4 ...
        heading = "#" * heading_level

        for node_name, subtree in sorted(tree.items(), key=lambda x: x[0].lower()):
            if node_name == "__pages__":
                continue

            full_tag = f"{prefix}/{node_name}" if prefix else node_name
            icon_html = self._get_tag_icon_html(full_tag, tag_icons)
            if not icon_html:
                icon_html = self._get_tag_icon_html(node_name, tag_icons)
            parent_label = prefix if prefix else ""
            indent_class = f" gd-tag-indent-{min(depth, 2)}" if depth > 0 else ""

            # Collect pages for tooltip — use __pages__ from the tree node since
            # tag_index keys may be escaped (e.g. "AI\/LLM") while full_tag is
            # the unescaped display form.
            node_pages = subtree.get("__pages__", [])

            # For segmented pills, icon goes on the parent (LHS)
            if parent_label:
                parent_icon = self._get_tag_icon_html(parent_label, tag_icons)
                tooltip = self._tag_tooltip(node_pages, lang=self._config.language)
                pill_html = self._tag_heading_pill(
                    node_name,
                    "",
                    parent=parent_label,
                    parent_icon=parent_icon,
                    tooltip=tooltip,
                )
            else:
                tooltip = self._tag_tooltip(node_pages, lang=self._config.language)
                pill_html = self._tag_heading_pill(node_name, icon_html, tooltip=tooltip)
            lines.append(f'<div class="gd-tag-heading{indent_class}">{pill_html}</div>')
            lines.append("")

            # Pages directly under this tag
            pages = subtree.get("__pages__", [])
            if pages:
                lines.append(f'<div class="gd-tag-pages{indent_class}">')
                for page in pages:
                    section_badge = (
                        f' <span class="gd-tag-section">{page["section"]}</span>'
                        if page.get("section")
                        else ""
                    )
                    lines.append(
                        f'<div class="gd-tag-page-link">[{page["title"]}](../{page["href"]}){section_badge}</div>'
                    )
                lines.append("</div>")
                lines.append("")

            # Recurse into children
            child_keys = [k for k in subtree if k != "__pages__"]
            if child_keys:
                self._render_tag_tree(
                    lines,
                    subtree,
                    tag_icons,
                    depth + 1,
                    full_tag,
                    tag_index=tag_index,
                )

    def _generate_tags_json(self, tag_index: dict[str, list[dict[str, str]]]) -> None:
        """
        Write `_tags.json` for the client-side JS to render tag pills.

        Parameters
        ----------
        tag_index
            Tag-to-pages mapping from `_collect_page_tags()`.
        """
        # Build a page-centric index: href → [tag1, tag2, ...]
        page_tags: dict[str, list[str]] = {}
        shadow_tags = set(self._config.tags_shadow)

        for tag_name, pages in tag_index.items():
            for page in pages:
                page_tags.setdefault(page["href"], []).append(tag_name)

        # Also collect shadow tags per page (so they get meta tags but no pills)
        # and per-page tag_location overrides from frontmatter
        # Re-scan tagged directories
        page_tag_locations: dict[str, str] = {}
        scan_dirs: list[Path] = [self.project_path / "user-guide", self.project_path / "recipes"]
        seen_dirs: set[Path] = {d.resolve() for d in scan_dirs}
        # Include custom section directories
        for section_cfg in self._config.sections:
            section_dir = self._section_build_dir(section_cfg)
            if section_dir is not None and section_dir.resolve() not in seen_dirs:
                scan_dirs.append(section_dir)
                seen_dirs.add(section_dir.resolve())
        for scan_dir in scan_dirs:
            if not scan_dir.is_dir():
                continue
            for qmd_file in sorted(scan_dir.rglob("*.qmd")):
                if qmd_file.name == "index.qmd":
                    continue
                try:
                    content = qmd_file.read_text(encoding="utf-8")
                except (OSError, UnicodeDecodeError):
                    continue
                fm, _ = self._split_frontmatter(content)
                raw_tags = fm.get("tags", [])
                if not raw_tags or not isinstance(raw_tags, list):
                    continue
                href = str(qmd_file.relative_to(self.project_path))
                for tag in raw_tags:
                    tag_str = str(tag).strip()
                    if tag_str and tag_str in shadow_tags:
                        page_tags.setdefault(href, [])
                        # Shadow tags are NOT added to the visible list

                # Collect per-page tag-location override
                tag_loc = fm.get("tag-location")
                if tag_loc in ("top", "bottom"):
                    page_tag_locations[href] = tag_loc

        # Build per-tag metadata (page count + sections) for tooltips
        tag_meta: dict[str, dict] = {}
        for tag_name, pages in tag_index.items():
            sections = sorted({p["section"] for p in pages if p.get("section")})
            tag_meta[tag_name] = {"count": len(pages), "sections": sections}

        # Resolve tooltip templates for client-side JS
        from ._translations import get_translation

        lang = self._config.language
        tooltip_templates = {
            "one": get_translation("tags_page_count_one", lang),
            "other": get_translation("tags_page_count_other", lang),
            "one_no_section": get_translation("tags_page_count_one_no_section", lang),
            "other_no_section": get_translation("tags_page_count_other_no_section", lang),
        }

        # Write the JSON
        # Resolve icon names to inline Lucide SVGs for client-side rendering
        from ._icons import get_icon_svg

        resolved_icons: dict[str, str] = {}
        for tag_label, icon_name in self._config.tags_icons.items():
            svg = get_icon_svg(icon_name, size=14, css_class="gd-tag-icon-svg")
            if svg:
                resolved_icons[tag_label] = svg
            else:
                print(f"Warning: Unknown tag icon '{icon_name}' for tag '{tag_label}'")

        tags_json = {
            "page_tags": page_tags,
            "tag_meta": tag_meta,
            "tooltip_templates": tooltip_templates,
            "icons": resolved_icons,
            "shadow": list(shadow_tags),
            "hierarchical": self._config.tags_hierarchical,
            "default_location": self._config.tags_location,
            "page_tag_locations": page_tag_locations,
        }
        tags_path = self.project_path / "_tags.json"
        with open(tags_path, "w", encoding="utf-8") as f:
            json.dump(tags_json, f)

    def _add_tags_to_navbar(self) -> None:
        """Add a *Tags* link to the navbar (idempotent)."""
        from ._translations import get_translation

        lang = self._config.language
        tags_label = get_translation("site_tags", lang)

        quarto_yml = self.project_path / "_quarto.yml"
        if not quarto_yml.exists():
            return

        with open(quarto_yml, "r") as f:
            config = read_yaml(f) or {}

        navbar = config.get("website", {}).get("navbar")
        if not navbar or "left" not in navbar:
            return

        # Already present?
        if any(
            isinstance(item, dict) and item.get("text") == tags_label for item in navbar["left"]
        ):
            return

        link = {"text": tags_label, "href": "tags/index.qmd"}
        # Insert before Reference
        self._insert_before_reference(navbar["left"], link)

        self._write_quarto_yml(quarto_yml, config)

    def _process_tags(self) -> bool:
        """
        Main entry point for the page tags feature.

        Collects tags from frontmatter, generates the tags index page, writes `_tags.json` for
        client-side rendering, and wires the navbar link.

        Returns
        -------
        bool
            `True` if any tags were found and processed.
        """
        tag_index = self._collect_page_tags()
        if not tag_index:
            return False

        # Generate the tags index page
        if self._config.tags_index_page:
            self._generate_tags_index_page(tag_index)

        # Write JSON for client-side tag pill rendering
        if self._config.tags_show_on_pages:
            self._generate_tags_json(tag_index)
            self._inject_tags_data_inline()

        return True

    def _inject_tags_data_inline(self) -> None:
        """Inject `_tags.json` data as an inline `<script>` in `_quarto.yml`.

        This ensures `page-tags.js` can read the data directly from `window.__GD_TAGS_DATA__`
        without an XHR request, which would fail under `file://` protocol or when the relative path
        depth is wrong.
        """
        tags_json_path = self.project_path / "_tags.json"
        if not tags_json_path.is_file():
            return

        quarto_yml = self.project_path / "_quarto.yml"
        if not quarto_yml.is_file():
            return

        import yaml

        with open(quarto_yml, encoding="utf-8") as f:
            config = yaml.safe_load(f)

        if "format" not in config or "html" not in config.get("format", {}):
            return  # pragma: no cover

        if "include-after-body" not in config["format"]["html"]:
            config["format"]["html"]["include-after-body"] = []  # pragma: no cover
        elif isinstance(config["format"]["html"]["include-after-body"], str):
            config["format"]["html"]["include-after-body"] = [  # pragma: no cover
                config["format"]["html"]["include-after-body"]
            ]

        # Check if already injected
        entries = config["format"]["html"]["include-after-body"]
        if any("__GD_TAGS_DATA__" in str(item) for item in entries):
            return

        tags_json_content = tags_json_path.read_text(encoding="utf-8")
        # Escape "</" sequences so the HTML parser doesn't treat them as
        # closing tags inside the <script> block (e.g. "</svg>" in icon SVGs).
        tags_json_content = tags_json_content.replace("</", r"<\/")
        inline_entry = {
            "text": ("<script>window.__GD_TAGS_DATA__=" + tags_json_content + ";</script>")
        }

        # Insert before the page-tags.js script entry (if present) so data is
        # available when the script runs
        insert_idx = None
        for idx, item in enumerate(entries):
            if "page-tags.js" in str(item):
                insert_idx = idx
                break
        if insert_idx is not None:
            entries.insert(insert_idx, inline_entry)
        else:
            entries.append(inline_entry)  # pragma: no cover

        with open(quarto_yml, "w", encoding="utf-8") as f:
            yaml.dump(config, f, default_flow_style=False, sort_keys=False)

    @staticmethod
    def _tag_slug(tag_name: str) -> str:
        """Convert a tag name to a URL-friendly slug."""
        # Unescape literal slashes first so "AI\/LLM" → "AI/LLM" → "ai-llm"
        clean = tag_name.replace("\\/", "/")
        return re.sub(r"[^a-z0-9]+", "-", clean.lower()).strip("-")

    @staticmethod
    def _get_tag_icon_html(tag_name: str, tag_icons: dict[str, str]) -> str:
        """Return an inline Lucide SVG icon for a tag, or empty string."""
        from ._icons import get_icon_svg

        # Try the raw key first, then the unescaped form (e.g. "AI\/LLM" → "AI/LLM")
        icon_name = tag_icons.get(tag_name) or tag_icons.get(tag_name.replace("\\/", "/"))
        if not icon_name:
            return ""
        svg = get_icon_svg(icon_name, size=14, css_class="gd-tag-icon-svg")
        if not svg:
            return ""
        return f'<span style="margin-right:0.4em;display:inline-flex;vertical-align:middle">{svg}</span>'

    @staticmethod
    def _tag_tooltip(pages: list[dict[str, str]], lang: str = "en") -> str:
        """Build a translated tooltip string like `'2 pages in User Guide'`."""
        from ._translations import get_translation

        n = len(pages)
        if n == 0:
            return ""
        sections = sorted({p["section"] for p in pages if p.get("section")})
        if sections:
            key = "tags_page_count_one" if n == 1 else "tags_page_count_other"
            template = get_translation(key, lang)
            return template.replace("{count}", str(n)).replace("{sections}", ", ".join(sections))
        else:
            key = "tags_page_count_one_no_section" if n == 1 else "tags_page_count_other_no_section"
            template = get_translation(key, lang)
            return template.replace("{count}", str(n))

    @staticmethod
    def _tag_heading_pill(
        tag_name: str,
        icon_html: str,
        parent: str = "",
        parent_icon: str = "",
        tooltip: str = "",
    ) -> str:
        """Return a pill-styled `<span>` for use as a tag heading.

        For hierarchical child tags, renders a segmented pill with the parent
        as a muted left segment separated by a vertical line.  The icon always
        appears on the parent (LHS); the child (RHS) never has one.
        """
        tip_attr = f' data-tippy-content="{tooltip}"' if tooltip else ""
        if parent:
            return (
                f'<span class="gd-tag-pill gd-tag-pill-segmented"{tip_attr}>'
                f'<span class="gd-tag-pill-segment gd-tag-pill-parent">{parent_icon}{parent}</span>'
                '<span class="gd-tag-pill-sep"></span>'
                f'<span class="gd-tag-pill-segment">{tag_name}</span>'
                "</span>"
            )
        return f'<span class="gd-tag-pill"{tip_attr}>{icon_html}{tag_name}</span>'

    # =========================================================================
    # Page Status Badges Methods
    # =========================================================================

    def _collect_page_statuses(self) -> dict[str, str]:
        """
        Scan all built .qmd files for `status` in frontmatter.

        Returns a mapping of page href (relative to build dir) to the status string (e.g. `"new"`,
        `"deprecated"`).

        Returns
        -------
        dict[str, str]
            Mapping of page href → status string.
        """
        status_map: dict[str, str] = {}
        valid_statuses = set(self._config.page_status_definitions.keys())

        # Directories to scan (same as tags)
        scan_dirs: list[Path] = []
        seen_dirs: set[Path] = set()
        for subdir in ("user-guide", "recipes", "reference"):
            d = self.project_path / subdir
            if d.is_dir():
                scan_dirs.append(d)
                seen_dirs.add(d.resolve())
        for section_cfg in self._config.sections:
            section_dir = self._section_build_dir(section_cfg)
            if (
                section_dir is not None
                and section_dir.is_dir()
                and section_dir.resolve() not in seen_dirs
            ):
                scan_dirs.append(section_dir)
                seen_dirs.add(section_dir.resolve())

        for scan_dir in scan_dirs:
            for qmd_file in sorted(scan_dir.rglob("*.qmd")):
                try:
                    content = qmd_file.read_text(encoding="utf-8")
                except (OSError, UnicodeDecodeError):  # pragma: no cover
                    continue  # pragma: no cover

                fm, _ = self._split_frontmatter(content)
                status = fm.get("status")
                if not status or not isinstance(status, str):
                    continue
                status = status.strip().lower()
                if status not in valid_statuses:
                    print(
                        f"Warning: Unknown page status '{status}' in "
                        f"{qmd_file.relative_to(self.project_path)}"
                    )
                    continue
                href = str(qmd_file.relative_to(self.project_path))
                status_map[href] = status

        return status_map

    def _generate_status_json(self, status_map: dict[str, str]) -> None:
        """
        Write `_page_status.json` for the client-side JS to render badges.

        Parameters
        ----------
        status_map
            Page href → status string mapping from `_collect_page_statuses()`.

        """
        from ._icons import get_icon_svg
        from ._translations import get_translation

        lang = self._config.language
        builtin_statuses = {"new", "updated", "beta", "deprecated", "experimental", "upcoming"}

        definitions = self._config.page_status_definitions
        resolved_statuses: dict[str, dict[str, str]] = {}
        for status_key, status_def in definitions.items():
            icon_name = status_def.get("icon", "")
            svg = ""
            if icon_name:
                svg = get_icon_svg(icon_name, size=12, css_class="gd-status-icon-svg") or ""

            if status_key in builtin_statuses:
                label = get_translation(f"status_label_{status_key}", lang)
                description = get_translation(f"status_desc_{status_key}", lang)
            else:
                label = status_def.get("label", status_key.title())  # pragma: no cover
                description = status_def.get("description", "")  # pragma: no cover

            resolved_statuses[status_key] = {
                "label": label,
                "icon": svg,
                "color": status_def.get("color", "#6b7280"),
                "description": description,
            }

        status_json = {
            "page_statuses": status_map,
            "definitions": resolved_statuses,
            "show_in_sidebar": self._config.page_status_show_in_sidebar,
            "show_on_pages": self._config.page_status_show_on_pages,
        }
        status_path = self.project_path / "_page_status.json"
        with open(status_path, "w", encoding="utf-8") as f:
            json.dump(status_json, f)

    def _inject_status_data_inline(self) -> None:
        """Inject `_page_status.json` data as an inline `<script>` in `_quarto.yml`.

        Ensures `page-status-badges.js` can read status data directly from
        `window.__GD_STATUS_DATA__`.
        """
        status_json_path = self.project_path / "_page_status.json"
        if not status_json_path.is_file():
            return

        quarto_yml = self.project_path / "_quarto.yml"
        if not quarto_yml.is_file():
            return

        import yaml

        with open(quarto_yml, encoding="utf-8") as f:
            config = yaml.safe_load(f)

        if "format" not in config or "html" not in config.get("format", {}):
            return  # pragma: no cover

        if "include-after-body" not in config["format"]["html"]:
            config["format"]["html"]["include-after-body"] = []  # pragma: no cover
        elif isinstance(config["format"]["html"]["include-after-body"], str):
            config["format"]["html"]["include-after-body"] = [
                config["format"]["html"]["include-after-body"]
            ]

        entries = config["format"]["html"]["include-after-body"]
        # Check if the data assignment (not just a reference) is already injected.
        # The inlined JS reads from __GD_STATUS_DATA__ but we need the assignment.
        if any("__GD_STATUS_DATA__=" in str(item) for item in entries):
            return  # pragma: no cover

        status_json_content = status_json_path.read_text(encoding="utf-8")
        status_json_content = status_json_content.replace("</", r"<\/")
        inline_entry = {
            "text": ("<script>window.__GD_STATUS_DATA__=" + status_json_content + ";</script>")
        }

        # Insert before the page-status-badges script entry (if present)
        insert_idx = None
        for idx, item in enumerate(entries):
            if "page-status-badges" in str(item) or "renderPageBadge" in str(item):
                insert_idx = idx
                break
        if insert_idx is not None:
            entries.insert(insert_idx, inline_entry)
        else:
            entries.append(inline_entry)

        with open(quarto_yml, "w", encoding="utf-8") as f:
            yaml.dump(config, f, default_flow_style=False, sort_keys=False)

    def _process_page_statuses(self) -> bool:
        """
        Main entry point for the page status badges feature.

        Collects statuses from frontmatter, writes `_page_status.json`, and injects the inline data
        script.

        Returns
        -------
        bool
            `True` if any pages with status were found and processed.
        """
        status_map = self._collect_page_statuses()
        if not status_map:
            return False

        self._generate_status_json(status_map)
        self._inject_status_data_inline()

        return True

    # =========================================================================
    # Custom Sections Methods
    # =========================================================================

    def _process_sections(self) -> int:
        """
        Process custom sections: discover files, copy, generate index, wire nav.

        Each configured section gets its own navbar link, sidebar, and (optionally) an
        auto-generated index page. Blog-type sections use Quarto's native `listing:` directive
        instead of cards and skip the sidebar.

        Returns
        -------
        int
            Number of sections successfully processed.
        """
        sections_config = self._config.sections
        if not sections_config:
            return 0

        # Sections must be a list of dicts
        if not isinstance(sections_config, list):
            print(f"   ⚠️  'sections' must be a list, got {type(sections_config).__name__}")
            return 0

        processed = 0

        for section_cfg in sections_config:
            if not isinstance(section_cfg, dict):
                print(f"   ⚠️  Section entry must be a dict, skipping: {section_cfg}")
                continue
            title = section_cfg.get("title")
            src_dir = section_cfg.get("dir")
            if not title or not src_dir:
                print(f"   ⚠️  Section missing 'title' or 'dir', skipping: {section_cfg}")
                continue

            source_path = self.project_root / src_dir
            if not source_path.exists() or not source_path.is_dir():
                print(f"   ⚠️  Section directory '{src_dir}' not found, skipping")
                continue

            section_type = section_cfg.get("type", "default")

            # Discover .qmd / .md files
            files = sorted(
                [
                    f
                    for f in source_path.rglob("*")
                    if f.suffix in (".qmd", ".md") and f.name != "README.md"
                ]
            )

            if not files:
                print(f"   ⚠️  Section '{title}' directory '{src_dir}' has no .qmd/.md files")
                continue

            # Determine the slug for the build directory (lowercase, hyphenated).
            # `_section_build_dir` mirrors this formula so tag/status scanning
            # resolves the same destination directory.
            slug = src_dir.replace("_", "-").replace(" ", "-").lower()
            dest_dir = self.project_path / slug
            dest_dir.mkdir(parents=True, exist_ok=True)

            # Copy asset directories (images, data, code snippets, etc.)
            for item in source_path.iterdir():
                if item.is_dir() and self._is_asset_dir(item):
                    dst_dir = dest_dir / item.name
                    if dst_dir.exists():
                        shutil.rmtree(dst_dir)
                    shutil.copytree(item, dst_dir)

            if section_type == "blog":
                # Blog sections use Quarto's native listing directive
                copied = self._copy_blog_files(files, source_path, dest_dir)

                # For blogs, only a root-level index.qmd counts as a user index
                # (subdirectory index.qmd files are individual blog posts)
                has_user_index = (source_path / "index.qmd").exists()
                if has_user_index:
                    # Inject blog-index layout options into the user's index
                    blog_index = dest_dir / "index.qmd"
                    idx_content = blog_index.read_text(encoding="utf-8")
                    idx_content = self._add_frontmatter_option(idx_content, "toc", False)
                    idx_content = self._add_frontmatter_option(
                        idx_content, "body-classes", "gd-blog-index"
                    )
                    blog_index.write_text(idx_content, encoding="utf-8")
                    index_href = f"{slug}/index.qmd"
                else:
                    self._generate_blog_index(title, slug, dest_dir)
                    index_href = f"{slug}/index.qmd"

                # Blog sections don't get a sidebar — the listing page
                # is the primary navigation experience.
            else:
                # Default: sections with sidebar (index page is opt-in)
                copied = self._copy_section_files(files, source_path, dest_dir)

                has_user_index = (source_path / "index.qmd").exists()
                generate_index = section_cfg.get("index", False)

                if has_user_index:
                    # User provided their own index.qmd — always use it
                    index_href = f"{slug}/index.qmd"
                elif generate_index:
                    # Auto-generate a card-based index page (opt-in)
                    index_columns = section_cfg.get("index_columns", 2)
                    self._generate_section_index(
                        title, copied, slug, dest_dir, columns=index_columns
                    )
                    index_href = f"{slug}/index.qmd"
                else:
                    # No index page — navbar links to the first content page
                    first_page = next((p for p in copied if p["filename"] != "index.qmd"), None)
                    if first_page:
                        index_href = f"{slug}/{first_page['filename']}"
                    else:
                        index_href = f"{slug}/index.qmd"  # pragma: no cover

                # Add sidebar (skipped for single-page sections)
                dir_titles = section_cfg.get("dir_titles", {})
                sidebar_groups = section_cfg.get("sidebar_groups")
                self._add_section_sidebar(
                    title,
                    slug,
                    copied,
                    has_user_index,
                    generate_index,
                    dir_titles=dir_titles,
                    sidebar_groups=sidebar_groups,
                )

                # For single-page sections (no sidebar), inject a body class so
                # CSS can expand the content area into the sidebar space.
                content_pages = [p for p in copied if p["filename"] != "index.qmd"]
                has_index = has_user_index or generate_index
                sidebar_items = (1 if has_index else 0) + len(content_pages)
                if sidebar_items <= 1:
                    self._inject_section_body_class(slug, copied, dest_dir)

            # Add navbar link
            navbar_after = section_cfg.get("navbar_after")
            self._add_section_to_navbar(title, index_href, navbar_after)

            n_pages = len(copied)
            label = "📰" if section_type == "blog" else "📂"
            print(f"   {label} {title}: {n_pages} post(s) from {src_dir}/")
            processed += 1

        return processed

    def _copy_section_files(
        self,
        files: list[Path],
        source_dir: Path,
        dest_dir: Path,
    ) -> list[dict]:
        """
        Copy section files to the build directory.

        Strips numeric prefixes and adds `bread-crumbs: false` to frontmatter.

        Parameters
        ----------
        files
            List of source file paths.
        source_dir
            Root of the section source directory.
        dest_dir
            Destination directory in the build tree.

        Returns
        -------
        list[dict]
            List of dicts with `filename`, `title`, `description`, `image` keys.
        """
        copied: list[dict] = []

        for src_file in files:
            rel = src_file.relative_to(source_dir)

            # Strip numeric prefix from filename (e.g., 01-intro.qmd -> intro.qmd)
            clean_name = self._strip_numeric_prefix(rel.name)

            # Strip numeric prefixes from subdirectory parts too (e.g.,
            # 02-topic-b/page.qmd -> topic-b/page.qmd), mirroring the user-guide
            # copy. This keeps on-disk paths in sync with the link rewriting done
            # by _fix_numeric_prefix_links, which strips prefixes from every path
            # component.
            clean_parent = (
                Path(*[self._strip_numeric_prefix(p) for p in rel.parent.parts])
                if rel.parent.parts
                else rel.parent
            )
            dest_file = dest_dir / clean_parent / clean_name

            # Ensure subdirectories exist
            dest_file.parent.mkdir(parents=True, exist_ok=True)

            content = src_file.read_text(encoding="utf-8")

            # Expand {{< code-include >}} shortcodes before Quarto sees the file
            content = self._expand_code_includes(content, src_file.parent)

            # Fix links to .qmd files with numeric prefixes
            content = self._fix_numeric_prefix_links(content)

            # Parse frontmatter for metadata
            title = clean_name.replace(".qmd", "").replace(".md", "").replace("-", " ").title()
            description = ""
            image = ""

            if content.startswith("---"):
                parts = content.split("---", 2)
                if len(parts) >= 3:
                    try:
                        fm = parse_yaml(parts[1])
                        if isinstance(fm, dict):
                            title = fm.get("title", title)
                            description = fm.get("description", "")
                            image = fm.get("image", "")

                            # Add bread-crumbs: false
                            changed = False
                            if "bread-crumbs" not in fm:
                                fm["bread-crumbs"] = False
                                changed = True
                            if changed:
                                parts[1] = "\n" + format_yaml(fm) + "\n"
                                content = "---".join(parts)
                    except ValueError:  # pragma: no cover
                        pass  # pragma: no cover

            dest_file.write_text(content, encoding="utf-8")

            copied.append(
                {
                    "filename": str(clean_parent / clean_name)
                    if clean_parent != Path(".")
                    else clean_name,
                    "title": title,
                    "description": description,
                    "image": image,
                }
            )

        return copied

    def _copy_blog_files(
        self,
        files: list[Path],
        source_dir: Path,
        dest_dir: Path,
    ) -> list[dict]:
        """
        Copy blog post files to the build directory.

        Preserves subdirectory structure (e.g., `blog/my-post/index.qmd`)
        and leaves blog frontmatter intact so Quarto's `listing:` directive
        can read `title`, `author`, `date`, `categories`, etc.

        Also copies co-located asset files (images, data files, etc.)
        from any directory that contains a .qmd/.md file, so that
        relative references like `![](./image.png)` resolve correctly.

        Parameters
        ----------
        files
            List of source file paths.
        source_dir
            Root of the blog source directory.
        dest_dir
            Destination directory in the build tree.

        Returns
        -------
        list[dict]
            List of dicts with `filename`, `title`, `description` keys.
        """
        copied: list[dict] = []

        # Track directories that contain content files so we can copy
        # their co-located assets afterward
        content_dirs: set[Path] = set()

        for src_file in files:
            rel = src_file.relative_to(source_dir)
            dest_file = dest_dir / rel

            # Ensure subdirectories exist
            dest_file.parent.mkdir(parents=True, exist_ok=True)

            content_dirs.add(src_file.parent)

            content = src_file.read_text(encoding="utf-8")

            # Expand {{< code-include >}} shortcodes before Quarto sees the file
            content = self._expand_code_includes(content, src_file.parent)

            # Parse frontmatter for metadata (but don't modify it —
            # Quarto's listing needs the original frontmatter)
            title = rel.stem.replace("-", " ").title()
            description = ""

            if content.startswith("---"):
                parts = content.split("---", 2)
                if len(parts) >= 3:
                    try:
                        fm = parse_yaml(parts[1])
                        if isinstance(fm, dict):
                            title = fm.get("title", title)
                            description = fm.get("description", "")
                    except ValueError:
                        pass

            dest_file.write_text(content, encoding="utf-8")

            copied.append(
                {
                    "filename": str(rel),
                    "title": title,
                    "description": description,
                }
            )

        # Copy co-located non-content files (images, data, etc.) from
        # every directory that contained a .qmd/.md file
        content_suffixes = {".qmd", ".md"}
        for src_dir_path in content_dirs:
            for item in src_dir_path.iterdir():
                if item.is_file() and item.suffix.lower() not in content_suffixes:
                    rel = item.relative_to(source_dir)
                    dest_file = dest_dir / rel
                    dest_file.parent.mkdir(parents=True, exist_ok=True)
                    shutil.copy2(item, dest_file)
            # Also copy subdirectories that don't contain .qmd files
            # (e.g., blog/my-post/datasets/)
            for item in src_dir_path.iterdir():
                if item.is_dir():
                    has_content = any(f.suffix in content_suffixes for f in item.rglob("*"))
                    if not has_content:
                        rel = item.relative_to(source_dir)
                        dest_sub = dest_dir / rel
                        if dest_sub.exists():
                            shutil.rmtree(dest_sub)
                        shutil.copytree(item, dest_sub)

        return copied

    def _generate_blog_index(
        self,
        title: str,
        slug: str,
        dest_dir: Path,
    ) -> None:
        """
        Auto-generate a blog listing index using Quarto's `listing:` directive.

        Creates a page that mirrors the approach used by Quarto-based project blogs: a table-style
        listing sorted by date.

        Parameters
        ----------
        title
            Blog section title (used as page heading).
        slug
            URL slug for the blog section.
        dest_dir
            Build directory for the blog section.
        """
        lines = [
            "---",
            f'title: "{title}"',
            "listing:",
            "  type: default",
            '  sort: "date desc"',
            "  contents:",
            '    - "**.qmd"',
            "bread-crumbs: false",
            "toc: false",
            "body-classes: gd-blog-index",
            "---",
            "",
        ]

        index_file = dest_dir / "index.qmd"
        index_file.write_text("\n".join(lines), encoding="utf-8")

    def _generate_section_index(
        self,
        title: str,
        pages: list[dict],
        slug: str,
        dest_dir: Path,
        columns: int = 2,
    ) -> None:
        """
        Auto-generate an index page for a custom section.

        Creates a gallery-style listing from each page's frontmatter title
        and description.  Entries that have an `image` field are rendered
        as image cards in a grid (1- or 2-column); entries without images
        are rendered as a simple single-column link list below the cards.

        Parameters
        ----------
        title
            Section title (used as page heading).
        pages
            List of page dicts from `_copy_section_files`.
        slug
            URL slug for the section.
        dest_dir
            Build directory for the section.
        columns
            Number of columns for image-card grid (1 or 2, default 2).
        """
        lines = [
            "---",
            f'title: "{title}"',
            "bread-crumbs: false",
            "---",
            "",
        ]

        # Filter out the index itself if present
        entries = [p for p in pages if p["filename"] != "index.qmd"]

        if not entries:
            lines.append("*No pages found.*")
        else:
            # Split entries into image cards and plain links
            image_entries = [e for e in entries if e.get("image")]
            plain_entries = [e for e in entries if not e.get("image")]

            # Use inline styles to ensure card layout renders correctly
            # regardless of Quarto's page-columns grid system.
            # Build as a single contiguous HTML block so Quarto's Markdown
            # parser does not inject extra <p> or duplicate <a> elements.
            parts: list[str] = []

            # --- Image card grid ---
            if image_entries:
                columns = max(1, min(columns, 2))
                col_cls = "section-cards-1col" if columns == 1 else "section-cards-2col"
                if columns == 1:
                    grid_style = (
                        "display: grid; grid-template-columns: 1fr; gap: 1rem; margin-top: 0.5rem;"
                    )
                else:
                    grid_style = (
                        "display: grid; "
                        "grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); "
                        "gap: 1rem; "
                        "margin-top: 0.5rem;"
                    )
                card_style = (
                    "display: block; "
                    "padding: 1.25rem 1.5rem; "
                    "border: 1px solid #dee2e6; "
                    "border-radius: 0.5rem; "
                    "color: inherit; "
                    "text-decoration: none;"
                )
                title_style = (
                    "font-size: 1.1rem; font-weight: 600; margin-bottom: 0.35rem; color: #0d6efd;"
                )
                desc_style = "font-size: 0.9rem; color: #6c757d; line-height: 1.45;"

                parts.append(f'<div class="section-cards {col_cls}" style="{grid_style}">')
                for entry in image_entries:
                    href = entry["filename"]
                    entry_title = entry["title"]
                    desc = entry.get("description", "")
                    image = entry["image"]

                    parts.append(f'<a href="{href}" class="section-card" style="{card_style}">')
                    parts.append(
                        f'<img src="{image}" class="section-card-img" '
                        'style="width: 100%; border-radius: 0.375rem; '
                        'margin-bottom: 0.75rem;" />'
                    )
                    parts.append(
                        f'<div class="section-card-title" style="{title_style}">{entry_title}</div>'
                    )
                    if desc:
                        parts.append(
                            f'<div class="section-card-desc" style="{desc_style}">{desc}</div>'
                        )
                    parts.append("</a>")

                # Close grid container on the same line as the last </a>
                parts[-1] = parts[-1] + "</div>"

            # --- Separator ---
            if image_entries and plain_entries:
                parts.append("<hr>")

            # --- Plain link list ---
            if plain_entries:
                card_style = (
                    "display: block; "
                    "padding: 1.25rem 1.5rem; "
                    "border: 1px solid #dee2e6; "
                    "border-radius: 0.5rem; "
                    "color: inherit; "
                    "text-decoration: none;"
                )
                title_style = (
                    "font-size: 1.1rem; font-weight: 600; margin-bottom: 0.35rem; color: #0d6efd;"
                )
                desc_style = "font-size: 0.9rem; color: #6c757d; line-height: 1.45;"

                parts.append(
                    '<div class="section-cards section-cards-list" '
                    'style="display: grid; grid-template-columns: 1fr; '
                    'gap: 1rem; margin-top: 0.5rem;">'
                )
                for entry in plain_entries:
                    href = entry["filename"]
                    entry_title = entry["title"]
                    desc = entry.get("description", "")

                    parts.append(f'<a href="{href}" class="section-card" style="{card_style}">')
                    parts.append(
                        f'<div class="section-card-title" style="{title_style}">{entry_title}</div>'
                    )
                    if desc:
                        parts.append(
                            f'<div class="section-card-desc" style="{desc_style}">{desc}</div>'
                        )
                    parts.append("</a>")

                # Close list container
                parts[-1] = parts[-1] + "</div>"

            # Join without blank lines to keep it as one HTML block
            lines.append("\n".join(parts))

        lines.append("")
        index_file = dest_dir / "index.qmd"
        index_file.write_text("\n".join(lines), encoding="utf-8")

    def _add_section_sidebar(
        self,
        title: str,
        slug: str,
        pages: list[dict],
        has_user_index: bool,
        has_generated_index: bool = False,
        dir_titles: dict[str, str] | None = None,
        sidebar_groups: list[dict] | None = None,
    ) -> None:
        """
        Add a sidebar for the custom section to `_quarto.yml`.

        Parameters
        ----------
        title
            Section title.
        slug
            URL slug for the section directory.
        pages
            List of page dicts from `_copy_section_files`.
        has_user_index
            Whether the user provided their own index.qmd.
        has_generated_index
            Whether an auto-generated index page was created.
        dir_titles
            Optional mapping of subdirectory names (after prefix stripping) to
            custom sidebar section titles.
        sidebar_groups
            Optional list of group dicts for organizing flat pages into sidebar sections. Each dict
            has `section` (title) and `contents` (list of page filenames without numeric prefix).
        """
        quarto_yml = self.project_path / "_quarto.yml"
        config = self._read_quarto_config(quarto_yml)

        sidebar_id = slug
        contents: list[dict | str] = []

        has_index = has_user_index or has_generated_index

        if has_index:
            # Add index as the first item when one exists
            contents.append({"text": title, "href": f"{slug}/index.qmd"})

        # Build a filename -> page dict lookup for grouped sidebar
        page_by_filename: dict[str, dict] = {}
        for page in pages:
            if page["filename"] != "index.qmd":
                page_by_filename[page["filename"]] = page

        if sidebar_groups:
            # Use explicit sidebar groups to organize flat pages into sections
            grouped_filenames: set[str] = set()
            for group in sidebar_groups:
                section_title = group.get("section", "")
                group_contents_cfg = group.get("contents", [])
                section_contents = []
                for filename in group_contents_cfg:
                    # Match against stripped filenames in page_by_filename
                    page = page_by_filename.get(filename)
                    if page:
                        section_contents.append(
                            {
                                "text": page["title"],
                                "href": f"{slug}/{page['filename']}",
                            }
                        )
                        grouped_filenames.add(filename)
                if section_contents:
                    contents.append({"section": section_title, "contents": section_contents})

            # Add any ungrouped pages at the end
            for filename, page in page_by_filename.items():
                if filename not in grouped_filenames:
                    contents.append(
                        {
                            "text": page["title"],
                            "href": f"{slug}/{page['filename']}",
                        }
                    )
        else:
            # Default: flat listing with optional subdirectory grouping
            from pathlib import PurePosixPath

            subdir_groups_dict: dict[str, list[dict]] = {}
            top_level_pages: list[dict] = []

            for page in pages:
                if page["filename"] == "index.qmd":
                    continue
                parts = PurePosixPath(page["filename"]).parts
                if len(parts) > 1:
                    # Page is in a subdirectory — group by parent dir
                    subdir = parts[0]  # pragma: no cover
                    subdir_groups_dict.setdefault(subdir, []).append(page)  # pragma: no cover
                else:
                    top_level_pages.append(page)

            # Add top-level pages first
            for page in top_level_pages:
                contents.append(
                    {
                        "text": page["title"],
                        "href": f"{slug}/{page['filename']}",
                    }
                )

            # Add subdirectory groups as section headers
            if dir_titles is None:
                dir_titles = {}
            # Preserve source discovery order rather than sorting the keys. The
            # source file list is sorted with numeric prefixes intact, so first
            # encounter order already reflects the author's intended ordering.
            # Sorting here would use the prefix-stripped names and lose it.
            for subdir in subdir_groups_dict:
                clean_subdir = self._strip_numeric_prefix(subdir)  # pragma: no cover
                section_title = dir_titles.get(  # pragma: no cover
                    clean_subdir,
                    clean_subdir.replace("-", " ").replace("_", " ").title(),
                )  # pragma: no cover
                section_contents = []  # pragma: no cover
                for page in subdir_groups_dict[subdir]:  # pragma: no cover
                    section_contents.append(  # pragma: no cover
                        {
                            "text": page["title"],
                            "href": f"{slug}/{page['filename']}",
                        }
                    )
                contents.append(
                    {"section": section_title, "contents": section_contents}
                )  # pragma: no cover

        # Skip sidebar when a section has only a single page — the lone item
        # provides no navigation value and wastes horizontal space.  Without a
        # sidebar entry the page content expands into the full width.
        if len(contents) <= 1:
            return

        sidebar_config = {
            "id": sidebar_id,
            "title": title,
            "contents": contents,
        }

        # Remove existing sidebar with same id
        sidebar = config["website"]["sidebar"]
        sidebar = [s for s in sidebar if not (isinstance(s, dict) and s.get("id") == sidebar_id)]
        sidebar.append(sidebar_config)
        config["website"]["sidebar"] = sidebar

        self._write_quarto_yml(quarto_yml, config)

    def _inject_section_body_class(
        self,
        slug: str,
        pages: list[dict],
        dest_dir: Path,
    ) -> None:
        """
        Add `body-classes: gd-section-no-sidebar` to frontmatter of every page in a single-page
        section so CSS can expand the content area.
        """
        for page in pages:
            qmd_path = dest_dir / page["filename"]
            if not qmd_path.exists():
                continue

            content = qmd_path.read_text(encoding="utf-8")
            if not content.startswith("---"):
                continue

            parts = content.split("---", 2)
            if len(parts) < 3:  # pragma: no cover
                continue

            try:
                fm = parse_yaml(parts[1])
                if not isinstance(fm, dict):
                    continue
            except ValueError:  # pragma: no cover
                continue

            # Merge body-classes
            existing = fm.get("body-classes", "")
            classes = existing.split() if existing else []
            if "gd-section-no-sidebar" not in classes:
                classes.append("gd-section-no-sidebar")
                fm["body-classes"] = " ".join(classes)
                parts[1] = "\n" + format_yaml(fm) + "\n"
                qmd_path.write_text("---".join(parts), encoding="utf-8")

    def _add_section_to_navbar(
        self,
        title: str,
        href: str,
        navbar_after: str | None = None,
    ) -> None:
        """
        Add a navbar link for the custom section.

        Parameters
        ----------
        title
            Link text.
        href
            Link target.
        navbar_after
            Name of an existing navbar item to insert after. If None,
            inserts before "Reference".
        """
        quarto_yml = self.project_path / "_quarto.yml"
        config = self._read_quarto_config(quarto_yml)

        navbar = config.get("website", {}).get("navbar", {})
        if not navbar or "left" not in navbar:
            return  # pragma: no cover

        # Check if link already exists
        if any(isinstance(item, dict) and item.get("text") == title for item in navbar["left"]):
            return

        link = {"text": title, "href": href}

        if navbar_after:
            # Find the item to insert after
            for i, item in enumerate(navbar["left"]):
                if isinstance(item, dict) and item.get("text") == navbar_after:
                    navbar["left"].insert(i + 1, link)
                    break
            else:
                # Fallback: insert before Reference
                self._insert_before_reference(navbar["left"], link)
        else:
            # Default: insert before Reference
            self._insert_before_reference(navbar["left"], link)

        self._write_quarto_yml(quarto_yml, config)

    def _reorder_navbar(self, config: dict) -> None:
        """Reorder navbar items according to `navbar_order` in config."""
        order = self._config.navbar_order
        if not order:
            return

        navbar = config.get("website", {}).get("navbar", {})
        left = navbar.get("left", [])
        if not left:
            return

        by_text: dict[str, dict] = {}
        for item in left:
            if isinstance(item, dict) and "text" in item:
                by_text[item["text"]] = item

        ordered: list[dict] = []
        for label in order:
            if label in by_text:
                ordered.append(by_text.pop(label))

        for item in left:
            if isinstance(item, dict) and item.get("text") in by_text:
                ordered.append(item)
                by_text.pop(item["text"], None)

        navbar["left"] = ordered

    def _insert_before_reference(self, navbar_items: list, link: dict) -> None:
        """Insert a link before the 'Reference' navbar item, or append."""
        for i, item in enumerate(navbar_items):
            if isinstance(item, dict) and item.get("text") == "Reference":
                navbar_items.insert(i, link)
                return
        # If no Reference found then append
        navbar_items.append(link)

    def _read_quarto_config(self, quarto_yml: Path) -> dict:
        """Read and return the _quarto.yml config, ensuring website.sidebar exists."""
        if quarto_yml.exists():
            with open(quarto_yml, "r") as f:
                config = read_yaml(f) or {}
        else:
            config = {}

        config.setdefault("website", {})
        config["website"].setdefault("sidebar", [])
        config["website"].setdefault("navbar", {"left": []})

        return config

    # =========================================================================
    # Custom Static Pages Methods
    # =========================================================================

    def _get_custom_page_sources(self) -> list[dict[str, Path | str]]:
        """Return configured custom page source directories that exist."""
        sources: list[dict[str, Path | str]] = []

        for entry in self._config.custom_pages:
            source_dir = self.project_root / entry["dir"]
            if not source_dir.exists() or not source_dir.is_dir():
                if entry["dir"] != "custom" or self._config.exists():
                    print(f"   ⚠️  Custom pages directory '{entry['dir']}' not found, skipping")
                continue

            sources.append(
                {
                    "source_dir": source_dir,
                    "output": entry["output"],
                }
            )

        return sources

    def _split_frontmatter(self, content: str) -> tuple[dict, str]:
        """Split YAML frontmatter from file content when present."""
        normalized = content.lstrip()

        if not normalized.startswith("---"):
            return {}, content

        parts = normalized.split("---", 2)
        if len(parts) < 3:
            return {}, content  # pragma: no cover

        try:
            frontmatter = parse_yaml(parts[1]) or {}
        except ValueError:
            return {}, content

        if not isinstance(frontmatter, dict):
            return {}, content

        return frontmatter, parts[2].lstrip("\n")

    @staticmethod
    def _html_escape(text: str) -> str:
        """Escape HTML-special characters in *text*."""
        import html

        return html.escape(text, quote=True)

    def _derive_page_title(self, path: Path) -> str:
        """Derive a human-readable title from a source filename."""
        name = path.stem.replace("-", " ").replace("_", " ")
        return name.title()

    def _get_custom_page_navbar_config(
        self,
        frontmatter: dict,
        default_title: str,
    ) -> tuple[str, str | None] | None:
        """Resolve optional navbar configuration for a custom page."""
        navbar = frontmatter.get("navbar")

        if navbar in (None, False):
            return None

        if navbar is True:
            return default_title, frontmatter.get("navbar_after")

        if isinstance(navbar, str):
            return navbar, frontmatter.get("navbar_after")

        if isinstance(navbar, dict):
            text = navbar.get("text", default_title)
            after = navbar.get("after", frontmatter.get("navbar_after"))
            if isinstance(text, str) and text:
                return text, after if isinstance(after, str) else None

        return None  # pragma: no cover

    def _add_project_resources(
        self,
        resources_to_add: list[str],
        render_excludes: list[str] | None = None,
    ) -> None:
        """Add resource paths and render exclusions to _quarto.yml."""
        if not resources_to_add and not render_excludes:
            return

        quarto_yml = self.project_path / "_quarto.yml"
        config = self._read_quarto_config(quarto_yml)
        project = config.setdefault("project", {})

        resources = project.setdefault("resources", [])
        if isinstance(resources, str):
            resources = [resources]  # pragma: no cover
            project["resources"] = resources  # pragma: no cover

        for resource in resources_to_add:
            if resource not in resources:
                resources.append(resource)

        if render_excludes:
            if "render" not in project:
                project["render"] = list(_QUARTO_RENDER_GLOBS)
            render = project["render"]
            if isinstance(render, str):
                render = [render]  # pragma: no cover
                project["render"] = render  # pragma: no cover
            for path in render_excludes:
                exclude = f"!{path}"
                if exclude not in render:
                    render.append(exclude)

        self._write_quarto_yml(quarto_yml, config)

    def _process_custom_pages(self) -> int:
        """Process configured custom HTML pages."""
        sources = self._get_custom_page_sources()
        if not sources:
            return 0

        resources_to_add: list[str] = []
        raw_render_excludes: list[str] = []
        processed = 0

        for source in sources:
            source_dir = source["source_dir"]
            output_prefix = str(source["output"])
            dest_dir = self.project_path / output_prefix
            dest_dir.mkdir(parents=True, exist_ok=True)

            for src_path in sorted(source_dir.rglob("*")):
                if not src_path.is_file():
                    continue

                rel_path = src_path.relative_to(source_dir)
                dest_path = dest_dir / rel_path
                output_rel_path = Path(output_prefix) / rel_path
                dest_path.parent.mkdir(parents=True, exist_ok=True)

                if src_path.suffix.lower() not in (".html", ".htm"):
                    shutil.copy2(src_path, dest_path)
                    resources_to_add.append(output_rel_path.as_posix())
                    continue

                content = src_path.read_text(encoding="utf-8")
                frontmatter, body = self._split_frontmatter(content)
                layout = str(frontmatter.get("layout", "passthrough")).lower()
                page_title = str(frontmatter.get("title") or self._derive_page_title(src_path))

                if layout not in {"passthrough", "raw"}:
                    print(  # pragma: no cover
                        f"   ⚠️  Unsupported custom page layout '{layout}' in {output_rel_path}; "
                        "defaulting to passthrough"
                    )
                    layout = "passthrough"  # pragma: no cover

                if layout == "raw":
                    dest_path.write_text(body, encoding="utf-8")
                    raw_resource = output_rel_path.as_posix()
                    resources_to_add.append(raw_resource)
                    raw_render_excludes.append(raw_resource)
                    nav_href = raw_resource
                else:
                    qmd_path = dest_path.with_suffix(".qmd")
                    qmd_frontmatter = dict(frontmatter)
                    qmd_frontmatter.pop("layout", None)
                    qmd_frontmatter.setdefault("title", page_title)
                    qmd_frontmatter.setdefault("bread-crumbs", False)
                    qmd_body = body if body.endswith("\n") else f"{body}\n"
                    qmd_content = f"---\n{format_yaml(qmd_frontmatter)}\n---\n\n{qmd_body}"
                    qmd_path.write_text(qmd_content, encoding="utf-8")
                    nav_href = output_rel_path.with_suffix(".qmd").as_posix()

                navbar_cfg = self._get_custom_page_navbar_config(frontmatter, page_title)
                if navbar_cfg is not None:
                    nav_text, navbar_after = navbar_cfg
                    self._add_section_to_navbar(nav_text, nav_href, navbar_after)

                processed += 1

        self._add_project_resources(resources_to_add, raw_render_excludes)
        return processed

    # =========================================================================
    # CLI Documentation Methods
    # =========================================================================

    def _discover_click_cli(
        self, package_name: str, display_name: str | None = None
    ) -> dict | None:
        """
        Discover Click CLI commands and groups from a package.

        Attempts to find and import the Click CLI from the package, then extracts
        command structure, help text, options, and arguments.

        Parameters
        ----------
        package_name
            The importable module name to search for the CLI.
        display_name
            The name shown for the CLI entry point when `[project.scripts]` is
            absent (typically the PyPI project name). Falls back to
            `package_name` when not provided.

        Returns
        -------
        dict | None
            Dictionary containing CLI structure, or None if no Click CLI found.
            Structure: {
                "name": "cli-name",
                "help": "CLI help text",
                "commands": [...],  # List of command dicts
                "options": [...],   # Global options
            }
        """
        metadata = self._get_package_metadata()

        # Check if CLI documentation is enabled
        if not metadata.get("cli_enabled", False):
            return None

        try:
            import click
        except ImportError:  # pragma: no cover
            print("Click not installed, skipping CLI documentation")  # pragma: no cover
            return None  # pragma: no cover

        # Normalize to importable module name (hyphens → underscores)
        importable_name = self._normalize_package_name(package_name)

        # Determine the CLI module to import
        cli_module_path = metadata.get("cli_module")
        if not cli_module_path:
            # Try common CLI module locations
            common_cli_modules = [
                f"{importable_name}.cli",
                f"{importable_name}.__main__",
                f"{importable_name}.main",
                f"{importable_name}.console",
                f"{importable_name}.commands",
            ]
            cli_module_path = None
            for module_path in common_cli_modules:
                try:
                    import importlib

                    module = importlib.import_module(module_path)
                    # Look for Click command/group in module
                    for attr_name in dir(module):
                        attr = getattr(module, attr_name)
                        if isinstance(attr, (click.Command, click.Group)):
                            cli_module_path = module_path
                            break
                    if cli_module_path:
                        break
                except ImportError:
                    continue

        if not cli_module_path:
            print(f"No Click CLI found in {package_name}")
            return None

        try:
            import importlib

            module = importlib.import_module(cli_module_path)
        except ImportError as e:
            print(f"Could not import CLI module {cli_module_path}: {e}")
            return None

        # Find the main Click command/group
        cli_obj = None
        cli_name = metadata.get("cli_name")

        # First, look for explicitly named CLI
        if cli_name:
            cli_obj = getattr(module, cli_name, None)

        # Otherwise, search for Click commands/groups
        if not cli_obj:
            for attr_name in ["cli", "main", "app", "command", importable_name]:
                attr = getattr(module, attr_name, None)
                if isinstance(attr, (click.Command, click.Group)):
                    cli_obj = attr
                    cli_name = attr_name
                    break

        # If still not found, look for any Click command/group
        if not cli_obj:
            for attr_name in dir(module):
                if attr_name.startswith("_"):
                    continue
                attr = getattr(module, attr_name)
                if isinstance(attr, (click.Command, click.Group)):
                    cli_obj = attr
                    cli_name = attr_name
                    break

        if not cli_obj:
            print(f"No Click command/group found in {cli_module_path}")
            return None

        print(f"Found Click CLI: {cli_name} in {cli_module_path}")

        # Get the entry point name from pyproject.toml. When [project.scripts]
        # is absent, fall back to the display name (the project name) rather
        # than the importable module name, which may differ.
        entry_point_name = self._get_cli_entry_point_name(package_name)
        resolved_name = entry_point_name or (display_name or package_name).replace("_", "-")

        # Extract CLI structure
        cli_info = self._extract_click_command(cli_obj, resolved_name)
        cli_info["entry_point_name"] = resolved_name
        return cli_info

    def _find_click_cli_obj(self, package_name: str) -> object | None:
        """Return the raw Click command object for *package_name*, or *None*."""
        metadata = self._get_package_metadata()
        if not metadata.get("cli_enabled", False):
            return None
        try:
            import click
        except ImportError:
            return None

        importable_name = self._normalize_package_name(package_name)
        cli_module_path = metadata.get("cli_module")
        if not cli_module_path:
            for mod in (
                f"{importable_name}.cli",
                f"{importable_name}.__main__",
                f"{importable_name}.main",
            ):
                try:
                    import importlib

                    m = importlib.import_module(mod)
                    if any(
                        isinstance(getattr(m, a, None), (click.Command, click.Group))
                        for a in dir(m)
                    ):
                        cli_module_path = mod
                        break
                except ImportError:
                    continue
        if not cli_module_path:
            return None
        try:
            import importlib

            module = importlib.import_module(cli_module_path)
        except ImportError:
            return None

        cli_name = metadata.get("cli_name")
        if cli_name:
            obj = getattr(module, cli_name, None)
            if isinstance(obj, (click.Command, click.Group)):
                return obj

        for attr_name in ["cli", "main", "app", "command", importable_name]:
            obj = getattr(module, attr_name, None)
            if isinstance(obj, (click.Command, click.Group)):
                return obj

        for attr_name in dir(module):
            if attr_name.startswith("_"):
                continue
            obj = getattr(module, attr_name)
            if isinstance(obj, (click.Command, click.Group)):
                return obj
        return None

    def _get_cli_entry_point_name(self, package_name: str) -> str | None:
        """
        Get the CLI entry point name from `pyproject.toml`.

        Parameters
        ----------
        package_name
            The name of the package.

        Returns
        -------
        str | None
            The entry point name (e.g., `"great-docs"` from `"[project.scripts]"`), or `None` if not
            found.
        """
        package_root = self._find_package_root()
        pyproject_path = package_root / "pyproject.toml"

        if not pyproject_path.exists():
            return None

        import tomllib

        try:
            with open(pyproject_path, "rb") as f:
                data = tomllib.load(f)

            # Look for [project.scripts]
            scripts = data.get("project", {}).get("scripts", {})
            if scripts:
                # Return the first entry point name
                return list(scripts.keys())[0]

            # Also check [project.gui-scripts] for GUI apps
            gui_scripts = data.get("project", {}).get("gui-scripts", {})
            if gui_scripts:
                return list(gui_scripts.keys())[0]

        except Exception:  # pragma: no cover
            pass  # pragma: no cover

        return None

    def _extract_click_command(self, cmd, name: str, parent_path: str = "") -> dict:
        """
        Extract information from a Click command or group.

        Parameters
        ----------
        cmd
            The Click Command or Group object.
        name
            The command name.
        parent_path
            The parent command path for nested commands.

        Returns
        -------
        dict
            Dictionary containing command information including options, arguments, and parsed
            examples.
        """
        import click

        full_path = f"{parent_path} {name}".strip() if parent_path else name

        # Get the actual --help output from Click
        help_text = self._get_click_help_text(cmd, full_path)

        # Extract options and arguments from Click params
        options = []
        arguments = []
        for param in cmd.params:
            if isinstance(param, click.Option):
                opt_info = self._extract_click_option(param)
                if opt_info:
                    options.append(opt_info)
            elif isinstance(param, click.Argument):
                arg_info = self._extract_click_argument(param)
                if arg_info:
                    arguments.append(arg_info)

        # Parse description and examples from the help text
        description, examples = self._parse_click_help_parts(cmd.help or "")

        # Extract the short help (Click may auto-derive it, but sometimes it's
        # empty for subcommands).  Fall back to the first line/sentence of help.
        short_help = getattr(cmd, "short_help", "") or ""
        if not short_help and cmd.help:
            short_help = cmd.get_short_help_str(limit=150)

        info = {
            "name": name,
            "full_path": full_path,
            "help": cmd.help or "",
            "short_help": short_help,
            "help_text": help_text,  # The actual --help output
            "deprecated": getattr(cmd, "deprecated", False),
            "hidden": getattr(cmd, "hidden", False),
            "commands": [],
            "is_group": isinstance(cmd, click.Group),
            "options": options,
            "arguments": arguments,
            "description": description,
            "examples": examples,
        }

        # Extract subcommands if this is a group
        if isinstance(cmd, click.Group):
            for subcmd_name, subcmd in cmd.commands.items():
                if not getattr(subcmd, "hidden", False):
                    subcmd_info = self._extract_click_command(subcmd, subcmd_name, full_path)
                    info["commands"].append(subcmd_info)

        return info

    @staticmethod
    def _extract_click_option(param) -> dict | None:
        """Extract structured info from a Click Option parameter."""
        # Skip the --help option itself
        if param.name == "help":
            return None

        decls = param.opts + param.secondary_opts
        names = sorted(decls, key=len)  # short first, long second

        type_name = param.type.name if hasattr(param.type, "name") else str(param.type)

        # Normalise the default: Click uses sentinel objects for unset values
        default = param.default
        if default is None or (
            type(default).__name__ in ("Sentinel", "_DEFAULT_SENTINEL")
            or repr(default).startswith("Sentinel")
        ):
            default = None

        return {
            "names": names,
            "name_display": ", ".join(names),
            "help": param.help or "",
            "type": type_name.upper() if type_name != "BOOL" else None,
            "default": default,
            "required": param.required,
            "is_flag": param.is_flag,
            "multiple": param.multiple,
            "envvar": param.envvar,
            "show_default": getattr(param, "show_default", False),
            "is_eager": param.is_eager,
            "hidden": getattr(param, "hidden", False),
        }

    @staticmethod
    def _extract_click_argument(param) -> dict | None:
        """Extract structured info from a Click Argument parameter."""
        type_name = param.type.name if hasattr(param.type, "name") else str(param.type)

        return {
            "name": param.name,
            "name_display": param.human_readable_name,
            "type": type_name.upper() if type_name != "TEXT" else None,
            "required": param.required,
            "nargs": param.nargs,
        }

    @staticmethod
    def _parse_click_help_parts(help_text: str) -> tuple[str, list[str]]:
        """
        Parse a Click command's help string into description and examples.

        Parameters
        ----------
        help_text
            The raw help string from `cmd.help`.

        Returns
        -------
        tuple[str, list[str]]
            A (description, examples) pair. `description` is the prose before the examples block;
            `examples` is a list of example lines (without the `Examples:` header).
        """
        if not help_text:
            return "", []

        lines = help_text.split("\n")
        desc_lines: list[str] = []
        example_lines: list[str] = []
        in_examples = False

        for line in lines:
            stripped = line.strip()

            # Click uses \x08 (literal backspace) as the "don't rewrap" marker;
            # skip lines that are just that marker.
            if stripped in ("\x08", "\\b"):
                continue

            # Detect the examples block header
            if stripped.lower() == "examples:" and not in_examples:
                in_examples = True
                continue

            if in_examples:
                # Strip common Click indentation (2 or 4 spaces)
                dedented = line
                if line.startswith("    "):
                    dedented = line[4:]
                elif line.startswith("  "):
                    dedented = line[2:]
                example_lines.append(dedented)
            else:
                desc_lines.append(line)

        # Clean up: strip leading/trailing blank lines
        description = "\n".join(desc_lines).strip()
        # Remove any remaining \x08 bytes from the description
        description = description.replace("\x08", "")
        # Remove literal \b if present
        description = description.replace("\\b", "")
        # Collapse runs of blank lines to a single blank line
        import re

        description = re.sub(r"\n{3,}", "\n\n", description)

        # Clean trailing blanks from examples
        while example_lines and not example_lines[-1].strip():
            example_lines.pop()

        return description, example_lines

    @staticmethod
    def _backtick_cli_prose(text: str, option_names: set[str]) -> str:
        """Wrap CLI option names and single-quoted tokens in backticks.

        Handles two cases:

        1. Known option names (e.g. `--watch`) -> `--watch`
        2. Single-quoted text (e.g. `'great-docs/'`) -> `great-docs/`

        Only wraps tokens not already inside backticks.  Avoids matching
        apostrophes in contractions/possessives (e.g. "package's", "hasn't").
        """
        import re

        if not text:
            return text

        # 1. Wrap single-quoted text in backticks.
        # Require the opening quote to be preceded by whitespace or start-of-string
        # and the content to be at least 2 chars (avoids possessives like package's).
        text = re.sub(r"(?<=\s)'([^']{2,})'(?=[\s,.\)!?;:]|$)", r"`\1`", text)
        text = re.sub(r"^'([^']{2,})'(?=[\s,.\)!?;:]|$)", r"`\1`", text)

        # 2. Wrap known option names
        if option_names:
            sorted_names = sorted(option_names, key=len, reverse=True)
            pattern = "|".join(re.escape(n) for n in sorted_names)
            text = re.sub(
                rf"(?<!`)({pattern})(?!`)",
                r"`\1`",
                text,
            )

        return text

    def _get_click_help_text(self, cmd: "click.Command", full_path: str) -> str:
        """
        Get the formatted `--help` output from a Click command.

        Parameters
        ----------
        cmd
            The Click Command object.
        full_path
            The full command path (e.g., `"great-docs build"`).

        Returns
        -------
        str
            The formatted help text as it would appear from `--help`.
        """
        import click

        # Create a context to get the help text
        ctx = click.Context(cmd, info_name=full_path)
        return cmd.get_help(ctx)

    def _generate_cli_reference_pages(self, cli_info: dict) -> list[str | dict]:
        """
        Generate Quarto reference pages for CLI commands.

        Parameters
        ----------
        cli_info
            Dictionary containing CLI structure from `_discover_click_cli`.

        Returns
        -------
        list[str | dict]
            Sidebar items: plain path strings for leaf commands, or
            `{"section": ..., "contents": [...]}` dicts for groups.
        """
        if not cli_info:
            return []

        cli_ref_dir = self.project_path / "reference" / "cli"
        cli_ref_dir.mkdir(parents=True, exist_ok=True)

        generated_files: list[str | dict] = []
        generated_paths: list[str] = []

        entry_name = cli_info.get("entry_point_name") or cli_info["name"]
        entry_safe = entry_name.replace("-", "_")

        # Generate the CLI index (landing) page — a listing of all commands, modelled on the
        # API reference index page rather than the root command's own --help dump.
        index_page = self._generate_cli_index_page(cli_info, entry_safe)
        index_path = cli_ref_dir / "index.qmd"
        with open(index_path, "w") as f:
            f.write(index_page)
        # Label the index entry explicitly (mirrors the "API Index" link) so it reads distinctly
        # from the enclosing "CLI Reference" sidebar section title.
        from ._translations import get_translation

        cli_index_label = get_translation("cli_index", self._config.language)
        generated_files.append({"text": cli_index_label, "href": "reference/cli/index.qmd"})
        generated_paths.append(str(index_path.relative_to(self.project_path)))

        # Generate the root command page (global options, usage, full --help) on its own page so
        # the index can stay a pure listing.
        root_page = self._generate_cli_command_page(cli_info, is_main=True)
        root_path = cli_ref_dir / f"{entry_safe}.qmd"
        with open(root_path, "w") as f:
            f.write(root_page)
        generated_files.append(f"reference/cli/{entry_safe}.qmd")
        generated_paths.append(str(root_path.relative_to(self.project_path)))

        # Generate pages for subcommands
        subcommand_items = self._generate_subcommand_pages(
            cli_info, cli_ref_dir, _paths=generated_paths
        )

        # When explicit sections are configured, order the flat top-level command entries to
        # match the index sections (group entries keep their relative order at the end).
        if self._config.cli_sections:
            subcommand_items = self._order_cli_sidebar_items(
                subcommand_items, self._config.cli_sections
            )
        generated_files.extend(subcommand_items)

        # Print grouped summary
        if generated_paths:
            print("Generating CLI reference .qmd files:")
            for p in generated_paths:
                print(f"  - {p}")

        return generated_files

    def _generate_cli_index_page(self, cli_info: dict, entry_safe: str) -> str:
        """
        Generate the CLI reference index (landing) page.

        Models the API reference index page: an optional intro paragraph followed by one or more
        ``## Section {.doc-group}`` blocks, each a definition list of command links with their
        short help. Sections come from ``cli.sections`` in great-docs.yml when configured;
        otherwise commands are auto-grouped (leaf commands first, in code order, then one section
        per command group).

        Parameters
        ----------
        cli_info
            The root command info dict (from `_extract_click_command`).
        entry_safe
            The filesystem-safe name of the entry point (used to link the root command page).

        Returns
        -------
        str
            Quarto markdown content for ``reference/cli/index.qmd``.
        """
        from ._translations import get_translation

        lang = self._config.language
        title = self._config.cli_title or get_translation("cli_reference", lang)

        lines: list[str] = []

        # --- Front matter (mirrors the API reference index) ---
        lines.append("---")
        lines.append(f'title: "{title}"')
        lines.append("body-classes: doc-reference doc-cli-reference")
        lines.append("sidebar: cli-reference")
        lines.append("page-navigation: false")
        lines.append("html-table-processing: none")
        lines.append("---")
        lines.append("")

        # No body `# {title}` heading: the frontmatter title provides the page title block (as on
        # the API reference index), which keeps the `## {.doc-group}` sections at level 2.

        # --- Intro paragraph ---
        if self._config.cli_desc:
            lines.append("::: {.doc-description}")
            lines.append(self._config.cli_desc.strip())
            lines.append(":::")
            lines.append("")

        entry_name = cli_info.get("entry_point_name") or cli_info["name"]
        top_commands = [c for c in cli_info.get("commands", []) if not c.get("hidden")]
        by_name = {c["name"]: c for c in top_commands}

        def emit_entry(cmd: dict, href: str) -> None:
            label = "doc-label-cli-group" if cmd.get("is_group") else "doc-label-cli"
            name = cmd["name"]
            short = (cmd.get("short_help") or "").strip()
            lines.append(f"[{name}]({href}){{.doc-function .doc-label .{label}}}")
            lines.append(f":   {short}" if short else ":   &nbsp;")
            lines.append("")

        def emit_section(section_title: str, section_desc: str | None = None) -> None:
            lines.append(f"## {section_title} {{.doc-group}}")
            lines.append("")
            if section_desc:
                lines.append("::: {.doc-description}")
                lines.append(section_desc.strip())
                lines.append(":::")
                lines.append("")

        # --- Root command link (global options / usage live one click away) ---
        root_short = (cli_info.get("short_help") or "").strip()
        lines.append(
            f"[{entry_name}]({entry_safe}.qmd){{.doc-function .doc-label .doc-label-cli-group}}"
        )
        lines.append(f":   {root_short}" if root_short else ":   &nbsp;")
        lines.append("")

        cli_sections = self._config.cli_sections
        if cli_sections:
            # Author-controlled sections: list exactly the named top-level commands, in order.
            for section in cli_sections:
                emit_section(section.get("title", ""), section.get("desc"))
                for cmd_name in section.get("contents", []):
                    cmd = by_name.get(cmd_name)
                    if not cmd:
                        continue
                    emit_entry(cmd, f"{cmd_name.replace('-', '_')}.qmd")
        else:
            # Auto layout: leaf commands first (code order), then one section per group.
            leaf = [c for c in top_commands if not c.get("is_group")]
            groups = [c for c in top_commands if c.get("is_group")]

            if leaf:
                emit_section(get_translation("cli_commands", lang))
                for cmd in leaf:
                    emit_entry(cmd, f"{cmd['name'].replace('-', '_')}.qmd")

            for group in groups:
                gsafe = group["name"].replace("-", "_")
                emit_section(group["name"], (group.get("short_help") or "").strip() or None)
                # The group's own overview page, then each subcommand.
                emit_entry(group, f"{gsafe}.qmd")
                for sub in group.get("commands", []):
                    if sub.get("hidden"):
                        continue
                    ssafe = sub["name"].replace("-", "_")
                    emit_entry(sub, f"{gsafe}/{ssafe}.qmd")

        return "\n".join(lines)

    def _generate_subcommand_pages(
        self,
        cmd_info: dict,
        output_dir: Path,
        rel_prefix: str = "reference/cli",
        _paths: list[str] | None = None,
    ) -> list[str | dict]:
        """
        Recursively generate pages for subcommands.

        Parameters
        ----------
        cmd_info
            Command information dictionary.
        output_dir
            Directory to write pages to.
        rel_prefix
            The relative path prefix for sidebar entries (e.g. `reference/cli` or
            `reference/cli/task` for nested groups).

        Returns
        -------
        list[str | dict]
            Sidebar items: plain path strings for leaf commands, or
            `{"section": ..., "contents": [...]}` dicts for groups.
        """
        sidebar_items: list[str | dict] = []

        for subcmd in cmd_info.get("commands", []):
            # Generate page for this subcommand
            page_content = self._generate_cli_command_page(subcmd, is_main=False)
            safe_name = subcmd["name"].replace("-", "_")
            page_path = output_dir / f"{safe_name}.qmd"

            with open(page_path, "w") as f:
                f.write(page_content)

            rel_path = f"{rel_prefix}/{safe_name}.qmd"
            if _paths is not None:
                _paths.append(str(page_path.relative_to(self.project_path)))

            # Recursively generate for nested subcommands
            if subcmd.get("commands"):
                subcmd_dir = output_dir / safe_name
                subcmd_dir.mkdir(exist_ok=True)
                nested = self._generate_subcommand_pages(
                    subcmd, subcmd_dir, f"{rel_prefix}/{safe_name}", _paths=_paths
                )
                sidebar_items.append({"section": subcmd["name"], "contents": [rel_path] + nested})
            else:
                sidebar_items.append(rel_path)

        return sidebar_items

    def _generate_cli_command_page(self, cmd_info: dict, is_main: bool = False) -> str:
        """
        Generate a structured Quarto reference page for a CLI command.

        Produces output modelled on the API reference pages: a title with semantic class labels, a
        short description, a usage/signature block, structured Options / Arguments / Subcommands
        sections, an examples block, and a collapsible `<details>` containing the raw `--help`
        output.

        Parameters
        ----------
        cmd_info
            Command information dictionary (from `_extract_click_command`).
        is_main
            Whether this is the main CLI entry point.

        Returns
        -------
        str
            Quarto markdown content.
        """
        lines: list[str] = []

        title = cmd_info["full_path"] if not is_main else cmd_info["name"]
        label_class = "doc-label-cli-group" if cmd_info.get("is_group") else "doc-label-cli"

        # --- Front matter ---
        lines.append("---")
        lines.append(
            f'title: "[{title}]{{.doc-object-name .doc-function .doc-label .{label_class}}}"'
        )
        lines.append("body-classes: doc-api-page doc-cli-reference")
        lines.append("sidebar: cli-reference")
        lines.append("page-navigation: false")
        lines.append("html-table-processing: none")
        lines.append("---")
        lines.append("")

        # --- Heading ---
        lines.append(f"# [{title}]{{.doc-object-name .doc-function .doc-label .{label_class}}}")
        lines.append("")

        # --- Description ---
        description = cmd_info.get("description", "") or cmd_info.get("help", "")
        # Collect option names for auto-backticking in prose
        option_names = set()
        for opt in cmd_info.get("options", []):
            for n in opt.get("names", []):
                option_names.add(n)
        if description:
            # Take first paragraph as the short subject
            paragraphs = description.split("\n\n")
            short_desc = paragraphs[0].replace("\n", " ").strip()
            short_desc = self._backtick_cli_prose(short_desc, option_names)
            lines.append("::: {.doc-subject}")
            lines.append(short_desc)
            lines.append(":::")
            lines.append("")

        # --- Usage / Signature ---
        # Build the usage string from the command path + arguments
        arguments = cmd_info.get("arguments", [])
        options = cmd_info.get("options", [])
        usage_parts = [title]
        if options:
            usage_parts.append("[OPTIONS]")
        for arg in arguments:
            arg_display = arg.get("name_display", arg.get("name", "")).upper()
            if not arg.get("required", True):
                arg_display = f"[{arg_display}]"
            if arg.get("nargs", 1) == -1:
                arg_display += "..."
            usage_parts.append(arg_display)
        if cmd_info.get("is_group") and cmd_info.get("commands"):
            usage_parts.append("COMMAND [ARGS]...")

        lines.append("::: {.doc-signature .doc-Kind.FUNCTION}")
        lines.append("```bash")
        lines.append(" ".join(usage_parts))
        lines.append("```")
        lines.append(":::")
        lines.append("")

        # --- Extended description ---
        if description:
            paragraphs = description.split("\n\n")
            extra = [p.strip() for p in paragraphs[1:] if p.strip()]
            if extra:
                lines.append("::: {.doc-text}")
                for para in extra:
                    para_lines = para.split("\n")
                    # Handle bullet lists (Click uses • or - )
                    if any(
                        line.strip().startswith(("•", "- ", "* "))
                        for line in para_lines
                        if line.strip()
                    ):
                        for bline in para_lines:
                            stripped = bline.strip()
                            if not stripped:
                                continue
                            # Normalise • bullets to markdown -
                            if stripped.startswith("•"):
                                stripped = stripped[1:].strip()
                            elif stripped.startswith(("- ", "* ")):
                                stripped = stripped[2:].strip()
                            if stripped:
                                stripped = self._backtick_cli_prose(stripped, option_names)
                                lines.append(f"- {stripped}")
                    # Preserve blocks with indented content (e.g. "Quick start:")
                    elif any(line.startswith("  ") for line in para_lines):
                        for bline in para_lines:
                            bline = self._backtick_cli_prose(bline, option_names)
                            lines.append(bline)
                    else:
                        text = para.replace("\n", " ")
                        text = self._backtick_cli_prose(text, option_names)
                        lines.append(text)
                    lines.append("")
                lines.append(":::")
                lines.append("")

        # --- Collapsible --help output ---
        help_text = cmd_info.get("help_text", "")
        if help_text:
            lines.append('::: {.details summary="Full --help output"}')
            lines.append("")
            lines.append("::: {.cli-manpage}")
            lines.append("")
            lines.append("```")
            lines.append(help_text.rstrip())
            lines.append("```")
            lines.append("")
            lines.append(":::")
            lines.append("")
            lines.append(":::")
            lines.append("")

        # --- Arguments section ---
        if arguments:
            lines.append("## Arguments {.doc-parameters}")
            lines.append("")
            lines.append("::: {.doc-definition-items}")
            for arg in arguments:
                display = arg.get("name_display", arg.get("name", "")).upper()
                type_str = arg.get("type")
                if type_str:
                    lines.append(
                        f'<code><span class="doc-parameter-name">{display}</span>'
                        f'<span class="doc-parameter-annotation-sep">:</span>'
                        f' <span class="doc-parameter-annotation">{type_str}</span></code>'
                    )
                else:
                    lines.append(f'<code><span class="doc-parameter-name">{display}</span></code>')
                req = "Required." if arg.get("required", True) else "Optional."
                lines.append(f":   {req}")
                lines.append("")
            lines.append(":::")
            lines.append("")

        # --- Options section ---
        if options:
            lines.append("## Options {.doc-parameters}")
            lines.append("")
            lines.append("::: {.doc-definition-items}")
            for opt in options:
                if opt.get("hidden"):
                    continue
                name_display = opt.get("name_display", "")
                type_str = opt.get("type")

                # Escape double-dashes as HTML entities to prevent Pandoc's smart
                # extension from converting "--" to an en-dash inside inline HTML.
                name_display_html = name_display.replace("--", "&#45;&#45;")

                # Build the <code> element with HTML spans
                code_parts = [f'<span class="doc-parameter-name">{name_display_html}</span>']
                if type_str and not opt.get("is_flag"):
                    code_parts.append(
                        f'<span class="doc-parameter-annotation-sep">:</span>'
                        f' <span class="doc-parameter-annotation">{type_str}</span>'
                    )
                if opt.get("default") is not None and not opt.get("is_flag"):
                    default_val = opt["default"]
                    code_parts.append(
                        f' <span class="doc-parameter-default-sep op">=</span>'
                        f' <span class="doc-parameter-default">{default_val}</span>'
                    )

                lines.append(f"<code>{''.join(code_parts)}</code>")

                # Description line
                help_str = opt.get("help", "")
                if opt.get("required"):
                    help_str = f"**Required.** {help_str}" if help_str else "**Required.**"
                if opt.get("envvar"):
                    envvar = opt["envvar"]
                    if isinstance(envvar, (list, tuple)):
                        envvar = ", ".join(envvar)
                    help_str += f" Environment variable: `{envvar}`."
                help_str = self._backtick_cli_prose(help_str.strip(), option_names)

                lines.append(f":   {help_str}")
                lines.append("")

            lines.append(":::")
            lines.append("")

        # --- Subcommands section ---
        commands = cmd_info.get("commands", [])
        if commands:
            lines.append("## Commands {.doc-parameters}")
            lines.append("")
            lines.append("::: {.doc-definition-items}")
            for subcmd in commands:
                subcmd_name = subcmd["name"]
                safe_name = subcmd_name.replace("-", "_")
                # Link to the subcommand page
                short_help = subcmd.get("short_help", "") or ""
                # For the main page, subcommands are at cli/<name>.qmd
                # For group pages, subcommands are at <group>/<name>.qmd
                if is_main:
                    href = f"{safe_name}.qmd"
                else:
                    parent_safe = cmd_info["name"].replace("-", "_")
                    href = f"{parent_safe}/{safe_name}.qmd"
                lines.append(f"<code>[{subcmd_name}]{{.doc-parameter-name}}</code>")
                link_text = f"[{short_help}]({href})" if short_help else f"[Details]({href})"
                lines.append(f":   {link_text}")
                lines.append("")
            lines.append(":::")
            lines.append("")

        # --- Examples section ---
        examples = cmd_info.get("examples", [])
        if examples:
            lines.append("## Examples {.doc-examples}")
            lines.append("")
            lines.append("```bash")
            for ex in examples:
                lines.append(ex)
            lines.append("```")
            lines.append("")

        return "\n".join(lines)

    @staticmethod
    def _order_cli_sidebar_items(
        items: list[str | dict], cli_sections: list[dict]
    ) -> list[str | dict]:
        """
        Reorder flat top-level command entries to match the configured `cli.sections` order.

        String entries (leaf commands) are sorted to follow the order in which their command
        names appear across the configured sections; commands not mentioned keep their original
        (code) order after the configured ones. Group `{"section": ...}` dicts retain their
        relative order and are appended at the end.

        Parameters
        ----------
        items
            The sidebar items produced by `_generate_subcommand_pages`.
        cli_sections
            The `cli.sections` config list.

        Returns
        -------
        list[str | dict]
            The reordered sidebar items.
        """
        # Build the desired rank for each "reference/cli/<safe>.qmd" path.
        rank: dict[str, int] = {}
        for section in cli_sections:
            for cmd_name in section.get("contents", []):
                safe = cmd_name.replace("-", "_")
                rank.setdefault(f"reference/cli/{safe}.qmd", len(rank))

        strings = [i for i in items if isinstance(i, str)]
        groups = [i for i in items if not isinstance(i, str)]

        # Configured commands first (in section order); unmentioned ones keep their code order.
        base = len(rank)
        ordered = sorted(enumerate(strings), key=lambda iv: (rank.get(iv[1], base + iv[0]),))
        return [path for _, path in ordered] + groups

    @staticmethod
    def _count_cli_sidebar_items(items: list) -> int:
        """Count the total number of .qmd pages in a (possibly nested) sidebar list."""
        count = 0
        for item in items:
            if isinstance(item, str):
                count += 1
            elif isinstance(item, dict):
                if item.get("href"):
                    # A labeled link entry (e.g. the index link) is a single page.
                    count += 1
                else:
                    count += GreatDocs._count_cli_sidebar_items(item.get("contents", []))
        return count

    def _update_sidebar_with_cli(self, cli_files: list[str | dict]) -> None:
        """
        Update the sidebar configuration to include CLI reference.

        Parameters
        ----------
        cli_files
            Sidebar items — plain path strings for leaf commands, or
            `{"section": ..., "contents": [...]}` dicts for groups.
        """
        if not cli_files:
            return

        quarto_yml = self.project_path / "_quarto.yml"
        if not quarto_yml.exists():
            return

        with open(quarto_yml, "r") as f:
            config = read_yaml(f) or {}

        if "website" not in config:
            config["website"] = {}

        # Ensure sidebar exists
        if "sidebar" not in config["website"]:
            config["website"]["sidebar"] = []

        # Check if CLI section already exists
        sidebar = config["website"]["sidebar"]
        cli_section_exists = False

        for section in sidebar:
            if isinstance(section, dict) and section.get("id") == "cli-reference":
                cli_section_exists = True
                # Update contents
                section["contents"] = cli_files
                break

        if not cli_section_exists:
            from ._translations import get_translation

            cli_section_title = self._config.cli_title or get_translation(
                "cli_reference", self._config.language
            )
            # Add CLI section
            cli_section = {
                "id": "cli-reference",
                "title": cli_section_title,
                "contents": cli_files,
            }
            sidebar.append(cli_section)

        # Ensure the reference sidebar has an API link at the top
        for section in sidebar:
            if isinstance(section, dict) and section.get("id") == "reference":
                contents = section.get("contents", [])
                # Check if API link already exists at the top
                has_api_link = False
                if contents and isinstance(contents[0], dict):
                    if contents[0].get("text") in ("API", "API Index") or contents[0].get(
                        "href", ""
                    ).endswith("reference/index.qmd"):
                        has_api_link = True
                if not has_api_link:
                    # Add API link at the top
                    from ._translations import get_translation

                    api_index_label = get_translation("api_index", self._config.language)
                    section["contents"] = [
                        {"text": api_index_label, "href": "reference/index.qmd"},
                    ] + contents
                break

        self._write_quarto_yml(quarto_yml, config)

        # Ensure there's a "Reference" navbar entry so users can reach the CLI
        # reference pages.  If a Python API reference already added the entry,
        # that link (reference/index.qmd) takes precedence; otherwise we add one
        # pointing directly to the CLI index.
        navbar = config.get("website", {}).get("navbar", {})
        left = navbar.get("left", [])
        has_ref = any(
            isinstance(item, dict)
            and (
                item.get("text") in ("Reference", "CLI Reference")
                or (item.get("href") or "").startswith("reference/")
            )
            for item in left
        )
        if not has_ref:
            left.append({"text": "Reference", "href": "reference/cli/index.qmd"})
            navbar["left"] = left
            config["website"]["navbar"] = navbar
            self._write_quarto_yml(quarto_yml, config)

        print(
            f"Updated sidebar with {self._count_cli_sidebar_items(cli_files)} CLI reference page(s)"
        )

    # =========================================================================
    # MCP Server Documentation Methods
    # =========================================================================

    def _discover_mcp_server(self) -> dict | None:
        """
        Discover and introspect the configured MCP server module.

        Returns
        -------
        dict | None
            Server metadata with tools/resources/prompts, or None if not found.
        """
        module_path = self._config.mcp_module
        if not module_path:
            # Try common MCP module locations based on the documented package
            package_name = self._normalize_package_name(
                self._config["module"] or self.project_path.name
            )
            common_mcp_modules = [
                f"{package_name}.mcp",
                f"{package_name}.mcp_server",
                f"{package_name}.server",
            ]
            for candidate in common_mcp_modules:
                try:
                    import importlib

                    importlib.import_module(candidate)
                    module_path = candidate
                    break
                except ImportError:
                    continue

        if not module_path:
            print("No MCP module configured or found")
            return None

        from ._mcp_docs import discover_mcp_server

        return discover_mcp_server(
            module_path=module_path,
            server_var=self._config.mcp_server_var,
        )

    def _generate_mcp_reference_pages(self, mcp_info: dict) -> list[str | dict]:
        """
        Generate Quarto reference pages for MCP server tools/resources/prompts.

        Parameters
        ----------
        mcp_info
            Server metadata from _discover_mcp_server().

        Returns
        -------
        list[str | dict]
            Sidebar items for the generated pages.
        """
        from ._mcp_docs import generate_mcp_reference_pages

        mcp_ref_dir = self.project_path / "reference" / "mcp"
        return generate_mcp_reference_pages(
            server_info=mcp_info,
            output_dir=mcp_ref_dir,
            categories=self._config.mcp_categories or None,
            display_name=self._config.mcp_name,
        )

    def _update_sidebar_with_mcp(self, mcp_files: list[str | dict]) -> None:
        """
        Update the sidebar configuration to include MCP reference.

        Parameters
        ----------
        mcp_files
            Sidebar items for MCP reference pages.
        """
        if not mcp_files:
            return

        quarto_yml = self.project_path / "_quarto.yml"
        if not quarto_yml.exists():
            return

        with open(quarto_yml, "r") as f:
            config = read_yaml(f) or {}

        if "website" not in config:
            config["website"] = {}

        # Ensure sidebar exists
        if "sidebar" not in config["website"]:
            config["website"]["sidebar"] = []

        sidebar = config["website"]["sidebar"]
        mcp_section_exists = False

        for section in sidebar:
            if isinstance(section, dict) and section.get("id") == "mcp-reference":
                mcp_section_exists = True
                section["contents"] = mcp_files
                break

        if not mcp_section_exists:
            mcp_section = {
                "id": "mcp-reference",
                "title": "MCP Reference",
                "contents": [{"text": "MCP Index", "href": "reference/mcp/index.qmd"}] + mcp_files,
            }
            sidebar.append(mcp_section)

        self._write_quarto_yml(quarto_yml, config)

        # Count total tool pages
        n_pages = self._count_cli_sidebar_items(mcp_files)
        print(f"Updated sidebar with {n_pages} MCP reference page(s)")

    def _remove_mcp_from_ref_sections(self) -> None:
        """
        Remove 'mcp' from the reference switcher data attribute in _quarto.yml.

        Called when MCP is enabled in config but no MCP server is discovered
        or no MCP pages are generated. This prevents a dangling MCP navigation
        item that would lead to a 404 page.
        """
        quarto_yml = self.project_path / "_quarto.yml"
        if not quarto_yml.exists():
            return

        with open(quarto_yml, "r") as f:
            config = read_yaml(f) or {}

        html_config = config.get("format", {}).get("html", {})
        include_in_header = html_config.get("include-in-header", [])
        include_after_body = html_config.get("include-after-body", [])

        # Find and update the data-gd-ref-sections script
        updated = False
        for i, item in enumerate(include_in_header):
            if isinstance(item, dict) and "data-gd-ref-sections" in item.get("text", ""):
                # Extract current sections and remove 'mcp'
                text = item["text"]
                # The format is: ...setAttribute('data-gd-ref-sections','api,cli,mcp')...
                match = re.search(r"'data-gd-ref-sections','([^']*)'", text)
                if match:
                    sections = [s for s in match.group(1).split(",") if s != "mcp"]
                    if len(sections) <= 1:
                        # Only 'api' remains — remove the ref-sections script entirely
                        include_in_header.pop(i)
                        # Also remove the reference-switcher.js script
                        config["format"]["html"]["include-after-body"] = [
                            entry
                            for entry in include_after_body
                            if not (
                                isinstance(entry, dict)
                                and "reference-switcher" in entry.get("text", "")
                            )
                        ]
                    else:
                        new_sections = ",".join(sections)
                        item["text"] = re.sub(
                            r"'data-gd-ref-sections','[^']*'",
                            f"'data-gd-ref-sections','{new_sections}'",
                            text,
                        )
                    updated = True
                break

        if updated:
            self._write_quarto_yml(quarto_yml, config)

    def _generate_mcp_manifest(self, mcp_info: dict) -> None:
        """
        Generate a .well-known/mcp.json discovery manifest.

        Enables clients and registries to auto-discover the MCP server
        and its capabilities from the documentation site URL.

        Parameters
        ----------
        mcp_info
            Server metadata from _discover_mcp_server().
        """
        from ._mcp_docs import generate_mcp_manifest

        # Gather project metadata
        package_name = self._detect_package_name()
        owner, repo, _ = self._get_github_repo_info()
        repo_url = f"https://github.com/{owner}/{repo}" if owner and repo else None
        site_url = self._config["site_url"]

        generate_mcp_manifest(
            server_info=mcp_info,
            output_dir=self.project_path,
            package_name=package_name,
            repo_url=repo_url,
            site_url=site_url,
        )

    # =========================================================================
    # User Guide Methods
    # =========================================================================

    def _find_user_guide_dir(self) -> Path | None:
        """
        Find the user guide source directory.

        Resolves the directory in the following order:

        1. If `user_guide` is a string in great-docs.yml, that path is used.
        2. Otherwise, looks for `user_guide/` then `user-guide/` in the project root.

        Returns
        -------
        Path | None
            Path to the user guide source directory, or `None` if not found.
        """
        package_root = self._find_package_root()
        configured_dir = self._config.user_guide_dir  # str or None (ignores list)

        if configured_dir is not None:
            user_guide_dir = package_root / configured_dir

            # Warn if conventional directory also exists but is being ignored
            conventional_dir = package_root / "user_guide"
            if conventional_dir.exists() and conventional_dir.is_dir():
                if user_guide_dir.resolve() != conventional_dir.resolve():
                    print(
                        f"   ⚠️  Both 'user_guide' config option ('{configured_dir}') and "
                        f"'user_guide/' directory exist; using configured path"
                    )

            if not user_guide_dir.exists() or not user_guide_dir.is_dir():
                print(
                    f"   ⚠️  User guide directory '{configured_dir}' "
                    f"specified in great-docs.yml does not exist"
                )
                return None

            return user_guide_dir

        # Fall back to conventional directory names
        user_guide_dir = package_root / "user_guide"
        if not user_guide_dir.exists() or not user_guide_dir.is_dir():
            user_guide_dir = package_root / "user-guide"
        if not user_guide_dir.exists() or not user_guide_dir.is_dir():
            return None

        return user_guide_dir

    def _discover_user_guide_explicit(
        self, user_guide_dir: Path, explicit_config: list[dict]
    ) -> dict | None:
        """
        Build user guide info from explicit section ordering in great-docs.yml.

        When `user_guide` is a list of section dicts in the config, this method
        resolves the referenced files and builds the user_guide_info structure.
        Files are referenced by name relative to the user guide source directory
        (no `user_guide/` prefix needed). Numeric filename prefixes are preserved.

        Parameters
        ----------
        user_guide_dir
            Path to the user guide source directory.
        explicit_config
            The list of section dicts from `user_guide` config.

        Returns
        -------
        dict | None
            User guide info structure with `explicit` flag set to True, or `None`
            if no valid files are found.
        """
        files_info = []
        sections: dict[str, list] = {}
        has_index = False
        seen_files: set[str] = set()

        for section_entry in explicit_config:
            section_name = section_entry.get("section", "")
            section_contents = section_entry.get("contents", [])

            if not section_name or not section_contents:
                continue

            section_files = []

            for item in section_contents:
                # Each item is either a string (filename) or a dict with text/href
                if isinstance(item, str):
                    filename = item
                    custom_text = None
                elif isinstance(item, dict):
                    filename = item.get("href", "")
                    custom_text = item.get("text")
                else:
                    continue  # pragma: no cover

                if not filename or filename in seen_files:
                    continue

                # Resolve the file in the source directory
                file_path = user_guide_dir / filename
                if not file_path.exists():
                    print(f"   ⚠️  User guide file '{filename}' referenced in config does not exist")
                    continue

                seen_files.add(filename)

                # Parse the file for title/frontmatter
                file_info = self._parse_user_guide_file(file_path)
                if not file_info:
                    continue  # pragma: no cover

                # Override section from config (not frontmatter)
                file_info["section"] = section_name

                # Store custom text if provided
                if custom_text:
                    file_info["custom_text"] = custom_text

                files_info.append(file_info)
                section_files.append(file_info)

                if file_path.name == "index.qmd":
                    has_index = True

            if section_files:
                sections[section_name] = section_files

        if not files_info:
            return None

        return {
            "files": files_info,
            "sections": sections,
            "has_index": has_index,
            "source_dir": user_guide_dir,
            "explicit": True,
            "explicit_config": explicit_config,
        }

    def _discover_user_guide(self) -> dict | None:
        """
        Discover user guide content from the user_guide directory.

        Supports two modes:

        1. **Explicit ordering**: When `user_guide` in great-docs.yml is a list of
           section dicts, files are ordered and grouped exactly as specified.
        2. **Auto-discovery**: When `user_guide` is a string (directory path) or `None`,
           files are discovered from the directory and sorted by filename.

        The source directory is resolved by `_find_user_guide_dir()`.

        Returns
        -------
        dict | None
            Dictionary containing user guide structure, or `None` if no user guide found.
            Structure: {
                "files": [{"path": Path, "section": str | None, "title": str}, ...],
                "sections": {"Section Name": [file_info, ...], ...},
                "has_index": bool,
                "source_dir": Path,
                "explicit": bool  (True when using explicit config ordering)
            }
        """
        # Check for explicit section ordering in config
        user_guide_config = self._config.user_guide
        if isinstance(user_guide_config, list):
            user_guide_dir = self._find_user_guide_dir()
            if not user_guide_dir:
                return None  # pragma: no cover
            return self._discover_user_guide_explicit(user_guide_dir, user_guide_config)

        # Auto-discovery mode
        user_guide_dir = self._find_user_guide_dir()
        if not user_guide_dir:
            return None

        # Find all .qmd and .md files (not in subdirectories that are likely asset folders)
        guide_files = []
        valid_extensions = {".qmd", ".md"}
        # Check if directory is completely empty
        dir_contents = list(user_guide_dir.iterdir())
        if not dir_contents:
            print(f"   ⚠️  User guide directory '{user_guide_dir}' is empty")
            return None

        for item in dir_contents:
            if item.is_file() and item.suffix in valid_extensions:
                guide_files.append(item)
            elif item.is_dir():
                # Recursively check subdirectories for guide files at any depth
                for ext in valid_extensions:
                    for subitem in item.rglob(f"*{ext}"):
                        if subitem.is_file():
                            guide_files.append(subitem)

        if not guide_files:
            print(f"   ⚠️  User guide directory '{user_guide_dir}' contains no .qmd or .md files")
            return None

        # Sort files by full relative path, with root-level files first
        # and index.qmd prioritized within each level
        guide_files.sort(
            key=lambda p: (
                p.parent != user_guide_dir,  # Root-level files first
                p.name != "index.qmd",  # index.qmd first within each level
                p.relative_to(user_guide_dir),
            )
        )

        # Parse each file to extract section and title from frontmatter
        files_info = []
        sections: dict[str, list] = {}
        has_index = False

        for qmd_path in guide_files:
            file_info = self._parse_user_guide_file(qmd_path)
            if file_info:
                files_info.append(file_info)

                # Track if there's a root-level index.qmd (subdir index.qmd files
                # are used only for section titles, not as the UG landing page)
                if qmd_path.name == "index.qmd" and qmd_path.parent == user_guide_dir:
                    has_index = True

                # Group by section
                section_name = file_info.get("section")
                if section_name:
                    if section_name not in sections:
                        sections[section_name] = []
                    sections[section_name].append(file_info)

        if not files_info:
            return None  # pragma: no cover

        return {
            "files": files_info,
            "sections": sections,
            "has_index": has_index,
            "source_dir": user_guide_dir,
            "explicit": False,
        }

    def _parse_user_guide_file(self, qmd_path: Path) -> dict | None:
        """
        Parse a user guide .qmd file to extract metadata from frontmatter.

        Parameters
        ----------
        qmd_path
            Path to the .qmd file.

        Returns
        -------
        dict | None
            Dictionary with file info: {"path": Path, "section": str | None, "title": str}
        """
        try:
            with open(qmd_path, "r", encoding="utf-8") as f:
                content = f.read()
        except Exception:
            return None

        # Extract YAML frontmatter
        frontmatter = {}
        if content.startswith("---"):
            parts = content.split("---", 2)
            if len(parts) >= 3:
                try:
                    frontmatter = parse_yaml(parts[1]) or {}
                except ValueError:  # pragma: no cover
                    pass  # pragma: no cover

        # Get title from frontmatter or derive from filename
        title = frontmatter.get("title")
        if not title:
            # Derive title from filename: "01-getting-started.qmd" -> "Getting Started"
            name = qmd_path.stem
            # Remove leading number prefix like "01-" or "00_"
            name = re.sub(r"^\d+[-_]", "", name)
            # Convert to title case
            title = name.replace("-", " ").replace("_", " ").title()

        # Get section from frontmatter (use 'guide-section' to avoid conflict with Quarto's 'section')
        section = frontmatter.get("guide-section")

        return {
            "path": qmd_path,
            "section": section,
            "title": title,
            "frontmatter": frontmatter,
        }

    def _strip_numeric_prefix(self, filename: str) -> str:
        """
        Strip numeric ordering prefix from a filename.

        Handles common patterns like:

        - 00-introduction.qmd -> introduction.qmd
        - 01-installation.qmd -> installation.qmd
        - 1-getting-started.qmd -> getting-started.qmd
        - 0001-overview.qmd -> overview.qmd

        Parameters
        ----------
        filename
            The filename to process.

        Returns
        -------
        str
            The filename with numeric prefix stripped, or unchanged if no prefix.
        """
        # Pattern matches: digits followed by a hyphen or underscore at the start
        # e.g., "00-", "01-", "1-", "0001-", "00_", etc.
        pattern = r"^\d+-|^\d+_"
        return re.sub(pattern, "", filename)

    def _fix_numeric_prefix_links(self, content: str) -> str:
        """
        Rewrite relative Markdown links to `.qmd` files, stripping numeric prefixes.

        When numeric prefixes are stripped from filenames during the copy step (e.g.,
        `11-theming.qmd` -> `theming.qmd`), any cross-references between pages that use the original
        prefixed names would break. This method fixes those links so authors can write
        `[Theming](11-theming.qmd)` in source and it resolves correctly in the rendered site.

        Only relative links to `.qmd` files are affected; absolute URLs and anchors are left
        untouched.
        """

        def _rewrite(m: re.Match) -> str:
            path = m.group(1)
            # Split off anchor / query string
            anchor = ""
            for sep in ("#", "?"):
                idx = path.find(sep)
                if idx != -1:
                    anchor = path[idx:]
                    path = path[:idx]
                    break
            # Strip numeric prefix from each path component
            parts = path.split("/")
            clean_parts = [re.sub(r"^\d+[-_]", "", p) for p in parts]
            return "](" + "/".join(clean_parts) + anchor + ")"

        # Match markdown link targets that are relative paths ending in .qmd
        # (skip absolute URLs starting with http://, https://, or /)
        return re.sub(r"\]\((?!https?://|/)([^)]+\.qmd(?:[#?][^)]*)?)\)", _rewrite, content)

    def _copy_user_guide_to_docs(self, user_guide_info: dict) -> list[str]:
        """
        Copy user guide files from project root to docs directory.

        Adds `bread-crumbs: false` to the frontmatter of each file to disable breadcrumb
        navigation on user guide pages.

        When using explicit ordering (`user_guide` is a list in config), filenames are
        preserved as-is. In auto-discovery mode, numeric prefixes are stripped for cleaner URLs.

        Parameters
        ----------
        user_guide_info
            User guide structure from `_discover_user_guide`.

        Returns
        -------
        list[str]
            List of copied file paths relative to docs dir.
        """
        if not user_guide_info:
            return []

        source_dir = user_guide_info["source_dir"]
        target_dir = self.project_path / "user-guide"
        target_dir.mkdir(parents=True, exist_ok=True)

        is_explicit = user_guide_info.get("explicit", False)
        copied_files = []

        for file_info in user_guide_info["files"]:
            src_path = file_info["path"]

            # Determine relative path from source_dir
            try:
                rel_path = src_path.relative_to(source_dir)
            except ValueError:  # pragma: no cover
                rel_path = Path(src_path.name)  # pragma: no cover

            if is_explicit:
                # Explicit mode: preserve filenames as-is
                dest_rel_path = rel_path
            else:
                # Auto-discovery mode: strip numeric prefixes from both
                # directory names and filenames for cleaner URLs
                clean_parts = [self._strip_numeric_prefix(part) for part in rel_path.parts]
                dest_rel_path = Path(*clean_parts) if clean_parts else rel_path

            dst_path = target_dir / dest_rel_path
            dst_path.parent.mkdir(parents=True, exist_ok=True)

            # Read the source file and modify frontmatter
            with open(src_path, "r", encoding="utf-8") as f:
                content = f.read()

            # Expand {{< code-include >}} shortcodes before Quarto sees the file
            content = self._expand_code_includes(content, src_path.parent)

            # In auto-discovery mode, fix links to .qmd files with numeric prefixes
            if not is_explicit:
                content = self._fix_numeric_prefix_links(content)

            # Add bread-crumbs: false to frontmatter
            content = self._add_frontmatter_option(content, "bread-crumbs", False)

            # Write to destination
            with open(dst_path, "w", encoding="utf-8") as f:
                f.write(content)

            copied_files.append(f"user-guide/{dest_rel_path}")

        # Also copy any asset directories (images, data, code snippets, etc.)
        for item in source_dir.iterdir():
            if item.is_dir() and self._is_asset_dir(item):
                dst_dir = target_dir / item.name
                if dst_dir.exists():
                    shutil.rmtree(dst_dir)  # pragma: no cover
                shutil.copytree(item, dst_dir)

        return copied_files

    @staticmethod
    def _is_asset_dir(directory: Path) -> bool:
        """
        Determine whether *directory* is an asset directory (images, data, snippets, etc.).

        A directory is considered an asset directory when either:

        - It contains no ``.qmd`` files at any depth, **or**
        - Its name starts with ``_`` (e.g., ``_includes/``, ``_snippets/``).

        The underscore rule lets authors store ``.qmd`` snippets intended for
        ``code-include`` (or other non-page purposes) alongside images and data
        files without the directory being mistaken for a content subdirectory.
        """
        if directory.name.startswith("_"):
            return True
        return not any(f.suffix == ".qmd" for f in directory.rglob("*"))

    def _add_frontmatter_option(self, content: str, key: str, value) -> str:
        """
        Add or update an option in the YAML frontmatter of a .qmd file.

        Parameters
        ----------
        content
            The file content.
        key
            The frontmatter key to add/update.
        value
            The value to set.

        Returns
        -------
        str
            The modified content with the frontmatter option added.
        """
        # Convert value to YAML string representation
        if isinstance(value, bool):
            yaml_value = "true" if value else "false"
        elif isinstance(value, str):
            yaml_value = f'"{value}"'
        else:
            yaml_value = str(value)

        if content.startswith("---"):
            parts = content.split("---", 2)
            if len(parts) >= 3:
                frontmatter = parts[1]
                rest = parts[2]

                # Check if the key already exists
                key_pattern = rf"^{re.escape(key)}:\s*.*$"
                if re.search(key_pattern, frontmatter, re.MULTILINE):
                    # Update existing key
                    frontmatter = re.sub(
                        key_pattern, f"{key}: {yaml_value}", frontmatter, flags=re.MULTILINE
                    )
                else:
                    # Add new key at the end of frontmatter
                    frontmatter = frontmatter.rstrip() + f"\n{key}: {yaml_value}\n"

                return f"---{frontmatter}---{rest}"

        # No frontmatter, create one
        return f"---\n{key}: {yaml_value}\n---\n\n{content}"

    # include double but ignore triple curly braces
    _CODE_INCLUDE_RE = re.compile(
        r"(?<!\{)\{\{<\s*include\s+(.*?)\s*>\}\}(?!\})",
    )

    _CONTENT_EXTENSIONS = {".qmd", ".md"}

    _EXT_TO_LANG: dict[str, str] = {
        ".py": "python",
        ".r": "r",
        ".js": "javascript",
        ".ts": "typescript",
        ".jsx": "jsx",
        ".tsx": "tsx",
        ".rb": "ruby",
        ".rs": "rust",
        ".go": "go",
        ".java": "java",
        ".cpp": "cpp",
        ".cc": "cpp",
        ".cxx": "cpp",
        ".c": "c",
        ".h": "c",
        ".hpp": "cpp",
        ".cs": "csharp",
        ".css": "css",
        ".scss": "scss",
        ".html": "html",
        ".sql": "sql",
        ".sh": "bash",
        ".bash": "bash",
        ".zsh": "bash",
        ".yaml": "yaml",
        ".yml": "yaml",
        ".json": "json",
        ".toml": "toml",
        ".xml": "xml",
        ".lua": "lua",
        ".swift": "swift",
        ".kt": "kotlin",
        ".scala": "scala",
        ".jl": "julia",
        ".m": "matlab",
        ".pl": "perl",
        ".php": "php",
    }

    def _expand_code_includes(self, content: str, source_dir: Path) -> str:
        """
        Replace ``{{< include path >}}`` shortcodes with fenced code blocks.

        Only intercepts includes that target code files (non-``.qmd``/``.md``)
        or that use the ``lang`` or ``lines`` keywords.  Plain
        ``{{< include _shared.qmd >}}`` shortcodes are left untouched so
        Quarto can handle them natively.

        Supports optional keyword arguments:

        - ``lang="python"`` — override the auto-detected language
        - ``lines="5-10"`` — include only the specified line range (1-based, inclusive)

        File paths are resolved first relative to *source_dir* (the directory
        containing the source ``.qmd`` file), then relative to the project root.

        Parameters
        ----------
        content
            The ``.qmd`` file content.
        source_dir
            Directory of the source file (for relative path resolution).

        Returns
        -------
        str
            Content with code-file ``include`` shortcodes replaced by fenced
            code blocks.
        """

        def _replace(m: re.Match) -> str:
            raw_args = m.group(1)

            file_path_str, lang, lines_spec = self._parse_code_include_args(raw_args)

            if not file_path_str:
                return m.group(0)

            ext = Path(file_path_str).suffix.lower()
            is_content_file = ext in self._CONTENT_EXTENSIONS

            if is_content_file and not lang and not lines_spec:
                return m.group(0)

            resolved = self._resolve_code_include_path(file_path_str, source_dir)
            if resolved is None:
                return f"<!-- include error: file not found: {file_path_str} -->"

            try:
                file_content = resolved.read_text(encoding="utf-8")
            except (OSError, UnicodeDecodeError) as e:
                return f"<!-- include error: {e} -->"

            if lines_spec:
                file_content = self._select_lines(file_content, lines_spec)

            if not lang:
                lang = self._EXT_TO_LANG.get(resolved.suffix.lower(), "")

            longest_run = 0
            current_run = 0
            for ch in file_content:
                if ch == "`":
                    current_run += 1
                    longest_run = max(longest_run, current_run)
                else:
                    current_run = 0
            fence = "`" * max(3, longest_run + 1)
            return f"{fence}{lang}\n{file_content.rstrip()}\n{fence}"

        return self._CODE_INCLUDE_RE.sub(_replace, content)

    @staticmethod
    def _parse_code_include_args(raw: str) -> tuple[str, str, str]:
        """
        Parse ``include`` shortcode arguments.

        Returns `(file_path, lang, lines)` where *lang* and *lines* may be empty strings if not
        specified.
        """
        lang = ""
        lines = ""
        file_path = ""

        # Extract keyword arguments
        kw_re = re.compile(r'(\w+)\s*=\s*"([^"]*)"')
        for kw_match in kw_re.finditer(raw):
            key, value = kw_match.group(1), kw_match.group(2)
            if key == "lang":
                lang = value
            elif key == "lines":
                lines = value

        # The file path is whatever remains after stripping keyword args
        remaining = kw_re.sub("", raw).strip()
        # Remove surrounding quotes if present
        if remaining and remaining[0] in ('"', "'") and remaining[-1] == remaining[0]:
            remaining = remaining[1:-1]
        file_path = remaining

        return file_path, lang, lines

    def _resolve_code_include_path(self, file_path_str: str, source_dir: Path) -> Path | None:
        """
        Resolve a code-include file path.

        Tries *source_dir* first, then the project root.
        """
        candidates = [
            source_dir / file_path_str,
            self.project_root / file_path_str,
        ]
        for candidate in candidates:
            resolved = candidate.resolve()
            if resolved.is_file():
                return resolved
        return None

    @staticmethod
    def _select_lines(content: str, lines_spec: str) -> str:
        """
        Select a range of lines from *content*.

        *lines_spec* is `"start-end"` (1-based, inclusive) or `"N"` for a single line.
        """
        all_lines = content.splitlines(keepends=True)

        if "-" in lines_spec:
            parts = lines_spec.split("-", 1)
            start = int(parts[0]) - 1
            end = int(parts[1])
        else:
            start = int(lines_spec) - 1
            end = start + 1

        start = max(0, start)
        end = min(len(all_lines), end)
        return "".join(all_lines[start:end])

    # Regex for detecting a top-level `freeze:` key in YAML frontmatter
    _FREEZE_FM_RE = re.compile(r"^freeze:\s+(.+)$", re.MULTILINE)

    def _normalize_freeze_shorthand(self) -> list[str]:
        """Rewrite page-level `freeze: auto|true` to `execute: freeze: auto|true`.

        Scans all `.qmd` files in the build directory and transforms the Great Docs shorthand into
        the nested form that Quarto understands.

        Returns the list of relative file paths that were modified.
        """
        modified: list[str] = []
        for qmd in self.project_path.rglob("*.qmd"):
            content = qmd.read_text(encoding="utf-8")
            if not content.startswith("---"):
                continue

            parts = content.split("---", 2)
            if len(parts) < 3:
                continue

            frontmatter = parts[1]

            # Check for top-level `freeze:` (not indented under `execute:`)
            # The regex uses ^ with MULTILINE, so it only matches `freeze:` at column 0
            m = self._FREEZE_FM_RE.search(frontmatter)
            if not m:
                continue

            freeze_value = m.group(1).strip()

            # Remove the standalone `freeze:` line
            new_frontmatter = frontmatter[: m.start()] + frontmatter[m.end() :]

            # Check if `execute:` block already exists
            exec_match = re.search(r"^execute:\s*$", new_frontmatter, re.MULTILINE)
            if exec_match:
                # Insert `  freeze: <value>` after the `execute:` line
                insert_pos = exec_match.end()
                new_frontmatter = (
                    new_frontmatter[:insert_pos]
                    + f"\n  freeze: {freeze_value}"
                    + new_frontmatter[insert_pos:]
                )
            else:
                # Add a new `execute:` block
                new_frontmatter = (
                    new_frontmatter.rstrip() + f"\nexecute:\n  freeze: {freeze_value}\n"
                )

            content = f"---{new_frontmatter}---{parts[2]}"
            qmd.write_text(content, encoding="utf-8")
            rel_path = str(qmd.relative_to(self.project_path))
            modified.append(rel_path)

        return modified

    def _generate_user_guide_sidebar(self, user_guide_info: dict) -> dict:
        """
        Generate sidebar configuration for the user guide.

        In explicit mode, builds the sidebar directly from the config structure.
        In auto-discovery mode, builds it from discovered files and frontmatter sections.

        Parameters
        ----------
        user_guide_info
            User guide structure from _discover_user_guide.

        Returns
        -------
        dict
            Sidebar configuration dict for Quarto.
        """
        is_explicit = user_guide_info.get("explicit", False)

        if is_explicit:
            return self._generate_user_guide_sidebar_explicit(user_guide_info)

        return self._generate_user_guide_sidebar_auto(user_guide_info)

    def _generate_user_guide_sidebar_explicit(self, user_guide_info: dict) -> dict:
        """
        Generate sidebar from explicit config ordering.

        Builds the sidebar structure directly from the `user_guide` config list,
        preserving exact section ordering and file ordering as specified by the user.
        Filenames are used as-is (no numeric prefix stripping).

        Parameters
        ----------
        user_guide_info
            User guide structure from `_discover_user_guide_explicit`.

        Returns
        -------
        dict
            Sidebar configuration dict for Quarto.
        """
        explicit_config = user_guide_info["explicit_config"]
        source_dir = user_guide_info["source_dir"]
        title_lookup = {
            fi["path"].relative_to(source_dir).as_posix(): fi["title"]
            for fi in user_guide_info.get("files", [])
            if fi.get("title")
        }
        contents = []

        for section_entry in explicit_config:
            section_name = section_entry.get("section", "")
            section_contents = section_entry.get("contents", [])

            if not section_name or not section_contents:
                continue  # pragma: no cover

            sidebar_section_contents = []

            for item in section_contents:
                if isinstance(item, str):
                    filename = item
                    custom_text = None
                elif isinstance(item, dict):
                    filename = item.get("href", "")
                    custom_text = item.get("text")
                else:
                    continue  # pragma: no cover

                if not filename:
                    continue  # pragma: no cover

                # Verify the file exists in the source directory
                file_path = source_dir / filename
                if not file_path.exists():
                    continue

                href = f"user-guide/{filename}"
                text = custom_text or title_lookup.get(filename)

                if text:
                    sidebar_section_contents.append({"text": text, "href": href})
                else:
                    sidebar_section_contents.append(href)

            if sidebar_section_contents:
                contents.append(
                    {
                        "section": section_name,
                        "contents": sidebar_section_contents,
                    }
                )

        return {
            "id": "user-guide",
            "title": "User Guide",
            "contents": contents,
        }

    def _generate_user_guide_sidebar_auto(self, user_guide_info: dict) -> dict:
        """
        Generate sidebar from auto-discovered files.

        Builds the sidebar structure from discovered files and frontmatter sections,
        stripping numeric prefixes from filenames for cleaner URLs.

        Parameters
        ----------
        user_guide_info
            User guide structure from _discover_user_guide.

        Returns
        -------
        dict
            Sidebar configuration dict for Quarto.
        """
        source_dir = user_guide_info["source_dir"]
        files_info = user_guide_info["files"]
        sections = user_guide_info["sections"]

        contents = []

        # Helper to get clean href (strips numeric prefixes for cleaner URLs)
        def get_clean_href(file_info: dict) -> str:
            rel_path = file_info["path"].relative_to(source_dir)
            clean_parts = [self._strip_numeric_prefix(part) for part in rel_path.parts]
            clean_rel_path = Path(*clean_parts) if clean_parts else rel_path
            return f"user-guide/{clean_rel_path}"

        # If we have sections, organize by section
        if sections:
            # Track which files have been assigned to sections
            assigned_files = set()

            # First, preserve section order based on first file appearance
            section_order = []
            for file_info in files_info:
                section = file_info.get("section")
                if section and section not in section_order:
                    section_order.append(section)

            # Build section entries
            for section_name in section_order:
                section_files = sections[section_name]
                section_contents = []

                for file_info in section_files:
                    href = get_clean_href(file_info)
                    assigned_files.add(file_info["path"])

                    if file_info.get("title"):
                        section_contents.append(
                            {
                                "text": file_info["title"],
                                "href": href,
                            }
                        )
                    else:
                        section_contents.append(href)

                contents.append(
                    {
                        "section": section_name,
                        "contents": section_contents,
                    }
                )

            # Add any files without sections at the end
            unsectioned = []
            for file_info in files_info:
                if file_info["path"] not in assigned_files:
                    href = get_clean_href(file_info)
                    if file_info.get("title"):
                        unsectioned.append({"text": file_info["title"], "href": href})
                    else:
                        unsectioned.append(href)

            if unsectioned:
                contents.extend(unsectioned)

        else:
            # Check if files span multiple subdirectories — if so, group by
            # parent directory to give the sidebar a logical structure.
            subdirs = {}
            root_files = []
            for file_info in files_info:
                rel_path = file_info["path"].relative_to(source_dir)
                parent = rel_path.parent
                if parent == Path("."):
                    root_files.append(file_info)
                else:
                    parent_str = str(parent)
                    if parent_str not in subdirs:
                        subdirs[parent_str] = []
                    subdirs[parent_str].append(file_info)

            if subdirs:
                # Root index.qmd is always first; all other root files and subdir
                # sections are sorted together so numeric prefixes control the order
                # regardless of whether an item is a file or a directory.
                root_index_file = next(
                    (fi for fi in root_files if fi["path"].name == "index.qmd"), None
                )
                other_root_files = [fi for fi in root_files if fi["path"].name != "index.qmd"]

                if root_index_file:
                    href = get_clean_href(root_index_file)
                    if root_index_file.get("title"):
                        contents.append({"text": root_index_file["title"], "href": href})
                    else:
                        contents.append(href)  # pragma: no cover

                # Build a combined list keyed by each item's leading name component
                # so that files and directory sections sort together.
                nav_items: list = []
                for file_info in other_root_files:
                    nav_items.append(("file", file_info["path"].name, file_info))
                for subdir, dir_files in subdirs.items():
                    sort_key = Path(subdir).parts[0]
                    nav_items.append(("section", sort_key, subdir, dir_files))
                nav_items.sort(key=lambda x: x[1])

                for item in nav_items:
                    if item[0] == "file":
                        file_info = item[2]
                        href = get_clean_href(file_info)
                        if file_info.get("title"):
                            contents.append({"text": file_info["title"], "href": href})
                        else:
                            contents.append(href)  # pragma: no cover
                    else:
                        _, _, subdir, dir_files = item
                        # Use the index.qmd title as the section title if present,
                        # otherwise derive from the directory name
                        clean_subdir = self._strip_numeric_prefix(subdir)
                        section_title = clean_subdir.replace("-", " ").replace("_", " ").title()
                        section_contents = []
                        for file_info in dir_files:
                            if file_info["path"].name == "index.qmd":
                                section_title = file_info["title"]
                            else:
                                href = get_clean_href(file_info)
                                if file_info.get("title"):
                                    section_contents.append(
                                        {"text": file_info["title"], "href": href}
                                    )
                                else:
                                    section_contents.append(href)

                        contents.append({"section": section_title, "contents": section_contents})
            else:
                # All files at the root level — list in order
                for file_info in files_info:
                    href = get_clean_href(file_info)
                    if file_info.get("title"):
                        contents.append({"text": file_info["title"], "href": href})
                    else:
                        contents.append(href)

        return {
            "id": "user-guide",
            "title": "User Guide",
            "contents": contents,
        }

    def _rewrite_sidebar_first_entry(self, sidebar_config: dict, blended_first: str) -> None:
        """
        Rewrite the first sidebar entry to point to `index.qmd`.

        In blended homepage mode the first UG page lives at the site root as `index.qmd`, so the
        sidebar must link there instead of to `user-guide/<page>.qmd`.

        Handles both flat and sectioned sidebar layouts by recursively searching for the first href
        match.

        Parameters
        ----------
        sidebar_config
            The user-guide sidebar dict (modified in place).
        blended_first
            The original href that was promoted (e.g. `"user-guide/introduction.qmd"`).
        """
        contents = sidebar_config.get("contents", [])
        self._rewrite_href_recursive(contents, blended_first, "index.qmd")

    def _rewrite_href_recursive(self, items: list, old_href: str, new_href: str) -> bool:
        """Recursively find and replace the first matching href in sidebar items."""
        for item in items:
            if isinstance(item, dict):
                if item.get("href") == old_href:
                    item["href"] = new_href
                    return True
                # Check nested section contents
                nested = item.get("contents", [])
                if nested and self._rewrite_href_recursive(nested, old_href, new_href):
                    return True
            elif isinstance(item, str) and item == old_href:
                # Bare string entry — replace in parent list
                idx = items.index(item)
                items[idx] = {"text": "Home", "href": new_href}
                return True
        return False

    def _update_config_with_user_guide(self, user_guide_info: dict) -> None:
        """
        Update _quarto.yml with user guide sidebar and navbar.

        Parameters
        ----------
        user_guide_info
            User guide structure from _discover_user_guide.
        """
        quarto_yml = self.project_path / "_quarto.yml"
        if not quarto_yml.exists():
            return

        with open(quarto_yml, "r") as f:
            config = read_yaml(f) or {}

        if "website" not in config:
            config["website"] = {}

        # Generate and add/update user guide sidebar
        sidebar_config = self._generate_user_guide_sidebar(user_guide_info)

        # In blended homepage mode, rewrite the first sidebar entry to point
        # to index.qmd (the promoted first UG page) instead of user-guide/...
        blended_first = getattr(self, "_blended_first_page", None)
        if self._config.homepage == "user_guide" and blended_first:
            self._rewrite_sidebar_first_entry(sidebar_config, blended_first)  # pragma: no cover

        if "sidebar" not in config["website"]:
            config["website"]["sidebar"] = []

        sidebar = config["website"]["sidebar"]

        # Remove existing user-guide sidebar if present
        sidebar = [s for s in sidebar if not (isinstance(s, dict) and s.get("id") == "user-guide")]

        # Add the new user guide sidebar
        sidebar.append(sidebar_config)
        config["website"]["sidebar"] = sidebar

        # Update navbar to include User Guide link (skip in blended homepage mode)
        if self._config.homepage != "user_guide" and "navbar" in config["website"]:
            navbar = config["website"]["navbar"]
            if "left" in navbar:
                # Check if User Guide link already exists
                has_user_guide = any(
                    isinstance(item, dict) and item.get("text") == "User Guide"
                    for item in navbar["left"]
                )
                if not has_user_guide:
                    # Find the position before "Reference"
                    insert_idx = 0  # Default: beginning of navbar
                    for i, item in enumerate(navbar["left"]):
                        if isinstance(item, dict) and item.get("text") == "Reference":
                            insert_idx = i
                            break

                    # Determine the href for User Guide (with clean filename)
                    if user_guide_info.get("has_index"):
                        user_guide_href = "user-guide/index.qmd"  # pragma: no cover
                    else:
                        # Use the first file with clean filename
                        first_file = user_guide_info["files"][0]
                        rel_path = first_file["path"].relative_to(user_guide_info["source_dir"])
                        clean_filename = self._strip_numeric_prefix(rel_path.name)
                        clean_rel_path = rel_path.parent / clean_filename
                        user_guide_href = f"user-guide/{clean_rel_path}"

                    navbar["left"].insert(
                        insert_idx,
                        {
                            "text": "User Guide",
                            "href": user_guide_href,
                        },
                    )

        self._write_quarto_yml(quarto_yml, config)

    def _process_user_guide(self) -> bool:
        """
        Process user guide content: discover, copy, and update configuration.

        Returns
        -------
        bool
            True if user guide was processed, False otherwise.
        """
        # Discover user guide
        user_guide_info = self._discover_user_guide()
        if not user_guide_info:
            # Fallback: if homepage mode is "user_guide" but no UG pages exist,
            # warn and create a normal index from README instead.
            if self._config.homepage == "user_guide":
                print(
                    "   ⚠️  homepage: user_guide is set but no user guide pages found; "
                    "falling back to index mode"
                )
                self._homepage_fallback_to_index = True
                self._create_index_from_readme(force_rebuild=True)
                self._homepage_fallback_to_index = False
            return False

        print("\n📖 Processing User Guide...")

        is_explicit = user_guide_info.get("explicit", False)
        if is_explicit:
            print("   Using explicit section ordering from great-docs.yml")  # pragma: no cover

        print(f"   Found {len(user_guide_info['files'])} page(s)")

        # Copy files to docs directory
        copied_files = self._copy_user_guide_to_docs(user_guide_info)
        print(f"   Copied {len(copied_files)} file(s) to docs/user-guide/")

        # In "user_guide" homepage mode, promote the first UG page to index.qmd
        if self._config.homepage == "user_guide":
            self._create_blended_index(user_guide_info, copied_files)  # pragma: no cover

        # Update configuration
        self._update_config_with_user_guide(user_guide_info)

        # Report sections
        if user_guide_info["sections"]:
            section_names = list(user_guide_info["sections"].keys())
            print(f"   Sections: {', '.join(section_names)}")

        print("✅ User Guide configured")
        return True

    def _create_blended_index(self, user_guide_info: dict, copied_files: list[str]) -> None:
        """
        Create `index.qmd` from the first user-guide page (blended homepage mode).

        The first UG page becomes the site root `index.qmd` with the project
        metadata sidebar appended in the right margin.  The duplicate file
        under `user-guide/` is removed.

        Parameters
        ----------
        user_guide_info
            User guide structure from `_discover_user_guide()`.
        copied_files
            List of copied file paths relative to the docs dir (e.g.
            `["user-guide/introduction.qmd", ...]`).
        """
        if not user_guide_info["files"]:
            return

        first_file = user_guide_info["files"][0]
        is_explicit = user_guide_info.get("explicit", False)

        # Determine the filename of the first page in user-guide/
        source_dir = user_guide_info["source_dir"]
        try:
            rel_path = first_file["path"].relative_to(source_dir)
        except ValueError:  # pragma: no cover
            rel_path = Path(first_file["path"].name)  # pragma: no cover

        if is_explicit:
            dest_rel = rel_path
        else:
            clean_parts = [self._strip_numeric_prefix(part) for part in rel_path.parts]
            dest_rel = Path(*clean_parts) if clean_parts else rel_path

        first_ug_path = self.project_path / "user-guide" / dest_rel

        if not first_ug_path.exists():
            print(f"   ⚠️  First UG page '{first_ug_path}' not found for blended homepage")
            return

        # Read the first UG file content
        with open(first_ug_path, "r", encoding="utf-8") as f:
            content = f.read()

        # Inject toc: false into frontmatter (bread-crumbs: false is already set
        # by _copy_user_guide_to_docs). Keep the original frontmatter title so
        # Quarto renders a proper title-block header matching other UG pages.
        content = self._add_frontmatter_option(content, "toc", False)

        # Build the metadata margin sidebar
        margin_content = self._build_metadata_margin()

        # Build hero section for blended homepage
        hero_html, _ = self._build_hero_section()

        # Split content into frontmatter and body. Headings are NOT bumped
        # here, unlike _create_index_from_readme, because UG pages already
        # have a frontmatter title. Quarto absorbs the first body `#`
        # heading (matching the title) and `shift-heading-level-by: -1`
        # promotes the remaining `##` → `<h1>`, `###` → `<h2>`, etc.
        # — exactly the same as every other UG page.
        if content.startswith("---"):
            parts = content.split("---", 2)
            if len(parts) >= 3:
                frontmatter_block = f"---{parts[1]}---\n"
                body = parts[2].lstrip("\n")
            else:
                frontmatter_block = ""  # pragma: no cover
                body = content  # pragma: no cover
        else:
            frontmatter_block = ""  # pragma: no cover
            body = content  # pragma: no cover

        # Strip the first `# …` heading from the body. The frontmatter
        # `title` already provides the title-block heading; keeping the
        # body `#` would render as a duplicate paragraph because
        # `shift-heading-level-by: -1` demotes it below heading level.
        import re

        body = re.sub(r"^#\s+[^\n]*\n?", "", body, count=1, flags=re.MULTILINE)

        # Build hero block for insertion
        hero_block = ""
        if hero_html:
            hero_block = f"""```{{=html}}  # pragma: no cover
{hero_html}```

"""

        # Reassemble with the metadata margin sidebar placed AFTER the body.
        # On desktop `.gd-meta-sidebar` is absolutely positioned into the right
        # margin (see great-docs.scss), so its position in the DOM is
        # irrelevant there. On mobile it falls back into normal document flow;
        # emitting it after the body keeps the hero/content first rather than
        # pushing it below the metadata panel (links/developers/etc.). See
        # https://github.com/posit-dev/great-docs/issues/220.
        if margin_content:
            blended_content = f"{frontmatter_block}\n{hero_block}{body}\n\n::: {{.gd-meta-sidebar}}\n{margin_content}\n:::\n"
        else:
            blended_content = f"{frontmatter_block}\n{hero_block}{body}"

        # Write to index.qmd at the site root
        index_qmd = self.project_path / "index.qmd"
        with open(index_qmd, "w", encoding="utf-8") as f:
            f.write(blended_content)

        # Remove the duplicate from user-guide/
        first_ug_path.unlink()

        # Track the first file's destination path for sidebar adjustment
        self._blended_first_page = f"user-guide/{dest_rel}"

        print("   📄 Blended homepage: first UG page → index.qmd")

    def _get_source_location(self, package_name: str, item_name: str) -> dict | None:
        """
        Get source file and line numbers for a class, method, or function.

        Uses griffe for static analysis to avoid import side effects.
        Leverages the per-build griffe cache to avoid redundant package loads.

        Parameters
        ----------
        package_name
            The name of the package containing the item.
        item_name
            The fully qualified name of the item (e.g., "ClassName" or "ClassName.method_name").

        Returns
        -------
        dict | None
            Dictionary with file path and line numbers, or None if not found.
        """
        try:
            normalized_name = package_name.replace("-", "_")
            pkg = self._get_griffe_package(normalized_name)
            return self._get_source_location_from_pkg(pkg, item_name)
        except Exception:
            return None

    def _build_github_source_url(
        self, source_location: dict, branch: str | None = None
    ) -> str | None:
        """
        Build a GitHub URL for viewing source code at specific line numbers.

        Parameters
        ----------
        source_location
            Dictionary with file path and line numbers.
        branch
            Git branch/tag to link to. If None, attempts to detect from git
            or falls back to 'main'.

        Returns
        -------
        str | None
            Full GitHub URL with line anchors, or None if repo info not available.
        """
        owner, repo, base_url = self._get_github_repo_info()

        if not base_url:
            return None

        # Determine the branch/ref to use
        if branch is None:
            branch = self._detect_git_ref()  # pragma: no cover

        # Get the file path relative to the repository root
        filepath = source_location.get("file", "")
        package_root = self._find_package_root()

        # Handle source path configuration for monorepos
        metadata = self._get_package_metadata()
        source_path = metadata.get("source_link_path")

        if source_path:
            # Custom source path specified (for monorepos)
            relative_path = f"{source_path}/{Path(filepath).name}"
        else:
            # Try to make the path relative to package root
            try:
                filepath_obj = Path(filepath)
                if filepath_obj.is_absolute():
                    relative_path = str(filepath_obj.relative_to(package_root))
                else:
                    relative_path = filepath
            except ValueError:
                # Path is not relative to package root, use as-is
                relative_path = filepath

        # Build the URL with line number anchors
        start_line = source_location.get("start_line", 1)
        end_line = source_location.get("end_line", start_line)

        url = f"{base_url}/blob/{branch}/{relative_path}"

        if start_line == end_line:
            url += f"#L{start_line}"
        else:
            url += f"#L{start_line}-L{end_line}"

        return url

    def _detect_git_ref(self) -> str:
        """
        Detect the current git branch or tag.

        Returns
        -------
        str
            The current branch/tag name, or 'main' as fallback.
        """
        import subprocess

        package_root = self._find_package_root()

        # First check if there's a configured branch in metadata
        metadata = self._get_package_metadata()
        configured_branch = metadata.get("source_link_branch")
        if configured_branch:
            return configured_branch

        try:
            # Try to get the current tag first (for versioned docs)
            result = subprocess.run(
                ["git", "describe", "--tags", "--exact-match"],
                cwd=package_root,
                capture_output=True,
                **TEXT_MODE_KWARGS,
            )
            if result.returncode == 0:
                return result.stdout.strip()

            # Fall back to branch name
            result = subprocess.run(
                ["git", "rev-parse", "--abbrev-ref", "HEAD"],
                cwd=package_root,
                capture_output=True,
                **TEXT_MODE_KWARGS,
            )
            if result.returncode == 0:
                branch = result.stdout.strip()
                if branch != "HEAD":
                    return branch

        except Exception:
            pass

        # Default fallback
        return "main"

    def _generate_source_links_json(self, package_name: str) -> None:
        """
        Generate a JSON file mapping object names to their GitHub source URLs.

        This file is used by the post-render script to inject source links
        into the HTML documentation.

        Parameters
        ----------
        package_name
            The name of the package to generate source links for.
        """
        import json

        metadata = self._get_package_metadata()

        # Check if source links are enabled
        if not metadata.get("source_link_enabled", True):
            print("Source links disabled in configuration")
            return

        # Check if we have GitHub repo info
        owner, repo, base_url = self._get_github_repo_info()
        if not base_url:
            print("No GitHub repository URL found, skipping source links")
            return

        print(f"Generating source links for {package_name}...")

        source_links: dict[str, dict] = {}
        normalized_name = package_name.replace("-", "_")

        # Get all exports
        exports = self._get_package_exports(package_name)
        if not exports:
            return

        # Get branch for source links
        branch = self._detect_git_ref()
        print(f"Using git ref: {branch}")

        # Pre-warm the griffe cache so _get_source_location doesn't reload per-item
        try:
            pkg = self._get_griffe_package(normalized_name)
        except Exception:
            pkg = None

        # Derive class membership directly from the griffe tree — avoids the
        # expensive _categorize_api_objects() path which calls get_object() per
        # method (dynamic imports, ~100-200ms each).
        _INIT_DUNDERS = {"__init__", "__new__", "__init_subclass__"}
        class_method_names: dict[str, list[str]] = {}
        if pkg is not None:
            for item_name in exports:
                if item_name not in pkg.members:
                    continue
                obj = pkg.members[item_name]
                try:
                    if obj.kind.value != "class":
                        continue
                except Exception:
                    continue
                methods = []
                try:
                    for member_name, member in obj.members.items():
                        if member_name.startswith("__") and member_name.endswith("__"):
                            if member_name in _INIT_DUNDERS:
                                continue
                        elif member_name.startswith("_"):
                            continue
                        try:
                            if member.kind.value in ("function", "method", "attribute"):
                                methods.append(member_name)
                        except Exception:
                            continue
                except Exception:
                    continue
                if methods:
                    class_method_names[item_name] = methods
                    print(f"{item_name}: class with {len(methods)} public methods")

        # Hoist invariants for _build_github_source_url out of the loop
        package_root = self._find_package_root()
        metadata = self._get_package_metadata()
        source_path = metadata.get("source_link_path")

        def _make_github_url(source_loc: dict) -> str | None:
            filepath = source_loc.get("file", "")
            if source_path:
                relative_path = f"{source_path}/{Path(filepath).name}"
            else:
                try:
                    filepath_obj = Path(filepath)
                    if filepath_obj.is_absolute():
                        relative_path = str(filepath_obj.relative_to(package_root))
                    else:
                        relative_path = filepath
                except ValueError:
                    relative_path = filepath

            start_line = source_loc.get("start_line", 1)
            end_line = source_loc.get("end_line", start_line)
            url = f"{base_url}/blob/{branch}/{relative_path}"
            if start_line == end_line:
                url += f"#L{start_line}"
            else:
                url += f"#L{start_line}-L{end_line}"
            return url

        # Generate source links for each export (uses cached griffe pkg internally)
        for item_name in exports:
            source_loc = self._get_source_location(normalized_name, item_name)
            if source_loc:
                github_url = _make_github_url(source_loc)
                if github_url:
                    source_links[item_name] = {
                        "url": github_url,
                        "file": source_loc.get("file", ""),
                        "start_line": source_loc.get("start_line", 0),
                        "end_line": source_loc.get("end_line", 0),
                    }

            # Also get source links for methods of classes
            if item_name in class_method_names:
                for method_name in class_method_names[item_name]:
                    full_name = f"{item_name}.{method_name}"
                    method_loc = self._get_source_location(normalized_name, full_name)
                    if method_loc:
                        method_url = _make_github_url(method_loc)
                        if method_url:
                            source_links[full_name] = {
                                "url": method_url,
                                "file": method_loc.get("file", ""),
                                "start_line": method_loc.get("start_line", 0),
                                "end_line": method_loc.get("end_line", 0),
                            }

        # Write to JSON file in the docs directory
        source_links_path = self.project_path / "_source_links.json"
        with open(source_links_path, "w", encoding="utf-8") as f:
            json.dump(source_links, f, indent=2)

        print(f"Generated source links for {len(source_links)} items")

    def _find_package_init(self, package_name: str) -> Path | None:
        """
        Find the __init__.py file for a package, searching common locations.

        This handles packages with non-standard structures like Rust bindings
        that may have their Python code in subdirectories like python/, src/, etc.

        Parameters
        ----------
        package_name
            The name of the package to find.

        Returns
        -------
        Path | None
            Path to the __init__.py file, or None if not found.
        """
        # Normalize package name (replace dashes with underscores)
        normalized_name = package_name.replace("-", "_")

        # First, try to get explicit package info from pyproject.toml
        package_root = self._find_package_root()
        pyproject_path = package_root / "pyproject.toml"
        explicit_packages = []

        if pyproject_path.exists():
            import tomllib

            try:
                with open(pyproject_path, "rb") as f:
                    data = tomllib.load(f)

                # Check [tool.setuptools.packages] for explicit package list
                setuptools = data.get("tool", {}).get("setuptools", {})
                if "packages" in setuptools:
                    explicit_packages = setuptools["packages"]
                elif "packages" in setuptools.get("find", {}):  # pragma: no cover
                    # [tool.setuptools.packages.find] with where
                    where = setuptools.get("find", {}).get("where", ["."])
                    if isinstance(where, str):
                        where = [where]
                    for base_dir in where:
                        base_path = package_root / base_dir
                        if base_path.exists():
                            # Look for packages in this directory
                            for item in base_path.iterdir():
                                if item.is_dir() and (item / "__init__.py").exists():
                                    if not item.name.startswith((".", "_", "test")):
                                        explicit_packages.append(item.name)

                # Check [tool.hatch.build.targets.wheel] for hatch projects
                hatch_packages = (
                    data.get("tool", {})
                    .get("hatch", {})
                    .get("build", {})
                    .get("targets", {})
                    .get("wheel", {})
                    .get("packages", [])
                )
                if hatch_packages:
                    explicit_packages.extend(hatch_packages)

            except Exception:  # pragma: no cover
                pass

        # Build search paths, prioritizing explicit packages from pyproject.toml
        search_paths = []

        # Add explicit packages first (from pyproject.toml config)
        for pkg in explicit_packages:
            # Handle "src/package" style paths
            if "/" in pkg:
                search_paths.append(package_root / pkg)
            else:
                search_paths.append(package_root / pkg)
                search_paths.append(package_root / "src" / pkg)

        # Handle dotted module names (namespace packages like "firebird.base")
        # by converting dots to path separators
        dotted_path = None
        if "." in package_name:
            dotted_path = package_name.replace(".", os.sep)
            search_paths.extend(
                [
                    package_root / dotted_path,
                    package_root / "src" / dotted_path,
                    package_root / "python" / dotted_path,
                    package_root / "lib" / dotted_path,
                ]
            )

        # Then add standard locations based on package name
        search_paths.extend(
            [
                package_root / package_name,
                package_root / normalized_name,
                package_root / "python" / package_name,
                package_root / "python" / normalized_name,
                package_root / "src" / package_name,
                package_root / "src" / normalized_name,
                package_root / "lib" / package_name,
                package_root / "lib" / normalized_name,
            ]
        )

        # First pass: look for __init__.py with __version__ or __all__
        for package_dir in search_paths:
            if not package_dir.exists() or not package_dir.is_dir():
                continue

            init_file = package_dir / "__init__.py"
            if init_file.exists():
                try:
                    with open(init_file, "r", encoding="utf-8") as f:
                        content = f.read()
                        if "__version__" in content or "__all__" in content:
                            return init_file
                except Exception:  # pragma: no cover
                    continue

        # Second pass: accept any __init__.py in a matching directory
        for package_dir in search_paths:
            if not package_dir.exists() or not package_dir.is_dir():
                continue

            init_file = package_dir / "__init__.py"
            if init_file.exists():
                return init_file

        # Third pass: auto-discover any Python package in common locations
        # This handles cases where the package name doesn't match the project name
        auto_discover_dirs = [
            package_root,
            package_root / "src",
            package_root / "python",
            package_root / "lib",
        ]

        for base_dir in auto_discover_dirs:
            if not base_dir.exists():
                continue
            for item in base_dir.iterdir():
                if not item.is_dir():
                    continue
                # Skip common non-package directories
                if item.name.startswith((".", "_")) or item.name in (
                    "tests",
                    "test",
                    "docs",
                    "doc",
                    "examples",
                    "scripts",
                    "build",
                    "dist",
                    "__pycache__",
                    "venv",
                    ".venv",
                    "node_modules",
                    "site-packages",
                ):
                    continue
                init_file = item / "__init__.py"
                if init_file.exists():
                    return init_file

        return None

    def _parse_package_exports(self, package_name: str) -> list | None:
        """
        Parse __all__ from package's __init__.py to get public API.

        Also checks for exclude in great-docs.yml to filter out
        non-documentable items.

        Parameters
        ----------
        package_name
            The name of the package to parse.

        Returns
        -------
        list | None
            List of public names from __all__ (filtered by exclusions), or None if not found.
        """
        # Find the package's __init__.py file
        init_file = self._find_package_init(package_name)
        if not init_file:
            # Show both the original and normalized name for clarity
            normalized = package_name.replace("-", "_")
            if normalized != package_name:
                print(
                    f"Could not locate __init__.py for package '{package_name}' (module name: '{normalized}')",
                    file=sys.stderr,
                )
                print(
                    f"Tip: Ensure a '{normalized}/' directory exists with an __init__.py file",
                    file=sys.stderr,
                )
            else:
                print(
                    f"Could not locate __init__.py for package '{package_name}'",
                    file=sys.stderr,
                )
            return None

        print(f"Found package __init__.py at: {init_file.relative_to(self.project_root)}")

        # Get exclusions from great-docs.yml
        metadata = self._get_package_metadata()
        config_exclude = metadata.get("exclude", [])

        try:
            with open(init_file, "r", encoding="utf-8") as f:
                content = f.read()

            # Try to extract __all__ using AST (safer than eval)
            import ast

            tree = ast.parse(content)

            all_exports = None

            for node in ast.walk(tree):
                if isinstance(node, ast.Assign):
                    for target in node.targets:
                        # Extract __all__
                        if isinstance(target, ast.Name) and target.id == "__all__":
                            if isinstance(node.value, ast.List):
                                all_exports = []
                                for elt in node.value.elts:
                                    if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
                                        all_exports.append(elt.value)

            if all_exports:
                print(f"Successfully parsed __all__ with {len(all_exports)} exports")

                # Filter out excluded items from great-docs.yml
                if config_exclude:
                    filtered = [e for e in all_exports if e not in config_exclude]
                    excluded_count = len(all_exports) - len(filtered)
                    if excluded_count > 0:
                        print(
                            f"Filtered out {excluded_count} item(s) from great-docs.yml exclude: {', '.join(config_exclude)}"
                        )
                    return filtered
                else:
                    return all_exports

            print("No __all__ definition found in __init__.py")
            return None
        except Exception as e:  # pragma: no cover
            print(f"Error parsing __all__: {type(e).__name__}: {e}")
            return None

    # Auto-excluded names that are typically not meant for documentation
    # These are common internal/utility exports that most packages don't want documented
    AUTO_EXCLUDE = {
        # CLI and entry points
        "main",  # CLI entry point function
        "cli",  # CLI module
        # Version and metadata
        "version",  # Version string/function
        "VERSION",  # Uppercase version constant
        "VERSION_INFO",  # Version info tuple
        # Common module re-exports
        "core",  # Core module
        "utils",  # Utilities module
        "helpers",  # Helpers module
        "constants",  # Constants module
        "config",  # Config module
        "settings",  # Settings module
        # Standard library re-exports
        "PackageNotFoundError",  # importlib.metadata exception
        "typing",  # typing module re-export
        "annotations",  # annotations module re-export
        "TYPE_CHECKING",  # typing.TYPE_CHECKING constant
        # Logging
        "logger",  # Module-level logger instance
        "log",  # Alternative logger name
        "logging",  # logging module re-export
    }

    def _discover_package_exports(self, package_name: str) -> list | None:
        """
        Discover public API objects using griffe introspection.

        This method uses griffe (the project's introspection module) to statically analyze the
        package and discover all public objects by filtering out private/internal names (those
        starting with underscore).

        Auto-excludes common internal names (see `AUTO_EXCLUDE`). Additional items can be
        excluded via the `exclude` option in `great-docs.yml`.

        Parameters
        ----------
        package_name
            The name of the package to discover exports from.

        Returns
        -------
        list | None
            List of public names discovered (filtered by exclusions), or `None` if discovery failed.
        """
        try:
            import griffe

            # Normalize package name (replace dashes with underscores)
            normalized_name = package_name.replace("-", "_")

            # Load the package using the per-build griffe cache
            try:
                pkg = self._get_griffe_package(normalized_name)
            except Exception as e:
                print(
                    f"Warning: Could not load package with griffe ({type(e).__name__})",
                    file=sys.stderr,
                )
                if package_name != normalized_name:
                    print(
                        f"   (Looking for module '{normalized_name}' from project '{package_name}')",
                        file=sys.stderr,
                    )
                return None

            # Get all members from the package (equivalent to dir(package))
            all_members = list(pkg.members.keys())

            # If the package defines __all__, restrict to those names
            if pkg.exports:
                public_members = [name for name in all_members if name in set(pkg.exports)]
                print(f"Using __all__ with {len(public_members)} exports")
                # If __all__ lists names that griffe couldn't resolve as actual
                # members (e.g., lazy imports, re-exports), fall back to AST
                # parsing so the names are still discovered.
                if not public_members and pkg.exports:
                    return None
            else:
                # Filter out private names (starting with underscore)
                # This also filters out dunder names like __version__, __all__, etc.
                public_members = [name for name in all_members if not name.startswith("_")]
                print(f"Discovered {len(public_members)} public names")

            # Get config from great-docs.yml
            metadata = self._get_package_metadata()
            config_exclude = set(metadata.get("exclude", []))
            auto_include = set(metadata.get("auto_include", []))
            no_auto_exclude = metadata.get("no_auto_exclude", False)

            # Determine effective auto-exclude set
            if no_auto_exclude:
                effective_auto_exclude: set[str] = set()
                print("Auto-exclude list bypassed (no_auto_exclude: true)")
            else:
                effective_auto_exclude = self.AUTO_EXCLUDE - auto_include
                if auto_include:
                    forced = auto_include & self.AUTO_EXCLUDE
                    if forced:
                        print(
                            f"Force-including {len(forced)} auto-excluded name(s): "
                            f"{', '.join(sorted(forced))}"
                        )

            # Apply auto-exclusions
            auto_excluded_found = [
                name for name in public_members if name in effective_auto_exclude
            ]
            if auto_excluded_found:
                print(
                    f"Auto-excluding {len(auto_excluded_found)} item(s): "
                    f"{', '.join(sorted(auto_excluded_found))}"
                )

            # Combine all exclusions (auto + user-specified)
            all_exclude = effective_auto_exclude | config_exclude

            # Filter out excluded items
            filtered = [name for name in public_members if name not in all_exclude]

            # Report user-specified exclusions separately
            if config_exclude:
                user_excluded_found = [
                    name
                    for name in public_members
                    if name in config_exclude and name not in effective_auto_exclude
                ]
                if user_excluded_found:
                    print(
                        f"Filtered out {len(user_excluded_found)} item(s) from great-docs.yml exclude: "
                        f"{', '.join(sorted(user_excluded_found))}"
                    )

            # Validate exports using the already-loaded griffe tree.
            # This catches cyclic/unresolvable aliases, submodules, Rust/PyO3
            # objects, and external re-exports without the cost of per-item
            # dynamic imports (which dominated build time for large packages).
            safe_exports = []
            failed_exports = {}

            for name in filtered:
                if name not in pkg.members:
                    failed_exports[name] = "not found"
                    continue

                obj = pkg.members[name]

                try:
                    kind_val = obj.kind.value
                except griffe.CyclicAliasError:
                    failed_exports[name] = "cyclic alias"
                    continue
                except griffe.AliasResolutionError:
                    failed_exports[name] = "unresolvable alias"
                    continue
                except KeyError:
                    failed_exports[name] = "not found (likely Rust/PyO3)"
                    continue
                except Exception as e:
                    failed_exports[name] = f"{type(e).__name__}"
                    continue

                if kind_val == "module":
                    safe_exports.append(name)
                    continue

                try:
                    _ = obj.members
                except griffe.CyclicAliasError:
                    failed_exports[name] = "cyclic alias"
                    continue
                except griffe.AliasResolutionError:
                    failed_exports[name] = "unresolvable alias"
                    continue
                except Exception as e:
                    failed_exports[name] = f"{type(e).__name__}"
                    continue

                if hasattr(obj, "is_alias") and obj.is_alias:
                    try:
                        canon = obj.canonical_path
                        if not canon.startswith(normalized_name + "."):
                            failed_exports[name] = "external re-export"
                            continue
                    except Exception:
                        pass

                safe_exports.append(name)

            # Report excluded items grouped by error type
            if failed_exports:
                # Group by error type for cleaner output
                by_error = {}
                for name, error in failed_exports.items():
                    by_error.setdefault(error, []).append(name)

                for error_type, names in sorted(by_error.items()):
                    print(
                        f"Excluding {len(names)} object(s) ({error_type}): "
                        f"{', '.join(sorted(names))}"
                    )

            return safe_exports

        except ImportError:  # pragma: no cover
            print("Warning: griffe not available, cannot use dir() discovery")  # pragma: no cover
            return None  # pragma: no cover
        except Exception as e:  # pragma: no cover
            print(
                f"Error discovering exports via dir(): {type(e).__name__}: {e}"
            )  # pragma: no cover
            return None  # pragma: no cover

    def _detect_docstring_style(self, package_name: str) -> str:
        """
        Detect the docstring style used in a package.

        Analyzes docstrings from the package to determine if they use NumPy, Google,
        or Sphinx style formatting. The detection is based on characteristic patterns:

        - NumPy style: Uses `---` underlines under section headers (Parameters, Returns, etc.)
        - Google style: Uses section headers with colons but NO underlines (Args:, Returns:)
        - Sphinx style: Uses `:param:`, `:returns:`, etc. field markers

        Parameters
        ----------
        package_name
            The name of the package to analyze.

        Returns
        -------
        str
            The detected docstring style: "numpy", "google", or "sphinx".
            Defaults to "numpy" if detection is inconclusive.
        """
        try:
            import griffe

            # Normalize package name
            normalized_name = package_name.replace("-", "_")

            # Load the package using the per-build griffe cache
            try:
                pkg = self._get_griffe_package(normalized_name)
            except Exception as e:
                print(
                    f"Warning: Could not load package for docstring detection ({type(e).__name__})"
                )
                return "numpy"

            # Collect docstrings from the package
            docstrings = []

            def collect_docstrings(obj, depth=0):
                """Recursively collect docstrings from an object and its members."""
                if depth > 2:  # Limit recursion depth
                    return  # pragma: no cover

                # Get the object's docstring
                if hasattr(obj, "docstring") and obj.docstring:
                    docstrings.append(obj.docstring.value)

                # Recurse into members
                if hasattr(obj, "members"):
                    for member in obj.members.values():
                        try:
                            # Skip aliases to avoid infinite loops
                            if hasattr(member, "is_alias") and member.is_alias:
                                continue  # pragma: no cover
                            collect_docstrings(member, depth + 1)
                        except Exception:  # pragma: no cover
                            continue

            collect_docstrings(pkg)

            if not docstrings:
                print("No docstrings found, defaulting to numpy style")
                return "numpy"

            # Analyze docstrings for style indicators
            numpy_indicators = 0
            google_indicators = 0
            sphinx_indicators = 0

            # Patterns for detection
            # NumPy: section headers followed by dashes (e.g., "Parameters\n----------")
            numpy_section_pattern = re.compile(
                r"^\s*(Parameters|Returns|Yields|Raises|Examples|Attributes|Methods|See Also|Notes|References|Warnings)\s*\n\s*-{3,}",
                re.MULTILINE,
            )

            # Google: section headers with colons (e.g., "Args:", "Returns:")
            google_section_pattern = re.compile(
                r"^\s*(Args|Arguments|Returns|Yields|Raises|Examples|Attributes|Note|Notes|Todo|Warning|Warnings):\s*$",
                re.MULTILINE,
            )

            # Sphinx: field markers (e.g., ":param name:", ":returns:")
            sphinx_pattern = re.compile(
                r"^\s*:(param|type|returns|rtype|raises|var|ivar|cvar)\s",
                re.MULTILINE,
            )

            # Example blocks with >>> are common in both NumPy and Google styles
            # but the presence/absence of --- is the key differentiator

            for docstring in docstrings:
                if not docstring:
                    continue  # pragma: no cover

                # Check for NumPy style (section + dashes)
                if numpy_section_pattern.search(docstring):
                    numpy_indicators += 1

                # Check for Google style (section headers with colons, no dashes)
                if google_section_pattern.search(docstring):
                    # Only count as Google if there are NO numpy-style dashes nearby
                    if not numpy_section_pattern.search(docstring):
                        google_indicators += 1

                # Check for Sphinx style
                if sphinx_pattern.search(docstring):
                    sphinx_indicators += 1

            # Determine the winner
            total_indicators = numpy_indicators + google_indicators + sphinx_indicators

            if total_indicators == 0:
                print("No clear docstring style detected, defaulting to numpy")
                return "numpy"

            # Report findings
            print(
                f"Docstring style detection: numpy={numpy_indicators}, "
                f"google={google_indicators}, sphinx={sphinx_indicators}"
            )

            # Return the style with most indicators
            if sphinx_indicators > numpy_indicators and sphinx_indicators > google_indicators:
                print("Detected sphinx docstring style")
                return "sphinx"
            elif google_indicators > numpy_indicators:
                print("Detected google docstring style")
                return "google"
            else:
                print("Detected numpy docstring style")
                return "numpy"

        except ImportError:  # pragma: no cover
            print("Warning: griffe not available for docstring detection, defaulting to numpy")
            return "numpy"
        except Exception as e:  # pragma: no cover
            print(f"Error detecting docstring style: {type(e).__name__}: {e}")
            return "numpy"

    def _detect_dynamic_mode(self, package_name: str) -> bool:
        """
        Detect whether dynamic introspection mode works for a package.

        This method tests if dynamic mode works by attempting to load objects
        with the renderer's get_object function in dynamic mode AND accessing their
        members (which is what triggers cyclic alias errors in some packages).

        Parameters
        ----------
        package_name
            The name of the package to test.

        Returns
        -------
        bool
            True if dynamic mode works, False if it causes errors.
        """
        if not package_name or not package_name.strip():
            return True

        try:
            import griffe

            from great_docs._apiref.introspect import get_object as qd_get_object
        except ImportError:  # pragma: no cover
            # If renderer isn't available, default to True (will fail at build time anyway)
            return True  # pragma: no cover

        # Normalize package name
        normalized_name = package_name.replace("-", "_")

        # Get a sample of exports to test
        try:
            pkg = self._get_griffe_package(normalized_name)
            exports = [
                name
                for name in list(pkg.members.keys())[:10]  # Test first 10
                if not name.startswith("_")
            ]
        except Exception:
            # Can't load package, default to True
            return True

        if not exports:
            return True

        # Test dynamic mode with a few exports
        # We need to actually access .members and .kind to trigger cyclic alias errors
        cyclic_errors = 0
        for name in exports[:5]:  # Test up to 5 exports
            try:
                obj = qd_get_object(f"{normalized_name}:{name}", dynamic=True)
                # Access .members to trigger cyclic alias resolution
                # This is what the renderer does when rendering docs
                _ = obj.members
                _ = obj.kind
                # For classes, try to access individual members too
                if hasattr(obj, "members") and obj.members:
                    for member_name, member in list(obj.members.items())[:3]:  # pragma: no cover
                        try:  # pragma: no cover
                            _ = member.kind  # pragma: no cover
                        except griffe.CyclicAliasError:  # pragma: no cover
                            cyclic_errors += 1  # pragma: no cover
                            break  # pragma: no cover
            except griffe.CyclicAliasError:
                cyclic_errors += 1  # pragma: no cover
            except Exception:  # pragma: no cover
                # Other errors don't necessarily mean dynamic mode won't work
                pass  # pragma: no cover

        if cyclic_errors > 0:
            print(f"Detected {cyclic_errors} cyclic alias error(s), using dynamic: false")
            return False
        else:
            print("Dynamic introspection mode works for this package")
            return True

    def _get_package_exports(self, package_name: str) -> list | None:
        """
        Get package exports using static analysis.

        Uses griffe to statically analyze the package and discover public objects.
        Falls back to parsing __all__ if griffe introspection fails.

        Parameters
        ----------
        package_name
            The name of the package to get exports from.

        Returns
        -------
        list | None
            List of exported/public names, or None if discovery failed.
        """
        normalized = package_name.replace("-", "_")
        if self._cached_package_name == normalized and self._cached_exports is not None:
            return self._cached_exports

        exports = self._discover_package_exports(package_name)
        if exports is None:
            print("Falling back to __all__ discovery", file=sys.stderr)
            exports = self._parse_package_exports(package_name)

        self._cached_package_name = normalized
        self._cached_exports = exports
        return exports

    # ── Exception base classes (used for sub-classification) ─────────────
    _EXCEPTION_BASES = frozenset(
        {
            "Exception",
            "BaseException",
            # Built-in error types
            "ArithmeticError",
            "AssertionError",
            "AttributeError",
            "BlockingIOError",
            "BrokenPipeError",
            "BufferError",
            "BytesWarning",
            "ChildProcessError",
            "ConnectionAbortedError",
            "ConnectionError",
            "ConnectionRefusedError",
            "ConnectionResetError",
            "DeprecationWarning",
            "EOFError",
            "EnvironmentError",
            "FileExistsError",
            "FileNotFoundError",
            "FloatingPointError",
            "FutureWarning",
            "GeneratorExit",
            "IOError",
            "ImportError",
            "ImportWarning",
            "IndentationError",
            "IndexError",
            "InterruptedError",
            "IsADirectoryError",
            "KeyError",
            "KeyboardInterrupt",
            "LookupError",
            "MemoryError",
            "ModuleNotFoundError",
            "NameError",
            "NotADirectoryError",
            "NotImplementedError",
            "OSError",
            "OverflowError",
            "PendingDeprecationWarning",
            "PermissionError",
            "ProcessLookupError",
            "RecursionError",
            "ReferenceError",
            "ResourceWarning",
            "RuntimeError",
            "RuntimeWarning",
            "StopAsyncIteration",
            "StopIteration",
            "SyntaxError",
            "SyntaxWarning",
            "SystemError",
            "SystemExit",
            "TabError",
            "TimeoutError",
            "TypeError",
            "UnboundLocalError",
            "UnicodeDecodeError",
            "UnicodeEncodeError",
            "UnicodeError",
            "UnicodeTranslationError",
            "UnicodeWarning",
            "UserWarning",
            "ValueError",
            "Warning",
            "ZeroDivisionError",
        }
    )

    # ── Enum base classes ────────────────────────────────────────────────
    _ENUM_BASES = frozenset(
        {"Enum", "IntEnum", "StrEnum", "Flag", "IntFlag", "ReprEnum", "EnumCheck"}
    )

    @staticmethod
    def _sub_classify_class(obj) -> str:
        """
        Determine the sub-type of a griffe `class` object.

        Returns one of: `dataclass`, `enum`, `exception`,
        `namedtuple`, `typeddict`, `protocol`, `abc`,
        or `class` (the generic fallback).

        Parameters
        ----------
        obj
            A griffe `Class` object (or `Alias` that resolves to one).

        Returns
        -------
        str
            The sub-classification label.
        """
        # --- labels (fast path) ---
        try:
            labels = obj.labels
        except Exception:  # pragma: no cover
            labels = set()  # pragma: no cover

        if "dataclass" in labels:
            return "dataclass"

        # --- bases (may be short names like "Enum" or dotted) ---
        try:
            bases = [str(b) for b in obj.bases]
        except Exception:  # pragma: no cover
            bases = []  # pragma: no cover

        # Normalize base names: "enum.IntEnum" → "IntEnum"
        short_bases = {b.rsplit(".", 1)[-1] for b in bases}

        if short_bases & GreatDocs._ENUM_BASES:
            return "enum"
        if short_bases & GreatDocs._EXCEPTION_BASES:
            return "exception"
        if "NamedTuple" in short_bases:
            return "namedtuple"
        if "TypedDict" in short_bases:
            return "typeddict"
        if "Protocol" in short_bases or "runtime_checkable" in short_bases:
            return "protocol"
        if short_bases & {"ABC", "ABCMeta"}:
            return "abc"

        # --- decorators (secondary check for ABC) ---
        try:
            decorators = [str(d.value) for d in obj.decorators]
        except Exception:  # pragma: no cover
            decorators = []  # pragma: no cover

        if any("abstractmethod" in d for d in decorators):
            return "abc"

        return "class"

    @staticmethod
    def _sub_classify_function(obj) -> str:
        """
        Determine the sub-type of a griffe `function` object.

        Returns one of: `async`, `classmethod`, `staticmethod`,
        `property`, or `function` (the generic fallback).

        Parameters
        ----------
        obj
            A griffe `Function` object (or `Alias` that resolves to one).

        Returns
        -------
        str
            The sub-classification label.
        """
        try:
            labels = obj.labels
        except Exception:
            labels = set()

        if "async" in labels:
            return "async"
        if "classmethod" in labels:
            return "classmethod"
        if "staticmethod" in labels:
            return "staticmethod"
        if "property" in labels:
            return "property"

        return "function"

    @staticmethod
    def _sub_classify_attribute(obj) -> str:
        """
        Determine the sub-type of a griffe `attribute` object.

        Returns one of: `type_alias`, `typevar`, or `constant`.

        Parameters
        ----------
        obj
            A griffe `Attribute` object (or `Alias` that resolves to one).

        Returns
        -------
        str
            The sub-classification label.
        """
        try:
            labels = obj.labels
        except Exception:  # pragma: no cover
            labels = set()  # pragma: no cover

        # griffe gives PEP 695 type aliases a dedicated "type alias" kind.
        # Reading `.kind` can raise for an unresolved alias, hence the guard.
        try:
            if obj.kind.value == "type alias":
                return "type_alias"
        except Exception:
            pass

        # Check annotation for TypeVar / ParamSpec / TypeVarTuple
        try:
            annotation = str(obj.annotation) if obj.annotation else ""
        except Exception:  # pragma: no cover
            annotation = ""  # pragma: no cover

        if "TypeVar" in annotation or "ParamSpec" in annotation or "TypeVarTuple" in annotation:
            return "typevar"

        # Heuristic: assignment like `MyAlias = list[int]` with no annotation
        # is often a type alias, but we can't reliably distinguish from a
        # constant without more context. Default to constant.
        return "constant"

    @staticmethod
    def _extract_constant_metadata(obj, name: str, categories: dict) -> None:
        """
        Extract value and type annotation from a griffe constant/attribute.

        Populates `categories["constant_metadata"][name]` with `"value"`
        and `"annotation"` strings when they are available and simple enough
        to display.

        Parameters
        ----------
        obj
            A griffe `Attribute` (or `Alias`) object.
        name
            The documented name of the constant.
        categories
            The mutable categories dict being built.
        """
        meta: dict[str, str] = {}
        try:
            if obj.value is not None:
                val = str(obj.value)
                # Only store "simple" values (literals, short collections).
                # Avoid huge reprs that would clutter the page.
                if len(val) <= 200:
                    meta["value"] = val
        except Exception:
            pass
        try:
            if obj.annotation is not None:
                meta["annotation"] = str(obj.annotation)
        except Exception:
            pass
        if meta:
            categories["constant_metadata"][name] = meta

    @staticmethod
    def _empty_categories() -> dict:
        """Return a fresh, empty categories dict with all keys."""
        return {
            # ── Class-like ──
            "classes": [],
            "dataclasses": [],
            "enums": [],
            "exceptions": [],
            "namedtuples": [],
            "typeddicts": [],
            "protocols": [],
            "abstract_classes": [],
            # ── Function-like ──
            "functions": [],
            "async_functions": [],
            # ── Data ──
            "constants": [],
            "type_aliases": [],
            # ── Catch-all ──
            "other": [],
            # ── Metadata ──
            "class_methods": {},
            "class_method_names": {},
            "class_member_types": {},
            "constant_metadata": {},
            "cyclic_alias_count": 0,
            # ── Convenience unions ──
            "all_classes": [],
            "all_functions": [],
        }

    def _write_object_types_json(self, categories: dict) -> None:
        """
        Write `_object_types.json` mapping each documented name to its type.

        The post-render script reads this file instead of relying on heuristics
        (e.g. first-character case) to classify objects for badges and
        parentheses.

        Parameters
        ----------
        categories
            Dictionary returned by `_categorize_api_objects`.
        """
        # Read-only callers (e.g. `documented_symbol_names`) must not drop
        # build artifacts into the project root.
        if self._suppress_artifact_writes:
            return

        import json

        # Map each category key to the type label used by post-render
        _CATEGORY_TYPE_MAP = {
            "classes": "class",
            "dataclasses": "class",
            "enums": "enum",
            "exceptions": "exception",
            "namedtuples": "namedtuple",
            "typeddicts": "typeddict",
            "protocols": "protocol",
            "abstract_classes": "abc",
            "functions": "function",
            "async_functions": "function",
            "constants": "constant",
            "type_aliases": "type_alias",
            "other": "other",
        }

        object_types: dict[str, str] = {}

        for cat_key, obj_type in _CATEGORY_TYPE_MAP.items():
            for name in categories.get(cat_key, []):
                object_types[name] = obj_type

        # Methods: ClassName.method → specific type (classmethod/staticmethod/method)
        member_types = categories.get("class_member_types", {})
        for class_name, method_names in categories.get("class_method_names", {}).items():
            for method in method_names:
                key = f"{class_name}.{method}"
                # Use the specific sub-type if available, otherwise "method"
                object_types[key] = member_types.get(key, "method")

        # Properties and other descriptor members not in class_method_names
        # (e.g., @property attributes classified as "attribute" by griffe)
        for key, member_type in member_types.items():
            if key not in object_types:
                object_types[key] = member_type

        types_path = self.project_path / "_object_types.json"
        types_path.parent.mkdir(parents=True, exist_ok=True)
        with open(types_path, "w") as f:
            json.dump(object_types, f, indent=2, sort_keys=True)

        print(f"Wrote object type metadata ({len(object_types)} items) to {types_path}")

        # Write constant value metadata (sidecar file) when any constants have
        # extractable values or annotations.
        constant_metadata = categories.get("constant_metadata", {})
        if constant_metadata:
            values_path = self.project_path / "_constant_values.json"
            with open(values_path, "w") as f:
                json.dump(constant_metadata, f, indent=2, sort_keys=True)
            print(
                f"Wrote constant value metadata ({len(constant_metadata)} items) to {values_path}"
            )

    def _categorize_api_objects(self, package_name: str, exports: list) -> dict:
        """
        Categorize API objects using griffe introspection.

        Uses griffe (the project's introspection module) to analyze the package
        structure without importing it. This is safer and works with packages
        that have non-Python components (e.g., Rust bindings).

        Parameters
        ----------
        package_name
            The name of the package.
        exports
            List of exported names from __all__.

        Returns
        -------
        dict
            Dictionary with:
            - classes: list of regular class names
            - dataclasses: list of dataclass names
            - enums: list of enum class names
            - exceptions: list of exception class names
            - namedtuples: list of NamedTuple class names
            - typeddicts: list of TypedDict class names
            - protocols: list of Protocol class names
            - abstract_classes: list of ABC class names
            - functions: list of function names
            - async_functions: list of async function names
            - constants: list of module-level constant names
            - type_aliases: list of type alias names
            - other: list of other object names
            - class_methods: dict mapping class name to method count
            - class_method_names: dict mapping class name to list of method names
            - all_classes: list of ALL class-like names (union for compat)
            - all_functions: list of ALL function-like names (union for compat)
        """
        try:
            import griffe

            # Load the package using griffe
            normalized_name = package_name.replace("-", "_")

            # Try to use the renderer's `get_object()` for validation
            # Use a shared loader to avoid re-loading the module on every call
            gd_get_object = None
            try:
                from functools import partial

                from great_docs._apiref.introspect import (
                    get_object as qd_get_object,
                )
                from great_docs._apiref.introspect import make_loader

                _shared_loader = make_loader("numpy")
                gd_get_object = partial(qd_get_object, dynamic=True, loader=_shared_loader)
            except ImportError:  # pragma: no cover
                pass  # pragma: no cover

            # Try to load the package with griffe (uses per-build cache)
            try:
                pkg = self._get_griffe_package(normalized_name)
            except Exception as e:
                print(
                    f"Warning: Could not load package with griffe ({type(e).__name__})",
                    file=sys.stderr,
                )
                # Fallback: use importlib + inspect to categorize exports
                return self._categorize_api_objects_fallback(normalized_name, exports)

            categories = self._empty_categories()
            failed_introspection = []
            cyclic_aliases = []

            # Skip common metadata variables
            skip_names = {"__version__", "__author__", "__email__", "__all__"}

            for name in exports:
                # Skip metadata variables
                if name in skip_names:
                    continue

                try:
                    # Get the object from the loaded package
                    if name not in pkg.members:
                        categories["other"].append(name)
                        failed_introspection.append(name)
                        continue

                    obj = pkg.members[name]

                    # Categorize based on griffe's kind
                    # Note: Accessing obj.kind or obj.members on an Alias can trigger
                    # resolution which may raise CyclicAliasError or AliasResolutionError
                    if obj.kind.value == "class":
                        # Sub-classify the class
                        sub = self._sub_classify_class(obj)
                        _CLASS_SUB_MAP = {
                            "dataclass": "dataclasses",
                            "enum": "enums",
                            "exception": "exceptions",
                            "namedtuple": "namedtuples",
                            "typeddict": "typeddicts",
                            "protocol": "protocols",
                            "abc": "abstract_classes",
                            "class": "classes",
                        }
                        cat_key = _CLASS_SUB_MAP.get(sub, "classes")
                        categories[cat_key].append(name)
                        # Get documentable methods (exclude private methods but include
                        # documented dunder methods like __repr__, __eq__, __len__, etc.)
                        # __init__ is excluded because its parameters are already shown
                        # in the class constructor signature.
                        # We need to handle each member individually to catch cyclic aliases
                        # AND validate each method with get_object to catch type hint issues
                        # Collect (method_name, lineno) tuples to preserve source order
                        _INIT_DUNDERS = {"__init__", "__new__", "__init_subclass__"}
                        method_entries = []
                        skipped_methods = []
                        try:
                            for member_name, member in obj.members.items():
                                if member_name.startswith("__") and member_name.endswith("__"):
                                    # Dunder method: skip constructor-related ones,
                                    # include the rest (they have user-written docstrings)
                                    if member_name in _INIT_DUNDERS:
                                        continue
                                elif member_name.startswith("_"):
                                    # Single-underscore private method: skip
                                    continue
                                try:
                                    # Accessing member.kind can trigger alias resolution
                                    if member.kind.value in ("function", "method"):
                                        # Sub-classify to detect classmethod/staticmethod/property
                                        member_sub = self._sub_classify_function(member)
                                        # Get line number for source ordering. Dynamically inspected
                                        # members (PyO3 / .so extensions) often have `lineno=None`;
                                        # coerce to inf so sort() doesn't crash comparing
                                        # `None < None`.
                                        lineno = getattr(member, "lineno", None)
                                        if lineno is None:
                                            lineno = float("inf")
                                        # Validate with get_object if available
                                        if gd_get_object is not None:
                                            try:
                                                qd_obj = gd_get_object(
                                                    f"{normalized_name}:{name}.{member_name}"
                                                )
                                                # Try to access properties that might fail
                                                _ = qd_obj.members
                                                _ = qd_obj.kind
                                                method_entries.append((member_name, lineno))
                                                # Store sub-type for classmethod/staticmethod/property
                                                if member_sub in (
                                                    "classmethod",
                                                    "staticmethod",
                                                    "property",
                                                ):
                                                    categories["class_member_types"][
                                                        f"{name}.{member_name}"
                                                    ] = member_sub
                                            except Exception:  # pragma: no cover
                                                # Method can't be documented by the renderer
                                                skipped_methods.append(
                                                    member_name
                                                )  # pragma: no cover
                                        else:
                                            method_entries.append(
                                                (member_name, lineno)
                                            )  # pragma: no cover
                                            # Store sub-type for classmethod/staticmethod/property
                                            if member_sub in (  # pragma: no cover
                                                "classmethod",
                                                "staticmethod",
                                                "property",
                                            ):
                                                categories[
                                                    "class_member_types"
                                                ][  # pragma: no cover
                                                    f"{name}.{member_name}"
                                                ] = member_sub
                                    elif member.kind.value == "attribute":
                                        # Check if this attribute is a @property descriptor
                                        try:
                                            member_labels = member.labels
                                        except Exception:  # pragma: no cover
                                            member_labels = set()  # pragma: no cover
                                        if "property" in member_labels:
                                            categories["class_member_types"][
                                                f"{name}.{member_name}"
                                            ] = "property"
                                except (
                                    griffe.CyclicAliasError,
                                    griffe.AliasResolutionError,
                                ):  # pragma: no cover
                                    # Skip cyclic/unresolvable class members
                                    skipped_methods.append(member_name)  # pragma: no cover
                                except Exception:  # pragma: no cover
                                    # Skip members that can't be introspected
                                    pass  # pragma: no cover
                        except (
                            griffe.CyclicAliasError,
                            griffe.AliasResolutionError,
                        ):  # pragma: no cover
                            # If we can't even iterate members, class has issues
                            skipped_methods.append("<members>")  # pragma: no cover

                        # Sort by line number to preserve source file order
                        method_entries.sort(key=lambda x: x[1])
                        method_names = [entry[0] for entry in method_entries]

                        if skipped_methods:
                            print(  # pragma: no cover
                                f"{name}: class with {len(method_names)} public methods "
                                f"(skipped {len(skipped_methods)} undocumentable method(s): "
                                f"{', '.join(skipped_methods[:3])}{'...' if len(skipped_methods) > 3 else ''})"
                            )
                        else:
                            print(f"{name}: class with {len(method_names)} public methods")

                        categories["class_methods"][name] = len(method_names)
                        categories["class_method_names"][name] = method_names
                    elif obj.kind.value == "function":
                        # Sub-classify the function
                        sub = self._sub_classify_function(obj)
                        if sub == "async":
                            categories["async_functions"].append(name)
                        else:
                            categories["functions"].append(name)
                    elif obj.kind.value in ("attribute", "type alias"):
                        sub = self._sub_classify_attribute(obj)
                        if sub == "type_alias":
                            categories["type_aliases"].append(name)  # pragma: no cover
                        elif sub == "typevar":
                            categories["type_aliases"].append(name)  # pragma: no cover
                        else:
                            categories["constants"].append(name)
                            self._extract_constant_metadata(obj, name, categories)
                    elif obj.kind.value == "module":
                        # Drill into the module to discover its public classes and functions
                        # Use qualified names like "module.ClassName" so the renderer resolves
                        # them relative to the package (e.g., dateutil:easter.easter)
                        module_had_members = False
                        # Build a set of short names already in the exports list so we
                        # can skip submodule members that are re-exported at the package
                        # level (avoids duplicate entries like both "Model" and
                        # "models.Model" appearing in the reference).
                        _exports_set = set(exports)
                        try:
                            for member_name, member in obj.members.items():
                                if member_name.startswith("_"):
                                    continue
                                # If this member is already re-exported at the
                                # package level (i.e. its short name is in
                                # __all__ / exports), skip the qualified version
                                # to avoid duplicate reference entries.
                                if member_name in _exports_set:
                                    continue  # pragma: no cover
                                try:
                                    member_kind = member.kind.value
                                except (
                                    griffe.CyclicAliasError,
                                    griffe.AliasResolutionError,
                                ):  # pragma: no cover
                                    continue  # pragma: no cover
                                except Exception:  # pragma: no cover
                                    continue  # pragma: no cover

                                qualified = f"{name}.{member_name}"

                                # Validate with get_object if available
                                # Try dynamic first, fall back to static if it fails
                                if gd_get_object is not None:
                                    try:
                                        qd_obj = gd_get_object(f"{normalized_name}:{qualified}")
                                        _ = qd_obj.kind  # pragma: no cover
                                    except Exception:  # pragma: no cover
                                        # Dynamic mode failed; try static (no dynamic=True)
                                        try:
                                            from great_docs._apiref.introspect import (
                                                get_object as qd_get,
                                            )

                                            qd_obj = qd_get(
                                                f"{normalized_name}:{qualified}",
                                                parser="numpy",
                                            )
                                            _ = qd_obj.kind
                                        except Exception:  # pragma: no cover
                                            # Neither mode works; skip this member
                                            continue  # pragma: no cover

                                if member_kind == "class":
                                    sub = self._sub_classify_class(member)
                                    _CLASS_SUB_MAP = {
                                        "dataclass": "dataclasses",
                                        "enum": "enums",
                                        "exception": "exceptions",
                                        "namedtuple": "namedtuples",
                                        "typeddict": "typeddicts",
                                        "protocol": "protocols",
                                        "abc": "abstract_classes",
                                        "class": "classes",
                                    }
                                    cat_key = _CLASS_SUB_MAP.get(sub, "classes")
                                    categories[cat_key].append(qualified)
                                    module_had_members = True

                                    # Count public methods for classes inside modules
                                    method_entries = []
                                    try:
                                        for meth_name, meth in member.members.items():
                                            if meth_name.startswith("_"):
                                                continue  # pragma: no cover
                                            try:
                                                if meth.kind.value in ("function", "method"):
                                                    # Sub-classify for descriptor types
                                                    meth_sub = self._sub_classify_function(meth)
                                                    # `lineno` may be `None` for dynamically
                                                    # inspected (PyO3) methods.
                                                    lineno = getattr(meth, "lineno", None)
                                                    if lineno is None:
                                                        lineno = float("inf")
                                                    if gd_get_object is not None:
                                                        try:
                                                            qd_m = gd_get_object(
                                                                f"{normalized_name}:{qualified}.{meth_name}"
                                                            )
                                                            _ = qd_m.kind  # pragma: no cover
                                                            method_entries.append(  # pragma: no cover
                                                                (meth_name, lineno)
                                                            )
                                                            # Store sub-type
                                                            if meth_sub in (  # pragma: no cover
                                                                "classmethod",
                                                                "staticmethod",
                                                                "property",
                                                            ):
                                                                categories[
                                                                    "class_member_types"
                                                                ][  # pragma: no cover
                                                                    f"{qualified}.{meth_name}"
                                                                ] = meth_sub
                                                        except Exception:  # pragma: no cover
                                                            # Dynamic failed; try static
                                                            try:
                                                                from great_docs._apiref.introspect import (
                                                                    get_object as qd_get,
                                                                )

                                                                qd_m = qd_get(
                                                                    f"{normalized_name}:{qualified}.{meth_name}",
                                                                    parser="numpy",
                                                                )
                                                                _ = qd_m.kind
                                                                method_entries.append(
                                                                    (meth_name, lineno)
                                                                )
                                                                # Store sub-type
                                                                if meth_sub in (
                                                                    "classmethod",
                                                                    "staticmethod",
                                                                    "property",
                                                                ):
                                                                    categories[
                                                                        "class_member_types"
                                                                    ][
                                                                        f"{qualified}.{meth_name}"
                                                                    ] = meth_sub
                                                            except Exception:  # pragma: no cover
                                                                pass  # pragma: no cover
                                                    else:
                                                        method_entries.append(
                                                            (meth_name, lineno)
                                                        )  # pragma: no cover
                                                        # Store sub-type
                                                        if meth_sub in (  # pragma: no cover
                                                            "classmethod",
                                                            "staticmethod",
                                                            "property",
                                                        ):
                                                            categories[
                                                                "class_member_types"
                                                            ][  # pragma: no cover
                                                                f"{qualified}.{meth_name}"
                                                            ] = meth_sub
                                                elif (
                                                    meth.kind.value == "attribute"
                                                ):  # pragma: no cover
                                                    # Check if attribute is a @property
                                                    try:  # pragma: no cover
                                                        meth_labels = (
                                                            meth.labels
                                                        )  # pragma: no cover
                                                    except Exception:  # pragma: no cover
                                                        meth_labels = set()  # pragma: no cover
                                                    if (
                                                        "property" in meth_labels
                                                    ):  # pragma: no cover
                                                        categories[
                                                            "class_member_types"
                                                        ][  # pragma: no cover
                                                            f"{qualified}.{meth_name}"
                                                        ] = "property"
                                            except (
                                                griffe.CyclicAliasError,
                                                griffe.AliasResolutionError,
                                            ):  # pragma: no cover
                                                pass  # pragma: no cover
                                            except Exception:  # pragma: no cover
                                                pass  # pragma: no cover
                                    except (
                                        griffe.CyclicAliasError,
                                        griffe.AliasResolutionError,
                                    ):  # pragma: no cover
                                        pass  # pragma: no cover

                                    method_entries.sort(key=lambda x: x[1])
                                    method_names_list = [e[0] for e in method_entries]
                                    categories["class_methods"][qualified] = len(method_names_list)
                                    categories["class_method_names"][qualified] = method_names_list
                                    print(
                                        f"{qualified}: class with "
                                        f"{len(method_names_list)} public methods"
                                    )

                                elif member_kind == "function":
                                    sub = self._sub_classify_function(member)
                                    if sub == "async":
                                        categories["async_functions"].append(
                                            qualified
                                        )  # pragma: no cover
                                    else:
                                        categories["functions"].append(qualified)
                                    module_had_members = True

                                elif member_kind in ("attribute", "type alias"):
                                    sub = self._sub_classify_attribute(member)
                                    if sub == "type_alias":
                                        categories["type_aliases"].append(
                                            qualified
                                        )  # pragma: no cover
                                    elif sub == "typevar":
                                        categories["type_aliases"].append(
                                            qualified
                                        )  # pragma: no cover
                                    else:
                                        categories["constants"].append(qualified)
                                        self._extract_constant_metadata(
                                            member, qualified, categories
                                        )
                                    module_had_members = True

                        except (  # pragma: no cover
                            griffe.CyclicAliasError,
                            griffe.AliasResolutionError,
                        ):
                            # Can't iterate module members
                            pass  # pragma: no cover

                        if not module_had_members:
                            # Module has no documentable public members
                            categories["other"].append(name)
                    else:
                        # Aliases and other unknown kinds
                        categories["other"].append(name)  # pragma: no cover

                except griffe.CyclicAliasError:  # pragma: no cover
                    # Cyclic alias detected (e.g., re-exported symbol pointing to itself)
                    # This can happen with complex re-export patterns
                    # Do NOT add to categories (these must be excluded entirely)
                    print(f"  Warning: Cyclic alias detected for '{name}', excluding from docs")
                    cyclic_aliases.append(name)
                except griffe.AliasResolutionError:  # pragma: no cover
                    # Alias could not be resolved (target not found)
                    # Do NOT add to categories (these must be excluded entirely)
                    print(f"  Warning: Could not resolve alias for '{name}', excluding from docs")
                    failed_introspection.append(name)
                except Exception as e:  # pragma: no cover
                    # If introspection fails for a specific object, still include it
                    print(f"  Warning: Could not introspect '{name}': {type(e).__name__}")
                    categories["other"].append(name)
                    failed_introspection.append(name)

            if cyclic_aliases:  # pragma: no cover
                print(f"Note: Excluded {len(cyclic_aliases)} cyclic alias(es) from documentation")
                categories["cyclic_alias_count"] = len(cyclic_aliases)

            if failed_introspection:  # pragma: no cover
                print(
                    f"Note: Could not introspect {len(failed_introspection)} item(s), categorizing as 'Other'"
                )

            # Build convenience union keys for backward compatibility
            categories["all_classes"] = (
                categories["classes"]
                + categories["dataclasses"]
                + categories["enums"]
                + categories["exceptions"]
                + categories["namedtuples"]
                + categories["typeddicts"]
                + categories["protocols"]
                + categories["abstract_classes"]
            )
            categories["all_functions"] = categories["functions"] + categories["async_functions"]

            return categories

        except ImportError:  # pragma: no cover
            print("Warning: griffe not available, using fallback categorization")
            # Fallback: use importlib + inspect to categorize exports
            normalized_name = package_name.replace("-", "_")
            return self._categorize_api_objects_fallback(normalized_name, exports)

    def _categorize_api_objects_fallback(self, package_name: str, exports: list[str]) -> dict:
        """
        Categorize API objects using importlib + inspect when griffe is unavailable.

        This fallback is used when griffe cannot load the package (e.g. during
        `great-docs init` when the package is not yet on sys.path). It
        attempts to import the module via `importlib` and uses `inspect` to
        distinguish functions from classes and other objects.

        Parameters
        ----------
        package_name
            The normalized importable package name.
        exports
            List of exported names to categorize.

        Returns
        -------
        dict
            Categories dictionary with the same structure as `_categorize_api_objects`.
        """
        import inspect

        skip_names = {"__version__", "__author__", "__email__", "__all__"}
        filtered_exports = [e for e in exports if e not in skip_names]
        categories = self._empty_categories()

        try:
            mod = __import__(package_name)
        except Exception:
            # The normalized project name may not match the actual module name.
            # Try to discover the correct module by scanning the package root for
            # Python packages (dirs with __init__.py), just like _get_package_exports.
            mod = None
            try:
                package_root = self._find_package_root()
                for child in sorted(package_root.iterdir()):
                    if (
                        child.is_dir()
                        and not child.name.startswith((".", "_"))
                        and child.name not in ("great-docs", "docs", "tests", "test")
                        and (child / "__init__.py").exists()
                    ):
                        try:
                            mod = __import__(child.name)
                            print(
                                f"  Fallback: imported '{child.name}' "
                                f"(project name was '{package_name}')"
                            )
                            break
                        except Exception:  # pragma: no cover
                            continue  # pragma: no cover
            except Exception:  # pragma: no cover
                pass  # pragma: no cover

            if mod is None:
                # Neither the normalized name nor any discovered module worked
                categories["other"] = filtered_exports
                return categories

        for name in filtered_exports:
            obj = getattr(mod, name, None)
            if obj is None:
                categories["other"].append(name)
                continue

            if inspect.isclass(obj):
                if issubclass(obj, Exception):
                    categories["exceptions"].append(name)
                elif hasattr(obj, "__dataclass_fields__"):
                    categories["dataclasses"].append(name)
                else:
                    categories["classes"].append(name)
            elif inspect.isfunction(obj) or inspect.isbuiltin(obj):
                if inspect.iscoroutinefunction(obj):
                    categories["async_functions"].append(name)
                else:
                    categories["functions"].append(name)
            elif inspect.ismodule(obj):
                categories["other"].append(name)
            else:
                # Constants, type aliases, etc.
                categories["constants"].append(name)

        # Build convenience union keys
        categories["all_classes"] = (
            categories["classes"]
            + categories["dataclasses"]
            + categories["enums"]
            + categories["exceptions"]
            + categories["namedtuples"]
            + categories["typeddicts"]
            + categories["protocols"]
            + categories["abstract_classes"]
        )
        categories["all_functions"] = categories["functions"] + categories["async_functions"]

        return categories

    def _create_api_sections(self, package_name: str) -> list | None:
        """
        Create API reference sections based on discovered package exports.

        Uses static analysis (griffe) to discover public objects.

        Uses this heuristic:

        - classes with ≤5 methods: documented inline
        - classes with >5 methods: separate pages for each method

        Parameters
        ----------
        package_name
            The name of the package.

        Returns
        -------
        list | None
            List of section dictionaries, or None if no sections could be created.
        """
        exports = self._get_package_exports(package_name)
        if not exports:
            return None

        # Filter out metadata variables at the export level too
        skip_names = {"__version__", "__author__", "__email__", "__all__"}
        exports = [e for e in exports if e not in skip_names]

        if not exports:
            return None  # pragma: no cover

        print(f"Found {len(exports)} exported names to document")

        # Categorize the exports
        categories = self._categorize_api_objects(package_name, exports)

        sections = []

        # i18n helper for section titles/descriptions
        from ._translations import get_translation

        lang = self._config.language

        def _t(key: str, fallback: str) -> str:
            return get_translation(key, lang) if lang != "en" else fallback

        # ── Helper: build a class section with big-class splitting ────────
        def _make_class_section(title: str, desc: str, class_names: list) -> list[dict]:
            """Return section dicts (possibly with a Methods companion section)."""
            if not class_names:
                return []

            class_contents = []
            separate_methods = []

            for class_name in class_names:
                method_count = categories["class_methods"].get(class_name, 0)
                if self._config.should_split_methods(method_count):
                    class_contents.append({"name": class_name, "members": []})
                    separate_methods.append(class_name)
                else:
                    class_contents.append(class_name)

            result = [{"title": title, "desc": desc, "contents": class_contents}]

            for class_name in separate_methods:
                method_names = categories["class_method_names"].get(class_name, [])
                method_count = len(method_names)
                method_contents = [f"{class_name}.{method}" for method in method_names]
                result.append(
                    {
                        "title": f"{class_name} Methods",
                        "desc": f"Methods for the {class_name} class",
                        "contents": method_contents,
                    }
                )
                print(f"  Created separate section for {class_name} with {method_count} methods")

            return result

        # ── Class-like sections ──────────────────────────────────────────
        sections.extend(
            _make_class_section(
                _t("classes", "Classes"),
                _t("core_classes_desc", "Core classes"),
                categories["classes"],
            )
        )
        sections.extend(
            _make_class_section(
                _t("dataclasses", "Dataclasses"),
                _t("data_classes_desc", "Data-holding classes"),
                categories["dataclasses"],
            )
        )
        sections.extend(
            _make_class_section(
                _t("abstract_classes", "Abstract Classes"),
                _t("abstract_classes_desc", "Abstract base classes"),
                categories["abstract_classes"],
            )
        )
        sections.extend(
            _make_class_section(
                _t("protocols", "Protocols"),
                _t("protocols_desc", "Structural typing protocols"),
                categories["protocols"],
            )
        )

        # Enums, Exceptions, NamedTuples, TypedDicts — no big-class splitting
        if categories["enums"]:
            sections.append(
                {
                    "title": _t("enumerations", "Enumerations"),
                    "desc": _t("enum_types_desc", "Enum types"),
                    "contents": categories["enums"],
                }
            )
        if categories["exceptions"]:
            sections.append(
                {
                    "title": _t("exceptions", "Exceptions"),
                    "desc": _t("exception_classes_desc", "Exception classes"),
                    "contents": categories["exceptions"],
                }
            )
        if categories["namedtuples"]:
            sections.append(
                {
                    "title": _t("named_tuples", "Named Tuples"),
                    "desc": _t("namedtuple_types_desc", "NamedTuple types"),
                    "contents": categories["namedtuples"],
                }
            )
        if categories["typeddicts"]:
            sections.append(
                {
                    "title": _t("typed_dicts", "Typed Dicts"),
                    "desc": _t("typeddict_types_desc", "TypedDict types"),
                    "contents": categories["typeddicts"],
                }
            )

        # ── Function sections ────────────────────────────────────────────
        if categories["functions"]:
            sections.append(
                {
                    "title": _t("functions", "Functions"),
                    "desc": _t("public_functions_desc", "Public functions"),
                    "contents": categories["functions"],
                }
            )

        if categories["async_functions"]:
            sections.append(
                {
                    "title": _t("async_functions", "Async Functions"),
                    "desc": _t("async_functions_desc", "Asynchronous functions"),
                    "contents": categories["async_functions"],
                }
            )

        # ── Data sections ────────────────────────────────────────────────
        if categories["constants"]:
            sections.append(
                {
                    "title": _t("constants", "Constants"),
                    "desc": _t("constants_desc", "Module-level constants and data"),
                    "contents": categories["constants"],
                }
            )

        if categories["type_aliases"]:
            sections.append(
                {
                    "title": _t("type_aliases", "Type Aliases"),
                    "desc": _t("type_alias_desc", "Type alias definitions"),
                    "contents": categories["type_aliases"],
                }
            )

        # ── Catch-all ────────────────────────────────────────────────────
        if categories["other"]:
            sections.append(
                {
                    "title": _t("other", "Other"),
                    "desc": _t("additional_exports_desc", "Additional exports"),
                    "contents": categories["other"],
                }
            )

        # Write object type metadata for post-render classification
        self._write_object_types_json(categories)

        return sections if sections else None

    def _build_sections_from_reference_config(
        self, reference_config: list[dict]
    ) -> list[dict] | None:
        """
        Build API reference sections directly from reference config without validation.

        This is a fallback method used when auto-discovery fails but the user has
        explicitly specified their API structure in great-docs.yml. Unlike
        `_create_api_sections_from_config`, this method doesn't attempt to
        validate the references against discovered exports.

        Parameters
        ----------
        reference_config
            The reference configuration from great-docs.yml.

        Returns
        -------
        list[dict] | None
            List of section dictionaries, or None if config is empty.
        """
        if not reference_config:
            return None

        sections = []

        for section_config in reference_config:
            if not isinstance(section_config, dict):
                continue
            title = section_config.get("title", "Untitled")
            desc = section_config.get("desc", "")
            contents_config = section_config.get("contents", [])

            if not contents_config:
                continue

            section_contents = []

            for item in contents_config:
                if isinstance(item, str):
                    # Simple string reference - use as-is
                    section_contents.append(item)
                elif isinstance(item, dict):
                    # Dict with name and optional members config
                    name = item.get("name", "")
                    if not name:
                        continue

                    members = item.get("members", True)
                    include_inherited = item.get("include_inherited", None)

                    if members is False:
                        # Don't document methods - just the class
                        section_contents.append({"name": name, "members": []})
                    elif isinstance(members, list):
                        # Explicit member list — pass through directly
                        entry: dict = {"name": name, "members": members}
                        if include_inherited is not None:
                            entry["include_inherited"] = include_inherited
                        section_contents.append(entry)
                    else:
                        # Default: inline documentation (members: true)
                        if include_inherited is not None:
                            section_contents.append(
                                {"name": name, "include_inherited": include_inherited}
                            )
                        else:
                            section_contents.append(name)

            if section_contents:
                sections.append(
                    {
                        "title": title,
                        "desc": desc,
                        "contents": section_contents,
                    }
                )

        return sections if sections else None

    def _categorize_referenced_objects(self, package_name: str, reference_config: list) -> dict:
        """
        Lightweight categorization that only inspects objects listed in the reference config.

        Unlike `_categorize_api_objects` which introspects all package exports and deep-enumerates
        every class's methods (O(exports x methods)), this method only classifies the specific names
        referenced in great-docs.yml. For a package with 195 exports and 500+ methods, this could
        avoid ~90% of griffe work.

        Parameters
        ----------
        package_name
            The package name (may contain dashes).
        reference_config
            The `reference:` list from great-docs.yml.

        Returns
        -------
        dict
            A categories dict compatible with `_write_object_types_json`.
        """
        import griffe

        normalized_name = package_name.replace("-", "_")

        try:
            pkg = self._get_griffe_package(normalized_name)
        except Exception:
            return self._empty_categories()

        # Collect all names referenced in the config
        referenced_names: set[str] = set()
        for section in reference_config:
            if not isinstance(section, dict):
                continue
            for item in section.get("contents", []):
                if isinstance(item, str):
                    referenced_names.add(item)
                elif isinstance(item, dict):
                    name = item.get("name", "")
                    if name:
                        referenced_names.add(name)

        categories = self._empty_categories()

        # Separate into top-level names and qualified names (Class.method)
        top_level_names: set[str] = set()
        method_refs: dict[str, list[str]] = {}  # class_name -> [method_name, ...]
        for name in referenced_names:
            if "." in name:
                parts = name.split(".", 1)
                class_name, method_name = parts[0], parts[1]
                top_level_names.add(class_name)
                method_refs.setdefault(class_name, []).append(method_name)
            else:
                top_level_names.add(name)

        # Classify each top-level name
        missing_names: list[str] = []
        for name in sorted(top_level_names):
            if name not in pkg.members:
                missing_names.append(name)
                continue

            try:
                obj = pkg.members[name]
                kind = obj.kind.value
            except (griffe.CyclicAliasError, griffe.AliasResolutionError):
                categories["other"].append(name)
                continue
            except Exception:
                categories["other"].append(name)
                continue

            if kind == "class":
                sub = self._sub_classify_class(obj)
                _CLASS_SUB_MAP = {
                    "dataclass": "dataclasses",
                    "enum": "enums",
                    "exception": "exceptions",
                    "namedtuple": "namedtuples",
                    "typeddict": "typeddicts",
                    "protocol": "protocols",
                    "abc": "abstract_classes",
                    "class": "classes",
                }
                cat_key = _CLASS_SUB_MAP.get(sub, "classes")
                categories[cat_key].append(name)

                # Count public methods for should_split_methods check
                # (lightweight: just count, don't validate each with get_object)
                # Only count functions/methods and @property attributes and not
                # plain attributes (e.g., dataclass fields) which aren't methods.
                _INIT_DUNDERS = {"__init__", "__new__", "__init_subclass__"}
                method_count = 0
                method_names_list: list[str] = []
                try:
                    for member_name, member in obj.members.items():
                        if member_name.startswith("__") and member_name.endswith("__"):
                            if member_name in _INIT_DUNDERS:
                                continue
                        elif member_name.startswith("_"):
                            continue
                        try:
                            member_kind = member.kind.value
                            if member_kind in ("function", "method"):
                                method_count += 1
                                method_names_list.append(member_name)
                            elif member_kind == "attribute":
                                # Only count @property attributes, not plain fields
                                try:
                                    if "property" in member.labels:
                                        method_count += 1
                                        method_names_list.append(member_name)
                                except Exception:
                                    pass
                        except (
                            griffe.CyclicAliasError,
                            griffe.AliasResolutionError,
                        ):
                            pass
                        except Exception:
                            pass
                except (griffe.CyclicAliasError, griffe.AliasResolutionError):
                    pass

                categories["class_methods"][name] = method_count
                if method_names_list:
                    categories["class_method_names"][name] = method_names_list

            elif kind == "function":
                sub = self._sub_classify_function(obj)
                if sub == "async":
                    categories["async_functions"].append(name)
                else:
                    categories["functions"].append(name)
            elif kind in ("attribute", "type alias"):
                sub = self._sub_classify_attribute(obj)
                if sub == "type_alias":
                    categories["type_aliases"].append(name)
                elif sub == "typevar":
                    categories["type_aliases"].append(name)
                else:
                    categories["constants"].append(name)
                    self._extract_constant_metadata(obj, name, categories)
            elif kind == "module":
                # Expand module members into qualified names for module_members mapping
                try:
                    for member_name, member in obj.members.items():
                        if member_name.startswith("_"):
                            continue
                        try:
                            member_kind = member.kind.value
                        except (
                            griffe.CyclicAliasError,
                            griffe.AliasResolutionError,
                        ):
                            continue
                        except Exception:
                            continue
                        qualified = f"{name}.{member_name}"
                        if member_kind == "class":
                            sub = self._sub_classify_class(member)
                            cat_key_mod = _CLASS_SUB_MAP.get(sub, "classes")
                            categories[cat_key_mod].append(qualified)
                        elif member_kind == "function":
                            categories["functions"].append(qualified)
                        elif member_kind in ("attribute", "type alias"):
                            categories["constants"].append(qualified)
                except (griffe.CyclicAliasError, griffe.AliasResolutionError):
                    pass
                except Exception:
                    pass
            else:
                categories["other"].append(name)

        # Fail loudly if configured items cannot be found in the package,
        # but only when the package is genuinely installed (has a resolvable version).
        # If the package isn't installed (version unknown), this is likely a
        # bootstrap/development scenario — fall back gracefully.
        if missing_names:
            try:
                from importlib.metadata import version as _mv

                pkg_ver = _mv(package_name)
            except Exception:
                pkg_ver = None

            if pkg_ver is not None:
                items_str = ", ".join(f"`{n}`" for n in missing_names)
                raise SystemExit(
                    f"ERROR: Configured reference item(s) not found in "
                    f"`{package_name}` v{pkg_ver}: {items_str}\n"
                    f"Check that the correct version of `{package_name}` is installed "
                    f"and that these names exist in the package's public API."
                )
            else:
                # Package not installed — treat missing items as "other" (old behavior)
                categories["other"].extend(missing_names)

        # Classify explicitly-referenced methods (ClassName.method_name)
        for class_name, methods in method_refs.items():
            if class_name not in pkg.members:
                continue
            try:
                class_obj = pkg.members[class_name]
                if class_obj.kind.value != "class":
                    continue
            except Exception:
                continue

            for method_name in methods:
                key = f"{class_name}.{method_name}"
                if method_name not in class_obj.members:
                    categories["class_member_types"][key] = "method"
                    continue
                try:
                    member = class_obj.members[method_name]
                    if member.kind.value in ("function", "method"):
                        sub = self._sub_classify_function(member)
                        if sub in ("classmethod", "staticmethod", "property"):
                            categories["class_member_types"][key] = sub
                        else:
                            categories["class_member_types"][key] = "method"
                    elif member.kind.value == "attribute":
                        try:
                            labels = member.labels
                        except Exception:
                            labels = set()
                        if "property" in labels:
                            categories["class_member_types"][key] = "property"
                        else:
                            categories["class_member_types"][key] = "attribute"
                    else:
                        categories["class_member_types"][key] = "method"
                except (griffe.CyclicAliasError, griffe.AliasResolutionError):
                    categories["class_member_types"][key] = "method"
                except Exception:
                    categories["class_member_types"][key] = "method"

        # Build convenience unions
        categories["all_classes"] = (
            categories["classes"]
            + categories["dataclasses"]
            + categories["enums"]
            + categories["exceptions"]
            + categories["namedtuples"]
            + categories["typeddicts"]
            + categories["protocols"]
            + categories["abstract_classes"]
        )
        categories["all_functions"] = categories["functions"] + categories["async_functions"]

        return categories

    def _create_api_sections_from_config(self, package_name: str) -> list | None:
        """
        Create API reference sections from the `reference` config in great-docs.yml.

        This method reads the explicit section configuration from great-docs.yml
        and generates API reference sections accordingly. If no `reference` config
        is provided, returns None to fall back to auto-discovery.

        Parameters
        ----------
        package_name
            The name of the package.

        Returns
        -------
        list | None
            List of section dictionaries, or None if no reference config.
        """
        reference_config = self._config.reference
        if not reference_config:
            return None

        print("Using explicit reference configuration from great-docs.yml")

        # Use lightweight targeted categorization instead of full export enumeration
        categories = self._categorize_referenced_objects(package_name, reference_config)

        # Build a mapping of module names → their expanded member names across
        # all categories.  When a user lists a bare module name (e.g. "parser")
        # in their reference config we expand it into the individual classes,
        # functions, constants, etc. that _categorize_api_objects discovered.
        _MEMBER_CATS = [
            "classes",
            "dataclasses",
            "abstract_classes",
            "protocols",
            "enums",
            "exceptions",
            "namedtuples",
            "typeddicts",
            "functions",
            "async_functions",
            "constants",
            "type_aliases",
            "other",
        ]
        module_members: dict[str, list[str]] = {}
        for cat_key in _MEMBER_CATS:
            for qualified in categories.get(cat_key, []):
                if "." in qualified:
                    mod_prefix = qualified.split(".")[0]
                    module_members.setdefault(mod_prefix, []).append(qualified)

        sections = []

        for section_config in reference_config:
            if not isinstance(section_config, dict):
                continue  # pragma: no cover
            title = section_config.get("title", "Untitled")
            desc = section_config.get("desc", "")
            contents_config = section_config.get("contents", [])

            if not contents_config:
                continue  # pragma: no cover

            section_contents = []
            # Track large classes that need companion method sections
            large_classes_in_section: list[str] = []

            for item in contents_config:
                if isinstance(item, str):
                    if item in module_members:
                        # This is a module name — expand into its individual members
                        for member in module_members[item]:
                            method_count = categories["class_methods"].get(member, 0)
                            if self._config.should_split_methods(method_count):
                                section_contents.append(
                                    {"name": member, "members": []}
                                )  # pragma: no cover
                                large_classes_in_section.append(member)  # pragma: no cover
                            else:
                                section_contents.append(member)
                    else:
                        # Regular item (class, function, etc.) — use as-is
                        method_count = categories["class_methods"].get(item, 0)
                        if self._config.should_split_methods(method_count):
                            section_contents.append({"name": item, "members": []})
                            large_classes_in_section.append(item)
                        else:
                            section_contents.append(item)
                elif isinstance(item, dict):
                    # Dict with name and optional members config
                    name = item.get("name", "")
                    if not name:
                        continue  # pragma: no cover

                    members = item.get("members", True)
                    include_inherited = item.get("include_inherited", None)

                    if members is False:
                        # Don't document methods - just the class
                        section_contents.append({"name": name, "members": []})
                    elif isinstance(members, list):
                        # Explicit member list — pass through so inherited
                        # methods listed here are documented
                        entry: dict = {"name": name, "members": members}
                        if include_inherited is not None:
                            entry["include_inherited"] = include_inherited
                        section_contents.append(entry)
                    else:
                        # Default: inline documentation (members: true)
                        if include_inherited is not None:
                            section_contents.append(
                                {"name": name, "include_inherited": include_inherited}
                            )
                        else:
                            section_contents.append(name)

            if section_contents:
                sections.append(
                    {
                        "title": title,
                        "desc": desc,
                        "contents": section_contents,
                    }
                )

            # Add companion method sections for large classes
            for class_name in large_classes_in_section:
                method_names = categories["class_method_names"].get(class_name, [])
                if method_names:
                    method_contents = [f"{class_name}.{m}" for m in method_names]
                    sections.append(
                        {
                            "title": f"{class_name} Methods",
                            "desc": f"Methods for the {class_name} class",
                            "contents": method_contents,
                        }
                    )
                    print(
                        f"  Created separate section for {class_name} "
                        f"with {len(method_names)} methods"
                    )

        if sections:
            print(f"Generated {len(sections)} section(s) from reference config")

        # Write object type metadata for post-render classification
        self._write_object_types_json(categories)

        return sections if sections else None

    def documented_symbol_names(self, package_name: str) -> list[str]:
        """Dotted reference-page stems for the documented public API

        Each stem names one published reference page: a top-level class or
        function, a submodule-qualified class (`scores.CosineScore`), or a
        method (`scores.CosineScore.fit`). The set matches the rendered
        reference — top-level objects and their documented members, `%nodoc`
        excluded — whether from an explicit `reference:` config or
        auto-discovery, so a versioned snapshot and the live build describe the
        same API surface. Empty when the package documents nothing.

        Parameters
        ----------
        package_name
            The package name (may contain dashes).

        Returns
        -------
        list[str]
            Dotted stems, deduplicated, in first-occurrence order.
        """
        import contextlib
        import io
        import sys
        from types import ModuleType

        added_paths: list[str] = []
        cached_modules: dict[str, ModuleType] = {}
        modules_evicted = False
        importable_name = self._normalize_package_name(package_name)

        try:
            # Suppress diagnostic prints from the resolution/filtering pipeline — this is a
            # programmatic query method and its callers own their own output streams.
            # `_suppress_artifact_writes` additionally prevents the pipeline from writing
            # `_object_types.json` (and its sidecar) into the project root.
            self._suppress_artifact_writes = True
            # `APIReference` loads the package through a plain griffe loader (sys.path-based),
            # unlike `_create_api_sections_with_config`'s own loader, which is handed explicit
            # search paths. Temporarily extend sys.path so a package that isn't installed (e.g.
            # a src-layout dev checkout) can still be found, then restore it.
            added_paths = [str(p) for p in self._griffe_search_paths() if str(p) not in sys.path]
            sys.path[:0] = added_paths
            # Drop any cached import of the package (and its submodules) so dynamic
            # resolution reflects what's on disk *now* — not a stale module object
            # left in `sys.modules` by an earlier call in this process (e.g. a
            # different git-tag checkout during a versioned-snapshot loop).
            module_keys = [
                k
                for k in sys.modules
                if k == importable_name or k.startswith(f"{importable_name}.")
            ]
            cached_modules = {key: sys.modules[key] for key in module_keys}
            for key in module_keys:
                del sys.modules[key]
            modules_evicted = True
            with (
                contextlib.redirect_stdout(io.StringIO()),
                contextlib.redirect_stderr(io.StringIO()),
            ):
                sections = self._create_api_sections_with_config(package_name)
                if not sections:
                    return []
                from great_docs._apiref.api_reference import APIReference
                from great_docs._apiref.resolve import ObjectNotFoundError

                # In `dynamic` mode, resolving `documented_symbols` below performs a real
                # import of the target package (executing its top-level code). The
                # `sys.modules` eviction above only refreshes the target package's own
                # modules, not third-party packages it imports — so a same-process loop
                # over different checkouts can still see third-party global-state
                # carryover (e.g. module-level caches or singletons).
                ref = APIReference(
                    {
                        "api-reference": {
                            "package": importable_name,
                            "sections": sections,
                            "parser": self._config.parser or "numpy",
                            "dynamic": self._config.dynamic,
                        }
                    }
                )
                try:
                    return ref.documented_symbols
                except (ObjectNotFoundError, ImportError, AttributeError):
                    return []
        finally:
            self._suppress_artifact_writes = False
            if modules_evicted:
                for key in [
                    k
                    for k in sys.modules
                    if k == importable_name or k.startswith(f"{importable_name}.")
                ]:
                    del sys.modules[key]
                sys.modules.update(cached_modules)
            for p in added_paths:
                if p in sys.path:
                    sys.path.remove(p)

    def _create_api_sections_with_config(self, package_name: str) -> list | None:
        """
        Create API reference sections, prioritizing explicit config over auto-discovery.

        First checks for explicit `reference` configuration in great-docs.yml.
        If not found, falls back to auto-generating sections from discovered exports.

        Parameters
        ----------
        package_name
            The name of the package.

        Returns
        -------
        list | None
            List of section dictionaries, or None if no exports found.
        """
        # First, check for explicit reference config in great-docs.yml
        config_sections = self._create_api_sections_from_config(package_name)
        if config_sections:
            sections = config_sections
        else:
            # Fall back to auto-generated sections from discovered exports
            sections = self._create_api_sections(package_name)

        return sections

    def _extract_authors_from_pyproject(self) -> list[dict[str, str]]:
        """
        Extract author information from pyproject.toml.

        Reads authors and maintainers from pyproject.toml and combines them
        into a list suitable for the great-docs.yml authors section.

        Returns
        -------
        list[dict[str, str]]
            List of author dictionaries with name, email, and role fields.
        """
        package_root = self._find_package_root()
        pyproject_path = package_root / "pyproject.toml"

        if not pyproject_path.exists():
            return []

        try:
            import tomllib

            with open(pyproject_path, "rb") as f:
                data = tomllib.load(f)
                project = data.get("project", {})

            authors_list = project.get("authors", [])
            maintainers_list = project.get("maintainers", [])

            # Track seen names to avoid duplicates
            seen_names: set[str] = set()
            result: list[dict[str, str]] = []

            # Process maintainers first (they get "Maintainer" role)
            for maintainer in maintainers_list:
                if isinstance(maintainer, dict):
                    name = maintainer.get("name", "")
                    email = maintainer.get("email", "")
                    if name and name not in seen_names:
                        seen_names.add(name)
                        author_entry: dict[str, str] = {"name": name, "role": "Maintainer"}
                        if email:
                            author_entry["email"] = email
                        result.append(author_entry)

            # Process authors (they get "Author" role, unless already added as maintainer)
            for author in authors_list:
                if isinstance(author, dict):
                    name = author.get("name", "")
                    email = author.get("email", "")
                    if name and name not in seen_names:
                        seen_names.add(name)
                        author_entry = {"name": name, "role": "Author"}
                        if email:
                            author_entry["email"] = email
                        result.append(author_entry)

            return result

        except Exception:  # pragma: no cover
            return []  # pragma: no cover

    def _format_authors_yaml(self, authors: list[dict[str, str]]) -> str:
        """
        Format authors list as YAML for great-docs.yml.

        Parameters
        ----------
        authors
            List of author dictionaries from _extract_authors_from_pyproject.

        Returns
        -------
        str
            YAML-formatted authors section, or empty string if no authors.
        """
        if not authors:
            return ""

        lines = ["authors:"]

        for author in authors:
            lines.append(f"  - name: {author['name']}")
            if "role" in author:
                lines.append(f"    role: {author['role']}")
            # Add commented placeholders for optional fields
            lines.append("    # affiliation: ")
            if "email" in author:
                lines.append(f"    email: {author['email']}")
            else:
                lines.append("    # email: ")
            lines.append("    # github: ")
            lines.append("    # orcid: ")
            lines.append("    # homepage: ")

        return "\n".join(lines)

    def _generate_initial_config(self, force: bool = False) -> bool:
        """
        Generate an initial great-docs.yml with discovered exports.

        Renders the shipped `great-docs.default.yml` template with detected
        values (`parser`, `dynamic`, and optionally `module`, `authors`,
        `reference`) spliced in as live keys.

        Parameters
        ----------
        force
            If `True`, overwrite existing great-docs.yml without prompting.

        Returns
        -------
        bool
            True if config was created, False if skipped.
        """
        config_path = self._find_package_root() / "great-docs.yml"

        if config_path.exists() and not force:
            print(
                f"great-docs.yml already exists at {config_path}\n"
                "Use --force to overwrite it (this will reset to defaults)."
            )
            return False

        # Detect package name
        package_name = self._detect_package_name()
        if not package_name:
            print("Warning: Could not detect package name, creating minimal config")
            # No importable name to detect from; fall back to the documented
            # parser/dynamic defaults so the emitted config is never empty.
            minimal_overrides = {"parser": "parser: numpy", "dynamic": "dynamic: true"}
            config_path.write_text(create_default_config(minimal_overrides), encoding="utf-8")
            print(f"Created {config_path}")
            return True

        overrides: dict[str, str] = {}

        # Resolve the importable module name.
        importable_name = self._normalize_package_name(package_name)
        module_name = self._detect_module_name()
        if module_name:
            importable_name = module_name
        normalized_name = self._normalize_package_name(package_name)
        explicit_module = module_name if module_name != normalized_name else None
        if explicit_module:
            overrides["module"] = f"module: {explicit_module}"

        # Detect docstring style
        print("Detecting docstring style...")
        parser_style = self._detect_docstring_style(importable_name)
        overrides["parser"] = f"parser: {parser_style}"

        # Authors from pyproject.toml
        authors_yaml = self._format_authors_yaml(self._extract_authors_from_pyproject())
        if authors_yaml:
            overrides["authors"] = authors_yaml

        # Discover exports
        exports = self._get_package_exports(importable_name)
        if not exports:
            print(
                "Warning: Could not discover exports; edit the commented "
                "'reference:' section in great-docs.yml to list your API."
            )
            print("Testing dynamic introspection mode...")
            dynamic_mode = self._detect_dynamic_mode(importable_name)
            overrides["dynamic"] = f"dynamic: {'true' if dynamic_mode else 'false'}"
            config_path.write_text(create_default_config(overrides), encoding="utf-8")
            print(f"Created {config_path}")
            return True

        # Categorize exports (this also detects cyclic aliases)
        print("Categorizing API objects...")
        categories = self._categorize_api_objects(importable_name, exports)

        cyclic_alias_count = categories.get("cyclic_alias_count", 0)
        if cyclic_alias_count > 0:
            print(
                f"Detected {cyclic_alias_count} cyclic alias(es), using dynamic: false"
            )  # pragma: no cover
            dynamic_mode = False  # pragma: no cover
        else:
            print("Testing dynamic introspection mode...")
            dynamic_mode = self._detect_dynamic_mode(importable_name)
        overrides["dynamic"] = f"dynamic: {'true' if dynamic_mode else 'false'}"

        reference_yaml = self._build_reference_yaml(categories)
        if "  - title:" in reference_yaml:
            overrides["reference"] = reference_yaml

        config_path.write_text(create_default_config(overrides), encoding="utf-8")
        print(f"Created {config_path}")
        return True

    def _build_reference_yaml(self, categories: dict) -> str:
        """
        Build the `reference:` YAML block from categorized exports

        Parameters
        ----------
        categories
            Dictionary from `_categorize_api_objects` with class-like and flat
            export groups plus method-count metadata.

        Returns
        -------
        str
            A YAML block beginning with `reference:`; bare `reference:` when no
            category has items.
        """
        lines = ["reference:"]

        class_methods = categories.get("class_methods", {})
        class_method_names = categories.get("class_method_names", {})
        large_classes: list[str] = []
        has_prev_section = False

        _class_like_sections = [
            ("classes", "Classes", "Main classes provided by the package"),
            ("dataclasses", "Dataclasses", "Dataclass definitions"),
            ("abstract_classes", "Abstract Classes", "Abstract base classes"),
            ("protocols", "Protocols", "Protocol / structural-typing interfaces"),
        ]

        for cat_key, title, desc in _class_like_sections:
            items = categories.get(cat_key, [])
            if not items:
                continue
            if has_prev_section:
                lines.append("")
            lines.append(f"  - title: {title}")
            lines.append(f"    desc: {desc}")
            lines.append("    contents:")
            for class_name in sorted(items):
                method_count = class_methods.get(class_name, 0)
                if self._config.should_split_methods(method_count):
                    lines.append(f"      - name: {class_name}")
                    lines.append(f"        members: false  # {method_count} methods listed below")
                    large_classes.append(class_name)
                elif method_count > 0:
                    lines.append(f"      - {class_name}  # {method_count} method(s)")
                else:
                    lines.append(f"      - {class_name}")
            has_prev_section = True

        for class_name in large_classes:
            method_names = class_method_names.get(class_name, [])
            if method_names:
                lines.append("")
                lines.append(f"  - title: {class_name} Methods")
                lines.append(f"    desc: Methods for the {class_name} class")
                lines.append("    contents:")
                for method_name in method_names:
                    lines.append(f"      - {class_name}.{method_name}")

        _flat_sections = [
            ("enums", "Enumerations", "Enumeration types"),
            ("exceptions", "Exceptions", "Exception classes"),
            ("namedtuples", "Named Tuples", "Named tuple definitions"),
            ("typeddicts", "Typed Dicts", "TypedDict definitions"),
            ("functions", "Functions", "Utility functions"),
            ("async_functions", "Async Functions", "Asynchronous functions"),
            ("constants", "Constants", "Module-level constants and data"),
            ("type_aliases", "Type Aliases", "Type alias definitions"),
            ("other", "Other", "Additional exports"),
        ]

        for cat_key, title, desc in _flat_sections:
            items = categories.get(cat_key, [])
            if not items:
                continue
            if has_prev_section:
                lines.append("")
            lines.append(f"  - title: {title}")
            lines.append(f"    desc: {desc}")
            lines.append("    contents:")
            for name in sorted(items):
                lines.append(f"      - {name}")
            has_prev_section = True

        return "\n".join(lines)

    def _find_index_source_file(self) -> tuple[Path | None, list[str]]:
        """
        Find the best source file for index.qmd based on priority.

        Priority order (highest to lowest):
        1. index.qmd in project root
        2. index.md in project root
        3. README.md in project root
        4. README.rst in project root (converted to Markdown via pandoc)

        Returns
        -------
        tuple[Path | None, list[str]]
            A tuple of (source_file_path, warnings_list).
            source_file_path is None if no suitable file is found.
        """
        package_root = self._find_package_root()
        warnings = []

        # Candidates in priority order
        candidates = [
            package_root / "index.qmd",
            package_root / "index.md",
            package_root / "README.md",
            package_root / "README.rst",
        ]

        found = [c for c in candidates if c.exists()]

        if not found:
            return None, warnings

        winner = found[0]

        # Warn if multiple candidates exist
        if len(found) > 1:
            others = ", ".join(f.name for f in found[1:])
            warnings.append(
                f"⚠️  Multiple index source files detected. Using {winner.name} (ignoring {others})"
            )

        return winner, warnings

    def _convert_rst_to_markdown(self, rst_path: Path) -> str:
        """
        Convert a reStructuredText file to Markdown using Quarto's bundled pandoc.

        Falls back to the raw RST content if pandoc conversion fails.

        Parameters
        ----------
        rst_path
            Path to the `.rst` file.

        Returns
        -------
        str
            The converted Markdown content.
        """
        import shutil
        import subprocess

        # Try 'quarto pandoc' first (always available when quarto is installed),
        # then fall back to standalone 'pandoc'
        pandoc_cmd = None
        if shutil.which("quarto"):
            pandoc_cmd = ["quarto", "pandoc"]
        elif shutil.which("pandoc"):
            pandoc_cmd = ["pandoc"]

        if pandoc_cmd is None:
            print("   ⚠️  pandoc not found; using raw RST content for landing page")
            return rst_path.read_text(encoding="utf-8")

        try:
            result = subprocess.run(
                [*pandoc_cmd, str(rst_path), "-f", "rst", "-t", "markdown", "--wrap=none"],
                capture_output=True,
                **TEXT_MODE_KWARGS,
                timeout=30,
            )
            if result.returncode == 0:
                return result.stdout
            else:
                print(f"   ⚠️  pandoc conversion failed: {result.stderr.strip()}")
                print("   Falling back to raw RST content")
                return rst_path.read_text(encoding="utf-8")
        except Exception as e:
            print(f"   ⚠️  pandoc conversion error: {e}")
            print("   Falling back to raw RST content")
            return rst_path.read_text(encoding="utf-8")

    def _generate_landing_page_content(self, metadata: dict) -> str:
        """
        Generate landing page content from package metadata.

        When no README.md, index.md, or index.qmd is available, this method
        creates a tasteful landing page drawn from package metadata including
        the package name, description, installation instructions, and
        navigation links to available documentation sections.

        Parameters
        ----------
        metadata
            Dictionary of package metadata from `_get_package_metadata()`.

        Returns
        -------
        str
            The generated Markdown content for the landing page body (not including YAML frontmatter
            or sidebar as those are added by the caller).
        """
        package_name = self._detect_package_name() or "Package"
        description = metadata.get("description", "")

        lines = []
        lines.append(f"## {package_name}")
        lines.append("")

        if description:
            lines.append(description)
            lines.append("")

        # Installation section
        install_name = package_name
        lines.append("### Installation")
        lines.append("")
        lines.append("```bash")
        lines.append(f"pip install {install_name}")
        lines.append("```")
        lines.append("")

        # Get Started - link to available sections
        nav_items = []

        # API Reference (if documentable exports exist)
        if self._has_api_reference:
            nav_items.append("- [API Reference](reference/index.qmd) — Full API documentation")

        # Check for User Guide (look at source directory since build hasn't run yet)
        ug_source = self._discover_user_guide()
        if ug_source:
            nav_items.append("- [User Guide](user-guide/index.qmd) — Guides and tutorials")

        if nav_items:
            lines.append("### Get Started")
            lines.append("")
            lines.extend(nav_items)
            lines.append("")

        return "\n".join(lines) + "\n"

    def _extract_badges_from_content(self, content: str) -> tuple[list[dict], str, dict]:
        """Extract badge image-links and hero elements from README/index content.

        Supports two common README layouts:

        1. **Top-of-file badges** — bare `[![alt](img)](url)` lines right
           after the first heading.
        2. **Centered-div badges** — badges inside a
           `<div align="center">` block. When this layout is detected, the entire
           centered block (hero image, italic tagline, and badges) is
           stripped because the hero section replaces it. The logo image
           and tagline text are returned so the hero section can use them.

        Parameters
        ----------
        content
            Raw Markdown content (after any YAML frontmatter has been
            stripped).

        Returns
        -------
        tuple[list[dict], str, dict]
            `(badges, cleaned_content, hero_extras)` — a list of badge
            dicts (keys: `url`, `img`, `alt`), the content with the
            badge block removed, and a dict of extracted hero elements
            (optional keys: `logo_url`, `tagline`).
        """
        import re

        badge_hosts = (
            "img.shields.io",
            "badge.fury.io",
            "github.com/",  # GitHub Actions badge URLs contain /badge.svg
            "codecov.io",
            "readthedocs.org",
            "repostatus.org",
            "pepy.tech",
            "pyopensci.org",
            "zenodo.org/badge",
            "www.contributor-covenant.org",
            "deepwiki.com",
            "static.pepy.tech",
        )

        # Pattern: [![alt](img_url)](link_url)
        md_badge_re = re.compile(r"\[!\[([^\]]*)\]\(([^)]+)\)\]\(([^)]+)\)")

        # ── Strategy 1: centered-div badges ─────────────────────────
        # Look for <div align="center"> containing badge markdown.
        center_div_re = re.compile(r'^<div\s+align=["\']center["\']', re.IGNORECASE)
        # Patterns for extracting hero elements from centered divs
        # HTML <a><img></a> (linked logo image)
        linked_img_re = re.compile(
            r'<a\s[^>]*href="([^"]+)"[^>]*>\s*<img\s[^>]*src="([^"]+)"[^>]*/?>\s*</a>',
            re.IGNORECASE,
        )
        # Bare <img> tag (logo image without link)
        bare_img_re = re.compile(
            r'<img\s[^>]*src="([^"]+)"[^>]*/?>',
            re.IGNORECASE,
        )
        # Italic tagline: *text* or _text_
        italic_re = re.compile(r"^[*_](.+)[*_]$")

        lines = content.split("\n")
        div_start = None
        div_end = None

        for i, line in enumerate(lines):
            if center_div_re.match(line.strip()):
                # Found a centered div — scan forward for badges and hero elements
                div_badges: list[dict] = []
                hero_extras: dict = {}
                for j in range(i + 1, len(lines)):
                    sline = lines[j].strip()
                    if sline == "</div>":
                        div_end = j
                        break

                    # Collect badges
                    for alt, img, url in md_badge_re.findall(sline):
                        if any(host in img for host in badge_hosts):
                            div_badges.append({"alt": alt, "img": img, "url": url})

                    # Extract logo image (HTML <a><img> or bare <img>),
                    # but skip lines that are markdown badge patterns
                    if "logo_url" not in hero_extras and not md_badge_re.search(sline):
                        linked_match = linked_img_re.search(sline)
                        if linked_match:
                            hero_extras["logo_url"] = linked_match.group(2)
                        else:
                            bare_match = bare_img_re.search(sline)
                            if bare_match:
                                hero_extras["logo_url"] = bare_match.group(1)

                    # Extract italic tagline
                    if "tagline" not in hero_extras:
                        italic_match = italic_re.match(sline)
                        if italic_match:
                            hero_extras["tagline"] = italic_match.group(1)

                if div_badges and div_end is not None:
                    div_start = i
                    # Remove the entire centered div block
                    remaining = lines[:div_start] + lines[div_end + 1 :]
                    cleaned = "\n".join(remaining)
                    # Collapse excess blank lines at the splice point
                    cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
                    return div_badges, cleaned, hero_extras

                # Not a badge div — keep scanning for other centered divs
                continue

        # ── Strategy 2: top-of-file badges ──────────────────────────
        badges: list[dict] = []
        badge_end_idx = 0

        started = False
        for i, line in enumerate(lines):
            stripped = line.strip()

            if not started and not stripped:
                continue  # pragma: no cover

            if not started and stripped.startswith("# "):
                started = True
                continue

            if not started and stripped:
                started = True

            if started and not stripped:
                has_more_badges = False
                for j in range(i + 1, min(i + 3, len(lines))):
                    if md_badge_re.search(lines[j]):
                        has_more_badges = True
                        break
                if has_more_badges:
                    badge_end_idx = i + 1
                    continue
                else:
                    break

            found_in_line = md_badge_re.findall(stripped)
            if found_in_line:
                for alt, img, url in found_in_line:
                    if any(host in img for host in badge_hosts):
                        badges.append({"alt": alt, "img": img, "url": url})
                badge_end_idx = i + 1
            elif started:
                break

        if badges and badge_end_idx > 0:
            kept_before: list[str] = []
            for i in range(badge_end_idx):
                stripped = lines[i].strip()
                if stripped.startswith("# "):
                    kept_before.append(lines[i])

            remaining = lines[badge_end_idx:]
            cleaned = "\n".join(kept_before + remaining)
            cleaned = cleaned.lstrip("\n")
            if kept_before:
                heading = kept_before[0]
                rest = "\n".join(remaining).lstrip("\n")
                cleaned = heading + "\n\n" + rest
        else:
            cleaned = content

        return badges, cleaned, {}

    def _build_hero_section(
        self,
        readme_content: str | None = None,
    ) -> tuple[str, str | None]:
        """Build the hero section HTML and optionally clean badges from content.

        Parameters
        ----------
        readme_content
            Raw README/index Markdown content. When provided and badge
            auto-extraction is enabled, badges are extracted and the cleaned
            content is returned.

        Returns
        -------
        tuple[str, str | None]
            `(hero_html, cleaned_content)` — the hero HTML block (empty
            string when hero is disabled) and the cleaned README content
            (`None` when no cleaning was performed).
        """
        if self._config.hero_explicitly_disabled:
            return "", None

        # ── Early badge + hero extraction from README ───────────────
        # This must happen before the auto-enable check so we can
        # use README hero elements (logo, tagline) to decide whether
        # to auto-enable.
        badges_config = self._config.hero_badges
        badges: list[dict] = []
        cleaned_content = None
        readme_hero: dict = {}

        if badges_config == "auto" and readme_content:
            badges, cleaned, readme_hero = self._extract_badges_from_content(readme_content)
            if badges:
                cleaned_content = cleaned
        elif isinstance(badges_config, list):
            badges = badges_config

        if not self._config.hero_enabled:
            # Not explicitly enabled and no config-level logo — auto-enable
            # if hero logo files are detected on disk or README has hero content.
            if self._detect_hero_logo() is None and not readme_hero:
                return "", None

        metadata = self._get_package_metadata()

        # ── Logo ────────────────────────────────────────────────────
        logo_config = self._config.hero_logo
        logo_height = self._config.hero_logo_height
        logo_html = ""

        # Fallback chain: explicit hero.logo → auto-detected hero logo
        # → explicit top-level logo → auto-detected navbar logo
        if logo_config is None:
            logo_config = self._detect_hero_logo()
        if logo_config is None:
            logo_config = self._config.logo
        if logo_config is None:
            logo_config = self._detect_logo()
        if logo_config is None and readme_hero.get("logo_url"):
            logo_config = readme_hero["logo_url"]  # pragma: no cover
        if logo_config is False:
            logo_config = None  # pragma: no cover

        # Copy auto-detected logo files into the build dir so the HTML
        # references resolve.  Files under assets/ are already copied by
        # _copy_assets; root-level files need an explicit copy.
        if logo_config and isinstance(logo_config, dict):
            package_root = self._find_package_root()
            for key in ("light", "dark"):
                rel = logo_config.get(key)
                if rel and not rel.startswith("assets/"):
                    src = package_root / rel
                    if src.is_file():
                        dest = self.project_path / rel
                        dest.parent.mkdir(parents=True, exist_ok=True)
                        if not dest.exists():
                            shutil.copy2(src, dest)

        if logo_config:
            if isinstance(logo_config, dict):
                light = logo_config.get("light")
                dark = logo_config.get("dark")
                alt = logo_config.get("alt", "Logo")

                if light and dark and light != dark:
                    logo_html = (
                        f'<img src="{light}" alt="{alt}" class="gd-hero-logo gd-only-light" style="max-height:{logo_height}" />\n'
                        f'<img src="{dark}" alt="{alt}" class="gd-hero-logo gd-only-dark" style="max-height:{logo_height}" />'
                    )
                elif light:  # pragma: no cover
                    logo_html = f'<img src="{light}" alt="{alt}" class="gd-hero-logo" style="max-height:{logo_height}" />'  # pragma: no cover
            elif isinstance(logo_config, str):
                logo_html = f'<img src="{logo_config}" alt="Logo" class="gd-hero-logo" style="max-height:{logo_height}" />'

        # ── Name ────────────────────────────────────────────────────
        hero_name = self._config.hero_name
        if hero_name is None:
            # Fallback to package name from metadata
            hero_name = metadata.get("name") or self._detect_package_name()
        name_html = f'<p class="gd-hero-name">{hero_name}</p>' if hero_name else ""

        # ── Tagline ─────────────────────────────────────────────────
        tagline = self._config.hero_tagline
        if tagline is None:
            tagline = readme_hero.get("tagline") or metadata.get("description", "")
        tagline_html = f'<p class="gd-hero-tagline">{tagline}</p>' if tagline else ""

        # ── Badges ──────────────────────────────────────────────────
        badges_html = ""
        if badges:
            badge_items = []
            for b in badges:
                alt = b.get("alt", "")
                img = b.get("img", "")
                url = b.get("url", "")
                if url:
                    badge_items.append(f'<a href="{url}"><img alt="{alt}" src="{img}" /></a>')
                else:
                    badge_items.append(f'<img alt="{alt}" src="{img}" />')
            badges_html = '<div class="gd-hero-badges">\n' + "\n".join(badge_items) + "\n</div>"

        # ── Assemble ────────────────────────────────────────────────
        parts = [p for p in [logo_html, name_html, tagline_html, badges_html] if p]
        if not parts:
            return "", None  # pragma: no cover

        # ── Starfield canvas (optional) ─────────────────────────────
        starfield_html = ""
        if self._config.hero_starfield:
            starfield_html = '<canvas id="gd-starfield"></canvas>\n'

        hero_html = '<div class="gd-hero">\n' + starfield_html + "\n".join(parts) + "\n</div>\n\n"

        # Append starfield script tag after the hero div so the canvas
        # is in the DOM when the script executes.
        if self._config.hero_starfield:
            hero_html += '<script src="starfield.js"></script>\n\n'

        return hero_html, cleaned_content

    def _build_metadata_margin(self) -> str:
        """
        Build the `.column-margin` metadata sidebar content for the homepage.

        Generates Markdown for the right-hand margin containing project metadata
        in five sections: Links, AI / Agents, Developers, Community, and Meta.

        As a side effect, creates `contributing.qmd`, `code-of-conduct.qmd`,
        `roadmap.qmd`, and `security.qmd` in the project directory if the
        corresponding source files exist.

        Returns
        -------
        str
            The margin content as a Markdown string (without the `::: {.column-margin}`
            wrapper). Empty string if no metadata is available.
        """
        package_root = self._find_package_root()
        metadata = self._get_package_metadata()

        # Determine which supporting pages exist (already created by _create_index_from_readme)
        license_qmd = self.project_path / "license.qmd"
        license_link = "license.qmd" if license_qmd.exists() else None
        citation_qmd = self.project_path / "citation.qmd"
        citation_link = "citation.qmd" if citation_qmd.exists() else None

        margin_sections: list[str] = []

        from ._translations import get_translation

        lang = self._config.language

        # ── 1. Links ─────────────────────────────────────────────────────
        links_items: list[str] = []

        # Lucide arrow-up-right icon (indicates external link)
        _ext_arrow = (
            '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" '
            'fill="none" stroke="currentColor" stroke-width="2" '
            'stroke-linecap="round" stroke-linejoin="round" '
            'style="vertical-align: -0.05em; margin-left: 0em; margin-top: 0.1em;" '
            'viewBox="0 0 24 24">'
            '<path d="M7 7h10v10"/><path d="M7 17 17 7"/></svg>'
        )

        package_name = self._detect_package_name()
        pypi_setting = self._config.pypi
        if pypi_setting is not False:
            if isinstance(pypi_setting, str):
                pypi_url = pypi_setting
            else:
                pypi_url = f"https://pypi.org/project/{package_name}/" if package_name else None
            if pypi_url:
                links_items.append(
                    f'<a href="{pypi_url}">{get_translation("view_on_pypi", lang)}{_ext_arrow}</a><br>'
                )

        if metadata.get("urls"):
            urls = metadata["urls"]
            url_map = {
                "homepage": None,
                "repository": get_translation("browse_source_code", lang),
                "bug_tracker": get_translation("report_a_bug", lang),
                "documentation": None,
            }
            for name, url in urls.items():
                name_lower = name.lower().replace(" ", "_")
                display_name = url_map.get(name_lower, name.replace("_", " ").title())
                if display_name:
                    links_items.append(f'<a href="{url}">{display_name}{_ext_arrow}</a><br>')

        if links_items:
            margin_sections.append(f"#### {get_translation('links', lang)}\n")
            margin_sections.extend(links_items)

        # ── 2. AI / Agents ───────────────────────────────────────────────
        ai_items: list[str] = []
        if self._config.skill_enabled:
            # Determine if skills are hand-written/curated (intensify effect)
            _curated_skill = bool(self._config.skill_file or self._config.skill_skills)
            if not _curated_skill:
                pkg_name = self._detect_package_name() or ""
                for cand in [pkg_name.replace("_", "-"), pkg_name]:
                    if cand and (package_root / "skills" / cand / "SKILL.md").exists():
                        _curated_skill = True
                        break

            _sparkle_class = "gd-sparkle-curated" if _curated_skill else "gd-sparkle"
            _sparkle_svg = (
                '<svg class="' + _sparkle_class + '" xmlns="http://www.w3.org/2000/svg" '
                'width="0.85em" height="0.85em" viewBox="0 0 24 24" fill="none" '
                'stroke="currentColor" stroke-width="2" stroke-linecap="round" '
                'stroke-linejoin="round" style="vertical-align: -0.1em; margin-left: 0.25em;">'
                '<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.582a.5.5 0 0 1 0 .963L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/>'
                '<path d="M20 3v4"/><path d="M22 5h-4"/></svg>'
            )
            ai_items.append(f'<a href="skills.html">Skills{_sparkle_svg}</a><br>')
        ai_items.append("[llms.txt](llms.txt)<br>")
        ai_items.append("[llms-full.txt](llms-full.txt)<br>")

        margin_sections.append(f"\n#### {get_translation('ai_agents', lang)}\n")
        margin_sections.extend(ai_items)

        # ── 3. Developers (Authors + Funding) ────────────────────────────
        authors_to_display = metadata.get("rich_authors") or metadata.get("authors", [])
        funding = metadata.get("funding")
        has_funding = funding and isinstance(funding, dict) and funding.get("name")

        if authors_to_display or has_funding:
            margin_sections.append(f"\n#### {get_translation('developers', lang)}\n")

            # Authors
            fallback_github = None
            if metadata.get("urls"):
                repo_url = metadata["urls"].get("repository", "") or metadata["urls"].get(
                    "Repository", ""
                )
                if "github.com/" in repo_url:
                    parts = repo_url.rstrip("/").split("github.com/")
                    if len(parts) > 1:
                        username_part = parts[1].split("/")[0]
                        if username_part:
                            fallback_github = username_part

            dev_idx = 0
            for author in authors_to_display:
                if isinstance(author, dict):
                    name = author.get("name", "")
                    email = author.get("email", "")
                    role = author.get("role", "")
                    affiliation = author.get("affiliation", "")
                    github = author.get("github", "")
                    homepage = author.get("homepage", "") or author.get("url", "")
                    orcid = author.get("orcid", "")

                    author_html_parts = []
                    name_html = (
                        f'<span><p><strong style="padding-bottom: 4px;">{name}</strong></p></span>'
                    )
                    author_html_parts.append(name_html)

                    if role:
                        role_html = (
                            f'<span style="margin-top: -0.15em; display: block;">'
                            f"<p><small>{role}</small></p></span>"
                        )
                        author_html_parts.append(role_html)

                    if affiliation:
                        affiliation_html = (
                            f'<span><p><small style="margin-top: -0.15em; display: block;">'
                            f"{affiliation}</small></p></span>"
                        )
                        author_html_parts.append(affiliation_html)

                    icon_links = []
                    if email:
                        icon_links.append(
                            f'<a href="mailto:{email}" title="Email"><i class="bi bi-envelope-fill"></i></a>'
                        )
                    if github:
                        icon_links.append(
                            f'<a href="https://github.com/{github}" title="GitHub"><i class="bi bi-github"></i></a>'
                        )
                    elif fallback_github:
                        icon_links.append(
                            f'<a href="https://github.com/{fallback_github}" title="GitHub"><i class="bi bi-github"></i></a>'
                        )
                    if homepage:
                        icon_links.append(
                            f'<a href="{homepage}" title="Website"><i class="bi bi-globe"></i></a>'
                        )
                    if orcid:
                        orcid_url = (
                            orcid if orcid.startswith("http") else f"https://orcid.org/{orcid}"
                        )
                        icon_links.append(
                            f'<a href="{orcid_url}" title="ORCID"><i class="fa-brands fa-orcid"></i></a>'
                        )

                    if icon_links:
                        icon_html = (
                            '<span style="margin-top: -0.15em; display: block;">'
                            + " ".join(icon_links)
                            + "</span>"
                        )
                        author_html_parts.append(icon_html)

                    author_div_content = "".join(author_html_parts)
                    if dev_idx == 0:
                        margin_sections.append(f"<div>{author_div_content}</div>")
                    else:
                        margin_sections.append(  # pragma: no cover
                            f'<div style="padding-top: 10px;">{author_div_content}</div>'
                        )
                    dev_idx += 1

            # Funding (rendered as another entry in the Developers section)
            if has_funding:
                funder_name = funding.get("name", "")
                roles = funding.get("roles", [])
                homepage = funding.get("homepage", "")
                ror_url = funding.get("ror", "")

                funder_html_parts = []
                name_html = f'<span><p><strong style="padding-bottom: 4px;">{funder_name}</strong></p></span>'
                funder_html_parts.append(name_html)

                if roles:
                    roles_text = ", ".join(roles)
                    roles_html = (
                        f'<span style="margin-top: -0.15em; display: block;">'
                        f"<p><small>{roles_text}</small></p></span>"
                    )
                    funder_html_parts.append(roles_html)

                icon_links = []
                if homepage:
                    icon_links.append(
                        f'<a href="{homepage}" title="Website"><i class="bi bi-globe"></i></a>'
                    )
                if ror_url:
                    ror_icon = (
                        '<svg class="ror-sidebar-icon" viewBox="0 0 164 118" '
                        'xmlns="http://www.w3.org/2000/svg" '
                        'style="width: 1em; height: 0.75em; vertical-align: -0.05em; fill: currentColor;">'
                        '<g transform="translate(-0.945,-0.815)">'
                        '<path d="M68.65,4.16L56.52,22.74L44.38,4.16L68.65,4.16Z" style="fill:rgb(83,186,161);"/>'
                        '<path d="M119.41,4.16L107.28,22.74L95.14,4.16L119.41,4.16Z" style="fill:rgb(83,186,161);"/>'
                        '<path d="M44.38,115.47L56.52,96.88L68.65,115.47L44.38,115.47Z" style="fill:rgb(83,186,161);"/>'
                        '<path d="M95.14,115.47L107.28,96.88L119.41,115.47L95.14,115.47Z" style="fill:rgb(83,186,161);"/>'
                        '<path d="M145.53,63.71C149.83,62.91 153.1,61 155.33,57.99C157.57,54.98 158.68,51.32 158.68,47.03C158.68,43.47 158.06,40.51 156.83,38.13C155.6,35.75 153.93,33.86 151.84,32.45C149.75,31.05 147.31,30.04 144.53,29.44C141.75,28.84 138.81,28.54 135.72,28.54L112.16,28.54L112.16,47.37C111.97,46.82 111.77,46.28 111.55,45.74C109.92,41.79 107.64,38.42 104.71,35.64C101.78,32.86 98.32,30.72 94.3,29.23C90.29,27.74 85.9,26.99 81.14,26.99C76.38,26.99 72,27.74 67.98,29.23C63.97,30.72 60.5,32.86 57.57,35.64C54.95,38.13 52.85,41.1 51.27,44.54C51.04,42.07 50.46,39.93 49.53,38.13C48.3,35.75 46.63,33.86 44.54,32.45C42.45,31.05 40.01,30.04 37.23,29.44C34.45,28.84 31.51,28.54 28.42,28.54L4.87,28.54L4.87,89.42L18.28,89.42L18.28,65.08L24.9,65.08L37.63,89.42L53.71,89.42L38.24,63.71C42.54,62.91 45.81,61 48.04,57.99C48.14,57.85 48.23,57.7 48.33,57.56C48.31,58.03 48.3,58.5 48.3,58.98C48.3,63.85 49.12,68.27 50.75,72.22C52.38,76.17 54.66,79.54 57.59,82.32C60.51,85.1 63.98,87.24 68,88.73C72.01,90.22 76.4,90.97 81.16,90.97C85.92,90.97 90.3,90.22 94.32,88.73C98.33,87.24 101.8,85.1 104.73,82.32C107.65,79.54 109.93,76.17 111.57,72.22C111.79,71.69 111.99,71.14 112.18,70.59L112.18,89.42L125.59,89.42L125.59,65.08L132.21,65.08L144.94,89.42L161.02,89.42L145.53,63.71ZM36.39,50.81C35.67,51.73 34.77,52.4 33.68,52.83C32.59,53.26 31.37,53.52 30.03,53.6C28.68,53.69 27.41,53.73 26.2,53.73L18.29,53.73L18.29,39.89L27.06,39.89C28.26,39.89 29.5,39.98 30.76,40.15C32.02,40.32 33.14,40.65 34.11,41.14C35.08,41.63 35.89,42.33 36.52,43.25C37.15,44.17 37.47,45.4 37.47,46.95C37.47,48.6 37.11,49.89 36.39,50.81ZM98.74,66.85C97.85,69.23 96.58,71.29 94.91,73.04C93.25,74.79 91.26,76.15 88.93,77.13C86.61,78.11 84.01,78.59 81.15,78.59C78.28,78.59 75.69,78.1 73.37,77.13C71.05,76.16 69.06,74.79 67.39,73.04C65.73,71.29 64.45,69.23 63.56,66.85C62.67,64.47 62.23,61.85 62.23,58.98C62.23,56.17 62.67,53.56 63.56,51.15C64.45,48.74 65.72,46.67 67.39,44.92C69.05,43.17 71.04,41.81 73.37,40.83C75.69,39.86 78.28,39.37 81.15,39.37C84.02,39.37 86.61,39.86 88.93,40.83C91.25,41.8 93.24,43.17 94.91,44.92C96.57,46.67 97.85,48.75 98.74,51.15C99.63,53.56 100.07,56.17 100.07,58.98C100.07,61.85 99.63,64.47 98.74,66.85ZM143.68,50.81C142.96,51.73 142.06,52.4 140.97,52.83C139.88,53.26 138.66,53.52 137.32,53.6C135.97,53.69 134.7,53.73 133.49,53.73L125.58,53.73L125.58,39.89L134.35,39.89C135.55,39.89 136.79,39.98 138.05,40.15C139.31,40.32 140.43,40.65 141.4,41.14C142.37,41.63 143.18,42.33 143.81,43.25C144.44,44.17 144.76,45.4 144.76,46.95C144.76,48.6 144.4,49.89 143.68,50.81Z" style="fill:currentColor;"/>'
                        "</g></svg>"
                    )
                    icon_links.append(
                        f'<a href="{ror_url}" title="Research Organization Registry">{ror_icon}</a>'
                    )

                if icon_links:
                    icon_html = (
                        '<span style="margin-top: -0.15em; display: block;">'
                        + " ".join(icon_links)
                        + "</span>"
                    )
                    funder_html_parts.append(icon_html)

                funder_div_content = "".join(funder_html_parts)
                if dev_idx == 0:
                    margin_sections.append(f"<div>{funder_div_content}</div>")
                else:
                    margin_sections.append(  # pragma: no cover
                        f'<div style="padding-top: 10px;">{funder_div_content}</div>'
                    )

        # ── 4. Community (Contributing, CoC, Roadmap, License, Citation) ──
        community_items: list[str] = []

        # Check for CONTRIBUTING.md in root or .github directory
        contributing_path = package_root / "CONTRIBUTING.md"
        if not contributing_path.exists():
            contributing_path = package_root / ".github" / "CONTRIBUTING.md"

        # Check for CODE_OF_CONDUCT.md in root or .github directory
        coc_path = package_root / "CODE_OF_CONDUCT.md"
        if not coc_path.exists():
            coc_path = package_root / ".github" / "CODE_OF_CONDUCT.md"

        if contributing_path.exists():
            community_items.append(
                f"[{get_translation('contributing_guide', lang)}](contributing.qmd)<br>"
            )
            with open(contributing_path, "r", encoding="utf-8") as f:
                contributing_content = f.read()

            lines = contributing_content.split("\n")
            if lines and lines[0].startswith("# "):
                contributing_content = "\n".join(lines[1:]).lstrip()

            contributing_qmd = self.project_path / "contributing.qmd"
            contributing_qmd_content = f"""---
title: "Contributing"
---

{contributing_content}
"""
            with open(contributing_qmd, "w", encoding="utf-8") as f:
                f.write(contributing_qmd_content)

        if coc_path.exists():
            community_items.append(
                f"[{get_translation('code_of_conduct', lang)}](code-of-conduct.qmd)<br>"
            )
            with open(coc_path, "r", encoding="utf-8") as f:
                coc_content = f.read()

            lines = coc_content.split("\n")
            if lines and lines[0].startswith("# "):
                coc_content = "\n".join(lines[1:]).lstrip()

            coc_qmd = self.project_path / "code-of-conduct.qmd"
            coc_qmd_content = f"""---
title: "Code of Conduct"
---

{coc_content}
"""
            with open(coc_qmd, "w", encoding="utf-8") as f:
                f.write(coc_qmd_content)

        # Check for SECURITY.md in root or .github directory
        security_path = package_root / "SECURITY.md"
        if not security_path.exists():
            security_path = package_root / ".github" / "SECURITY.md"

        # Check for ROADMAP.md in root
        roadmap_path = package_root / "ROADMAP.md"

        if roadmap_path.exists():
            community_items.append(f"[{get_translation('project_roadmap', lang)}](roadmap.qmd)<br>")
            with open(roadmap_path, "r", encoding="utf-8") as f:
                roadmap_content = f.read()

            lines = roadmap_content.split("\n")
            if lines and lines[0].startswith("# "):
                roadmap_content = "\n".join(lines[1:]).lstrip()

            roadmap_qmd = self.project_path / "roadmap.qmd"
            roadmap_qmd_content = f"""---
title: "Roadmap"
---

{roadmap_content}
"""
            with open(roadmap_qmd, "w", encoding="utf-8") as f:
                f.write(roadmap_qmd_content)

        if security_path.exists() and self._config.show_security:
            community_items.append(
                f"[{get_translation('security_policy', lang)}](security.qmd)<br>"
            )
            with open(security_path, "r", encoding="utf-8") as f:
                security_content = f.read()

            lines = security_content.split("\n")
            if lines and lines[0].startswith("# "):
                security_content = "\n".join(lines[1:]).lstrip()

            security_qmd = self.project_path / "security.qmd"
            security_qmd_content = f"""---
title: "Security Policy"
---

{security_content}
"""
            with open(security_qmd, "w", encoding="utf-8") as f:
                f.write(security_qmd_content)

        # License (folded into Community) — "Full license" link + small SPDX badge
        from ._license import get_license_info

        license_id = metadata.get("license", "")
        license_info = get_license_info(license_id) if license_id else None

        if license_link:
            _full_license = get_translation("full_license", lang)
            if license_info is not None:
                # Underlined link + small SPDX badge with tooltip
                spdx_html = (  # pragma: no cover
                    f'<a href="{license_link}">{_full_license}</a> '
                    f'<span title="{license_info.full_name}" '
                    f'class="gd-spdx-badge">{license_info.spdx_id}</span>'
                )
                community_items.append(f"{spdx_html}<br>")  # pragma: no cover
            else:
                community_items.append(f"[{_full_license}]({license_link})<br>")

        # Citation (folded into Community)
        if citation_link:
            pkg_display = package_name or self._detect_package_name() or "this package"
            _citing = get_translation("citing", lang)
            community_items.append(f"[{_citing} {pkg_display}]({citation_link})<br>")

        if community_items:
            margin_sections.append(f"\n#### {get_translation('community', lang)}\n")
            margin_sections.extend(community_items)

        # ── 5. Meta ──────────────────────────────────────────────────────
        meta_items: list[str] = []
        if metadata.get("requires_python"):
            _requires = get_translation("requires_python", lang)
            meta_items.append(f"**{_requires}:** Python `{metadata['requires_python']}`")

        if metadata.get("optional_dependencies"):
            extras = list(metadata["optional_dependencies"].keys())
            if extras:
                extras_formatted = ", ".join(f"`{extra}`" for extra in extras)
                _provides = get_translation("provides_extra", lang)
                meta_items.append(f"**{_provides}:** {extras_formatted}")

        if self._config.tags_enabled and self._config.tags_index_page:
            tags_label = get_translation("site_tags", lang)
            meta_items.append(f"[{tags_label}](tags/index.html)")

        # Package Info page link
        pkg_info_qmd = self.project_path / "package-info.qmd"
        if pkg_info_qmd.exists():
            _pkg_info_label = get_translation("package_info", lang)
            meta_items.append(f"[{_pkg_info_label}](package-info.html)")

        if meta_items:
            margin_sections.append(f"\n#### {get_translation('meta', lang)}\n")
            margin_sections.append("<br>\n".join(meta_items))

        return "\n".join(margin_sections) if margin_sections else ""

    @staticmethod
    def _bump_heading_levels(content: str) -> str:
        """
        Bump every Markdown heading up by one level, skipping fenced code blocks.

        Headings (`# ` … `###### `) are promoted one level deeper so that the
        embedded README body sits below the homepage wrapper. Lines inside
        fenced code blocks (delimited by ``` or ~~~) are left untouched, which
        prevents the bump from turning Quarto cell options like
        `#| code-fold: true` into `##| code-fold: true` (a plain comment Quarto
        ignores).
        """
        import re

        fence_re = re.compile(r"^\s*(`{3,}|~{3,})")
        heading_re = re.compile(r"^(#{1,6})(\s+)")

        lines = content.split("\n")
        in_fence = False
        fence_char = ""
        for i, line in enumerate(lines):
            fence_match = fence_re.match(line)
            if fence_match:
                marker = fence_match.group(1)
                if not in_fence:
                    in_fence = True
                    fence_char = marker[0]
                elif marker[0] == fence_char:
                    in_fence = False
                    fence_char = ""
                continue

            if in_fence:
                continue

            if heading_re.match(line):
                lines[i] = "#" + line

        return "\n".join(lines)

    def _create_index_from_readme(self, force_rebuild: bool = False) -> None:
        """
        Create or update index.qmd from the best available source file.

        Source file priority (highest to lowest):
        1. index.qmd in project root
        2. index.md in project root
        3. README.md in project root
        4. README.rst in project root (converted to Markdown via pandoc)

        If none of these exist, a landing page is auto-generated from package
        metadata (name, description, install command, navigation links).
        Includes a metadata sidebar with package information (license, authors, links, etc.)

        Parameters
        ----------
        force_rebuild
            If `True`, always rebuild `index.qmd` even if it exists. Used by the build command to
            sync with source file changes.
        """
        package_root = self._find_package_root()

        # Always create license.qmd if LICENSE file exists
        license_path = package_root / "LICENSE"
        license_link = None
        if license_path.exists():
            license_qmd = self.project_path / "license.qmd"
            with open(license_path, "r", encoding="utf-8") as f:
                license_content = f.read()

            # Build optional license-features section from SPDX data
            from ._license import (
                BADGE_TOOLTIP_KEYS,
                build_license_features_html,
                get_license_info,
            )
            from ._translations import get_translation

            metadata_for_license = self._get_package_metadata()
            license_id = metadata_for_license.get("license", "")
            license_features_html = ""
            if license_id:
                license_info = get_license_info(license_id)  # pragma: no cover
                if license_info is not None:  # pragma: no cover
                    _lang = self._config.language  # pragma: no cover
                    features_label = get_translation("license_features", _lang)  # pragma: no cover
                    # Build translated tooltips for each badge
                    _tooltips = {  # pragma: no cover
                        label: get_translation(key, _lang)
                        for label, key in BADGE_TOOLTIP_KEYS.items()
                    }
                    license_features_html = build_license_features_html(  # pragma: no cover
                        license_info,
                        features_label=features_label,
                        permissions_label=get_translation("license_permissions", _lang),
                        conditions_label=get_translation("license_conditions", _lang),
                        limitations_label=get_translation("license_limitations", _lang),
                        tooltips=_tooltips,
                    )

            # Wrap all HTML in raw blocks so Quarto passes them through verbatim
            features_block = ""
            if license_features_html:
                features_block = (
                    "```{=html}\n" + license_features_html + "\n```\n\n"
                )  # pragma: no cover

            license_qmd_content = f"""---
title: "License"
toc: false
sidebar: false
page-layout: full
body-classes: "gd-license-page"
anchor-sections: false
---

{features_block}```{{=html}}
<pre class="gd-skills-raw">
```

````markdown
{license_content}
````

```{{=html}}
</pre>
```
"""
            with open(license_qmd, "w", encoding="utf-8") as f:
                f.write(license_qmd_content)
            print(f"Created {license_qmd}")
            license_link = "license.qmd"

        # Always create citation.qmd if CITATION.cff exists
        citation_path = package_root / "CITATION.cff"
        citation_link = None
        if citation_path.exists():
            citation_qmd = self.project_path / "citation.qmd"

            # Get metadata first to access rich_authors and repo info
            metadata = self._get_package_metadata()

            # Parse CITATION.cff for structured data

            with open(citation_path, "r", encoding="utf-8") as f:
                citation_data = read_yaml(f)

            # Get package name for intro paragraph
            package_name = metadata.get("name", "this package")

            # Build GitHub link to CITATION.cff if repo URL is available
            repo_url = citation_data.get("repository-code") or citation_data.get("url")
            citation_file_link = ""
            if repo_url:
                # Ensure URL doesn't end with trailing slash
                repo_url = repo_url.rstrip("/")
                citation_file_link = f"{repo_url}/blob/main/CITATION.cff"

            # Build intro paragraph
            intro_paragraph = f"""
To cite {package_name} in publications, please use the following citation.
"""

            # Build Authors section
            authors_section = "## Authors\n\n"
            if citation_data.get("authors"):
                for author in citation_data["authors"]:
                    given = author.get("given-names", "")
                    family = author.get("family-names", "")
                    full_name = f"{given} {family}".strip()

                    # Get role from rich_authors if available
                    role = "Author"
                    if metadata.get("rich_authors"):
                        for rich_author in metadata["rich_authors"]:  # pragma: no cover
                            if rich_author.get("name") == full_name:  # pragma: no cover
                                role = rich_author.get("role", "Author")  # pragma: no cover
                                break  # pragma: no cover

                    authors_section += f"{full_name}. {role}.  \n"

            # Build Citation section with text and BibTeX
            citation_section = "## Citation\n\n"

            # Add link to CITATION.cff on GitHub if available
            if citation_file_link:
                citation_section += f"**Source:** [`CITATION.cff`]({citation_file_link})\n\n"
            else:
                citation_section += "**Source:** `CITATION.cff`\n\n"

            # Parse year from date-released field or use current year (used in multiple places)
            from datetime import datetime

            year = datetime.now().year
            if citation_data.get("date-released"):
                try:
                    date_released = citation_data["date-released"]
                    if isinstance(date_released, str):
                        year = datetime.fromisoformat(date_released).year
                    elif hasattr(date_released, "year"):  # pragma: no cover
                        year = date_released.year  # pragma: no cover
                except (ValueError, AttributeError):
                    pass  # Keep current year as fallback

            # Generate text citation
            if citation_data.get("authors"):
                # For text citation, use "et al." if more than 2 authors
                author_list = citation_data["authors"]
                if len(author_list) == 1:
                    author = author_list[0]
                    family = author.get("family-names", "")
                    given = author.get("given-names", "")
                    initial = given[0] if given else ""
                    authors_str = f"{family} {initial}" if initial else family
                elif len(author_list) == 2:
                    author_names = []
                    for author in author_list:
                        family = author.get("family-names", "")
                        given = author.get("given-names", "")
                        initial = given[0] if given else ""
                        author_names.append(f"{family} {initial}" if initial else family)
                    authors_str = " and ".join(author_names)
                else:
                    # More than 2 authors: use first author et al.
                    author = author_list[0]
                    family = author.get("family-names", "")
                    given = author.get("given-names", "")
                    initial = given[0] if given else ""
                    first_author = f"{family} {initial}" if initial else family
                    authors_str = f"{first_author} et al."

            # Generate BibTeX
            citation_section += "### BibTeX\n\n"
            citation_section += "```bibtex\n"
            citation_section += "@Manual{,\n"

            if citation_data.get("title"):
                citation_section += f"  title = {{{citation_data['title']}}},\n"

            if citation_data.get("authors"):
                author_names = []
                for author in citation_data["authors"]:
                    given = author.get("given-names", "")
                    family = author.get("family-names", "")
                    full_name = f"{given} {family}".strip()
                    author_names.append(full_name)
                citation_section += f"  author = {{{' and '.join(author_names)}}},\n"

            citation_section += f"  year = {{{year}}},\n"

            if citation_data.get("version"):
                citation_section += (
                    f"  note = {{Python package version {citation_data['version']}}},\n"
                )

            if citation_data.get("url"):
                citation_section += f"  url = {{{citation_data['url']}}},\n"

            citation_section += "}\n```\n\n"

            # Generate APA format
            citation_section += "### APA\n\n"
            if citation_data.get("authors"):
                # APA format: Author, A. A., Author, B. B., & Author, C. C. (Year).
                author_list = citation_data["authors"]
                apa_authors = []
                for author in author_list:
                    family = author.get("family-names", "")
                    given = author.get("given-names", "")
                    # Get initials from given names
                    initials = (
                        ". ".join([name[0] for name in given.split() if name]) + "."
                        if given
                        else ""
                    )
                    apa_authors.append(f"{family}, {initials}" if initials else family)

                if len(apa_authors) == 1:
                    apa_authors_str = apa_authors[0]
                elif len(apa_authors) == 2:
                    apa_authors_str = f"{apa_authors[0]} & {apa_authors[1]}"
                else:
                    # For 3+ authors, use commas and & before last
                    apa_authors_str = ", ".join(apa_authors[:-1]) + ", & " + apa_authors[-1]

                title = citation_data.get("title", "")
                version = citation_data.get("version", "")
                url = citation_data.get("url", "")

                # APA software citation format
                citation_section += f"{apa_authors_str} ({year}). *{title}* (Version {version}) [Computer software]. {url}\n\n"

            # Generate RIS format (for reference managers like Zotero, Mendeley, EndNote)
            citation_section += "### RIS\n\n"
            citation_section += "```ris\n"
            citation_section += "TY  - COMP\n"  # Type: Computer Program

            if citation_data.get("title"):
                citation_section += f"TI  - {citation_data['title']}\n"

            if citation_data.get("authors"):
                for author in citation_data["authors"]:
                    given = author.get("given-names", "")
                    family = author.get("family-names", "")
                    # RIS format: AU  - Last, First
                    citation_section += f"AU  - {family}, {given}\n"

            citation_section += f"PY  - {year}\n"

            if citation_data.get("version"):
                citation_section += f"VL  - {citation_data['version']}\n"

            if citation_data.get("url"):
                citation_section += f"UR  - {citation_data['url']}\n"

            if citation_data.get("abstract"):
                citation_section += f"AB  - {citation_data['abstract']}\n"  # pragma: no cover

            citation_section += "ER  - \n"
            citation_section += "```\n"

            citation_qmd_content = f"""---
title: "Authors and Citation"
---
{intro_paragraph}

{authors_section}

{citation_section}
"""
            with open(citation_qmd, "w", encoding="utf-8") as f:
                f.write(citation_qmd_content)
            print(f"Created {citation_qmd}")
            citation_link = "citation.qmd"

        # Always create package-info.qmd if enabled and package has dependencies
        self._generate_package_info_page()

        # Now check if we should create index.qmd
        index_qmd = self.project_path / "index.qmd"

        # In "user_guide" homepage mode, the first UG page becomes index.qmd
        # instead — skip README-based index generation entirely.
        # The _homepage_fallback_to_index flag overrides this when no UG pages exist.
        if self._config.homepage == "user_guide" and not getattr(
            self, "_homepage_fallback_to_index", False
        ):
            return  # pragma: no cover

        if index_qmd.exists() and not force_rebuild:
            print("index.qmd already exists, skipping creation")
            return

        # Find the best source file
        source_file, warnings = self._find_index_source_file()

        # Print any warnings about multiple source files
        for warning in warnings:
            print(warning)

        # Title for the generated homepage. Empty by default so "Home" doesn't
        # appear on an auto-generated landing page; a user-authored source file
        # may override it via its frontmatter title.
        homepage_title = ""

        if source_file is None:
            print("No index source file found (index.qmd, index.md, README.md, or README.rst)")
            print("Generating landing page from package metadata...")
            readme_content = self._generate_landing_page_content(self._get_package_metadata())
        else:
            source_name = source_file.name
            if force_rebuild:
                print(f"Rebuilding index.qmd from {source_name}...")
            else:
                print(f"Creating index.qmd from {source_name}...")

            # Read source content
            with open(source_file, "r", encoding="utf-8") as f:
                readme_content = f.read()

            # Convert RST to Markdown using Quarto's bundled pandoc
            if source_file.suffix.lower() == ".rst":
                readme_content = self._convert_rst_to_markdown(source_file)

            # Strip any leading YAML frontmatter from the source file. The
            # wrapper template below supplies its own frontmatter, so embedding
            # the source's frontmatter mid-document would render it as a
            # horizontal rule plus visible raw YAML text (e.g. index.qmd files,
            # which universally start with a `---` block). The source title is
            # carried over into the wrapper so an authored title survives.
            source_fm, readme_content = self._split_frontmatter(readme_content)
            source_title = source_fm.get("title", "")
            if isinstance(source_title, str) and source_title:
                homepage_title = source_title

            # Copy images referenced in the source file to the build directory
            self._copy_readme_images(source_file)

        # Build hero section (must run before heading adjustment so badge
        # extraction works on original markdown)
        hero_html, cleaned_content = self._build_hero_section(readme_content)
        if cleaned_content is not None:
            readme_content = cleaned_content  # pragma: no cover

        # If hero displays the package name, strip a matching first heading
        # to avoid visual duplication
        if hero_html and self._config.hero_enabled:
            import re as _re

            hero_name = self._config.hero_name
            if hero_name is None:
                metadata = self._get_package_metadata()
                hero_name = metadata.get("name") or self._detect_package_name()
            if hero_name:
                first_h1 = _re.match(r"^#\s+(.+)$", readme_content.strip(), _re.MULTILINE)
                if first_h1 and first_h1.group(1).strip().lower() == hero_name.strip().lower():
                    readme_content = readme_content.replace(first_h1.group(0), "", 1).lstrip("\n")

        if source_file is not None:
            # Adjust heading levels: bump all headings up by one level. This
            # prevents h1 from becoming paragraphs and keeps proper hierarchy.
            # Lines inside fenced code blocks are skipped so Quarto cell options
            # (`#| code-fold: true`) and shell-style comments aren't mangled.
            readme_content = self._bump_heading_levels(readme_content)

        # Build margin content using the shared helper
        margin_content = self._build_metadata_margin()

        # CSS to reduce top margin of first heading element
        # The heading ends up inside a section.level1 > h1 structure
        first_heading_style = """<style>
section.level1:first-of-type > h1:first-child,
section.level2:first-of-type > h2:first-child,
.column-body-outset-right > section.level1:first-of-type > h1,
#quarto-document-content > section:first-of-type > h1 {
  margin-top: 4px !important;
}
</style>

"""

        # Create a qmd file with the README content
        # Add margin content in a special div that Quarto will place in the margin
        # Prepend hero section (raw HTML block) when enabled
        hero_block = ""
        if hero_html:
            hero_block = f"""```{{=html}}
{hero_html}```

"""

        # Render the title line YAML-safe (quotes/colons in an authored title
        # are escaped). Empty title renders as `title: ""`.
        title_line = format_yaml({"title": homepage_title}).rstrip()

        if margin_content:
            qmd_content = f"""---
{title_line}
toc: false
body-classes: "gd-homepage"
---

{first_heading_style}{hero_block}::: {{.column-margin}}
{margin_content}
:::

{readme_content}
"""
        else:
            qmd_content = f"""---  # pragma: no cover
{title_line}
toc: false
body-classes: "gd-homepage"
---

{first_heading_style}{hero_block}{readme_content}
"""

        with open(index_qmd, "w", encoding="utf-8") as f:
            f.write(qmd_content)

        print(f"Created {index_qmd}")

    @staticmethod
    def _fetch_pypi_dates(package_names: set[str]) -> dict[str, str]:
        """
        Fetch the latest release date for each package from PyPI.

        Uses the PyPI JSON API (`https://pypi.org/pypi/{name}/json`) to look up the most recent
        upload timestamp. Requests are made with a short timeout and failures are silently skipped
        (the date simply shows "—").

        Parameters
        ----------
        package_names
            Set of PyPI package names to look up.

        Returns
        -------
        dict[str, str]
            Mapping of package name to a formatted date string (`YYYY-MM-DD`).
        """
        import concurrent.futures

        import requests

        dates: dict[str, str] = {}
        if not package_names:
            return dates

        def _fetch_one(name: str) -> tuple[str, str]:
            try:
                resp = requests.get(
                    f"https://pypi.org/pypi/{name}/json",
                    timeout=5,
                    headers={"Accept": "application/json"},
                )
                if resp.status_code == 200:
                    data = resp.json()
                    # The "info" dict has the latest version; look up its upload time
                    latest_version = data.get("info", {}).get("version", "")
                    releases = data.get("releases", {})
                    if latest_version and latest_version in releases:
                        files = releases[latest_version]
                        if files:
                            upload_time = files[0].get("upload_time_iso_8601", "")
                            if upload_time:
                                return name, upload_time[:10]  # YYYY-MM-DD
            except Exception:
                pass
            return name, ""

        # Fetch in parallel with a bounded thread pool
        with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
            futures = {pool.submit(_fetch_one, n): n for n in package_names}
            for future in concurrent.futures.as_completed(futures):
                pkg_name, date_str = future.result()
                if date_str:
                    dates[pkg_name] = date_str

        return dates

    def _generate_package_info_page(self) -> str | None:
        """
        Generate package-info.qmd with dependency details.

        Creates a page listing runtime dependencies, optional dependency groups (with full package
        lists), and a summary. The page is linked from the homepage Meta section.

        Returns
        -------
        str | None
            The relative path `"package-info.qmd"` when the page is created, or `None` when
            generation is skipped (disabled or no deps found).
        """
        if not self._config.package_info_page:
            return None

        from ._icons import get_icon_svg
        from ._translations import get_translation

        metadata = self._get_package_metadata()
        lang = self._config.language

        runtime_deps: list[dict] = metadata.get("dependencies", [])
        opt_deps_full: dict[str, list[dict]] = metadata.get("optional_dependencies_full", {})
        requires_python: str = metadata.get("requires_python", "")

        # Skip if there's nothing to show
        if not runtime_deps and not opt_deps_full and not requires_python:
            return None

        # Translated labels
        _title = get_translation("package_info", lang)
        _runtime = get_translation("runtime_dependencies", lang)
        _optional = get_translation("optional_dependencies", lang)
        _summary = get_translation("dependency_summary", lang)
        _pkg_col = get_translation("dep_package", lang)
        _ver_col = get_translation("dep_version_constraint", lang)
        _marker_col = get_translation("dep_environment_marker", lang)

        # Build a small inline SVG link icon for PyPI column
        _link_svg = get_icon_svg("external-link", size=14, css_class="")

        # Fetch latest release dates from PyPI for all unique dep names
        all_dep_names = {d["name"] for d in runtime_deps}
        for deps in opt_deps_full.values():
            all_dep_names |= {d["name"] for d in deps}
        pypi_dates = self._fetch_pypi_dates(all_dep_names)

        sections: list[str] = []

        # ── Runtime Dependencies ──────────────────────────────────────
        if runtime_deps:
            has_markers = any(d.get("marker") for d in runtime_deps)
            header_cols = f"| {_pkg_col} | {_ver_col} | Last Published | PyPI |"
            separator = "|----|----|----|----|"
            if has_markers:
                header_cols = f"| {_pkg_col} | {_ver_col} | {_marker_col} | Last Published | PyPI |"
                separator = "|----|----|----|----|----|"

            rows: list[str] = []
            for dep in runtime_deps:
                name = dep["name"]
                spec = f"`{dep['specifier']}`" if dep["specifier"] else "—"
                date_str = pypi_dates.get(name, "—")
                pypi_cell = f"[{_link_svg}](https://pypi.org/project/{name}/){{.gd-pypi-link}}"
                if has_markers:
                    marker = f"`{dep['marker']}`" if dep.get("marker") else ""
                    rows.append(
                        f"| `{name}`{{.gd-no-link}} | {spec} | {marker}"
                        f" | {date_str} | {pypi_cell} |"
                    )
                else:
                    rows.append(f"| `{name}`{{.gd-no-link}} | {spec} | {date_str} | {pypi_cell} |")

            sections.append(f"## {_runtime}\n")
            sections.append(header_cols)
            sections.append(separator)
            sections.extend(rows)
            sections.append("")

        # ── Optional Dependencies ─────────────────────────────────────
        if opt_deps_full:
            sections.append(f"## {_optional}\n")

            for group_name, group_deps in sorted(opt_deps_full.items()):
                sections.append(f"### `{group_name}`\n")

                if group_deps:
                    has_markers = any(d.get("marker") for d in group_deps)
                    header_cols = f"| {_pkg_col} | {_ver_col} | Last Published | PyPI |"
                    separator = "|----|----|----|----|"
                    if has_markers:
                        header_cols = (
                            f"| {_pkg_col} | {_ver_col} | {_marker_col} | Last Published | PyPI |"
                        )
                        separator = "|----|----|----|----|----|"

                    sections.append(header_cols)
                    sections.append(separator)

                    for dep in group_deps:
                        name = dep["name"]
                        spec = f"`{dep['specifier']}`" if dep["specifier"] else "—"
                        date_str = pypi_dates.get(name, "—")
                        pypi_cell = (
                            f"[{_link_svg}](https://pypi.org/project/{name}/){{.gd-pypi-link}}"
                        )
                        if has_markers:
                            marker = f"`{dep['marker']}`" if dep.get("marker") else ""
                            sections.append(
                                f"| `{name}`{{.gd-no-link}} | {spec} | {marker}"
                                f" | {date_str} | {pypi_cell} |"
                            )
                        else:
                            sections.append(
                                f"| `{name}`{{.gd-no-link}} | {spec} | {date_str} | {pypi_cell} |"
                            )
                    sections.append("")
                else:
                    sections.append("*No dependencies declared.*\n")

        # ── Summary ───────────────────────────────────────────────────
        sections.append(f"## {_summary}\n")

        n_runtime = len(runtime_deps)
        n_opt_groups = len(opt_deps_full)
        all_opt_names = {dep["name"] for deps in opt_deps_full.values() for dep in deps}
        n_opt_unique = len(all_opt_names)
        all_names = {dep["name"] for dep in runtime_deps} | all_opt_names
        n_total = len(all_names)

        summary_items: list[str] = []
        if requires_python:
            _requires_label = get_translation("requires_python", lang)
            summary_items.append(f"- **{_requires_label}:** Python `{requires_python}`")
        summary_items.append(f"- **{_runtime}:** {n_runtime}")
        if n_opt_groups:
            summary_items.append(
                f"- **{_optional}:** {n_opt_groups} groups ({n_opt_unique} unique packages)"
            )
        summary_items.append(f"- **Total unique dependencies:** {n_total}")

        sections.extend(summary_items)
        sections.append("")

        body = "\n".join(sections)

        qmd_content = f"""---
title: "{_title}"
toc: true
sidebar: false
body-classes: "gd-package-info-page"
anchor-sections: true
---

{body}
"""

        pkg_info_qmd = self.project_path / "package-info.qmd"
        with open(pkg_info_qmd, "w", encoding="utf-8") as f:
            f.write(qmd_content)
        print(f"Created {pkg_info_qmd}")
        return "package-info.qmd"

    def _add_api_reference_config(self) -> None:
        """
        Add API reference configuration to _quarto.yml if not present.

        Adds sensible defaults for API reference generation with automatic
        package detection.  If no documentable exports are discovered (and no
        explicit `reference` config exists in great-docs.yml), the method
        skips API reference setup entirely and sets
        `self._has_api_reference = False`.
        """
        # Explicit opt-out via great-docs.yml
        if not self._config.reference_enabled:
            print("API reference disabled (reference: false), skipping")
            self._has_api_reference = False
            return

        # Python API reference only applies to projects with a Python component.
        # Non-Python projects (e.g. project_type: go) have no importable package
        # to introspect — skip rather than prompting for a package name.
        if not self._config.is_python_project:
            print("API reference skipped (non-Python project)")
            self._has_api_reference = False
            return

        quarto_yml = self.project_path / "_quarto.yml"

        with open(quarto_yml, "r") as f:
            config = read_yaml(f) or {}

        # Check if API reference config already exists
        if "api-reference" in config:
            print("API reference configuration already exists, skipping")
            return

        # Detect package name (project name from pyproject.toml)
        package_name = self._detect_package_name()

        if not package_name:
            try:  # pragma: no cover
                response = input(
                    "\nCould not auto-detect package name. Enter package name for API reference (or press Enter to skip): "
                ).strip()
            except EOFError:  # pragma: no cover
                response = ""
            if not response:  # pragma: no cover
                print("Skipping API reference configuration")
                self._has_api_reference = False
                return
            package_name = response  # pragma: no cover

        print(f"Adding API reference configuration for package: {package_name}")

        # Detect actual module name (may differ from project name)
        # e.g., project 'py-yaml12' might have module 'yaml12'
        module_name = self._detect_module_name()
        if module_name:
            importable_name = module_name
            if module_name != package_name and module_name != self._normalize_package_name(
                package_name
            ):
                print(f"Detected module name: {module_name}")  # pragma: no cover
        else:
            # Fall back to normalizing package name (hyphens -> underscores)
            importable_name = self._normalize_package_name(package_name)

        # Check if this is a compiled extension
        if self._is_compiled_extension():  # pragma: no cover
            print("Detected compiled extension package (PyO3/Rust/Cython)")
            print("Note: The package must be installed (`pip install -e .` or `maturin develop`)")
            print("      for API reference generation.")

        # Try to auto-generate sections from discovered exports
        # Prioritizes explicit reference config from great-docs.yml over auto-discovery
        sections = self._create_api_sections_with_config(importable_name)

        # Mark that discovery was performed this build (so Step 2 can skip re-doing it)
        self._api_discovery_done = True

        # If no documentable exports were found, skip API reference entirely
        if not sections:
            print("No documentable exports found — skipping API reference")
            self._has_api_reference = False
            return

        # Add API reference configuration with sensible defaults
        # Use the importable name (actual module name) for the package field.
        # The reference index *page* title defaults to "API Reference" (consistent with the
        # "CLI Reference" / "MCP Reference" index titles). The *navbar* keeps the shorter
        # "Reference" umbrella label, since that entry covers the API, CLI, and MCP references
        # via the reference switcher (and other code locates it by the literal text "Reference").
        ref_title = self._config.reference_title or "API Reference"
        nav_title = self._config.reference_title or "Reference"
        ref_desc = self._config.reference_desc

        # Translate default titles for i18n
        if not self._config.reference_title and self._config.language != "en":
            from ._translations import get_translation  # pragma: no cover

            ref_title = get_translation("api_reference", self._config.language)  # pragma: no cover
            nav_title = get_translation("reference", self._config.language)  # pragma: no cover

        # Configure the API reference
        api_ref_config = {
            "package": importable_name,
            "dir": "reference",
            "title": ref_title,
        }
        if ref_desc:
            api_ref_config["desc"] = ref_desc  # pragma: no cover

        # Get dynamic setting from great-docs.yml config (defaults to True)
        dynamic = self._config.dynamic
        api_ref_config["dynamic"] = dynamic
        if not dynamic:
            print("Using static introspection mode (dynamic: false)")  # pragma: no cover

        # Get parser from great-docs.yml config (defaults to numpy)
        parser = self._config.parser
        if parser and parser != "numpy":
            # Only add parser if it's not the default (numpy)
            api_ref_config["parser"] = parser  # pragma: no cover
            print(f"Using '{parser}' docstring parser")  # pragma: no cover
        else:
            # Always explicitly set parser for clarity
            api_ref_config["parser"] = "numpy"

        # Get jupyter kernel from great-docs.yml config (defaults to python3)
        jupyter_kernel = self._config.jupyter
        if jupyter_kernel:
            config["jupyter"] = jupyter_kernel
            print(f"Using '{jupyter_kernel}' Jupyter kernel")

        api_ref_config["sections"] = sections
        print(f"Auto-generated {len(sections)} section(s) from package exports")

        config["api-reference"] = api_ref_config

        # Add "Reference" link to navbar
        if "website" in config and "navbar" in config["website"]:
            navbar = config["website"]["navbar"]
            left = navbar.get("left", [])
            # Only add if not already present
            has_ref = any(
                isinstance(item, dict) and item.get("href") == "reference/index.qmd"
                for item in left
            )
            if not has_ref:
                left.append({"text": nav_title, "href": "reference/index.qmd"})
                navbar["left"] = left

        # Add reference sidebar
        if "website" in config and "sidebar" in config["website"]:
            sidebars = config["website"]["sidebar"]
            has_ref_sidebar = any(
                isinstance(s, dict) and s.get("id") == "reference" for s in sidebars
            )
            if not has_ref_sidebar:
                sidebars.append({"id": "reference", "contents": "reference/"})

        # Write back to file
        self._write_quarto_yml(quarto_yml, config)

        print(f"Added API reference configuration to {quarto_yml}")

    def _refresh_api_reference_config(self) -> None:
        """
        Refresh the API reference sections in _quarto.yml based on current package exports.

        This method re-discovers the package API and updates the API reference sections without touching
        other configuration. Use this when your package API has changed (new classes, methods, or
        functions added/removed).

        The method preserves:

        - package name and other API reference settings
        - all non-API reference configuration in _quarto.yml

        Only the 'sections' key in API reference config is regenerated.
        """
        # If _add_api_reference_config already ran discovery this build, skip redundant work
        if getattr(self, "_api_discovery_done", False):
            print("API reference already configured in this build, skipping re-discovery")
            return

        quarto_yml = self.project_path / "_quarto.yml"

        if not quarto_yml.exists():
            print("Error: _quarto.yml not found. Run 'great-docs init' first.")
            return

        with open(quarto_yml, "r") as f:
            config = read_yaml(f) or {}

        if "api-reference" not in config:
            print("Error: No API reference configuration found. Run 'great-docs init' first.")
            return

        # Get the package name from existing config
        package_name = config["api-reference"].get("package")
        if not package_name:
            print("Error: No package name in API reference config.")
            return

        print(f"Re-discovering exports for package: {package_name}")

        # Re-generate sections from current package exports
        # Prioritizes explicit config from great-docs.yml over auto-discovery
        sections = self._create_api_sections_with_config(package_name)

        # Update parser and dynamic settings from great-docs.yml config
        parser = self._config.parser
        if parser:
            config["api-reference"]["parser"] = parser

        dynamic = self._config.dynamic
        config["api-reference"]["dynamic"] = dynamic

        # Update title and desc from great-docs.yml config
        ref_title = self._config.reference_title
        if ref_title:
            config["api-reference"]["title"] = ref_title  # pragma: no cover
        ref_desc = self._config.reference_desc
        if ref_desc:
            config["api-reference"]["desc"] = ref_desc  # pragma: no cover
        elif "desc" in config["api-reference"]:
            # Remove desc if it was previously set but is now absent
            del config["api-reference"]["desc"]  # pragma: no cover

        # Update jupyter kernel from great-docs.yml config
        jupyter_kernel = self._config.jupyter
        if jupyter_kernel:
            config["jupyter"] = jupyter_kernel

        if sections:
            config["api-reference"]["sections"] = sections
            print(f"Updated API reference config with {len(sections)} section(s)")

            # Write back to file first, so sidebar update reads the new sections
            self._write_quarto_yml(quarto_yml, config)

            # Now update the sidebar to match the new sections
            self._update_sidebar_from_sections()

            print(f"✅ Refreshed API reference configuration in {quarto_yml}")
        else:
            # Check if user has explicit reference config that should be applied
            # even though auto-discovery failed
            reference_config = self._config.reference
            if reference_config:
                print(
                    "Auto-discovery failed, but applying explicit reference config from great-docs.yml"
                )
                # Build sections directly from user config without validation
                user_sections = self._build_sections_from_reference_config(reference_config)
                if user_sections:
                    config["api-reference"]["sections"] = user_sections
                    self._write_quarto_yml(quarto_yml, config)
                    self._update_sidebar_from_sections()
                    print(f"✅ Applied {len(user_sections)} section(s) from great-docs.yml")
                else:
                    print(
                        "Warning: reference config in great-docs.yml produced no sections"
                    )  # pragma: no cover
            else:
                print(
                    "Warning: Could not discover package exports. Config unchanged."
                )  # pragma: no cover
                print(  # pragma: no cover
                    "Tip: Add a 'reference' section to great-docs.yml to manually specify your API structure."
                )

    def _write_quarto_yml(self, quarto_yml: Path, config: dict) -> None:
        """
        Write _quarto.yml with a header comment.

        Parameters
        ----------
        quarto_yml
            Path to the _quarto.yml file.
        config
            The configuration dictionary to write.
        """
        # Translate navbar labels for i18n
        self._translate_navbar_labels(config)

        header_comment = (
            "# Generated by Great Docs - Do not modify this file by hand.\n"
            "# Configure settings in great-docs.yml instead.\n\n"
        )
        with open(quarto_yml, "w") as f:
            f.write(header_comment)
            write_yaml(config, f)

    def _translate_navbar_labels(self, config: dict) -> None:
        """
        Translate known navbar text labels using the configured language.

        Modifies the config dict in place. Only translates labels that were
        auto-generated (matching the English defaults).
        """
        from ._translations import get_translation

        lang = self._config.language
        if lang == "en":
            return  # No translation needed

        # Map of English label -> translation key
        label_map = {
            "User Guide": "user_guide",
            "Recipes": "recipes",
            "Reference": "reference",
            "Changelog": "changelog",
        }

        navbar = config.get("website", {}).get("navbar", {})
        for side in ("left", "right"):
            items = navbar.get(side, [])
            for item in items:
                if isinstance(item, dict) and item.get("text") in label_map:
                    key = label_map[item["text"]]
                    item["text"] = get_translation(key, lang)

    def _inject_nav_icons(self, config: dict) -> None:
        """Inject navigation icon data and script into the Quarto config.

        Resolves configured icon names to inline SVG strings and emits a
        self-contained `<script>` block in `include-after-body` that
        prepends icons to matching navigation entries at runtime.

        Everything is inlined into a single script tag to avoid relative-path
        issues with external JS files on subpages.
        """
        from ._icons import get_icon_svg  # pragma: no cover

        nav_icons = self._config.nav_icons  # pragma: no cover
        if not nav_icons:  # pragma: no cover
            return  # pragma: no cover

        # Resolve icon names to SVG markup
        resolved: dict[str, dict[str, str]] = {}  # pragma: no cover
        for scope in ("navbar", "sidebar"):  # pragma: no cover
            mapping = nav_icons.get(scope, {})  # pragma: no cover
            if not mapping:  # pragma: no cover
                continue  # pragma: no cover
            scope_resolved: dict[str, str] = {}  # pragma: no cover
            for label, icon_name in mapping.items():  # pragma: no cover
                svg = get_icon_svg(icon_name)  # pragma: no cover
                if svg:  # pragma: no cover
                    scope_resolved[label] = svg  # pragma: no cover
                else:
                    print(
                        f"Warning: Unknown nav icon '{icon_name}' for '{label}'"
                    )  # pragma: no cover
            if scope_resolved:  # pragma: no cover
                resolved[scope] = scope_resolved  # pragma: no cover

        if not resolved:  # pragma: no cover
            return  # pragma: no cover

        # Serialize to JSON
        import json  # pragma: no cover

        icon_json = json.dumps(resolved, separators=(",", ":"))  # pragma: no cover

        # Read the nav-icons.js source and build a self-contained inline script
        nav_icons_js = self.assets_path / "nav-icons.js"  # pragma: no cover
        if nav_icons_js.exists():  # pragma: no cover
            js_source = nav_icons_js.read_text(encoding="utf-8")  # pragma: no cover
        else:
            return  # pragma: no cover

        if "include-after-body" not in config["format"]["html"]:  # pragma: no cover
            config["format"]["html"]["include-after-body"] = []  # pragma: no cover
        elif isinstance(config["format"]["html"]["include-after-body"], str):  # pragma: no cover
            config["format"]["html"]["include-after-body"] = [  # pragma: no cover
                config["format"]["html"]["include-after-body"]
            ]

        # Emit a single self-contained <script> with the data + logic inlined
        inline_script = (  # pragma: no cover
            f'<script id="gd-nav-icons-data" type="application/json">'
            f"{icon_json}</script>\n"
            f"<script>{js_source}</script>"
        )

        entry = {"text": inline_script}  # pragma: no cover
        has_nav_icons = any(  # pragma: no cover
            "gd-nav-icons-data" in str(item)
            for item in config["format"]["html"]["include-after-body"]
        )
        if not has_nav_icons:  # pragma: no cover
            config["format"]["html"]["include-after-body"].append(entry)  # pragma: no cover

    def _update_quarto_config(self) -> None:
        """
        Update _quarto.yml with great-docs configuration.

        This private method modifies the Quarto configuration file to include the
        post-render script, CSS file, and website navigation required by great-docs.
        It preserves existing configuration while adding the necessary great-docs
        settings. If website navigation is not present, it adds a navbar with Home
        and API Reference links, and sets the site title to the package name.
        """
        quarto_yml = self.project_path / "_quarto.yml"

        if not quarto_yml.exists():
            print("Warning: _quarto.yml not found. Creating minimal configuration...")
            config = {
                "project": {"type": "website", "post-render": "scripts/post-render.py"},
                "format": {"html": {"theme": "flatly"}},
            }
        else:
            # Load existing configuration
            with open(quarto_yml, "r") as f:
                config = read_yaml(f) or {}

        # Ensure required structure exists
        if "project" not in config:
            config["project"] = {}
        if "format" not in config:
            config["format"] = {}
        if "html" not in config["format"]:
            config["format"]["html"] = {}

        # Add post-render script
        config["project"]["post-render"] = "scripts/post-render.py"

        # Build pre-render script list
        pre_render_entries: list[str] = []

        # Auto-inject restore-freeze when freeze is configured OR _freeze/ exists
        freeze_mode = self._config.freeze
        has_freeze_cache = (self.project_root / "_freeze").is_dir()
        if freeze_mode is not None or has_freeze_cache:
            pre_render_entries.append("scripts/restore-freeze.py")

        # Add user-provided pre-render scripts
        for s in self._config.pre_render:
            entry = f"scripts/{Path(s).name}"
            if entry not in pre_render_entries:
                pre_render_entries.append(entry)

        if pre_render_entries:
            if len(pre_render_entries) == 1:
                config["project"]["pre-render"] = pre_render_entries[0]
            else:
                config["project"]["pre-render"] = pre_render_entries

        # Add freeze configuration (execute: freeze:)
        if freeze_mode is not None:
            if "execute" not in config:
                config["execute"] = {}
            config["execute"]["freeze"] = freeze_mode

        # Add resources to copy static JS files to _site
        if "resources" not in config["project"]:
            config["project"]["resources"] = []
        elif isinstance(config["project"]["resources"], str):
            config["project"]["resources"] = [config["project"]["resources"]]

        # Ensure JS files are included as resources
        js_resource_files = [
            "github-widget.js",
            "sidebar-filter.js",
            "sidebar-wrap.js",
            "sidebar-float.js",
            "dark-mode-toggle.js",
            "theme-init.js",
            "copy-code.js",
            "tooltips.js",
            "color-swatch.js",
            "mermaid-renderer.js",
            "responsive-tables.js",
            "video-embed.js",
            "navbar-widgets.js",
            "details.js",
        ]
        if self._config.markdown_pages_widget:
            js_resource_files.append("copy-page.js")
        if self._config.show_dates:
            js_resource_files.append("page-metadata.js")  # pragma: no cover
        if self._config.back_to_top:
            js_resource_files.append("back-to-top.js")
            js_resource_files.append("on-this-page.js")
        if self._config.keyboard_nav:
            js_resource_files.append("keyboard-nav.js")
        if self._config.tags_show_on_pages:
            js_resource_files.append("page-tags.js")  # pragma: no cover
        if self._config.page_status_enabled:
            js_resource_files.append("page-status-badges.js")  # pragma: no cover
        if self._config.hero_starfield:
            js_resource_files.append("starfield.js")
        if self._config.skill_skills and len(self._config.skill_skills) > 1:
            js_resource_files.append("skill-switcher.js")
        js_resource_files.append("termshow.js")
        for js_file in js_resource_files:
            if js_file not in config["project"]["resources"]:
                config["project"]["resources"].append(js_file)
        if "termshow.css" not in config["project"]["resources"]:
            config["project"]["resources"].append("termshow.css")

        # Add gd-lightbox assets as resources
        for lb_res in (
            "gd-lightbox.js",
            "gd-lightbox.css",
            "gd-lightbox-compare.js",
            "gd-lightbox-compare.css",
            "gd-lightbox-annotate.js",
            "gd-lightbox-annotate.css",
        ):
            if lb_res not in config["project"]["resources"]:
                config["project"]["resources"].append(lb_res)

        # Add all user-guide asset directories to resources so that images
        # referenced only via data attributes (e.g., dark-mode variants) get
        # copied to _site
        ug_dir = self.project_path / "user-guide"
        if ug_dir.exists() and ug_dir.is_dir():
            for item in ug_dir.iterdir():
                if item.is_dir() and self._is_asset_dir(item):
                    res_glob = f"user-guide/{item.name}/**"
                    if res_glob not in config["project"]["resources"]:
                        config["project"]["resources"].append(res_glob)

        # Add assets directory to resources if it exists
        assets_dir = self.project_path / "assets"
        if assets_dir.exists() and assets_dir.is_dir():
            if "assets/**" not in config["project"]["resources"]:
                config["project"]["resources"].append("assets/**")

        # Add custom section asset directories to resources
        sections_config = self._config.sections
        if sections_config and isinstance(sections_config, list):
            for section_cfg in sections_config:
                if not isinstance(section_cfg, dict):
                    continue
                src_dir = section_cfg.get("dir")
                if not src_dir:
                    continue
                slug = src_dir.replace("_", "-").replace(" ", "-").lower()
                section_build_dir = self.project_path / slug
                if section_build_dir.exists() and section_build_dir.is_dir():
                    for item in section_build_dir.iterdir():
                        if item.is_dir() and self._is_asset_dir(item):
                            res_glob = f"{slug}/{item.name}/**"
                            if res_glob not in config["project"]["resources"]:
                                config["project"]["resources"].append(res_glob)

        # Add skill.md and .well-known to resources (so Quarto copies them to _site)
        # Also exclude them from rendering so Quarto doesn't convert them to HTML
        if self._config.skill_enabled:
            if "skill.md" not in config["project"]["resources"]:
                config["project"]["resources"].append("skill.md")

            # Exclude skill.md from rendering (Quarto renders .md by default).
            # The render list enumerates Quarto's default input globs before the
            # exclusions; a recursive `**` would overpower the `!skill.md`
            # negation and render skill.md anyway (see issue #228).
            if "render" not in config["project"]:
                config["project"]["render"] = list(_QUARTO_RENDER_GLOBS)
            if "!skill.md" not in config["project"]["render"]:
                config["project"]["render"].append("!skill.md")

            if self._config.skill_well_known:
                if ".well-known/**" not in config["project"]["resources"]:
                    config["project"]["resources"].append(".well-known/**")
                if "!.well-known/**" not in config["project"]["render"]:
                    config["project"]["render"].append("!.well-known/**")

        # Site settings are a pure Quarto passthrough: the whole subtree
        # (theme, toc, toc-depth, html-math-method defaults from
        # great-docs.default.yml, plus any user format.html keys) merges into
        # format.html. Legacy great-docs keys were normalized to the top level
        # at load, and css is applied separately below.
        config["format"]["html"] = Config._merge(config["format"]["html"], self._config.site_quarto)

        # toc-title: use the translated label unless the user overrode it via
        # site.toc-title. Test the user's `site` rather than the merged result —
        # a label this method wrote on an earlier pass must be refreshed from the
        # current language, not treated as an override.
        if "toc-title" not in self._config.site_quarto:
            from ._translations import get_translation

            config["format"]["html"]["toc-title"] = get_translation(
                "on_this_page", self._config.language
            )

        # Disable Quarto's native code-copy — we supply our own via copy-code.js
        config["format"]["html"]["code-copy"] = False

        # Disable Quarto's built-in GLightbox — we supply gd-lightbox instead
        config["lightbox"] = False

        # Forward project-level bibliography/CSL from great-docs.yml. The files
        # are copied into the build directory (see _prepare_build_directory), so
        # the _quarto.yml keys reference them by basename.
        bib_files = self._config.bibliography
        if bib_files:
            bib_names = [Path(b).name for b in bib_files]
            config["bibliography"] = bib_names[0] if len(bib_names) == 1 else bib_names
            print(f"Using bibliography: {', '.join(bib_names)}")
            # Note: the auto-generated references heading is localized by Quarto
            # itself from the document `lang` (set below), e.g. "Les références"
            # for French. We deliberately do not set `reference-section-title`:
            # under our `shift-heading-level-by: -1` it would be demoted to a
            # stray <p> and duplicated alongside Quarto's appendix heading.
        csl_path = self._config.csl
        if csl_path:
            config["csl"] = Path(csl_path).name

        # Set document language for Quarto built-in i18n (search widget, etc.)
        if self._config.language and self._config.language != "en":
            config["lang"] = self._config.language  # pragma: no cover

        # Disable Quarto's HTML table processing so that GT tables (and other
        # pre-styled HTML tables) are not affected by Bootstrap striping/styles
        config["format"]["html"]["html-table-processing"] = "none"

        # Configure Mermaid diagrams - use 'default' (light) theme always
        # We provide a light background container in dark mode via CSS
        config["format"]["html"]["mermaid"] = {"theme": "default"}

        if "great-docs.scss" not in config["format"]["html"]["theme"]:
            if isinstance(config["format"]["html"]["theme"], str):
                config["format"]["html"]["theme"] = [config["format"]["html"]["theme"]]
            config["format"]["html"]["theme"].append("great-docs.scss")

        # Add the custom CSS from site.css
        css_files = self._config.css
        if css_files:
            config["format"]["html"]["css"] = [Path(c).name for c in css_files]

        if "shift-heading-level-by" not in config["format"]["html"]:
            config["format"]["html"]["shift-heading-level-by"] = -1

        # Add Font Awesome for ORCID icon support
        if "include-in-header" not in config["format"]["html"]:
            config["format"]["html"]["include-in-header"] = []
        elif isinstance(config["format"]["html"]["include-in-header"], str):
            config["format"]["html"]["include-in-header"] = [
                config["format"]["html"]["include-in-header"]
            ]

        # Merge user-provided include-in-header entries from great-docs.yml
        for entry in self._config.include_in_header:
            if entry not in config["format"]["html"]["include-in-header"]:
                config["format"]["html"]["include-in-header"].append(entry)

        # Add Font Awesome CDN if not already present
        fa_cdn = '<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">'
        fa_entry = {"text": fa_cdn}
        if fa_entry not in config["format"]["html"]["include-in-header"]:
            # Check if any Font Awesome link already exists
            has_fa = any(
                "font-awesome" in str(item).lower()
                for item in config["format"]["html"]["include-in-header"]
            )
            if not has_fa:
                config["format"]["html"]["include-in-header"].append(fa_entry)

        # Add termshow CSS
        tp_css_entry = {
            "text": (
                "<script>document.head.appendChild(Object.assign("
                "document.createElement('link'),{rel:'stylesheet',"
                "href:(document.querySelector('meta[name=\"quarto:offset\"]')"
                "||{content:''}).content+'termshow.css'}));</script>"
            )
        }
        if not any(
            "termshow.css" in str(item) for item in config["format"]["html"]["include-in-header"]
        ):
            config["format"]["html"]["include-in-header"].append(tp_css_entry)

        # Add gd-lightbox CSS (uses quarto:offset for subdirectory-safe paths)
        lb_css_entry = {
            "text": (
                "<script>document.head.appendChild(Object.assign("
                "document.createElement('link'),{rel:'stylesheet',"
                "href:(document.querySelector('meta[name=\"quarto:offset\"]')"
                "||{content:''}).content+'gd-lightbox.css'}));</script>"
            )
        }
        if not any(
            "gd-lightbox.css" in str(item) for item in config["format"]["html"]["include-in-header"]
        ):
            config["format"]["html"]["include-in-header"].append(lb_css_entry)

        # Add gd-lightbox JS (deferred, uses quarto:offset)
        lb_js_entry = {
            "text": (
                "<script>(function(){var s=document.createElement('script');"
                "var m=document.querySelector('meta[name=\"quarto:offset\"]');"
                "s.src=(m?m.content:'')+'gd-lightbox.js';s.defer=true;"
                "document.head.appendChild(s);})();</script>"
            )
        }
        if not any(
            "gd-lightbox.js" in str(item) for item in config["format"]["html"]["include-in-header"]
        ):
            config["format"]["html"]["include-in-header"].append(lb_js_entry)

        # Add gd-lightbox-compare CSS (uses quarto:offset)
        lb_compare_css_entry = {
            "text": (
                "<script>document.head.appendChild(Object.assign("
                "document.createElement('link'),{rel:'stylesheet',"
                "href:(document.querySelector('meta[name=\"quarto:offset\"]')"
                "||{content:''}).content+'gd-lightbox-compare.css'}));</script>"
            )
        }
        if not any(
            "gd-lightbox-compare.css" in str(item)
            for item in config["format"]["html"]["include-in-header"]
        ):
            config["format"]["html"]["include-in-header"].append(lb_compare_css_entry)

        # Add gd-lightbox-compare JS (deferred, uses quarto:offset)
        lb_compare_js_entry = {
            "text": (
                "<script>(function(){var s=document.createElement('script');"
                "var m=document.querySelector('meta[name=\"quarto:offset\"]');"
                "s.src=(m?m.content:'')+'gd-lightbox-compare.js';s.defer=true;"
                "document.head.appendChild(s);})();</script>"
            )
        }
        if not any(
            "gd-lightbox-compare.js" in str(item)
            for item in config["format"]["html"]["include-in-header"]
        ):
            config["format"]["html"]["include-in-header"].append(lb_compare_js_entry)

        # Add gd-lightbox-annotate CSS (uses quarto:offset)
        lb_annotate_css_entry = {
            "text": (
                "<script>document.head.appendChild(Object.assign("
                "document.createElement('link'),{rel:'stylesheet',"
                "href:(document.querySelector('meta[name=\"quarto:offset\"]')"
                "||{content:''}).content+'gd-lightbox-annotate.css'}));</script>"
            )
        }
        if not any(
            "gd-lightbox-annotate.css" in str(item)
            for item in config["format"]["html"]["include-in-header"]
        ):
            config["format"]["html"]["include-in-header"].append(lb_annotate_css_entry)

        # Add gd-lightbox-annotate JS (deferred, uses quarto:offset)
        lb_annotate_js_entry = {
            "text": (
                "<script>(function(){var s=document.createElement('script');"
                "var m=document.querySelector('meta[name=\"quarto:offset\"]');"
                "s.src=(m?m.content:'')+'gd-lightbox-annotate.js';s.defer=true;"
                "document.head.appendChild(s);})();</script>"
            )
        }
        if not any(
            "gd-lightbox-annotate.js" in str(item)
            for item in config["format"]["html"]["include-in-header"]
        ):
            config["format"]["html"]["include-in-header"].append(lb_annotate_js_entry)

        # Add website navigation if not present
        if "website" not in config:
            config["website"] = {}

        # Set site-url for subdirectory deployments (from great-docs.yml)
        if self._config.site_url:
            config["website"]["site-url"] = self._config.site_url

        # Enable page navigation for TOC
        if "page-navigation" not in config["website"]:
            config["website"]["page-navigation"] = True

        # Set title to package name if not already set
        if "title" not in config["website"]:
            # Use display_name from config if available, otherwise use package name as-is
            display_name = self._config.display_name
            if display_name:
                config["website"]["title"] = display_name
            else:
                package_name = self._detect_package_name()
                if not package_name and self._config.go_cli_enabled:
                    # For Go CLI-only projects there is no Python package name; fall
                    # back to the Go binary name so the navbar shows a home link.
                    go_project = self._detect_go_cli_project()
                    if go_project:
                        package_name = go_project.binary_name
                if package_name:
                    config["website"]["title"] = package_name

        # Get GitHub info and style preference
        owner, repo, repo_url = self._get_github_repo_info()
        metadata = self._get_package_metadata()
        github_style = metadata.get("github_style", "widget")  # "widget" or "icon"

        # Add or update navbar
        if "navbar" not in config["website"]:
            navbar_config = {
                "left": [],
            }

            # Add GitHub link on the right if repository URL is available
            if owner and repo and repo_url and github_style == "widget":
                stats = self._fetch_github_repo_stats(owner, repo)
                stats_attrs = ""
                if stats:
                    stats_attrs = f' data-stars="{stats["stars"]}" data-forks="{stats["forks"]}"'
                gh_widget_html = f'<div id="github-widget" data-owner="{owner}" data-repo="{repo}"{stats_attrs}></div>'
                navbar_config["right"] = [{"text": gh_widget_html}]
            elif repo_url:
                navbar_config["right"] = [{"icon": "github", "href": repo_url}]

            config["website"]["navbar"] = navbar_config
        else:
            # Update existing navbar: upgrade icon to widget if configured
            self._update_navbar_github_link(config, owner, repo, repo_url, github_style)

            # Remove legacy "Home" link (package name in navbar already links home)
            if "left" in config["website"]["navbar"]:
                config["website"]["navbar"]["left"] = [
                    item
                    for item in config["website"]["navbar"]["left"]
                    if not (isinstance(item, dict) and item.get("text") == "Home")
                ]

        # --- Logo & favicon injection ---
        logo_config = self._config.logo
        if logo_config is None:
            # Auto-detect logo from conventional paths
            logo_config = self._detect_logo()

        if logo_config is not None:
            package_root = self._find_package_root()
            navbar = config["website"]["navbar"]

            # Copy logo files into the Quarto project directory
            light_src = package_root / logo_config["light"]
            if light_src.is_file():
                light_dest_name = light_src.name
                # Avoid name collision: prefix with "logo-light" if needed
                if logo_config.get("dark") and logo_config["dark"] != logo_config["light"]:
                    if light_dest_name == Path(logo_config["dark"]).name:
                        light_dest_name = f"logo-light{light_src.suffix}"
                shutil.copy2(light_src, self.project_path / light_dest_name)
                navbar["logo"] = light_dest_name
            else:
                print(f"Warning: Logo file not found: {light_src}")

            # Dark variant
            dark_path = logo_config.get("dark")
            if dark_path and dark_path != logo_config["light"]:
                dark_src = package_root / dark_path
                if dark_src.is_file():
                    dark_dest_name = dark_src.name
                    shutil.copy2(dark_src, self.project_path / dark_dest_name)
                    navbar["logo-dark"] = dark_dest_name

                    # Ensure the dark logo is included as a resource so Quarto
                    # copies it to _site (needed for custom dark-mode toggle)
                    if "resources" not in config["project"]:
                        config["project"]["resources"] = []  # pragma: no cover
                    if dark_dest_name not in config["project"]["resources"]:
                        config["project"]["resources"].append(dark_dest_name)

                    # Inject a meta tag so dark-mode-toggle.js can find the
                    # dark logo path and fix the navbar <img> src at runtime
                    dark_logo_meta = {
                        "text": f'<meta name="gd-logo-dark" content="{dark_dest_name}">'
                    }
                    header_list = config["format"]["html"].setdefault("include-in-header", [])
                    if not any("gd-logo-dark" in str(h) for h in header_list):
                        header_list.append(dark_logo_meta)
                else:
                    print(f"Warning: Dark logo file not found: {dark_src}")

            # Alt text (for accessibility)
            alt_text = logo_config.get("alt")
            if not alt_text:
                # Fall back to display name or package name
                alt_text = self._config.display_name or self._detect_package_name() or ""
            if alt_text:
                navbar["logo-alt"] = alt_text

            # Logo href (defaults to site root)
            logo_href = logo_config.get("href")
            if logo_href:
                navbar["logo-href"] = logo_href

            # Suppress the text title in the navbar unless show_title is True
            if not self._config.logo_show_title:
                navbar["title"] = False

            # Favicon: use explicit config, or auto-generate from logo
            favicon_config = self._config.favicon
            generated: dict[str, str] = {}
            if favicon_config is not None:
                # User supplied explicit favicon — generate raster variants too
                icon_src_path = favicon_config.get("icon")
                if icon_src_path:
                    fav_src = package_root / icon_src_path
                    if fav_src.is_file():
                        generated = self._generate_favicons(fav_src, self.project_path)
                        if generated.get("icon"):
                            config["website"]["favicon"] = generated["icon"]
                        else:
                            # Fallback: just copy the file
                            shutil.copy2(
                                fav_src, self.project_path / fav_src.name
                            )  # pragma: no cover
                            config["website"]["favicon"] = fav_src.name  # pragma: no cover
                    else:
                        print(f"Warning: Favicon file not found: {fav_src}")  # pragma: no cover
            elif light_src.is_file():
                # Auto-generate favicons from the logo
                generated = self._generate_favicons(light_src, self.project_path)
                if generated.get("icon"):
                    config["website"]["favicon"] = generated["icon"]

            # Add generated favicon files to project.resources so Quarto copies
            # them into _site/ (without this they may be missing from the deployed site)
            if generated:
                favicon_files = list(dict.fromkeys(generated.values()))
                resources_list = config["project"].setdefault("resources", [])
                if isinstance(resources_list, str):
                    resources_list = [resources_list]  # pragma: no cover
                    config["project"]["resources"] = resources_list  # pragma: no cover
                for fname in favicon_files:
                    if fname not in resources_list:
                        resources_list.append(fname)

            # Inject <link> tags for extra favicon assets using absolute URLs.
            # Bare relative paths (e.g., href="favicon.ico") break on subpages because
            # Quarto does not rewrite paths inside raw HTML injected via include-in-header.
            # We derive the base URL from site-url or the Documentation URL in pyproject.toml.
            if generated:
                site_url = config.get("website", {}).get("site-url", "")
                if not site_url:
                    urls = metadata.get("urls", {})
                    site_url = urls.get("Documentation", "")
                if site_url:
                    # Clean up: strip fragment, ensure trailing slash
                    if "#" in site_url:
                        site_url = site_url.split("#")[0]
                    if not site_url.endswith("/"):
                        site_url += "/"

                    favicon_links: list[str] = []
                    if generated.get("icon") and generated["icon"].endswith(".ico"):
                        favicon_links.append(
                            f'<link rel="icon" type="image/x-icon" href="{site_url}favicon.ico" sizes="48x48">'
                        )
                    if generated.get("icon-svg"):
                        favicon_links.append(
                            f'<link rel="icon" type="image/svg+xml" href="{site_url}favicon.svg">'
                        )
                    if generated.get("icon-32"):
                        favicon_links.append(
                            f'<link rel="icon" type="image/png" sizes="32x32" href="{site_url}favicon-32x32.png">'
                        )
                    if generated.get("icon-16"):
                        favicon_links.append(
                            f'<link rel="icon" type="image/png" sizes="16x16" href="{site_url}favicon-16x16.png">'
                        )
                    if generated.get("apple-touch-icon"):
                        favicon_links.append(
                            f'<link rel="apple-touch-icon" sizes="180x180" href="{site_url}apple-touch-icon.png">'
                        )

                    if favicon_links:
                        header_items = config["format"]["html"].get("include-in-header", [])
                        if isinstance(header_items, str):
                            header_items = [header_items]  # pragma: no cover
                        for link_tag in favicon_links:
                            entry = {"text": link_tag}
                            if not any(link_tag in str(item) for item in header_items):
                                header_items.append(entry)
                        config["format"]["html"]["include-in-header"] = header_items

        # Add GitHub widget script to page if using widget style
        if owner and repo and github_style == "widget":
            if "include-after-body" not in config["format"]["html"]:
                config["format"]["html"]["include-after-body"] = []
            elif isinstance(config["format"]["html"]["include-after-body"], str):
                config["format"]["html"]["include-after-body"] = [
                    config["format"]["html"]["include-after-body"]
                ]

            # Add the GitHub widget script
            gh_script_entry = {"text": '<script src="github-widget.js"></script>'}
            if gh_script_entry not in config["format"]["html"]["include-after-body"]:
                # Check if github-widget.js is already included
                has_gh_widget = any(
                    "github-widget" in str(item)
                    for item in config["format"]["html"]["include-after-body"]
                )
                if not has_gh_widget:
                    config["format"]["html"]["include-after-body"].append(gh_script_entry)

        # Add copy-page widget script (if markdown_pages widget is enabled)
        if metadata.get("markdown_pages_widget", True):
            if "include-after-body" not in config["format"]["html"]:
                config["format"]["html"]["include-after-body"] = []
            elif isinstance(config["format"]["html"]["include-after-body"], str):
                config["format"]["html"]["include-after-body"] = [
                    config["format"]["html"]["include-after-body"]
                ]

            copy_page_entry = {"text": '<script src="copy-page.js"></script>'}
            has_copy_page = any(
                "copy-page" in str(item) for item in config["format"]["html"]["include-after-body"]
            )
            if not has_copy_page:
                config["format"]["html"]["include-after-body"].append(copy_page_entry)

        # Add sidebar smart-wrap script (inserts <wbr> for pretty line breaks)
        if "include-after-body" not in config["format"]["html"]:
            config["format"]["html"]["include-after-body"] = []
        elif isinstance(config["format"]["html"]["include-after-body"], str):
            config["format"]["html"]["include-after-body"] = [  # pragma: no cover
                config["format"]["html"]["include-after-body"]
            ]

        wrap_script_entry = {"text": '<script src="sidebar-wrap.js"></script>'}
        has_wrap = any(
            "sidebar-wrap" in str(item) for item in config["format"]["html"]["include-after-body"]
        )
        if not has_wrap:
            config["format"]["html"]["include-after-body"].append(wrap_script_entry)

        # Add floating sidebar script (pinned, scrollable sidebar on desktop).
        # Quarto does not always rewrite bare src paths in text entries for
        # newly-added files, so we use an inline loader that reads the
        # quarto:offset meta tag (always present) to resolve the site root.
        float_inline = (
            "<script>"
            "(function(){"
            "var s=document.createElement('script');"
            "var m=document.querySelector('meta[name=\"quarto:offset\"]');"
            "s.src=(m?m.content:'')+'sidebar-float.js';"
            "document.body.appendChild(s);"
            "})();"
            "</script>"
        )
        float_script_entry = {"text": float_inline}
        has_float = any(
            "sidebar-float" in str(item) for item in config["format"]["html"]["include-after-body"]
        )
        if not has_float:
            config["format"]["html"]["include-after-body"].append(float_script_entry)

        # Add sidebar filter script if enabled
        if metadata.get("sidebar_filter_enabled", True):
            if "include-after-body" not in config["format"]["html"]:
                config["format"]["html"]["include-after-body"] = []  # pragma: no cover
            elif isinstance(config["format"]["html"]["include-after-body"], str):
                config["format"]["html"]["include-after-body"] = [  # pragma: no cover
                    config["format"]["html"]["include-after-body"]
                ]

            # Add the sidebar filter script
            filter_script_entry = {"text": '<script src="sidebar-filter.js"></script>'}
            has_filter = any(
                "sidebar-filter" in str(item)
                for item in config["format"]["html"]["include-after-body"]
            )
            if not has_filter:
                config["format"]["html"]["include-after-body"].append(filter_script_entry)

            # Add data attributes for configuration (must be before sidebar-filter.js)
            min_items = metadata.get("sidebar_filter_min_items", 20)
            if min_items != 20:
                # Add custom min_items via body data attribute and insert BEFORE the filter script
                min_items_script = {
                    "text": f'<script>document.body.dataset.sidebarFilterMinItems = "{min_items}";</script>'
                }
                has_min_items = any(
                    "sidebarFilterMinItems" in str(item)
                    for item in config["format"]["html"]["include-after-body"]
                )
                if not has_min_items:
                    # Insert before the sidebar-filter.js script
                    filter_index = next(
                        (
                            i
                            for i, item in enumerate(config["format"]["html"]["include-after-body"])
                            if "sidebar-filter" in str(item)
                        ),
                        len(config["format"]["html"]["include-after-body"]),
                    )
                    config["format"]["html"]["include-after-body"].insert(
                        filter_index, min_items_script
                    )

        # Add dark mode toggle script (if enabled)
        dark_mode_enabled = metadata.get("dark_mode_toggle_enabled", True)

        # When dark mode toggle is disabled, add a meta tag so theme-init.js
        # knows to ignore any stored preference and always use light mode.
        if not dark_mode_enabled:
            if "include-in-header" not in config["format"]["html"]:
                config["format"]["html"]["include-in-header"] = []
            elif isinstance(config["format"]["html"]["include-in-header"], str):
                config["format"]["html"]["include-in-header"] = [
                    config["format"]["html"]["include-in-header"]
                ]
            dm_meta = {"text": '<meta name="gd-dark-mode" content="disabled">'}
            has_dm_meta = any(
                "gd-dark-mode" in str(item)
                for item in config["format"]["html"]["include-in-header"]
            )
            if not has_dm_meta:
                config["format"]["html"]["include-in-header"].append(dm_meta)

        if dark_mode_enabled:
            if "include-after-body" not in config["format"]["html"]:
                config["format"]["html"]["include-after-body"] = []  # pragma: no cover
            elif isinstance(config["format"]["html"]["include-after-body"], str):
                config["format"]["html"]["include-after-body"] = [  # pragma: no cover
                    config["format"]["html"]["include-after-body"]
                ]

            dark_mode_script_entry = {"text": '<script src="dark-mode-toggle.js"></script>'}
            has_dark_mode = any(
                "dark-mode-toggle" in str(item)
                for item in config["format"]["html"]["include-after-body"]
            )
            if not has_dark_mode:
                config["format"]["html"]["include-after-body"].append(dark_mode_script_entry)

        # Add back-to-top button script (if enabled)
        if metadata.get("back_to_top_enabled", True):
            if "include-after-body" not in config["format"]["html"]:
                config["format"]["html"]["include-after-body"] = []  # pragma: no cover
            elif isinstance(config["format"]["html"]["include-after-body"], str):
                config["format"]["html"]["include-after-body"] = [  # pragma: no cover
                    config["format"]["html"]["include-after-body"]
                ]

            back_to_top_entry = {"text": '<script src="back-to-top.js"></script>'}
            has_back_to_top = any(
                "back-to-top.js" in str(item)
                for item in config["format"]["html"]["include-after-body"]
            )
            if not has_back_to_top:
                config["format"]["html"]["include-after-body"].append(back_to_top_entry)

            on_this_page_entry = {
                "text": (
                    "<script>(function(){var s=document.createElement('script');"
                    "var m=document.querySelector('meta[name=\"quarto:offset\"]');"
                    "s.src=(m?m.content:'')+'on-this-page.js';"
                    "document.body.appendChild(s);})();</script>"
                )
            }
            has_on_this_page = any(
                "on-this-page.js" in str(item)
                for item in config["format"]["html"]["include-after-body"]
            )
            if not has_on_this_page:
                config["format"]["html"]["include-after-body"].append(on_this_page_entry)

        # Add keyboard navigation script (if enabled)
        if metadata.get("keyboard_nav_enabled", True):
            if "include-after-body" not in config["format"]["html"]:
                config["format"]["html"]["include-after-body"] = []  # pragma: no cover
            elif isinstance(config["format"]["html"]["include-after-body"], str):
                config["format"]["html"]["include-after-body"] = [  # pragma: no cover
                    config["format"]["html"]["include-after-body"]
                ]

            keyboard_nav_entry = {"text": '<script src="keyboard-nav.js"></script>'}
            has_keyboard_nav = any(
                "keyboard-nav.js" in str(item)
                for item in config["format"]["html"]["include-after-body"]
            )
            if not has_keyboard_nav:
                config["format"]["html"]["include-after-body"].append(keyboard_nav_entry)

        # Add custom copy-code button script (replaces Quarto's native code-copy)
        if "include-after-body" not in config["format"]["html"]:
            config["format"]["html"]["include-after-body"] = []  # pragma: no cover
        elif isinstance(config["format"]["html"]["include-after-body"], str):
            config["format"]["html"]["include-after-body"] = [  # pragma: no cover
                config["format"]["html"]["include-after-body"]
            ]

        copy_code_entry = {"text": '<script src="copy-code.js"></script>'}
        has_copy_code = any(
            "copy-code.js" in str(item) for item in config["format"]["html"]["include-after-body"]
        )
        if not has_copy_code:
            config["format"]["html"]["include-after-body"].append(copy_code_entry)

        # Add collapsible details script (accordion + smooth animation)
        details_entry = {"text": '<script src="details.js"></script>'}
        has_details = any(
            "details.js" in str(item) for item in config["format"]["html"]["include-after-body"]
        )
        if not has_details:
            config["format"]["html"]["include-after-body"].append(details_entry)

            # Add early theme detection script in header to prevent flash of wrong theme
            if "include-in-header" not in config["format"]["html"]:
                config["format"]["html"]["include-in-header"] = []  # pragma: no cover
            elif isinstance(config["format"]["html"]["include-in-header"], str):
                config["format"]["html"]["include-in-header"] = [  # pragma: no cover
                    config["format"]["html"]["include-in-header"]
                ]

            # Reference external script file for early theme detection (cleaner YAML)
            early_theme_script = {"text": '<script src="theme-init.js"></script>'}
            has_early_theme = any(
                "theme-init" in str(item) for item in config["format"]["html"]["include-in-header"]
            )
            if not has_early_theme:
                config["format"]["html"]["include-in-header"].append(early_theme_script)

        # Add mermaid renderer script (custom mermaid with proper theme support)
        mermaid_renderer_entry = {"text": '<script src="mermaid-renderer.js"></script>'}
        has_mermaid_renderer = any(
            "mermaid-renderer.js" in str(item)
            for item in config["format"]["html"]["include-after-body"]
        )
        if not has_mermaid_renderer:
            config["format"]["html"]["include-after-body"].append(mermaid_renderer_entry)

        # Add page metadata script (if show_dates is enabled)
        if self._config.show_dates:
            if "include-after-body" not in config["format"]["html"]:  # pragma: no cover
                config["format"]["html"]["include-after-body"] = []  # pragma: no cover
            elif isinstance(
                config["format"]["html"]["include-after-body"], str
            ):  # pragma: no cover
                config["format"]["html"]["include-after-body"] = [  # pragma: no cover
                    config["format"]["html"]["include-after-body"]
                ]

            page_metadata_entry = {
                "text": '<script src="page-metadata.js"></script>'
            }  # pragma: no cover
            has_page_metadata = any(  # pragma: no cover
                "page-metadata.js" in str(item)
                for item in config["format"]["html"]["include-after-body"]
            )
            if not has_page_metadata:  # pragma: no cover
                config["format"]["html"]["include-after-body"].append(
                    page_metadata_entry
                )  # pragma: no cover

        # Add page tags script (if tags are enabled with show_on_pages)
        if self._config.tags_show_on_pages:
            if "include-after-body" not in config["format"]["html"]:  # pragma: no cover
                config["format"]["html"]["include-after-body"] = []  # pragma: no cover
            elif isinstance(
                config["format"]["html"]["include-after-body"], str
            ):  # pragma: no cover
                config["format"]["html"]["include-after-body"] = [  # pragma: no cover
                    config["format"]["html"]["include-after-body"]
                ]

            page_tags_entry = {"text": '<script src="page-tags.js"></script>'}  # pragma: no cover
            has_page_tags = any(  # pragma: no cover
                "page-tags.js" in str(item)
                for item in config["format"]["html"]["include-after-body"]
            )
            if not has_page_tags:  # pragma: no cover
                config["format"]["html"]["include-after-body"].append(
                    page_tags_entry
                )  # pragma: no cover

        # Add page status badges script (if page_status is enabled)
        if self._config.page_status_enabled:
            if "include-after-body" not in config["format"]["html"]:  # pragma: no cover
                config["format"]["html"]["include-after-body"] = []  # pragma: no cover
            elif isinstance(
                config["format"]["html"]["include-after-body"], str
            ):  # pragma: no cover
                config["format"]["html"]["include-after-body"] = [  # pragma: no cover
                    config["format"]["html"]["include-after-body"]
                ]

            has_page_status = any(  # pragma: no cover
                "page-status-badges" in str(item)
                for item in config["format"]["html"]["include-after-body"]
            )
            if not has_page_status:  # pragma: no cover
                # Inline the script content so Quarto doesn't need to resolve
                # relative paths (which fail for file:// and nested pages)
                status_js_path = self.project_path / "page-status-badges.js"  # pragma: no cover
                if status_js_path.is_file():  # pragma: no cover
                    js_content = status_js_path.read_text(encoding="utf-8")  # pragma: no cover
                    page_status_entry = {
                        "text": f"<script>{js_content}</script>"
                    }  # pragma: no cover
                else:
                    page_status_entry = {
                        "text": '<script src="page-status-badges.js"></script>'
                    }  # pragma: no cover
                config["format"]["html"]["include-after-body"].append(
                    page_status_entry
                )  # pragma: no cover

        # Add reference switcher script (if CLI, Go CLI, or MCP is enabled)
        cli_enabled = metadata.get("cli_enabled", False)
        go_cli_enabled = metadata.get("go_cli_enabled", False)
        # Use the runtime flag if MCP discovery has already run; otherwise fall
        # back to the config value (optimistic, will be patched later if needed)
        mcp_enabled = (
            self._mcp_pages_generated
            if self._mcp_pages_generated is not None
            else metadata.get("mcp_enabled", False)
        )
        if cli_enabled or go_cli_enabled or mcp_enabled:
            if "include-after-body" not in config["format"]["html"]:
                config["format"]["html"]["include-after-body"] = []  # pragma: no cover
            elif isinstance(config["format"]["html"]["include-after-body"], str):
                config["format"]["html"]["include-after-body"] = [  # pragma: no cover
                    config["format"]["html"]["include-after-body"]
                ]

            # Build the sections list for the data attribute.
            # Only include "api" if a Python API reference was actually generated.
            ref_sections = []
            if self._has_api_reference:
                ref_sections.append("api")
            if cli_enabled or go_cli_enabled:
                ref_sections.append("cli")
            if mcp_enabled:
                ref_sections.append("mcp")
            sections_attr = ",".join(ref_sections)

            # Inject data-gd-ref-sections on <body> via include-in-header script
            if "include-in-header" not in config["format"]["html"]:
                config["format"]["html"]["include-in-header"] = []
            elif isinstance(config["format"]["html"]["include-in-header"], str):
                config["format"]["html"]["include-in-header"] = [
                    config["format"]["html"]["include-in-header"]
                ]
            ref_sections_script = {
                "text": (
                    "<script>"
                    f"document.addEventListener('DOMContentLoaded',function(){{document.body.setAttribute('data-gd-ref-sections','{sections_attr}');}});"
                    "</script>"
                )
            }
            # Always replace the ref-sections script so a later call to
            # _update_quarto_config (at Step 13) can correct stale values
            # written during prepare() when _has_api_reference was still True.
            headers = config["format"]["html"]["include-in-header"]
            replaced = False
            for i, item in enumerate(headers):
                if "data-gd-ref-sections" in str(item):
                    headers[i] = ref_sections_script
                    replaced = True
                    break
            if not replaced:
                headers.append(ref_sections_script)

            ref_switcher_script_entry = {"text": '<script src="reference-switcher.js"></script>'}
            has_ref_switcher = any(
                "reference-switcher" in str(item)
                for item in config["format"]["html"]["include-after-body"]
            )
            if not has_ref_switcher:
                config["format"]["html"]["include-after-body"].append(ref_switcher_script_entry)

        # Add tooltips script (always enabled — converts title attributes to styled tooltips)
        if "include-after-body" not in config["format"]["html"]:
            config["format"]["html"]["include-after-body"] = []  # pragma: no cover
        elif isinstance(config["format"]["html"]["include-after-body"], str):
            config["format"]["html"]["include-after-body"] = [  # pragma: no cover
                config["format"]["html"]["include-after-body"]
            ]

        tooltips_script_entry = {"text": '<script src="tooltips.js"></script>'}
        has_tooltips = any(
            "tooltips.js" in str(item) for item in config["format"]["html"]["include-after-body"]
        )
        if not has_tooltips:
            config["format"]["html"]["include-after-body"].append(tooltips_script_entry)

        # Add color-swatch script (always enabled — loaded after tooltips)
        # Use an inline loader that resolves the path against the site root via
        # Quarto's quarto:offset meta tag (always present). Bare src= paths and
        # canonical-URL stripping both 404 on nested pages; quarto:offset is the
        # robust pattern shared with termshow.js, on-this-page.js, etc.
        color_swatch_entry = {
            "text": (
                "<script>(function(){var s=document.createElement('script');"
                "var m=document.querySelector('meta[name=\"quarto:offset\"]');"
                "s.src=(m?m.content:'')+'color-swatch.js';"
                "document.body.appendChild(s);})();</script>"
            )
        }
        # Remove any stale plain <script src="color-swatch.js"> entries so the
        # dynamic loader below is used instead.
        config["format"]["html"]["include-after-body"] = [
            item
            for item in config["format"]["html"]["include-after-body"]
            if not (
                isinstance(item, dict)
                and "color-swatch.js" in item.get("text", "")
                and "createElement" not in item.get("text", "")
            )
        ]
        config["format"]["html"]["include-after-body"].append(color_swatch_entry)

        # Add responsive tables script (always enabled — wraps tables in scroll containers)
        responsive_tables_script_entry = {"text": '<script src="responsive-tables.js"></script>'}
        has_responsive_tables = any(
            "responsive-tables.js" in str(item)
            for item in config["format"]["html"]["include-after-body"]
        )
        if not has_responsive_tables:
            config["format"]["html"]["include-after-body"].append(responsive_tables_script_entry)

        # Add video embed script (always enabled — lazy loading + YouTube thumbnails)
        # Use an inline loader that resolves the path against the site root via
        # Quarto's quarto:offset meta tag (always present). A bare src= path 404s
        # on nested pages; quarto:offset is the robust pattern shared with
        # termshow.js, on-this-page.js, etc.
        video_embed_script_entry = {
            "text": (
                "<script>(function(){var s=document.createElement('script');"
                "var m=document.querySelector('meta[name=\"quarto:offset\"]');"
                "s.src=(m?m.content:'')+'video-embed.js';"
                "document.body.appendChild(s);})();</script>"
            )
        }
        # Remove any stale plain <script src="video-embed.js"> entries so the
        # dynamic loader below is used instead.
        config["format"]["html"]["include-after-body"] = [
            item
            for item in config["format"]["html"]["include-after-body"]
            if not (
                isinstance(item, dict)
                and "video-embed.js" in item.get("text", "")
                and "createElement" not in item.get("text", "")
            )
        ]
        has_video_embed = any(
            "video-embed.js" in str(item) for item in config["format"]["html"]["include-after-body"]
        )
        if not has_video_embed:
            config["format"]["html"]["include-after-body"].append(video_embed_script_entry)

        # Add termshow script (always enabled — only activates if .gd-termshow elements exist)
        term_player_script_entry = {
            "text": (
                "<script>(function(){var s=document.createElement('script');"
                "var m=document.querySelector('meta[name=\"quarto:offset\"]');"
                "s.src=(m?m.content:'')+'termshow.js';"
                "document.body.appendChild(s);})();</script>"
            )
        }
        has_term_player = any(
            "termshow.js" in str(item) for item in config["format"]["html"]["include-after-body"]
        )
        if not has_term_player:
            config["format"]["html"]["include-after-body"].append(term_player_script_entry)

        # Add navbar widget collector (always enabled — consolidates dark-mode,
        # keyboard, search, and GitHub widgets into #gd-navbar-widgets)
        navbar_widgets_entry = {"text": '<script src="navbar-widgets.js"></script>'}
        has_navbar_widgets = any(
            "navbar-widgets.js" in str(item)
            for item in config["format"]["html"]["include-after-body"]
        )
        if not has_navbar_widgets:
            config["format"]["html"]["include-after-body"].append(navbar_widgets_entry)

        # Add navigation icons (Lucide SVG) if configured
        if self._config.nav_icons:
            self._inject_nav_icons(config)  # pragma: no cover

        # Add sidebar navigation (reference sidebar added later by
        # _add_api_reference_config if exports are discovered)
        if "sidebar" not in config["website"]:
            config["website"]["sidebar"] = []

        # Add page footer with "Developed by" notice (pkgdown style) if not present
        if "page-footer" not in config["website"]:
            metadata = self._get_package_metadata()

            # Build a dict of author name -> homepage URL from rich_authors
            author_homepages: dict[str, str] = {}
            for author in metadata.get("rich_authors", []):
                if isinstance(author, dict) and author.get("name"):
                    homepage = author.get("homepage", "")
                    if homepage:
                        author_homepages[author["name"]] = homepage

            # Collect all author/maintainer names from pyproject.toml
            author_names: list[str] = []
            for author in metadata.get("authors", []):
                if isinstance(author, dict) and author.get("name"):
                    author_names.append(author["name"])
                elif isinstance(author, str):  # pragma: no cover
                    author_names.append(author)  # pragma: no cover

            for maintainer in metadata.get("maintainers", []):
                if isinstance(maintainer, dict) and maintainer.get("name"):
                    name = maintainer["name"]
                    if name not in author_names:
                        author_names.append(name)
                elif (
                    isinstance(maintainer, str) and maintainer not in author_names
                ):  # pragma: no cover
                    author_names.append(maintainer)  # pragma: no cover

            # Also check rich_authors from great-docs.yml (may have more detail)
            for author in metadata.get("rich_authors", []):
                if isinstance(author, dict) and author.get("name"):
                    name = author["name"]
                    if name not in author_names:
                        author_names.append(name)  # pragma: no cover

            if author_names:
                # Format names, making them links if they have a homepage
                formatted_names: list[str] = []
                for name in author_names:
                    # Use non-breaking spaces so each name stays on one line
                    display_name = name.replace(" ", "&nbsp;")
                    if name in author_homepages:
                        formatted_names.append(
                            f'<a href="{author_homepages[name]}"><strong>{display_name}</strong></a>'
                        )
                    else:
                        formatted_names.append(f"<strong>{display_name}</strong>")

                from ._translations import get_translation

                lang = self._config.language
                _dev_by = get_translation("developed_by", lang)
                _and = get_translation("and", lang)

                if len(formatted_names) == 1:
                    developed_by = f"{_dev_by} {formatted_names[0]}."
                elif len(formatted_names) == 2:
                    developed_by = f"{_dev_by} {formatted_names[0]} {_and} {formatted_names[1]}."
                else:
                    developed_by = (
                        f"{_dev_by} "
                        + ", ".join(formatted_names[:-1])
                        + f", {_and} "
                        + formatted_names[-1]
                        + "."
                    )

                # Build footer HTML with optional funding sentence
                footer_html = developed_by

                # Add funding organization as second sentence if configured
                funding = metadata.get("funding")
                if funding and isinstance(funding, dict) and funding.get("name"):
                    funder_name = funding["name"]
                    funder_url = funding.get("homepage", "")
                    # Use non-breaking spaces so the funder name stays on one line
                    funder_display = funder_name.replace(" ", "&nbsp;")
                    if funder_url:
                        funder_label = (
                            f'<a href="{funder_url}"><strong>{funder_display}</strong></a>'
                        )
                    else:
                        funder_label = f"<strong>{funder_display}</strong>"
                    _sup_by = get_translation("supported_by", lang)
                    footer_html += f" {_sup_by} {funder_label}."

                config["website"]["page-footer"] = {"center": footer_html}

            else:
                # No authors: still emit a footer if funding is configured
                funding = metadata.get("funding")
                if funding and isinstance(funding, dict) and funding.get("name"):
                    funder_name = funding["name"]
                    funder_url = funding.get("homepage", "")
                    # Use non-breaking spaces so the funder name stays on one line
                    funder_display = funder_name.replace(" ", "&nbsp;")
                    if funder_url:
                        funder_label = (
                            f'<a href="{funder_url}"><strong>{funder_display}</strong></a>'
                        )
                    else:
                        funder_label = f"<strong>{funder_display}</strong>"
                    from ._translations import get_translation

                    lang = self._config.language
                    _sup_by = get_translation("supported_by", lang)
                    config["website"]["page-footer"] = {"center": f"{_sup_by} {funder_label}."}

        # Append Great Docs attribution to footer if enabled
        if self._config.attribution:
            # Get the short commit hash of the great-docs source repo (only when
            # running from a development checkout, not a pip-installed copy)
            import subprocess

            gd_version_label = ""
            try:
                gd_source_dir = Path(__file__).resolve().parent.parent
                # Verify this is actually the great-docs repo, not the user's project
                remote_result = subprocess.run(
                    ["git", "config", "--get", "remote.origin.url"],
                    cwd=gd_source_dir,
                    capture_output=True,
                    **TEXT_MODE_KWARGS,
                    timeout=5,
                )
                remote_url = remote_result.stdout.strip() if remote_result.returncode == 0 else ""
                if "great-docs" in remote_url:
                    result = subprocess.run(
                        ["git", "rev-parse", "--short", "HEAD"],
                        cwd=gd_source_dir,
                        capture_output=True,
                        **TEXT_MODE_KWARGS,
                        timeout=5,
                    )
                    if result.returncode == 0:
                        short_hash = result.stdout.strip()
                        gd_version_label = (
                            f' (<a href="https://github.com/posit-dev/great-docs/'
                            f'commit/{short_hash}">{short_hash}</a>)'
                        )
            except Exception:  # pragma: no cover
                pass  # pragma: no cover

            from ._translations import get_translation

            _site_created = get_translation("site_created_with", self._config.language)
            attribution = f"{_site_created} <strong>Great&nbsp;Docs</strong>{gd_version_label}."

            if "page-footer" in config["website"]:
                existing = config["website"]["page-footer"].get("center", "")
                if "Great&nbsp;Docs" not in existing:
                    if existing:
                        config["website"]["page-footer"]["center"] = f"{existing}<br>{attribution}"
                    else:
                        config["website"]["page-footer"]["center"] = attribution  # pragma: no cover
            else:
                config["website"]["page-footer"] = {"center": attribution}

        # Add "Supported by Posit" badge if funding name contains "Posit" as a word
        funding = metadata.get("funding")
        if funding and isinstance(funding, dict) and funding.get("name"):
            if re.search(r"\bPosit\b", funding["name"], re.IGNORECASE):
                header_list = config["format"]["html"].setdefault("include-in-header", [])
                if isinstance(header_list, str):
                    header_list = [header_list]  # pragma: no cover
                    config["format"]["html"]["include-in-header"] = header_list  # pragma: no cover
                posit_badge_script = (
                    '<script src="https://cdn.jsdelivr.net/gh/posit-dev/'
                    'supported-by-posit/js/badge.min.js"></script>'
                )
                posit_entry = {"text": posit_badge_script}
                has_posit_badge = any("supported-by-posit" in str(item) for item in header_list)
                if not has_posit_badge:
                    header_list.append(posit_entry)

        # Add announcement banner if configured
        announcement = self._config.announcement
        if announcement:
            import html as html_mod
            import re as re_mod

            # Convert inline Markdown (backticks, bold, italic, links) to HTML
            # before escaping for the data attribute. The browser's getAttribute()
            # decodes the entities, so the HTML tags survive the round-trip.
            def _inline_md_to_html(text: str) -> str:
                text = re_mod.sub(r"`([^`]+)`", r"<code>\1</code>", text)
                text = re_mod.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", text)
                text = re_mod.sub(r"\*([^*]+)\*", r"<em>\1</em>", text)
                text = re_mod.sub(
                    r"\[([^\]]+)\]\(([^)]+)\)",
                    r'<a href="\2" style="color:inherit">\1</a>',
                    text,
                )
                return text

            ann_content = html_mod.escape(_inline_md_to_html(announcement["content"]))
            ann_type = html_mod.escape(announcement.get("type", "info"))
            ann_dismissable = "true" if announcement.get("dismissable", True) else "false"
            ann_url = html_mod.escape(announcement.get("url") or "")
            ann_style = html_mod.escape(announcement.get("style") or "")
            ann_position = html_mod.escape(announcement.get("position", "above-navbar"))

            ann_meta_tag = (
                f'<meta name="gd-announcement"'
                f' data-content="{ann_content}"'
                f' data-type="{ann_type}"'
                f' data-dismissable="{ann_dismissable}"'
                f' data-url="{ann_url}"'
                f' data-style="{ann_style}"'
                f' data-position="{ann_position}">'
            )

            # Add meta tag to header (replace any existing announcement meta)
            header_list = config["format"]["html"].setdefault("include-in-header", [])
            if isinstance(header_list, str):
                header_list = [header_list]  # pragma: no cover
                config["format"]["html"]["include-in-header"] = header_list  # pragma: no cover
            ann_meta_entry = {"text": ann_meta_tag}
            # Remove any stale announcement meta from a previous build
            header_list[:] = [h for h in header_list if "gd-announcement" not in str(h)]
            header_list.append(ann_meta_entry)

            # Add the banner script to after-body
            after_body = config["format"]["html"].setdefault("include-after-body", [])
            if isinstance(after_body, str):
                after_body = [after_body]  # pragma: no cover
                config["format"]["html"]["include-after-body"] = after_body  # pragma: no cover
            ann_script_entry = {"text": '<script src="announcement-banner.js"></script>'}
            if not any("announcement-banner" in str(item) for item in after_body):
                # Insert at position 0 so the banner script runs first
                after_body.insert(0, ann_script_entry)

            # Ensure the JS file is in resources
            resources_list = config["project"].setdefault("resources", [])
            if "announcement-banner.js" not in resources_list:
                resources_list.append("announcement-banner.js")

        # Add navbar gradient style if configured
        navbar_style = self._config.navbar_style
        if navbar_style:
            import html as html_mod_nb

            nb_preset = html_mod_nb.escape(str(navbar_style))
            nb_meta_tag = f'<meta name="gd-navbar-style" data-preset="{nb_preset}">'

            header_list = config["format"]["html"].setdefault("include-in-header", [])
            if isinstance(header_list, str):
                header_list = [header_list]  # pragma: no cover
                config["format"]["html"]["include-in-header"] = header_list  # pragma: no cover
            header_list[:] = [h for h in header_list if "gd-navbar-style" not in str(h)]
            header_list.append({"text": nb_meta_tag})

            after_body = config["format"]["html"].setdefault("include-after-body", [])
            if isinstance(after_body, str):
                after_body = [after_body]  # pragma: no cover
                config["format"]["html"]["include-after-body"] = after_body  # pragma: no cover
            nb_script_entry = {"text": '<script src="navbar-style.js"></script>'}
            if not any("navbar-style" in str(item) for item in after_body):
                after_body.append(nb_script_entry)

            resources_list = config["project"].setdefault("resources", [])
            if "navbar-style.js" not in resources_list:
                resources_list.append("navbar-style.js")

        # Add site-wide accent color if configured
        accent_color = self._config.accent_color
        if accent_color:
            import html as html_mod_ac

            accent_css_parts: list[str] = []
            for mode in ("light", "dark"):
                color_val = accent_color.get(mode)
                if not color_val:
                    continue
                escaped = html_mod_ac.escape(color_val)
                if mode == "light":
                    selector = ":root, html.quarto-light, :root[data-bs-theme='light']"
                else:
                    selector = "html.quarto-dark, :root[data-bs-theme='dark']"
                accent_css_parts.append(f"{selector} {{\n    --gd-accent: {escaped};\n}}")
            if accent_css_parts:
                accent_css = "\n".join(accent_css_parts)
                accent_style_tag = (
                    f"<style>\n/* Great Docs: accent_color overrides */\n{accent_css}\n</style>"
                )
                after_body = config["format"]["html"].setdefault("include-after-body", [])
                if isinstance(after_body, str):
                    after_body = [after_body]
                    config["format"]["html"]["include-after-body"] = after_body
                after_body[:] = [h for h in after_body if "accent_color overrides" not in str(h)]
                after_body.append({"text": accent_style_tag})

        # Add navbar solid color if configured (ignored when `navbar_style` is set)
        navbar_color = self._config.navbar_color
        if navbar_color:
            from great_docs.contrast import ideal_text_color, parse_color

            css_parts: list[str] = []

            for mode in ("light", "dark"):
                bg = navbar_color.get(mode)
                if not bg:
                    continue
                try:
                    r, g, b = parse_color(bg)
                except ValueError:  # pragma: no cover
                    continue  # pragma: no cover

                bg_hex = f"#{r:02x}{g:02x}{b:02x}"
                text_hex = ideal_text_color(bg)
                # Determine if elements on the navbar should look "light" or "dark"
                is_light_fg = text_hex.lower() in ("#ffffff", "#fff", "white")

                if is_light_fg:
                    # Light text elements on a dark-ish background
                    hover_bg = "rgba(255, 255, 255, 0.15)"
                    border_col = "rgba(255, 255, 255, 0.15)"
                    btn_bg = "rgba(255, 255, 255, 0.08)"
                    btn_border = "rgba(255, 255, 255, 0.15)"
                    btn_hover_bg = "rgba(255, 255, 255, 0.12)"
                    btn_hover_border = "rgba(255, 255, 255, 0.25)"
                    active_underline = "rgba(255, 255, 255, 0.75)"
                    toggler_stroke = "%23e0e0e0"
                else:
                    # Dark text elements on a light background
                    hover_bg = "rgba(0, 0, 0, 0.08)"
                    border_col = "rgba(0, 0, 0, 0.08)"
                    btn_bg = "rgba(0, 0, 0, 0.06)"
                    btn_border = "rgba(0, 0, 0, 0.15)"
                    btn_hover_bg = "rgba(0, 0, 0, 0.10)"
                    btn_hover_border = "rgba(0, 0, 0, 0.25)"
                    active_underline = "rgba(0, 0, 0, 0.45)"
                    toggler_stroke = "rgba(0,0,0,0.65)"

                if mode == "light":
                    prefixes = ("html.quarto-light", ":root[data-bs-theme='light']")
                else:
                    prefixes = ("html.quarto-dark", ":root[data-bs-theme='dark']")

                # Distribute each suffix across both theme prefixes.  Writing
                # this as a comma-joined `selector` and then appending
                # ` .navbar` is a trap: `html.quarto-dark, :root[...] .navbar`
                # parses as two selectors — `html.quarto-dark` (the bare root
                # element) and `:root[...] .navbar` — so the navbar styling
                # leaks onto <html>.  Firefox then paints that leaked
                # background/border in the transparent regions of the blended
                # homepage as gray bars behind the content (issue #230).
                def sel(*suffixes: str) -> str:
                    return ",\n".join(p + suffix for suffix in suffixes for p in prefixes)

                css_parts.append(f"""{sel("")} {{
    --gd-navbar-bg: {bg_hex};
    --gd-navbar-text: {text_hex};
}}
{sel(" .navbar")} {{
    background: {bg_hex} !important;
    border-bottom: 1px solid {border_col};
}}
{sel(" .navbar .navbar-title", " .navbar .nav-link")} {{
    color: {text_hex} !important;
}}
{sel(" .navbar .nav-link:hover")} {{
    background-color: {hover_bg} !important;
    color: {text_hex} !important;
}}
{sel(" .navbar .nav-item:has(#github-widget) .nav-link:hover")} {{
    background-color: transparent !important;
}}
{sel(" .navbar .nav-link.active")} {{
    text-decoration-color: {active_underline} !important;
}}
{sel(" .navbar .dark-mode-toggle", " #quarto-search .aa-DetachedSearchButton")} {{
    background: {btn_bg};
    border-color: {btn_border};
    color: {text_hex};
}}
{sel(" .navbar .dark-mode-toggle:hover", " #quarto-search .aa-DetachedSearchButton:hover")} {{
    background: {btn_hover_bg};
    border-color: {btn_hover_border};
}}
{sel(" .navbar .gh-widget-trigger")} {{
    background: {btn_bg};
    border-color: {btn_border};
    color: {text_hex};
}}
{sel(" .navbar .gh-widget-trigger:hover")} {{
    background: {btn_hover_bg};
    border-color: {btn_hover_border};
}}
{sel(" .navbar .quarto-navbar-tools button", " .navbar .quarto-navbar-tools .quarto-navigation-tool")} {{
    background: {btn_bg};
    border-color: {btn_border};
    color: {text_hex};
}}
{sel(" .navbar .quarto-navbar-tools button:hover", " .navbar .quarto-navbar-tools .quarto-navigation-tool:hover")} {{
    background: {btn_hover_bg};
    border-color: {btn_hover_border};
}}
{sel(" .navbar .nav-item.compact .nav-link", " .navbar .gd-navbar-icon")} {{
    background: {btn_bg};
    border: 1px solid {btn_border};
    border-radius: 6px;
    color: {text_hex};
}}
{sel(" .navbar .nav-item.compact .nav-link:hover", " .navbar .gd-navbar-icon:hover")} {{
    background: {btn_hover_bg};
    border-color: {btn_hover_border};
}}
{sel(" .navbar .navbar-toggler-icon")} {{
    background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='{toggler_stroke}' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") !important;
}}
{sel(" .navbar .bi", " .navbar .aa-SubmitIcon", " .navbar .aa-SearchIcon", " .navbar .aa-DetachedSearchButtonIcon", " .navbar .aa-DetachedSearchButtonIcon svg")} {{
    color: {text_hex} !important;
    fill: {text_hex} !important;
}}
{sel(" .navbar .version-badge")} {{
    color: {text_hex};
    opacity: 0.75;
    background-color: {btn_bg};
}}""")

            if css_parts:
                navbar_color_css = "\n".join(css_parts)
                style_tag = f"<style>\n/* Great Docs: navbar_color overrides */\n{navbar_color_css}\n</style>"
                after_body = config["format"]["html"].setdefault("include-after-body", [])
                if isinstance(after_body, str):
                    after_body = [after_body]  # pragma: no cover
                    config["format"]["html"]["include-after-body"] = after_body  # pragma: no cover
                after_body[:] = [h for h in after_body if "navbar_color overrides" not in str(h)]
                after_body.append({"text": style_tag})

        # Add content area gradient glow if configured
        content_style = self._config.content_style
        if content_style:
            import html as html_mod_cs

            cs_preset = html_mod_cs.escape(str(content_style["preset"]))
            cs_pages = html_mod_cs.escape(str(content_style["pages"]))
            cs_meta_tag = (
                f'<meta name="gd-content-style" data-preset="{cs_preset}" data-pages="{cs_pages}">'
            )

            header_list = config["format"]["html"].setdefault("include-in-header", [])
            if isinstance(header_list, str):
                header_list = [header_list]  # pragma: no cover
                config["format"]["html"]["include-in-header"] = header_list  # pragma: no cover
            header_list[:] = [h for h in header_list if "gd-content-style" not in str(h)]
            header_list.append({"text": cs_meta_tag})

            after_body = config["format"]["html"].setdefault("include-after-body", [])
            if isinstance(after_body, str):
                after_body = [after_body]  # pragma: no cover
                config["format"]["html"]["include-after-body"] = after_body  # pragma: no cover
            cs_script_entry = {"text": '<script src="content-style.js"></script>'}
            if not any("content-style" in str(item) for item in after_body):
                after_body.append(cs_script_entry)

            resources_list = config["project"].setdefault("resources", [])
            if "content-style.js" not in resources_list:
                resources_list.append("content-style.js")

        # Add scale-to-fit selectors if configured
        scale_selectors = self._config.scale_to_fit
        if scale_selectors:
            import html as html_mod_stf

            # JSON-encode the selector list and HTML-escape for the attribute
            import json as json_mod_stf

            selectors_json = html_mod_stf.escape(json_mod_stf.dumps(scale_selectors))
            min_scale = self._config.scale_to_fit_min_scale
            min_scale_attr = f' data-min-scale="{min_scale}"' if min_scale else ""
            stf_meta_tag = (
                f'<meta name="gd-scale-to-fit" data-selectors="{selectors_json}"{min_scale_attr}>'
            )

            header_list = config["format"]["html"].setdefault("include-in-header", [])
            if isinstance(header_list, str):
                header_list = [header_list]  # pragma: no cover
                config["format"]["html"]["include-in-header"] = header_list  # pragma: no cover
            header_list[:] = [h for h in header_list if "gd-scale-to-fit" not in str(h)]
            header_list.append({"text": stf_meta_tag})

        # Write package metadata JSON for post-render version badge injection.
        # The version and release date come from the latest GitHub Release so
        # the badge always reflects what has actually been published.
        meta_path = self.project_path / "_package_meta.json"

        if owner and repo:
            try:
                releases = self._fetch_github_releases(owner, repo, max_releases=1)
            except Exception:  # pragma: no cover
                releases = []

            if releases:
                latest = releases[0]
                tag = latest.get("tag_name", "")
                published = latest.get("published_at", "")

                # Strip leading 'v' for normalisation, then re-add for display
                version_str = tag.lstrip("v") if tag else ""

                if version_str:
                    meta: dict[str, str] = {"version": version_str}
                    if published:
                        meta["published_at"] = published
                    with open(meta_path, "w") as f:
                        json.dump(meta, f)
                    print(f"Wrote package metadata (version={version_str}) to {meta_path}")
                else:
                    # Tag with no version-like content — remove stale file
                    meta_path.unlink(missing_ok=True)  # pragma: no cover
            else:
                print(
                    "No GitHub releases found; skipping version badge metadata"
                )  # pragma: no cover
                # Remove stale metadata from a previous build
                meta_path.unlink(missing_ok=True)  # pragma: no cover
        else:
            print("No GitHub repository info available; skipping version badge metadata")
            meta_path.unlink(missing_ok=True)

        # Register Quarto extension filters (output-title wraps cell
        # outputs in a titled container when #| output-title: is set)
        if "filters" not in config:
            config["filters"] = []
        if "output-title" not in config["filters"]:
            config["filters"].append("output-title")
        if "details" not in config["filters"]:
            config["filters"].append("details")
        if "gd-lightbox" not in config["filters"]:
            config["filters"].append("gd-lightbox")

        # Apply explicit navbar ordering from config (if set)
        self._reorder_navbar(config)

        # Write back to file
        self._write_quarto_yml(quarto_yml, config)

        # Inject version selector widget if multi-version is enabled
        if self._config.has_versions:
            self._inject_version_selector(quarto_yml)

        print(f"Updated {quarto_yml} with great-docs configuration")

    def _inject_version_selector(self, quarto_yml: Path) -> None:
        """
        Inject the version selector widget into the Quarto config.

        Adds the version-selector.js script and a `<meta name="gd-version-map">` tag containing the
        serialized `_version_map.json` data so the widget can resolve versions client-side.
        """
        import html as html_mod

        from great_docs._versioning import build_version_map, parse_versions_config

        with open(quarto_yml, "r") as f:
            config = read_yaml(f) or {}

        versions = parse_versions_config(self._config.versions)

        # Build a minimal version map (pages populated later during build)
        version_map = build_version_map(versions, {v.tag: [] for v in versions})

        # Serialize the version map as a meta tag in the header
        version_map_json = html_mod.escape(json.dumps(version_map, separators=(",", ":")))
        version_meta = f'<meta name="gd-version-map" content="{version_map_json}">'

        header_list = config["format"]["html"].setdefault("include-in-header", [])
        if isinstance(header_list, str):
            header_list = [header_list]
            config["format"]["html"]["include-in-header"] = header_list
        # Remove stale version map meta from previous build
        header_list[:] = [h for h in header_list if "gd-version-map" not in str(h)]
        header_list.append({"text": version_meta})

        # Add warning banner meta if disabled
        if not self._config.version_warning_banner:
            banner_meta = '<meta name="gd-version-warning-banner" content="false">'
            header_list[:] = [h for h in header_list if "gd-version-warning-banner" not in str(h)]
            header_list.append({"text": banner_meta})

        # Add the version selector script to after-body, BEFORE navbar-widgets.js
        # so the widget element exists when the collector runs.
        after_body = config["format"]["html"].setdefault("include-after-body", [])
        if isinstance(after_body, str):
            after_body = [after_body]
            config["format"]["html"]["include-after-body"] = after_body
        vs_script_entry = {"text": '<script src="version-selector.js"></script>'}
        if not any("version-selector" in str(item) for item in after_body):
            # Insert before navbar-widgets.js if present, otherwise append
            nw_idx = next(
                (i for i, item in enumerate(after_body) if "navbar-widgets" in str(item)),
                None,
            )
            if nw_idx is not None:
                after_body.insert(nw_idx, vs_script_entry)
            else:
                after_body.append(vs_script_entry)

        # Ensure version-selector.js is in resources
        resources_list = config["project"].setdefault("resources", [])
        if "version-selector.js" not in resources_list:
            resources_list.append("version-selector.js")

        self._write_quarto_yml(quarto_yml, config)

    def _auto_save_snapshot(self) -> None:
        """
        Auto-save an API snapshot after a successful build (Strategy C).

        Saves to `.great-docs/snapshots/<version>.json` where `<version>` is the current git tag (if
        exactly on one) or `dev`.
        """
        import subprocess as _sp

        from great_docs._api_diff import snapshot_from_griffe

        package_name = self._detect_package_name()
        if not package_name:
            return

        # griffe loads by importable module name, which can differ from the
        # PyPI project name.
        importable_name = self._resolve_importable_name(package_name)

        # Determine version label
        version = "dev"
        try:
            result = _sp.run(
                ["git", "describe", "--tags", "--exact-match", "HEAD"],
                cwd=self.project_root,
                capture_output=True,
                **TEXT_MODE_KWARGS,
                timeout=5,
            )
            if result.returncode == 0 and result.stdout.strip():
                version = result.stdout.strip()
        except Exception:
            pass

        snap = snapshot_from_griffe(importable_name, version=version)

        # Include CLI snapshot when CLI documentation is enabled
        if self._config.cli_enabled:
            try:
                from great_docs._api_diff import snapshot_cli_from_click

                cli_obj = self._find_click_cli_obj(importable_name)
                if cli_obj is not None:
                    snap.cli_commands = snapshot_cli_from_click(cli_obj)
            except Exception:
                pass  # CLI snapshot is best-effort

        snap_dir = self.project_root / ".great-docs" / "snapshots"
        snap.save(snap_dir / f"{version}.json")

    def _update_sidebar_from_sections(self) -> None:
        """
        Update sidebar navigation based on API reference sections.

        Builds a structured sidebar with sections and their contents, and excludes the index page
        from showing the sidebar.
        """
        quarto_yml = self.project_path / "_quarto.yml"

        if not quarto_yml.exists():
            return  # pragma: no cover

        with open(quarto_yml, "r") as f:
            config = read_yaml(f) or {}

        # Get API reference sections if they exist
        if "api-reference" not in config or "sections" not in config["api-reference"]:
            return

        sections = config["api-reference"]["sections"]
        sidebar_contents = []

        # Build sidebar structure from sections
        for section in sections:
            section_entry = {"section": section["title"], "contents": []}

            # Add each item in the section
            for item in section.get("contents", []):
                # Handle both string and dict formats
                if isinstance(item, str):
                    item_name = item
                elif isinstance(item, dict):
                    item_name = item.get("name", str(item))
                else:
                    item_name = str(item)  # pragma: no cover
                section_entry["contents"].append(
                    {"text": item_name, "href": f"reference/{item_name}.qmd"}
                )

            sidebar_contents.append(section_entry)

        # Update sidebar configuration
        if "website" not in config:
            config["website"] = {}  # pragma: no cover

        # Build sidebar with API link at top (not subject to filtering)
        # followed by the sectioned contents
        from ._translations import get_translation

        api_index_label = get_translation("api_index", self._config.language)
        full_contents = [
            {"text": api_index_label, "href": "reference/index.qmd"},
        ] + sidebar_contents

        config["website"]["sidebar"] = [
            {
                "id": "reference",
                "contents": full_contents,
            }
        ]

        # Write back
        self._write_quarto_yml(quarto_yml, config)

    def _update_reference_index_frontmatter(self) -> None:
        """Ensure reference/index.qmd has proper frontmatter."""
        index_path = self.docs_dir / "reference" / "index.qmd"

        if not index_path.exists():
            return

        # Read the current content
        with open(index_path, "r") as f:
            content = f.read()

        # Check if frontmatter already exists; if so, inject page-navigation
        if content.startswith("---"):
            fm_section = content.split("---", 2)[1]
            if "page-navigation:" not in fm_section:
                content = content.replace("---\n", "---\npage-navigation: false\n", 1)
            if "html-table-processing:" not in fm_section:
                content = content.replace("---\n", "---\nhtml-table-processing: none\n", 1)
            with open(index_path, "w") as f:
                f.write(content)
            return

        # Add minimal frontmatter if none exists
        content = f"---\npage-navigation: false\nhtml-table-processing: none\n---\n\n{content}"

        # Write updated content
        with open(index_path, "w") as f:
            f.write(content)

    def _generate_llms_txt(self) -> None:
        """
        Generate an llms.txt file for LLM documentation indexing.

        Creates a structured markdown file that indexes the API reference pages, following the
        llms.txt standard format for LLM-readable documentation. The file is saved to the docs
        directory and will be included in the built site.

        The format follows the structure:

        - package title with description
        - API Reference section with links to each documented item
        """
        quarto_yml = self.project_path / "_quarto.yml"

        if not quarto_yml.exists():
            return

        with open(quarto_yml, "r") as f:
            config = read_yaml(f) or {}

        # Get API reference sections and package info
        if "api-reference" not in config:
            return

        api_ref_config = config["api-reference"]
        sections = api_ref_config.get("sections", [])
        package_name = api_ref_config.get("package")

        if not package_name or not sections:
            return  # pragma: no cover

        # Get package metadata for description and site URL
        metadata = self._get_package_metadata()
        description = metadata.get("description", "")

        # Get the site URL and prefer the Documentation URL from pyproject.toml,
        # fall back to site-url from _quarto.yml
        urls = metadata.get("urls", {})
        site_url = urls.get("Documentation", "") or config.get("website", {}).get("site-url", "")

        # Clean up site URL and remove any trailing anchors or paths that aren't the base
        if site_url:
            # Remove trailing #readme or similar anchors
            if "#" in site_url:
                site_url = site_url.split("#")[0]
            # Ensure trailing slash
            if not site_url.endswith("/"):
                site_url += "/"

        # Build the llms.txt content
        lines = []

        # Header with package name
        lines.append(f"# {package_name}")
        lines.append("")

        # Description
        if description:
            lines.append(f"> {description}")
            lines.append("")

        # API Reference section
        lines.append("## Docs")
        lines.append("")
        lines.append("### API Reference")
        lines.append("")

        # Process each section
        for section in sections:
            section_title = section.get("title", "")
            section_desc = section.get("desc", "")

            # Add section header as a comment or sub-heading if there are multiple sections
            if len(sections) > 1 and section_title:
                lines.append(f"#### {section_title}")
                if section_desc:
                    lines.append(f"> {section_desc}")
                lines.append("")

            # Add each item in the section
            for item in section.get("contents", []):
                # Handle both string and dict formats
                if isinstance(item, str):
                    item_name = item
                    item_desc = ""
                elif isinstance(item, dict):
                    item_name = item.get("name", str(item))
                    item_desc = ""
                else:
                    continue  # pragma: no cover

                # Get description from docstring if available
                if not item_desc:
                    item_desc = self._get_docstring_summary(package_name, item_name)

                # Build the URL
                if site_url:
                    url = f"{site_url}reference/{item_name}.html"
                else:
                    url = f"reference/{item_name}.html"

                # Format the line
                if item_desc:
                    lines.append(f"- [{item_name}]({url}): {item_desc}")
                else:
                    lines.append(f"- [{item_name}]({url})")

            lines.append("")

        # Write the llms.txt file
        llms_txt_path = self.project_path / "llms.txt"
        with open(llms_txt_path, "w", encoding="utf-8") as f:
            f.write("\n".join(lines))

        print(f"Created {llms_txt_path}")

    def _get_docstring_summary(self, package_name: str, item_name: str) -> str:
        """
        Get the first line of a docstring for an item.

        Parameters
        ----------
        package_name
            The name of the package containing the item.
        item_name
            The name of the class, function, or module to get the docstring for.

        Returns
        -------
        str
            The first line of the docstring, or empty string if not available.
        """
        try:
            import importlib

            # Normalize package name
            normalized_name = package_name.replace("-", "_")
            module = importlib.import_module(normalized_name)

            # Try to get the object
            obj = getattr(module, item_name, None)
            if obj is None:
                return ""

            # Get docstring
            docstring = getattr(obj, "__doc__", None)
            if not docstring:
                return ""  # pragma: no cover

            # Extract first line/sentence
            first_line = docstring.strip().split("\n")[0].strip()

            # Clean up the line (remove trailing periods, normalize whitespace)
            first_line = first_line.rstrip(".")

            return first_line

        except Exception:  # pragma: no cover
            return ""  # pragma: no cover

    def _generate_llms_full_txt(self) -> None:
        """
        Generate an llms-full.txt file with comprehensive API documentation for LLMs.

        Creates a detailed markdown file containing full function/class signatures and
        docstrings, plus CLI help text if available. This provides comprehensive context
        for LLM-based tools to understand and work with the package's API.

        The format includes:
        - Package header with description
        - Full API documentation with signatures and docstrings organized by section
        - CLI documentation with --help output for all commands
        """

        quarto_yml = self.project_path / "_quarto.yml"

        if not quarto_yml.exists():
            return

        with open(quarto_yml, "r") as f:
            config = read_yaml(f) or {}

        # Get API reference sections and package info
        if "api-reference" not in config:
            return

        api_ref_config = config["api-reference"]
        sections = api_ref_config.get("sections", [])
        package_name = api_ref_config.get("package")

        if not package_name:
            return

        # Get package metadata
        metadata = self._get_package_metadata()
        description = metadata.get("description", "")

        # Import the package
        try:
            import importlib

            normalized_name = package_name.replace("-", "_")
            module = importlib.import_module(normalized_name)
        except ImportError as e:
            print(f"Could not import {package_name}: {e}")
            return

        sep_line = "-" * 70

        # Build the content
        lines = []
        lines.append(sep_line)
        lines.append(f"This is the API documentation for the {package_name} library.")
        lines.append(sep_line)
        lines.append("")

        # Process each section
        for section in sections:
            section_title = section.get("title", "")
            section_desc = section.get("desc", "")

            # Add section header
            if section_title:
                lines.append(f"\n## {section_title}\n")
                if section_desc:
                    lines.append(f"{section_desc}\n")
                lines.append("")

            # Process each item in the section
            for item in section.get("contents", []):
                # Handle both string and dict formats
                if isinstance(item, str):
                    item_name = item
                elif isinstance(item, dict):
                    item_name = item.get("name", str(item))
                else:
                    continue  # pragma: no cover

                # Get the object's signature and docstring
                api_text = self._get_api_details(module, item_name)
                if api_text:
                    lines.append(api_text)
                    lines.append("")

        # Add CLI documentation if enabled
        cli_text = self._get_cli_help_text_for_llms()
        if cli_text:
            lines.append("")
            lines.append(sep_line)
            lines.append("This is the CLI documentation for the package.")
            lines.append(sep_line)
            lines.append("")
            lines.append(cli_text)

        # Add User Guide content if available
        user_guide_text = self._get_user_guide_text_for_llms()
        if user_guide_text:
            lines.append("")
            lines.append(sep_line)
            lines.append("This is the User Guide documentation for the package.")
            lines.append(sep_line)
            lines.append("")
            lines.append(user_guide_text)

        # Write the llms-full.txt file
        llms_full_path = self.project_path / "llms-full.txt"
        with open(llms_full_path, "w", encoding="utf-8") as f:
            f.write("\n".join(lines))

        print(f"Created {llms_full_path}")

    def _get_api_details(self, module, item_name: str) -> str:
        """
        Retrieve the signature and docstring for a function/class.

        Parameters
        ----------
        module
            The module from which to retrieve the function/class.
        item_name
            The name of the function or class (can be dotted for nested attributes).

        Returns
        -------
        str
            A string containing the name, signature, and docstring.
        """
        import inspect

        try:
            # Split the attribute path to handle nested attributes (e.g., "Class.method")
            parts = item_name.split(".")
            obj = module
            for part in parts:
                obj = getattr(obj, part)

            # Get the name of the object
            obj_name = getattr(obj, "__name__", item_name)

            # Get the function/class signature
            try:
                sig = inspect.signature(obj)
                sig_str = f"{obj_name}{sig}"
            except (ValueError, TypeError):
                # Some objects don't have signatures (e.g., some built-ins)
                sig_str = obj_name

            # Get the docstring
            doc = getattr(obj, "__doc__", None) or ""

            # Clean up the docstring - remove excessive indentation
            if doc:
                doc = inspect.cleandoc(doc)

            # Combine the signature and docstring
            return f"{sig_str}\n\n{doc}" if doc else sig_str

        except AttributeError:  # pragma: no cover
            return ""  # pragma: no cover
        except Exception:  # pragma: no cover
            return ""  # pragma: no cover

    def _generate_skill_md(self) -> None:
        """
        Generate a SKILL.md file conforming to the Agent Skills specification.

        Creates a skill file that gives AI coding agents structured context about the documented
        package: its capabilities, API decision table, gotchas, and links to comprehensive
        documentation.

        If the user has provided a hand-written SKILL.md via `skill.file` in `great-docs.yml`, that
        file is copied verbatim instead of generating one.

        When `skill.skills` is configured with multiple named skills, each is placed at its own
        `.well-known/agent-skills/<name>/SKILL.md` path and aggregated into a single `index.json`
        discovery manifest. The first skill is also written to `<docs>/skill.md`.

        The generated file is written to `<docs>/skill.md` and optionally published at
        `<docs>/.well-known/agent-skills/<name>/SKILL.md` with a discovery manifest at
        `<docs>/.well-known/agent-skills/index.json` for `npx skills add` auto-discovery.
        """
        import shutil

        if not self._config.skill_enabled:
            return

        package_root = self._find_package_root()

        # --- Multi-skill mode: skill.skills is configured ---
        if self._config.skill_skills:
            self._generate_multi_skills(package_root)
            return

        # If user provided a hand-written SKILL.md via config, copy it
        if self._config.skill_file:
            src = package_root / self._config.skill_file
            if src.exists():
                dest = self.project_path / "skill.md"
                shutil.copy2(src, dest)
                print(f"Copied user SKILL.md from {src}")
                self._place_well_known_skill(dest)
                self._generate_skills_page(dest, skill_dir=src.parent)
                return
            else:
                print(f"Warning: skill.file '{src}' not found, falling back to discovery")

        # Check for a curated skill in skills/<package-name>/SKILL.md
        # This is the standard Agent Skills repo layout for distribution
        package_name_for_skill = self._detect_package_name() or ""
        install_name = package_name_for_skill.replace("_", "-") if package_name_for_skill else ""

        for candidate_name in [install_name, package_name_for_skill]:
            if not candidate_name:
                continue
            skills_path = package_root / "skills" / candidate_name / "SKILL.md"
            if skills_path.exists():
                dest = self.project_path / "skill.md"
                shutil.copy2(skills_path, dest)
                print(f"Using curated skill from {skills_path}")
                self._place_well_known_skill(dest)
                self._generate_skills_page(dest, skill_dir=skills_path.parent)
                return

        quarto_yml = self.project_path / "_quarto.yml"
        if not quarto_yml.exists():
            return

        with open(quarto_yml, "r") as f:
            config = read_yaml(f) or {}

        # Get API reference sections and package info
        api_ref_config = config.get("api-reference", {})
        sections = api_ref_config.get("sections", [])
        package_name = api_ref_config.get("package", "")

        # Get package metadata
        metadata = self._get_package_metadata()
        description = metadata.get("description", "")
        license_text = metadata.get("license", "")
        requires_python = metadata.get("requires_python", "")

        # Get site URL
        urls = metadata.get("urls", {})
        site_url = urls.get("Documentation", "") or config.get("website", {}).get("site-url", "")
        if site_url and "#" in site_url:
            site_url = site_url.split("#")[0]  # pragma: no cover
        if site_url and not site_url.endswith("/"):
            site_url += "/"

        repo_url = urls.get("Repository", "") or urls.get("Source", "")

        # Use the distribution name (from pyproject.toml) for the pip install command.
        # package_name_for_skill comes from _detect_package_name() which reads project.name, so it
        # holds the real PyPI distribution name (e.g. "toast-analytics") even when the importable
        # module name differs (e.g. "toast"). Fall back to normalizing the module name only when no
        # pyproject.toml / setup.cfg / setup.py is available.
        install_name = package_name_for_skill or (
            package_name.replace("_", "-") if package_name else ""
        )

        # Build skill name: lowercase, hyphens only, max 64 chars
        skill_name = install_name.lower()[:64] if install_name else "package"

        # Build frontmatter
        lines = ["---"]
        lines.append(f"name: {skill_name}")

        # Build description (max 1024 chars)
        desc_parts = []
        if description:
            desc_parts.append(description.rstrip(".") + ".")
        if package_name:
            desc_parts.append(f"Use when writing Python code that uses the {package_name} package.")
        skill_desc = " ".join(desc_parts)[:1024] if desc_parts else f"Use the {skill_name} package."
        lines.append("description: >")
        lines.append(f"  {skill_desc}")

        if license_text:
            lines.append(f"license: {license_text}")
        if requires_python:
            lines.append(f"compatibility: Requires Python {requires_python}.")
        lines.append("---")
        lines.append("")

        # Body: Package header
        display_name = self._config.display_name or package_name or skill_name
        lines.append(f"# {display_name}")
        lines.append("")
        if description:
            lines.append(description)
            lines.append("")

        # Installation section
        if install_name:
            lines.append("## Installation")
            lines.append("")
            lines.append("```bash")
            lines.append(f"pip install {install_name}")
            lines.append("```")
            lines.append("")

        # API decision table
        if sections:
            # Collect manual decision table rows from config
            manual_rows = self._config.skill_decision_table

            if manual_rows:
                lines.append("## When to use what")
                lines.append("")
                lines.append("| Need | Use |")
                lines.append("|------|-----|")
                for row in manual_rows:
                    need = row.get("need", "")
                    use = row.get("use", "")
                    lines.append(f"| {need} | `{use}` |")
                lines.append("")

            # API capabilities by section
            lines.append("## API overview")
            lines.append("")

            for section in sections:
                section_title = section.get("title", "")
                section_desc = section.get("desc", "")

                if section_title:
                    lines.append(f"### {section_title}")
                    if section_desc:
                        lines.append("")
                        lines.append(section_desc)
                    lines.append("")

                for item in section.get("contents", []):
                    if isinstance(item, str):
                        item_name = item
                    elif isinstance(item, dict):  # pragma: no cover
                        item_name = item.get("name", str(item))  # pragma: no cover
                    else:
                        continue  # pragma: no cover

                    item_desc = self._get_docstring_summary(package_name, item_name)
                    if item_desc:
                        lines.append(f"- `{item_name}`: {item_desc}")  # pragma: no cover
                    else:
                        lines.append(f"- `{item_name}`")

                lines.append("")

        # Gotchas section
        gotchas = self._config.skill_gotchas
        if gotchas:
            lines.append("## Gotchas")
            lines.append("")
            for i, gotcha in enumerate(gotchas, 1):
                lines.append(f"{i}. {gotcha}")
            lines.append("")

        # Best practices section
        best_practices = self._config.skill_best_practices
        if best_practices:
            lines.append("## Best practices")
            lines.append("")
            for practice in best_practices:
                lines.append(f"- {practice}")
            lines.append("")

        # Append extra body content if provided
        if self._config.skill_extra_body:
            extra_path = package_root / self._config.skill_extra_body
            if extra_path.exists():
                extra_content = extra_path.read_text(encoding="utf-8")
                lines.append(extra_content)
                lines.append("")

        # Links section
        lines.append("## Resources")
        lines.append("")
        if site_url:
            lines.append(f"- [Full documentation]({site_url})")
        lines.append("- [llms.txt](llms.txt) — Indexed API reference for LLMs")
        lines.append("- [llms-full.txt](llms-full.txt) — Comprehensive documentation for LLMs")
        if repo_url:
            lines.append(f"- [Source code]({repo_url})")
        lines.append("")

        # Write the skill.md file
        skill_path = self.project_path / "skill.md"
        with open(skill_path, "w", encoding="utf-8") as f:
            f.write("\n".join(lines))

        print(f"Created {skill_path}")

        # Place in .well-known if enabled
        self._place_well_known_skill(skill_path)

        # Generate the rendered skills.qmd page
        self._generate_skills_page(skill_path)

    def _generate_skills_page(
        self,
        skill_path: "Path",
        *,
        skill_dir: "Path | None" = None,
        extra_skills: "list[tuple[Path, Path | None]] | None" = None,
    ) -> None:
        """
        Generate a `skills.qmd` page that renders the raw SKILL.md content in a styled,
        human-readable format.

        The page displays the skill's YAML frontmatter as a highlighted block and the Markdown body
        with color-coded headings and monospaced font: a halfway point between raw Markdown and
        fully rendered HTML.

        When *skill_dir* points to a curated skill directory that contains companion subdirectories
        (`references/`, `scripts/`, `assets/`), a directory tree is rendered before the SKILL.md and
        each `.md` / `.sh` file is displayed in its own text area with anchor links.

        When *extra_skills* is provided (a list of `(skill_path, skill_dir)` tuples for additional
        skills beyond the primary one), a sticky pill-style switcher bar is rendered at the top of
        the page so users can toggle between skills without leaving the page.

        Parameters
        ----------
        skill_path
            Path to the skill.md file to render.
        skill_dir
            Optional path to the curated skill directory containing SKILL.md and its companion
            subdirectories.
        extra_skills
            Optional list of additional `(skill_path, skill_dir)` tuples. Each entry is rendered as
            a switchable panel alongside the primary skill.
        """
        import re

        from ._translations import get_translation

        lang = self._config.language

        def _t(key: str, fallback: str) -> str:
            return get_translation(key, lang) if lang != "en" else fallback

        if not skill_path.exists():
            return  # pragma: no cover

        raw_content = skill_path.read_text(encoding="utf-8")

        # Split frontmatter from body
        frontmatter = ""
        body = raw_content
        if raw_content.startswith("---"):
            fm_match = re.match(r"^---\n(.*?\n)---\n?(.*)", raw_content, re.DOTALL)
            if fm_match:
                frontmatter = fm_match.group(1)
                body = fm_match.group(2).lstrip("\n")

        # ── Collect all skills for potential multi-skill rendering ──
        all_skills: list[tuple["Path", "Path | None"]] = [(skill_path, skill_dir)]
        if extra_skills:
            all_skills.extend(extra_skills)
        multi_skill = len(all_skills) > 1

        # Parse metadata for each skill (name, description)
        skill_meta: list[dict] = []
        for sp, sd in all_skills:
            if not sp.exists():
                continue
            content = sp.read_text(encoding="utf-8")
            fm, _ = self._split_frontmatter(content)
            skill_meta.append(
                {
                    "name": fm.get("name", sp.stem),
                    "description": (fm.get("description") or "").strip(),
                    "path": sp,
                    "dir": sd,
                }
            )

        # Build the skills.qmd page
        lines: list[str] = []
        lines.append("---")
        lines.append(f"title: {_t('skills_title', 'Skills')}")
        lines.append("toc: false")
        lines.append("sidebar: false")
        lines.append("page-layout: full")
        lines.append('body-classes: "gd-skills-page"')
        lines.append("---")
        lines.append("")

        # ── Intro: what is a skill? ──
        if multi_skill:
            lines.append(
                _t(
                    "skills_intro_multi",
                    "A skill is a package of structured files that teaches an AI "
                    "coding agent how to work with a specific tool or framework. "
                    "This project ships multiple skills — use the switcher below "
                    "to browse each one. Install a skill in your agent and it will "
                    "be able to run commands, edit configuration, write content, "
                    "and troubleshoot problems without step-by-step guidance from you.",
                )
            )
        elif not skill_dir:
            lines.append(
                _t(
                    "skills_intro",
                    "A skill is a package of structured files that teaches an AI "
                    "coding agent how to work with a specific tool or framework. "
                    "The skill below was generated by Great Docs from this "
                    "project's documentation. Install it in your agent and it will "
                    "be able to run commands, edit configuration, write content, "
                    "and troubleshoot problems without step-by-step guidance from you.",
                )
            )
        else:
            lines.append(
                _t(
                    "skills_intro_curated",
                    "A skill is a package of structured files that teaches an AI "
                    "coding agent how to work with a specific tool or framework. "
                    "Install it in your agent and it will be able to run commands, "
                    "edit configuration, write content, and troubleshoot problems "
                    "without step-by-step guidance from you.",
                )
            )
        lines.append("")

        # Install instructions
        # Detect GitHub repo URL from metadata
        metadata = self._get_package_metadata()
        urls = metadata.get("urls", {})
        repo_url = urls.get("Repository", "") or urls.get("Source", "")
        github_owner_repo = ""
        if repo_url:
            gh_match = re.match(r"https?://github\.com/([^/]+/[^/]+?)(?:\.git)?/?$", repo_url)
            if gh_match:
                github_owner_repo = gh_match.group(1)

        # Detect site URL
        quarto_yml = self.project_path / "_quarto.yml"
        site_url = ""
        if quarto_yml.exists():
            with open(quarto_yml, "r") as f:
                qconfig = read_yaml(f) or {}
            site_url = urls.get("Documentation", "") or qconfig.get("website", {}).get(
                "site-url", ""
            )
            if site_url and not site_url.endswith("/"):
                site_url += "/"

        lines.append("```{=html}")
        lines.append('<div class="gd-skills-install">')
        lines.append(
            '  <button class="gd-skills-install-toggle" '
            'aria-expanded="false" aria-controls="gd-skills-install-body">'
        )
        _install_label = (
            _t("install_these_skills", "Install these skills")
            if multi_skill
            else _t("install_this_skill", "Install this skill")
        )
        lines.append('    <span class="gd-skills-install-icon">&#9654;</span>    ' + _install_label)
        lines.append("  </button>")
        lines.append('  <div class="gd-skills-install-body" id="gd-skills-install-body">')
        lines.append('    <div class="gd-skills-install-inner">')
        lines.append('      <div class="gd-skills-install-pad">')
        lines.append("```")
        lines.append("")

        # ── npx (universal installer) ──
        if multi_skill:
            lines.append(
                f"**{_t('any_agent', 'Any agent')}** --- "
                f"{_t('install_all_with_npx', 'install all with')} "
                "[npx](https://github.com/vercel-labs/skills):"
            )
        else:
            lines.append(
                f"**{_t('any_agent', 'Any agent')}** --- "
                f"{_t('install_with_npx', 'install with')} "
                "[npx](https://github.com/vercel-labs/skills):"
            )
        lines.append("")
        if site_url:
            _npx_cmd = f"npx skills add {site_url}"
        elif github_owner_repo:
            _npx_cmd = f"npx skills add {github_owner_repo}"
        else:
            _npx_cmd = "npx skills add <site-url>"
        lines.append("```{=html}")
        lines.append(
            f'<div class="sourceCode"><pre class="sourceCode bash code-with-copy" style="padding-bottom: 0;">'
            f"<code>{_npx_cmd}</code></pre></div>"
        )
        lines.append("```")
        lines.append("")

        if multi_skill:
            # ── CLI install (multi-skill only, requires detectable package name) ──
            _package_name = self._detect_package_name() or ""
            if _package_name:
                _install_name = _package_name.replace("_", "-")
                lines.append(
                    f"**{_t('cli_install', 'CLI')}** --- "
                    f"{_t('install_all_in_project', 'install all skills in a project')}:"
                )
                lines.append("")
                _cli_cmd = f"great-docs skill install {_install_name}"
                lines.append("```{=html}")
                lines.append(
                    f'<div class="sourceCode"><pre class="sourceCode bash code-with-copy" style="padding-bottom: 0;">'
                    f"<code>{_cli_cmd}</code></pre></div>"
                )
                lines.append("```")
                lines.append("")

            # ── Codex / OpenCode (multi-skill: list all URLs) ──
            lines.append("**Codex / OpenCode**")
            lines.append("")
            _tell_line = _t("tell_the_agent_to_fetch", "Tell the agent to fetch these skill files")
            _skill_url_lines = [f"{_tell_line}:"]
            for meta in skill_meta:
                sname = meta["name"]
                if site_url:
                    _skill_url_lines.append(f"{site_url}.well-known/agent-skills/{sname}/SKILL.md")
                else:
                    _skill_url_lines.append(
                        f"&lt;site-url&gt;/.well-known/agent-skills/{sname}/SKILL.md"
                    )
            _url_list_html = "<br>".join(_skill_url_lines)
            lines.append("```{=html}")
            lines.append(
                f'<div class="sourceCode"><pre class="sourceCode text code-with-copy" style="padding-bottom: 0;">'
                f"<code>{_url_list_html}</code></pre></div>"
            )
            lines.append("```")
            lines.append("")
            _browse_label = _t("browse_skill_files", "Or browse the skill files below")
            lines.append("```{=html}")
            lines.append(f"<p>{_browse_label}.</p>")
            lines.append("```")
            lines.append("")

        else:
            # ── Codex / OpenCode (single skill) ──
            skill_file_url = f"{site_url}skill.md" if site_url else ""
            lines.append("**Codex / OpenCode**")
            lines.append("")
            _tell_the_agent = _t("tell_the_agent", "Tell the agent")
            _fetch_verb = _t("fetch_skill_file_at", "Fetch the skill file at")
            _fetch_from_verb = _t("fetch_skill_file_from", "Fetch the skill file from")
            if skill_file_url:
                _follow = _t("and_follow_instructions", "and follow the instructions.")
                _prompt_text = f"{_fetch_verb} {skill_file_url} {_follow}"
            elif github_owner_repo:
                _follow = _t("and_follow_instructions", "and follow the instructions.")
                _prompt_text = (
                    f"{_fetch_from_verb} https://github.com/{github_owner_repo} {_follow}"
                )
            else:
                _follow = _t("and_follow_instructions", "and follow the instructions.")
                _prompt_text = f"{_fetch_verb} &lt;site-url&gt;/skill.md {_follow}"
            _code_content = f"{_tell_the_agent}:<br>{_prompt_text}"
            lines.append("```{=html}")
            lines.append(
                f'<div class="sourceCode"><pre class="sourceCode text code-with-copy" style="padding-bottom: 0;">'
                f"<code>{_code_content}</code></pre></div>"
            )
            lines.append("```")
            lines.append("")

            # ── Manual (curl + raw links, single skill only) ──
            _download_label = _t("download_skill_file", "download the skill file")
            lines.append(f"**{_t('manual_download', 'Manual')}** --- {_download_label}:")
            lines.append("")
            if skill_file_url:
                _curl_cmd = f"curl -O {skill_file_url}"
            else:
                _curl_cmd = "curl -O &lt;site-url&gt;/skill.md"
            lines.append("```{=html}")
            lines.append(
                f'<div class="sourceCode"><pre class="sourceCode bash code-with-copy" style="padding-bottom: 0;">'
                f"<code>{_curl_cmd}</code></pre></div>"
            )
            lines.append("```")
            lines.append("")
            _browse_label = (
                f"{_t('browse_skill_file', 'Or browse the')} "
                '<a href="skill.md" class="gd-raw-link"><code>SKILL.md</code></a> '
                f"{_t('file_word', 'file')}"
            )
            lines.append("```{=html}")
            lines.append(f"<p>{_browse_label}.</p>")
            lines.append("```")
            lines.append("")

        lines.append("```{=html}")
        lines.append("      </div>")
        lines.append("    </div>")
        lines.append("  </div>")
        lines.append("</div>")
        lines.append("<script>")
        lines.append("(function() {")
        lines.append("  var btn = document.querySelector('.gd-skills-install-toggle');")
        lines.append("  var body = document.getElementById('gd-skills-install-body');")
        lines.append("  if (!btn || !body) return;")
        lines.append("  btn.addEventListener('click', function() {")
        lines.append("    var open = body.classList.toggle('gd-skills-install-open');")
        lines.append("    btn.setAttribute('aria-expanded', open);")
        lines.append("  });")
        lines.append("})();")
        lines.append("</script>")
        lines.append("```")
        lines.append("")

        # ── Multi-skill switcher bar ──
        if multi_skill:
            lines.append("```{=html}")
            lines.append(
                '<div class="gd-skill-switcher" role="tablist" aria-label="Skill selector">'
            )
            for idx, meta in enumerate(skill_meta):
                active_cls = " gd-skill-pill--active" if idx == 0 else ""
                selected = "true" if idx == 0 else "false"
                tabindex = "0" if idx == 0 else "-1"
                name = self._html_escape(meta["name"])
                desc = self._html_escape(meta["description"])
                title_attr = f' title="{desc}"' if desc else ""
                lines.append(
                    f'  <button class="gd-skill-pill{active_cls}" role="tab" '
                    f'aria-selected="{selected}" tabindex="{tabindex}"{title_attr}>'
                    f"{name}</button>"
                )
            lines.append("</div>")
            lines.append("```")
            lines.append("")

        # ── Render each skill as a panel ──
        for panel_idx, meta in enumerate(skill_meta):
            sp = meta["path"]
            sd = meta["dir"]

            if multi_skill:
                hidden_attr = " hidden" if panel_idx > 0 else ""
                lines.append("```{=html}")
                lines.append(f'<div class="gd-skill-panel"{hidden_attr}>')
                lines.append("```")
                lines.append("")

            self._append_skill_panel_content(lines, sp, sd, _t, panel_prefix=meta["name"])

            if multi_skill:
                lines.append("```{=html}")
                lines.append("</div>")
                lines.append("```")
                lines.append("")

        # ── Script tag for the switcher (only for multi-skill pages) ──
        if multi_skill:
            lines.append("```{=html}")
            lines.append('<script src="skill-switcher.js"></script>')
            lines.append("```")
            lines.append("")

        skills_page = self.project_path / "skills.qmd"
        with open(skills_page, "w", encoding="utf-8") as f:
            f.write("\n".join(lines))

    def _append_skill_panel_content(
        self,
        lines: "list[str]",
        skill_path: "Path",
        skill_dir: "Path | None",
        _t: "object",
        *,
        panel_prefix: str = "",
    ) -> None:
        """
        Append the directory-tree, SKILL.md raw block, and companion-file blocks for a
        single skill to *lines*.

        This is the per-skill content that appears inside each switchable panel (or
        directly on the page when there is only one skill).
        """
        if not skill_path.exists():
            return  # pragma: no cover

        raw_content = skill_path.read_text(encoding="utf-8")

        def _fence(text: str, min_ticks: int = 4) -> str:
            """Return a backtick fence string longer than any fence in *text*."""
            import re as _re

            runs = _re.findall(r"`{3,}", text)
            max_run = max((len(r) for r in runs), default=0)
            return "`" * max(min_ticks, max_run + 1)

        def _neutralize_executable_cells(text: str) -> str:
            """Convert executable Quarto cells to display-only so they aren't run.

            Quarto's Jupyter engine extracts ``{python}`` (and ``{r}``, etc.)
            cells from the *entire* document before Markdown fence parsing,
            so wrapping in deeper fences is not sufficient.  Replacing
            ``{python}`` with ``{.python}`` keeps syntax highlighting but
            prevents execution.
            """
            import re as _re

            return _re.sub(r"\{python\}", "{.python}", text)

        raw_content = _neutralize_executable_cells(raw_content)

        # ── Discover companion files in the skill directory ──
        companion_files: list[tuple[str, str, str]] = []  # (subdir/name, ext, content)
        _ALLOWED_SUBDIRS = ("references", "scripts", "assets")
        _RENDERABLE_EXTS = {".md", ".sh", ".yaml", ".yml", ".py"}

        if skill_dir and skill_dir.is_dir():
            for subdir_name in _ALLOWED_SUBDIRS:
                subdir = skill_dir / subdir_name
                if not subdir.is_dir():
                    continue
                for file_path in sorted(subdir.iterdir()):
                    if file_path.is_file() and file_path.suffix in _RENDERABLE_EXTS:
                        rel = f"{subdir_name}/{file_path.name}"
                        content = _neutralize_executable_cells(
                            file_path.read_text(encoding="utf-8")
                        )
                        companion_files.append((rel, file_path.suffix, content))

        # Prefix for unique anchor IDs when multiple skills share the page
        anchor_pfx = f"{panel_prefix}-" if panel_prefix else ""

        # ── Directory tree (only when companion files exist) ──
        if companion_files:
            dir_label = skill_dir.name if skill_dir else "skill"
            tree_lines = [f"{dir_label}/"]
            tree_lines.append("\u251c\u2500\u2500 SKILL.md")
            seen_subdirs: dict[str, list[str]] = {}
            for rel, _ext, _content in companion_files:
                parts = rel.split("/", 1)
                seen_subdirs.setdefault(parts[0], []).append(parts[1])
            subdir_items = list(seen_subdirs.items())
            for i, (sdir, files) in enumerate(subdir_items):
                is_last_dir = i == len(subdir_items) - 1
                branch = "\u2514\u2500\u2500" if is_last_dir else "\u251c\u2500\u2500"
                tree_lines.append(f"{branch} {sdir}/")
                for j, fname in enumerate(files):
                    is_last_file = j == len(files) - 1
                    prefix = "    " if is_last_dir else "\u2502   "
                    connector = "\u2514\u2500\u2500" if is_last_file else "\u251c\u2500\u2500"
                    anchor = f"{anchor_pfx}{sdir}/{fname}".replace("/", "-").replace(".", "-")
                    tree_lines.append(f'{prefix}{connector} <a href="#{anchor}">{fname}</a>')

            lines.append("```{=html}")
            lines.append(
                f'<h3 class="gd-skills-section-heading">{_t("skill_layout", "SKILL LAYOUT")}</h3>'
            )
            lines.append('<pre class="gd-skills-tree">')
            lines.append("\n".join(tree_lines))
            lines.append("</pre>")
            lines.append("```")
            lines.append("")

        # ── Render the full skill.md as a raw pre block (terminal style) ──
        lines.append("```{=html}")
        lines.append('<h3 class="gd-skills-file-heading">SKILL.md</h3>')
        lines.append('<pre class="gd-skills-raw">')
        lines.append("```")
        lines.append("")
        skill_fence = _fence(raw_content)
        lines.append(f"{skill_fence}markdown")
        lines.append(raw_content.rstrip("\n"))
        lines.append(skill_fence)
        lines.append("")
        lines.append("```{=html}")
        lines.append("</pre>")
        lines.append("```")
        lines.append("")

        # ── Render each companion file in its own pre block ──
        for rel, ext, content in companion_files:
            anchor = f"{anchor_pfx}{rel}".replace("/", "-").replace(".", "-")
            lang_map = {
                ".md": "markdown",
                ".sh": "bash",
                ".yaml": "yaml",
                ".yml": "yaml",
                ".py": "python",
            }
            lang_hint = lang_map.get(ext, "")

            lines.append("```{=html}")
            lines.append(f'<h3 id="{anchor}" class="gd-skills-file-heading">{rel}</h3>')
            lines.append('<pre class="gd-skills-raw">')
            lines.append("```")
            lines.append("")
            comp_fence = _fence(content)
            lines.append(f"{comp_fence}{lang_hint}")
            lines.append(content.rstrip("\n"))
            lines.append(comp_fence)
            lines.append("")
            lines.append("```{=html}")
            lines.append("</pre>")
            lines.append("```")
            lines.append("")

    def _generate_multi_skills(self, package_root: "Path") -> None:
        """
        Generate multiple named skills from the `skill.skills` config list.

        Each entry in `skill.skills` must have `name` and `file` keys. The first skill is also
        written to `<docs>/skill.md` and used for the skills page. All skills are aggregated into a
        single `.well-known/agent-skills/index.json` discovery manifest.
        """
        import shutil

        skill_entries = self._config.skill_skills
        if not skill_entries:
            return

        placed_skills: list[tuple["Path", "Path"]] = []
        # list of (skill_path_in_docs, source_dir)

        for i, entry in enumerate(skill_entries):
            name = entry.get("name")
            file_path = entry.get("file")
            if not name or not file_path:
                print(f"Warning: skill.skills[{i}] missing 'name' or 'file', skipping")
                continue

            src = package_root / file_path
            if not src.exists():
                print(f"Warning: skill file '{src}' not found, skipping")
                continue

            # First skill becomes the primary skill.md
            if i == 0:
                dest = self.project_path / "skill.md"
                shutil.copy2(src, dest)
                print(f"Copied primary skill '{name}' from {src}")
                placed_skills.append((dest, src.parent))
            else:
                placed_skills.append((src, src.parent))

        if not placed_skills:
            return

        # Place all skills in .well-known with a combined index.json
        self._place_well_known_skills(placed_skills)

        # Generate the skills page with all skills
        primary_path, primary_dir = placed_skills[0]
        extra = placed_skills[1:] if len(placed_skills) > 1 else None
        self._generate_skills_page(primary_path, skill_dir=primary_dir, extra_skills=extra)

    def _place_well_known_skill(self, skill_path: "Path") -> None:
        """
        Copy the SKILL.md to .well-known/ directories for auto-discovery.

        Places the skill at two well-known locations:

        1. `.well-known/agent-skills/{name}/SKILL.md` with an `index.json` discovery manifest: the
        preferred path used by `npx skills add`.
        2. `.well-known/skills/default/SKILL.md`: legacy fallback.

        Parameters
        ----------
        skill_path
            Path to the skill.md file to copy.
        """
        self._place_well_known_skills([(skill_path, None)])

    def _place_well_known_skills(
        self,
        skill_entries: "list[tuple[Path, Path | None]]",
    ) -> None:
        """
        Place one or more skills at .well-known/ directories for auto-discovery.

        Creates individual `SKILL.md` files under `.well-known/agent-skills/{name}/` and a combined
        `index.json` discovery manifest listing all skills.

        Parameters
        ----------
        skill_entries
            List of `(skill_path, source_dir)` tuples. `source_dir` may be `None`
            and is used to copy additional reference files alongside the `SKILL.md`.
        """
        import shutil

        if not self._config.skill_well_known:
            return

        index_skills: list[dict] = []

        for skill_path, source_dir in skill_entries:
            # Parse frontmatter to extract skill name and description
            content = skill_path.read_text(encoding="utf-8")
            fm, _ = self._split_frontmatter(content)
            skill_name = fm.get("name", "default")
            skill_description = fm.get("description", "")
            if isinstance(skill_description, str):
                skill_description = skill_description.strip()

            # --- Preferred: .well-known/agent-skills/{name}/SKILL.md ---
            agent_skills_dir = self.project_path / ".well-known" / "agent-skills" / skill_name
            agent_skills_dir.mkdir(parents=True, exist_ok=True)
            shutil.copy2(skill_path, agent_skills_dir / "SKILL.md")

            # Copy additional reference files from the source directory
            extra_files = ["SKILL.md"]
            if source_dir and source_dir.is_dir():
                for extra in source_dir.rglob("*"):
                    if extra.is_file() and extra.name != "SKILL.md":
                        rel = extra.relative_to(source_dir)
                        dest = agent_skills_dir / rel
                        dest.parent.mkdir(parents=True, exist_ok=True)
                        shutil.copy2(extra, dest)
                        extra_files.append(str(rel))

            index_skills.append(
                {
                    "name": skill_name,
                    "description": skill_description,
                    "files": extra_files,
                }
            )

        # --- Combined index.json for all skills ---
        if index_skills:
            index_data = {"skills": index_skills}
            index_path = self.project_path / ".well-known" / "agent-skills" / "index.json"
            with open(index_path, "w", encoding="utf-8") as f:
                json.dump(index_data, f, indent=2)
                f.write("\n")

        # --- Legacy: .well-known/skills/default/SKILL.md (first skill only) ---
        if skill_entries:
            first_skill_path = skill_entries[0][0]
            well_known_dir = self.project_path / ".well-known" / "skills" / "default"
            well_known_dir.mkdir(parents=True, exist_ok=True)
            shutil.copy2(first_skill_path, well_known_dir / "SKILL.md")

    # ══════════════════════════════════════════════════════════════════════════
    # SEO GENERATION METHODS
    # ══════════════════════════════════════════════════════════════════════════

    def _get_canonical_base_url(self) -> str | None:
        """
        Get the canonical base URL for the site.

        Checks configuration first, then tries to auto-detect from GitHub Pages URL.

        Returns
        -------
        str | None
            The canonical base URL (with trailing slash) or None if not determined.
        """
        # Check config first
        base_url = self._config.canonical_base_url
        if base_url:
            return base_url.rstrip("/") + "/"

        # Try to auto-detect from GitHub Pages
        owner, repo, _ = self._get_github_repo_info()
        if owner and repo:
            # Standard GitHub Pages URL pattern
            return f"https://{owner}.github.io/{repo}/"  # pragma: no cover

        return None

    def _categorize_page(self, html_path: str) -> str:
        """
        Categorize an HTML file by page type for SEO settings.

        Parameters
        ----------
        html_path
            Relative path to the HTML file within _site/.

        Returns
        -------
        str
            Page type: "homepage", "reference", "user_guide", "changelog", or "default".
        """
        # Normalize path separators
        path = html_path.replace("\\", "/")

        if path == "index.html":
            return "homepage"
        elif path.startswith("reference/"):
            return "reference"
        elif path.startswith("user-guide/"):
            return "user_guide"
        elif path == "changelog.html":
            return "changelog"
        elif path.startswith("recipes/"):
            return "user_guide"  # Recipes are similar to user guide
        else:
            return "default"

    def _generate_sitemap_xml(self) -> None:
        """
        Generate a sitemap.xml file for search engine indexing.

        Creates an XML sitemap at _site/sitemap.xml with proper priorities and change frequencies
        based on page type.
        """
        if not self._config.sitemap_enabled:
            return  # pragma: no cover

        site_dir = self.project_path / "_site"
        if not site_dir.exists():
            print("   ⚠️  _site directory not found, skipping sitemap generation")
            return

        base_url = self._get_canonical_base_url()
        if not base_url:
            print("   ⚠️  No base URL configured, skipping sitemap generation")
            print("      Set seo.canonical.base_url in great-docs.yml or configure a GitHub repo")
            return

        # Get change frequencies and priorities from config
        changefreq = self._config.sitemap_changefreq
        priority = self._config.sitemap_priority

        # Find all HTML files in _site
        html_files = list(site_dir.rglob("*.html"))

        # Build sitemap entries
        entries = []
        for html_file in html_files:
            rel_path = html_file.relative_to(site_dir).as_posix()

            # Skip internal/system files
            if rel_path.startswith("_") or rel_path.startswith("."):
                continue  # pragma: no cover

            # Categorize the page
            page_type = self._categorize_page(rel_path)

            # Build URL (index.html becomes just the directory)
            if rel_path == "index.html":
                loc = base_url
            elif rel_path.endswith("/index.html"):
                loc = base_url + rel_path[:-10]  # Remove /index.html  # pragma: no cover
            else:
                loc = base_url + rel_path

            # Get the last modified time
            mtime = html_file.stat().st_mtime
            lastmod = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d")

            entry = {
                "loc": loc,
                "lastmod": lastmod,
                "changefreq": changefreq.get(page_type, changefreq["default"]),
                "priority": priority.get(page_type, priority["default"]),
            }
            entries.append(entry)

        # Sort entries by priority (descending) then by path
        entries.sort(key=lambda x: (-x["priority"], x["loc"]))

        # Generate XML
        xml_lines = ['<?xml version="1.0" encoding="UTF-8"?>']
        xml_lines.append('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">')

        for entry in entries:
            xml_lines.append("  <url>")
            xml_lines.append(f"    <loc>{entry['loc']}</loc>")
            xml_lines.append(f"    <lastmod>{entry['lastmod']}</lastmod>")
            xml_lines.append(f"    <changefreq>{entry['changefreq']}</changefreq>")
            xml_lines.append(f"    <priority>{entry['priority']:.1f}</priority>")
            xml_lines.append("  </url>")

        xml_lines.append("</urlset>")

        # Write sitemap.xml
        sitemap_path = site_dir / "sitemap.xml"
        sitemap_path.write_text("\n".join(xml_lines), encoding="utf-8")
        print(f"   Generated sitemap.xml with {len(entries)} URLs")

    def _generate_robots_txt(self) -> None:
        """
        Generate a robots.txt file for search engine crawlers.

        Creates a robots.txt at _site/robots.txt with configurable rules.
        """
        if not self._config.robots_enabled:
            return  # pragma: no cover

        site_dir = self.project_path / "_site"
        if not site_dir.exists():
            print("   ⚠️  _site directory not found, skipping robots.txt generation")
            return

        lines = []

        # Default user-agent rules
        lines.append("# Robots.txt generated by Great Docs")
        lines.append("")
        lines.append("User-agent: *")

        if self._config.robots_allow_all:
            lines.append("Allow: /")
        else:
            lines.append("Disallow: /")  # pragma: no cover

        # Add specific disallow rules
        for path in self._config.robots_disallow:
            lines.append(f"Disallow: {path}")

        # Add crawl delay if configured
        crawl_delay = self._config.robots_crawl_delay
        if crawl_delay:
            lines.append(f"Crawl-delay: {crawl_delay}")  # pragma: no cover

        lines.append("")

        # Add extra rules (e.g., for AI crawlers)
        extra_rules = self._config.robots_extra_rules
        if extra_rules:
            lines.append("# Additional rules")
            for rule in extra_rules:
                lines.append(rule)
            lines.append("")

        # Add sitemap reference if sitemap is enabled
        if self._config.sitemap_enabled:
            base_url = self._get_canonical_base_url()
            if base_url:
                lines.append(f"Sitemap: {base_url}sitemap.xml")
                lines.append("")

        # Add llms.txt references for AI agent discoverability
        base_url = self._get_canonical_base_url()
        if base_url:
            llms_path = site_dir / "llms.txt"
            llms_full_path = site_dir / "llms-full.txt"
            if llms_path.exists() or llms_full_path.exists():
                lines.append("# AI agent context files")
                if llms_path.exists():
                    lines.append(f"Llms-txt: {base_url}llms.txt")
                if llms_full_path.exists():
                    lines.append(f"Llms-txt: {base_url}llms-full.txt")
                lines.append("")

        # Write robots.txt
        robots_path = site_dir / "robots.txt"
        robots_path.write_text("\n".join(lines), encoding="utf-8")
        print("   Generated robots.txt")

    def _resolve_social_card_image_url(self) -> str | None:
        """
        Resolve the social card image to an absolute URL.

        If the configured image is a local file path, copies it to the build directory and returns
        the site-relative path. If it's already a URL, returns it as-is. If no image is configured,
        returns `None`.

        Returns
        -------
        str | None
            Absolute URL or site-relative path for the social card image.
        """
        image_path = self._config.social_cards_image
        if not image_path:
            return None

        # If already a URL, return as-is
        if image_path.startswith(("http://", "https://")):  # pragma: no cover
            return image_path  # pragma: no cover

        # Resolve relative to project root
        source = self.project_root / image_path  # pragma: no cover
        if not source.is_file():  # pragma: no cover
            print(f"   ⚠️  Social card image not found: {image_path}")  # pragma: no cover
            return None  # pragma: no cover

        # Copy to build directory root (so it's served at site root)
        dest = self.project_path / source.name  # pragma: no cover
        shutil.copy2(source, dest)  # pragma: no cover

        # Build absolute URL if canonical base is available
        base_url = self._get_canonical_base_url()  # pragma: no cover
        if base_url:  # pragma: no cover
            return base_url + source.name  # pragma: no cover

        # Fallback to site-relative path (works for most crawlers)
        return source.name  # pragma: no cover

    def _get_seo_options(self) -> dict:
        """
        Get SEO options for the post-render script.

        Returns
        -------
        dict
            SEO configuration to be written to _gd_options.json.
        """
        metadata = self._get_package_metadata()

        # Handle social card image (copy to build dir if needed)
        social_image_url = None
        if self._config.social_cards_enabled:
            social_image_url = self._resolve_social_card_image_url()

        return {
            "seo_enabled": self._config.seo_enabled,
            "canonical_enabled": self._config.canonical_enabled,
            "canonical_base_url": self._get_canonical_base_url(),
            "title_template": self._config.seo_title_template,
            "structured_data_enabled": self._config.structured_data_enabled,
            "structured_data_type": self._config.structured_data_type,
            "default_description": (
                self._config.seo_default_description or metadata.get("description", "")
            ),
            "package_name": self._detect_package_name() or "",
            "package_description": metadata.get("description", ""),
            "package_license": metadata.get("license", ""),
            "package_version": metadata.get("version", ""),
            "repo_url": metadata.get("urls", {}).get("Repository", ""),
            "site_name": self._config.display_name or self._detect_package_name() or "",
            # Social cards
            "social_cards_enabled": self._config.social_cards_enabled,
            "social_cards_image": social_image_url,
            "social_cards_twitter_card": self._config.social_cards_twitter_card,
            "social_cards_twitter_site": self._config.social_cards_twitter_site,
        }

    def _prerender_term_player(self, log) -> None:
        """Pre-render .termshow recordings into SVG keyframes + manifest.json.

        Discovers all .termshow files in the project root, applies any
        companion .termshow.yml scripts, and renders SVG frames into
        great-docs/termshow/<basename>/ so the Quarto shortcode can
        reference them.
        """

        # Find all .termshow files in the project root (they live outside
        # great-docs/ since that directory is ephemeral)
        termshow_files = []
        for f in self.project_root.rglob("*.termshow"):
            rel = f.relative_to(self.project_root)
            # Skip files inside great-docs/ or _site/
            if rel.parts[0] in ("great-docs", "_site"):
                continue
            termshow_files.append(f)
        if not termshow_files:
            return

        try:
            from great_docs._term_player import (
                apply_script,
                generate_manifest,
                parse_termshow,
            )
            from great_docs._term_player.script import load_script
        except ImportError:
            return

        rendered_count = 0
        for ts_file in termshow_files:
            basename = ts_file.stem
            # Output into the great-docs project directory (Quarto project)
            output_dir = self.project_path / "termshow" / basename

            try:
                recording = parse_termshow(str(ts_file))

                # Apply script if companion .termshow.yml exists
                script = None
                script_file = ts_file.with_suffix(".termshow.yml")
                if script_file.exists():
                    script = load_script(str(script_file))
                    recording = apply_script(recording, script)

                generate_manifest(
                    recording,
                    script=script,
                    output_dir=str(output_dir),
                    keyframe_interval=2.0,
                )
                rendered_count += 1
            except Exception as e:
                log.warn(f"termshow: failed to render {ts_file.name}: {e}")

        if rendered_count:
            log.detail(f"Pre-rendered {rendered_count} terminal recording(s)")
            # Register termshow output as a Quarto resource so it gets
            # copied to _site/
            self._add_project_resources(["termshow/**"], [])

    def _generate_seo_files(self) -> None:
        """
        Generate all SEO-related files (sitemap.xml, robots.txt).

        Called during the post-render phase after Quarto has built the site.
        """
        if not self._config.seo_enabled:
            return  # pragma: no cover

        print("\n🔍 Generating SEO files...")
        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.

        Returns
        -------
        str
            Formatted CLI help text with all commands and subcommands.
        """
        metadata = self._get_package_metadata()

        if not metadata.get("cli_enabled", False):
            return ""

        package_name = self._detect_package_name()
        if not package_name:
            return ""  # pragma: no cover

        # The Click CLI is imported from the package's module, which can differ
        # from the PyPI project name.
        importable_name = self._resolve_importable_name(package_name)
        cli_info = self._discover_click_cli(importable_name, display_name=package_name)
        if not cli_info:
            return ""  # pragma: no cover

        lines = []
        entry_point = cli_info.get("entry_point_name", package_name)

        # Main CLI help
        lines.append(f"## CLI: {entry_point}")
        lines.append("")
        lines.append("```")
        lines.append(cli_info.get("help_text", ""))
        lines.append("```")
        lines.append("")

        # Process subcommands recursively
        def add_subcommand_help(cmd_info: dict, depth: int = 0):
            nonlocal lines
            for subcmd in cmd_info.get("commands", []):
                if subcmd.get("hidden"):
                    continue

                full_path = subcmd.get("full_path", subcmd.get("name", ""))
                lines.append(f"### {full_path}")
                lines.append("")
                lines.append("```")
                lines.append(subcmd.get("help_text", ""))
                lines.append("```")
                lines.append("")

                # Recurse for nested subcommands
                if subcmd.get("commands"):
                    add_subcommand_help(subcmd, depth + 1)  # pragma: no cover

        add_subcommand_help(cli_info)

        return "\n".join(lines)

    def _get_user_guide_text_for_llms(self) -> str:
        """
        Get User Guide content formatted for llms-full.txt.

        Reads all user guide .qmd files in order and extracts their content, stripping YAML
        frontmatter but preserving the document structure.

        Returns
        -------
        str
            Formatted User Guide content with all pages in order.
        """
        user_guide_info = self._discover_user_guide()
        if not user_guide_info:
            return ""

        lines = []
        files = user_guide_info.get("files", [])

        if not files:
            return ""  # pragma: no cover

        current_section = None

        for file_info in files:
            file_path = file_info.get("path")
            title = file_info.get("title", "")
            section = file_info.get("section")

            if not file_path or not file_path.exists():
                continue  # pragma: no cover

            # Add section header if this is a new section
            if section and section != current_section:
                lines.append(f"\n## {section}\n")
                current_section = section

            # Read the file content
            try:
                with open(file_path, "r", encoding="utf-8") as f:
                    content = f.read()
            except Exception:  # pragma: no cover
                continue  # pragma: no cover

            # Strip YAML frontmatter
            content = self._strip_frontmatter(content)

            # Add the page title as a header if not already starting with one
            content_stripped = content.strip()
            if not content_stripped.startswith("#"):
                lines.append(f"### {title}\n")

            lines.append(content)
            lines.append("")

        return "\n".join(lines)

    def _strip_frontmatter(self, content: str) -> str:
        """
        Remove YAML frontmatter from a document.

        Parameters
        ----------
        content
            The document content.

        Returns
        -------
        str
            The content with frontmatter removed.
        """
        if content.startswith("---"):
            parts = content.split("---", 2)
            if len(parts) >= 3:
                return parts[2].lstrip()
        return content

    def uninstall(self) -> None:
        """
        Remove great-docs configuration and build directory from the project.

        This method deletes the great-docs.yml configuration file and the great-docs/
        build directory (if it exists).

        ::: {.callout-note}
        In practice, you would normally use the `great-docs uninstall` CLI command
        rather than calling this method directly. See the
        [CLI reference](cli/uninstall.qmd) for details.
        :::

        Examples
        --------
        Uninstall great-docs from the current directory:

        ```python
        from great_docs import GreatDocs

        docs = GreatDocs()
        docs.uninstall()
        ```

        Uninstall from a specific project directory:

        ```python
        docs = GreatDocs("/path/to/my/project")
        docs.uninstall()
        ```
        """
        print("Uninstalling great-docs from your project...")

        # Remove the great-docs.yml configuration file
        config_path = self.project_root / "great-docs.yml"
        if config_path.exists():
            config_path.unlink()
            print(f"Removed {config_path.relative_to(self.project_root)}")

        # Remove the great-docs/ build directory if it exists
        if self.project_path.exists():
            shutil.rmtree(self.project_path)
            print(f"Removed {self.project_path.relative_to(self.project_root)}/ directory")

        print("✅ Great-docs uninstalled successfully!")

    def _persist_freeze_cache(self) -> int | None:
        """Copy _freeze/ from build directories back to the project root.

        Handles both single-version builds (freeze in project_path/_freeze)
        and versioned builds (freeze in _great_docs_build/v__*/_freeze).

        Returns the number of cached files, or None if no freeze cache exists.
        """
        freeze_sources: list[Path] = []

        # Single-version build
        single = self.project_path / "_freeze"
        if single.is_dir():
            freeze_sources.append(single)

        # Versioned build
        versioned_root = self.project_root / "_great_docs_build"
        if versioned_root.is_dir():
            for ver_dir in versioned_root.iterdir():
                candidate = ver_dir / "_freeze"
                if candidate.is_dir():
                    freeze_sources.append(candidate)

        if not freeze_sources:
            return None

        freeze_dst = self.project_root / "_freeze"
        if freeze_dst.exists():
            shutil.rmtree(freeze_dst)
        freeze_dst.mkdir()

        for src in freeze_sources:
            for item in src.iterdir():
                dest = freeze_dst / item.name
                if item.is_dir():
                    if dest.exists():
                        shutil.rmtree(dest)
                    shutil.copytree(item, dest)
                else:
                    shutil.copy2(item, dest)

        for js_file in freeze_dst.rglob("*.js"):
            data = js_file.read_bytes()
            if data and not data.endswith(b"\n"):
                js_file.write_bytes(data + b"\n")

        return sum(1 for _ in freeze_dst.rglob("*") if _.is_file())

    def _prepare_for_freeze(self) -> None:
        """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.
        """
        import os
        from contextlib import redirect_stdout

        devnull = open(os.devnull, "w")

        # Step 1: Prepare build directory (copies files, applies transformations)
        with redirect_stdout(devnull):
            self._prepare_build_directory()

        # Change to build directory for subsequent steps
        original_dir = os.getcwd()
        try:
            os.chdir(self.project_path)

            # Steps 2-13: Process user guide, sections, pages, tags, etc.
            # These can modify .qmd files (add breadcrumbs, expand tags, etc.)
            with redirect_stdout(devnull):
                try:
                    self._refresh_api_reference_config()
                except Exception:
                    pass
                try:
                    self._generate_llms_txt()
                    self._generate_llms_full_txt()
                except Exception:
                    pass
                try:
                    self._generate_skill_md()
                except Exception:
                    pass
                try:
                    self._generate_source_links()
                except Exception:
                    pass
                try:
                    self._generate_changelog()
                except Exception:
                    pass
                try:
                    self._generate_cli_reference()
                except Exception:
                    pass
                try:
                    self._process_user_guide()
                except Exception:
                    pass
                try:
                    self._process_sections()
                except Exception:
                    pass
                try:
                    self._process_custom_pages()
                except Exception:
                    pass
                try:
                    self._process_page_tags()
                except Exception:
                    pass
                try:
                    self._process_page_status()
                except Exception:
                    pass
                try:
                    self._copy_assets()
                except Exception:
                    pass

            # Step 14: Generate API reference
            try:
                from quartodoc import Builder

                with redirect_stdout(devnull):
                    quarto_yml = self.project_path / "_quarto.yml"
                    if quarto_yml.exists():
                        builder = Builder.from_quarto_config(str(quarto_yml))
                        builder.build()
            except Exception:
                pass

            # Normalize freeze shorthand (runs right before render in a full build)
            self._normalize_freeze_shorthand()

            # Expand source-code: mock cells
            from great_docs._mock_code import process_directory as _expand_mock

            _expand_mock(self.project_path)

            # Update quarto config (ensures _quarto.yml has pre-render, freeze, etc.)
            with redirect_stdout(devnull):
                self._update_quarto_config()

        finally:
            os.chdir(original_dir)
            devnull.close()

    def build(
        self,
        watch: bool = False,
        refresh: bool = True,
        version_tags: list[str] | None = None,
        latest_only: bool = False,
    ) -> None:
        """
        Build the documentation site.

        Generates API reference pages followed by `quarto render`. By default, re-discovers package
        exports and updates the API reference configuration before building.

        ::: {.callout-note}
        In practice, you would normally use the `great-docs build` CLI command rather
        than calling this method directly. See the
        [CLI reference](cli/build.qmd) for details.
        :::

        Parameters
        ----------
        watch
            If `True`, watch for changes and rebuild automatically.
        refresh
            If `True` (default), re-discover package exports and update API reference config before
            building. Set to False for faster rebuilds when your package API hasn't changed.
        version_tags
            If provided, only build these specific version tags (for multi-version sites). Ignored
            when no `versions:` config is present.
        latest_only
            If `True`, build only the latest version (skip historical versions). Ignored when no
            `versions:` config is present.

        Examples
        --------
        Build the documentation (with API refresh):

        ```python
        from great_docs import GreatDocs

        docs = GreatDocs()
        docs.build()
        ```

        Build with watch mode:

        ```python
        docs.build(watch=True)
        ```

        Quick rebuild without API refresh:

        ```python
        docs.build(refresh=False)
        ```
        """
        # Require an explicit config file; init must have been run first
        config_path = self.project_root / "great-docs.yml"
        if not config_path.exists():
            raise FileNotFoundError(
                "great-docs.yml not found. Run 'great-docs init' first to "
                "generate a configuration file."
            )

        _ensure_quarto_installed()

        import re as _re_build
        import subprocess
        import sys
        import threading
        from contextlib import contextmanager

        from great_docs._build_log import BuildLog, estimate_build_time

        _ansi_re = _re_build.compile(r"\033\[[0-9;]*m")

        class _DetailStream:
            """A file-like object that forwards each line to `log.detail()`."""

            def __init__(self):
                self._buf = ""

            def write(self, s):
                self._buf += s
                while "\n" in self._buf:
                    line, self._buf = self._buf.split("\n", 1)
                    if line.strip():
                        log.detail(line.rstrip())

            def flush(self):
                # Flush any remaining partial line
                if self._buf.strip():
                    log.detail(self._buf.rstrip())
                    self._buf = ""

        @contextmanager
        def _quiet_prints():
            """Redirect print() to `log.detail()` in real time.

            `log._write()` is unaffected because it writes to the
            `stream` object captured at init time (not `sys.stdout`).
            """
            old_stdout = sys.stdout
            sys.stdout = _DetailStream()
            try:
                yield
            finally:
                sys.stdout.flush()
                sys.stdout = old_stdout

        # --- Gather metadata for the build header -------------------------
        package_name = self._detect_package_name() or ""
        package_version = ""
        package_location = ""
        if package_name:
            try:
                from importlib.metadata import version as _pkg_version

                package_version = _pkg_version(package_name)
            except Exception:
                pass
            # Show the location Griffe will actually use for introspection
            # (prioritizes project root / src layout over sys.path)
            _normalized_pkg = package_name.replace("-", "_")
            for _sp in self._griffe_search_paths():
                _candidate = Path(_sp) / _normalized_pkg
                if _candidate.is_dir() and (_candidate / "__init__.py").exists():
                    package_location = str(_candidate)
                    break
            if not package_location:
                try:
                    import importlib.util

                    _spec = importlib.util.find_spec(_normalized_pkg)
                    if _spec and _spec.origin:
                        package_location = str(Path(_spec.origin).parent)
                except Exception:
                    pass
            # For --from-repo builds (temp clone dirs), show the git remote URL
            # instead of the opaque temp path
            if package_location:
                try:
                    import subprocess as _sp_sub
                    import tempfile

                    _git_remote = _sp_sub.run(
                        ["git", "config", "--get", "remote.origin.url"],
                        cwd=str(self.project_root),
                        capture_output=True,
                        **TEXT_MODE_KWARGS,
                    )
                    if _git_remote.returncode == 0 and _git_remote.stdout.strip():
                        _remote_url = _git_remote.stdout.strip()
                        # Check if the package lives inside a temp clone directory
                        _tmp_prefixes = ("/tmp/", "/var/folders/", tempfile.gettempdir())
                        if any(package_location.startswith(p) for p in _tmp_prefixes):
                            package_location = _remote_url
                except Exception:
                    pass

        # Count total pages for time estimate (API items from config)
        n_api_items = 0
        n_total_pages = 0
        n_code_cells = 0
        obj_types_path = self.project_path / "_object_types.json"
        if obj_types_path.exists():
            try:
                with open(obj_types_path) as f:
                    n_api_items = len(json.load(f))
            except Exception:
                pass

        # Cheap page & code-cell scan for the time estimate
        try:
            _qmd_exts = ("*.qmd", "*.md")
            _page_dirs: list[Path] = []

            # User guide pages
            ug_dir = self._find_user_guide_dir()
            if ug_dir and ug_dir.is_dir():
                _page_dirs.append(ug_dir)

            # Custom section directories
            for sec in self._config.sections:
                sec_dir_name = sec.get("dir", "")
                if sec_dir_name:
                    sec_dir = self._find_package_root() / sec_dir_name
                    if sec_dir.is_dir():
                        _page_dirs.append(sec_dir)

            for d in _page_dirs:
                for ext in _qmd_exts:
                    for p in d.rglob(ext):
                        n_total_pages += 1
                        try:
                            text = p.read_text(encoding="utf-8", errors="ignore")
                            n_code_cells += text.count("```{python}")
                        except OSError:
                            pass

            # Reference pages (≈ n_api_items, one page per item)
            n_total_pages += n_api_items

            # Fixed pages: homepage, changelog, citation, etc.
            n_total_pages += 3

            # CLI pages (rough estimate if enabled)
            if self._config.cli_enabled:
                n_total_pages += 10
        except Exception:
            pass  # never let estimation crash the build

        total_steps = 19
        est_seconds = estimate_build_time(
            n_api_items=n_api_items,
            n_total_pages=n_total_pages,
            n_code_cells=n_code_cells,
        )

        log = BuildLog(
            package_name=package_name,
            package_version=package_version,
            package_location=package_location,
            total_steps=total_steps,
            estimated_seconds=est_seconds,
        )

        log.header()

        # Step counter
        step = 0

        _GD_PASS_PREFIX = "##GD:PASS:"  # pragma: no cover

        def run_streaming(
            cmd, env=None, progress_bar=None, on_pass=None, on_bar_done=None
        ):  # pragma: no cover
            """Run a subprocess, stream output, optionally feeding a progress bar.

            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,
                stderr=subprocess.PIPE,
                **TEXT_MODE_KWARGS,
                env=env,
                bufsize=1,
            )

            stderr_lines = []
            pass_labels: list[str] = []
            quarto_warnings: list[str] = []
            bar_finished = [False]  # mutable flag for closure
            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."""
                if not bar_finished[0] and progress_bar:
                    bar_finished[0] = True
                    progress_bar.finish()
                    for w in quarto_warnings:
                        log.warn(w)
                    if on_bar_done:
                        on_bar_done()

            def _handle_line(stripped):
                """Route a single output line: progress / pass-marker / detail."""
                if stripped.startswith(_GD_PASS_PREFIX):
                    label = stripped[len(_GD_PASS_PREFIX) :]
                    pass_labels.append(label)
                    if on_pass:
                        on_pass(label)
                    return
                if progress_bar:
                    if not bar_finished[0]:
                        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)
                            # Auto-finish bar when rendering is complete
                            if cur >= tot:
                                _finish_bar()
                            return
                        # Capture Quarto warnings (strip ANSI codes first);
                        # discard all other noise while the bar is active.
                        plain = _ansi_re.sub("", stripped)
                        if plain.startswith("WARN"):
                            quarto_warnings.append(plain)
                    # After bar finishes, post-render output streams through
                    # here — suppress it (Step 16 shows the substep checklist).
                    return
                log.detail(stripped)

            def read_stderr():
                for line in process.stderr:
                    stderr_lines.append(line)
                    stripped = line.rstrip()
                    if stripped:
                        _handle_line(stripped)

            stderr_thread = threading.Thread(target=read_stderr, daemon=True)
            stderr_thread.start()

            for line in process.stdout:
                stripped = line.rstrip()
                if stripped:
                    _handle_line(stripped)

            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

            r = Result()
            r.returncode = process.returncode
            r.stderr = "".join(stderr_lines)
            r.stdout = ""
            r.passes = pass_labels
            r.page_timings = page_timings
            return r

        # ── Step 1: Prepare build directory ────────────────────────────
        step += 1
        log.step_start(step, "Prepare build directory")
        with _quiet_prints():
            self._prepare_build_directory()
        log.step_done("Ready")

        # Log freeze/pre-render configuration
        freeze_mode = self._config.freeze
        pre_render_scripts = self._config.pre_render
        if freeze_mode is not None:
            mode_label = "auto" if freeze_mode == "auto" else "true"
            log.detail(f"Freeze mode: {mode_label}")
        if pre_render_scripts:
            for script in pre_render_scripts:
                log.detail(f"Pre-render script: {script}")

        # Change to build directory
        original_dir = os.getcwd()
        try:
            os.chdir(self.project_path)

            # ── Step 2: Configure API reference ────────────────────────
            step += 1
            log.step_start(step, "Configure API reference")
            if refresh and self._has_api_reference:
                with _quiet_prints():
                    self._refresh_api_reference_config()
                log.step_done("API reference refreshed")
            elif self._has_api_reference:
                log.step_done("Using existing config")
            else:
                log.step_skip(step, "no API reference")

            # ── Step 3: Generate llms.txt / llms-full.txt ──────────────
            step += 1
            log.step_start(step, "Generate llms.txt / llms-full.txt")
            with _quiet_prints():
                self._generate_llms_txt()
                self._generate_llms_full_txt()
            log.step_done("Created llms.txt + llms-full.txt")

            # ── Step 4: Generate SKILL.md ──────────────────────────────
            step += 1
            log.step_start(step, "Generate SKILL.md")
            if self._config.skill_enabled:
                with _quiet_prints():
                    self._generate_skill_md()
                log.step_done("Created skill.md")
                # Warn if no site URL is available for the skills page install instructions
                _meta_urls = self._get_package_metadata().get("urls", {})
                _has_site_url = bool(self._config.site_url or _meta_urls.get("Documentation", ""))
                if not _has_site_url:
                    _qy = self.project_path / "_quarto.yml"
                    if _qy.exists():
                        with open(_qy, "r") as _f:
                            _qc = read_yaml(_f) or {}
                        _has_site_url = bool(_qc.get("website", {}).get("site-url", ""))
                if not _has_site_url:
                    log.warn(
                        "No site URL found — skills page will show <site-url> placeholders. "
                        "Set site_url in great-docs.yml or add a Documentation URL to "
                        "[project.urls] in pyproject.toml."
                    )
            else:
                log.step_skip(step, "disabled in config")

            # ── Step 5: Generate source links ──────────────────────────
            step += 1
            log.step_start(step, "Generate source links")
            package_name = self._detect_package_name()
            if package_name:
                # Source-link generation (and the CLI reference in Step 7,
                # which reuses pkg_name) load the importable module, which can
                # differ from the PyPI project name. Honor an explicit
                # ``module:`` and build-backend hints via _detect_module_name.
                pkg_name = self._resolve_importable_name(package_name)
                with _quiet_prints():
                    self._generate_source_links_json(pkg_name)
                log.step_done(f"Source links generated for {pkg_name}")
            else:
                pkg_name = None
                log.step_skip(step, "no package detected")

            # ── Step 6: Generate changelog ─────────────────────────────
            step += 1
            log.step_start(step, "Generate changelog")
            if self._config.changelog_enabled:
                owner, repo, _base_url = self._get_github_repo_info()
                if owner and repo:
                    try:
                        with _quiet_prints():
                            result_file = self._generate_changelog_page()
                        if result_file:
                            self._add_changelog_to_navbar()
                            log.step_done("Changelog from GitHub Releases")
                        else:
                            log.step_done("No releases found; skipped")
                    except Exception as e:
                        log.warn(f"Error generating changelog: {e}")
                        log.step_done("Changelog generation had issues")
                else:
                    log.step_skip(step, "no GitHub repo info")
            else:
                log.step_skip(step, "disabled in config")

            # ── Step 7: Generate CLI reference ─────────────────────────
            step += 1
            log.step_start(step, "Generate CLI reference")
            metadata = self._get_package_metadata()
            if metadata.get("cli_enabled", False) and not pkg_name:
                log.step_skip(step, "no package detected")
            elif metadata.get("cli_enabled", False):
                try:
                    with _quiet_prints():
                        cli_info = self._discover_click_cli(pkg_name, display_name=package_name)
                    if cli_info:
                        with _quiet_prints():
                            cli_files = self._generate_cli_reference_pages(cli_info)
                        if cli_files:
                            with _quiet_prints():
                                self._update_sidebar_with_cli(cli_files)
                            n_pages = self._count_cli_sidebar_items(cli_files)
                            log.step_done(f"{n_pages} CLI reference page(s)")
                        else:
                            log.step_done("No CLI pages generated")  # pragma: no cover
                    else:
                        log.step_skip(step, "no Click CLI found")
                except Exception as e:
                    log.warn(f"Error generating CLI docs: {e}")
                    log.step_done("CLI generation had issues")
            else:
                log.step_skip(step, "CLI not enabled")

            # ── Step 7a: Generate Go CLI reference ─────────────────────
            step += 1
            log.step_start(step, "Generate Go CLI reference")
            if self._config.go_cli_enabled:
                try:
                    from great_docs._go_cli import introspect_cobra_cli

                    with _quiet_prints():
                        go_project = self._detect_go_cli_project()
                    if go_project:
                        with _quiet_prints():
                            cli_info = introspect_cobra_cli(go_project)
                        if cli_info:
                            with _quiet_prints():
                                cli_files = self._generate_cli_reference_pages(cli_info)
                            if cli_files:
                                with _quiet_prints():
                                    self._update_sidebar_with_cli(cli_files)
                                n_pages = self._count_cli_sidebar_items(cli_files)
                                log.step_done(
                                    f"{n_pages} Go CLI reference page(s) ({go_project.binary_name})"
                                )
                            else:
                                log.step_done("No Go CLI pages generated")  # pragma: no cover
                        else:
                            log.step_skip(step, "Go CLI introspection failed (is 'go' on PATH?)")
                    else:
                        log.step_skip(step, "no Go CLI project detected")
                except Exception as e:
                    log.warn(f"Error generating Go CLI docs: {e}")
                    log.step_done("Go CLI generation had issues")
            else:
                log.step_skip(step, "Go CLI not enabled")

            # ── Step 7b: Generate MCP server reference ─────────────────
            step += 1
            log.step_start(step, "Generate MCP server reference")
            if self._config.mcp_enabled:
                try:
                    with _quiet_prints():
                        mcp_info = self._discover_mcp_server()
                    if mcp_info:
                        with _quiet_prints():
                            mcp_files = self._generate_mcp_reference_pages(mcp_info)
                        if mcp_files:
                            self._mcp_pages_generated = True
                            with _quiet_prints():
                                self._update_sidebar_with_mcp(mcp_files)
                            # Generate .well-known/mcp.json discovery manifest
                            with _quiet_prints():
                                self._generate_mcp_manifest(mcp_info)
                            n_tools = len(mcp_info.get("tools", []))
                            log.step_done(f"{n_tools} MCP tool page(s) + manifest")
                        else:
                            self._mcp_pages_generated = False
                            self._remove_mcp_from_ref_sections()
                            log.step_done("No MCP pages generated")
                    else:
                        self._mcp_pages_generated = False
                        self._remove_mcp_from_ref_sections()
                        log.step_skip(step, "no MCP server found")
                except Exception as e:
                    self._mcp_pages_generated = False
                    log.warn(f"Error generating MCP docs: {e}")
                    self._remove_mcp_from_ref_sections()
                    log.step_done("MCP generation had issues")
            else:
                log.step_skip(step, "MCP not enabled")

            # ── Step 8: Process User Guide ─────────────────────────────
            step += 1
            log.step_start(step, "Process User Guide")
            try:
                with _quiet_prints():
                    ug_result = self._process_user_guide()
                if ug_result:
                    log.step_done("User Guide configured")
                else:
                    log.step_skip(step, "no user guide found")
            except Exception as e:
                log.warn(f"Error processing User Guide: {e}")
                log.step_done("User Guide had issues")

            # ── Step 9: Process custom sections ────────────────────────
            step += 1
            log.step_start(step, "Process custom sections")
            if self._config.sections:
                try:
                    with _quiet_prints():
                        n_sections = self._process_sections()
                    if n_sections:
                        log.step_done(f"{n_sections} section(s) processed")
                    else:
                        log.step_done("No sections to process")
                except Exception as e:
                    log.warn(f"Error processing sections: {e}")
                    log.step_done("Sections had issues")
            else:
                log.step_skip(step, "no sections configured")

            # ── Step 10: Process custom pages ──────────────────────────
            step += 1
            log.step_start(step, "Process custom pages")
            try:
                with _quiet_prints():
                    n_custom_pages = self._process_custom_pages()
                if n_custom_pages:
                    log.step_done(f"{n_custom_pages} custom page(s)")
                else:
                    log.step_skip(step, "no custom pages")
            except Exception as e:  # pragma: no cover
                log.warn(f"Error processing custom pages: {e}")
                log.step_done("Custom pages had issues")

            # ── Step 11: Process page tags ─────────────────────────────
            step += 1
            log.step_start(step, "Process page tags")
            if self._config.tags_enabled:
                try:
                    with _quiet_prints():
                        has_tags = self._process_tags()
                    if has_tags:
                        log.step_done("Tags processed")
                    else:
                        log.step_skip(step, "no tagged pages found")
                except Exception as e:
                    log.warn(f"Error processing tags: {e}")
                    log.step_done("Tags had issues")
            else:
                log.step_skip(step, "tags not enabled")

            # ── Step 12: Process page status badges ────────────────────
            step += 1
            log.step_start(step, "Process page status badges")
            if self._config.page_status_enabled:
                try:
                    with _quiet_prints():
                        has_statuses = self._process_page_statuses()
                    if has_statuses:
                        log.step_done("Status badges processed")
                    else:
                        log.step_skip(step, "no pages with status")
                except Exception as e:
                    log.warn(f"Error processing page statuses: {e}")
                    log.step_done("Status badges had issues")
            else:
                log.step_skip(step, "page status not enabled")

            # ── Step 13: Copy assets ───────────────────────────────────
            step += 1
            log.step_start(step, "Copy assets")
            try:
                with _quiet_prints():
                    assets_copied = self._copy_assets()
                    # Always update quarto config after user guide + assets steps
                    # (registers user-guide/images/** and other dynamic resources)
                    self._update_quarto_config()
                if assets_copied:
                    log.step_done("Assets copied")
                else:
                    log.step_skip(step, "no assets directory")
            except Exception as e:
                log.warn(f"Error copying assets: {e}")
                log.step_done("Assets had issues")

            # ── Step 14: Generate API reference ────────────────────────
            step += 1
            log.step_start(step, "Generate API reference")
            if self._has_api_reference:
                build_env = self._get_quarto_env()
                extra_paths = build_env.get("PYTHONPATH", "").split(os.pathsep)
                for p in extra_paths:
                    if p and p not in sys.path:
                        sys.path.insert(0, p)  # pragma: no cover

                quarto_yml = self.project_path / "_quarto.yml"
                try:
                    from great_docs._apiref.api_reference import APIReference

                    with _quiet_prints():
                        APIReference(str(quarto_yml)).build()
                    log.step_done("API reference generated")
                except SystemExit:
                    # Missing config items or other fatal errors — don't mask them
                    raise
                except Exception as e:
                    dynamic = self._config.dynamic
                    if dynamic:
                        log.warn(
                            "Dynamic introspection failed for a resolved item, "
                            "retrying with static analysis..."
                        )
                        log.detail(str(e))
                        with open(quarto_yml, "r") as f:
                            qconfig = read_yaml(f) or {}
                        if "api-reference" in qconfig:
                            qconfig["api-reference"]["dynamic"] = False
                            with open(quarto_yml, "w") as f:
                                write_yaml(qconfig, f)
                        try:
                            with _quiet_prints():
                                APIReference(str(quarto_yml)).build()
                            log.step_done("API reference generated (static analysis)")
                        except Exception as e2:
                            log.step_fail(f"API reference build failed: {e2}")
                            log.footer()
                            sys.exit(1)
                    else:
                        log.step_fail(f"API reference build failed: {e}")
                        log.error_detail(str(e))
                        log.footer()
                        sys.exit(1)
            else:
                log.step_skip(step, "API reference disabled")

            # ── Step 15: Prepare freeze cache ──────────────────────────
            step += 1
            log.step_start(step, "Prepare freeze cache")

            # Normalize page-level freeze shorthand before Quarto render
            frozen_pages = self._normalize_freeze_shorthand()
            has_freeze_cache = (self.project_root / "_freeze").is_dir()

            if frozen_pages or has_freeze_cache:
                if frozen_pages:
                    n = len(frozen_pages)
                    log.step_done(f"Using {n} page(s) from the freeze cache")
                    for p in frozen_pages:
                        log.detail(f"  - {p}")
                else:
                    log.step_done("Freeze cache will be restored during render")
            else:
                log.step_skip(step, "no frozen pages")

            # Expand source-code: mock cells before Quarto sees them
            from great_docs._mock_code import process_directory as _expand_mock_cells

            mock_modified = _expand_mock_cells(self.project_path)
            if mock_modified:
                log.detail(
                    f"Expanded {len(mock_modified)} mock-code cell(s): " + ", ".join(mock_modified)
                )

            # Get environment with QUARTO_PYTHON set
            quarto_env = self._get_quarto_env()

            # ── Pre-render termshow recordings ──────────────────────
            self._prerender_term_player(log)

            # ── Step 16: Build site with Quarto ────────────────────────
            step += 1
            log.step_start(step, "Build site with Quarto")
            if watch:
                log.step_done("Starting watch mode...")
                log.footer(watch_mode=True)
                subprocess.run(["quarto", "preview", "--no-browser"], env=quarto_env)
                return
            elif self._config.has_versions:
                # ── Multi-version build pipeline ───────────────────────
                from great_docs._versioned_build import run_versioned_build
                from great_docs._versioning import parse_versions_config

                versions_parsed = parse_versions_config(self._config.versions)

                # Determine which versions will be built
                if latest_only:
                    from great_docs._versioning import get_latest_version

                    lt = get_latest_version(versions_parsed)
                    lt_tag = lt.tag if lt else versions_parsed[0].tag
                    targets_parsed = [v for v in versions_parsed if v.tag == lt_tag]
                elif version_tags:
                    tag_set = set(version_tags)
                    targets_parsed = [v for v in versions_parsed if v.tag in tag_set]
                else:
                    targets_parsed = list(versions_parsed)

                n_versions = len(targets_parsed)
                label = "1 version" if n_versions == 1 else f"{n_versions} version(s)"
                log.detail(f"Multi-version build: {label}")

                # Create multi-progress bar for the parallel renders
                bar_labels = [f"v{v.label}" for v in targets_parsed]
                mbar = log.multi_progress(bar_labels)

                def _progress_cb(slot: int, current: int, total: int) -> None:
                    mbar.set_total(slot, total)
                    mbar.update(slot, current)

                def _on_renders_done() -> None:
                    mbar.finish()

                vb_result = run_versioned_build(
                    source_dir=self.project_path,
                    project_root=self.project_root,
                    versions_config=self._config.versions,
                    quarto_env=quarto_env,
                    version_tags=version_tags,
                    latest_only=latest_only,
                    site_url=self._config.site_url,
                    progress_callback=_progress_cb,
                    on_renders_done=_on_renders_done,
                    badge_expiry_raw=self._config["new_is_old"],
                )

                if not vb_result["success"]:
                    for err in vb_result["errors"]:
                        log.error_detail(err)
                    if not vb_result["versions_built"]:
                        log.step_fail("All version builds failed")

                        # Skip steps 17, 18
                        step += 1
                        log.step_start(step, "Post-render processing")
                        log.step_skip(step, "build failed")
                        step += 1
                        log.step_start(step, "Generate SEO files")
                        log.step_skip(step, "build failed")

                        log.footer()
                        sys.exit(1)
                    else:
                        n_ok = len(vb_result["versions_built"])
                        n_err = len(vb_result["errors"])
                        log.step_done(f"{n_ok} version(s) rendered ({n_err} failed)")
                else:
                    n_ok = len(vb_result["versions_built"])
                    log.step_done(f"{n_ok} version(s) rendered")

                # ── Step 17: Post-render processing ────────────────
                step += 1
                log.step_start(step, "Post-render processing")
                log.step_done("Version assembly complete")

                # ── Step 18: Generate SEO files ────────────────────
                step += 1
                log.step_start(step, "Generate SEO files")
                try:
                    with _quiet_prints():
                        self._generate_seo_files()
                    log.step_done("sitemap.xml + robots.txt")
                except Exception as e:
                    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}")

                # ── Persist freeze cache ──────────────────────────
                n_frozen = self._persist_freeze_cache()
                if n_frozen is not None:
                    log.detail(f"Saved freeze cache ({n_frozen} files)")

                # ── Auto-save API snapshot (Strategy C) ────────────
                try:
                    self._auto_save_snapshot()
                except Exception as e:
                    log.warn(f"Snapshot auto-save failed: {e}")

                site_path = self.project_path / "_site" / "index.html"
                if site_path.exists():
                    log.footer(site_path=str(site_path))
                else:
                    log.footer(site_path=str(self.project_path / "_site"))
            else:
                bar = log.progress("Rendering pages", 1)

                # Track Step 15→16 transition for live substep display.
                step16_started = [False]
                pass_count = [0]

                def _start_step16():
                    """Transition from Step 15 to Step 16 immediately."""
                    if not step16_started[0]:
                        step16_started[0] = True
                        log.step_done("Site rendered")
                        nonlocal step
                        step += 1
                        log.step_start(step, "Post-render processing")

                def _on_pass(label):
                    _start_step16()
                    pass_count[0] += 1
                    log.substep(label)

                result = run_streaming(
                    ["quarto", "render"],
                    env=quarto_env,
                    progress_bar=bar,
                    on_pass=_on_pass,
                    on_bar_done=_start_step16,
                )
                bar.finish()  # no-op if auto-finished at 100%

                # Retry once on transient Quarto filesystem race conditions
                # (e.g., "No such file or directory: rename ..." during output
                # copying in large projects)
                _TRANSIENT_PATTERNS = (
                    "No such file or directory",
                    "renameSync",
                    "os error 2",
                )
                if result.returncode != 0 and any(p in result.stderr for p in _TRANSIENT_PATTERNS):
                    log.warn("Quarto hit a transient filesystem error — retrying render...")
                    # Reset progress state for retry
                    bar = log.progress("Rendering pages (retry)", 1)
                    step16_started[0] = False
                    pass_count[0] = 0
                    result = run_streaming(
                        ["quarto", "render"],
                        env=quarto_env,
                        progress_bar=bar,
                        on_pass=_on_pass,
                        on_bar_done=_start_step16,
                    )
                    bar.finish()

                if result.returncode != 0:
                    log.step_fail("Quarto render failed")
                    log.error_detail(result.stderr)

                    # ── Steps 16, 17 skipped on failure ────────────────
                    if not step16_started[0]:
                        step += 1
                        log.step_start(step, "Post-render processing")
                    log.step_skip(step, "build failed")
                    step += 1
                    log.step_start(step, "Generate SEO files")
                    log.step_skip(step, "build failed")

                    log.footer()
                    sys.exit(1)
                else:
                    # Step 15 done + Step 16 substeps were emitted live
                    # via the _on_pass callback.  Finish up Step 16.
                    if not step16_started[0]:  # pragma: no cover
                        # No passes arrived (shouldn't happen, but be safe)
                        log.step_done("Site rendered")  # pragma: no cover
                        step += 1  # pragma: no cover
                        log.step_start(step, "Post-render processing")  # pragma: no cover
                        log.step_done("HTML transforms applied")  # pragma: no cover
                    else:
                        n = pass_count[0]  # pragma: no cover
                        log.step_done(  # pragma: no cover
                            f"{n} transforms applied"  # pragma: no cover
                        )  # pragma: no cover

                    # ── Step 18: Generate SEO files ────────────────────
                    step += 1  # pragma: no cover
                    log.step_start(step, "Generate SEO files")  # pragma: no cover
                    try:  # pragma: no cover
                        with _quiet_prints():  # pragma: no cover
                            self._generate_seo_files()  # pragma: no cover
                        log.step_done("sitemap.xml + robots.txt")  # pragma: no cover
                    except Exception as e:  # pragma: no cover
                        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

                    # ── Persist freeze cache ──────────────────────────
                    n_frozen = self._persist_freeze_cache()  # pragma: no cover
                    if n_frozen is not None:  # pragma: no cover
                        log.detail(f"Saved freeze cache ({n_frozen} files)")  # pragma: no cover

                    # ── Auto-save API snapshot (Strategy C) ────────────
                    if self._config.has_versions:  # pragma: no cover
                        try:  # pragma: no cover
                            self._auto_save_snapshot()  # pragma: no cover
                        except Exception as e:  # pragma: no cover
                            log.warn(f"Snapshot auto-save failed: {e}")  # pragma: no cover

                    site_path = self.project_path / "_site" / "index.html"  # pragma: no cover
                    if site_path.exists():  # pragma: no cover
                        log.footer(site_path=str(site_path))  # pragma: no cover
                    else:  # pragma: no cover
                        log.footer(  # pragma: no cover
                            site_path=str(self.project_path / "_site")
                        )

        finally:
            os.chdir(original_dir)

    @classmethod
    def build_from_repo(
        cls,
        repo_url: str,
        *,
        branch: str | None = None,
        output_dir: str | None = None,
        refresh: bool = True,
        version_tags: list[str] | None = None,
        latest_only: bool = False,
        shallow: bool = False,
    ) -> Path:
        """
        Clone a remote repository and build its documentation site.

        This is a convenience method for building documentation from a separate repository. It
        handles cloning, creating a temporary virtual environment, installing the package, building
        the docs, and copying the output.

        After the initial shallow clone, the target's `great-docs.yml` is inspected so the git
        history depth can be adapted automatically:

        * `versions:` is non-empty -> full unshallow + fetch tags (versioned builds check out
        multiple tags).
        * `site.show_dates: true` -> full unshallow (page-creation dates need `git log` history).
        * Otherwise, only `git fetch --tags` (enough for source-link tag detection).

        Pass `shallow=True` to skip all post-clone fetching (fastest, but git-history features will
        be degraded).

        Parameters
        ----------
        repo_url
            Git URL of the repository to clone (HTTPS or SSH).
        branch
            Branch, tag, or commit to check out. If `None`, uses the repository's default branch.
        output_dir
            Directory to copy the built site into. If `None`, defaults to `./great-docs/_site` in
            the current working directory.
        refresh
            If `True` (default), re-discover package exports before building.
        version_tags
            If provided, only build these specific version tags.
        latest_only
            If `True`, build only the latest version.
        shallow
            If `True`, force a shallow clone (`--depth=1`) with no additional history fetching.
            Fastest option, but versioned docs, page metadata dates, and tag-based source links will
            not work.

        Returns
        -------
        Path
            Absolute path to the directory containing the built site.

        Examples
        --------
        Build docs from a remote repository:

        ```python
        from great_docs import GreatDocs

        site_dir = GreatDocs.build_from_repo(
            "https://github.com/posit-dev/great-tables.git",
            branch="main",
            output_dir="./my-site",
        )
        ```
        """
        import subprocess
        import sys
        import tempfile
        import venv

        if output_dir is None:
            output_path = Path.cwd() / "great-docs" / "_site"
        else:
            output_path = Path(output_dir).resolve()

        tmpdir = tempfile.mkdtemp(prefix="great-docs-remote-")
        clone_dir = Path(tmpdir) / "repo"
        venv_dir = Path(tmpdir) / "venv"

        try:
            # ── 1. Clone the repository (shallow first) ────────────────
            print(f"📦 Cloning {repo_url}...")
            clone_cmd = ["git", "clone", "--depth=1"]
            if branch:
                clone_cmd += ["--branch", branch]
            clone_cmd += [repo_url, str(clone_dir)]
            result = subprocess.run(clone_cmd, capture_output=True, **TEXT_MODE_KWARGS)
            if result.returncode != 0:
                raise RuntimeError(
                    f"git clone failed (exit {result.returncode}):\n{result.stderr.strip()}"
                )

            # ── 1b. Inspect great-docs.yml and deepen history if needed ─
            if not shallow:
                needs = cls._inspect_repo_git_needs(clone_dir)
                if needs == "full":
                    print("   Fetching full history (versioned docs / page dates)...")
                    subprocess.run(
                        ["git", "fetch", "--unshallow"],
                        cwd=str(clone_dir),
                        capture_output=True,
                        **TEXT_MODE_KWARGS,
                    )
                    subprocess.run(
                        ["git", "fetch", "--tags"],
                        cwd=str(clone_dir),
                        capture_output=True,
                        **TEXT_MODE_KWARGS,
                    )
                elif needs == "tags":
                    print("   Fetching tags for source link detection...")
                    subprocess.run(
                        ["git", "fetch", "--tags"],
                        cwd=str(clone_dir),
                        capture_output=True,
                        **TEXT_MODE_KWARGS,
                    )
            print("   Cloned to temporary directory")

            # ── 2. Create temporary virtual environment ────────────────
            print("🐍 Creating temporary virtual environment...")
            venv.create(str(venv_dir), with_pip=True, clear=True)

            if sys.platform == "win32":
                venv_python = venv_dir / "Scripts" / "python.exe"
                venv_pip = venv_dir / "Scripts" / "pip.exe"
            else:
                venv_python = venv_dir / "bin" / "python"
                venv_pip = venv_dir / "bin" / "pip"

            # ── 3. Install great-docs into the temp venv ───────────────
            print("📥 Installing great-docs into temporary environment...")
            result = subprocess.run(
                [str(venv_pip), "install", "great-docs"],
                capture_output=True,
                **TEXT_MODE_KWARGS,
            )
            if result.returncode != 0:
                # Fall back to installing from the current source tree if
                # the PyPI package is not available (e.g. during development)
                src_root = Path(__file__).resolve().parent.parent
                result = subprocess.run(
                    [str(venv_pip), "install", str(src_root)],
                    capture_output=True,
                    **TEXT_MODE_KWARGS,
                )
                if result.returncode != 0:
                    raise RuntimeError(f"Failed to install great-docs:\n{result.stderr.strip()}")

            # ── 4. Install the target package ──────────────────────────
            print("📥 Installing target package...")
            # Try installing with optional dev/docs extras first
            install_cmd = [str(venv_pip), "install", "-e"]
            extras = cls._detect_install_extras(clone_dir)
            if extras:
                install_cmd.append(f"{clone_dir}[{extras}]")
            else:
                install_cmd.append(str(clone_dir))
            result = subprocess.run(install_cmd, capture_output=True, **TEXT_MODE_KWARGS)
            if result.returncode != 0:
                # Retry without extras
                result = subprocess.run(
                    [str(venv_pip), "install", "-e", str(clone_dir)],
                    capture_output=True,
                    **TEXT_MODE_KWARGS,
                )
                if result.returncode != 0:
                    raise RuntimeError(
                        f"Failed to install target package:\n{result.stderr.strip()}"
                    )

            # ── 5. Build docs using the temp venv's great-docs ─────────
            print("🔨 Building documentation...")
            if sys.platform == "win32":
                gd_cli = venv_dir / "Scripts" / "great-docs.exe"
            else:
                gd_cli = venv_dir / "bin" / "great-docs"

            build_cmd = [str(gd_cli), "build", "--project-path", str(clone_dir)]
            if not refresh:
                build_cmd.append("--no-refresh")
            if version_tags:
                build_cmd.extend(["--versions", ",".join(version_tags)])
            if latest_only:
                build_cmd.append("--latest-only")

            # Pass through GITHUB_TOKEN / GH_TOKEN and PATH
            build_env = os.environ.copy()
            build_env["VIRTUAL_ENV"] = str(venv_dir)
            build_env["PATH"] = (
                str(venv_dir / ("Scripts" if sys.platform == "win32" else "bin"))
                + os.pathsep
                + build_env.get("PATH", "")
            )

            result = subprocess.run(
                build_cmd,
                env=build_env,
                cwd=str(clone_dir),
            )
            if result.returncode != 0:
                raise RuntimeError(f"great-docs build failed (exit {result.returncode})")

            # ── 6. Copy built site to output directory ─────────────────
            site_dir = clone_dir / "great-docs" / "_site"
            if not site_dir.exists():
                raise RuntimeError(f"Build completed but _site/ directory not found at {site_dir}")

            print(f"📂 Copying built site to {output_path}...")
            output_path.parent.mkdir(parents=True, exist_ok=True)
            if output_path.exists():
                shutil.rmtree(output_path)
            shutil.copytree(site_dir, output_path)

            print(f"✅ Site built successfully: {output_path}")
            return output_path

        finally:
            # ── 7. Clean up temporary directory ────────────────────────
            try:
                shutil.rmtree(tmpdir)
            except Exception:
                pass  # Best-effort cleanup

    @staticmethod
    def preview_site(
        site_dir: str | Path,
        port: int = 3000,
        *,
        open_path: str = "",
        open_browser: bool = True,
    ) -> None:
        """Preview a pre-built documentation site from any directory.

        Starts a local HTTP server and opens the site in the default browser. This is useful for
        previewing output from `build_from_repo()` or any other pre-built site directory.

        Parameters
        ----------
        site_dir
            Path to the directory containing the built site (must have `index.html`).
        port
            The port number for the local HTTP server (default `3000`).
        open_path
            Optional site-relative page to open in the browser (e.g.
            `reference/mcp/gd_config.html`). Defaults to the site root. If the page does not
            exist under `site_dir`, a warning is printed and the root is opened instead.
        open_browser
            Whether to launch the default browser. When `False`, the URL is printed but no
            browser is opened.
        """
        import functools
        import http.server
        import sys
        import threading
        import webbrowser

        site_path = Path(site_dir)
        index_html = site_path / "index.html"

        if not index_html.exists():
            print(f"❌ No index.html found in {site_path}")
            sys.exit(1)

        # Normalize the requested deep-link page and confirm it exists on disk before we
        # advertise/open it; fall back to the root otherwise.
        rel_path = (open_path or "").strip().lstrip("/")
        if rel_path:
            target = site_path / rel_path
            if not target.is_file():
                print(f"⚠️  Page '{rel_path}' not found in the site; opening the home page instead.")
                rel_path = ""

        handler = functools.partial(
            http.server.SimpleHTTPRequestHandler,
            directory=str(site_path),
        )
        # Reap half-open sockets (e.g. Safari's idle speculative connections) so a
        # stalled connection cannot tie up a worker thread indefinitely.
        http.server.SimpleHTTPRequestHandler.timeout = 10
        http.server.ThreadingHTTPServer.allow_reuse_address = True

        try:
            # Use a threading server so one idle/speculative connection (notably from
            # Safari) cannot block the whole preview. See issue #219.
            httpd = http.server.ThreadingHTTPServer(("", port), handler)
        except OSError:
            print(f"❌ Port {port} is already in use. Try a different port.")
            sys.exit(1)

        base_url = f"http://localhost:{port}/"
        url = base_url + rel_path
        print(f"\n🌐 Serving site at {base_url}")
        print(f"   Site directory: {site_path}")
        if rel_path:
            print(f"   Opening: {url}")
        print("   Press Ctrl+C to stop\n")

        if open_browser:
            threading.Timer(0.3, webbrowser.open, args=(url,)).start()

        try:
            httpd.serve_forever()
        except KeyboardInterrupt:
            pass
        finally:
            httpd.server_close()

    @staticmethod
    def _detect_install_extras(project_dir: Path) -> str:
        """Detect optional dependency groups suitable for a docs build.

        Scans `pyproject.toml` for extras like `dev`, `docs`, `test` and returns a comma-separated
        string (e.g. `"dev,docs"`).

        Parameters
        ----------
        project_dir
            Path to the cloned project root.

        Returns
        -------
        str
            Comma-separated extras string, or empty string if none found.
        """
        pyproject = project_dir / "pyproject.toml"
        if not pyproject.exists():
            return ""

        try:
            import tomllib
        except ImportError:  # pragma: no cover
            try:
                import tomli as tomllib  # type: ignore[no-redef]
            except ImportError:
                return ""

        try:
            with open(pyproject, "rb") as f:
                data = tomllib.load(f)
        except Exception:
            return ""

        opt_deps = data.get("project", {}).get("optional-dependencies", {})
        # Prioritize groups likely to contain docs/dev deps
        candidates = ["dev", "docs", "doc", "all"]
        found = [c for c in candidates if c in opt_deps]
        return ",".join(found)

    @staticmethod
    def _inspect_repo_git_needs(clone_dir: Path) -> str:
        """Inspect a cloned repo's `great-docs.yml` to determine git depth needs.

        Returns one of three strings indicating how much git history is
        required for the features declared in the config:

        * `"full"`: full history needed (versioned docs or page dates).
        * `"tags"`: only tags needed (source-link tag detection).
        * `"none"`: shallow clone is sufficient.

        Parameters
        ----------
        clone_dir
            Path to the cloned repository root.

        Returns
        -------
        str
            One of `"full"`, `"tags"`, or `"none"`.
        """
        config_path = clone_dir / "great-docs.yml"
        if not config_path.exists():
            return "none"

        try:
            from yaml12 import read_yaml

            with open(config_path, "r", encoding="utf-8") as f:
                cfg = read_yaml(f) or {}
        except Exception:
            return "none"

        # Versioned docs require checking out multiple tags → full history
        versions = cfg.get("versions", [])
        if versions:
            return "full"

        # Page metadata dates need git log for first-commit detection.
        # `show_dates` lives at the top level; `site.show_dates` is kept as a
        # legacy fallback since this raw-YAML inspection runs before any
        # `Config`-level normalization (Config._lift_legacy_site_keys never
        # sees this clone).
        show_dates = cfg.get("show_dates")
        if show_dates is None:
            site = cfg.get("site")
            if isinstance(site, dict):
                show_dates = site.get("show_dates")
        if show_dates:
            return "full"

        # Source links benefit from tags for _detect_git_ref()
        source = cfg.get("source", {})
        if isinstance(source, dict):
            # If a branch is explicitly set, tags aren't needed for detection
            if source.get("branch"):
                return "none"

        return "tags"

    def preview(self, port: int = 3000) -> None:
        """
        Preview the documentation site locally.

        Starts a local HTTP server and opens the built site in the default browser. If the site
        hasn't been built yet, it will be built first. Use `great-docs build` to rebuild the site if
        you've made changes.

        ::: {.callout-note}
        In practice, you would normally use the `great-docs preview` CLI command rather than calling
        this method directly. See the [CLI reference](cli/preview.qmd) for details.
        :::

        Parameters
        ----------
        port
            The port number for the local HTTP server (default `3000`).

        Examples
        --------
        Preview the documentation:

        ```python
        from great_docs import GreatDocs

        docs = GreatDocs()
        docs.preview()
        ```
        """
        import functools
        import http.server
        import sys
        import threading
        import webbrowser

        print("Previewing documentation...")

        # Check if site has been built
        site_path = self.project_path / "_site"
        index_html = site_path / "index.html"

        if not index_html.exists():
            print("Site not found, building first...")
            self.build()

        if not index_html.exists():
            print("❌ Could not find built site")
            sys.exit(1)

        handler = functools.partial(
            http.server.SimpleHTTPRequestHandler,
            directory=str(site_path),
        )
        # Reap half-open sockets (e.g. Safari's idle speculative connections) so a
        # stalled connection cannot tie up a worker thread indefinitely.
        http.server.SimpleHTTPRequestHandler.timeout = 10
        # Allow quick restart after Ctrl-C
        http.server.ThreadingHTTPServer.allow_reuse_address = True

        try:
            # Use a threading server so one idle/speculative connection (notably from
            # Safari) cannot block the whole preview. See issue #219.
            httpd = http.server.ThreadingHTTPServer(("", port), handler)
        except OSError:
            print(f"❌ Port {port} is already in use. Try a different port.")
            sys.exit(1)

        url = f"http://localhost:{port}/"
        print(f"\n🌐 Serving site at {url}")
        print("   Press Ctrl+C to stop\n")

        # Open browser after a short delay so the server is ready
        threading.Timer(0.3, webbrowser.open, args=(url,)).start()

        try:
            httpd.serve_forever()
        except KeyboardInterrupt:
            pass
        finally:
            httpd.server_close()

    def check_links(
        self,
        include_source: bool = True,
        include_docs: bool = True,
        timeout: float = 10.0,
        ignore_patterns: list[str] | None = None,
        verbose: bool = False,
    ) -> dict:
        """
        Check all links in source code and documentation for broken links.

        ::: {.callout-note}
        In practice, you would normally use the `great-docs check-links` CLI command rather than
        calling this method directly. See the [CLI reference](cli/check_links.qmd) for details.
        :::

        This method scans Python source files and documentation files (`.qmd`, `.md`) for URLs and
        checks their HTTP status. It reports broken links (404s) and warns about redirects.

        The following content is automatically excluded from link checking:

        - **Python comments**: URLs in lines starting with `#`
        - **Code blocks**: URLs inside fenced code blocks (````` ... `````)
        - **Inline code**: URLs inside backticks (`` `...` ``)
        - **Marked URLs**: URLs followed by `{.gd-no-link}` in `.qmd`/`.md` files

        For documentation, the checker scans the source `user_guide/` directory rather than the
        generated `docs/` directory to avoid checking transient files.

        In `.qmd` files, you can exclude specific URLs from checking by adding `{.gd-no-link}`
        immediately after the URL:

            Visit http://example.com{.gd-no-link} for an example.
            Also works with inline code: `http://example.com`{.gd-no-link}

        Parameters
        ----------
        include_source
            If `True`, scan Python source files in the package directory for URLs. Default is
            `True`.
        include_docs
            If `True`, scan documentation files (`.qmd`, `.md`) for URLs. Default is `True`.
        timeout
            Timeout in seconds for each HTTP request. Default is `10.0`.
        ignore_patterns
            List of URL patterns (strings or regex) to ignore. URLs matching any pattern will be
            skipped. Default is `None`.
        verbose
            If `True`, print detailed progress information. Default is `False`.

        Returns
        -------
        dict
            A dictionary containing:

            - `total`: total number of unique links checked
            - `ok`: list of links that returned 2xx status
            - `redirects`: list of dicts with `url`, `status`, `location` for 3xx responses
            - `broken`: list of dicts with `url`, `status`, `error` for 4xx/5xx or errors
            - `skipped`: list of URLs that were skipped (matched ignore patterns)
            - `by_file`: dict mapping file paths to lists of links found in each file

        Examples
        --------
        Check all links in a project:

        ```python
        from great_docs import GreatDocs

        docs = GreatDocs()
        results = docs.check_links()

        print(f"Checked {results['total']} links")
        print(f"Broken: {len(results['broken'])}")
        print(f"Redirects: {len(results['redirects'])}")
        ```

        Check only documentation files with custom timeout:

        ```python
        results = docs.check_links(
            include_source=False,
            timeout=5.0,
            ignore_patterns=["localhost", "127.0.0.1", "example.com"]
        )
        ```
        """
        import requests

        # URL regex pattern - matches http and https URLs
        url_pattern = re.compile(
            r'https?://[^\s<>"\')\]}`\\]+',
            re.IGNORECASE,
        )

        # Pattern to detect URLs marked with {.gd-no-link} in .qmd files
        # This allows marking example/fake links for exclusion: http://example.com{.gd-no-link}
        # Also handles URLs in inline code: `http://example.com`{.gd-no-link}
        gd_no_link_pattern = re.compile(
            r'`?(https?://[^\s<>"\')\]}`\\{]+)`?\{\.gd-no-link\}',
            re.IGNORECASE,
        )

        # Compile ignore patterns
        ignore_regexes = []
        if ignore_patterns:
            for pattern in ignore_patterns:
                try:
                    ignore_regexes.append(re.compile(pattern, re.IGNORECASE))
                except re.error:
                    # Treat as literal string if not valid regex
                    ignore_regexes.append(re.compile(re.escape(pattern), re.IGNORECASE))

        # Collect all files to scan
        files_to_scan: list[Path] = []

        if include_source:
            # Find package directory (the importable module name, which can
            # differ from the PyPI project name).
            package_name = self._detect_package_name()
            if package_name:
                importable_name = self._resolve_importable_name(package_name)
                # Dotted module names (e.g. namespace packages "firebird.base")
                # map to nested directories.
                package_dir = self.project_root.joinpath(*importable_name.split("."))
                if package_dir.exists():
                    files_to_scan.extend(package_dir.rglob("*.py"))

        if include_docs:
            # Scan documentation files
            # Priority: if user_guide/ exists, scan that (it's the source)
            # Otherwise, scan the docs directory directly

            user_guide_dir = self.project_root / "user_guide"
            if user_guide_dir.exists():
                # Scan user_guide source directory instead of generated docs/
                files_to_scan.extend(user_guide_dir.rglob("*.qmd"))
                files_to_scan.extend(user_guide_dir.rglob("*.md"))
            elif self.project_path.exists():  # pragma: no cover
                # No user_guide/, scan docs directory directly
                files_to_scan.extend(self.project_path.rglob("*.qmd"))  # pragma: no cover
                files_to_scan.extend(self.project_path.rglob("*.md"))  # pragma: no cover

            # Also check README in project root
            readme = self.project_root / "README.md"
            if readme.exists():
                files_to_scan.append(readme)

        # Extract URLs from all files
        url_to_files: dict[str, list[str]] = {}
        by_file: dict[str, list[str]] = {}

        for file_path in files_to_scan:
            try:
                content = file_path.read_text(encoding="utf-8", errors="ignore")

                # For .qmd and .md files, find URLs marked with {.gd-no-link} and exclude them
                # Also strip code blocks to avoid checking example URLs
                excluded_urls: set[str] = set()
                if file_path.suffix in (".qmd", ".md"):
                    for match in gd_no_link_pattern.finditer(content):
                        excluded_urls.add(match.group(1))

                    # Remove fenced code blocks (``` ... ```) before URL extraction
                    # This prevents example URLs in code blocks from being checked
                    content = re.sub(r"```[^`]*```", "", content, flags=re.DOTALL)

                    # Also remove inline code (`...`) to avoid example URLs
                    content = re.sub(r"`[^`]+`", "", content)

                # For Python files, exclude URLs in comments (lines starting with #)
                # This prevents example URLs in code comments from being checked
                if file_path.suffix == ".py":
                    # Remove single-line comments before URL extraction
                    lines = content.split("\n")
                    non_comment_lines = []
                    for line in lines:
                        # Find the start of a comment (# not inside a string)
                        # Simple approach: strip the comment portion from each line
                        stripped = line.lstrip()
                        if stripped.startswith("#"):
                            # Entire line is a comment, skip it
                            non_comment_lines.append("")
                        else:
                            # Keep the line (inline comments after code are less common
                            # for example URLs, but we keep them for now)
                            non_comment_lines.append(line)
                    content = "\n".join(non_comment_lines)

                urls = url_pattern.findall(content)

                # Clean URLs (remove trailing punctuation)
                cleaned_urls = []
                for url in urls:
                    # Remove trailing punctuation that's likely not part of the URL
                    url = url.rstrip(".,;:!?")
                    # Remove trailing parentheses if unbalanced
                    while url.endswith(")") and url.count(")") > url.count("("):
                        url = url[:-1]  # pragma: no cover
                    # Skip URLs with f-string placeholders (e.g., {variable} or partial {var)
                    # This catches both complete {var} and incomplete {var patterns
                    if "{" in url:
                        continue
                    # Skip URLs marked with {.gd-no-link}
                    if url in excluded_urls:
                        continue  # pragma: no cover
                    cleaned_urls.append(url)

                if cleaned_urls:
                    rel_path = str(file_path.relative_to(self.project_root))
                    by_file[rel_path] = cleaned_urls

                    for url in cleaned_urls:
                        if url not in url_to_files:
                            url_to_files[url] = []
                        url_to_files[url].append(rel_path)

            except Exception as e:  # pragma: no cover
                if verbose:
                    print(f"Warning: Could not read {file_path}: {e}")

        # Check each unique URL
        results = {
            "total": len(url_to_files),
            "ok": [],
            "redirects": [],
            "broken": [],
            "skipped": [],
            "by_file": by_file,
        }

        if verbose:
            print(f"\n🔍 Found {len(url_to_files)} unique URLs to check\n")

        for url in url_to_files:
            # Check if URL matches any ignore pattern
            should_skip = False
            for pattern in ignore_regexes:
                if pattern.search(url):
                    should_skip = True
                    break

            if should_skip:
                results["skipped"].append(url)
                if verbose:
                    print(f"⏭️  Skipped: {url}")
                continue

            try:
                # Use HEAD request first (faster), fall back to GET if needed
                response = requests.head(
                    url,
                    timeout=timeout,
                    allow_redirects=False,
                    headers={"User-Agent": "great-docs-link-checker/1.0"},
                )

                # Some servers don't support HEAD, try GET
                if response.status_code == 405:
                    response = requests.get(
                        url,
                        timeout=timeout,
                        allow_redirects=False,
                        headers={"User-Agent": "great-docs-link-checker/1.0"},
                        stream=True,  # Don't download body
                    )
                    response.close()

                status = response.status_code

                if 200 <= status < 300:
                    results["ok"].append(url)
                    if verbose:
                        print(f"✅ {status} {url}")
                elif 300 <= status < 400:  # pragma: no cover
                    location = response.headers.get("Location", "Unknown")
                    results["redirects"].append(
                        {
                            "url": url,
                            "status": status,
                            "location": location,
                            "files": url_to_files[url],
                        }
                    )
                    if verbose:
                        print(f"↪️  {status} {url} -> {location}")
                else:  # pragma: no cover
                    results["broken"].append(
                        {
                            "url": url,
                            "status": status,
                            "error": f"HTTP {status}",
                            "files": url_to_files[url],
                        }
                    )
                    if verbose:
                        print(f"❌ {status} {url}")

            except requests.exceptions.Timeout:  # pragma: no cover
                results["broken"].append(
                    {
                        "url": url,
                        "status": None,
                        "error": "Timeout",
                        "files": url_to_files[url],
                    }
                )
                if verbose:
                    print(f"⏱️  Timeout: {url}")
            except requests.exceptions.SSLError as e:  # pragma: no cover
                results["broken"].append(
                    {
                        "url": url,
                        "status": None,
                        "error": f"SSL Error: {str(e)[:50]}",
                        "files": url_to_files[url],
                    }
                )
                if verbose:
                    print(f"🔐 SSL Error: {url}")
            except requests.exceptions.ConnectionError:  # pragma: no cover
                results["broken"].append(
                    {
                        "url": url,
                        "status": None,
                        "error": "Connection failed",
                        "files": url_to_files[url],
                    }
                )
                if verbose:
                    print(f"🔌 Connection failed: {url}")
            except Exception as e:  # pragma: no cover
                results["broken"].append(
                    {
                        "url": url,
                        "status": None,
                        "error": str(e)[:100],
                        "files": url_to_files[url],
                    }
                )
                if verbose:
                    print(f"⚠️  Error: {url} - {e}")

        return results

    def proofread(
        self,
        include_docs: bool = True,
        include_docstrings: bool = False,
        custom_dictionary: list[str] | None = None,
        dialect: str = "us",
        ignore_rules: list[str] | None = None,
        only_rules: list[str] | None = None,
        verbose: bool = False,
    ) -> dict:
        """
        Check spelling and grammar in documentation files using Harper.

        ::: {.callout-note}
        In practice, you would normally use the `great-docs proofread` CLI command rather than
        calling this method directly. See the [CLI reference](cli/proofread.qmd) for details.
        :::

        This method uses Harper, a fast privacy-first grammar checker, to scan documentation files
        (`.qmd`, `.md`) for spelling errors, grammatical issues, style problems, and more. Harper
        runs locally and provides comprehensive English language checking.

        Parameters
        ----------
        include_docs
            If `True`, scan documentation files (`.qmd`, `.md`). Default is `True`.
        include_docstrings
            If `True`, also scan Python docstrings in the package. Default is `False`.
        custom_dictionary
            List of additional words to consider correct (e.g., project-specific terms, library
            names). Default is `None`.
        dialect
            English dialect: "us", "uk", "au", "in", "ca". Default is `"us"`.
        ignore_rules
            List of Harper rule names to skip (e.g., `["SentenceCapitalization"]`). Default is
            `None`.
        only_rules
            List of Harper rule names to run exclusively (e.g., `["SpellCheck"]`). Default is
            `None`.
        verbose
            If `True`, print detailed progress information. Default is `False`.

        Returns
        -------
        dict
            A dictionary containing:

            - `files_checked`: number of files checked
            - `total_issues`: total number of issues found
            - `by_kind`: dict mapping lint kind to count (e.g., "Spelling", "Grammar")
            - `by_rule`: dict mapping rule name to count (e.g., "SpellCheck")
            - `issues`: list of issue dicts with file, line, column, message, etc.
            - `by_file`: dict mapping file paths to their issues

        Raises
        ------
        RuntimeError
            If harper-cli is not installed.

        Examples
        --------
        Check all documentation:

        ```python
        from great_docs import GreatDocs

        docs = GreatDocs()
        results = docs.proofread()

        print(f"Checked {results['files_checked']} files")
        print(f"Issues: {results['total_issues']}")

        for issue in results["issues"]:
            print(f"  {issue['file']}:{issue['line']} - {issue['message']}")
        ```

        Check with custom dictionary:

        ```python
        results = docs.proofread(
            custom_dictionary=["griffe", "quartodoc", "navbar"]
        )
        ```

        Check only spelling:

        ```python
        results = docs.proofread(only_rules=["SpellCheck"])
        ```
        """
        import tempfile
        from collections import defaultdict

        from ._harper import (
            HarperError,
            HarperFileResult,
            HarperNotFoundError,
            run_harper,
            run_harper_on_text,
        )

        # Collect files to check
        files_to_check: list[Path] = []

        if include_docs:
            # Scan user_guide directory if it exists
            user_guide_dir = self.project_root / "user_guide"
            if user_guide_dir.exists():
                files_to_check.extend(user_guide_dir.rglob("*.qmd"))
                files_to_check.extend(user_guide_dir.rglob("*.md"))

            # Check README in project root
            readme = self.project_root / "README.md"
            if readme.exists():
                files_to_check.append(readme)  # pragma: no cover

            # Check recipes if they exist
            recipes_dir = self.project_root / "recipes"
            if recipes_dir.exists():
                files_to_check.extend(recipes_dir.rglob("*.qmd"))  # pragma: no cover
                files_to_check.extend(recipes_dir.rglob("*.md"))  # pragma: no cover

        # Add Python files if checking docstrings
        py_files: list[Path] = []
        if include_docstrings:
            # Scan the importable module directory, which can differ from the
            # PyPI project name. Skip when no package is detectable.
            package_name = self._detect_package_name()
            if package_name:
                importable_name = self._resolve_importable_name(package_name)
                # Dotted module names (e.g. namespace packages "firebird.base")
                # map to nested directories.
                package_dir = self.project_root.joinpath(*importable_name.split("."))
                if package_dir.exists():
                    py_files.extend(package_dir.rglob("*.py"))

        # Build custom dictionary file if needed
        dict_path = None
        if custom_dictionary:
            tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False)
            tmp.write("\n".join(custom_dictionary))
            tmp.close()
            dict_path = tmp.name

        all_results: list[HarperFileResult] = []

        try:
            # Separate files by type
            md_files = [f for f in files_to_check if f.suffix == ".md"]
            qmd_files = [f for f in files_to_check if f.suffix == ".qmd"]

            # Check .md files directly
            if md_files:
                if verbose:  # pragma: no cover
                    print(f"Checking {len(md_files)} Markdown file(s)...")  # pragma: no cover

                results = run_harper(  # pragma: no cover
                    md_files,
                    dialect=dialect,
                    user_dict_path=dict_path,
                    ignore_rules=ignore_rules,
                    only_rules=only_rules,
                )
                all_results.extend(results)  # pragma: no cover

            # Check .qmd files via stdin (Harper doesn't recognize extension)
            for qmd_file in qmd_files:
                if verbose:
                    print(f"Checking {qmd_file.name}...")  # pragma: no cover

                try:
                    content = qmd_file.read_text(encoding="utf-8")
                    lints = run_harper_on_text(
                        content,
                        dialect=dialect,
                        user_dict_path=dict_path,
                        ignore_rules=ignore_rules,
                        only_rules=only_rules,
                    )

                    rel_path = str(qmd_file.relative_to(self.project_root))
                    for lint in lints:
                        lint.file = rel_path

                    all_results.append(
                        HarperFileResult(
                            file=rel_path,
                            lint_count=len(lints),
                            lints=lints,
                            error=None,
                        )
                    )
                except Exception as e:  # pragma: no cover
                    rel_path = str(qmd_file.relative_to(self.project_root))  # pragma: no cover
                    all_results.append(  # pragma: no cover
                        HarperFileResult(
                            file=rel_path,
                            lint_count=0,
                            lints=[],
                            error=str(e),
                        )
                    )

            # Check Python files if requested
            if py_files:
                if verbose:  # pragma: no cover
                    print(f"Checking {len(py_files)} Python file(s)...")  # pragma: no cover

                results = run_harper(  # pragma: no cover
                    py_files,
                    dialect=dialect,
                    user_dict_path=dict_path,
                    ignore_rules=ignore_rules,
                    only_rules=only_rules,
                )
                all_results.extend(results)  # pragma: no cover

        except HarperNotFoundError as e:  # pragma: no cover
            raise RuntimeError(str(e)) from e  # pragma: no cover
        except HarperError as e:  # pragma: no cover
            raise RuntimeError(f"Harper error: {e}") from e  # pragma: no cover
        finally:
            # Clean up temp dictionary file
            if dict_path:
                try:
                    Path(dict_path).unlink()
                except Exception:  # pragma: no cover
                    pass  # pragma: no cover

        # Aggregate results
        total_issues = sum(r.lint_count for r in all_results)
        by_kind: dict[str, int] = defaultdict(int)
        by_rule: dict[str, int] = defaultdict(int)
        by_file: dict[str, list[dict]] = {}
        all_issues: list[dict] = []

        for result in all_results:
            if result.lints:
                by_file[result.file] = []

            for lint in result.lints:
                by_kind[lint.kind] += 1
                by_rule[lint.rule] += 1

                issue = {
                    "file": lint.file,
                    "line": lint.line,
                    "column": lint.column,
                    "kind": lint.kind,
                    "rule": lint.rule,
                    "message": lint.message,
                    "matched_text": lint.matched_text,
                    "suggestions": lint.suggestions,
                    "priority": lint.priority,
                }
                all_issues.append(issue)
                by_file[result.file].append(issue)

        return {
            "files_checked": len(files_to_check) + len(py_files),
            "total_issues": total_issues,
            "by_kind": dict(by_kind),
            "by_rule": dict(by_rule),
            "issues": all_issues,
            "by_file": by_file,
        }
