Skip to content

Commit f217b4e

Browse files
committed
doc: Add version switcher
1 parent 438f643 commit f217b4e

File tree

6 files changed

+289
-6
lines changed

6 files changed

+289
-6
lines changed

docs/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,18 @@ BUILD_CPP_DOCS=1 BUILD_RUST_DOCS=1 uv run --group docs sphinx-autobuild docs doc
9595
BUILD_CPP_DOCS=1 BUILD_RUST_DOCS=1 uv run --group docs sphinx-build -M html docs docs/_build
9696
```
9797

98+
### Multi-version build (main + tags)
99+
100+
Build the documentation for `main` and tags matching `vX.Y.Z` (for example `v0.1.0`, `v0.1.5`). `sphinx-multiversion` builds from git archives, so the helper script sets a pretend version and regenerates the switcher JSON automatically:
101+
102+
```bash
103+
BUILD_CPP_DOCS=1 BUILD_RUST_DOCS=1 BASE_URL="/" uv run --group docs bash docs/build_multiversion.sh
104+
```
105+
106+
If the site is hosted under a subpath (for example `https://tvm.apache.org/ffi/`), set `BASE_URL="/ffi"` in that invocation to keep the switcher JSON and root redirect pointing at the correct prefix.
107+
108+
The JSON (`_static/versions.json`) is read by the book theme’s version switcher across every built version; the root `index.html` redirects to `main/`. The script handles metadata emission, switcher generation, and the final multi-version build.
109+
98110
## Cleanup
99111

100112
Remove generated artifacts when they are no longer needed:

docs/_static/versions.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[
2+
{
3+
"name": "main",
4+
"version": "main",
5+
"url": "/main/",
6+
"preferred": true
7+
}
8+
]

docs/build_multiversion.sh

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
#!/usr/bin/env bash
2+
# Licensed to the Apache Software Foundation (ASF) under one
3+
# or more contributor license agreements. See the NOTICE file
4+
# distributed with this work for additional information
5+
# regarding copyright ownership. The ASF licenses this file
6+
# to you under the Apache License, Version 2.0 (the
7+
# "License"); you may not use this file except in compliance
8+
# with the License. You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing,
13+
# software distributed under the License is distributed on an
14+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
# KIND, either express or implied. See the License for the
16+
# specific language governing permissions and limitations
17+
# under the License.
18+
19+
set -euo pipefail
20+
21+
export SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0
22+
export BASE_URL="$BASE_URL"
23+
24+
HTML_PATH=docs/_build/html
25+
mkdir -p "$HTML_PATH/_static/"
26+
27+
sphinx-multiversion docs "$HTML_PATH" --dump-metadata >"$HTML_PATH/_static/versions_metadata.json"
28+
python docs/tools/write_versions_json.py --base-url "$BASE_URL" "$HTML_PATH"
29+
sphinx-multiversion docs "$HTML_PATH"

docs/conf.py

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@
3434

3535
build_exhale = os.environ.get("BUILD_CPP_DOCS", "0") == "1"
3636
build_rust_docs = os.environ.get("BUILD_RUST_DOCS", "0") == "1"
37+
build_multiversion = (
38+
os.environ.get("SPHINX_MULTIVERSION_NAME", None) is not None
39+
or os.environ.get("SPHINX_MULTIVERSION_VERSION", None) is not None
40+
)
3741

3842
# Auto-detect sphinx-autobuild: Check if sphinx-autobuild is in the execution path
3943
is_autobuild = any("sphinx-autobuild" in str(arg) for arg in sys.argv)
@@ -44,15 +48,33 @@
4448

4549
# -- General configuration ------------------------------------------------
4650
# Determine version without reading pyproject.toml
47-
# Always use setuptools_scm (assumed available in docs env)
48-
__version__ = setuptools_scm.get_version(root="..")
51+
# sphinx-multiversion builds from git archives (no .git), so allow a fallback
52+
# using the version name that the extension injects into the environment.
4953

50-
project = "tvm-ffi"
5154

52-
author = "Apache TVM FFI contributors"
55+
def _get_version() -> str:
56+
env_version = (
57+
os.environ.get("SPHINX_MULTIVERSION_NAME")
58+
or os.environ.get("SPHINX_MULTIVERSION_VERSION")
59+
or os.environ.get("READTHEDOCS_VERSION")
60+
)
61+
if env_version:
62+
return env_version
63+
64+
try:
65+
return setuptools_scm.get_version(root="..", fallback_version="0.0.0")
66+
except Exception:
67+
return "0.0.0"
68+
69+
70+
__version__ = _get_version()
5371

72+
project = "tvm-ffi"
73+
author = "Apache TVM FFI contributors"
5474
version = __version__
5575
release = __version__
76+
_github_ref = os.environ.get("SPHINX_MULTIVERSION_NAME", "main")
77+
_base_url = ("/" + os.environ.get("BASE_URL", "").strip("/") + "/").replace("//", "/")
5678

