@immediately-run/grove 0.1.1

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.
Files changed (92) hide show
  1. package/README.md +122 -0
  2. package/docs/ENGINE_BOUNDARY.md +233 -0
  3. package/llms.txt +115 -0
  4. package/package.json +82 -0
  5. package/src/App.tsx +131 -0
  6. package/src/GroveApp.css +2774 -0
  7. package/src/GroveWiki.tsx +386 -0
  8. package/src/components/AssetImage.tsx +58 -0
  9. package/src/components/Backlinks.tsx +104 -0
  10. package/src/components/Callout.tsx +26 -0
  11. package/src/components/ChildPages.tsx +40 -0
  12. package/src/components/DefaultLayout.tsx +27 -0
  13. package/src/components/Directory.tsx +65 -0
  14. package/src/components/DirectoryList.test.tsx +275 -0
  15. package/src/components/DirectoryList.tsx +189 -0
  16. package/src/components/DirectoryView.tsx +68 -0
  17. package/src/components/DocList.tsx +109 -0
  18. package/src/components/DocsByTag.tsx +13 -0
  19. package/src/components/Drawer.tsx +45 -0
  20. package/src/components/EntryHeader.tsx +51 -0
  21. package/src/components/FamilyTree.tsx +95 -0
  22. package/src/components/GroveAgent.tsx +264 -0
  23. package/src/components/GroveFooter.tsx +20 -0
  24. package/src/components/GroveNav.tsx +102 -0
  25. package/src/components/Icon.tsx +56 -0
  26. package/src/components/Infobox.tsx +19 -0
  27. package/src/components/Kbd.tsx +10 -0
  28. package/src/components/KeyValue.tsx +32 -0
  29. package/src/components/Lede.tsx +6 -0
  30. package/src/components/More.tsx +10 -0
  31. package/src/components/Outlet.tsx +11 -0
  32. package/src/components/PageMeta.tsx +24 -0
  33. package/src/components/PageView.tsx +98 -0
  34. package/src/components/Quote.tsx +36 -0
  35. package/src/components/RecentlyUpdated.tsx +6 -0
  36. package/src/components/SafeEntryBody.tsx +72 -0
  37. package/src/components/SafeLayout.tsx +35 -0
  38. package/src/components/ScrollToFragment.tsx +63 -0
  39. package/src/components/Search.tsx +127 -0
  40. package/src/components/Sidebar.tsx +118 -0
  41. package/src/components/TableOfContents.test.tsx +163 -0
  42. package/src/components/TableOfContents.tsx +101 -0
  43. package/src/components/TagCloud.tsx +46 -0
  44. package/src/components/TagList.tsx +31 -0
  45. package/src/components/Timeline.tsx +55 -0
  46. package/src/components/Toc.tsx +14 -0
  47. package/src/components/WikiLink.tsx +112 -0
  48. package/src/data/themes.ts +14 -0
  49. package/src/devfs.d.ts +4 -0
  50. package/src/hooks/useContentComponents.ts +122 -0
  51. package/src/hooks/useCorpusMetadata.ts +43 -0
  52. package/src/hooks/useDirectoryListing.ts +56 -0
  53. package/src/hooks/useHeadings.ts +96 -0
  54. package/src/hooks/useOpenWikiBoot.ts +95 -0
  55. package/src/index.css +120 -0
  56. package/src/lib/compose.test.ts +92 -0
  57. package/src/lib/compose.ts +99 -0
  58. package/src/lib/content.test.ts +269 -0
  59. package/src/lib/content.ts +267 -0
  60. package/src/lib/contentRoot.ts +61 -0
  61. package/src/lib/corpusComponents.test.ts +101 -0
  62. package/src/lib/corpusComponents.ts +117 -0
  63. package/src/lib/corpusScan.test.ts +157 -0
  64. package/src/lib/corpusScan.ts +105 -0
  65. package/src/lib/directory.test.ts +216 -0
  66. package/src/lib/directory.ts +262 -0
  67. package/src/lib/fragment.test.ts +88 -0
  68. package/src/lib/fragment.ts +55 -0
  69. package/src/lib/frontmatter.ts +26 -0
  70. package/src/lib/layout.ts +84 -0
  71. package/src/lib/openWiki.test.ts +216 -0
  72. package/src/lib/openWiki.ts +84 -0
  73. package/src/lib/queries.test.ts +74 -0
  74. package/src/lib/queries.ts +84 -0
  75. package/src/lib/safeIntrinsics.test.tsx +99 -0
  76. package/src/lib/safeIntrinsics.tsx +77 -0
  77. package/src/lib/safeRender.test.ts +359 -0
  78. package/src/lib/safeSources.ts +25 -0
  79. package/src/lib/shell.ts +71 -0
  80. package/src/lib/sourceCache.test.ts +66 -0
  81. package/src/lib/sourceCache.ts +42 -0
  82. package/src/lib/tocScroll.test.ts +71 -0
  83. package/src/lib/tocScroll.ts +93 -0
  84. package/src/lib/wiki.test.ts +194 -0
  85. package/src/lib/wiki.ts +175 -0
  86. package/src/lib.ts +54 -0
  87. package/src/main.tsx +19 -0
  88. package/src/mdx.d.ts +9 -0
  89. package/src/mdxComponents.ts +89 -0
  90. package/src/test/setup.ts +19 -0
  91. package/viewer-manifest.schema.json +62 -0
  92. package/viewer.manifest.json +267 -0
