@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.
- package/llms.txt +13 -1
- package/package.json +1 -1
- package/src/GroveWiki.tsx +13 -52
- package/src/components/AssetImage.test.tsx +64 -0
- package/src/components/AssetImage.tsx +10 -10
- package/src/components/Backlinks.tsx +13 -6
- package/src/components/ChildPages.tsx +8 -6
- package/src/components/Directory.tsx +6 -1
- package/src/components/DirectoryList.tsx +16 -8
- package/src/components/DirectoryView.tsx +5 -2
- package/src/components/DocList.tsx +9 -2
- package/src/components/Drawer.tsx +14 -1
- package/src/components/EntryBody.tsx +138 -0
- package/src/components/EntryHeader.tsx +16 -3
- package/src/components/FamilyTree.tsx +3 -5
- package/src/components/GroveAgent.tsx +1 -1
- package/src/components/GroveEntry.test.tsx +200 -0
- package/src/components/GroveEntry.tsx +108 -0
- package/src/components/GroveFooter.tsx +5 -2
- package/src/components/GroveNav.tsx +11 -2
- package/src/components/PageMeta.tsx +3 -5
- package/src/components/PageView.tsx +13 -63
- package/src/components/Search.tsx +10 -1
- package/src/components/Sidebar.tsx +15 -7
- package/src/components/TableOfContents.tsx +1 -1
- package/src/components/Timeline.tsx +6 -1
- package/src/components/WikiLink.test.tsx +83 -0
- package/src/components/WikiLink.tsx +29 -8
- package/src/components/navigationPolicy.sweep.test.tsx +261 -0
- package/src/hooks/useEntryKey.ts +21 -0
- package/src/hooks/useFollowLink.ts +11 -0
- package/src/hooks/useHeadings.test.tsx +93 -0
- package/src/hooks/useHeadings.ts +73 -12
- package/src/lib/assetPath.ts +4 -3
- package/src/lib/content.ts +0 -5
- package/src/lib/entryContext.test.ts +46 -0
- package/src/lib/entryContext.ts +35 -0
- package/src/lib/fragment.test.ts +21 -0
- package/src/lib/fragment.ts +9 -6
- package/src/lib/navigationPolicy.test.ts +65 -0
- package/src/lib/navigationPolicy.ts +57 -0
- package/src/lib/renderMode.ts +17 -0
- package/src/lib/shell.ts +4 -0
- package/src/lib.ts +18 -0
|
@@ -0,0 +1,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
|
+
});
|
package/src/hooks/useHeadings.ts
CHANGED
|
@@ -29,8 +29,15 @@ function headingText(node: Element): string {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* Scan
|
|
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
|
|
43
|
-
|
|
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
|
|
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
|
|
76
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/lib/assetPath.ts
CHANGED
|
@@ -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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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.
|
package/src/lib/content.ts
CHANGED
|
@@ -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 `` 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
|
+
}
|
package/src/lib/fragment.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/lib/fragment.ts
CHANGED
|
@@ -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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
41
|
-
//
|
|
42
|
-
//
|
|
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';
|