Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions docs/src/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ wiki = [

]
themes = [
"klink>=0.1.10",
"shibuya",
"sphinxawesome-theme",
]
Expand All @@ -85,6 +86,7 @@ develop = [
"sphinx-rust",
"sphinx-js>=5.0.0",
# Themes
"klink>=0.1.10",
"shibuya",
"sphinxawesome-theme",
]
Expand Down
26 changes: 22 additions & 4 deletions yardang/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -309,6 +320,7 @@ def customize(args):
"html_extra_path": [],
"html_css_files": [],
"html_js_files": [],
"intersphinx_mapping": {},
"source_suffix": [],
"exclude_patterns": [],
"language": "en",
Expand Down Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
10 changes: 8 additions & 2 deletions yardang/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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:
Expand Down
15 changes: 12 additions & 3 deletions yardang/conf.py.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand All @@ -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 = [
Expand All @@ -162,7 +166,9 @@ exclude_patterns = [
".github/*",
"AGENTS.md",
"CLAUDE.md",
{% if not existing_source %}
"README.md",
{% endif %}
"ROADMAP.md",
"js/*",
"*.wiki",
Expand All @@ -187,6 +193,7 @@ exclude_patterns = [
*{{exclude_patterns}}
]
language = "{{language}}"
intersphinx_mapping = {{intersphinx_mapping}}
pygments_style = "{{pygments_style}}"

# myst / myst-nb
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
102 changes: 102 additions & 0 deletions yardang/tests/test_source_directory.py
Original file line number Diff line number Diff line change
@@ -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