Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import { BuyerNav, type BuyerDestination } from "./components/BuyerNav";
import { LineageDag } from "./LineageDag";
import { PostBody } from "./PostBody";
import { decodeHtmlEntities } from "./postBodyDisplay";
import { rememberOidcReturnUrl, returnUrlFromLocation } from "./oidcReturnUrl";
import { FiveW1H } from "./components/FiveW1H";
import { subgraphForPost } from "./lineageLayout";
import {
Expand All @@ -101,7 +102,6 @@ import {
tf,
useLocale,
} from "./i18n";
import { rememberOidcReturnUrl, returnUrlFromLocation } from "./oidcReturnUrl";
import "./App.css";

function orchestratorUnavailableMessage(err: unknown, action: string): string {
Expand Down Expand Up @@ -4621,7 +4621,7 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean
<small>Enterprise SSO Authentication</small>
</div>
</div>
{destination === "admin" && accessToken ? <AdminPanel currentBrandName={brandName} onBrandNameChange={setBrandName} accessToken={accessToken} /> : null}
{destination === "admin" && accessToken ? <AdminPanel currentBrandName={brandName} onBrandNameChange={setBrandName} accessToken={accessToken} /> : null}
</main>
<footer className="app-footer" role="contentinfo">
<div className="app-footer-title">
Expand Down
20 changes: 14 additions & 6 deletions scripts/build_ontology_site.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

from rdflib import Graph, Literal, URIRef
from rdflib.compare import to_canonical_graph
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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(
Expand All @@ -171,7 +176,7 @@ def _render_term(graph: Graph, subject: URIRef, ontology_subjects: set[URIRef])
)
return (
f'<article class="term-card" id="{html.escape(fragment, quote=True)}">'
f'<h3><a class="fragment-link" href="#{quote(fragment, safe="-._~")}" '
f'<h3><a class="fragment-link" href="#{html.escape(fragment, quote=True)}" '
Comment on lines 178 to +179

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Fragment id/href encoding now consistent

The element id and its self-link href both derive from public_fragment(raw_fragment) and the same html.escape, matching _term_href/_render_link (scripts/build_ontology_site.py:120) and the publisher's duplicate check. The label fallback correctly keeps the raw, human-readable fragment. This resolves the prior mismatch where the id was escaped but the href was percent-encoded.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

f'aria-label="Link to {html.escape(label, quote=True)}">#</a> '
f"{html.escape(label)}</h3>"
f'<p class="iri"><code>{html.escape(str(subject))}</code></p>'
Expand Down Expand Up @@ -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}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Direct builder path skips fragment/link validation

validate_public_graph (fragment-collision and link-scheme checks) runs only in publish_site. The builder's own CLI (build_ontology_site.main) calls build_site with no validation, so colliding percent-encoded fragments produce duplicate HTML ids only when the raw builder is used directly. Pre-existing, but the shared encoding contract now spans both files.

(Refers to this code)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


if output.exists():
shutil.rmtree(output)
raise FileExistsError(
"refusing to replace an existing output directory; "
"use publish_ontology_site for marked replacement"
)
Comment on lines 418 to +422

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Raw builder now errors on reused output dir

build_site raises FileExistsError instead of clearing the directory. CI only invokes publish_ontology_site.py, which removes a marked directory first, so publishing is unaffected. Any manual or external caller reusing an output directory via the raw builder now fails instead of overwriting.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

ontology_dir = output / "ontology"
ontology_dir.mkdir(parents=True)

Expand Down
8 changes: 8 additions & 0 deletions scripts/ontology_site_contract.py
Original file line number Diff line number Diff line change
@@ -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="-._~")
10 changes: 9 additions & 1 deletion scripts/publish_ontology_site.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import argparse
import importlib.util
import shutil
from collections.abc import Iterable
from pathlib import Path
from types import ModuleType
Expand All @@ -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
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

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")
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Comment on lines +131 to +132

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Output replacement moved into validated publisher path

build_site fails closed on an existing output directory; publish_site does the shutil.rmtree only after marker/symlink/overlap checks. CI invokes the publisher, not the builder, so Pages publication is unaffected. A renderer failure after the removal would leave the marked output deleted without a rebuild.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

renderer.build_site(root, output)
(output / OUTPUT_MARKER).write_text("", encoding="utf-8")

Expand Down
45 changes: 43 additions & 2 deletions tests/test_ontology_site.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down
22 changes: 22 additions & 0 deletions tests/test_publish_ontology_site.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import builtins
import importlib.util
from pathlib import Path

Expand All @@ -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"
Expand All @@ -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


Expand Down