markdown-lsp 1.0.0 → 1.2.0

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/cli.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { parseArgs } from "node:util";
2
+ import * as fs from "node:fs";
2
3
  import { buildGraph } from "./graph.js";
3
4
  import { listPages, searchText, searchTextRanked, searchSymbols, searchPaths, } from "./bridge/index.js";
4
5
  const USAGE = `
5
- markdown-lsp v1.0.0 — CLI for querying Markdown documentation graphs
6
+ markdown-lsp v1.2.0 — CLI for querying Markdown documentation graphs
6
7
 
7
8
  USAGE
8
9
  markdown-lsp <subcommand> [options]
@@ -37,6 +38,26 @@ SUBCOMMANDS
37
38
  get-section <docs-dir> <page> <anchor>
38
39
  Retrieve a section by its anchor slug.
39
40
 
41
+ graph <docs-dir> [--format json|dot|mermaid|html] [--out <file>]
42
+ [--semantic] [--sim-threshold <0-1>] [--sim-top-k <n>]
43
+ [--model <embedding-model>]
44
+ Export the doc link graph. Default format: json (nodes/edges).
45
+ Use --format html for a self-contained interactive D3 visualisation.
46
+ Use --out <file> to write to disk instead of stdout.
47
+ Add --semantic to overlay AI-powered similarity edges (requires
48
+ OPENROUTER_API_KEY or AI_GATEWAY_API_KEY). Both link edges and
49
+ semantic edges are shown in the HTML graph with checkboxes to
50
+ toggle each type. Clicking a node opens a side-panel with full
51
+ info and highlights all its connections.
52
+ --sim-threshold Minimum cosine similarity for a semantic edge (default: 0.75)
53
+ --sim-top-k Maximum semantic neighbours per node (default: 5)
54
+ --model Embedding model override (default: openai/text-embedding-3-small)
55
+
56
+ semantic-search <docs-dir> <query> [--limit <n>] [--model <embedding-model>]
57
+ AI-powered semantic search using embeddings. Requires OPENROUTER_API_KEY
58
+ (or AI_GATEWAY_API_KEY). Results are cached in .markdown-lsp-cache/.
59
+ Default embedding model: openai/text-embedding-3-small
60
+
40
61
  lsp [--stdio]
41
62
  serve [--stdio]
42
63
  Start the LSP stdio server (for editor integration).
@@ -54,9 +75,45 @@ OUTPUT
54
75
  over stdio — it does NOT print JSON.
55
76
 
56
77
  EXAMPLES
78
+ # Overview of all pages
57
79
  markdown-lsp workspace-outline ./docs
58
- markdown-lsp search-text ./docs "getting started" --pretty
80
+
81
+ # Heading outline of one page
59
82
  markdown-lsp outline ./docs introduction.md
83
+
84
+ # Full-text search (natural-language, ranked)
85
+ markdown-lsp search-text ./docs "getting started"
86
+ markdown-lsp search-text ./docs "webhook signing" --mode verbatim --limit 5
87
+
88
+ # Fuzzy heading search
89
+ markdown-lsp search-symbols ./docs "webhook" --limit 10
90
+
91
+ # Find pages by filename glob
92
+ markdown-lsp search-paths ./docs "ai/*.md"
93
+
94
+ # Link graph
95
+ markdown-lsp graph ./docs --format json --pretty
96
+ markdown-lsp graph ./docs --format html --out graph.html
97
+ markdown-lsp graph ./docs --format dot | dot -Tsvg > graph.svg
98
+ markdown-lsp graph ./docs --format mermaid
99
+
100
+ # Build embeddings + interactive semantic graph in one command
101
+ markdown-lsp graph ./docs --format html --semantic --out graph.html
102
+ markdown-lsp graph ./docs --format html --semantic --sim-threshold 0.75 --sim-top-k 5 --out graph.html
103
+
104
+ # Backlinks / outgoing links
105
+ markdown-lsp links-to ./docs quick-start.md
106
+ markdown-lsp links-from ./docs README.md
107
+
108
+ # Resolve / read
109
+ markdown-lsp resolve-link ./docs README.md "Getting Started"
110
+ markdown-lsp get-section ./docs overview.md "quick-links"
111
+
112
+ # Semantic search (AI)
113
+ markdown-lsp semantic-search ./docs "how to set up webhooks" --limit 5
114
+ markdown-lsp semantic-search ./docs "authentication" --model openai/text-embedding-3-small
115
+
116
+ # LSP server
60
117
  markdown-lsp lsp --stdio
61
118
  `.trim();
