mdzilla 0.0.6 → 0.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/main.mjs CHANGED
@@ -1,43 +1,23 @@
1
1
  #!/usr/bin/env node
2
- import { a as DocsSourceFS, i as DocsSourceGit, n as DocsSourceNpm, r as DocsSourceHTTP, s as DocsManager, t as exportDocsToFS } from "../_chunks/exporter.mjs";
2
+ import { l as extractSnippets, n as writeCollection, r as resolveSource, u as Collection } from "../_chunks/exporter.mjs";
3
+ import { parseMeta, renderToAnsi, renderToText } from "md4x";
3
4
  import { readFile } from "node:fs/promises";
4
5
  import { basename } from "node:path";
5
- import { parseMeta, renderToAnsi, renderToText } from "md4x";
6
6
  import { parseArgs } from "node:util";
7
7
  import { isAgent } from "std-env";
8
8
  import { highlightText } from "@speed-highlight/core/terminal";
9
- import { exec, execSync } from "node:child_process";
9
+ import { exec, execFile } from "node:child_process";
10
10
  //#region src/cli/_ansi.ts
11
11
  const noColor = !!(process.env.NO_COLOR || process.env.TERM === "dumb" || !process.stdout.isTTY || isAgent);
12
12
  const ESC = "\x1B[";
13
13
  const ANSI_RE = /\x1B(?:\[[0-9;]*[a-zA-Z]|\][^\x07\x1B]*(?:\x07|\x1B\\))/g;
14
- const clear = () => process.stdout.write(`${ESC}2J${ESC}3J${ESC}H`);
15
- const enterAltScreen = () => process.stdout.write(`${ESC}?1049h`);
16
- const leaveAltScreen = () => process.stdout.write(`${ESC}?1049l`);
17
- const hideCursor = () => process.stdout.write(`${ESC}?25l`);
18
- const showCursor = () => process.stdout.write(`${ESC}?25h`);
19
14
  const bold = (s) => noColor ? s : `${ESC}1m${s}${ESC}0m`;
20
15
  const dim = (s) => noColor ? s : `${ESC}2m${s}${ESC}0m`;
21
16
  const cyan = (s) => noColor ? s : `${ESC}36m${s}${ESC}0m`;
22
17
  const yellow = (s) => noColor ? s : `${ESC}33m${s}${ESC}0m`;
23
- const bgGray = (s) => {
24
- if (noColor) return s;
25
- const bg = `${ESC}48;5;237m`;
26
- return `${bg}${s.replaceAll(`${ESC}0m`, `${ESC}0m${bg}`)}${ESC}0m`;
27
- };
28
18
  const stripAnsi = (s) => s.replace(ANSI_RE, "");
29
- const visibleLength = (s) => stripAnsi(s).length;
30
- function padTo(s, width) {
31
- const visible = visibleLength(s);
32
- if (visible >= width) return truncateTo(s, width);
33
- return s + " ".repeat(width - visible);
34
- }
35
- function truncateTo(s, width) {
36
- if (visibleLength(s) <= width) return s;
37
- return wrapAnsi(s, width)[0] || "";
38
- }
39
19
  function wrapAnsi(s, width) {
40
- if (width <= 0 || visibleLength(s) <= width) return [s];
20
+ if (width <= 0 || stripAnsi(s).length <= width) return [s];
41
21
  const tokens = [];
42
22
  let last = 0;
43
23
  const re = new RegExp(ANSI_RE.source, "g");
@@ -83,74 +63,18 @@ function wrapAnsi(s, width) {
83
63
  }
84
64
  return lines;
85
65
  }
