@immediately-run/grove 0.1.8 → 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.
Files changed (44) hide show
  1. package/llms.txt +13 -1
  2. package/package.json +4 -3
  3. package/src/GroveWiki.tsx +13 -52
  4. package/src/components/AssetImage.test.tsx +64 -0
  5. package/src/components/AssetImage.tsx +10 -10
  6. package/src/components/Backlinks.tsx +13 -6
  7. package/src/components/ChildPages.tsx +8 -6
  8. package/src/components/Directory.tsx +6 -1
  9. package/src/components/DirectoryList.tsx +16 -8
  10. package/src/components/DirectoryView.tsx +5 -2
  11. package/src/components/DocList.tsx +9 -2
  12. package/src/components/Drawer.tsx +14 -1
  13. package/src/components/EntryBody.tsx +138 -0
  14. package/src/components/EntryHeader.tsx +16 -3
  15. package/src/components/FamilyTree.tsx +3 -5
  16. package/src/components/GroveAgent.tsx +1 -1
  17. package/src/components/GroveEntry.test.tsx +200 -0
  18. package/src/components/GroveEntry.tsx +108 -0
  19. package/src/components/GroveFooter.tsx +5 -2
  20. package/src/components/GroveNav.tsx +11 -2
  21. package/src/components/PageMeta.tsx +3 -5
  22. package/src/components/PageView.tsx +13 -63
  23. package/src/components/Search.tsx +10 -1
  24. package/src/components/Sidebar.tsx +15 -7
  25. package/src/components/TableOfContents.tsx +1 -1
  26. package/src/components/Timeline.tsx +6 -1
  27. package/src/components/WikiLink.test.tsx +83 -0
  28. package/src/components/WikiLink.tsx +29 -8
  29. package/src/components/navigationPolicy.sweep.test.tsx +261 -0
  30. package/src/hooks/useEntryKey.ts +21 -0
  31. package/src/hooks/useFollowLink.ts +11 -0
  32. package/src/hooks/useHeadings.test.tsx +93 -0
  33. package/src/hooks/useHeadings.ts +73 -12
  34. package/src/lib/assetPath.ts +4 -3
  35. package/src/lib/content.ts +0 -5
  36. package/src/lib/entryContext.test.ts +46 -0
  37. package/src/lib/entryContext.ts +35 -0
  38. package/src/lib/fragment.test.ts +21 -0
  39. package/src/lib/fragment.ts +9 -6
  40. package/src/lib/navigationPolicy.test.ts +65 -0
  41. package/src/lib/navigationPolicy.ts +57 -0
  42. package/src/lib/renderMode.ts +17 -0
  43. package/src/lib/shell.ts +4 -0
  44. package/src/lib.ts +18 -0
