diff --git a/CODEOWNERS b/CODEOWNERS index 24d8c6c10a..b393a35380 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -22,7 +22,6 @@ api-goldens/element-ng/data-range-filter/ @siemens/siemens-element-owners @dr-it api-goldens/element-ng/datepicker/ @siemens/siemens-element-owners @dr-itz @spliffone api-goldens/element-ng/chat-messages/ @siemens/siemens-element-owners @dr-itz api-goldens/element-ng/markdown/ @siemens/siemens-element-owners @dr-itz -api-goldens/element-ng/markdown-renderer/ @siemens/siemens-element-owners @dr-itz api-goldens/element-ng/filtered-search/ @siemens/siemens-element-owners @chintankavathia @spliffone api-goldens/element-ng/form/ @siemens/siemens-element-owners @spliffone api-goldens/element-ng/formly/ @siemens/siemens-element-owners @chintankavathia @spliffone diff --git a/api-goldens/element-ng/chat-messages/index.api.md b/api-goldens/element-ng/chat-messages/index.api.md index a8fe1120a2..512217e913 100644 --- a/api-goldens/element-ng/chat-messages/index.api.md +++ b/api-goldens/element-ng/chat-messages/index.api.md @@ -57,11 +57,9 @@ export interface PromptSuggestion { // @public export class SiAiMessageComponent { - constructor(); readonly actionParam: _angular_core.InputSignal; readonly actions: _angular_core.InputSignal; readonly content: _angular_core.InputSignal; - readonly contentFormatter: _angular_core.InputSignal<((text: string) => string | Node) | undefined>; readonly loading: _angular_core.InputSignalWithTransform; readonly secondaryActions: _angular_core.InputSignal; readonly secondaryActionsLabel: _angular_core.InputSignal<_siemens_element_translate_ng_translate.TranslatableString>; @@ -152,12 +150,10 @@ export class SiChatMessageComponent { // @public export class SiUserMessageComponent { - constructor(); readonly actionParam: _angular_core.InputSignal; readonly actions: _angular_core.InputSignal; readonly attachments: _angular_core.InputSignal; readonly content: _angular_core.InputSignal; - readonly contentFormatter: _angular_core.InputSignal<((text: string) => string | Node) | undefined>; readonly secondaryActions: _angular_core.InputSignal; readonly secondaryActionsLabel: _angular_core.InputSignal<_siemens_element_translate_ng_translate.TranslatableString>; } diff --git a/api-goldens/element-ng/markdown-renderer/index.api.md b/api-goldens/element-ng/markdown-renderer/index.api.md deleted file mode 100644 index 089ac799bc..0000000000 --- a/api-goldens/element-ng/markdown-renderer/index.api.md +++ /dev/null @@ -1,21 +0,0 @@ -## Public API Report File for "@siemens/element-ng_markdown-renderer" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import { DomSanitizer } from '@angular/platform-browser'; -import * as i0 from '@angular/core'; - -// @public -export const getMarkdownRenderer: (sanitizer: DomSanitizer) => ((text: string) => Node); - -// @public -export class SiMarkdownRendererComponent { - constructor(); - readonly text: i0.InputSignal; -} - -// (No @packageDocumentation comment for this package) - -``` diff --git a/docs/components/chat-messages/chat-message.md b/docs/components/chat-messages/chat-message.md index 8db5df0c12..90da0c7f50 100644 --- a/docs/components/chat-messages/chat-message.md +++ b/docs/components/chat-messages/chat-message.md @@ -108,12 +108,4 @@ The slots are: -### Markdown renderer - -> **Note:** The markdown renderer is currently experimental and may undergo changes in future releases. - - - - - diff --git a/playwright/e2e/element-examples/static.spec.ts b/playwright/e2e/element-examples/static.spec.ts index fe6bc5a5bf..f9d103d664 100644 --- a/playwright/e2e/element-examples/static.spec.ts +++ b/playwright/e2e/element-examples/static.spec.ts @@ -125,14 +125,6 @@ test('typography/color-variants', ({ si }) => si.static()); test('typography/type-styles', ({ si }) => si.static()); test('typography/display-styles', ({ si }) => si.static()); test('typography/typography', ({ si }) => si.static()); -test('si-markdown-renderer/si-markdown-renderer', ({ si }) => - si.static({ - disabledA11yRules: ['link-in-text-block'], - waitCallback: async page => { - await page.getByRole('heading', { name: 'AI Assistant Response' }).waitFor(); - await page.evaluate(() => document.fonts.ready); - } - })); test('si-chat-messages/si-ai-message', ({ si }) => si.static()); test('si-chat-messages/si-user-message', ({ si }) => si.static()); test('si-chat-messages/si-chat-message', ({ si }) => si.static()); diff --git a/projects/element-ng/chat-messages/si-ai-message.component.html b/projects/element-ng/chat-messages/si-ai-message.component.html index 5146612bba..2d12b575b8 100644 --- a/projects/element-ng/chat-messages/si-ai-message.component.html +++ b/projects/element-ng/chat-messages/si-ai-message.component.html @@ -1,11 +1,6 @@ @if (content()) { - @let content = textContent(); - @if (content) { - {{ content }} - } @else { -
- } + {{ content() }} } @else { } diff --git a/projects/element-ng/chat-messages/si-ai-message.component.spec.ts b/projects/element-ng/chat-messages/si-ai-message.component.spec.ts index bbfe089c01..9688a1e6d3 100644 --- a/projects/element-ng/chat-messages/si-ai-message.component.spec.ts +++ b/projects/element-ng/chat-messages/si-ai-message.component.spec.ts @@ -2,21 +2,26 @@ * Copyright (c) Siemens 2016 - 2026 * SPDX-License-Identifier: MIT */ -import { DebugElement, inputBinding, signal, WritableSignal } from '@angular/core'; +import { Component, DebugElement, inputBinding, signal, WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { By, DomSanitizer } from '@angular/platform-browser'; -import { getMarkdownRenderer } from '@siemens/element-ng/markdown-renderer'; +import { By } from '@angular/platform-browser'; import { MenuItem } from '@siemens/element-ng/menu'; import { MessageAction } from './message-action.model'; import { SiAiMessageComponent as TestComponent } from './si-ai-message.component'; +@Component({ + imports: [TestComponent], + template: ` + Projected AI content + ` +}) +class ProjectedContentHostComponent {} + describe('SiAiMessageComponent', () => { let fixture: ComponentFixture; let debugElement: DebugElement; - let markdownRenderer: (text: string) => string | Node; let content: WritableSignal; - let contentFormatter: WritableSignal<((text: string) => string | Node) | undefined>; let loading: WritableSignal; let actions: WritableSignal; let secondaryActions: WritableSignal; @@ -24,7 +29,6 @@ describe('SiAiMessageComponent', () => { beforeEach(() => { content = signal(''); - contentFormatter = signal<((text: string) => string | Node) | undefined>(undefined); loading = signal(false); actions = signal([]); secondaryActions = signal([]); @@ -33,7 +37,6 @@ describe('SiAiMessageComponent', () => { fixture = TestBed.createComponent(TestComponent, { bindings: [ inputBinding('content', content), - inputBinding('contentFormatter', contentFormatter), inputBinding('loading', loading), inputBinding('actions', actions), inputBinding('secondaryActions', secondaryActions), @@ -41,18 +44,6 @@ describe('SiAiMessageComponent', () => { ] }); debugElement = fixture.debugElement; - const sanitizer = TestBed.inject(DomSanitizer); - markdownRenderer = getMarkdownRenderer(sanitizer); - }); - - it('should render markdown content', async () => { - content.set('This is **bold** text'); - contentFormatter.set(markdownRenderer); - await fixture.whenStable(); - - const markdownContent = fixture.nativeElement.querySelector('.markdown-content') as HTMLElement; - expect(markdownContent).toBeTruthy(); - expect(markdownContent.textContent).toBeTruthy(); }); it('should pass loading state to chat message', async () => { @@ -70,6 +61,24 @@ describe('SiAiMessageComponent', () => { expect(chatMessage.componentInstance.alignment()).toBe('start'); }); + it('should project content when content input is empty', async () => { + const hostFixture = TestBed.createComponent(ProjectedContentHostComponent); + await hostFixture.whenStable(); + + const projectedContent = hostFixture.nativeElement.querySelector( + 'si-chat-message [data-testid="projected-content"]' + ); + expect(projectedContent).toHaveTextContent('Projected AI content'); + }); + + it('should render text content', async () => { + content.set('AI response'); + await fixture.whenStable(); + + const textContent = fixture.nativeElement.querySelector('si-chat-message .text-pre-wrap'); + expect(textContent).toHaveTextContent('AI response'); + }); + it('should render action buttons when actions are provided', async () => { actions.set([ { diff --git a/projects/element-ng/chat-messages/si-ai-message.component.ts b/projects/element-ng/chat-messages/si-ai-message.component.ts index f0579fe0ae..bf7816c2dd 100644 --- a/projects/element-ng/chat-messages/si-ai-message.component.ts +++ b/projects/element-ng/chat-messages/si-ai-message.component.ts @@ -3,16 +3,7 @@ * SPDX-License-Identifier: MIT */ import { CdkMenuTrigger } from '@angular/cdk/menu'; -import { - booleanAttribute, - Component, - effect, - input, - viewChild, - ElementRef, - signal, - output -} from '@angular/core'; +import { booleanAttribute, Component, input, output } from '@angular/core'; import { elementOptionsVertical } from '@siemens/element-icons'; import { addIcons, SiIconComponent } from '@siemens/element-ng/icon'; import { MenuItem, SiMenuFactoryComponent } from '@siemens/element-ng/menu'; @@ -27,19 +18,17 @@ import { SiChatMessageComponent } from './si-chat-message.component'; * AI message component for displaying AI-generated responses in conversational interfaces. * * The AI message component renders AI-generated content in chat interfaces, - * supporting text formatting, markdown, loading states, and contextual actions. + * supporting text, loading states, and contextual actions. * It appears as text (no bubble) aligned to the left side without any avatar/icon slot. * Can be used within {@link SiChatContainerComponent}. * * The component automatically handles: * - Styling for AI messages distinct from user or generic chat messages - * - Option to render markdown content, provide via `contentFormatter` input with a markdown renderer function (e.g., from {@link getMarkdownRenderer}) * - Showing loading states with skeleton UI during generation * - Displaying primary and secondary actions * * @see {@link SiChatMessageComponent} for the base message wrapper component * @see {@link SiUserMessageComponent} for the user message component - * @see {@link getMarkdownRenderer} for markdown formatting support * @see {@link SiChatContainerComponent} for the chat container to use this within * * @experimental @@ -59,7 +48,6 @@ import { SiChatMessageComponent } from './si-chat-message.component'; styleUrl: './si-ai-message.component.scss' }) export class SiAiMessageComponent { - protected readonly formattedContent = viewChild>('formattedContent'); protected readonly icons = addIcons({ elementOptionsVertical }); /** @@ -68,46 +56,6 @@ export class SiAiMessageComponent { */ readonly content = input(''); - /** - * Optional formatter function to transform content before display. - * - Returns string: Content will be sanitized using Angular's DomSanitizer - * - Returns Node: DOM node will be inserted directly without sanitization - * - * **Note:** If using a markdown renderer, make sure to apply the `markdown-content` class - * to the root element to ensure proper styling using the Element theme (e.g., `div.className = 'markdown-content'`). - * The function returned by {@link getMarkdownRenderer} does this automatically. - * - * **Warning:** When returning a Node, ensure the content is safe to prevent XSS attacks - * @defaultValue undefined - */ - readonly contentFormatter = input<((text: string) => string | Node) | undefined>(undefined); - - protected readonly textContent = signal(undefined); - - constructor() { - effect(() => { - const formatter = this.contentFormatter(); - const contentValue = this.content(); - const container = this.formattedContent()?.nativeElement; - - if (container && contentValue) { - if (formatter) { - const formatted = formatter(contentValue); - - if (typeof formatted === 'string') { - this.textContent.set(formatted); - } else if (formatted instanceof Node) { - this.textContent.set(undefined); - container.innerHTML = ''; - container.appendChild(formatted); - } - } else { - this.textContent.set(contentValue); - } - } - }); - } - /** * Whether the message is currently being generated (shows skeleton) * @defaultValue false diff --git a/projects/element-ng/chat-messages/si-chat-message.component.ts b/projects/element-ng/chat-messages/si-chat-message.component.ts index fd4f67f087..29ddd865d1 100644 --- a/projects/element-ng/chat-messages/si-chat-message.component.ts +++ b/projects/element-ng/chat-messages/si-chat-message.component.ts @@ -22,7 +22,7 @@ import { SiResponsiveContainerDirective } from '@siemens/element-ng/resize-obser * - Responsive behavior that adapts to container size * * This is a low-level component designed for slotting in custom content, it provides slots via content projection: - * - Default content: Main message content area (consider using {@link SiMarkdownRendererComponent} for markdown support) + * - Default content: Main message content area (consider using `si-markdown` for markdown support) * - `si-avatar/si-icon/img` selector: Avatar or icon representing the message sender * - `si-chat-message-action` selector: Action buttons related to the message * - `si-attachment-list` selector: Attachment list component for displaying file attachments @@ -31,7 +31,6 @@ import { SiResponsiveContainerDirective } from '@siemens/element-ng/resize-obser * @see {@link SiAiMessageComponent} for AI message display * @see {@link SiAttachmentListComponent} for attachment list to slot in * @see {@link SiChatMessageActionDirective} for action buttons to slot in - * @see {@link SiMarkdownRendererComponent} for markdown content rendering * @see {@link SiChatContainerComponent} for the chat container to use this within * * @experimental diff --git a/projects/element-ng/chat-messages/si-user-message.component.html b/projects/element-ng/chat-messages/si-user-message.component.html index 09a0665b3f..fa3a18f635 100644 --- a/projects/element-ng/chat-messages/si-user-message.component.html +++ b/projects/element-ng/chat-messages/si-user-message.component.html @@ -4,12 +4,7 @@ } @if (content()) { - @let content = textContent(); - @if (content) { - {{ content }} - } @else { -
- } + {{ content() }} } @else { } diff --git a/projects/element-ng/chat-messages/si-user-message.component.spec.ts b/projects/element-ng/chat-messages/si-user-message.component.spec.ts index 5d129e8227..cbfb43422b 100644 --- a/projects/element-ng/chat-messages/si-user-message.component.spec.ts +++ b/projects/element-ng/chat-messages/si-user-message.component.spec.ts @@ -2,22 +2,27 @@ * Copyright (c) Siemens 2016 - 2026 * SPDX-License-Identifier: MIT */ -import { DebugElement, inputBinding, signal, WritableSignal } from '@angular/core'; +import { Component, DebugElement, inputBinding, signal, WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { By, DomSanitizer } from '@angular/platform-browser'; -import { getMarkdownRenderer } from '@siemens/element-ng/markdown-renderer'; +import { By } from '@angular/platform-browser'; import { MenuItem } from '@siemens/element-ng/menu'; import { MessageAction } from './message-action.model'; import { Attachment } from './si-attachment-list.component'; import { SiUserMessageComponent as TestComponent } from './si-user-message.component'; +@Component({ + imports: [TestComponent], + template: ` + Projected user content + ` +}) +class ProjectedContentHostComponent {} + describe('SiUserMessageComponent', () => { let fixture: ComponentFixture; let debugElement: DebugElement; - let markdownRenderer: (text: string) => string | Node; let content: WritableSignal; - let contentFormatter: WritableSignal<((text: string) => string | Node) | undefined>; let actions: WritableSignal; let secondaryActions: WritableSignal; let attachments: WritableSignal; @@ -25,7 +30,6 @@ describe('SiUserMessageComponent', () => { beforeEach(() => { content = signal(''); - contentFormatter = signal<((text: string) => string | Node) | undefined>(undefined); actions = signal([]); secondaryActions = signal([]); attachments = signal([]); @@ -34,7 +38,6 @@ describe('SiUserMessageComponent', () => { fixture = TestBed.createComponent(TestComponent, { bindings: [ inputBinding('content', content), - inputBinding('contentFormatter', contentFormatter), inputBinding('actions', actions), inputBinding('secondaryActions', secondaryActions), inputBinding('attachments', attachments), @@ -42,25 +45,31 @@ describe('SiUserMessageComponent', () => { ] }); debugElement = fixture.debugElement; - const sanitizer = TestBed.inject(DomSanitizer); - markdownRenderer = getMarkdownRenderer(sanitizer); }); - it('should render markdown content', async () => { - content.set('This is my **message**'); - contentFormatter.set(markdownRenderer); + it('should use end alignment for chat message', async () => { await fixture.whenStable(); - const markdownContent = fixture.nativeElement.querySelector('.markdown-content') as HTMLElement; - expect(markdownContent).toBeTruthy(); - expect(markdownContent.textContent).toBeTruthy(); + const chatMessage = debugElement.query(By.css('si-chat-message')); + expect(chatMessage.componentInstance.alignment()).toBe('end'); }); - it('should use end alignment for chat message', async () => { + it('should project content when content input is empty', async () => { + const hostFixture = TestBed.createComponent(ProjectedContentHostComponent); + await hostFixture.whenStable(); + + const projectedContent = hostFixture.nativeElement.querySelector( + 'si-chat-message [data-testid="projected-content"]' + ); + expect(projectedContent).toHaveTextContent('Projected user content'); + }); + + it('should render text content', async () => { + content.set('User message'); await fixture.whenStable(); - const chatMessage = debugElement.query(By.css('si-chat-message')); - expect(chatMessage.componentInstance.alignment()).toBe('end'); + const textContent = fixture.nativeElement.querySelector('si-chat-message .text-pre-wrap'); + expect(textContent).toHaveTextContent('User message'); }); it('should render action buttons when actions are provided', async () => { diff --git a/projects/element-ng/chat-messages/si-user-message.component.ts b/projects/element-ng/chat-messages/si-user-message.component.ts index eb4956acb6..bd24e02c34 100644 --- a/projects/element-ng/chat-messages/si-user-message.component.ts +++ b/projects/element-ng/chat-messages/si-user-message.component.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: MIT */ import { CdkMenuTrigger } from '@angular/cdk/menu'; -import { Component, effect, input, viewChild, ElementRef, computed, signal } from '@angular/core'; +import { Component, input, computed } from '@angular/core'; import { elementOptionsVertical } from '@siemens/element-icons'; import { addIcons, SiIconComponent } from '@siemens/element-ng/icon'; import { MenuItem, SiMenuFactoryComponent } from '@siemens/element-ng/menu'; @@ -19,19 +19,17 @@ import { SiChatMessageComponent } from './si-chat-message.component'; * * The user message component renders user-submitted content in (AI) chat interfaces, * supporting text, attachments, and contextual actions. It appears as a text bubble - * aligned to the right side and supports markdown formatting for rich content. + * aligned to the right side. * Can be used within {@link SiChatContainerComponent}. * * The component automatically handles: * - Styling for user messages distinct from AI or generic chat messages - * - Option to render markdown content, provide via `contentFormatter` input with a markdown renderer function (e.g., from {@link getMarkdownRenderer}) * - Displaying attachments above the message bubble * - Displaying primary and secondary actions * * @see {@link SiChatMessageComponent} for the base message wrapper component * @see {@link SiAiMessageComponent} for the AI message component * @see {@link SiAttachmentListComponent} for the base attachment component - * @see {@link getMarkdownRenderer} for markdown formatting support * @see {@link SiChatContainerComponent} for the chat container to use this within * * @experimental @@ -51,7 +49,6 @@ import { SiChatMessageComponent } from './si-chat-message.component'; styleUrl: './si-user-message.component.scss' }) export class SiUserMessageComponent { - protected readonly formattedContent = viewChild>('formattedContent'); protected readonly icons = addIcons({ elementOptionsVertical }); /** @@ -60,20 +57,6 @@ export class SiUserMessageComponent { */ readonly content = input(''); - /** - * Optional formatter function to transform content before display. - * - Returns string: Content will be inserted as text with built-in sanitization - * - Returns Node: DOM node will be inserted directly without sanitization - * - * **Note:** When returning a Node with formatted content, apply the `markdown-content` class - * to the root element to ensure proper styling (e.g., `div.className = 'markdown-content'`). - * The function returned by {@link getMarkdownRenderer} does this automatically. - * - * **Warning:** When returning a Node, ensure the content is safe to prevent XSS attacks - * @defaultValue undefined - */ - readonly contentFormatter = input<((text: string) => string | Node) | undefined>(undefined); - /** * Primary message actions (edit, delete, copy, etc.). * All actions displayed inline @@ -109,30 +92,4 @@ export class SiUserMessageComponent { ); protected readonly hasAttachments = computed(() => this.attachments()?.length > 0); - - protected readonly textContent = signal(undefined); - - constructor() { - effect(() => { - const formatter = this.contentFormatter(); - const contentValue = this.content(); - const container = this.formattedContent()?.nativeElement; - - if (container && contentValue) { - if (formatter) { - const formatted = formatter(contentValue); - - if (typeof formatted === 'string') { - this.textContent.set(formatted); - } else if (formatted instanceof Node) { - this.textContent.set(undefined); - container.innerHTML = ''; - container.appendChild(formatted); - } - } else { - this.textContent.set(contentValue); - } - } - }); - } } diff --git a/projects/element-ng/docs.ts b/projects/element-ng/docs.ts index e0e44c5cbc..08f23378b4 100644 --- a/projects/element-ng/docs.ts +++ b/projects/element-ng/docs.ts @@ -81,7 +81,6 @@ export * from './summary-widget'; export * from './system-banner'; export * from './tabs-legacy'; export * from './tabs'; -export * from './markdown-renderer'; export * from './theme'; export * from './threshold'; export * from './toast-notification'; diff --git a/projects/element-ng/markdown-renderer/index.ts b/projects/element-ng/markdown-renderer/index.ts deleted file mode 100644 index 9e62163a9c..0000000000 --- a/projects/element-ng/markdown-renderer/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Copyright (c) Siemens 2016 - 2026 - * SPDX-License-Identifier: MIT - */ -export * from './si-markdown-renderer.component'; -export * from './markdown-renderer'; diff --git a/projects/element-ng/markdown-renderer/markdown-renderer.ts b/projects/element-ng/markdown-renderer/markdown-renderer.ts deleted file mode 100644 index d7f1022c2d..0000000000 --- a/projects/element-ng/markdown-renderer/markdown-renderer.ts +++ /dev/null @@ -1,263 +0,0 @@ -/** - * Copyright (c) Siemens 2016 - 2026 - * SPDX-License-Identifier: MIT - */ -import { SecurityContext } from '@angular/core'; -import { DomSanitizer } from '@angular/platform-browser'; - -/** - * Returns a markdown renderer function which_ - * - Transforms markdown text into formatted HTML. - * - Returns a DOM node containing the formatted content. - * - * **Warning:** The returned Node is inserted without additional sanitization. - * Input content is sanitized before processing. - * - * @experimental - * @param sanitizer - Angular DomSanitizer instance - * @returns A function taking the markdown text to transform and returning a DOM div element containing the formatted HTML - */ -export const getMarkdownRenderer = (sanitizer: DomSanitizer): ((text: string) => Node) => { - return (text: string): Node => { - const div = document.createElement('div'); - div.className = 'markdown-content text-break'; - - if (!text) { - return div; - } - - // Generate a random placeholder for newlines to preserve them during HTML sanitization - const newlinePlaceholder = `--NEWLINE-${Math.random().toString(36).substring(2, 15)}--`; - - // Replace newlines with placeholder before sanitization - const valueWithPlaceholders = text.replace(/\n/g, newlinePlaceholder); - - // Sanitize the input using Angular's HTML sanitizer - const sanitizedInput = sanitizer.sanitize(SecurityContext.HTML, valueWithPlaceholders) ?? ''; - - // Restore newlines from placeholder for markdown processing. - let html = sanitizedInput.replace(new RegExp(newlinePlaceholder, 'g'), '\n'); - - // Process tables first - html = html - // Remove table separator lines first - .replace(/^\|\s*[-:]+.*\|\s*$/gm, '') - // Process table rows - .replace(/^\|(.+)\|\s*$/gm, (_match, htmlContent) => { - // Handle escaped pipes by temporarily replacing them - const escapedPipePlaceholder = `--ESCAPED-PIPE-${Math.random().toString(36).substring(2, 15)}--`; - const contentWithPlaceholders = htmlContent.replace(/\\\|/g, escapedPipePlaceholder); - const cells = contentWithPlaceholders.split('|').map((cell: string) => { - const trimmedCell = cell.trim(); - // Restore escaped pipes - const cellWithPipes = trimmedCell.replace(new RegExp(escapedPipePlaceholder, 'g'), '|'); - - return cellWithPipes; - }); - // Make cell ready for markdown processing by replacing code blocks with inline code and
with newlines - const cellsWithNewlines = cells.map((cell: string) => { - // Replace multiline code blocks with single line code blocks - const cellWithoutMultilineCode = cell.replace( - /```([\s\S]*?)```/g, - (_innerMatch, inlineCodeContent) => { - return '`' + inlineCodeContent.replace(/`/g, '') + '`'; - } - ); - // Temporarily replace single line code blocks to avoid replacing
inside them - const tableInlineCodeBrPlaceholder = `--INLINE-CODE-BR--${Math.random().toString(36).substring(2, 15)}--`; - const cellWithPlaceholders = cellWithoutMultilineCode.replace( - /(`[^`]*`)/g, - inlineCodeMatch => { - return inlineCodeMatch.replace(/
/g, tableInlineCodeBrPlaceholder); - } - ); - // Replace
with newlines - const cellWithNewlines = cellWithPlaceholders.replace(//gi, '\n'); - // Restore
in inline code placeholders - const preProcessedCell = cellWithNewlines.replace( - new RegExp(tableInlineCodeBrPlaceholder, 'g'), - '
' - ); - return preProcessedCell; - }); - - // Recursively process cell content for markdown formatting - const processedCells = cellsWithNewlines.map((cell: string) => { - return transformMarkdownText(cell, false, sanitizer); - }); - - return `${processedCells.map((cell: string) => `${cell}`).join('')}`; - }) - // Wrap table rows in table elements - .replace(/(.*?<\/tr>)/gs, '$1
') - // Remove duplicate table tags - .replace(/<\/table>\s*/g, ''); - - html = transformMarkdownText(html, true, sanitizer); - - div.innerHTML = html; - return div; - }; -}; - -const transformMarkdownText = ( - html: string, - keepAdditionalNewlines = true, - sanitizer: DomSanitizer -): string => { - // Generate a random placeholder for inner code blocks to prevent markdown processing inside them - const innerCodeQuotePlaceholder = `--INNER-CODE-${Math.random().toString(36).substring(2, 15)}--`; - const codeSectionPlaceholderMap = new Map(); - - const escapedAsteriskPlaceholder = `--ASTERISK-${Math.random().toString(36).substring(2, 15)}--`; - const escapedUnderscorePlaceholder = `--UNDERSCORE-${Math.random().toString(36).substring(2, 15)}--`; - - // Apply markdown transformations to the sanitized content - html = html - // Multiline code blocks ```code``` with placeholder - .replace(/```[^\n]*\n?([\s\S]*?)\n?```/g, (match, content) => { - // Escape HTML special characters in code blocks (not for security, but for correct display) and preserve inner backticks - const code = `
${content.replace(//g, '>').replace(/`/g, innerCodeQuotePlaceholder)}
`; - const codePlaceholder = `--CODE-BLOCK-${Math.random().toString(36).substring(2, 15)}--`; - codeSectionPlaceholderMap.set(codePlaceholder, code); - return codePlaceholder; - }) - - // Inline code `text` - .replace(/`(.*?)`/g, (match, content) => { - // Escape HTML special characters in inline code (not for security, but for correct display) - const code = `${content.replace(//g, '>')}`; - const codePlaceholder = `--INLINE-CODE-${Math.random().toString(36).substring(2, 15)}--`; - codeSectionPlaceholderMap.set(codePlaceholder, code); - return codePlaceholder; - }) - - // Images ![alt](url) - .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, url) => { - const sanitizedUrl = sanitizeUrl(url, sanitizer); - const escapedAlt = alt - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(//g, '>'); - return `${escapedAlt}`; - }) - - // Links [text](url) - .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, text, url) => { - const sanitizedUrl = sanitizeUrl(url, sanitizer); - return `${text}`; - }) - - // Auto-detect URLs and convert to links - .replace(/(? { - const sanitizedUrl = sanitizeUrl(match, sanitizer); - return `${match}`; - }) - - .replace(/(?$1') - .replace(/__(.*?)__/g, '$1') - - // Italic *text* or _text_ - .replace(/\*(.*?)\*/g, '$1') - .replace(/_(.*?)_/g, '$1') - - .replace(new RegExp(escapedAsteriskPlaceholder, 'g'), '*') - .replace(new RegExp(escapedUnderscorePlaceholder, 'g'), '_') - - // Headings #, ##, ###, etc. - .replace(/^###### (.*$)/gm, '$1') - .replace(/^##### (.*$)/gm, '
$1
') - .replace(/^#### (.*$)/gm, '

$1

') - .replace(/^### (.*$)/gm, '

$1

') - .replace(/^## (.*$)/gm, '

$1

') - .replace(/^# (.*$)/gm, '

$1

'); - - html = html - // Bullet points - handle each type separately (• gets converted to • by sanitizer) - .replace(/^• (.*$)/gm, '
  • $1
  • ') - .replace(/^- (.*$)/gm, '
  • $1
  • ') - .replace(/^\* (.*$)/gm, '
  • $1
  • ') - - // Ordered list items (1., 2., 3., etc.) - .replace(/^\d+\. (.*$)/gm, '
  • $1
  • '); - - html = html.replace(/^\s*(?:>|>)\s*(.*)$/gm, '
    $1
    '); - - // Generate a random placeholder for newlines to differentiate them from those used for paragraphs - const finalNewlinePlaceholder = `--NEWLINE-${Math.random().toString(36).substring(2, 15)}--`; - - html = html - // Wrap ordered lists - .replace(/(
  • .*?<\/li>)/gs, '
      $1
    ') - - // Wrap unordered lists - .replace(/(
  • .*?<\/li>)/gs, '
      $1
    ') - - // Remove duplicate ol/ul tags - .replace(/<\/ol>\s*
      /g, '') - .replace(/<\/ul>\s*
        /g, '') - - // Clean up class attributes - .replace(/ class="ordered"/g, '') - .replace(/ class="unordered"/g, ''); - - html = html - // Convert double newlines to paragraphs (before single line breaks) - .split(/\n{2}/g) - // Wrap non-block elements in

        tags - .map(segment => { - // If the segment starts with a block element, return as is - if (!segment.trim() || /^\s*<(h[1-6]|pre|blockquote|ul|ol)/.test(segment.trim())) { - // Replace newlines inside blocks with the placeholder - return segment.replace(/\n/g, finalNewlinePlaceholder); - } - // Otherwise, wrap in

        tags - return `

        ${segment}

        `; - }) - // Use newline placeholder again so as not to replace newlines between blocks - .join(finalNewlinePlaceholder) - // Convert remaining newlines to line breaks (do this LAST) - .replace(/\n/g, '
        ') - // Restore newline placeholders - .replace(new RegExp(finalNewlinePlaceholder, 'g'), keepAdditionalNewlines ? '\n' : ' '); - - // Restore code placeholders - codeSectionPlaceholderMap.forEach((code, placeholder) => { - html = html.replace(new RegExp(placeholder, 'g'), code); - }); - - // Restore inner code block placeholders - html = html.replace(new RegExp(innerCodeQuotePlaceholder, 'g'), '`'); - - return html; -}; - -/** - * Sanitizes a URL to prevent XSS attacks - * @param url - The URL to sanitize - * @param sanitizer - Angular DomSanitizer instance - * @returns The sanitized URL or '#' if invalid - */ -const sanitizeUrl = (url: string, sanitizer: DomSanitizer): string => { - // Remove any whitespace - url = url.trim(); - - // Allow only http, https, and mailto protocols - const allowed = /^(https?:\/\/|mailto:|\/(?!\/)|\.{1,2}\/|#)/i; - - // Sanitize the URL using Angular's sanitizer - if (!allowed.test(url)) { - return '#'; - } - - // Sanitize the URL using Angular's sanitizer - const sanitized = sanitizer.sanitize(SecurityContext.URL, url); - - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - return sanitized || '#'; -}; diff --git a/projects/element-ng/markdown-renderer/ng-package.json b/projects/element-ng/markdown-renderer/ng-package.json deleted file mode 100644 index bb6786ef97..0000000000 --- a/projects/element-ng/markdown-renderer/ng-package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", - "lib": { - "entryFile": "index.ts" - } -} diff --git a/projects/element-ng/markdown-renderer/si-markdown-renderer.component.spec.ts b/projects/element-ng/markdown-renderer/si-markdown-renderer.component.spec.ts deleted file mode 100644 index a4f4a31fdd..0000000000 --- a/projects/element-ng/markdown-renderer/si-markdown-renderer.component.spec.ts +++ /dev/null @@ -1,293 +0,0 @@ -/** - * Copyright (c) Siemens 2016 - 2026 - * SPDX-License-Identifier: MIT - */ -import { inputBinding, signal, WritableSignal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { SiMarkdownRendererComponent as TestComponent } from './si-markdown-renderer.component'; - -describe('SiMarkdownRendererComponent', () => { - let fixture: ComponentFixture; - let hostElement: HTMLElement; - let text: WritableSignal; - - beforeEach(() => { - text = signal(''); - fixture = TestBed.createComponent(TestComponent, { - bindings: [inputBinding('text', text)] - }); - hostElement = fixture.nativeElement; - }); - - it('should render empty content for null/undefined input', async () => { - text.set(null); - await fixture.whenStable(); - - const markdownDiv = hostElement.firstElementChild!; - expect(markdownDiv.innerHTML).toBe(''); - }); - - it('should render plain text without transformation', async () => { - const plainText = 'This is plain text'; - text.set(plainText); - await fixture.whenStable(); - - const markdownDiv = hostElement.firstElementChild!; - expect(markdownDiv).toHaveTextContent(plainText); - }); - - it('should transform bold markdown **text**', async () => { - text.set('This is **bold** text'); - await fixture.whenStable(); - - const markdownDiv = hostElement.firstElementChild!; - const strongElement = markdownDiv.querySelector('strong')!; - expect(strongElement).toHaveTextContent('bold'); - }); - - it('should transform italic markdown *text*', async () => { - text.set('This is *italic* text'); - await fixture.whenStable(); - - const markdownDiv = hostElement.firstElementChild!; - const emElement = markdownDiv.querySelector('em')!; - expect(emElement).toHaveTextContent('italic'); - }); - - it('should transform inline code `text`', async () => { - text.set('This is `code_` text'); - await fixture.whenStable(); - - const markdownDiv = hostElement.firstElementChild!; - const codeElement = markdownDiv.querySelector('code')!; - expect(codeElement).toHaveTextContent('code_'); - }); - - it('should transform code blocks ```code```', async () => { - text.set('```\nconst x = 1;\n```'); - await fixture.whenStable(); - - const markdownDiv = hostElement.firstElementChild!; - const preElement = markdownDiv.querySelector('pre')!; - const codeElement = preElement.querySelector('code')!; - expect(codeElement).toHaveTextContent('const x = 1;'); - }); - - it('should transform bullet points to lists (• character)', async () => { - text.set('• First item\n• Second item'); - await fixture.whenStable(); - - const markdownDiv = hostElement.firstElementChild!; - const innerHTML = markdownDiv.innerHTML; - - expect(innerHTML).toContain('
      • First item
      • '); - expect(innerHTML).toContain('
      • Second item
      • '); - expect(innerHTML).toContain('
          '); - }); - - it('should transform bullet points to lists (- character)', async () => { - text.set('- First item\n- Second item'); - await fixture.whenStable(); - - const markdownDiv = hostElement.firstElementChild!; - const innerHTML = markdownDiv.innerHTML; - - expect(innerHTML).toContain('
        • First item
        • '); - expect(innerHTML).toContain('
        • Second item
        • '); - expect(innerHTML).toContain('
            '); - }); - - it('should convert newlines to line breaks', async () => { - text.set('Line 1\nLine 2'); - await fixture.whenStable(); - - const markdownDiv = hostElement.firstElementChild!; - const brElements = markdownDiv.querySelectorAll('br'); - expect(brElements).toHaveLength(1); - }); - - it('should handle complex markdown with multiple elements', async () => { - const complexMarkdown = `This is **bold** text with _italic_, escaped \\_ and \\* and \`code\`. - -• First item -• Second item - -\`\`\` -const example = "code block"; -\`\`\``; - - text.set(complexMarkdown); - await fixture.whenStable(); - - const markdownDiv = hostElement.firstElementChild!; - const innerHTML = markdownDiv.innerHTML; - - // Check for transformed markdown in the HTML string - expect(innerHTML).toContain('bold'); - expect(innerHTML).toContain('italic'); - expect(innerHTML).toContain('code'); - expect(innerHTML).toContain('
            ');
            -    expect(innerHTML).toContain('
          • First item
          • '); - }); - - it('should sanitize potentially dangerous HTML', async () => { - text.set('Safe text'); - await fixture.whenStable(); - - const markdownDiv = hostElement.firstElementChild!; - const innerHTML = markdownDiv.innerHTML; - - // Script tags should be completely removed by Angular's sanitizer - expect(innerHTML).not.toContain(' | Bold |`; - - text.set(tableMarkdown); - await fixture.whenStable(); - - const markdownDiv = hostElement.firstElementChild!; - const tdElements = markdownDiv.querySelectorAll('td'); - - expect(tdElements[2].innerHTML).not.toContain('