diff --git a/.github/workflows/doc-build.yaml b/.github/workflows/doc-build.yaml index b4dd5ddf..5450a1ae 100644 --- a/.github/workflows/doc-build.yaml +++ b/.github/workflows/doc-build.yaml @@ -34,5 +34,5 @@ jobs: cd docs rm -rf doxygen _build py_api doxygen - make html + make multiversion touch _build/html/.nojekyll 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..5bc7422e 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -5,17 +5,46 @@ # 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 +# NOTE: This target should only be run from the main branch to ensure +# the root docs correctly represent "main (dev)". For local development +# on feature branches, use "make html" instead. +# GitHub Actions workflow runs this on main branch only. +multiversion: generate-versions + @cd .. && python3 -m setuptools_scm --force-write-version-files + @export LC_ALL=C.UTF-8; $(SPHINXBUILD) -M html "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + @echo "Building versioned docs from tags..." + @export LC_ALL=C.UTF-8; $(SPHINXMULTIVERSION) "$(SOURCEDIR)" "$(BUILDDIR)/html" $(SPHINXOPTS) $(O) + @mkdir -p $(BUILDDIR)/html/_static + @cp -f _static/versions.js $(BUILDDIR)/html/_static/ 2>/dev/null || true + @cp -f _static/version-selector.js $(BUILDDIR)/html/_static/ 2>/dev/null || true + +# 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..0efc47fe --- /dev/null +++ b/docs/_static/version-selector.js @@ -0,0 +1,211 @@ +// 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' } + ]; + + /** + * Detect the base path for GitHub Pages project sites. + * For project sites, the URL is like /repository-name/v0.8.0/guide.html + * For root sites or local development, the URL is like /v0.8.0/guide.html + * @returns {string} The base path (e.g., '/mscclpp' or '') + */ + function detectBasePath() { + const path = window.location.pathname; + // Match pattern: /base-path/vX.Y.Z/... or /base-path/main/... + // The base path is everything before the version or main directory + const match = path.match(/^(\/[^\/]+)?(?=\/(v\d+\.\d+\.\d+|main)\/)/); + if (match && match[1]) { + return match[1]; + } + // Check if we're at a root that's actually a project site + // Look for common indicators like the repository name in the path + const projectMatch = path.match(/^(\/[^\/]+)(?=\/)/); + if (projectMatch) { + // Verify this isn't a version path at root + const potentialBase = projectMatch[1]; + if (!potentialBase.match(/^\/v\d+\.\d+\.\d+$/) && potentialBase !== '/main') { + // Check if the remaining path contains version info + const remainingPath = path.substring(potentialBase.length); + if (remainingPath.match(/^\/(v\d+\.\d+\.\d+|main)\//)) { + return potentialBase; + } + } + } + return ''; + } + + 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 + // This works for both /v0.8.0/... and /mscclpp/v0.8.0/... + 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 createVersionSelector() { + const currentVersion = detectCurrentVersion(); + const basePath = detectBasePath(); + 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 with base path for GitHub Pages + let url; + const currentPath = window.location.pathname; + + // Extract the page path relative to the version directory + // For /mscclpp/v0.7.0/design/design.html -> design/design.html + // For /v0.7.0/design/design.html -> design/design.html + // For /mscclpp/index.html -> index.html + // For /index.html -> index.html + let relativePath; + + // Remove base path first if present + let pathWithoutBase = currentPath; + if (basePath && currentPath.startsWith(basePath)) { + pathWithoutBase = currentPath.substring(basePath.length); + } + + const versionMatch = pathWithoutBase.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) - remove leading slash + relativePath = pathWithoutBase.substring(1) || 'index.html'; + // Handle /main/ path + const mainMatch = pathWithoutBase.match(/^\/main\/(.*)/); + if (mainMatch) { + relativePath = mainMatch[1] || 'index.html'; + } + } + + if (version.version === 'main' && version.path === '') { + // For main (dev) at root + url = basePath + '/' + relativePath; + } else { + // For versioned releases + url = basePath + '/' + 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; + const targetUrl = baseUrl + currentHash; + + // Calculate fallback URL (version index page) + let fallbackUrl; + const versionMatch = baseUrl.match(/\/(v\d+\.\d+\.\d+)\//); + if (versionMatch) { + fallbackUrl = basePath + '/' + versionMatch[1] + '/index.html'; + } else { + fallbackUrl = basePath + '/index.html'; + } + + // Check if the target page exists using HEAD request + const controller = new AbortController(); + const timeoutId = setTimeout(function() { controller.abort(); }, 2000); + + fetch(baseUrl, { + method: 'HEAD', + signal: controller.signal + }) + .then(function(response) { + clearTimeout(timeoutId); + if (response.ok) { + window.location.href = targetUrl; + } else { + // Page doesn't exist, navigate to version index + window.location.href = fallbackUrl; + } + }) + .catch(function(error) { + clearTimeout(timeoutId); + // On network error or timeout, try fallback first + // This handles cases where the page truly doesn't exist + window.location.href = fallbackUrl; + }); + } + }); + + 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/conf.py b/docs/conf.py index 126f6ee0..fdfb8d66 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -43,8 +43,19 @@ extensions = [ "sphinx.ext.napoleon", "sphinx.ext.intersphinx", "sphinx_autodoc_typehints", + "sphinx_multiversion", ] +# sphinx-multiversion configuration +# Only build tagged versions - main branch is built separately via sphinx-build +# to avoid issues with config loading during ref checkout +smv_tag_whitelist = r"^v\d+\.\d+\.\d+$" +smv_branch_whitelist = r"^$" # Don't build any branches, only tags +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 +88,51 @@ mermaid_init_js = "mermaid.initialize({startOnLoad:true});" html_theme = "sphinx_rtd_theme" html_static_path = ["_static"] + +# Build html_js_files dynamically - only include files that exist +# versions.js is auto-generated by generate_versions.py before build +html_js_files = [] +_static_dir = Path(__file__).parent / "_static" +if (_static_dir / "versions.js").exists(): + html_js_files.append("versions.js") +if (_static_dir / "version-selector.js").exists(): + html_js_files.append("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 + + 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..a57bfb6d --- /dev/null +++ b/docs/generate_versions.py @@ -0,0 +1,94 @@ +# 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. + + Filters out versions before v0.4.0 as they don't have compatible docs structure + for sphinx-multiversion. + """ + 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+$") + valid_tags = [] + for tag in tags: + if tag and version_pattern.match(tag): + # Filter out versions before v0.4.0 (no compatible docs structure) + match = re.match(r"v(\d+)\.(\d+)\.(\d+)", tag) + if match: + major, minor, patch = int(match.group(1)), int(match.group(2)), int(match.group(3)) + # Include v0.4.0 and later + if major > 0 or (major == 0 and minor >= 4): + valid_tags.append(tag) + return valid_tags + 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