mdzilla 0.1.0 → 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,8 +1,8 @@
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";
@@ -11,33 +11,13 @@ import { exec, execFile } from "node:child_process";
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,
@@ -277,38 +213,81 @@ async function pageMode(docs, pagePath, plain) {
277
213
  process.stdout.write(renderToText(raw) + "\n");
278
214
  if (isAgent) process.stdout.write(agentTrailer(docs, normalized));
279
215
  } else {
280
- const lines = await renderContent(raw, navEntry, 0);
216
+ const lines = await renderContent(raw, navEntry);
281
217
  process.stdout.write(lines.join("\n") + "\n");
282
218
  }
283
219
  }
284
- 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) {
285
266
  const navigable = docs.pages;
286
267
  if (navigable.length === 0) {
287
268
  console.log("No pages found.");
288
269
  return;
289
270
  }
290
- if (isAgent && pagePath) {
291
- const normalized = pagePath.startsWith("/") ? pagePath : "/" + pagePath;
292
- const resolved = await docs.resolvePage(normalized);
293
- if (resolved.raw) process.stdout.write(renderToText(resolved.raw) + "\n");
294
- else process.stdout.write(`Page not found: ${pagePath}\n`);
295
- process.stdout.write(agentTrailer(docs, normalized));
296
- return;
297
- }
298
271
  const tocLines = ["Table of Contents", ""];
299
272
  for (const f of navigable) {
300
273
  const indent = " ".repeat(f.depth);
301
274
  tocLines.push(`${indent}- [${f.entry.title}](${f.entry.path})`);
302
275
  }
303
276
  process.stdout.write(tocLines.join("\n") + "\n");
304
- if (pagePath) {
305
- const resolved = await docs.resolvePage(pagePath);
306
- if (resolved.raw) process.stdout.write(renderToText(resolved.raw) + "\n\n");
307
- } else {
308
- const raw = await docs.getContent(navigable[0]);
309
- if (raw) process.stdout.write(renderToText(raw) + "\n\n");
310
- }
311
- if (isAgent && navigable.length > 1) process.stdout.write("\n---\n\nTo read a specific page from the table of contents above, run this command again with `--page <path>`.\n");
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");
280
+ }
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);
312
291
  }
313
292
  function agentTrailer(docs, currentPath) {
314
293
  const pages = docs.pages;
@@ -322,446 +301,19 @@ function agentTrailer(docs, currentPath) {
322
301
  "Other available pages:",
323
302
  ...otherPages.map((p) => ` - [${p.entry.title}](${p.entry.path})`),
324
303
  "",
325
- "To read a specific page, run this command again with `--page <path>`.",
326
- "To view the full table of contents, run this command without `--page`.",
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.",
327
306
  ""
328
307
  ].join("\n");
329
308
  }
