@immediately-run/grove 0.1.1
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 +122 -0
- package/docs/ENGINE_BOUNDARY.md +233 -0
- package/llms.txt +115 -0
- package/package.json +82 -0
- package/src/App.tsx +131 -0
- package/src/GroveApp.css +2774 -0
- package/src/GroveWiki.tsx +386 -0
- package/src/components/AssetImage.tsx +58 -0
- package/src/components/Backlinks.tsx +104 -0
- package/src/components/Callout.tsx +26 -0
- package/src/components/ChildPages.tsx +40 -0
- package/src/components/DefaultLayout.tsx +27 -0
- package/src/components/Directory.tsx +65 -0
- package/src/components/DirectoryList.test.tsx +275 -0
- package/src/components/DirectoryList.tsx +189 -0
- package/src/components/DirectoryView.tsx +68 -0
- package/src/components/DocList.tsx +109 -0
- package/src/components/DocsByTag.tsx +13 -0
- package/src/components/Drawer.tsx +45 -0
- package/src/components/EntryHeader.tsx +51 -0
- package/src/components/FamilyTree.tsx +95 -0
- package/src/components/GroveAgent.tsx +264 -0
- package/src/components/GroveFooter.tsx +20 -0
- package/src/components/GroveNav.tsx +102 -0
- package/src/components/Icon.tsx +56 -0
- package/src/components/Infobox.tsx +19 -0
- package/src/components/Kbd.tsx +10 -0
- package/src/components/KeyValue.tsx +32 -0
- package/src/components/Lede.tsx +6 -0
- package/src/components/More.tsx +10 -0
- package/src/components/Outlet.tsx +11 -0
- package/src/components/PageMeta.tsx +24 -0
- package/src/components/PageView.tsx +98 -0
- package/src/components/Quote.tsx +36 -0
- package/src/components/RecentlyUpdated.tsx +6 -0
- package/src/components/SafeEntryBody.tsx +72 -0
- package/src/components/SafeLayout.tsx +35 -0
- package/src/components/ScrollToFragment.tsx +63 -0
- package/src/components/Search.tsx +127 -0
- package/src/components/Sidebar.tsx +118 -0
- package/src/components/TableOfContents.test.tsx +163 -0
- package/src/components/TableOfContents.tsx +101 -0
- package/src/components/TagCloud.tsx +46 -0
- package/src/components/TagList.tsx +31 -0
- package/src/components/Timeline.tsx +55 -0
- package/src/components/Toc.tsx +14 -0
- package/src/components/WikiLink.tsx +112 -0
- package/src/data/themes.ts +14 -0
- package/src/devfs.d.ts +4 -0
- package/src/hooks/useContentComponents.ts +122 -0
- package/src/hooks/useCorpusMetadata.ts +43 -0
- package/src/hooks/useDirectoryListing.ts +56 -0
- package/src/hooks/useHeadings.ts +96 -0
- package/src/hooks/useOpenWikiBoot.ts +95 -0
- package/src/index.css +120 -0
- package/src/lib/compose.test.ts +92 -0
- package/src/lib/compose.ts +99 -0
- package/src/lib/content.test.ts +269 -0
- package/src/lib/content.ts +267 -0
- package/src/lib/contentRoot.ts +61 -0
- package/src/lib/corpusComponents.test.ts +101 -0
- package/src/lib/corpusComponents.ts +117 -0
- package/src/lib/corpusScan.test.ts +157 -0
- package/src/lib/corpusScan.ts +105 -0
- package/src/lib/directory.test.ts +216 -0
- package/src/lib/directory.ts +262 -0
- package/src/lib/fragment.test.ts +88 -0
- package/src/lib/fragment.ts +55 -0
- package/src/lib/frontmatter.ts +26 -0
- package/src/lib/layout.ts +84 -0
- package/src/lib/openWiki.test.ts +216 -0
- package/src/lib/openWiki.ts +84 -0
- package/src/lib/queries.test.ts +74 -0
- package/src/lib/queries.ts +84 -0
- package/src/lib/safeIntrinsics.test.tsx +99 -0
- package/src/lib/safeIntrinsics.tsx +77 -0
- package/src/lib/safeRender.test.ts +359 -0
- package/src/lib/safeSources.ts +25 -0
- package/src/lib/shell.ts +71 -0
- package/src/lib/sourceCache.test.ts +66 -0
- package/src/lib/sourceCache.ts +42 -0
- package/src/lib/tocScroll.test.ts +71 -0
- package/src/lib/tocScroll.ts +93 -0
- package/src/lib/wiki.test.ts +194 -0
- package/src/lib/wiki.ts +175 -0
- package/src/lib.ts +54 -0
- package/src/main.tsx +19 -0
- package/src/mdx.d.ts +9 -0
- package/src/mdxComponents.ts +89 -0
- package/src/test/setup.ts +19 -0
- package/viewer-manifest.schema.json +62 -0
- package/viewer.manifest.json +267 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { useCallback } from 'react';
|
|
3
|
+
import { Link, useFileMetadata, useMetadataQuery } from '@immediately-run/sdk';
|
|
4
|
+
import { contentDir, keyToHref } from '../lib/content';
|
|
5
|
+
import { queryPaths } from '../lib/wiki';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
team?: string;
|
|
9
|
+
compact?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isPerson(m: any): boolean {
|
|
13
|
+
return m && m.name && m.role && m.team && m.phone;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function Row({ path, compact }: { path: string; compact?: boolean }) {
|
|
17
|
+
const m = useFileMetadata(path) as any;
|
|
18
|
+
if (!m) return null;
|
|
19
|
+
return (
|
|
20
|
+
<tr>
|
|
21
|
+
<td style={{ fontWeight: 600 }}>
|
|
22
|
+
<Link href={keyToHref(path)}>{m.name}</Link>
|
|
23
|
+
</td>
|
|
24
|
+
<td>{m.role}</td>
|
|
25
|
+
{!compact && <td>{m.team}</td>}
|
|
26
|
+
<td className="mono">{m.phone}</td>
|
|
27
|
+
{!compact && <td className="mono">{m.email}</td>}
|
|
28
|
+
</tr>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Import-free engine component: the phone book, built from per-person entries.
|
|
33
|
+
export default function Directory({ team, compact }: Props) {
|
|
34
|
+
const queryFn = useCallback(
|
|
35
|
+
(filesMetadata: Record<string, any>) =>
|
|
36
|
+
Object.keys(filesMetadata)
|
|
37
|
+
.filter((p) => p.startsWith(contentDir()) && isPerson(filesMetadata[p]))
|
|
38
|
+
.filter((p) => !team || filesMetadata[p].team === team)
|
|
39
|
+
.sort((a, b) => (filesMetadata[a].name < filesMetadata[b].name ? -1 : 1)),
|
|
40
|
+
[team]
|
|
41
|
+
);
|
|
42
|
+
const result = useMetadataQuery(queryFn);
|
|
43
|
+
const paths: string[] = queryPaths(result);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className="grove-table-wrap">
|
|
47
|
+
<table className="grove-table">
|
|
48
|
+
<thead>
|
|
49
|
+
<tr>
|
|
50
|
+
<th>Name</th>
|
|
51
|
+
<th>Role</th>
|
|
52
|
+
{!compact && <th>Team</th>}
|
|
53
|
+
<th>Phone</th>
|
|
54
|
+
{!compact && <th>Email</th>}
|
|
55
|
+
</tr>
|
|
56
|
+
</thead>
|
|
57
|
+
<tbody>
|
|
58
|
+
{paths.map((p) => (
|
|
59
|
+
<Row key={p} path={p} compact={compact} />
|
|
60
|
+
))}
|
|
61
|
+
</tbody>
|
|
62
|
+
</table>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
import { act } from 'react';
|
|
4
|
+
import { createRoot } from 'react-dom/client';
|
|
5
|
+
import type { ReactNode } from 'react';
|
|
6
|
+
import { MDXProvider } from '@immediately-run/sdk';
|
|
7
|
+
import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
|
|
8
|
+
import { resetContentRoot, setContentRoot } from '../lib/contentRoot';
|
|
9
|
+
import { GroveShellContext, type GroveShell } from '../lib/shell';
|
|
10
|
+
|
|
11
|
+
// `useDirectoryListing` reads the real `fs` module — the one the host supplies inside the
|
|
12
|
+
// sandbox. Mocked here so the table is exercised over a known tree, and mocked at the
|
|
13
|
+
// MODULE level (not injected through a prop) because the injection point is not part of
|
|
14
|
+
// the component's contract: content writes `<DirectoryList path="…"/>` and nothing else.
|
|
15
|
+
const readdir = vi.fn();
|
|
16
|
+
vi.mock('fs', () => ({ default: { promises: { readdir: (...a: unknown[]) => readdir(...a) } } }));
|
|
17
|
+
|
|
18
|
+
const { default: DirectoryList } = await import('./DirectoryList');
|
|
19
|
+
const { default: DirectoryView } = await import('./DirectoryView');
|
|
20
|
+
|
|
21
|
+
/** The slice of the shell <DirectoryView> reads, cast in. */
|
|
22
|
+
const shell = (entryKey: string): GroveShell =>
|
|
23
|
+
({ entryKey, siteTitle: 'Meridian handbook' }) as GroveShell;
|
|
24
|
+
|
|
25
|
+
const dirent = (name: string, isDirectory = false) => ({ name, isDirectory: () => isDirectory });
|
|
26
|
+
|
|
27
|
+
const METADATA = {
|
|
28
|
+
'/app/content/handbook/onboarding.mdx': {
|
|
29
|
+
title: 'Onboarding, day one.',
|
|
30
|
+
description: 'What every new hire does in their first week.',
|
|
31
|
+
tags: ['handbook', 'ui/nav'],
|
|
32
|
+
date: '2026-06-22',
|
|
33
|
+
},
|
|
34
|
+
'/app/content/handbook/expenses.mdx': { title: 'Expenses.' },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function host(children: ReactNode, sandboxPath = '/files/content/handbook/onboarding.mdx') {
|
|
38
|
+
const value = {
|
|
39
|
+
outerHref: 'https://immediately.run/present/github/o/r/main/files/content/handbook/onboarding.mdx',
|
|
40
|
+
navigationState: { mode: 'present', namespace: 'github', provider: 'github', repository: 'o/r', ref: 'main', sandboxPath, hash: '', search: '' },
|
|
41
|
+
routingSpec: { routes: [] },
|
|
42
|
+
filesMetadata: METADATA,
|
|
43
|
+
};
|
|
44
|
+
return <TinkerableContext.Provider value={value as never}>{children}</TinkerableContext.Provider>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function render(node: ReactNode): Promise<HTMLElement> {
|
|
48
|
+
const container = document.createElement('div');
|
|
49
|
+
document.body.appendChild(container);
|
|
50
|
+
await act(async () => {
|
|
51
|
+
createRoot(container).render(node);
|
|
52
|
+
});
|
|
53
|
+
return container;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
readdir.mockReset();
|
|
58
|
+
readdir.mockResolvedValue([
|
|
59
|
+
dirent('archive', true),
|
|
60
|
+
dirent('onboarding.mdx'),
|
|
61
|
+
dirent('expenses.mdx'),
|
|
62
|
+
dirent('policy.pdf'),
|
|
63
|
+
dirent('_layout.mdx'),
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
resetContentRoot();
|
|
68
|
+
document.body.innerHTML = '';
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('<DirectoryList/>', () => {
|
|
72
|
+
it('lists the folder of the current entry when given no path', async () => {
|
|
73
|
+
const el = await render(host(<DirectoryList />));
|
|
74
|
+
expect(readdir).toHaveBeenCalledWith('/app/content/handbook', { withFileTypes: true });
|
|
75
|
+
const names = [...el.querySelectorAll('.gdir__labels')].map((n) => n.textContent);
|
|
76
|
+
// Directory first; the structural `_layout.mdx` is not a reader-facing row.
|
|
77
|
+
expect(names).toEqual([
|
|
78
|
+
'archive',
|
|
79
|
+
'Expensesexpenses.mdx',
|
|
80
|
+
'Onboarding, day oneonboarding.mdx',
|
|
81
|
+
'policy.pdf',
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('links entries and subfolders, and leaves an asset unlinked', async () => {
|
|
86
|
+
const el = await render(host(<DirectoryList />));
|
|
87
|
+
const hrefs = [...el.querySelectorAll('tbody a')].map((a) => a.getAttribute('href'));
|
|
88
|
+
expect(hrefs.some((h) => h?.endsWith('/files/content/handbook/archive'))).toBe(true);
|
|
89
|
+
expect(hrefs.some((h) => h?.endsWith('/files/content/handbook/onboarding.mdx'))).toBe(true);
|
|
90
|
+
expect(hrefs.some((h) => h?.includes('policy.pdf'))).toBe(false);
|
|
91
|
+
expect(el.querySelector('tr[data-kind="file"] .gdir__plain')?.textContent).toBe('policy.pdf');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('shows only the metadata columns the corpus supplies, and drops `ui/` tags', async () => {
|
|
95
|
+
const el = await render(host(<DirectoryList />));
|
|
96
|
+
expect([...el.querySelectorAll('th')].map((t) => t.textContent)).toEqual([
|
|
97
|
+
'Name',
|
|
98
|
+
'Description',
|
|
99
|
+
'Tags',
|
|
100
|
+
'Updated',
|
|
101
|
+
]);
|
|
102
|
+
expect([...el.querySelectorAll('.grove-tag')].map((t) => t.textContent)).toEqual(['#handbook']);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('renders a plain table over a corpus with no frontmatter at all', async () => {
|
|
106
|
+
readdir.mockResolvedValue([dirent('a.md'), dirent('b.md')]);
|
|
107
|
+
const el = await render(host(<DirectoryList path="/plain" />));
|
|
108
|
+
expect([...el.querySelectorAll('th')].map((t) => t.textContent)).toEqual(['Name']);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('resolves `path` corpus-relative with a leading slash, entry-relative without', async () => {
|
|
112
|
+
await render(host(<DirectoryList path="/teams" />));
|
|
113
|
+
expect(readdir).toHaveBeenCalledWith('/app/content/teams', { withFileTypes: true });
|
|
114
|
+
readdir.mockClear();
|
|
115
|
+
await render(host(<DirectoryList path="archive" />));
|
|
116
|
+
expect(readdir).toHaveBeenCalledWith('/app/content/handbook/archive', { withFileTypes: true });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('says so when the folder is empty rather than rendering an empty table', async () => {
|
|
120
|
+
readdir.mockResolvedValue([]);
|
|
121
|
+
const el = await render(host(<DirectoryList />));
|
|
122
|
+
expect(el.textContent).toContain('This folder is empty.');
|
|
123
|
+
expect(el.textContent).toContain('No entries, subfolders or files here yet.');
|
|
124
|
+
expect(el.querySelector('table')).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('distinguishes an EMPTY folder from one whose every child is hidden', async () => {
|
|
128
|
+
// Both produce zero rows. "This folder is empty." is false for the second, and a
|
|
129
|
+
// reader who knows `_layout.mdx` is in there goes looking for a bug that is not one.
|
|
130
|
+
readdir.mockResolvedValue([dirent('_layout.mdx'), dirent('.DS_Store')]);
|
|
131
|
+
const el = await render(host(<DirectoryList />));
|
|
132
|
+
expect(el.textContent).toContain('Nothing to show in this folder.');
|
|
133
|
+
expect(el.textContent).toContain('2 files skipped');
|
|
134
|
+
expect(el.textContent).not.toContain('This folder is empty.');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('survives an unreadable path without throwing', async () => {
|
|
138
|
+
readdir.mockRejectedValue(new Error('ENOTDIR'));
|
|
139
|
+
const el = await render(host(<DirectoryList path="/nope" />));
|
|
140
|
+
expect(el.querySelector('table')).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('reads `hidden` as a STRING attribute — the safe renderer passes literals only', async () => {
|
|
144
|
+
// Under the interpreter every attribute arrives as a string, so `hidden="false"`
|
|
145
|
+
// would switch the flag ON if truthiness were the test.
|
|
146
|
+
const off = await render(host(<DirectoryList hidden="false" />));
|
|
147
|
+
expect(off.textContent).not.toContain('_layout.mdx');
|
|
148
|
+
const on = await render(host(<DirectoryList hidden="true" />));
|
|
149
|
+
expect(on.textContent).toContain('_layout.mdx');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('is overridable through the MDX provider — the FOLDER ROUTE included', async () => {
|
|
153
|
+
// The override contract in one assertion, driven through the real <DirectoryView>:
|
|
154
|
+
// whatever the provider resolves for the name `DirectoryList` is what the folder
|
|
155
|
+
// route renders. The view LOOKS THE COMPONENT UP rather than importing it, so a
|
|
156
|
+
// replacement reaches the route too — not only MDX bodies that spell the tag out.
|
|
157
|
+
const Replacement = ({ path }: { path?: string }) => <p className="mine">listing of {path}</p>;
|
|
158
|
+
const el = await render(
|
|
159
|
+
host(
|
|
160
|
+
<MDXProvider components={{ DirectoryList: Replacement }}>
|
|
161
|
+
<GroveShellContext.Provider value={shell('/app/content/handbook')}>
|
|
162
|
+
<DirectoryView />
|
|
163
|
+
</GroveShellContext.Provider>
|
|
164
|
+
</MDXProvider>,
|
|
165
|
+
'/files/content/handbook'
|
|
166
|
+
)
|
|
167
|
+
);
|
|
168
|
+
expect(el.querySelector('.mine')?.textContent).toBe('listing of /handbook');
|
|
169
|
+
// …and the stock table is genuinely gone, not merely joined.
|
|
170
|
+
expect(el.querySelector('.grove-dirlist__table')).toBeNull();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('falls back to the built-in table when nothing overrides the name', async () => {
|
|
174
|
+
const el = await render(
|
|
175
|
+
host(
|
|
176
|
+
<GroveShellContext.Provider value={shell('/app/content/handbook')}>
|
|
177
|
+
<DirectoryView />
|
|
178
|
+
</GroveShellContext.Provider>,
|
|
179
|
+
'/files/content/handbook'
|
|
180
|
+
)
|
|
181
|
+
);
|
|
182
|
+
expect(el.querySelector('.grove-dirlist__table')).not.toBeNull();
|
|
183
|
+
expect(el.querySelector('h1')?.textContent).toContain('handbook');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ── The DISPATCH packaging ────────────────────────────────────────────────────────────
|
|
188
|
+
//
|
|
189
|
+
// A dispatched Grove renders SOMEBODY ELSE'S corpus, mounted at a host-minted chroot
|
|
190
|
+
// (`/mnt/<hash>/`), while the engine itself is loaded from Grove's own repo. Every path
|
|
191
|
+
// helper is a function of that root — which is why `contentRoot.ts` warns "call it, don't
|
|
192
|
+
// capture it": a helper that froze `/app/content/` at module scope renders the VIEWER's
|
|
193
|
+
// corpus while claiming to render yours, and corrects itself never.
|
|
194
|
+
//
|
|
195
|
+
// Directory listings touch both path spaces at once — they READ through the mount and
|
|
196
|
+
// LINK through the URL space — so they are exactly where the two can be got backwards.
|
|
197
|
+
// Verified live against a dispatched corpus on the local host (2026-08-18); these pin it.
|
|
198
|
+
describe('<DirectoryList/> under dispatch', () => {
|
|
199
|
+
const MOUNT = '/mnt/abc123def456/';
|
|
200
|
+
const CORPUS_META = {
|
|
201
|
+
'/mnt/abc123def456/context/ways_of_working.mdx': {
|
|
202
|
+
title: 'Ways of working.',
|
|
203
|
+
description: 'The engineering practices behind the product values.',
|
|
204
|
+
updated: '2026-07-11',
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
function dispatched(children: ReactNode, sandboxPath: string) {
|
|
209
|
+
setContentRoot(MOUNT);
|
|
210
|
+
const value = {
|
|
211
|
+
outerHref: `https://local.immediately.run/present/local/ns/corpus/live${sandboxPath}`,
|
|
212
|
+
navigationState: { mode: 'present', namespace: 'local', provider: 'local', repository: 'ns/corpus', ref: 'live', sandboxPath, hash: '', search: '' },
|
|
213
|
+
routingSpec: { routes: [] },
|
|
214
|
+
filesMetadata: CORPUS_META,
|
|
215
|
+
};
|
|
216
|
+
return <TinkerableContext.Provider value={value as never}>{children}</TinkerableContext.Provider>;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
beforeEach(() => {
|
|
220
|
+
readdir.mockReset();
|
|
221
|
+
readdir.mockResolvedValue([dirent('ways_of_working.mdx'), dirent('diagram.svg')]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('reads through the MOUNT, not the fork root', async () => {
|
|
225
|
+
await render(dispatched(<DirectoryList />, '/files/context'));
|
|
226
|
+
// `/app/content/context` here would be the viewer rendering its own corpus.
|
|
227
|
+
expect(readdir).toHaveBeenCalledWith('/mnt/abc123def456/context', { withFileTypes: true });
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('links in CORPUS-relative URL space — the mount path never reaches the DOM', async () => {
|
|
231
|
+
const el = await render(
|
|
232
|
+
dispatched(
|
|
233
|
+
<GroveShellContext.Provider value={shell('/mnt/abc123def456/context')}>
|
|
234
|
+
<DirectoryView />
|
|
235
|
+
</GroveShellContext.Provider>,
|
|
236
|
+
'/files/context'
|
|
237
|
+
)
|
|
238
|
+
);
|
|
239
|
+
const hrefs = [...el.querySelectorAll('tbody a')].map((a) => a.getAttribute('href'));
|
|
240
|
+
expect(hrefs).toHaveLength(1); // the asset is listed, not linked
|
|
241
|
+
expect(hrefs[0]?.endsWith('/files/context/ways_of_working.mdx')).toBe(true);
|
|
242
|
+
|
|
243
|
+
// The load-bearing assertion, and the one that would have caught the R3-268 bug this
|
|
244
|
+
// same mapping already had once: the chroot prefix is HOST knowledge. Grove can read
|
|
245
|
+
// through it but must never publish it — a `/mnt/<hash>/…` href is a URL no router
|
|
246
|
+
// resolves and a path the host's existence check misses.
|
|
247
|
+
expect(el.innerHTML).not.toContain('mnt');
|
|
248
|
+
expect(el.innerHTML).not.toContain('abc123def456');
|
|
249
|
+
expect(el.innerHTML).not.toContain('/app/');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('measures breadcrumbs from the corpus root, not from `/`', async () => {
|
|
253
|
+
const el = await render(
|
|
254
|
+
dispatched(
|
|
255
|
+
<GroveShellContext.Provider value={shell('/mnt/abc123def456/plans/ui-as-apps')}>
|
|
256
|
+
<DirectoryView />
|
|
257
|
+
</GroveShellContext.Provider>,
|
|
258
|
+
'/files/plans/ui-as-apps'
|
|
259
|
+
)
|
|
260
|
+
);
|
|
261
|
+
const crumb = el.querySelector('.crumb')?.textContent ?? '';
|
|
262
|
+
expect(crumb).toContain('plans');
|
|
263
|
+
expect(crumb).not.toContain('mnt');
|
|
264
|
+
expect(el.querySelector('h1')?.textContent).toContain('ui-as-apps');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('resolves `path` against the mounted corpus, and confines traversal to it', async () => {
|
|
268
|
+
await render(dispatched(<DirectoryList path="/specs" />, '/files/context'));
|
|
269
|
+
expect(readdir).toHaveBeenCalledWith('/mnt/abc123def456/specs', { withFileTypes: true });
|
|
270
|
+
readdir.mockClear();
|
|
271
|
+
// Foreign content authored the href; `..` must not become a readdir of the host's fs.
|
|
272
|
+
await render(dispatched(<DirectoryList path="../../.." />, '/files/context'));
|
|
273
|
+
expect(readdir).toHaveBeenCalledWith('/mnt/abc123def456', { withFileTypes: true });
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { useContext } from 'react';
|
|
3
|
+
import { Link, useAllMetadata } from '@immediately-run/sdk';
|
|
4
|
+
import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
|
|
5
|
+
import { sandboxPathToKey } from '../lib/content';
|
|
6
|
+
import {
|
|
7
|
+
buildDirectoryRows,
|
|
8
|
+
columnValue,
|
|
9
|
+
parseColumns,
|
|
10
|
+
resolveDirKey,
|
|
11
|
+
rowLabel,
|
|
12
|
+
visibleColumns,
|
|
13
|
+
type DirColumn,
|
|
14
|
+
type DirRow,
|
|
15
|
+
} from '../lib/directory';
|
|
16
|
+
import { useDirectoryListing } from '../hooks/useDirectoryListing';
|
|
17
|
+
import Icon from './Icon';
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
/** The folder to list. Corpus-relative with a leading `/` (`/handbook`), otherwise
|
|
21
|
+
* relative to the current entry's folder. Omit it to list the current folder. */
|
|
22
|
+
path?: string;
|
|
23
|
+
/** Heading above the table. `null`/`""` renders no heading. */
|
|
24
|
+
title?: string | null;
|
|
25
|
+
/** `description,tags,updated,status` — which metadata columns to consider. A column
|
|
26
|
+
* no row carries is dropped regardless, so this narrows, never pads. */
|
|
27
|
+
columns?: string;
|
|
28
|
+
sort?: 'name' | 'title' | 'updated';
|
|
29
|
+
/** Include dot- and `_`-prefixed names. */
|
|
30
|
+
hidden?: boolean | string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const HEADINGS: Record<DirColumn, string> = {
|
|
34
|
+
name: 'Name',
|
|
35
|
+
description: 'Description',
|
|
36
|
+
tags: 'Tags',
|
|
37
|
+
updated: 'Updated',
|
|
38
|
+
status: 'Status',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// `hidden="false"` is a string in MDX, and under the safe renderer EVERY attribute is a
|
|
42
|
+
// literal string — so a boolean prop that trusted truthiness would read `"false"` as on.
|
|
43
|
+
function flag(v: boolean | string | undefined): boolean {
|
|
44
|
+
return v === true || v === '' || v === 'true';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function Cell({ row, col }: { row: DirRow; col: DirColumn }) {
|
|
48
|
+
const value = columnValue(row, col);
|
|
49
|
+
if (col === 'tags') {
|
|
50
|
+
const tags = Array.isArray(value) ? value : [];
|
|
51
|
+
return (
|
|
52
|
+
<td className="gdir__tags">
|
|
53
|
+
{/* Same reason as the name cell: the flex box goes INSIDE the `<td>`. */}
|
|
54
|
+
<div className="gdir__tagbox">
|
|
55
|
+
{tags.slice(0, 4).map((t) => (
|
|
56
|
+
<span key={t} className="grove-tag">#{t}</span>
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
</td>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (col === 'updated' || col === 'status') {
|
|
63
|
+
return <td className={`mono gdir__${col}`}>{typeof value === 'string' ? value : null}</td>;
|
|
64
|
+
}
|
|
65
|
+
return <td className="gdir__desc">{typeof value === 'string' ? value : null}</td>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function Row({ row, cols }: { row: DirRow; cols: DirColumn[] }) {
|
|
69
|
+
const label = rowLabel(row);
|
|
70
|
+
return (
|
|
71
|
+
<tr data-kind={row.kind}>
|
|
72
|
+
<td className="gdir__name">
|
|
73
|
+
{/* The flex box is INSIDE the cell. `display:flex` on a `<td>` takes it out of
|
|
74
|
+
the table layout algorithm, and the column stops sharing a width with its
|
|
75
|
+
header — the symptom is per-row borders that don't line up. */}
|
|
76
|
+
<div className="gdir__namebox">
|
|
77
|
+
<span className="gdir__icon" aria-hidden="true">
|
|
78
|
+
<Icon name={row.kind === 'dir' ? 'folder' : 'file'} />
|
|
79
|
+
</span>
|
|
80
|
+
<span className="gdir__labels">
|
|
81
|
+
{row.href ? (
|
|
82
|
+
<Link href={row.href} className="gdir__link">
|
|
83
|
+
{label}
|
|
84
|
+
</Link>
|
|
85
|
+
) : (
|
|
86
|
+
// An asset is listed but not linked: Grove's route space renders entries,
|
|
87
|
+
// so a link here would resolve to the 404 it was meant to replace.
|
|
88
|
+
<span className="gdir__plain">{label}</span>
|
|
89
|
+
)}
|
|
90
|
+
{label !== row.name ? <span className="gdir__file">{row.name}</span> : null}
|
|
91
|
+
</span>
|
|
92
|
+
</div>
|
|
93
|
+
</td>
|
|
94
|
+
{cols.slice(1).map((c) => (
|
|
95
|
+
<Cell key={c} row={row} col={c} />
|
|
96
|
+
))}
|
|
97
|
+
</tr>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `<DirectoryList/>` — every file in a folder as a table, enriched with whatever Grove
|
|
103
|
+
* frontmatter its entries carry.
|
|
104
|
+
*
|
|
105
|
+
* Two callers, one component. Content uses it as ordinary import-free vocabulary
|
|
106
|
+
* (`<DirectoryList path="/handbook"/>`); the router renders it for a URL that names a
|
|
107
|
+
* FOLDER rather than an entry — the case that used to 404. Both go through the MDX
|
|
108
|
+
* component map, so a fork or a content-provided `DirectoryList` (R3-174) replaces the
|
|
109
|
+
* built-in on *both* surfaces at once. That is the point of resolving it by name rather
|
|
110
|
+
* than importing it in the router: an override that only reached MDX bodies and left the
|
|
111
|
+
* folder route rendering the stock table would be a half-override nobody could see.
|
|
112
|
+
*/
|
|
113
|
+
export default function DirectoryList({ path, title, columns, sort = 'name', hidden }: Props) {
|
|
114
|
+
const ctx = useContext(TinkerableContext) as any;
|
|
115
|
+
const routeKey = sandboxPathToKey(ctx?.navigationState?.sandboxPath || '/');
|
|
116
|
+
// The route key is a FILE when it names an entry; a listing anchored at an entry means
|
|
117
|
+
// "the folder that entry lives in", which is what an author writing `<DirectoryList/>`
|
|
118
|
+
// inside a page means by "here".
|
|
119
|
+
const fromDir = /\.mdx?$/.test(routeKey) ? routeKey.slice(0, routeKey.lastIndexOf('/')) : routeKey;
|
|
120
|
+
const dirKey = resolveDirKey(path, fromDir);
|
|
121
|
+
|
|
122
|
+
const listing = useDirectoryListing(dirKey);
|
|
123
|
+
const metadata = useAllMetadata() as Record<string, any>;
|
|
124
|
+
|
|
125
|
+
if (listing.status !== 'ready') {
|
|
126
|
+
return (
|
|
127
|
+
<div className="grove-dirlist" data-state={listing.status}>
|
|
128
|
+
<div className="sk sk-line" style={{ width: '60%' }} />
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const rows = buildDirectoryRows(dirKey, listing.entries, metadata, {
|
|
134
|
+
hidden: flag(hidden),
|
|
135
|
+
sort,
|
|
136
|
+
});
|
|
137
|
+
const cols = visibleColumns(rows, parseColumns(columns));
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div className="grove-dirlist">
|
|
141
|
+
{title ? (
|
|
142
|
+
<div className="grove-doclist__head">
|
|
143
|
+
<h2>{title}</h2>
|
|
144
|
+
<span className="n">
|
|
145
|
+
{rows.length} {rows.length === 1 ? 'item' : 'items'}
|
|
146
|
+
</span>
|
|
147
|
+
</div>
|
|
148
|
+
) : null}
|
|
149
|
+
{rows.length === 0 ? (
|
|
150
|
+
// Two different facts, said differently. A folder with nothing in it and a folder
|
|
151
|
+
// whose every child is structural (`_layout.mdx`) both produce zero rows, and
|
|
152
|
+
// "This folder is empty." is false for the second — the reader goes looking for
|
|
153
|
+
// the file they know is there. Say which it is.
|
|
154
|
+
<div className="grove-dirlist__empty">
|
|
155
|
+
<span className="gdir__icon" aria-hidden="true">
|
|
156
|
+
<Icon name="folder" />
|
|
157
|
+
</span>
|
|
158
|
+
<p className="grove-dirlist__empty-t">
|
|
159
|
+
{listing.entries.length === 0 ? 'This folder is empty.' : 'Nothing to show in this folder.'}
|
|
160
|
+
</p>
|
|
161
|
+
<p className="grove-dirlist__empty-d">
|
|
162
|
+
{listing.entries.length === 0
|
|
163
|
+
? 'No entries, subfolders or files here yet.'
|
|
164
|
+
: `Everything here is hidden or structural — ${listing.entries.length} ${listing.entries.length === 1 ? 'file' : 'files'} skipped.`}
|
|
165
|
+
</p>
|
|
166
|
+
</div>
|
|
167
|
+
) : (
|
|
168
|
+
<div className="grove-table-wrap">
|
|
169
|
+
<table className="grove-table grove-dirlist__table">
|
|
170
|
+
<thead>
|
|
171
|
+
<tr>
|
|
172
|
+
{cols.map((c) => (
|
|
173
|
+
<th key={c} scope="col" className={`gdir__th gdir__th--${c}`}>
|
|
174
|
+
{HEADINGS[c]}
|
|
175
|
+
</th>
|
|
176
|
+
))}
|
|
177
|
+
</tr>
|
|
178
|
+
</thead>
|
|
179
|
+
<tbody>
|
|
180
|
+
{rows.map((r) => (
|
|
181
|
+
<Row key={r.key} row={r} cols={cols} />
|
|
182
|
+
))}
|
|
183
|
+
</tbody>
|
|
184
|
+
</table>
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { Fragment } from 'react';
|
|
3
|
+
import type { ComponentType } from 'react';
|
|
4
|
+
import { Link, useMDXComponents } from '@immediately-run/sdk';
|
|
5
|
+
import { useShell } from '../lib/shell';
|
|
6
|
+
import { keyToHref } from '../lib/content';
|
|
7
|
+
import { dirCrumbs, dirKeyToPath } from '../lib/directory';
|
|
8
|
+
import DirectoryList from './DirectoryList';
|
|
9
|
+
import Icon from './Icon';
|
|
10
|
+
|
|
11
|
+
// Resolving the table through the MDX component map is what makes it OVERRIDABLE.
|
|
12
|
+
// `useMDXComponents({})` returns the provider's map — the SDK defaults, under Grove's
|
|
13
|
+
// vocabulary, under whatever a fork or (once R3-174 lands) the CONTENT registered. So
|
|
14
|
+
// replacing `DirectoryList` replaces it everywhere it renders, including this route,
|
|
15
|
+
// rather than only where an author typed the tag. The literal `{}` is hoisted because
|
|
16
|
+
// the SDK memoizes on the argument's identity.
|
|
17
|
+
const NO_OVERRIDES = {};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The reading view for a URL that names a FOLDER.
|
|
21
|
+
*
|
|
22
|
+
* Grove's routes are filesystem paths, so a folder is an address a reader can reach —
|
|
23
|
+
* by typing it, by trimming a URL, from the host's file explorer, or by following a
|
|
24
|
+
* link into a namespace. It used to resolve to "No entry at …", which is false: the
|
|
25
|
+
* entries exist, one level down. This renders the folder instead: breadcrumb, name, and
|
|
26
|
+
* the overridable `<DirectoryList/>`.
|
|
27
|
+
*
|
|
28
|
+
* (An author who wants a *curated* page at a folder URL writes `index.mdx` in it — the
|
|
29
|
+
* router prefers that over this view. See `folderIndexKey`.)
|
|
30
|
+
*/
|
|
31
|
+
export default function DirectoryView() {
|
|
32
|
+
const { entryKey, siteTitle } = useShell();
|
|
33
|
+
const components = useMDXComponents(NO_OVERRIDES) as Record<string, ComponentType<any> | undefined>;
|
|
34
|
+
const List = components.DirectoryList ?? DirectoryList;
|
|
35
|
+
|
|
36
|
+
const crumbs = dirCrumbs(entryKey);
|
|
37
|
+
const name = crumbs.length ? crumbs[crumbs.length - 1].label : siteTitle;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<article className="grove-page" data-layout="doc" data-view="directory">
|
|
41
|
+
<div className="gp-main">
|
|
42
|
+
<header className="grove-entry-header">
|
|
43
|
+
<nav className="crumb">
|
|
44
|
+
<Link href="/">{siteTitle}</Link>
|
|
45
|
+
{crumbs.slice(0, -1).map((c) => (
|
|
46
|
+
<Fragment key={c.key}>
|
|
47
|
+
<span aria-hidden="true">/</span>
|
|
48
|
+
<Link href={keyToHref(c.key)}>{c.label}</Link>
|
|
49
|
+
</Fragment>
|
|
50
|
+
))}
|
|
51
|
+
</nav>
|
|
52
|
+
<h1>
|
|
53
|
+
<span className="gdir__title-icon" aria-hidden="true">
|
|
54
|
+
<Icon name="folder" />
|
|
55
|
+
</span>
|
|
56
|
+
{name}
|
|
57
|
+
</h1>
|
|
58
|
+
<div className="grove-meta">
|
|
59
|
+
<span>Folder</span>
|
|
60
|
+
</div>
|
|
61
|
+
</header>
|
|
62
|
+
<div className="grove-prose">
|
|
63
|
+
<List path={dirKeyToPath(entryKey)} />
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</article>
|
|
67
|
+
);
|
|
68
|
+
}
|