mdzilla 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,9 +56,24 @@ Flatten any docs source into plain `.md` files:
56
56
  npx mdzilla <source> --export <outdir>
57
57
  ```
58
58
 
59
+ ### Single Page
60
+
61
+ Print a specific page by path and exit:
62
+
63
+ ```sh
64
+ npx mdzilla gh:nuxt/nuxt --page /getting-started/seo-meta
65
+ npx mdzilla gh:nuxt/nuxt --plain --page /getting-started/seo-meta
66
+ ```
67
+
59
68
  ### Headless Mode
60
69
 
61
- Use `--plain` for non-interactive output — auto-enabled when called by AI agents or when stdout is not a TTY.
70
+ Use `--plain` (or `--headless`) for non-interactive output — works like `cat` but for rendered markdown. Auto-enabled when piping output or when called by AI agents.
71
+
72
+ ```sh
73
+ npx mdzilla README.md --plain # Pretty-print a markdown file
74
+ npx mdzilla README.md | head # Auto-plain when piped (no TTY)
75
+ npx mdzilla gh:unjs/h3 --plain # List all pages in plain text
76
+ ```
62
77
 
63
78
  ### Keyboard Controls
64
79
 
@@ -40,6 +40,45 @@ var DocsManager = class {
40
40
  filter(query) {
41
41
  return fuzzyFilter(this.flat, query, ({ entry }) => [entry.title, entry.path]);
42
42
  }
43
+ /** Flat entries that are navigable pages (excludes directory stubs). */
44
+ get pages() {
45
+ return this.flat.filter((f) => f.entry.page !== false);
46
+ }
47
+ /** Find a flat entry by path (exact or with trailing slash). */
48
+ findByPath(path) {
49
+ return this.flat.find((f) => f.entry.page !== false && (f.entry.path === path || f.entry.path === path + "/"));
50
+ }
51
+ /**
52
+ * Resolve a page path to its content, trying:
53
+ * 1. Exact match in the navigation tree
54
+ * 2. Stripped common prefix (e.g., /docs/guide/... → /guide/...)
55
+ * 3. Direct source fetch (for HTTP sources with uncrawled paths)
56
+ */
57
+ async resolvePage(path) {
58
+ const normalized = path.startsWith("/") ? path : "/" + path;
59
+ const entry = this.findByPath(normalized);
60
+ if (entry) {
61
+ const raw = await this.getContent(entry);
62
+ if (raw) return {
63
+ entry,
64
+ raw
65
+ };
66
+ }
67
+ const prefixed = normalized.match(/^\/[^/]+(\/.+)$/);
68
+ if (prefixed) {
69
+ const stripped = this.findByPath(prefixed[1]);
70
+ if (stripped) {
71
+ const raw = await this.getContent(stripped);
72
+ if (raw) return {
73
+ entry: stripped,
74
+ raw
75
+ };
76
+ }
77
+ }
78
+ const raw = await this.source.readContent(normalized).catch(() => void 0);
79
+ if (raw) return { raw };
80
+ return {};
81
+ }
43
82
  /** Return indices of matching flat entries (case-insensitive substring). */
44
83
  matchIndices(query) {
45
84
  if (!query) return [];
@@ -341,6 +380,64 @@ var DocsSourceGit = class extends DocsSource {
341
380
  }
342
381
  };
343
382
  //#endregion
383
+ //#region src/docs/sources/_npm.ts
384
+ /**
385
+ * Parse an npm package spec: `[@scope/]name[@version][/subdir]`
386
+ */
387
+ function parseNpmSpec(input) {
388
+ let rest = input;
389
+ let subdir = "";
390
+ if (rest.startsWith("@")) {
391
+ const secondSlash = rest.indexOf("/", rest.indexOf("/") + 1);
392
+ if (secondSlash > 0) {
393
+ subdir = rest.slice(secondSlash);
394
+ rest = rest.slice(0, secondSlash);
395
+ }
396
+ } else {
397
+ const firstSlash = rest.indexOf("/");
398
+ if (firstSlash > 0) {
399
+ subdir = rest.slice(firstSlash);
400
+ rest = rest.slice(0, firstSlash);
401
+ }
402
+ }
403
+ const versionSep = rest.startsWith("@") ? rest.indexOf("@", 1) : rest.indexOf("@");
404
+ const hasVersion = versionSep > 0;
405
+ return {
406
+ name: hasVersion ? rest.slice(0, versionSep) : rest,
407
+ version: hasVersion ? rest.slice(versionSep + 1) : "latest",
408
+ subdir
409
+ };
410
+ }
411
+ /**
412
+ * Fetch package metadata from the npm registry.
413
+ * When `version` is provided, fetches that specific version.
414
+ * Otherwise fetches the full package document.
415
+ */
416
+ async function fetchNpmInfo(name, version) {
417
+ const registryURL = version ? `https://registry.npmjs.org/${name}/${version}` : `https://registry.npmjs.org/${name}`;
418
+ const res = await fetch(registryURL);
419
+ if (!res.ok) throw new Error(`Failed to fetch package info for ${name}${version ? `@${version}` : ""}: ${res.status} ${res.statusText}`);
420
+ return res.json();
421
+ }
422
+ /**
423
+ * Detect npmjs.com URLs and extract the package name.
424
+ * Supports: npmjs.com/\<pkg\>, npmjs.com/package/\<pkg\>, www.npmjs.com/package/\<pkg\>
425
+ * Also handles scoped packages: npmjs.com/package/@scope/name
426
+ */
427
+ function parseNpmURL(url) {
428
+ let parsed;
429
+ try {
430
+ parsed = new URL(url);
431
+ } catch {
432
+ return;
433
+ }
434
+ if (parsed.hostname !== "www.npmjs.com" && parsed.hostname !== "npmjs.com") return;
435
+ const pkgMatch = parsed.pathname.match(/^\/package\/((?:@[^/]+\/)?[^/]+)\/?$/);
436
+ if (pkgMatch) return pkgMatch[1];
437
+ const shortMatch = parsed.pathname.match(/^\/((?:@[^/]+\/)?[^/]+)\/?$/);
438
+ if (shortMatch && !/^(package|settings|signup|login|org|search)$/.test(shortMatch[1])) return shortMatch[1];
439
+ }
440
+ //#endregion
344
441
  //#region src/docs/sources/http.ts
