hmr-serve 0.1.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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +75 -0
  3. package/bin/hmr-serve.js +10 -0
  4. package/dist/cli/index.d.ts +11 -0
  5. package/dist/cli/index.js +62 -0
  6. package/dist/cli/index.js.map +1 -0
  7. package/dist/server/createServer.d.ts +15 -0
  8. package/dist/server/createServer.js +41 -0
  9. package/dist/server/createServer.js.map +1 -0
  10. package/dist/server/fs.d.ts +17 -0
  11. package/dist/server/fs.js +102 -0
  12. package/dist/server/fs.js.map +1 -0
  13. package/dist/server/index/searchIndex.d.ts +17 -0
  14. package/dist/server/index/searchIndex.js +98 -0
  15. package/dist/server/index/searchIndex.js.map +1 -0
  16. package/dist/server/index/watcher.d.ts +31 -0
  17. package/dist/server/index/watcher.js +75 -0
  18. package/dist/server/index/watcher.js.map +1 -0
  19. package/dist/server/plugin.d.ts +15 -0
  20. package/dist/server/plugin.js +143 -0
  21. package/dist/server/plugin.js.map +1 -0
  22. package/dist/server/renderers/html.d.ts +6 -0
  23. package/dist/server/renderers/html.js +15 -0
  24. package/dist/server/renderers/html.js.map +1 -0
  25. package/dist/server/renderers/markdown.d.ts +2 -0
  26. package/dist/server/renderers/markdown.js +21 -0
  27. package/dist/server/renderers/markdown.js.map +1 -0
  28. package/dist/server/renderers/registry.d.ts +19 -0
  29. package/dist/server/renderers/registry.js +38 -0
  30. package/dist/server/renderers/registry.js.map +1 -0
  31. package/dist/server/renderers/source.d.ts +4 -0
  32. package/dist/server/renderers/source.js +29 -0
  33. package/dist/server/renderers/source.js.map +1 -0
  34. package/dist/server/renderers/types.d.ts +20 -0
  35. package/dist/server/renderers/types.js +2 -0
  36. package/dist/server/renderers/types.js.map +1 -0
  37. package/dist/shared/types.d.ts +52 -0
  38. package/dist/shared/types.js +8 -0
  39. package/dist/shared/types.js.map +1 -0
  40. package/package.json +71 -0
  41. package/src/client/App.vue +101 -0
  42. package/src/client/api.ts +23 -0
  43. package/src/client/components/ContentPane.vue +93 -0
  44. package/src/client/components/FileTree.vue +58 -0
  45. package/src/client/components/SearchBox.vue +32 -0
  46. package/src/client/components/TreeNode.vue +59 -0
  47. package/src/client/env.d.ts +8 -0
  48. package/src/client/hmr.ts +17 -0
  49. package/src/client/index.html +12 -0
  50. package/src/client/main.ts +5 -0
  51. package/src/client/styles.css +188 -0
  52. package/src/shared/types.ts +63 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Blair Googer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # hmr-serve
