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__/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('>'); + }); + }); +}); 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); + }); + }); }); 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 }], [ 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) diff --git a/processor/transform/callouts.ts b/processor/transform/callouts.ts index f5eb9925b..cf432f102 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,50 @@ export const wrapHeading = (node: Blockquote | Callout): Heading => { }; }; -const calloutTransformer = () => { +/** + * 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 = ({ format }: { format?: string } = {}) => { 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)) { + 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 textValue = isEmpty && format === 'md' ? '>' : content; + const textNode: Text = { + type: 'text', + value: textValue, + }; + 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 +83,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