86
- /** Highlight query matches inside an already-ANSI-styled string */
87
- function highlightAnsi(s, query) {
88
- if (!query) return s;
89
- const re = new RegExp(ANSI_RE.source, "g");
90
- const visibleChars = [];
91
- let last = 0;
92
- let match;
93
- while ((match = re.exec(s)) !== null) {
94
- for (let i = last; i < match.index; i++) visibleChars.push({
95
- char: s[i],
96
- rawIdx: i
97
- });
98
- last = match.index + match[0].length;
99
- }
100
- for (let i = last; i < s.length; i++) visibleChars.push({
101
- char: s[i],
102
- rawIdx: i
103
- });
104
- const lowerVisible = visibleChars.map((c) => c.char).join("").toLowerCase();
66
+ function highlight(text, query) {
67
+ if (!query) return text;
68
+ const lowerText = text.toLowerCase();
105
69
  const lowerQuery = query.toLowerCase();
106
- const matches = [];
107
- let pos = 0;
108
- while ((pos = lowerVisible.indexOf(lowerQuery, pos)) >= 0) {
109
- matches.push([pos, pos + query.length]);
110
- pos += 1;
111
- }
112
- if (matches.length === 0) return s;
113
- const hlRanges = [];
114
- for (const [start, end] of matches) {
115
- const rawStart = visibleChars[start].rawIdx;
116
- const rawEnd = end < visibleChars.length ? visibleChars[end].rawIdx : s.length;
117
- hlRanges.push([rawStart, rawEnd]);
118
- }
119
- const hlOn = `${ESC}7m`;
120
- const hlOff = `${ESC}27m`;
121
- const resetSeq = `${ESC}0m`;
122
70
  let result = "";
123
- let inHighlight = false;
124
- let rawIdx = 0;
125
- const escRe = new RegExp(ANSI_RE.source, "g");
126
- let escMatch;
127
- const escapes = [];
128
- while ((escMatch = escRe.exec(s)) !== null) escapes.push({
129
- idx: escMatch.index,
130
- seq: escMatch[0]
131
- });
132
- let escIdx = 0;
133
- for (rawIdx = 0; rawIdx <= s.length; rawIdx++) {
134
- const wasIn = inHighlight;
135
- inHighlight = hlRanges.some(([a, b]) => rawIdx >= a && rawIdx < b);
136
- if (inHighlight && !wasIn) result += hlOn;
137
- if (!inHighlight && wasIn) result += hlOff;
138
- if (rawIdx >= s.length) break;
139
- if (escIdx < escapes.length && escapes[escIdx].idx === rawIdx) {
140
- const esc = escapes[escIdx];
141
- result += esc.seq;
142
- if (inHighlight && esc.seq === resetSeq) result += hlOn;
143
- rawIdx += esc.seq.length - 1;
144
- escIdx++;
145
- } else result += s[rawIdx];
71
+ let last = 0;
72
+ let pos = 0;
73
+ while ((pos = lowerText.indexOf(lowerQuery, last)) >= 0) {
74
+ result += text.slice(last, pos) + bold(yellow(text.slice(pos, pos + query.length)));
75
+ last = pos + query.length;
146
76
  }
147
- return result;
148
- }
149
- function highlight(text, query) {
150
- if (!query) return text;
151
- const idx = text.toLowerCase().indexOf(query.toLowerCase());
152
- if (idx < 0) return text;
153
- return text.slice(0, idx) + bold(yellow(text.slice(idx, idx + query.length))) + text.slice(idx + query.length);
77
+ return last === 0 ? text : result + text.slice(last);
154
78
  }
155
79
  //#endregion
156
80
  //#region src/cli/_usage.ts
@@ -175,31 +99,34 @@ function printUsage(hasInput) {
175
99
  ` ${bold("mdzilla")} ${dim("— Markdown browser for humans and agents")}`,
176
100
  "",
177
101
  `${bold("Usage:")}`,
178
- ` ${bin} ${cyan("<dir>")} ${dim("Browse local docs directory")}`,
179
- ` ${bin} ${cyan("<file.md>")} ${dim("Render a single markdown file")}`,
180
- ` ${bin} ${cyan("gh:owner/repo")} ${dim("Browse GitHub repo docs")}`,
181
- ` ${bin} ${cyan("npm:package-name")} ${dim("Browse npm package docs")}`,
182
- ` ${bin} ${cyan("https://example.com")} ${dim("Browse remote docs via HTTP")}`,
102
+ ` ${bin} ${cyan("<source>")} ${dim("Open docs in browser")}`,
103
+ ` ${bin} ${cyan("<source>")} ${cyan("<path>")} ${dim("Render a specific page")}`,
104
+ ` ${bin} ${cyan("<source>")} ${cyan("<query>")} ${dim("Search docs")}`,
105
+ ` ${bin} ${cyan("<file.md>")} ${dim("Render a single markdown file")}`,
106
+ "",
107
+ `${bold("Sources:")}`,
108
+ ` ${cyan("./docs")} Local directory`,
109
+ ` ${cyan("gh:owner/repo")} GitHub repo (looks for docs/ directory)`,
110
+ ` ${cyan("npm:package-name")} npm package docs`,
111
+ ` ${cyan("https://example.com")} Remote docs via HTTP / llms.txt`,
183
112
  "",
184
113
  `${bold("Options:")}`,
185
114
  ` ${cyan("--export")} ${dim("<dir>")} Export docs to flat .md files`,
186
- ` ${cyan("--page")} ${dim("<path>")} Print a single page and exit`,
187
- ` ${cyan("--plain")} Plain text output (no TUI)`,
188
- ` ${cyan("--headless")} Alias for --plain`,
115
+ ` ${cyan("--plain")} Plain text output (auto-enabled for agents / non-TTY)`,
189
116
  ` ${cyan("-h, --help")} Show this help message`,
190
117
  "",
191
- `${bold("Remarks:")}`,
192
- ` ${dim("Headless mode is auto-enabled when called by AI agents or when stdout is not a TTY.")}`,
193
- ` ${dim("GitHub source (gh:) looks for a docs/ directory in the repository.")}`,
194
- ` ${dim("HTTP source tries /llms.txt first, then fetches with Accept: text/markdown,")}`,
195
- ` ${dim("and falls back to HTML-to-markdown conversion.")}`
118
+ `${bold("Examples:")}`,
119
+ ` ${bin} ${cyan("gh:unjs/h3")} ${dim("Open H3 docs in browser")}`,
120
+ ` ${bin} ${cyan("gh:unjs/h3 /guide/basics")} ${dim("Render a specific page")}`,
121
+ ` ${bin} ${cyan("gh:unjs/h3 router")} ${dim("Search for 'router'")}`,
122
+ ` ${bin} ${cyan("gh:unjs/h3 --export ./h3-docs")} ${dim("Export to flat files")}`
196
123
  ].join("\n"));
197
124
  process.exit(hasInput ? 0 : 1);
198
125
  }
199
126
  //#endregion
200
127
  //#region src/cli/content.ts
201
- async function renderContent(content, entry, navWidth) {
202
- const contentWidth = (process.stdout.columns || 80) - navWidth - 2;
128
+ async function renderContent(content, entry) {
129
+ const cols = process.stdout.columns || 80;
203
130
  const codeBlocks = [];
204
131
  const codeRe = /```(\w+)[^\n]*\n([\s\S]*?)```/g;
205
132
  let m;
@@ -230,18 +157,27 @@ async function renderContent(content, entry, navWidth) {
230
157
  if (hl) {
231
158
  const hlLines = hl.split("\n");
232
159
  if (hlLines.length > 0 && hlLines[hlLines.length - 1] === "") hlLines.pop();
233
- for (const hlLine of hlLines) for (const w of wrapAnsi(" " + hlLine, contentWidth)) lines.push(w + "\x1B[0m");
234
- } else for (const dl of dimLines) for (const w of wrapAnsi(dl, contentWidth)) lines.push(w + "\x1B[0m");
160
+ for (const hlLine of hlLines) for (const w of wrapAnsi(" " + hlLine, cols)) lines.push(w + "\x1B[0m");
161
+ } else for (const dl of dimLines) for (const w of wrapAnsi(dl, cols)) lines.push(w + "\x1B[0m");
235
162
  blockIdx++;
236
163
  lines.push("");
237
164
  } else dimLines.push(rawLine);
238
165
  continue;
239
166
  }
