From 467641c4648aa6e22acf4834e92edda5cd506cdf Mon Sep 17 00:00:00 2001 From: Ryan Burke Date: Fri, 10 Jul 2026 17:38:38 +0100 Subject: [PATCH] Fix: resolve relative concept links in the graph viewer detail panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled graph viewer's detail panel only converted absolute markdown links (e.g. `/tables/users.md`) into in-app node selections. Relative links (`users.md`, `../references/metrics/dau.md`) — which the Python edge extractor `_extract_links` already resolves, and which the format's own examples use — fell through to the "external link" branch and opened the raw `.md` file instead of selecting the concept. Resolve link hrefs to concept ids relative to the current concept's directory (handling `.`, `..`, and `#anchor`), so clicking any intra-bundle link selects that node, matching the behaviour of the graph edges. viz.js is a browser-only static asset with no JS test harness in the repo; the relative-link contract is already covered on the Python side by test_viewer.py::test_cross_links_become_edges. Verified manually that absolute and relative links (including `..` traversal and `#anchors`) resolve to the correct concept id. Co-Authored-By: Claude Opus 4.8 (1M context) --- okf/src/reference_agent/viewer/static/viz.js | 46 ++++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/okf/src/reference_agent/viewer/static/viz.js b/okf/src/reference_agent/viewer/static/viz.js index 580b893..e04570f 100644 --- a/okf/src/reference_agent/viewer/static/viz.js +++ b/okf/src/reference_agent/viewer/static/viz.js @@ -185,7 +185,7 @@ const html = marked.parse(body, { breaks: false, gfm: true }); const bodyEl = document.getElementById("detail-body"); bodyEl.innerHTML = html; - rewriteInternalLinks(bodyEl); + rewriteInternalLinks(bodyEl, conceptId); const bl = backlinks[conceptId] || []; const blSection = document.getElementById("detail-backlinks"); @@ -213,21 +213,41 @@ cy.animate({ center: { eles: node }, zoom: Math.max(cy.zoom(), 1.0) }, { duration: 200 }); } - function rewriteInternalLinks(root) { + // Resolve a markdown-link href to a concept id, honoring both absolute + // ("/clinical/careplan.md") and relative ("../clinical/careplan.md", + // "founding-member-v1.md") links, the latter relative to currentId's dir. + function hrefToConceptId(href, currentId) { + let h = href.split("#")[0]; + if (!h.endsWith(".md")) return null; + h = h.slice(0, -3); + let parts; + if (h.startsWith("/")) { + parts = h.slice(1).split("/"); + } else { + parts = currentId.split("/").slice(0, -1).concat(h.split("/")); + } + const out = []; + for (const p of parts) { + if (p === "" || p === ".") continue; + if (p === "..") out.pop(); + else out.push(p); + } + return out.join("/"); + } + + function rewriteInternalLinks(root, currentId) { root.querySelectorAll("a[href]").forEach((a) => { const href = a.getAttribute("href"); if (!href) return; - if (href.startsWith("/") && href.endsWith(".md")) { - const target = href.slice(1, -3); - if (nodeIndex[target]) { - a.className = "internal"; - a.setAttribute("href", "javascript:void(0)"); - a.addEventListener("click", (e) => { - e.preventDefault(); - showDetail(target); - }); - return; - } + const target = hrefToConceptId(href, currentId || ""); + if (target && nodeIndex[target]) { + a.className = "internal"; + a.setAttribute("href", "javascript:void(0)"); + a.addEventListener("click", (e) => { + e.preventDefault(); + showDetail(target); + }); + return; } a.className = "external"; a.setAttribute("target", "_blank");