@immediately-run/grove 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +122 -0
- package/docs/ENGINE_BOUNDARY.md +233 -0
- package/llms.txt +115 -0
- package/package.json +82 -0
- package/src/App.tsx +131 -0
- package/src/GroveApp.css +2774 -0
- package/src/GroveWiki.tsx +386 -0
- package/src/components/AssetImage.tsx +58 -0
- package/src/components/Backlinks.tsx +104 -0
- package/src/components/Callout.tsx +26 -0
- package/src/components/ChildPages.tsx +40 -0
- package/src/components/DefaultLayout.tsx +27 -0
- package/src/components/Directory.tsx +65 -0
- package/src/components/DirectoryList.test.tsx +275 -0
- package/src/components/DirectoryList.tsx +189 -0
- package/src/components/DirectoryView.tsx +68 -0
- package/src/components/DocList.tsx +109 -0
- package/src/components/DocsByTag.tsx +13 -0
- package/src/components/Drawer.tsx +45 -0
- package/src/components/EntryHeader.tsx +51 -0
- package/src/components/FamilyTree.tsx +95 -0
- package/src/components/GroveAgent.tsx +264 -0
- package/src/components/GroveFooter.tsx +20 -0
- package/src/components/GroveNav.tsx +102 -0
- package/src/components/Icon.tsx +56 -0
- package/src/components/Infobox.tsx +19 -0
- package/src/components/Kbd.tsx +10 -0
- package/src/components/KeyValue.tsx +32 -0
- package/src/components/Lede.tsx +6 -0
- package/src/components/More.tsx +10 -0
- package/src/components/Outlet.tsx +11 -0
- package/src/components/PageMeta.tsx +24 -0
- package/src/components/PageView.tsx +98 -0
- package/src/components/Quote.tsx +36 -0
- package/src/components/RecentlyUpdated.tsx +6 -0
- package/src/components/SafeEntryBody.tsx +72 -0
- package/src/components/SafeLayout.tsx +35 -0
- package/src/components/ScrollToFragment.tsx +63 -0
- package/src/components/Search.tsx +127 -0
- package/src/components/Sidebar.tsx +118 -0
- package/src/components/TableOfContents.test.tsx +163 -0
- package/src/components/TableOfContents.tsx +101 -0
- package/src/components/TagCloud.tsx +46 -0
- package/src/components/TagList.tsx +31 -0
- package/src/components/Timeline.tsx +55 -0
- package/src/components/Toc.tsx +14 -0
- package/src/components/WikiLink.tsx +112 -0
- package/src/data/themes.ts +14 -0
- package/src/devfs.d.ts +4 -0
- package/src/hooks/useContentComponents.ts +122 -0
- package/src/hooks/useCorpusMetadata.ts +43 -0
- package/src/hooks/useDirectoryListing.ts +56 -0
- package/src/hooks/useHeadings.ts +96 -0
- package/src/hooks/useOpenWikiBoot.ts +95 -0
- package/src/index.css +120 -0
- package/src/lib/compose.test.ts +92 -0
- package/src/lib/compose.ts +99 -0
- package/src/lib/content.test.ts +269 -0
- package/src/lib/content.ts +267 -0
- package/src/lib/contentRoot.ts +61 -0
- package/src/lib/corpusComponents.test.ts +101 -0
- package/src/lib/corpusComponents.ts +117 -0
- package/src/lib/corpusScan.test.ts +157 -0
- package/src/lib/corpusScan.ts +105 -0
- package/src/lib/directory.test.ts +216 -0
- package/src/lib/directory.ts +262 -0
- package/src/lib/fragment.test.ts +88 -0
- package/src/lib/fragment.ts +55 -0
- package/src/lib/frontmatter.ts +26 -0
- package/src/lib/layout.ts +84 -0
- package/src/lib/openWiki.test.ts +216 -0
- package/src/lib/openWiki.ts +84 -0
- package/src/lib/queries.test.ts +74 -0
- package/src/lib/queries.ts +84 -0
- package/src/lib/safeIntrinsics.test.tsx +99 -0
- package/src/lib/safeIntrinsics.tsx +77 -0
- package/src/lib/safeRender.test.ts +359 -0
- package/src/lib/safeSources.ts +25 -0
- package/src/lib/shell.ts +71 -0
- package/src/lib/sourceCache.test.ts +66 -0
- package/src/lib/sourceCache.ts +42 -0
- package/src/lib/tocScroll.test.ts +71 -0
- package/src/lib/tocScroll.ts +93 -0
- package/src/lib/wiki.test.ts +194 -0
- package/src/lib/wiki.ts +175 -0
- package/src/lib.ts +54 -0
- package/src/main.tsx +19 -0
- package/src/mdx.d.ts +9 -0
- package/src/mdxComponents.ts +89 -0
- package/src/test/setup.ts +19 -0
- package/viewer-manifest.schema.json +62 -0
- package/viewer.manifest.json +267 -0
|
@@ -0,0 +1,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
|
+
}
|