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
23 changes: 22 additions & 1 deletion docs/src/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,27 @@ github_url = "https://github.com/you/project"
toc_style = "clerk"
```

For `fuma`, Yardang infers `github_url` from GitHub URLs in `[project.urls]`.
It checks `Repository`, `Source`, `Source Code`, and `Homepage` first, in that
order, then remaining entries in declaration order. Labels are case-insensitive.
HTTP and HTTPS GitHub links are normalized to `https://github.com/owner/repo`,
removing `.git`, trailing slashes, subpaths, queries, and fragments.
An explicit `github_url` takes precedence; `github_url = ""` disables the link.
Other themes receive no inferred option.

Yardang also infers Fuma's `nav_links` from these case-insensitive project URL labels:

| Project URL label | Link title |
| --------------------------- | ---------- |
| `Homepage` | Home |
| `Documentation` (or `Docs`) | Docs |

These links appear in the header and sidebar toolbar, in Home, Docs order.
`Documentation` takes precedence over `Docs`. Duplicate destinations, including
the GitHub link, are omitted; trailing slashes are ignored when comparing URLs.
Explicit `nav_links` replaces these defaults; `nav_links = []` disables them.
Missing URLs produce no link.

| Option | Default | Description |
| -------------------------------------------- | --------- | -------------------------------------------------------------------------------------- |
| `layout` | `docs` | `docs` puts the header beside the sidebar; `notebook` spans it across the top. |
Expand All @@ -114,7 +135,7 @@ toc_style = "clerk"
| `default_open_level` | `1` | Sidebar folder depth expanded by default. |
| `github_url` | — | Adds a GitHub link, and an "Edit this page" link. |
| `edit_page_url_template` | — | Overrides the edit link, e.g. `https://host/edit/main/{filename}`. |
| `nav_links` | — | Header links, as `[{title, url}]` or `"Title\|url, Title\|url"`. |
| `nav_links` | — | Header and sidebar toolbar links, as `[{title, url}]` or `"Title\|url, Title\|url"`. |
| `sidebar_tabs` | — | Navigator entries under the search box, as `[{title, url, description, icon, match}]`. |
| `announcement` | — | Banner text shown above the header. |
| `footer_text` | — | Extra line in the footer. |
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ yardang = "yardang.cli:main"
[project.urls]
Repository = "https://github.com/python-project-templates/yardang"
Homepage = "https://github.com/python-project-templates/yardang"
Documentation = "https://yardang.python-templates.dev"

[tool.bumpversion]
current_version = "0.10.3"
Expand Down Expand Up @@ -203,7 +204,6 @@ pages = [
]
use-autoapi = false
html_extra_path = ["docs/examples"]
html_theme_options = { github_url = "https://github.com/python-project-templates/yardang" }

[tool.yardang.breathe]
projects = { calculator = "examples/cpp/xml" }
Expand Down
32 changes: 32 additions & 0 deletions yardang/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from contextlib import contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
from urllib.parse import urlsplit

from jinja2 import Environment, FileSystemLoader

Expand All @@ -25,6 +26,19 @@ def theme_module(theme: str) -> str:
return _THEME_MODULES.get(theme, theme)


def _github_repository_url(urls: dict[str, str]) -> str | None:
"""Find a GitHub repository in project URLs, preferring source links."""
urls = {label.lower(): url for label, url in urls.items()}
candidates = [urls.get(label, "") for label in ("repository", "source", "source code", "homepage")]
for url in candidates + list(urls.values()):
if not url.lower().startswith(("https://github.com/", "http://github.com/")):
continue
parts = urlsplit(url).path.strip("/").split("/")
if len(parts) >= 2 and parts[0] and parts[1]:
return f"https://github.com/{parts[0]}/{parts[1].removesuffix('.git')}"
return None