@@ -0,0 +1,386 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { useCallback, useContext, useEffect, useState } from 'react';
3
+ import type { ReactNode } from 'react';
4
+ import fs from 'fs';
5
+ import type { Metadata } from '@immediately-run/sdk';
6
+ import {
7
+ Include,
8
+ useAllMetadata,
9
+ useFileMetadata,
10
+ useMetadataQuery,
11
+ useMounts,
12
+ } from '@immediately-run/sdk';
13
+ import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
14
+ import { LinkSpaceContext } from '@immediately-run/sdk/linkSpace';
15
+ import { CorpusContext, toCorpusPath, fromCorpusPath } from '@immediately-run/sdk/corpus';
16
+ import {
17
+ contentDir,
18
+ homeKey,
19
+ isContentEntry,
20
+ keyToFsPath,
21
+ keyToHref,
22
+ keyToInclude,
23
+ sandboxPathToKey,
24
+ } from './lib/content';
25
+ import { queryPaths, queryRecords, readingTime, stripFrontmatter } from './lib/wiki';
26
+ import { navQuery } from './lib/queries';
27
+ import type { NavRecord } from './lib/queries';
28
+ import { layoutChainForKey } from './lib/layout';
29
+ import { folderIndexKey } from './lib/directory';
30
+ import { useDirectoryListing } from './hooks/useDirectoryListing';
31
+ import { getContentRoot, isDispatched } from './lib/contentRoot';
32
+ import type { RejectedComponent } from './lib/corpusComponents';
33
+ import { GroveShellContext, OutletContext } from './lib/shell';
34
+ import type { GroveShell, NavItem } from './lib/shell';
35
+ import PageView from './components/PageView';
36
+ import SafeLayout from './components/SafeLayout';
37
+ import DefaultLayout from './components/DefaultLayout';
38
+ import Search from './components/Search';
39
+ import Drawer from './components/Drawer';
40
+ import GroveAgent from './components/GroveAgent';
41
+
42
+ declare const module: any;
43
+
44
+ function readPref(k: string): string | null {
45
+ try {
46
+ return localStorage.getItem(k);
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+ function writePref(k: string, v: string): void {
52
+ try {
53
+ localStorage.setItem(k, v);
54
+ } catch {
55
+ /* ignore */
56
+ }
57
+ }
58
+
59
+ // Corpus-absolute path → the href that navigates to it, for CONTENT (R3-174).
60
+ //
61
+ // Module scope, and that is load-bearing rather than tidiness: this function is handed to
62
+ // content through `CorpusContext`, and the SDK's `useCorpusEntries` memoizes on its
63
+ // identity. A closure rebuilt each render would make that memo never hold, so the hook the
64
+ // SDK documents as "safe in a dependency array" would quietly stop being one — from the
65
+ // PROVIDER's side, where nobody using it would think to look. Nothing here is reactive:
66
+ // `contentDir()` is boot-settled module state and `keyToHref` is a pure function of it.
67
+ function corpusHref(corpusPath: string): string {
68
+ const absolute = fromCorpusPath(corpusPath, contentDir().replace(/\/+$/, ''));
69
+ return absolute === null ? corpusPath : keyToHref(absolute);
70
+ }
71
+
72
+ // Build the nested render for a layout chain (outermost first). Each layer wraps
73
+ // its `_layout.mdx` (or the built-in <DefaultLayout/>) in an OutletContext whose
74
+ // value is the node one level inward — so `<Outlet/>` inside a layer renders the
75
+ // next layer, and the innermost <Outlet/> renders the page (<PageView/>).
76
+ //
77
+ // `safe` picks the RENDERER for each layer, exactly as it does for entry bodies in
78
+ // <PageView/> (R3-263). Before this, every layer went through <Include> whatever the wiki
79
+ // declared — so an interpreter-mode wiki still EXECUTED author JavaScript out of its
80
+ // `_layout.mdx`, and the non-executable guarantee had a hole in the shell rather than in
81
+ // the entries. It is also what makes the chain work at all under dispatch: <Include>
82
+ // evaluates an app-source module, which a layout resident in a content mount is not.
83
+ function renderLayers(chain: string[], useDefault: boolean, safe: boolean): ReactNode {
84
+ let node: ReactNode = <PageView />;
85
+ if (useDefault) {
86
+ return <OutletContext.Provider value={node}><DefaultLayout /></OutletContext.Provider>;
87
+ }
88
+ for (let i = chain.length - 1; i >= 0; i--) {
89
+ const inner = node;
90
+ node = (
91
+ <OutletContext.Provider value={inner} key={chain[i]}>
92
+ {safe ? <SafeLayout layoutKey={chain[i]} /> : <Include filename={chain[i]} baseModule={module} />}
93
+ </OutletContext.Provider>
94
+ );
95
+ }
96
+ return node;
97
+ }
98
+
99
+ /**
100
+ * The wiki itself: routing, chrome, and the entry render. Everything here reads the
101
+ * content root, so it may only mount once that root is settled — which is why the
102
+ * default export of `App.tsx` is a gate in front of it rather than this component
103
+ * (R3-169: under dispatch the root is a runtime value, not `/app/content/`).
104
+ */
105
+ export default function GroveWiki({
106
+ readOnly = false,
107
+ rejectedComponents = [],
108
+ }: {
109
+ readOnly?: boolean;
110
+ /** Corpus component declarations that could not be loaded (R3-174). Shown, not
111
+ * swallowed: a `<RoadmapBoard/>` that silently never appears is indistinguishable from
112
+ * one nobody wrote, and the author has no other channel to learn which it was. */
113
+ rejectedComponents?: RejectedComponent[];
114
+ }) {
115
+ const ctx = useContext(TinkerableContext) as any;
116
+ const sandboxPath: string = ctx?.navigationState?.sandboxPath || '/';
117
+ const mounts = useMounts() as any[];
118
+
119
+ const [theme, setTheme] = useState(() => readPref('grove:theme') || 'default');
120
+ const [light, setLight] = useState(() => readPref('grove:appearance') === 'light');
121
+ const [menuOpen, setMenuOpen] = useState(false);
122
+ const [searchOpen, setSearchOpen] = useState(false);
123
+ const [drawerOpen, setDrawerOpen] = useState(false);
124
+ const [mins, setMins] = useState(0);
125
+ const [vw, setVw] = useState<'mobile' | 'desktop'>(() =>
126
+ typeof window !== 'undefined' && window.matchMedia('(max-width: 720px)').matches ? 'mobile' : 'desktop'
127
+ );
128
+
129
+ useEffect(() => {
130
+ const mq = window.matchMedia('(max-width: 720px)');
131
+ const on = () => setVw(mq.matches ? 'mobile' : 'desktop');
132
+ mq.addEventListener('change', on);
133
+ return () => mq.removeEventListener('change', on);
134
+ }, []);
135
+ useEffect(() => writePref('grove:theme', theme), [theme]);
136
+ useEffect(() => writePref('grove:appearance', light ? 'light' : 'dark'), [light]);
137
+
138
+ // ⌘K / Ctrl-K opens search.
139
+ useEffect(() => {
140
+ const on = (e: KeyboardEvent) => {
141
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
142
+ e.preventDefault();
143
+ setSearchOpen((o) => !o);
144
+ }
145
+ };
146
+ window.addEventListener('keydown', on);
147
+ return () => window.removeEventListener('keydown', on);
148
+ }, []);
149
+
150
+ // ⚠ TEMPORARY, and not a design: dispatched content IS writable — R3-266.
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.
158
+ //
159
+ // The fix is a verb swap, not a withheld capability: delegate the entry to
160
+ // `invokeTask('edit-file', { file: capFile({ mountId, relPath }, { mode: 'rw' }) })`,
161
+ // declare `invokes: edit-file`, and gate on the CORPUS MOUNT's mode rather than on the
162
+ // packaging. A task callee already holds the minted delegated grant, so nothing new has
163
+ // to be minted. Tracked in R3-266; `docs/specs/REPO_CONTENT_DISPATCH_SPEC.mdx` §5.
164
+ const writable =
165
+ !isDispatched() && !readOnly && (mounts?.some((m) => m.type === 'worktree' && m.mode !== 'ro') ?? false);
166
+
167
+ const routeKey = sandboxPathToKey(sandboxPath) || homeKey();
168
+ // The site brand is a wiki-wide constant, so read it from the home entry's
169
+ // `site` frontmatter — not the current entry's (which only home would carry),
170
+ // else the brand flips to the 'Grove' fallback on every sub-page.
171
+ const homeMeta = useFileMetadata(homeKey()) as any;
172
+ // Existence / 404: the whole index tells us if a followed link is dead. Layout
173
+ // files are structure, not entries, so they're excluded here (and everywhere).
174
+ const allKeysQuery = useCallback((fm: Record<string, any>) => Object.keys(fm).filter(isContentEntry), []);
175
+ const idx = useMetadataQuery(allKeysQuery);
176
+ const keys: string[] = queryPaths(idx);
177
+ const indexLoaded = keys.length > 0;
178
+
179
+ // A URL that names a FOLDER is a legitimate address, and there are two right answers
180
+ // to it — in this order:
181
+ //
182
+ // 1. the folder's own `index.mdx`, if the author wrote one. Curation beats
183
+ // generation, and it is how a corpus overrides the listing per folder without
184
+ // touching the engine;
185
+ // 2. otherwise the generated <DirectoryView> — the entries and assets that are
186
+ // actually there.
187
+ //
188
+ // Both used to be "No entry at …", which is the one answer that is false.
189
+ const folderIndex = indexLoaded && !keys.includes(routeKey) ? folderIndexKey(routeKey, keys) : null;
190
+ const entryKey = folderIndex ?? routeKey;
191
+ const includePath = keyToInclude(entryKey);
192
+ const meta = useFileMetadata(entryKey) as any;
193
+
194
+ const layout: string = meta?.layout || 'doc';
195
+ const navMode: 'top' | 'side' = 'side';
196
+ const siteTitle: string = meta?.site || homeMeta?.site || 'Grove';
197
+ // Interpreter mode (TRUST_MODES §5 / R3-213): render this entry's body through the
198
+ // non-executable safe renderer instead of the compiled `<Include>` path.
199
+ //
200
+ // Read from the HOME entry (wiki-wide) OR this entry (per-entry), because the two
201
+ // answer different questions. Wiki-wide is the interpreter declaration — a Grove
202
+ // rendering foreign content sets it there. Per-entry exists because this corpus has
203
+ // one document, the non-executable proof page, whose whole point is planted code that
204
+ // must NOT execute: on the compiled path that page doesn't just look wrong, it runs
205
+ // (R3-252). A document that is only correct as data says so itself.
206
+ const safe: boolean = homeMeta?.render === 'safe' || meta?.render === 'safe';
207
+ const showRails = layout === 'doc' && !meta?.view;
208
+
209
+
210
+ // Ask the filesystem only about a key the ENTRY index already missed: an ordinary page
211
+ // render performs no extra I/O, and this readdir replaces a 404 that was about to
212
+ // render anyway. The index cannot answer it — it holds `.md`/`.mdx` only, so a folder
213
+ // of assets is invisible to it.
214
+ const unresolved = indexLoaded && !keys.includes(entryKey);
215
+ const directory = useDirectoryListing(unresolved ? entryKey : null);
216
+ const missing = unresolved && directory.status === 'none';
217
+
218
+ // The layout chain wrapping this entry (outermost first). Needs the full
219
+ // frontmatter map (folder convention + `frame` override), so it reads the whole
220
+ // metadata store and re-derives when the content set or the entry changes.
221
+ const allMeta = useAllMetadata() as Record<string, Record<string, unknown>>;
222
+ const chain: string[] = layoutChainForKey(entryKey, allMeta);
223
+ const frameNone = meta?.frame === 'none' || meta?.frame === false;
224
+ const useDefault = chain.length === 0 && !frameNone;
225
+
226
+ // Reading time: read the entry body once per entry.
227
+ useEffect(() => {
228
+ let active = true;
229
+ // eslint-disable-next-line react-hooks/set-state-in-effect
230
+ setMins(0);
231
+ fs.promises
232
+ .readFile(keyToFsPath(entryKey), 'utf8')
233
+ .then((b: unknown) => {
234
+ if (active) setMins(readingTime(stripFrontmatter(String(b))));
235
+ })
236
+ .catch(() => undefined);
237
+ return () => {
238
+ active = false;
239
+ };
240
+ }, [entryKey]);
241
+
242
+ // Records, not tab-encoded paths (R3-276a): the query selects `{ path, label }`
243
+ // and the hook returns them as-is — no fake-path encoding round-trip.
244
+ const navResult = useMetadataQuery<Metadata, NavRecord>(navQuery);
245
+ const navItems: NavItem[] = queryRecords<NavRecord>(navResult).map(({ path, label }) => ({
246
+ key: path,
247
+ href: keyToHref(path),
248
+ label,
249
+ }));
250
+
251
+ // Closest-match suggestion for a 404 (shared namespace or name overlap).
252
+ const suggestion = missing
253
+ ? keys
254
+ .map((k) => ({ k, score: overlap(k, entryKey) }))
255
+ .sort((a, b) => b.score - a.score)[0]?.k
256
+ : undefined;
257
+
258
+ const shell: GroveShell = {
259
+ theme,
260
+ setTheme,
261
+ light,
262
+ setLight,
263
+ menuOpen,
264
+ setMenuOpen,
265
+ searchOpen,
266
+ setSearchOpen,
267
+ drawerOpen,
268
+ setDrawerOpen,
269
+ vw,
270
+ navMode,
271
+ writable,
272
+ siteTitle,
273
+ safe,
274
+ navItems,
275
+ entryKey,
276
+ includePath,
277
+ layout,
278
+ showRails,
279
+ mins,
280
+ missing,
281
+ suggestion,
282
+ directory,
283
+ };
284
+
285
+ // The corpus scope handed to CONTENT (R3-174; MDX_FROM_MOUNT_SPEC §2, §7 1a).
286
+ //
287
+ // A component the corpus ships cannot import this engine — it would resolve a second
288
+ // copy from the registry, with its own `contentRoot` module state, and answer about the
289
+ // wrong corpus — so everything it needs about the corpus arrives through the SDK, which
290
+ // both sides genuinely share (one `/node_modules` per frame). Three facts, and each is
291
+ // one a content component cannot derive for itself:
292
+ //
293
+ // • `root` — so metadata keys can be rebased off the mount prefix. Content must never
294
+ // see `/mnt/<hash>/…`: it is host knowledge the viewer reads THROUGH but does not
295
+ // publish (the property `DirectoryList.test.tsx` pins), and it is not stable across
296
+ // loads, so anything content stored or linked with it would rot.
297
+ // • `entry` — the entry being READ, not the file the component sits in. A component in
298
+ // a `_layout.mdx` wraps the entry, so `<Include>`'s own module identity would name
299
+ // the layout; furniture in the layout chain (a status line, a dependency rail) needs
300
+ // the page it is describing.
301
+ // • `toHref` — because corpus-path→URL is this VIEWER's policy and the two packagings
302
+ // genuinely disagree (`urlAnchor`). Content that computed its own hrefs would be
303
+ // correct in exactly one packaging, which is the mode-invariance rule
304
+ // (PLATFORM_LAYERING §1.1) broken in the least visible possible way.
305
+ // `contentRoot` is module state settled at boot, so it is read here — into a local the
306
+ // memo can list as a dependency — rather than inside the factory. Calling it inside
307
+ // would close over a value the dependency list does not name, which the React Compiler
308
+ // correctly refuses to preserve memoization across.
309
+ // Built fresh each render, like everything else in this component — no hand-memoization
310
+ // (the rest of the file has none either; `react-hooks/preserve-manual-memoization`
311
+ // rejects one here because `entryKey` derives from the metadata query's array).
312
+ //
313
+ // That is safe because the EXPENSIVE half does not key on this object's identity: the
314
+ // SDK's `useCorpusEntries` destructures `{root, toHref}` and memoizes on those, and both
315
+ // are stable — `root` is a string compared by value, `toHref` is the module-scope
316
+ // `corpusHref` above. A new wrapper re-renders consumers (cheap); it does not re-derive
317
+ // 800-odd entries. Making `toHref` a closure would silently undo that, which is the
318
+ // whole reason it is not one.
319
+ const corpusRoot = contentDir().replace(/\/+$/, '');
320
+ const corpusEntry = toCorpusPath(entryKey, corpusRoot);
321
+ const corpusScope = { root: corpusRoot, entry: corpusEntry, toHref: corpusHref };
322
+
323
+ return (
324
+ // R3-277b: declare the enclosing corpus for the platform's link-space consumers
325
+ // (the shared resolver's corpus-anchored absolute + `$fs:` handling read this).
326
+ <LinkSpaceContext.Provider value={{ corpusRoot: getContentRoot() }}>
327
+ {/* R3-174: the corpus scope CONTENT reads — sibling to the link space, not a
328
+ replacement for it. The two answer different questions: `LinkSpaceContext` tells
329
+ the platform's link resolver where absolute hrefs are anchored; `CorpusContext`
330
+ tells a component the corpus ships which entries exist, which one is being read,
331
+ and how to turn a corpus path into a URL. */}
332
+ <CorpusContext value={corpusScope}>
333
+ <GroveShellContext.Provider value={shell}>
334
+ <div
335
+ className="grove-root"
336
+ data-vw={vw}
337
+ data-nav={navMode}
338
+ data-grove-theme={theme === 'default' ? undefined : theme}
339
+ data-theme={theme === 'default' && light ? 'light' : undefined}
340
+ >
341
+ <div className="device__scroll">
342
+ {rejectedComponents.length > 0 ? (
343
+ <div className="grove-decl-error" role="status">
344
+ <strong>This corpus declares components that could not be loaded.</strong>
345
+ <ul>
346
+ {rejectedComponents.map((r) => (
347
+ <li key={r.name}>
348
+ <code>{r.name}</code> — {r.reason}
349
+ </li>
350
+ ))}
351
+ </ul>
352
+ </div>
353
+ ) : null}
354
+ <div className="grove-shell" data-nav={frameNone ? undefined : navMode}>
355
+ {renderLayers(chain, useDefault, safe)}
356
+ </div>
357
+ </div>
358
+
359
+ {searchOpen ? <Search onClose={() => setSearchOpen(false)} /> : null}
360
+ {drawerOpen ? (
361
+ <Drawer
362
+ siteTitle={siteTitle}
363
+ nav={navItems.map((n) => ({ href: n.href, label: n.label, cur: n.key === entryKey }))}
364
+ onClose={() => setDrawerOpen(false)}
365
+ />
366
+ ) : null}
367
+ <GroveAgent writable={writable} entryKey={entryKey} entryTitle={(meta?.title || 'this entry').replace(/\.$/, '')} />
368
+ </div>
369
+ </GroveShellContext.Provider>
370
+ </CorpusContext>
371
+ </LinkSpaceContext.Provider>
372
+ );
373
+ }
374
+
375
+ // Crude closest-match score for the 404 suggestion: shared namespace + name chars.
376
+ function overlap(a: string, b: string): number {
377
+ const an = a.replace(contentDir(), '').replace(/\.mdx?$/, '');
378
+ const bn = b.replace(contentDir(), '').replace(/\.mdx?$/, '');
379
+ const aNs = an.split('/').slice(0, -1).join('/');
380
+ const bNs = bn.split('/').slice(0, -1).join('/');
381
+ let score = aNs && aNs === bNs ? 5 : 0;
382
+ const aName = an.split('/').pop() || '';
383
+ const bName = bn.split('/').pop() || '';
384
+ for (const ch of new Set(aName)) if (bName.includes(ch)) score++;
385
+ return score;
386
+ }
@@ -0,0 +1,58 @@
1
+ import { useContext, useMemo } from 'react';
2
+ import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
3
+ import { MountImage } from '@immediately-run/sdk';
4
+ import type { SandboxMount } from '@immediately-run/sdk';
5
+ import { toFsPath } from '../lib/content';
6
+
7
+ // MDX `img` override: display a mount-relative image by reading its bytes off the
8
+ // sandbox fs (the opaque-origin iframe can't fetch a relative path). Resolves the
9
+ // src relative to the entry currently being rendered (navigationState.sandboxPath),
10
+ // then hands the file to the SDK's `MountImage`, which owns the read → object URL →
11
+ // revoke lifecycle we used to hand-roll here.
12
+
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
+ // The whole sandbox fs, `/`-rooted. The resolved asset path is already absolute
26
+ // (`/app/content/…`), so anchor at root and pass it as the mount-relative path
27
+ // (leading slash stripped) — preserving the exact paths the old `fs.readFile` read.
28
+ const ROOT_MOUNT: SandboxMount = { path: '/', type: 'repo' };
29
+
30
+ interface Props {
31
+ src?: string;
32
+ alt?: string;
33
+ className?: string;
34
+ }
35
+
36
+ export default function AssetImage({ src = '', alt = '', className }: Props) {
37
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
38
+ const { navigationState } = useContext(TinkerableContext) as any;
39
+
40
+ const relPath = useMemo(() => {
41
+ // The entry's absolute fs path (/app/content/...) is the base for relative assets.
42
+ const base = toFsPath(navigationState?.sandboxPath || '/');
43
+ return resolvePath(base, src).replace(/^\/+/, '');
44
+ }, [navigationState?.sandboxPath, src]);
45
+
46
+ return (
47
+ <MountImage
48
+ mount={ROOT_MOUNT}
49
+ relPath={relPath}
50
+ alt={alt}
51
+ className={className || 'grove-img__el'}
52
+ placeholder={
53
+ <span className="grove-img__box" style={{ display: 'block', minHeight: 80 }} />
54
+ }
55
+ fallback={<span className="grove-img__cap">missing asset: {src}</span>}
56
+ />
57
+ );
58
+ }
@@ -0,0 +1,104 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { useCallback, useContext, useEffect, useState } from 'react';
3
+ import fs from 'fs';
4
+ import { Link, useFileMetadata, useMetadataQuery } from '@immediately-run/sdk';
5
+ import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
6
+ import { isContentEntry, keyToFsPath, keyToHref, sandboxPathToKey } from '../lib/content';
7
+ import { backlinkSnippet, bodyLinksTo, crumb, queryPaths } from '../lib/wiki';
8
+
9
+ interface Hit {
10
+ key: string;
11
+ snippet: string;
12
+ }
13
+
14
+ // One linking entry: title + namespace crumb + the snippet around the link.
15
+ function Row({ hit }: { hit: Hit }) {
16
+ const meta = useFileMetadata(hit.key) as any;
17
+ const title = (meta?.title || crumb(hit.key)).replace(/\.$/, '');
18
+ return (
19
+ <Link href={keyToHref(hit.key)} className="grove-bl">
20
+ <div className="grove-bl__t">
21
+ {title}
22
+ <span className="crumb">/{crumb(hit.key)}</span>
23
+ </div>
24
+ <div className="grove-bl__snip" dangerouslySetInnerHTML={{ __html: hit.snippet }} />
25
+ </Link>
26
+ );
27
+ }
28
+
29
+ // `<Backlinks/>` — the signature wiki affordance: who links here. Reads sibling
30
+ // entry bodies off the fs and scans for a link to the current entry.
31
+ export default function Backlinks() {
32
+ const ctx = useContext(TinkerableContext) as any;
33
+ const currentKey = sandboxPathToKey(ctx?.navigationState?.sandboxPath || '/');
34
+
35
+ const allKeys = useCallback(
36
+ (fm: Record<string, any>) => Object.keys(fm).filter(isContentEntry),
37
+ []
38
+ );
39
+ const q = useMetadataQuery(allKeys);
40
+ const keys: string[] = queryPaths(q);
41
+ const keysKey = keys.join('|');
42
+
43
+ const [hits, setHits] = useState<Hit[] | null>(null);
44
+
45
+ useEffect(() => {
46
+ let active = true;
47
+ if (!keys.length) return; // index not loaded yet → keep the skeleton
48
+ const others = keys.filter((k) => k !== currentKey);
49
+ Promise.all(
50
+ others.map((k) =>
51
+ fs.promises
52
+ .readFile(keyToFsPath(k), 'utf8')
53
+ .then((body: unknown) => ({ key: k, body: String(body) }))
54
+ .catch(() => null)
55
+ )
56
+ ).then((rows) => {
57
+ if (!active) return;
58
+ const found: Hit[] = [];
59
+ for (const r of rows) {
60
+ // R3-283: the linking entry's own key is part of the question — corpus links
61
+ // are RELATIVE to the file they are written in, so `r.key` is what resolves them.
62
+ if (r && bodyLinksTo(r.body, currentKey, r.key)) {
63
+ found.push({ key: r.key, snippet: backlinkSnippet(r.body, currentKey, r.key) });
64
+ }
65
+ }
66
+ setHits(found.sort((a, b) => (a.key < b.key ? -1 : 1)));
67
+ });
68
+ return () => {
69
+ active = false;
70
+ };
71
+ // eslint-disable-next-line react-hooks/exhaustive-deps
72
+ }, [currentKey, keysKey]);
73
+
74
+ // Skeleton while the bodies resolve.
75
+ if (hits === null) {
76
+ return (
77
+ <section className="grove-backlinks">
78
+ <div className="grove-backlinks__h">Backlinks</div>
79
+ <div className="grove-backlinks__list">
80
+ <div className="sk sk-line" style={{ width: '60%' }} />
81
+ <div className="sk sk-line" style={{ width: '40%' }} />
82
+ </div>
83
+ </section>
84
+ );
85
+ }
86
+
87
+ return (
88
+ <section className="grove-backlinks">
89
+ <h2 className="grove-backlinks__h">
90
+ Linked from
91
+ <span className="n">{hits.length} {hits.length === 1 ? 'entry' : 'entries'}</span>
92
+ </h2>
93
+ {hits.length ? (
94
+ <div className="grove-backlinks__list">
95
+ {hits.map((h) => (
96
+ <Row key={h.key} hit={h} />
97
+ ))}
98
+ </div>
99
+ ) : (
100
+ <p className="grove-bl__snip">Nothing links here yet.</p>
101
+ )}
102
+ </section>
103
+ );
104
+ }
@@ -0,0 +1,26 @@
1
+ import type { ReactNode } from 'react';
2
+ import Icon from './Icon';
3
+
4
+ interface Props {
5
+ variant?: 'tip' | 'note' | 'warning';
6
+ type?: 'tip' | 'note' | 'warning';
7
+ title?: string;
8
+ children?: ReactNode;
9
+ }
10
+
11
+ const ICON: Record<string, string> = { tip: 'sparkles', note: 'message', warning: 'alert' };
12
+
13
+ // Import-free engine component: an Obsidian-style callout panel with an accent
14
+ // spine + a Lucide cue (tip / note / warning).
15
+ export default function Callout({ variant, type, title, children }: Props) {
16
+ const v = variant || type || 'note';
17
+ return (
18
+ <div className="grove-callout" data-v={v}>
19
+ <Icon name={ICON[v] || 'message'} />
20
+ <div>
21
+ {title ? <div className="grove-callout__t">{title}</div> : null}
22
+ <div className="grove-callout__b">{children}</div>
23
+ </div>
24
+ </div>
25
+ );
26
+ }
@@ -0,0 +1,40 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { useCallback, useContext } from 'react';
3
+ import { Link, useMetadataQuery } from '@immediately-run/sdk';
4
+ import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
5
+ import { contentDir, isContentEntry, keyToHref, sandboxPathToKey } from '../lib/content';
6
+ import { namespaceOf, queryPaths } from '../lib/wiki';
7
+
8
+ // `<ChildPages/>` — the entries that live under the current entry's namespace, as
9
+ // a compact nested list (e.g. everything in `handbook/` from the handbook index).
10
+ export default function ChildPages() {
11
+ const ctx = useContext(TinkerableContext) as any;
12
+ const currentKey = sandboxPathToKey(ctx?.navigationState?.sandboxPath || '/');
13
+ const rel = currentKey.replace(contentDir(), '').replace(/\.mdx?$/, '');
14
+ // The folder this entry indexes: its own slug if it's a section index, else its namespace.
15
+ const scope = rel.replace(/\/?(index|home)$/, '');
16
+
17
+ const queryFn = useCallback(
18
+ (fm: Record<string, any>) =>
19
+ Object.keys(fm)
20
+ .filter((p) => {
21
+ if (!isContentEntry(p) || p === currentKey) return false;
22
+ return namespaceOf(p) === scope;
23
+ })
24
+ .sort(),
25
+ [currentKey, scope]
26
+ );
27
+ const q = useMetadataQuery(queryFn);
28
+ const paths: string[] = queryPaths(q);
29
+
30
+ if (!paths.length) return null;
31
+ return (
32
+ <div className="grove-doclist" data-shape="feed">
33
+ {paths.map((p) => (
34
+ <Link key={p} href={keyToHref(p)} className="gdl-row">
35
+ <div className="gdl-row__t">{p.replace(contentDir(), '').replace(/\.mdx?$/, '').split('/').pop()}</div>
36
+ </Link>
37
+ ))}
38
+ </div>
39
+ );
40
+ }
@@ -0,0 +1,27 @@
1
+ import { useShell } from '../lib/shell';
2
+ import GroveNav from './GroveNav';
3
+ import Sidebar from './Sidebar';
4
+ import GroveFooter from './GroveFooter';
5
+ import Outlet from './Outlet';
6
+
7
+ // The built-in root layout: the standard Grove shell, used when a repo ships no
8
+ // `content/_layout.mdx` of its own — so even a bare folder of `.mdx` files gets
9
+ // full chrome (product value 3, "a bare folder is a usable site"). A repo makes
10
+ // the shell its own by dropping in `content/_layout.mdx`, which overrides this.
11
+ //
12
+ // This is intentionally the SAME arrangement a content `_layout.mdx` would write
13
+ // by hand (`<GroveNav/> <GroveSidebar/> <main><Outlet/></main> <GroveFooter/>`),
14
+ // so the default and a custom shell are structurally identical.
15
+ export default function DefaultLayout() {
16
+ const { navMode } = useShell();
17
+ return (
18
+ <>
19
+ <GroveNav />
20
+ {navMode === 'side' ? <Sidebar /> : null}
21
+ <main className="grove-content">
22
+ <Outlet />
23
+ </main>
24
+ <GroveFooter />
25
+ </>
26
+ );
27
+ }