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,93 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section class="content-pane" data-testid="content-pane">
|
|
3
|
+
<div v-if="loading" class="status">Loading…</div>
|
|
4
|
+
<div v-else-if="error" class="status error" data-testid="content-error">{{ error }}</div>
|
|
5
|
+
<div v-else-if="!file" class="status">Select a file to preview</div>
|
|
6
|
+
<iframe
|
|
7
|
+
v-else-if="file.kind === 'iframe'"
|
|
8
|
+
:key="framedSrc"
|
|
9
|
+
:src="framedSrc"
|
|
10
|
+
class="raw-frame"
|
|
11
|
+
data-testid="content-iframe"
|
|
12
|
+
sandbox="allow-scripts allow-same-origin"
|
|
13
|
+
></iframe>
|
|
14
|
+
<!-- eslint-disable-next-line vue/no-v-html -- content is server-rendered from the user's own local files -->
|
|
15
|
+
<div v-else class="rendered markdown-body" data-testid="content-html" v-html="file.html"></div>
|
|
16
|
+
</section>
|
|
17
|
+
</template>
|
|
18
|
+
|
|
19
|
+
<script lang="ts">
|
|
20
|
+
import { defineComponent, type PropType } from 'vue';
|
|
21
|
+
import type { RenderedFile } from '../../shared/types';
|
|
22
|
+
import { fetchFile } from '../api';
|
|
23
|
+
import { onFileUpdate } from '../hmr';
|
|
24
|
+
|
|
25
|
+
interface ContentPaneData {
|
|
26
|
+
file: RenderedFile | null;
|
|
27
|
+
loading: boolean;
|
|
28
|
+
error: string | null;
|
|
29
|
+
version: number;
|
|
30
|
+
unsubscribe: (() => void) | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default defineComponent({
|
|
34
|
+
name: 'ContentPane',
|
|
35
|
+
props: {
|
|
36
|
+
path: { type: String as PropType<string | null>, default: null },
|
|
37
|
+
},
|
|
38
|
+
data(): ContentPaneData {
|
|
39
|
+
return { file: null, loading: false, error: null, version: 0, unsubscribe: null };
|
|
40
|
+
},
|
|
41
|
+
computed: {
|
|
42
|
+
// Cache-busting version keeps the sandboxed iframe reloading on live updates.
|
|
43
|
+
framedSrc(): string {
|
|
44
|
+
if (!this.file?.iframeSrc) return '';
|
|
45
|
+
const sep = this.file.iframeSrc.includes('?') ? '&' : '?';
|
|
46
|
+
return `${this.file.iframeSrc}${sep}v=${this.version}`;
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
watch: {
|
|
50
|
+
path: {
|
|
51
|
+
immediate: true,
|
|
52
|
+
handler(next: string | null): void {
|
|
53
|
+
void this.load(next);
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
mounted(): void {
|
|
58
|
+
// Live updates ride Vite's HMR socket; re-fetch only when the open file changes.
|
|
59
|
+
this.unsubscribe = onFileUpdate((update) => {
|
|
60
|
+
if (update.path !== this.path) return;
|
|
61
|
+
this.version += 1;
|
|
62
|
+
if (update.event === 'unlink') {
|
|
63
|
+
this.file = null;
|
|
64
|
+
this.error = 'File was removed';
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
void this.load(this.path);
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
beforeUnmount(): void {
|
|
71
|
+
this.unsubscribe?.();
|
|
72
|
+
},
|
|
73
|
+
methods: {
|
|
74
|
+
async load(path: string | null): Promise<void> {
|
|
75
|
+
if (!path) {
|
|
76
|
+
this.file = null;
|
|
77
|
+
this.error = null;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
this.loading = true;
|
|
81
|
+
this.error = null;
|
|
82
|
+
try {
|
|
83
|
+
this.file = await fetchFile(path);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
this.file = null;
|
|
86
|
+
this.error = err instanceof Error ? err.message : String(err);
|
|
87
|
+
} finally {
|
|
88
|
+
this.loading = false;
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
</script>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<nav class="file-tree" data-testid="file-tree" role="tree">
|
|
3
|
+
<ul class="root">
|
|
4
|
+
<tree-node
|
|
5
|
+
v-for="node in visibleNodes"
|
|
6
|
+
:key="node.path"
|
|
7
|
+
:node="node"
|
|
8
|
+
:selected-path="selectedPath"
|
|
9
|
+
@select="$emit('select', $event)"
|
|
10
|
+
/>
|
|
11
|
+
</ul>
|
|
12
|
+
<p v-if="visibleNodes.length === 0" class="empty" data-testid="tree-empty">
|
|
13
|
+
{{ filter ? 'No matches' : 'No files' }}
|
|
14
|
+
</p>
|
|
15
|
+
</nav>
|
|
16
|
+
</template>
|
|
17
|
+
|
|
18
|
+
<script lang="ts">
|
|
19
|
+
import { defineComponent, type PropType } from 'vue';
|
|
20
|
+
import type { FileNode } from '../../shared/types';
|
|
21
|
+
import TreeNode from './TreeNode.vue';
|
|
22
|
+
|
|
23
|
+
/** Prune the tree to files whose name matches `query` (case-insensitive),
|
|
24
|
+
* keeping ancestor directories of any surviving file. Pure so behavior is
|
|
25
|
+
* easy to reason about and test through the rendered output. */
|
|
26
|
+
function filterTree(nodes: FileNode[], query: string): FileNode[] {
|
|
27
|
+
if (!query) return nodes;
|
|
28
|
+
const needle = query.toLowerCase();
|
|
29
|
+
const out: FileNode[] = [];
|
|
30
|
+
for (const node of nodes) {
|
|
31
|
+
if (node.type === 'file') {
|
|
32
|
+
if (node.name.toLowerCase().includes(needle)) out.push(node);
|
|
33
|
+
} else {
|
|
34
|
+
const children = filterTree(node.children ?? [], query);
|
|
35
|
+
if (children.length > 0) out.push({ ...node, children });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default defineComponent({
|
|
42
|
+
name: 'FileTree',
|
|
43
|
+
components: { TreeNode },
|
|
44
|
+
props: {
|
|
45
|
+
nodes: { type: Array as PropType<FileNode[]>, required: true },
|
|
46
|
+
selectedPath: { type: String as PropType<string | null>, default: null },
|
|
47
|
+
filter: { type: String, default: '' },
|
|
48
|
+
},
|
|
49
|
+
emits: {
|
|
50
|
+
select: (path: string) => typeof path === 'string',
|
|
51
|
+
},
|
|
52
|
+
computed: {
|
|
53
|
+
visibleNodes(): FileNode[] {
|
|
54
|
+
return filterTree(this.nodes, this.filter.trim());
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
</script>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="search-box">
|
|
3
|
+
<input
|
|
4
|
+
:value="modelValue"
|
|
5
|
+
type="search"
|
|
6
|
+
class="search-input"
|
|
7
|
+
placeholder="Search files…"
|
|
8
|
+
data-testid="search-input"
|
|
9
|
+
aria-label="Search files"
|
|
10
|
+
@input="onInput"
|
|
11
|
+
/>
|
|
12
|
+
</div>
|
|
13
|
+
</template>
|
|
14
|
+
|
|
15
|
+
<script lang="ts">
|
|
16
|
+
import { defineComponent } from 'vue';
|
|
17
|
+
|
|
18
|
+
export default defineComponent({
|
|
19
|
+
name: 'SearchBox',
|
|
20
|
+
props: {
|
|
21
|
+
modelValue: { type: String, default: '' },
|
|
22
|
+
},
|
|
23
|
+
emits: {
|
|
24
|
+
'update:modelValue': (value: string) => typeof value === 'string',
|
|
25
|
+
},
|
|
26
|
+
methods: {
|
|
27
|
+
onInput(event: Event): void {
|
|
28
|
+
this.$emit('update:modelValue', (event.target as HTMLInputElement).value);
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
</script>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<li class="tree-node">
|
|
3
|
+
<div
|
|
4
|
+
v-if="node.type === 'directory'"
|
|
5
|
+
class="node dir"
|
|
6
|
+
role="treeitem"
|
|
7
|
+
:aria-expanded="expanded"
|
|
8
|
+
@click="toggle"
|
|
9
|
+
>
|
|
10
|
+
<span class="twisty">{{ expanded ? 'â–ľ' : 'â–¸' }}</span>
|
|
11
|
+
<span class="name">{{ node.name }}</span>
|
|
12
|
+
</div>
|
|
13
|
+
<ul v-if="node.type === 'directory' && expanded" class="children">
|
|
14
|
+
<tree-node
|
|
15
|
+
v-for="child in node.children"
|
|
16
|
+
:key="child.path"
|
|
17
|
+
:node="child"
|
|
18
|
+
:selected-path="selectedPath"
|
|
19
|
+
@select="$emit('select', $event)"
|
|
20
|
+
/>
|
|
21
|
+
</ul>
|
|
22
|
+
<div
|
|
23
|
+
v-else-if="node.type === 'file'"
|
|
24
|
+
class="node file"
|
|
25
|
+
role="treeitem"
|
|
26
|
+
data-testid="file-node"
|
|
27
|
+
:data-path="node.path"
|
|
28
|
+
:class="{ selected: node.path === selectedPath }"
|
|
29
|
+
@click="$emit('select', node.path)"
|
|
30
|
+
>
|
|
31
|
+
<span class="name">{{ node.name }}</span>
|
|
32
|
+
</div>
|
|
33
|
+
</li>
|
|
34
|
+
</template>
|
|
35
|
+
|
|
36
|
+
<script lang="ts">
|
|
37
|
+
import { defineComponent, type PropType } from 'vue';
|
|
38
|
+
import type { FileNode } from '../../shared/types';
|
|
39
|
+
|
|
40
|
+
export default defineComponent({
|
|
41
|
+
// Named so the template can recurse into itself.
|
|
42
|
+
name: 'TreeNode',
|
|
43
|
+
props: {
|
|
44
|
+
node: { type: Object as PropType<FileNode>, required: true },
|
|
45
|
+
selectedPath: { type: String as PropType<string | null>, default: null },
|
|
46
|
+
},
|
|
47
|
+
emits: {
|
|
48
|
+
select: (path: string) => typeof path === 'string',
|
|
49
|
+
},
|
|
50
|
+
data() {
|
|
51
|
+
return { expanded: true };
|
|
52
|
+
},
|
|
53
|
+
methods: {
|
|
54
|
+
toggle(): void {
|
|
55
|
+
this.expanded = !this.expanded;
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
</script>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
|
|
3
|
+
declare module '*.vue' {
|
|
4
|
+
import type { DefineComponent } from 'vue';
|
|
5
|
+
// Options-API SFCs; props/emits are inferred at call sites via defineComponent.
|
|
6
|
+
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
|
|
7
|
+
export default component;
|
|
8
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { HMR_UPDATE_EVENT, type HmrServeUpdate } from '../shared/types.js';
|
|
2
|
+
|
|
3
|
+
export type UpdateListener = (update: HmrServeUpdate) => void;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Subscribe to server-pushed file updates over Vite's HMR channel. Returns an
|
|
7
|
+
* unsubscribe function. This is the live-update transport for the content pane —
|
|
8
|
+
* it rides Vite's own websocket rather than a bespoke one, and Vite's client
|
|
9
|
+
* transparently reconnects it if the socket drops.
|
|
10
|
+
*/
|
|
11
|
+
export function onFileUpdate(listener: UpdateListener): () => void {
|
|
12
|
+
const hot = import.meta.hot;
|
|
13
|
+
if (!hot) return () => {};
|
|
14
|
+
const handler = (data: HmrServeUpdate): void => listener(data);
|
|
15
|
+
hot.on(HMR_UPDATE_EVENT, handler);
|
|
16
|
+
return () => hot.off(HMR_UPDATE_EVENT, handler);
|
|
17
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>hmr-serve</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="app"></div>
|
|
10
|
+
<script type="module" src="/main.ts"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #1e1e1e;
|
|
3
|
+
--bg-side: #252526;
|
|
4
|
+
--bg-hover: #2a2d2e;
|
|
5
|
+
--bg-sel: #094771;
|
|
6
|
+
--fg: #cccccc;
|
|
7
|
+
--fg-dim: #8a8a8a;
|
|
8
|
+
--border: #333333;
|
|
9
|
+
--accent: #4daafc;
|
|
10
|
+
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
11
|
+
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
* {
|
|
15
|
+
box-sizing: border-box;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
html,
|
|
19
|
+
body,
|
|
20
|
+
#app {
|
|
21
|
+
height: 100%;
|
|
22
|
+
margin: 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
body {
|
|
26
|
+
font-family: var(--font);
|
|
27
|
+
color: var(--fg);
|
|
28
|
+
background: var(--bg);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.app {
|
|
32
|
+
display: flex;
|
|
33
|
+
flex-direction: column;
|
|
34
|
+
height: 100%;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.topbar {
|
|
38
|
+
display: flex;
|
|
39
|
+
align-items: center;
|
|
40
|
+
gap: 16px;
|
|
41
|
+
padding: 8px 12px;
|
|
42
|
+
background: var(--bg-side);
|
|
43
|
+
border-bottom: 1px solid var(--border);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.brand {
|
|
47
|
+
font-weight: 600;
|
|
48
|
+
color: var(--accent);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.search-box {
|
|
52
|
+
flex: 1;
|
|
53
|
+
max-width: 420px;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.search-input {
|
|
57
|
+
width: 100%;
|
|
58
|
+
padding: 5px 10px;
|
|
59
|
+
background: var(--bg);
|
|
60
|
+
color: var(--fg);
|
|
61
|
+
border: 1px solid var(--border);
|
|
62
|
+
border-radius: 4px;
|
|
63
|
+
font: inherit;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.body {
|
|
67
|
+
display: flex;
|
|
68
|
+
flex: 1;
|
|
69
|
+
min-height: 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.sidebar {
|
|
73
|
+
width: 280px;
|
|
74
|
+
min-width: 180px;
|
|
75
|
+
overflow: auto;
|
|
76
|
+
background: var(--bg-side);
|
|
77
|
+
border-right: 1px solid var(--border);
|
|
78
|
+
padding: 6px 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.main {
|
|
82
|
+
flex: 1;
|
|
83
|
+
min-width: 0;
|
|
84
|
+
overflow: auto;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.file-tree ul,
|
|
88
|
+
.search-results {
|
|
89
|
+
list-style: none;
|
|
90
|
+
margin: 0;
|
|
91
|
+
padding: 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.file-tree .children {
|
|
95
|
+
padding-left: 14px;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.node {
|
|
99
|
+
display: flex;
|
|
100
|
+
align-items: center;
|
|
101
|
+
gap: 4px;
|
|
102
|
+
padding: 2px 10px;
|
|
103
|
+
cursor: pointer;
|
|
104
|
+
white-space: nowrap;
|
|
105
|
+
user-select: none;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.node:hover {
|
|
109
|
+
background: var(--bg-hover);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.node.file.selected {
|
|
113
|
+
background: var(--bg-sel);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.twisty {
|
|
117
|
+
width: 12px;
|
|
118
|
+
color: var(--fg-dim);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.empty {
|
|
122
|
+
padding: 8px 12px;
|
|
123
|
+
color: var(--fg-dim);
|
|
124
|
+
font-size: 13px;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.search-hit {
|
|
128
|
+
padding: 6px 12px;
|
|
129
|
+
cursor: pointer;
|
|
130
|
+
border-bottom: 1px solid var(--border);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.search-hit:hover {
|
|
134
|
+
background: var(--bg-hover);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.search-hit.selected {
|
|
138
|
+
background: var(--bg-sel);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.hit-title {
|
|
142
|
+
display: block;
|
|
143
|
+
font-weight: 500;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.hit-snippet {
|
|
147
|
+
display: block;
|
|
148
|
+
color: var(--fg-dim);
|
|
149
|
+
font-size: 12px;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.content-pane {
|
|
153
|
+
height: 100%;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.status {
|
|
157
|
+
padding: 24px;
|
|
158
|
+
color: var(--fg-dim);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.status.error {
|
|
162
|
+
color: #f48771;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.raw-frame {
|
|
166
|
+
width: 100%;
|
|
167
|
+
height: 100%;
|
|
168
|
+
border: 0;
|
|
169
|
+
background: #fff;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.rendered {
|
|
173
|
+
padding: 24px 32px;
|
|
174
|
+
max-width: 900px;
|
|
175
|
+
line-height: 1.6;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.rendered pre,
|
|
179
|
+
.rendered code {
|
|
180
|
+
font-family: var(--mono);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.rendered pre {
|
|
184
|
+
background: var(--bg-side);
|
|
185
|
+
padding: 12px 16px;
|
|
186
|
+
border-radius: 6px;
|
|
187
|
+
overflow: auto;
|
|
188
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
|
|
4
|
+
/** How the client should present a file's rendered content. */
|
|
5
|
+
export type RenderKind = 'html' | 'iframe' | 'code';
|
|
6
|
+
|
|
7
|
+
/** A node in the file tree. Directories carry `children`; files carry `ext`. */
|
|
8
|
+
export interface FileNode {
|
|
9
|
+
/** Path relative to the served root, POSIX-separated (e.g. `docs/intro.md`). */
|
|
10
|
+
path: string;
|
|
11
|
+
/** Base name for display (e.g. `intro.md`). */
|
|
12
|
+
name: string;
|
|
13
|
+
type: 'file' | 'directory';
|
|
14
|
+
/** Lower-case extension without the dot (e.g. `md`). Files only. */
|
|
15
|
+
ext?: string;
|
|
16
|
+
/** Present on directories; sorted dirs-first then alphabetically. */
|
|
17
|
+
children?: FileNode[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TreeResponse {
|
|
21
|
+
root: string;
|
|
22
|
+
nodes: FileNode[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Server-rendered content for a single file, returned by `/file`. */
|
|
26
|
+
export interface RenderedFile {
|
|
27
|
+
path: string;
|
|
28
|
+
kind: RenderKind;
|
|
29
|
+
/** Rendered HTML for `html`/`code` kinds. Absent for `iframe`. */
|
|
30
|
+
html?: string;
|
|
31
|
+
/** URL the client should point a sandboxed iframe at, for `iframe` kind. */
|
|
32
|
+
iframeSrc?: string;
|
|
33
|
+
/** Highlight.js language for `code` kind, when detected. */
|
|
34
|
+
language?: string;
|
|
35
|
+
title: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SearchHit {
|
|
39
|
+
path: string;
|
|
40
|
+
title: string;
|
|
41
|
+
/** A short excerpt around the first match, plain text. */
|
|
42
|
+
snippet: string;
|
|
43
|
+
score: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SearchResponse {
|
|
47
|
+
query: string;
|
|
48
|
+
hits: SearchHit[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Payload pushed over Vite's HMR channel when a watched file changes. */
|
|
52
|
+
export interface HmrServeUpdate {
|
|
53
|
+
event: 'add' | 'change' | 'unlink';
|
|
54
|
+
path: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The custom HMR event name the client subscribes to via `import.meta.hot`. */
|
|
58
|
+
export const HMR_UPDATE_EVENT = 'hmr-serve:update';
|
|
59
|
+
|
|
60
|
+
/** Extensions the tool renders at launch. Central so the tree filter, the
|
|
61
|
+
* renderer registry, and the indexer agree on one source of truth. */
|
|
62
|
+
export const SUPPORTED_EXTENSIONS = ['md', 'html', 'css', 'txt'] as const;
|
|
63
|
+
export type SupportedExtension = (typeof SUPPORTED_EXTENSIONS)[number];
|