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 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 <dir> # Browse local docs directory
27
- npx mdzilla <file.md> # Render a single markdown file
28
- npx mdzilla gh:owner/repo # Browse GitHub repo docs
29
- npx mdzilla npm:package-name # Browse npm package docs
30
- npx mdzilla https://example.com # Browse remote docs via HTTP
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
- ### Single Page
61
+ ### Smart Resolve
62
62
 
63
- Print a specific page by path and exit:
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:nuxt/nuxt --page /getting-started/seo-meta
67
- npx mdzilla gh:nuxt/nuxt --plain --page /getting-started/seo-meta
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
- ### Headless Mode
70
+ ### Web Server
71
71
 
72
- 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.
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
- ### Keyboard Controls
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
- </details>
95
+ ### Export Docs
97
96
 
98
- <details>
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
- </details>
99
+ ```js
100
+ import { exportSource } from "mdzilla";
115
101
 
116
- <details>
117
- <summary><strong>Search mode</strong></summary>
102
+ await exportSource("./docs", "./dist/docs", {
103
+ title: "My Docs",
104
+ filter: (e) => !e.entry.path.startsWith("/blog"),
105
+ });
118
106
 
119
- | Key | Action |
120
- | :------ | :--------------- |
121
- | _Type_ | Filter results |
122
- | `↑` `↓` | Navigate results |
123
- | `Enter` | Confirm |
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
- </details>
113
+ ### Collection
127
114
 
128
- ## Programmatic API
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 { DocsManager, DocsSourceFS } from "mdzilla";
118
+ import { Collection, resolveSource } from "mdzilla";
132
119
 
133
- const docs = new DocsManager(new DocsSourceFS("./docs"));
120
+ const docs = new Collection(resolveSource("./docs"));
134
121
  await docs.load();
135
122
 
136
- // Browse the navigation tree
137
- console.log(docs.tree);
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
- // Get page content
140
- const content = await docs.getContent(docs.flat[0]);
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/docs/manager.ts
7
- var DocsManager = class {
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
- /** Return indices of matching flat entries (case-insensitive substring). */
83
- matchIndices(query) {
84
- if (!query) return [];
85
- const lower = query.toLowerCase();
86
- const matched = /* @__PURE__ */ new Set();
87
- for (let i = 0; i < this.flat.length; i++) {
88
- const { entry } = this.flat[i];
89
- if (entry.title.toLowerCase().includes(lower) || entry.path.toLowerCase().includes(lower)) {
90
- matched.add(i);
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
- return [...matched].sort((a, b) => a - b);
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 >= 0 && s < best) best = 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/docs/sources/_base.ts
159
- var DocsSource = class {};
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/docs/nav.ts
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 meta = applyNavigationOverride(parseMeta(await readFile(fullPath, "utf8")));
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 title = meta.title || humanizeSlug(slug) || "index";
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/docs/sources/fs.ts
305
- var DocsSourceFS = class extends DocsSource {
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/docs/sources/git.ts
373
- var DocsSourceGit = class extends DocsSource {
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 DocsSourceFS(docsDir);
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("DocsSourceGit: call load() before readContent()");
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/docs/sources/_npm.ts
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/docs/sources/http.ts
469
- var DocsSourceHTTP = class extends DocsSource {
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/docs/sources/npm.ts
731
- var DocsSourceNpm = class extends DocsSource {
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 DocsSourceFS(docsDir);
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("DocsSourceNpm: call load() before readContent()");
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/docs/exporter.ts
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>/<path>.md` (or `<dir>/<path>/index.md` for directory
787
- * index pages). Navigation order is preserved via `order` frontmatter in pages and
788
- * `.navigation.yml` files in directories.
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 exportDocsToFS(manager, dir, options = {}) {
793
- const rootEntry = manager.flat.find((f) => f.entry.path === "/");
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(manager.tree, dirPaths);
798
- for (const flat of manager.flat) {
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 manager.getContent(flat);
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 filePath = flat.entry.path === "/" || dirPaths.has(flat.entry.path) ? flat.entry.path === "/" ? "/index.md" : `${flat.entry.path}/index.md` : flat.entry.path.endsWith(".md") ? flat.entry.path : `${flat.entry.path}.md`;
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 { DocsSourceFS as a, DocsSourceGit as i, DocsSourceNpm as n, DocsSource as o, DocsSourceHTTP as r, DocsManager as s, exportDocsToFS as t };
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 };