Skip to content

Commit 09ec5aa

Browse files
feat: add chat component Angular wrapper (#16255)
Co-authored-by: Galina Edinakova <[email protected]>
1 parent b1794c1 commit 09ec5aa

25 files changed

+1002
-101
lines changed

package-lock.json

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

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,17 @@
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.3.0",
7677
"express": "^5.1.0",
7778
"fflate": "^0.8.1",
7879
"igniteui-theming": "^24.0.0",
7980
"igniteui-trial-watermark": "^3.1.0",
8081
"jspdf": "^3.0.4",
8182
"lodash-es": "^4.17.21",
83+
"marked": "^16.4.0",
84+
"marked-shiki": "^1.2.1",
8285
"rxjs": "^7.8.2",
86+
"shiki": "^3.13.0",
8387
"tslib": "^2.3.0",
8488
"zone.js": "~0.15.0"
8589
},
@@ -122,7 +126,7 @@
122126
"ig-typedoc-theme": "^7.0.0",
123127
"igniteui-dockmanager": "^1.17.0",
124128
"igniteui-sassdoc-theme": "^2.1.0",
125-
"igniteui-webcomponents": "6.2.1",
129+
"igniteui-webcomponents": "^6.3.1",
126130
"jasmine": "^5.6.0",
127131
"jasmine-core": "^5.6.0",
128132
"karma": "^6.4.4",
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './src/public_api';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{}
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+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { MarkdownPipe } from './markdown-pipe';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './src/public_api';

0 commit comments

Comments
 (0)