@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.
- package/llms.txt +2 -1
- package/package.json +1 -1
- 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.json +1 -0
package/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @immediately-run/grove — the viewer kit for directory-as-content wikis
|
|
2
2
|
|
|
3
|
-
> The Grove viewer: a kit of React components, layouts and themes for building immediately.run wikis. Composable as a fork, a dispatch target, or a pinned library. (v0.1.
|
|
3
|
+
> The Grove viewer: a kit of React components, layouts and themes for building immediately.run wikis. Composable as a fork, a dispatch target, or a pinned library. (v0.1.5)
|
|
4
4
|
|
|
5
5
|
Grove is NOT a wiki engine: routing, MDX compilation, the frontmatter index, link
|
|
6
6
|
spaces and heading anchors live in the sandbox + `@immediately-run/sdk`. What this
|
|
@@ -97,6 +97,7 @@ is one corpus's conventions (declared by that corpus, not this repo).
|
|
|
97
97
|
|
|
98
98
|
- `site`
|
|
99
99
|
- `theme`
|
|
100
|
+
- `stylesheets`
|
|
100
101
|
- `layout`
|
|
101
102
|
- `view`
|
|
102
103
|
- `frame`
|
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -12,8 +12,12 @@
|
|
|
12
12
|
// Every helper the wiki is made of — which keys are entries, what their hrefs are, which
|
|
13
13
|
// layouts wrap them — is a function of the root; every surface a reader sees — nav,
|
|
14
14
|
// sidebar, search, backlinks, routing, the 404 index — is a function of the index. A
|
|
15
|
-
// component that rendered before
|
|
16
|
-
//
|
|
15
|
+
// component that rendered before the root was settled would show the VIEWER's corpus, and
|
|
16
|
+
// then not correct itself; so would one that rendered with no index in scope, because the
|
|
17
|
+
// metadata hooks fall back to the host's store. So this file holds until the root is
|
|
18
|
+
// settled and the bundle is LISTED — every key known, rows filling in — and from then on
|
|
19
|
+
// always provides the bundle's index. Which rows an entry needs READ before it paints is
|
|
20
|
+
// GroveWiki's call (MDX_FROM_MOUNT_SPEC D8).
|
|
17
21
|
//
|
|
18
22
|
// Hence the split: this file resolves, `GroveWiki` renders.
|
|
19
23
|
|
|
@@ -29,6 +33,8 @@ import { useContentComponents } from './hooks/useContentComponents';
|
|
|
29
33
|
import { getContentRoot } from './lib/contentRoot';
|
|
30
34
|
import { viewedDocumentForTarget } from './lib/content';
|
|
31
35
|
import GroveWiki from './GroveWiki';
|
|
36
|
+
import BootMessage from './components/BootMessage';
|
|
37
|
+
import { CorpusScanContext } from './lib/corpusScanContext';
|
|
32
38
|
|
|
33
39
|
// R3-268 — the viewed-document rule, registered ONCE at module load: every
|
|
34
40
|
// `navigate()`/`<Link>` navigation declares which file the destination renders,
|
|
@@ -81,25 +87,18 @@ export default function App() {
|
|
|
81
87
|
boot.status === 'ready' || boot.status === 'fork' ? getContentRoot() : null,
|
|
82
88
|
);
|
|
83
89
|
|
|
84
|
-
if (boot.status === 'failed') {
|
|
85
|
-
return (
|
|
86
|
-
<div className="grove-boot">
|
|
87
|
-
<p className="grove-boot__msg">{boot.message}</p>
|
|
88
|
-
</div>
|
|
89
|
-
);
|
|
90
|
-
}
|
|
90
|
+
if (boot.status === 'failed') return <BootMessage>{boot.message}</BootMessage>;
|
|
91
91
|
|
|
92
92
|
// The provider must be COMPLETE before content paints (MDX_FROM_MOUNT_SPEC §2's
|
|
93
93
|
// invariant): rendering into a half-composed map would flash a missing-component error
|
|
94
94
|
// for `<RoadmapBoard>` until registration landed — the very error content components
|
|
95
95
|
// exist to remove — and a nested provider patched in afterwards would do the same.
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
);
|
|
96
|
+
//
|
|
97
|
+
// The frontmatter index is NOT held for (D8). Once the bundle is listed every key is
|
|
98
|
+
// known; the rows the requested entry needs are GroveWiki's to wait for, and the rest
|
|
99
|
+
// fill in around a painted page.
|
|
100
|
+
if (boot.status === 'waiting' || bundle.status === 'listing' || contentComponents.status === 'loading') {
|
|
101
|
+
return <BootMessage />;
|
|
103
102
|
}
|
|
104
103
|
|
|
105
104
|
// Corpus-declared components (R3-174) go on as a nested provider, which the SDK's
|
|
@@ -120,14 +119,17 @@ export default function App() {
|
|
|
120
119
|
// the resolved theme (the catalogue looks change the reading face); nothing
|
|
121
120
|
// font-shaped waits on the scan from here.
|
|
122
121
|
|
|
123
|
-
if (bundle.
|
|
122
|
+
if (bundle.metadata && bundle.scan) {
|
|
124
123
|
// Provide the scanned bundle as the metadata SOURCE through the supported
|
|
125
124
|
// surface (R3-276), not a wholesale TinkerableContext re-provision: the
|
|
126
125
|
// platform stays free to grow its own state, and the hooks read the nearest
|
|
127
126
|
// MetadataSource — so every consumer works unchanged, and nothing re-states
|
|
128
|
-
// host fields it does not own.
|
|
127
|
+
// host fields it does not own. Provided from the first partial index on, so the
|
|
128
|
+
// tree keeps its shape when the scan completes rather than remounting the wiki.
|
|
129
129
|
return (
|
|
130
|
-
<
|
|
130
|
+
<CorpusScanContext value={bundle.scan}>
|
|
131
|
+
<MetadataSource value={bundle.metadata}>{withComponents}</MetadataSource>
|
|
132
|
+
</CorpusScanContext>
|
|
131
133
|
);
|
|
132
134
|
}
|
|
133
135
|
|
package/src/GroveApp.css
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
/* R3-316 — the escape hatch, pinned where content cannot reach: for !important
|
|
10
10
|
declarations EARLIER layers win, so grove.reset (the first layer, ordered in
|
|
11
|
-
index.css) beats grove.content no matter what a
|
|
11
|
+
index.css) beats grove.content no matter what a content stylesheet tries. A reader
|
|
12
12
|
can always reach a shipped theme without clearing storage — the one guarantee
|
|
13
13
|
the content layer must not be able to revoke. Proven by test (the hiding
|
|
14
14
|
vectors are enumerated), not by reading this comment. */
|
|
@@ -2994,9 +2994,9 @@ button.grove-search__row {
|
|
|
2994
2994
|
padding: 0 18px;
|
|
2995
2995
|
}
|
|
2996
2996
|
|
|
2997
|
-
/* Boot gate (R3-169) — shown
|
|
2998
|
-
|
|
2999
|
-
|
|
2997
|
+
/* Boot gate (R3-169) — shown while a dispatched viewer resolves its delegated corpus,
|
|
2998
|
+
lists it, or reads the few files the requested entry needs; and when that delegation
|
|
2999
|
+
never arrived. Deliberately plain: the corpus's own theming is not applied yet. */
|
|
3000
3000
|
.grove-boot {
|
|
3001
3001
|
display: grid;
|
|
3002
3002
|
place-items: center;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// The entry gate and declared stylesheets, rendered (MDX_FROM_MOUNT_SPEC D7, D8):
|
|
3
|
+
// • while a file the entry needs is unread, the body is the boot line and the layers are
|
|
4
|
+
// not rendered — and those files are asked for first;
|
|
5
|
+
// • once they are read, the entry renders in the same mounted shell;
|
|
6
|
+
// • the stylesheets home declares hold the body until they are read, then apply;
|
|
7
|
+
// • a declaration that names no entry is shown to the reader.
|
|
8
|
+
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
|
|
9
|
+
import { act } from 'react';
|
|
10
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
11
|
+
import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
|
|
12
|
+
import { CorpusScanContext } from './lib/corpusScanContext';
|
|
13
|
+
import type { CorpusScanGate } from './lib/corpusScan';
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
17
|
+
writable: true,
|
|
18
|
+
value: (q: string) => ({ matches: false, media: q, addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {}, dispatchEvent: () => false, onchange: null }),
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// One read double behind both doors: the `fs` module (entry bodies, declared stylesheets)
|
|
23
|
+
// and the SDK's shared fs (layouts, theme faces).
|
|
24
|
+
const { readFile } = vi.hoisted(() => ({ readFile: vi.fn() }));
|
|
25
|
+
vi.mock('fs', () => ({ default: { promises: { readFile: (...a: unknown[]) => readFile(...a) } } }));
|
|
26
|
+
(globalThis as { __sandpackSharedFs?: unknown }).__sandpackSharedFs = {
|
|
27
|
+
promises: { readFile: (...a: unknown[]) => readFile(...a) },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const { default: GroveWiki } = await import('./GroveWiki');
|
|
31
|
+
|
|
32
|
+
const ENTRY = '/app/content/wiki/a.mdx';
|
|
33
|
+
const HOME = '/app/content/home.mdx';
|
|
34
|
+
const LAYOUT = '/app/content/_layout.mdx';
|
|
35
|
+
const SHEET = '/app/content/themes/paper.mdx';
|
|
36
|
+
|
|
37
|
+
const NAV = {
|
|
38
|
+
mode: 'github',
|
|
39
|
+
namespace: 'immediately-run',
|
|
40
|
+
provider: 'github',
|
|
41
|
+
repository: 'corpus',
|
|
42
|
+
ref: 'main',
|
|
43
|
+
sandboxPath: ENTRY,
|
|
44
|
+
hash: '',
|
|
45
|
+
search: '',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// `render: safe` renders the body through the real safe renderer over the fs double (the
|
|
49
|
+
// compiled evaluator exists only inside the sandbox bundler).
|
|
50
|
+
function meta(home: Record<string, unknown> = {}) {
|
|
51
|
+
return {
|
|
52
|
+
[ENTRY]: { title: 'Reference entry.', render: 'safe' },
|
|
53
|
+
[HOME]: { title: 'Home', ...home },
|
|
54
|
+
[LAYOUT]: { site: 'Gate fixture' },
|
|
55
|
+
[SHEET]: {},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function gate(settled: Set<string> | 'all', failed: Record<string, string> = {}) {
|
|
60
|
+
return {
|
|
61
|
+
isSettled: (k: string) => settled === 'all' || settled.has(k),
|
|
62
|
+
prioritize: vi.fn<CorpusScanGate['prioritize']>(),
|
|
63
|
+
readFailure: (k: string) => failed[k] ?? null,
|
|
64
|
+
} satisfies CorpusScanGate;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function render(root: Root, scan: CorpusScanGate, filesMetadata: Record<string, unknown>) {
|
|
68
|
+
await act(async () => {
|
|
69
|
+
root.render(
|
|
70
|
+
<TinkerableContext.Provider
|
|
71
|
+
value={{ outerHref: 'https://immediately.run/x', navigationState: NAV, routingSpec: { routes: [] } as never, filesMetadata } as never}
|
|
72
|
+
>
|
|
73
|
+
<CorpusScanContext value={scan}>
|
|
74
|
+
<GroveWiki />
|
|
75
|
+
</CorpusScanContext>
|
|
76
|
+
</TinkerableContext.Provider>,
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
for (let i = 0; i < 6; i++) await act(async () => {});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function mount(): { container: HTMLElement; root: Root } {
|
|
83
|
+
const container = document.createElement('div');
|
|
84
|
+
document.body.appendChild(container);
|
|
85
|
+
return { container, root: createRoot(container) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const bootLine = (c: HTMLElement) => c.querySelector('.grove-shell .grove-boot__msg')?.textContent ?? null;
|
|
89
|
+
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
readFile.mockReset();
|
|
92
|
+
readFile.mockImplementation(async (path: string) =>
|
|
93
|
+
path === SHEET ? '---\ntitle: Paper\n---\n--bg: #123456;\n' : '---\ntitle: Reference entry.\nrender: safe\n---\n\nThe body text.\n',
|
|
94
|
+
);
|
|
95
|
+
localStorage.clear();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('the entry gate (D8)', () => {
|
|
99
|
+
it('holds the body on the boot line while the entry is unread, and asks for its files first', async () => {
|
|
100
|
+
const { container, root } = mount();
|
|
101
|
+
const scan = gate(new Set([HOME, LAYOUT]));
|
|
102
|
+
await render(root, scan, meta());
|
|
103
|
+
expect(bootLine(container)).toBe('Opening…');
|
|
104
|
+
expect(container.textContent).not.toContain('The body text.');
|
|
105
|
+
expect(container.querySelector('.grove-root')).toBeTruthy(); // the shell is mounted
|
|
106
|
+
expect(scan.prioritize).toHaveBeenCalledWith([HOME, ENTRY, LAYOUT]);
|
|
107
|
+
await act(async () => root.unmount());
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('renders the entry in the same shell once its files are read', async () => {
|
|
111
|
+
const { container, root } = mount();
|
|
112
|
+
await render(root, gate(new Set([HOME, LAYOUT])), meta());
|
|
113
|
+
const shell = container.querySelector('.grove-root');
|
|
114
|
+
await render(root, gate('all'), meta());
|
|
115
|
+
expect(bootLine(container)).toBeNull();
|
|
116
|
+
expect(container.textContent).toContain('The body text.');
|
|
117
|
+
expect(container.querySelector('.grove-root')).toBe(shell); // not remounted
|
|
118
|
+
await act(async () => root.unmount());
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('a critical file that failed to read fails closed: the reason, never the layers', async () => {
|
|
122
|
+
const { container, root } = mount();
|
|
123
|
+
await render(root, gate('all', { [HOME]: 'EIO' }), meta());
|
|
124
|
+
expect(bootLine(container)).toBe(`Could not read ${HOME} (EIO). Reload to try again.`);
|
|
125
|
+
expect(container.textContent).not.toContain('The body text.');
|
|
126
|
+
await act(async () => root.unmount());
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('an unread HOME holds the body too — its `render: safe` is not known yet', async () => {
|
|
130
|
+
const { container, root } = mount();
|
|
131
|
+
await render(root, gate(new Set([ENTRY, LAYOUT])), meta());
|
|
132
|
+
expect(bootLine(container)).toBe('Opening…');
|
|
133
|
+
await act(async () => root.unmount());
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('declared stylesheets (D7)', () => {
|
|
138
|
+
it('hold the body until read, then apply into the content layer', async () => {
|
|
139
|
+
let release!: () => void;
|
|
140
|
+
const held = new Promise<void>((r) => (release = r));
|
|
141
|
+
readFile.mockImplementation(async (path: string) => {
|
|
142
|
+
if (path === SHEET) {
|
|
143
|
+
await held;
|
|
144
|
+
return '---\ntitle: Paper\n---\n--bg: #123456;\n';
|
|
145
|
+
}
|
|
146
|
+
return '---\ntitle: Reference entry.\nrender: safe\n---\n\nThe body text.\n';
|
|
147
|
+
});
|
|
148
|
+
const { container, root } = mount();
|
|
149
|
+
await render(root, gate('all'), meta({ stylesheets: ['themes/paper.mdx'] }));
|
|
150
|
+
expect(bootLine(container)).toBe('Opening…');
|
|
151
|
+
expect(readFile).toHaveBeenCalledWith(SHEET, 'utf8');
|
|
152
|
+
await act(async () => release());
|
|
153
|
+
for (let i = 0; i < 6; i++) await act(async () => {});
|
|
154
|
+
expect(bootLine(container)).toBeNull();
|
|
155
|
+
expect(container.querySelector('style[data-grove-content-theme]')?.textContent).toContain('--bg: #123456;');
|
|
156
|
+
await act(async () => root.unmount());
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('a tagged entry that home does not declare is NOT applied — tag discovery is gone', async () => {
|
|
160
|
+
const { container, root } = mount();
|
|
161
|
+
const m = meta();
|
|
162
|
+
m[SHEET] = { tags: ['ui/stylesheet'] };
|
|
163
|
+
await render(root, gate('all'), m);
|
|
164
|
+
expect(container.querySelector('style[data-grove-content-theme]')).toBeNull();
|
|
165
|
+
expect(readFile).not.toHaveBeenCalledWith(SHEET, 'utf8');
|
|
166
|
+
await act(async () => root.unmount());
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('a declaration that names no entry is shown, and does not hold the page', async () => {
|
|
170
|
+
const { container, root } = mount();
|
|
171
|
+
await render(root, gate('all'), meta({ stylesheets: ['../outside.mdx'] }));
|
|
172
|
+
const error = container.querySelector('.grove-decl-error');
|
|
173
|
+
expect(error?.textContent).toContain('../outside.mdx');
|
|
174
|
+
expect(bootLine(container)).toBeNull();
|
|
175
|
+
await act(async () => root.unmount());
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('a declared sheet that will not read is shown with its path', async () => {
|
|
179
|
+
// Its own path: `safeSources` is the session-wide source cache, and a sheet an earlier
|
|
180
|
+
// case read successfully stays read.
|
|
181
|
+
const broken = '/app/content/themes/broken.mdx';
|
|
182
|
+
readFile.mockImplementation(async (path: string) => {
|
|
183
|
+
if (path === broken) throw new Error('ENOENT');
|
|
184
|
+
return '---\ntitle: Reference entry.\nrender: safe\n---\n\nThe body text.\n';
|
|
185
|
+
});
|
|
186
|
+
const { container, root } = mount();
|
|
187
|
+
await render(root, gate('all'), { ...meta({ stylesheets: ['themes/broken.mdx'] }), [broken]: {} });
|
|
188
|
+
expect(container.querySelector('.grove-decl-error')?.textContent).toContain(`${broken} — could not be read`);
|
|
189
|
+
expect(container.textContent).toContain('The body text.');
|
|
190
|
+
await act(async () => root.unmount());
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('declared stylesheets — the deadline', () => {
|
|
195
|
+
it('a sheet read that never answers stops holding the page, and says so', async () => {
|
|
196
|
+
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
|
197
|
+
try {
|
|
198
|
+
const stalled = '/app/content/themes/stalled.mdx';
|
|
199
|
+
readFile.mockImplementation((path: string) =>
|
|
200
|
+
path === stalled ? new Promise(() => undefined) : Promise.resolve('---\ntitle: Reference entry.\nrender: safe\n---\n\nThe body text.\n'),
|
|
201
|
+
);
|
|
202
|
+
const { container, root } = mount();
|
|
203
|
+
await render(root, gate('all'), { ...meta({ stylesheets: ['themes/stalled.mdx'] }), [stalled]: {} });
|
|
204
|
+
expect(bootLine(container)).toBe('Opening…');
|
|
205
|
+
await act(async () => {
|
|
206
|
+
vi.advanceTimersByTime(15_000);
|
|
207
|
+
});
|
|
208
|
+
for (let i = 0; i < 6; i++) await act(async () => {});
|
|
209
|
+
expect(bootLine(container)).toBeNull();
|
|
210
|
+
expect(container.querySelector('.grove-decl-error')?.textContent).toContain(`${stalled} — could not be read (no answer after 15 s)`);
|
|
211
|
+
await act(async () => root.unmount());
|
|
212
|
+
} finally {
|
|
213
|
+
vi.useRealTimers();
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
});
|
package/src/GroveWiki.tsx
CHANGED
|
@@ -44,9 +44,13 @@ import Search from './components/Search';
|
|
|
44
44
|
import Drawer from './components/Drawer';
|
|
45
45
|
import GroveAgent from './components/GroveAgent';
|
|
46
46
|
import ThemeAssets from './components/ThemeAssets';
|
|
47
|
-
import ContentTheme
|
|
47
|
+
import ContentTheme from './components/ContentTheme';
|
|
48
|
+
import BootMessage from './components/BootMessage';
|
|
48
49
|
import { themeAssetsFor } from './data/themeFonts';
|
|
49
|
-
import {
|
|
50
|
+
import { useContentStylesheets } from './hooks/useContentStylesheets';
|
|
51
|
+
import { criticalKeys } from './lib/criticalKeys';
|
|
52
|
+
import { criticalFailure, entryPending } from './lib/entryGate';
|
|
53
|
+
import { CorpusScanContext } from './lib/corpusScanContext';
|
|
50
54
|
|
|
51
55
|
declare const module: any;
|
|
52
56
|
|
|
@@ -270,44 +274,29 @@ export default function GroveWiki({
|
|
|
270
274
|
// metadata store and re-derives when the content set or the entry changes.
|
|
271
275
|
const allMeta = useAllMetadata() as Record<string, Record<string, unknown>>;
|
|
272
276
|
|
|
273
|
-
// ── Content-carried themes (R3-316)
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
const
|
|
277
|
+
// ── Content-carried themes (R3-316; MDX_FROM_MOUNT_SPEC D7) ─────────────────
|
|
278
|
+
// The stylesheets the home entry declares (`stylesheets:`); bodies are raw CSS
|
|
279
|
+
// behind frontmatter, gated by the grammar inside ContentTheme and admitted only
|
|
280
|
+
// into the lowest cascade layer. A declaration that names no entry, a sheet that
|
|
281
|
+
// will not read, and a sheet the grammar rejects all degrade to a status line
|
|
282
|
+
// naming it — never a silent drop, never a crash.
|
|
283
|
+
const stylesheets = useContentStylesheets(homeMeta?.stylesheets, homeKey());
|
|
280
284
|
const [rejectedSheet, setRejectedSheet] = useState<string | null>(null);
|
|
285
|
+
const sheetErrors = rejectedSheet ? [...stylesheets.errors, rejectedSheet] : stylesheets.errors;
|
|
286
|
+
|
|
287
|
+
// ── The entry gate (MDX_FROM_MOUNT_SPEC D8) ─────────────────────────────────
|
|
288
|
+
// Under dispatch the index fills in while the wiki is already on screen. The files
|
|
289
|
+
// that decide how THIS entry renders — itself, home, its layouts, its `frame:` — are
|
|
290
|
+
// read ahead of the rest, and the body waits for them: an unread row reads as
|
|
291
|
+
// `render` unset, which is the executing path. The chrome around it stays mounted.
|
|
292
|
+
const scanGate = useContext(CorpusScanContext);
|
|
293
|
+
const critical = criticalKeys(entryKey, allMeta, scanGate.readFailure);
|
|
294
|
+
const criticalSig = critical.join('|');
|
|
281
295
|
useEffect(() => {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
return Array.isArray(tags) && tags.includes('ui/stylesheet');
|
|
287
|
-
});
|
|
288
|
-
(async () => {
|
|
289
|
-
for (const p of paths) {
|
|
290
|
-
try {
|
|
291
|
-
const raw = await (await import('./lib/safeSources')).safeSources.read(p);
|
|
292
|
-
const parsed = parseFrontmatter(raw);
|
|
293
|
-
found.push({
|
|
294
|
-
path: p,
|
|
295
|
-
css: parsed.body,
|
|
296
|
-
declarations: {
|
|
297
|
-
...(Array.isArray(parsed.data.fonts) ? { fonts: parsed.data.fonts as never } : {}),
|
|
298
|
-
...(parsed.data.assets && typeof parsed.data.assets === 'object' ? { assets: parsed.data.assets as never } : {}),
|
|
299
|
-
},
|
|
300
|
-
});
|
|
301
|
-
} catch {
|
|
302
|
-
/* an unreadable stylesheet contributes nothing — same as an absent one */
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
if (alive) setContentSheets(found);
|
|
306
|
-
})();
|
|
307
|
-
return () => {
|
|
308
|
-
alive = false;
|
|
309
|
-
};
|
|
310
|
-
}, [allMeta]);
|
|
296
|
+
scanGate.prioritize(criticalSig.split('|'));
|
|
297
|
+
}, [scanGate, criticalSig]);
|
|
298
|
+
const pending = entryPending(critical, scanGate.isSettled, stylesheets.status);
|
|
299
|
+
const failure = criticalFailure(critical, scanGate.readFailure);
|
|
311
300
|
const chain: string[] = layoutChainForKey(entryKey, allMeta);
|
|
312
301
|
const frameNone = meta?.frame === 'none' || meta?.frame === false;
|
|
313
302
|
|
|
@@ -448,17 +437,21 @@ export default function GroveWiki({
|
|
|
448
437
|
@font-face rules (R3-315's mechanism); switching themes revokes the
|
|
449
438
|
outgoing set and re-mints. */}
|
|
450
439
|
<ThemeAssets declarations={themeAssetsFor(theme)} />
|
|
451
|
-
<ContentTheme sheets={
|
|
440
|
+
<ContentTheme sheets={stylesheets.sheets} onRejected={(path, v) => setRejectedSheet(`${path}:${v.line} — ${v.reason}`)} />
|
|
452
441
|
{/* R3-627: remember where the reader was on the entry they leave, and put
|
|
453
442
|
them back there on Back. Grove's scroller is its own container, so the
|
|
454
443
|
ref goes across; `useScrollReset` above stands down on the same
|
|
455
444
|
traversal so the two do not fight. */}
|
|
456
445
|
<ScrollRestoration scroller={scrollRef} />
|
|
457
446
|
<div className="device__scroll" ref={scrollRef}>
|
|
458
|
-
{
|
|
447
|
+
{sheetErrors.length > 0 ? (
|
|
459
448
|
<div className="grove-decl-error" role="status">
|
|
460
|
-
<strong>A stylesheet
|
|
461
|
-
<
|
|
449
|
+
<strong>A stylesheet this corpus declares could not be applied.</strong>
|
|
450
|
+
<ul>
|
|
451
|
+
{sheetErrors.map((e, i) => (
|
|
452
|
+
<li key={i}>{e}</li>
|
|
453
|
+
))}
|
|
454
|
+
</ul>
|
|
462
455
|
</div>
|
|
463
456
|
) : null}
|
|
464
457
|
{rejectedComponents.length > 0 ? (
|
|
@@ -474,7 +467,7 @@ export default function GroveWiki({
|
|
|
474
467
|
</div>
|
|
475
468
|
) : null}
|
|
476
469
|
<div className="grove-shell" data-nav={frameNone ? undefined : navMode}>
|
|
477
|
-
{renderLayers(chain, useDefault, safe)}
|
|
470
|
+
{failure ? <BootMessage>{failure}</BootMessage> : pending ? <BootMessage /> : renderLayers(chain, useDefault, safe)}
|
|
478
471
|
</div>
|
|
479
472
|
</div>
|
|
480
473
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
/** The single line Grove shows while it cannot paint an entry yet — booting, listing the
|
|
4
|
+
* bundle, or waiting on the files an entry needs — and, given a message, when boot fails. */
|
|
5
|
+
export default function BootMessage({ children = 'Opening…' }: { children?: ReactNode }) {
|
|
6
|
+
return (
|
|
7
|
+
<div className="grove-boot">
|
|
8
|
+
<p className="grove-boot__msg">{children}</p>
|
|
9
|
+
</div>
|
|
10
|
+
);
|
|
11
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
// R3-316's component + escape-hatch exits:
|
|
3
|
-
// • a clean
|
|
3
|
+
// • a clean content stylesheet renders into @layer grove.content (and its declared
|
|
4
4
|
// faces mint through the R3-315 path — never a url());
|
|
5
5
|
// • a rejected sheet names the line and degrades (no style element for it);
|
|
6
6
|
// • THE ESCAPE HATCH: the hiding vectors a content stylesheet would try are
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
-
import { gateStylesheet } from '../lib/contentStylesheet';
|
|
2
|
+
import { gateStylesheet, type ContentStylesheet } from '../lib/contentStylesheet';
|
|
3
3
|
import { mintThemeAssets, type MintedThemeAssets } from '../lib/themeAssets';
|
|
4
4
|
import { openFs } from '@immediately-run/sdk';
|
|
5
5
|
|
|
6
|
-
// A bundle's own look, carried as content (R3-316): each
|
|
7
|
-
//
|
|
8
|
-
// `@import` or `@font-face` is
|
|
9
|
-
// dropped) and admitted ONLY into the lowest cascade layer (`grove.content`),
|
|
6
|
+
// A bundle's own look, carried as content (R3-316): each stylesheet the home entry
|
|
7
|
+
// declares (`stylesheets:`, MDX_FROM_MOUNT_SPEC D7) has its body gated by the grammar
|
|
8
|
+
// (declarations only — a selector, at-rule, `url(`, `@import` or `@font-face` is
|
|
9
|
+
// rejected with the line named, never silently dropped) and admitted ONLY into the lowest cascade layer (`grove.content`),
|
|
10
10
|
// where it can style tokens and nothing else. The entry's frontmatter may
|
|
11
11
|
// declare `fonts:`/`assets:` — minted by the engine exactly as an engine theme's
|
|
12
12
|
// are, so a content theme can change the reading face without naming a location.
|
|
@@ -22,15 +22,6 @@ import { openFs } from '@immediately-run/sdk';
|
|
|
22
22
|
|
|
23
23
|
const ROOT_MOUNT = { path: '/', type: 'repo' } as const;
|
|
24
24
|
|
|
25
|
-
export interface ContentStylesheet {
|
|
26
|
-
/** The entry's absolute fs path (the declaring file for its asset refs). */
|
|
27
|
-
path: string;
|
|
28
|
-
/** The raw body bytes (CSS). */
|
|
29
|
-
css: string;
|
|
30
|
-
/** The entry's declared fonts/assets, if any. */
|
|
31
|
-
declarations: { fonts?: unknown; assets?: unknown };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
25
|
interface Props {
|
|
35
26
|
sheets: ContentStylesheet[];
|
|
36
27
|
/** Where a rejected sheet's verdict goes (the reader-facing degrade). */
|
|
@@ -5,39 +5,56 @@
|
|
|
5
5
|
// wiki renders with empty nav, empty sidebar, no search, no backlinks and no routing —
|
|
6
6
|
// silently, because an absent index is indistinguishable from an empty bundle.
|
|
7
7
|
//
|
|
8
|
-
// The
|
|
9
|
-
//
|
|
10
|
-
//
|
|
8
|
+
// The index is PROGRESSIVE (MDX_FROM_MOUNT_SPEC D8): once the listing is done every key is
|
|
9
|
+
// present, and rows fill in as files are read. The caller hands `metadata` to a
|
|
10
|
+
// `MetadataSource`, which is where `useMetadataQuery` / `useFileMetadata` / `useAllMetadata`
|
|
11
|
+
// read from, and hands `scan` to the entry gate, which asks whether the rows it needs have
|
|
12
|
+
// been read.
|
|
11
13
|
|
|
12
|
-
import { useEffect, useState } from 'react';
|
|
14
|
+
import { useEffect, useState, useSyncExternalStore } from 'react';
|
|
13
15
|
import fs from 'fs';
|
|
14
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
createCorpusScan,
|
|
18
|
+
type CorpusMetadata,
|
|
19
|
+
type CorpusScan,
|
|
20
|
+
type CorpusScanSnapshot,
|
|
21
|
+
type CorpusScanStatus,
|
|
22
|
+
type ScanFs,
|
|
23
|
+
} from '../lib/corpusScan';
|
|
15
24
|
|
|
16
25
|
export interface BundleIndex {
|
|
17
|
-
/** `idle` — a fork, nothing to scan · `
|
|
18
|
-
|
|
26
|
+
/** `idle` — a fork, nothing to scan · `listing` — hold the render, no key is known yet ·
|
|
27
|
+
* `reading` — every key is known, rows are filling in · `complete` — every row is read. */
|
|
28
|
+
status: 'idle' | CorpusScanStatus;
|
|
29
|
+
/** Null until the listing is done (and always for a fork). */
|
|
19
30
|
metadata: CorpusMetadata | null;
|
|
31
|
+
/** The running scan, for the entry gate. Null for a fork and before the scan starts. */
|
|
32
|
+
scan: CorpusScan | null;
|
|
20
33
|
}
|
|
21
34
|
|
|
35
|
+
const LISTING: CorpusScanSnapshot = { status: 'listing', metadata: {} };
|
|
36
|
+
const noSubscribe = () => () => undefined;
|
|
37
|
+
const listingSnapshot = () => LISTING;
|
|
38
|
+
|
|
22
39
|
/** Scan `root`, or do nothing when it is null (the fork packaging). */
|
|
23
40
|
export function useBundleMetadata(root: string | null): BundleIndex {
|
|
24
|
-
const [
|
|
41
|
+
const [owned, setOwned] = useState<{ root: string; scan: CorpusScan } | null>(null);
|
|
25
42
|
|
|
26
43
|
useEffect(() => {
|
|
27
|
-
if (!root
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
};
|
|
38
|
-
}, [root, index?.root]);
|
|
44
|
+
if (!root) return;
|
|
45
|
+
const scan = createCorpusScan(root, fs.promises as unknown as ScanFs);
|
|
46
|
+
// The scan is an external resource this effect owns: created here, disposed below.
|
|
47
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
48
|
+
setOwned({ root, scan });
|
|
49
|
+
return () => scan.dispose();
|
|
50
|
+
}, [root]);
|
|
51
|
+
|
|
52
|
+
const scan = owned && owned.root === root ? owned.scan : null;
|
|
53
|
+
const snap = useSyncExternalStore(scan ? scan.subscribe : noSubscribe, scan ? scan.snapshot : listingSnapshot);
|
|
39
54
|
|
|
40
|
-
if (!root) return { status: 'idle', metadata: null };
|
|
41
|
-
|
|
42
|
-
|
|
55
|
+
if (!root) return { status: 'idle', metadata: null, scan: null };
|
|
56
|
+
// A bundle that resolves to nothing is still a result: `complete` with an empty map
|
|
57
|
+
// renders the 404 index, which tells the reader the folder has no entries.
|
|
58
|
+
if (snap.status === 'listing') return { status: 'listing', metadata: null, scan };
|
|
59
|
+
return { status: snap.status, metadata: snap.metadata, scan };
|
|
43
60
|
}
|
|
@@ -95,8 +95,8 @@ async function loadDeclared(root: string): Promise<Omit<ContentComponents, 'stat
|
|
|
95
95
|
*
|
|
96
96
|
* Returns `loading` until every declaration has resolved, so the caller can hold the
|
|
97
97
|
* content paint. That is the §2 invariant — compose the complete provider before content
|
|
98
|
-
* paints, never render into a partial one — and it costs
|
|
99
|
-
* already gates on `useOpenWikiBoot` and
|
|
98
|
+
* paints, never render into a partial one — and it costs little here because Grove
|
|
99
|
+
* already gates on `useOpenWikiBoot` and on the bundle listing. Rendering into a
|
|
100
100
|
* half-composed provider would flash a missing-component error for `<RoadmapBoard>` until
|
|
101
101
|
* registration landed, which is exactly the error this path removes.
|
|
102
102
|
*/
|