5779
# -- Extensions and extension configurations --------------------------------
5880

@@ -79,6 +101,8 @@
79101
"sphinxcontrib.mermaid",
80102
]
81103

104+
if build_multiversion:
105+
extensions.append("sphinx_multiversion")
82106
if build_exhale:
83107
extensions.append("exhale")
84108

@@ -189,6 +213,7 @@ def _build_rust_docs() -> None:
189213
if not build_rust_docs:
190214
return
191215

216+
(_DOCS_DIR / "reference" / "rust" / "generated").mkdir(parents=True, exist_ok=True)
192217
print("Building Rust documentation...")
193218
try:
194219
target_doc = _RUST_DIR / "target" / "doc"
@@ -214,10 +239,32 @@ def _build_rust_docs() -> None:
214239
print("Warning: cargo not found, skipping Rust documentation build")
215240

216241

217-
def _apply_config_overrides(_: object, config: object) -> None:
242+
def _override_version(app_or_config: sphinx.application.Sphinx | sphinx.config.Config) -> None:
243+
global __version__, version, release # noqa: PLW0603
244+
if not build_multiversion:
245+
return
246+
if isinstance(app_or_config, sphinx.application.Sphinx):
247+
config = app_or_config.config
248+
elif isinstance(app_or_config, sphinx.config.Config):
249+
config = app_or_config
250+
else:
251+
raise TypeError(f"Expected Sphinx app or config object, got {type(app_or_config)=}")
252+
smv_ver = getattr(config, "smv_current_version", None)
253+
if smv_ver:
254+
config.version = version = smv_ver
255+
config.release = release = smv_ver
256+
__version__ = smv_ver
257+
258+
259+
def _apply_config_overrides(app: sphinx.application.Sphinx, config: sphinx.config.Config) -> None:
218260
"""Apply runtime configuration overrides derived from environment variables."""
219261
config.build_exhale = build_exhale
220262
config.build_rust_docs = build_rust_docs
263+
if build_exhale:
264+
config.exhale_args["containmentFolder"] = str(
265+
Path(app.srcdir) / "reference" / "cpp" / "generated"
266+
)
267+
_override_version(config)
221268

222269

223270
def _copy_rust_docs_to_output(app: sphinx.application.Sphinx, exception: Exception | None) -> None:
@@ -245,6 +292,7 @@ def setup(app: sphinx.application.Sphinx) -> None:
245292
_build_rust_docs()
246293
app.add_config_value("build_exhale", build_exhale, "env")
247294
app.add_config_value("build_rust_docs", build_rust_docs, "env")
295+
app.connect("builder-inited", _override_version)
248296
app.connect("config-inited", _apply_config_overrides)
249297
app.connect("build-finished", _copy_rust_docs_to_output)
250298
app.connect("autodoc-skip-member", _filter_inherited_members)
@@ -450,11 +498,22 @@ def footer_html() -> str:
450498
"show_toc_level": 2,
451499
"extra_footer": footer_html(),
452500
}
501+
if build_multiversion:
502+
html_theme_options.update(
503+
{
504+
"navbar_end": ["version-switcher", "navbar-icon-links"],
505+
"switcher": {
506+
"json_url": f"{_base_url}_static/versions.json",
507+
"version_match": version,
508+
},
509+
"show_version_warning_banner": True,
510+
}
511+
)
453512

454513
html_context = {
455514
"display_github": True,
456515
"github_user": "apache",
457-
"github_version": "main",
516+
"github_version": _github_ref,
458517
"conf_py_path": "/docs/",
459518
}
460519

@@ -465,3 +524,10 @@ def footer_html() -> str:
465524

466525

467526
html_css_files = ["custom.css"]
527+
528+
# sphinx-multiversion configuration
529+
smv_tag_whitelist = r"^v\d+(?:\.\d+){0,2}(?:[-\.]?(?:rc|post)\d*)?$"
530+
smv_branch_whitelist = r"^main$"
531+
smv_remote_whitelist = r"^(origin|upstream)$"
532+
smv_released_pattern = r"^refs/tags/v\d+(?:\.\d+){0,2}(?:[-\.]?(?:rc|post)\d*)?$"
533+
smv_latest_version = "main"