def _resolve_custom_asset(value: str | Path | None, theme: str | None, extension: str, *, assets_dir: Path) -> str | None:
"""Resolve custom CSS/JS content for the docs build.

Expand Down Expand Up @@ -408,6 +422,24 @@ def customize(args):
if configuration_args[config_option] is None:
configuration_args[config_option] = default

if theme == "fuma":
project_urls = get_config(section="urls", base="project") or {}
theme_options = configuration_args["html_theme_options"]
if "github_url" not in theme_options:
github_url = _github_repository_url(project_urls)
if github_url:
theme_options["github_url"] = github_url
if "nav_links" not in theme_options:
urls = {label.lower(): url for label, url in project_urls.items()}
links = []
seen = {theme_options.get("github_url", "").rstrip("/")}
for link_title, url in (("Home", urls.get("homepage")), ("Docs", urls.get("documentation") or urls.get("docs"))):
if url and url.rstrip("/") not in seen:
links.append({"title": link_title, "url": url})
seen.add(url.rstrip("/"))
if links:
theme_options["nav_links"] = links

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]]
Expand Down
132 changes: 132 additions & 0 deletions yardang/tests/test_themes.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import ast
import os
from pathlib import Path
from unittest.mock import patch

import pytest
import toml
from sphinx.cmd.build import build_main

import yardang.build as build_module
from yardang.build import BUNDLED_THEMES, _resolve_custom_asset, generate_docs_configuration
Expand Down Expand Up @@ -85,6 +88,135 @@ def test_unconfigured_theme_is_fuma(self, tmp_path):
assert "sphinx_fuma" in conf


class TestProjectLinks:
@pytest.fixture
def project(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / "README.md").write_text("# Test Project\n")
return toml.loads(MINIMAL_PYPROJECT)

def _options(self, project, **kwargs):
Path("pyproject.toml").write_text(toml.dumps(project))
with generate_docs_configuration(**kwargs) as config_dir:
conf = Path(config_dir, "conf.py").read_text()
line = next(line for line in conf.splitlines() if line.startswith("html_theme_options = "))
return ast.literal_eval(line.split(" = ", 1)[1])

@pytest.mark.parametrize(
("urls", "expected"),
[
({}, {}),
({"Homepage": "https://example.com"}, {}),
({"Repository": "https://gitlab.com/owner/repo"}, {}),
({"Homepage": "https://owner.github.io/repo"}, {}),
({"Repository": "https://github.com.evil.test/owner/repo"}, {}),
({"Repository": "https://github.com/owner"}, {}),
({"Repository": "ftp://github.com/owner/repo"}, {}),
({"Repository": "https://github.com/owner/repo"}, {"github_url": "https://github.com/owner/repo"}),
({"Homepage": "https://github.com/owner/repo/"}, {"github_url": "https://github.com/owner/repo"}),
({"Source Code": "https://github.com/owner/repo.git"}, {"github_url": "https://github.com/owner/repo"}),
({"Bug Tracker": "https://github.com/owner/repo/issues?q=open#top"}, {"github_url": "https://github.com/owner/repo"}),
(
{"Homepage": "https://github.com/owner/home", "rEpOsItOrY": "https://github.com/owner/repo"},
{"github_url": "https://github.com/owner/repo"},
),
(
{"Homepage": "https://github.com/owner/home", "Source": "https://github.com/owner/repo"},
{"github_url": "https://github.com/owner/repo"},
),
(
{"Repository": "https://example.com/repo", "Homepage": "https://github.com/owner/repo"},
{"github_url": "https://github.com/owner/repo"},
),
],
)
def test_infers_github_repository(self, project, urls, expected):
project["project"]["urls"] = urls
assert self._options(project).get("github_url") == expected.get("github_url")

@pytest.mark.parametrize(
("urls", "expected"),
[
({}, []),
({"Homepage": "https://example.com"}, [{"title": "Home", "url": "https://example.com"}]),
({"Documentation": "https://docs.example.com"}, [{"title": "Docs", "url": "https://docs.example.com"}]),
({"Docs": "https://docs.example.com"}, [{"title": "Docs", "url": "https://docs.example.com"}]),
(
{"hOmEpAgE": "https://example.com", "dOcUmEnTaTiOn": "https://docs.example.com/guide"},
[{"title": "Home", "url": "https://example.com"}, {"title": "Docs", "url": "https://docs.example.com/guide"}],
),
(
{"Docs": "https://old.example.com", "Documentation": "https://docs.example.com"},
[{"title": "Docs", "url": "https://docs.example.com"}],
),
(
{"Homepage": "https://example.com/", "Documentation": "https://example.com"},
[{"title": "Home", "url": "https://example.com/"}],
),
({"Homepage": "https://github.com/owner/repo/"}, []),
],
)
def test_infers_home_and_docs_links(self, project, urls, expected):
project["project"]["urls"] = urls
assert self._options(project).get("nav_links", []) == expected

@pytest.mark.parametrize("nav_links", [[], [{"title": "Blog", "url": "https://example.com/blog"}], "Blog|https://example.com/blog"])
@pytest.mark.parametrize("option_key", ["html_theme_options", "html-theme-options"])
def test_explicit_navigation_takes_precedence(self, project, nav_links, option_key):
project["project"]["urls"] = {
"Repository": "https://github.com/owner/repo",
"Homepage": "https://example.com",
"Documentation": "https://docs.example.com",
}
project["tool"]["yardang"][option_key] = {"nav_links": nav_links}
assert self._options(project) == {"nav_links": nav_links, "github_url": "https://github.com/owner/repo"}

@pytest.mark.parametrize("github_url", ["https://github.com/other/repo", ""])
@pytest.mark.parametrize("option_key", ["html_theme_options", "html-theme-options"])
def test_explicit_options_take_precedence(self, project, github_url, option_key):
project["project"]["urls"] = {"Repository": "https://github.com/owner/repo", "Homepage": "https://example.com"}
options = {"github_url": github_url, "toc_style": "clerk"}
project["tool"]["yardang"][option_key] = options
assert self._options(project) == {**options, "nav_links": [{"title": "Home", "url": "https://example.com"}]}

@pytest.mark.parametrize("theme", [theme for theme in BUNDLED_THEMES if theme != "fuma"])
def test_other_themes_do_not_receive_project_links(self, project, theme):
project["project"]["urls"] = {
"Repository": "https://github.com/owner/repo",
"Homepage": "https://example.com",
"Documentation": "https://docs.example.com",
}
assert self._options(project, theme=theme) == {}

def test_custom_config_base_and_existing_options(self, project):
project["project"]["urls"] = {"Repository": "https://github.com/owner/repo", "Documentation": "https://docs.example.com"}
project["tool"]["custom"] = project["tool"].pop("yardang")
project["tool"]["custom"]["html-theme-options"] = {"toc_style": "clerk"}
assert self._options(project, config_base="tool.custom") == {
"toc_style": "clerk",
"github_url": "https://github.com/owner/repo",
"nav_links": [{"title": "Docs", "url": "https://docs.example.com"}],
}

def test_inferred_urls_render_project_links(self, project, tmp_path):
project["project"]["urls"] = {
"Repository": "https://github.com/owner/repo",
"Homepage": "https://example.com",
"Documentation": "https://docs.example.com",
}
self._options(project)
output = tmp_path / "html"
with generate_docs_configuration(html_output_dir=str(output)) as config_dir:
result = build_main(["-q", "-b", "html", "-c", str(config_dir), ".", str(output)])
assert result == 0
html = (output / "index.html").read_text()
assert "<title>Test Project" in html
assert 'href="https://github.com/owner/repo"' in html
assert 'href="https://github.com/owner/repo/edit/HEAD/index.md"' in html
assert html.count('href="https://example.com" target="_blank" rel="noopener">Home</a>') == 2
assert html.count('href="https://docs.example.com" target="_blank" rel="noopener">Docs</a>') == 2


class TestResolveCustomAsset:
"""Layered resolution of bundled and user-supplied CSS/JS."""

Expand Down