62
119
  function die(msg) {
@@ -66,7 +123,606 @@ function die(msg) {
66
123
  function out(value, pretty) {
67
124
  process.stdout.write((pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value)) + "\n");
68
125
  }
69
- async function startLsp() {
126
+ function buildGraphExport(raw) {
127
+ // Build node map with full side-panel data
128
+ const nodeMap = new Map();
129
+ for (const p of raw.pages) {
130
+ nodeMap.set(p.path, {
131
+ id: p.path,
132
+ title: p.title ?? p.path,
133
+ charCount: p.content.length,
134
+ sectionsCount: p.sections.length,
135
+ sections: p.sections.map((s) => ({
136
+ anchor: s.anchor,
137
+ headingPath: s.headingPath,
138
+ level: s.level,
139
+ })),
140
+ outgoing: [],
141
+ incoming: [],
142
+ topSimilar: [],
143
+ });
144
+ }
145
+ const edges = [];
146
+ for (const l of raw.links) {
147
+ if (l.toResolvedPath === null)
148
+ continue;
149
+ edges.push({
150
+ source: l.fromPath,
151
+ target: l.toResolvedPath,
152
+ kind: l.kind,
153
+ ...(l.textAtLink ? { label: l.textAtLink } : {}),
154
+ });
155
+ nodeMap.get(l.fromPath)?.outgoing.push({
156
+ target: l.toResolvedPath,
157
+ label: l.textAtLink,
158
+ kind: l.kind,
159
+ });
160
+ nodeMap.get(l.toResolvedPath)?.incoming.push({
161
+ source: l.fromPath,
162
+ label: l.textAtLink,
163
+ kind: l.kind,
164
+ });
165
+ }
166
+ return {
167
+ nodes: Array.from(nodeMap.values()),
168
+ edges,
169
+ semanticEdges: [],
170
+ unresolvedCount: raw.unresolved.length,
171
+ };
172
+ }
173
+ // ── Cosine similarity (no deps) ───────────────────────────────────────────────
174
+ function cosineSim(a, b) {
175
+ let dot = 0, na = 0, nb = 0;
176
+ for (let i = 0; i < a.length; i++) {
177
+ dot += a[i] * b[i];
178
+ na += a[i] * a[i];
179
+ nb += b[i] * b[i];
180
+ }
181
+ if (na === 0 || nb === 0)
182
+ return 0;
183
+ return dot / (Math.sqrt(na) * Math.sqrt(nb));
184
+ }
185
+ // ── Semantic edge computation ─────────────────────────────────────────────────
186
+ async function addSemanticEdges(data, pages, modelOverride, simThreshold, simTopK) {
187
+ const { assertApiKey } = await import("./ai/config.js");
188
+ assertApiKey();
189
+ const { embedTexts } = await import("./ai/embeddings.js");
190
+ const pageTexts = pages.map((p) => {
191
+ const titlePart = p.title ? p.title + "\n\n" : "";
192
+ return titlePart + p.content.slice(0, 2000);
193
+ });
194
+ process.stderr.write(`[markdown-lsp] Embedding ${pages.length} pages` +
195
+ ` (model: ${modelOverride ?? "openai/text-embedding-3-small"})...\n`);
196
+ const { vectors, tokensUsed } = await embedTexts(pageTexts, modelOverride, true);
197
+ if (tokensUsed > 0) {
198
+ process.stderr.write(`[markdown-lsp] Embeddings computed (${tokensUsed} tokens used).\n`);
199
+ }
200
+ else {
201
+ process.stderr.write(`[markdown-lsp] Embeddings loaded from cache (0 API tokens).\n`);
202
+ }
203
+ const semanticEdges = [];
204
+ for (let i = 0; i < pages.length; i++) {
205
+ const vecI = vectors[i];
206
+ if (!vecI)
207
+ continue;
208
+ // Compute similarity to all other pages
209
+ const sims = [];
210
+ for (let j = 0; j < pages.length; j++) {
211
+ if (j === i)
212
+ continue;
213
+ const vecJ = vectors[j];
214
+ if (!vecJ)
215
+ continue;
216
+ const score = cosineSim(vecI, vecJ);
217
+ if (score >= simThreshold) {
218
+ sims.push({ j, score });
219
+ }
220
+ }
221
+ // topSimilar for side panel (no i<j constraint, full ranking)
222
+ const topSimilar = [...sims]
223
+ .sort((a, b) => b.score - a.score)
224
+ .slice(0, simTopK)
225
+ .map(({ j, score }) => ({
226
+ path: pages[j].path,
227
+ title: pages[j].title,
228
+ score: Math.round(score * 10000) / 10000,
229
+ }));
230
+ const node = data.nodes.find((n) => n.id === pages[i].path);
231
+ if (node)
232
+ node.topSimilar = topSimilar;
233
+ // Semantic edges: de-dup using i<j to avoid duplicates
234
+ for (const { j, score } of sims) {
235
+ if (j > i) {
236
+ semanticEdges.push({
237
+ source: pages[i].path,
238
+ target: pages[j].path,
239
+ score: Math.round(score * 10000) / 10000,
240
+ kind: "semantic",
241
+ });
242
+ }
243
+ }
244
+ }
245
+ data.semanticEdges = semanticEdges;
246
+ process.stderr.write(`[markdown-lsp] ${semanticEdges.length} semantic edges` +
247
+ ` (threshold=${simThreshold}, topK=${simTopK}).\n`);
248
+ }
249
+ // ── Graph renderers ───────────────────────────────────────────────────────────
250
+ function renderGraphJson(data, pretty) {
251
+ return pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data);
252
+ }
253
+ function renderGraphDot(data) {
254
+ const lines = ['digraph G {', ' rankdir=LR;'];
255
+ for (const n of data.nodes) {
256
+ const label = n.title.replace(/"/g, '\\"');
257
+ const id = n.id.replace(/"/g, '\\"');
258
+ lines.push(` "${id}" [label="${label}"];`);
259
+ }
260
+ for (const e of data.edges) {
261
+ const src = e.source.replace(/"/g, '\\"');
262
+ const tgt = e.target.replace(/"/g, '\\"');
263
+ lines.push(` "${src}" -> "${tgt}";`);
264
+ }
265
+ for (const e of data.semanticEdges) {
266
+ const src = e.source.replace(/"/g, '\\"');
267
+ const tgt = e.target.replace(/"/g, '\\"');
268
+ lines.push(` "${src}" -> "${tgt}" [style=dashed color="#f59e0b" label="${e.score}"];`);
269
+ }
270
+ lines.push('}');
271
+ return lines.join('\n');
272
+ }
273
+ function renderGraphMermaid(data) {
274
+ const lines = ['graph TD'];
275
+ for (const e of data.edges) {
276
+ const src = e.source.replace(/"/g, '\\"');
277
+ const tgt = e.target.replace(/"/g, '\\"');
278
+ lines.push(` "${src}" --> "${tgt}"`);
279
+ }
280
+ for (const e of data.semanticEdges) {
281
+ const src = e.source.replace(/"/g, '\\"');
282
+ const tgt = e.target.replace(/"/g, '\\"');
283
+ lines.push(` "${src}" -. ${e.score} .-> "${tgt}"`);
284
+ }
285
+ return lines.join('\n');
286
+ }
287
+ function renderGraphHtml(data, docsDir, hasSemantic) {
288
+ const title = docsDir.split('/').filter(Boolean).pop() ?? docsDir;
289
+ const jsonData = JSON.stringify(data);
290
+ return `<!DOCTYPE html>
291
+ <html lang="en">
292
+ <head>
293
+ <meta charset="utf-8">
294
+ <title>Doc Graph — ${title}</title>
295
+ <script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
296
+ <style>
297
+ * { box-sizing: border-box; margin: 0; padding: 0; }
298
+ body { font-family: system-ui, sans-serif; background: #0f1117; color: #e2e8f0; overflow: hidden; }
299
+ #toolbar { position: fixed; top: 0; left: 0; right: 0; height: 44px; background: #1a1d27;
300
+ border-bottom: 1px solid #2d3148; display: flex; align-items: center; padding: 0 16px;
301
+ z-index: 10; gap: 16px; font-size: 13px; }
302
+ #toolbar h1 { font-size: 14px; font-weight: 600; color: #a5b4fc; }
303
+ #toolbar span { color: #64748b; }
304
+ .toggle-label { display: flex; align-items: center; gap: 6px; cursor: pointer; user-select: none; font-size: 12px; }
305
+ .toggle-label input[type=checkbox] { accent-color: #a5b4fc; width: 14px; height: 14px; cursor: pointer; }
306
+ .toggle-links { color: #a5b4fc; }
307
+ .toggle-semantic { color: #f59e0b; }
308
+ .toggle-label.disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
309
+ #graph-wrap { position: fixed; top: 44px; left: 0; bottom: 0; right: 0; transition: right 0.25s; }
310
+ #graph-wrap.panel-open { right: 320px; }
311
+ svg#graph { width: 100%; height: 100%; display: block; }
312
+ #tooltip { position: fixed; pointer-events: none; background: #1e2130; border: 1px solid #3b4168;
313
+ border-radius: 6px; padding: 8px 12px; font-size: 12px; line-height: 1.5;
314
+ max-width: 280px; z-index: 20; display: none; }
315
+ #tooltip .title { font-weight: 600; color: #c7d2fe; margin-bottom: 2px; }
316
+ #tooltip .path { color: #64748b; font-size: 11px; }
317
+ #tooltip .stats { color: #94a3b8; font-size: 11px; margin-top: 4px; }
318
+ #panel { position: fixed; top: 44px; right: 0; width: 320px; bottom: 0;
319
+ background: #1a1d27; border-left: 1px solid #2d3148; z-index: 15;
320
+ overflow-y: auto; display: none; }
321
+ #panel.open { display: block; }
322
+ #panel-header { padding: 14px 16px 10px; border-bottom: 1px solid #2d3148;
323
+ display: flex; align-items: flex-start; gap: 8px; }
324
+ #panel-close { background: none; border: none; color: #64748b; cursor: pointer;
325
+ font-size: 18px; line-height: 1; padding: 2px; flex-shrink: 0; margin-left: auto; }
326
+ #panel-close:hover { color: #e2e8f0; }
327
+ #panel-title { font-weight: 700; font-size: 14px; color: #c7d2fe; margin-bottom: 2px; word-break: break-all; }
328
+ #panel-path { font-size: 11px; color: #64748b; word-break: break-all; }
329
+ #panel-stats { font-size: 11px; color: #94a3b8; margin-top: 4px; }
330
+ #panel-body { padding: 12px 16px; }
331
+ .panel-section { margin-bottom: 16px; }
332
+ .panel-section h3 { font-size: 11px; font-weight: 600; color: #64748b; text-transform: uppercase;
333
+ letter-spacing: 0.05em; margin-bottom: 8px; }
334
+ .panel-section ul { list-style: none; }
335
+ .panel-section ul li { font-size: 12px; color: #94a3b8; padding: 3px 0; border-bottom: 1px solid #1e2130; }
336
+ .panel-section ul li:last-child { border-bottom: none; }
337
+ .panel-section ul li a { color: #a5b4fc; text-decoration: none; cursor: pointer; }
338
+ .panel-section ul li a:hover { text-decoration: underline; }
339
+ .panel-section ul li .score { font-size: 11px; color: #64748b; margin-left: 6px; }
340
+ .panel-section ul li .kind { font-size: 10px; color: #475569; margin-left: 4px; background: #1e2130;
341
+ padding: 1px 4px; border-radius: 3px; }
342
+ .panel-section .empty { color: #475569; font-size: 12px; font-style: italic; }
343
+ .node circle { cursor: pointer; transition: r 0.15s; }
344
+ .node text { pointer-events: none; font-size: 10px; fill: #cbd5e1; }
345
+ .link { stroke: #3b4168; stroke-opacity: 0.6; fill: none; }
346
+ .link.highlighted { stroke: #a5b4fc; stroke-opacity: 1; stroke-width: 1.5; }
347
+ .link-semantic { stroke: #f59e0b; stroke-opacity: 0.35; stroke-dasharray: 5,3; fill: none; }
348
+ .link-semantic.highlighted { stroke-opacity: 0.9; stroke-dasharray: none; }
349
+ .node.dimmed circle { opacity: 0.12; }
350
+ .node.dimmed text { opacity: 0.06; }
351
+ .node.highlighted circle { stroke: #a5b4fc !important; stroke-width: 2px !important; }
352
+ .node.selected circle { stroke: #f59e0b !important; stroke-width: 2.5px !important; }
353
+ </style>
354
+ </head>
355
+ <body>
356
+ <div id="toolbar">
357
+ <h1>Doc Graph — ${title}</h1>
358
+ <span id="stats"></span>
359
+ <label class="toggle-label toggle-links" title="Toggle link edges (solid lines)">
360
+ <input type="checkbox" id="chk-links" checked> Links
361
+ </label>
362
+ <label class="toggle-label toggle-semantic${hasSemantic ? '' : ' disabled'}"
363
+ title="${hasSemantic ? 'Toggle semantic similarity edges (dashed lines)' : 'Run graph --semantic to enable'}">
364
+ <input type="checkbox" id="chk-semantic"${hasSemantic ? ' checked' : ' disabled'}> Semantic
365
+ </label>
366
+ </div>
367
+ <div id="graph-wrap">
368
+ <svg id="graph"></svg>
369
+ </div>
370
+ <div id="panel">
371
+ <div id="panel-header">
372
+ <div>
373
+ <div id="panel-title"></div>
374
+ <div id="panel-path"></div>
375
+ <div id="panel-stats"></div>
376
+ </div>
377
+ <button id="panel-close" title="Close panel">&#x2715;</button>
378
+ </div>
379
+ <div id="panel-body"></div>
380
+ </div>
381
+ <div id="tooltip"></div>
382
+ <script>
383
+ const DATA = ${jsonData};
384
+ const HAS_SEMANTIC = ${hasSemantic};
385
+
386
+ // Node map for fast lookup
387
+ const nodeById = new Map(DATA.nodes.map(n => [n.id, n]));
388
+
389
+ // Build adjacency maps for highlight
390
+ const adjLink = new Map();
391
+ const adjSemantic = new Map();
392
+ for (const n of DATA.nodes) { adjLink.set(n.id, new Set()); adjSemantic.set(n.id, new Set()); }
393
+ for (const e of DATA.edges) {
394
+ if (adjLink.has(e.source)) adjLink.get(e.source).add(e.target);
395
+ if (adjLink.has(e.target)) adjLink.get(e.target).add(e.source);
396
+ }
397
+ for (const e of DATA.semanticEdges) {
398
+ if (adjSemantic.has(e.source)) adjSemantic.get(e.source).add(e.target);
399
+ if (adjSemantic.has(e.target)) adjSemantic.get(e.target).add(e.source);
400
+ }
401
+
402
+ const linkCount = DATA.edges.length;
403
+ const semCount = DATA.semanticEdges.length;
404
+ document.getElementById('stats').textContent =
405
+ DATA.nodes.length + ' pages · ' + linkCount + ' links' +
406
+ (semCount > 0 ? ' · ' + semCount + ' semantic' : '') +
407
+ ' · ' + DATA.unresolvedCount + ' unresolved';
408
+
409
+ const graphWrap = document.getElementById('graph-wrap');
410
+ function wrapW() { return graphWrap.offsetWidth || window.innerWidth; }
411
+ function wrapH() { return graphWrap.offsetHeight || (window.innerHeight - 44); }
412
+
413
+ const svg = d3.select('svg#graph');
414
+
415
+ const g = svg.append('g');
416
+
417
+ // Zoom + click-on-background
418
+ svg.call(d3.zoom()
419
+ .scaleExtent([0.05, 4])
420
+ .on('zoom', (event) => g.attr('transform', event.transform)));
421
+
422
+ svg.on('click', (event) => {
423
+ if (event.target.tagName === 'svg' || event.target.tagName === 'rect') {
424
+ clearHighlight();
425
+ closePanel();
426
+ }
427
+ });
428
+
429
+ // Arrow markers
430
+ const defs = svg.append('defs');
431
+ defs.append('marker').attr('id','arrow')
432
+ .attr('viewBox','0 -4 8 8').attr('refX',14).attr('refY',0)
433
+ .attr('markerWidth',6).attr('markerHeight',6).attr('orient','auto')
434
+ .append('path').attr('d','M0,-4L8,0L0,4').attr('fill','#3b4168');
435
+ defs.append('marker').attr('id','arrow-sem')
436
+ .attr('viewBox','0 -4 8 8').attr('refX',14).attr('refY',0)
437
+ .attr('markerWidth',6).attr('markerHeight',6).attr('orient','auto')
438
+ .append('path').attr('d','M0,-4L8,0L0,4').attr('fill','#f59e0b');
439
+
440
+ // Semantic edges (behind links)
441
+ const semanticLinkSel = g.append('g').selectAll('line')
442
+ .data(DATA.semanticEdges)
443
+ .join('line')
444
+ .attr('class','link-semantic')
445
+ .attr('stroke-width', d => Math.max(0.8, d.score * 2.5))
446
+ .attr('marker-end','url(#arrow-sem)');
447
+
448
+ // Link edges
449
+ const linkSel = g.append('g').selectAll('line')
450
+ .data(DATA.edges)
451
+ .join('line')
452
+ .attr('class','link')
453
+ .attr('marker-end','url(#arrow)');
454
+
455
+ // Color by first path segment
456
+ const color = d3.scaleOrdinal(d3.schemeTableau10);
457
+ const segColor = id => color(id.split('/')[0] ?? '');
458
+
459
+ let selectedNodeId = null;
460
+
461
+ // Nodes
462
+ const nodeSel = g.append('g').selectAll('g')
463
+ .data(DATA.nodes)
464
+ .join('g')
465
+ .attr('class','node')
466
+ .call(d3.drag()
467
+ .on('start',(ev,d)=>{ if(!ev.active) sim.alphaTarget(0.3).restart(); d.fx=d.x; d.fy=d.y; })
468
+ .on('drag',(ev,d)=>{ d.fx=ev.x; d.fy=ev.y; })
469
+ .on('end',(ev,d)=>{ if(!ev.active) sim.alphaTarget(0); d.fx=null; d.fy=null; }));
470
+
471
+ nodeSel.append('circle')
472
+ .attr('r', d => Math.max(5, Math.min(14, Math.sqrt(d.charCount/120))))
473
+ .attr('fill', d => segColor(d.id))
474
+ .attr('fill-opacity', 0.85)
475
+ .attr('stroke', d => segColor(d.id))
476
+ .attr('stroke-width', 1);
477
+
478
+ nodeSel.append('text')
479
+ .attr('dx', d => Math.max(5, Math.min(14, Math.sqrt(d.charCount/120))) + 3)
480
+ .attr('dy', '0.35em')
481
+ .text(d => d.title.length > 24 ? d.title.slice(0,22) + '\\u2026' : d.title);
482
+
483
+ // Tooltip
484
+ const tooltip = document.getElementById('tooltip');
485
+ nodeSel
486
+ .on('mouseover', (ev,d) => {
487
+ tooltip.style.display = 'block';
488
+ tooltip.innerHTML =
489
+ '<div class="title">'+esc(d.title)+'</div>'+
490
+ '<div class="path">'+esc(d.id)+'</div>'+
491
+ '<div class="stats">'+d.charCount.toLocaleString()+' chars · '+d.sectionsCount+' sections</div>';
492
+ if (!selectedNodeId) highlightNode(d);
493
+ })
494
+ .on('mousemove', ev => {
495
+ tooltip.style.left = (ev.clientX+14)+'px';
496
+ tooltip.style.top = (ev.clientY+14)+'px';
497
+ })
498
+ .on('mouseout', () => {
499
+ tooltip.style.display='none';
500
+ if (!selectedNodeId) clearHighlight();
501
+ })
502
+ .on('click', (ev,d) => {
503
+ ev.stopPropagation();
504
+ if (selectedNodeId === d.id) {
505
+ selectedNodeId = null;
506
+ clearHighlight();
507
+ closePanel();
508
+ } else {
509
+ selectedNodeId = d.id;
510
+ highlightNode(d);
511
+ openPanel(d);
512
+ }
513
+ });
514
+
515
+ function esc(s) {
516
+ return String(s)
517
+ .replace(/&/g,'&amp;').replace(/</g,'&lt;')
518
+ .replace(/>/g,'&gt;').replace(/"/g,'&quot;');
519
+ }
520
+
521
+ function highlightNode(d) {
522
+ const ln = adjLink.get(d.id) ?? new Set();
523
+ const sn = adjSemantic.get(d.id) ?? new Set();
524
+ const all = new Set([...ln, ...sn]);
525
+ nodeSel
526
+ .classed('dimmed', n => n.id !== d.id && !all.has(n.id))
527
+ .classed('highlighted',n => n.id !== d.id && all.has(n.id))
528
+ .classed('selected', n => n.id === d.id);
529
+ linkSel.classed('highlighted',
530
+ e => e.source.id === d.id || e.target.id === d.id);
531
+ semanticLinkSel.classed('highlighted',
532
+ e => e.source.id === d.id || e.target.id === d.id);
533
+ }
534
+
535
+ function clearHighlight() {
536
+ selectedNodeId = null;
537
+ nodeSel.classed('dimmed',false).classed('highlighted',false).classed('selected',false);
538
+ linkSel.classed('highlighted',false);
539
+ semanticLinkSel.classed('highlighted',false);
540
+ }
541
+
542
+ // ── Side panel ────────────────────────────────────────────────────────────────
543
+
544
+ const panel = document.getElementById('panel');
545
+ const panelTitle = document.getElementById('panel-title');
546
+ const panelPath = document.getElementById('panel-path');
547
+ const panelStats = document.getElementById('panel-stats');
548
+ const panelBody = document.getElementById('panel-body');
549
+
550
+ document.getElementById('panel-close').addEventListener('click', () => {
551
+ clearHighlight(); closePanel();
552
+ });
553
+
554
+ function openPanel(d) {
555
+ panelTitle.textContent = d.title;
556
+ panelPath.textContent = d.id;
557
+ panelStats.textContent = d.charCount.toLocaleString()+' chars · '+d.sectionsCount+' sections';
558
+ panelBody.innerHTML = '';
559
+
560
+ // Sections
561
+ if (d.sections && d.sections.length > 0) {
562
+ const sec = ps('Sections ('+d.sections.length+')');
563
+ const ul = document.createElement('ul');
564
+ const shown = d.sections.slice(0,10);
565
+ for (const s of shown) {
566
+ const li = document.createElement('li');
567
+ const label = s.headingPath.length > 0
568
+ ? s.headingPath[s.headingPath.length-1]
569
+ : (s.anchor ?? '(section)');
570
+ li.innerHTML = esc(label)+(s.anchor ? ' <span class="kind">#'+esc(s.anchor)+'</span>' : '');
571
+ ul.appendChild(li);
572
+ }
573
+ if (d.sections.length > 10) {
574
+ const li = document.createElement('li');
575
+ li.className = 'empty';
576
+ li.textContent = '… and '+(d.sections.length-10)+' more';
577
+ ul.appendChild(li);
578
+ }
579
+ sec.appendChild(ul); panelBody.appendChild(sec);
580
+ }
581
+
582
+ // Outgoing links
583
+ {
584
+ const out = d.outgoing || [];
585
+ const sec = ps('Links from this page'+(out.length ? ' ('+out.length+')' : ''));
586
+ if (out.length > 0) {
587
+ const ul = document.createElement('ul');
588
+ for (const o of out.slice(0,15)) {
589
+ const li = document.createElement('li');
590
+ const a = document.createElement('a');
591
+ a.textContent = (o.label && o.label !== o.target) ? o.label : o.target;
592
+ a.title = o.target;
593
+ a.addEventListener('click', ()=>focusNode(o.target));
594
+ li.appendChild(a);
595
+ const sp = document.createElement('span');
596
+ sp.className = 'kind'; sp.textContent = o.kind;
597
+ li.appendChild(sp);
598
+ ul.appendChild(li);
599
+ }
600
+ if (out.length > 15) {
601
+ const li = document.createElement('li');
602
+ li.className = 'empty';
603
+ li.textContent = '… and '+(out.length-15)+' more';
604
+ ul.appendChild(li);
605
+ }
606
+ sec.appendChild(ul);
607
+ } else {
608
+ sec.appendChild(empty('None'));
609
+ }
610
+ panelBody.appendChild(sec);
611
+ }
612
+
613
+ // Incoming links
614
+ {
615
+ const inc = d.incoming || [];
616
+ const sec = ps('Pages linking here'+(inc.length ? ' ('+inc.length+')' : ''));
617
+ if (inc.length > 0) {
618
+ const ul = document.createElement('ul');
619
+ for (const i of inc.slice(0,15)) {
620
+ const li = document.createElement('li');
621
+ const a = document.createElement('a');
622
+ a.textContent = i.source; a.title = i.source;
623
+ a.addEventListener('click', ()=>focusNode(i.source));
624
+ li.appendChild(a);
625
+ ul.appendChild(li);
626
+ }
627
+ if (inc.length > 15) {
628
+ const li = document.createElement('li');
629
+ li.className = 'empty';
630
+ li.textContent = '… and '+(inc.length-15)+' more';
631
+ ul.appendChild(li);
632
+ }
633
+ sec.appendChild(ul);
634
+ } else {
635
+ sec.appendChild(empty('None (orphan page)'));
636
+ }
637
+ panelBody.appendChild(sec);
638
+ }
639
+
640
+ // Semantic similar
641
+ if (HAS_SEMANTIC) {
642
+ const sim2 = d.topSimilar || [];
643
+ const sec = ps('Semantically similar'+(sim2.length ? ' ('+sim2.length+')' : ''));
644
+ if (sim2.length > 0) {
645
+ const ul = document.createElement('ul');
646
+ for (const s of sim2) {
647
+ const li = document.createElement('li');
648
+ const a = document.createElement('a');
649
+ a.textContent = s.title ?? s.path; a.title = s.path;
650
+ a.addEventListener('click', ()=>focusNode(s.path));
651
+ li.appendChild(a);
652
+ const sp = document.createElement('span');
653
+ sp.className = 'score'; sp.textContent = s.score.toFixed(3);
654
+ li.appendChild(sp);
655
+ ul.appendChild(li);
656
+ }
657
+ sec.appendChild(ul);
658
+ } else {
659
+ sec.appendChild(empty('No pages above threshold'));
660
+ }
661
+ panelBody.appendChild(sec);
662
+ }
663
+
664
+ panel.classList.add('open');
665
+ graphWrap.classList.add('panel-open');
666
+ if (sim) sim.force('center', d3.forceCenter(wrapW()/2, wrapH()/2)).alpha(0.1).restart();
667
+ }
668
+
669
+ function closePanel() {
670
+ panel.classList.remove('open');
671
+ graphWrap.classList.remove('panel-open');
672
+ if (sim) sim.force('center', d3.forceCenter(wrapW()/2, wrapH()/2)).alpha(0.1).restart();
673
+ }
674
+
675
+ function focusNode(nodeId) {
676
+ const node = DATA.nodes.find(n => n.id === nodeId);
677
+ if (!node) return;
678
+ selectedNodeId = nodeId;
679
+ highlightNode(node);
680
+ openPanel(node);
681
+ }
682
+
683
+ function ps(heading) {
684
+ const div = document.createElement('div'); div.className = 'panel-section';
685
+ const h3 = document.createElement('h3'); h3.textContent = heading;
686
+ div.appendChild(h3); return div;
687
+ }
688
+ function empty(text) {
689
+ const p = document.createElement('p'); p.className = 'empty'; p.textContent = text; return p;
690
+ }
691
+
692
+ // ── Checkbox toggles ──────────────────────────────────────────────────────────
693
+
694
+ document.getElementById('chk-links').addEventListener('change', ev => {
695
+ linkSel.style('display', ev.target.checked ? null : 'none');
696
+ });
697
+ document.getElementById('chk-semantic').addEventListener('change', ev => {
698
+ semanticLinkSel.style('display', ev.target.checked ? null : 'none');
699
+ });
700
+
701
+ // ── Force simulation ──────────────────────────────────────────────────────────
702
+
703
+ const allSimEdges = [...DATA.edges, ...DATA.semanticEdges];
704
+
705
+ const sim = d3.forceSimulation(DATA.nodes)
706
+ .force('link', d3.forceLink(allSimEdges).id(d => d.id)
707
+ .distance(80).strength(e => e.kind === 'semantic' ? 0.2 : 0.5))
708
+ .force('charge', d3.forceManyBody().strength(-180))
709
+ .force('center', d3.forceCenter(wrapW()/2, wrapH()/2))
710
+ .force('collide', d3.forceCollide(18))
711
+ .on('tick', () => {
712
+ linkSel
713
+ .attr('x1', d => d.source.x).attr('y1', d => d.source.y)
714
+ .attr('x2', d => d.target.x).attr('y2', d => d.target.y);
715
+ semanticLinkSel
716
+ .attr('x1', d => d.source.x).attr('y1', d => d.source.y)
717
+ .attr('x2', d => d.target.x).attr('y2', d => d.target.y);
718
+ nodeSel.attr('transform', d => 'translate('+d.x+','+d.y+')');
719
+ });
720
+ </script>
721
+ </body>
722
+ </html>`;
723
+ }
724
+ // ── Main ──────────────────────────────────────────────────────────────────────
725
+ async function startLspServer() {
70
726
  await import("./lsp.js");
71
727
  }
72
728
  async function main() {
@@ -82,7 +738,7 @@ async function main() {
82
738
  process.exit(0);
83
739
  }
84
740
  // Old LSP invocation style — keep working so editor configs don't break.
85
- await startLsp();
741
+ await startLspServer();
86
742
  return;
87
743
  }
88
744
  const subcommand = argv[0];
@@ -94,7 +750,7 @@ async function main() {
94
750
  }
95
751
  // LSP subcommand
96
752
  if (subcommand === "lsp" || subcommand === "serve") {
97
- await startLsp();
753
+ await startLspServer();
98
754
  return;
99
755
  }
100
756
  // Parse global flags from the remaining args
@@ -253,13 +909,146 @@ async function main() {
253
909
  out(section, pretty);
254
910
  break;
255
911
  }
912
+ case "graph": {
913
+ const { values, positionals } = parseArgs({
914
+ args: filteredRest,
915
+ options: {
916
+ format: { type: "string" },
917
+ out: { type: "string" },
918
+ semantic: { type: "boolean" },
919
+ "sim-threshold": { type: "string" },
920
+ "sim-top-k": { type: "string" },
921
+ model: { type: "string" },
922
+ },
923
+ allowPositionals: true,
924
+ });
925
+ const docsDir = positionals[0] ?? die("graph requires <docs-dir>");
926
+ const format = (values.format ?? "json");
927
+ const semantic = values.semantic ?? false;
928
+ const simThreshold = values["sim-threshold"] !== undefined
929
+ ? parseFloat(values["sim-threshold"])
930
+ : 0.75;
931
+ const simTopK = values["sim-top-k"] !== undefined
932
+ ? parseInt(values["sim-top-k"], 10)
933
+ : 5;
934
+ const modelOverride = values.model;
935
+ const graph = buildGraph(docsDir);
936
+ const raw = graph.toJSON();
937
+ const data = buildGraphExport(raw);
938
+ if (semantic) {
939
+ try {
940
+ await addSemanticEdges(data, raw.pages, modelOverride, simThreshold, simTopK);
941
+ }
942
+ catch (err) {
943
+ const msg = err instanceof Error ? err.message : String(err);
944
+ if (msg.includes("OPENROUTER_API_KEY") ||
945
+ msg.includes("AI_GATEWAY_API_KEY") ||
946
+ msg.includes("assertApiKey") ||
947
+ msg.includes("API key") ||
948
+ msg.includes("api key")) {
949
+ process.stderr.write("Error: --semantic requires an API key.\n" +
950
+ " Set OPENROUTER_API_KEY=<key> (OpenRouter — recommended)\n" +
951
+ " or AI_GATEWAY_API_KEY=<key> (Vercel AI Gateway)\n\n" +
952
+ " Default model: openai/text-embedding-3-small\n" +
953
+ " To override: --model openai/text-embedding-3-small\n" +
954
+ " If the model name is rejected try: --model text-embedding-3-small (no prefix)\n");
955
+ process.exit(1);
956
+ }
957
+ if (msg.toLowerCase().includes("model") ||
958
+ msg.includes("404") ||
959
+ msg.includes("not found")) {
960
+ process.stderr.write("Error computing semantic embeddings: " + msg + "\n" +
961
+ " Tip: try --model text-embedding-3-small (no openai/ prefix)\n" +
962
+ " or --model openai/text-embedding-3-small (with prefix for OpenRouter)\n");
963
+ process.exit(1);
964
+ }
965
+ throw err;
966
+ }
967
+ }
968
+ let result;
969
+ switch (format) {
970
+ case "json":
971
+ result = renderGraphJson(data, pretty);
972
+ break;
973
+ case "dot":
974
+ result = renderGraphDot(data);
975
+ break;
976
+ case "mermaid":
977
+ result = renderGraphMermaid(data);
978
+ break;
979
+ case "html":
980
+ result = renderGraphHtml(data, docsDir, semantic && data.semanticEdges.length > 0);
981
+ break;
982
+ default:
983
+ die(`Unknown format: ${format}. Use json, dot, mermaid, or html.`);
984
+ }
985
+ if (values.out) {
986
+ fs.writeFileSync(values.out, result, "utf8");
987
+ process.stderr.write(`Graph written to ${values.out}\n`);
988
+ }
989
+ else {
990
+ process.stdout.write(result + "\n");
991
+ }
992
+ break;
993
+ }
994
+ case "semantic-search": {
995
+ const { values, positionals } = parseArgs({
996
+ args: filteredRest,
997
+ options: {
998
+ limit: { type: "string" },
999
+ model: { type: "string" },
1000
+ },
1001
+ allowPositionals: true,
1002
+ });
1003
+ const docsDir = positionals[0] ?? die("semantic-search requires <docs-dir>");
1004
+ const query = positionals[1] ?? die("semantic-search requires <query>");
1005
+ const limit = values.limit !== undefined ? parseInt(values.limit, 10) : 10;
1006
+ const modelOverride = values.model;
1007
+ // Check API key before doing any work
1008
+ const { assertApiKey } = await import("./ai/config.js");
1009
+ assertApiKey();
1010
+ const { embedTexts, embedOne } = await import("./ai/embeddings.js");
1011
+ const graph = buildGraph(docsDir);
1012
+ const pages = graph.pages;
1013
+ // Build embedding texts (title + first 2000 chars of content per page)
1014
+ const pageTexts = pages.map((p) => {
1015
+ const titlePart = p.title ? p.title + "\n\n" : "";
1016
+ return titlePart + p.content.slice(0, 2000);
1017
+ });
1018
+ // Embed all pages (cached) and the query
1019
+ const [pageEmbeddings, queryVec] = await Promise.all([
1020
+ embedTexts(pageTexts, modelOverride, true),
1021
+ embedOne(query, modelOverride),
1022
+ ]);
1023
+ // Score each page
1024
+ const scored = pages.map((p, i) => {
1025
+ const vec = pageEmbeddings.vectors[i];
1026
+ if (!vec)
1027
+ return { page: p, score: 0 };
1028
+ return { page: p, score: cosineSim(queryVec, vec) };
1029
+ });
1030
+ scored.sort((a, b) => b.score - a.score);
1031
+ const topN = scored.slice(0, limit);
1032
+ const results = topN.map(({ page, score }) => {
1033
+ const snippet = page.content.slice(0, 200).replace(/\s+/g, " ").trim();
1034
+ return {
1035
+ pagePath: page.path,
1036
+ pageTitle: page.title ?? page.path,
1037
+ score: Math.round(score * 10000) / 10000,
1038
+ snippet: snippet.length < page.content.length ? snippet + "…" : snippet,
1039
+ };
1040
+ });
1041
+ out(results, pretty);
1042
+ break;
1043
+ }
256
1044
  default:
257
1045
  process.stderr.write(`Unknown subcommand: ${subcommand}\n\n${USAGE}\n`);
258
1046
  process.exit(1);
259
1047
  }
260
1048
  }
261
1049
  main().catch((err) => {
262
- process.stderr.write("[markdown-lsp] Fatal error: " + (err instanceof Error ? err.stack ?? err.message : String(err)) + "\n");
1050
+ process.stderr.write("[markdown-lsp] Fatal error: " +
1051
+ (err instanceof Error ? err.stack ?? err.message : String(err)) + "\n");
263
1052
  process.exit(1);
264
1053
  });
265
1054
  //# sourceMappingURL=cli.js.map