Markdown Rendering with Syntax Highlighting Implementation
Overview
This document explains the implementation of markdown rendering with syntax highlighting for LLM (Large Language Model) responses in an Angular 17 chat application. The goal was to transform plain text LLM responses containing markdown into properly formatted, syntax-highlighted content.
Initial Problem
The chat application was displaying LLM messages as plain text using simple Angular interpolation ({{ message.content }}
). This meant that markdown formatting like bold, italic, code blocks
, lists, and other markdown elements were not being rendered properly - they appeared as raw markdown syntax.
Example of the Problem:
Input: "Here's some **bold text** and a code example:\n```typescript\nconst hello = 'world';\n```"
Output: "Here's some **bold text** and a code example:\n```typescript\nconst hello = 'world';\n```"
Solution Overview
We implemented a comprehensive solution using:
- ngx-markdown - Angular library for markdown rendering
- Prism.js - Syntax highlighting library
- Custom styling - Themed to match the chat interface
- Service architecture - Clean, reusable implementation
Implementation Steps
Step 1: Library Selection and Installation
Decision: Why ngx-markdown?
- Specifically designed for Angular applications
- Built-in security (XSS protection)
- Easy integration with syntax highlighting
- Support for custom renderers
- Perfect for future code copying functionality
Installation:
npm install ngx-markdown@17.2.0 marked@9.1.6
npm install prismjs @types/prismjs
Version Compatibility Issue:
- Initial attempt to install latest ngx-markdown (v20.0.0) failed
- Problem: Version mismatch - ngx-markdown v20 requires Angular 20, but project uses Angular 17
- Solution: Installed compatible version ngx-markdown@17.2.0
Step 2: Basic Markdown Integration
Updated main.ts:
import { MarkdownModule, MARKED_OPTIONS } from 'ngx-markdown';
import { HttpClientModule } from '@angular/common/http';
bootstrapApplication(AppComponent, {
providers: [
importProvidersFrom(
// ... other modules
HttpClientModule, // Required for ngx-markdown
MarkdownModule.forRoot({
markedOptions: {
provide: MARKED_OPTIONS,
useValue: {
gfm: true, // GitHub Flavored Markdown
breaks: false, // Don't convert \n to <br>
pedantic: false, // Don't be strict about markdown
},
},
})
)
]
});
Updated MessageBubbleComponent:
// Added MarkdownModule to imports
imports: [CommonModule, MarkdownModule]
// Updated template to conditionally render markdown
<div class="message-content">
@if (message.isUser) {
{{ message.content }} <!-- Plain text for user messages -->
} @else {
<markdown [data]="message.content"></markdown> <!-- Markdown for AI messages -->
}
</div>
Step 3: Syntax Highlighting Implementation
Initial Approach - Direct Prism Import:
// This caused issues
import 'prismjs';
import 'prismjs/components/prism-typescript';
// ... more language imports
Problems Encountered:
-
CommonJS Warning:
Warning: CommonJS or AMD dependencies can cause optimization bailouts.
For more info see: https://angular.io/guide/build#configuring-commonjs-dependencies -
TypeScript Error:
error TS2693: 'MarkedOptions' only refers to a type, but is being used as a value here.
-
Type Declaration Errors:
error TS7016: Could not find a declaration file for module 'prismjs/components/prism-typescript'
Solutions Applied:
Solution 1: CommonJS Configuration
Problem: Angular's build optimizer doesn't handle CommonJS modules well Solution: Added allowedCommonJsDependencies to angular.json
{
"build": {
"options": {
"allowedCommonJsDependencies": [
"prismjs"
]
}
}
}
Solution 2: Fixed MarkedOptions Usage
Problem: Trying to use TypeScript type as a runtime value Solution: Used proper injection token
// Wrong:
provide: MarkedOptions,
useFactory: markedOptionsFactory,
// Correct:
provide: MARKED_OPTIONS,
useValue: {
gfm: true,
breaks: false,
pedantic: false,
},
Solution 3: Custom Type Declarations
Problem: Missing TypeScript declarations for Prism language components Solution: Created custom type declarations
Created src/types/prism.d.ts:
declare module 'prismjs/components/prism-*.js' {
const content: any;
export default content;
}
declare module 'prismjs/components/prism-*' {
const content: any;
export default content;
}
Updated tsconfig.json:
{
"compilerOptions": {
"typeRoots": [
"node_modules/@types",
"src/types" // Added custom types
]
}
}
Step 4: Service Architecture
Created PrismService for Better Management:
@Injectable({
providedIn: 'root'
})
export class PrismService {
private prismLoaded = false;
async loadPrism(): Promise<any> {
if (this.prismLoaded) {
return Promise.resolve(typeof Prism !== 'undefined' ? Prism : null);
}
try {
await import('prismjs');
await this.loadLanguageComponents();
this.prismLoaded = true;
return typeof Prism !== 'undefined' ? Prism : null;
} catch (error) {
console.error('Failed to load Prism:', error);
return null;
}
}
private async loadLanguageComponents(): Promise<void> {
const languages = [
'typescript', 'javascript', 'css', 'scss', 'json',
'markdown', 'bash', 'python', 'java', 'csharp', 'sql'
];
// Sequential loading to avoid conflicts
for (const lang of languages) {
try {
await import(`prismjs/components/prism-${lang}.js`);
} catch (error) {
console.warn(`Failed to load Prism language: ${lang}`, error);
}
}
}
async highlightElement(element: HTMLElement): Promise<void> {
const prism = await this.loadPrism();
if (prism && prism.highlightElement) {
try {
prism.highlightElement(element);
} catch (error) {
console.warn('Failed to highlight element:', error);
}
}
}
}
Benefits of Service Architecture:
- Centralized logic: All Prism-related code in one place
- Reusable: Other components can use the service
- Error handling: Graceful fallbacks
- Performance: Lazy loading of languages
- Maintainable: Easy to add new languages
Step 5: Component Integration
Updated MessageBubbleComponent:
export class MessageBubbleComponent implements AfterViewInit {
@Input() message!: Message;
@ViewChild('messageContent') messageContent!: ElementRef;
constructor(private prismService: PrismService) {}
ngAfterViewInit(): void {
this.highlightCode();
}
onMarkdownReady(): void {
// Highlight after markdown renders
setTimeout(() => this.highlightCode(), 0);
}
private async highlightCode(): Promise<void> {
if (this.messageContent && !this.message.isUser) {
const codeBlocks = this.messageContent.nativeElement.querySelectorAll('pre code');
for (const block of codeBlocks) {
await this.prismService.highlightElement(block as HTMLElement);
}
}
}
}
Step 6: Custom Styling
Added Comprehensive CSS in styles.scss:
/* Import Prism theme */
@import 'prismjs/themes/prism.css';
/* Custom Prism variables */
:root {
--prism-background: #f8f9fa;
--prism-text: #24292e;
--prism-comment: #6a737d;
--prism-keyword: #d73a49;
--prism-string: #032f62;
--prism-function: #6f42c1;
--prism-number: #005cc5;
--prism-operator: #d73a49;
}
/* Enhanced styling for code blocks */
pre[class*="language-"] {
padding: 1em !important;
margin: 0.8em 0 !important;
overflow: auto;
border-radius: 8px !important;
border: 1px solid rgba(0, 0, 0, 0.1) !important;
}
/* Dark theme for AI messages */
.ai-bubble {
--prism-background: rgba(255, 255, 255, 0.05);
--prism-text: #e1e4e8;
--prism-comment: #8b949e;
--prism-keyword: #ff7b72;
--prism-string: #a5d6ff;
--prism-function: #d2a8ff;
--prism-number: #79c0ff;
--prism-operator: #ff7b72;
}
Final Architecture
src/
├── main.ts # Bootstrap with MarkdownModule
├── styles.scss # Global Prism styling
├── types/
│ └── prism.d.ts # Custom type declarations
├── app/
│ ├── services/
│ │ └── prism.service.ts # Centralized Prism logic
│ └── components/
│ └── message-bubble/
│ └── message-bubble.component.ts # Updated with markdown rendering
Problems Solved
Problem | Root Cause | Solution |
---|---|---|
Version Compatibility | ngx-markdown v20 requires Angular 20 | Used compatible version 17.2.0 |
CommonJS Warnings | Angular build optimizer issues | Added allowedCommonJsDependencies |
TypeScript Type Error | Using type as value | Used MARKED_OPTIONS token |
Missing Type Declarations | Prism components lack TypeScript definitions | Created custom .d.ts files |
Performance Issues | Synchronous imports blocking | Async service with lazy loading |
Error Handling | No fallbacks for failed imports | Try-catch with graceful degradation |
Features Achieved
✅ Markdown Rendering
- Headers: H1-H6 with proper sizing
- Text formatting: Bold, italic, strikethrough
- Lists: Ordered and unordered
- Links: Clickable with hover effects
- Blockquotes: Styled with left border
- Tables: Full table support with borders
✅ Syntax Highlighting
- Languages: TypeScript, JavaScript, Python, CSS, JSON, SQL, Java, C#, Bash
- Themes: Light theme for general use, dark theme for AI messages
- Performance: Lazy loading, sequential language loading
- Error handling: Graceful fallbacks
✅ User Experience
- Conditional rendering: Plain text for users, markdown for AI
- Responsive design: Works on mobile and desktop
- Accessibility: Proper semantic HTML
- Performance: No blocking operations
Future Enhancements
- Code Copying: Add copy buttons to code blocks
- More Languages: Go, Rust, PHP, Swift, Kotlin
- Custom Themes: User-selectable color schemes
- Line Numbers: Optional line numbering for code blocks
- Code Folding: Collapsible large code blocks
Lessons Learned
- Version Compatibility: Always check library compatibility with your Angular version
- Build Configuration: Modern Angular requires explicit CommonJS dependency declarations
- Type Safety: Custom type declarations are sometimes necessary for third-party libraries
- Service Architecture: Centralizing complex logic in services improves maintainability
- Error Handling: Always implement graceful fallbacks for external dependencies
- Performance: Lazy loading and async operations prevent blocking the UI
Testing
To test the implementation, send messages with markdown content:
Here's a **bold** statement with some `inline code`.
## Code Example
```typescript
interface User {
name: string;
age: number;
}
const user: User = {
name: "John Doe",
age: 30
};
```
### List Example
- Item 1
- Item 2
- Item 3
> This is a blockquote with important information.
The result should be properly formatted markdown with syntax-highlighted code blocks, demonstrating the successful implementation of the entire system.