@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
@@ -0,0 +1,21 @@
1
+ // useEntryKey — "which entry am I rendered inside?" (R3-871).
2
+ //
3
+ // The one place the URL→key expression survives (R6): every component that
4
+ // needs the current entry reads this hook, so an included fragment or a
5
+ // layout resolves relative links against the entry it renders, not whatever
6
+ // the address bar happens to say. Falls back to the routed key when no
7
+ // provider is present — the default-preserving seam of APP_CUSTOMIZATION §3
8
+ // (R-CUST-2/3). Internal for now; R3-872 exports it with the other seams.
9
+ import { useContext } from 'react';
10
+ import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
11
+ import { entryKeyOr, EntryContext } from '../lib/entryContext';
12
+
13
+ export { EntryContext };
14
+
15
+ /** The metadata key of the entry this subtree renders (the routed key when
16
+ * no provider is present). */
17
+ export function useEntryKey(): string {
18
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
19
+ const ctx = useContext(TinkerableContext) as any;
20
+ return entryKeyOr(useContext(EntryContext), ctx?.navigationState?.sandboxPath || '/');
21
+ }
@@ -0,0 +1,11 @@
1
+ // `useFollowLink` — read the active navigation policy (APP_CUSTOMIZATION §4.3,
2
+ // R3-872). The hook lives in its own file per the repo's one-hook-per-file
3
+ // convention; the context + the stock default live in `lib/navigationPolicy.ts`
4
+ // (no components there — the Fast-Refresh rule).
5
+ import { useContext } from 'react';
6
+ import { NavigationPolicyContext, type FollowLink } from '../lib/navigationPolicy';
7
+
8
+ /** The active navigation policy — the provider's, or the stock `navigate(href)`. */
9
+ export function useFollowLink(): FollowLink {
10
+ return useContext(NavigationPolicyContext);
11
+ }
@@ -0,0 +1,93 @@
1
+ // @vitest-environment jsdom
2
+ // R3-872 (APP_CUSTOMIZATION §4.5) — the scoped branch of useHeadings, the one
3
+ // that shipped broken at the first review (the marker sits INSIDE .grove-prose,
4
+ // so a scoped `.grove-prose` lookup found nothing). These cases plant MARKED
5
+ // bodies — the shape the stock DOM always has — and assert the scoped read.
6
+ import { describe, it, expect, beforeAll } from 'vitest';
7
+ import { act } from 'react';
8
+ import { createRoot } from 'react-dom/client';
9
+ import { useHeadings } from './useHeadings';
10
+
11
+ beforeAll(() => {
12
+ Object.defineProperty(window, 'matchMedia', {
13
+ writable: true,
14
+ value: (q: string) => ({ matches: false, media: q, addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {}, dispatchEvent: () => false, onchange: null }),
15
+ });
16
+ });
17
+
18
+ const A = '/app/content/specs/a.mdx';
19
+ const B = '/app/content/specs/b.mdx';
20
+
21
+ /** Two marked bodies, each with its own headings — the multi-entry page shape
22
+ * (both paths carry the marker since R3-872, and the safe path's marker wraps
23
+ * the body inside .grove-prose, as EntryBody renders it). */
24
+ function plantTwoBodies() {
25
+ // A carries one ID-LESS heading (the kernel assigns none sometimes) — B's scan
26
+ // must never write an id onto it (the side effect is scoped, not just the read).
27
+ document.body.innerHTML = `
28
+ <div class="grove-prose"><div data-entry="${A}"><h2 id="sec-1">A one</h2><h2>A two (no id)</h2></div></div>
29
+ <div class="grove-prose"><div data-entry="${B}"><h2 id="sec-1">B one</h2><h3 id="sec-2-1">B two-one</h3></div></div>
30
+ `;
31
+ }
32
+
33
+ function Probe({ entryKey, seen }: { entryKey: string; seen: (h: { id: string }[]) => void }) {
34
+ const heads = useHeadings(entryKey);
35
+ seen(heads);
36
+ return null;
37
+ }
38
+
39
+ describe('useHeadings — entry-scoped (R3-872)', () => {
40
+ it("reads only the named entry's headings when both bodies are marked", async () => {
41
+ plantTwoBodies();
42
+ let latest: { id: string }[] = [];
43
+ const container = document.createElement('div');
44
+ document.body.appendChild(container);
45
+ const root = createRoot(container);
46
+ await act(async () => {
47
+ root.render(<Probe entryKey={B} seen={(h) => (latest = h)} />);
48
+ });
49
+ expect(latest.map((h) => h.id)).toEqual(['sec-1', 'sec-2-1']);
50
+ // the OTHER entry's id-less heading stays id-less — the scan's id-assignment
51
+ // side effect is scoped, not just the read
52
+ expect(document.querySelector(`[data-entry="${A}"] h2:nth-of-type(2)`)?.id ?? '').toBe('');
53
+ await act(async () => root.unmount());
54
+ container.remove();
55
+ });
56
+
57
+ it('a late-committing marked body is found by the document observer (no empty ToC for life)', async () => {
58
+ document.body.innerHTML = '';
59
+ let latest: { id: string }[] = [];
60
+ const container = document.createElement('div');
61
+ document.body.appendChild(container);
62
+ const root = createRoot(container);
63
+ await act(async () => {
64
+ root.render(<Probe entryKey={B} seen={(h) => (latest = h)} />);
65
+ });
66
+ expect(latest).toEqual([]); // no marker yet — nothing found, not a wrong read
67
+ // the body lands late (the compile window)
68
+ await act(async () => {
69
+ plantTwoBodies();
70
+ });
71
+ // the observer's mutation callback is async — flush it
72
+ await act(async () => {
73
+ await new Promise((r) => setTimeout(r, 20));
74
+ });
75
+ expect(latest.map((h) => h.id)).toEqual(['sec-1', 'sec-2-1']);
76
+ await act(async () => root.unmount());
77
+ container.remove();
78
+ });
79
+
80
+ it('no marked bodies anywhere → the document fallback (the pre-marker world)', async () => {
81
+ document.body.innerHTML = '<div class="grove-prose"><h2 id="sec-1">Only one</h2></div>';
82
+ let latest: { id: string }[] = [];
83
+ const container = document.createElement('div');
84
+ document.body.appendChild(container);
85
+ const root = createRoot(container);
86
+ await act(async () => {
87
+ root.render(<Probe entryKey={B} seen={(h) => (latest = h)} />);
88
+ });
89
+ expect(latest.map((h) => h.id)).toEqual(['sec-1']);
90
+ await act(async () => root.unmount());
91
+ container.remove();
92
+ });
93
+ });
@@ -29,8 +29,15 @@ function headingText(node: Element): string {
29
29
  }
