@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.
Files changed (44) hide show
  1. package/llms.txt +13 -1
  2. package/package.json +1 -1
  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
@@ -1,27 +1,26 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { Include, Link } from '@immediately-run/sdk';
1
+ import { Link } from '@immediately-run/sdk';
3
2
  import { useShell, EDIT_REFUSED_NOTICE } from '../lib/shell';
4
- import { keyToHref, keyToRepoRel } from '../lib/content';
3
+ import { keyToHref, keyToRepoRel, homeKey } from '../lib/content';
5
4
  import { crumb } from '../lib/wiki';
6
5
  import { useHeadingFragmentUrl } from '../hooks/useHeadingFragmentUrl';
6
+ import { useFollowLink } from '../hooks/useFollowLink';
7
+ import { followLinkOnClick } from '../lib/navigationPolicy';
7
8
  import DirectoryView from './DirectoryView';
8
- import EntryHeader from './EntryHeader';
9
- import SafeEntryBody from './SafeEntryBody';
10
- import ScrollToFragment from './ScrollToFragment';
11
- import Toc from './Toc';
12
- import Backlinks from './Backlinks';
9
+ import EntryBody from './EntryBody';
13
10
  import Icon from './Icon';
14
11
 
15
- declare const module: any;
16
-
17
12
  // `<PageView/>` — the reading view for the current entry: the 404/missing state,
18
13
  // or the entry header + prose body (+ ToC / backlinks rails). This is what the
19
14
  // INNERMOST `<Outlet/>` renders at the bottom of the layout chain. It carries no
20
15
  // site chrome (nav / sidebar / footer) — that's the layout's job — so the page
21
16
  // stays free of shell concerns.
