markdown-lsp 1.1.0 → 1.2.1
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 +79 -8
- package/dist/cli.js +806 -108
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,7 @@ import * as fs from "node:fs";
|
|
|
3
3
|
import { buildGraph } from "./graph.js";
|
|
4
4
|
import { listPages, searchText, searchTextRanked, searchSymbols, searchPaths, } from "./bridge/index.js";
|
|
5
5
|
const USAGE = `
|
|
6
|
-
markdown-lsp v1.1
|
|
6
|
+
markdown-lsp v1.2.1 — CLI for querying Markdown documentation graphs
|
|
7
7
|
|
|
8
8
|
USAGE
|
|
9
9
|
markdown-lsp <subcommand> [options]
|
|
@@ -39,9 +39,19 @@ SUBCOMMANDS
|
|
|
39
39
|
Retrieve a section by its anchor slug.
|
|
40
40
|
|
|
41
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>]
|
|
42
44
|
Export the doc link graph. Default format: json (nodes/edges).
|
|
43
45
|
Use --format html for a self-contained interactive D3 visualisation.
|
|
44
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)
|
|
45
55
|
|
|
46
56
|
semantic-search <docs-dir> <query> [--limit <n>] [--model <embedding-model>]
|
|
47
57
|
AI-powered semantic search using embeddings. Requires OPENROUTER_API_KEY
|
|
@@ -65,12 +75,45 @@ OUTPUT
|
|
|
65
75
|
over stdio — it does NOT print JSON.
|
|
66
76
|
|
|
67
77
|
EXAMPLES
|
|
78
|
+
# Overview of all pages
|
|
68
79
|
markdown-lsp workspace-outline ./docs
|
|
69
|
-
|
|
80
|
+
|
|
81
|
+
# Heading outline of one page
|
|
70
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
|
|
71
95
|
markdown-lsp graph ./docs --format json --pretty
|
|
72
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)
|
|
73
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
|
|
74
117
|
markdown-lsp lsp --stdio
|
|
75
118
|
`.trim();
|
|
76
119
|
function die(msg) {
|
|
@@ -80,43 +123,150 @@ function die(msg) {
|
|
|
80
123
|
function out(value, pretty) {
|
|
81
124
|
process.stdout.write((pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value)) + "\n");
|
|
82
125
|
}
|
|
83
|
-
async function startLsp() {
|
|
84
|
-
await import("./lsp.js");
|
|
85
|
-
}
|
|
86
126
|
function buildGraphExport(raw) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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`);
|
|
102
248
|
}
|
|
249
|
+
// ── Graph renderers ───────────────────────────────────────────────────────────
|
|
103
250
|
function renderGraphJson(data, pretty) {
|
|
104
251
|
return pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data);
|
|
105
252
|
}
|
|
106
253
|
function renderGraphDot(data) {
|
|
107
254
|
const lines = ['digraph G {', ' rankdir=LR;'];
|
|
108
|
-
// Declare nodes with labels
|
|
109
255
|
for (const n of data.nodes) {
|
|
110
256
|
const label = n.title.replace(/"/g, '\\"');
|
|
111
257
|
const id = n.id.replace(/"/g, '\\"');
|
|
112
258
|
lines.push(` "${id}" [label="${label}"];`);
|
|
113
259
|
}
|
|
114
|
-
// Edges
|
|
115
260
|
for (const e of data.edges) {
|
|
116
261
|
const src = e.source.replace(/"/g, '\\"');
|
|
117
262
|
const tgt = e.target.replace(/"/g, '\\"');
|
|
118
263
|
lines.push(` "${src}" -> "${tgt}";`);
|
|
119
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
|
+
}
|
|
120
270
|
lines.push('}');
|
|
121
271
|
return lines.join('\n');
|
|
122
272
|
}
|
|
@@ -127,9 +277,14 @@ function renderGraphMermaid(data) {
|
|
|
127
277
|
const tgt = e.target.replace(/"/g, '\\"');
|
|
128
278
|
lines.push(` "${src}" --> "${tgt}"`);
|
|
129
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
|
+
}
|
|
130
285
|
return lines.join('\n');
|
|
131
286
|
}
|
|
132
|
-
function renderGraphHtml(data, docsDir) {
|
|
287
|
+
function renderGraphHtml(data, docsDir, hasSemantic) {
|
|
133
288
|
const title = docsDir.split('/').filter(Boolean).pop() ?? docsDir;
|
|
134
289
|
const jsonData = JSON.stringify(data);
|
|
135
290
|
return `<!DOCTYPE html>
|
|
@@ -143,167 +298,429 @@ function renderGraphHtml(data, docsDir) {
|
|
|
143
298
|
body { font-family: system-ui, sans-serif; background: #0f1117; color: #e2e8f0; overflow: hidden; }
|
|
144
299
|
#toolbar { position: fixed; top: 0; left: 0; right: 0; height: 44px; background: #1a1d27;
|
|
145
300
|
border-bottom: 1px solid #2d3148; display: flex; align-items: center; padding: 0 16px;
|
|
146
|
-
z-index: 10; gap:
|
|
301
|
+
z-index: 10; gap: 16px; font-size: 13px; }
|
|
147
302
|
#toolbar h1 { font-size: 14px; font-weight: 600; color: #a5b4fc; }
|
|
148
303
|
#toolbar span { color: #64748b; }
|
|
149
|
-
|
|
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; }
|
|
150
312
|
#tooltip { position: fixed; pointer-events: none; background: #1e2130; border: 1px solid #3b4168;
|
|
151
313
|
border-radius: 6px; padding: 8px 12px; font-size: 12px; line-height: 1.5;
|
|
152
314
|
max-width: 280px; z-index: 20; display: none; }
|
|
153
315
|
#tooltip .title { font-weight: 600; color: #c7d2fe; margin-bottom: 2px; }
|
|
154
316
|
#tooltip .path { color: #64748b; font-size: 11px; }
|
|
155
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; }
|
|
156
343
|
.node circle { cursor: pointer; transition: r 0.15s; }
|
|
157
344
|
.node text { pointer-events: none; font-size: 10px; fill: #cbd5e1; }
|
|
158
345
|
.link { stroke: #3b4168; stroke-opacity: 0.6; fill: none; }
|
|
159
|
-
.link.highlighted { stroke: #a5b4fc; stroke-opacity: 1; }
|
|
160
|
-
.
|
|
161
|
-
.
|
|
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; }
|
|
162
351
|
.node.highlighted circle { stroke: #a5b4fc !important; stroke-width: 2px !important; }
|
|
352
|
+
.node.selected circle { stroke: #f59e0b !important; stroke-width: 2.5px !important; }
|
|
163
353
|
</style>
|
|
164
354
|
</head>
|
|
165
355
|
<body>
|
|
166
356
|
<div id="toolbar">
|
|
167
357
|
<h1>Doc Graph — ${title}</h1>
|
|
168
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">✕</button>
|
|
378
|
+
</div>
|
|
379
|
+
<div id="panel-body"></div>
|
|
169
380
|
</div>
|
|
170
381
|
<div id="tooltip"></div>
|
|
171
|
-
<svg id="graph"></svg>
|
|
172
382
|
<script>
|
|
173
383
|
const DATA = ${jsonData};
|
|
384
|
+
const HAS_SEMANTIC = ${hasSemantic};
|
|
174
385
|
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
const svg = d3.select('#graph')
|
|
178
|
-
.attr('width', width)
|
|
179
|
-
.attr('height', height);
|
|
386
|
+
// Node map for fast lookup
|
|
387
|
+
const nodeById = new Map(DATA.nodes.map(n => [n.id, n]));
|
|
180
388
|
|
|
181
|
-
// Build adjacency for highlight
|
|
182
|
-
const
|
|
183
|
-
|
|
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()); }
|
|
184
393
|
for (const e of DATA.edges) {
|
|
185
|
-
if (
|
|
186
|
-
if (
|
|
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);
|
|
187
400
|
}
|
|
188
401
|
|
|
402
|
+
const linkCount = DATA.edges.length;
|
|
403
|
+
const semCount = DATA.semanticEdges.length;
|
|
189
404
|
document.getElementById('stats').textContent =
|
|
190
|
-
DATA.nodes.length + ' pages · ' +
|
|
191
|
-
|
|
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');
|
|
192
414
|
|
|
193
415
|
const g = svg.append('g');
|
|
194
416
|
|
|
195
|
-
// Zoom
|
|
417
|
+
// Zoom + click-on-background
|
|
196
418
|
svg.call(d3.zoom()
|
|
197
419
|
.scaleExtent([0.05, 4])
|
|
198
420
|
.on('zoom', (event) => g.attr('transform', event.transform)));
|
|
199
421
|
|
|
200
|
-
|
|
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
|
|
201
449
|
const linkSel = g.append('g').selectAll('line')
|
|
202
450
|
.data(DATA.edges)
|
|
203
451
|
.join('line')
|
|
204
|
-
.attr('class',
|
|
205
|
-
.attr('marker-end',
|
|
206
|
-
|
|
207
|
-
//
|
|
208
|
-
svg.append('defs').append('marker')
|
|
209
|
-
.attr('id', 'arrow')
|
|
210
|
-
.attr('viewBox', '0 -4 8 8')
|
|
211
|
-
.attr('refX', 14)
|
|
212
|
-
.attr('refY', 0)
|
|
213
|
-
.attr('markerWidth', 6)
|
|
214
|
-
.attr('markerHeight', 6)
|
|
215
|
-
.attr('orient', 'auto')
|
|
216
|
-
.append('path')
|
|
217
|
-
.attr('d', 'M0,-4L8,0L0,4')
|
|
218
|
-
.attr('fill', '#3b4168');
|
|
219
|
-
|
|
220
|
-
// Color scale by first path segment
|
|
452
|
+
.attr('class','link')
|
|
453
|
+
.attr('marker-end','url(#arrow)');
|
|
454
|
+
|
|
455
|
+
// Color by first path segment
|
|
221
456
|
const color = d3.scaleOrdinal(d3.schemeTableau10);
|
|
222
|
-
const segColor =
|
|
457
|
+
const segColor = id => color(id.split('/')[0] ?? '');
|
|
458
|
+
|
|
459
|
+
let selectedNodeId = null;
|
|
223
460
|
|
|
224
461
|
// Nodes
|
|
225
462
|
const nodeSel = g.append('g').selectAll('g')
|
|
226
463
|
.data(DATA.nodes)
|
|
227
464
|
.join('g')
|
|
228
|
-
.attr('class',
|
|
465
|
+
.attr('class','node')
|
|
229
466
|
.call(d3.drag()
|
|
230
|
-
.on('start',
|
|
231
|
-
.on('drag',
|
|
232
|
-
.on('end',
|
|
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; }));
|
|
233
470
|
|
|
234
471
|
nodeSel.append('circle')
|
|
235
|
-
.attr('r', d => Math.max(5, Math.min(14, Math.sqrt(d.charCount
|
|
472
|
+
.attr('r', d => Math.max(5, Math.min(14, Math.sqrt(d.charCount/120))))
|
|
236
473
|
.attr('fill', d => segColor(d.id))
|
|
237
474
|
.attr('fill-opacity', 0.85)
|
|
238
475
|
.attr('stroke', d => segColor(d.id))
|
|
239
476
|
.attr('stroke-width', 1);
|
|
240
477
|
|
|
241
478
|
nodeSel.append('text')
|
|
242
|
-
.attr('dx', d => Math.max(5, Math.min(14, Math.sqrt(d.charCount
|
|
479
|
+
.attr('dx', d => Math.max(5, Math.min(14, Math.sqrt(d.charCount/120))) + 3)
|
|
243
480
|
.attr('dy', '0.35em')
|
|
244
|
-
.text(d => d.title.length > 24 ? d.title.slice(0,
|
|
481
|
+
.text(d => d.title.length > 24 ? d.title.slice(0,22) + '\\u2026' : d.title);
|
|
245
482
|
|
|
246
483
|
// Tooltip
|
|
247
484
|
const tooltip = document.getElementById('tooltip');
|
|
248
485
|
nodeSel
|
|
249
|
-
.on('mouseover', (
|
|
486
|
+
.on('mouseover', (ev,d) => {
|
|
250
487
|
tooltip.style.display = 'block';
|
|
251
|
-
tooltip.innerHTML =
|
|
252
|
-
'<div class="
|
|
253
|
-
'<div class="
|
|
254
|
-
|
|
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);
|
|
255
493
|
})
|
|
256
|
-
.on('mousemove',
|
|
257
|
-
tooltip.style.left = (
|
|
258
|
-
tooltip.style.top
|
|
494
|
+
.on('mousemove', ev => {
|
|
495
|
+
tooltip.style.left = (ev.clientX+14)+'px';
|
|
496
|
+
tooltip.style.top = (ev.clientY+14)+'px';
|
|
259
497
|
})
|
|
260
498
|
.on('mouseout', () => {
|
|
261
|
-
tooltip.style.display
|
|
262
|
-
clearHighlight();
|
|
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
|
+
}
|
|
263
513
|
});
|
|
264
514
|
|
|
515
|
+
function esc(s) {
|
|
516
|
+
return String(s)
|
|
517
|
+
.replace(/&/g,'&').replace(/</g,'<')
|
|
518
|
+
.replace(/>/g,'>').replace(/"/g,'"');
|
|
519
|
+
}
|
|
520
|
+
|
|
265
521
|
function highlightNode(d) {
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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);
|
|
270
533
|
}
|
|
271
534
|
|
|
272
535
|
function clearHighlight() {
|
|
273
|
-
|
|
274
|
-
|
|
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;
|
|
275
690
|
}
|
|
276
691
|
|
|
277
|
-
//
|
|
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
|
+
|
|
278
705
|
const sim = d3.forceSimulation(DATA.nodes)
|
|
279
|
-
.force('link', d3.forceLink(
|
|
706
|
+
.force('link', d3.forceLink(allSimEdges).id(d => d.id)
|
|
707
|
+
.distance(80).strength(e => e.kind === 'semantic' ? 0.2 : 0.5))
|
|
280
708
|
.force('charge', d3.forceManyBody().strength(-180))
|
|
281
|
-
.force('center', d3.forceCenter(
|
|
709
|
+
.force('center', d3.forceCenter(wrapW()/2, wrapH()/2))
|
|
282
710
|
.force('collide', d3.forceCollide(18))
|
|
283
711
|
.on('tick', () => {
|
|
284
712
|
linkSel
|
|
285
|
-
.attr('x1', d => d.source.x)
|
|
286
|
-
.attr('
|
|
287
|
-
|
|
288
|
-
.attr('
|
|
289
|
-
|
|
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+')');
|
|
290
719
|
});
|
|
291
720
|
</script>
|
|
292
721
|
</body>
|
|
293
722
|
</html>`;
|
|
294
723
|
}
|
|
295
|
-
// ── Cosine similarity (no deps) ───────────────────────────────────────────────
|
|
296
|
-
function cosineSim(a, b) {
|
|
297
|
-
let dot = 0, na = 0, nb = 0;
|
|
298
|
-
for (let i = 0; i < a.length; i++) {
|
|
299
|
-
dot += a[i] * b[i];
|
|
300
|
-
na += a[i] * a[i];
|
|
301
|
-
nb += b[i] * b[i];
|
|
302
|
-
}
|
|
303
|
-
if (na === 0 || nb === 0)
|
|
304
|
-
return 0;
|
|
305
|
-
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
306
|
-
}
|
|
307
724
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
308
725
|
async function startLspServer() {
|
|
309
726
|
await import("./lsp.js");
|
|
@@ -331,11 +748,239 @@ async function main() {
|
|
|
331
748
|
process.stdout.write(USAGE + "\n");
|
|
332
749
|
process.exit(0);
|
|
333
750
|
}
|
|
334
|
-
// LSP subcommand
|
|
751
|
+
// LSP subcommand — check for --help/-h before starting the server
|
|
335
752
|
if (subcommand === "lsp" || subcommand === "serve") {
|
|
753
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
754
|
+
process.stdout.write(`lsp [--stdio]\nserve [--stdio]\n\n Start the LSP stdio server for editor integration.\n\n Options:\n --stdio Use stdio transport (default and recommended)\n\n Note: back-compat — --stdio | --node-ipc | --socket=<n> as first arg\n also starts the LSP server.\n\n Examples:\n markdown-lsp lsp --stdio\n markdown-lsp serve --stdio\n`);
|
|
755
|
+
process.exit(0);
|
|
756
|
+
}
|
|
336
757
|
await startLspServer();
|
|
337
758
|
return;
|
|
338
759
|
}
|
|
760
|
+
// Per-subcommand help strings
|
|
761
|
+
const SUB_USAGE = {
|
|
762
|
+
"workspace-outline": `
|
|
763
|
+
workspace-outline <docs-dir> [--prefix <p>] [--limit <n>]
|
|
764
|
+
|
|
765
|
+
List all pages in the workspace with metadata.
|
|
766
|
+
|
|
767
|
+
Arguments:
|
|
768
|
+
<docs-dir> Path to the documentation directory
|
|
769
|
+
|
|
770
|
+
Options:
|
|
771
|
+
--prefix <p> Filter pages whose path starts with <p>
|
|
772
|
+
--limit <n> Return at most <n> results
|
|
773
|
+
--pretty Pretty-print JSON output
|
|
774
|
+
|
|
775
|
+
Examples:
|
|
776
|
+
markdown-lsp workspace-outline ./docs
|
|
777
|
+
markdown-lsp workspace-outline ./docs --prefix api/ --limit 20
|
|
778
|
+
`.trim(),
|
|
779
|
+
"outline": `
|
|
780
|
+
outline <docs-dir> <page>
|
|
781
|
+
|
|
782
|
+
Show the heading outline of a single page.
|
|
783
|
+
|
|
784
|
+
Arguments:
|
|
785
|
+
<docs-dir> Path to the documentation directory
|
|
786
|
+
<page> Relative path to the page (e.g. introduction.md)
|
|
787
|
+
|
|
788
|
+
Options:
|
|
789
|
+
--pretty Pretty-print JSON output
|
|
790
|
+
|
|
791
|
+
Examples:
|
|
792
|
+
markdown-lsp outline ./docs introduction.md
|
|
793
|
+
`.trim(),
|
|
794
|
+
"search-text": `
|
|
795
|
+
search-text <docs-dir> <query> [--mode ranked|verbatim] [--regex]
|
|
796
|
+
[--case-sensitive] [--prefix <p>] [--limit <n>] [--context <n>]
|
|
797
|
+
|
|
798
|
+
Full-text search across all pages.
|
|
799
|
+
|
|
800
|
+
Arguments:
|
|
801
|
+
<docs-dir> Path to the documentation directory
|
|
802
|
+
<query> Search query (natural language or pattern)
|
|
803
|
+
|
|
804
|
+
Options:
|
|
805
|
+
--mode ranked Natural-language ranked search (default)
|
|
806
|
+
--mode verbatim Exact substring search
|
|
807
|
+
--regex Treat query as a regular expression
|
|
808
|
+
--case-sensitive Case-sensitive matching
|
|
809
|
+
--prefix <p> Filter pages whose path starts with <p>
|
|
810
|
+
--limit <n> Return at most <n> results
|
|
811
|
+
--context <n> Characters of context around each match
|
|
812
|
+
--pretty Pretty-print JSON output
|
|
813
|
+
|
|
814
|
+
Examples:
|
|
815
|
+
markdown-lsp search-text ./docs "getting started"
|
|
816
|
+
markdown-lsp search-text ./docs "webhook signing" --mode verbatim --limit 5
|
|
817
|
+
markdown-lsp search-text ./docs "auth.*token" --regex
|
|
818
|
+
`.trim(),
|
|
819
|
+
"search-symbols": `
|
|
820
|
+
search-symbols <docs-dir> <query> [--limit <n>]
|
|
821
|
+
|
|
822
|
+
Fuzzy subsequence search across all headings.
|
|
823
|
+
|
|
824
|
+
Arguments:
|
|
825
|
+
<docs-dir> Path to the documentation directory
|
|
826
|
+
<query> Heading search query
|
|
827
|
+
|
|
828
|
+
Options:
|
|
829
|
+
--limit <n> Return at most <n> results
|
|
830
|
+
--pretty Pretty-print JSON output
|
|
831
|
+
|
|
832
|
+
Examples:
|
|
833
|
+
markdown-lsp search-symbols ./docs "webhook" --limit 10
|
|
834
|
+
`.trim(),
|
|
835
|
+
"search-paths": `
|
|
836
|
+
search-paths <docs-dir> <glob>
|
|
837
|
+
|
|
838
|
+
List pages whose paths match a glob pattern (*, **, ?).
|
|
839
|
+
|
|
840
|
+
Arguments:
|
|
841
|
+
<docs-dir> Path to the documentation directory
|
|
842
|
+
<glob> Glob pattern to match against page paths
|
|
843
|
+
|
|
844
|
+
Options:
|
|
845
|
+
--pretty Pretty-print JSON output
|
|
846
|
+
|
|
847
|
+
Examples:
|
|
848
|
+
markdown-lsp search-paths ./docs "ai/*.md"
|
|
849
|
+
markdown-lsp search-paths ./docs "**/*auth*"
|
|
850
|
+
`.trim(),
|
|
851
|
+
"links-to": `
|
|
852
|
+
links-to <docs-dir> <page>
|
|
853
|
+
|
|
854
|
+
Show all pages that link to <page>.
|
|
855
|
+
|
|
856
|
+
Arguments:
|
|
857
|
+
<docs-dir> Path to the documentation directory
|
|
858
|
+
<page> Relative path to the target page
|
|
859
|
+
|
|
860
|
+
Options:
|
|
861
|
+
--pretty Pretty-print JSON output
|
|
862
|
+
|
|
863
|
+
Examples:
|
|
864
|
+
markdown-lsp links-to ./docs quick-start.md
|
|
865
|
+
`.trim(),
|
|
866
|
+
"links-from": `
|
|
867
|
+
links-from <docs-dir> <page>
|
|
868
|
+
|
|
869
|
+
Show all links that originate from <page>.
|
|
870
|
+
|
|
871
|
+
Arguments:
|
|
872
|
+
<docs-dir> Path to the documentation directory
|
|
873
|
+
<page> Relative path to the source page
|
|
874
|
+
|
|
875
|
+
Options:
|
|
876
|
+
--pretty Pretty-print JSON output
|
|
877
|
+
|
|
878
|
+
Examples:
|
|
879
|
+
markdown-lsp links-from ./docs README.md
|
|
880
|
+
`.trim(),
|
|
881
|
+
"resolve-link": `
|
|
882
|
+
resolve-link <docs-dir> <from-page> <link-text>
|
|
883
|
+
|
|
884
|
+
Resolve a specific link text from a given page.
|
|
885
|
+
|
|
886
|
+
Arguments:
|
|
887
|
+
<docs-dir> Path to the documentation directory
|
|
888
|
+
<from-page> Relative path to the source page
|
|
889
|
+
<link-text> Exact link text to resolve
|
|
890
|
+
|
|
891
|
+
Options:
|
|
892
|
+
--pretty Pretty-print JSON output
|
|
893
|
+
|
|
894
|
+
Examples:
|
|
895
|
+
markdown-lsp resolve-link ./docs README.md "Getting Started"
|
|
896
|
+
`.trim(),
|
|
897
|
+
"get-section": `
|
|
898
|
+
get-section <docs-dir> <page> <anchor>
|
|
899
|
+
|
|
900
|
+
Retrieve a section by its anchor slug.
|
|
901
|
+
|
|
902
|
+
Arguments:
|
|
903
|
+
<docs-dir> Path to the documentation directory
|
|
904
|
+
<page> Relative path to the page
|
|
905
|
+
<anchor> Section anchor slug (e.g. "quick-links")
|
|
906
|
+
|
|
907
|
+
Options:
|
|
908
|
+
--pretty Pretty-print JSON output
|
|
909
|
+
|
|
910
|
+
Examples:
|
|
911
|
+
markdown-lsp get-section ./docs overview.md "quick-links"
|
|
912
|
+
`.trim(),
|
|
913
|
+
"graph": `
|
|
914
|
+
graph <docs-dir> [--format json|dot|mermaid|html] [--out <file>]
|
|
915
|
+
[--semantic] [--sim-threshold <0-1>] [--sim-top-k <n>]
|
|
916
|
+
[--model <embedding-model>]
|
|
917
|
+
|
|
918
|
+
Export the documentation link graph.
|
|
919
|
+
|
|
920
|
+
Arguments:
|
|
921
|
+
<docs-dir> Path to the documentation directory
|
|
922
|
+
|
|
923
|
+
Options:
|
|
924
|
+
--format json JSON output with nodes/edges (default)
|
|
925
|
+
--format dot Graphviz DOT format
|
|
926
|
+
--format mermaid Mermaid diagram format
|
|
927
|
+
--format html Self-contained interactive D3 visualisation
|
|
928
|
+
--out <file> Write output to file instead of stdout
|
|
929
|
+
--semantic Overlay AI-powered similarity edges
|
|
930
|
+
--sim-threshold <0-1> Minimum cosine similarity (default: 0.75)
|
|
931
|
+
--sim-top-k <n> Max semantic neighbours per node (default: 5)
|
|
932
|
+
--model <model> Embedding model override
|
|
933
|
+
--pretty Pretty-print JSON output
|
|
934
|
+
|
|
935
|
+
Note: --semantic requires OPENROUTER_API_KEY or AI_GATEWAY_API_KEY.
|
|
936
|
+
|
|
937
|
+
Examples:
|
|
938
|
+
markdown-lsp graph ./docs --format json --pretty
|
|
939
|
+
markdown-lsp graph ./docs --format html --out graph.html
|
|
940
|
+
markdown-lsp graph ./docs --format dot | dot -Tsvg > graph.svg
|
|
941
|
+
markdown-lsp graph ./docs --format html --semantic --out graph.html
|
|
942
|
+
`.trim(),
|
|
943
|
+
"semantic-search": `
|
|
944
|
+
semantic-search <docs-dir> <query> [--limit <n>] [--model <embedding-model>]
|
|
945
|
+
|
|
946
|
+
AI-powered semantic search using embeddings.
|
|
947
|
+
|
|
948
|
+
Arguments:
|
|
949
|
+
<docs-dir> Path to the documentation directory
|
|
950
|
+
<query> Natural-language search query
|
|
951
|
+
|
|
952
|
+
Options:
|
|
953
|
+
--limit <n> Return at most <n> results (default: 10)
|
|
954
|
+
--model <m> Embedding model override
|
|
955
|
+
--pretty Pretty-print JSON output
|
|
956
|
+
|
|
957
|
+
Note: requires OPENROUTER_API_KEY or AI_GATEWAY_API_KEY.
|
|
958
|
+
Default embedding model: openai/text-embedding-3-small.
|
|
959
|
+
Results are cached in .markdown-lsp-cache/.
|
|
960
|
+
|
|
961
|
+
Examples:
|
|
962
|
+
markdown-lsp semantic-search ./docs "how to set up webhooks" --limit 5
|
|
963
|
+
markdown-lsp semantic-search ./docs "authentication" --model openai/text-embedding-3-small
|
|
964
|
+
`.trim(),
|
|
965
|
+
"lsp": `
|
|
966
|
+
lsp [--stdio]
|
|
967
|
+
serve [--stdio]
|
|
968
|
+
|
|
969
|
+
Start the LSP stdio server for editor integration.
|
|
970
|
+
|
|
971
|
+
Options:
|
|
972
|
+
--stdio Use stdio transport (default and recommended)
|
|
973
|
+
|
|
974
|
+
Note: back-compat — --stdio | --node-ipc | --socket=<n> as first arg
|
|
975
|
+
also starts the LSP server.
|
|
976
|
+
|
|
977
|
+
Examples:
|
|
978
|
+
markdown-lsp lsp --stdio
|
|
979
|
+
markdown-lsp serve --stdio
|
|
980
|
+
`.trim(),
|
|
981
|
+
};
|
|
982
|
+
// Alias: "serve" shares lsp help
|
|
983
|
+
SUB_USAGE["serve"] = SUB_USAGE["lsp"];
|
|
339
984
|
// Parse global flags from the remaining args
|
|
340
985
|
let pretty = false;
|
|
341
986
|
const filteredRest = [];
|
|
@@ -345,6 +990,17 @@ async function main() {
|
|
|
345
990
|
else
|
|
346
991
|
filteredRest.push(arg);
|
|
347
992
|
}
|
|
993
|
+
// Per-subcommand --help / -h: intercept before parseArgs sees it
|
|
994
|
+
if (filteredRest.includes("--help") || filteredRest.includes("-h")) {
|
|
995
|
+
const subUsage = SUB_USAGE[subcommand];
|
|
996
|
+
if (subUsage) {
|
|
997
|
+
process.stdout.write(subUsage + "\n");
|
|
998
|
+
}
|
|
999
|
+
else {
|
|
1000
|
+
process.stdout.write(USAGE + "\n");
|
|
1001
|
+
}
|
|
1002
|
+
process.exit(0);
|
|
1003
|
+
}
|
|
348
1004
|
switch (subcommand) {
|
|
349
1005
|
case "workspace-outline": {
|
|
350
1006
|
const { values, positionals } = parseArgs({
|
|
@@ -498,14 +1154,56 @@ async function main() {
|
|
|
498
1154
|
options: {
|
|
499
1155
|
format: { type: "string" },
|
|
500
1156
|
out: { type: "string" },
|
|
1157
|
+
semantic: { type: "boolean" },
|
|
1158
|
+
"sim-threshold": { type: "string" },
|
|
1159
|
+
"sim-top-k": { type: "string" },
|
|
1160
|
+
model: { type: "string" },
|
|
501
1161
|
},
|
|
502
1162
|
allowPositionals: true,
|
|
503
1163
|
});
|
|
504
1164
|
const docsDir = positionals[0] ?? die("graph requires <docs-dir>");
|
|
505
1165
|
const format = (values.format ?? "json");
|
|
1166
|
+
const semantic = values.semantic ?? false;
|
|
1167
|
+
const simThreshold = values["sim-threshold"] !== undefined
|
|
1168
|
+
? parseFloat(values["sim-threshold"])
|
|
1169
|
+
: 0.75;
|
|
1170
|
+
const simTopK = values["sim-top-k"] !== undefined
|
|
1171
|
+
? parseInt(values["sim-top-k"], 10)
|
|
1172
|
+
: 5;
|
|
1173
|
+
const modelOverride = values.model;
|
|
506
1174
|
const graph = buildGraph(docsDir);
|
|
507
1175
|
const raw = graph.toJSON();
|
|
508
1176
|
const data = buildGraphExport(raw);
|
|
1177
|
+
if (semantic) {
|
|
1178
|
+
try {
|
|
1179
|
+
await addSemanticEdges(data, raw.pages, modelOverride, simThreshold, simTopK);
|
|
1180
|
+
}
|
|
1181
|
+
catch (err) {
|
|
1182
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1183
|
+
if (msg.includes("OPENROUTER_API_KEY") ||
|
|
1184
|
+
msg.includes("AI_GATEWAY_API_KEY") ||
|
|
1185
|
+
msg.includes("assertApiKey") ||
|
|
1186
|
+
msg.includes("API key") ||
|
|
1187
|
+
msg.includes("api key")) {
|
|
1188
|
+
process.stderr.write("Error: --semantic requires an API key.\n" +
|
|
1189
|
+
" Set OPENROUTER_API_KEY=<key> (OpenRouter — recommended)\n" +
|
|
1190
|
+
" or AI_GATEWAY_API_KEY=<key> (Vercel AI Gateway)\n\n" +
|
|
1191
|
+
" Default model: openai/text-embedding-3-small\n" +
|
|
1192
|
+
" To override: --model openai/text-embedding-3-small\n" +
|
|
1193
|
+
" If the model name is rejected try: --model text-embedding-3-small (no prefix)\n");
|
|
1194
|
+
process.exit(1);
|
|
1195
|
+
}
|
|
1196
|
+
if (msg.toLowerCase().includes("model") ||
|
|
1197
|
+
msg.includes("404") ||
|
|
1198
|
+
msg.includes("not found")) {
|
|
1199
|
+
process.stderr.write("Error computing semantic embeddings: " + msg + "\n" +
|
|
1200
|
+
" Tip: try --model text-embedding-3-small (no openai/ prefix)\n" +
|
|
1201
|
+
" or --model openai/text-embedding-3-small (with prefix for OpenRouter)\n");
|
|
1202
|
+
process.exit(1);
|
|
1203
|
+
}
|
|
1204
|
+
throw err;
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
509
1207
|
let result;
|
|
510
1208
|
switch (format) {
|
|
511
1209
|
case "json":
|
|
@@ -518,7 +1216,7 @@ async function main() {
|
|
|
518
1216
|
result = renderGraphMermaid(data);
|
|
519
1217
|
break;
|
|
520
1218
|
case "html":
|
|
521
|
-
result = renderGraphHtml(data, docsDir);
|
|
1219
|
+
result = renderGraphHtml(data, docsDir, semantic && data.semanticEdges.length > 0);
|
|
522
1220
|
break;
|
|
523
1221
|
default:
|
|
524
1222
|
die(`Unknown format: ${format}. Use json, dot, mermaid, or html.`);
|
|
@@ -571,7 +1269,6 @@ async function main() {
|
|
|
571
1269
|
scored.sort((a, b) => b.score - a.score);
|
|
572
1270
|
const topN = scored.slice(0, limit);
|
|
573
1271
|
const results = topN.map(({ page, score }) => {
|
|
574
|
-
// Extract a short snippet (first 200 chars of content)
|
|
575
1272
|
const snippet = page.content.slice(0, 200).replace(/\s+/g, " ").trim();
|
|
576
1273
|
return {
|
|
577
1274
|
pagePath: page.path,
|
|
@@ -589,7 +1286,8 @@ async function main() {
|
|
|
589
1286
|
}
|
|
590
1287
|
}
|
|
591
1288
|
main().catch((err) => {
|
|
592
|
-
process.stderr.write("[markdown-lsp] Fatal error: " +
|
|
1289
|
+
process.stderr.write("[markdown-lsp] Fatal error: " +
|
|
1290
|
+
(err instanceof Error ? err.stack ?? err.message : String(err)) + "\n");
|
|
593
1291
|
process.exit(1);
|
|
594
1292
|
});
|
|
595
1293
|
//# sourceMappingURL=cli.js.map
|