@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.
Files changed (64) hide show
  1. package/package.json +55 -1
  2. package/src/bridge/agentTypes.ts +21 -1
  3. package/src/bridge/collectionImagePaste.ts +19 -1
  4. package/src/bridge/context.mjs +32 -0
  5. package/src/bridge/documents.d.mts +76 -0
  6. package/src/bridge/documents.mjs +40 -0
  7. package/src/bridge/methods.d.mts +23 -0
  8. package/src/bridge/methods.mjs +23 -0
  9. package/src/bridge/pureRender/base.css +44 -0
  10. package/src/bridge/pureRender/document.ts +29 -2
  11. package/src/bridge/pureRender/index.ts +3 -0
  12. package/src/bridge/pureRender/normalize.ts +167 -0
  13. package/src/bridge/pureRender/sanitizeExportBody.test.ts +51 -0
  14. package/src/bridge/pureRender/sanitizeExportBody.ts +61 -0
  15. package/src/bridge/pureRender/styles.ts +125 -5
  16. package/src/bridge/pureRender/theme.test.ts +259 -0
  17. package/src/bridge/pureRender/theme.ts +19 -1
  18. package/src/bridge/pureRender/types.ts +33 -0
  19. package/src/bridge/pureRender/visualLayoutInspection.test.ts +26 -0
  20. package/src/bridge/pureRender/visualLayoutInspection.ts +75 -0
  21. package/src/bridge/react/useDocumentHotkeys.ts +41 -0
  22. package/src/bridge/react/useDocumentLifecycle.tsx +359 -0
  23. package/src/bridge/react/usePlatformAppSettings.test.tsx +105 -0
  24. package/src/bridge/react/usePlatformAppSettings.tsx +104 -0
  25. package/src/bridge/react/usePlatformJsonStore.test.tsx +152 -0
  26. package/src/bridge/react/usePlatformJsonStore.tsx +149 -0
  27. package/src/bridge/storage.d.mts +3 -1
  28. package/src/bridge/storage.test.ts +2 -2
  29. package/src/bridge/types.ts +7 -0
  30. package/src/components/agents/AgentDrawerPanel.tsx +2 -0
  31. package/src/components/agents/AgentMessageList.tsx +6 -1
  32. package/src/components/agents/AgentToolCallCard.tsx +20 -20
  33. package/src/components/agents/ContextPicker.tsx +239 -0
  34. package/src/components/agents/agentPanelStyles.ts +3 -4
  35. package/src/components/agents/agentTypes.ts +2 -0
  36. package/src/components/chrome/WorkspaceTabStrip.tsx +18 -1
  37. package/src/components/chrome/workspaceTabTypes.ts +2 -0
  38. package/src/components/common/containers/AppFrame.test.tsx +4 -4
  39. package/src/components/common/containers/AppFrame.tsx +10 -1
  40. package/src/components/common/containers/AppHeader.tsx +22 -0
  41. package/src/components/common/documents/DocumentHeaderActions.tsx +206 -0
  42. package/src/components/common/documents/DocumentSwitcher.tsx +1176 -0
  43. package/src/components/common/documents/index.ts +8 -0
  44. package/src/components/common/research/EvidenceDossier.tsx +1232 -150
  45. package/src/components/common/research/index.ts +2 -0
  46. package/src/components/print-design/PrintDesignPanel.test.tsx +430 -40
  47. package/src/components/print-design/PrintDesignPanel.tsx +1770 -207
  48. package/src/components/print-design/index.ts +2 -0
  49. package/src/components/print-design/previewContent.test.ts +32 -0
  50. package/src/components/print-design/previewContent.ts +31 -0
  51. package/src/context/buildContextDocument.test.ts +240 -0
  52. package/src/context/buildContextDocument.ts +309 -0
  53. package/src/context/contextAccess.ts +66 -0
  54. package/src/context/contextConfig.ts +72 -0
  55. package/src/context/contextDocument.test.ts +155 -0
  56. package/src/context/contextDocument.ts +300 -0
  57. package/src/context/contextSections.test.ts +88 -0
  58. package/src/context/contextSections.ts +84 -0
  59. package/src/context/htmlToContextMarkdown.test.ts +134 -0
  60. package/src/context/htmlToContextMarkdown.ts +369 -0
  61. package/src/theme/appAccents.test.ts +36 -35
  62. package/src/theme/appAccents.ts +87 -104
  63. package/src/theme/appIdentityCss.mjs +169 -231
  64. 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
+ }