@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.
- package/README.md +122 -0
- package/docs/ENGINE_BOUNDARY.md +233 -0
- package/llms.txt +115 -0
- package/package.json +82 -0
- package/src/App.tsx +131 -0
- package/src/GroveApp.css +2774 -0
- package/src/GroveWiki.tsx +386 -0
- package/src/components/AssetImage.tsx +58 -0
- package/src/components/Backlinks.tsx +104 -0
- package/src/components/Callout.tsx +26 -0
- package/src/components/ChildPages.tsx +40 -0
- package/src/components/DefaultLayout.tsx +27 -0
- package/src/components/Directory.tsx +65 -0
- package/src/components/DirectoryList.test.tsx +275 -0
- package/src/components/DirectoryList.tsx +189 -0
- package/src/components/DirectoryView.tsx +68 -0
- package/src/components/DocList.tsx +109 -0
- package/src/components/DocsByTag.tsx +13 -0
- package/src/components/Drawer.tsx +45 -0
- package/src/components/EntryHeader.tsx +51 -0
- package/src/components/FamilyTree.tsx +95 -0
- package/src/components/GroveAgent.tsx +264 -0
- package/src/components/GroveFooter.tsx +20 -0
- package/src/components/GroveNav.tsx +102 -0
- package/src/components/Icon.tsx +56 -0
- package/src/components/Infobox.tsx +19 -0
- package/src/components/Kbd.tsx +10 -0
- package/src/components/KeyValue.tsx +32 -0
- package/src/components/Lede.tsx +6 -0
- package/src/components/More.tsx +10 -0
- package/src/components/Outlet.tsx +11 -0
- package/src/components/PageMeta.tsx +24 -0
- package/src/components/PageView.tsx +98 -0
- package/src/components/Quote.tsx +36 -0
- package/src/components/RecentlyUpdated.tsx +6 -0
- package/src/components/SafeEntryBody.tsx +72 -0
- package/src/components/SafeLayout.tsx +35 -0
- package/src/components/ScrollToFragment.tsx +63 -0
- package/src/components/Search.tsx +127 -0
- package/src/components/Sidebar.tsx +118 -0
- package/src/components/TableOfContents.test.tsx +163 -0
- package/src/components/TableOfContents.tsx +101 -0
- package/src/components/TagCloud.tsx +46 -0
- package/src/components/TagList.tsx +31 -0
- package/src/components/Timeline.tsx +55 -0
- package/src/components/Toc.tsx +14 -0
- package/src/components/WikiLink.tsx +112 -0
- package/src/data/themes.ts +14 -0
- package/src/devfs.d.ts +4 -0
- package/src/hooks/useContentComponents.ts +122 -0
- package/src/hooks/useCorpusMetadata.ts +43 -0
- package/src/hooks/useDirectoryListing.ts +56 -0
- package/src/hooks/useHeadings.ts +96 -0
- package/src/hooks/useOpenWikiBoot.ts +95 -0
- package/src/index.css +120 -0
- package/src/lib/compose.test.ts +92 -0
- package/src/lib/compose.ts +99 -0
- package/src/lib/content.test.ts +269 -0
- package/src/lib/content.ts +267 -0
- package/src/lib/contentRoot.ts +61 -0
- package/src/lib/corpusComponents.test.ts +101 -0
- package/src/lib/corpusComponents.ts +117 -0
- package/src/lib/corpusScan.test.ts +157 -0
- package/src/lib/corpusScan.ts +105 -0
- package/src/lib/directory.test.ts +216 -0
- package/src/lib/directory.ts +262 -0
- package/src/lib/fragment.test.ts +88 -0
- package/src/lib/fragment.ts +55 -0
- package/src/lib/frontmatter.ts +26 -0
- package/src/lib/layout.ts +84 -0
- package/src/lib/openWiki.test.ts +216 -0
- package/src/lib/openWiki.ts +84 -0
- package/src/lib/queries.test.ts +74 -0
- package/src/lib/queries.ts +84 -0
- package/src/lib/safeIntrinsics.test.tsx +99 -0
- package/src/lib/safeIntrinsics.tsx +77 -0
- package/src/lib/safeRender.test.ts +359 -0
- package/src/lib/safeSources.ts +25 -0
- package/src/lib/shell.ts +71 -0
- package/src/lib/sourceCache.test.ts +66 -0
- package/src/lib/sourceCache.ts +42 -0
- package/src/lib/tocScroll.test.ts +71 -0
- package/src/lib/tocScroll.ts +93 -0
- package/src/lib/wiki.test.ts +194 -0
- package/src/lib/wiki.ts +175 -0
- package/src/lib.ts +54 -0
- package/src/main.tsx +19 -0
- package/src/mdx.d.ts +9 -0
- package/src/mdxComponents.ts +89 -0
- package/src/test/setup.ts +19 -0
- package/viewer-manifest.schema.json +62 -0
- 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
|
+
}
|