@immediately-run/grove 0.1.1 → 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 +6 -2
- package/package.json +20 -8
- package/src/App.tsx +11 -7
- package/src/GroveApp.css +485 -85
- package/src/GroveWiki.tsx +176 -54
- 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 +24 -15
- 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 -149
- package/src/components/GroveNav.test.tsx +229 -0
- package/src/components/GroveNav.tsx +69 -20
- 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 +19 -4
- 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 +44 -4
- package/src/devfs.d.ts +5 -4
- package/src/hooks/{useCorpusMetadata.ts → useBundleMetadata.ts} +6 -6
- package/src/hooks/useContentComponents.ts +1 -1
- package/src/hooks/useEditAffordance.ts +99 -0
- package/src/hooks/useOpenWikiBoot.ts +1 -1
- 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/contentRoot.ts +16 -1
- 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/editTarget.test.ts +108 -0
- package/src/lib/editTarget.ts +93 -0
- package/src/lib/inlineProse.parity.test.ts +52 -0
- package/src/lib/layout.ts +29 -0
- package/src/lib/openWiki.test.ts +46 -5
- package/src/lib/openWiki.ts +18 -3
- 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 +17 -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 +52 -0
- package/src/lib/themeSelection.ts +59 -0
- package/src/mdxComponents.ts +4 -0
- package/viewer-manifest.schema.json +37 -0
- package/viewer.manifest.json +133 -6
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// R-GA-1 / G-GA-1 / G-GA-6 / G-GA-10 — the reach card is a pure function of the
|
|
2
|
+
// envelope; no pixel claims a capability the session lacks, causes are distinct and
|
|
3
|
+
// concrete, and state flips recompute rows AND chips.
|
|
4
|
+
import { describe, it, expect } from 'vitest';
|
|
5
|
+
import { computeReachRows, reachChips, showEgressDisclosure } from './reachCard';
|
|
6
|
+
import type { ChatProviderState } from '@immediately-run/sdk';
|
|
7
|
+
|
|
8
|
+
const configured = (tools = true): ChatProviderState => ({
|
|
9
|
+
status: 'configured',
|
|
10
|
+
provider: { providerId: 'llm.chat.anthropic', hostVouched: true, features: { vision: false, tools, jsonMode: true, reasoning: false, maxContextTokens: 100000 } },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const row = (rows: ReturnType<typeof computeReachRows>, key: string) => rows.find((r) => r.key === key)!;
|
|
14
|
+
|
|
15
|
+
describe('G-GA-10 — the Q&A row renders the provider three-state honestly', () => {
|
|
16
|
+
it('unknown renders NEUTRAL — no cause, no connect copy (the R3-300 rule)', () => {
|
|
17
|
+
const rows = computeReachRows({ providerState: { status: 'unknown' }, chatGranted: true, writable: true, sourceShared: true });
|
|
18
|
+
expect(row(rows, 'answer').state).toBe('neutral');
|
|
19
|
+
expect(row(rows, 'answer').cause).toBeUndefined();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('not-configured names the KEY cause; ungranted names the CONSENT cause — never conflated', () => {
|
|
23
|
+
const noKey = computeReachRows({ providerState: { status: 'not-configured' }, chatGranted: true, writable: true, sourceShared: false });
|
|
24
|
+
expect(row(noKey, 'answer').state).toBe('blocked');
|
|
25
|
+
expect(row(noKey, 'answer').cause).toContain('add one in Settings');
|
|
26
|
+
|
|
27
|
+
const forbidden = computeReachRows({ providerState: configured(), chatGranted: false, writable: true, sourceShared: false });
|
|
28
|
+
expect(row(forbidden, 'answer').state).toBe('blocked');
|
|
29
|
+
expect(row(forbidden, 'answer').cause).toContain("wasn't granted chat");
|
|
30
|
+
expect(row(forbidden, 'answer').cause).not.toContain('Settings');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('configured + granted is ✓ and carries the read-flavored chips', () => {
|
|
34
|
+
const ok = computeReachRows({ providerState: configured(), chatGranted: true, writable: false, sourceShared: false });
|
|
35
|
+
expect(row(ok, 'answer').state).toBe('ok');
|
|
36
|
+
expect(row(ok, 'answer').chips).toBeDefined();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('G-GA-1 — the read/draft/apply rows derive from writability, never copy', () => {
|
|
41
|
+
it('read-only blocks Draft with the reader cause and yields NO write chip', () => {
|
|
42
|
+
const rows = computeReachRows({ providerState: configured(), chatGranted: true, writable: false, sourceShared: false });
|
|
43
|
+
expect(row(rows, 'draft').state).toBe('blocked');
|
|
44
|
+
expect(row(rows, 'draft').cause).toContain('reader');
|
|
45
|
+
const chips = reachChips(rows);
|
|
46
|
+
expect(chips.some((c) => /add an entry|fix broken|reorganize|timeline|suggest an edit/i.test(c))).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('writable turns Draft ✓ — and its chip is a DESCRIBE, not an apply', () => {
|
|
50
|
+
const rows = computeReachRows({ providerState: configured(), chatGranted: true, writable: true, sourceShared: false });
|
|
51
|
+
expect(row(rows, 'draft').state).toBe('ok');
|
|
52
|
+
expect((row(rows, 'draft').chips ?? []).join(' ')).toMatch(/suggest an edit/i);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('Apply is NEVER ok from this panel (R-GA-3) — the cause says where changes go', () => {
|
|
56
|
+
for (const writable of [true, false]) {
|
|
57
|
+
const rows = computeReachRows({ providerState: configured(), chatGranted: true, writable, sourceShared: false });
|
|
58
|
+
expect(row(rows, 'apply').state).toBe('blocked');
|
|
59
|
+
expect(row(rows, 'apply').cause).toContain('editor / workbench');
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('the shared-source sentence rides the Apply cause only when trust says others can write', () => {
|
|
64
|
+
const shared = computeReachRows({ providerState: configured(), chatGranted: true, writable: true, sourceShared: true });
|
|
65
|
+
expect(row(shared, 'apply').cause).toContain('treated as if others can write');
|
|
66
|
+
const solo = computeReachRows({ providerState: configured(), chatGranted: true, writable: true, sourceShared: false });
|
|
67
|
+
expect(row(solo, 'apply').cause).not.toContain('others can write');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('G-GA-6 — envelope flips recompute rows and chips together', () => {
|
|
72
|
+
it('disconnecting the provider flips answer ✓→blocked and drops its chips', () => {
|
|
73
|
+
const before = computeReachRows({ providerState: configured(), chatGranted: true, writable: true, sourceShared: false });
|
|
74
|
+
const after = computeReachRows({ providerState: { status: 'not-configured' }, chatGranted: true, writable: true, sourceShared: false });
|
|
75
|
+
expect(row(before, 'answer').state).toBe('ok');
|
|
76
|
+
expect(row(after, 'answer').state).toBe('blocked');
|
|
77
|
+
expect(reachChips(after)).not.toContain('Summarize this entry');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('revoking write flips draft ✓→blocked on the next computation', () => {
|
|
81
|
+
const before = computeReachRows({ providerState: configured(), chatGranted: true, writable: true, sourceShared: false });
|
|
82
|
+
const after = computeReachRows({ providerState: configured(), chatGranted: true, writable: false, sourceShared: false });
|
|
83
|
+
expect(row(before, 'draft').state).toBe('ok');
|
|
84
|
+
expect(row(after, 'draft').state).toBe('blocked');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('R-GA-6 — the egress disclosure is unconditional when a provider is bound', () => {
|
|
89
|
+
it('shows for configured, not for unknown or not-configured, whatever the trust mode', () => {
|
|
90
|
+
expect(showEgressDisclosure(configured())).toBe(true);
|
|
91
|
+
expect(showEgressDisclosure({ status: 'unknown' })).toBe(false);
|
|
92
|
+
expect(showEgressDisclosure({ status: 'not-configured' })).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// The reach card (GROVE_AGENT_SPEC §6) — the agent's envelope, rendered as rows in
|
|
2
|
+
// the two-word vocabulary with a cause for every ✗ (R-SP-3). R-GA-1: every row is
|
|
3
|
+
// COMPUTED from the session's envelope (provider three-state, chat grant, mount
|
|
4
|
+
// writability, source trust); no capability claim on any pixel of this surface is
|
|
5
|
+
// hand-written copy. The four old banners collapse into these rows; chips render
|
|
6
|
+
// only for rows that are ✓ — derived, not curated.
|
|
7
|
+
|
|
8
|
+
import type { ChatProviderState } from '@immediately-run/sdk';
|
|
9
|
+
|
|
10
|
+
/** One reach-card row. `state: 'neutral'` renders neither ✓ nor ✗ — used only for
|
|
11
|
+
* the unknown provider state (rendering a cause there re-creates the false banner
|
|
12
|
+
* R3-300 fixed: `unknown` means unanswered, not ungranted). */
|
|
13
|
+
export interface ReachRow {
|
|
14
|
+
key: 'answer' | 'read' | 'draft' | 'apply';
|
|
15
|
+
label: string;
|
|
16
|
+
state: 'ok' | 'blocked' | 'neutral';
|
|
17
|
+
cause?: string;
|
|
18
|
+
/** Chips this row contributes when ✓ — the panel renders exactly these. */
|
|
19
|
+
chips?: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ReachInputs {
|
|
23
|
+
providerState: ChatProviderState;
|
|
24
|
+
/** Whether the grant-filtered catalog advertises `llm:chat` (the consented
|
|
25
|
+
* capability — absent on an ungranted fork, a distinct cause from "no key"). */
|
|
26
|
+
chatGranted: boolean;
|
|
27
|
+
writable: boolean;
|
|
28
|
+
/** Fail-closed source trust (git ⇒ indeterminate ⇒ treated as shared). */
|
|
29
|
+
sourceShared: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The rows, computed. Order is the card's display order. */
|
|
33
|
+
export function computeReachRows({ providerState, chatGranted, writable, sourceShared }: ReachInputs): ReachRow[] {
|
|
34
|
+
// Row 1 — Q&A. Three provider states × the grant, with the two NOT-causes never
|
|
35
|
+
// conflated (G-GA-10): "no key" is the user's to fix in Settings; "not granted"
|
|
36
|
+
// is this copy's consent state, and reading works either way.
|
|
37
|
+
let answer: ReachRow;
|
|
38
|
+
if (providerState.status === 'unknown') {
|
|
39
|
+
answer = { key: 'answer', label: 'Answer questions about this wiki', state: 'neutral' };
|
|
40
|
+
} else if (providerState.status === 'not-configured') {
|
|
41
|
+
answer = {
|
|
42
|
+
key: 'answer',
|
|
43
|
+
label: 'Answer questions about this wiki',
|
|
44
|
+
state: 'blocked',
|
|
45
|
+
cause: 'no model key connected — add one in Settings',
|
|
46
|
+
};
|
|
47
|
+
} else if (!chatGranted) {
|
|
48
|
+
answer = {
|
|
49
|
+
key: 'answer',
|
|
50
|
+
label: 'Answer questions about this wiki',
|
|
51
|
+
state: 'blocked',
|
|
52
|
+
cause: "this Grove wasn't granted chat — reading works as normal",
|
|
53
|
+
};
|
|
54
|
+
} else {
|
|
55
|
+
answer = {
|
|
56
|
+
key: 'answer',
|
|
57
|
+
label: 'Answer questions about this wiki',
|
|
58
|
+
state: 'ok',
|
|
59
|
+
chips: ['Summarize this entry', 'What entries are tagged security?'],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Row 2 — the body source. Both packagings define one post-S2 (the fork's own
|
|
64
|
+
// bundled corpus; the dispatched wiki mount), so this row is ✓ unless degenerate.
|
|
65
|
+
// It is computed, not assumed: the day a packaging lacks a source, the card says so.
|
|
66
|
+
const read: ReachRow = {
|
|
67
|
+
key: 'read',
|
|
68
|
+
label: 'Read entries and structure',
|
|
69
|
+
state: 'ok',
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Row 3 — drafting. Writability is the mount's answer; the chip proposes an edit
|
|
73
|
+
// the agent DESCRIBES (S4's host-confirmed diffs are not built — v1 drafts in chat).
|
|
74
|
+
const draft: ReachRow = writable
|
|
75
|
+
? {
|
|
76
|
+
key: 'draft',
|
|
77
|
+
label: 'Draft changes',
|
|
78
|
+
state: 'ok',
|
|
79
|
+
chips: [`Suggest an edit for "${'this entry'}"`],
|
|
80
|
+
}
|
|
81
|
+
: { key: 'draft', label: 'Draft changes', state: 'blocked', cause: 'you’re a reader here' };
|
|
82
|
+
|
|
83
|
+
// Row 4 — applying. NEVER from this panel (R-GA-3): the widget renders content and
|
|
84
|
+
// is exactly the broker core_concepts §8a Axis D forbids. The cause is where
|
|
85
|
+
// changes go, plus the shared-source sentence when trust says others can write.
|
|
86
|
+
const apply: ReachRow = {
|
|
87
|
+
key: 'apply',
|
|
88
|
+
label: 'Apply changes',
|
|
89
|
+
state: 'blocked',
|
|
90
|
+
cause:
|
|
91
|
+
'changes open in the editor / workbench, where you confirm them' +
|
|
92
|
+
(sourceShared ? ' — this repo is treated as if others can write (sole authorship can’t be verified yet), so agent actions go past you first' : ''),
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return [answer, read, draft, apply];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The chips the panel shows: exactly the ✓ rows' chips, in card order (R-GA-1 —
|
|
99
|
+
* derived from the envelope, never curated). */
|
|
100
|
+
export function reachChips(rows: ReachRow[]): string[] {
|
|
101
|
+
return rows.flatMap((r) => (r.state === 'ok' ? r.chips ?? [] : []));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** R-GA-6's unconditional egress line — shown whenever a provider is bound,
|
|
105
|
+
* whatever the wiki's trust mode: Q&A composes read + provider egress, and the
|
|
106
|
+
* confidentiality axis is grant-based, not sharedness-based. */
|
|
107
|
+
export const EGRESS_DISCLOSURE =
|
|
108
|
+
'answers come from your connected model provider, which receives what the agent reads here';
|
|
109
|
+
|
|
110
|
+
export function showEgressDisclosure(providerState: ChatProviderState): boolean {
|
|
111
|
+
return providerState.status === 'configured';
|
|
112
|
+
}
|
package/src/lib/shell.ts
CHANGED
|
@@ -33,7 +33,20 @@ export interface GroveShell {
|
|
|
33
33
|
// Environment
|
|
34
34
|
vw: 'mobile' | 'desktop';
|
|
35
35
|
navMode: 'top' | 'side';
|
|
36
|
+
/** Whether to render an edit affordance — the corpus MOUNT's answer, re-read live
|
|
37
|
+
* (R3-266), never a property of how this instance was packaged. */
|
|
36
38
|
writable: boolean;
|
|
39
|
+
/** Open a content entry in the platform editor. Which verb that takes differs by
|
|
40
|
+
* packaging (`lib/editTarget`); the chrome never has to know which. */
|
|
41
|
+
openEditor: (entryKey: string) => void;
|
|
42
|
+
/** True while an editor is being summoned, for a busy label. */
|
|
43
|
+
editBusy: boolean;
|
|
44
|
+
/** True when the host REFUSED the last edit request — render it where the
|
|
45
|
+
* affordance was offered (3.3.1, R3-608); a cancelled request never sets it. */
|
|
46
|
+
editRefused: boolean;
|
|
47
|
+
/** What a save actually does, for the affordance's title — under dispatch it says that
|
|
48
|
+
* proposing a change back to the content repo is not wired yet (R3-266's residual). */
|
|
49
|
+
editHint: string;
|
|
37
50
|
siteTitle: string;
|
|
38
51
|
/** Interpreter mode (TRUST_MODES §5): render this entry's body through the
|
|
39
52
|
* non-executable safe renderer (R3-213) instead of the compiled/executable `<Include>`
|
|
@@ -55,6 +68,10 @@ export interface GroveShell {
|
|
|
55
68
|
directory: DirectoryListing;
|
|
56
69
|
}
|
|
57
70
|
|
|
71
|
+
/** The refusal sentence, ONE home (R6, R3-608): every surface that offers an edit
|
|
72
|
+
* renders this where the affordance was offered. */
|
|
73
|
+
export const EDIT_REFUSED_NOTICE = 'Could not open the editor — the host refused';
|
|
74
|
+
|
|
58
75
|
export const GroveShellContext = createContext<GroveShell | null>(null);
|
|
59
76
|
|
|
60
77
|
/** Read the shell context. Throws if used outside App's provider — which only
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// The layout-starter gate (R3-309, bucket A) — every starter under
|
|
2
|
+
// `content/_layouts/` rendered through the REAL published safe renderer with
|
|
3
|
+
// Grove's own SAFE_MDX map, exactly what SafeLayout sends a `_layout.mdx` through
|
|
4
|
+
// in interpreter mode. Proven by fault injection on all quiet failure modes.
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { parseSafeMdast, renderMdast } from '@immediately-run/sdk/safeContent/index';
|
|
9
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
10
|
+
import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
|
|
11
|
+
import { SAFE_MDX } from '../mdxComponents';
|
|
12
|
+
import { GroveShellContext, OutletContext, type GroveShell } from './shell';
|
|
13
|
+
import {
|
|
14
|
+
starterViolations,
|
|
15
|
+
STARTER_MARKERS,
|
|
16
|
+
stripStarterFrontmatter,
|
|
17
|
+
} from './starterSweep';
|
|
18
|
+
|
|
19
|
+
const LAYOUTS = join(process.cwd(), 'content', '_layouts');
|
|
20
|
+
|
|
21
|
+
/** A complete-enough shell for the chrome primitives to render statically (they
|
|
22
|
+
* read state through useShell; a bare render throws in the provider check, which
|
|
23
|
+
* is correct — the sweep must run them the way the wiki does, with state). */
|
|
24
|
+
const SHELL: GroveShell = {
|
|
25
|
+
theme: 'default',
|
|
26
|
+
setTheme: () => {},
|
|
27
|
+
light: false,
|
|
28
|
+
setLight: () => {},
|
|
29
|
+
menuOpen: false,
|
|
30
|
+
setMenuOpen: () => {},
|
|
31
|
+
searchOpen: false,
|
|
32
|
+
setSearchOpen: () => {},
|
|
33
|
+
drawerOpen: false,
|
|
34
|
+
setDrawerOpen: () => {},
|
|
35
|
+
vw: 'desktop',
|
|
36
|
+
navMode: 'side',
|
|
37
|
+
writable: false,
|
|
38
|
+
openEditor: () => {},
|
|
39
|
+
editBusy: false,
|
|
40
|
+
editHint: '',
|
|
41
|
+
siteTitle: 'Grove',
|
|
42
|
+
safe: false,
|
|
43
|
+
navItems: [],
|
|
44
|
+
entryKey: '/app/content/index.mdx',
|
|
45
|
+
includePath: 'index.mdx',
|
|
46
|
+
layout: 'doc',
|
|
47
|
+
showRails: true,
|
|
48
|
+
mins: 0,
|
|
49
|
+
missing: false,
|
|
50
|
+
directory: { status: 'idle' },
|
|
51
|
+
} as unknown as GroveShell;
|
|
52
|
+
|
|
53
|
+
/** The real interpreter render, with the Outlet sentinel a starter's `<Outlet/>`
|
|
54
|
+
* renders — a static render has no inner page, so the sentinel is how presence
|
|
55
|
+
* is distinguished from a tag that quietly collapsed to nothing. The host
|
|
56
|
+
* navigation provider stands in for the sandbox, exactly as it would supply it. */
|
|
57
|
+
const NAV = {
|
|
58
|
+
outerHref: 'https://example.immediately.run/app/x',
|
|
59
|
+
navigationState: { sandboxPath: '/app/x' },
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const renderBody = async (body: string): Promise<string> => {
|
|
63
|
+
const tree = await parseSafeMdast(body);
|
|
64
|
+
const el = renderMdast(tree, { components: SAFE_MDX as never });
|
|
65
|
+
return renderToStaticMarkup(
|
|
66
|
+
<TinkerableContext.Provider value={NAV as never}>
|
|
67
|
+
<GroveShellContext.Provider value={SHELL}>
|
|
68
|
+
<OutletContext.Provider value={<p>OUTLET-SENTINEL</p>}>{el as never}</OutletContext.Provider>
|
|
69
|
+
</GroveShellContext.Provider>
|
|
70
|
+
</TinkerableContext.Provider>,
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const knownTags = new Set(Object.keys(SAFE_MDX) as string[]);
|
|
75
|
+
|
|
76
|
+
describe('the layout-starter sweep (R3-309 bucket A)', () => {
|
|
77
|
+
const starters = readdirSync(LAYOUTS).filter((f) => f.endsWith('.mdx'));
|
|
78
|
+
|
|
79
|
+
it('found the starters — a sweep that examines nothing defends nothing (checked > 0)', () => {
|
|
80
|
+
expect(starters.length).toBe(3);
|
|
81
|
+
expect(new Set(starters.map((f) => f.replace(/\.mdx$/, '')))).toEqual(
|
|
82
|
+
new Set(['shell', 'section', 'hero']),
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it.each(starters)('%s survives the real safe renderer', async (file) => {
|
|
87
|
+
const src = readFileSync(join(LAYOUTS, file), 'utf8');
|
|
88
|
+
const violations = await starterViolations(src, { render: renderBody, knownTags });
|
|
89
|
+
expect(violations).toEqual([]);
|
|
90
|
+
// And the Outlet sentinel came through: the starter actually frames a page.
|
|
91
|
+
expect(await renderBody(stripStarterFrontmatter(src))).toContain('OUTLET-SENTINEL');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('the hero starter selects the top arrangement and omits the sidebar', async () => {
|
|
95
|
+
// Exit criterion: a shell starter selecting `top` renders without the sidebar.
|
|
96
|
+
// The hero IS that starter — `nav: top` in frontmatter, no `<GroveSidebar/>`
|
|
97
|
+
// in the body.
|
|
98
|
+
const src = readFileSync(join(LAYOUTS, 'hero.mdx'), 'utf8');
|
|
99
|
+
expect(src).toMatch(/^nav:\s*top$/m);
|
|
100
|
+
expect(stripStarterFrontmatter(src)).not.toContain('<GroveSidebar');
|
|
101
|
+
const markup = await renderBody(stripStarterFrontmatter(src));
|
|
102
|
+
expect(markup).not.toContain('grove-sidebar');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ── fault injection: each quiet failure mode, planted and caught ────────────
|
|
106
|
+
const GOOD = readFileSync(join(LAYOUTS, 'shell.mdx'), 'utf8');
|
|
107
|
+
|
|
108
|
+
it('baseline passes, so the injections below mean the PLANT', async () => {
|
|
109
|
+
expect(await starterViolations(GOOD, { render: renderBody, knownTags })).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('a planted raw-HTML block is caught (it renders as literal angle brackets, never markup)', async () => {
|
|
113
|
+
// The shape safeRender.test.ts proved: a raw `<script>` survives as ESCAPED TEXT.
|
|
114
|
+
// Starters carry no raw HTML, so ANY '<' in the visible text is this failure —
|
|
115
|
+
// and the script must not have executed, either.
|
|
116
|
+
const planted = stripStarterFrontmatter(GOOD).replace(
|
|
117
|
+
'<GroveFooter />',
|
|
118
|
+
'<script>globalThis.__pwnedByStarter = 1</script>',
|
|
119
|
+
);
|
|
120
|
+
const v = await starterViolations(planted, { render: renderBody, knownTags });
|
|
121
|
+
expect(v.some((x) => /angle brackets/.test(x))).toBe(true);
|
|
122
|
+
expect((globalThis as Record<string, unknown>).__pwnedByStarter).toBeUndefined();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('a planted import line is caught (it renders as visible prose)', async () => {
|
|
126
|
+
const planted = stripStarterFrontmatter(GOOD).replace(
|
|
127
|
+
'<GroveNav />',
|
|
128
|
+
'import GroveNav from "./components/GroveNav"\n\n<GroveNav />',
|
|
129
|
+
);
|
|
130
|
+
const v = await starterViolations(planted, { render: renderBody, knownTags });
|
|
131
|
+
expect(v.some((x) => /import\/export line/.test(x))).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('a planted off-vocabulary wrapper is caught (it collapses to its children)', async () => {
|
|
135
|
+
const planted = stripStarterFrontmatter(GOOD).replace(
|
|
136
|
+
'<GroveFooter />',
|
|
137
|
+
'<NotARealWrapper className="x">\n\nTrapped text.\n\n</NotARealWrapper>',
|
|
138
|
+
);
|
|
139
|
+
const v = await starterViolations(planted, { render: renderBody, knownTags });
|
|
140
|
+
expect(v.some((x) => /not in the component map/.test(x))).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('a planted JSX comment is caught — under the interpreter its braces are prose', async () => {
|
|
144
|
+
// The shipped sample layouts carry `{/* … */}` teaching comments, which the
|
|
145
|
+
// COMPILED path drops and the interpreter renders as literal text. Starters
|
|
146
|
+
// must not teach that: their teaching lives in frontmatter, which never renders.
|
|
147
|
+
const planted = stripStarterFrontmatter(GOOD).replace(
|
|
148
|
+
'<GroveNav />',
|
|
149
|
+
'{/* a teaching comment */}\n\n<GroveNav />',
|
|
150
|
+
);
|
|
151
|
+
const v = await starterViolations(planted, { render: renderBody, knownTags });
|
|
152
|
+
expect(v.some((x) => /braces render as visible text/.test(x))).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('every marker the sweep keys on exists in the vocabulary it checks', () => {
|
|
156
|
+
for (const tag of Object.keys(STARTER_MARKERS)) {
|
|
157
|
+
expect(knownTags.has(tag)).toBe(true);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// The layout-starter sweep (R3-309, bucket A's gate) — PURE analysis over a starter's
|
|
2
|
+
// source and its rendered markup, so the same checker drives the real starters from
|
|
3
|
+
// disk AND fault-injected fixtures in the test.
|
|
4
|
+
//
|
|
5
|
+
// WHY A GATE AT ALL. A starter is a file people COPY, which makes it teaching
|
|
6
|
+
// material: whatever it does, corpora will do. The safe renderer fails QUIETLY in
|
|
7
|
+
// four ways (ENGINE_BOUNDARY §6 / 03-layout-catalogue §3) — expression props
|
|
8
|
+
// dropped, unregistered tags collapsed to their children, `import` lines rendered
|
|
9
|
+
// as visible prose, mid-line block tags eaten as literal angle brackets — and a
|
|
10
|
+
// shipped starter that trips any of them teaches the failure with no error
|
|
11
|
+
// anywhere. Worse than no starter.
|
|
12
|
+
//
|
|
13
|
+
// The render itself is INJECTED (`render(body) → markup`) because the real one is
|
|
14
|
+
// the published safe renderer plus Grove's component map — exactly what
|
|
15
|
+
// SafeLayout sends a `_layout.mdx` through — and the test wires that up; keeping
|
|
16
|
+
// it out of this module keeps the analysis pure and runnable over strings.
|
|
17
|
+
|
|
18
|
+
/** Which marker substring an ALWAYS-RENDERING primitive must leave in the markup. A
|
|
19
|
+
* primitive whose marker is absent either collapsed (unregistered) or changed its
|
|
20
|
+
* root — both are drift a copy would inherit. Data-dependent components
|
|
21
|
+
* (`DocList`, `ChildPages`) are deliberately absent: with no corpus in the sweep
|
|
22
|
+
* they render nothing BY DESIGN, and their registration is checked through
|
|
23
|
+
* `knownTags` instead. */
|
|
24
|
+
export const STARTER_MARKERS: Record<string, string> = {
|
|
25
|
+
GroveNav: 'grove-nav',
|
|
26
|
+
GroveSidebar: 'grove-sidebar',
|
|
27
|
+
GroveFooter: 'grove-footer',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Strip a leading frontmatter block — the render path never sees it (the sweep
|
|
31
|
+
* asserts the BODY a reader would get). */
|
|
32
|
+
export function stripStarterFrontmatter(src: string): string {
|
|
33
|
+
return src.replace(/^---\n[\s\S]*?\n---\n?/, '');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Every capitalized component tag a starter uses (`<GroveNav`, `<DocList`). */
|
|
37
|
+
export function componentTagsOf(body: string): string[] {
|
|
38
|
+
return [...body.matchAll(/<([A-Z][A-Za-z0-9]*)/g)].map((m) => m[1]!);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The text a reader sees, once every element is stripped away — what "visible
|
|
42
|
+
* prose" checks run over. Entities are decoded so an eaten tag shows up as '<'. */
|
|
43
|
+
export function visibleTextOf(markup: string): string {
|
|
44
|
+
return markup
|
|
45
|
+
.replace(/<[^>]+>/g, '')
|
|
46
|
+
.replace(/</g, '<')
|
|
47
|
+
.replace(/>/g, '>')
|
|
48
|
+
.replace(/&/g, '&');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface StarterSweepDeps {
|
|
52
|
+
/** Render a starter BODY through the real safe renderer + component map. Async:
|
|
53
|
+
* the real parser is. */
|
|
54
|
+
render: (body: string) => string | Promise<string>;
|
|
55
|
+
/** The component names the map actually registers (off-vocabulary detection). */
|
|
56
|
+
knownTags: ReadonlySet<string>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Sweep one starter. Returns one violation string per finding — empty means the
|
|
61
|
+
* starter survives the interpreter.
|
|
62
|
+
*/
|
|
63
|
+
export async function starterViolations(
|
|
64
|
+
source: string,
|
|
65
|
+
deps: StarterSweepDeps,
|
|
66
|
+
): Promise<string[]> {
|
|
67
|
+
const body = stripStarterFrontmatter(source);
|
|
68
|
+
const markup = await deps.render(body);
|
|
69
|
+
const text = visibleTextOf(markup);
|
|
70
|
+
const out: string[] = [];
|
|
71
|
+
|
|
72
|
+
// (3) an unresolved import renders as visible prose — a copy would teach it.
|
|
73
|
+
if (/^\s*(import|export)\s/m.test(text)) out.push('an import/export line renders as visible prose');
|
|
74
|
+
|
|
75
|
+
// (4) a block tag that does not open on its own line is eaten by micromark and
|
|
76
|
+
// shows up as literal angle brackets. Starters carry no raw HTML, so ANY '<' in
|
|
77
|
+
// the visible text is this failure.
|
|
78
|
+
if (text.includes('<')) out.push(`literal angle brackets in the visible text (${JSON.stringify(text.match(/.{0,24}<.{0,24}/)?.[0])})`);
|
|
79
|
+
|
|
80
|
+
// Starters are structural: braces belong to no tag we document, and under the
|
|
81
|
+
// interpreter they stay characters — a `{/* comment */}` renders as prose.
|
|
82
|
+
if (text.includes('{') || text.includes('}')) {
|
|
83
|
+
out.push('braces render as visible text (an expression or JSX comment the interpreter does not consume)');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const tag of componentTagsOf(body)) {
|
|
87
|
+
if (!deps.knownTags.has(tag)) {
|
|
88
|
+
out.push(`<${tag}> is not in the component map — under the interpreter it collapses to its children`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const marker = STARTER_MARKERS[tag];
|
|
92
|
+
if (marker && !markup.includes(marker)) {
|
|
93
|
+
out.push(`<${tag}> rendered without its "${marker}" marker — the wrapper collapsed or drifted`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// R3-315's adversarial exits, at the minter:
|
|
2
|
+
// • faces mint from LOCAL bytes — no network location is named anywhere;
|
|
3
|
+
// • switching sets revokes the outgoing URLs (no leak across a theme lifetime);
|
|
4
|
+
// • a missing font or asset degrades — skipped, never thrown, fallback answers;
|
|
5
|
+
// • minted URLs and @font-face srcs are `blob:` — no chroot prefix can reach
|
|
6
|
+
// them (proven non-vacuous by a leaking canary the same check catches).
|
|
7
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
8
|
+
import { mintThemeAssets } from './themeAssets';
|
|
9
|
+
import type { AssetReader } from './themeAssets';
|
|
10
|
+
|
|
11
|
+
const BYTES = new Uint8Array([1, 2, 3, 4]);
|
|
12
|
+
const ok: AssetReader = async () => BYTES;
|
|
13
|
+
const miss: AssetReader = async () => {
|
|
14
|
+
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL');
|
|
18
|
+
const createSpy = vi.spyOn(URL, 'createObjectURL');
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
revokeSpy.mockClear();
|
|
22
|
+
createSpy.mockClear();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('minting', () => {
|
|
26
|
+
it('emits one @font-face per declared face, blob: src, descriptors carried', async () => {
|
|
27
|
+
const m = await mintThemeAssets(
|
|
28
|
+
{ fonts: [{ family: 'Source Serif 4', src: './fonts/a.woff2', weight: '400 700', style: 'normal' }] },
|
|
29
|
+
'/app/themes/x.css',
|
|
30
|
+
ok,
|
|
31
|
+
);
|
|
32
|
+
expect(m.minted).toBe(1);
|
|
33
|
+
expect(m.fontFaceCss).toContain('@font-face');
|
|
34
|
+
expect(m.fontFaceCss).toContain('"Source Serif 4"');
|
|
35
|
+
expect(m.fontFaceCss).toContain('font-weight: 400 700');
|
|
36
|
+
expect(m.fontFaceCss).toMatch(/src: url\("blob:/);
|
|
37
|
+
expect(m.fontFaceCss).toContain('format("woff2")');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('resolves src RELATIVE TO THE DECLARING FILE and reads the resolved path', async () => {
|
|
41
|
+
const read = vi.fn(ok);
|
|
42
|
+
await mintThemeAssets({ fonts: [{ family: 'A', src: '../fonts/a.woff2' }] }, '/app/themes/x.css', read);
|
|
43
|
+
expect(read).toHaveBeenCalledWith('/app/fonts/a.woff2');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('named assets become --asset-<name> vars on .grove-root', async () => {
|
|
47
|
+
const m = await mintThemeAssets({ assets: { paper: './textures/paper.jpg' } }, '/app/themes/x.css', ok);
|
|
48
|
+
expect(m.assetVarsCss).toMatch(/^\.grove-root\{--asset-paper: url\("blob:/);
|
|
49
|
+
expect(m.minted).toBe(1);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('malformed names/refs are skipped, not thrown', async () => {
|
|
53
|
+
const m = await mintThemeAssets(
|
|
54
|
+
{ assets: { 'bad name': './x.png', 'also__bad!': './y.png', ok: './z.png' } },
|
|
55
|
+
'/app/t.css',
|
|
56
|
+
ok,
|
|
57
|
+
);
|
|
58
|
+
expect(m.minted).toBe(1);
|
|
59
|
+
expect(m.assetVarsCss).toContain('--asset-ok');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('degrade, never break', () => {
|
|
64
|
+
it('a missing font file skips its @font-face — the token fallback answers', async () => {
|
|
65
|
+
const m = await mintThemeAssets(
|
|
66
|
+
{ fonts: [{ family: 'Here', src: './here.woff2' }, { family: 'Gone', src: './gone.woff2' }] },
|
|
67
|
+
'/app/t.css',
|
|
68
|
+
async (p) => (p.endsWith('here.woff2') ? BYTES : Promise.reject(new Error('ENOENT'))),
|
|
69
|
+
);
|
|
70
|
+
expect(m.minted).toBe(1);
|
|
71
|
+
expect(m.fontFaceCss).toContain('"Here"');
|
|
72
|
+
expect(m.fontFaceCss).not.toContain('"Gone"');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('a missing named asset skips its var — the var() fallback answers', async () => {
|
|
76
|
+
const m = await mintThemeAssets({ assets: { paper: './none.jpg' } }, '/app/t.css', miss);
|
|
77
|
+
expect(m.minted).toBe(0);
|
|
78
|
+
expect(m.assetVarsCss).toBe('');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('nothing throws for an all-missing set', async () => {
|
|
82
|
+
const m = await mintThemeAssets({ fonts: [{ family: 'X', src: './x.woff2' }], assets: { a: './b.png' } }, '/app/t.css', miss);
|
|
83
|
+
expect(m.minted).toBe(0);
|
|
84
|
+
expect(m.fontFaceCss).toBe('');
|
|
85
|
+
expect(m.assetVarsCss).toBe('');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('the revoke scope is the theme lifetime (no leak on switch)', () => {
|
|
90
|
+
it('revoke() releases every minted URL exactly once (idempotent)', async () => {
|
|
91
|
+
const m = await mintThemeAssets(
|
|
92
|
+
{ fonts: [{ family: 'A', src: './a.woff2' }], assets: { b: './b.png' } },
|
|
93
|
+
'/app/t.css',
|
|
94
|
+
ok,
|
|
95
|
+
);
|
|
96
|
+
expect(m.minted).toBe(2);
|
|
97
|
+
m.revoke();
|
|
98
|
+
m.revoke(); // idempotent
|
|
99
|
+
expect(revokeSpy).toHaveBeenCalledTimes(2);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('mint → revoke → mint again mints FRESH URLs (a switch leaks nothing)', async () => {
|
|
103
|
+
const first = await mintThemeAssets({ fonts: [{ family: 'A', src: './a.woff2' }] }, '/app/t.css', ok);
|
|
104
|
+
const urls1 = first.fontFaceCss;
|
|
105
|
+
first.revoke();
|
|
106
|
+
const second = await mintThemeAssets({ fonts: [{ family: 'A', src: './a.woff2' }] }, '/app/t.css', ok);
|
|
107
|
+
expect(second.minted).toBe(1);
|
|
108
|
+
expect(second.fontFaceCss).not.toBe(urls1); // distinct URL per mint
|
|
109
|
+
expect(revokeSpy).toHaveBeenCalledTimes(1);
|
|
110
|
+
second.revoke();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('UNDER DISPATCH no chroot prefix reaches a minted URL or @font-face src', () => {
|
|
115
|
+
it('the minted output is blob:-only — the mount path appears nowhere in it', async () => {
|
|
116
|
+
const m = await mintThemeAssets(
|
|
117
|
+
{ fonts: [{ family: 'A', src: './a.woff2' }], assets: { b: './b.png' } },
|
|
118
|
+
'/mnt/abc123def456/themes/x.css',
|
|
119
|
+
ok,
|
|
120
|
+
);
|
|
121
|
+
const all = m.fontFaceCss + m.assetVarsCss;
|
|
122
|
+
expect(all).not.toContain('mnt');
|
|
123
|
+
expect(all).not.toContain('abc123def456');
|
|
124
|
+
expect(all).toMatch(/blob:/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('NON-VACUOUS by fault injection: the same assertion catches a leaking src', () => {
|
|
128
|
+
// The canary: the regression class this exit exists for — an implementation
|
|
129
|
+
// that writes the resolved path into the CSS instead of the minted URL.
|
|
130
|
+
const leaky = `@font-face{src: url("/mnt/abc123def456/themes/a.woff2");}`;
|
|
131
|
+
expect(() => {
|
|
132
|
+
expect(leaky).not.toContain('abc123def456');
|
|
133
|
+
}).toThrow();
|
|
134
|
+
});
|
|
135
|
+
});
|