@immediately-run/grove 0.1.10 → 0.2.2

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 (53) hide show
  1. package/llms.txt +13 -1
  2. package/package.json +3 -3
  3. package/src/GroveApp.css +11 -0
  4. package/src/GroveWiki.tsx +19 -60
  5. package/src/components/AssetImage.test.tsx +64 -0
  6. package/src/components/AssetImage.tsx +10 -10
  7. package/src/components/Backlinks.tsx +13 -6
  8. package/src/components/ChildPages.tsx +8 -6
  9. package/src/components/Directory.tsx +6 -1
  10. package/src/components/DirectoryList.tsx +16 -8
  11. package/src/components/DirectoryView.tsx +5 -2
  12. package/src/components/DocList.tsx +9 -2
  13. package/src/components/Drawer.tsx +14 -1
  14. package/src/components/EntryBody.tsx +138 -0
  15. package/src/components/EntryHeader.tsx +16 -3
  16. package/src/components/FamilyTree.tsx +3 -5
  17. package/src/components/GroveAgent.tsx +1 -1
  18. package/src/components/GroveEntry.test.tsx +200 -0
  19. package/src/components/GroveEntry.tsx +108 -0
  20. package/src/components/GroveFooter.tsx +5 -2
  21. package/src/components/GroveNav.tsx +11 -2
  22. package/src/components/PageMeta.tsx +3 -5
  23. package/src/components/PageView.tsx +13 -63
  24. package/src/components/Search.tsx +10 -1
  25. package/src/components/Sidebar.tsx +15 -7
  26. package/src/components/TableOfContents.tsx +1 -1
  27. package/src/components/TagCloud.test.tsx +103 -0
  28. package/src/components/TagCloud.tsx +47 -29
  29. package/src/components/Timeline.tsx +6 -1
  30. package/src/components/WikiLink.test.tsx +83 -0
  31. package/src/components/WikiLink.tsx +29 -8
  32. package/src/components/navigationPolicy.sweep.test.tsx +261 -0
  33. package/src/hooks/useEditAffordance.test.tsx +110 -0
  34. package/src/hooks/useEditAffordance.ts +51 -14
  35. package/src/hooks/useEntryKey.ts +21 -0
  36. package/src/hooks/useFollowLink.ts +11 -0
  37. package/src/hooks/useHeadings.test.tsx +93 -0
  38. package/src/hooks/useHeadings.ts +73 -12
  39. package/src/lib/assetPath.ts +4 -3
  40. package/src/lib/content.ts +0 -5
  41. package/src/lib/editTarget.test.ts +78 -18
  42. package/src/lib/editTarget.ts +39 -10
  43. package/src/lib/entryContext.test.ts +46 -0
  44. package/src/lib/entryContext.ts +35 -0
  45. package/src/lib/fragment.test.ts +21 -0
  46. package/src/lib/fragment.ts +9 -6
  47. package/src/lib/navigationPolicy.test.ts +65 -0
  48. package/src/lib/navigationPolicy.ts +57 -0
  49. package/src/lib/renderMode.ts +17 -0
  50. package/src/lib/shell.ts +4 -0
  51. package/src/lib/tagCloud.test.ts +115 -0
  52. package/src/lib/tagCloud.ts +58 -0
  53. 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);
