@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,112 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { useCallback, useContext } from 'react';
3
+ import { Link, useMetadataQuery } from '@immediately-run/sdk';
4
+ import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
5
+ import { hrefKeyCandidates, hrefTargetKey, isContentEntry, keyToHref, linkKind, sandboxPathToKey, splitFragment } from '../lib/content';
6
+ import { isFolderKey } from '../lib/directory';
7
+ import { queryPaths } from '../lib/wiki';
8
+ import Icon from './Icon';
9
+
10
+ interface Props {
11
+ href?: string;
12
+ children?: React.ReactNode;
13
+ className?: string;
14
+ }
15
+
16
+ // The MDX `a` override. A link into the content space renders as a wiki-link with one of
17
+ // three states (brief 00) — resolved / broken / self; an external scheme or a bare
18
+ // `#anchor` renders as an ordinary markdown link, so the two are distinguishable at a
19
+ // glance. Those are the ONLY two shapes: an in-app href that resolves to nothing renders
20
+ // broken, never as a bare `<a>` (see `linkKind` — R3-252).
21
+ export default function WikiLink({ href = '', children, ...rest }: Props) {
22
+ const ctx = useContext(TinkerableContext) as any;
23
+ const currentKey = sandboxPathToKey(ctx?.navigationState?.sandboxPath || '/');
24
+
25
+ // Resolve existence against the whole in-memory index (so a missing target is
26
+ // *definitively* broken, not a load-time flash).
27
+ const allKeysQuery = useCallback(
28
+ (fm: Record<string, any>) => Object.keys(fm).filter(isContentEntry),
29
+ []
30
+ );
31
+ const q = useMetadataQuery(allKeysQuery);
32
+ const keys: string[] = queryPaths(q);
33
+ const loaded = keys.length > 0;
34
+
35
+ // Normalize an in-app content href to its canonical metadata key; null for
36
+ // external/anchor links. Handles the ABSOLUTE forms (`/content/x.mdx`,
37
+ // `/files/content/x.mdx`) and the RELATIVE form an author actually writes
38
+ // (`roadmap/index.mdx`, `../specs/FOO.mdx`), which is resolved against the current
39
+ // entry the same way `check-docs-wiki` resolves `[[…]]` links. A relative href used
40
+ // to land here as `null` and render as a bare `<a>`, so clicking it made the sandbox
41
+ // perform a real navigation instead of routing — the "Failed to construct 'URL'"
42
+ // crash. Candidates are tried in order so a pre-cutover `foo.md` still finds `foo.mdx`
43
+ // even though the corpus itself no longer carries those names.
44
+ const candidates = hrefKeyCandidates(href, currentKey);
45
+ const targetKey = candidates.find((k) => keys.includes(k)) ?? candidates[0] ?? null;
46
+ // Route on the RESOLVED key, not the author's text: handing `<Link>` a relative href
47
+ // would make it resolve against the outer page URL rather than the content tree.
48
+ const [, fragment] = splitFragment(href);
49
+ const resolvedHref = targetKey ? keyToHref(targetKey) + fragment : href;
50
+
51
+ // Only an href that MEANS to leave the document becomes a real `<a>` — see `linkKind`.
52
+ // An unresolvable in-app href renders broken rather than falling through to an anchor
53
+ // that would navigate the sandboxed frame away and kill the app (R3-252).
54
+ if (!targetKey) {
55
+ const kind = linkKind(href);
56
+ // A FOLDER is a destination too, since directory listings landed: `[the
57
+ // handbook](handbook)` names something real, and the extension test that decides
58
+ // what an *entry* is would otherwise call it broken. Judged from the index (no fs
59
+ // call per link) — see `isFolderKey` for what that trades away.
60
+ const dirKey = kind === 'content' ? hrefTargetKey(href, currentKey) : null;
61
+ if (dirKey && loaded && isFolderKey(dirKey, keys)) {
62
+ return (
63
+ <Link href={keyToHref(dirKey) + fragment} className="grove-wikilink" data-state="ok" {...rest}>
64
+ {children}
65
+ </Link>
66
+ );
67
+ }
68
+ if (kind !== 'content') {
69
+ const web = /^https?:/i.test(href);
70
+ return (
71
+ <a
72
+ className="mdlink"
73
+ href={href}
74
+ {...(web ? { target: '_blank', rel: 'noreferrer' } : {})}
75
+ {...rest}
76
+ >
77
+ {children}
78
+ </a>
79
+ );
80
+ }
81
+ return (
82
+ <span className="grove-wikilink" data-state="broken" title={`No entry at ${href}`}>
83
+ <Icon name="unlink" />
84
+ {children}
85
+ </span>
86
+ );
87
+ }
88
+
89
+ if (targetKey === currentKey) {
90
+ return (
91
+ <span className="grove-wikilink" data-state="self">
92
+ {children}
93
+ </span>
94
+ );
95
+ }
96
+
97
+ const exists = !loaded || keys.includes(targetKey); // optimistic until loaded
98
+ if (!exists) {
99
+ return (
100
+ <span className="grove-wikilink" data-state="broken" title={`No entry at ${href}`}>
101
+ <Icon name="unlink" />
102
+ {children}
103
+ </span>
104
+ );
105
+ }
106
+
107
+ return (
108
+ <Link href={resolvedHref} className="grove-wikilink" data-state="ok" {...rest}>
109
+ {children}
110
+ </Link>
111
+ );
112
+ }
@@ -0,0 +1,14 @@
1
+ // The theme catalogue for the theme menu (id → label + swatch gradient). Data,
2
+ // not components — kept out of the chrome components per the Fast-Refresh rule.
3
+ export interface Theme {
4
+ id: string;
5
+ label: string;
6
+ swatch: string;
7
+ }
8
+
9
+ export const THEMES: Theme[] = [
10
+ { id: 'default', label: 'immediately.run', swatch: 'linear-gradient(96deg,#f6f1fb,#f49ad4 46%,#b285f2)' },
11
+ { id: 'pixies', label: 'Pixies', swatch: 'linear-gradient(96deg,#ffe14d,#ff2d8e 50%,#9d29ff)' },
12
+ { id: 'family', label: 'Family journal', swatch: 'linear-gradient(96deg,#f3cf9a,#e09a6a 50%,#c8744f)' },
13
+ { id: 'lotr', label: 'Middle-earth', swatch: 'linear-gradient(96deg,#b89a56,#8a6a36 50%,#4a5a38)' },
14
+ ];
package/src/devfs.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ // Pull in the `fs` module types provided by @immediately-run/dev-fs, so app
2
+ // code can `import fs from 'fs'` and type-check against the async-only surface
3
+ // immediately.run exposes. See https://github.com/immediately-run/dev-fs
4
+ /// <reference types="@immediately-run/dev-fs/fs" />
@@ -0,0 +1,122 @@
1
+ // Corpus-declared components — the LOAD half (R3-174; MDX_FROM_MOUNT_SPEC §2).
2
+ //
3
+ // Reads the corpus's own `immediately.run.json`, evaluates each declared module out of the
4
+ // corpus filesystem, and hands back a `name → component` map for the MDX provider. The
5
+ // declaration half (what counts as a usable entry) is `lib/corpusComponents`.
6
+ //
7
+ // **Why this can evaluate a module that lives in someone else's repo.** It is the same
8
+ // call `<Include>` already makes for every entry body: `getModuleEvaluationContext` on an
9
+ // absolute path. Under dispatch that path is in the corpus mount, and the bundler
10
+ // transpiles and evaluates it exactly as it does an `/app` module — the two filesystems
11
+ // differ in where the bytes come from, not in how a module is made from them.
12
+ //
13
+ // **What a content component may import, and why it is not restricted** (decision B,
14
+ // 2026-08-26; recorded in `MDX_FROM_MOUNT_SPEC` §2). `resolveAsync` walks the dirname
15
+ // chain from the importing file, and `/node_modules` is one registryfs shared by the whole
16
+ // frame — so a corpus module's `react` and `@immediately-run/sdk` are THE VIEWER'S
17
+ // instances, not second copies. That is what makes `useMetadataQuery` and `<Link>` work
18
+ // from content: same React, same context. It also means confinement would have to be
19
+ // added deliberately, and the decision is not to: inside an executor there is nothing to
20
+ // confine, since the module reaches `globalThis.fetch` whatever the resolver permits
21
+ // (`MDX_FROM_MOUNT_SPEC` §4 already concedes this). A corpus that wants no code execution
22
+ // declares `render: safe`, where no import exists at all.
23
+ //
24
+ // **No live re-registration in v1, deliberately.** §7 1b asks for a reactive, name-keyed
25
+ // registry so a hot edit cannot leave a stale reference behind. The map here IS keyed by
26
+ // name, so re-evaluation overwrites cleanly — but nothing re-triggers it: the working-tree
27
+ // change stream (`onFsChange`) is gated on elevated `editor:read`, which a dispatched
28
+ // viewer does not hold, so there is no signal to subscribe to. An author edit needs a
29
+ // reload. The shape is the one that extends; the trigger is what is missing.
30
+
31
+ import { useEffect, useState } from 'react';
32
+ import fs from 'fs';
33
+ import { parseCorpusComponents, type RejectedComponent } from '../lib/corpusComponents';
34
+
35
+ declare const module: {
36
+ getModuleEvaluationContext: (name: string) => Promise<{ exports: Record<string, unknown> }>;
37
+ };
38
+
39
+ /** The marker file a corpus uses to declare itself content (and now its vocabulary). */
40
+ const MARKER = 'immediately.run.json';
41
+
42
+ export interface ContentComponents {
43
+ /** `idle` — no corpus root yet, nothing to do · `loading` — hold the render, the
44
+ * provider must be complete before content paints · `ready` — use `components`. */
45
+ status: 'idle' | 'loading' | 'ready';
46
+ /** Name → component, empty when the corpus declares none (the ordinary case). */
47
+ components: Record<string, unknown>;
48
+ /** Declarations that could not be used, with the reason. Rendered by the caller as a
49
+ * visible notice rather than swallowed: a component that silently never appears is the
50
+ * failure mode this whole path exists to remove. */
51
+ rejected: RejectedComponent[];
52
+ }
53
+
54
+ const NOTHING: ContentComponents = { status: 'idle', components: {}, rejected: [] };
55
+
56
+ async function loadDeclared(root: string): Promise<Omit<ContentComponents, 'status'>> {
57
+ const base = root.endsWith('/') ? root : `${root}/`;
58
+ let marker: unknown;
59
+ try {
60
+ marker = JSON.parse(await fs.promises.readFile(`${base}${MARKER}`, 'utf8'));
61
+ } catch {
62
+ // No marker, or unreadable/malformed JSON. Both mean "this corpus declares no
63
+ // components" — the overwhelmingly common case, and not worth a notice.
64
+ return { components: {}, rejected: [] };
65
+ }
66
+ const { components: declared, rejected } = parseCorpusComponents(marker, base);
67
+ const components: Record<string, unknown> = {};
68
+ const failures = [...rejected];
69
+ // Sequential, not parallel: the list is short (a corpus declares a vocabulary, not a
70
+ // dependency closure) and the bundler already dedups and queues resolution, so
71
+ // concurrency here buys nothing and makes a failure harder to attribute.
72
+ for (const { name, path } of declared) {
73
+ try {
74
+ const evaluated = await module.getModuleEvaluationContext(path);
75
+ // `default` is the convention every other module-from-a-path surface uses
76
+ // (`<Include exportedSymbol="default">`); a named export matching the declared
77
+ // name is accepted too, so a file exporting `export function RoadmapBoard()` and
78
+ // nothing else works without a redundant default re-export.
79
+ const component = evaluated.exports.default ?? evaluated.exports[name];
80
+ if (typeof component !== 'function') {
81
+ failures.push({ name, reason: `${path} exports no component (default or ${name})` });
82
+ continue;
83
+ }
84
+ components[name] = component;
85
+ } catch (error) {
86
+ failures.push({ name, reason: `${path} failed to load: ${String(error)}` });
87
+ }
88
+ }
89
+ return { components, rejected: failures };
90
+ }
91
+
92
+ /**
93
+ * Load the components `root`'s corpus declares, or do nothing when `root` is null (the
94
+ * boot gate has not resolved a corpus yet).
95
+ *
96
+ * Returns `loading` until every declaration has resolved, so the caller can hold the
97
+ * content paint. That is the §2 invariant — compose the complete provider before content
98
+ * paints, never render into a partial one — and it costs nothing here because Grove
99
+ * already gates on `useOpenWikiBoot` and `useCorpusMetadata`. Rendering into a
100
+ * half-composed provider would flash a missing-component error for `<RoadmapBoard>` until
101
+ * registration landed, which is exactly the error this path removes.
102
+ */
103
+ export function useContentComponents(root: string | null): ContentComponents {
104
+ const [loaded, setLoaded] = useState<{ root: string } & Omit<ContentComponents, 'status'> | null>(null);
105
+
106
+ useEffect(() => {
107
+ if (!root || loaded?.root === root) return;
108
+ let cancelled = false;
109
+ void loadDeclared(root).then((result) => {
110
+ if (!cancelled) setLoaded({ root, ...result });
111
+ });
112
+ return () => {
113
+ cancelled = true;
114
+ };
115
+ }, [root, loaded?.root]);
116
+
117
+ if (!root) return NOTHING;
118
+ if (loaded?.root === root) {
119
+ return { status: 'ready', components: loaded.components, rejected: loaded.rejected };
120
+ }
121
+ return { status: 'loading', components: {}, rejected: [] };
122
+ }
@@ -0,0 +1,43 @@
1
+ // The dispatched corpus's frontmatter index (R3-265).
2
+ //
3
+ // A FORK gets its index from the bundler and this hook does nothing. A DISPATCHED viewer
4
+ // has to build it: the corpus is a mount the bundler never scanned, so without this the
5
+ // wiki renders with empty nav, empty sidebar, no search, no backlinks and no routing —
6
+ // silently, because an absent index is indistinguishable from an empty corpus.
7
+ //
8
+ // The scan runs ONCE per root and the result is handed to `TinkerableContext.filesMetadata`,
9
+ // which is where `useMetadataQuery` / `useFileMetadata` / `useAllMetadata` already read
10
+ // from — so every consumer keeps working untouched.
11
+
12
+ import { useEffect, useState } from 'react';
13
+ import fs from 'fs';
14
+ import { scanCorpus, type CorpusMetadata, type ScanFs } from '../lib/corpusScan';
15
+
16
+ export interface CorpusIndex {
17
+ /** `idle` — a fork, nothing to scan · `scanning` — hold the render · `ready` — use it. */
18
+ status: 'idle' | 'scanning' | 'ready';
19
+ metadata: CorpusMetadata | null;
20
+ }
21
+
22
+ /** Scan `root`, or do nothing when it is null (the fork packaging). */
23
+ export function useCorpusMetadata(root: string | null): CorpusIndex {
24
+ const [index, setIndex] = useState<{ root: string; metadata: CorpusMetadata } | null>(null);
25
+
26
+ useEffect(() => {
27
+ if (!root || index?.root === root) return;
28
+ let cancelled = false;
29
+ void scanCorpus(root, fs.promises as unknown as ScanFs).then((metadata) => {
30
+ // A corpus that resolves to nothing is still a result: `ready` with an empty map
31
+ // renders the 404 index, which tells the reader the folder has no entries. Staying
32
+ // in `scanning` forever would show a spinner and say nothing.
33
+ if (!cancelled) setIndex({ root, metadata });
34
+ });
35
+ return () => {
36
+ cancelled = true;
37
+ };
38
+ }, [root, index?.root]);
39
+
40
+ if (!root) return { status: 'idle', metadata: null };
41
+ if (index?.root === root) return { status: 'ready', metadata: index.metadata };
42
+ return { status: 'scanning', metadata: null };
43
+ }
@@ -0,0 +1,56 @@
1
+ // Does this key name a DIRECTORY, and what is in it?
2
+ //
3
+ // The frontmatter index cannot answer this. It holds `.md`/`.mdx` entries only, so a
4
+ // folder of assets is invisible to it and a folder whose only child is an image would
5
+ // read as "nothing here". The filesystem is the authority, so this hook asks it — the
6
+ // one effectful step in the directory feature (the rules live in `lib/directory`).
7
+ //
8
+ // It runs ONLY for a key the entry index already missed, which is the whole cost
9
+ // argument: an ordinary page render performs no extra I/O, and the readdir happens on
10
+ // the path that was about to render a 404 anyway.
11
+
12
+ import { useEffect, useState } from 'react';
13
+ import fs from 'fs';
14
+ import type { DirEntry } from '../lib/directory';
15
+
16
+ export type DirectoryListing =
17
+ /** Not asked (a resolved entry), or the key is a file — render the entry / the 404. */
18
+ | { status: 'none' }
19
+ /** The readdir is in flight. Callers must NOT show the 404 here: a 404 that flashes
20
+ * and then becomes a listing reads as a broken link that healed itself. */
21
+ | { status: 'checking' }
22
+ | { status: 'ready'; entries: DirEntry[] };
23
+
24
+ /** The `fs.promises` slice this hook needs — injectable so the effect is testable. */
25
+ export interface ReaddirFs {
26
+ readdir(path: string, opts: { withFileTypes: true }): Promise<Array<{ name: string; isDirectory(): boolean }>>;
27
+ }
28
+
29
+ /** Read `dirKey`, or do nothing when it is null. A read that throws means "not a
30
+ * directory" (ENOTDIR/ENOENT are the same answer to the only question asked). */
31
+ export function useDirectoryListing(dirKey: string | null, io?: ReaddirFs): DirectoryListing {
32
+ const [state, setState] = useState<{ key: string; entries: DirEntry[] | null } | null>(null);
33
+
34
+ useEffect(() => {
35
+ if (!dirKey || state?.key === dirKey) return;
36
+ let cancelled = false;
37
+ const api = io ?? (fs.promises as unknown as ReaddirFs);
38
+ api
39
+ .readdir(dirKey, { withFileTypes: true })
40
+ .then((items) => {
41
+ if (!cancelled) {
42
+ setState({ key: dirKey, entries: items.map((i) => ({ name: i.name, isDirectory: i.isDirectory() })) });
43
+ }
44
+ })
45
+ .catch(() => {
46
+ if (!cancelled) setState({ key: dirKey, entries: null });
47
+ });
48
+ return () => {
49
+ cancelled = true;
50
+ };
51
+ }, [dirKey, state?.key, io]);
52
+
53
+ if (!dirKey) return { status: 'none' };
54
+ if (state?.key !== dirKey) return { status: 'checking' };
55
+ return state.entries ? { status: 'ready', entries: state.entries } : { status: 'none' };
56
+ }
@@ -0,0 +1,96 @@
1
+ // The rendered entry's headings, and which one the reader is in.
2
+ //
3
+ // Extracted from `<Toc>` when `<TableOfContents>` arrived, so the two surfaces cannot
4
+ // disagree about what a heading is — the drift ENGINE_BOUNDARY §4 exists to prevent. The
5
+ // scan reads the DOM rather than the source because an entry is rendered through
6
+ // `<Include>` (compiled MDX) or the safe renderer; neither hands back a heading list, and
7
+ // the ids a citation lands on are the ones actually in the document.
8
+
9
+ import { useEffect, useState } from 'react';
10
+ import { headingId } from '../lib/wiki';
11
+
12
+ export interface Heading {
13
+ id: string;
14
+ text: string;
15
+ level: number;
16
+ }
17
+
18
+ /** Heading text WITHOUT the kernel's autolink anchor. The SDK's HeadingAnchor (§15.4)
19
+ * prepends `<a class="ir-heading-anchor">#</a>` as the first child, so `textContent`
20
+ * alone would put a stray `#` in every label and in the id fallback. */
21
+ function headingText(node: Element): string {
22
+ const anchor = node.querySelector('.ir-heading-anchor');
23
+ if (!anchor) return (node.textContent || '').trim();
24
+ return Array.from(node.childNodes)
25
+ .filter((c) => c !== anchor)
26
+ .map((c) => c.textContent ?? '')
27
+ .join('')
28
+ .trim();
29
+ }
30
+
31
+ /**
32
+ * Scan `.grove-prose` for `h2`/`h3`, assigning the canonical id to any heading the kernel
33
+ * did not emit one for, and re-scan as the prose mounts or swaps on navigation.
34
+ */
35
+ export function useHeadings(entryKey?: string): Heading[] {
36
+ const [heads, setHeads] = useState<Heading[]>([]);
37
+
38
+ useEffect(() => {
39
+ // eslint-disable-next-line react-hooks/set-state-in-effect
40
+ setHeads([]);
41
+ const scan = () => {
42
+ const prose = document.querySelector('.grove-prose');
43
+ const nodes = prose ? Array.from(prose.querySelectorAll('h2, h3')) : [];
44
+ const found: Heading[] = nodes.map((n) => {
45
+ const text = headingText(n);
46
+ // Prefer the kernel-emitted id (§15.5); `headingId` reproduces the same canon
47
+ // (`@immediately-run/mdx-plugins`) for a heading that has none.
48
+ const id = n.id || headingId(text);
49
+ n.id = id;
50
+ return { id, text, level: n.tagName === 'H3' ? 3 : 2 };
51
+ });
52
+ // Identity-stable when nothing changed: this runs from a MutationObserver, and a
53
+ // fresh array every mutation would re-run every downstream effect (the spy, the
54
+ // scroll) on each keystroke of an editor-driven re-render.
55
+ setHeads((prev) =>
56
+ prev.length === found.length && prev.every((h, i) => h.id === found[i].id) ? prev : found
57
+ );
58
+ };
59
+ scan();
60
+ const prose = document.querySelector('.grove-prose');
61
+ const obs = prose ? new MutationObserver(scan) : null;
62
+ if (prose && obs) obs.observe(prose, { childList: true, subtree: true });
63
+ // `<Include>` resolves asynchronously, and the observer only fires if `.grove-prose`
64
+ // already existed — these catch the window where it did not.
65
+ const timers = [120, 300, 600].map((d) => setTimeout(scan, d));
66
+ return () => {
67
+ obs?.disconnect();
68
+ timers.forEach(clearTimeout);
69
+ };
70
+ }, [entryKey]);
71
+
72
+ return heads;
73
+ }
74
+
75
+ /** Which heading the reader is currently in — scroll-spied off the document. */
76
+ export function useActiveHeading(heads: Heading[]): string {
77
+ const [cur, setCur] = useState<string>('');
78
+
79
+ useEffect(() => {
80
+ if (!heads.length) return;
81
+ const obs = new IntersectionObserver(
82
+ (entries) => {
83
+ const visible = entries.filter((e) => e.isIntersecting);
84
+ if (visible.length) setCur((visible[0].target as HTMLElement).id);
85
+ },
86
+ { rootMargin: '-64px 0px -70% 0px', threshold: 0 }
87
+ );
88
+ heads.forEach((h) => {
89
+ const el = document.getElementById(h.id);
90
+ if (el) obs.observe(el);
91
+ });
92
+ return () => obs.disconnect();
93
+ }, [heads]);
94
+
95
+ return cur;
96
+ }
@@ -0,0 +1,95 @@
1
+ // Boot resolution for the `open-wiki` provider (R3-169).
2
+ //
3
+ // Grove has two packagings and this is where they diverge, ONCE, before anything reads
4
+ // the corpus:
5
+ //
6
+ // • FORK — engine and corpus in one repo. No task input ever arrives; the content
7
+ // root stays `/app/content/` and this hook is a no-op.
8
+ // • DISPATCH — a caller invoked `open-wiki` with a directory delegation. The host
9
+ // mounts the corpus at a task-scoped chroot and we re-point the content
10
+ // root at it before the first entry renders.
11
+ //
12
+ // The distinction has to be settled BEFORE render, not during it: every helper that
13
+ // answers "is this key an entry / what is its href / which layouts wrap it" is built on
14
+ // the root, so a component that renders while the root is still the default would resolve
15
+ // against the VIEWER's corpus and then not re-resolve. Hence a gate, not a fallback.
16
+
17
+ import { useEffect, useState } from 'react';
18
+ import { useMounts, getAppMountPath } from '@immediately-run/sdk/mounts';
19
+ import { useTaskInput, cancelTask } from '@immediately-run/sdk/tasks';
20
+ import { setContentRoot, isDispatched, isContentReadOnly } from '../lib/contentRoot';
21
+ import { resolveOpenWiki, openWikiFailureMessage } from '../lib/openWiki';
22
+
23
+ /** How long a callee waits for its delegated mount before cancelling. Long enough for the
24
+ * host's mount announcement to land after the overlay mounts, short enough that a reader
25
+ * is not left looking at a blank frame until the §5.7.1 liveness bound fires. */
26
+ const MOUNT_GRACE_MS = 5_000;
27
+
28
+ export interface OpenWikiBoot {
29
+ /** `fork` — render our own corpus · `waiting` — a callee whose mount hasn't arrived ·
30
+ * `ready` — the delegated root is set · `failed` — cancelled, show the message. */
31
+ status: 'fork' | 'waiting' | 'ready' | 'failed';
32
+ message: string;
33
+ readOnly: boolean;
34
+ }
35
+
36
+ export function useOpenWikiBoot(): OpenWikiBoot {
37
+ const input = useTaskInput();
38
+ const mounts = useMounts();
39
+ const [failed, setFailed] = useState('');
40
+
41
+ const resolution = resolveOpenWiki(input, mounts ?? [], getAppMountPath());
42
+ if (resolution.ok) {
43
+ // Setting the root is what makes the corpus readable, so it happens in render — an
44
+ // effect would run AFTER the first content render, which is the whole failure this
45
+ // gate exists to prevent. Idempotent and purely derived, so a StrictMode double
46
+ // render sets the same value twice.
47
+ setContentRoot(resolution.root, { readOnly: resolution.readOnly });
48
+ }
49
+
50
+ // The module IS the latch: once a root is set, the delegation is final for the life of
51
+ // the instance. Deriving "resolved" from it rather than from local state is what stops
52
+ // a momentarily empty mount set from cancelling a task that is already open and being
53
+ // read — and it needs no ref, which the render rules would not allow anyway.
54
+ const resolved = isDispatched();
55
+
56
+ // ⚠ THE COLD-LOAD RACE, and why this does NOT wait for it.
57
+ //
58
+ // A task callee always has a `useTaskInput()`, so "no input" reliably meant "a fork". On a
59
+ // cold URL load there is no input either way, so at first render "fork" and "dispatched,
60
+ // mount not yet announced" look identical.
61
+ //
62
+ // The obvious guard — hold the render until the mount set is non-empty — is WRONG here,
63
+ // and measurably so: a plain present-mode fork publishes **no mounts at all** (only
64
+ // worktrees, spaces and dispatched corpora are published; the app's own repo arrives as
65
+ // `/app` through the bundler, not through the mount channel). So "wait for mounts" would
66
+ // block every ordinary wiki for the full grace period on a guess that never pays off.
67
+ //
68
+ // Instead: answer immediately, and let a late mark CORRECT the answer. `resolveOpenWiki`
69
+ // re-runs on every mount change and `setContentRoot` latches, so a corpus announced after
70
+ // first paint still flips this to `ready`. The residual is a brief flash of the viewer's
71
+ // own corpus IF the host announces late — which it does not in practice, because the host
72
+ // publishes from a React effect long before the sandboxed app's bundler has finished
73
+ // loading it. **That ordering is a requirement on the host, not a hope**: publish the
74
+ // corpus mount before the app boots, exactly as task delegations are minted before the
75
+ // callee boots (`runTaskInvoke`).
76
+ const pendingReason = resolved || resolution.ok || !input ? null : resolution.reason;
77
+
78
+ useEffect(() => {
79
+ if (!pendingReason) return;
80
+ // A callee whose delegation never arrives must not silently render its OWN corpus —
81
+ // the reader asked for THEIR folder. Give the mount a moment (it arrives on a host
82
+ // message just after the overlay mounts), then cancel with a typed error so the
83
+ // caller's `invokeTask` rejects `cancelled` instead of hanging to the liveness bound.
84
+ const t = setTimeout(() => {
85
+ setFailed(openWikiFailureMessage(pendingReason));
86
+ cancelTask();
87
+ }, MOUNT_GRACE_MS);
88
+ return () => clearTimeout(t);
89
+ }, [pendingReason]);
90
+
91
+ if (failed) return { status: 'failed', message: failed, readOnly: false };
92
+ if (resolved) return { status: 'ready', message: '', readOnly: isContentReadOnly() };
93
+ if (!input) return { status: 'fork', message: '', readOnly: false };
94
+ return { status: 'waiting', message: '', readOnly: false };
95
+ }
package/src/index.css ADDED
@@ -0,0 +1,120 @@
1
+ /* Global foundation: brand fonts, design tokens, and base resets.
2
+ Imported from App.tsx (NOT main.tsx) so immediately.run applies it at runtime.
3
+ Tokens mirror the immediately.run design system — pull colors, fonts, radii,
4
+ and shadows from here rather than hard-coding values. */
5
+
6
+ /* Web fonts MUST be the first line of the file. */
7
+ @import url('https://fonts.googleapis.com/css2?family=Gabarito:wght@400;500;600;700;800;900&family=Public+Sans:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap');
8
+
9
+ /* ---------------------------------------------------------------- DARK (default) */
10
+ :root {
11
+ /* — Surfaces — */
12
+ --bg: #0a0b11; /* cool near-black page background */
13
+ --panel: #13141d; /* tile / card surface */
14
+ --panel-2: #191b26; /* raised / hover surface */
15
+
16
+ /* — Hairlines — */
17
+ --line: rgba(180,170,225,.14); /* default border */
18
+ --line-2: rgba(180,170,225,.22); /* stronger border / focus ring base */
19
+
20
+ /* — Text — */
21
+ --ink: #ecebf4; /* primary text (near-white, faint violet) */
22
+ --ink-2: #9b97b3; /* muted violet-gray — secondary text */
23
+ --ink-3: #6a677f; /* faint — captions, disabled */
24
+
25
+ /* — Accents — */
26
+ --accent: oklch(0.74 0.17 350); /* pink-magenta — the brand through-line */
27
+ --accent-2: oklch(0.66 0.18 295); /* violet */
28
+ --accent-3: oklch(0.82 0.12 340); /* soft pink highlight (tags, badges) */
29
+
30
+ /* — Signature gradient + glow — */
31
+ --grad: linear-gradient(96deg, #f6f1fb 0%, #f49ad4 46%, #b285f2 100%);
32
+ --glow: 0 0 0 1px rgba(150,110,240,.5), 0 0 34px rgba(150,110,240,.32);
33
+
34
+ /* — Solid accent fallbacks (for text/icons that can't take a gradient) — */
35
+ --accent-pink: #f49ad4;
36
+ --accent-violet: #b285f2;
37
+ --accent-hot: #c43d96; /* the saturated "accent tile" magenta */
38
+
39
+ /* — Type families — */
40
+ --disp: "Gabarito", system-ui, sans-serif; /* display / headings */
41
+ --sans: "Public Sans", system-ui, sans-serif; /* body / UI */
42
+ --mono: "Space Mono", ui-monospace, monospace; /* code, stats, labels */
43
+
44
+ /* — Radii — */
45
+ --r-xs: 5px; /* logo square, inline code */
46
+ --r-sm: 6px; /* small tags, keycaps */
47
+ --r-md: 12px; /* code blocks, inputs */
48
+ --r-lg: 16px; /* cards, tiles, panels */
49
+ --r-xl: 18px; /* modals */
50
+ --r-pill: 30px; /* buttons, nav links, badges */
51
+
52
+ /* — Elevation — */
53
+ --shadow-card: 6px 6px 0 var(--accent-2); /* hard offset on tile hover */
54
+ --shadow-modal: 0 24px 70px rgba(0,0,0,.5);
55
+ --shadow-pop: 0 12px 34px rgba(0,0,0,.45);
56
+
57
+ /* — Ambient page wash (radial accents behind content) — */
58
+ --page-wash:
59
+ radial-gradient(60% 50% at 78% 0%, rgba(178,133,242,.12), transparent 60%),
60
+ radial-gradient(50% 40% at 12% 8%, rgba(244,154,212,.07), transparent 60%);
61
+ }
62
+
63
+ /* ---------------------------------------------------------------- LIGHT */
64
+ html[data-theme="light"] {
65
+ --bg: #f6f4fb;
66
+ --panel: #ffffff;
67
+ --panel-2: #f0ecf8;
68
+ --line: rgba(40,24,70,.12);
69
+ --line-2: rgba(40,24,70,.20);
70
+ --ink: #1c1726;
71
+ --ink-2: #5d5670;
72
+ --ink-3: #938aa6;
73
+ --accent: oklch(0.58 0.21 1);
74
+ --accent-2: oklch(0.52 0.21 295);
75
+ --accent-3: oklch(0.50 0.20 350);
76
+ --grad: linear-gradient(96deg, #c43d96 0%, #9a45e6 100%);
77
+ --glow: 0 0 0 1px rgba(150,90,235,.4), 0 8px 26px rgba(150,90,235,.20);
78
+ --shadow-modal: 0 24px 70px rgba(40,24,70,.18);
79
+ --shadow-pop: 0 12px 34px rgba(40,24,70,.14);
80
+ --page-wash:
81
+ radial-gradient(60% 50% at 78% 0%, rgba(154,69,230,.09), transparent 60%),
82
+ radial-gradient(50% 40% at 12% 8%, rgba(212,69,154,.06), transparent 60%);
83
+ }
84
+
85
+ /* ---------------------------------------------------------------- reset + base */
86
+ * { box-sizing: border-box; margin: 0; padding: 0; }
87
+ html { scroll-behavior: smooth; }
88
+ body {
89
+ background: var(--bg);
90
+ background-image: var(--page-wash);
91
+ color: var(--ink);
92
+ font: 400 16px/1.5 var(--sans);
93
+ -webkit-font-smoothing: antialiased;
94
+ min-height: 100vh;
95
+ overflow-x: hidden;
96
+ }
97
+ a { color: inherit; text-decoration: none; }
98
+ ::selection { background: var(--accent); color: #16101a; }
99
+
100
+ /* Keyboard key cap — <Kbd>⌘K</Kbd> in MDX. */
101
+ .grove-kbd {
102
+ display: inline-flex;
103
+ align-items: center;
104
+ padding: 0 .42em;
105
+ border: 1px solid var(--line);
106
+ border-radius: var(--r-sm);
107
+ background: var(--panel-2);
108
+ color: var(--ink);
109
+ font: 700 .82em/1 var(--mono);
110
+ letter-spacing: .02em;
111
+ box-shadow: 0 1px 0 var(--line-2);
112
+ }
113
+
114
+ /* Apply the signature gradient to any text: <span className="grad-text">…</span> */
115
+ .grad-text {
116
+ background: var(--grad);
117
+ -webkit-background-clip: text;
118
+ background-clip: text;
119
+ color: transparent;
120
+ }