240
- for (const w of wrapAnsi(rawLine, contentWidth)) lines.push(w + "\x1B[0m");
167
+ for (const w of wrapAnsi(rawLine, cols)) lines.push(w + "\x1B[0m");
241
168
  }
242
169
  return lines;
243
170
  }
244
171
  //#endregion
172
+ //#region src/cli/_utils.ts
173
+ function openInBrowser(url) {
174
+ const parsed = new URL(url);
175
+ if (parsed.hostname === "[::]" || parsed.hostname === "[::1]" || parsed.hostname === "127.0.0.1") parsed.hostname = "localhost";
176
+ url = parsed.href;
177
+ if (process.platform === "win32") exec(`start "" ${JSON.stringify(url)}`, () => {});
178
+ else execFile(process.platform === "darwin" ? "open" : "xdg-open", [url], () => {});
179
+ }
180
+ //#endregion
245
181
  //#region src/cli/render.ts
246
182
  async function singleFileMode(filePath, plain, isURL) {
247
183
  const raw = isURL ? await fetch(filePath, { headers: { accept: "text/markdown, text/plain;q=0.9, text/html;q=0.8" } }).then((r) => r.text()) : await readFile(filePath, "utf8");
@@ -256,16 +192,16 @@ async function singleFileMode(filePath, plain, isURL) {
256
192
  path: "/" + slug,
257
193
  title: meta.title || slug,
258
194
  order: 0
259
- }, 0);
195
+ });
260
196
  process.stdout.write(lines.join("\n") + "\n");
261
197
  }
