@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,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
|
+
});
|