Skip to content

Commit 69cffe7

Browse files
fix(CodeBlock): render nested ambiguous markdown fence as raw
- unambiguous fences render as code blocks - ambiguous nested same-length fences render as literal raw text instead of broken code UI For raw segments in `src/components/Messages/Messages.tsx`, the renderer: - checks for a complete outer ```markdown... ``` wrapper - strips that wrapper - renders the inner content in CodeBlock with language="markdown" So ambiguous nested markdown examples now show as markdown source without the outer ```markdown fence clutter, while still avoiding reparsing as live markdown.
1 parent 754d1f6 commit 69cffe7

3 files changed

Lines changed: 334 additions & 116 deletions

File tree

src/components/CodeBlock/CodeBlock.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ interface CodeBlockProps {
1111

1212
const highlightCache = new Map<string, string>();
1313

14-
export const CODE_BLOCK_REGEX =
14+
const CODE_BLOCK_REGEX =
1515
/^(?<indent>[ \t]*)(`{3,})(\w+)?[ \t]*\n([\s\S]*?)^\k<indent>\2[ \t]*$/gm;
1616

1717
export function normalizeCodeBlockContent(

src/components/Messages/Messages.test.tsx

Lines changed: 202 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -258,91 +258,104 @@ describe('Messages', () => {
258258
expect(frame).not.toContain('\n ## Usage');
259259
});
260260

261-
it('renders the markdown sample assistant message without leaking fenced block delimiters', () => {
262-
const markdownSamplesMessage: { role: Role; content: string } = {
261+
it('falls back to raw text for ambiguous nested fences inside markdown examples', () => {
262+
const nestedFenceMessage: { role: Role; content: string } = {
263263
role: ROLE.ASSISTANT,
264-
content:
265-
"Based on the search results, Markdown is a lightweight markup language used to format plain text. It's designed to be easy to read and write, and it gets converted into HTML for display.\n\n" +
266-
"Here are some common markdown samples covering the basic syntax. I'll show you the **Markdown Input** and what the **Rendered Output** should look like.\n\n" +
267-
'### ✏️ Basic Structure & Formatting\n\n' +
268-
'| Feature | Markdown Input | Rendered Output |\n' +
269-
'| :--- | :--- | :--- |\n' +
270-
'| **Heading 1** | `# Main Title` | **<h1>Main Title</h1>** |\n' +
271-
'| **Heading 2** | `## Section Header` | **<h2>Section Header</h2>** |\n' +
272-
'| **Heading 3** | `### Subsection` | **<h3>Subsection</h3>** |\n' +
273-
'| **Bold Text** | `**This text is bold**` or `__This text is bold__` | **This text is bold** |\n' +
274-
'| **Italics Text** | `*This text is italic*` or `_This text is italic_` | *This text is italic* |\n' +
275-
'| **Strikethrough** | `~~This text is crossed out~~` | ~~This text is crossed out~~ |\n' +
276-
'| **Blockquote** | `> This is a quote.` | *This is a quote.* |\n\n' +
277-
'### 📝 Lists\n\n' +
278-
'Markdown supports ordered (numbered) and unordered (bulleted) lists.\n\n' +
279-
'**Unordered List (Bullets)**\n' +
280-
'```markdown\n' +
281-
'* Item one\n' +
282-
'* Item two\n' +
283-
' * Sub-item A\n' +
284-
' * Sub-item B\n' +
285-
'* Item three\n' +
286-
'```\n' +
287-
'*Rendered Output:*\n' +
288-
'* Item one\n' +
289-
'* Item two\n' +
290-
' * Sub-item A\n' +
291-
' * Sub-item B\n' +
292-
'* Item three\n\n' +
293-
'**Ordered List (Numbered)**\n' +
294-
'```markdown\n' +
295-
'1. First step\n' +
296-
'2. Second step\n' +
297-
'3. Third step\n' +
298-
'```\n' +
299-
'*Rendered Output:*\n' +
300-
'1. First step\n' +
301-
'2. Second step\n' +
302-
'3. Third step\n\n' +
303-
'### 🔗 Links and Images\n\n' +
304-
'| Element | Markdown Input | Rendered Output |\n' +
305-
'| :--- | :--- | :--- |\n' +
306-
'| **Link** | `[Google Links](https://www.google.com)` | [Google Links](https://www.google.com) |\n' +
307-
'| **Image** | `![Alt text](image-url.jpg)` | *(Displays an image)* |\n\n' +
308-
'### 💻 Code Blocks\n\n' +
309-
'Code blocks are essential for showing snippets of code. There are two main types:\n\n' +
310-
'1. **Inline Code** (for short snippets within a sentence): Use single backticks (\\`).\n' +
311-
" *Input:* `The function is called \\`calculateSum()\\`.'`\n" +
312-
' *Output:* The function is called `calculateSum()`.\n\n' +
313-
'2. **Code Block** (for multi-line code): Use triple backticks (```) and optionally specify the language for syntax highlighting.\n' +
314-
' *Input:*\n' +
315-
' ```typescript\n' +
316-
' function greet(name: string): void {\n' +
317-
' console.log(`Hello, ${name}!`);\n' +
318-
' }\n' +
319-
' ```\n' +
320-
' *Output:* (Formatted as a code block, typically with syntax highlighting)\n\n' +
321-
'### 📊 Tables\n\n' +
322-
'Tables are structured using pipes (`|`) and hyphens (`-`).\n\n' +
323-
'```markdown\n' +
324-
'| Header 1 | Header 2 | Header 3 |\n' +
325-
'| :--- | :---: | ---: |\n' +
326-
'| Left Aligned | Center Aligned | Right Aligned |\n' +
327-
'| Data A | Data B | Data C |\n' +
328-
'```\n' +
329-
'*Rendered Output:* (A clean table structure)\n\n' +
330-
'***\n\n' +
331-
'Do you need samples for a more specific feature, such as **Tables**, **Footnotes**, or perhaps how to integrate this with **TypeScript/Code Snippets**?',
264+
content: [
265+
'**Current:**',
266+
'```markdown',
267+
'## Usage',
268+
'',
269+
'```sh',
270+
'code-ollama',
271+
'```',
272+
'```',
273+
'',
274+
'After example.',
275+
].join('\n'),
332276
};
333277

334278
const { lastFrame } = render(
335-
<Messages messages={[markdownSamplesMessage]} isLoading={false} />,
279+
<Messages messages={[nestedFenceMessage]} isLoading={false} />,
336280
);
337281
const frame = lastFrame() ?? '';
338282

339-
expect(frame).toContain('Basic Structure & Formatting');
340-
expect(frame).toContain('Unordered List (Bullets)');
341-
expect(frame).toContain('function greet(name: string): void {');
342-
expect(frame).toContain('console.log(`Hello, ${name}!`);');
343-
expect(frame).toContain('Do you need samples for a more specific feature');
283+
expect(frame).toContain('Current:');
284+
expect(frame).toContain('```sh');
285+
expect(frame).toContain('code-ollama');
344286
expect(frame).not.toContain('```markdown');
345-
expect(frame).not.toContain('```typescript');
287+
expect(frame).toContain('After example.');
288+
});
289+
290+
it('keeps non-markdown ambiguous raw fences literal inside a code block', () => {
291+
const nestedShellFenceMessage: { role: Role; content: string } = {
292+
role: ROLE.ASSISTANT,
293+
content: [
294+
'Shell example:',
295+
'```sh',
296+
'echo start',
297+
'```ts',
298+
'const x = 1;',
299+
'```',
300+
'```',
301+
].join('\n'),
302+
};
303+
304+
const { lastFrame } = render(
305+
<Messages messages={[nestedShellFenceMessage]} isLoading={false} />,
306+
);
307+
const frame = lastFrame() ?? '';
308+
309+
expect(frame).toContain('Shell example:');
310+
expect(frame).toContain('```sh');
311+
expect(frame).toContain('```ts');
312+
expect(frame).toContain('const x = 1;');
313+
});
314+
315+
it('does not swallow following markdown headings into the previous code block', () => {
316+
const messageWithFollowingHeading: { role: Role; content: string } = {
317+
role: ROLE.ASSISTANT,
318+
content: [
319+
'View the help documentation:',
320+
'',
321+
'```sh',
322+
'code-ollama --help',
323+
'```',
324+
'',
325+
'### ⭐ 3. Adding a "Prerequisites" Section',
326+
'',
327+
'**Goal:** Ensure users know what they need installed *before* they run the CLI.',
328+
].join('\n'),
329+
};
330+
331+
const { lastFrame } = render(
332+
<Messages messages={[messageWithFollowingHeading]} isLoading={false} />,
333+
);
334+
const frame = lastFrame() ?? '';
335+
const lines = frame.split('\n');
336+
const codeLineIndex = lines.findIndex((line) =>
337+
line.includes('code-ollama --help'),
338+
);
339+
const headingLineIndex = lines.findIndex((line) =>
340+
line.includes('3. Adding a "Prerequisites" Section'),
341+
);
342+
const borderAfterCode = lines.findIndex(
343+
(line, index) =>
344+
index > codeLineIndex &&
345+
(line.includes('┘') ||
346+
line.includes('┛') ||
347+
line.includes('└') ||
348+
line.includes('┗')),
349+
);
350+
351+
expect(frame).toContain('View the help documentation:');
352+
expect(frame).toContain('code-ollama --help');
353+
expect(frame).toContain('3. Adding a "Prerequisites" Section');
354+
expect(frame).toContain('Ensure users know what they need installed');
355+
expect(codeLineIndex).toBeGreaterThan(-1);
356+
expect(headingLineIndex).toBeGreaterThan(-1);
357+
expect(borderAfterCode).toBeGreaterThan(-1);
358+
expect(borderAfterCode).toBeLessThan(headingLineIndex);
346359
});
347360

348361
it('renders system code blocks as plain text (no syntax highlighting)', () => {
@@ -383,4 +396,116 @@ describe('Messages', () => {
383396
expect(frame).toContain('const x = 1;');
384397
expect(frame).toContain(UI.PROMPT_PREFIX);
385398
});
399+
400+
it('handles ambiguous nested fences with language identifiers', () => {
401+
const ambiguousNestedMessage: { role: Role; content: string } = {
402+
role: ROLE.ASSISTANT,
403+
content: [
404+
'Example:',
405+
'```markdown',
406+
'## Title',
407+
'```js',
408+
'console.log("hello");',
409+
'```',
410+
'```js',
411+
'const x = 2;',
412+
'```',
413+
'```',
414+
'Done.',
415+
].join('\n'),
416+
};
417+
418+
const { lastFrame } = render(
419+
<Messages messages={[ambiguousNestedMessage]} isLoading={false} />,
420+
);
421+
const frame = lastFrame() ?? '';
422+
423+
expect(frame).toContain('Example:');
424+
expect(frame).toContain('## Title');
425+
expect(frame).toContain('console.log("hello");');
426+
expect(frame).toContain('Done.');
427+
});
428+
429+
it('treats unclosed fences as plain text', () => {
430+
const unclosedMessage: { role: Role; content: string } = {
431+
role: ROLE.ASSISTANT,
432+
content: [
433+
'Start',
434+
'```typescript',
435+
'const x = 1;',
436+
'console.log(x);',
437+
].join('\n'),
438+
};
439+
440+
const { lastFrame } = render(
441+
<Messages messages={[unclosedMessage]} isLoading={false} />,
442+
);
443+
const frame = lastFrame() ?? '';
444+
445+
expect(frame).toContain('Start');
446+
expect(frame).toContain('const x = 1;');
447+
expect(frame).toContain('console.log(x);');
448+
});
449+
450+
it('handles empty code blocks', () => {
451+
const emptyCodeMessage: { role: Role; content: string } = {
452+
role: ROLE.ASSISTANT,
453+
content: 'Example:\n```typescript\n \n```\nDone.',
454+
};
455+
456+
const { lastFrame } = render(
457+
<Messages messages={[emptyCodeMessage]} isLoading={false} />,
458+
);
459+
const frame = lastFrame() ?? '';
460+
461+
expect(frame).toContain('Example:');
462+
expect(frame).toContain('Done.');
463+
});
464+
465+
it('handles mismatched fence markers with same indent', () => {
466+
const mismatchedFenceMessage: { role: Role; content: string } = {
467+
role: ROLE.ASSISTANT,
468+
content: [
469+
'Example:',
470+
'```typescript',
471+
'const x = 1;',
472+
'~~~~',
473+
'four ticks',
474+
'~~~~',
475+
'```',
476+
].join('\n'),
477+
};
478+
479+
const { lastFrame } = render(
480+
<Messages messages={[mismatchedFenceMessage]} isLoading={false} />,
481+
);
482+
const frame = lastFrame() ?? '';
483+
484+
expect(frame).toContain('Example:');
485+
expect(frame).toContain('const x = 1;');
486+
expect(frame).toContain('four ticks');
487+
});
488+
489+
it('handles different indent with same fence chars', () => {
490+
const differentIndentMessage: { role: Role; content: string } = {
491+
role: ROLE.ASSISTANT,
492+
content: [
493+
'Example:',
494+
'```typescript',
495+
'const x = 1;',
496+
' ```',
497+
'indented close',
498+
'```',
499+
].join('\n'),
500+
};
501+
502+
const { lastFrame } = render(
503+
<Messages messages={[differentIndentMessage]} isLoading={false} />,
504+
);
505+
const frame = lastFrame() ?? '';
506+
507+
expect(frame).toContain('Example:');
508+
expect(frame).toContain('const x = 1;');
509+
expect(frame).toContain('indented close');
510+
});
386511
});

0 commit comments

Comments
 (0)