@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.
- package/llms.txt +13 -1
- package/package.json +4 -3
- 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
|
@@ -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 {
|
|
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
|
-
// <
|
|
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 } =
|
|
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
|
-
|
|
6
|
-
import {
|
|
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
|
|
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
|
|
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
|
-
|
|
5
|
-
import {
|
|
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
|
|
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/')) : [];
|