@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,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 nothing here because Grove
99
- * already gates on `useOpenWikiBoot` and `useBundleMetadata`. Rendering into a
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
+ }
@@ -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
+ });
@@ -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, { currentFile: fromKey, corpusRoot: getContentRoot() });
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, { currentFile: fromKey, corpusRoot: getContentRoot() });
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
- // The content-stylesheet grammar gate (R3-316; plan 05-content-carried-themes).
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
- // Author-supplied CSS is contained by a GRAMMAR, not by the CSP: a `ui/stylesheet`
4
- // entry may carry declarations and NOTHING else. A selector would let it reach
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 ui/stylesheet carries declarations only',
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
+ }