@immediately-run/grove 0.1.10 → 0.2.2
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/llms.txt +13 -1
- package/package.json +3 -3
- package/src/GroveApp.css +11 -0
- package/src/GroveWiki.tsx +19 -60
- package/src/components/AssetImage.test.tsx +64 -0
- package/src/components/AssetImage.tsx +10 -10
- package/src/components/Backlinks.tsx +13 -6
- package/src/components/ChildPages.tsx +8 -6
- package/src/components/Directory.tsx +6 -1
- package/src/components/DirectoryList.tsx +16 -8
- package/src/components/DirectoryView.tsx +5 -2
- package/src/components/DocList.tsx +9 -2
- package/src/components/Drawer.tsx +14 -1
- package/src/components/EntryBody.tsx +138 -0
- package/src/components/EntryHeader.tsx +16 -3
- package/src/components/FamilyTree.tsx +3 -5
- package/src/components/GroveAgent.tsx +1 -1
- package/src/components/GroveEntry.test.tsx +200 -0
- package/src/components/GroveEntry.tsx +108 -0
- package/src/components/GroveFooter.tsx +5 -2
- package/src/components/GroveNav.tsx +11 -2
- package/src/components/PageMeta.tsx +3 -5
- package/src/components/PageView.tsx +13 -63
- package/src/components/Search.tsx +10 -1
- package/src/components/Sidebar.tsx +15 -7
- package/src/components/TableOfContents.tsx +1 -1
- package/src/components/TagCloud.test.tsx +103 -0
- package/src/components/TagCloud.tsx +47 -29
- package/src/components/Timeline.tsx +6 -1
- package/src/components/WikiLink.test.tsx +83 -0
- package/src/components/WikiLink.tsx +29 -8
- package/src/components/navigationPolicy.sweep.test.tsx +261 -0
- package/src/hooks/useEditAffordance.test.tsx +110 -0
- package/src/hooks/useEditAffordance.ts +51 -14
- package/src/hooks/useEntryKey.ts +21 -0
- package/src/hooks/useFollowLink.ts +11 -0
- package/src/hooks/useHeadings.test.tsx +93 -0
- package/src/hooks/useHeadings.ts +73 -12
- package/src/lib/assetPath.ts +4 -3
- package/src/lib/content.ts +0 -5
- package/src/lib/editTarget.test.ts +78 -18
- package/src/lib/editTarget.ts +39 -10
- package/src/lib/entryContext.test.ts +46 -0
- package/src/lib/entryContext.ts +35 -0
- package/src/lib/fragment.test.ts +21 -0
- package/src/lib/fragment.ts +9 -6
- package/src/lib/navigationPolicy.test.ts +65 -0
- package/src/lib/navigationPolicy.ts +57 -0
- package/src/lib/renderMode.ts +17 -0
- package/src/lib/shell.ts +4 -0
- package/src/lib/tagCloud.test.ts +115 -0
- package/src/lib/tagCloud.ts +58 -0
- package/src/lib.ts +18 -0
|
@@ -1,27 +1,26 @@
|
|
|
1
|
-
|
|
2
|
-
import { Include, Link } from '@immediately-run/sdk';
|
|
1
|
+
import { Link } from '@immediately-run/sdk';
|
|
3
2
|
import { useShell, EDIT_REFUSED_NOTICE } from '../lib/shell';
|
|
4
|
-
import { keyToHref, keyToRepoRel } from '../lib/content';
|
|
3
|
+
import { keyToHref, keyToRepoRel, homeKey } from '../lib/content';
|
|
5
4
|
import { crumb } from '../lib/wiki';
|
|
6
5
|
import { useHeadingFragmentUrl } from '../hooks/useHeadingFragmentUrl';
|
|
6
|
+
import { useFollowLink } from '../hooks/useFollowLink';
|
|
7
|
+
import { followLinkOnClick } from '../lib/navigationPolicy';
|
|
7
8
|
import DirectoryView from './DirectoryView';
|
|
8
|
-
import
|
|
9
|
-
import SafeEntryBody from './SafeEntryBody';
|
|
10
|
-
import ScrollToFragment from './ScrollToFragment';
|
|
11
|
-
import Toc from './Toc';
|
|
12
|
-
import Backlinks from './Backlinks';
|
|
9
|
+
import EntryBody from './EntryBody';
|
|
13
10
|
import Icon from './Icon';
|
|
14
11
|
|
|
15
|
-
declare const module: any;
|
|
16
|
-
|
|
17
12
|
// `<PageView/>` — the reading view for the current entry: the 404/missing state,
|
|
18
13
|
// or the entry header + prose body (+ ToC / backlinks rails). This is what the
|
|
19
14
|
// INNERMOST `<Outlet/>` renders at the bottom of the layout chain. It carries no
|
|
20
15
|
// site chrome (nav / sidebar / footer) — that's the layout's job — so the page
|
|
21
16
|
// stays free of shell concerns.
|
|
22
17
|
export default function PageView() {
|
|
23
|
-
|
|
18
|
+
// R3-872: the entry content lives in EntryBody (one renderer, shared with
|
|
19
|
+
// GroveEntry); the page keeps only the page-level states (checking /
|
|
20
|
+
// directory / missing) and the deep-link half it owns.
|
|
21
|
+
const { entryKey, missing, suggestion, writable, openEditor, editBusy, editRefused, editHint, directory } =
|
|
24
22
|
useShell();
|
|
23
|
+
const follow = useFollowLink();
|
|
25
24
|
|
|
26
25
|
// The reading view owns the headings, so it owns the outgoing half of deep
|
|
27
26
|
// linking: same-page heading navigation writes the fragment into the host's
|
|
@@ -41,10 +40,10 @@ export default function PageView() {
|
|
|
41
40
|
<h2>No entry at <code>{keyToRepoRel(entryKey).replace(/^content/, '')}</code>.</h2>
|
|
42
41
|
<p>
|
|
43
42
|
That link points to an entry that doesn’t exist yet.
|
|
44
|
-
{suggestion ? <> Did you mean <Link className="grove-wikilink" data-state="ok" href={keyToHref(suggestion)}>{crumb(suggestion)}</Link>?</> : null}
|
|
43
|
+
{suggestion ? <> Did you mean <Link className="grove-wikilink" data-state="ok" href={keyToHref(suggestion)} onClick={followLinkOnClick(follow, { key: suggestion, href: keyToHref(suggestion), from: entryKey })}>{crumb(suggestion)}</Link>?</> : null}
|
|
45
44
|
</p>
|
|
46
45
|
<div className="grove-state__actions">
|
|
47
|
-
<Link className="btn-ghost" href="/"><Icon name="chevron-right" /> Back to home</Link>
|
|
46
|
+
<Link className="btn-ghost" href="/" onClick={followLinkOnClick(follow, { key: homeKey(), href: '/', from: entryKey })}><Icon name="chevron-right" /> Back to home</Link>
|
|
48
47
|
{writable ? (
|
|
49
48
|
<>
|
|
50
49
|
<button
|
|
@@ -66,54 +65,5 @@ export default function PageView() {
|
|
|
66
65
|
);
|
|
67
66
|
}
|
|
68
67
|
|
|
69
|
-
return
|
|
70
|
-
<article className="grove-page" data-layout={layout}>
|
|
71
|
-
<div className="gp-main">
|
|
72
|
-
<EntryHeader entryKey={entryKey} writable={writable} mins={mins} />
|
|
73
|
-
{showRails && vw === 'mobile' ? (
|
|
74
|
-
<details className="grove-toc__disclosure">
|
|
75
|
-
<summary>On this page</summary>
|
|
76
|
-
<Toc entryKey={entryKey} />
|
|
77
|
-
</details>
|
|
78
|
-
) : null}
|
|
79
|
-
<div className="grove-prose">
|
|
80
|
-
{/* Interpreter mode (R3-213) renders the raw entry as data — no author JS runs;
|
|
81
|
-
executable mode compiles + runs the MDX via <Include>. Both are supported,
|
|
82
|
-
and since R3-252 the choice is per-entry as well as wiki-wide.
|
|
83
|
-
|
|
84
|
-
The compiled branch mounts <ScrollToFragment> too — deep-link landing is not
|
|
85
|
-
a safe-path feature — but deliberately carries NO `data-entry` marker, and
|
|
86
|
-
both halves of that are load-bearing (R3-252).
|
|
87
|
-
|
|
88
|
-
`key={entryKey}` forces an unmount across a client-side navigation. Without
|
|
89
|
-
it React reuses this subtree and keeps the PREVIOUS document mounted while
|
|
90
|
-
the new one compiles, so `#sec-4` resolves against the wrong entry — every
|
|
91
|
-
entry here numbers its sections from 1, so that lookup always succeeds and
|
|
92
|
-
always lands wrong. Measured on the host: a citation to core_concepts §4
|
|
93
|
-
("4 — Principal") scrolled to a different document's "4. The provider-facing
|
|
94
|
-
contract" (R3-249's hazard, reintroduced).
|
|
95
|
-
|
|
96
|
-
No marker, because `<Include>` owns an internal <Suspense> and this wrapper
|
|
97
|
-
is OUTSIDE it — so a marker here would appear the instant we navigate, while
|
|
98
|
-
the body is still compiling, and claim a commit that has not happened. That
|
|
99
|
-
is worse than no signal: `data-entry` means "this subtree holds entry X's
|
|
100
|
-
committed body" everywhere else (`SafeEntryBody` puts it inside the suspended
|
|
101
|
-
subtree, so it is true there), and the on-host harness waits on it. Keying
|
|
102
|
-
already removes the hazard the marker defended against — the outgoing
|
|
103
|
-
document is gone from the DOM, not lingering — so `resolveFragmentTarget`'s
|
|
104
|
-
documented document-wide fallback for the compiled path is safe here. */}
|
|
105
|
-
{safe ? (
|
|
106
|
-
<SafeEntryBody entryKey={entryKey} />
|
|
107
|
-
) : (
|
|
108
|
-
<div className="grove-entry-body" key={entryKey}>
|
|
109
|
-
<Include filename={includePath} baseModule={module} />
|
|
110
|
-
<ScrollToFragment entryKey={entryKey} />
|
|
111
|
-
</div>
|
|
112
|
-
)}
|
|
113
|
-
</div>
|
|
114
|
-
{showRails ? <Backlinks /> : null}
|
|
115
|
-
</div>
|
|
116
|
-
{showRails && vw === 'desktop' ? <Toc entryKey={entryKey} /> : null}
|
|
117
|
-
</article>
|
|
118
|
-
);
|
|
68
|
+
return <EntryBody entryKey={entryKey} />;
|
|
119
69
|
}
|
|
@@ -4,6 +4,8 @@ import type { Metadata } from '@immediately-run/sdk';
|
|
|
4
4
|
import { keyToHref } from '../lib/content';
|
|
5
5
|
import { crumb } from '../lib/wiki';
|
|
6
6
|
import { useOverlayFocusDismiss } from '../hooks/useOverlayFocusDismiss';
|
|
7
|
+
import { useFollowLink } from '../hooks/useFollowLink';
|
|
8
|
+
import { followLinkOnClick } from '../lib/navigationPolicy';
|
|
7
9
|
import InlineProse from './InlineProse';
|
|
8
10
|
import { matchesQuery, searchQuery, toSearchEntries } from '../lib/queries';
|
|
9
11
|
import type { SearchRecord } from '../lib/queries';
|
|
@@ -75,6 +77,7 @@ export default function Search({ onClose }: { onClose: () => void }) {
|
|
|
75
77
|
}
|
|
76
78
|
};
|
|
77
79
|
|
|
80
|
+
const follow = useFollowLink();
|
|
78
81
|
const activeId = total > 0 ? `${listId}-opt-${sel}` : undefined;
|
|
79
82
|
|
|
80
83
|
return (
|
|
@@ -125,7 +128,13 @@ export default function Search({ onClose }: { onClose: () => void }) {
|
|
|
125
128
|
id={`${listId}-opt-${i}`}
|
|
126
129
|
role="option"
|
|
127
130
|
aria-selected={sel === i}
|
|
128
|
-
onClick={
|
|
131
|
+
onClick={(e) => {
|
|
132
|
+
// R3-872: the policy first (it preventDefaults plain clicks and
|
|
133
|
+
// no-ops modified ones), then the overlay's own close — the
|
|
134
|
+
// same order the bare onClick={onClose} had with no policy.
|
|
135
|
+
followLinkOnClick(follow, { key: h.key, href: keyToHref(h.key) })(e);
|
|
136
|
+
onClose();
|
|
137
|
+
}}
|
|
129
138
|
>
|
|
130
139
|
<Icon name="file" />
|
|
131
140
|
<span className="t">
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
import { useContext, useMemo, useState } from 'react';
|
|
1
|
+
import { useMemo, useState } from 'react';
|
|
3
2
|
import { Link, useMetadataQuery } from '@immediately-run/sdk';
|
|
4
3
|
import type { Metadata } from '@immediately-run/sdk';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { contentDir, keyToHref } from '../lib/content';
|
|
5
|
+
import { useEntryKey } from '../hooks/useEntryKey';
|
|
6
|
+
import { useFollowLink } from '../hooks/useFollowLink';
|
|
7
|
+
import { followLinkOnClick } from '../lib/navigationPolicy';
|
|
7
8
|
import { sidebarQuery } from '../lib/queries';
|
|
8
9
|
import { plainLabel } from '../lib/queries';
|
|
9
10
|
import InlineProse from './InlineProse';
|
|
@@ -77,6 +78,7 @@ function Branch({
|
|
|
77
78
|
const isFolder = childKeys.length > 0;
|
|
78
79
|
const containsCurrent = !!currentKey && (node.key === currentKey || hasDescendant(node, currentKey));
|
|
79
80
|
const [open, setOpen] = useState(depth < 1 || containsCurrent);
|
|
81
|
+
const follow = useFollowLink();
|
|
80
82
|
|
|
81
83
|
if (!isFolder) {
|
|
82
84
|
return (
|
|
@@ -89,6 +91,7 @@ function Branch({
|
|
|
89
91
|
aria-level={depth + 1}
|
|
90
92
|
aria-current={node.key === currentKey ? 'page' : undefined}
|
|
91
93
|
tabIndex={node.key === stopRowId ? 0 : -1}
|
|
94
|
+
onClick={followLinkOnClick(follow, { key: node.key!, href: keyToHref(node.key!), from: currentKey })}
|
|
92
95
|
>
|
|
93
96
|
<Icon name="file" />
|
|
94
97
|
<InlineProse text={node.title || node.name} trimPeriod />
|
|
@@ -141,8 +144,8 @@ function Branch({
|
|
|
141
144
|
// `.grove-sidebar` — the wiki shell's left rail: a namespace tree + author-defined
|
|
142
145
|
// `ui/sidebar` sections (a header + a link into the tagged entry).
|
|
143
146
|
export default function Sidebar() {
|
|
144
|
-
const
|
|
145
|
-
const
|
|
147
|
+
const currentKey = useEntryKey();
|
|
148
|
+
const follow = useFollowLink();
|
|
146
149
|
|
|
147
150
|
// Records, not tab-encoded paths (R3-276a).
|
|
148
151
|
const q = useMetadataQuery<Metadata, SidebarRecord>(sidebarQuery);
|
|
@@ -209,7 +212,12 @@ export default function Sidebar() {
|
|
|
209
212
|
<div className="gs-block" key={s.key}>
|
|
210
213
|
<div className="gs-block__h">{s.label}</div>
|
|
211
214
|
<div className="gs-section__body">
|
|
212
|
-
<Link
|
|
215
|
+
<Link
|
|
216
|
+
href={keyToHref(s.key)}
|
|
217
|
+
className="grove-wikilink"
|
|
218
|
+
data-state="ok"
|
|
219
|
+
onClick={followLinkOnClick(follow, { key: s.key, href: keyToHref(s.key), from: currentKey })}
|
|
220
|
+
>
|
|
213
221
|
Open
|
|
214
222
|
</Link>
|
|
215
223
|
</div>
|
|
@@ -42,7 +42,7 @@ const MANUAL_SCROLL_GRACE_MS = 2000;
|
|
|
42
42
|
*/
|
|
43
43
|
export default function TableOfContents({ entryKey, title = 'On this page', className }: Props) {
|
|
44
44
|
const heads = useHeadings(entryKey);
|
|
45
|
-
const cur = useActiveHeading(heads);
|
|
45
|
+
const cur = useActiveHeading(heads, entryKey);
|
|
46
46
|
const listRef = useRef<HTMLElement | null>(null);
|
|
47
47
|
const itemsRef = useRef(new Map<string, HTMLAnchorElement>());
|
|
48
48
|
const manualUntilRef = useRef(0);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// TagCloud's render contract: chips carry a 0..1 `--tag-weight` and NO inline
|
|
3
|
+
// font-size (the pixel range is GroveApp.css's), the wrapper carries the
|
|
4
|
+
// --weighted modifier, and `limit` keeps the N most-used tags (a bad limit is
|
|
5
|
+
// ignored with one warning). Metadata is supplied through TinkerableContext, the
|
|
6
|
+
// way Sidebar.test.tsx supplies its map. The rendered pixel size itself is
|
|
7
|
+
// jsdom-untestable (no calc()/custom-property resolution) — that leg is R3-946.
|
|
8
|
+
import { describe, expect, it, vi, afterEach } from 'vitest';
|
|
9
|
+
import { act } from 'react';
|
|
10
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
11
|
+
import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
|
|
12
|
+
|
|
13
|
+
const { default: TagCloud } = await import('./TagCloud');
|
|
14
|
+
|
|
15
|
+
// One tag on 191 entries (the real docs corpus's `bug` scale) and one on a
|
|
16
|
+
// single entry.
|
|
17
|
+
const FILES = {
|
|
18
|
+
...Object.fromEntries(
|
|
19
|
+
Array.from({ length: 191 }, (_, i) => [`/app/content/e${i}.mdx`, { title: `E${i}`, tags: ['bug'] }]),
|
|
20
|
+
),
|
|
21
|
+
'/app/content/single.mdx': { title: 'Single.', tags: ['doc'] },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const NAV = {
|
|
25
|
+
outerHref: 'https://example.immediately.run/app/x',
|
|
26
|
+
navigationState: {
|
|
27
|
+
sandboxPath: '/app/x',
|
|
28
|
+
provider: 'github',
|
|
29
|
+
namespace: 'immediately-run',
|
|
30
|
+
repository: 'docs',
|
|
31
|
+
ref: 'main',
|
|
32
|
+
hash: '',
|
|
33
|
+
search: '',
|
|
34
|
+
},
|
|
35
|
+
routingSpec: {} as never,
|
|
36
|
+
filesMetadata: FILES,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
let mounted: { root: Root; container: HTMLElement } | null = null;
|
|
40
|
+
|
|
41
|
+
async function mountCloud(limit?: number): Promise<HTMLElement> {
|
|
42
|
+
const container = document.createElement('div');
|
|
43
|
+
document.body.appendChild(container);
|
|
44
|
+
const root = createRoot(container);
|
|
45
|
+
await act(async () => {
|
|
46
|
+
root.render(
|
|
47
|
+
<TinkerableContext.Provider value={NAV as never}>
|
|
48
|
+
<TagCloud limit={limit} />
|
|
49
|
+
</TinkerableContext.Provider>,
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
mounted = { root, container };
|
|
53
|
+
return container;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
if (mounted) {
|
|
58
|
+
act(() => mounted!.root.unmount());
|
|
59
|
+
mounted!.container.remove();
|
|
60
|
+
mounted = null;
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const chips = (container: HTMLElement): HTMLElement[] =>
|
|
65
|
+
[...container.querySelectorAll('.grove-tag')] as HTMLElement[];
|
|
66
|
+
|
|
67
|
+
describe('TagCloud', () => {
|
|
68
|
+
it('weights every chip between 0 and 1 with no inline font-size, on a --weighted wrapper', async () => {
|
|
69
|
+
const container = await mountCloud();
|
|
70
|
+
expect(container.querySelector('.grove-tagcloud')!.classList.contains('grove-tagcloud--weighted')).toBe(true);
|
|
71
|
+
const all = chips(container);
|
|
72
|
+
expect(all.length).toBe(2);
|
|
73
|
+
const weights = all.map((chip) => Number.parseFloat(chip.style.getPropertyValue('--tag-weight')));
|
|
74
|
+
for (const w of weights) {
|
|
75
|
+
expect(Number.isFinite(w)).toBe(true);
|
|
76
|
+
expect(w).toBeGreaterThanOrEqual(0);
|
|
77
|
+
expect(w).toBeLessThanOrEqual(1);
|
|
78
|
+
}
|
|
79
|
+
// Chips read alphabetically: `bug` (191 entries, the maximum) then `doc`
|
|
80
|
+
// (one entry, the minimum).
|
|
81
|
+
expect(weights).toEqual([1, 0]);
|
|
82
|
+
for (const chip of all) expect(chip.style.fontSize).toBe('');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('limit={1} renders only the most-used tag, still scaled against the corpus', async () => {
|
|
86
|
+
const container = await mountCloud(1);
|
|
87
|
+
const all = chips(container);
|
|
88
|
+
expect(all.length).toBe(1);
|
|
89
|
+
expect(all[0]!.textContent).toContain('bug');
|
|
90
|
+
// Corpus-relative (not subset-relative): the corpus maximum keeps weight 1
|
|
91
|
+
// under the limit — subset scaling would give it min === max → 0.
|
|
92
|
+
expect(all[0]!.style.getPropertyValue('--tag-weight')).toBe('1');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('limit={0} is treated as absent and warns once', async () => {
|
|
96
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
97
|
+
const container = await mountCloud(0);
|
|
98
|
+
expect(chips(container).length).toBe(2);
|
|
99
|
+
expect(warn.mock.calls.length).toBe(1);
|
|
100
|
+
expect(String(warn.mock.calls[0]![0])).toContain('0');
|
|
101
|
+
warn.mockRestore();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -1,46 +1,64 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
-
import { useCallback } from 'react';
|
|
2
|
+
import { useCallback, useEffect } from 'react';
|
|
3
|
+
import type { CSSProperties } from 'react';
|
|
3
4
|
import { useMetadataQuery } from '@immediately-run/sdk';
|
|
4
5
|
import { contentDir } from '../lib/content';
|
|
5
6
|
import { queryPaths } from '../lib/wiki';
|
|
7
|
+
import { countTags, tagWeight, topTags, type TagCount } from '../lib/tagCloud';
|
|
6
8
|
|
|
7
9
|
// Import-free engine component: every tag across the site, sized by frequency.
|
|
8
10
|
// Chrome tags (`ui/*`) are excluded — they drive layout, not classification.
|
|
9
|
-
|
|
11
|
+
//
|
|
12
|
+
// Sizing: each chip carries its weight as `--tag-weight` and the CSS owns the
|
|
13
|
+
// pixels (11.5–22px) — the old `11 + count * 2 + 'px'` inline style had no
|
|
14
|
+
// ceiling and let a frequent tag fill the viewport. The wrapper carries the
|
|
15
|
+
// `--weighted` modifier so the rule never touches TagList's plain chips, which
|
|
16
|
+
// share the `.grove-tagcloud` wrapper class.
|
|
17
|
+
|
|
18
|
+
// A bad `limit` warns once per distinct value per session — an authored page
|
|
19
|
+
// never goes blank over a prop.
|
|
20
|
+
const warnedLimits = new Set<string>();
|
|
21
|
+
|
|
22
|
+
export default function TagCloud({ limit }: { limit?: number }) {
|
|
23
|
+
const limitOk = limit === undefined || (Number.isInteger(limit) && limit > 0);
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (limitOk) return;
|
|
26
|
+
const key = String(limit);
|
|
27
|
+
if (warnedLimits.has(key)) return;
|
|
28
|
+
warnedLimits.add(key);
|
|
29
|
+
console.warn(`TagCloud: limit must be a positive integer, got ${key} — showing every tag`);
|
|
30
|
+
}, [limit, limitOk]);
|
|
31
|
+
|
|
10
32
|
const queryFn = useCallback((filesMetadata: Record<string, any>) => {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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]}`);
|
|
33
|
+
// The query contract is string-valued, so the pairs ride as `tag:count`
|
|
34
|
+
// (the encoding is unchanged).
|
|
35
|
+
return countTags(filesMetadata, contentDir()).map(({ tag, count }) => `${tag}:${count}`);
|
|
25
36
|
}, []);
|
|
26
37
|
|
|
27
38
|
const result = useMetadataQuery(queryFn);
|
|
28
39
|
const entries: string[] = queryPaths(result);
|
|
40
|
+
const pairs: TagCount[] = entries.map((e) => {
|
|
41
|
+
const [tag, count] = e.split(':');
|
|
42
|
+
return { tag: tag!, count: Number(count) };
|
|
43
|
+
});
|
|
44
|
+
const shown = topTags(pairs, limitOk ? limit : undefined);
|
|
45
|
+
// The scale is relative to the CORPUS (every tag), not the shown subset —
|
|
46
|
+
// under a limit the corpus's most-used tag must still read as the largest.
|
|
47
|
+
const counts = pairs.map((p) => p.count);
|
|
48
|
+
const min = Math.min(...counts);
|
|
49
|
+
const max = Math.max(...counts);
|
|
29
50
|
|
|
30
51
|
return (
|
|
31
|
-
<div className="grove-tagcloud">
|
|
32
|
-
{
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
</span>
|
|
42
|
-
);
|
|
43
|
-
})}
|
|
52
|
+
<div className="grove-tagcloud grove-tagcloud--weighted">
|
|
53
|
+
{shown.map(({ tag, count }) => (
|
|
54
|
+
<span
|
|
55
|
+
key={tag}
|
|
56
|
+
className="grove-tag"
|
|
57
|
+
style={{ '--tag-weight': String(tagWeight(count, min, max)) } as CSSProperties}
|
|
58
|
+
>
|
|
59
|
+
#{tag} <span className="grove-tag__count">{count}</span>
|
|
60
|
+
</span>
|
|
61
|
+
))}
|
|
44
62
|
</div>
|
|
45
63
|
);
|
|
46
64
|
}
|
|
@@ -4,15 +4,20 @@ import { Link, useFileMetadata, useMetadataQuery } from '@immediately-run/sdk';
|
|
|
4
4
|
import { isContentEntry, keyToHref } from '../lib/content';
|
|
5
5
|
import InlineProse from './InlineProse';
|
|
6
6
|
import { queryPaths } from '../lib/wiki';
|
|
7
|
+
import { useEntryKey } from '../hooks/useEntryKey';
|
|
8
|
+
import { useFollowLink } from '../hooks/useFollowLink';
|
|
9
|
+
import { followLinkOnClick } from '../lib/navigationPolicy';
|
|
7
10
|
import EntryImage from './EntryImage';
|
|
8
11
|
|
|
9
12
|
// One dated entry on the axis: mono date · node · card.
|
|
10
13
|
function Row({ path }: { path: string }) {
|
|
11
14
|
const m = useFileMetadata(path) as any;
|
|
15
|
+
const from = useEntryKey();
|
|
16
|
+
const follow = useFollowLink();
|
|
12
17
|
if (!m) return null;
|
|
13
18
|
const tags: string[] = Array.isArray(m.tags) ? m.tags.filter((t: string) => !t.startsWith('ui/')) : [];
|
|
14
19
|
return (
|
|
15
|
-
<Link href={keyToHref(path)} className="gtl-row">
|
|
20
|
+
<Link href={keyToHref(path)} className="gtl-row" onClick={followLinkOnClick(follow, { key: path, href: keyToHref(path), from })}>
|
|
16
21
|
<div className="gtl-date">{m.date}</div>
|
|
17
22
|
<div className="gtl-node" />
|
|
18
23
|
<div className="gtl-card">
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// R3-871 — WikiLink resolves against the entry it renders inside, not the URL.
|
|
3
|
+
// The URL routes to content/home.mdx while an EntryContext provider says this
|
|
4
|
+
// subtree renders content/teams/engineering.mdx: a relative link must resolve
|
|
5
|
+
// from engineering's directory, and "self" must be judged against engineering.
|
|
6
|
+
// Keys come from the real key grammar (sandboxPathToKey/hrefKeyCandidates), the
|
|
7
|
+
// same fixtures shape the other component tests use.
|
|
8
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
9
|
+
import { act } from 'react';
|
|
10
|
+
import { createRoot } from 'react-dom/client';
|
|
11
|
+
import type { ReactNode } from 'react';
|
|
12
|
+
import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
|
|
13
|
+
import { resetContentRoot, setContentRoot } from '../lib/contentRoot';
|
|
14
|
+
|
|
15
|
+
const { default: WikiLink } = await import('./WikiLink');
|
|
16
|
+
const { EntryContext } = await import('../hooks/useEntryKey');
|
|
17
|
+
|
|
18
|
+
const METADATA = {
|
|
19
|
+
'/app/content/home.mdx': { title: 'Home' },
|
|
20
|
+
'/app/content/teams/engineering.mdx': { title: 'Engineering' },
|
|
21
|
+
'/app/content/people/ada-lovelace.mdx': { title: 'Ada Lovelace' },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** The URL routes HOME; the provider (when given) says which entry we render. */
|
|
25
|
+
function host(children: ReactNode, entryKey?: string, sandboxPath = '/files/content/home.mdx') {
|
|
26
|
+
const value = {
|
|
27
|
+
outerHref: 'https://immediately.run/present/github/o/r/main/files/content/home.mdx',
|
|
28
|
+
navigationState: { mode: 'present', namespace: 'github', provider: 'github', repository: 'o/r', ref: 'main', sandboxPath, hash: '', search: '' },
|
|
29
|
+
routingSpec: { routes: [] },
|
|
30
|
+
filesMetadata: METADATA,
|
|
31
|
+
};
|
|
32
|
+
const inner = <TinkerableContext.Provider value={value as never}>{children}</TinkerableContext.Provider>;
|
|
33
|
+
return entryKey === undefined ? inner : <EntryContext.Provider value={{ entryKey }}>{inner}</EntryContext.Provider>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function render(node: ReactNode): Promise<HTMLElement> {
|
|
37
|
+
const container = document.createElement('div');
|
|
38
|
+
document.body.appendChild(container);
|
|
39
|
+
await act(async () => {
|
|
40
|
+
createRoot(container).render(node);
|
|
41
|
+
});
|
|
42
|
+
return container;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('WikiLink — the entry context decides the base (R3-871)', () => {
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
setContentRoot('/app/content/');
|
|
48
|
+
return () => resetContentRoot();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('a relative link inside a non-routed entry resolves against that entry', async () => {
|
|
52
|
+
// the URL routes home; the subtree renders engineering (teams/engineering.mdx).
|
|
53
|
+
const el = await render(
|
|
54
|
+
host(
|
|
55
|
+
<WikiLink href="../people/ada-lovelace.mdx">Ada</WikiLink>,
|
|
56
|
+
'/app/content/teams/engineering.mdx',
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
const link = el.querySelector('.grove-wikilink') as HTMLElement;
|
|
60
|
+
expect(link).not.toBeNull();
|
|
61
|
+
expect(link.getAttribute('data-state')).toBe('ok');
|
|
62
|
+
expect(link.getAttribute('href')).toContain('people/ada-lovelace.mdx');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('the same relative link with no provider resolves against the routed entry (home)', async () => {
|
|
66
|
+
// From home/, ../people/ada-lovelace.mdx lands outside the corpus → broken.
|
|
67
|
+
// This is the default-preserving seam: same input, different base, by design.
|
|
68
|
+
const el = await render(host(<WikiLink href="../people/ada-lovelace.mdx">Ada</WikiLink>));
|
|
69
|
+
const link = el.querySelector('.grove-wikilink') as HTMLElement;
|
|
70
|
+
expect(link.getAttribute('data-state')).toBe('broken');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('a link to the context entry\'s own entry is self — judged against the entry, not the URL', async () => {
|
|
74
|
+
const el = await render(
|
|
75
|
+
host(
|
|
76
|
+
<WikiLink href="engineering.mdx">this very page</WikiLink>,
|
|
77
|
+
'/app/content/teams/engineering.mdx',
|
|
78
|
+
),
|
|
79
|
+
);
|
|
80
|
+
const link = el.querySelector('.grove-wikilink') as HTMLElement;
|
|
81
|
+
expect(link.getAttribute('data-state')).toBe('self');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
-
import { useCallback
|
|
2
|
+
import { useCallback } from 'react';
|
|
3
3
|
import { Link, useMetadataQuery } from '@immediately-run/sdk';
|
|
4
|
-
import {
|
|
5
|
-
import { hrefKeyCandidates, hrefTargetKey, isContentEntry, keyToHref, linkKind, sandboxPathToKey, splitFragment } from '../lib/content';
|
|
4
|
+
import { hrefKeyCandidates, hrefTargetKey, isContentEntry, keyToHref, linkKind, splitFragment } from '../lib/content';
|
|
6
5
|
import { isFolderKey } from '../lib/directory';
|
|
7
6
|
import { queryPaths } from '../lib/wiki';
|
|
7
|
+
import { useEntryKey } from '../hooks/useEntryKey';
|
|
8
|
+
import { useFollowLink } from '../hooks/useFollowLink';
|
|
9
|
+
import { followLinkOnClick } from '../lib/navigationPolicy';
|
|
8
10
|
import Icon from './Icon';
|
|
9
11
|
|
|
10
12
|
interface Props {
|
|
@@ -19,8 +21,10 @@ interface Props {
|
|
|
19
21
|
// glance. Those are the ONLY two shapes: an in-app href that resolves to nothing renders
|
|
20
22
|
// broken, never as a bare `<a>` (see `linkKind` — R3-252).
|
|
21
23
|
export default function WikiLink({ href = '', children, ...rest }: Props) {
|
|
22
|
-
|
|
23
|
-
const currentKey =
|
|
24
|
+
// R3-871: the entry this link renders inside, not whatever the URL says.
|
|
25
|
+
const currentKey = useEntryKey();
|
|
26
|
+
// R3-872: plain clicks ride the navigation policy; the href stays real.
|
|
27
|
+
const follow = useFollowLink();
|
|
24
28
|
|
|
25
29
|
// Resolve existence against the whole in-memory index (so a missing target is
|
|
26
30
|
// *definitively* broken, not a load-time flash).
|
|
@@ -45,7 +49,12 @@ export default function WikiLink({ href = '', children, ...rest }: Props) {
|
|
|
45
49
|
const targetKey = candidates.find((k) => keys.includes(k)) ?? candidates[0] ?? null;
|
|
46
50
|
// Route on the RESOLVED key, not the author's text: handing `<Link>` a relative href
|
|
47
51
|
// would make it resolve against the outer page URL rather than the content tree.
|
|
48
|
-
const [,
|
|
52
|
+
const [, fragmentRaw] = splitFragment(href);
|
|
53
|
+
// `fragmentRaw` keeps the '#' for the hrefs (they concatenate it); the policy
|
|
54
|
+
// target carries the bare id (the FollowLinkTarget shape and
|
|
55
|
+
// resolveFragmentTarget's convention agree — never '#' + id).
|
|
56
|
+
const fragment = fragmentRaw || '';
|
|
57
|
+
const policyFragment = fragmentRaw ? fragmentRaw.replace(/^#/, '') : undefined;
|
|
49
58
|
const resolvedHref = targetKey ? keyToHref(targetKey) + fragment : href;
|
|
50
59
|
|
|
51
60
|
// Only an href that MEANS to leave the document becomes a real `<a>` — see `linkKind`.
|
|
@@ -60,7 +69,13 @@ export default function WikiLink({ href = '', children, ...rest }: Props) {
|
|
|
60
69
|
const dirKey = kind === 'content' ? hrefTargetKey(href, currentKey) : null;
|
|
61
70
|
if (dirKey && loaded && isFolderKey(dirKey, keys)) {
|
|
62
71
|
return (
|
|
63
|
-
<Link
|
|
72
|
+
<Link
|
|
73
|
+
href={keyToHref(dirKey) + fragment}
|
|
74
|
+
className="grove-wikilink"
|
|
75
|
+
data-state="ok"
|
|
76
|
+
onClick={followLinkOnClick(follow, { key: dirKey, fragment: policyFragment, href: keyToHref(dirKey) + fragment, from: currentKey })}
|
|
77
|
+
{...rest}
|
|
78
|
+
>
|
|
64
79
|
{children}
|
|
65
80
|
</Link>
|
|
66
81
|
);
|
|
@@ -105,7 +120,13 @@ export default function WikiLink({ href = '', children, ...rest }: Props) {
|
|
|
105
120
|
}
|
|
106
121
|
|
|
107
122
|
return (
|
|
108
|
-
<Link
|
|
123
|
+
<Link
|
|
124
|
+
href={resolvedHref}
|
|
125
|
+
className="grove-wikilink"
|
|
126
|
+
data-state="ok"
|
|
127
|
+
onClick={followLinkOnClick(follow, { key: targetKey, fragment: policyFragment, href: resolvedHref, from: currentKey })}
|
|
128
|
+
{...rest}
|
|
129
|
+
>
|
|
109
130
|
{children}
|
|
110
131
|
</Link>
|
|
111
132
|
);
|