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: