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.
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/bin/hmr-serve.js +10 -0
- package/dist/cli/index.d.ts +11 -0
- package/dist/cli/index.js +62 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/server/createServer.d.ts +15 -0
- package/dist/server/createServer.js +41 -0
- package/dist/server/createServer.js.map +1 -0
- package/dist/server/fs.d.ts +17 -0
- package/dist/server/fs.js +102 -0
- package/dist/server/fs.js.map +1 -0
- package/dist/server/index/searchIndex.d.ts +17 -0
- package/dist/server/index/searchIndex.js +98 -0
- package/dist/server/index/searchIndex.js.map +1 -0
- package/dist/server/index/watcher.d.ts +31 -0
- package/dist/server/index/watcher.js +75 -0
- package/dist/server/index/watcher.js.map +1 -0
- package/dist/server/plugin.d.ts +15 -0
- package/dist/server/plugin.js +143 -0
- package/dist/server/plugin.js.map +1 -0
- package/dist/server/renderers/html.d.ts +6 -0
- package/dist/server/renderers/html.js +15 -0
- package/dist/server/renderers/html.js.map +1 -0
- package/dist/server/renderers/markdown.d.ts +2 -0
- package/dist/server/renderers/markdown.js +21 -0
- package/dist/server/renderers/markdown.js.map +1 -0
- package/dist/server/renderers/registry.d.ts +19 -0
- package/dist/server/renderers/registry.js +38 -0
- package/dist/server/renderers/registry.js.map +1 -0
- package/dist/server/renderers/source.d.ts +4 -0
- package/dist/server/renderers/source.js +29 -0
- package/dist/server/renderers/source.js.map +1 -0
- package/dist/server/renderers/types.d.ts +20 -0
- package/dist/server/renderers/types.js +2 -0
- package/dist/server/renderers/types.js.map +1 -0
- package/dist/shared/types.d.ts +52 -0
- package/dist/shared/types.js +8 -0
- package/dist/shared/types.js.map +1 -0
- package/package.json +71 -0
- package/src/client/App.vue +101 -0
- package/src/client/api.ts +23 -0
- package/src/client/components/ContentPane.vue +93 -0
- package/src/client/components/FileTree.vue +58 -0
- package/src/client/components/SearchBox.vue +32 -0
- package/src/client/components/TreeNode.vue +59 -0
- package/src/client/env.d.ts +8 -0
- package/src/client/hmr.ts +17 -0
- package/src/client/index.html +12 -0
- package/src/client/main.ts +5 -0
- package/src/client/styles.css +188 -0
- package/src/shared/types.ts +63 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { resolve, sep } from 'node:path';
|
|
2
|
+
import { RendererRegistry } from './renderers/registry.js';
|
|
3
|
+
import { SearchIndex } from './index/searchIndex.js';
|
|
4
|
+
import { createWatchHandler } from './index/watcher.js';
|
|
5
|
+
import { buildTree, extOf, isFile, listSupportedFiles, readFileText, toRelPosix } from './fs.js';
|
|
6
|
+
import { HMR_UPDATE_EVENT, } from '../shared/types.js';
|
|
7
|
+
/** Base path for all server API routes. */
|
|
8
|
+
export const API_BASE = '/__hmrserve';
|
|
9
|
+
function sendJson(res, status, body) {
|
|
10
|
+
const payload = JSON.stringify(body);
|
|
11
|
+
res.statusCode = status;
|
|
12
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
13
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
14
|
+
res.end(payload);
|
|
15
|
+
}
|
|
16
|
+
/** Resolve a client-supplied relative path against the served root, rejecting
|
|
17
|
+
* anything that escapes it (traversal / absolute paths). Returns null if unsafe. */
|
|
18
|
+
function safeResolve(targetDir, rel) {
|
|
19
|
+
if (!rel)
|
|
20
|
+
return null;
|
|
21
|
+
const abs = resolve(targetDir, rel);
|
|
22
|
+
if (abs !== targetDir && !abs.startsWith(targetDir + sep))
|
|
23
|
+
return null;
|
|
24
|
+
return abs;
|
|
25
|
+
}
|
|
26
|
+
function queryParam(req, key) {
|
|
27
|
+
const url = new URL(req.url ?? '', 'http://localhost');
|
|
28
|
+
return url.searchParams.get(key);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The hmr-serve Vite plugin. Owns the search index + file watcher and exposes
|
|
32
|
+
* the tree/file/raw/search endpoints. Content live-updates are pushed over
|
|
33
|
+
* Vite's own HMR websocket (a custom event) rather than a hand-rolled channel.
|
|
34
|
+
*/
|
|
35
|
+
export function hmrServePlugin(options) {
|
|
36
|
+
const targetDir = options.targetDir;
|
|
37
|
+
const registry = options.registry ?? new RendererRegistry();
|
|
38
|
+
const index = new SearchIndex();
|
|
39
|
+
return {
|
|
40
|
+
name: 'hmr-serve',
|
|
41
|
+
async configureServer(server) {
|
|
42
|
+
// 1. Initial index scan.
|
|
43
|
+
const files = await listSupportedFiles(targetDir, registry);
|
|
44
|
+
await Promise.all(files.map(async (rel) => {
|
|
45
|
+
const raw = await readFileText(resolve(targetDir, rel));
|
|
46
|
+
index.add(rel, extOf(rel), raw);
|
|
47
|
+
}));
|
|
48
|
+
// 2. Reuse Vite's chokidar watcher (single source of truth) for the target.
|
|
49
|
+
server.watcher.add(targetDir);
|
|
50
|
+
const emit = (update) => {
|
|
51
|
+
server.ws.send({ type: 'custom', event: HMR_UPDATE_EVENT, data: update });
|
|
52
|
+
};
|
|
53
|
+
const handlers = createWatchHandler({
|
|
54
|
+
index,
|
|
55
|
+
extOf,
|
|
56
|
+
isSupported: (ext) => registry.supports(ext),
|
|
57
|
+
readFile: (rel) => readFileText(resolve(targetDir, rel)),
|
|
58
|
+
emit,
|
|
59
|
+
});
|
|
60
|
+
const within = (abs) => abs === targetDir || abs.startsWith(targetDir + sep) ? toRelPosix(targetDir, abs) : null;
|
|
61
|
+
server.watcher.on('add', (abs) => {
|
|
62
|
+
const rel = within(abs);
|
|
63
|
+
if (rel)
|
|
64
|
+
handlers.onAdd(rel);
|
|
65
|
+
});
|
|
66
|
+
server.watcher.on('change', (abs) => {
|
|
67
|
+
const rel = within(abs);
|
|
68
|
+
if (rel)
|
|
69
|
+
handlers.onChange(rel);
|
|
70
|
+
});
|
|
71
|
+
server.watcher.on('unlink', (abs) => {
|
|
72
|
+
const rel = within(abs);
|
|
73
|
+
if (rel)
|
|
74
|
+
handlers.onUnlink(rel);
|
|
75
|
+
});
|
|
76
|
+
server.httpServer?.once('close', () => handlers.dispose());
|
|
77
|
+
// 3. API routes.
|
|
78
|
+
server.middlewares.use(`${API_BASE}/tree`, (_req, res, next) => {
|
|
79
|
+
buildTree(targetDir, registry)
|
|
80
|
+
.then((nodes) => {
|
|
81
|
+
const body = { root: targetDir, nodes };
|
|
82
|
+
sendJson(res, 200, body);
|
|
83
|
+
})
|
|
84
|
+
.catch(next);
|
|
85
|
+
});
|
|
86
|
+
server.middlewares.use(`${API_BASE}/file`, (req, res, next) => {
|
|
87
|
+
const abs = safeResolve(targetDir, queryParam(req, 'path'));
|
|
88
|
+
if (!abs)
|
|
89
|
+
return sendJson(res, 400, { error: 'invalid path' });
|
|
90
|
+
const rel = toRelPosix(targetDir, abs);
|
|
91
|
+
const ext = extOf(rel);
|
|
92
|
+
if (!registry.supports(ext))
|
|
93
|
+
return sendJson(res, 415, { error: 'unsupported type' });
|
|
94
|
+
isFile(abs)
|
|
95
|
+
.then((ok) => {
|
|
96
|
+
if (!ok)
|
|
97
|
+
return sendJson(res, 404, { error: 'not found' });
|
|
98
|
+
return readFileText(abs).then((raw) => {
|
|
99
|
+
const result = registry.render({ path: rel, ext, raw });
|
|
100
|
+
const body = {
|
|
101
|
+
path: rel,
|
|
102
|
+
kind: result.kind,
|
|
103
|
+
title: result.title,
|
|
104
|
+
...(result.kind === 'iframe'
|
|
105
|
+
? { iframeSrc: `${API_BASE}/raw?path=${encodeURIComponent(rel)}` }
|
|
106
|
+
: { html: result.html }),
|
|
107
|
+
...(result.language ? { language: result.language } : {}),
|
|
108
|
+
};
|
|
109
|
+
sendJson(res, 200, body);
|
|
110
|
+
});
|
|
111
|
+
})
|
|
112
|
+
.catch(next);
|
|
113
|
+
});
|
|
114
|
+
server.middlewares.use(`${API_BASE}/raw`, (req, res, next) => {
|
|
115
|
+
const abs = safeResolve(targetDir, queryParam(req, 'path'));
|
|
116
|
+
if (!abs)
|
|
117
|
+
return sendJson(res, 400, { error: 'invalid path' });
|
|
118
|
+
if (extOf(abs) !== 'html' && extOf(abs) !== 'htm') {
|
|
119
|
+
return sendJson(res, 415, { error: 'raw only serves html' });
|
|
120
|
+
}
|
|
121
|
+
isFile(abs)
|
|
122
|
+
.then((ok) => {
|
|
123
|
+
if (!ok)
|
|
124
|
+
return sendJson(res, 404, { error: 'not found' });
|
|
125
|
+
return readFileText(abs).then((raw) => {
|
|
126
|
+
res.statusCode = 200;
|
|
127
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
128
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
129
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
130
|
+
res.end(raw);
|
|
131
|
+
});
|
|
132
|
+
})
|
|
133
|
+
.catch(next);
|
|
134
|
+
});
|
|
135
|
+
server.middlewares.use(`${API_BASE}/search`, (req, res) => {
|
|
136
|
+
const query = queryParam(req, 'q') ?? '';
|
|
137
|
+
const body = { query, hits: index.search(query) };
|
|
138
|
+
sendJson(res, 200, body);
|
|
139
|
+
});
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
//# sourceMappingURL=plugin.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugin.js","sourceRoot":"","sources":["../../src/server/plugin.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Renderer } from './types.js';
|
|
2
|
+
/** Raw HTML is presented in a sandboxed iframe by the client, so the renderer
|
|
3
|
+
* itself only supplies metadata — the bytes are streamed by the /raw endpoint.
|
|
4
|
+
* Kept separate from the markdown path so untrusted markup never lands in the
|
|
5
|
+
* app-shell DOM. */
|
|
6
|
+
export declare const htmlRenderer: Renderer;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
/** Raw HTML is presented in a sandboxed iframe by the client, so the renderer
|
|
3
|
+
* itself only supplies metadata — the bytes are streamed by the /raw endpoint.
|
|
4
|
+
* Kept separate from the markdown path so untrusted markup never lands in the
|
|
5
|
+
* app-shell DOM. */
|
|
6
|
+
export const htmlRenderer = {
|
|
7
|
+
extensions: ['html', 'htm'],
|
|
8
|
+
render({ path }) {
|
|
9
|
+
return {
|
|
10
|
+
kind: 'iframe',
|
|
11
|
+
title: basename(path),
|
|
12
|
+
};
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
//# sourceMappingURL=html.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"html.js","sourceRoot":"","sources":["../../../src/server/renderers/html.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAGrC;;;qBAGqB;AACrB,MAAM,CAAC,MAAM,YAAY,GAAa;IACpC,UAAU,EAAE,CAAC,MAAM,EAAE,KAAK,CAAC;IAC3B,MAAM,CAAC,EAAE,IAAI,EAAe;QAC1B,OAAO;YACL,IAAI,EAAE,QAAQ;YACd,KAAK,EAAE,QAAQ,CAAC,IAAI,CAAC;SACtB,CAAC;IACJ,CAAC;CACF,CAAC"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import MarkdownIt from 'markdown-it';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
const md = new MarkdownIt({ html: true, linkify: true, typographer: true });
|
|
4
|
+
/** First ATX/setext heading text, else the file name. */
|
|
5
|
+
function deriveTitle(raw, path) {
|
|
6
|
+
const atx = raw.match(/^#{1,6}\s+(.+?)\s*#*\s*$/m);
|
|
7
|
+
if (atx?.[1])
|
|
8
|
+
return atx[1].trim();
|
|
9
|
+
return basename(path);
|
|
10
|
+
}
|
|
11
|
+
export const markdownRenderer = {
|
|
12
|
+
extensions: ['md', 'markdown'],
|
|
13
|
+
render({ raw, path }) {
|
|
14
|
+
return {
|
|
15
|
+
kind: 'html',
|
|
16
|
+
html: md.render(raw),
|
|
17
|
+
title: deriveTitle(raw, path),
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
//# sourceMappingURL=markdown.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"markdown.js","sourceRoot":"","sources":["../../../src/server/renderers/markdown.ts"],"names":[],"mappings":"AAAA,OAAO,UAAU,MAAM,aAAa,CAAC;AACrC,OAAO,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAGrC,MAAM,EAAE,GAAG,IAAI,UAAU,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC,CAAC;AAE5E,yDAAyD;AACzD,SAAS,WAAW,CAAC,GAAW,EAAE,IAAY;IAC5C,MAAM,GAAG,GAAG,GAAG,CAAC,KAAK,CAAC,2BAA2B,CAAC,CAAC;IACnD,IAAI,GAAG,EAAE,CAAC,CAAC,CAAC;QAAE,OAAO,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IACnC,OAAO,QAAQ,CAAC,IAAI,CAAC,CAAC;AACxB,CAAC;AAED,MAAM,CAAC,MAAM,gBAAgB,GAAa;IACxC,UAAU,EAAE,CAAC,IAAI,EAAE,UAAU,CAAC;IAC9B,MAAM,CAAC,EAAE,GAAG,EAAE,IAAI,EAAe;QAC/B,OAAO;YACL,IAAI,EAAE,MAAM;YACZ,IAAI,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC;YACpB,KAAK,EAAE,WAAW,CAAC,GAAG,EAAE,IAAI,CAAC;SAC9B,CAAC;IACJ,CAAC;CACF,CAAC"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Renderer, RenderInput, RenderResult } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Extension-keyed renderer dispatch. New file types register a `Renderer` here
|
|
4
|
+
* (or via `register`) without any change to the middleware or client routing —
|
|
5
|
+
* that decoupling is the whole point of the registry.
|
|
6
|
+
*/
|
|
7
|
+
export declare class RendererRegistry {
|
|
8
|
+
private readonly byExt;
|
|
9
|
+
constructor(renderers?: Renderer[]);
|
|
10
|
+
register(renderer: Renderer): void;
|
|
11
|
+
/** True if some renderer claims `ext` (lower-case, no dot). */
|
|
12
|
+
supports(ext: string): boolean;
|
|
13
|
+
supportedExtensions(): string[];
|
|
14
|
+
/** Dispatch to the matching renderer. Throws if none claims the extension —
|
|
15
|
+
* callers gate on `supports()` first (the tree never surfaces others). */
|
|
16
|
+
render(input: RenderInput): RenderResult;
|
|
17
|
+
}
|
|
18
|
+
export declare const DEFAULT_RENDERERS: Renderer[];
|
|
19
|
+
export type { Renderer, RenderInput, RenderResult } from './types.js';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { markdownRenderer } from './markdown.js';
|
|
2
|
+
import { htmlRenderer } from './html.js';
|
|
3
|
+
import { sourceRenderer } from './source.js';
|
|
4
|
+
/**
|
|
5
|
+
* Extension-keyed renderer dispatch. New file types register a `Renderer` here
|
|
6
|
+
* (or via `register`) without any change to the middleware or client routing —
|
|
7
|
+
* that decoupling is the whole point of the registry.
|
|
8
|
+
*/
|
|
9
|
+
export class RendererRegistry {
|
|
10
|
+
byExt = new Map();
|
|
11
|
+
constructor(renderers = DEFAULT_RENDERERS) {
|
|
12
|
+
for (const renderer of renderers)
|
|
13
|
+
this.register(renderer);
|
|
14
|
+
}
|
|
15
|
+
register(renderer) {
|
|
16
|
+
for (const ext of renderer.extensions) {
|
|
17
|
+
this.byExt.set(ext.toLowerCase(), renderer);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** True if some renderer claims `ext` (lower-case, no dot). */
|
|
21
|
+
supports(ext) {
|
|
22
|
+
return this.byExt.has(ext.toLowerCase());
|
|
23
|
+
}
|
|
24
|
+
supportedExtensions() {
|
|
25
|
+
return [...this.byExt.keys()];
|
|
26
|
+
}
|
|
27
|
+
/** Dispatch to the matching renderer. Throws if none claims the extension —
|
|
28
|
+
* callers gate on `supports()` first (the tree never surfaces others). */
|
|
29
|
+
render(input) {
|
|
30
|
+
const renderer = this.byExt.get(input.ext.toLowerCase());
|
|
31
|
+
if (!renderer) {
|
|
32
|
+
throw new Error(`No renderer registered for extension: ${input.ext}`);
|
|
33
|
+
}
|
|
34
|
+
return renderer.render(input);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export const DEFAULT_RENDERERS = [markdownRenderer, htmlRenderer, sourceRenderer];
|
|
38
|
+
//# sourceMappingURL=registry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registry.js","sourceRoot":"","sources":["../../../src/server/renderers/registry.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AACjD,OAAO,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAE7C;;;;GAIG;AACH,MAAM,OAAO,gBAAgB;IACV,KAAK,GAAG,IAAI,GAAG,EAAoB,CAAC;IAErD,YAAY,YAAwB,iBAAiB;QACnD,KAAK,MAAM,QAAQ,IAAI,SAAS;YAAE,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;IAC5D,CAAC;IAED,QAAQ,CAAC,QAAkB;QACzB,KAAK,MAAM,GAAG,IAAI,QAAQ,CAAC,UAAU,EAAE,CAAC;YACtC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,WAAW,EAAE,EAAE,QAAQ,CAAC,CAAC;QAC9C,CAAC;IACH,CAAC;IAED,+DAA+D;IAC/D,QAAQ,CAAC,GAAW;QAClB,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,WAAW,EAAE,CAAC,CAAC;IAC3C,CAAC;IAED,mBAAmB;QACjB,OAAO,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC;IAChC,CAAC;IAED;+EAC2E;IAC3E,MAAM,CAAC,KAAkB;QACvB,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,WAAW,EAAE,CAAC,CAAC;QACzD,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,MAAM,IAAI,KAAK,CAAC,yCAAyC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC;QACxE,CAAC;QACD,OAAO,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAChC,CAAC;CACF;AAED,MAAM,CAAC,MAAM,iBAAiB,GAAe,CAAC,gBAAgB,EAAE,YAAY,EAAE,cAAc,CAAC,CAAC"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import hljs from 'highlight.js';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
/** Maps our supported source extensions to highlight.js language ids.
|
|
4
|
+
* `txt` has no language and is rendered as escaped plain text. */
|
|
5
|
+
const LANGUAGE_BY_EXT = {
|
|
6
|
+
css: 'css',
|
|
7
|
+
txt: undefined,
|
|
8
|
+
};
|
|
9
|
+
function escapeHtml(value) {
|
|
10
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
11
|
+
}
|
|
12
|
+
/** Read-only syntax-highlighted view for source files. Highlighting runs here
|
|
13
|
+
* on the server so the client bundle stays free of a highlighter. */
|
|
14
|
+
export const sourceRenderer = {
|
|
15
|
+
extensions: Object.keys(LANGUAGE_BY_EXT),
|
|
16
|
+
render({ raw, ext, path }) {
|
|
17
|
+
const language = LANGUAGE_BY_EXT[ext];
|
|
18
|
+
const inner = language && hljs.getLanguage(language)
|
|
19
|
+
? hljs.highlight(raw, { language }).value
|
|
20
|
+
: escapeHtml(raw);
|
|
21
|
+
return {
|
|
22
|
+
kind: 'code',
|
|
23
|
+
html: `<pre class="hljs"><code>${inner}</code></pre>`,
|
|
24
|
+
language,
|
|
25
|
+
title: basename(path),
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
//# sourceMappingURL=source.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"source.js","sourceRoot":"","sources":["../../../src/server/renderers/source.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,cAAc,CAAC;AAChC,OAAO,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAGrC;mEACmE;AACnE,MAAM,eAAe,GAAuC;IAC1D,GAAG,EAAE,KAAK;IACV,GAAG,EAAE,SAAS;CACf,CAAC;AAEF,SAAS,UAAU,CAAC,KAAa;IAC/B,OAAO,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;AAClF,CAAC;AAED;sEACsE;AACtE,MAAM,CAAC,MAAM,cAAc,GAAa;IACtC,UAAU,EAAE,MAAM,CAAC,IAAI,CAAC,eAAe,CAAC;IACxC,MAAM,CAAC,EAAE,GAAG,EAAE,GAAG,EAAE,IAAI,EAAe;QACpC,MAAM,QAAQ,GAAG,eAAe,CAAC,GAAG,CAAC,CAAC;QACtC,MAAM,KAAK,GACT,QAAQ,IAAI,IAAI,CAAC,WAAW,CAAC,QAAQ,CAAC;YACpC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,QAAQ,EAAE,CAAC,CAAC,KAAK;YACzC,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC;QACtB,OAAO;YACL,IAAI,EAAE,MAAM;YACZ,IAAI,EAAE,2BAA2B,KAAK,eAAe;YACrD,QAAQ;YACR,KAAK,EAAE,QAAQ,CAAC,IAAI,CAAC;SACtB,CAAC;IACJ,CAAC;CACF,CAAC"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { RenderKind } from '../../shared/types.js';
|
|
2
|
+
export interface RenderInput {
|
|
3
|
+
/** Path relative to the served root, POSIX-separated. */
|
|
4
|
+
path: string;
|
|
5
|
+
/** Lower-case extension without the dot. */
|
|
6
|
+
ext: string;
|
|
7
|
+
/** Raw file contents. */
|
|
8
|
+
raw: string;
|
|
9
|
+
}
|
|
10
|
+
export interface RenderResult {
|
|
11
|
+
kind: RenderKind;
|
|
12
|
+
html?: string;
|
|
13
|
+
language?: string;
|
|
14
|
+
title: string;
|
|
15
|
+
}
|
|
16
|
+
export interface Renderer {
|
|
17
|
+
/** Lower-case extensions (no dot) this renderer claims. */
|
|
18
|
+
readonly extensions: readonly string[];
|
|
19
|
+
render(input: RenderInput): RenderResult;
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/server/renderers/types.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** How the client should present a file's rendered content. */
|
|
2
|
+
export type RenderKind = 'html' | 'iframe' | 'code';
|
|
3
|
+
/** A node in the file tree. Directories carry `children`; files carry `ext`. */
|
|
4
|
+
export interface FileNode {
|
|
5
|
+
/** Path relative to the served root, POSIX-separated (e.g. `docs/intro.md`). */
|
|
6
|
+
path: string;
|
|
7
|
+
/** Base name for display (e.g. `intro.md`). */
|
|
8
|
+
name: string;
|
|
9
|
+
type: 'file' | 'directory';
|
|
10
|
+
/** Lower-case extension without the dot (e.g. `md`). Files only. */
|
|
11
|
+
ext?: string;
|
|
12
|
+
/** Present on directories; sorted dirs-first then alphabetically. */
|
|
13
|
+
children?: FileNode[];
|
|
14
|
+
}
|
|
15
|
+
export interface TreeResponse {
|
|
16
|
+
root: string;
|
|
17
|
+
nodes: FileNode[];
|
|
18
|
+
}
|
|
19
|
+
/** Server-rendered content for a single file, returned by `/file`. */
|
|
20
|
+
export interface RenderedFile {
|
|
21
|
+
path: string;
|
|
22
|
+
kind: RenderKind;
|
|
23
|
+
/** Rendered HTML for `html`/`code` kinds. Absent for `iframe`. */
|
|
24
|
+
html?: string;
|
|
25
|
+
/** URL the client should point a sandboxed iframe at, for `iframe` kind. */
|
|
26
|
+
iframeSrc?: string;
|
|
27
|
+
/** Highlight.js language for `code` kind, when detected. */
|
|
28
|
+
language?: string;
|
|
29
|
+
title: string;
|
|
30
|
+
}
|
|
31
|
+
export interface SearchHit {
|
|
32
|
+
path: string;
|
|
33
|
+
title: string;
|
|
34
|
+
/** A short excerpt around the first match, plain text. */
|
|
35
|
+
snippet: string;
|
|
36
|
+
score: number;
|
|
37
|
+
}
|
|
38
|
+
export interface SearchResponse {
|
|
39
|
+
query: string;
|
|
40
|
+
hits: SearchHit[];
|
|
41
|
+
}
|
|
42
|
+
/** Payload pushed over Vite's HMR channel when a watched file changes. */
|
|
43
|
+
export interface HmrServeUpdate {
|
|
44
|
+
event: 'add' | 'change' | 'unlink';
|
|
45
|
+
path: string;
|
|
46
|
+
}
|
|
47
|
+
/** The custom HMR event name the client subscribes to via `import.meta.hot`. */
|
|
48
|
+
export declare const HMR_UPDATE_EVENT = "hmr-serve:update";
|
|
49
|
+
/** Extensions the tool renders at launch. Central so the tree filter, the
|
|
50
|
+
* renderer registry, and the indexer agree on one source of truth. */
|
|
51
|
+
export declare const SUPPORTED_EXTENSIONS: readonly ["md", "html", "css", "txt"];
|
|
52
|
+
export type SupportedExtension = (typeof SUPPORTED_EXTENSIONS)[number];
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Types shared across the Node server and the Vue client. Kept dependency-free
|
|
2
|
+
// so both the `node` and `vite/client` type environments can import it.
|
|
3
|
+
/** The custom HMR event name the client subscribes to via `import.meta.hot`. */
|
|
4
|
+
export const HMR_UPDATE_EVENT = 'hmr-serve:update';
|
|
5
|
+
/** Extensions the tool renders at launch. Central so the tree filter, the
|
|
6
|
+
* renderer registry, and the indexer agree on one source of truth. */
|
|
7
|
+
export const SUPPORTED_EXTENSIONS = ['md', 'html', 'css', 'txt'];
|
|
8
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/shared/types.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,wEAAwE;AAuDxE,gFAAgF;AAChF,MAAM,CAAC,MAAM,gBAAgB,GAAG,kBAAkB,CAAC;AAEnD;uEACuE;AACvE,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,CAAU,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hmr-serve",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Point at any folder and review it live in the browser — VSCode-style file tree + content pane, powered by Vite's dev server and HMR client.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"vite",
|
|
9
|
+
"hmr",
|
|
10
|
+
"markdown",
|
|
11
|
+
"preview",
|
|
12
|
+
"cli",
|
|
13
|
+
"dev-server",
|
|
14
|
+
"documentation"
|
|
15
|
+
],
|
|
16
|
+
"bin": {
|
|
17
|
+
"hmr-serve": "bin/hmr-serve.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"bin",
|
|
21
|
+
"dist",
|
|
22
|
+
"src/client",
|
|
23
|
+
"src/shared"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20.11.0"
|
|
27
|
+
},
|
|
28
|
+
"packageManager": "pnpm@11.11.0",
|
|
29
|
+
"scripts": {
|
|
30
|
+
"dev": "tsx src/cli/index.ts",
|
|
31
|
+
"build": "tsc -p tsconfig.build.json",
|
|
32
|
+
"prepublishOnly": "pnpm run build",
|
|
33
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && vue-tsc -p tsconfig.client.json --noEmit",
|
|
34
|
+
"lint": "eslint . && prettier --check .",
|
|
35
|
+
"format": "prettier --write .",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"test:unit": "vitest run tests/unit tests/component",
|
|
38
|
+
"test:watch": "vitest",
|
|
39
|
+
"test:e2e": "playwright test",
|
|
40
|
+
"test:e2e:install": "playwright install --with-deps chromium"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@vitejs/plugin-vue": "^5.2.1",
|
|
44
|
+
"cac": "^6.7.14",
|
|
45
|
+
"highlight.js": "^11.10.0",
|
|
46
|
+
"markdown-it": "^14.1.0",
|
|
47
|
+
"minisearch": "^7.1.1",
|
|
48
|
+
"vite": "^7.0.0",
|
|
49
|
+
"vue": "^3.5.13"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@eslint/js": "^9.17.0",
|
|
53
|
+
"@types/markdown-it": "^14.1.2",
|
|
54
|
+
"@types/node": "^22.10.5",
|
|
55
|
+
"@typescript-eslint/eslint-plugin": "^8.19.1",
|
|
56
|
+
"@typescript-eslint/parser": "^8.19.1",
|
|
57
|
+
"@vue/test-utils": "^2.4.6",
|
|
58
|
+
"@playwright/test": "^1.49.1",
|
|
59
|
+
"eslint": "^9.17.0",
|
|
60
|
+
"eslint-config-prettier": "^9.1.0",
|
|
61
|
+
"eslint-plugin-vue": "^9.32.0",
|
|
62
|
+
"jsdom": "^25.0.1",
|
|
63
|
+
"prettier": "^3.4.2",
|
|
64
|
+
"tsx": "^4.19.2",
|
|
65
|
+
"typescript": "^5.7.3",
|
|
66
|
+
"typescript-eslint": "^8.19.1",
|
|
67
|
+
"vue-eslint-parser": "^9.4.3",
|
|
68
|
+
"vue-tsc": "^2.2.0",
|
|
69
|
+
"vitest": "^2.1.8"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="app">
|
|
3
|
+
<header class="topbar">
|
|
4
|
+
<span class="brand">hmr-serve</span>
|
|
5
|
+
<search-box v-model="query" />
|
|
6
|
+
</header>
|
|
7
|
+
<div class="body">
|
|
8
|
+
<aside class="sidebar">
|
|
9
|
+
<ul v-if="query.trim()" class="search-results" data-testid="search-results">
|
|
10
|
+
<li v-if="hits.length === 0" class="empty" data-testid="search-empty">No matches</li>
|
|
11
|
+
<li
|
|
12
|
+
v-for="hit in hits"
|
|
13
|
+
:key="hit.path"
|
|
14
|
+
class="search-hit"
|
|
15
|
+
data-testid="search-hit"
|
|
16
|
+
:data-path="hit.path"
|
|
17
|
+
:class="{ selected: hit.path === selectedPath }"
|
|
18
|
+
@click="select(hit.path)"
|
|
19
|
+
>
|
|
20
|
+
<span class="hit-title">{{ hit.title }}</span>
|
|
21
|
+
<span class="hit-snippet">{{ hit.snippet }}</span>
|
|
22
|
+
</li>
|
|
23
|
+
</ul>
|
|
24
|
+
<file-tree v-else :nodes="tree" :selected-path="selectedPath" @select="select" />
|
|
25
|
+
</aside>
|
|
26
|
+
<main class="main">
|
|
27
|
+
<content-pane :path="selectedPath" />
|
|
28
|
+
</main>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</template>
|
|
32
|
+
|
|
33
|
+
<script lang="ts">
|
|
34
|
+
import { defineComponent } from 'vue';
|
|
35
|
+
import type { FileNode, SearchHit } from '../shared/types';
|
|
36
|
+
import { fetchTree, search } from './api';
|
|
37
|
+
import { onFileUpdate } from './hmr';
|
|
38
|
+
import FileTree from './components/FileTree.vue';
|
|
39
|
+
import ContentPane from './components/ContentPane.vue';
|
|
40
|
+
import SearchBox from './components/SearchBox.vue';
|
|
41
|
+
|
|
42
|
+
interface AppData {
|
|
43
|
+
tree: FileNode[];
|
|
44
|
+
selectedPath: string | null;
|
|
45
|
+
query: string;
|
|
46
|
+
hits: SearchHit[];
|
|
47
|
+
searchTimer: ReturnType<typeof setTimeout> | null;
|
|
48
|
+
unsubscribe: (() => void) | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default defineComponent({
|
|
52
|
+
name: 'App',
|
|
53
|
+
components: { FileTree, ContentPane, SearchBox },
|
|
54
|
+
data(): AppData {
|
|
55
|
+
return {
|
|
56
|
+
tree: [],
|
|
57
|
+
selectedPath: null,
|
|
58
|
+
query: '',
|
|
59
|
+
hits: [],
|
|
60
|
+
searchTimer: null,
|
|
61
|
+
unsubscribe: null,
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
watch: {
|
|
65
|
+
query(): void {
|
|
66
|
+
// Debounce so each keystroke doesn't hit the server.
|
|
67
|
+
if (this.searchTimer) clearTimeout(this.searchTimer);
|
|
68
|
+
this.searchTimer = setTimeout(() => void this.runSearch(), 120);
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
async mounted(): Promise<void> {
|
|
72
|
+
await this.loadTree();
|
|
73
|
+
// Structural changes (add/unlink) reshape the tree; a change may alter a
|
|
74
|
+
// file's searchable text. Refresh both so the nav and results stay live.
|
|
75
|
+
this.unsubscribe = onFileUpdate((update) => {
|
|
76
|
+
if (update.event !== 'change') void this.loadTree();
|
|
77
|
+
if (this.query.trim()) void this.runSearch();
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
beforeUnmount(): void {
|
|
81
|
+
this.unsubscribe?.();
|
|
82
|
+
if (this.searchTimer) clearTimeout(this.searchTimer);
|
|
83
|
+
},
|
|
84
|
+
methods: {
|
|
85
|
+
async loadTree(): Promise<void> {
|
|
86
|
+
this.tree = (await fetchTree()).nodes;
|
|
87
|
+
},
|
|
88
|
+
async runSearch(): Promise<void> {
|
|
89
|
+
const q = this.query.trim();
|
|
90
|
+
if (!q) {
|
|
91
|
+
this.hits = [];
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
this.hits = (await search(q)).hits;
|
|
95
|
+
},
|
|
96
|
+
select(path: string): void {
|
|
97
|
+
this.selectedPath = path;
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
</script>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { RenderedFile, SearchResponse, TreeResponse } from '../shared/types.js';
|
|
2
|
+
|
|
3
|
+
const API_BASE = '/__hmrserve';
|
|
4
|
+
|
|
5
|
+
async function getJson<T>(url: string): Promise<T> {
|
|
6
|
+
const res = await fetch(url, { headers: { Accept: 'application/json' } });
|
|
7
|
+
if (!res.ok) {
|
|
8
|
+
throw new Error(`Request failed (${res.status}): ${url}`);
|
|
9
|
+
}
|
|
10
|
+
return (await res.json()) as T;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function fetchTree(): Promise<TreeResponse> {
|
|
14
|
+
return getJson<TreeResponse>(`${API_BASE}/tree`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function fetchFile(path: string): Promise<RenderedFile> {
|
|
18
|
+
return getJson<RenderedFile>(`${API_BASE}/file?path=${encodeURIComponent(path)}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function search(query: string): Promise<SearchResponse> {
|
|
22
|
+
return getJson<SearchResponse>(`${API_BASE}/search?q=${encodeURIComponent(query)}`);
|
|
23
|
+
}
|