From 8f665cbbb9de3702209cc1f7763bb95a3f8c4804 Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:15:11 -0400 Subject: [PATCH] Infer Fuma navigation links from project metadata Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com> --- docs/src/configuration.md | 23 +++++- pyproject.toml | 2 +- yardang/build.py | 32 +++++++++ yardang/tests/test_themes.py | 132 +++++++++++++++++++++++++++++++++++ 4 files changed, 187 insertions(+), 2 deletions(-) diff --git a/docs/src/configuration.md b/docs/src/configuration.md index b620c25a..397b837d 100644 --- a/docs/src/configuration.md +++ b/docs/src/configuration.md @@ -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. | @@ -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. | diff --git a/pyproject.toml b/pyproject.toml index f032e91e..7ac2fb0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" @@ -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" } diff --git a/yardang/build.py b/yardang/build.py index b50293b2..c3a547a9 100644 --- a/yardang/build.py +++ b/yardang/build.py @@ -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 @@ -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. @@ -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]] diff --git a/yardang/tests/test_themes.py b/yardang/tests/test_themes.py index 18641bf9..e425be9f 100644 --- a/yardang/tests/test_themes.py +++ b/yardang/tests/test_themes.py @@ -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 @@ -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 "