@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.
- package/llms.txt +2 -1
- package/package.json +3 -3
- package/src/App.tsx +21 -19
- package/src/GroveApp.css +21 -4
- package/src/GroveWiki.entryGate.test.tsx +216 -0
- package/src/GroveWiki.tsx +60 -48
- package/src/components/BootMessage.tsx +11 -0
- package/src/components/ContentTheme.test.tsx +1 -1
- package/src/components/ContentTheme.tsx +5 -14
- package/src/components/GroveAgent.test.tsx +130 -91
- package/src/components/GroveAgent.tsx +53 -11
- package/src/components/GroveAgent.unanswered.test.tsx +45 -0
- package/src/hooks/useBundleMetadata.ts +40 -23
- package/src/hooks/useCatalogAnswered.late.test.tsx +59 -0
- package/src/hooks/useCatalogAnswered.test.tsx +89 -0
- package/src/hooks/useCatalogAnswered.ts +73 -0
- package/src/hooks/useContentComponents.ts +2 -2
- package/src/hooks/useContentStylesheets.ts +67 -0
- package/src/lib/content.test.ts +47 -1
- package/src/lib/content.ts +21 -2
- 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/src/lib/reachCard.test.ts +479 -61
- package/src/lib/reachCard.ts +170 -28
- package/viewer.manifest.json +1 -0
|
@@ -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);
|