@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,93 @@
1
+ // "Scroll the selected entry into view — when necessary."
2
+ //
3
+ // The arithmetic is here, as a pure function, for two reasons.
4
+ //
5
+ // **It is the part that is easy to get subtly wrong**, and the failure is invisible in a
6
+ // screenshot: scrolling when the entry was already visible (the list twitches on every
7
+ // heading the reader passes), or leaving it just under the fold because the margin was
8
+ // measured against the wrong edge.
9
+ //
10
+ // **The obvious API is a trap.** `element.scrollIntoView()` — even with
11
+ // `block: 'nearest'` — walks EVERY scrollable ancestor, so calling it on a table-of-
12
+ // contents link also scrolls the document that contains it. The reader passes a heading,
13
+ // the page jumps: the exact opposite of the feature. So this returns a `scrollTop` for ONE
14
+ // container and the caller assigns it, and no ancestor can move.
15
+
16
+ /** The measurements this needs, in one object — so it can be called from a real DOM
17
+ * (`clientHeight`/`scrollTop`/`offsetTop`) and from a test with plain numbers. */
18
+ export interface ScrollView {
19
+ /** The scroll container's visible height. */
20
+ viewHeight: number;
21
+ /** Its current scroll offset. */
22
+ scrollTop: number;
23
+ /** Its total scrollable height. */
24
+ scrollHeight: number;
25
+ }
26
+
27
+ export interface ScrollItem {
28
+ /** The item's offset from the top of the container's content. */
29
+ top: number;
30
+ height: number;
31
+ }
32
+
33
+ export interface ScrollOptions {
34
+ /** Breathing room kept between the item and the edge it is being pulled away from.
35
+ * An item flush against the fold reads as "the last one" — the margin is what tells
36
+ * the reader there is more list in that direction. */
37
+ margin?: number;
38
+ }
39
+
40
+ /**
41
+ * The `scrollTop` that brings `item` into view inside `view`, or **null when no scroll is
42
+ * needed** — the "when necessary" half, and the reason this returns null rather than the
43
+ * current offset: a caller that assigned an unchanged value would still cancel a smooth
44
+ * scroll already in flight and re-fire scroll events.
45
+ *
46
+ * Nearest-edge, not centred: an entry one line below the fold should rise one line, not
47
+ * jump to the middle. Centring would move the list on almost every step and destroy the
48
+ * reader's sense of place in it.
49
+ */
50
+ export function scrollOffsetFor(
51
+ view: ScrollView,
52
+ item: ScrollItem,
53
+ opts: ScrollOptions = {}
54
+ ): number | null {
55
+ const margin = opts.margin ?? 0;
56
+ const maxScroll = Math.max(0, view.scrollHeight - view.viewHeight);
57
+ // Nothing to scroll: the whole list fits. Every item is already in view by definition,
58
+ // and clamping below would otherwise return 0 for an item at the top and read as "scroll".
59
+ if (maxScroll <= 0) return null;
60
+
61
+ const top = item.top;
62
+ const bottom = item.top + item.height;
63
+ const viewTop = view.scrollTop;
64
+ const viewBottom = view.scrollTop + view.viewHeight;
65
+
66
+ let target: number;
67
+ if (top - margin < viewTop) {
68
+ target = top - margin; // above the fold — pull it down to the top edge
69
+ } else if (bottom + margin > viewBottom) {
70
+ target = bottom + margin - view.viewHeight; // below — pull it up to the bottom edge
71
+ } else {
72
+ return null; // already visible with room to spare
73
+ }
74
+
75
+ const clamped = Math.max(0, Math.min(target, maxScroll));
76
+ // The clamp can land exactly where we already are — an item inside the top margin when
77
+ // the list is already scrolled to 0. That is "already in view", not a scroll.
78
+ return clamped === view.scrollTop ? null : clamped;
79
+ }
80
+
81
+ /** Read a live element pair into the shapes above. `offsetTop` is relative to the nearest
82
+ * positioned ancestor, so the container must be a positioning context (the stylesheet
83
+ * gives it `position: relative`) or the arithmetic is measured from the wrong origin. */
84
+ export function measure(container: HTMLElement, item: HTMLElement): [ScrollView, ScrollItem] {
85
+ return [
86
+ {
87
+ viewHeight: container.clientHeight,
88
+ scrollTop: container.scrollTop,
89
+ scrollHeight: container.scrollHeight,
90
+ },
91
+ { top: item.offsetTop, height: item.offsetHeight },
92
+ ];
93
+ }
@@ -0,0 +1,194 @@
1
+ import { SLUG_PARITY_FIXTURE } from '@immediately-run/mdx-plugins';
2
+ import { describe, it, expect } from 'vitest';
3
+ import { backlinkSnippet, bodyLinks, bodyLinksTo, headingId, sectionId, textSlug } from './wiki';
4
+
5
+ // The heading ids Grove computes MUST match the kernel's heading-slug plugin
6
+ // (`@immediately-run/mdx-plugins` remarkHeadingAnchors, MARKDOWN_SYNTAX_SPEC §15.1 /
7
+ // R3-186 + R3-211) so a `<Toc>` link and the heading's own autolink anchor resolve
8
+ // to the same target (§15.5).
9
+ //
10
+ // Since R3-277 that is true by CONSTRUCTION — `wiki.ts` re-exports the canon rather
11
+ // than reproducing it — and the fixture below is what makes the claim testable
12
+ // instead of merely structural: if a future change reintroduces a local copy, or the
13
+ // canon moves under us, this fails here rather than in a reader's broken TOC link.
14
+ describe('the shared slug parity fixture (R3-277)', () => {
15
+ it('grove agrees with the canon on every case', () => {
16
+ for (const c of SLUG_PARITY_FIXTURE) {
17
+ expect(textSlug(c.text), c.why).toBe(c.slug);
18
+ expect(sectionId(c.text), c.why).toBe(c.section);
19
+ expect(headingId(c.text), c.why).toBe(c.id);
20
+ }
21
+ });
22
+
23
+ it('the fixture actually arrived (a vacuous loop would pass silently)', () => {
24
+ expect(SLUG_PARITY_FIXTURE.length).toBeGreaterThan(10);
25
+ });
26
+ });
27
+
28
+ // The cases below predate the fixture and are kept: they read as documentation of
29
+ // the grammar at the call site, and they would catch a fixture that lost a case.
30
+ describe('headingId — byte-identical with the kernel (§15.5)', () => {
31
+ it('prose heading → GitHub text slug', () => {
32
+ expect(headingId('Getting started')).toBe('getting-started');
33
+ expect(headingId('The **bold** heading'.replace(/\*\*/g, ''))).toBe('the-bold-heading');
34
+ });
35
+
36
+ it('numbered heading → prose-independent section id (R3-211)', () => {
37
+ expect(headingId('8.9 Powerbox')).toBe('sec-8-9');
38
+ expect(headingId('8.9 Renamed entirely')).toBe('sec-8-9'); // prose-independent
39
+ expect(headingId('7. The guarantee')).toBe('sec-7');
40
+ expect(headingId('7A. Filesystem trust mode')).toBe('sec-7a'); // distinct from sec-7
41
+ expect(headingId('A.0 Branding')).toBe('sec-a-0');
42
+ expect(headingId('3.2.1 Something')).toBe('sec-3-2-1');
43
+ });
44
+
45
+ it('sectionId is null for prose (no false positives)', () => {
46
+ expect(sectionId('Decisions & rejected alternatives')).toBeNull();
47
+ expect(sectionId('Getting started')).toBeNull();
48
+ });
49
+
50
+ it('textSlug matches GitHub slugging (drops `.`, collapses hyphens)', () => {
51
+ expect(textSlug('8.9 Powerbox')).toBe('89-powerbox'); // GitHub drops the dot
52
+ expect(textSlug('Q & A: notes!')).toBe('q-a-notes');
53
+ expect(textSlug(' spaced out ')).toBe('spaced-out');
54
+ });
55
+ });
56
+
57
+ // ── Backlinks (R3-283) ───────────────────────────────────────────────────────
58
+ //
59
+ // The index was dead: 9,030 wiki-links in the corpus, 0 of 710 entries with a
60
+ // backlink, because `bodyLinksTo` matched three literal link forms the corpus never
61
+ // writes. The failure shape is why it survived — a feature that renders its empty
62
+ // state perfectly, where "nothing links here yet" is indistinguishable from a correct
63
+ // answer. So these assert SPECIFIC counts per supported form: a matcher that resolves
64
+ // only one form fails here rather than looking plausible.
65
+ describe('bodyLinksTo — every link form the corpus actually writes (R3-283)', () => {
66
+ const FROM = '/app/content/roadmap/R3-283.mdx';
67
+ const SPEC = '/app/content/specs/PLATFORM_LAYERING_SPEC.mdx';
68
+ const SIBLING = '/app/content/roadmap/R3-282.mdx';
69
+
70
+ const forms: Array<{ why: string; body: string; target: string }> = [
71
+ { why: 'relative wiki-link with ../ and the extension', body: 'see [[../specs/PLATFORM_LAYERING_SPEC.mdx]]', target: SPEC },
72
+ { why: '…and with a #fragment', body: 'see [[../specs/PLATFORM_LAYERING_SPEC.mdx#sec-2]]', target: SPEC },
73
+ { why: '…and with an explicit label (label|target, §13.1 order)', body: 'see [[layering|../specs/PLATFORM_LAYERING_SPEC.mdx#sec-2]]', target: SPEC },
74
+ { why: 'sibling wiki-link, no path prefix', body: 'see [[R3-282.mdx]]', target: SIBLING },
75
+ { why: 'extension-less target — the legacy [[slug]] form', body: 'see [[R3-282]]', target: SIBLING },
76
+ { why: 'markdown link, relative', body: 'see [the spec](../specs/PLATFORM_LAYERING_SPEC.mdx)', target: SPEC },
77
+ { why: 'markdown link, corpus-absolute', body: 'see [x](/content/specs/PLATFORM_LAYERING_SPEC.mdx)', target: SPEC },
78
+ { why: 'markdown link, /files-prefixed', body: 'see [x](/files/content/specs/PLATFORM_LAYERING_SPEC.mdx)', target: SPEC },
79
+ { why: 'markdown link with a fragment', body: 'see [x](../specs/PLATFORM_LAYERING_SPEC.mdx#sec-2)', target: SPEC },
80
+ ];
81
+
82
+ for (const f of forms) {
83
+ it(`resolves: ${f.why}`, () => {
84
+ expect(bodyLinksTo(f.body, f.target, FROM)).toBe(true);
85
+ });
86
+ }
87
+
88
+ it('finds ALL of them in one body — the count is specific, not ">= 1"', () => {
89
+ const body = forms.map((f) => f.body).join('\n\n');
90
+ expect(bodyLinks(body)).toHaveLength(forms.length);
91
+ expect(bodyLinksTo(body, SPEC, FROM)).toBe(true);
92
+ expect(bodyLinksTo(body, SIBLING, FROM)).toBe(true);
93
+ });
94
+ });
95
+
96
+ describe('bodyLinksTo — what must NOT count as a backlink (R3-283)', () => {
97
+ const FROM = '/app/content/roadmap/R3-283.mdx';
98
+ const OTHER = '/app/content/specs/OTHER_SPEC.mdx';
99
+
100
+ it('a PREFIX of the target key is not the target', () => {
101
+ // The bug this guards: substring matching would call these the same entry.
102
+ expect(bodyLinksTo('see [[../specs/OTHER.mdx]]', OTHER, FROM)).toBe(false);
103
+ expect(bodyLinksTo('see [[../specs/OTHER_SPEC_V2.mdx]]', OTHER, FROM)).toBe(false);
104
+ expect(bodyLinksTo('see [[../specs/OTHER_SPEC.mdx]]', OTHER, FROM)).toBe(true);
105
+ });
106
+
107
+ it('a link inside a fenced code block is being QUOTED, not written', () => {
108
+ const body = ['```ts', 'body.includes(`[[../specs/OTHER_SPEC.mdx]]`)', '```'].join('\n');
109
+ expect(bodyLinksTo(body, OTHER, FROM)).toBe(false);
110
+ });
111
+
112
+ it('a link inside an inline code span is quoted too', () => {
113
+ // Verbatim from this very item's prose, which documents the forms it fixes.
114
+ expect(bodyLinksTo('it wants `[[../specs/OTHER_SPEC.mdx]]` instead', OTHER, FROM)).toBe(false);
115
+ });
116
+
117
+ it('a link in the frontmatter is metadata, not body prose', () => {
118
+ const body = ['---', 'reads-first:', ' - ../specs/OTHER_SPEC.mdx', 'title: "[[../specs/OTHER_SPEC.mdx]]"', '---', 'nothing here'].join('\n');
119
+ expect(bodyLinksTo(body, OTHER, FROM)).toBe(false);
120
+ });
121
+
122
+ it('an image is not a link, and an external href is not a corpus link', () => {
123
+ expect(bodyLinksTo('![alt](../specs/OTHER_SPEC.mdx)', OTHER, FROM)).toBe(false);
124
+ expect(bodyLinksTo('[x](https://example.com/specs/OTHER_SPEC.mdx)', OTHER, FROM)).toBe(false);
125
+ });
126
+
127
+ it('relative means relative TO THE LINKING ENTRY, not to the corpus root', () => {
128
+ // `[[specs/X.mdx]]` written in roadmap/ denotes roadmap/specs/X.mdx. Reading it as
129
+ // corpus-relative (what the old matcher did for `[[slug]]`) would credit a backlink
130
+ // to an entry the link does not navigate to.
131
+ expect(bodyLinksTo('see [[specs/OTHER_SPEC.mdx]]', OTHER, FROM)).toBe(false);
132
+ expect(bodyLinksTo('see [[specs/OTHER_SPEC.mdx]]', '/app/content/roadmap/specs/OTHER_SPEC.mdx', FROM)).toBe(true);
133
+ });
134
+ });
135
+
136
+ describe('backlinkSnippet — marks the linking phrase, not the first 160 chars (R3-283)', () => {
137
+ const FROM = '/app/content/roadmap/R3-283.mdx';
138
+ const SPEC = '/app/content/specs/PLATFORM_LAYERING_SPEC.mdx';
139
+ const lead = 'A paragraph of preamble that exists only to push the link past the first 160 characters of the entry, so a snippet that fell back to a prefix would be visibly wrong. ';
140
+
141
+ it('uses the markdown label', () => {
142
+ const snip = backlinkSnippet(`${lead}and then [the layering spec](../specs/PLATFORM_LAYERING_SPEC.mdx) closes it.`, SPEC, FROM);
143
+ expect(snip).toContain('<mark>the layering spec</mark>');
144
+ expect(snip).toContain('closes it');
145
+ });
146
+
147
+ it('uses the wiki label when one is given', () => {
148
+ const snip = backlinkSnippet(`${lead}and then [[the layering spec|../specs/PLATFORM_LAYERING_SPEC.mdx]] closes it.`, SPEC, FROM);
149
+ expect(snip).toContain('<mark>the layering spec</mark>');
150
+ });
151
+
152
+ it('falls back to a prefix only when the phrase is not in the rendered text', () => {
153
+ const snip = backlinkSnippet(`${lead}[[../specs/PLATFORM_LAYERING_SPEC.mdx]]`, SPEC, FROM);
154
+ // An unlabelled wiki-link's "phrase" is its target path, which the stripped text
155
+ // does contain — so this marks it rather than degrading.
156
+ expect(snip).toContain('<mark>');
157
+ });
158
+ });
159
+
160
+ describe('backlinkSnippet — a snippet is prose, not source (R3-283)', () => {
161
+ const FROM = '/app/content/roadmap/R3-283.mdx';
162
+ const SPEC = '/app/content/specs/TARGET_SPEC.mdx';
163
+
164
+ it('drops the link syntax around the marked phrase', () => {
165
+ const snip = backlinkSnippet(
166
+ 'The link itself calls the target [[the layering contract|../specs/TARGET_SPEC.mdx#sec-2]] and carries a fragment.',
167
+ SPEC,
168
+ FROM,
169
+ );
170
+ expect(snip).toContain('calls the target <mark>the layering contract</mark> and carries');
171
+ for (const noise of ['[[', ']]', '../specs/TARGET_SPEC.mdx']) expect(snip).not.toContain(noise);
172
+ });
173
+
174
+ it('does the same for a markdown link', () => {
175
+ const snip = backlinkSnippet('This entry links to [the target spec](../specs/TARGET_SPEC.mdx) with ordinary markdown.', SPEC, FROM);
176
+ expect(snip).toContain('links to <mark>the target spec</mark> with ordinary markdown');
177
+ expect(snip).not.toContain('](');
178
+ });
179
+
180
+ it('an UNLABELLED wiki-link renders as its target, and that is what gets marked', () => {
181
+ const snip = backlinkSnippet('This entry mentions [[../specs/TARGET_SPEC.mdx]] once.', SPEC, FROM);
182
+ expect(snip).toContain('mentions <mark>../specs/TARGET_SPEC.mdx</mark> once');
183
+ expect(snip).not.toContain('[[');
184
+ });
185
+
186
+ it('other links in the same sentence are reduced too, not just the matched one', () => {
187
+ const snip = backlinkSnippet(
188
+ 'See [the other one](../specs/OTHER.mdx) and then [the target spec](../specs/TARGET_SPEC.mdx).',
189
+ SPEC,
190
+ FROM,
191
+ );
192
+ expect(snip).toContain('See the other one and then <mark>the target spec</mark>');
193
+ });
194
+ });
@@ -0,0 +1,175 @@
1
+ // Pure wiki helpers — no React, no components (kept out of component files per the
2
+ // Fast-Refresh rule). Shared by the reading-view, index, and agent surfaces.
3
+ import { contentDir, hrefKeyCandidates, splitFragment } from './content';
4
+ // The canonical `[[label|target]]` grammar (MARKDOWN_SYNTAX_SPEC §13.1) — the SAME
5
+ // parser the safe renderer splits wiki-links with, so the backlink index and the link
6
+ // a reader clicks can never disagree about what a target is.
7
+ import { parseWikiInner } from '@immediately-run/sdk/safeContent/index';
8
+
9
+ /** Extract the path strings from a `useMetadataQuery` result. The hook returns a
10
+ * `{ path, meta }[]` array directly (or `{ error }`), NOT a `{ result }` wrapper —
11
+ * centralising the unwrap here keeps every consumer honest. */
12
+ export function queryPaths(q: unknown): string[] {
13
+ return Array.isArray(q) ? (q as { path: string }[]).map((e) => e.path) : [];
14
+ }
15
+
16
+ /** Records from a metadata query that returned records: the entries as given, or
17
+ * `[]` when the query threw (`{ error }` result). The record-shape twin of
18
+ * {@link queryPaths}. */
19
+ export function queryRecords<E extends object>(q: unknown): ({ path: string } & E)[] {
20
+ return Array.isArray(q) ? (q as ({ path: string } & E)[]) : [];
21
+ }
22
+
23
+ /** Average adult reading speed; `→ N min read` is rounded up, min 1. */
24
+ export function readingTime(body: string): number {
25
+ const words = body.trim().split(/\s+/).filter(Boolean).length;
26
+ return Math.max(1, Math.round(words / 220));
27
+ }
28
+
29
+ // Heading ids come from the CANON (R3-277). `@immediately-run/mdx-plugins` owns the
30
+ // slug grammar — it is the plugin the compiled and safe render paths both run, so its
31
+ // `headingId` is by definition the id a reader lands on. Grove reproduced it locally
32
+ // (per §15.5 "specified precisely so a consumer can reproduce it") and the two agreed
33
+ // because they were written from the same paragraph, which is a promise rather than a
34
+ // mechanism: the failure mode is a `<Toc>` entry that scrolls nowhere, silently.
35
+ //
36
+ // This deliberately relaxes "grove depends on the SDK, not the transpiler" for the
37
+ // PLUGINS package only. That package is the byte-canon, is dependency-free, and is
38
+ // consumed here for a pure function — none of the transpiler's machinery comes with
39
+ // it. Re-exported so every existing import site in this repo is unchanged.
40
+ export { textSlug, sectionId, headingId } from '@immediately-run/mdx-plugins';
41
+
42
+ /** A content key → its namespace breadcrumb, e.g.
43
+ * `/app/content/handbook/onboarding.mdx` → `handbook / onboarding`. */
44
+ export function crumb(key: string): string {
45
+ return key
46
+ .replace(contentDir(), '')
47
+ .replace(/\.mdx?$/, '')
48
+ .split('/')
49
+ .join(' / ');
50
+ }
51
+
52
+ /** The namespace folder of a key, e.g. `handbook` (or '' for a root entry). */
53
+ export function namespaceOf(key: string): string {
54
+ const rel = key.replace(contentDir(), '').replace(/\.mdx?$/, '');
55
+ const parts = rel.split('/');
56
+ return parts.length > 1 ? parts.slice(0, -1).join('/') : '';
57
+ }
58
+
59
+ /** Drop a leading `--- … ---` YAML frontmatter block from raw MDX source. */
60
+ export function stripFrontmatter(src: string): string {
61
+ const m = src.match(/^---\n[\s\S]*?\n---\n?/);
62
+ return m ? src.slice(m[0].length) : src;
63
+ }
64
+
65
+ // ── Backlinks (R3-283) ───────────────────────────────────────────────────────
66
+ //
67
+ // `bodyLinksTo` used to string-match three literal forms — `(/content/X.mdx)`,
68
+ // `(/files/content/X.mdx)` and `[[X]]` (corpus-relative, extension-less). The corpus
69
+ // writes NONE of them: it writes `[[../specs/X.mdx#sec-2]]` and `(ways_of_working.mdx)`,
70
+ // both RELATIVE to the linking entry, with the extension, sometimes with a fragment.
71
+ // Result: 9,030 wiki-links, 0 of 710 entries with a backlink — `<Backlinks/>`, the
72
+ // signature wiki affordance, had never worked on this corpus and rendered its empty
73
+ // state perfectly while doing so.
74
+ //
75
+ // So these do not pattern-match link SYNTAX any more. They EXTRACT each link and
76
+ // resolve it through `hrefKeyCandidates` — the same function the renderer routes
77
+ // clicks through, and the same rule `check-docs-wiki`'s `contentResolve` audits the
78
+ // corpus by. A backlink now means exactly "a link that would navigate here", which is
79
+ // the only definition that cannot drift from the links themselves.
80
+ //
81
+ // Deliberately DROPPED: the old corpus-relative reading of `[[specs/X]]`. Under the one
82
+ // resolution rule that link, written in `content/roadmap/foo.mdx`, denotes
83
+ // `content/roadmap/specs/X.mdx` — so honouring the old reading would credit a backlink
84
+ // to an entry the link does not go to. An extension-less target still works; it is just
85
+ // resolved like every other link.
86
+
87
+ /** Frontmatter, fenced code and inline code spans removed — the regions where a
88
+ * `[[…]]` or `[…](…)` is being QUOTED rather than linked. Without this the roadmap
89
+ * items that document link forms would manufacture backlinks out of their own prose. */
90
+ export function linkScannableBody(body: string): string {
91
+ return stripFrontmatter(body)
92
+ .replace(/^[ \t]*(```|~~~)[^\n]*\n[\s\S]*?^[ \t]*\1[^\n]*$/gm, '')
93
+ .replace(/`[^`\n]*`/g, '');
94
+ }
95
+
96
+ /** One extracted link: the raw href, plus the text a reader sees (the wiki label or
97
+ * the markdown label) — what `backlinkSnippet` marks. */
98
+ export interface BodyLink {
99
+ href: string;
100
+ label: string;
101
+ }
102
+
103
+ /** Every corpus link in `body`, in source order. Wiki targets are parsed with the SDK's
104
+ * canonical `parseWikiInner` (`[[label|target]]`, label first — MARKDOWN_SYNTAX §13.1)
105
+ * rather than a local regex, so this cannot drift from what the renderer links. */
106
+ export function bodyLinks(body: string): BodyLink[] {
107
+ const scannable = linkScannableBody(body);
108
+ const out: BodyLink[] = [];
109
+ for (const m of scannable.matchAll(/\[\[([^[\]]+)\]\]/g)) {
110
+ const token = parseWikiInner(m[1]);
111
+ if (token) out.push({ href: token.target, label: token.label ?? token.target });
112
+ }
113
+ // `[label](href)`, skipping images (`![alt](src)`) and an optional "title".
114
+ for (const m of scannable.matchAll(/(!?)\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+"[^"]*")?\s*\)/g)) {
115
+ if (m[1] === '!') continue;
116
+ out.push({ href: m[3], label: m[2] });
117
+ }
118
+ return out;
119
+ }
120
+
121
+ /** The entry keys a body link denotes. An extension-less target also gets an `.mdx`
122
+ * candidate, so the legacy `[[slug]]` form keeps resolving — by the same rule, not a
123
+ * special case. */
124
+ function linkTargetKeys(href: string, fromKey: string): string[] {
125
+ const keys = hrefKeyCandidates(href, fromKey);
126
+ if (keys.length) return keys;
127
+ const [path, frag] = splitFragment(href);
128
+ return /\.mdx?$/.test(path) || !path ? [] : hrefKeyCandidates(`${path}.mdx${frag}`, fromKey);
129
+ }
130
+
131
+ /** Does the entry at `fromKey` link to the entry at `targetKey`? Resolves every link
132
+ * in the body the way the renderer does, then compares KEYS — so a near-miss
133
+ * (`[[specs/OTHER.mdx]]` against `specs/OTHER_SPEC.mdx`) cannot match. */
134
+ export function bodyLinksTo(body: string, targetKey: string, fromKey: string): boolean {
135
+ return bodyLinks(body).some((l) => linkTargetKeys(l.href, fromKey).includes(targetKey));
136
+ }
137
+
138
+ /** Body prose as a READER sees it: HTML tags dropped, and a link reduced to the text
139
+ * it renders as. Without the second half a snippet reads
140
+ * `calls the target [[the layering contract|../specs/X.mdx#sec-2]] and carries`, which
141
+ * is the source, not the sentence — the noise was invisible while the index was empty
142
+ * and appeared the moment backlinks started resolving (R3-283). */
143
+ export function renderedText(body: string): string {
144
+ return stripFrontmatter(body)
145
+ .replace(/<[^>]+>/g, ' ')
146
+ // `[[label|target]]` → label, `[[target]]` → target (the §13.1 order).
147
+ .replace(/\[\[([^[\]]+)\]\]/g, (_, inner: string) => {
148
+ const token = parseWikiInner(inner);
149
+ return token ? (token.label ?? token.target) : inner;
150
+ })
151
+ // `[label](href)` → label; an image renders as its alt text.
152
+ .replace(/!?\[([^\]]*)\]\(\s*[^)\s]+(?:\s+"[^"]*")?\s*\)/g, '$1')
153
+ .replace(/\s+/g, ' ')
154
+ .trim();
155
+ }
156
+
157
+ /** A ~160-char snippet of `body` around the first link to `targetKey`, with the
158
+ * linking phrase wrapped in <mark>… (returned as an HTML string for the snippet). */
159
+ export function backlinkSnippet(body: string, targetKey: string, fromKey: string): string {
160
+ const text = renderedText(body);
161
+ // The MARK is the linking phrase as the reader sees it: a wiki label (or its target
162
+ // when unlabelled), or the markdown label. Matching on the resolved link rather than
163
+ // on a literal href is what stops every hit falling back to "first 160 chars".
164
+ const link = bodyLinks(body).find((l) => linkTargetKeys(l.href, fromKey).includes(targetKey));
165
+ const phrase = link?.label.trim();
166
+ const idx = phrase ? text.toLowerCase().indexOf(phrase.toLowerCase()) : -1;
167
+ if (!phrase || idx === -1) {
168
+ return text.slice(0, 160) + (text.length > 160 ? '…' : '');
169
+ }
170
+ const start = Math.max(0, idx - 70);
171
+ const end = Math.min(text.length, idx + phrase.length + 70);
172
+ const before = (start > 0 ? '…' : '') + text.slice(start, idx);
173
+ const after = text.slice(idx + phrase.length, end) + (end < text.length ? '…' : '');
174
+ return `${before}<mark>${phrase}</mark>${after}`;
175
+ }
package/src/lib.ts ADDED
@@ -0,0 +1,54 @@
1
+ // The library entry (PLATFORM_LAYERING_SPEC §1.1 mode M3, R3-280).
2
+ //
3
+ // Grove composes with a corpus in three modes and this is the third: a thin shell — an
4
+ // ordinary `App.tsx` — imports the engine as a pinned dependency and arranges it, keeping
5
+ // ownership of composition without forking engine source. The other two modes are
6
+ // unchanged by this file: `src/App.tsx` is still the fork/dispatch entry immediately.run
7
+ // renders, and it is exported here too, so a shell that wants the whole wiki (gate
8
+ // included) mounts one component.
9
+ //
10
+ // What Grove IS, stated once because the packaging makes it answerable: a kit of React
11
+ // components, prebuilt layouts and themes that make a wiki easy to build and good-looking
12
+ // by default — NOT a wiki engine in the traditional sense. The parts a traditional wiki
13
+ // engine owns are cross-cutting platform concerns and live below this package: routing,
14
+ // MDX compilation, the frontmatter metadata index, link spaces, includes and heading
15
+ // anchors are sandbox + SDK, because every immediately.run app needs them and not only
16
+ // wikis. What is left here — and it is a real thing to own — is the vocabulary, the
17
+ // chrome, and the defaults.
18
+ //
19
+ // Exports only. No component is DEFINED here, so the Fast-Refresh rule is satisfied by
20
+ // re-export; the components themselves live one per file as they always did.
21
+
22
+ export { default as GroveApp } from './App';
23
+ export { default as GroveWiki } from './GroveWiki';
24
+
25
+ // The component vocabulary + the two render maps.
26
+ export { GROVE_MDX, SAFE_MDX } from './mdxComponents';
27
+
28
+ // The override contract of library composition.
29
+ export {
30
+ VIEWER_MANIFEST,
31
+ ManifestOverrideError,
32
+ composeComponents,
33
+ manifestNames,
34
+ overridableNames,
35
+ } from './lib/compose';
36
+ export type { ViewerManifest, ManifestComponent } from './lib/compose';
37
+
38
+ // Shell state, so an overriding chrome component can read what the stock one reads.
39
+ export { GroveShellContext, OutletContext, useShell } from './lib/shell';
40
+ export type { GroveShell, NavItem } from './lib/shell';
41
+
42
+ // Corpus helpers a shell needs to address entries the way the engine does.
43
+ export {
44
+ contentDir,
45
+ homeKey,
46
+ isContentEntry,
47
+ keyToFsPath,
48
+ keyToHref,
49
+ keyToInclude,
50
+ sandboxPathToKey,
51
+ } from './lib/content';
52
+ export { getContentRoot, isDispatched } from './lib/contentRoot';
53
+ export { layoutChainForKey } from './lib/layout';
54
+ export { queryPaths, readingTime, stripFrontmatter } from './lib/wiki';
package/src/main.tsx ADDED
@@ -0,0 +1,19 @@
1
+ // Entry point. immediately.run runs this module (package.json "main") and
2
+ // boot() installs the host runtime providers (module cache, MDX provider,
3
+ // TinkerableContext, router) and renders into #root. Global CSS is imported
4
+ // here so it is present at runtime.
5
+ import './index.css';
6
+ import './GroveApp.css';
7
+ import { boot } from '@immediately-run/sdk/boot';
8
+ import { GROVE_MDX } from './mdxComponents';
9
+ import App from './App';
10
+
11
+ // A single catch-all route renders the Grove shell for every URL; the shell
12
+ // reads the current path from TinkerableContext and renders the matching entry,
13
+ // so the nav/footer/theme chrome persists across navigation.
14
+ boot({
15
+ mdxComponents: GROVE_MDX as never,
16
+ routingSpec: {
17
+ routes: [{ name: 'grove', pattern: /.*/, reactNode: <App /> }],
18
+ },
19
+ });
package/src/mdx.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ // Lets TypeScript treat `import Article from './x.mdx'` as a React component.
2
+ // immediately.run renders .mdx natively; the Vite MDX plugin (vite.config.ts)
3
+ // keeps local build/lint in sync. Use .mdx ONLY for long-form prose, never for
4
+ // structured/repeated data (that belongs in src/data/*.ts).
5
+ declare module '*.mdx' {
6
+ import type { ComponentType } from 'react';
7
+ const MDXComponent: ComponentType;
8
+ export default MDXComponent;
9
+ }
@@ -0,0 +1,89 @@
1
+ import { DEFAULT_MDX_COMPONENTS } from '@immediately-run/sdk';
2
+ import { SAFE_INTRINSICS } from './lib/safeIntrinsics';
3
+ import AssetImage from './components/AssetImage';
4
+ import Callout from './components/Callout';
5
+ import Lede from './components/Lede';
6
+ import Infobox from './components/Infobox';
7
+ import More from './components/More';
8
+ import DocList from './components/DocList';
9
+ import TagCloud from './components/TagCloud';
10
+ import TagList from './components/TagList';
11
+ import Directory from './components/Directory';
12
+ import DirectoryList from './components/DirectoryList';
13
+ import WikiLink from './components/WikiLink';
14
+ import Quote from './components/Quote';
15
+ import KeyValue from './components/KeyValue';
16
+ import Kbd from './components/Kbd';
17
+ import Toc from './components/Toc';
18
+ import TableOfContents from './components/TableOfContents';
19
+ import Backlinks from './components/Backlinks';
20
+ import PageMeta from './components/PageMeta';
21
+ import RecentlyUpdated from './components/RecentlyUpdated';
22
+ import DocsByTag from './components/DocsByTag';
23
+ import ChildPages from './components/ChildPages';
24
+ import Timeline from './components/Timeline';
25
+ import FamilyTree from './components/FamilyTree';
26
+ import Outlet from './components/Outlet';
27
+ import GroveNav from './components/GroveNav';
28
+ import GroveFooter from './components/GroveFooter';
29
+ import Sidebar from './components/Sidebar';
30
+
31
+ // The import-free component vocabulary every entry shares (Grove's "engine
32
+ // components" tier). Registered into the MDXProvider by boot(), so MDX uses them
33
+ // with no import line. `a` is overridden with the wiki-link resolver (resolved /
34
+ // broken / self states); `img` resolves mount-relative assets off the fs.
35
+ //
36
+ // boot() merges this map OVER the SDK's DEFAULT_MDX_COMPONENTS (@immediately-run/sdk
37
+ // ≥ 0.23.0, MARKDOWN_SYNTAX_SPEC §11.3), so the platform defaults (default `a`,
38
+ // `Admonition`, `WikiLink`) are inherited automatically — do NOT re-add a manual
39
+ // `...DEFAULT_MDX_COMPONENTS` spread here (redundant under merge semantics).
40
+ export const GROVE_MDX = {
41
+ a: WikiLink,
42
+ img: AssetImage,
43
+ Callout,
44
+ Lede,
45
+ Infobox,
46
+ More,
47
+ DocList,
48
+ TagCloud,
49
+ TagList,
50
+ Directory,
51
+ DirectoryList,
52
+ Quote,
53
+ KeyValue,
54
+ Kbd,
55
+ Toc,
56
+ TableOfContents,
57
+ Backlinks,
58
+ PageMeta,
59
+ RecentlyUpdated,
60
+ DocsByTag,
61
+ ChildPages,
62
+ Timeline,
63
+ FamilyTree,
64
+ // Layout primitives — a `_layout.mdx` arranges the shell out of these around an
65
+ // <Outlet/>, so the site chrome is content (see src/lib/layout.ts).
66
+ Outlet,
67
+ GroveNav,
68
+ GroveSidebar: Sidebar,
69
+ GroveFooter,
70
+ };
71
+
72
+ // The map the INTERPRETER (safe) renderer consumes — one definition, used by both
73
+ // `SafeEntryBody` (entry bodies) and `SafeLayout` (the `_layout.mdx` chain), so the two
74
+ // halves of a safe-rendered page can never drift apart in what they resolve. (R3-263)
75
+ //
76
+ // Layered exactly as `boot()` layers the compiled path, so a document renders identically
77
+ // under either standard: the SDK's platform defaults (Admonition / WikiLink / HeadingAnchor,
78
+ // carrying the deep-link resolver) UNDER the Grove vocabulary above — plus the sanitizing
79
+ // structural tags (`src/lib/safeIntrinsics.tsx`), which the compiled path gets for free from
80
+ // JSX and the safe path must be handed explicitly.
81
+ //
82
+ // Precedence is deliberate: intrinsics go on the BOTTOM. `GROVE_MDX` overrides `a` and `img`
83
+ // with <WikiLink>/<AssetImage>, and those must win — SAFE_INTRINSICS does not register `a`
84
+ // or `img` at all, but ordering it last would be a trap for whoever adds them.
85
+ export const SAFE_MDX = {
86
+ ...SAFE_INTRINSICS,
87
+ ...(DEFAULT_MDX_COMPONENTS as Record<string, unknown>),
88
+ ...GROVE_MDX,
89
+ };
@@ -0,0 +1,19 @@
1
+ // A stub host transport, installed before any test imports the SDK.
2
+ //
3
+ // `@immediately-run/sdk`'s root module wires a debug push-channel at import time, and with
4
+ // no transport that THROWS during module evaluation — so a component test fails at import
5
+ // with "no host transport" before a single assertion runs, which reads like a broken test
6
+ // file rather than a missing environment. Inside immediately.run the host injects this
7
+ // object; here it is inert (nothing under test sends a message).
8
+ //
9
+ // Deliberately not a mock of anything: tests that care about host traffic should inject
10
+ // their own doubles rather than reach for this.
11
+ const noop = (): void => undefined;
12
+
13
+ (globalThis as { __immediatelyRun__?: unknown }).__immediatelyRun__ = {
14
+ transport: {
15
+ sendMessage: noop,
16
+ protocolRequest: async () => ({}),
17
+ onMessage: () => ({ dispose: noop }),
18
+ },
19
+ };