330
- //#endregion
331
- //#region src/cli/_utils.ts
332
- function openInBrowser(url) {
333
- const parsed = new URL(url);
334
- if (parsed.hostname === "[::]" || parsed.hostname === "[::1]" || parsed.hostname === "127.0.0.1") parsed.hostname = "localhost";
335
- url = parsed.href;
336
- if (process.platform === "win32") exec(`start "" ${JSON.stringify(url)}`, () => {});
337
- else execFile(process.platform === "darwin" ? "open" : "xdg-open", [url], () => {});
338
- }
339
- //#endregion
340
- //#region src/cli/interactive/nav.ts
341
- function calcNavWidth(flat) {
342
- const cols = process.stdout.columns || 80;
343
- let max = 6;
344
- for (const { entry, depth } of flat) {
345
- const w = depth === 0 ? 3 + entry.title.length : 2 + 2 * depth + entry.title.length;
346
- if (w > max) max = w;
347
- }
348
- return Math.min(max + 2, Math.floor(cols * .2), 56);
349
- }
350
- function renderNavPanel(flat, cursor, maxRows, width, search, searchMatches) {
351
- const lines = [];
352
- lines.push(search !== void 0 ? bold(" mdzilla") + " " + dim("/") + search + "▌" : bold(" mdzilla"));
353
- lines.push("");
354
- const listRows = maxRows - 2;
355
- let start = 0;
356
- if (flat.length > listRows) start = Math.max(0, Math.min(cursor - Math.floor(listRows / 2), flat.length - listRows));
357
- const end = Math.min(start + listRows, flat.length);
358
- const treePrefixes = computeTreePrefixes(flat, computeIsLastChild(flat));
359
- for (let i = start; i < end; i++) {
360
- const { entry, depth } = flat[i];
361
- const isPage = entry.page !== false;
362
- const active = i === cursor;
363
- const maxTitle = width - (depth === 0 ? 3 : 2 + 2 * depth) - 1;
364
- let displayTitle = entry.title;
365
- if (displayTitle.length > maxTitle && maxTitle > 1) displayTitle = displayTitle.slice(0, maxTitle - 1) + "…";
366
- const isMatch = searchMatches ? searchMatches.has(i) : false;
367
- const title = search ? highlight(displayTitle, search) : displayTitle;
368
- let label;
369
- if (depth === 0) label = `${isPage ? dim("◆") : dim("◇")} ${search ? isMatch ? title : dim(displayTitle) : active || isPage ? title : dim(title)}`;
370
- else label = ` ${dim(treePrefixes[i])}${search ? isMatch ? title : dim(displayTitle) : active || isPage ? title : dim(title)}`;
371
- lines.push(active ? bgGray(padTo(label, width)) : label);
372
- }
373
- if (flat.length > listRows) lines.push(dim(` ${start > 0 ? "↑" : " "} ${end < flat.length ? "↓" : " "} ${cursor + 1}/${flat.length}`));
374
- return lines;
375
- }
376
- function computeIsLastChild(flat) {
377
- const result = Array.from({ length: flat.length });
378
- for (let i = 0; i < flat.length; i++) {
379
- const d = flat[i].depth;
380
- let last = true;
381
- for (let j = i + 1; j < flat.length; j++) {
382
- if (flat[j].depth < d) break;
383
- if (flat[j].depth === d) {
384
- last = false;
385
- break;
386
- }
387
- }
388
- result[i] = last;
389
- }
390
- return result;
391
- }
392
- function computeTreePrefixes(flat, isLast) {
393
- const prefixes = Array.from({ length: flat.length });
394
- const continues = [];
395
- for (let i = 0; i < flat.length; i++) {
396
- const d = flat[i].depth;
397
- if (d === 0) {
398
- prefixes[i] = "";
399
- continues.length = 1;
400
- continues[0] = !isLast[i];
401
- continue;
402
- }
403
- let prefix = "";
404
- for (let level = 1; level < d; level++) prefix += continues[level] ? "│ " : " ";
405
- prefix += isLast[i] ? "╰─" : "├─";
406
- prefixes[i] = prefix;
407
- continues[d] = !isLast[i];
408
- continues.length = d + 1;
409
- }
410
- return prefixes;
411
- }
412
- //#endregion
413
- //#region src/cli/interactive/render.ts
414
- function renderSplit(flat, cursor, contentLines, contentScroll, search, focus, contentSearch, searchMatches, sidebarVisible = true) {
415
- const rows = process.stdout.rows || 24;
416
- const navWidth = sidebarVisible ? calcNavWidth(flat) : 0;
417
- const bodyRows = rows - 1;
418
- const navLines = sidebarVisible ? renderNavPanel(flat, cursor, bodyRows, navWidth, search, searchMatches) : [];
419
- const rawRight = contentLines.slice(contentScroll, contentScroll + bodyRows);
420
- const rightLines = contentSearch ? rawRight.map((l) => highlightAnsi(l, contentSearch)) : rawRight;
421
- const cols = process.stdout.columns || 80;
422
- const contentWidth = sidebarVisible ? cols - navWidth - 3 : cols - 2;
423
- const isFocusContent = focus === "content" || focus === "content-search";
424
- const reset = "\x1B[0m";
425
- const output = [];
426
- for (let i = 0; i < bodyRows; i++) {
427
- const right = rightLines[i] || "";
428
- if (sidebarVisible) output.push(reset + padTo(navLines[i] || "", navWidth) + reset + (isFocusContent ? cyan("│") : dim("│")) + reset + " " + truncateTo(right, contentWidth) + reset);
429
- else output.push(reset + " " + truncateTo(right, contentWidth) + reset);
430
- }
431
- 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");
432
- const eol = "\x1B[K";
433
- return output.map((l) => l + eol).join("\n");
434
- }
435
- //#endregion
436
- //#region src/cli/interactive/index.ts
437
- async function interactiveMode(docs) {
438
- const flat = docs.flat;
439
- if (flat.length === 0) {
440
- console.log("No pages found.");
441
- process.exit(0);
442
- }
443
- const isNavigable = (list, i) => list[i]?.entry.page !== false;
444
- const nextNavigable = (list, from, dir) => {
445
- let i = from + dir;
446
- while (i >= 0 && i < list.length) {
447
- if (isNavigable(list, i)) return i;
448
- i += dir;
449
- }
450
- return from;
451
- };
452
- const firstNavigable = (list) => {
453
- for (let i = 0; i < list.length; i++) if (isNavigable(list, i)) return i;
454
- return 0;
455
- };
456
- let cursor = firstNavigable(flat);
457
- let searching = false;
458
- let searchQuery = "";
459
- let searchMatches = [];
460
- let contentScroll = 0;
461
- let contentLines = [];
462
- let loadedPath = "";
463
- let focusContent = false;
464
- let contentSearching = false;
465
- let contentSearchQuery = "";
466
- let contentMatches = [];
467
- let contentMatchIdx = 0;
468
- let sidebarVisible = true;
469
- let contentLinks = [];
470
- let linkIdx = -1;
471
- const extractLinks = (lines) => {
472
- const links = [];
473
- const re = /\x1B\]8;;([^\x07\x1B]+?)(?:\x07|\x1B\\)/g;
474
- for (let i = 0; i < lines.length; i++) {
475
- re.lastIndex = 0;
476
- let m;
477
- let occ = 0;
478
- while ((m = re.exec(lines[i])) !== null) links.push({
479
- line: i,
480
- url: m[1],
481
- occurrence: occ++
482
- });
483
- }
484
- return links;
485
- };
486
- const highlightLinkOnLine = (line, occurrence) => {
487
- const oscRe = /\x1B\]8;;([^\x07\x1B]*?)(?:\x07|\x1B\\)/g;
488
- let occ = 0;
489
- let m;
490
- while ((m = oscRe.exec(line)) !== null) {
491
- if (!m[1]) continue;
492
- if (occ === occurrence) {
493
- const openerEnd = m.index + m[0].length;
494
- const closer = oscRe.exec(line);
495
- if (!closer) break;
496
- return line.slice(0, openerEnd) + "\x1B[7m" + line.slice(openerEnd, closer.index) + "\x1B[27m" + line.slice(closer.index);
497
- }
498
- occ++;
499
- }
500
- return line;
501
- };
502
- const findContentMatches = (query) => {
503
- if (!query) return [];
504
- const lower = query.toLowerCase();
505
- const matches = [];
506
- for (let i = 0; i < contentLines.length; i++) if (stripAnsi(contentLines[i]).toLowerCase().includes(lower)) matches.push(i);
507
- return matches;
508
- };
509
- const scrollToMatch = () => {
510
- if (contentMatches.length === 0) return;
511
- const rows = process.stdout.rows || 24;
512
- const line = contentMatches[contentMatchIdx];
513
- contentScroll = Math.max(0, Math.min(line - Math.floor(rows / 2), contentLines.length - rows + 2));
514
- };
515
- process.stdin.setRawMode(true);
516
- process.stdin.resume();
517
- enterAltScreen();
518
- hideCursor();
519
- const draw = () => {
520
- let displayLines = contentLines;
521
- if (linkIdx >= 0 && linkIdx < contentLinks.length) {
522
- const link = contentLinks[linkIdx];
523
- displayLines = [...contentLines];
524
- displayLines[link.line] = highlightLinkOnLine(displayLines[link.line], link.occurrence);
525
- }
526
- 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);
527
- process.stdout.write(`\x1B[H${frame}`);
528
- };
529
- const loadContent = (entry) => {
530
- if (!entry?.filePath || entry.entry.page === false) {
531
- if (loadedPath !== "") {
532
- contentLines = [];
533
- contentScroll = 0;
534
- loadedPath = "";
535
- draw();
536
- }
537
- return;
538
- }
539
- if (entry.filePath === loadedPath) return;
540
- const targetPath = entry.filePath;
541
- docs.getContent(entry).then(async (raw) => {
542
- if (!raw || flat[cursor]?.filePath !== targetPath) return;
543
- contentLines = await renderContent(raw, entry.entry, sidebarVisible ? calcNavWidth(flat) : 0);
544
- contentScroll = 0;
545
- contentSearchQuery = "";
546
- contentMatches = [];
547
- contentLinks = extractLinks(contentLines);
548
- linkIdx = -1;
549
- loadedPath = targetPath;
550
- draw();
551
- });
552
- };
553
- let cleaned = false;
554
- const cleanup = (code = 0) => {
555
- if (cleaned) return;
556
- cleaned = true;
557
- showCursor();
558
- leaveAltScreen();
559
- process.exit(code);
560
- };
561
- const reloadContent = () => {
562
- const entry = flat[cursor];
563
- if (!entry?.filePath || entry.entry.page === false) return;
564
- docs.invalidate(entry.filePath);
565
- loadedPath = "";
566
- loadContent(entry);
567
- };
568
- process.on("SIGINT", () => cleanup());
569
- process.on("SIGTERM", () => cleanup());
570
- process.on("uncaughtException", (err) => {
571
- if (cleaned) return;
572
- cleaned = true;
573
- showCursor();
574
- leaveAltScreen();
575
- console.error(err);
576
- process.exit(1);
577
- });
578
- process.on("unhandledRejection", (err) => {
579
- if (cleaned) return;
580
- cleaned = true;
581
- showCursor();
582
- leaveAltScreen();
583
- console.error(err);
584
- process.exit(1);
585
- });
586
- process.stdout.on("resize", () => {
587
- reloadContent();
588
- draw();
589
- });
590
- clear();
591
- draw();
592
- loadContent(flat[cursor]);
593
- process.stdin.on("data", (data) => {
594
- const key = data.toString();
595
- if (key === "") return cleanup();
596
- if (searching) handleSearch(key);
597
- else if (contentSearching) handleContentSearch(key);
598
- else if (focusContent) handleContent(key);
599
- else handleNav(key);
600
- draw();
601
- });
602
- function handleNav(key) {
603
- const rows = process.stdout.rows || 24;
604
- const maxScroll = Math.max(0, contentLines.length - rows + 2);
605
- if (key === "q") return cleanup();
606
- if (key === "/") {
607
- searching = true;
608
- searchQuery = "";
609
- searchMatches = [];
610
- showCursor();
611
- } else if (key === "\x1B[A" || key === "k") {
612
- cursor = nextNavigable(flat, cursor, -1);
613
- loadContent(flat[cursor]);
614
- } else if (key === "\x1B[B" || key === "j") {
615
- cursor = nextNavigable(flat, cursor, 1);
616
- loadContent(flat[cursor]);
617
- } else if (key === "\r" || key === "\n" || key === " " || key === "\x1B[C") {
618
- if (contentLines.length > 0) focusContent = true;
619
- } else if (key === " " || key === "\x1B[6~") contentScroll = Math.min(maxScroll, contentScroll + rows - 2);
620
- else if (key === "b" || key === "\x1B[5~") contentScroll = Math.max(0, contentScroll - rows + 2);
621
- else if (key === "g") {
622
- cursor = firstNavigable(flat);
623
- loadContent(flat[cursor]);
624
- } else if (key === "G") {
625
- for (let i = flat.length - 1; i >= 0; i--) if (isNavigable(flat, i)) {
626
- cursor = i;
627
- break;
628
- }
629
- loadContent(flat[cursor]);
630
- } else if (key === "t") {
631
- sidebarVisible = !sidebarVisible;
632
- reloadContent();
633
- }
634
- }
635
- const scrollToLink = () => {
636
- if (linkIdx < 0 || linkIdx >= contentLinks.length) return;
637
- const rows = process.stdout.rows || 24;
638
- const line = contentLinks[linkIdx].line;
639
- if (line < contentScroll || line >= contentScroll + rows - 2) contentScroll = Math.max(0, Math.min(line - Math.floor(rows / 3), contentLines.length - rows + 2));
640
- };
641
- const activateLink = (url) => {
642
- if (url.startsWith("http://") || url.startsWith("https://")) openInBrowser(url);
643
- else {
644
- const target = url.replace(/^\.\//, "/").replace(/\/$/, "");
645
- const idx = flat.indexOf(docs.findByPath(target));
646
- if (idx >= 0) {
647
- cursor = idx;
648
- focusContent = false;
649
- linkIdx = -1;
650
- loadContent(flat[cursor]);
651
- }
652
- }
653
- };
654
- function handleContent(key) {
655
- const rows = process.stdout.rows || 24;
656
- const maxScroll = Math.max(0, contentLines.length - rows + 2);
657
- if (key === "" || key === "\b" || key === "\x1B[D" || key === "\x1B") {
658
- focusContent = false;
659
- linkIdx = -1;
660
- contentSearchQuery = "";
661
- contentMatches = [];
662
- } else if (key === " ") {
663
- if (contentLinks.length > 0) {
664
- linkIdx = linkIdx < contentLinks.length - 1 ? linkIdx + 1 : 0;
665
- scrollToLink();
666
- }
667
- } else if (key === "\x1B[Z") {
668
- if (contentLinks.length > 0) {
669
- linkIdx = linkIdx > 0 ? linkIdx - 1 : contentLinks.length - 1;
670
- scrollToLink();
671
- }
672
- } else if ((key === "\r" || key === "\n") && linkIdx >= 0 && linkIdx < contentLinks.length) activateLink(contentLinks[linkIdx].url);
673
- else if (key === "\x1B[A" || key === "k") contentScroll = Math.max(0, contentScroll - 1);
674
- else if (key === "\x1B[B" || key === "j") contentScroll = Math.min(maxScroll, contentScroll + 1);
675
- else if (key === " " || key === "\x1B[6~") contentScroll = Math.min(maxScroll, contentScroll + rows - 2);
676
- else if (key === "b" || key === "\x1B[5~") contentScroll = Math.max(0, contentScroll - rows + 2);
677
- else if (key === "/") {
678
- contentSearching = true;
679
- contentSearchQuery = "";
680
- contentMatches = [];
681
- contentMatchIdx = 0;
682
- showCursor();
683
- } else if (key === "n" && contentMatches.length > 0) {
684
- contentMatchIdx = (contentMatchIdx + 1) % contentMatches.length;
685
- scrollToMatch();
686
- } else if (key === "N" && contentMatches.length > 0) {
687
- contentMatchIdx = (contentMatchIdx - 1 + contentMatches.length) % contentMatches.length;
688
- scrollToMatch();
689
- } else if (key === "g") contentScroll = 0;
690
- else if (key === "G") contentScroll = maxScroll;
691
- else if (key === "t") {
692
- sidebarVisible = !sidebarVisible;
693
- reloadContent();
694
- } else if (key === "q") return cleanup();
695
- }
696
- function handleContentSearch(key) {
697
- if (key === "\x1B") {
698
- contentSearching = false;
699
- contentSearchQuery = "";
700
- contentMatches = [];
701
- hideCursor();
702
- } else if (key === "\r" || key === "\n") {
703
- contentSearching = false;
704
- hideCursor();
705
- } else if (key === "" || key === "\b") {
706
- contentSearchQuery = contentSearchQuery.slice(0, -1);
707
- contentMatches = findContentMatches(contentSearchQuery);
708
- contentMatchIdx = 0;
709
- scrollToMatch();
710
- } else if (key === "\x1B[A") {
711
- if (contentMatches.length > 0) {
712
- contentMatchIdx = (contentMatchIdx - 1 + contentMatches.length) % contentMatches.length;
713
- scrollToMatch();
714
- }
715
- } else if (key === "\x1B[B") {
716
- if (contentMatches.length > 0) {
717
- contentMatchIdx = (contentMatchIdx + 1) % contentMatches.length;
718
- scrollToMatch();
719
- }
720
- } else if (key.length === 1 && key >= " ") {
721
- contentSearchQuery += key;
722
- contentMatches = findContentMatches(contentSearchQuery);
723
- contentMatchIdx = 0;
724
- scrollToMatch();
725
- }
726
- }
727
- function updateSearchMatches() {
728
- searchMatches = docs.matchIndices(searchQuery);
729
- if (searchMatches.length > 0) cursor = searchMatches[0];
730
- else if (!searchQuery) cursor = firstNavigable(flat);
731
- loadContent(flat[cursor]);
732
- }
733
- function nextSearchMatch(dir) {
734
- if (searchMatches.length === 0) return;
735
- const curIdx = searchMatches.indexOf(cursor);
736
- if (curIdx < 0) cursor = searchMatches[0];
737
- else {
738
- const next = curIdx + dir;
739
- cursor = searchMatches[(next + searchMatches.length) % searchMatches.length];
740
- }
741
- loadContent(flat[cursor]);
742
- }
743
- function handleSearch(key) {
744
- if (key === "\x1B" || key === "\x1B[D") {
745
- searching = false;
746
- searchMatches = [];
747
- cursor = firstNavigable(flat);
748
- hideCursor();
749
- loadContent(flat[cursor]);
750
- } else if (key === "\r" || key === "\n") {
751
- searching = false;
752
- searchMatches = [];
753
- hideCursor();
754
- loadContent(flat[cursor]);
755
- } else if (key === "" || key === "\b") {
756
- searchQuery = searchQuery.slice(0, -1);
757
- updateSearchMatches();
758
- } else if (key === "\x1B[A") nextSearchMatch(-1);
759
- else if (key === "\x1B[B") nextSearchMatch(1);
760
- else if (key.length === 1 && key >= " ") {
761
- searchQuery += key;
762
- updateSearchMatches();
763
- }
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`);
764
315
  }
316
+ process.stderr.write("\nTo search within pages, pass a search query as second argument.\n");
765
317
  }
766
318
  //#endregion
767
319
  //#region src/cli/main.ts
@@ -772,62 +324,55 @@ async function main() {
772
324
  });
773
325
  const { values, positionals } = parseArgs({
774
326
  args: process.argv.slice(2),
327
+ allowPositionals: true,
775
328
  options: {
776
329
  help: {
777
330
  type: "boolean",
778
331
  short: "h"
779
332
  },
780
333
  export: { type: "string" },
781
- page: {
782
- type: "string",
783
- short: "p"
784
- },
785
334
  plain: {
786
335
  type: "boolean",
787
336
  default: isAgent || !process.stdout.isTTY
788
- },
789
- headless: { type: "boolean" },
790
- tui: { type: "boolean" }
791
- },
792
- allowPositionals: true
337
+ }
338
+ }
793
339
  });
794
- const exportDir = values.export;
795
- const docsDir = positionals[0];
796
- const plain = values.plain || values.headless || docsDir?.startsWith("npm:") || false;
797
- if (values.help || !docsDir) return printUsage(!!docsDir);
798
- const isURL = docsDir.startsWith("http://") || docsDir.startsWith("https://");
799
- if (docsDir.endsWith(".md")) return singleFileMode(docsDir, plain, isURL);
800
- const source = isURL ? new DocsSourceHTTP(docsDir) : docsDir.startsWith("gh:") ? new DocsSourceGit(docsDir) : docsDir.startsWith("npm:") ? new DocsSourceNpm(docsDir) : new DocsSourceFS(docsDir);
801
- 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);
802
348
  await docs.load();
803
- if (exportDir) {
804
- await exportDocsToFS(docs, exportDir, { plainText: plain });
805
- 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}`);
806
352
  return;
807
353
  }
808
- let pagePath = values.page;
809
- if (!pagePath && isURL) {
810
- const urlPath = new URL(docsDir).pathname.replace(/\/+$/, "");
811
- 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;
812
358
  }
813
- if (pagePath && !plain) return pageMode(docs, pagePath, plain);
814
- if (plain) return plainMode(docs, pagePath);
815
- if (values.tui) interactiveMode(docs);
816
- else {
817
- const { serve } = await import("srvx");
818
- const { createDocsServer } = await import("../_chunks/server.mjs");
819
- const server = serve({
820
- fetch: (await createDocsServer({ source })).fetch,
821
- gracefulShutdown: false
822
- });
823
- await server.ready();
824
- await server.fetch(new Request(new URL("/api/meta", server.url)));
825
- 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);
826
372
  }
373
+ return searchMode(docs, query, plain);
827
374
  }
828
375
  main().catch((err) => {
829
- showCursor();
830
- leaveAltScreen();
831
376
  console.error(err);
832
377
  process.exit(1);
833
378
  });