@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
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// G-CUST-3 (APP_CUSTOMIZATION_SPEC §4.3, R3-872) — the sweep: EVERY component under
|
|
3
|
+
// src/components/ that renders the SDK `<Link>` (or calls `navigate(`) is
|
|
4
|
+
// enumerated FROM DISK (never a hand-list — a new component that skips the policy
|
|
5
|
+
// fails this suite on arrival), rendered with a recording policy over a real
|
|
6
|
+
// corpus entry, and plain-clicked: the policy must receive the resolved target
|
|
7
|
+
// `{ key, href, fragment?, from? }` and the browser default must be prevented.
|
|
8
|
+
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
|
|
9
|
+
import { act } from 'react';
|
|
10
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
11
|
+
import type { ReactNode } from 'react';
|
|
12
|
+
// The enumeration reads the components directory through a DIRECT require of
|
|
13
|
+
// node:fs (createRequire), not the test graph's `fs` — the file mocks `fs` for
|
|
14
|
+
// the components under test, and the enumeration must not eat its own mock.
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
const { readFileSync, readdirSync } = createRequire(import.meta.url)('node:fs') as typeof import('node:fs');
|
|
18
|
+
import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
|
|
19
|
+
import { GroveShellContext, type GroveShell } from '../lib/shell';
|
|
20
|
+
import { NavigationPolicyContext, type FollowLinkTarget } from '../lib/navigationPolicy';
|
|
21
|
+
import { keyToHref } from '../lib/content';
|
|
22
|
+
import { setContentRoot } from '../lib/contentRoot';
|
|
23
|
+
|
|
24
|
+
beforeAll(() => {
|
|
25
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
26
|
+
writable: true,
|
|
27
|
+
value: (q: string) => ({ matches: false, media: q, addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {}, dispatchEvent: () => false, onchange: null }),
|
|
28
|
+
});
|
|
29
|
+
setContentRoot('/app/content/');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// ── The corpus fixture (a real two-entry shape) ──────────────────────────────
|
|
33
|
+
const ENTRY = '/app/content/wiki/a.mdx';
|
|
34
|
+
const OTHER = '/app/content/wiki/b.mdx';
|
|
35
|
+
const PERSON = '/app/content/people/ada.mdx';
|
|
36
|
+
const INDEX = '/app/content/wiki/index.mdx';
|
|
37
|
+
const WIKI_C = '/app/content/wiki/c.mdx';
|
|
38
|
+
const META: Record<string, Record<string, unknown>> = {
|
|
39
|
+
[ENTRY]: { title: 'Reference entry.', render: 'safe', date: '2026-06-22' },
|
|
40
|
+
[INDEX]: { title: 'The wiki index.', render: 'safe' },
|
|
41
|
+
[WIKI_C]: { title: 'A third entry.', render: 'safe' },
|
|
42
|
+
[OTHER]: { title: 'The other entry.', render: 'safe', date: '2026-06-22' },
|
|
43
|
+
[PERSON]: { name: 'Ada', role: 'eng', team: 'core', phone: '555-1', email: 'a@b.c' },
|
|
44
|
+
'/app/content/home.mdx': { title: 'Home', render: 'safe' },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const { readFile, readdirFs } = vi.hoisted(() => ({ readFile: vi.fn(), readdirFs: vi.fn() }));
|
|
48
|
+
vi.mock('fs', async (importOriginal) => {
|
|
49
|
+
const actual = (await importOriginal()) as Record<string, unknown>;
|
|
50
|
+
return {
|
|
51
|
+
...actual,
|
|
52
|
+
default: {
|
|
53
|
+
...(actual.default as Record<string, unknown>),
|
|
54
|
+
promises: {
|
|
55
|
+
...((actual.default as { promises: Record<string, unknown> }).promises),
|
|
56
|
+
readFile: (...a: unknown[]) => readFile(...(a as [])),
|
|
57
|
+
readdir: (...a: unknown[]) => readdirFs(...(a as [])),
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
(globalThis as { __sandpackSharedFs?: unknown }).__sandpackSharedFs = {
|
|
63
|
+
promises: { readFile: (...a: unknown[]) => readFile(...(a as [])) },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const NAV = {
|
|
67
|
+
mode: 'github',
|
|
68
|
+
namespace: 'immediately-run',
|
|
69
|
+
provider: 'github',
|
|
70
|
+
repository: 'corpus',
|
|
71
|
+
ref: 'main',
|
|
72
|
+
sandboxPath: ENTRY,
|
|
73
|
+
hash: '',
|
|
74
|
+
search: '',
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** The shell slice the chrome components read (DirectoryView/PageView read the page
|
|
78
|
+
* states; GroveNav/Footer read nav; every entry link's `from` reads the route key). */
|
|
79
|
+
const shell: GroveShell = {
|
|
80
|
+
siteTitle: 'Fixture',
|
|
81
|
+
navItems: [{ key: OTHER, href: keyToHref(OTHER), label: 'The other entry.' }],
|
|
82
|
+
entryKey: ENTRY,
|
|
83
|
+
vw: 'desktop',
|
|
84
|
+
navMode: 'top',
|
|
85
|
+
writable: false,
|
|
86
|
+
openEditor: () => {},
|
|
87
|
+
editBusy: false,
|
|
88
|
+
editRefused: false,
|
|
89
|
+
editHint: '',
|
|
90
|
+
directory: { status: 'none' },
|
|
91
|
+
missing: true,
|
|
92
|
+
suggestion: OTHER,
|
|
93
|
+
mins: 0,
|
|
94
|
+
safe: true,
|
|
95
|
+
includePath: ENTRY,
|
|
96
|
+
layout: 'doc',
|
|
97
|
+
showRails: false,
|
|
98
|
+
} as unknown as GroveShell;
|
|
99
|
+
|
|
100
|
+
// ── The disk enumeration (G-CUST-3's "never a hand-list") ────────────────────
|
|
101
|
+
const COMPONENTS_DIR = join(__dirname);
|
|
102
|
+
const linkComponents = readdirSync(COMPONENTS_DIR)
|
|
103
|
+
.filter((f) => f.endsWith('.tsx') && !f.includes('.test.'))
|
|
104
|
+
.filter((f) => /<Link\b|navigate\(/.test(readFileSync(join(COMPONENTS_DIR, f), 'utf8')))
|
|
105
|
+
.map((f) => f.replace(/\.tsx$/, ''));
|
|
106
|
+
|
|
107
|
+
// Per-component minimal render context (props, and a sandboxPath when the
|
|
108
|
+
// component's content depends on which entry it renders inside).
|
|
109
|
+
const FIXTURES: Record<string, { props?: Record<string, unknown>; sandboxPath?: string }> = {
|
|
110
|
+
WikiLink: { props: { href: '/wiki/b.mdx#sec-4', children: 'the other' } },
|
|
111
|
+
Search: { props: { onClose: () => {} } },
|
|
112
|
+
Drawer: { props: { siteTitle: 'Fixture', nav: [{ key: OTHER, href: keyToHref(OTHER), label: 'Other', cur: false }], onClose: () => {} } },
|
|
113
|
+
// ChildPages lists the current entry's INDEX siblings — the current entry must
|
|
114
|
+
// be the namespace's index for the listing to have rows.
|
|
115
|
+
ChildPages: { sandboxPath: '/app/content/wiki/index.mdx' },
|
|
116
|
+
// DirectoryList reads the current entry's folder — the readdir mock answers it
|
|
117
|
+
// (see beforeEach), and the rows link to entries the index knows.
|
|
118
|
+
DirectoryList: { sandboxPath: '/app/content/wiki/index.mdx' },
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// ── Harness ──────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
function Harness({ children, record, sandboxPath }: { children: ReactNode; record: (t: FollowLinkTarget) => void; sandboxPath?: string }) {
|
|
124
|
+
return (
|
|
125
|
+
<TinkerableContext.Provider
|
|
126
|
+
value={{
|
|
127
|
+
outerHref: 'https://immediately.run/x',
|
|
128
|
+
navigationState: { ...NAV, sandboxPath: sandboxPath ?? NAV.sandboxPath },
|
|
129
|
+
routingSpec: { routes: [] } as never,
|
|
130
|
+
filesMetadata: META,
|
|
131
|
+
} as never}
|
|
132
|
+
>
|
|
133
|
+
<GroveShellContext.Provider value={shell}>
|
|
134
|
+
<NavigationPolicyContext.Provider value={record}>{children}</NavigationPolicyContext.Provider>
|
|
135
|
+
</GroveShellContext.Provider>
|
|
136
|
+
</TinkerableContext.Provider>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Render once and read the anchor hrefs on screen (each href re-rendered + clicked
|
|
141
|
+
* on its own mount — overlay components unmount after their first click). */
|
|
142
|
+
async function renderAndClickAll(
|
|
143
|
+
name: string,
|
|
144
|
+
build: () => ReactNode,
|
|
145
|
+
): Promise<{ perHref: Array<{ href: string; calls: FollowLinkTarget[]; prevented: boolean }> }> {
|
|
146
|
+
// first pass: which hrefs does it render?
|
|
147
|
+
const probe = document.createElement('div');
|
|
148
|
+
document.body.appendChild(probe);
|
|
149
|
+
const probeRoot: Root = createRoot(probe);
|
|
150
|
+
await act(async () => {
|
|
151
|
+
probeRoot.render(<Harness record={() => {}} sandboxPath={FIXTURES[name]?.sandboxPath}>{build()}</Harness>);
|
|
152
|
+
});
|
|
153
|
+
for (let i = 0; i < 8; i++) await act(async () => {});
|
|
154
|
+
// Every anchor OCCURRENCE, not every distinct href (round 2 nit): a second
|
|
155
|
+
// same-href anchor reverted to direct navigation must fail too.
|
|
156
|
+
const hrefs = [...probe.querySelectorAll('a[href]')].map((a) => a.getAttribute('href')!);
|
|
157
|
+
expect(hrefs.length, `${name} renders at least one link`).toBeGreaterThan(0);
|
|
158
|
+
await act(async () => {
|
|
159
|
+
probeRoot.unmount();
|
|
160
|
+
});
|
|
161
|
+
probe.remove();
|
|
162
|
+
|
|
163
|
+
const perHref: Array<{ href: string; calls: FollowLinkTarget[]; prevented: boolean }> = [];
|
|
164
|
+
for (let occurrence = 0; occurrence < hrefs.length; occurrence++) {
|
|
165
|
+
const href = hrefs[occurrence];
|
|
166
|
+
const ordinal = hrefs.slice(0, occurrence).filter((h) => h === href).length;
|
|
167
|
+
const calls: FollowLinkTarget[] = [];
|
|
168
|
+
const container = document.createElement('div');
|
|
169
|
+
document.body.appendChild(container);
|
|
170
|
+
const root: Root = createRoot(container);
|
|
171
|
+
await act(async () => {
|
|
172
|
+
root.render(
|
|
173
|
+
<Harness
|
|
174
|
+
record={(t) => {
|
|
175
|
+
calls.push(t);
|
|
176
|
+
}}
|
|
177
|
+
sandboxPath={FIXTURES[name]?.sandboxPath}
|
|
178
|
+
>
|
|
179
|
+
{build()}
|
|
180
|
+
</Harness>,
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
for (let i = 0; i < 8; i++) await act(async () => {});
|
|
184
|
+
const anchor = container.querySelectorAll(`a[href="${href.replace(/"/g, '\\"')}"]`)[ordinal];
|
|
185
|
+
expect(anchor, `${name}: the ${href} link still renders`).toBeTruthy();
|
|
186
|
+
let prevented = false;
|
|
187
|
+
const event = new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 });
|
|
188
|
+
const origPrevent = event.preventDefault.bind(event);
|
|
189
|
+
event.preventDefault = () => {
|
|
190
|
+
prevented = true;
|
|
191
|
+
origPrevent();
|
|
192
|
+
};
|
|
193
|
+
await act(async () => {
|
|
194
|
+
anchor!.dispatchEvent(event);
|
|
195
|
+
});
|
|
196
|
+
await act(async () => {
|
|
197
|
+
root.unmount();
|
|
198
|
+
});
|
|
199
|
+
container.remove();
|
|
200
|
+
perHref.push({ href, calls, prevented });
|
|
201
|
+
}
|
|
202
|
+
return { perHref };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
beforeEach(() => {
|
|
206
|
+
readFile.mockReset();
|
|
207
|
+
readFile.mockImplementation(async (p: unknown) =>
|
|
208
|
+
// Backlinks scans bodies for links TO the rendered entry: b's body links a.
|
|
209
|
+
String(p).endsWith('/b.mdx')
|
|
210
|
+
? '---\ntitle: B\nrender: safe\n---\n\n[the first](/wiki/a.mdx)\n'
|
|
211
|
+
: '---\ntitle: T\nrender: safe\n---\n\nplain body\n',
|
|
212
|
+
);
|
|
213
|
+
readdirFs.mockReset();
|
|
214
|
+
const dirent = (name: string, isDir = false) => ({ name, isDirectory: () => isDir });
|
|
215
|
+
readdirFs.mockImplementation(async () => [dirent('c.mdx'), dirent('assets', true)]);
|
|
216
|
+
void readdirFs;
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('G-CUST-3 — every entry link rides the navigation policy', () => {
|
|
220
|
+
it('the enumeration covers the known link components (non-vacuity)', () => {
|
|
221
|
+
for (const name of ['WikiLink', 'Backlinks', 'ChildPages', 'Directory', 'DirectoryList', 'DirectoryView', 'DocList', 'Drawer', 'GroveFooter', 'GroveNav', 'PageView', 'Search', 'Sidebar', 'Timeline']) {
|
|
222
|
+
expect(linkComponents, `${name} is enumerated`).toContain(name);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it.each(linkComponents)('%s: a plain click reaches the policy with a resolved target', async (name) => {
|
|
227
|
+
const mod = await import(`./${name}`);
|
|
228
|
+
const Component = mod.default;
|
|
229
|
+
const props = FIXTURES[name]?.props ?? {};
|
|
230
|
+
// EVERY anchor the component renders, each clicked on its own mount (an
|
|
231
|
+
// overlay's first click closes it) — the item's do-not carve-out is a second
|
|
232
|
+
// link navigating directly 'because it's chrome', and only probing the first
|
|
233
|
+
// anchor would miss exactly that.
|
|
234
|
+
const { perHref } = await renderAndClickAll(name, () => <Component {...props} />);
|
|
235
|
+
for (const { href, calls, prevented } of perHref) {
|
|
236
|
+
expect(calls.length, `${name}: the plain click on ${href} reaches the policy`).toBeGreaterThan(0);
|
|
237
|
+
const t = calls[0];
|
|
238
|
+
expect(typeof t.href, 'the target carries the concrete href').toBe('string');
|
|
239
|
+
expect(t.href.length).toBeGreaterThan(0);
|
|
240
|
+
// the DOM anchor carries the SDK's outer-URL resolution of the href the
|
|
241
|
+
// component passed; the policy receives the component-level one
|
|
242
|
+
expect(href === t.href || href.endsWith(t.href), `${name}: the DOM href ${href} resolves the policy's ${t.href}`).toBe(true);
|
|
243
|
+
expect(typeof t.key, 'the target carries the resolved key').toBe('string');
|
|
244
|
+
expect(t.key.length).toBeGreaterThan(0);
|
|
245
|
+
// §4.3's shape: fragment is absent or a bare id, never '#'-prefixed
|
|
246
|
+
if (t.fragment !== undefined) expect(t.fragment.startsWith('#'), `fragment carries no '#' (${t.fragment})`).toBe(false);
|
|
247
|
+
// `from` is the entry key when the link renders inside one (content
|
|
248
|
+
// components), absent for the chrome (nav/footer/drawer/sidebar render
|
|
249
|
+
// outside an entry) — the shape pinned, not the value guessed.
|
|
250
|
+
if (t.from !== undefined) expect(typeof t.from).toBe('string');
|
|
251
|
+
// the WikiLink fixture is fragment-bearing: the rendered href KEEPS the
|
|
252
|
+
// '#sec-4' (round 2's regression — the '#' strip corrupted hrefs), and the
|
|
253
|
+
// policy's fragment is the bare id
|
|
254
|
+
if (name === 'WikiLink') {
|
|
255
|
+
expect(href).toContain('#sec-4');
|
|
256
|
+
expect(t.fragment).toBe('sec-4');
|
|
257
|
+
}
|
|
258
|
+
expect(prevented, `${name}: the plain click on ${href} prevents the browser default`).toBe(true);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// R3-877 — the workbench delivery of the edit affordance for a read-only opener
|
|
3
|
+
// delegation: `requestEdit({ bundleFile })`, with the refusal contract pinned:
|
|
4
|
+
// `read-only` hides the affordance until the next mount announcement, `cancelled`
|
|
5
|
+
// stays silent, `forbidden` (and anything else) sets `refused`.
|
|
6
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
7
|
+
import { act, useEffect } from 'react';
|
|
8
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
9
|
+
|
|
10
|
+
import type { EditAffordance } from './useEditAffordance';
|
|
11
|
+
|
|
12
|
+
const requestEditMock = vi.fn<(t?: unknown) => Promise<void>>();
|
|
13
|
+
const invokeTaskMock = vi.fn<(a: unknown) => Promise<void>>();
|
|
14
|
+
const useMountsMock = vi.fn<() => unknown[]>();
|
|
15
|
+
|
|
16
|
+
vi.mock('@immediately-run/sdk', () => ({
|
|
17
|
+
requestEdit: (a?: unknown) => requestEditMock(a),
|
|
18
|
+
invokeTask: (a: unknown) => invokeTaskMock(a),
|
|
19
|
+
capFile: (ref: unknown, opts: unknown) => ({ ...(ref as object), ...(opts as object) }),
|
|
20
|
+
useMounts: () => useMountsMock(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
// The corpus identity comes from the real contentRoot module, driven through its
|
|
24
|
+
// own producer (R3-877 round 1, R2) — never a hand-typed shape.
|
|
25
|
+
import { getContentRoot, resetContentRoot, setContentRoot } from '../lib/contentRoot';
|
|
26
|
+
|
|
27
|
+
import { useEditAffordance } from './useEditAffordance';
|
|
28
|
+
|
|
29
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
30
|
+
|
|
31
|
+
const RO_MOUNT = { type: 'task-delegation', path: '/task/t1/dir', id: '/task/t1/dir', mode: 'ro', readerCanEdit: true };
|
|
32
|
+
|
|
33
|
+
let latest: EditAffordance | null = null;
|
|
34
|
+
function Probe() {
|
|
35
|
+
const affordance = useEditAffordance(false);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
latest = affordance;
|
|
38
|
+
});
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let container: HTMLDivElement;
|
|
43
|
+
let root: Root;
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
setContentRoot('/task/t1/dir', { mountId: '/task/t1/dir' });
|
|
47
|
+
requestEditMock.mockReset().mockResolvedValue(undefined);
|
|
48
|
+
invokeTaskMock.mockReset().mockResolvedValue(undefined);
|
|
49
|
+
useMountsMock.mockReset().mockReturnValue([RO_MOUNT]);
|
|
50
|
+
latest = null;
|
|
51
|
+
container = document.createElement('div');
|
|
52
|
+
document.body.appendChild(container);
|
|
53
|
+
root = createRoot(container);
|
|
54
|
+
act(() => root.render(<Probe />));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const rerender = () => act(() => root.render(<Probe />));
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
resetContentRoot();
|
|
61
|
+
act(() => root.unmount());
|
|
62
|
+
container.remove();
|
|
63
|
+
});
|
|
64
|
+
const open = async (key = `${getContentRoot()}plot/the-rail.mdx`) => {
|
|
65
|
+
await act(async () => {
|
|
66
|
+
latest!.openEditor(key);
|
|
67
|
+
await Promise.resolve();
|
|
68
|
+
});
|
|
69
|
+
rerender();
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
describe('useEditAffordance — the workbench delivery (R3-877)', () => {
|
|
73
|
+
it('an ro delegation offers Edit and delivers it as requestEdit({ bundleFile })', async () => {
|
|
74
|
+
expect(latest!.writable).toBe(true);
|
|
75
|
+
await open();
|
|
76
|
+
expect(requestEditMock).toHaveBeenCalledWith({ bundleFile: '/plot/the-rail.mdx' });
|
|
77
|
+
expect(invokeTaskMock).not.toHaveBeenCalled();
|
|
78
|
+
expect(latest!.refused).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('an ro delegation with `readerCanEdit: false` does not offer the control', () => {
|
|
82
|
+
useMountsMock.mockReturnValue([{ ...RO_MOUNT, readerCanEdit: false }]);
|
|
83
|
+
rerender();
|
|
84
|
+
expect(latest!.writable).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('a `read-only` refusal hides the affordance until the next mount announcement', async () => {
|
|
88
|
+
requestEditMock.mockRejectedValue(Object.assign(new Error('no'), { code: 'read-only' }));
|
|
89
|
+
await open();
|
|
90
|
+
expect(latest!.refused).toBe(false); // not a render-in-place refusal
|
|
91
|
+
expect(latest!.writable).toBe(false); // hidden…
|
|
92
|
+
useMountsMock.mockReturnValue([{ ...RO_MOUNT }]); // …until the next announcement…
|
|
93
|
+
rerender();
|
|
94
|
+
expect(latest!.writable).toBe(true); // …unlatches it.
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('a `cancelled` rejection stays silent (the reader closed the editor)', async () => {
|
|
98
|
+
requestEditMock.mockRejectedValue(Object.assign(new Error('closed'), { code: 'cancelled' }));
|
|
99
|
+
await open();
|
|
100
|
+
expect(latest!.refused).toBe(false);
|
|
101
|
+
expect(latest!.writable).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('a `forbidden` refusal sets `refused` (rendered as text in place) and never retries via edit-file', async () => {
|
|
105
|
+
requestEditMock.mockRejectedValue(Object.assign(new Error('no'), { code: 'forbidden' }));
|
|
106
|
+
await open();
|
|
107
|
+
expect(latest!.refused).toBe(true);
|
|
108
|
+
expect(invokeTaskMock).not.toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
// was withheld entirely and the wiki became read-only for the one packaging where the
|
|
8
8
|
// content is most obviously somebody's to edit.
|
|
9
9
|
//
|
|
10
|
-
// The decision is pure (`lib/editTarget`); this hook is the wiring: it reads the
|
|
11
|
-
// mount list so a role downgrade
|
|
12
|
-
//
|
|
10
|
+
// The decision is pure (`lib/editTarget`); this hook is the wiring: it reads the live
|
|
11
|
+
// mount list so a role downgrade reroutes the delivery on the next render (rw → the
|
|
12
|
+
// `edit-file` overlay; ro → the workbench under the reader's authority, R3-877) rather
|
|
13
|
+
// than producing `EROFS` on click — and a `readerCanEdit: false` hint or a `read-only`
|
|
14
|
+
// refusal hides the offer — and it hands back one `openEditor(entryKey)` the chrome
|
|
13
15
|
// calls without knowing which packaging it is in.
|
|
14
16
|
import { useCallback, useMemo, useState } from 'react';
|
|
15
17
|
import { capFile, invokeTask, requestEdit, useMounts } from '@immediately-run/sdk';
|
|
@@ -46,14 +48,28 @@ export function useEditAffordance(readOnly: boolean): EditAffordance {
|
|
|
46
48
|
|
|
47
49
|
// Read the corpus identity through the mount list's identity, so the memo re-runs when
|
|
48
50
|
// the host re-announces a mount. The root itself is latched at boot (see `contentRoot`);
|
|
49
|
-
// the MODE is not, and that is the half this hook exists to keep current.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
// the MODE is not, and that is the half this hook exists to keep current. R3-877: the
|
|
52
|
+
// delegation's live MODE now also routes the delivery (rw → the edit-file overlay,
|
|
53
|
+
// ro → the workbench under the reader's authority).
|
|
54
|
+
const corpus = useMemo(() => {
|
|
55
|
+
const mountId = getCorpusMountId();
|
|
56
|
+
const mount = mounts?.find((m) => (m.id ?? m.path) === mountId);
|
|
57
|
+
return { dispatched: isDispatched(), contentRoot: getContentRoot(), mountId, mountMode: mount?.mode ?? null };
|
|
58
|
+
}, [mounts]);
|
|
59
|
+
|
|
60
|
+
// R3-877: a `read-only` refusal (the reader cannot edit the source) hides the
|
|
61
|
+
// affordance until the next mount announcement — the hint's re-announcement is
|
|
62
|
+
// exactly what unlatches it. Reset on every mount-list change — the
|
|
63
|
+
// render-adjusted-state pattern (not an effect, which would paint one frame
|
|
64
|
+
// stale), the React-sanctioned form.
|
|
65
|
+
const [readerReadOnly, setReaderReadOnly] = useState(false);
|
|
66
|
+
const [resetFor, setResetFor] = useState(mounts);
|
|
67
|
+
if (resetFor !== mounts) {
|
|
68
|
+
setResetFor(mounts);
|
|
69
|
+
setReaderReadOnly(false);
|
|
70
|
+
}
|
|
55
71
|
|
|
56
|
-
const writable = !readOnly && corpusWritable(mounts, corpus);
|
|
72
|
+
const writable = !readOnly && !readerReadOnly && corpusWritable(mounts, corpus);
|
|
57
73
|
|
|
58
74
|
// A refusal surfaces where the affordance was offered (3.3.1, R3-608);
|
|
59
75
|
// `cancelled` — the reader closing the editor — stays silent by contract.
|
|
@@ -75,10 +91,31 @@ export function useEditAffordance(readOnly: boolean): EditAffordance {
|
|
|
75
91
|
requestEdit({ path: target.path }).catch(refusedUnlessCancelled).finally(done);
|
|
76
92
|
return;
|
|
77
93
|
}
|
|
78
|
-
// Dispatch:
|
|
79
|
-
// the
|
|
80
|
-
//
|
|
81
|
-
//
|
|
94
|
+
// Dispatch, read-only delegation: ask the workbench to open the entry's source
|
|
95
|
+
// under the reader's authority (R3-876 / APP_CUSTOMIZATION §5a). Our chroot is
|
|
96
|
+
// never upgraded and nothing is minted for us; the host needs a real gesture,
|
|
97
|
+
// which this click is. `cancelled` stays silent; `read-only` hides the
|
|
98
|
+
// affordance until the next mount announcement; anything else renders in place
|
|
99
|
+
// as text (the `refused` flag) — and is never retried through `edit-file`.
|
|
100
|
+
if (target.via === 'workbench') {
|
|
101
|
+
requestEdit({ bundleFile: target.relPath })
|
|
102
|
+
.catch((e: unknown) => {
|
|
103
|
+
const code = (e as { code?: string } | null)?.code;
|
|
104
|
+
if (code === 'cancelled') return;
|
|
105
|
+
if (code === 'read-only') {
|
|
106
|
+
setReaderReadOnly(true);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
setRefused(true);
|
|
110
|
+
})
|
|
111
|
+
.finally(done);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// Dispatch, writable delegation: attenuate the corpus delegation down to this
|
|
115
|
+
// one file and hand it to the platform editor. Nothing new is minted — we
|
|
116
|
+
// already hold the directory, and `edit-file` is one hop further along a chain
|
|
117
|
+
// §5.7.1 bounds at depth 4. The host resolves the cap against our grants, so
|
|
118
|
+
// this can only ever narrow.
|
|
82
119
|
invokeTask('edit-file', {
|
|
83
120
|
file: capFile({ mountId: target.mountId, relPath: target.relPath }, { mode: 'rw' }),
|
|
84
121
|
})
|
|
@@ -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
|
+
});
|