@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/codemap/serve.mjs CHANGED
@@ -20,11 +20,15 @@
20
20
  //
21
21
  // Local viewer by design: binds 127.0.0.1. HEAD is answered without a body —
22
22
  // the in-page navigation probes targets before embedding them.
23
+ //
24
+ // The pieces are exported (and the auto-run at the bottom is main-module
25
+ // guarded) so the test suite can drive them in-process; the CLI dispatcher
26
+ // always runs this file as a child's MAIN module, where nothing changes.
23
27
  import { createServer } from "node:http";
24
- import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync, openSync, unlinkSync, readdirSync, watch } from "node:fs";
25
- import { join, resolve, sep, basename, dirname } from "node:path";
28
+ import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync, openSync, unlinkSync, readdirSync, watch, realpathSync } from "node:fs";
29
+ import { join, resolve, sep, basename, dirname, relative } from "node:path";
26
30
  import { spawn } from "node:child_process";
27
- import { fileURLToPath } from "node:url";
31
+ import { fileURLToPath, pathToFileURL } from "node:url";
28
32
  import { parse, renderHtml } from "../dist/geml.js";
29
33
  import { buildCodeGraph } from "../dist/render.js";
30
34
  import { isSourcePath, SKIP_DIRS } from "./detect.mjs";
@@ -33,59 +37,62 @@ import { isSourcePath, SKIP_DIRS } from "./detect.mjs";
33
37
  // import the parser in the browser (live in-place navigation).
34
38
  const DIST_DIR = resolve(join(dirname(fileURLToPath(import.meta.url)), "..", "dist"));
35
39
 
