@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.
@@ -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
+ }
@@ -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
- const walkDir = async (
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
- results: FileEntry[],
79
- opts: { all: boolean; sort: SortOrder },
80
- ): Promise<void> => {
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
- await walkDir(entryPath, rootPath, levels, results, opts)
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
- 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
106
127
  if (!opts.all && isIgnored(entryPath, false, levels)) continue
107
128
 
108
- results.push({
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
- * Walk a directory tree and return markdown files in a stable order.
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
- ): Effect.Effect<readonly FileEntry[], DiscoveryError> =>
132
- Effect.tryPromise({
133
- try: async () => {
134
- const absRoot = resolve(root)
135
- const results: FileEntry[] = []
136
- await walkDir(absRoot, absRoot, [], results, {
137
- all: options.all ?? false,
138
- sort: options.sort ?? "dirs-first",
139
- })
140
- return results
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
- catch: (cause) => new DiscoveryError({ root, cause }),
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)))