@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
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,108 @@
|
|
|
1
|
+
// R3-266 — dispatched content is writable, and the MOUNT decides.
|
|
2
|
+
//
|
|
3
|
+
// The two things these tests pin are the two things that were wrong before: a dispatched
|
|
4
|
+
// viewer must send its edit to the CORPUS (never to Grove's own repo), and whether it may
|
|
5
|
+
// offer one at all must be the corpus mount's CURRENT mode rather than a property of the
|
|
6
|
+
// packaging or a flag latched at boot.
|
|
7
|
+
import { describe, expect, it } from 'vitest';
|
|
8
|
+
import { corpusWritable, editTarget, keyToSelfPath } from './editTarget';
|
|
9
|
+
import type { CorpusIdentity } from './editTarget';
|
|
10
|
+
import type { SandboxMount } from '@immediately-run/sdk/mounts';
|
|
11
|
+
|
|
12
|
+
const fork: CorpusIdentity = {
|
|
13
|
+
dispatched: false,
|
|
14
|
+
contentRoot: '/app/content/',
|
|
15
|
+
mountId: null,
|
|
16
|
+
};
|
|
17
|
+
const dispatched: CorpusIdentity = {
|
|
18
|
+
dispatched: true,
|
|
19
|
+
contentRoot: '/task/t1/dir/',
|
|
20
|
+
mountId: '/task/t1/dir',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const mount = (over: Partial<SandboxMount> = {}): SandboxMount =>
|
|
24
|
+
({ type: 'firestore', path: '/task/t1/dir', id: '/task/t1/dir', mode: 'rw', ...over }) as SandboxMount;
|
|
25
|
+
|
|
26
|
+
describe('editTarget — the verb follows the authority, not the packaging', () => {
|
|
27
|
+
it('a FORK edits its own source through the self-scoped present→edit transition', () => {
|
|
28
|
+
expect(editTarget('/app/content/handbook/onboarding.mdx', fork)).toEqual({
|
|
29
|
+
via: 'self',
|
|
30
|
+
path: 'content/handbook/onboarding.mdx',
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('a DISPATCHED viewer delegates the CORPUS file, never a path in its own repo', () => {
|
|
35
|
+
expect(editTarget('/task/t1/dir/plot/the-rail.mdx', dispatched)).toEqual({
|
|
36
|
+
via: 'delegate',
|
|
37
|
+
mountId: '/task/t1/dir',
|
|
38
|
+
relPath: 'plot/the-rail.mdx',
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('is corpus-relative under dispatch — the mount root IS the corpus root', () => {
|
|
43
|
+
const t = editTarget('/task/t1/dir/home.mdx', dispatched);
|
|
44
|
+
expect(t).toMatchObject({ relPath: 'home.mdx' });
|
|
45
|
+
// The fork's `content/` segment must NOT leak into a corpus-relative path: the
|
|
46
|
+
// delegated chroot is minted AT the content directory.
|
|
47
|
+
expect((t as { relPath: string }).relPath.startsWith('content/')).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('offers nothing for a key outside the mounted corpus (a leftover from the viewer)', () => {
|
|
51
|
+
expect(editTarget('/app/content/home.mdx', dispatched)).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('offers nothing when a dispatched corpus has no mount id to delegate from', () => {
|
|
55
|
+
expect(editTarget('/task/t1/dir/home.mdx', { ...dispatched, mountId: null })).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('offers nothing for the corpus root itself (a directory is not an entry)', () => {
|
|
59
|
+
expect(editTarget('/task/t1/dir/', dispatched)).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('never throws on a junk key', () => {
|
|
63
|
+
expect(editTarget('', dispatched)).toBeNull();
|
|
64
|
+
expect(editTarget(undefined as unknown as string, fork)).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('keyToSelfPath strips the app anchor exactly as the fork URLs require', () => {
|
|
68
|
+
expect(keyToSelfPath('/app/content/x.mdx')).toBe('content/x.mdx');
|
|
69
|
+
expect(keyToSelfPath('/content/x.mdx')).toBe('content/x.mdx');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('corpusWritable — the mount decides, live', () => {
|
|
74
|
+
it('a fork asks about its working tree, as before', () => {
|
|
75
|
+
expect(corpusWritable([{ type: 'worktree', path: '/app', mode: 'rw' } as SandboxMount], fork)).toBe(true);
|
|
76
|
+
expect(corpusWritable([{ type: 'worktree', path: '/app', mode: 'ro' } as SandboxMount], fork)).toBe(false);
|
|
77
|
+
expect(corpusWritable([], fork)).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('a DISPATCHED viewer on an rw corpus is writable — packaging is not trust', () => {
|
|
81
|
+
expect(corpusWritable([mount()], dispatched)).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('a ro corpus is not writable, so the affordance is hidden rather than EROFS-ing', () => {
|
|
85
|
+
expect(corpusWritable([mount({ mode: 'ro' })], dispatched)).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('follows a LIVE downgrade: the same mount re-announced ro flips the answer', () => {
|
|
89
|
+
expect(corpusWritable([mount({ mode: 'rw' })], dispatched)).toBe(true);
|
|
90
|
+
expect(corpusWritable([mount({ mode: 'ro' })], dispatched)).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('a corpus mount that has vanished is not writable', () => {
|
|
94
|
+
expect(corpusWritable([mount({ id: 'space:other', path: '/mnt/x' })], dispatched)).toBe(false);
|
|
95
|
+
expect(corpusWritable([], dispatched)).toBe(false);
|
|
96
|
+
expect(corpusWritable(null, dispatched)).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('matches a mount that carries no id by its path (what the host publishes)', () => {
|
|
100
|
+
expect(corpusWritable([{ type: 'firestore', path: '/task/t1/dir', mode: 'rw' } as SandboxMount], dispatched)).toBe(
|
|
101
|
+
true,
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('never reports writable when there is no mount id at all', () => {
|
|
106
|
+
expect(corpusWritable([mount()], { ...dispatched, mountId: null })).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// R3-266 — WHERE an edit goes, and whether one may be offered at all.
|
|
2
|
+
//
|
|
3
|
+
// Grove ships in two packagings, and the edit verb differs between them because the
|
|
4
|
+
// AUTHORITY does, not because dispatched content is somehow less editable:
|
|
5
|
+
//
|
|
6
|
+
// • FORK — the corpus is this app's own repo, so "edit this entry" is the
|
|
7
|
+
// present→edit transition on our own source: `requestEdit({ path })`,
|
|
8
|
+
// which is **self-scoped by contract** ("v1 supports only a repo-relative
|
|
9
|
+
// path in the CURRENT repo").
|
|
10
|
+
// • DISPATCH — the corpus is a MOUNT somebody handed us. `requestEdit` there would
|
|
11
|
+
// name a path in GROVE's repo, so the same call would offer to edit the
|
|
12
|
+
// viewer instead of the wiki on screen. The right verb is the one for a
|
|
13
|
+
// file in a mount: `invokeTask('edit-file', { file: capFile(...) })`,
|
|
14
|
+
// attenuating the corpus delegation down to the single entry.
|
|
15
|
+
//
|
|
16
|
+
// The previous code withheld the affordance under dispatch and said so in a comment that
|
|
17
|
+
// was careful to call it temporary. It was still the wrong outcome: dispatch changes the
|
|
18
|
+
// PACKAGING, not the authority — the same corpus, forked, is editable — so a read-only
|
|
19
|
+
// dispatched viewer breaks packaging-is-not-trust exactly where a reader would notice.
|
|
20
|
+
//
|
|
21
|
+
// **The mount decides.** Writability is a property of the delegation's current mode, not
|
|
22
|
+
// of how the app was loaded. That is why `corpusWritable` takes the live mount list rather
|
|
23
|
+
// than the boot-time flag: a role downgrade re-announces the mount `ro`, and the
|
|
24
|
+
// affordance must disappear rather than surface `EROFS` when clicked.
|
|
25
|
+
//
|
|
26
|
+
// Pure — no SDK, no React — so all of the above is testable without a host.
|
|
27
|
+
|
|
28
|
+
import type { SandboxMount } from '@immediately-run/sdk/mounts';
|
|
29
|
+
|
|
30
|
+
/** How an edit of a content entry is delivered. */
|
|
31
|
+
export type EditTarget =
|
|
32
|
+
/** The fork: our own repo, via the self-scoped present→edit transition. */
|
|
33
|
+
| { via: 'self'; path: string }
|
|
34
|
+
/** Dispatch: one file of the delegated corpus, handed to the platform editor. */
|
|
35
|
+
| { via: 'delegate'; mountId: string; relPath: string };
|
|
36
|
+
|
|
37
|
+
export interface CorpusIdentity {
|
|
38
|
+
/** Whether the corpus is a mount rather than this app's own repo. */
|
|
39
|
+
dispatched: boolean;
|
|
40
|
+
/** The content root, with a trailing slash (`getContentRoot()`). */
|
|
41
|
+
contentRoot: string;
|
|
42
|
+
/** The corpus mount id, when dispatched (`getCorpusMountId()`). */
|
|
43
|
+
mountId: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `/app/content/x.mdx` → `content/x.mdx` — the fork's repo-relative path. */
|
|
47
|
+
export function keyToSelfPath(key: string): string {
|
|
48
|
+
return key.replace(/^\/app\//, '').replace(/^\//, '');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Where an edit of `entryKey` should go, or null when there is nowhere to send it.
|
|
53
|
+
*
|
|
54
|
+
* Null is not "read-only" — that is {@link corpusWritable}'s question. Null means the key
|
|
55
|
+
* does not name a file in this corpus at all, or a dispatched viewer has no mount id to
|
|
56
|
+
* delegate from (an older host that published the corpus without one). Either way there is
|
|
57
|
+
* nothing to offer, and offering it anyway would produce a refusal the reader must decode.
|
|
58
|
+
*/
|
|
59
|
+
export function editTarget(entryKey: string, corpus: CorpusIdentity): EditTarget | null {
|
|
60
|
+
if (typeof entryKey !== 'string' || entryKey === '') return null;
|
|
61
|
+
if (!corpus.dispatched) return { via: 'self', path: keyToSelfPath(entryKey) };
|
|
62
|
+
if (!corpus.mountId) return null;
|
|
63
|
+
if (!entryKey.startsWith(corpus.contentRoot)) return null;
|
|
64
|
+
const relPath = entryKey.slice(corpus.contentRoot.length);
|
|
65
|
+
return relPath ? { via: 'delegate', mountId: corpus.mountId, relPath } : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* May this instance offer an edit at all, given the mounts it holds RIGHT NOW?
|
|
70
|
+
*
|
|
71
|
+
* A fork asks about its working tree, as before. A dispatched viewer asks about the corpus
|
|
72
|
+
* mount — and asks the LIVE mount list, not the boot-time flag, so a live `rw → ro`
|
|
73
|
+
* downgrade (a role change the host re-announces on the same mount id) hides the
|
|
74
|
+
* affordance on the next render. That is the whole difference between "hidden because you
|
|
75
|
+
* may not" and "shown, then `EROFS` when you try".
|
|
76
|
+
*
|
|
77
|
+
* A corpus mount that has vanished from the list answers `false`: no mount, no write.
|
|
78
|
+
*/
|
|
79
|
+
export function corpusWritable(
|
|
80
|
+
mounts: readonly SandboxMount[] | null | undefined,
|
|
81
|
+
corpus: CorpusIdentity,
|
|
82
|
+
): boolean {
|
|
83
|
+
const list = mounts ?? [];
|
|
84
|
+
if (!corpus.dispatched) {
|
|
85
|
+
return list.some((m) => m.type === 'worktree' && m.mode !== 'ro');
|
|
86
|
+
}
|
|
87
|
+
if (!corpus.mountId) return false;
|
|
88
|
+
const mount = list.find((m) => (m.id ?? m.path) === corpus.mountId);
|
|
89
|
+
// `mode` is absent on the primary repo mount and rw by default elsewhere; a corpus
|
|
90
|
+
// mount that reports nothing is treated as writable exactly as `resolveOpenWiki` reads
|
|
91
|
+
// it, so the two never disagree about the same mount.
|
|
92
|
+
return !!mount && mount.mode !== 'ro';
|
|
93
|
+
}
|
|
@@ -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 {
|
package/src/lib/openWiki.test.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
2
|
import { resolveOpenWiki, OPEN_WIKI_TASK, CONTENT_MOUNT_TYPE } from './openWiki';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
getContentRoot,
|
|
5
|
+
getCorpusMountId,
|
|
6
|
+
setContentRoot,
|
|
7
|
+
resetContentRoot,
|
|
8
|
+
isDispatched,
|
|
9
|
+
APP_CONTENT_ROOT,
|
|
10
|
+
} from './contentRoot';
|
|
4
11
|
import { slugToKey, isContentEntry, homeKey, contentDir, keyToHref, sandboxPathToKey } from './content';
|
|
5
12
|
import { layoutChainForKey } from './layout';
|
|
6
13
|
import type { SandboxMount } from '@immediately-run/sdk/mounts';
|
|
@@ -13,14 +20,14 @@ afterEach(resetContentRoot);
|
|
|
13
20
|
describe('resolveOpenWiki — the delegated corpus', () => {
|
|
14
21
|
it('resolves the dir param mounted at the host-minted chroot', () => {
|
|
15
22
|
const r = resolveOpenWiki({ task: OPEN_WIKI_TASK, params: {} }, [mount('/app'), mount('/task/t1/dir')]);
|
|
16
|
-
expect(r).toEqual({ ok: true, root: '/task/t1/dir', readOnly: false, via: 'task' });
|
|
23
|
+
expect(r).toEqual({ ok: true, root: '/task/t1/dir', readOnly: false, via: 'task', mountId: '/task/t1/dir' });
|
|
17
24
|
});
|
|
18
25
|
|
|
19
26
|
it('reports a read-only delegation without refusing it', () => {
|
|
20
27
|
// Sharing a corpus read-only is legitimate — the reader still reads. Only the WRITE
|
|
21
28
|
// affordances may consult this; refusing the whole open would break the ordinary case.
|
|
22
29
|
const r = resolveOpenWiki({ task: OPEN_WIKI_TASK, params: {} }, [mount('/task/t1/dir', { mode: 'ro' })]);
|
|
23
|
-
expect(r).toEqual({ ok: true, root: '/task/t1/dir', readOnly: true, via: 'task' });
|
|
30
|
+
expect(r).toEqual({ ok: true, root: '/task/t1/dir', readOnly: true, via: 'task', mountId: '/task/t1/dir' });
|
|
24
31
|
});
|
|
25
32
|
|
|
26
33
|
it('is not a callee when there is no task input — the ordinary fork boot', () => {
|
|
@@ -48,7 +55,7 @@ describe('resolveOpenWiki — the delegated corpus', () => {
|
|
|
48
55
|
// The host owns the `/task/<slot>/<param>` grammar; if it ever renames the segment,
|
|
49
56
|
// suffix-matching alone would cancel a task the user really asked for.
|
|
50
57
|
const r = resolveOpenWiki({ task: OPEN_WIKI_TASK, params: {} }, [mount('/app'), mount('/mnt/abc123')]);
|
|
51
|
-
expect(r).toEqual({ ok: true, root: '/mnt/abc123', readOnly: false, via: 'task' });
|
|
58
|
+
expect(r).toEqual({ ok: true, root: '/mnt/abc123', readOnly: false, via: 'task', mountId: '/mnt/abc123' });
|
|
52
59
|
});
|
|
53
60
|
|
|
54
61
|
it('does not guess between two foreign mounts', () => {
|
|
@@ -72,7 +79,7 @@ describe('repo-load dispatch — a cold URL load, with no task input at all', ()
|
|
|
72
79
|
mount('/app'),
|
|
73
80
|
mount('/mnt/deadbeef', { type: CONTENT_MOUNT_TYPE, name: 'neumark/book-nine-from-here' }),
|
|
74
81
|
]);
|
|
75
|
-
expect(r).toEqual({ ok: true, root: '/mnt/deadbeef', readOnly: false, via: 'repo-load' });
|
|
82
|
+
expect(r).toEqual({ ok: true, root: '/mnt/deadbeef', readOnly: false, via: 'repo-load', mountId: '/mnt/deadbeef' });
|
|
76
83
|
});
|
|
77
84
|
|
|
78
85
|
it('carries a read-only delegation through', () => {
|
|
@@ -214,3 +221,37 @@ describe('routing — the URL space follows the packaging', () => {
|
|
|
214
221
|
expect(key).toBe('/task/t1/dir/app/content/home.mdx');
|
|
215
222
|
});
|
|
216
223
|
});
|
|
224
|
+
|
|
225
|
+
// R3-266 — the corpus mount ID, which is what an onward delegation NAMES. Without it a
|
|
226
|
+
// dispatched viewer can locate the corpus and still not hand one of its files to the
|
|
227
|
+
// platform editor, which is the whole of the dispatched write path.
|
|
228
|
+
describe('resolveOpenWiki — the corpus mount id (the onward-delegation handle)', () => {
|
|
229
|
+
it('prefers the host-published id over the path', () => {
|
|
230
|
+
const r = resolveOpenWiki({ task: OPEN_WIKI_TASK, params: {} }, [
|
|
231
|
+
mount('/task/t1/dir', { id: 'space:abc' }),
|
|
232
|
+
]);
|
|
233
|
+
expect(r).toMatchObject({ ok: true, mountId: 'space:abc' });
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('falls back to the mount PATH, which is exactly what the host publishes for a chroot', () => {
|
|
237
|
+
// `mintDelegations` names the descriptor `{ path, type: 'task-delegation', id: path }`,
|
|
238
|
+
// so path and id coincide for a task delegation — the fallback is the same answer, not
|
|
239
|
+
// a guess, and it keeps working against a host that publishes no id at all.
|
|
240
|
+
const r = resolveOpenWiki({ task: OPEN_WIKI_TASK, params: {} }, [mount('/task/t1/dir')]);
|
|
241
|
+
expect(r).toMatchObject({ ok: true, mountId: '/task/t1/dir' });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('carries the id through the repo-load branch too', () => {
|
|
245
|
+
const r = resolveOpenWiki(null, [
|
|
246
|
+
mount('/mnt/deadbeef', { type: CONTENT_MOUNT_TYPE, id: 'github:neumark/book@main' }),
|
|
247
|
+
]);
|
|
248
|
+
expect(r).toMatchObject({ ok: true, via: 'repo-load', mountId: 'github:neumark/book@main' });
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('reaches the contentRoot module, so the affordance can read it back', () => {
|
|
252
|
+
setContentRoot('/task/t1/dir', { readOnly: false, mountId: 'space:abc' });
|
|
253
|
+
expect(getCorpusMountId()).toBe('space:abc');
|
|
254
|
+
resetContentRoot();
|
|
255
|
+
expect(getCorpusMountId()).toBeNull();
|
|
256
|
+
});
|
|
257
|
+
});
|
package/src/lib/openWiki.ts
CHANGED
|
@@ -25,7 +25,16 @@ export const DIR_PARAM = 'dir';
|
|
|
25
25
|
export const CONTENT_MOUNT_TYPE = 'content';
|
|
26
26
|
|
|
27
27
|
export type OpenWikiResolution =
|
|
28
|
-
| {
|
|
28
|
+
| {
|
|
29
|
+
ok: true;
|
|
30
|
+
root: string;
|
|
31
|
+
readOnly: boolean;
|
|
32
|
+
via: 'task' | 'repo-load';
|
|
33
|
+
/** The corpus mount's id — what an onward delegation names (R3-266). Falls back to
|
|
34
|
+
* the mount PATH, which is exactly what the host publishes as the id for a task
|
|
35
|
+
* chroot (`mintDelegations` uses the mount point as the descriptor id). */
|
|
36
|
+
mountId: string;
|
|
37
|
+
}
|
|
29
38
|
| { ok: false; reason: 'not-a-callee' | 'wrong-task' | 'no-mount' };
|
|
30
39
|
|
|
31
40
|
/**
|
|
@@ -53,7 +62,13 @@ export function resolveOpenWiki(
|
|
|
53
62
|
// input and would otherwise fall out as `not-a-callee` and render our own corpus.
|
|
54
63
|
const marked = mounts.find((m) => m.type === CONTENT_MOUNT_TYPE);
|
|
55
64
|
if (marked) {
|
|
56
|
-
return {
|
|
65
|
+
return {
|
|
66
|
+
ok: true,
|
|
67
|
+
root: marked.path,
|
|
68
|
+
readOnly: marked.mode === 'ro',
|
|
69
|
+
via: 'repo-load',
|
|
70
|
+
mountId: marked.id ?? marked.path,
|
|
71
|
+
};
|
|
57
72
|
}
|
|
58
73
|
|
|
59
74
|
if (!input) return { ok: false, reason: 'not-a-callee' };
|
|
@@ -67,7 +82,7 @@ export function resolveOpenWiki(
|
|
|
67
82
|
// `mode` is absent on the primary repo mount and rw by default elsewhere. A read-only
|
|
68
83
|
// delegation is a legitimate way to share a corpus — the reader still reads — so it
|
|
69
84
|
// resolves normally and only the WRITE affordances consult this flag.
|
|
70
|
-
return { ok: true, root: hit.path, readOnly: hit.mode === 'ro', via: 'task' };
|
|
85
|
+
return { ok: true, root: hit.path, readOnly: hit.mode === 'ro', via: 'task', mountId: hit.id ?? hit.path };
|
|
71
86
|
}
|
|
72
87
|
|
|
73
88
|
/** The message a failed resolution should show, in the reader's terms rather than the
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// The bucket-B gate + the value-3 invariant (R3-309): page variants are CSS
|
|
2
|
+
// selected by data attributes, so the checks are consistency — every variant the
|
|
3
|
+
// CSS implements is reachable, every reachable value has a rule, an undeclared
|
|
4
|
+
// value falls back — plus the promise that deleting every `_layout.mdx` still
|
|
5
|
+
// yields the full default shell.
|
|
6
|
+
import { describe, it, expect } from 'vitest';
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
10
|
+
import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
|
|
11
|
+
import DefaultLayout from '../components/DefaultLayout';
|
|
12
|
+
import { resolveNavMode, resolvePageLayout } from './layout';
|
|
13
|
+
import { GroveShellContext, OutletContext, type GroveShell } from './shell';
|
|
14
|
+
|
|
15
|
+
const css = readFileSync(join(process.cwd(), 'src', 'GroveApp.css'), 'utf8');
|
|
16
|
+
|
|
17
|
+
const dataLayoutRules = new Set([...css.matchAll(/\[data-layout="([^"]+)"\]/g)].map((m) => m[1]!));
|
|
18
|
+
const dataNavRules = new Set([...css.matchAll(/\[data-nav="([^"]+)"\]/g)].map((m) => m[1]!));
|
|
19
|
+
|
|
20
|
+
describe('page variants — every declared value has a rule, every rule is reachable (bucket B)', () => {
|
|
21
|
+
it('the CSS implements exactly the three layout variants the engine can emit', () => {
|
|
22
|
+
// resolvePageLayout is the closed emitter: doc | post | full. A CSS rule for a
|
|
23
|
+
// fourth value would be dead selectors; an emitter value without a rule would
|
|
24
|
+
// be a silently unstyled page. Pinned together so neither can drift alone.
|
|
25
|
+
expect(dataLayoutRules).toEqual(new Set(['doc', 'post', 'full']));
|
|
26
|
+
for (const v of ['doc', 'post', 'full'] as const) {
|
|
27
|
+
expect(resolvePageLayout({ layout: v })).toBe(v);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('an undeclared layout value falls back to the reference look, never an unstyled page', () => {
|
|
32
|
+
expect(resolvePageLayout(undefined)).toBe('doc');
|
|
33
|
+
expect(resolvePageLayout({})).toBe('doc');
|
|
34
|
+
expect(resolvePageLayout({ layout: 'wide-but-unimplemented' })).toBe('doc');
|
|
35
|
+
expect(resolvePageLayout({ layout: 7 })).toBe('doc');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('`frame: none` is the fourth variant and needs no layout rule — it removes the shell', () => {
|
|
39
|
+
// Bare-by-frontmatter is handled in GroveWiki (frameNone drops the chain); it
|
|
40
|
+
// selects no [data-layout] variant, so its absence from the CSS set is the
|
|
41
|
+
// contract, asserted here so a future "bare" CSS rule is a visible decision.
|
|
42
|
+
expect(dataLayoutRules.has('none')).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('nav arrangement — selectable, with both polarities real (R3-309)', () => {
|
|
47
|
+
it("'side' carries CSS rules; 'top' is the base .grove-shell grid they override", () => {
|
|
48
|
+
// The top arrangement is what .grove-shell IS (base grid); [data-nav='side'] is
|
|
49
|
+
// the variant. So the consistency check is: side has rules, and the base
|
|
50
|
+
// block exists for top to fall through to.
|
|
51
|
+
expect(dataNavRules.has('side')).toBe(true);
|
|
52
|
+
expect(css).toMatch(/^\.grove-shell\s*\{/m);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('resolveNavMode: declared top selects top; anything else falls back to side', () => {
|
|
56
|
+
expect(resolveNavMode({ nav: 'top' })).toBe('top');
|
|
57
|
+
expect(resolveNavMode({ nav: 'side' })).toBe('side');
|
|
58
|
+
expect(resolveNavMode(undefined)).toBe('side');
|
|
59
|
+
expect(resolveNavMode({ nav: 'diagonal' })).toBe('side');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('deleting every _layout.mdx still yields the full default shell (value 3)', () => {
|
|
64
|
+
it('DefaultLayout renders nav, sidebar, footer AND frames a page at Outlet', () => {
|
|
65
|
+
// Asserted rather than assumed: the whole starter catalogue rests on the
|
|
66
|
+
// promise that starters are a convenience — remove them all and a bare folder
|
|
67
|
+
// of .mdx is still a whole site.
|
|
68
|
+
const SHELL = { navMode: 'side', vw: 'desktop', navItems: [] } as unknown as GroveShell;
|
|
69
|
+
const NAV = {
|
|
70
|
+
outerHref: 'https://example.immediately.run/app/x',
|
|
71
|
+
navigationState: { sandboxPath: '/app/x' },
|
|
72
|
+
};
|
|
73
|
+
const html = renderToStaticMarkup(
|
|
74
|
+
<TinkerableContext.Provider value={NAV as never}>
|
|
75
|
+
<GroveShellContext.Provider value={SHELL}>
|
|
76
|
+
<OutletContext.Provider value={<p>THE-PAGE</p>}>
|
|
77
|
+
<DefaultLayout />
|
|
78
|
+
</OutletContext.Provider>
|
|
79
|
+
</GroveShellContext.Provider>
|
|
80
|
+
</TinkerableContext.Provider>,
|
|
81
|
+
);
|
|
82
|
+
expect(html).toContain('grove-nav');
|
|
83
|
+
expect(html).toContain('grove-sidebar');
|
|
84
|
+
expect(html).toContain('grove-footer');
|
|
85
|
+
expect(html).toContain('THE-PAGE');
|
|
86
|
+
});
|
|
87
|
+
});
|
package/src/lib/queries.test.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
familyTreeQuery,
|
|
4
|
+
matchesQuery,
|
|
5
|
+
navQuery,
|
|
6
|
+
searchQuery,
|
|
7
|
+
sidebarQuery,
|
|
8
|
+
toSearchEntries,
|
|
9
|
+
} from './queries';
|
|
3
10
|
|
|
4
11
|
// The metadata queries select records (R3-276a). These tests pin the selections
|
|
5
12
|
// themselves; the components are thin wiring around them. The store shape is the
|
|
@@ -26,11 +33,36 @@ describe('navQuery', () => {
|
|
|
26
33
|
expect(navQuery({ '/app/content/a.mdx': { title: 'A.', tags: ['ui/nav'] } })[0].label).toBe('A');
|
|
27
34
|
});
|
|
28
35
|
|
|
36
|
+
it('a label is the PLAIN form — frontmatter markers dropped, content kept (R3-531)', () => {
|
|
37
|
+
const rows = navQuery({ '/app/content/a.mdx': { title: 'Run `x` now.', tags: ['ui/nav'] } });
|
|
38
|
+
expect(rows[0]!.label).toBe('Run x now');
|
|
39
|
+
// An explicit `nav` is author intent and passes through the plain form too —
|
|
40
|
+
// a stray marker there gets the same treatment one level down.
|
|
41
|
+
expect(navQuery({ '/app/content/b.mdx': { title: 'T', nav: 'The **big** door', tags: ['ui/nav'] } })[0].label).toBe('The big door');
|
|
42
|
+
});
|
|
43
|
+
|
|
29
44
|
it('never tab-encodes (the R3-276a regression: fake paths are gone)', () => {
|
|
30
45
|
for (const r of navQuery(fm)) expect(JSON.stringify(r)).not.toContain('\t');
|
|
31
46
|
});
|
|
32
47
|
});
|
|
33
48
|
|
|
49
|
+
describe('the ⌘K index and matcher (R3-531)', () => {
|
|
50
|
+
it('toSearchEntries keeps the RAW title for rendering beside the plain form for matching', () => {
|
|
51
|
+
const [e] = toSearchEntries([{ path: '/app/content/a.mdx', title: 'A `cache.yml` entry.', desc: 'About **keys**.' }]);
|
|
52
|
+
expect(e.title).toBe('A `cache.yml` entry.');
|
|
53
|
+
expect(e.plain).toBe('A cache.yml entry');
|
|
54
|
+
expect(e.desc).toBe('About keys.');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('matchesQuery reads the plain forms — a query typed over what the reader sees hits', () => {
|
|
58
|
+
const [e] = toSearchEntries([{ path: '/app/content/a.mdx', title: 'A `cache.yml` entry.', desc: '' }]);
|
|
59
|
+
expect(matchesQuery(e, 'cache.yml')).toBe(true);
|
|
60
|
+
expect(matchesQuery(e, '`cache`')).toBe(false); // the raw markers are not searchable text
|
|
61
|
+
expect(matchesQuery(e, 'entry')).toBe(true);
|
|
62
|
+
expect(matchesQuery(e, 'zzz')).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
34
66
|
describe('searchQuery', () => {
|
|
35
67
|
it('indexes every content entry with title/desc and non-ui tags', () => {
|
|
36
68
|
const rows = searchQuery(fm);
|
package/src/lib/queries.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// Every record field is derived data; `path`/`meta` are applied by the hook.
|
|
12
12
|
|
|
13
13
|
import { isContentEntry } from './content';
|
|
14
|
+
import { plainProse } from '@immediately-run/mdx-plugins';
|
|
14
15
|
|
|
15
16
|
/** A nav item derived from `ui/nav` frontmatter. */
|
|
16
17
|
export type NavRecord = {
|
|
@@ -24,7 +25,38 @@ export function navQuery(fm: Record<string, any>): NavRecord[] {
|
|
|
24
25
|
return Object.keys(fm)
|
|
25
26
|
.filter((p) => isContentEntry(p) && Array.isArray(fm[p]?.tags) && fm[p].tags.includes('ui/nav'))
|
|
26
27
|
.sort((a, b) => (fm[a].order ?? 999) - (fm[b].order ?? 999))
|
|
27
|
-
.map((p) => ({ path: p, label: fm[p]?.nav ||
|
|
28
|
+
.map((p) => ({ path: p, label: plainLabel(fm[p]?.nav || fm[p]?.title || '') }));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A field bound for an attribute, a label, a prompt or an index — the plain
|
|
32
|
+
* form (R3-531): markers dropped, content kept, then the period trimmed. */
|
|
33
|
+
export function plainLabel(s: string): string {
|
|
34
|
+
return plainProse(s).replace(/\.$/, '');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One searchable entry: the RAW title (the palette renders it as inline prose)
|
|
38
|
+
* beside the plain form the matcher reads. */
|
|
39
|
+
export type SearchEntry = {
|
|
40
|
+
key: string;
|
|
41
|
+
title: string;
|
|
42
|
+
plain: string;
|
|
43
|
+
desc: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** The ⌘K index over search records — pure, so the plain/raw split is testable. */
|
|
47
|
+
export function toSearchEntries(rows: { path: string; title: string; desc: string }[]): SearchEntry[] {
|
|
48
|
+
return rows.map(({ path, title, desc }) => ({
|
|
49
|
+
key: path,
|
|
50
|
+
title: title || path,
|
|
51
|
+
plain: plainLabel(title || path),
|
|
52
|
+
desc: plainProse(desc || ''),
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Match an entry against a lowercased query on its PLAIN forms — matching the
|
|
57
|
+
* raw markdown would miss queries typed over what the reader actually sees. */
|
|
58
|
+
export function matchesQuery(e: SearchEntry, ql: string): boolean {
|
|
59
|
+
return e.plain.toLowerCase().includes(ql) || e.desc.toLowerCase().includes(ql) || e.key.toLowerCase().includes(ql);
|
|
28
60
|
}
|
|
29
61
|
|
|
30
62
|
/** One search index row. `tags` excludes the `ui/*` furniture namespace. */
|