@immediately-run/grove 0.1.4 → 0.1.7

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.
@@ -1,5 +1,5 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { scanCorpus, listCorpusFiles, type ScanFs } from './corpusScan';
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { createCorpusScan, scanCorpus, listCorpusFiles, type ScanFs } from './corpusScan';
3
3
  import { parseFrontmatter } from './frontmatter';
4
4
 
5
5
  /** An in-memory tree, shaped like the fs slice the scan injects. */
@@ -189,3 +189,183 @@ describe('scanCorpus — the additive headings index (GROVE_AGENT_SPEC §4)', ()
189
189
  expect(meta['/mnt/c/a.mdx']).toEqual({ title: 'A' });
190
190
  });
191
191
  });
192
+
193
+ const tick = () => new Promise((r) => setTimeout(r, 0));
194
+
195
+ /** The same tree as `fakeFs`, but every `readFile` waits until the test releases it — so
196
+ * a test can observe the scan between the listing and the reads, and see read order. */
197
+ function heldFs(files: Record<string, string>, opts: { unreadable?: string[] } = {}) {
198
+ const base = fakeFs(files, opts);
199
+ const calls: string[] = [];
200
+ const held = new Map<string, () => void>();
201
+ const fs: ScanFs = {
202
+ readdir: base.readdir,
203
+ readFile(path, enc) {
204
+ calls.push(path);
205
+ return new Promise<void>((resolve) => held.set(path, resolve)).then(() => base.readFile(path, enc));
206
+ },
207
+ };
208
+ const release = async (path: string) => {
209
+ held.get(path)!();
210
+ held.delete(path);
211
+ await tick(); // let the settle + pump run
212
+ };
213
+ return { fs, calls, held, release };
214
+ }
215
+
216
+
217
+ describe('createCorpusScan — the progressive index (MDX_FROM_MOUNT_SPEC D8)', () => {
218
+ const tree = {
219
+ '/mnt/c/home.mdx': entry('Home'),
220
+ '/mnt/c/a.mdx': entry('A'),
221
+ '/mnt/c/z.mdx': entry('Z'),
222
+ };
223
+
224
+ it('seeds EVERY listed key with an empty row before any file is read', async () => {
225
+ const h = heldFs(tree);
226
+ const scan = createCorpusScan('/mnt/c', h.fs, { flushMs: 60_000 });
227
+ expect(scan.snapshot().status).toBe('listing');
228
+ await vi.waitFor(() => expect(scan.snapshot().status).toBe('reading'));
229
+ expect(scan.snapshot().metadata).toEqual({ '/mnt/c/a.mdx': {}, '/mnt/c/home.mdx': {}, '/mnt/c/z.mdx': {} });
230
+ expect(h.held.size).toBeGreaterThan(0); // reads started, none resolved
231
+ scan.dispose();
232
+ });
233
+
234
+ it('isSettled: false while listing and for a listed-unread key; true once read, once failed, or never listed', async () => {
235
+ const h = heldFs(tree, { unreadable: ['/mnt/c/z.mdx'] });
236
+ const scan = createCorpusScan('/mnt/c', h.fs, { flushMs: 0 }); // settled = read AND published
237
+ expect(scan.isSettled('/mnt/c/a.mdx')).toBe(false);
238
+ expect(scan.isSettled('/mnt/c/missing.mdx')).toBe(false); // the listing has not answered yet
239
+ await vi.waitFor(() => expect(scan.snapshot().status).toBe('reading'));
240
+ expect(scan.isSettled('/mnt/c/a.mdx')).toBe(false);
241
+ expect(scan.isSettled('/mnt/c/missing.mdx')).toBe(true);
242
+ await h.release('/mnt/c/a.mdx');
243
+ await vi.waitFor(() => expect(scan.isSettled('/mnt/c/a.mdx')).toBe(true));
244
+ await h.release('/mnt/c/z.mdx');
245
+ await vi.waitFor(() => expect(scan.isSettled('/mnt/c/z.mdx')).toBe(true));
246
+ await h.release('/mnt/c/home.mdx');
247
+ await scan.done;
248
+ // The unreadable entry leaves the index, exactly as the whole-corpus scan always did.
249
+ expect(Object.keys(scan.snapshot().metadata).sort()).toEqual(['/mnt/c/a.mdx', '/mnt/c/home.mdx']);
250
+ expect(scan.snapshot().status).toBe('complete');
251
+ });
252
+
253
+ it('prioritize: a key asked for is the NEXT read, ahead of the rest of the corpus', async () => {
254
+ const h = heldFs(tree);
255
+ const scan = createCorpusScan('/mnt/c', h.fs, { concurrency: 1, flushMs: 60_000 });
256
+ await vi.waitFor(() => expect(h.calls).toEqual(['/mnt/c/a.mdx'])); // sorted order, one at a time
257
+ scan.prioritize(['/mnt/c/z.mdx']);
258
+ await h.release('/mnt/c/a.mdx');
259
+ await vi.waitFor(() => expect(h.calls).toEqual(['/mnt/c/a.mdx', '/mnt/c/z.mdx']));
260
+ scan.dispose();
261
+ });
262
+
263
+ it('prioritize before the listing finishes: the wanted key is read first', async () => {
264
+ const h = heldFs(tree);
265
+ const scan = createCorpusScan('/mnt/c', h.fs, { concurrency: 1, flushMs: 60_000 });
266
+ scan.prioritize(['/mnt/c/z.mdx']);
267
+ await vi.waitFor(() => expect(h.calls).toEqual(['/mnt/c/z.mdx']));
268
+ scan.dispose();
269
+ });
270
+
271
+ it('a prioritized key publishes as soon as it settles, without waiting for the flush timer', async () => {
272
+ const h = heldFs(tree);
273
+ const scan = createCorpusScan('/mnt/c', h.fs, { concurrency: 1, flushMs: 60_000 });
274
+ await vi.waitFor(() => expect(scan.snapshot().status).toBe('reading'));
275
+ const before = scan.snapshot();
276
+ const seen = vi.fn();
277
+ scan.subscribe(seen);
278
+ scan.prioritize(['/mnt/c/a.mdx']); // already in flight — still wanted
279
+ await h.release('/mnt/c/a.mdx');
280
+ await vi.waitFor(() => expect(seen).toHaveBeenCalledTimes(1));
281
+ expect(scan.snapshot()).not.toBe(before);
282
+ expect(scan.snapshot().metadata['/mnt/c/a.mdx'].title).toBe('A');
283
+ scan.dispose();
284
+ });
285
+
286
+ it('a read key is not settled until its row is published — the gate never runs ahead of the index', async () => {
287
+ const h = heldFs(tree);
288
+ const scan = createCorpusScan('/mnt/c', h.fs, { concurrency: 1, flushMs: 60_000 });
289
+ await vi.waitFor(() => expect(scan.snapshot().status).toBe('reading'));
290
+ await h.release('/mnt/c/a.mdx'); // read, nobody asked for it: waits for the flush
291
+ expect(scan.snapshot().metadata['/mnt/c/a.mdx']).toEqual({});
292
+ expect(scan.isSettled('/mnt/c/a.mdx')).toBe(false);
293
+ scan.prioritize(['/mnt/c/a.mdx']); // now it is wanted: published at once
294
+ expect(scan.isSettled('/mnt/c/a.mdx')).toBe(true);
295
+ expect(scan.snapshot().metadata['/mnt/c/a.mdx'].title).toBe('A');
296
+ scan.dispose();
297
+ });
298
+
299
+ it('an unprioritized read is batched into the next flush', async () => {
300
+ const h = heldFs(tree);
301
+ const scan = createCorpusScan('/mnt/c', h.fs, { concurrency: 1, flushMs: 20 });
302
+ await vi.waitFor(() => expect(scan.snapshot().status).toBe('reading'));
303
+ const seen = vi.fn();
304
+ scan.subscribe(seen);
305
+ await h.release('/mnt/c/a.mdx');
306
+ await tick();
307
+ expect(seen).not.toHaveBeenCalled(); // not per file
308
+ await vi.waitFor(() => expect(seen).toHaveBeenCalledTimes(1)); // the timer's flush
309
+ scan.dispose();
310
+ });
311
+
312
+ it('dispose stops handing out reads and cancels the pending publish', async () => {
313
+ const h = heldFs(tree);
314
+ const scan = createCorpusScan('/mnt/c', h.fs, { concurrency: 1, flushMs: 5 });
315
+ await vi.waitFor(() => expect(h.calls).toHaveLength(1));
316
+ const seen = vi.fn();
317
+ scan.subscribe(seen);
318
+ scan.dispose();
319
+ await h.release(h.calls[0]!);
320
+ await new Promise((r) => setTimeout(r, 20));
321
+ expect(h.calls).toHaveLength(1);
322
+ expect(seen).not.toHaveBeenCalled();
323
+ await scan.done; // resolves on dispose rather than hanging
324
+ });
325
+
326
+ it('unsubscribe removes exactly that listener', async () => {
327
+ const h = heldFs(tree);
328
+ const scan = createCorpusScan('/mnt/c', h.fs, { flushMs: 60_000 });
329
+ const kept = vi.fn();
330
+ const dropped = vi.fn();
331
+ scan.subscribe(kept);
332
+ const off = scan.subscribe(dropped);
333
+ off();
334
+ await vi.waitFor(() => expect(scan.snapshot().status).toBe('reading'));
335
+ expect(kept).toHaveBeenCalledTimes(1);
336
+ expect(dropped).not.toHaveBeenCalled();
337
+ scan.dispose();
338
+ });
339
+
340
+ it('an empty bundle completes straight from the listing', async () => {
341
+ const scan = createCorpusScan('/mnt/c', fakeFs({ '/mnt/c/notes.txt': 'x' }));
342
+ await scan.done;
343
+ expect(scan.snapshot()).toEqual({ status: 'complete', metadata: {} });
344
+ });
345
+ });
346
+
347
+ describe('createCorpusScan — read failures', () => {
348
+ const notFound = () => Object.assign(new Error('no such file'), { code: 'ENOENT' });
349
+
350
+ it('a transient failure is reported for the key once settled; a missing file is not a failure', async () => {
351
+ const files = { '/mnt/c/a.mdx': entry('A'), '/mnt/c/b.mdx': entry('B') };
352
+ const base = fakeFs(files);
353
+ const fs: ScanFs = {
354
+ readdir: base.readdir,
355
+ async readFile(path) {
356
+ if (path === '/mnt/c/a.mdx') throw new Error('EIO: the channel dropped the request');
357
+ if (path === '/mnt/c/b.mdx') throw notFound();
358
+ return base.readFile(path, 'utf8');
359
+ },
360
+ };
361
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
362
+ const scan = createCorpusScan('/mnt/c', fs, { flushMs: 0 });
363
+ expect(scan.readFailure('/mnt/c/a.mdx')).toBeNull(); // nothing is known yet
364
+ await scan.done;
365
+ expect(scan.readFailure('/mnt/c/a.mdx')).toBe('EIO: the channel dropped the request');
366
+ expect(scan.readFailure('/mnt/c/b.mdx')).toBeNull();
367
+ expect(scan.snapshot().metadata).toEqual({}); // both rows leave the index either way
368
+ expect(warn).toHaveBeenCalledTimes(1);
369
+ warn.mockRestore();
370
+ });
371
+ });
@@ -38,6 +38,11 @@ export interface ScanFs {
38
38
  * floods the channel the rest of the app shares. A small pool is the honest middle. */
39
39
  const READ_CONCURRENCY = 8;
40
40
 
41
+ /** How often a running scan publishes a new metadata map. Every consumer of the index
42
+ * re-derives on a new identity, so a map per file would re-render the wiki a thousand
43
+ * times on a large corpus; a key the entry is waiting on publishes at once instead. */
44
+ const FLUSH_MS = 250;
45
+
41
46
  /** Entry files. `_layout.mdx` is INCLUDED deliberately: `layoutChainForKey` resolves the
42
47
  * chain by looking for layout keys in this very map, so excluding structural files here
43
48
  * would silently drop every layout under dispatch. Reader-facing enumerations filter with
@@ -47,7 +52,8 @@ const ENTRY_RE = /\.mdx?$/;
47
52
  /** Directories never worth walking in a content mount. */
48
53
  const SKIP_DIRS = new Set(['.git', 'node_modules', '.immediately.run']);
49
54
 
50
- /** Every entry path under `root`, depth-first, absolute. */
55
+ /** Every entry path under `root`, absolute and sorted. Sibling directories are walked
56
+ * concurrently: the listing is on the path to first paint, and it reads no file bodies. */
51
57
  export async function listCorpusFiles(root: string, fs: ScanFs, maxDepth = 12): Promise<string[]> {
52
58
  const out: string[] = [];
53
59
  const walk = async (dir: string, depth: number): Promise<void> => {
@@ -66,55 +72,222 @@ export async function listCorpusFiles(root: string, fs: ScanFs, maxDepth = 12):
66
72
  out.push(`${dir}${it.name}`);
67
73
  }
68
74
  }
69
- for (const d of dirs) await walk(d, depth + 1);
75
+ await Promise.all(dirs.map((d) => walk(d, depth + 1)));
70
76
  };
