Source code for sphinx_typesense

"""sphinx-typesense: A Sphinx extension for Typesense search integration.

This extension replaces Sphinx's default search with Typesense or Pagefind,
providing typo-tolerant, fast search functionality for documentation sites.

Features:
    - Build-time indexing of documentation content
    - Support for multiple search backends (Typesense, Pagefind)
    - DocSearch-compatible frontend search UI
    - Support for multiple Sphinx themes (RTD, Furo, Alabaster, PyData)
    - Both self-hosted Typesense Server and Typesense Cloud support
    - Zero-config Pagefind for static site search

Example:
    Add to your Sphinx conf.py::

        extensions = ["sphinx_typesense"]

        # For Typesense backend:
        typesense_host = "localhost"
        typesense_port = "8108"
        typesense_protocol = "http"
        typesense_api_key = "your_admin_key"
        typesense_search_api_key = "your_search_key"

        # Or for Pagefind backend (no server required):
        typesense_backend = "pagefind"

"""

from __future__ import annotations

import json
from pathlib import Path
from typing import TYPE_CHECKING

from sphinx.util import logging

from sphinx_typesense.backends.base import SearchBackend
from sphinx_typesense.config import get_effective_backend, setup_config, validate_config
from sphinx_typesense.templates import inject_search_assets

if TYPE_CHECKING:
    from sphinx.application import Sphinx
    from sphinx.config import Config

# Path to static files bundled with the extension
STATIC_PATH = Path(__file__).parent / "static"

__version__ = "0.1.0"
__all__ = ["SearchBackend", "__version__", "get_backend", "setup"]

logger = logging.getLogger(__name__)


def get_backend(app: Sphinx) -> SearchBackend:
    """Get the appropriate backend instance based on configuration.

    This factory function returns the correct backend implementation
    based on the typesense_backend configuration value.

    Args:
        app: The Sphinx application instance.

    Returns:
        SearchBackend instance (TypesenseBackend or PagefindBackend).

    Example:
        Get and use the backend for indexing::

            backend = get_backend(app)
            if backend.is_available():
                count = backend.index_all()
                print(f"Indexed {count} documents")

    """
    # Lazy imports to avoid circular dependencies and improve startup time
    from sphinx_typesense.backends.pagefind import PagefindBackend  # noqa: PLC0415
    from sphinx_typesense.backends.typesense import TypesenseBackend  # noqa: PLC0415

    backend_name = get_effective_backend(app.config)
    logger.debug("sphinx-typesense: Creating backend instance for '%s'", backend_name)

    if backend_name == "typesense":
        return TypesenseBackend(app)
    # pagefind
    return PagefindBackend(app)


def _write_config_js(app: Sphinx) -> None:
    """Write the Typesense configuration to a JS file.

    This function writes the TYPESENSE_CONFIG object to a JavaScript file
    that gets loaded before the init script.

    Args:
        app: The Sphinx application instance.

    """
    backend_name = get_effective_backend(app.config)
    if backend_name != "typesense":
        return

    # Build the config object
    # Note: port must be an integer for the Typesense JS client
    config = {
        "collectionName": app.config.typesense_collection_name,
        "host": app.config.typesense_host,
        "port": int(app.config.typesense_port),
        "protocol": app.config.typesense_protocol,
        "apiKey": app.config.typesense_search_api_key,
        "placeholder": app.config.typesense_placeholder,
        "numTypos": app.config.typesense_num_typos,
        "perPage": app.config.typesense_per_page,
        "filterBy": app.config.typesense_filter_by,
        "container": app.config.typesense_container,
    }

    config_js = f"window.TYPESENSE_CONFIG = {json.dumps(config, indent=2)};"

    # Write to the output static directory
    outdir = Path(app.outdir) / "_static"
    outdir.mkdir(parents=True, exist_ok=True)
    config_path = outdir / "typesense-config.js"
    config_path.write_text(config_js)
    logger.debug("sphinx-typesense: Wrote config to %s", config_path)


