@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,262 @@
1
+ // Directory listings — the pure half (ways_of_working §5).
2
+ //
3
+ // Grove's route space is a filesystem, so a reader can always ask for a path that names a
4
+ // FOLDER rather than an entry: a typed URL, a link into a namespace, the host's file
5
+ // explorer, a trailing slash on a citation. Before this module every one of those resolved
6
+ // to the 404 ("No entry at …"), which is a lie — the entries are right there, one level
7
+ // down. A folder is a legitimate destination and this is what it renders.
8
+ //
9
+ // Everything here is a pure function over (directory listing, frontmatter index) so the
10
+ // ordering, classification, and column-selection rules are testable without a filesystem
11
+ // and without React. The effectful half is `hooks/useDirectoryListing`; the rendering half
12
+ // is `components/DirectoryList`.
13
+
14
+ import type { Frontmatter } from './frontmatter';
15
+ import { contentDir, isContentEntry, keyToHref } from './content';
16
+
17
+ /** One `readdir` result — the slice of `Dirent` this module needs. */
18
+ export interface DirEntry {
19
+ name: string;
20
+ isDirectory: boolean;
21
+ }
22
+
23
+ /** What a row IS, which decides whether it is a link and how it is iconed.
24
+ * - `dir` — a subfolder; navigates to its own listing.
25
+ * - `entry` — a content entry (`isContentEntry`); navigates to the page.
26
+ * - `file` — anything else (an asset, a data file). Listed, never linked: the
27
+ * viewer's route space only renders entries, so a link would land on a 404. */
28
+ export type DirRowKind = 'dir' | 'entry' | 'file';
29
+
30
+ export interface DirRow {
31
+ /** The on-disk name (`onboarding.mdx`, `people`). */
32
+ name: string;
33
+ /** The canonical key — the absolute fs path, the same identifier the metadata
34
+ * index and `<Include>` use (see lib/content). */
35
+ key: string;
36
+ kind: DirRowKind;
37
+ /** The `<Link>` target, or null for a row that is not navigable. */
38
+ href: string | null;
39
+ /** The entry's frontmatter, when the index has any. `null` for dirs and assets. */
40
+ meta: Frontmatter | null;
41
+ }
42
+
43
+ /** The metadata columns a listing can carry. `name` is structural and always shown;
44
+ * the rest are *grove-specific frontmatter* and appear only when the corpus supplies
45
+ * them — a folder of plain markdown gets a plain table rather than four empty columns. */
46
+ export type DirColumn = 'name' | 'description' | 'tags' | 'updated' | 'status';
47
+
48
+ export const ALL_COLUMNS: readonly DirColumn[] = ['name', 'description', 'tags', 'updated', 'status'];
49
+
50
+ /** Folders that are machinery, never content — hidden even with `hidden` set, because
51
+ * they are not part of any corpus and walking into one is never what a reader meant.
52
+ * Mirrors `corpusScan`'s SKIP_DIRS. */
53
+ const NEVER_LIST = new Set(['.git', 'node_modules', '.immediately.run']);
54
+
55
+ /** `_`-prefixed files are STRUCTURE (`_layout.mdx`), dot-prefixed files are hidden by
56
+ * filesystem convention. Neither is a reader-facing entry, so both stay out of the
57
+ * default listing — `isContentEntry` already excludes them from every other Grove
58
+ * enumeration, and a listing that disagreed would offer a row that renders nothing. */
59
+ function isHiddenName(name: string): boolean {
60
+ return name.startsWith('.') || name.startsWith('_');
61
+ }
62
+
63
+ /** Strip Grove's headline-ends-on-a-period house style for use as a table label. */
64
+ export function rowLabel(row: DirRow): string {
65
+ const title = typeof row.meta?.title === 'string' ? row.meta.title.trim() : '';
66
+ if (title) return title.replace(/\.$/, '');
67
+ return row.kind === 'entry' ? row.name.replace(/\.mdx?$/, '') : row.name;
68
+ }
69
+
70
+ /** The value a metadata column shows for a row, or `null` when the row has none.
71
+ * `updated` accepts either spelling — corpora in the wild use `updated:` (the docs
72
+ * wiki) or `date:` (the sample corpus), and a listing that knew only one would show
73
+ * an empty column over a corpus that dates every entry. */
74
+ export function columnValue(row: DirRow, col: DirColumn): string | string[] | null {
75
+ if (col === 'name') return rowLabel(row);
76
+ const m = row.meta;
77
+ if (!m) return null;
78
+ if (col === 'tags') {
79
+ const tags = Array.isArray(m.tags) ? m.tags.filter((t): t is string => typeof t === 'string') : [];
80
+ // `ui/…` tags are wiring (nav placement), not subject matter — the same filter
81
+ // <DocList> applies, so the two surfaces describe an entry the same way.
82
+ const visible = tags.filter((t) => !t.startsWith('ui/'));
83
+ return visible.length ? visible : null;
84
+ }
85
+ const raw = col === 'updated' ? (m.updated ?? m.date) : m[col];
86
+ // Scalars only. A frontmatter key can hold a map (`owns:`) or a list, and
87
+ // `String()`-ing one into a table cell prints `[object Object]` — worse than an
88
+ // empty cell, because it looks like data.
89
+ if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') {
90
+ const text = String(raw).trim();
91
+ return text ? text : null;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ /** The columns to render: `name`, plus every requested metadata column that at least
97
+ * one row actually carries. Adaptive rather than fixed so the table describes the
98
+ * corpus it is over instead of the corpus its author imagined. */
99
+ export function visibleColumns(rows: DirRow[], requested: readonly DirColumn[] = ALL_COLUMNS): DirColumn[] {
100
+ const out: DirColumn[] = ['name'];
101
+ for (const col of requested) {
102
+ if (col === 'name') continue;
103
+ if (rows.some((r) => columnValue(r, col) !== null)) out.push(col);
104
+ }
105
+ return out;
106
+ }
107
+
108
+ /** Parse a `columns="description,tags"` attribute into a validated column list.
109
+ * Unknown names are dropped rather than thrown: the attribute is author input in
110
+ * content, and one typo must not take the page down. */
111
+ export function parseColumns(spec: string | undefined): readonly DirColumn[] {
112
+ if (!spec) return ALL_COLUMNS;
113
+ const named = spec
114
+ .split(',')
115
+ .map((s) => s.trim())
116
+ .filter((s): s is DirColumn => (ALL_COLUMNS as readonly string[]).includes(s));
117
+ return named.length ? named : ALL_COLUMNS;
118
+ }
119
+
120
+ export interface BuildOptions {
121
+ /** Include dot- and `_`-prefixed names (default false). */
122
+ hidden?: boolean;
123
+ /** `name` (default) sorts on the on-disk name; `title` on the rendered label;
124
+ * `updated` puts the most recently updated first. Directories always sort first —
125
+ * they are the navigational skeleton and a reader scans for them. */
126
+ sort?: 'name' | 'title' | 'updated';
127
+ }
128
+
129
+ /**
130
+ * Turn a directory listing plus the frontmatter index into display rows.
131
+ *
132
+ * `dirKey` is the absolute key of the folder (no trailing slash needed); `metadata` is
133
+ * the same `Record<absolutePath, frontmatter>` every other Grove surface reads, so a
134
+ * dispatched corpus (scanned) and a fork (bundler-fed) behave identically here.
135
+ */
136
+ export function buildDirectoryRows(
137
+ dirKey: string,
138
+ entries: readonly DirEntry[],
139
+ metadata: Record<string, Frontmatter> = {},
140
+ opts: BuildOptions = {}
141
+ ): DirRow[] {
142
+ const base = dirKey.replace(/\/+$/, '');
143
+ const rows: DirRow[] = [];
144
+ for (const e of entries) {
145
+ if (NEVER_LIST.has(e.name)) continue;
146
+ if (!opts.hidden && isHiddenName(e.name)) continue;
147
+ const key = `${base}/${e.name}`;
148
+ if (e.isDirectory) {
149
+ rows.push({ name: e.name, key, kind: 'dir', href: keyToHref(key), meta: null });
150
+ continue;
151
+ }
152
+ const entry = isContentEntry(key);
153
+ rows.push({
154
+ name: e.name,
155
+ key,
156
+ kind: entry ? 'entry' : 'file',
157
+ href: entry ? keyToHref(key) : null,
158
+ meta: metadata[key] ?? null,
159
+ });
160
+ }
161
+ return sortRows(rows, opts.sort ?? 'name');
162
+ }
163
+
164
+ // Three tiers, always, whatever the sort key: subfolders, then entries, then assets.
165
+ // Interleaving assets with entries by name buries the content — measured on a dispatched
166
+ // corpus where `diagram.svg` sorted above every page in the folder purely on its initial.
167
+ // Folders lead because they are the navigational skeleton a reader scans for.
168
+ const KIND_RANK: Record<DirRowKind, number> = { dir: 0, entry: 1, file: 2 };
169
+
170
+ function sortRows(rows: DirRow[], sort: NonNullable<BuildOptions['sort']>): DirRow[] {
171
+ const rank = (r: DirRow): number => KIND_RANK[r.kind];
172
+ return rows.sort((a, b) => {
173
+ if (rank(a) !== rank(b)) return rank(a) - rank(b);
174
+ if (sort === 'title') return rowLabel(a).localeCompare(rowLabel(b));
175
+ if (sort === 'updated') {
176
+ const av = String(columnValue(a, 'updated') ?? '');
177
+ const bv = String(columnValue(b, 'updated') ?? '');
178
+ if (av !== bv) return bv.localeCompare(av); // newest first; undated sinks
179
+ }
180
+ return a.name.localeCompare(b.name);
181
+ });
182
+ }
183
+
184
+ /**
185
+ * The folder a listing is FOR, given a `path` attribute and the route's own directory.
186
+ *
187
+ * A leading `/` means corpus-relative (`/handbook`); anything else resolves against
188
+ * `fromDir`, exactly the rule `hrefKeyCandidates` applies to author-written links — an
189
+ * author writes paths relative to the entry they are in. Traversal is resolved, then the
190
+ * result is confined to the corpus: a `path` is author (or reader) input, and under
191
+ * dispatch it is foreign, so `../../..` must not become a readdir of somebody's mount.
192
+ * Out-of-corpus resolves to the corpus root rather than erroring — the same
193
+ * fail-to-home posture `sandboxPathToKey` takes.
194
+ */
195
+ export function resolveDirKey(path: string | undefined, fromDir: string): string {
196
+ const root = contentDir().replace(/\/+$/, '');
197
+ const from = fromDir.replace(/\/+$/, '') || root;
198
+ const raw = !path ? from : path.startsWith('/') ? `${root}${path}` : `${from}/${path}`;
199
+ const out: string[] = [];
200
+ for (const seg of raw.split('/')) {
201
+ if (!seg || seg === '.') continue;
202
+ if (seg === '..') out.pop();
203
+ else out.push(seg);
204
+ }
205
+ const abs = `/${out.join('/')}`;
206
+ return abs === root || abs.startsWith(`${root}/`) ? abs : root;
207
+ }
208
+
209
+ /** The breadcrumb segments of a directory key, corpus-root first. */
210
+ export function dirCrumbs(dirKey: string): Array<{ label: string; key: string }> {
211
+ const root = contentDir().replace(/\/+$/, '');
212
+ const rel = dirKey.replace(/\/+$/, '').slice(root.length).replace(/^\//, '');
213
+ const out: Array<{ label: string; key: string }> = [];
214
+ let acc = root;
215
+ for (const seg of rel.split('/').filter(Boolean)) {
216
+ acc = `${acc}/${seg}`;
217
+ out.push({ label: seg, key: acc });
218
+ }
219
+ return out;
220
+ }
221
+
222
+ /**
223
+ * The entry a folder URL should render INSTEAD of a generated listing — the folder-index
224
+ * convention. This is the corpus's own override: an author who wants `handbook/` to be a
225
+ * curated page writes `handbook/index.mdx` and gets it, with no engine change and no
226
+ * component registration. Absent one, the listing is the answer.
227
+ */
228
+ const INDEX_NAMES = ['index.mdx', 'index.md'];
229
+
230
+ export function folderIndexKey(dirKey: string, keys: readonly string[]): string | null {
231
+ const base = dirKey.replace(/\/+$/, '');
232
+ for (const name of INDEX_NAMES) {
233
+ const candidate = `${base}/${name}`;
234
+ if (keys.includes(candidate)) return candidate;
235
+ }
236
+ return null;
237
+ }
238
+
239
+ /** A directory key → the corpus-relative `path` attribute that names it
240
+ * (`/app/content/handbook` → `/handbook`; the corpus root → `/`). The inverse of
241
+ * {@link resolveDirKey}'s leading-slash form, so the router can hand a folder to the
242
+ * overridable `<DirectoryList/>` through its PUBLIC prop rather than a private one. */
243
+ export function dirKeyToPath(dirKey: string): string {
244
+ const root = contentDir().replace(/\/+$/, '');
245
+ const rel = dirKey.replace(/\/+$/, '').slice(root.length);
246
+ return rel || '/';
247
+ }
248
+
249
+ /**
250
+ * Does this key name a FOLDER that contains entries, judged from the frontmatter index
251
+ * alone?
252
+ *
253
+ * Index-only on purpose: this answers a question asked once per link in a rendered body,
254
+ * and a `readdir` per link would turn a page of prose into a burst of host RPCs. The
255
+ * blind spot is a folder holding nothing but assets — which no author links to as a
256
+ * destination, and which the ROUTE still resolves correctly because that path does ask
257
+ * the filesystem.
258
+ */
259
+ export function isFolderKey(key: string, keys: readonly string[]): boolean {
260
+ const prefix = `${key.replace(/\/+$/, '')}/`;
261
+ return keys.some((k) => k.startsWith(prefix));
262
+ }
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { fragmentOf, resolveFragmentTarget } from './fragment';
3
+
4
+ /** A minimal DOM stand-in: enough of `querySelectorAll('[data-entry]')` + scoped
5
+ * `querySelector` for what `resolveFragmentTarget` does. */
6
+ function makeDoc(bodies: { entry: string; ids: string[]; slugs?: string[] }[]): Document {
7
+ const scopeFor = (b: { entry: string; ids: string[]; slugs?: string[] }) => ({
8
+ getAttribute: (name: string) => (name === 'data-entry' ? b.entry : null),
9
+ querySelector: (sel: string) => {
10
+ const id = sel.match(/^\[id="(.*)"\]$/)?.[1];
11
+ const slug = sel.match(/^\[data-slug="(.*)"\]$/)?.[1];
12
+ if (id && b.ids.includes(id)) return { entry: b.entry, id, scrollIntoView() {} } as unknown as HTMLElement;
13
+ if (slug && (b.slugs ?? []).includes(slug)) return { entry: b.entry, slug, scrollIntoView() {} } as unknown as HTMLElement;
14
+ return null;
15
+ },
16
+ });
17
+ const scopes = bodies.map(scopeFor);
18
+ return {
19
+ querySelectorAll: (sel: string) => (sel === '[data-entry]' ? scopes : []),
20
+ // The no-marker fallback searches the whole document.
21
+ querySelector: (sel: string) => {
22
+ for (const s of scopes) {
23
+ const hit = s.querySelector(sel);
24
+ if (hit) return hit;
25
+ }
26
+ return null;
27
+ },
28
+ } as unknown as Document;
29
+ }
30
+
31
+ describe('fragmentOf', () => {
32
+ it('strips the leading hash', () => {
33
+ expect(fragmentOf('#sec-8-9')).toBe('sec-8-9');
34
+ expect(fragmentOf('sec-8-9')).toBe('sec-8-9');
35
+ });
36
+
37
+ it('drops the local dev provider locator glued onto the fragment', () => {
38
+ // `immediately.run dev` puts `#ir-endpoint=…&ir-token=…` on the URL, so a fragment can
39
+ // arrive with the locator attached. Taking the whole string would look for an id
40
+ // called `sec-4#ir-endpoint=…` and silently never scroll.
41
+ expect(fragmentOf('#sec-4#ir-endpoint=http%3A%2F%2F127.0.0.1%3A7700&ir-token=abc')).toBe('sec-4');
42
+ expect(fragmentOf('#sec-4&ir-token=abc')).toBe('sec-4');
43
+ });
44
+
45
+ it('is empty for no hash', () => {
46
+ expect(fragmentOf('')).toBe('');
47
+ expect(fragmentOf(undefined)).toBe('');
48
+ });
49
+ });
50
+
51
+ describe('resolveFragmentTarget — the stale-document guard (R3-249)', () => {
52
+ const OUTGOING = '/app/content/specs/PERSISTENCE_SPEC.mdx';
53
+ const INCOMING = '/app/content/context/core_concepts.mdx';
54
+
55
+ it('refuses to resolve while the PREVIOUS entry is still the one rendered', () => {
56
+ // Both documents have a `sec-4` — the whole reason the bug exists. Asking for the
57
+ // incoming entry's section while the outgoing body is on screen must yield nothing,
58
+ // so the caller waits instead of scrolling to the wrong page's section.
59
+ const doc = makeDoc([{ entry: OUTGOING, ids: ['sec-1', 'sec-4', 'sec-8'] }]);
60
+ expect(resolveFragmentTarget(doc, INCOMING, 'sec-4')).toBeNull();
61
+ });
62
+
63
+ it('resolves once the incoming entry is the one rendered', () => {
64
+ const doc = makeDoc([{ entry: INCOMING, ids: ['sec-1', 'sec-4'] }]);
65
+ expect(resolveFragmentTarget(doc, INCOMING, 'sec-4')).not.toBeNull();
66
+ });
67
+
68
+ it('picks the section from the CURRENT entry when both bodies are in the DOM', () => {
69
+ // The transition window: the outgoing body has not unmounted yet.
70
+ const doc = makeDoc([
71
+ { entry: OUTGOING, ids: ['sec-4'] },
72
+ { entry: INCOMING, ids: ['sec-4'] },
73
+ ]);
74
+ const el = resolveFragmentTarget(doc, INCOMING, 'sec-4') as unknown as { entry: string };
75
+ expect(el?.entry).toBe(INCOMING);
76
+ });
77
+
78
+ it('accepts a numbered heading by its prose slug too', () => {
79
+ const doc = makeDoc([{ entry: INCOMING, ids: ['sec-4'], slugs: ['4-principal'] }]);
80
+ expect(resolveFragmentTarget(doc, INCOMING, '4-principal')).not.toBeNull();
81
+ });
82
+
83
+ it('returns null for an unknown fragment, and for an empty one', () => {
84
+ const doc = makeDoc([{ entry: INCOMING, ids: ['sec-4'] }]);
85
+ expect(resolveFragmentTarget(doc, INCOMING, 'sec-99')).toBeNull();
86
+ expect(resolveFragmentTarget(doc, INCOMING, '')).toBeNull();
87
+ });
88
+ });
@@ -0,0 +1,55 @@
1
+ // Deep-link fragment resolution — pure helpers, no React (the Fast-Refresh rule).
2
+ //
3
+ // The problem these solve is specific to a corpus whose documents share section ids. Every
4
+ // spec numbers its sections from 1, so `#sec-4` exists in almost every entry. A scroll
5
+ // fired when navigation *starts* therefore finds a perfectly good `#sec-4` — belonging to
6
+ // the document the reader is leaving — scrolls to it, reports success, and is then undone
7
+ // when the incoming entry renders. The reader lands at the top of the right page.
8
+ //
9
+ // The fix is to refuse to scroll until the element found belongs to the entry that is
10
+ // actually on screen, which the `data-entry` marker inside the suspended body makes
11
+ // checkable (see `SafeEntryBody`).
12
+
13
+ /** The fragment the current navigation is asking for, without its `#`. */
14
+ export function fragmentOf(hash: string | undefined | null): string {
15
+ if (!hash) return '';
16
+ const raw = hash.startsWith('#') ? hash.slice(1) : hash;
17
+ // The local dev provider appends its own `#ir-endpoint=…&ir-token=…` locator, so a
18
+ // fragment can arrive with the locator glued onto it. Take the leading component.
19
+ return raw.split(/[#&]/)[0].trim();
20
+ }
21
+
22
+ /**
23
+ * The element a fragment names, but only once the body on screen is the one being asked
24
+ * about. Returns null while the previous entry is still rendered, which is the signal to
25
+ * wait rather than to scroll.
26
+ *
27
+ * Falls back to the whole document when no marked body is present — the compiled
28
+ * (`<Include>`) path does not carry the marker, and scrolling imperfectly there is better
29
+ * than not at all.
30
+ */
31
+ export function resolveFragmentTarget(doc: Document, entryKey: string, frag: string): HTMLElement | null {
32
+ if (!frag) return null;
33
+ // Compare the attribute VALUE rather than selecting on it. An entry key is a path
34
+ // (`/app/content/context/core_concepts.mdx`), and getting it through a CSS attribute
35
+ // selector means trusting `CSS.escape` and the selector parser to agree about slashes
36
+ // and dots — a silent no-match if they do not. Reading the attribute and comparing
37
+ // strings has no such failure mode, and there are only a handful of marked bodies.
38
+ const marked = Array.from(doc.querySelectorAll<HTMLElement>('[data-entry]'));
39
+ const scope: ParentNode | null = marked.find((n) => n.getAttribute('data-entry') === entryKey) ?? null;
40
+ // No marked body at all: the compiled (`<Include>`) path does not carry the marker, so
41
+ // fall back to the document. A marked body for a DIFFERENT entry means the previous
42
+ // document is still on screen — return null and wait, rather than scroll the wrong page.
43
+ const root: ParentNode | null = scope ?? (marked.length === 0 ? doc : null);
44
+ if (!root) return null;
45
+ const byId = root.querySelector<HTMLElement>(`[id="${cssEscape(frag)}"]`);
46
+ if (byId) return byId;
47
+ // A numbered heading also carries its prose slug, which is a valid landing hook.
48
+ return root.querySelector<HTMLElement>(`[data-slug="${cssEscape(frag)}"]`);
49
+ }
50
+
51
+ /** `CSS.escape`, with a conservative fallback for the characters ids here can contain. */
52
+ function cssEscape(value: string): string {
53
+ if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') return CSS.escape(value);
54
+ return value.replace(/["\\]/g, '\\$&');
55
+ }
@@ -0,0 +1,26 @@
1
+ // Frontmatter parsing for the viewer-side corpus scan (R3-265).
2
+ //
3
+ // IMPORTED, not ported, since R3-277a. This file used to carry a documented port of
4
+ // the docs repo's `scripts/lib/wiki.mjs` parser — the one that has read the whole
5
+ // docs corpus for the generator and the conformance checker. The port was faithful,
6
+ // and that was the problem: the two agreed because someone kept them agreeing, and
7
+ // nothing would have reported the day that stopped. A viewer that parses frontmatter
8
+ // differently from the tooling that validates it renders a corpus the gate says is
9
+ // fine and the reader says is broken.
10
+ //
11
+ // The canon is `@immediately-run/mdx-plugins` — the package that already owns the
12
+ // slug grammar (R3-277), so a consumer gets both halves of the corpus contract from
13
+ // one place at one version.
14
+ //
15
+ // Why a parser at all, rather than the bundler's metadata: under DISPATCH the corpus
16
+ // is a mount, and the bundler only scans the app's own source. Nothing else has read
17
+ // these bytes.
18
+ //
19
+ // The re-export keeps every import site in this repo unchanged. `Frontmatter` is
20
+ // re-exported under its local name — the viewer's own code says `Frontmatter`, and
21
+ // renaming it across the repo would be churn for no reader's benefit.
22
+ export { parseFrontmatter } from '@immediately-run/mdx-plugins';
23
+ export type {
24
+ FrontmatterValue,
25
+ ParsedFrontmatter as Frontmatter,
26
+ } from '@immediately-run/mdx-plugins';
@@ -0,0 +1,84 @@
1
+ // Layout resolution — the "outlet chain" for a content entry. Pure helpers (no
2
+ // React), so the same rules drive rendering and any future manifest/validation.
3
+ //
4
+ // A LAYOUT is an MDX file (`…/_layout.mdx`) that renders shared chrome — nav,
5
+ // section hero, footer — around an `<Outlet/>` where the page (or an inner
6
+ // layout) renders. This is Grove's answer to React Router's nested layout
7
+ // routes: the URL always names the *leaf content file*, and the layout chain is
8
+ // resolved *around* it at render time, invisible to routing.
9
+ //
10
+ // Association is FOLDER CONVENTION, nested up the namespace: a `_layout.mdx` in a
11
+ // directory wraps every entry in that subtree, and directories nest
12
+ // (`content/_layout.mdx` wraps `content/people/_layout.mdx` wraps the page).
13
+ // The `frame` frontmatter key OVERRIDES the convention on a per-entry basis; the
14
+ // existing `layout` field (the CSS `data-layout` variant) is unrelated and
15
+ // untouched.
16
+ import { contentDir, slugToKey, isLayoutKey } from './content';
17
+
18
+ /** The `_layout.mdx` key for a directory key (which ends in `/`). */
19
+ function layoutKeyForDir(dir: string): string {
20
+ return dir + '_layout.mdx';
21
+ }
22
+
23
+ /** Does an entry/layout opt out of an inherited layout chain? `frame: none`
24
+ * (or `frame: false`) means "render me bare / stop inheritance above here". */
25
+ function optsOut(meta: Record<string, unknown> | undefined): boolean {
26
+ const f = meta?.frame;
27
+ return f === 'none' || f === false;
28
+ }
29
+
30
+ /**
31
+ * Resolve the ordered chain of layout files that wrap `entryKey`, **outermost
32
+ * first** (root → nearest). The caller nests them so the last one is closest to
33
+ * the page and the page renders at the innermost `<Outlet/>`.
34
+ *
35
+ * Rules, in precedence order:
36
+ * 1. `frame: none` / `frame: false` on the entry → `[]` (render bare, no chrome).
37
+ * 2. `frame: '<slug>'` on the entry → exactly that one layout, if it exists.
38
+ * 3. Otherwise folder convention: every `_layout.mdx` from the content root down to
39
+ * the entry's own directory that exists in `allKeys`. A `_layout.mdx` whose
40
+ * own frontmatter opts out (`frame: none`) becomes a new root — layouts above
41
+ * it are dropped.
42
+ *
43
+ * Layout files never wrap themselves (a `_layout.mdx` gets no layout chain).
44
+ */
45
+ export function layoutChainForKey(
46
+ entryKey: string,
47
+ filesMetadata: Record<string, Record<string, unknown> | undefined>,
48
+ ): string[] {
49
+ if (isLayoutKey(entryKey)) return [];
50
+
51
+ const allKeys = Object.keys(filesMetadata);
52
+ const metaOf = (key: string) => filesMetadata[key];
53
+ const entryMeta = metaOf(entryKey);
54
+ if (optsOut(entryMeta)) return [];
55
+
56
+ const explicit = entryMeta?.frame;
57
+ if (typeof explicit === 'string' && explicit && explicit !== 'none') {
58
+ const key = slugToKey(explicit);
59
+ return allKeys.includes(key) ? [key] : [];
60
+ }
61
+
62
+ const present = new Set(allKeys.filter(isLayoutKey));
63
+ const root = contentDir();
64
+ if (!entryKey.startsWith(root)) return [];
65
+
66
+ // Directories from the content root down to (but not including) the entry file.
67
+ const rel = entryKey.slice(root.length); // e.g. "people/ada.mdx"
68
+ const segs = rel.split('/').slice(0, -1); // e.g. ["people"]
69
+ const dirs = [root];
70
+ let dir = root;
71
+ for (const s of segs) {
72
+ dir = dir + s + '/';
73
+ dirs.push(dir);
74
+ }
75
+
76
+ const chain: string[] = [];
77
+ for (const d of dirs) {
78
+ const lk = layoutKeyForDir(d);
79
+ if (!present.has(lk)) continue;
80
+ if (optsOut(metaOf(lk))) chain.length = 0; // this layout is a new root
81
+ chain.push(lk);
82
+ }
83
+ return chain;
84
+ }