From a914146107acdff6fbcb90a7cf44250d543fc65c Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:26:26 -0400 Subject: [PATCH] Support existing Sphinx sites and the Klink theme Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com> --- docs/src/configuration.md | 40 ++++++++++ pyproject.toml | 2 + yardang/build.py | 26 ++++++- yardang/cli.py | 10 ++- yardang/conf.py.j2 | 15 +++- yardang/tests/test_source_directory.py | 102 +++++++++++++++++++++++++ 6 files changed, 186 insertions(+), 9 deletions(-) create mode 100644 yardang/tests/test_source_directory.py diff --git a/docs/src/configuration.md b/docs/src/configuration.md index 351a5784..b05a3318 100644 --- a/docs/src/configuration.md +++ b/docs/src/configuration.md @@ -70,6 +70,7 @@ dependency) for the following themes: - [`sphinxawesome_theme`](https://sphinxawesome.xyz/) - [`shibuya`](https://shibuya.lepture.com/) - [`fuma`](https://github.com/python-project-templates/sphinx-fuma) +- [`klink`](https://github.com/pmorissette/klink) `furo` is always available; install the rest with `pip install yardang[themes]`. Any other installed Sphinx theme works too — you just won't get the bundled @@ -303,6 +304,45 @@ each theme is browsable live at a suburl of the published site: - [`/_previews/shibuya/`](https://yardang.python-templates.dev/_previews/shibuya/) - [`/_previews/fuma/`](https://yardang.python-templates.dev/_previews/fuma/) +## `source-dir` + +Optional existing Sphinx source directory, relative to the project directory. +With this option, `yardang build` reads the existing source tree instead of +generating an `index.md` from a README. It leaves source documents and +`.gitignore` unchanged. Define navigation in the source documents; `pages` +does not generate a toctree in this mode. + +`root` names an existing document relative to `source-dir` and defaults to +`index.rst`. Static and extra asset paths are also relative to `source-dir`. +Yardang still generates its configuration from `pyproject.toml`; a `conf.py` +inside the source directory is not loaded. The CLI's `--source-dir` overrides +the configured directory. + +Use `yardang build --warning-is-error` to fail the build on Sphinx warnings, +for example in CI. Sphinx still processes all documents before returning a +failure status. + +```toml +[tool.yardang] +source-dir = "docs/source" +root = "index.rst" +use-autoapi = false +html-static-path = ["_static"] +``` + +To retain a Klink site, install `klink` (or `yardang[themes]`) and set `theme = "klink"`. +Yardang registers Klink's theme path, including for releases without a Sphinx +theme entry point. Theme options go in `[tool.yardang.html-theme-options]`. +Intersphinx inventories can be configured with explicit inventory URLs: + +```toml +[tool.yardang] +extensions = ["sphinx.ext.intersphinx"] + +[tool.yardang.intersphinx-mapping] +python = ["https://docs.python.org/3/", "https://docs.python.org/3/objects.inv"] +``` + ## `root` The root page to use, defaults to `README.md`. diff --git a/pyproject.toml b/pyproject.toml index 68c593d6..ed402032 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ wiki = [ ] themes = [ + "klink>=0.1.10", "shibuya", "sphinxawesome-theme", ] @@ -85,6 +86,7 @@ develop = [ "sphinx-rust", "sphinx-js>=5.0.0", # Themes + "klink>=0.1.10", "shibuya", "sphinxawesome-theme", ] diff --git a/yardang/build.py b/yardang/build.py index 353fc8c8..a57b6254 100644 --- a/yardang/build.py +++ b/yardang/build.py @@ -14,7 +14,7 @@ # Themes for which yardang ships per-theme defaults (a bundled ``{theme}.css`` and/or # an optional dependency). Used as the default set for ``yardang preview``. -BUNDLED_THEMES = ("furo", "sphinxawesome_theme", "shibuya", "fuma") +BUNDLED_THEMES = ("furo", "sphinxawesome_theme", "shibuya", "fuma", "klink") # Themes whose Sphinx package is not importable under the theme's own name. _THEME_MODULES = {"fuma": "sphinx_fuma"} @@ -157,6 +157,7 @@ def generate_docs_configuration( theme: str | None = None, docs_root: str | None = None, root: str | None = None, + source_dir: str | None = None, cname: str | None = None, pages: list | None = None, use_autoapi: bool | None = None, @@ -191,6 +192,8 @@ def generate_docs_configuration( theme: Sphinx theme name. Defaults to ``"furo"``. docs_root: Base URL for hosted documentation. Used for canonical URLs. root: Path to README or index file to use as documentation root. + source_dir: Existing Sphinx source directory. When set, root names an + existing document relative to this directory and no index is generated. cname: Custom domain name for GitHub Pages CNAME file. pages: List of page paths to include in the toctree. use_autoapi: Whether to use sphinx-autoapi for Python API docs. @@ -298,7 +301,15 @@ def customize(args): custom_css = _resolve_custom_asset(custom_css, theme, "css", assets_dir=assets_dir) custom_js = _resolve_custom_asset(custom_js, theme, "js", assets_dir=assets_dir) - source_dir = os.path.curdir + source_dir = source_dir or get_config_flex(section="source-dir", base=config_base) + existing_source = source_dir is not None + if existing_source: + source_dir = str(Path(source_dir).resolve()) + root = root or "index.rst" + if not (Path(source_dir) / root).is_file(): + raise FileNotFoundError(str(Path(source_dir) / root)) + else: + source_dir = os.path.curdir configuration_args = {} for config_option, default in { @@ -309,6 +320,7 @@ def customize(args): "html_extra_path": [], "html_css_files": [], "html_js_files": [], + "intersphinx_mapping": {}, "source_suffix": [], "exclude_patterns": [], "language": "en", @@ -388,6 +400,10 @@ def customize(args): if configuration_args[config_option] is None: configuration_args[config_option] = default + if existing_source: + for option in ("html_static_path", "html_extra_path"): + configuration_args[option] = [str(Path(source_dir) / path) for path in configuration_args[option]] + # Load breathe/doxygen configuration from tool.yardang.breathe breathe_config_base = f"{config_base}.breathe" breathe_args = {} @@ -574,6 +590,8 @@ def customize(args): use_autoapi=use_autoapi, autoapi_ignore=autoapi_ignore, source_dir=source_dir, + existing_source=existing_source, + master_doc=Path(root).with_suffix("").as_posix() if existing_source else "index", previous_versions=previous_versions, use_breathe=use_breathe, use_sphinx_rust=use_sphinx_rust, @@ -617,7 +635,7 @@ def customize(args): (js_dir / "custom.js").write_text(custom_js or "") # append docs-specific ignores to gitignore - if Path(".gitignore").exists(): + if not existing_source and Path(".gitignore").exists(): has_html_build_folder = False has_index_md = False with open(".gitignore", "r+") as fp: @@ -632,7 +650,7 @@ def customize(args): fp.write("docs/html\n") if not has_index_md: fp.write("index.md\n") - if "index.md" not in pages: + if not existing_source and "index.md" not in pages: Path("index.md").touch(exist_ok=True) # yield folder path to sphinx build yield td diff --git a/yardang/cli.py b/yardang/cli.py index 56800185..15157c4f 100644 --- a/yardang/cli.py +++ b/yardang/cli.py @@ -7,7 +7,7 @@ from typer import Exit, Typer from .build import BUNDLED_THEMES, generate_docs_configuration, generate_wiki_configuration, theme_module -from .utils import get_config +from .utils import get_config, get_config_flex from .wiki import process_wiki_output @@ -16,6 +16,7 @@ def build( quiet: bool = False, debug: bool = False, pdb: bool = False, + warning_is_error: bool = False, project: str | None = None, title: str | None = None, module: str | None = None, @@ -26,6 +27,7 @@ def build( theme: str | None = None, docs_root: str | None = None, root: str | None = None, + source_dir: str | None = None, cname: str | None = None, pages: list[Path] | None = None, use_autoapi: bool | None = None, @@ -35,6 +37,7 @@ def build( config_base: str | None = "tool.yardang", previous_versions: bool | None = False, ): + source_dir = source_dir or get_config_flex(section="source-dir", base=config_base or "tool.yardang") with generate_docs_configuration( project=project, title=title, @@ -46,6 +49,7 @@ def build( theme=theme, docs_root=docs_root, root=root, + source_dir=source_dir, cname=cname, pages=pages, use_autoapi=use_autoapi, @@ -55,7 +59,9 @@ def build( config_base=config_base, previous_versions=previous_versions, ) as file: - build_cmd = [executable, "-m", "sphinx", ".", output, "-c", file] + build_cmd = [executable, "-m", "sphinx", source_dir or ".", output, "-c", file] + if warning_is_error: + build_cmd.extend(["-W", "--keep-going"]) if debug: print(" ".join(build_cmd)) if quiet: diff --git a/yardang/conf.py.j2 b/yardang/conf.py.j2 index a92a879b..e9097d3a 100644 --- a/yardang/conf.py.j2 +++ b/yardang/conf.py.j2 @@ -130,6 +130,10 @@ os.environ["SPHINX_BUILDING"] = "1" # THEMEING # ############ html_theme = "{{theme}}" +if html_theme == "klink": + import klink + html_theme_path = [klink.get_html_theme_path()] + html_title = f"{title} v{version}" html_theme_options = {{html_theme_options}} html_static_path = {{html_static_path}} html_extra_path = {{html_extra_path}} @@ -146,7 +150,7 @@ html_js_files = [ # SPHINX INTERNALS # #################### -master_doc = "index" +master_doc = {{ master_doc | tojson }} templates_path = ["_templates"] source_suffix = [".rst", ".md", *{{source_suffix}}] exclude_patterns = [ @@ -162,7 +166,9 @@ exclude_patterns = [ ".github/*", "AGENTS.md", "CLAUDE.md", + {% if not existing_source %} "README.md", + {% endif %} "ROADMAP.md", "js/*", "*.wiki", @@ -187,6 +193,7 @@ exclude_patterns = [ *{{exclude_patterns}} ] language = "{{language}}" +intersphinx_mapping = {{intersphinx_mapping}} pygments_style = "{{pygments_style}}" # myst / myst-nb @@ -326,8 +333,8 @@ def run_copyreadme(app): content = rebase_relative_references(content, source=readme, destination=out) out.write_text(toctree_root + "\n" + content) -def run_copycname(_): - out = Path("{{source_dir}}") / "docs" / "html" / "CNAME" +def run_copycname(app): + out = Path(app.outdir) / "CNAME" if cname: out.write_text(cname) @@ -400,6 +407,8 @@ def setup(app): if {{previous_versions}}: app.connect("builder-inited", run_create_previous_version_markdown) app.connect("doctree-read", run_add_version_links_to_toctree, priority=500) + {% if not existing_source %} app.connect("builder-inited", run_copyreadme) + {% endif %} app.connect("builder-inited", run_copycname) app.connect("source-read", run_convert_github_admonitions_to_rst) diff --git a/yardang/tests/test_source_directory.py b/yardang/tests/test_source_directory.py new file mode 100644 index 00000000..822bae17 --- /dev/null +++ b/yardang/tests/test_source_directory.py @@ -0,0 +1,102 @@ +from pathlib import Path +from unittest.mock import patch + +import pytest +from typer import Exit + +from yardang.build import generate_docs_configuration +from yardang.cli import build + + +@pytest.fixture +def source_project(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text(""" +[project] +name = "test-project" +version = "1.0.0" + +[tool.yardang] +source-dir = "docs/source" +use-autoapi = false +use-search = false +html-static-path = ["_static"] +html-extra-path = ["extra"] +cname = "docs.example.com" +""") + source = tmp_path / "docs/source" + source.mkdir(parents=True) + (source / "index.rst").write_text("Test Project\n============\n\n.. include:: introduction.rst\n\n.. toctree::\n\n guide\n") + (source / "introduction.rst").write_text("Existing introduction.\n") + (source / "guide.rst").write_text("Guide\n=====\n\nExisting guide.\n") + (source / "_static").mkdir() + (source / "_static/site.css").write_text("body { color: navy; }\n") + (source / "extra").mkdir() + (source / "extra/robots.txt").write_text("User-agent: *\n") + (tmp_path / ".gitignore").write_text(".venv/\n") + return source + + +def test_existing_sources_are_not_rewritten(source_project): + original = (source_project / "index.rst").read_bytes() + with generate_docs_configuration() as conf_dir: + conf = (Path(conf_dir) / "conf.py").read_text() + assert 'master_doc = "index"' in conf + assert 'app.connect("builder-inited", run_copyreadme)' not in conf + assert str(source_project / "_static") in conf + assert str(source_project / "extra") in conf + assert (source_project / "index.rst").read_bytes() == original + assert not (source_project / "index.md").exists() + assert not Path("index.md").exists() + assert Path(".gitignore").read_text() == ".venv/\n" + + +def test_missing_root_fails_before_generating_files(source_project): + with pytest.raises(FileNotFoundError, match="missing.rst"), generate_docs_configuration(root="missing.rst"): + pass + assert not Path("index.md").exists() + + +def test_custom_root(source_project): + with generate_docs_configuration(root="guide.rst") as conf_dir: + assert 'master_doc = "guide"' in (Path(conf_dir) / "conf.py").read_text() + + +def test_cli_source_directory_overrides_configuration(source_project): + with patch("yardang.cli.Popen") as popen: + popen.return_value.poll.return_value = 0 + popen.return_value.returncode = 0 + build(source_dir=str(source_project), output="site", warning_is_error=True) + assert popen.call_args.args[0][3:5] == [str(source_project), "site"] + assert popen.call_args.args[0][-2:] == ["-W", "--keep-going"] + + +@pytest.mark.parametrize("theme", ["furo", "klink"]) +def test_build_existing_rst_site(source_project, theme): + build(output="site", theme=theme, warning_is_error=True) + assert "Existing introduction." in Path("site/index.html").read_text() + assert "Existing guide." in Path("site/guide.html").read_text() + assert Path("site/_static/site.css").is_file() + assert Path("site/robots.txt").is_file() + assert Path("site/CNAME").read_text() == "docs.example.com" + assert not Path("index.md").exists() + + +def test_build_existing_markdown_root(source_project): + (source_project / "README.md").write_text("# Existing Markdown\n\n```{toctree}\nindex\n```\n") + build(root="README.md", output="site", warning_is_error=True) + assert "Existing Markdown" in Path("site/README.html").read_text() + assert not Path("index.md").exists() + + +def test_strict_build_fails_on_warnings(source_project): + (source_project / "guide.rst").write_text("Guide\n=====\n\n.. toctree::\n\n missing-page\n") + with pytest.raises(Exit): + build(output="site", warning_is_error=True) + + +def test_klink_theme_path(source_project): + with generate_docs_configuration(theme="klink") as conf_dir: + conf = (Path(conf_dir) / "conf.py").read_text() + assert 'html_theme = "klink"' in conf + assert "html_theme_path = [klink.get_html_theme_path()]" in conf