@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/README.md +41 -11
- package/codemap/adapters/crg.mjs +11 -0
- package/codemap/adapters/scip.mjs +481 -52
- package/codemap/browser-stub.mjs +5 -0
- package/codemap/build.mjs +270 -45
- package/codemap/detect.mjs +230 -16
- package/codemap/emit.mjs +78 -7
- package/codemap/entries.mjs +129 -0
- package/codemap/find.mjs +63 -0
- package/codemap/foldings.mjs +110 -0
- package/codemap/mcp-server.mjs +44 -15
- package/codemap/normalize.mjs +0 -0
- package/codemap/recipe-trust.mjs +103 -0
- package/codemap/refresh.mjs +158 -8
- package/codemap/serve.mjs +428 -228
- package/codemap/sfc-virtualize.mjs +367 -0
- package/codemap/verify.mjs +20 -3
- package/dist/from-md.js +66 -7
- package/dist/geml.d.ts +2 -1
- package/dist/geml.js +369 -97
- package/dist/history.js +102 -62
- package/dist/inline.d.ts +2 -1
- package/dist/inline.js +61 -8
- package/dist/render-html.d.ts +3 -0
- package/dist/render-html.js +95 -0
- package/dist/render.d.ts +32 -2
- package/dist/render.js +308 -116
- package/dist/serialize.js +22 -2
- package/dist/table.js +40 -5
- package/dist/to-md.js +4 -0
- package/package.json +63 -58
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
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,
|
|
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,
|
|
250
|
+
return send(500, JSON.stringify({ error: e.message }), "application/json; charset=utf-8");
|
|
251
251
|
}
|
|
252
252
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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,
|
|
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
|
-
|
|
270
|
-
|
|
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
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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 {
|
|
323
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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();
|