@immediately-run/grove 0.1.10 → 0.2.0
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/llms.txt +13 -1
- package/package.json +1 -1
- package/src/GroveWiki.tsx +13 -52
- package/src/components/AssetImage.test.tsx +64 -0
- package/src/components/AssetImage.tsx +10 -10
- package/src/components/Backlinks.tsx +13 -6
- package/src/components/ChildPages.tsx +8 -6
- package/src/components/Directory.tsx +6 -1
- package/src/components/DirectoryList.tsx +16 -8
- package/src/components/DirectoryView.tsx +5 -2
- package/src/components/DocList.tsx +9 -2
- package/src/components/Drawer.tsx +14 -1
- package/src/components/EntryBody.tsx +138 -0
- package/src/components/EntryHeader.tsx +16 -3
- package/src/components/FamilyTree.tsx +3 -5
- package/src/components/GroveAgent.tsx +1 -1
- package/src/components/GroveEntry.test.tsx +200 -0
- package/src/components/GroveEntry.tsx +108 -0
- package/src/components/GroveFooter.tsx +5 -2
- package/src/components/GroveNav.tsx +11 -2
- package/src/components/PageMeta.tsx +3 -5
- package/src/components/PageView.tsx +13 -63
- package/src/components/Search.tsx +10 -1
- package/src/components/Sidebar.tsx +15 -7
- package/src/components/TableOfContents.tsx +1 -1
- package/src/components/Timeline.tsx +6 -1
- package/src/components/WikiLink.test.tsx +83 -0
- package/src/components/WikiLink.tsx +29 -8
- package/src/components/navigationPolicy.sweep.test.tsx +261 -0
- package/src/hooks/useEntryKey.ts +21 -0
- package/src/hooks/useFollowLink.ts +11 -0
- package/src/hooks/useHeadings.test.tsx +93 -0
- package/src/hooks/useHeadings.ts +73 -12
- package/src/lib/assetPath.ts +4 -3
- package/src/lib/content.ts +0 -5
- package/src/lib/entryContext.test.ts +46 -0
- package/src/lib/entryContext.ts +35 -0
- package/src/lib/fragment.test.ts +21 -0
- package/src/lib/fragment.ts +9 -6
- package/src/lib/navigationPolicy.test.ts +65 -0
- package/src/lib/navigationPolicy.ts +57 -0
- package/src/lib/renderMode.ts +17 -0
- package/src/lib/shell.ts +4 -0
- package/src/lib.ts +18 -0
package/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @immediately-run/grove — the viewer kit for directory-as-content wikis
|
|
2
2
|
|
|
3
|
-
> The Grove viewer: a kit of React components, layouts and themes for building immediately.run wikis. Composable as a fork, a dispatch target, or a pinned library. (v0.
|
|
3
|
+
> The Grove viewer: a kit of React components, layouts and themes for building immediately.run wikis. Composable as a fork, a dispatch target, or a pinned library. (v0.2.0)
|
|
4
4
|
|
|
5
5
|
Grove is NOT a wiki engine: routing, MDX compilation, the frontmatter index, link
|
|
6
6
|
spaces and heading anchors live in the sandbox + `@immediately-run/sdk`. What this
|
|
@@ -25,20 +25,28 @@ and the defaults. Three composition modes (PLATFORM_LAYERING_SPEC §1.1):
|
|
|
25
25
|
|
|
26
26
|
## Composition entry (src/lib.ts)
|
|
27
27
|
|
|
28
|
+
- `FollowLink`
|
|
29
|
+
- `FollowLinkTarget`
|
|
28
30
|
- `GROVE_MDX`
|
|
29
31
|
- `GroveApp`
|
|
32
|
+
- `GroveEntry`
|
|
30
33
|
- `GroveShell`
|
|
31
34
|
- `GroveShellContext`
|
|
32
35
|
- `GroveWiki`
|
|
36
|
+
- `Heading`
|
|
33
37
|
- `ManifestComponent`
|
|
34
38
|
- `ManifestOverrideError`
|
|
35
39
|
- `NavItem`
|
|
40
|
+
- `NavigationPolicyContext`
|
|
36
41
|
- `OutletContext`
|
|
37
42
|
- `SAFE_MDX`
|
|
38
43
|
- `VIEWER_MANIFEST`
|
|
39
44
|
- `ViewerManifest`
|
|
40
45
|
- `composeComponents`
|
|
41
46
|
- `contentDir`
|
|
47
|
+
- `defaultFollowLink`
|
|
48
|
+
- `followLinkOnClick`
|
|
49
|
+
- `fragmentOf`
|
|
42
50
|
- `getContentRoot`
|
|
43
51
|
- `homeKey`
|
|
44
52
|
- `isContentEntry`
|
|
@@ -51,8 +59,12 @@ and the defaults. Three composition modes (PLATFORM_LAYERING_SPEC §1.1):
|
|
|
51
59
|
- `overridableNames`
|
|
52
60
|
- `queryPaths`
|
|
53
61
|
- `readingTime`
|
|
62
|
+
- `resolveFragmentTarget`
|
|
54
63
|
- `sandboxPathToKey`
|
|
55
64
|
- `stripFrontmatter`
|
|
65
|
+
- `useEntryKey`
|
|
66
|
+
- `useFollowLink`
|
|
67
|
+
- `useHeadings`
|
|
56
68
|
- `useShell`
|
|
57
69
|
|
|
58
70
|
## Component vocabulary — the override contract (viewer.manifest.json)
|
package/package.json
CHANGED
package/src/GroveWiki.tsx
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
2
|
import { useCallback, useContext, useEffect, useState } from 'react';
|
|
3
|
-
import type { ReactNode } from 'react';
|
|
4
3
|
import fs from 'fs';
|
|
5
4
|
import type { Metadata } from '@immediately-run/sdk';
|
|
6
5
|
import {
|
|
7
|
-
Include,
|
|
8
6
|
ScrollRestoration,
|
|
9
7
|
useAllMetadata,
|
|
10
8
|
useFileMetadata,
|
|
@@ -27,7 +25,9 @@ import { queryPaths, queryRecords, readingTime, stripFrontmatter } from './lib/w
|
|
|
27
25
|
import { navQuery, plainLabel } from './lib/queries';
|
|
28
26
|
import type { NavRecord } from './lib/queries';
|
|
29
27
|
import { layoutChainForKey, resolveNavMode, resolvePageLayout } from './lib/layout';
|
|
28
|
+
import { resolveSafeRender } from './lib/renderMode';
|
|
30
29
|
import { folderIndexKey } from './lib/directory';
|
|
30
|
+
import GroveEntry from './components/GroveEntry';
|
|
31
31
|
import { resolvePalette, resolvePolarity, type Polarity } from './lib/themeSelection';
|
|
32
32
|
import { preferredPolarity } from './data/themes';
|
|
33
33
|
import { useDirectoryListing } from './hooks/useDirectoryListing';
|
|
@@ -35,24 +35,15 @@ import { useScrollReset } from './hooks/useScrollReset';
|
|
|
35
35
|
import { useEditAffordance } from './hooks/useEditAffordance';
|
|
36
36
|
import { getContentRoot, isDispatched } from './lib/contentRoot';
|
|
37
37
|
import type { RejectedComponent } from './lib/corpusComponents';
|
|
38
|
-
import { GroveShellContext
|
|
38
|
+
import { GroveShellContext } from './lib/shell';
|
|
39
39
|
import type { GroveShell, NavItem } from './lib/shell';
|
|
40
|
-
import PageView from './components/PageView';
|
|
41
|
-
import SafeLayout from './components/SafeLayout';
|
|
42
|
-
import DefaultLayout from './components/DefaultLayout';
|
|
43
40
|
import Search from './components/Search';
|
|
44
41
|
import Drawer from './components/Drawer';
|
|
45
42
|
import GroveAgent from './components/GroveAgent';
|
|
46
43
|
import ThemeAssets from './components/ThemeAssets';
|
|
47
44
|
import ContentTheme from './components/ContentTheme';
|
|
48
|
-
import BootMessage from './components/BootMessage';
|
|
49
45
|
import { themeAssetsFor } from './data/themeFonts';
|
|
50
46
|
import { useContentStylesheets } from './hooks/useContentStylesheets';
|
|
51
|
-
import { criticalKeys } from './lib/criticalKeys';
|
|
52
|
-
import { criticalFailure, entryPending } from './lib/entryGate';
|
|
53
|
-
import { CorpusScanContext } from './lib/corpusScanContext';
|
|
54
|
-
|
|
55
|
-
declare const module: any;
|
|
56
47
|
|
|
57
48
|
function readPref(k: string): string | null {
|
|
58
49
|
try {
|
|
@@ -82,33 +73,6 @@ function bundleHref(bundlePath: string): string {
|
|
|
82
73
|
return absolute === null ? bundlePath : keyToHref(absolute);
|
|
83
74
|
}
|
|
84
75
|
|
|
85
|
-
// Build the nested render for a layout chain (outermost first). Each layer wraps
|
|
86
|
-
// its `_layout.mdx` (or the built-in <DefaultLayout/>) in an OutletContext whose
|
|
87
|
-
// value is the node one level inward — so `<Outlet/>` inside a layer renders the
|
|
88
|
-
// next layer, and the innermost <Outlet/> renders the page (<PageView/>).
|
|
89
|
-
//
|
|
90
|
-
// `safe` picks the RENDERER for each layer, exactly as it does for entry bodies in
|
|
91
|
-
// <PageView/> (R3-263). Before this, every layer went through <Include> whatever the wiki
|
|
92
|
-
// declared — so an interpreter-mode wiki still EXECUTED author JavaScript out of its
|
|
93
|
-
// `_layout.mdx`, and the non-executable guarantee had a hole in the shell rather than in
|
|
94
|
-
// the entries. It is also what makes the chain work at all under dispatch: <Include>
|
|
95
|
-
// evaluates an app-source module, which a layout resident in a content mount is not.
|
|
96
|
-
function renderLayers(chain: string[], useDefault: boolean, safe: boolean): ReactNode {
|
|
97
|
-
let node: ReactNode = <PageView />;
|
|
98
|
-
if (useDefault) {
|
|
99
|
-
return <OutletContext.Provider value={node}><DefaultLayout /></OutletContext.Provider>;
|
|
100
|
-
}
|
|
101
|
-
for (let i = chain.length - 1; i >= 0; i--) {
|
|
102
|
-
const inner = node;
|
|
103
|
-
node = (
|
|
104
|
-
<OutletContext.Provider value={inner} key={chain[i]}>
|
|
105
|
-
{safe ? <SafeLayout layoutKey={chain[i]} /> : <Include filename={chain[i]} baseModule={module} />}
|
|
106
|
-
</OutletContext.Provider>
|
|
107
|
-
);
|
|
108
|
-
}
|
|
109
|
-
return node;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
76
|
/**
|
|
113
77
|
* The wiki itself: routing, chrome, and the entry render. Everything here reads the
|
|
114
78
|
* content root, so it may only mount once that root is settled — which is why the
|
|
@@ -257,7 +221,7 @@ export default function GroveWiki({
|
|
|
257
221
|
// one document, the non-executable proof page, whose whole point is planted code that
|
|
258
222
|
// must NOT execute: on the compiled path that page doesn't just look wrong, it runs
|
|
259
223
|
// (R3-252). A document that is only correct as data says so itself.
|
|
260
|
-
const safe: boolean = homeMeta
|
|
224
|
+
const safe: boolean = resolveSafeRender(homeMeta, meta);
|
|
261
225
|
const showRails = layout === 'doc' && !meta?.view;
|
|
262
226
|
|
|
263
227
|
|
|
@@ -289,14 +253,9 @@ export default function GroveWiki({
|
|
|
289
253
|
// that decide how THIS entry renders — itself, home, its layouts, its `frame:` — are
|
|
290
254
|
// read ahead of the rest, and the body waits for them: an unread row reads as
|
|
291
255
|
// `render` unset, which is the executing path. The chrome around it stays mounted.
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
useEffect(() => {
|
|
296
|
-
scanGate.prioritize(criticalSig.split('|'));
|
|
297
|
-
}, [scanGate, criticalSig]);
|
|
298
|
-
const pending = entryPending(critical, scanGate.isSettled, stylesheets.status);
|
|
299
|
-
const failure = criticalFailure(critical, scanGate.readFailure);
|
|
256
|
+
// R3-872: the entry gate (prioritize/pending/failure) and the safe derivation
|
|
257
|
+
// moved into GroveEntry's chain frame — GroveWiki keeps only what the chrome
|
|
258
|
+
// reads (navMode's root-layout lookup needs the chain's outermost key).
|
|
300
259
|
const chain: string[] = layoutChainForKey(entryKey, allMeta);
|
|
301
260
|
const frameNone = meta?.frame === 'none' || meta?.frame === false;
|
|
302
261
|
|
|
@@ -305,8 +264,6 @@ export default function GroveWiki({
|
|
|
305
264
|
// same value through the shell). Undeclared or unknown → 'side', the arrangement
|
|
306
265
|
// Grove has always shipped — never an unstyled page.
|
|
307
266
|
const navMode = resolveNavMode(chain.length ? (allMeta[chain[0]!] as Record<string, unknown>) : undefined);
|
|
308
|
-
const useDefault = chain.length === 0 && !frameNone;
|
|
309
|
-
|
|
310
267
|
// Every navigation starts at the top of the entry, except one aimed at a section. The
|
|
311
268
|
// ref goes on `.device__scroll` below — the only thing on the page that scrolls.
|
|
312
269
|
const scrollRef = useScrollReset(entryKey, hash);
|
|
@@ -372,6 +329,7 @@ export default function GroveWiki({
|
|
|
372
329
|
missing,
|
|
373
330
|
suggestion,
|
|
374
331
|
directory,
|
|
332
|
+
stylesheetsStatus: stylesheets.status,
|
|
375
333
|
};
|
|
376
334
|
|
|
377
335
|
// The bundle scope handed to CONTENT (R3-174; MDX_FROM_MOUNT_SPEC §2, §7 1a).
|
|
@@ -486,7 +444,10 @@ export default function GroveWiki({
|
|
|
486
444
|
</div>
|
|
487
445
|
) : null}
|
|
488
446
|
<div className="grove-shell" data-nav={frameNone ? undefined : navMode}>
|
|
489
|
-
{
|
|
447
|
+
{/* R3-872: the entry region is GroveEntry — the chain, the gate and the
|
|
448
|
+
entry context (R3-871) live inside it, so a shell composing entries
|
|
449
|
+
directly renders the same thing (APP_CUSTOMIZATION §4.1). */}
|
|
450
|
+
<GroveEntry entryKey={entryKey} frame={frameNone ? 'none' : 'chain'} />
|
|
490
451
|
</div>
|
|
491
452
|
</div>
|
|
492
453
|
|
|
@@ -494,7 +455,7 @@ export default function GroveWiki({
|
|
|
494
455
|
{drawerOpen ? (
|
|
495
456
|
<Drawer
|
|
496
457
|
siteTitle={siteTitle}
|
|
497
|
-
nav={navItems.map((n) => ({ href: n.href, label: n.label, cur: n.key === entryKey }))}
|
|
458
|
+
nav={navItems.map((n) => ({ key: n.key, href: n.href, label: n.label, cur: n.key === entryKey }))}
|
|
498
459
|
onClose={() => setDrawerOpen(false)}
|
|
499
460
|
/>
|
|
500
461
|
) : null}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// R3-871 — a relative asset src resolves against the entry context's entry
|
|
3
|
+
// directory, not the URL. The URL routes home while the provider says the
|
|
4
|
+
// subtree renders teams/engineering.mdx: the mount-relative path handed to
|
|
5
|
+
// MountImage must start from engineering's directory.
|
|
6
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
7
|
+
import { act } from 'react';
|
|
8
|
+
import { createRoot } from 'react-dom/client';
|
|
9
|
+
import type { ReactNode } from 'react';
|
|
10
|
+
import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
|
|
11
|
+
import { resetContentRoot, setContentRoot } from '../lib/contentRoot';
|
|
12
|
+
|
|
13
|
+
// The fs slice MountImage reads; the assertion target is the relPath it is
|
|
14
|
+
// handed, so the double only needs to serve bytes.
|
|
15
|
+
const readFile = vi.fn(async (relPath: string): Promise<Uint8Array> => {
|
|
16
|
+
void relPath;
|
|
17
|
+
return new Uint8Array([1]);
|
|
18
|
+
});
|
|
19
|
+
(globalThis as { __sandpackSharedFs?: unknown }).__sandpackSharedFs = {
|
|
20
|
+
promises: { readFile: (relPath: string) => readFile(relPath) },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const { default: AssetImage } = await import('./AssetImage');
|
|
24
|
+
const { EntryContext } = await import('../hooks/useEntryKey');
|
|
25
|
+
|
|
26
|
+
function host(children: ReactNode, entryKey?: string) {
|
|
27
|
+
const value = {
|
|
28
|
+
navigationState: { mode: 'present', sandboxPath: '/files/content/home.mdx', hash: '', search: '' },
|
|
29
|
+
routingSpec: { routes: [] },
|
|
30
|
+
filesMetadata: { '/app/content/home.mdx': { title: 'Home' } },
|
|
31
|
+
};
|
|
32
|
+
const inner = <TinkerableContext.Provider value={value as never}>{children}</TinkerableContext.Provider>;
|
|
33
|
+
return entryKey === undefined ? inner : <EntryContext.Provider value={{ entryKey }}>{inner}</EntryContext.Provider>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function render(node: ReactNode): Promise<HTMLElement> {
|
|
37
|
+
const container = document.createElement('div');
|
|
38
|
+
document.body.appendChild(container);
|
|
39
|
+
await act(async () => {
|
|
40
|
+
createRoot(container).render(node);
|
|
41
|
+
});
|
|
42
|
+
return container;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('AssetImage — the base is the entry context (R3-871)', () => {
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
setContentRoot('/app/content/');
|
|
48
|
+
return () => resetContentRoot();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('a relative src inside a non-routed entry resolves against that entry\'s directory', async () => {
|
|
52
|
+
await render(host(<AssetImage src="posters/ada.png" alt="Ada" />, '/app/content/teams/engineering.mdx'));
|
|
53
|
+
expect(readFile).toHaveBeenCalled();
|
|
54
|
+
const relPath = (readFile.mock.calls[0] as unknown[])[0] as string;
|
|
55
|
+
expect(relPath).toBe('/app/content/teams/posters/ada.png');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('with no provider, the routed entry (home) is the base — the default preserved', async () => {
|
|
59
|
+
readFile.mockClear();
|
|
60
|
+
await render(host(<AssetImage src="posters/ada.png" alt="Ada" />));
|
|
61
|
+
const relPath = (readFile.mock.calls[0] as unknown[])[0] as string;
|
|
62
|
+
expect(relPath).toBe('/app/content/posters/ada.png');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
|
|
1
|
+
import { useMemo } from 'react';
|
|
3
2
|
import { MountImage } from '@immediately-run/sdk';
|
|
4
3
|
import type { SandboxMount } from '@immediately-run/sdk';
|
|
5
|
-
import {
|
|
4
|
+
import { keyToFsPath } from '../lib/content';
|
|
6
5
|
import { resolvePath } from '../lib/assetPath';
|
|
6
|
+
import { useEntryKey } from '../hooks/useEntryKey';
|
|
7
7
|
|
|
8
8
|
// MDX `img` override: display a mount-relative image by reading its bytes off the
|
|
9
9
|
// sandbox fs (the opaque-origin iframe can't fetch a relative path). Resolves the
|
|
10
|
-
// src relative to the entry currently being rendered
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// src relative to the entry currently being rendered — the entry context's entry,
|
|
11
|
+
// not the URL (R3-871: inside an included fragment or a layout, the URL names the
|
|
12
|
+
// wrong base) — then hands the file to the SDK's `MountImage`, which owns the
|
|
13
|
+
// read → object URL → revoke lifecycle we used to hand-roll here.
|
|
13
14
|
|
|
14
15
|
// The whole sandbox fs, `/`-rooted. The resolved asset path is already absolute
|
|
15
16
|
// (`/app/content/…`), so anchor at root and pass it as the mount-relative path
|
|
@@ -23,14 +24,13 @@ interface Props {
|
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export default function AssetImage({ src = '', alt = '', className }: Props) {
|
|
26
|
-
|
|
27
|
-
const { navigationState } = useContext(TinkerableContext) as any;
|
|
27
|
+
const entryKey = useEntryKey();
|
|
28
28
|
|
|
29
29
|
const relPath = useMemo(() => {
|
|
30
30
|
// The entry's absolute fs path (/app/content/...) is the base for relative assets.
|
|
31
|
-
const base =
|
|
31
|
+
const base = keyToFsPath(entryKey);
|
|
32
32
|
return resolvePath(base, src).replace(/^\/+/, '');
|
|
33
|
-
}, [
|
|
33
|
+
}, [entryKey, src]);
|
|
34
34
|
|
|
35
35
|
return (
|
|
36
36
|
<MountImage
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
-
import { useCallback,
|
|
2
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
3
3
|
import fs from 'fs';
|
|
4
4
|
import { Link, useFileMetadata, useMetadataQuery } from '@immediately-run/sdk';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { isContentEntry, keyToFsPath, keyToHref } from '../lib/content';
|
|
6
|
+
import { useEntryKey } from '../hooks/useEntryKey';
|
|
7
|
+
import { useFollowLink } from '../hooks/useFollowLink';
|
|
8
|
+
import { followLinkOnClick } from '../lib/navigationPolicy';
|
|
7
9
|
import { backlinkSnippet, bodyLinksTo, crumb, queryPaths } from '../lib/wiki';
|
|
8
10
|
import InlineProse from './InlineProse';
|
|
9
11
|
|
|
@@ -15,8 +17,14 @@ interface Hit {
|
|
|
15
17
|
// One linking entry: title + namespace crumb + the snippet around the link.
|
|
16
18
|
function Row({ hit }: { hit: Hit }) {
|
|
17
19
|
const meta = useFileMetadata(hit.key) as any;
|
|
20
|
+
const from = useEntryKey();
|
|
21
|
+
const follow = useFollowLink();
|
|
18
22
|
return (
|
|
19
|
-
<Link
|
|
23
|
+
<Link
|
|
24
|
+
href={keyToHref(hit.key)}
|
|
25
|
+
className="grove-bl"
|
|
26
|
+
onClick={followLinkOnClick(follow, { key: hit.key, href: keyToHref(hit.key), from })}
|
|
27
|
+
>
|
|
20
28
|
<div className="grove-bl__t">
|
|
21
29
|
<InlineProse text={meta?.title || crumb(hit.key)} trimPeriod />
|
|
22
30
|
<span className="crumb">/{crumb(hit.key)}</span>
|
|
@@ -29,8 +37,7 @@ function Row({ hit }: { hit: Hit }) {
|
|
|
29
37
|
// `<Backlinks/>` — the signature wiki affordance: who links here. Reads sibling
|
|
30
38
|
// entry bodies off the fs and scans for a link to the current entry.
|
|
31
39
|
export default function Backlinks() {
|
|
32
|
-
const
|
|
33
|
-
const currentKey = sandboxPathToKey(ctx?.navigationState?.sandboxPath || '/');
|
|
40
|
+
const currentKey = useEntryKey();
|
|
34
41
|
|
|
35
42
|
const allKeys = useCallback(
|
|
36
43
|
(fm: Record<string, any>) => Object.keys(fm).filter(isContentEntry),
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
-
import { useCallback
|
|
2
|
+
import { useCallback } from 'react';
|
|
3
3
|
import { Link, useMetadataQuery } from '@immediately-run/sdk';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { contentDir, isContentEntry, keyToHref } from '../lib/content';
|
|
5
|
+
import { useEntryKey } from '../hooks/useEntryKey';
|
|
6
|
+
import { useFollowLink } from '../hooks/useFollowLink';
|
|
7
|
+
import { followLinkOnClick } from '../lib/navigationPolicy';
|
|
6
8
|
import { namespaceOf, queryPaths } from '../lib/wiki';
|
|
7
9
|
|
|
8
10
|
// `<ChildPages/>` — the entries that live under the current entry's namespace, as
|
|
9
11
|
// a compact nested list (e.g. everything in `handbook/` from the handbook index).
|
|
10
12
|
export default function ChildPages() {
|
|
11
|
-
const
|
|
12
|
-
const currentKey = sandboxPathToKey(ctx?.navigationState?.sandboxPath || '/');
|
|
13
|
+
const currentKey = useEntryKey();
|
|
13
14
|
const rel = currentKey.replace(contentDir(), '').replace(/\.mdx?$/, '');
|
|
14
15
|
// The folder this entry indexes: its own slug if it's a section index, else its namespace.
|
|
15
16
|
const scope = rel.replace(/\/?(index|home)$/, '');
|
|
@@ -26,12 +27,13 @@ export default function ChildPages() {
|
|
|
26
27
|
);
|
|
27
28
|
const q = useMetadataQuery(queryFn);
|
|
28
29
|
const paths: string[] = queryPaths(q);
|
|
30
|
+
const follow = useFollowLink();
|
|
29
31
|
|
|
30
32
|
if (!paths.length) return null;
|
|
31
33
|
return (
|
|
32
34
|
<div className="grove-doclist" data-shape="feed">
|
|
33
35
|
{paths.map((p) => (
|
|
34
|
-
<Link key={p} href={keyToHref(p)} className="gdl-row">
|
|
36
|
+
<Link key={p} href={keyToHref(p)} className="gdl-row" onClick={followLinkOnClick(follow, { key: p, href: keyToHref(p), from: currentKey })}>
|
|
35
37
|
<div className="gdl-row__t">{p.replace(contentDir(), '').replace(/\.mdx?$/, '').split('/').pop()}</div>
|
|
36
38
|
</Link>
|
|
37
39
|
))}
|
|
@@ -3,6 +3,9 @@ import { useCallback } from 'react';
|
|
|
3
3
|
import { Link, useFileMetadata, useMetadataQuery } from '@immediately-run/sdk';
|
|
4
4
|
import { contentDir, keyToHref } from '../lib/content';
|
|
5
5
|
import { queryPaths } from '../lib/wiki';
|
|
6
|
+
import { useEntryKey } from '../hooks/useEntryKey';
|
|
7
|
+
import { useFollowLink } from '../hooks/useFollowLink';
|
|
8
|
+
import { followLinkOnClick } from '../lib/navigationPolicy';
|
|
6
9
|
|
|
7
10
|
interface Props {
|
|
8
11
|
team?: string;
|
|
@@ -15,11 +18,13 @@ function isPerson(m: any): boolean {
|
|
|
15
18
|
|
|
16
19
|
function Row({ path, compact }: { path: string; compact?: boolean }) {
|
|
17
20
|
const m = useFileMetadata(path) as any;
|
|
21
|
+
const from = useEntryKey();
|
|
22
|
+
const follow = useFollowLink();
|
|
18
23
|
if (!m) return null;
|
|
19
24
|
return (
|
|
20
25
|
<tr>
|
|
21
26
|
<td style={{ fontWeight: 600 }}>
|
|
22
|
-
<Link href={keyToHref(path)}>{m.name}</Link>
|
|
27
|
+
<Link href={keyToHref(path)} onClick={followLinkOnClick(follow, { key: path, href: keyToHref(path), from })}>{m.name}</Link>
|
|
23
28
|
</td>
|
|
24
29
|
<td>{m.role}</td>
|
|
25
30
|
{!compact && <td>{m.team}</td>}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
-
import { useContext } from 'react';
|
|
3
2
|
import { Link, useAllMetadata } from '@immediately-run/sdk';
|
|
4
|
-
|
|
5
|
-
import {
|
|
3
|
+
|
|
4
|
+
import { useEntryKey } from '../hooks/useEntryKey';
|
|
5
|
+
import { useFollowLink } from '../hooks/useFollowLink';
|
|
6
|
+
import { followLinkOnClick } from '../lib/navigationPolicy';
|
|
6
7
|
import {
|
|
7
8
|
buildDirectoryRows,
|
|
8
9
|
columnValue,
|
|
@@ -67,6 +68,8 @@ function Cell({ row, col }: { row: DirRow; col: DirColumn }) {
|
|
|
67
68
|
|
|
68
69
|
function Row({ row, cols }: { row: DirRow; cols: DirColumn[] }) {
|
|
69
70
|
const label = rowLabel(row);
|
|
71
|
+
const from = useEntryKey();
|
|
72
|
+
const follow = useFollowLink();
|
|
70
73
|
return (
|
|
71
74
|
<tr data-kind={row.kind}>
|
|
72
75
|
<td className="gdir__name">
|
|
@@ -79,7 +82,11 @@ function Row({ row, cols }: { row: DirRow; cols: DirColumn[] }) {
|
|
|
79
82
|
</span>
|
|
80
83
|
<span className="gdir__labels">
|
|
81
84
|
{row.href ? (
|
|
82
|
-
<Link
|
|
85
|
+
<Link
|
|
86
|
+
href={row.href}
|
|
87
|
+
className="gdir__link"
|
|
88
|
+
onClick={followLinkOnClick(follow, { key: row.key, href: row.href, from })}
|
|
89
|
+
>
|
|
83
90
|
{label}
|
|
84
91
|
</Link>
|
|
85
92
|
) : (
|
|
@@ -111,12 +118,13 @@ function Row({ row, cols }: { row: DirRow; cols: DirColumn[] }) {
|
|
|
111
118
|
* folder route rendering the stock table would be a half-override nobody could see.
|
|
112
119
|
*/
|
|
113
120
|
export default function DirectoryList({ path, title, columns, sort = 'name', hidden }: Props) {
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
//
|
|
121
|
+
const entryKey = useEntryKey();
|
|
122
|
+
// The entry this listing renders inside (an entry-context key, so an included
|
|
123
|
+
// fragment or a layout anchors where it renders, not where the URL points).
|
|
124
|
+
// It is a FILE when it names an entry; a listing anchored at an entry means
|
|
117
125
|
// "the folder that entry lives in", which is what an author writing `<DirectoryList/>`
|
|
118
126
|
// inside a page means by "here".
|
|
119
|
-
const fromDir = /\.mdx?$/.test(
|
|
127
|
+
const fromDir = /\.mdx?$/.test(entryKey) ? entryKey.slice(0, entryKey.lastIndexOf('/')) : entryKey;
|
|
120
128
|
const dirKey = resolveDirKey(path, fromDir);
|
|
121
129
|
|
|
122
130
|
const listing = useDirectoryListing(dirKey);
|
|
@@ -3,6 +3,8 @@ import { Fragment } from 'react';
|
|
|
3
3
|
import type { ComponentType } from 'react';
|
|
4
4
|
import { Link, useMDXComponents } from '@immediately-run/sdk';
|
|
5
5
|
import { useShell } from '../lib/shell';
|
|
6
|
+
import { useFollowLink } from '../hooks/useFollowLink';
|
|
7
|
+
import { followLinkOnClick } from '../lib/navigationPolicy';
|
|
6
8
|
import { keyToHref } from '../lib/content';
|
|
7
9
|
import { dirCrumbs, dirKeyToPath } from '../lib/directory';
|
|
8
10
|
import DirectoryList from './DirectoryList';
|
|
@@ -30,6 +32,7 @@ const NO_OVERRIDES = {};
|
|
|
30
32
|
*/
|
|
31
33
|
export default function DirectoryView() {
|
|
32
34
|
const { entryKey, siteTitle } = useShell();
|
|
35
|
+
const follow = useFollowLink();
|
|
33
36
|
const components = useMDXComponents(NO_OVERRIDES) as Record<string, ComponentType<any> | undefined>;
|
|
34
37
|
const List = components.DirectoryList ?? DirectoryList;
|
|
35
38
|
|
|
@@ -41,11 +44,11 @@ export default function DirectoryView() {
|
|
|
41
44
|
<div className="gp-main">
|
|
42
45
|
<header className="grove-entry-header">
|
|
43
46
|
<nav className="crumb">
|
|
44
|
-
<Link href="/">{siteTitle}</Link>
|
|
47
|
+
<Link href="/" onClick={followLinkOnClick(follow, { key: crumbs[0]?.key ?? '/', href: '/', from: entryKey })}>{siteTitle}</Link>
|
|
45
48
|
{crumbs.slice(0, -1).map((c) => (
|
|
46
49
|
<Fragment key={c.key}>
|
|
47
50
|
<span aria-hidden="true">/</span>
|
|
48
|
-
<Link href={keyToHref(c.key)}>{c.label}</Link>
|
|
51
|
+
<Link href={keyToHref(c.key)} onClick={followLinkOnClick(follow, { key: c.key, href: keyToHref(c.key), from: entryKey })}>{c.label}</Link>
|
|
49
52
|
</Fragment>
|
|
50
53
|
))}
|
|
51
54
|
</nav>
|
|
@@ -5,6 +5,9 @@ import { isContentEntry, keyToHref, slugToKey } from '../lib/content';
|
|
|
5
5
|
import InlineProse from './InlineProse';
|
|
6
6
|
import { queryPaths } from '../lib/wiki';
|
|
7
7
|
import EntryImage from './EntryImage';
|
|
8
|
+
import { useEntryKey } from '../hooks/useEntryKey';
|
|
9
|
+
import { useFollowLink } from '../hooks/useFollowLink';
|
|
10
|
+
import { followLinkOnClick } from '../lib/navigationPolicy';
|
|
8
11
|
|
|
9
12
|
interface Props {
|
|
10
13
|
shape?: 'feed' | 'grid';
|
|
@@ -29,9 +32,11 @@ function tagsOf(meta: any): string[] {
|
|
|
29
32
|
// One entry as a feed row — reads its own frontmatter so the hook count is stable.
|
|
30
33
|
function Row({ path }: { path: string }) {
|
|
31
34
|
const meta = useFileMetadata(path) as any;
|
|
35
|
+
const from = useEntryKey();
|
|
36
|
+
const follow = useFollowLink();
|
|
32
37
|
if (!meta) return <div className="gdl-row"><div className="sk sk-line" style={{ width: '50%' }} /></div>;
|
|
33
38
|
return (
|
|
34
|
-
<Link href={keyToHref(path)} className="gdl-row">
|
|
39
|
+
<Link href={keyToHref(path)} className="gdl-row" onClick={followLinkOnClick(follow, { key: path, href: keyToHref(path), from })}>
|
|
35
40
|
<div>
|
|
36
41
|
<div className="gdl-row__t"><InlineProse text={meta.title || path} trimPeriod /></div>
|
|
37
42
|
{meta.description && <div className="gdl-row__d"><InlineProse text={meta.description} /></div>}
|
|
@@ -52,9 +57,11 @@ function Row({ path }: { path: string }) {
|
|
|
52
57
|
// unreadable. Degrade, never break.
|
|
53
58
|
function CardTile({ path }: { path: string }) {
|
|
54
59
|
const meta = useFileMetadata(path) as any;
|
|
60
|
+
const from = useEntryKey();
|
|
61
|
+
const follow = useFollowLink();
|
|
55
62
|
if (!meta) return <div className="gdl-card"><div className="gdl-card__pic" /></div>;
|
|
56
63
|
return (
|
|
57
|
-
<Link href={keyToHref(path)} className="gdl-card">
|
|
64
|
+
<Link href={keyToHref(path)} className="gdl-card" onClick={followLinkOnClick(follow, { key: path, href: keyToHref(path), from })}>
|
|
58
65
|
<div className="gdl-card__pic">
|
|
59
66
|
<EntryImage entryPath={path} src={meta.cover} alt="" className="gdl-card__cover" degrade={<span className="gdl-card__lattice" />} />
|
|
60
67
|
</div>
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { Link } from '@immediately-run/sdk';
|
|
2
2
|
import { useOverlayFocusDismiss } from '../hooks/useOverlayFocusDismiss';
|
|
3
|
+
import { useFollowLink } from '../hooks/useFollowLink';
|
|
4
|
+
import { followLinkOnClick } from '../lib/navigationPolicy';
|
|
3
5
|
import Sidebar from './Sidebar';
|
|
4
6
|
import Icon from './Icon';
|
|
5
7
|
|
|
6
8
|
interface NavItem {
|
|
9
|
+
/** The corpus key (R3-872 — the navigation policy's target names it). */
|
|
10
|
+
key: string;
|
|
7
11
|
href: string;
|
|
8
12
|
label: string;
|
|
9
13
|
cur: boolean;
|
|
@@ -23,6 +27,7 @@ export default function Drawer({
|
|
|
23
27
|
onClose: () => void;
|
|
24
28
|
}) {
|
|
25
29
|
const dialogRef = useOverlayFocusDismiss(true, onClose);
|
|
30
|
+
const follow = useFollowLink();
|
|
26
31
|
return (
|
|
27
32
|
<div className="grove-drawer" onClick={onClose}>
|
|
28
33
|
<div
|
|
@@ -44,7 +49,15 @@ export default function Drawer({
|
|
|
44
49
|
{nav.length ? (
|
|
45
50
|
<nav className="grove-drawer__nav">
|
|
46
51
|
{nav.map((n) => (
|
|
47
|
-
<Link
|
|
52
|
+
<Link
|
|
53
|
+
key={n.href}
|
|
54
|
+
href={n.href}
|
|
55
|
+
data-cur={n.cur ? '1' : '0'}
|
|
56
|
+
onClick={(e) => {
|
|
57
|
+
followLinkOnClick(follow, { key: n.key, href: n.href })(e);
|
|
58
|
+
onClose();
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
48
61
|
{n.label}
|
|
49
62
|
</Link>
|
|
50
63
|
))}
|