@@ -0,0 +1,138 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ // `<EntryBody/>` — ONE entry's content region (R3-872, APP_CUSTOMIZATION §4.1):
3
+ // the header (with its edit affordance), the body on the safe or compiled path
4
+ // exactly as the stock page renders it, the metadata line and the tags. Carved
5
+ // out of PageView so `GroveEntry` (and any shell composing several entries on one
6
+ // page) renders the same thing the stock page does — there is one entry renderer.
7
+ //
8
+ // Reads the shell context NON-throwing (the stock page provides it; a bare
9
+ // library-mode composition does not) and derives anything missing from the
10
+ // entryKey itself — one derivation, two possible readers.
11
+ import { useEffect, useState } from 'react';
12
+ import { useContext } from 'react';
13
+ import { Include, useFileMetadata } from '@immediately-run/sdk';
14
+ import { GroveShellContext } from '../lib/shell';
15
+ import { keyToInclude, homeKey } from '../lib/content';
16
+ import { resolvePageLayout } from '../lib/layout';
17
+ import { readingTime, stripFrontmatter } from '../lib/wiki';
18
+ import { keyToFsPath } from '../lib/content';
19
+ import { resolveSafeRender } from '../lib/renderMode';
20
+ import { useEditAffordance } from '../hooks/useEditAffordance';
21
+ import fs from 'fs';
22
+ import EntryHeader from './EntryHeader';
23
+ import SafeEntryBody from './SafeEntryBody';
24
+ import ScrollToFragment from './ScrollToFragment';
25
+ import Toc from './Toc';
26
+ import Backlinks from './Backlinks';
27
+
28
+ declare const module: any;
29
+
30
+ /** The reading-time minutes for one entry — read the body once per entry.
31
+ * `enabled` gates the read (hooks may not be conditional): the stock page's shell
32
+ * computes `mins` once already, and a second read per navigation is the
33
+ * double-read this flag exists to prevent (review round 1). */
34
+ function useReadingTimeMins(entryKey: string, enabled: boolean): number {
35
+ const [mins, setMins] = useState(0);
36
+ useEffect(() => {
37
+ if (!enabled) return;
38
+ let active = true;
39
+ // eslint-disable-next-line react-hooks/set-state-in-effect
40
+ setMins(0);
41
+ fs.promises
42
+ .readFile(keyToFsPath(entryKey), 'utf8')
43
+ .then((b: unknown) => {
44
+ if (active) setMins(readingTime(stripFrontmatter(String(b))));
45
+ })
46
+ .catch(() => undefined);
47
+ return () => {
48
+ active = false;
49
+ };
50
+ }, [entryKey, enabled]);
51
+ return mins;
52
+ }
53
+
54
+ export default function EntryBody({ entryKey }: { entryKey: string }) {
55
+ const sh = useContext(GroveShellContext);
56
+ const meta = useFileMetadata(entryKey) as any;
57
+ const homeMeta = useFileMetadata(homeKey()) as any;
58
+ // The shell's values win when present (the stock page derives them once);
59
+ // standalone, the same expressions run here.
60
+ const includePath = sh?.includePath ?? keyToInclude(entryKey);
61
+ const layout = sh?.layout ?? resolvePageLayout(meta);
62
+ const showRails = sh?.showRails ?? (layout === 'doc' && !meta?.view);
63
+ const safe = sh?.safe ?? resolveSafeRender(homeMeta, meta);
64
+ const vw = sh?.vw ?? 'desktop';
65
+ const localMins = useReadingTimeMins(entryKey, !sh); // the shell computes it on the stock page
66
+ const mins = sh?.mins ?? localMins;
67
+
68
+ return (
69
+ <article className="grove-page" data-layout={layout}>
70
+ <div className="gp-main">
71
+ {sh ? (
72
+ <EntryHeader
73
+ entryKey={entryKey}
74
+ writable={sh.writable}
75
+ mins={mins}
76
+ affordance={{ busy: sh.editBusy, refused: sh.editRefused, openEditor: sh.openEditor, hint: sh.editHint }}
77
+ />
78
+ ) : (
79
+ // Standalone (library mode): the header's affordance derives itself —
80
+ // the mount's live answer, exactly what the shell would have carried.
81
+ <StandaloneHeader entryKey={entryKey} mins={mins} />
82
+ )}
83
+ {showRails && vw === 'mobile' ? (
84
+ <details className="grove-toc__disclosure">
85
+ <summary>On this page</summary>
86
+ <Toc entryKey={entryKey} />
87
+ </details>
88
+ ) : null}
89
+ <div className="grove-prose">
90
+ {/* Interpreter mode (R3-213) renders the raw entry as data — no author JS runs;
91
+ executable mode compiles + runs the MDX via <Include>. Both are supported,
92
+ and since R3-252 the choice is per-entry as well as wiki-wide.
93
+
94
+ `key={entryKey}` forces an unmount across a client-side navigation. Without
95
+ it React reuses this subtree and keeps the PREVIOUS document mounted while
96
+ the new one compiles, so `#sec-4` resolves against the wrong entry — every
97
+ entry here numbers its sections from 1, so that lookup always succeeds and
98
+ always lands wrong. Measured on the host: a citation to core_concepts §4
99
+ ("4 — Principal") scrolled to a different document's "4. The provider-facing
100
+ contract" (R3-249's hazard, reintroduced).
101
+
102
+ R3-872: BOTH paths now carry the `data-entry` marker (the compiled path's
103
+ omission predates entry-scoped fragment resolution). On the compiled path
104
+ the marker is present while the body compiles — that reads as "scoped, not
105
+ yet found", and `resolveFragmentTarget`'s null-until-found is exactly the
106
+ wait the caller needs; the marker means "this subtree is entry X's region",
107
+ and an uncommitted body contains no fragment target to find, so the old
108
+ claim-too-early failure has no path to fire. */}
109
+ {safe ? (
110
+ <SafeEntryBody entryKey={entryKey} />
111
+ ) : (
112
+ <div className="grove-entry-body" key={entryKey} data-entry={entryKey}>
113
+ <Include filename={includePath} baseModule={module} />
114
+ <ScrollToFragment entryKey={entryKey} />
115
+ </div>
116
+ )}
117
+ </div>
118
+ {showRails ? <Backlinks /> : null}
119
+ </div>
120
+ {showRails && vw === 'desktop' ? <Toc entryKey={entryKey} /> : null}
121
+ </article>
122
+ );
123
+ }
124
+
125
+ /** The header for a shell-less composition: the edit affordance derives itself
126
+ * from the live mount set (the hook only runs on this path — no double
127
+ * subscription on the stock page). */
128
+ function StandaloneHeader({ entryKey, mins }: { entryKey: string; mins: number }) {
129
+ const local = useEditAffordance(false);
130
+ return (
131
+ <EntryHeader
132
+ entryKey={entryKey}
133
+ writable={local.writable}
134
+ mins={mins}
135
+ affordance={{ busy: local.busy, refused: local.refused, openEditor: local.openEditor, hint: local.editHint }}
136
+ />
137
+ );
138
+ }
@@ -1,23 +1,36 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  import { useFileMetadata } from '@immediately-run/sdk';
3
- import { useShell, EDIT_REFUSED_NOTICE } from '../lib/shell';
3
+ import { EDIT_REFUSED_NOTICE } from '../lib/shell';
4
4
  import { crumb } from '../lib/wiki';
