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 = `{\`
+
+\`}`;
- 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 = `{\`
+
+\`}`;
+ 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 = `{\`
-
-\`}`;
-
- 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 = `{\`
+
+\`}`;
+
+ 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}');
});
});
});