@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.
Files changed (92) hide show
  1. package/README.md +122 -0
  2. package/docs/ENGINE_BOUNDARY.md +233 -0
  3. package/llms.txt +115 -0
  4. package/package.json +82 -0
  5. package/src/App.tsx +131 -0
  6. package/src/GroveApp.css +2774 -0
  7. package/src/GroveWiki.tsx +386 -0
  8. package/src/components/AssetImage.tsx +58 -0
  9. package/src/components/Backlinks.tsx +104 -0
  10. package/src/components/Callout.tsx +26 -0
  11. package/src/components/ChildPages.tsx +40 -0
  12. package/src/components/DefaultLayout.tsx +27 -0
  13. package/src/components/Directory.tsx +65 -0
  14. package/src/components/DirectoryList.test.tsx +275 -0
  15. package/src/components/DirectoryList.tsx +189 -0
  16. package/src/components/DirectoryView.tsx +68 -0
  17. package/src/components/DocList.tsx +109 -0
  18. package/src/components/DocsByTag.tsx +13 -0
  19. package/src/components/Drawer.tsx +45 -0
  20. package/src/components/EntryHeader.tsx +51 -0
  21. package/src/components/FamilyTree.tsx +95 -0
  22. package/src/components/GroveAgent.tsx +264 -0
  23. package/src/components/GroveFooter.tsx +20 -0
  24. package/src/components/GroveNav.tsx +102 -0
  25. package/src/components/Icon.tsx +56 -0
  26. package/src/components/Infobox.tsx +19 -0
  27. package/src/components/Kbd.tsx +10 -0
  28. package/src/components/KeyValue.tsx +32 -0
  29. package/src/components/Lede.tsx +6 -0
  30. package/src/components/More.tsx +10 -0
  31. package/src/components/Outlet.tsx +11 -0
  32. package/src/components/PageMeta.tsx +24 -0
  33. package/src/components/PageView.tsx +98 -0
  34. package/src/components/Quote.tsx +36 -0
  35. package/src/components/RecentlyUpdated.tsx +6 -0
  36. package/src/components/SafeEntryBody.tsx +72 -0
  37. package/src/components/SafeLayout.tsx +35 -0
  38. package/src/components/ScrollToFragment.tsx +63 -0
  39. package/src/components/Search.tsx +127 -0
  40. package/src/components/Sidebar.tsx +118 -0
  41. package/src/components/TableOfContents.test.tsx +163 -0
  42. package/src/components/TableOfContents.tsx +101 -0
  43. package/src/components/TagCloud.tsx +46 -0
  44. package/src/components/TagList.tsx +31 -0
  45. package/src/components/Timeline.tsx +55 -0
  46. package/src/components/Toc.tsx +14 -0
  47. package/src/components/WikiLink.tsx +112 -0
  48. package/src/data/themes.ts +14 -0
  49. package/src/devfs.d.ts +4 -0
  50. package/src/hooks/useContentComponents.ts +122 -0
  51. package/src/hooks/useCorpusMetadata.ts +43 -0
  52. package/src/hooks/useDirectoryListing.ts +56 -0
  53. package/src/hooks/useHeadings.ts +96 -0
  54. package/src/hooks/useOpenWikiBoot.ts +95 -0
  55. package/src/index.css +120 -0
  56. package/src/lib/compose.test.ts +92 -0
  57. package/src/lib/compose.ts +99 -0
  58. package/src/lib/content.test.ts +269 -0
  59. package/src/lib/content.ts +267 -0
  60. package/src/lib/contentRoot.ts +61 -0
  61. package/src/lib/corpusComponents.test.ts +101 -0
  62. package/src/lib/corpusComponents.ts +117 -0
  63. package/src/lib/corpusScan.test.ts +157 -0
  64. package/src/lib/corpusScan.ts +105 -0
  65. package/src/lib/directory.test.ts +216 -0
  66. package/src/lib/directory.ts +262 -0
  67. package/src/lib/fragment.test.ts +88 -0
  68. package/src/lib/fragment.ts +55 -0
  69. package/src/lib/frontmatter.ts +26 -0
  70. package/src/lib/layout.ts +84 -0
  71. package/src/lib/openWiki.test.ts +216 -0
  72. package/src/lib/openWiki.ts +84 -0
  73. package/src/lib/queries.test.ts +74 -0
  74. package/src/lib/queries.ts +84 -0
  75. package/src/lib/safeIntrinsics.test.tsx +99 -0
  76. package/src/lib/safeIntrinsics.tsx +77 -0
  77. package/src/lib/safeRender.test.ts +359 -0
  78. package/src/lib/safeSources.ts +25 -0
  79. package/src/lib/shell.ts +71 -0
  80. package/src/lib/sourceCache.test.ts +66 -0
  81. package/src/lib/sourceCache.ts +42 -0
  82. package/src/lib/tocScroll.test.ts +71 -0
  83. package/src/lib/tocScroll.ts +93 -0
  84. package/src/lib/wiki.test.ts +194 -0
  85. package/src/lib/wiki.ts +175 -0
  86. package/src/lib.ts +54 -0
  87. package/src/main.tsx +19 -0
  88. package/src/mdx.d.ts +9 -0
  89. package/src/mdxComponents.ts +89 -0
  90. package/src/test/setup.ts +19 -0
  91. package/viewer-manifest.schema.json +62 -0
  92. package/viewer.manifest.json +267 -0