2
+
3
+ <!-- Add once CI is green:
4
+ [![CI](https://github.com/<owner>/hmr-serve/actions/workflows/ci.yml/badge.svg)](https://github.com/<owner>/hmr-serve/actions/workflows/ci.yml)
5
+ -->
6
+
7
+ Point `hmr-serve` at any folder and review it live in the browser: a VSCode-style
8
+ file tree on the left, a content pane on the right, and **instant updates as files
9
+ change on disk** — powered by Vite's real dev server and HMR client, not a
10
+ hand-rolled watcher/websocket.
11
+
12
+ Built for reviewing AI-agent output (markdown/HTML docs) as it iterates.
13
+
14
+ ```bash
15
+ npx hmr-serve ./some-folder
16
+ # → opens http://localhost:5183 with a live tree + content pane
17
+ ```
18
+
19
+ ## Quickstart
20
+
21
+ ```bash
22
+ # from a clone
23
+ pnpm install
24
+ pnpm dev ./fixtures # serve the sample folder
25
+ ```
26
+
27
+ CLI:
28
+
29
+ ```
30
+ hmr-serve [dir] Folder to serve (default: current directory)
31
+ --port <port> Port to listen on (default: 5183)
32
+ --open Open the browser on start
33
+ ```
34
+
35
+ Supported file types at launch: `.md` (rendered), `.html` (sandboxed iframe),
36
+ `.css` / `.txt` (read-only syntax-highlighted source).
37
+
38
+ ## Architecture
39
+
40
+ 1. **No build step for your content.** Runs in Vite dev-server mode only. The app
41
+ shell (a tiny Vue UI) is Vite's root; your folder is mounted alongside it via
42
+ middleware + `server.fs.allow`. Your files _are_ the content — nothing is compiled.
43
+ 2. **Live search index.** Built at startup with [MiniSearch](https://github.com/lucaong/minisearch)
44
+ (no native deps) and kept current incrementally off file-watch events — no rebuild step.
45
+ 3. **Plain HTTP + WebSocket, cross-browser.** The browser only talks to the Node
46
+ server over HTTP and Vite's HMR socket. No File System Access API, no
47
+ Chromium-only APIs — works in Safari and Firefox.
48
+ 4. **Extensible renderer registry.** `.md` → `markdown-it`; `.html` → sandboxed
49
+ iframe; `.css`/`.txt` → server-side `highlight.js`. New types register in
50
+ `src/server/renderers/` without touching routing.
51
+ 5. **Vue 3, Options API only.** Deliberately no Composition API / `<script setup>`,
52
+ and no component library — the client stays minimal.
53
+ 6. **Lightweight CLI** via [`cac`](https://github.com/cacjs/cac): `<path>`, `--port`, `--open`.
54
+
55
+ Live content updates ride Vite's HMR channel: the server pushes a custom
56
+ `hmr-serve:update` event and the content pane re-fetches — so Vite's client
57
+ handles transport and reconnection for us.
58
+
59
+ ```
60
+ src/
61
+ cli/ arg parsing + entry
62
+ server/ Vite plugin, middleware, search index, watcher, renderers
63
+ client/ Vue app shell (tree + content pane)
64
+ shared/ types shared across server and client
65
+ fixtures/ versioned sample files used by tests
66
+ tests/ unit · component · e2e
67
+ ```
68
+
69
+ ## Contributing
70
+
71
+ See [CONTRIBUTING.md](./CONTRIBUTING.md) for local setup and test expectations.
72
+
73
+ ## License
74
+
75
+ [MIT](./LICENSE)
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ // Published entry point. Invokes the compiled CLI directly rather than relying
3
+ // on import-time self-execution detection, which is unreliable when launched
4
+ // through npm's .bin symlink (argv[1] is the symlink, not this file).
5
+ import { run } from '../dist/cli/index.js';
6
+
7
+ run(process.argv.slice(2)).catch((error) => {
8
+ console.error(`\n ${error instanceof Error ? error.message : String(error)}\n`);
9
+ process.exitCode = 1;
10
+ });
@@ -0,0 +1,11 @@
1
+ export interface ParsedArgs {
2
+ targetDir: string;
3
+ port?: number;
4
+ open: boolean;
5
+ }
6
+ /** Pure arg parsing, split out so it can be unit-tested without a live server.
7
+ * Throws `Error` with a user-facing message on invalid input. */
8
+ export declare function parseArgs(argv: string[]): ParsedArgs;
9
+ /** Validate the target is an existing directory. Throws on failure. */
10
+ export declare function assertDirectory(targetDir: string): void;
11
+ export declare function run(argv: string[]): Promise<void>;
@@ -0,0 +1,62 @@
1
+ import { statSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { cac } from 'cac';
4
+ import { createServer } from '../server/createServer.js';
5
+ /** Pure arg parsing, split out so it can be unit-tested without a live server.
6
+ * Throws `Error` with a user-facing message on invalid input. */
7
+ export function parseArgs(argv) {
8
+ const cli = cac('hmr-serve');
9
+ cli
10
+ .command('[dir]', 'Serve a folder with a live file tree + content pane')
11
+ .option('--port <port>', 'Port to listen on', { default: 5183 })
12
+ .option('--open', 'Open the browser on start', { default: false });
13
+ cli.help();
14
+ const parsed = cli.parse(['node', 'hmr-serve', ...argv], { run: false });
15
+ const dir = parsed.args[0] ?? '.';
16
+ const port = Number(parsed.options.port);
17
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
18
+ throw new Error(`Invalid --port: ${parsed.options.port}`);
19
+ }
20
+ return {
21
+ targetDir: resolve(process.cwd(), dir),
22
+ port,
23
+ open: Boolean(parsed.options.open),
24
+ };
25
+ }
26
+ /** Validate the target is an existing directory. Throws on failure. */
27
+ export function assertDirectory(targetDir) {
28
+ let stat;
29
+ try {
30
+ stat = statSync(targetDir);
31
+ }
32
+ catch {
33
+ throw new Error(`Folder does not exist: ${targetDir}`);
34
+ }
35
+ if (!stat.isDirectory()) {
36
+ throw new Error(`Not a directory: ${targetDir}`);
37
+ }
38
+ }
39
+ export async function run(argv) {
40
+ const args = parseArgs(argv);
41
+ assertDirectory(args.targetDir);
42
+ const server = await createServer({
43
+ targetDir: args.targetDir,
44
+ port: args.port,
45
+ open: args.open,
46
+ });
47
+ await server.listen();
48
+ const info = server.resolvedUrls?.local[0] ?? `http://localhost:${args.port}/`;
49
+ console.log(`\n hmr-serve → serving ${args.targetDir}\n ${info}\n`);
50
+ }
51
+ // Self-execute only when this module is the directly-run script (e.g.
52
+ // `tsx src/cli/index.ts` in dev/e2e, or `node dist/cli/index.js`). The published
53
+ // `bin` shim imports and calls `run()` itself, so it does not match here — which
54
+ // keeps the tool from starting twice.
55
+ const invokedPath = process.argv[1] ? resolve(process.argv[1]) : '';
56
+ if (import.meta.url === `file://${invokedPath}`) {
57
+ run(process.argv.slice(2)).catch((error) => {
58
+ console.error(`\n ${error instanceof Error ? error.message : String(error)}\n`);
59
+ process.exitCode = 1;
60
+ });
61
+ }
62
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,15 @@
1
+ import { type ViteDevServer } from 'vite';
2
+ export interface CreateServerOptions {
3
+ /** Folder to serve. Resolved to an absolute path if relative. */
4
+ targetDir: string;
5
+ port?: number;
6
+ /** Open the browser once listening. */
7
+ open?: boolean;
8
+ host?: string;
9
+ }
10
+ /**
11
+ * Build (but do not start) a Vite dev server whose root is our app shell, with
12
+ * the target folder mounted via {@link hmrServePlugin}. Call `.listen()` on the
13
+ * returned server to start it.
14
+ */
15
+ export declare function createServer(options: CreateServerOptions): Promise<ViteDevServer>;
@@ -0,0 +1,41 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { existsSync } from 'node:fs';
4
+ import { createServer as createViteServer } from 'vite';
5
+ import vue from '@vitejs/plugin-vue';
6
+ import { hmrServePlugin } from './plugin.js';
7
+ /** Absolute path to the shipped Vue app shell (`src/client`), resolved relative
8
+ * to this module so it works both from `src` (tsx/dev) and `dist` (published). */
9
+ function resolveClientRoot() {
10
+ const here = dirname(fileURLToPath(import.meta.url));
11
+ // src/server/createServer.ts → ../client ; dist/server/createServer.js → ../../src/client
12
+ const fromSrc = resolve(here, '../client');
13
+ const fromDist = resolve(here, '../../src/client');
14
+ return existsSync(fromSrc) ? fromSrc : fromDist;
15
+ }
16
+ /**
17
+ * Build (but do not start) a Vite dev server whose root is our app shell, with
18
+ * the target folder mounted via {@link hmrServePlugin}. Call `.listen()` on the
19
+ * returned server to start it.
20
+ */
21
+ export async function createServer(options) {
22
+ const targetDir = resolve(process.cwd(), options.targetDir);
23
+ const clientRoot = resolveClientRoot();
24
+ // The package root (…/src/client → package) holds the shell, shared types and
25
+ // node_modules; the target folder lives anywhere. Both must be fs-allowed.
26
+ const packageRoot = resolve(clientRoot, '../..');
27
+ return createViteServer({
28
+ root: clientRoot,
29
+ configFile: false,
30
+ plugins: [vue(), hmrServePlugin({ targetDir })],
31
+ server: {
32
+ port: options.port ?? 5183,
33
+ host: options.host,
34
+ open: options.open ?? false,
35
+ fs: { allow: [packageRoot, targetDir] },
36
+ },
37
+ // The app shell is tiny and self-contained; no user config should leak in.
38
+ envFile: false,
39
+ });
40
+ }
41
+ //# sourceMappingURL=createServer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"createServer.js","sourceRoot":"","sources":["../../src/server/createServer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,YAAY,IAAI,gBAAgB,EAAsB,MAAM,MAAM,CAAC;AAC5E,OAAO,GAAG,MAAM,oBAAoB,CAAC;AACrC,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAW7C;mFACmF;AACnF,SAAS,iBAAiB;IACxB,MAAM,IAAI,GAAG,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;IACrD,0FAA0F;IAC1F,MAAM,OAAO,GAAG,OAAO,CAAC,IAAI,EAAE,WAAW,CAAC,CAAC;IAC3C,MAAM,QAAQ,GAAG,OAAO,CAAC,IAAI,EAAE,kBAAkB,CAAC,CAAC;IACnD,OAAO,UAAU,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,QAAQ,CAAC;AAClD,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,OAA4B;IAC7D,MAAM,SAAS,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,OAAO,CAAC,SAAS,CAAC,CAAC;IAC5D,MAAM,UAAU,GAAG,iBAAiB,EAAE,CAAC;IACvC,8EAA8E;IAC9E,2EAA2E;IAC3E,MAAM,WAAW,GAAG,OAAO,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;IAEjD,OAAO,gBAAgB,CAAC;QACtB,IAAI,EAAE,UAAU;QAChB,UAAU,EAAE,KAAK;QACjB,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE,cAAc,CAAC,EAAE,SAAS,EAAE,CAAC,CAAC;QAC/C,MAAM,EAAE;YACN,IAAI,EAAE,OAAO,CAAC,IAAI,IAAI,IAAI;YAC1B,IAAI,EAAE,OAAO,CAAC,IAAI;YAClB,IAAI,EAAE,OAAO,CAAC,IAAI,IAAI,KAAK;YAC3B,EAAE,EAAE,EAAE,KAAK,EAAE,CAAC,WAAW,EAAE,SAAS,CAAC,EAAE;SACxC;QACD,2EAA2E;QAC3E,OAAO,EAAE,KAAK;KACf,CAAC,CAAC;AACL,CAAC"}
@@ -0,0 +1,17 @@
1
+ import type { FileNode } from '../shared/types.js';
2
+ import type { RendererRegistry } from './renderers/registry.js';
3
+ /** Lower-case extension without the dot; '' when there is none. */
4
+ export declare function extOf(name: string): string;
5
+ /** Absolute path → root-relative, POSIX-separated path used everywhere in the API. */
6
+ export declare function toRelPosix(root: string, abs: string): string;
7
+ export declare function readFileText(abs: string): Promise<string>;
8
+ /**
9
+ * Recursively build the file tree, keeping only directories and files whose
10
+ * extension the registry supports. Directories that end up empty are pruned so
11
+ * the nav doesn't show dead branches. Entries are sorted dirs-first, then by name.
12
+ */
13
+ export declare function buildTree(root: string, registry: RendererRegistry): Promise<FileNode[]>;
14
+ /** Collect every supported file (root-relative paths) for the initial index scan. */
15
+ export declare function listSupportedFiles(root: string, registry: RendererRegistry): Promise<string[]>;
16
+ /** Whether `abs` resolves to a real file (used to guard endpoint reads). */
17
+ export declare function isFile(abs: string): Promise<boolean>;
@@ -0,0 +1,102 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { join, relative, sep } from 'node:path';
3
+ /** Lower-case extension without the dot; '' when there is none. */
4
+ export function extOf(name) {
5
+ const dot = name.lastIndexOf('.');
6
+ if (dot <= 0)
7
+ return '';
8
+ return name.slice(dot + 1).toLowerCase();
9
+ }
10
+ /** Absolute path → root-relative, POSIX-separated path used everywhere in the API. */
11
+ export function toRelPosix(root, abs) {
12
+ return relative(root, abs).split(sep).join('/');
13
+ }
14
+ /** Directories skipped during the tree walk and initial index scan. */
15
+ const IGNORED_DIRS = new Set(['.git', 'node_modules', '.vite', 'dist', '.DS_Store']);
16
+ export async function readFileText(abs) {
17
+ return readFile(abs, 'utf8');
18
+ }
19
+ /**
20
+ * Recursively build the file tree, keeping only directories and files whose
21
+ * extension the registry supports. Directories that end up empty are pruned so
22
+ * the nav doesn't show dead branches. Entries are sorted dirs-first, then by name.
23
+ */
24
+ export async function buildTree(root, registry) {
25
+ async function walk(dir) {
26
+ let entries;
27
+ try {
28
+ entries = await readdir(dir, { withFileTypes: true });
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ const nodes = [];
34
+ for (const entry of entries) {
35
+ if (entry.name.startsWith('.') || IGNORED_DIRS.has(entry.name))
36
+ continue;
37
+ const abs = join(dir, entry.name);
38
+ if (entry.isDirectory()) {
39
+ const children = await walk(abs);
40
+ if (children.length > 0) {
41
+ nodes.push({
42
+ path: toRelPosix(root, abs),
43
+ name: entry.name,
44
+ type: 'directory',
45
+ children,
46
+ });
47
+ }
48
+ }
49
+ else if (entry.isFile()) {
50
+ const ext = extOf(entry.name);
51
+ if (registry.supports(ext)) {
52
+ nodes.push({ path: toRelPosix(root, abs), name: entry.name, type: 'file', ext });
53
+ }
54
+ }
55
+ }
56
+ return sortNodes(nodes);
57
+ }
58
+ return walk(root);
59
+ }
60
+ function sortNodes(nodes) {
61
+ return nodes.sort((a, b) => {
62
+ if (a.type !== b.type)
63
+ return a.type === 'directory' ? -1 : 1;
64
+ return a.name.localeCompare(b.name);
65
+ });
66
+ }
67
+ /** Collect every supported file (root-relative paths) for the initial index scan. */
68
+ export async function listSupportedFiles(root, registry) {
69
+ const out = [];
70
+ async function walk(dir) {
71
+ let entries;
72
+ try {
73
+ entries = await readdir(dir, { withFileTypes: true });
74
+ }
75
+ catch {
76
+ return;
77
+ }
78
+ for (const entry of entries) {
79
+ if (entry.name.startsWith('.') || IGNORED_DIRS.has(entry.name))
80
+ continue;
81
+ const abs = join(dir, entry.name);
82
+ if (entry.isDirectory()) {
83
+ await walk(abs);
84
+ }
85
+ else if (entry.isFile() && registry.supports(extOf(entry.name))) {
86
+ out.push(toRelPosix(root, abs));
87
+ }
88
+ }
89
+ }
90
+ await walk(root);
91
+ return out;
92
+ }
93
+ /** Whether `abs` resolves to a real file (used to guard endpoint reads). */
94
+ export async function isFile(abs) {
95
+ try {
96
+ return (await stat(abs)).isFile();
97
+ }
98
+ catch {
99
+ return false;
100
+ }
101
+ }
102
+ //# sourceMappingURL=fs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fs.js","sourceRoot":"","sources":["../../src/server/fs.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,17 @@
1
+ import type { SearchHit } from '../../shared/types.js';
2
+ /**
3
+ * Thin MiniSearch wrapper the watcher drives incrementally: `add` on discovery,
4
+ * `update` on change (replace-in-place), `remove` on unlink. No rebuild step.
5
+ */
6
+ export declare class SearchIndex {
7
+ private readonly engine;
8
+ /** Retains raw content per id so we can build snippets at query time. */
9
+ private readonly content;
10
+ constructor();
11
+ private toDoc;
12
+ add(path: string, ext: string, raw: string): void;
13
+ update(path: string, ext: string, raw: string): void;
14
+ remove(path: string): void;
15
+ get size(): number;
16
+ search(query: string, limit?: number): SearchHit[];
17
+ }
@@ -0,0 +1,98 @@
1
+ import MiniSearch from 'minisearch';
2
+ /** Strip HTML tags so markup files index on their visible text, not attributes. */
3
+ function extractText(ext, raw) {
4
+ if (ext === 'html' || ext === 'htm') {
5
+ return raw
6
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
7
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
8
+ .replace(/<[^>]+>/g, ' ')
9
+ .replace(/\s+/g, ' ')
10
+ .trim();
11
+ }
12
+ return raw;
13
+ }
14
+ /** First markdown/heading line or the path, used as the hit title. */
15
+ function deriveTitle(path, raw) {
16
+ const heading = raw.match(/^#{1,6}\s+(.+?)\s*#*\s*$/m);
17
+ if (heading?.[1])
18
+ return heading[1].trim();
19
+ return path;
20
+ }
21
+ function makeSnippet(content, query) {
22
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
23
+ const lower = content.toLowerCase();
24
+ let at = -1;
25
+ for (const term of terms) {
26
+ const found = lower.indexOf(term);
27
+ if (found !== -1 && (at === -1 || found < at))
28
+ at = found;
29
+ }
30
+ if (at === -1)
31
+ return content.slice(0, 120).trim();
32
+ const start = Math.max(0, at - 40);
33
+ const end = Math.min(content.length, at + 80);
34
+ return `${start > 0 ? '…' : ''}${content.slice(start, end).trim()}${end < content.length ? '…' : ''}`;
35
+ }
36
+ /**
37
+ * Thin MiniSearch wrapper the watcher drives incrementally: `add` on discovery,
38
+ * `update` on change (replace-in-place), `remove` on unlink. No rebuild step.
39
+ */
40
+ export class SearchIndex {
41
+ engine;
42
+ /** Retains raw content per id so we can build snippets at query time. */
43
+ content = new Map();
44
+ constructor() {
45
+ this.engine = new MiniSearch({
46
+ fields: ['title', 'content'],
47
+ storeFields: ['title'],
48
+ searchOptions: { boost: { title: 2 }, prefix: true, fuzzy: 0.2 },
49
+ });
50
+ }
51
+ toDoc(path, ext, raw) {
52
+ return { id: path, title: deriveTitle(path, raw), content: extractText(ext, raw) };
53
+ }
54
+ add(path, ext, raw) {
55
+ if (this.content.has(path)) {
56
+ this.update(path, ext, raw);
57
+ return;
58
+ }
59
+ const doc = this.toDoc(path, ext, raw);
60
+ this.engine.add(doc);
61
+ this.content.set(path, doc.content);
62
+ }
63
+ update(path, ext, raw) {
64
+ if (!this.content.has(path)) {
65
+ this.add(path, ext, raw);
66
+ return;
67
+ }
68
+ const doc = this.toDoc(path, ext, raw);
69
+ // MiniSearch requires the *old* doc to discard; `replace` handles that when
70
+ // the id matches, but we guard with a manual remove for older versions.
71
+ this.engine.replace(doc);
72
+ this.content.set(path, doc.content);
73
+ }
74
+ remove(path) {
75
+ if (!this.content.has(path))
76
+ return;
77
+ this.engine.discard(path);
78
+ this.content.delete(path);
79
+ }
80
+ get size() {
81
+ return this.content.size;
82
+ }
83
+ search(query, limit = 20) {
84
+ const trimmed = query.trim();
85
+ if (!trimmed)
86
+ return [];
87
+ return this.engine
88
+ .search(trimmed)
89
+ .slice(0, limit)
90
+ .map((result) => ({
91
+ path: result.id,
92
+ title: result.title,
93
+ snippet: makeSnippet(this.content.get(result.id) ?? '', trimmed),
94
+ score: result.score,
95
+ }));
96
+ }
97
+ }
98
+ //# sourceMappingURL=searchIndex.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"searchIndex.js","sourceRoot":"","sources":["../../../src/server/index/searchIndex.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,31 @@
1
+ import type { SearchIndex } from './searchIndex.js';
2
+ import type { HmrServeUpdate } from '../../shared/types.js';
3
+ export interface WatchHandlerOptions {
4
+ index: SearchIndex;
5
+ /** Lower-case extension without the dot, given a root-relative path. */
6
+ extOf: (path: string) => string;
7
+ /** True if some renderer supports the extension. */
8
+ isSupported: (ext: string) => boolean;
9
+ /** Read a file's text given its root-relative path. */
10
+ readFile: (path: string) => Promise<string>;
11
+ /** Push an HMR update to connected clients. */
12
+ emit: (update: HmrServeUpdate) => void;
13
+ /** Coalesce rapid successive changes to the same file. Default 40ms. */
14
+ debounceMs?: number;
15
+ }
16
+ export interface WatchHandlers {
17
+ onAdd(path: string): void;
18
+ onChange(path: string): void;
19
+ onUnlink(path: string): void;
20
+ /** Force-run any pending debounced work immediately (used by tests). */
21
+ flush(): Promise<void>;
22
+ /** Cancel pending timers (used on server close). */
23
+ dispose(): void;
24
+ }
25
+ /**
26
+ * Pure watch-event logic, decoupled from Vite/chokidar so it can be unit-tested
27
+ * with injected `readFile`/`emit`. `add`/`change` are debounced per path (an
28
+ * editor save often fires several writes); `unlink` is applied immediately so a
29
+ * deleted file can't linger in the index behind a pending change.
30
+ */
31
+ export declare function createWatchHandler(options: WatchHandlerOptions): WatchHandlers;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Pure watch-event logic, decoupled from Vite/chokidar so it can be unit-tested
3
+ * with injected `readFile`/`emit`. `add`/`change` are debounced per path (an
4
+ * editor save often fires several writes); `unlink` is applied immediately so a
5
+ * deleted file can't linger in the index behind a pending change.
6
+ */
7
+ export function createWatchHandler(options) {
8
+ const { index, extOf, isSupported, readFile, emit, debounceMs = 40 } = options;
9
+ const timers = new Map();
10
+ const pending = new Map();
11
+ async function apply(path) {
12
+ const kind = pending.get(path);
13
+ pending.delete(path);
14
+ timers.delete(path);
15
+ if (!kind)
16
+ return;
17
+ const ext = extOf(path);
18
+ if (!isSupported(ext))
19
+ return;
20
+ let raw;
21
+ try {
22
+ raw = await readFile(path);
23
+ }
24
+ catch {
25
+ // File vanished between event and read — treat as no-op; an unlink will follow.
26
+ return;
27
+ }
28
+ if (kind === 'add')
29
+ index.add(path, ext, raw);
30
+ else
31
+ index.update(path, ext, raw);
32
+ emit({ event: kind, path });
33
+ }
34
+ function schedule(path, kind) {
35
+ if (!isSupported(extOf(path)))
36
+ return;
37
+ // An 'add' already queued should win over a later 'change' for the same tick.
38
+ if (!(pending.get(path) === 'add' && kind === 'change'))
39
+ pending.set(path, kind);
40
+ const existing = timers.get(path);
41
+ if (existing)
42
+ clearTimeout(existing);
43
+ timers.set(path, setTimeout(() => void apply(path), debounceMs));
44
+ }
45
+ return {
46
+ onAdd(path) {
47
+ schedule(path, 'add');
48
+ },
49
+ onChange(path) {
50
+ schedule(path, 'change');
51
+ },
52
+ onUnlink(path) {
53
+ const timer = timers.get(path);
54
+ if (timer)
55
+ clearTimeout(timer);
56
+ timers.delete(path);
57
+ pending.delete(path);
58
+ if (!isSupported(extOf(path)))
59
+ return;
60
+ index.remove(path);
61
+ emit({ event: 'unlink', path });
62
+ },
63
+ async flush() {
64
+ const paths = [...pending.keys()];
65
+ await Promise.all(paths.map((p) => apply(p)));
66
+ },
67
+ dispose() {
68
+ for (const timer of timers.values())
69
+ clearTimeout(timer);
70
+ timers.clear();
71
+ pending.clear();
72
+ },
73
+ };
74
+ }
75
+ //# sourceMappingURL=watcher.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"watcher.js","sourceRoot":"","sources":["../../../src/server/index/watcher.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,15 @@
1
+ import type { Plugin } from 'vite';
2
+ import { RendererRegistry } from './renderers/registry.js';
3
+ /** Base path for all server API routes. */
4
+ export declare const API_BASE = "/__hmrserve";
5
+ export interface HmrServePluginOptions {
6
+ /** Absolute path to the folder being served. */
7
+ targetDir: string;
8
+ registry?: RendererRegistry;
9
+ }
10
+ /**
11
+ * The hmr-serve Vite plugin. Owns the search index + file watcher and exposes
12
+ * the tree/file/raw/search endpoints. Content live-updates are pushed over
13
+ * Vite's own HMR websocket (a custom event) rather than a hand-rolled channel.
14
+ */
15
+ export declare function hmrServePlugin(options: HmrServePluginOptions): Plugin;