diff --git a/api-goldens/element-ng/markdown-renderer/index.api.md b/api-goldens/element-ng/markdown-renderer/index.api.md index 089ac799bc..10e6cae398 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 from '@siemens/element-translate-ng/translate'; +import { SiTranslateService } from '@siemens/element-translate-ng/translate'; +import { TranslatableString } from '@siemens/element-translate-ng/translate-types'; // @public -export const getMarkdownRenderer: (sanitizer: DomSanitizer) => ((text: string) => Node); +export const getMarkdownRenderer: (sanitizer: DomSanitizer, options?: MarkdownRendererOptions, doc?: Document, isBrowser?: boolean) => ((text: string) => Node); + +// @public (undocumented) +export interface MarkdownRendererOptions { + downloadTableButton?: TranslatableString; + translateSync?: SiTranslateService['translateSync']; +} // @public export class SiMarkdownRendererComponent { constructor(); - readonly text: i0.InputSignal; + readonly disableDownloadButton: _angular_core.InputSignal; + readonly downloadButtonLabel: _angular_core.InputSignal<_siemens_element_translate_ng_translate.TranslatableString>; + 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 1f95e64013..0bc10399a8 100644 --- a/api-goldens/element-ng/translate/index.api.md +++ b/api-goldens/element-ng/translate/index.api.md @@ -350,6 +350,8 @@ export interface SiTranslatableKeys { // (undocumented) 'SI_MAIN_DETAIL_CONTAINER.BACK'?: string; // (undocumented) + 'SI_MARKDOWN_RENDERER.DOWNLOAD'?: 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-ai-message-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-dark-linux.png index 2ee076d320..e5494a03a0 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-dark-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-dark-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:87bca818ae96eb1f8e85f138e862646fc787a1cace67579b589a282f9dabb866 -size 13930 +oid sha256:b17f626eba110891730376dbd2bedaf259f274d3fbad96cb66c4142bd0845cb6 +size 13834 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 80c9bb0810..532dd51083 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: diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-dark-linux.png index b7a085448a..330c34c0eb 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-dark-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-dark-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e16910032892fcb3cb90409e190b1eee22f61c90fff8b7c85516a5698b303361 -size 14583 +oid sha256:eb7e8f0f280cb6abedbd22e4ebf33f6e0b7a011cc3d6ef536f46ea8887a0ff2e +size 14584 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-light-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-light-linux.png index 5d975069b3..3f3aaffc9e 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-light-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message-element-examples-chromium-light-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:58dfa48dee58689efefba1c9629fd127a31186d16b693826b1e1f9b42026ca7c -size 14224 +oid sha256:152047c64d8437edea14f76be0dad86f6aa677ead9e816d8d03ee36486399404 +size 14242 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message.yaml b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message.yaml index a4ba54ee17..08a98b5682 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message.yaml +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-user-message.yaml @@ -4,8 +4,7 @@ - text: Can you help me with this - strong: code snippet - text: "?" -- paragraph: - - code: console.log('Hello World') +- code: console.log('Hello World') - paragraph: I'm getting an error when I run it. - button "Edit message" - button "Copy message" 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 51092e1d98..83cc3675b8 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:1ea39b354fa4656cb3c8cfcc2aa15afad01d8ad5b59af5b0b040876e4ac8ae60 +size 151131 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 b0d249bfeb..b0ca585e7d 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:dc434913688b2e13eb056baa60429fe2745bc002d5d1d5c77c6c39888c71f7a6 -size 137998 +oid sha256:fa26ec9e2dbe7b6b3d1d700ad9029cbeaeb9a78dd776130b7c1e5cbad8e60b8c +size 147975 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 caf1199cb2..264a38aacc 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 @@ -10,9 +10,9 @@ - text: You can use inline code like - code: console.log('Hello World') - text: "or multi-line code blocks:" -- paragraph +- text: javascript - code: "function calculateSum(a, b) { return a + b; } const result = calculateSum(5, 3); console.log(`Result: ${result}`);" -- paragraph +- separator - heading "Formatting Options" [level=2] - paragraph: Here's a paragraph explaining the formatting options available. - paragraph: "Another paragraph with different formatting elements:" @@ -39,6 +39,7 @@ - text: "Links are also automatically detected:" - link "https://angular.io": - /url: https://angular.io +- separator - heading "Lists and Bullets" [level=2] - paragraph: "Here are the key features:" - list: @@ -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:" @@ -54,7 +57,9 @@ - listitem: First, analyze the requirements - listitem: Then, implement the solution - listitem: Finally, test the implementation -- blockquote: This is a blockquote that demonstrates how quoted text appears in the markdown content component. +- separator +- blockquote: + - paragraph: This is a blockquote that demonstrates how quoted text appears in the markdown content component. - paragraph: This paragraph follows the blockquotes to demonstrate proper paragraph separation. - paragraph: This is a separate paragraph created by double line breaks. - list: @@ -65,20 +70,24 @@ - listitem: First ordered item - listitem: Second ordered item - paragraph: Final paragraph to show proper spacing. +- separator +- heading "Images" [level=2] +- paragraph: "Images can be included as follows:" +- separator - heading "Tables" [level=2] - paragraph: "Tables are also supported:" -- paragraph - table: - rowgroup: - row "Feature Examples Status Notes": - - cell "Feature": + - columnheader "Feature": - paragraph: Feature - - cell "Examples": + - columnheader "Examples": - paragraph: Examples - - cell "Status": + - columnheader "Status": - paragraph: Status - - cell "Notes": + - columnheader "Notes": - paragraph: Notes + - rowgroup: - row "Basic content Alice Johnson Bob Smith ✓ Complete Simple text and line breaks": - cell "Basic content": - paragraph: @@ -139,5 +148,4 @@ - text: Uses - code:
- text: tags -- text: This paragraph appears after the tables to demonstrate proper spacing. -- paragraph \ No newline at end of file +- paragraph: This paragraph appears after the tables to demonstrate proper spacing. \ No newline at end of file diff --git a/projects/element-ng/chat-messages/si-ai-message.component.spec.ts b/projects/element-ng/chat-messages/si-ai-message.component.spec.ts index 82868ac14d..c0bee3cac6 100644 --- a/projects/element-ng/chat-messages/si-ai-message.component.spec.ts +++ b/projects/element-ng/chat-messages/si-ai-message.component.spec.ts @@ -2,6 +2,7 @@ * Copyright (c) Siemens 2016 - 2026 * SPDX-License-Identifier: MIT */ +import { DOCUMENT } from '@angular/common'; import { DebugElement, inputBinding, signal, WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By, DomSanitizer } from '@angular/platform-browser'; @@ -42,7 +43,8 @@ describe('SiAiMessageComponent', () => { }); debugElement = fixture.debugElement; const sanitizer = TestBed.inject(DomSanitizer); - markdownRenderer = getMarkdownRenderer(sanitizer); + const doc = TestBed.inject(DOCUMENT); + markdownRenderer = getMarkdownRenderer(sanitizer, undefined, doc, true); }); it('should render markdown content', async () => { diff --git a/projects/element-ng/chat-messages/si-attachment-list.component.ts b/projects/element-ng/chat-messages/si-attachment-list.component.ts index 75f84ea1ad..6be67e7c3a 100644 --- a/projects/element-ng/chat-messages/si-attachment-list.component.ts +++ b/projects/element-ng/chat-messages/si-attachment-list.component.ts @@ -105,7 +105,8 @@ export class SiAttachmentListComponent { if (template) { event.preventDefault(); this.modalService.show(template, { - inputValues: { 'attachment': attachment } + inputValues: { 'attachment': attachment }, + ignoreBackdropClick: false }); } } 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 b2484bba0d..4f34bb8708 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 { diff --git a/projects/element-ng/chat-messages/si-user-message.component.spec.ts b/projects/element-ng/chat-messages/si-user-message.component.spec.ts index ca1e94ffe8..597170bc00 100644 --- a/projects/element-ng/chat-messages/si-user-message.component.spec.ts +++ b/projects/element-ng/chat-messages/si-user-message.component.spec.ts @@ -2,6 +2,7 @@ * Copyright (c) Siemens 2016 - 2026 * SPDX-License-Identifier: MIT */ +import { DOCUMENT } from '@angular/common'; import { DebugElement, inputBinding, signal, WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By, DomSanitizer } from '@angular/platform-browser'; @@ -43,7 +44,8 @@ describe('SiUserMessageComponent', () => { }); debugElement = fixture.debugElement; const sanitizer = TestBed.inject(DomSanitizer); - markdownRenderer = getMarkdownRenderer(sanitizer); + const doc = TestBed.inject(DOCUMENT); + markdownRenderer = getMarkdownRenderer(sanitizer, undefined, doc, true); }); it('should render markdown content', async () => { 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 0000000000..da40565b63 --- /dev/null +++ b/projects/element-ng/markdown-renderer/markdown-renderer-helpers.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ + +/** + * 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, + doc: Document +): 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 = doc.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; +}; diff --git a/projects/element-ng/markdown-renderer/markdown-renderer.ts b/projects/element-ng/markdown-renderer/markdown-renderer.ts index d7f1022c2d..5566540580 100644 --- a/projects/element-ng/markdown-renderer/markdown-renderer.ts +++ b/projects/element-ng/markdown-renderer/markdown-renderer.ts @@ -4,260 +4,726 @@ */ import { SecurityContext } from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; +import { type SiTranslateService } from '@siemens/element-translate-ng/translate'; +import { type TranslatableString } from '@siemens/element-translate-ng/translate-types'; + +import { getCachedOrCreateElement } from './markdown-renderer-helpers'; + +const CACHE_SIZE = 100; + +export interface MarkdownRendererOptions { + /** + * Provide this to enable the download table button functionality. + * Label for the download table button (will be translated internally if translateSync is provided). + */ + downloadTableButton?: TranslatableString; + + /** + * Synchronous translation function for button labels. + */ + translateSync?: SiTranslateService['translateSync']; +} + +interface ProcessOptions { + allowCodeBlocks?: boolean; + allowBlockquotes?: boolean; + allowTables?: boolean; + allowInlineCode?: boolean; + allowLinks?: boolean; +} /** - * Returns a markdown renderer function which_ - * - Transforms markdown text into formatted HTML. - * - Returns a DOM node containing the formatted content. + * Returns a function that transforms markdown text into a formatted HTML node. * - * **Warning:** The returned Node is inserted without additional sanitization. - * Input content is sanitized before processing. + * **Important for SSR**: When using this function in an SSR context, you must provide the `doc` and `isBrowser` parameters. + * Call this within an Angular injection context and pass `inject(DOCUMENT)` and `isPlatformBrowser(inject(PLATFORM_ID))`. * * @experimental * @param sanitizer - Angular DomSanitizer instance + * @param options - Optional configuration for the markdown renderer + * @param doc - Document instance (optional for browser-only apps, required for SSR - pass inject(DOCUMENT)) + * @param isBrowser - Whether running in browser (optional for browser-only apps, required for SSR - pass isPlatformBrowser(inject(PLATFORM_ID))) * @returns A function taking the markdown text to transform and returning a DOM div element containing the formatted HTML */ -export const getMarkdownRenderer = (sanitizer: DomSanitizer): ((text: string) => Node) => { - return (text: string): Node => { - const div = document.createElement('div'); - div.className = 'markdown-content text-break'; +export const getMarkdownRenderer = ( + sanitizer: DomSanitizer, + options?: MarkdownRendererOptions, + doc?: Document, + isBrowser?: boolean +): ((text: string) => Node) => { + // Use provided document or fall back to global document for backwards compatibility + const docRef = doc ?? document; + const isInBrowser = isBrowser ?? true; + + // Persistent caches within this renderer instance + const codeBlockCache = new Map(); + const tableCache = new Map(); + const codeBlockCacheOrder: string[] = []; + const tableCacheOrder: string[] = []; + + // Placeholder maps for cached elements + const codeBlockPlaceholderMap = new Map(); + const tablePlaceholderMap = new Map(); + + // Store table data for CSV export + const tableCellData = new Map>>(); + let tableCounter = 0; + + // Placeholder maps for inline elements + const linkPlaceholderMap = new Map(); + + /** + * Main recursive processing function + */ + const processMarkdown = ( + text: string, + processOpts: ProcessOptions = { + allowCodeBlocks: true, + allowBlockquotes: true, + allowTables: true, + allowInlineCode: true, + allowLinks: true + } + ): string => { + let result = text; + + // Step 1: Extract and process code blocks (4+ backticks for nested markdown) + if (processOpts.allowCodeBlocks) { + const codeBlockMap = new Map(); + + // Match code blocks with 4 or more backticks (for displaying nested code blocks) + result = result.replace( + /(^|\n)([\s]*)(````+)([^\n]*)\n?([\s\S]*?)\n?\s*\3/gm, + (match, prefix, indent, backticks, language, content) => { + const placeholder = `--CODE-BLOCK-${Math.random().toString(36).substring(2, 15)}--`; + const cacheKey = createCodeBlockCacheKey(language.trim(), content); + codeBlockPlaceholderMap.set(placeholder, cacheKey); + codeBlockMap.set(placeholder, ``); + return prefix + indent + placeholder; + } + ); + + // Match standard code blocks (3 backticks) + result = result.replace( + /(^|\n)([\s]*)(```)([^\n]*)\n?([\s\S]*?)(?:\n\s*```|```$)/gm, + (match, prefix, indent, backticks, language, content) => { + const placeholder = `--CODE-BLOCK-${Math.random().toString(36).substring(2, 15)}--`; + const cacheKey = createCodeBlockCacheKey(language.trim(), content); + codeBlockPlaceholderMap.set(placeholder, cacheKey); + codeBlockMap.set(placeholder, ``); + return prefix + indent + placeholder; + } + ); + + // Restore code block placeholders + codeBlockMap.forEach((html, placeholder) => { + result = result.replace(placeholder, html); + }); + } - if (!text) { - return div; + // Step 2: Extract and process blockquotes (can contain code blocks and inline elements) + if (processOpts.allowBlockquotes) { + const blockquoteMap = new Map(); + const lines = result.split('\n'); + let i = 0; + + while (i < lines.length) { + if (lines[i].match(/^\s*>/)) { + const blockquoteLines: string[] = []; + while (i < lines.length && lines[i].match(/^\s*>/)) { + blockquoteLines.push(lines[i].replace(/^\s*>\s?/, '')); + i++; + } + + const blockquoteContent = blockquoteLines.join('\n'); + const processedContent = processMarkdown(blockquoteContent, { + ...processOpts, + allowBlockquotes: false, + allowTables: false + }); + + const placeholder = `--BLOCKQUOTE-${Math.random().toString(36).substring(2, 15)}--`; + blockquoteMap.set(placeholder, `
${processedContent}
`); + lines.splice(i - blockquoteLines.length, blockquoteLines.length, placeholder); + i = i - blockquoteLines.length + 1; + } else { + i++; + } + } + + result = lines.join('\n'); + + blockquoteMap.forEach((html, placeholder) => { + result = result.replace(placeholder, html); + }); } - // Generate a random placeholder for newlines to preserve them during HTML sanitization - const newlinePlaceholder = `--NEWLINE-${Math.random().toString(36).substring(2, 15)}--`; - - // Replace newlines with placeholder before sanitization - const valueWithPlaceholders = text.replace(/\n/g, newlinePlaceholder); - - // Sanitize the input using Angular's HTML sanitizer - const sanitizedInput = sanitizer.sanitize(SecurityContext.HTML, valueWithPlaceholders) ?? ''; - - // Restore newlines from placeholder for markdown processing. - let html = sanitizedInput.replace(new RegExp(newlinePlaceholder, 'g'), '\n'); - - // Process tables first - html = html - // Remove table separator lines first - .replace(/^\|\s*[-:]+.*\|\s*$/gm, '') - // Process table rows - .replace(/^\|(.+)\|\s*$/gm, (_match, htmlContent) => { - // Handle escaped pipes by temporarily replacing them - const escapedPipePlaceholder = `--ESCAPED-PIPE-${Math.random().toString(36).substring(2, 15)}--`; - const contentWithPlaceholders = htmlContent.replace(/\\\|/g, escapedPipePlaceholder); - const cells = contentWithPlaceholders.split('|').map((cell: string) => { - const trimmedCell = cell.trim(); - // Restore escaped pipes - const cellWithPipes = trimmedCell.replace(new RegExp(escapedPipePlaceholder, 'g'), '|'); - - return cellWithPipes; - }); - // Make cell ready for markdown processing by replacing code blocks with inline code and
with newlines - const cellsWithNewlines = cells.map((cell: string) => { - // Replace multiline code blocks with single line code blocks - const cellWithoutMultilineCode = cell.replace( - /```([\s\S]*?)```/g, - (_innerMatch, inlineCodeContent) => { - return '`' + inlineCodeContent.replace(/`/g, '') + '`'; - } - ); - // Temporarily replace single line code blocks to avoid replacing
inside them - const tableInlineCodeBrPlaceholder = `--INLINE-CODE-BR--${Math.random().toString(36).substring(2, 15)}--`; - const cellWithPlaceholders = cellWithoutMultilineCode.replace( - /(`[^`]*`)/g, - inlineCodeMatch => { - return inlineCodeMatch.replace(/
/g, tableInlineCodeBrPlaceholder); - } + // Step 3: Extract and process tables + if (processOpts.allowTables) { + result = processTables(result); + } + + // Step 4: Extract and process inline code (must be before inline formatting) + if (processOpts.allowInlineCode) { + const inlineCodeMap = new Map(); + + result = result.replace(/(? { + const placeholder = `--INLINE-CODE-${Math.random().toString(36).substring(2, 15)}--`; + const escaped = content.replace(//g, '>'); + inlineCodeMap.set(placeholder, `${escaped}`); + return placeholder; + }); + + inlineCodeMap.forEach((html, placeholder) => { + result = result.replace(placeholder, html); + }); + } + + // Step 5: Process links (both formats, can contain inline code) + if (processOpts.allowLinks) { + result = result.replace(/<(https?:\/\/[^\s>]+)>/g, (match, url) => { + const sanitizedUrl = sanitizeUrl(url, sanitizer); + return `${escapeHtml(url)}`; + }); + + // Images: ![alt](url) + result = result.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, url) => { + const sanitizedUrl = sanitizeUrl(url, sanitizer); + const escapedAlt = escapeHtml(alt); + return `${escapedAlt}`; + }); + + // Links: [text](url) - keep as placeholder to protect from line breaks + result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, linkText, url) => { + const placeholder = `--LINK-${Math.random().toString(36).substring(2, 15)}--`; + linkPlaceholderMap.set(placeholder, { text: linkText, url }); + return placeholder; + }); + + // Auto-detect URLs + result = result.replace(/(? { + const sanitizedUrl = sanitizeUrl(match, sanitizer); + return `${escapeHtml(match)}`; + }); + } + + // Step 6: Process inline formatting only on text segments, not on block elements + result = processTextSegments(result, sanitizer); + + return result; + }; + + /** + * Create cache key for code block + */ + const createCodeBlockCacheKey = (language: string, content: string): string => { + return `${language}|||${content}`; + }; + + /** + * Create a code block element (cached) + */ + const createCodeBlockElement = (language: string, content: string): HTMLElement => { + const cacheKey = createCodeBlockCacheKey(language, content); + + return getCachedOrCreateElement( + codeBlockCache, + codeBlockCacheOrder, + CACHE_SIZE, + cacheKey, + () => { + // Escape HTML for code blocks + const displayContent = content.replace(//g, '>'); + + // Sanitize the display content + const sanitized = sanitizer.sanitize(SecurityContext.HTML, displayContent) ?? ''; + + const languageLabel = language + ? `${escapeHtml(language)}` + : ''; + const headerContent = languageLabel + ? `
${languageLabel}
` + : ''; + const wrapperClass = headerContent ? 'code-wrapper has-header' : 'code-wrapper'; + return `
${headerContent}
${sanitized}
`; + }, + docRef + ); + }; + + /** + * Process tables + */ + const processTables = (input: string): string => { + const lines = input.split('\n'); + const tableMap = new Map(); + let i = 0; + + while (i < lines.length) { + if (lines[i].match(/^\|.+/)) { + const tableLines: string[] = []; + let hasSeparator = false; + + while (i < lines.length && lines[i].match(/^\|.+/)) { + const line = lines[i]; + if (line.match(/^\|\s*[-:]+/)) { + hasSeparator = true; + } else { + tableLines.push(line); + } + i++; + } + + if (tableLines.length > 0) { + const currentTableIndex = tableCounter++; + const placeholder = `--TABLE-${Math.random().toString(36).substring(2, 15)}--`; + tablePlaceholderMap.set( + placeholder, + JSON.stringify({ tableIndex: currentTableIndex, hasSeparator, tableLines }) ); - // Replace
with newlines - const cellWithNewlines = cellWithPlaceholders.replace(//gi, '\n'); - // Restore
in inline code placeholders - const preProcessedCell = cellWithNewlines.replace( - new RegExp(tableInlineCodeBrPlaceholder, 'g'), - '
' + tableMap.set(placeholder, ``); + + // Initialize table data map for CSV export + tableCellData.set(currentTableIndex, new Map()); + + lines.splice( + i - tableLines.length - (hasSeparator ? 1 : 0), + tableLines.length + (hasSeparator ? 1 : 0), + placeholder ); - return preProcessedCell; + i = i - tableLines.length - (hasSeparator ? 1 : 0) + 1; + } + } else { + i++; + } + } + + let result = lines.join('\n'); + + tableMap.forEach((html, placeholder) => { + result = result.replace(placeholder, html); + }); + + return result; + }; + + /** + * Create cache key for table + */ + const createTableCacheKey = ( + tableLines: string[], + hasSeparator: boolean, + tableIndex: number + ): string => { + return `${tableLines.join('|||')}|||${hasSeparator}|||${tableIndex}|||${options?.downloadTableButton ?? ''}`; + }; + + /** + * Create table HTML element (cached) + */ + const createTableElement = ( + tableLines: string[], + hasSeparator: boolean, + tableIndex: number + ): HTMLElement => { + const cacheKey = createTableCacheKey(tableLines, hasSeparator, tableIndex); + + return getCachedOrCreateElement( + tableCache, + tableCacheOrder, + CACHE_SIZE, + cacheKey, + () => { + const rows: string[] = []; + const cellData = tableCellData.get(tableIndex); + + tableLines.forEach((line, rowIndex) => { + if (!line.trim()) { + return; + } + + const escapedPipePlaceholder = `___ESCAPED_PIPE___${Math.random().toString(36).substring(2, 15)}___`; + const contentWithPlaceholders = line.replace(/\\\|/g, escapedPipePlaceholder); + const parts = contentWithPlaceholders.split('|'); + const cells = parts.slice(1, -1); + + const processedCells = cells.map((cell, cellIndex) => { + let originalCell = cell.replace(new RegExp(escapedPipePlaceholder, 'g'), '|').trim(); + + // Extract inline code to protect
tags within code + const inlineCodeMap = new Map(); + originalCell = originalCell.replace( + /(? { + const inlinePlaceholder = `___INLINE_CODE_${Math.random().toString(36).substring(2, 15)}___`; + inlineCodeMap.set(inlinePlaceholder, match); + return inlinePlaceholder; + } + ); + + // Convert
tags to newlines for table cells (outside of code) + originalCell = originalCell.replace(//gi, '\n'); + + // Restore inline code + inlineCodeMap.forEach((code, inlinePlaceholder) => { + originalCell = originalCell.replace(inlinePlaceholder, code); + }); + + // Store in cellData for CSV export + if (cellData) { + const rowData = cellData.get(rowIndex) ?? new Map(); + rowData.set(cellIndex, originalCell); + cellData.set(rowIndex, rowData); + } + + // Process cell content for inline elements + return processMarkdown(originalCell, { + allowCodeBlocks: false, + allowBlockquotes: false, + allowTables: false, + allowInlineCode: true, + allowLinks: true + }); + }); + + const isHeader = hasSeparator && rowIndex === 0; + const tag = isHeader ? 'th' : 'td'; + const rowHtml = `${processedCells.map(cell => `<${tag}>${cell}`).join('')}`; + rows.push(rowHtml); }); - // Recursively process cell content for markdown formatting - const processedCells = cellsWithNewlines.map((cell: string) => { - return transformMarkdownText(cell, false, sanitizer); + // Filter out empty rows + const filteredRows = rows.filter(row => { + const tempTable = docRef.createElement('table'); + tempTable.innerHTML = `${row}`; + const tableCells = tempTable.querySelectorAll('td, th'); + return Array.from(tableCells).some(cell => { + const hasText = !!cell.textContent?.trim(); + const hasHtml = !!cell.innerHTML?.trim(); + return hasText || hasHtml; + }); }); - return `${processedCells.map((cell: string) => `${cell}`).join('')}`; + if (filteredRows.length === 0) { + return '
'; + } + + let tableHtml = + ''; + + if (hasSeparator && filteredRows.length > 0) { + tableHtml += '' + filteredRows[0] + ''; + if (filteredRows.length > 1) { + tableHtml += '' + filteredRows.slice(1).join('') + ''; + } + } else { + tableHtml += '' + filteredRows.join('') + ''; + } + + tableHtml += '
'; + + // Add download button if enabled + let downloadButton = ''; + if (options?.downloadTableButton) { + const tableId = tableHtml.match(/id="([^"]+)"/)?.[1] ?? ''; + const translatedLabel = options.translateSync + ? options.translateSync(options.downloadTableButton) + : options.downloadTableButton; + const buttonLabel = escapeHtml(translatedLabel); + downloadButton = ``; + } + + return `
${tableHtml}
${downloadButton}
`; + }, + docRef + ); + }; + + /** + * Process text segments separately from block elements + */ + const processTextSegments = (input: string, domSanitizer: DomSanitizer): string => { + const blockElementRegex = + /(<(pre|blockquote|ul|ol|hr|h[1-6]|table)[^>]*>[\s\S]*?<\/\2>)|()/g; + + const parts: string[] = []; + let lastIndex = 0; + let blockMatch; + + while ((blockMatch = blockElementRegex.exec(input)) !== null) { + if (blockMatch.index > lastIndex) { + parts.push(input.slice(lastIndex, blockMatch.index)); + } + parts.push(blockMatch[0]); + lastIndex = blockMatch.index + blockMatch[0].length; + } + if (lastIndex < input.length) { + parts.push(input.slice(lastIndex)); + } + + const processedParts = parts.map(part => { + if (part.match(/^<(pre|blockquote|ul|ol|hr|h[1-6]|table)|^