From 9e38eeef5cd49879ffdca1406db14cc6cce73ad6 Mon Sep 17 00:00:00 2001 From: Linus Schlumberger Date: Sat, 6 Dec 2025 18:39:19 +0100 Subject: [PATCH 01/30] feat(chat-messages): add code copy button to markdown renderer --- .../element-ng/markdown-renderer/index.api.md | 17 ++++- api-goldens/element-ng/translate/index.api.md | 2 + .../si-chat-messages--si-chat-container.yaml | 15 ++-- projects/element-ng/link/si-link.directive.ts | 1 - .../markdown-renderer/markdown-renderer.ts | 76 ++++++++++++++++++- .../si-markdown-renderer.component.spec.ts | 7 +- .../si-markdown-renderer.component.ts | 29 ++++++- .../si-translatable-keys.interface.ts | 1 + .../src/styles/components/_markdown.scss | 20 ++++- .../si-chat-messages/si-ai-message.ts | 8 +- .../si-chat-messages/si-chat-container.ts | 55 ++++++++------ .../si-chat-messages/si-user-message.ts | 7 +- 12 files changed, 190 insertions(+), 48 deletions(-) diff --git a/api-goldens/element-ng/markdown-renderer/index.api.md b/api-goldens/element-ng/markdown-renderer/index.api.md index e47c3c773..5597c397c 100644 --- a/api-goldens/element-ng/markdown-renderer/index.api.md +++ b/api-goldens/element-ng/markdown-renderer/index.api.md @@ -4,16 +4,27 @@ ```ts +import * as _angular_core from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; -import * as i0 from '@angular/core'; +import * as _siemens_element_translate_ng_translate_types from '@siemens/element-translate-ng/translate-types'; +import { SiTranslateService } from '@siemens/element-translate-ng/translate'; +import { TranslatableString } from '@siemens/element-translate-ng/translate'; // @public -export const getMarkdownRenderer: (sanitizer: DomSanitizer) => ((text: string) => Node); +export const getMarkdownRenderer: (sanitizer: DomSanitizer, options?: MarkdownRendererOptions) => ((text: string) => Node); + +// @public (undocumented) +export interface MarkdownRendererOptions { + copyCodeButton?: TranslatableString; + translateSync?: SiTranslateService['translateSync']; +} // @public export class SiMarkdownRendererComponent { constructor(); - readonly text: i0.InputSignal; + readonly copyButtonLabel: _angular_core.InputSignal<_siemens_element_translate_ng_translate_types.TranslatableString>; + readonly disableCopyButton: _angular_core.InputSignal; + readonly text: _angular_core.InputSignal; } // (No @packageDocumentation comment for this package) diff --git a/api-goldens/element-ng/translate/index.api.md b/api-goldens/element-ng/translate/index.api.md index d7e5013e3..90813d72a 100644 --- a/api-goldens/element-ng/translate/index.api.md +++ b/api-goldens/element-ng/translate/index.api.md @@ -334,6 +334,8 @@ export interface SiTranslatableKeys { // (undocumented) 'SI_MAIN_DETAIL_CONTAINER.BACK'?: string; // (undocumented) + 'SI_MARKDOWN_RENDERER.COPY'?: string; + // (undocumented) 'SI_NAVBAR.OPEN_LAUNCHPAD'?: string; // (undocumented) 'SI_NAVBAR.TOGGLE_NAVIGATION'?: string; diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container.yaml b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container.yaml index 8c06f612f..03f27a49c 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container.yaml +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container.yaml @@ -4,16 +4,16 @@ - button "dataset.csv" - paragraph: Can you help me analyze these files? - paragraph: I'm having trouble understanding the data structure and need assistance with the implementation. -- button "Copy message" +- button "Export message" - paragraph: I'd be happy to help you analyze your files! I can see you've shared a Python script and a CSV dataset. - paragraph: Let me examine the structure and provide guidance. -- button "Good response" -- button "Copy response" +- button "Add to list" +- button "Export response" - button "Retry response" - button "More actions" - paragraph: Perfect! What should I focus on first - paragraph: I also want to make sure the performance is optimized for large datasets since this will be used in production with potentially millions of rows? -- button "Copy message" +- button "Export message" - paragraph: Great question! When analyzing large datasets, it's crucial to focus on... - alert: Info AI responses are for demonstration purposes. - group: @@ -23,8 +23,9 @@ - text: mockup.png - button "Remove attachment mockup.png" - textbox "Chat message input": - - /placeholder: Enter a command, question or topic… + - /placeholder: Enter a command, question or topic... - button "Attach file" -- button "Text formatting" -- button "Message templates" +- button "Format text" +- button "Use template" - button "Send" +- text: The content is AI generated. Always verify the information for accuracy. \ No newline at end of file diff --git a/projects/element-ng/link/si-link.directive.ts b/projects/element-ng/link/si-link.directive.ts index ec01d90a2..46e52960b 100644 --- a/projects/element-ng/link/si-link.directive.ts +++ b/projects/element-ng/link/si-link.directive.ts @@ -2,7 +2,6 @@ * Copyright (c) Siemens 2016 - 2025 * SPDX-License-Identifier: MIT */ -/* eslint-disable @angular-eslint/no-conflicting-lifecycle */ import { LocationStrategy } from '@angular/common'; import { booleanAttribute, diff --git a/projects/element-ng/markdown-renderer/markdown-renderer.ts b/projects/element-ng/markdown-renderer/markdown-renderer.ts index 1f072426d..f57119ca6 100644 --- a/projects/element-ng/markdown-renderer/markdown-renderer.ts +++ b/projects/element-ng/markdown-renderer/markdown-renderer.ts @@ -4,6 +4,24 @@ */ import { SecurityContext } from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; +import type { + SiTranslateService, + TranslatableString +} from '@siemens/element-translate-ng/translate'; + +export interface MarkdownRendererOptions { + /** + * Provide this to enable the copy code button functionality. + * Label for the copy code button (will be translated internally if translateService is provided). + * @defaultValue undefined + */ + copyCodeButton?: TranslatableString; + /** + * Optional translate sync function of a service instance for translating the copy button label. + * @defaultValue undefined + */ + translateSync?: SiTranslateService['translateSync']; +} /** * Returns a markdown renderer function which_ @@ -15,9 +33,13 @@ import { DomSanitizer } from '@angular/platform-browser'; * * @experimental * @param sanitizer - Angular DomSanitizer instance + * @param options - Optional configuration for the markdown renderer * @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) => { +export const getMarkdownRenderer = ( + sanitizer: DomSanitizer, + options?: MarkdownRendererOptions +): ((text: string) => Node) => { return (text: string): Node => { const div = document.createElement('div'); div.className = 'markdown-content text-break'; @@ -93,9 +115,32 @@ export const getMarkdownRenderer = (sanitizer: DomSanitizer): ((text: string) => // Remove duplicate table tags .replace(/<\/table>\s*/g, ''); - html = transformMarkdownText(html, true, sanitizer); + html = transformMarkdownText(html, true, sanitizer, options); div.innerHTML = html; + + // Add copy functionality to code blocks + div.querySelectorAll('.copy-code-btn').forEach(btn => { + btn.addEventListener('click', e => { + const button = e.target as HTMLButtonElement; + const codeId = button.getAttribute('data-code-id'); + if (!codeId) { + return; + } + + const codeElement = div.querySelector(`#${codeId}`); + if (!codeElement) { + return; + } + + const code = codeElement.textContent ?? ''; + + navigator.clipboard.writeText(code).catch(() => { + // Clipboard API may fail if not in a secure context or permissions denied + console.warn('Failed to copy code to clipboard'); + }); + }); + }); return div; }; }; @@ -103,7 +148,8 @@ export const getMarkdownRenderer = (sanitizer: DomSanitizer): ((text: string) => const transformMarkdownText = ( html: string, keepAdditionalNewlines = true, - sanitizer: DomSanitizer + sanitizer: DomSanitizer, + options?: MarkdownRendererOptions ): 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)}--`; @@ -117,7 +163,29 @@ const transformMarkdownText = ( // 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 escapedCode = content + .replace(//g, '>') + .replace(/`/g, innerCodeQuotePlaceholder); + + const codeId = `code-${Math.random().toString(36).substring(2, 15)}`; + + const translatedLabel = + options?.copyCodeButton && options?.translateSync + ? options.translateSync(options.copyCodeButton) + : options?.copyCodeButton + ? String(options.copyCodeButton) + : undefined; + const buttonLabel = translatedLabel + ?.replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + const codeCopyButton = buttonLabel + ? `` + : ''; + + const code = `
${codeCopyButton}
${escapedCode}
`; const codePlaceholder = `--CODE-BLOCK-${Math.random().toString(36).substring(2, 15)}--`; codeSectionPlaceholderMap.set(codePlaceholder, code); return codePlaceholder; 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 index ccb34536f..474aaca5f 100644 --- a/projects/element-ng/markdown-renderer/si-markdown-renderer.component.spec.ts +++ b/projects/element-ng/markdown-renderer/si-markdown-renderer.component.spec.ts @@ -122,7 +122,12 @@ const example = "code block"; expect(innerHTML).toContain('bold'); expect(innerHTML).toContain('italic'); expect(innerHTML).toContain('code'); - expect(innerHTML).toContain('
');
+    const codeWrapper = markdownDiv.querySelector('.code-wrapper')!;
+    expect(codeWrapper).toBeTruthy();
+    expect(codeWrapper.querySelector('button.copy-code-btn')).toBeTruthy();
+    const preElement = codeWrapper.querySelector('pre')!;
+    expect(preElement).toBeTruthy();
+    expect(preElement.querySelector('code')).toBeTruthy();
     expect(innerHTML).toContain('
  • First item
  • '); }); diff --git a/projects/element-ng/markdown-renderer/si-markdown-renderer.component.ts b/projects/element-ng/markdown-renderer/si-markdown-renderer.component.ts index fcf668b13..f268c9051 100644 --- a/projects/element-ng/markdown-renderer/si-markdown-renderer.component.ts +++ b/projects/element-ng/markdown-renderer/si-markdown-renderer.component.ts @@ -4,8 +4,9 @@ */ import { Component, effect, inject, input, ElementRef } from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; +import { injectSiTranslateService, t } from '@siemens/element-translate-ng/translate'; -import { getMarkdownRenderer } from './markdown-renderer'; +import { getMarkdownRenderer, type MarkdownRendererOptions } from './markdown-renderer'; /** * Component to display markdown text, uses the {@link getMarkdownRenderer} function internally, relies on `markdown-content` theme class. @@ -18,7 +19,7 @@ import { getMarkdownRenderer } from './markdown-renderer'; export class SiMarkdownRendererComponent { private sanitizer = inject(DomSanitizer); private hostElement = inject(ElementRef); - private markdownRenderer = getMarkdownRenderer(this.sanitizer); + private translateService = injectSiTranslateService(); /** * The markdown text to transform and display @@ -26,13 +27,35 @@ export class SiMarkdownRendererComponent { */ readonly text = input(''); + /** + * Do not display the copy code button. + * @defaultValue false + */ + readonly disableCopyButton = input(false); + + /** + * Label for the copy button. + * @defaultValue + * ``` + * t(() => $localize`:@@SI_MARKDOWN_RENDERER.COPY:Copy`) + * ``` + */ + readonly copyButtonLabel = input(t(() => $localize`:@@SI_MARKDOWN_RENDERER.COPY:Copy`)); + constructor() { effect(() => { const contentValue = this.text(); const containerEl = this.hostElement.nativeElement; + const options: MarkdownRendererOptions | undefined = { + copyCodeButton: !this.disableCopyButton() ? this.copyButtonLabel() : undefined, + translateSync: this.translateService.translateSync.bind(this.translateService) + }; + + const markdownRenderer = getMarkdownRenderer(this.sanitizer, options); + if (containerEl) { - const formattedNode = this.markdownRenderer(contentValue); + const formattedNode = markdownRenderer(contentValue); containerEl.innerHTML = ''; containerEl.appendChild(formattedNode); } diff --git a/projects/element-ng/translate/si-translatable-keys.interface.ts b/projects/element-ng/translate/si-translatable-keys.interface.ts index f5ce83212..ee9f03e33 100644 --- a/projects/element-ng/translate/si-translatable-keys.interface.ts +++ b/projects/element-ng/translate/si-translatable-keys.interface.ts @@ -165,6 +165,7 @@ export interface SiTranslatableKeys { 'SI_LOGIN_BASIC.USERNAME'?: string; 'SI_LOGIN_SINGLE-SIGN-ON.LOGIN_SIGN_UP'?: string; 'SI_MAIN_DETAIL_CONTAINER.BACK'?: string; + 'SI_MARKDOWN_RENDERER.COPY'?: string; 'SI_NAVBAR.OPEN_LAUNCHPAD'?: string; 'SI_NAVBAR.TOGGLE_NAVIGATION'?: string; 'SI_NAVBAR_VERTICAL.COLLAPSE'?: string; diff --git a/projects/element-theme/src/styles/components/_markdown.scss b/projects/element-theme/src/styles/components/_markdown.scss index 6cf0f4755..af5ba92d1 100644 --- a/projects/element-theme/src/styles/components/_markdown.scss +++ b/projects/element-theme/src/styles/components/_markdown.scss @@ -1,7 +1,7 @@ -@use '../variables'; - @use 'sass:map'; +@use '../variables'; + .markdown-content { max-inline-size: 100%; min-inline-size: 0; @@ -43,6 +43,12 @@ overflow-wrap: break-word; } + .code-wrapper { + position: relative; + inline-size: max-content; + max-inline-size: 100%; + } + pre { background-color: variables.$element-ui-4; padding-block: map.get(variables.$spacers, 4); @@ -66,6 +72,16 @@ } } + .copy-code-btn { + position: absolute; + inset-block-start: 0; + inset-inline-end: 0; + border-start-start-radius: 0; + border-start-end-radius: 0; + border-end-end-radius: 0; + z-index: 1; + } + em { font-style: italic; } diff --git a/src/app/examples/si-chat-messages/si-ai-message.ts b/src/app/examples/si-chat-messages/si-ai-message.ts index 7b46f6353..b37387867 100644 --- a/src/app/examples/si-chat-messages/si-ai-message.ts +++ b/src/app/examples/si-chat-messages/si-ai-message.ts @@ -7,7 +7,9 @@ import { DomSanitizer } from '@angular/platform-browser'; import { MessageAction, SiAiMessageComponent } from '@siemens/element-ng/chat-messages'; import { getMarkdownRenderer } from '@siemens/element-ng/markdown-renderer'; import { MenuItemAction } from '@siemens/element-ng/menu'; +import { injectSiTranslateService } from '@siemens/element-translate-ng/translate'; import { LOG_EVENT } from '@siemens/live-preview'; +import hljs from 'highlight.js'; @Component({ selector: 'app-sample', @@ -18,8 +20,12 @@ import { LOG_EVENT } from '@siemens/live-preview'; export class SampleComponent { logEvent = inject(LOG_EVENT); private sanitizer = inject(DomSanitizer); + private translate = injectSiTranslateService(); - protected markdownRenderer = getMarkdownRenderer(this.sanitizer); + protected markdownRenderer = getMarkdownRenderer(this.sanitizer, { + copyCodeButton: 'SI_MARKDOWN_RENDERER.COPY', + translateSync: this.translate.translateSync.bind(this.translate) + }); content = `Here's a **simple response** with basic formatting. diff --git a/src/app/examples/si-chat-messages/si-chat-container.ts b/src/app/examples/si-chat-messages/si-chat-container.ts index 60fc3b431..6dfef63fd 100644 --- a/src/app/examples/si-chat-messages/si-chat-container.ts +++ b/src/app/examples/si-chat-messages/si-chat-container.ts @@ -32,6 +32,7 @@ import { } from '@siemens/element-ng/markdown-renderer'; import { MenuItem } from '@siemens/element-ng/menu'; import { SiToastNotificationService } from '@siemens/element-ng/toast-notification'; +import { injectSiTranslateService } from '@siemens/element-translate-ng/translate'; import { LOG_EVENT } from '@siemens/live-preview'; interface ChatMessage { @@ -63,8 +64,12 @@ export class SampleComponent { private readonly modalTemplate = viewChild>('modalTemplate'); private sanitizer = inject(DomSanitizer); private readonly toastService = inject(SiToastNotificationService); + private translate = injectSiTranslateService(); - protected markdownRenderer = getMarkdownRenderer(this.sanitizer); + protected markdownRenderer = getMarkdownRenderer(this.sanitizer, { + copyCodeButton: 'SI_MARKDOWN_RENDERER.COPY', + translateSync: this.translate.translateSync.bind(this.translate) + }); readonly preAttachedFiles: ChatInputAttachment[] = [ { @@ -100,10 +105,10 @@ export class SampleComponent { ], actions: [ { - label: 'Copy message', + label: 'Export message', icon: 'element-export', action: (message: ChatMessage) => - this.logEvent(`Copy user message ${message.content.slice(0, 20)}...`) + this.logEvent(`Export user message ${message.content.slice(0, 20)}...`) } ] }, @@ -114,14 +119,14 @@ export class SampleComponent { Let me examine the structure and provide guidance.`, actions: [ { - label: 'Good response', + label: 'Add to list', icon: 'element-plus', - action: (_message: ChatMessage) => this.logEvent('Thumbs up for AI message') + action: (_message: ChatMessage) => this.logEvent('Add AI message to list') }, { - label: 'Copy response', + label: 'Export response', icon: 'element-export', - action: (_message: ChatMessage) => this.logEvent('Copy AI message') + action: (_message: ChatMessage) => this.logEvent('Export AI message') }, { label: 'Retry response', @@ -141,10 +146,10 @@ export class SampleComponent { 'Perfect! What should I focus on first\n\nI also want to make sure the performance is optimized for large datasets since this will be used in production with potentially millions of rows?', actions: [ { - label: 'Copy message', + label: 'Export message', icon: 'element-export', action: (_message: ChatMessage) => - this.logEvent(`Copy user message ${_message.content.slice(0, 20)}...`) + this.logEvent(`Export user message ${_message.content.slice(0, 20)}...`) } ] }, @@ -163,23 +168,23 @@ export class SampleComponent { inputActions: MessageAction[] = [ { - label: 'Text formatting', + label: 'Format text', icon: 'element-brush', - action: () => this.logEvent('Text formatting clicked') + action: () => this.logEvent('Format text clicked') }, { - label: 'Message templates', + label: 'Use template', icon: 'element-template', - action: () => this.logEvent('Templates clicked') + action: () => this.logEvent('Use template clicked') } ]; userActions: MessageAction[] = [ { - label: 'Copy message', + label: 'Export message', icon: 'element-export', action: (_message: ChatMessage) => - this.logEvent(`Copy user message ${_message.content.slice(0, 20)}...`) + this.logEvent(`Export user message ${_message.content.slice(0, 20)}...`) }, { label: 'Delete message', @@ -191,14 +196,14 @@ export class SampleComponent { aiActions: MessageAction[] = [ { - label: 'Good response', + label: 'Add to list', icon: 'element-plus', - action: (_message: ChatMessage) => this.logEvent('Thumbs up for AI message') + action: (_message: ChatMessage) => this.logEvent('Add AI message to list') }, { - label: 'Copy response', + label: 'Export response', icon: 'element-export', - action: (_message: ChatMessage) => this.logEvent('Copy AI message') + action: (_message: ChatMessage) => this.logEvent('Export AI message') } ]; @@ -211,9 +216,9 @@ export class SampleComponent { content: event.content, actions: [ { - label: 'Copy message', + label: 'Export message', icon: 'element-export', - action: () => this.logEvent('Copy user message') + action: () => this.logEvent('Export user message') } ], attachments: event.attachments.map(att => ({ @@ -253,14 +258,14 @@ export class SampleComponent { content: response, actions: [ { - label: 'Good response', + label: 'Add to list', icon: 'element-plus', - action: () => this.logEvent('Thumbs up for AI message') + action: () => this.logEvent('Add AI message to list') }, { - label: 'Copy response', + label: 'Export response', icon: 'element-export', - action: () => this.logEvent('Copy AI message') + action: () => this.logEvent('Export AI message') } ] } diff --git a/src/app/examples/si-chat-messages/si-user-message.ts b/src/app/examples/si-chat-messages/si-user-message.ts index be6738c71..d5715da8c 100644 --- a/src/app/examples/si-chat-messages/si-user-message.ts +++ b/src/app/examples/si-chat-messages/si-user-message.ts @@ -10,6 +10,7 @@ import { MessageAction } from '@siemens/element-ng/chat-messages'; import { getMarkdownRenderer } from '@siemens/element-ng/markdown-renderer'; +import { injectSiTranslateService } from '@siemens/element-translate-ng/translate'; import { LOG_EVENT } from '@siemens/live-preview'; @Component({ @@ -21,8 +22,12 @@ import { LOG_EVENT } from '@siemens/live-preview'; export class SampleComponent { logEvent = inject(LOG_EVENT); private sanitizer = inject(DomSanitizer); + private translate = injectSiTranslateService(); - protected markdownRenderer = getMarkdownRenderer(this.sanitizer); + protected markdownRenderer = getMarkdownRenderer(this.sanitizer, { + copyCodeButton: 'SI_MARKDOWN_RENDERER.COPY', + translateSync: this.translate.translateSync.bind(this.translate) + }); content = `Can you help me with this **code snippet**? From a21fc3dced7c8a4ed8bd69ed70cd3c27160ee33e Mon Sep 17 00:00:00 2001 From: Linus Schlumberger Date: Sat, 6 Dec 2025 14:48:47 +0100 Subject: [PATCH 02/30] feat(chat-messages): add table download button to markdown renderer --- .../element-ng/markdown-renderer/index.api.md | 3 + api-goldens/element-ng/translate/index.api.md | 2 + .../markdown-renderer/markdown-renderer.ts | 126 +++++++++++++++++- .../si-markdown-renderer.component.ts | 18 +++ .../si-translatable-keys.interface.ts | 1 + .../src/styles/components/_markdown.scss | 22 ++- .../si-chat-messages/si-ai-message.ts | 1 + .../si-chat-messages/si-chat-container.ts | 1 + .../si-chat-messages/si-user-message.ts | 1 + 9 files changed, 168 insertions(+), 7 deletions(-) diff --git a/api-goldens/element-ng/markdown-renderer/index.api.md b/api-goldens/element-ng/markdown-renderer/index.api.md index 5597c397c..ee60c23eb 100644 --- a/api-goldens/element-ng/markdown-renderer/index.api.md +++ b/api-goldens/element-ng/markdown-renderer/index.api.md @@ -16,6 +16,7 @@ export const getMarkdownRenderer: (sanitizer: DomSanitizer, options?: MarkdownRe // @public (undocumented) export interface MarkdownRendererOptions { copyCodeButton?: TranslatableString; + downloadTableButton?: TranslatableString; translateSync?: SiTranslateService['translateSync']; } @@ -24,6 +25,8 @@ export class SiMarkdownRendererComponent { constructor(); readonly copyButtonLabel: _angular_core.InputSignal<_siemens_element_translate_ng_translate_types.TranslatableString>; readonly disableCopyButton: _angular_core.InputSignal; + readonly disableDownloadButton: _angular_core.InputSignal; + readonly downloadButtonLabel: _angular_core.InputSignal<_siemens_element_translate_ng_translate_types.TranslatableString>; readonly text: _angular_core.InputSignal; } diff --git a/api-goldens/element-ng/translate/index.api.md b/api-goldens/element-ng/translate/index.api.md index 90813d72a..a2aac848f 100644 --- a/api-goldens/element-ng/translate/index.api.md +++ b/api-goldens/element-ng/translate/index.api.md @@ -336,6 +336,8 @@ export interface SiTranslatableKeys { // (undocumented) 'SI_MARKDOWN_RENDERER.COPY'?: string; // (undocumented) + 'SI_MARKDOWN_RENDERER.DOWNLOAD'?: string; + // (undocumented) 'SI_NAVBAR.OPEN_LAUNCHPAD'?: string; // (undocumented) 'SI_NAVBAR.TOGGLE_NAVIGATION'?: string; diff --git a/projects/element-ng/markdown-renderer/markdown-renderer.ts b/projects/element-ng/markdown-renderer/markdown-renderer.ts index f57119ca6..20a6b45b3 100644 --- a/projects/element-ng/markdown-renderer/markdown-renderer.ts +++ b/projects/element-ng/markdown-renderer/markdown-renderer.ts @@ -17,7 +17,13 @@ export interface MarkdownRendererOptions { */ copyCodeButton?: TranslatableString; /** - * Optional translate sync function of a service instance for translating the copy button label. + * Provide this to enable the table CSV download button functionality. + * Download button label for table CSV export (will be translated internally if translateService is provided). + * @defaultValue undefined + */ + downloadTableButton?: TranslatableString; + /** + * Optional translate sync function of a service instance for translating the copy button label and download button label. * @defaultValue undefined */ translateSync?: SiTranslateService['translateSync']; @@ -44,6 +50,9 @@ export const getMarkdownRenderer = ( const div = document.createElement('div'); div.className = 'markdown-content text-break'; + // Map to store original table cell content: tableIndex -> rowIndex -> columnIndex -> original text + const tableCellData = new Map>>(); + if (!text) { return div; } @@ -61,11 +70,27 @@ export const getMarkdownRenderer = ( let html = sanitizedInput.replace(new RegExp(newlinePlaceholder, 'g'), '\n'); // Process tables first + let tableIndex = -1; + const rowIndexMap = new Map(); + html = html // Remove table separator lines first .replace(/^\|\s*[-:]+.*\|\s*$/gm, '') // Process table rows .replace(/^\|(.+)\|\s*$/gm, (_match, htmlContent) => { + // Track which table we're in (increment on first row of each table) + const isFirstRow = rowIndexMap.get(tableIndex) === undefined; + if (isFirstRow) { + tableIndex++; + rowIndexMap.set(tableIndex, 0); + tableCellData.set(tableIndex, new Map()); + } + + const currentRowIndex = rowIndexMap.get(tableIndex)!; + const tableData = tableCellData.get(tableIndex)!; + tableData.set(currentRowIndex, new Map()); + const rowData = tableData.get(currentRowIndex)!; + // Handle escaped pipes by temporarily replacing them const escapedPipePlaceholder = `--ESCAPED-PIPE-${Math.random().toString(36).substring(2, 15)}--`; const contentWithPlaceholders = htmlContent.replace(/\\\|/g, escapedPipePlaceholder); @@ -103,17 +128,54 @@ export const getMarkdownRenderer = ( return preProcessedCell; }); - // Recursively process cell content for markdown formatting - const processedCells = cellsWithNewlines.map((cell: string) => { - return transformMarkdownText(cell, false, sanitizer); + // Recursively process cell content for markdown formatting and store original data + const processedCells = cellsWithNewlines.map((cell: string, index: number) => { + const formattedContent = transformMarkdownText(cell, false, sanitizer, options); + // Store original cell text in the map + rowData.set(index, cells[index]); + return formattedContent; }); + // Increment row index for next row + rowIndexMap.set(tableIndex, currentRowIndex + 1); + return `
    ${processedCells.map((cell: string) => ``).join('')}`; }) // Wrap table rows in table elements - .replace(/(.*?<\/tr>)/gs, '
    ${cell}
    $1
    ') + .replace( + /(.*?<\/tr>)/gs, + (() => { + let currentTableWrapIndex = -1; + + const translatedLabel = + options?.downloadTableButton && options?.translateSync + ? options.translateSync(options.downloadTableButton) + : options?.downloadTableButton + ? String(options.downloadTableButton) + : undefined; + const buttonLabel = translatedLabel + ?.replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + + return (match: string) => { + currentTableWrapIndex++; + const tableId = `table-${Math.random().toString(36).substring(2, 15)}`; + + const downloadTableCsvButton = buttonLabel + ? `` + : ''; + + return `
    ${downloadTableCsvButton}${match}
    `; + }; + })() + ) // Remove duplicate table tags - .replace(/<\/table>\s*/g, ''); + .replace( + /<\/table><\/div>\s*
    ]*><\/button>
    ]*>/g, + '' + ); html = transformMarkdownText(html, true, sanitizer, options); @@ -141,6 +203,58 @@ export const getMarkdownRenderer = ( }); }); }); + + // Add download functionality to tables (CSV export) + div.querySelectorAll('.download-table-btn').forEach(btn => { + btn.addEventListener('click', e => { + const button = e.target as HTMLButtonElement; + const tableId = button.getAttribute('data-table-id'); + const tableIndexStr = button.getAttribute('data-table-index'); + if (!tableId || tableIndexStr === null) { + return; + } + + const tableElement = div.querySelector(`#${tableId}`) as HTMLTableElement; + if (!tableElement) { + return; + } + + const currentTableIndex = parseInt(tableIndexStr, 10); + const tableData = tableCellData.get(currentTableIndex); + + // Convert table to CSV + const rows = Array.from(tableElement.querySelectorAll('tr')); + const csv = rows + .map((row, rowIndex) => { + const cells = Array.from(row.querySelectorAll('td, th')); + return cells + .map((cell, columnIndex) => { + // Use original text from map, fallback to textContent + const cellText = + tableData?.get(rowIndex)?.get(columnIndex) ?? cell.textContent ?? ''; + // Escape quotes and wrap in quotes if contains comma, quote, or newline + if (cellText.includes(',') || cellText.includes('"') || cellText.includes('\n')) { + return `"${cellText.replace(/"/g, '""')}"`; + } + return cellText; + }) + .join(','); + }) + .join('\n'); + + // Create and trigger download + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + link.setAttribute('href', url); + link.setAttribute('download', 'table.csv'); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }); + }); return div; }; }; diff --git a/projects/element-ng/markdown-renderer/si-markdown-renderer.component.ts b/projects/element-ng/markdown-renderer/si-markdown-renderer.component.ts index f268c9051..12c2f6c86 100644 --- a/projects/element-ng/markdown-renderer/si-markdown-renderer.component.ts +++ b/projects/element-ng/markdown-renderer/si-markdown-renderer.component.ts @@ -33,6 +33,12 @@ export class SiMarkdownRendererComponent { */ readonly disableCopyButton = input(false); + /** + * Do not display the download CSV button for tables. + * @defaultValue false + */ + readonly disableDownloadButton = input(false); + /** * Label for the copy button. * @defaultValue @@ -42,6 +48,17 @@ export class SiMarkdownRendererComponent { */ readonly copyButtonLabel = input(t(() => $localize`:@@SI_MARKDOWN_RENDERER.COPY:Copy`)); + /** + * Label for the download CSV button. + * @defaultValue + * ``` + * t(() => $localize`:@@SI_MARKDOWN_RENDERER.DOWNLOAD:Download CSV`) + * ``` + */ + readonly downloadButtonLabel = input( + t(() => $localize`:@@SI_MARKDOWN_RENDERER.DOWNLOAD:Download CSV`) + ); + constructor() { effect(() => { const contentValue = this.text(); @@ -49,6 +66,7 @@ export class SiMarkdownRendererComponent { const options: MarkdownRendererOptions | undefined = { copyCodeButton: !this.disableCopyButton() ? this.copyButtonLabel() : undefined, + downloadTableButton: !this.disableDownloadButton() ? this.downloadButtonLabel() : undefined, translateSync: this.translateService.translateSync.bind(this.translateService) }; diff --git a/projects/element-ng/translate/si-translatable-keys.interface.ts b/projects/element-ng/translate/si-translatable-keys.interface.ts index ee9f03e33..e4e0390a5 100644 --- a/projects/element-ng/translate/si-translatable-keys.interface.ts +++ b/projects/element-ng/translate/si-translatable-keys.interface.ts @@ -166,6 +166,7 @@ export interface SiTranslatableKeys { 'SI_LOGIN_SINGLE-SIGN-ON.LOGIN_SIGN_UP'?: string; 'SI_MAIN_DETAIL_CONTAINER.BACK'?: string; 'SI_MARKDOWN_RENDERER.COPY'?: string; + 'SI_MARKDOWN_RENDERER.DOWNLOAD'?: string; 'SI_NAVBAR.OPEN_LAUNCHPAD'?: string; 'SI_NAVBAR.TOGGLE_NAVIGATION'?: string; 'SI_NAVBAR_VERTICAL.COLLAPSE'?: string; diff --git a/projects/element-theme/src/styles/components/_markdown.scss b/projects/element-theme/src/styles/components/_markdown.scss index af5ba92d1..20e7cb089 100644 --- a/projects/element-theme/src/styles/components/_markdown.scss +++ b/projects/element-theme/src/styles/components/_markdown.scss @@ -105,13 +105,33 @@ margin-block-end: 0 !important; } - table { + .table-wrapper { + position: relative; inline-size: max-content; max-inline-size: 100%; // stylelint-disable-next-line declaration-no-important margin-block-start: map.get(variables.$spacers, 6) !important; // stylelint-disable-next-line declaration-no-important margin-block-end: 0 !important; + } + + .download-table-btn { + position: absolute; + inset-block-start: 0; + inset-inline-end: 0; + border-start-start-radius: 0; + border-start-end-radius: 0; + border-end-end-radius: 0; + z-index: 1; + } + + table { + inline-size: max-content; + max-inline-size: 100%; + // stylelint-disable-next-line declaration-no-important + margin-block-start: 0 !important; + // stylelint-disable-next-line declaration-no-important + margin-block-end: 0 !important; overflow: auto; display: block; white-space: nowrap; diff --git a/src/app/examples/si-chat-messages/si-ai-message.ts b/src/app/examples/si-chat-messages/si-ai-message.ts index b37387867..2bcd6354f 100644 --- a/src/app/examples/si-chat-messages/si-ai-message.ts +++ b/src/app/examples/si-chat-messages/si-ai-message.ts @@ -24,6 +24,7 @@ export class SampleComponent { protected markdownRenderer = getMarkdownRenderer(this.sanitizer, { copyCodeButton: 'SI_MARKDOWN_RENDERER.COPY', + downloadTableButton: 'SI_MARKDOWN_RENDERER.DOWNLOAD', translateSync: this.translate.translateSync.bind(this.translate) }); diff --git a/src/app/examples/si-chat-messages/si-chat-container.ts b/src/app/examples/si-chat-messages/si-chat-container.ts index 6dfef63fd..8140743a2 100644 --- a/src/app/examples/si-chat-messages/si-chat-container.ts +++ b/src/app/examples/si-chat-messages/si-chat-container.ts @@ -68,6 +68,7 @@ export class SampleComponent { protected markdownRenderer = getMarkdownRenderer(this.sanitizer, { copyCodeButton: 'SI_MARKDOWN_RENDERER.COPY', + downloadTableButton: 'SI_MARKDOWN_RENDERER.DOWNLOAD', translateSync: this.translate.translateSync.bind(this.translate) }); diff --git a/src/app/examples/si-chat-messages/si-user-message.ts b/src/app/examples/si-chat-messages/si-user-message.ts index d5715da8c..9a556118e 100644 --- a/src/app/examples/si-chat-messages/si-user-message.ts +++ b/src/app/examples/si-chat-messages/si-user-message.ts @@ -26,6 +26,7 @@ export class SampleComponent { protected markdownRenderer = getMarkdownRenderer(this.sanitizer, { copyCodeButton: 'SI_MARKDOWN_RENDERER.COPY', + downloadTableButton: 'SI_MARKDOWN_RENDERER.DOWNLOAD', translateSync: this.translate.translateSync.bind(this.translate) }); From aec92055cc525c0a15f3f0d1459f1e27f34bc473 Mon Sep 17 00:00:00 2001 From: Linus Schlumberger Date: Sat, 6 Dec 2025 14:45:04 +0100 Subject: [PATCH 03/30] fix(chat-messages): complete unfinished code blocks for streaming in markdown renderer --- .../markdown-renderer/markdown-renderer.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/projects/element-ng/markdown-renderer/markdown-renderer.ts b/projects/element-ng/markdown-renderer/markdown-renderer.ts index 20a6b45b3..be1b5b792 100644 --- a/projects/element-ng/markdown-renderer/markdown-renderer.ts +++ b/projects/element-ng/markdown-renderer/markdown-renderer.ts @@ -273,6 +273,11 @@ const transformMarkdownText = ( const escapedUnderscorePlaceholder = `--UNDERSCORE-${Math.random().toString(36).substring(2, 15)}--`; // Apply markdown transformations to the sanitized content + + // Add temporary closing backticks at the end to handle incomplete code blocks during streaming + const tempClosingMarker = `\n\`\`\`--TEMP-CLOSE--\n`; + html = html + tempClosingMarker; + html = html // Multiline code blocks ```code``` with placeholder .replace(/```[^\n]*\n?([\s\S]*?)\n?```/g, (match, content) => { @@ -303,7 +308,12 @@ const transformMarkdownText = ( const codePlaceholder = `--CODE-BLOCK-${Math.random().toString(36).substring(2, 15)}--`; codeSectionPlaceholderMap.set(codePlaceholder, code); return codePlaceholder; - }) + }); + + // Remove temporary closing marker if it's still there (wasn't part of a code block) + html = html.replace(tempClosingMarker, '').replace(/--TEMP-CLOSE--/g, ''); + + html = html // Inline code `text` .replace(/`(.*?)`/g, (match, content) => { From 3e38d1b162f24e8a6e621b18090ed07a5c76ec07 Mon Sep 17 00:00:00 2001 From: Linus Schlumberger Date: Sat, 6 Dec 2025 17:49:35 +0100 Subject: [PATCH 04/30] fix(chat-messages): sanitize code-block separately --- .../markdown-renderer/markdown-renderer.ts | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/projects/element-ng/markdown-renderer/markdown-renderer.ts b/projects/element-ng/markdown-renderer/markdown-renderer.ts index be1b5b792..736afbdd9 100644 --- a/projects/element-ng/markdown-renderer/markdown-renderer.ts +++ b/projects/element-ng/markdown-renderer/markdown-renderer.ts @@ -61,7 +61,17 @@ export const getMarkdownRenderer = ( const newlinePlaceholder = `--NEWLINE-${Math.random().toString(36).substring(2, 15)}--`; // Replace newlines with placeholder before sanitization - const valueWithPlaceholders = text.replace(/\n/g, newlinePlaceholder); + let valueWithPlaceholders = text.replace(/\n/g, newlinePlaceholder); + + // Replace code blocks with placeholders to preserve them during sanitization + const codeBlockPlaceholderMap = new Map(); + valueWithPlaceholders = valueWithPlaceholders + // Preserve code blocks by replacing them with placeholders + .replace(/```([\s\S]*?)```/g, match => { + const codePlaceholder = `--CODE-BLOCK-${Math.random().toString(36).substring(2, 15)}--`; + codeBlockPlaceholderMap.set(codePlaceholder, match); + return codePlaceholder; + }); // Sanitize the input using Angular's HTML sanitizer const sanitizedInput = sanitizer.sanitize(SecurityContext.HTML, valueWithPlaceholders) ?? ''; @@ -69,6 +79,15 @@ export const getMarkdownRenderer = ( // Restore newlines from placeholder for markdown processing. let html = sanitizedInput.replace(new RegExp(newlinePlaceholder, 'g'), '\n'); + // Restore code blocks from placeholders + codeBlockPlaceholderMap.forEach((codeBlock, placeholder) => { + // In the blocks, restore newlines from placeholder for markdown processing. + html = html.replace( + new RegExp(placeholder, 'g'), + codeBlock.replace(new RegExp(newlinePlaceholder, 'g'), '\n') + ); + }); + // Process tables first let tableIndex = -1; const rowIndexMap = new Map(); @@ -289,6 +308,9 @@ const transformMarkdownText = ( const codeId = `code-${Math.random().toString(36).substring(2, 15)}`; + // Apply sanitization to the final code content + const codeBlockContent = sanitizer.sanitize(SecurityContext.HTML, escapedCode); + const translatedLabel = options?.copyCodeButton && options?.translateSync ? options.translateSync(options.copyCodeButton) @@ -304,7 +326,7 @@ const transformMarkdownText = ( ? `` : ''; - const code = `
    ${codeCopyButton}
    ${escapedCode}
    `; + const code = `
    ${codeCopyButton}
    ${codeBlockContent}
    `; const codePlaceholder = `--CODE-BLOCK-${Math.random().toString(36).substring(2, 15)}--`; codeSectionPlaceholderMap.set(codePlaceholder, code); return codePlaceholder; From eaaa2d8cac42bc8b0335395c6150fd28f592bb79 Mon Sep 17 00:00:00 2001 From: Linus Schlumberger Date: Sat, 6 Dec 2025 14:46:26 +0100 Subject: [PATCH 05/30] feat(chat-messages): add support for code syntax highlighting in markdown renderer --- .../element-ng/markdown-renderer/index.api.md | 2 + package-lock.json | 19 ++- package.json | 1 + .../markdown-renderer/markdown-renderer.ts | 21 ++- .../si-markdown-renderer.component.ts | 37 +++-- .../components/_markdown-hljs-theme.scss | 142 ++++++++++++++++++ .../src/styles/components/_markdown.scss | 2 + .../si-chat-messages/si-ai-message.ts | 17 ++- .../si-chat-messages/si-chat-container.ts | 18 ++- .../si-chat-messages/si-user-message.ts | 18 ++- .../si-markdown-renderer.html | 2 +- .../si-markdown-renderer.ts | 17 +++ 12 files changed, 276 insertions(+), 20 deletions(-) create mode 100644 projects/element-theme/src/styles/components/_markdown-hljs-theme.scss diff --git a/api-goldens/element-ng/markdown-renderer/index.api.md b/api-goldens/element-ng/markdown-renderer/index.api.md index ee60c23eb..0b6dfd733 100644 --- a/api-goldens/element-ng/markdown-renderer/index.api.md +++ b/api-goldens/element-ng/markdown-renderer/index.api.md @@ -17,6 +17,7 @@ export const getMarkdownRenderer: (sanitizer: DomSanitizer, options?: MarkdownRe export interface MarkdownRendererOptions { copyCodeButton?: TranslatableString; downloadTableButton?: TranslatableString; + syntaxHighlighter?: (code: string, language?: string) => string | undefined; translateSync?: SiTranslateService['translateSync']; } @@ -27,6 +28,7 @@ export class SiMarkdownRendererComponent { readonly disableCopyButton: _angular_core.InputSignal; readonly disableDownloadButton: _angular_core.InputSignal; readonly downloadButtonLabel: _angular_core.InputSignal<_siemens_element_translate_ng_translate_types.TranslatableString>; + readonly syntaxHighlighter: _angular_core.InputSignal<((code: string, language?: string) => string | undefined) | undefined>; readonly text: _angular_core.InputSignal; } diff --git a/package-lock.json b/package-lock.json index bd321a72b..26061b895 100644 --- a/package-lock.json +++ b/package-lock.json @@ -83,6 +83,7 @@ "eslint-plugin-perfectionist": "5.3.1", "eslint-plugin-prefer-arrow": "1.2.3", "eslint-plugin-tsdoc": "0.5.0", + "highlight.js": "^11.11.1", "http-server": "14.1.1", "husky": "9.1.7", "jasmine": "5.13.0", @@ -12090,6 +12091,16 @@ "dev": true, "license": "MIT" }, + "node_modules/cli-highlight/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/cli-highlight/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -16220,13 +16231,13 @@ } }, "node_modules/highlight.js": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", - "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", "dev": true, "license": "BSD-3-Clause", "engines": { - "node": "*" + "node": ">=12.0.0" } }, "node_modules/homedir-polyfill": { diff --git a/package.json b/package.json index 3bad464cc..fa816bfdc 100644 --- a/package.json +++ b/package.json @@ -151,6 +151,7 @@ "eslint-plugin-perfectionist": "5.3.1", "eslint-plugin-prefer-arrow": "1.2.3", "eslint-plugin-tsdoc": "0.5.0", + "highlight.js": "^11.11.1", "http-server": "14.1.1", "husky": "9.1.7", "jasmine": "5.13.0", diff --git a/projects/element-ng/markdown-renderer/markdown-renderer.ts b/projects/element-ng/markdown-renderer/markdown-renderer.ts index 736afbdd9..dae902c59 100644 --- a/projects/element-ng/markdown-renderer/markdown-renderer.ts +++ b/projects/element-ng/markdown-renderer/markdown-renderer.ts @@ -22,6 +22,14 @@ export interface MarkdownRendererOptions { * @defaultValue undefined */ downloadTableButton?: TranslatableString; + /** + * Optional syntax highlighter function. + * Receives code content and optional language, returns an HTML content string to display inside of the code block or undefined to use default rendering. + * The returned code is sanitized before insertion. + * Make sure that the required styles/scripts for the syntax highlighter are included in your application. + * @defaultValue undefined + */ + syntaxHighlighter?: (code: string, language?: string) => string | undefined; /** * Optional translate sync function of a service instance for translating the copy button label and download button label. * @defaultValue undefined @@ -299,7 +307,7 @@ const transformMarkdownText = ( html = html // Multiline code blocks ```code``` with placeholder - .replace(/```[^\n]*\n?([\s\S]*?)\n?```/g, (match, content) => { + .replace(/```([^\n]*)\n?([\s\S]*?)\n?```/g, (match, language, content) => { // Escape HTML special characters in code blocks (not for security, but for correct display) and preserve inner backticks const escapedCode = content .replace(/(false); + /** + * Optional syntax highlighter function for code blocks. + * Receives code content and optional language, returns an HTML content string to display inside of the code block or undefined to use default rendering. + * The returned code is sanitized before insertion. + * Make sure that the required styles/scripts for the syntax highlighter are included in your application. + * @defaultValue undefined + */ + readonly syntaxHighlighter = input< + ((code: string, language?: string) => string | undefined) | undefined + >(undefined); + /** * Label for the copy button. * @defaultValue @@ -59,21 +70,27 @@ export class SiMarkdownRendererComponent { t(() => $localize`:@@SI_MARKDOWN_RENDERER.DOWNLOAD:Download CSV`) ); + private readonly markdownRenderer = computed(() => { + const highlighterFn = this.syntaxHighlighter(); + + const options: MarkdownRendererOptions | undefined = { + copyCodeButton: !this.disableCopyButton() ? this.copyButtonLabel() : undefined, + downloadTableButton: !this.disableDownloadButton() ? this.downloadButtonLabel() : undefined, + syntaxHighlighter: highlighterFn, + translateSync: this.translateService.translateSync.bind(this.translateService) + }; + + return getMarkdownRenderer(this.sanitizer, options); + }); + constructor() { effect(() => { const contentValue = this.text(); const containerEl = this.hostElement.nativeElement; - - const options: MarkdownRendererOptions | undefined = { - copyCodeButton: !this.disableCopyButton() ? this.copyButtonLabel() : undefined, - downloadTableButton: !this.disableDownloadButton() ? this.downloadButtonLabel() : undefined, - translateSync: this.translateService.translateSync.bind(this.translateService) - }; - - const markdownRenderer = getMarkdownRenderer(this.sanitizer, options); + const renderer = this.markdownRenderer(); if (containerEl) { - const formattedNode = markdownRenderer(contentValue); + const formattedNode = renderer(contentValue); containerEl.innerHTML = ''; containerEl.appendChild(formattedNode); } diff --git a/projects/element-theme/src/styles/components/_markdown-hljs-theme.scss b/projects/element-theme/src/styles/components/_markdown-hljs-theme.scss new file mode 100644 index 000000000..cf56192b6 --- /dev/null +++ b/projects/element-theme/src/styles/components/_markdown-hljs-theme.scss @@ -0,0 +1,142 @@ +@use '../variables'; + +// stylelint-disable selector-class-pattern + +// Custom highlight.js theme using Element Design System tokens +// Automatically adapts to light and dark themes +// Token mapping: +// 1. Default and text: element-text-primary +// 2. Keywords: element-data-purple-2 +// 3. Operators: element-data-plum-2 +// 4. Variables: element-data-6 +// 5. Functions: element-text-caution +// 6. Strings: element-text-warning +// 7. Numbers: element-text-success +// 8. Constants: element-data-13 +// 9. Comments: element-data-green-3 +// 10. Punctuation: element-data-17 +// 11. Tags: element-data-5 +// 12. Types: element-data-4 +// 13. Invalid: element-data-10 + +.markdown-content { + // 1. Default and text + .hljs { + color: variables.$element-text-primary; + } + + // 2. Keywords + .hljs-keyword, + .hljs-literal { + color: variables.$element-data-purple-2; + } + + // 3. Special keywords and operators + .hljs-operator { + color: variables.$element-data-plum-2; + } + + // 4. Variables + .hljs-variable, + .hljs-variable.language_, + .hljs-variable.constant_, + .hljs-params, + .hljs-property { + color: variables.$element-data-6; + } + + // 5. Functions and methods + .hljs-title, + .hljs-title.function_, + .hljs-title.function_.invoke__, + .hljs-built_in { + color: variables.$element-text-caution; + } + + // 6. Strings + .hljs-string, + .hljs-regexp, + .hljs-char.escape_, + .hljs-subst, + .hljs-doctag { + color: variables.$element-text-warning; + } + + // 7. Numbers + .hljs-number { + color: variables.$element-text-success; + } + + // 8. Constants and enums + .hljs-symbol, + .hljs-bullet { + color: variables.$element-data-13; + } + + // 9. Comments + .hljs-comment, + .hljs-quote { + color: variables.$element-data-green-3; + } + + // 10. Brackets/punctuation + .hljs-punctuation { + color: variables.$element-data-17; + } + + // 11. Tags (HTML/XML) + .hljs-tag, + .hljs-name, + .hljs-attr, + .hljs-attribute, + .hljs-selector-tag, + .hljs-selector-id, + .hljs-selector-class, + .hljs-selector-attr, + .hljs-selector-pseudo, + .hljs-meta, + .hljs-meta.keyword_, + .hljs-template-tag, + .hljs-template-variable { + color: variables.$element-data-5; + } + + // 12. Types declaration and references + .hljs-type, + .hljs-title.class_, + .hljs-title.class_.inherited__, + .hljs-section { + color: variables.$element-data-4; + } + + // 13. Invalid + .hljs-deletion { + color: variables.$element-data-10; + } + + // Additions in diffs (use comment green) + .hljs-addition { + color: variables.$element-data-green-3; + } + + // Emphasis + .hljs-emphasis { + font-style: italic; + } + + // Strong emphasis + .hljs-strong { + font-weight: variables.$si-font-weight-semibold; + } + + // Code blocks and formulas + .hljs-code, + .hljs-formula { + color: variables.$element-text-primary; + } + + // Links + .hljs-link { + color: variables.$element-text-warning; + } +} diff --git a/projects/element-theme/src/styles/components/_markdown.scss b/projects/element-theme/src/styles/components/_markdown.scss index 20e7cb089..6288f7036 100644 --- a/projects/element-theme/src/styles/components/_markdown.scss +++ b/projects/element-theme/src/styles/components/_markdown.scss @@ -2,6 +2,8 @@ @use '../variables'; +@use './markdown-hljs-theme'; + .markdown-content { max-inline-size: 100%; min-inline-size: 0; diff --git a/src/app/examples/si-chat-messages/si-ai-message.ts b/src/app/examples/si-chat-messages/si-ai-message.ts index 2bcd6354f..d7e6dfc77 100644 --- a/src/app/examples/si-chat-messages/si-ai-message.ts +++ b/src/app/examples/si-chat-messages/si-ai-message.ts @@ -25,7 +25,22 @@ export class SampleComponent { protected markdownRenderer = getMarkdownRenderer(this.sanitizer, { copyCodeButton: 'SI_MARKDOWN_RENDERER.COPY', downloadTableButton: 'SI_MARKDOWN_RENDERER.DOWNLOAD', - translateSync: this.translate.translateSync.bind(this.translate) + translateSync: this.translate.translateSync.bind(this.translate), + // Optional: Syntax highlighting with highlight.js + // This function returns highlighted HTML markup for the code content. + // The returned HTML is sanitized before insertion. + // Element provides a built-in highlight.js theme that adapts to light/dark mode. + // Make sure to include highlight.js as a dependency. + syntaxHighlighter: (code: string, language?: string): string | undefined => { + if (language && hljs.getLanguage(language)) { + try { + return hljs.highlight(code, { language }).value; + } catch { + // If highlighting fails, fall back to no highlighting + } + } + return undefined; + } }); content = `Here's a **simple response** with basic formatting. diff --git a/src/app/examples/si-chat-messages/si-chat-container.ts b/src/app/examples/si-chat-messages/si-chat-container.ts index 8140743a2..96fe05142 100644 --- a/src/app/examples/si-chat-messages/si-chat-container.ts +++ b/src/app/examples/si-chat-messages/si-chat-container.ts @@ -34,6 +34,7 @@ import { MenuItem } from '@siemens/element-ng/menu'; import { SiToastNotificationService } from '@siemens/element-ng/toast-notification'; import { injectSiTranslateService } from '@siemens/element-translate-ng/translate'; import { LOG_EVENT } from '@siemens/live-preview'; +import hljs from 'highlight.js'; interface ChatMessage { type: 'user' | 'ai' | 'custom'; @@ -69,7 +70,22 @@ export class SampleComponent { protected markdownRenderer = getMarkdownRenderer(this.sanitizer, { copyCodeButton: 'SI_MARKDOWN_RENDERER.COPY', downloadTableButton: 'SI_MARKDOWN_RENDERER.DOWNLOAD', - translateSync: this.translate.translateSync.bind(this.translate) + translateSync: this.translate.translateSync.bind(this.translate), + // Optional: Syntax highlighting with highlight.js + // This function returns highlighted HTML markup for the code content. + // The returned HTML is sanitized before insertion. + // Element provides a built-in highlight.js theme that adapts to light/dark mode. + // Make sure to include highlight.js as a dependency. + syntaxHighlighter: (code: string, language?: string): string | undefined => { + if (language && hljs.getLanguage(language)) { + try { + return hljs.highlight(code, { language }).value; + } catch { + // If highlighting fails, fall back to no highlighting + } + } + return undefined; + } }); readonly preAttachedFiles: ChatInputAttachment[] = [ diff --git a/src/app/examples/si-chat-messages/si-user-message.ts b/src/app/examples/si-chat-messages/si-user-message.ts index 9a556118e..0838a260e 100644 --- a/src/app/examples/si-chat-messages/si-user-message.ts +++ b/src/app/examples/si-chat-messages/si-user-message.ts @@ -12,6 +12,7 @@ import { import { getMarkdownRenderer } from '@siemens/element-ng/markdown-renderer'; import { injectSiTranslateService } from '@siemens/element-translate-ng/translate'; import { LOG_EVENT } from '@siemens/live-preview'; +import hljs from 'highlight.js'; @Component({ selector: 'app-sample', @@ -27,7 +28,22 @@ export class SampleComponent { protected markdownRenderer = getMarkdownRenderer(this.sanitizer, { copyCodeButton: 'SI_MARKDOWN_RENDERER.COPY', downloadTableButton: 'SI_MARKDOWN_RENDERER.DOWNLOAD', - translateSync: this.translate.translateSync.bind(this.translate) + translateSync: this.translate.translateSync.bind(this.translate), + // Optional: Syntax highlighting with highlight.js + // This function returns highlighted HTML markup for the code content. + // The returned HTML is sanitized before insertion. + // Element provides a built-in highlight.js theme that adapts to light/dark mode. + // Make sure to include highlight.js as a dependency. + syntaxHighlighter: (code: string, language?: string): string | undefined => { + if (language && hljs.getLanguage(language)) { + try { + return hljs.highlight(code, { language }).value; + } catch { + // If highlighting fails, fall back to no highlighting + } + } + return undefined; + } }); content = `Can you help me with this **code snippet**? diff --git a/src/app/examples/si-markdown-renderer/si-markdown-renderer.html b/src/app/examples/si-markdown-renderer/si-markdown-renderer.html index cf0ac2aad..56494fbc3 100644 --- a/src/app/examples/si-markdown-renderer/si-markdown-renderer.html +++ b/src/app/examples/si-markdown-renderer/si-markdown-renderer.html @@ -1,3 +1,3 @@
    - +
    diff --git a/src/app/examples/si-markdown-renderer/si-markdown-renderer.ts b/src/app/examples/si-markdown-renderer/si-markdown-renderer.ts index 1aa759fd4..32d7619ef 100644 --- a/src/app/examples/si-markdown-renderer/si-markdown-renderer.ts +++ b/src/app/examples/si-markdown-renderer/si-markdown-renderer.ts @@ -12,6 +12,7 @@ import { signal } from '@angular/core'; import { SiMarkdownRendererComponent } from '@siemens/element-ng/markdown-renderer'; +import hljs from 'highlight.js'; @Component({ selector: 'app-sample', @@ -24,6 +25,22 @@ export class SampleComponent implements OnInit { readonly markdownText = signal(''); private cdRef = inject(ChangeDetectorRef); + // Optional: Syntax highlighting with highlight.js + // This function returns highlighted HTML markup for the code content. + // The returned HTML is sanitized before insertion. + // Element provides a built-in highlight.js theme that adapts to light/dark mode. + // Make sure to include highlight.js as a dependency. + readonly syntaxHighlighter = (code: string, language?: string): string | undefined => { + if (language && hljs.getLanguage(language)) { + try { + return hljs.highlight(code, { language }).value; + } catch { + // If highlighting fails, fall back to no highlighting + } + } + return undefined; + }; + ngOnInit(): void { this.http.get('assets/sample-markdown.md', { responseType: 'text' }).subscribe(text => { this.markdownText.set(text); From 717b3ca7defdf6a3998cf4f1fe75a1409b286153 Mon Sep 17 00:00:00 2001 From: Linus Schlumberger Date: Mon, 22 Dec 2025 16:11:23 +0100 Subject: [PATCH 06/30] fix(chat-messages): support horizontal rule/divider in markdown renderer --- projects/element-ng/markdown-renderer/markdown-renderer.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/projects/element-ng/markdown-renderer/markdown-renderer.ts b/projects/element-ng/markdown-renderer/markdown-renderer.ts index dae902c59..368f73f63 100644 --- a/projects/element-ng/markdown-renderer/markdown-renderer.ts +++ b/projects/element-ng/markdown-renderer/markdown-renderer.ts @@ -406,7 +406,10 @@ const transformMarkdownText = ( .replace(/^#### (.*$)/gm, '

    $1

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

    $1

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

    $1

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

    $1

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

    $1

    ') + + // Horizontal rule --- + .replace(/^---+$/gm, '
    '); html = html // Bullet points - handle each type separately (• gets converted to • by sanitizer) From e0d15a52b71a1ed4779af3d37aa27c5e73c2291e Mon Sep 17 00:00:00 2001 From: Linus Schlumberger Date: Sat, 6 Dec 2025 15:12:14 +0100 Subject: [PATCH 07/30] docs(chat-messages): extend markdown renderer example --- .../e2e/element-examples/static.spec.ts | 2 +- ...r-element-examples-chromium-dark-linux.png | 4 ++-- ...-element-examples-chromium-light-linux.png | 4 ++-- ...rkdown-renderer--si-markdown-renderer.yaml | 16 ++++++++++---- src/assets/sample-markdown.md | 21 ++++++++++++++++++- 5 files changed, 37 insertions(+), 10 deletions(-) diff --git a/playwright/e2e/element-examples/static.spec.ts b/playwright/e2e/element-examples/static.spec.ts index af0c0ef63..7002c8182 100644 --- a/playwright/e2e/element-examples/static.spec.ts +++ b/playwright/e2e/element-examples/static.spec.ts @@ -110,7 +110,7 @@ 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'] })); + si.static({ disabledA11yRules: ['link-in-text-block', 'color-contrast'] })); 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/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-dark-linux.png index 51092e1d9..dc9ab7539 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-dark-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-dark-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:812619a4f94ad30609fb21818985dd7c84413357890804d08673e6215cbdd499 -size 142176 +oid sha256:1226683d7f271c163e51f305b54059d263456efc42b6a04fc0afdac9fcd26b87 +size 477971 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-light-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-light-linux.png index 9abc29eea..53e7c592e 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-light-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-light-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:93b378bb738fd8100e34380f71d548aaf6cc0244424c403910e28807a3b1be90 -size 137686 +oid sha256:b3b7925adf2c8ee2dd53a22608b697c74ed13a21bd3bc9bbb801be6d55a36e41 +size 473756 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer.yaml b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer.yaml index 8b16fe2e6..15379810c 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer.yaml +++ b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer.yaml @@ -1,5 +1,5 @@ -- heading "AI Assistant Response" [level=2]: - - strong: AI Assistant Response +- heading "Sample Markdown Content" [level=2]: + - strong: Sample Markdown Content - paragraph: - text: Here's a - strong: comprehensive example @@ -11,6 +11,7 @@ - code: console.log('Hello World') - text: "or multi-line code blocks:" - paragraph +- button "Copy" - code: "function calculateSum(a, b) { return a + b; } const result = calculateSum(5, 3); console.log(`Result: ${result}`);" - paragraph - heading "Formatting Options" [level=2] @@ -33,7 +34,7 @@ - /url: https://element.siemens.io - text: for more information, as well as local links like - link "Internal Page": - - /url: "/#/internal-page" + - /url: /#/internal-page - text: . - paragraph: - text: "Links are also automatically detected:" @@ -47,6 +48,8 @@ - listitem: Inline code highlighting - listitem: Bullet point lists - listitem: Blockquote support + - listitem: Or in the alternate format + - listitem: Another bullet point - paragraph: This paragraph appears after the list to show proper spacing. - heading "Ordered Lists" [level=2] - paragraph: "Step-by-step instructions:" @@ -65,9 +68,14 @@ - listitem: First ordered item - listitem: Second ordered item - paragraph: Final paragraph to show proper spacing. +- heading "Images" [level=2] +- paragraph: "Images can be included as follows:" +- paragraph: + - img "Building Image" - heading "Tables" [level=2] - paragraph: "Tables are also supported:" - paragraph +- button "Download CSV" - table: - rowgroup: - row "Feature Examples Status Notes": @@ -140,4 +148,4 @@ - code:
    - text: tags - text: This paragraph appears after the tables to demonstrate proper spacing. -- paragraph +- paragraph \ No newline at end of file diff --git a/src/assets/sample-markdown.md b/src/assets/sample-markdown.md index a0fd07cd4..c72640dc3 100644 --- a/src/assets/sample-markdown.md +++ b/src/assets/sample-markdown.md @@ -1,4 +1,4 @@ -# AI Assistant Response +# Sample Markdown Content Here's a **comprehensive example** of markdown content with various formatting options. @@ -17,6 +17,8 @@ const result = calculateSum(5, 3); console.log(`Result: ${result}`); ``` +--- + ## Formatting Options Here's a paragraph explaining the formatting options available. @@ -33,6 +35,8 @@ You can include links such as [Element](https://element.siemens.io) for more inf Links are also automatically detected: https://angular.io +--- + ## Lists and Bullets Here are the key features: @@ -44,6 +48,9 @@ Here are the key features: - Bullet point lists - Blockquote support +* Or in the alternate format +* Another bullet point + This paragraph appears after the list to show proper spacing. ## Ordered Lists @@ -54,6 +61,8 @@ Step-by-step instructions: 2. Then, implement the solution 3. Finally, test the implementation +--- + > This is a blockquote that demonstrates how quoted text appears in the markdown content component. This paragraph follows the blockquotes to demonstrate proper paragraph separation. @@ -70,6 +79,16 @@ Another paragraph between the lists. Final paragraph to show proper spacing. +--- + +## Images + +Images can be included as follows: + +![Building Image](./assets/images/building-1.webp) + +--- + ## Tables Tables are also supported: From 5853e943d5c8f3daa3ce8d672e896160cd464cdb Mon Sep 17 00:00:00 2001 From: Linus Schlumberger Date: Tue, 9 Dec 2025 14:18:16 +0100 Subject: [PATCH 08/30] fix(chat-messages): keep width of si-chat-message constrained --- .../element-ng/chat-messages/si-chat-message.component.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/projects/element-ng/chat-messages/si-chat-message.component.scss b/projects/element-ng/chat-messages/si-chat-message.component.scss index b2484bba0..4f34bb870 100644 --- a/projects/element-ng/chat-messages/si-chat-message.component.scss +++ b/projects/element-ng/chat-messages/si-chat-message.component.scss @@ -35,6 +35,7 @@ margin-block-end: auto; background-color: var(--chat-message-bubble-bg); min-inline-size: 0; + max-inline-size: 100%; overflow-wrap: break-word; word-break: break-word; @@ -50,6 +51,7 @@ .message-wrapper { min-inline-size: 0; + max-inline-size: 100%; } .attachment-slot { From d2bc17bf994faa44d5941febc08a1e4e42902f31 Mon Sep 17 00:00:00 2001 From: Linus Schlumberger Date: Tue, 9 Dec 2025 15:05:08 +0100 Subject: [PATCH 09/30] fix(chat-messages): auto scroll si-chat-container correctly with fast streaming --- .../si-chat-container.component.ts | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/projects/element-ng/chat-messages/si-chat-container.component.ts b/projects/element-ng/chat-messages/si-chat-container.component.ts index a4f6cab3a..081077209 100644 --- a/projects/element-ng/chat-messages/si-chat-container.component.ts +++ b/projects/element-ng/chat-messages/si-chat-container.component.ts @@ -50,6 +50,9 @@ export class SiChatContainerComponent implements AfterContentInit, OnDestroy { private isUserAtBottom = true; private scrollTimeout: ReturnType | undefined; + private lastScrollTime = 0; + private pendingScroll = false; + private scrollDebounceMs = 7; // ~144fps private resizeObserver: ResizeObserver | undefined; private contentObserver: MutationObserver | undefined; @@ -107,13 +110,28 @@ export class SiChatContainerComponent implements AfterContentInit, OnDestroy { } private debouncedScrollToBottom(): void { + const now = Date.now(); + const timeSinceLastScroll = now - this.lastScrollTime; + + if (timeSinceLastScroll >= this.scrollDebounceMs) { + this.lastScrollTime = now; + this.scrollToBottom(); + this.pendingScroll = false; + } else { + this.pendingScroll = true; + } + if (this.scrollTimeout) { clearTimeout(this.scrollTimeout); } this.scrollTimeout = setTimeout(() => { - this.scrollToBottom(); - }, 100); + if (this.pendingScroll) { + this.lastScrollTime = Date.now(); + this.scrollToBottom(); + this.pendingScroll = false; + } + }, this.scrollDebounceMs); } private setupResizeObserver(): void { From 4324397d5ba7654b88e9b03f769ac908e5688334 Mon Sep 17 00:00:00 2001 From: Linus Schlumberger Date: Tue, 16 Dec 2025 11:20:39 +0100 Subject: [PATCH 10/30] fix(chat-messages): expose method to scroll to bottom --- .../element-ng/chat-messages/index.api.md | 3 +++ .../si-chat-container.component.ts | 17 +++++++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/api-goldens/element-ng/chat-messages/index.api.md b/api-goldens/element-ng/chat-messages/index.api.md index c6876cdf9..1b4115ee1 100644 --- a/api-goldens/element-ng/chat-messages/index.api.md +++ b/api-goldens/element-ng/chat-messages/index.api.md @@ -67,6 +67,9 @@ export class SiChatContainerComponent implements AfterContentInit, OnDestroy { readonly colorVariant: _angular_core.InputSignal; focus(): void; readonly noAutoScroll: _angular_core.InputSignalWithTransform; + // (undocumented) + protected onScroll(): void; + scrollToBottom(): void; } // @public diff --git a/projects/element-ng/chat-messages/si-chat-container.component.ts b/projects/element-ng/chat-messages/si-chat-container.component.ts index 081077209..ebbc13e1d 100644 --- a/projects/element-ng/chat-messages/si-chat-container.component.ts +++ b/projects/element-ng/chat-messages/si-chat-container.component.ts @@ -80,7 +80,7 @@ export class SiChatContainerComponent implements AfterContentInit, OnDestroy { } ngAfterContentInit(): void { - this.scrollToBottom(); + this.scrollToBottomDuringStreaming(); } ngOnDestroy(): void { @@ -95,7 +95,7 @@ export class SiChatContainerComponent implements AfterContentInit, OnDestroy { } } - private scrollToBottom(): void { + private scrollToBottomDuringStreaming(): void { if (this.noAutoScroll() || !this.isUserAtBottom) { return; } @@ -115,7 +115,7 @@ export class SiChatContainerComponent implements AfterContentInit, OnDestroy { if (timeSinceLastScroll >= this.scrollDebounceMs) { this.lastScrollTime = now; - this.scrollToBottom(); + this.scrollToBottomDuringStreaming(); this.pendingScroll = false; } else { this.pendingScroll = true; @@ -128,7 +128,7 @@ export class SiChatContainerComponent implements AfterContentInit, OnDestroy { this.scrollTimeout = setTimeout(() => { if (this.pendingScroll) { this.lastScrollTime = Date.now(); - this.scrollToBottom(); + this.scrollToBottomDuringStreaming(); this.pendingScroll = false; } }, this.scrollDebounceMs); @@ -188,6 +188,15 @@ export class SiChatContainerComponent implements AfterContentInit, OnDestroy { this.checkIfUserAtBottom(); } + /** + * Scrolls to the bottom of the messages container immediately. + * This method forces a scroll even if the user has scrolled up. + */ + public scrollToBottom(): void { + this.isUserAtBottom = true; + this.scrollToBottomDuringStreaming(); + } + /** * Focuses the messages container element. */ From 9623ac003055a93c4552e76d91c97833cc607432 Mon Sep 17 00:00:00 2001 From: Linus Schlumberger Date: Tue, 16 Dec 2025 11:21:48 +0100 Subject: [PATCH 11/30] docs(chat-messages): scroll to bottom when user sends message in chat-container --- src/app/examples/si-chat-messages/si-chat-container.html | 4 ++-- src/app/examples/si-chat-messages/si-chat-container.ts | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/app/examples/si-chat-messages/si-chat-container.html b/src/app/examples/si-chat-messages/si-chat-container.html index e5e5d1255..f9beb0363 100644 --- a/src/app/examples/si-chat-messages/si-chat-container.html +++ b/src/app/examples/si-chat-messages/si-chat-container.html @@ -1,6 +1,6 @@
    - - @for (message of messages(); track message) { + + @for (message of messages(); track $index) { @if (message.type === 'user') { >('modalTemplate'); + private readonly chatContainer = viewChild('chatContainer'); private sanitizer = inject(DomSanitizer); private readonly toastService = inject(SiToastNotificationService); private translate = injectSiTranslateService(); @@ -245,6 +246,10 @@ export class SampleComponent { } ]); this.simulateAiResponse(event.content); + + setTimeout(() => { + this.chatContainer()?.scrollToBottom(); + }, 0); } onInterrupt(): void { From ba73669a17595c174b98f1ba14aea439189588f7 Mon Sep 17 00:00:00 2001 From: Linus Schlumberger Date: Mon, 22 Dec 2025 16:30:47 +0100 Subject: [PATCH 12/30] fix(chat-messages): cache rendering in markdown renderer to allow for scrolling --- .../markdown-renderer/markdown-renderer.ts | 259 +++++++++++++++--- 1 file changed, 215 insertions(+), 44 deletions(-) diff --git a/projects/element-ng/markdown-renderer/markdown-renderer.ts b/projects/element-ng/markdown-renderer/markdown-renderer.ts index 368f73f63..40336c382 100644 --- a/projects/element-ng/markdown-renderer/markdown-renderer.ts +++ b/projects/element-ng/markdown-renderer/markdown-renderer.ts @@ -9,6 +9,8 @@ import type { TranslatableString } from '@siemens/element-translate-ng/translate'; +const CACHE_SIZE = 50; + export interface MarkdownRendererOptions { /** * Provide this to enable the copy code button functionality. @@ -54,6 +56,11 @@ export const getMarkdownRenderer = ( sanitizer: DomSanitizer, options?: MarkdownRendererOptions ): ((text: string) => Node) => { + const codeBlockCache = new Map(); + const tableCache = new Map(); + const codeBlockCacheOrder: string[] = []; + const tableCacheOrder: string[] = []; + return (text: string): Node => { const div = document.createElement('div'); div.className = 'markdown-content text-break'; @@ -61,6 +68,12 @@ export const getMarkdownRenderer = ( // Map to store original table cell content: tableIndex -> rowIndex -> columnIndex -> original text const tableCellData = new Map>>(); + // Maps to track placeholder IDs to cache keys + const tablePlaceholderMap = new Map(); + const codeBlockCachePlaceholderMap = new Map(); + let tablePlaceholderCounter = 0; + const codeBlockPlaceholderState = { counter: 0 }; + if (!text) { return div; } @@ -157,7 +170,17 @@ export const getMarkdownRenderer = ( // Recursively process cell content for markdown formatting and store original data const processedCells = cellsWithNewlines.map((cell: string, index: number) => { - const formattedContent = transformMarkdownText(cell, false, sanitizer, options); + const formattedContent = transformMarkdownText( + cell, + false, + sanitizer, + options, + codeBlockCache, + codeBlockCacheOrder, + CACHE_SIZE, + codeBlockCachePlaceholderMap, + codeBlockPlaceholderState + ); // Store original cell text in the map rowData.set(index, cells[index]); return formattedContent; @@ -170,7 +193,7 @@ export const getMarkdownRenderer = ( }) // Wrap table rows in table elements .replace( - /(
    .*?<\/tr>)/gs, + /([\s\S]*?<\/tr>(?:\s*[\s\S]*?<\/tr>)*)/g, (() => { let currentTableWrapIndex = -1; @@ -188,13 +211,20 @@ export const getMarkdownRenderer = ( return (match: string) => { currentTableWrapIndex++; - const tableId = `table-${Math.random().toString(36).substring(2, 15)}`; + const cacheKey = `${match}|||${options?.downloadTableButton ?? ''}`; + const placeholderId = `table-${tablePlaceholderCounter++}`; + tablePlaceholderMap.set(placeholderId, cacheKey); + + getCachedOrCreateElement(tableCache, tableCacheOrder, CACHE_SIZE, cacheKey, () => { + const tableId = `table-${Math.random().toString(36).substring(2, 15)}`; - const downloadTableCsvButton = buttonLabel - ? `` - : ''; + const downloadTableCsvButton = buttonLabel + ? `` + : ''; - return `
    ${downloadTableCsvButton}
    ${match}
    `; + return `
    ${downloadTableCsvButton}${match}
    `; + }); + return ``; }; })() ) @@ -204,10 +234,59 @@ export const getMarkdownRenderer = ( '' ); - html = transformMarkdownText(html, true, sanitizer, options); + html = transformMarkdownText( + html, + true, + sanitizer, + options, + codeBlockCache, + codeBlockCacheOrder, + CACHE_SIZE, + codeBlockCachePlaceholderMap, + codeBlockPlaceholderState + ); div.innerHTML = html; + // Replace placeholders with cached elements + const walker = document.createTreeWalker(div, NodeFilter.SHOW_COMMENT); + const commentsToReplace: { comment: Comment; element: HTMLElement }[] = []; + + let currentNode = walker.nextNode(); + while (currentNode) { + const comment = currentNode as Comment; + const tableMatch = comment.textContent?.match(/TABLE-PLACEHOLDER-(.*)/); + if (tableMatch) { + const placeholderId = tableMatch[1]; + const cacheKey = tablePlaceholderMap.get(placeholderId); + if (cacheKey) { + const cachedElement = tableCache.get(cacheKey); + if (cachedElement) { + commentsToReplace.push({ comment, element: cachedElement }); + } + } + } + + const codeMatch = comment.textContent?.match(/CODE-BLOCK-PLACEHOLDER-(.*)/); + if (codeMatch) { + const placeholderId = codeMatch[1]; + const cacheKey = codeBlockCachePlaceholderMap.get(placeholderId); + if (cacheKey) { + const cachedElement = codeBlockCache.get(cacheKey); + if (cachedElement) { + commentsToReplace.push({ comment, element: cachedElement }); + } + } + } + + currentNode = walker.nextNode(); + } + + // Replace all comments with their cached elements + commentsToReplace.forEach(({ comment, element }) => { + comment.parentNode?.replaceChild(element, comment); + }); + // Add copy functionality to code blocks div.querySelectorAll('.copy-code-btn').forEach(btn => { btn.addEventListener('click', e => { @@ -286,11 +365,50 @@ export const getMarkdownRenderer = ( }; }; +const getCachedOrCreateElement = ( + cache: Map, + cacheOrder: string[], + cacheSize: number, + key: string, + createHtml: () => string +): HTMLElement => { + const cached = cache.get(key); + if (cached) { + const orderIndex = cacheOrder.indexOf(key); + if (orderIndex > -1) { + cacheOrder.splice(orderIndex, 1); + } + cacheOrder.push(key); + return cached; + } + + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = createHtml(); + const element = tempDiv.firstElementChild as HTMLElement; + + cache.set(key, element); + cacheOrder.push(key); + + if (cacheOrder.length > cacheSize) { + const oldestKey = cacheOrder.shift(); + if (oldestKey) { + cache.delete(oldestKey); + } + } + + return element; +}; + const transformMarkdownText = ( html: string, keepAdditionalNewlines = true, sanitizer: DomSanitizer, - options?: MarkdownRendererOptions + options?: MarkdownRendererOptions, + codeBlockCache?: Map, + codeBlockCacheOrder?: string[], + cacheSize?: number, + codeBlockCachePlaceholderMap?: Map, + codeBlockPlaceholderState?: { counter: number } ): 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)}--`; @@ -308,45 +426,98 @@ const transformMarkdownText = ( html = html // Multiline code blocks ```code``` with placeholder .replace(/```([^\n]*)\n?([\s\S]*?)\n?```/g, (match, language, content) => { - // Escape HTML special characters in code blocks (not for security, but for correct display) and preserve inner backticks - const escapedCode = content - .replace(//g, '>') - .replace(/`/g, innerCodeQuotePlaceholder); + const cacheKey = `${language}|||${content}|||${options?.copyCodeButton ?? ''}`; + + if ( + codeBlockCache && + codeBlockCacheOrder && + cacheSize && + codeBlockCachePlaceholderMap && + codeBlockPlaceholderState + ) { + const placeholderId = `code-${codeBlockPlaceholderState.counter++}`; + codeBlockCachePlaceholderMap.set(placeholderId, cacheKey); + + getCachedOrCreateElement(codeBlockCache, codeBlockCacheOrder, cacheSize, cacheKey, () => { + // Escape HTML special characters in code blocks (not for security, but for correct display) and preserve inner backticks + const escapedCode = content + .replace(//g, '>') + .replace(/`/g, innerCodeQuotePlaceholder); - const codeId = `code-${Math.random().toString(36).substring(2, 15)}`; + const codeId = `code-${Math.random().toString(36).substring(2, 15)}`; - // Apply syntax highlighting if highlighter is provided - const highlightedCode = options?.syntaxHighlighter - ? // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - options.syntaxHighlighter(content, language?.trim() || undefined) - : undefined; + // Apply syntax highlighting if highlighter is provided + const highlightedCode = options?.syntaxHighlighter + ? // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + options.syntaxHighlighter(content, language?.trim() || undefined) + : undefined; - // Apply sanitization to the final code content - const codeBlockContent = sanitizer.sanitize( - SecurityContext.HTML, - highlightedCode ?? escapedCode - ); + // Apply sanitization to the final code content + const codeBlockContent = sanitizer.sanitize( + SecurityContext.HTML, + highlightedCode ?? escapedCode + ); - const translatedLabel = - options?.copyCodeButton && options?.translateSync - ? options.translateSync(options.copyCodeButton) - : options?.copyCodeButton - ? String(options.copyCodeButton) - : undefined; - const buttonLabel = translatedLabel - ?.replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); - const codeCopyButton = buttonLabel - ? `` - : ''; - - const code = `
    ${codeCopyButton}
    ${codeBlockContent}
    `; - const codePlaceholder = `--CODE-BLOCK-${Math.random().toString(36).substring(2, 15)}--`; - codeSectionPlaceholderMap.set(codePlaceholder, code); - return codePlaceholder; + const translatedLabel = + options?.copyCodeButton && options?.translateSync + ? options.translateSync(options.copyCodeButton) + : options?.copyCodeButton + ? String(options.copyCodeButton) + : undefined; + const buttonLabel = translatedLabel + ?.replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + const codeCopyButton = buttonLabel + ? `` + : ''; + + return `
    ${codeCopyButton}
    ${codeBlockContent}
    `; + }); + return ``; + } else { + // Escape HTML special characters in code blocks (not for security, but for correct display) and preserve inner backticks + const escapedCode = content + .replace(//g, '>') + .replace(/`/g, innerCodeQuotePlaceholder); + + const codeId = `code-${Math.random().toString(36).substring(2, 15)}`; + + // Apply syntax highlighting if highlighter is provided + const highlightedCode = options?.syntaxHighlighter + ? // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + options.syntaxHighlighter(content, language?.trim() || undefined) + : undefined; + + // Apply sanitization to the final code content + const codeBlockContent = sanitizer.sanitize( + SecurityContext.HTML, + highlightedCode ?? escapedCode + ); + + const translatedLabel = + options?.copyCodeButton && options?.translateSync + ? options.translateSync(options.copyCodeButton) + : options?.copyCodeButton + ? String(options.copyCodeButton) + : undefined; + const buttonLabel = translatedLabel + ?.replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + const codeCopyButton = buttonLabel + ? `` + : ''; + + const code = `
    ${codeCopyButton}
    ${codeBlockContent}
    `; + const codePlaceholder = `--CODE-BLOCK-${Math.random().toString(36).substring(2, 15)}--`; + codeSectionPlaceholderMap.set(codePlaceholder, code); + return codePlaceholder; + } }); // Remove temporary closing marker if it's still there (wasn't part of a code block) From 3e6215324241c98dd8847aef4fd5aa35fc0394f4 Mon Sep 17 00:00:00 2001 From: Linus Schlumberger Date: Mon, 22 Dec 2025 16:45:08 +0100 Subject: [PATCH 13/30] fix(chat-messages): support table headers in markdown renderer --- .../markdown-renderer/markdown-renderer.ts | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/projects/element-ng/markdown-renderer/markdown-renderer.ts b/projects/element-ng/markdown-renderer/markdown-renderer.ts index 40336c382..79b8f7345 100644 --- a/projects/element-ng/markdown-renderer/markdown-renderer.ts +++ b/projects/element-ng/markdown-renderer/markdown-renderer.ts @@ -112,9 +112,16 @@ export const getMarkdownRenderer = ( // Process tables first let tableIndex = -1; const rowIndexMap = new Map(); + const tableHasSeparator = new Map(); html = html - // Remove table separator lines first + // Mark tables that have separator lines (these have headers) + .replace(/^\|(.+)\|\s*\n\|\s*[-:]+.*\|\s*$/gm, match => { + const nextTableIndex = tableIndex + 1; + tableHasSeparator.set(nextTableIndex, true); + return match; + }) + // Remove table separator lines .replace(/^\|\s*[-:]+.*\|\s*$/gm, '') // Process table rows .replace(/^\|(.+)\|\s*$/gm, (_match, htmlContent) => { @@ -130,6 +137,7 @@ export const getMarkdownRenderer = ( const tableData = tableCellData.get(tableIndex)!; tableData.set(currentRowIndex, new Map()); const rowData = tableData.get(currentRowIndex)!; + const isHeaderRow = currentRowIndex === 0 && tableHasSeparator.get(tableIndex); // Handle escaped pipes by temporarily replacing them const escapedPipePlaceholder = `--ESCAPED-PIPE-${Math.random().toString(36).substring(2, 15)}--`; @@ -189,11 +197,19 @@ export const getMarkdownRenderer = ( // Increment row index for next row rowIndexMap.set(tableIndex, currentRowIndex + 1); - return `${processedCells.map((cell: string) => `${cell}`).join('')}`; + const cellTag = isHeaderRow ? 'th' : 'td'; + return `${processedCells.map((cell: string) => `<${cellTag}>${cell}`).join('')}`; }) - // Wrap table rows in table elements + // Wrap header rows in thead + .replace(/([\s\S]*?<\/th><\/tr>)/g, '$1') + // Wrap body rows in tbody (rows that are not already in thead) + .replace( + /([\s\S]*?<\/td><\/tr>(?:\s*[\s\S]*?<\/td><\/tr>)*)/g, + '$1' + ) + // Wrap table rows in table elements (match thead+tbody together or just tbody) .replace( - /([\s\S]*?<\/tr>(?:\s*[\s\S]*?<\/tr>)*)/g, + /(?:[\s\S]*?<\/th><\/tr><\/thead>\s*)?[\s\S]*?<\/td><\/tr>(?:\s*[\s\S]*?<\/td><\/tr>)*<\/tbody>/g, (() => { let currentTableWrapIndex = -1; From 3df524121ce97fcb32b9b1ef74213d0413f42c5b Mon Sep 17 00:00:00 2001 From: Linus Schlumberger Date: Mon, 22 Dec 2025 18:29:30 +0100 Subject: [PATCH 14/30] feat(chat-messages): add support for latex formula rendering in markdown renderer --- angular.json | 2 +- .../element-ng/chat-messages/index.api.md | 2 - .../element-ng/markdown-renderer/index.api.md | 2 + package-lock.json | 340 ++++++++++++------ package.json | 2 + ...r-element-examples-chromium-dark-linux.png | 4 +- ...-element-examples-chromium-light-linux.png | 4 +- ...rkdown-renderer--si-markdown-renderer.yaml | 174 +++++---- .../markdown-renderer-helpers.ts | 159 ++++++++ .../markdown-renderer/markdown-renderer.ts | 157 ++++++-- .../si-markdown-renderer.component.spec.ts | 251 ++++++++++++- .../si-markdown-renderer.component.ts | 13 + .../src/styles/components/_markdown.scss | 14 + .../si-chat-messages/si-ai-message.ts | 17 + .../si-chat-messages/si-chat-container.ts | 17 + .../si-chat-messages/si-user-message.ts | 17 + .../si-markdown-renderer.html | 6 +- .../si-markdown-renderer.ts | 19 + src/assets/sample-markdown.md | 16 + 19 files changed, 959 insertions(+), 257 deletions(-) create mode 100644 projects/element-ng/markdown-renderer/markdown-renderer-helpers.ts diff --git a/angular.json b/angular.json index bff0c15fc..a2495a798 100644 --- a/angular.json +++ b/angular.json @@ -42,7 +42,7 @@ "stylePreprocessorOptions": { "includePaths": ["node_modules/"] }, - "styles": ["src/styles.scss"], + "styles": ["src/styles.scss", "node_modules/katex/dist/katex.min.css"], "scripts": [], "allowedCommonJsDependencies": [ "@babel/standalone", diff --git a/api-goldens/element-ng/chat-messages/index.api.md b/api-goldens/element-ng/chat-messages/index.api.md index 1b4115ee1..df202efca 100644 --- a/api-goldens/element-ng/chat-messages/index.api.md +++ b/api-goldens/element-ng/chat-messages/index.api.md @@ -67,8 +67,6 @@ export class SiChatContainerComponent implements AfterContentInit, OnDestroy { readonly colorVariant: _angular_core.InputSignal; focus(): void; readonly noAutoScroll: _angular_core.InputSignalWithTransform; - // (undocumented) - protected onScroll(): void; scrollToBottom(): void; } diff --git a/api-goldens/element-ng/markdown-renderer/index.api.md b/api-goldens/element-ng/markdown-renderer/index.api.md index 0b6dfd733..c1634e395 100644 --- a/api-goldens/element-ng/markdown-renderer/index.api.md +++ b/api-goldens/element-ng/markdown-renderer/index.api.md @@ -17,6 +17,7 @@ export const getMarkdownRenderer: (sanitizer: DomSanitizer, options?: MarkdownRe export interface MarkdownRendererOptions { copyCodeButton?: TranslatableString; downloadTableButton?: TranslatableString; + latexRenderer?: (latex: string, displayMode: boolean) => string | undefined; syntaxHighlighter?: (code: string, language?: string) => string | undefined; translateSync?: SiTranslateService['translateSync']; } @@ -28,6 +29,7 @@ export class SiMarkdownRendererComponent { readonly disableCopyButton: _angular_core.InputSignal; readonly disableDownloadButton: _angular_core.InputSignal; readonly downloadButtonLabel: _angular_core.InputSignal<_siemens_element_translate_ng_translate_types.TranslatableString>; + readonly latexRenderer: _angular_core.InputSignal<((latex: string, displayMode: boolean) => string | undefined) | undefined>; readonly syntaxHighlighter: _angular_core.InputSignal<((code: string, language?: string) => string | undefined) | undefined>; readonly text: _angular_core.InputSignal; } diff --git a/package-lock.json b/package-lock.json index 26061b895..9b8168aca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,6 +72,7 @@ "@types/geojson": "7946.0.16", "@types/google-libphonenumber": "7.4.30", "@types/jasmine": "5.1.15", + "@types/katex": "0.16.7", "@types/node": "24.10.9", "angular-eslint": "21.1.0", "axe-html-reporter": "2.2.11", @@ -97,6 +98,7 @@ "karma-jasmine-seed-reporter": "0.2.0", "karma-junit-reporter": "2.0.1", "karma-spec-reporter": "0.0.36", + "katex": "0.16.27", "ng-packagr": "21.0.1", "piscina": "5.1.4", "postcss": "8.5.6", @@ -469,7 +471,6 @@ "integrity": "sha512-My42P8i/FrZgEsTnsCS9IXKMk7ikJwa14i0aBcHg3lMBAPrdpHVzgDS6/1SOO1HsoVYF/SiPjwnlL152xlm8/Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "0.2100.5", @@ -772,7 +773,6 @@ "integrity": "sha512-PYVgNbjNtuD5/QOuS6cHR8A7bRqsVqxtUUXGqdv76FYMAajQcAvyfR0QxOkqf3NmYxgNgO3hlUHWq0ILjVbcow==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@angular-eslint/bundled-angular-compiler": "21.1.0", "eslint-scope": "^9.0.0" @@ -819,7 +819,6 @@ "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.0.8.tgz", "integrity": "sha512-1YXHZQO/LYiExbg7sZhiqqF5fMcH17iVgK1tI2Gk90Yy0HQAuqnteOv3pPGgUfLowNOWK0sGhCYbB2Lq21LA3w==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -968,7 +967,6 @@ "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.0.6.tgz", "integrity": "sha512-5Gw8mXtKXvcvDMWEciPLRYB6Ja5vsikLAidZsdCEIF6Bc51GmoqT5Tk/Ke+ciCd5Hq9Aco/IcHxT1RC3470lZg==", "license": "MIT", - "peer": true, "dependencies": { "parse5": "^8.0.0", "tslib": "^2.3.0" @@ -985,7 +983,6 @@ "integrity": "sha512-UYFQqn9Ow1wFVSwdB/xfjmZo4Yb7CUNxilbeYDFIybesfxXSdjMJBbXLtV0+icIhjmqfSUm2gTls6WIrG8qv9A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@angular-devkit/architect": "0.2100.5", "@angular-devkit/core": "21.0.5", @@ -1082,7 +1079,6 @@ "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.0.8.tgz", "integrity": "sha512-on1B4oc/pf7IlkbG08Et/cCDSX8dpZz9iwp3zMFN/0JvorspyL5YOovFJfjdpmjdlrIi+ToGImwyIkY9P8Mblw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1113,7 +1109,6 @@ "integrity": "sha512-+i/wFvi5FTg47Ei+aiFf8j3iYfjQ79ieg8oJM86+Mw4bNwEKQqvWcpmKjoqcfmCescuw0sr2DXU6OEeX+yWeVg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "7.28.4", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -1207,7 +1202,6 @@ "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.0.8.tgz", "integrity": "sha512-8dNolIQn8WHrD3PsqGuPrujxDX5hjpMbioifIByjjX9yaJy9on7AewVGb8m/DHVwWQ1eGVAGmvW9wt+h+nlzLg==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1249,7 +1243,6 @@ "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.0.8.tgz", "integrity": "sha512-H03A50elawXO53xkz0Aytar5kYT14GLeaj6dLKc1kcR5NqvX9Y/R7z3bY52tvypAdIR8CmPT7ad07TlT4O9lkg==", "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "tslib": "^2.3.0" @@ -1280,7 +1273,6 @@ "integrity": "sha512-b4R3lLq32CbRXZrwMct4K7rQ5yzL7EXihg1IfyHNSEcxuuzdtXw/M1xexIkEVtLIfA+SROAThISbYgSgWq6rwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "7.28.4", "@types/babel__core": "7.20.5", @@ -1366,7 +1358,6 @@ "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.0.8.tgz", "integrity": "sha512-5rPyrP6n6ClO0ZEUXndS2/Xb7nZrbjjYWOxgfCb+ZTCiU7eyN6zhSmicKk2dLQxE1M15wbTa87dN6/Ytuq2uvg==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1389,7 +1380,6 @@ "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.0.8.tgz", "integrity": "sha512-LPR65wyWBSyR46fGeQtD92+TM635o0lh+N5k9qPZdMacogwViTrtBHWPfKYBtBUXLWEWXXKJfSbXvhh3w3uLxw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1447,7 +1437,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -3095,7 +3084,6 @@ "integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -3137,7 +3125,6 @@ "integrity": "sha512-NtInjSlyev/+SLPvx/ulz8hRE25Wf5S9dLNDcIwazq0JyB4/w1ROF/5nV0ObPTX8YpRaKYeKtXDYWqumBNHWsw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@commitlint/format": "^20.3.1", "@commitlint/lint": "^20.3.1", @@ -3160,7 +3147,6 @@ "integrity": "sha512-NCzwvxepstBZbmVXsvg49s+shCxlJDJPWxXqONVcAtJH9wWrOlkMQw/zyl+dJmt8lyVopt5mwQ3mR5M2N2rUWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@commitlint/types": "^20.3.1", "conventional-changelog-conventionalcommits": "^7.0.2" @@ -3430,7 +3416,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -3474,7 +3459,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -4222,7 +4206,6 @@ "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -4610,7 +4593,6 @@ "integrity": "sha512-X7/+dG9SLpSzRkwgG5/xiIzW0oMrV3C0HOa7YHG1WnrLK+vCQHfte4k/T80059YBdei29RBC3s+pSMvPJDU9/A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/checkbox": "^4.3.0", "@inquirer/confirm": "^5.1.19", @@ -5436,6 +5418,7 @@ "resolved": "https://registry.npmjs.org/@module-federation/bridge-react-webpack-plugin/-/bridge-react-webpack-plugin-0.9.1.tgz", "integrity": "sha512-znN/Qm6M0U1t3iF10gu1hSxDkk18yz78yvk+AMB34UDzpXHiC1zbpIeV2CQNV5GCeafmCICmcn9y1qh7F54KTg==", "license": "MIT", + "peer": true, "dependencies": { "@module-federation/sdk": "0.9.1", "@types/semver": "7.5.8", @@ -5447,6 +5430,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "license": "ISC", + "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -5459,6 +5443,7 @@ "resolved": "https://registry.npmjs.org/@module-federation/data-prefetch/-/data-prefetch-0.9.1.tgz", "integrity": "sha512-rS1AsgRvIMAWK8oMprEBF0YQ3WvsqnumjinvAZU1Dqut5DICmpQMTPEO1OrAKyjO+PQgEhmq13HggzN6ebGLrQ==", "license": "MIT", + "peer": true, "dependencies": { "@module-federation/runtime": "0.9.1", "@module-federation/sdk": "0.9.1", @@ -5474,6 +5459,7 @@ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "license": "MIT", + "peer": true, "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", @@ -5489,6 +5475,7 @@ "resolved": "https://registry.npmjs.org/@module-federation/dts-plugin/-/dts-plugin-0.9.1.tgz", "integrity": "sha512-DezBrFaIKfDcEY7UhqyO1WbYocERYsR/CDN8AV6OvMnRlQ8u0rgM8qBUJwx0s+K59f+CFQFKEN4C8p7naCiHrw==", "license": "MIT", + "peer": true, "dependencies": { "@module-federation/error-codes": "0.9.1", "@module-federation/managers": "0.9.1", @@ -5522,6 +5509,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", + "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -5537,6 +5525,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", "license": "MIT", + "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -5550,6 +5539,7 @@ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "license": "MIT", + "peer": true, "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", @@ -5565,6 +5555,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -5622,13 +5613,15 @@ "version": "0.9.1", "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.9.1.tgz", "integrity": "sha512-q8spCvlwUzW42iX1irnlBTcwcZftRNHyGdlaoFO1z/fW4iphnBIfijzkigWQzOMhdPgzqN/up7XN+g5hjBGBtw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@module-federation/inject-external-runtime-core-plugin": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/@module-federation/inject-external-runtime-core-plugin/-/inject-external-runtime-core-plugin-0.9.1.tgz", "integrity": "sha512-BPfzu1cqDU5BhM493enVF1VfxJWmruen0ktlHrWdJJlcddhZzyFBGaLAGoGc+83fS75aEllvJTEthw4kMViMQQ==", "license": "MIT", + "peer": true, "peerDependencies": { "@module-federation/runtime-tools": "0.9.1" } @@ -5638,6 +5631,7 @@ "resolved": "https://registry.npmjs.org/@module-federation/managers/-/managers-0.9.1.tgz", "integrity": "sha512-8hpIrvGfiODxS1qelTd7eaLRVF7jrp17RWgeH1DWoprxELANxm5IVvqUryB+7j+BhoQzamog9DL5q4MuNfGgIA==", "license": "MIT", + "peer": true, "dependencies": { "@module-federation/sdk": "0.9.1", "find-pkg": "2.0.0", @@ -5649,6 +5643,7 @@ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "license": "MIT", + "peer": true, "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", @@ -5664,6 +5659,7 @@ "resolved": "https://registry.npmjs.org/@module-federation/manifest/-/manifest-0.9.1.tgz", "integrity": "sha512-+GteKBXrAUkq49i2CSyWZXM4vYa+mEVXxR9Du71R55nXXxgbzAIoZj9gxjRunj9pcE8+YpAOyfHxLEdWngxWdg==", "license": "MIT", + "peer": true, "dependencies": { "@module-federation/dts-plugin": "0.9.1", "@module-federation/managers": "0.9.1", @@ -5677,6 +5673,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", + "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -5692,6 +5689,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", "license": "MIT", + "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -5705,6 +5703,7 @@ "resolved": "https://registry.npmjs.org/@module-federation/rspack/-/rspack-0.9.1.tgz", "integrity": "sha512-ZJqG75dWHhyTMa9I0YPJEV2XRt0MFxnDiuMOpI92esdmwWY633CBKyNh1XxcLd629YVeTv03+whr+Fz/f91JEw==", "license": "MIT", + "peer": true, "dependencies": { "@module-federation/bridge-react-webpack-plugin": "0.9.1", "@module-federation/dts-plugin": "0.9.1", @@ -5733,6 +5732,7 @@ "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-0.9.1.tgz", "integrity": "sha512-jp7K06weabM5BF5sruHr/VLyalO+cilvRDy7vdEBqq88O9mjc0RserD8J+AP4WTl3ZzU7/GRqwRsiwjjN913dA==", "license": "MIT", + "peer": true, "dependencies": { "@module-federation/error-codes": "0.9.1", "@module-federation/runtime-core": "0.9.1", @@ -5744,6 +5744,7 @@ "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-0.6.21.tgz", "integrity": "sha512-CLQiPP3kpcPbgPkiu/A1VURI2v4geFnEdizlB1tq0c6eDZqb5aLzvp87ZCGDVSuwY7DCq6jh1k+CM2WGge/2xA==", "license": "MIT", + "peer": true, "dependencies": { "@module-federation/error-codes": "0.9.0", "@module-federation/sdk": "0.9.0" @@ -5753,13 +5754,15 @@ "version": "0.9.0", "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.9.0.tgz", "integrity": "sha512-dNqIs5cQfE4p+WIdiZ64cTSRJ5KjGaV+epvZkGttrNjXW9XAAtE7zgpo7cMQ8GWA3wCGaKnFw7Dn48XcU5ZMNw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@module-federation/runtime-core/node_modules/@module-federation/sdk": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-0.9.0.tgz", "integrity": "sha512-84MklxE6Z79gCAr+6HCyqOpF95pqSah+fGnhLz+g4ePcWf98J73bWfrdOWFO/UfxMRneXKBZBNbpDVvPLgaFeQ==", "license": "MIT", + "peer": true, "dependencies": { "isomorphic-rslog": "0.0.7" } @@ -5780,6 +5783,7 @@ "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-0.9.1.tgz", "integrity": "sha512-r61ufhKt5pjl81v7TkmhzeIoSPOaNtLynW6+aCy3KZMa3RfRevFxmygJqv4Nug1L0NhqUeWtdLejh4VIglNy5Q==", "license": "MIT", + "peer": true, "dependencies": { "@module-federation/error-codes": "0.9.1", "@module-federation/sdk": "0.9.1" @@ -5789,13 +5793,15 @@ "version": "0.9.1", "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-0.9.1.tgz", "integrity": "sha512-YQonPTImgnCqZjE/A+3N2g3J5ypR6kx1tbBzc9toUANKr/dw/S63qlh/zHKzWQzxjjNNVMdXRtTMp07g3kgEWg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@module-federation/third-party-dts-extractor": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/@module-federation/third-party-dts-extractor/-/third-party-dts-extractor-0.9.1.tgz", "integrity": "sha512-KeIByP718hHyq+Mc53enZ419pZZ1fh9Ns6+/bYLkc3iCoJr/EDBeiLzkbMwh2AS4Qk57WW0yNC82xzf7r0Zrrw==", "license": "MIT", + "peer": true, "dependencies": { "find-pkg": "2.0.0", "fs-extra": "9.1.0", @@ -5807,6 +5813,7 @@ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "license": "MIT", + "peer": true, "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", @@ -5822,6 +5829,7 @@ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", "license": "MIT", + "peer": true, "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", @@ -5839,6 +5847,7 @@ "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.9.1.tgz", "integrity": "sha512-CxySX01gT8cBowKl9xZh+voiHvThMZ471icasWnlDIZb14KasZoX1eCh9wpGvwoOdIk9rIRT7h70UvW9nmop6w==", "license": "MIT", + "peer": true, "dependencies": { "@module-federation/runtime": "0.9.1", "@module-federation/sdk": "0.9.1" @@ -6239,6 +6248,7 @@ "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", @@ -6267,7 +6277,6 @@ "resolved": "https://registry.npmjs.org/@ngx-formly/bootstrap/-/bootstrap-6.3.12.tgz", "integrity": "sha512-2HPqyC7DJjz5mwgNw+hkzXVmyaD4BfykWgUiF9peeNmhmYqF0z1JoGRotbdtzuwGeaGVkX86dPEt2pHJDeS3Pw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.0.0" }, @@ -6281,7 +6290,6 @@ "resolved": "https://registry.npmjs.org/@ngx-formly/core/-/core-6.3.12.tgz", "integrity": "sha512-88MOfn9dM1B33t04jl8x0Glh0Ed0lUKMkhYajicRH7ZHTmwIdla1SQjiblp2C+EcCFvsY7XAU2/JUZQdl56aUw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.0.0" }, @@ -6295,7 +6303,6 @@ "resolved": "https://registry.npmjs.org/@ngx-translate/core/-/core-16.0.4.tgz", "integrity": "sha512-s8llTL2SJvROhqttxvEs7Cg+6qSf4kvZPFYO+cTOY1d8DWTjlutRkWAleZcPPoeX927Dm7ALfL07G7oYDJ7z6w==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -6659,7 +6666,6 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -7866,6 +7872,7 @@ "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-1.4.6.tgz", "integrity": "sha512-rRc6sbKWxhomxxJeqi4QS3S/2T6pKf4JwC/VHXs7KXw7lHXHa3yxPynmn3xHstL0H6VLaM5xQj87Wh7lQYRAPg==", "license": "MIT", + "peer": true, "optionalDependencies": { "@rspack/binding-darwin-arm64": "1.4.6", "@rspack/binding-darwin-x64": "1.4.6", @@ -7890,7 +7897,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rspack/binding-darwin-x64": { "version": "1.4.6", @@ -7903,7 +7911,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rspack/binding-linux-arm64-gnu": { "version": "1.4.6", @@ -7916,7 +7925,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rspack/binding-linux-arm64-musl": { "version": "1.4.6", @@ -7929,7 +7939,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rspack/binding-linux-x64-gnu": { "version": "1.4.6", @@ -7942,7 +7953,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rspack/binding-linux-x64-musl": { "version": "1.4.6", @@ -7955,7 +7967,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rspack/binding-wasm32-wasi": { "version": "1.4.6", @@ -7966,6 +7979,7 @@ ], "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@napi-rs/wasm-runtime": "^0.2.11" } @@ -7981,7 +7995,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rspack/binding-win32-ia32-msvc": { "version": "1.4.6", @@ -7994,7 +8009,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rspack/binding-win32-x64-msvc": { "version": "1.4.6", @@ -8007,7 +8023,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rspack/core": { "version": "1.4.6", @@ -8036,13 +8053,15 @@ "version": "0.15.0", "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.15.0.tgz", "integrity": "sha512-CFJSF+XKwTcy0PFZ2l/fSUpR4z247+Uwzp1sXVkdIfJ/ATsnqf0Q01f51qqSEA6MYdQi6FKos9FIcu3dCpQNdg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@rspack/core/node_modules/@module-federation/runtime": { "version": "0.15.0", "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-0.15.0.tgz", "integrity": "sha512-dTPsCNum9Bhu3yPOcrPYq0YnM9eCMMMNB1wuiqf1+sFbQlNApF0vfZxooqz3ln0/MpgE0jerVvFsLVGfqvC9Ug==", "license": "MIT", + "peer": true, "dependencies": { "@module-federation/error-codes": "0.15.0", "@module-federation/runtime-core": "0.15.0", @@ -8054,6 +8073,7 @@ "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-0.15.0.tgz", "integrity": "sha512-RYzI61fRDrhyhaEOXH3AgIGlHiot0wPFXu7F43cr+ZnTi+VlSYWLdlZ4NBuT9uV6JSmH54/c+tEZm5SXgKR2sQ==", "license": "MIT", + "peer": true, "dependencies": { "@module-federation/error-codes": "0.15.0", "@module-federation/sdk": "0.15.0" @@ -8064,6 +8084,7 @@ "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-0.15.0.tgz", "integrity": "sha512-kzFn3ObUeBp5vaEtN1WMxhTYBuYEErxugu1RzFUERD21X3BZ+b4cWwdFJuBDlsmVjctIg/QSOoZoPXRKAO0foA==", "license": "MIT", + "peer": true, "dependencies": { "@module-federation/runtime": "0.15.0", "@module-federation/webpack-bundler-runtime": "0.15.0" @@ -8073,13 +8094,15 @@ "version": "0.15.0", "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-0.15.0.tgz", "integrity": "sha512-PWiYbGcJrKUD6JZiEPihrXhV3bgXdll4bV7rU+opV7tHaun+Z0CdcawjZ82Xnpb8MCPGmqHwa1MPFeUs66zksw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@rspack/core/node_modules/@module-federation/webpack-bundler-runtime": { "version": "0.15.0", "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.15.0.tgz", "integrity": "sha512-i+3wu2Ljh2TmuUpsnjwZVupOVqV50jP0ndA8PSP4gwMKlgdGeaZ4VH5KkHAXGr2eiYUxYLMrJXz1+eILJqeGDg==", "license": "MIT", + "peer": true, "dependencies": { "@module-federation/runtime": "0.15.0", "@module-federation/sdk": "0.15.0" @@ -8090,6 +8113,7 @@ "resolved": "https://registry.npmjs.org/@rspack/lite-tapable/-/lite-tapable-1.0.1.tgz", "integrity": "sha512-VynGOEsVw2s8TAlLf/uESfrgfrq2+rcXB1muPJYBWbsm1Oa6r5qVQhjA5ggM6z/coYPrsVMgovl3Ff7Q7OCp1w==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.0.0" } @@ -8270,7 +8294,6 @@ "integrity": "sha512-uNBIilq5bGnln3D7Nbm3/K+Ot++eGj4rygU0DCw//IZiTQU/iSyF3UAsN++iRetu/OMs+97T/RoGPjD22ryiZg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@angular-devkit/core": "21.0.5", "@angular-devkit/schematics": "21.0.5", @@ -8968,7 +8991,6 @@ "resolved": "https://registry.npmjs.org/@siemens/ngx-datatable/-/ngx-datatable-25.0.0.tgz", "integrity": "sha512-iK1/ESVGApP/V6WHMtwP1YkK0f+7JRbcD8hE/vL8SOrtn+blchiCOrGwPDIzOb8HFRXX5+ptrYpBL4IRnXz0QQ==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -9397,6 +9419,7 @@ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" @@ -9458,7 +9481,8 @@ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/eslint": { "version": "9.6.1", @@ -9570,6 +9594,13 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/katex": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz", + "integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -9583,7 +9614,6 @@ "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -9637,7 +9667,6 @@ "integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -9663,7 +9692,8 @@ "version": "7.5.8", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/send": { "version": "1.2.1", @@ -9924,7 +9954,6 @@ "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", @@ -10316,7 +10345,6 @@ "integrity": "sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -10360,7 +10388,6 @@ "integrity": "sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.46.4", @@ -10429,6 +10456,7 @@ "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", @@ -10447,6 +10475,7 @@ "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/spy": "4.0.16", "estree-walker": "^3.0.3", @@ -10474,6 +10503,7 @@ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.0" } @@ -10484,6 +10514,7 @@ "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tinyrainbow": "^3.0.3" }, @@ -10497,6 +10528,7 @@ "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "4.0.16", "pathe": "^2.0.3" @@ -10511,6 +10543,7 @@ "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/pretty-format": "4.0.16", "magic-string": "^0.30.21", @@ -10526,6 +10559,7 @@ "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://opencollective.com/vitest" } @@ -10536,6 +10570,7 @@ "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" @@ -10764,7 +10799,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -10842,6 +10876,7 @@ "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=12.0" } @@ -10871,7 +10906,6 @@ "resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-34.3.1.tgz", "integrity": "sha512-PwlrPudsFOzGumphi2y9ihWeaUlIwKhOra/MXu2LjeV2U8DgLLcYS8CartE5Hszhn1poJHawwI9HWrxlKliwdw==", "license": "MIT", - "peer": true, "dependencies": { "ag-charts-types": "12.3.1" } @@ -10906,7 +10940,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -10996,7 +11029,6 @@ "integrity": "sha512-qXpIEBNYpfgpBaFblnyFegVSQjWCVUdCXTHvMcvtNtmMgtPwIDKvG8wuJo5BbQ/MNt2d8npmnRUaS2ddzdCzww==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@angular-devkit/core": ">= 21.0.0 < 22.0.0", "@angular-devkit/schematics": ">= 21.0.0 < 22.0.0", @@ -11174,6 +11206,7 @@ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" } @@ -11199,13 +11232,15 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/at-least-node": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", "license": "ISC", + "peer": true, "engines": { "node": ">= 4.0.0" } @@ -11254,7 +11289,6 @@ "integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==", "dev": true, "license": "MPL-2.0", - "peer": true, "engines": { "node": ">=4" } @@ -11280,6 +11314,7 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", "license": "MIT", + "peer": true, "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", @@ -11687,7 +11722,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -11707,6 +11741,7 @@ "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", "license": "(MIT OR Apache-2.0)", + "peer": true, "bin": { "btoa": "bin/btoa.js" }, @@ -11798,6 +11833,7 @@ "resolved": "https://registry.npmjs.org/cache-content-type/-/cache-content-type-1.0.1.tgz", "integrity": "sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==", "license": "MIT", + "peer": true, "dependencies": { "mime-types": "^2.1.18", "ylru": "^1.2.0" @@ -11904,6 +11940,7 @@ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -12458,6 +12495,7 @@ "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", "license": "MIT", + "peer": true, "engines": { "iojs": ">= 1.0.0", "node": ">= 0.12.0" @@ -12527,6 +12565,7 @@ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "license": "MIT", + "peer": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -12867,6 +12906,7 @@ "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", "license": "MIT", + "peer": true, "dependencies": { "depd": "~2.0.0", "keygrip": "~1.1.0" @@ -12980,7 +13020,6 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -13086,6 +13125,7 @@ "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", "license": "MIT", + "peer": true, "dependencies": { "luxon": "^3.2.1" }, @@ -13315,7 +13355,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", "integrity": "sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/deep-extend": { "version": "0.6.0", @@ -13382,6 +13423,7 @@ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.4.0" } @@ -13390,7 +13432,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/depd": { "version": "2.0.0", @@ -13642,7 +13685,6 @@ "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz", "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "2.3.0", "zrender": "6.0.0" @@ -13700,6 +13742,29 @@ "node": ">= 0.8" } }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/engine.io": { "version": "6.6.4", "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", @@ -14033,6 +14098,7 @@ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", + "peer": true, "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", @@ -14150,7 +14216,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -14224,7 +14289,6 @@ "integrity": "sha512-v8kAP8TarQYqDC4kxr343ZNi++/oOlBnmWovsUZpbJ7A/pq1VHGlgsf/fDh4CdEvEstzkrc8NLvoVKtfpsC4oA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/utils": "^8.52.0", "natural-orderby": "^5.0.0" @@ -14396,7 +14460,6 @@ "integrity": "sha512-J9I5PKCOJretVuiZRGvPQxCbllxGAV/viI20JO3LYblAodofBxyMnZAJ+WGeClHgANnSJberTNoFWWjrWKBuXQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "eslint": ">=2.0.0" } @@ -14818,6 +14881,7 @@ "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", "integrity": "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==", "license": "MIT", + "peer": true, "dependencies": { "homedir-polyfill": "^1.0.1" }, @@ -14831,6 +14895,7 @@ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=12.0.0" } @@ -15379,6 +15444,7 @@ "resolved": "https://registry.npmjs.org/find-file-up/-/find-file-up-2.0.1.tgz", "integrity": "sha512-qVdaUhYO39zmh28/JLQM5CoYN9byEOKEH4qfa8K1eNV17W0UUMJ9WgbR/hHFH+t5rcl+6RTb5UC7ck/I+uRkpQ==", "license": "MIT", + "peer": true, "dependencies": { "resolve-dir": "^1.0.1" }, @@ -15391,6 +15457,7 @@ "resolved": "https://registry.npmjs.org/find-pkg/-/find-pkg-2.0.0.tgz", "integrity": "sha512-WgZ+nKbELDa6N3i/9nrHeNznm+lY3z4YfhDDWgW+5P0pdmMj26bxaxU11ookgY3NyP9GC7HvZ9etp0jRFqGEeQ==", "license": "MIT", + "peer": true, "dependencies": { "find-file-up": "^2.0.1" }, @@ -15450,8 +15517,7 @@ "version": "7.5.0", "resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz", "integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/flat": { "version": "5.0.2", @@ -15525,6 +15591,7 @@ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", + "peer": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -15691,6 +15758,7 @@ "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4" } @@ -16080,7 +16148,6 @@ "resolved": "https://registry.npmjs.org/google-libphonenumber/-/google-libphonenumber-3.2.43.tgz", "integrity": "sha512-TbIX/UC3BFRJwCxbBeCPwuRC4Qws9Jz/CECmfTM1t9RFoI3X6eRThurv6AYr9wSrt640IA9KFIHuAD/vlyjqRw==", "license": "(MIT AND Apache-2.0)", - "peer": true, "engines": { "node": ">=0.10" } @@ -16117,8 +16184,7 @@ "url": "https://www.venmo.com/adumesny" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/handle-thing": { "version": "2.0.1", @@ -16245,6 +16311,7 @@ "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", "integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==", "license": "MIT", + "peer": true, "dependencies": { "parse-passwd": "^1.0.0" }, @@ -16423,6 +16490,7 @@ "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", "integrity": "sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==", "license": "MIT", + "peer": true, "dependencies": { "deep-equal": "~1.0.1", "http-errors": "~1.8.0" @@ -16436,6 +16504,7 @@ "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -16445,6 +16514,7 @@ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", "license": "MIT", + "peer": true, "dependencies": { "depd": "~1.1.2", "inherits": "2.0.4", @@ -17020,6 +17090,7 @@ "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "license": "MIT", + "peer": true, "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", @@ -17227,6 +17298,7 @@ "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -17285,6 +17357,7 @@ "resolved": "https://registry.npmjs.org/isomorphic-rslog/-/isomorphic-rslog-0.0.7.tgz", "integrity": "sha512-n6/XnKnZ5eLEj6VllG4XmamXG7/F69nls8dcynHyhcTpsPUYgcgx4ifEaCo4lQJ2uzwfmIT+F0KBGwBcMKmt5g==", "license": "MIT", + "peer": true, "engines": { "node": ">=14.17.6" } @@ -17294,6 +17367,7 @@ "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", "license": "MIT", + "peer": true, "peerDependencies": { "ws": "*" } @@ -17431,8 +17505,7 @@ "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.13.0.tgz", "integrity": "sha512-vsYjfh7lyqvZX5QgqKc4YH8phs7g96Z8bsdIFNEU3VqXhlHaq+vov/Fgn/sr6MiUczdZkyXRC3TX369Ll4Nzbw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/jasmine/node_modules/glob": { "version": "10.5.0", @@ -17679,7 +17752,6 @@ "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@colors/colors": "1.5.0", "body-parser": "^1.19.0", @@ -18174,12 +18246,40 @@ "node": ">=10" } }, + "node_modules/katex": { + "version": "0.16.27", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.27.tgz", + "integrity": "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==", + "dev": true, + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/keygrip": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "license": "MIT", + "peer": true, "dependencies": { "tsscmp": "1.0.6" }, @@ -18219,6 +18319,7 @@ "resolved": "https://registry.npmjs.org/koa/-/koa-2.15.4.tgz", "integrity": "sha512-7fNBIdrU2PEgLljXoPWoyY4r1e+ToWCmzS/wwMPbUNs7X+5MMET1ObhJBlUkF5uZG9B6QhM2zS1TsH6adegkiQ==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^1.3.5", "cache-content-type": "^1.0.0", @@ -18252,13 +18353,15 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-4.1.0.tgz", "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/koa-convert": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-2.0.0.tgz", "integrity": "sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==", "license": "MIT", + "peer": true, "dependencies": { "co": "^4.6.0", "koa-compose": "^4.1.0" @@ -18272,6 +18375,7 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "5.2.1" }, @@ -18284,6 +18388,7 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -18293,6 +18398,7 @@ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", "license": "MIT", + "peer": true, "dependencies": { "depd": "~1.1.2", "inherits": "2.0.4", @@ -18309,6 +18415,7 @@ "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -18336,7 +18443,6 @@ "integrity": "sha512-j1n1IuTX1VQjIy3tT7cyGbX7nvQOsFLoIqobZv4ttI5axP923gA44zUj6miiA6R5Aoms4sEGVIIcucXUbRI14g==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "copy-anything": "^2.0.1", "parse-node-version": "^1.0.1", @@ -18502,7 +18608,6 @@ "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", @@ -18670,7 +18775,8 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.clonedeepwith/-/lodash.clonedeepwith-4.5.0.tgz", "integrity": "sha512-QRBRSxhbtsX1nc0baxSkkK5WlVTTm/s48DSukcGcWZwIyI8Zz+lB+kFiELJXtzfH4Aj6kMWQ1VWW4U5uUDgZMA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.debounce": { "version": "4.0.8", @@ -18838,7 +18944,8 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz", "integrity": "sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lru-cache": { "version": "5.1.1", @@ -18862,6 +18969,7 @@ "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" } @@ -18975,7 +19083,6 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -19616,7 +19723,6 @@ "integrity": "sha512-IZGxuF226GF0d8FOZIfPvHsyBl53PrDEg/IB2+CVamsm3r4+gUw3mBp27eygpowBpdVLG0Sm2IbUiH4aSspzyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.3.0", "@rollup/plugin-json": "^6.1.0", @@ -20165,7 +20271,6 @@ "resolved": "https://registry.npmjs.org/ngx-image-cropper/-/ngx-image-cropper-9.1.6.tgz", "integrity": "sha512-b250YJ+jZovfqIj8vdEOrpEFay34be5f1Hpvg6Db68VMlvdyyuzboJdR0gCupbXtVcG6qQ86L7YG+SYxXJwApw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -20332,6 +20437,7 @@ "resolved": "https://registry.npmjs.org/node-schedule/-/node-schedule-2.1.1.tgz", "integrity": "sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==", "license": "MIT", + "peer": true, "dependencies": { "cron-parser": "^4.2.0", "long-timeout": "0.1.1", @@ -22947,7 +23053,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -23232,14 +23337,14 @@ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" ], - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ol": { "version": "10.7.0", "resolved": "https://registry.npmjs.org/ol/-/ol-10.7.0.tgz", "integrity": "sha512-122U5gamPqNgLpLOkogFJhgpywvd/5en2kETIDW+Ubfi9lPnZ0G9HWRdG+CX0oP8od2d6u6ky3eewIYYlrVczw==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@types/rbush": "4.0.0", "earcut": "^3.0.0", @@ -23257,7 +23362,6 @@ "resolved": "https://registry.npmjs.org/ol-ext/-/ol-ext-4.0.37.tgz", "integrity": "sha512-RxzdgMWnNBDP9VZCza3oS3rl1+OCl+1SJLMjt7ATyDDLZl/zzrsQELfJ25WAL6HIWgjkQ2vYDh3nnHFupxOH4w==", "license": "BSD-3-Clause", - "peer": true, "peerDependencies": { "ol": ">= 5.3.0" } @@ -23267,7 +23371,6 @@ "resolved": "https://registry.npmjs.org/ol-mapbox-style/-/ol-mapbox-style-13.2.0.tgz", "integrity": "sha512-7jKoejdVMBxdUk97DlaHy/7ZddGslBq8obnW1yGEMD705Eo+khqZiaVbaABpszzDLAf17fKeXn+fm+WWT9OYCQ==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@maplibre/maplibre-gl-style-spec": "^23.1.0", "mapbox-to-css-font": "^3.2.0" @@ -23327,7 +23430,8 @@ "node_modules/only": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/only/-/only-0.0.2.tgz", - "integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==" + "integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==", + "peer": true }, "node_modules/open": { "version": "10.2.0", @@ -23694,6 +23798,7 @@ "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", "integrity": "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -23893,7 +23998,8 @@ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/pbf": { "version": "4.0.1", @@ -24088,7 +24194,6 @@ "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -24145,7 +24250,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -24311,7 +24415,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=12.0" }, @@ -24325,7 +24428,6 @@ "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -24357,7 +24459,6 @@ "integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -24465,7 +24566,8 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/prr": { "version": "1.0.1", @@ -24580,7 +24682,8 @@ "version": "9.4.2", "resolved": "https://registry.npmjs.org/rambda/-/rambda-9.4.2.tgz", "integrity": "sha512-++euMfxnl7OgaEKwXh9QqThOjMeta2HH001N1v4mYQzBjJBnmXBh2BCK6dZAbICFVXOFUVD3xFG0R3ZPU0mxXw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/randombytes": { "version": "2.1.0", @@ -24932,6 +25035,7 @@ "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz", "integrity": "sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==", "license": "MIT", + "peer": true, "dependencies": { "expand-tilde": "^2.0.0", "global-modules": "^1.0.0" @@ -24945,6 +25049,7 @@ "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", "integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==", "license": "MIT", + "peer": true, "dependencies": { "global-prefix": "^1.0.1", "is-windows": "^1.0.1", @@ -24959,6 +25064,7 @@ "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz", "integrity": "sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==", "license": "MIT", + "peer": true, "dependencies": { "expand-tilde": "^2.0.2", "homedir-polyfill": "^1.0.1", @@ -24974,13 +25080,15 @@ "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/resolve-dir/node_modules/which": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", "license": "ISC", + "peer": true, "dependencies": { "isexe": "^2.0.0" }, @@ -25221,7 +25329,6 @@ "integrity": "sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -25346,7 +25453,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -25401,7 +25507,6 @@ "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -25470,7 +25575,8 @@ "version": "0.26.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/schema-utils": { "version": "4.3.3", @@ -25544,7 +25650,6 @@ "integrity": "sha512-6qGjWccl5yoyugHt3jTgztJ9Y0JVzyH8/Voc/D8PlLat9pwxQYXz7W1Dpnq5h0/G5GCYGUaDSlYcyk3AMh5A6g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -26351,7 +26456,8 @@ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/signal-exit": { "version": "4.1.0", @@ -26688,7 +26794,8 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz", "integrity": "sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/source-map": { "version": "0.7.6", @@ -26868,7 +26975,8 @@ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/statuses": { "version": "1.5.0", @@ -26884,7 +26992,8 @@ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/stdin-discarder": { "version": "0.2.2", @@ -27184,7 +27293,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-syntax-patches-for-csstree": "^1.0.19", @@ -27239,7 +27347,6 @@ "integrity": "sha512-ZKmHMZolxeuYsnB+PCYrTpFce0/QWX9i9gh0hPXzp73WjuIMqUpzdQaBCrKoLWh6XtCFSaNDErkMPqdjy1/8aA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "css-tree": "^3.0.1", "is-plain-object": "^5.0.0", @@ -27887,7 +27994,8 @@ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tinyexec": { "version": "1.0.2", @@ -27928,6 +28036,7 @@ "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=14.0.0" } @@ -28087,14 +28196,14 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsscmp": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.6.x" } @@ -28105,7 +28214,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -28674,7 +28782,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -28689,7 +28796,6 @@ "integrity": "sha512-xHURCQNxZ1dsWn0sdOaOfCSQG0HKeqSj9OexIxrz6ypU6wHYOdX2I3D2b8s8wFSsSOYJb+6q283cLiLlkEsBYw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/eslint-plugin": "8.53.0", "@typescript-eslint/parser": "8.53.0", @@ -29082,6 +29188,7 @@ "resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz", "integrity": "sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==", "license": "MIT", + "peer": true, "engines": { "node": ">=4", "yarn": "*" @@ -29235,7 +29342,6 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -29873,7 +29979,8 @@ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/void-elements": { "version": "2.0.1", @@ -29935,7 +30042,6 @@ "integrity": "sha512-5DeICTX8BVgNp6afSPYXAFjskIgWGlygQH58bcozPOXgo2r/6xx39Y1+cULZ3gTxUYQP88jmwLj2anu4Xaq84g==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -30042,7 +30148,6 @@ "integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/bonjour": "^3.5.13", "@types/connect-history-api-fallback": "^1.5.4", @@ -30631,6 +30736,7 @@ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" @@ -30867,7 +30973,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -31061,6 +31166,7 @@ "resolved": "https://registry.npmjs.org/ylru/-/ylru-1.4.0.tgz", "integrity": "sha512-2OQsPNEmBCvXuFlIni/a+Rn+R2pHW9INm0BxXJ4hVDA8TirqMj+J/Rp9ItLatT/5pZqWwefVrTQcHpixsxnVlA==", "license": "MIT", + "peer": true, "engines": { "node": ">= 4.0.0" } @@ -31120,7 +31226,6 @@ "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -31193,7 +31298,6 @@ "name": "@siemens/element-ng", "version": "48.9.0", "license": "MIT", - "peer": true, "peerDependencies": { "@angular/animations": "21", "@angular/cdk": "21", @@ -31238,8 +31342,7 @@ "projects/element-theme": { "name": "@siemens/element-theme", "version": "48.9.0", - "license": "MIT", - "peer": true + "license": "MIT" }, "projects/element-translate-cli": { "name": "@siemens/element-translate-cli", @@ -31256,7 +31359,6 @@ "name": "@siemens/element-translate-ng", "version": "48.9.0", "license": "MIT", - "peer": true, "peerDependencies": { "@angular/common": "21", "@angular/core": "21", diff --git a/package.json b/package.json index fa816bfdc..82ec76a1e 100644 --- a/package.json +++ b/package.json @@ -140,6 +140,7 @@ "@types/geojson": "7946.0.16", "@types/google-libphonenumber": "7.4.30", "@types/jasmine": "5.1.15", + "@types/katex": "0.16.7", "@types/node": "24.10.9", "angular-eslint": "21.1.0", "axe-html-reporter": "2.2.11", @@ -156,6 +157,7 @@ "husky": "9.1.7", "jasmine": "5.13.0", "jasmine-core": "5.13.0", + "katex": "0.16.27", "karma": "6.4.4", "karma-chrome-launcher": "3.2.0", "karma-coverage": "2.2.1", diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-dark-linux.png index dc9ab7539..209f818b8 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-dark-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-dark-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1226683d7f271c163e51f305b54059d263456efc42b6a04fc0afdac9fcd26b87 -size 477971 +oid sha256:cb37760dabe3b79d51abc31a0cb25a5471720eab6a7f95efdc3dfa5a72abdb19 +size 500865 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-light-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-light-linux.png index 53e7c592e..cfcffc612 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-light-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-light-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b3b7925adf2c8ee2dd53a22608b697c74ed13a21bd3bc9bbb801be6d55a36e41 -size 473756 +oid sha256:fed3e2f7e574ac2e6c8cf6efb2637d2471d38bb080778f55e5be9023510de374 +size 496561 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer.yaml b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer.yaml index 15379810c..3abed602e 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer.yaml +++ b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer.yaml @@ -10,9 +10,11 @@ - text: You can use inline code like - code: console.log('Hello World') - text: "or multi-line code blocks:" +- paragraph: + - button "Copy" + - code: "function calculateSum(a, b) { return a + b; } const result = calculateSum(5, 3); console.log(`Result: ${result}`);" - paragraph -- button "Copy" -- code: "function calculateSum(a, b) { return a + b; } const result = calculateSum(5, 3); console.log(`Result: ${result}`);" +- separator - paragraph - heading "Formatting Options" [level=2] - paragraph: Here's a paragraph explaining the formatting options available. @@ -40,6 +42,9 @@ - text: "Links are also automatically detected:" - link "https://angular.io": - /url: https://angular.io +- paragraph +- separator +- paragraph - heading "Lists and Bullets" [level=2] - paragraph: "Here are the key features:" - list: @@ -57,7 +62,10 @@ - listitem: First, analyze the requirements - listitem: Then, implement the solution - listitem: Finally, test the implementation +- paragraph +- separator - blockquote: This is a blockquote that demonstrates how quoted text appears in the markdown content component. +- paragraph - paragraph: This paragraph follows the blockquotes to demonstrate proper paragraph separation. - paragraph: This is a separate paragraph created by double line breaks. - list: @@ -68,84 +76,100 @@ - listitem: First ordered item - listitem: Second ordered item - paragraph: Final paragraph to show proper spacing. +- paragraph +- separator +- paragraph - heading "Images" [level=2] - paragraph: "Images can be included as follows:" - paragraph: - img "Building Image" +- paragraph +- separator +- paragraph - heading "Tables" [level=2] - paragraph: "Tables are also supported:" +- paragraph: + - button "Download CSV" + - table: + - rowgroup: + - row "Feature Examples Status Notes": + - columnheader "Feature": + - paragraph: Feature + - columnheader "Examples": + - paragraph: Examples + - columnheader "Status": + - paragraph: Status + - columnheader "Notes": + - paragraph: Notes + - rowgroup: + - row "Basic content Alice Johnson Bob Smith ✓ Complete Simple text and line breaks": + - cell "Basic content": + - paragraph: + - strong: Basic content + - cell "Alice Johnson Bob Smith": + - paragraph: Alice Johnson Bob Smith + - cell "✓ Complete": + - paragraph: ✓ Complete + - cell "Simple text and line breaks": + - paragraph: Simple text and line breaks + - row "Formatting Bold and italic Plus code and multiline code ✓ Complete Multiple markdown formats": + - cell "Formatting": + - paragraph: + - emphasis: Formatting + - cell "Bold and italic Plus code and multiline code": + - paragraph: + - strong: Bold + - text: and + - emphasis: italic + - text: Plus + - code: code + - text: and + - code: multiline code + - cell "✓ Complete": + - paragraph: ✓ Complete + - cell "Multiple markdown formats": + - paragraph: Multiple markdown formats + - row "Lists in cells First item Second item Third item ✓ Complete Bullet lists work properly": + - cell "Lists in cells": + - paragraph: Lists in cells + - cell "First item Second item Third item": + - list: + - listitem: First item + - listitem: Second item + - listitem: Third item + - cell "✓ Complete": + - paragraph: ✓ Complete + - cell "Bullet lists work properly": + - paragraph: Bullet lists work properly + - 'row "Escaped pipes grep \"text|pattern\" awk ''{print 2}'' ✓ Complete Use | for literal pipes"': + - cell "Escaped pipes": + - paragraph: Escaped pipes + - 'cell "grep \"text|pattern\" awk ''{print 2}''"': + - paragraph: "grep \"text|pattern\" awk '{print 2}'" + - cell "✓ Complete": + - paragraph: ✓ Complete + - cell "Use | for literal pipes": + - paragraph: Use | for literal pipes + - row "Line breaks Line 1 Line 2 Line 3 ✓ Complete Uses
    tags": + - cell "Line breaks": + - paragraph: Line breaks + - cell "Line 1 Line 2 Line 3": + - paragraph: Line 1 Line 2 Line 3 + - cell "✓ Complete": + - paragraph: ✓ Complete + - cell "Uses
    tags": + - paragraph: + - text: Uses + - code:
    + - text: tags + - text: This paragraph appears after the tables to demonstrate proper spacing. +- paragraph +- separator +- paragraph +- heading "Math Expressions" [level=2] +- paragraph: LaTeX math expressions are supported for mathematical notation. +- paragraph: "Inline math can be written like this: or the quadratic formula ." +- paragraph: "/You can escape dollar signs with a backslash to show literal prices: \\$\\d+, \\$\\d+, \\$\\d+\\./" +- paragraph: "Display math uses double dollar signs for block equations:" - paragraph -- button "Download CSV" -- table: - - rowgroup: - - row "Feature Examples Status Notes": - - cell "Feature": - - paragraph: Feature - - cell "Examples": - - paragraph: Examples - - cell "Status": - - paragraph: Status - - cell "Notes": - - paragraph: Notes - - row "Basic content Alice Johnson Bob Smith ✓ Complete Simple text and line breaks": - - cell "Basic content": - - paragraph: - - strong: Basic content - - cell "Alice Johnson Bob Smith": - - paragraph: Alice Johnson Bob Smith - - cell "✓ Complete": - - paragraph: ✓ Complete - - cell "Simple text and line breaks": - - paragraph: Simple text and line breaks - - row "Formatting Bold and italic Plus code and multiline code ✓ Complete Multiple markdown formats": - - cell "Formatting": - - paragraph: - - emphasis: Formatting - - cell "Bold and italic Plus code and multiline code": - - paragraph: - - strong: Bold - - text: and - - emphasis: italic - - text: Plus - - code: code - - text: and - - code: multiline code - - cell "✓ Complete": - - paragraph: ✓ Complete - - cell "Multiple markdown formats": - - paragraph: Multiple markdown formats - - row "Lists in cells First item Second item Third item ✓ Complete Bullet lists work properly": - - cell "Lists in cells": - - paragraph: Lists in cells - - cell "First item Second item Third item": - - list: - - listitem: First item - - listitem: Second item - - listitem: Third item - - cell "✓ Complete": - - paragraph: ✓ Complete - - cell "Bullet lists work properly": - - paragraph: Bullet lists work properly - - 'row "Escaped pipes grep \"text|pattern\" awk ''{print $1|$2}'' ✓ Complete Use | for literal pipes"': - - cell "Escaped pipes": - - paragraph: Escaped pipes - - 'cell "grep \"text|pattern\" awk ''{print $1|$2}''"': - - paragraph: "grep \"text|pattern\" awk '{print $1|$2}'" - - cell "✓ Complete": - - paragraph: ✓ Complete - - cell "Use | for literal pipes": - - paragraph: Use | for literal pipes - - row "Line breaks Line 1 Line 2 Line 3 ✓ Complete Uses
    tags": - - cell "Line breaks": - - paragraph: Line breaks - - cell "Line 1 Line 2 Line 3": - - paragraph: Line 1 Line 2 Line 3 - - cell "✓ Complete": - - paragraph: ✓ Complete - - cell "Uses
    tags": - - paragraph: - - text: Uses - - code:
    - - text: tags -- text: This paragraph appears after the tables to demonstrate proper spacing. - paragraph \ No newline at end of file diff --git a/projects/element-ng/markdown-renderer/markdown-renderer-helpers.ts b/projects/element-ng/markdown-renderer/markdown-renderer-helpers.ts new file mode 100644 index 000000000..038de057f --- /dev/null +++ b/projects/element-ng/markdown-renderer/markdown-renderer-helpers.ts @@ -0,0 +1,159 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ +import { SecurityContext } from '@angular/core'; +import { DomSanitizer } from '@angular/platform-browser'; + +/** + * Sanitizes HTML while preserving inline style attributes. + * Uses class-based placeholders to preserve styles through sanitization. + * This prevents style attributes from being stripped while maintaining security. + */ +export const sanitizeHtmlWithStyles = (html: string, sanitizer: DomSanitizer): string | null => { + const styleMap = new Map(); + let counter = 0; + const styleId = `STYLE-PH-${Math.random().toString(36).substring(2, 15)}`; + + // Step 1: Extract all style attributes and replace with placeholder classes + let processed = html; + + // Match any element with a style attribute + processed = processed.replace( + /(<[a-z][a-z0-9]*[^>]*?)\s+style="([^"]*)"/gi, + (match, tagStart, styleContent) => { + const placeholder = `${styleId}-${counter++}`; + styleMap.set(placeholder, styleContent); + + // Check if tag already has a class attribute + const hasClass = /\sclass="[^"]*"/.test(tagStart); + + if (hasClass) { + // Add placeholder to existing class attribute + return tagStart.replace(/(\sclass=")([^"]*)"/i, `$1$2 ${placeholder}"`); + } else { + // Add new class attribute with placeholder + return `${tagStart} class="${placeholder}"`; + } + } + ); + + // Step 2: Remove any remaining style attributes (safety measure) + processed = processed.replace(/\s+style="[^"]*"/gi, ''); + + // Step 3: Sanitize the HTML structure (now without style attributes) + const sanitized = sanitizer.sanitize(SecurityContext.HTML, processed); + if (!sanitized) { + return null; + } + + // Step 4: Restore style attributes from placeholders + let restored = sanitized; + styleMap.forEach((styleContent, placeholder) => { + // Sanitize individual style content + const sanitizedStyle = sanitizer.sanitize(SecurityContext.STYLE, styleContent); + if (sanitizedStyle) { + // Escape the sanitized style to prevent injection + const escapedStyle = sanitizedStyle + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); + + // Find and replace the placeholder in class attributes + restored = restored.replace(new RegExp(`class="([^"]*)"`, 'g'), (match, classContent) => { + if (classContent.includes(placeholder)) { + // Remove the placeholder from class list + const remainingClasses = classContent + .split(/\s+/) + .filter((cls: string) => cls && cls !== placeholder) + .join(' ') + .trim(); + + // Add style attribute and keep remaining classes if any + if (remainingClasses) { + return `class="${remainingClasses}" style="${escapedStyle}"`; + } + return `style="${escapedStyle}"`; + } + return match; + }); + } + }); + + return restored; +}; + +/** + * Gets a cached HTML element or creates a new one if not in cache. + * Implements LRU caching strategy. + */ +export const getCachedOrCreateElement = ( + cache: Map, + cacheOrder: string[], + cacheSize: number, + key: string, + createHtml: () => string +): HTMLElement => { + const cached = cache.get(key); + if (cached) { + const orderIndex = cacheOrder.indexOf(key); + if (orderIndex > -1) { + cacheOrder.splice(orderIndex, 1); + } + cacheOrder.push(key); + return cached; + } + + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = createHtml(); + const element = tempDiv.firstElementChild as HTMLElement; + + cache.set(key, element); + cacheOrder.push(key); + + if (cacheOrder.length > cacheSize) { + const oldestKey = cacheOrder.shift(); + if (oldestKey) { + cache.delete(oldestKey); + } + } + + return element; +}; + +/** + * Gets a cached string or creates a new one if not in cache. + * Implements LRU caching strategy. + */ +export const getCachedOrCreateString = ( + cache: Map, + cacheOrder: string[], + cacheSize: number, + key: string, + createString: () => string +): string => { + const cached = cache.get(key); + if (cached) { + const orderIndex = cacheOrder.indexOf(key); + if (orderIndex > -1) { + cacheOrder.splice(orderIndex, 1); + } + cacheOrder.push(key); + return cached; + } + + const result = createString(); + + cache.set(key, result); + cacheOrder.push(key); + + if (cacheOrder.length > cacheSize) { + const oldestKey = cacheOrder.shift(); + if (oldestKey) { + cache.delete(oldestKey); + } + } + + return result; +}; diff --git a/projects/element-ng/markdown-renderer/markdown-renderer.ts b/projects/element-ng/markdown-renderer/markdown-renderer.ts index 79b8f7345..959f77fc3 100644 --- a/projects/element-ng/markdown-renderer/markdown-renderer.ts +++ b/projects/element-ng/markdown-renderer/markdown-renderer.ts @@ -9,6 +9,12 @@ import type { TranslatableString } from '@siemens/element-translate-ng/translate'; +import { + sanitizeHtmlWithStyles, + getCachedOrCreateElement, + getCachedOrCreateString +} from './markdown-renderer-helpers'; + const CACHE_SIZE = 50; export interface MarkdownRendererOptions { @@ -32,6 +38,14 @@ export interface MarkdownRendererOptions { * @defaultValue undefined */ syntaxHighlighter?: (code: string, language?: string) => string | undefined; + /** + * Optional LaTeX renderer function. + * Receives LaTeX content and display mode boolean, returns an HTML content string or undefined to use default rendering. + * The returned HTML is sanitized before insertion. + * Make sure that the required styles/scripts for the LaTeX renderer (e.g., KaTeX) are included in your application. + * @defaultValue undefined + */ + latexRenderer?: (latex: string, displayMode: boolean) => string | undefined; /** * Optional translate sync function of a service instance for translating the copy button label and download button label. * @defaultValue undefined @@ -58,8 +72,10 @@ export const getMarkdownRenderer = ( ): ((text: string) => Node) => { const codeBlockCache = new Map(); const tableCache = new Map(); + const latexCache = new Map(); const codeBlockCacheOrder: string[] = []; const tableCacheOrder: string[] = []; + const latexCacheOrder: string[] = []; return (text: string): Node => { const div = document.createElement('div'); @@ -187,7 +203,9 @@ export const getMarkdownRenderer = ( codeBlockCacheOrder, CACHE_SIZE, codeBlockCachePlaceholderMap, - codeBlockPlaceholderState + codeBlockPlaceholderState, + latexCache, + latexCacheOrder ); // Store original cell text in the map rowData.set(index, cells[index]); @@ -259,7 +277,9 @@ export const getMarkdownRenderer = ( codeBlockCacheOrder, CACHE_SIZE, codeBlockCachePlaceholderMap, - codeBlockPlaceholderState + codeBlockPlaceholderState, + latexCache, + latexCacheOrder ); div.innerHTML = html; @@ -381,40 +401,6 @@ export const getMarkdownRenderer = ( }; }; -const getCachedOrCreateElement = ( - cache: Map, - cacheOrder: string[], - cacheSize: number, - key: string, - createHtml: () => string -): HTMLElement => { - const cached = cache.get(key); - if (cached) { - const orderIndex = cacheOrder.indexOf(key); - if (orderIndex > -1) { - cacheOrder.splice(orderIndex, 1); - } - cacheOrder.push(key); - return cached; - } - - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = createHtml(); - const element = tempDiv.firstElementChild as HTMLElement; - - cache.set(key, element); - cacheOrder.push(key); - - if (cacheOrder.length > cacheSize) { - const oldestKey = cacheOrder.shift(); - if (oldestKey) { - cache.delete(oldestKey); - } - } - - return element; -}; - const transformMarkdownText = ( html: string, keepAdditionalNewlines = true, @@ -424,7 +410,9 @@ const transformMarkdownText = ( codeBlockCacheOrder?: string[], cacheSize?: number, codeBlockCachePlaceholderMap?: Map, - codeBlockPlaceholderState?: { counter: number } + codeBlockPlaceholderState?: { counter: number }, + latexCache?: Map, + latexCacheOrder?: string[] ): 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)}--`; @@ -432,6 +420,7 @@ const transformMarkdownText = ( const escapedAsteriskPlaceholder = `--ASTERISK-${Math.random().toString(36).substring(2, 15)}--`; const escapedUnderscorePlaceholder = `--UNDERSCORE-${Math.random().toString(36).substring(2, 15)}--`; + const escapedDollarPlaceholder = `--DOLLAR-${Math.random().toString(36).substring(2, 15)}--`; // Apply markdown transformations to the sanitized content @@ -539,6 +528,92 @@ const transformMarkdownText = ( // Remove temporary closing marker if it's still there (wasn't part of a code block) html = html.replace(tempClosingMarker, '').replace(/--TEMP-CLOSE--/g, ''); + // Process LaTeX expressions after code blocks are extracted + const latexPlaceholderMap = new Map(); + + // Replace escaped dollar signs before processing LaTeX + html = html.replace(/\\\$/g, escapedDollarPlaceholder); + + // Display math: $$ ... $$ + html = html.replace(/\$\$([\s\S]*?)\$\$/g, (match, latex) => { + if (options?.latexRenderer) { + try { + const cacheKey = `display|||${latex.trim()}`; + + // Check cache first (if available) + const cached = + latexCache && latexCacheOrder + ? getCachedOrCreateString(latexCache, latexCacheOrder, CACHE_SIZE, cacheKey, () => { + const rendered = options.latexRenderer?.(latex.trim(), true); + if (rendered) { + const sanitized = sanitizeHtmlWithStyles(rendered, sanitizer); + if (sanitized) { + return `
    ${sanitized}
    `; + } + } + return ''; + }) + : (() => { + const rendered = options.latexRenderer?.(latex.trim(), true); + if (rendered) { + const sanitized = sanitizeHtmlWithStyles(rendered, sanitizer); + if (sanitized) { + return `
    ${sanitized}
    `; + } + } + return ''; + })(); + + if (cached) { + const latexPlaceholder = `--LATEX-DISPLAY-${Math.random().toString(36).substring(2, 15)}--`; + latexPlaceholderMap.set(latexPlaceholder, cached); + return latexPlaceholder; + } + } catch { + // If rendering fails, return the original + } + } + return match; + }); + + // Inline math: $ ... $ (but not $$) + html = html.replace(/(? { + if (options?.latexRenderer) { + try { + const cacheKey = `inline|||${latex.trim()}`; + + // Check cache first (if available) + const cached = + latexCache && latexCacheOrder + ? getCachedOrCreateString(latexCache, latexCacheOrder, CACHE_SIZE, cacheKey, () => { + const rendered = options.latexRenderer?.(latex.trim(), false); + if (rendered) { + const sanitized = sanitizeHtmlWithStyles(rendered, sanitizer); + return sanitized ?? ''; + } + return ''; + }) + : (() => { + const rendered = options.latexRenderer?.(latex.trim(), false); + if (rendered) { + const sanitized = sanitizeHtmlWithStyles(rendered, sanitizer); + return sanitized ?? ''; + } + return ''; + })(); + + if (cached) { + const latexPlaceholder = `--LATEX-INLINE-${Math.random().toString(36).substring(2, 15)}--`; + latexPlaceholderMap.set(latexPlaceholder, cached); + return latexPlaceholder; + } + } catch { + // If rendering fails, return the original + } + } + return match; + }); + html = html // Inline code `text` @@ -655,6 +730,14 @@ const transformMarkdownText = ( // Restore inner code block placeholders html = html.replace(new RegExp(innerCodeQuotePlaceholder, 'g'), '`'); + // Restore LaTeX placeholders + latexPlaceholderMap.forEach((latex, placeholder) => { + html = html.replace(new RegExp(placeholder, 'g'), latex); + }); + + // Restore escaped dollar signs + html = html.replace(new RegExp(escapedDollarPlaceholder, 'g'), '$'); + return html; }; 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 index 474aaca5f..4336a1367 100644 --- a/projects/element-ng/markdown-renderer/si-markdown-renderer.component.spec.ts +++ b/projects/element-ng/markdown-renderer/si-markdown-renderer.component.spec.ts @@ -184,17 +184,21 @@ const example = "code block"; const markdownDiv = hostElement.firstElementChild!; const tableElement = markdownDiv.querySelector('table')!; const trElements = markdownDiv.querySelectorAll('tr'); + const thElements = markdownDiv.querySelectorAll('th'); const tdElements = markdownDiv.querySelectorAll('td'); expect(tableElement).toBeTruthy(); expect(tableElement.classList).toContain('table'); expect(tableElement.classList).toContain('table-hover'); expect(trElements.length).toBe(3); // Header + 2 data rows - expect(tdElements.length).toBe(6); // 2 columns × 3 rows - expect(tdElements[0].textContent?.trim()).toBe('Name'); - expect(tdElements[1].textContent?.trim()).toBe('Role'); - expect(tdElements[2].textContent?.trim()).toBe('Alice'); - expect(tdElements[3].textContent?.trim()).toBe('Developer'); + expect(thElements.length).toBe(2); // 2 header cells + expect(tdElements.length).toBe(4); // 2 columns × 2 data rows + expect(thElements[0].textContent?.trim()).toBe('Name'); + expect(thElements[1].textContent?.trim()).toBe('Role'); + expect(tdElements[0].textContent?.trim()).toBe('Alice'); + expect(tdElements[1].textContent?.trim()).toBe('Developer'); + expect(tdElements[2].textContent?.trim()).toBe('Bob'); + expect(tdElements[3].textContent?.trim()).toBe('Designer'); }); it('should escape HTML in table cells', () => { @@ -208,9 +212,9 @@ const example = "code block"; const markdownDiv = hostElement.firstElementChild!; const tdElements = markdownDiv.querySelectorAll('td'); - expect(tdElements[2].innerHTML).not.toContain('