@immediately-run/grove 0.2.0 → 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 +1 -1
- package/package.json +3 -3
- package/src/GroveApp.css +11 -0
- package/src/GroveWiki.tsx +6 -8
- package/src/components/TagCloud.test.tsx +103 -0
- package/src/components/TagCloud.tsx +47 -29
- package/src/hooks/useEditAffordance.test.tsx +110 -0
- package/src/hooks/useEditAffordance.ts +51 -14
- package/src/lib/editTarget.test.ts +78 -18
- package/src/lib/editTarget.ts +39 -10
- package/src/lib/tagCloud.test.ts +115 -0
- package/src/lib/tagCloud.ts +58 -0
package/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @immediately-run/grove — the viewer kit for directory-as-content wikis
|
|
2
2
|
|
|
3
|
-
> The Grove viewer: a kit of React components, layouts and themes for building immediately.run wikis. Composable as a fork, a dispatch target, or a pinned library. (v0.2.
|
|
3
|
+
> The Grove viewer: a kit of React components, layouts and themes for building immediately.run wikis. Composable as a fork, a dispatch target, or a pinned library. (v0.2.2)
|
|
4
4
|
|
|
5
5
|
Grove is NOT a wiki engine: routing, MDX compilation, the frontmatter index, link
|
|
6
6
|
spaces and heading anchors live in the sandbox + `@immediately-run/sdk`. What this
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@immediately-run/grove",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/main.tsx",
|
|
6
6
|
"immediately.run": {
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@immediately-run/mdx-plugins": "0.7.1",
|
|
45
|
-
"@immediately-run/sdk": "^0.
|
|
45
|
+
"@immediately-run/sdk": "^0.80.0",
|
|
46
46
|
"react": "^19.2.5",
|
|
47
47
|
"react-dom": "^19.2.5"
|
|
48
48
|
},
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
"llms.txt"
|
|
89
89
|
],
|
|
90
90
|
"peerDependencies": {
|
|
91
|
-
"@immediately-run/sdk": "^0.
|
|
91
|
+
"@immediately-run/sdk": "^0.80.0",
|
|
92
92
|
"react": "^19.2.5",
|
|
93
93
|
"react-dom": "^19.2.5"
|
|
94
94
|
}
|
package/src/GroveApp.css
CHANGED
|
@@ -1677,6 +1677,17 @@ a.gdl-row {
|
|
|
1677
1677
|
gap: 8px;
|
|
1678
1678
|
align-items: center;
|
|
1679
1679
|
}
|
|
1680
|
+
/* The weighted cloud's pixel range lives HERE and nowhere else (the component
|
|
1681
|
+
only sets --tag-weight): 11.5px (the plain .grove-tag size) to 22px. The rule
|
|
1682
|
+
hangs off the --weighted modifier because TagList shares the .grove-tagcloud
|
|
1683
|
+
wrapper and must stay unweighted. */
|
|
1684
|
+
.grove-tagcloud--weighted .grove-tag {
|
|
1685
|
+
font-size: calc(11.5px + var(--tag-weight, 0) * 10.5px);
|
|
1686
|
+
}
|
|
1687
|
+
.grove-tag__count {
|
|
1688
|
+
opacity: 0.5;
|
|
1689
|
+
margin-left: 4px;
|
|
1690
|
+
}
|
|
1680
1691
|
|
|
1681
1692
|
/* ============ skeleton ============ */
|
|
1682
1693
|
@keyframes gshimmer {
|
package/src/GroveWiki.tsx
CHANGED
|
@@ -373,8 +373,8 @@ export default function GroveWiki({
|
|
|
373
373
|
return (
|
|
374
374
|
// R3-277b: declare the enclosing bundle for the platform's link-space consumers
|
|
375
375
|
// (the shared resolver's bundle-anchored absolute + `$fs:` handling read this).
|
|
376
|
-
// R3-482: the SDK pin (^0.72.0, #75) reads `bundleRoot` new-then-old, so the
|
|
377
|
-
// spelling is stated alone — the deprecated `corpusRoot` stays in the type for
|
|
376
|
+
// R3-482: the SDK pin (since ^0.72.0, #75) reads `bundleRoot` new-then-old, so the
|
|
377
|
+
// new spelling is stated alone — the deprecated `corpusRoot` stays in the type for
|
|
378
378
|
// older consumers (mdx-plugins' forever-compat) but no longer here.
|
|
379
379
|
<LinkSpaceContext.Provider
|
|
380
380
|
value={{
|
|
@@ -387,12 +387,10 @@ export default function GroveWiki({
|
|
|
387
387
|
// chroot), so a dispatched corpus document's `$fs:/mnt/{hash}/…` cannot
|
|
388
388
|
// name a federated mount materialised beside it — the app-level mount
|
|
389
389
|
// point is not a corpus path — and the fork's `$fs:` stays
|
|
390
|
-
// mount-absolute.
|
|
391
|
-
// component consumers (WikiLink/MDXComponents)
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
// stated here rather than blurred (§8.3's dated note:
|
|
395
|
-
// built-but-not-in-force is a materially different state from done).
|
|
390
|
+
// mount-absolute. This field carries the same flag for the SDK's generic
|
|
391
|
+
// component consumers (WikiLink/MDXComponents) — consumed since the
|
|
392
|
+
// 0.79.0 pin (R3-783): those components forward it to the shared
|
|
393
|
+
// resolver, so the clamp is load-bearing on the generic path too.
|
|
396
394
|
bundleChrooted: isDispatched(),
|
|
397
395
|
}}
|
|
398
396
|
>
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// R3-877 — the workbench delivery of the edit affordance for a read-only opener
|
|
3
|
+
// delegation: `requestEdit({ bundleFile })`, with the refusal contract pinned:
|
|
4
|
+
// `read-only` hides the affordance until the next mount announcement, `cancelled`
|
|
5
|
+
// stays silent, `forbidden` (and anything else) sets `refused`.
|
|
6
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
7
|
+
import { act, useEffect } from 'react';
|
|
8
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
9
|
+
|
|
10
|
+
import type { EditAffordance } from './useEditAffordance';
|
|
11
|
+
|
|
12
|
+
const requestEditMock = vi.fn<(t?: unknown) => Promise<void>>();
|
|
13
|
+
const invokeTaskMock = vi.fn<(a: unknown) => Promise<void>>();
|
|
14
|
+
const useMountsMock = vi.fn<() => unknown[]>();
|
|
15
|
+
|
|
16
|
+
vi.mock('@immediately-run/sdk', () => ({
|
|
17
|
+
requestEdit: (a?: unknown) => requestEditMock(a),
|
|
18
|
+
invokeTask: (a: unknown) => invokeTaskMock(a),
|
|
19
|
+
capFile: (ref: unknown, opts: unknown) => ({ ...(ref as object), ...(opts as object) }),
|
|
20
|
+
useMounts: () => useMountsMock(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
// The corpus identity comes from the real contentRoot module, driven through its
|
|
24
|
+
// own producer (R3-877 round 1, R2) — never a hand-typed shape.
|
|
25
|
+
import { getContentRoot, resetContentRoot, setContentRoot } from '../lib/contentRoot';
|
|
26
|
+
|
|
27
|
+
import { useEditAffordance } from './useEditAffordance';
|
|
28
|
+
|
|
29
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
30
|
+
|
|
31
|
+
const RO_MOUNT = { type: 'task-delegation', path: '/task/t1/dir', id: '/task/t1/dir', mode: 'ro', readerCanEdit: true };
|
|
32
|
+
|
|
33
|
+
let latest: EditAffordance | null = null;
|
|
34
|
+
function Probe() {
|
|
35
|
+
const affordance = useEditAffordance(false);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
latest = affordance;
|
|
38
|
+
});
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let container: HTMLDivElement;
|
|
43
|
+
let root: Root;
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
setContentRoot('/task/t1/dir', { mountId: '/task/t1/dir' });
|
|
47
|
+
requestEditMock.mockReset().mockResolvedValue(undefined);
|
|
48
|
+
invokeTaskMock.mockReset().mockResolvedValue(undefined);
|
|
49
|
+
useMountsMock.mockReset().mockReturnValue([RO_MOUNT]);
|
|
50
|
+
latest = null;
|
|
51
|
+
container = document.createElement('div');
|
|
52
|
+
document.body.appendChild(container);
|
|
53
|
+
root = createRoot(container);
|
|
54
|
+
act(() => root.render(<Probe />));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const rerender = () => act(() => root.render(<Probe />));
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
resetContentRoot();
|
|
61
|
+
act(() => root.unmount());
|
|
62
|
+
container.remove();
|
|
63
|
+
});
|
|
64
|
+
const open = async (key = `${getContentRoot()}plot/the-rail.mdx`) => {
|
|
65
|
+
await act(async () => {
|
|
66
|
+
latest!.openEditor(key);
|
|
67
|
+
await Promise.resolve();
|
|
68
|
+
});
|
|
69
|
+
rerender();
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
describe('useEditAffordance — the workbench delivery (R3-877)', () => {
|
|
73
|
+
it('an ro delegation offers Edit and delivers it as requestEdit({ bundleFile })', async () => {
|
|
74
|
+
expect(latest!.writable).toBe(true);
|
|
75
|
+
await open();
|
|
76
|
+
expect(requestEditMock).toHaveBeenCalledWith({ bundleFile: '/plot/the-rail.mdx' });
|
|
77
|
+
expect(invokeTaskMock).not.toHaveBeenCalled();
|
|
78
|
+
expect(latest!.refused).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('an ro delegation with `readerCanEdit: false` does not offer the control', () => {
|
|
82
|
+
useMountsMock.mockReturnValue([{ ...RO_MOUNT, readerCanEdit: false }]);
|
|
83
|
+
rerender();
|
|
84
|
+
expect(latest!.writable).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('a `read-only` refusal hides the affordance until the next mount announcement', async () => {
|
|
88
|
+
requestEditMock.mockRejectedValue(Object.assign(new Error('no'), { code: 'read-only' }));
|
|
89
|
+
await open();
|
|
90
|
+
expect(latest!.refused).toBe(false); // not a render-in-place refusal
|
|
91
|
+
expect(latest!.writable).toBe(false); // hidden…
|
|
92
|
+
useMountsMock.mockReturnValue([{ ...RO_MOUNT }]); // …until the next announcement…
|
|
93
|
+
rerender();
|
|
94
|
+
expect(latest!.writable).toBe(true); // …unlatches it.
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('a `cancelled` rejection stays silent (the reader closed the editor)', async () => {
|
|
98
|
+
requestEditMock.mockRejectedValue(Object.assign(new Error('closed'), { code: 'cancelled' }));
|
|
99
|
+
await open();
|
|
100
|
+
expect(latest!.refused).toBe(false);
|
|
101
|
+
expect(latest!.writable).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('a `forbidden` refusal sets `refused` (rendered as text in place) and never retries via edit-file', async () => {
|
|
105
|
+
requestEditMock.mockRejectedValue(Object.assign(new Error('no'), { code: 'forbidden' }));
|
|
106
|
+
await open();
|
|
107
|
+
expect(latest!.refused).toBe(true);
|
|
108
|
+
expect(invokeTaskMock).not.toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
// was withheld entirely and the wiki became read-only for the one packaging where the
|
|
8
8
|
// content is most obviously somebody's to edit.
|
|
9
9
|
//
|
|
10
|
-
// The decision is pure (`lib/editTarget`); this hook is the wiring: it reads the
|
|
11
|
-
// mount list so a role downgrade
|
|
12
|
-
//
|
|
10
|
+
// The decision is pure (`lib/editTarget`); this hook is the wiring: it reads the live
|
|
11
|
+
// mount list so a role downgrade reroutes the delivery on the next render (rw → the
|
|
12
|
+
// `edit-file` overlay; ro → the workbench under the reader's authority, R3-877) rather
|
|
13
|
+
// than producing `EROFS` on click — and a `readerCanEdit: false` hint or a `read-only`
|
|
14
|
+
// refusal hides the offer — and it hands back one `openEditor(entryKey)` the chrome
|
|
13
15
|
// calls without knowing which packaging it is in.
|
|
14
16
|
import { useCallback, useMemo, useState } from 'react';
|
|
15
17
|
import { capFile, invokeTask, requestEdit, useMounts } from '@immediately-run/sdk';
|
|
@@ -46,14 +48,28 @@ export function useEditAffordance(readOnly: boolean): EditAffordance {
|
|
|
46
48
|
|
|
47
49
|
// Read the corpus identity through the mount list's identity, so the memo re-runs when
|
|
48
50
|
// the host re-announces a mount. The root itself is latched at boot (see `contentRoot`);
|
|
49
|
-
// the MODE is not, and that is the half this hook exists to keep current.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
// the MODE is not, and that is the half this hook exists to keep current. R3-877: the
|
|
52
|
+
// delegation's live MODE now also routes the delivery (rw → the edit-file overlay,
|
|
53
|
+
// ro → the workbench under the reader's authority).
|
|
54
|
+
const corpus = useMemo(() => {
|
|
55
|
+
const mountId = getCorpusMountId();
|
|
56
|
+
const mount = mounts?.find((m) => (m.id ?? m.path) === mountId);
|
|
57
|
+
return { dispatched: isDispatched(), contentRoot: getContentRoot(), mountId, mountMode: mount?.mode ?? null };
|
|
58
|
+
}, [mounts]);
|
|
59
|
+
|
|
60
|
+
// R3-877: a `read-only` refusal (the reader cannot edit the source) hides the
|
|
61
|
+
// affordance until the next mount announcement — the hint's re-announcement is
|
|
62
|
+
// exactly what unlatches it. Reset on every mount-list change — the
|
|
63
|
+
// render-adjusted-state pattern (not an effect, which would paint one frame
|
|
64
|
+
// stale), the React-sanctioned form.
|
|
65
|
+
const [readerReadOnly, setReaderReadOnly] = useState(false);
|
|
66
|
+
const [resetFor, setResetFor] = useState(mounts);
|
|
67
|
+
if (resetFor !== mounts) {
|
|
68
|
+
setResetFor(mounts);
|
|
69
|
+
setReaderReadOnly(false);
|
|
70
|
+
}
|
|
55
71
|
|
|
56
|
-
const writable = !readOnly && corpusWritable(mounts, corpus);
|
|
72
|
+
const writable = !readOnly && !readerReadOnly && corpusWritable(mounts, corpus);
|
|
57
73
|
|
|
58
74
|
// A refusal surfaces where the affordance was offered (3.3.1, R3-608);
|
|
59
75
|
// `cancelled` — the reader closing the editor — stays silent by contract.
|
|
@@ -75,10 +91,31 @@ export function useEditAffordance(readOnly: boolean): EditAffordance {
|
|
|
75
91
|
requestEdit({ path: target.path }).catch(refusedUnlessCancelled).finally(done);
|
|
76
92
|
return;
|
|
77
93
|
}
|
|
78
|
-
// Dispatch:
|
|
79
|
-
// the
|
|
80
|
-
//
|
|
81
|
-
//
|
|
94
|
+
// Dispatch, read-only delegation: ask the workbench to open the entry's source
|
|
95
|
+
// under the reader's authority (R3-876 / APP_CUSTOMIZATION §5a). Our chroot is
|
|
96
|
+
// never upgraded and nothing is minted for us; the host needs a real gesture,
|
|
97
|
+
// which this click is. `cancelled` stays silent; `read-only` hides the
|
|
98
|
+
// affordance until the next mount announcement; anything else renders in place
|
|
99
|
+
// as text (the `refused` flag) — and is never retried through `edit-file`.
|
|
100
|
+
if (target.via === 'workbench') {
|
|
101
|
+
requestEdit({ bundleFile: target.relPath })
|
|
102
|
+
.catch((e: unknown) => {
|
|
103
|
+
const code = (e as { code?: string } | null)?.code;
|
|
104
|
+
if (code === 'cancelled') return;
|
|
105
|
+
if (code === 'read-only') {
|
|
106
|
+
setReaderReadOnly(true);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
setRefused(true);
|
|
110
|
+
})
|
|
111
|
+
.finally(done);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// Dispatch, writable delegation: attenuate the corpus delegation down to this
|
|
115
|
+
// one file and hand it to the platform editor. Nothing new is minted — we
|
|
116
|
+
// already hold the directory, and `edit-file` is one hop further along a chain
|
|
117
|
+
// §5.7.1 bounds at depth 4. The host resolves the cap against our grants, so
|
|
118
|
+
// this can only ever narrow.
|
|
82
119
|
invokeTask('edit-file', {
|
|
83
120
|
file: capFile({ mountId: target.mountId, relPath: target.relPath }, { mode: 'rw' }),
|
|
84
121
|
})
|
|
@@ -4,21 +4,29 @@
|
|
|
4
4
|
// viewer must send its edit to the CORPUS (never to Grove's own repo), and whether it may
|
|
5
5
|
// offer one at all must be the corpus mount's CURRENT mode rather than a property of the
|
|
6
6
|
// packaging or a flag latched at boot.
|
|
7
|
-
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import { describe, expect, it, afterEach } from 'vitest';
|
|
8
8
|
import { corpusWritable, editTarget, keyToSelfPath } from './editTarget';
|
|
9
9
|
import type { CorpusIdentity } from './editTarget';
|
|
10
10
|
import type { SandboxMount } from '@immediately-run/sdk/mounts';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
import { getContentRoot, getCorpusMountId, isDispatched, resetContentRoot, setContentRoot } from './contentRoot';
|
|
12
|
+
|
|
13
|
+
// The corpus identities come from the real producer (R3-877 round 1, R2): the
|
|
14
|
+
// trailing-slash normalization every `slice` in editTarget depends on lives in
|
|
15
|
+
// `setContentRoot` — a hand-typed literal would keep passing while it broke.
|
|
16
|
+
const forkFor = (): CorpusIdentity => {
|
|
17
|
+
resetContentRoot();
|
|
18
|
+
return { dispatched: isDispatched(), contentRoot: getContentRoot(), mountId: getCorpusMountId() };
|
|
16
19
|
};
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
contentRoot:
|
|
20
|
-
mountId: '/task/t1/dir',
|
|
20
|
+
const dispatchedFor = (): CorpusIdentity => {
|
|
21
|
+
setContentRoot('/task/t1/dir', { mountId: '/task/t1/dir' });
|
|
22
|
+
return { dispatched: isDispatched(), contentRoot: getContentRoot(), mountId: getCorpusMountId() };
|
|
21
23
|
};
|
|
24
|
+
afterEach(() => resetContentRoot());
|
|
25
|
+
|
|
26
|
+
const fork: CorpusIdentity = forkFor();
|
|
27
|
+
const dispatched: CorpusIdentity = dispatchedFor();
|
|
28
|
+
// The dispatched entry keys, built off the producer's root — never a hand-typed prefix.
|
|
29
|
+
const IN = (rel: string) => `${dispatched.contentRoot}${rel}`;
|
|
22
30
|
|
|
23
31
|
const mount = (over: Partial<SandboxMount> = {}): SandboxMount =>
|
|
24
32
|
({ type: 'firestore', path: '/task/t1/dir', id: '/task/t1/dir', mode: 'rw', ...over }) as SandboxMount;
|
|
@@ -32,7 +40,7 @@ describe('editTarget — the verb follows the authority, not the packaging', ()
|
|
|
32
40
|
});
|
|
33
41
|
|
|
34
42
|
it('a DISPATCHED viewer delegates the CORPUS file, never a path in its own repo', () => {
|
|
35
|
-
expect(editTarget('
|
|
43
|
+
expect(editTarget(IN('plot/the-rail.mdx'), dispatched)).toEqual({
|
|
36
44
|
via: 'delegate',
|
|
37
45
|
mountId: '/task/t1/dir',
|
|
38
46
|
relPath: 'plot/the-rail.mdx',
|
|
@@ -40,7 +48,7 @@ describe('editTarget — the verb follows the authority, not the packaging', ()
|
|
|
40
48
|
});
|
|
41
49
|
|
|
42
50
|
it('is corpus-relative under dispatch — the mount root IS the corpus root', () => {
|
|
43
|
-
const t = editTarget('
|
|
51
|
+
const t = editTarget(IN('home.mdx'), dispatched);
|
|
44
52
|
expect(t).toMatchObject({ relPath: 'home.mdx' });
|
|
45
53
|
// The fork's `content/` segment must NOT leak into a corpus-relative path: the
|
|
46
54
|
// delegated chroot is minted AT the content directory.
|
|
@@ -52,11 +60,11 @@ describe('editTarget — the verb follows the authority, not the packaging', ()
|
|
|
52
60
|
});
|
|
53
61
|
|
|
54
62
|
it('offers nothing when a dispatched corpus has no mount id to delegate from', () => {
|
|
55
|
-
expect(editTarget('
|
|
63
|
+
expect(editTarget(IN('home.mdx'), { ...dispatched, mountId: null })).toBeNull();
|
|
56
64
|
});
|
|
57
65
|
|
|
58
66
|
it('offers nothing for the corpus root itself (a directory is not an entry)', () => {
|
|
59
|
-
expect(editTarget('
|
|
67
|
+
expect(editTarget(IN(''), dispatched)).toBeNull();
|
|
60
68
|
});
|
|
61
69
|
|
|
62
70
|
it('never throws on a junk key', () => {
|
|
@@ -81,13 +89,19 @@ describe('corpusWritable — the mount decides, live', () => {
|
|
|
81
89
|
expect(corpusWritable([mount()], dispatched)).toBe(true);
|
|
82
90
|
});
|
|
83
91
|
|
|
84
|
-
it('a ro corpus is
|
|
85
|
-
|
|
92
|
+
it('a ro corpus with no hint is still offerable (R3-877: the workbench class) — never EROFS, a refusal tells', () => {
|
|
93
|
+
// Pre-R3-877 this was `false` — an ro mount hid the affordance outright. The
|
|
94
|
+
// workbench class edits under the reader's authority, so our ro mount is the
|
|
95
|
+
// normal case, not a refusal. An explicit `false` hint still hides it (below).
|
|
96
|
+
expect(corpusWritable([mount({ mode: 'ro' })], dispatched)).toBe(true);
|
|
86
97
|
});
|
|
87
98
|
|
|
88
|
-
it('follows a
|
|
99
|
+
it('follows a live downgrade: re-announced ro flips the delivery, and a false hint flips the offer', () => {
|
|
89
100
|
expect(corpusWritable([mount({ mode: 'rw' })], dispatched)).toBe(true);
|
|
90
|
-
|
|
101
|
+
// ro with no hint: still offerable, now via the workbench (reader's authority).
|
|
102
|
+
expect(corpusWritable([mount({ mode: 'ro' })], dispatched)).toBe(true);
|
|
103
|
+
// ro with the host saying the reader cannot edit: hidden.
|
|
104
|
+
expect(corpusWritable([mount({ mode: 'ro', readerCanEdit: false })], dispatched)).toBe(false);
|
|
91
105
|
});
|
|
92
106
|
|
|
93
107
|
it('a corpus mount that has vanished is not writable', () => {
|
|
@@ -106,3 +120,49 @@ describe('corpusWritable — the mount decides, live', () => {
|
|
|
106
120
|
expect(corpusWritable([mount()], { ...dispatched, mountId: null })).toBe(false);
|
|
107
121
|
});
|
|
108
122
|
});
|
|
123
|
+
|
|
124
|
+
// R3-877 — the third outcome: an ro delegation edits via the workbench, under the
|
|
125
|
+
// reader's authority (`requestEdit({ bundleFile })`, R3-876). Order of preference:
|
|
126
|
+
// self → delegate (rw) → workbench (ro).
|
|
127
|
+
describe('editTarget — the workbench class for a read-only delegation (R3-877)', () => {
|
|
128
|
+
// The corpus identity's contentRoot mirrors the real getContentRoot() shape: the
|
|
129
|
+
// delegated chroot root, trailing slash.
|
|
130
|
+
const roCorpus: CorpusIdentity = { ...dispatched, mountMode: 'ro' };
|
|
131
|
+
|
|
132
|
+
it('an `ro` dispatched corpus yields workbench with the leading-slash bundle-relative path', () => {
|
|
133
|
+
expect(editTarget(IN('plot/the-rail.mdx'), roCorpus)).toEqual({
|
|
134
|
+
via: 'workbench',
|
|
135
|
+
relPath: '/plot/the-rail.mdx',
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('an `rw` delegation still yields delegate (the edit-file overlay is unchanged)', () => {
|
|
140
|
+
expect(editTarget(IN('plot/the-rail.mdx'), { ...dispatched, mountMode: 'rw' })).toEqual({
|
|
141
|
+
via: 'delegate',
|
|
142
|
+
mountId: '/task/t1/dir',
|
|
143
|
+
relPath: 'plot/the-rail.mdx',
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('an UNKNOWN mode (an older host announces none) keeps the pre-R3-877 delegate behavior', () => {
|
|
148
|
+
expect(editTarget(IN('home.mdx'), dispatched)).toEqual({
|
|
149
|
+
via: 'delegate',
|
|
150
|
+
mountId: '/task/t1/dir',
|
|
151
|
+
relPath: 'home.mdx',
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('the corpus root itself is still nothing to edit, workbench included', () => {
|
|
156
|
+
expect(editTarget(IN(''), roCorpus)).toBeNull();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('corpusWritable — the ro delegation is offerable on the hint (R3-877)', () => {
|
|
161
|
+
it('ro + readerCanEdit true → offered', () => {
|
|
162
|
+
expect(corpusWritable([mount({ mode: 'ro', readerCanEdit: true })], dispatched)).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('ro + readerCanEdit false → not offered (never show a control that refuses)', () => {
|
|
166
|
+
expect(corpusWritable([mount({ mode: 'ro', readerCanEdit: false })], dispatched)).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
});
|
package/src/lib/editTarget.ts
CHANGED
|
@@ -20,8 +20,11 @@
|
|
|
20
20
|
//
|
|
21
21
|
// **The mount decides.** Writability is a property of the delegation's current mode, not
|
|
22
22
|
// of how the app was loaded. That is why `corpusWritable` takes the live mount list rather
|
|
23
|
-
// than the boot-time flag
|
|
24
|
-
//
|
|
23
|
+
// than the boot-time flag. Since R3-877 a role downgrade no longer hides the affordance —
|
|
24
|
+
// it reroutes the delivery: `rw` hands the file to the `edit-file` overlay, `ro` asks the
|
|
25
|
+
// workbench under the reader's authority (`requestEdit({ bundleFile })`). The offer hides
|
|
26
|
+
// only when the host's `readerCanEdit` hint says the reader cannot edit, or a `read-only`
|
|
27
|
+
// refusal proved it.
|
|
25
28
|
//
|
|
26
29
|
// Pure — no SDK, no React — so all of the above is testable without a host.
|
|
27
30
|
|
|
@@ -31,8 +34,14 @@ import type { SandboxMount } from '@immediately-run/sdk/mounts';
|
|
|
31
34
|
export type EditTarget =
|
|
32
35
|
/** The fork: our own repo, via the self-scoped present→edit transition. */
|
|
33
36
|
| { via: 'self'; path: string }
|
|
34
|
-
/** Dispatch: one file of the
|
|
35
|
-
|
|
37
|
+
/** Dispatch, writable delegation: one file of the corpus, handed to the platform
|
|
38
|
+
* editor as a narrowed `edit-file` delegation (the overlay). */
|
|
39
|
+
| { via: 'delegate'; mountId: string; relPath: string }
|
|
40
|
+
/** Dispatch, read-only delegation (an app-declared opener's chroot): ask the
|
|
41
|
+
* workbench to open the entry's source in the main-pane editor under the
|
|
42
|
+
* reader's authority — `requestEdit({ bundleFile })` (R3-876 / APP_CUSTOMIZATION
|
|
43
|
+
* §5a). The path is bundle-relative with a leading slash. */
|
|
44
|
+
| { via: 'workbench'; relPath: string };
|
|
36
45
|
|
|
37
46
|
export interface CorpusIdentity {
|
|
38
47
|
/** Whether the corpus is a mount rather than this app's own repo. */
|
|
@@ -41,6 +50,11 @@ export interface CorpusIdentity {
|
|
|
41
50
|
contentRoot: string;
|
|
42
51
|
/** The corpus mount id, when dispatched (`getCorpusMountId()`). */
|
|
43
52
|
mountId: string | null;
|
|
53
|
+
/** The corpus delegation's current mode, read off the live mount list (R3-877):
|
|
54
|
+
* `ro` routes the edit to the workbench under the reader's authority; `rw` keeps
|
|
55
|
+
* the `edit-file` overlay. Absent/unknown keeps the pre-R3-877 behavior
|
|
56
|
+
* (`delegate` — an `rw`-assuming host that announces no mode). */
|
|
57
|
+
mountMode?: 'ro' | 'rw' | null;
|
|
44
58
|
}
|
|
45
59
|
|
|
46
60
|
/** `/app/content/x.mdx` → `content/x.mdx` — the fork's repo-relative path. */
|
|
@@ -62,17 +76,25 @@ export function editTarget(entryKey: string, corpus: CorpusIdentity): EditTarget
|
|
|
62
76
|
if (!corpus.mountId) return null;
|
|
63
77
|
if (!entryKey.startsWith(corpus.contentRoot)) return null;
|
|
64
78
|
const relPath = entryKey.slice(corpus.contentRoot.length);
|
|
65
|
-
|
|
79
|
+
if (!relPath) return null;
|
|
80
|
+
// An `ro` delegation (the opener's chroot — the only mode an app-declared opener
|
|
81
|
+
// ever holds) goes to the workbench: the reader's authority, not ours — the mount
|
|
82
|
+
// is never upgraded and nothing is minted for us. Leading-slash, the host's
|
|
83
|
+
// `bundleFile` grammar.
|
|
84
|
+
if (corpus.mountMode === 'ro') return { via: 'workbench', relPath: `/${relPath}` };
|
|
85
|
+
return { via: 'delegate', mountId: corpus.mountId, relPath };
|
|
66
86
|
}
|
|
67
87
|
|
|
68
88
|
/**
|
|
69
89
|
* May this instance offer an edit at all, given the mounts it holds RIGHT NOW?
|
|
70
90
|
*
|
|
71
91
|
* A fork asks about its working tree, as before. A dispatched viewer asks about the corpus
|
|
72
|
-
* mount — and asks the
|
|
73
|
-
* downgrade (a role change the host re-announces on the same mount id)
|
|
74
|
-
*
|
|
75
|
-
*
|
|
92
|
+
* mount — and asks the live mount list, not the boot-time flag, so a live `rw → ro`
|
|
93
|
+
* downgrade (a role change the host re-announces on the same mount id) reroutes the
|
|
94
|
+
* delivery on the next render (to the workbench class) and an explicit
|
|
95
|
+
* `readerCanEdit: false` hides the offer — rather than surfacing `EROFS` on click.
|
|
96
|
+
* That is the whole difference between "hidden because you may not" and "shown, then
|
|
97
|
+
* `EROFS` when you try".
|
|
76
98
|
*
|
|
77
99
|
* A corpus mount that has vanished from the list answers `false`: no mount, no write.
|
|
78
100
|
*/
|
|
@@ -89,5 +111,12 @@ export function corpusWritable(
|
|
|
89
111
|
// `mode` is absent on the primary repo mount and rw by default elsewhere; a corpus
|
|
90
112
|
// mount that reports nothing is treated as writable exactly as `resolveOpenWiki` reads
|
|
91
113
|
// it, so the two never disagree about the same mount.
|
|
92
|
-
|
|
114
|
+
if (!mount) return false;
|
|
115
|
+
if (mount.mode !== 'ro') return true;
|
|
116
|
+
// R3-877 (APP_CUSTOMIZATION §5a.5): an `ro` delegation can still offer the
|
|
117
|
+
// workbench edit — the reader's authority — gated on the host's advisory
|
|
118
|
+
// `readerCanEdit` hint: offer when it is true, OR when the host sent no hint
|
|
119
|
+
// (absent = unknown — the refusal would tell, and `read-only` hides it after).
|
|
120
|
+
// Never offer on an explicit `false`.
|
|
121
|
+
return mount.readerCanEdit !== false;
|
|
93
122
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// countTags / tagWeight / topTags — the pure half of TagCloud. The counting case
|
|
2
|
+
// runs over the repo's REAL content tree, parsed with the real frontmatter parser,
|
|
3
|
+
// so a drifted fixture cannot agree with the code by accident (ways_of_working §4).
|
|
4
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import { parseFrontmatter } from './frontmatter';
|
|
8
|
+
import { countTags, tagWeight, topTags, type TagCount } from './tagCloud';
|
|
9
|
+
|
|
10
|
+
const CONTENT_ROOT = '/app/content/';
|
|
11
|
+
|
|
12
|
+
/** Every .mdx under the repo's content/ as a metadata map keyed the way the
|
|
13
|
+
* store keys it (absolute module path under the app root). */
|
|
14
|
+
const realCorpusMetadata = (): Record<string, { tags?: unknown }> => {
|
|
15
|
+
const dir = join(process.cwd(), 'content');
|
|
16
|
+
const walk = (d: string): string[] =>
|
|
17
|
+
readdirSync(d, { withFileTypes: true }).flatMap((e) =>
|
|
18
|
+
e.isDirectory() ? walk(join(d, e.name)) : e.name.endsWith('.mdx') ? [join(d, e.name)] : [],
|
|
19
|
+
);
|
|
20
|
+
const files: Record<string, { tags?: unknown }> = {};
|
|
21
|
+
for (const abs of walk(dir)) {
|
|
22
|
+
const { data } = parseFrontmatter(readFileSync(abs, 'utf8'));
|
|
23
|
+
files[CONTENT_ROOT + abs.slice(dir.length + 1)] = { tags: (data as { tags?: unknown }).tags };
|
|
24
|
+
}
|
|
25
|
+
return files;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** The same count, computed independently in the test from the same parsed map. */
|
|
29
|
+
const expectCounts = (files: Record<string, { tags?: unknown }>): TagCount[] => {
|
|
30
|
+
const counts = new Map<string, number>();
|
|
31
|
+
for (const [p, m] of Object.entries(files)) {
|
|
32
|
+
if (!p.startsWith(CONTENT_ROOT)) continue;
|
|
33
|
+
for (const t of Array.isArray(m.tags) ? (m.tags as string[]) : []) {
|
|
34
|
+
if (t.startsWith('ui/')) continue;
|
|
35
|
+
counts.set(t, (counts.get(t) ?? 0) + 1);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return [...counts.keys()].sort().map((tag) => ({ tag, count: counts.get(tag)! }));
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
describe('countTags', () => {
|
|
42
|
+
it('over the real corpus, equals the independent count and carries no ui/ tag', () => {
|
|
43
|
+
const files = realCorpusMetadata();
|
|
44
|
+
expect(Object.keys(files).length).toBeGreaterThan(0);
|
|
45
|
+
const result = countTags(files, CONTENT_ROOT);
|
|
46
|
+
expect(result).toEqual(expectCounts(files));
|
|
47
|
+
expect(result.some(({ tag }) => tag.startsWith('ui/'))).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('ignores entries outside the content root and non-array tags', () => {
|
|
51
|
+
const result = countTags(
|
|
52
|
+
{
|
|
53
|
+
'/app/content/a.mdx': { tags: ['x'] },
|
|
54
|
+
'/other/b.mdx': { tags: ['x'] },
|
|
55
|
+
'/app/content/c.mdx': { tags: 'x' },
|
|
56
|
+
'/app/content/d.mdx': null,
|
|
57
|
+
},
|
|
58
|
+
CONTENT_ROOT,
|
|
59
|
+
);
|
|
60
|
+
expect(result).toEqual([{ tag: 'x', count: 1 }]);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('tagWeight', () => {
|
|
65
|
+
it('gives the minimum 0 and the maximum 1', () => {
|
|
66
|
+
expect(tagWeight(1, 1, 191)).toBe(0);
|
|
67
|
+
expect(tagWeight(191, 1, 191)).toBe(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('is strictly increasing over the real corpus spread (1, 15, 191)', () => {
|
|
71
|
+
const w = [1, 15, 191].map((c) => tagWeight(c, 1, 191));
|
|
72
|
+
expect(w[0]!).toBeLessThan(w[1]!);
|
|
73
|
+
expect(w[1]!).toBeLessThan(w[2]!);
|
|
74
|
+
// Logarithmic: a tag 15x the minimum sits well under the arithmetic midpoint.
|
|
75
|
+
expect(w[1]!).toBeLessThan(0.6);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('clamps a count beyond the range', () => {
|
|
79
|
+
expect(tagWeight(10_000, 1, 191)).toBe(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('returns 0 when there is no spread (one tag, or all equal)', () => {
|
|
83
|
+
expect(tagWeight(7, 7, 7)).toBe(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('returns 0 for a count below 1 or a non-finite count', () => {
|
|
87
|
+
expect(tagWeight(0, 1, 10)).toBe(0);
|
|
88
|
+
expect(tagWeight(Number.NaN, 1, 10)).toBe(0);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('topTags', () => {
|
|
93
|
+
const five: TagCount[] = [
|
|
94
|
+
{ tag: 'alpha', count: 3 },
|
|
95
|
+
{ tag: 'beta', count: 9 },
|
|
96
|
+
{ tag: 'gamma', count: 12 },
|
|
97
|
+
{ tag: 'delta', count: 12 },
|
|
98
|
+
{ tag: 'epsilon', count: 1 },
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
it('keeps the N highest counts and returns them in tag order', () => {
|
|
102
|
+
expect(topTags(five, 2).map((t) => t.tag)).toEqual(['delta', 'gamma']);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('breaks a tie at the cut by tag name', () => {
|
|
106
|
+
// beta(9) and a tie between gamma/delta(12): limit 2 takes delta+gamma; a
|
|
107
|
+
// tie AT the cut (limit 3 leaves beta out) — add a second 9 to force it.
|
|
108
|
+
const withTie: TagCount[] = [...five.slice(0, 2), { tag: 'zed', count: 9 }, ...five.slice(2)];
|
|
109
|
+
expect(topTags(withTie, 3).map((t) => t.tag)).toEqual(['beta', 'delta', 'gamma']);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('returns the input when no limit is given', () => {
|
|
113
|
+
expect(topTags(five)).toBe(five);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// TagCloud's counting and scaling, extracted from the component so the cases are
|
|
2
|
+
// unit-testable (the component is a thin wiring shell). The chip's pixel range is
|
|
3
|
+
// NOT here — it lives in GroveApp.css, and the component only hands each chip a
|
|
4
|
+
// 0..1 weight via `--tag-weight`.
|
|
5
|
+
|
|
6
|
+
/** One tag and how many content entries carry it. */
|
|
7
|
+
export interface TagCount {
|
|
8
|
+
tag: string;
|
|
9
|
+
count: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface TaggedMeta {
|
|
13
|
+
tags?: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Every tag across the corpus with its count, sorted by tag. Only entries under
|
|
17
|
+
* the content root count; `ui/` tags are chrome (they drive layout, not
|
|
18
|
+
* classification) and are skipped. */
|
|
19
|
+
export function countTags(filesMetadata: Record<string, TaggedMeta | null>, contentRoot: string): TagCount[] {
|
|
20
|
+
const counts: Record<string, number> = {};
|
|
21
|
+
Object.entries(filesMetadata).forEach(([p, m]) => {
|
|
22
|
+
if (!p.startsWith(contentRoot)) return;
|
|
23
|
+
if (m && Array.isArray(m.tags)) {
|
|
24
|
+
(m.tags as string[]).forEach((t) => {
|
|
25
|
+
if (t.startsWith('ui/')) return;
|
|
26
|
+
counts[t] = (counts[t] || 0) + 1;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
return Object.keys(counts)
|
|
31
|
+
.sort()
|
|
32
|
+
.map((tag) => ({ tag, count: counts[tag]! }));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A tag's weight in the closed range 0..1: logarithmic and relative to THIS
|
|
36
|
+
* corpus's own minimum and maximum, so the scale holds at 10 entries and at
|
|
37
|
+
* 10,000 — the least-used tag renders 0, the most-used 1, and a tag used ten
|
|
38
|
+
* times as often is visibly but not ten times larger. No spread (`max === min`)
|
|
39
|
+
* returns 0, so a uniform corpus renders plain chips; a count below 1 or a
|
|
40
|
+
* non-finite count returns 0. */
|
|
41
|
+
export function tagWeight(count: number, min: number, max: number): number {
|
|
42
|
+
if (!Number.isFinite(count) || count < 1) return 0;
|
|
43
|
+
if (max <= min) return 0;
|
|
44
|
+
const w = (Math.log(count) - Math.log(min)) / (Math.log(max) - Math.log(min));
|
|
45
|
+
return Math.min(1, Math.max(0, w));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The `limit` most-used tags, ties at the cut broken by tag name, re-sorted by
|
|
49
|
+
* tag for display (the cloud reads alphabetically). An absent limit returns the
|
|
50
|
+
* input unchanged — validating a bad limit is the component's job (it warns). */
|
|
51
|
+
export function topTags(entries: TagCount[], limit?: number): TagCount[] {
|
|
52
|
+
if (limit === undefined) return entries;
|
|
53
|
+
return entries
|
|
54
|
+
.slice()
|
|
55
|
+
.sort((a, b) => b.count - a.count || (a.tag < b.tag ? -1 : a.tag > b.tag ? 1 : 0))
|
|
56
|
+
.slice(0, limit)
|
|
57
|
+
.sort((a, b) => (a.tag < b.tag ? -1 : a.tag > b.tag ? 1 : 0));
|
|
58
|
+
}
|