[docs] def index_documents(app: Sphinx, exception: Exception | None) -> None: """Sphinx event handler to index documents after build completes. This function is connected to Sphinx's ``build-finished`` event and triggers the indexing of all HTML documents using the selected backend. Args: app: The Sphinx application instance. exception: Exception raised during build, if any. When not None, indexing is skipped to avoid indexing partial or broken content. Note: Indexing is controlled by the ``typesense_enable_indexing`` config option. Set to False to disable automatic indexing (useful for development builds or when indexing is handled separately). Example: This function is called automatically by Sphinx after the build completes. It can also be invoked manually for testing:: index_documents(app, None) """ logger.debug("sphinx-typesense: index_documents handler invoked") # Skip if build failed if exception is not None: logger.warning("sphinx-typesense: Build failed with exception, skipping: %s", exception) return # Write the config JS file (for frontend search) _write_config_js(app) # Skip if indexing is disabled if not app.config.typesense_enable_indexing: logger.info("sphinx-typesense: Indexing disabled via typesense_enable_indexing=False") return # Get and use the appropriate backend backend = get_backend(app) # Check if backend is available (server up for Typesense, CLI available for Pagefind) if not backend.is_available(): logger.warning("sphinx-typesense: Backend '%s' not available, skipping indexing", backend.name) return # Perform indexing logger.info("sphinx-typesense: Starting document indexing with %s backend", backend.name) count = backend.index_all() logger.info("sphinx-typesense: Indexing complete - %d documents indexed", count)
def _add_static_files(app: Sphinx, config: Config) -> None: """Add static files based on selected backend. This function is connected to the config-inited event to add the appropriate CSS and JavaScript files for the selected backend. Args: app: The Sphinx application instance. config: The Sphinx configuration object. Note: Static files are added after configuration is initialized so that the backend selection is properly resolved. """ backend_name = get_effective_backend(config) logger.debug("sphinx-typesense: Adding static files for %s backend", backend_name) if backend_name == "typesense": # Add CSS for DocSearch (CDN base styles + local customizations) app.add_css_file( "https://cdn.jsdelivr.net/npm/typesense-docsearch-css@0.4.1/dist/style.min.css", priority=400, ) app.add_css_file("typesense-docsearch.css", priority=401) # Add DocSearch JS from CDN, then our init script app.add_js_file( "https://cdn.jsdelivr.net/npm/typesense-docsearch.js@3.4.0/dist/umd/index.min.js", priority=500, ) app.add_js_file("typesense-config.js", priority=500) # Generated during build app.add_js_file("typesense-init.js", priority=501) else: # pagefind # Pagefind assets are loaded dynamically from the _pagefind directory # We only need our init script and optional CSS customizations app.add_css_file("pagefind-ui.css", priority=400) app.add_js_file("pagefind-init.js", priority=500)
[docs] def setup(app: Sphinx) -> dict[str, str | bool]: """Sphinx extension entry point. Registers configuration values, connects to Sphinx build events, and adds static assets for the search UI. Args: app: The Sphinx application instance. Returns: Extension metadata dictionary containing: - version: The extension version string - parallel_read_safe: Whether parallel reading is supported - parallel_write_safe: Whether parallel writing is supported Example: This function is called automatically by Sphinx when the extension is loaded. Users enable it by adding to conf.py:: extensions = ["sphinx_typesense"] """ logger.info("sphinx-typesense: Initializing extension v%s", __version__) logger.debug("sphinx-typesense: setup() entry point invoked") # Register configuration values logger.debug("sphinx-typesense: Registering configuration values") setup_config(app) # Connect to Sphinx events logger.debug("sphinx-typesense: Connecting to Sphinx events") app.connect("config-inited", validate_config) app.connect("build-finished", index_documents) app.connect("html-page-context", inject_search_assets) # Register static files directory logger.debug("sphinx-typesense: Registering static files directory") app.config.html_static_path.append(str(STATIC_PATH)) # Defer adding static files until config is initialized # This allows the backend selection to be properly resolved app.connect("config-inited", _add_static_files) logger.debug("sphinx-typesense: Extension setup complete") return { "version": __version__, "parallel_read_safe": True, "parallel_write_safe": True, }