@puredesktop/puredesktop-ui-bridge 2.1.6 → 2.1.8
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/package.json +55 -1
- package/src/bridge/agentTypes.ts +21 -1
- package/src/bridge/collectionImagePaste.ts +19 -1
- package/src/bridge/context.mjs +32 -0
- package/src/bridge/documents.d.mts +76 -0
- package/src/bridge/documents.mjs +40 -0
- package/src/bridge/methods.d.mts +23 -0
- package/src/bridge/methods.mjs +23 -0
- package/src/bridge/pureRender/base.css +44 -0
- package/src/bridge/pureRender/document.ts +29 -2
- package/src/bridge/pureRender/index.ts +3 -0
- package/src/bridge/pureRender/normalize.ts +167 -0
- package/src/bridge/pureRender/sanitizeExportBody.test.ts +51 -0
- package/src/bridge/pureRender/sanitizeExportBody.ts +61 -0
- package/src/bridge/pureRender/styles.ts +125 -5
- package/src/bridge/pureRender/theme.test.ts +259 -0
- package/src/bridge/pureRender/theme.ts +19 -1
- package/src/bridge/pureRender/types.ts +33 -0
- package/src/bridge/pureRender/visualLayoutInspection.test.ts +26 -0
- package/src/bridge/pureRender/visualLayoutInspection.ts +75 -0
- package/src/bridge/react/useDocumentHotkeys.ts +41 -0
- package/src/bridge/react/useDocumentLifecycle.tsx +359 -0
- package/src/bridge/react/usePlatformAppSettings.test.tsx +105 -0
- package/src/bridge/react/usePlatformAppSettings.tsx +104 -0
- package/src/bridge/react/usePlatformJsonStore.test.tsx +152 -0
- package/src/bridge/react/usePlatformJsonStore.tsx +149 -0
- package/src/bridge/storage.d.mts +3 -1
- package/src/bridge/storage.test.ts +2 -2
- package/src/bridge/types.ts +7 -0
- package/src/components/agents/AgentDrawerPanel.tsx +2 -0
- package/src/components/agents/AgentMessageList.tsx +6 -1
- package/src/components/agents/AgentToolCallCard.tsx +20 -20
- package/src/components/agents/ContextPicker.tsx +239 -0
- package/src/components/agents/agentPanelStyles.ts +3 -4
- package/src/components/agents/agentTypes.ts +2 -0
- package/src/components/chrome/WorkspaceTabStrip.tsx +18 -1
- package/src/components/chrome/workspaceTabTypes.ts +2 -0
- package/src/components/common/containers/AppFrame.test.tsx +4 -4
- package/src/components/common/containers/AppFrame.tsx +10 -1
- package/src/components/common/containers/AppHeader.tsx +22 -0
- package/src/components/common/documents/DocumentHeaderActions.tsx +206 -0
- package/src/components/common/documents/DocumentSwitcher.tsx +1176 -0
- package/src/components/common/documents/index.ts +8 -0
- package/src/components/common/research/EvidenceDossier.tsx +1232 -150
- package/src/components/common/research/index.ts +2 -0
- package/src/components/print-design/PrintDesignPanel.test.tsx +430 -40
- package/src/components/print-design/PrintDesignPanel.tsx +1770 -207
- package/src/components/print-design/index.ts +2 -0
- package/src/components/print-design/previewContent.test.ts +32 -0
- package/src/components/print-design/previewContent.ts +31 -0
- package/src/context/buildContextDocument.test.ts +240 -0
- package/src/context/buildContextDocument.ts +309 -0
- package/src/context/contextAccess.ts +66 -0
- package/src/context/contextConfig.ts +72 -0
- package/src/context/contextDocument.test.ts +155 -0
- package/src/context/contextDocument.ts +300 -0
- package/src/context/contextSections.test.ts +88 -0
- package/src/context/contextSections.ts +84 -0
- package/src/context/htmlToContextMarkdown.test.ts +134 -0
- package/src/context/htmlToContextMarkdown.ts +369 -0
- package/src/theme/appAccents.test.ts +36 -35
- package/src/theme/appAccents.ts +87 -104
- package/src/theme/appIdentityCss.mjs +169 -231
- package/src/theme/appIdentityCss.ts +213 -233
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarative context configuration — the `app.context` block in plugin.json.
|
|
3
|
+
*
|
|
4
|
+
* Each app declares how its package objects map to a context document; the
|
|
5
|
+
* generic builder (`buildContextDocument`) interprets this config. The shell
|
|
6
|
+
* stays free of app-specific logic the same way catalog routing does with
|
|
7
|
+
* `openable*` and `createEntities` metadata.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface PlatformContextContentSource {
|
|
11
|
+
/** Literal content file path relative to the package root. */
|
|
12
|
+
file?: string
|
|
13
|
+
/** Optional heading for a literal file's section (defaults to file name). */
|
|
14
|
+
section?: string
|
|
15
|
+
/**
|
|
16
|
+
* Read an ordered list of content entries from a manifest key instead of a
|
|
17
|
+
* literal path (e.g. book chapters). The key addresses an array of records.
|
|
18
|
+
*/
|
|
19
|
+
fromManifestList?: string
|
|
20
|
+
/** Record key holding each entry's file path (default: 'file'). */
|
|
21
|
+
pathKey?: string
|
|
22
|
+
/** Record key holding each entry's section title (default: 'title'). */
|
|
23
|
+
titleKey?: string
|
|
24
|
+
/** Directory the list entries' paths are relative to, e.g. 'chapters'. */
|
|
25
|
+
baseDir?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PlatformAppContextManifest {
|
|
29
|
+
/** Object kind — also the context file suffix: `<name>.<kind>.md`. */
|
|
30
|
+
kind: string
|
|
31
|
+
/** Package folder suffix this config applies to, e.g. '.manuscript'. */
|
|
32
|
+
packageSuffix: string
|
|
33
|
+
/** Manifest file inside the package (json or yaml by extension). */
|
|
34
|
+
manifestFile: string
|
|
35
|
+
/** Ordered content sources composing the body. */
|
|
36
|
+
content: PlatformContextContentSource[]
|
|
37
|
+
/** Assets directory relative to the package root, e.g. 'assets'. */
|
|
38
|
+
assetsDir?: string
|
|
39
|
+
/** Dot path to the object title inside the manifest (default: 'title'). */
|
|
40
|
+
titlePath?: string
|
|
41
|
+
/** Dot path to a stable object id (default: package folder base name). */
|
|
42
|
+
idPath?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Read a dot-path string value from a parsed manifest. */
|
|
46
|
+
export function manifestStringAt(
|
|
47
|
+
manifest: unknown,
|
|
48
|
+
path: string | undefined,
|
|
49
|
+
): string | undefined {
|
|
50
|
+
if (!path) return undefined
|
|
51
|
+
let current: unknown = manifest
|
|
52
|
+
for (const key of path.split('.')) {
|
|
53
|
+
if (typeof current !== 'object' || current === null) return undefined
|
|
54
|
+
current = (current as Record<string, unknown>)[key]
|
|
55
|
+
}
|
|
56
|
+
return typeof current === 'string' && current.length > 0
|
|
57
|
+
? current
|
|
58
|
+
: undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'bmp', 'tiff'])
|
|
62
|
+
|
|
63
|
+
/** Guess a context link kind from a file extension. */
|
|
64
|
+
export function contextLinkKindForFile(fileName: string): string {
|
|
65
|
+
const extension = fileName.split('.').pop()?.toLowerCase() ?? ''
|
|
66
|
+
if (IMAGE_EXTENSIONS.has(extension)) return 'image'
|
|
67
|
+
if (extension === 'svg') return 'vector'
|
|
68
|
+
if (extension === 'mmd' || extension === 'mermaid') return 'mermaid'
|
|
69
|
+
if (extension === 'tex') return 'math'
|
|
70
|
+
if (extension === 'csv' || extension === 'tsv' || extension === 'json') return 'data'
|
|
71
|
+
return 'binary'
|
|
72
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
PLATFORM_CONTEXT_SCHEMA_VERSION,
|
|
5
|
+
PlatformContextParseError,
|
|
6
|
+
contextFileName,
|
|
7
|
+
isContextFileName,
|
|
8
|
+
parseContextDocument,
|
|
9
|
+
serializeContextDocument,
|
|
10
|
+
type PlatformContextDocument,
|
|
11
|
+
} from './contextDocument.js'
|
|
12
|
+
|
|
13
|
+
function sampleDocument(): PlatformContextDocument {
|
|
14
|
+
return {
|
|
15
|
+
envelope: {
|
|
16
|
+
schemaVersion: PLATFORM_CONTEXT_SCHEMA_VERSION,
|
|
17
|
+
kind: 'manuscript',
|
|
18
|
+
appSlug: 'puremanuscript',
|
|
19
|
+
objectId: 'ms-001',
|
|
20
|
+
title: 'Effects of X on Y',
|
|
21
|
+
source: {
|
|
22
|
+
path: '/Users/example/MyPaper.manuscript',
|
|
23
|
+
manifestFile: 'manuscript.json',
|
|
24
|
+
contentFiles: ['manuscript.html'],
|
|
25
|
+
},
|
|
26
|
+
sourceHash: 'sha256:abc123',
|
|
27
|
+
generatedAt: '2026-07-02T10:00:00.000Z',
|
|
28
|
+
generator: 'on-access',
|
|
29
|
+
links: [
|
|
30
|
+
{ relativePath: 'assets/fig-01.png', kind: 'image', label: 'Figure 1' },
|
|
31
|
+
{ relativePath: 'references.json' },
|
|
32
|
+
],
|
|
33
|
+
sections: [
|
|
34
|
+
{ id: 'abstract', title: 'Abstract', level: 2, startLine: 2, endLine: 6 },
|
|
35
|
+
{ id: 'body', title: 'Body', level: 2, startLine: 6, endLine: 40 },
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
body: '# Effects of X on Y\n\n## Abstract\n\nText.\n',
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('serializeContextDocument', () => {
|
|
43
|
+
it('is deterministic', () => {
|
|
44
|
+
const document = sampleDocument()
|
|
45
|
+
expect(serializeContextDocument(document)).toBe(
|
|
46
|
+
serializeContextDocument(document),
|
|
47
|
+
)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('emits front matter followed by the body', () => {
|
|
51
|
+
const raw = serializeContextDocument(sampleDocument())
|
|
52
|
+
expect(raw.startsWith('---\n')).toBe(true)
|
|
53
|
+
expect(raw).toContain('\n---\n\n# Effects of X on Y')
|
|
54
|
+
expect(raw.endsWith('\n')).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('omits empty optional fields', () => {
|
|
58
|
+
const document = sampleDocument()
|
|
59
|
+
delete document.envelope.sourceHash
|
|
60
|
+
document.envelope.links = []
|
|
61
|
+
const raw = serializeContextDocument(document)
|
|
62
|
+
expect(raw).not.toContain('sourceHash')
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('parseContextDocument', () => {
|
|
67
|
+
it('round-trips the envelope and body', () => {
|
|
68
|
+
const document = sampleDocument()
|
|
69
|
+
const parsed = parseContextDocument(serializeContextDocument(document))
|
|
70
|
+
expect(parsed.envelope).toEqual(document.envelope)
|
|
71
|
+
expect(parsed.body).toBe('# Effects of X on Y\n\n## Abstract\n\nText.\n')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('rejects documents without front matter', () => {
|
|
75
|
+
expect(() => parseContextDocument('# Just markdown\n')).toThrow(
|
|
76
|
+
PlatformContextParseError,
|
|
77
|
+
)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('rejects unterminated front matter', () => {
|
|
81
|
+
expect(() => parseContextDocument('---\nkind: manuscript\n')).toThrow(
|
|
82
|
+
/not closed/,
|
|
83
|
+
)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('rejects unsupported schema versions', () => {
|
|
87
|
+
const raw = serializeContextDocument(sampleDocument()).replace(
|
|
88
|
+
'schemaVersion: 1',
|
|
89
|
+
'schemaVersion: 99',
|
|
90
|
+
)
|
|
91
|
+
expect(() => parseContextDocument(raw)).toThrow(/schemaVersion/)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('rejects missing required strings', () => {
|
|
95
|
+
const raw = serializeContextDocument(sampleDocument()).replace(
|
|
96
|
+
'appSlug: puremanuscript\n',
|
|
97
|
+
'',
|
|
98
|
+
)
|
|
99
|
+
expect(() => parseContextDocument(raw)).toThrow(/appSlug/)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('rejects unknown generators', () => {
|
|
103
|
+
const raw = serializeContextDocument(sampleDocument()).replace(
|
|
104
|
+
'generator: on-access',
|
|
105
|
+
'generator: llm-magic',
|
|
106
|
+
)
|
|
107
|
+
expect(() => parseContextDocument(raw)).toThrow(/generator/)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('rejects links without relativePath', () => {
|
|
111
|
+
const raw = serializeContextDocument(sampleDocument()).replace(
|
|
112
|
+
'relativePath: assets/fig-01.png',
|
|
113
|
+
'wrongKey: assets/fig-01.png',
|
|
114
|
+
)
|
|
115
|
+
expect(() => parseContextDocument(raw)).toThrow(/links\[0\]/)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('defaults links and sections to empty lists', () => {
|
|
119
|
+
const raw = [
|
|
120
|
+
'---',
|
|
121
|
+
'schemaVersion: 1',
|
|
122
|
+
'kind: manuscript',
|
|
123
|
+
'appSlug: puremanuscript',
|
|
124
|
+
'objectId: ms-001',
|
|
125
|
+
'title: T',
|
|
126
|
+
'source:',
|
|
127
|
+
' path: /tmp/x.manuscript',
|
|
128
|
+
'generatedAt: 2026-07-02T10:00:00.000Z',
|
|
129
|
+
'generator: on-access',
|
|
130
|
+
'---',
|
|
131
|
+
'',
|
|
132
|
+
'Body.',
|
|
133
|
+
'',
|
|
134
|
+
].join('\n')
|
|
135
|
+
const parsed = parseContextDocument(raw)
|
|
136
|
+
expect(parsed.envelope.links).toEqual([])
|
|
137
|
+
expect(parsed.envelope.sections).toEqual([])
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('context file naming', () => {
|
|
142
|
+
it('builds <name>.<kind>.md', () => {
|
|
143
|
+
expect(contextFileName('MyPaper', 'manuscript')).toBe(
|
|
144
|
+
'MyPaper.manuscript.md',
|
|
145
|
+
)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('matches context file names', () => {
|
|
149
|
+
expect(isContextFileName('MyPaper.manuscript.md')).toBe(true)
|
|
150
|
+
expect(isContextFileName('MyPaper.manuscript.md', 'manuscript')).toBe(true)
|
|
151
|
+
expect(isContextFileName('MyPaper.manuscript.md', 'book')).toBe(false)
|
|
152
|
+
expect(isContextFileName('notes.md')).toBe(false)
|
|
153
|
+
expect(isContextFileName('manuscript.html')).toBe(false)
|
|
154
|
+
})
|
|
155
|
+
})
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform context document — the agent-facing markdown representation of one
|
|
3
|
+
* app object (`<name>.<kind>.md`, e.g. `MyPaper.manuscript.md`).
|
|
4
|
+
*
|
|
5
|
+
* The envelope (YAML front-matter) is platform-owned and fixed here; the
|
|
6
|
+
* markdown body is app-owned. See CONTEXT_FILES_PLAN.md.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
|
|
10
|
+
|
|
11
|
+
export const PLATFORM_CONTEXT_SCHEMA_VERSION = 1
|
|
12
|
+
|
|
13
|
+
/** Marker for how the current file contents were produced. */
|
|
14
|
+
export type PlatformContextGenerator = 'on-access' | 'app-save' | 'enrichment'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Durable reference to an asset or binary belonging to the object.
|
|
18
|
+
* `relativePath` is relative to the object package root (`source.path`) —
|
|
19
|
+
* the same `(collectionPath, relativePath)` tuple the assets bridge uses.
|
|
20
|
+
* Never store ephemeral preview URLs here; they expire.
|
|
21
|
+
*/
|
|
22
|
+
export interface PlatformContextLink {
|
|
23
|
+
relativePath: string
|
|
24
|
+
/** Asset kind hint, e.g. 'image' | 'mermaid' | 'table' | 'math' | 'binary'. */
|
|
25
|
+
kind?: string
|
|
26
|
+
label?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One addressable section of the body. `id` is the durable key for segmented
|
|
31
|
+
* reads; line ranges are advisory and recomputed on every regeneration.
|
|
32
|
+
*/
|
|
33
|
+
export interface PlatformContextSection {
|
|
34
|
+
id: string
|
|
35
|
+
title: string
|
|
36
|
+
/** Heading level, 1–6. */
|
|
37
|
+
level: number
|
|
38
|
+
/** First body line of the section (0-based, inclusive), i.e. the heading line. */
|
|
39
|
+
startLine: number
|
|
40
|
+
/** End body line (0-based, exclusive). */
|
|
41
|
+
endLine: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Where the object lives on disk and which files the body was derived from. */
|
|
45
|
+
export interface PlatformContextSource {
|
|
46
|
+
/** Absolute path of the package folder (the assets collectionPath). */
|
|
47
|
+
path: string
|
|
48
|
+
manifestFile?: string
|
|
49
|
+
contentFiles?: string[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface PlatformContextEnvelope {
|
|
53
|
+
schemaVersion: number
|
|
54
|
+
/** App-declared object kind, e.g. 'manuscript' | 'book' | 'document'. */
|
|
55
|
+
kind: string
|
|
56
|
+
appSlug: string
|
|
57
|
+
/** Stable object id (manifest id or store record id). */
|
|
58
|
+
objectId: string
|
|
59
|
+
title: string
|
|
60
|
+
source: PlatformContextSource
|
|
61
|
+
/** Hash of manifest + content files; drives staleness. */
|
|
62
|
+
sourceHash?: string
|
|
63
|
+
/**
|
|
64
|
+
* Cheap mtime+size fingerprint of the same file set — staleness checks
|
|
65
|
+
* short-circuit on this before re-hashing content (large packages).
|
|
66
|
+
*/
|
|
67
|
+
sourceFingerprint?: string
|
|
68
|
+
/** ISO 8601 timestamp of generation. */
|
|
69
|
+
generatedAt: string
|
|
70
|
+
generator: PlatformContextGenerator
|
|
71
|
+
links: PlatformContextLink[]
|
|
72
|
+
sections: PlatformContextSection[]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface PlatformContextDocument {
|
|
76
|
+
envelope: PlatformContextEnvelope
|
|
77
|
+
/** Markdown body — app-owned schema. */
|
|
78
|
+
body: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class PlatformContextParseError extends Error {
|
|
82
|
+
constructor(message: string) {
|
|
83
|
+
super(message)
|
|
84
|
+
this.name = 'PlatformContextParseError'
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const FRONT_MATTER_OPEN = '---\n'
|
|
89
|
+
const FRONT_MATTER_CLOSE = '\n---\n'
|
|
90
|
+
|
|
91
|
+
const CONTEXT_GENERATORS: readonly PlatformContextGenerator[] = [
|
|
92
|
+
'on-access',
|
|
93
|
+
'app-save',
|
|
94
|
+
'enrichment',
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
/** Canonical context file name for an object: `<baseName>.<kind>.md`. */
|
|
98
|
+
export function contextFileName(baseName: string, kind: string): string {
|
|
99
|
+
return `${baseName}.${kind}.md`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** True when a file name matches the `<name>.<kind>.md` context convention. */
|
|
103
|
+
export function isContextFileName(fileName: string, kind?: string): boolean {
|
|
104
|
+
if (kind) return fileName.endsWith(`.${kind}.md`)
|
|
105
|
+
const parts = fileName.split('.')
|
|
106
|
+
return parts.length >= 3 && parts[parts.length - 1] === 'md'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Serialize an envelope + body into the on-disk markdown document.
|
|
111
|
+
* Envelope keys are emitted in a fixed order so output is deterministic.
|
|
112
|
+
*/
|
|
113
|
+
export function serializeContextDocument(
|
|
114
|
+
document: PlatformContextDocument,
|
|
115
|
+
): string {
|
|
116
|
+
const { envelope, body } = document
|
|
117
|
+
const ordered: Record<string, unknown> = {
|
|
118
|
+
schemaVersion: envelope.schemaVersion,
|
|
119
|
+
kind: envelope.kind,
|
|
120
|
+
appSlug: envelope.appSlug,
|
|
121
|
+
objectId: envelope.objectId,
|
|
122
|
+
title: envelope.title,
|
|
123
|
+
source: {
|
|
124
|
+
path: envelope.source.path,
|
|
125
|
+
...(envelope.source.manifestFile
|
|
126
|
+
? { manifestFile: envelope.source.manifestFile }
|
|
127
|
+
: {}),
|
|
128
|
+
...(envelope.source.contentFiles?.length
|
|
129
|
+
? { contentFiles: envelope.source.contentFiles }
|
|
130
|
+
: {}),
|
|
131
|
+
},
|
|
132
|
+
...(envelope.sourceHash ? { sourceHash: envelope.sourceHash } : {}),
|
|
133
|
+
...(envelope.sourceFingerprint
|
|
134
|
+
? { sourceFingerprint: envelope.sourceFingerprint }
|
|
135
|
+
: {}),
|
|
136
|
+
generatedAt: envelope.generatedAt,
|
|
137
|
+
generator: envelope.generator,
|
|
138
|
+
links: envelope.links.map(link => ({
|
|
139
|
+
relativePath: link.relativePath,
|
|
140
|
+
...(link.kind ? { kind: link.kind } : {}),
|
|
141
|
+
...(link.label ? { label: link.label } : {}),
|
|
142
|
+
})),
|
|
143
|
+
sections: envelope.sections.map(section => ({
|
|
144
|
+
id: section.id,
|
|
145
|
+
title: section.title,
|
|
146
|
+
level: section.level,
|
|
147
|
+
startLine: section.startLine,
|
|
148
|
+
endLine: section.endLine,
|
|
149
|
+
})),
|
|
150
|
+
}
|
|
151
|
+
const frontMatter = stringifyYaml(ordered).trimEnd()
|
|
152
|
+
const trimmedBody = body.replace(/\s+$/, '')
|
|
153
|
+
return `---\n${frontMatter}\n---\n\n${trimmedBody}\n`
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Parse an on-disk context document. Throws PlatformContextParseError. */
|
|
157
|
+
export function parseContextDocument(raw: string): PlatformContextDocument {
|
|
158
|
+
if (!raw.startsWith(FRONT_MATTER_OPEN)) {
|
|
159
|
+
throw new PlatformContextParseError(
|
|
160
|
+
'Context document must start with YAML front matter (---)',
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
const closeIndex = raw.indexOf(FRONT_MATTER_CLOSE, FRONT_MATTER_OPEN.length)
|
|
164
|
+
if (closeIndex < 0) {
|
|
165
|
+
throw new PlatformContextParseError(
|
|
166
|
+
'Context document front matter is not closed (missing ---)',
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
const frontMatter = raw.slice(FRONT_MATTER_OPEN.length, closeIndex)
|
|
170
|
+
const body = raw
|
|
171
|
+
.slice(closeIndex + FRONT_MATTER_CLOSE.length)
|
|
172
|
+
.replace(/^\n+/, '')
|
|
173
|
+
let parsed: unknown
|
|
174
|
+
try {
|
|
175
|
+
parsed = parseYaml(frontMatter)
|
|
176
|
+
} catch (error) {
|
|
177
|
+
throw new PlatformContextParseError(
|
|
178
|
+
`Invalid YAML front matter: ${error instanceof Error ? error.message : String(error)}`,
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
return { envelope: readContextEnvelope(parsed), body }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Validate an unknown value as a context envelope. */
|
|
185
|
+
export function readContextEnvelope(value: unknown): PlatformContextEnvelope {
|
|
186
|
+
if (!isRecord(value)) {
|
|
187
|
+
throw new PlatformContextParseError('Front matter must be a YAML mapping')
|
|
188
|
+
}
|
|
189
|
+
const schemaVersion = value.schemaVersion
|
|
190
|
+
if (schemaVersion !== PLATFORM_CONTEXT_SCHEMA_VERSION) {
|
|
191
|
+
throw new PlatformContextParseError(
|
|
192
|
+
`Unsupported context schemaVersion: ${String(schemaVersion)}`,
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
const source = value.source
|
|
196
|
+
if (!isRecord(source)) {
|
|
197
|
+
throw new PlatformContextParseError('Envelope source must be a mapping')
|
|
198
|
+
}
|
|
199
|
+
const generator = requireString(value, 'generator')
|
|
200
|
+
if (!CONTEXT_GENERATORS.includes(generator as PlatformContextGenerator)) {
|
|
201
|
+
throw new PlatformContextParseError(
|
|
202
|
+
`Unknown context generator: ${generator}`,
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
schemaVersion: PLATFORM_CONTEXT_SCHEMA_VERSION,
|
|
207
|
+
kind: requireString(value, 'kind'),
|
|
208
|
+
appSlug: requireString(value, 'appSlug'),
|
|
209
|
+
objectId: requireString(value, 'objectId'),
|
|
210
|
+
title: requireString(value, 'title'),
|
|
211
|
+
source: {
|
|
212
|
+
path: requireString(source, 'source.path', source.path),
|
|
213
|
+
...(typeof source.manifestFile === 'string'
|
|
214
|
+
? { manifestFile: source.manifestFile }
|
|
215
|
+
: {}),
|
|
216
|
+
...(isStringArray(source.contentFiles)
|
|
217
|
+
? { contentFiles: source.contentFiles }
|
|
218
|
+
: {}),
|
|
219
|
+
},
|
|
220
|
+
...(typeof value.sourceHash === 'string'
|
|
221
|
+
? { sourceHash: value.sourceHash }
|
|
222
|
+
: {}),
|
|
223
|
+
...(typeof value.sourceFingerprint === 'string'
|
|
224
|
+
? { sourceFingerprint: value.sourceFingerprint }
|
|
225
|
+
: {}),
|
|
226
|
+
generatedAt: requireString(value, 'generatedAt'),
|
|
227
|
+
generator: generator as PlatformContextGenerator,
|
|
228
|
+
links: readLinks(value.links),
|
|
229
|
+
sections: readSections(value.sections),
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function readLinks(value: unknown): PlatformContextLink[] {
|
|
234
|
+
if (value == null) return []
|
|
235
|
+
if (!Array.isArray(value)) {
|
|
236
|
+
throw new PlatformContextParseError('Envelope links must be a list')
|
|
237
|
+
}
|
|
238
|
+
return value.map((entry, index) => {
|
|
239
|
+
if (!isRecord(entry) || typeof entry.relativePath !== 'string') {
|
|
240
|
+
throw new PlatformContextParseError(
|
|
241
|
+
`Envelope links[${index}] must have a relativePath`,
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
relativePath: entry.relativePath,
|
|
246
|
+
...(typeof entry.kind === 'string' ? { kind: entry.kind } : {}),
|
|
247
|
+
...(typeof entry.label === 'string' ? { label: entry.label } : {}),
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function readSections(value: unknown): PlatformContextSection[] {
|
|
253
|
+
if (value == null) return []
|
|
254
|
+
if (!Array.isArray(value)) {
|
|
255
|
+
throw new PlatformContextParseError('Envelope sections must be a list')
|
|
256
|
+
}
|
|
257
|
+
return value.map((entry, index) => {
|
|
258
|
+
if (
|
|
259
|
+
!isRecord(entry) ||
|
|
260
|
+
typeof entry.id !== 'string' ||
|
|
261
|
+
typeof entry.title !== 'string' ||
|
|
262
|
+
typeof entry.level !== 'number' ||
|
|
263
|
+
typeof entry.startLine !== 'number' ||
|
|
264
|
+
typeof entry.endLine !== 'number'
|
|
265
|
+
) {
|
|
266
|
+
throw new PlatformContextParseError(
|
|
267
|
+
`Envelope sections[${index}] must have id, title, level, startLine, endLine`,
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
id: entry.id,
|
|
272
|
+
title: entry.title,
|
|
273
|
+
level: entry.level,
|
|
274
|
+
startLine: entry.startLine,
|
|
275
|
+
endLine: entry.endLine,
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
281
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function isStringArray(value: unknown): value is string[] {
|
|
285
|
+
return Array.isArray(value) && value.every(item => typeof item === 'string')
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function requireString(
|
|
289
|
+
record: Record<string, unknown>,
|
|
290
|
+
key: string,
|
|
291
|
+
raw?: unknown,
|
|
292
|
+
): string {
|
|
293
|
+
const value = raw === undefined ? record[key] : raw
|
|
294
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
295
|
+
throw new PlatformContextParseError(
|
|
296
|
+
`Envelope ${key} must be a non-empty string`,
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
return value
|
|
300
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
buildContextSections,
|
|
5
|
+
contextSectionId,
|
|
6
|
+
sliceContextSection,
|
|
7
|
+
} from './contextSections.js'
|
|
8
|
+
|
|
9
|
+
const BODY = [
|
|
10
|
+
'# Effects of X on Y', // 0
|
|
11
|
+
'', // 1
|
|
12
|
+
'## Abstract', // 2
|
|
13
|
+
'', // 3
|
|
14
|
+
'Short abstract.', // 4
|
|
15
|
+
'', // 5
|
|
16
|
+
'## Body', // 6
|
|
17
|
+
'', // 7
|
|
18
|
+
'### Methods', // 8
|
|
19
|
+
'', // 9
|
|
20
|
+
'Methods text.', // 10
|
|
21
|
+
'', // 11
|
|
22
|
+
'## References', // 12
|
|
23
|
+
'', // 13
|
|
24
|
+
'1. Someone (2020)', // 14
|
|
25
|
+
].join('\n')
|
|
26
|
+
|
|
27
|
+
describe('contextSectionId', () => {
|
|
28
|
+
it('slugifies titles', () => {
|
|
29
|
+
expect(contextSectionId('Abstract')).toBe('abstract')
|
|
30
|
+
expect(contextSectionId('Chapter 2: The Method!')).toBe(
|
|
31
|
+
'chapter-2-the-method',
|
|
32
|
+
)
|
|
33
|
+
expect(contextSectionId(' -- ')).toBe('section')
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('buildContextSections', () => {
|
|
38
|
+
it('indexes headings with durable ids', () => {
|
|
39
|
+
const sections = buildContextSections(BODY)
|
|
40
|
+
expect(sections.map(section => section.id)).toEqual([
|
|
41
|
+
'effects-of-x-on-y',
|
|
42
|
+
'abstract',
|
|
43
|
+
'body',
|
|
44
|
+
'methods',
|
|
45
|
+
'references',
|
|
46
|
+
])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('spans a section through its own subsections', () => {
|
|
50
|
+
const sections = buildContextSections(BODY)
|
|
51
|
+
const body = sections.find(section => section.id === 'body')
|
|
52
|
+
expect(body).toMatchObject({ level: 2, startLine: 6, endLine: 12 })
|
|
53
|
+
const top = sections.find(section => section.id === 'effects-of-x-on-y')
|
|
54
|
+
expect(top?.endLine).toBe(15)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('ignores headings inside fenced code blocks', () => {
|
|
58
|
+
const body = ['## Real', '', '```md', '# Not a heading', '```', ''].join(
|
|
59
|
+
'\n',
|
|
60
|
+
)
|
|
61
|
+
const sections = buildContextSections(body)
|
|
62
|
+
expect(sections.map(section => section.title)).toEqual(['Real'])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('dedupes duplicate heading ids in document order', () => {
|
|
66
|
+
const body = ['## Notes', '', '## Notes', ''].join('\n')
|
|
67
|
+
const sections = buildContextSections(body)
|
|
68
|
+
expect(sections.map(section => section.id)).toEqual(['notes', 'notes-2'])
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('sliceContextSection', () => {
|
|
73
|
+
it('returns the markdown for one section id', () => {
|
|
74
|
+
expect(sliceContextSection(BODY, 'abstract')).toBe(
|
|
75
|
+
'## Abstract\n\nShort abstract.',
|
|
76
|
+
)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('includes subsections in a parent slice', () => {
|
|
80
|
+
const slice = sliceContextSection(BODY, 'body')
|
|
81
|
+
expect(slice).toContain('### Methods')
|
|
82
|
+
expect(slice).not.toContain('## References')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('returns null for unknown ids', () => {
|
|
86
|
+
expect(sliceContextSection(BODY, 'missing')).toBeNull()
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section index for context documents. Sections are addressed by durable `id`
|
|
3
|
+
* (slugified heading, deduped); line ranges are advisory and recomputed on
|
|
4
|
+
* every regeneration. Supports segmented reads (`context.read` section mode).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { PlatformContextSection } from './contextDocument.js'
|
|
8
|
+
|
|
9
|
+
const HEADING_PATTERN = /^(#{1,6})\s+(.+?)\s*#*\s*$/
|
|
10
|
+
const FENCE_PATTERN = /^(```|~~~)/
|
|
11
|
+
|
|
12
|
+
/** Slugify a heading title into a stable section id. */
|
|
13
|
+
export function contextSectionId(title: string): string {
|
|
14
|
+
const slug = title
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.normalize('NFKD')
|
|
17
|
+
.replace(/[̀-ͯ]/g, '')
|
|
18
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
19
|
+
.replace(/^-+|-+$/g, '')
|
|
20
|
+
return slug || 'section'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build the section index for a markdown body. A section spans from its
|
|
25
|
+
* heading line to the next heading of the same or shallower level (so a
|
|
26
|
+
* chapter includes its own subsections). Headings inside fenced code blocks
|
|
27
|
+
* are ignored. Duplicate ids get `-2`, `-3`, … suffixes in document order.
|
|
28
|
+
*/
|
|
29
|
+
export function buildContextSections(body: string): PlatformContextSection[] {
|
|
30
|
+
const lines = body.split('\n')
|
|
31
|
+
const headings: { line: number; level: number; title: string }[] = []
|
|
32
|
+
let fence: string | null = null
|
|
33
|
+
|
|
34
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
35
|
+
const line = lines[index]
|
|
36
|
+
const fenceMatch = FENCE_PATTERN.exec(line.trimStart())
|
|
37
|
+
if (fenceMatch) {
|
|
38
|
+
if (fence === null) fence = fenceMatch[1]
|
|
39
|
+
else if (line.trimStart().startsWith(fence)) fence = null
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
if (fence !== null) continue
|
|
43
|
+
const match = HEADING_PATTERN.exec(line)
|
|
44
|
+
if (!match) continue
|
|
45
|
+
headings.push({ line: index, level: match[1].length, title: match[2] })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const usedIds = new Map<string, number>()
|
|
49
|
+
return headings.map((heading, headingIndex) => {
|
|
50
|
+
let endLine = lines.length
|
|
51
|
+
for (let next = headingIndex + 1; next < headings.length; next += 1) {
|
|
52
|
+
if (headings[next].level <= heading.level) {
|
|
53
|
+
endLine = headings[next].line
|
|
54
|
+
break
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const base = contextSectionId(heading.title)
|
|
58
|
+
const seen = usedIds.get(base) ?? 0
|
|
59
|
+
usedIds.set(base, seen + 1)
|
|
60
|
+
return {
|
|
61
|
+
id: seen === 0 ? base : `${base}-${seen + 1}`,
|
|
62
|
+
title: heading.title,
|
|
63
|
+
level: heading.level,
|
|
64
|
+
startLine: heading.line,
|
|
65
|
+
endLine,
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Extract one section's markdown from a body by durable section id.
|
|
72
|
+
* Recomputes the index from the body — stored ranges are advisory only.
|
|
73
|
+
* Returns null when the id does not exist.
|
|
74
|
+
*/
|
|
75
|
+
export function sliceContextSection(
|
|
76
|
+
body: string,
|
|
77
|
+
sectionId: string,
|
|
78
|
+
): string | null {
|
|
79
|
+
const sections = buildContextSections(body)
|
|
80
|
+
const section = sections.find(entry => entry.id === sectionId)
|
|
81
|
+
if (!section) return null
|
|
82
|
+
const lines = body.split('\n')
|
|
83
|
+
return lines.slice(section.startLine, section.endLine).join('\n').trimEnd()
|
|
84
|
+
}
|