Skip to content

Commit e544e39

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

File tree

6 files changed

+258
-6
lines changed

6 files changed

+258
-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: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,33 @@
4444

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

50-
project = "tvm-ffi"
5150

52-
author = "Apache TVM FFI contributors"
51+
def _get_version() -> str:
52+
env_version = (
53+
os.environ.get("SPHINX_MULTIVERSION_NAME")
54+
or os.environ.get("SPHINX_MULTIVERSION_VERSION")
55+
or os.environ.get("READTHEDOCS_VERSION")
56+
)
57+
if env_version:
58+
return env_version
59+
60+
try:
61+
return setuptools_scm.get_version(root="..", fallback_version="0.0.0")
62+
except Exception:
63+
return "0.0.0"
5364

65+
66+
__version__ = _get_version()
67+
68+
project = "tvm-ffi"
69+
author = "Apache TVM FFI contributors"
5470
version = __version__
5571
release = __version__
72+
_github_ref = os.environ.get("SPHINX_MULTIVERSION_NAME", "main")
73+
_base_url = ("/" + os.environ.get("BASE_URL", "").strip("/") + "/").replace("//", "/")
5674

5775
# -- Extensions and extension configurations --------------------------------
5876

@@ -77,6 +95,7 @@
7795
"sphinx_toolbox.collapse",
7896
"sphinxcontrib.httpdomain",
7997
"sphinxcontrib.mermaid",
98+
"sphinx_multiversion",
8099
]
81100

82101
if build_exhale:
@@ -189,6 +208,7 @@ def _build_rust_docs() -> None:
189208
if not build_rust_docs:
190209
return
191210

211+
(_DOCS_DIR / "reference" / "rust" / "generated").mkdir(parents=True, exist_ok=True)
192212
print("Building Rust documentation...")
193213
try:
194214
target_doc = _RUST_DIR / "target" / "doc"
@@ -214,10 +234,14 @@ def _build_rust_docs() -> None:
214234
print("Warning: cargo not found, skipping Rust documentation build")
215235

216236

217-
def _apply_config_overrides(_: object, config: object) -> None:
237+
def _apply_config_overrides(app: sphinx.application.Sphinx, config: object) -> None:
218238
"""Apply runtime configuration overrides derived from environment variables."""
219239
config.build_exhale = build_exhale
220240
config.build_rust_docs = build_rust_docs
241+
if build_exhale:
242+
config.exhale_args["containmentFolder"] = str(
243+
Path(app.srcdir) / "reference" / "cpp" / "generated"
244+
)
221245

222246

223247
def _copy_rust_docs_to_output(app: sphinx.application.Sphinx, exception: Exception | None) -> None:
@@ -449,12 +473,17 @@ def footer_html() -> str:
449473
"use_repository_button": True,
450474
"show_toc_level": 2,
451475
"extra_footer": footer_html(),
476+
"navbar_end": ["version-switcher", "navbar-icon-links"],
477+
"switcher": {
478+
"json_url": f"{_base_url}_static/versions.json",
479+
"version_match": version,
480+
},
452481
}
453482

454483
html_context = {
455484
"display_github": True,
456485
"github_user": "apache",
457-
"github_version": "main",
486+
"github_version": _github_ref,
458487
"conf_py_path": "/docs/",
459488
}
460489

@@ -465,3 +494,10 @@ def footer_html() -> str:
465494

466495

467496
html_css_files = ["custom.css"]
497+
498+
# sphinx-multiversion configuration
499+
smv_tag_whitelist = r"^v\d+(?:\.\d+){0,2}(?:[-\.]?(?:rc|post)\d*)?$"
500+
smv_branch_whitelist = r"^main$"
501+
smv_remote_whitelist = r"^(origin|upstream)$"
502+
smv_released_pattern = r"^refs/tags/v\d+(?:\.\d+){0,2}(?:[-\.]?(?:rc|post)\d*)?$"
503+
smv_latest_version = "main"

docs/tools/write_versions_json.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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+
released = [v for v in versions if v["is_released"]]
65+
if released:
66+
return max(released, key=lambda v: v["creatordate"])["name"]
67+
for v in versions:
68+
if v["name"] == latest:
69+
return latest
70+
return versions[0]["name"]
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 = {
82+
"name": v["name"],
83+
"version": v["version"],
84+
"url": f"{base}/{v['name']}/" if base else f"/{v['name']}/",
85+
"preferred": v["name"] == preferred_name,
86+
}
87+
if v["name"] == "main":
88+
main_entry = entry
89+
else:
90+
tag_entries.append(entry)
91+
92+
def _sort_key(entry: dict[str, object]) -> pkg_version.Version:
93+
name = str(entry["name"])
94+
label = name[1:] if name.startswith("v") else name
95+
try:
96+
return pkg_version.parse(label)
97+
except Exception:
98+
return pkg_version.parse("0")
99+
100+
tag_entries.sort(key=_sort_key, reverse=True)
101+
102+
ordered = []
103+
if main_entry:
104+
ordered.append(main_entry)
105+
ordered.extend(tag_entries)
106+
return ordered
107+
108+
109+
def _write_root_index(html_root: Path, target_version: str, base_url: str) -> str:
110+
base = base_url.rstrip("/") or "/"
111+
target = f"{base}/{target_version}/" if base != "/" else f"/{target_version}/"
112+
html_root.mkdir(parents=True, exist_ok=True)
113+
index_path = html_root / "index.html"
114+
index_path.write_text(
115+
"\n".join(
116+
[
117+
"<!DOCTYPE html>",
118+
'<meta charset="utf-8" />',
119+
"<title>tvm-ffi docs</title>",
120+
f'<meta http-equiv="refresh" content="0; url={target}" />',
121+
"<script>",
122+
f"location.replace('{target}');",
123+
"</script>",
124+
f'<p>Redirecting to <a href="{target}">{target}</a>.</p>',
125+
]
126+
),
127+
encoding="utf-8",
128+
)
129+
return target
130+
131+
132+
def main() -> int:
133+
"""Entrypoint."""
134+
parser = argparse.ArgumentParser()
135+
parser.add_argument(
136+
"html_root",
137+
type=Path,
138+
help="Root of the built HTML output (expects _static/versions_metadata.json inside)",
139+
)
140+
parser.add_argument(
141+
"--base-url",
142+
default=DEFAULT_BASE_URL,
143+
help="Base URL prefix (leading slash, no trailing slash) for version links, e.g. '/' or '/ffi'",
144+
)
145+
args = parser.parse_args()
146+
147+
html_root = args.html_root
148+
metadata_path = html_root / "_static" / METADATA_NAME
149+
metadata_path.parent.mkdir(parents=True, exist_ok=True)
150+
151+
versions = _load_versions(metadata_path)
152+
preferred_name = _pick_preferred(versions, ROOT_VERSION)
153+
output = _to_switcher(versions, preferred_name, args.base_url)
154+
155+
out_path = html_root / "_static" / "versions.json"
156+
out_path.parent.mkdir(parents=True, exist_ok=True)
157+
out_path.write_text(json.dumps(output, indent=2), encoding="utf-8")
158+
print(f"Wrote version switcher data for {len(output)} entries to {out_path}")
159+
160+
target = _write_root_index(html_root, ROOT_VERSION, args.base_url)
161+
print(f"Wrote root index redirect to {target}")
162+
return 0
163+
164+
165+
if __name__ == "__main__":
166+
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)