@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,267 @@
|
|
|
1
|
+
import { isContentEntryPath } from '@immediately-run/mdx-plugins';
|
|
2
|
+
import { FS_PREFIX, normalizeAbsolute, resolveLinkTarget } from '@immediately-run/sdk/linkSpace';
|
|
3
|
+
|
|
4
|
+
// Path conventions for Grove content.
|
|
5
|
+
//
|
|
6
|
+
// The CANONICAL KEY in this app is the file's ABSOLUTE module/fs path, e.g.
|
|
7
|
+
// `/app/content/handbook/onboarding.mdx`. This is the SAME identifier used by:
|
|
8
|
+
// • the metadata store — `useFileMetadata(key)` / `useMetadataQuery` are keyed
|
|
9
|
+
// by it (since the bundler change "Key MDX metadata by the absolute /app
|
|
10
|
+
// module path", sandbox #41 — metadata and modules now share one path);
|
|
11
|
+
// • the fs / `<Include>` / `module.dynamicImport` / `fs.readFile` path space.
|
|
12
|
+
//
|
|
13
|
+
// So an entry's metadata is read and its body is rendered with the identical
|
|
14
|
+
// string — no more juggling two path spaces. The ONLY place a different space
|
|
15
|
+
// appears is the URL/href layer, which owns the `/files`↔APP_ROOT translation:
|
|
16
|
+
// `keyToHref` strips `/app` (the runtime <Link> then prepends `/files`), and
|
|
17
|
+
// `keyToRepoRel` strips `/app/` for requestEdit. Pure helpers — no components.
|
|
18
|
+
|
|
19
|
+
import { getContentRoot, isDispatched } from './contentRoot';
|
|
20
|
+
|
|
21
|
+
export const APP_PREFIX = '/app';
|
|
22
|
+
export const FILES_PREFIX = '/files';
|
|
23
|
+
|
|
24
|
+
/** Where this instance's corpus lives — `/app/content/` for a fork, the delegated
|
|
25
|
+
* directory for a dispatched viewer. A FUNCTION, not a constant: see
|
|
26
|
+
* [[contentRoot]] for why capturing it at module scope is the bug it replaces. */
|
|
27
|
+
export function contentDir(): string {
|
|
28
|
+
return getContentRoot();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The site root entry — the home page of whichever corpus is mounted. */
|
|
32
|
+
export function homeKey(): string {
|
|
33
|
+
return `${getContentRoot()}home.mdx`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Is this a content-entry metadata key? */
|
|
37
|
+
export function isEntryKey(key: string): boolean {
|
|
38
|
+
return key.startsWith(contentDir()) && /\.mdx?$/.test(key);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Is this a folder-convention layout file (`…/_layout.mdx`)? These are structure,
|
|
42
|
+
* not entries: they wrap pages at an `<Outlet/>` and are excluded from every
|
|
43
|
+
* content enumeration (routing, nav, sidebar tree, index, search, backlinks). */
|
|
44
|
+
export function isLayoutKey(key: string): boolean {
|
|
45
|
+
return /(^|\/)_layout\.mdx?$/.test(key);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Is this a *content entry* — an `.mdx` under content that a reader can navigate to?
|
|
49
|
+
* Excludes layout files and any other `_`-prefixed structural file. This is the
|
|
50
|
+
* single predicate every enumeration (nav, sidebar, 404 index, search, indexes)
|
|
51
|
+
* should filter on, so layout files never leak into reader-facing surfaces. */
|
|
52
|
+
export function isContentEntry(key: string): boolean {
|
|
53
|
+
// The ROOTING is this viewer's (a metadata key under whichever corpus is mounted);
|
|
54
|
+
// the ENTRY RULE is shared with the corpus tooling (R3-277a), because a file that
|
|
55
|
+
// counts as an entry in one and not the other is a page that exists but cannot be
|
|
56
|
+
// found — or an index row pointing at a layout wrapper.
|
|
57
|
+
if (!key.startsWith(contentDir())) return false;
|
|
58
|
+
return isContentEntryPath(key);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `handbook/onboarding` → `/app/content/handbook/onboarding.mdx` (the canonical key). */
|
|
62
|
+
export function slugToKey(slug: string): string {
|
|
63
|
+
return contentDir() + slug.replace(/^\//, '') + '.mdx';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The prefix the URL space is measured FROM — the one place the two packagings differ in
|
|
68
|
+
* how a key becomes a link, and vice versa.
|
|
69
|
+
*
|
|
70
|
+
* A FORK's URLs are anchored at the app root, so a key `/app/content/x.mdx` is the href
|
|
71
|
+
* `/content/x.mdx` — `content/` included. That is not a detail to tidy up: those URLs are
|
|
72
|
+
* published, cited and deep-linked, so the fork's mapping must stay byte-identical.
|
|
73
|
+
*
|
|
74
|
+
* A DISPATCHED viewer has no app-root to measure from — the corpus is a chroot minted AT
|
|
75
|
+
* the content directory, so the mount root IS the corpus root and hrefs are corpus-relative
|
|
76
|
+
* (`/plot/the-rail.mdx`). Nothing is published against that space yet, which is why it can
|
|
77
|
+
* be defined here rather than negotiated.
|
|
78
|
+
*
|
|
79
|
+
* (The repo-load URL space — where a dispatched entry's URL must name a path in the CONTENT
|
|
80
|
+
* repo, `content/` segment and all — is R3-172's, and is decided when that space exists.
|
|
81
|
+
* Under the task/overlay dispatch that ships today there is no external URL to preserve.)
|
|
82
|
+
*/
|
|
83
|
+
function urlAnchor(): string {
|
|
84
|
+
return isDispatched() ? contentDir().replace(/\/+$/, '') : APP_PREFIX;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Canonical key → href for the runtime <Link>. The key is the absolute fs path; the URL
|
|
88
|
+
* space drops the anchor and <Link> prepends `/files` itself. */
|
|
89
|
+
export function keyToHref(key: string): string {
|
|
90
|
+
const anchor = urlAnchor();
|
|
91
|
+
return key.startsWith(anchor) ? key.slice(anchor.length) : key;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Canonical key → the absolute fs/module path for <Include> / dynamicImport /
|
|
95
|
+
* fs.readFile. The canonical key already IS that path, so this is identity. */
|
|
96
|
+
export function keyToInclude(key: string): string {
|
|
97
|
+
return key;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Canonical key → absolute fs path (alias of {@link keyToInclude}, for reads). */
|
|
101
|
+
export function keyToFsPath(key: string): string {
|
|
102
|
+
return key;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Canonical key → repo-relative path for requestEdit (`content/…`). */
|
|
106
|
+
export function keyToRepoRel(key: string): string {
|
|
107
|
+
return key.replace(/^\/app\//, '').replace(/^\//, '');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* R3-268 — the viewed-document declaration for a navigation target (the value the
|
|
112
|
+
* resolver hands the SDK): the destination entry's path in this app's VISIBLE tree.
|
|
113
|
+
*
|
|
114
|
+
* Two path spaces, one per packaging:
|
|
115
|
+
* - **Fork**: the engine repo IS the working tree, so the `/app/`-anchored key strips
|
|
116
|
+
* to a repo-relative path (`content/themes.mdx`) — `keyToRepoRel`, unchanged.
|
|
117
|
+
* - **Dispatch**: the delegated corpus mount is ALL this app can see or name, so the
|
|
118
|
+
* declaration is CORPUS-relative (`themes.mdx` — the key minus the content root).
|
|
119
|
+
* The host owns the corpus→repo translation and joins its chroot prefix before the
|
|
120
|
+
* existence check. `keyToRepoRel` here was the bug: it only knows the `/app/`
|
|
121
|
+
* anchor, so a dispatched key leaked its sandbox MOUNT path (`mnt/<hash>/…`) into
|
|
122
|
+
* the declaration, the host's existence check missed, and the highlight silently
|
|
123
|
+
* degraded to none.
|
|
124
|
+
*
|
|
125
|
+
* A non-entry view declares `null` (clear the highlight).
|
|
126
|
+
*/
|
|
127
|
+
export function viewedDocumentForTarget(targetHref: string): string | null {
|
|
128
|
+
const path = new URL(targetHref, 'https://placeholder.invalid').pathname;
|
|
129
|
+
const i = path.indexOf(`${FILES_PREFIX}/`);
|
|
130
|
+
const sandboxPath = i >= 0 ? path.slice(i) : '';
|
|
131
|
+
const key = sandboxPathToKey(sandboxPath);
|
|
132
|
+
if (!isEntryKey(key)) return null;
|
|
133
|
+
// isEntryKey guarantees the key starts with the content root, so the slice is total.
|
|
134
|
+
return isDispatched() ? key.slice(getContentRoot().length) : keyToRepoRel(key);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Split a link target into its path part and its `#fragment` (the `#` kept), e.g.
|
|
138
|
+
* `"FOO.mdx#sec-8-9"` → `["FOO.mdx", "#sec-8-9"]`. Only the FIRST `#` splits. */
|
|
139
|
+
export function splitFragment(href: string): [string, string] {
|
|
140
|
+
const i = href.indexOf('#');
|
|
141
|
+
return i === -1 ? [href, ''] : [href.slice(0, i), href.slice(i)];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Collapse `.` and `..` segments in an absolute key — the shared normalizer
|
|
145
|
+
* (R3-277b; was a local copy of the same arithmetic). */
|
|
146
|
+
const normalizeKey = normalizeAbsolute;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The canonical keys a link href may denote, most-specific first — empty when the href
|
|
150
|
+
* is external, a bare anchor, or doesn't name a content entry.
|
|
151
|
+
*
|
|
152
|
+
* An author writes links RELATIVE to the entry they are in (`[Roadmap](roadmap/index.mdx)`
|
|
153
|
+
* from `content/home.mdx`), which is the same rule `scripts/lib/wiki.mjs` `contentResolve`
|
|
154
|
+
* applies to `[[…]]` links and `check-docs-wiki` audits them by. Resolving markdown links
|
|
155
|
+
* the same way is what keeps the rendered wiki and the corpus gate agreeing: before this,
|
|
156
|
+
* a relative href fell through to a plain `<a>`, the sandbox performed a real navigation,
|
|
157
|
+
* and the app died with "Failed to construct 'URL': Invalid URL".
|
|
158
|
+
*
|
|
159
|
+
* Two candidates rather than one, so a `foo.md` still finds `foo.mdx`. R3-252 rewrote the
|
|
160
|
+
* corpus off those pre-cutover names and `check-docs-wiki` now refuses new ones, so this
|
|
161
|
+
* fallback should never fire from committed content — it is kept because the gate being
|
|
162
|
+
* STRICTER than the renderer is the right asymmetry: a stale hand-typed link should still
|
|
163
|
+
* land a reader somewhere, and only the corpus is held to the canonical name. The CALLER
|
|
164
|
+
* picks the first candidate that exists, so this stays pure and testable.
|
|
165
|
+
*/
|
|
166
|
+
export function hrefKeyCandidates(href: string, fromKey: string): string[] {
|
|
167
|
+
const abs = hrefTargetKey(href, fromKey);
|
|
168
|
+
if (!abs || !/\.mdx?$/.test(abs)) return [];
|
|
169
|
+
return abs.endsWith('.md') ? [abs, abs + 'x'] : [abs];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The corpus path an in-app href denotes — WITHOUT requiring it to name a file. Null for
|
|
174
|
+
* an external scheme, a bare anchor, or a path that resolves outside the corpus.
|
|
175
|
+
*
|
|
176
|
+
* Split out from {@link hrefKeyCandidates} because a folder is a destination now: since
|
|
177
|
+
* directory listings landed, `[the handbook](handbook)` names something real, and the
|
|
178
|
+
* extension test that makes an entry an entry would have rendered it as a broken link.
|
|
179
|
+
* The two callers ask different questions of the same resolution — "which entry?" and
|
|
180
|
+
* "which path?" — so the resolution lives here once.
|
|
181
|
+
*/
|
|
182
|
+
export function hrefTargetKey(href: string, fromKey: string): string | null {
|
|
183
|
+
if (!href) return null;
|
|
184
|
+
if (/^(https?:|mailto:|tel:|#)/i.test(href)) return null;
|
|
185
|
+
const [rawPath] = splitFragment(href);
|
|
186
|
+
if (!rawPath) return null;
|
|
187
|
+
const path = rawPath.replace(/^\/files/, '');
|
|
188
|
+
// R3-277b: resolution is the SHARED resolver (`@immediately-run/sdk/linkSpace`,
|
|
189
|
+
// R3-273) — the same function the platform's own WikiLink and the docs corpus
|
|
190
|
+
// checker route through, so the runtime and the gate cannot drift. Relative
|
|
191
|
+
// targets resolve against the authoring file; `$fs:` targets resolve
|
|
192
|
+
// mount-absolute (addressing, never reach — R3-273).
|
|
193
|
+
if (!path.startsWith('/')) {
|
|
194
|
+
const rel = resolveLinkTarget(path, { currentFile: fromKey, corpusRoot: getContentRoot() });
|
|
195
|
+
if (rel.state !== 'resolved') return null;
|
|
196
|
+
// Confinement, not tidiness: an href in foreign content is untrusted, and the
|
|
197
|
+
// result flows into `fs` reads. In the default space anything that lands outside
|
|
198
|
+
// the corpus denotes nothing (`$fs:` is exempt by design; the entry checks
|
|
199
|
+
// downstream decide what renders).
|
|
200
|
+
return rel.path.startsWith(contentDir()) || path.startsWith(FS_PREFIX) ? rel.path : null;
|
|
201
|
+
}
|
|
202
|
+
// Absolute spellings come in two generations, both accepted INBOUND (the R3-272
|
|
203
|
+
// rule; emission via keyToHref stays canonical): the legacy repo-root spelling
|
|
204
|
+
// ('/content/handbook/x.mdx' — anchored where the corpus's own repo keeps it,
|
|
205
|
+
// under 'content/') and the canonical corpus-absolute ('/handbook/x.mdx' —
|
|
206
|
+
// anchored at the corpus root by the shared resolver). The legacy spelling is
|
|
207
|
+
// recognized by its prefix and wins when it matches, because that is the only
|
|
208
|
+
// form a repo-shaped corpus could have meant by it; anything else is
|
|
209
|
+
// corpus-absolute, closed under traversal ('..' clamps INSIDE the corpus —
|
|
210
|
+
// R3-273's rule, which supersedes this file's old escape-to-null behavior).
|
|
211
|
+
const legacyPrefix = contentDir().endsWith('/content/') ? '/content' : null;
|
|
212
|
+
if (legacyPrefix && (path === legacyPrefix || path.startsWith(`${legacyPrefix}/`))) {
|
|
213
|
+
const legacyAnchor = contentDir().slice(0, -'content/'.length);
|
|
214
|
+
const legacy = normalizeAbsolute(legacyAnchor + path);
|
|
215
|
+
if (legacy.startsWith(contentDir())) return legacy;
|
|
216
|
+
}
|
|
217
|
+
const corpusAnchored = resolveLinkTarget(path, { currentFile: fromKey, corpusRoot: getContentRoot() });
|
|
218
|
+
if (corpusAnchored.state === 'resolved' && corpusAnchored.path.startsWith(contentDir())) {
|
|
219
|
+
return corpusAnchored.path;
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* What an href in an entry body IS, which decides whether it may become a real `<a>`.
|
|
226
|
+
*
|
|
227
|
+
* A real `<a href>` performs a REAL NAVIGATION on click, which takes the sandboxed frame
|
|
228
|
+
* out of the app — the R3-252 crash (`Failed to construct 'URL': Invalid URL`). So an
|
|
229
|
+
* anchor is only ever correct for an href that genuinely means to leave the document:
|
|
230
|
+
*
|
|
231
|
+
* - `'external'` — an absolute scheme (`https:`, `mailto:`, `tel:`). Leaving is the point.
|
|
232
|
+
* - `'anchor'` — a bare `#fragment`. Same document; navigates nowhere.
|
|
233
|
+
* - `'content'` — anything else: a link INTO the corpus. It must be routed, and if it
|
|
234
|
+
* cannot be resolved to an entry it is BROKEN — never "external by default". That
|
|
235
|
+
* fallthrough is what made an unresolvable body link lethal instead of merely wrong,
|
|
236
|
+
* and it is the class the wiki still contains (`../scripts/x.mjs`, `.claude/…`).
|
|
237
|
+
*/
|
|
238
|
+
export function linkKind(href: string): 'external' | 'anchor' | 'content' {
|
|
239
|
+
if (/^(https?:|mailto:|tel:)/i.test(href)) return 'external';
|
|
240
|
+
if (href.startsWith('#')) return 'anchor';
|
|
241
|
+
return 'content';
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Current navigation sandboxPath → the canonical metadata/Include key. */
|
|
245
|
+
export function sandboxPathToKey(sandboxPath: string): string {
|
|
246
|
+
if (!sandboxPath || sandboxPath === '/') return homeKey();
|
|
247
|
+
const anchor = urlAnchor();
|
|
248
|
+
let p = sandboxPath;
|
|
249
|
+
if (p.startsWith(FILES_PREFIX)) p = p.slice(FILES_PREFIX.length); // /files/content/x → /content/x
|
|
250
|
+
if (!p.startsWith(anchor + '/')) p = anchor + p; // relative → anchored (an anchored path stays)
|
|
251
|
+
// NORMALIZE BEFORE CHECKING. The containment test below used to run on the raw text, so
|
|
252
|
+
// `/../../app/src/App.tsx` anchored to `/task/t1/dir/../../app/src/App.tsx` — which
|
|
253
|
+
// starts with the content root as a STRING and resolves somewhere else entirely as a
|
|
254
|
+
// PATH. The key flows straight into `fs.readFile` (entry bodies, reading time,
|
|
255
|
+
// backlinks), and under dispatch the URL is attacker-influenceable: a link in foreign
|
|
256
|
+
// content, or a crafted address. Resolve the traversal first, then ask where it landed.
|
|
257
|
+
p = normalizeKey(p);
|
|
258
|
+
if (p === anchor || p === anchor + '/') return homeKey();
|
|
259
|
+
// The guard, not a formality: anything that does not land inside the corpus resolves
|
|
260
|
+
// HOME rather than becoming a key that reads some other file.
|
|
261
|
+
return p.startsWith(contentDir()) ? p : homeKey();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** A sandboxPath → the absolute fs base for resolving relative assets. */
|
|
265
|
+
export function toFsPath(sandboxPath: string): string {
|
|
266
|
+
return keyToFsPath(sandboxPathToKey(sandboxPath));
|
|
267
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// WHERE THE CORPUS IS — the one place that answers it.
|
|
2
|
+
//
|
|
3
|
+
// Grove was written for a single packaging (Mode A, the FORK): engine and corpus ship
|
|
4
|
+
// from one repo, so the content root was the module constant `/app/content/` and every
|
|
5
|
+
// helper built on it inherited that assumption. Under DISPATCH the corpus is somebody
|
|
6
|
+
// else's repo, mounted at a host-minted chroot, and the engine is loaded from Grove's
|
|
7
|
+
// own repo — so the root is a RUNTIME value, and a constant is not merely inconvenient,
|
|
8
|
+
// it is wrong in a way that renders the viewer's own corpus while claiming to render
|
|
9
|
+
// yours (`docs/specs/REPO_CONTENT_DISPATCH_SPEC.mdx` §3; R3-169/R3-265).
|
|
10
|
+
//
|
|
11
|
+
// The root is resolved ONCE, before the first render, and never changes for the life of
|
|
12
|
+
// the instance: a task callee is invoked with one directory, and a fork reads its own.
|
|
13
|
+
// Deliberately module state rather than React context — the consumers are pure helpers
|
|
14
|
+
// (`lib/content.ts`, `lib/layout.ts`, `lib/wiki.ts`) that components call during render,
|
|
15
|
+
// and threading a context through them would put the render tree in the middle of a
|
|
16
|
+
// question that is settled at boot.
|
|
17
|
+
//
|
|
18
|
+
// **Call it, don't capture it.** `const dir = getContentRoot()` at module scope freezes
|
|
19
|
+
// the default before `setContentRoot` runs and silently restores the old bug — read it
|
|
20
|
+
// inside the function that needs it. `contentRoot.test.ts` pins that.
|
|
21
|
+
|
|
22
|
+
/** The fork packaging's root: the engine's own repo, at the sandbox's APP_ROOT. */
|
|
23
|
+
export const APP_CONTENT_ROOT = '/app/content/';
|
|
24
|
+
|
|
25
|
+
let root: string = APP_CONTENT_ROOT;
|
|
26
|
+
let readOnly = false;
|
|
27
|
+
|
|
28
|
+
/** Where this instance's corpus lives, with a trailing slash. Read at CALL time. */
|
|
29
|
+
export function getContentRoot(): string {
|
|
30
|
+
return root;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Point this instance at a corpus. Called once at boot — by the `open-wiki` task handler
|
|
35
|
+
* with the delegated directory, or not at all (the fork, which keeps the default).
|
|
36
|
+
* Normalizes the trailing slash so every `startsWith`/`slice` in the helpers holds.
|
|
37
|
+
*/
|
|
38
|
+
export function setContentRoot(dir: string, opts: { readOnly?: boolean } = {}): void {
|
|
39
|
+
if (!dir) return;
|
|
40
|
+
root = dir.endsWith('/') ? dir : `${dir}/`;
|
|
41
|
+
readOnly = opts.readOnly ?? false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Whether the mounted corpus was delegated read-only. Lives here rather than in React
|
|
45
|
+
* state because it is the same fact as the root — one delegation, decided at boot — and
|
|
46
|
+
* every consumer that asks "may I offer an edit?" already reads the root. */
|
|
47
|
+
export function isContentReadOnly(): boolean {
|
|
48
|
+
return readOnly;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** True when the corpus is NOT this app's own repo — i.e. we are a dispatched viewer.
|
|
52
|
+
* Surfaces where the two packagings genuinely differ (provenance, the edit target). */
|
|
53
|
+
export function isDispatched(): boolean {
|
|
54
|
+
return root !== APP_CONTENT_ROOT;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Test-only: restore the fork default so cases don't leak into each other. */
|
|
58
|
+
export function resetContentRoot(): void {
|
|
59
|
+
root = APP_CONTENT_ROOT;
|
|
60
|
+
readOnly = false;
|
|
61
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// The corpus component DECLARATION (R3-174; MDX_FROM_MOUNT_SPEC §7 1b).
|
|
2
|
+
//
|
|
3
|
+
// Two properties matter here and neither is about the happy path. First, a bad
|
|
4
|
+
// declaration is NAMED, not swallowed — a `<RoadmapBoard/>` that silently never renders
|
|
5
|
+
// is indistinguishable from one nobody wrote, and the author has no other channel to find
|
|
6
|
+
// out which. Second, one bad declaration does not cost the corpus its other components.
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect } from 'vitest';
|
|
9
|
+
import { parseCorpusComponents, resolveComponentPath } from './corpusComponents';
|
|
10
|
+
|
|
11
|
+
const ROOT = '/mnt/ec1210aa4dfa0067260861b1eeb31a9b';
|
|
12
|
+
|
|
13
|
+
describe('resolveComponentPath', () => {
|
|
14
|
+
it('resolves a corpus-relative path, with or without ./', () => {
|
|
15
|
+
expect(resolveComponentPath('./components/X.jsx', ROOT)).toBe(`${ROOT}/components/X.jsx`);
|
|
16
|
+
expect(resolveComponentPath('components/X.jsx', ROOT)).toBe(`${ROOT}/components/X.jsx`);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('tolerates a trailing slash on the root', () => {
|
|
20
|
+
expect(resolveComponentPath('./x.jsx', `${ROOT}/`)).toBe(`${ROOT}/x.jsx`);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('rejects an absolute path — that is a different space, not a corpus path', () => {
|
|
24
|
+
expect(resolveComponentPath('/app/src/App.tsx', ROOT)).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('rejects traversal out of the corpus, checked on the NORMALIZED path', () => {
|
|
28
|
+
// `x/../../y` starts with the root as a STRING and resolves outside it as a PATH —
|
|
29
|
+
// the hazard `sandboxPathToKey` documents. A marker must not be able to point the
|
|
30
|
+
// engine at its own modules and have them register under a corpus name.
|
|
31
|
+
expect(resolveComponentPath('../../app/src/App.tsx', ROOT)).toBeNull();
|
|
32
|
+
expect(resolveComponentPath('components/../../escape.jsx', ROOT)).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('rejects NUL and backslash smuggling, and the empty path', () => {
|
|
36
|
+
expect(resolveComponentPath('x\0.jsx', ROOT)).toBeNull();
|
|
37
|
+
expect(resolveComponentPath('..\\escape.jsx', ROOT)).toBeNull();
|
|
38
|
+
expect(resolveComponentPath('', ROOT)).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('rejects the corpus root itself — it is not a module', () => {
|
|
42
|
+
expect(resolveComponentPath('.', ROOT)).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('rejects a non-string', () => {
|
|
46
|
+
expect(resolveComponentPath(42, ROOT)).toBeNull();
|
|
47
|
+
expect(resolveComponentPath(null, ROOT)).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('parseCorpusComponents', () => {
|
|
52
|
+
it('reads a well-formed map', () => {
|
|
53
|
+
const { components, rejected } = parseCorpusComponents(
|
|
54
|
+
{ components: { ProjectIndex: './components/ProjectIndex.jsx' } },
|
|
55
|
+
ROOT,
|
|
56
|
+
);
|
|
57
|
+
expect(rejected).toEqual([]);
|
|
58
|
+
expect(components).toEqual([{ name: 'ProjectIndex', path: `${ROOT}/components/ProjectIndex.jsx` }]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('is silent for a marker with no components key — the ordinary case', () => {
|
|
62
|
+
expect(parseCorpusComponents({ opensWith: { task: 'open-wiki' } }, ROOT)).toEqual({
|
|
63
|
+
components: [],
|
|
64
|
+
rejected: [],
|
|
65
|
+
});
|
|
66
|
+
expect(parseCorpusComponents(null, ROOT)).toEqual({ components: [], rejected: [] });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('rejects a lowercase name — MDX would resolve it as an intrinsic and never consult the provider', () => {
|
|
70
|
+
const { components, rejected } = parseCorpusComponents({ components: { board: './b.jsx' } }, ROOT);
|
|
71
|
+
expect(components).toEqual([]);
|
|
72
|
+
expect(rejected).toEqual([{ name: 'board', reason: 'not a capitalized component name' }]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('rejects a name that is not a component reference at all', () => {
|
|
76
|
+
const { rejected } = parseCorpusComponents({ components: { 'My-Board': './b.jsx' } }, ROOT);
|
|
77
|
+
expect(rejected.map((r) => r.name)).toEqual(['My-Board']);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('keeps the good declarations when one is bad', () => {
|
|
81
|
+
const { components, rejected } = parseCorpusComponents(
|
|
82
|
+
{
|
|
83
|
+
components: {
|
|
84
|
+
Good: './components/Good.jsx',
|
|
85
|
+
Escapes: '../../app/src/App.tsx',
|
|
86
|
+
AlsoGood: './components/AlsoGood.jsx',
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
ROOT,
|
|
90
|
+
);
|
|
91
|
+
expect(components.map((c) => c.name)).toEqual(['Good', 'AlsoGood']);
|
|
92
|
+
expect(rejected.map((r) => r.name)).toEqual(['Escapes']);
|
|
93
|
+
expect(rejected[0].reason).toContain('inside the corpus');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('rejects a components value that is not an object', () => {
|
|
97
|
+
expect(parseCorpusComponents({ components: ['./x.jsx'] }, ROOT).rejected).toEqual([
|
|
98
|
+
{ name: '(components)', reason: 'not an object' },
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Corpus-declared components — the parse half (R3-174; MDX_FROM_MOUNT_SPEC §2, §7 1b).
|
|
2
|
+
//
|
|
3
|
+
// A corpus may ship its own component vocabulary and have the viewer register it
|
|
4
|
+
// import-free, so `<ProjectIndex/>` works with no engine fork. This module owns the
|
|
5
|
+
// DECLARATION: reading `components` out of the corpus's own `immediately.run.json` and
|
|
6
|
+
// deciding which entries are usable. Evaluating them is `hooks/useContentComponents`.
|
|
7
|
+
//
|
|
8
|
+
// **Why the marker file and not a frontmatter scan.** §7 1b left the declaration form
|
|
9
|
+
// open and named three candidates (an `.mdx` module with `type: component` frontmatter, a
|
|
10
|
+
// per-component metadata entry, a `components/` path convention). The marker wins on four
|
|
11
|
+
// counts: it is already IN the corpus and already read, so discovery costs no scan and no
|
|
12
|
+
// sidecar — which matters because a private corpus has neither (the docs wiki has no Pages
|
|
13
|
+
// site, so no cache zip and no frontmatter sidecar; it loads over the REST API); a raw
|
|
14
|
+
// `.jsx` carries no frontmatter, so the frontmatter form would force a wrapper module for
|
|
15
|
+
// every component; it is one greppable place that states the corpus's whole vocabulary,
|
|
16
|
+
// which is what `viewer.manifest.json`'s `tier: "corpus"` entries were already reaching
|
|
17
|
+
// for; and it adds no non-component module export anywhere, so it cannot break Fast
|
|
18
|
+
// Refresh (the constraint §7 1b is bounded by).
|
|
19
|
+
//
|
|
20
|
+
// **The host does not read this key and must not start.** `site-main`'s
|
|
21
|
+
// `contentMarker.ts` parses `opensWith` and `kind` and ignores everything else, so the
|
|
22
|
+
// vocabulary is viewer policy, not part of the host's authority decision. That separation
|
|
23
|
+
// is the point: the marker names a CONTRACT to the host, and a component list to whoever
|
|
24
|
+
// honours the contract.
|
|
25
|
+
|
|
26
|
+
/** One usable declaration: an MDX component name bound to an absolute module path. */
|
|
27
|
+
export interface CorpusComponent {
|
|
28
|
+
/** The name an entry writes as `<Name/>`. */
|
|
29
|
+
name: string;
|
|
30
|
+
/** Absolute module path inside the corpus, ready for `getModuleEvaluationContext`. */
|
|
31
|
+
path: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A declaration that was thrown out, and why — surfaced, never silently dropped. */
|
|
35
|
+
export interface RejectedComponent {
|
|
36
|
+
name: string;
|
|
37
|
+
reason: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CorpusComponentDecls {
|
|
41
|
+
components: CorpusComponent[];
|
|
42
|
+
rejected: RejectedComponent[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** MDX resolves a lowercase tag as an intrinsic element and only consults the provider for
|
|
46
|
+
* a CAPITALIZED reference, so a lowercase name could never be reached — and a name with a
|
|
47
|
+
* dot or a dash would not parse as a component reference at all. Rejecting them is the
|
|
48
|
+
* difference between "your component silently never renders" and a named error. */
|
|
49
|
+
const COMPONENT_NAME = /^[A-Z][A-Za-z0-9_]*$/;
|
|
50
|
+
|
|
51
|
+
/** Collapse `.`/`..`/empty segments. `..` cannot climb above the root: a virtual root's
|
|
52
|
+
* parent is itself, which is what keeps the corpus space closed under traversal (the same
|
|
53
|
+
* rule as the SDK's `normalizeAbsolute` and `lib/content`'s `normalizeKey`). */
|
|
54
|
+
function normalizeAbsolute(abs: string): string {
|
|
55
|
+
const out: string[] = [];
|
|
56
|
+
for (const seg of abs.split('/')) {
|
|
57
|
+
if (!seg || seg === '.') continue;
|
|
58
|
+
if (seg === '..') out.pop();
|
|
59
|
+
else out.push(seg);
|
|
60
|
+
}
|
|
61
|
+
return '/' + out.join('/');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve one declared path against the corpus root, or null if it does not name a file
|
|
66
|
+
* inside the corpus.
|
|
67
|
+
*
|
|
68
|
+
* Under decision B (2026-08-26) a content component may import anything the viewer can,
|
|
69
|
+
* so this containment check is **coherence, not confinement** — it stops a marker naming
|
|
70
|
+
* `/app/src/App.tsx` and having the engine register its own module under a corpus name,
|
|
71
|
+
* which would be baffling rather than dangerous. The confinement argument is recorded in
|
|
72
|
+
* `MDX_FROM_MOUNT_SPEC` §2: inside an executor there is nothing to confine, because the
|
|
73
|
+
* module reaches `globalThis.fetch` whatever the resolver allows.
|
|
74
|
+
*/
|
|
75
|
+
export function resolveComponentPath(raw: unknown, root: string): string | null {
|
|
76
|
+
if (typeof raw !== 'string' || raw === '') return null;
|
|
77
|
+
if (raw.includes('\0') || raw.includes('\\')) return null;
|
|
78
|
+
if (raw.startsWith('/')) return null; // corpus-relative only — an absolute path is a different space
|
|
79
|
+
const base = root.endsWith('/') ? root : `${root}/`;
|
|
80
|
+
const resolved = normalizeAbsolute(base + raw.replace(/^\.\//, ''));
|
|
81
|
+
// Containment, checked on the NORMALIZED path: `a/../../x` starts with the root as a
|
|
82
|
+
// STRING and resolves outside it as a PATH (the hazard `sandboxPathToKey` documents).
|
|
83
|
+
const contained = normalizeAbsolute(base);
|
|
84
|
+
if (resolved !== contained && !resolved.startsWith(`${contained}/`)) return null;
|
|
85
|
+
if (resolved === contained) return null; // the root itself is not a module
|
|
86
|
+
return resolved;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Read the `components` map out of a parsed `immediately.run.json`.
|
|
91
|
+
*
|
|
92
|
+
* Fails soft per entry: one bad declaration is reported and skipped, it never costs the
|
|
93
|
+
* corpus its other components. A marker with no `components` key is the ordinary case and
|
|
94
|
+
* yields an empty list with nothing rejected.
|
|
95
|
+
*/
|
|
96
|
+
export function parseCorpusComponents(marker: unknown, root: string): CorpusComponentDecls {
|
|
97
|
+
const components: CorpusComponent[] = [];
|
|
98
|
+
const rejected: RejectedComponent[] = [];
|
|
99
|
+
const raw = (marker as { components?: unknown } | null | undefined)?.components;
|
|
100
|
+
if (raw === undefined || raw === null) return { components, rejected };
|
|
101
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
102
|
+
return { components, rejected: [{ name: '(components)', reason: 'not an object' }] };
|
|
103
|
+
}
|
|
104
|
+
for (const [name, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
105
|
+
if (!COMPONENT_NAME.test(name)) {
|
|
106
|
+
rejected.push({ name, reason: 'not a capitalized component name' });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const path = resolveComponentPath(value, root);
|
|
110
|
+
if (path === null) {
|
|
111
|
+
rejected.push({ name, reason: `not a corpus-relative path inside the corpus: ${String(value)}` });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
components.push({ name, path });
|
|
115
|
+
}
|
|
116
|
+
return { components, rejected };
|
|
117
|
+
}
|