From 69d3b79ecdd614aa12db5dba636fe6294d150237 Mon Sep 17 00:00:00 2001 From: Qinghua Zhou Date: Sat, 24 Jan 2026 01:45:41 +0800 Subject: [PATCH] Support versioning for mscclpp document (#724) Show all the versions of mscclpp document on the webpage https://microsoft.github.io/mscclpp/ Add sphinx-multiversion to generate documents for different versions. Add version selector on document webpage. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Binyang Li --- .github/workflows/gh-pages.yml | 3 +- .gitignore | 3 +- docs/Makefile | 27 ++++- docs/_static/version-selector.js | 178 +++++++++++++++++++++++++++++++ docs/_templates/layout.html | 24 +++++ docs/conf.py | 52 +++++++++ docs/generate_versions.py | 80 ++++++++++++++ docs/requirements.txt | 1 + 8 files changed, 363 insertions(+), 5 deletions(-) create mode 100644 docs/_static/version-selector.js create mode 100644 docs/_templates/layout.html create mode 100644 docs/generate_versions.py diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index ed01b4af..afbd38a2 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -43,7 +43,8 @@ jobs: cd docs rm -rf doxygen _build py_api doxygen - make html + # Use multiversion target to build all versions + make multiversion touch _build/html/.nojekyll - name: Upload artifacts uses: actions/upload-pages-artifact@v3 diff --git a/.gitignore b/.gitignore index 81cdc6ef..9c4da143 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ __pycache__ .*.swp .idea/ *.so -_codeql_detected_source_root +docs/_static/versions.js +_codeql_detected_source_root \ No newline at end of file diff --git a/docs/Makefile b/docs/Makefile index c1fc7365..285bb7c1 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -5,17 +5,38 @@ # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build +SPHINXMULTIVERSION ?= sphinx-multiversion SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: + @echo "Usage:" + @echo " make html - Build single-version HTML (fast, for development)" + @echo " make multiversion - Build all versions with sphinx-multiversion" + @echo " make clean - Remove build directory" + @echo "" @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -.PHONY: help Makefile +.PHONY: help Makefile generate-versions multiversion clean + +# Generate versions.js from git tags before building +generate-versions: + @python3 generate_versions.py + +# Build all documentation versions using sphinx-multiversion +# Use this for production builds or to test version switching +multiversion: generate-versions + @cd .. && python3 -m setuptools_scm --force-write-version-files + @export LC_ALL=C.UTF-8; $(SPHINXMULTIVERSION) "$(SOURCEDIR)" "$(BUILDDIR)/html" $(SPHINXOPTS) $(O) + +# Clean build directory +clean: + @rm -rf $(BUILDDIR) # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile +# This builds single-version only (fast for development). +%: Makefile generate-versions @cd .. && python3 -m setuptools_scm --force-write-version-files - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + @export LC_ALL=C.UTF-8; $(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_static/version-selector.js b/docs/_static/version-selector.js new file mode 100644 index 00000000..84260920 --- /dev/null +++ b/docs/_static/version-selector.js @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Version selector for sphinx-multiversion documentation. + * + * The DEFINED_VERSIONS array is auto-generated from git tags by generate_versions.py + * which runs automatically during 'make html'. This ensures the version list stays + * in sync with sphinx-multiversion without manual updates. + * + * The versions.js file (loaded before this script) defines DEFINED_VERSIONS. + */ +(function() { + 'use strict'; + + // DEFINED_VERSIONS is defined in versions.js (auto-generated from git tags) + // Fallback to main only if versions.js failed to load + const versions = (typeof DEFINED_VERSIONS !== 'undefined') ? DEFINED_VERSIONS : [ + { name: 'main (dev)', path: '', version: 'main' } + ]; + + function detectCurrentVersion() { + const path = window.location.pathname; + // Check for version tags first + // Match version tags in the format v0.0.0 within the URL path + const match = path.match(/\/(v\d+\.\d+\.\d+)\//); + if (match) { + return match[1]; + } + // Check for main branch directory + if (path.includes('/main/')) { + return 'main'; + } + // If at root (no version in path), it's main + return 'main'; + } + + function getBasePath() { + const path = window.location.pathname; + // Find how many levels deep we are from the version directory + if (match) { + const depth = match[2].split('/').filter(p => p && p !== 'index.html').length; + return '../'.repeat(depth + 1); + } + // For root level (latest), calculate depth + const segments = path.split('/').filter(p => p && p !== 'index.html'); + return '../'.repeat(segments.length); + } + + function createVersionSelector() { + const currentVersion = detectCurrentVersion(); + const searchDiv = document.querySelector('.wy-side-nav-search'); + + if (!searchDiv) return; + + // Find the title link (mscclpp) + const titleLink = searchDiv.querySelector('a.icon-home'); + + // Create version selector container + const selectorDiv = document.createElement('div'); + selectorDiv.style.padding = '10px'; + selectorDiv.style.paddingTop = '5px'; + selectorDiv.style.paddingBottom = '10px'; + + const select = document.createElement('select'); + select.id = 'version-selector'; + select.style.width = '100%'; + select.style.padding = '5px'; + select.style.backgroundColor = '#2c2c2c'; + select.style.color = '#ffffff'; + select.style.border = '1px solid #404040'; + select.style.borderRadius = '3px'; + + // Add options + versions.forEach(function(version) { + const option = document.createElement('option'); + const isSelected = currentVersion === version.version; + + // Build the URL - use absolute paths from root (without hash) + let url; + const currentPath = window.location.pathname; + + // Extract the page path relative to the version directory + // For /v0.7.0/design/design.html -> design/design.html + // For /index.html -> index.html + let relativePath; + const versionMatch = currentPath.match(/^\/(v\d+\.\d+\.\d+)\/(.*)/); + if (versionMatch) { + // We're in a versioned directory + relativePath = versionMatch[2] || 'index.html'; + } else { + // We're at root (main/dev) + relativePath = currentPath.substring(1) || 'index.html'; + } + + if (version.version === 'main' && version.path === '') { + // For main (dev) at root + url = '/' + relativePath; + } else { + // For versioned releases + url = '/' + version.path + '/' + relativePath; + } + + option.value = url; + option.textContent = version.name; + if (isSelected) { + option.selected = true; + } + select.appendChild(option); + }); + + select.addEventListener('change', function() { + if (this.value) { + const baseUrl = this.value; + const currentHash = window.location.hash; // Get current hash at selection time + const targetUrl = baseUrl + currentHash; // Append current hash to target URL + + // Check if the target page exists using a fetch with abort + const controller = new AbortController(); + const timeoutId = setTimeout(function() { controller.abort(); }, 1000); + + fetch(baseUrl, { + method: 'GET', + signal: controller.signal + }) + .then(function(response) { + clearTimeout(timeoutId); + if (response.ok) { + // Page exists, navigate to it with hash + window.location.href = targetUrl; + } else { + // Page doesn't exist, fall back to version root index.html + // For versioned paths like /v0.8.0/... -> /v0.8.0/index.html + // For root paths like /py_api/... -> /index.html + let fallbackUrl; + const versionMatch = baseUrl.match(/^\/(v\d+\.\d+\.\d+)\//); + if (versionMatch) { + // It's a versioned path + fallbackUrl = '/' + versionMatch[1] + '/index.html'; + } else { + // It's a root path (main/dev) + fallbackUrl = '/index.html'; + } + window.location.href = fallbackUrl; + } + }) + .catch(function(error) { + clearTimeout(timeoutId); + // On error (including timeout), try to navigate anyway + window.location.href = targetUrl; + }); + } + }); + + selectorDiv.appendChild(select); + + // Insert after the title link in the searchDiv + if (titleLink) { + // Insert after the title link element + const nextElement = titleLink.nextSibling; + if (nextElement) { + searchDiv.insertBefore(selectorDiv, nextElement); + } else { + searchDiv.appendChild(selectorDiv); + } + } else { + // Fallback: insert at the beginning of searchDiv + searchDiv.insertBefore(selectorDiv, searchDiv.firstChild); + } + } + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', createVersionSelector); + } else { + createVersionSelector(); + } +})(); diff --git a/docs/_templates/layout.html b/docs/_templates/layout.html new file mode 100644 index 00000000..56b9326b --- /dev/null +++ b/docs/_templates/layout.html @@ -0,0 +1,24 @@ +{% extends "!layout.html" %} + +{%- block sidebarsearch %} + {{ super() }} + + {# Version selector #} + {% if versions %} +
+ + +
+ {% endif %} +{%- endblock %} diff --git a/docs/conf.py b/docs/conf.py index 126f6ee0..e1acc39a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -43,8 +43,16 @@ extensions = [ "sphinx.ext.napoleon", "sphinx.ext.intersphinx", "sphinx_autodoc_typehints", + "sphinx_multiversion", ] +smv_tag_whitelist = r"^v\d+\.\d+\.\d+$" +smv_branch_whitelist = r"^main$" +smv_remote_whitelist = None +smv_released_pattern = r"^tags/.*$" +smv_outputdir_format = "{ref.name}" +smv_prefer_remote_refs = False + autosummary_generate = True autodoc_default_options = { "members": True, @@ -77,3 +85,47 @@ mermaid_init_js = "mermaid.initialize({startOnLoad:true});" html_theme = "sphinx_rtd_theme" html_static_path = ["_static"] +html_js_files = [ + "versions.js", # Auto-generated from git tags - must load before version-selector.js + "version-selector.js", +] + + +def setup(app): + """Set up custom Sphinx build hooks for sphinx-multiversion support. + + This function registers a build-finished event handler that copies the + version selector JavaScript files to a shared location accessible by all + versioned documentation builds. + + Args: + app: The Sphinx application instance. + """ + import shutil + from pathlib import Path + + def copy_version_files(app, exception): + """Copy version JS files to the root build directory after a successful build. + + When using sphinx-multiversion, each version's documentation is built into + its own subdirectory (e.g., _build/html/v0.8.0/). The version selector + JavaScript files need to be available at the root _static directory + (_build/html/_static/) so they can be shared across all versions and + properly navigate between different documentation versions. + + Args: + app: The Sphinx application instance. + exception: Exception raised during build, or None if build succeeded. + """ + if exception is None: # Only copy if build succeeded + source_static = Path(app.srcdir) / "_static" + dest_root = Path(app.outdir).parent / "_static" + dest_root.mkdir(parents=True, exist_ok=True) + + # Copy both versions.js and version-selector.js + for filename in ["versions.js", "version-selector.js"]: + source = source_static / filename + if source.exists(): + shutil.copy2(source, dest_root / filename) + + app.connect("build-finished", copy_version_files) diff --git a/docs/generate_versions.py b/docs/generate_versions.py new file mode 100644 index 00000000..7be7f8b9 --- /dev/null +++ b/docs/generate_versions.py @@ -0,0 +1,80 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Generate versions.js from git tags for the documentation version selector. + +This script reads git tags matching the sphinx-multiversion pattern (vX.Y.Z) +and generates a JavaScript file containing the version list. This ensures the +version selector stays in sync with available documentation versions without +requiring manual updates. + +Usage: + python generate_versions.py + +The script should be run before building documentation with sphinx-multiversion. +""" + +import json +import re +import subprocess +from pathlib import Path + + +def get_git_tags(): + """Get all version tags from git matching vX.Y.Z pattern.""" + try: + result = subprocess.run( + ["git", "tag", "-l", "v*.*.*"], + capture_output=True, + text=True, + check=True, + ) + tags = result.stdout.strip().split("\n") + # Filter to match sphinx-multiversion pattern: ^v\d+\.\d+\.\d+$ + version_pattern = re.compile(r"^v\d+\.\d+\.\d+$") + return [tag for tag in tags if tag and version_pattern.match(tag)] + except subprocess.CalledProcessError: + return [] + + +def version_sort_key(version): + """Extract (major, minor, patch) tuple for sorting.""" + match = re.match(r"v(\d+)\.(\d+)\.(\d+)", version) + if match: + return (int(match.group(1)), int(match.group(2)), int(match.group(3))) + return (0, 0, 0) + + +def generate_versions_js(output_path): + """Generate versions.js file from git tags.""" + tags = get_git_tags() + + # Sort versions in descending order (newest first) + tags.sort(key=version_sort_key, reverse=True) + + # Build the version list with main (dev) first + version_list = [{"name": "main (dev)", "path": "", "version": "main"}] + + for i, version in enumerate(tags): + name = f"{version} (latest)" if i == 0 else version + version_list.append({"name": name, "path": version, "version": version}) + + # Generate JavaScript content + js_content = f"""\ +// Auto-generated from git tags by generate_versions.py - do not edit manually +// Run 'python generate_versions.py' or 'make html' to regenerate +const DEFINED_VERSIONS = {json.dumps(version_list, indent=4)}; +""" + + # Write to output path + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(js_content) + print(f"Generated {output_path} with {len(version_list)} versions") + + +if __name__ == "__main__": + # Generate versions.js in _static directory + script_dir = Path(__file__).parent + output_file = script_dir / "_static" / "versions.js" + generate_versions_js(output_file) diff --git a/docs/requirements.txt b/docs/requirements.txt index 3e2233f3..a74d79e6 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -5,4 +5,5 @@ pybind11 sphinx_rtd_theme sphinxcontrib-mermaid sphinx-autodoc-typehints +sphinx-multiversion==0.2.4 setuptools_scm