Skip to content

Commit 2df1761

Browse files
Merge pull request #407 from nicolethoen/admonitions_and_accordions
fix: correctly render admonitions and accordions
2 parents 262bcb8 + e570b7a commit 2df1761

File tree

11 files changed

+424
-85
lines changed

11 files changed

+424
-85
lines changed

packages/module/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666
"@rollup/plugin-commonjs": "^17.0.0",
6767
"@rollup/plugin-json": "^4.1.0",
6868
"@rollup/plugin-node-resolve": "^11.1.0",
69-
"@testing-library/react": "^11.2.2",
69+
"@testing-library/react": "^13.4.0",
7070
"@types/dompurify": "^3.0.5",
7171
"@types/enzyme": "^3.10.7",
7272
"@types/enzyme-adapter-react-16": "^1.0.6",

packages/module/src/ConsoleInternal/components/markdown-view.tsx

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const markdownConvert = async (markdown: string, extensions?: ShowdownExt
2525
return node;
2626
}
2727

28-
// add PF content classes
28+
// add PF content classes to standard elements (details blocks get handled separately)
2929
if (node.nodeType === 1) {
3030
const contentElements = [
3131
'ul',
@@ -85,15 +85,98 @@ export const markdownConvert = async (markdown: string, extensions?: ShowdownExt
8585
);
8686
const markdownWithSubstitutedCodeFences = reverseString(reverseMarkdownWithSubstitutedCodeFences);
8787

88-
const parsedMarkdown = await marked.parse(markdownWithSubstitutedCodeFences);
88+
// Fix malformed HTML entities early in the process
89+
let preprocessedMarkdown = markdownWithSubstitutedCodeFences;
90+
preprocessedMarkdown = preprocessedMarkdown
91+
.replace(/&nbsp([^;])/g, ' $1')
92+
.replace(/ /g, ' ');
93+
preprocessedMarkdown = preprocessedMarkdown.replace(/&nbsp(?![;])/g, ' ');
94+
95+
// Process content in segments to ensure markdown parsing continues after HTML blocks
96+
const htmlBlockRegex =
97+
/(<(?:details|div|section|article)[^>]*>[\s\S]*?<\/(?:details|div|section|article)>)/g;
98+
99+
let parsedMarkdown = '';
100+
101+
// Check if there are any HTML blocks
102+
if (htmlBlockRegex.test(preprocessedMarkdown)) {
103+
// Reset regex for actual processing
104+
htmlBlockRegex.lastIndex = 0;
105+
106+
let lastIndex = 0;
107+
let match;
108+
109+
while ((match = htmlBlockRegex.exec(preprocessedMarkdown)) !== null) {
110+
// Process markdown before the HTML block
111+
const markdownBefore = preprocessedMarkdown.slice(lastIndex, match.index).trim();
112+
if (markdownBefore) {
113+
const parsed = await marked.parse(markdownBefore);
114+
parsedMarkdown += parsed;
115+
}
116+
117+
// Process the HTML block: parse markdown content inside while preserving HTML structure
118+
let htmlBlock = match[1];
119+
120+
// Find and process markdown content inside HTML tags
121+
const contentRegex = />(\s*[\s\S]*?)\s*</g;
122+
const contentMatches = [];
123+
let contentMatch;
124+
125+
while ((contentMatch = contentRegex.exec(htmlBlock)) !== null) {
126+
const content = contentMatch[1];
127+
// Only process content that has markdown formatting but no extension syntax
128+
if (
129+
content.trim() &&
130+
!content.includes('{{') &&
131+
(content.includes('**') || content.includes('- ') || content.includes('\n'))
132+
) {
133+
// This looks like markdown content without extensions - parse it as block content
134+
const parsedContent = await marked.parse(content.trim());
135+
// Remove wrapping <p> tags if they exist since we're inside HTML already
136+
const cleanedContent = parsedContent.replace(/^<p[^>]*>([\s\S]*)<\/p>[\s]*$/g, '$1');
137+
contentMatches.push({
138+
original: contentMatch[0],
139+
replacement: `>${cleanedContent}<`,
140+
});
141+
}
142+
}
143+
144+
// Apply the content replacements
145+
contentMatches.forEach(({ original, replacement }) => {
146+
htmlBlock = htmlBlock.replace(original, replacement);
147+
});
148+
149+
// Apply extensions (like admonitions) to the processed HTML block
150+
if (extensions) {
151+
extensions.forEach(({ regex, replace }) => {
152+
if (regex) {
153+
htmlBlock = htmlBlock.replace(regex, replace);
154+
}
155+
});
156+
}
157+
158+
parsedMarkdown += htmlBlock;
159+
lastIndex = htmlBlockRegex.lastIndex;
160+
}
161+
162+
// Process any remaining markdown after the last HTML block
163+
const markdownAfter = preprocessedMarkdown.slice(lastIndex).trim();
164+
if (markdownAfter) {
165+
const parsed = await marked.parse(markdownAfter);
166+
parsedMarkdown += parsed;
167+
}
168+
} else {
169+
// No HTML blocks found, process normally
170+
parsedMarkdown = await marked.parse(preprocessedMarkdown);
171+
}
89172
// Swap the temporary tokens back to code fences before we run the extensions
90173
let md = parsedMarkdown.replace(/@@@/g, '```');
91174

92175
if (extensions) {
93176
// Convert code spans back to md format before we run the custom extension regexes
94177
md = md.replace(/<code>(.*)<\/code>/g, '`$1`');
95178

96-
extensions.forEach(({ regex, replace }) => {
179+
extensions.forEach(({ regex, replace }, _index) => {
97180
if (regex) {
98181
md = md.replace(regex, replace);
99182
}
@@ -102,6 +185,7 @@ export const markdownConvert = async (markdown: string, extensions?: ShowdownExt
102185
// Convert any remaining backticks back into code spans
103186
md = md.replace(/`(.*)`/g, '<code>$1</code>');
104187
}
188+
105189
return DOMPurify.sanitize(md);
106190
};
107191

@@ -210,7 +294,10 @@ const InlineMarkdownView: FC<InnerSyncMarkdownProps> = ({
210294
const id = useMemo(() => uniqueId('markdown'), []);
211295
return (
212296
<div className={css({ 'is-empty': isEmpty } as any, className)} id={id}>
213-
<div dangerouslySetInnerHTML={{ __html: markup }} />
297+
<div
298+
style={{ marginBlockEnd: 'var(--pf-t-global--spacer--md)' }}
299+
dangerouslySetInnerHTML={{ __html: markup }}
300+
/>
214301
{renderExtension && (
215302
<RenderExtension renderExtension={renderExtension} selector={`#${id}`} markup={markup} />
216303
)}
@@ -299,6 +386,7 @@ const IFrameMarkdownView: FC<InnerSyncMarkdownProps> = ({
299386
return (
300387
<>
301388
<iframe
389+
title="Markdown content preview"
302390
sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin"
303391
srcDoc={contents}
304392
style={{ border: '0px', display: 'block', width: '100%', height: '0' }}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Generated by Cursor
2+
// AI-assisted implementation with human review and modifications
3+
import { renderHook } from '@testing-library/react';
4+
import useAccordionShowdownExtension from '../accordion-extension';
5+
import { ACCORDION_MARKDOWN_BUTTON_ID, ACCORDION_MARKDOWN_CONTENT_ID } from '../const';
6+
import { marked } from 'marked';
7+
8+
// Mock marked
9+
jest.mock('marked', () => ({
10+
marked: {
11+
parseInline: jest.fn((text) => `<em>${text}</em>`),
12+
},
13+
}));
14+
15+
// Mock DOMPurify
16+
jest.mock('dompurify', () => ({
17+
sanitize: jest.fn((html) => html),
18+
}));
19+
20+
describe('useAccordionShowdownExtension', () => {
21+
beforeEach(() => {
22+
jest.clearAllMocks();
23+
});
24+
25+
it('should return a showdown extension with correct properties', () => {
26+
const { result } = renderHook(() => useAccordionShowdownExtension());
27+
const extension = result.current;
28+
29+
expect(extension.type).toBe('lang');
30+
expect(extension.regex).toEqual(/\[(.+)]{{(accordion) (&quot;(.*?)&quot;)}}/g);
31+
expect(typeof extension.replace).toBe('function');
32+
});
33+
34+
it('should match accordion syntax with HTML-encoded quotes', () => {
35+
const { result } = renderHook(() => useAccordionShowdownExtension());
36+
const { regex } = result.current;
37+
38+
const testText = '[Some content]{{accordion &quot;My Title&quot;}}';
39+
const matches = regex.exec(testText);
40+
41+
expect(matches).not.toBeNull();
42+
if (matches) {
43+
expect(matches[1]).toBe('Some content');
44+
expect(matches[2]).toBe('accordion');
45+
expect(matches[4]).toBe('My Title');
46+
}
47+
});
48+
49+
it('should not match accordion syntax with regular quotes', () => {
50+
const { result } = renderHook(() => useAccordionShowdownExtension());
51+
const { regex } = result.current;
52+
53+
const testText = '[Some content]{{accordion "My Title"}}';
54+
expect(testText.match(regex)).toBeNull();
55+
});
56+
57+
it('should generate correct accordion HTML structure', () => {
58+
const { result } = renderHook(() => useAccordionShowdownExtension());
59+
const { replace } = result.current;
60+
61+
const html = replace(
62+
'[Test content]{{accordion &quot;Test Title&quot;}}',
63+
'Test content',
64+
'accordion',
65+
'&quot;Test Title&quot;',
66+
'Test Title',
67+
);
68+
69+
expect(html).toContain('pf-v6-c-accordion');
70+
expect(html).toContain('pf-v6-c-accordion__toggle');
71+
expect(html).toContain(`${ACCORDION_MARKDOWN_BUTTON_ID}-Test-Title`);
72+
expect(html).toContain(`${ACCORDION_MARKDOWN_CONTENT_ID}-Test-Title`);
73+
expect(html).toContain('Test Title');
74+
});
75+
76+
it('should process content through marked and sanitize HTML', () => {
77+
const { result } = renderHook(() => useAccordionShowdownExtension());
78+
const { replace } = result.current;
79+
80+
replace(
81+
'[**Bold text**]{{accordion &quot;Title&quot;}}',
82+
'**Bold text**',
83+
'accordion',
84+
'&quot;Title&quot;',
85+
'Title',
86+
);
87+
88+
expect(marked.parseInline).toHaveBeenCalledWith('**Bold text**');
89+
});
90+
91+
it('should handle titles with spaces in IDs', () => {
92+
const { result } = renderHook(() => useAccordionShowdownExtension());
93+
const { replace } = result.current;
94+
95+
const html = replace(
96+
'[Content]{{accordion &quot;My Test Title&quot;}}',
97+
'Content',
98+
'accordion',
99+
'&quot;My Test Title&quot;',
100+
'My Test Title',
101+
);
102+
103+
expect(html).toContain(`${ACCORDION_MARKDOWN_BUTTON_ID}-My-Test-Title`);
104+
});
105+
});
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { renderHook } from '@testing-library/react';
2+
import useAdmonitionShowdownExtension from '../admonition-extension';
3+
import { marked } from 'marked';
4+
5+
// Mock marked
6+
jest.mock('marked', () => ({
7+
marked: {
8+
parseInline: jest.fn((text) => `<strong>${text}</strong>`),
9+
},
10+
}));
11+
12+
// Mock DOMPurify
13+
jest.mock('dompurify', () => ({
14+
sanitize: jest.fn((html) => html),
15+
}));
16+
17+
describe('useAdmonitionShowdownExtension', () => {
18+
beforeEach(() => {
19+
jest.clearAllMocks();
20+
});
21+
22+
it('should return a showdown extension with correct properties', () => {
23+
const { result } = renderHook(() => useAdmonitionShowdownExtension());
24+
const extension = result.current;
25+
26+
expect(extension.type).toBe('lang');
27+
expect(extension.regex).toEqual(/\[(.+)]{{(admonition) ([\w-]+)}}/g);
28+
expect(typeof extension.replace).toBe('function');
29+
});
30+
31+
it('should match different admonition types', () => {
32+
const { result } = renderHook(() => useAdmonitionShowdownExtension());
33+
const { regex } = result.current;
34+
35+
const admonitionTypes = ['note', 'tip', 'important', 'warning', 'caution', 'custom-type'];
36+
37+
admonitionTypes.forEach((type) => {
38+
const testText = `[Content for ${type}]{{admonition ${type}}}`;
39+
// Reset regex state for global flag
40+
regex.lastIndex = 0;
41+
const matches = regex.exec(testText);
42+
43+
expect(matches).not.toBeNull();
44+
if (matches) {
45+
expect(matches[1]).toBe(`Content for ${type}`);
46+
expect(matches[2]).toBe('admonition');
47+
expect(matches[3]).toBe(type);
48+
}
49+
});
50+
});
51+
52+
it('should not match malformed admonition syntax', () => {
53+
const { result } = renderHook(() => useAdmonitionShowdownExtension());
54+
const { regex } = result.current;
55+
56+
const malformedCases = [
57+
'Content]{{admonition note}}',
58+
'[Content{{admonition note}}',
59+
'[Content]{{admonition}}',
60+
'[Content]{{notadmonition note}}',
61+
];
62+
63+
malformedCases.forEach((testCase) => {
64+
expect(testCase.match(regex)).toBeNull();
65+
});
66+
});
67+
68+
it('should generate correct alert HTML structure', () => {
69+
const { result } = renderHook(() => useAdmonitionShowdownExtension());
70+
const { replace } = result.current;
71+
72+
const html = replace('[Test message]{{admonition note}}', 'Test message', 'admonition', 'note');
73+
74+
expect(html).toContain('pf-v6-c-alert');
75+
expect(html).toContain('pf-m-info'); // note maps to info variant
76+
expect(html).toContain('pf-m-inline');
77+
expect(html).toContain('pfext-markdown-admonition');
78+
expect(html).toContain('NOTE'); // uppercase title
79+
});
80+
81+
it('should handle different admonition types with correct variants', () => {
82+
const { result } = renderHook(() => useAdmonitionShowdownExtension());
83+
const { replace } = result.current;
84+
85+
const testCases = [
86+
{ type: 'note', expectedClass: 'pf-m-info' },
87+
{ type: 'warning', expectedClass: 'pf-m-warning' },
88+
{ type: 'important', expectedClass: 'pf-m-danger' },
89+
];
90+
91+
testCases.forEach(({ type, expectedClass }) => {
92+
const html = replace(`[Content]{{admonition ${type}}}`, 'Content', 'admonition', type);
93+
94+
expect(html).toContain(expectedClass);
95+
expect(html).toContain(type.toUpperCase());
96+
});
97+
});
98+
99+
it('should process content through marked', () => {
100+
const { result } = renderHook(() => useAdmonitionShowdownExtension());
101+
const { replace } = result.current;
102+
103+
replace('[**Bold text**]{{admonition note}}', '**Bold text**', 'admonition', 'note');
104+
105+
expect(marked.parseInline).toHaveBeenCalledWith('**Bold text**');
106+
});
107+
108+
it('should return original text for invalid cases', () => {
109+
const { result } = renderHook(() => useAdmonitionShowdownExtension());
110+
const { replace } = result.current;
111+
112+
// Missing content
113+
const originalText = '[Content]{{admonition note}}';
114+
const result1 = replace(originalText, '', 'admonition', 'note');
115+
expect(result1).toBe(originalText);
116+
117+
// Wrong command
118+
const result2 = replace(originalText, 'Content', 'not-admonition', 'note');
119+
expect(result2).toBe(originalText);
120+
});
121+
});

0 commit comments

Comments
 (0)