@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,92 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ ManifestOverrideError,
4
+ VIEWER_MANIFEST,
5
+ composeComponents,
6
+ manifestNames,
7
+ overridableNames,
8
+ } from './compose';
9
+
10
+ // PLATFORM_LAYERING_SPEC §1.1 M3 — library composition. The manifest is the whole override
11
+ // surface, and R3-280's exit says an override of an unmanifested component must fail
12
+ // VISIBLY, not silently. These pin that, because silent is the easy accident: `{...base,
13
+ // ...overrides}` accepts anything.
14
+
15
+ const Stock = () => null;
16
+ const Custom = () => null;
17
+ const base = { Callout: Stock, Toc: Stock, Outlet: Stock } as Record<string, unknown>;
18
+
19
+ describe('composeComponents — the manifest IS the override surface', () => {
20
+ it('applies an override of a declared, overridable component', () => {
21
+ const composed = composeComponents(base, { Callout: Custom });
22
+ expect(composed.Callout).toBe(Custom);
23
+ expect(composed.Toc).toBe(Stock);
24
+ });
25
+
26
+ it('leaves the base untouched when there are no overrides', () => {
27
+ expect(composeComponents(base)).toEqual(base);
28
+ });
29
+
30
+ it('THROWS on a component that is not in the manifest — internals stay internal', () => {
31
+ // The failure a silent merge would produce: a shell ships `SafeEntryBody: Custom`,
32
+ // nothing happens, and nobody learns why until someone reads the render path.
33
+ expect(() => composeComponents(base, { SafeEntryBody: Custom })).toThrow(
34
+ ManifestOverrideError,
35
+ );
36
+ expect(() => composeComponents(base, { Nonexistent: Custom })).toThrow(
37
+ /not in the manifest: Nonexistent/,
38
+ );
39
+ });
40
+
41
+ it('THROWS on a declared-but-locked component', () => {
42
+ // Outlet is the standing case: replacing it detaches every layout layer from the page
43
+ // it wraps, and the symptom is a blank body rather than an error.
44
+ expect(() => composeComponents(base, { Outlet: Custom })).toThrow(
45
+ /declared but not overridable: Outlet/,
46
+ );
47
+ });
48
+
49
+ it('reports EVERY offending name at once, not one per rebuild', () => {
50
+ try {
51
+ composeComponents(base, { Nope: Custom, AlsoNope: Custom, Outlet: Custom });
52
+ throw new Error('expected a throw');
53
+ } catch (e) {
54
+ expect(e).toBeInstanceOf(ManifestOverrideError);
55
+ expect((e as ManifestOverrideError).names.sort()).toEqual(
56
+ ['AlsoNope', 'Nope', 'Outlet'].sort(),
57
+ );
58
+ }
59
+ });
60
+
61
+ it('names the available surface in the message, so the fix is in the error', () => {
62
+ expect(() => composeComponents(base, { Nope: Custom })).toThrow(/declared: .*Callout/);
63
+ });
64
+ });
65
+
66
+ describe('the manifest itself', () => {
67
+ it('declares a non-empty, strictly smaller overridable surface', () => {
68
+ expect(manifestNames().length).toBeGreaterThan(0);
69
+ expect(overridableNames().length).toBeGreaterThan(0);
70
+ // If everything were overridable the `overridable` flag would be decoration.
71
+ expect(overridableNames().length).toBeLessThan(manifestNames().length);
72
+ });
73
+
74
+ it('carries the viewer identity a shell resolves the contract by', () => {
75
+ expect(VIEWER_MANIFEST.viewer.name).toBe('@immediately-run/grove');
76
+ expect(VIEWER_MANIFEST.viewer.task).toBe('open-wiki');
77
+ });
78
+
79
+ it('declares the engine frontmatter keys it reads, and stays open to the rest', () => {
80
+ // Pass-through is what keeps a foreign corpus with its own vocabulary renderable
81
+ // (PLATFORM_LAYERING §5 target 1).
82
+ expect(VIEWER_MANIFEST.frontmatter?.engine).toContain('layout');
83
+ expect(VIEWER_MANIFEST.frontmatter?.passThrough).toBe(true);
84
+ });
85
+
86
+ it('gives every component a tier and an overridable flag', () => {
87
+ for (const [name, c] of Object.entries(VIEWER_MANIFEST.components)) {
88
+ expect(['engine', 'chrome', 'corpus'], name).toContain(c.tier);
89
+ expect(typeof c.overridable, name).toBe('boolean');
90
+ }
91
+ });
92
+ });
@@ -0,0 +1,99 @@
1
+ // Library composition (PLATFORM_LAYERING_SPEC §1.1 mode M3): a thin shell imports the
2
+ // engine as a pinned dependency and overrides components through its DECLARED surface,
3
+ // without forking engine source.
4
+ //
5
+ // The manifest is the whole contract. A component named in it is part of the engine's
6
+ // public vocabulary; anything else is internal, and internal means internal — a shell that
7
+ // overrides an unmanifested name gets an error, not a silent no-op. That asymmetry is the
8
+ // point: a silent no-op is how a shell ends up shipping an override nobody notices stopped
9
+ // working when the engine renames a component, which is exactly the drift ENGINE_BOUNDARY
10
+ // §4 records happening once already.
11
+ //
12
+ // This file exports no components, so it is exempt from the Fast-Refresh rule.
13
+
14
+ import manifest from '../../viewer.manifest.json';
15
+
16
+ /** One component's declared contract. Mirrors `viewer-manifest.schema.json`. */
17
+ export interface ManifestComponent {
18
+ tier: 'engine' | 'chrome' | 'corpus';
19
+ overridable: boolean;
20
+ sanitizing?: boolean;
21
+ props?: Record<string, string>;
22
+ summary?: string;
23
+ }
24
+
25
+ export interface ViewerManifest {
26
+ schemaVersion: number;
27
+ viewer: { name: string; kind?: string; task?: string };
28
+ components: Record<string, ManifestComponent>;
29
+ frontmatter?: { engine?: string[]; corpusTooling?: string[]; passThrough?: boolean };
30
+ }
31
+
32
+ export const VIEWER_MANIFEST = manifest as ViewerManifest;
33
+
34
+ /** Every declared component name, in manifest order. */
35
+ export const manifestNames = (): string[] => Object.keys(VIEWER_MANIFEST.components);
36
+
37
+ /** The names a composing shell may replace (declared AND `overridable`). */
38
+ export const overridableNames = (): string[] =>
39
+ manifestNames().filter((n) => VIEWER_MANIFEST.components[n].overridable);
40
+
41
+ /** Thrown when a shell's override map does not match the declared surface. Carries the
42
+ * offending names so the message can say which, not just that something was wrong. */
43
+ export class ManifestOverrideError extends Error {
44
+ /** The offending names, so a caller can act on them rather than re-parse the message. */
45
+ readonly names: string[];
46
+
47
+ constructor(message: string, names: string[]) {
48
+ super(message);
49
+ this.name = 'ManifestOverrideError';
50
+ this.names = names;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Merge a shell's overrides over a base component map, enforcing the manifest.
56
+ *
57
+ * Two rejections, both loud:
58
+ * - **not declared** — the name is not in the manifest at all (a typo, or an internal
59
+ * component the shell has no business replacing);
60
+ * - **not overridable** — the name is declared but its behaviour is engine mechanics
61
+ * (`Outlet` is the standing case: replacing it detaches every layout layer from the
62
+ * page it wraps, and the failure would show up as a blank body, not as an error).
63
+ *
64
+ * Both are collected before throwing, so a shell fixing several typos sees all of them in
65
+ * one run rather than one per rebuild.
66
+ */
67
+ export function composeComponents<T extends Record<string, unknown>>(
68
+ base: T,
69
+ overrides: Record<string, unknown> = {},
70
+ ): T {
71
+ const undeclared: string[] = [];
72
+ const locked: string[] = [];
73
+ for (const name of Object.keys(overrides)) {
74
+ const declared = VIEWER_MANIFEST.components[name];
75
+ if (!declared) undeclared.push(name);
76
+ else if (!declared.overridable) locked.push(name);
77
+ }
78
+ if (undeclared.length || locked.length) {
79
+ const parts: string[] = [];
80
+ if (undeclared.length) {
81
+ parts.push(
82
+ `not in the manifest: ${undeclared.join(', ')} — these are engine internals, not ` +
83
+ `part of the override surface (declared: ${overridableNames().join(', ')})`,
84
+ );
85
+ }
86
+ if (locked.length) {
87
+ parts.push(
88
+ `declared but not overridable: ${locked.join(', ')} — their behaviour is engine ` +
89
+ `mechanics, and replacing them breaks rendering in ways that surface as blank ` +
90
+ `output rather than as an error`,
91
+ );
92
+ }
93
+ throw new ManifestOverrideError(
94
+ `${VIEWER_MANIFEST.viewer.name}: cannot override ${parts.join('; ')}.`,
95
+ [...undeclared, ...locked],
96
+ );
97
+ }
98
+ return { ...base, ...overrides };
99
+ }
@@ -0,0 +1,269 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { hrefKeyCandidates, hrefTargetKey, keyToHref, linkKind, splitFragment } from './content';
3
+
4
+ // `hrefKeyCandidates` is the runtime half of the corpus's link contract: an author writes
5
+ // links RELATIVE to the entry they sit in, and `scripts/lib/wiki.mjs` `contentResolve`
6
+ // (which `check-docs-wiki` audits `[[…]]` links by) resolves them that way. Before this,
7
+ // `<WikiLink>` only recognised absolute `/content/…` hrefs, so every relative markdown link
8
+ // fell through to a plain `<a>`; clicking one made the sandboxed iframe attempt a real
9
+ // navigation and the app died with "Failed to construct 'URL': Invalid URL". These cases
10
+ // are taken from the corpus as it actually is.
11
+
12
+ const HOME = '/app/content/home.mdx';
13
+
14
+ describe('hrefKeyCandidates — relative hrefs resolve against the current entry', () => {
15
+ it('resolves the home page links that crashed the app', () => {
16
+ // content/home.mdx line 31 — the reported bug.
17
+ expect(hrefKeyCandidates('roadmap/index.mdx', HOME)).toEqual(['/app/content/roadmap/index.mdx']);
18
+ expect(hrefKeyCandidates('specs/index.mdx', HOME)).toEqual(['/app/content/specs/index.mdx']);
19
+ expect(hrefKeyCandidates('roadmap/archive/index.mdx', HOME)).toEqual([
20
+ '/app/content/roadmap/archive/index.mdx',
21
+ ]);
22
+ });
23
+
24
+ it('resolves sibling links inside a subdirectory', () => {
25
+ // context/ways_of_working.mdx -> product_definition.md
26
+ expect(hrefKeyCandidates('product_definition.md', '/app/content/context/ways_of_working.mdx')[0]).toBe(
27
+ '/app/content/context/product_definition.md'
28
+ );
29
+ });
30
+
31
+ it('walks ../ hops', () => {
32
+ expect(hrefKeyCandidates('../specs/PERSISTENCE_SPEC.mdx', '/app/content/roadmap/R3-1.mdx')).toEqual([
33
+ '/app/content/specs/PERSISTENCE_SPEC.mdx',
34
+ ]);
35
+ expect(
36
+ hrefKeyCandidates('../../context/ways_of_working.mdx', '/app/content/roadmap/archive/R3-1.mdx')
37
+ ).toEqual(['/app/content/context/ways_of_working.mdx']);
38
+ });
39
+
40
+ it('still accepts the absolute forms it always did', () => {
41
+ expect(hrefKeyCandidates('/content/roadmap/index.mdx', HOME)).toEqual([
42
+ '/app/content/roadmap/index.mdx',
43
+ ]);
44
+ expect(hrefKeyCandidates('/files/content/roadmap/index.mdx', HOME)).toEqual([
45
+ '/app/content/roadmap/index.mdx',
46
+ ]);
47
+ });
48
+ });
49
+
50
+ describe('hrefKeyCandidates — the .md/.mdx split the cutover left behind', () => {
51
+ it('offers the .mdx variant second, so a pre-cutover .md target still resolves', () => {
52
+ // ~460 corpus links name `foo.md` whose entry is now `foo.mdx`.
53
+ expect(hrefKeyCandidates('product_values.md', '/app/content/context/product_definition.mdx')).toEqual([
54
+ '/app/content/context/product_values.md',
55
+ '/app/content/context/product_values.mdx',
56
+ ]);
57
+ });
58
+
59
+ it('offers the literal first, so the genuine GROVE.md entry is not rewritten away', () => {
60
+ // content/GROVE.md really is a .md file; a blanket .md->.mdx rewrite would break it.
61
+ const c = hrefKeyCandidates('GROVE.md', HOME);
62
+ expect(c[0]).toBe('/app/content/GROVE.md');
63
+ expect(c).toContain('/app/content/GROVE.mdx');
64
+ });
65
+
66
+ it('does not invent a variant for an .mdx target', () => {
67
+ expect(hrefKeyCandidates('roadmap/index.mdx', HOME)).toHaveLength(1);
68
+ });
69
+ });
70
+
71
+ describe('hrefKeyCandidates — what is deliberately NOT a content link', () => {
72
+ it('ignores external, mail and anchor hrefs', () => {
73
+ for (const h of ['https://github.com/x/y', 'http://example.com', 'mailto:a@b.c', 'tel:+1', '#sec-8-9']) {
74
+ expect(hrefKeyCandidates(h, HOME)).toEqual([]);
75
+ }
76
+ });
77
+
78
+ it('ignores non-entry targets (scripts, directories, retired trees)', () => {
79
+ // Real corpus cases that must stay ordinary <a>, not wiki-links.
80
+ expect(hrefKeyCandidates('../snippets/dualRead.mjs', '/app/content/context/x.mdx')).toEqual([]);
81
+ expect(hrefKeyCandidates('../specs/', '/app/content/context/x.mdx')).toEqual([]);
82
+ expect(hrefKeyCandidates('', HOME)).toEqual([]);
83
+ });
84
+
85
+ it('never escapes the content tree', () => {
86
+ // A traversal that climbs out resolves to something outside /app/content and so is
87
+ // not an entry key — it must not become a link into the app.
88
+ expect(hrefKeyCandidates('../../../../etc/passwd', HOME)).toEqual([]);
89
+ });
90
+ });
91
+
92
+ describe('fragments are carried, not resolved', () => {
93
+ it('splits on the first # only', () => {
94
+ expect(splitFragment('FOO.mdx#sec-8-9')).toEqual(['FOO.mdx', '#sec-8-9']);
95
+ expect(splitFragment('FOO.mdx')).toEqual(['FOO.mdx', '']);
96
+ expect(splitFragment('#sec-3')).toEqual(['', '#sec-3']);
97
+ });
98
+
99
+ it('resolves the path part while the fragment rides along', () => {
100
+ expect(hrefKeyCandidates('../specs/FOO.mdx#sec-8-9', '/app/content/roadmap/R3-1.mdx')).toEqual([
101
+ '/app/content/specs/FOO.mdx',
102
+ ]);
103
+ // What <WikiLink> hands <Link>: an absolute content path, never the author's text.
104
+ const [key] = hrefKeyCandidates('roadmap/index.mdx', HOME);
105
+ expect(keyToHref(key) + splitFragment('roadmap/index.mdx#sec-2')[1]).toBe(
106
+ '/content/roadmap/index.mdx#sec-2'
107
+ );
108
+ });
109
+ });
110
+
111
+ describe('linkKind — which hrefs may become a navigating <a>', () => {
112
+ // The R3-252 crash was a bare `<a href="roadmap/index.mdx">`: clicking it made the
113
+ // sandboxed frame perform a real navigation out of the app. Only an href that MEANS to
114
+ // leave the document may be an anchor; everything else is in-app and must be routed or
115
+ // shown broken.
116
+ it('absolute schemes are external', () => {
117
+ expect(linkKind('https://immediately.run')).toBe('external');
118
+ expect(linkKind('http://x.dev/a')).toBe('external');
119
+ expect(linkKind('mailto:a@b.c')).toBe('external');
120
+ expect(linkKind('tel:+123')).toBe('external');
121
+ expect(linkKind('HTTPS://X.DEV')).toBe('external'); // scheme is case-insensitive
122
+ });
123
+
124
+ it('a bare fragment is a same-document anchor', () => {
125
+ expect(linkKind('#sec-8-9')).toBe('anchor');
126
+ });
127
+
128
+ it('every other shape is in-app content — including the ones that used to crash', () => {
129
+ expect(linkKind('roadmap/index.mdx')).toBe('content'); // the reported click
130
+ expect(linkKind('../specs/FOO.mdx#sec-3')).toBe('content');
131
+ expect(linkKind('/content/home.mdx')).toBe('content');
132
+ expect(linkKind('../scripts/check-rename-transition.mjs')).toBe('content'); // not an entry
133
+ expect(linkKind('.claude/memory/x.md')).toBe('content');
134
+ expect(linkKind('')).toBe('content'); // an empty href is broken, not external
135
+ });
136
+
137
+ it('a scheme-like word in a path does not make it external', () => {
138
+ expect(linkKind('plans/https-migration.mdx')).toBe('content');
139
+ });
140
+ });
141
+
142
+ // R3-268 under dispatch — the viewed-document declaration's PATH SPACE. The regression:
143
+ // `keyToRepoRel` only knows the fork's `/app/` anchor, so a dispatched key leaked its
144
+ // sandbox mount path (`mnt/<hash>/themes.mdx`) into the declaration; the host's existence
145
+ // check (against the corpus repo's real `/content/themes.mdx`) missed, and the explorer
146
+ // highlight silently degraded to none. The contract now: fork declares REPO-relative,
147
+ // dispatch declares CORPUS-relative (the host joins its chroot prefix — the corpus's
148
+ // repo-side location is host knowledge this app cannot see).
149
+ import { viewedDocumentForTarget } from './content';
150
+ import { setContentRoot, resetContentRoot } from './contentRoot';
151
+ import { afterEach } from 'vitest';
152
+
153
+ describe('viewedDocumentForTarget — the R3-268 declaration path space', () => {
154
+ afterEach(resetContentRoot);
155
+
156
+ const HOST = 'https://immediately.run/edit/github/ns/repo/main';
157
+
158
+ it('fork: an entry target declares the REPO-relative path (the /app anchor strips)', () => {
159
+ // Fork URL keys are REPO-relative (`files/content/…`) — the engine repo is the tree.
160
+ expect(viewedDocumentForTarget(`${HOST}/files/content/themes.mdx`)).toBe('content/themes.mdx');
161
+ expect(viewedDocumentForTarget(`${HOST}/files/content/plot/index.mdx`)).toBe('content/plot/index.mdx');
162
+ });
163
+
164
+ it('dispatch: an entry target declares the CORPUS-relative path — never the mount path', () => {
165
+ setContentRoot('/mnt/0a1b2c3d');
166
+ expect(viewedDocumentForTarget(`${HOST}/files/themes.mdx`)).toBe('themes.mdx');
167
+ expect(viewedDocumentForTarget(`${HOST}/files/plot/index.mdx`)).toBe('plot/index.mdx');
168
+ // The regression shape: the mount path must not leak into the declaration.
169
+ expect(viewedDocumentForTarget(`${HOST}/files/themes.mdx`)).not.toMatch(/^mnt\//);
170
+ });
171
+
172
+ it('the wiki root declares the HOME entry (the root route renders home.mdx)', () => {
173
+ expect(viewedDocumentForTarget(`${HOST}/files/`)).toBe('content/home.mdx');
174
+ setContentRoot('/mnt/0a1b2c3d');
175
+ expect(viewedDocumentForTarget(`${HOST}/files/`)).toBe('home.mdx');
176
+ });
177
+
178
+ it('a non-entry target (not .mdx) declares null, in both packagings', () => {
179
+ expect(viewedDocumentForTarget(`${HOST}/files/content/llms.txt`)).toBeNull();
180
+ setContentRoot('/mnt/0a1b2c3d');
181
+ expect(viewedDocumentForTarget(`${HOST}/files/llms.txt`)).toBeNull();
182
+ });
183
+
184
+ it('a traversal in the target degrades to HOME — an out-of-tree path never leaks', () => {
185
+ // sandboxPathToKey resolves traversals BEFORE the containment check; anything
186
+ // outside the corpus resolves to the home key, so the declaration is home, never
187
+ // a `..`-bearing or out-of-corpus path.
188
+ setContentRoot('/mnt/0a1b2c3d');
189
+ const declared = viewedDocumentForTarget(`${HOST}/files/../../app/src/App.tsx`);
190
+ expect(declared).toBe('home.mdx');
191
+ });
192
+ });
193
+
194
+ // A folder is a destination since directory listings landed, so link resolution has to
195
+ // answer "which PATH?" as well as "which entry?" — `[the handbook](handbook)` names
196
+ // something real and must not render as a broken link.
197
+ describe('hrefTargetKey — resolution without the entry-file requirement', () => {
198
+ it('resolves a folder href the entry-candidate list rejects', () => {
199
+ expect(hrefKeyCandidates('handbook', HOME)).toEqual([]);
200
+ expect(hrefTargetKey('handbook', HOME)).toBe('/app/content/handbook');
201
+ expect(hrefTargetKey('../teams', '/app/content/handbook/onboarding.mdx')).toBe('/app/content/teams');
202
+ });
203
+
204
+ it('still resolves entries, identically to the candidate list', () => {
205
+ expect(hrefTargetKey('roadmap/index.mdx', HOME)).toBe('/app/content/roadmap/index.mdx');
206
+ expect(hrefTargetKey('/files/content/about.mdx', HOME)).toBe('/app/content/about.mdx');
207
+ });
208
+
209
+ it('denotes nothing for an external scheme, a bare anchor, or an escape', () => {
210
+ expect(hrefTargetKey('https://example.com', HOME)).toBeNull();
211
+ expect(hrefTargetKey('#sec-1', HOME)).toBeNull();
212
+ expect(hrefTargetKey('', HOME)).toBeNull();
213
+ // Confinement: the result flows into fs reads, and under dispatch the href is
214
+ // foreign content. RELATIVE climbs out of the corpus still denote nothing.
215
+ expect(hrefTargetKey('../../src/App.tsx', HOME)).toBeNull();
216
+ // An ABSOLUTE traversal no longer escapes to null, it CLAMPS inside the corpus
217
+ // (R3-273's closed-space rule, adopted by R3-277b — the corpus space is closed
218
+ // under traversal; the old escape-to-null reading is superseded).
219
+ expect(hrefTargetKey('/../../src/App.tsx', HOME)).toBe('/app/content/src/App.tsx');
220
+ expect(hrefTargetKey('/../package.json', HOME)).toBe('/app/content/package.json'); // clamped INSIDE, not escaped
221
+ });
222
+ });
223
+
224
+ // R3-277b — the link-resolution parity harness: grove's runtime resolution
225
+ // (`hrefTargetKey`) must agree with the SHARED fixture (and therefore with the
226
+ // SDK resolver and the docs checker, which assert against the same cases).
227
+ import { LINK_SPACE_FIXTURE } from '@immediately-run/mdx-plugins';
228
+
229
+ describe('link-space parity (LINK_SPACE_FIXTURE, R3-277b)', () => {
230
+ // The corpus-rooted cases, run through grove's own resolution with the fork
231
+ // packaging's root. The mount-absolute corpus of the fixture is '/app/content'
232
+ // — exactly the fork's — so the corpus-space cases translate verbatim; the
233
+ // fs-rooted/no-corpus case and the chroot collapse are resolver-level (the
234
+ // checker + SDK suites own them) and are skipped by corpusRoot here.
235
+ const FORK_ROOT = '/app/content';
236
+ const contentCases = LINK_SPACE_FIXTURE.filter(
237
+ (c) => c.corpusRoot === FORK_ROOT && !c.bundleChrooted && c.currentFile !== undefined,
238
+ );
239
+
240
+ it('the fixture still reaches this harness (non-vacuous)', () => {
241
+ expect(contentCases.length).toBeGreaterThanOrEqual(7);
242
+ });
243
+
244
+ for (const c of contentCases) {
245
+ it(`${c.raw} from ${c.currentFile ?? '<unknown>'} — ${c.why}`, () => {
246
+ const got = hrefTargetKey(c.raw, c.currentFile ?? '/app/content/home.mdx');
247
+ if (c.expect.state === 'resolved') {
248
+ // grove confines default-space targets to the corpus; the fixture's one
249
+ // escapes-corpus case ('../specs/A.mdx') therefore maps to null here —
250
+ // an existence-denial, not a disagreement (the resolver case above
251
+ // proved the path arithmetic; entry checks would reject it anyway).
252
+ const insideCorpus = c.expect.path.startsWith(FORK_ROOT);
253
+ expect(got).toBe(insideCorpus || c.raw.startsWith('$fs:') ? c.expect.path : null);
254
+ } else {
255
+ expect(got).toBe(null);
256
+ }
257
+ });
258
+ }
259
+
260
+ it('legacy repo-root absolute spellings are accepted inbound (R3-272 rule)', () => {
261
+ expect(hrefTargetKey('/content/handbook/onboarding.mdx', '/app/content/home.mdx')).toBe(
262
+ '/app/content/handbook/onboarding.mdx'
263
+ );
264
+ // canonical corpus-absolute still wins
265
+ expect(hrefTargetKey('/handbook/onboarding.mdx', '/app/content/home.mdx')).toBe(
266
+ '/app/content/handbook/onboarding.mdx'
267
+ );
268
+ });
269
+ });