mdzilla 0.2.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
@@ -67,6 +67,19 @@ npx mdzilla gh:unjs/h3 /guide/basics # Render a specific page
67
67
  npx mdzilla gh:unjs/h3 router # Search for 'router'
68
68
  ```
69
69
 
70
+ ### Web Server
71
+
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
+
70
83
  ### Plain Mode
71
84
 
72
85
  Use `--plain` for plain text output. Auto-enabled when piping output or when called by AI agents.
@@ -123,6 +136,13 @@ const results = docs.filter("instal"); // sorted by match score
123
136
 
124
137
  // Substring match (returns indices into docs.flat)
125
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()
126
146
  ```
127
147
 
128
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`).
@@ -1,7 +1,7 @@
1
1
  import { parseMeta, renderToMarkdown, renderToText } from "md4x";
2
2
  import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
3
+ import { existsSync, watch } from "node:fs";
3
4
  import { basename, dirname, extname, join } from "node:path";
4
- import { existsSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  //#region src/collection.ts
7
7
  var Collection = class {
@@ -10,6 +10,8 @@ var Collection = class {
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 Collection = 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;
@@ -354,11 +378,13 @@ async function _scanNav(dirPath, parentPath, options) {
354
378
  } else if (extname(entry) === ".md") {
355
379
  const { order, slug, draft } = parseNumberedName(basename(entry));
356
380
  if (draft && !options.drafts) continue;
357
- const meta = applyNavigationOverride(parseMeta(await readFile(fullPath, "utf8")));
381
+ const rawMeta = parseMeta(await readFile(fullPath, "utf8"));
382
+ const meta = applyNavigationOverride(rawMeta);
358
383
  if (meta.navigation === false) continue;
359
384
  const resolvedOrder = typeof meta.order === "number" ? meta.order : order;
360
385
  const resolvedSlug = slug === "index" ? "" : slug;
361
- 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";
362
388
  const entryPath = resolvedSlug === "" ? parentPath === "/" ? "/" : parentPath : parentPath === "/" ? `/${resolvedSlug}` : `${parentPath}/${resolvedSlug}`;
363
389
  const extra = extraMeta(meta);
364
390
  const navEntry = {
@@ -366,6 +392,7 @@ async function _scanNav(dirPath, parentPath, options) {
366
392
  path: entryPath,
367
393
  title,
368
394
  order: resolvedOrder,
395
+ ...firstHeading && firstHeading !== title ? { heading: firstHeading } : {},
369
396
  ...meta.icon ? { icon: meta.icon } : {},
370
397
  ...meta.description ? { description: meta.description } : {},
371
398
  ...draft ? { draft: true } : {},
@@ -379,11 +406,17 @@ async function _scanNav(dirPath, parentPath, options) {
379
406
  }
380
407
  //#endregion
381
408
  //#region src/sources/_base.ts
382
- var Source = class {};
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
+ };
383
415
  //#endregion
384
416
  //#region src/sources/fs.ts
385
417
  var FSSource = class extends Source {
386
418
  dir;
419
+ _watcher;
387
420
  constructor(dir) {
388
421
  super();
389
422
  this.dir = dir;
@@ -399,6 +432,17 @@ var FSSource = class extends Source {
399
432
  async readContent(filePath) {
400
433
  return readFile(filePath, "utf8");
401
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
+ }
402
446
  };
403
447
  function parseSlug(name) {
404
448
  const base = name.endsWith(".draft") ? name.slice(0, -6) : name;