From 69d8c810dd10cc206297de4079fcec4a582eea74 Mon Sep 17 00:00:00 2001 From: Maximilian Falco Widjaya Date: Fri, 12 Dec 2025 16:18:39 +0700 Subject: [PATCH 1/6] fix: allow parsing empty callouts without breaking --- processor/transform/callouts.ts | 39 +++++++++++++++++++-- processor/transform/extract-text.ts | 25 +++++++++++++ processor/transform/mdxish/mdxish-tables.ts | 1 + 3 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 processor/transform/extract-text.ts diff --git a/processor/transform/callouts.ts b/processor/transform/callouts.ts index f5eb9925b..c7ea9d5f8 100644 --- a/processor/transform/callouts.ts +++ b/processor/transform/callouts.ts @@ -1,4 +1,4 @@ -import type { Blockquote, Heading, Node, Root } from 'mdast'; +import type { Blockquote, Heading, Node, Paragraph, Parent, Root, Text } from 'mdast'; import type { Callout } from 'types'; import emojiRegex from 'emoji-regex'; @@ -8,6 +8,8 @@ import { themes } from '../../components/Callout'; import { NodeTypes } from '../../enums'; import plain from '../../lib/plain'; +import { extractText } from './extract-text'; + const regex = `^(${emojiRegex().source}|⚠)(\\s+|$)`; const findFirst = (node: Node): Node | null => { @@ -30,10 +32,40 @@ export const wrapHeading = (node: Blockquote | Callout): Heading => { }; }; +/** + * Checks if a blockquote matches the expected callout structure: + * blockquote > paragraph > text node + */ +const isCalloutStructure = (node: Blockquote): boolean => { + const firstChild = node.children?.[0]; + if (!firstChild || firstChild.type !== 'paragraph') return false; + + if (!('children' in firstChild)) return false; + + const firstTextChild = firstChild.children?.[0]; + return firstTextChild?.type === 'text'; +}; + const calloutTransformer = () => { return (tree: Root) => { - visit(tree, 'blockquote', (node: Blockquote) => { - if (!(node.children[0].type === 'paragraph' && node.children[0].children[0].type === 'text')) return; + visit(tree, 'blockquote', (node: Blockquote, index: number | undefined, parent: Parent | undefined) => { + if (!isCalloutStructure(node)) { + // Replace blockquote with a paragraph containing its stringified content + if (index !== undefined && parent) { + const content = extractText(node) || '>'; + const textNode: Text = { + type: 'text', + value: content, + }; + const paragraphNode: Paragraph = { + type: 'paragraph', + children: [textNode], + position: node.position, + }; + parent.children.splice(index, 1, paragraphNode); + } + return; + } // @ts-expect-error -- @todo: update plain to accept mdast const startText = plain(node.children[0]).toString(); @@ -41,6 +73,7 @@ const calloutTransformer = () => { if (icon && match) { const heading = startText.slice(match.length); + // @ts-expect-error - isCalloutStructure ensures node.children[0] is a paragraph with children const empty = !heading.length && node.children[0].children.length === 1; const theme = themes[icon] || 'default'; diff --git a/processor/transform/extract-text.ts b/processor/transform/extract-text.ts new file mode 100644 index 000000000..7ae37a241 --- /dev/null +++ b/processor/transform/extract-text.ts @@ -0,0 +1,25 @@ +/** + * Extracts text content from a single AST node recursively. + * Works with both MDAST and HAST-like node structures. + * + * Placed this outside of the utils.ts file to avoid circular dependencies. + * + * @param node - The node to extract text from (can be MDAST Node or HAST-like structure) + * @returns The concatenated text content + */ +export const extractText = (node: { children?: unknown[]; type?: string; value?: unknown }): string => { + if (node.type === 'text' && typeof node.value === 'string') { + return node.value; + } + if (node.children && Array.isArray(node.children)) { + return node.children + .map(child => { + if (child && typeof child === 'object' && 'type' in child) { + return extractText(child as { children?: unknown[]; type?: string; value?: unknown }); + } + return ''; + }) + .join(''); + } + return ''; +}; diff --git a/processor/transform/mdxish/mdxish-tables.ts b/processor/transform/mdxish/mdxish-tables.ts index 63899325e..453b67a32 100644 --- a/processor/transform/mdxish/mdxish-tables.ts +++ b/processor/transform/mdxish/mdxish-tables.ts @@ -45,6 +45,7 @@ const isTextOnly = (children: unknown[]): boolean => { /** * Extract text content from children nodes + * This helper function is different from the one in `processor/utils/extract-text.ts` because this works with an array of children nodes. */ const extractText = (children: unknown[]): string => { return children From cc4457ab04ec66563b4c38ccb3e01a67305f5993 Mon Sep 17 00:00:00 2001 From: Maximilian Falco Widjaya Date: Fri, 12 Dec 2025 17:27:39 +0700 Subject: [PATCH 2/6] feat: separate logic depending on format in callouttransformer - defaults to the mdx behaviour --- processor/transform/callouts.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/processor/transform/callouts.ts b/processor/transform/callouts.ts index c7ea9d5f8..cf432f102 100644 --- a/processor/transform/callouts.ts +++ b/processor/transform/callouts.ts @@ -46,16 +46,26 @@ const isCalloutStructure = (node: Blockquote): boolean => { return firstTextChild?.type === 'text'; }; -const calloutTransformer = () => { +const calloutTransformer = ({ format }: { format?: string } = {}) => { return (tree: Root) => { visit(tree, 'blockquote', (node: Blockquote, index: number | undefined, parent: Parent | undefined) => { if (!isCalloutStructure(node)) { + const content = extractText(node); + const isEmpty = !content || content.trim() === ''; + + // For 'mdx' format (or undefined): leave empty blockquotes as-is (can be rendered as empty callout) + // For 'md' format: always replace non-callout blockquotes with stringified content + if (format !== 'md' && isEmpty) { + return; // Leave empty blockquote unchanged for mdx format + } + // Replace blockquote with a paragraph containing its stringified content + // For empty blockquotes in 'md' format, use '>' as the content if (index !== undefined && parent) { - const content = extractText(node) || '>'; + const textValue = isEmpty && format === 'md' ? '>' : content; const textNode: Text = { type: 'text', - value: content, + value: textValue, }; const paragraphNode: Paragraph = { type: 'paragraph', From 397d0e797e581837636a6edc718bada70734a05f Mon Sep 17 00:00:00 2001 From: Maximilian Falco Widjaya Date: Fri, 12 Dec 2025 17:28:01 +0700 Subject: [PATCH 3/6] make mdx pipeline respect the format for callout transformer --- lib/compile.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/compile.ts b/lib/compile.ts index 7997d731d..508da5439 100644 --- a/lib/compile.ts +++ b/lib/compile.ts @@ -26,7 +26,7 @@ export type CompileOpts = CompileOptions & { useTailwind?: boolean; }; -const { codeTabsTransformer, ...transforms } = defaultTransforms; +const { codeTabsTransformer, calloutTransformer, ...transforms } = defaultTransforms; const sanitizeSchema = deepmerge(defaultSchema, { protocols: ['doc', 'ref', 'blog', 'changelog', 'page'], @@ -39,6 +39,7 @@ const compile = ( const remarkPlugins: PluggableList = [ remarkFrontmatter, remarkGfm, + [calloutTransformer, { format: opts.format }], ...Object.values(transforms), [codeTabsTransformer, { copyButtons }], [ From 7345b10b2192555c719fab2b8aa4ae97f5794cd5 Mon Sep 17 00:00:00 2001 From: Maximilian Falco Widjaya Date: Fri, 12 Dec 2025 17:28:31 +0700 Subject: [PATCH 4/6] support format options in mdxish as well --- lib/mdxish.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/mdxish.ts b/lib/mdxish.ts index c20b5caf1..2f433def2 100644 --- a/lib/mdxish.ts +++ b/lib/mdxish.ts @@ -33,11 +33,12 @@ import { loadComponents } from './utils/mdxish/mdxish-load-components'; export interface MdxishOpts { components?: CustomComponents; + format?: string; jsxContext?: JSXContext; useTailwind?: boolean; } -const defaultTransformers = [calloutTransformer, codeTabsTransformer, gemojiTransformer, embedTransformer]; +const defaultTransformers = [codeTabsTransformer, gemojiTransformer, embedTransformer]; /** * Process markdown content with MDX syntax support. @@ -46,7 +47,7 @@ const defaultTransformers = [calloutTransformer, codeTabsTransformer, gemojiTran * @see {@link https://github.com/readmeio/rmdx/blob/main/docs/mdxish-flow.md} */ export function mdxish(mdContent: string, opts: MdxishOpts = {}): Root { - const { components: userComponents = {}, jsxContext = {}, useTailwind } = opts; + const { components: userComponents = {}, jsxContext = {}, useTailwind, format } = opts; const components: CustomComponents = { ...loadComponents(), @@ -70,6 +71,7 @@ export function mdxish(mdContent: string, opts: MdxishOpts = {}): Root { .use(remarkFrontmatter) .use(magicBlockRestorer, { blocks }) .use(imageTransformer, { isMdxish: true }) + .use(calloutTransformer, { format }) .use(defaultTransformers) .use(mdxishComponentBlocks) .use(mdxishTables) From 703a679040f5cda824f338f0d1ddeb990099d9e2 Mon Sep 17 00:00:00 2001 From: Maximilian Falco Widjaya Date: Fri, 12 Dec 2025 17:32:11 +0700 Subject: [PATCH 5/6] add tests in callout.test and mdx.test --- __tests__/lib/mdx.test.ts | 26 ++++++++++++- __tests__/transformers/callouts.test.ts | 51 +++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/__tests__/lib/mdx.test.ts b/__tests__/lib/mdx.test.ts index 3c405d471..88747b0d5 100644 --- a/__tests__/lib/mdx.test.ts +++ b/__tests__/lib/mdx.test.ts @@ -1,4 +1,5 @@ import { mdast, mdx } from '../../index'; +import calloutTransformer from '../../processor/transform/callouts'; describe('mdx serialization', () => { it('should not add indentation to JSX comment content when serializing', () => { @@ -21,5 +22,28 @@ describe('mdx serialization', () => { // Check that the line does NOT have leading spaces (indentation) expect(contentLine).not.toMatch(/^\s+/); }); -}); + describe('should print out just ">"', () => { + it('with format "mdx" (or undefined) - leaves empty blockquote as-is', () => { + const md = '>'; + + const tree = mdast(md, { missingComponents: 'ignore' }); + const serialized = mdx(tree); + + // When format is mdx/undefined, empty blockquote is left as blockquote, serializes to '>' + expect(serialized.trim()).toBe('>'); + }); + + it('with format "md" - replaces empty blockquote with stringified content', () => { + const md = '>'; + + const tree = mdast(md, { missingComponents: 'ignore' }); + const transformer = calloutTransformer({ format: 'md' }); + transformer(tree); + const serialized = mdx(tree); + + // When format is 'md', empty blockquote is replaced with paragraph containing '>', which serializes to '\>' + expect(serialized.trim()).toContain('\\>'); + }); + }); +}); diff --git a/__tests__/transformers/callouts.test.ts b/__tests__/transformers/callouts.test.ts index a04a74dcb..e015b9598 100644 --- a/__tests__/transformers/callouts.test.ts +++ b/__tests__/transformers/callouts.test.ts @@ -1,6 +1,7 @@ import { removePosition } from 'unist-util-remove-position'; import { mdast } from '../../index'; +import calloutTransformer from '../../processor/transform/callouts'; describe('callouts transformer', () => { it('can parse callouts', () => { @@ -243,4 +244,54 @@ describe('callouts transformer', () => { expect(tree.children[0].data.hProperties).toHaveProperty('theme', 'okay'); }); + + describe('format-specific behavior for empty blockquotes', () => { + it('with format "mdx" (or undefined) - leaves empty blockquote as-is', () => { + const md = '>'; + + const tree = mdast(md, { missingComponents: 'ignore' }); + const transformer = calloutTransformer({ format: 'mdx' }); + transformer(tree); + + // Empty blockquote should remain as blockquote when format is 'mdx' + const hasBlockquote = tree.children.some(child => child.type === 'blockquote'); + expect(hasBlockquote).toBe(true); + }); + + it('with format undefined - leaves empty blockquote as-is', () => { + const md = '>'; + + const tree = mdast(md, { missingComponents: 'ignore' }); + const transformer = calloutTransformer(); + transformer(tree); + + // Empty blockquote should remain as blockquote when format is undefined + const hasBlockquote = tree.children.some(child => child.type === 'blockquote'); + expect(hasBlockquote).toBe(true); + }); + + it('with format "md" - replaces empty blockquote with paragraph containing stringified content', () => { + const md = '>'; + + const tree = mdast(md, { missingComponents: 'ignore' }); + const transformer = calloutTransformer({ format: 'md' }); + transformer(tree); + + // Empty blockquote should be replaced with paragraph when format is 'md' + const hasBlockquote = tree.children.some(child => child.type === 'blockquote'); + expect(hasBlockquote).toBe(false); + + // Should have a paragraph with '>' as content + const hasParagraph = tree.children.some( + child => + child.type === 'paragraph' && + 'children' in child && + child.children.some( + (c: unknown) => + c && typeof c === 'object' && 'type' in c && c.type === 'text' && 'value' in c && c.value === '>', + ), + ); + expect(hasParagraph).toBe(true); + }); + }); }); From b36a2c307bb464b235d1e76181b2c9ff50255a6f Mon Sep 17 00:00:00 2001 From: Maximilian Falco Widjaya Date: Fri, 12 Dec 2025 17:34:31 +0700 Subject: [PATCH 6/6] add tests for mdxish --- __tests__/lib/mdxish/mdxish.test.ts | 40 ++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/__tests__/lib/mdxish/mdxish.test.ts b/__tests__/lib/mdxish/mdxish.test.ts index f4c4f9417..c1a8dca43 100644 --- a/__tests__/lib/mdxish/mdxish.test.ts +++ b/__tests__/lib/mdxish/mdxish.test.ts @@ -1,4 +1,5 @@ import { mdxish } from '../../../lib/mdxish'; +import { extractText } from '../../../processor/transform/extract-text'; describe('mdxish', () => { describe('invalid mdx syntax', () => { @@ -7,10 +8,43 @@ describe('mdxish', () => { expect(() => mdxish(md)).not.toThrow(); }); - it('should render content in new lines', () => { - const md = `
hello + it('should render content in new lines', () => { + const md = `
hello
`; expect(() => mdxish(md)).not.toThrow(); }); }); -}); \ No newline at end of file + + describe('should handle just ">"', () => { + it('with format undefined (mdx) - leaves empty blockquote as-is in HAST', () => { + const md = '>'; + + const tree = mdxish(md); + // With format undefined, empty blockquote is left as blockquote, which becomes empty
in HAST + // Extracting text from empty blockquote gives whitespace, not '>' + const textContent = extractText(tree); + expect(textContent.trim()).toBe(''); + + // Verify it's a blockquote element in HAST + const hasBlockquote = tree.children.some( + child => + child && + typeof child === 'object' && + 'type' in child && + child.type === 'element' && + 'tagName' in child && + child.tagName === 'blockquote', + ); + expect(hasBlockquote).toBe(true); + }); + + it('with format "md" - replaces empty blockquote with paragraph containing ">"', () => { + const md = '>'; + + const tree = mdxish(md, { format: 'md' }); + const textContent = extractText(tree); + // With format 'md', empty blockquote is replaced with paragraph containing '>' + expect(textContent.trim()).toBe('>'); + }); + }); +});