From 65e98168bb69c9f8b30096b75eca0ec12c2df77d Mon Sep 17 00:00:00 2001 From: nicolethoen Date: Fri, 25 Jul 2025 18:17:13 -0400 Subject: [PATCH 1/2] fix: correctly render admonitions and accordions Assisted-by: Cursor --- packages/module/package.json | 2 +- .../components/markdown-view.tsx | 87 +++- .../__tests__/accordion-extension.spec.tsx | 318 +++++++++++++++ .../__tests__/admonition-extension.spec.tsx | 375 ++++++++++++++++++ .../accordion-extension.tsx | 42 +- .../accordion-render-extension.tsx | 24 +- .../admonition-extension.tsx | 22 +- yarn.lock | 91 +++-- 8 files changed, 882 insertions(+), 79 deletions(-) create mode 100644 packages/module/src/ConsoleShared/src/components/markdown-extensions/__tests__/accordion-extension.spec.tsx create mode 100644 packages/module/src/ConsoleShared/src/components/markdown-extensions/__tests__/admonition-extension.spec.tsx diff --git a/packages/module/package.json b/packages/module/package.json index e79fdf46..6683157f 100644 --- a/packages/module/package.json +++ b/packages/module/package.json @@ -66,7 +66,7 @@ "@rollup/plugin-commonjs": "^17.0.0", "@rollup/plugin-json": "^4.1.0", "@rollup/plugin-node-resolve": "^11.1.0", - "@testing-library/react": "^11.2.2", + "@testing-library/react": "^13.4.0", "@types/dompurify": "^3.0.5", "@types/enzyme": "^3.10.7", "@types/enzyme-adapter-react-16": "^1.0.6", diff --git a/packages/module/src/ConsoleInternal/components/markdown-view.tsx b/packages/module/src/ConsoleInternal/components/markdown-view.tsx index c3fa0eb1..c6e40a4c 100644 --- a/packages/module/src/ConsoleInternal/components/markdown-view.tsx +++ b/packages/module/src/ConsoleInternal/components/markdown-view.tsx @@ -25,7 +25,7 @@ export const markdownConvert = async (markdown: string, extensions?: ShowdownExt return node; } - // add PF content classes + // add PF content classes to standard elements (details blocks get handled separately) if (node.nodeType === 1) { const contentElements = [ 'ul', @@ -85,7 +85,83 @@ export const markdownConvert = async (markdown: string, extensions?: ShowdownExt ); const markdownWithSubstitutedCodeFences = reverseString(reverseMarkdownWithSubstitutedCodeFences); - const parsedMarkdown = await marked.parse(markdownWithSubstitutedCodeFences); + // Fix malformed HTML entities early in the process + let preprocessedMarkdown = markdownWithSubstitutedCodeFences; + preprocessedMarkdown = preprocessedMarkdown.replace(/ ([^;])/g, ' $1').replace(/&nbsp;/g, ' '); + preprocessedMarkdown = preprocessedMarkdown.replace(/ (?![;])/g, ' '); + + // Process content in segments to ensure markdown parsing continues after HTML blocks + const htmlBlockRegex = /(<(?:details|div|section|article)[^>]*>[\s\S]*?<\/(?:details|div|section|article)>)/g; + + let parsedMarkdown = ''; + + // Check if there are any HTML blocks + if (htmlBlockRegex.test(preprocessedMarkdown)) { + // Reset regex for actual processing + htmlBlockRegex.lastIndex = 0; + + let lastIndex = 0; + let match; + + while ((match = htmlBlockRegex.exec(preprocessedMarkdown)) !== null) { + // Process markdown before the HTML block + const markdownBefore = preprocessedMarkdown.slice(lastIndex, match.index).trim(); + if (markdownBefore) { + const parsed = await marked.parse(markdownBefore); + parsedMarkdown += parsed; + } + + // Process the HTML block: parse markdown content inside while preserving HTML structure + let htmlBlock = match[1]; + + // Find and process markdown content inside HTML tags + const contentRegex = />(\s*[\s\S]*?)\s* tags if they exist since we're inside HTML already + const cleanedContent = parsedContent.replace(/^]*>([\s\S]*)<\/p>[\s]*$/g, '$1'); + contentMatches.push({ + original: contentMatch[0], + replacement: `>${cleanedContent}<` + }); + } + } + + // Apply the content replacements + contentMatches.forEach(({ original, replacement }) => { + htmlBlock = htmlBlock.replace(original, replacement); + }); + + // Apply extensions (like admonitions) to the processed HTML block + if (extensions) { + extensions.forEach(({ regex, replace }) => { + if (regex) { + htmlBlock = htmlBlock.replace(regex, replace); + } + }); + } + + parsedMarkdown += htmlBlock; + lastIndex = htmlBlockRegex.lastIndex; + } + + // Process any remaining markdown after the last HTML block + const markdownAfter = preprocessedMarkdown.slice(lastIndex).trim(); + if (markdownAfter) { + const parsed = await marked.parse(markdownAfter); + parsedMarkdown += parsed; + } + } else { + // No HTML blocks found, process normally + parsedMarkdown = await marked.parse(preprocessedMarkdown); + } // Swap the temporary tokens back to code fences before we run the extensions let md = parsedMarkdown.replace(/@@@/g, '```'); @@ -93,7 +169,7 @@ export const markdownConvert = async (markdown: string, extensions?: ShowdownExt // Convert code spans back to md format before we run the custom extension regexes md = md.replace(/(.*)<\/code>/g, '`$1`'); - extensions.forEach(({ regex, replace }) => { + extensions.forEach(({ regex, replace }, index) => { if (regex) { md = md.replace(regex, replace); } @@ -101,7 +177,8 @@ export const markdownConvert = async (markdown: string, extensions?: ShowdownExt // Convert any remaining backticks back into code spans md = md.replace(/`(.*)`/g, '$1'); - } + } + return DOMPurify.sanitize(md); }; @@ -210,7 +287,7 @@ const InlineMarkdownView: FC = ({ const id = useMemo(() => uniqueId('markdown'), []); return (
-
+
{renderExtension && ( )} diff --git a/packages/module/src/ConsoleShared/src/components/markdown-extensions/__tests__/accordion-extension.spec.tsx b/packages/module/src/ConsoleShared/src/components/markdown-extensions/__tests__/accordion-extension.spec.tsx new file mode 100644 index 00000000..b1de176b --- /dev/null +++ b/packages/module/src/ConsoleShared/src/components/markdown-extensions/__tests__/accordion-extension.spec.tsx @@ -0,0 +1,318 @@ +// Generated by Cursor +// AI-assisted implementation with human review and modifications +import * as React from 'react'; + +import { render, screen } from '@testing-library/react'; +import useAccordionShowdownExtension from '../accordion-extension'; +import { ACCORDION_MARKDOWN_BUTTON_ID, ACCORDION_MARKDOWN_CONTENT_ID } from '../const'; +import { marked } from 'marked'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const DOMPurify = require('dompurify'); + +// Mock marked +jest.mock('marked', () => ({ + marked: { + parseInline: jest.fn((text) => `${text}`), + }, +})); + +// Mock DOMPurify +jest.mock('dompurify', () => ({ + sanitize: jest.fn((html) => html), +})); + +// Test component that uses the hook +interface TestComponentProps { + onExtensionReady?: (extension: any) => void; +} + +const TestAccordionComponent: React.FunctionComponent = ({ onExtensionReady }) => { + const extension = useAccordionShowdownExtension(); + + React.useEffect(() => { + if (onExtensionReady) { + onExtensionReady(extension); + } + }, [extension, onExtensionReady]); + + return ( +
+
{extension.type}
+
{extension.regex.source}
+
{extension.regex.flags}
+ +
+ ); +}; + +describe('useAccordionShowdownExtension', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Clean up any previous test results + const existingResults = document.querySelectorAll('[data-testid="replace-result"]'); + existingResults.forEach(el => el.remove()); + }); + + it('should return extension with correct type', () => { + render(); + expect(screen.getByTestId('extension-type').textContent).toBe('lang'); + }); + + it('should return extension with global regex', () => { + render(); + expect(screen.getByTestId('extension-regex-flags').textContent).toBe('g'); + }); + + it('should return extension with correct regex pattern for HTML-encoded quotes', () => { + render(); + const regexSource = screen.getByTestId('extension-regex-source').textContent; + expect(regexSource).toBe('\\[(.+)]{{(accordion) ("(.*?)")}}'); + }); + + describe('regex pattern matching', () => { + let extension: any; + + beforeEach(() => { + const handleExtensionReady = (ext: any) => { + extension = ext; + }; + render(); + }); + + it('should match accordion syntax with HTML-encoded quotes', () => { + const testText = '[Some content here]{{accordion "My Title"}}'; + const regex = new RegExp(extension.regex); + const matches = regex.exec(testText); + + expect(matches).not.toBeNull(); + expect(matches![1]).toBe('Some content here'); + expect(matches![2]).toBe('accordion'); + expect(matches![3]).toBe('"My Title"'); + expect(matches![4]).toBe('My Title'); + }); + + it('should match multiple accordions in the same text', () => { + const testText = ` + [First content]{{accordion "First Title"}} + Some other text + [Second content]{{accordion "Second Title"}} + `; + + const matches = Array.from(testText.matchAll(extension.regex)); + expect(matches).toHaveLength(2); + expect(matches[0][4]).toBe('First Title'); + expect(matches[1][4]).toBe('Second Title'); + }); + + it('should not match accordion syntax with regular quotes', () => { + const testText = '[Some content]{{accordion "My Title"}}'; + const matches = testText.match(extension.regex); + expect(matches).toBeNull(); + }); + + it('should not match malformed accordion syntax', () => { + const malformedCases = [ + 'Some content]{{accordion "My Title"}}', + '[Some content{{accordion "My Title"}}', + '[Some content]{{accordion "My Title"}', + '[Some content]{{accordion My Title}}', + '[Some content]{{notaccordion "My Title"}}', + ]; + + malformedCases.forEach(testCase => { + const matches = testCase.match(extension.regex); + expect(matches).toBeNull(); + }); + }); + }); + + describe('HTML generation', () => { + it('should generate correct accordion HTML structure when replace function is called', () => { + render(); + + const testButton = screen.getByTestId('test-replace-button'); + testButton.click(); + + const result = document.querySelector('[data-testid="replace-result"]'); + expect(result).not.toBeNull(); + + // Check for PatternFly accordion classes in HTML + expect(result!.innerHTML).toContain('pf-v6-c-accordion'); + expect(result!.innerHTML).toContain('pf-v6-c-accordion__item'); + expect(result!.innerHTML).toContain('pf-v6-c-accordion__toggle'); + expect(result!.innerHTML).toContain('pf-v6-c-accordion__expandable-content'); + + // Check for correct IDs + expect(result!.innerHTML).toContain(`${ACCORDION_MARKDOWN_BUTTON_ID}-Test-Title`); + expect(result!.innerHTML).toContain(`${ACCORDION_MARKDOWN_CONTENT_ID}-Test-Title`); + + // Check that title is rendered + expect(result!.textContent).toContain('Test Title'); + }); + + it('should call marked.parseInline and DOMPurify.sanitize during rendering', () => { + let extension: any; + const handleExtensionReady = (ext: any) => { + extension = ext; + }; + + render(); + + // Call replace function directly + extension.replace( + '[**Bold text**]{{accordion "Title"}}', + '**Bold text**', + 'accordion', + '"Title"', + 'Title' + ); + + expect(marked.parseInline).toHaveBeenCalledWith('**Bold text**'); + expect(DOMPurify.sanitize).toHaveBeenCalled(); + }); + + it('should handle titles with spaces by replacing them with dashes in IDs', () => { + let extension: any; + const handleExtensionReady = (ext: any) => { + extension = ext; + }; + + render(); + + const result = extension.replace( + '[Content]{{accordion "My Test Title"}}', + 'Content', + 'accordion', + '"My Test Title"', + 'My Test Title' + ); + + expect(result).toContain(`id="${ACCORDION_MARKDOWN_BUTTON_ID}-My-Test-Title"`); + expect(result).toContain(`id="${ACCORDION_MARKDOWN_CONTENT_ID}-My-Test-Title"`); + }); + + it('should handle special characters in titles', () => { + let extension: any; + const handleExtensionReady = (ext: any) => { + extension = ext; + }; + + render(); + + const result = extension.replace( + '[Content]{{accordion "Title with 123 & symbols!"}}', + 'Content', + 'accordion', + '"Title with 123 & symbols!"', + 'Title with 123 & symbols!' + ); + + expect(result).toContain(`id="${ACCORDION_MARKDOWN_BUTTON_ID}-Title-with-123-&-symbols!"`); + expect(result).toContain('Title with 123 & symbols!'); + }); + }); + + describe('edge cases', () => { + it('should handle empty content', () => { + let extension: any; + const handleExtensionReady = (ext: any) => { + extension = ext; + }; + + render(); + + const result = extension.replace( + '[]{{accordion "Empty"}}', + '', + 'accordion', + '"Empty"', + 'Empty' + ); + + expect(result).toContain('class="pf-v6-c-accordion"'); + expect(result).toContain('Empty'); + }); + + it('should handle content with HTML entities', () => { + let extension: any; + const handleExtensionReady = (ext: any) => { + extension = ext; + }; + + render(); + + const result = extension.replace( + '[Content with <tags> & entities]{{accordion "Title"}}', + 'Content with <tags> & entities', + 'accordion', + '"Title"', + 'Title' + ); + + expect(result).toContain('Content with <tags> & entities'); + }); + + it('should handle very long titles', () => { + let extension: any; + const handleExtensionReady = (ext: any) => { + extension = ext; + }; + + render(); + + const longTitle = 'This is a very long title that might cause issues with ID generation and display'; + const result = extension.replace( + '[Content]{{accordion "' + longTitle + '"}}', + 'Content', + 'accordion', + '"' + longTitle + '"', + longTitle + ); + + expect(result).toContain(longTitle); + expect(result).toContain(`id="${ACCORDION_MARKDOWN_BUTTON_ID}-${longTitle.replace(/\s/g, '-')}"`); + }); + }); + + describe('memoization', () => { + it('should return the same extension object on subsequent renders', () => { + let callCount = 0; + let firstExtension: any; + let secondExtension: any; + + const handleExtensionReady = (ext: any) => { + callCount++; + if (callCount === 1) { + firstExtension = ext; + } else if (callCount === 2) { + secondExtension = ext; + } + }; + + const { rerender } = render(); + rerender(); + + expect(callCount).toBeGreaterThanOrEqual(1); + expect(firstExtension).toBeDefined(); + expect(firstExtension.type).toBe('lang'); + expect(typeof firstExtension.replace).toBe('function'); + }); + }); +}); \ No newline at end of file diff --git a/packages/module/src/ConsoleShared/src/components/markdown-extensions/__tests__/admonition-extension.spec.tsx b/packages/module/src/ConsoleShared/src/components/markdown-extensions/__tests__/admonition-extension.spec.tsx new file mode 100644 index 00000000..51261c94 --- /dev/null +++ b/packages/module/src/ConsoleShared/src/components/markdown-extensions/__tests__/admonition-extension.spec.tsx @@ -0,0 +1,375 @@ + // Generated by Cursor + // AI-assisted implementation with human review and modifications +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; +import useAdmonitionShowdownExtension from '../admonition-extension'; +import { marked } from 'marked'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const DOMPurify = require('dompurify'); + +// Mock marked +jest.mock('marked', () => ({ + marked: { + parseInline: jest.fn((text) => `${text}`), + }, +})); + +// Mock DOMPurify +jest.mock('dompurify', () => ({ + sanitize: jest.fn((html) => html), +})); + +// Test component that uses the hook +interface TestComponentProps { + onExtensionReady?: (extension: any) => void; +} + +const TestAdmonitionComponent: React.FunctionComponent = ({ onExtensionReady }) => { + const extension = useAdmonitionShowdownExtension(); + + React.useEffect(() => { + if (onExtensionReady) { + onExtensionReady(extension); + } + }, [extension, onExtensionReady]); + + return ( +
+
{extension.type}
+
{extension.regex.source}
+
{extension.regex.flags}
+ +
+ ); +}; + +describe('useAdmonitionShowdownExtension', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Clean up any previous test results + const existingResults = document.querySelectorAll('[data-testid="replace-result"]'); + existingResults.forEach(el => el.remove()); + }); + + it('should return extension with correct type', () => { + render(); + expect(screen.getByTestId('extension-type').textContent).toBe('lang'); + }); + + it('should return extension with global regex', () => { + render(); + expect(screen.getByTestId('extension-regex-flags').textContent).toBe('g'); + }); + + it('should return extension with correct regex pattern for admonitions', () => { + render(); + const regexSource = screen.getByTestId('extension-regex-source').textContent; + expect(regexSource).toBe('\\[(.+)]{{(admonition) ([\\w-]+)}}'); + }); + + describe('regex pattern matching', () => { + let extension: any; + + beforeEach(() => { + const handleExtensionReady = (ext: any) => { + extension = ext; + }; + render(); + }); + + it('should match NOTE admonition syntax', () => { + const testText = '[Important message here]{{admonition note}}'; + const regex = new RegExp(extension.regex); + const matches = regex.exec(testText); + + expect(matches).not.toBeNull(); + expect(matches![1]).toBe('Important message here'); + expect(matches![2]).toBe('admonition'); + expect(matches![3]).toBe('note'); + }); + + it('should match different admonition types', () => { + const admonitionTypes = ['note', 'tip', 'important', 'warning', 'caution']; + + admonitionTypes.forEach(type => { + const testText = `[Content for ${type}]{{admonition ${type}}}`; + const regex = new RegExp(extension.regex); + const matches = regex.exec(testText); + + expect(matches).not.toBeNull(); + expect(matches![3]).toBe(type); + }); + }); + + it('should match admonitions with hyphenated types', () => { + const testText = '[Content]{{admonition custom-type}}'; + const regex = new RegExp(extension.regex); + const matches = regex.exec(testText); + + expect(matches).not.toBeNull(); + expect(matches![3]).toBe('custom-type'); + }); + + it('should match multiple admonitions in the same text', () => { + const testText = ` + [First note]{{admonition note}} + Some other content + [Warning message]{{admonition warning}} + `; + + const matches = Array.from(testText.matchAll(extension.regex)); + expect(matches).toHaveLength(2); + expect(matches[0][3]).toBe('note'); + expect(matches[1][3]).toBe('warning'); + }); + + it('should not match malformed admonition syntax', () => { + const malformedCases = [ + 'Content]{{admonition note}}', + '[Content{{admonition note}}', + '[Content]{{admonition note}', + '[Content]{{admonition}}', + '[Content]{{notadmonition note}}', + '[Content]{{admonition note extra}}', + ]; + + malformedCases.forEach(testCase => { + const matches = testCase.match(extension.regex); + expect(matches).toBeNull(); + }); + }); + }); + + describe('HTML generation', () => { + it('should generate correct alert HTML structure when replace function is called', () => { + render(); + + const testButton = screen.getByTestId('test-replace-button'); + testButton.click(); + + const result = document.querySelector('[data-testid="replace-result"]'); + expect(result).not.toBeNull(); + + // Check for PatternFly alert classes in HTML + expect(result!.innerHTML).toContain('pf-v6-c-alert'); + expect(result!.innerHTML).toContain('pf-m-info'); // NOTE maps to info variant + expect(result!.innerHTML).toContain('pf-m-inline'); + expect(result!.innerHTML).toContain('pfext-markdown-admonition'); + + // Check title shows the admonition type (uppercase) + expect(result!.textContent).toContain('NOTE'); + }); + + it('should call marked.parseInline and DOMPurify.sanitize during rendering', () => { + let extension: any; + const handleExtensionReady = (ext: any) => { + extension = ext; + }; + + render(); + + // Call replace function directly + extension.replace( + '[**Bold text**]{{admonition note}}', + '**Bold text**', + 'admonition', + 'note' + ); + + expect(marked.parseInline).toHaveBeenCalledWith('**Bold text**'); + expect(DOMPurify.sanitize).toHaveBeenCalled(); + }); + + it('should handle different admonition types with correct variants', () => { + let extension: any; + const handleExtensionReady = (ext: any) => { + extension = ext; + }; + + render(); + + const testCases = [ + { type: 'note', expectedClass: 'pf-m-info' }, + { type: 'tip', expectedClass: 'pf-m-custom' }, + { type: 'important', expectedClass: 'pf-m-danger' }, + { type: 'warning', expectedClass: 'pf-m-warning' }, + { type: 'caution', expectedClass: 'pf-m-warning' }, + ]; + + testCases.forEach(({ type, expectedClass }) => { + const result = extension.replace( + `[Content]{{admonition ${type}}}`, + 'Content', + 'admonition', + type + ); + + expect(result).toContain(expectedClass); + expect(result).toContain(type.toUpperCase()); + }); + }); + }); + + describe('edge cases and validation', () => { + it('should return original text when content is missing', () => { + let extension: any; + const handleExtensionReady = (ext: any) => { + extension = ext; + }; + + render(); + + const originalText = '[Content]{{admonition note}}'; + const result = extension.replace(originalText, '', 'admonition', 'note'); + + expect(result).toBe(originalText); + }); + + it('should return original text when admonition type is missing', () => { + let extension: any; + const handleExtensionReady = (ext: any) => { + extension = ext; + }; + + render(); + + const originalText = '[Content]{{admonition}}'; + const result = extension.replace(originalText, 'Content', 'admonition', ''); + + expect(result).toBe(originalText); + }); + + it('should return original text when command is not admonition', () => { + let extension: any; + const handleExtensionReady = (ext: any) => { + extension = ext; + }; + + render(); + + const originalText = '[Content]{{something note}}'; + const result = extension.replace(originalText, 'Content', 'something', 'note'); + + expect(result).toBe(originalText); + }); + + it('should handle content with HTML entities', () => { + let extension: any; + const handleExtensionReady = (ext: any) => { + extension = ext; + }; + + render(); + + const result = extension.replace( + '[Content with <tags> & entities]{{admonition note}}', + 'Content with <tags> & entities', + 'admonition', + 'note' + ); + + expect(result).toContain('Content with <tags> & entities'); + expect(result).toContain('class="pf-v6-c-alert'); + }); + + it('should convert admonition type to uppercase in title', () => { + let extension: any; + const handleExtensionReady = (ext: any) => { + extension = ext; + }; + + render(); + + const result = extension.replace( + '[Content]{{admonition note}}', + 'Content', + 'admonition', + 'note' + ); + + expect(result).toContain('NOTE'); + expect(result).not.toMatch(/[^A-Z]note[^A-Z]/); // Should not contain lowercase 'note' + }); + + it('should handle very long content', () => { + let extension: any; + const handleExtensionReady = (ext: any) => { + extension = ext; + }; + + render(); + + const longContent = 'This is a very long message that might cause layout issues'.repeat(3); + const result = extension.replace( + `[${longContent}]{{admonition note}}`, + longContent, + 'admonition', + 'note' + ); + + expect(result).toContain('class="pf-v6-c-alert'); + expect(result).toContain(longContent); + }); + }); + + describe('unknown admonition types', () => { + it('should handle unknown admonition types gracefully', () => { + let extension: any; + const handleExtensionReady = (ext: any) => { + extension = ext; + }; + + render(); + + expect(() => { + extension.replace( + '[Content]{{admonition unknown}}', + 'Content', + 'admonition', + 'unknown' + ); + }).not.toThrow(); + }); + }); + + describe('memoization', () => { + it('should return the same extension object on subsequent renders', () => { + let callCount = 0; + let firstExtension: any; + let secondExtension: any; + + const handleExtensionReady = (ext: any) => { + callCount++; + if (callCount === 1) { + firstExtension = ext; + } else if (callCount === 2) { + secondExtension = ext; + } + }; + + const { rerender } = render(); + rerender(); + + expect(callCount).toBeGreaterThanOrEqual(1); + expect(firstExtension).toBeDefined(); + expect(firstExtension.type).toBe('lang'); + expect(typeof firstExtension.replace).toBe('function'); + }); + }); +}); \ No newline at end of file diff --git a/packages/module/src/ConsoleShared/src/components/markdown-extensions/accordion-extension.tsx b/packages/module/src/ConsoleShared/src/components/markdown-extensions/accordion-extension.tsx index 798e510e..d6810a9e 100644 --- a/packages/module/src/ConsoleShared/src/components/markdown-extensions/accordion-extension.tsx +++ b/packages/module/src/ConsoleShared/src/components/markdown-extensions/accordion-extension.tsx @@ -8,34 +8,46 @@ import { import { renderToStaticMarkup } from 'react-dom/server'; import { removeTemplateWhitespace } from './utils'; import { ACCORDION_MARKDOWN_BUTTON_ID, ACCORDION_MARKDOWN_CONTENT_ID } from './const'; +import { marked } from 'marked'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const DOMPurify = require('dompurify'); const useAccordionShowdownExtension = () => useMemo( () => ({ type: 'lang', - regex: /\[(.+)]{{(accordion) ("(.*?)")}}/g, + regex: /\[(.+)]{{(accordion) ("(.*?)")}}/g, replace: ( _text: string, accordionContent: string, _command: string, + _quotedHeading: string, accordionHeading: string, - ): string => { - const accordionId = String(accordionHeading).replace(/\s/g, '-'); + ): string => { + const accordionId = String(accordionHeading).replace(/\s/g, '-'); + + // Process accordion content with markdown + const processedContent = marked.parseInline(accordionContent); + const sanitizedContent = DOMPurify.sanitize(processedContent); return removeTemplateWhitespace( renderToStaticMarkup( - <> - - - - {accordionHeading} - - - {accordionContent} - - - - , + + + + {accordionHeading} + +