@carlesandres/house 0.4.13 → 0.5.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/CHANGELOG.md +32 -2
- package/README.md +10 -8
- package/dist/bin.js +92 -0
- package/dist/index.js +10005 -0
- package/package.json +15 -3
- package/src/Browser.tsx +0 -1210
- package/src/CommandPalette.tsx +0 -214
- package/src/Footer.tsx +0 -258
- package/src/Header.tsx +0 -71
- package/src/PromptRow.tsx +0 -51
- package/src/Spinner.tsx +0 -39
- package/src/StatusIndicator.tsx +0 -55
- package/src/StatusPopover.tsx +0 -166
- package/src/brand.ts +0 -7
- package/src/cli/argv.ts +0 -179
- package/src/commands/buildCommands.ts +0 -98
- package/src/commands/paletteOnlyCommands.ts +0 -27
- package/src/commands/score.ts +0 -78
- package/src/commands/types.ts +0 -26
- package/src/config/load.ts +0 -381
- package/src/config/save.ts +0 -95
- package/src/discovery/filter.ts +0 -148
- package/src/discovery/show.ts +0 -48
- package/src/discovery/walk.ts +0 -209
- package/src/index.tsx +0 -464
- package/src/io/clipboard.ts +0 -53
- package/src/io/editor.ts +0 -162
- package/src/io/readFile.ts +0 -14
- package/src/keymap/browser.ts +0 -335
- package/src/keymap/displayKey.ts +0 -17
- package/src/keymap/keymap.ts +0 -95
- package/src/layout/resolve.ts +0 -52
- package/src/layout/sidebarEmptyState.ts +0 -7
- package/src/layout/sidebarRow.ts +0 -69
- package/src/markdown/frontmatter.ts +0 -62
- package/src/serve/css.ts +0 -120
- package/src/serve/openBrowser.ts +0 -19
- package/src/serve/render.ts +0 -56
- package/src/serve/server.ts +0 -166
- package/src/theme/atom.ts +0 -23
- package/src/theme/colors.ts +0 -86
- package/src/theme/loader.ts +0 -110
- package/src/theme/registry.ts +0 -12
- package/src/theme/resolve.ts +0 -168
- package/src/theme/themes/aura.json +0 -58
- package/src/theme/themes/ayu.json +0 -69
- package/src/theme/themes/carbonfox.json +0 -201
- package/src/theme/themes/catppuccin-frappe.json +0 -186
- package/src/theme/themes/catppuccin-macchiato.json +0 -186
- package/src/theme/themes/catppuccin.json +0 -212
- package/src/theme/themes/cobalt2.json +0 -181
- package/src/theme/themes/cursor.json +0 -202
- package/src/theme/themes/dracula.json +0 -172
- package/src/theme/themes/everforest.json +0 -194
- package/src/theme/themes/flexoki.json +0 -190
- package/src/theme/themes/github.json +0 -186
- package/src/theme/themes/gruvbox.json +0 -195
- package/src/theme/themes/kanagawa.json +0 -180
- package/src/theme/themes/lucent-orng.json +0 -186
- package/src/theme/themes/material.json +0 -188
- package/src/theme/themes/matrix.json +0 -180
- package/src/theme/themes/mercury.json +0 -198
- package/src/theme/themes/monokai.json +0 -174
- package/src/theme/themes/nightowl.json +0 -174
- package/src/theme/themes/nord.json +0 -176
- package/src/theme/themes/one-dark.json +0 -184
- package/src/theme/themes/opencode.json +0 -198
- package/src/theme/themes/orng.json +0 -202
- package/src/theme/themes/osaka-jade.json +0 -193
- package/src/theme/themes/palenight.json +0 -175
- package/src/theme/themes/rosepine.json +0 -187
- package/src/theme/themes/solarized.json +0 -180
- package/src/theme/themes/synthwave84.json +0 -179
- package/src/theme/themes/tokyonight.json +0 -196
- package/src/theme/themes/vercel.json +0 -198
- package/src/theme/themes/vesper.json +0 -171
- package/src/theme/themes/zenburn.json +0 -176
- package/src/theme/types.ts +0 -115
- package/src/tips.ts +0 -88
- package/src/ui/middleTruncate.ts +0 -27
- package/src/update/cache.ts +0 -77
- package/src/update/check.ts +0 -165
- package/src/update/compare.ts +0 -41
- package/src/update/notice.ts +0 -29
- package/src/update/runtime.ts +0 -48
- package/src/update/useUpdateNotice.ts +0 -24
package/src/layout/resolve.ts
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Layout primitives for the two-pane shape (sidebar + reader).
|
|
3
|
-
*
|
|
4
|
-
* Visibility and width are decoupled (see DESIGN.md §7.1):
|
|
5
|
-
* visible = shown || focus === "sidebar"
|
|
6
|
-
* Width is a pure function of viewport, independent of visibility:
|
|
7
|
-
* resolveSidebarWidth(viewport, preferred)
|
|
8
|
-
*
|
|
9
|
-
* Both render code and key handlers consume these primitives — there is no
|
|
10
|
-
* parallel implementation living in JSX.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
/** Minimum useful sidebar width; below this, file rows truncate too aggressively. */
|
|
14
|
-
export const SIDEBAR_MIN_WIDTH = 28
|
|
15
|
-
/** Maximum sidebar width; beyond this, wasted whitespace on wide terminals. */
|
|
16
|
-
export const SIDEBAR_MAX_WIDTH = 60
|
|
17
|
-
/** Reader pane minimum; below this, prose wraps unpleasantly. */
|
|
18
|
-
export const READER_MIN_WIDTH = 40
|
|
19
|
-
/** Column gap painted between the two panes when both are inline. */
|
|
20
|
-
export const DIVIDER_WIDTH = 1
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Continuous clamp. `preferred` is the user's desired width (until #13 lands,
|
|
24
|
-
* derived from viewport). The reader-min ceiling means the sidebar yields
|
|
25
|
-
* space rather than squeezing the reader below readability.
|
|
26
|
-
*
|
|
27
|
-
* Result is not clamped *up* to SIDEBAR_MIN when the viewport itself is too
|
|
28
|
-
* narrow to hold both panes — the caller decides whether to render at all
|
|
29
|
-
* (e.g. single-pane stack instead of inline). See `canFitInline`.
|
|
30
|
-
*/
|
|
31
|
-
export const resolveSidebarWidth = (viewport: number, preferred: number): number => {
|
|
32
|
-
const ceiling = viewport - DIVIDER_WIDTH - READER_MIN_WIDTH
|
|
33
|
-
const lower = Math.min(SIDEBAR_MIN_WIDTH, Math.max(0, ceiling))
|
|
34
|
-
const upper = Math.max(lower, ceiling)
|
|
35
|
-
return Math.max(lower, Math.min(upper, preferred))
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Default preferred sidebar width, derived from viewport until persistent
|
|
40
|
-
* config (#13) provides a user-set value. Matches the previous inline math.
|
|
41
|
-
*/
|
|
42
|
-
export const defaultPreferredWidth = (viewport: number): number =>
|
|
43
|
-
Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, Math.floor(viewport * 0.25)))
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* True when an inline (side-by-side) layout still gives the reader at least
|
|
47
|
-
* READER_MIN_WIDTH. When false, the viewport is "narrow" and the UI runs in
|
|
48
|
-
* single-pane stack mode — sidebar OR reader fills the pane area, never both.
|
|
49
|
-
* See DESIGN.md §7.1.
|
|
50
|
-
*/
|
|
51
|
-
export const canFitInline = (viewport: number): boolean =>
|
|
52
|
-
viewport >= SIDEBAR_MIN_WIDTH + DIVIDER_WIDTH + READER_MIN_WIDTH
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
const HEAD_ELISION_PREFIX = "…"
|
|
2
|
-
|
|
3
|
-
export const fitSidebarEmptyValue = (value: string, width: number): string => {
|
|
4
|
-
if (value.length <= width) return value
|
|
5
|
-
if (width <= 1) return value.slice(value.length - 1)
|
|
6
|
-
return HEAD_ELISION_PREFIX + value.slice(value.length - width + 1)
|
|
7
|
-
}
|
package/src/layout/sidebarRow.ts
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Sidebar row layout — basename-first with a dim parent suffix sized to the
|
|
3
|
-
* available width.
|
|
4
|
-
*
|
|
5
|
-
* Pure per-row formatting: a row's rendered shape depends only on its own
|
|
6
|
-
* path and the column budget — never on neighboring rows. That keeps the
|
|
7
|
-
* sidebar stable as filters change and the file set grows, and keeps the
|
|
8
|
-
* function trivially predictable. Disambiguation against same-basename
|
|
9
|
-
* siblings is the header's job (it shows the full relative path of the
|
|
10
|
-
* selected row); a future auto-scroll on the selected sidebar row can carry
|
|
11
|
-
* the same information without altering layout for the rest.
|
|
12
|
-
*
|
|
13
|
-
* Truncation policy: the basename stays whole when possible; the parent path
|
|
14
|
-
* middle-truncates into `a/…/z`-style output once it exceeds the remaining
|
|
15
|
-
* width. This preserves both the start and the end of the path, which is
|
|
16
|
-
* usually what users need to disambiguate nested docs.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { middleTruncate } from "../ui/middleTruncate.ts"
|
|
20
|
-
|
|
21
|
-
export const SIDEBAR_ROW_SEPARATOR = " "
|
|
22
|
-
const MIN_PARENT_BUDGET = 3
|
|
23
|
-
|
|
24
|
-
export interface SidebarRowParts {
|
|
25
|
-
readonly basename: string
|
|
26
|
-
readonly separator: string
|
|
27
|
-
readonly parent: string
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export const formatSidebarRow = (relativePath: string, totalWidth: number): SidebarRowParts => {
|
|
31
|
-
const slash = relativePath.lastIndexOf("/")
|
|
32
|
-
if (slash < 0) {
|
|
33
|
-
return { basename: fitTail(relativePath, totalWidth), separator: "", parent: "" }
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const basename = relativePath.slice(slash + 1)
|
|
37
|
-
const parentFull = relativePath.slice(0, slash)
|
|
38
|
-
|
|
39
|
-
if (basename.length >= totalWidth) {
|
|
40
|
-
return { basename: fitTail(basename, totalWidth), separator: "", parent: "" }
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const remaining = totalWidth - basename.length - SIDEBAR_ROW_SEPARATOR.length
|
|
44
|
-
if (remaining < MIN_PARENT_BUDGET || parentFull.length === 0) {
|
|
45
|
-
return { basename, separator: "", parent: "" }
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
if (parentFull.length <= remaining) {
|
|
49
|
-
return row(basename, parentFull)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
return row(basename, middleTruncate(parentFull, remaining))
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const row = (basename: string, parent: string): SidebarRowParts => ({
|
|
56
|
-
basename,
|
|
57
|
-
separator: SIDEBAR_ROW_SEPARATOR,
|
|
58
|
-
parent,
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
const fitTail = (s: string, width: number): string => {
|
|
62
|
-
if (s.length <= width) return s
|
|
63
|
-
// At width ≤ 1 there's no room for both a character and the ellipsis; emit
|
|
64
|
-
// a single char so the result respects the budget. Callers currently clamp
|
|
65
|
-
// width to ≥ 4, but the helper carries its own floor so a tighter future
|
|
66
|
-
// caller can't silently overflow the column.
|
|
67
|
-
if (width <= 1) return s.slice(0, 1)
|
|
68
|
-
return s.slice(0, width - 1) + "…"
|
|
69
|
-
}
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
export interface FrontmatterField {
|
|
2
|
-
readonly key: string
|
|
3
|
-
readonly value: string
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
export interface FrontmatterRenderModel {
|
|
7
|
-
readonly body: string
|
|
8
|
-
readonly fields: readonly FrontmatterField[]
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const FRONTMATTER_OPEN = "---"
|
|
12
|
-
const FRONTMATTER_CLOSE = "---"
|
|
13
|
-
|
|
14
|
-
const normalizeValue = (raw: string): string => {
|
|
15
|
-
const value = raw.trim()
|
|
16
|
-
if (
|
|
17
|
-
(value.startsWith('"') && value.endsWith('"')) ||
|
|
18
|
-
(value.startsWith("'") && value.endsWith("'"))
|
|
19
|
-
) {
|
|
20
|
-
return value.slice(1, -1)
|
|
21
|
-
}
|
|
22
|
-
return value
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const formatKey = (key: string): string => key.replace(/[-_]+/g, " ")
|
|
26
|
-
|
|
27
|
-
const toDisplayField = (key: string, value: string): FrontmatterField => ({
|
|
28
|
-
key: formatKey(key),
|
|
29
|
-
value: normalizeValue(value),
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
export const parseFrontmatter = (content: string): FrontmatterRenderModel => {
|
|
33
|
-
if (
|
|
34
|
-
!content.startsWith(`${FRONTMATTER_OPEN}\n`) &&
|
|
35
|
-
!content.startsWith(`${FRONTMATTER_OPEN}\r\n`)
|
|
36
|
-
) {
|
|
37
|
-
return { body: content, fields: [] }
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const lines = content.split(/\r?\n/)
|
|
41
|
-
if (lines[0] !== FRONTMATTER_OPEN) return { body: content, fields: [] }
|
|
42
|
-
|
|
43
|
-
const closeIndex = lines.indexOf(FRONTMATTER_CLOSE, 1)
|
|
44
|
-
if (closeIndex <= 0) return { body: content, fields: [] }
|
|
45
|
-
|
|
46
|
-
const fields: FrontmatterField[] = []
|
|
47
|
-
for (const line of lines.slice(1, closeIndex)) {
|
|
48
|
-
if (line.trim().length === 0) continue
|
|
49
|
-
const colon = line.indexOf(":")
|
|
50
|
-
if (colon <= 0) return { body: content, fields: [] }
|
|
51
|
-
const key = line.slice(0, colon).trim()
|
|
52
|
-
const value = line.slice(colon + 1)
|
|
53
|
-
if (key.length === 0) return { body: content, fields: [] }
|
|
54
|
-
fields.push(toDisplayField(key, value))
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const body = lines
|
|
58
|
-
.slice(closeIndex + 1)
|
|
59
|
-
.join("\n")
|
|
60
|
-
.replace(/^\n+/, "")
|
|
61
|
-
return { body, fields }
|
|
62
|
-
}
|
package/src/serve/css.ts
DELETED
|
@@ -1,120 +0,0 @@
|
|
|
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()
|
package/src/serve/openBrowser.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
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
|
-
}
|
package/src/serve/render.ts
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
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 "&"
|
|
17
|
-
case "<":
|
|
18
|
-
return "<"
|
|
19
|
-
case ">":
|
|
20
|
-
return ">"
|
|
21
|
-
case '"':
|
|
22
|
-
return """
|
|
23
|
-
default:
|
|
24
|
-
return "'"
|
|
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
|
-
}
|
package/src/serve/server.ts
DELETED
|
@@ -1,166 +0,0 @@
|
|
|
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, server) {
|
|
90
|
-
const url = new URL(req.url)
|
|
91
|
-
if (url.pathname === "/__reload") {
|
|
92
|
-
// SSE stream is silent between file changes; without this Bun
|
|
93
|
-
// closes the request at the default 10s idleTimeout and warns.
|
|
94
|
-
server.timeout(req, 0)
|
|
95
|
-
// `cancel` receives a reason, not the controller — capture
|
|
96
|
-
// the controller in `start` so we can remove it from the set
|
|
97
|
-
// on disconnect. Without this, dead clients accumulate.
|
|
98
|
-
let ctrl: ReloadController | null = null
|
|
99
|
-
const stream = new ReadableStream<Uint8Array>({
|
|
100
|
-
start(controller) {
|
|
101
|
-
ctrl = controller
|
|
102
|
-
clients.add(controller)
|
|
103
|
-
// Initial comment keeps some proxies from buffering.
|
|
104
|
-
controller.enqueue(encoder.encode(": connected\n\n"))
|
|
105
|
-
},
|
|
106
|
-
cancel() {
|
|
107
|
-
if (ctrl) clients.delete(ctrl)
|
|
108
|
-
},
|
|
109
|
-
})
|
|
110
|
-
return new Response(stream, {
|
|
111
|
-
headers: {
|
|
112
|
-
"content-type": "text/event-stream",
|
|
113
|
-
"cache-control": "no-cache",
|
|
114
|
-
connection: "keep-alive",
|
|
115
|
-
},
|
|
116
|
-
})
|
|
117
|
-
}
|
|
118
|
-
if (url.pathname !== "/") {
|
|
119
|
-
return new Response("not found", { status: 404 })
|
|
120
|
-
}
|
|
121
|
-
try {
|
|
122
|
-
const md = await readFile(target, "utf8")
|
|
123
|
-
const html = renderHtml(md, basename(target))
|
|
124
|
-
return new Response(html, {
|
|
125
|
-
headers: {
|
|
126
|
-
"content-type": "text/html; charset=utf-8",
|
|
127
|
-
"cache-control": "no-store",
|
|
128
|
-
},
|
|
129
|
-
})
|
|
130
|
-
} catch (err) {
|
|
131
|
-
return new Response(`cannot read ${target}: ${String(err)}`, {
|
|
132
|
-
status: 500,
|
|
133
|
-
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
134
|
-
})
|
|
135
|
-
}
|
|
136
|
-
},
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
const url = `http://localhost:${server.port}`
|
|
140
|
-
|
|
141
|
-
return {
|
|
142
|
-
url,
|
|
143
|
-
currentTarget: () => target,
|
|
144
|
-
setTarget: (next) => {
|
|
145
|
-
if (next === target) {
|
|
146
|
-
broadcastReload()
|
|
147
|
-
return
|
|
148
|
-
}
|
|
149
|
-
target = next
|
|
150
|
-
startWatching(next)
|
|
151
|
-
broadcastReload()
|
|
152
|
-
},
|
|
153
|
-
stop: async () => {
|
|
154
|
-
watcher?.close()
|
|
155
|
-
for (const c of clients) {
|
|
156
|
-
try {
|
|
157
|
-
c.close()
|
|
158
|
-
} catch {
|
|
159
|
-
// already closed
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
clients.clear()
|
|
163
|
-
await server.stop(true)
|
|
164
|
-
},
|
|
165
|
-
}
|
|
166
|
-
}
|
package/src/theme/atom.ts
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* themeAtom — signals the active theme identity to React.
|
|
3
|
-
*
|
|
4
|
-
* Components read this atom only to subscribe to re-renders; they pull the
|
|
5
|
-
* actual token values from the `colors` singleton (mutated by
|
|
6
|
-
* `setActiveTheme` before the re-render fires).
|
|
7
|
-
*
|
|
8
|
-
* Shape: `{ id, tone }` so that a future theme picker can write both fields
|
|
9
|
-
* atomically and the component sees a single re-render.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import * as Atom from "effect/unstable/reactivity/Atom"
|
|
13
|
-
import type { Tone } from "./types.ts"
|
|
14
|
-
|
|
15
|
-
export interface ThemeState {
|
|
16
|
-
readonly id: string
|
|
17
|
-
readonly tone: Tone
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export const themeAtom: Atom.Writable<ThemeState, ThemeState> = Atom.make<ThemeState>({
|
|
21
|
-
id: "opencode",
|
|
22
|
-
tone: "dark",
|
|
23
|
-
})
|
package/src/theme/colors.ts
DELETED
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import type { StyleDefinitionInput } from "@opentui/core"
|
|
2
|
-
import { resolveTheme } from "./resolve.ts"
|
|
3
|
-
import type { ColorPalette, ResolvedTheme, ThemeDefinition, Tone } from "./types.ts"
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Adapt a resolved theme (opencode-shaped flat tokens) to the
|
|
7
|
-
* {@link ColorPalette} shape consumed by Browser / HelpOverlay / index.
|
|
8
|
-
*
|
|
9
|
-
* - UI tokens map name-for-name with OpenCode's TUI theme semantics.
|
|
10
|
-
* - `syntax` is a fully populated opentui tree-sitter scope map built from
|
|
11
|
-
* `markdown*` and `syntax*` tokens.
|
|
12
|
-
*/
|
|
13
|
-
const buildPalette = (r: ResolvedTheme): ColorPalette => {
|
|
14
|
-
return {
|
|
15
|
-
background: r.background,
|
|
16
|
-
backgroundPanel: r.backgroundPanel,
|
|
17
|
-
backgroundElement: r.backgroundElement,
|
|
18
|
-
text: r.text,
|
|
19
|
-
textMuted: r.textMuted,
|
|
20
|
-
border: r.border,
|
|
21
|
-
borderActive: r.borderActive,
|
|
22
|
-
borderSubtle: r.borderSubtle,
|
|
23
|
-
selectedListItemText: r.selectedListItemText,
|
|
24
|
-
primary: r.primary,
|
|
25
|
-
secondary: r.secondary,
|
|
26
|
-
accent: r.accent,
|
|
27
|
-
error: r.error,
|
|
28
|
-
warning: r.warning,
|
|
29
|
-
success: r.success,
|
|
30
|
-
info: r.info,
|
|
31
|
-
syntax: buildSyntaxMap(r),
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const buildSyntaxMap = (r: ResolvedTheme): Record<string, StyleDefinitionInput> => {
|
|
36
|
-
const codeBg = r.backgroundPanel
|
|
37
|
-
return {
|
|
38
|
-
keyword: { fg: r.syntaxKeyword, bold: true },
|
|
39
|
-
string: { fg: r.syntaxString },
|
|
40
|
-
comment: { fg: r.syntaxComment, italic: true },
|
|
41
|
-
number: { fg: r.syntaxNumber },
|
|
42
|
-
function: { fg: r.syntaxFunction },
|
|
43
|
-
type: { fg: r.syntaxType },
|
|
44
|
-
operator: { fg: r.syntaxOperator },
|
|
45
|
-
variable: { fg: r.syntaxVariable },
|
|
46
|
-
property: { fg: r.syntaxFunction },
|
|
47
|
-
"punctuation.bracket": { fg: r.syntaxPunctuation },
|
|
48
|
-
"punctuation.delimiter": { fg: r.syntaxPunctuation },
|
|
49
|
-
"punctuation.special": { fg: r.syntaxPunctuation },
|
|
50
|
-
"markup.heading": { fg: r.markdownHeading, bold: true },
|
|
51
|
-
"markup.heading.1": { fg: r.markdownHeading, bold: true, underline: true },
|
|
52
|
-
"markup.heading.2": { fg: r.markdownHeading, bold: true },
|
|
53
|
-
"markup.heading.3": { fg: r.markdownHeading },
|
|
54
|
-
"markup.bold": { fg: r.markdownStrong, bold: true },
|
|
55
|
-
"markup.strong": { fg: r.markdownStrong, bold: true },
|
|
56
|
-
"markup.italic": { fg: r.markdownEmph, italic: true },
|
|
57
|
-
// opentui's SyntaxStyle has no strikethrough attribute — best-effort:
|
|
58
|
-
// dim + muted color so `~~strike~~` is at least visually distinguishable.
|
|
59
|
-
"markup.strikethrough": { fg: r.textMuted, dim: true },
|
|
60
|
-
"markup.list": { fg: r.markdownListItem },
|
|
61
|
-
"markup.quote": { fg: r.markdownBlockQuote, italic: true },
|
|
62
|
-
"markup.raw": { fg: r.markdownCode, bg: codeBg },
|
|
63
|
-
"markup.raw.block": { fg: r.markdownCodeBlock, bg: codeBg },
|
|
64
|
-
"markup.raw.inline": { fg: r.markdownCode, bg: codeBg },
|
|
65
|
-
"markup.link": { fg: r.markdownLink, underline: true },
|
|
66
|
-
"markup.link.label": { fg: r.markdownLinkText, underline: true },
|
|
67
|
-
"markup.link.url": { fg: r.markdownLink, underline: true },
|
|
68
|
-
label: { fg: r.markdownListItem },
|
|
69
|
-
conceal: { fg: r.borderSubtle },
|
|
70
|
-
default: { fg: r.markdownText },
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Active flat palette. Components import `colors` and read tokens
|
|
76
|
-
* directly. `setActiveTheme` mutates this in place so the reference stays
|
|
77
|
-
* stable (no Provider/context).
|
|
78
|
-
*
|
|
79
|
-
* Initial value uses the all-fallback resolution; `setActiveTheme` is
|
|
80
|
-
* called at boot from `index.tsx` before the React tree mounts.
|
|
81
|
-
*/
|
|
82
|
-
export const colors: ColorPalette = buildPalette(resolveTheme({ theme: {} }, "dark"))
|
|
83
|
-
|
|
84
|
-
export const setActiveTheme = (definition: ThemeDefinition, tone: Tone): void => {
|
|
85
|
-
Object.assign(colors, buildPalette(resolveTheme(definition.source, tone)))
|
|
86
|
-
}
|