mdzilla 0.0.6 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -60
- package/dist/_chunks/exporter.mjs +174 -62
- package/dist/_chunks/server.mjs +394 -370
- package/dist/cli/main.mjs +167 -596
- package/dist/index.d.mts +105 -64
- package/dist/index.mjs +2 -2
- package/package.json +10 -11
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Markdown browser for humans and agents.
|
|
|
14
14
|
|
|
15
15
|
> Browse docs from local directories, GitHub repos, and remote websites — all from your terminal.
|
|
16
16
|
|
|
17
|
-
Built with [md4x](https://github.com/unjs/md4x), [
|
|
17
|
+
Built with [md4x](https://github.com/unjs/md4x), [giget](https://github.com/unjs/giget) and [speed-highlight](https://github.com/speed-highlight/core), [nitro](https://v3.nitro.build/), [h3](https://h3.dev/), [srvx](https://srvx.h3.dev/) and [vite](https://vite.dev/).
|
|
18
18
|
|
|
19
19
|
Supports any website with [`/llms.txt`](https://llmstxt.org/) or markdown content negotiation.
|
|
20
20
|
|
|
@@ -23,11 +23,11 @@ Works best with [Docus](https://docus.dev)/[Undocs](https://undocs.pages.dev/) d
|
|
|
23
23
|
## Quick Start
|
|
24
24
|
|
|
25
25
|
```sh
|
|
26
|
-
npx mdzilla <
|
|
27
|
-
npx mdzilla <
|
|
28
|
-
npx mdzilla
|
|
29
|
-
npx mdzilla
|
|
30
|
-
npx mdzilla
|
|
26
|
+
npx mdzilla <source> # Open docs in browser
|
|
27
|
+
npx mdzilla <source> <path> # Render a specific page
|
|
28
|
+
npx mdzilla <source> <query> # Search docs
|
|
29
|
+
npx mdzilla <file.md> # Render a single markdown file
|
|
30
|
+
npx mdzilla <source> --export <outdir> # Export docs to flat .md files
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
## Agent Skill
|
|
@@ -58,18 +58,18 @@ Flatten any docs source into plain `.md` files:
|
|
|
58
58
|
npx mdzilla <source> --export <outdir>
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
###
|
|
61
|
+
### Smart Resolve
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
The second positional argument is smart-resolved: if it matches a navigation path, the page is rendered; otherwise it's treated as a search query.
|
|
64
64
|
|
|
65
65
|
```sh
|
|
66
|
-
npx mdzilla gh:
|
|
67
|
-
npx mdzilla gh:
|
|
66
|
+
npx mdzilla gh:unjs/h3 /guide/basics # Render a specific page
|
|
67
|
+
npx mdzilla gh:unjs/h3 router # Search for 'router'
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
-
###
|
|
70
|
+
### Plain Mode
|
|
71
71
|
|
|
72
|
-
Use `--plain`
|
|
72
|
+
Use `--plain` for plain text output. Auto-enabled when piping output or when called by AI agents.
|
|
73
73
|
|
|
74
74
|
```sh
|
|
75
75
|
npx mdzilla README.md --plain # Pretty-print a markdown file
|
|
@@ -77,69 +77,56 @@ npx mdzilla README.md | head # Auto-plain when piped (no TTY)
|
|
|
77
77
|
npx mdzilla gh:unjs/h3 --plain # List all pages in plain text
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
<details>
|
|
83
|
-
<summary><strong>Browse mode</strong></summary>
|
|
84
|
-
|
|
85
|
-
| Key | Action |
|
|
86
|
-
| :-------------------- | :------------------- |
|
|
87
|
-
| `↑` `↓` / `j` `k` | Navigate entries |
|
|
88
|
-
| `Enter` / `Tab` / `→` | Focus content |
|
|
89
|
-
| `Space` / `PgDn` | Page down |
|
|
90
|
-
| `b` / `PgUp` | Page up |
|
|
91
|
-
| `g` / `G` | Jump to first / last |
|
|
92
|
-
| `/` | Search |
|
|
93
|
-
| `t` | Toggle sidebar |
|
|
94
|
-
| `q` | Quit |
|
|
80
|
+
## Programmatic API
|
|
95
81
|
|
|
96
|
-
|
|
82
|
+
### Export Docs
|
|
97
83
|
|
|
98
|
-
|
|
99
|
-
<summary><strong>Content mode</strong></summary>
|
|
100
|
-
|
|
101
|
-
| Key | Action |
|
|
102
|
-
| :------------------ | :-------------------- |
|
|
103
|
-
| `↑` `↓` / `j` `k` | Scroll |
|
|
104
|
-
| `Space` / `PgDn` | Page down |
|
|
105
|
-
| `b` / `PgUp` | Page up |
|
|
106
|
-
| `g` / `G` | Jump to top / bottom |
|
|
107
|
-
| `/` | Search in page |
|
|
108
|
-
| `n` / `N` | Next / previous match |
|
|
109
|
-
| `Tab` / `Shift+Tab` | Cycle links |
|
|
110
|
-
| `Enter` | Open link |
|
|
111
|
-
| `Backspace` / `Esc` | Back to nav |
|
|
112
|
-
| `q` | Quit |
|
|
84
|
+
One-call export — resolves source, loads, and writes flat `.md` files:
|
|
113
85
|
|
|
114
|
-
|
|
86
|
+
```js
|
|
87
|
+
import { exportSource } from "mdzilla";
|
|
115
88
|
|
|
116
|
-
|
|
117
|
-
|
|
89
|
+
await exportSource("./docs", "./dist/docs", {
|
|
90
|
+
title: "My Docs",
|
|
91
|
+
filter: (e) => !e.entry.path.startsWith("/blog"),
|
|
92
|
+
});
|
|
118
93
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
| `Esc` | Cancel |
|
|
94
|
+
// Works with any source
|
|
95
|
+
await exportSource("gh:unjs/h3", "./dist/h3-docs");
|
|
96
|
+
await exportSource("npm:h3", "./dist/h3-docs", { plainText: true });
|
|
97
|
+
await exportSource("https://h3.unjs.io", "./dist/h3-docs");
|
|
98
|
+
```
|
|
125
99
|
|
|
126
|
-
|
|
100
|
+
### Collection
|
|
127
101
|
|
|
128
|
-
|
|
102
|
+
`Collection` is the main class for working with documentation programmatically — browse the nav tree, read page content, search, and filter entries.
|
|
129
103
|
|
|
130
104
|
```js
|
|
131
|
-
import {
|
|
105
|
+
import { Collection, resolveSource } from "mdzilla";
|
|
132
106
|
|
|
133
|
-
const docs = new
|
|
107
|
+
const docs = new Collection(resolveSource("./docs"));
|
|
134
108
|
await docs.load();
|
|
135
109
|
|
|
136
|
-
//
|
|
137
|
-
|
|
110
|
+
docs.tree; // NavEntry[] — nested navigation tree
|
|
111
|
+
docs.flat; // FlatEntry[] — flattened list with depth info
|
|
112
|
+
docs.pages; // FlatEntry[] — only navigable pages (no directory stubs)
|
|
113
|
+
|
|
114
|
+
// Read page content
|
|
115
|
+
const page = docs.findByPath("/guide/installation");
|
|
116
|
+
const content = await docs.getContent(page);
|
|
138
117
|
|
|
139
|
-
//
|
|
140
|
-
const
|
|
118
|
+
// Resolve a page flexibly (exact match, prefix stripping, direct fetch)
|
|
119
|
+
const { entry, raw } = await docs.resolvePage("/docs/guide/installation");
|
|
120
|
+
|
|
121
|
+
// Fuzzy search
|
|
122
|
+
const results = docs.filter("instal"); // sorted by match score
|
|
123
|
+
|
|
124
|
+
// Substring match (returns indices into docs.flat)
|
|
125
|
+
const indices = docs.matchIndices("getting started");
|
|
141
126
|
```
|
|
142
127
|
|
|
128
|
+
`resolveSource` auto-detects the source type from the input string (`gh:`, `npm:`, `https://`, or local path). You can also use specific source classes directly (`FSSource`, `GitSource`, `NpmSource`, `HTTPSource`).
|
|
129
|
+
|
|
143
130
|
## Development
|
|
144
131
|
|
|
145
132
|
<details>
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
+
import { parseMeta, renderToMarkdown, renderToText } from "md4x";
|
|
1
2
|
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
2
3
|
import { basename, dirname, extname, join } from "node:path";
|
|
3
|
-
import { parseMeta, renderToMarkdown, renderToText } from "md4x";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
|
-
//#region src/
|
|
7
|
-
var
|
|
6
|
+
//#region src/collection.ts
|
|
7
|
+
var Collection = class {
|
|
8
8
|
source;
|
|
9
9
|
tree = [];
|
|
10
10
|
flat = [];
|
|
@@ -36,10 +36,51 @@ var DocsManager = class {
|
|
|
36
36
|
invalidate(filePath) {
|
|
37
37
|
this._contentCache.delete(filePath);
|
|
38
38
|
}
|
|
39
|
-
/** Fuzzy filter flat entries by query string. */
|
|
39
|
+
/** Fuzzy filter flat entries by query string (title and path only). */
|
|
40
40
|
filter(query) {
|
|
41
41
|
return fuzzyFilter(this.flat, query, ({ entry }) => [entry.title, entry.path]);
|
|
42
42
|
}
|
|
43
|
+
/** Search flat entries by query string, including page contents. Yields scored results as found. */
|
|
44
|
+
async *search(query) {
|
|
45
|
+
if (!query) return;
|
|
46
|
+
const lower = query.toLowerCase();
|
|
47
|
+
const terms = lower.split(/\s+/).filter(Boolean);
|
|
48
|
+
const matchAll = (text) => terms.every((t) => text.includes(t));
|
|
49
|
+
const seen = /* @__PURE__ */ new Set();
|
|
50
|
+
for (const flat of this.flat) {
|
|
51
|
+
if (flat.entry.page === false) continue;
|
|
52
|
+
if (seen.has(flat.entry.path)) continue;
|
|
53
|
+
seen.add(flat.entry.path);
|
|
54
|
+
const titleLower = flat.entry.title.toLowerCase();
|
|
55
|
+
const titleMatch = matchAll(titleLower) || matchAll(flat.entry.path.toLowerCase());
|
|
56
|
+
const content = await this.getContent(flat);
|
|
57
|
+
const contentLower = content?.toLowerCase();
|
|
58
|
+
const contentHit = contentLower ? matchAll(contentLower) : false;
|
|
59
|
+
if (!titleMatch && !contentHit) continue;
|
|
60
|
+
let score = 300;
|
|
61
|
+
let heading;
|
|
62
|
+
if (titleMatch) score = titleLower === lower ? 0 : 100;
|
|
63
|
+
else if (content) {
|
|
64
|
+
const meta = parseMeta(content);
|
|
65
|
+
for (const h of meta.headings || []) {
|
|
66
|
+
const hLower = h.text.toLowerCase();
|
|
67
|
+
if (matchAll(hLower)) {
|
|
68
|
+
score = hLower === lower ? 150 : 200;
|
|
69
|
+
heading = h.text;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const contentMatches = content ? findMatchLines(content, lower) : [];
|
|
75
|
+
yield {
|
|
76
|
+
flat,
|
|
77
|
+
score,
|
|
78
|
+
titleMatch,
|
|
79
|
+
heading,
|
|
80
|
+
contentMatches
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
43
84
|
/** Flat entries that are navigable pages (excludes directory stubs). */
|
|
44
85
|
get pages() {
|
|
45
86
|
return this.flat.filter((f) => f.entry.page !== false);
|
|
@@ -79,23 +120,18 @@ var DocsManager = class {
|
|
|
79
120
|
if (raw) return { raw };
|
|
80
121
|
return {};
|
|
81
122
|
}
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const parentDepth = this.flat[i].depth;
|
|
92
|
-
for (let j = i + 1; j < this.flat.length; j++) {
|
|
93
|
-
if (this.flat[j].depth <= parentDepth) break;
|
|
94
|
-
matched.add(j);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
123
|
+
/** Suggest related pages for a query (fuzzy + keyword fallback). */
|
|
124
|
+
suggest(query, max = 5) {
|
|
125
|
+
let results = this.filter(query);
|
|
126
|
+
if (results.length > 0) return results.slice(0, max);
|
|
127
|
+
const segments = query.replace(/^\/+/, "").split("/").filter(Boolean);
|
|
128
|
+
const lastSegment = segments.at(-1);
|
|
129
|
+
if (lastSegment && lastSegment !== query) {
|
|
130
|
+
results = this.filter(lastSegment);
|
|
131
|
+
if (results.length > 0) return results.slice(0, max);
|
|
97
132
|
}
|
|
98
|
-
|
|
133
|
+
const keywords = segments.flatMap((s) => s.split("-")).filter(Boolean);
|
|
134
|
+
return this.pages.filter((f) => keywords.some((kw) => f.entry.title.toLowerCase().includes(kw) || f.entry.path.toLowerCase().includes(kw))).slice(0, max);
|
|
99
135
|
}
|
|
100
136
|
};
|
|
101
137
|
function flattenTree(entries, depth, fileMap) {
|
|
@@ -144,7 +180,7 @@ function fuzzyFilter(items, query, getText) {
|
|
|
144
180
|
let best = Infinity;
|
|
145
181
|
for (const text of getText(item)) {
|
|
146
182
|
const s = fuzzyMatch(query, text);
|
|
147
|
-
if (s
|
|
183
|
+
if (s !== -1 && s < best) best = s;
|
|
148
184
|
}
|
|
149
185
|
if (best < Infinity) scored.push({
|
|
150
186
|
item,
|
|
@@ -154,11 +190,52 @@ function fuzzyFilter(items, query, getText) {
|
|
|
154
190
|
scored.sort((a, b) => a.score - b.score);
|
|
155
191
|
return scored.map((s) => s.item);
|
|
156
192
|
}
|
|
193
|
+
function findMatchLines(content, lowerQuery, contextLines = 1) {
|
|
194
|
+
const matches = [];
|
|
195
|
+
const lines = content.split("\n");
|
|
196
|
+
for (let i = 0; i < lines.length; i++) if (lines[i].toLowerCase().includes(lowerQuery)) {
|
|
197
|
+
const context = [];
|
|
198
|
+
for (let j = Math.max(0, i - contextLines); j <= Math.min(lines.length - 1, i + contextLines); j++) if (j !== i) context.push(lines[j].trim());
|
|
199
|
+
matches.push({
|
|
200
|
+
line: i + 1,
|
|
201
|
+
text: lines[i].trim(),
|
|
202
|
+
context
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
return matches;
|
|
206
|
+
}
|
|
157
207
|
//#endregion
|
|
158
|
-
//#region src/
|
|
159
|
-
|
|
208
|
+
//#region src/utils.ts
|
|
209
|
+
/** Extract short text snippets around matching terms. */
|
|
210
|
+
function extractSnippets(content, terms, opts = {}) {
|
|
211
|
+
const { maxSnippets = 3, radius = 80 } = opts;
|
|
212
|
+
const lower = content.toLowerCase();
|
|
213
|
+
const positions = [];
|
|
214
|
+
for (const term of terms) {
|
|
215
|
+
let idx = lower.indexOf(term);
|
|
216
|
+
while (idx !== -1 && positions.length < maxSnippets * 2) {
|
|
217
|
+
positions.push(idx);
|
|
218
|
+
idx = lower.indexOf(term, idx + term.length);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
positions.sort((a, b) => a - b);
|
|
222
|
+
const snippets = [];
|
|
223
|
+
let prevEnd = -1;
|
|
224
|
+
for (const pos of positions) {
|
|
225
|
+
if (snippets.length >= maxSnippets) break;
|
|
226
|
+
const start = Math.max(0, pos - radius);
|
|
227
|
+
const end = Math.min(content.length, pos + radius);
|
|
228
|
+
if (start <= prevEnd) continue;
|
|
229
|
+
prevEnd = end;
|
|
230
|
+
let snippet = content.slice(start, end).trim().replaceAll(/\s+/g, " ");
|
|
231
|
+
if (start > 0) snippet = "…" + snippet;
|
|
232
|
+
if (end < content.length) snippet = snippet + "…";
|
|
233
|
+
snippets.push(snippet);
|
|
234
|
+
}
|
|
235
|
+
return snippets;
|
|
236
|
+
}
|
|
160
237
|
//#endregion
|
|
161
|
-
//#region src/
|
|
238
|
+
//#region src/nav.ts
|
|
162
239
|
/**
|
|
163
240
|
* Parse a numbered filename/dirname like "1.guide" or "3.middleware.md"
|
|
164
241
|
* into { order, slug }. Also strips `.draft` suffix.
|
|
@@ -301,8 +378,11 @@ async function _scanNav(dirPath, parentPath, options) {
|
|
|
301
378
|
return entries;
|
|
302
379
|
}
|
|
303
380
|
//#endregion
|
|
304
|
-
//#region src/
|
|
305
|
-
var
|
|
381
|
+
//#region src/sources/_base.ts
|
|
382
|
+
var Source = class {};
|
|
383
|
+
//#endregion
|
|
384
|
+
//#region src/sources/fs.ts
|
|
385
|
+
var FSSource = class extends Source {
|
|
306
386
|
dir;
|
|
307
387
|
constructor(dir) {
|
|
308
388
|
super();
|
|
@@ -369,8 +449,8 @@ function reorderTree(entries, manifest) {
|
|
|
369
449
|
}
|
|
370
450
|
}
|
|
371
451
|
//#endregion
|
|
372
|
-
//#region src/
|
|
373
|
-
var
|
|
452
|
+
//#region src/sources/git.ts
|
|
453
|
+
var GitSource = class extends Source {
|
|
374
454
|
src;
|
|
375
455
|
options;
|
|
376
456
|
_fs;
|
|
@@ -398,16 +478,16 @@ var DocsSourceGit = class extends DocsSource {
|
|
|
398
478
|
break;
|
|
399
479
|
}
|
|
400
480
|
}
|
|
401
|
-
this._fs = new
|
|
481
|
+
this._fs = new FSSource(docsDir);
|
|
402
482
|
return this._fs.load();
|
|
403
483
|
}
|
|
404
484
|
async readContent(filePath) {
|
|
405
|
-
if (!this._fs) throw new Error("
|
|
485
|
+
if (!this._fs) throw new Error("GitSource: call load() before readContent()");
|
|
406
486
|
return this._fs.readContent(filePath);
|
|
407
487
|
}
|
|
408
488
|
};
|
|
409
489
|
//#endregion
|
|
410
|
-
//#region src/
|
|
490
|
+
//#region src/sources/_npm.ts
|
|
411
491
|
/**
|
|
412
492
|
* Parse an npm package spec: `[@scope/]name[@version][/subdir]`
|
|
413
493
|
*/
|
|
@@ -465,8 +545,8 @@ function parseNpmURL(url) {
|
|
|
465
545
|
if (shortMatch && !/^(package|settings|signup|login|org|search)$/.test(shortMatch[1])) return shortMatch[1];
|
|
466
546
|
}
|
|
467
547
|
//#endregion
|
|
468
|
-
//#region src/
|
|
469
|
-
var
|
|
548
|
+
//#region src/sources/http.ts
|
|
549
|
+
var HTTPSource = class extends Source {
|
|
470
550
|
url;
|
|
471
551
|
options;
|
|
472
552
|
_contentCache = /* @__PURE__ */ new Map();
|
|
@@ -591,21 +671,9 @@ var DocsSourceHTTP = class extends DocsSource {
|
|
|
591
671
|
return `# Fetch Error\n\nFailed to fetch \`${url}\`\n\n> ${err instanceof Error ? err.message : String(err)}`;
|
|
592
672
|
}
|
|
593
673
|
if (!res.ok) return `# ${res.status} ${res.statusText}\n\nFailed to fetch \`${url}\``;
|
|
594
|
-
|
|
595
|
-
const text = await res.text();
|
|
596
|
-
if (_isHTML(contentType, text)) {
|
|
597
|
-
const { htmlToMarkdown } = await import("mdream");
|
|
598
|
-
return htmlToMarkdown(text, { origin: url });
|
|
599
|
-
}
|
|
600
|
-
return text;
|
|
674
|
+
return await res.text();
|
|
601
675
|
}
|
|
602
676
|
};
|
|
603
|
-
/** Check if a response is HTML by content-type or content sniffing */
|
|
604
|
-
function _isHTML(contentType, body) {
|
|
605
|
-
if (contentType.includes("text/html") || contentType.includes("application/xhtml")) return true;
|
|
606
|
-
const trimmed = body.trimStart();
|
|
607
|
-
return trimmed.startsWith("<!") || trimmed.startsWith("<html");
|
|
608
|
-
}
|
|
609
677
|
/** Extract a readable title from a URL */
|
|
610
678
|
function _titleFromURL(url) {
|
|
611
679
|
try {
|
|
@@ -739,8 +807,8 @@ function _resolveHref(href, baseURL) {
|
|
|
739
807
|
}
|
|
740
808
|
}
|
|
741
809
|
//#endregion
|
|
742
|
-
//#region src/
|
|
743
|
-
var
|
|
810
|
+
//#region src/sources/npm.ts
|
|
811
|
+
var NpmSource = class extends Source {
|
|
744
812
|
src;
|
|
745
813
|
options;
|
|
746
814
|
_fs;
|
|
@@ -770,11 +838,11 @@ var DocsSourceNpm = class extends DocsSource {
|
|
|
770
838
|
break;
|
|
771
839
|
}
|
|
772
840
|
}
|
|
773
|
-
this._fs = new
|
|
841
|
+
this._fs = new FSSource(docsDir);
|
|
774
842
|
return this._fs.load();
|
|
775
843
|
}
|
|
776
844
|
async readContent(filePath) {
|
|
777
|
-
if (!this._fs) throw new Error("
|
|
845
|
+
if (!this._fs) throw new Error("NpmSource: call load() before readContent()");
|
|
778
846
|
return this._fs.readContent(filePath);
|
|
779
847
|
}
|
|
780
848
|
};
|
|
@@ -789,31 +857,65 @@ async function npmProvider(input) {
|
|
|
789
857
|
};
|
|
790
858
|
}
|
|
791
859
|
//#endregion
|
|
792
|
-
//#region src/
|
|
860
|
+
//#region src/source.ts
|
|
861
|
+
/**
|
|
862
|
+
* Resolve a source string to the appropriate Source instance.
|
|
863
|
+
*
|
|
864
|
+
* Supports: local paths, `gh:owner/repo`, `npm:package`, `http(s)://...`
|
|
865
|
+
*/
|
|
866
|
+
function resolveSource(input) {
|
|
867
|
+
if (input.startsWith("http://") || input.startsWith("https://")) return new HTTPSource(input);
|
|
868
|
+
if (input.startsWith("gh:")) return new GitSource(input);
|
|
869
|
+
if (input.startsWith("npm:")) return new NpmSource(input);
|
|
870
|
+
return new FSSource(input);
|
|
871
|
+
}
|
|
872
|
+
//#endregion
|
|
873
|
+
//#region src/exporter.ts
|
|
874
|
+
/**
|
|
875
|
+
* High-level export: resolve source, load, and export in one call.
|
|
876
|
+
*
|
|
877
|
+
* ```ts
|
|
878
|
+
* await exportSource("./docs", "./dist/docs");
|
|
879
|
+
* await exportSource("gh:unjs/h3", "./dist/h3-docs");
|
|
880
|
+
* await exportSource("npm:h3", "./dist/h3-docs", { plainText: true });
|
|
881
|
+
* await exportSource("https://h3.unjs.io", "./dist/h3-docs");
|
|
882
|
+
* ```
|
|
883
|
+
*/
|
|
884
|
+
async function exportSource(input, dir, options = {}) {
|
|
885
|
+
const collection = new Collection(typeof input === "string" ? resolveSource(input) : input);
|
|
886
|
+
await collection.load();
|
|
887
|
+
await mkdir(dir, { recursive: true });
|
|
888
|
+
await writeCollection(collection, dir, options);
|
|
889
|
+
return collection;
|
|
890
|
+
}
|
|
793
891
|
/** Paths to skip during export (generated by source, not actual docs) */
|
|
794
892
|
const IGNORED_PATHS = new Set(["/llms.txt", "/llms-full.txt"]);
|
|
795
893
|
/**
|
|
796
894
|
* Export documentation entries to a local filesystem directory as flat `.md` files.
|
|
797
895
|
*
|
|
798
|
-
* Each entry is written to `<dir>/<
|
|
799
|
-
* index pages). Navigation order is preserved via
|
|
800
|
-
*
|
|
896
|
+
* Each entry is written to `<dir>/<prefix>.<slug>.md` (or `<dir>/<prefix>.<slug>/index.md`
|
|
897
|
+
* for directory index pages). Navigation order is preserved via numeric prefixes on
|
|
898
|
+
* directories and files (e.g., `1.guide/`, `2.getting-started.md`) so the nav scanner
|
|
899
|
+
* can infer order without additional metadata files.
|
|
801
900
|
*
|
|
802
901
|
* A `README.md` table of contents is generated at the root of the output directory.
|
|
803
902
|
*/
|
|
804
|
-
async function
|
|
805
|
-
const rootEntry =
|
|
903
|
+
async function writeCollection(collection, dir, options = {}) {
|
|
904
|
+
const rootEntry = collection.flat.find((f) => f.entry.path === "/");
|
|
806
905
|
const tocLines = [`# ${options.title ?? rootEntry?.entry.title ?? "Table of Contents"}`, ""];
|
|
807
906
|
const writtenFiles = /* @__PURE__ */ new Set();
|
|
907
|
+
const pathMap = /* @__PURE__ */ new Map();
|
|
908
|
+
buildNumberedPaths(collection.tree, "", pathMap);
|
|
808
909
|
const dirPaths = /* @__PURE__ */ new Set();
|
|
809
|
-
collectDirPaths(
|
|
810
|
-
for (const flat of
|
|
910
|
+
collectDirPaths(collection.tree, dirPaths);
|
|
911
|
+
for (const flat of collection.flat) {
|
|
811
912
|
if (options.filter ? !options.filter(flat) : flat.entry.page === false) continue;
|
|
812
913
|
if (IGNORED_PATHS.has(flat.entry.path)) continue;
|
|
813
|
-
let content = await
|
|
914
|
+
let content = await collection.getContent(flat);
|
|
814
915
|
if (content === void 0) continue;
|
|
815
916
|
const cleanContent = options.plainText ? renderToText(content) : renderToMarkdown(content);
|
|
816
|
-
const
|
|
917
|
+
const numberedPath = pathMap.get(flat.entry.path) ?? flat.entry.path;
|
|
918
|
+
const filePath = flat.entry.path === "/" || dirPaths.has(flat.entry.path) ? flat.entry.path === "/" ? "/index.md" : `${numberedPath}/index.md` : numberedPath.endsWith(".md") ? numberedPath : `${numberedPath}.md`;
|
|
817
919
|
const dest = join(dir, filePath);
|
|
818
920
|
await mkdir(dirname(dest), { recursive: true });
|
|
819
921
|
await writeFile(dest, cleanContent, "utf8");
|
|
@@ -825,7 +927,6 @@ async function exportDocsToFS(manager, dir, options = {}) {
|
|
|
825
927
|
let tocFile = options.tocFile ?? "README.md";
|
|
826
928
|
if (writtenFiles.has(tocFile)) tocFile = `_${tocFile}`;
|
|
827
929
|
await writeFile(join(dir, tocFile), tocLines.join("\n") + "\n", "utf8");
|
|
828
|
-
await writeFile(join(dir, "_navigation.json"), JSON.stringify(manager.tree, null, 2) + "\n", "utf8");
|
|
829
930
|
}
|
|
830
931
|
/** Collect all paths that are directories (have children in the tree). */
|
|
831
932
|
function collectDirPaths(entries, set) {
|
|
@@ -834,5 +935,16 @@ function collectDirPaths(entries, set) {
|
|
|
834
935
|
collectDirPaths(entry.children, set);
|
|
835
936
|
}
|
|
836
937
|
}
|
|
938
|
+
/**
|
|
939
|
+
* Build a map from nav path → numbered filesystem path.
|
|
940
|
+
* Uses sibling index as the numeric prefix (e.g., `/guide` → `/1.guide`).
|
|
941
|
+
*/
|
|
942
|
+
function buildNumberedPaths(entries, parentPath, map) {
|
|
943
|
+
for (const [i, entry] of entries.entries()) {
|
|
944
|
+
const numbered = `${parentPath}/${i}.${entry.slug || "index"}`;
|
|
945
|
+
map.set(entry.path, numbered);
|
|
946
|
+
if (entry.children?.length) buildNumberedPaths(entry.children, numbered, map);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
837
949
|
//#endregion
|
|
838
|
-
export {
|
|
950
|
+
export { HTTPSource as a, Source as c, NpmSource as i, extractSnippets as l, writeCollection as n, GitSource as o, resolveSource as r, FSSource as s, exportSource as t, Collection as u };
|