@carlesandres/house 0.3.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 (57) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/LICENSE +21 -0
  3. package/README.md +99 -0
  4. package/package.json +67 -0
  5. package/src/Browser.tsx +472 -0
  6. package/src/Footer.tsx +151 -0
  7. package/src/HelpOverlay.tsx +130 -0
  8. package/src/cli/argv.ts +106 -0
  9. package/src/discovery/filter.ts +56 -0
  10. package/src/discovery/walk.ts +143 -0
  11. package/src/index.tsx +265 -0
  12. package/src/io/readFile.ts +14 -0
  13. package/src/keymap/browser.ts +229 -0
  14. package/src/keymap/keymap.ts +86 -0
  15. package/src/serve/css.ts +120 -0
  16. package/src/serve/openBrowser.ts +19 -0
  17. package/src/serve/render.ts +56 -0
  18. package/src/serve/server.ts +163 -0
  19. package/src/theme/atom.ts +23 -0
  20. package/src/theme/colors.ts +79 -0
  21. package/src/theme/loader.ts +110 -0
  22. package/src/theme/registry.ts +12 -0
  23. package/src/theme/resolve.ts +168 -0
  24. package/src/theme/themes/aura.json +58 -0
  25. package/src/theme/themes/ayu.json +69 -0
  26. package/src/theme/themes/carbonfox.json +201 -0
  27. package/src/theme/themes/catppuccin-frappe.json +186 -0
  28. package/src/theme/themes/catppuccin-macchiato.json +186 -0
  29. package/src/theme/themes/catppuccin.json +212 -0
  30. package/src/theme/themes/cobalt2.json +181 -0
  31. package/src/theme/themes/cursor.json +202 -0
  32. package/src/theme/themes/dracula.json +172 -0
  33. package/src/theme/themes/everforest.json +194 -0
  34. package/src/theme/themes/flexoki.json +190 -0
  35. package/src/theme/themes/github.json +186 -0
  36. package/src/theme/themes/gruvbox.json +195 -0
  37. package/src/theme/themes/kanagawa.json +180 -0
  38. package/src/theme/themes/lucent-orng.json +186 -0
  39. package/src/theme/themes/material.json +188 -0
  40. package/src/theme/themes/matrix.json +180 -0
  41. package/src/theme/themes/mercury.json +198 -0
  42. package/src/theme/themes/monokai.json +174 -0
  43. package/src/theme/themes/nightowl.json +174 -0
  44. package/src/theme/themes/nord.json +176 -0
  45. package/src/theme/themes/one-dark.json +184 -0
  46. package/src/theme/themes/opencode.json +198 -0
  47. package/src/theme/themes/orng.json +202 -0
  48. package/src/theme/themes/osaka-jade.json +193 -0
  49. package/src/theme/themes/palenight.json +175 -0
  50. package/src/theme/themes/rosepine.json +187 -0
  51. package/src/theme/themes/solarized.json +176 -0
  52. package/src/theme/themes/synthwave84.json +179 -0
  53. package/src/theme/themes/tokyonight.json +196 -0
  54. package/src/theme/themes/vercel.json +198 -0
  55. package/src/theme/themes/vesper.json +171 -0
  56. package/src/theme/themes/zenburn.json +176 -0
  57. package/src/theme/types.ts +109 -0
@@ -0,0 +1,14 @@
1
+ import { readFile } from "node:fs/promises"
2
+ import { Data, Effect } from "effect"
3
+
4
+ export class FileReadError extends Data.TaggedError("FileReadError")<{
5
+ readonly path: string
6
+ readonly cause: unknown
7
+ }> {}
8
+
9
+ /** Read a UTF-8 text file. Errors as `FileReadError`. */
10
+ export const readFileText = (path: string): Effect.Effect<string, FileReadError> =>
11
+ Effect.tryPromise({
12
+ try: () => readFile(path, "utf8"),
13
+ catch: (cause) => new FileReadError({ path, cause }),
14
+ })
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Browser keymap — the data backing `Browser.tsx`'s `useKeyboard` handler
3
+ * and (next iteration) the `?` help overlay.
4
+ */
5
+
6
+ import type { FileEntry } from "../discovery/walk.ts"
7
+ import type { KeyBinding } from "./keymap.ts"
8
+
9
+ export type BrowserFocus = "sidebar" | "reader"
10
+
11
+ export interface BrowserCtx {
12
+ readonly files: readonly FileEntry[]
13
+ readonly focus: BrowserFocus
14
+ readonly sidebarVisible: boolean
15
+ readonly helpVisible: boolean
16
+ readonly filterOpen: boolean
17
+ readonly setFocus: (next: BrowserFocus | ((prev: BrowserFocus) => BrowserFocus)) => void
18
+ readonly setSelectedIndex: (updater: (prev: number) => number) => void
19
+ readonly setSidebarVisible: (updater: (prev: boolean) => boolean) => void
20
+ readonly setHelpVisible: (updater: (prev: boolean) => boolean) => void
21
+ readonly openFilter: () => void
22
+ readonly cycleTheme: (delta: 1 | -1) => void
23
+ readonly toggleTone: () => void
24
+ readonly quit: () => void
25
+ /** Start (or retarget) the HTML preview server on the focused file. */
26
+ readonly serveCurrent: () => void
27
+ }
28
+
29
+ /** Step size for shift+j/k and the space/b/page keys. Constant for v1; could
30
+ * later be derived from the visible window height. */
31
+ const JUMP = 8
32
+
33
+ const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n))
34
+ const lastIndex = (c: BrowserCtx) => Math.max(0, c.files.length - 1)
35
+ const haveFiles = (c: BrowserCtx) => c.files.length > 0
36
+ const stepBy = (c: BrowserCtx, delta: number) =>
37
+ c.setSelectedIndex((i) => clamp(i + delta, 0, lastIndex(c)))
38
+
39
+ const inSidebar = (c: BrowserCtx) => c.focus === "sidebar"
40
+ const inSidebarFilterClosed = (c: BrowserCtx) => inSidebar(c) && !c.filterOpen
41
+ const inReader = (c: BrowserCtx) => c.focus === "reader"
42
+ const inSidebarWithFiles = (c: BrowserCtx) => inSidebar(c) && haveFiles(c)
43
+ const inReaderWithFiles = (c: BrowserCtx) => inReader(c) && haveFiles(c)
44
+
45
+ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
46
+ // Global
47
+ {
48
+ id: "quit",
49
+ group: "Global",
50
+ description: "Quit",
51
+ hint: "quit",
52
+ keys: ["q", "ctrl+c"],
53
+ run: (c) => c.quit(),
54
+ },
55
+ {
56
+ id: "focus.toggle",
57
+ group: "Global",
58
+ description: "Toggle focus (sidebar ↔ reader)",
59
+ hint: "focus",
60
+ keys: ["tab"],
61
+ run: (c) => c.setFocus((f) => (f === "sidebar" ? "reader" : "sidebar")),
62
+ },
63
+ {
64
+ id: "sidebar.toggle",
65
+ group: "Global",
66
+ description: "Toggle sidebar visibility",
67
+ hint: "sidebar",
68
+ keys: ["s"],
69
+ run: (c) => {
70
+ const willHide = c.sidebarVisible
71
+ c.setSidebarVisible((v) => !v)
72
+ // When hiding the sidebar, move focus to the reader so input has a
73
+ // target. When revealing it, move focus back to the sidebar.
74
+ c.setFocus(willHide ? "reader" : "sidebar")
75
+ },
76
+ },
77
+ {
78
+ id: "help.toggle",
79
+ group: "Global",
80
+ description: "Show / dismiss help",
81
+ hint: "help",
82
+ keys: ["?"],
83
+ run: (c) => c.setHelpVisible((v) => !v),
84
+ },
85
+ {
86
+ id: "filter.open",
87
+ group: "Sidebar",
88
+ description: "Filter files (fuzzy match on path)",
89
+ hint: "filter",
90
+ keys: ["/"],
91
+ when: inSidebarFilterClosed,
92
+ run: (c) => c.openFilter(),
93
+ },
94
+ {
95
+ id: "serve.current",
96
+ group: "Global",
97
+ description: "Open current file in browser as HTML",
98
+ hint: "html",
99
+ keys: ["o"],
100
+ when: haveFiles,
101
+ run: (c) => c.serveCurrent(),
102
+ },
103
+ {
104
+ id: "theme.next",
105
+ group: "Global",
106
+ description: "Next theme",
107
+ hint: "theme",
108
+ keys: ["t"],
109
+ run: (c) => c.cycleTheme(1),
110
+ },
111
+ {
112
+ id: "theme.prev",
113
+ group: "Global",
114
+ description: "Previous theme",
115
+ keys: ["shift+t"],
116
+ run: (c) => c.cycleTheme(-1),
117
+ },
118
+ {
119
+ id: "theme.toneToggle",
120
+ group: "Global",
121
+ description: "Toggle dark / light tone",
122
+ keys: ["shift+l"],
123
+ run: (c) => c.toggleTone(),
124
+ },
125
+
126
+ // Sidebar
127
+ {
128
+ id: "sidebar.down",
129
+ group: "Sidebar",
130
+ description: "Move selection down",
131
+ keys: ["j", "down"],
132
+ when: inSidebarWithFiles,
133
+ run: (c) => stepBy(c, 1),
134
+ },
135
+ {
136
+ id: "sidebar.up",
137
+ group: "Sidebar",
138
+ description: "Move selection up",
139
+ keys: ["k", "up"],
140
+ when: inSidebarWithFiles,
141
+ run: (c) => stepBy(c, -1),
142
+ },
143
+ {
144
+ id: "sidebar.jumpDown",
145
+ group: "Sidebar",
146
+ description: `Jump down ${JUMP}`,
147
+ keys: ["shift+j"],
148
+ when: inSidebarWithFiles,
149
+ run: (c) => stepBy(c, JUMP),
150
+ },
151
+ {
152
+ id: "sidebar.jumpUp",
153
+ group: "Sidebar",
154
+ description: `Jump up ${JUMP}`,
155
+ keys: ["shift+k"],
156
+ when: inSidebarWithFiles,
157
+ run: (c) => stepBy(c, -JUMP),
158
+ },
159
+ {
160
+ id: "sidebar.pageDown",
161
+ group: "Sidebar",
162
+ description: "Page down",
163
+ keys: ["space", "pagedown", "ctrl+d"],
164
+ when: inSidebarWithFiles,
165
+ run: (c) => stepBy(c, JUMP),
166
+ },
167
+ {
168
+ id: "sidebar.pageUp",
169
+ group: "Sidebar",
170
+ description: "Page up",
171
+ keys: ["b", "pageup", "ctrl+u"],
172
+ when: inSidebarWithFiles,
173
+ run: (c) => stepBy(c, -JUMP),
174
+ },
175
+ {
176
+ id: "sidebar.top",
177
+ group: "Sidebar",
178
+ description: "Jump to first file",
179
+ keys: ["g"],
180
+ when: inSidebarWithFiles,
181
+ run: (c) => c.setSelectedIndex(() => 0),
182
+ },
183
+ {
184
+ id: "sidebar.bottom",
185
+ group: "Sidebar",
186
+ description: "Jump to last file",
187
+ keys: ["shift+g"],
188
+ when: inSidebarWithFiles,
189
+ run: (c) => c.setSelectedIndex(() => lastIndex(c)),
190
+ },
191
+ {
192
+ id: "sidebar.open",
193
+ group: "Sidebar",
194
+ description: "Open file (focus reader)",
195
+ hint: "open",
196
+ keys: ["return", "right", "l"],
197
+ when: inSidebar,
198
+ run: (c) => c.setFocus("reader"),
199
+ },
200
+
201
+ // Reader
202
+ {
203
+ id: "reader.back",
204
+ group: "Reader",
205
+ description: "Back to sidebar",
206
+ hint: "back",
207
+ keys: ["escape", "left", "h"],
208
+ when: inReader,
209
+ run: (c) => c.setFocus("sidebar"),
210
+ },
211
+ {
212
+ id: "reader.prevFile",
213
+ group: "Reader",
214
+ description: "Prev file",
215
+ hint: "prev",
216
+ keys: ["["],
217
+ when: inReaderWithFiles,
218
+ run: (c) => stepBy(c, -1),
219
+ },
220
+ {
221
+ id: "reader.nextFile",
222
+ group: "Reader",
223
+ description: "Next file",
224
+ hint: "next",
225
+ keys: ["]"],
226
+ when: inReaderWithFiles,
227
+ run: (c) => stepBy(c, 1),
228
+ },
229
+ ]
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Tiny declarative keymap.
3
+ *
4
+ * Bindings are values: `{ id, description, keys, when?, run }`. A pure
5
+ * `dispatch` looks up the first matching, enabled binding for a key event
6
+ * and runs it. The same array drives the help overlay, so there is one
7
+ * source of truth.
8
+ *
9
+ * Deliberately *not* a port of ghui's `@ghui/keymap`: no chord sequences,
10
+ * no count prefixes, no scope contramaps. See DESIGN.md §12 for the full
11
+ * trigger that would warrant adopting that machinery.
12
+ */
13
+
14
+ /** The shape of a parsed key event we care about. `KeyEvent` from opentui satisfies this. */
15
+ export interface KeyMatch {
16
+ readonly name: string
17
+ readonly shift?: boolean
18
+ readonly ctrl?: boolean
19
+ readonly meta?: boolean
20
+ }
21
+
22
+ export interface KeyBinding<C> {
23
+ /** Stable id; used for tests and (later) command-palette routing. */
24
+ readonly id: string
25
+ /** Human-readable summary, shown in the help overlay. */
26
+ readonly description: string
27
+ /** Key chords that trigger this binding, e.g. ["j", "down"], ["shift+k"], ["ctrl+c"]. */
28
+ readonly keys: readonly string[]
29
+ /** Optional grouping label for the help overlay. Bindings without a group are excluded from help. */
30
+ readonly group?: string
31
+ /** Optional compact label for the footer hint row, e.g. "help" for `?:help`.
32
+ * Bindings without a hint are excluded from the footer. Order in the bindings
33
+ * array drives overflow priority (later bindings get truncated first). */
34
+ readonly hint?: string
35
+ /** If present, the binding only fires when this returns true. */
36
+ readonly when?: (ctx: C) => boolean
37
+ readonly run: (ctx: C) => void
38
+ }
39
+
40
+ interface ParsedChord {
41
+ readonly key: string
42
+ readonly shift: boolean
43
+ readonly ctrl: boolean
44
+ readonly meta: boolean
45
+ }
46
+
47
+ const parseChord = (raw: string): ParsedChord => {
48
+ const parts = raw.toLowerCase().split("+")
49
+ const key = parts.at(-1) ?? ""
50
+ return {
51
+ key,
52
+ shift: parts.includes("shift"),
53
+ ctrl: parts.includes("ctrl"),
54
+ meta: parts.includes("meta"),
55
+ }
56
+ }
57
+
58
+ const chordMatches = (chord: ParsedChord, key: KeyMatch): boolean => {
59
+ if (chord.key !== key.name) return false
60
+ if (chord.shift !== Boolean(key.shift)) return false
61
+ if (chord.ctrl !== Boolean(key.ctrl)) return false
62
+ if (chord.meta !== Boolean(key.meta)) return false
63
+ return true
64
+ }
65
+
66
+ /**
67
+ * Return the first binding that (a) is enabled for `ctx` and (b) has a key
68
+ * that matches `key`. Runs the binding's action and returns it. Returns null
69
+ * if nothing matched.
70
+ */
71
+ export const dispatch = <C>(
72
+ bindings: readonly KeyBinding<C>[],
73
+ ctx: C,
74
+ key: KeyMatch,
75
+ ): KeyBinding<C> | null => {
76
+ for (const binding of bindings) {
77
+ if (binding.when && !binding.when(ctx)) continue
78
+ for (const raw of binding.keys) {
79
+ if (chordMatches(parseChord(raw), key)) {
80
+ binding.run(ctx)
81
+ return binding
82
+ }
83
+ }
84
+ }
85
+ return null
86
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Neutral GitHub-ish CSS for the serve action.
3
+ *
4
+ * Embedded inline by render.ts so each served page is self-contained — no
5
+ * second request, "Save Page As" gives a working file, future --export
6
+ * reuses the same renderer.
7
+ */
8
+ export const css = `
9
+ :root {
10
+ color-scheme: light dark;
11
+ --bg: #ffffff;
12
+ --fg: #1f2328;
13
+ --muted: #59636e;
14
+ --border: #d1d9e0;
15
+ --code-bg: #f6f8fa;
16
+ --accent: #0969da;
17
+ --blockquote: #59636e;
18
+ --blockquote-border: #d1d9e0;
19
+ }
20
+ @media (prefers-color-scheme: dark) {
21
+ :root {
22
+ --bg: #0d1117;
23
+ --fg: #e6edf3;
24
+ --muted: #9198a1;
25
+ --border: #30363d;
26
+ --code-bg: #151b23;
27
+ --accent: #4493f8;
28
+ --blockquote: #9198a1;
29
+ --blockquote-border: #30363d;
30
+ }
31
+ }
32
+ * { box-sizing: border-box; }
33
+ html, body {
34
+ margin: 0;
35
+ padding: 0;
36
+ background: var(--bg);
37
+ color: var(--fg);
38
+ }
39
+ body {
40
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans",
41
+ Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
42
+ font-size: 16px;
43
+ line-height: 1.6;
44
+ }
45
+ main {
46
+ max-width: 860px;
47
+ margin: 0 auto;
48
+ padding: 2.5rem 1.5rem 6rem;
49
+ }
50
+ h1, h2, h3, h4, h5, h6 {
51
+ margin-top: 1.75em;
52
+ margin-bottom: 0.6em;
53
+ font-weight: 600;
54
+ line-height: 1.25;
55
+ }
56
+ h1 { font-size: 2em; padding-bottom: .3em; border-bottom: 1px solid var(--border); }
57
+ h2 { font-size: 1.5em; padding-bottom: .3em; border-bottom: 1px solid var(--border); }
58
+ h3 { font-size: 1.25em; }
59
+ h4 { font-size: 1em; }
60
+ h5 { font-size: .9em; }
61
+ h6 { font-size: .85em; color: var(--muted); }
62
+ p, ul, ol, blockquote, pre, table { margin: 0 0 1em; }
63
+ a { color: var(--accent); text-decoration: none; }
64
+ a:hover { text-decoration: underline; }
65
+ ul, ol { padding-left: 2em; }
66
+ li + li { margin-top: .25em; }
67
+ blockquote {
68
+ margin: 0 0 1em;
69
+ padding: 0 1em;
70
+ color: var(--blockquote);
71
+ border-left: .25em solid var(--blockquote-border);
72
+ }
73
+ code {
74
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
75
+ "Liberation Mono", monospace;
76
+ font-size: 85%;
77
+ padding: .2em .4em;
78
+ background: var(--code-bg);
79
+ border-radius: 6px;
80
+ }
81
+ pre {
82
+ background: var(--code-bg);
83
+ border: 1px solid var(--border);
84
+ padding: 1em;
85
+ border-radius: 6px;
86
+ overflow: auto;
87
+ font-size: 85%;
88
+ line-height: 1.45;
89
+ }
90
+ pre code {
91
+ padding: 0;
92
+ background: transparent;
93
+ border-radius: 0;
94
+ font-size: 100%;
95
+ }
96
+ table {
97
+ border-collapse: collapse;
98
+ display: block;
99
+ overflow: auto;
100
+ width: max-content;
101
+ max-width: 100%;
102
+ }
103
+ th, td { padding: .4em .8em; border: 1px solid var(--border); }
104
+ th { background: var(--code-bg); font-weight: 600; }
105
+ hr { height: 1px; border: 0; background: var(--border); margin: 1.5em 0; }
106
+ img { max-width: 100%; }
107
+ kbd {
108
+ display: inline-block;
109
+ padding: 3px 5px;
110
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
111
+ font-size: 11px;
112
+ line-height: 10px;
113
+ color: var(--fg);
114
+ vertical-align: middle;
115
+ background: var(--code-bg);
116
+ border: 1px solid var(--border);
117
+ border-radius: 6px;
118
+ box-shadow: inset 0 -1px 0 var(--border);
119
+ }
120
+ `.trim()
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Best-effort "open URL in default browser". Fire and forget — failures are
3
+ * silent because the URL has already been printed; user can click it.
4
+ */
5
+
6
+ import { spawn } from "node:child_process"
7
+
8
+ export const openInBrowser = (url: string): void => {
9
+ const cmd =
10
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open"
11
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url]
12
+ try {
13
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true })
14
+ child.on("error", () => {})
15
+ child.unref()
16
+ } catch {
17
+ // platform without a launcher; URL was printed already.
18
+ }
19
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * renderHtml — wraps marked-rendered markdown in a self-contained HTML page.
3
+ *
4
+ * Embeds the CSS and a tiny SSE reload script so each response is one round
5
+ * trip. The reload script reconnects on drop; the server's `/__reload`
6
+ * endpoint pushes a `reload` event whenever the served file changes.
7
+ */
8
+
9
+ import { marked } from "marked"
10
+ import { css } from "./css.ts"
11
+
12
+ const escapeHtml = (s: string): string =>
13
+ s.replace(/[&<>"']/g, (c) => {
14
+ switch (c) {
15
+ case "&":
16
+ return "&amp;"
17
+ case "<":
18
+ return "&lt;"
19
+ case ">":
20
+ return "&gt;"
21
+ case '"':
22
+ return "&quot;"
23
+ default:
24
+ return "&#39;"
25
+ }
26
+ })
27
+
28
+ const reloadScript = `
29
+ <script>
30
+ (function () {
31
+ function connect() {
32
+ var es = new EventSource("/__reload");
33
+ es.addEventListener("reload", function () { location.reload(); });
34
+ es.onerror = function () { es.close(); setTimeout(connect, 500); };
35
+ }
36
+ connect();
37
+ })();
38
+ </script>
39
+ `.trim()
40
+
41
+ export const renderHtml = (markdown: string, title: string): string => {
42
+ const body = marked.parse(markdown, { async: false }) as string
43
+ return `<!DOCTYPE html>
44
+ <html lang="en">
45
+ <head>
46
+ <meta charset="utf-8">
47
+ <meta name="viewport" content="width=device-width, initial-scale=1">
48
+ <title>${escapeHtml(title)}</title>
49
+ <style>${css}</style>
50
+ </head>
51
+ <body>
52
+ <main>${body}</main>
53
+ ${reloadScript}
54
+ </body>
55
+ </html>`
56
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Local HTML preview server for a single markdown file.
3
+ *
4
+ * One long-lived `Bun.serve` instance. The served file path is swappable
5
+ * via `setTarget(path)` — used by the TUI's `o` binding so pressing it on a
6
+ * new file retargets the existing server (live-reload fires) instead of
7
+ * spawning a second one.
8
+ *
9
+ * Live reload: an SSE endpoint at `/__reload` holds connections open and
10
+ * pushes a `reload` event whenever the watched file changes. A new watcher
11
+ * is created per `setTarget` call; the previous one is closed.
12
+ */
13
+
14
+ import { basename } from "node:path"
15
+ import { watch, type FSWatcher } from "node:fs"
16
+ import { readFile } from "node:fs/promises"
17
+ import { renderHtml } from "./render.ts"
18
+
19
+ export interface ServerHandle {
20
+ /** Base URL, e.g. http://localhost:51234 */
21
+ readonly url: string
22
+ /** Swap which file is served. Pushes a reload to connected clients. */
23
+ setTarget(path: string): void
24
+ /** Path currently being served. */
25
+ currentTarget(): string
26
+ stop(): Promise<void>
27
+ }
28
+
29
+ export interface StartOptions {
30
+ readonly path: string
31
+ /** 0 = OS-assigned. */
32
+ readonly port?: number
33
+ }
34
+
35
+ type ReloadController = ReadableStreamDefaultController<Uint8Array>
36
+
37
+ const encoder = new TextEncoder()
38
+ const sseEvent = (event: string, data = ""): Uint8Array =>
39
+ encoder.encode(`event: ${event}\ndata: ${data}\n\n`)
40
+
41
+ export const startServer = ({ path, port = 0 }: StartOptions): ServerHandle => {
42
+ let target = path
43
+ let watcher: FSWatcher | null = null
44
+ const clients = new Set<ReloadController>()
45
+
46
+ const broadcastReload = () => {
47
+ for (const c of clients) {
48
+ try {
49
+ c.enqueue(sseEvent("reload"))
50
+ } catch {
51
+ clients.delete(c)
52
+ }
53
+ }
54
+ }
55
+
56
+ // `fs.watch` watches an inode, not a path. Editors that save via
57
+ // write-tmp + rename (vim default, VS Code, JetBrains, …) replace the
58
+ // inode, after which our watcher fires nothing. So we re-watch on every
59
+ // event, and debounce because a single save often emits 2–3 events.
60
+ const startWatching = (p: string) => {
61
+ watcher?.close()
62
+ watcher = null
63
+ let timer: ReturnType<typeof setTimeout> | null = null
64
+ try {
65
+ watcher = watch(p, () => {
66
+ if (timer) clearTimeout(timer)
67
+ timer = setTimeout(() => {
68
+ broadcastReload()
69
+ startWatching(p)
70
+ }, 30)
71
+ })
72
+ watcher.on("error", () => {
73
+ // Stale handle after rename; the change event already scheduled
74
+ // a re-watch. Swallow so it doesn't crash the process.
75
+ })
76
+ } catch {
77
+ // Path went away between presses. Server still serves the last
78
+ // good read; live reload stays off until the path returns.
79
+ }
80
+ }
81
+ startWatching(target)
82
+
83
+ const server = Bun.serve({
84
+ port,
85
+ // Bind to loopback. Default is 0.0.0.0 (LAN-exposed); we render the
86
+ // user's local files, so leaking them to the network would be a
87
+ // surprise. URL strings are localhost-only by construction below.
88
+ hostname: "127.0.0.1",
89
+ async fetch(req) {
90
+ const url = new URL(req.url)
91
+ if (url.pathname === "/__reload") {
92
+ // `cancel` receives a reason, not the controller — capture
93
+ // the controller in `start` so we can remove it from the set
94
+ // on disconnect. Without this, dead clients accumulate.
95
+ let ctrl: ReloadController | null = null
96
+ const stream = new ReadableStream<Uint8Array>({
97
+ start(controller) {
98
+ ctrl = controller
99
+ clients.add(controller)
100
+ // Initial comment keeps some proxies from buffering.
101
+ controller.enqueue(encoder.encode(": connected\n\n"))
102
+ },
103
+ cancel() {
104
+ if (ctrl) clients.delete(ctrl)
105
+ },
106
+ })
107
+ return new Response(stream, {
108
+ headers: {
109
+ "content-type": "text/event-stream",
110
+ "cache-control": "no-cache",
111
+ connection: "keep-alive",
112
+ },
113
+ })
114
+ }
115
+ if (url.pathname !== "/") {
116
+ return new Response("not found", { status: 404 })
117
+ }
118
+ try {
119
+ const md = await readFile(target, "utf8")
120
+ const html = renderHtml(md, basename(target))
121
+ return new Response(html, {
122
+ headers: {
123
+ "content-type": "text/html; charset=utf-8",
124
+ "cache-control": "no-store",
125
+ },
126
+ })
127
+ } catch (err) {
128
+ return new Response(`cannot read ${target}: ${String(err)}`, {
129
+ status: 500,
130
+ headers: { "content-type": "text/plain; charset=utf-8" },
131
+ })
132
+ }
133
+ },
134
+ })
135
+
136
+ const url = `http://localhost:${server.port}`
137
+
138
+ return {
139
+ url,
140
+ currentTarget: () => target,
141
+ setTarget: (next) => {
142
+ if (next === target) {
143
+ broadcastReload()
144
+ return
145
+ }
146
+ target = next
147
+ startWatching(next)
148
+ broadcastReload()
149
+ },
150
+ stop: async () => {
151
+ watcher?.close()
152
+ for (const c of clients) {
153
+ try {
154
+ c.close()
155
+ } catch {
156
+ // already closed
157
+ }
158
+ }
159
+ clients.clear()
160
+ await server.stop(true)
161
+ },
162
+ }
163
+ }