diff --git a/scripts/build_docs_example_report.py b/scripts/build_docs_example_report.py index 805adccd..3819143e 100644 --- a/scripts/build_docs_example_report.py +++ b/scripts/build_docs_example_report.py @@ -8,8 +8,10 @@ from __future__ import annotations import argparse +import importlib import json import os +import re import shutil import subprocess import sys @@ -17,11 +19,20 @@ from datetime import datetime, timezone from pathlib import Path from tempfile import TemporaryDirectory +from typing import cast +from urllib.parse import urlparse from codeclone import __version__ DEFAULT_OUTPUT_DIR = Path("site/examples/report/live") CODECLONE_CLI_MODULE = "codeclone.main" +_ARTIFACT_NAMES: tuple[str, ...] = ( + "index.html", + "report.json", + "report.sarif", + "manifest.json", +) +_RELATIVE_LIVE_HREF = re.compile(r'href="live/([a-zA-Z0-9_.-]+)"') @dataclass(frozen=True) @@ -106,6 +117,93 @@ def _copy_artifacts(source: ReportArtifacts, destination: ReportArtifacts) -> No shutil.copy2(source.manifest, destination.manifest) +def _load_toml_text(text: str) -> dict[str, object]: + if sys.version_info >= (3, 11): + import tomllib + + payload = tomllib.loads(text) + else: + tomli_module = importlib.import_module("tomli") + loads_fn = getattr(tomli_module, "loads", None) + if not callable(loads_fn): + msg = "Invalid 'tomli' module: missing callable 'loads'." + raise RuntimeError(msg) + payload = loads_fn(text) + if not isinstance(payload, dict): + msg = "TOML root must be a table." + raise ValueError(msg) + return cast(dict[str, object], payload) + + +def _read_site_url(repo_root: Path) -> str: + config_path = repo_root / "zensical.toml" + payload = _load_toml_text(config_path.read_text(encoding="utf-8")) + project = payload.get("project") + if not isinstance(project, dict): + raise ValueError(f"{config_path} is missing a [project] table.") + site_url = project.get("site_url") + if not isinstance(site_url, str) or not site_url.strip(): + raise ValueError(f"{config_path} must define project.site_url.") + return site_url.strip() + + +def _published_artifact_href(site_url: str, artifact_name: str) -> str: + if artifact_name not in _ARTIFACT_NAMES: + msg = f"unsupported sample-report artifact: {artifact_name}" + raise ValueError(msg) + parsed = urlparse(site_url) + if not parsed.scheme or not parsed.netloc: + msg = f"project.site_url must be an absolute URL, got {site_url!r}" + raise ValueError(msg) + base_path = parsed.path.rstrip("/") + artifact_path = f"{base_path}/examples/report/live/{artifact_name}" + return f"{parsed.scheme}://{parsed.netloc}{artifact_path}" + + +def _sample_report_page_path(output_dir: Path) -> Path: + return output_dir.parent / "index.html" + + +def _patch_sample_report_links(*, output_dir: Path, site_url: str) -> None: + """Rewrite relative live/* hrefs to absolute published URLs. + + Relative ``live/...`` links break when the Sample Report page URL lacks a + trailing slash (common with navigation.instant), resolving to + ``/examples/live/...`` instead of ``/examples/report/live/...``. + """ + report_page = _sample_report_page_path(output_dir) + if not report_page.is_file(): + return + text = report_page.read_text(encoding="utf-8") + + def _replace(match: re.Match[str]) -> str: + artifact_name = match.group(1) + href = _published_artifact_href(site_url, artifact_name) + return f'href="{href}"' + + patched = _RELATIVE_LIVE_HREF.sub(_replace, text) + if patched != text: + report_page.write_text(patched, encoding="utf-8") + + +def _verify_report_artifacts(destination: ReportArtifacts) -> None: + missing = [ + str(path) + for path in ( + destination.html, + destination.json, + destination.sarif, + destination.manifest, + ) + if not path.is_file() + ] + if missing: + joined = ", ".join(missing) + raise FileNotFoundError( + f"sample report artifacts missing after build: {joined}" + ) + + def build_docs_example_report(output_dir: Path) -> None: scan_root = _repo_root() destination = _artifacts_for_dir(output_dir) @@ -115,6 +213,10 @@ def build_docs_example_report(output_dir: Path) -> None: _run_codeclone(scan_root, working) _write_manifest(scan_root, working) _copy_artifacts(working, destination) + _verify_report_artifacts(destination) + _patch_sample_report_links( + output_dir=output_dir, site_url=_read_site_url(scan_root) + ) def main(argv: list[str] | None = None) -> int: diff --git a/tests/test_docs_example_report.py b/tests/test_docs_example_report.py index eee1230e..bba51227 100644 --- a/tests/test_docs_example_report.py +++ b/tests/test_docs_example_report.py @@ -19,6 +19,54 @@ def _load_docs_report_namespace() -> dict[str, object]: return runpy.run_path(str(script_path)) +def test_published_artifact_href_uses_site_url_path_prefix() -> None: + module = _load_docs_report_namespace() + published_artifact_href = module["_published_artifact_href"] + assert callable(published_artifact_href) + href = published_artifact_href( + "https://orenlab.github.io/codeclone/", + "index.html", + ) + assert href == "https://orenlab.github.io/codeclone/examples/report/live/index.html" + + +def test_patch_sample_report_links_rewrites_relative_live_hrefs( + tmp_path: Path, +) -> None: + module = _load_docs_report_namespace() + patch_sample_report_links = module["_patch_sample_report_links"] + assert callable(patch_sample_report_links) + + output_dir = tmp_path / "examples" / "report" / "live" + output_dir.mkdir(parents=True) + report_page = tmp_path / "examples" / "report" / "index.html" + report_page.write_text( + "\n".join( + [ + 'HTML', + 'JSON', + ] + ), + encoding="utf-8", + ) + + patch_sample_report_links( + output_dir=output_dir, + site_url="https://orenlab.github.io/codeclone/", + ) + + patched = report_page.read_text(encoding="utf-8") + assert 'href="live/index.html"' not in patched + assert ( + 'href="https://orenlab.github.io/codeclone/examples/report/live/index.html"' + in patched + ) + assert ( + 'href="https://orenlab.github.io/codeclone/examples/report/live/report.json"' + in patched + ) + + def test_docs_example_report_uses_main_entrypoint( tmp_path: Path, ) -> None: