@immediately-run/grove 0.1.4 → 0.1.5

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.
@@ -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
+ }
@@ -0,0 +1,136 @@
1
+ // The body gate (MDX_FROM_MOUNT_SPEC D8), driven by a REAL scan rather than a stubbed
2
+ // `isSettled`, so "pending" means what the scan means by it.
3
+ import { describe, it, expect, vi } from 'vitest';
4
+ import { criticalFailure, entryPending } from './entryGate';
5
+ import { createCorpusScan, type ScanFs } from './corpusScan';
6
+ import { criticalKeys } from './criticalKeys';
7
+
8
+ /** A two-file corpus whose reads wait for `release()`. */
9
+ function corpus(files: Record<string, string>) {
10
+ const held: Array<() => void> = [];
11
+ const fs: ScanFs = {
12
+ async readdir() {
13
+ return Object.keys(files).map((p) => ({ name: p.slice('/c/'.length), isDirectory: () => false }));
14
+ },
15
+ readFile(path) {
16
+ return new Promise<string>((resolve) => held.push(() => resolve(files[path]!)));
17
+ },
18
+ };
19
+ const release = () => held.splice(0).forEach((r) => r());
20
+ return { fs, release };
21
+ }
22
+
23
+ const FILES = { '/c/home.mdx': '---\ntitle: H\n---\n', '/c/x.mdx': '---\ntitle: X\n---\n' };
24
+
25
+ describe('entryPending', () => {
26
+ it('pending while a critical key is unread; clear once the scan has read it', async () => {
27
+ const c = corpus(FILES);
28
+ const scan = createCorpusScan('/c/', c.fs, { flushMs: 0 });
29
+ const critical = ['/c/home.mdx', '/c/x.mdx'];
30
+ expect(entryPending(critical, scan.isSettled, 'ready')).toBe(true); // still listing
31
+ await new Promise((r) => setTimeout(r, 0));
32
+ expect(entryPending(critical, scan.isSettled, 'ready')).toBe(true); // listed, unread
33
+ c.release();
34
+ await scan.done;
35
+ expect(entryPending(critical, scan.isSettled, 'ready')).toBe(false);
36
+ });
37
+
38
+ it('a critical key the listing did not find (no home.mdx) does not hold the entry', async () => {
39
+ const c = corpus({ '/c/x.mdx': FILES['/c/x.mdx'] });
40
+ const scan = createCorpusScan('/c/', c.fs, { flushMs: 0 });
41
+ await new Promise((r) => setTimeout(r, 0));
42
+ c.release();
43
+ await scan.done;
44
+ expect(entryPending(['/c/home.mdx', '/c/x.mdx'], scan.isSettled, 'ready')).toBe(false);
45
+ });
46
+
47
+ it('pending while declared stylesheets load, even with every key read', () => {
48
+ expect(entryPending(['/c/x.mdx'], () => true, 'loading')).toBe(true);
49
+ expect(entryPending(['/c/x.mdx'], () => true, 'ready')).toBe(false);
50
+ });
51
+ });
52
+
53
+ describe('criticalFailure', () => {
54
+ it('names the first critical key whose read failed, and nothing when none did', () => {
55
+ const failed: Record<string, string> = { '/c/x.mdx': 'EIO' };
56
+ expect(criticalFailure(['/c/home.mdx', '/c/x.mdx'], (k) => failed[k] ?? null)).toBe(
57
+ 'Could not read /c/x.mdx (EIO). Reload to try again.',
58
+ );
59
+ expect(criticalFailure(['/c/home.mdx'], (k) => failed[k] ?? null)).toBeNull();
60
+ });
61
+ });
62
+
63
+ /** A listing-shaped fs over `files`; a read of any path in `failing` throws EIO. */
64
+ function failingFs(files: Record<string, string>, failing: (path: string) => boolean): ScanFs {
65
+ return {
66
+ async readdir(dir) {
67
+ const names = new Map<string, boolean>();
68
+ for (const p of Object.keys(files)) {
69
+ if (!p.startsWith(dir)) continue;
70
+ const rest = p.slice(dir.length);
71
+ const slash = rest.indexOf('/');
72
+ names.set(slash === -1 ? rest : rest.slice(0, slash), slash !== -1);
73
+ }
74
+ return [...names].map(([name, isDir]) => ({ name, isDirectory: () => isDir }));
75
+ },
76
+ async readFile(path) {
77
+ if (failing(path)) throw new Error('EIO dropped');
78
+ return files[path]!;
79
+ },
80
+ };
81
+ }
82
+
83
+ describe('fail closed on a failed FRAME read, end to end with a real scan', () => {
84
+ it('a frame: target whose read failed stays critical and fails the gate', async () => {
85
+ const files: Record<string, string> = {
86
+ '/app/content/home.mdx': '---\ntitle: H\n---\n',
87
+ '/app/content/frames/wide.mdx': '---\n---\n',
88
+ '/app/content/a.mdx': '---\ntitle: A\nframe: frames/wide\n---\n',
89
+ };
90
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
91
+ const scan = createCorpusScan('/app/content/', failingFs(files, (p) => p.endsWith('wide.mdx')), { flushMs: 0 });
92
+ await scan.done;
93
+ const critical = criticalKeys('/app/content/a.mdx', scan.snapshot().metadata, scan.readFailure);
94
+ expect(critical).toContain('/app/content/frames/wide.mdx');
95
+ expect(criticalFailure(critical, scan.readFailure)).toBe(
96
+ 'Could not read /app/content/frames/wide.mdx (EIO dropped). Reload to try again.',
97
+ );
98
+ warn.mockRestore();
99
+ });
100
+ });
101
+
102
+ describe('fail closed on a failed LAYOUT read, end to end with a real scan', () => {
103
+ it('a layout whose read failed stays critical and fails the gate, instead of painting without it', async () => {
104
+ const files: Record<string, string> = {
105
+ '/app/content/home.mdx': '---\ntitle: H\n---\n',
106
+ '/app/content/guides/_layout.mdx': '---\n---\n',
107
+ '/app/content/guides/a.mdx': '---\ntitle: A\n---\n',
108
+ };
109
+ const fs: ScanFs = {
110
+ async readdir(dir) {
111
+ const names = new Map<string, boolean>();
112
+ for (const p of Object.keys(files)) {
113
+ if (!p.startsWith(dir)) continue;
114
+ const rest = p.slice(dir.length);
115
+ const slash = rest.indexOf('/');
116
+ names.set(slash === -1 ? rest : rest.slice(0, slash), slash !== -1);
117
+ }
118
+ return [...names].map(([name, isDir]) => ({ name, isDirectory: () => isDir }));
119
+ },
120
+ async readFile(path) {
121
+ if (path.endsWith('_layout.mdx')) throw new Error('EIO dropped');
122
+ return files[path]!;
123
+ },
124
+ };
125
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
126
+ const scan = createCorpusScan('/app/content/', fs, { flushMs: 0 });
127
+ await scan.done;
128
+ const entry = '/app/content/guides/a.mdx';
129
+ const critical = criticalKeys(entry, scan.snapshot().metadata, scan.readFailure);
130
+ expect(critical).toContain('/app/content/guides/_layout.mdx');
131
+ expect(criticalFailure(critical, scan.readFailure)).toBe(
132
+ 'Could not read /app/content/guides/_layout.mdx (EIO dropped). Reload to try again.',
133
+ );
134
+ warn.mockRestore();
135
+ });
136
+ });
@@ -0,0 +1,28 @@
1
+ // Whether the entry body must still wait (MDX_FROM_MOUNT_SPEC D8).
2
+ //
3
+ // Pending while any file that decides how the entry renders is unread, or while the
4
+ // stylesheets home declares are still loading (D7) — the page paints once, styled. And
5
+ // FAILED — not painted — when one of those files could not be read for a reason other than
6
+ // its absence: an unread home or entry reads as `render` unset, which is the executing
7
+ // path, so a transient failure must not quietly become "no frontmatter".
8
+ import type { CorpusScanGate } from './corpusScan';
9
+
10
+ export function entryPending(
11
+ critical: readonly string[],
12
+ isSettled: CorpusScanGate['isSettled'],
13
+ stylesheets: 'loading' | 'ready',
14
+ ): boolean {
15
+ return stylesheets === 'loading' || critical.some((key) => !isSettled(key));
16
+ }
17
+
18
+ /** The reader-facing line for the first critical key whose read failed, or null. */
19
+ export function criticalFailure(
20
+ critical: readonly string[],
21
+ readFailure: CorpusScanGate['readFailure'],
22
+ ): string | null {
23
+ for (const key of critical) {
24
+ const reason = readFailure(key);
25
+ if (reason !== null) return `Could not read ${key} (${reason}). Reload to try again.`;
26
+ }
27
+ return null;
28
+ }
package/src/lib/layout.ts CHANGED
@@ -20,6 +20,26 @@ function layoutKeyForDir(dir: string): string {
20
20
  return dir + '_layout.mdx';
21
21
  }
