From 27c7a48f3a3324285145ccfaddd9957266905544 Mon Sep 17 00:00:00 2001 From: Maximilian Falco Widjaya Date: Thu, 16 Apr 2026 15:23:10 +1000 Subject: [PATCH 01/12] feat: add new htmlblock tokenizer --- lib/mdast-util/html-block-component/index.ts | 54 ++++ lib/micromark/html-block-component/index.ts | 1 + lib/micromark/html-block-component/syntax.ts | 288 +++++++++++++++++++ 3 files changed, 343 insertions(+) create mode 100644 lib/mdast-util/html-block-component/index.ts create mode 100644 lib/micromark/html-block-component/index.ts create mode 100644 lib/micromark/html-block-component/syntax.ts diff --git a/lib/mdast-util/html-block-component/index.ts b/lib/mdast-util/html-block-component/index.ts new file mode 100644 index 000000000..d2d27240b --- /dev/null +++ b/lib/mdast-util/html-block-component/index.ts @@ -0,0 +1,54 @@ +import type { Html } from 'mdast'; +import type { CompileContext, Extension as FromMarkdownExtension, Handle, Token } from 'mdast-util-from-markdown'; + +const contextMap = new WeakMap(); + +function findHtmlBlockComponentToken(this: CompileContext): Token | undefined { + const events = this.tokenStack; + for (let i = events.length - 1; i >= 0; i -= 1) { + if (events[i][0].type === 'htmlBlockComponent') return events[i][0]; + } + return undefined; +} + +// Produces an MDAST `html` node, mdxishHtmlBlocks then transforms it into an `html-block` node +function enterHtmlBlockComponent(this: CompileContext, token: Parameters[0]): void { + contextMap.set(token, { chunks: [], lastEndLine: token.start.line }); + this.enter({ type: 'html', value: '' } as Html, token); +} + +function exitHtmlBlockComponentData(this: CompileContext, token: Parameters[0]): void { + const componentToken = findHtmlBlockComponentToken.call(this); + if (!componentToken) return; + const ctx = contextMap.get(componentToken); + if (ctx) { + const gap = token.start.line - ctx.lastEndLine; + if (ctx.chunks.length > 0 && gap > 0) { + ctx.chunks.push('\n'.repeat(gap)); + } + ctx.chunks.push(this.sliceSerialize(token)); + ctx.lastEndLine = token.end.line; + } +} + +function exitHtmlBlockComponent(this: CompileContext, token: Parameters[0]): void { + const ctx = contextMap.get(token); + const node = this.stack[this.stack.length - 1] as Html; + if (ctx) { + node.value = ctx.chunks.join(''); + contextMap.delete(token); + } + this.exit(token); +} + +export function htmlBlockComponentFromMarkdown(): FromMarkdownExtension { + return { + enter: { + htmlBlockComponent: enterHtmlBlockComponent, + }, + exit: { + htmlBlockComponentData: exitHtmlBlockComponentData, + htmlBlockComponent: exitHtmlBlockComponent, + }, + }; +} diff --git a/lib/micromark/html-block-component/index.ts b/lib/micromark/html-block-component/index.ts new file mode 100644 index 000000000..1a86842bd --- /dev/null +++ b/lib/micromark/html-block-component/index.ts @@ -0,0 +1 @@ +export { htmlBlockComponent } from './syntax'; diff --git a/lib/micromark/html-block-component/syntax.ts b/lib/micromark/html-block-component/syntax.ts new file mode 100644 index 000000000..2a76cdec8 --- /dev/null +++ b/lib/micromark/html-block-component/syntax.ts @@ -0,0 +1,288 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import type { Code, Construct, Effects, Extension, Resolver, State, TokenizeContext } from 'micromark-util-types'; + +import { markdownLineEnding } from 'micromark-util-character'; +import { codes, types } from 'micromark-util-symbol'; + +declare module 'micromark-util-types' { + interface TokenTypeMap { + htmlBlockComponent: 'htmlBlockComponent'; + htmlBlockComponentData: 'htmlBlockComponentData'; + } +} + +const TAG_SUFFIX: Code[] = [ + codes.uppercaseT, + codes.uppercaseM, + codes.uppercaseL, + codes.uppercaseB, + codes.lowercaseL, + codes.lowercaseO, + codes.lowercaseC, + codes.lowercaseK, +]; + +// --------------------------------------------------------------------------- +// Shared tokenizer factory +// --------------------------------------------------------------------------- + +/** + * Creates a tokenize function for `...`. + * + * - **flow** (block-level): supports multiline content via line continuations, + * consumes trailing whitespace after the closing tag. + * - **text** (inline): single-line only, exits immediately after the closing tag. + */ +function createTokenize(mode: 'flow' | 'text') { + return function tokenize(this: TokenizeContext, effects: Effects, ok: State, nok: State) { + let depth = 1; + + function matchChars(chars: Code[], onMatch: State, onFail: (code: Code) => State | undefined): State { + if (chars.length === 0) return onMatch; + return ((code: Code): State | undefined => { + if (code === chars[0]) { + effects.consume(code); + return matchChars(chars.slice(1), onMatch, onFail); + } + return onFail(code); + }) as State; + } + + function matchTagName(onMatch: State, onFail: (code: Code) => State | undefined): State { + return ((code: Code): State | undefined => { + if (code === codes.uppercaseH) { + effects.consume(code); + return matchChars(TAG_SUFFIX, onMatch, onFail); + } + return onFail(code); + }) as State; + } + + return start; + + function start(code: Code): State | undefined { + if (code !== codes.lessThan) return nok(code); + effects.enter('htmlBlockComponent'); + effects.enter('htmlBlockComponentData'); + effects.consume(code); + return matchTagName(afterTagName, nok); + } + + function afterTagName(code: Code): State | undefined { + if (code === codes.greaterThan) { + effects.consume(code); + return body; + } + if (code === codes.space || code === codes.horizontalTab) { + effects.consume(code); + return inAttributes; + } + if (code === codes.slash) { + effects.consume(code); + return selfClose; + } + return nok(code); + } + + function inAttributes(code: Code): State | undefined { + if (code === codes.greaterThan) { + effects.consume(code); + return body; + } + if (code === null || markdownLineEnding(code)) { + return nok(code); + } + effects.consume(code); + return inAttributes; + } + + function selfClose(code: Code): State | undefined { + if (code === codes.greaterThan) { + effects.consume(code); + return mode === 'flow' ? afterClose : done(code); + } + return nok(code); + } + + function body(code: Code): State | undefined { + if (code === null) return nok(code); + + if (markdownLineEnding(code)) { + if (mode === 'text') return nok(code); + effects.exit('htmlBlockComponentData'); + return continuationStart(code); + } + + if (code === codes.lessThan) { + effects.consume(code); + return closeSlash; + } + + effects.consume(code); + return body; + } + + function closeSlash(code: Code): State | undefined { + if (code === codes.slash) { + effects.consume(code); + return matchTagName(closeGt, body); + } + return matchTagName(openAfterTagName, body)(code); + } + + function openAfterTagName(code: Code): State | undefined { + if (code === codes.greaterThan || code === codes.space || code === codes.horizontalTab) { + depth += 1; + effects.consume(code); + return body; + } + return body(code); + } + + function closeGt(code: Code): State | undefined { + if (code === codes.greaterThan) { + depth -= 1; + effects.consume(code); + if (depth === 0) { + return mode === 'flow' ? afterClose : done(code); + } + return body; + } + return body(code); + } + + // -- flow-only states --------------------------------------------------- + + function afterClose(code: Code): State | undefined { + if (code === null || markdownLineEnding(code)) { + return done(code); + } + effects.consume(code); + return afterClose; + } + + function continuationStart(code: Code): State | undefined { + return effects.check(nonLazyContinuationStart, continuationStartNonLazy, continuationAfter)(code); + } + + function continuationStartNonLazy(code: Code): State | undefined { + effects.enter(types.lineEnding); + effects.consume(code); + effects.exit(types.lineEnding); + return continuationBefore; + } + + function continuationBefore(code: Code): State | undefined { + if (code === null || markdownLineEnding(code)) { + return continuationStart(code); + } + effects.enter('htmlBlockComponentData'); + return body(code); + } + + function continuationAfter(code: Code): State | undefined { + if (code === null) return nok(code); + effects.exit('htmlBlockComponent'); + return ok(code); + } + + // -- shared exit -------------------------------------------------------- + + function done(_code: Code): State | undefined { + effects.exit('htmlBlockComponentData'); + effects.exit('htmlBlockComponent'); + return ok(_code); + } + }; +} + +// --------------------------------------------------------------------------- +// Flow construct (block-level) +// --------------------------------------------------------------------------- + +const nonLazyContinuationStart: Construct = { + tokenize: tokenizeNonLazyContinuationStart, + partial: true, +}; + +function resolveToHtmlBlockComponent(events: Parameters[0]) { + let index = events.length; + + while (index > 0) { + index -= 1; + if (events[index][0] === 'enter' && events[index][1].type === 'htmlBlockComponent') { + break; + } + } + + if (index > 1 && events[index - 2][1].type === types.linePrefix) { + events[index][1].start = events[index - 2][1].start; + events[index + 1][1].start = events[index - 2][1].start; + events.splice(index - 2, 2); + } + + return events; +} + +const htmlBlockComponentFlowConstruct: Construct = { + name: 'htmlBlockComponent', + tokenize: createTokenize('flow'), + resolveTo: resolveToHtmlBlockComponent, + concrete: true, +}; + +function tokenizeNonLazyContinuationStart(this: TokenizeContext, effects: Effects, ok: State, nok: State) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + + return start; + + function start(code: Code): State | undefined { + if (markdownLineEnding(code)) { + effects.enter(types.lineEnding); + effects.consume(code); + effects.exit(types.lineEnding); + return after; + } + return nok(code); + } + + function after(code: Code): State | undefined { + if (self.parser.lazy[self.now().line]) { + return nok(code); + } + return ok(code); + } +} + +// --------------------------------------------------------------------------- +// Text construct (inline) +// --------------------------------------------------------------------------- + +const htmlBlockComponentTextConstruct: Construct = { + name: 'htmlBlockComponent', + tokenize: createTokenize('text'), +}; + +// --------------------------------------------------------------------------- +// Extension +// --------------------------------------------------------------------------- + +/** + * Micromark extension that tokenizes `...` as a single + * token at both flow (block) and text (inline) levels. + * + * Prevents the 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. * @@ -336,16 +294,13 @@ function evaluateAttributeExpressions(content: string, context: JSXContext, prot * @returns Preprocessed content ready for markdown parsing */ export function preprocessJSXExpressions(content: string, context: JSXContext = {}): string { - // Step 0: Base64 encode HTMLBlock content - let processed = protectHTMLBlockContent(content); - // Step 1: Protect code blocks and inline code - const { protectedCode, protectedContent } = protectCodeBlocks(processed); + const { protectedCode, protectedContent } = protectCodeBlocks(content); // Step 2: Evaluate attribute expressions (JSX attribute syntax: href={baseUrl}) // For inline expressions, we use a library to parse the expression & evaluate it later // For attribute expressions, it was difficult to use a library to parse them, so do it manually - processed = evaluateAttributeExpressions(protectedContent, context, protectedCode); + let processed = evaluateAttributeExpressions(protectedContent, context, protectedCode); // Step 3: Escape problematic braces to prevent MDX expression parsing errors // This handles both unbalanced braces and paragraph-spanning expressions in one pass From 631aebd9ed62bd2c2f1f537af928349e57ee1d01 Mon Sep 17 00:00:00 2001 From: Maximilian Falco Widjaya Date: Thu, 16 Apr 2026 15:25:02 +1000 Subject: [PATCH 03/12] fix: simplify mdxishHtmlBlocks transformer we can simplify it significantly since the tokenizer now ensures a single shape coming in --- .../transform/mdxish/mdxish-html-blocks.ts | 321 +----------------- 1 file changed, 16 insertions(+), 305 deletions(-) diff --git a/processor/transform/mdxish/mdxish-html-blocks.ts b/processor/transform/mdxish/mdxish-html-blocks.ts index 26398b11f..dbc6b1720 100644 --- a/processor/transform/mdxish/mdxish-html-blocks.ts +++ b/processor/transform/mdxish/mdxish-html-blocks.ts @@ -1,5 +1,5 @@ import type { HTMLBlock } from '../../../types'; -import type { Paragraph, Parent } from 'mdast'; +import type { Parent } from 'mdast'; import type { Transform } from 'mdast-util-from-markdown'; import { visit } from 'unist-util-visit'; @@ -7,58 +7,6 @@ import { visit } from 'unist-util-visit'; import { NodeTypes } from '../../../enums'; 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 - */ -function collectTextContent(node: { children?: unknown[]; lang?: string; type?: string; value?: string }): string { - const parts: string[] = []; - - if (node.type === 'text' && node.value) { - parts.push(node.value); - } else if (node.type === 'html' && node.value) { - parts.push(node.value); - } else if (node.type === 'inlineCode' && node.value) { - parts.push(node.value); - } else if (node.type === 'code' && node.value) { - // Reconstruct code fence syntax (markdown parser consumes opening ```) - const lang = node.lang || ''; - const fence = `\`\`\`${lang ? `${lang}\n` : ''}`; - parts.push(fence); - parts.push(node.value); - // Add newline before closing fence if missing - const closingFence = node.value.endsWith('\n') ? '```' : '\n```'; - parts.push(closingFence); - } else if (node.children && Array.isArray(node.children)) { - node.children.forEach(child => { - if (typeof child === 'object' && child !== null) { - parts.push(collectTextContent(child as { children?: unknown[]; lang?: string; type?: string; value?: string })); - } - }); - } - - return parts.join(''); -} - /** * Extracts boolean attribute from HTML tag. Handles JSX (safeMode={true}) and string (safeMode="true") syntax. * Returns "true"/"false" string to survive rehypeRaw serialization. @@ -120,270 +68,33 @@ function createHTMLBlockNode( } /** - * Checks for opening tag only (for split detection) - */ -function hasOpeningTagOnly(node: { children?: unknown[]; type?: string; value?: string }): { - attrs: string; - found: boolean; -} { - let hasOpening = false; - let hasClosed = false; - let attrs = ''; - - const check = (n: { children?: unknown[]; type?: string; value?: string }) => { - if (n.type === 'html' && n.value) { - if (n.value === '') { - hasOpening = true; - } else { - const match = n.value.match(/^]*)?>$/); - if (match) { - hasOpening = true; - attrs = match[1] || ''; - } - } - if (n.value === '' || n.value.includes('')) { - hasClosed = true; - } - } - if (n.children && Array.isArray(n.children)) { - n.children.forEach(child => { - check(child as { children?: unknown[]; type?: string; value?: string }); - }); - } - }; - - check(node); - // Return true only if opening without closing (split case) - return { attrs, found: hasOpening && !hasClosed }; -} - -/** - * Checks if a node contains an HTMLBlock closing tag - */ -function hasClosingTag(node: { children?: unknown[]; type?: string; value?: string }): boolean { - if (node.type === 'html' && node.value) { - if (node.value === '' || node.value.includes('')) return true; - } - if (node.children && Array.isArray(node.children)) { - return node.children.some(child => hasClosingTag(child as { children?: unknown[]; type?: string; value?: string })); - } - return false; -} - -/** - * Transforms HTMLBlock MDX JSX to html-block nodes. Handles {`...`} syntax. + * Transforms HTMLBlock syntax to html-block MDAST nodes. + * + * The htmlBlockComponent micromark tokenizer captures `...` + * as single `html` nodes at both flow and text levels. This transformer converts + * those nodes into `html-block` MDAST nodes with extracted content and attributes. */ const mdxishHtmlBlocks = (): Transform => tree => { - // Handle HTMLBlock split across root children (caused by newlines) - visit(tree, 'root', (root: Parent) => { - const children = root.children; - let i = 0; - - while (i < children.length) { - const child = children[i] as { children?: unknown[]; type?: string; value?: string }; - const { attrs, found: hasOpening } = hasOpeningTagOnly(child); - - if (hasOpening) { - // Find closing tag in subsequent siblings - let closingIdx = -1; - for (let j = i + 1; j < children.length; j += 1) { - if (hasClosingTag(children[j] as { children?: unknown[]; type?: string; value?: string })) { - closingIdx = j; - break; - } - } - - if (closingIdx !== -1) { - // Collect inner content between tags - const contentParts: string[] = []; - for (let j = i; j <= closingIdx; j += 1) { - const node = children[j] as { children?: unknown[]; type?: string; value?: string }; - contentParts.push(collectTextContent(node)); - } - - // Remove the opening/closing tags and template literal syntax from content - 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); - - const htmlString = formatHtmlForMdxish(content); - const runScripts = extractRunScriptsAttr(attrs); - const safeMode = extractBooleanAttr(attrs, 'safeMode'); - - // Replace range with single HTMLBlock node - const mdNode = createHTMLBlockNode( - htmlString, - (children[i] as { position?: unknown }).position as HTMLBlock['position'], - runScripts, - safeMode, - ); - root.children.splice(i, closingIdx - i + 1, mdNode); - } - } - i += 1; - } - }); - - // Handle HTMLBlock parsed as HTML elements (when template literal contains block-level HTML tags) visit(tree, 'html', (node, index, parent: Parent | undefined) => { if (!parent || index === undefined) return; const value = (node as { value?: string }).value; if (!value) return; - // Case 1: Full HTMLBlock in single node - const fullMatch = value.match(/^]*)?>([\s\S]*)<\/HTMLBlock>$/); - if (fullMatch) { - const attrs = fullMatch[1] || ''; - let content = fullMatch[2] || ''; - - // 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); - - const htmlString = formatHtmlForMdxish(content); - const runScripts = extractRunScriptsAttr(attrs); - const safeMode = extractBooleanAttr(attrs, 'safeMode'); - - parent.children[index] = createHTMLBlockNode(htmlString, node.position, runScripts, safeMode); - return; - } - - // Case 2: Opening tag only (split by blank lines) - if (value === '' || value.match(/^]*>$/)) { - const siblings = parent.children; - let closingIdx = -1; - - // Find closing tag in siblings - for (let i = index + 1; i < siblings.length; i += 1) { - const sibling = siblings[i]; - if (sibling.type === 'html') { - const sibVal = (sibling as { value?: string }).value; - if (sibVal === '' || sibVal?.includes('')) { - closingIdx = i; - break; - } - } - } - - if (closingIdx === -1) return; - - // Collect content between tags, skipping template literal delimiters - const contentParts: string[] = []; - for (let i = index + 1; i < closingIdx; i += 1) { - const sibling = siblings[i]; - // Skip template literal delimiters - if (sibling.type === 'text') { - const textVal = (sibling as { value?: string }).value; - if (textVal === '{' || textVal === '}' || textVal === '{`' || textVal === '`}') { - // eslint-disable-next-line no-continue - continue; - } - } - contentParts.push(collectTextContent(sibling as { children?: unknown[]; type?: string; value?: string })); - } - - // Decode protected content that was base64 encoded during preprocessing - const decodedContent = decodeProtectedContent(contentParts.join('')); - const htmlString = formatHtmlForMdxish(decodedContent); - const runScripts = extractRunScriptsAttr(value); - const safeMode = extractBooleanAttr(value, 'safeMode'); + const fullMatch = value.match(/^]*)?>([\s\S]*)<\/HTMLBlock>\s*$/); + if (!fullMatch) return; - // Replace opening tag with HTMLBlock node, remove consumed siblings - parent.children[index] = createHTMLBlockNode(htmlString, node.position, runScripts, safeMode); - parent.children.splice(index + 1, closingIdx - index); - } - }); - - // Handle HTMLBlock inside paragraphs (parsed as inline elements) - visit(tree, 'paragraph', (node: Paragraph, index, parent: Parent | undefined) => { - if (!parent || index === undefined) return; - - const children = node.children || []; - - let htmlBlockStartIdx = -1; - let htmlBlockEndIdx = -1; - let templateLiteralStartIdx = -1; - let templateLiteralEndIdx = -1; - - for (let i = 0; i < children.length; i += 1) { - const child = children[i]; - - if (child.type === 'html' && typeof (child as { value?: string }).value === 'string') { - const value = (child as { value: string }).value; - if (value === '' || value.match(/^]*>$/)) { - htmlBlockStartIdx = i; - } else if (value === '') { - htmlBlockEndIdx = i; - } - } + const attrs = fullMatch[1] || ''; + let content = fullMatch[2] || ''; - // Find opening brace after HTMLBlock start - if (htmlBlockStartIdx !== -1 && templateLiteralStartIdx === -1 && child.type === 'text') { - const value = (child as { value?: string }).value; - if (value === '{') { - templateLiteralStartIdx = i; - } - } + // Remove template literal syntax if present: {`...`} + content = content.replace(/^\s*\{\s*`/, '').replace(/`\s*\}\s*$/, ''); - // Find closing brace before HTMLBlock end - if (htmlBlockStartIdx !== -1 && htmlBlockEndIdx === -1 && child.type === 'text') { - const value = (child as { value?: string }).value; - if (value === '}') { - templateLiteralEndIdx = i; - } - } - } - - if ( - htmlBlockStartIdx !== -1 && - htmlBlockEndIdx !== -1 && - templateLiteralStartIdx !== -1 && - templateLiteralEndIdx !== -1 && - templateLiteralStartIdx < templateLiteralEndIdx - ) { - const openingTag = children[htmlBlockStartIdx] as { value?: string }; - - // Collect content between braces (handles code blocks) - const templateContent: string[] = []; - for (let i = templateLiteralStartIdx + 1; i < templateLiteralEndIdx; i += 1) { - const child = children[i]; - templateContent.push( - collectTextContent(child as { children?: unknown[]; lang?: string; type?: string; value?: string }), - ); - } - - // Decode protected content that was base64 encoded during preprocessing - const decodedContent = decodeProtectedContent(templateContent.join('')); - const htmlString = formatHtmlForMdxish(decodedContent); - - const runScripts = openingTag.value ? extractRunScriptsAttr(openingTag.value) : undefined; - const safeMode = openingTag.value ? extractBooleanAttr(openingTag.value, 'safeMode') : undefined; - - const mdNode = createHTMLBlockNode(htmlString, node.position, runScripts, safeMode); - - parent.children[index] = mdNode; - } - }); + const htmlString = formatHtmlForMdxish(content); + const runScripts = extractRunScriptsAttr(attrs); + const safeMode = extractBooleanAttr(attrs, 'safeMode'); - // Ensure html-block nodes have HTML in children as text node - visit(tree, 'html-block', (node: HTMLBlock) => { - const html = node.data?.hProperties?.html; - if ( - html && - (!node.children || - node.children.length === 0 || - (node.children.length === 1 && node.children[0].type === 'text' && node.children[0].value !== html)) - ) { - node.children = [ - { - type: 'text', - value: html, - }, - ]; - } + parent.children[index] = createHTMLBlockNode(htmlString, node.position, runScripts, safeMode); }); return tree; From 3001457bf25481fbbf7c6fae9a46b593d1903843 Mon Sep 17 00:00:00 2001 From: Maximilian Falco Widjaya Date: Thu, 16 Apr 2026 18:21:34 +1000 Subject: [PATCH 04/12] fix: preserve trailing text after closing tag --- lib/micromark/html-block-component/syntax.ts | 26 ++++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/lib/micromark/html-block-component/syntax.ts b/lib/micromark/html-block-component/syntax.ts index 2a76cdec8..7cd7c151e 100644 --- a/lib/micromark/html-block-component/syntax.ts +++ b/lib/micromark/html-block-component/syntax.ts @@ -108,9 +108,13 @@ function createTokenize(mode: 'flow' | 'text') { if (code === null) return nok(code); if (markdownLineEnding(code)) { - if (mode === 'text') return nok(code); - effects.exit('htmlBlockComponentData'); - return continuationStart(code); + if (mode === 'flow') { + effects.exit('htmlBlockComponentData'); + return continuationStart(code); + } + // text constructs can span paragraph lines + effects.consume(code); + return body; } if (code === codes.lessThan) { @@ -157,10 +161,22 @@ function createTokenize(mode: 'flow' | 'text') { if (code === null || markdownLineEnding(code)) { return done(code); } - effects.consume(code); - return afterClose; + if (code === codes.space || code === codes.horizontalTab) { + effects.consume(code); + return afterClose; + } + // Reject so the block re-parses as a paragraph, deferring to the + // text tokenizer which preserves trailing content in the same line. + // + // {`Hello, World!`}. HI + // + // Without this, the flow construct would consume "HI". + return nok(code); } + // -- flow-only: line continuation --------------------------------------- + function continuationStart(code: Code): State | undefined { return effects.check(nonLazyContinuationStart, continuationStartNonLazy, continuationAfter)(code); } From fb6450e2bf2da074f3eaefe1aae95d2682d44cef Mon Sep 17 00:00:00 2001 From: Maximilian Falco Widjaya Date: Thu, 16 Apr 2026 18:52:57 +1000 Subject: [PATCH 05/12] fix: allow htmlblock opening tag to span multiple lines --- lib/micromark/html-block-component/syntax.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/micromark/html-block-component/syntax.ts b/lib/micromark/html-block-component/syntax.ts index 7cd7c151e..7ba81ac8d 100644 --- a/lib/micromark/html-block-component/syntax.ts +++ b/lib/micromark/html-block-component/syntax.ts @@ -73,7 +73,7 @@ function createTokenize(mode: 'flow' | 'text') { effects.consume(code); return body; } - if (code === codes.space || code === codes.horizontalTab) { + if (code === codes.space || code === codes.horizontalTab || markdownLineEnding(code)) { effects.consume(code); return inAttributes; } @@ -89,7 +89,7 @@ function createTokenize(mode: 'flow' | 'text') { effects.consume(code); return body; } - if (code === null || markdownLineEnding(code)) { + if (code === null) { return nok(code); } effects.consume(code); From d8043242f5c0ef65faa180e1c70261634de39bdc Mon Sep 17 00:00:00 2001 From: Maximilian Falco Widjaya Date: Thu, 16 Apr 2026 19:02:57 +1000 Subject: [PATCH 06/12] chore: add tests --- __tests__/compilers/html-block.test.ts | 401 ++++++++++++++++++++----- 1 file changed, 327 insertions(+), 74 deletions(-) diff --git a/__tests__/compilers/html-block.test.ts b/__tests__/compilers/html-block.test.ts index 6f739bb64..8ba2a52b2 100644 --- a/__tests__/compilers/html-block.test.ts +++ b/__tests__/compilers/html-block.test.ts @@ -65,18 +65,16 @@ describe('mdxish html-block compiler', () => { const hast = mdxish(markdown.trim()); const callout = hast.children[0] as Element; - expect(callout.type).toBe('element'); expect(callout.tagName).toBe('Callout'); - // Find HTMLBlock within the callout const htmlBlock = findHTMLBlock(callout); - expect(htmlBlock).toBeDefined(); expect(htmlBlock?.tagName).toBe('html-block'); + expect(htmlBlock?.properties?.html).toBe(' Hello, World!'); + expect(htmlBlock?.properties?.html).not.toMatch(/^>/m); }); it('compiles html blocks preserving newlines', () => { - const markdown = ` -{\` + const markdown = `{\`

 const foo = () => {
   const bar = {
@@ -86,103 +84,358 @@ const foo = () => {
   return bar
 }
 
-\`}
-`; - - const hast = mdxish(markdown.trim()); - const paragraph = hast.children[0] as Element; +\`}
`; - expect(paragraph.type).toBe('element'); - const htmlBlock = findHTMLBlock(paragraph); - expect(htmlBlock).toBeDefined(); + const hast = mdxish(markdown); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); expect(htmlBlock?.tagName).toBe('html-block'); + + const htmlProp = htmlBlock?.properties?.html as string; + expect(htmlProp).toContain('
');
+    expect(htmlProp).toContain('const foo = () => {');
+    expect(htmlProp).toContain("baz: 'blammo'");
+    expect(htmlProp).toContain('
'); }); it('adds newlines for readability', () => { - const markdown = '{`

Hello, World!

`}
'; - - const hast = mdxish(markdown); - const paragraph = hast.children[0] as Element; - - expect(paragraph.type).toBe('element'); - const htmlBlock = findHTMLBlock(paragraph); - expect(htmlBlock).toBeDefined(); + const hast = mdxish('{`

Hello, World!

`}
'); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); expect(htmlBlock?.tagName).toBe('html-block'); + expect(htmlBlock?.properties?.html).toBe('

Hello, World!

'); }); it('unescapes backticks in HTML content', () => { - const markdown = '{`\\`example\\``}'; + const hast = mdxish('{`\\`example\\``}'); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock?.tagName).toBe('html-block'); + expect(htmlBlock?.properties?.html).toBe('`example`'); + }); - const hast = mdxish(markdown); - const paragraph = hast.children[0] as Element; + it('passes safeMode property correctly', () => { + const hast = mdxish('{`

Content

`}
'); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock?.tagName).toBe('html-block'); + expect(htmlBlock?.properties?.safeMode).toBe('true'); + expect(htmlBlock?.properties?.html).toBe('

Content

'); + }); - expect(paragraph.type).toBe('element'); - const htmlBlock = findHTMLBlock(paragraph); - expect(htmlBlock).toBeDefined(); + it('handles template literal with variables', () => { + // eslint-disable-next-line quotes + const hast = mdxish(`{\`const x = \${variable}\`}`); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); expect(htmlBlock?.tagName).toBe('html-block'); + // eslint-disable-next-line no-template-curly-in-string + expect(htmlBlock?.properties?.html).toBe('const x = ${variable}'); + }); - // Verify that escaped backticks \` are unescaped to ` in the HTML - const htmlProp = htmlBlock?.properties?.html as string; - expect(htmlProp).toBeDefined(); - expect(htmlProp).toContain('`example`'); - expect(htmlProp).not.toContain('\\`'); + it('handles nested template literals', () => { + const hast = mdxish('{`
\\`\\`\\`javascript\\nconst x = 1;\\n\\`\\`\\`
`}
'); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock?.tagName).toBe('html-block'); + expect(htmlBlock?.properties?.html).toBe('
```javascript\nconst x = 1;\n```
'); }); - it('passes safeMode property correctly', () => { - // Test with both JSX expression and string syntax - const markdown = '{`

Content

`}
'; + describe('flow-level (standalone block)', () => { + it('handles simple single-line HTMLBlock', () => { + const hast = mdxish('{`
hello
`}
'); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock?.tagName).toBe('html-block'); + expect(htmlBlock?.properties?.html).toBe('
hello
'); + }); + + it('handles multiline content', () => { + const markdown = `{\` +
    +
  • one
  • +
  • two
  • +
+\`}
`; - const hast = mdxish(markdown); - const paragraph = hast.children[0] as Element; + const hast = mdxish(markdown); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock?.tagName).toBe('html-block'); - expect(paragraph.type).toBe('element'); - const htmlBlock = findHTMLBlock(paragraph); - expect(htmlBlock).toBeDefined(); + const htmlProp = htmlBlock?.properties?.html as string; + expect(htmlProp).toContain('
  • one
  • '); + expect(htmlProp).toContain('
  • two
  • '); + }); - const allProps = htmlBlock?.properties; - expect(allProps).toBeDefined(); + it('handles content with blank lines', () => { + const markdown = `{\` +
    before
    - const safeMode = allProps?.safeMode; - expect(safeMode).toBe('true'); +
    after
    +\`}
    `; - // Verify that html property is still present (for safeMode to render as escaped text) - const htmlProp = allProps?.html as string; - expect(htmlProp).toBeDefined(); - expect(htmlProp).toContain(''); - expect(htmlProp).toContain('

    Content

    '); - }); + const hast = mdxish(markdown); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock?.tagName).toBe('html-block'); - it('should handle template literal with variables', () => { - // eslint-disable-next-line quotes - const markdown = `{\`const x = \${variable}\`}`; + const htmlProp = htmlBlock?.properties?.html as string; + expect(htmlProp).toContain('
    before
    '); + expect(htmlProp).toContain('
    after
    '); + }); - const hast = mdxish(markdown); - const paragraph = hast.children[0] as Element; + it('handles script tags without being consumed by the markdown parser', () => { + const markdown = `{\` + +

    visible

    +\`}
    `; - expect(paragraph.type).toBe('element'); - const htmlBlock = findHTMLBlock(paragraph); - expect(htmlBlock).toBeDefined(); - // eslint-disable-next-line no-template-curly-in-string - expect(htmlBlock?.properties?.html).toBe('const x = ${variable}'); + const hast = mdxish(markdown); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock?.tagName).toBe('html-block'); + + const htmlProp = htmlBlock?.properties?.html as string; + expect(htmlProp).toContain(''); + expect(htmlProp).toContain('

    visible

    '); + }); + + it('handles style tags without being consumed by the markdown parser', () => { + const markdown = `{\` + +

    styled

    +\`}
    `; + + const hast = mdxish(markdown); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock?.tagName).toBe('html-block'); + + const htmlProp = htmlBlock?.properties?.html as string; + expect(htmlProp).toContain(''); + expect(htmlProp).toContain('

    styled

    '); + }); + + it('handles HTMLBlock without template literal syntax', () => { + const hast = mdxish('plain'); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock?.tagName).toBe('html-block'); + expect(htmlBlock?.properties?.html).toBe('plain'); + }); + + it('handles HTMLBlock with runScripts attribute', () => { + const hast = mdxish('{``}'); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock?.tagName).toBe('html-block'); + expect(htmlBlock?.properties?.runScripts).toBe(true); + expect(htmlBlock?.properties?.html).toBe(''); + }); + + it('handles opening tag with attributes on a new line', () => { + const markdown = '{``}'; + const hast = mdxish(markdown); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock?.tagName).toBe('html-block'); + expect(htmlBlock?.properties?.runScripts).toBe(true); + expect(htmlBlock?.properties?.html).toBe(''); + }); + + it('handles trailing whitespace after closing tag', () => { + const hast = mdxish('{`
    hello
    `}
    '); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock?.tagName).toBe('html-block'); + expect(htmlBlock?.properties?.html).toBe('
    hello
    '); + }); }); - it('should handle nested template literals', () => { - // Use a regular string to avoid nested template literal syntax error - // The content should be:
    ```javascript\nconst x = 1;\n```
    - const markdown = '{`
    \\`\\`\\`javascript\\nconst x = 1;\\n\\`\\`\\`
    `}
    '; + describe('text-level (inline)', () => { + it('handles inline HTMLBlock surrounded by text', () => { + const hast = mdxish('before {`middle`} after'); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock?.tagName).toBe('html-block'); + expect(htmlBlock?.properties?.html).toBe('middle'); + }); + + it('handles inline HTMLBlock at the end of a paragraph', () => { + const hast = mdxish('some text {`italic`}'); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock?.tagName).toBe('html-block'); + expect(htmlBlock?.properties?.html).toBe('italic'); + }); + + it('handles inline HTMLBlock with attributes', () => { + const hast = mdxish('text {``} more'); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock?.tagName).toBe('html-block'); + expect(htmlBlock?.properties?.safeMode).toBe('true'); + expect(htmlBlock?.properties?.html).toBe(''); + }); + + it('handles multiline inline HTMLBlock with trailing text', () => { + const markdown = 'hello {`Hello, World!`} world'; + const hast = mdxish(markdown); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock?.tagName).toBe('html-block'); + expect(htmlBlock?.properties?.html).toContain('Hello, World!'); + }); + }); - const hast = mdxish(markdown); - const paragraph = hast.children[0] as Element; + describe('trailing text preservation', () => { + it('preserves trailing text after single-line HTMLBlock', () => { + const hast = mdxish('{`Hello`} trailing'); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock?.tagName).toBe('html-block'); + expect(htmlBlock?.properties?.html).toBe('Hello'); + }); + + it('preserves trailing text after multiline HTMLBlock', () => { + const markdown = '{`Hello`}. trailing'; + const hast = mdxish(markdown); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock?.tagName).toBe('html-block'); + expect(htmlBlock?.properties?.html).toContain('Hello'); + }); + + it('does not crash with trailing text after closing tag', () => { + expect(() => mdxish('{`

    content

    `}
    hello world')).not.toThrow(); + }); + + it('does not crash with trailing punctuation after closing tag', () => { + expect(() => mdxish('{`

    content

    `}
    . stuff')).not.toThrow(); + }); + }); + + describe('inside callouts', () => { + it('handles HTMLBlock in a callout without stray > characters', () => { + const markdown = `> 🚧 It compiles! +> +> {\` +> Hello, World! +> \`}`; - expect(paragraph.type).toBe('element'); - const htmlBlock = findHTMLBlock(paragraph); - expect(htmlBlock).toBeDefined(); + const hast = mdxish(markdown); + const callout = hast.children[0] as Element; + expect(callout.tagName).toBe('Callout'); - // Verify that the HTML content is preserved correctly with newlines - const htmlProp = htmlBlock?.properties?.html as string; - expect(htmlProp).toBeDefined(); + const htmlBlock = findHTMLBlock(callout); + expect(htmlBlock?.tagName).toBe('html-block'); + expect(htmlBlock?.properties?.html).toBe(' Hello, World!'); + }); + + it('handles HTMLBlock in a callout with script tags', () => { + const markdown = `> ⚠️ Warning +> +> {\` +> +>

    safe content

    +> \`}
    `; + + const hast = mdxish(markdown); + const callout = hast.children[0] as Element; + expect(callout.tagName).toBe('Callout'); + + const htmlBlock = findHTMLBlock(callout); + expect(htmlBlock?.tagName).toBe('html-block'); + + const htmlProp = htmlBlock?.properties?.html as string; + expect(htmlProp).toContain(''); + expect(htmlProp).toContain('

    safe content

    '); + expect(htmlProp).not.toMatch(/^>/m); + }); + + it('handles HTMLBlock in a callout with blank lines in content', () => { + const markdown = `> 🚧 Test +> +> {\` +>
    first
    +> +>
    second
    +> \`}
    `; + + const hast = mdxish(markdown); + const callout = hast.children[0] as Element; + expect(callout.tagName).toBe('Callout'); + + const htmlBlock = findHTMLBlock(callout); + expect(htmlBlock?.tagName).toBe('html-block'); + + const htmlProp = htmlBlock?.properties?.html as string; + expect(htmlProp).toContain('
    first
    '); + expect(htmlProp).toContain('
    second
    '); + expect(htmlProp).not.toMatch(/^>/m); + }); + + it('handles HTMLBlock in a callout with trailing whitespace', () => { + const markdown = `> 🚧 It compiles! +> +> {\` +> Hello +> \`}${' '}`; + + const hast = mdxish(markdown); + const callout = hast.children[0] as Element; + expect(callout.tagName).toBe('Callout'); + + const htmlBlock = findHTMLBlock(callout); + expect(htmlBlock?.tagName).toBe('html-block'); + expect(htmlBlock?.properties?.html).toBe(' Hello'); + }); + + it('handles HTMLBlock in an empty callout (no title text)', () => { + const markdown = `> 📘 +> +> {\`

    body only

    \`}
    `; + + const hast = mdxish(markdown); + const callout = hast.children[0] as Element; + expect(callout.tagName).toBe('Callout'); + + const htmlBlock = findHTMLBlock(callout); + expect(htmlBlock?.tagName).toBe('html-block'); + expect(htmlBlock?.properties?.html).toBe('

    body only

    '); + }); + }); + + describe('inside blockquotes', () => { + it('handles single-line HTMLBlock in a blockquote', () => { + const hast = mdxish('> {`

    quoted

    `}
    '); + const bq = hast.children[0] as Element; + expect(bq.tagName).toBe('blockquote'); + + const htmlBlock = findHTMLBlock(bq); + expect(htmlBlock?.tagName).toBe('html-block'); + expect(htmlBlock?.properties?.html).toBe('

    quoted

    '); + }); + + it('handles multiline HTMLBlock in a blockquote without stray > characters', () => { + const markdown = `> {\` +>
    line1
    +>
    line2
    +> \`}
    `; + + const hast = mdxish(markdown); + const bq = hast.children[0] as Element; + expect(bq.tagName).toBe('blockquote'); + + const htmlBlock = findHTMLBlock(bq); + expect(htmlBlock?.tagName).toBe('html-block'); + + const htmlProp = htmlBlock?.properties?.html as string; + expect(htmlProp).toContain('
    line1
    '); + expect(htmlProp).toContain('
    line2
    '); + expect(htmlProp).not.toMatch(/^>/m); + }); + }); - // The expected content should have triple backticks - expect(htmlProp).toBe('
    ```javascript\nconst x = 1;\n```
    '); + describe('inside lists', () => { + it('handles HTMLBlock in an unordered list item', () => { + const hast = mdxish('- {`listed`}'); + const list = hast.children[0] as Element; + expect(list.tagName).toBe('ul'); + + const htmlBlock = findHTMLBlock(list); + expect(htmlBlock?.tagName).toBe('html-block'); + expect(htmlBlock?.properties?.html).toBe('listed'); + }); + + it('handles HTMLBlock in an ordered list item', () => { + const hast = mdxish('1. {`ordered`}'); + const list = hast.children[0] as Element; + expect(list.tagName).toBe('ol'); + + const htmlBlock = findHTMLBlock(list); + expect(htmlBlock?.tagName).toBe('html-block'); + expect(htmlBlock?.properties?.html).toBe('ordered'); + }); }); }); From 18a081d871c0bf488ffb4f2e81b3670055219e67 Mon Sep 17 00:00:00 2001 From: Maximilian Falco Widjaya Date: Thu, 16 Apr 2026 23:34:08 +1000 Subject: [PATCH 07/12] fix: preserve trailing text after closing tag --- components/HTMLBlock/index.tsx | 5 +-- lib/micromark/html-block-component/syntax.ts | 47 +++++++++++++++----- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/components/HTMLBlock/index.tsx b/components/HTMLBlock/index.tsx index ed3ff975a..c73bfbfef 100644 --- a/components/HTMLBlock/index.tsx +++ b/components/HTMLBlock/index.tsx @@ -25,10 +25,7 @@ const HTMLBlock = ({ children = '', html: htmlProp, runScripts, safeMode: safeMo let html: string = ''; if (htmlProp !== undefined) { html = htmlProp; - } else { - if (typeof children !== 'string') { - throw new TypeError('HTMLBlock: children must be a string'); - } + } else if (typeof children === 'string') { html = children; } diff --git a/lib/micromark/html-block-component/syntax.ts b/lib/micromark/html-block-component/syntax.ts index 7ba81ac8d..37517d459 100644 --- a/lib/micromark/html-block-component/syntax.ts +++ b/lib/micromark/html-block-component/syntax.ts @@ -73,10 +73,14 @@ function createTokenize(mode: 'flow' | 'text') { effects.consume(code); return body; } - if (code === codes.space || code === codes.horizontalTab || markdownLineEnding(code)) { + if (code === codes.space || code === codes.horizontalTab) { effects.consume(code); return inAttributes; } + if (mode === 'flow' && markdownLineEnding(code)) { + effects.exit('htmlBlockComponentData'); + return attributeContinuationStart(code); + } if (code === codes.slash) { effects.consume(code); return selfClose; @@ -92,10 +96,34 @@ function createTokenize(mode: 'flow' | 'text') { if (code === null) { return nok(code); } + if (markdownLineEnding(code)) { + if (mode === 'text') return nok(code); + effects.exit('htmlBlockComponentData'); + return attributeContinuationStart(code); + } effects.consume(code); return inAttributes; } + function attributeContinuationStart(code: Code): State | undefined { + return effects.check(nonLazyContinuationStart, attributeContinuationNonLazy, continuationAfter)(code); + } + + function attributeContinuationNonLazy(code: Code): State | undefined { + effects.enter(types.lineEnding); + effects.consume(code); + effects.exit(types.lineEnding); + return attributeContinuationBefore; + } + + function attributeContinuationBefore(code: Code): State | undefined { + if (code === null || markdownLineEnding(code)) { + return attributeContinuationStart(code); + } + effects.enter('htmlBlockComponentData'); + return inAttributes(code); + } + function selfClose(code: Code): State | undefined { if (code === codes.greaterThan) { effects.consume(code); @@ -108,13 +136,13 @@ function createTokenize(mode: 'flow' | 'text') { if (code === null) return nok(code); if (markdownLineEnding(code)) { - if (mode === 'flow') { - effects.exit('htmlBlockComponentData'); - return continuationStart(code); + if (mode === 'text') { + // Text constructs operate on paragraph content which spans lines + effects.consume(code); + return body; } - // text constructs can span paragraph lines - effects.consume(code); - return body; + effects.exit('htmlBlockComponentData'); + return continuationStart(code); } if (code === codes.lessThan) { @@ -167,11 +195,6 @@ function createTokenize(mode: 'flow' | 'text') { } // Reject so the block re-parses as a paragraph, deferring to the // text tokenizer which preserves trailing content in the same line. - // - // {`Hello, World!`}. HI - // - // Without this, the flow construct would consume "HI". return nok(code); } From 69def82d760687c2204bdde9f13bd981fedb90fe Mon Sep 17 00:00:00 2001 From: Maximilian Falco Widjaya Date: Fri, 17 Apr 2026 15:46:22 +1000 Subject: [PATCH 08/12] chore: removed as State casts --- lib/micromark/html-block-component/syntax.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/micromark/html-block-component/syntax.ts b/lib/micromark/html-block-component/syntax.ts index 37517d459..fd665533e 100644 --- a/lib/micromark/html-block-component/syntax.ts +++ b/lib/micromark/html-block-component/syntax.ts @@ -39,23 +39,25 @@ function createTokenize(mode: 'flow' | 'text') { function matchChars(chars: Code[], onMatch: State, onFail: (code: Code) => State | undefined): State { if (chars.length === 0) return onMatch; - return ((code: Code): State | undefined => { + const next: State = (code: Code): State | undefined => { if (code === chars[0]) { effects.consume(code); return matchChars(chars.slice(1), onMatch, onFail); } return onFail(code); - }) as State; + }; + return next; } function matchTagName(onMatch: State, onFail: (code: Code) => State | undefined): State { - return ((code: Code): State | undefined => { + const next: State = (code: Code): State | undefined => { if (code === codes.uppercaseH) { effects.consume(code); return matchChars(TAG_SUFFIX, onMatch, onFail); } return onFail(code); - }) as State; + }; + return next; } return start; From 10d4f9154cbc8abfa7d7097d41d18046832d5203 Mon Sep 17 00:00:00 2001 From: Maximilian Falco Widjaya Date: Fri, 17 Apr 2026 15:47:34 +1000 Subject: [PATCH 09/12] chore: add stricter and more robust test expectations --- __tests__/compilers/html-block.test.ts | 223 +++++++++++++++---------- 1 file changed, 138 insertions(+), 85 deletions(-) diff --git a/__tests__/compilers/html-block.test.ts b/__tests__/compilers/html-block.test.ts index 8ba2a52b2..5a7289b66 100644 --- a/__tests__/compilers/html-block.test.ts +++ b/__tests__/compilers/html-block.test.ts @@ -68,9 +68,10 @@ describe('mdxish html-block compiler', () => { expect(callout.tagName).toBe('Callout'); const htmlBlock = findHTMLBlock(callout); - expect(htmlBlock?.tagName).toBe('html-block'); - expect(htmlBlock?.properties?.html).toBe(' Hello, World!'); - expect(htmlBlock?.properties?.html).not.toMatch(/^>/m); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: ' Hello, World!' }, + }); }); it('compiles html blocks preserving newlines', () => { @@ -88,7 +89,7 @@ const foo = () => { const hast = mdxish(markdown); const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock?.tagName).toBe('html-block'); + expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); const htmlProp = htmlBlock?.properties?.html as string; expect(htmlProp).toContain('
    ');
    @@ -100,47 +101,58 @@ const foo = () => {
       it('adds newlines for readability', () => {
         const hast = mdxish('{`

    Hello, World!

    `}
    '); const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock?.tagName).toBe('html-block'); - expect(htmlBlock?.properties?.html).toBe('

    Hello, World!

    '); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: '

    Hello, World!

    ' }, + }); }); it('unescapes backticks in HTML content', () => { const hast = mdxish('{`\\`example\\``}'); const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock?.tagName).toBe('html-block'); - expect(htmlBlock?.properties?.html).toBe('`example`'); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: '`example`' }, + }); }); it('passes safeMode property correctly', () => { const hast = mdxish('{`

    Content

    `}
    '); const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock?.tagName).toBe('html-block'); - expect(htmlBlock?.properties?.safeMode).toBe('true'); - expect(htmlBlock?.properties?.html).toBe('

    Content

    '); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { safeMode: 'true', html: '

    Content

    ' }, + }); }); it('handles template literal with variables', () => { // eslint-disable-next-line quotes const hast = mdxish(`{\`const x = \${variable}\`}`); const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock?.tagName).toBe('html-block'); - // eslint-disable-next-line no-template-curly-in-string - expect(htmlBlock?.properties?.html).toBe('const x = ${variable}'); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + // eslint-disable-next-line no-template-curly-in-string + properties: { html: 'const x = ${variable}' }, + }); }); it('handles nested template literals', () => { const hast = mdxish('{`
    \\`\\`\\`javascript\\nconst x = 1;\\n\\`\\`\\`
    `}
    '); const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock?.tagName).toBe('html-block'); - expect(htmlBlock?.properties?.html).toBe('
    ```javascript\nconst x = 1;\n```
    '); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: '
    ```javascript\nconst x = 1;\n```
    ' }, + }); }); describe('flow-level (standalone block)', () => { it('handles simple single-line HTMLBlock', () => { const hast = mdxish('{`
    hello
    `}
    '); const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock?.tagName).toBe('html-block'); - expect(htmlBlock?.properties?.html).toBe('
    hello
    '); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: '
    hello
    ' }, + }); }); it('handles multiline content', () => { @@ -153,7 +165,7 @@ const foo = () => { const hast = mdxish(markdown); const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock?.tagName).toBe('html-block'); + expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); const htmlProp = htmlBlock?.properties?.html as string; expect(htmlProp).toContain('
  • one
  • '); @@ -169,7 +181,7 @@ const foo = () => { const hast = mdxish(markdown); const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock?.tagName).toBe('html-block'); + expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); const htmlProp = htmlBlock?.properties?.html as string; expect(htmlProp).toContain('
    before
    '); @@ -184,7 +196,7 @@ const foo = () => { const hast = mdxish(markdown); const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock?.tagName).toBe('html-block'); + expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); const htmlProp = htmlBlock?.properties?.html as string; expect(htmlProp).toContain(''); @@ -199,7 +211,7 @@ const foo = () => { const hast = mdxish(markdown); const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock?.tagName).toBe('html-block'); + expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); const htmlProp = htmlBlock?.properties?.html as string; expect(htmlProp).toContain(''); @@ -209,32 +221,38 @@ const foo = () => { it('handles HTMLBlock without template literal syntax', () => { const hast = mdxish('plain'); const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock?.tagName).toBe('html-block'); - expect(htmlBlock?.properties?.html).toBe('plain'); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: 'plain' }, + }); }); it('handles HTMLBlock with runScripts attribute', () => { const hast = mdxish('{``}'); const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock?.tagName).toBe('html-block'); - expect(htmlBlock?.properties?.runScripts).toBe(true); - expect(htmlBlock?.properties?.html).toBe(''); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { runScripts: true, html: '' }, + }); }); it('handles opening tag with attributes on a new line', () => { const markdown = '{``}'; const hast = mdxish(markdown); const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock?.tagName).toBe('html-block'); - expect(htmlBlock?.properties?.runScripts).toBe(true); - expect(htmlBlock?.properties?.html).toBe(''); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { runScripts: true, html: '' }, + }); }); it('handles trailing whitespace after closing tag', () => { const hast = mdxish('{`
    hello
    `}
    '); const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock?.tagName).toBe('html-block'); - expect(htmlBlock?.properties?.html).toBe('
    hello
    '); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: '
    hello
    ' }, + }); }); }); @@ -242,30 +260,35 @@ const foo = () => { it('handles inline HTMLBlock surrounded by text', () => { const hast = mdxish('before {`middle`} after'); const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock?.tagName).toBe('html-block'); - expect(htmlBlock?.properties?.html).toBe('middle'); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: 'middle' }, + }); }); it('handles inline HTMLBlock at the end of a paragraph', () => { const hast = mdxish('some text {`italic`}'); const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock?.tagName).toBe('html-block'); - expect(htmlBlock?.properties?.html).toBe('italic'); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: 'italic' }, + }); }); it('handles inline HTMLBlock with attributes', () => { const hast = mdxish('text {``} more'); const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock?.tagName).toBe('html-block'); - expect(htmlBlock?.properties?.safeMode).toBe('true'); - expect(htmlBlock?.properties?.html).toBe(''); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { safeMode: 'true', html: '' }, + }); }); it('handles multiline inline HTMLBlock with trailing text', () => { const markdown = 'hello {`Hello, World!`} world'; const hast = mdxish(markdown); const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock?.tagName).toBe('html-block'); + expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); expect(htmlBlock?.properties?.html).toContain('Hello, World!'); }); }); @@ -274,15 +297,17 @@ const foo = () => { it('preserves trailing text after single-line HTMLBlock', () => { const hast = mdxish('{`Hello`} trailing'); const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock?.tagName).toBe('html-block'); - expect(htmlBlock?.properties?.html).toBe('Hello'); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: 'Hello' }, + }); }); it('preserves trailing text after multiline HTMLBlock', () => { const markdown = '{`Hello`}. trailing'; const hast = mdxish(markdown); const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock?.tagName).toBe('html-block'); + expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); expect(htmlBlock?.properties?.html).toContain('Hello'); }); @@ -304,12 +329,13 @@ const foo = () => { > \`}`; const hast = mdxish(markdown); - const callout = hast.children[0] as Element; - expect(callout.tagName).toBe('Callout'); + expect((hast.children[0] as Element).tagName).toBe('Callout'); - const htmlBlock = findHTMLBlock(callout); - expect(htmlBlock?.tagName).toBe('html-block'); - expect(htmlBlock?.properties?.html).toBe(' Hello, World!'); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: ' Hello, World!' }, + }); }); it('handles HTMLBlock in a callout with script tags', () => { @@ -321,11 +347,10 @@ const foo = () => { > \`}`; const hast = mdxish(markdown); - const callout = hast.children[0] as Element; - expect(callout.tagName).toBe('Callout'); + expect((hast.children[0] as Element).tagName).toBe('Callout'); - const htmlBlock = findHTMLBlock(callout); - expect(htmlBlock?.tagName).toBe('html-block'); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); const htmlProp = htmlBlock?.properties?.html as string; expect(htmlProp).toContain(''); @@ -343,11 +368,10 @@ const foo = () => { > \`}`; const hast = mdxish(markdown); - const callout = hast.children[0] as Element; - expect(callout.tagName).toBe('Callout'); + expect((hast.children[0] as Element).tagName).toBe('Callout'); - const htmlBlock = findHTMLBlock(callout); - expect(htmlBlock?.tagName).toBe('html-block'); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); const htmlProp = htmlBlock?.properties?.html as string; expect(htmlProp).toContain('
    first
    '); @@ -363,12 +387,13 @@ const foo = () => { > \`}${' '}`; const hast = mdxish(markdown); - const callout = hast.children[0] as Element; - expect(callout.tagName).toBe('Callout'); + expect((hast.children[0] as Element).tagName).toBe('Callout'); - const htmlBlock = findHTMLBlock(callout); - expect(htmlBlock?.tagName).toBe('html-block'); - expect(htmlBlock?.properties?.html).toBe(' Hello'); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: ' Hello' }, + }); }); it('handles HTMLBlock in an empty callout (no title text)', () => { @@ -377,24 +402,26 @@ const foo = () => { > {\`

    body only

    \`}
    `; const hast = mdxish(markdown); - const callout = hast.children[0] as Element; - expect(callout.tagName).toBe('Callout'); + expect((hast.children[0] as Element).tagName).toBe('Callout'); - const htmlBlock = findHTMLBlock(callout); - expect(htmlBlock?.tagName).toBe('html-block'); - expect(htmlBlock?.properties?.html).toBe('

    body only

    '); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: '

    body only

    ' }, + }); }); }); describe('inside blockquotes', () => { it('handles single-line HTMLBlock in a blockquote', () => { const hast = mdxish('> {`

    quoted

    `}
    '); - const bq = hast.children[0] as Element; - expect(bq.tagName).toBe('blockquote'); + expect((hast.children[0] as Element).tagName).toBe('blockquote'); - const htmlBlock = findHTMLBlock(bq); - expect(htmlBlock?.tagName).toBe('html-block'); - expect(htmlBlock?.properties?.html).toBe('

    quoted

    '); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: '

    quoted

    ' }, + }); }); it('handles multiline HTMLBlock in a blockquote without stray > characters', () => { @@ -404,11 +431,10 @@ const foo = () => { > \`}`; const hast = mdxish(markdown); - const bq = hast.children[0] as Element; - expect(bq.tagName).toBe('blockquote'); + expect((hast.children[0] as Element).tagName).toBe('blockquote'); - const htmlBlock = findHTMLBlock(bq); - expect(htmlBlock?.tagName).toBe('html-block'); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); const htmlProp = htmlBlock?.properties?.html as string; expect(htmlProp).toContain('
    line1
    '); @@ -420,22 +446,49 @@ const foo = () => { describe('inside lists', () => { it('handles HTMLBlock in an unordered list item', () => { const hast = mdxish('- {`listed`}'); - const list = hast.children[0] as Element; - expect(list.tagName).toBe('ul'); + expect((hast.children[0] as Element).tagName).toBe('ul'); - const htmlBlock = findHTMLBlock(list); - expect(htmlBlock?.tagName).toBe('html-block'); - expect(htmlBlock?.properties?.html).toBe('listed'); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: 'listed' }, + }); }); it('handles HTMLBlock in an ordered list item', () => { const hast = mdxish('1. {`ordered`}'); - const list = hast.children[0] as Element; - expect(list.tagName).toBe('ol'); + expect((hast.children[0] as Element).tagName).toBe('ol'); - const htmlBlock = findHTMLBlock(list); - expect(htmlBlock?.tagName).toBe('html-block'); - expect(htmlBlock?.properties?.html).toBe('ordered'); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: 'ordered' }, + }); + }); + }); + + describe('edge cases', () => { + it('handles standalone multiline HTMLBlock with surrounding paragraphs', () => { + const markdown = `Hello + +{\` +

    Hello, World!

    +\`}
    + +there`; + const hast = mdxish(markdown); + const htmlBlock = (hast.children as Element[]) + .filter(child => child.type === 'element') + .reduce((found, child) => found || findHTMLBlock(child), undefined); + expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); + expect(htmlBlock?.properties?.html).toContain('Hello, World!

    '); + }); + + it('handles nested HTMLBlock tags in content', () => { + const hast = mdxish('{`{inner}`}'); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); + expect(htmlBlock?.properties?.html).toContain('{inner}'); }); }); }); From 24390a975915072b05d1130b40c7c28e98cb51ce Mon Sep 17 00:00:00 2001 From: Maximilian Falco Widjaya Date: Fri, 17 Apr 2026 15:51:15 +1000 Subject: [PATCH 10/12] chore: add htmlblock transformer tests --- .../transformers/mdxish-html-blocks.test.ts | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 __tests__/transformers/mdxish-html-blocks.test.ts diff --git a/__tests__/transformers/mdxish-html-blocks.test.ts b/__tests__/transformers/mdxish-html-blocks.test.ts new file mode 100644 index 000000000..e1084f140 --- /dev/null +++ b/__tests__/transformers/mdxish-html-blocks.test.ts @@ -0,0 +1,135 @@ +import type { Root } from 'mdast'; + +import remarkParse from 'remark-parse'; +import { unified } from 'unified'; + +import { htmlBlockComponentFromMarkdown } from '../../lib/mdast-util/html-block-component'; +import { htmlBlockComponent } from '../../lib/micromark/html-block-component/syntax'; +import mdxishHtmlBlocks from '../../processor/transform/mdxish/mdxish-html-blocks'; +import { collectNodes } from '../helpers'; + +interface HTMLBlockNode { + children: { type: string; value: string }[]; + data: { + hName: string; + hProperties: Record; + }; + type: string; +} + +const parseWithPlugin = (markdown: string): Root => { + const processor = unified() + .data('micromarkExtensions', [htmlBlockComponent()]) + .data('fromMarkdownExtensions', [htmlBlockComponentFromMarkdown()]) + .use(remarkParse) + .use(mdxishHtmlBlocks); + const tree = processor.parse(markdown); + processor.runSync(tree); + return tree as Root; +}; + +const findHtmlBlockNodes = (tree: Root): HTMLBlockNode[] => + collectNodes(tree, node => node.type === 'html-block') as unknown as HTMLBlockNode[]; + +describe('mdxish-html-blocks transformer', () => { + describe('attribute extraction', () => { + it('extracts safeMode from JSX syntax', () => { + const tree = parseWithPlugin('{`

    content

    `}
    '); + const [node] = findHtmlBlockNodes(tree); + expect(node.data.hProperties).toMatchObject({ safeMode: 'true', html: '

    content

    ' }); + }); + + it('extracts safeMode from string syntax', () => { + const tree = parseWithPlugin('{`

    content

    `}
    '); + const [node] = findHtmlBlockNodes(tree); + expect(node.data.hProperties).toMatchObject({ safeMode: 'false', html: '

    content

    ' }); + }); + + it('extracts runScripts boolean true', () => { + const tree = parseWithPlugin('{`

    content

    `}
    '); + const [node] = findHtmlBlockNodes(tree); + expect(node.data.hProperties.runScripts).toBe(true); + }); + + it('extracts runScripts boolean false', () => { + const tree = parseWithPlugin('{`

    content

    `}
    '); + const [node] = findHtmlBlockNodes(tree); + expect(node.data.hProperties.runScripts).toBe(false); + }); + + it('extracts runScripts string value', () => { + const tree = parseWithPlugin('{`

    content

    `}
    '); + const [node] = findHtmlBlockNodes(tree); + expect(node.data.hProperties.runScripts).toBe('afterRender'); + }); + + it('extracts multiple attributes', () => { + const tree = parseWithPlugin( + '{`

    content

    `}
    ', + ); + const [node] = findHtmlBlockNodes(tree); + expect(node.data.hProperties).toMatchObject({ safeMode: 'true', runScripts: true }); + }); + + it('omits runScripts and safeMode when absent', () => { + const tree = parseWithPlugin('{`

    content

    `}
    '); + const [node] = findHtmlBlockNodes(tree); + expect(node.data.hProperties).toStrictEqual({ html: '

    content

    ' }); + }); + }); + + describe('content extraction', () => { + it('strips template literal delimiters', () => { + const tree = parseWithPlugin('{`
    hello
    `}
    '); + const [node] = findHtmlBlockNodes(tree); + expect(node.data.hProperties.html).toBe('
    hello
    '); + }); + + it('handles content without template literal syntax', () => { + const tree = parseWithPlugin('plain'); + const [node] = findHtmlBlockNodes(tree); + expect(node.data.hProperties.html).toBe('plain'); + }); + + it('unescapes backticks in HTML content', () => { + const tree = parseWithPlugin('{`\\`example\\``}'); + const [node] = findHtmlBlockNodes(tree); + expect(node.data.hProperties.html).toBe('`example`'); + }); + + it('preserves multiline content', () => { + const markdown = `{\` +
      +
    • one
    • +
    • two
    • +
    +\`}
    `; + const tree = parseWithPlugin(markdown); + const [node] = findHtmlBlockNodes(tree); + const html = node.data.hProperties.html as string; + expect(html).toContain('
  • one
  • '); + expect(html).toContain('
  • two
  • '); + }); + }); + + describe('node structure', () => { + it('produces correct node type and hName', () => { + const tree = parseWithPlugin('{`

    test

    `}
    '); + const [node] = findHtmlBlockNodes(tree); + expect(node.type).toBe('html-block'); + expect(node.data.hName).toBe('html-block'); + }); + + it('sets children text node matching html property', () => { + const tree = parseWithPlugin('{`

    test

    `}
    '); + const [node] = findHtmlBlockNodes(tree); + expect(node.children).toStrictEqual([{ type: 'text', value: '

    test

    ' }]); + }); + + it('does not transform non-HTMLBlock html nodes', () => { + const tree = parseWithPlugin('
    just html
    '); + const htmlBlockNodes = findHtmlBlockNodes(tree); + expect(htmlBlockNodes).toHaveLength(0); + }); + }); +}); From 012ebb8c887a126953cd8557de2a75b8845ca893 Mon Sep 17 00:00:00 2001 From: Maximilian Falco Widjaya Date: Fri, 17 Apr 2026 16:01:04 +1000 Subject: [PATCH 11/12] chore: move transformer tests in to the transformer test file --- __tests__/compilers/html-block.test.ts | 454 +----------------- .../transformers/mdxish-html-blocks.test.ts | 452 +++++++++++++++++ 2 files changed, 453 insertions(+), 453 deletions(-) diff --git a/__tests__/compilers/html-block.test.ts b/__tests__/compilers/html-block.test.ts index 5a7289b66..7b62977fb 100644 --- a/__tests__/compilers/html-block.test.ts +++ b/__tests__/compilers/html-block.test.ts @@ -1,15 +1,4 @@ -import type { Element } from 'hast'; - -import { mdast, mdx, mdxish } from '../../index'; - -function findHTMLBlock(element: Element): Element | undefined { - if (element.tagName === 'HTMLBlock' || element.tagName === 'html-block') { - return element; - } - return element.children - .filter((child): child is Element => child.type === 'element') - .reduce((found, child) => found || findHTMLBlock(child), undefined); -} +import { mdast, mdx } from '../../index'; describe('html-block compiler', () => { it('compiles html blocks within containers', () => { @@ -51,444 +40,3 @@ const foo = () => { expect(mdx(mdast(markdown)).trim()).toBe(expected.trim()); }); }); - -describe('mdxish html-block compiler', () => { - it('compiles html blocks within containers', () => { - const markdown = ` -> 🚧 It compiles! -> -> {\` -> Hello, World! -> \`} -`; - - const hast = mdxish(markdown.trim()); - const callout = hast.children[0] as Element; - - expect(callout.tagName).toBe('Callout'); - - const htmlBlock = findHTMLBlock(callout); - expect(htmlBlock).toMatchObject({ - tagName: 'html-block', - properties: { html: ' Hello, World!' }, - }); - }); - - it('compiles html blocks preserving newlines', () => { - const markdown = `{\` -
    
    -const foo = () => {
    -  const bar = {
    -    baz: 'blammo'
    -  }
    -
    -  return bar
    -}
    -
    -\`}
    `; - - const hast = mdxish(markdown); - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); - - const htmlProp = htmlBlock?.properties?.html as string; - expect(htmlProp).toContain('
    ');
    -    expect(htmlProp).toContain('const foo = () => {');
    -    expect(htmlProp).toContain("baz: 'blammo'");
    -    expect(htmlProp).toContain('
    '); - }); - - it('adds newlines for readability', () => { - const hast = mdxish('{`

    Hello, World!

    `}
    '); - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ - tagName: 'html-block', - properties: { html: '

    Hello, World!

    ' }, - }); - }); - - it('unescapes backticks in HTML content', () => { - const hast = mdxish('{`\\`example\\``}'); - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ - tagName: 'html-block', - properties: { html: '`example`' }, - }); - }); - - it('passes safeMode property correctly', () => { - const hast = mdxish('{`

    Content

    `}
    '); - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ - tagName: 'html-block', - properties: { safeMode: 'true', html: '

    Content

    ' }, - }); - }); - - it('handles template literal with variables', () => { - // eslint-disable-next-line quotes - const hast = mdxish(`{\`const x = \${variable}\`}`); - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ - tagName: 'html-block', - // eslint-disable-next-line no-template-curly-in-string - properties: { html: 'const x = ${variable}' }, - }); - }); - - it('handles nested template literals', () => { - const hast = mdxish('{`
    \\`\\`\\`javascript\\nconst x = 1;\\n\\`\\`\\`
    `}
    '); - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ - tagName: 'html-block', - properties: { html: '
    ```javascript\nconst x = 1;\n```
    ' }, - }); - }); - - describe('flow-level (standalone block)', () => { - it('handles simple single-line HTMLBlock', () => { - const hast = mdxish('{`
    hello
    `}
    '); - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ - tagName: 'html-block', - properties: { html: '
    hello
    ' }, - }); - }); - - it('handles multiline content', () => { - const markdown = `{\` -
      -
    • one
    • -
    • two
    • -
    -\`}
    `; - - const hast = mdxish(markdown); - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); - - const htmlProp = htmlBlock?.properties?.html as string; - expect(htmlProp).toContain('
  • one
  • '); - expect(htmlProp).toContain('
  • two
  • '); - }); - - it('handles content with blank lines', () => { - const markdown = `{\` -
    before
    - -
    after
    -\`}
    `; - - const hast = mdxish(markdown); - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); - - const htmlProp = htmlBlock?.properties?.html as string; - expect(htmlProp).toContain('
    before
    '); - expect(htmlProp).toContain('
    after
    '); - }); - - it('handles script tags without being consumed by the markdown parser', () => { - const markdown = `{\` - -

    visible

    -\`}
    `; - - const hast = mdxish(markdown); - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); - - const htmlProp = htmlBlock?.properties?.html as string; - expect(htmlProp).toContain(''); - expect(htmlProp).toContain('

    visible

    '); - }); - - it('handles style tags without being consumed by the markdown parser', () => { - const markdown = `{\` - -

    styled

    -\`}
    `; - - const hast = mdxish(markdown); - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); - - const htmlProp = htmlBlock?.properties?.html as string; - expect(htmlProp).toContain(''); - expect(htmlProp).toContain('

    styled

    '); - }); - - it('handles HTMLBlock without template literal syntax', () => { - const hast = mdxish('plain'); - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ - tagName: 'html-block', - properties: { html: 'plain' }, - }); - }); - - it('handles HTMLBlock with runScripts attribute', () => { - const hast = mdxish('{``}'); - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ - tagName: 'html-block', - properties: { runScripts: true, html: '' }, - }); - }); - - it('handles opening tag with attributes on a new line', () => { - const markdown = '{``}'; - const hast = mdxish(markdown); - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ - tagName: 'html-block', - properties: { runScripts: true, html: '' }, - }); - }); - - it('handles trailing whitespace after closing tag', () => { - const hast = mdxish('{`
    hello
    `}
    '); - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ - tagName: 'html-block', - properties: { html: '
    hello
    ' }, - }); - }); - }); - - describe('text-level (inline)', () => { - it('handles inline HTMLBlock surrounded by text', () => { - const hast = mdxish('before {`middle`} after'); - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ - tagName: 'html-block', - properties: { html: 'middle' }, - }); - }); - - it('handles inline HTMLBlock at the end of a paragraph', () => { - const hast = mdxish('some text {`italic`}'); - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ - tagName: 'html-block', - properties: { html: 'italic' }, - }); - }); - - it('handles inline HTMLBlock with attributes', () => { - const hast = mdxish('text {``} more'); - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ - tagName: 'html-block', - properties: { safeMode: 'true', html: '' }, - }); - }); - - it('handles multiline inline HTMLBlock with trailing text', () => { - const markdown = 'hello {`Hello, World!`} world'; - const hast = mdxish(markdown); - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); - expect(htmlBlock?.properties?.html).toContain('Hello, World!'); - }); - }); - - describe('trailing text preservation', () => { - it('preserves trailing text after single-line HTMLBlock', () => { - const hast = mdxish('{`Hello`} trailing'); - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ - tagName: 'html-block', - properties: { html: 'Hello' }, - }); - }); - - it('preserves trailing text after multiline HTMLBlock', () => { - const markdown = '{`Hello`}. trailing'; - const hast = mdxish(markdown); - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); - expect(htmlBlock?.properties?.html).toContain('Hello'); - }); - - it('does not crash with trailing text after closing tag', () => { - expect(() => mdxish('{`

    content

    `}
    hello world')).not.toThrow(); - }); - - it('does not crash with trailing punctuation after closing tag', () => { - expect(() => mdxish('{`

    content

    `}
    . stuff')).not.toThrow(); - }); - }); - - describe('inside callouts', () => { - it('handles HTMLBlock in a callout without stray > characters', () => { - const markdown = `> 🚧 It compiles! -> -> {\` -> Hello, World! -> \`}`; - - const hast = mdxish(markdown); - expect((hast.children[0] as Element).tagName).toBe('Callout'); - - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ - tagName: 'html-block', - properties: { html: ' Hello, World!' }, - }); - }); - - it('handles HTMLBlock in a callout with script tags', () => { - const markdown = `> ⚠️ Warning -> -> {\` -> ->

    safe content

    -> \`}
    `; - - const hast = mdxish(markdown); - expect((hast.children[0] as Element).tagName).toBe('Callout'); - - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); - - const htmlProp = htmlBlock?.properties?.html as string; - expect(htmlProp).toContain(''); - expect(htmlProp).toContain('

    safe content

    '); - expect(htmlProp).not.toMatch(/^>/m); - }); - - it('handles HTMLBlock in a callout with blank lines in content', () => { - const markdown = `> 🚧 Test -> -> {\` ->
    first
    -> ->
    second
    -> \`}
    `; - - const hast = mdxish(markdown); - expect((hast.children[0] as Element).tagName).toBe('Callout'); - - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); - - const htmlProp = htmlBlock?.properties?.html as string; - expect(htmlProp).toContain('
    first
    '); - expect(htmlProp).toContain('
    second
    '); - expect(htmlProp).not.toMatch(/^>/m); - }); - - it('handles HTMLBlock in a callout with trailing whitespace', () => { - const markdown = `> 🚧 It compiles! -> -> {\` -> Hello -> \`}${' '}`; - - const hast = mdxish(markdown); - expect((hast.children[0] as Element).tagName).toBe('Callout'); - - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ - tagName: 'html-block', - properties: { html: ' Hello' }, - }); - }); - - it('handles HTMLBlock in an empty callout (no title text)', () => { - const markdown = `> 📘 -> -> {\`

    body only

    \`}
    `; - - const hast = mdxish(markdown); - expect((hast.children[0] as Element).tagName).toBe('Callout'); - - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ - tagName: 'html-block', - properties: { html: '

    body only

    ' }, - }); - }); - }); - - describe('inside blockquotes', () => { - it('handles single-line HTMLBlock in a blockquote', () => { - const hast = mdxish('> {`

    quoted

    `}
    '); - expect((hast.children[0] as Element).tagName).toBe('blockquote'); - - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ - tagName: 'html-block', - properties: { html: '

    quoted

    ' }, - }); - }); - - it('handles multiline HTMLBlock in a blockquote without stray > characters', () => { - const markdown = `> {\` ->
    line1
    ->
    line2
    -> \`}
    `; - - const hast = mdxish(markdown); - expect((hast.children[0] as Element).tagName).toBe('blockquote'); - - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); - - const htmlProp = htmlBlock?.properties?.html as string; - expect(htmlProp).toContain('
    line1
    '); - expect(htmlProp).toContain('
    line2
    '); - expect(htmlProp).not.toMatch(/^>/m); - }); - }); - - describe('inside lists', () => { - it('handles HTMLBlock in an unordered list item', () => { - const hast = mdxish('- {`listed`}'); - expect((hast.children[0] as Element).tagName).toBe('ul'); - - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ - tagName: 'html-block', - properties: { html: 'listed' }, - }); - }); - - it('handles HTMLBlock in an ordered list item', () => { - const hast = mdxish('1. {`ordered`}'); - expect((hast.children[0] as Element).tagName).toBe('ol'); - - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ - tagName: 'html-block', - properties: { html: 'ordered' }, - }); - }); - }); - - describe('edge cases', () => { - it('handles standalone multiline HTMLBlock with surrounding paragraphs', () => { - const markdown = `Hello - -{\` -

    Hello, World!

    -\`}
    - -there`; - const hast = mdxish(markdown); - const htmlBlock = (hast.children as Element[]) - .filter(child => child.type === 'element') - .reduce((found, child) => found || findHTMLBlock(child), undefined); - expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); - expect(htmlBlock?.properties?.html).toContain('Hello, World!

    '); - }); - - it('handles nested HTMLBlock tags in content', () => { - const hast = mdxish('{`{inner}`}'); - const htmlBlock = findHTMLBlock(hast.children[0] as Element); - expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); - expect(htmlBlock?.properties?.html).toContain('{inner}'); - }); - }); -}); diff --git a/__tests__/transformers/mdxish-html-blocks.test.ts b/__tests__/transformers/mdxish-html-blocks.test.ts index e1084f140..f697c74c5 100644 --- a/__tests__/transformers/mdxish-html-blocks.test.ts +++ b/__tests__/transformers/mdxish-html-blocks.test.ts @@ -1,8 +1,10 @@ +import type { Element } from 'hast'; import type { Root } from 'mdast'; import remarkParse from 'remark-parse'; import { unified } from 'unified'; +import { mdxish } from '../../index'; import { htmlBlockComponentFromMarkdown } from '../../lib/mdast-util/html-block-component'; import { htmlBlockComponent } from '../../lib/micromark/html-block-component/syntax'; import mdxishHtmlBlocks from '../../processor/transform/mdxish/mdxish-html-blocks'; @@ -31,6 +33,15 @@ const parseWithPlugin = (markdown: string): Root => { const findHtmlBlockNodes = (tree: Root): HTMLBlockNode[] => collectNodes(tree, node => node.type === 'html-block') as unknown as HTMLBlockNode[]; +function findHTMLBlock(element: Element): Element | undefined { + if (element.tagName === 'HTMLBlock' || element.tagName === 'html-block') { + return element; + } + return element.children + .filter((child): child is Element => child.type === 'element') + .reduce((found, child) => found || findHTMLBlock(child), undefined); +} + describe('mdxish-html-blocks transformer', () => { describe('attribute extraction', () => { it('extracts safeMode from JSX syntax', () => { @@ -133,3 +144,444 @@ describe('mdxish-html-blocks transformer', () => { }); }); }); + +describe('mdxish html-block integration', () => { + it('compiles html blocks within containers', () => { + const markdown = ` +> 🚧 It compiles! +> +> {\` +> Hello, World! +> \`} +`; + + const hast = mdxish(markdown.trim()); + const callout = hast.children[0] as Element; + + expect(callout.tagName).toBe('Callout'); + + const htmlBlock = findHTMLBlock(callout); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: ' Hello, World!' }, + }); + }); + + it('compiles html blocks preserving newlines', () => { + const markdown = `{\` +
    
    +const foo = () => {
    +  const bar = {
    +    baz: 'blammo'
    +  }
    +
    +  return bar
    +}
    +
    +\`}
    `; + + const hast = mdxish(markdown); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); + + const htmlProp = htmlBlock?.properties?.html as string; + expect(htmlProp).toContain('
    ');
    +    expect(htmlProp).toContain('const foo = () => {');
    +    expect(htmlProp).toContain("baz: 'blammo'");
    +    expect(htmlProp).toContain('
    '); + }); + + it('adds newlines for readability', () => { + const hast = mdxish('{`

    Hello, World!

    `}
    '); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: '

    Hello, World!

    ' }, + }); + }); + + it('unescapes backticks in HTML content', () => { + const hast = mdxish('{`\\`example\\``}'); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: '`example`' }, + }); + }); + + it('passes safeMode property correctly', () => { + const hast = mdxish('{`

    Content

    `}
    '); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { safeMode: 'true', html: '

    Content

    ' }, + }); + }); + + it('handles template literal with variables', () => { + // eslint-disable-next-line quotes + const hast = mdxish(`{\`const x = \${variable}\`}`); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + // eslint-disable-next-line no-template-curly-in-string + properties: { html: 'const x = ${variable}' }, + }); + }); + + it('handles nested template literals', () => { + const hast = mdxish('{`
    \\`\\`\\`javascript\\nconst x = 1;\\n\\`\\`\\`
    `}
    '); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: '
    ```javascript\nconst x = 1;\n```
    ' }, + }); + }); + + describe('flow-level (standalone block)', () => { + it('handles simple single-line HTMLBlock', () => { + const hast = mdxish('{`
    hello
    `}
    '); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: '
    hello
    ' }, + }); + }); + + it('handles multiline content', () => { + const markdown = `{\` +
      +
    • one
    • +
    • two
    • +
    +\`}
    `; + + const hast = mdxish(markdown); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); + + const htmlProp = htmlBlock?.properties?.html as string; + expect(htmlProp).toContain('
  • one
  • '); + expect(htmlProp).toContain('
  • two
  • '); + }); + + it('handles content with blank lines', () => { + const markdown = `{\` +
    before
    + +
    after
    +\`}
    `; + + const hast = mdxish(markdown); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); + + const htmlProp = htmlBlock?.properties?.html as string; + expect(htmlProp).toContain('
    before
    '); + expect(htmlProp).toContain('
    after
    '); + }); + + it('handles script tags without being consumed by the markdown parser', () => { + const markdown = `{\` + +

    visible

    +\`}
    `; + + const hast = mdxish(markdown); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); + + const htmlProp = htmlBlock?.properties?.html as string; + expect(htmlProp).toContain(''); + expect(htmlProp).toContain('

    visible

    '); + }); + + it('handles style tags without being consumed by the markdown parser', () => { + const markdown = `{\` + +

    styled

    +\`}
    `; + + const hast = mdxish(markdown); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); + + const htmlProp = htmlBlock?.properties?.html as string; + expect(htmlProp).toContain(''); + expect(htmlProp).toContain('

    styled

    '); + }); + + it('handles HTMLBlock without template literal syntax', () => { + const hast = mdxish('plain'); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: 'plain' }, + }); + }); + + it('handles HTMLBlock with runScripts attribute', () => { + const hast = mdxish('{``}'); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { runScripts: true, html: '' }, + }); + }); + + it('handles opening tag with attributes on a new line', () => { + const markdown = '{``}'; + const hast = mdxish(markdown); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { runScripts: true, html: '' }, + }); + }); + + it('handles trailing whitespace after closing tag', () => { + const hast = mdxish('{`
    hello
    `}
    '); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: '
    hello
    ' }, + }); + }); + }); + + describe('text-level (inline)', () => { + it('handles inline HTMLBlock surrounded by text', () => { + const hast = mdxish('before {`middle`} after'); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: 'middle' }, + }); + }); + + it('handles inline HTMLBlock at the end of a paragraph', () => { + const hast = mdxish('some text {`italic`}'); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: 'italic' }, + }); + }); + + it('handles inline HTMLBlock with attributes', () => { + const hast = mdxish('text {``} more'); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { safeMode: 'true', html: '' }, + }); + }); + + it('handles multiline inline HTMLBlock with trailing text', () => { + const markdown = 'hello {`Hello, World!`} world'; + const hast = mdxish(markdown); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); + expect(htmlBlock?.properties?.html).toContain('Hello, World!'); + }); + }); + + describe('trailing text preservation', () => { + it('preserves trailing text after single-line HTMLBlock', () => { + const hast = mdxish('{`Hello`} trailing'); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: 'Hello' }, + }); + }); + + it('preserves trailing text after multiline HTMLBlock', () => { + const markdown = '{`Hello`}. trailing'; + const hast = mdxish(markdown); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); + expect(htmlBlock?.properties?.html).toContain('Hello'); + }); + + it('does not crash with trailing text after closing tag', () => { + expect(() => mdxish('{`

    content

    `}
    hello world')).not.toThrow(); + }); + + it('does not crash with trailing punctuation after closing tag', () => { + expect(() => mdxish('{`

    content

    `}
    . stuff')).not.toThrow(); + }); + }); + + describe('inside callouts', () => { + it('handles HTMLBlock in a callout without stray > characters', () => { + const markdown = `> 🚧 It compiles! +> +> {\` +> Hello, World! +> \`}`; + + const hast = mdxish(markdown); + expect((hast.children[0] as Element).tagName).toBe('Callout'); + + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: ' Hello, World!' }, + }); + }); + + it('handles HTMLBlock in a callout with script tags', () => { + const markdown = `> ⚠️ Warning +> +> {\` +> +>

    safe content

    +> \`}
    `; + + const hast = mdxish(markdown); + expect((hast.children[0] as Element).tagName).toBe('Callout'); + + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); + + const htmlProp = htmlBlock?.properties?.html as string; + expect(htmlProp).toContain(''); + expect(htmlProp).toContain('

    safe content

    '); + expect(htmlProp).not.toMatch(/^>/m); + }); + + it('handles HTMLBlock in a callout with blank lines in content', () => { + const markdown = `> 🚧 Test +> +> {\` +>
    first
    +> +>
    second
    +> \`}
    `; + + const hast = mdxish(markdown); + expect((hast.children[0] as Element).tagName).toBe('Callout'); + + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); + + const htmlProp = htmlBlock?.properties?.html as string; + expect(htmlProp).toContain('
    first
    '); + expect(htmlProp).toContain('
    second
    '); + expect(htmlProp).not.toMatch(/^>/m); + }); + + it('handles HTMLBlock in a callout with trailing whitespace', () => { + const markdown = `> 🚧 It compiles! +> +> {\` +> Hello +> \`}${' '}`; + + const hast = mdxish(markdown); + expect((hast.children[0] as Element).tagName).toBe('Callout'); + + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: ' Hello' }, + }); + }); + + it('handles HTMLBlock in an empty callout (no title text)', () => { + const markdown = `> 📘 +> +> {\`

    body only

    \`}
    `; + + const hast = mdxish(markdown); + expect((hast.children[0] as Element).tagName).toBe('Callout'); + + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: '

    body only

    ' }, + }); + }); + }); + + describe('inside blockquotes', () => { + it('handles single-line HTMLBlock in a blockquote', () => { + const hast = mdxish('> {`

    quoted

    `}
    '); + expect((hast.children[0] as Element).tagName).toBe('blockquote'); + + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: '

    quoted

    ' }, + }); + }); + + it('handles multiline HTMLBlock in a blockquote without stray > characters', () => { + const markdown = `> {\` +>
    line1
    +>
    line2
    +> \`}
    `; + + const hast = mdxish(markdown); + expect((hast.children[0] as Element).tagName).toBe('blockquote'); + + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); + + const htmlProp = htmlBlock?.properties?.html as string; + expect(htmlProp).toContain('
    line1
    '); + expect(htmlProp).toContain('
    line2
    '); + expect(htmlProp).not.toMatch(/^>/m); + }); + }); + + describe('inside lists', () => { + it('handles HTMLBlock in an unordered list item', () => { + const hast = mdxish('- {`listed`}'); + expect((hast.children[0] as Element).tagName).toBe('ul'); + + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: 'listed' }, + }); + }); + + it('handles HTMLBlock in an ordered list item', () => { + const hast = mdxish('1. {`ordered`}'); + expect((hast.children[0] as Element).tagName).toBe('ol'); + + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ + tagName: 'html-block', + properties: { html: 'ordered' }, + }); + }); + }); + + describe('edge cases', () => { + it('handles standalone multiline HTMLBlock with surrounding paragraphs', () => { + const markdown = `Hello + +{\` +

    Hello, World!

    +\`}
    + +there`; + const hast = mdxish(markdown); + const htmlBlock = (hast.children as Element[]) + .filter(child => child.type === 'element') + .reduce((found, child) => found || findHTMLBlock(child), undefined); + expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); + expect(htmlBlock?.properties?.html).toContain('Hello, World!

    '); + }); + + it('handles nested HTMLBlock tags in content', () => { + const hast = mdxish('{`{inner}`}'); + const htmlBlock = findHTMLBlock(hast.children[0] as Element); + expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); + expect(htmlBlock?.properties?.html).toContain('{inner}'); + }); + }); +}); From 989aa8f40e315a60f5575eb513ca485fbd3a168b Mon Sep 17 00:00:00 2001 From: Maximilian Falco Widjaya Date: Fri, 17 Apr 2026 16:19:32 +1000 Subject: [PATCH 12/12] chore: tweak nested html block test --- __tests__/transformers/mdxish-html-blocks.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/__tests__/transformers/mdxish-html-blocks.test.ts b/__tests__/transformers/mdxish-html-blocks.test.ts index f697c74c5..e0407d29f 100644 --- a/__tests__/transformers/mdxish-html-blocks.test.ts +++ b/__tests__/transformers/mdxish-html-blocks.test.ts @@ -578,10 +578,10 @@ there`; }); it('handles nested HTMLBlock tags in content', () => { - const hast = mdxish('{`{inner}`}'); + const hast = mdxish('{`{Hello}`}'); const htmlBlock = findHTMLBlock(hast.children[0] as Element); expect(htmlBlock).toMatchObject({ tagName: 'html-block' }); - expect(htmlBlock?.properties?.html).toContain('{inner}'); + expect(htmlBlock?.properties?.html).toContain('{Hello}'); }); }); });