@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
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The navigation policy (APP_CUSTOMIZATION_SPEC §4.3, R3-872) — every in-bundle entry
|
|
2
|
+
// link's plain click goes through ONE replaceable function, so a shell that composes
|
|
3
|
+
// GroveEntry decides what "follow" means (route, open elsewhere, intercept) without
|
|
4
|
+
// forking a component.
|
|
5
|
+
//
|
|
6
|
+
// This file exports no component (the Fast-Refresh rule), so the context lives here
|
|
7
|
+
// beside the default it falls back to.
|
|
8
|
+
|
|
9
|
+
import { createContext } from 'react';
|
|
10
|
+
import { navigate } from '@immediately-run/sdk';
|
|
11
|
+
|
|
12
|
+
/** A resolved link target: the corpus key, any `#fragment`, the concrete href the
|
|
13
|
+
* anchor already renders (modifier-/middle-click still open it in a new tab), and
|
|
14
|
+
* the entry the link was rendered inside (`from`). Resolution happens at the call
|
|
15
|
+
* site — the policy receives the ANSWER, never a string to re-derive. */
|
|
16
|
+
export interface FollowLinkTarget {
|
|
17
|
+
key: string;
|
|
18
|
+
fragment?: string;
|
|
19
|
+
href: string;
|
|
20
|
+
from?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** What a plain click on an entry link does. */
|
|
24
|
+
export type FollowLink = (target: FollowLinkTarget) => void;
|
|
25
|
+
|
|
26
|
+
/** The stock behaviour: navigate to the resolved href, exactly as the SDK's Link
|
|
27
|
+
* would have. */
|
|
28
|
+
export const defaultFollowLink: FollowLink = ({ href }) => navigate(href);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The active policy. Read via `useFollowLink()` (the hook file keeps the
|
|
32
|
+
* component-tree rule); the context's default is the stock behaviour, so an
|
|
33
|
+
* unprovided tree behaves exactly as before (R-CUST-2).
|
|
34
|
+
*/
|
|
35
|
+
export const NavigationPolicyContext = createContext<FollowLink>(defaultFollowLink);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The anchor `onClick` half of the policy (R-CUST-3): a PLAIN click (no modifier,
|
|
39
|
+
* primary button) goes to the policy and never to the browser; a modified click
|
|
40
|
+
* falls through to the anchor's real `href` (new tab/window semantics are the
|
|
41
|
+
* browser's, and an anchor that lost them is a bug). A policy that throws is caught,
|
|
42
|
+
* logged with the target, and NOT silently fallen back from (R-CUST-5: fail loudly).
|
|
43
|
+
*/
|
|
44
|
+
export function followLinkOnClick(
|
|
45
|
+
follow: FollowLink,
|
|
46
|
+
target: FollowLinkTarget,
|
|
47
|
+
): (e: { button: number; metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean; preventDefault: () => void }) => void {
|
|
48
|
+
return (e) => {
|
|
49
|
+
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
50
|
+
e.preventDefault();
|
|
51
|
+
try {
|
|
52
|
+
follow(target);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
console.error(`[grove] the navigation policy threw for ${target.key}${target.fragment ?? ''}`, err);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The safe-vs-compiled render decision, in ONE place (R3-872 review, round 1):
|
|
2
|
+
// interpreter mode is read from the HOME entry (wiki-wide) OR the current entry
|
|
3
|
+
// (per-entry), and the two answer different questions — wiki-wide is the
|
|
4
|
+
// interpreter declaration for foreign content; per-entry exists for the document
|
|
5
|
+
// that is only correct as data (R3-252's proof page). Three renderers decide on
|
|
6
|
+
// this — the layout chain's renderer pick, the entry body's pick, and the shell's
|
|
7
|
+
// `safe` field — and drift between them is a trust-boundary hole (an executing
|
|
8
|
+
// body inside a safe chain), never a style issue. Pure, so the rule is testable
|
|
9
|
+
// without a render.
|
|
10
|
+
|
|
11
|
+
/** True when the entry renders through the non-executable interpreter path. */
|
|
12
|
+
export function resolveSafeRender(
|
|
13
|
+
homeMeta: Record<string, unknown> | null | undefined,
|
|
14
|
+
entryMeta: Record<string, unknown> | null | undefined,
|
|
15
|
+
): boolean {
|
|
16
|
+
return homeMeta?.render === 'safe' || entryMeta?.render === 'safe';
|
|
17
|
+
}
|
package/src/lib/shell.ts
CHANGED
|
@@ -67,6 +67,10 @@ export interface GroveShell {
|
|
|
67
67
|
* `checking` while the readdir is in flight — <PageView> must render neither the
|
|
68
68
|
* entry nor the 404 then, or a folder URL flashes "No entry at …" before healing. */
|
|
69
69
|
directory: DirectoryListing;
|
|
70
|
+
/** R3-872 — the entry frame's gate input: the content stylesheets' read state.
|
|
71
|
+
* Wiki-wide (the home entry's declaration), provided by the engine root;
|
|
72
|
+
* standalone compositions default to 'ready' (no sheets declared). */
|
|
73
|
+
stylesheetsStatus?: 'loading' | 'ready';
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
/** The refusal sentence, ONE home (R6, R3-608): every surface that offers an edit
|
|
@@ -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
|
+
}
|
package/src/lib.ts
CHANGED
|
@@ -52,3 +52,21 @@ export {
|
|
|
52
52
|
export { getContentRoot, isDispatched } from './lib/contentRoot';
|
|
53
53
|
export { layoutChainForKey } from './lib/layout';
|
|
54
54
|
export { queryPaths, readingTime, stripFrontmatter } from './lib/wiki';
|
|
55
|
+
|
|
56
|
+
// The entry composition seam (R3-872, APP_CUSTOMIZATION_SPEC §4.1): render one entry
|
|
57
|
+
// from a key — header + body + metadata + tags — framed by its layout chain or bare,
|
|
58
|
+
// publishing the entry context either way.
|
|
59
|
+
export { default as GroveEntry } from './components/GroveEntry';
|
|
60
|
+
export { useEntryKey } from './hooks/useEntryKey';
|
|
61
|
+
|
|
62
|
+
// The navigation policy (§4.3): every in-bundle entry link's plain click rides one
|
|
63
|
+
// replaceable function; the href stays real for modifier/middle clicks.
|
|
64
|
+
export { NavigationPolicyContext, defaultFollowLink, followLinkOnClick } from './lib/navigationPolicy';
|
|
65
|
+
export type { FollowLink, FollowLinkTarget } from './lib/navigationPolicy';
|
|
66
|
+
export { useFollowLink } from './hooks/useFollowLink';
|
|
67
|
+
|
|
68
|
+
// The entry-scoped helpers (§4.5): fragment resolution and heading collection scoped
|
|
69
|
+
// to one entry's marked body.
|
|
70
|
+
export { fragmentOf, resolveFragmentTarget } from './lib/fragment';
|
|
71
|
+
export { useHeadings } from './hooks/useHeadings';
|
|
72
|
+
export type { Heading } from './hooks/useHeadings';
|