36
- const args = process.argv.slice(2);
37
- const portIdx = args.indexOf("--port");
38
- const port = portIdx >= 0 ? Number(args[portIdx + 1]) : 8140;
39
- const background = args.includes("--background");
40
- const stop = args.includes("--stop");
41
- const noWarm = args.includes("--no-warm");
42
- const noOpen = args.includes("--no-open");
43
- const watchMode = args.includes("--watch");
44
- const cacheIdx = args.indexOf("--cache-mb");
45
- const cacheMb = cacheIdx >= 0 ? Number(args[cacheIdx + 1]) : 256;
46
- if (args.includes("--help") || args.includes("-h") || !Number.isInteger(port) || port <= 0 || !(cacheMb > 0)) {
47
- console.error("usage: geml codemap serve [codemap-dir] [--port 8140] [--cache-mb 256] [--no-warm] [--no-open] [--watch] [--background|--stop] (dir defaults to ./.geml-code-graph)");
48
- process.exit(2);
40
+ const USAGE = "usage: geml codemap serve [codemap-dir] [--port 8140] [--cache-mb 256] [--no-warm] [--no-open] [--watch] [--background|--stop] (dir defaults to ./.geml-code-graph)";
41
+
42
+ // argv -> options, or null on a usage error (--help included: the caller
43
+ // prints the usage line and exits 2 either way).
44
+ export function parseServeArgs(args) {
45
+ const portIdx = args.indexOf("--port");
46
+ const port = portIdx >= 0 ? Number(args[portIdx + 1]) : 8140;
47
+ const background = args.includes("--background");
48
+ const stop = args.includes("--stop");
49
+ const noWarm = args.includes("--no-warm");
50
+ const noOpen = args.includes("--no-open");
51
+ const watchMode = args.includes("--watch");
52
+ const cacheIdx = args.indexOf("--cache-mb");
53
+ const cacheMb = cacheIdx >= 0 ? Number(args[cacheIdx + 1]) : 256;
54
+ if (args.includes("--help") || args.includes("-h") || !Number.isInteger(port) || port <= 0 || !(cacheMb > 0)) return null;
55
+ const dir = args.find((a, i) => !a.startsWith("--") && (portIdx < 0 || i !== portIdx + 1) && (cacheIdx < 0 || i !== cacheIdx + 1)) || ".geml-code-graph";
56
+ return { dir, port, background, stop, noWarm, noOpen, watchMode, cacheMb };
49
57
  }
50
- const dir = args.find((a, i) => !a.startsWith("--") && (portIdx < 0 || i !== portIdx + 1) && (cacheIdx < 0 || i !== cacheIdx + 1)) || ".geml-code-graph";
51
- const root = resolve(dir);
52
- const runDir = join(root, "_index");
53
- const pidPath = join(runDir, "serve.pid");
54
- const logPath = join(runDir, "serve.log");
55
58
 
56
59
  // Project root for the source route: a method node's src path is
57
60
  // project-root-relative, so it always misses inside the codemap dir. The
58
61
  // recorded build recipe knows the root (_index/refresh.json "root", relative
59
62
  // to the codemap dir); without one, assume the codemap sits at <root>/<dir>.
60
- let srcRoot = resolve(root, "..");
61
- try { srcRoot = resolve(root, JSON.parse(readFileSync(join(root, "_index", "refresh.json"), "utf8")).root ?? ".."); } catch { /* no recipe: parent */ }
63
+ export function resolveSrcRoot(root) {
64
+ let srcRoot = resolve(root, "..");
65
+ try { srcRoot = resolve(root, JSON.parse(readFileSync(join(root, "_index", "refresh.json"), "utf8")).root ?? ".."); } catch { /* no recipe: parent */ }
66
+ return srcRoot;
67
+ }
62
68
 
63
- if (stop) {
64
- if (!existsSync(pidPath)) { console.error("codemap serve: no pid file — nothing to stop"); process.exit(0); }
69
+ // --stop: end a background server via its recorded pid. Returns the exit code.
70
+ export function stopServer({ pidPath }) {
71
+ if (!existsSync(pidPath)) { console.error("codemap serve: no pid file — nothing to stop"); return 0; }
65
72
  const pid = Number(readFileSync(pidPath, "utf8").trim());
66
- try {
67
- process.kill(pid);
68
- console.error(`codemap serve: stopped (pid ${pid})`);
69
- } catch {
70
- console.error(`codemap serve: pid ${pid} not running (stale pid file removed)`);
71
- }
73
+ let running = true;
74
+ try { process.kill(pid); } catch { running = false; }
75
+ // Remove the pid file BEFORE reporting — the old order announced "stale pid
76
+ // file removed" and only then attempted the unlink, so the message could
77
+ // outrun (or misstate) the actual removal.
72
78
  try { unlinkSync(pidPath); } catch { /* already gone */ }
73
- process.exit(0);
79
+ console.error(running
80
+ ? `codemap serve: stopped (pid ${pid})`
81
+ : `codemap serve: pid ${pid} not running (stale pid file removed)`);
82
+ return 0;
74
83
  }
75
84
 
76
- if (!existsSync(join(root, "index.geml")) && !existsSync(join(root, "index.html"))) {
77
- console.error(`error: ${root} has no index.geml not a codemap directory? (build one: geml codemap build)`);
78
- process.exit(1);
79
- }
80
-
81
- if (background) {
85
+ // --background: start a detached copy of this script and report once the port
86
+ // answers. Returns the exit code. `selfPath` defaults to this script
87
+ // (argv[1]); it is a parameter so tests can substitute a stand-in child.
88
+ export async function launchBackground({ dir, root, port, cacheMb, noWarm, watchMode, runDir, logPath }, selfPath = process.argv[1]) {
82
89
  // Already serving? Don't stack a second server on the port.
83
90
  try {
84
91
  const pre = await fetch(`http://127.0.0.1:${port}/`, { method: "HEAD" });
85
92
  if (pre.status > 0) {
86
93
  console.error(`codemap serve: port ${port} already answers — assuming it is up`);
87
94
  console.error(` -> http://localhost:${port}/ (stop: geml codemap serve ${dir} --stop)`);
88
- process.exit(0);
95
+ return 0;
89
96
  }
90
97
  } catch { /* nothing there: start one */ }
91
98
  // Detach fully: own process group, stdio to the log file — the child owes
@@ -93,7 +100,7 @@ if (background) {
93
100
  mkdirSync(runDir, { recursive: true });
94
101
  const logFd = openSync(logPath, "a");
95
102
  const child = spawn(process.execPath,
96
- [process.argv[1], root, "--port", String(port), "--cache-mb", String(cacheMb), "--no-open", ...(noWarm ? ["--no-warm"] : []), ...(watchMode ? ["--watch"] : [])],
103
+ [selfPath, root, "--port", String(port), "--cache-mb", String(cacheMb), "--no-open", ...(noWarm ? ["--no-warm"] : []), ...(watchMode ? ["--watch"] : [])],
97
104
  { detached: true, stdio: ["ignore", logFd, logFd] });
98
105
  child.unref();
99
106
  const deadline = Date.now() + 8000;
@@ -109,12 +116,12 @@ if (background) {
109
116
  let tail = "";
110
117
  try { tail = readFileSync(logPath, "utf8").split("\n").slice(-4).join("\n "); } catch { /* no log */ }
111
118
  console.error(`codemap serve: failed to start on port ${port}\n ${tail}`);
112
- process.exit(1);
119
+ return 1;
113
120
  }
114
121
  console.error(`codemap serve: running in background (pid ${child.pid}) — survives this session`);
115
122
  console.error(` -> http://localhost:${port}/`);
116
123
  console.error(` stop: geml codemap serve ${dir} --stop (log: ${logPath})`);
117
- process.exit(0);
124
+ return 0;
118
125
  }
119
126
 
120
127
  const MIME = {
@@ -126,201 +133,298 @@ const MIME = {
126
133
  ".js": "text/javascript; charset=utf-8",
127
134
  ".svg": "image/svg+xml",
128
135
  };
129
- const extOf = (p) => { const m = /\.[A-Za-z0-9]+$/.exec(p); return m ? m[0].toLowerCase() : ""; };
136
+ export const extOf = (p) => { const m = /\.[A-Za-z0-9]+$/.exec(p); return m ? m[0].toLowerCase() : ""; };
130
137
 
131
- // Parsed-document cache. Pages still render on every request (never stale:
132
- // entries are validated against mtime+size, so a rebuild is picked up on the
133
- // next hit), but a click-walk revisits the same multi-MB documents constantly
134
- // and re-parsing 7 MB of geml per request is pure waste. The LRU bound is a
135
- // TEXT-BYTE budget, not a document count: one page's graph slice can cross
136
- // hundreds of small documents (a count bound would thrash — evict and
137
- // re-parse the whole working set on every request), while a handful of
138
- // 7 MB documents is what actually threatens memory.
139
- const DOC_CACHE_BUDGET = cacheMb * 1024 * 1024; // --cache-mb, default 256
140
- const docCache = new Map(); // abs path -> { mtime, size, text, doc }
141
- const parsedByText = new Map(); // text (same instance as in docCache) -> doc
142
- let docCacheBytes = 0;
143
- const evict = (abs, entry) => {
144
- parsedByText.delete(entry.text);
145
- docCache.delete(abs);
146
- docCacheBytes -= entry.size;
147
- };
148
- const loadCached = (abs) => {
149
- let st;
150
- try { st = statSync(abs); } catch { return null; }
151
- const hit = docCache.get(abs);
152
- if (hit && hit.mtime === st.mtimeMs && hit.size === st.size) {
153
- docCache.delete(abs); docCache.set(abs, hit); // LRU touch
154
- return hit;
155
- }
156
- if (hit) evict(abs, hit);
157
- let text;
158
- try { text = readFileSync(abs, "utf8"); } catch { return null; }
159
- const entry = { mtime: st.mtimeMs, size: st.size, text, doc: parse(text) };
160
- docCache.set(abs, entry);
161
- parsedByText.set(text, entry.doc);
162
- docCacheBytes += entry.size;
163
- while (docCacheBytes > DOC_CACHE_BUDGET && docCache.size > 1) {
164
- const oldest = docCache.keys().next().value;
165
- evict(oldest, docCache.get(oldest));
166
- }
167
- return entry;
168
- };
169
- const loadDoc = (rel) => {
170
- const e = loadCached(join(root, rel));
171
- return e ? e.text : null;
172
- };
173
- // loadDoc hands out the cached string instance, so the by-text lookup hits
174
- // without re-hashing anything the render loop already loaded.
175
- const parseDoc = (s) => parsedByText.get(s) ?? parse(s);
138
+ // The parse cache, request handler, and http server for ONE codemap
139
+ // directory. `dir` is the raw argument spelling (for the --stop hint).
140
+ export function createApp({ dir, root, port, cacheMb, srcRoot }) {
141
+ // Flattened [name, doc, id] rows for the /_search endpoint, loaded once from
142
+ // name-lookup.json on first query (kept server-side so a huge index never
143
+ // ships to the browser).
144
+ let searchRows = null;
176
145
 
177
- const server = createServer((req, res) => {
178
- const send = (status, body, type) => {
179
- // never-stale extends to the BROWSER: without this, heuristic caching
180
- // keeps serving yesterday's pages and /_dist modules across restarts.
181
- res.writeHead(status, { "content-type": type || "text/plain; charset=utf-8", "cache-control": "no-cache" });
182
- res.end(req.method === "HEAD" ? undefined : body);
146
+ // Parsed-document cache. Pages still render on every request (never stale:
147
+ // entries are validated against mtime+size, so a rebuild is picked up on the
148
+ // next hit), but a click-walk revisits the same multi-MB documents constantly
149
+ // and re-parsing 7 MB of geml per request is pure waste. The LRU bound is a
150
+ // TEXT-BYTE budget, not a document count: one page's graph slice can cross
151
+ // hundreds of small documents (a count bound would thrash evict and
152
+ // re-parse the whole working set on every request), while a handful of
153
+ // 7 MB documents is what actually threatens memory.
154
+ const DOC_CACHE_BUDGET = cacheMb * 1024 * 1024; // --cache-mb, default 256
155
+ const docCache = new Map(); // abs path -> { mtime, size, text, doc }
156
+ const parsedByText = new Map(); // text (same instance as in docCache) -> doc
157
+ let docCacheBytes = 0;
158
+ const evict = (abs, entry) => {
159
+ parsedByText.delete(entry.text);
160
+ docCache.delete(abs);
161
+ docCacheBytes -= entry.size;
183
162
  };
184
- let urlPath;
185
- try {
186
- urlPath = decodeURIComponent(new URL(req.url, `http://127.0.0.1:${port}`).pathname);
187
- } catch {
188
- return send(400, "bad request");
189
- }
190
- if (urlPath.endsWith("/")) urlPath += "index.html";
191
-
192
- const done = (status) => console.error(`${req.method} ${urlPath} ${status}`);
193
- // Graph payloads as a sidecar: pages carry data-graph-src instead of a
194
- // multi-MB inline attribute; the runtime fetches this route after first
195
- // paint. Computed on demand from the SAME parse cache — never stale.
196
- if (urlPath === "/_graph") {
197
- let rel = "";
198
- try { rel = new URL(req.url, `http://127.0.0.1:${port}`).searchParams.get("doc") || ""; } catch { /* fall through */ }
199
- const target = resolve(join(root, "." + ("/" + rel).replace(/\//g, sep)));
200
- if (!rel.endsWith(".geml") || (target !== root && !target.startsWith(root + sep)) || !existsSync(target)) {
201
- done(404);
202
- return send(404, JSON.stringify({ error: `no such document: ${rel}` }), "application/json; charset=utf-8");
163
+ const loadCached = (abs) => {
164
+ let st;
165
+ try { st = statSync(abs); } catch { return null; }
166
+ const hit = docCache.get(abs);
167
+ if (hit && hit.mtime === st.mtimeMs && hit.size === st.size) {
168
+ docCache.delete(abs); docCache.set(abs, hit); // LRU touch
169
+ return hit;
203
170
  }
204
- try {
205
- const r = buildCodeGraph(rel, { loadDoc, parseDoc });
206
- done(200);
207
- return send(200,
208
- JSON.stringify(r.error !== undefined ? { error: r.error } : { data: r.data, truncated: !!r.truncated }),
209
- "application/json; charset=utf-8");
210
- } catch (e) {
211
- done(500);
212
- return send(500, JSON.stringify({ error: e.message }), "application/json; charset=utf-8");
171
+ if (hit) evict(abs, hit);
172
+ let text;
173
+ try { text = readFileSync(abs, "utf8"); } catch { return null; }
174
+ const entry = { mtime: st.mtimeMs, size: st.size, text, doc: parse(text) };
175
+ docCache.set(abs, entry);
176
+ parsedByText.set(text, entry.doc);
177
+ docCacheBytes += entry.size;
178
+ while (docCacheBytes > DOC_CACHE_BUDGET && docCache.size > 1) {
179
+ const oldest = docCache.keys().next().value;
180
+ evict(oldest, docCache.get(oldest));
213
181
  }
214
- }
215
- // The parser's own ESM dist, for the live module script the pages load —
216
- // clicks then swap views in place instead of navigating between pages.
217
- if (urlPath.startsWith("/_dist/")) {
218
- const sub = urlPath.slice("/_dist/".length);
219
- // The import map in served pages sends every node:* builtin here, so the
220
- // parser dist loads in a browser exactly like the bundled viewer does.
221
- if (sub === "_node-stub.js") {
222
- done(200);
223
- return send(200, readFileSync(join(dirname(fileURLToPath(import.meta.url)), "browser-stub.mjs")), "text/javascript; charset=utf-8");
224
- }
225
- const distFile = resolve(join(DIST_DIR, "." + sep + sub.replace(/\//g, sep)));
226
- if (!distFile.startsWith(DIST_DIR + sep) || !distFile.endsWith(".js") || !existsSync(distFile)) {
227
- done(404);
228
- return send(404, `not found: ${urlPath}`);
182
+ return entry;
183
+ };
184
+ const loadDoc = (rel) => {
185
+ const e = loadCached(join(root, rel));
186
+ return e ? e.text : null;
187
+ };
188
+ // loadDoc hands out the cached string instance, so the by-text lookup hits
189
+ // without re-hashing anything the render loop already loaded.
190
+ const parseDoc = (s) => parsedByText.get(s) ?? parse(s);
191
+
192
+ // Symlink-safe confinement: a lexical resolve()+startsWith() guard is
193
+ // defeated by a symlink inside the served dir that resolves lexically-inside
194
+ // but points at an external target. realpathSync canonicalizes through
195
+ // symlinks, so comparing the REAL path against the REAL base closes that
196
+ // hole (and Windows path casing/8.3 shortnames normalize the same way, since
197
+ // both sides come from realpathSync). A path that does not exist makes
198
+ // realpathSync throw — that is a normal miss, answered as null (never a
199
+ // crash), so callers fall through to their existing 404. Bases are resolved
200
+ // once; if a base itself cannot be realpath'd we fall back to its lexical
201
+ // form (nothing will resolve under it, so confine still refuses).
202
+ const realBase = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
203
+ const realRoot = realBase(root);
204
+ const realSrcRoot = realBase(srcRoot);
205
+ const confine = (abs, base = realRoot) => {
206
+ let real;
207
+ try { real = realpathSync(abs); } catch { return null; }
208
+ return (real === base || real.startsWith(base + sep)) ? real : null;
209
+ };
210
+
211
+ const handler = (req, res) => {
212
+ const send = (status, body, type) => {
213
+ // never-stale extends to the BROWSER: without this, heuristic caching
214
+ // keeps serving yesterday's pages and /_dist modules across restarts.
215
+ res.writeHead(status, { "content-type": type || "text/plain; charset=utf-8", "cache-control": "no-cache" });
216
+ res.end(req.method === "HEAD" ? undefined : body);
217
+ };
218
+ let urlPath;
219
+ try {
220
+ urlPath = decodeURIComponent(new URL(req.url, `http://127.0.0.1:${port}`).pathname);
221
+ } catch {
222
+ return send(400, "bad request");
229
223
  }
230
- done(200);
231
- return send(200, readFileSync(distFile), "text/javascript; charset=utf-8");
232
- }
233
- // Stay inside the codemap directory a viewer, not a file server.
234
- const file = resolve(join(root, "." + urlPath.replace(/\//g, sep)));
235
- if (file !== root && !file.startsWith(root + sep)) return send(403, "forbidden");
236
- // *.html: render the .geml source live when it exists.
237
- if (urlPath.endsWith(".html")) {
238
- const geml = file.replace(/\.html$/, ".geml");
239
- if (existsSync(geml)) {
224
+ if (urlPath.endsWith("/")) urlPath += "index.html";
225
+
226
+ const done = (status) => console.error(`${req.method} ${urlPath} ${status}`);
227
+ // Graph payloads as a sidecar: pages carry data-graph-src instead of a
228
+ // multi-MB inline attribute; the runtime fetches this route after first
229
+ // paint. Computed on demand from the SAME parse cache — never stale.
230
+ if (urlPath === "/_graph") {
231
+ let rel = "";
232
+ try { rel = new URL(req.url, `http://127.0.0.1:${port}`).searchParams.get("doc") || ""; } catch { /* fall through */ }
233
+ const target = resolve(join(root, "." + ("/" + rel).replace(/\//g, sep)));
234
+ // confine() both requires existence (realpathSync throws otherwise) and
235
+ // rejects a symlink that escapes root; target===root (a codemap dir
236
+ // itself named *.geml, reached via ../<basename>) stays allowed and the
237
+ // builder reports its own clean load failure.
238
+ if (!rel.endsWith(".geml") || !confine(target)) {
239
+ done(404);
240
+ return send(404, JSON.stringify({ error: `no such document: ${rel}` }), "application/json; charset=utf-8");
241
+ }
240
242
  try {
241
- const doc = loadCached(geml).doc;
242
- const html = renderHtml(doc, {
243
- source: basename(geml), loadDoc, parseDoc,
244
- liveGraph: "/_dist/", graphSidecar: "/_graph?doc=",
245
- });
243
+ const r = buildCodeGraph(rel, { loadDoc, parseDoc });
246
244
  done(200);
247
- return send(200, html, MIME[".html"]);
245
+ return send(200,
246
+ JSON.stringify(r.error !== undefined ? { error: r.error } : { data: r.data, truncated: !!r.truncated }),
247
+ "application/json; charset=utf-8");
248
248
  } catch (e) {
249
249
  done(500);
250
- return send(500, `render error in ${basename(geml)}: ${e.message}`);
250
+ return send(500, JSON.stringify({ error: e.message }), "application/json; charset=utf-8");
251
251
  }
252
252
  }
253
- }
254
- if (existsSync(file) && statSync(file).isFile()) {
255
- done(200);
256
- return send(200, readFileSync(file), MIME[extOf(file)] || "application/octet-stream");
257
- }
258
- // Source files as a route: the graph's click-to-source fetches a method's
259
- // src path (project-root-relative), which misses inside the codemap dir.
260
- // Resolve the miss against the project root read-only, indexed source
261
- // extensions only, traversal-guarded. Still a viewer, not a file server.
262
- if (isSourcePath(urlPath)) {
263
- const srcFile = resolve(join(srcRoot, "." + urlPath.replace(/\//g, sep)));
264
- if (srcFile.startsWith(srcRoot + sep) && existsSync(srcFile) && statSync(srcFile).isFile()) {
253
+ // Name -> node search for the viewer typeahead: substring-match the build's
254
+ // name-lookup and return the top matches (small), so even a 45M index stays
255
+ // server-side and never ships to the browser. Static file:// pages, which
256
+ // can't hit this route, load _index/search-index.js via <script> instead.
257
+ if (urlPath === "/_search") {
258
+ let q = "";
259
+ try { q = (new URL(req.url, `http://127.0.0.1:${port}`).searchParams.get("q") || "").trim().toLowerCase(); } catch { /* fall through */ }
260
+ if (q.length < 2) { done(200); return send(200, JSON.stringify({ total: 0, hits: [] }), MIME[".json"]); }
261
+ if (!searchRows) {
262
+ searchRows = [];
263
+ try {
264
+ const lk = JSON.parse(readFileSync(join(root, "_index", "name-lookup.json"), "utf8"));
265
+ for (const name of Object.keys(lk)) for (const c of lk[name]) searchRows.push([name, c.doc, c.id]);
266
+ } catch { /* no lookup — leave empty */ }
267
+ }
268
+ // Rank so the cap keeps the BEST hits, not the alphabetically first:
269
+ // exact name, then prefix, then qualified-tail prefix (Cls::q / Cls.q),
270
+ // then substring. The lookup also aliases bare member names to the same
271
+ // node — dedupe on doc#id keeping the best-ranked row, and report the
272
+ // HONEST total so the UI can say "showing K of N". (The static viewer
273
+ // ranks with the same rules client-side over search-index.js.)
274
+ const score = (n) => {
275
+ if (n === q) return 0;
276
+ if (n.startsWith(q)) return 1;
277
+ const c2 = n.lastIndexOf("::"), d = n.lastIndexOf(".");
278
+ const cut = Math.max(c2 >= 0 ? c2 + 2 : 0, d >= 0 ? d + 1 : 0);
279
+ if (cut > 0 && n.slice(cut).startsWith(q)) return 2;
280
+ return n.includes(q) ? 3 : -1;
281
+ };
282
+ const ranked = [];
283
+ for (const [name, doc, id] of searchRows) {
284
+ const s = score(name.toLowerCase());
285
+ if (s >= 0) ranked.push({ s, name, doc, id });
286
+ }
287
+ ranked.sort((a, b) => a.s - b.s || a.name.localeCompare(b.name));
288
+ const seen = new Set(), hits = [];
289
+ for (const h of ranked) {
290
+ const k = h.doc + "#" + h.id;
291
+ if (seen.has(k)) continue;
292
+ seen.add(k);
293
+ hits.push(h);
294
+ }
265
295
  done(200);
266
- return send(200, readFileSync(srcFile), "text/plain; charset=utf-8");
296
+ return send(200, JSON.stringify({ total: hits.length, hits: hits.slice(0, 100).map(({ name, doc, id }) => ({ name, doc, id })) }), MIME[".json"]);
267
297
  }
268
- }
269
- done(404);
270
- return send(404, `not found: ${urlPath}`);
271
- });
298
+ // The parser's own ESM dist, for the live module script the pages load —
299
+ // clicks then swap views in place instead of navigating between pages.
300
+ if (urlPath.startsWith("/_dist/")) {
301
+ const sub = urlPath.slice("/_dist/".length);
302
+ // The import map in served pages sends every node:* builtin here, so the
303
+ // parser dist loads in a browser exactly like the bundled viewer does.
304
+ if (sub === "_node-stub.js") {
305
+ done(200);
306
+ return send(200, readFileSync(join(dirname(fileURLToPath(import.meta.url)), "browser-stub.mjs")), "text/javascript; charset=utf-8");
307
+ }
308
+ const distFile = resolve(join(DIST_DIR, "." + sep + sub.replace(/\//g, sep)));
309
+ if (!distFile.startsWith(DIST_DIR + sep) || !distFile.endsWith(".js") || !existsSync(distFile)) {
310
+ done(404);
311
+ return send(404, `not found: ${urlPath}`);
312
+ }
313
+ done(200);
314
+ return send(200, readFileSync(distFile), "text/javascript; charset=utf-8");
315
+ }
316
+ // Stay inside the codemap directory — a viewer, not a file server.
317
+ const file = resolve(join(root, "." + urlPath.replace(/\//g, sep)));
318
+ if (file !== root && !file.startsWith(root + sep)) return send(403, "forbidden");
319
+ // *.html: render the .geml source live when it exists. confine() gates on
320
+ // the REAL path so a symlinked .geml that points outside root is refused
321
+ // (a directory named *.geml still resolves in-root and the render
322
+ // try/catch answers its clean 500, as before).
323
+ if (urlPath.endsWith(".html")) {
324
+ const geml = file.replace(/\.html$/, ".geml");
325
+ if (confine(geml)) {
326
+ try {
327
+ const doc = loadCached(geml).doc;
328
+ const html = renderHtml(doc, {
329
+ source: basename(geml), loadDoc, parseDoc,
330
+ liveGraph: "/_dist/", graphSidecar: "/_graph?doc=",
331
+ });
332
+ done(200);
333
+ return send(200, html, MIME[".html"]);
334
+ } catch (e) {
335
+ done(500);
336
+ return send(500, `render error in ${basename(geml)}: ${e.message}`);
337
+ }
338
+ }
339
+ }
340
+ const realFile = confine(file);
341
+ if (realFile && statSync(realFile).isFile()) {
342
+ done(200);
343
+ return send(200, readFileSync(realFile), MIME[extOf(file)] || "application/octet-stream");
344
+ }
345
+ // Source files as a route: the graph's click-to-source fetches a method's
346
+ // src path (project-root-relative), which misses inside the codemap dir.
347
+ // Resolve the miss against the project root — read-only, indexed source
348
+ // extensions only, traversal-guarded. Still a viewer, not a file server.
349
+ if (isSourcePath(urlPath)) {
350
+ const srcFile = resolve(join(srcRoot, "." + urlPath.replace(/\//g, sep)));
351
+ // Same symlink-safe confinement against the (realpath'd) source root: a
352
+ // symlinked source file pointing outside the project tree is refused.
353
+ const realSrcFile = confine(srcFile, realSrcRoot);
354
+ if (realSrcFile && statSync(realSrcFile).isFile()) {
355
+ done(200);
356
+ return send(200, readFileSync(realSrcFile), "text/plain; charset=utf-8");
357
+ }
358
+ }
359
+ done(404);
360
+ return send(404, `not found: ${urlPath}`);
361
+ };
272
362
 
273
- server.on("error", (e) => {
274
- console.error(e && e.code === "EADDRINUSE"
275
- ? `error: port ${port} is in use — pick another with --port, or stop the old server (geml codemap serve ${dir} --stop)`
276
- : `error: ${e.message}`);
277
- process.exit(1);
278
- });
279
- // Background prewarm: the parse cache is lazy, so the FIRST click into a big
280
- // container otherwise pays its whole cross-document working set (seconds at
281
- // repo scale). Warm largest-first — the big documents are the long-tail
282
- // first-clicks ONE document per event-loop turn so requests arriving
283
- // mid-warm are served normally (a tight synchronous loop would block them),
284
- // and stop at 80% of the byte budget: warming past it only evicts what was
285
- // just warmed. Requests still validate mtime+size, so a rebuild mid-warm is
286
- // picked up as usual.
287
- async function warmCache() {
288
- let files = [];
289
- try {
290
- files = readdirSync(root)
291
- .filter((f) => f.endsWith(".geml"))
292
- .map((f) => {
293
- const p = join(root, f);
294
- try { return { p, size: statSync(p).size }; } catch { return null; }
295
- })
296
- .filter(Boolean)
297
- .sort((a, b) => b.size - a.size);
298
- } catch { return; }
299
- const t0 = Date.now();
300
- let n = 0;
301
- // Brake on CUMULATIVE bytes pushed through the cache, not on current
302
- // occupancy the LRU evicts as it goes, so occupancy self-limits and
303
- // would never stop the loop; past 80% of the budget every further load
304
- // only evicts something just warmed.
305
- let warmed = 0;
306
- for (const { p, size } of files) {
307
- if (warmed >= DOC_CACHE_BUDGET * 0.8) break;
308
- if (loadCached(p)) { n++; warmed += size; }
309
- await new Promise((r) => setImmediate(r));
363
+ const server = createServer(handler);
364
+
365
+ server.on("error", (e) => {
366
+ console.error(e && e.code === "EADDRINUSE"
367
+ ? `error: port ${port} is in use — pick another with --port, or stop the old server (geml codemap serve ${dir} --stop)`
368
+ : `error: ${e.message}`);
369
+ process.exit(1);
370
+ });
371
+
372
+ // Background prewarm: the parse cache is lazy, so the FIRST click into a big
373
+ // container otherwise pays its whole cross-document working set (seconds at
374
+ // repo scale). Warm largest-first the big documents are the long-tail
375
+ // first-clicks ONE document per event-loop turn so requests arriving
376
+ // mid-warm are served normally (a tight synchronous loop would block them),
377
+ // and stop at 80% of the byte budget: warming past it only evicts what was
378
+ // just warmed. Requests still validate mtime+size, so a rebuild mid-warm is
379
+ // picked up as usual.
380
+ async function warmCache() {
381
+ let files = [];
382
+ try {
383
+ files = readdirSync(root)
384
+ .filter((f) => f.endsWith(".geml"))
385
+ .map((f) => {
386
+ const p = join(root, f);
387
+ try { return { p, size: statSync(p).size }; } catch { return null; }
388
+ })
389
+ .filter(Boolean)
390
+ .sort((a, b) => b.size - a.size);
391
+ } catch { return; }
392
+ const t0 = Date.now();
393
+ let n = 0;
394
+ // Brake on CUMULATIVE bytes pushed through the cache, not on current
395
+ // occupancy the LRU evicts as it goes, so occupancy self-limits and
396
+ // would never stop the loop; past 80% of the budget every further load
397
+ // only evicts something just warmed.
398
+ let warmed = 0;
399
+ for (const { p, size } of files) {
400
+ if (warmed >= DOC_CACHE_BUDGET * 0.8) break;
401
+ if (loadCached(p)) { n++; warmed += size; }
402
+ await new Promise((r) => setImmediate(r));
403
+ }
404
+ console.error(`prewarm: ${n}/${files.length} document(s), ${(docCacheBytes / 1048576).toFixed(1)} MB cached, ${((Date.now() - t0) / 1000).toFixed(1)}s`);
310
405
  }
311
- console.error(`prewarm: ${n}/${files.length} document(s), ${(docCacheBytes / 1048576).toFixed(1)} MB cached, ${((Date.now() - t0) / 1000).toFixed(1)}s`);
406
+
407
+ return { server, handler, loadCached, loadDoc, parseDoc, warmCache, docCache, cacheBytes: () => docCacheBytes };
312
408
  }
313
409
 
314
410
  // Open the graph in the default browser — ONLY when serving interactively in a
315
411
  // real terminal (isTTY). A --background child (stdio -> log file) and piped/CI
316
412
  // runs are non-TTY and never open; `--no-open` opts out explicitly. A missing
317
413
  // opener is not an error — the URL is already printed.
318
- const openBrowser = (url) => {
414
+ export const openBrowser = (url, spawnImpl = spawn) => {
319
415
  const argv = process.platform === "win32" ? ["cmd", "/c", "start", "", url]
320
416
  : process.platform === "darwin" ? ["open", url]
321
417
  : ["xdg-open", url];
322
- try { spawn(argv[0], argv.slice(1), { stdio: "ignore", detached: true }).unref(); }
323
- catch { /* no opener available: the printed URL is enough */ }
418
+ try {
419
+ const child = spawnImpl(argv[0], argv.slice(1), { stdio: "ignore", detached: true });
420
+ // spawn() reports a missing opener (e.g. no xdg-open on a headless Linux
421
+ // box) ASYNCHRONOUSLY via an 'error' event on the ChildProcess — the
422
+ // try/catch only catches a SYNCHRONOUS throw, so without this listener the
423
+ // unhandled 'error' would crash the whole serve process. Swallow it: the
424
+ // URL is already printed, so a failed auto-open is never fatal.
425
+ child?.on?.("error", () => { /* no opener available: the printed URL is enough */ });
426
+ child?.unref?.();
427
+ } catch { /* synchronous spawn failure: the printed URL is enough */ }
324
428
  };
325
429
 
326
430
  // --watch: editing-time sync. Watch the project's indexed source files and,
@@ -330,11 +434,18 @@ const openBrowser = (url) => {
330
434
  // Single-flight — a change arriving mid-refresh queues exactly one more run.
331
435
  // Pages render live from .geml, so when a run lands an F5 shows it.
332
436
  const WATCH_QUIET = Number(process.env.GEML_WATCH_QUIET_MS) || 30_000;
333
- function startWatch() {
437
+ export function startWatch({ root, runDir, srcRoot, logPath }) {
334
438
  if (!existsSync(join(runDir, "refresh.json"))) {
335
439
  console.error("watch: no _index/refresh.json recipe recorded — --watch disabled (build once first)");
336
440
  return;
337
441
  }
442
+ // A missing source root must disable watch on EVERY platform. Windows' native
443
+ // fs.watch throws on a nonexistent path (caught below), but Linux's manual
444
+ // watchTree silently tolerates it and would "watch" nothing — so guard here.
445
+ if (!existsSync(srcRoot)) {
446
+ console.error(`watch: recursive fs.watch unavailable here (source root ${srcRoot} does not exist) — --watch disabled`);
447
+ return;
448
+ }
338
449
  let timer = null, running = false, again = false;
339
450
  const run = () => {
340
451
  if (running) { again = true; return; }
@@ -352,27 +463,116 @@ function startWatch() {
352
463
  });
353
464
  };
354
465
  const schedule = () => { clearTimeout(timer); timer = setTimeout(run, WATCH_QUIET); };
355
- try {
356
- watch(srcRoot, { recursive: true }, (_ev, rel) => {
357
- if (!rel) return;
466
+ // Shared filter: `rel` is the changed path relative to srcRoot, or null when
467
+ // the platform could not attribute the event. An event we cannot filter
468
+ // still schedules — the quiet window and the single-flight runner absorb an
469
+ // occasional false refresh, whereas dropping it deafens --watch.
470
+ const onFsEvent = (rel) => {
471
+ if (rel) {
358
472
  const parts = String(rel).split(/[\\/]/);
359
473
  if (parts.some((p) => SKIP_DIRS.has(p) || p.startsWith("."))) return;
360
474
  if (!isSourcePath(String(rel))) return;
361
- schedule();
362
- });
475
+ }
476
+ schedule();
477
+ };
478
+ try {
479
+ // Linux's native recursive fs.watch silently misses events inside
480
+ // pre-existing subdirectories (CI proved it: zero events for 15s of edits
481
+ // under src/), so there the tree is watched by hand — one plain inotify
482
+ // watcher per directory, SKIP_DIRS pruned. macOS/Windows keep the native
483
+ // recursive watcher. GEML_WATCH_TREE=1 forces the manual walker so tests
484
+ // exercise it on every platform.
485
+ if (process.platform === "linux" || process.env.GEML_WATCH_TREE === "1") {
486
+ watchTree(srcRoot, onFsEvent);
487
+ } else {
488
+ watch(srcRoot, { recursive: true }, (_ev, rel) => onFsEvent(rel));
489
+ }
363
490
  console.error(`watch: watching ${srcRoot} — a source change re-runs the recipe after ${WATCH_QUIET / 1000}s of quiet`);
364
491
  } catch (e) {
365
492
  console.error(`watch: recursive fs.watch unavailable here (${e.message}) — --watch disabled`);
493
+ return;
366
494
  }
495
+ return { run, schedule, onFsEvent };
496
+ }
497
+
498
+ // Manual recursive watcher: one non-recursive fs.watch per directory, new
499
+ // directories picked up as they appear. Dead watchers on deleted directories
500
+ // just fall silent — nothing to clean up for our purpose.
501
+ export function watchTree(rootDir, onEvent) {
502
+ const watched = new Set();
503
+ const add = (dir) => {
504
+ if (watched.has(dir)) return;
505
+ let w;
506
+ try { w = watch(dir, (_ev, name) => hit(dir, name ? String(name) : null)); } catch { return; } // vanished mid-walk
507
+ watched.add(dir);
508
+ w.on("error", () => watched.delete(dir));
509
+ let entries = [];
510
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
511
+ for (const e of entries) {
512
+ if (e.isDirectory() && !SKIP_DIRS.has(e.name) && !e.name.startsWith(".")) add(join(dir, e.name));
513
+ }
514
+ };
515
+ const hit = (dir, name) => {
516
+ if (!name) { onEvent(null); return; } // unattributed: let the caller decide
517
+ const full = join(dir, name);
518
+ try {
519
+ if (statSync(full).isDirectory()) {
520
+ if (!SKIP_DIRS.has(name) && !name.startsWith(".")) add(full); // new subtree
521
+ return; // directory churn itself is not a source edit
522
+ }
523
+ } catch { /* deleted — an unlinked source file is still a change */ }
524
+ onEvent(relative(rootDir, full));
525
+ };
526
+ add(rootDir);
527
+ return { add, hit, watched };
528
+ }
529
+
530
+ // Foreground serving: create the app and listen. Returns the app so an
531
+ // in-process caller can close the server; the CLI just leaves it running.
532
+ export function startServing(cfg) {
533
+ const { root, port } = cfg;
534
+ const app = createApp(cfg);
535
+ app.server.listen(port, "127.0.0.1", () => {
536
+ // Record the pid so `--stop` can find us (best effort — a read-only
537
+ // codemap dir just means no pid file).
538
+ try { mkdirSync(cfg.runDir, { recursive: true }); writeFileSync(cfg.pidPath, String(process.pid)); } catch { /* read-only */ }
539
+ console.error(`geml codemap serve: ${root}`);
540
+ console.error(` -> http://localhost:${port}/ (pages render live from .geml — rebuilds show on refresh)`);
541
+ if (process.stdout.isTTY && !cfg.noOpen) openBrowser(`http://localhost:${port}/`);
542
+ if (!cfg.noWarm) app.warmCache();
543
+ if (cfg.watchMode) startWatch(cfg);
544
+ });
545
+ return app;
546
+ }
547
+
548
+ export async function main(argv = process.argv.slice(2)) {
549
+ const cfg = parseServeArgs(argv);
550
+ if (!cfg) {
551
+ console.error(USAGE);
552
+ process.exit(2);
553
+ }
554
+ const root = resolve(cfg.dir);
555
+ const runDir = join(root, "_index");
556
+ const ctx = {
557
+ ...cfg, root, runDir,
558
+ pidPath: join(runDir, "serve.pid"),
559
+ logPath: join(runDir, "serve.log"),
560
+ srcRoot: resolveSrcRoot(root),
561
+ };
562
+
563
+ if (ctx.stop) process.exit(stopServer(ctx));
564
+
565
+ if (!existsSync(join(root, "index.geml")) && !existsSync(join(root, "index.html"))) {
566
+ console.error(`error: ${root} has no index.geml — not a codemap directory? (build one: geml codemap build)`);
567
+ process.exit(1);
568
+ }
569
+
570
+ if (ctx.background) process.exit(await launchBackground(ctx));
571
+
572
+ return startServing(ctx);
367
573
  }
368
574
 
369
- server.listen(port, "127.0.0.1", () => {
370
- // Record the pid so `--stop` can find us (best effort a read-only
371
- // codemap dir just means no pid file).
372
- try { mkdirSync(runDir, { recursive: true }); writeFileSync(pidPath, String(process.pid)); } catch { /* read-only */ }
373
- console.error(`geml codemap serve: ${root}`);
374
- console.error(` -> http://localhost:${port}/ (pages render live from .geml — rebuilds show on refresh)`);
375
- if (process.stdout.isTTY && !noOpen) openBrowser(`http://localhost:${port}/`);
376
- if (!noWarm) warmCache();
377
- if (watchMode) startWatch();
378
- });
575
+ // Auto-run only as a MAIN module: the CLI dispatcher spawns this file as a
576
+ // child's entry script (src/geml.ts runCodemap), and `node codemap/serve.mjs`
577
+ // hits it directly an in-process `import` (the tests) stays inert.
578
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();