diff --git a/scripts/build_ontology_site.py b/scripts/build_ontology_site.py
index 423aff2c6..16077c117 100644
--- a/scripts/build_ontology_site.py
+++ b/scripts/build_ontology_site.py
@@ -16,7 +16,11 @@
from collections.abc import Iterable
from pathlib import Path
from typing import Any
-from urllib.parse import quote
+
+try:
+ from scripts.ontology_site_contract import public_fragment
+except ModuleNotFoundError: # direct execution with ``scripts`` as sys.path[0]
+ from ontology_site_contract import public_fragment
from rdflib import Graph, Literal, URIRef
from rdflib.compare import to_canonical_graph
@@ -113,7 +117,7 @@ def _write_serializations(graph: Graph, ontology_dir: Path) -> None:
def _term_href(value: URIRef, ontology_subjects: set[URIRef]) -> str:
"""Return a local fragment for local terms and an absolute IRI otherwise."""
if value in ontology_subjects:
- return f"#{quote(_fragment(value), safe='-._~')}"
+ return f"#{public_fragment(_fragment(value))}"
return str(value)
@@ -146,11 +150,12 @@ def _render_relation_rows(
def _render_term(graph: Graph, subject: URIRef, ontology_subjects: set[URIRef]) -> str:
"""Render one fragment-addressable ontology term section."""
- fragment = _fragment(subject)
+ raw_fragment = _fragment(subject)
+ fragment = public_fragment(raw_fragment)
label = (
_preferred_literal(graph, subject, RDFS.label)
or _preferred_literal(graph, subject, SKOS.prefLabel)
- or fragment
+ or raw_fragment
)
comment = _preferred_literal(graph, subject, RDFS.comment)
lookup_predicate = URIRef(
@@ -171,7 +176,7 @@ def _render_term(graph: Graph, subject: URIRef, ontology_subjects: set[URIRef])
)
return (
f'
'
- f'# '
f"{html.escape(label)}
"
f'{html.escape(str(subject))}
'
@@ -411,7 +416,10 @@ def build_site(repository_root: Path, output_dir: Path) -> None:
raise FileNotFoundError(f"PROV-O support profile is missing: {prov_profile}")
if output.exists():
- shutil.rmtree(output)
+ raise FileExistsError(
+ "refusing to replace an existing output directory; "
+ "use publish_ontology_site for marked replacement"
+ )
ontology_dir = output / "ontology"
ontology_dir.mkdir(parents=True)
diff --git a/scripts/ontology_site_contract.py b/scripts/ontology_site_contract.py
new file mode 100644
index 000000000..b82c4c5bc
--- /dev/null
+++ b/scripts/ontology_site_contract.py
@@ -0,0 +1,8 @@
+"""Shared contracts for safe public ontology-site identifiers."""
+
+from urllib.parse import quote
+
+
+def public_fragment(fragment: str) -> str:
+ """Encode one local fragment identically for HTML IDs and hrefs."""
+ return quote(fragment, safe="-._~")
diff --git a/scripts/publish_ontology_site.py b/scripts/publish_ontology_site.py
index fee47d3d3..bccdaa9f4 100644
--- a/scripts/publish_ontology_site.py
+++ b/scripts/publish_ontology_site.py
@@ -10,6 +10,7 @@
import argparse
import importlib.util
+import shutil
from collections.abc import Iterable
from pathlib import Path
from types import ModuleType
@@ -18,6 +19,11 @@
from rdflib import Graph, URIRef
from rdflib.namespace import OWL, RDF, RDFS, SKOS
+try:
+ from scripts.ontology_site_contract import public_fragment
+except ModuleNotFoundError: # direct execution with ``scripts`` as sys.path[0]
+ from ontology_site_contract import public_fragment
+
OUTPUT_MARKER = ".lineageweave-ontology-site"
SOURCE_RELATIVE_PATH = Path("docs/ontology/lineageweave-kg.ttl")
PROV_PROFILE_RELATIVE_PATH = Path("docs/ontology/prov-o-support-profile.ttl")
@@ -75,7 +81,7 @@ def validate_public_graph(graph: Graph) -> None:
subjects = _public_subjects(graph)
fragment_owner: dict[str, URIRef] = {}
for subject in sorted(subjects, key=str):
- fragment = _fragment(subject)
+ fragment = public_fragment(_fragment(subject))
owner = fragment_owner.setdefault(fragment, subject)
if owner != subject:
raise ValueError(
@@ -122,6 +128,8 @@ def publish_site(repository_root: Path, output_dir: Path) -> None:
validate_public_graph(graph)
renderer = _load_renderer(root)
+ if output.exists():
+ shutil.rmtree(output)
renderer.build_site(root, output)
(output / OUTPUT_MARKER).write_text("", encoding="utf-8")
diff --git a/tests/test_ontology_site.py b/tests/test_ontology_site.py
index 46f16bfd2..36b29e37d 100644
--- a/tests/test_ontology_site.py
+++ b/tests/test_ontology_site.py
@@ -2,9 +2,11 @@
from __future__ import annotations
+import builtins
import hashlib
import importlib.util
import json
+import shutil
from pathlib import Path
from rdflib import Graph
@@ -23,6 +25,24 @@ def _load_builder():
return module
+def test_builder_supports_direct_script_import_context(monkeypatch) -> None:
+ """The CLI fallback imports the sibling contract when not package-loaded."""
+ monkeypatch.syspath_prepend(str(SCRIPT.parent))
+ original_import = builtins.__import__
+
+ def block_package_import(name, globals=None, locals=None, fromlist=(), level=0):
+ if name == "scripts.ontology_site_contract":
+ raise ModuleNotFoundError(name=name)
+ return original_import(name, globals, locals, fromlist, level)
+
+ monkeypatch.setattr(builtins, "__import__", block_package_import)
+ spec = importlib.util.spec_from_file_location("build_ontology_site_direct", SCRIPT)
+ assert spec is not None and spec.loader is not None
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ assert module.public_fragment("Safety/한국어 term") == "Safety%2F%ED%95%9C%EA%B5%AD%EC%96%B4%20term"
+
+
def _tree_hashes(root: Path) -> dict[str, str]:
return {
str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest()
@@ -99,6 +119,18 @@ def test_render_term_uses_skos_preferred_label_without_rdfs_label() -> None:
assert 'aria-label="Link to Human label"' in rendered
+def test_render_term_uses_one_encoded_fragment_for_id_and_href() -> None:
+ builder = _load_builder()
+ graph = Graph()
+ term = builder.URIRef("https://example.test/ontology#Safety/한국어 term")
+ graph.add((term, builder.RDF.type, builder.OWL.Class))
+
+ rendered = builder._render_term(graph, term, {term})
+
+ assert 'id="Safety%2F%ED%95%9C%EA%B5%AD%EC%96%B4%20term"' in rendered
+ assert 'href="#Safety%2F%ED%95%9C%EA%B5%AD%EC%96%B4%20term"' in rendered
+
+
def test_serializations_round_trip_to_the_source_graph(tmp_path: Path) -> None:
builder = _load_builder()
output = tmp_path / "site"
@@ -173,7 +205,7 @@ def test_render_term_sections_keeps_one_anchor_for_multi_typed_terms() -> None:
assert term_count == 1
-def test_builder_fails_closed_for_missing_sources_and_replaces_output(tmp_path: Path) -> None:
+def test_builder_fails_closed_for_missing_sources_and_rejects_existing_output(tmp_path: Path) -> None:
builder = _load_builder()
repository = tmp_path / "repository"
output = tmp_path / "site"
@@ -201,8 +233,17 @@ def test_builder_fails_closed_for_missing_sources_and_replaces_output(tmp_path:
raise AssertionError("missing PROV-O profile was accepted")
(ontology_dir / "prov-o-support-profile.ttl").write_text("", encoding="utf-8")
+
+ try:
+ builder.build_site(repository, output)
+ except FileExistsError as exc:
+ assert "publish_ontology_site" in str(exc)
+ else:
+ raise AssertionError("direct builder replaced an existing output")
+ assert (output / "stale.txt").is_file()
+ shutil.rmtree(output)
builder.build_site(repository, output)
- assert not (output / "stale.txt").exists()
+ assert (output / "ontology" / "index.html").is_file()
def test_cli_main_and_module_entrypoint(tmp_path: Path, monkeypatch) -> None:
diff --git a/tests/test_publish_ontology_site.py b/tests/test_publish_ontology_site.py
index bec404ac2..8230d08e4 100644
--- a/tests/test_publish_ontology_site.py
+++ b/tests/test_publish_ontology_site.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import builtins
import importlib.util
from pathlib import Path
@@ -22,6 +23,24 @@ def _load_publisher():
return module
+def test_publisher_supports_direct_script_import_context(monkeypatch) -> None:
+ """The CLI fallback imports the sibling contract when not package-loaded."""
+ monkeypatch.syspath_prepend(str(SCRIPT.parent))
+ original_import = builtins.__import__
+
+ def block_package_import(name, globals=None, locals=None, fromlist=(), level=0):
+ if name == "scripts.ontology_site_contract":
+ raise ModuleNotFoundError(name=name)
+ return original_import(name, globals, locals, fromlist, level)
+
+ monkeypatch.setattr(builtins, "__import__", block_package_import)
+ spec = importlib.util.spec_from_file_location("publish_ontology_site_direct", SCRIPT)
+ assert spec is not None and spec.loader is not None
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ assert module.public_fragment("Safety/한국어 term") == "Safety%2F%ED%95%9C%EA%B5%AD%EC%96%B4%20term"
+
+
def _repository_fixture(tmp_path: Path) -> Path:
repository = tmp_path / "repository"
ontology_dir = repository / "docs" / "ontology"
@@ -33,6 +52,9 @@ def _repository_fixture(tmp_path: Path) -> Path:
(scripts_dir / "build_ontology_site.py").write_bytes(
(ROOT / "scripts" / "build_ontology_site.py").read_bytes()
)
+ (scripts_dir / "ontology_site_contract.py").write_bytes(
+ (ROOT / "scripts" / "ontology_site_contract.py").read_bytes()
+ )
return repository