Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions __tests__/lib/mdxish/html-blocks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import type { Element } from 'hast';

import { mdxish } from '../../../lib';
import { findAllElementsByTagName, findElementByTagName } from '../../helpers';

describe('mdxish HTMLBlock', () => {
describe('standalone', () => {
it('renders as <html-block> with the decoded html prop', () => {
const tree = mdxish('<HTMLBlock>{`<div style="color: red;">Hello</div>`}</HTMLBlock>');

const htmlBlock = findElementByTagName(tree, 'html-block');
expect(htmlBlock).toMatchObject({
type: 'element',
tagName: 'html-block',
properties: { html: '<div style="color: red;">Hello</div>' },
children: [],
});
});
});

describe('nested inside JSX blocks (RM-16726)', () => {
it('renders inside a <Table> cell as <html-block> with the decoded html prop', () => {
const md = `<Table>
<thead>
<tr><th>Name</th><th>Markup</th></tr>
</thead>
<tbody>
<tr>
<td>Custom</td>
<td><HTMLBlock>{\`<div style="color: red;">Hello</div>\`}</HTMLBlock></td>
</tr>
</tbody>
</Table>`;

const tree = mdxish(md);

const rawHtmlBlock = findElementByTagName(tree, 'HTMLBlock');
expect(rawHtmlBlock).toBeNull();

const htmlBlock = findElementByTagName(tree, 'html-block');
expect(htmlBlock).toMatchObject({
type: 'element',
tagName: 'html-block',
properties: { html: '<div style="color: red;">Hello</div>' },
children: [],
});
});

it('renders inside a generic JSX block as <html-block> with the decoded html prop', () => {
const md = '<div><HTMLBlock>{`<p>nested</p>`}</HTMLBlock></div>';

const tree = mdxish(md);

const htmlBlock = findElementByTagName(tree, 'html-block');
expect(htmlBlock).toMatchObject({
type: 'element',
tagName: 'html-block',
properties: { html: '<p>nested</p>' },
});
});

it('preserves safeMode and runScripts attributes when nested', () => {
const md = `<Table>
<tbody>
<tr>
<td><HTMLBlock safeMode="true" runScripts="false">{\`<div>raw</div>\`}</HTMLBlock></td>
</tr>
</tbody>
</Table>`;

const tree = mdxish(md);

const htmlBlock = findElementByTagName(tree, 'html-block');
expect(htmlBlock).toMatchObject({
type: 'element',
tagName: 'html-block',
properties: {
html: '<div>raw</div>',
safeMode: 'true',
runScripts: 'false',
},
});
});

it('renders multiple HTMLBlocks inside the same Table', () => {
const md = `<Table>
<tbody>
<tr>
<td><HTMLBlock>{\`<span>one</span>\`}</HTMLBlock></td>
<td><HTMLBlock>{\`<span>two</span>\`}</HTMLBlock></td>
</tr>
</tbody>
</Table>`;

const tree = mdxish(md);

const htmlBlocks = findAllElementsByTagName(tree, 'html-block');
expect(htmlBlocks).toHaveLength(2);
expect(htmlBlocks[0].properties).toMatchObject({ html: '<span>one</span>' });
expect(htmlBlocks[1].properties).toMatchObject({ html: '<span>two</span>' });
});

it('leaves no RDMX_HTMLBLOCK markers or stray comment nodes in the tree', () => {
const md = `<Table>
<tbody>
<tr>
<td>
<HTMLBlock>{\`<div>x</div>\`}</HTMLBlock>
</td>
</tr>
</tbody>
</Table>`;

const tree = mdxish(md);
const serialized = JSON.stringify(tree);

expect(serialized).not.toContain('RDMX_HTMLBLOCK');

const htmlBlock = findElementByTagName(tree, 'html-block') as Element;
expect(htmlBlock.children).toStrictEqual([]);
});
});
});
2 changes: 2 additions & 0 deletions lib/mdxish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
preprocessJSXExpressions,
removeJSXComments,
} from '../processor/transform/mdxish/preprocess-jsx-expressions';
import rehypeHtmlBlocksInJsx from '../processor/transform/mdxish/rehype-html-blocks-in-jsx';
import restoreSnakeCaseComponentNames from '../processor/transform/mdxish/restore-snake-case-component-name';
import {
preserveBooleanProperties,
Expand Down Expand Up @@ -282,6 +283,7 @@ export function mdxish(mdContent: string, opts: MdxishOpts = {}): Root {
.use(rehypeRaw, { passThrough: ['html-block', 'mdx-jsx'] }) // MDX JSX nodes bypass parse5's string-only HTML round-trip
.use(restoreBooleanProperties)
.use(normalizeMdxJsxNodes) // Rewrite `mdx-jsx` back to standard `element` nodes for downstream plugins
.use(rehypeHtmlBlocksInJsx) // Reattach HTMLBlock contents that survived rehypeRaw nested inside JSX blocks
.use(rehypeFlattenTableCellParagraphs) // Remove <p> wrappers inside table cells to prevent margin issues
.use(mdxishMermaidTransformer) // Add mermaid-render className to pre wrappers
.use(generateSlugForHeadings)
Expand Down
74 changes: 74 additions & 0 deletions processor/transform/mdxish/rehype-html-blocks-in-jsx.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { Element, ElementContent, Properties, Root } from 'hast';
import type { Transformer } from 'unified';

import { visit } from 'unist-util-visit';

import { formatHtmlForMdxish } from '../../utils';

import { base64Decode, HTML_BLOCK_CONTENT_END, HTML_BLOCK_CONTENT_START } from './preprocess-jsx-expressions';

const startEscaped = HTML_BLOCK_CONTENT_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const endEscaped = HTML_BLOCK_CONTENT_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Marker emitted as an HTML comment by preprocessJSXExpressions. rehypeRaw parses
// the comment body (sans <!-- -->), so we match the inner form here.
const COMMENT_MARKER_RE = new RegExp(
`^${startEscaped.replace(/^<!--/, '')}([A-Za-z0-9+/=]+)${endEscaped.replace(/-->$/, '')}$`,
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
);

const KNOWN_HTML_BLOCK_PROPS: Record<string, string> = {
safemode: 'safeMode',
runscripts: 'runScripts',
};

function decodeProtectedComment(value: string): string | null {
const match = value.match(COMMENT_MARKER_RE);
if (!match) return null;
try {
return base64Decode(match[1]);
} catch {
return null;
}
}

function findEncodedPayload(children: ElementContent[]): string | null {
return children.reduce<string | null>((found, child) => {
if (found !== null) return found;
if (child.type !== 'comment') return null;
return decodeProtectedComment(child.value);
}, null);
}

function normalizeHtmlBlockProperties(properties: Properties | undefined, html: string): Properties {
const normalized: Properties = { html };
if (!properties) return normalized;

Object.entries(properties).forEach(([key, value]) => {
if (key === 'html') return;
const canonical = KNOWN_HTML_BLOCK_PROPS[key.toLowerCase()] ?? key;
normalized[canonical] = value;
});
return normalized;
}

/**
* Converts <HTMLBlock> elements that survived rehypeRaw (because they were nested
* inside another JSX block like <Table>, so the mdast-level transformer never saw
* them) into the canonical <html-block> hast element the renderer expects.
*/
const rehypeHtmlBlocksInJsx = (): Transformer<Root, Root> => tree => {
visit(tree, 'element', (node: Element) => {
// rehypeRaw routes HTMLBlock through parse5, which lowercases tag names.
if (node.tagName.toLowerCase() !== 'htmlblock') return;

const encoded = findEncodedPayload(node.children ?? []);
if (encoded === null) return;

const html = formatHtmlForMdxish(encoded);

node.tagName = 'html-block';
node.properties = normalizeHtmlBlockProperties(node.properties, html);
node.children = [];
});
};

export default rehypeHtmlBlocksInJsx;