@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.
@@ -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
+ }
@@ -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
+ });
@@ -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, { currentFile: fromKey, corpusRoot: getContentRoot() });
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, { currentFile: fromKey, corpusRoot: getContentRoot() });
243
+ const corpusAnchored = resolveLinkTarget(path, linkSpaceOpts(fromKey));
225
244
  if (corpusAnchored.state === 'resolved' && corpusAnchored.path.startsWith(contentDir())) {
226
245
  return corpusAnchored.path;
227
246
  }