22
22
 
23
+ /**
24
+ * The folder-convention layout keys that COULD wrap `entryKey`, outermost first: one per
25
+ * directory from the content root down to the entry's own. Whether each exists is the
26
+ * caller's question — the chain asks the index, the entry gate asks it too, and both must
27
+ * walk the same directories.
28
+ */
29
+ export function layoutKeysOnPath(entryKey: string): string[] {
30
+ const root = contentDir();
31
+ if (!entryKey.startsWith(root)) return [];
32
+ // Directories from the content root down to (but not including) the entry file.
33
+ const segs = entryKey.slice(root.length).split('/').slice(0, -1); // "people/ada.mdx" → ["people"]
34
+ const keys = [layoutKeyForDir(root)];
35
+ let dir = root;
36
+ for (const s of segs) {
37
+ dir = dir + s + '/';
38
+ keys.push(layoutKeyForDir(dir));
39
+ }
40
+ return keys;
41
+ }
42
+
23
43
  /**
24
44
  * R3-309 — which nav arrangement the ROOT layout asks for. The root `_layout.mdx`'s
25
45
  * frontmatter may carry `nav: top | nav: side`; anything else (absent, misspelled,
@@ -49,6 +69,14 @@ export function resolvePageLayout(
49
69
  return v === 'post' || v === 'full' ? v : 'doc';
50
70
  }
51
71
 
72
+ /** The layout key an entry names with `frame: '<slug>'`, or null when it names none
73
+ * (absent, `none`, `false`, or not a string). Whether that layout exists is the caller's
74
+ * question. */
75
+ export function explicitFrameKey(entryMeta: Record<string, unknown> | undefined): string | null {
76
+ const f = entryMeta?.frame;
77
+ return typeof f === 'string' && f && f !== 'none' ? slugToKey(f) : null;
78
+ }
79
+
52
80
  /** Does an entry/layout opt out of an inherited layout chain? `frame: none`
53
81
  * (or `frame: false`) means "render me bare / stop inheritance above here". */
