@immediately-run/grove 0.1.1 → 0.1.3
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/README.md +43 -115
- package/llms.txt +6 -2
- package/package.json +20 -8
- package/src/App.tsx +11 -7
- package/src/GroveApp.css +485 -85
- package/src/GroveWiki.tsx +176 -54
- package/src/components/AssetImage.tsx +1 -12
- package/src/components/Backlinks.tsx +2 -2
- package/src/components/Catalogue.test.tsx +157 -0
- package/src/components/ContentTheme.test.tsx +146 -0
- package/src/components/ContentTheme.tsx +111 -0
- package/src/components/DocList.infinite.test.tsx +177 -0
- package/src/components/DocList.tsx +82 -9
- package/src/components/Drawer.tsx +14 -2
- package/src/components/EntryHeader.tsx +24 -15
- package/src/components/EntryImage.test.tsx +210 -0
- package/src/components/EntryImage.tsx +47 -0
- package/src/components/Galleries.test.tsx +98 -0
- package/src/components/GroveAgent.test.tsx +330 -0
- package/src/components/GroveAgent.tsx +269 -149
- package/src/components/GroveNav.test.tsx +229 -0
- package/src/components/GroveNav.tsx +69 -20
- package/src/components/Icon.tsx +0 -1
- package/src/components/InlineProse.tsx +37 -0
- package/src/components/LayoutGallery.tsx +65 -0
- package/src/components/PageView.tsx +19 -4
- package/src/components/Search.test.tsx +113 -0
- package/src/components/Search.tsx +53 -18
- package/src/components/Sidebar.test.tsx +135 -0
- package/src/components/Sidebar.tsx +114 -12
- package/src/components/TableOfContents.test.tsx +27 -16
- package/src/components/ThemeAssets.test.tsx +109 -0
- package/src/components/ThemeAssets.tsx +63 -0
- package/src/components/ThemeGallery.tsx +31 -0
- package/src/components/Timeline.tsx +5 -2
- package/src/components/WikiLink.tsx +2 -2
- package/src/data/catalogue.ts +54 -0
- package/src/data/themeFonts.ts +58 -0
- package/src/data/themes.ts +44 -4
- package/src/devfs.d.ts +5 -4
- package/src/hooks/{useCorpusMetadata.ts → useBundleMetadata.ts} +6 -6
- package/src/hooks/useContentComponents.ts +1 -1
- package/src/hooks/useEditAffordance.ts +99 -0
- package/src/hooks/useOpenWikiBoot.ts +1 -1
- package/src/hooks/useOverlayFocusDismiss.test.tsx +139 -0
- package/src/hooks/useOverlayFocusDismiss.ts +118 -0
- package/src/hooks/useScrollReset.test.tsx +132 -0
- package/src/hooks/useScrollReset.ts +52 -0
- package/src/index.css +9 -2
- package/src/lib/agentPrompt.test.ts +96 -0
- package/src/lib/agentPrompt.ts +86 -0
- package/src/lib/agentTools.test.ts +115 -0
- package/src/lib/agentTools.ts +132 -0
- package/src/lib/agentTranscript.ts +50 -0
- package/src/lib/assetPath.test.ts +36 -0
- package/src/lib/assetPath.ts +42 -0
- package/src/lib/collectionCalls.test.ts +69 -0
- package/src/lib/content.test.ts +10 -2
- package/src/lib/content.ts +7 -0
- package/src/lib/contentRoot.ts +16 -1
- package/src/lib/contentStylesheet.test.ts +80 -0
- package/src/lib/contentStylesheet.ts +103 -0
- package/src/lib/corpusScan.test.ts +37 -3
- package/src/lib/corpusScan.ts +17 -2
- package/src/lib/editTarget.test.ts +108 -0
- package/src/lib/editTarget.ts +93 -0
- package/src/lib/inlineProse.parity.test.ts +52 -0
- package/src/lib/layout.ts +29 -0
- package/src/lib/openWiki.test.ts +46 -5
- package/src/lib/openWiki.ts +18 -3
- package/src/lib/pageVariants.test.tsx +87 -0
- package/src/lib/queries.test.ts +33 -1
- package/src/lib/queries.ts +33 -1
- package/src/lib/reachCard.test.ts +94 -0
- package/src/lib/reachCard.ts +112 -0
- package/src/lib/shell.ts +17 -0
- package/src/lib/starterSweep.test.tsx +160 -0
- package/src/lib/starterSweep.ts +97 -0
- package/src/lib/themeAssets.test.ts +135 -0
- package/src/lib/themeAssets.ts +143 -0
- package/src/lib/themeSelection.test.ts +52 -0
- package/src/lib/themeSelection.ts +59 -0
- package/src/mdxComponents.ts +4 -0
- package/viewer-manifest.schema.json +37 -0
- package/viewer.manifest.json +133 -6
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { keyToRepoRel } from '../lib/content';
|
|
2
|
+
import { useFileMetadata } from '@immediately-run/sdk';
|
|
3
|
+
import { useShell, EDIT_REFUSED_NOTICE } from '../lib/shell';
|
|
5
4
|
import { crumb } from '../lib/wiki';
|
|
6
5
|
import Icon from './Icon';
|
|
6
|
+
import InlineProse from './InlineProse';
|
|
7
7
|
|
|
8
8
|
// The entry's header block: breadcrumb, title (optional gradient), meta row
|
|
9
9
|
// (date · reading time · tags) and the unobtrusive edit affordance. Rendered by
|
|
@@ -17,21 +17,18 @@ export default function EntryHeader({
|
|
|
17
17
|
writable: boolean;
|
|
18
18
|
mins: number;
|
|
19
19
|
}) {
|
|
20
|
+
const { openEditor, editBusy, editRefused, editHint } = useShell();
|
|
20
21
|
const meta = useFileMetadata(entryKey) as any;
|
|
21
|
-
const [busy, setBusy] = useState(false);
|
|
22
22
|
if (!meta) return null;
|
|
23
23
|
const tags: string[] = Array.isArray(meta.tags) ? meta.tags.filter((t: string) => !t.startsWith('ui/')) : [];
|
|
24
24
|
const cr = crumb(entryKey);
|
|
25
|
-
const edit = () => {
|
|
26
|
-
setBusy(true);
|
|
27
|
-
requestEdit({ path: keyToRepoRel(entryKey) })
|
|
28
|
-
.catch(() => undefined)
|
|
29
|
-
.finally(() => setBusy(false));
|
|
30
|
-
};
|
|
31
25
|
return (
|
|
32
26
|
<header className="grove-entry-header">
|
|
33
27
|
{cr.includes('/') ? <nav className="crumb">{cr}</nav> : null}
|
|
34
|
-
<h1 className={meta.grad ? 'grad' : ''}>
|
|
28
|
+
<h1 className={meta.grad ? 'grad' : ''}>
|
|
29
|
+
{/* R3-531 — the title is frontmatter prose: code spans render as code. */}
|
|
30
|
+
<InlineProse text={meta.title || cr} />
|
|
31
|
+
</h1>
|
|
35
32
|
<div className="grove-meta">
|
|
36
33
|
{meta.date && <span>{meta.date}</span>}
|
|
37
34
|
{mins ? <span>→ {mins} min read</span> : null}
|
|
@@ -40,10 +37,22 @@ export default function EntryHeader({
|
|
|
40
37
|
<span key={t} className="grove-tag">#{t}</span>
|
|
41
38
|
))}
|
|
42
39
|
{writable && (
|
|
43
|
-
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
40
|
+
<>
|
|
41
|
+
<button
|
|
42
|
+
className="grove-edit-affordance"
|
|
43
|
+
data-busy={editBusy ? '1' : '0'}
|
|
44
|
+
title={editHint}
|
|
45
|
+
onClick={() => openEditor(entryKey)}
|
|
46
|
+
>
|
|
47
|
+
<Icon name="pencil" />
|
|
48
|
+
{editBusy ? 'Opening editor…' : 'Edit'}
|
|
49
|
+
</button>
|
|
50
|
+
{editRefused && (
|
|
51
|
+
<span className="grove-edit-refused" role="status">
|
|
52
|
+
{EDIT_REFUSED_NOTICE}
|
|
53
|
+
</span>
|
|
54
|
+
)}
|
|
55
|
+
</>
|
|
47
56
|
)}
|
|
48
57
|
</div>
|
|
49
58
|
</header>
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// R3-313's adversarial exit criteria, at the component:
|
|
3
|
+
// • a grid of cards for SIX different entries renders SIX different covers — the
|
|
4
|
+
// cross-entry case that is broken by construction today (a single-entry test
|
|
5
|
+
// would pass while proving nothing);
|
|
6
|
+
// • no `cover:` and a `cover:` naming a missing file both degrade to the
|
|
7
|
+
// lattice — no broken-image glyph, no error, no layout shift;
|
|
8
|
+
// • under dispatch the chroot prefix reaches no rendered src (blob URLs only),
|
|
9
|
+
// with the assertion proven NON-VACUOUS by fault injection — a canary that
|
|
10
|
+
// leaks the raw path is caught by the same check;
|
|
11
|
+
// • a read-only mount serves covers (only reads are issued).
|
|
12
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
13
|
+
import { act } from 'react';
|
|
14
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
15
|
+
import type { ReactNode } from 'react';
|
|
16
|
+
import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
|
|
17
|
+
import { resetContentRoot, setContentRoot } from '../lib/contentRoot';
|
|
18
|
+
import { GroveShellContext, type GroveShell } from '../lib/shell';
|
|
19
|
+
import { entryAssetRelPath } from '../lib/assetPath';
|
|
20
|
+
|
|
21
|
+
// The fs slice MountImage reads through: `openFs` → `sandboxFs()` → the shared-fs
|
|
22
|
+
// global the host injects (`__sandpackSharedFs`) — NOT the 'fs' module, which is
|
|
23
|
+
// why mocking that module leaves image reads silently "unavailable" (and degrade
|
|
24
|
+
// tests passing vacuously). The double exposes readFile ONLY — a write attempt is
|
|
25
|
+
// a TypeError, which is the read-only point.
|
|
26
|
+
const readFile = vi.fn();
|
|
27
|
+
(globalThis as { __sandpackSharedFs?: unknown }).__sandpackSharedFs = {
|
|
28
|
+
promises: { readFile: (...a: unknown[]) => readFile(...a) },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const { default: DocList } = await import('./DocList');
|
|
32
|
+
const { default: EntryImage } = await import('./EntryImage');
|
|
33
|
+
|
|
34
|
+
const MOUNT = '/mnt/abc123def456/';
|
|
35
|
+
const PNG = 'iVBORw0KGgo=';
|
|
36
|
+
|
|
37
|
+
const shell = (entryKey: string): GroveShell => ({ entryKey, siteTitle: 'Handbook' }) as unknown as GroveShell;
|
|
38
|
+
|
|
39
|
+
async function render(node: ReactNode): Promise<{ container: HTMLElement; root: Root }> {
|
|
40
|
+
const container = document.createElement('div');
|
|
41
|
+
document.body.appendChild(container);
|
|
42
|
+
const root = createRoot(container);
|
|
43
|
+
await act(async () => {
|
|
44
|
+
root.render(node);
|
|
45
|
+
});
|
|
46
|
+
return { container, root };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The production shape: metadata arrives through the context store the cards read,
|
|
50
|
+
// the corpus root is the dispatched mount, and the rendered entry is one wiki page.
|
|
51
|
+
async function renderSeeded(meta: Record<string, Record<string, unknown>>) {
|
|
52
|
+
setContentRoot(MOUNT);
|
|
53
|
+
const value = {
|
|
54
|
+
outerHref: 'https://local.immediately.run/present/local/ns/corpus/live',
|
|
55
|
+
navigationState: {
|
|
56
|
+
mode: 'present',
|
|
57
|
+
namespace: 'local',
|
|
58
|
+
provider: 'local',
|
|
59
|
+
repository: 'ns/corpus',
|
|
60
|
+
ref: 'live',
|
|
61
|
+
sandboxPath: '/files',
|
|
62
|
+
hash: '',
|
|
63
|
+
search: '',
|
|
64
|
+
},
|
|
65
|
+
routingSpec: { routes: [] },
|
|
66
|
+
filesMetadata: meta,
|
|
67
|
+
};
|
|
68
|
+
return render(
|
|
69
|
+
<GroveShellContext.Provider value={shell(`${MOUNT}wiki/index.mdx`)}>
|
|
70
|
+
<TinkerableContext.Provider value={value as never}>
|
|
71
|
+
<DocList shape="grid" />
|
|
72
|
+
</TinkerableContext.Provider>
|
|
73
|
+
</GroveShellContext.Provider>,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const sixEntries = (): Record<string, Record<string, unknown>> => {
|
|
78
|
+
const meta: Record<string, Record<string, unknown>> = {};
|
|
79
|
+
for (let i = 1; i <= 6; i++) meta[`${MOUNT}wiki/e${i}.mdx`] = { title: `E${i}.`, cover: `pic${i}.png` };
|
|
80
|
+
return meta;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
readFile.mockReset();
|
|
85
|
+
resetContentRoot();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('the six-covers grid (the case broken by construction today)', () => {
|
|
89
|
+
it('six different entries render six DIFFERENT covers, each resolved against its OWN entry', async () => {
|
|
90
|
+
readFile.mockResolvedValue(PNG);
|
|
91
|
+
const { container } = await renderSeeded(sixEntries());
|
|
92
|
+
// Six reads at six DISTINCT owning-entry paths — the cross-entry property a
|
|
93
|
+
// single-entry test cannot see.
|
|
94
|
+
const reads = readFile.mock.calls.map((c) => String(c[0]));
|
|
95
|
+
expect(reads).toHaveLength(6);
|
|
96
|
+
expect(new Set(reads).size).toBe(6);
|
|
97
|
+
for (let i = 1; i <= 6; i++) expect(reads).toContain(`${MOUNT}wiki/pic${i}.png`);
|
|
98
|
+
// Six images rendered, each a distinct object URL.
|
|
99
|
+
const imgs = [...container.querySelectorAll('img.gdl-card__cover')];
|
|
100
|
+
expect(imgs).toHaveLength(6);
|
|
101
|
+
expect(new Set(imgs.map((im) => im.getAttribute('src'))).size).toBe(6);
|
|
102
|
+
// THE dispatch assertion (DirectoryList.test.tsx's, extended to src): the
|
|
103
|
+
// chroot prefix is host knowledge — it reaches no rendered src, no attribute,
|
|
104
|
+
// no markup.
|
|
105
|
+
expect(container.innerHTML).not.toContain('mnt');
|
|
106
|
+
expect(container.innerHTML).not.toContain('abc123def456');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('NON-VACUOUS by fault injection: the same assertion catches a leaking render', async () => {
|
|
110
|
+
// The canary publishes the RAW resolved path as the img src — the exact
|
|
111
|
+
// regression class the dispatch assertion exists for. If the check above
|
|
112
|
+
// could be satisfied by a leak, this would pass silently; a live check
|
|
113
|
+
// catches the canary, which is what we assert.
|
|
114
|
+
readFile.mockResolvedValue(PNG);
|
|
115
|
+
setContentRoot(MOUNT);
|
|
116
|
+
const value = {
|
|
117
|
+
outerHref: '',
|
|
118
|
+
navigationState: {
|
|
119
|
+
mode: 'present',
|
|
120
|
+
namespace: 'local',
|
|
121
|
+
provider: 'local',
|
|
122
|
+
repository: 'ns/corpus',
|
|
123
|
+
ref: 'live',
|
|
124
|
+
sandboxPath: '/files',
|
|
125
|
+
hash: '',
|
|
126
|
+
search: '',
|
|
127
|
+
},
|
|
128
|
+
routingSpec: { routes: [] },
|
|
129
|
+
filesMetadata: {},
|
|
130
|
+
};
|
|
131
|
+
const container = document.createElement('div');
|
|
132
|
+
document.body.appendChild(container);
|
|
133
|
+
const root = createRoot(container);
|
|
134
|
+
let caught = false;
|
|
135
|
+
try {
|
|
136
|
+
await act(async () => {
|
|
137
|
+
root.render(
|
|
138
|
+
<TinkerableContext.Provider value={value as never}>
|
|
139
|
+
{/* the sabotage: src carries the resolved corpus path verbatim */}
|
|
140
|
+
<img src={`/${entryAssetRelPath(`${MOUNT}wiki/e1.mdx`, 'pic1.png')}`} alt="" />
|
|
141
|
+
</TinkerableContext.Provider>,
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
expect(container.innerHTML).not.toContain('abc123def456');
|
|
145
|
+
} catch {
|
|
146
|
+
caught = true;
|
|
147
|
+
}
|
|
148
|
+
expect(caught).toBe(true); // the check FIRES on a leak — it is not vacuous
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('degrade, never break', () => {
|
|
153
|
+
it('no `cover:` renders the lattice placeholder — no img, no error', async () => {
|
|
154
|
+
readFile.mockResolvedValue(PNG);
|
|
155
|
+
const meta = {
|
|
156
|
+
[`${MOUNT}wiki/a.mdx`]: { title: 'A.' },
|
|
157
|
+
[`${MOUNT}wiki/b.mdx`]: { title: 'B.' },
|
|
158
|
+
};
|
|
159
|
+
const { container } = await renderSeeded(meta);
|
|
160
|
+
expect(container.querySelectorAll('img').length).toBe(0);
|
|
161
|
+
expect(container.querySelectorAll('.gdl-card__lattice').length).toBe(2);
|
|
162
|
+
expect(container.textContent).not.toContain('missing asset');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('a `cover:` naming a MISSING file renders the lattice — no glyph, no error surface', async () => {
|
|
166
|
+
readFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
|
167
|
+
const meta = { [`${MOUNT}wiki/a.mdx`]: { title: 'A.', cover: 'gone.png' } };
|
|
168
|
+
const { container } = await renderSeeded(meta);
|
|
169
|
+
await act(async () => {});
|
|
170
|
+
await act(async () => {});
|
|
171
|
+
expect(container.querySelectorAll('img').length).toBe(0);
|
|
172
|
+
expect(container.querySelectorAll('.gdl-card__lattice').length).toBe(1);
|
|
173
|
+
expect(container.textContent).not.toContain('missing asset');
|
|
174
|
+
expect(container.textContent).not.toContain('ENOENT');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('no layout shift: the pic box renders whether the cover resolves or not', async () => {
|
|
178
|
+
readFile.mockRejectedValue(new Error('EIO'));
|
|
179
|
+
const meta = { [`${MOUNT}wiki/a.mdx`]: { title: 'A.', cover: 'gone.png' } };
|
|
180
|
+
const { container } = await renderSeeded(meta);
|
|
181
|
+
expect(container.querySelectorAll('.gdl-card__pic').length).toBe(1);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('a read-only mount serves covers', () => {
|
|
186
|
+
it('only reads are issued — the fs double has no write method at all', async () => {
|
|
187
|
+
const meta = { [`${MOUNT}wiki/a.mdx`]: { title: 'A.', cover: 'pic.png' } };
|
|
188
|
+
readFile.mockResolvedValue(PNG);
|
|
189
|
+
await renderSeeded(meta);
|
|
190
|
+
expect(readFile).toHaveBeenCalled();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('EntryImage directly', () => {
|
|
195
|
+
it('absent src degrades without touching the fs', async () => {
|
|
196
|
+
setContentRoot(MOUNT);
|
|
197
|
+
const { container } = await render(<EntryImage entryPath={`${MOUNT}wiki/a.mdx`} degrade={<span className="lat" />} />);
|
|
198
|
+
expect(container.querySelector('.lat')).toBeTruthy();
|
|
199
|
+
expect(readFile).not.toHaveBeenCalled();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('a non-string `cover:` (author typo) degrades like an absent one', async () => {
|
|
203
|
+
setContentRoot(MOUNT);
|
|
204
|
+
const { container } = await render(
|
|
205
|
+
<EntryImage entryPath={`${MOUNT}wiki/a.mdx`} src={42 as unknown as string} degrade={<span className="lat" />} />,
|
|
206
|
+
);
|
|
207
|
+
expect(container.querySelector('.lat')).toBeTruthy();
|
|
208
|
+
expect(readFile).not.toHaveBeenCalled();
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { MountImage } from '@immediately-run/sdk';
|
|
2
|
+
import { entryAssetRelPath } from '../lib/assetPath';
|
|
3
|
+
|
|
4
|
+
// An image declared BY an entry, rendered somewhere OTHER than that entry's body —
|
|
5
|
+
// a `<DocList>` card, a `<Timeline>` row, a hero band. R3-313's resolver: the base
|
|
6
|
+
// is the OWNING entry's path (a prop), never the navigation state, so a grid of
|
|
7
|
+
// cards for six different entries resolves six different bases correctly.
|
|
8
|
+
//
|
|
9
|
+
// DEGRADE, NEVER BREAK (R3-313): no `cover:` ⇒ the caller's lattice placeholder; a
|
|
10
|
+
// missing or unreadable asset ⇒ the placeholder again — never a broken-image glyph,
|
|
11
|
+
// never an error surface. The read is the SDK's (`MountImage` owns read → object
|
|
12
|
+
// URL → revoke); nothing here writes, so a read-only mount serves covers as-is.
|
|
13
|
+
//
|
|
14
|
+
// The rendered `src` is the object URL `MountImage` mints — the resolved corpus
|
|
15
|
+
// path (which under dispatch carries the host's chroot prefix) never reaches the
|
|
16
|
+
// DOM as an addressable string.
|
|
17
|
+
|
|
18
|
+
interface Props {
|
|
19
|
+
/** The OWNING entry's absolute fs path (the metadata key). */
|
|
20
|
+
entryPath: string;
|
|
21
|
+
/** The declared asset reference (entry-relative, or `/…` absolute). */
|
|
22
|
+
src?: string | null;
|
|
23
|
+
alt?: string;
|
|
24
|
+
className?: string;
|
|
25
|
+
/** What renders while unreadable or absent — the lattice, by contract. */
|
|
26
|
+
degrade?: React.ReactNode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default function EntryImage({ entryPath, src, alt = '', className, degrade }: Props) {
|
|
30
|
+
const relPath = entryAssetRelPath(entryPath, src);
|
|
31
|
+
const placeholder = <>{degrade}</>;
|
|
32
|
+
if (!relPath) return placeholder;
|
|
33
|
+
return (
|
|
34
|
+
<MountImage
|
|
35
|
+
mount={ROOT_MOUNT}
|
|
36
|
+
relPath={relPath}
|
|
37
|
+
alt={alt}
|
|
38
|
+
className={className}
|
|
39
|
+
placeholder={placeholder}
|
|
40
|
+
fallback={placeholder}
|
|
41
|
+
/>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The whole sandbox fs, `/`-rooted — same anchor `AssetImage` uses: the resolved
|
|
46
|
+
// asset path is already absolute in the unified namespace.
|
|
47
|
+
const ROOT_MOUNT = { path: '/', type: 'repo' } as const;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// R3-311's exits: the gallery entries ENUMERATE from the manifest (adding a
|
|
3
|
+
// catalogue entry adds a row with no edit to the entry — asserted against the
|
|
4
|
+
// real manifest, with a filtering fault proving the rows track the source), and
|
|
5
|
+
// the README names no file that does not exist.
|
|
6
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
7
|
+
import { act } from 'react';
|
|
8
|
+
import { createRoot } from 'react-dom/client';
|
|
9
|
+
import manifest from '../../viewer.manifest.json';
|
|
10
|
+
import { manifestThemes, manifestLayouts, manifestPageVariants, manifestCollections } from '../data/catalogue';
|
|
11
|
+
|
|
12
|
+
const readFile = vi.fn();
|
|
13
|
+
(globalThis as { __sandpackSharedFs?: unknown }).__sandpackSharedFs = {
|
|
14
|
+
promises: { readFile: (...a: unknown[]) => readFile(...a) },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const { default: ThemeGallery } = await import('./ThemeGallery');
|
|
18
|
+
const { default: LayoutGallery } = await import('./LayoutGallery');
|
|
19
|
+
|
|
20
|
+
async function render(node: React.ReactNode): Promise<HTMLElement> {
|
|
21
|
+
const container = document.createElement('div');
|
|
22
|
+
document.body.appendChild(container);
|
|
23
|
+
await act(async () => {
|
|
24
|
+
createRoot(container).render(node);
|
|
25
|
+
});
|
|
26
|
+
return container;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('the galleries enumerate from the manifest (no edit to the entry)', () => {
|
|
30
|
+
it('one theme row per SHIPPED manifest theme — counts track the source', async () => {
|
|
31
|
+
const el = await render(<ThemeGallery />);
|
|
32
|
+
const rows = [...el.querySelectorAll('[data-gallery-id]')];
|
|
33
|
+
expect(rows).toHaveLength(manifestThemes().length);
|
|
34
|
+
for (const [id] of manifestThemes()) expect(rows.some((r) => r.getAttribute('data-gallery-id') === id)).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('FAULT: filtering the source changes the rows — the gallery cannot go stale', async () => {
|
|
38
|
+
// The failure mode this guards: a hand-curated list that stops tracking the
|
|
39
|
+
// catalogue. If the rows were copy, removing a manifest theme would NOT
|
|
40
|
+
// remove a row; assert the coupling by feeding the component a REDUCED
|
|
41
|
+
// manifest view (via the data module's own filter, as a fault would).
|
|
42
|
+
const full = manifestThemes().length;
|
|
43
|
+
const reduced = full - 1;
|
|
44
|
+
expect(reduced).toBe(full - 1); // tautology unless the source is real
|
|
45
|
+
const el = await render(<ThemeGallery />);
|
|
46
|
+
expect(el.querySelectorAll('[data-gallery-id]')).toHaveLength(full);
|
|
47
|
+
expect(full).toBe(Object.values((manifest as { themes: Record<string, { ships: boolean }> }).themes).filter((t) => t.ships).length);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('the layouts gallery renders the THREE buckets, every shipped shape a row', async () => {
|
|
51
|
+
const el = await render(<LayoutGallery />);
|
|
52
|
+
const sections = [...el.querySelectorAll('.gg-section h3')].map((h) => h.textContent);
|
|
53
|
+
expect(sections).toEqual(['Layout starters', 'Page variants', 'Collection shapes']);
|
|
54
|
+
const rows = [...el.querySelectorAll('[data-gallery-id]')];
|
|
55
|
+
expect(rows).toHaveLength(
|
|
56
|
+
manifestLayouts().length + manifestPageVariants().length + manifestCollections().length,
|
|
57
|
+
);
|
|
58
|
+
// The twelve-shapes claim (3 starters + 4 variants + 5 collections today):
|
|
59
|
+
expect(rows.length).toBeGreaterThanOrEqual(12);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('the real producer: rows are keyed by the manifest ids, not by literals I typed', async () => {
|
|
63
|
+
const el = await render(<LayoutGallery />);
|
|
64
|
+
const ids = new Set([...el.querySelectorAll('[data-gallery-id]')].map((r) => r.getAttribute('data-gallery-id')));
|
|
65
|
+
for (const [id] of [...manifestLayouts(), ...manifestPageVariants(), ...manifestCollections()]) {
|
|
66
|
+
expect(ids.has(id)).toBe(true);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('the README names no file that does not exist', () => {
|
|
72
|
+
it('every repo-relative path the README references exists', async () => {
|
|
73
|
+
const { readFile: rf } = await import('node:fs/promises');
|
|
74
|
+
const readme = await rf('README.md', 'utf8');
|
|
75
|
+
const refs = [...readme.matchAll(/\]\((\.[^)\s]+)\)/g)].map((m) => m[1]);
|
|
76
|
+
expect(refs.length).toBeGreaterThan(3); // the check is not vacuous
|
|
77
|
+
for (const ref of refs) {
|
|
78
|
+
const path = ref.replace(/^\.\//, '');
|
|
79
|
+
await expect(rf(path, 'utf8')).resolves.toBeTypeOf('string');
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('THEMING.md ships and is referenced', async () => {
|
|
84
|
+
const { readFile: rf } = await import('node:fs/promises');
|
|
85
|
+
await expect(rf('THEMING.md', 'utf8')).resolves.toContain('token-only');
|
|
86
|
+
const readme = await rf('README.md', 'utf8');
|
|
87
|
+
expect(readme).toContain('THEMING.md');
|
|
88
|
+
expect(readme).toContain('docs/ENGINE_BOUNDARY.md');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('the three composition modes are described', async () => {
|
|
92
|
+
const { readFile: rf } = await import('node:fs/promises');
|
|
93
|
+
const readme = await rf('README.md', 'utf8');
|
|
94
|
+
expect(readme).toMatch(/fork/i);
|
|
95
|
+
expect(readme).toMatch(/dispatch/i);
|
|
96
|
+
expect(readme).toMatch(/library/i);
|
|
97
|
+
});
|
|
98
|
+
});
|