@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/src/Footer.tsx CHANGED
@@ -71,30 +71,40 @@ const displayKey = (raw: string): string => {
71
71
  }
72
72
  }
73
73
 
74
- const formatHint = <C,>(b: KeyBinding<C>): string | null => {
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 `${displayKey(first)}:${b.hint}`
88
+ return { key: displayKey(first), label: b.hint }
79
89
  }
80
90
 
81
- /** Drop hints from the end until the joined string fits within `width`.
82
- * If not even the first hint fits, fall back to the bare key portion so
83
- * the user still sees a discoverability anchor (e.g. `?` instead of an
84
- * empty row on an 8-column terminal). */
85
- const fitHints = (hints: readonly string[], width: number): string => {
86
- if (width <= 0 || hints.length === 0) return ""
87
- let acc = ""
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 next = acc.length === 0 ? h : `${acc}${HINT_SEPARATOR}${h}`
90
- if (next.length > width) break
91
- acc = next
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
- // Nothing fit. Render just the first hint's key (everything before `:`)
95
- // truncated to width, so the row is never silently blank.
96
- const firstKey = hints[0]!.split(":")[0] ?? ""
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.background,
129
+ backgroundColor: colors.surface,
120
130
  } as const
121
131
 
122
- const hints: string[] = []
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 hintContent = fitHints(hints, hintsWidth)
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
- <text content={hintContent} wrapMode="none" style={{ fg: colors.textMuted }} />
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
+ }
@@ -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={left}
86
- top={top}
87
- width={overlayWidth}
88
- height={overlayHeight}
89
+ left={0}
90
+ top={0}
91
+ width={viewportWidth}
92
+ height={viewportHeight}
89
93
  zIndex={10}
90
- title=" Help "
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
- {rows.map((row) => {
101
- switch (row.kind) {
102
- case "header":
103
- return (
104
- <text
105
- key={row.key}
106
- wrapMode="none"
107
- content={row.text}
108
- style={{ fg: colors.borderActive, attributes: 1 /* bold */ }}
109
- />
110
- )
111
- case "footer":
112
- return (
113
- <text
114
- key={row.key}
115
- wrapMode="none"
116
- content={row.text}
117
- style={{ fg: colors.textMuted }}
118
- />
119
- )
120
- case "spacer":
121
- return <text key={row.key} content=" " />
122
- case "binding":
123
- return (
124
- <text key={row.key} wrapMode="none" content={row.text} style={{ fg: colors.text }} />
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 { path, theme, tone, width, all, sort, serve, port, help, version, configPath, sidebar }
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`
@@ -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 reject typo'd keys (e.g. `them = "..."`) loudly
37
- * rather than silently falling back to defaults.
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({ theme: DEFAULT_THEME, tone: DEFAULT_TONE })
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 upstream).
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 = (path: string): ConfigProvider.ConfigProvider => {
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 unknown = Object.keys(parsed).filter((k) => !KNOWN_FILE_KEYS.has(k))
73
- if (unknown.length > 0) {
74
- const known = [...KNOWN_FILE_KEYS].join(", ")
75
- return yield* Effect.fail(
76
- new ConfigProvider.SourceError({
77
- message: `unknown key${unknown.length > 1 ? "s" : ""} in ${path}: ${unknown.map((k) => `"${k}"`).join(", ")} (known: ${known})`,
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: parsed }
82
- return parsed
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.parse(provider)
228
+ return schema
229
+ .parse(provider)
230
+ .pipe(Effect.map((raw) => ({ theme: raw.theme, tone: raw.tone, mdx: raw.mdx === "true" })))
166
231
  }
@@ -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
- if (!MARKDOWN_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue
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> = {