71
77
  await walk(root.endsWith('/') ? root : `${root}/`, 0);
72
78
  return out.sort();
73
79
  }
74
80
 
75
- /** Read + parse a list of entries into the metadata map, bounded-concurrently.
76
- *
77
- * The additive headings index extension (GROVE_AGENT_SPEC §4): a dispatched row
78
- * carries its entry's `headings: [{id, text, depth}]`, ids from the same
79
- * mdx-plugins canon the render path emits (via the SDK's `collectHeadings` — one
80
- * implementation, shared with the tool that reads the field). The author's own
81
- * frontmatter `headings` key wins; a row with none of either simply lacks the
82
- * field (readers degrade to body reads). */
83
- async function readAll(paths: string[], fs: ScanFs): Promise<CorpusMetadata> {
84
- const meta: CorpusMetadata = {};
85
- let next = 0;
86
- const worker = async (): Promise<void> => {
87
- for (;;) {
88
- const i = next++;
89
- if (i >= paths.length) return;
90
- const path = paths[i];
91
- try {
92
- const raw = await fs.readFile(path, 'utf8');
93
- const parsed = parseFrontmatter(raw);
94
- const row = parsed.data;
95
- const headings = collectHeadings(parsed.body);
96
- if (headings.length && !Object.prototype.hasOwnProperty.call(row, 'headings')) {
97
- meta[path] = { ...row, headings } as Frontmatter & { headings?: unknown };
98
- } else {
99
- meta[path] = row;
100
- }
101
- } catch {
102
- // One unreadable entry must not empty the whole corpus. It is simply absent from
103
- // the index — the same state it would be in if the author had not written it.
104
- }
105
- }
106
- };
107
- await Promise.all(Array.from({ length: Math.min(READ_CONCURRENCY, paths.length) }, worker));
108
- return meta;
81
+ /** One entry's index row: its frontmatter, plus the additive headings index extension
82
+ * (GROVE_AGENT_SPEC §4). A dispatched row carries its entry's `headings: [{id, text,
83
+ * depth}]`, ids from the same mdx-plugins canon the render path emits (via the SDK's
84
+ * `collectHeadings` — one implementation, shared with the tool that reads the field). The
85
+ * author's own frontmatter `headings` key wins; a row with none of either simply lacks the
86
+ * field (readers degrade to body reads). */
87
+ function rowFor(raw: string): Frontmatter {
88
+ const parsed = parseFrontmatter(raw);
89
+ const row = parsed.data;
90
+ const headings = collectHeadings(parsed.body);
91
+ if (headings.length && !Object.prototype.hasOwnProperty.call(row, 'headings')) {
92
+ return { ...row, headings } as Frontmatter & { headings?: unknown };
93
+ }
94
+ return row;
95
+ }
96
+
97
+ /** A file that is simply not there is a corpus property, not a failure. */
98
+ function isNotFound(err: unknown): boolean {
99
+ return typeof err === 'object' && err !== null && (err as { code?: unknown }).code === 'ENOENT';
100
+ }
101
+
102
+ export type CorpusScanStatus = 'listing' | 'reading' | 'complete';
103
+
104
+ export interface CorpusScanSnapshot {
105
+ status: CorpusScanStatus;
106
+ /** Every listed entry, keyed by absolute path. A row is `{}` until its file is read, so
107
+ * existence and link resolution are right from the first render; whether a row has been
108
+ * READ is `isSettled`'s question, never the row's. */
109
+ metadata: CorpusMetadata;
110
+ }
111
+
112
+ /** The part of a scan the wiki's entry gate needs. The fork packaging's index is complete
113
+ * at boot, so its stand-in answers "settled" for every key. */
114
+ export interface CorpusScanGate {
115
+ /** True once `key` has been read, has failed to read, or the listing has finished
116
+ * without it. False while the listing is still running. */
117
+ isSettled(key: string): boolean;
118
+ /** Read these keys next, ahead of the rest of the corpus. */
119
+ prioritize(keys: readonly string[]): void;
120
+ /** Why a settled key's read failed, when the cause was anything but "not found". */
121
+ readFailure(key: string): string | null;
122
+ }
123
+
124
+ export interface CorpusScan extends CorpusScanGate {
125
+ /** The current published state. Stable identity between publishes. */
126
+ snapshot(): CorpusScanSnapshot;
127
+ subscribe(listener: () => void): () => void;
128
+ /** Resolves when the scan completes or is disposed. */
129
+ readonly done: Promise<void>;
130
+ /** Stop reading, cancel the pending publish, drop every listener. */
131
+ dispose(): void;
132
+ }
133
+
134
+ export interface CorpusScanOptions {
135
+ concurrency?: number;
136
+ flushMs?: number;
109
137
  }
110
138
 
111
139
  /**
112
- * Build the frontmatter index for a corpus resident at `root`.
140
+ * Start building the frontmatter index for a corpus resident at `root`.
141
+ *
142
+ * The listing comes first and reads no file bodies; every listed path is then in the index
143
+ * as an empty row, and the rows fill in as the files are read, a bounded pool at a time.
144
+ * A key passed to `prioritize` jumps the queue, which is how an entry the reader is waiting
145
+ * for is read before the rest of the corpus.
113
146
  *
114
147
  * Failure is per-file by design: a corpus is a foreign author's tree, and one malformed or
115
- * unreadable entry may not take the wiki down with it.
148
+ * unreadable entry may not take the wiki down with it. An unreadable entry leaves the index
149
+ * — the same state it would be in if the author had not written it — and counts as settled.
116
150
  */
151
+ export function createCorpusScan(root: string, fs: ScanFs, opts: CorpusScanOptions = {}): CorpusScan {
152
+ const concurrency = Math.max(1, opts.concurrency ?? READ_CONCURRENCY);
153
+ const flushMs = opts.flushMs ?? FLUSH_MS;
154
+ const rows: CorpusMetadata = {};
155
+ // A key is SETTLED only once its row is in a published snapshot — `isSettled` must never
156
+ // run ahead of the metadata a render can see, or the entry gate would open on an empty
157
+ // row that reads as `render` unset. `read` holds keys whose row is in `rows` but not yet
158
+ // published.
159
+ const settled = new Set<string>();
160
+ const read = new Set<string>();
161
+ // Reads that failed for a reason other than "no such file" — a dropped RPC, a rate
162
+ // limit. The row is gone either way, but the entry gate must not treat a transient
163
+ // failure of a file it needs as "this file has no frontmatter".
164
+ const failures = new Map<string, string>();
165
+ const wanted = new Set<string>();
166
+ const listeners = new Set<() => void>();
167
+ let listed: Set<string> | null = null;
168
+ let queue: string[] = [];
169
+ let status: CorpusScanStatus = 'listing';
170
+ let snap: CorpusScanSnapshot = { status, metadata: {} };
171
+ let active = 0;
172
+ let timer: ReturnType<typeof setTimeout> | null = null;
173
+ let disposed = false;
174
+ let resolveDone!: () => void;
175
+ const done = new Promise<void>((resolve) => {
176
+ resolveDone = resolve;
177
+ });
178
+
179
+ const publish = (): void => {
180
+ if (timer !== null) {
181
+ clearTimeout(timer);
182
+ timer = null;
183
+ }
184
+ if (disposed) return;
185
+ for (const key of read) settled.add(key);
186
+ read.clear();
187
+ snap = { status, metadata: { ...rows } };
188
+ for (const listener of [...listeners]) listener();
189
+ };
190
+ const schedule = (): void => {
191
+ if (timer === null && !disposed) timer = setTimeout(publish, flushMs);
192
+ };
193
+ const finish = (): void => {
194
+ status = 'complete';
195
+ publish();
196
+ resolveDone();
197
+ };
198
+
199
+ const readOne = async (path: string): Promise<void> => {
200
+ try {
201
+ rows[path] = rowFor(await fs.readFile(path, 'utf8'));
202
+ } catch (err) {
203
+ delete rows[path];
204
+ if (!isNotFound(err)) {
205
+ const message = err instanceof Error ? err.message : String(err);
206
+ failures.set(path, message);
207
+ console.warn(`[grove] could not read ${path}: ${message}`);
208
+ }
209
+ }
210
+ };
211
+ const pump = (): void => {
212
+ while (!disposed && active < concurrency && queue.length) {
213
+ const path = queue.shift()!;
214
+ active++;
215
+ // `readOne` settles every failure itself, so this chain has no rejection to lose.
216
+ void readOne(path).then(() => {
217
+ active--;
218
+ if (disposed) return;
219
+ read.add(path);
220
+ if (wanted.delete(path)) publish();
221
+ else schedule();
222
+ pump();
223
+ });
224
+ }
225
+ if (!disposed && status === 'reading' && active === 0 && queue.length === 0) finish();
226
+ };
227
+
228
+ void listCorpusFiles(root, fs)
229
+ .then((paths) => {
230
+ if (disposed) return;
231
+ listed = new Set(paths);
232
+ for (const path of paths) rows[path] = {};
233
+ const first = [...wanted].filter((k) => listed!.has(k));
234
+ const firstSet = new Set(first);
235
+ queue = [...first, ...paths.filter((p) => !firstSet.has(p))];
236
+ status = 'reading';
237
+ publish();
238
+ pump();
239
+ })
240
+ .catch((err: unknown) => {
241
+ // `listCorpusFiles` swallows per-directory failures, so this is a bug, not a corpus
242
+ // property: say so, and settle as an empty corpus rather than "Opening…" forever.
243
+ console.error('[grove] corpus listing failed', err);
244
+ if (disposed) return;
245
+ listed = new Set();
246
+ finish();
247
+ });
248
+
249
+ return {
250
+ snapshot: () => snap,
251
+ subscribe(listener) {
252
+ listeners.add(listener);
253
+ return () => {
254
+ listeners.delete(listener);
255
+ };
256
+ },
257
+ isSettled: (key) => settled.has(key) || (listed !== null && !listed.has(key)),
258
+ readFailure: (key) => (settled.has(key) ? (failures.get(key) ?? null) : null),
259
+ prioritize(keys) {
260
+ const front: string[] = [];
261
+ let readButUnpublished = false;
262
+ for (const key of keys) {
263
+ if (read.has(key)) readButUnpublished = true;
264
+ if (settled.has(key) || read.has(key) || wanted.has(key)) continue;
265
+ wanted.add(key);
266
+ const at = queue.indexOf(key);
267
+ if (at !== -1) {
268
+ queue.splice(at, 1);
269
+ front.push(key);
270
+ }
271
+ }
272
+ queue.unshift(...front);
273
+ // Wanted, and already read before anyone asked: publish now rather than at the timer.
274
+ if (readButUnpublished) publish();
275
+ },
276
+ done,
277
+ dispose() {
278
+ disposed = true;
279
+ if (timer !== null) clearTimeout(timer);
280
+ timer = null;
281
+ queue = [];
282
+ listeners.clear();
283
+ resolveDone();
284
+ },
285
+ };
286
+ }
287
+
288
+ /** Build the whole index and resolve with it — for callers that want the finished map. */
117
289
  export async function scanCorpus(root: string, fs: ScanFs): Promise<CorpusMetadata> {
118
- const files = await listCorpusFiles(root, fs);
119
- return readAll(files, fs);
290
+ const scan = createCorpusScan(root, fs);
291
+ await scan.done;
292
+ return scan.snapshot().metadata;
120
293
  }
@@ -0,0 +1,15 @@
1
+ // How the wiki reaches the running corpus scan (MDX_FROM_MOUNT_SPEC D8).
2
+ //
3
+ // The default is the fork packaging's answer: the bundler's index is complete at boot, so
4
+ // every key is settled and there is nothing to prioritize. Only a dispatched viewer, which
5
+ // builds its index at runtime, provides a live scan here.
6
+ import { createContext } from 'react';
7
+ import type { CorpusScanGate } from './corpusScan';
8
+
9
+ const COMPLETE: CorpusScanGate = {
10
+ isSettled: () => true,
11
+ prioritize: () => undefined,
12
+ readFailure: () => null,
13
+ };
14
+
15
+ export const CorpusScanContext = createContext<CorpusScanGate>(COMPLETE);
@@ -0,0 +1,82 @@
1
+ // The files an entry needs read before it paints (MDX_FROM_MOUNT_SPEC D8). One input is
2
+ // this repo's own corpus, listed by the real `listCorpusFiles` over the real directory —
3
+ // so a layout that exists on disk is proven to be asked for, not assumed.
4
+ import { describe, it, expect } from 'vitest';
5
+ import { promises as nodeFs } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { criticalKeys } from './criticalKeys';
8
+ import { listCorpusFiles, type ScanFs } from './corpusScan';
9
+ import { APP_CONTENT_ROOT } from './contentRoot';
10
+ import { folderIndexKey } from './directory';
11
+
12
+ const DISK_ROOT = join(process.cwd(), 'content') + '/';
13
+
14
+ /** The real corpus's listing, rebased to the fork's `/app/content/` keys, as an index of
15
+ * unread rows — exactly what the scan publishes when its listing finishes. */
16
+ async function listedIndex(): Promise<Record<string, Record<string, unknown>>> {
17
+ const paths = await listCorpusFiles(DISK_ROOT, nodeFs as unknown as ScanFs);
18
+ return Object.fromEntries(paths.map((p) => [APP_CONTENT_ROOT + p.slice(DISK_ROOT.length), {}]));
19
+ }
20
+
21
+ describe('criticalKeys', () => {
22
+ it('over the real corpus: home, the entry, and every layout on its folder path that exists', async () => {
23
+ const index = await listedIndex();
24
+ const entry = Object.keys(index).find((k) => k.startsWith('/app/content/people/') && !k.endsWith('_layout.mdx'))!;
25
+ expect(entry).toBeDefined();
26
+ const keys = criticalKeys(entry, index);
27
+ expect(keys).toEqual([
28
+ '/app/content/home.mdx',
29
+ entry,
30
+ '/app/content/_layout.mdx',
31
+ '/app/content/people/_layout.mdx',
32
+ ]);
33
+ // Every layout named is one the listing found — none is invented.
34
+ for (const k of keys) expect(index).toHaveProperty([k]);
35
+ });
36
+
37
+ it('asks only for layouts that exist', () => {
38
+ const index = { '/app/content/home.mdx': {}, '/app/content/a/b/x.mdx': {}, '/app/content/a/_layout.mdx': {} };
39
+ expect(criticalKeys('/app/content/a/b/x.mdx', index)).toEqual([
40
+ '/app/content/home.mdx',
41
+ '/app/content/a/b/x.mdx',
42
+ '/app/content/a/_layout.mdx',
43
+ ]);
44
+ });
45
+
46
+ it('adds the `frame:` target once the entry row is read and names one that exists', () => {
47
+ const index: Record<string, Record<string, unknown>> = {
48
+ '/app/content/home.mdx': {},
49
+ '/app/content/x.mdx': {},
50
+ '/app/content/frames/wide.mdx': {},
51
+ };
52
+ expect(criticalKeys('/app/content/x.mdx', index)).not.toContain('/app/content/frames/wide.mdx');
53
+ index['/app/content/x.mdx'] = { frame: 'frames/wide' };
54
+ expect(criticalKeys('/app/content/x.mdx', index)).toContain('/app/content/frames/wide.mdx');
55
+ index['/app/content/x.mdx'] = { frame: 'frames/absent' };
56
+ expect(criticalKeys('/app/content/x.mdx', index)).toEqual(['/app/content/home.mdx', '/app/content/x.mdx']);
57
+ index['/app/content/x.mdx'] = { frame: 'none' };
58
+ expect(criticalKeys('/app/content/x.mdx', index)).toEqual(['/app/content/home.mdx', '/app/content/x.mdx']);
59
+ });
60
+
61
+ it('a folder route: the entry is the folder index the router resolves, wrapped by that folder\'s layout', () => {
62
+ // GroveWiki hands `folderIndexKey(route, keys) ?? route` to criticalKeys; the real
63
+ // resolver picks the entry here, so the case follows the router, not a guess at it.
64
+ const index = {
65
+ '/app/content/home.mdx': {},
66
+ '/app/content/guides/index.mdx': {},
67
+ '/app/content/guides/_layout.mdx': {},
68
+ '/app/content/guides/first.mdx': {},
69
+ };
70
+ const entry = folderIndexKey('/app/content/guides', Object.keys(index));
71
+ expect(entry).toBe('/app/content/guides/index.mdx');
72
+ expect(criticalKeys(entry!, index)).toEqual([
73
+ '/app/content/home.mdx',
74
+ '/app/content/guides/index.mdx',
75
+ '/app/content/guides/_layout.mdx',
76
+ ]);
77
+ });
78
+
79
+ it('home is named once when it is the entry', () => {
80
+ expect(criticalKeys('/app/content/home.mdx', { '/app/content/home.mdx': {} })).toEqual(['/app/content/home.mdx']);
81
+ });
82
+ });
@@ -0,0 +1,37 @@
1
+ // The files that decide how an entry renders — the ones a dispatched viewer must have READ
2
+ // before it paints the entry (MDX_FROM_MOUNT_SPEC D8).
3
+ //
4
+ // Everything else in the index feeds the surfaces AROUND the entry (nav, sidebar, search,
5
+ // backlinks, index components), which may fill in as the scan continues. These do not: the
6
+ // entry's own row carries `layout`, `view`, `frame` and a per-entry `render: safe`; home
7
+ // carries the wiki-wide `site`, `theme`, `render: safe` and `stylesheets`; the layouts on
8
+ // the entry's folder path are the chrome it renders inside. Painting before any of them is
9
+ // read either shows the wrong page or, for `render: safe`, runs code that must not run.
10
+ import { homeKey } from './content';
11
+ import { explicitFrameKey, layoutKeysOnPath } from './layout';
12
+ import type { CorpusScanGate } from './corpusScan';
13
+
14
+ /**
15
+ * The keys to read before `entryKey` paints, given the index as it stands. `metadata` holds
16
+ * every LISTED key (read or not), so a layout that does not exist is never asked for. The
17
+ * set grows once the entry's own row is read and turns out to name a `frame:` — the caller
18
+ * recomputes on every index update, so it converges.
19
+ *
20
+ * A file whose read FAILED has left `metadata`, but it exists; it stays in the set so the
21
+ * gate sees its failure and fails closed, rather than painting the entry without that
22
+ * layout. `readFailure` is the scan's; the default suits an index with no scan behind it.
23
+ */
24
+ export function criticalKeys(
25
+ entryKey: string,
26
+ metadata: Record<string, unknown>,
27
+ readFailure: CorpusScanGate['readFailure'] = () => null,
28
+ ): string[] {
29
+ const exists = (key: string) => key in metadata || readFailure(key) !== null;
30
+ const keys = [homeKey(), entryKey];
31
+ for (const lk of layoutKeysOnPath(entryKey)) {
32
+ if (exists(lk)) keys.push(lk);
33
+ }
34
+ const frame = explicitFrameKey(metadata[entryKey] as Record<string, unknown> | undefined);
35
+ if (frame !== null && exists(frame)) keys.push(frame);
36
+ return [...new Set(keys)];
37
+ }