@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.
@@ -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);