@geml/geml 1.1.1 → 1.3.2

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.
package/dist/render.js CHANGED
@@ -15,21 +15,37 @@ const PALETTE = ["#2563eb", "#dc2626", "#059669", "#d97706", "#7c3aed", "#db2777
15
15
  // ---------------------------------------------------------------------------
16
16
  // Escaping
17
17
  // ---------------------------------------------------------------------------
18
- function esc(s) {
18
+ export function esc(s) {
19
19
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
20
20
  }
21
- function escAttr(s) {
21
+ export function escAttr(s) {
22
22
  return esc(s).replace(/"/g, "&quot;");
23
23
  }
24
+ // Build a `class="…"` value from document-author-controlled tokens. Class names
25
+ // are dropped to the HTML token charset ([A-Za-z0-9_-]) so a crafted `.class`
26
+ // (e.g. `.x" onmouseover="alert(1)`) cannot break out of the attribute, then the
27
+ // joined result is escAttr'd as well (defense-in-depth). §4.
28
+ function classAttr(tokens) {
29
+ const safe = tokens
30
+ .map((t) => t.replace(/[^A-Za-z0-9_-]/g, ""))
31
+ .filter((t) => t !== "");
32
+ return escAttr(safe.join(" "));
33
+ }
34
+ // Maximum block-nesting depth the renderer will descend before bailing out with
35
+ // a diagnostic instead of overflowing the call stack (block()↔list()↔typed() are
36
+ // mutually recursive). 256 is far past any legitimate document yet well under the
37
+ // few-thousand-frame native stack limit. Kept in step with the parser's cap.
38
+ const MAX_NESTING = 256;
24
39
  // ---------------------------------------------------------------------------
25
40
  // Render context
26
41
  // ---------------------------------------------------------------------------
27
- class RenderCtx {
42
+ export class RenderCtx {
28
43
  doc;
29
44
  opts;
30
45
  usedMath = false;
31
46
  usedMermaid = false;
32
47
  usedCodeGraph = false;
48
+ renderDepth = 0;
33
49
  labels = new Map(); // id -> link label for [[#id]] auto-refs
34
50
  constructor(doc, opts = {}) {
35
51
  this.doc = doc;
@@ -121,6 +137,20 @@ class RenderCtx {
121
137
  }
122
138
  // ----- blocks -----
123
139
  block(b) {
140
+ // Guard the block()↔list()↔typed() mutual recursion so a pathologically
141
+ // nested document degrades to a diagnostic rather than a RangeError.
142
+ if (this.renderDepth >= MAX_NESTING) {
143
+ return `<div class="render-error">block nesting too deep (max ${MAX_NESTING})</div>`;
144
+ }
145
+ this.renderDepth++;
146
+ try {
147
+ return this.blockInner(b);
148
+ }
149
+ finally {
150
+ this.renderDepth--;
151
+ }
152
+ }
153
+ blockInner(b) {
124
154
  switch (b.kind) {
125
155
  case "hidden": return "";
126
156
  case "heading": {
@@ -172,10 +202,17 @@ class RenderCtx {
172
202
  this.usedMath = true;
173
203
  return `<div class="math-block"${idAttr}>\\[${esc(raw)}\\]</div>`;
174
204
  case "note": {
175
- const classes = ["callout", b.type, ...b.classes].join(" ");
205
+ const classes = classAttr(["callout", b.type, ...b.classes]);
176
206
  const inner = (b.children ?? []).map((c) => this.block(c)).filter((s) => s).join("\n");
177
207
  return `<aside class="${classes}"${idAttr}>\n${inner}\n</aside>`;
178
208
  }
209
+ case "text": {
210
+ // Addressable prose (§3): flow children in a NEUTRAL container — the
211
+ // block exists for its id/attrs, not for callout chrome (that's note).
212
+ const inner = (b.children ?? []).map((c) => this.block(c)).filter((s) => s).join("\n");
213
+ const classes = classAttr(["text", ...b.classes]);
214
+ return `<div class="${classes}"${idAttr}>\n${inner}\n</div>`;
215
+ }
179
216
  case "table":
180
217
  return b.table ? this.table(b.table, b.id, caption) : `<p class="render-error">table failed to parse</p>`;
181
218
  case "diagram":
@@ -243,7 +280,10 @@ class RenderCtx {
243
280
  // model keeps every row: charts, computed summaries and the code-graph
244
281
  // never read the HTML). Codemap edge tables additionally fold shut —
245
282
  // they are machine data; elsewhere the table is content and stays open.
246
- const maxRows = this.opts.tableRows ?? 500;
283
+ // The codemap index's #modules table IS the page's content — the module
284
+ // inventory people scan and filter — so it renders in full. Edge tables
285
+ // (#calls / #called-by) stay previewed+folded: machine data at scale.
286
+ const maxRows = this.isCodemapDoc && id === "modules" ? Infinity : (this.opts.tableRows ?? 500);
247
287
  const allRows = t.rows;
248
288
  const rows = allRows.length > maxRows ? allRows.slice(0, maxRows) : allRows;
249
289
  // Coverage grid for declared spans, so cells a span covers are not emitted.
@@ -251,8 +291,12 @@ class RenderCtx {
251
291
  rows.forEach((row, r) => row.forEach((cell, c) => {
252
292
  if (!cell.span)
253
293
  return;
254
- for (let dr = 0; dr < cell.span.rows; dr++)
255
- for (let dc = 0; dc < cell.span.cols; dc++) {
294
+ // Bound the sweep to the rendered grid regardless of the declared span, so
295
+ // an oversized span can never drive an O(hugerows×hugecols) loop (DoS).
296
+ const spanRows = Math.min(cell.span.rows, rows.length - r);
297
+ const spanCols = Math.min(cell.span.cols, row.length - c);
298
+ for (let dr = 0; dr < spanRows; dr++)
299
+ for (let dc = 0; dc < spanCols; dc++) {
256
300
  if (dr === 0 && dc === 0)
257
301
  continue;
258
302
  const rr = r + dr, cc = c + dc;
@@ -313,7 +357,7 @@ function niceMax(v) {
313
357
  // depth-limited; the layered LAYOUT happens in the page runtime (draw time).
314
358
  // ---------------------------------------------------------------------------
315
359
  // Hard payload ceiling only — the VIEW paces itself: the runtime draws the
316
- // first 400 by BFS order and offers "+400"/"all" to walk deeper. The data in
360
+ // first 600 by BFS order and offers "+600"/"all" to walk deeper. The data in
317
361
  // the codemap documents is always complete regardless.
318
362
  const CG_MAX_NODES = 4000;
319
363
  // Tiny posix-path helpers (no node:path dependency in the renderer).
@@ -399,6 +443,13 @@ function buildCodeGraph(startRel, opts, view) {
399
443
  entryDocs.push(d);
400
444
  }
401
445
  }
446
+ // …plus documents whose app entry is FILE-level (app-entry-docs meta:
447
+ // top-level bootstrap code with no function symbol, e.g. a Nuxt app.vue).
448
+ for (const t of String(meta0["app-entry-docs"] ?? "").split(/\s+/).filter(Boolean)) {
449
+ const d = cgJoin(cgDir(start), t);
450
+ if (!entryDocs.includes(d))
451
+ entryDocs.push(d);
452
+ }
402
453
  return { data: { start, depth: 99, roots: [], nodes: {}, edges: [], mode: "modules", mods: list, medges: em, entryDocs } };
403
454
  }
404
455
  }
@@ -494,6 +545,8 @@ function buildCodeGraph(startRel, opts, view) {
494
545
  node.test = true;
495
546
  if (b.classes.includes("accessor"))
496
547
  node.acc = true;
548
+ if (b.classes.includes("app-entry"))
549
+ node.entry = true;
497
550
  idx.set(b.id, node);
498
551
  }
499
552
  cache.set(docRel, idx);
@@ -835,7 +888,7 @@ function trunc(s, n) {
835
888
  // ---------------------------------------------------------------------------
836
889
  // Page shell, inline CSS, inline interactivity JS
837
890
  // ---------------------------------------------------------------------------
838
- const CSS = `
891
+ export const CSS = `
839
892
  :root { --fg:#1f2328; --muted:#656d76; --bd:#d0d7de; --bg:#fff; --accent:#2563eb; --code-bg:#f6f8fa; }
840
893
  * { box-sizing: border-box; }
841
894
  body { margin:0; color:var(--fg); background:#fafbfc; font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,"PingFang SC","Microsoft Yahei",sans-serif; }
@@ -885,8 +938,16 @@ sup.fn a { font-size:.75em; }
885
938
  .geml-footer code { font-size:.95em; }
886
939
  .code-graph { margin:1.4em 0; }
887
940
  .cg-mount { border:1px solid var(--bd); border-radius:8px; padding:10px 12px; background:var(--bg); }
888
- .cg-scroll { overflow:auto; max-height:72vh; }
941
+ .cg-scroll { overflow:auto; min-height:52vh; max-height:72vh; }
889
942
  .cg-svg { display:block; }
943
+ .cg-search-wrap { position:relative; display:inline-block; }
944
+ .cg-search { font:12px/1.4 inherit; padding:2px 7px; border:1px solid var(--bd); border-radius:4px; background:var(--bg); color:var(--fg); min-width:13ch; }
945
+ .cg-search-menu { position:absolute; z-index:30; top:calc(100% + 2px); left:0; min-width:24ch; max-width:52ch; max-height:52vh; overflow:auto; background:var(--bg); border:1px solid var(--bd); border-radius:6px; box-shadow:0 6px 20px rgba(0,0,0,.18); }
946
+ .cg-search-row { display:block; width:100%; text-align:left; padding:4px 9px 4px 18px; border:0; background:none; color:var(--fg); cursor:pointer; font:12px/1.4 inherit; }
947
+ .cg-search-row:hover { background:var(--bd); }
948
+ .cg-search-count { position:sticky; top:0; padding:4px 9px; font-size:11px; opacity:.65; background:var(--bg); border-bottom:1px solid var(--bd); }
949
+ .cg-search-grp { padding:6px 9px 2px; font-size:11px; font-weight:600; opacity:.7; border-top:1px solid var(--bd); }
950
+ .cg-search-grp:first-of-type { border-top:0; }
890
951
  .cg-stage { display:flex; gap:10px; align-items:flex-start; }
891
952
  .cg-stage .cg-scroll { flex:1 1 auto; min-width:0; }
892
953
  .cg-src { flex:0 0 42%; max-width:46%; display:flex; flex-direction:column; border:1px solid var(--bd); border-radius:6px; overflow:hidden; background:var(--bg); }
@@ -927,7 +988,7 @@ sup.fn a { font-size:.75em; }
927
988
  .cg-svg.hl .cg-n.hl { opacity:1; }
928
989
  .cg-svg.hl .cg-e.hl { opacity:1; stroke-width:1.6; }
929
990
  `;
930
- const JS = `
991
+ export const JS = `
931
992
  (function () {
932
993
  function cmp(a, b) {
933
994
  var na = a.dataset.sort, nb = b.dataset.sort;
@@ -984,7 +1045,7 @@ export function codeGraphRuntime(root) {
984
1045
  // Arrow-marker ids must be unique per drawn svg — several mounts share one
985
1046
  // document, and duplicate ids would make every graph point at the first.
986
1047
  var arrowSeq = 0;
987
- function boot(mount, data0) {
1048
+ function boot(mount, data0, gpath) {
988
1049
  var data, out;
989
1050
  function setData(d) {
990
1051
  data = d;
@@ -1076,8 +1137,14 @@ export function codeGraphRuntime(root) {
1076
1137
  if (!p)
1077
1138
  return;
1078
1139
  var kk = keyOf(p);
1079
- if (kk && kk.indexOf("x:") !== 0 && roots.indexOf(kk) < 0)
1080
- roots.push(kk);
1140
+ if (kk && kk.indexOf("x:") !== 0) {
1141
+ if (roots.indexOf(kk) < 0)
1142
+ roots.push(kk);
1143
+ // Badge the module (or the group holding it): this is where the
1144
+ // program starts — the ▶ the label renderer prepends.
1145
+ if (nodes[kk])
1146
+ nodes[kk].appEntry = 1;
1147
+ }
1081
1148
  });
1082
1149
  var hasIn = {};
1083
1150
  edges.forEach(function (e) { hasIn[e[1]] = 1; });
@@ -1092,10 +1159,10 @@ export function codeGraphRuntime(root) {
1092
1159
  function homeData() {
1093
1160
  return data0.mode === "modules" && data0.mods ? deriveView([]) : data0;
1094
1161
  }
1095
- setData(homeData());
1162
+ setData(data0.mode === "modules" && data0.mods && gpath && gpath.length ? deriveView(gpath) : homeData());
1096
1163
  // scale null = fit-to-width on first draw. Left-right is the default —
1097
1164
  // call flow reads with the text; the toggle persists per reader.
1098
- var state = { roots: data.roots.slice(), trail: [], scale: null, dir: "LR", frame: null, cap: 400, showAcc: false };
1165
+ var state = { roots: data.roots.slice(), trail: [], scale: null, dir: "LR", frame: null, cap: 600, showAcc: false };
1099
1166
  // Direction survives module -> container navigation (each page is a fresh
1100
1167
  // document); best-effort only — file:// or the DOM stub may lack storage.
1101
1168
  try {
@@ -1276,7 +1343,9 @@ export function codeGraphRuntime(root) {
1276
1343
  // width no longer reserves room for it.
1277
1344
  function label(k) {
1278
1345
  var n = data.nodes[k];
1279
- var full = n.n + (n.more ? " ›" : "");
1346
+ // = this module (or group) holds an app entry — where the program
1347
+ // starts, from index meta entry= / app-entry-docs.
1348
+ var full = (n.appEntry || n.entry ? "▶ " : "") + n.n + (n.more ? " ›" : "");
1280
1349
  if (full.length <= 32)
1281
1350
  return full;
1282
1351
  return data.mode === "modules" ? "…" + full.slice(full.length - 31) : full.slice(0, 31) + "…";
@@ -1552,7 +1621,7 @@ export function codeGraphRuntime(root) {
1552
1621
  }
1553
1622
  catch (e) { /* stub */ }
1554
1623
  }
1555
- function openDoc(rel) {
1624
+ function openDoc(rel, gpath) {
1556
1625
  var lv = live();
1557
1626
  if (lv) {
1558
1627
  Promise.resolve(lv({ doc: rel })).then(function (nd) {
@@ -1565,7 +1634,7 @@ export function codeGraphRuntime(root) {
1565
1634
  // payload so its grouping tree derives; pushView alone would draw
1566
1635
  // the empty raw payload (nodes come out {}).
1567
1636
  if (nd.mode === "modules" && nd.mods)
1568
- boot(mount, nd);
1637
+ boot(mount, nd, gpath);
1569
1638
  else
1570
1639
  pushView(nd);
1571
1640
  }, function () { flash("cannot load " + rel); });
@@ -1646,9 +1715,12 @@ export function codeGraphRuntime(root) {
1646
1715
  seg("modules", function () { openDoc(navBase + "index.geml"); });
1647
1716
  sepEl();
1648
1717
  var modName = String(data.module || String(data.start || "").replace(/^.*\//, "").replace(/\.geml$/, "") || "container");
1718
+ // The middle crumb reads as the OVERVIEW level ("modules / <module>"),
1719
+ // so clicking it goes THERE — the module tier listing this container's
1720
+ // siblings — not a reload of the page you are already on.
1649
1721
  seg(modName, function () {
1650
1722
  if (live())
1651
- openDoc(String(data.start));
1723
+ openDoc(navBase + "index.geml", [modName.split("/")[0]]);
1652
1724
  else {
1653
1725
  state.trail = [];
1654
1726
  setData(homeData());
@@ -1659,8 +1731,12 @@ export function codeGraphRuntime(root) {
1659
1731
  sepEl();
1660
1732
  seg(data.dir === "up"
1661
1733
  ? "callers of " + (data.nodes[data.focus] ? data.nodes[data.focus].n : "") + (data.partial ? " (in-slice)" : "") + (Object.keys(data.nodes).length <= 1 ? " — none recorded" : "")
1662
- : state.trail.length ? "root: " + state.roots.map(function (k) { return data.nodes[k].n; }).join(", ")
1663
- : "roots: entry", null);
1734
+ : state.trail.length && state.roots.length === 1 ? "root: " + (data.nodes[state.roots[0]] || {}).n
1735
+ // Many roots = this IS the module's own view (its whole entry
1736
+ // list) — the methods are already on the graph; naming them all
1737
+ // here just makes a paragraph-long crumb.
1738
+ : state.trail.length ? "roots: entry"
1739
+ : "roots: entry", null);
1664
1740
  }
1665
1741
  bar.appendChild(crumb);
1666
1742
  var scroller = document.createElement("div");
@@ -1753,8 +1829,8 @@ export function codeGraphRuntime(root) {
1753
1829
  capInfo.textContent = "showing " + (s.total - s.capped) + " of " + s.total + " reachable";
1754
1830
  bar.appendChild(capInfo);
1755
1831
  var moreBtn = document.createElement("button");
1756
- moreBtn.textContent = "+400";
1757
- moreBtn.onclick = function () { state.cap += 400; draw(); };
1832
+ moreBtn.textContent = "+600";
1833
+ moreBtn.onclick = function () { state.cap += 600; draw(); };
1758
1834
  bar.appendChild(moreBtn);
1759
1835
  var allBtn = document.createElement("button");
1760
1836
  allBtn.textContent = "all";
@@ -1771,6 +1847,152 @@ export function codeGraphRuntime(root) {
1771
1847
  resetBtn.onclick = function () { state.trail = []; setData(homeData()); state.roots = data.roots.slice(); draw(); };
1772
1848
  bar.appendChild(resetBtn);
1773
1849
  }
1850
+ // Find a method by name -> jump to its node. Data source: a served page
1851
+ // (http) queries the /_search endpoint (top matches only — a huge index
1852
+ // never ships); a static page (file://) lazy-loads the compact
1853
+ // _index/search-index.js via <script> (fetch is CORS-blocked on file://,
1854
+ // a script tag is not). Picking a hit opens its FOCUSED call graph (B)
1855
+ // in place on a served page; on a static page (no live loader) it
1856
+ // navigates to the node's document (A). Alt-click always just locates.
1857
+ if (typeof location !== "undefined") { // browser only — skipped in the fake-DOM runtime test
1858
+ var searchWrap = document.createElement("span");
1859
+ searchWrap.className = "cg-search-wrap";
1860
+ var searchBox = document.createElement("input");
1861
+ searchBox.type = "search";
1862
+ searchBox.className = "cg-search";
1863
+ searchBox.placeholder = "find a method…";
1864
+ searchBox.setAttribute("aria-label", "Find a method by name");
1865
+ var searchMenu = document.createElement("div");
1866
+ searchMenu.className = "cg-search-menu";
1867
+ searchMenu.hidden = true;
1868
+ searchWrap.appendChild(searchBox);
1869
+ searchWrap.appendChild(searchMenu);
1870
+ bar.appendChild(searchWrap);
1871
+ var srvSearch = /^https?:$/.test(location.protocol);
1872
+ function withIndex(cb) {
1873
+ if (window.__gemlSearch)
1874
+ return cb(window.__gemlSearch);
1875
+ var s = document.createElement("script");
1876
+ s.src = navBase + "_index/search-index.js";
1877
+ s.onload = function () { cb(window.__gemlSearch || []); };
1878
+ s.onerror = function () { cb([]); };
1879
+ document.head.appendChild(s);
1880
+ }
1881
+ // Rank exactly like serve's /_search (exact > prefix > qualified-tail
1882
+ // prefix > substring), so both data paths order hits the same way.
1883
+ function hitScore(n, q) {
1884
+ if (n === q)
1885
+ return 0;
1886
+ if (n.indexOf(q) === 0)
1887
+ return 1;
1888
+ var c2 = n.lastIndexOf("::"), d = n.lastIndexOf(".");
1889
+ var cut = Math.max(c2 >= 0 ? c2 + 2 : 0, d >= 0 ? d + 1 : 0);
1890
+ if (cut > 0 && n.slice(cut).indexOf(q) === 0)
1891
+ return 2;
1892
+ return n.indexOf(q) >= 0 ? 3 : -1;
1893
+ }
1894
+ function candidates(q, cb) {
1895
+ q = q.trim().toLowerCase();
1896
+ if (q.length < 2)
1897
+ return cb({ total: 0, hits: [] });
1898
+ if (srvSearch) {
1899
+ fetch("/_search?q=" + encodeURIComponent(q))
1900
+ .then(function (r) { return r.ok ? r.json() : { total: 0, hits: [] }; })
1901
+ .then(function (a) { cb(a && a.hits ? a : { total: 0, hits: [] }); })
1902
+ .catch(function () { cb({ total: 0, hits: [] }); });
1903
+ }
1904
+ else {
1905
+ withIndex(function (rows) {
1906
+ var ranked = [];
1907
+ for (var i = 0; i < rows.length; i++) {
1908
+ var s = hitScore(String(rows[i][0]).toLowerCase(), q);
1909
+ if (s >= 0)
1910
+ ranked.push({ s: s, name: rows[i][0], doc: rows[i][1], id: rows[i][2] });
1911
+ }
1912
+ ranked.sort(function (a, b) { return a.s - b.s || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0); });
1913
+ // The lookup aliases bare member names to the same node — dedupe
1914
+ // on doc#id, keeping the best-ranked row.
1915
+ var seen = {}, hits = [];
1916
+ for (var j = 0; j < ranked.length; j++) {
1917
+ var rj = ranked[j];
1918
+ var k = rj.doc + "#" + rj.id;
1919
+ if (seen[k])
1920
+ continue;
1921
+ seen[k] = 1;
1922
+ hits.push(rj);
1923
+ }
1924
+ cb({ total: hits.length, hits: hits.slice(0, 100) });
1925
+ });
1926
+ }
1927
+ }
1928
+ function gotoHit(doc, id, locate) {
1929
+ searchMenu.hidden = true;
1930
+ if (live() && !locate) {
1931
+ showCallees(doc + "#" + id);
1932
+ return;
1933
+ }
1934
+ location.href = navBase + doc.replace(/\.geml$/, ".html") + "#" + encodeURIComponent(id);
1935
+ }
1936
+ var searchSeq = 0, searchTop = null; // best-ranked hit — Enter opens it
1937
+ searchBox.addEventListener("input", function () {
1938
+ var my = ++searchSeq, qv = searchBox.value;
1939
+ candidates(qv, function (res) {
1940
+ if (my !== searchSeq)
1941
+ return; // a newer keystroke already fired
1942
+ searchMenu.replaceChildren();
1943
+ var hits = res.hits || [];
1944
+ searchTop = hits.length ? hits[0] : null;
1945
+ if (!hits.length) {
1946
+ searchMenu.hidden = true;
1947
+ return;
1948
+ }
1949
+ // Honest count first — a capped list must say so.
1950
+ var count = document.createElement("div");
1951
+ count.className = "cg-search-count";
1952
+ count.textContent = (res.total > hits.length ? "showing " + hits.length + " of " + res.total + " matches" : res.total + (res.total === 1 ? " match" : " matches")) + " · Enter opens the first";
1953
+ searchMenu.appendChild(count);
1954
+ // Group by module (document), groups in best-hit order — hits arrive
1955
+ // globally ranked, so first appearance = the group's best rank.
1956
+ var order = [], byDoc = {};
1957
+ hits.forEach(function (c) {
1958
+ if (!byDoc[c.doc]) {
1959
+ byDoc[c.doc] = [];
1960
+ order.push(c.doc);
1961
+ }
1962
+ byDoc[c.doc].push(c);
1963
+ });
1964
+ order.forEach(function (doc) {
1965
+ var hd = document.createElement("div");
1966
+ hd.className = "cg-search-grp";
1967
+ hd.textContent = String(doc).replace(/\.geml$/, "").replace(/--/g, "/");
1968
+ searchMenu.appendChild(hd);
1969
+ byDoc[doc].forEach(function (c) {
1970
+ var row = document.createElement("button");
1971
+ row.className = "cg-search-row";
1972
+ row.type = "button";
1973
+ var nm = document.createElement("b");
1974
+ nm.textContent = c.name;
1975
+ row.appendChild(nm);
1976
+ row.onclick = function (ev) { gotoHit(c.doc, c.id, !!ev.altKey); };
1977
+ searchMenu.appendChild(row);
1978
+ });
1979
+ });
1980
+ searchMenu.hidden = false;
1981
+ });
1982
+ });
1983
+ searchBox.addEventListener("keydown", function (ev) {
1984
+ if (ev.key === "Escape") {
1985
+ searchMenu.hidden = true;
1986
+ searchBox.blur();
1987
+ }
1988
+ else if (ev.key === "Enter" && searchTop) {
1989
+ ev.preventDefault();
1990
+ gotoHit(searchTop.doc, searchTop.id, !!ev.altKey);
1991
+ }
1992
+ });
1993
+ document.addEventListener("click", function (ev) { if (!searchWrap.contains(ev.target))
1994
+ searchMenu.hidden = true; });
1995
+ } // end browser-only search box
1774
1996
  mount.appendChild(bar);
1775
1997
  mount.appendChild(stage);
1776
1998
  if (state.scale === null)
@@ -1784,6 +2006,33 @@ export function codeGraphRuntime(root) {
1784
2006
  else
1785
2007
  scroller.scrollTop = 1e6;
1786
2008
  }
2009
+ // Centre the CROSS axis (the fit-to-pane one): the tree fans out around
2010
+ // its midline, so a big graph clamped to the 2/3 scale floor would
2011
+ // otherwise open on an empty top/left corner with every node off-screen.
2012
+ // Reading the scroll extent forces the post-applyScale reflow; when the
2013
+ // cross axis already fits, the delta is ≤0 and this is a no-op. The
2014
+ // reading axis is untouched (root start, or far-end for callers above).
2015
+ // If the view holds an app entry (▶), aim the midline at the FIRST one
2016
+ // (roots first, then any node) instead of the geometric centre — the
2017
+ // reader lands where the program starts.
2018
+ var entryK = null;
2019
+ state.roots.concat(Object.keys(data.nodes)).some(function (ek) {
2020
+ var en = data.nodes[ek];
2021
+ if (en && (en.appEntry || en.entry) && pos[ek]) {
2022
+ entryK = ek;
2023
+ return true;
2024
+ }
2025
+ return false;
2026
+ });
2027
+ var aim = function (full, pane, at) { return Math.max(0, Math.min(full - pane, at - pane / 2)); };
2028
+ if (LR)
2029
+ scroller.scrollTop = entryK
2030
+ ? aim(scroller.scrollHeight, scroller.clientHeight, (pos[entryK].y + NH / 2) * state.scale)
2031
+ : Math.max(0, (scroller.scrollHeight - scroller.clientHeight) / 2);
2032
+ else
2033
+ scroller.scrollLeft = entryK
2034
+ ? aim(scroller.scrollWidth, scroller.clientWidth, (pos[entryK].x + pos[entryK].w / 2) * state.scale)
2035
+ : Math.max(0, (scroller.scrollWidth - scroller.clientWidth) / 2);
1787
2036
  // Footer: live facts, not a static cheat-sheet (navigation lives in
1788
2037
  // the breadcrumb above).
1789
2038
  var footer = document.createElement("div");
@@ -1823,16 +2072,35 @@ export function codeGraphRuntime(root) {
1823
2072
  // document loader (mount._cgView, attached by the upgrade step); a
1824
2073
  // static CLI page reverses its in-slice edges — partial but honest,
1825
2074
  // and labelled as such in the crumb.
2075
+ // No recorded callers (an app/framework entry, or dead code): "up" from
2076
+ // a METHOD is its CONTAINER — one level, never the whole-repo overview
2077
+ // two levels up. From a focused/derived view that means the method's own
2078
+ // container page (the view its module node opens); already sitting on
2079
+ // that default view, say why and stay put — the "don't jump, say why"
2080
+ // contract.
2081
+ function noCallers(k) {
2082
+ var docRel = k.slice(0, k.lastIndexOf("#"));
2083
+ if (docRel !== data0.start) {
2084
+ openDoc(navBase + docRel);
2085
+ return;
2086
+ }
2087
+ if (state.trail.length) {
2088
+ state.trail = [];
2089
+ setData(homeData());
2090
+ state.roots = data.roots.slice();
2091
+ draw();
2092
+ return;
2093
+ }
2094
+ flash("no recorded callers — an app/framework entry point");
2095
+ }
1826
2096
  function showCallers(k) {
1827
2097
  var lv = live();
1828
2098
  if (lv) {
1829
2099
  Promise.resolve(lv({ dir: "up", node: k })).then(function (nd) {
1830
- // In-degree-zero entry (agent/AOP hook, app top): it has no callers,
1831
- // so "up" means the module page — not an empty caller view.
1832
2100
  if (nd && Object.keys(nd.nodes).length > 1)
1833
2101
  pushView(nd);
1834
2102
  else
1835
- openDoc(navBase + "index.geml");
2103
+ noCallers(k);
1836
2104
  });
1837
2105
  return;
1838
2106
  }
@@ -1849,9 +2117,9 @@ export function codeGraphRuntime(root) {
1849
2117
  } });
1850
2118
  }
1851
2119
  if (Object.keys(keep).length <= 1) {
1852
- openDoc(navBase + "index.geml");
2120
+ noCallers(k);
1853
2121
  return;
1854
- } // no callers -> module page
2122
+ }
1855
2123
  var nodes = {}, edges = [];
1856
2124
  for (var nk in keep)
1857
2125
  nodes[nk] = data0.nodes[nk];
@@ -1941,16 +2209,22 @@ export function codeGraphRuntime(root) {
1941
2209
  var ub = tgt && tgt.closest ? tgt.closest(".cg-upbtn") : null;
1942
2210
  if (ub) {
1943
2211
  if (ub.getAttribute("data-act") === "down") {
1944
- // The mirrored handle flips back to the callee chain — the up
1945
- // view was pushed from there, so this is exactly one step back.
1946
- if (state.trail.length) {
2212
+ // "Back to its callee chain" must LAND on the callee chain: pop
2213
+ // the trail only when the view underneath IS this method's own
2214
+ // focused view (the search/chain path that opened these callers).
2215
+ // Arriving from anywhere wider — the module page — popping would
2216
+ // land there instead, so build the method's chain fresh.
2217
+ var k0 = ub.getAttribute("data-k");
2218
+ var top0 = state.trail[state.trail.length - 1];
2219
+ var ownChain = top0 && top0.data && top0.data.dir !== "up" && top0.roots && top0.roots.length === 1 && top0.roots[0] === k0;
2220
+ if (ownChain) {
1947
2221
  var tr0 = state.trail.pop();
1948
2222
  setData(tr0.data);
1949
2223
  state.roots = tr0.roots;
1950
2224
  draw();
1951
2225
  }
1952
2226
  else
1953
- showCallees(ub.getAttribute("data-k"));
2227
+ showCallees(k0);
1954
2228
  }
1955
2229
  else
1956
2230
  showCallers(ub.getAttribute("data-k"));
@@ -2048,7 +2322,7 @@ export function codeGraphRuntime(root) {
2048
2322
  });
2049
2323
  }
2050
2324
  // CLI inlining: the compiled runtime function, verbatim, run against document.
2051
- const CODE_GRAPH_JS = `(${codeGraphRuntime.toString()})(document);`;
2325
+ export const CODE_GRAPH_JS = `(${codeGraphRuntime.toString()})(document);`;
2052
2326
  // Browser-side wave builder: the slice builder is synchronous with a
2053
2327
  // synchronous loader, but a browser fetches documents asynchronously — so
2054
2328
  // run the build in WAVES: every pass records the documents it needed but did
@@ -2094,89 +2368,7 @@ export function codeGraphWaves(fetchDoc, parseFn) {
2094
2368
  },
2095
2369
  };
2096
2370
  }
2097
- function page(title, body, ctx, source) {
2098
- const mathHead = ctx.usedMath
2099
- ? `<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">\n` +
2100
- `<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>\n` +
2101
- `<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js" onload="renderMathInElement(document.body,{delimiters:[{left:'\\\\[',right:'\\\\]',display:true},{left:'\\\\(',right:'\\\\)',display:false}]})"></script>\n`
2102
- : "";
2103
- const mermaidHead = ctx.usedMermaid
2104
- ? `<script type="module">import m from "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";m.initialize({startOnLoad:true});</script>\n`
2105
- : "";
2106
- const footer = source
2107
- ? `<footer class="geml-footer">Rendered from <code>${esc(source)}</code> by the GEML runtime. Tables are sortable and filterable; the chart is inline SVG drawn from its bound table.</footer>`
2108
- : "";
2109
- // Live enhancement for served pages: attach _cgView loaders after the
2110
- // static bootstrap has drawn. The runtime reads the hook lazily, so late
2111
- // binding works with no redraw; if this module never loads (offline copy,
2112
- // old browser), the page simply stays static. The parser dist imports
2113
- // node:* for its CLI paths — an import map points those at the served stub
2114
- // (same trick as the viewer's esbuild alias), and the process shim must be
2115
- // in place BEFORE the modules evaluate, hence the dynamic import().
2116
- const wantLive = ctx.usedCodeGraph && !!ctx.opts.liveGraph;
2117
- const lg = wantLive ? escAttr(ctx.opts.liveGraph) : "";
2118
- const importMap = wantLive
2119
- ? `<script type="importmap">{"imports":{"node:fs":"${lg}_node-stub.js","node:path":"${lg}_node-stub.js","node:crypto":"${lg}_node-stub.js","node:url":"${lg}_node-stub.js","node:child_process":"${lg}_node-stub.js"}}</script>\n`
2120
- : "";
2121
- const liveJs = wantLive
2122
- ? `<script type="module">
2123
- globalThis.process ??= { argv: [], env: {} };
2124
- const { parse } = await import("${lg}geml.js");
2125
- const { codeGraphWaves } = await import("${lg}render.js");
2126
- const w = codeGraphWaves(async (rel) => {
2127
- try { const r = await fetch(rel, { cache: "no-cache" }); return r.ok ? await r.text() : null; } catch { return null; }
2128
- }, parse);
2129
- for (const m of document.querySelectorAll(".cg-mount[data-start]")) {
2130
- const start = m.getAttribute("data-start");
2131
- m._cgView = async (view) => {
2132
- // A directed view builds from the node's OWN document (its meta names the
2133
- // module and graph-depth); {doc} opens that document; else the mount's.
2134
- const src = view && view.doc ? view.doc
2135
- : view && view.node ? view.node.slice(0, view.node.lastIndexOf("#"))
2136
- : start;
2137
- const r = await w.build(src, view && view.doc ? undefined : view);
2138
- return r.error !== undefined ? null : r.data;
2139
- };
2140
- }
2141
- </script>\n`
2142
- : "";
2143
- return `<!doctype html>
2144
- <html lang="en">
2145
- <head>
2146
- <meta charset="utf-8">
2147
- <meta name="viewport" content="width=device-width, initial-scale=1">
2148
- <title>${esc(title)}</title>
2149
- <style>${CSS}</style>
2150
- ${importMap}${mathHead}${mermaidHead}</head>
2151
- <body>
2152
- <main>
2153
- ${body}
2154
- </main>
2155
- ${footer}
2156
- <script>${JS}</script>
2157
- ${ctx.usedCodeGraph ? `<script>${CODE_GRAPH_JS}</script>\n` : ""}${liveJs}</body>
2158
- </html>
2159
- `;
2160
- }
2161
2371
  // ---------------------------------------------------------------------------
2162
2372
  // Public entry
2163
2373
  // ---------------------------------------------------------------------------
2164
2374
  export { buildCodeGraph };
2165
- export function renderHtml(doc, opts = {}) {
2166
- const ctx = new RenderCtx(doc, opts);
2167
- let body = doc.children.map((b) => ctx.block(b)).filter((s) => s !== "").join("\n");
2168
- // Codemap scenario ① (GEP-0003): a codemap document (meta declares module=
2169
- // or container=, plus an entry surface) IS the graph data — offer the layered
2170
- // method-flow view at the top, an implicit self-embed.
2171
- const meta = doc.children.find((b) => b.kind === "block" && b.type === "meta" && b.data);
2172
- const md = meta?.data ?? {};
2173
- if ((md["module"] !== undefined || md["container"] !== undefined)
2174
- && opts.loadDoc && opts.parseDoc && opts.source) {
2175
- const cap = md["entry"] !== undefined || md["container"] !== undefined
2176
- ? `layered method flow — roots from this document's <code>entry</code>`
2177
- : `layered method flow — roots: in-degree-zero methods (no <code>entry</code> declared)`;
2178
- body = ctx.codeGraphFigure(opts.source, "", `<figcaption>${cap}</figcaption>`) + "\n" + body;
2179
- }
2180
- const title = opts.title ?? ctx.docTitle() ?? "GEML document";
2181
- return page(title, body, ctx, opts.source);
2182
- }