Skip to content

Commit 95ab978

Browse files
committed
refactor(chat): Simplify markdown renderer
1 parent 9382e23 commit 95ab978

File tree

2 files changed

+54
-119
lines changed

2 files changed

+54
-119
lines changed
Lines changed: 40 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import DOMPurify from 'dompurify';
2-
import { html, type TemplateResult } from 'lit';
3-
import { Marked, Renderer } from 'marked';
2+
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
3+
import { Marked } from 'marked';
44
import markedShiki from 'marked-shiki';
55
import { getSingletonHighlighter } from 'shiki';
66
import type { IgcMessage } from '../types.js';
@@ -30,121 +30,50 @@ export interface MarkdownRendererOptions {
3030
sanitizer?: (html: string) => string;
3131
}
3232

33-
/**
34-
* A renderer that converts markdown chat messages to HTML using `marked`,
35-
* with optional syntax highlighting powered by `shiki`.
36-
*
37-
*/
38-
export class MarkdownMessageRenderer {
39-
private highlighter?: any;
40-
private theme: string;
41-
private langs: string[];
42-
private _marked: Marked;
43-
private initialized = false;
44-
private _sanitizer: (html: string) => string;
45-
46-
/**
47-
* Creates a new MarkdownMessageRenderer.
48-
*
49-
* @param {MarkdownRendererOptions} [opts={}] - Configuration options.
50-
*/
51-
constructor(private opts: MarkdownRendererOptions = {}) {
52-
this.theme = opts.theme ?? 'github-light';
53-
this.langs = opts.languages ?? ['javascript', 'typescript', 'html', 'css'];
54-
55-
this._marked = new Marked();
56-
this.initMarked();
57-
this._sanitizer = this.initSanitizer();
58-
}
59-
60-
/**
61-
* Initializes the `marked` instance with custom renderer options.
62-
* Currently modifies link rendering to open in a new tab with safe attributes.
63-
*
64-
* @private
65-
*/
66-
private initMarked() {
67-
const renderer = new Renderer();
68-
69-
// Customize link rendering
70-
renderer.link = (href, title, text) => {
71-
return `<a href="${href}" target="_blank" rel="noopener noreferrer" ${title ? `title="${title}"` : ''}>${text}</a>`;
72-
};
73-
74-
this._marked.setOptions({
75-
gfm: true,
76-
breaks: true,
77-
renderer,
78-
});
79-
}
80-
81-
private initSanitizer() {
82-
return this.opts?.sanitizer ?? DOMPurify.sanitize;
83-
}
84-
85-
/**
86-
* Performs async initialization for syntax highlighting.
87-
* Loads Shiki and configures the `marked` instance with `marked-shiki`.
88-
* This is called lazily during rendering unless pre-called explicitly.
89-
*
90-
* @async
91-
* @returns {Promise<void>} A promise that resolves once initialization is complete.
92-
*/
93-
async init(): Promise<void> {
94-
if (this.highlighter || this.opts.noHighlighter) {
95-
this.initialized = true;
96-
return;
97-
}
98-
99-
this.highlighter = await getSingletonHighlighter({
100-
themes: [this.theme],
101-
langs: this.langs,
33+
export async function createMarkdownRenderer(
34+
options?: MarkdownRendererOptions
35+
) {
36+
const sanitizer = options?.sanitizer ?? DOMPurify.sanitize;
37+
38+
const markedInstance = new Marked({
39+
breaks: true,
40+
gfm: true,
41+
extensions: [
42+
{
43+
name: 'link',
44+
renderer({ href, title, text }) {
45+
return `<a href="${href}" target="_blank" rel="noopener noreferrer" ${title ? `title="${title}"` : ''}>${text}</a>`;
46+
},
47+
},
48+
],
49+
});
50+
51+
if (!options?.noHighlighter) {
52+
const themes = [options?.theme ?? 'github-light'];
53+
const langs = options?.languages
54+
? options.languages
55+
: ['javascript', 'typescript', 'html', 'css'];
56+
57+
const shikiInstance = await getSingletonHighlighter({
58+
themes,
59+
langs,
10260
});
10361

104-
this._marked.use(
62+
markedInstance.use(
10563
markedShiki({
106-
highlight: (code: any, lang: string) => {
107-
try {
108-
const safeLang =
109-
lang && this.highlighter.getLoadedLanguages?.().includes(lang)
110-
? lang
111-
: 'text';
112-
113-
return (
114-
this.highlighter?.codeToHtml(code, {
115-
lang: safeLang,
116-
themes: { light: this.theme },
117-
}) ?? code
118-
);
119-
} catch (_err) {
120-
// if Shiki still throws for some reason, just return escaped code
121-
return `<pre><code>${DOMPurify.sanitize(code)}</code></pre>`;
122-
}
64+
highlight(code, lang, _) {
65+
return shikiInstance.codeToHtml(code, {
66+
lang,
67+
themes: { light: themes[0] },
68+
});
12369
},
12470
})
12571
);
126-
127-
this.initialized = true;
12872
}
12973

130-
/**
131-
* Renders the given chat message as markdown, with optional syntax highlighting.
132-
*
133-
* @param {IgcMessage} message - The message to render.
134-
* @returns {Promise<TemplateResult>} A lit template containing the rendered markdown content.
135-
*/
136-
async render(message: IgcMessage): Promise<TemplateResult> {
137-
if (!this.initialized) {
138-
await this.init();
139-
}
140-
141-
if (!message.text) return html``;
142-
143-
const rendered = await this._marked?.parse(message.text);
144-
const cleanHtml = this._sanitizer(rendered);
145-
const template = document.createElement('template');
146-
template.innerHTML = cleanHtml ?? message.text;
147-
148-
return html`${template.content}`;
149-
}
74+
return async (message: IgcMessage): Promise<unknown> => {
75+
return message.text
76+
? unsafeHTML(sanitizer(await markedInstance.parse(message.text)))
77+
: '';
78+
};
15079
}

stories/chat.stories.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
defineComponents,
77
registerIcon,
88
} from 'igniteui-webcomponents';
9-
import { MarkdownMessageRenderer } from 'igniteui-webcomponents/extras';
9+
import { createMarkdownRenderer } from 'igniteui-webcomponents/extras';
1010
import type {
1111
IgcChatOptions,
1212
IgcMessage,
@@ -174,9 +174,9 @@ const _customRenderer = {
174174
return html`<span>${m.text.toUpperCase()}</span>`;
175175
},
176176
};
177-
const _markdownRenderer = new MarkdownMessageRenderer();
177+
const _markdownRenderer = await createMarkdownRenderer();
178178

179-
const ai_chat_options = {
179+
const ai_chat_options: IgcChatOptions = {
180180
headerText: 'Chat',
181181
inputPlaceholder: 'Type your message here...',
182182
suggestions: [
@@ -185,7 +185,7 @@ const ai_chat_options = {
185185
'Show me very short sample typescript code',
186186
],
187187
renderers: {
188-
messageContent: (ctx) => _markdownRenderer.render(ctx.param),
188+
messageContent: async ({ param }) => _markdownRenderer(param),
189189
},
190190
};
191191

@@ -196,7 +196,7 @@ const chat_options: IgcChatOptions = {
196196
inputPlaceholder: 'Type your message here...',
197197
headerText: 'Chat',
198198
renderers: {
199-
messageContent: async (ctx) => _markdownRenderer.render(ctx.param),
199+
messageContent: async (ctx) => _markdownRenderer(ctx.param),
200200
},
201201
};
202202

@@ -482,6 +482,13 @@ export const Basic: Story = {
482482
render: () => {
483483
messages = initialMessages;
484484
return html`
485+
<style>
486+
.shiki {
487+
code {
488+
font-family: monospace;
489+
}
490+
}
491+
</style>
485492
<igc-chat
486493
style="--igc-chat-height: calc(100vh - 32px);"
487494
.messages=${messages}
@@ -505,7 +512,7 @@ export const AI: Story = {
505512
`,
506513
};
507514

508-
let options: any;
515+
let options: IgcChatOptions;
509516
export const Chat_Templates: Story = {
510517
play: async () => {
511518
const chat = document.querySelector('igc-chat');
@@ -525,8 +532,7 @@ export const Chat_Templates: Story = {
525532
suggestions: ['Hello', 'Hi', 'Generate an image!'],
526533
renderers: {
527534
messageHeader: (ctx) => _messageAuthorTemplate(ctx.param, ctx),
528-
messageContent: (ctx) => _markdownRenderer.render(ctx.param),
529-
// messageContent: (ctx) => html`${ctx.param.text.toUpperCase()}`,
535+
messageContent: (ctx) => _markdownRenderer(ctx.param),
530536
messageActions: (ctx) => _messageActionsTemplate(ctx.param),
531537
attachmentHeader: () => nothing,
532538
inputActions: (ctx) => _actionsTemplate(ctx),

0 commit comments

Comments
 (0)