@immediately-run/grove 0.1.3 → 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.
- package/llms.txt +2 -1
- package/package.json +3 -3
- package/src/App.tsx +21 -19
- package/src/GroveApp.css +4 -4
- package/src/GroveWiki.entryGate.test.tsx +216 -0
- package/src/GroveWiki.tsx +36 -43
- package/src/components/BootMessage.tsx +11 -0
- package/src/components/ContentTheme.test.tsx +1 -1
- package/src/components/ContentTheme.tsx +5 -14
- package/src/hooks/useBundleMetadata.ts +40 -23
- package/src/hooks/useContentComponents.ts +2 -2
- package/src/hooks/useContentStylesheets.ts +67 -0
- package/src/lib/contentStylesheet.test.ts +57 -1
- package/src/lib/contentStylesheet.ts +62 -4
- package/src/lib/corpusScan.test.ts +182 -2
- package/src/lib/corpusScan.ts +213 -40
- package/src/lib/corpusScanContext.ts +15 -0
- package/src/lib/criticalKeys.test.ts +82 -0
- package/src/lib/criticalKeys.ts +37 -0
- package/src/lib/entryGate.test.ts +136 -0
- package/src/lib/entryGate.ts +28 -0
- package/src/lib/layout.ts +31 -20
- package/viewer-manifest.schema.json +30 -0
- package/viewer.manifest.json +1 -0
package/src/lib/corpusScan.ts
CHANGED
|
@@ -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`,
|
|
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
|
-
|
|
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
|
-
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
*
|
|
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
|
|
119
|
-
|
|
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
|
|
86
|
-
if (
|
|
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
|
|
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);
|