# Copyright 2025โ2026 European Union
# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
# SPDX-License-Identifier: EUPL-1.2
"""
Version management helpers for MAFw versioned documentation.
This module provides functions for writing ``versions.json``, creating
redirect pages, mirroring version directories, pruning old versions,
and generating landing pages.
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
from mafw.devtools.documentation.builder import parse_version_tuple
[docs]
def write_versions_json(outdir: Path, versions: list[dict[str, str]]) -> None:
"""
Write versions information to a JSON file.
:param outdir: Output directory for the JSON file
:type outdir: Path
:param versions: List of version information dictionaries
:type versions: list[dict[str, str]]
"""
p = outdir / 'versions.json'
with open(p, 'w', encoding='utf-8') as f:
json.dump(versions, f, indent=2)
print(f'๐งพ Wrote versions.json to {p}')
for v in versions:
if v['label'] == 'alias':
sub = v['version']
else:
sub = v['path']
shutil.copy(p, outdir / sub)
shutil.copy(p, outdir / sub / 'generated')
[docs]
def mirror_version(outdir: Path, src_tag: str, target_tag: str, use_symlink: bool = True) -> None:
"""
Mirror a version directory from one tag to another.
Can use symlinks for efficiency or copy for compatibility.
:param outdir: Output directory containing version directories
:type outdir: Path
:param src_tag: Source tag directory name
:type src_tag: str
:param target_tag: Target tag directory name
:type target_tag: str
:param use_symlink: Whether to use symlink instead of copying, defaults to True
:type use_symlink: bool
"""
src = outdir / src_tag
dst = outdir / target_tag
# Remove existing destination if it exists
if dst.exists() or dst.is_symlink():
if dst.is_symlink():
dst.unlink()
else:
shutil.rmtree(dst)
if use_symlink:
print(f'๐ Symlinking {target_tag} -> {src_tag}')
# Create relative symlink
dst.symlink_to(src_tag, target_is_directory=True)
else:
print(f'๐ช Mirroring {src_tag} to {target_tag}')
dst.mkdir(parents=True, exist_ok=True)
shutil.copytree(src, dst, dirs_exist_ok=True)
[docs]
def write_redirect_page(outdir: Path, name: str, target_tag: str) -> None:
"""
Create a redirect page for a version alias.
:param outdir: Output directory for the redirect page
:type outdir: Path
:param name: Name of the redirect alias (e.g., 'stable', 'dev')
:type name: str
:param target_tag: Tag that the redirect should point to
:type target_tag: str
"""
d = outdir / name
d.mkdir(parents=True, exist_ok=True)
target = f'../{target_tag}/index.html' # relative path from stable/index.html to tag/index
html = f"""<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="0; url={target}">
<link rel="canonical" href="{target}">
<title>Redirecting to {target_tag}</title>
</head>
<body>
<p>Redirecting to <a href="{target}">{target}</a></p>
</body>
</html>
"""
with open(d / 'index.html', 'w', encoding='utf-8') as f:
f.write(html)
print(f'๐งพ Wrote redirect page {d / "index.html"} -> {target}')
[docs]
def write_legacy_redirect_page(outdir: Path) -> None:
"""
Create a legacy redirect page at the root of the output directory.
:param outdir: Output directory for the redirect page
:type outdir: Path
"""
html = """<!doctype html>
<html>
<head>
<meta charset="utf-8">
<script>
// Detect if we're in /doc/ subdirectory and redirect accordingly
const path = window.location.pathname;
const targetUrl = path.startsWith('/doc/')
? '/doc/stable/index.html'
: 'stable/index.html';
window.location.replace(targetUrl);
</script>
<meta http-equiv="refresh" content="0; url=stable/index.html">
<link rel="canonical" href="stable/index.html">
<title>Redirecting to stable documentation</title>
</head>
<body>
<p>Redirecting to <a href="stable/index.html">Documentation of the last stable release</a></p>
</body>
</html>
"""
d = outdir / Path('index.html')
with open(d, 'w', encoding='utf-8') as f:
f.write(html)
print(f'๐งพ Wrote legacy redirect page {d}')
[docs]
def write_redirects_file(outdir: Path) -> None:
"""
Create a _redirects file for GitLab Pages.
:param outdir: Output directory for the redirects file
:type outdir: Path
"""
redirects_content = """# Redirects for GitLab Pages
# See: https://docs.gitlab.com/ee/user/project/pages/redirects.html
# Redirect old PDF URL to new PDF downloads page
/doc/mafw.pdf /doc/pdf_downloads.html 301
# Redirect /doc root to stable documentation
# Note: These are specific patterns to avoid redirecting /doc/pdf_downloads.html
/doc/ /doc/stable/ 301
/doc/index.html /doc/stable/index.html 301
/doc/doc_tutorial.html /doc/stable/doc_tutorial.html 301
"""
redirects_file = outdir / '_redirects'
with open(redirects_file, 'w', encoding='utf-8') as f:
f.write(redirects_content)
print(f'๐ Wrote _redirects file: {redirects_file}')
print(' Note: Copy this file to the public/ directory root for GitLab Pages')
[docs]
def write_root_landing_page(build_root: Path, project_name: str = 'MAFw') -> None:
"""
Create a landing page for the project root with links to documentation and coverage.
:param build_root: Root build directory (should contain 'doc' subdirectory)
:type build_root: Path
:param project_name: Project name for the page title
:type project_name: str
"""
html_content = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{project_name} - Documentation Hub</title>
<link rel="shortcut icon" href="doc/stable/_static/mafw-logo.svg"/>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
max-width: 1000px;
margin: 0 auto;
padding: 40px 20px;
line-height: 1.6;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}}
.container {{
background: white;
border-radius: 10px;
padding: 40px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
}}
h1 {{
color: #2c3e50;
border-bottom: 3px solid #3498db;
padding-bottom: 15px;
margin-top: 0;
}}
.section {{
margin: 30px 0;
padding: 25px;
background: #f8f9fa;
border-radius: 8px;
border-left: 4px solid #3498db;
}}
.section h2 {{
color: #2c3e50;
margin-top: 0;
display: flex;
align-items: center;
gap: 10px;
}}
.links {{
display: flex;
flex-wrap: wrap;
gap: 15px;
margin-top: 15px;
}}
.link-btn {{
display: inline-block;
background: #3498db;
color: white;
padding: 12px 24px;
text-decoration: none;
border-radius: 5px;
transition: all 0.3s;
font-weight: 500;
}}
.link-btn:hover {{
background: #2980b9;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.4);
}}
.link-btn.secondary {{
background: #95a5a6;
}}
.link-btn.secondary:hover {{
background: #7f8c8d;
}}
.description {{
color: #555;
margin: 10px 0;
}}
.icon {{
font-size: 1.5em;
}}
</style>
</head>
<body>
<div class="container">
<h1>๐ {project_name} Documentation Hub</h1>
<p class="description">
Welcome to the {project_name} project documentation portal.
Access the latest documentation, download PDFs, or view test coverage reports.
</p>
<div class="section">
<h2><span class="icon">๐</span> Documentation</h2>
<p class="description">
Browse the complete documentation with tutorials, API reference, and guides.
</p>
<div class="links">
<a href="doc/stable/index.html" class="link-btn">
๐ Latest Stable Documentation
</a>
<a href="doc/latest/index.html" class="link-btn secondary">
๐ฌ Development Version
</a>
<a href="doc/pdf_downloads.html" class="link-btn secondary">
๐ Download PDFs
</a>
</div>
</div>
<div class="section">
<h2><span class="icon">๐งช</span> Test Coverage</h2>
<p class="description">
View detailed test coverage reports showing which parts of the codebase are tested.
</p>
<div class="links">
<a href="coverage/index.html" class="link-btn">
๐ View Coverage Report
</a>
</div>
</div>
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #dee2e6; color: #6c757d; font-size: 0.9em;">
<p>
๐ก <strong>Tip:</strong> Bookmark the stable documentation link for quick access to the latest version.
</p>
</div>
</div>
</body>
</html>
"""
landing_page = build_root / 'index.html'
with open(landing_page, 'w', encoding='utf-8') as f:
f.write(html_content)
print(f'๐ Generated root landing page: {landing_page}')
print(' Note: This should be copied to public/index.html in GitLab CI')
[docs]
def get_directory_size(path: Path) -> int:
"""
Calculate total size of a directory in bytes.
:param path: Directory path
:type path: Path
:return: Total size in bytes
:rtype: int
"""
total = 0
for item in path.rglob('*'):
if item.is_file():
total += item.stat().st_size
return total
[docs]
def prune_old_versions(outdir: Path, max_size_mb: int = 100, dry_run: bool = False) -> tuple[list[str], int]:
"""
Remove oldest version directories until total size is below threshold.
Always keeps 'stable', 'latest', and 'dev' (if present).
:param outdir: Output directory containing version directories
:type outdir: Path
:param max_size_mb: Maximum size in megabytes
:type max_size_mb: int
:param dry_run: If True, only report what would be deleted
:type dry_run: bool
:return: Tuple of (list of removed versions, final size in bytes)
:rtype: tuple[list[str], int]
"""
outdir = Path(outdir).resolve()
max_size_bytes = max_size_mb * 1024 * 1024
# Get current total size
current_size = get_directory_size(outdir)
print(f'๐ Current total size: {format_size(current_size)}')
print(f'๐ฏ Target maximum: {format_size(max_size_bytes)}')
if current_size <= max_size_bytes:
print('โ
Size is within limit. No pruning needed.')
return [], current_size
# Find all version directories
protected_versions = {'stable', 'latest', 'dev'}
version_dirs = []
for item in outdir.iterdir():
if item.is_dir() and item.name not in protected_versions:
# Skip if it's a symlink (it's an alias)
if item.is_symlink():
continue
size = get_directory_size(item)
version_dirs.append((item.name, size, item))
# Sort by version (oldest first) using semantic versioning
version_dirs.sort(key=lambda x: parse_version_tuple(x[0]))
print(f'\n๐ฆ Found {len(version_dirs)} version directories (excluding protected):')
for name, size, _ in version_dirs:
print(f' โข {name}: {format_size(size)}')
print(f'\n๐ก๏ธ Protected versions (will never be removed): {", ".join(protected_versions)}')
# Remove oldest versions until we're under the limit
removed = []
for name, size, path in version_dirs:
if current_size <= max_size_bytes:
break
print(f'\n๐๏ธ {"[DRY RUN] Would remove" if dry_run else "Removing"} {name} ({format_size(size)})...')
if not dry_run:
shutil.rmtree(path)
removed.append(name)
current_size -= size
print(f' New total size: {format_size(current_size)}')
if not removed:
print(f'\nโ ๏ธ Warning: Cannot reduce size below {format_size(max_size_bytes)}')
print(' All remaining versions are protected or size target is too aggressive.')
return removed, current_size
[docs]
def regenerate_versions_json_after_pruning(outdir: Path, removed_versions: list[str]) -> None:
"""
Regenerate versions.json after pruning, excluding removed versions.
:param outdir: Output directory containing version directories
:type outdir: Path
:param removed_versions: List of version names that were removed
:type removed_versions: list[str]
"""
versions_file = outdir / 'versions.json'
if not versions_file.exists():
print('โ ๏ธ versions.json not found, skipping regeneration')
return
# Read existing versions.json
with open(versions_file, encoding='utf-8') as f:
versions = json.load(f)
# Filter out removed versions
original_count = len(versions)
versions = [v for v in versions if v['version'] not in removed_versions and v.get('path') not in removed_versions]
removed_count = original_count - len(versions)
if removed_count == 0:
print('โน๏ธ No versions removed from versions.json')
return
print('\n๐ Regenerating versions.json...')
print(f' Removed {removed_count} entries')
# Write updated versions.json
write_versions_json(outdir, versions)
[docs]
def ensure_versions_json_exists(outdir: Path) -> bool:
"""
Ensure versions.json exists in outdir. If not, try to copy from another version.
:param outdir: Output directory that should contain versions.json
:type outdir: Path
:return: True if versions.json exists or was successfully copied
:rtype: bool
"""
versions_file = outdir / 'versions.json'
if versions_file.exists():
return True
print('โ ๏ธ versions.json not found in output directory')
# Look for versions.json in other version directories
for item in outdir.iterdir():
if item.is_dir() and not item.is_symlink():
candidate = item / 'versions.json'
if candidate.exists():
print(f'๐ Copying versions.json from {item.name}/')
shutil.copy(candidate, versions_file)
shutil.copy(candidate, outdir / 'generated/versions.json')
return True
print('โ Could not find versions.json in any version directory')
return False