@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.
Files changed (53) hide show
  1. package/llms.txt +13 -1
  2. package/package.json +3 -3
  3. package/src/GroveApp.css +11 -0
  4. package/src/GroveWiki.tsx +19 -60
  5. package/src/components/AssetImage.test.tsx +64 -0
  6. package/src/components/AssetImage.tsx +10 -10
  7. package/src/components/Backlinks.tsx +13 -6
  8. package/src/components/ChildPages.tsx +8 -6
  9. package/src/components/Directory.tsx +6 -1
  10. package/src/components/DirectoryList.tsx +16 -8
  11. package/src/components/DirectoryView.tsx +5 -2
  12. package/src/components/DocList.tsx +9 -2
  13. package/src/components/Drawer.tsx +14 -1
  14. package/src/components/EntryBody.tsx +138 -0
  15. package/src/components/EntryHeader.tsx +16 -3
  16. package/src/components/FamilyTree.tsx +3 -5
  17. package/src/components/GroveAgent.tsx +1 -1
  18. package/src/components/GroveEntry.test.tsx +200 -0
  19. package/src/components/GroveEntry.tsx +108 -0
  20. package/src/components/GroveFooter.tsx +5 -2
  21. package/src/components/GroveNav.tsx +11 -2
  22. package/src/components/PageMeta.tsx +3 -5
  23. package/src/components/PageView.tsx +13 -63
  24. package/src/components/Search.tsx +10 -1
  25. package/src/components/Sidebar.tsx +15 -7
  26. package/src/components/TableOfContents.tsx +1 -1
  27. package/src/components/TagCloud.test.tsx +103 -0
  28. package/src/components/TagCloud.tsx +47 -29
  29. package/src/components/Timeline.tsx +6 -1
  30. package/src/components/WikiLink.test.tsx +83 -0
  31. package/src/components/WikiLink.tsx +29 -8
  32. package/src/components/navigationPolicy.sweep.test.tsx +261 -0
  33. package/src/hooks/useEditAffordance.test.tsx +110 -0
  34. package/src/hooks/useEditAffordance.ts +51 -14
  35. package/src/hooks/useEntryKey.ts +21 -0
  36. package/src/hooks/useFollowLink.ts +11 -0
  37. package/src/hooks/useHeadings.test.tsx +93 -0
  38. package/src/hooks/useHeadings.ts +73 -12
  39. package/src/lib/assetPath.ts +4 -3
  40. package/src/lib/content.ts +0 -5
  41. package/src/lib/editTarget.test.ts +78 -18
  42. package/src/lib/editTarget.ts +39 -10
  43. package/src/lib/entryContext.test.ts +46 -0
  44. package/src/lib/entryContext.ts +35 -0
  45. package/src/lib/fragment.test.ts +21 -0
  46. package/src/lib/fragment.ts +9 -6
  47. package/src/lib/navigationPolicy.test.ts +65 -0
  48. package/src/lib/navigationPolicy.ts +57 -0
  49. package/src/lib/renderMode.ts +17 -0
  50. package/src/lib/shell.ts +4 -0
  51. package/src/lib/tagCloud.test.ts +115 -0
  52. package/src/lib/tagCloud.ts +58 -0
  53. 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';