@aprovan/patchwork-editor 0.1.0
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/.turbo/turbo-build.log +16 -0
- package/LICENSE +373 -0
- package/dist/index.d.ts +331 -0
- package/dist/index.js +1597 -0
- package/package.json +45 -0
- package/src/components/CodeBlockExtension.tsx +190 -0
- package/src/components/CodePreview.tsx +344 -0
- package/src/components/MarkdownEditor.tsx +270 -0
- package/src/components/ServicesInspector.tsx +118 -0
- package/src/components/edit/EditHistory.tsx +89 -0
- package/src/components/edit/EditModal.tsx +236 -0
- package/src/components/edit/FileTree.tsx +144 -0
- package/src/components/edit/api.ts +100 -0
- package/src/components/edit/index.ts +6 -0
- package/src/components/edit/types.ts +53 -0
- package/src/components/edit/useEditSession.ts +164 -0
- package/src/components/index.ts +5 -0
- package/src/index.ts +72 -0
- package/src/lib/code-extractor.ts +210 -0
- package/src/lib/diff.ts +308 -0
- package/src/lib/index.ts +4 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/vfs.ts +106 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +10 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Components
|
|
2
|
+
export {
|
|
3
|
+
CodeBlockExtension,
|
|
4
|
+
CodePreview,
|
|
5
|
+
MarkdownEditor,
|
|
6
|
+
ServicesInspector,
|
|
7
|
+
type ServiceInfo,
|
|
8
|
+
} from './components';
|
|
9
|
+
|
|
10
|
+
// Edit components
|
|
11
|
+
export {
|
|
12
|
+
EditModal,
|
|
13
|
+
EditHistory,
|
|
14
|
+
FileTree,
|
|
15
|
+
useEditSession,
|
|
16
|
+
sendEditRequest,
|
|
17
|
+
type EditModalProps,
|
|
18
|
+
type UseEditSessionOptions,
|
|
19
|
+
type EditHistoryEntry,
|
|
20
|
+
type EditSessionState,
|
|
21
|
+
type EditSessionActions,
|
|
22
|
+
type EditRequest,
|
|
23
|
+
type EditResponse,
|
|
24
|
+
type CompileResult,
|
|
25
|
+
type CompileFn,
|
|
26
|
+
type EditApiOptions,
|
|
27
|
+
type FileTreeProps,
|
|
28
|
+
getActiveContent,
|
|
29
|
+
getFiles,
|
|
30
|
+
} from './components/edit';
|
|
31
|
+
|
|
32
|
+
// Lib utilities
|
|
33
|
+
export {
|
|
34
|
+
// Code extractor
|
|
35
|
+
extractCodeBlocks,
|
|
36
|
+
findFirstCodeBlock,
|
|
37
|
+
hasCodeBlock,
|
|
38
|
+
getCodeBlockLanguages,
|
|
39
|
+
extractProject,
|
|
40
|
+
type TextPart,
|
|
41
|
+
type CodePart,
|
|
42
|
+
type ParsedPart,
|
|
43
|
+
type ExtractOptions,
|
|
44
|
+
|
|
45
|
+
// Diff utilities
|
|
46
|
+
parseCodeBlockAttributes,
|
|
47
|
+
parseCodeBlocks,
|
|
48
|
+
findDiffMarkers,
|
|
49
|
+
sanitizeDiffMarkers,
|
|
50
|
+
parseEditResponse,
|
|
51
|
+
parseDiffs,
|
|
52
|
+
applyDiffs,
|
|
53
|
+
hasDiffBlocks,
|
|
54
|
+
extractTextWithoutDiffs,
|
|
55
|
+
extractSummary,
|
|
56
|
+
type CodeBlockAttributes,
|
|
57
|
+
type CodeBlock,
|
|
58
|
+
type DiffBlock,
|
|
59
|
+
type ParsedEditResponse,
|
|
60
|
+
|
|
61
|
+
// VFS utilities
|
|
62
|
+
getVFSConfig,
|
|
63
|
+
getVFSStore,
|
|
64
|
+
saveProject,
|
|
65
|
+
loadProject,
|
|
66
|
+
listProjects,
|
|
67
|
+
saveFile,
|
|
68
|
+
isVFSAvailable,
|
|
69
|
+
|
|
70
|
+
// General utilities
|
|
71
|
+
cn,
|
|
72
|
+
} from './lib';
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { VirtualFile, VirtualProject } from '@aprovan/patchwork-compiler';
|
|
2
|
+
import { createProjectFromFiles, detectMainFile } from '@aprovan/patchwork-compiler';
|
|
3
|
+
|
|
4
|
+
// Matches fenced code blocks with optional attributes: ```language attr="value"\n...content...```
|
|
5
|
+
// Captures: [1] = language (optional), [2] = attributes (optional), [3] = content
|
|
6
|
+
const CODE_BLOCK_REGEX = /```([a-zA-Z0-9_+-]*)((?:\s+[a-zA-Z_][\w-]*="[^"]*")*)\s*\n([\s\S]*?)```/g;
|
|
7
|
+
|
|
8
|
+
// Matches an unclosed code block at the end (streaming case)
|
|
9
|
+
const UNCLOSED_BLOCK_REGEX = /```([a-zA-Z0-9_+-]*)((?:\s+[a-zA-Z_][\w-]*="[^"]*")*)\s*\n([\s\S]*)$/;
|
|
10
|
+
|
|
11
|
+
// Parse attributes from string like: note="value" path="@/foo.tsx"
|
|
12
|
+
const ATTRIBUTE_REGEX = /([a-zA-Z_][\w-]*)="([^"]*)"/g;
|
|
13
|
+
|
|
14
|
+
export type TextPart = { type: 'text'; content: string };
|
|
15
|
+
export type CodePart = {
|
|
16
|
+
type: 'code' | string;
|
|
17
|
+
content: string;
|
|
18
|
+
language: 'jsx' | 'tsx' | string;
|
|
19
|
+
attributes?: Record<string, string>;
|
|
20
|
+
};
|
|
21
|
+
export type ParsedPart = TextPart | CodePart;
|
|
22
|
+
|
|
23
|
+
export interface ExtractOptions {
|
|
24
|
+
/** Only extract these languages (default: all) */
|
|
25
|
+
filterLanguages?: Set<string>;
|
|
26
|
+
/** Include unclosed code blocks at the end (for streaming) */
|
|
27
|
+
includeUnclosed?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parse attributes string into key-value pairs.
|
|
32
|
+
*/
|
|
33
|
+
function parseAttributes(attrString: string): Record<string, string> {
|
|
34
|
+
const attrs: Record<string, string> = {};
|
|
35
|
+
if (!attrString) return attrs;
|
|
36
|
+
|
|
37
|
+
const regex = new RegExp(ATTRIBUTE_REGEX.source, 'g');
|
|
38
|
+
let match;
|
|
39
|
+
while ((match = regex.exec(attrString)) !== null) {
|
|
40
|
+
const key = match[1];
|
|
41
|
+
const value = match[2];
|
|
42
|
+
if (key && value !== undefined) {
|
|
43
|
+
attrs[key] = value;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return attrs;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Extract code blocks from markdown text.
|
|
51
|
+
*/
|
|
52
|
+
export function extractCodeBlocks(
|
|
53
|
+
text: string,
|
|
54
|
+
options: ExtractOptions = {}
|
|
55
|
+
): ParsedPart[] {
|
|
56
|
+
const { filterLanguages, includeUnclosed = false } = options;
|
|
57
|
+
const parts: ParsedPart[] = [];
|
|
58
|
+
let lastIndex = 0;
|
|
59
|
+
|
|
60
|
+
// First pass: find all code blocks and track their positions
|
|
61
|
+
const allMatches: Array<{
|
|
62
|
+
match: RegExpExecArray;
|
|
63
|
+
language: string;
|
|
64
|
+
content: string;
|
|
65
|
+
attributes: Record<string, string>;
|
|
66
|
+
included: boolean;
|
|
67
|
+
}> = [];
|
|
68
|
+
const regex = new RegExp(CODE_BLOCK_REGEX.source, 'g');
|
|
69
|
+
let match;
|
|
70
|
+
|
|
71
|
+
while ((match = regex.exec(text)) !== null) {
|
|
72
|
+
const language = match[1]?.toLowerCase() || '';
|
|
73
|
+
const attributes = parseAttributes(match[2] || '');
|
|
74
|
+
const content = match[3] ?? '';
|
|
75
|
+
const included = !filterLanguages || filterLanguages.has(language);
|
|
76
|
+
allMatches.push({ match, language, content, attributes, included });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Process matches in order
|
|
80
|
+
for (const { match, language, content, attributes, included } of allMatches) {
|
|
81
|
+
// Add preceding text (excluding any skipped code blocks)
|
|
82
|
+
if (match.index > lastIndex) {
|
|
83
|
+
const textBefore = text.slice(lastIndex, match.index);
|
|
84
|
+
if (textBefore.trim()) {
|
|
85
|
+
parts.push({ type: 'text', content: textBefore });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Always advance lastIndex past this block (even if not included)
|
|
90
|
+
lastIndex = match.index + match[0].length;
|
|
91
|
+
|
|
92
|
+
// Only add the block if it passes the filter
|
|
93
|
+
if (included) {
|
|
94
|
+
parts.push({ type: 'code', content, language, attributes });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Check for unclosed code block at the end (streaming case)
|
|
99
|
+
const remainingText = text.slice(lastIndex);
|
|
100
|
+
if (includeUnclosed && remainingText.includes('```')) {
|
|
101
|
+
const unclosedMatch = remainingText.match(UNCLOSED_BLOCK_REGEX);
|
|
102
|
+
if (unclosedMatch) {
|
|
103
|
+
const language = unclosedMatch[1]?.toLowerCase() || '';
|
|
104
|
+
const attributes = parseAttributes(unclosedMatch[2] || '');
|
|
105
|
+
const content = unclosedMatch[3] ?? '';
|
|
106
|
+
const included = !filterLanguages || filterLanguages.has(language);
|
|
107
|
+
|
|
108
|
+
// Add text before the unclosed block
|
|
109
|
+
const unclosedIndex = remainingText.indexOf('```');
|
|
110
|
+
if (unclosedIndex > 0) {
|
|
111
|
+
const textBefore = remainingText.slice(0, unclosedIndex);
|
|
112
|
+
if (textBefore.trim()) {
|
|
113
|
+
parts.push({ type: 'text', content: textBefore });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (included) {
|
|
118
|
+
parts.push({ type: 'code', content, language, attributes });
|
|
119
|
+
}
|
|
120
|
+
lastIndex = text.length; // Mark all text as processed
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Add remaining text
|
|
125
|
+
if (lastIndex < text.length) {
|
|
126
|
+
const remaining = text.slice(lastIndex);
|
|
127
|
+
if (remaining.trim()) {
|
|
128
|
+
parts.push({ type: 'text', content: remaining });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// If no parts found, return the whole text
|
|
133
|
+
if (parts.length === 0) {
|
|
134
|
+
parts.push({ type: 'text', content: text });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return parts;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Find the first JSX/TSX block in the text.
|
|
142
|
+
* Returns null if no JSX block is found.
|
|
143
|
+
*/
|
|
144
|
+
export function findFirstCodeBlock(text: string): CodePart | null {
|
|
145
|
+
const parts = extractCodeBlocks(text);
|
|
146
|
+
return (parts.find((p) => p.type === 'code') as CodePart) ?? null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Check if text contains any JSX/TSX code blocks.
|
|
151
|
+
*/
|
|
152
|
+
export function hasCodeBlock(text: string): boolean {
|
|
153
|
+
return findFirstCodeBlock(text) !== null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Get all unique languages found in code blocks.
|
|
158
|
+
*/
|
|
159
|
+
export function getCodeBlockLanguages(text: string): Set<string> {
|
|
160
|
+
const parts = extractCodeBlocks(text);
|
|
161
|
+
const languages = new Set<string>();
|
|
162
|
+
for (const part of parts) {
|
|
163
|
+
if (part.type === 'code') {
|
|
164
|
+
languages.add(part.language);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return languages;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Extract code blocks as a VirtualProject.
|
|
172
|
+
* Groups files with path attributes into a multi-file project.
|
|
173
|
+
* Files without paths are treated as the main entry file.
|
|
174
|
+
*/
|
|
175
|
+
export function extractProject(
|
|
176
|
+
text: string,
|
|
177
|
+
options?: ExtractOptions
|
|
178
|
+
): { project: VirtualProject; textParts: TextPart[] } {
|
|
179
|
+
const parts = extractCodeBlocks(text, options);
|
|
180
|
+
|
|
181
|
+
const files: VirtualFile[] = [];
|
|
182
|
+
const textParts: TextPart[] = [];
|
|
183
|
+
|
|
184
|
+
for (const part of parts) {
|
|
185
|
+
if (part.type === 'text') {
|
|
186
|
+
textParts.push(part as TextPart);
|
|
187
|
+
} else if (part.type === 'code') {
|
|
188
|
+
const codePart = part as CodePart;
|
|
189
|
+
if (codePart.attributes?.path) {
|
|
190
|
+
files.push({
|
|
191
|
+
path: codePart.attributes.path,
|
|
192
|
+
content: codePart.content,
|
|
193
|
+
language: codePart.language,
|
|
194
|
+
note: codePart.attributes.note,
|
|
195
|
+
});
|
|
196
|
+
} else {
|
|
197
|
+
files.push({
|
|
198
|
+
path: detectMainFile(codePart.language),
|
|
199
|
+
content: codePart.content,
|
|
200
|
+
language: codePart.language,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
project: createProjectFromFiles(files),
|
|
208
|
+
textParts,
|
|
209
|
+
};
|
|
210
|
+
}
|
package/src/lib/diff.ts
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
const DIFF_BLOCK_REGEX = /<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE/g;
|
|
2
|
+
|
|
3
|
+
/** Patterns that indicate diff markers in text */
|
|
4
|
+
const DIFF_MARKER_PATTERNS = [
|
|
5
|
+
/^<<<<<<< SEARCH\s*$/m,
|
|
6
|
+
/^=======\s*$/m,
|
|
7
|
+
/^>>>>>>> REPLACE\s*$/m,
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Regex to match code fence opening with language and optional attributes.
|
|
12
|
+
* Format: triple-backtick + lang + attr="value" attr2="value2"
|
|
13
|
+
* Captures: [1]=language, [2]=attributes string
|
|
14
|
+
*/
|
|
15
|
+
const CODE_FENCE_REGEX = /^```(\w*)\s*((?:[a-zA-Z_][\w-]*="[^"]*"\s*)*)\s*$/;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Regex to match individual key="value" attributes.
|
|
19
|
+
*/
|
|
20
|
+
const ATTRIBUTE_REGEX = /([a-zA-Z_][\w-]*)="([^"]*)"/g;
|
|
21
|
+
|
|
22
|
+
export interface CodeBlockAttributes {
|
|
23
|
+
/** Progress note for UI display (optional but encouraged, comes first) */
|
|
24
|
+
note?: string;
|
|
25
|
+
/** Virtual file path for multi-file generation (uses \@/ prefix) */
|
|
26
|
+
path?: string;
|
|
27
|
+
/** Additional arbitrary attributes */
|
|
28
|
+
[key: string]: string | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CodeBlock {
|
|
32
|
+
/** Language identifier (e.g., tsx, json, diff) */
|
|
33
|
+
language: string;
|
|
34
|
+
/** Parsed attributes from the fence line */
|
|
35
|
+
attributes: CodeBlockAttributes;
|
|
36
|
+
/** Raw content between the fence markers */
|
|
37
|
+
content: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface DiffBlock {
|
|
41
|
+
search: string;
|
|
42
|
+
replace: string;
|
|
43
|
+
/** Progress note from the code fence attributes */
|
|
44
|
+
note?: string;
|
|
45
|
+
/** Target file path for multi-file edits */
|
|
46
|
+
path?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Parse attributes from a code fence line.
|
|
51
|
+
*
|
|
52
|
+
* Example input: 'note="Adding handler" path="\@/components/Button.tsx"'
|
|
53
|
+
*
|
|
54
|
+
* Returns: an object with note, path, and any other attributes
|
|
55
|
+
*/
|
|
56
|
+
export function parseCodeBlockAttributes(attrString: string): CodeBlockAttributes {
|
|
57
|
+
const attrs: CodeBlockAttributes = {};
|
|
58
|
+
if (!attrString) return attrs;
|
|
59
|
+
|
|
60
|
+
const regex = new RegExp(ATTRIBUTE_REGEX.source, 'g');
|
|
61
|
+
let match;
|
|
62
|
+
while ((match = regex.exec(attrString)) !== null) {
|
|
63
|
+
const key = match[1];
|
|
64
|
+
const value = match[2];
|
|
65
|
+
if (key && value !== undefined) {
|
|
66
|
+
attrs[key] = value;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return attrs;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Parse all code blocks from text, extracting language and attributes.
|
|
74
|
+
* Returns blocks in order of appearance.
|
|
75
|
+
*/
|
|
76
|
+
export function parseCodeBlocks(text: string): CodeBlock[] {
|
|
77
|
+
const blocks: CodeBlock[] = [];
|
|
78
|
+
const lines = text.split('\n');
|
|
79
|
+
let i = 0;
|
|
80
|
+
|
|
81
|
+
while (i < lines.length) {
|
|
82
|
+
const line = lines[i];
|
|
83
|
+
if (!line) {
|
|
84
|
+
i++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const fenceMatch = line.match(CODE_FENCE_REGEX);
|
|
88
|
+
|
|
89
|
+
if (fenceMatch) {
|
|
90
|
+
const language = fenceMatch[1] || '';
|
|
91
|
+
const attributes = parseCodeBlockAttributes(fenceMatch[2] ?? '');
|
|
92
|
+
const contentLines: string[] = [];
|
|
93
|
+
i++; // Move past opening fence
|
|
94
|
+
|
|
95
|
+
// Collect content until closing fence
|
|
96
|
+
while (i < lines.length) {
|
|
97
|
+
const currentLine = lines[i];
|
|
98
|
+
if (currentLine !== undefined && currentLine.match(/^```\s*$/)) {
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
contentLines.push(currentLine ?? '');
|
|
102
|
+
i++;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
blocks.push({
|
|
106
|
+
language,
|
|
107
|
+
attributes,
|
|
108
|
+
content: contentLines.join('\n'),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
i++;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return blocks;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Check if text contains any diff markers.
|
|
119
|
+
* Returns the first marker found, or null if clean.
|
|
120
|
+
*/
|
|
121
|
+
export function findDiffMarkers(text: string): string | null {
|
|
122
|
+
for (const pattern of DIFF_MARKER_PATTERNS) {
|
|
123
|
+
const match = text.match(pattern);
|
|
124
|
+
if (match) {
|
|
125
|
+
return match[0].trim();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Remove stray diff markers from text.
|
|
133
|
+
* Use as a fallback when markers leak into output.
|
|
134
|
+
*/
|
|
135
|
+
export function sanitizeDiffMarkers(text: string): string {
|
|
136
|
+
let result = text;
|
|
137
|
+
// Remove standalone marker lines
|
|
138
|
+
result = result.replace(/^<<<<<<< SEARCH\s*\n?/gm, '');
|
|
139
|
+
result = result.replace(/^=======\s*\n?/gm, '');
|
|
140
|
+
result = result.replace(/^>>>>>>> REPLACE\s*\n?/gm, '');
|
|
141
|
+
// Clean up any double newlines created by removals
|
|
142
|
+
result = result.replace(/\n{3,}/g, '\n\n');
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface ParsedEditResponse {
|
|
147
|
+
/** Progress notes extracted from code block attributes (in order of appearance) */
|
|
148
|
+
progressNotes: string[];
|
|
149
|
+
/** Parsed diff blocks with their attributes */
|
|
150
|
+
diffs: DiffBlock[];
|
|
151
|
+
/** Summary markdown text (content outside of code blocks) */
|
|
152
|
+
summary: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Parse progress notes and diffs from an edit response.
|
|
157
|
+
*
|
|
158
|
+
* New format uses tagged attributes on code fences:
|
|
159
|
+
* ```diff note="Adding handler" path="@/components/Button.tsx"
|
|
160
|
+
* <<<<<<< SEARCH
|
|
161
|
+
* exact code
|
|
162
|
+
* =======
|
|
163
|
+
* replacement
|
|
164
|
+
* >>>>>>> REPLACE
|
|
165
|
+
* ```
|
|
166
|
+
*
|
|
167
|
+
* Summary markdown is everything outside of code blocks.
|
|
168
|
+
*/
|
|
169
|
+
export function parseEditResponse(text: string): ParsedEditResponse {
|
|
170
|
+
const progressNotes: string[] = [];
|
|
171
|
+
const diffs: DiffBlock[] = [];
|
|
172
|
+
|
|
173
|
+
// Parse all code blocks to extract notes and diffs
|
|
174
|
+
const codeBlocks = parseCodeBlocks(text);
|
|
175
|
+
|
|
176
|
+
for (const block of codeBlocks) {
|
|
177
|
+
// Collect progress notes from any code block with a note attribute
|
|
178
|
+
if (block.attributes.note) {
|
|
179
|
+
progressNotes.push(block.attributes.note);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Check if this block contains a diff
|
|
183
|
+
const diffMatch = block.content.match(
|
|
184
|
+
/<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE/
|
|
185
|
+
);
|
|
186
|
+
if (diffMatch && diffMatch[1] !== undefined && diffMatch[2] !== undefined) {
|
|
187
|
+
diffs.push({
|
|
188
|
+
note: block.attributes.note,
|
|
189
|
+
path: block.attributes.path,
|
|
190
|
+
search: diffMatch[1],
|
|
191
|
+
replace: diffMatch[2],
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Extract summary: everything outside of code blocks
|
|
197
|
+
const summary = extractSummary(text);
|
|
198
|
+
|
|
199
|
+
return { progressNotes, diffs, summary };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Parse diff blocks from text, extracting attributes from code fences.
|
|
204
|
+
* Supports both fenced code blocks with attributes and raw diff markers.
|
|
205
|
+
*/
|
|
206
|
+
export function parseDiffs(text: string): DiffBlock[] {
|
|
207
|
+
const blocks: DiffBlock[] = [];
|
|
208
|
+
|
|
209
|
+
// First, try to parse from code blocks with attributes
|
|
210
|
+
const codeBlocks = parseCodeBlocks(text);
|
|
211
|
+
for (const block of codeBlocks) {
|
|
212
|
+
const diffMatch = block.content.match(
|
|
213
|
+
/<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE/
|
|
214
|
+
);
|
|
215
|
+
if (diffMatch && diffMatch[1] !== undefined && diffMatch[2] !== undefined) {
|
|
216
|
+
blocks.push({
|
|
217
|
+
note: block.attributes.note,
|
|
218
|
+
path: block.attributes.path,
|
|
219
|
+
search: diffMatch[1],
|
|
220
|
+
replace: diffMatch[2],
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// If no fenced diffs found, fall back to raw diff markers (legacy support)
|
|
226
|
+
if (blocks.length === 0) {
|
|
227
|
+
const regex = new RegExp(DIFF_BLOCK_REGEX.source, 'g');
|
|
228
|
+
let match;
|
|
229
|
+
while ((match = regex.exec(text)) !== null) {
|
|
230
|
+
if (match[1] !== undefined && match[2] !== undefined) {
|
|
231
|
+
blocks.push({ search: match[1], replace: match[2] });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return blocks;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function applyDiffs(
|
|
240
|
+
code: string,
|
|
241
|
+
diffs: DiffBlock[],
|
|
242
|
+
options: { sanitize?: boolean } = {},
|
|
243
|
+
): { code: string; applied: number; failed: string[]; warning?: string } {
|
|
244
|
+
let result = code;
|
|
245
|
+
let applied = 0;
|
|
246
|
+
const failed: string[] = [];
|
|
247
|
+
|
|
248
|
+
for (const diff of diffs) {
|
|
249
|
+
if (result.includes(diff.search)) {
|
|
250
|
+
result = result.replace(diff.search, diff.replace);
|
|
251
|
+
applied++;
|
|
252
|
+
} else {
|
|
253
|
+
// Provide more context: first 100 chars or first 3 lines, whichever is shorter
|
|
254
|
+
const lines = diff.search.split('\n').slice(0, 3);
|
|
255
|
+
const preview = lines.join('\n').slice(0, 100);
|
|
256
|
+
const suffix = diff.search.length > preview.length ? '...' : '';
|
|
257
|
+
failed.push(preview + suffix);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Check for stray diff markers in the result
|
|
262
|
+
const marker = findDiffMarkers(result);
|
|
263
|
+
let warning: string | undefined;
|
|
264
|
+
|
|
265
|
+
if (marker) {
|
|
266
|
+
if (options.sanitize) {
|
|
267
|
+
result = sanitizeDiffMarkers(result);
|
|
268
|
+
warning = `Removed stray diff marker "${marker}" from output`;
|
|
269
|
+
} else {
|
|
270
|
+
warning = `Output contains diff marker "${marker}" - the LLM may have generated a malformed response`;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return { code: result, applied, failed, warning };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function hasDiffBlocks(text: string): boolean {
|
|
278
|
+
return DIFF_BLOCK_REGEX.test(text);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function extractTextWithoutDiffs(text: string): string {
|
|
282
|
+
return text.replace(DIFF_BLOCK_REGEX, '').trim();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Regex to match complete code blocks (with optional attributes) for removal.
|
|
287
|
+
* Matches triple-backtick fenced code with language and attributes.
|
|
288
|
+
*/
|
|
289
|
+
const CODE_BLOCK_FULL_REGEX = /```\w*(?:\s+[a-zA-Z_][\w-]*="[^"]*")*\s*\n[\s\S]*?\n```/g;
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Extract the summary markdown from an edit response.
|
|
293
|
+
* Removes code blocks (with their attributes), and any leading/trailing whitespace.
|
|
294
|
+
* Preserves regular markdown prose outside of code blocks.
|
|
295
|
+
*/
|
|
296
|
+
export function extractSummary(text: string): string {
|
|
297
|
+
// Remove complete code blocks (including those with attributes)
|
|
298
|
+
let summary = text.replace(CODE_BLOCK_FULL_REGEX, '');
|
|
299
|
+
// Remove stray diff fence markers that might be left over
|
|
300
|
+
summary = summary.replace(/^<<<<<<< SEARCH\s*$/gm, '');
|
|
301
|
+
summary = summary.replace(/^=======\s*$/gm, '');
|
|
302
|
+
summary = summary.replace(/^>>>>>>> REPLACE\s*$/gm, '');
|
|
303
|
+
// Remove standalone ``` markers (not part of a code block)
|
|
304
|
+
summary = summary.replace(/^```[\w]*(?:\s+[a-zA-Z_][\w-]*="[^"]*")*\s*$/gm, '');
|
|
305
|
+
// Clean up multiple newlines (3+ becomes 2) and trim
|
|
306
|
+
summary = summary.replace(/\n{3,}/g, '\n\n').trim();
|
|
307
|
+
return summary;
|
|
308
|
+
}
|
package/src/lib/index.ts
ADDED