Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion __tests__/lib/mdx.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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('\\>');
});
});
});
40 changes: 37 additions & 3 deletions __tests__/lib/mdxish/mdxish.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { mdxish } from '../../../lib/mdxish';
import { extractText } from '../../../processor/transform/extract-text';

describe('mdxish', () => {
describe('invalid mdx syntax', () => {
Expand All @@ -7,10 +8,43 @@ describe('mdxish', () => {
expect(() => mdxish(md)).not.toThrow();
});

it('should render content in new lines', () => {
const md = `<div>hello
it('should render content in new lines', () => {
const md = `<div>hello
</div>`;
expect(() => mdxish(md)).not.toThrow();
});
});
});

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 <blockquote> 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('>');
});
});
});
51 changes: 51 additions & 0 deletions __tests__/transformers/callouts.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
});
3 changes: 2 additions & 1 deletion lib/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -39,6 +39,7 @@ const compile = (
const remarkPlugins: PluggableList = [
remarkFrontmatter,
remarkGfm,
[calloutTransformer, { format: opts.format }],
...Object.values(transforms),
[codeTabsTransformer, { copyButtons }],
[
Expand Down
6 changes: 4 additions & 2 deletions lib/mdxish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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(),
Expand All @@ -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)
Expand Down
51 changes: 47 additions & 4 deletions processor/transform/callouts.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 => {
Expand All @@ -30,17 +32,58 @@ 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();
const [match, icon] = startText.match(regex) || [];

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';

Expand Down
25 changes: 25 additions & 0 deletions processor/transform/extract-text.ts
Original file line number Diff line number Diff line change
@@ -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 '';
};
1 change: 1 addition & 0 deletions processor/transform/mdxish/mdxish-tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down