54
82
  function optsOut(meta: Record<string, unknown> | undefined): boolean {
@@ -82,29 +110,12 @@ export function layoutChainForKey(
82
110
  const entryMeta = metaOf(entryKey);
83
111
  if (optsOut(entryMeta)) return [];
84
112
 
85
- const explicit = entryMeta?.frame;
86
- if (typeof explicit === 'string' && explicit && explicit !== 'none') {
87
- const key = slugToKey(explicit);
88
- return allKeys.includes(key) ? [key] : [];
89
- }
113
+ const explicit = explicitFrameKey(entryMeta);
114
+ if (explicit !== null) return allKeys.includes(explicit) ? [explicit] : [];
90
115
 
91
116
  const present = new Set(allKeys.filter(isLayoutKey));
92
- const root = contentDir();
93
- if (!entryKey.startsWith(root)) return [];
94
-
95
- // Directories from the content root down to (but not including) the entry file.
96
- const rel = entryKey.slice(root.length); // e.g. "people/ada.mdx"
97
- const segs = rel.split('/').slice(0, -1); // e.g. ["people"]
98
- const dirs = [root];
99
- let dir = root;
100
- for (const s of segs) {
101
- dir = dir + s + '/';
102
- dirs.push(dir);
103
- }
104
-
105
117
  const chain: string[] = [];
106
- for (const d of dirs) {
107
- const lk = layoutKeyForDir(d);
118
+ for (const lk of layoutKeysOnPath(entryKey)) {
108
119
  if (!present.has(lk)) continue;
109
120
  if (optsOut(metaOf(lk))) chain.length = 0; // this layout is a new root
110
121
  chain.push(lk);