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 +16 -1
- package/dist/_chunks/exporter.mjs +115 -28
- package/dist/cli/main.mjs +235 -178
- package/dist/index.d.mts +14 -0
- package/package.json +1 -1
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 —
|
|
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 =
|
|
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
|
|
443
|
-
|
|
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
|
|
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
|
-
|
|
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/
|
|
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/
|
|
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/
|
|
296
|
-
async function
|
|
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.
|
|
573
|
-
if (idx >= 0
|
|
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
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
const
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
-
|
|
716
|
-
|
|
717
|
-
const
|
|
718
|
-
|
|
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
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
}
|