Skip to content

Commit 3fe54af

Browse files
Sma1lboyautofix-ci[bot]icycodes
authoredApr 7, 2025··
fix(markdown): handle JSON parsing errors in processCodeBlocksWithLabel (#4114)
* fix(markdown): handle JSON parsing errors in processCodeBlocksWithLabel function * feat(markdown): add formatObjectToMarkdownBlock and formatPlaceholder functions for improved markdown handling - Introduced formatObjectToMarkdownBlock to format objects into markdown code blocks with metadata. - Added formatPlaceholder to create formatted placeholders for file and symbol types. - Updated processingPlaceholder to utilize the new formatting functions. - Enhanced custom AST stringification in markdown processing. - Added tests for new formatting functions and existing markdown utilities. * feat(markdown): enhance markdown processing with placeholder handling and unified dependency - Added a placeholder handler in the markdown processor to manage custom nodes. - Updated the `createRemarkProcessor` function to include the new placeholder handler. - Refactored `customAstToString` to streamline AST stringification. - Introduced a new `unified` dependency for improved markdown processing. - Added comprehensive tests for new functionality and existing markdown utilities. * refactor(chat): remove unused log * chore: remove log * [autofix.ci] apply automated fixes * Update ee/tabby-ui/lib/utils/markdown.ts * Update ee/tabby-ui/lib/utils/markdown.ts --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Zhiming Ma <codes.icy@gmail.com>
1 parent 6b31dbe commit 3fe54af

File tree

5 files changed

+481
-172
lines changed

5 files changed

+481
-172
lines changed
 

‎ee/tabby-ui/lib/utils/chat.ts

+10-5
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ import {
2525
PLACEHOLDER_FILE_REGEX,
2626
PLACEHOLDER_SYMBOL_REGEX
2727
} from '../constants/regex'
28-
import { convertContextBlockToPlaceholder } from './markdown'
28+
import {
29+
convertContextBlockToPlaceholder,
30+
formatObjectToMarkdownBlock
31+
} from './markdown'
2932

3033
export const isCodeSourceContext = (kind: ContextSourceKind) => {
3134
return [
@@ -354,8 +357,7 @@ export async function processingPlaceholder(
354357
})
355358
let replacement = ''
356359
if (content) {
357-
const fileInfoJSON = JSON.stringify(fileInfo).replace(/"/g, '\\"')
358-
replacement = `\n\`\`\`context label='file' object='${fileInfoJSON}'\n${content}\n\`\`\`\n`
360+
replacement = formatObjectToMarkdownBlock('file', fileInfo, content)
359361
}
360362
processedMessage = processedMessage.replace(match[0], replacement)
361363
tempMessage = tempMessage.replace(match[0], replacement)
@@ -381,8 +383,11 @@ export async function processingPlaceholder(
381383
})
382384
let replacement = ''
383385
if (content) {
384-
const symbolInfoJSON = JSON.stringify(symbolInfo).replace(/"/g, '\\"')
385-
replacement = `\n\`\`\`context label='symbol' object='${symbolInfoJSON}'\n${content}\n\`\`\`\n`
386+
replacement = formatObjectToMarkdownBlock(
387+
'symbol',
388+
symbolInfo,
389+
content
390+
)
386391
}
387392
processedMessage = processedMessage.replace(match[0], replacement)
388393
tempMessage = tempMessage.replace(match[0], replacement)

‎ee/tabby-ui/lib/utils/markdown.ts

+113-60
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,37 @@
11
import { Parent, Root, RootContent } from 'mdast'
22
import { remark } from 'remark'
3-
import remarkStringify from 'remark-stringify'
3+
import remarkStringify, { Options } from 'remark-stringify'
44

5-
/**
6-
* Custom stringification of AST to preserve special patterns
7-
* @param ast AST to stringify
8-
* @returns Plain string representation
9-
*/
10-
export function customAstToString(ast: Root): string {
11-
let result = ''
12-
for (const node of ast.children) {
13-
result += nodeToString(node) + '\n'
14-
}
15-
return result.trim()
5+
const REMARK_STRINGIFY_OPTIONS: Options = {
6+
bullet: '*',
7+
emphasis: '*',
8+
fences: true,
9+
listItemIndent: 'one',
10+
tightDefinitions: true,
11+
handlers: {
12+
placeholder: (node: PlaceholderNode) => {
13+
return node.value
14+
}
15+
} as any
1616
}
1717

18-
/**
19-
* Convert a single node to string
20-
* @param node AST node
21-
* @returns String representation
22-
*/
23-
function nodeToString(node: any): string {
24-
switch (node.type) {
25-
case 'paragraph':
26-
return paragraphToString(node)
27-
case 'text':
28-
return node.value
29-
default:
30-
const processor = remark().use(remarkStringify)
31-
return processor.stringify({ type: 'root', children: [node] }).trim()
32-
}
18+
function createRemarkProcessor() {
19+
return remark().use(remarkStringify, REMARK_STRINGIFY_OPTIONS)
3320
}
3421

3522
/**
36-
* Convert paragraph node to string
37-
* @param node Paragraph node
38-
* @returns String representation
23+
* Custom stringification of AST using remarkStringify
24+
* @param ast AST to stringify
25+
* @returns Plain string representation
3926
*/
40-
function paragraphToString(node: any): string {
41-
return childrenToString(node)
27+
export function customAstToString(ast: Root): string {
28+
const processor = createRemarkProcessor()
29+
return processor.stringify(ast).trim()
4230
}
4331

4432
/**
45-
* Process children of a node and join them
46-
* @param node Parent node
47-
* @returns Combined string of all children
33+
* Process code blocks with labels and convert them to placeholders
4834
*/
49-
function childrenToString(node: any): string {
50-
if (!node.children || node.children.length === 0) {
51-
return ''
52-
}
53-
return node.children.map((child: any) => nodeToString(child)).join('')
54-
}
55-
5635
export function processCodeBlocksWithLabel(ast: Root): RootContent[] {
5736
const newChildren: RootContent[] = []
5837
for (let i = 0; i < ast.children.length; i++) {
@@ -62,8 +41,7 @@ export function processCodeBlocksWithLabel(ast: Root): RootContent[] {
6241
if (node.type === 'code' && node.meta) {
6342
node.meta?.split(' ').forEach(item => {
6443
const [key, rawValue] = item.split(/=(.+)/)
65-
const value = rawValue?.replace(/^['"]|['"]$/g, '') || ''
66-
metas[key] = value
44+
metas[key] = rawValue
6745
})
6846
}
6947

@@ -84,31 +62,57 @@ export function processCodeBlocksWithLabel(ast: Root): RootContent[] {
8462
nextNode.position.start.line - node.position.end.line === 1
8563

8664
let finalCommandText = ''
65+
let placeholderNode: RootContent | null = null
66+
let shouldProcessNode = true
8767

88-
// processing differet type of context
8968
switch (metas['label']) {
9069
case 'changes':
91-
finalCommandText = '[[contextCommand:"changes"]]'
70+
finalCommandText = '"changes"'
71+
placeholderNode = createPlaceholderNode(
72+
`[[contextCommand:${finalCommandText}]]`
73+
) as unknown as RootContent
9274
break
9375
case 'file':
9476
if (metas['object']) {
95-
const fileObject = JSON.parse(metas['object'].replace(/\\"/g, '"'))
96-
finalCommandText = `[[file:${JSON.stringify(fileObject)}]]`
77+
try {
78+
placeholderNode = createPlaceholderNode(
79+
`[[file:${metas['object']}]]`
80+
) as unknown as RootContent
81+
if (!placeholderNode) {
82+
shouldProcessNode = false
83+
newChildren.push(node)
84+
}
85+
} catch (error) {
86+
shouldProcessNode = false
87+
newChildren.push(node)
88+
}
9789
}
9890
break
9991
case 'symbol':
10092
if (metas['object']) {
101-
const symbolObject = JSON.parse(
102-
metas['object'].replace(/\\"/g, '"')
103-
)
104-
finalCommandText = `[[symbol:${JSON.stringify(symbolObject)}]]`
93+
try {
94+
placeholderNode = createPlaceholderNode(
95+
`[[symbol:${metas['object']}]]`
96+
) as unknown as RootContent
97+
if (!placeholderNode) {
98+
shouldProcessNode = false
99+
newChildren.push(node)
100+
}
101+
} catch (error) {
102+
shouldProcessNode = false
103+
newChildren.push(node)
104+
}
105105
}
106106
break
107107
default:
108108
newChildren.push(node)
109109
continue
110110
}
111111

112+
if (!shouldProcessNode) {
113+
continue
114+
}
115+
112116
if (
113117
prevNode &&
114118
prevNode.type === 'paragraph' &&
@@ -123,7 +127,7 @@ export function processCodeBlocksWithLabel(ast: Root): RootContent[] {
123127
type: 'paragraph',
124128
children: [
125129
...(prevNode.children || []),
126-
{ type: 'text', value: ` ${finalCommandText} ` },
130+
placeholderNode || { type: 'text', value: ` ${finalCommandText} ` },
127131
...(nextNode.children || [])
128132
]
129133
} as RootContent)
@@ -136,7 +140,7 @@ export function processCodeBlocksWithLabel(ast: Root): RootContent[] {
136140
newChildren.push({
137141
type: 'paragraph',
138142
children: [
139-
{ type: 'text', value: `${finalCommandText} ` },
143+
placeholderNode || { type: 'text', value: `${finalCommandText} ` },
140144
...(nextNode.children || [])
141145
]
142146
} as RootContent)
@@ -145,14 +149,15 @@ export function processCodeBlocksWithLabel(ast: Root): RootContent[] {
145149
prevNode.type === 'paragraph' &&
146150
isPrevNodeSameLine
147151
) {
148-
;(prevNode.children || []).push({
149-
type: 'text',
150-
value: ` ${finalCommandText}`
151-
})
152+
;(prevNode.children || []).push(
153+
placeholderNode || { type: 'text', value: ` ${finalCommandText}` }
154+
)
152155
} else {
153156
newChildren.push({
154157
type: 'paragraph',
155-
children: [{ type: 'text', value: finalCommandText }]
158+
children: [
159+
placeholderNode || { type: 'text', value: finalCommandText }
160+
]
156161
} as RootContent)
157162
}
158163
} else {
@@ -163,7 +168,7 @@ export function processCodeBlocksWithLabel(ast: Root): RootContent[] {
163168
}
164169

165170
export function processContextCommand(input: string): string {
166-
const processor = remark()
171+
const processor = createRemarkProcessor()
167172
const ast = processor.parse(input) as Root
168173
ast.children = processCodeBlocksWithLabel(ast)
169174
return customAstToString(ast)
@@ -172,3 +177,51 @@ export function processContextCommand(input: string): string {
172177
export function convertContextBlockToPlaceholder(input: string): string {
173178
return processContextCommand(input)
174179
}
180+
181+
/**
182+
* Format an object into a markdown code block with proper metadata
183+
* @param label The label for the code block (e.g., 'file', 'symbol')
184+
* @param obj The object to format
185+
* @param content The content to include in the code block
186+
* @returns A formatted markdown code block string
187+
*/
188+
export function formatObjectToMarkdownBlock(
189+
label: string,
190+
obj: any,
191+
content: string
192+
): string {
193+
try {
194+
const objJSON = JSON.stringify(obj)
195+
196+
const codeNode: Root = {
197+
type: 'root',
198+
children: [
199+
{
200+
type: 'code',
201+
lang: 'context',
202+
meta: `label=${label} object=${objJSON}`,
203+
value: content
204+
} as RootContent
205+
]
206+
}
207+
208+
const processor = createRemarkProcessor()
209+
210+
const res = '\n' + processor.stringify(codeNode).trim() + '\n'
211+
return res
212+
} catch (error) {
213+
return `\n*Error formatting ${label}*\n`
214+
}
215+
}
216+
217+
export interface PlaceholderNode extends Node {
218+
type: 'placeholder'
219+
value: string
220+
}
221+
222+
export function createPlaceholderNode(value: string): PlaceholderNode {
223+
return {
224+
type: 'placeholder',
225+
value: value
226+
} as PlaceholderNode
227+
}

‎ee/tabby-ui/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@
130130
"swr": "^2.2.4",
131131
"tabby-chat-panel": "workspace:*",
132132
"tippy.js": "^6.3.7",
133+
"unified": "^11.0.5",
133134
"urql": "^4.0.6",
134135
"use-local-storage": "^3.0.0",
135136
"wonka": "^6.3.4",
+218
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { formatObjectToMarkdownBlock } from '../../lib/utils/markdown'
3+
import { Filepath } from 'tabby-chat-panel/index';
4+
5+
describe('formatObjectToMarkdownBlock - comprehensive tests', () => {
6+
describe('filepath types with standard content', () => {
7+
const jsContent = `// Example JavaScript code
8+
function example() {
9+
const greeting = "Hello World!";
10+
console.log(greeting);
11+
return {
12+
message: greeting,
13+
timestamp: new Date().getTime()
14+
};
15+
}
16+
17+
// ES6 features
18+
const arrowFunc = () => {
19+
return Promise.resolve(42);
20+
};
21+
`;
22+
23+
it('should format Unix path with git format', () => {
24+
const unixGitObj = {
25+
kind: "git",
26+
filepath: '/home/user/projects/example.js',
27+
gitUrl: "https://github.com/tabbyml/tabby"
28+
} as Filepath;
29+
30+
const result = formatObjectToMarkdownBlock('file', unixGitObj, jsContent);
31+
32+
expect(result).toContain('```context label=file');
33+
expect(result).toContain(`object=${JSON.stringify(unixGitObj)}`);
34+
expect(result).toContain(jsContent);
35+
expect(result).toContain('```');
36+
});
37+
38+
it('should format Unix path with uri format', () => {
39+
const unixUriObj = {
40+
kind: "uri",
41+
uri: '/home/user/projects/example.js'
42+
} as Filepath;
43+
44+
const result = formatObjectToMarkdownBlock('file', unixUriObj, jsContent);
45+
46+
expect(result).toContain('```context label=file');
47+
expect(result).toContain(`object=${JSON.stringify(unixUriObj)}`);
48+
expect(result).toContain(jsContent);
49+
expect(result).toContain('```');
50+
});
51+
52+
it('should format Windows path with uri format and backslashes', () => {
53+
const winUriObj = {
54+
kind: "uri",
55+
uri: 'C:\\Users\\johndoe\\Projects\\example.js'
56+
} as Filepath;
57+
58+
const result = formatObjectToMarkdownBlock('file', winUriObj, jsContent);
59+
60+
const expectedJson = JSON.stringify(winUriObj).replace(/\\\\/g, '\\\\\\');
61+
62+
expect(result).toContain('```context label=file');
63+
expect(result).toContain(`object=${expectedJson}`);
64+
expect(result).toContain(jsContent);
65+
expect(result).toContain('```');
66+
});
67+
});
68+
69+
describe('markdown content handling', () => {
70+
const markdownContent = `# Example Markdown Document
71+
72+
This is a Markdown document with various elements.
73+
74+
## JavaScript Code Example
75+
76+
\`\`\`javascript
77+
function hello() {
78+
console.log('Hello, world!');
79+
}
80+
\`\`\`
81+
82+
## Rust Code Example
83+
84+
\`\`\`rust
85+
fn main() {
86+
println!("Hello from Rust!");
87+
}
88+
\`\`\`
89+
90+
## Blockquotes and Lists
91+
92+
> This is a blockquote
93+
94+
* List item 1
95+
* List item 2
96+
* Nested list item
97+
`;
98+
99+
it('should correctly handle markdown with nested code blocks', () => {
100+
const gitObj = {
101+
kind: "git",
102+
filepath: '/home/user/docs/README.md',
103+
gitUrl: "https://github.com/tabbyml/tabby"
104+
} as Filepath;
105+
106+
const result = formatObjectToMarkdownBlock('file', gitObj, markdownContent);
107+
108+
expect(result).toContain('```context label=file');
109+
expect(result).toContain(`object=${JSON.stringify(gitObj)}`);
110+
expect(result).toContain('```javascript');
111+
expect(result).toContain('```rust');
112+
expect(result).toContain('# Example Markdown Document');
113+
expect(result).toContain('```');
114+
});
115+
116+
it('should correctly handle markdown with Windows paths', () => {
117+
const winUriObj = {
118+
kind: "uri",
119+
uri: 'C:\\Users\\johndoe\\Documents\\README.md'
120+
} as Filepath;
121+
122+
const result = formatObjectToMarkdownBlock('file', winUriObj, markdownContent);
123+
124+
const expectedJson = JSON.stringify(winUriObj).replace(/\\\\/g, '\\\\\\');
125+
126+
expect(result).toContain('```context label=file');
127+
expect(result).toContain(`object=${expectedJson}`);
128+
expect(result).toContain('```javascript');
129+
expect(result).toContain('```rust');
130+
expect(result).toContain('```');
131+
});
132+
});
133+
134+
describe('special cases', () => {
135+
it('should handle path with additional metadata', () => {
136+
const objWithMetadata = {
137+
kind: "git",
138+
filepath: '/Users/johndoe/Developer/main.rs',
139+
gitUrl: "https://github.com/tabbyml/tabby",
140+
line: 5,
141+
highlight: true
142+
} as Filepath;
143+
144+
const rustContent = `// Example Rust code
145+
fn main() {
146+
println!("Hello, Rust!");
147+
}`;
148+
149+
const result = formatObjectToMarkdownBlock('file', objWithMetadata, rustContent);
150+
151+
expect(result).toContain('```context label=file');
152+
expect(result).toContain(`object=${JSON.stringify(objWithMetadata)}`);
153+
expect(result).toContain(rustContent);
154+
expect(result).toContain('```');
155+
});
156+
157+
it('should handle special characters in paths', () => {
158+
const specialPathObj = {
159+
kind: "git",
160+
filepath: '/Users/user/Projects/special-chars/file with spaces.js',
161+
gitUrl: "https://github.com/tabbyml/tabby",
162+
branch: 'feature/new-branch'
163+
} as Filepath;
164+
165+
const jsContent = 'console.log("Special characters test");';
166+
167+
const result = formatObjectToMarkdownBlock('file', specialPathObj, jsContent);
168+
169+
expect(result).toContain('```context label=file');
170+
expect(result).toContain(`object=${JSON.stringify(specialPathObj)}`);
171+
expect(result).toContain(jsContent);
172+
expect(result).toContain('```');
173+
});
174+
175+
it('should handle complex content types', () => {
176+
const winObj = {
177+
kind: "uri",
178+
uri: 'D:\\Projects\\TypeScript\\interfaces.ts'
179+
} as Filepath;
180+
181+
const tsContent = `/**
182+
* User interface representing a person.
183+
*/
184+
interface User {
185+
id: number;
186+
name: string;
187+
email: string;
188+
isActive?: boolean; // Optional property
189+
}
190+
191+
/**
192+
* Create a new user with default values.
193+
*/
194+
function createUser(name: string, email: string): User {
195+
return {
196+
id: Math.floor(Math.random() * 1000),
197+
name,
198+
email,
199+
isActive: true
200+
};
201+
}
202+
203+
// Test the function
204+
const newUser = createUser("John Doe", "john@example.com");
205+
console.log(newUser);
206+
`;
207+
208+
const result = formatObjectToMarkdownBlock('file', winObj, tsContent);
209+
210+
const expectedJson = JSON.stringify(winObj).replace(/\\\\/g, '\\\\\\');
211+
212+
expect(result).toContain('```context label=file');
213+
expect(result).toContain(`object=${expectedJson}`);
214+
expect(result).toContain(tsContent);
215+
expect(result).toContain('```');
216+
});
217+
});
218+
});

‎pnpm-lock.yaml

+139-107
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.