@immediately-run/grove 0.1.2 → 0.1.4
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/README.md +43 -115
- package/llms.txt +5 -2
- package/package.json +12 -10
- package/src/App.tsx +11 -7
- package/src/GroveApp.css +484 -152
- package/src/GroveWiki.tsx +105 -30
- package/src/components/AssetImage.tsx +1 -12
- package/src/components/Backlinks.tsx +2 -2
- package/src/components/Catalogue.test.tsx +157 -0
- package/src/components/ContentTheme.test.tsx +146 -0
- package/src/components/ContentTheme.tsx +111 -0
- package/src/components/DocList.infinite.test.tsx +177 -0
- package/src/components/DocList.tsx +82 -9
- package/src/components/Drawer.tsx +14 -2
- package/src/components/EntryHeader.tsx +23 -12
- package/src/components/EntryImage.test.tsx +210 -0
- package/src/components/EntryImage.tsx +47 -0
- package/src/components/Galleries.test.tsx +98 -0
- package/src/components/GroveAgent.test.tsx +330 -0
- package/src/components/GroveAgent.tsx +269 -150
- package/src/components/GroveNav.test.tsx +145 -5
- package/src/components/GroveNav.tsx +51 -8
- package/src/components/Icon.tsx +0 -1
- package/src/components/InlineProse.tsx +37 -0
- package/src/components/LayoutGallery.tsx +65 -0
- package/src/components/PageView.tsx +18 -3
- package/src/components/Search.test.tsx +113 -0
- package/src/components/Search.tsx +53 -18
- package/src/components/Sidebar.test.tsx +135 -0
- package/src/components/Sidebar.tsx +114 -12
- package/src/components/TableOfContents.test.tsx +27 -16
- package/src/components/ThemeAssets.test.tsx +109 -0
- package/src/components/ThemeAssets.tsx +63 -0
- package/src/components/ThemeGallery.tsx +31 -0
- package/src/components/Timeline.tsx +5 -2
- package/src/components/WikiLink.tsx +2 -2
- package/src/data/catalogue.ts +54 -0
- package/src/data/themeFonts.ts +58 -0
- package/src/data/themes.ts +16 -7
- package/src/hooks/{useCorpusMetadata.ts → useBundleMetadata.ts} +6 -6
- package/src/hooks/useContentComponents.ts +1 -1
- package/src/hooks/useEditAffordance.ts +17 -4
- package/src/hooks/useOverlayFocusDismiss.test.tsx +139 -0
- package/src/hooks/useOverlayFocusDismiss.ts +118 -0
- package/src/hooks/useScrollReset.test.tsx +132 -0
- package/src/hooks/useScrollReset.ts +52 -0
- package/src/index.css +9 -2
- package/src/lib/agentPrompt.test.ts +96 -0
- package/src/lib/agentPrompt.ts +86 -0
- package/src/lib/agentTools.test.ts +115 -0
- package/src/lib/agentTools.ts +132 -0
- package/src/lib/agentTranscript.ts +50 -0
- package/src/lib/assetPath.test.ts +36 -0
- package/src/lib/assetPath.ts +42 -0
- package/src/lib/collectionCalls.test.ts +69 -0
- package/src/lib/content.test.ts +10 -2
- package/src/lib/content.ts +7 -0
- package/src/lib/contentStylesheet.test.ts +80 -0
- package/src/lib/contentStylesheet.ts +103 -0
- package/src/lib/corpusScan.test.ts +37 -3
- package/src/lib/corpusScan.ts +17 -2
- package/src/lib/inlineProse.parity.test.ts +52 -0
- package/src/lib/layout.ts +29 -0
- package/src/lib/pageVariants.test.tsx +87 -0
- package/src/lib/queries.test.ts +33 -1
- package/src/lib/queries.ts +33 -1
- package/src/lib/reachCard.test.ts +94 -0
- package/src/lib/reachCard.ts +112 -0
- package/src/lib/shell.ts +7 -0
- package/src/lib/starterSweep.test.tsx +160 -0
- package/src/lib/starterSweep.ts +97 -0
- package/src/lib/themeAssets.test.ts +135 -0
- package/src/lib/themeAssets.ts +143 -0
- package/src/lib/themeSelection.test.ts +2 -2
- package/src/mdxComponents.ts +4 -0
- package/viewer-manifest.schema.json +67 -0
- package/viewer.manifest.json +132 -6
package/src/GroveWiki.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import fs from 'fs';
|
|
|
5
5
|
import type { Metadata } from '@immediately-run/sdk';
|
|
6
6
|
import {
|
|
7
7
|
Include,
|
|
8
|
+
ScrollRestoration,
|
|
8
9
|
useAllMetadata,
|
|
9
10
|
useFileMetadata,
|
|
10
11
|
useHostTheme,
|
|
@@ -12,7 +13,7 @@ import {
|
|
|
12
13
|
} from '@immediately-run/sdk';
|
|
13
14
|
import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
|
|
14
15
|
import { LinkSpaceContext } from '@immediately-run/sdk/linkSpace';
|
|
15
|
-
import {
|
|
16
|
+
import { BundleContext, toBundlePath, fromBundlePath } from '@immediately-run/sdk/bundle';
|
|
16
17
|
import {
|
|
17
18
|
contentDir,
|
|
18
19
|
homeKey,
|
|
@@ -23,13 +24,14 @@ import {
|
|
|
23
24
|
sandboxPathToKey,
|
|
24
25
|
} from './lib/content';
|
|
25
26
|
import { queryPaths, queryRecords, readingTime, stripFrontmatter } from './lib/wiki';
|
|
26
|
-
import { navQuery } from './lib/queries';
|
|
27
|
+
import { navQuery, plainLabel } from './lib/queries';
|
|
27
28
|
import type { NavRecord } from './lib/queries';
|
|
28
|
-
import { layoutChainForKey } from './lib/layout';
|
|
29
|
+
import { layoutChainForKey, resolveNavMode, resolvePageLayout } from './lib/layout';
|
|
29
30
|
import { folderIndexKey } from './lib/directory';
|
|
30
31
|
import { resolvePalette, resolvePolarity, type Polarity } from './lib/themeSelection';
|
|
31
32
|
import { preferredPolarity } from './data/themes';
|
|
32
33
|
import { useDirectoryListing } from './hooks/useDirectoryListing';
|
|
34
|
+
import { useScrollReset } from './hooks/useScrollReset';
|
|
33
35
|
import { useEditAffordance } from './hooks/useEditAffordance';
|
|
34
36
|
import { getContentRoot } from './lib/contentRoot';
|
|
35
37
|
import type { RejectedComponent } from './lib/corpusComponents';
|
|
@@ -41,6 +43,10 @@ import DefaultLayout from './components/DefaultLayout';
|
|
|
41
43
|
import Search from './components/Search';
|
|
42
44
|
import Drawer from './components/Drawer';
|
|
43
45
|
import GroveAgent from './components/GroveAgent';
|
|
46
|
+
import ThemeAssets from './components/ThemeAssets';
|
|
47
|
+
import ContentTheme, { type ContentStylesheet } from './components/ContentTheme';
|
|
48
|
+
import { themeAssetsFor } from './data/themeFonts';
|
|
49
|
+
import { parseFrontmatter } from './lib/frontmatter';
|
|
44
50
|
|
|
45
51
|
declare const module: any;
|
|
46
52
|
|
|
@@ -59,17 +65,17 @@ function writePref(k: string, v: string): void {
|
|
|
59
65
|
}
|
|
60
66
|
}
|
|
61
67
|
|
|
62
|
-
//
|
|
68
|
+
// Bundle-absolute path → the href that navigates to it, for CONTENT (R3-174).
|
|
63
69
|
//
|
|
64
70
|
// Module scope, and that is load-bearing rather than tidiness: this function is handed to
|
|
65
|
-
// content through `
|
|
71
|
+
// content through `BundleContext`, and the SDK's `useBundleEntries` memoizes on its
|
|
66
72
|
// identity. A closure rebuilt each render would make that memo never hold, so the hook the
|
|
67
73
|
// SDK documents as "safe in a dependency array" would quietly stop being one — from the
|
|
68
74
|
// PROVIDER's side, where nobody using it would think to look. Nothing here is reactive:
|
|
69
75
|
// `contentDir()` is boot-settled module state and `keyToHref` is a pure function of it.
|
|
70
|
-
function
|
|
71
|
-
const absolute =
|
|
72
|
-
return absolute === null ?
|
|
76
|
+
function bundleHref(bundlePath: string): string {
|
|
77
|
+
const absolute = fromBundlePath(bundlePath, contentDir().replace(/\/+$/, ''));
|
|
78
|
+
return absolute === null ? bundlePath : keyToHref(absolute);
|
|
73
79
|
}
|
|
74
80
|
|
|
75
81
|
// Build the nested render for a layout chain (outermost first). Each layer wraps
|
|
@@ -117,6 +123,7 @@ export default function GroveWiki({
|
|
|
117
123
|
}) {
|
|
118
124
|
const ctx = useContext(TinkerableContext) as any;
|
|
119
125
|
const sandboxPath: string = ctx?.navigationState?.sandboxPath || '/';
|
|
126
|
+
const hash: string = ctx?.navigationState?.hash ?? '';
|
|
120
127
|
|
|
121
128
|
// ── Theme selection (R3-308, 02-theme-contract §4) ─────────────────────────
|
|
122
129
|
//
|
|
@@ -191,7 +198,7 @@ export default function GroveWiki({
|
|
|
191
198
|
// `lib/editTarget` — and the gate is the corpus mount's CURRENT mode, re-read on every
|
|
192
199
|
// mount change so a live role downgrade hides the affordance instead of producing
|
|
193
200
|
// `EROFS` on click.
|
|
194
|
-
const { writable, busy: editBusy, openEditor, editHint } = useEditAffordance(readOnly);
|
|
201
|
+
const { writable, busy: editBusy, refused: editRefused, openEditor, editHint } = useEditAffordance(readOnly);
|
|
195
202
|
|
|
196
203
|
const routeKey = sandboxPathToKey(sandboxPath) || homeKey();
|
|
197
204
|
// The site brand is a wiki-wide constant, so read it from the home entry's
|
|
@@ -235,8 +242,7 @@ export default function GroveWiki({
|
|
|
235
242
|
const includePath = keyToInclude(entryKey);
|
|
236
243
|
const meta = useFileMetadata(entryKey) as any;
|
|
237
244
|
|
|
238
|
-
const layout
|
|
239
|
-
const navMode: 'top' | 'side' = 'side';
|
|
245
|
+
const layout = resolvePageLayout(meta);
|
|
240
246
|
const siteTitle: string = meta?.site || homeMeta?.site || 'Grove';
|
|
241
247
|
// Interpreter mode (TRUST_MODES §5 / R3-213): render this entry's body through the
|
|
242
248
|
// non-executable safe renderer instead of the compiled `<Include>` path.
|
|
@@ -263,10 +269,59 @@ export default function GroveWiki({
|
|
|
263
269
|
// frontmatter map (folder convention + `frame` override), so it reads the whole
|
|
264
270
|
// metadata store and re-derives when the content set or the entry changes.
|
|
265
271
|
const allMeta = useAllMetadata() as Record<string, Record<string, unknown>>;
|
|
272
|
+
|
|
273
|
+
// ── Content-carried themes (R3-316) ────────────────────────────────────────
|
|
274
|
+
// `ui/stylesheet` entries discovered from the SAME index every other surface
|
|
275
|
+
// reads (mode-invariant: fork, dispatch and library all fill it); bodies are
|
|
276
|
+
// raw CSS behind frontmatter, gated by the grammar inside ContentTheme and
|
|
277
|
+
// admitted only into the lowest cascade layer. A rejected sheet degrades to a
|
|
278
|
+
// status line naming the line — never a silent drop, never a crash.
|
|
279
|
+
const [contentSheets, setContentSheets] = useState<ContentStylesheet[]>([]);
|
|
280
|
+
const [rejectedSheet, setRejectedSheet] = useState<string | null>(null);
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
let alive = true;
|
|
283
|
+
const found: ContentStylesheet[] = [];
|
|
284
|
+
const paths = Object.keys(allMeta).filter((p) => {
|
|
285
|
+
const tags = allMeta[p]?.tags;
|
|
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]);
|
|
266
311
|
const chain: string[] = layoutChainForKey(entryKey, allMeta);
|
|
267
312
|
const frameNone = meta?.frame === 'none' || meta?.frame === false;
|
|
313
|
+
|
|
314
|
+
// R3-309 — the nav arrangement is the ROOT layout's to choose (`nav: top` in the
|
|
315
|
+
// outermost _layout.mdx's frontmatter; the default shell and the starters read the
|
|
316
|
+
// same value through the shell). Undeclared or unknown → 'side', the arrangement
|
|
317
|
+
// Grove has always shipped — never an unstyled page.
|
|
318
|
+
const navMode = resolveNavMode(chain.length ? (allMeta[chain[0]!] as Record<string, unknown>) : undefined);
|
|
268
319
|
const useDefault = chain.length === 0 && !frameNone;
|
|
269
320
|
|
|
321
|
+
// Every navigation starts at the top of the entry, except one aimed at a section. The
|
|
322
|
+
// ref goes on `.device__scroll` below — the only thing on the page that scrolls.
|
|
323
|
+
const scrollRef = useScrollReset(entryKey, hash);
|
|
324
|
+
|
|
270
325
|
// Reading time: read the entry body once per entry.
|
|
271
326
|
useEffect(() => {
|
|
272
327
|
let active = true;
|
|
@@ -315,6 +370,7 @@ export default function GroveWiki({
|
|
|
315
370
|
writable,
|
|
316
371
|
openEditor,
|
|
317
372
|
editBusy,
|
|
373
|
+
editRefused,
|
|
318
374
|
editHint,
|
|
319
375
|
siteTitle,
|
|
320
376
|
safe,
|
|
@@ -329,11 +385,11 @@ export default function GroveWiki({
|
|
|
329
385
|
directory,
|
|
330
386
|
};
|
|
331
387
|
|
|
332
|
-
// The
|
|
388
|
+
// The bundle scope handed to CONTENT (R3-174; MDX_FROM_MOUNT_SPEC §2, §7 1a).
|
|
333
389
|
//
|
|
334
|
-
// A component the
|
|
390
|
+
// A component the bundle ships cannot import this engine — it would resolve a second
|
|
335
391
|
// copy from the registry, with its own `contentRoot` module state, and answer about the
|
|
336
|
-
// wrong
|
|
392
|
+
// wrong bundle — so everything it needs about the bundle arrives through the SDK, which
|
|
337
393
|
// both sides genuinely share (one `/node_modules` per frame). Three facts, and each is
|
|
338
394
|
// one a content component cannot derive for itself:
|
|
339
395
|
//
|
|
@@ -345,7 +401,7 @@ export default function GroveWiki({
|
|
|
345
401
|
// a `_layout.mdx` wraps the entry, so `<Include>`'s own module identity would name
|
|
346
402
|
// the layout; furniture in the layout chain (a status line, a dependency rail) needs
|
|
347
403
|
// the page it is describing.
|
|
348
|
-
// • `toHref` — because
|
|
404
|
+
// • `toHref` — because bundle-path→URL is this VIEWER's policy and the two packagings
|
|
349
405
|
// genuinely disagree (`urlAnchor`). Content that computed its own hrefs would be
|
|
350
406
|
// correct in exactly one packaging, which is the mode-invariance rule
|
|
351
407
|
// (PLATFORM_LAYERING §1.1) broken in the least visible possible way.
|
|
@@ -358,25 +414,28 @@ export default function GroveWiki({
|
|
|
358
414
|
// rejects one here because `entryKey` derives from the metadata query's array).
|
|
359
415
|
//
|
|
360
416
|
// That is safe because the EXPENSIVE half does not key on this object's identity: the
|
|
361
|
-
// SDK's `
|
|
417
|
+
// SDK's `useBundleEntries` destructures `{root, toHref}` and memoizes on those, and both
|
|
362
418
|
// are stable — `root` is a string compared by value, `toHref` is the module-scope
|
|
363
|
-
// `
|
|
419
|
+
// `bundleHref` above. A new wrapper re-renders consumers (cheap); it does not re-derive
|
|
364
420
|
// 800-odd entries. Making `toHref` a closure would silently undo that, which is the
|
|
365
421
|
// whole reason it is not one.
|
|
366
|
-
const
|
|
367
|
-
const
|
|
368
|
-
const
|
|
422
|
+
const bundleRoot = contentDir().replace(/\/+$/, '');
|
|
423
|
+
const bundleEntry = toBundlePath(entryKey, bundleRoot);
|
|
424
|
+
const bundleScope = { root: bundleRoot, entry: bundleEntry, toHref: bundleHref };
|
|
369
425
|
|
|
370
426
|
return (
|
|
371
|
-
// R3-277b: declare the enclosing
|
|
372
|
-
// (the shared resolver's
|
|
427
|
+
// R3-277b: declare the enclosing bundle for the platform's link-space consumers
|
|
428
|
+
// (the shared resolver's bundle-anchored absolute + `$fs:` handling read this).
|
|
429
|
+
// R3-482: the field keeps the deprecated `corpusRoot` spelling until grove's SDK
|
|
430
|
+
// pin reaches a release whose WikiLink reads `bundleRoot` (sdk#171 / 0.68.1) —
|
|
431
|
+
// stating only the new spelling now would silently un-anchor every absolute link.
|
|
373
432
|
<LinkSpaceContext.Provider value={{ corpusRoot: getContentRoot() }}>
|
|
374
|
-
{/* R3-174: the
|
|
433
|
+
{/* R3-174: the bundle scope CONTENT reads — sibling to the link space, not a
|
|
375
434
|
replacement for it. The two answer different questions: `LinkSpaceContext` tells
|
|
376
|
-
the platform's link resolver where absolute hrefs are anchored; `
|
|
377
|
-
tells a component the
|
|
378
|
-
and how to turn a
|
|
379
|
-
<
|
|
435
|
+
the platform's link resolver where absolute hrefs are anchored; `BundleContext`
|
|
436
|
+
tells a component the bundle ships which entries exist, which one is being read,
|
|
437
|
+
and how to turn a bundle path into a URL. */}
|
|
438
|
+
<BundleContext value={bundleScope}>
|
|
380
439
|
<GroveShellContext.Provider value={shell}>
|
|
381
440
|
<div
|
|
382
441
|
className="grove-root"
|
|
@@ -385,7 +444,23 @@ export default function GroveWiki({
|
|
|
385
444
|
data-grove-theme={theme === 'default' ? undefined : theme}
|
|
386
445
|
data-theme={polarity}
|
|
387
446
|
>
|
|
388
|
-
|
|
447
|
+
{/* R3-310: the ACTIVE theme's declared faces — the engine mints blob:
|
|
448
|
+
@font-face rules (R3-315's mechanism); switching themes revokes the
|
|
449
|
+
outgoing set and re-mints. */}
|
|
450
|
+
<ThemeAssets declarations={themeAssetsFor(theme)} />
|
|
451
|
+
<ContentTheme sheets={contentSheets} onRejected={(path, v) => setRejectedSheet(`${path}:${v.line} — ${v.reason}`)} />
|
|
452
|
+
{/* R3-627: remember where the reader was on the entry they leave, and put
|
|
453
|
+
them back there on Back. Grove's scroller is its own container, so the
|
|
454
|
+
ref goes across; `useScrollReset` above stands down on the same
|
|
455
|
+
traversal so the two do not fight. */}
|
|
456
|
+
<ScrollRestoration scroller={scrollRef} />
|
|
457
|
+
<div className="device__scroll" ref={scrollRef}>
|
|
458
|
+
{rejectedSheet ? (
|
|
459
|
+
<div className="grove-decl-error" role="status">
|
|
460
|
+
<strong>A stylesheet entry was rejected by the theme grammar.</strong>
|
|
461
|
+
<div>{rejectedSheet}</div>
|
|
462
|
+
</div>
|
|
463
|
+
) : null}
|
|
389
464
|
{rejectedComponents.length > 0 ? (
|
|
390
465
|
<div className="grove-decl-error" role="status">
|
|
391
466
|
<strong>This corpus declares components that could not be loaded.</strong>
|
|
@@ -411,10 +486,10 @@ export default function GroveWiki({
|
|
|
411
486
|
onClose={() => setDrawerOpen(false)}
|
|
412
487
|
/>
|
|
413
488
|
) : null}
|
|
414
|
-
<GroveAgent writable={writable} entryKey={entryKey} entryTitle={(meta?.title || 'this entry')
|
|
489
|
+
<GroveAgent writable={writable} entryKey={entryKey} entryTitle={plainLabel(meta?.title || 'this entry')} />
|
|
415
490
|
</div>
|
|
416
491
|
</GroveShellContext.Provider>
|
|
417
|
-
</
|
|
492
|
+
</BundleContext>
|
|
418
493
|
</LinkSpaceContext.Provider>
|
|
419
494
|
);
|
|
420
495
|
}
|
|
@@ -3,6 +3,7 @@ import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
|
|
|
3
3
|
import { MountImage } from '@immediately-run/sdk';
|
|
4
4
|
import type { SandboxMount } from '@immediately-run/sdk';
|
|
5
5
|
import { toFsPath } from '../lib/content';
|
|
6
|
+
import { resolvePath } from '../lib/assetPath';
|
|
6
7
|
|
|
7
8
|
// MDX `img` override: display a mount-relative image by reading its bytes off the
|
|
8
9
|
// sandbox fs (the opaque-origin iframe can't fetch a relative path). Resolves the
|
|
@@ -10,18 +11,6 @@ import { toFsPath } from '../lib/content';
|
|
|
10
11
|
// then hands the file to the SDK's `MountImage`, which owns the read → object URL →
|
|
11
12
|
// revoke lifecycle we used to hand-roll here.
|
|
12
13
|
|
|
13
|
-
function resolvePath(basePath: string, relativePath: string): string {
|
|
14
|
-
if (relativePath.startsWith('/')) return relativePath;
|
|
15
|
-
const parts = basePath.split('/');
|
|
16
|
-
parts.pop();
|
|
17
|
-
for (const part of relativePath.split('/')) {
|
|
18
|
-
if (part === '.' || part === '') continue;
|
|
19
|
-
if (part === '..') parts.pop();
|
|
20
|
-
else parts.push(part);
|
|
21
|
-
}
|
|
22
|
-
return parts.join('/');
|
|
23
|
-
}
|
|
24
|
-
|
|
25
14
|
// The whole sandbox fs, `/`-rooted. The resolved asset path is already absolute
|
|
26
15
|
// (`/app/content/…`), so anchor at root and pass it as the mount-relative path
|
|
27
16
|
// (leading slash stripped) — preserving the exact paths the old `fs.readFile` read.
|
|
@@ -5,6 +5,7 @@ import { Link, useFileMetadata, useMetadataQuery } from '@immediately-run/sdk';
|
|
|
5
5
|
import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
|
|
6
6
|
import { isContentEntry, keyToFsPath, keyToHref, sandboxPathToKey } from '../lib/content';
|
|
7
7
|
import { backlinkSnippet, bodyLinksTo, crumb, queryPaths } from '../lib/wiki';
|
|
8
|
+
import InlineProse from './InlineProse';
|
|
8
9
|
|
|
9
10
|
interface Hit {
|
|
10
11
|
key: string;
|
|
@@ -14,11 +15,10 @@ interface Hit {
|
|
|
14
15
|
// One linking entry: title + namespace crumb + the snippet around the link.
|
|
15
16
|
function Row({ hit }: { hit: Hit }) {
|
|
16
17
|
const meta = useFileMetadata(hit.key) as any;
|
|
17
|
-
const title = (meta?.title || crumb(hit.key)).replace(/\.$/, '');
|
|
18
18
|
return (
|
|
19
19
|
<Link href={keyToHref(hit.key)} className="grove-bl">
|
|
20
20
|
<div className="grove-bl__t">
|
|
21
|
-
{title}
|
|
21
|
+
<InlineProse text={meta?.title || crumb(hit.key)} trimPeriod />
|
|
22
22
|
<span className="crumb">/{crumb(hit.key)}</span>
|
|
23
23
|
</div>
|
|
24
24
|
<div className="grove-bl__snip" dangerouslySetInnerHTML={{ __html: hit.snippet }} />
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// R3-310's catalogue exits at the component:
|
|
3
|
+
// • BYTE-IDENTICAL MARKUP under all four themes — the claim the catalogue is
|
|
4
|
+
// for ("assert it, do not eyeball it"): the same wiki renders the same DOM,
|
|
5
|
+
// the theme being the single root-level attribute that differs;
|
|
6
|
+
// • the webfont looks declare their faces and mint them from LOCAL bytes
|
|
7
|
+
// (network-off rendering is byte-for-byte the same mechanism);
|
|
8
|
+
// • the catalogue exposes exactly the four shippable looks.
|
|
9
|
+
import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest';
|
|
10
|
+
import { act } from 'react';
|
|
11
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
12
|
+
import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
|
|
13
|
+
import { mintThemeAssets } from '../lib/themeAssets';
|
|
14
|
+
import { themeAssetsFor, ARCHIVE_THEME_ASSETS, JOURNAL_THEME_ASSETS } from '../data/themeFonts';
|
|
15
|
+
import { THEMES } from '../data/themes';
|
|
16
|
+
|
|
17
|
+
// GroveWiki reads viewport state at mount; jsdom has no matchMedia.
|
|
18
|
+
beforeAll(() => {
|
|
19
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
20
|
+
writable: true,
|
|
21
|
+
value: (q: string) => ({ matches: false, media: q, addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {}, dispatchEvent: () => false, onchange: null }),
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const readFile = vi.fn();
|
|
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 NAV = {
|
|
33
|
+
mode: 'github',
|
|
34
|
+
namespace: 'immediately-run',
|
|
35
|
+
provider: 'github',
|
|
36
|
+
repository: 'corpus',
|
|
37
|
+
ref: 'main',
|
|
38
|
+
sandboxPath: '/app/content/wiki/a.mdx',
|
|
39
|
+
hash: '',
|
|
40
|
+
search: '',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// `render: safe` — the interpreter body path, so the render exercises the real
|
|
44
|
+
// safe renderer over the fs double instead of the compiled-MDX evaluator, which
|
|
45
|
+
// exists only inside the sandbox bundler. (Both paths produce the same DOM shape;
|
|
46
|
+
// the compiled one is the bundler's, not the theme's.)
|
|
47
|
+
const META = {
|
|
48
|
+
'/app/content/wiki/a.mdx': { title: 'Reference entry.', tags: ['x'], render: 'safe' },
|
|
49
|
+
'/app/content/_layout.mdx': { site: 'Catalogue fixture' },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
async function renderWiki(): Promise<{ container: HTMLElement; root: Root }> {
|
|
53
|
+
const container = document.createElement('div');
|
|
54
|
+
document.body.appendChild(container);
|
|
55
|
+
const root = createRoot(container);
|
|
56
|
+
await act(async () => {
|
|
57
|
+
root.render(
|
|
58
|
+
<TinkerableContext.Provider
|
|
59
|
+
value={{
|
|
60
|
+
outerHref: 'https://immediately.run/x',
|
|
61
|
+
navigationState: NAV,
|
|
62
|
+
routingSpec: { routes: [] } as never,
|
|
63
|
+
filesMetadata: META,
|
|
64
|
+
} as never}
|
|
65
|
+
>
|
|
66
|
+
<GroveWiki />
|
|
67
|
+
</TinkerableContext.Provider>,
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
// Flush the async bits the wiki mounts (the scan, the asset mint, observers).
|
|
71
|
+
for (let i = 0; i < 6; i++) await act(async () => {});
|
|
72
|
+
return { container, root };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Serialize the wiki with the THEME ATTRIBUTES MASKED — everything else must be
|
|
76
|
+
* byte-identical across themes. */
|
|
77
|
+
function serialized(container: HTMLElement, maskAttr: string[]): string {
|
|
78
|
+
const clone = container.querySelector('.grove-root')?.cloneNode(true) as HTMLElement | null;
|
|
79
|
+
if (!clone) return '(no .grove-root)';
|
|
80
|
+
// Drop engine-minted style URLs (they are per-mount object URLs by design).
|
|
81
|
+
clone.querySelectorAll('style[data-grove-theme-assets]').forEach((s) => s.remove());
|
|
82
|
+
const rootEl = clone;
|
|
83
|
+
for (const a of maskAttr) rootEl.removeAttribute(a);
|
|
84
|
+
return rootEl.innerHTML;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
readFile.mockReset();
|
|
89
|
+
readFile.mockResolvedValue(new Uint8Array([1, 2, 3]));
|
|
90
|
+
localStorage.clear();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('byte-identical markup under all four themes', () => {
|
|
94
|
+
for (const theme of ['default', 'archive', 'journal', 'editorial']) {
|
|
95
|
+
it(`theme=${theme}: the same wiki, the same DOM`, async () => {
|
|
96
|
+
localStorage.setItem('grove:theme', theme);
|
|
97
|
+
const { container, root } = await renderWiki();
|
|
98
|
+
const html = serialized(container, ['data-grove-theme', 'data-theme']);
|
|
99
|
+
expect(container.querySelector('.grove-root')).toBeTruthy();
|
|
100
|
+
// The theme actually applied (the one assertion attr-specific):
|
|
101
|
+
const applied = container.querySelector('.grove-root')?.getAttribute('data-grove-theme');
|
|
102
|
+
expect(applied ?? 'default').toBe(theme);
|
|
103
|
+
(globalThis as unknown as Record<string, string>)[`__wiki_${theme}`] = html;
|
|
104
|
+
await act(async () => {
|
|
105
|
+
root.unmount();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
it('all four serializations are byte-identical', () => {
|
|
111
|
+
const g = globalThis as unknown as Record<string, string>;
|
|
112
|
+
const set = new Set(['default', 'archive', 'journal', 'editorial'].map((t) => g[`__wiki_${t}`]));
|
|
113
|
+
expect(set.size).toBe(1);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('the webfont looks declare and mint their faces from local bytes', () => {
|
|
118
|
+
it('archive mints Source Serif 4 from the in-repo bytes (network-off by construction)', async () => {
|
|
119
|
+
const m = await mintThemeAssets(ARCHIVE_THEME_ASSETS, '/app/src/index.css', async (p) => {
|
|
120
|
+
// Only the theme's OWN additions resolve here; the default-set faces share
|
|
121
|
+
// the same mechanism (proven in ThemeAssets.test) — assert the serif legs.
|
|
122
|
+
if (!p.includes('source-serif-4')) throw new Error('ENOENT');
|
|
123
|
+
return new Uint8Array([1]);
|
|
124
|
+
});
|
|
125
|
+
expect(m.minted).toBe(4); // 400/500/600/700
|
|
126
|
+
expect(m.fontFaceCss).toContain('"Source Serif 4"');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('journal mints Lora the same way', async () => {
|
|
130
|
+
const m = await mintThemeAssets(JOURNAL_THEME_ASSETS, '/app/src/index.css', async (p) => {
|
|
131
|
+
if (!p.includes('lora')) throw new Error('ENOENT');
|
|
132
|
+
return new Uint8Array([1]);
|
|
133
|
+
});
|
|
134
|
+
expect(m.minted).toBe(4);
|
|
135
|
+
expect(m.fontFaceCss).toContain('"Lora"');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('editorial keeps the default set — loud through weight and colour, not a new face', () => {
|
|
139
|
+
expect(themeAssetsFor('editorial')).toEqual(themeAssetsFor('default'));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('every declared face file actually ships in the repo (the offline guarantee)', async () => {
|
|
143
|
+
const { readdir } = await import('node:fs/promises');
|
|
144
|
+
const shipped = new Set(await readdir('assets/fonts'));
|
|
145
|
+
for (const set of [themeAssetsFor('default'), ARCHIVE_THEME_ASSETS, JOURNAL_THEME_ASSETS]) {
|
|
146
|
+
for (const f of set.fonts ?? []) {
|
|
147
|
+
expect(shipped.has(f.src.split('/').pop() as string)).toBe(true);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('the catalogue', () => {
|
|
154
|
+
it('is exactly the four shippable looks; the showcase skins are retired', () => {
|
|
155
|
+
expect(THEMES.map((t) => t.id)).toEqual(['default', 'archive', 'journal', 'editorial']);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// R3-316's component + escape-hatch exits:
|
|
3
|
+
// • a clean ui/stylesheet renders into @layer grove.content (and its declared
|
|
4
|
+
// faces mint through the R3-315 path — never a url());
|
|
5
|
+
// • a rejected sheet names the line and degrades (no style element for it);
|
|
6
|
+
// • THE ESCAPE HATCH: the hiding vectors a content stylesheet would try are
|
|
7
|
+
// each answered by a grove.reset !important pin — and the layer ORDER puts
|
|
8
|
+
// grove.reset first (for !important, earlier layers win), so grove.content
|
|
9
|
+
// cannot out-priority it. Proven against the SHIPPED css bytes.
|
|
10
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
11
|
+
import { act } from 'react';
|
|
12
|
+
import { createRoot } from 'react-dom/client';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
const readFile = vi.fn();
|
|
16
|
+
(globalThis as { __sandpackSharedFs?: unknown }).__sandpackSharedFs = {
|
|
17
|
+
promises: { readFile: (...a: unknown[]) => readFile(...a) },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const { default: ContentTheme } = await import('./ContentTheme');
|
|
21
|
+
|
|
22
|
+
async function render(node: React.ReactNode): Promise<HTMLElement> {
|
|
23
|
+
const container = document.createElement('div');
|
|
24
|
+
document.body.appendChild(container);
|
|
25
|
+
await act(async () => {
|
|
26
|
+
createRoot(container).render(node);
|
|
27
|
+
});
|
|
28
|
+
for (let i = 0; i < 4; i++) await act(async () => {});
|
|
29
|
+
return container;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const SHEET = (css: string, extra: Record<string, unknown> = {}) => ({
|
|
33
|
+
path: '/app/content/style.mdx',
|
|
34
|
+
css,
|
|
35
|
+
declarations: extra as never,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
beforeEach(() => readFile.mockReset());
|
|
39
|
+
|
|
40
|
+
describe('admission into the lowest layer', () => {
|
|
41
|
+
it('a clean sheet renders as @layer grove.content on .grove-root', async () => {
|
|
42
|
+
const el = await render(<ContentTheme sheets={[SHEET('--bg: #123;\n')]} />);
|
|
43
|
+
const style = el.querySelector('style[data-grove-content-theme]');
|
|
44
|
+
expect(style?.textContent).toContain('@layer grove.content');
|
|
45
|
+
expect(style?.textContent).toContain('--bg: #123');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('a rejected sheet degrades — no style element, verdict delivered to the caller', async () => {
|
|
49
|
+
const onRejected = vi.fn();
|
|
50
|
+
const el = await render(
|
|
51
|
+
<ContentTheme sheets={[SHEET('.evil { display: none; }')]} onRejected={onRejected} />,
|
|
52
|
+
);
|
|
53
|
+
expect(el.querySelector('style[data-grove-content-theme]')).toBeNull();
|
|
54
|
+
expect(onRejected).toHaveBeenCalledWith(
|
|
55
|
+
'/app/content/style.mdx',
|
|
56
|
+
expect.objectContaining({ line: 1, reason: expect.stringMatching(/selector/) }),
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('declared faces mint through the engine path (blob:), never a url()', async () => {
|
|
61
|
+
readFile.mockResolvedValue(new Uint8Array([1, 2, 3]));
|
|
62
|
+
const el = await render(
|
|
63
|
+
<ContentTheme
|
|
64
|
+
sheets={[
|
|
65
|
+
SHEET('--bg: #123;\n', {
|
|
66
|
+
fonts: [{ family: 'Lora', src: './lora.woff2', weight: '400' }],
|
|
67
|
+
}),
|
|
68
|
+
]}
|
|
69
|
+
/>,
|
|
70
|
+
);
|
|
71
|
+
const assets = el.querySelector('style[data-grove-content-assets]');
|
|
72
|
+
expect(assets?.textContent).toContain('"Lora"');
|
|
73
|
+
expect(assets?.textContent).toMatch(/blob:/);
|
|
74
|
+
expect(assets?.textContent).not.toContain('/app/content');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Read the shipped bytes via node:fs/promises (node:fs's readFileSync resolves
|
|
79
|
+
// through the dev-fs bridge under jsdom, which is not what these read).
|
|
80
|
+
const { readFile: readFileAsync } = await import('node:fs/promises');
|
|
81
|
+
const css = await readFileAsync(join('src', 'GroveApp.css'), 'utf8');
|
|
82
|
+
const indexCss = await readFileAsync(join('src', 'index.css'), 'utf8');
|
|
83
|
+
const readNav = async () => readFileAsync(join('src', 'components', 'GroveNav.tsx'), 'utf8');
|
|
84
|
+
|
|
85
|
+
describe('THE ESCAPE HATCH — a planted stylesheet cannot hide the theme control', () => {
|
|
86
|
+
|
|
87
|
+
/** Extract one `@layer grove.reset { … }` block's body. */
|
|
88
|
+
const resetBlock = (() => {
|
|
89
|
+
const i = css.indexOf('@layer grove.reset {');
|
|
90
|
+
if (i === -1) return '';
|
|
91
|
+
let depth = 0;
|
|
92
|
+
let end = i + '@layer grove.reset '.length;
|
|
93
|
+
for (; end < css.length; end++) {
|
|
94
|
+
if (css[end] === '{') depth++;
|
|
95
|
+
else if (css[end] === '}') {
|
|
96
|
+
depth--;
|
|
97
|
+
if (depth === 0) break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return css.slice(i, end);
|
|
101
|
+
})();
|
|
102
|
+
|
|
103
|
+
it('the layer order puts grove.reset FIRST — for !important, earlier layers win', () => {
|
|
104
|
+
const order = indexCss.match(/@layer\s+([^;]+);/)?.[1] ?? '';
|
|
105
|
+
const layers = order.split(',').map((l) => l.trim());
|
|
106
|
+
expect(layers[0]).toBe('grove.reset');
|
|
107
|
+
expect(layers.indexOf('grove.content')).toBeGreaterThan(layers.indexOf('grove.reset'));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('every hiding vector a content stylesheet would try is answered by a reset pin', () => {
|
|
111
|
+
expect(resetBlock).toContain('.grove-theme-control');
|
|
112
|
+
// The vectors: display/visibility/opacity/pointer-events (the obvious four),
|
|
113
|
+
// clip-path/transform/position/z-index (the geometric ones),
|
|
114
|
+
// width/height/overflow (collapse), filter (blur-to-nothing).
|
|
115
|
+
for (const vector of [
|
|
116
|
+
'display',
|
|
117
|
+
'visibility',
|
|
118
|
+
'opacity',
|
|
119
|
+
'pointer-events',
|
|
120
|
+
'clip-path',
|
|
121
|
+
'transform',
|
|
122
|
+
'position',
|
|
123
|
+
'z-index',
|
|
124
|
+
'width',
|
|
125
|
+
'height',
|
|
126
|
+
'overflow',
|
|
127
|
+
'filter',
|
|
128
|
+
]) {
|
|
129
|
+
expect(resetBlock).toContain(`${vector}:`);
|
|
130
|
+
}
|
|
131
|
+
// And every pin is !important — plain declarations in grove.reset would LOSE
|
|
132
|
+
// to grove.content's own !important for normal... they need the important flag.
|
|
133
|
+
const pins = resetBlock.match(/\w[\w-]*:\s*[^;]+!/g) ?? [];
|
|
134
|
+
expect(pins.length).toBeGreaterThanOrEqual(12);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('the control carries the protected class in the shipped markup', async () => {
|
|
138
|
+
const nav = await readNav();
|
|
139
|
+
expect(nav).toContain('grove-theme-control');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('FAULT: a reset block missing the vector list fails this check (not vacuous)', () => {
|
|
143
|
+
const sabotage = resetBlock.replace('visibility: visible !important;', '');
|
|
144
|
+
expect(sabotage).not.toContain('visibility: visible !important;');
|
|
145
|
+
});
|
|
146
|
+
});
|