@immediately-run/grove 0.1.10 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/llms.txt +13 -1
- package/package.json +3 -3
- package/src/GroveApp.css +11 -0
- package/src/GroveWiki.tsx +19 -60
- 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/TagCloud.test.tsx +103 -0
- package/src/components/TagCloud.tsx +47 -29
- 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/useEditAffordance.test.tsx +110 -0
- package/src/hooks/useEditAffordance.ts +51 -14
- 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/editTarget.test.ts +78 -18
- package/src/lib/editTarget.ts +39 -10
- 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/tagCloud.test.ts +115 -0
- package/src/lib/tagCloud.ts +58 -0
- package/src/lib.ts +18 -0
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
|
-
}
|
|
@@ -4,21 +4,29 @@
|
|
|
4
4
|
// viewer must send its edit to the CORPUS (never to Grove's own repo), and whether it may
|
|
5
5
|
// offer one at all must be the corpus mount's CURRENT mode rather than a property of the
|
|
6
6
|
// packaging or a flag latched at boot.
|
|
7
|
-
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import { describe, expect, it, afterEach } from 'vitest';
|
|
8
8
|
import { corpusWritable, editTarget, keyToSelfPath } from './editTarget';
|
|
9
9
|
import type { CorpusIdentity } from './editTarget';
|
|
10
10
|
import type { SandboxMount } from '@immediately-run/sdk/mounts';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
import { getContentRoot, getCorpusMountId, isDispatched, resetContentRoot, setContentRoot } from './contentRoot';
|
|
12
|
+
|
|
13
|
+
// The corpus identities come from the real producer (R3-877 round 1, R2): the
|
|
14
|
+
// trailing-slash normalization every `slice` in editTarget depends on lives in
|
|
15
|
+
// `setContentRoot` — a hand-typed literal would keep passing while it broke.
|
|
16
|
+
const forkFor = (): CorpusIdentity => {
|
|
17
|
+
resetContentRoot();
|
|
18
|
+
return { dispatched: isDispatched(), contentRoot: getContentRoot(), mountId: getCorpusMountId() };
|
|
16
19
|
};
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
contentRoot:
|
|
20
|
-
mountId: '/task/t1/dir',
|
|
20
|
+
const dispatchedFor = (): CorpusIdentity => {
|
|
21
|
+
setContentRoot('/task/t1/dir', { mountId: '/task/t1/dir' });
|
|
22
|
+
return { dispatched: isDispatched(), contentRoot: getContentRoot(), mountId: getCorpusMountId() };
|
|
21
23
|
};
|
|
24
|
+
afterEach(() => resetContentRoot());
|
|
25
|
+
|
|
26
|
+
const fork: CorpusIdentity = forkFor();
|
|
27
|
+
const dispatched: CorpusIdentity = dispatchedFor();
|
|
28
|
+
// The dispatched entry keys, built off the producer's root — never a hand-typed prefix.
|
|
29
|
+
const IN = (rel: string) => `${dispatched.contentRoot}${rel}`;
|
|
22
30
|
|
|
23
31
|
const mount = (over: Partial<SandboxMount> = {}): SandboxMount =>
|
|
24
32
|
({ type: 'firestore', path: '/task/t1/dir', id: '/task/t1/dir', mode: 'rw', ...over }) as SandboxMount;
|
|
@@ -32,7 +40,7 @@ describe('editTarget — the verb follows the authority, not the packaging', ()
|
|
|
32
40
|
});
|
|
33
41
|
|
|
34
42
|
it('a DISPATCHED viewer delegates the CORPUS file, never a path in its own repo', () => {
|
|
35
|
-
expect(editTarget('
|
|
43
|
+
expect(editTarget(IN('plot/the-rail.mdx'), dispatched)).toEqual({
|
|
36
44
|
via: 'delegate',
|
|
37
45
|
mountId: '/task/t1/dir',
|
|
38
46
|
relPath: 'plot/the-rail.mdx',
|
|
@@ -40,7 +48,7 @@ describe('editTarget — the verb follows the authority, not the packaging', ()
|
|
|
40
48
|
});
|
|
41
49
|
|
|
42
50
|
it('is corpus-relative under dispatch — the mount root IS the corpus root', () => {
|
|
43
|
-
const t = editTarget('
|
|
51
|
+
const t = editTarget(IN('home.mdx'), dispatched);
|
|
44
52
|
expect(t).toMatchObject({ relPath: 'home.mdx' });
|
|
45
53
|
// The fork's `content/` segment must NOT leak into a corpus-relative path: the
|
|
46
54
|
// delegated chroot is minted AT the content directory.
|
|
@@ -52,11 +60,11 @@ describe('editTarget — the verb follows the authority, not the packaging', ()
|
|
|
52
60
|
});
|
|
53
61
|
|
|
54
62
|
it('offers nothing when a dispatched corpus has no mount id to delegate from', () => {
|
|
55
|
-
expect(editTarget('
|
|
63
|
+
expect(editTarget(IN('home.mdx'), { ...dispatched, mountId: null })).toBeNull();
|
|
56
64
|
});
|
|
57
65
|
|
|
58
66
|
it('offers nothing for the corpus root itself (a directory is not an entry)', () => {
|
|
59
|
-
expect(editTarget('
|
|
67
|
+
expect(editTarget(IN(''), dispatched)).toBeNull();
|
|
60
68
|
});
|
|
61
69
|
|
|
62
70
|
it('never throws on a junk key', () => {
|
|
@@ -81,13 +89,19 @@ describe('corpusWritable — the mount decides, live', () => {
|
|
|
81
89
|
expect(corpusWritable([mount()], dispatched)).toBe(true);
|
|
82
90
|
});
|
|
83
91
|
|
|
84
|
-
it('a ro corpus is
|
|
85
|
-
|
|
92
|
+
it('a ro corpus with no hint is still offerable (R3-877: the workbench class) — never EROFS, a refusal tells', () => {
|
|
93
|
+
// Pre-R3-877 this was `false` — an ro mount hid the affordance outright. The
|
|
94
|
+
// workbench class edits under the reader's authority, so our ro mount is the
|
|
95
|
+
// normal case, not a refusal. An explicit `false` hint still hides it (below).
|
|
96
|
+
expect(corpusWritable([mount({ mode: 'ro' })], dispatched)).toBe(true);
|
|
86
97
|
});
|
|
87
98
|
|
|
88
|
-
it('follows a
|
|
99
|
+
it('follows a live downgrade: re-announced ro flips the delivery, and a false hint flips the offer', () => {
|
|
89
100
|
expect(corpusWritable([mount({ mode: 'rw' })], dispatched)).toBe(true);
|
|
90
|
-
|
|
101
|
+
// ro with no hint: still offerable, now via the workbench (reader's authority).
|
|
102
|
+
expect(corpusWritable([mount({ mode: 'ro' })], dispatched)).toBe(true);
|
|
103
|
+
// ro with the host saying the reader cannot edit: hidden.
|
|
104
|
+
expect(corpusWritable([mount({ mode: 'ro', readerCanEdit: false })], dispatched)).toBe(false);
|
|
91
105
|
});
|
|
92
106
|
|
|
93
107
|
it('a corpus mount that has vanished is not writable', () => {
|
|
@@ -106,3 +120,49 @@ describe('corpusWritable — the mount decides, live', () => {
|
|
|
106
120
|
expect(corpusWritable([mount()], { ...dispatched, mountId: null })).toBe(false);
|
|
107
121
|
});
|
|
108
122
|
});
|
|
123
|
+
|
|
124
|
+
// R3-877 — the third outcome: an ro delegation edits via the workbench, under the
|
|
125
|
+
// reader's authority (`requestEdit({ bundleFile })`, R3-876). Order of preference:
|
|
126
|
+
// self → delegate (rw) → workbench (ro).
|
|
127
|
+
describe('editTarget — the workbench class for a read-only delegation (R3-877)', () => {
|
|
128
|
+
// The corpus identity's contentRoot mirrors the real getContentRoot() shape: the
|
|
129
|
+
// delegated chroot root, trailing slash.
|
|
130
|
+
const roCorpus: CorpusIdentity = { ...dispatched, mountMode: 'ro' };
|
|
131
|
+
|
|
132
|
+
it('an `ro` dispatched corpus yields workbench with the leading-slash bundle-relative path', () => {
|
|
133
|
+
expect(editTarget(IN('plot/the-rail.mdx'), roCorpus)).toEqual({
|
|
134
|
+
via: 'workbench',
|
|
135
|
+
relPath: '/plot/the-rail.mdx',
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('an `rw` delegation still yields delegate (the edit-file overlay is unchanged)', () => {
|
|
140
|
+
expect(editTarget(IN('plot/the-rail.mdx'), { ...dispatched, mountMode: 'rw' })).toEqual({
|
|
141
|
+
via: 'delegate',
|
|
142
|
+
mountId: '/task/t1/dir',
|
|
143
|
+
relPath: 'plot/the-rail.mdx',
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('an UNKNOWN mode (an older host announces none) keeps the pre-R3-877 delegate behavior', () => {
|
|
148
|
+
expect(editTarget(IN('home.mdx'), dispatched)).toEqual({
|
|
149
|
+
via: 'delegate',
|
|
150
|
+
mountId: '/task/t1/dir',
|
|
151
|
+
relPath: 'home.mdx',
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('the corpus root itself is still nothing to edit, workbench included', () => {
|
|
156
|
+
expect(editTarget(IN(''), roCorpus)).toBeNull();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('corpusWritable — the ro delegation is offerable on the hint (R3-877)', () => {
|
|
161
|
+
it('ro + readerCanEdit true → offered', () => {
|
|
162
|
+
expect(corpusWritable([mount({ mode: 'ro', readerCanEdit: true })], dispatched)).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('ro + readerCanEdit false → not offered (never show a control that refuses)', () => {
|
|
166
|
+
expect(corpusWritable([mount({ mode: 'ro', readerCanEdit: false })], dispatched)).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
});
|
package/src/lib/editTarget.ts
CHANGED
|
@@ -20,8 +20,11 @@
|
|
|
20
20
|
//
|
|
21
21
|
// **The mount decides.** Writability is a property of the delegation's current mode, not
|
|
22
22
|
// of how the app was loaded. That is why `corpusWritable` takes the live mount list rather
|
|
23
|
-
// than the boot-time flag
|
|
24
|
-
//
|
|
23
|
+
// than the boot-time flag. Since R3-877 a role downgrade no longer hides the affordance —
|
|
24
|
+
// it reroutes the delivery: `rw` hands the file to the `edit-file` overlay, `ro` asks the
|
|
25
|
+
// workbench under the reader's authority (`requestEdit({ bundleFile })`). The offer hides
|
|
26
|
+
// only when the host's `readerCanEdit` hint says the reader cannot edit, or a `read-only`
|
|
27
|
+
// refusal proved it.
|
|
25
28
|
//
|
|
26
29
|
// Pure — no SDK, no React — so all of the above is testable without a host.
|
|
27
30
|
|
|
@@ -31,8 +34,14 @@ import type { SandboxMount } from '@immediately-run/sdk/mounts';
|
|
|
31
34
|
export type EditTarget =
|
|
32
35
|
/** The fork: our own repo, via the self-scoped present→edit transition. */
|
|
33
36
|
| { via: 'self'; path: string }
|
|
34
|
-
/** Dispatch: one file of the
|
|
35
|
-
|
|
37
|
+
/** Dispatch, writable delegation: one file of the corpus, handed to the platform
|
|
38
|
+
* editor as a narrowed `edit-file` delegation (the overlay). */
|
|
39
|
+
| { via: 'delegate'; mountId: string; relPath: string }
|
|
40
|
+
/** Dispatch, read-only delegation (an app-declared opener's chroot): ask the
|
|
41
|
+
* workbench to open the entry's source in the main-pane editor under the
|
|
42
|
+
* reader's authority — `requestEdit({ bundleFile })` (R3-876 / APP_CUSTOMIZATION
|
|
43
|
+
* §5a). The path is bundle-relative with a leading slash. */
|
|
44
|
+
| { via: 'workbench'; relPath: string };
|
|
36
45
|
|
|
37
46
|
export interface CorpusIdentity {
|
|
38
47
|
/** Whether the corpus is a mount rather than this app's own repo. */
|
|
@@ -41,6 +50,11 @@ export interface CorpusIdentity {
|
|
|
41
50
|
contentRoot: string;
|
|
42
51
|
/** The corpus mount id, when dispatched (`getCorpusMountId()`). */
|
|
43
52
|
mountId: string | null;
|
|
53
|
+
/** The corpus delegation's current mode, read off the live mount list (R3-877):
|
|
54
|
+
* `ro` routes the edit to the workbench under the reader's authority; `rw` keeps
|
|
55
|
+
* the `edit-file` overlay. Absent/unknown keeps the pre-R3-877 behavior
|
|
56
|
+
* (`delegate` — an `rw`-assuming host that announces no mode). */
|
|
57
|
+
mountMode?: 'ro' | 'rw' | null;
|
|
44
58
|
}
|
|
45
59
|
|
|
46
60
|
/** `/app/content/x.mdx` → `content/x.mdx` — the fork's repo-relative path. */
|
|
@@ -62,17 +76,25 @@ export function editTarget(entryKey: string, corpus: CorpusIdentity): EditTarget
|
|
|
62
76
|
if (!corpus.mountId) return null;
|
|
63
77
|
if (!entryKey.startsWith(corpus.contentRoot)) return null;
|
|
64
78
|
const relPath = entryKey.slice(corpus.contentRoot.length);
|
|
65
|
-
|
|
79
|
+
if (!relPath) return null;
|
|
80
|
+
// An `ro` delegation (the opener's chroot — the only mode an app-declared opener
|
|
81
|
+
// ever holds) goes to the workbench: the reader's authority, not ours — the mount
|
|
82
|
+
// is never upgraded and nothing is minted for us. Leading-slash, the host's
|
|
83
|
+
// `bundleFile` grammar.
|
|
84
|
+
if (corpus.mountMode === 'ro') return { via: 'workbench', relPath: `/${relPath}` };
|
|
85
|
+
return { via: 'delegate', mountId: corpus.mountId, relPath };
|
|
66
86
|
}
|
|
67
87
|
|
|
68
88
|
/**
|
|
69
89
|
* May this instance offer an edit at all, given the mounts it holds RIGHT NOW?
|
|
70
90
|
*
|
|
71
91
|
* A fork asks about its working tree, as before. A dispatched viewer asks about the corpus
|
|
72
|
-
* mount — and asks the
|
|
73
|
-
* downgrade (a role change the host re-announces on the same mount id)
|
|
74
|
-
*
|
|
75
|
-
*
|
|
92
|
+
* mount — and asks the live mount list, not the boot-time flag, so a live `rw → ro`
|
|
93
|
+
* downgrade (a role change the host re-announces on the same mount id) reroutes the
|
|
94
|
+
* delivery on the next render (to the workbench class) and an explicit
|
|
95
|
+
* `readerCanEdit: false` hides the offer — rather than surfacing `EROFS` on click.
|
|
96
|
+
* That is the whole difference between "hidden because you may not" and "shown, then
|
|
97
|
+
* `EROFS` when you try".
|
|
76
98
|
*
|
|
77
99
|
* A corpus mount that has vanished from the list answers `false`: no mount, no write.
|
|
78
100
|
*/
|
|
@@ -89,5 +111,12 @@ export function corpusWritable(
|
|
|
89
111
|
// `mode` is absent on the primary repo mount and rw by default elsewhere; a corpus
|
|
90
112
|
// mount that reports nothing is treated as writable exactly as `resolveOpenWiki` reads
|
|
91
113
|
// it, so the two never disagree about the same mount.
|
|
92
|
-
|
|
114
|
+
if (!mount) return false;
|
|
115
|
+
if (mount.mode !== 'ro') return true;
|
|
116
|
+
// R3-877 (APP_CUSTOMIZATION §5a.5): an `ro` delegation can still offer the
|
|
117
|
+
// workbench edit — the reader's authority — gated on the host's advisory
|
|
118
|
+
// `readerCanEdit` hint: offer when it is true, OR when the host sent no hint
|
|
119
|
+
// (absent = unknown — the refusal would tell, and `read-only` hides it after).
|
|
120
|
+
// Never offer on an explicit `false`.
|
|
121
|
+
return mount.readerCanEdit !== false;
|
|
93
122
|
}
|
|
@@ -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
|
+
});
|