Skip to content

Commit d32278c

Browse files
queeliusclaude
andcommitted
fix(spa): make .md links in Analysis page scroll to inline sections
Analysis markdown files often cross-reference each other with relative links like [details.md](details.md). In the SPA these files are embedded inline, so the links were dead. Now each section gets an anchor ID and .md links are rewritten to smooth-scroll to the target, expanding collapsed <details> elements automatically. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f8a333b commit d32278c

2 files changed

Lines changed: 72 additions & 2 deletions

File tree

src/chartfold/spa/js/sections.js

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -932,16 +932,42 @@ const Sections = {
932932
return;
933933
}
934934

935+
// Build filename -> anchor-id map for cross-linking between analysis files
936+
var filenameMap = {};
937+
for (var j = 0; j < data.length; j++) {
938+
var fn = data[j].filename || '';
939+
if (fn) filenameMap[fn] = 'analysis-' + fn.replace(/\.md$/i, '').replace(/[^a-z0-9-]/gi, '-').toLowerCase();
940+
}
941+
935942
for (var i = 0; i < data.length; i++) {
936943
var entry = data[i];
944+
var anchorId = filenameMap[entry.filename] || ('analysis-' + i);
937945
var contentDiv = UI.el('div', { className: 'analysis-content' });
938-
contentDiv.innerHTML = Markdown.render(entry.body);
946+
// Markdown.render returns sanitized HTML from our own embedded markdown parser
947+
contentDiv.innerHTML = Markdown.render(entry.body); // trusted internal content
948+
949+
// Rewrite .md file links to scroll to the corresponding inline section
950+
var links = contentDiv.querySelectorAll('a[href]');
951+
for (var li = 0; li < links.length; li++) {
952+
var href = links[li].getAttribute('href');
953+
if (href && /\.md$/i.test(href)) {
954+
var targetFn = href.replace(/^.*\//, ''); // strip path prefix, keep filename
955+
var targetId = filenameMap[targetFn];
956+
if (targetId) {
957+
links[li].setAttribute('href', '#' + targetId);
958+
links[li].setAttribute('data-analysis-target', targetId);
959+
}
960+
}
961+
}
939962

940963
if (i === 0) {
941964
// First entry expanded by default
942-
el.appendChild(UI.clinicalCard(entry.title, entry.filename || '', contentDiv));
965+
var card = UI.clinicalCard(entry.title, entry.filename || '', contentDiv);
966+
card.id = anchorId;
967+
el.appendChild(card);
943968
} else {
944969
var details = UI.el('details', { style: 'margin-bottom: 8px;' });
970+
details.id = anchorId;
945971
details.appendChild(UI.el('summary', {
946972
textContent: entry.title,
947973
style: 'cursor: pointer; font-weight: 600; padding: 8px 0;'
@@ -951,6 +977,19 @@ const Sections = {
951977
el.appendChild(details);
952978
}
953979
}
980+
981+
// Handle clicks on .md links: expand the target <details> and scroll to it
982+
el.addEventListener('click', function(e) {
983+
var link = e.target.closest('a[data-analysis-target]');
984+
if (!link) return;
985+
e.preventDefault();
986+
var targetId = link.getAttribute('data-analysis-target');
987+
var targetEl = document.getElementById(targetId);
988+
if (targetEl) {
989+
if (targetEl.tagName === 'DETAILS') targetEl.open = true;
990+
targetEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
991+
}
992+
});
954993
},
955994

956995
sql_console(el, db) {

tests/test_spa_export.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,6 +1164,37 @@ def test_analysis_markdown_embedded(self, spa_db, tmp_path):
11641164
assert data[0]["filename"] == "cea-analysis.md"
11651165
assert "CEA values are stable." in data[0]["body"]
11661166

1167+
def test_analysis_md_links_get_anchor_ids(self, spa_db, tmp_path):
1168+
"""Analysis entries get anchor IDs so cross-file .md links can scroll to them."""
1169+
analysis_dir = tmp_path / "analysis"
1170+
analysis_dir.mkdir()
1171+
# First file links to the second
1172+
(analysis_dir / "index.md").write_text(
1173+
"See [details.md](details.md) for more.\n"
1174+
)
1175+
(analysis_dir / "details.md").write_text("The detailed analysis.\n")
1176+
out_path = str(tmp_path / "cross_link.html")
1177+
export_spa(spa_db, out_path, analysis_dir=str(analysis_dir))
1178+
with open(out_path, encoding="utf-8") as f:
1179+
html = f.read()
1180+
# The JS builds anchor IDs like "analysis-details" from "details.md"
1181+
# and the sections.js code assigns id attributes and rewrites href to #analysis-details
1182+
# Verify both files are embedded
1183+
match = re.search(
1184+
r'<script id="chartfold-analysis" type="application/json">(.*?)</script>',
1185+
html,
1186+
re.DOTALL,
1187+
)
1188+
assert match is not None
1189+
data = json.loads(match.group(1))
1190+
assert len(data) == 2
1191+
filenames = [d["filename"] for d in data]
1192+
assert "details.md" in filenames
1193+
assert "index.md" in filenames
1194+
# The link text in the first file's body should reference details.md
1195+
index_entry = next(d for d in data if d["filename"] == "index.md")
1196+
assert "details.md" in index_entry["body"]
1197+
11671198
def test_embed_images_flag(self, spa_db, tmp_path):
11681199
"""embed_images=True triggers image asset loading from database."""
11691200
# Create a small image file

0 commit comments

Comments
 (0)