5
5
  import Icon from './Icon';
6
6
  import InlineProse from './InlineProse';
7
7
 
8
8
  // The entry's header block: breadcrumb, title (optional gradient), meta row
9
9
  // (date · reading time · tags) and the unobtrusive edit affordance. Rendered by
10
- // <PageView> at the top of every entry.
10
+ // <EntryBody> at the top of every entry (R3-872) — on the stock page (inside
11
+ // PageView inside the chain) and standalone, identically.
12
+ export interface EntryHeaderAffordance {
13
+ busy: boolean;
14
+ refused: boolean;
15
+ openEditor: (entryKey: string) => void;
16
+ hint: string;
17
+ }
18
+
11
19
  export default function EntryHeader({
12
20
  entryKey,
13
21
  writable,
14
22
  mins,
23
+ affordance,
15
24
  }: {
16
25
  entryKey: string;
17
26
  writable: boolean;
18
27
  mins: number;
28
+ /** The edit affordance's live state — R3-872: passed in (the shell's on the
29
+ * stock page, self-derived standalone), never read off a context here, so the
30
+ * header renders inside any entry composition. */
31
+ affordance: EntryHeaderAffordance;
19
32
  }) {
20
- const { openEditor, editBusy, editRefused, editHint } = useShell();
33
+ const { openEditor, busy: editBusy, refused: editRefused, hint: editHint } = affordance;
21
34
  const meta = useFileMetadata(entryKey) as any;
22
35
  if (!meta) return null;
23
36
  const tags: string[] = Array.isArray(meta.tags) ? meta.tags.filter((t: string) => !t.startsWith('ui/')) : [];
@@ -1,9 +1,8 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { useContext } from 'react';
3
2
  import { useMetadataQuery } from '@immediately-run/sdk';
4
3
  import type { Metadata } from '@immediately-run/sdk';
5
- import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
6
- import { sandboxPathToKey } from '../lib/content';
4
+
5
+ import { useEntryKey } from '../hooks/useEntryKey';
7
6
  import { familyTreeQuery } from '../lib/queries';
8
7
  import type { FamilyNodeRecord } from '../lib/queries';
9
8
 
@@ -20,8 +19,7 @@ interface Node {
20
19
  // literal: group hubs joined by hairline edges to their members. Genealogy
21
20
  // (`parent`/`house`) and org charts (`team`/`manager`) share the same rendering.
22
21
  export default function FamilyTree() {
23
- const ctx = useContext(TinkerableContext) as any;
24
- const currentKey = sandboxPathToKey(ctx?.navigationState?.sandboxPath || '/');
22
+ const currentKey = useEntryKey();
25
23
 
26
24
  // Records, not tab-encoded paths (R3-276a).
27
25
  const q = useMetadataQuery<Metadata, FamilyNodeRecord>(familyTreeQuery);
@@ -48,7 +48,7 @@ export default function GroveAgent({
48
48
  const catalog = useCatalog();
49
49
  const index = useAllMetadata();
50
50
  const headings = useHeadings(entryKey);
51
- const activeHeading = useActiveHeading(headings);
51
+ const activeHeading = useActiveHeading(headings, entryKey);
52
52
  const { openEditor, editRefused } = useShell();
53
53
  const [open, setOpen] = useState(false);
54
54
  const [detent, setDetent] = useState<'half' | 'full'>('half');
@@ -0,0 +1,200 @@
1
+ // @vitest-environment jsdom
2
+ // R3-872 (APP_CUSTOMIZATION_SPEC §4.1) — GroveEntry: one entry from a key.
3
+ // • frame 'none' renders the bare entry — header + body + metadata — with NO
4
+ // layout-chain wrapper;
5
+ // • frame 'chain' renders through the layout chain (the stock page's shape);
6
+ // • both publish the entry context (a WikiLink inside resolves relative to the
7
+ // entry, not the URL) and both carry the data-entry marker on the body
8
+ // region (§4.5's scoping contract, both render paths);
9
+ // • the stock page renders through GroveEntry with today's DOM landmarks.
10
+ import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
11
+ import { act } from 'react';
12
+ import { createRoot, type Root } from 'react-dom/client';
13
+ import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
14
+ import { CorpusScanContext } from '../lib/corpusScanContext';
15
+ import type { CorpusScanGate } from '../lib/corpusScan';
16
+
17
+ beforeAll(() => {
18
+ Object.defineProperty(window, 'matchMedia', {
19
+ writable: true,
20
+ value: (q: string) => ({ matches: false, media: q, addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {}, dispatchEvent: () => false, onchange: null }),
21
+ });
22
+ });
23
+
24
+ // The fs double: entry bodies read through it. `render: safe` in the metadata keeps the
25
+ // body on the interpreter path (the compiled evaluator only exists in the sandbox
26
+ // bundler) — the marker assertions below pin the WRAPPER, which both paths mount.
27
+ const { readFile } = vi.hoisted(() => ({ readFile: vi.fn() }));
28
+ vi.mock('fs', () => ({ default: { promises: { readFile: (...a: unknown[]) => readFile(...a) } } }));
29
+ (globalThis as { __sandpackSharedFs?: unknown }).__sandpackSharedFs = {
30
+ promises: { readFile: (...a: unknown[]) => readFile(...a) },
31
+ };
32
+
33
+ const { default: GroveEntry } = await import('./GroveEntry');
34
+ const { default: GroveWiki } = await import('../GroveWiki');
35
+
36
+ const ENTRY = '/app/content/wiki/a.mdx';
37
+ const HOME = '/app/content/home.mdx';
38
+ const LAYOUT = '/app/content/_layout.mdx';
39
+
40
+ const NAV = {
41
+ mode: 'github',
42
+ namespace: 'immediately-run',
43
+ provider: 'github',
44
+ repository: 'corpus',
45
+ ref: 'main',
46
+ sandboxPath: ENTRY,
47
+ hash: '',
48
+ search: '',
49
+ };
50
+
51
+ const META = {
52
+ [ENTRY]: { title: 'Reference entry.', render: 'safe', date: '2026-06-22', tags: ['handbook'] },
53
+ [HOME]: { title: 'Home', render: 'safe' },
54
+ [LAYOUT]: { site: 'Fixture' },
55
+ };
56
+
57
+ beforeEach(() => {
58
+ readFile.mockReset();
59
+ readFile.mockImplementation(async (path: string) =>
60
+ // A layout carries <Outlet /> where the page goes (a layout body without one
61
+ // swallows the page — pre-existing harness behavior, not under test here).
62
+ path === LAYOUT
63
+ ? '---\nlayoutRole: root\n---\n\nCHAIN-MARKER\n\n<Outlet />\n'
64
+ : '---\ntitle: Reference entry.\nrender: safe\n---\n\nThe body text.\n',
65
+ );
66
+ });
67
+
68
+ const gateAll: CorpusScanGate = {
69
+ isSettled: () => true,
70
+ prioritize: () => undefined,
71
+ readFailure: () => null,
72
+ };
73
+
74
+ function mount(): { container: HTMLElement; root: Root } {
75
+ const container = document.createElement('div');
76
+ document.body.appendChild(container);
77
+ return { container, root: createRoot(container) };
78
+ }
79
+
80
+ async function renderInto(root: Root, node: React.ReactNode) {
81
+ await act(async () => {
82
+ root.render(
83
+ <TinkerableContext.Provider
84
+ value={{ outerHref: 'https://immediately.run/x', navigationState: NAV, routingSpec: { routes: [] } as never, filesMetadata: META } as never}
85
+ >
86
+ <CorpusScanContext value={gateAll}>{node}</CorpusScanContext>
87
+ </TinkerableContext.Provider>,
88
+ );
89
+ });
90
+ for (let i = 0; i < 6; i++) await act(async () => {});
91
+ }
92
+
93
+ describe('GroveEntry (R3-872)', () => {
94
+ it("frame 'none' renders the bare entry — no layout chain, the landmarks intact", async () => {
95
+ const { container, root } = mount();
96
+ await renderInto(root, <GroveEntry entryKey={ENTRY} frame="none" />);
97
+ // the entry renders
98
+ const article = container.querySelector('article.grove-page');
99
+ expect(article).toBeTruthy();
100
+ expect(container.querySelector('.grove-entry-header h1')?.textContent).toContain('Reference entry');
101
+ expect(container.querySelector('.grove-tag')?.textContent).toBe('#handbook');
102
+ // no layout chain: no shell wrapper, no default-layout landmarks
103
+ expect(container.querySelector('.grove-shell')).toBeNull();
104
+ expect(container.textContent).not.toContain('CHAIN-MARKER');
105
+ // the body region carries the marker (the safe path — SafeEntryBody — and the
106
+ // compiled path's wrapper both carry it; here the safe path renders)
107
+ expect(container.querySelector('[data-entry]')).toBeTruthy();
108
+ expect(container.querySelector('[data-entry]')?.getAttribute('data-entry')).toBe(ENTRY);
109
+ await act(async () => root.unmount());
110
+ });
111
+
112
+ it("frame 'chain' renders the layout chain around the entry (the stock page's shape)", async () => {
113
+ // The chain's chrome reads the shell — the same shape GroveNav.test.tsx
114
+ // builds (a composer rendering 'chain' provides the shell; the stock page
115
+ // is exactly that).
116
+ const { GroveShellContext } = await import('../lib/shell');
117
+ const shell = {
118
+ siteTitle: 'Fixture',
119
+ navItems: [],
120
+ entryKey: ENTRY,
121
+ vw: 'desktop',
122
+ navMode: 'top',
123
+ writable: false,
124
+ openEditor: () => {},
125
+ editBusy: false,
126
+ editRefused: false,
127
+ editHint: '',
128
+ // PageView's page-level states read these (the shell owns them).
129
+ directory: { status: 'none' },
130
+ missing: false,
131
+ suggestion: undefined,
132
+ mins: 0,
133
+ safe: true,
134
+ includePath: '/app/content/wiki/a.mdx',
135
+ layout: 'doc',
136
+ showRails: false,
137
+ };
138
+ const { container, root } = mount();
139
+ await act(async () => {
140
+ root.render(
141
+ <TinkerableContext.Provider
142
+ value={{ outerHref: 'https://immediately.run/x', navigationState: NAV, routingSpec: { routes: [] } as never, filesMetadata: META } as never}
143
+ >
144
+ <CorpusScanContext value={gateAll}>
145
+ <GroveShellContext.Provider value={shell as never}>
146
+ <GroveEntry entryKey={ENTRY} frame="chain" />
147
+ </GroveShellContext.Provider>
148
+ </CorpusScanContext>
149
+ </TinkerableContext.Provider>,
150
+ );
151
+ });
152
+ for (let i = 0; i < 6; i++) await act(async () => {});
153
+ // the chain's own evidence (the mocked layout carries the marker text
154
+ // around its <Outlet/>) + the entry inside it
155
+ expect(container.textContent).toContain('CHAIN-MARKER');
156
+ const article = container.querySelector('article.grove-page');
157
+ expect(article).toBeTruthy();
158
+ expect(container.querySelector('.grove-entry-header h1')?.textContent).toContain('Reference entry');
159
+ expect(container.querySelector('[data-entry]')?.getAttribute('data-entry')).toBe(ENTRY);
160
+ await act(async () => root.unmount());
161
+ });
162
+
163
+ it('the compiled path carries the marker too (§4.5: both render paths, so fragment scoping never falls document-wide on a multi-entry page)', async () => {
164
+ // render: safe removed from the entry's metadata → the compiled branch. The
165
+ // marker sits on EntryBody's wrapper div, mounted before <Include> resolves —
166
+ // the marker means "this region belongs to entry X"; a fragment not yet in it
167
+ // reads as not-found and the caller keeps waiting (the commit-timing rule).
168
+ const metaCompiled = { ...META, [ENTRY]: { title: 'Reference entry.', date: '2026-06-22', tags: ['handbook'] } };
169
+ const { container, root } = mount();
170
+ await act(async () => {
171
+ root.render(
172
+ <TinkerableContext.Provider
173
+ value={{ outerHref: 'https://immediately.run/x', navigationState: NAV, routingSpec: { routes: [] } as never, filesMetadata: metaCompiled } as never}
174
+ >
175
+ <CorpusScanContext value={gateAll}>
176
+ <GroveEntry entryKey={ENTRY} frame="none" />
177
+ </CorpusScanContext>
178
+ </TinkerableContext.Provider>,
179
+ );
180
+ });
181
+ for (let i = 0; i < 6; i++) await act(async () => {});
182
+ const marked = container.querySelector('[data-entry]');
183
+ expect(marked?.getAttribute('data-entry')).toBe(ENTRY);
184
+ await act(async () => root.unmount());
185
+ });
186
+
187
+ it('the stock page renders through GroveEntry with the same DOM landmarks (content/home.mdx)', async () => {
188
+ const { container, root } = mount();
189
+ await renderInto(root, <GroveWiki />);
190
+ // the landmarks every reader path depends on: shell, the article, header,
191
+ // prose, the marker naming the rendered entry
192
+ expect(container.querySelector('.grove-shell')).toBeTruthy();
193
+ expect(container.querySelector('article.grove-page')).toBeTruthy();
194
+ expect(container.querySelector('.grove-entry-header')).toBeTruthy();
195
+ expect(container.querySelector('.grove-prose')).toBeTruthy();
196
+ const marked = container.querySelector('[data-entry]');
197
+ expect(marked?.getAttribute('data-entry')).toBe(ENTRY);
198
+ await act(async () => root.unmount());
199
+ });
200
+ });
@@ -0,0 +1,108 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ // `<GroveEntry/>` — one entry from a key (APP_CUSTOMIZATION_SPEC §4.1, R3-872):
3
+ // the header with its edit affordance, the body on the safe or compiled path
4
+ // exactly as the stock page renders it, the metadata line and the tags, with
5
+ // `frame: 'chain' | 'none'` — 'chain' renders through the layout chain (the
6
+ // stock page's shape), 'none' renders the bare entry for shells composing
7
+ // several entries on one page. Either way it PUBLISHES the entry context, so
8
+ // everything inside resolves relative links and anchors against THIS entry
9
+ // (R3-871), and the fragment/heading scopes find its body (§4.5).
10
+ //
11
+ // The stock page renders through GroveEntry fed by the route key — one entry
12
+ // renderer, no fork.
13
+
14
+ import { useContext, useEffect } from 'react';
15
+ import type { ReactNode } from 'react';
16
+ import { Include, useAllMetadata, useFileMetadata } from '@immediately-run/sdk';
17
+ import { EntryContext } from '../hooks/useEntryKey';
18
+ import { layoutChainForKey } from '../lib/layout';
19
+ import { criticalKeys } from '../lib/criticalKeys';
20
+ import { criticalFailure, entryPending } from '../lib/entryGate';
21
+ import { CorpusScanContext } from '../lib/corpusScanContext';
22
+ import { homeKey } from '../lib/content';
23
+ import { resolveSafeRender } from '../lib/renderMode';
24
+ import BootMessage from './BootMessage';
25
+ import DefaultLayout from './DefaultLayout';
26
+ import SafeLayout from './SafeLayout';
27
+ import PageView from './PageView';
28
+ import EntryBody from './EntryBody';
29
+ import { GroveShellContext, OutletContext } from '../lib/shell';
30
+
31
+ declare const module: any;
32
+
33
+ // Build the nested render for a layout chain (outermost first). Each layer wraps
34
+ // its `_layout.mdx` (or the built-in <DefaultLayout/>) in an OutletContext whose
35
+ // value is the node one level inward — so `<Outlet/>` inside a layer renders the
36
+ // next layer, and the innermost <Outlet/> renders the page (<PageView/>).
37
+ //
38
+ // `safe` picks the RENDERER for each layer, exactly as it does for entry bodies in
39
+ // <EntryBody/> (R3-263). Before this, every layer went through <Include> whatever the wiki
40
+ // declared — so an interpreter-mode wiki still EXECUTED author JavaScript out of its
41
+ // `_layout.mdx`, and the non-executable guarantee had a hole in the shell rather than in
42
+ // the entries. It is also what makes the chain work at all under dispatch: <Include>
43
+ // evaluates an app-source module, which a layout resident in a content mount is not.
44
+ function renderLayers(chain: string[], useDefault: boolean, safe: boolean): ReactNode {
45
+ let node: ReactNode = <PageView />;
46
+ if (useDefault) {
47
+ return <OutletContext.Provider value={node}><DefaultLayout /></OutletContext.Provider>;
48
+ }
49
+ for (let i = chain.length - 1; i >= 0; i--) {
50
+ const inner = node;
51
+ node = (
52
+ <OutletContext.Provider value={inner} key={chain[i]}>
53
+ {safe ? <SafeLayout layoutKey={chain[i]} /> : <Include filename={chain[i]} baseModule={module} />}
54
+ </OutletContext.Provider>
55
+ );
56
+ }
57
+ return node;
58
+ }
59
+
60
+ /** The 'chain' frame: the entry inside its layout chain, with the entry gate
61
+ * (critical-file scan) waiting the body until its render inputs are read. */
62
+ function EntryFrame({ entryKey }: { entryKey: string }) {
63
+ const meta = useFileMetadata(entryKey) as any;
64
+ const homeMeta = useFileMetadata(homeKey()) as any;
65
+ const allMeta = useAllMetadata() as Record<string, Record<string, unknown>>;
66
+ // Interpreter mode (TRUST_MODES §5 / R3-213) — the decision lives in ONE
67
+ // module (lib/renderMode.ts): the chain renderer, the body renderer and the
68
+ // shell's `safe` field must never disagree.
69
+ const safe: boolean = resolveSafeRender(homeMeta, meta);
70
+ const chain: string[] = layoutChainForKey(entryKey, allMeta);
71
+ const frameNone = meta?.frame === 'none' || meta?.frame === false;
72
+ const useDefault = chain.length === 0 && !frameNone;
73
+
74
+ // ── The entry gate (MDX_FROM_MOUNT_SPEC D8) ────────────────────────────────
75
+ // The files that decide how THIS entry renders — itself, home, its layouts, its
76
+ // `frame:` — are read ahead of the rest, and the body waits for them: an unread
77
+ // row reads as `render` unset, which is the executing path. The chrome around it
78
+ // stays mounted. The prioritize effect lives HERE now (moved with the gate) —
79
+ // the shell carries the stylesheets' read state (wiki-wide), 'ready' standalone.
80
+ const scanGate = useContext(CorpusScanContext);
81
+ const sheets = useContext(GroveShellContext)?.stylesheetsStatus ?? 'ready';
82
+ const critical = criticalKeys(entryKey, allMeta, scanGate.readFailure);
83
+ const criticalSig = critical.join('|');
84
+ useEffect(() => {
85
+ scanGate.prioritize(criticalSig.split('|'));
86
+ }, [scanGate, criticalSig]);
87
+ const pending = entryPending(critical, scanGate.isSettled, sheets);
88
+ const failure = criticalFailure(critical, scanGate.readFailure);
89
+
90
+ return <>{failure ? <BootMessage>{failure}</BootMessage> : pending ? <BootMessage /> : renderLayers(chain, useDefault, safe)}</>;
91
+ }
92
+
93
+ export default function GroveEntry({
94
+ entryKey,
95
+ frame = 'chain',
96
+ }: {
97
+ entryKey: string;
98
+ /** 'chain' (default): the entry inside its layout chain — the stock page's
99
+ * shape. 'none': the bare entry (header + body + rails), for shells that
100
+ * compose several entries on one page. */
101
+ frame?: 'chain' | 'none';
102
+ }) {
103
+ return (
104
+ <EntryContext.Provider value={{ entryKey }}>
105
+ {frame === 'none' ? <EntryBody entryKey={entryKey} /> : <EntryFrame entryKey={entryKey} />}
106
+ </EntryContext.Provider>
107
+ );
108
+ }
@@ -1,15 +1,18 @@
1
1
  import { Link } from '@immediately-run/sdk';
2
2
  import { useShell } from '../lib/shell';
3
+ import { useFollowLink } from '../hooks/useFollowLink';
4
+ import { followLinkOnClick } from '../lib/navigationPolicy';
3
5
 
4
6
  // `<GroveFooter/>` — the site footer. Import-free in MDX; reads the nav items from
5
7
  // the shell context so a layout just places `<GroveFooter/>`.
6
8
  export default function GroveFooter() {
7
- const { navItems } = useShell();
9
+ const { navItems, entryKey } = useShell();
10
+ const follow = useFollowLink();
8
11
  return (
9
12
  <footer className="grove-footer">
10
13
  <div className="grove-footer__links">
11
14
  {navItems.slice(0, 4).map((n) => (
12
- <Link key={n.key} href={n.href}>
15
+ <Link key={n.key} href={n.href} onClick={followLinkOnClick(follow, { key: n.key, href: n.href, from: entryKey })}>
13
16
  {n.label}
14
17
  </Link>
15
18
  ))}
@@ -1,6 +1,9 @@
1
1
  import { Link } from '@immediately-run/sdk';
2
2
  import { useShell, EDIT_REFUSED_NOTICE } from '../lib/shell';
3
3
  import { getContentRoot } from '../lib/contentRoot';
4
+ import { homeKey } from '../lib/content';
5
+ import { useFollowLink } from '../hooks/useFollowLink';
6
+ import { followLinkOnClick } from '../lib/navigationPolicy';
4
7
  import { useOverlayFocusDismiss } from '../hooks/useOverlayFocusDismiss';
5
8
  import { THEMES } from '../data/themes';
6
9
  import Icon from './Icon';
@@ -27,6 +30,7 @@ export default function GroveNav() {
27
30
  setSearchOpen,
28
31
  setDrawerOpen,
29
32
  } = useShell();
33
+ const follow = useFollowLink();
30
34
 
31
35
  const askGrove = () => {
32
36
  const el = (document.querySelector('.ga-foot input') || document.querySelector('.ga-line input')) as HTMLElement | null;
@@ -64,13 +68,18 @@ export default function GroveNav() {
64
68
  <button className="grove-hamburger icbtn" aria-label="Menu" onClick={() => setDrawerOpen(true)}>
65
69
  <Icon name="list" />
66
70
  </button>
67
- <Link href="/" className="grove-brand">
71
+ <Link href="/" className="grove-brand" onClick={followLinkOnClick(follow, { key: homeKey(), href: '/', from: entryKey })}>
68
72
  <span className="tile" style={{ background: 'var(--grad)' }} />
69
73
  {siteTitle}
70
74
  </Link>
71
75
  <div className="grove-nav__links">
72
76
  {navItems.map((n) => (
73
- <Link key={n.key} href={n.href} data-cur={n.key === entryKey ? '1' : '0'}>
77
+ <Link
78
+ key={n.key}
79
+ href={n.href}
80
+ data-cur={n.key === entryKey ? '1' : '0'}
81
+ onClick={followLinkOnClick(follow, { key: n.key, href: n.href, from: entryKey })}
82
+ >
74
83
  {n.label}
75
84
  </Link>
76
85
  ))}
@@ -1,14 +1,12 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { useContext } from 'react';
3
2
  import { useFileMetadata } from '@immediately-run/sdk';
4
- import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
5
- import { sandboxPathToKey } from '../lib/content';
3
+
4
+ import { useEntryKey } from '../hooks/useEntryKey';
6
5
 
7
6
  // `<PageMeta/>` — the current entry's date + tags as a styled strip, for authors
8
7
  // who want the meta inside the body (the entry header already renders one).
9
8
  export default function PageMeta() {
10
- const ctx = useContext(TinkerableContext) as any;
11
- const key = sandboxPathToKey(ctx?.navigationState?.sandboxPath || '/');
9
+ const key = useEntryKey();
12
10
  const meta = useFileMetadata(key) as any;
13
11
  if (!meta) return null;
14
12
  const tags: string[] = Array.isArray(meta.tags) ? meta.tags.filter((t: string) => !t.startsWith('ui/')) : [];