22
17
  export default function PageView() {
23
- const { entryKey, includePath, layout, showRails, mins, missing, suggestion, writable, openEditor, editBusy, editRefused, editHint, vw, safe, directory } =
18
+ // R3-872: the entry content lives in EntryBody (one renderer, shared with
19
+ // GroveEntry); the page keeps only the page-level states (checking /
20
+ // directory / missing) and the deep-link half it owns.
21
+ const { entryKey, missing, suggestion, writable, openEditor, editBusy, editRefused, editHint, directory } =
24
22
  useShell();
23
+ const follow = useFollowLink();
25
24
 
26
25
  // The reading view owns the headings, so it owns the outgoing half of deep
27
26
  // linking: same-page heading navigation writes the fragment into the host's
@@ -41,10 +40,10 @@ export default function PageView() {
41
40
  <h2>No entry at <code>{keyToRepoRel(entryKey).replace(/^content/, '')}</code>.</h2>
42
41
  <p>
43
42
  That link points to an entry that doesn’t exist yet.
44
- {suggestion ? <> Did you mean <Link className="grove-wikilink" data-state="ok" href={keyToHref(suggestion)}>{crumb(suggestion)}</Link>?</> : null}
43
+ {suggestion ? <> Did you mean <Link className="grove-wikilink" data-state="ok" href={keyToHref(suggestion)} onClick={followLinkOnClick(follow, { key: suggestion, href: keyToHref(suggestion), from: entryKey })}>{crumb(suggestion)}</Link>?</> : null}
45
44
  </p>
46
45
  <div className="grove-state__actions">
47
- <Link className="btn-ghost" href="/"><Icon name="chevron-right" /> Back to home</Link>
46
+ <Link className="btn-ghost" href="/" onClick={followLinkOnClick(follow, { key: homeKey(), href: '/', from: entryKey })}><Icon name="chevron-right" /> Back to home</Link>
48
47
  {writable ? (
49
48
  <>
50
49
  <button
@@ -66,54 +65,5 @@ export default function PageView() {
66
65
  );
67
66
  }
68
67
 
69
- return (
70
- <article className="grove-page" data-layout={layout}>
71
- <div className="gp-main">
72
- <EntryHeader entryKey={entryKey} writable={writable} mins={mins} />
73
- {showRails && vw === 'mobile' ? (
74
- <details className="grove-toc__disclosure">
75
- <summary>On this page</summary>
76
- <Toc entryKey={entryKey} />
77
- </details>
78
- ) : null}
79
- <div className="grove-prose">
80
- {/* Interpreter mode (R3-213) renders the raw entry as data — no author JS runs;
81
- executable mode compiles + runs the MDX via <Include>. Both are supported,
82
- and since R3-252 the choice is per-entry as well as wiki-wide.
83
-
84
- The compiled branch mounts <ScrollToFragment> too — deep-link landing is not
85
- a safe-path feature — but deliberately carries NO `data-entry` marker, and
86
- both halves of that are load-bearing (R3-252).
87
-
88
- `key={entryKey}` forces an unmount across a client-side navigation. Without
89
- it React reuses this subtree and keeps the PREVIOUS document mounted while
90
- the new one compiles, so `#sec-4` resolves against the wrong entry — every
91
- entry here numbers its sections from 1, so that lookup always succeeds and
92
- always lands wrong. Measured on the host: a citation to core_concepts §4
93
- ("4 — Principal") scrolled to a different document's "4. The provider-facing
94
- contract" (R3-249's hazard, reintroduced).
95
-
96
- No marker, because `<Include>` owns an internal <Suspense> and this wrapper
97
- is OUTSIDE it — so a marker here would appear the instant we navigate, while
98
- the body is still compiling, and claim a commit that has not happened. That
99
- is worse than no signal: `data-entry` means "this subtree holds entry X's
100
- committed body" everywhere else (`SafeEntryBody` puts it inside the suspended
101
- subtree, so it is true there), and the on-host harness waits on it. Keying
102
- already removes the hazard the marker defended against — the outgoing
103
- document is gone from the DOM, not lingering — so `resolveFragmentTarget`'s
104
- documented document-wide fallback for the compiled path is safe here. */}
105
- {safe ? (
106
- <SafeEntryBody entryKey={entryKey} />
107
- ) : (
108
- <div className="grove-entry-body" key={entryKey}>
109
- <Include filename={includePath} baseModule={module} />
110
- <ScrollToFragment entryKey={entryKey} />
111
- </div>
112
- )}
113
- </div>
114
- {showRails ? <Backlinks /> : null}
115
- </div>
116
- {showRails && vw === 'desktop' ? <Toc entryKey={entryKey} /> : null}
117
- </article>
118
- );
68
+ return <EntryBody entryKey={entryKey} />;
119
69
  }
@@ -4,6 +4,8 @@ import type { Metadata } from '@immediately-run/sdk';
4
4
  import { keyToHref } from '../lib/content';
5
5
  import { crumb } from '../lib/wiki';
6
6
  import { useOverlayFocusDismiss } from '../hooks/useOverlayFocusDismiss';
7
+ import { useFollowLink } from '../hooks/useFollowLink';
8
+ import { followLinkOnClick } from '../lib/navigationPolicy';
7
9
  import InlineProse from './InlineProse';
8
10
  import { matchesQuery, searchQuery, toSearchEntries } from '../lib/queries';
9
11
  import type { SearchRecord } from '../lib/queries';
@@ -75,6 +77,7 @@ export default function Search({ onClose }: { onClose: () => void }) {
75
77
  }
76
78
  };
77
79
 
80
+ const follow = useFollowLink();
78
81
  const activeId = total > 0 ? `${listId}-opt-${sel}` : undefined;
79
82
 
80
83
  return (
@@ -125,7 +128,13 @@ export default function Search({ onClose }: { onClose: () => void }) {
125
128
  id={`${listId}-opt-${i}`}
126
129
  role="option"
127
130
  aria-selected={sel === i}
128
- onClick={onClose}
131
+ onClick={(e) => {
132
+ // R3-872: the policy first (it preventDefaults plain clicks and
133
+ // no-ops modified ones), then the overlay's own close — the
134
+ // same order the bare onClick={onClose} had with no policy.
135
+ followLinkOnClick(follow, { key: h.key, href: keyToHref(h.key) })(e);
136
+ onClose();
137
+ }}
129
138
  >
130
139
  <Icon name="file" />
131
140
  <span className="t">
@@ -1,9 +1,10 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { useContext, useMemo, useState } from 'react';
1
+ import { useMemo, useState } from 'react';
3
2
  import { Link, useMetadataQuery } from '@immediately-run/sdk';
4
3
  import type { Metadata } from '@immediately-run/sdk';
5
- import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
6
- import { contentDir, keyToHref, sandboxPathToKey } from '../lib/content';
4
+ import { contentDir, keyToHref } from '../lib/content';
5
+ import { useEntryKey } from '../hooks/useEntryKey';
6
+ import { useFollowLink } from '../hooks/useFollowLink';
7
+ import { followLinkOnClick } from '../lib/navigationPolicy';
7
8
  import { sidebarQuery } from '../lib/queries';
8
9
  import { plainLabel } from '../lib/queries';
9
10
  import InlineProse from './InlineProse';
@@ -77,6 +78,7 @@ function Branch({
77
78
  const isFolder = childKeys.length > 0;
78
79
  const containsCurrent = !!currentKey && (node.key === currentKey || hasDescendant(node, currentKey));
79
80
  const [open, setOpen] = useState(depth < 1 || containsCurrent);
81
+ const follow = useFollowLink();
80
82
 
81
83
  if (!isFolder) {
82
84
  return (
@@ -89,6 +91,7 @@ function Branch({
89
91
  aria-level={depth + 1}
90
92
  aria-current={node.key === currentKey ? 'page' : undefined}
91
93
  tabIndex={node.key === stopRowId ? 0 : -1}
94
+ onClick={followLinkOnClick(follow, { key: node.key!, href: keyToHref(node.key!), from: currentKey })}
92
95
  >
93
96
  <Icon name="file" />
94
97
  <InlineProse text={node.title || node.name} trimPeriod />
@@ -141,8 +144,8 @@ function Branch({
141
144
  // `.grove-sidebar` — the wiki shell's left rail: a namespace tree + author-defined
142
145
  // `ui/sidebar` sections (a header + a link into the tagged entry).
143
146
  export default function Sidebar() {
144
- const ctx = useContext(TinkerableContext) as any;
145
- const currentKey = sandboxPathToKey(ctx?.navigationState?.sandboxPath || '/');
147
+ const currentKey = useEntryKey();
148
+ const follow = useFollowLink();
146
149
 
147
150
  // Records, not tab-encoded paths (R3-276a).
148
151
  const q = useMetadataQuery<Metadata, SidebarRecord>(sidebarQuery);
@@ -209,7 +212,12 @@ export default function Sidebar() {
209
212
  <div className="gs-block" key={s.key}>
210
213
  <div className="gs-block__h">{s.label}</div>
211
214
  <div className="gs-section__body">
212
- <Link href={keyToHref(s.key)} className="grove-wikilink" data-state="ok">
215
+ <Link
216
+ href={keyToHref(s.key)}
217
+ className="grove-wikilink"
218
+ data-state="ok"
219
+ onClick={followLinkOnClick(follow, { key: s.key, href: keyToHref(s.key), from: currentKey })}
220
+ >
213
221
  Open
214
222
  </Link>
215
223
  </div>
@@ -42,7 +42,7 @@ const MANUAL_SCROLL_GRACE_MS = 2000;
42
42
  */
43
43
  export default function TableOfContents({ entryKey, title = 'On this page', className }: Props) {
44
44
  const heads = useHeadings(entryKey);
45
- const cur = useActiveHeading(heads);
45
+ const cur = useActiveHeading(heads, entryKey);
46
46
  const listRef = useRef<HTMLElement | null>(null);
47
47
  const itemsRef = useRef(new Map<string, HTMLAnchorElement>());
48
48
  const manualUntilRef = useRef(0);
@@ -4,15 +4,20 @@ import { Link, useFileMetadata, useMetadataQuery } from '@immediately-run/sdk';
4
4
  import { isContentEntry, keyToHref } from '../lib/content';
5
5
  import InlineProse from './InlineProse';
6
6
  import { queryPaths } from '../lib/wiki';
7
+ import { useEntryKey } from '../hooks/useEntryKey';
8
+ import { useFollowLink } from '../hooks/useFollowLink';
9
+ import { followLinkOnClick } from '../lib/navigationPolicy';
7
10
  import EntryImage from './EntryImage';
8
11
 
9
12
  // One dated entry on the axis: mono date · node · card.
10
13
  function Row({ path }: { path: string }) {
11
14
  const m = useFileMetadata(path) as any;
15
+ const from = useEntryKey();
16
+ const follow = useFollowLink();
12
17
  if (!m) return null;
13
18
  const tags: string[] = Array.isArray(m.tags) ? m.tags.filter((t: string) => !t.startsWith('ui/')) : [];
14
19
  return (
15
- <Link href={keyToHref(path)} className="gtl-row">
20
+ <Link href={keyToHref(path)} className="gtl-row" onClick={followLinkOnClick(follow, { key: path, href: keyToHref(path), from })}>
16
21
  <div className="gtl-date">{m.date}</div>
17
22
  <div className="gtl-node" />
18
23
  <div className="gtl-card">
@@ -0,0 +1,83 @@
1
+ // @vitest-environment jsdom
2
+ // R3-871 — WikiLink resolves against the entry it renders inside, not the URL.
3
+ // The URL routes to content/home.mdx while an EntryContext provider says this
4
+ // subtree renders content/teams/engineering.mdx: a relative link must resolve
5
+ // from engineering's directory, and "self" must be judged against engineering.
6
+ // Keys come from the real key grammar (sandboxPathToKey/hrefKeyCandidates), the
7
+ // same fixtures shape the other component tests use.
8
+ import { describe, it, expect, beforeEach } from 'vitest';
9
+ import { act } from 'react';
10
+ import { createRoot } from 'react-dom/client';
11
+ import type { ReactNode } from 'react';
12
+ import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
13
+ import { resetContentRoot, setContentRoot } from '../lib/contentRoot';
14
+
15
+ const { default: WikiLink } = await import('./WikiLink');
16
+ const { EntryContext } = await import('../hooks/useEntryKey');
17
+
18
+ const METADATA = {
19
+ '/app/content/home.mdx': { title: 'Home' },
20
+ '/app/content/teams/engineering.mdx': { title: 'Engineering' },
21
+ '/app/content/people/ada-lovelace.mdx': { title: 'Ada Lovelace' },
22
+ };
23
+
24
+ /** The URL routes HOME; the provider (when given) says which entry we render. */
25
+ function host(children: ReactNode, entryKey?: string, sandboxPath = '/files/content/home.mdx') {
26
+ const value = {
27
+ outerHref: 'https://immediately.run/present/github/o/r/main/files/content/home.mdx',
28
+ navigationState: { mode: 'present', namespace: 'github', provider: 'github', repository: 'o/r', ref: 'main', sandboxPath, hash: '', search: '' },
29
+ routingSpec: { routes: [] },
30
+ filesMetadata: METADATA,
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('WikiLink — the entry context decides the base (R3-871)', () => {
46
+ beforeEach(() => {
47
+ setContentRoot('/app/content/');
48
+ return () => resetContentRoot();
49
+ });
50
+
51
+ it('a relative link inside a non-routed entry resolves against that entry', async () => {
52
+ // the URL routes home; the subtree renders engineering (teams/engineering.mdx).
53
+ const el = await render(
54
+ host(
55
+ <WikiLink href="../people/ada-lovelace.mdx">Ada</WikiLink>,
56
+ '/app/content/teams/engineering.mdx',
57
+ ),
58
+ );
59
+ const link = el.querySelector('.grove-wikilink') as HTMLElement;
60
+ expect(link).not.toBeNull();
61
+ expect(link.getAttribute('data-state')).toBe('ok');
62
+ expect(link.getAttribute('href')).toContain('people/ada-lovelace.mdx');
63
+ });
64
+
65
+ it('the same relative link with no provider resolves against the routed entry (home)', async () => {
66
+ // From home/, ../people/ada-lovelace.mdx lands outside the corpus → broken.
67
+ // This is the default-preserving seam: same input, different base, by design.
68
+ const el = await render(host(<WikiLink href="../people/ada-lovelace.mdx">Ada</WikiLink>));
69
+ const link = el.querySelector('.grove-wikilink') as HTMLElement;
70
+ expect(link.getAttribute('data-state')).toBe('broken');
71
+ });
72
+
73
+ it('a link to the context entry\'s own entry is self — judged against the entry, not the URL', async () => {
74
+ const el = await render(
75
+ host(
76
+ <WikiLink href="engineering.mdx">this very page</WikiLink>,
77
+ '/app/content/teams/engineering.mdx',
78
+ ),
79
+ );
80
+ const link = el.querySelector('.grove-wikilink') as HTMLElement;
81
+ expect(link.getAttribute('data-state')).toBe('self');
82
+ });
83
+ });
@@ -1,10 +1,12 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { useCallback, useContext } from 'react';
2
+ import { useCallback } from 'react';
3
3
  import { Link, useMetadataQuery } from '@immediately-run/sdk';
4
- import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
5
- import { hrefKeyCandidates, hrefTargetKey, isContentEntry, keyToHref, linkKind, sandboxPathToKey, splitFragment } from '../lib/content';
4
+ import { hrefKeyCandidates, hrefTargetKey, isContentEntry, keyToHref, linkKind, splitFragment } from '../lib/content';
6
5
  import { isFolderKey } from '../lib/directory';
7
6
  import { queryPaths } from '../lib/wiki';
7
+ import { useEntryKey } from '../hooks/useEntryKey';
8
+ import { useFollowLink } from '../hooks/useFollowLink';
9
+ import { followLinkOnClick } from '../lib/navigationPolicy';
8
10
  import Icon from './Icon';
9
11
 
10
12
  interface Props {
@@ -19,8 +21,10 @@ interface Props {
19
21
  // glance. Those are the ONLY two shapes: an in-app href that resolves to nothing renders
20
22
  // broken, never as a bare `<a>` (see `linkKind` — R3-252).
21
23
  export default function WikiLink({ href = '', children, ...rest }: Props) {
22
- const ctx = useContext(TinkerableContext) as any;
23
- const currentKey = sandboxPathToKey(ctx?.navigationState?.sandboxPath || '/');
24
+ // R3-871: the entry this link renders inside, not whatever the URL says.
25
+ const currentKey = useEntryKey();
26
+ // R3-872: plain clicks ride the navigation policy; the href stays real.
27
+ const follow = useFollowLink();
24
28
 
25
29
  // Resolve existence against the whole in-memory index (so a missing target is
26
30
  // *definitively* broken, not a load-time flash).
@@ -45,7 +49,12 @@ export default function WikiLink({ href = '', children, ...rest }: Props) {
45
49
  const targetKey = candidates.find((k) => keys.includes(k)) ?? candidates[0] ?? null;
46
50
  // Route on the RESOLVED key, not the author's text: handing `<Link>` a relative href
47
51
  // would make it resolve against the outer page URL rather than the content tree.
48
- const [, fragment] = splitFragment(href);
52
+ const [, fragmentRaw] = splitFragment(href);
53
+ // `fragmentRaw` keeps the '#' for the hrefs (they concatenate it); the policy
54
+ // target carries the bare id (the FollowLinkTarget shape and
55
+ // resolveFragmentTarget's convention agree — never '#' + id).
56
+ const fragment = fragmentRaw || '';
57
+ const policyFragment = fragmentRaw ? fragmentRaw.replace(/^#/, '') : undefined;
49
58
  const resolvedHref = targetKey ? keyToHref(targetKey) + fragment : href;
50
59
 
51
60
  // Only an href that MEANS to leave the document becomes a real `<a>` — see `linkKind`.
@@ -60,7 +69,13 @@ export default function WikiLink({ href = '', children, ...rest }: Props) {
60
69
  const dirKey = kind === 'content' ? hrefTargetKey(href, currentKey) : null;
61
70
  if (dirKey && loaded && isFolderKey(dirKey, keys)) {
62
71
  return (
63
- <Link href={keyToHref(dirKey) + fragment} className="grove-wikilink" data-state="ok" {...rest}>
72
+ <Link
73
+ href={keyToHref(dirKey) + fragment}
74
+ className="grove-wikilink"
75
+ data-state="ok"
76
+ onClick={followLinkOnClick(follow, { key: dirKey, fragment: policyFragment, href: keyToHref(dirKey) + fragment, from: currentKey })}
77
+ {...rest}
78
+ >
64
79
  {children}
65
80
  </Link>
66
81
  );
@@ -105,7 +120,13 @@ export default function WikiLink({ href = '', children, ...rest }: Props) {
105
120
  }
106
121
 
107
122
  return (
108
- <Link href={resolvedHref} className="grove-wikilink" data-state="ok" {...rest}>
123
+ <Link
124
+ href={resolvedHref}
125
+ className="grove-wikilink"
126
+ data-state="ok"
127
+ onClick={followLinkOnClick(follow, { key: targetKey, fragment: policyFragment, href: resolvedHref, from: currentKey })}
128
+ {...rest}
129
+ >
109
130
  {children}
110
131
  </Link>
111
132
  );
@@ -0,0 +1,261 @@
1
+ // @vitest-environment jsdom
2
+ // G-CUST-3 (APP_CUSTOMIZATION_SPEC §4.3, R3-872) — the sweep: EVERY component under
3
+ // src/components/ that renders the SDK `<Link>` (or calls `navigate(`) is
4
+ // enumerated FROM DISK (never a hand-list — a new component that skips the policy
5
+ // fails this suite on arrival), rendered with a recording policy over a real
6
+ // corpus entry, and plain-clicked: the policy must receive the resolved target
7
+ // `{ key, href, fragment?, from? }` and the browser default must be prevented.
8
+ import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
9
+ import { act } from 'react';
10
+ import { createRoot, type Root } from 'react-dom/client';
11
+ import type { ReactNode } from 'react';
12
+ // The enumeration reads the components directory through a DIRECT require of
13
+ // node:fs (createRequire), not the test graph's `fs` — the file mocks `fs` for
14
+ // the components under test, and the enumeration must not eat its own mock.
15
+ import { createRequire } from 'node:module';
16
+ import { join } from 'node:path';
17
+ const { readFileSync, readdirSync } = createRequire(import.meta.url)('node:fs') as typeof import('node:fs');
18
+ import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
19
+ import { GroveShellContext, type GroveShell } from '../lib/shell';
20
+ import { NavigationPolicyContext, type FollowLinkTarget } from '../lib/navigationPolicy';
21
+ import { keyToHref } from '../lib/content';
22
+ import { setContentRoot } from '../lib/contentRoot';
23
+
24
+ beforeAll(() => {
25
+ Object.defineProperty(window, 'matchMedia', {
26
+ writable: true,
27
+ value: (q: string) => ({ matches: false, media: q, addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {}, dispatchEvent: () => false, onchange: null }),
28
+ });
29
+ setContentRoot('/app/content/');
30
+ });
31
+
32
+ // ── The corpus fixture (a real two-entry shape) ──────────────────────────────
33
+ const ENTRY = '/app/content/wiki/a.mdx';
34
+ const OTHER = '/app/content/wiki/b.mdx';
35
+ const PERSON = '/app/content/people/ada.mdx';
36
+ const INDEX = '/app/content/wiki/index.mdx';
37
+ const WIKI_C = '/app/content/wiki/c.mdx';
38
+ const META: Record<string, Record<string, unknown>> = {
39
+ [ENTRY]: { title: 'Reference entry.', render: 'safe', date: '2026-06-22' },
40
+ [INDEX]: { title: 'The wiki index.', render: 'safe' },
41
+ [WIKI_C]: { title: 'A third entry.', render: 'safe' },
42
+ [OTHER]: { title: 'The other entry.', render: 'safe', date: '2026-06-22' },
43
+ [PERSON]: { name: 'Ada', role: 'eng', team: 'core', phone: '555-1', email: 'a@b.c' },
44
+ '/app/content/home.mdx': { title: 'Home', render: 'safe' },
45
+ };
46
+
47
+ const { readFile, readdirFs } = vi.hoisted(() => ({ readFile: vi.fn(), readdirFs: vi.fn() }));
48
+ vi.mock('fs', async (importOriginal) => {
49
+ const actual = (await importOriginal()) as Record<string, unknown>;
50
+ return {
51
+ ...actual,
52
+ default: {
53
+ ...(actual.default as Record<string, unknown>),
54
+ promises: {
55
+ ...((actual.default as { promises: Record<string, unknown> }).promises),
56
+ readFile: (...a: unknown[]) => readFile(...(a as [])),
57
+ readdir: (...a: unknown[]) => readdirFs(...(a as [])),
58
+ },
59
+ },
60
+ };
61
+ });
62
+ (globalThis as { __sandpackSharedFs?: unknown }).__sandpackSharedFs = {
63
+ promises: { readFile: (...a: unknown[]) => readFile(...(a as [])) },
64
+ };
65
+
66
+ const NAV = {
67
+ mode: 'github',
68
+ namespace: 'immediately-run',
69
+ provider: 'github',
70
+ repository: 'corpus',
71
+ ref: 'main',
72
+ sandboxPath: ENTRY,
73
+ hash: '',
74
+ search: '',
75
+ };
76
+
77
+ /** The shell slice the chrome components read (DirectoryView/PageView read the page
78
+ * states; GroveNav/Footer read nav; every entry link's `from` reads the route key). */
79
+ const shell: GroveShell = {
80
+ siteTitle: 'Fixture',
81
+ navItems: [{ key: OTHER, href: keyToHref(OTHER), label: 'The other entry.' }],
82
+ entryKey: ENTRY,
83
+ vw: 'desktop',
84
+ navMode: 'top',
85
+ writable: false,
86
+ openEditor: () => {},
87
+ editBusy: false,
88
+ editRefused: false,
89
+ editHint: '',
90
+ directory: { status: 'none' },
91
+ missing: true,
92
+ suggestion: OTHER,
93
+ mins: 0,
94
+ safe: true,
95
+ includePath: ENTRY,
96
+ layout: 'doc',
97
+ showRails: false,
98
+ } as unknown as GroveShell;
99
+
100
+ // ── The disk enumeration (G-CUST-3's "never a hand-list") ────────────────────
101
+ const COMPONENTS_DIR = join(__dirname);
102
+ const linkComponents = readdirSync(COMPONENTS_DIR)
103
+ .filter((f) => f.endsWith('.tsx') && !f.includes('.test.'))
104
+ .filter((f) => /<Link\b|navigate\(/.test(readFileSync(join(COMPONENTS_DIR, f), 'utf8')))
105
+ .map((f) => f.replace(/\.tsx$/, ''));
106
+
107
+ // Per-component minimal render context (props, and a sandboxPath when the
108
+ // component's content depends on which entry it renders inside).
109
+ const FIXTURES: Record<string, { props?: Record<string, unknown>; sandboxPath?: string }> = {
110
+ WikiLink: { props: { href: '/wiki/b.mdx#sec-4', children: 'the other' } },
111
+ Search: { props: { onClose: () => {} } },
112
+ Drawer: { props: { siteTitle: 'Fixture', nav: [{ key: OTHER, href: keyToHref(OTHER), label: 'Other', cur: false }], onClose: () => {} } },
113
+ // ChildPages lists the current entry's INDEX siblings — the current entry must
114
+ // be the namespace's index for the listing to have rows.
115
+ ChildPages: { sandboxPath: '/app/content/wiki/index.mdx' },
116
+ // DirectoryList reads the current entry's folder — the readdir mock answers it
117
+ // (see beforeEach), and the rows link to entries the index knows.
118
+ DirectoryList: { sandboxPath: '/app/content/wiki/index.mdx' },
119
+ };
120
+
121
+ // ── Harness ──────────────────────────────────────────────────────────────────
122
+
123
+ function Harness({ children, record, sandboxPath }: { children: ReactNode; record: (t: FollowLinkTarget) => void; sandboxPath?: string }) {
124
+ return (
125
+ <TinkerableContext.Provider
126
+ value={{
127
+ outerHref: 'https://immediately.run/x',
128
+ navigationState: { ...NAV, sandboxPath: sandboxPath ?? NAV.sandboxPath },
129
+ routingSpec: { routes: [] } as never,
130
+ filesMetadata: META,
131
+ } as never}
132
+ >
133
+ <GroveShellContext.Provider value={shell}>
134
+ <NavigationPolicyContext.Provider value={record}>{children}</NavigationPolicyContext.Provider>
135
+ </GroveShellContext.Provider>
136
+ </TinkerableContext.Provider>
137
+ );
138
+ }
139
+
140
+ /** Render once and read the anchor hrefs on screen (each href re-rendered + clicked
141
+ * on its own mount — overlay components unmount after their first click). */
142
+ async function renderAndClickAll(
143
+ name: string,
144
+ build: () => ReactNode,
145
+ ): Promise<{ perHref: Array<{ href: string; calls: FollowLinkTarget[]; prevented: boolean }> }> {
146
+ // first pass: which hrefs does it render?
147
+ const probe = document.createElement('div');
148
+ document.body.appendChild(probe);
149
+ const probeRoot: Root = createRoot(probe);
150
+ await act(async () => {
151
+ probeRoot.render(<Harness record={() => {}} sandboxPath={FIXTURES[name]?.sandboxPath}>{build()}</Harness>);
152
+ });
153
+ for (let i = 0; i < 8; i++) await act(async () => {});
154
+ // Every anchor OCCURRENCE, not every distinct href (round 2 nit): a second
155
+ // same-href anchor reverted to direct navigation must fail too.
156
+ const hrefs = [...probe.querySelectorAll('a[href]')].map((a) => a.getAttribute('href')!);
157
+ expect(hrefs.length, `${name} renders at least one link`).toBeGreaterThan(0);
158
+ await act(async () => {
159
+ probeRoot.unmount();
160
+ });
161
+ probe.remove();
162
+
163
+ const perHref: Array<{ href: string; calls: FollowLinkTarget[]; prevented: boolean }> = [];
164
+ for (let occurrence = 0; occurrence < hrefs.length; occurrence++) {
165
+ const href = hrefs[occurrence];
166
+ const ordinal = hrefs.slice(0, occurrence).filter((h) => h === href).length;
167
+ const calls: FollowLinkTarget[] = [];
168
+ const container = document.createElement('div');
169
+ document.body.appendChild(container);
170
+ const root: Root = createRoot(container);
171
+ await act(async () => {
172
+ root.render(
173
+ <Harness
174
+ record={(t) => {
175
+ calls.push(t);
176
+ }}
177
+ sandboxPath={FIXTURES[name]?.sandboxPath}
178
+ >
179
+ {build()}
180
+ </Harness>,
181
+ );
182
+ });
183
+ for (let i = 0; i < 8; i++) await act(async () => {});
184
+ const anchor = container.querySelectorAll(`a[href="${href.replace(/"/g, '\\"')}"]`)[ordinal];
185
+ expect(anchor, `${name}: the ${href} link still renders`).toBeTruthy();
186
+ let prevented = false;
187
+ const event = new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 });
188
+ const origPrevent = event.preventDefault.bind(event);
189
+ event.preventDefault = () => {
190
+ prevented = true;
191
+ origPrevent();
192
+ };
193
+ await act(async () => {
194
+ anchor!.dispatchEvent(event);
195
+ });
196
+ await act(async () => {
197
+ root.unmount();
198
+ });
199
+ container.remove();
200
+ perHref.push({ href, calls, prevented });
201
+ }
202
+ return { perHref };
203
+ }
204
+
205
+ beforeEach(() => {
206
+ readFile.mockReset();
207
+ readFile.mockImplementation(async (p: unknown) =>
208
+ // Backlinks scans bodies for links TO the rendered entry: b's body links a.
209
+ String(p).endsWith('/b.mdx')
210
+ ? '---\ntitle: B\nrender: safe\n---\n\n[the first](/wiki/a.mdx)\n'
211
+ : '---\ntitle: T\nrender: safe\n---\n\nplain body\n',
212
+ );
213
+ readdirFs.mockReset();
214
+ const dirent = (name: string, isDir = false) => ({ name, isDirectory: () => isDir });
215
+ readdirFs.mockImplementation(async () => [dirent('c.mdx'), dirent('assets', true)]);
216
+ void readdirFs;
217
+ });
218
+
219
+ describe('G-CUST-3 — every entry link rides the navigation policy', () => {
220
+ it('the enumeration covers the known link components (non-vacuity)', () => {
221
+ for (const name of ['WikiLink', 'Backlinks', 'ChildPages', 'Directory', 'DirectoryList', 'DirectoryView', 'DocList', 'Drawer', 'GroveFooter', 'GroveNav', 'PageView', 'Search', 'Sidebar', 'Timeline']) {
222
+ expect(linkComponents, `${name} is enumerated`).toContain(name);
223
+ }
224
+ });
225
+
226
+ it.each(linkComponents)('%s: a plain click reaches the policy with a resolved target', async (name) => {
227
+ const mod = await import(`./${name}`);
228
+ const Component = mod.default;
229
+ const props = FIXTURES[name]?.props ?? {};
230
+ // EVERY anchor the component renders, each clicked on its own mount (an
231
+ // overlay's first click closes it) — the item's do-not carve-out is a second
232
+ // link navigating directly 'because it's chrome', and only probing the first
233
+ // anchor would miss exactly that.
234
+ const { perHref } = await renderAndClickAll(name, () => <Component {...props} />);
235
+ for (const { href, calls, prevented } of perHref) {
236
+ expect(calls.length, `${name}: the plain click on ${href} reaches the policy`).toBeGreaterThan(0);
237
+ const t = calls[0];
238
+ expect(typeof t.href, 'the target carries the concrete href').toBe('string');
239
+ expect(t.href.length).toBeGreaterThan(0);
240
+ // the DOM anchor carries the SDK's outer-URL resolution of the href the
241
+ // component passed; the policy receives the component-level one
242
+ expect(href === t.href || href.endsWith(t.href), `${name}: the DOM href ${href} resolves the policy's ${t.href}`).toBe(true);
243
+ expect(typeof t.key, 'the target carries the resolved key').toBe('string');
244
+ expect(t.key.length).toBeGreaterThan(0);
245
+ // §4.3's shape: fragment is absent or a bare id, never '#'-prefixed
246
+ if (t.fragment !== undefined) expect(t.fragment.startsWith('#'), `fragment carries no '#' (${t.fragment})`).toBe(false);
247
+ // `from` is the entry key when the link renders inside one (content
248
+ // components), absent for the chrome (nav/footer/drawer/sidebar render
249
+ // outside an entry) — the shape pinned, not the value guessed.
250
+ if (t.from !== undefined) expect(typeof t.from).toBe('string');
251
+ // the WikiLink fixture is fragment-bearing: the rendered href KEEPS the
252
+ // '#sec-4' (round 2's regression — the '#' strip corrupted hrefs), and the
253
+ // policy's fragment is the bare id
254
+ if (name === 'WikiLink') {
255
+ expect(href).toContain('#sec-4');
256
+ expect(t.fragment).toBe('sec-4');
257
+ }
258
+ expect(prevented, `${name}: the plain click on ${href} prevents the browser default`).toBe(true);
259
+ }
260
+ });
261
+ });