@immediately-run/grove 0.1.5 → 0.1.8
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 +1 -1
- package/package.json +3 -3
- package/src/GroveApp.css +37 -0
- package/src/GroveWiki.tsx +24 -5
- package/src/components/GroveAgent.test.tsx +282 -91
- package/src/components/GroveAgent.tsx +73 -13
- package/src/components/GroveAgent.unanswered.test.tsx +45 -0
- package/src/components/PageView.tsx +6 -0
- package/src/hooks/useCatalogAnswered.late.test.tsx +59 -0
- package/src/hooks/useCatalogAnswered.test.tsx +89 -0
- package/src/hooks/useCatalogAnswered.ts +73 -0
- package/src/hooks/useEditAffordance.ts +6 -9
- package/src/hooks/useHeadingFragmentUrl.test.tsx +146 -0
- package/src/hooks/useHeadingFragmentUrl.ts +98 -0
- package/src/lib/content.test.ts +47 -1
- package/src/lib/content.ts +21 -2
- package/src/lib/reachCard.test.ts +507 -61
- package/src/lib/reachCard.ts +193 -28
- package/src/lib/shell.ts +3 -2
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { useCallback, useEffect } from 'react';
|
|
2
|
+
import { useContext } from 'react';
|
|
3
|
+
import { navigate } from '@immediately-run/sdk';
|
|
4
|
+
import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
|
|
5
|
+
import { constructOuterUrl } from '@immediately-run/sdk/urlUtils';
|
|
6
|
+
import { fragmentOf } from '../lib/fragment';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Reflect same-page heading navigation in the ADDRESS BAR.
|
|
10
|
+
*
|
|
11
|
+
* The deep-linking pair has exactly one half so far: the host forwards the outer URL's
|
|
12
|
+
* `#fragment` in (R3-249, Capability C §13.5) and `<ScrollToFragment>` lands it — but
|
|
13
|
+
* nothing ever WRITES a fragment, so a reader who follows the contents rail, clicks a
|
|
14
|
+
* heading permalink or lands on a section has a URL bar that still names no section.
|
|
15
|
+
* Copying the address gives someone else the top of the page.
|
|
16
|
+
*
|
|
17
|
+
* This hook is the outgoing half. A delegated `click` listener on the document covers
|
|
18
|
+
* every way a heading is reached without each surface wiring its own handler:
|
|
19
|
+
*
|
|
20
|
+
* - the contents rail's `<a href="#id">` (grove's `<TableOfContents>` smooth-scrolls
|
|
21
|
+
* itself; the URL simply never heard about it);
|
|
22
|
+
* - the SDK's `<HeadingAnchor>` permalinks — absolute host URLs whose own pathname
|
|
23
|
+
* (a `/files/…` subpath) must NOT be navigated to, so only the FRAGMENT is taken and
|
|
24
|
+
* the target is rebuilt on the reader's current path;
|
|
25
|
+
* - a click on a heading element with an id (any depth).
|
|
26
|
+
*
|
|
27
|
+
* `navigate()` is the sanctioned app→host URL channel: an in-prefix `urlchange` whose
|
|
28
|
+
* target carries a hash is pushed onto the browser's address bar without a reload, and
|
|
29
|
+
* the echoed navigation sets `navigationState.hash`, which is what `<ScrollToFragment>`
|
|
30
|
+
* re-runs on — the platform's own loop completes the landing. Modifier clicks are the
|
|
31
|
+
* browser's (open-in-new-tab and copy-link are what the permalink's absolute href is
|
|
32
|
+
* FOR); a fragment naming nothing on the page is ignored rather than pushed; and a
|
|
33
|
+
* fragment equal to the one already in the URL does not stack another history entry.
|
|
34
|
+
*
|
|
35
|
+
* Off-host (`vite dev`, an empty `outerHref`) there is no host to tell, so the fragment
|
|
36
|
+
* is written to the frame's own URL with `history.replaceState` — a reload then lands
|
|
37
|
+
* where the reader was, and no history is spent.
|
|
38
|
+
*/
|
|
39
|
+
export function useHeadingFragmentUrl(): void {
|
|
40
|
+
// Deep import per <ScrollToFragment>'s precedent: the context is not on the SDK's
|
|
41
|
+
// index surface, and this app reads it for `outerHref`/`navigationState` only.
|
|
42
|
+
const ctx = useContext(TinkerableContext) as {
|
|
43
|
+
outerHref?: string;
|
|
44
|
+
navigationState?: { hash?: string };
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const sync = useCallback(
|
|
48
|
+
(fragment: string) => {
|
|
49
|
+
const navigationState = ctx?.navigationState;
|
|
50
|
+
if (!ctx?.outerHref || !navigationState) {
|
|
51
|
+
try {
|
|
52
|
+
history.replaceState(null, '', `#${fragment}`);
|
|
53
|
+
} catch {
|
|
54
|
+
// a history the browser refuses is dev noise, not an error
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
navigate(constructOuterUrl(ctx.outerHref, `#${fragment}`, navigationState as never));
|
|
59
|
+
},
|
|
60
|
+
[ctx],
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
const onClick = (event: MouseEvent) => {
|
|
65
|
+
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
66
|
+
const fragment = clickedFragment(event.target as HTMLElement | null, ctx?.outerHref ?? '');
|
|
67
|
+
if (!fragment) return;
|
|
68
|
+
// Already shown (locator-glued dev hashes included — `fragmentOf` takes the
|
|
69
|
+
// leading component): a second push would only stack a history entry.
|
|
70
|
+
if (fragmentOf(ctx?.navigationState?.hash) === fragment) return;
|
|
71
|
+
if (!document.getElementById(fragment) && !document.querySelector(`[data-slug="${fragment}"]`)) return;
|
|
72
|
+
sync(fragment);
|
|
73
|
+
};
|
|
74
|
+
document.addEventListener('click', onClick);
|
|
75
|
+
return () => document.removeEventListener('click', onClick);
|
|
76
|
+
}, [sync, ctx]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The fragment this click addresses, or null when it is not heading navigation. */
|
|
80
|
+
function clickedFragment(element: HTMLElement | null, outerHref: string): string | null {
|
|
81
|
+
const anchor = element?.closest('a');
|
|
82
|
+
if (anchor) {
|
|
83
|
+
const href = anchor.getAttribute('href') ?? '';
|
|
84
|
+
if (href.startsWith('#')) return fragmentOf(href);
|
|
85
|
+
try {
|
|
86
|
+
const url = new URL(href, window.location.href);
|
|
87
|
+
// Absolute hrefs: only the app's OWN permalink URLs count. Their origin is the
|
|
88
|
+
// outer origin — the sandbox frame's `location.origin` is different, so compare
|
|
89
|
+
// against the outer href, never against the frame.
|
|
90
|
+
const ownOrigin = outerHref ? new URL(outerHref).origin : window.location.origin;
|
|
91
|
+
if (!url.hash || url.origin !== ownOrigin) return null;
|
|
92
|
+
return fragmentOf(url.hash);
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return element?.closest('h1[id], h2[id], h3[id], h4[id]')?.id ?? null;
|
|
98
|
+
}
|
package/src/lib/content.test.ts
CHANGED
|
@@ -147,7 +147,7 @@ describe('linkKind — which hrefs may become a navigating <a>', () => {
|
|
|
147
147
|
// dispatch declares CORPUS-relative (the host joins its chroot prefix — the corpus's
|
|
148
148
|
// repo-side location is host knowledge this app cannot see).
|
|
149
149
|
import { viewedDocumentForTarget } from './content';
|
|
150
|
-
import { setContentRoot, resetContentRoot } from './contentRoot';
|
|
150
|
+
import { setContentRoot, resetContentRoot, isDispatched } from './contentRoot';
|
|
151
151
|
import { afterEach } from 'vitest';
|
|
152
152
|
|
|
153
153
|
describe('viewedDocumentForTarget — the R3-268 declaration path space', () => {
|
|
@@ -195,6 +195,25 @@ describe('viewedDocumentForTarget — the R3-268 declaration path space', () =>
|
|
|
195
195
|
// answer "which PATH?" as well as "which entry?" — `[the handbook](handbook)` names
|
|
196
196
|
// something real and must not render as a broken link.
|
|
197
197
|
describe('hrefTargetKey — resolution without the entry-file requirement', () => {
|
|
198
|
+
afterEach(resetContentRoot);
|
|
199
|
+
it('R3-184 S2 — the $fs: clamp, ON THE RENDER PATH (the join: isDispatched → resolveLinkTarget)', () => {
|
|
200
|
+
// The clamp that is in force lives in hrefTargetKey's linkSpaceOpts: the
|
|
201
|
+
// decision (isDispatched) and the emission (resolveLinkTarget) join in ONE
|
|
202
|
+
// call, and THIS is the join — ways_of_working §4's rule that a decision and
|
|
203
|
+
// an emitter can both be tested while the line joining them is not.
|
|
204
|
+
// Fork (own repo): $fs: stays mount-absolute, as shipped (R3-273).
|
|
205
|
+
expect(hrefTargetKey('$fs:/mnt/aaa/x.mdx', HOME)).toBe('/mnt/aaa/x.mdx');
|
|
206
|
+
// Dispatched (a corpus mount): the shared resolver clamps $fs: onto the
|
|
207
|
+
// bundle root (R3-319's bundleChrooted), so the app-level mount point is
|
|
208
|
+
// not nameable — the resolved target is a CORPUS path, and only a real
|
|
209
|
+
// corpus entry there resolves downstream.
|
|
210
|
+
setContentRoot('/mnt/chroot1');
|
|
211
|
+
expect(hrefTargetKey('$fs:/mnt/aaa/x.mdx', '/mnt/chroot1/h.mdx')).toBe('/mnt/chroot1/mnt/aaa/x.mdx');
|
|
212
|
+
// And the same corpus path by its ordinary spelling resolves identically —
|
|
213
|
+
// the clamp makes $fs:/p and /p the same address, which is the invariant.
|
|
214
|
+
expect(hrefTargetKey('/mnt/aaa/x.mdx', '/mnt/chroot1/h.mdx')).toBe('/mnt/chroot1/mnt/aaa/x.mdx');
|
|
215
|
+
});
|
|
216
|
+
|
|
198
217
|
it('resolves a folder href the entry-candidate list rejects', () => {
|
|
199
218
|
expect(hrefKeyCandidates('handbook', HOME)).toEqual([]);
|
|
200
219
|
expect(hrefTargetKey('handbook', HOME)).toBe('/app/content/handbook');
|
|
@@ -275,3 +294,30 @@ describe('link-space parity (LINK_SPACE_FIXTURE, R3-277b)', () => {
|
|
|
275
294
|
);
|
|
276
295
|
});
|
|
277
296
|
});
|
|
297
|
+
|
|
298
|
+
describe('isDispatched — the R3-184 S2 `$fs:` clamp discriminator', () => {
|
|
299
|
+
afterEach(resetContentRoot);
|
|
300
|
+
// The clamp caller (GroveWiki's LinkSpaceContext) keys the SDK's
|
|
301
|
+
// `bundleChrooted` flag on this: a DISPATCHED corpus renders inside a
|
|
302
|
+
// host-minted chroot, so `$fs:` resolves within the corpus and a federated
|
|
303
|
+
// mount materialised beside it is not nameable from a corpus document; the
|
|
304
|
+
// fork's `$fs:` stays mount-absolute, as shipped. The flag derivation is
|
|
305
|
+
// one line over THIS module's state. The JOIN — this decision reaching the
|
|
306
|
+
// shared resolver on the render path — is pinned by the `$fs:` clamp case in
|
|
307
|
+
// the hrefTargetKey describe above (the two tests are the pair: the decision
|
|
308
|
+
// here, the emission there, and the wire between them asserted once).
|
|
309
|
+
it('fork (own repo): false — the corpus is the engine repo, not a chroot', () => {
|
|
310
|
+
expect(isDispatched()).toBe(false);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('dispatch (a corpus mount): true', () => {
|
|
314
|
+
setContentRoot('/mnt/0a1b2c3d');
|
|
315
|
+
expect(isDispatched()).toBe(true);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('the afterEach resets the root — a later reader sees the fork state', () => {
|
|
319
|
+
// The reset itself is the convention's job (afterEach, above); asserting it
|
|
320
|
+
// keeps the leak-visible property testable rather than assumed.
|
|
321
|
+
expect(isDispatched()).toBe(false);
|
|
322
|
+
});
|
|
323
|
+
});
|
package/src/lib/content.ts
CHANGED
|
@@ -186,6 +186,25 @@ export function hrefKeyCandidates(href: string, fromKey: string): string[] {
|
|
|
186
186
|
* The two callers ask different questions of the same resolution — "which entry?" and
|
|
187
187
|
* "which path?" — so the resolution lives here once.
|
|
188
188
|
*/
|
|
189
|
+
// R3-184 S2 (PERSISTENCE_SPEC §8.3) — the `$fs:` clamp ON the render path, the one
|
|
190
|
+
// the spec's dated note names as the missing piece. This file's hrefTargetKey is
|
|
191
|
+
// where a corpus document's link targets actually resolve (grove's own WikiLink
|
|
192
|
+
// override routes here), so the clamp lives HERE: `bundleChrooted: isDispatched()`
|
|
193
|
+
// makes the shared resolver treat `$fs:/p` exactly like `/p` under the bundle root
|
|
194
|
+
// (R3-319), so a dispatched corpus document's `$fs:/mnt/{hash}/…` cannot name a
|
|
195
|
+
// federated mount materialised beside it — the app-level mount point is not a
|
|
196
|
+
// corpus path. The fork (own repo, not a corpus mount) keeps `$fs:`
|
|
197
|
+
// mount-absolute, as shipped. The LinkSpaceContext field in GroveWiki carries the
|
|
198
|
+
// same flag for the SDK's generic component consumers, the day a release that
|
|
199
|
+
// forwards it is pinned.
|
|
200
|
+
const linkSpaceOpts = (fromKey: string) => ({
|
|
201
|
+
currentFile: fromKey,
|
|
202
|
+
// The canonical spelling (mdx-plugins reads bundleRoot-else-corpusRoot; the
|
|
203
|
+
// deprecated corpusRoot opts field stays for older consumers, not for new code).
|
|
204
|
+
bundleRoot: getContentRoot(),
|
|
205
|
+
bundleChrooted: isDispatched(),
|
|
206
|
+
});
|
|
207
|
+
|
|
189
208
|
export function hrefTargetKey(href: string, fromKey: string): string | null {
|
|
190
209
|
if (!href) return null;
|
|
191
210
|
if (/^(https?:|mailto:|tel:|#)/i.test(href)) return null;
|
|
@@ -198,7 +217,7 @@ export function hrefTargetKey(href: string, fromKey: string): string | null {
|
|
|
198
217
|
// targets resolve against the authoring file; `$fs:` targets resolve
|
|
199
218
|
// mount-absolute (addressing, never reach — R3-273).
|
|
200
219
|
if (!path.startsWith('/')) {
|
|
201
|
-
const rel = resolveLinkTarget(path,
|
|
220
|
+
const rel = resolveLinkTarget(path, linkSpaceOpts(fromKey));
|
|
202
221
|
if (rel.state !== 'resolved') return null;
|
|
203
222
|
// Confinement, not tidiness: an href in foreign content is untrusted, and the
|
|
204
223
|
// result flows into `fs` reads. In the default space anything that lands outside
|
|
@@ -221,7 +240,7 @@ export function hrefTargetKey(href: string, fromKey: string): string | null {
|
|
|
221
240
|
const legacy = normalizeAbsolute(legacyAnchor + path);
|
|
222
241
|
if (legacy.startsWith(contentDir())) return legacy;
|
|
223
242
|
}
|
|
224
|
-
const corpusAnchored = resolveLinkTarget(path,
|
|
243
|
+
const corpusAnchored = resolveLinkTarget(path, linkSpaceOpts(fromKey));
|
|
225
244
|
if (corpusAnchored.state === 'resolved' && corpusAnchored.path.startsWith(contentDir())) {
|
|
226
245
|
return corpusAnchored.path;
|
|
227
246
|
}
|