@immediately-run/grove 0.1.1 → 0.1.3
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 +6 -2
- package/package.json +20 -8
- package/src/App.tsx +11 -7
- package/src/GroveApp.css +485 -85
- package/src/GroveWiki.tsx +176 -54
- 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 +24 -15
- 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 -149
- package/src/components/GroveNav.test.tsx +229 -0
- package/src/components/GroveNav.tsx +69 -20
- 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 +19 -4
- 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 +44 -4
- package/src/devfs.d.ts +5 -4
- package/src/hooks/{useCorpusMetadata.ts → useBundleMetadata.ts} +6 -6
- package/src/hooks/useContentComponents.ts +1 -1
- package/src/hooks/useEditAffordance.ts +99 -0
- package/src/hooks/useOpenWikiBoot.ts +1 -1
- 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/contentRoot.ts +16 -1
- 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/editTarget.test.ts +108 -0
- package/src/lib/editTarget.ts +93 -0
- package/src/lib/inlineProse.parity.test.ts +52 -0
- package/src/lib/layout.ts +29 -0
- package/src/lib/openWiki.test.ts +46 -5
- package/src/lib/openWiki.ts +18 -3
- 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 +17 -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 +52 -0
- package/src/lib/themeSelection.ts +59 -0
- package/src/mdxComponents.ts +4 -0
- package/viewer-manifest.schema.json +37 -0
- package/viewer.manifest.json +133 -6
package/src/GroveWiki.tsx
CHANGED
|
@@ -5,14 +5,15 @@ 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,
|
|
11
|
+
useHostTheme,
|
|
10
12
|
useMetadataQuery,
|
|
11
|
-
useMounts,
|
|
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,12 +24,16 @@ 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';
|
|
31
|
+
import { resolvePalette, resolvePolarity, type Polarity } from './lib/themeSelection';
|
|
32
|
+
import { preferredPolarity } from './data/themes';
|
|
30
33
|
import { useDirectoryListing } from './hooks/useDirectoryListing';
|
|
31
|
-
import {
|
|
34
|
+
import { useScrollReset } from './hooks/useScrollReset';
|
|
35
|
+
import { useEditAffordance } from './hooks/useEditAffordance';
|
|
36
|
+
import { getContentRoot } from './lib/contentRoot';
|
|
32
37
|
import type { RejectedComponent } from './lib/corpusComponents';
|
|
33
38
|
import { GroveShellContext, OutletContext } from './lib/shell';
|
|
34
39
|
import type { GroveShell, NavItem } from './lib/shell';
|
|
@@ -38,6 +43,10 @@ import DefaultLayout from './components/DefaultLayout';
|
|
|
38
43
|
import Search from './components/Search';
|
|
39
44
|
import Drawer from './components/Drawer';
|
|
40
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';
|
|
41
50
|
|
|
42
51
|
declare const module: any;
|
|
43
52
|
|
|
@@ -56,17 +65,17 @@ function writePref(k: string, v: string): void {
|
|
|
56
65
|
}
|
|
57
66
|
}
|
|
58
67
|
|
|
59
|
-
//
|
|
68
|
+
// Bundle-absolute path → the href that navigates to it, for CONTENT (R3-174).
|
|
60
69
|
//
|
|
61
70
|
// Module scope, and that is load-bearing rather than tidiness: this function is handed to
|
|
62
|
-
// content through `
|
|
71
|
+
// content through `BundleContext`, and the SDK's `useBundleEntries` memoizes on its
|
|
63
72
|
// identity. A closure rebuilt each render would make that memo never hold, so the hook the
|
|
64
73
|
// SDK documents as "safe in a dependency array" would quietly stop being one — from the
|
|
65
74
|
// PROVIDER's side, where nobody using it would think to look. Nothing here is reactive:
|
|
66
75
|
// `contentDir()` is boot-settled module state and `keyToHref` is a pure function of it.
|
|
67
|
-
function
|
|
68
|
-
const absolute =
|
|
69
|
-
return absolute === null ?
|
|
76
|
+
function bundleHref(bundlePath: string): string {
|
|
77
|
+
const absolute = fromBundlePath(bundlePath, contentDir().replace(/\/+$/, ''));
|
|
78
|
+
return absolute === null ? bundlePath : keyToHref(absolute);
|
|
70
79
|
}
|
|
71
80
|
|
|
72
81
|
// Build the nested render for a layout chain (outermost first). Each layer wraps
|
|
@@ -114,10 +123,45 @@ export default function GroveWiki({
|
|
|
114
123
|
}) {
|
|
115
124
|
const ctx = useContext(TinkerableContext) as any;
|
|
116
125
|
const sandboxPath: string = ctx?.navigationState?.sandboxPath || '/';
|
|
117
|
-
const
|
|
126
|
+
const hash: string = ctx?.navigationState?.hash ?? '';
|
|
118
127
|
|
|
119
|
-
|
|
120
|
-
|
|
128
|
+
// ── Theme selection (R3-308, 02-theme-contract §4) ─────────────────────────
|
|
129
|
+
//
|
|
130
|
+
// Two INDEPENDENT axes with three sources, resolved through ONE module
|
|
131
|
+
// (lib/themeSelection) so no surface re-derives the precedence:
|
|
132
|
+
//
|
|
133
|
+
// palette = reader override, else the author's `theme:` on the home entry, else default
|
|
134
|
+
// polarity = reader override, else the host's theme, else the palette's preferred
|
|
135
|
+
//
|
|
136
|
+
// The stored prefs are OVERRIDES, nullable by nature: absent until the reader
|
|
137
|
+
// acts, which is what gives the author's declaration its turn. They are written
|
|
138
|
+
// by the user actions (chooseTheme/choosePolarity), NEVER by an effect on mount
|
|
139
|
+
// — the old effects promoted the initial value to a reader choice on first
|
|
140
|
+
// visit, which is exactly why a `theme:` declaration could never have won.
|
|
141
|
+
// (Visitors from before this change carry a mount-written pref; it stands —
|
|
142
|
+
// they are Grove readers, and a reader outranks an author.)
|
|
143
|
+
const [readerTheme, setReaderTheme] = useState<string | null>(() => readPref('grove:theme'));
|
|
144
|
+
const [readerAppearance, setReaderAppearance] = useState<Polarity | null>(() => {
|
|
145
|
+
const p = readPref('grove:appearance');
|
|
146
|
+
return p === 'light' || p === 'dark' ? p : null;
|
|
147
|
+
});
|
|
148
|
+
const chooseTheme = (id: string) => {
|
|
149
|
+
setReaderTheme(id);
|
|
150
|
+
writePref('grove:theme', id);
|
|
151
|
+
};
|
|
152
|
+
const choosePolarity = (wantLight: boolean) => {
|
|
153
|
+
const p: Polarity = wantLight ? 'light' : 'dark';
|
|
154
|
+
setReaderAppearance(p);
|
|
155
|
+
writePref('grove:appearance', p);
|
|
156
|
+
};
|
|
157
|
+
// The host drives POLARITY ONLY (`theme:read` is the one theme capability the
|
|
158
|
+
// open-wiki binding holds) — and only when there IS a host. `useHostTheme`'s
|
|
159
|
+
// channel reports an `initial: 'dark'` before any host speaks, so an unframed
|
|
160
|
+
// standalone `vite dev` render would otherwise carry a phantom host opinion
|
|
161
|
+
// and flip light-preferred themes. Framed === a host exists to have one.
|
|
162
|
+
const framed = typeof window !== 'undefined' && window.parent !== window;
|
|
163
|
+
const hostTheme = useHostTheme();
|
|
164
|
+
const hostPolarity: Polarity | null = framed ? hostTheme : null;
|
|
121
165
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
122
166
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
123
167
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
@@ -132,8 +176,6 @@ export default function GroveWiki({
|
|
|
132
176
|
mq.addEventListener('change', on);
|
|
133
177
|
return () => mq.removeEventListener('change', on);
|
|
134
178
|
}, []);
|
|
135
|
-
useEffect(() => writePref('grove:theme', theme), [theme]);
|
|
136
|
-
useEffect(() => writePref('grove:appearance', light ? 'light' : 'dark'), [light]);
|
|
137
179
|
|
|
138
180
|
// ⌘K / Ctrl-K opens search.
|
|
139
181
|
useEffect(() => {
|
|
@@ -147,28 +189,37 @@ export default function GroveWiki({
|
|
|
147
189
|
return () => window.removeEventListener('keydown', on);
|
|
148
190
|
}, []);
|
|
149
191
|
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
// The affordance below calls `requestEdit`, which is **self-scoped by contract** ("v1
|
|
153
|
-
// supports only a repo-relative path in the CURRENT repo … editing a file in one of your
|
|
154
|
-
// mounts is the `edit-file` task, not this"). Under dispatch the corpus is a mount, so
|
|
155
|
-
// that call would edit GROVE rather than the corpus on screen. Offering it would be
|
|
156
|
-
// wrong; withholding it *as a design* is also wrong, and this comment exists so the next
|
|
157
|
-
// reader does not conclude the second from the first.
|
|
192
|
+
// R3-266 — dispatched content IS writable, and the MOUNT decides.
|
|
158
193
|
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
194
|
+
// This used to read `!isDispatched() && …`, withholding every edit affordance from a
|
|
195
|
+
// dispatched viewer. The reason was real but the conclusion was not: `requestEdit` is
|
|
196
|
+
// **self-scoped by contract**, so under dispatch it names a path in GROVE's repo rather
|
|
197
|
+
// than in the corpus on screen. The fix is a verb swap, not a withheld capability — see
|
|
198
|
+
// `lib/editTarget` — and the gate is the corpus mount's CURRENT mode, re-read on every
|
|
199
|
+
// mount change so a live role downgrade hides the affordance instead of producing
|
|
200
|
+
// `EROFS` on click.
|
|
201
|
+
const { writable, busy: editBusy, refused: editRefused, openEditor, editHint } = useEditAffordance(readOnly);
|
|
166
202
|
|
|
167
203
|
const routeKey = sandboxPathToKey(sandboxPath) || homeKey();
|
|
168
204
|
// The site brand is a wiki-wide constant, so read it from the home entry's
|
|
169
205
|
// `site` frontmatter — not the current entry's (which only home would carry),
|
|
170
206
|
// else the brand flips to the 'Grove' fallback on every sub-page.
|
|
171
207
|
const homeMeta = useFileMetadata(homeKey()) as any;
|
|
208
|
+
// R3-308: the author's palette declaration — `theme:` on the home entry, the
|
|
209
|
+
// wiki-wide sibling of `site:`. Like `site`, it is read from HOME and not the
|
|
210
|
+
// current entry, so a sub-page never flips the wiki's look back to `default`.
|
|
211
|
+
const authorTheme: string | null =
|
|
212
|
+
typeof homeMeta?.theme === 'string' && homeMeta.theme ? homeMeta.theme : null;
|
|
213
|
+
// The two axes, resolved through the one module that owns the precedence. `theme`
|
|
214
|
+
// and `light` below are the RESOLVED values every surface renders from — the raw
|
|
215
|
+
// reader overrides live only in state and in the menu handlers.
|
|
216
|
+
const theme = resolvePalette({ reader: readerTheme, author: authorTheme });
|
|
217
|
+
const polarity: Polarity = resolvePolarity({
|
|
218
|
+
reader: readerAppearance,
|
|
219
|
+
host: hostPolarity,
|
|
220
|
+
preferred: preferredPolarity(theme),
|
|
221
|
+
});
|
|
222
|
+
const light = polarity === 'light';
|
|
172
223
|
// Existence / 404: the whole index tells us if a followed link is dead. Layout
|
|
173
224
|
// files are structure, not entries, so they're excluded here (and everywhere).
|
|
174
225
|
const allKeysQuery = useCallback((fm: Record<string, any>) => Object.keys(fm).filter(isContentEntry), []);
|
|
@@ -191,8 +242,7 @@ export default function GroveWiki({
|
|
|
191
242
|
const includePath = keyToInclude(entryKey);
|
|
192
243
|
const meta = useFileMetadata(entryKey) as any;
|
|
193
244
|
|
|
194
|
-
const layout
|
|
195
|
-
const navMode: 'top' | 'side' = 'side';
|
|
245
|
+
const layout = resolvePageLayout(meta);
|
|
196
246
|
const siteTitle: string = meta?.site || homeMeta?.site || 'Grove';
|
|
197
247
|
// Interpreter mode (TRUST_MODES §5 / R3-213): render this entry's body through the
|
|
198
248
|
// non-executable safe renderer instead of the compiled `<Include>` path.
|
|
@@ -219,10 +269,59 @@ export default function GroveWiki({
|
|
|
219
269
|
// frontmatter map (folder convention + `frame` override), so it reads the whole
|
|
220
270
|
// metadata store and re-derives when the content set or the entry changes.
|
|
221
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]);
|
|
222
311
|
const chain: string[] = layoutChainForKey(entryKey, allMeta);
|
|
223
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);
|
|
224
319
|
const useDefault = chain.length === 0 && !frameNone;
|
|
225
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
|
+
|
|
226
325
|
// Reading time: read the entry body once per entry.
|
|
227
326
|
useEffect(() => {
|
|
228
327
|
let active = true;
|
|
@@ -257,9 +356,9 @@ export default function GroveWiki({
|
|
|
257
356
|
|
|
258
357
|
const shell: GroveShell = {
|
|
259
358
|
theme,
|
|
260
|
-
setTheme,
|
|
359
|
+
setTheme: chooseTheme,
|
|
261
360
|
light,
|
|
262
|
-
setLight,
|
|
361
|
+
setLight: choosePolarity,
|
|
263
362
|
menuOpen,
|
|
264
363
|
setMenuOpen,
|
|
265
364
|
searchOpen,
|
|
@@ -269,6 +368,10 @@ export default function GroveWiki({
|
|
|
269
368
|
vw,
|
|
270
369
|
navMode,
|
|
271
370
|
writable,
|
|
371
|
+
openEditor,
|
|
372
|
+
editBusy,
|
|
373
|
+
editRefused,
|
|
374
|
+
editHint,
|
|
272
375
|
siteTitle,
|
|
273
376
|
safe,
|
|
274
377
|
navItems,
|
|
@@ -282,11 +385,11 @@ export default function GroveWiki({
|
|
|
282
385
|
directory,
|
|
283
386
|
};
|
|
284
387
|
|
|
285
|
-
// The
|
|
388
|
+
// The bundle scope handed to CONTENT (R3-174; MDX_FROM_MOUNT_SPEC §2, §7 1a).
|
|
286
389
|
//
|
|
287
|
-
// A component the
|
|
390
|
+
// A component the bundle ships cannot import this engine — it would resolve a second
|
|
288
391
|
// copy from the registry, with its own `contentRoot` module state, and answer about the
|
|
289
|
-
// wrong
|
|
392
|
+
// wrong bundle — so everything it needs about the bundle arrives through the SDK, which
|
|
290
393
|
// both sides genuinely share (one `/node_modules` per frame). Three facts, and each is
|
|
291
394
|
// one a content component cannot derive for itself:
|
|
292
395
|
//
|
|
@@ -298,7 +401,7 @@ export default function GroveWiki({
|
|
|
298
401
|
// a `_layout.mdx` wraps the entry, so `<Include>`'s own module identity would name
|
|
299
402
|
// the layout; furniture in the layout chain (a status line, a dependency rail) needs
|
|
300
403
|
// the page it is describing.
|
|
301
|
-
// • `toHref` — because
|
|
404
|
+
// • `toHref` — because bundle-path→URL is this VIEWER's policy and the two packagings
|
|
302
405
|
// genuinely disagree (`urlAnchor`). Content that computed its own hrefs would be
|
|
303
406
|
// correct in exactly one packaging, which is the mode-invariance rule
|
|
304
407
|
// (PLATFORM_LAYERING §1.1) broken in the least visible possible way.
|
|
@@ -311,34 +414,53 @@ export default function GroveWiki({
|
|
|
311
414
|
// rejects one here because `entryKey` derives from the metadata query's array).
|
|
312
415
|
//
|
|
313
416
|
// That is safe because the EXPENSIVE half does not key on this object's identity: the
|
|
314
|
-
// SDK's `
|
|
417
|
+
// SDK's `useBundleEntries` destructures `{root, toHref}` and memoizes on those, and both
|
|
315
418
|
// are stable — `root` is a string compared by value, `toHref` is the module-scope
|
|
316
|
-
// `
|
|
419
|
+
// `bundleHref` above. A new wrapper re-renders consumers (cheap); it does not re-derive
|
|
317
420
|
// 800-odd entries. Making `toHref` a closure would silently undo that, which is the
|
|
318
421
|
// whole reason it is not one.
|
|
319
|
-
const
|
|
320
|
-
const
|
|
321
|
-
const
|
|
422
|
+
const bundleRoot = contentDir().replace(/\/+$/, '');
|
|
423
|
+
const bundleEntry = toBundlePath(entryKey, bundleRoot);
|
|
424
|
+
const bundleScope = { root: bundleRoot, entry: bundleEntry, toHref: bundleHref };
|
|
322
425
|
|
|
323
426
|
return (
|
|
324
|
-
// R3-277b: declare the enclosing
|
|
325
|
-
// (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.
|
|
326
432
|
<LinkSpaceContext.Provider value={{ corpusRoot: getContentRoot() }}>
|
|
327
|
-
{/* R3-174: the
|
|
433
|
+
{/* R3-174: the bundle scope CONTENT reads — sibling to the link space, not a
|
|
328
434
|
replacement for it. The two answer different questions: `LinkSpaceContext` tells
|
|
329
|
-
the platform's link resolver where absolute hrefs are anchored; `
|
|
330
|
-
tells a component the
|
|
331
|
-
and how to turn a
|
|
332
|
-
<
|
|
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}>
|
|
333
439
|
<GroveShellContext.Provider value={shell}>
|
|
334
440
|
<div
|
|
335
441
|
className="grove-root"
|
|
336
442
|
data-vw={vw}
|
|
337
443
|
data-nav={navMode}
|
|
338
444
|
data-grove-theme={theme === 'default' ? undefined : theme}
|
|
339
|
-
data-theme={
|
|
445
|
+
data-theme={polarity}
|
|
340
446
|
>
|
|
341
|
-
|
|
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}
|
|
342
464
|
{rejectedComponents.length > 0 ? (
|
|
343
465
|
<div className="grove-decl-error" role="status">
|
|
344
466
|
<strong>This corpus declares components that could not be loaded.</strong>
|
|
@@ -364,10 +486,10 @@ export default function GroveWiki({
|
|
|
364
486
|
onClose={() => setDrawerOpen(false)}
|
|
365
487
|
/>
|
|
366
488
|
) : null}
|
|
367
|
-
<GroveAgent writable={writable} entryKey={entryKey} entryTitle={(meta?.title || 'this entry')
|
|
489
|
+
<GroveAgent writable={writable} entryKey={entryKey} entryTitle={plainLabel(meta?.title || 'this entry')} />
|
|
368
490
|
</div>
|
|
369
491
|
</GroveShellContext.Provider>
|
|
370
|
-
</
|
|
492
|
+
</BundleContext>
|
|
371
493
|
</LinkSpaceContext.Provider>
|
|
372
494
|
);
|
|
373
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
|
+
});
|