Skip to content

Commit a220f5a

Browse files
authored
Merge pull request #169 from python-project-templates/tkp/infer-fuma-project-links
Infer Fuma navigation links from project metadata
2 parents 4ad752e + 8f665cb commit a220f5a

4 files changed

Lines changed: 187 additions & 2 deletions

File tree

‎docs/src/configuration.md‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,27 @@ github_url = "https://github.com/you/project"
103103
toc_style = "clerk"
104104
```
105105

106+
For `fuma`, Yardang infers `github_url` from GitHub URLs in `[project.urls]`.
107+
It checks `Repository`, `Source`, `Source Code`, and `Homepage` first, in that
108+
order, then remaining entries in declaration order. Labels are case-insensitive.
109+
HTTP and HTTPS GitHub links are normalized to `https://github.com/owner/repo`,
110+
removing `.git`, trailing slashes, subpaths, queries, and fragments.
111+
An explicit `github_url` takes precedence; `github_url = ""` disables the link.
112+
Other themes receive no inferred option.
113+
114+
Yardang also infers Fuma's `nav_links` from these case-insensitive project URL labels:
115+
116+
| Project URL label | Link title |
117+
| --------------------------- | ---------- |
118+
| `Homepage` | Home |
119+
| `Documentation` (or `Docs`) | Docs |
120+
121+
These links appear in the header and sidebar toolbar, in Home, Docs order.
122+
`Documentation` takes precedence over `Docs`. Duplicate destinations, including
123+
the GitHub link, are omitted; trailing slashes are ignored when comparing URLs.
124+
Explicit `nav_links` replaces these defaults; `nav_links = []` disables them.
125+
Missing URLs produce no link.
126+
106127
| Option | Default | Description |
107128
| -------------------------------------------- | --------- | -------------------------------------------------------------------------------------- |
108129
| `layout` | `docs` | `docs` puts the header beside the sidebar; `notebook` spans it across the top. |
@@ -114,7 +135,7 @@ toc_style = "clerk"
114135
| `default_open_level` | `1` | Sidebar folder depth expanded by default. |
115136
| `github_url` | — | Adds a GitHub link, and an "Edit this page" link. |
116137
| `edit_page_url_template` | — | Overrides the edit link, e.g. `https://host/edit/main/{filename}`. |
117-
| `nav_links` | — | Header links, as `[{title, url}]` or `"Title\|url, Title\|url"`. |
138+
| `nav_links` | — | Header and sidebar toolbar links, as `[{title, url}]` or `"Title\|url, Title\|url"`. |
118139
| `sidebar_tabs` | — | Navigator entries under the search box, as `[{title, url, description, icon, match}]`. |
119140
| `announcement` | — | Banner text shown above the header. |
120141
| `footer_text` | — | Extra line in the footer. |

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ yardang = "yardang.cli:main"
9898
[project.urls]
9999
Repository = "https://github.com/python-project-templates/yardang"
100100
Homepage = "https://github.com/python-project-templates/yardang"
101+
Documentation = "https://yardang.python-templates.dev"
101102

102103
[tool.bumpversion]
103104
current_version = "0.10.3"
@@ -203,7 +204,6 @@ pages = [
203204
]
204205
use-autoapi = false
205206
html_extra_path = ["docs/examples"]
206-
html_theme_options = { github_url = "https://github.com/python-project-templates/yardang" }
207207

208208
[tool.yardang.breathe]
209209
projects = { calculator = "examples/cpp/xml" }

‎yardang/build.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from contextlib import contextmanager
66
from pathlib import Path
77
from tempfile import TemporaryDirectory
8+
from urllib.parse import urlsplit
89

910
from jinja2 import Environment, FileSystemLoader
1011

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

2728

