@commentray/render 0.0.5 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/browse-page-slug.d.ts +6 -0
  2. package/dist/browse-page-slug.d.ts.map +1 -0
  3. package/dist/browse-page-slug.js +9 -0
  4. package/dist/browse-page-slug.js.map +1 -0
  5. package/dist/build-commentray-nav-search.d.ts +11 -5
  6. package/dist/build-commentray-nav-search.d.ts.map +1 -1
  7. package/dist/build-commentray-nav-search.js +11 -11
  8. package/dist/build-commentray-nav-search.js.map +1 -1
  9. package/dist/code-browser-block-rays.d.ts +48 -0
  10. package/dist/code-browser-block-rays.d.ts.map +1 -0
  11. package/dist/code-browser-block-rays.js +95 -0
  12. package/dist/code-browser-block-rays.js.map +1 -0
  13. package/dist/code-browser-client.bundle.js +12 -12
  14. package/dist/code-browser-client.js +388 -70
  15. package/dist/code-browser-client.js.map +1 -1
  16. package/dist/code-browser-pair-nav.d.ts +23 -0
  17. package/dist/code-browser-pair-nav.d.ts.map +1 -0
  18. package/dist/code-browser-pair-nav.js +59 -0
  19. package/dist/code-browser-pair-nav.js.map +1 -0
  20. package/dist/code-browser-search.d.ts +45 -0
  21. package/dist/code-browser-search.d.ts.map +1 -1
  22. package/dist/code-browser-search.js +89 -0
  23. package/dist/code-browser-search.js.map +1 -1
  24. package/dist/code-browser.d.ts +22 -1
  25. package/dist/code-browser.d.ts.map +1 -1
  26. package/dist/code-browser.js +342 -103
  27. package/dist/code-browser.js.map +1 -1
  28. package/dist/index.d.ts +1 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/markdown-pipeline.d.ts.map +1 -1
  33. package/dist/markdown-pipeline.js +2 -1
  34. package/dist/markdown-pipeline.js.map +1 -1
  35. package/dist/mermaid-runtime-html.d.ts.map +1 -1
  36. package/dist/mermaid-runtime-html.js +3 -1
  37. package/dist/mermaid-runtime-html.js.map +1 -1
  38. package/package.json +2 -2
@@ -1,11 +1,23 @@
1
1
  import { FuzzySearcher, PrefixSearcher, Query, SearcherFactory, SubstringSearcher, } from "@m31coding/fuzzy-search";
2
+ import { activeBlockIdForViewport, clampViewportYToGutterLocal, codeLineDomIndex0, gutterRayBezierPaths, sortBlockLinksBySource, } from "./code-browser-block-rays.js";
2
3
  import { mirroredScrollTop, pickCommentrayLineForSourceScroll, pickSourceLine0ForCommentrayScroll, } from "./code-browser-scroll-sync.js";
3
4
  import { decodeBase64Utf8 } from "./code-browser-encoding.js";
4
5
  import { readEmbeddedRawB64Strings } from "./code-browser-embedded-payload.js";
5
- import { escapeHtmlHighlightingSearchTokens, findOrderedTokenSpans, lineAtIndex, offsetToLineIndex, } from "./code-browser-search.js";
6
+ import { escapeHtmlHighlightingSearchTokens, filterPairsByDocumentedTreeQuery, findOrderedTokenSpans, lineAtIndex, offsetToLineIndex, pathRowsFromDocumentedPairs, tokenizeQuery, uniqueSourceFilePreviewRows, } from "./code-browser-search.js";
7
+ import { findDocumentedPair, isSameDocumentedPair, normPosixPath, resolveStaticBrowseHref, } from "./code-browser-pair-nav.js";
6
8
  import { readWebStorageItem, writeWebStorageItem } from "./code-browser-web-storage.js";