30
30
 
31
31
  /**
32
- * Scan `.grove-prose` for `h2`/`h3`, assigning the canonical id to any heading the kernel
33
- * did not emit one for, and re-scan as the prose mounts or swaps on navigation.
32
+ * Scan the entry's body for `h2`/`h3`, assigning the canonical id to any heading the
33
+ * kernel did not emit one for, and re-scan as the prose mounts or swaps on navigation.
34
+ *
35
+ * R3-872 (APP_CUSTOMIZATION §4.5): the scan is scoped to the ENTRY's marked body
36
+ * (`[data-entry="<entryKey>"]` — emitted on both render paths), never the whole
37
+ * document: on a multi-entry page the first `.grove-prose` is whichever entry mounted
38
+ * first, and its headings are the wrong entry's. Unscoped (no key, or no marked body
39
+ * anywhere) falls back to the document — the pre-marker behavior, for content that
40
+ * never renders through the marked paths.
34
41
  */
35
42
  export function useHeadings(entryKey?: string): Heading[] {
36
43
  const [heads, setHeads] = useState<Heading[]>([]);
@@ -38,9 +45,39 @@ export function useHeadings(entryKey?: string): Heading[] {
38
45
  useEffect(() => {
39
46
  // eslint-disable-next-line react-hooks/set-state-in-effect
40
47
  setHeads([]);
48
+ // The scan root: this entry's marked body when markers exist; the document only
49
+ // when NO body is marked at all (the pre-marker world). A page with markers but
50
+ // none for this key (the body is still compiling) scans nothing — the observer
51
+ // watches the DOCUMENT until this entry's marker lands, then re-scopes (a
52
+ // late-committing entry's ToC must not stay empty for the mount's lifetime).
53
+ // NOTE: the marker sits INSIDE `.grove-prose` (both render paths), so the
54
+ // scoped query runs over the marker's own subtree — searching the marker FOR
55
+ // '.grove-prose' would find nothing (review round 1, G-CUST-2 regression).
56
+ const markedRoot = (): HTMLElement | null =>
57
+ entryKey === undefined
58
+ ? null
59
+ : Array.from(document.querySelectorAll<HTMLElement>('[data-entry]')).find(
60
+ (n) => n.getAttribute('data-entry') === entryKey,
61
+ ) ?? null;
62
+ const anyMarked = () => document.querySelectorAll('[data-entry]').length > 0;
63
+ const root = (): ParentNode | null => {
64
+ const marked = markedRoot();
65
+ if (marked) return marked;
66
+ if (entryKey === undefined) return document;
67
+ return anyMarked() ? null : document;
68
+ };
41
69
  const scan = () => {
42
- const prose = document.querySelector('.grove-prose');
43
- const nodes = prose ? Array.from(prose.querySelectorAll('h2, h3')) : [];
70
+ const r = root();
71
+ // Scoped: the marker's subtree holds the entry's headings. Unscoped (no
72
+ // markers anywhere): the document's `.grove-prose`, as before.
73
+ const nodes = !r
74
+ ? []
75
+ : r === document
76
+ ? (() => {
77
+ const prose = document.querySelector('.grove-prose');
78
+ return prose ? Array.from(prose.querySelectorAll('h2, h3')) : [];
79
+ })()
80
+ : Array.from(r.querySelectorAll('h2, h3'));
44
81
  const found: Heading[] = nodes.map((n) => {
45
82
  const text = headingText(n);
46
83
  // Prefer the kernel-emitted id (§15.5); `headingId` reproduces the same canon
@@ -56,15 +93,29 @@ export function useHeadings(entryKey?: string): Heading[] {
56
93
  prev.length === found.length && prev.every((h, i) => h.id === found[i].id) ? prev : found
57
94
  );
58
95
  };
96
+ // Observe the SCOPED root when it exists; while our marker is absent (the
97
+ // body still compiling), observe the DOCUMENT so the marker's arrival
98
+ // triggers the rescan that re-scopes — then move the observer onto the
99
+ // marker (a whole-document watch would re-scan on every mutation anywhere,
100
+ // the churn the identity guard exists to absorb).
101
+ let observing: ParentNode | null = null;
102
+ const obs = new MutationObserver(() => {
103
+ const wanted = root() ?? document;
104
+ if (wanted !== observing) {
105
+ obs.disconnect();
106
+ obs.observe(wanted, { childList: true, subtree: true });
107
+ observing = wanted;
108
+ }
109
+ scan();
110
+ });
111
+ observing = root() ?? document;
112
+ obs.observe(observing, { childList: true, subtree: true });
59
113
  scan();
60
- const prose = document.querySelector('.grove-prose');
61
- const obs = prose ? new MutationObserver(scan) : null;
62
- if (prose && obs) obs.observe(prose, { childList: true, subtree: true });
63
114
  // `<Include>` resolves asynchronously, and the observer only fires if `.grove-prose`
64
115
  // already existed — these catch the window where it did not.
65
116
  const timers = [120, 300, 600].map((d) => setTimeout(scan, d));
66
117
  return () => {
67
- obs?.disconnect();
118
+ obs.disconnect();
68
119
  timers.forEach(clearTimeout);
69
120
  };
70
121
  }, [entryKey]);
@@ -72,12 +123,21 @@ export function useHeadings(entryKey?: string): Heading[] {
72
123
  return heads;
73
124
  }
74
125
 
75
- /** Which heading the reader is currently in — scroll-spied off the document. */
76
- export function useActiveHeading(heads: Heading[]): string {
126
+ /** Which heading the reader is currently in — scroll-spied. R3-872: scoped to the
127
+ * entry's marked body when a key is given — duplicate section ids across entries on
128
+ * one page are the norm (every spec numbers from 1), and `document.getElementById`
129
+ * would return the FIRST entry's heading, spying on the wrong entry. */
130
+ export function useActiveHeading(heads: Heading[], entryKey?: string): string {
77
131
  const [cur, setCur] = useState<string>('');
78
132
 
79
133
  useEffect(() => {
80
134
  if (!heads.length) return;
135
+ const scope: ParentNode =
136
+ entryKey === undefined
137
+ ? document
138
+ : Array.from(document.querySelectorAll<HTMLElement>('[data-entry]')).find(
139
+ (n) => n.getAttribute('data-entry') === entryKey,
140
+ ) ?? document;
81
141
  const obs = new IntersectionObserver(
82
142
  (entries) => {
83
143
  const visible = entries.filter((e) => e.isIntersecting);
@@ -86,11 +146,12 @@ export function useActiveHeading(heads: Heading[]): string {
86
146
  { rootMargin: '-64px 0px -70% 0px', threshold: 0 }
87
147
  );
88
148
  heads.forEach((h) => {
89
- const el = document.getElementById(h.id);
149
+ // find the element INSIDE the scope (a heading id is only unique per entry)
150
+ const el = scope === document ? document.getElementById(h.id) : scope.querySelector(`#${CSS.escape(h.id)}`);
90
151
  if (el) obs.observe(el);
91
152
  });
92
153
  return () => obs.disconnect();
93
- }, [heads]);
154
+ }, [heads, entryKey]);
94
155
 
95
156
  return cur;
96
157
  }
@@ -2,9 +2,10 @@
2
2
  // `cover:`/`img src`" into the fs path the SDK's `MountImage` reads.
3
3
  //
4
4
  // The distinction this module exists for: a body image resolves against the entry
5
- // CURRENTLY BEING RENDERED (`navigationState.sandboxPath` — `AssetImage`), while a
6
- // cover resolves against its OWNING entry — the entry the card/row/tile is ABOUT,
7
- // which under dispatch is usually a DIFFERENT base. Getting this wrong is not a bug
5
+ // currently being rendered (the entry context's key via useEntryKey — AssetImage;
6
+ // the routed key only as the no-provider fallback), while a
7
+ // cover resolves against its owning entry — the entry the card/row/tile is about,
8
+ // which under dispatch is usually a different base. Getting this wrong is not a bug
8
9
  // in `AssetImage`; it is the second half of design-pass gap 7: a `<DocList>` card or
9
10
  // `<Timeline>` row showing another entry's picture resolved against the wrong base
10
11
  // by construction.
@@ -286,8 +286,3 @@ export function sandboxPathToKey(sandboxPath: string): string {
286
286
  // HOME rather than becoming a key that reads some other file.
287
287
  return p.startsWith(contentDir()) ? p : homeKey();
288
288
  }
289
-
290
- /** A sandboxPath → the absolute fs base for resolving relative assets. */
291
- export function toFsPath(sandboxPath: string): string {
292
- return keyToFsPath(sandboxPathToKey(sandboxPath));
293
- }
@@ -0,0 +1,46 @@
1
+ // @vitest-environment jsdom
2
+ // R3-871 — the entry context's actual behaviour, over the real key grammar.
3
+ // The context key wins when a provider is present; the routed key (real
4
+ // sandboxPathToKey) otherwise; and the component-level case — a relative link
5
+ // inside a non-routed entry resolving against that entry — is driven in
6
+ // WikiLink.test.tsx.
7
+ import { describe, it, expect } from 'vitest';
8
+ import { entryKeyOr } from './entryContext';
9
+ import { sandboxPathToKey } from './content';
10
+
11
+ describe('entryKeyOr — the one fallback rule (R3-871)', () => {
12
+ it('returns the context key when a provider is present', () => {
13
+ expect(entryKeyOr({ entryKey: '/app/content/teams/engineering.mdx' }, '/files/content/home.mdx')).toBe(
14
+ '/app/content/teams/engineering.mdx',
15
+ );
16
+ });
17
+
18
+ it('falls back to the routed key when the context is null (no provider)', () => {
19
+ expect(entryKeyOr(null, '/files/content/handbook/onboarding.mdx')).toBe(
20
+ sandboxPathToKey('/files/content/handbook/onboarding.mdx'),
21
+ );
22
+ });
23
+
24
+ it('treats an absent context the same as null', () => {
25
+ expect(entryKeyOr(undefined, '/files/content/handbook/onboarding.mdx')).toBe(
26
+ entryKeyOr(null, '/files/content/handbook/onboarding.mdx'),
27
+ );
28
+ });
29
+
30
+ it('the fallback is computed by the REAL sandboxPathToKey — the routed cases', () => {
31
+ // the folder URL form and the bare root both route HOME (the exact rule
32
+ // sandboxPathToKey encodes), so the default rendering is unchanged.
33
+ expect(entryKeyOr(null, '/')).toBe(sandboxPathToKey('/'));
34
+ expect(entryKeyOr(null, '')).toBe(sandboxPathToKey('/'));
35
+ // the /files-prefixed form strips to the content key
36
+ expect(entryKeyOr(null, '/files/content/a/b.mdx')).toBe('/app/content/a/b.mdx');
37
+ });
38
+ });
39
+
40
+ describe('entryKeyOr — a provider that says nothing', () => {
41
+ it('an empty-string entryKey is not a key: it falls back to the routed key', () => {
42
+ expect(entryKeyOr({ entryKey: '' }, '/files/content/handbook/onboarding.mdx')).toBe(
43
+ sandboxPathToKey('/files/content/handbook/onboarding.mdx'),
44
+ );
45
+ });
46
+ });
@@ -0,0 +1,35 @@
1
+ // The entry context (APP_CUSTOMIZATION_SPEC §4.2; R3-871).
2
+ //
3
+ // Seven-plus Grove components used to work out "the entry I am in" from the
4
+ // URL (`sandboxPathToKey(ctx?.navigationState?.sandboxPath || '/')`). Inside
5
+ // anything but the routed entry — an included fragment, a layout, and next a
6
+ // story-river card — a relative `[[link]]` or `![](img.png)` resolved against
7
+ // the wrong base, and "self" was judged against the URL. The provider placed
8
+ // by `GroveWiki` (R3-872 moves it into `GroveEntry`) says which entry a
9
+ // subtree renders; this module owns the context shape and the pure fallback
10
+ // rule so both are testable without React.
11
+ import { createContext } from 'react';
12
+ import { sandboxPathToKey } from './content';
13
+
14
+ /** What a provider subtree declares: the metadata key of the entry it renders. */
15
+ export interface EntryContextValue {
16
+ entryKey: string;
17
+ }
18
+
19
+ /** The provider seam itself (§4.2): null — not the routed key — when no
20
+ * provider is above, so the pure fallback rule below decides, exactly once.
21
+ * Lives here, beside the rule it carries, per the item's placement; it is a
22
+ * context object, not a component, so the Fast Refresh rule is not engaged. */
23
+ export const EntryContext = createContext<EntryContextValue | null>(null);
24
+
25
+ /**
26
+ * The one fallback rule: the context's entry when a provider is present, the
27
+ * routed key otherwise — computed by the real `sandboxPathToKey`, so the
28
+ * default rendering is byte-identical to before the context existed
29
+ * (R-CUST-3, default-preserving).
30
+ */
31
+ export function entryKeyOr(contextValue: EntryContextValue | null | undefined, sandboxPath: string): string {
32
+ // `||` (not `??`): an empty-string entryKey is "a provider that says
33
+ // nothing", and falls back — '' is not a key anything can resolve against.
34
+ return contextValue?.entryKey || sandboxPathToKey(sandboxPath || '/');
35
+ }
@@ -86,3 +86,24 @@ describe('resolveFragmentTarget — the stale-document guard (R3-249)', () => {
86
86
  expect(resolveFragmentTarget(doc, INCOMING, '')).toBeNull();
87
87
  });
88
88
  });
89
+
90
+ describe('G-CUST-4 — entry-scoped resolution on a multi-entry page (R3-872)', () => {
91
+ const FIRST = '/app/content/specs/A_SPEC.mdx';
92
+ const SECOND = '/app/content/specs/B_SPEC.mdx';
93
+
94
+ it('two marked bodies, both with #sec-4 (one per render path — both carry data-entry since R3-872): scoping to the second lands in the second', () => {
95
+ const doc = makeDoc([
96
+ { entry: FIRST, ids: ['sec-4'] },
97
+ { entry: SECOND, ids: ['sec-4'] },
98
+ ]);
99
+ const el = resolveFragmentTarget(doc, SECOND, 'sec-4') as unknown as { entry: string };
100
+ expect(el?.entry).toBe(SECOND);
101
+ });
102
+
103
+ it('the document-wide fallback fires only when NO body is marked at all (pre-marker content)', () => {
104
+ const doc = makeDoc([]);
105
+ // no marked bodies → the fallback is the whole document (today's compiled-path
106
+ // behavior for content that never renders the marked paths)
107
+ expect(resolveFragmentTarget(doc, FIRST, 'sec-4')).toBeNull(); // nothing anywhere
108
+ });
109
+ });
@@ -24,9 +24,12 @@ export function fragmentOf(hash: string | undefined | null): string {
24
24
  * about. Returns null while the previous entry is still rendered, which is the signal to
25
25
  * wait rather than to scroll.
26
26
  *
27
- * Falls back to the whole document when no marked body is present — the compiled
28
- * (`<Include>`) path does not carry the marker, and scrolling imperfectly there is better
29
- * than not at all.
27
+ * R3-872: BOTH render paths carry the `data-entry` marker now (the compiled path's
28
+ * omission predates entry-scoped resolution) — the marker means "this region belongs to
29
+ * entry X", and a fragment not yet inside it is not-found, so the caller keeps waiting
30
+ * through the compile window (ScrollToFragment retries; claim-too-early has no path to
31
+ * fire). The document-wide fallback serves only content rendered with NO marked bodies
32
+ * at all (pre-marker content), never a multi-entry page.
30
33
  */
31
34
  export function resolveFragmentTarget(doc: Document, entryKey: string, frag: string): HTMLElement | null {
32
35
  if (!frag) return null;
@@ -37,9 +40,9 @@ export function resolveFragmentTarget(doc: Document, entryKey: string, frag: str
37
40
  // strings has no such failure mode, and there are only a handful of marked bodies.
38
41
  const marked = Array.from(doc.querySelectorAll<HTMLElement>('[data-entry]'));
39
42
  const scope: ParentNode | null = marked.find((n) => n.getAttribute('data-entry') === entryKey) ?? null;
40
- // No marked body at all: the compiled (`<Include>`) path does not carry the marker, so
41
- // fall back to the document. A marked body for a DIFFERENT entry means the previous
42
- // document is still on screen — return null and wait, rather than scroll the wrong page.
43
+ // No marked body at all (content rendered outside the marked paths): fall back to
44
+ // the document. A marked body for a DIFFERENT entry means the previous document is
45
+ // still on screen — return null and wait, rather than scroll the wrong page.
43
46
  const root: ParentNode | null = scope ?? (marked.length === 0 ? doc : null);
44
47
  if (!root) return null;
45
48
  const byId = root.querySelector<HTMLElement>(`[id="${cssEscape(frag)}"]`);
@@ -0,0 +1,65 @@
1
+ // The navigation policy's defaults + click guard (APP_CUSTOMIZATION_SPEC §4.3,
2
+ // R3-872): the stock policy navigates the resolved href; a plain click routes to
3
+ // the policy with the browser default prevented; a modified click falls through
4
+ // to the real href; a throwing policy fails LOUDLY (logged, never silently
5
+ // retried by the default — R-CUST-5).
6
+ import { describe, it, expect, vi } from 'vitest';
7
+
8
+ const navigate = vi.fn();
9
+ vi.mock('@immediately-run/sdk', () => ({ navigate: (...a: unknown[]) => navigate(...(a as [])) }));
10
+
11
+ import { defaultFollowLink, followLinkOnClick } from './navigationPolicy';
12
+
13
+ const click = (over: Partial<Parameters<ReturnType<typeof followLinkOnClick>>[0]> = {}) => ({
14
+ button: 0,
15
+ metaKey: false,
16
+ ctrlKey: false,
17
+ shiftKey: false,
18
+ altKey: false,
19
+ preventDefault: vi.fn(),
20
+ ...over,
21
+ });
22
+
23
+ describe('navigationPolicy', () => {
24
+ it('defaultFollowLink calls the SDK navigate with the resolved href', () => {
25
+ defaultFollowLink({ key: '/app/content/a.mdx', href: '/content/a' });
26
+ expect(navigate).toHaveBeenCalledWith('/content/a');
27
+ });
28
+
29
+ it('a plain click (primary button, no modifier) reaches the policy and prevents the default', () => {
30
+ const follow = vi.fn();
31
+ const e = click();
32
+ followLinkOnClick(follow, { key: 'k', fragment: 'sec-4', href: '/h#sec-4', from: 'here' })(e);
33
+ expect(follow).toHaveBeenCalledWith({ key: 'k', fragment: 'sec-4', href: '/h#sec-4', from: 'here' });
34
+ expect(e.preventDefault).toHaveBeenCalled();
35
+ });
36
+
37
+ it.each([
38
+ ['meta', { metaKey: true }],
39
+ ['ctrl', { ctrlKey: true }],
40
+ ['shift', { shiftKey: true }],
41
+ ['alt', { altKey: true }],
42
+ ['non-primary button', { button: 1 }],
43
+ ])('a %s click never reaches the policy — the real href handles it', (_name, over) => {
44
+ const follow = vi.fn();
45
+ const e = click(over);
46
+ followLinkOnClick(follow, { key: 'k', href: '/h' })(e);
47
+ expect(follow).not.toHaveBeenCalled();
48
+ expect(e.preventDefault).not.toHaveBeenCalled();
49
+ });
50
+
51
+ it('a throwing policy is logged with the target and NOT silently fallen back from', () => {
52
+ const boom = new Error('policy broke');
53
+ const follow = vi.fn(() => {
54
+ throw boom;
55
+ });
56
+ const err = vi.spyOn(console, 'error').mockImplementation(() => {});
57
+ const e = click();
58
+ expect(() => followLinkOnClick(follow, { key: 'k', href: '/h' })(e)).not.toThrow();
59
+ expect(err).toHaveBeenCalledWith(expect.stringContaining('k'), boom);
60
+ // and no default navigation happened in place of the policy (the click was
61
+ // already preventDefault'd — the shell's bug is visible, never papered over)
62
+ expect(e.preventDefault).toHaveBeenCalled();
63
+ err.mockRestore();
64
+ });
65
+ });
@@ -0,0 +1,57 @@
1
+ // The navigation policy (APP_CUSTOMIZATION_SPEC §4.3, R3-872) — every in-bundle entry
2
+ // link's plain click goes through ONE replaceable function, so a shell that composes
3
+ // GroveEntry decides what "follow" means (route, open elsewhere, intercept) without
4
+ // forking a component.
5
+ //
6
+ // This file exports no component (the Fast-Refresh rule), so the context lives here
7
+ // beside the default it falls back to.
8
+
9
+ import { createContext } from 'react';
10
+ import { navigate } from '@immediately-run/sdk';
11
+
12
+ /** A resolved link target: the corpus key, any `#fragment`, the concrete href the
13
+ * anchor already renders (modifier-/middle-click still open it in a new tab), and
14
+ * the entry the link was rendered inside (`from`). Resolution happens at the call
15
+ * site — the policy receives the ANSWER, never a string to re-derive. */
16
+ export interface FollowLinkTarget {
17
+ key: string;
18
+ fragment?: string;
19
+ href: string;
20
+ from?: string;
21
+ }
22
+
23
+ /** What a plain click on an entry link does. */
24
+ export type FollowLink = (target: FollowLinkTarget) => void;
25
+
26
+ /** The stock behaviour: navigate to the resolved href, exactly as the SDK's Link
27
+ * would have. */
28
+ export const defaultFollowLink: FollowLink = ({ href }) => navigate(href);
29
+
30
+ /**
31
+ * The active policy. Read via `useFollowLink()` (the hook file keeps the
32
+ * component-tree rule); the context's default is the stock behaviour, so an
33
+ * unprovided tree behaves exactly as before (R-CUST-2).
34
+ */
35
+ export const NavigationPolicyContext = createContext<FollowLink>(defaultFollowLink);
36
+
37
+ /**
38
+ * The anchor `onClick` half of the policy (R-CUST-3): a PLAIN click (no modifier,
39
+ * primary button) goes to the policy and never to the browser; a modified click
40
+ * falls through to the anchor's real `href` (new tab/window semantics are the
41
+ * browser's, and an anchor that lost them is a bug). A policy that throws is caught,
42
+ * logged with the target, and NOT silently fallen back from (R-CUST-5: fail loudly).
43
+ */
44
+ export function followLinkOnClick(
45
+ follow: FollowLink,
46
+ target: FollowLinkTarget,
47
+ ): (e: { button: number; metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean; preventDefault: () => void }) => void {
48
+ return (e) => {
49
+ if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
50
+ e.preventDefault();
51
+ try {
52
+ follow(target);
53
+ } catch (err) {
54
+ console.error(`[grove] the navigation policy threw for ${target.key}${target.fragment ?? ''}`, err);
55
+ }
56
+ };
57
+ }
@@ -0,0 +1,17 @@
1
+ // The safe-vs-compiled render decision, in ONE place (R3-872 review, round 1):
2
+ // interpreter mode is read from the HOME entry (wiki-wide) OR the current entry
3
+ // (per-entry), and the two answer different questions — wiki-wide is the
4
+ // interpreter declaration for foreign content; per-entry exists for the document
5
+ // that is only correct as data (R3-252's proof page). Three renderers decide on
6
+ // this — the layout chain's renderer pick, the entry body's pick, and the shell's
7
+ // `safe` field — and drift between them is a trust-boundary hole (an executing
8
+ // body inside a safe chain), never a style issue. Pure, so the rule is testable
9
+ // without a render.
10
+
11
+ /** True when the entry renders through the non-executable interpreter path. */
12
+ export function resolveSafeRender(
13
+ homeMeta: Record<string, unknown> | null | undefined,
14
+ entryMeta: Record<string, unknown> | null | undefined,
15
+ ): boolean {
16
+ return homeMeta?.render === 'safe' || entryMeta?.render === 'safe';
17
+ }
package/src/lib/shell.ts CHANGED
@@ -67,6 +67,10 @@ export interface GroveShell {
67
67
  * `checking` while the readdir is in flight — <PageView> must render neither the
68
68
  * entry nor the 404 then, or a folder URL flashes "No entry at …" before healing. */
69
69
  directory: DirectoryListing;
70
+ /** R3-872 — the entry frame's gate input: the content stylesheets' read state.
71
+ * Wiki-wide (the home entry's declaration), provided by the engine root;
72
+ * standalone compositions default to 'ready' (no sheets declared). */
73
+ stylesheetsStatus?: 'loading' | 'ready';
70
74
  }
71
75
 
72
76
  /** The refusal sentence, ONE home (R6, R3-608): every surface that offers an edit
package/src/lib.ts CHANGED
@@ -52,3 +52,21 @@ export {
52
52
  export { getContentRoot, isDispatched } from './lib/contentRoot';
53
53
  export { layoutChainForKey } from './lib/layout';
54
54
  export { queryPaths, readingTime, stripFrontmatter } from './lib/wiki';
55
+
56
+ // The entry composition seam (R3-872, APP_CUSTOMIZATION_SPEC §4.1): render one entry
57
+ // from a key — header + body + metadata + tags — framed by its layout chain or bare,
58
+ // publishing the entry context either way.
59
+ export { default as GroveEntry } from './components/GroveEntry';
60
+ export { useEntryKey } from './hooks/useEntryKey';
61
+
62
+ // The navigation policy (§4.3): every in-bundle entry link's plain click rides one
63
+ // replaceable function; the href stays real for modifier/middle clicks.
64
+ export { NavigationPolicyContext, defaultFollowLink, followLinkOnClick } from './lib/navigationPolicy';
65
+ export type { FollowLink, FollowLinkTarget } from './lib/navigationPolicy';
66
+ export { useFollowLink } from './hooks/useFollowLink';
67
+
68
+ // The entry-scoped helpers (§4.5): fragment resolution and heading collection scoped
69
+ // to one entry's marked body.
70
+ export { fragmentOf, resolveFragmentTarget } from './lib/fragment';
71
+ export { useHeadings } from './hooks/useHeadings';
72
+ export type { Heading } from './hooks/useHeadings';