@carlesandres/house 0.3.1 → 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 +59 -128
- package/README.md +40 -12
- package/package.json +3 -2
- package/src/Browser.tsx +482 -109
- package/src/CommandPalette.tsx +150 -0
- package/src/Footer.tsx +122 -55
- package/src/Header.tsx +68 -0
- package/src/HelpOverlay.tsx +59 -41
- package/src/brand.ts +7 -0
- package/src/cli/argv.ts +61 -2
- package/src/commands/buildCommands.ts +102 -0
- package/src/commands/paletteOnlyCommands.ts +16 -0
- package/src/commands/score.ts +78 -0
- package/src/commands/types.ts +26 -0
- package/src/config/load.ts +231 -0
- package/src/discovery/walk.ts +67 -23
- package/src/index.tsx +163 -32
- package/src/keymap/browser.ts +26 -11
- package/src/layout/resolve.ts +60 -0
- 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
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive palette commands from `browserBindings` plus the annotation map.
|
|
3
|
+
*
|
|
4
|
+
* The annotation map is the *only* hand-written list keyed by binding id:
|
|
5
|
+
* it carries (a) hide flags for bindings that are pure keystroke nav, and
|
|
6
|
+
* (b) title rewrites for bindings whose `description` reads as a help-row
|
|
7
|
+
* entry rather than a palette command. Every other binding is exposed
|
|
8
|
+
* verbatim — its `description` becomes the palette `title`, its first key
|
|
9
|
+
* becomes the `shortcut`.
|
|
10
|
+
*
|
|
11
|
+
* Commands close over the per-render `BrowserCtx`, same shape the keymap
|
|
12
|
+
* dispatcher uses, so the palette and the keymap fire the same action via
|
|
13
|
+
* different surfaces. See #70 design log §list construction (option 3b)
|
|
14
|
+
* for why this beat (a) a fully hand-written palette list and (b) deriving
|
|
15
|
+
* everything via #92's atom-driven registry.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { browserBindings, type BrowserCtx } from "../keymap/browser.ts"
|
|
19
|
+
import { paletteOnlyCommands } from "./paletteOnlyCommands.ts"
|
|
20
|
+
import type { AppCommand } from "./types.ts"
|
|
21
|
+
|
|
22
|
+
interface Annotation {
|
|
23
|
+
/** Override the binding's `description` for palette display. */
|
|
24
|
+
readonly title?: string
|
|
25
|
+
/** Carried for #91's category headers; unused in v1's flat list. */
|
|
26
|
+
readonly category?: string
|
|
27
|
+
/** If true, the binding does not appear in the palette. */
|
|
28
|
+
readonly hidden?: boolean
|
|
29
|
+
readonly keywords?: readonly string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Pure-keystroke nav (j/k/space/b/[/]…) is intentionally hidden — those
|
|
34
|
+
* bindings have no command-shaped meaning. Reader prev/next file (`[`/`]`)
|
|
35
|
+
* and sidebar `open` (Return/l) are the borderline cases from #70 Q6a;
|
|
36
|
+
* hidden in v1, reconsider if users ask. Title rewrites convert
|
|
37
|
+
* help-overlay phrasing ("Toggle sidebar visibility") into imperative
|
|
38
|
+
* palette phrasing ("Toggle sidebar"). See #70 design log §6.
|
|
39
|
+
*/
|
|
40
|
+
const annotations: Record<string, Annotation> = {
|
|
41
|
+
// --- Keep, with title rewrites where the binding description reads awkwardly as a command ---
|
|
42
|
+
quit: { category: "App" },
|
|
43
|
+
"focus.toggle": { title: "Toggle focus", category: "View" },
|
|
44
|
+
"sidebar.toggle": { title: "Toggle sidebar", category: "View" },
|
|
45
|
+
"help.toggle": { title: "Show help", category: "App" },
|
|
46
|
+
"filter.open": { title: "Filter files…", category: "Navigation" },
|
|
47
|
+
"serve.current": { title: "Open in browser", category: "File" },
|
|
48
|
+
"theme.next": { category: "Appearance" },
|
|
49
|
+
"theme.prev": { category: "Appearance" },
|
|
50
|
+
"theme.toneToggle": { title: "Toggle dark/light tone", category: "Appearance" },
|
|
51
|
+
|
|
52
|
+
// --- Hide: pure keystroke navigation (j/k/space/b/g/G…) ---
|
|
53
|
+
"sidebar.down": { hidden: true },
|
|
54
|
+
"sidebar.up": { hidden: true },
|
|
55
|
+
"sidebar.jumpDown": { hidden: true },
|
|
56
|
+
"sidebar.jumpUp": { hidden: true },
|
|
57
|
+
"sidebar.pageDown": { hidden: true },
|
|
58
|
+
"sidebar.pageUp": { hidden: true },
|
|
59
|
+
"sidebar.top": { hidden: true },
|
|
60
|
+
"sidebar.bottom": { hidden: true },
|
|
61
|
+
|
|
62
|
+
// --- Hide: borderline reader nav. `[`/`]` and Return-to-open feel command-shaped
|
|
63
|
+
// but are pure keystroke navigation under the hood. #70 Q6a — reconsider
|
|
64
|
+
// if user feedback expects them in the palette.
|
|
65
|
+
"sidebar.open": { hidden: true },
|
|
66
|
+
"reader.back": { hidden: true },
|
|
67
|
+
"reader.prevFile": { hidden: true },
|
|
68
|
+
"reader.nextFile": { hidden: true },
|
|
69
|
+
|
|
70
|
+
// --- Hide: the palette opener itself shouldn't appear in the palette ---
|
|
71
|
+
"palette.open": { hidden: true },
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build the AppCommand list for a given render. Iterates `browserBindings`
|
|
76
|
+
* in array order (the empty-query palette renders in this order, by design
|
|
77
|
+
* — see #70 design log §empty-state ordering), drops hidden entries and
|
|
78
|
+
* those whose `when` predicate currently returns false, and resolves
|
|
79
|
+
* annotations to populate title / category / keywords.
|
|
80
|
+
*/
|
|
81
|
+
export const buildCommands = (ctx: BrowserCtx): readonly AppCommand[] => {
|
|
82
|
+
const out: AppCommand[] = []
|
|
83
|
+
for (const binding of browserBindings) {
|
|
84
|
+
const ann = annotations[binding.id]
|
|
85
|
+
if (ann?.hidden) continue
|
|
86
|
+
// Same gating the keymap dispatcher uses. Disabled bindings get
|
|
87
|
+
// hidden from the palette (per #70 Q5b — see #96 for the show-with-
|
|
88
|
+
// reason follow-up after the atom-driven migration).
|
|
89
|
+
if (binding.when && !binding.when(ctx)) continue
|
|
90
|
+
const cmd: AppCommand = {
|
|
91
|
+
id: binding.id,
|
|
92
|
+
title: ann?.title ?? binding.description,
|
|
93
|
+
...(ann?.category !== undefined && { category: ann.category }),
|
|
94
|
+
...(ann?.keywords !== undefined && { keywords: ann.keywords }),
|
|
95
|
+
...(binding.keys[0] !== undefined && { shortcut: binding.keys[0] }),
|
|
96
|
+
run: () => binding.run(ctx),
|
|
97
|
+
}
|
|
98
|
+
out.push(cmd)
|
|
99
|
+
}
|
|
100
|
+
for (const cmd of paletteOnlyCommands(ctx)) out.push(cmd)
|
|
101
|
+
return out
|
|
102
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commands that exist *only* in the palette — no `browserBindings` entry.
|
|
3
|
+
*
|
|
4
|
+
* Empty in v1 by design (#70 Q6c). This file exists as scaffolding so
|
|
5
|
+
* future palette-only commands (#93 reveal-in-OS, #94 copy-path, …) have
|
|
6
|
+
* an obvious home that's already wired into `buildCommands.ts`.
|
|
7
|
+
*
|
|
8
|
+
* Commands here follow the same shape as keymap-derived ones: they take
|
|
9
|
+
* the per-render `BrowserCtx` so they can read selection state, mutate
|
|
10
|
+
* focus, surface notices, etc.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { BrowserCtx } from "../keymap/browser.ts"
|
|
14
|
+
import type { AppCommand } from "./types.ts"
|
|
15
|
+
|
|
16
|
+
export const paletteOnlyCommands = (_ctx: BrowserCtx): readonly AppCommand[] => []
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command palette scorer — ported from ghui's pre-May `commands.ts`.
|
|
3
|
+
*
|
|
4
|
+
* Tiered ranking: title-prefix → text-prefix → title-includes →
|
|
5
|
+
* text-includes → acronym → fuzzy-includes. Lower score wins. Ties broken
|
|
6
|
+
* by `browserBindings` array order (the `index` field) so the empty-query
|
|
7
|
+
* palette renders in the keymap's natural order. See #70 design log for
|
|
8
|
+
* why we didn't reach for `fuzzysort`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { AppCommand } from "./types.ts"
|
|
12
|
+
|
|
13
|
+
const normalize = (text: string): string =>
|
|
14
|
+
text
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.replace(/[^a-z0-9#]+/g, " ")
|
|
17
|
+
.trim()
|
|
18
|
+
|
|
19
|
+
const acronym = (text: string): string =>
|
|
20
|
+
normalize(text)
|
|
21
|
+
.split(" ")
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.map((word) => word[0])
|
|
24
|
+
.join("")
|
|
25
|
+
|
|
26
|
+
const fuzzyIncludes = (text: string, query: string): boolean => {
|
|
27
|
+
let index = 0
|
|
28
|
+
for (const char of text) {
|
|
29
|
+
if (char === query[index]) index++
|
|
30
|
+
if (index >= query.length) return true
|
|
31
|
+
}
|
|
32
|
+
return query.length === 0
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const searchText = (command: AppCommand): string =>
|
|
36
|
+
normalize(
|
|
37
|
+
[command.title, command.category, command.shortcut, ...(command.keywords ?? [])]
|
|
38
|
+
.filter((s): s is string => Boolean(s))
|
|
39
|
+
.join(" "),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
/** Returns the tier (lower = better) or `null` if the query doesn't match. */
|
|
43
|
+
const score = (command: AppCommand, query: string): number | null => {
|
|
44
|
+
const q = normalize(query)
|
|
45
|
+
if (q.length === 0) return 0
|
|
46
|
+
const title = normalize(command.title)
|
|
47
|
+
const text = searchText(command)
|
|
48
|
+
if (title.startsWith(q)) return 0
|
|
49
|
+
if (text.startsWith(q)) return 1
|
|
50
|
+
if (title.includes(q)) return 2
|
|
51
|
+
if (text.includes(q)) return 3
|
|
52
|
+
if (acronym(command.title).startsWith(q)) return 4
|
|
53
|
+
if (fuzzyIncludes(text, q.replaceAll(" ", ""))) return 5
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Filter and sort commands against a query. With an empty query, every
|
|
59
|
+
* command tiers to 0 and the original index breaks ties — so the result is
|
|
60
|
+
* the input list in its original order (which v1 uses as `browserBindings`
|
|
61
|
+
* order). See #70 design log §empty-state ordering.
|
|
62
|
+
*/
|
|
63
|
+
export const filterCommands = (
|
|
64
|
+
commands: readonly AppCommand[],
|
|
65
|
+
query: string,
|
|
66
|
+
): readonly AppCommand[] =>
|
|
67
|
+
commands
|
|
68
|
+
.flatMap((command, index) => {
|
|
69
|
+
const tier = score(command, query)
|
|
70
|
+
return tier === null ? [] : [{ command, index, tier }]
|
|
71
|
+
})
|
|
72
|
+
.sort((a, b) => a.tier - b.tier || a.index - b.index)
|
|
73
|
+
.map(({ command }) => command)
|
|
74
|
+
|
|
75
|
+
export const clampSelectedIndex = (index: number, commands: readonly AppCommand[]): number => {
|
|
76
|
+
if (commands.length === 0) return 0
|
|
77
|
+
return Math.max(0, Math.min(commands.length - 1, index))
|
|
78
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Palette command shape.
|
|
3
|
+
*
|
|
4
|
+
* Commands are derived at render time from `browserBindings` via the
|
|
5
|
+
* annotation map (`buildCommands.ts`). The `run` field closes over the
|
|
6
|
+
* per-render `BrowserCtx` so dispatching a command is equivalent to firing
|
|
7
|
+
* the corresponding key binding — single source of truth for the action,
|
|
8
|
+
* two surfaces (keymap + palette) that can invoke it.
|
|
9
|
+
*
|
|
10
|
+
* See issue #70 (and its design-log comments) for the rejected alternatives
|
|
11
|
+
* — atom-driven registry (#92), fully hand-written list, fuzzysort dep, etc.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface AppCommand {
|
|
15
|
+
/** Stable id; matches the binding's id for keymap-derived commands. */
|
|
16
|
+
readonly id: string
|
|
17
|
+
/** Imperative-phrased label shown in the palette row. */
|
|
18
|
+
readonly title: string
|
|
19
|
+
/** Optional category for #91's grouped headers. Carried but unused in v1. */
|
|
20
|
+
readonly category?: string
|
|
21
|
+
/** Extra match terms (synonyms, alt phrasings). */
|
|
22
|
+
readonly keywords?: readonly string[]
|
|
23
|
+
/** Display-only shortcut hint (first key of the binding, if any). */
|
|
24
|
+
readonly shortcut?: string
|
|
25
|
+
readonly run: () => void
|
|
26
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layered configuration loader.
|
|
3
|
+
*
|
|
4
|
+
* Precedence (high to low): CLI args → env vars → user TOML file → built-in defaults.
|
|
5
|
+
* Each source is wrapped as a `ConfigProvider` and composed via `orElse`,
|
|
6
|
+
* which falls through per-key when the upstream source returns `undefined`.
|
|
7
|
+
*
|
|
8
|
+
* The schema (`Config.schema` + `Schema.Literals`) validates `theme` against
|
|
9
|
+
* the registered theme ids and `tone` against `"dark" | "light"`. Validation
|
|
10
|
+
* failures and TOML parse errors both surface as `ConfigError` from `loadConfig`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { homedir } from "node:os"
|
|
14
|
+
import { join } from "node:path"
|
|
15
|
+
import { Config, ConfigProvider, Effect, Schema } from "effect"
|
|
16
|
+
import { themeDefinitions } from "../theme/registry.ts"
|
|
17
|
+
|
|
18
|
+
export interface HouseConfig {
|
|
19
|
+
readonly theme: string
|
|
20
|
+
readonly tone: "dark" | "light"
|
|
21
|
+
readonly mdx: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CliOverrides {
|
|
25
|
+
readonly theme: string | null
|
|
26
|
+
readonly tone: string | null
|
|
27
|
+
readonly mdx: boolean | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEFAULT_THEME = "opencode"
|
|
31
|
+
const DEFAULT_TONE: "dark" | "light" = "dark"
|
|
32
|
+
const DEFAULT_MDX = true
|
|
33
|
+
|
|
34
|
+
const themeIds = themeDefinitions.map((t) => t.id)
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Top-level keys the config file is allowed to set. Kept in sync by hand
|
|
38
|
+
* with `schema` below — when adding a key, add it both places.
|
|
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.
|
|
41
|
+
*/
|
|
42
|
+
const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set(["theme", "tone", "mdx"])
|
|
43
|
+
|
|
44
|
+
const schema = Config.all({
|
|
45
|
+
theme: Config.schema(Schema.Literals(themeIds), "theme"),
|
|
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"),
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const defaultsProvider = (): ConfigProvider.ConfigProvider =>
|
|
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
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Reads a TOML file at `path`. Missing file → `undefined` for every key
|
|
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 = "..."`.
|
|
106
|
+
*/
|
|
107
|
+
const fileProvider = (
|
|
108
|
+
path: string,
|
|
109
|
+
onWarning: (message: string) => void,
|
|
110
|
+
): ConfigProvider.ConfigProvider => {
|
|
111
|
+
let cache: { data: Record<string, unknown> | null } | null = null
|
|
112
|
+
const load = Effect.gen(function* () {
|
|
113
|
+
if (cache !== null) return cache.data
|
|
114
|
+
const file = Bun.file(path)
|
|
115
|
+
const exists = yield* Effect.promise(() => file.exists())
|
|
116
|
+
if (!exists) {
|
|
117
|
+
cache = { data: null }
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
const text = yield* Effect.promise(() => file.text())
|
|
121
|
+
const parsed = yield* Effect.try({
|
|
122
|
+
try: () => Bun.TOML.parse(text) as Record<string, unknown>,
|
|
123
|
+
catch: (cause) =>
|
|
124
|
+
new ConfigProvider.SourceError({
|
|
125
|
+
message: `invalid TOML in ${path}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
126
|
+
cause,
|
|
127
|
+
}),
|
|
128
|
+
})
|
|
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
|
+
}
|
|
137
|
+
}
|
|
138
|
+
cache = { data: filtered }
|
|
139
|
+
return filtered
|
|
140
|
+
})
|
|
141
|
+
return ConfigProvider.make((path) =>
|
|
142
|
+
Effect.gen(function* () {
|
|
143
|
+
const data = yield* load
|
|
144
|
+
if (data === null) return undefined
|
|
145
|
+
if (path.length === 0) {
|
|
146
|
+
return ConfigProvider.makeRecord(new Set(Object.keys(data)))
|
|
147
|
+
}
|
|
148
|
+
const head = path[0]
|
|
149
|
+
if (typeof head !== "string") return undefined
|
|
150
|
+
const value = data[head]
|
|
151
|
+
if (value === undefined) return undefined
|
|
152
|
+
if (typeof value === "string") return ConfigProvider.makeValue(value)
|
|
153
|
+
// Numbers/booleans coerced to their string form so Schema.Literals matches.
|
|
154
|
+
return ConfigProvider.makeValue(String(value))
|
|
155
|
+
}),
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Reads `HOUSE_THEME` / `HOUSE_TONE` directly into a `fromUnknown` provider.
|
|
161
|
+
*
|
|
162
|
+
* We don't use `fromEnv().pipe(nested("HOUSE"), constantCase)` here because
|
|
163
|
+
* `ConfigProvider.orElse` composes providers via `.get(path)` (raw store
|
|
164
|
+
* access), which bypasses `mapInput`/`prefix`. That means an env provider
|
|
165
|
+
* built with `nested` + `constantCase` silently returns `undefined` once it
|
|
166
|
+
* sits behind an `orElse`. Reading env vars eagerly sidesteps the issue.
|
|
167
|
+
*/
|
|
168
|
+
const envProvider = (env: Record<string, string | undefined>): ConfigProvider.ConfigProvider => {
|
|
169
|
+
const entries: Array<[string, string]> = []
|
|
170
|
+
const theme = env["HOUSE_THEME"]
|
|
171
|
+
const tone = env["HOUSE_TONE"]
|
|
172
|
+
const mdx = env["HOUSE_MDX"]
|
|
173
|
+
if (theme !== undefined) entries.push(["theme", theme])
|
|
174
|
+
if (tone !== undefined) entries.push(["tone", tone])
|
|
175
|
+
if (mdx !== undefined) entries.push(["mdx", mdx])
|
|
176
|
+
return ConfigProvider.fromUnknown(Object.fromEntries(entries))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const cliProvider = (overrides: CliOverrides): ConfigProvider.ConfigProvider => {
|
|
180
|
+
const entries: Array<[string, string]> = []
|
|
181
|
+
if (overrides.theme !== null) entries.push(["theme", overrides.theme])
|
|
182
|
+
if (overrides.tone !== null) entries.push(["tone", overrides.tone])
|
|
183
|
+
if (overrides.mdx !== null) entries.push(["mdx", String(overrides.mdx)])
|
|
184
|
+
return ConfigProvider.fromUnknown(Object.fromEntries(entries))
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface LoadOptions {
|
|
188
|
+
readonly cli?: CliOverrides
|
|
189
|
+
/** Override the TOML path (tests). Defaults to `$XDG_CONFIG_HOME/house/config.toml`. */
|
|
190
|
+
readonly filePath?: string
|
|
191
|
+
/** Override env (tests). Defaults to `process.env`. */
|
|
192
|
+
readonly env?: Record<string, string>
|
|
193
|
+
/** Sink for non-fatal warnings (unknown keys). Defaults to stderr. */
|
|
194
|
+
readonly onWarning?: (message: string) => void
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export const defaultConfigPath = (): string =>
|
|
198
|
+
join(process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config"), "house", "config.toml")
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Renders a `ConfigError` (or any error) as a short single-line message
|
|
202
|
+
* suitable for `console.error("house: " + ...)`. Strips Effect's
|
|
203
|
+
* `ConfigError(SchemaError(...))` wrapping when present.
|
|
204
|
+
*/
|
|
205
|
+
export const formatConfigError = (err: unknown): string => {
|
|
206
|
+
if (err instanceof Config.ConfigError) {
|
|
207
|
+
const cause = err.cause
|
|
208
|
+
const raw = "message" in cause ? cause.message : String(cause)
|
|
209
|
+
return raw
|
|
210
|
+
.replace(/\s+at \[[^\]]+\]\s*$/, "")
|
|
211
|
+
.replace(/\s+/g, " ")
|
|
212
|
+
.trim()
|
|
213
|
+
}
|
|
214
|
+
if (err instanceof Error) return err.message
|
|
215
|
+
return String(err)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export const loadConfig = (
|
|
219
|
+
options: LoadOptions = {},
|
|
220
|
+
): Effect.Effect<HouseConfig, Config.ConfigError> => {
|
|
221
|
+
const cli = options.cli ?? { theme: null, tone: null, mdx: null }
|
|
222
|
+
const onWarning = options.onWarning ?? ((msg) => process.stderr.write(`${msg}\n`))
|
|
223
|
+
const provider = cliProvider(cli).pipe(
|
|
224
|
+
ConfigProvider.orElse(envProvider(options.env ?? process.env)),
|
|
225
|
+
ConfigProvider.orElse(fileProvider(options.filePath ?? defaultConfigPath(), onWarning)),
|
|
226
|
+
ConfigProvider.orElse(defaultsProvider()),
|
|
227
|
+
)
|
|
228
|
+
return schema
|
|
229
|
+
.parse(provider)
|
|
230
|
+
.pipe(Effect.map((raw) => ({ theme: raw.theme, tone: raw.tone, mdx: raw.mdx === "true" })))
|
|
231
|
+
}
|
package/src/discovery/walk.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readdir, readFile } from "node:fs/promises"
|
|
2
2
|
import { extname, join, relative, resolve } from "node:path"
|
|
3
|
-
import { Data, Effect } from "effect"
|
|
3
|
+
import { Data, Effect, Stream } from "effect"
|
|
4
4
|
import ignore, { type Ignore } from "ignore"
|
|
5
5
|
|
|
6
6
|
export interface FileEntry {
|
|
@@ -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 {
|
|
@@ -71,21 +74,38 @@ const sortEntries = <T extends { name: string; isDirectory: () => boolean }>(
|
|
|
71
74
|
return a.name.localeCompare(b.name)
|
|
72
75
|
})
|
|
73
76
|
|
|
74
|
-
|
|
77
|
+
/**
|
|
78
|
+
* DFS generator. Yields each markdown FileEntry as it is discovered, before
|
|
79
|
+
* descending further. Per-directory sort still happens before yielding so
|
|
80
|
+
* arrival order within a directory matches the configured sort.
|
|
81
|
+
*
|
|
82
|
+
* Cancellation: `signal.aborted` is checked between syscalls. Node's
|
|
83
|
+
* `readdir` doesn't accept an AbortSignal, so a single in-flight `readdir`
|
|
84
|
+
* on a slow filesystem still runs to completion before we notice — the
|
|
85
|
+
* generator only exits at the next checkpoint.
|
|
86
|
+
*/
|
|
87
|
+
async function* walkDirGen(
|
|
75
88
|
dirPath: string,
|
|
76
89
|
rootPath: string,
|
|
77
90
|
parentLevels: readonly IgnoreLevel[],
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
):
|
|
91
|
+
opts: { all: boolean; sort: SortOrder; mdx: boolean },
|
|
92
|
+
signal: AbortSignal,
|
|
93
|
+
): AsyncGenerator<FileEntry, void, void> {
|
|
94
|
+
if (signal.aborted) return
|
|
95
|
+
|
|
81
96
|
let levels = parentLevels
|
|
82
97
|
if (!opts.all) {
|
|
83
98
|
const ig = await tryLoadGitignore(dirPath)
|
|
99
|
+
if (signal.aborted) return
|
|
84
100
|
if (ig) levels = [...parentLevels, { dir: dirPath, ig }]
|
|
85
101
|
}
|
|
86
102
|
|
|
87
103
|
const raw = await readdir(dirPath, { withFileTypes: true })
|
|
104
|
+
if (signal.aborted) return
|
|
105
|
+
|
|
88
106
|
for (const entry of sortEntries(raw, opts.sort)) {
|
|
107
|
+
if (signal.aborted) return
|
|
108
|
+
|
|
89
109
|
// Never follow symlinks — cycle hazard, and a markdown reader doesn't
|
|
90
110
|
// need them. May be relaxed (files only) in a later iteration.
|
|
91
111
|
if (entry.isSymbolicLink()) continue
|
|
@@ -96,28 +116,32 @@ const walkDir = async (
|
|
|
96
116
|
if (HARD_SKIP_DIRS.has(entry.name)) continue
|
|
97
117
|
if (!opts.all && entry.name.startsWith(".")) continue
|
|
98
118
|
if (!opts.all && isIgnored(entryPath, true, levels)) continue
|
|
99
|
-
|
|
119
|
+
yield* walkDirGen(entryPath, rootPath, levels, opts, signal)
|
|
100
120
|
continue
|
|
101
121
|
}
|
|
102
122
|
|
|
103
123
|
if (!entry.isFile()) continue
|
|
104
124
|
if (!opts.all && entry.name.startsWith(".")) continue
|
|
105
|
-
|
|
125
|
+
const allowed = opts.mdx ? MARKDOWN_EXTENSIONS : MARKDOWN_EXTENSIONS_NO_MDX
|
|
126
|
+
if (!allowed.has(extname(entry.name).toLowerCase())) continue
|
|
106
127
|
if (!opts.all && isIgnored(entryPath, false, levels)) continue
|
|
107
128
|
|
|
108
|
-
|
|
129
|
+
yield {
|
|
109
130
|
path: entryPath,
|
|
110
131
|
relativePath: relative(rootPath, entryPath),
|
|
111
132
|
name: entry.name,
|
|
112
|
-
}
|
|
133
|
+
}
|
|
113
134
|
}
|
|
114
135
|
}
|
|
115
136
|
|
|
116
137
|
/**
|
|
117
|
-
*
|
|
138
|
+
* Stream markdown files under `root`. Entries arrive in DFS order respecting
|
|
139
|
+
* the per-directory sort. The stream is interruptible at syscall boundaries:
|
|
140
|
+
* the consumer's teardown trips an AbortController, and the generator exits
|
|
141
|
+
* at its next `signal.aborted` check.
|
|
118
142
|
*
|
|
119
143
|
* Rules (see DESIGN.md §6):
|
|
120
|
-
* - Extensions: `.md`, `.markdown`, `.mdx
|
|
144
|
+
* - Extensions: `.md`, `.markdown`, and `.mdx` (unless `mdx: false`).
|
|
121
145
|
* - Hard skips (always): `node_modules`, `.git`, `.venv`.
|
|
122
146
|
* - Hidden files/dirs (leading `.`) skipped unless `all: true`.
|
|
123
147
|
* - `.gitignore` honored, including nested `.gitignore` files.
|
|
@@ -128,16 +152,36 @@ const walkDir = async (
|
|
|
128
152
|
export const walk = (
|
|
129
153
|
root: string,
|
|
130
154
|
options: WalkOptions = {},
|
|
131
|
-
):
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
155
|
+
): Stream.Stream<FileEntry, DiscoveryError> => {
|
|
156
|
+
const absRoot = resolve(root)
|
|
157
|
+
const opts = {
|
|
158
|
+
all: options.all ?? false,
|
|
159
|
+
sort: options.sort ?? ("dirs-first" as SortOrder),
|
|
160
|
+
mdx: options.mdx ?? true,
|
|
161
|
+
}
|
|
162
|
+
const controller = new AbortController()
|
|
163
|
+
const iterable: AsyncIterable<FileEntry> = {
|
|
164
|
+
[Symbol.asyncIterator]() {
|
|
165
|
+
const gen = walkDirGen(absRoot, absRoot, [], opts, controller.signal)
|
|
166
|
+
return {
|
|
167
|
+
next: () => gen.next(),
|
|
168
|
+
return: async (value?: void) => {
|
|
169
|
+
controller.abort()
|
|
170
|
+
return gen.return(value as void)
|
|
171
|
+
},
|
|
172
|
+
}
|
|
141
173
|
},
|
|
142
|
-
|
|
143
|
-
})
|
|
174
|
+
}
|
|
175
|
+
return Stream.fromAsyncIterable(iterable, (cause) => new DiscoveryError({ root, cause }))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Test/convenience helper: collect the full walk into an array. Mirrors the
|
|
180
|
+
* pre-streaming `walk()` signature so call sites that don't need streaming
|
|
181
|
+
* (notably tests) stay terse.
|
|
182
|
+
*/
|
|
183
|
+
export const walkToArray = (
|
|
184
|
+
root: string,
|
|
185
|
+
options: WalkOptions = {},
|
|
186
|
+
): Effect.Effect<readonly FileEntry[], DiscoveryError> =>
|
|
187
|
+
Stream.runCollect(walk(root, options)).pipe(Effect.map((chunk) => Array.from(chunk)))
|