7
- function tokenizeQuery(q) {
8
- return q.trim().split(/\s+/).filter(Boolean);
9
+ function runMermaidOnFreshDocNodes(docBody) {
10
+ if (typeof globalThis.location !== "undefined" && globalThis.location.protocol === "file:")
11
+ return;
12
+ const nodes = docBody.querySelectorAll(".mermaid");
13
+ if (nodes.length === 0)
14
+ return;
15
+ const m = globalThis
16
+ .commentrayMermaid;
17
+ if (!m)
18
+ return;
19
+ const list = Array.from(nodes);
20
+ void m.run({ nodes: list }).catch(() => { });
9
21
  }
10
22
  function clamp(n, lo, hi) {
11
23
  return Math.max(lo, Math.min(hi, n));
@@ -154,6 +166,35 @@ function searchResultsInnerHtml(scope, combined, tokens, ctx) {
154
166
  }
155
167
  return buf.join("");
156
168
  }
169
+ function emptyBrowsePreviewHint(scope, rowCount, totalUnique, usedIndexFallback) {
170
+ if (scope === "full") {
171
+ return "Documented source for this page. Type to search.";
172
+ }
173
+ if (usedIndexFallback) {
174
+ return "Documented source on this page. Type to search the index when it is available.";
175
+ }
176
+ if (totalUnique > rowCount) {
177
+ return `Indexed source files (${String(rowCount)} of ${String(totalUnique)} shown). Type to search.`;
178
+ }
179
+ return `Indexed source files (${String(totalUnique)}). Type to search.`;
180
+ }
181
+ function emptySearchBrowsePreviewInnerHtml(hint, rows, ctx) {
182
+ const tokens = [];
183
+ const buf = [`<div class="hint">${escapeHtmlText(hint)}</div>`];
184
+ const hits = rows.map((r, i) => ({
185
+ kind: "path",
186
+ line: i,
187
+ text: r.sourcePath,
188
+ score: 1000,
189
+ source: "ordered",
190
+ spPath: r.sourcePath,
191
+ crPath: r.commentrayPath,
192
+ }));
193
+ for (const h of hits) {
194
+ buf.push(searchHitButtonHtml(h, tokens, ctx));
195
+ }
196
+ return buf.join("");
197
+ }
157
198
  function scrollDocToMarkdownLine0(docScrollEl, line0, mdLineCount) {
158
199
  const el = docScrollEl.querySelector(`#commentray-md-line-${String(line0)}`);
159
200
  if (el instanceof HTMLElement) {
@@ -169,22 +210,19 @@ function scrollDocToMarkdownLine0(docScrollEl, line0, mdLineCount) {
169
210
  const maxScroll = docScrollEl.scrollHeight - docScrollEl.clientHeight;
170
211
  docScrollEl.scrollTo({ top: ratio * Math.max(0, maxScroll), behavior: "smooth" });
171
212
  }
172
- function openForeignPairBrowseOrGithub(pairs, commentrayPath, mdLine0) {
173
- const p = pairs.find((x) => x.commentrayPath === commentrayPath);
174
- if (!p)
175
- return;
176
- if (p.staticBrowseUrl?.trim()) {
177
- const u = new URL(p.staticBrowseUrl.trim(), globalThis.location.href);
213
+ function navigateToDocumentedPair(pair, mdLine0) {
214
+ if (pair.staticBrowseUrl?.trim()) {
215
+ const href = resolveStaticBrowseHref(pair.staticBrowseUrl.trim(), globalThis.location.pathname, globalThis.location.origin);
216
+ const u = new URL(href);
178
217
  if (mdLine0 !== null && mdLine0 >= 0)
179
218
  u.hash = `commentray-md-line-${String(mdLine0)}`;
180
- globalThis.open(u.toString(), "_blank", "noopener,noreferrer");
219
+ globalThis.location.assign(u.toString());
181
220
  return;
182
221
  }
183
- if (p.commentrayOnGithub) {
184
- const url = mdLine0 !== null && mdLine0 >= 0
185
- ? `${p.commentrayOnGithub}#L${String(mdLine0 + 1)}`
186
- : p.commentrayOnGithub;
187
- globalThis.open(url, "_blank", "noopener,noreferrer");
222
+ const gh = (pair.commentrayOnGithub ?? "").trim();
223
+ if (gh.length > 0) {
224
+ const url = mdLine0 !== null && mdLine0 >= 0 ? `${gh}#L${String(mdLine0 + 1)}` : gh;
225
+ globalThis.location.assign(url);
188
226
  }
189
227
  }
190
228
  function readSearchScopeFromShell(shell) {
@@ -243,18 +281,25 @@ function scrollCodeHitToView(line) {
243
281
  el.scrollIntoView({ block: "nearest", behavior: "smooth" });
244
282
  }
245
283
  function handlePathSearchHit(button, deps) {
246
- const cr = (button.getAttribute("data-cr-path")?.trim() ?? "").trim();
247
- const curCr = deps.mutable.commentrayPathLabel.trim();
248
- const isCurrentPair = cr.length === 0 || cr === curCr;
249
- if (isCurrentPair) {
284
+ const hitCr = (button.getAttribute("data-cr-path") ?? "").trim();
285
+ const hitSp = (button.getAttribute("data-sp-path") ?? "").trim();
286
+ const pair = findDocumentedPair(deps.mutable.documentedPairs, hitCr, hitSp);
287
+ if (pair && isSameDocumentedPair(pair, deps.filePathLabel, deps.mutable.commentrayPathLabel)) {
250
288
  deps.docScrollEl.scrollTo({ top: 0, behavior: "smooth" });
251
289
  return;
252
290
  }
253
- openForeignPairBrowseOrGithub(deps.mutable.documentedPairs, cr, null);
291
+ if (pair)
292
+ navigateToDocumentedPair(pair, null);
254
293
  }
255
294
  function handleMdSearchHit(line, crHit, deps) {
256
- if (crHit.length > 0 && crHit !== deps.mutable.commentrayPathLabel) {
257
- openForeignPairBrowseOrGithub(deps.mutable.documentedPairs, crHit, line);
295
+ const curCr = deps.mutable.commentrayPathLabel.trim();
296
+ const cr = crHit.trim();
297
+ if (cr.length > 0 && normPosixPath(cr) !== normPosixPath(curCr)) {
298
+ const pair = findDocumentedPair(deps.mutable.documentedPairs, cr, "");
299
+ if (pair) {
300
+ navigateToDocumentedPair(pair, line);
301
+ return;
302
+ }
258
303
  return;
259
304
  }
260
305
  scrollDocToMarkdownLine0(deps.docScrollEl, line, deps.mutable.mdLines.length);
@@ -273,6 +318,36 @@ function handleSearchHitButtonClick(button, deps) {
273
318
  }
274
319
  handleMdSearchHit(line, crHit, deps);
275
320
  }
321
+ /** Empty query + ArrowDown: browse preview HTML, or null when there is nothing to show. */
322
+ function emptyBrowsePreviewInnerHtml(scope, filePathLabel, mutable) {
323
+ const hitCtx = {
324
+ currentCommentrayPath: mutable.commentrayPathLabel,
325
+ currentSourcePath: filePathLabel,
326
+ };
327
+ if (scope === "full") {
328
+ const sp = filePathLabel.trim();
329
+ if (sp.length === 0)
330
+ return null;
331
+ const rows = [
332
+ { sourcePath: sp, commentrayPath: mutable.commentrayPathLabel.trim() },
333
+ ];
334
+ const hint = emptyBrowsePreviewHint("full", rows.length, rows.length, false);
335
+ return emptySearchBrowsePreviewInnerHtml(hint, rows, hitCtx);
336
+ }
337
+ const { rows, totalUnique } = uniqueSourceFilePreviewRows(mutable.documentedPairs);
338
+ if (rows.length > 0) {
339
+ const hint = emptyBrowsePreviewHint("commentray-and-paths", rows.length, totalUnique, false);
340
+ return emptySearchBrowsePreviewInnerHtml(hint, rows, hitCtx);
341
+ }
342
+ const sp = filePathLabel.trim();
343
+ if (sp.length === 0)
344
+ return null;
345
+ const fb = [
346
+ { sourcePath: sp, commentrayPath: mutable.commentrayPathLabel.trim() },
347
+ ];
348
+ const hint = emptyBrowsePreviewHint("commentray-and-paths", fb.length, fb.length, true);
349
+ return emptySearchBrowsePreviewInnerHtml(hint, fb, hitCtx);
350
+ }
276
351
  function wireSearchUi(ctx) {
277
352
  const { scope, filePathLabel, mutable, rawCode, searchInput, searchClear, searchResults, docScrollEl, } = ctx;
278
353
  let debounceTimer;
@@ -283,6 +358,13 @@ function wireSearchUi(ctx) {
283
358
  searchResults.innerHTML = "";
284
359
  searchResults.hidden = true;
285
360
  }
361
+ function renderEmptyBrowsePreview() {
362
+ const html = emptyBrowsePreviewInnerHtml(scope, filePathLabel, mutable);
363
+ if (html === null)
364
+ return;
365
+ searchResults.hidden = false;
366
+ searchResults.innerHTML = html;
367
+ }
286
368
  function runSearch() {
287
369
  const tokens = tokenizeQuery(searchInput.value);
288
370
  if (tokens.length === 0) {
@@ -308,7 +390,7 @@ function wireSearchUi(ctx) {
308
390
  currentSourcePath: filePathLabel,
309
391
  });
310
392
  }
311
- const hitClickDeps = { mutable, docScrollEl };
393
+ const hitClickDeps = { mutable, docScrollEl, filePathLabel };
312
394
  searchResults.addEventListener("click", (ev) => {
313
395
  const hit = findSearchHitButton(ev.target, searchResults);
314
396
  if (!hit)
@@ -319,6 +401,14 @@ function wireSearchUi(ctx) {
319
401
  clearTimeout(debounceTimer);
320
402
  debounceTimer = setTimeout(runSearch, 200);
321
403
  });
404
+ searchInput.addEventListener("keydown", (e) => {
405
+ if (e.key !== "ArrowDown")
406
+ return;
407
+ if (tokenizeQuery(searchInput.value).length > 0)
408
+ return;
409
+ renderEmptyBrowsePreview();
410
+ e.preventDefault();
411
+ });
322
412
  searchClear.addEventListener("click", clearSearch);
323
413
  document.addEventListener("keydown", (e) => {
324
414
  if (e.key !== "Escape")
@@ -477,15 +567,151 @@ function wireProportionalScrollSync(codePane, docPane) {
477
567
  codePane.scrollTop = mirroredScrollTop(docPane.scrollTop, docPane.scrollHeight, docPane.clientHeight, codePane.scrollHeight, codePane.clientHeight);
478
568
  });
479
569
  }
570
+ function centerYInViewport(el) {
571
+ const r = el.getBoundingClientRect();
572
+ return (r.top + r.bottom) / 2;
573
+ }
574
+ /**
575
+ * Vertical anchor for gutter rays on the source side. Must match the **numbered row** the reader
576
+ * sees: the full `.code-line` row (line number + highlighted code) shares one grid row with
577
+ * aligned line-heights. Measuring only `pre code` can shift Y (hljs spans, sub-pixel layout) so
578
+ * rays sit above the line labels; the row’s geometric center tracks `lines:a-b` anchors reliably.
579
+ */
580
+ function codeLineHighlightCenterYViewport(lineEl) {
581
+ return centerYInViewport(lineEl);
582
+ }
583
+ function commentaryBandEndYViewport(docScrollEl, next, docTop) {
584
+ if (next) {
585
+ const nextEl = document.getElementById(`commentray-block-${next.id}`);
586
+ return nextEl ? nextEl.getBoundingClientRect().top - 3 : centerYInViewport(docTop);
587
+ }
588
+ const dr = docScrollEl.getBoundingClientRect();
589
+ let bottom = dr.bottom - 4;
590
+ const lastKid = docScrollEl.children[docScrollEl.children.length - 1];
591
+ if (lastKid)
592
+ bottom = Math.min(bottom, lastKid.getBoundingClientRect().bottom - 4);
593
+ return bottom;
594
+ }
595
+ function subscribeBlockRayRedraw(gutter, codePane, docScrollEl, scheduleDraw) {
596
+ const onScrollOrResize = () => scheduleDraw();
597
+ codePane.addEventListener("scroll", onScrollOrResize, { passive: true });
598
+ docScrollEl.addEventListener("scroll", onScrollOrResize, { passive: true });
599
+ globalThis.addEventListener("resize", onScrollOrResize);
600
+ const ro = new ResizeObserver(onScrollOrResize);
601
+ ro.observe(gutter);
602
+ ro.observe(codePane);
603
+ ro.observe(docScrollEl);
604
+ const shell = gutter.parentElement;
605
+ if (shell)
606
+ ro.observe(shell);
607
+ }
608
+ function drawBlockRaysIntoSvg(svg, gutter, docScrollEl, getLinks, probeTopSourceLine1Based) {
609
+ const links = getLinks();
610
+ const sorted = sortBlockLinksBySource(links);
611
+ const gutterRect = gutter.getBoundingClientRect();
612
+ const w = gutterRect.width;
613
+ const h = gutterRect.height;
614
+ if (w <= 0 || h <= 0 || sorted.length === 0) {
615
+ svg.replaceChildren();
616
+ return;
617
+ }
618
+ const activeId = activeBlockIdForViewport(links, probeTopSourceLine1Based());
619
+ svg.setAttribute("viewBox", `0 0 ${String(w)} ${String(h)}`);
620
+ svg.setAttribute("preserveAspectRatio", "none");
621
+ const parts = [];
622
+ for (let i = 0; i < sorted.length; i++) {
623
+ const link = sorted[i];
624
+ if (!link)
625
+ continue;
626
+ const next = sorted[i + 1];
627
+ const i0 = codeLineDomIndex0(link.sourceStart);
628
+ const i1 = codeLineDomIndex0(link.sourceEnd);
629
+ const codeTop = document.getElementById(`code-line-${String(i0)}`);
630
+ const codeBot = document.getElementById(`code-line-${String(i1)}`);
631
+ const docTop = document.getElementById(`commentray-block-${link.id}`);
632
+ if (!codeTop || !codeBot || !docTop)
633
+ continue;
634
+ const docEndYViewport = commentaryBandEndYViewport(docScrollEl, next, docTop);
635
+ const yCodeTop = codeLineHighlightCenterYViewport(codeTop);
636
+ const yCodeBot = codeLineHighlightCenterYViewport(codeBot);
637
+ const yDocTop = docTop.getBoundingClientRect().top + 2;
638
+ const yDocEnd = Math.max(docEndYViewport, yDocTop + 4);
639
+ const c0 = clampViewportYToGutterLocal(yCodeTop, gutterRect.top, h);
640
+ const c1 = clampViewportYToGutterLocal(yDocTop, gutterRect.top, h);
641
+ const c2 = clampViewportYToGutterLocal(yCodeBot, gutterRect.top, h);
642
+ const c3 = clampViewportYToGutterLocal(yDocEnd, gutterRect.top, h);
643
+ const strokeClass = link.id === activeId ? "gutter__rays-path gutter__rays-path--active" : "gutter__rays-path";
644
+ const trailClass = `${strokeClass} gutter__rays-path--trail`;
645
+ const topPaths = gutterRayBezierPaths(0, c0.y, w, c1.y, {
646
+ tension: 0.38,
647
+ clipStart: c0.clipped,
648
+ clipEnd: c1.clipped,
649
+ });
650
+ const botPaths = gutterRayBezierPaths(0, c2.y, w, c3.y, {
651
+ tension: 0.38,
652
+ clipStart: c2.clipped,
653
+ clipEnd: c3.clipped,
654
+ });
655
+ const topExtra = topPaths.dotted ? `<path class="${trailClass}" d="${topPaths.dotted}" />` : "";
656
+ const botExtra = botPaths.dotted ? `<path class="${trailClass}" d="${botPaths.dotted}" />` : "";
657
+ parts.push(`<g class="gutter__rays-block" data-commentray-block="${escapeHtmlText(link.id)}">` +
658
+ `<path class="${strokeClass}" d="${topPaths.solid}" />` +
659
+ topExtra +
660
+ `<path class="${strokeClass}" d="${botPaths.solid}" />` +
661
+ botExtra +
662
+ `</g>`);
663
+ }
664
+ svg.innerHTML = parts.join("");
665
+ }
666
+ /**
667
+ * Splines in the gutter between each block’s source range and its commentary band (dual pane,
668
+ * index-backed blocks). Emphasizes the block aligned with the current source viewport; clamps
669
+ * off-screen endpoints so readers see which way to scroll.
670
+ */
671
+ function wireBlockRayConnectors(args) {
672
+ const { gutter, codePane, docScrollEl, getLinks, probeTopSourceLine1Based } = args;
673
+ const svgNs = "http://www.w3.org/2000/svg";
674
+ const host = document.createElement("div");
675
+ host.className = "gutter__rays";
676
+ host.setAttribute("aria-hidden", "true");
677
+ const svg = document.createElementNS(svgNs, "svg");
678
+ host.appendChild(svg);
679
+ gutter.appendChild(host);
680
+ let raf = 0;
681
+ function scheduleDraw() {
682
+ if (raf !== 0)
683
+ return;
684
+ raf = globalThis.requestAnimationFrame(() => {
685
+ raf = 0;
686
+ drawBlockRaysIntoSvg(svg, gutter, docScrollEl, getLinks, probeTopSourceLine1Based);
687
+ });
688
+ }
689
+ subscribeBlockRayRedraw(gutter, codePane, docScrollEl, scheduleDraw);
690
+ scheduleDraw();
691
+ /** First paint can report gutter height 0 before flex layout settles; redraw after layout. */
692
+ globalThis.requestAnimationFrame(() => {
693
+ scheduleDraw();
694
+ globalThis.requestAnimationFrame(scheduleDraw);
695
+ });
696
+ }
480
697
  function isDocumentedPairNav(x) {
481
698
  if (typeof x !== "object" || x === null)
482
699
  return false;
483
700
  const o = x;
484
- return (typeof o.sourcePath === "string" &&
485
- typeof o.commentrayPath === "string" &&
486
- typeof o.sourceOnGithub === "string" &&
487
- typeof o.commentrayOnGithub === "string" &&
488
- (o.staticBrowseUrl === undefined || typeof o.staticBrowseUrl === "string"));
701
+ if (typeof o.sourcePath !== "string" || typeof o.commentrayPath !== "string")
702
+ return false;
703
+ if (o.staticBrowseUrl !== undefined && typeof o.staticBrowseUrl !== "string")
704
+ return false;
705
+ const sg = o.sourceOnGithub;
706
+ const cg = o.commentrayOnGithub;
707
+ const hasSg = typeof sg === "string";
708
+ const hasCg = typeof cg === "string";
709
+ if (hasSg !== hasCg)
710
+ return false;
711
+ const browseOk = typeof o.staticBrowseUrl === "string" && o.staticBrowseUrl.trim().length > 0;
712
+ if (!browseOk && !hasSg)
713
+ return false;
714
+ return true;
489
715
  }
490
716
  function pairsFromJsonArray(raw) {
491
717
  const pairs = [];
@@ -497,27 +723,6 @@ function pairsFromJsonArray(raw) {
497
723
  }
498
724
  return pairs;
499
725
  }
500
- function pathRowsFromDocumentedPairs(pairs) {
501
- const seen = new Set();
502
- const out = [];
503
- let line = 0;
504
- for (const p of pairs) {
505
- for (const seg of [p.sourcePath, p.commentrayPath]) {
506
- const t = seg.trim();
507
- if (!t || seen.has(t))
508
- continue;
509
- seen.add(t);
510
- out.push({
511
- kind: "path",
512
- line: line++,
513
- text: t,
514
- spPath: p.sourcePath,
515
- crPath: p.commentrayPath,
516
- });
517
- }
518
- }
519
- return out;
520
- }
521
726
  function commentrayLineRowFromNavJson(r) {
522
727
  if (r.kind !== "commentrayLine")
523
728
  return null;
@@ -612,6 +817,25 @@ function treeFileLinkLabel(pr, disambiguate) {
612
817
  const stem = companionDocStem(pr.commentrayPath);
613
818
  return stem !== "" && stem !== base ? `${base} · ${stem}` : base;
614
819
  }
820
+ /** Prefer static hub browse page; optional SCM blob URL when the export has no `staticBrowseUrl`. */
821
+ function treeFileLinkHref(pr) {
822
+ const browse = (pr.staticBrowseUrl ?? "").trim();
823
+ if (browse.length > 0) {
824
+ return resolveStaticBrowseHref(browse, globalThis.location.pathname, globalThis.location.origin);
825
+ }
826
+ const gh = (pr.commentrayOnGithub ?? "").trim();
827
+ return gh.length > 0 ? gh : "#";
828
+ }
829
+ function treeFileLinkTitle(pr) {
830
+ const browse = (pr.staticBrowseUrl ?? "").trim();
831
+ if (browse.length > 0) {
832
+ return `${pr.sourcePath} — open this pair in the site viewer`;
833
+ }
834
+ if ((pr.commentrayOnGithub ?? "").trim().length > 0) {
835
+ return `${pr.sourcePath} — open companion commentray on the repository host`;
836
+ }
837
+ return pr.sourcePath;
838
+ }
615
839
  function renderDocumentedTreeHtml(node) {
616
840
  const keys = [...node.children.keys()].sort((a, b) => a.localeCompare(b));
617
841
  if (keys.length === 0)
@@ -629,19 +853,23 @@ function renderDocumentedTreeHtml(node) {
629
853
  const multi = ch.pairs.length > 1;
630
854
  for (const pr of ch.pairs) {
631
855
  const label = escapeHtmlText(treeFileLinkLabel(pr, multi));
632
- const title = escapeHtmlText(`${pr.sourcePath} — open companion on GitHub`);
856
+ const title = escapeHtmlText(treeFileLinkTitle(pr));
857
+ const href = escapeHtmlText(treeFileLinkHref(pr));
858
+ const useSiteBrowse = (pr.staticBrowseUrl?.trim() ?? "").length > 0;
859
+ const external = useSiteBrowse ? "" : ' target="_blank" rel="noopener noreferrer"';
633
860
  lis.push(`<li><div class="tree-file">` +
634
- `<a class="tree-file-link" href="${escapeHtmlText(pr.commentrayOnGithub)}" target="_blank" rel="noopener noreferrer" title="${title}">${label}</a>` +
861
+ `<a class="tree-file-link" href="${href}"${external} title="${title}">${label}</a>` +
635
862
  `</div></li>`);
636
863
  }
637
864
  }
638
865
  }
639
866
  return `<ul>${lis.join("")}</ul>`;
640
867
  }
641
- function renderDocumentedPairsIntoHost(treeHost, pairs) {
868
+ function renderDocumentedPairsIntoHost(treeHost, pairs, emptyBecauseFilter) {
642
869
  if (pairs.length === 0) {
643
- treeHost.innerHTML =
644
- '<p class="nav-rail__doc-hub-hint" role="status">No documented pairs in this export.</p>';
870
+ treeHost.innerHTML = emptyBecauseFilter
871
+ ? '<p class="nav-rail__doc-hub-hint" role="status">No paths match this filter.</p>'
872
+ : '<p class="nav-rail__doc-hub-hint" role="status">No documented pairs in this export.</p>';
645
873
  return;
646
874
  }
647
875
  const root = { children: new Map(), pairs: [] };
@@ -680,6 +908,7 @@ function loadDocumentedPairs(jsonUrl, embeddedB64) {
680
908
  function wireDocumentedFilesTree() {
681
909
  const hub = document.getElementById("documented-files-hub");
682
910
  const treeHost = document.getElementById("documented-files-tree");
911
+ const filterInput = document.getElementById("documented-files-filter");
683
912
  const shell = document.getElementById("shell");
684
913
  if (!(hub instanceof HTMLDetailsElement) || !(treeHost instanceof HTMLElement)) {
685
914
  return;
@@ -690,12 +919,23 @@ function wireDocumentedFilesTree() {
690
919
  if (jsonUrl.length === 0 && embeddedB64.length === 0)
691
920
  return;
692
921
  const ensureLoaded = loadDocumentedPairs(jsonUrl, embeddedB64);
922
+ let cachedPairs = null;
923
+ function applyFilterAndRender() {
924
+ if (cachedPairs === null)
925
+ return;
926
+ const q = filterInput instanceof HTMLInputElement ? filterInput.value : "";
927
+ const pairs = filterPairsByDocumentedTreeQuery(cachedPairs, q);
928
+ const filterActive = q.trim().length > 0;
929
+ renderDocumentedPairsIntoHost(treeMount, pairs, filterActive && cachedPairs.length > 0 && pairs.length === 0);
930
+ }
693
931
  async function hydrateTree() {
694
932
  try {
695
933
  const pairs = await ensureLoaded();
696
- renderDocumentedPairsIntoHost(treeMount, pairs);
934
+ cachedPairs = pairs;
935
+ applyFilterAndRender();
697
936
  }
698
937
  catch {
938
+ cachedPairs = null;
699
939
  treeMount.innerHTML =
700
940
  '<p class="nav-rail__doc-hub-hint" role="alert">Could not load the file list.</p>';
701
941
  }
@@ -705,6 +945,13 @@ function wireDocumentedFilesTree() {
705
945
  return;
706
946
  void hydrateTree();
707
947
  });
948
+ if (filterInput instanceof HTMLInputElement) {
949
+ filterInput.addEventListener("input", () => {
950
+ if (!hub.open || cachedPairs === null)
951
+ return;
952
+ applyFilterAndRender();
953
+ });
954
+ }
708
955
  }
709
956
  function wireSplitter(storageSplit, shell, codePane, gutter, initialPct) {
710
957
  let dragging = false;
@@ -805,7 +1052,46 @@ function initialCommentrayScopePathState(shell, scope, filePathLabel, commentray
805
1052
  : [filePathLabel, commentrayPathLabel].filter((s) => s.trim().length > 0).join("\n");
806
1053
  return { documentedPairs, pathRowsForOrdering, pathBlobWide };
807
1054
  }
808
- function wireDualPaneNavSearchFetch(navSearchUrl, indexState, mutable, rebuildSearcher, searchInput) {
1055
+ /**
1056
+ * Fetched `commentray-nav-search.json` sometimes omits `staticBrowseUrl` on pairs; the hub embed
1057
+ * carries browse URLs from the same build — merge so search hits open `_site/browse/…`, not GitHub.
1058
+ */
1059
+ function mergeFetchedDocumentedPairsWithEmbeddedBrowse(embedded, fetched) {
1060
+ if (fetched.length === 0)
1061
+ return embedded;
1062
+ if (embedded.length === 0)
1063
+ return fetched;
1064
+ const browseByCr = new Map();
1065
+ for (const p of embedded) {
1066
+ const b = (p.staticBrowseUrl ?? "").trim();
1067
+ if (b.length === 0)
1068
+ continue;
1069
+ browseByCr.set(normPosixPath(p.commentrayPath), b);
1070
+ }
1071
+ return fetched.map((p) => {
1072
+ const have = (p.staticBrowseUrl ?? "").trim();
1073
+ if (have.length > 0)
1074
+ return p;
1075
+ const fromEmb = browseByCr.get(normPosixPath(p.commentrayPath));
1076
+ if (fromEmb !== undefined && fromEmb.length > 0) {
1077
+ return { ...p, staticBrowseUrl: fromEmb };
1078
+ }
1079
+ return p;
1080
+ });
1081
+ }
1082
+ function resolvedNavSearchJsonUrl(shell) {
1083
+ const raw = shell.getAttribute("data-nav-search-json-url")?.trim() ?? "";
1084
+ if (raw.length === 0)
1085
+ return "";
1086
+ try {
1087
+ return new URL(raw, globalThis.location.href).href;
1088
+ }
1089
+ catch {
1090
+ return raw;
1091
+ }
1092
+ }
1093
+ function wireDualPaneNavSearchFetch(shell, embeddedPairs, indexState, mutable, rebuildSearcher, searchInput) {
1094
+ const navSearchUrl = resolvedNavSearchJsonUrl(shell);
809
1095
  if (navSearchUrl.length === 0)
810
1096
  return;
811
1097
  void (async () => {
@@ -815,9 +1101,10 @@ function wireDualPaneNavSearchFetch(navSearchUrl, indexState, mutable, rebuildSe
815
1101
  return;
816
1102
  const doc = (await res.json());
817
1103
  const fetched = pairsFromJsonArray(doc.documentedPairs);
818
- if (fetched.length > 0) {
819
- indexState.documentedPairs = fetched;
820
- mutable.documentedPairs = fetched;
1104
+ const mergedPairs = mergeFetchedDocumentedPairsWithEmbeddedBrowse(embeddedPairs, fetched);
1105
+ if (mergedPairs.length > 0) {
1106
+ indexState.documentedPairs = mergedPairs;
1107
+ mutable.documentedPairs = mergedPairs;
821
1108
  }
822
1109
  const nr = rowsFromNavSearchJson(doc);
823
1110
  if (nr.length === 0)
@@ -837,10 +1124,9 @@ function wireDualPaneNavSearchFetch(navSearchUrl, indexState, mutable, rebuildSe
837
1124
  })();
838
1125
  }
839
1126
  function wireDualPaneMultiAngleAndScroll(args) {
840
- const { codePane, docScrollEl, docBody, shell, scrollLinks, multiPayload, mutable, rebuildSearcher, searchInput, searchResults, } = args;
841
- const activeLinks = { current: scrollLinks };
1127
+ const { codePane, docScrollEl, docBody, shell, scrollLinksRef, multiPayload, mutable, rebuildSearcher, searchInput, searchResults, } = args;
842
1128
  if (multiPayload) {
843
- wireBlockAwareScrollSync(codePane, docScrollEl, () => activeLinks.current);
1129
+ wireBlockAwareScrollSync(codePane, docScrollEl, () => scrollLinksRef.current);
844
1130
  const angleSel = document.getElementById("angle-select");
845
1131
  if (angleSel && docBody) {
846
1132
  angleSel.addEventListener("change", () => {
@@ -848,16 +1134,39 @@ function wireDualPaneMultiAngleAndScroll(args) {
848
1134
  if (!a)
849
1135
  return;
850
1136
  docBody.innerHTML = decodeBase64Utf8(a.docInnerHtmlB64);
1137
+ runMermaidOnFreshDocNodes(docBody);
851
1138
  mutable.rawMd = decodeBase64Utf8(a.rawMdB64);
852
1139
  mutable.mdLines = mutable.rawMd.split("\n");
853
1140
  mutable.commentrayPathLabel = a.commentrayPathForSearch;
854
1141
  rebuildSearcher();
855
- activeLinks.current = parseScrollBlockLinksFromShell(a.scrollBlockLinksB64);
1142
+ scrollLinksRef.current = parseScrollBlockLinksFromShell(a.scrollBlockLinksB64);
856
1143
  shell.setAttribute("data-scroll-block-links-b64", a.scrollBlockLinksB64);
857
1144
  shell.setAttribute("data-search-commentray-path", a.commentrayPathForSearch);
1145
+ const docPathEl = document.getElementById("nav-rail-doc-path");
1146
+ if (docPathEl) {
1147
+ const path = a.commentrayPathForSearch.trim();
1148
+ docPathEl.textContent = path.length > 0 ? path : "—";
1149
+ if (path.length > 0)
1150
+ docPathEl.setAttribute("title", path);
1151
+ else
1152
+ docPathEl.removeAttribute("title");
1153
+ }
858
1154
  const gh = document.getElementById("toolbar-commentray-github");
859
- if (gh instanceof HTMLAnchorElement && a.commentrayOnGithubUrl?.trim()) {
860
- gh.href = a.commentrayOnGithubUrl.trim();
1155
+ if (gh instanceof HTMLAnchorElement) {
1156
+ const browse = a.staticBrowseUrl?.trim() ?? "";
1157
+ if (browse.length > 0) {
1158
+ gh.href = resolveStaticBrowseHref(browse, globalThis.location.pathname, globalThis.location.origin);
1159
+ gh.removeAttribute("target");
1160
+ gh.setAttribute("rel", "noopener");
1161
+ }
1162
+ else {
1163
+ const ghu = a.commentrayOnGithubUrl?.trim();
1164
+ if (ghu) {
1165
+ gh.href = ghu;
1166
+ gh.target = "_blank";
1167
+ gh.setAttribute("rel", "noopener noreferrer");
1168
+ }
1169
+ }
861
1170
  }
862
1171
  searchInput.value = "";
863
1172
  searchResults.innerHTML = "";
@@ -866,8 +1175,8 @@ function wireDualPaneMultiAngleAndScroll(args) {
866
1175
  }
867
1176
  return;
868
1177
  }
869
- if (scrollLinks.length > 0) {
870
- wireBlockAwareScrollSync(codePane, docScrollEl, () => scrollLinks);
1178
+ if (scrollLinksRef.current.length > 0) {
1179
+ wireBlockAwareScrollSync(codePane, docScrollEl, () => scrollLinksRef.current);
871
1180
  return;
872
1181
  }
873
1182
  wireProportionalScrollSync(codePane, docScrollEl);
@@ -896,6 +1205,7 @@ function wireDualPaneCodeBrowser(shell, codePane) {
896
1205
  const rawCode = decodeBase64Utf8(rawCodeB64);
897
1206
  const rawMd = decodeBase64Utf8(rawMdB64);
898
1207
  const scrollLinks = parseScrollBlockLinksFromShell(shell.getAttribute("data-scroll-block-links-b64") || "");
1208
+ const scrollLinksRef = { current: scrollLinks };
899
1209
  const { scope, filePathLabel, commentrayPathLabel } = readSearchScopeFromShell(shell);
900
1210
  const pathInit = initialCommentrayScopePathState(shell, scope, filePathLabel, commentrayPathLabel);
901
1211
  const indexState = {
@@ -934,8 +1244,7 @@ function wireDualPaneCodeBrowser(shell, codePane) {
934
1244
  searchResults,
935
1245
  docScrollEl,
936
1246
  });
937
- const navSearchUrl = shell.getAttribute("data-nav-search-json-url")?.trim() ?? "";
938
- wireDualPaneNavSearchFetch(navSearchUrl, indexState, mutable, rebuildSearcher, searchInput);
1247
+ wireDualPaneNavSearchFetch(shell, pathInit.documentedPairs, indexState, mutable, rebuildSearcher, searchInput);
939
1248
  const pct0 = parseFloat(readWebStorageItem(localStorage, STORAGE_SPLIT_PCT) || "50");
940
1249
  const pct = clamp(Number.isFinite(pct0) ? pct0 : 50, 15, 85);
941
1250
  codePane.style.flex = `0 0 ${pct}%`;
@@ -948,13 +1257,22 @@ function wireDualPaneCodeBrowser(shell, codePane) {
948
1257
  docScrollEl,
949
1258
  docBody,
950
1259
  shell,
951
- scrollLinks,
1260
+ scrollLinksRef,
952
1261
  multiPayload,
953
1262
  mutable,
954
1263
  rebuildSearcher,
955
1264
  searchInput,
956
1265
  searchResults,
957
1266
  });
1267
+ if (scrollLinksRef.current.length > 0) {
1268
+ wireBlockRayConnectors({
1269
+ gutter,
1270
+ codePane,
1271
+ docScrollEl,
1272
+ getLinks: () => scrollLinksRef.current,
1273
+ probeTopSourceLine1Based: () => probeCodeLine1FromViewport(codePane),
1274
+ });
1275
+ }
958
1276
  wireDualPaneCommentrayLocationHash(docScrollEl, () => mutable.mdLines.length);
959
1277
  }
960
1278
  function main() {