345
442
  var DocsSourceHTTP = class extends DocsSource {
346
443
  url;
@@ -352,7 +449,7 @@ var DocsSourceHTTP = class extends DocsSource {
352
449
  constructor(url, options = {}) {
353
450
  super();
354
451
  this.url = url.replace(/\/+$/, "");
355
- this._npmPackage = _parseNpmURL(this.url);
452
+ this._npmPackage = parseNpmURL(this.url);
356
453
  this.options = options;
357
454
  }
358
455
  async load() {
@@ -436,15 +533,10 @@ var DocsSourceHTTP = class extends DocsSource {
436
533
  }
437
534
  /** Load an npm package README from the registry */
438
535
  async _loadNpm(pkg) {
439
- const registryURL = `https://registry.npmjs.org/${pkg}`;
440
536
  let markdown;
441
537
  try {
442
- const res = await fetch(registryURL, { headers: { accept: "application/json" } });
443
- if (!res.ok) markdown = `# ${res.status} ${res.statusText}\n\nFailed to fetch package \`${pkg}\` from npm registry`;
444
- else {
445
- const data = await res.json();
446
- markdown = data.readme || `# ${data.name || pkg}\n\n${data.description || "No README available."}`;
447
- }
538
+ const data = await fetchNpmInfo(pkg);
539
+ markdown = data.readme || `# ${data.name || pkg}\n\n${data.description || "No README available."}`;
448
540
  } catch (err) {
449
541
  markdown = `# Fetch Error\n\nFailed to fetch package \`${pkg}\`\n\n> ${err instanceof Error ? err.message : String(err)}`;
450
542
  }
@@ -606,24 +698,6 @@ function _extractLinks(markdown, baseURL) {
606
698
  tocPaths
607
699
  };
608
700
  }
609
- /**
610
- * Detect npmjs.com URLs and extract the package name.
611
- * Supports: npmjs.com/<pkg>, npmjs.com/package/<pkg>, www.npmjs.com/package/<pkg>
612
- * Also handles scoped packages: npmjs.com/package/@scope/name
613
- */
614
- function _parseNpmURL(url) {
615
- let parsed;
616
- try {
617
- parsed = new URL(url);
618
- } catch {
619
- return;
620
- }
621
- if (parsed.hostname !== "www.npmjs.com" && parsed.hostname !== "npmjs.com") return;
622
- const pkgMatch = parsed.pathname.match(/^\/package\/((?:@[^/]+\/)?[^/]+)\/?$/);
623
- if (pkgMatch) return pkgMatch[1];
624
- const shortMatch = parsed.pathname.match(/^\/((?:@[^/]+\/)?[^/]+)\/?$/);
625
- if (shortMatch && !/^(package|settings|signup|login|org|search)$/.test(shortMatch[1])) return shortMatch[1];
626
- }
627
701
  /** Resolve an href relative to a base URL, returning null for external links */
628
702
  function _resolveHref(href, baseURL) {
629
703
  try {
@@ -649,14 +723,17 @@ var DocsSourceNpm = class extends DocsSource {
649
723
  this.options = options;
650
724
  }
651
725
  async load() {
652
- const source = this.options.subdir ? `${this.src}/${this.options.subdir}` : this.src;
726
+ const pkg = this.src.startsWith("npm:") ? this.src : `npm:${this.src}`;
727
+ const source = this.options.subdir ? `${pkg}/${this.options.subdir}` : pkg;
653
728
  const id = source.replace(/[/#:@]/g, "_");
654
729
  const dir = join(tmpdir(), "mdzilla", "npm", id);
655
730
  const { downloadTemplate } = await import("giget");
656
731
  await downloadTemplate(source, {
657
732
  dir,
658
733
  force: true,
659
- install: false
734
+ install: false,
735
+ providers: { npm: npmProvider },
736
+ registry: false
660
737
  });
661
738
  let docsDir = dir;
662
739
  for (const sub of ["docs/content", "docs"]) {
@@ -674,6 +751,16 @@ var DocsSourceNpm = class extends DocsSource {
674
751
  return this._fs.readContent(filePath);
675
752
  }
676
753
  };
754
+ async function npmProvider(input) {
755
+ const { name, version, subdir } = parseNpmSpec(input);
756
+ const info = await fetchNpmInfo(name, version);
757
+ return {
758
+ name: info.name,
759
+ version: info.version,
760
+ subdir,
761
+ tar: info.dist.tarball
762
+ };
763
+ }
677
764
  //#endregion
678
765
  //#region src/docs/exporter.ts
679
766
  var DocsExporter = class {};
package/dist/cli/main.mjs CHANGED
@@ -3,11 +3,12 @@ import { a as DocsSourceGit, c as DocsManager, i as DocsSourceHTTP, n as DocsExp
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { basename } from "node:path";
5
5
  import { parseMeta, renderToAnsi, renderToText } from "md4x";
6
- import { execSync } from "node:child_process";
7
6
  import { parseArgs } from "node:util";
8
7
  import { isAgent } from "std-env";
9
8
  import { highlightText } from "@speed-highlight/core/terminal";
10
- //#region src/cli/ansi.ts
9
+ import { execSync } from "node:child_process";
10
+ //#region src/cli/_ansi.ts
11
+ const noColor = !!(process.env.NO_COLOR || process.env.TERM === "dumb" || !process.stdout.isTTY || isAgent);
11
12
  const ESC = "\x1B[";
12
13
  const ANSI_RE = /\x1B(?:\[[0-9;]*[a-zA-Z]|\][^\x07\x1B]*(?:\x07|\x1B\\))/g;
13
14
  const clear = () => process.stdout.write(`${ESC}2J${ESC}3J${ESC}H`);
@@ -15,11 +16,12 @@ const enterAltScreen = () => process.stdout.write(`${ESC}?1049h`);
15
16
  const leaveAltScreen = () => process.stdout.write(`${ESC}?1049l`);
16
17
  const hideCursor = () => process.stdout.write(`${ESC}?25l`);
17
18
  const showCursor = () => process.stdout.write(`${ESC}?25h`);
18
- const bold = (s) => `${ESC}1m${s}${ESC}0m`;
19
- const dim = (s) => `${ESC}2m${s}${ESC}0m`;
20
- const cyan = (s) => `${ESC}36m${s}${ESC}0m`;
21
- const yellow = (s) => `${ESC}33m${s}${ESC}0m`;
19
+ const bold = (s) => noColor ? s : `${ESC}1m${s}${ESC}0m`;
20
+ const dim = (s) => noColor ? s : `${ESC}2m${s}${ESC}0m`;
21
+ const cyan = (s) => noColor ? s : `${ESC}36m${s}${ESC}0m`;
22
+ const yellow = (s) => noColor ? s : `${ESC}33m${s}${ESC}0m`;
22
23
  const bgGray = (s) => {
24
+ if (noColor) return s;
23
25
  const bg = `${ESC}48;5;237m`;
24
26
  return `${bg}${s.replaceAll(`${ESC}0m`, `${ESC}0m${bg}`)}${ESC}0m`;
25
27
  };
@@ -151,7 +153,155 @@ function highlight(text, query) {
151
153
  return text.slice(0, idx) + bold(yellow(text.slice(idx, idx + query.length))) + text.slice(idx + query.length);
152
154
  }
153
155
  //#endregion
154
- //#region src/cli/nav.ts
156
+ //#region src/cli/_usage.ts
157
+ function printUsage(hasInput) {
158
+ const bin = `${bold(cyan("npx"))} ${bold("mdzilla")}`;
159
+ const banner = isAgent ? [] : [
160
+ dim(" /\\ /\\ /\\"),
161
+ dim(" / \\ / \\ / \\"),
162
+ dim(" ╭────────────────╮"),
163
+ dim(" │") + bold(" # ") + dim(" ░░░░░ │"),
164
+ dim(" │ ░░░░░░░░ │"),
165
+ dim(" │ ░░░░░░ │"),
166
+ dim(" │ ░░░░░░░ │"),
167
+ dim(" │ ░░░░ │"),
168
+ dim(" │ ") + cyan("◉") + dim(" ") + cyan("◉") + dim(" │"),
169
+ dim(" ╰─┬──┬──┬──┬──┬──╯"),
170
+ dim(" ▽ ▽ ▽ ▽ ▽"),
171
+ ""
172
+ ];
173
+ console.log([
174
+ ...banner,
175
+ ` ${bold("mdzilla")} ${dim("— Markdown browser for humans and agents")}`,
176
+ "",
177
+ `${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")}`,
183
+ "",
184
+ `${bold("Options:")}`,
185
+ ` ${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`,
189
+ ` ${cyan("-h, --help")} Show this help message`,
190
+ "",
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.")}`
196
+ ].join("\n"));
197
+ process.exit(hasInput ? 0 : 1);
198
+ }
199
+ //#endregion
200
+ //#region src/cli/content.ts
201
+ async function renderContent(content, entry, navWidth) {
202
+ const contentWidth = (process.stdout.columns || 80) - navWidth - 2;
203
+ const codeBlocks = [];
204
+ const codeRe = /```(\w+)[^\n]*\n([\s\S]*?)```/g;
205
+ let m;
206
+ while ((m = codeRe.exec(content)) !== null) codeBlocks.push({
207
+ lang: m[1],
208
+ code: m[2]
209
+ });
210
+ const highlights = /* @__PURE__ */ new Map();
211
+ if (codeBlocks.length > 0) await Promise.all(codeBlocks.map(({ lang, code }) => highlightText(code, lang).then((h) => highlights.set(code, h)).catch(() => {})));
212
+ const rawLines = renderToAnsi(content).split("\n");
213
+ const lines = [];
214
+ lines.push(dim(entry.path));
215
+ lines.push("");
216
+ let inDim = false;
217
+ let dimLines = [];
218
+ let blockIdx = 0;
219
+ for (const rawLine of rawLines) {
220
+ if (!inDim && rawLine.includes("\x1B[2m") && !rawLine.includes("\x1B[22m")) {
221
+ inDim = true;
222
+ dimLines = [rawLine];
223
+ continue;
224
+ }
225
+ if (inDim) {
226
+ if (rawLine.startsWith("\x1B[22m")) {
227
+ inDim = false;
228
+ const block = codeBlocks[blockIdx];
229
+ const hl = block ? highlights.get(block.code) : void 0;
230
+ if (hl) {
231
+ const hlLines = hl.split("\n");
232
+ 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");
235
+ blockIdx++;
236
+ lines.push("");
237
+ } else dimLines.push(rawLine);
238
+ continue;
239
+ }
240
+ for (const w of wrapAnsi(rawLine, contentWidth)) lines.push(w + "\x1B[0m");
241
+ }
242
+ return lines;
243
+ }
244
+ //#endregion
245
+ //#region src/cli/render.ts
246
+ async function singleFileMode(filePath, plain, isURL) {
247
+ 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");
248
+ if (plain) {
249
+ process.stdout.write(renderToText(raw) + "\n");
250
+ return;
251
+ }
252
+ const meta = parseMeta(raw);
253
+ const slug = isURL ? new URL(filePath).pathname.split("/").pop()?.replace(/\.md$/i, "") || "page" : basename(filePath, ".md");
254
+ const lines = await renderContent(raw, {
255
+ slug,
256
+ path: "/" + slug,
257
+ title: meta.title || slug,
258
+ order: 0
259
+ }, 0);
260
+ process.stdout.write(lines.join("\n") + "\n");
261
+ }
262
+ async function pageMode(docs, pagePath, plain) {
263
+ const normalized = pagePath.startsWith("/") ? pagePath : "/" + pagePath;
264
+ const { entry, raw } = await docs.resolvePage(normalized);
265
+ if (!raw) {
266
+ console.error(`Page not found: ${pagePath}`);
267
+ process.exit(1);
268
+ }
269
+ const slug = (entry?.entry.path || normalized).split("/").pop() || "";
270
+ const navEntry = entry?.entry || {
271
+ slug,
272
+ path: normalized,
273
+ title: parseMeta(raw).title || slug,
274
+ order: 0
275
+ };
276
+ if (plain) process.stdout.write(renderToText(raw) + "\n");
277
+ else {
278
+ const lines = await renderContent(raw, navEntry, 0);
279
+ process.stdout.write(lines.join("\n") + "\n");
280
+ }
281
+ }
282
+ async function plainMode(docs, pagePath) {
283
+ const navigable = docs.pages;
284
+ if (navigable.length === 0) {
285
+ console.log("No pages found.");
286
+ return;
287
+ }
288
+ const tocLines = ["Table of Contents", ""];
289
+ for (const f of navigable) {
290
+ const indent = " ".repeat(f.depth);
291
+ tocLines.push(`${indent}- [${f.entry.title}](${f.entry.path})`);
292
+ }
293
+ 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
+ }
302
+ }
303
+ //#endregion
304
+ //#region src/cli/interactive/nav.ts
155
305
  function calcNavWidth(flat) {
156
306
  const cols = process.stdout.columns || 80;
157
307
  let max = 6;
@@ -224,52 +374,7 @@ function computeTreePrefixes(flat, isLast) {
224
374
  return prefixes;
225
375
  }
226
376
  //#endregion
227
- //#region src/cli/content.ts
228
- async function renderContent(content, entry, navWidth) {
229
- const contentWidth = (process.stdout.columns || 80) - navWidth - 2;
230
- const codeBlocks = [];
231
- const codeRe = /```(\w+)[^\n]*\n([\s\S]*?)```/g;
232
- let m;
233
- while ((m = codeRe.exec(content)) !== null) codeBlocks.push({
234
- lang: m[1],
235
- code: m[2]
236
- });
237
- const highlights = /* @__PURE__ */ new Map();
238
- if (codeBlocks.length > 0) await Promise.all(codeBlocks.map(({ lang, code }) => highlightText(code, lang).then((h) => highlights.set(code, h)).catch(() => {})));
239
- const rawLines = renderToAnsi(content).split("\n");
240
- const lines = [];
241
- lines.push(dim(entry.path));
242
- lines.push("");
243
- let inDim = false;
244
- let dimLines = [];
245
- let blockIdx = 0;
246
- for (const rawLine of rawLines) {
247
- if (!inDim && rawLine.includes("\x1B[2m") && !rawLine.includes("\x1B[22m")) {
248
- inDim = true;
249
- dimLines = [rawLine];
250
- continue;
251
- }
252
- if (inDim) {
253
- if (rawLine.startsWith("\x1B[22m")) {
254
- inDim = false;
255
- const block = codeBlocks[blockIdx];
256
- const hl = block ? highlights.get(block.code) : void 0;
257
- if (hl) {
258
- const hlLines = hl.split("\n");
259
- if (hlLines.length > 0 && hlLines[hlLines.length - 1] === "") hlLines.pop();
260
- for (const hlLine of hlLines) for (const w of wrapAnsi(" " + hlLine, contentWidth)) lines.push(w + "\x1B[0m");
261
- } else for (const dl of dimLines) for (const w of wrapAnsi(dl, contentWidth)) lines.push(w + "\x1B[0m");
262
- blockIdx++;
263
- lines.push("");
264
- } else dimLines.push(rawLine);
265
- continue;
266
- }
267
- for (const w of wrapAnsi(rawLine, contentWidth)) lines.push(w + "\x1B[0m");
268
- }
269
- return lines;
270
- }
271
- //#endregion
272
- //#region src/cli/render.ts
377
+ //#region src/cli/interactive/render.ts
273
378
  function renderSplit(flat, cursor, contentLines, contentScroll, search, focus, contentSearch, searchMatches, sidebarVisible = true) {
274
379
  const rows = process.stdout.rows || 24;
275
380
  const navWidth = sidebarVisible ? calcNavWidth(flat) : 0;
@@ -292,79 +397,41 @@ function renderSplit(flat, cursor, contentLines, contentScroll, search, focus, c
292
397
  return output.map((l) => l + eol).join("\n");
293
398
  }
294
399
  //#endregion
295
- //#region src/cli/main.ts
296
- async function main() {
297
- const { values, positionals } = parseArgs({
298
- args: process.argv.slice(2),
299
- options: {
300
- help: {
301
- type: "boolean",
302
- short: "h"
303
- },
304
- export: { type: "string" },
305
- plain: {
306
- type: "boolean",
307
- default: isAgent || !process.stdout.isTTY
308
- },
309
- headless: { type: "boolean" }
310
- },
311
- allowPositionals: true
312
- });
313
- const exportDir = values.export;
314
- const docsDir = positionals[0];
315
- const plain = values.plain || values.headless || docsDir?.startsWith("npm:") || false;
316
- if (values.help || !docsDir) {
317
- const bin = `${bold(cyan("npx"))} ${bold("mdzilla")}`;
318
- console.log([
319
- dim(" /\\ /\\ /\\"),
320
- dim(" / \\ / \\ / \\"),
321
- dim(" ╭────────────────╮"),
322
- dim(" │") + bold(" # ") + dim(" ░░░░░ │"),
323
- dim(" │ ░░░░░░░░ │"),
324
- dim(" │ ░░░░░░ │"),
325
- dim(" │ ░░░░░░░ │"),
326
- dim(" │ ░░░░ │"),
327
- dim(" │ ") + cyan("◉") + dim(" ") + cyan("◉") + dim(" │"),
328
- dim(" ╰─┬──┬──┬──┬──┬──╯"),
329
- dim(" ▽ ▽ ▽ ▽ ▽"),
330
- "",
331
- ` ${bold("mdzilla")} ${dim("— Markdown browser for humans and agents")}`,
332
- "",
333
- `${bold("Usage:")}`,
334
- ` ${bin} ${cyan("<dir>")} ${dim("Browse local docs directory")}`,
335
- ` ${bin} ${cyan("<file.md>")} ${dim("Render a single markdown file")}`,
336
- ` ${bin} ${cyan("gh:owner/repo")} ${dim("Browse GitHub repo docs")}`,
337
- ` ${bin} ${cyan("npm:package-name")} ${dim("Browse npm package docs")}`,
338
- ` ${bin} ${cyan("https://example.com")} ${dim("Browse remote docs via HTTP")}`,
339
- "",
340
- `${bold("Options:")}`,
341
- ` ${cyan("--export")} ${dim("<dir>")} Export docs to flat .md files`,
342
- ` ${cyan("--plain")} Plain text output (no TUI)`,
343
- ` ${cyan("--headless")} Alias for --plain`,
344
- ` ${cyan("-h, --help")} Show this help message`,
345
- "",
346
- `${bold("Remarks:")}`,
347
- ` ${dim("Headless mode is auto-enabled when called by AI agents or when stdout is not a TTY.")}`,
348
- ` ${dim("GitHub source (gh:) looks for a docs/ directory in the repository.")}`,
349
- ` ${dim("HTTP source tries /llms.txt first, then fetches with Accept: text/markdown,")}`,
350
- ` ${dim("and falls back to HTML-to-markdown conversion.")}`
351
- ].join("\n"));
352
- process.exit(docsDir ? 0 : 1);
353
- }
354
- if (docsDir.endsWith(".md")) return singleFileMode(docsDir, plain);
355
- const docs = new DocsManager(docsDir.startsWith("http://") || docsDir.startsWith("https://") ? new DocsSourceHTTP(docsDir) : docsDir.startsWith("gh:") ? new DocsSourceGit(docsDir) : docsDir.startsWith("npm:") ? new DocsSourceNpm(docsDir) : new DocsSourceFS(docsDir));
356
- await docs.load();
357
- if (exportDir) {
358
- await new DocsExporterFS(exportDir).export(docs, { plainText: plain });
359
- console.log(`Exported ${docs.flat.filter((f) => f.entry.page !== false).length} pages to ${exportDir}`);
360
- return;
361
- }
362
- if (plain) return plainMode(docs);
400
+ //#region src/cli/interactive/index.ts
401
+ async function interactiveMode(docs) {
363
402
  const flat = docs.flat;
364
403
  if (flat.length === 0) {
365
404
  console.log("No pages found.");
366
405
  process.exit(0);
367
406
  }
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;
368
435
  const extractLinks = (lines) => {
369
436
  const links = [];
370
437
  const re = /\x1B\]8;;([^\x07\x1B]+?)(?:\x07|\x1B\\)/g;
@@ -396,34 +463,6 @@ async function main() {
396
463
  }
397
464
  return line;
398
465
  };
399
- const isNavigable = (list, i) => list[i]?.entry.page !== false;
400
- const nextNavigable = (list, from, dir) => {
401
- let i = from + dir;
402
- while (i >= 0 && i < list.length) {
403
- if (isNavigable(list, i)) return i;
404
- i += dir;
405
- }
406
- return from;
407
- };
408
- const firstNavigable = (list) => {
409
- for (let i = 0; i < list.length; i++) if (isNavigable(list, i)) return i;
410
- return 0;
411
- };
412
- let cursor = firstNavigable(flat);
413
- let searching = false;
414
- let searchQuery = "";
415
- let searchMatches = [];
416
- let contentScroll = 0;
417
- let contentLines = [];
418
- let loadedPath = "";
419
- let focusContent = false;
420
- let contentSearching = false;
421
- let contentSearchQuery = "";
422
- let contentMatches = [];
423
- let contentMatchIdx = 0;
424
- let sidebarVisible = true;
425
- let contentLinks = [];
426
- let linkIdx = -1;
427
466
  const findContentMatches = (query) => {
428
467
  if (!query) return [];
429
468
  const lower = query.toLowerCase();
@@ -569,8 +608,8 @@ async function main() {
569
608
  } catch {}
570
609
  else {
571
610
  const target = url.replace(/^\.\//, "/").replace(/\/$/, "");
572
- const idx = flat.findIndex((f) => f.entry.path === target || f.entry.path === target + "/");
573
- if (idx >= 0 && isNavigable(flat, idx)) {
611
+ const idx = flat.indexOf(docs.findByPath(target));
612
+ if (idx >= 0) {
574
613
  cursor = idx;
575
614
  focusContent = false;
576
615
  linkIdx = -1;
@@ -690,36 +729,54 @@ async function main() {
690
729
  }
691
730
  }
692
731
  }
693
- async function singleFileMode(filePath, plain) {
694
- const raw = await readFile(filePath, "utf8");
695
- if (plain) {
696
- process.stdout.write(renderToText(raw) + "\n");
697
- return;
698
- }
699
- const meta = parseMeta(raw);
700
- const slug = basename(filePath, ".md");
701
- const lines = await renderContent(raw, {
702
- slug,
703
- path: "/" + slug,
704
- title: meta.title || slug,
705
- order: 0
706
- }, 0);
707
- process.stdout.write(lines.join("\n") + "\n");
708
- }
709
- async function plainMode(docs) {
710
- const navigable = docs.flat.filter((f) => f.entry.page !== false);
711
- if (navigable.length === 0) {
712
- console.log("No pages found.");
732
+ //#endregion
733
+ //#region src/cli/main.ts
734
+ async function main() {
735
+ process.stdout.on("error", (err) => {
736
+ if (err.code === "EPIPE") process.exit(0);
737
+ throw err;
738
+ });
739
+ const { values, positionals } = parseArgs({
740
+ args: process.argv.slice(2),
741
+ options: {
742
+ help: {
743
+ type: "boolean",
744
+ short: "h"
745
+ },
746
+ export: { type: "string" },
747
+ page: {
748
+ type: "string",
749
+ short: "p"
750
+ },
751
+ plain: {
752
+ type: "boolean",
753
+ default: isAgent || !process.stdout.isTTY
754
+ },
755
+ headless: { type: "boolean" }
756
+ },
757
+ allowPositionals: true
758
+ });
759
+ const exportDir = values.export;
760
+ const docsDir = positionals[0];
761
+ const plain = values.plain || values.headless || docsDir?.startsWith("npm:") || false;
762
+ if (values.help || !docsDir) return printUsage(!!docsDir);
763
+ const isURL = docsDir.startsWith("http://") || docsDir.startsWith("https://");
764
+ if (docsDir.endsWith(".md")) return singleFileMode(docsDir, plain, isURL);
765
+ const docs = new DocsManager(isURL ? new DocsSourceHTTP(docsDir) : docsDir.startsWith("gh:") ? new DocsSourceGit(docsDir) : docsDir.startsWith("npm:") ? new DocsSourceNpm(docsDir) : new DocsSourceFS(docsDir));
766
+ await docs.load();
767
+ if (exportDir) {
768
+ await new DocsExporterFS(exportDir).export(docs, { plainText: plain });
769
+ console.log(`Exported ${docs.pages.length} pages to ${exportDir}`);
713
770
  return;
714
771
  }
715
- const tocLines = ["# Table of Contents", ""];
716
- for (const f of navigable) {
717
- const indent = " ".repeat(f.depth);
718
- tocLines.push(`${indent}- [${f.entry.title}](${f.entry.path})`);
772
+ let pagePath = values.page;
773
+ if (!pagePath && isURL) {
774
+ const urlPath = new URL(docsDir).pathname.replace(/\/+$/, "");
775
+ if (urlPath && urlPath !== "/") pagePath = urlPath;
719
776
  }
720
- process.stdout.write(tocLines.join("\n") + "\n\n---\n\n");
721
- const raw = await docs.getContent(navigable[0]);
722
- if (raw) process.stdout.write(renderToText(raw) + "\n");
777
+ if (pagePath && !plain) return pageMode(docs, pagePath, plain);
778
+ if (plain) return plainMode(docs, pagePath);
779
+ return interactiveMode(docs);
723
780
  }
724
781
  main().catch((err) => {
725
782
  showCursor();
package/dist/index.d.mts CHANGED
@@ -124,6 +124,20 @@ declare class DocsManager {
124
124
  invalidate(filePath: string): void;
125
125
  /** Fuzzy filter flat entries by query string. */
126
126
  filter(query: string): FlatEntry[];
127
+ /** Flat entries that are navigable pages (excludes directory stubs). */
128
+ get pages(): FlatEntry[];
129
+ /** Find a flat entry by path (exact or with trailing slash). */
130
+ findByPath(path: string): FlatEntry | undefined;
131
+ /**
132
+ * Resolve a page path to its content, trying:
133
+ * 1. Exact match in the navigation tree
134
+ * 2. Stripped common prefix (e.g., /docs/guide/... → /guide/...)
135
+ * 3. Direct source fetch (for HTTP sources with uncrawled paths)
136
+ */
137
+ resolvePage(path: string): Promise<{
138
+ entry?: FlatEntry;
139
+ raw?: string;
140
+ }>;
127
141
  /** Return indices of matching flat entries (case-insensitive substring). */
128
142
  matchIndices(query: string): number[];
129
143
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mdzilla",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "",
5
5
  "license": "MIT",
6
6
  "repository": "pi0/mdzilla",