@@ -0,0 +1,157 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { scanCorpus, listCorpusFiles, type ScanFs } from './corpusScan';
3
+ import { parseFrontmatter } from './frontmatter';
4
+
5
+ /** An in-memory tree, shaped like the fs slice the scan injects. */
6
+ function fakeFs(files: Record<string, string>, opts: { unreadable?: string[] } = {}): ScanFs {
7
+ const unreadable = new Set(opts.unreadable ?? []);
8
+ return {
9
+ async readdir(dir) {
10
+ if (unreadable.has(dir)) throw new Error('EACCES');
11
+ const names = new Map<string, boolean>(); // name → isDirectory
12
+ for (const path of Object.keys(files)) {
13
+ if (!path.startsWith(dir)) continue;
14
+ const rest = path.slice(dir.length);
15
+ const slash = rest.indexOf('/');
16
+ if (slash === -1) names.set(rest, false);
17
+ else names.set(rest.slice(0, slash), true);
18
+ }
19
+ return [...names].map(([name, isDir]) => ({ name, isDirectory: () => isDir }));
20
+ },
21
+ async readFile(path) {
22
+ if (unreadable.has(path)) throw new Error('EIO');
23
+ const hit = files[path];
24
+ if (hit === undefined) throw new Error('ENOENT');
25
+ return hit;
26
+ },
27
+ };
28
+ }
29
+
30
+ const entry = (title: string, extra = '') => `---\ntitle: "${title}"\n${extra}---\n\nbody\n`;
31
+
32
+ describe('listCorpusFiles', () => {
33
+ it('walks nested directories and returns absolute paths', async () => {
34
+ const fs = fakeFs({
35
+ '/mnt/c/home.mdx': entry('Home'),
36
+ '/mnt/c/plot/the-rail.mdx': entry('The rail'),
37
+ '/mnt/c/plot/deep/x.mdx': entry('Deep'),
38
+ '/mnt/c/notes.txt': 'not an entry',
39
+ });
40
+ expect(await listCorpusFiles('/mnt/c', fs)).toEqual([
41
+ '/mnt/c/home.mdx',
42
+ '/mnt/c/plot/deep/x.mdx',
43
+ '/mnt/c/plot/the-rail.mdx',
44
+ ]);
45
+ });
46
+
47
+ it('includes _layout.mdx — the layout chain is resolved FROM this map', async () => {
48
+ // Excluding structural files here would silently drop every layout under dispatch:
49
+ // `layoutChainForKey` finds layouts by scanning these very keys.
50
+ const fs = fakeFs({ '/mnt/c/_layout.mdx': entry('L'), '/mnt/c/home.mdx': entry('Home') });
51
+ expect(await listCorpusFiles('/mnt/c', fs)).toContain('/mnt/c/_layout.mdx');
52
+ });
53
+
54
+ it('tolerates a missing trailing slash on the root', async () => {
55
+ const fs = fakeFs({ '/mnt/c/home.mdx': entry('Home') });
56
+ expect(await listCorpusFiles('/mnt/c/', fs)).toEqual(['/mnt/c/home.mdx']);
57
+ });
58
+
59
+ it('skips .git and node_modules rather than walking a whole checkout', async () => {
60
+ const fs = fakeFs({
61
+ '/mnt/c/home.mdx': entry('Home'),
62
+ '/mnt/c/.git/config.mdx': entry('nope'),
63
+ '/mnt/c/node_modules/pkg/readme.md': entry('nope'),
64
+ });
65
+ expect(await listCorpusFiles('/mnt/c', fs)).toEqual(['/mnt/c/home.mdx']);
66
+ });
67
+
68
+ it('does not fail the whole scan on an unreadable directory', async () => {
69
+ const fs = fakeFs(
70
+ { '/mnt/c/home.mdx': entry('Home'), '/mnt/c/private/x.mdx': entry('X') },
71
+ { unreadable: ['/mnt/c/private/'] },
72
+ );
73
+ expect(await listCorpusFiles('/mnt/c', fs)).toEqual(['/mnt/c/home.mdx']);
74
+ });
75
+ });
76
+
77
+ describe('scanCorpus — the index a dispatched viewer reads', () => {
78
+ it('keys metadata by ABSOLUTE path, the same shape the bundler feeds', async () => {
79
+ // The whole design is "swap the source of the map, not the map": every consumer
80
+ // (`useFileMetadata`, `useMetadataQuery`, `layoutChainForKey`) is keyed this way.
81
+ const fs = fakeFs({
82
+ '/mnt/c/home.mdx': entry('Home', 'site: "Writers\' room"\ntags: [ui/nav]\n'),
83
+ '/mnt/c/themes.mdx': entry('Themes', 'topics: [themes, bans]\nowns:\n concepts: [the-frame]\n'),
84
+ });
85
+ const meta = await scanCorpus('/mnt/c', fs);
86
+ expect(Object.keys(meta).sort()).toEqual(['/mnt/c/home.mdx', '/mnt/c/themes.mdx']);
87
+ expect(meta['/mnt/c/home.mdx'].title).toBe('Home');
88
+ expect(meta['/mnt/c/home.mdx'].site).toBe("Writers' room");
89
+ expect(meta['/mnt/c/themes.mdx'].topics).toEqual(['themes', 'bans']);
90
+ expect(meta['/mnt/c/themes.mdx'].owns).toEqual({ concepts: ['the-frame'] });
91
+ });
92
+
93
+ it('keeps an entry with NO frontmatter, with empty metadata', async () => {
94
+ // A draft or a bare `_layout.mdx` is still part of the corpus; dropping it would make
95
+ // the file unroutable rather than merely unlabelled.
96
+ const fs = fakeFs({ '/mnt/c/raw.mdx': '# just a heading\n' });
97
+ expect(await scanCorpus('/mnt/c', fs)).toEqual({ '/mnt/c/raw.mdx': {} });
98
+ });
99
+
100
+ it('loses only the unreadable entry, never the corpus', async () => {
101
+ const fs = fakeFs(
102
+ { '/mnt/c/a.mdx': entry('A'), '/mnt/c/b.mdx': entry('B'), '/mnt/c/c.mdx': entry('C') },
103
+ { unreadable: ['/mnt/c/b.mdx'] },
104
+ );
105
+ const meta = await scanCorpus('/mnt/c', fs);
106
+ expect(Object.keys(meta).sort()).toEqual(['/mnt/c/a.mdx', '/mnt/c/c.mdx']);
107
+ });
108
+
109
+ it('reads a corpus larger than the concurrency pool', async () => {
110
+ const files: Record<string, string> = {};
111
+ for (let i = 0; i < 40; i++) files[`/mnt/c/e${i}.mdx`] = entry(`E${i}`);
112
+ const meta = await scanCorpus('/mnt/c', fakeFs(files));
113
+ expect(Object.keys(meta)).toHaveLength(40);
114
+ expect(meta['/mnt/c/e39.mdx'].title).toBe('E39');
115
+ });
116
+ });
117
+
118
+ describe('parseFrontmatter — the grammar the authoring contract documents', () => {
119
+ it('reads scalars, inline lists, block lists and one level of nesting', () => {
120
+ const { data, body } = parseFrontmatter(
121
+ [
122
+ '---',
123
+ 'title: "GLORIA REEVES — Denise\'s mother"',
124
+ 'status: draft',
125
+ 'topics: [characters, denise]',
126
+ 'reads-first:',
127
+ ' - themes.mdx',
128
+ ' - world.mdx',
129
+ 'owns:',
130
+ ' concepts: [gloria-reeves]',
131
+ 'prs: []',
132
+ '---',
133
+ '',
134
+ '# Heading',
135
+ ].join('\n'),
136
+ );
137
+ expect(data.title).toBe("GLORIA REEVES — Denise's mother");
138
+ expect(data.status).toBe('draft');
139
+ expect(data.topics).toEqual(['characters', 'denise']);
140
+ expect(data['reads-first']).toEqual(['themes.mdx', 'world.mdx']);
141
+ expect(data.owns).toEqual({ concepts: ['gloria-reeves'] });
142
+ expect(data.prs).toEqual([]);
143
+ expect(body).toBe('# Heading');
144
+ });
145
+
146
+ it('treats an unterminated block as no frontmatter, keeping the body whole', () => {
147
+ const src = '---\ntitle: X\n\n# body';
148
+ // `hadFrontmatter` distinguishes this from `---\n---` (a block WITH no keys) —
149
+ // carried by the shared parser since R3-277a, where Grove's port had dropped it.
150
+ expect(parseFrontmatter(src)).toEqual({ data: {}, body: src, hadFrontmatter: false });
151
+ });
152
+
153
+ it('does not mistake a horizontal rule mid-document for frontmatter', () => {
154
+ const src = '# Title\n\n---\n\nmore';
155
+ expect(parseFrontmatter(src).data).toEqual({});
156
+ });
157
+ });
@@ -0,0 +1,105 @@
1
+ // The viewer-side corpus scan (R3-265) — how a DISPATCHED Grove learns what its corpus
2
+ // contains.
3
+ //
4
+ // In the fork packaging the frontmatter index is bundler-fed: the sandbox scans the app's
5
+ // own MDX at build time and hands the result to `TinkerableContext.filesMetadata`, which is
6
+ // what `useMetadataQuery` / `useFileMetadata` / `useAllMetadata` read. Under dispatch the
7
+ // corpus is a MOUNT, and the bundler never saw it — so nav, sidebar, search, backlinks,
8
+ // routing and the 404 index would all be empty while the files sit right there. Nothing
9
+ // errors; the wiki is simply blank, which is the failure mode this module exists to remove.
10
+ //
11
+ // The scan produces the SAME SHAPE the bundler produces — `Record<absolutePath, metadata>`,
12
+ // keyed by the ABSOLUTE path (sandbox #41; the JSDoc that once said "repo-relative" was
13
+ // wrong) — so every consumer keeps working with no call-site change. That is the whole
14
+ // design: swap the source of the map, not the map.
15
+ //
16
+ // **The sidecar is the fast path, when there is one.** `R3-168`'s pre-computed frontmatter
17
+ // sidecar makes this scan unnecessary for a repo that publishes one. A PRIVATE repo has no
18
+ // Pages site and therefore no sidecar BY DESIGN (publishing one would publish the corpus),
19
+ // so the scan is not a fallback for the pilot — it is the only path. Wiring the sidecar as a
20
+ // fast path is additive and belongs with R3-168.
21
+
22
+ import type { Frontmatter } from './frontmatter';
23
+ import { parseFrontmatter } from './frontmatter';
24
+
25
+ /** The metadata map shape the SDK hooks read (`FilesMetadata`). */
26
+ export type CorpusMetadata = Record<string, Frontmatter>;
27
+
28
+ /** The slice of `fs.promises` the scan needs — injected, so the walk is testable without a
29
+ * sandbox and without mocking the module registry. */
30
+ export interface ScanFs {
31
+ readdir(path: string, opts: { withFileTypes: true }): Promise<Array<{ name: string; isDirectory(): boolean }>>;
32
+ readFile(path: string, encoding: 'utf8'): Promise<string>;
33
+ }
34
+
35
+ /** How many files are read at once. The sandbox fs is an RPC to the host, so a sequential
36
+ * walk of a 40-entry corpus is 40 round trips of latency; unbounded parallelism instead
37
+ * floods the channel the rest of the app shares. A small pool is the honest middle. */
38
+ const READ_CONCURRENCY = 8;
39
+
40
+ /** Entry files. `_layout.mdx` is INCLUDED deliberately: `layoutChainForKey` resolves the
41
+ * chain by looking for layout keys in this very map, so excluding structural files here
42
+ * would silently drop every layout under dispatch. Reader-facing enumerations filter with
43
+ * `isContentEntry`, which is where `_`-prefixed files are meant to disappear. */
44
+ const ENTRY_RE = /\.mdx?$/;
45
+
46
+ /** Directories never worth walking in a content mount. */
47
+ const SKIP_DIRS = new Set(['.git', 'node_modules', '.immediately.run']);
48
+
49
+ /** Every entry path under `root`, depth-first, absolute. */
50
+ export async function listCorpusFiles(root: string, fs: ScanFs, maxDepth = 12): Promise<string[]> {
51
+ const out: string[] = [];
52
+ const walk = async (dir: string, depth: number): Promise<void> => {
53
+ if (depth > maxDepth) return;
54
+ let items: Array<{ name: string; isDirectory(): boolean }>;
55
+ try {
56
+ items = await fs.readdir(dir, { withFileTypes: true });
57
+ } catch {
58
+ return; // an unreadable directory is not a corpus error — it contributes nothing
59
+ }
60
+ const dirs: string[] = [];
61
+ for (const it of items) {
62
+ if (it.isDirectory()) {
63
+ if (!SKIP_DIRS.has(it.name)) dirs.push(`${dir}${it.name}/`);
64
+ } else if (ENTRY_RE.test(it.name)) {
65
+ out.push(`${dir}${it.name}`);
66
+ }
67
+ }
68
+ for (const d of dirs) await walk(d, depth + 1);
69
+ };
70
+ await walk(root.endsWith('/') ? root : `${root}/`, 0);
71
+ return out.sort();
72
+ }
73
+
74
+ /** Read + parse a list of entries into the metadata map, bounded-concurrently. */
75
+ async function readAll(paths: string[], fs: ScanFs): Promise<CorpusMetadata> {
76
+ const meta: CorpusMetadata = {};
77
+ let next = 0;
78
+ const worker = async (): Promise<void> => {
79
+ for (;;) {
80
+ const i = next++;
81
+ if (i >= paths.length) return;
82
+ const path = paths[i];
83
+ try {
84
+ const raw = await fs.readFile(path, 'utf8');
85
+ meta[path] = parseFrontmatter(raw).data;
86
+ } catch {
87
+ // One unreadable entry must not empty the whole corpus. It is simply absent from
88
+ // the index — the same state it would be in if the author had not written it.
89
+ }
90
+ }
91
+ };
92
+ await Promise.all(Array.from({ length: Math.min(READ_CONCURRENCY, paths.length) }, worker));
93
+ return meta;
94
+ }
95
+
96
+ /**
97
+ * Build the frontmatter index for a corpus resident at `root`.
98
+ *
99
+ * Failure is per-file by design: a corpus is a foreign author's tree, and one malformed or
100
+ * unreadable entry may not take the wiki down with it.
101
+ */
102
+ export async function scanCorpus(root: string, fs: ScanFs): Promise<CorpusMetadata> {
103
+ const files = await listCorpusFiles(root, fs);
104
+ return readAll(files, fs);
105
+ }
@@ -0,0 +1,216 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import {
3
+ ALL_COLUMNS,
4
+ buildDirectoryRows,
5
+ columnValue,
6
+ dirCrumbs,
7
+ dirKeyToPath,
8
+ folderIndexKey,
9
+ isFolderKey,
10
+ parseColumns,
11
+ resolveDirKey,
12
+ rowLabel,
13
+ visibleColumns,
14
+ type DirEntry,
15
+ type DirRow,
16
+ } from './directory';
17
+ import { resetContentRoot, setContentRoot } from './contentRoot';
18
+ import type { Frontmatter } from './frontmatter';
19
+
20
+ afterEach(() => resetContentRoot());
21
+
22
+ const dir = (name: string): DirEntry => ({ name, isDirectory: true });
23
+ const file = (name: string): DirEntry => ({ name, isDirectory: false });
24
+
25
+ const ROOT = '/app/content';
26
+
27
+ describe('buildDirectoryRows', () => {
28
+ it('classifies subfolders, entries and assets — and links only the navigable ones', () => {
29
+ const rows = buildDirectoryRows(`${ROOT}/reports`, [
30
+ file('q2-2026.mdx'),
31
+ file('revenue.svg'),
32
+ dir('archive'),
33
+ ]);
34
+ expect(rows.map((r) => [r.name, r.kind, r.href])).toEqual([
35
+ ['archive', 'dir', '/content/reports/archive'],
36
+ ['q2-2026.mdx', 'entry', '/content/reports/q2-2026.mdx'],
37
+ // An asset is listed but NOT linked: the route space renders entries, so a link
38
+ // would land on the very 404 this feature exists to replace.
39
+ ['revenue.svg', 'file', null],
40
+ ]);
41
+ });
42
+
43
+ it('hides dot- and underscore-prefixed names by default, and shows them on request', () => {
44
+ const entries = [file('_layout.mdx'), file('.keep'), file('a.mdx')];
45
+ expect(buildDirectoryRows(ROOT, entries).map((r) => r.name)).toEqual(['a.mdx']);
46
+ // Asserted as a set: the relative order of `.`- and `_`-prefixed names is
47
+ // `localeCompare`'s business and varies by locale, which is not what this pins.
48
+ expect(new Set(buildDirectoryRows(ROOT, entries, {}, { hidden: true }).map((r) => r.name))).toEqual(
49
+ new Set(['.keep', '_layout.mdx', 'a.mdx'])
50
+ );
51
+ });
52
+
53
+ it('never lists repo machinery, even with hidden set', () => {
54
+ const rows = buildDirectoryRows(ROOT, [dir('node_modules'), dir('.git'), dir('teams')], {}, { hidden: true });
55
+ expect(rows.map((r) => r.name)).toEqual(['teams']);
56
+ });
57
+
58
+ it('attaches frontmatter from the index, keyed by the absolute path', () => {
59
+ const meta: Record<string, Frontmatter> = {
60
+ [`${ROOT}/handbook/onboarding.mdx`]: { title: 'Onboarding, day one.', description: 'First week.' },
61
+ };
62
+ const rows = buildDirectoryRows(`${ROOT}/handbook`, [file('onboarding.mdx'), file('expenses.mdx')], meta);
63
+ expect(rowLabel(rows[1])).toBe('Onboarding, day one'); // trailing period is house style
64
+ expect(rowLabel(rows[0])).toBe('expenses'); // no frontmatter → the bare filename
65
+ expect(rows[0].meta).toBeNull();
66
+ });
67
+
68
+ it('groups subfolders, then entries, then assets — whatever the sort key', () => {
69
+ // An asset interleaved with entries by name buries the content: on a real dispatched
70
+ // corpus `diagram.svg` sorted above every page in the folder purely on its initial.
71
+ const entries = [file('diagram.svg'), file('product_definition.mdx'), dir('sub')];
72
+ for (const sort of ['name', 'title', 'updated'] as const) {
73
+ expect(buildDirectoryRows(ROOT, entries, {}, { sort }).map((r) => r.name)).toEqual([
74
+ 'sub',
75
+ 'product_definition.mdx',
76
+ 'diagram.svg',
77
+ ]);
78
+ }
79
+ });
80
+
81
+ it('sorts within a tier by the chosen key', () => {
82
+ const meta: Record<string, Frontmatter> = {
83
+ [`${ROOT}/a.mdx`]: { title: 'Zulu.', updated: '2026-01-01' },
84
+ [`${ROOT}/b.mdx`]: { title: 'Alpha.', updated: '2026-06-01' },
85
+ };
86
+ const entries = [file('b.mdx'), file('a.mdx'), dir('sub')];
87
+ expect(buildDirectoryRows(ROOT, entries, meta, { sort: 'name' }).map((r) => r.name)).toEqual([
88
+ 'sub',
89
+ 'a.mdx',
90
+ 'b.mdx',
91
+ ]);
92
+ expect(buildDirectoryRows(ROOT, entries, meta, { sort: 'title' }).map((r) => r.name)).toEqual([
93
+ 'sub',
94
+ 'b.mdx',
95
+ 'a.mdx',
96
+ ]);
97
+ expect(buildDirectoryRows(ROOT, entries, meta, { sort: 'updated' }).map((r) => r.name)).toEqual([
98
+ 'sub',
99
+ 'b.mdx',
100
+ 'a.mdx',
101
+ ]);
102
+ });
103
+
104
+ it('builds corpus-relative hrefs under dispatch, where the mount root IS the corpus root', () => {
105
+ setContentRoot('/mnt/abc123/');
106
+ const rows = buildDirectoryRows('/mnt/abc123/context', [file('threat_model.mdx')]);
107
+ // Corpus-relative — the mount root IS the corpus root under dispatch, so the
108
+ // subfolder stays in the href and `/app` never appears.
109
+ expect(rows[0].href).toBe('/context/threat_model.mdx');
110
+ });
111
+ });
112
+
113
+ describe('columnValue / visibleColumns', () => {
114
+ const row = (meta: Frontmatter | null): DirRow => ({
115
+ name: 'x.mdx',
116
+ key: `${ROOT}/x.mdx`,
117
+ kind: 'entry',
118
+ href: '/content/x.mdx',
119
+ meta,
120
+ });
121
+
122
+ it('reads `updated` under either spelling — corpora in the wild use both', () => {
123
+ expect(columnValue(row({ updated: '2026-08-18' }), 'updated')).toBe('2026-08-18');
124
+ expect(columnValue(row({ date: '2026-06-22' }), 'updated')).toBe('2026-06-22');
125
+ expect(columnValue(row({}), 'updated')).toBeNull();
126
+ });
127
+
128
+ it('drops `ui/…` wiring tags, exactly as <DocList> does', () => {
129
+ expect(columnValue(row({ tags: ['handbook', 'ui/nav'] }), 'tags')).toEqual(['handbook']);
130
+ expect(columnValue(row({ tags: ['ui/nav'] }), 'tags')).toBeNull();
131
+ });
132
+
133
+ it('refuses to print a non-scalar into a cell', () => {
134
+ // `owns:` is a map; String()-ing it renders "[object Object]", which looks like data.
135
+ expect(columnValue(row({ description: { concepts: ['a'] } }), 'description')).toBeNull();
136
+ });
137
+
138
+ it('shows only the metadata columns the corpus actually supplies', () => {
139
+ const bare = [row({}), row(null)];
140
+ expect(visibleColumns(bare)).toEqual(['name']);
141
+ const rich = [row({ description: 'd' }), row({ tags: ['t'], status: 'draft' })];
142
+ expect(visibleColumns(rich)).toEqual(['name', 'description', 'tags', 'status']);
143
+ });
144
+
145
+ it('narrows to a requested subset, but still never pads', () => {
146
+ const rows = [row({ description: 'd', tags: ['t'] })];
147
+ expect(visibleColumns(rows, parseColumns('tags'))).toEqual(['name', 'tags']);
148
+ expect(visibleColumns(rows, parseColumns('status'))).toEqual(['name']);
149
+ });
150
+
151
+ it('ignores unknown column names rather than throwing on author input', () => {
152
+ expect(parseColumns('description,nonsense')).toEqual(['description']);
153
+ expect(parseColumns('nonsense')).toEqual(ALL_COLUMNS);
154
+ expect(parseColumns(undefined)).toEqual(ALL_COLUMNS);
155
+ });
156
+ });
157
+
158
+ describe('resolveDirKey', () => {
159
+ it('treats a leading slash as corpus-relative and everything else as entry-relative', () => {
160
+ expect(resolveDirKey('/handbook', `${ROOT}/reports`)).toBe(`${ROOT}/handbook`);
161
+ expect(resolveDirKey('archive', `${ROOT}/reports`)).toBe(`${ROOT}/reports/archive`);
162
+ expect(resolveDirKey(undefined, `${ROOT}/reports`)).toBe(`${ROOT}/reports`);
163
+ });
164
+
165
+ it('confines traversal to the corpus — a `path` is untrusted input', () => {
166
+ // Under dispatch the corpus is foreign content and the address bar is user-typed,
167
+ // so `..` must not become a readdir of somebody else's mount.
168
+ expect(resolveDirKey('../../..', `${ROOT}/reports`)).toBe(ROOT);
169
+ expect(resolveDirKey('/../../app/src', ROOT)).toBe(ROOT);
170
+ expect(resolveDirKey('a/../b', ROOT)).toBe(`${ROOT}/b`);
171
+ });
172
+
173
+ it('round-trips through dirKeyToPath', () => {
174
+ expect(dirKeyToPath(`${ROOT}/handbook`)).toBe('/handbook');
175
+ expect(dirKeyToPath(ROOT)).toBe('/');
176
+ expect(resolveDirKey(dirKeyToPath(`${ROOT}/a/b`), ROOT)).toBe(`${ROOT}/a/b`);
177
+ });
178
+ });
179
+
180
+ describe('dirCrumbs', () => {
181
+ it('names every ancestor between the corpus root and the folder', () => {
182
+ expect(dirCrumbs(`${ROOT}/plans/ui-as-apps`)).toEqual([
183
+ { label: 'plans', key: `${ROOT}/plans` },
184
+ { label: 'ui-as-apps', key: `${ROOT}/plans/ui-as-apps` },
185
+ ]);
186
+ expect(dirCrumbs(ROOT)).toEqual([]);
187
+ });
188
+ });
189
+
190
+ describe('folderIndexKey', () => {
191
+ it("prefers the author's curated folder index over a generated listing", () => {
192
+ const keys = [`${ROOT}/roadmap/index.mdx`, `${ROOT}/roadmap/R3-1.mdx`];
193
+ expect(folderIndexKey(`${ROOT}/roadmap`, keys)).toBe(`${ROOT}/roadmap/index.mdx`);
194
+ expect(folderIndexKey(`${ROOT}/roadmap/`, keys)).toBe(`${ROOT}/roadmap/index.mdx`);
195
+ });
196
+
197
+ it('returns null when there is none, so the listing is the answer', () => {
198
+ expect(folderIndexKey(`${ROOT}/people`, [`${ROOT}/people/ada.mdx`])).toBeNull();
199
+ });
200
+ });
201
+
202
+ describe('isFolderKey', () => {
203
+ const keys = [`${ROOT}/handbook/onboarding.mdx`, `${ROOT}/about.mdx`];
204
+
205
+ it('recognises a folder from the entry index alone — no fs call per link', () => {
206
+ expect(isFolderKey(`${ROOT}/handbook`, keys)).toBe(true);
207
+ expect(isFolderKey(`${ROOT}/handbook/`, keys)).toBe(true);
208
+ });
209
+
210
+ it('is false for an entry, for a non-existent path, and for a prefix that is not a segment', () => {
211
+ expect(isFolderKey(`${ROOT}/about.mdx`, keys)).toBe(false);
212
+ expect(isFolderKey(`${ROOT}/nope`, keys)).toBe(false);
213
+ // `handbo` is a string prefix of `handbook/…` but not a path segment of it.
214
+ expect(isFolderKey(`${ROOT}/handbo`, keys)).toBe(false);
215
+ });
216
+ });