Skip to content

Commit 94ee98a

Browse files
committed
fix: Markdown render and shiki initialization
1 parent fe80cc1 commit 94ee98a

File tree

11 files changed

+344
-128
lines changed

11 files changed

+344
-128
lines changed

package-lock.json

Lines changed: 54 additions & 51 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@
7373
"@igniteui/material-icons-extended": "^3.1.0",
7474
"@lit-labs/ssr-dom-shim": "^1.3.0",
7575
"@types/source-map": "0.5.2",
76-
"dompurify": "^3.2.7",
76+
"dompurify": "^3.3.0",
7777
"express": "^5.1.0",
7878
"fflate": "^0.8.1",
7979
"igniteui-theming": "^20.0.0",

projects/igniteui-angular/src/lib/chat/chat.component.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,25 +54,30 @@ type ChatTemplatesContextMap = {
5454
};
5555
};
5656

57-
export type NgChatTemplates = {
57+
export type IgxChatTemplates = {
5858
[K in keyof ChatRenderers]?: TemplateRef<ChatTemplatesContextMap[K]>;
5959
};
6060

61-
export type NgChatOptions = Omit<IgcChatOptions, 'renderers'>;
61+
export type IgxChatOptions = Omit<IgcChatOptions, 'renderers'>;
6262

6363

6464
@Component({
6565
selector: 'igx-chat',
6666
changeDetection: ChangeDetectionStrategy.OnPush,
6767
schemas: [CUSTOM_ELEMENTS_SCHEMA],
6868
templateUrl: './chat.component.html',
69+
styles: `
70+
igc-chat {
71+
--igc-chat-height: calc(100vh - 32px);
72+
}
73+
`
6974
})
7075
export class IgxChatComponent implements OnInit, OnDestroy {
7176
//#region Internal state
7277

7378
private readonly _view = inject(ViewContainerRef);
7479
private readonly _templateViewRefs = new Map<TemplateRef<any>, Set<ViewRef>>();
75-
private _oldTemplates: NgChatTemplates = {};
80+
private _oldTemplates: IgxChatTemplates = {};
7681

7782
protected readonly _mergedOptions = signal<IgcChatOptions>({});
7883
protected readonly _transformedTemplates = signal<ChatRenderers>({});
@@ -85,8 +90,8 @@ export class IgxChatComponent implements OnInit, OnDestroy {
8590
public readonly draftMessage = input<
8691
{ text: string; attachments?: IgcChatMessageAttachment[] } | undefined
8792
>({ text: '' });
88-
public readonly options = input<NgChatOptions>({});
89-
public readonly templates = input<NgChatTemplates>({});
93+
public readonly options = input<IgxChatOptions>({});
94+
public readonly templates = input<IgxChatTemplates>({});
9095

9196
//#endregion
9297

@@ -131,7 +136,7 @@ export class IgxChatComponent implements OnInit, OnDestroy {
131136
});
132137
}
133138

134-
private _mergeOptions(options: NgChatOptions): void {
139+
private _mergeOptions(options: IgxChatOptions): void {
135140
const transformedTemplates = this._transformedTemplates();
136141
const merged: IgcChatOptions = {
137142
...options,
@@ -140,12 +145,12 @@ export class IgxChatComponent implements OnInit, OnDestroy {
140145
this._mergedOptions.set(merged);
141146
}
142147

143-
private _setTemplates(newTemplates: NgChatTemplates): void {
148+
private _setTemplates(newTemplates: IgxChatTemplates): void {
144149
const templateCopies: ChatRenderers = {};
145-
const newTemplateKeys = Object.keys(newTemplates) as Array<keyof NgChatTemplates>;
150+
const newTemplateKeys = Object.keys(newTemplates) as Array<keyof IgxChatTemplates>;
146151

147152
const oldTemplates = this._oldTemplates;
148-
const oldTemplateKeys = Object.keys(oldTemplates) as Array<keyof NgChatTemplates>;
153+
const oldTemplateKeys = Object.keys(oldTemplates) as Array<keyof IgxChatTemplates>;
149154

150155
for (const key of oldTemplateKeys) {
151156
const oldRef = oldTemplates[key];
@@ -174,7 +179,7 @@ export class IgxChatComponent implements OnInit, OnDestroy {
174179
this._transformedTemplates.set(templateCopies);
175180
}
176181

177-
private _createTemplateRenderer<K extends keyof NgChatTemplates>(ref: NonNullable<NgChatTemplates[K]>) {
182+
private _createTemplateRenderer<K extends keyof IgxChatTemplates>(ref: NonNullable<IgxChatTemplates[K]>) {
178183
type ChatContext = ExtractChatContext<NonNullable<ChatRenderers[K]>>;
179184

180185
if (!this._templateViewRefs.has(ref)) {
@@ -239,3 +244,7 @@ export class IgxChatInputContextDirective {
239244
return true;
240245
}
241246
}
247+
248+
export { MarkdownPipe } from './markdown-pipe';
249+
250+

projects/igniteui-angular/src/lib/chat/chat.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'
2-
import { IgxChatComponent, IgxChatMessageContextDirective, NgChatTemplates } from './chat.component'
2+
import { IgxChatComponent, IgxChatMessageContextDirective, type IgxChatTemplates } from './chat.component'
33
import { Component, signal, TemplateRef, viewChild } from '@angular/core';
44
import type { IgcChatComponent, IgcChatMessage, IgcTextareaComponent } from 'igniteui-webcomponents';
55

@@ -144,7 +144,7 @@ class ChatTemplatesBed {
144144
imports: [IgxChatComponent, IgxChatMessageContextDirective]
145145
})
146146
class ChatDynamicTemplatesBed {
147-
public templates = signal<NgChatTemplates | null>(null);
147+
public templates = signal<IgxChatTemplates | null>(null);
148148
public messages = signal<IgcChatMessage[]>([{
149149
id: '1',
150150
sender: 'user',
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { DomSanitizer } from '@angular/platform-browser';
2+
import { TestBed } from '@angular/core/testing';
3+
import { IgxChatMarkdownService } from './markdown-service';
4+
import { MarkdownPipe } from './markdown-pipe';
5+
import Spy = jasmine.Spy;
6+
7+
// Mock the Service: We only care that the pipe calls the service and gets an HTML string.
8+
// We provide a *known* unsafe HTML string to ensure sanitization is working.
9+
const mockUnsafeHtml = `
10+
<pre class="shiki" style="color: var(--shiki-fg);"><code><span style="color: #FF0000;">unsafe</span></code></pre>
11+
<img src="x" onerror="alert(1)">
12+
`;
13+
14+
class MockChatMarkdownService {
15+
public async parse(_: string): Promise<string> {
16+
return mockUnsafeHtml;
17+
}
18+
}
19+
20+
describe('MarkdownPipe', () => {
21+
let pipe: MarkdownPipe;
22+
let sanitizer: DomSanitizer;
23+
let bypassSpy: Spy;
24+
25+
beforeEach(() => {
26+
TestBed.configureTestingModule({
27+
providers: [
28+
MarkdownPipe,
29+
{ provide: IgxChatMarkdownService, useClass: MockChatMarkdownService },
30+
],
31+
});
32+
33+
pipe = TestBed.inject(MarkdownPipe);
34+
sanitizer = TestBed.inject(DomSanitizer);
35+
bypassSpy = spyOn(sanitizer, 'bypassSecurityTrustHtml').and.callThrough();
36+
});
37+
38+
it('should be created', () => {
39+
expect(pipe).toBeTruthy();
40+
});
41+
42+
it('should call the service, sanitize content, and return SafeHtml', async () => {
43+
await pipe.transform('some markdown');
44+
45+
expect(bypassSpy).toHaveBeenCalledTimes(1);
46+
47+
const sanitizedString = bypassSpy.calls.mostRecent().args[0];
48+
49+
expect(sanitizedString).not.toContain('onerror');
50+
expect(sanitizedString).toContain('style="color: var(--shiki-fg);"');
51+
});
52+
53+
it('should handle undefined input text', async () => {
54+
await pipe.transform(undefined);
55+
expect(sanitizer.bypassSecurityTrustHtml).toHaveBeenCalled();
56+
});
57+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import DOMPurify from 'dompurify';
2+
import { inject, Pipe, type PipeTransform } from '@angular/core';
3+
import { IgxChatMarkdownService } from './markdown-service';
4+
import { DomSanitizer, type SafeHtml } from '@angular/platform-browser';
5+
6+
7+
@Pipe({ name: 'fromMarkdown' })
8+
export class MarkdownPipe implements PipeTransform {
9+
private _service = inject(IgxChatMarkdownService);
10+
private _sanitizer = inject(DomSanitizer);
11+
12+
13+
public async transform(text?: string): Promise<SafeHtml> {
14+
return this._sanitizer.bypassSecurityTrustHtml(DOMPurify.sanitize(
15+
await this._service.parse(text ?? '')
16+
));
17+
}
18+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { TestBed } from '@angular/core/testing';
2+
import { IgxChatMarkdownService } from './markdown-service';
3+
4+
describe('IgxChatMarkdownService', () => {
5+
let service: IgxChatMarkdownService;
6+
7+
beforeEach(() => {
8+
TestBed.configureTestingModule({});
9+
service = TestBed.inject(IgxChatMarkdownService);
10+
});
11+
12+
it('should be created', () => {
13+
expect(service).toBeTruthy();
14+
});
15+
16+
it('should parse basic markdown to HTML', async () => {
17+
const markdown = '**Hello** *World*';
18+
const expectedHtml = '<p><strong>Hello</strong> <em>World</em></p>\n';
19+
20+
const result = await service.parse(markdown);
21+
expect(result).toBe(expectedHtml);
22+
});
23+
24+
it('should parse a code block with shiki highlighting', async () => {
25+
const markdown = '```typescript\nconst x = 5;\n```';
26+
const result = await service.parse(markdown);
27+
28+
expect(result).toContain('<pre class="shiki shiki-themes github-light github-dark"');
29+
expect(result).toContain('const');
30+
expect(result).toMatch(/--shiki-.*?/);
31+
expect(result).toContain('code');
32+
});
33+
34+
it('should apply custom link extension with target="_blank"', async () => {
35+
const markdown = '[Infragistics](https://www.infragistics.com)';
36+
const expectedLink = '<p><a href="https://www.infragistics.com" target="_blank" rel="noopener noreferrer" >Infragistics</a></p>';
37+
38+
const result = await service.parse(markdown);
39+
expect(result).toContain(expectedLink);
40+
});
41+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { Injectable } from '@angular/core';
2+
import { Marked } from 'marked';
3+
import markedShiki from 'marked-shiki';
4+
import { bundledThemes, createHighlighter } from 'shiki/bundle/web';
5+
6+
7+
const DEFAULT_LANGUAGES = ['javascript', 'typescript', 'html', 'css'];
8+
const DEFAULT_THEMES = {
9+
light: 'github-light',
10+
dark: 'github-dark'
11+
};
12+
13+
@Injectable({ providedIn: 'root' })
14+
export class IgxChatMarkdownService {
15+
16+
private _instance: Marked;
17+
private _isInitialized: Promise<void>;
18+
19+
private _initializeMarked(): void {
20+
this._instance = new Marked({
21+
breaks: true,
22+
gfm: true,
23+
extensions: [
24+
{
25+
name: 'link',
26+
renderer({ href, title, text }) {
27+
return `<a href="${href}" target="_blank" rel="noopener noreferrer" ${title ? `title="${title}"` : ''}>${text}</a>`;
28+
}
29+
}
30+
]
31+
});
32+
}
33+
34+
private async _initializeShiki(): Promise<void> {
35+
const highlighter = await createHighlighter({
36+
langs: DEFAULT_LANGUAGES,
37+
themes: Object.keys(bundledThemes)
38+
});
39+
40+
this._instance.use(
41+
markedShiki({
42+
highlight(code, lang, _) {
43+
try {
44+
return highlighter.codeToHtml(code, {
45+
lang,
46+
themes: DEFAULT_THEMES,
47+
});
48+
49+
} catch {
50+
return `<pre><code>${code}</code></pre>`;
51+
}
52+
}
53+
})
54+
);
55+
}
56+
57+
58+
constructor() {
59+
this._initializeMarked();
60+
this._isInitialized = this._initializeShiki();
61+
}
62+
63+
public async parse(text: string): Promise<string> {
64+
await this._isInitialized;
65+
return await this._instance.parse(text);
66+
}
67+
}

projects/igniteui-angular/src/public_api.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,3 +200,6 @@ export * from './lib/tabs/tabs/tabs.module';
200200
export * from './lib/time-picker/time-picker.module';
201201
export * from './lib/toast/toast.module';
202202
export * from './lib/tree/tree.module';
203+
204+
export * from './lib/chat/chat.component';
205+
export { MarkdownPipe } from './lib/chat/markdown-pipe';

src/app/chat/chat.sample.html

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
<div>
2-
<igx-chat [options]="options" [messages]="messages" [templates]="{ messageContent: renderer }"></igx-chat>
3-
<ng-template #renderer let-message igxChatMessageContext>
4-
{{ markdownRenderer()(message) }}
5-
<!-- {{ message.text.toUpperCase() }} -->
6-
</ng-template>
7-
</div>
1+
<igx-chat [options]="options()" [messages]="messages()" [templates]="templates()" />
2+
3+
<ng-template #renderer let-message igxChatMessageContext>
4+
<div [innerHTML]="message.text | fromMarkdown | async"></div>
5+
</ng-template>

0 commit comments

Comments
 (0)