@carlesandres/house 0.4.0 → 0.4.1
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 +47 -137
- package/README.md +15 -15
- package/package.json +1 -1
- package/src/Browser.tsx +212 -134
- package/src/CommandPalette.tsx +70 -46
- package/src/Footer.tsx +66 -26
- package/src/Header.tsx +68 -0
- package/src/HelpOverlay.tsx +59 -41
- package/src/brand.ts +7 -0
- package/src/cli/argv.ts +34 -3
- package/src/config/load.ts +84 -19
- package/src/discovery/walk.ts +8 -3
- package/src/index.tsx +64 -11
- package/src/layout/resolve.ts +9 -9
- package/src/theme/colors.ts +51 -15
- package/src/theme/types.ts +7 -0
- package/src/update/cache.ts +77 -0
- package/src/update/check.ts +165 -0
- package/src/update/compare.ts +41 -0
- package/src/update/notice.ts +29 -0
- package/src/update/runtime.ts +48 -0
- package/src/update/useUpdateNotice.ts +24 -0
package/src/Footer.tsx
CHANGED
|
@@ -71,30 +71,40 @@ const displayKey = (raw: string): string => {
|
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
/** Hint row entries. `key === null` is a standalone chip (e.g. the filter
|
|
75
|
+
* chip) and renders as muted text without the key/label split. */
|
|
76
|
+
interface Hint {
|
|
77
|
+
readonly key: string | null
|
|
78
|
+
readonly label: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const hintWidth = (h: Hint): number =>
|
|
82
|
+
h.key === null ? h.label.length : h.key.length + 1 + h.label.length // key + " " + label
|
|
83
|
+
|
|
84
|
+
const formatHint = <C,>(b: KeyBinding<C>): Hint | null => {
|
|
75
85
|
if (!b.hint) return null
|
|
76
86
|
const first = b.keys[0]
|
|
77
87
|
if (!first) return null
|
|
78
|
-
return
|
|
88
|
+
return { key: displayKey(first), label: b.hint }
|
|
79
89
|
}
|
|
80
90
|
|
|
81
|
-
/** Drop hints from the end until
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
let
|
|
91
|
+
/** Drop hints from the end until they fit within `width`. If not even the
|
|
92
|
+
* first hint fits, fall back to the bare key (or label chip) truncated to
|
|
93
|
+
* width, so the row is never silently blank on tight viewports. */
|
|
94
|
+
const fitHints = (hints: readonly Hint[], width: number): Hint[] => {
|
|
95
|
+
if (width <= 0 || hints.length === 0) return []
|
|
96
|
+
const acc: Hint[] = []
|
|
97
|
+
let used = 0
|
|
88
98
|
for (const h of hints) {
|
|
89
|
-
const
|
|
90
|
-
if (
|
|
91
|
-
acc
|
|
99
|
+
const add = acc.length === 0 ? hintWidth(h) : HINT_SEPARATOR.length + hintWidth(h)
|
|
100
|
+
if (used + add > width) break
|
|
101
|
+
acc.push(h)
|
|
102
|
+
used += add
|
|
92
103
|
}
|
|
93
104
|
if (acc.length > 0) return acc
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
return firstKey.slice(0, width)
|
|
105
|
+
const first = hints[0]!
|
|
106
|
+
if (first.key === null) return [{ key: null, label: first.label.slice(0, width) }]
|
|
107
|
+
return [{ key: first.key.slice(0, width), label: "" }]
|
|
98
108
|
}
|
|
99
109
|
|
|
100
110
|
const STATUS_SEPARATOR = " · "
|
|
@@ -116,16 +126,16 @@ export const Footer = <C,>({
|
|
|
116
126
|
flexDirection: "row",
|
|
117
127
|
paddingLeft: 1,
|
|
118
128
|
paddingRight: 1,
|
|
119
|
-
backgroundColor: colors.
|
|
129
|
+
backgroundColor: colors.surface,
|
|
120
130
|
} as const
|
|
121
131
|
|
|
122
|
-
const hints:
|
|
132
|
+
const hints: Hint[] = []
|
|
123
133
|
// The filter chip prepends to the hint row when a filter is applied and the
|
|
124
134
|
// input is closed. Bracketed to avoid looking like a `key:hint` binding —
|
|
125
135
|
// "filter" is not a key. Surfaces the otherwise-invisible invariant that
|
|
126
136
|
// `[`/`]` walks the filtered set. See DESIGN.md §7.1 Q1.
|
|
127
137
|
if (filterQuery && filterQuery.length > 0) {
|
|
128
|
-
hints.push(`[filter: ${filterQuery}]`)
|
|
138
|
+
hints.push({ key: null, label: `[filter: ${filterQuery}]` })
|
|
129
139
|
}
|
|
130
140
|
for (const b of bindings) {
|
|
131
141
|
if (b.when && !b.when(ctx)) continue
|
|
@@ -139,7 +149,7 @@ export const Footer = <C,>({
|
|
|
139
149
|
const status = discoveryStatus && discoveryStatus.length > 0 ? discoveryStatus : null
|
|
140
150
|
const statusBudget = status ? Math.min(status.length + STATUS_SEPARATOR.length, usableWidth) : 0
|
|
141
151
|
const hintsWidth = Math.max(0, usableWidth - statusBudget)
|
|
142
|
-
const
|
|
152
|
+
const visibleHints = fitHints(hints, hintsWidth)
|
|
143
153
|
const statusContent = status
|
|
144
154
|
? status.slice(0, Math.max(0, statusBudget - STATUS_SEPARATOR.length))
|
|
145
155
|
: ""
|
|
@@ -150,6 +160,40 @@ export const Footer = <C,>({
|
|
|
150
160
|
: notice
|
|
151
161
|
: null
|
|
152
162
|
|
|
163
|
+
// Two-tone hint row: keys render in `text` (foreground-strength), the
|
|
164
|
+
// `:label` portion in `textMuted`. Matches ghui's footer treatment so
|
|
165
|
+
// the key — the actionable token — visually leads each hint.
|
|
166
|
+
const renderHints = () =>
|
|
167
|
+
visibleHints.flatMap((h, i) => {
|
|
168
|
+
const sep =
|
|
169
|
+
i > 0
|
|
170
|
+
? [
|
|
171
|
+
<text
|
|
172
|
+
key={`s${i}`}
|
|
173
|
+
content={HINT_SEPARATOR}
|
|
174
|
+
wrapMode="none"
|
|
175
|
+
style={{ fg: colors.textMuted }}
|
|
176
|
+
/>,
|
|
177
|
+
]
|
|
178
|
+
: []
|
|
179
|
+
if (h.key === null) {
|
|
180
|
+
return [
|
|
181
|
+
...sep,
|
|
182
|
+
<text key={`l${i}`} content={h.label} wrapMode="none" style={{ fg: colors.textMuted }} />,
|
|
183
|
+
]
|
|
184
|
+
}
|
|
185
|
+
return [
|
|
186
|
+
...sep,
|
|
187
|
+
<text key={`k${i}`} content={h.key} wrapMode="none" style={{ fg: colors.text }} />,
|
|
188
|
+
<text
|
|
189
|
+
key={`l${i}`}
|
|
190
|
+
content={` ${h.label}`}
|
|
191
|
+
wrapMode="none"
|
|
192
|
+
style={{ fg: colors.textMuted }}
|
|
193
|
+
/>,
|
|
194
|
+
]
|
|
195
|
+
})
|
|
196
|
+
|
|
153
197
|
// Priority: notice > (status + hints). Notice fg is strong; status sits
|
|
154
198
|
// at the muted level so it reads as ambient state, not an event.
|
|
155
199
|
if (noticeContent !== null) {
|
|
@@ -165,14 +209,10 @@ export const Footer = <C,>({
|
|
|
165
209
|
<box style={rowStyle}>
|
|
166
210
|
<text content={statusContent} wrapMode="none" style={{ fg: colors.textMuted }} />
|
|
167
211
|
<text content={STATUS_SEPARATOR} wrapMode="none" style={{ fg: colors.textMuted }} />
|
|
168
|
-
|
|
212
|
+
{renderHints()}
|
|
169
213
|
</box>
|
|
170
214
|
)
|
|
171
215
|
}
|
|
172
216
|
|
|
173
|
-
return (
|
|
174
|
-
<box style={rowStyle}>
|
|
175
|
-
<text content={hintContent} wrapMode="none" style={{ fg: colors.textMuted }} />
|
|
176
|
-
</box>
|
|
177
|
-
)
|
|
217
|
+
return <box style={rowStyle}>{renderHints()}</box>
|
|
178
218
|
}
|
package/src/Header.tsx
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Header — single-row chrome above the two-pane area.
|
|
3
|
+
*
|
|
4
|
+
* Borderless single line modeled on ghui's PlainLine header: brand and
|
|
5
|
+
* current filename on the left, version on the right. The row is
|
|
6
|
+
* informational, not interactive — see issue #38 for the design discussion.
|
|
7
|
+
*
|
|
8
|
+
* Width degrades gracefully: the version drops first when the row gets
|
|
9
|
+
* tight, then the filename, leaving the brand mark as the irreducible
|
|
10
|
+
* identity element. Always rendered — the row is worth one cell on any
|
|
11
|
+
* viewport so the user never loses the filename indicator (notably, when
|
|
12
|
+
* the sidebar drawer overlays the reader on narrow viewports).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import pkg from "../package.json" with { type: "json" }
|
|
16
|
+
import { BRAND, BRAND_NAME } from "./brand.ts"
|
|
17
|
+
import { colors } from "./theme/colors.ts"
|
|
18
|
+
|
|
19
|
+
export const HEADER_HEIGHT = 1
|
|
20
|
+
|
|
21
|
+
const FILE_SEPARATOR = " · "
|
|
22
|
+
|
|
23
|
+
export interface HeaderProps {
|
|
24
|
+
readonly width: number
|
|
25
|
+
/** Currently selected file's relative path. When set, the Header shows
|
|
26
|
+
* it next to the brand mark — replaces the per-pane border title that
|
|
27
|
+
* used to carry this information. */
|
|
28
|
+
readonly currentFile?: string | null
|
|
29
|
+
/** Optional override for the version string (testing). Defaults to
|
|
30
|
+
* the running package's version. */
|
|
31
|
+
readonly version?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const Header = ({ width, currentFile, version = pkg.version }: HeaderProps) => {
|
|
35
|
+
const brand = `${BRAND} ${BRAND_NAME}`
|
|
36
|
+
const right = `v${version}`
|
|
37
|
+
const file = currentFile && currentFile.length > 0 ? currentFile : null
|
|
38
|
+
const usableWidth = Math.max(0, width - 2) // 1-cell horizontal padding each side
|
|
39
|
+
|
|
40
|
+
// Priority: brand > filename > version. Brand is the irreducible identity
|
|
41
|
+
// anchor. Filename is per-selection useful info — keep it before the
|
|
42
|
+
// largely-static version string. `1` is the minimum gap between left and
|
|
43
|
+
// right groups so they never visually collide.
|
|
44
|
+
const leftWithFile = file !== null ? `${brand}${FILE_SEPARATOR}${file}` : brand
|
|
45
|
+
const showFileWithVersion = leftWithFile.length + 1 + right.length <= usableWidth
|
|
46
|
+
const showFileWithoutVersion = leftWithFile.length <= usableWidth
|
|
47
|
+
const showFile = file !== null && (showFileWithVersion || showFileWithoutVersion)
|
|
48
|
+
const left = showFile ? leftWithFile : brand
|
|
49
|
+
const showRight = left.length + 1 + right.length <= usableWidth
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<box
|
|
53
|
+
style={{
|
|
54
|
+
width,
|
|
55
|
+
height: HEADER_HEIGHT,
|
|
56
|
+
flexShrink: 0,
|
|
57
|
+
flexDirection: "row",
|
|
58
|
+
justifyContent: "space-between",
|
|
59
|
+
paddingLeft: 1,
|
|
60
|
+
paddingRight: 1,
|
|
61
|
+
backgroundColor: colors.surface,
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
<text content={left} wrapMode="none" style={{ fg: colors.text }} />
|
|
65
|
+
{showRight && <text content={right} wrapMode="none" style={{ fg: colors.text }} />}
|
|
66
|
+
</box>
|
|
67
|
+
)
|
|
68
|
+
}
|
package/src/HelpOverlay.tsx
CHANGED
|
@@ -6,9 +6,13 @@
|
|
|
6
6
|
* keys — the dispatcher and the help text are the same source of truth.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { RGBA } from "@opentui/core"
|
|
9
10
|
import type { KeyBinding } from "./keymap/keymap.ts"
|
|
10
11
|
import { colors } from "./theme/colors.ts"
|
|
11
12
|
|
|
13
|
+
// See CommandPalette.tsx for the scrim rationale.
|
|
14
|
+
const SCRIM = RGBA.fromInts(0, 0, 0, 150)
|
|
15
|
+
|
|
12
16
|
export interface HelpOverlayProps<C> {
|
|
13
17
|
readonly bindings: readonly KeyBinding<C>[]
|
|
14
18
|
readonly viewportWidth: number
|
|
@@ -82,49 +86,63 @@ export const HelpOverlay = <C,>({
|
|
|
82
86
|
return (
|
|
83
87
|
<box
|
|
84
88
|
position="absolute"
|
|
85
|
-
left={
|
|
86
|
-
top={
|
|
87
|
-
width={
|
|
88
|
-
height={
|
|
89
|
+
left={0}
|
|
90
|
+
top={0}
|
|
91
|
+
width={viewportWidth}
|
|
92
|
+
height={viewportHeight}
|
|
89
93
|
zIndex={10}
|
|
90
|
-
|
|
91
|
-
titleAlignment="left"
|
|
92
|
-
style={{
|
|
93
|
-
border: true,
|
|
94
|
-
borderColor: colors.borderActive,
|
|
95
|
-
padding: 1,
|
|
96
|
-
flexDirection: "column",
|
|
97
|
-
backgroundColor: colors.surface,
|
|
98
|
-
}}
|
|
94
|
+
style={{ backgroundColor: SCRIM }}
|
|
99
95
|
>
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
96
|
+
<box
|
|
97
|
+
position="absolute"
|
|
98
|
+
left={left}
|
|
99
|
+
top={top}
|
|
100
|
+
width={overlayWidth}
|
|
101
|
+
height={overlayHeight}
|
|
102
|
+
title=" Help "
|
|
103
|
+
titleAlignment="left"
|
|
104
|
+
style={{
|
|
105
|
+
border: true,
|
|
106
|
+
borderColor: colors.textMuted,
|
|
107
|
+
padding: 1,
|
|
108
|
+
flexDirection: "column",
|
|
109
|
+
backgroundColor: colors.surface,
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
{rows.map((row) => {
|
|
113
|
+
switch (row.kind) {
|
|
114
|
+
case "header":
|
|
115
|
+
return (
|
|
116
|
+
<text
|
|
117
|
+
key={row.key}
|
|
118
|
+
wrapMode="none"
|
|
119
|
+
content={row.text}
|
|
120
|
+
style={{ fg: colors.borderActive, attributes: 1 /* bold */ }}
|
|
121
|
+
/>
|
|
122
|
+
)
|
|
123
|
+
case "footer":
|
|
124
|
+
return (
|
|
125
|
+
<text
|
|
126
|
+
key={row.key}
|
|
127
|
+
wrapMode="none"
|
|
128
|
+
content={row.text}
|
|
129
|
+
style={{ fg: colors.textMuted }}
|
|
130
|
+
/>
|
|
131
|
+
)
|
|
132
|
+
case "spacer":
|
|
133
|
+
return <text key={row.key} content=" " />
|
|
134
|
+
case "binding":
|
|
135
|
+
return (
|
|
136
|
+
<text
|
|
137
|
+
key={row.key}
|
|
138
|
+
wrapMode="none"
|
|
139
|
+
content={row.text}
|
|
140
|
+
style={{ fg: colors.text }}
|
|
141
|
+
/>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
})}
|
|
145
|
+
</box>
|
|
128
146
|
</box>
|
|
129
147
|
)
|
|
130
148
|
}
|
package/src/brand.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brand mark. U+2302 HOUSE is a single-cell glyph in every monospace font
|
|
3
|
+
* we care about; falls back to a tofu box on rare fonts that lack it.
|
|
4
|
+
* Single import point so future placements (#76) stay in sync.
|
|
5
|
+
*/
|
|
6
|
+
export const BRAND = "⌂"
|
|
7
|
+
export const BRAND_NAME = "house"
|
package/src/cli/argv.ts
CHANGED
|
@@ -25,6 +25,12 @@ export interface ParsedArgs {
|
|
|
25
25
|
readonly configPath: boolean
|
|
26
26
|
/** Value of `--sidebar <mode>` (`auto`, `on`, `off`), or null. Validated by the boot layer. */
|
|
27
27
|
readonly sidebar: string | null
|
|
28
|
+
/** True when `--no-update-check` was passed: suppress the npm-registry
|
|
29
|
+
* probe and the "update available" notice. Mirrors the
|
|
30
|
+
* `NO_UPDATE_NOTIFIER` env var so opt-out is reachable without env state. */
|
|
31
|
+
readonly noUpdateCheck: boolean
|
|
32
|
+
/** True when `--no-mdx` was passed: exclude `.mdx` files from discovery. */
|
|
33
|
+
readonly noMdx: boolean
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
/**
|
|
@@ -47,6 +53,8 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
|
|
|
47
53
|
let version = false
|
|
48
54
|
let configPath = false
|
|
49
55
|
let sidebar: string | null = null
|
|
56
|
+
let noUpdateCheck = false
|
|
57
|
+
let noMdx = false
|
|
50
58
|
|
|
51
59
|
for (let i = 0; i < argv.length; i++) {
|
|
52
60
|
const arg = argv[i]!
|
|
@@ -99,13 +107,34 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
|
|
|
99
107
|
}
|
|
100
108
|
continue
|
|
101
109
|
}
|
|
110
|
+
case "--no-update-check":
|
|
111
|
+
noUpdateCheck = true
|
|
112
|
+
continue
|
|
113
|
+
case "--no-mdx":
|
|
114
|
+
noMdx = true
|
|
115
|
+
continue
|
|
102
116
|
}
|
|
103
117
|
if (path === null && !arg.startsWith("-")) {
|
|
104
118
|
path = arg
|
|
105
119
|
}
|
|
106
120
|
}
|
|
107
121
|
|
|
108
|
-
return {
|
|
122
|
+
return {
|
|
123
|
+
path,
|
|
124
|
+
theme,
|
|
125
|
+
tone,
|
|
126
|
+
width,
|
|
127
|
+
all,
|
|
128
|
+
sort,
|
|
129
|
+
serve,
|
|
130
|
+
port,
|
|
131
|
+
help,
|
|
132
|
+
version,
|
|
133
|
+
configPath,
|
|
134
|
+
sidebar,
|
|
135
|
+
noUpdateCheck,
|
|
136
|
+
noMdx,
|
|
137
|
+
}
|
|
109
138
|
}
|
|
110
139
|
|
|
111
140
|
const themeList = themeDefinitions.map((t) => t.id).join(", ")
|
|
@@ -126,9 +155,11 @@ options:
|
|
|
126
155
|
-h, --help show this help and exit
|
|
127
156
|
-v, --version print version and exit
|
|
128
157
|
--config-path print path to the config file and exit
|
|
158
|
+
--no-update-check suppress the "newer version available" check (also via NO_UPDATE_NOTIFIER=1)
|
|
159
|
+
--no-mdx exclude .mdx files from discovery (default: included)
|
|
129
160
|
|
|
130
161
|
configuration:
|
|
131
162
|
file: $XDG_CONFIG_HOME/house/config.toml (default ~/.config/house/config.toml)
|
|
132
|
-
keys: theme, tone
|
|
133
|
-
env: HOUSE_THEME, HOUSE_TONE
|
|
163
|
+
keys: theme, tone, mdx
|
|
164
|
+
env: HOUSE_THEME, HOUSE_TONE, HOUSE_MDX
|
|
134
165
|
precedence (high → low): flags → env → file → defaults`
|
package/src/config/load.ts
CHANGED
|
@@ -18,39 +18,96 @@ import { themeDefinitions } from "../theme/registry.ts"
|
|
|
18
18
|
export interface HouseConfig {
|
|
19
19
|
readonly theme: string
|
|
20
20
|
readonly tone: "dark" | "light"
|
|
21
|
+
readonly mdx: boolean
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export interface CliOverrides {
|
|
24
25
|
readonly theme: string | null
|
|
25
26
|
readonly tone: string | null
|
|
27
|
+
readonly mdx: boolean | null
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
const DEFAULT_THEME = "opencode"
|
|
29
31
|
const DEFAULT_TONE: "dark" | "light" = "dark"
|
|
32
|
+
const DEFAULT_MDX = true
|
|
30
33
|
|
|
31
34
|
const themeIds = themeDefinitions.map((t) => t.id)
|
|
32
35
|
|
|
33
36
|
/**
|
|
34
37
|
* Top-level keys the config file is allowed to set. Kept in sync by hand
|
|
35
38
|
* with `schema` below — when adding a key, add it both places.
|
|
36
|
-
* Used by `fileProvider` to
|
|
37
|
-
*
|
|
39
|
+
* Used by `fileProvider` to warn about unrecognized keys (with a
|
|
40
|
+
* did-you-mean hint when one is close) while still loading the rest.
|
|
38
41
|
*/
|
|
39
|
-
const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set(["theme", "tone"])
|
|
42
|
+
const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set(["theme", "tone", "mdx"])
|
|
40
43
|
|
|
41
44
|
const schema = Config.all({
|
|
42
45
|
theme: Config.schema(Schema.Literals(themeIds), "theme"),
|
|
43
46
|
tone: Config.schema(Schema.Literals(["dark", "light"] as const), "tone"),
|
|
47
|
+
// Boolean stored as string literal because providers stringify values
|
|
48
|
+
// (TOML bools, env vars, CLI flags all flow through as text). Mapped to
|
|
49
|
+
// a real boolean in `loadConfig` below.
|
|
50
|
+
mdx: Config.schema(Schema.Literals(["true", "false"] as const), "mdx"),
|
|
44
51
|
})
|
|
45
52
|
|
|
46
53
|
const defaultsProvider = (): ConfigProvider.ConfigProvider =>
|
|
47
|
-
ConfigProvider.fromUnknown({
|
|
54
|
+
ConfigProvider.fromUnknown({
|
|
55
|
+
theme: DEFAULT_THEME,
|
|
56
|
+
tone: DEFAULT_TONE,
|
|
57
|
+
mdx: String(DEFAULT_MDX),
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Levenshtein edit distance, capped at `cap` for early exit.
|
|
62
|
+
* Used only to suggest "did you mean X?" when a config key looks like a
|
|
63
|
+
* typo of a known one. Tiny inputs (≤ ~20 chars), so the naive O(n·m)
|
|
64
|
+
* fill is fine.
|
|
65
|
+
*/
|
|
66
|
+
const editDistance = (a: string, b: string, cap: number): number => {
|
|
67
|
+
if (Math.abs(a.length - b.length) > cap) return cap + 1
|
|
68
|
+
const prev: number[] = Array.from({ length: b.length + 1 })
|
|
69
|
+
const curr: number[] = Array.from({ length: b.length + 1 })
|
|
70
|
+
for (let j = 0; j <= b.length; j++) prev[j] = j
|
|
71
|
+
for (let i = 1; i <= a.length; i++) {
|
|
72
|
+
curr[0] = i
|
|
73
|
+
let rowMin = curr[0]!
|
|
74
|
+
for (let j = 1; j <= b.length; j++) {
|
|
75
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
|
76
|
+
curr[j] = Math.min(prev[j]! + 1, curr[j - 1]! + 1, prev[j - 1]! + cost)
|
|
77
|
+
if (curr[j]! < rowMin) rowMin = curr[j]!
|
|
78
|
+
}
|
|
79
|
+
if (rowMin > cap) return cap + 1
|
|
80
|
+
for (let j = 0; j <= b.length; j++) prev[j] = curr[j]!
|
|
81
|
+
}
|
|
82
|
+
return prev[b.length]!
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const suggestKey = (unknown: string, known: readonly string[]): string | null => {
|
|
86
|
+
let best: { key: string; dist: number } | null = null
|
|
87
|
+
for (const k of known) {
|
|
88
|
+
const d = editDistance(unknown, k, 2)
|
|
89
|
+
if (d <= 2 && (best === null || d < best.dist)) best = { key: k, dist: d }
|
|
90
|
+
}
|
|
91
|
+
return best?.key ?? null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const formatUnknownKeyWarning = (path: string, key: string, known: readonly string[]): string => {
|
|
95
|
+
const suggestion = suggestKey(key, known)
|
|
96
|
+
const hint = suggestion ? ` — did you mean "${suggestion}"?` : ""
|
|
97
|
+
return `house: ignoring unknown key "${key}" in ${path}${hint}`
|
|
98
|
+
}
|
|
48
99
|
|
|
49
100
|
/**
|
|
50
101
|
* Reads a TOML file at `path`. Missing file → `undefined` for every key
|
|
51
|
-
* (per-key fallthrough). Malformed TOML → `SourceError` (hard fail
|
|
102
|
+
* (per-key fallthrough). Malformed TOML → `SourceError` (hard fail
|
|
103
|
+
* upstream). Unknown top-level keys are warned about via `onWarning` and
|
|
104
|
+
* dropped — this preserves forward-compat with newer config schemas while
|
|
105
|
+
* still flagging typos like `them = "..."`.
|
|
52
106
|
*/
|
|
53
|
-
const fileProvider = (
|
|
107
|
+
const fileProvider = (
|
|
108
|
+
path: string,
|
|
109
|
+
onWarning: (message: string) => void,
|
|
110
|
+
): ConfigProvider.ConfigProvider => {
|
|
54
111
|
let cache: { data: Record<string, unknown> | null } | null = null
|
|
55
112
|
const load = Effect.gen(function* () {
|
|
56
113
|
if (cache !== null) return cache.data
|
|
@@ -69,17 +126,17 @@ const fileProvider = (path: string): ConfigProvider.ConfigProvider => {
|
|
|
69
126
|
cause,
|
|
70
127
|
}),
|
|
71
128
|
})
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
129
|
+
const known = [...KNOWN_FILE_KEYS]
|
|
130
|
+
const filtered: Record<string, unknown> = {}
|
|
131
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
132
|
+
if (KNOWN_FILE_KEYS.has(k)) {
|
|
133
|
+
filtered[k] = v
|
|
134
|
+
} else {
|
|
135
|
+
onWarning(formatUnknownKeyWarning(path, k, known))
|
|
136
|
+
}
|
|
80
137
|
}
|
|
81
|
-
cache = { data:
|
|
82
|
-
return
|
|
138
|
+
cache = { data: filtered }
|
|
139
|
+
return filtered
|
|
83
140
|
})
|
|
84
141
|
return ConfigProvider.make((path) =>
|
|
85
142
|
Effect.gen(function* () {
|
|
@@ -112,8 +169,10 @@ const envProvider = (env: Record<string, string | undefined>): ConfigProvider.Co
|
|
|
112
169
|
const entries: Array<[string, string]> = []
|
|
113
170
|
const theme = env["HOUSE_THEME"]
|
|
114
171
|
const tone = env["HOUSE_TONE"]
|
|
172
|
+
const mdx = env["HOUSE_MDX"]
|
|
115
173
|
if (theme !== undefined) entries.push(["theme", theme])
|
|
116
174
|
if (tone !== undefined) entries.push(["tone", tone])
|
|
175
|
+
if (mdx !== undefined) entries.push(["mdx", mdx])
|
|
117
176
|
return ConfigProvider.fromUnknown(Object.fromEntries(entries))
|
|
118
177
|
}
|
|
119
178
|
|
|
@@ -121,6 +180,7 @@ const cliProvider = (overrides: CliOverrides): ConfigProvider.ConfigProvider =>
|
|
|
121
180
|
const entries: Array<[string, string]> = []
|
|
122
181
|
if (overrides.theme !== null) entries.push(["theme", overrides.theme])
|
|
123
182
|
if (overrides.tone !== null) entries.push(["tone", overrides.tone])
|
|
183
|
+
if (overrides.mdx !== null) entries.push(["mdx", String(overrides.mdx)])
|
|
124
184
|
return ConfigProvider.fromUnknown(Object.fromEntries(entries))
|
|
125
185
|
}
|
|
126
186
|
|
|
@@ -130,6 +190,8 @@ export interface LoadOptions {
|
|
|
130
190
|
readonly filePath?: string
|
|
131
191
|
/** Override env (tests). Defaults to `process.env`. */
|
|
132
192
|
readonly env?: Record<string, string>
|
|
193
|
+
/** Sink for non-fatal warnings (unknown keys). Defaults to stderr. */
|
|
194
|
+
readonly onWarning?: (message: string) => void
|
|
133
195
|
}
|
|
134
196
|
|
|
135
197
|
export const defaultConfigPath = (): string =>
|
|
@@ -156,11 +218,14 @@ export const formatConfigError = (err: unknown): string => {
|
|
|
156
218
|
export const loadConfig = (
|
|
157
219
|
options: LoadOptions = {},
|
|
158
220
|
): Effect.Effect<HouseConfig, Config.ConfigError> => {
|
|
159
|
-
const cli = options.cli ?? { theme: null, tone: null }
|
|
221
|
+
const cli = options.cli ?? { theme: null, tone: null, mdx: null }
|
|
222
|
+
const onWarning = options.onWarning ?? ((msg) => process.stderr.write(`${msg}\n`))
|
|
160
223
|
const provider = cliProvider(cli).pipe(
|
|
161
224
|
ConfigProvider.orElse(envProvider(options.env ?? process.env)),
|
|
162
|
-
ConfigProvider.orElse(fileProvider(options.filePath ?? defaultConfigPath())),
|
|
225
|
+
ConfigProvider.orElse(fileProvider(options.filePath ?? defaultConfigPath(), onWarning)),
|
|
163
226
|
ConfigProvider.orElse(defaultsProvider()),
|
|
164
227
|
)
|
|
165
|
-
return schema
|
|
228
|
+
return schema
|
|
229
|
+
.parse(provider)
|
|
230
|
+
.pipe(Effect.map((raw) => ({ theme: raw.theme, tone: raw.tone, mdx: raw.mdx === "true" })))
|
|
166
231
|
}
|
package/src/discovery/walk.ts
CHANGED
|
@@ -19,6 +19,8 @@ export interface WalkOptions {
|
|
|
19
19
|
readonly all?: boolean
|
|
20
20
|
/** Group order within each directory. Default `dirs-first`. */
|
|
21
21
|
readonly sort?: SortOrder
|
|
22
|
+
/** Include `.mdx` files alongside `.md`/`.markdown`. Default `true`. */
|
|
23
|
+
readonly mdx?: boolean
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export class DiscoveryError extends Data.TaggedError("DiscoveryError")<{
|
|
@@ -27,6 +29,7 @@ export class DiscoveryError extends Data.TaggedError("DiscoveryError")<{
|
|
|
27
29
|
}> {}
|
|
28
30
|
|
|
29
31
|
const MARKDOWN_EXTENSIONS = new Set([".md", ".markdown", ".mdx"])
|
|
32
|
+
const MARKDOWN_EXTENSIONS_NO_MDX = new Set([".md", ".markdown"])
|
|
30
33
|
const HARD_SKIP_DIRS = new Set(["node_modules", ".git", ".venv"])
|
|
31
34
|
|
|
32
35
|
interface IgnoreLevel {
|
|
@@ -85,7 +88,7 @@ async function* walkDirGen(
|
|
|
85
88
|
dirPath: string,
|
|
86
89
|
rootPath: string,
|
|
87
90
|
parentLevels: readonly IgnoreLevel[],
|
|
88
|
-
opts: { all: boolean; sort: SortOrder },
|
|
91
|
+
opts: { all: boolean; sort: SortOrder; mdx: boolean },
|
|
89
92
|
signal: AbortSignal,
|
|
90
93
|
): AsyncGenerator<FileEntry, void, void> {
|
|
91
94
|
if (signal.aborted) return
|
|
@@ -119,7 +122,8 @@ async function* walkDirGen(
|
|
|
119
122
|
|
|
120
123
|
if (!entry.isFile()) continue
|
|
121
124
|
if (!opts.all && entry.name.startsWith(".")) continue
|
|
122
|
-
|
|
125
|
+
const allowed = opts.mdx ? MARKDOWN_EXTENSIONS : MARKDOWN_EXTENSIONS_NO_MDX
|
|
126
|
+
if (!allowed.has(extname(entry.name).toLowerCase())) continue
|
|
123
127
|
if (!opts.all && isIgnored(entryPath, false, levels)) continue
|
|
124
128
|
|
|
125
129
|
yield {
|
|
@@ -137,7 +141,7 @@ async function* walkDirGen(
|
|
|
137
141
|
* at its next `signal.aborted` check.
|
|
138
142
|
*
|
|
139
143
|
* Rules (see DESIGN.md §6):
|
|
140
|
-
* - Extensions: `.md`, `.markdown`, `.mdx
|
|
144
|
+
* - Extensions: `.md`, `.markdown`, and `.mdx` (unless `mdx: false`).
|
|
141
145
|
* - Hard skips (always): `node_modules`, `.git`, `.venv`.
|
|
142
146
|
* - Hidden files/dirs (leading `.`) skipped unless `all: true`.
|
|
143
147
|
* - `.gitignore` honored, including nested `.gitignore` files.
|
|
@@ -153,6 +157,7 @@ export const walk = (
|
|
|
153
157
|
const opts = {
|
|
154
158
|
all: options.all ?? false,
|
|
155
159
|
sort: options.sort ?? ("dirs-first" as SortOrder),
|
|
160
|
+
mdx: options.mdx ?? true,
|
|
156
161
|
}
|
|
157
162
|
const controller = new AbortController()
|
|
158
163
|
const iterable: AsyncIterable<FileEntry> = {
|