@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,216 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { resolveOpenWiki, OPEN_WIKI_TASK, CONTENT_MOUNT_TYPE } from './openWiki';
|
|
3
|
+
import { getContentRoot, setContentRoot, resetContentRoot, isDispatched, APP_CONTENT_ROOT } from './contentRoot';
|
|
4
|
+
import { slugToKey, isContentEntry, homeKey, contentDir, keyToHref, sandboxPathToKey } from './content';
|
|
5
|
+
import { layoutChainForKey } from './layout';
|
|
6
|
+
import type { SandboxMount } from '@immediately-run/sdk/mounts';
|
|
7
|
+
|
|
8
|
+
const mount = (path: string, extra: Partial<SandboxMount> = {}): SandboxMount =>
|
|
9
|
+
({ path, type: 'task', ...extra }) as SandboxMount;
|
|
10
|
+
|
|
11
|
+
afterEach(resetContentRoot);
|
|
12
|
+
|
|
13
|
+
describe('resolveOpenWiki — the delegated corpus', () => {
|
|
14
|
+
it('resolves the dir param mounted at the host-minted chroot', () => {
|
|
15
|
+
const r = resolveOpenWiki({ task: OPEN_WIKI_TASK, params: {} }, [mount('/app'), mount('/task/t1/dir')]);
|
|
16
|
+
expect(r).toEqual({ ok: true, root: '/task/t1/dir', readOnly: false, via: 'task' });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('reports a read-only delegation without refusing it', () => {
|
|
20
|
+
// Sharing a corpus read-only is legitimate — the reader still reads. Only the WRITE
|
|
21
|
+
// affordances may consult this; refusing the whole open would break the ordinary case.
|
|
22
|
+
const r = resolveOpenWiki({ task: OPEN_WIKI_TASK, params: {} }, [mount('/task/t1/dir', { mode: 'ro' })]);
|
|
23
|
+
expect(r).toEqual({ ok: true, root: '/task/t1/dir', readOnly: true, via: 'task' });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('is not a callee when there is no task input — the ordinary fork boot', () => {
|
|
27
|
+
expect(resolveOpenWiki(null, [mount('/app')])).toEqual({ ok: false, reason: 'not-a-callee' });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('refuses a task it does not provide', () => {
|
|
31
|
+
expect(resolveOpenWiki({ task: 'edit-file', params: {} }, [mount('/task/t1/file')]))
|
|
32
|
+
.toEqual({ ok: false, reason: 'wrong-task' });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('reports no-mount rather than falling back to its OWN corpus', () => {
|
|
36
|
+
// The dangerous failure: rendering the viewer's repo while the reader believes they
|
|
37
|
+
// are looking at the delegated one. Silence is not an option here — cancel instead.
|
|
38
|
+
expect(resolveOpenWiki({ task: OPEN_WIKI_TASK, params: {} }, [mount('/app')]))
|
|
39
|
+
.toEqual({ ok: false, reason: 'no-mount' });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('never mistakes the app\'s own repo mount for the delegation', () => {
|
|
43
|
+
const r = resolveOpenWiki({ task: OPEN_WIKI_TASK, params: {} }, [mount('/app'), mount('/app/dir')]);
|
|
44
|
+
expect(r).toEqual({ ok: false, reason: 'no-mount' });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('takes a single unambiguous foreign mount when the param segment is not the hook', () => {
|
|
48
|
+
// The host owns the `/task/<slot>/<param>` grammar; if it ever renames the segment,
|
|
49
|
+
// suffix-matching alone would cancel a task the user really asked for.
|
|
50
|
+
const r = resolveOpenWiki({ task: OPEN_WIKI_TASK, params: {} }, [mount('/app'), mount('/mnt/abc123')]);
|
|
51
|
+
expect(r).toEqual({ ok: true, root: '/mnt/abc123', readOnly: false, via: 'task' });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('does not guess between two foreign mounts', () => {
|
|
55
|
+
const r = resolveOpenWiki({ task: OPEN_WIKI_TASK, params: {} }, [mount('/mnt/a'), mount('/mnt/b')]);
|
|
56
|
+
expect(r).toEqual({ ok: false, reason: 'no-mount' });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('honours a canonical app mount path that is not /app', () => {
|
|
60
|
+
// The repo is dual-mounted at /app and /mnt/{hash}; the host reports the canonical
|
|
61
|
+
// one. Hardcoding /app here would read the viewer's own corpus as the delegation.
|
|
62
|
+
const r = resolveOpenWiki({ task: OPEN_WIKI_TASK, params: {} }, [mount('/mnt/self')], '/mnt/self');
|
|
63
|
+
expect(r).toEqual({ ok: false, reason: 'no-mount' });
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('repo-load dispatch — a cold URL load, with no task input at all', () => {
|
|
68
|
+
it('resolves the corpus from the MARKED mount', () => {
|
|
69
|
+
// R3-172: the URL named a content repo; the host resolved this viewer through the
|
|
70
|
+
// binding table and published the corpus stamped `type: 'content'`.
|
|
71
|
+
const r = resolveOpenWiki(null, [
|
|
72
|
+
mount('/app'),
|
|
73
|
+
mount('/mnt/deadbeef', { type: CONTENT_MOUNT_TYPE, name: 'neumark/book-nine-from-here' }),
|
|
74
|
+
]);
|
|
75
|
+
expect(r).toEqual({ ok: true, root: '/mnt/deadbeef', readOnly: false, via: 'repo-load' });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('carries a read-only delegation through', () => {
|
|
79
|
+
const r = resolveOpenWiki(null, [mount('/mnt/x', { type: CONTENT_MOUNT_TYPE, mode: 'ro' })]);
|
|
80
|
+
expect(r).toMatchObject({ ok: true, readOnly: true, via: 'repo-load' });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('answers FORK immediately when there are no mounts at all', () => {
|
|
84
|
+
// Measured, not assumed: a plain present-mode fork publishes NO mounts — worktrees,
|
|
85
|
+
// spaces and dispatched corpora are published, the app's own repo arrives as `/app`
|
|
86
|
+
// through the bundler. So an empty set must mean "fork, render now", never "wait and
|
|
87
|
+
// see", or every ordinary wiki pays a grace period for a guess that never pays off.
|
|
88
|
+
expect(resolveOpenWiki(null, [])).toEqual({ ok: false, reason: 'not-a-callee' });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('is NOT fooled by an unmarked foreign mount when there is no task', () => {
|
|
92
|
+
// The whole reason the host marks it: a reader who holds a space has a foreign mount
|
|
93
|
+
// that is not a corpus. Guessing here would render somebody's space as a wiki.
|
|
94
|
+
expect(resolveOpenWiki(null, [mount('/app'), mount('/spaces/s1', { type: 'firestore' })]))
|
|
95
|
+
.toEqual({ ok: false, reason: 'not-a-callee' });
|
|
96
|
+
expect(resolveOpenWiki(null, [mount('/app'), mount('/mnt/wt', { type: 'worktree' })]))
|
|
97
|
+
.toEqual({ ok: false, reason: 'not-a-callee' });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('prefers the marked mount over a task delegation, and says which', () => {
|
|
101
|
+
// Both present is not a real shape today, but the precedence must be decided rather
|
|
102
|
+
// than emergent: the mark is the host's explicit statement about THIS load.
|
|
103
|
+
const r = resolveOpenWiki({ task: OPEN_WIKI_TASK, params: {} }, [
|
|
104
|
+
mount('/task/t1/dir'),
|
|
105
|
+
mount('/mnt/marked', { type: CONTENT_MOUNT_TYPE }),
|
|
106
|
+
]);
|
|
107
|
+
expect(r).toMatchObject({ root: '/mnt/marked', via: 'repo-load' });
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('contentRoot — the corpus location is a runtime value', () => {
|
|
112
|
+
it('defaults to the fork packaging and reports itself as not dispatched', () => {
|
|
113
|
+
expect(getContentRoot()).toBe(APP_CONTENT_ROOT);
|
|
114
|
+
expect(isDispatched()).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('normalizes a missing trailing slash, so every startsWith/slice holds', () => {
|
|
118
|
+
setContentRoot('/task/t1/dir');
|
|
119
|
+
expect(getContentRoot()).toBe('/task/t1/dir/');
|
|
120
|
+
expect(isDispatched()).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('re-points every helper built on the root, not just the constant', () => {
|
|
124
|
+
// The reason this is a function and not a constant: these helpers are what nav,
|
|
125
|
+
// routing, the sidebar and the layout chain are made of. If any one of them kept the
|
|
126
|
+
// build-time root, a dispatched Grove would render a MIXTURE of two corpora.
|
|
127
|
+
setContentRoot('/task/t1/dir');
|
|
128
|
+
expect(contentDir()).toBe('/task/t1/dir/');
|
|
129
|
+
expect(homeKey()).toBe('/task/t1/dir/home.mdx');
|
|
130
|
+
expect(slugToKey('characters/the-cast')).toBe('/task/t1/dir/characters/the-cast.mdx');
|
|
131
|
+
expect(isContentEntry('/task/t1/dir/themes.mdx')).toBe(true);
|
|
132
|
+
// …and the viewer's OWN corpus stops being content once it is not the root.
|
|
133
|
+
expect(isContentEntry('/app/content/home.mdx')).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('builds the layout chain from the delegated root', () => {
|
|
137
|
+
setContentRoot('/task/t1/dir');
|
|
138
|
+
const corpus = {
|
|
139
|
+
'/task/t1/dir/_layout.mdx': {},
|
|
140
|
+
'/task/t1/dir/plot/_layout.mdx': {},
|
|
141
|
+
'/task/t1/dir/plot/the-rail.mdx': {},
|
|
142
|
+
};
|
|
143
|
+
expect(layoutChainForKey('/task/t1/dir/plot/the-rail.mdx', corpus)).toEqual([
|
|
144
|
+
'/task/t1/dir/_layout.mdx',
|
|
145
|
+
'/task/t1/dir/plot/_layout.mdx',
|
|
146
|
+
]);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('routing — the URL space follows the packaging', () => {
|
|
151
|
+
it('leaves a FORK\'s URLs byte-identical', () => {
|
|
152
|
+
// These are published, cited and deep-linked. A "tidy-up" here breaks every shared
|
|
153
|
+
// link into the docs wiki, so the fork mapping is pinned rather than merely tested.
|
|
154
|
+
expect(keyToHref('/app/content/specs/TRUST_MODES_SPEC.mdx')).toBe('/content/specs/TRUST_MODES_SPEC.mdx');
|
|
155
|
+
expect(sandboxPathToKey('/content/specs/TRUST_MODES_SPEC.mdx')).toBe('/app/content/specs/TRUST_MODES_SPEC.mdx');
|
|
156
|
+
expect(sandboxPathToKey('/files/content/home.mdx')).toBe('/app/content/home.mdx');
|
|
157
|
+
expect(sandboxPathToKey('/')).toBe('/app/content/home.mdx');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('makes a DISPATCHED viewer\'s URLs corpus-relative', () => {
|
|
161
|
+
// The mount is chrooted AT the content directory, so the mount root IS the corpus
|
|
162
|
+
// root — there is no app-root left to measure from.
|
|
163
|
+
setContentRoot('/task/t1/dir');
|
|
164
|
+
expect(keyToHref('/task/t1/dir/plot/the-rail.mdx')).toBe('/plot/the-rail.mdx');
|
|
165
|
+
expect(sandboxPathToKey('/plot/the-rail.mdx')).toBe('/task/t1/dir/plot/the-rail.mdx');
|
|
166
|
+
expect(sandboxPathToKey('/files/plot/the-rail.mdx')).toBe('/task/t1/dir/plot/the-rail.mdx');
|
|
167
|
+
expect(sandboxPathToKey('/')).toBe('/task/t1/dir/home.mdx');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('round-trips key → href → key under both packagings', () => {
|
|
171
|
+
// The pair has to be symmetric or a link renders to a URL that resolves to a different
|
|
172
|
+
// entry — the R3-252 class of failure, where navigation lands on the wrong document.
|
|
173
|
+
const fork = '/app/content/plot/the-rail.mdx';
|
|
174
|
+
expect(sandboxPathToKey(keyToHref(fork))).toBe(fork);
|
|
175
|
+
setContentRoot('/task/t1/dir');
|
|
176
|
+
const dispatched = '/task/t1/dir/plot/the-rail.mdx';
|
|
177
|
+
expect(sandboxPathToKey(keyToHref(dispatched))).toBe(dispatched);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('refuses a traversal that keeps the content-root PREFIX — the shipped-fork shape', () => {
|
|
181
|
+
// ⚠ This is the pre-existing defect, and it is a FORK bug too, not only a dispatch one.
|
|
182
|
+
// The escape needs the `..` to come AFTER the content segment, so the raw-text check
|
|
183
|
+
// passes while the path resolves elsewhere:
|
|
184
|
+
//
|
|
185
|
+
// /files/content/../../src/App.tsx → /app/content/../../src/App.tsx → /src/App.tsx
|
|
186
|
+
// /content/../../../spaces/s1/x.md → … → /spaces/s1/x.md
|
|
187
|
+
//
|
|
188
|
+
// The second is the one that matters: it leaves the app's own repo for ANOTHER MOUNT.
|
|
189
|
+
// The resulting key is what `fs.readFile` gets (bodies, reading time, backlinks) and
|
|
190
|
+
// what `<Include filename>` gets on the compiled path — where it is EVALUATED.
|
|
191
|
+
expect(sandboxPathToKey('/files/content/../../src/App.tsx')).toBe('/app/content/home.mdx');
|
|
192
|
+
expect(sandboxPathToKey('/content/../../../spaces/s1/private.md')).toBe('/app/content/home.mdx');
|
|
193
|
+
expect(sandboxPathToKey('/content/../.claude/memory/x.md')).toBe('/app/content/home.mdx');
|
|
194
|
+
// A `..` that stays inside the corpus is not an escape and still resolves.
|
|
195
|
+
expect(sandboxPathToKey('/content/plot/../home.mdx')).toBe('/app/content/home.mdx');
|
|
196
|
+
expect(sandboxPathToKey('/content/plot/../themes.mdx')).toBe('/app/content/themes.mdx');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('sends a path OUTSIDE the corpus home rather than reading it', () => {
|
|
200
|
+
// Under dispatch this is the guard that stops a crafted URL from naming a file outside
|
|
201
|
+
// the delegation: it can only ever resolve to an entry inside the content root.
|
|
202
|
+
setContentRoot('/task/t1/dir');
|
|
203
|
+
// Traversal resolves and lands outside → home. The check must run on the RESOLVED
|
|
204
|
+
// path: `/task/t1/dir/../../app/src/App.tsx` starts with the content root as a string
|
|
205
|
+
// and is a different file as a path, and this key is what `fs.readFile` receives.
|
|
206
|
+
expect(sandboxPathToKey('/../../app/src/App.tsx')).toBe('/task/t1/dir/home.mdx');
|
|
207
|
+
expect(sandboxPathToKey('/plot/../../../etc/passwd')).toBe('/task/t1/dir/home.mdx');
|
|
208
|
+
|
|
209
|
+
// A URL that merely LOOKS like the viewer's own app path is not one: under dispatch
|
|
210
|
+
// `/app` has no special meaning, so this is an ordinary corpus-relative path that
|
|
211
|
+
// stays inside the delegation and simply has no entry (the 404 index).
|
|
212
|
+
const key = sandboxPathToKey('/app/content/home.mdx');
|
|
213
|
+
expect(key.startsWith('/task/t1/dir/')).toBe(true);
|
|
214
|
+
expect(key).toBe('/task/t1/dir/app/content/home.mdx');
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// The `open-wiki` provider (R3-169) — Grove as a task callee.
|
|
2
|
+
//
|
|
3
|
+
// A caller that finds `opensWith: { task: 'open-wiki' }` on a directory it can read
|
|
4
|
+
// invokes the contract with `dir: capDir(...)`. The host attenuates that against the
|
|
5
|
+
// CALLER's own grants, mints a task-scoped chroot, and mounts it for us. So everything
|
|
6
|
+
// below is downstream of authority we never asked for and cannot widen: the mount IS the
|
|
7
|
+
// grant (`docs/specs/REPO_CONTENT_DISPATCH_SPEC.mdx` §1/§4, UI_AS_APPS_SPEC §5.7).
|
|
8
|
+
//
|
|
9
|
+
// The marker named the CONTRACT, not this app — the host's binding table chose Grove and
|
|
10
|
+
// a user can rebind it to their own fork. Nothing here may assume it is the only viewer.
|
|
11
|
+
|
|
12
|
+
import type { SandboxMount } from '@immediately-run/sdk/mounts';
|
|
13
|
+
import type { TaskInput } from '@immediately-run/sdk/tasks';
|
|
14
|
+
|
|
15
|
+
/** The contract this module provides, and the param carrying the corpus. */
|
|
16
|
+
export const OPEN_WIKI_TASK = 'open-wiki';
|
|
17
|
+
export const DIR_PARAM = 'dir';
|
|
18
|
+
|
|
19
|
+
/** The mount `type` the host stamps on a corpus it dispatched by REPO LOAD (R3-172).
|
|
20
|
+
*
|
|
21
|
+
* A cold URL load has no task invocation, so there is no `useTaskInput()` to learn from —
|
|
22
|
+
* the mount itself has to carry the role. We key on this mark and NOT on "the only foreign
|
|
23
|
+
* mount", which reads as clever and guesses wrong the moment the reader also holds a space
|
|
24
|
+
* or a worktree. If the host did not mark it, we are not dispatched. */
|
|
25
|
+
export const CONTENT_MOUNT_TYPE = 'content';
|
|
26
|
+
|
|
27
|
+
export type OpenWikiResolution =
|
|
28
|
+
| { ok: true; root: string; readOnly: boolean; via: 'task' | 'repo-load' }
|
|
29
|
+
| { ok: false; reason: 'not-a-callee' | 'wrong-task' | 'no-mount' };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Where the delegated corpus is, from the task input and the mount set — pure, so the
|
|
33
|
+
* whole resolution is testable without a host.
|
|
34
|
+
*
|
|
35
|
+
* The host mounts a directory delegation at `/task/<slot>/<paramKey>` (§5.7: for a DIR
|
|
36
|
+
* cap the mount point *is* the directory), so the param name is the hook. We match on the
|
|
37
|
+
* path SUFFIX rather than reconstructing the slot, because the slot is the host's to
|
|
38
|
+
* name and reconstructing it here would couple this app to a private path grammar.
|
|
39
|
+
*
|
|
40
|
+
* The single-non-app-mount fallback exists because that coupling is the failure we can't
|
|
41
|
+
* see: if the host ever renames the segment, suffix-matching alone would resolve to
|
|
42
|
+
* nothing and Grove would cancel a task the user really did ask for. One unambiguous foreign
|
|
43
|
+
* mount is not a guess.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveOpenWiki(
|
|
46
|
+
input: TaskInput | null,
|
|
47
|
+
mounts: readonly SandboxMount[],
|
|
48
|
+
appMountPath = '/app',
|
|
49
|
+
): OpenWikiResolution {
|
|
50
|
+
// REPO-LOAD dispatch (R3-172): the URL named a content repo, the host resolved this
|
|
51
|
+
// viewer through the binding table and published the corpus as a marked mount. There is
|
|
52
|
+
// no task to be a callee of — checked FIRST, because a repo-load dispatch has no task
|
|
53
|
+
// input and would otherwise fall out as `not-a-callee` and render our own corpus.
|
|
54
|
+
const marked = mounts.find((m) => m.type === CONTENT_MOUNT_TYPE);
|
|
55
|
+
if (marked) {
|
|
56
|
+
return { ok: true, root: marked.path, readOnly: marked.mode === 'ro', via: 'repo-load' };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!input) return { ok: false, reason: 'not-a-callee' };
|
|
60
|
+
if (input.task !== OPEN_WIKI_TASK) return { ok: false, reason: 'wrong-task' };
|
|
61
|
+
|
|
62
|
+
const foreign = mounts.filter((m) => m.path !== appMountPath && !m.path.startsWith(`${appMountPath}/`));
|
|
63
|
+
const byParam = foreign.filter((m) => m.path === `/${DIR_PARAM}` || m.path.endsWith(`/${DIR_PARAM}`));
|
|
64
|
+
const hit = byParam[0] ?? (foreign.length === 1 ? foreign[0] : undefined);
|
|
65
|
+
if (!hit) return { ok: false, reason: 'no-mount' };
|
|
66
|
+
|
|
67
|
+
// `mode` is absent on the primary repo mount and rw by default elsewhere. A read-only
|
|
68
|
+
// delegation is a legitimate way to share a corpus — the reader still reads — so it
|
|
69
|
+
// resolves normally and only the WRITE affordances consult this flag.
|
|
70
|
+
return { ok: true, root: hit.path, readOnly: hit.mode === 'ro', via: 'task' };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The message a failed resolution should show, in the reader's terms rather than the
|
|
74
|
+
* protocol's. `not-a-callee` never surfaces — it is the ordinary fork boot. */
|
|
75
|
+
export function openWikiFailureMessage(reason: Exclude<OpenWikiResolution, { ok: true }>['reason']): string {
|
|
76
|
+
switch (reason) {
|
|
77
|
+
case 'wrong-task':
|
|
78
|
+
return 'This viewer was opened with a task it does not provide.';
|
|
79
|
+
case 'no-mount':
|
|
80
|
+
return 'The folder to open was not delivered. Try opening it again.';
|
|
81
|
+
default:
|
|
82
|
+
return '';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { familyTreeQuery, navQuery, searchQuery, sidebarQuery } from './queries';
|
|
3
|
+
|
|
4
|
+
// The metadata queries select records (R3-276a). These tests pin the selections
|
|
5
|
+
// themselves; the components are thin wiring around them. The store shape is the
|
|
6
|
+
// real one the hooks hand a query: absolute paths → frontmatter maps.
|
|
7
|
+
|
|
8
|
+
const fm = {
|
|
9
|
+
'/app/content/index.mdx': { title: 'Home.', nav: 'Start', tags: ['ui/nav'], order: 2 },
|
|
10
|
+
'/app/content/docs.mdx': { title: 'Docs', tags: ['ui/nav'], order: 1 },
|
|
11
|
+
'/app/content/people/ada.mdx': { title: 'Ada Lovelace', team: 'Engineering', tags: ['person'] },
|
|
12
|
+
'/app/content/people/jobs.mdx': { name: 'Steve Jobs', parent: 'Home', tags: ['ui/hidden'] },
|
|
13
|
+
'/app/content/_layout.mdx': { title: 'Not an entry', tags: ['ui/nav'], team: 'X' },
|
|
14
|
+
'/app/src/App.tsx': { title: 'not content', tags: ['ui/nav'] },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe('navQuery', () => {
|
|
18
|
+
it('selects ui/nav entries ordered by `order` (missing = 999), label from nav else title', () => {
|
|
19
|
+
expect(navQuery(fm)).toEqual([
|
|
20
|
+
{ path: '/app/content/docs.mdx', label: 'Docs' },
|
|
21
|
+
{ path: '/app/content/index.mdx', label: 'Start' },
|
|
22
|
+
]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('trims a trailing period from a title-derived label', () => {
|
|
26
|
+
expect(navQuery({ '/app/content/a.mdx': { title: 'A.', tags: ['ui/nav'] } })[0].label).toBe('A');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('never tab-encodes (the R3-276a regression: fake paths are gone)', () => {
|
|
30
|
+
for (const r of navQuery(fm)) expect(JSON.stringify(r)).not.toContain('\t');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('searchQuery', () => {
|
|
35
|
+
it('indexes every content entry with title/desc and non-ui tags', () => {
|
|
36
|
+
const rows = searchQuery(fm);
|
|
37
|
+
expect(rows).toHaveLength(4); // entries only: no _layout.mdx, no src/
|
|
38
|
+
const ada = rows.find((r) => r.path.endsWith('ada.mdx'))!;
|
|
39
|
+
expect(ada.title).toBe('Ada Lovelace');
|
|
40
|
+
expect(ada.tags).toEqual(['person']);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('drops the ui/* furniture namespace from tags', () => {
|
|
44
|
+
expect(searchQuery(fm).find((r) => r.path.endsWith('jobs.mdx'))!.tags).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('sidebarQuery', () => {
|
|
49
|
+
it('indexes every entry with title, raw tags (ui/* kept), and nav', () => {
|
|
50
|
+
const rows = sidebarQuery(fm);
|
|
51
|
+
expect(rows).toHaveLength(4);
|
|
52
|
+
const home = rows.find((r) => r.path.endsWith('index.mdx'))!;
|
|
53
|
+
expect(home.title).toBe('Home.');
|
|
54
|
+
expect(home.nav).toBe('Start');
|
|
55
|
+
expect(home.tags).toEqual(['ui/nav']);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('familyTreeQuery', () => {
|
|
60
|
+
it('groups by house/team/parent/manager in that fallback order', () => {
|
|
61
|
+
expect(familyTreeQuery(fm)).toEqual([
|
|
62
|
+
{ path: '/app/content/people/ada.mdx', label: 'Ada Lovelace', group: 'Engineering' },
|
|
63
|
+
{ path: '/app/content/people/jobs.mdx', label: 'Steve Jobs', group: 'Home' },
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('needs both a group and a name/title; layout files are not entries', () => {
|
|
68
|
+
const out = familyTreeQuery({
|
|
69
|
+
'/app/content/x.mdx': { team: 'Engineering' }, // no name/title
|
|
70
|
+
'/app/content/_layout.mdx': { team: 'X', title: 'L' }, // not an entry
|
|
71
|
+
});
|
|
72
|
+
expect(out).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
// The wiki's metadata queries, as pure functions (R3-276a).
|
|
3
|
+
//
|
|
4
|
+
// These were inline callbacks in GroveWiki/Search/FamilyTree that had to
|
|
5
|
+
// TAB-ENCODE extra fields into fake paths (`[p, label].join('\t')`) because
|
|
6
|
+
// `useMetadataQuery` could only return path strings. The SDK now returns
|
|
7
|
+
// RECORDS (`{ path, ...extra }`, R3-276) and these are the same selections said
|
|
8
|
+
// directly — extracted here so the selection logic is testable without React
|
|
9
|
+
// and the three consumers share one definition of "entry", "label", "group".
|
|
10
|
+
//
|
|
11
|
+
// Every record field is derived data; `path`/`meta` are applied by the hook.
|
|
12
|
+
|
|
13
|
+
import { isContentEntry } from './content';
|
|
14
|
+
|
|
15
|
+
/** A nav item derived from `ui/nav` frontmatter. */
|
|
16
|
+
export type NavRecord = {
|
|
17
|
+
path: string;
|
|
18
|
+
/** `nav` if the entry names itself, else its title with the trailing period trimmed. */
|
|
19
|
+
label: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Entries tagged `ui/nav`, ordered by `order` (missing = 999). */
|
|
23
|
+
export function navQuery(fm: Record<string, any>): NavRecord[] {
|
|
24
|
+
return Object.keys(fm)
|
|
25
|
+
.filter((p) => isContentEntry(p) && Array.isArray(fm[p]?.tags) && fm[p].tags.includes('ui/nav'))
|
|
26
|
+
.sort((a, b) => (fm[a].order ?? 999) - (fm[b].order ?? 999))
|
|
27
|
+
.map((p) => ({ path: p, label: fm[p]?.nav || (fm[p]?.title || '').replace(/\.$/, '') }));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One search index row. `tags` excludes the `ui/*` furniture namespace. */
|
|
31
|
+
export type SearchRecord = {
|
|
32
|
+
path: string;
|
|
33
|
+
title: string;
|
|
34
|
+
desc: string;
|
|
35
|
+
tags: string[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Every content entry, flattened for the ⌘K palette. */
|
|
39
|
+
export function searchQuery(fm: Record<string, any>): SearchRecord[] {
|
|
40
|
+
return Object.keys(fm)
|
|
41
|
+
.filter(isContentEntry)
|
|
42
|
+
.map((p) => ({
|
|
43
|
+
path: p,
|
|
44
|
+
title: fm[p]?.title || '',
|
|
45
|
+
desc: fm[p]?.description || '',
|
|
46
|
+
tags: (fm[p]?.tags || []).filter((t: string) => !t.startsWith('ui/')),
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One sidebar row: the whole entry index with the fields the tree + the
|
|
51
|
+
* `ui/sidebar` sections derive from. */
|
|
52
|
+
export type SidebarRecord = {
|
|
53
|
+
path: string;
|
|
54
|
+
title: string;
|
|
55
|
+
tags: string[];
|
|
56
|
+
nav: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Every content entry, for the sidebar tree + its `ui/sidebar` sections. */
|
|
60
|
+
export function sidebarQuery(fm: Record<string, any>): SidebarRecord[] {
|
|
61
|
+
return Object.keys(fm)
|
|
62
|
+
.filter(isContentEntry)
|
|
63
|
+
.map((p) => ({ path: p, title: fm[p]?.title || '', tags: fm[p]?.tags || [], nav: fm[p]?.nav || '' }));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A node in the relational view: grouped by house/team/parent/manager. */
|
|
67
|
+
export type FamilyNodeRecord = {
|
|
68
|
+
path: string;
|
|
69
|
+
label: string;
|
|
70
|
+
group: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Entries with a relationship field and something to call them. */
|
|
74
|
+
export function familyTreeQuery(fm: Record<string, any>): FamilyNodeRecord[] {
|
|
75
|
+
const out: FamilyNodeRecord[] = [];
|
|
76
|
+
Object.entries(fm).forEach(([p, m]: [string, any]) => {
|
|
77
|
+
if (!isContentEntry(p)) return;
|
|
78
|
+
const group = m?.house || m?.team || m?.parent || m?.manager;
|
|
79
|
+
if (group && (m?.name || m?.title)) {
|
|
80
|
+
out.push({ path: p, label: (m.name || m.title).replace(/\.$/, ''), group: String(group) });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
3
|
+
import { parseSafeMdast, renderMdast } from '@immediately-run/sdk/safeContent/index';
|
|
4
|
+
import { SAFE_INTRINSICS, filterIntrinsicProps } from './safeIntrinsics';
|
|
5
|
+
|
|
6
|
+
// The structural-tag allow-list, proven against the REAL published safe renderer plus real
|
|
7
|
+
// React DOM rendering — because the property at stake ("a content file cannot reach the DOM
|
|
8
|
+
// with an arbitrary attribute") is only observable in the rendered markup. (R3-263)
|
|
9
|
+
|
|
10
|
+
/** Parse MDX-syntax source and render it to static markup through the safe renderer. */
|
|
11
|
+
async function render(src: string, components: Record<string, unknown>): Promise<string> {
|
|
12
|
+
const tree = await parseSafeMdast(src);
|
|
13
|
+
return renderToStaticMarkup(renderMdast(tree, { components: components as never }) as never);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A block-level intrinsic must be opened on its OWN line to parse as JSX — see the
|
|
17
|
+
* micromark case below — so every fixture here uses that shape. */
|
|
18
|
+
const block = (open: string, inner = 'text') => [open, ` ${inner}`, `</${open.match(/^<(\w+)/)![1]}>`].join('\n');
|
|
19
|
+
|
|
20
|
+
describe('safe intrinsics — the allow-list is the barrier, not React', () => {
|
|
21
|
+
it('renders an allow-listed tag with its allow-listed attributes', async () => {
|
|
22
|
+
const html = await render(block('<main className="grove-content" id="x">'), SAFE_INTRINSICS);
|
|
23
|
+
expect(html).toContain('<main class="grove-content" id="x">');
|
|
24
|
+
expect(html).toContain('text');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('drops `style`, which as a STRING crashes the render — and the render is the layout', async () => {
|
|
28
|
+
// React: "The `style` prop expects a mapping from style properties to values, not a
|
|
29
|
+
// string." A throw here takes down the whole shell, for every page, from one content
|
|
30
|
+
// file — so this is an availability defect, not a cosmetic one.
|
|
31
|
+
await expect(render(block('<main style="color:red">'), SAFE_INTRINSICS)).resolves.toContain('<main>');
|
|
32
|
+
// Non-vacuous: the RAW tag really does throw, which is why the wrapper exists.
|
|
33
|
+
await expect(render(block('<main style="color:red">'), { main: 'main' })).rejects.toThrow(/style/i);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('drops `dangerouslySetInnerHTML`, which also crashes the render', async () => {
|
|
37
|
+
await expect(render(block('<main dangerouslySetInnerHTML="<b>x</b>">'), SAFE_INTRINSICS)).resolves.toContain('<main>');
|
|
38
|
+
await expect(render(block('<main dangerouslySetInnerHTML="<b>x</b>">'), { main: 'main' })).rejects.toThrow();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('drops event handlers and URL-bearing attributes outright', async () => {
|
|
42
|
+
const html = await render(block('<main onclick="alert(1)" href="javascript:alert(2)" srcdoc="<script>x</script>">'), SAFE_INTRINSICS);
|
|
43
|
+
expect(html).toBe('<main><p>text</p></main>');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('passes `data-*` and `aria-*` through, since they carry no behaviour', () => {
|
|
47
|
+
expect(filterIntrinsicProps({ 'data-x': '1', 'aria-label': 'y', onclick: 'z', style: 'a' }))
|
|
48
|
+
.toEqual({ 'data-x': '1', 'aria-label': 'y' });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('does NOT register `a`/`img` — those stay the resolving, sanitizing Grove versions', () => {
|
|
52
|
+
// Registering the raw tags here would silently bypass <WikiLink>'s resolution and
|
|
53
|
+
// <AssetImage>'s mount-relative lookup, which is the whole reason they are overrides.
|
|
54
|
+
expect(SAFE_INTRINSICS.a).toBeUndefined();
|
|
55
|
+
expect(SAFE_INTRINSICS.img).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('does NOT register `iframe` — React passes `srcdoc` straight through', async () => {
|
|
59
|
+
expect(SAFE_INTRINSICS.iframe).toBeUndefined();
|
|
60
|
+
// Recorded as the reason: with the raw tag registered, the markup really does carry it.
|
|
61
|
+
const html = await render(block('<iframe srcdoc="<script>alert(1)</script>">'), { iframe: 'iframe' });
|
|
62
|
+
expect(html).toContain('srcdoc');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('does NOT register headings — a JSX heading would carry no `sec-…` id to cite', async () => {
|
|
66
|
+
expect(SAFE_INTRINSICS.h2).toBeUndefined();
|
|
67
|
+
// A markdown heading gets the id; that is the authoring route to keep.
|
|
68
|
+
const tree = await parseSafeMdast('## 8.9 Something');
|
|
69
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
70
|
+
const h = (tree.children as any[]).find((n) => n.type === 'heading');
|
|
71
|
+
expect(h?.data?.hProperties?.id).toBe('sec-8-9');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('an UNREGISTERED tag collapses to a Fragment that keeps its children', async () => {
|
|
75
|
+
// The failure mode is silent — the wrapper and its class vanish, the content stays —
|
|
76
|
+
// which is why the tags a layout needs are registered rather than left to this.
|
|
77
|
+
const html = await render(block('<figure className="x">'), {});
|
|
78
|
+
expect(html).toBe('<p>text</p>');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('the micromark gotcha that makes intrinsics look broken', () => {
|
|
83
|
+
it('a lowercase tag opened on its own line parses as JSX', async () => {
|
|
84
|
+
const tree = await parseSafeMdast(block('<main className="x">'));
|
|
85
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
86
|
+
expect((tree.children as any[])[0]).toMatchObject({ type: 'mdxJsxFlowElement', name: 'main' });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('the SAME tag with inline content on one line is an HTML block — inert text', async () => {
|
|
90
|
+
// Not a bug in the renderer: micromark's HTML-block rule wins. It matters because the
|
|
91
|
+
// symptom is "my layout renders as visible angle brackets" with no error anywhere, and
|
|
92
|
+
// the fix is a line break, which nobody guesses. Pinned so the two shapes stay distinct.
|
|
93
|
+
const tree = await parseSafeMdast('<main className="x">text</main>');
|
|
94
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
95
|
+
expect((tree.children as any[])[0]).toMatchObject({ type: 'html' });
|
|
96
|
+
const html = await render('<main className="x">text</main>', SAFE_INTRINSICS);
|
|
97
|
+
expect(html).not.toContain('<main');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createElement } from 'react';
|
|
2
|
+
import type { ComponentType, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
// The structural HTML tags a CONTENT file may use under the interpreter (safe) renderer,
|
|
5
|
+
// each wrapped so only allow-listed attributes reach the DOM. (R3-263)
|
|
6
|
+
//
|
|
7
|
+
// WHY WRAPPERS AND NOT RAW TAGS. `renderMdast` looks a JSX tag up in the component map by
|
|
8
|
+
// name and, on a hit, spreads the element's literal attributes onto it. The SDK's
|
|
9
|
+
// `literalProps` does **no name filtering and no URL sanitizing** — it copies every literal
|
|
10
|
+
// `mdxJsxAttribute` verbatim. So registering `main: 'main'` would hand a content author a
|
|
11
|
+
// direct channel to a real DOM element's props. Measured against React 19 before choosing
|
|
12
|
+
// this design:
|
|
13
|
+
//
|
|
14
|
+
// <main onclick="alert(1)"> → React DROPS it (lowercase `on*` is never emitted)
|
|
15
|
+
// <a href="javascript:alert(1)"> → React BLOCKS it (replaced with a throwing sentinel)
|
|
16
|
+
// <img onerror="alert(1)"> → React DROPS it
|
|
17
|
+
// <main style="color:red"> → **THROWS** — "style prop expects a mapping … not a string"
|
|
18
|
+
// <main dangerouslySetInnerHTML="…"> → **THROWS** — cannot set with children
|
|
19
|
+
// <iframe srcdoc="<script>…"> → **PASSES THROUGH** — a live XSS
|
|
20
|
+
//
|
|
21
|
+
// So React defends the two vectors people think of first and defends neither of the last
|
|
22
|
+
// two. The two throwing cases are not XSS but they are worse than they look: the throw is
|
|
23
|
+
// in the LAYOUT, so one content file takes down the entire shell for every page. And the
|
|
24
|
+
// `iframe` case is only absent here because `iframe` is not on the list — which is exactly
|
|
25
|
+
// the argument for a closed list rather than a filter people extend ad hoc.
|
|
26
|
+
//
|
|
27
|
+
// The rule, therefore: a closed set of purely structural tags, an allow-list of inert
|
|
28
|
+
// attributes, and no reliance on React's internal defenses as the only barrier.
|
|
29
|
+
//
|
|
30
|
+
// NOT REGISTERED, deliberately:
|
|
31
|
+
// • `a` / `img` — already in the map as <WikiLink>/<AssetImage>, which resolve and
|
|
32
|
+
// sanitize. Registering the raw tags would silently bypass both.
|
|
33
|
+
// • `iframe`, `object`, `embed`, `script`, `style`, `link`, `meta`, `form`, `input`,
|
|
34
|
+
// `button` — executable, navigational, or capable of carrying markup (`srcdoc`).
|
|
35
|
+
// • `h1`–`h6` — a JSX heading skips the shared heading-anchor plugin, so it would carry
|
|
36
|
+
// no `sec-…` id and no `data-slug`, and every citation to it would dangle. Author
|
|
37
|
+
// headings as markdown (`## 8.9 …`) and the ids come for free.
|
|
38
|
+
const ALLOWED_TAGS = [
|
|
39
|
+
'main', 'section', 'article', 'aside', 'nav', 'header', 'footer',
|
|
40
|
+
'div', 'span', 'p', 'ul', 'ol', 'li', 'dl', 'dt', 'dd',
|
|
41
|
+
'figure', 'figcaption', 'blockquote', 'strong', 'em', 'small', 'hr', 'br',
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
44
|
+
/** Inert, presentational attributes. Anything not here — `style`, `on*`,
|
|
45
|
+
* `dangerouslySetInnerHTML`, `href`, `src`, `srcdoc` — is dropped before React sees it. */
|
|
46
|
+
const ALLOWED_ATTRS = new Set(['className', 'id', 'title', 'lang', 'dir', 'role']);
|
|
47
|
+
|
|
48
|
+
/** `data-*` and `aria-*` are open-ended by design and carry no behaviour, so they pass as
|
|
49
|
+
* a prefix rule rather than being enumerated. */
|
|
50
|
+
function isAllowedAttr(name: string): boolean {
|
|
51
|
+
return ALLOWED_ATTRS.has(name) || name.startsWith('data-') || name.startsWith('aria-');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Drop every attribute not on the allow-list. Exported shape is a plain object so the
|
|
55
|
+
* caller can spread it; the filtering is the whole point, so it is tested directly. */
|
|
56
|
+
export function filterIntrinsicProps(props: Record<string, unknown>): Record<string, unknown> {
|
|
57
|
+
const out: Record<string, unknown> = {};
|
|
58
|
+
for (const [k, v] of Object.entries(props)) {
|
|
59
|
+
if (k === 'children') continue;
|
|
60
|
+
if (isAllowedAttr(k)) out[k] = v;
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function wrap(tag: string): ComponentType<Record<string, unknown>> {
|
|
66
|
+
const Safe = ({ children, ...rest }: { children?: ReactNode } & Record<string, unknown>) =>
|
|
67
|
+
createElement(tag, filterIntrinsicProps(rest), children);
|
|
68
|
+
Safe.displayName = `Safe(${tag})`;
|
|
69
|
+
return Safe as ComponentType<Record<string, unknown>>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** `tag → sanitizing wrapper`, merged into the component map the safe renderer consumes.
|
|
73
|
+
* A tag absent from this map is not an error: `renderMdast` collapses an unknown JSX tag
|
|
74
|
+
* to a Fragment that KEEPS ITS CHILDREN, so an unregistered wrapper loses its element and
|
|
75
|
+
* its styling but never its content. */
|
|
76
|
+
export const SAFE_INTRINSICS: Record<string, ComponentType<Record<string, unknown>>> =
|
|
77
|
+
Object.fromEntries(ALLOWED_TAGS.map((t) => [t, wrap(t)]));
|