@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,89 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// `useCatalogAnswered` — rendered in a real React tree, driven through the SDK's REAL
|
|
3
|
+
// catalog push channel against an emitting stub transport. The whole point of the hook is
|
|
4
|
+
// a timing property of that channel, so a mock of either would assert nothing.
|
|
5
|
+
//
|
|
6
|
+
// The property under test: the push channel has no value-equality check, so a host answer
|
|
7
|
+
// of `[]` — identical to the initial value — still notifies. That second notification is
|
|
8
|
+
// the signal. A test that only pushed a NON-empty catalog would pass against a naive
|
|
9
|
+
// `catalog.length > 0` implementation and prove nothing, which is why the empty-answer
|
|
10
|
+
// case is the one that carries this file.
|
|
11
|
+
import { describe, it, expect, vi, beforeAll } from 'vitest';
|
|
12
|
+
import { act } from 'react';
|
|
13
|
+
import { createRoot } from 'react-dom/client';
|
|
14
|
+
|
|
15
|
+
type Handler = (msg: Record<string, unknown>) => void;
|
|
16
|
+
const handlers = new Set<Handler>();
|
|
17
|
+
const emit = (msg: Record<string, unknown>): void => {
|
|
18
|
+
for (const h of handlers) h(msg);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Installed BEFORE the SDK is imported: the channel resolves its transport lazily, and
|
|
22
|
+
// the hook module subscribes at import time.
|
|
23
|
+
(globalThis as { __immediatelyRun__?: unknown }).__immediatelyRun__ = {
|
|
24
|
+
transport: {
|
|
25
|
+
sendMessage: vi.fn(),
|
|
26
|
+
protocolRequest: vi.fn(async () => ({})),
|
|
27
|
+
onMessage: (handler: Handler) => {
|
|
28
|
+
handlers.add(handler);
|
|
29
|
+
return { dispose: () => handlers.delete(handler) };
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
let useCatalogAnswered: typeof import('./useCatalogAnswered').useCatalogAnswered;
|
|
35
|
+
let getCatalog: typeof import('@immediately-run/sdk').getCatalog;
|
|
36
|
+
|
|
37
|
+
beforeAll(async () => {
|
|
38
|
+
({ useCatalogAnswered } = await import('./useCatalogAnswered'));
|
|
39
|
+
({ getCatalog } = await import('@immediately-run/sdk'));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** Render a probe recording the hook's value on every render. */
|
|
43
|
+
const renderProbe = async () => {
|
|
44
|
+
const seen: boolean[] = [];
|
|
45
|
+
const Probe = () => {
|
|
46
|
+
seen.push(useCatalogAnswered());
|
|
47
|
+
return null;
|
|
48
|
+
};
|
|
49
|
+
const root = createRoot(document.createElement('div'));
|
|
50
|
+
await act(async () => {
|
|
51
|
+
root.render(<Probe />);
|
|
52
|
+
});
|
|
53
|
+
return { seen, unmount: () => act(async () => root.unmount()) };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
describe('useCatalogAnswered', () => {
|
|
57
|
+
it('is false before the host answers — the subscribe-time call is not an answer', async () => {
|
|
58
|
+
// `onCatalogChange` invokes its listener once, SYNCHRONOUSLY, with the current value
|
|
59
|
+
// when you subscribe. If that counted, the flag would be true from module load and
|
|
60
|
+
// the reach card would read an unanswered catalog as "not granted" — the exact defect
|
|
61
|
+
// this hook exists to prevent.
|
|
62
|
+
getCatalog(); // start the channel the way the app does
|
|
63
|
+
const { seen, unmount } = await renderProbe();
|
|
64
|
+
expect(seen[seen.length - 1]).toBe(false);
|
|
65
|
+
await unmount();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('an EMPTY catalog answer flips it, and RE-RENDERS — an empty answer is still an answer', async () => {
|
|
69
|
+
const { seen, unmount } = await renderProbe();
|
|
70
|
+
expect(seen[seen.length - 1]).toBe(false);
|
|
71
|
+
const before = seen.length;
|
|
72
|
+
|
|
73
|
+
await act(async () => {
|
|
74
|
+
emit({ type: 'api-catalog', methods: [] });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Both halves matter: a hook that returned the flag without subscribing would show
|
|
78
|
+
// the right value only on the NEXT unrelated render.
|
|
79
|
+
expect(seen.length).toBeGreaterThan(before);
|
|
80
|
+
expect(seen[seen.length - 1]).toBe(true);
|
|
81
|
+
await unmount();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('stays true for a probe mounted after the answer', async () => {
|
|
85
|
+
const { seen, unmount } = await renderProbe();
|
|
86
|
+
expect(seen[seen.length - 1]).toBe(true);
|
|
87
|
+
await unmount();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
import { onCatalogChange } from '@immediately-run/sdk';
|
|
3
|
+
|
|
4
|
+
// Has the host actually ANSWERED the grant-filtered method catalog?
|
|
5
|
+
//
|
|
6
|
+
// `useCatalog()` starts `[]` and an empty catalog is a legitimate answer (a frame granted
|
|
7
|
+
// nothing), so the value alone cannot distinguish "granted nothing" from "has not replied
|
|
8
|
+
// yet". The reach card needs that distinction: `!chatGranted` is true in both cases, and
|
|
9
|
+
// reading it as "not granted" before the host has spoken claims a consent state about a
|
|
10
|
+
// frame that may well hold the grant (grove#75 round 1).
|
|
11
|
+
//
|
|
12
|
+
// It is derivable here with no SDK change, because the push channel has **no
|
|
13
|
+
// value-equality check**: every host push notifies subscribers, including one whose value
|
|
14
|
+
// equals the initial `[]`. So a notification after the subscribe means the host replied.
|
|
15
|
+
//
|
|
16
|
+
// THE SUBSCRIBE-TIME CALL. `onCatalogChange` invokes the listener once, SYNCHRONOUSLY,
|
|
17
|
+
// with the current value before returning. That call is the subscribe, not an answer — but
|
|
18
|
+
// it is not worthless either, and an earlier version of this file threw it away with a
|
|
19
|
+
// blanket reset. A NON-EMPTY catalog at subscribe can only have come from the host, so it
|
|
20
|
+
// is proof the answer already landed. Reading it that way is what makes this correct when
|
|
21
|
+
// the module is evaluated LATE: grove is also published as a pinned library
|
|
22
|
+
// (`src/lib.ts`), so "imported before any render" is an assumption about one embedding,
|
|
23
|
+
// not a guarantee (grove#75 round 3).
|
|
24
|
+
//
|
|
25
|
+
// RESIDUALS, stated rather than implied:
|
|
26
|
+
// - a host that answers `[]` BEFORE this module is evaluated is indistinguishable from
|
|
27
|
+
// one that has not answered, and leaves the flag false for the realm's life. The Q&A
|
|
28
|
+
// row then stays neutral rather than naming a cause — the same honest "we have not been
|
|
29
|
+
// told" that the provider channel's `unknown` already renders, but it will not
|
|
30
|
+
// self-correct, because the host does not push again;
|
|
31
|
+
// - a host that never answers at all leaves it false forever, for the same reason;
|
|
32
|
+
// - importing this module is what STARTS the catalog channel and sends
|
|
33
|
+
// `REQUEST_API_CATALOG`. That used to be the first `useCatalog()` render. The channel
|
|
34
|
+
// starts at most once and the poll is idempotent, but the timing moved.
|
|
35
|
+
let answered = false;
|
|
36
|
+
let subscribing = true;
|
|
37
|
+
const waiters = new Set<() => void>();
|
|
38
|
+
|
|
39
|
+
onCatalogChange((catalog) => {
|
|
40
|
+
if (subscribing) {
|
|
41
|
+
// Synchronous, at subscribe: the current value, not an answer — except that a
|
|
42
|
+
// non-empty catalog could only have come from the host.
|
|
43
|
+
answered = catalog.length > 0;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (answered) return;
|
|
47
|
+
answered = true;
|
|
48
|
+
for (const w of waiters) w();
|
|
49
|
+
});
|
|
50
|
+
subscribing = false;
|
|
51
|
+
|
|
52
|
+
const subscribe = (onStoreChange: () => void): (() => void) => {
|
|
53
|
+
waiters.add(onStoreChange);
|
|
54
|
+
return () => {
|
|
55
|
+
waiters.delete(onStoreChange);
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
const getSnapshot = (): boolean => answered;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether the host has answered the method catalog. Re-renders when it flips.
|
|
62
|
+
*
|
|
63
|
+
* `useSyncExternalStore` rather than `useState` + `useEffect`, deliberately: the flag is
|
|
64
|
+
* read during render and written outside React, and a passive effect leaves a window
|
|
65
|
+
* between the two. An answer landing in that window used to be lost — the effect saw the
|
|
66
|
+
* flag already set, returned early without registering a waiter, and the component
|
|
67
|
+
* rendered a stale `false` with nothing left to wake it (grove#75 round 3, reproduced).
|
|
68
|
+
* `useSyncExternalStore` re-reads the snapshot after subscribing and re-renders if it
|
|
69
|
+
* moved, which is exactly that hole.
|
|
70
|
+
*/
|
|
71
|
+
export function useCatalogAnswered(): boolean {
|
|
72
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
73
|
+
}
|
|
@@ -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
|
*/
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The stylesheets a corpus declares on its home entry, read (MDX_FROM_MOUNT_SPEC D7).
|
|
2
|
+
//
|
|
3
|
+
// `loading` is DERIVED in render — the loaded set is compared with the declared one — not
|
|
4
|
+
// set by an effect, so the render in which a declaration first appears (home was just read)
|
|
5
|
+
// already reports `loading`. The entry gate reads it; a render that saw `ready` there would
|
|
6
|
+
// paint the entry once unstyled.
|
|
7
|
+
import { useEffect, useState } from 'react';
|
|
8
|
+
import { declaredStylesheets, sheetFromSource, type ContentStylesheet } from '../lib/contentStylesheet';
|
|
9
|
+
import { safeSources } from '../lib/safeSources';
|
|
10
|
+
|
|
11
|
+
export interface ContentStylesheets {
|
|
12
|
+
status: 'loading' | 'ready';
|
|
13
|
+
sheets: ContentStylesheet[];
|
|
14
|
+
/** Reader-facing lines: a declaration that names no entry, or a sheet that did not read. */
|
|
15
|
+
errors: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Loaded {
|
|
19
|
+
sig: string;
|
|
20
|
+
sheets: ContentStylesheet[];
|
|
21
|
+
errors: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const NONE: Loaded = { sig: '', sheets: [], errors: [] };
|
|
25
|
+
|
|
26
|
+
/** How long first paint waits for one declared sheet. A sheet read is one file over the
|
|
27
|
+
* host channel — well under a second even as a cold GitHub blob — so a read still open
|
|
28
|
+
* after this is stalled, and the page paints without it and says so. */
|
|
29
|
+
const SHEET_READ_DEADLINE_MS = 15_000;
|
|
30
|
+
|
|
31
|
+
function withDeadline<T>(read: Promise<T>): Promise<T> {
|
|
32
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
33
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
34
|
+
timer = setTimeout(() => reject(new Error(`no answer after ${SHEET_READ_DEADLINE_MS / 1000} s`)), SHEET_READ_DEADLINE_MS);
|
|
35
|
+
});
|
|
36
|
+
return Promise.race([read, deadline]).finally(() => clearTimeout(timer));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function useContentStylesheets(declared: unknown, homeKey: string): ContentStylesheets {
|
|
40
|
+
const { keys, errors: declarationErrors } = declaredStylesheets(declared, homeKey);
|
|
41
|
+
const sig = keys.join('|');
|
|
42
|
+
const [loaded, setLoaded] = useState<Loaded>(NONE);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!sig) return;
|
|
46
|
+
let alive = true;
|
|
47
|
+
const paths = sig.split('|');
|
|
48
|
+
// `allSettled` never rejects, so this chain has no rejection to lose.
|
|
49
|
+
void Promise.allSettled(paths.map((p) => withDeadline(safeSources.read(p)))).then((results) => {
|
|
50
|
+
if (!alive) return;
|
|
51
|
+
const sheets: ContentStylesheet[] = [];
|
|
52
|
+
const errors: string[] = [];
|
|
53
|
+
results.forEach((r, i) => {
|
|
54
|
+
if (r.status === 'fulfilled') sheets.push(sheetFromSource(paths[i], r.value));
|
|
55
|
+
else errors.push(`${paths[i]} — could not be read (${r.reason instanceof Error ? r.reason.message : String(r.reason)})`);
|
|
56
|
+
});
|
|
57
|
+
setLoaded({ sig, sheets, errors });
|
|
58
|
+
});
|
|
59
|
+
return () => {
|
|
60
|
+
alive = false;
|
|
61
|
+
};
|
|
62
|
+
}, [sig]);
|
|
63
|
+
|
|
64
|
+
if (!sig) return { status: 'ready', sheets: [], errors: declarationErrors };
|
|
65
|
+
if (loaded.sig !== sig) return { status: 'loading', sheets: [], errors: declarationErrors };
|
|
66
|
+
return { status: 'ready', sheets: loaded.sheets, errors: [...declarationErrors, ...loaded.errors] };
|
|
67
|
+
}
|
package/src/lib/content.test.ts
CHANGED
|
@@ -147,7 +147,7 @@ describe('linkKind — which hrefs may become a navigating <a>', () => {
|
|
|
147
147
|
// dispatch declares CORPUS-relative (the host joins its chroot prefix — the corpus's
|
|
148
148
|
// repo-side location is host knowledge this app cannot see).
|
|
149
149
|
import { viewedDocumentForTarget } from './content';
|
|
150
|
-
import { setContentRoot, resetContentRoot } from './contentRoot';
|
|
150
|
+
import { setContentRoot, resetContentRoot, isDispatched } from './contentRoot';
|
|
151
151
|
import { afterEach } from 'vitest';
|
|
152
152
|
|
|
153
153
|
describe('viewedDocumentForTarget — the R3-268 declaration path space', () => {
|
|
@@ -195,6 +195,25 @@ describe('viewedDocumentForTarget — the R3-268 declaration path space', () =>
|
|
|
195
195
|
// answer "which PATH?" as well as "which entry?" — `[the handbook](handbook)` names
|
|
196
196
|
// something real and must not render as a broken link.
|
|
197
197
|
describe('hrefTargetKey — resolution without the entry-file requirement', () => {
|
|
198
|
+
afterEach(resetContentRoot);
|
|
199
|
+
it('R3-184 S2 — the $fs: clamp, ON THE RENDER PATH (the join: isDispatched → resolveLinkTarget)', () => {
|
|
200
|
+
// The clamp that is in force lives in hrefTargetKey's linkSpaceOpts: the
|
|
201
|
+
// decision (isDispatched) and the emission (resolveLinkTarget) join in ONE
|
|
202
|
+
// call, and THIS is the join — ways_of_working §4's rule that a decision and
|
|
203
|
+
// an emitter can both be tested while the line joining them is not.
|
|
204
|
+
// Fork (own repo): $fs: stays mount-absolute, as shipped (R3-273).
|
|
205
|
+
expect(hrefTargetKey('$fs:/mnt/aaa/x.mdx', HOME)).toBe('/mnt/aaa/x.mdx');
|
|
206
|
+
// Dispatched (a corpus mount): the shared resolver clamps $fs: onto the
|
|
207
|
+
// bundle root (R3-319's bundleChrooted), so the app-level mount point is
|
|
208
|
+
// not nameable — the resolved target is a CORPUS path, and only a real
|
|
209
|
+
// corpus entry there resolves downstream.
|
|
210
|
+
setContentRoot('/mnt/chroot1');
|
|
211
|
+
expect(hrefTargetKey('$fs:/mnt/aaa/x.mdx', '/mnt/chroot1/h.mdx')).toBe('/mnt/chroot1/mnt/aaa/x.mdx');
|
|
212
|
+
// And the same corpus path by its ordinary spelling resolves identically —
|
|
213
|
+
// the clamp makes $fs:/p and /p the same address, which is the invariant.
|
|
214
|
+
expect(hrefTargetKey('/mnt/aaa/x.mdx', '/mnt/chroot1/h.mdx')).toBe('/mnt/chroot1/mnt/aaa/x.mdx');
|
|
215
|
+
});
|
|
216
|
+
|
|
198
217
|
it('resolves a folder href the entry-candidate list rejects', () => {
|
|
199
218
|
expect(hrefKeyCandidates('handbook', HOME)).toEqual([]);
|
|
200
219
|
expect(hrefTargetKey('handbook', HOME)).toBe('/app/content/handbook');
|
|
@@ -275,3 +294,30 @@ describe('link-space parity (LINK_SPACE_FIXTURE, R3-277b)', () => {
|
|
|
275
294
|
);
|
|
276
295
|
});
|
|
277
296
|
});
|
|
297
|
+
|
|
298
|
+
describe('isDispatched — the R3-184 S2 `$fs:` clamp discriminator', () => {
|
|
299
|
+
afterEach(resetContentRoot);
|
|
300
|
+
// The clamp caller (GroveWiki's LinkSpaceContext) keys the SDK's
|
|
301
|
+
// `bundleChrooted` flag on this: a DISPATCHED corpus renders inside a
|
|
302
|
+
// host-minted chroot, so `$fs:` resolves within the corpus and a federated
|
|
303
|
+
// mount materialised beside it is not nameable from a corpus document; the
|
|
304
|
+
// fork's `$fs:` stays mount-absolute, as shipped. The flag derivation is
|
|
305
|
+
// one line over THIS module's state. The JOIN — this decision reaching the
|
|
306
|
+
// shared resolver on the render path — is pinned by the `$fs:` clamp case in
|
|
307
|
+
// the hrefTargetKey describe above (the two tests are the pair: the decision
|
|
308
|
+
// here, the emission there, and the wire between them asserted once).
|
|
309
|
+
it('fork (own repo): false — the corpus is the engine repo, not a chroot', () => {
|
|
310
|
+
expect(isDispatched()).toBe(false);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('dispatch (a corpus mount): true', () => {
|
|
314
|
+
setContentRoot('/mnt/0a1b2c3d');
|
|
315
|
+
expect(isDispatched()).toBe(true);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('the afterEach resets the root — a later reader sees the fork state', () => {
|
|
319
|
+
// The reset itself is the convention's job (afterEach, above); asserting it
|
|
320
|
+
// keeps the leak-visible property testable rather than assumed.
|
|
321
|
+
expect(isDispatched()).toBe(false);
|
|
322
|
+
});
|
|
323
|
+
});
|
package/src/lib/content.ts
CHANGED
|
@@ -186,6 +186,25 @@ export function hrefKeyCandidates(href: string, fromKey: string): string[] {
|
|
|
186
186
|
* The two callers ask different questions of the same resolution — "which entry?" and
|
|
187
187
|
* "which path?" — so the resolution lives here once.
|
|
188
188
|
*/
|
|
189
|
+
// R3-184 S2 (PERSISTENCE_SPEC §8.3) — the `$fs:` clamp ON the render path, the one
|
|
190
|
+
// the spec's dated note names as the missing piece. This file's hrefTargetKey is
|
|
191
|
+
// where a corpus document's link targets actually resolve (grove's own WikiLink
|
|
192
|
+
// override routes here), so the clamp lives HERE: `bundleChrooted: isDispatched()`
|
|
193
|
+
// makes the shared resolver treat `$fs:/p` exactly like `/p` under the bundle root
|
|
194
|
+
// (R3-319), so a dispatched corpus document's `$fs:/mnt/{hash}/…` cannot name a
|
|
195
|
+
// federated mount materialised beside it — the app-level mount point is not a
|
|
196
|
+
// corpus path. The fork (own repo, not a corpus mount) keeps `$fs:`
|
|
197
|
+
// mount-absolute, as shipped. The LinkSpaceContext field in GroveWiki carries the
|
|
198
|
+
// same flag for the SDK's generic component consumers, the day a release that
|
|
199
|
+
// forwards it is pinned.
|
|
200
|
+
const linkSpaceOpts = (fromKey: string) => ({
|
|
201
|
+
currentFile: fromKey,
|
|
202
|
+
// The canonical spelling (mdx-plugins reads bundleRoot-else-corpusRoot; the
|
|
203
|
+
// deprecated corpusRoot opts field stays for older consumers, not for new code).
|
|
204
|
+
bundleRoot: getContentRoot(),
|
|
205
|
+
bundleChrooted: isDispatched(),
|
|
206
|
+
});
|
|
207
|
+
|
|
189
208
|
export function hrefTargetKey(href: string, fromKey: string): string | null {
|
|
190
209
|
if (!href) return null;
|
|
191
210
|
if (/^(https?:|mailto:|tel:|#)/i.test(href)) return null;
|
|
@@ -198,7 +217,7 @@ export function hrefTargetKey(href: string, fromKey: string): string | null {
|
|
|
198
217
|
// targets resolve against the authoring file; `$fs:` targets resolve
|
|
199
218
|
// mount-absolute (addressing, never reach — R3-273).
|
|
200
219
|
if (!path.startsWith('/')) {
|
|
201
|
-
const rel = resolveLinkTarget(path,
|
|
220
|
+
const rel = resolveLinkTarget(path, linkSpaceOpts(fromKey));
|
|
202
221
|
if (rel.state !== 'resolved') return null;
|
|
203
222
|
// Confinement, not tidiness: an href in foreign content is untrusted, and the
|
|
204
223
|
// result flows into `fs` reads. In the default space anything that lands outside
|
|
@@ -221,7 +240,7 @@ export function hrefTargetKey(href: string, fromKey: string): string | null {
|
|
|
221
240
|
const legacy = normalizeAbsolute(legacyAnchor + path);
|
|
222
241
|
if (legacy.startsWith(contentDir())) return legacy;
|
|
223
242
|
}
|
|
224
|
-
const corpusAnchored = resolveLinkTarget(path,
|
|
243
|
+
const corpusAnchored = resolveLinkTarget(path, linkSpaceOpts(fromKey));
|
|
225
244
|
if (corpusAnchored.state === 'resolved' && corpusAnchored.path.startsWith(contentDir())) {
|
|
226
245
|
return corpusAnchored.path;
|
|
227
246
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// NAMED; comment- and string-hidden attempts do not change the verdict; clean
|
|
3
3
|
// declarations pass with their quoted values intact.
|
|
4
4
|
import { describe, it, expect } from 'vitest';
|
|
5
|
-
import { gateStylesheet, blankCssNoise } from './contentStylesheet';
|
|
5
|
+
import { gateStylesheet, blankCssNoise, declaredStylesheets, sheetFromSource } from './contentStylesheet';
|
|
6
6
|
|
|
7
7
|
describe('clean sheets pass', () => {
|
|
8
8
|
it('declarations-only CSS is admitted, quoted values intact', () => {
|
|
@@ -78,3 +78,59 @@ describe('the existence-oracle payload — the attack the grammar exists for', (
|
|
|
78
78
|
if (!v.ok) expect(v.reason).toMatch(/selector|url\(/);
|
|
79
79
|
});
|
|
80
80
|
});
|
|
81
|
+
|
|
82
|
+
describe('declaredStylesheets — the home entry names its sheets (MDX_FROM_MOUNT_SPEC D7)', () => {
|
|
83
|
+
const HOME = '/app/content/home.mdx';
|
|
84
|
+
|
|
85
|
+
it('resolves each value like a link in the home entry', () => {
|
|
86
|
+
expect(declaredStylesheets(['themes/paper.mdx', './themes/ink.md'], HOME)).toEqual({
|
|
87
|
+
keys: ['/app/content/themes/paper.mdx', '/app/content/themes/ink.md'],
|
|
88
|
+
errors: [],
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('absent means none, silently', () => {
|
|
93
|
+
expect(declaredStylesheets(undefined, HOME)).toEqual({ keys: [], errors: [] });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('a value that is not a list is an error naming the key, not a guess', () => {
|
|
97
|
+
const { keys, errors } = declaredStylesheets('themes/paper.mdx', HOME);
|
|
98
|
+
expect(keys).toEqual([]);
|
|
99
|
+
expect(errors).toEqual([expect.stringContaining('`stylesheets:`')]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('a value that names no entry inside the corpus is an error naming the value', () => {
|
|
103
|
+
const { keys, errors } = declaredStylesheets(['../outside.mdx', 'themes/paper.css', 42, 'themes/paper.mdx'], HOME);
|
|
104
|
+
expect(keys).toEqual(['/app/content/themes/paper.mdx']);
|
|
105
|
+
expect(errors).toHaveLength(3);
|
|
106
|
+
expect(errors[0]).toContain('../outside.mdx');
|
|
107
|
+
expect(errors[1]).toContain('themes/paper.css');
|
|
108
|
+
expect(errors[2]).toContain('42');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('a `$fs:` value may address outside the corpus as a link; as a stylesheet it may not', () => {
|
|
112
|
+
const { keys, errors } = declaredStylesheets(['$fs:/app/src/evil.mdx', '$fs:/mnt/0123abcd/secret.mdx'], HOME);
|
|
113
|
+
expect(keys).toEqual([]);
|
|
114
|
+
expect(errors).toHaveLength(2);
|
|
115
|
+
expect(errors[0]).toContain('$fs:/app/src/evil.mdx');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('a sheet declared twice is read once', () => {
|
|
119
|
+
expect(declaredStylesheets(['themes/paper.mdx', './themes/paper.mdx'], HOME).keys).toEqual([
|
|
120
|
+
'/app/content/themes/paper.mdx',
|
|
121
|
+
]);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('sheetFromSource', () => {
|
|
126
|
+
it('splits the body CSS from the declared fonts and assets', () => {
|
|
127
|
+
const sheet = sheetFromSource(
|
|
128
|
+
'/app/content/themes/paper.mdx',
|
|
129
|
+
'---\nfonts:\n - family: Lora\nassets:\n paper: ./paper.jpg\n---\n--wash: var(--asset-paper);\n',
|
|
130
|
+
);
|
|
131
|
+
expect(sheet.path).toBe('/app/content/themes/paper.mdx');
|
|
132
|
+
expect(sheet.css.trim()).toBe('--wash: var(--asset-paper);');
|
|
133
|
+
expect(sheet.declarations.assets).toEqual({ paper: './paper.jpg' });
|
|
134
|
+
expect(Array.isArray(sheet.declarations.fonts)).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Content stylesheets (R3-316; plan 05-content-carried-themes): how a corpus declares them
|
|
2
|
+
// and the grammar gate every one passes.
|
|
2
3
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
4
|
+
// DECLARATION (MDX_FROM_MOUNT_SPEC D7). The home entry lists them — `stylesheets:
|
|
5
|
+
// [themes/paper.mdx]` — resolved like a link in the home entry: relative to it, confined to
|
|
6
|
+
// the corpus. They used to be discovered by a frontmatter tag, which made the wiki's look
|
|
7
|
+
// depend on reading every file in the corpus before the first page could paint.
|
|
8
|
+
//
|
|
9
|
+
// GRAMMAR. Author-supplied CSS is contained by a GRAMMAR, not by the CSP: a content
|
|
10
|
+
// stylesheet may carry declarations and NOTHING else. A selector would let it reach
|
|
5
11
|
// the DOM (and hide the theme control); `url(`/`@import`/`@font-face` would let
|
|
6
12
|
// it name a network location — the existence-oracle channel the CSP does not
|
|
7
13
|
// close for an INTERPRETED, SHARED space (no CSP at all there), which is the gap
|
|
@@ -14,6 +20,9 @@
|
|
|
14
20
|
// values are legitimate: `--font-body: "Lora", serif;`), which is safe precisely
|
|
15
21
|
// because the blanked scan already proved every line is a declaration.
|
|
16
22
|
|
|
23
|
+
import { hrefTargetKey, isEntryKey } from './content';
|
|
24
|
+
import { parseFrontmatter } from './frontmatter';
|
|
25
|
+
|
|
17
26
|
export type GateResult =
|
|
18
27
|
| { ok: true; declarations: string }
|
|
19
28
|
| { ok: false; line: number; reason: string; excerpt: string };
|
|
@@ -81,7 +90,7 @@ export function gateStylesheet(css: string): GateResult {
|
|
|
81
90
|
return {
|
|
82
91
|
ok: false,
|
|
83
92
|
line,
|
|
84
|
-
reason: 'a selector/rule block — a
|
|
93
|
+
reason: 'a selector/rule block — a content stylesheet carries declarations only',
|
|
85
94
|
excerpt: css.split('\n')[line - 1]?.trim().slice(0, 80) ?? '',
|
|
86
95
|
};
|
|
87
96
|
}
|
|
@@ -101,3 +110,52 @@ export function gateStylesheet(css: string): GateResult {
|
|
|
101
110
|
}
|
|
102
111
|
return { ok: true, declarations: origLines.map((l) => l.trim()).filter(Boolean).join('\n') };
|
|
103
112
|
}
|
|
113
|
+
|
|
114
|
+
/** A declared stylesheet, read: the body is the CSS the grammar gates. */
|
|
115
|
+
export interface ContentStylesheet {
|
|
116
|
+
/** The entry's absolute fs path (the declaring file for its asset refs). */
|
|
117
|
+
path: string;
|
|
118
|
+
/** The raw body bytes (CSS). */
|
|
119
|
+
css: string;
|
|
120
|
+
/** The entry's declared fonts/assets, if any. */
|
|
121
|
+
declarations: { fonts?: unknown; assets?: unknown };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The stylesheet keys a home entry's `stylesheets:` value declares, resolved against the
|
|
126
|
+
* home entry, plus a reader-facing error for every value that cannot be one. Absent means
|
|
127
|
+
* none; any other non-list is an error rather than a guess, because the author has no
|
|
128
|
+
* other way to learn their theme did not load.
|
|
129
|
+
*/
|
|
130
|
+
export function declaredStylesheets(value: unknown, homeKey: string): { keys: string[]; errors: string[] } {
|
|
131
|
+
if (value === undefined || value === null) return { keys: [], errors: [] };
|
|
132
|
+
if (!Array.isArray(value)) {
|
|
133
|
+
return { keys: [], errors: ['`stylesheets:` on the home entry must be a list of entry paths'] };
|
|
134
|
+
}
|
|
135
|
+
const keys: string[] = [];
|
|
136
|
+
const errors: string[] = [];
|
|
137
|
+
for (const item of value) {
|
|
138
|
+
// `hrefTargetKey` deliberately lets a `$fs:` link address outside the corpus; a
|
|
139
|
+
// stylesheet may not, so the result must also be an entry key of THIS corpus.
|
|
140
|
+
const key = typeof item === 'string' && item ? hrefTargetKey(item, homeKey) : null;
|
|
141
|
+
if (key === null || !isEntryKey(key)) {
|
|
142
|
+
errors.push(`${String(item)} — does not name an entry inside this corpus`);
|
|
143
|
+
} else if (!keys.includes(key)) {
|
|
144
|
+
keys.push(key);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { keys, errors };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A stylesheet entry's source → its body CSS and declared `fonts:`/`assets:`. */
|
|
151
|
+
export function sheetFromSource(path: string, raw: string): ContentStylesheet {
|
|
152
|
+
const { data, body } = parseFrontmatter(raw);
|
|
153
|
+
return {
|
|
154
|
+
path,
|
|
155
|
+
css: body,
|
|
156
|
+
declarations: {
|
|
157
|
+
...(Array.isArray(data.fonts) ? { fonts: data.fonts } : {}),
|
|
158
|
+
...(data.assets && typeof data.assets === 'object' ? { assets: data.assets } : {}),
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|