docs/tools/write_versions_json.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
"""Convert sphinx-multiversion metadata into the version switcher JSON.
19+
20+
Usage:
21+
python docs/tools/write_versions_json.py <html_root> [--base-url /]
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import argparse
27+
import json
28+
from datetime import datetime
29+
from pathlib import Path
30+
31+
from packaging import version as pkg_version
32+
33+
ROOT_VERSION = "main"
34+
DEFAULT_BASE_URL = "/"
35+
METADATA_NAME = "versions_metadata.json"
36+
37+
38+
def _parse_creatordate(raw: str) -> datetime:
39+
try:
40+
return datetime.strptime(raw, "%Y-%m-%d %H:%M:%S %z")
41+
except Exception:
42+
return datetime.min.replace(tzinfo=None)
43+
44+
45+
def _load_versions(metadata_path: Path) -> list[dict[str, object]]:
46+
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
47+
versions = []
48+
for name, entry in metadata.items():
49+
version_label = entry.get("version") or name
50+
if version_label in {"0.0.0", "0+unknown"}:
51+
version_label = name
52+
versions.append(
53+
{
54+
"name": name,
55+
"version": version_label,
56+
"is_released": bool(entry.get("is_released")),
57+
"creatordate": _parse_creatordate(entry.get("creatordate", "")),
58+
}
59+
)
60+
return versions
61+
62+
63+
def _pick_preferred(versions: list[dict[str, object]], latest: str) -> str:
64+
non_main = [v for v in versions if v["name"] != "main"]
65+
released = [v for v in non_main if v["is_released"]]
66+
if released:
67+
return max(released, key=lambda v: v["creatordate"])["name"]
68+
if non_main:
69+
return max(non_main, key=lambda v: v["creatordate"])["name"]
70+
return latest
71+
72+
73+
def _to_switcher(
74+
versions: list[dict[str, object]], preferred_name: str, base_url: str
75+
) -> list[dict[str, object]]:
76+
base = base_url.rstrip("/")
77+
main_entry: dict[str, object] | None = None
78+
tag_entries: list[dict[str, object]] = []
79+
80+
for v in versions:
81+
entry: dict[str, object] = {
82+
"name": v["name"],
83+
"version": v["version"],
84+
"url": f"{base}/{v['name']}/" if base else f"/{v['name']}/",
85+
}
86+
if v["name"] == "main":
87+
main_entry = entry
88+
else:
89+
tag_entries.append(entry)
90+
91+
def _sort_key(entry: dict[str, object]) -> pkg_version.Version:
92+
name = str(entry["name"])
93+
label = name[1:] if name.startswith("v") else name
94+
try:
95+
return pkg_version.parse(label)
96+
except Exception:
97+
return pkg_version.parse("0")
98+
99+
tag_entries.sort(key=_sort_key, reverse=True)
100+
101+
ordered = tag_entries
102+
if main_entry:
103+
ordered.append(main_entry)
104+
105+
for entry in ordered:
106+
if entry["name"] == preferred_name:
107+
entry["preferred"] = True
108+
return ordered
109+
110+
111+
def _write_root_index(html_root: Path, target_version: str, base_url: str) -> None:
112+
base = base_url.rstrip("/") or "/"
113+
target = f"{base}/{target_version}/" if base != "/" else f"/{target_version}/"
114+
html_root.mkdir(parents=True, exist_ok=True)
115+
index_path = html_root / "index.html"
116+
index_path.write_text(
117+
"\n".join(
118+
[
119+
"<!DOCTYPE html>",
120+
'<meta charset="utf-8" />',
121+
"<title>tvm-ffi docs</title>",
122+
f'<meta http-equiv="refresh" content="0; url={target}" />',
123+
"<script>",
124+
f"location.replace('{target}');",
125+
"</script>",
126+
f'<p>Redirecting to <a href="{target}">{target}</a>.</p>',
127+
]
128+
),
129+
encoding="utf-8",
130+
)
131+
print(f"Wrote root index redirect to {target}")
132+
133+
134+
def main() -> int:
135+
"""Entrypoint."""
136+
parser = argparse.ArgumentParser()
137+
parser.add_argument(
138+
"html_root",
139+
type=Path,
140+
help="Root of the built HTML output (expects _static/versions_metadata.json inside)",
141+
)
142+
parser.add_argument(
143+
"--base-url",
144+
default=DEFAULT_BASE_URL,
145+
help="Base URL prefix (leading slash, no trailing slash) for version links, e.g. '/' or '/ffi'",
146+
)
147+
args = parser.parse_args()
148+
149+
html_root = args.html_root
150+
metadata_path = html_root / "_static" / METADATA_NAME
151+
metadata_path.parent.mkdir(parents=True, exist_ok=True)
152+
153+
versions = _load_versions(metadata_path)
154+
preferred_name = _pick_preferred(versions, ROOT_VERSION)
155+
output = _to_switcher(versions, preferred_name, args.base_url)
156+
157+
out_path = html_root / "_static" / "versions.json"
158+
out_path.parent.mkdir(parents=True, exist_ok=True)
159+
out_path.write_text(json.dumps(output, indent=2), encoding="utf-8")
160+
print(f"Wrote version switcher data for {len(output)} entries to {out_path}")
161+
162+
_write_root_index(html_root, preferred_name, args.base_url)
163+
return 0
164+
165+
166+
if __name__ == "__main__":
167+
raise SystemExit(main())

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ docs = [
8383
"sphinx",
8484
"sphinx-autobuild",
8585
"sphinx-book-theme",
86+
"sphinx-multiversion",
8687
"sphinx-copybutton",
8788
"sphinx-design",
8889
"sphinx-reredirects",

0 commit comments

Comments
 (0)