@@ -0,0 +1,103 @@
1
+ // @vitest-environment jsdom
2
+ // TagCloud's render contract: chips carry a 0..1 `--tag-weight` and NO inline
3
+ // font-size (the pixel range is GroveApp.css's), the wrapper carries the
4
+ // --weighted modifier, and `limit` keeps the N most-used tags (a bad limit is
5
+ // ignored with one warning). Metadata is supplied through TinkerableContext, the
6
+ // way Sidebar.test.tsx supplies its map. The rendered pixel size itself is
7
+ // jsdom-untestable (no calc()/custom-property resolution) — that leg is R3-946.
8
+ import { describe, expect, it, vi, afterEach } from 'vitest';
9
+ import { act } from 'react';
10
+ import { createRoot, type Root } from 'react-dom/client';
11
+ import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
12
+
13
+ const { default: TagCloud } = await import('./TagCloud');
14
+
15
+ // One tag on 191 entries (the real docs corpus's `bug` scale) and one on a
16
+ // single entry.
17
+ const FILES = {
18
+ ...Object.fromEntries(
19
+ Array.from({ length: 191 }, (_, i) => [`/app/content/e${i}.mdx`, { title: `E${i}`, tags: ['bug'] }]),
20
+ ),
21
+ '/app/content/single.mdx': { title: 'Single.', tags: ['doc'] },
22
+ };
23
+
24
+ const NAV = {
25
+ outerHref: 'https://example.immediately.run/app/x',
26
+ navigationState: {
27
+ sandboxPath: '/app/x',
28
+ provider: 'github',
29
+ namespace: 'immediately-run',
30
+ repository: 'docs',
31
+ ref: 'main',
32
+ hash: '',
33
+ search: '',
34
+ },
35
+ routingSpec: {} as never,
36
+ filesMetadata: FILES,
37
+ };
38
+
39
+ let mounted: { root: Root; container: HTMLElement } | null = null;
40
+
41
+ async function mountCloud(limit?: number): Promise<HTMLElement> {
42
+ const container = document.createElement('div');
43
+ document.body.appendChild(container);
44
+ const root = createRoot(container);
45
+ await act(async () => {
46
+ root.render(
47
+ <TinkerableContext.Provider value={NAV as never}>
48
+ <TagCloud limit={limit} />
49
+ </TinkerableContext.Provider>,
50
+ );
51
+ });
52
+ mounted = { root, container };
53
+ return container;
54
+ }
55
+
56
+ afterEach(() => {
57
+ if (mounted) {
58
+ act(() => mounted!.root.unmount());
59
+ mounted!.container.remove();
60
+ mounted = null;
61
+ }
62
+ });
63
+
64
+ const chips = (container: HTMLElement): HTMLElement[] =>
65
+ [...container.querySelectorAll('.grove-tag')] as HTMLElement[];
66
+
67
+ describe('TagCloud', () => {
68
+ it('weights every chip between 0 and 1 with no inline font-size, on a --weighted wrapper', async () => {
69
+ const container = await mountCloud();
70
+ expect(container.querySelector('.grove-tagcloud')!.classList.contains('grove-tagcloud--weighted')).toBe(true);
71
+ const all = chips(container);
72
+ expect(all.length).toBe(2);
73
+ const weights = all.map((chip) => Number.parseFloat(chip.style.getPropertyValue('--tag-weight')));
74
+ for (const w of weights) {
75
+ expect(Number.isFinite(w)).toBe(true);
76
+ expect(w).toBeGreaterThanOrEqual(0);
77
+ expect(w).toBeLessThanOrEqual(1);
78
+ }
79
+ // Chips read alphabetically: `bug` (191 entries, the maximum) then `doc`
80
+ // (one entry, the minimum).
81
+ expect(weights).toEqual([1, 0]);
82
+ for (const chip of all) expect(chip.style.fontSize).toBe('');
83
+ });
84
+
85
+ it('limit={1} renders only the most-used tag, still scaled against the corpus', async () => {
86
+ const container = await mountCloud(1);
87
+ const all = chips(container);
88
+ expect(all.length).toBe(1);
89
+ expect(all[0]!.textContent).toContain('bug');
90
+ // Corpus-relative (not subset-relative): the corpus maximum keeps weight 1
91
+ // under the limit — subset scaling would give it min === max → 0.
92
+ expect(all[0]!.style.getPropertyValue('--tag-weight')).toBe('1');
93
+ });
94
+
95
+ it('limit={0} is treated as absent and warns once', async () => {
96
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
97
+ const container = await mountCloud(0);
98
+ expect(chips(container).length).toBe(2);
99
+ expect(warn.mock.calls.length).toBe(1);
100
+ expect(String(warn.mock.calls[0]![0])).toContain('0');
101
+ warn.mockRestore();
102
+ });
103
+ });
@@ -1,46 +1,64 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { useCallback } from 'react';
2
+ import { useCallback, useEffect } from 'react';
3
+ import type { CSSProperties } from 'react';
3
4
  import { useMetadataQuery } from '@immediately-run/sdk';