262
- async function pageMode(docs, pagePath, plain) {
263
- const normalized = pagePath.startsWith("/") ? pagePath : "/" + pagePath;
264
- const { entry, raw } = await docs.resolvePage(normalized);
198
+ async function renderPage(docs, resolved, pagePath, plain) {
199
+ const { entry, raw } = resolved;
265
200
  if (!raw) {
266
- console.error(`Page not found: ${pagePath}`);
201
+ printNotFound(docs, pagePath);
267
202
  process.exit(1);
268
203
  }
204
+ const normalized = pagePath.startsWith("/") ? pagePath : "/" + pagePath;
269
205
  const slug = (entry?.entry.path || normalized).split("/").pop() || "";
270
206
  const navEntry = entry?.entry || {
271
207
  slug,
@@ -273,13 +209,60 @@ async function pageMode(docs, pagePath, plain) {
273
209
  title: parseMeta(raw).title || slug,
274
210
  order: 0
275
211
  };
276
- if (plain) process.stdout.write(renderToText(raw) + "\n");
277
- else {
278
- const lines = await renderContent(raw, navEntry, 0);
212
+ if (plain) {
213
+ process.stdout.write(renderToText(raw) + "\n");
214
+ if (isAgent) process.stdout.write(agentTrailer(docs, normalized));
215
+ } else {
216
+ const lines = await renderContent(raw, navEntry);
279
217
  process.stdout.write(lines.join("\n") + "\n");
280
218
  }
281
219
  }
282
- async function plainMode(docs, pagePath) {
220
+ async function searchMode(docs, query, plain) {
221
+ let count = 0;
222
+ const matchedPaths = [];
223
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
224
+ for await (const { flat: f, titleMatch, heading } of docs.search(query)) {
225
+ if (count === 0) process.stdout.write(bold(`Search results for "${query}":\n\n`));
226
+ count++;
227
+ matchedPaths.push(f.entry.path);
228
+ const raw = await docs.getContent(f);
229
+ const plainText = raw ? renderToText(raw) : "";
230
+ const snippets = plainText ? extractSnippets(plainText, terms) : [];
231
+ if (plain) {
232
+ const desc = f.entry.description ? ` — ${f.entry.description}` : "";
233
+ const badge = titleMatch && snippets.length > 0 ? " (title + content)" : titleMatch ? " (title)" : heading ? ` (heading: ${heading})` : ` (${snippets.length} match${snippets.length !== 1 ? "es" : ""})`;
234
+ process.stdout.write(`- **${f.entry.title}** \`${f.entry.path}\`${desc}${badge}\n`);
235
+ for (const s of snippets) process.stdout.write(` > ${s}\n`);
236
+ } else {
237
+ const subtitle = heading ? ` › ${cyan(heading)}` : "";
238
+ process.stdout.write(`${bold(cyan(f.entry.title))}${subtitle} ${dim(f.entry.path)}\n`);
239
+ for (const s of snippets) process.stdout.write(` ${highlight(s, query)}\n`);
240
+ }
241
+ }
242
+ if (count === 0) {
243
+ console.log(`No results for "${query}".`);
244
+ const suggestions = docs.suggest(query);
245
+ if (suggestions.length > 0) {
246
+ console.log("");
247
+ console.log(plain ? "Related pages:" : bold("Related pages:"));
248
+ for (const s of suggestions) if (plain) console.log(` - [${s.entry.title}](${s.entry.path})`);
249
+ else console.log(` ${bold(cyan(s.entry.title))} ${dim(s.entry.path)}`);
250
+ if (isAgent) {
251
+ console.log("");
252
+ console.log("To read a specific page, run this command again with the page path as second argument.");
253
+ }
254
+ }
255
+ } else if (isAgent) process.stdout.write([
256
+ "",
257
+ "---",
258
+ "",
259
+ `Found ${count} page${count > 1 ? "s" : ""} matching "${query}".`,
260
+ "To read a specific page, run this command again with the page path as second argument, for example:",
261
+ ...[...new Set(matchedPaths)].slice(0, 3).map((p) => ` mdzilla <source> ${p}`),
262
+ ""
263
+ ].join("\n"));
264
+ }
265
+ async function tocMode(docs) {
283
266
  const navigable = docs.pages;
284
267
  if (navigable.length === 0) {
285
268
  console.log("No pages found.");
@@ -291,451 +274,46 @@ async function plainMode(docs, pagePath) {
291
274
  tocLines.push(`${indent}- [${f.entry.title}](${f.entry.path})`);
292
275
  }
293
276
  process.stdout.write(tocLines.join("\n") + "\n");
294
- let targetEntry = navigable[0];
295
- if (pagePath) {
296
- const resolved = await docs.resolvePage(pagePath);
297
- if (resolved.raw) process.stdout.write(renderToText(resolved.raw) + "\n\n");
298
- } else {
299
- const raw = await docs.getContent(targetEntry);
300
- if (raw) process.stdout.write(renderToText(raw) + "\n\n");
301
- }
277
+ const raw = await docs.getContent(navigable[0]);
278
+ if (raw) process.stdout.write("\n" + renderToText(raw) + "\n");
279
+ if (isAgent && navigable.length > 1) process.stdout.write("\n---\n\nTo read a specific page, run this command again with the page path as second argument.\nTo search within pages, pass a search query as second argument.\n");
302
280
  }
303
- //#endregion
304
- //#region src/cli/interactive/nav.ts
305
- function calcNavWidth(flat) {
306
- const cols = process.stdout.columns || 80;
307
- let max = 6;
308
- for (const { entry, depth } of flat) {
309
- const w = depth === 0 ? 3 + entry.title.length : 2 + 2 * depth + entry.title.length;
310
- if (w > max) max = w;
311
- }
312
- return Math.min(max + 2, Math.floor(cols * .2), 56);
313
- }
314
- function renderNavPanel(flat, cursor, maxRows, width, search, searchMatches) {
315
- const lines = [];
316
- lines.push(search !== void 0 ? bold(" mdzilla") + " " + dim("/") + search + "▌" : bold(" mdzilla"));
317
- lines.push("");
318
- const listRows = maxRows - 2;
319
- let start = 0;
320
- if (flat.length > listRows) start = Math.max(0, Math.min(cursor - Math.floor(listRows / 2), flat.length - listRows));
321
- const end = Math.min(start + listRows, flat.length);
322
- const treePrefixes = computeTreePrefixes(flat, computeIsLastChild(flat));
323
- for (let i = start; i < end; i++) {
324
- const { entry, depth } = flat[i];
325
- const isPage = entry.page !== false;
326
- const active = i === cursor;
327
- const maxTitle = width - (depth === 0 ? 3 : 2 + 2 * depth) - 1;
328
- let displayTitle = entry.title;
329
- if (displayTitle.length > maxTitle && maxTitle > 1) displayTitle = displayTitle.slice(0, maxTitle - 1) + "…";
330
- const isMatch = searchMatches ? searchMatches.has(i) : false;
331
- const title = search ? highlight(displayTitle, search) : displayTitle;
332
- let label;
333
- if (depth === 0) label = `${isPage ? dim("◆") : dim("◇")} ${search ? isMatch ? title : dim(displayTitle) : active || isPage ? title : dim(title)}`;
334
- else label = ` ${dim(treePrefixes[i])}${search ? isMatch ? title : dim(displayTitle) : active || isPage ? title : dim(title)}`;
335
- lines.push(active ? bgGray(padTo(label, width)) : label);
336
- }
337
- if (flat.length > listRows) lines.push(dim(` ${start > 0 ? "↑" : " "} ${end < flat.length ? "↓" : " "} ${cursor + 1}/${flat.length}`));
338
- return lines;
339
- }
340
- function computeIsLastChild(flat) {
341
- const result = Array.from({ length: flat.length });
342
- for (let i = 0; i < flat.length; i++) {
343
- const d = flat[i].depth;
344
- let last = true;
345
- for (let j = i + 1; j < flat.length; j++) {
346
- if (flat[j].depth < d) break;
347
- if (flat[j].depth === d) {
348
- last = false;
349
- break;
350
- }
351
- }
352
- result[i] = last;
353
- }
354
- return result;
355
- }
356
- function computeTreePrefixes(flat, isLast) {
357
- const prefixes = Array.from({ length: flat.length });
358
- const continues = [];
359
- for (let i = 0; i < flat.length; i++) {
360
- const d = flat[i].depth;
361
- if (d === 0) {
362
- prefixes[i] = "";
363
- continues.length = 1;
364
- continues[0] = !isLast[i];
365
- continue;
366
- }
367
- let prefix = "";
368
- for (let level = 1; level < d; level++) prefix += continues[level] ? "│ " : " ";
369
- prefix += isLast[i] ? "╰─" : "├─";
370
- prefixes[i] = prefix;
371
- continues[d] = !isLast[i];
372
- continues.length = d + 1;
373
- }
374
- return prefixes;
281
+ async function serverMode(source, _docs) {
282
+ const { serve } = await import("srvx");
283
+ const { createDocsServer } = await import("../_chunks/server.mjs");
284
+ const server = serve({
285
+ fetch: (await createDocsServer({ source })).fetch,
286
+ gracefulShutdown: false
287
+ });
288
+ await server.ready();
289
+ await server.fetch(new Request(new URL("/api/meta", server.url)));
290
+ openInBrowser(server.url);
375
291
  }
376
- //#endregion
377
- //#region src/cli/interactive/render.ts
378
- function renderSplit(flat, cursor, contentLines, contentScroll, search, focus, contentSearch, searchMatches, sidebarVisible = true) {
379
- const rows = process.stdout.rows || 24;
380
- const navWidth = sidebarVisible ? calcNavWidth(flat) : 0;
381
- const bodyRows = rows - 1;
382
- const navLines = sidebarVisible ? renderNavPanel(flat, cursor, bodyRows, navWidth, search, searchMatches) : [];
383
- const rawRight = contentLines.slice(contentScroll, contentScroll + bodyRows);
384
- const rightLines = contentSearch ? rawRight.map((l) => highlightAnsi(l, contentSearch)) : rawRight;
385
- const cols = process.stdout.columns || 80;
386
- const contentWidth = sidebarVisible ? cols - navWidth - 3 : cols - 2;
387
- const isFocusContent = focus === "content" || focus === "content-search";
388
- const reset = "\x1B[0m";
389
- const output = [];
390
- for (let i = 0; i < bodyRows; i++) {
391
- const right = rightLines[i] || "";
392
- if (sidebarVisible) output.push(reset + padTo(navLines[i] || "", navWidth) + reset + (isFocusContent ? cyan("│") : dim("│")) + reset + " " + truncateTo(right, contentWidth) + reset);
393
- else output.push(reset + " " + truncateTo(right, contentWidth) + reset);
394
- }
395
- output.push(search !== void 0 ? dim(" esc") + " cancel " + dim("enter") + " go" : focus === "content-search" ? dim(" /") + contentSearch + "▌ " + dim("esc") + " cancel " + dim("enter") + " confirm" : focus === "content" ? dim(" ↑↓") + " scroll " + dim("tab") + " links " + dim("⏎") + " open " + dim("/") + " search" + (contentSearch ? " " + dim("n") + "/" + dim("N") + " next/prev" : "") + " " + dim("t") + " sidebar " + dim("⌫") + " back " + dim("q") + " quit" : dim(" ↑↓") + " navigate " + dim("⏎") + " read " + dim("space") + " page ↓ " + dim("/") + " search " + dim("t") + " sidebar " + dim("q") + " quit");
396
- const eol = "\x1B[K";
397
- return output.map((l) => l + eol).join("\n");
292
+ function agentTrailer(docs, currentPath) {
293
+ const pages = docs.pages;
294
+ if (pages.length <= 1) return "";
295
+ const normalized = currentPath?.startsWith("/") ? currentPath : currentPath ? "/" + currentPath : void 0;
296
+ const otherPages = pages.filter((p) => p.entry.path !== normalized);
297
+ if (otherPages.length === 0) return "";
298
+ return "\n" + [
299
+ "---",
300
+ "",
301
+ "Other available pages:",
302
+ ...otherPages.map((p) => ` - [${p.entry.title}](${p.entry.path})`),
303
+ "",
304
+ "To read a specific page, run this command again with the page path as second argument.",
305
+ "To search within pages, pass a search query as second argument.",
306
+ ""
307
+ ].join("\n");
398
308
  }
399
- //#endregion
400
- //#region src/cli/interactive/index.ts
401
- async function interactiveMode(docs) {
402
- const flat = docs.flat;
403
- if (flat.length === 0) {
404
- console.log("No pages found.");
405
- process.exit(0);
309
+ function printNotFound(docs, pagePath) {
310
+ process.stderr.write(`Page not found: ${pagePath}\n`);
311
+ const suggestions = docs.suggest(pagePath);
312
+ if (suggestions.length > 0) {
313
+ process.stderr.write("\nDid you mean:\n");
314
+ for (const s of suggestions) process.stderr.write(` - ${s.entry.title} (${s.entry.path})\n`);
406
315
  }
407
- const isNavigable = (list, i) => list[i]?.entry.page !== false;
408
- const nextNavigable = (list, from, dir) => {
409
- let i = from + dir;
410
- while (i >= 0 && i < list.length) {
411
- if (isNavigable(list, i)) return i;
412
- i += dir;
413
- }
414
- return from;
415
- };
416
- const firstNavigable = (list) => {
417
- for (let i = 0; i < list.length; i++) if (isNavigable(list, i)) return i;
418
- return 0;
419
- };
420
- let cursor = firstNavigable(flat);
421
- let searching = false;
422
- let searchQuery = "";
423
- let searchMatches = [];
424
- let contentScroll = 0;
425
- let contentLines = [];
426
- let loadedPath = "";
427
- let focusContent = false;
428
- let contentSearching = false;
429
- let contentSearchQuery = "";
430
- let contentMatches = [];
431
- let contentMatchIdx = 0;
432
- let sidebarVisible = true;
433
- let contentLinks = [];
434
- let linkIdx = -1;
435
- const extractLinks = (lines) => {
436
- const links = [];
437
- const re = /\x1B\]8;;([^\x07\x1B]+?)(?:\x07|\x1B\\)/g;
438
- for (let i = 0; i < lines.length; i++) {
439
- re.lastIndex = 0;
440
- let m;
441
- let occ = 0;
442
- while ((m = re.exec(lines[i])) !== null) links.push({
443
- line: i,
444
- url: m[1],
445
- occurrence: occ++
446
- });
447
- }
448
- return links;
449
- };
450
- const highlightLinkOnLine = (line, occurrence) => {
451
- const oscRe = /\x1B\]8;;([^\x07\x1B]*?)(?:\x07|\x1B\\)/g;
452
- let occ = 0;
453
- let m;
454
- while ((m = oscRe.exec(line)) !== null) {
455
- if (!m[1]) continue;
456
- if (occ === occurrence) {
457
- const openerEnd = m.index + m[0].length;
458
- const closer = oscRe.exec(line);
459
- if (!closer) break;
460
- return line.slice(0, openerEnd) + "\x1B[7m" + line.slice(openerEnd, closer.index) + "\x1B[27m" + line.slice(closer.index);
461
- }
462
- occ++;
463
- }
464
- return line;
465
- };
466
- const findContentMatches = (query) => {
467
- if (!query) return [];
468
- const lower = query.toLowerCase();
469
- const matches = [];
470
- for (let i = 0; i < contentLines.length; i++) if (stripAnsi(contentLines[i]).toLowerCase().includes(lower)) matches.push(i);
471
- return matches;
472
- };
473
- const scrollToMatch = () => {
474
- if (contentMatches.length === 0) return;
475
- const rows = process.stdout.rows || 24;
476
- const line = contentMatches[contentMatchIdx];
477
- contentScroll = Math.max(0, Math.min(line - Math.floor(rows / 2), contentLines.length - rows + 2));
478
- };
479
- process.stdin.setRawMode(true);
480
- process.stdin.resume();
481
- enterAltScreen();
482
- hideCursor();
483
- const draw = () => {
484
- let displayLines = contentLines;
485
- if (linkIdx >= 0 && linkIdx < contentLinks.length) {
486
- const link = contentLinks[linkIdx];
487
- displayLines = [...contentLines];
488
- displayLines[link.line] = highlightLinkOnLine(displayLines[link.line], link.occurrence);
489
- }
490
- const frame = renderSplit(flat, cursor, displayLines, contentScroll, searching ? searchQuery : void 0, contentSearching ? "content-search" : focusContent ? "content" : "nav", contentSearching ? contentSearchQuery : contentSearchQuery || void 0, searching ? new Set(searchMatches) : void 0, sidebarVisible);
491
- process.stdout.write(`\x1B[H${frame}`);
492
- };
493
- const loadContent = (entry) => {
494
- if (!entry?.filePath || entry.entry.page === false) {
495
- if (loadedPath !== "") {
496
- contentLines = [];
497
- contentScroll = 0;
498
- loadedPath = "";
499
- draw();
500
- }
501
- return;
502
- }
503
- if (entry.filePath === loadedPath) return;
504
- const targetPath = entry.filePath;
505
- docs.getContent(entry).then(async (raw) => {
506
- if (!raw || flat[cursor]?.filePath !== targetPath) return;
507
- contentLines = await renderContent(raw, entry.entry, sidebarVisible ? calcNavWidth(flat) : 0);
508
- contentScroll = 0;
509
- contentSearchQuery = "";
510
- contentMatches = [];
511
- contentLinks = extractLinks(contentLines);
512
- linkIdx = -1;
513
- loadedPath = targetPath;
514
- draw();
515
- });
516
- };
517
- let cleaned = false;
518
- const cleanup = (code = 0) => {
519
- if (cleaned) return;
520
- cleaned = true;
521
- showCursor();
522
- leaveAltScreen();
523
- process.exit(code);
524
- };
525
- const reloadContent = () => {
526
- const entry = flat[cursor];
527
- if (!entry?.filePath || entry.entry.page === false) return;
528
- docs.invalidate(entry.filePath);
529
- loadedPath = "";
530
- loadContent(entry);
531
- };
532
- process.on("SIGINT", () => cleanup());
533
- process.on("SIGTERM", () => cleanup());
534
- process.on("uncaughtException", (err) => {
535
- if (cleaned) return;
536
- cleaned = true;
537
- showCursor();
538
- leaveAltScreen();
539
- console.error(err);
540
- process.exit(1);
541
- });
542
- process.on("unhandledRejection", (err) => {
543
- if (cleaned) return;
544
- cleaned = true;
545
- showCursor();
546
- leaveAltScreen();
547
- console.error(err);
548
- process.exit(1);
549
- });
550
- process.stdout.on("resize", () => {
551
- reloadContent();
552
- draw();
553
- });
554
- clear();
555
- draw();
556
- loadContent(flat[cursor]);
557
- process.stdin.on("data", (data) => {
558
- const key = data.toString();
559
- if (key === "") return cleanup();
560
- if (searching) handleSearch(key);
561
- else if (contentSearching) handleContentSearch(key);
562
- else if (focusContent) handleContent(key);
563
- else handleNav(key);
564
- draw();
565
- });
566
- function handleNav(key) {
567
- const rows = process.stdout.rows || 24;
568
- const maxScroll = Math.max(0, contentLines.length - rows + 2);
569
- if (key === "q") return cleanup();
570
- if (key === "/") {
571
- searching = true;
572
- searchQuery = "";
573
- searchMatches = [];
574
- showCursor();
575
- } else if (key === "\x1B[A" || key === "k") {
576
- cursor = nextNavigable(flat, cursor, -1);
577
- loadContent(flat[cursor]);
578
- } else if (key === "\x1B[B" || key === "j") {
579
- cursor = nextNavigable(flat, cursor, 1);
580
- loadContent(flat[cursor]);
581
- } else if (key === "\r" || key === "\n" || key === " " || key === "\x1B[C") {
582
- if (contentLines.length > 0) focusContent = true;
583
- } else if (key === " " || key === "\x1B[6~") contentScroll = Math.min(maxScroll, contentScroll + rows - 2);
584
- else if (key === "b" || key === "\x1B[5~") contentScroll = Math.max(0, contentScroll - rows + 2);
585
- else if (key === "g") {
586
- cursor = firstNavigable(flat);
587
- loadContent(flat[cursor]);
588
- } else if (key === "G") {
589
- for (let i = flat.length - 1; i >= 0; i--) if (isNavigable(flat, i)) {
590
- cursor = i;
591
- break;
592
- }
593
- loadContent(flat[cursor]);
594
- } else if (key === "t") {
595
- sidebarVisible = !sidebarVisible;
596
- reloadContent();
597
- }
598
- }
599
- const scrollToLink = () => {
600
- if (linkIdx < 0 || linkIdx >= contentLinks.length) return;
601
- const rows = process.stdout.rows || 24;
602
- const line = contentLinks[linkIdx].line;
603
- if (line < contentScroll || line >= contentScroll + rows - 2) contentScroll = Math.max(0, Math.min(line - Math.floor(rows / 3), contentLines.length - rows + 2));
604
- };
605
- const activateLink = (url) => {
606
- if (url.startsWith("http://") || url.startsWith("https://")) try {
607
- execSync(`open ${JSON.stringify(url)}`, { stdio: "ignore" });
608
- } catch {}
609
- else {
610
- const target = url.replace(/^\.\//, "/").replace(/\/$/, "");
611
- const idx = flat.indexOf(docs.findByPath(target));
612
- if (idx >= 0) {
613
- cursor = idx;
614
- focusContent = false;
615
- linkIdx = -1;
616
- loadContent(flat[cursor]);
617
- }
618
- }
619
- };
620
- function handleContent(key) {
621
- const rows = process.stdout.rows || 24;
622
- const maxScroll = Math.max(0, contentLines.length - rows + 2);
623
- if (key === "" || key === "\b" || key === "\x1B[D" || key === "\x1B") {
624
- focusContent = false;
625
- linkIdx = -1;
626
- contentSearchQuery = "";
627
- contentMatches = [];
628
- } else if (key === " ") {
629
- if (contentLinks.length > 0) {
630
- linkIdx = linkIdx < contentLinks.length - 1 ? linkIdx + 1 : 0;
631
- scrollToLink();
632
- }
633
- } else if (key === "\x1B[Z") {
634
- if (contentLinks.length > 0) {
635
- linkIdx = linkIdx > 0 ? linkIdx - 1 : contentLinks.length - 1;
636
- scrollToLink();
637
- }
638
- } else if ((key === "\r" || key === "\n") && linkIdx >= 0 && linkIdx < contentLinks.length) activateLink(contentLinks[linkIdx].url);
639
- else if (key === "\x1B[A" || key === "k") contentScroll = Math.max(0, contentScroll - 1);
640
- else if (key === "\x1B[B" || key === "j") contentScroll = Math.min(maxScroll, contentScroll + 1);
641
- else if (key === " " || key === "\x1B[6~") contentScroll = Math.min(maxScroll, contentScroll + rows - 2);
642
- else if (key === "b" || key === "\x1B[5~") contentScroll = Math.max(0, contentScroll - rows + 2);
643
- else if (key === "/") {
644
- contentSearching = true;
645
- contentSearchQuery = "";
646
- contentMatches = [];
647
- contentMatchIdx = 0;
648
- showCursor();
649
- } else if (key === "n" && contentMatches.length > 0) {
650
- contentMatchIdx = (contentMatchIdx + 1) % contentMatches.length;
651
- scrollToMatch();
652
- } else if (key === "N" && contentMatches.length > 0) {
653
- contentMatchIdx = (contentMatchIdx - 1 + contentMatches.length) % contentMatches.length;
654
- scrollToMatch();
655
- } else if (key === "g") contentScroll = 0;
656
- else if (key === "G") contentScroll = maxScroll;
657
- else if (key === "t") {
658
- sidebarVisible = !sidebarVisible;
659
- reloadContent();
660
- } else if (key === "q") return cleanup();
661
- }
662
- function handleContentSearch(key) {
663
- if (key === "\x1B") {
664
- contentSearching = false;
665
- contentSearchQuery = "";
666
- contentMatches = [];
667
- hideCursor();
668
- } else if (key === "\r" || key === "\n") {
669
- contentSearching = false;
670
- hideCursor();
671
- } else if (key === "" || key === "\b") {
672
- contentSearchQuery = contentSearchQuery.slice(0, -1);
673
- contentMatches = findContentMatches(contentSearchQuery);
674
- contentMatchIdx = 0;
675
- scrollToMatch();
676
- } else if (key === "\x1B[A") {
677
- if (contentMatches.length > 0) {
678
- contentMatchIdx = (contentMatchIdx - 1 + contentMatches.length) % contentMatches.length;
679
- scrollToMatch();
680
- }
681
- } else if (key === "\x1B[B") {
682
- if (contentMatches.length > 0) {
683
- contentMatchIdx = (contentMatchIdx + 1) % contentMatches.length;
684
- scrollToMatch();
685
- }
686
- } else if (key.length === 1 && key >= " ") {
687
- contentSearchQuery += key;
688
- contentMatches = findContentMatches(contentSearchQuery);
689
- contentMatchIdx = 0;
690
- scrollToMatch();
691
- }
692
- }
693
- function updateSearchMatches() {
694
- searchMatches = docs.matchIndices(searchQuery);
695
- if (searchMatches.length > 0) cursor = searchMatches[0];
696
- else if (!searchQuery) cursor = firstNavigable(flat);
697
- loadContent(flat[cursor]);
698
- }
699
- function nextSearchMatch(dir) {
700
- if (searchMatches.length === 0) return;
701
- const curIdx = searchMatches.indexOf(cursor);
702
- if (curIdx < 0) cursor = searchMatches[0];
703
- else {
704
- const next = curIdx + dir;
705
- cursor = searchMatches[(next + searchMatches.length) % searchMatches.length];
706
- }
707
- loadContent(flat[cursor]);
708
- }
709
- function handleSearch(key) {
710
- if (key === "\x1B" || key === "\x1B[D") {
711
- searching = false;
712
- searchMatches = [];
713
- cursor = firstNavigable(flat);
714
- hideCursor();
715
- loadContent(flat[cursor]);
716
- } else if (key === "\r" || key === "\n") {
717
- searching = false;
718
- searchMatches = [];
719
- hideCursor();
720
- loadContent(flat[cursor]);
721
- } else if (key === "" || key === "\b") {
722
- searchQuery = searchQuery.slice(0, -1);
723
- updateSearchMatches();
724
- } else if (key === "\x1B[A") nextSearchMatch(-1);
725
- else if (key === "\x1B[B") nextSearchMatch(1);
726
- else if (key.length === 1 && key >= " ") {
727
- searchQuery += key;
728
- updateSearchMatches();
729
- }
730
- }
731
- }
732
- //#endregion
733
- //#region src/cli/_utils.ts
734
- function openInBrowser(url) {
735
- const parsed = new URL(url);
736
- if (parsed.hostname === "[::]" || parsed.hostname === "[::1]" || parsed.hostname === "127.0.0.1") parsed.hostname = "localhost";
737
- url = parsed.href;
738
- exec(process.platform === "win32" ? `start ${url}` : process.platform === "darwin" ? `open ${url}` : `xdg-open ${url}`, () => {});
316
+ process.stderr.write("\nTo search within pages, pass a search query as second argument.\n");
739
317
  }
740
318
  //#endregion
741
319
  //#region src/cli/main.ts
@@ -746,62 +324,55 @@ async function main() {
746
324
  });
747
325
  const { values, positionals } = parseArgs({
748
326
  args: process.argv.slice(2),
327
+ allowPositionals: true,
749
328
  options: {
750
329
  help: {
751
330
  type: "boolean",
752
331
  short: "h"
753
332
  },
754
333
  export: { type: "string" },
755
- page: {
756
- type: "string",
757
- short: "p"
758
- },
759
334
  plain: {
760
335
  type: "boolean",
761
336
  default: isAgent || !process.stdout.isTTY
762
- },
763
- headless: { type: "boolean" },
764
- tui: { type: "boolean" }
765
- },
766
- allowPositionals: true
337
+ }
338
+ }
767
339
  });
768
- const exportDir = values.export;
769
- const docsDir = positionals[0];
770
- const plain = values.plain || values.headless || docsDir?.startsWith("npm:") || false;
771
- if (values.help || !docsDir) return printUsage(!!docsDir);
772
- const isURL = docsDir.startsWith("http://") || docsDir.startsWith("https://");
773
- if (docsDir.endsWith(".md")) return singleFileMode(docsDir, plain, isURL);
774
- const source = isURL ? new DocsSourceHTTP(docsDir) : docsDir.startsWith("gh:") ? new DocsSourceGit(docsDir) : docsDir.startsWith("npm:") ? new DocsSourceNpm(docsDir) : new DocsSourceFS(docsDir);
775
- const docs = new DocsManager(source);
340
+ const input = positionals[0];
341
+ const query = positionals[1];
342
+ const plain = values.plain || input?.startsWith("npm:") || false;
343
+ if (values.help || !input) return printUsage(!!input);
344
+ const isURL = input.startsWith("http://") || input.startsWith("https://");
345
+ if (input.endsWith(".md")) return singleFileMode(input, plain, isURL);
346
+ const source = resolveSource(input);
347
+ const docs = new Collection(source);
776
348
  await docs.load();
777
- if (exportDir) {
778
- await exportDocsToFS(docs, exportDir, { plainText: plain });
779
- console.log(`Exported ${docs.pages.length} pages to ${exportDir}`);
349
+ if (values.export) {
350
+ await writeCollection(docs, values.export, { plainText: plain });
351
+ console.log(`Exported ${docs.pages.length} pages to ${values.export}`);
780
352
  return;
781
353
  }
782
- let pagePath = values.page;
783
- if (!pagePath && isURL) {
784
- const urlPath = new URL(docsDir).pathname.replace(/\/+$/, "");
785
- if (urlPath && urlPath !== "/") pagePath = urlPath;
354
+ let resolvedQuery = query;
355
+ if (!resolvedQuery && isURL) {
356
+ const urlPath = new URL(input).pathname.replace(/\/+$/, "");
357
+ if (urlPath && urlPath !== "/") resolvedQuery = urlPath;
786
358
  }
787
- if (pagePath && !plain) return pageMode(docs, pagePath, plain);
788
- if (plain) return plainMode(docs, pagePath);
789
- if (values.tui) interactiveMode(docs);
790
- else {
791
- const { serve } = await import("srvx");
792
- const { createDocsServer } = await import("../_chunks/server.mjs");
793
- const server = serve({
794
- fetch: (await createDocsServer({ source })).fetch,
795
- gracefulShutdown: false
796
- });
797
- await server.ready();
798
- await server.fetch(new Request(new URL("/api/meta", server.url)));
799
- openInBrowser(server.url);
359
+ if (resolvedQuery) return smartResolve(docs, resolvedQuery, plain);
360
+ if (plain) return tocMode(docs);
361
+ return serverMode(source, docs);
362
+ }
363
+ async function smartResolve(docs, query, plain) {
364
+ const normalized = query.startsWith("/") ? query : "/" + query;
365
+ const resolved = await docs.resolvePage(normalized);
366
+ if (resolved.raw) return renderPage(docs, resolved, normalized, plain);
367
+ const fuzzy = docs.filter(query);
368
+ if (fuzzy.length === 1 && fuzzy[0].entry.page !== false) {
369
+ const match = fuzzy[0];
370
+ const page = await docs.resolvePage(match.entry.path);
371
+ if (page.raw) return renderPage(docs, page, match.entry.path, plain);
800
372
  }
373
+ return searchMode(docs, query, plain);
801
374
  }
802
375
  main().catch((err) => {
803
- showCursor();
804
- leaveAltScreen();
805
376
  console.error(err);
806
377
  process.exit(1);
807
378
  });