@immediately-run/grove 0.1.3 → 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.
@@ -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
+ }
@@ -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
+ }
@@ -1,5 +1,5 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { scanCorpus, listCorpusFiles, type ScanFs } from './corpusScan';
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { createCorpusScan, scanCorpus, listCorpusFiles, type ScanFs } from './corpusScan';
3
3
  import { parseFrontmatter } from './frontmatter';
4
4
 
5
5
  /** An in-memory tree, shaped like the fs slice the scan injects. */
@@ -189,3 +189,183 @@ describe('scanCorpus — the additive headings index (GROVE_AGENT_SPEC §4)', ()
189
189
  expect(meta['/mnt/c/a.mdx']).toEqual({ title: 'A' });
190
190
  });
191
191
  });
192
+
193
+ const tick = () => new Promise((r) => setTimeout(r, 0));
194
+
195
+ /** The same tree as `fakeFs`, but every `readFile` waits until the test releases it — so
196
+ * a test can observe the scan between the listing and the reads, and see read order. */
197
+ function heldFs(files: Record<string, string>, opts: { unreadable?: string[] } = {}) {
198
+ const base = fakeFs(files, opts);
199
+ const calls: string[] = [];
200
+ const held = new Map<string, () => void>();
201
+ const fs: ScanFs = {
202
+ readdir: base.readdir,
203
+ readFile(path, enc) {
204
+ calls.push(path);
205
+ return new Promise<void>((resolve) => held.set(path, resolve)).then(() => base.readFile(path, enc));
206
+ },
207
+ };
208
+ const release = async (path: string) => {
209
+ held.get(path)!();
210
+ held.delete(path);
211
+ await tick(); // let the settle + pump run
212
+ };
213
+ return { fs, calls, held, release };
214
+ }
215
+
216
+
217
+ describe('createCorpusScan — the progressive index (MDX_FROM_MOUNT_SPEC D8)', () => {
218
+ const tree = {
219
+ '/mnt/c/home.mdx': entry('Home'),
220
+ '/mnt/c/a.mdx': entry('A'),
221
+ '/mnt/c/z.mdx': entry('Z'),
222
+ };
223
+
224
+ it('seeds EVERY listed key with an empty row before any file is read', async () => {
225
+ const h = heldFs(tree);
226
+ const scan = createCorpusScan('/mnt/c', h.fs, { flushMs: 60_000 });
227
+ expect(scan.snapshot().status).toBe('listing');
228
+ await vi.waitFor(() => expect(scan.snapshot().status).toBe('reading'));
229
+ expect(scan.snapshot().metadata).toEqual({ '/mnt/c/a.mdx': {}, '/mnt/c/home.mdx': {}, '/mnt/c/z.mdx': {} });
230
+ expect(h.held.size).toBeGreaterThan(0); // reads started, none resolved
231
+ scan.dispose();
232
+ });
233
+
234
+ it('isSettled: false while listing and for a listed-unread key; true once read, once failed, or never listed', async () => {
235
+ const h = heldFs(tree, { unreadable: ['/mnt/c/z.mdx'] });
236
+ const scan = createCorpusScan('/mnt/c', h.fs, { flushMs: 0 }); // settled = read AND published
237
+ expect(scan.isSettled('/mnt/c/a.mdx')).toBe(false);
238
+ expect(scan.isSettled('/mnt/c/missing.mdx')).toBe(false); // the listing has not answered yet
239
+ await vi.waitFor(() => expect(scan.snapshot().status).toBe('reading'));
240
+ expect(scan.isSettled('/mnt/c/a.mdx')).toBe(false);
241
+ expect(scan.isSettled('/mnt/c/missing.mdx')).toBe(true);
242
+ await h.release('/mnt/c/a.mdx');
243
+ await vi.waitFor(() => expect(scan.isSettled('/mnt/c/a.mdx')).toBe(true));
244
+ await h.release('/mnt/c/z.mdx');
245
+ await vi.waitFor(() => expect(scan.isSettled('/mnt/c/z.mdx')).toBe(true));
246
+ await h.release('/mnt/c/home.mdx');
247
+ await scan.done;
248
+ // The unreadable entry leaves the index, exactly as the whole-corpus scan always did.
249
+ expect(Object.keys(scan.snapshot().metadata).sort()).toEqual(['/mnt/c/a.mdx', '/mnt/c/home.mdx']);
250
+ expect(scan.snapshot().status).toBe('complete');
251
+ });
252
+
253
+ it('prioritize: a key asked for is the NEXT read, ahead of the rest of the corpus', async () => {
254
+ const h = heldFs(tree);
255
+ const scan = createCorpusScan('/mnt/c', h.fs, { concurrency: 1, flushMs: 60_000 });
256
+ await vi.waitFor(() => expect(h.calls).toEqual(['/mnt/c/a.mdx'])); // sorted order, one at a time
257
+ scan.prioritize(['/mnt/c/z.mdx']);
258
+ await h.release('/mnt/c/a.mdx');
259
+ await vi.waitFor(() => expect(h.calls).toEqual(['/mnt/c/a.mdx', '/mnt/c/z.mdx']));
260
+ scan.dispose();
261
+ });
262
+
263
+ it('prioritize before the listing finishes: the wanted key is read first', async () => {
264
+ const h = heldFs(tree);
265
+ const scan = createCorpusScan('/mnt/c', h.fs, { concurrency: 1, flushMs: 60_000 });
266
+ scan.prioritize(['/mnt/c/z.mdx']);
267
+ await vi.waitFor(() => expect(h.calls).toEqual(['/mnt/c/z.mdx']));
268
+ scan.dispose();
269
+ });
270
+
271
+ it('a prioritized key publishes as soon as it settles, without waiting for the flush timer', async () => {
272
+ const h = heldFs(tree);
273
+ const scan = createCorpusScan('/mnt/c', h.fs, { concurrency: 1, flushMs: 60_000 });
274
+ await vi.waitFor(() => expect(scan.snapshot().status).toBe('reading'));
275
+ const before = scan.snapshot();
276
+ const seen = vi.fn();
277
+ scan.subscribe(seen);
278
+ scan.prioritize(['/mnt/c/a.mdx']); // already in flight — still wanted
279
+ await h.release('/mnt/c/a.mdx');
280
+ await vi.waitFor(() => expect(seen).toHaveBeenCalledTimes(1));
281
+ expect(scan.snapshot()).not.toBe(before);
282
+ expect(scan.snapshot().metadata['/mnt/c/a.mdx'].title).toBe('A');
283
+ scan.dispose();
284
+ });
285
+
286
+ it('a read key is not settled until its row is published — the gate never runs ahead of the index', async () => {
287
+ const h = heldFs(tree);
288
+ const scan = createCorpusScan('/mnt/c', h.fs, { concurrency: 1, flushMs: 60_000 });
289
+ await vi.waitFor(() => expect(scan.snapshot().status).toBe('reading'));
290
+ await h.release('/mnt/c/a.mdx'); // read, nobody asked for it: waits for the flush
291
+ expect(scan.snapshot().metadata['/mnt/c/a.mdx']).toEqual({});
292
+ expect(scan.isSettled('/mnt/c/a.mdx')).toBe(false);
293
+ scan.prioritize(['/mnt/c/a.mdx']); // now it is wanted: published at once
294
+ expect(scan.isSettled('/mnt/c/a.mdx')).toBe(true);
295
+ expect(scan.snapshot().metadata['/mnt/c/a.mdx'].title).toBe('A');
296
+ scan.dispose();
297
+ });
298
+
299
+ it('an unprioritized read is batched into the next flush', async () => {
300
+ const h = heldFs(tree);
301
+ const scan = createCorpusScan('/mnt/c', h.fs, { concurrency: 1, flushMs: 20 });
302
+ await vi.waitFor(() => expect(scan.snapshot().status).toBe('reading'));
303
+ const seen = vi.fn();
304
+ scan.subscribe(seen);
305
+ await h.release('/mnt/c/a.mdx');
306
+ await tick();
307
+ expect(seen).not.toHaveBeenCalled(); // not per file
308
+ await vi.waitFor(() => expect(seen).toHaveBeenCalledTimes(1)); // the timer's flush
309
+ scan.dispose();
310
+ });
311
+
312
+ it('dispose stops handing out reads and cancels the pending publish', async () => {
313
+ const h = heldFs(tree);
314
+ const scan = createCorpusScan('/mnt/c', h.fs, { concurrency: 1, flushMs: 5 });
315
+ await vi.waitFor(() => expect(h.calls).toHaveLength(1));
316
+ const seen = vi.fn();
317
+ scan.subscribe(seen);
318
+ scan.dispose();
319
+ await h.release(h.calls[0]!);
320
+ await new Promise((r) => setTimeout(r, 20));
321
+ expect(h.calls).toHaveLength(1);
322
+ expect(seen).not.toHaveBeenCalled();
323
+ await scan.done; // resolves on dispose rather than hanging
324
+ });
325
+
326
+ it('unsubscribe removes exactly that listener', async () => {
327
+ const h = heldFs(tree);
328
+ const scan = createCorpusScan('/mnt/c', h.fs, { flushMs: 60_000 });
329
+ const kept = vi.fn();
330
+ const dropped = vi.fn();
331
+ scan.subscribe(kept);
332
+ const off = scan.subscribe(dropped);
333
+ off();
334
+ await vi.waitFor(() => expect(scan.snapshot().status).toBe('reading'));
335
+ expect(kept).toHaveBeenCalledTimes(1);
336
+ expect(dropped).not.toHaveBeenCalled();
337
+ scan.dispose();
338
+ });
339
+
340
+ it('an empty bundle completes straight from the listing', async () => {
341
+ const scan = createCorpusScan('/mnt/c', fakeFs({ '/mnt/c/notes.txt': 'x' }));
342
+ await scan.done;
343
+ expect(scan.snapshot()).toEqual({ status: 'complete', metadata: {} });
344
+ });
345
+ });
346
+
347
+ describe('createCorpusScan — read failures', () => {
348
+ const notFound = () => Object.assign(new Error('no such file'), { code: 'ENOENT' });
349
+
350
+ it('a transient failure is reported for the key once settled; a missing file is not a failure', async () => {
351
+ const files = { '/mnt/c/a.mdx': entry('A'), '/mnt/c/b.mdx': entry('B') };
352
+ const base = fakeFs(files);
353
+ const fs: ScanFs = {
354
+ readdir: base.readdir,
355
+ async readFile(path) {
356
+ if (path === '/mnt/c/a.mdx') throw new Error('EIO: the channel dropped the request');
357
+ if (path === '/mnt/c/b.mdx') throw notFound();
358
+ return base.readFile(path, 'utf8');
359
+ },
360
+ };
361
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
362
+ const scan = createCorpusScan('/mnt/c', fs, { flushMs: 0 });
363
+ expect(scan.readFailure('/mnt/c/a.mdx')).toBeNull(); // nothing is known yet
364
+ await scan.done;
365
+ expect(scan.readFailure('/mnt/c/a.mdx')).toBe('EIO: the channel dropped the request');
366
+ expect(scan.readFailure('/mnt/c/b.mdx')).toBeNull();
367
+ expect(scan.snapshot().metadata).toEqual({}); // both rows leave the index either way
368
+ expect(warn).toHaveBeenCalledTimes(1);
369
+ warn.mockRestore();
370
+ });
371
+ });