mdzilla 0.1.0 → 0.2.1
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 +66 -59
- package/dist/_chunks/exporter.mjs +220 -52
- package/dist/_chunks/server.mjs +649 -446
- package/dist/cli/main.mjs +149 -604
- package/dist/index.d.mts +123 -64
- package/dist/index.mjs +2 -2
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -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,31 @@ 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
|
+
### Web Server
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
Running `mdzilla <source>` without a query opens docs in the browser with a local web server:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
npx mdzilla ./docs # Browse local docs in browser
|
|
76
|
+
npx mdzilla gh:unjs/h3 # Browse GitHub repo docs
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The web UI provides a sidebar navigation, full-text search, syntax-highlighted pages, and dark/light theme support.
|
|
80
|
+
|
|
81
|
+
For local sources (`FSSource`), the server watches for file changes and live-reloads both the navigation and the current page via Server-Sent Events — no manual refresh needed.
|
|
82
|
+
|
|
83
|
+
### Plain Mode
|
|
84
|
+
|
|
85
|
+
Use `--plain` for plain text output. Auto-enabled when piping output or when called by AI agents.
|
|
73
86
|
|
|
74
87
|
```sh
|
|
75
88
|
npx mdzilla README.md --plain # Pretty-print a markdown file
|
|
@@ -77,69 +90,63 @@ npx mdzilla README.md | head # Auto-plain when piped (no TTY)
|
|
|
77
90
|
npx mdzilla gh:unjs/h3 --plain # List all pages in plain text
|
|
78
91
|
```
|
|
79
92
|
|
|
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 |
|
|
93
|
+
## Programmatic API
|
|
95
94
|
|
|
96
|
-
|
|
95
|
+
### Export Docs
|
|
97
96
|
|
|
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 |
|
|
97
|
+
One-call export — resolves source, loads, and writes flat `.md` files:
|
|
113
98
|
|
|
114
|
-
|
|
99
|
+
```js
|
|
100
|
+
import { exportSource } from "mdzilla";
|
|
115
101
|
|
|
116
|
-
|
|
117
|
-
|
|
102
|
+
await exportSource("./docs", "./dist/docs", {
|
|
103
|
+
title: "My Docs",
|
|
104
|
+
filter: (e) => !e.entry.path.startsWith("/blog"),
|
|
105
|
+
});
|
|
118
106
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
| `Esc` | Cancel |
|
|
107
|
+
// Works with any source
|
|
108
|
+
await exportSource("gh:unjs/h3", "./dist/h3-docs");
|
|
109
|
+
await exportSource("npm:h3", "./dist/h3-docs", { plainText: true });
|
|
110
|
+
await exportSource("https://h3.unjs.io", "./dist/h3-docs");
|
|
111
|
+
```
|
|
125
112
|
|
|
126
|
-
|
|
113
|
+
### Collection
|
|
127
114
|
|
|
128
|
-
|
|
115
|
+
`Collection` is the main class for working with documentation programmatically — browse the nav tree, read page content, search, and filter entries.
|
|
129
116
|
|
|
130
117
|
```js
|
|
131
|
-
import {
|
|
118
|
+
import { Collection, resolveSource } from "mdzilla";
|
|
132
119
|
|
|
133
|
-
const docs = new
|
|
120
|
+
const docs = new Collection(resolveSource("./docs"));
|
|
134
121
|
await docs.load();
|
|
135
122
|
|
|
136
|
-
//
|
|
137
|
-
|
|
123
|
+
docs.tree; // NavEntry[] — nested navigation tree
|
|
124
|
+
docs.flat; // FlatEntry[] — flattened list with depth info
|
|
125
|
+
docs.pages; // FlatEntry[] — only navigable pages (no directory stubs)
|
|
138
126
|
|
|
139
|
-
//
|
|
140
|
-
const
|
|
127
|
+
// Read page content
|
|
128
|
+
const page = docs.findByPath("/guide/installation");
|
|
129
|
+
const content = await docs.getContent(page);
|
|
130
|
+
|
|
131
|
+
// Resolve a page flexibly (exact match, prefix stripping, direct fetch)
|
|
132
|
+
const { entry, raw } = await docs.resolvePage("/docs/guide/installation");
|
|
133
|
+
|
|
134
|
+
// Fuzzy search
|
|
135
|
+
const results = docs.filter("instal"); // sorted by match score
|
|
136
|
+
|
|
137
|
+
// Substring match (returns indices into docs.flat)
|
|
138
|
+
const indices = docs.matchIndices("getting started");
|
|
139
|
+
|
|
140
|
+
// Watch for changes (FSSource only) with live reload
|
|
141
|
+
docs.watch();
|
|
142
|
+
const unsub = docs.onChange((path) => {
|
|
143
|
+
console.log(`Changed: ${path}`);
|
|
144
|
+
});
|
|
145
|
+
// Later: unsub() and docs.unwatch()
|
|
141
146
|
```
|
|
142
147
|
|
|
148
|
+
`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`).
|
|
149
|
+
|
|
143
150
|
## Development
|
|
144
151
|
|
|
145
152
|
<details>
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
+
import { parseMeta, renderToMarkdown, renderToText } from "md4x";
|
|
1
2
|
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { existsSync, watch } from "node:fs";
|
|
2
4
|
import { basename, dirname, extname, join } from "node:path";
|
|
3
|
-
import { parseMeta, renderToMarkdown, renderToText } from "md4x";
|
|
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 = [];
|
|
11
11
|
_fileMap = /* @__PURE__ */ new Map();
|
|
12
12
|
_contentCache = /* @__PURE__ */ new Map();
|
|
13
|
+
_changeListeners = /* @__PURE__ */ new Set();
|
|
14
|
+
_reloadTimer;
|
|
13
15
|
constructor(source) {
|
|
14
16
|
this.source = source;
|
|
15
17
|
}
|
|
@@ -23,6 +25,28 @@ var DocsManager = class {
|
|
|
23
25
|
async reload() {
|
|
24
26
|
return this.load();
|
|
25
27
|
}
|
|
28
|
+
/** Start watching source for changes. Debounces, reloads collection, and notifies listeners. */
|
|
29
|
+
watch() {
|
|
30
|
+
this.source.watch(({ path }) => {
|
|
31
|
+
clearTimeout(this._reloadTimer);
|
|
32
|
+
this._reloadTimer = setTimeout(() => {
|
|
33
|
+
this.reload().then(() => {
|
|
34
|
+
for (const listener of this._changeListeners) listener(path);
|
|
35
|
+
});
|
|
36
|
+
}, 100);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/** Stop watching source. */
|
|
40
|
+
unwatch() {
|
|
41
|
+
clearTimeout(this._reloadTimer);
|
|
42
|
+
this.source.unwatch();
|
|
43
|
+
this._changeListeners.clear();
|
|
44
|
+
}
|
|
45
|
+
/** Register a change listener. Returns unsubscribe function. */
|
|
46
|
+
onChange(listener) {
|
|
47
|
+
this._changeListeners.add(listener);
|
|
48
|
+
return () => this._changeListeners.delete(listener);
|
|
49
|
+
}
|
|
26
50
|
/** Get raw file content for a flat entry (cached). */
|
|
27
51
|
async getContent(entry) {
|
|
28
52
|
if (!entry.filePath || entry.entry.page === false) return void 0;
|
|
@@ -36,10 +60,51 @@ var DocsManager = class {
|
|
|
36
60
|
invalidate(filePath) {
|
|
37
61
|
this._contentCache.delete(filePath);
|
|
38
62
|
}
|
|
39
|
-
/** Fuzzy filter flat entries by query string. */
|
|
63
|
+
/** Fuzzy filter flat entries by query string (title and path only). */
|
|
40
64
|
filter(query) {
|
|
41
65
|
return fuzzyFilter(this.flat, query, ({ entry }) => [entry.title, entry.path]);
|
|
42
66
|
}
|
|
67
|
+
/** Search flat entries by query string, including page contents. Yields scored results as found. */
|
|
68
|
+
async *search(query) {
|
|
69
|
+
if (!query) return;
|
|
70
|
+
const lower = query.toLowerCase();
|
|
71
|
+
const terms = lower.split(/\s+/).filter(Boolean);
|
|
72
|
+
const matchAll = (text) => terms.every((t) => text.includes(t));
|
|
73
|
+
const seen = /* @__PURE__ */ new Set();
|
|
74
|
+
for (const flat of this.flat) {
|
|
75
|
+
if (flat.entry.page === false) continue;
|
|
76
|
+
if (seen.has(flat.entry.path)) continue;
|
|
77
|
+
seen.add(flat.entry.path);
|
|
78
|
+
const titleLower = flat.entry.title.toLowerCase();
|
|
79
|
+
const titleMatch = matchAll(titleLower) || matchAll(flat.entry.path.toLowerCase());
|
|
80
|
+
const content = await this.getContent(flat);
|
|
81
|
+
const contentLower = content?.toLowerCase();
|
|
82
|
+
const contentHit = contentLower ? matchAll(contentLower) : false;
|
|
83
|
+
if (!titleMatch && !contentHit) continue;
|
|
84
|
+
let score = 300;
|
|
85
|
+
let heading;
|
|
86
|
+
if (titleMatch) score = titleLower === lower ? 0 : 100;
|
|
87
|
+
else if (content) {
|
|
88
|
+
const meta = parseMeta(content);
|
|
89
|
+
for (const h of meta.headings || []) {
|
|
90
|
+
const hLower = h.text.toLowerCase();
|
|
91
|
+
if (matchAll(hLower)) {
|
|
92
|
+
score = hLower === lower ? 150 : 200;
|
|
93
|
+
heading = h.text;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const contentMatches = content ? findMatchLines(content, lower) : [];
|
|
99
|
+
yield {
|
|
100
|
+
flat,
|
|
101
|
+
score,
|
|
102
|
+
titleMatch,
|
|
103
|
+
heading,
|
|
104
|
+
contentMatches
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
43
108
|
/** Flat entries that are navigable pages (excludes directory stubs). */
|
|
44
109
|
get pages() {
|
|
45
110
|
return this.flat.filter((f) => f.entry.page !== false);
|
|
@@ -79,23 +144,18 @@ var DocsManager = class {
|
|
|
79
144
|
if (raw) return { raw };
|
|
80
145
|
return {};
|
|
81
146
|
}
|
|
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
|
-
}
|
|
147
|
+
/** Suggest related pages for a query (fuzzy + keyword fallback). */
|
|
148
|
+
suggest(query, max = 5) {
|
|
149
|
+
let results = this.filter(query);
|
|
150
|
+
if (results.length > 0) return results.slice(0, max);
|
|
151
|
+
const segments = query.replace(/^\/+/, "").split("/").filter(Boolean);
|
|
152
|
+
const lastSegment = segments.at(-1);
|
|
153
|
+
if (lastSegment && lastSegment !== query) {
|
|
154
|
+
results = this.filter(lastSegment);
|
|
155
|
+
if (results.length > 0) return results.slice(0, max);
|
|
97
156
|
}
|
|
98
|
-
|
|
157
|
+
const keywords = segments.flatMap((s) => s.split("-")).filter(Boolean);
|
|
158
|
+
return this.pages.filter((f) => keywords.some((kw) => f.entry.title.toLowerCase().includes(kw) || f.entry.path.toLowerCase().includes(kw))).slice(0, max);
|
|
99
159
|
}
|
|
100
160
|
};
|
|
101
161
|
function flattenTree(entries, depth, fileMap) {
|
|
@@ -144,7 +204,7 @@ function fuzzyFilter(items, query, getText) {
|
|
|
144
204
|
let best = Infinity;
|
|
145
205
|
for (const text of getText(item)) {
|
|
146
206
|
const s = fuzzyMatch(query, text);
|
|
147
|
-
if (s
|
|
207
|
+
if (s !== -1 && s < best) best = s;
|
|
148
208
|
}
|
|
149
209
|
if (best < Infinity) scored.push({
|
|
150
210
|
item,
|
|
@@ -154,11 +214,52 @@ function fuzzyFilter(items, query, getText) {
|
|
|
154
214
|
scored.sort((a, b) => a.score - b.score);
|
|
155
215
|
return scored.map((s) => s.item);
|
|
156
216
|
}
|
|
217
|
+
function findMatchLines(content, lowerQuery, contextLines = 1) {
|
|
218
|
+
const matches = [];
|
|
219
|
+
const lines = content.split("\n");
|
|
220
|
+
for (let i = 0; i < lines.length; i++) if (lines[i].toLowerCase().includes(lowerQuery)) {
|
|
221
|
+
const context = [];
|
|
222
|
+
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());
|
|
223
|
+
matches.push({
|
|
224
|
+
line: i + 1,
|
|
225
|
+
text: lines[i].trim(),
|
|
226
|
+
context
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return matches;
|
|
230
|
+
}
|
|
157
231
|
//#endregion
|
|
158
|
-
//#region src/
|
|
159
|
-
|
|
232
|
+
//#region src/utils.ts
|
|
233
|
+
/** Extract short text snippets around matching terms. */
|
|
234
|
+
function extractSnippets(content, terms, opts = {}) {
|
|
235
|
+
const { maxSnippets = 3, radius = 80 } = opts;
|
|
236
|
+
const lower = content.toLowerCase();
|
|
237
|
+
const positions = [];
|
|
238
|
+
for (const term of terms) {
|
|
239
|
+
let idx = lower.indexOf(term);
|
|
240
|
+
while (idx !== -1 && positions.length < maxSnippets * 2) {
|
|
241
|
+
positions.push(idx);
|
|
242
|
+
idx = lower.indexOf(term, idx + term.length);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
positions.sort((a, b) => a - b);
|
|
246
|
+
const snippets = [];
|
|
247
|
+
let prevEnd = -1;
|
|
248
|
+
for (const pos of positions) {
|
|
249
|
+
if (snippets.length >= maxSnippets) break;
|
|
250
|
+
const start = Math.max(0, pos - radius);
|
|
251
|
+
const end = Math.min(content.length, pos + radius);
|
|
252
|
+
if (start <= prevEnd) continue;
|
|
253
|
+
prevEnd = end;
|
|
254
|
+
let snippet = content.slice(start, end).trim().replaceAll(/\s+/g, " ");
|
|
255
|
+
if (start > 0) snippet = "…" + snippet;
|
|
256
|
+
if (end < content.length) snippet = snippet + "…";
|
|
257
|
+
snippets.push(snippet);
|
|
258
|
+
}
|
|
259
|
+
return snippets;
|
|
260
|
+
}
|
|
160
261
|
//#endregion
|
|
161
|
-
//#region src/
|
|
262
|
+
//#region src/nav.ts
|
|
162
263
|
/**
|
|
163
264
|
* Parse a numbered filename/dirname like "1.guide" or "3.middleware.md"
|
|
164
265
|
* into { order, slug }. Also strips `.draft` suffix.
|
|
@@ -277,11 +378,13 @@ async function _scanNav(dirPath, parentPath, options) {
|
|
|
277
378
|
} else if (extname(entry) === ".md") {
|
|
278
379
|
const { order, slug, draft } = parseNumberedName(basename(entry));
|
|
279
380
|
if (draft && !options.drafts) continue;
|
|
280
|
-
const
|
|
381
|
+
const rawMeta = parseMeta(await readFile(fullPath, "utf8"));
|
|
382
|
+
const meta = applyNavigationOverride(rawMeta);
|
|
281
383
|
if (meta.navigation === false) continue;
|
|
282
384
|
const resolvedOrder = typeof meta.order === "number" ? meta.order : order;
|
|
283
385
|
const resolvedSlug = slug === "index" ? "" : slug;
|
|
284
|
-
const
|
|
386
|
+
const firstHeading = rawMeta.headings?.find((h) => h.level === 1)?.text;
|
|
387
|
+
const title = meta.title || firstHeading || humanizeSlug(slug) || "index";
|
|
285
388
|
const entryPath = resolvedSlug === "" ? parentPath === "/" ? "/" : parentPath : parentPath === "/" ? `/${resolvedSlug}` : `${parentPath}/${resolvedSlug}`;
|
|
286
389
|
const extra = extraMeta(meta);
|
|
287
390
|
const navEntry = {
|
|
@@ -289,6 +392,7 @@ async function _scanNav(dirPath, parentPath, options) {
|
|
|
289
392
|
path: entryPath,
|
|
290
393
|
title,
|
|
291
394
|
order: resolvedOrder,
|
|
395
|
+
...firstHeading && firstHeading !== title ? { heading: firstHeading } : {},
|
|
292
396
|
...meta.icon ? { icon: meta.icon } : {},
|
|
293
397
|
...meta.description ? { description: meta.description } : {},
|
|
294
398
|
...draft ? { draft: true } : {},
|
|
@@ -301,9 +405,18 @@ async function _scanNav(dirPath, parentPath, options) {
|
|
|
301
405
|
return entries;
|
|
302
406
|
}
|
|
303
407
|
//#endregion
|
|
304
|
-
//#region src/
|
|
305
|
-
var
|
|
408
|
+
//#region src/sources/_base.ts
|
|
409
|
+
var Source = class {
|
|
410
|
+
/** Start watching for file changes. Override in sources that support it. */
|
|
411
|
+
watch(_callback) {}
|
|
412
|
+
/** Stop watching. Override in sources that support it. */
|
|
413
|
+
unwatch() {}
|
|
414
|
+
};
|
|
415
|
+
//#endregion
|
|
416
|
+
//#region src/sources/fs.ts
|
|
417
|
+
var FSSource = class extends Source {
|
|
306
418
|
dir;
|
|
419
|
+
_watcher;
|
|
307
420
|
constructor(dir) {
|
|
308
421
|
super();
|
|
309
422
|
this.dir = dir;
|
|
@@ -319,6 +432,17 @@ var DocsSourceFS = class extends DocsSource {
|
|
|
319
432
|
async readContent(filePath) {
|
|
320
433
|
return readFile(filePath, "utf8");
|
|
321
434
|
}
|
|
435
|
+
watch(callback) {
|
|
436
|
+
this.unwatch();
|
|
437
|
+
this._watcher = watch(this.dir, { recursive: true }, (_event, filename) => {
|
|
438
|
+
if (!filename || filename.startsWith(".") || filename.startsWith("_")) return;
|
|
439
|
+
callback({ path: "/" + filename });
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
unwatch() {
|
|
443
|
+
this._watcher?.close();
|
|
444
|
+
this._watcher = void 0;
|
|
445
|
+
}
|
|
322
446
|
};
|
|
323
447
|
function parseSlug(name) {
|
|
324
448
|
const base = name.endsWith(".draft") ? name.slice(0, -6) : name;
|
|
@@ -369,8 +493,8 @@ function reorderTree(entries, manifest) {
|
|
|
369
493
|
}
|
|
370
494
|
}
|
|
371
495
|
//#endregion
|
|
372
|
-
//#region src/
|
|
373
|
-
var
|
|
496
|
+
//#region src/sources/git.ts
|
|
497
|
+
var GitSource = class extends Source {
|
|
374
498
|
src;
|
|
375
499
|
options;
|
|
376
500
|
_fs;
|
|
@@ -398,16 +522,16 @@ var DocsSourceGit = class extends DocsSource {
|
|
|
398
522
|
break;
|
|
399
523
|
}
|
|
400
524
|
}
|
|
401
|
-
this._fs = new
|
|
525
|
+
this._fs = new FSSource(docsDir);
|
|
402
526
|
return this._fs.load();
|
|
403
527
|
}
|
|
404
528
|
async readContent(filePath) {
|
|
405
|
-
if (!this._fs) throw new Error("
|
|
529
|
+
if (!this._fs) throw new Error("GitSource: call load() before readContent()");
|
|
406
530
|
return this._fs.readContent(filePath);
|
|
407
531
|
}
|
|
408
532
|
};
|
|
409
533
|
//#endregion
|
|
410
|
-
//#region src/
|
|
534
|
+
//#region src/sources/_npm.ts
|
|
411
535
|
/**
|
|
412
536
|
* Parse an npm package spec: `[@scope/]name[@version][/subdir]`
|
|
413
537
|
*/
|
|
@@ -465,8 +589,8 @@ function parseNpmURL(url) {
|
|
|
465
589
|
if (shortMatch && !/^(package|settings|signup|login|org|search)$/.test(shortMatch[1])) return shortMatch[1];
|
|
466
590
|
}
|
|
467
591
|
//#endregion
|
|
468
|
-
//#region src/
|
|
469
|
-
var
|
|
592
|
+
//#region src/sources/http.ts
|
|
593
|
+
var HTTPSource = class extends Source {
|
|
470
594
|
url;
|
|
471
595
|
options;
|
|
472
596
|
_contentCache = /* @__PURE__ */ new Map();
|
|
@@ -727,8 +851,8 @@ function _resolveHref(href, baseURL) {
|
|
|
727
851
|
}
|
|
728
852
|
}
|
|
729
853
|
//#endregion
|
|
730
|
-
//#region src/
|
|
731
|
-
var
|
|
854
|
+
//#region src/sources/npm.ts
|
|
855
|
+
var NpmSource = class extends Source {
|
|
732
856
|
src;
|
|
733
857
|
options;
|
|
734
858
|
_fs;
|
|
@@ -758,11 +882,11 @@ var DocsSourceNpm = class extends DocsSource {
|
|
|
758
882
|
break;
|
|
759
883
|
}
|
|
760
884
|
}
|
|
761
|
-
this._fs = new
|
|
885
|
+
this._fs = new FSSource(docsDir);
|
|
762
886
|
return this._fs.load();
|
|
763
887
|
}
|
|
764
888
|
async readContent(filePath) {
|
|
765
|
-
if (!this._fs) throw new Error("
|
|
889
|
+
if (!this._fs) throw new Error("NpmSource: call load() before readContent()");
|
|
766
890
|
return this._fs.readContent(filePath);
|
|
767
891
|
}
|
|
768
892
|
};
|
|
@@ -777,31 +901,65 @@ async function npmProvider(input) {
|
|
|
777
901
|
};
|
|
778
902
|
}
|
|
779
903
|
//#endregion
|
|
780
|
-
//#region src/
|
|
904
|
+
//#region src/source.ts
|
|
905
|
+
/**
|
|
906
|
+
* Resolve a source string to the appropriate Source instance.
|
|
907
|
+
*
|
|
908
|
+
* Supports: local paths, `gh:owner/repo`, `npm:package`, `http(s)://...`
|
|
909
|
+
*/
|
|
910
|
+
function resolveSource(input) {
|
|
911
|
+
if (input.startsWith("http://") || input.startsWith("https://")) return new HTTPSource(input);
|
|
912
|
+
if (input.startsWith("gh:")) return new GitSource(input);
|
|
913
|
+
if (input.startsWith("npm:")) return new NpmSource(input);
|
|
914
|
+
return new FSSource(input);
|
|
915
|
+
}
|
|
916
|
+
//#endregion
|
|
917
|
+
//#region src/exporter.ts
|
|
918
|
+
/**
|
|
919
|
+
* High-level export: resolve source, load, and export in one call.
|
|
920
|
+
*
|
|
921
|
+
* ```ts
|
|
922
|
+
* await exportSource("./docs", "./dist/docs");
|
|
923
|
+
* await exportSource("gh:unjs/h3", "./dist/h3-docs");
|
|
924
|
+
* await exportSource("npm:h3", "./dist/h3-docs", { plainText: true });
|
|
925
|
+
* await exportSource("https://h3.unjs.io", "./dist/h3-docs");
|
|
926
|
+
* ```
|
|
927
|
+
*/
|
|
928
|
+
async function exportSource(input, dir, options = {}) {
|
|
929
|
+
const collection = new Collection(typeof input === "string" ? resolveSource(input) : input);
|
|
930
|
+
await collection.load();
|
|
931
|
+
await mkdir(dir, { recursive: true });
|
|
932
|
+
await writeCollection(collection, dir, options);
|
|
933
|
+
return collection;
|
|
934
|
+
}
|
|
781
935
|
/** Paths to skip during export (generated by source, not actual docs) */
|
|
782
936
|
const IGNORED_PATHS = new Set(["/llms.txt", "/llms-full.txt"]);
|
|
783
937
|
/**
|
|
784
938
|
* Export documentation entries to a local filesystem directory as flat `.md` files.
|
|
785
939
|
*
|
|
786
|
-
* Each entry is written to `<dir>/<
|
|
787
|
-
* index pages). Navigation order is preserved via
|
|
788
|
-
*
|
|
940
|
+
* Each entry is written to `<dir>/<prefix>.<slug>.md` (or `<dir>/<prefix>.<slug>/index.md`
|
|
941
|
+
* for directory index pages). Navigation order is preserved via numeric prefixes on
|
|
942
|
+
* directories and files (e.g., `1.guide/`, `2.getting-started.md`) so the nav scanner
|
|
943
|
+
* can infer order without additional metadata files.
|
|
789
944
|
*
|
|
790
945
|
* A `README.md` table of contents is generated at the root of the output directory.
|
|
791
946
|
*/
|
|
792
|
-
async function
|
|
793
|
-
const rootEntry =
|
|
947
|
+
async function writeCollection(collection, dir, options = {}) {
|
|
948
|
+
const rootEntry = collection.flat.find((f) => f.entry.path === "/");
|
|
794
949
|
const tocLines = [`# ${options.title ?? rootEntry?.entry.title ?? "Table of Contents"}`, ""];
|
|
795
950
|
const writtenFiles = /* @__PURE__ */ new Set();
|
|
951
|
+
const pathMap = /* @__PURE__ */ new Map();
|
|
952
|
+
buildNumberedPaths(collection.tree, "", pathMap);
|
|
796
953
|
const dirPaths = /* @__PURE__ */ new Set();
|
|
797
|
-
collectDirPaths(
|
|
798
|
-
for (const flat of
|
|
954
|
+
collectDirPaths(collection.tree, dirPaths);
|
|
955
|
+
for (const flat of collection.flat) {
|
|
799
956
|
if (options.filter ? !options.filter(flat) : flat.entry.page === false) continue;
|
|
800
957
|
if (IGNORED_PATHS.has(flat.entry.path)) continue;
|
|
801
|
-
let content = await
|
|
958
|
+
let content = await collection.getContent(flat);
|
|
802
959
|
if (content === void 0) continue;
|
|
803
960
|
const cleanContent = options.plainText ? renderToText(content) : renderToMarkdown(content);
|
|
804
|
-
const
|
|
961
|
+
const numberedPath = pathMap.get(flat.entry.path) ?? flat.entry.path;
|
|
962
|
+
const filePath = flat.entry.path === "/" || dirPaths.has(flat.entry.path) ? flat.entry.path === "/" ? "/index.md" : `${numberedPath}/index.md` : numberedPath.endsWith(".md") ? numberedPath : `${numberedPath}.md`;
|
|
805
963
|
const dest = join(dir, filePath);
|
|
806
964
|
await mkdir(dirname(dest), { recursive: true });
|
|
807
965
|
await writeFile(dest, cleanContent, "utf8");
|
|
@@ -813,7 +971,6 @@ async function exportDocsToFS(manager, dir, options = {}) {
|
|
|
813
971
|
let tocFile = options.tocFile ?? "README.md";
|
|
814
972
|
if (writtenFiles.has(tocFile)) tocFile = `_${tocFile}`;
|
|
815
973
|
await writeFile(join(dir, tocFile), tocLines.join("\n") + "\n", "utf8");
|
|
816
|
-
await writeFile(join(dir, "_navigation.json"), JSON.stringify(manager.tree, null, 2) + "\n", "utf8");
|
|
817
974
|
}
|
|
818
975
|
/** Collect all paths that are directories (have children in the tree). */
|
|
819
976
|
function collectDirPaths(entries, set) {
|
|
@@ -822,5 +979,16 @@ function collectDirPaths(entries, set) {
|
|
|
822
979
|
collectDirPaths(entry.children, set);
|
|
823
980
|
}
|
|
824
981
|
}
|
|
982
|
+
/**
|
|
983
|
+
* Build a map from nav path → numbered filesystem path.
|
|
984
|
+
* Uses sibling index as the numeric prefix (e.g., `/guide` → `/1.guide`).
|
|
985
|
+
*/
|
|
986
|
+
function buildNumberedPaths(entries, parentPath, map) {
|
|
987
|
+
for (const [i, entry] of entries.entries()) {
|
|
988
|
+
const numbered = `${parentPath}/${i}.${entry.slug || "index"}`;
|
|
989
|
+
map.set(entry.path, numbered);
|
|
990
|
+
if (entry.children?.length) buildNumberedPaths(entry.children, numbered, map);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
825
993
|
//#endregion
|
|
826
|
-
export {
|
|
994
|
+
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 };
|