4
5
  import { contentDir } from '../lib/content';
5
6
  import { queryPaths } from '../lib/wiki';
7
+ import { countTags, tagWeight, topTags, type TagCount } from '../lib/tagCloud';
6
8
 
7
9
  // Import-free engine component: every tag across the site, sized by frequency.
8
10
  // Chrome tags (`ui/*`) are excluded — they drive layout, not classification.
9
- export default function TagCloud() {
11
+ //
12
+ // Sizing: each chip carries its weight as `--tag-weight` and the CSS owns the
13
+ // pixels (11.5–22px) — the old `11 + count * 2 + 'px'` inline style had no
14
+ // ceiling and let a frequent tag fill the viewport. The wrapper carries the
15
+ // `--weighted` modifier so the rule never touches TagList's plain chips, which
16
+ // share the `.grove-tagcloud` wrapper class.
17
+
18
+ // A bad `limit` warns once per distinct value per session — an authored page
19
+ // never goes blank over a prop.
20
+ const warnedLimits = new Set<string>();
21
+
22
+ export default function TagCloud({ limit }: { limit?: number }) {
23
+ const limitOk = limit === undefined || (Number.isInteger(limit) && limit > 0);
24
+ useEffect(() => {
25
+ if (limitOk) return;
26
+ const key = String(limit);
27
+ if (warnedLimits.has(key)) return;
28
+ warnedLimits.add(key);
29
+ console.warn(`TagCloud: limit must be a positive integer, got ${key} — showing every tag`);
30
+ }, [limit, limitOk]);
31
+
10
32
  const queryFn = useCallback((filesMetadata: Record<string, any>) => {
11
- const counts: Record<string, number> = {};
12
- Object.entries(filesMetadata).forEach(([p, m]) => {
13
- if (!p.startsWith(contentDir())) return;
14
- if (m && Array.isArray(m.tags)) {
15
- m.tags.forEach((t: string) => {
16
- if (t.startsWith('ui/')) return;
17
- counts[t] = (counts[t] || 0) + 1;
18
- });
19
- }
20
- });
21
- // Encode counts into the string[] the query contract returns.
22
- return Object.keys(counts)
23
- .sort()
24
- .map((t) => `${t}:${counts[t]}`);
33
+ // The query contract is string-valued, so the pairs ride as `tag:count`
34
+ // (the encoding is unchanged).
35
+ return countTags(filesMetadata, contentDir()).map(({ tag, count }) => `${tag}:${count}`);
25
36
  }, []);
26
37
 
27
38
  const result = useMetadataQuery(queryFn);
28
39
  const entries: string[] = queryPaths(result);
40
+ const pairs: TagCount[] = entries.map((e) => {
41
+ const [tag, count] = e.split(':');
42
+ return { tag: tag!, count: Number(count) };
43
+ });
44
+ const shown = topTags(pairs, limitOk ? limit : undefined);
45
+ // The scale is relative to the CORPUS (every tag), not the shown subset —
46
+ // under a limit the corpus's most-used tag must still read as the largest.
47
+ const counts = pairs.map((p) => p.count);
48
+ const min = Math.min(...counts);
49
+ const max = Math.max(...counts);
29
50
 
30
51
  return (
31
- <div className="grove-tagcloud">
32
- {entries.map((e) => {
33
- const [tag, count] = e.split(':');
34
- return (
35
- <span
36
- key={tag}
37
- className="grove-tag"
38
- style={{ fontSize: 11 + Number(count) * 2 + 'px', padding: '4px 12px' }}
39
- >
40
- #{tag} <span style={{ opacity: 0.5, marginLeft: 4 }}>{count}</span>
41
- </span>
42
- );
43
- })}
52
+ <div className="grove-tagcloud grove-tagcloud--weighted">
53
+ {shown.map(({ tag, count }) => (
54
+ <span
55
+ key={tag}
56
+ className="grove-tag"
57
+ style={{ '--tag-weight': String(tagWeight(count, min, max)) } as CSSProperties}
58
+ >
59
+ #{tag} <span className="grove-tag__count">{count}</span>
60
+ </span>
61
+ ))}
44
62
  </div>
45
63
  );
46
64
  }
@@ -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
  );