29+
def _github_repository_url(urls: dict[str, str]) -> str | None:
30+
"""Find a GitHub repository in project URLs, preferring source links."""
31+
urls = {label.lower(): url for label, url in urls.items()}
32+
candidates = [urls.get(label, "") for label in ("repository", "source", "source code", "homepage")]
33+
for url in candidates + list(urls.values()):
34+
if not url.lower().startswith(("https://github.com/", "http://github.com/")):
35+
continue
36+
parts = urlsplit(url).path.strip("/").split("/")
37+
if len(parts) >= 2 and parts[0] and parts[1]:
38+
return f"https://github.com/{parts[0]}/{parts[1].removesuffix('.git')}"
39+
return None
40+
41+
2842
def _resolve_custom_asset(value: str | Path | None, theme: str | None, extension: str, *, assets_dir: Path) -> str | None:
2943
"""Resolve custom CSS/JS content for the docs build.
3044
@@ -408,6 +422,24 @@ def customize(args):
408422
if configuration_args[config_option] is None:
409423
configuration_args[config_option] = default
410424

425+
if theme == "fuma":
426+
project_urls = get_config(section="urls", base="project") or {}
427+
theme_options = configuration_args["html_theme_options"]
428+
if "github_url" not in theme_options:
429+
github_url = _github_repository_url(project_urls)
430+
if github_url:
431+
theme_options["github_url"] = github_url
432+
if "nav_links" not in theme_options:
433+
urls = {label.lower(): url for label, url in project_urls.items()}
434+
links = []
435+
seen = {theme_options.get("github_url", "").rstrip("/")}
436+
for link_title, url in (("Home", urls.get("homepage")), ("Docs", urls.get("documentation") or urls.get("docs"))):
437+
if url and url.rstrip("/") not in seen:
438+
links.append({"title": link_title, "url": url})
439+
seen.add(url.rstrip("/"))
440+
if links:
441+
theme_options["nav_links"] = links
442+
411443
if existing_source:
412444
for option in ("html_static_path", "html_extra_path"):
413445
configuration_args[option] = [str(Path(source_dir) / path) for path in configuration_args[option]]

‎yardang/tests/test_themes.py‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import ast
12
import os
23
from pathlib import Path
34
from unittest.mock import patch
45

56
import pytest
7+
import toml
8+
from sphinx.cmd.build import build_main
69

710
import yardang.build as build_module
811
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):
8588
assert "sphinx_fuma" in conf
8689

8790

91+
class TestProjectLinks:
92+
@pytest.fixture
93+
def project(self, tmp_path, monkeypatch):
94+
monkeypatch.chdir(tmp_path)
95+
(tmp_path / "README.md").write_text("# Test Project\n")
96+
return toml.loads(MINIMAL_PYPROJECT)
97+
98+
def _options(self, project, **kwargs):
99+
Path("pyproject.toml").write_text(toml.dumps(project))
100+
with generate_docs_configuration(**kwargs) as config_dir:
101+
conf = Path(config_dir, "conf.py").read_text()
102+
line = next(line for line in conf.splitlines() if line.startswith("html_theme_options = "))
103+
return ast.literal_eval(line.split(" = ", 1)[1])
104+
105+
@pytest.mark.parametrize(
106+
("urls", "expected"),
107+
[
108+
({}, {}),
109+
({"Homepage": "https://example.com"}, {}),
110+
({"Repository": "https://gitlab.com/owner/repo"}, {}),
111+
({"Homepage": "https://owner.github.io/repo"}, {}),
112+
({"Repository": "https://github.com.evil.test/owner/repo"}, {}),
113+
({"Repository": "https://github.com/owner"}, {}),
114+
({"Repository": "ftp://github.com/owner/repo"}, {}),
115+
({"Repository": "https://github.com/owner/repo"}, {"github_url": "https://github.com/owner/repo"}),
116+
({"Homepage": "https://github.com/owner/repo/"}, {"github_url": "https://github.com/owner/repo"}),
117+
({"Source Code": "https://github.com/owner/repo.git"}, {"github_url": "https://github.com/owner/repo"}),
118+
({"Bug Tracker": "https://github.com/owner/repo/issues?q=open#top"}, {"github_url": "https://github.com/owner/repo"}),
119+
(
120+
{"Homepage": "https://github.com/owner/home", "rEpOsItOrY": "https://github.com/owner/repo"},
121+
{"github_url": "https://github.com/owner/repo"},
122+
),
123+
(
124+
{"Homepage": "https://github.com/owner/home", "Source": "https://github.com/owner/repo"},
125+
{"github_url": "https://github.com/owner/repo"},
126+
),
127+
(
128+
{"Repository": "https://example.com/repo", "Homepage": "https://github.com/owner/repo"},
129+
{"github_url": "https://github.com/owner/repo"},
130+
),
131+
],
132+
)
133+
def test_infers_github_repository(self, project, urls, expected):
134+
project["project"]["urls"] = urls
135+
assert self._options(project).get("github_url") == expected.get("github_url")
136+
137+
@pytest.mark.parametrize(
138+
("urls", "expected"),
139+
[
140+
({}, []),
141+
({"Homepage": "https://example.com"}, [{"title": "Home", "url": "https://example.com"}]),
142+
({"Documentation": "https://docs.example.com"}, [{"title": "Docs", "url": "https://docs.example.com"}]),
143+
({"Docs": "https://docs.example.com"}, [{"title": "Docs", "url": "https://docs.example.com"}]),
144+
(
145+
{"hOmEpAgE": "https://example.com", "dOcUmEnTaTiOn": "https://docs.example.com/guide"},
146+
[{"title": "Home", "url": "https://example.com"}, {"title": "Docs", "url": "https://docs.example.com/guide"}],
147+
),
148+
(
149+
{"Docs": "https://old.example.com", "Documentation": "https://docs.example.com"},
150+
[{"title": "Docs", "url": "https://docs.example.com"}],
151+
),
152+
(
153+
{"Homepage": "https://example.com/", "Documentation": "https://example.com"},
154+
[{"title": "Home", "url": "https://example.com/"}],
155+
),
156+
({"Homepage": "https://github.com/owner/repo/"}, []),
157+
],
158+
)
159+
def test_infers_home_and_docs_links(self, project, urls, expected):
160+
project["project"]["urls"] = urls
161+
assert self._options(project).get("nav_links", []) == expected
162+
163+
@pytest.mark.parametrize("nav_links", [[], [{"title": "Blog", "url": "https://example.com/blog"}], "Blog|https://example.com/blog"])
164+
@pytest.mark.parametrize("option_key", ["html_theme_options", "html-theme-options"])
165+
def test_explicit_navigation_takes_precedence(self, project, nav_links, option_key):
166+
project["project"]["urls"] = {
167+
"Repository": "https://github.com/owner/repo",
168+
"Homepage": "https://example.com",
169+
"Documentation": "https://docs.example.com",
170+
}
171+
project["tool"]["yardang"][option_key] = {"nav_links": nav_links}
172+
assert self._options(project) == {"nav_links": nav_links, "github_url": "https://github.com/owner/repo"}
173+
174+
@pytest.mark.parametrize("github_url", ["https://github.com/other/repo", ""])
175+
@pytest.mark.parametrize("option_key", ["html_theme_options", "html-theme-options"])
176+
def test_explicit_options_take_precedence(self, project, github_url, option_key):
177+
project["project"]["urls"] = {"Repository": "https://github.com/owner/repo", "Homepage": "https://example.com"}
178+
options = {"github_url": github_url, "toc_style": "clerk"}
179+
project["tool"]["yardang"][option_key] = options
180+
assert self._options(project) == {**options, "nav_links": [{"title": "Home", "url": "https://example.com"}]}
181+
182+
@pytest.mark.parametrize("theme", [theme for theme in BUNDLED_THEMES if theme != "fuma"])
183+
def test_other_themes_do_not_receive_project_links(self, project, theme):
184+
project["project"]["urls"] = {
185+
"Repository": "https://github.com/owner/repo",
186+
"Homepage": "https://example.com",
187+
"Documentation": "https://docs.example.com",
188+
}
189+
assert self._options(project, theme=theme) == {}
190+
191+
def test_custom_config_base_and_existing_options(self, project):
192+
project["project"]["urls"] = {"Repository": "https://github.com/owner/repo", "Documentation": "https://docs.example.com"}
193+
project["tool"]["custom"] = project["tool"].pop("yardang")
194+
project["tool"]["custom"]["html-theme-options"] = {"toc_style": "clerk"}
195+
assert self._options(project, config_base="tool.custom") == {
196+
"toc_style": "clerk",
197+
"github_url": "https://github.com/owner/repo",
198+
"nav_links": [{"title": "Docs", "url": "https://docs.example.com"}],
199+
}
200+
201+
def test_inferred_urls_render_project_links(self, project, tmp_path):
202+
project["project"]["urls"] = {
203+
"Repository": "https://github.com/owner/repo",
204+
"Homepage": "https://example.com",
205+
"Documentation": "https://docs.example.com",
206+
}
207+
self._options(project)
208+
output = tmp_path / "html"
209+
with generate_docs_configuration(html_output_dir=str(output)) as config_dir:
210+
result = build_main(["-q", "-b", "html", "-c", str(config_dir), ".", str(output)])
211+
assert result == 0
212+
html = (output / "index.html").read_text()
213+
assert "<title>Test Project" in html
214+
assert 'href="https://github.com/owner/repo"' in html
215+
assert 'href="https://github.com/owner/repo/edit/HEAD/index.md"' in html
216+
assert html.count('href="https://example.com" target="_blank" rel="noopener">Home</a>') == 2
217+
assert html.count('href="https://docs.example.com" target="_blank" rel="noopener">Docs</a>') == 2
218+
219+
88220
class TestResolveCustomAsset:
89221
"""Layered resolution of bundled and user-supplied CSS/JS."""
90222

0 commit comments

Comments
 (0)