fix(markdown): preserve user code block characters (#1750)
Co-authored-by: Leonid Skorobogatyy <bash@opencode.itc.local>
This commit is contained in:
committed by
GitHub
co-authored by
Leonid Skorobogatyy
parent
8c551c40aa
commit
569342b3c2
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { prepareUserMarkdownContent } from './userTextPartContent';
|
||||||
|
|
||||||
|
describe('prepareUserMarkdownContent', () => {
|
||||||
|
test('keeps fenced code < and -> unescaped for the markdown renderer', () => {
|
||||||
|
const content = prepareUserMarkdownContent({
|
||||||
|
textContent: '```rust\nlet values: Vec<i32> = vec![];\nlet next = old -> new;\n```',
|
||||||
|
skillNames: new Set(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(content).toContain('Vec<i32>');
|
||||||
|
expect(content).toContain('old -> new');
|
||||||
|
expect(content).not.toContain('<');
|
||||||
|
expect(content).not.toContain('->');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('escapes raw HTML outside fences so tags display as text', () => {
|
||||||
|
const content = prepareUserMarkdownContent({
|
||||||
|
textContent: 'Use <b>bold</b> and <script>alert("x")</script>',
|
||||||
|
skillNames: new Set(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(content).toContain('<b>bold</b>');
|
||||||
|
expect(content).toContain('<script>alert("x")</script>');
|
||||||
|
expect(content).not.toContain('<b>bold</b>');
|
||||||
|
expect(content).not.toContain('<script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds hard line breaks outside fences but not inside', () => {
|
||||||
|
const content = prepareUserMarkdownContent({
|
||||||
|
textContent: 'first\nsecond\n```ts\nconst x = 1\nconst y = 2\n```\nthird',
|
||||||
|
skillNames: new Set(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(content).toContain('first \nsecond \n```ts\n');
|
||||||
|
expect(content).toContain('const x = 1\nconst y = 2\n``` \nthird');
|
||||||
|
expect(content).not.toContain('const x = 1 \nconst y = 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves mention conversion', () => {
|
||||||
|
const content = prepareUserMarkdownContent({
|
||||||
|
textContent: '@agent hello\n/skill-name',
|
||||||
|
agentMention: { name: 'build-agent', token: '@agent' },
|
||||||
|
skillNames: new Set(['skill-name']),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(content).toContain('[@agent](#openchamber-agent:build-agent)');
|
||||||
|
expect(content).toContain('[/skill-name](#openchamber-skill:skill-name)');
|
||||||
|
expect(content).toContain('hello \n[/skill-name]');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,11 +10,10 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
|||||||
import { getDirectoryForFilePath } from '@/lib/path-utils';
|
import { getDirectoryForFilePath } from '@/lib/path-utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import {
|
import {
|
||||||
buildAgentHref,
|
|
||||||
buildAgentMentionUrl,
|
buildAgentMentionUrl,
|
||||||
buildSkillHref,
|
|
||||||
parseSkillHref,
|
parseSkillHref,
|
||||||
} from '@/lib/messages/inlineMessageLinks';
|
} from '@/lib/messages/inlineMessageLinks';
|
||||||
|
import { prepareUserMarkdownContent, SKILL_TOKEN_PATTERN } from './userTextPartContent';
|
||||||
|
|
||||||
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
||||||
|
|
||||||
@@ -25,31 +24,10 @@ type UserTextPartProps = {
|
|||||||
agentMention?: AgentMentionInfo;
|
agentMention?: AgentMentionInfo;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SKILL_TOKEN_PATTERN = /(^|\s)\/([a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)/g;
|
|
||||||
|
|
||||||
const escapeHtml = (text: string): string => {
|
|
||||||
return text
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => {
|
const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => {
|
||||||
return mode === 'markdown' ? 'markdown' : 'plain';
|
return mode === 'markdown' ? 'markdown' : 'plain';
|
||||||
};
|
};
|
||||||
|
|
||||||
// In Markdown a single "\n" is a soft break (rendered as a space). Users type plain
|
|
||||||
// text where each newline is meant literally, so convert soft breaks into hard breaks
|
|
||||||
// (two trailing spaces) outside of fenced code blocks, where newlines are already literal.
|
|
||||||
const applyHardLineBreaks = (markdown: string): string => {
|
|
||||||
return markdown
|
|
||||||
.split(/(```[\s\S]*?```|~~~[\s\S]*?~~~)/g)
|
|
||||||
.map((segment, index) => (index % 2 === 1 ? segment : segment.replace(/ *\n/g, ' \n')))
|
|
||||||
.join('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMention }) => {
|
const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMention }) => {
|
||||||
const partWithText = part as PartWithText;
|
const partWithText = part as PartWithText;
|
||||||
const rawText = partWithText.text;
|
const rawText = partWithText.text;
|
||||||
@@ -145,26 +123,11 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const processedMarkdownContent = React.useMemo(() => {
|
const processedMarkdownContent = React.useMemo(() => {
|
||||||
let content = textContent;
|
return prepareUserMarkdownContent({
|
||||||
|
textContent,
|
||||||
// Step 1: First escape HTML to protect against XSS and ensure HTML tags display as text
|
agentMention,
|
||||||
content = escapeHtml(content);
|
skillNames: new Set(skillByName.keys()),
|
||||||
|
|
||||||
// Step 2: Insert agent mention links with an internal href so markdown renders them as mentions, not external links.
|
|
||||||
if (agentMention?.token && content.includes(agentMention.token)) {
|
|
||||||
const mentionMarkdown = `[${agentMention.token}](${buildAgentHref(agentMention.name)})`;
|
|
||||||
content = content.replace(agentMention.token, mentionMarkdown);
|
|
||||||
}
|
|
||||||
|
|
||||||
content = content.replace(SKILL_TOKEN_PATTERN, (match, prefix: string, skillName: string) => {
|
|
||||||
if (!skillByName.has(skillName)) return match;
|
|
||||||
return `${prefix}[/${skillName}](${buildSkillHref(skillName)})`;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 4: Preserve user newlines (markdown soft breaks would otherwise collapse to spaces)
|
|
||||||
content = applyHardLineBreaks(content);
|
|
||||||
|
|
||||||
return content;
|
|
||||||
}, [agentMention, skillByName, textContent]);
|
}, [agentMention, skillByName, textContent]);
|
||||||
|
|
||||||
const plainTextContent = React.useMemo(() => {
|
const plainTextContent = React.useMemo(() => {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { AgentMentionInfo } from '../types';
|
||||||
|
import { buildAgentHref, buildSkillHref } from '@/lib/messages/inlineMessageLinks';
|
||||||
|
|
||||||
|
export const SKILL_TOKEN_PATTERN = /(^|\s)\/([a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)/g;
|
||||||
|
|
||||||
|
const FENCED_CODE_SEGMENT_PATTERN = /(```[\s\S]*?```|~~~[\s\S]*?~~~)/g;
|
||||||
|
|
||||||
|
const escapeHtml = (text: string): string => {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapNonFencedSegments = (markdown: string, mapSegment: (segment: string) => string): string => {
|
||||||
|
return markdown
|
||||||
|
.split(FENCED_CODE_SEGMENT_PATTERN)
|
||||||
|
.map((segment, index) => (index % 2 === 1 ? segment : mapSegment(segment)))
|
||||||
|
.join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// In Markdown a single "\n" is a soft break (rendered as a space). Users type plain
|
||||||
|
// text where each newline is meant literally, so convert soft breaks into hard breaks
|
||||||
|
// (two trailing spaces) outside of fenced code blocks, where newlines are already literal.
|
||||||
|
const applyHardLineBreaks = (markdown: string): string => {
|
||||||
|
return mapNonFencedSegments(markdown, (segment) => segment.replace(/ *\n/g, ' \n'));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prepareUserMarkdownContent = ({
|
||||||
|
textContent,
|
||||||
|
agentMention,
|
||||||
|
skillNames,
|
||||||
|
}: {
|
||||||
|
textContent: string;
|
||||||
|
agentMention?: AgentMentionInfo;
|
||||||
|
skillNames: ReadonlySet<string>;
|
||||||
|
}): string => {
|
||||||
|
let content = mapNonFencedSegments(textContent, escapeHtml);
|
||||||
|
|
||||||
|
// Insert agent mention links with an internal href so markdown renders them as mentions, not external links.
|
||||||
|
if (agentMention?.token && content.includes(agentMention.token)) {
|
||||||
|
const mentionMarkdown = `[${agentMention.token}](${buildAgentHref(agentMention.name)})`;
|
||||||
|
content = content.replace(agentMention.token, mentionMarkdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
content = content.replace(SKILL_TOKEN_PATTERN, (match, prefix: string, skillName: string) => {
|
||||||
|
if (!skillNames.has(skillName)) return match;
|
||||||
|
return `${prefix}[/${skillName}](${buildSkillHref(skillName)})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Preserve user newlines (markdown soft breaks would otherwise collapse to spaces)
|
||||||
|
return applyHardLineBreaks(content);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user