@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,127 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { Link, useMetadataQuery } from '@immediately-run/sdk';
|
|
3
|
+
import type { Metadata } from '@immediately-run/sdk';
|
|
4
|
+
import { keyToHref } from '../lib/content';
|
|
5
|
+
import { crumb } from '../lib/wiki';
|
|
6
|
+
import { searchQuery } from '../lib/queries';
|
|
7
|
+
import type { SearchRecord } from '../lib/queries';
|
|
8
|
+
import Icon from './Icon';
|
|
9
|
+
|
|
10
|
+
interface Hit {
|
|
11
|
+
key: string;
|
|
12
|
+
title: string;
|
|
13
|
+
ns: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// `.grove-search` — the ⌘K command palette: client-side fuzzy filter over the
|
|
17
|
+
// in-memory index, grouped into entries + tags, keyboard-navigable.
|
|
18
|
+
export default function Search({ onClose }: { onClose: () => void }) {
|
|
19
|
+
const [query, setQuery] = useState('');
|
|
20
|
+
const [sel, setSel] = useState(0);
|
|
21
|
+
const boxRef = useRef<HTMLDivElement>(null);
|
|
22
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
23
|
+
|
|
24
|
+
// Records, not tab-encoded paths (R3-276a): `tags` is the array itself, so the
|
|
25
|
+
// join/split round-trip is gone.
|
|
26
|
+
const q = useMetadataQuery<Metadata, SearchRecord>(searchQuery);
|
|
27
|
+
const rows: SearchRecord[] = Array.isArray(q) ? q : [];
|
|
28
|
+
|
|
29
|
+
const rowsKey = rows.map((r) => r.path).join('|');
|
|
30
|
+
const { entries, tags } = useMemo(() => {
|
|
31
|
+
const es = rows.map(({ path, title, desc, tags: entryTags }) => ({
|
|
32
|
+
key: path,
|
|
33
|
+
title: (title || path).replace(/\.$/, ''),
|
|
34
|
+
desc: desc || '',
|
|
35
|
+
tags: entryTags.join(','),
|
|
36
|
+
}));
|
|
37
|
+
const tagSet = new Set<string>();
|
|
38
|
+
es.forEach((e) => e.tags.split(',').filter(Boolean).forEach((t) => tagSet.add(t)));
|
|
39
|
+
return { entries: es, tags: Array.from(tagSet).sort() };
|
|
40
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
41
|
+
}, [rowsKey]);
|
|
42
|
+
|
|
43
|
+
const ql = query.trim().toLowerCase();
|
|
44
|
+
const matchedEntries: Hit[] = (ql
|
|
45
|
+
? entries.filter(
|
|
46
|
+
(e) => e.title.toLowerCase().includes(ql) || e.desc.toLowerCase().includes(ql) || e.key.toLowerCase().includes(ql)
|
|
47
|
+
)
|
|
48
|
+
: entries.slice(0, 6)
|
|
49
|
+
).map((e) => ({ key: e.key, title: e.title, ns: crumb(e.key) }));
|
|
50
|
+
const matchedTags = ql ? tags.filter((t) => t.toLowerCase().includes(ql)) : [];
|
|
51
|
+
const total = matchedEntries.length + matchedTags.length;
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
inputRef.current?.focus();
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
const onKey = (e: React.KeyboardEvent) => {
|
|
58
|
+
if (e.key === 'Escape') return onClose();
|
|
59
|
+
if (e.key === 'ArrowDown') {
|
|
60
|
+
e.preventDefault();
|
|
61
|
+
setSel((s) => Math.min(s + 1, Math.max(0, total - 1)));
|
|
62
|
+
} else if (e.key === 'ArrowUp') {
|
|
63
|
+
e.preventDefault();
|
|
64
|
+
setSel((s) => Math.max(0, s - 1));
|
|
65
|
+
} else if (e.key === 'Enter') {
|
|
66
|
+
e.preventDefault();
|
|
67
|
+
(boxRef.current?.querySelector('.grove-search__row[data-sel="1"]') as HTMLElement | null)?.click();
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="grove-search" onClick={onClose}>
|
|
73
|
+
<div className="grove-search__box" ref={boxRef} onClick={(e) => e.stopPropagation()}>
|
|
74
|
+
<div className="grove-search__in">
|
|
75
|
+
<Icon name="search" />
|
|
76
|
+
<input
|
|
77
|
+
ref={inputRef}
|
|
78
|
+
value={query}
|
|
79
|
+
placeholder="Search entries and tags…"
|
|
80
|
+
onChange={(e) => {
|
|
81
|
+
setQuery(e.target.value);
|
|
82
|
+
setSel(0);
|
|
83
|
+
}}
|
|
84
|
+
onKeyDown={onKey}
|
|
85
|
+
/>
|
|
86
|
+
<kbd>esc</kbd>
|
|
87
|
+
</div>
|
|
88
|
+
<div className="grove-search__res">
|
|
89
|
+
{total === 0 ? (
|
|
90
|
+
<div className="grove-search__empty">
|
|
91
|
+
No entry matches <code>{query}</code>.
|
|
92
|
+
</div>
|
|
93
|
+
) : (
|
|
94
|
+
<>
|
|
95
|
+
{matchedEntries.length ? <div className="grove-search__grp">{ql ? 'Entries' : 'Recent'}</div> : null}
|
|
96
|
+
{matchedEntries.map((h, i) => (
|
|
97
|
+
<Link
|
|
98
|
+
key={h.key}
|
|
99
|
+
href={keyToHref(h.key)}
|
|
100
|
+
className="grove-search__row"
|
|
101
|
+
data-sel={sel === i ? '1' : '0'}
|
|
102
|
+
onClick={onClose}
|
|
103
|
+
>
|
|
104
|
+
<Icon name="file" />
|
|
105
|
+
<span className="t">{h.title}</span>
|
|
106
|
+
<span className="c">/{h.ns}</span>
|
|
107
|
+
</Link>
|
|
108
|
+
))}
|
|
109
|
+
{matchedTags.length ? <div className="grove-search__grp">Tags</div> : null}
|
|
110
|
+
{matchedTags.map((t, i) => (
|
|
111
|
+
<div
|
|
112
|
+
key={t}
|
|
113
|
+
className="grove-search__row"
|
|
114
|
+
data-sel={sel === matchedEntries.length + i ? '1' : '0'}
|
|
115
|
+
onClick={onClose}
|
|
116
|
+
>
|
|
117
|
+
<Icon name="list" />
|
|
118
|
+
<span className="t">#{t}</span>
|
|
119
|
+
</div>
|
|
120
|
+
))}
|
|
121
|
+
</>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { useContext, useMemo, useState } from 'react';
|
|
3
|
+
import { Link, useMetadataQuery } from '@immediately-run/sdk';
|
|
4
|
+
import type { Metadata } from '@immediately-run/sdk';
|
|
5
|
+
import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
|
|
6
|
+
import { contentDir, keyToHref, sandboxPathToKey } from '../lib/content';
|
|
7
|
+
import { sidebarQuery } from '../lib/queries';
|
|
8
|
+
import type { SidebarRecord } from '../lib/queries';
|
|
9
|
+
import Icon from './Icon';
|
|
10
|
+
|
|
11
|
+
interface TreeNode {
|
|
12
|
+
name: string;
|
|
13
|
+
key?: string; // leaf entry key
|
|
14
|
+
title?: string;
|
|
15
|
+
children: Record<string, TreeNode>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function insert(root: TreeNode, key: string, title: string) {
|
|
19
|
+
const rel = key.replace(contentDir(), '').replace(/\.mdx?$/, '');
|
|
20
|
+
const parts = rel.split('/');
|
|
21
|
+
let node = root;
|
|
22
|
+
parts.forEach((part, i) => {
|
|
23
|
+
const leaf = i === parts.length - 1;
|
|
24
|
+
node.children[part] = node.children[part] || { name: part, children: {} };
|
|
25
|
+
node = node.children[part];
|
|
26
|
+
if (leaf) {
|
|
27
|
+
node.key = key;
|
|
28
|
+
node.title = title;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function hasDescendant(node: TreeNode, key: string): boolean {
|
|
34
|
+
return Object.values(node.children).some((c) => c.key === key || hasDescendant(c, key));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A folder (has children) or a leaf entry. Folders are collapsible; the branch
|
|
38
|
+
// holding the current entry starts open.
|
|
39
|
+
function Branch({ node, currentKey, depth }: { node: TreeNode; currentKey: string; depth: number }) {
|
|
40
|
+
const childKeys = Object.keys(node.children).sort();
|
|
41
|
+
const isFolder = childKeys.length > 0;
|
|
42
|
+
const containsCurrent = !!currentKey && (node.key === currentKey || hasDescendant(node, currentKey));
|
|
43
|
+
const [open, setOpen] = useState(depth < 1 || containsCurrent);
|
|
44
|
+
|
|
45
|
+
if (!isFolder) {
|
|
46
|
+
return (
|
|
47
|
+
<Link href={keyToHref(node.key!)} className="gs-tree__row" data-cur={node.key === currentKey ? '1' : '0'}>
|
|
48
|
+
<Icon name="file" />
|
|
49
|
+
{(node.title || node.name).replace(/\.$/, '')}
|
|
50
|
+
</Link>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div>
|
|
56
|
+
<div className="gs-tree__row gs-folder" data-open={open ? '1' : '0'} onClick={() => setOpen((o) => !o)}>
|
|
57
|
+
<Icon name="chevron-down" className="chev" />
|
|
58
|
+
{node.name}
|
|
59
|
+
<span className="ct">{childKeys.length}</span>
|
|
60
|
+
</div>
|
|
61
|
+
{open ? (
|
|
62
|
+
<div className="gs-tree__children">
|
|
63
|
+
{childKeys.map((k) => (
|
|
64
|
+
<Branch key={k} node={node.children[k]} currentKey={currentKey} depth={depth + 1} />
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
) : null}
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// `.grove-sidebar` — the wiki shell's left rail: a namespace tree + author-defined
|
|
73
|
+
// `ui/sidebar` sections (a header + a link into the tagged entry).
|
|
74
|
+
export default function Sidebar() {
|
|
75
|
+
const ctx = useContext(TinkerableContext) as any;
|
|
76
|
+
const currentKey = sandboxPathToKey(ctx?.navigationState?.sandboxPath || '/');
|
|
77
|
+
|
|
78
|
+
// Records, not tab-encoded paths (R3-276a).
|
|
79
|
+
const q = useMetadataQuery<Metadata, SidebarRecord>(sidebarQuery);
|
|
80
|
+
const rows: SidebarRecord[] = Array.isArray(q) ? q : [];
|
|
81
|
+
|
|
82
|
+
const rowsKey = rows.map((r) => r.path).join('|');
|
|
83
|
+
const { tree, sections } = useMemo(() => {
|
|
84
|
+
const root: TreeNode = { name: '', children: {} };
|
|
85
|
+
const secs: { key: string; label: string }[] = [];
|
|
86
|
+
rows.forEach(({ path, title, tags, nav }) => {
|
|
87
|
+
insert(root, path, title);
|
|
88
|
+
if (tags.includes('ui/sidebar')) secs.push({ key: path, label: nav || title || path });
|
|
89
|
+
});
|
|
90
|
+
return { tree: root, sections: secs };
|
|
91
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
92
|
+
}, [rowsKey]);
|
|
93
|
+
|
|
94
|
+
const topKeys = Object.keys(tree.children).sort();
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<aside className="grove-sidebar">
|
|
98
|
+
<div className="gs-block">
|
|
99
|
+
<div className="gs-block__h">Browse</div>
|
|
100
|
+
<div className="gs-tree">
|
|
101
|
+
{topKeys.map((k) => (
|
|
102
|
+
<Branch key={k} node={tree.children[k]} currentKey={currentKey} depth={0} />
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
{sections.map((s) => (
|
|
107
|
+
<div className="gs-block" key={s.key}>
|
|
108
|
+
<div className="gs-block__h">{s.label.replace(/\.$/, '')}</div>
|
|
109
|
+
<div className="gs-section__body">
|
|
110
|
+
<Link href={keyToHref(s.key)} className="grove-wikilink" data-state="ok">
|
|
111
|
+
Open
|
|
112
|
+
</Link>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
))}
|
|
116
|
+
</aside>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
import { act } from 'react';
|
|
4
|
+
import { createRoot } from 'react-dom/client';
|
|
5
|
+
import TableOfContents from './TableOfContents';
|
|
6
|
+
|
|
7
|
+
// jsdom performs no layout, so every geometry property this component reads is 0. The
|
|
8
|
+
// numbers are planted instead — which is fine, because the arithmetic itself is proven in
|
|
9
|
+
// `lib/tocScroll.test.ts`. What THIS file proves is the wiring: that the component reads
|
|
10
|
+
// the right elements, moves the right one, and declines when it should.
|
|
11
|
+
|
|
12
|
+
let ioCallback: ((entries: unknown[]) => void) | null = null;
|
|
13
|
+
class FakeIO {
|
|
14
|
+
constructor(cb: (entries: unknown[]) => void) {
|
|
15
|
+
ioCallback = cb;
|
|
16
|
+
}
|
|
17
|
+
observe() {}
|
|
18
|
+
disconnect() {
|
|
19
|
+
ioCallback = null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Report a heading as the one the reader is currently in. */
|
|
24
|
+
async function spyTo(id: string) {
|
|
25
|
+
await act(async () => {
|
|
26
|
+
ioCallback?.([{ isIntersecting: true, target: document.getElementById(id) }]);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function plantProse(ids: string[]) {
|
|
31
|
+
const prose = document.createElement('div');
|
|
32
|
+
prose.className = 'grove-prose';
|
|
33
|
+
prose.innerHTML = ids.map((id) => `<h2 id="${id}">${id}</h2>`).join('');
|
|
34
|
+
document.body.appendChild(prose);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Give the rendered list a fake layout: 200px tall over 1000px of content, each row
|
|
38
|
+
* 20px starting at 40px (below the "On this page" header). */
|
|
39
|
+
function layout(nav: HTMLElement, opts: { viewHeight?: number; scrollHeight?: number } = {}) {
|
|
40
|
+
Object.defineProperty(nav, 'clientHeight', { value: opts.viewHeight ?? 200, configurable: true });
|
|
41
|
+
Object.defineProperty(nav, 'scrollHeight', { value: opts.scrollHeight ?? 1000, configurable: true });
|
|
42
|
+
let top = 0;
|
|
43
|
+
Object.defineProperty(nav, 'scrollTop', {
|
|
44
|
+
get: () => top,
|
|
45
|
+
set: (v) => { top = v; },
|
|
46
|
+
configurable: true,
|
|
47
|
+
});
|
|
48
|
+
nav.querySelectorAll('a').forEach((a, i) => {
|
|
49
|
+
Object.defineProperty(a, 'offsetTop', { value: 40 + i * 20, configurable: true });
|
|
50
|
+
Object.defineProperty(a, 'offsetHeight', { value: 20, configurable: true });
|
|
51
|
+
});
|
|
52
|
+
return nav;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const IDS = Array.from({ length: 40 }, (_, i) => `sec-${i + 1}`);
|
|
56
|
+
|
|
57
|
+
let scrollTo: ReturnType<typeof vi.fn>;
|
|
58
|
+
let scrollIntoView: ReturnType<typeof vi.fn>;
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
ioCallback = null;
|
|
62
|
+
(globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = FakeIO;
|
|
63
|
+
scrollTo = vi.fn();
|
|
64
|
+
scrollIntoView = vi.fn();
|
|
65
|
+
Element.prototype.scrollTo = scrollTo as never;
|
|
66
|
+
Element.prototype.scrollIntoView = scrollIntoView as never;
|
|
67
|
+
});
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
document.body.innerHTML = '';
|
|
70
|
+
vi.restoreAllMocks();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
async function mount(): Promise<HTMLElement> {
|
|
74
|
+
plantProse(IDS);
|
|
75
|
+
const host = document.createElement('div');
|
|
76
|
+
document.body.appendChild(host);
|
|
77
|
+
await act(async () => {
|
|
78
|
+
createRoot(host).render(<TableOfContents entryKey="/app/content/x.mdx" />);
|
|
79
|
+
});
|
|
80
|
+
return layout(host.querySelector('nav')!);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('<TableOfContents/>', () => {
|
|
84
|
+
it('lists the rendered entry’s headings', async () => {
|
|
85
|
+
const nav = await mount();
|
|
86
|
+
expect(nav.querySelectorAll('a')).toHaveLength(40);
|
|
87
|
+
expect(nav.querySelector('.grove-toc__h')?.textContent).toBe('On this page');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('scrolls its OWN container when the active entry is below the fold', async () => {
|
|
91
|
+
const nav = await mount();
|
|
92
|
+
await spyTo('sec-30'); // offsetTop 620, bottom 640 — far past a 200px view
|
|
93
|
+
expect(scrollTo).toHaveBeenCalledTimes(1);
|
|
94
|
+
expect(scrollTo.mock.calls[0][0]).toMatchObject({ top: 640 + 28 - 200 });
|
|
95
|
+
// …and on the LIST, not on some ancestor. The whole claim is "its own container".
|
|
96
|
+
expect(scrollTo.mock.instances[0]).toBe(nav);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('never calls scrollIntoView on a list entry — that would scroll the PAGE', async () => {
|
|
100
|
+
// The trap this component exists to avoid: `scrollIntoView` walks every scrollable
|
|
101
|
+
// ancestor, so using it here moves the document and the article jumps under the reader.
|
|
102
|
+
await mount();
|
|
103
|
+
await spyTo('sec-30');
|
|
104
|
+
expect(scrollIntoView).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('does nothing when the active entry is already in view', async () => {
|
|
108
|
+
await mount();
|
|
109
|
+
await spyTo('sec-3'); // offsetTop 80 — comfortably inside a 200px view at scrollTop 0
|
|
110
|
+
expect(scrollTo).not.toHaveBeenCalled();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('does nothing when the whole list fits', async () => {
|
|
114
|
+
plantProse(IDS);
|
|
115
|
+
const host = document.createElement('div');
|
|
116
|
+
document.body.appendChild(host);
|
|
117
|
+
await act(async () => {
|
|
118
|
+
createRoot(host).render(<TableOfContents entryKey="/app/content/x.mdx" />);
|
|
119
|
+
});
|
|
120
|
+
layout(host.querySelector('nav')!, { viewHeight: 1000, scrollHeight: 1000 });
|
|
121
|
+
await spyTo('sec-30');
|
|
122
|
+
expect(scrollTo).not.toHaveBeenCalled();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('does not fight a reader who just scrolled the list by hand', async () => {
|
|
126
|
+
const nav = await mount();
|
|
127
|
+
await act(async () => {
|
|
128
|
+
nav.dispatchEvent(new WheelEvent('wheel', { bubbles: true }));
|
|
129
|
+
});
|
|
130
|
+
await spyTo('sec-30');
|
|
131
|
+
expect(scrollTo).not.toHaveBeenCalled();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('marks the active entry for styling and for assistive tech', async () => {
|
|
135
|
+
const nav = await mount();
|
|
136
|
+
await spyTo('sec-12');
|
|
137
|
+
const active = nav.querySelector('a[data-cur="1"]');
|
|
138
|
+
expect(active?.getAttribute('href')).toBe('#sec-12');
|
|
139
|
+
expect(active?.getAttribute('aria-current')).toBe('location');
|
|
140
|
+
expect(nav.querySelectorAll('a[data-cur="1"]')).toHaveLength(1);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('renders nothing for an entry with no sub-headings', async () => {
|
|
144
|
+
const host = document.createElement('div');
|
|
145
|
+
document.body.appendChild(host);
|
|
146
|
+
await act(async () => {
|
|
147
|
+
createRoot(host).render(<TableOfContents entryKey="/app/content/empty.mdx" />);
|
|
148
|
+
});
|
|
149
|
+
expect(host.querySelector('nav')).toBeNull();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('clicking an entry scrolls the DOCUMENT to that heading, and suspends the auto-scroll', async () => {
|
|
153
|
+
const nav = await mount();
|
|
154
|
+
const link = nav.querySelector('a[href="#sec-30"]') as HTMLAnchorElement;
|
|
155
|
+
await act(async () => {
|
|
156
|
+
link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
157
|
+
});
|
|
158
|
+
// The heading, not the list entry.
|
|
159
|
+
expect(scrollIntoView).toHaveBeenCalledTimes(1);
|
|
160
|
+
await spyTo('sec-30');
|
|
161
|
+
expect(scrollTo).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
import { useHeadings, useActiveHeading } from '../hooks/useHeadings';
|
|
3
|
+
import { measure, scrollOffsetFor } from '../lib/tocScroll';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
/** Re-scan when the rendered entry changes. */
|
|
7
|
+
entryKey?: string;
|
|
8
|
+
/** Heading above the list; `null`/`""` renders none. */
|
|
9
|
+
title?: string | null;
|
|
10
|
+
/** Extra class on the `<nav>` — how `<Toc>` keeps its rail styling. */
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Breathing room kept between the active entry and the edge it is pulled away from. One
|
|
15
|
+
* row's worth: an entry flush against the fold reads as "the last one", and the margin is
|
|
16
|
+
* what tells the reader the list continues. */
|
|
17
|
+
const EDGE_MARGIN = 28;
|
|
18
|
+
|
|
19
|
+
/** How long a manual scroll of the list suppresses the automatic one. Without this the
|
|
20
|
+
* reader who scrolls the contents to look ahead is yanked back on the next heading the
|
|
21
|
+
* DOCUMENT passes — the list fighting the hand that moved it. */
|
|
22
|
+
const MANUAL_SCROLL_GRACE_MS = 2000;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `<TableOfContents/>` — the on-this-page list, which **keeps the current entry in view**.
|
|
26
|
+
*
|
|
27
|
+
* A long entry has a long contents list, and a list taller than its rail scrolls. Without
|
|
28
|
+
* this the reader scrolls into §12 and the contents still shows §1–§9 with nothing
|
|
29
|
+
* highlighted: the one surface whose whole job is "where am I" stops answering exactly
|
|
30
|
+
* when the document is long enough to need it.
|
|
31
|
+
*
|
|
32
|
+
* Three things it deliberately does NOT do:
|
|
33
|
+
*
|
|
34
|
+
* - **It never scrolls the page.** `scrollIntoView()` walks every scrollable ancestor, so
|
|
35
|
+
* the obvious implementation moves the document too — the reader passes a heading and
|
|
36
|
+
* the article jumps. This assigns `scrollTop` on its OWN container and nothing else can
|
|
37
|
+
* move (`lib/tocScroll`).
|
|
38
|
+
* - **It does not scroll when the entry is already visible.** Otherwise the list twitches
|
|
39
|
+
* on every heading the reader passes.
|
|
40
|
+
* - **It does not fight the reader.** A manual scroll of the list suspends the automatic
|
|
41
|
+
* one briefly.
|
|
42
|
+
*/
|
|
43
|
+
export default function TableOfContents({ entryKey, title = 'On this page', className }: Props) {
|
|
44
|
+
const heads = useHeadings(entryKey);
|
|
45
|
+
const cur = useActiveHeading(heads);
|
|
46
|
+
const listRef = useRef<HTMLElement | null>(null);
|
|
47
|
+
const itemsRef = useRef(new Map<string, HTMLAnchorElement>());
|
|
48
|
+
const manualUntilRef = useRef(0);
|
|
49
|
+
|
|
50
|
+
const noteManualScroll = useCallback(() => {
|
|
51
|
+
manualUntilRef.current = Date.now() + MANUAL_SCROLL_GRACE_MS;
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const list = listRef.current;
|
|
56
|
+
const item = cur ? itemsRef.current.get(cur) : undefined;
|
|
57
|
+
if (!list || !item) return;
|
|
58
|
+
if (Date.now() < manualUntilRef.current) return;
|
|
59
|
+
const offset = scrollOffsetFor(...measure(list, item), { margin: EDGE_MARGIN });
|
|
60
|
+
if (offset === null) return; // already in view — "when necessary"
|
|
61
|
+
// `scroll()` on the element, never `scrollIntoView()` on the item: only this container
|
|
62
|
+
// may move. Motion is opt-out — a reader who asked the OS for less of it gets a jump.
|
|
63
|
+
const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
|
64
|
+
list.scrollTo({ top: offset, behavior: reduced ? 'auto' : 'smooth' });
|
|
65
|
+
}, [cur]);
|
|
66
|
+
|
|
67
|
+
if (!heads.length) return null;
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<nav
|
|
71
|
+
ref={listRef}
|
|
72
|
+
className={['grove-toc', className].filter(Boolean).join(' ')}
|
|
73
|
+
aria-label={title || 'On this page'}
|
|
74
|
+
onWheel={noteManualScroll}
|
|
75
|
+
onTouchMove={noteManualScroll}
|
|
76
|
+
>
|
|
77
|
+
{title ? <div className="grove-toc__h">{title}</div> : null}
|
|
78
|
+
{heads.map((h) => (
|
|
79
|
+
<a
|
|
80
|
+
key={h.id}
|
|
81
|
+
ref={(el) => {
|
|
82
|
+
if (el) itemsRef.current.set(h.id, el);
|
|
83
|
+
else itemsRef.current.delete(h.id);
|
|
84
|
+
}}
|
|
85
|
+
href={`#${h.id}`}
|
|
86
|
+
data-cur={cur === h.id ? '1' : '0'}
|
|
87
|
+
aria-current={cur === h.id ? 'location' : undefined}
|
|
88
|
+
className={h.level === 3 ? 'lvl3' : undefined}
|
|
89
|
+
onClick={(e) => {
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
// Clicking IS the reader moving the list, so don't also auto-scroll it.
|
|
92
|
+
noteManualScroll();
|
|
93
|
+
document.getElementById(h.id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
{h.text}
|
|
97
|
+
</a>
|
|
98
|
+
))}
|
|
99
|
+
</nav>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { useCallback } from 'react';
|
|
3
|
+
import { useMetadataQuery } from '@immediately-run/sdk';
|
|
4
|
+
import { contentDir } from '../lib/content';
|
|
5
|
+
import { queryPaths } from '../lib/wiki';
|
|
6
|
+
|
|
7
|
+
// Import-free engine component: every tag across the site, sized by frequency.
|
|
8
|
+
// Chrome tags (`ui/*`) are excluded — they drive layout, not classification.
|
|
9
|
+
export default function TagCloud() {
|
|
10
|
+
const queryFn = useCallback((filesMetadata: Record<string, any>) => {
|
|
11
|
+
const counts: Record<string, number> = {};
|
|
12
|
+
Object.entries(filesMetadata).forEach(([p, m]) => {
|
|
13
|
+
if (!p.startsWith(contentDir())) return;
|
|
14
|
+
if (m && Array.isArray(m.tags)) {
|
|
15
|
+
m.tags.forEach((t: string) => {
|
|
16
|
+
if (t.startsWith('ui/')) return;
|
|
17
|
+
counts[t] = (counts[t] || 0) + 1;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
// Encode counts into the string[] the query contract returns.
|
|
22
|
+
return Object.keys(counts)
|
|
23
|
+
.sort()
|
|
24
|
+
.map((t) => `${t}:${counts[t]}`);
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
const result = useMetadataQuery(queryFn);
|
|
28
|
+
const entries: string[] = queryPaths(result);
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="grove-tagcloud">
|
|
32
|
+
{entries.map((e) => {
|
|
33
|
+
const [tag, count] = e.split(':');
|
|
34
|
+
return (
|
|
35
|
+
<span
|
|
36
|
+
key={tag}
|
|
37
|
+
className="grove-tag"
|
|
38
|
+
style={{ fontSize: 11 + Number(count) * 2 + 'px', padding: '4px 12px' }}
|
|
39
|
+
>
|
|
40
|
+
#{tag} <span style={{ opacity: 0.5, marginLeft: 4 }}>{count}</span>
|
|
41
|
+
</span>
|
|
42
|
+
);
|
|
43
|
+
})}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { useCallback } from 'react';
|
|
3
|
+
import { useMetadataQuery } from '@immediately-run/sdk';
|
|
4
|
+
import { contentDir } from '../lib/content';
|
|
5
|
+
import { queryPaths } from '../lib/wiki';
|
|
6
|
+
|
|
7
|
+
// `<TagList/>` — every content tag, sorted, as plain chips (the "related tags"
|
|
8
|
+
// strip on a tag page). Reserved `ui/*` chrome tags are excluded.
|
|
9
|
+
export default function TagList() {
|
|
10
|
+
const queryFn = useCallback((fm: Record<string, any>) => {
|
|
11
|
+
const set = new Set<string>();
|
|
12
|
+
Object.entries(fm).forEach(([p, m]: [string, any]) => {
|
|
13
|
+
if (!p.startsWith(contentDir())) return;
|
|
14
|
+
(Array.isArray(m?.tags) ? m.tags : []).forEach((t: string) => {
|
|
15
|
+
if (!t.startsWith('ui/')) set.add(t);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
return Array.from(set).sort();
|
|
19
|
+
}, []);
|
|
20
|
+
const q = useMetadataQuery(queryFn);
|
|
21
|
+
const tags: string[] = queryPaths(q);
|
|
22
|
+
|
|
23
|
+
if (!tags.length) return null;
|
|
24
|
+
return (
|
|
25
|
+
<div className="grove-tagcloud">
|
|
26
|
+
{tags.map((t) => (
|
|
27
|
+
<span key={t} className="grove-tag">#{t}</span>
|
|
28
|
+
))}
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { useCallback } from 'react';
|
|
3
|
+
import { Link, useFileMetadata, useMetadataQuery } from '@immediately-run/sdk';
|
|
4
|
+
import { isContentEntry, keyToHref } from '../lib/content';
|
|
5
|
+
import { queryPaths } from '../lib/wiki';
|
|
6
|
+
|
|
7
|
+
// One dated entry on the axis: mono date · node · card.
|
|
8
|
+
function Row({ path }: { path: string }) {
|
|
9
|
+
const m = useFileMetadata(path) as any;
|
|
10
|
+
if (!m) return null;
|
|
11
|
+
const tags: string[] = Array.isArray(m.tags) ? m.tags.filter((t: string) => !t.startsWith('ui/')) : [];
|
|
12
|
+
return (
|
|
13
|
+
<Link href={keyToHref(path)} className="gtl-row">
|
|
14
|
+
<div className="gtl-date">{m.date}</div>
|
|
15
|
+
<div className="gtl-node" />
|
|
16
|
+
<div className="gtl-card">
|
|
17
|
+
<div className="gtl-title">{(m.title || path).replace(/\.$/, '')}</div>
|
|
18
|
+
{m.description && <div className="gtl-desc">{m.description}</div>}
|
|
19
|
+
{tags.length ? (
|
|
20
|
+
<div className="gtl-tags">
|
|
21
|
+
{tags.slice(0, 3).map((t) => (
|
|
22
|
+
<span key={t} className="grove-tag">#{t}</span>
|
|
23
|
+
))}
|
|
24
|
+
</div>
|
|
25
|
+
) : null}
|
|
26
|
+
</div>
|
|
27
|
+
</Link>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// `<Timeline/>` — an agent-added view: every entry with a `date`, laid on a time
|
|
32
|
+
// axis newest-first, with a restrained per-row reveal (CSS `gtlin`).
|
|
33
|
+
export default function Timeline() {
|
|
34
|
+
const queryFn = useCallback(
|
|
35
|
+
(fm: Record<string, any>) =>
|
|
36
|
+
Object.keys(fm)
|
|
37
|
+
.filter((p) => isContentEntry(p) && fm[p]?.date && !fm[p]?.view)
|
|
38
|
+
.sort((a, b) => String(fm[b].date).localeCompare(String(fm[a].date))),
|
|
39
|
+
[]
|
|
40
|
+
);
|
|
41
|
+
const q = useMetadataQuery(queryFn);
|
|
42
|
+
const loaded = Array.isArray(q);
|
|
43
|
+
const paths: string[] = queryPaths(q);
|
|
44
|
+
|
|
45
|
+
if (loaded && !paths.length) {
|
|
46
|
+
return <p className="grove-search__empty">No dated entries yet.</p>;
|
|
47
|
+
}
|
|
48
|
+
return (
|
|
49
|
+
<div className="grove-timeline">
|
|
50
|
+
{paths.map((p) => (
|
|
51
|
+
<Row key={p} path={p} />
|
|
52
|
+
))}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import TableOfContents from './TableOfContents';
|
|
2
|
+
|
|
3
|
+
// `<Toc/>` — the on-this-page rail, and the name every existing layout and entry uses.
|
|
4
|
+
//
|
|
5
|
+
// It is now an alias for `<TableOfContents/>`, which is the same list plus "keep the
|
|
6
|
+
// current entry in view". Kept as its own name rather than rewritten at the call sites
|
|
7
|
+
// because `Toc` is registered content vocabulary: a corpus may already say `<Toc/>`, and
|
|
8
|
+
// the engine's names are an interface (spec_style's §-number rule, applied to components).
|
|
9
|
+
//
|
|
10
|
+
// Deliberately NOT a second implementation. The two used to be one copy of the DOM scan
|
|
11
|
+
// each, which is precisely the drift ENGINE_BOUNDARY §4 was written about.
|
|
12
|
+
export default function Toc({ entryKey }: { entryKey?: string }) {
|
|
13
|
+
return <TableOfContents entryKey={entryKey} />;
|
|
14
|
+
}
|