diff --git a/__tests__/lib/stripComments.test.ts b/__tests__/lib/stripComments.test.ts index 6794ab628..4c6864dfb 100644 --- a/__tests__/lib/stripComments.test.ts +++ b/__tests__/lib/stripComments.test.ts @@ -328,6 +328,29 @@ end"`); `); }); + it.each([ + ['mdx', { mdx: true }], + ['mdxish', { mdxish: true }], + ])('preserves comments inside MDX HTMLBlocks (%s)', async (_name, opts) => { + const input = `{\` + +
Hello world
+\`}
`; + const output = await stripComments(input, opts); + expect(output).toContain(''); + }); + + it('preserves HTMLBlock template literal expressions in mdxish mode', async () => { + const input = `{\` +
+ Hello, World! +
+\`}
`; + + const output = await stripComments(input, { mdxish: true }); + expect(output).toBe(input); + }); + it('preserves jsx tables in mdxish mode', async () => { const input = ` diff --git a/lib/stripComments.ts b/lib/stripComments.ts index 5bd204141..e5839a0f8 100644 --- a/lib/stripComments.ts +++ b/lib/stripComments.ts @@ -12,7 +12,8 @@ import { stripCommentsTransformer } from '../processor/transform/stripComments'; import { jsxTableFromMarkdown } from './mdast-util/jsx-table'; import { jsxTable } from './micromark/jsx-table'; -import { extractMagicBlocks, restoreMagicBlocks } from './utils/extractMagicBlocks'; +import { protectHTMLBlockContent, restoreHTMLBlockContent } from './utils/extractors/html-blocks'; +import { extractMagicBlocks, restoreMagicBlocks } from './utils/extractors/magic-blocks'; interface Opts { mdx?: boolean; @@ -23,7 +24,16 @@ interface Opts { * Removes Markdown and MDX comments. */ async function stripComments(doc: string, { mdx, mdxish }: Opts = {}): Promise { - const { replaced, blocks } = extractMagicBlocks(doc); + // Preprocessing step: Don't touch magic blocks and HTML block content + let preprocessedDoc = doc; + const { replaced, blocks } = extractMagicBlocks(preprocessedDoc); + preprocessedDoc = replaced; + + // We only need to protect HTML block content if we're in MDXish mode + // MDX has correctly handled them + if (mdxish) { + preprocessedDoc = protectHTMLBlockContent(preprocessedDoc); + } const processor = unified(); @@ -75,10 +85,15 @@ async function stripComments(doc: string, { mdx, mdxish }: Opts = {}): Promise{` ... `}` + */ +export const HTMLBLOCK_TEMPLATE_LITERAL_REGEX = /(]*>)\{\s*`((?:[^`\\]|\\.)*)`\s*\}(<\/HTMLBlock>)/g; + +/** + * Base64 encodes HTMLBlock template literal content to prevent markdown parser from consuming `}'; + * protectHTMLBlockContent(input) + * // Returns: 'RDMX-!#@-HTMLBLOCK-START%:PHNjcmlwdD5hbGVydCgieHNzIik8L3NjcmlwdD4=:%RDMX-!#@-HTMLBLOCK-END' + * ``` + */ +export function protectHTMLBlockContent(content: string) { + return content.replace( + HTMLBLOCK_TEMPLATE_LITERAL_REGEX, + (_match, openTag: string, templateContent: string, closeTag: string) => { + const encoded = base64Encode(templateContent); + return `${openTag}${HTML_BLOCK_CONTENT_START}${encoded}${HTML_BLOCK_CONTENT_END}${closeTag}`; + }, + ); +} + +/** + * Restores HTMLBlock content that was protected by `protectHTMLBlockContent`. + * When `withBackticks` is true, re-wraps the decoded body as `{`...`}` (used by `stripComments` to preserve + * original markup). When false or omitted, inserts decoded HTML only (typical for transformers). + * + * @param content + * @param withBackticks + * @returns Content with protected markers replaced by decoded HTML, optionally wrapped in template literal syntax + * @example + * ```typescript + * const input = + * 'RMDX-!#@-HTMLBLOCK-START%:PHNjcmlwdD5hbGVydCgieHNzIik8L3NjcmlwdD4=:RMDX-!#@-HTMLBLOCK-END'; + * restoreHTMLBlockContent(input, true); + * // Returns: '{``}' + * restoreHTMLBlockContent(input); + * // Returns: '' + * ``` + */ +export function restoreHTMLBlockContent(content: string, withBackticks?: boolean) { + const markerRegex = new RegExp(`${HTML_BLOCK_CONTENT_START}([A-Za-z0-9+/=]+)${HTML_BLOCK_CONTENT_END}`, 'g'); + return content.replace(markerRegex, (_match, encoded: string) => { + try { + const decoded = base64Decode(encoded); + return withBackticks ? `{\`${decoded}\`}` : decoded; + } catch { + return encoded; + } + }); +} diff --git a/lib/utils/extractMagicBlocks.ts b/lib/utils/extractors/magic-blocks.ts similarity index 100% rename from lib/utils/extractMagicBlocks.ts rename to lib/utils/extractors/magic-blocks.ts diff --git a/processor/transform/mdxish/mdxish-html-blocks.ts b/processor/transform/mdxish/mdxish-html-blocks.ts index 26398b11f..b14f4af53 100644 --- a/processor/transform/mdxish/mdxish-html-blocks.ts +++ b/processor/transform/mdxish/mdxish-html-blocks.ts @@ -5,28 +5,9 @@ import type { Transform } from 'mdast-util-from-markdown'; import { visit } from 'unist-util-visit'; import { NodeTypes } from '../../../enums'; +import { restoreHTMLBlockContent } from '../../../lib/utils/extractors/html-blocks'; import { formatHtmlForMdxish } from '../../utils'; -import { base64Decode, HTML_BLOCK_CONTENT_END, HTML_BLOCK_CONTENT_START } from './preprocess-jsx-expressions'; - -/** - * Decodes HTMLBlock content that was protected during preprocessing. - * Content is wrapped in - */ -function decodeProtectedContent(content: string): string { - // Escape special regex characters in the markers - const startEscaped = HTML_BLOCK_CONTENT_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const endEscaped = HTML_BLOCK_CONTENT_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const markerRegex = new RegExp(`${startEscaped}([A-Za-z0-9+/=]+)${endEscaped}`, 'g'); - return content.replace(markerRegex, (_match, encoded: string) => { - try { - return base64Decode(encoded); - } catch { - return encoded; - } - }); -} - /** * Collects text content from a node and its children recursively */ @@ -205,7 +186,7 @@ const mdxishHtmlBlocks = (): Transform => tree => { let content = contentParts.join(''); content = content.replace(/^]*>\s*\{?\s*`?/, '').replace(/`?\s*\}?\s*<\/HTMLBlock>$/, ''); // Decode protected content that was base64 encoded during preprocessing - content = decodeProtectedContent(content); + content = restoreHTMLBlockContent(content); const htmlString = formatHtmlForMdxish(content); const runScripts = extractRunScriptsAttr(attrs); @@ -241,7 +222,7 @@ const mdxishHtmlBlocks = (): Transform => tree => { // Remove template literal syntax if present: {`...`} content = content.replace(/^\s*\{\s*`/, '').replace(/`\s*\}\s*$/, ''); // Decode protected content that was base64 encoded during preprocessing - content = decodeProtectedContent(content); + content = restoreHTMLBlockContent(content); const htmlString = formatHtmlForMdxish(content); const runScripts = extractRunScriptsAttr(attrs); @@ -286,7 +267,7 @@ const mdxishHtmlBlocks = (): Transform => tree => { } // Decode protected content that was base64 encoded during preprocessing - const decodedContent = decodeProtectedContent(contentParts.join('')); + const decodedContent = restoreHTMLBlockContent(contentParts.join('')); const htmlString = formatHtmlForMdxish(decodedContent); const runScripts = extractRunScriptsAttr(value); const safeMode = extractBooleanAttr(value, 'safeMode'); @@ -356,7 +337,7 @@ const mdxishHtmlBlocks = (): Transform => tree => { } // Decode protected content that was base64 encoded during preprocessing - const decodedContent = decodeProtectedContent(templateContent.join('')); + const decodedContent = restoreHTMLBlockContent(templateContent.join('')); const htmlString = formatHtmlForMdxish(decodedContent); const runScripts = openingTag.value ? extractRunScriptsAttr(openingTag.value) : undefined; diff --git a/processor/transform/mdxish/preprocess-jsx-expressions.ts b/processor/transform/mdxish/preprocess-jsx-expressions.ts index a274f70c0..0fca5ae42 100644 --- a/processor/transform/mdxish/preprocess-jsx-expressions.ts +++ b/processor/transform/mdxish/preprocess-jsx-expressions.ts @@ -1,4 +1,5 @@ import { JSX_COMMENT_REGEX } from '../../../lib/micromark/jsx-comment/pattern'; +import { protectHTMLBlockContent } from '../../../lib/utils/extractors/html-blocks'; import { type ProtectedCode, protectCodeBlocks, @@ -6,22 +7,6 @@ import { restoreInlineCode, } from '../../../lib/utils/mdxish/protect-code-blocks'; -// Base64 encode (Node.js + browser compatible) -function base64Encode(str: string): string { - if (typeof Buffer !== 'undefined') { - return Buffer.from(str, 'utf-8').toString('base64'); - } - return btoa(unescape(encodeURIComponent(str))); -} - -// Base64 decode (Node.js + browser compatible) -export function base64Decode(str: string): string { - if (typeof Buffer !== 'undefined') { - return Buffer.from(str, 'base64').toString('utf-8'); - } - return decodeURIComponent(escape(atob(str))); -} - function escapeHtmlAttribute(value: string): string { return value .replace(/&/g, '&') @@ -35,10 +20,6 @@ function escapeHtmlAttribute(value: string): string { // Using a prefix that won't conflict with regular string values export const JSON_VALUE_MARKER = '__MDXISH_JSON__'; -// Markers for protected HTMLBlock content (HTML comments avoid markdown parsing issues) -export const HTML_BLOCK_CONTENT_START = ''; - /** * Pre-processes JSX-like expressions before markdown parsing. * Converts href={'value'} to href="value", evaluates {expressions}, etc. @@ -66,28 +47,6 @@ export function evaluateExpression(expression: string, context: JSXContext) { return func(...contextValues); } -/** - * Base64 encodes HTMLBlock template literal content to prevent markdown parser from consuming `}'; - * protectHTMLBlockContent(input) - * // Returns: '' - * ``` - */ -function protectHTMLBlockContent(content: string): string { - return content.replace( - /(]*>)\{\s*`((?:[^`\\]|\\.)*)`\s*\}(<\/HTMLBlock>)/g, - (_match, openTag: string, templateContent: string, closeTag: string) => { - const encoded = base64Encode(templateContent); - return `${openTag}${HTML_BLOCK_CONTENT_START}${encoded}${HTML_BLOCK_CONTENT_END}${closeTag}`; - }, - ); -} - /** * Removes JSX-style comments (e.g., { /* comment *\/ }) from content. *