diff --git a/__tests__/lib/mdxish/mdx-expressions.test.ts b/__tests__/lib/mdxish/mdx-expressions.test.ts
index 84602ffd0..ddc234267 100644
--- a/__tests__/lib/mdxish/mdx-expressions.test.ts
+++ b/__tests__/lib/mdxish/mdx-expressions.test.ts
@@ -644,4 +644,785 @@ Third {c} paragraph.`;
expect(blockquote).toBeDefined();
});
});
+
+ describe('multiline template literal children in JSX components', () => {
+ it('should not throw a parse error for multiline template literal children', () => {
+ const md = `{\`
+ $ npx run command
+ This is the response
+\`}`;
+
+ expect(() => mdxish(md)).not.toThrow();
+ });
+
+ it('should preserve newlines in multiline template literal expression results', () => {
+ const md = `{\`line1\nline2\nline3\`}`;
+ const ast = mdxish(md);
+
+ const p = ast.children.find(c => (c as Element).tagName === 'p') as Element;
+ // remarkBreaks splits \n into separate text nodes with
elements between them
+ const allText = p.children
+ .filter(c => c.type === 'text')
+ .map(c => (c as Text).value)
+ .join('');
+
+ expect(allText).toContain('line1');
+ expect(allText).toContain('line2');
+ expect(allText).toContain('line3');
+ });
+
+ it('should pass the full multiline string to component children', () => {
+ const mockTerminalModule = {
+ default: () => null,
+ Toc: null,
+ toc: [],
+ };
+
+ const md = `{\`
+ $ npx run command
+ This is the response
+
+ $ inputs start with a dollar sign
+ outputs can be multiline
+\`}`;
+
+ const ast = mdxish(md, { components: { Terminal: mockTerminalModule as any } });
+
+ // Terminal is inside a
(from paragraph wrapping), search recursively
+ const findElement = (node: any, tagName: string): Element | undefined => {
+ if (node.tagName === tagName) return node as Element;
+ if (node.children) {
+ for (const child of node.children) {
+ const found = findElement(child, tagName);
+ if (found) return found;
+ }
+ }
+ return undefined;
+ };
+
+ const terminal = findElement(ast, 'Terminal');
+ expect(terminal).toBeDefined();
+
+ // Children should be a single text node with newlines preserved
+ const findText = (node: Element | Text): string => {
+ if (node.type === 'text') return (node as Text).value;
+ if ('children' in node) return (node as Element).children.map(c => findText(c as Element | Text)).join('');
+ return '';
+ };
+ const content = findText(terminal!);
+ expect(content).toContain('$ npx run command');
+ expect(content).toContain('This is the response');
+ expect(content).toContain('\n');
+ });
+
+ it('should preserve a single text child node for component expecting string children', () => {
+ const mockTerminalModule = {
+ default: () => null,
+ Toc: null,
+ toc: [],
+ };
+
+ const md = `{\`
+ $ echo hello
+ hello
+\`}`;
+
+ const ast = mdxish(md, { components: { Terminal: mockTerminalModule as any } });
+
+ const findElement = (node: any, tagName: string): Element | undefined => {
+ if (node.tagName === tagName) return node as Element;
+ if (node.children) {
+ for (const child of node.children) {
+ const found = findElement(child, tagName);
+ if (found) return found;
+ }
+ }
+ return undefined;
+ };
+
+ const terminal = findElement(ast, 'Terminal');
+ expect(terminal).toBeDefined();
+
+ // Should be a single text child, not multiple elements
+ expect(terminal!.children).toHaveLength(1);
+ expect(terminal!.children[0].type).toBe('text');
+
+ const textNode = terminal!.children[0] as Text;
+ expect(textNode.value).toContain('$ echo hello');
+ expect(textNode.value).toContain('hello');
+ expect(textNode.value).toContain('\n');
+ });
+
+ it('should preserve indentation in multiline template literal children', () => {
+ const mockModule = { default: () => null, Toc: null, toc: [] };
+
+ const md = `{\`
+ function hello() {
+ console.log("world");
+ }
+\`}`;
+
+ const ast = mdxish(md, { components: { CodeBlock: mockModule as any } });
+
+ const findElement = (node: any, tagName: string): Element | undefined => {
+ if (node.tagName === tagName) return node as Element;
+ if (node.children) {
+ for (const child of node.children) {
+ const found = findElement(child, tagName);
+ if (found) return found;
+ }
+ }
+ return undefined;
+ };
+
+ const codeBlock = findElement(ast, 'CodeBlock');
+ expect(codeBlock).toBeDefined();
+
+ const textNode = codeBlock!.children[0] as Text;
+ expect(textNode.type).toBe('text');
+ // Leading/trailing whitespace is trimmed, but relative indentation is preserved
+ expect(textNode.value).toContain('function hello()');
+ expect(textNode.value).toContain(' console.log');
+ });
+
+ it('should handle empty lines in template literal children', () => {
+ const mockModule = { default: () => null, Toc: null, toc: [] };
+
+ const md = `{\`
+ $ first command
+ output
+
+ $ second command
+ more output
+\`}`;
+
+ const ast = mdxish(md, { components: { Terminal: mockModule as any } });
+
+ const findElement = (node: any, tagName: string): Element | undefined => {
+ if (node.tagName === tagName) return node as Element;
+ if (node.children) {
+ for (const child of node.children) {
+ const found = findElement(child, tagName);
+ if (found) return found;
+ }
+ }
+ return undefined;
+ };
+
+ const terminal = findElement(ast, 'Terminal');
+ expect(terminal).toBeDefined();
+
+ const textNode = terminal!.children[0] as Text;
+ expect(textNode.type).toBe('text');
+ expect(textNode.value).toContain('$ first command');
+ expect(textNode.value).toContain('$ second command');
+ // Empty line should be preserved as double newline
+ expect(textNode.value).toMatch(/output\n\n/);
+ });
+
+ it('should handle template literal with only whitespace and newlines', () => {
+ const mockModule = { default: () => null, Toc: null, toc: [] };
+
+ const md = `{\`
+\`}`;
+
+ const ast = mdxish(md, { components: { Spacer: mockModule as any } });
+
+ const findElement = (node: any, tagName: string): Element | undefined => {
+ if (node.tagName === tagName) return node as Element;
+ if (node.children) {
+ for (const child of node.children) {
+ const found = findElement(child, tagName);
+ if (found) return found;
+ }
+ }
+ return undefined;
+ };
+
+ const spacer = findElement(ast, 'Spacer');
+ expect(spacer).toBeDefined();
+ });
+
+ it('should not affect regular text children of components (non-expression)', () => {
+ const mockModule = {
+ default: () => null,
+ Toc: null,
+ toc: [],
+ };
+
+ const md = `
+
+ This is regular markdown content.
+
+`;
+
+ const ast = mdxish(md, { components: { MyComponent: mockModule as any } });
+
+ const findElement = (node: any, tagName: string): Element | undefined => {
+ if (node.tagName === tagName) return node as Element;
+ if (node.children) {
+ for (const child of node.children) {
+ const found = findElement(child, tagName);
+ if (found) return found;
+ }
+ }
+ return undefined;
+ };
+
+ const comp = findElement(ast, 'MyComponent');
+ expect(comp).toBeDefined();
+ // Regular markdown text should be processed through markdown (wrapped in
)
+ const hasP = comp!.children.some(c => c.type === 'element' && (c as Element).tagName === 'p');
+ expect(hasP).toBe(true);
+ });
+
+ it('should handle component with both attributes and template literal children', () => {
+ const mockModule = { default: () => null, Toc: null, toc: [] };
+
+ const md = `{\`
+ $ npm install
+ added 50 packages
+\`}`;
+
+ const ast = mdxish(md, { components: { Terminal: mockModule as any } });
+
+ const findElement = (node: any, tagName: string): Element | undefined => {
+ if (node.tagName === tagName) return node as Element;
+ if (node.children) {
+ for (const child of node.children) {
+ const found = findElement(child, tagName);
+ if (found) return found;
+ }
+ }
+ return undefined;
+ };
+
+ const terminal = findElement(ast, 'Terminal');
+ expect(terminal).toBeDefined();
+ expect(terminal!.properties?.title).toBe('My Terminal');
+
+ const textNode = terminal!.children[0] as Text;
+ expect(textNode.type).toBe('text');
+ expect(textNode.value).toContain('$ npm install');
+ expect(textNode.value).toContain('added 50 packages');
+ expect(textNode.value).toContain('\n');
+ });
+
+ it('should handle special characters in template literal children', () => {
+ const mockModule = { default: () => null, Toc: null, toc: [] };
+
+ const md = `{\`
+ $ echo "hello world"
+ hello world
+ $ echo 'single quotes'
+ single quotes
+\`}`;
+
+ const ast = mdxish(md, { components: { Terminal: mockModule as any } });
+
+ const findElement = (node: any, tagName: string): Element | undefined => {
+ if (node.tagName === tagName) return node as Element;
+ if (node.children) {
+ for (const child of node.children) {
+ const found = findElement(child, tagName);
+ if (found) return found;
+ }
+ }
+ return undefined;
+ };
+
+ const terminal = findElement(ast, 'Terminal');
+ expect(terminal).toBeDefined();
+
+ const textNode = terminal!.children[0] as Text;
+ expect(textNode.value).toContain('"hello world"');
+ expect(textNode.value).toContain("'single quotes'");
+ });
+ });
+
+ describe('nested components with multiline children', () => {
+ const mockModule = { default: () => null, Toc: null, toc: [] };
+
+ const findElement = (node: any, tagName: string): Element | undefined => {
+ if (node.tagName === tagName) return node as Element;
+ if (node.children) {
+ for (const child of node.children) {
+ const found = findElement(child, tagName);
+ if (found) return found;
+ }
+ }
+ return undefined;
+ };
+
+ it('should handle Terminal inside another component', () => {
+ const md = `{\`
+ $ npm install
+ done
+\`}`;
+
+ const ast = mdxish(md, {
+ components: { Card: mockModule as any, Terminal: mockModule as any },
+ });
+
+ const card = findElement(ast, 'Card');
+ expect(card).toBeDefined();
+
+ const terminal = findElement(card!, 'Terminal');
+ expect(terminal).toBeDefined();
+
+ const textNode = terminal!.children[0] as Text;
+ expect(textNode.type).toBe('text');
+ expect(textNode.value).toContain('$ npm install');
+ expect(textNode.value).toContain('\n');
+ });
+
+ it('should handle multiple nested levels with multiline children', () => {
+ const md = `{\`
+ $ deep nested command
+ output here
+\`}`;
+
+ const ast = mdxish(md, {
+ components: {
+ Accordion: mockModule as any,
+ AccordionItem: mockModule as any,
+ Terminal: mockModule as any,
+ },
+ });
+
+ const accordion = findElement(ast, 'Accordion');
+ expect(accordion).toBeDefined();
+
+ const terminal = findElement(accordion!, 'Terminal');
+ expect(terminal).toBeDefined();
+
+ const textNode = terminal!.children[0] as Text;
+ expect(textNode.value).toContain('$ deep nested command');
+ expect(textNode.value).toContain('\n');
+ });
+
+ it('should handle sibling components with multiline children', () => {
+ const md = `
+{\`
+ $ first terminal
+ output 1
+\`}
+{\`
+ $ second terminal
+ output 2
+\`}
+`;
+
+ const ast = mdxish(md, {
+ components: {
+ Tabs: mockModule as any,
+ Tab: mockModule as any,
+ Terminal: mockModule as any,
+ },
+ });
+
+ const tabs = findElement(ast, 'Tabs');
+ expect(tabs).toBeDefined();
+
+ // Find all Terminal elements
+ const terminals: Element[] = [];
+ const findAllTerminals = (node: any) => {
+ if (node.tagName === 'Terminal') terminals.push(node);
+ if (node.children) node.children.forEach(findAllTerminals);
+ };
+ findAllTerminals(tabs);
+
+ expect(terminals).toHaveLength(2);
+
+ // Helper to extract text content from terminal children
+ const getTextContent = (terminal: Element): string => {
+ const collectText = (node: any): string => {
+ if (node.type === 'text') return node.value;
+ if (node.children) return node.children.map(collectText).join('');
+ return '';
+ };
+ return collectText(terminal);
+ };
+
+ expect(getTextContent(terminals[0])).toContain('$ first terminal');
+ expect(getTextContent(terminals[1])).toContain('$ second terminal');
+ });
+ });
+
+ describe('multiple Terminal instances in same document', () => {
+ const mockModule = { default: () => null, Toc: null, toc: [] };
+
+ const findAllElements = (node: any, tagName: string): Element[] => {
+ const results: Element[] = [];
+ const search = (n: any) => {
+ if (n.tagName === tagName) results.push(n);
+ if (n.children) n.children.forEach(search);
+ };
+ search(node);
+ return results;
+ };
+
+ it('should handle multiple Terminals in sequence', () => {
+ const md = `{\`
+ $ first command
+ first output
+\`}
+
+{\`
+ $ second command
+ second output
+\`}
+
+{\`
+ $ third command
+ third output
+\`}`;
+
+ const ast = mdxish(md, { components: { Terminal: mockModule as any } });
+
+ const terminals = findAllElements(ast, 'Terminal');
+ expect(terminals).toHaveLength(3);
+
+ expect((terminals[0].children[0] as Text).value).toContain('$ first command');
+ expect((terminals[1].children[0] as Text).value).toContain('$ second command');
+ expect((terminals[2].children[0] as Text).value).toContain('$ third command');
+ });
+
+ it('should handle Terminals with different content lengths', () => {
+ // Use multiline content so newline markers are triggered
+ const shortContent = '$ short\noutput';
+ const longContent = Array(20).fill('$ long line with content').join('\n');
+
+ const md = `{\`${shortContent}\`}
+
+{\`${longContent}\`}`;
+
+ const ast = mdxish(md, { components: { Terminal: mockModule as any } });
+
+ const terminals = findAllElements(ast, 'Terminal');
+ expect(terminals).toHaveLength(2);
+
+ // Helper to extract text content from terminal children
+ const getTextContent = (terminal: Element): string => {
+ const collectText = (node: any): string => {
+ if (node.type === 'text') return node.value;
+ if (node.children) return node.children.map(collectText).join('');
+ return '';
+ };
+ return collectText(terminal);
+ };
+
+ expect(getTextContent(terminals[0])).toContain('$ short');
+ expect(getTextContent(terminals[1]).split('\n')).toHaveLength(20);
+ });
+
+ it('should handle Terminals interleaved with other content', () => {
+ const md = `# Introduction
+
+Some text here.
+
+{\`
+ $ npm install
+ done
+\`}
+
+More text explaining things.
+
+{\`
+ $ npm start
+ running
+\`}
+
+## Conclusion`;
+
+ const ast = mdxish(md, { components: { Terminal: mockModule as any } });
+
+ const terminals = findAllElements(ast, 'Terminal');
+ expect(terminals).toHaveLength(2);
+
+ // Verify headings also exist
+ const h1 = findAllElements(ast, 'h1');
+ const h2 = findAllElements(ast, 'h2');
+ expect(h1).toHaveLength(1);
+ expect(h2).toHaveLength(1);
+ });
+ });
+
+ describe('error recovery for malformed inputs', () => {
+ const mockModule = { default: () => null, Toc: null, toc: [] };
+
+ it('should not crash on empty expression', () => {
+ const md = `{}`;
+
+ expect(() => mdxish(md, { components: { Terminal: mockModule as any } })).not.toThrow();
+ });
+
+ it('should handle numeric expression result', () => {
+ const md = `{42}`;
+
+ const ast = mdxish(md, { components: { Terminal: mockModule as any } });
+
+ const findElement = (node: any, tagName: string): Element | undefined => {
+ if (node.tagName === tagName) return node as Element;
+ if (node.children) {
+ for (const child of node.children) {
+ const found = findElement(child, tagName);
+ if (found) return found;
+ }
+ }
+ return undefined;
+ };
+
+ const terminal = findElement(ast, 'Terminal');
+ expect(terminal).toBeDefined();
+ });
+
+ it('should handle null expression result', () => {
+ const md = `{null}`;
+
+ expect(() => mdxish(md, { components: { Terminal: mockModule as any } })).not.toThrow();
+ });
+
+ it('should handle undefined expression result', () => {
+ const md = `{undefined}`;
+
+ expect(() => mdxish(md, { components: { Terminal: mockModule as any } })).not.toThrow();
+ });
+
+ it('should handle array expression result', () => {
+ const md = `{[1, 2, 3]}`;
+
+ expect(() => mdxish(md, { components: { Terminal: mockModule as any } })).not.toThrow();
+ });
+
+ it('should handle object expression result', () => {
+ const md = `{{ key: "value" }}`;
+
+ expect(() => mdxish(md, { components: { Terminal: mockModule as any } })).not.toThrow();
+ });
+
+ it('should handle boolean expression result', () => {
+ const md = `{true}`;
+
+ expect(() => mdxish(md, { components: { Terminal: mockModule as any } })).not.toThrow();
+ });
+ });
+
+ describe('edge cases for multiline template literals', () => {
+ const mockModule = { default: () => null, Toc: null, toc: [] };
+
+ const findElement = (node: any, tagName: string): Element | undefined => {
+ if (node.tagName === tagName) return node as Element;
+ if (node.children) {
+ for (const child of node.children) {
+ const found = findElement(child, tagName);
+ if (found) return found;
+ }
+ }
+ return undefined;
+ };
+
+ it('should handle Unicode characters in multiline content', () => {
+ const md = `{\`
+ $ echo "こんにちは"
+ こんにちは
+ $ echo "🎉 emoji test 🚀"
+ 🎉 emoji test 🚀
+\`}`;
+
+ const ast = mdxish(md, { components: { Terminal: mockModule as any } });
+
+ const terminal = findElement(ast, 'Terminal');
+ expect(terminal).toBeDefined();
+
+ const textNode = terminal!.children[0] as Text;
+ expect(textNode.value).toContain('こんにちは');
+ expect(textNode.value).toContain('🎉');
+ expect(textNode.value).toContain('🚀');
+ });
+
+ it('should handle escaped backticks inside template literal', () => {
+ const md = `{\`
+ $ echo \\\`nested\\\`
+ \\\`nested\\\`
+\`}`;
+
+ const ast = mdxish(md, { components: { Terminal: mockModule as any } });
+
+ const terminal = findElement(ast, 'Terminal');
+ expect(terminal).toBeDefined();
+ // The backslashes should be processed
+ expect(terminal!.children.length).toBeGreaterThan(0);
+ });
+
+ it('should handle tab characters in multiline content', () => {
+ const md = `{\`
+ $ ls -la\t--color
+ output\twith\ttabs
+\`}`;
+
+ const ast = mdxish(md, { components: { Terminal: mockModule as any } });
+
+ const terminal = findElement(ast, 'Terminal');
+ expect(terminal).toBeDefined();
+
+ const textNode = terminal!.children[0] as Text;
+ expect(textNode.value).toContain('\t');
+ });
+
+ it('should handle Windows-style line endings (CRLF)', () => {
+ const md = `{\`$ cmd\r\noutput\r\nmore\`}`;
+
+ const ast = mdxish(md, { components: { Terminal: mockModule as any } });
+
+ const terminal = findElement(ast, 'Terminal');
+ expect(terminal).toBeDefined();
+ // Should process without error
+ expect(terminal!.children.length).toBeGreaterThan(0);
+ });
+
+ it('should handle mixed whitespace (spaces and tabs)', () => {
+ const md = `{\`
+ \t$ command with mixed whitespace\t
+ \toutput\t
+\`}`;
+
+ const ast = mdxish(md, { components: { Terminal: mockModule as any } });
+
+ const terminal = findElement(ast, 'Terminal');
+ expect(terminal).toBeDefined();
+
+ const textNode = terminal!.children[0] as Text;
+ expect(textNode.value).toContain('$ command with mixed whitespace');
+ });
+
+ it('should handle very long single line', () => {
+ // Add newline to trigger newline marker path
+ const longLine = '$ ' + 'x'.repeat(1000) + '\noutput';
+ const md = `{\`${longLine}\`}`;
+
+ const ast = mdxish(md, { components: { Terminal: mockModule as any } });
+
+ const terminal = findElement(ast, 'Terminal');
+ expect(terminal).toBeDefined();
+
+ // Helper to extract text content
+ const getTextContent = (node: Element): string => {
+ const collectText = (n: any): string => {
+ if (n.type === 'text') return n.value;
+ if (n.children) return n.children.map(collectText).join('');
+ return '';
+ };
+ return collectText(node);
+ };
+
+ expect(getTextContent(terminal!).length).toBeGreaterThan(1000);
+ });
+
+ it('should handle large multiline content (100+ lines)', () => {
+ const lines = Array(100)
+ .fill(null)
+ .map((_, i) => `$ command ${i}`)
+ .join('\n');
+ const md = `{\`${lines}\`}`;
+
+ const ast = mdxish(md, { components: { Terminal: mockModule as any } });
+
+ const terminal = findElement(ast, 'Terminal');
+ expect(terminal).toBeDefined();
+
+ const textNode = terminal!.children[0] as Text;
+ expect(textNode.value.split('\n')).toHaveLength(100);
+ });
+
+ it('should handle content with markdown-like characters', () => {
+ const md = `{\`
+ $ echo "# Not a heading"
+ # Not a heading
+ $ echo "**not bold**"
+ **not bold**
+ $ echo "[not a link](url)"
+ [not a link](url)
+\`}`;
+
+ const ast = mdxish(md, { components: { Terminal: mockModule as any } });
+
+ const terminal = findElement(ast, 'Terminal');
+ expect(terminal).toBeDefined();
+
+ const textNode = terminal!.children[0] as Text;
+ // Content should NOT be processed as markdown
+ expect(textNode.value).toContain('# Not a heading');
+ expect(textNode.value).toContain('**not bold**');
+ expect(textNode.value).toContain('[not a link](url)');
+ });
+
+ it('should handle content with HTML-like characters', () => {
+ const md = `{\`
+ $ echo "not html
"
+ not html
+ $ cat file.html
+ test
+\`}`;
+
+ const ast = mdxish(md, { components: { Terminal: mockModule as any } });
+
+ const terminal = findElement(ast, 'Terminal');
+ expect(terminal).toBeDefined();
+
+ const textNode = terminal!.children[0] as Text;
+ // Content should be preserved as text, not parsed as HTML
+ expect(textNode.value).toContain('
not html
');
+ });
+
+ it('should handle consecutive empty lines', () => {
+ const md = `{\`
+ $ command
+
+
+ $ another command
+\`}`;
+
+ const ast = mdxish(md, { components: { Terminal: mockModule as any } });
+
+ const terminal = findElement(ast, 'Terminal');
+ expect(terminal).toBeDefined();
+
+ const textNode = terminal!.children[0] as Text;
+ // Double empty line should produce triple newline
+ expect(textNode.value).toMatch(/command\n\n\n/);
+ });
+
+ it('should pass plain text children as string (not React elements)', () => {
+ const md = `$ npm install`;
+
+ const ast = mdxish(md, { components: { Terminal: mockModule as any } });
+
+ const terminal = findElement(ast, 'Terminal');
+ expect(terminal).toBeDefined();
+
+ // Children should be a single text node, not wrapped in
+ expect(terminal!.children).toHaveLength(1);
+ expect(terminal!.children[0].type).toBe('text');
+
+ const textNode = terminal!.children[0] as Text;
+ expect(textNode.value).toBe('$ npm install');
+ });
+
+ it('should handle multiline plain text children by extracting text', () => {
+ // For multiline plain text (not template literal), markdown creates separate paragraphs
+ // This is expected markdown behavior. Use template literals for preserving exact newlines.
+ const md = `$ npm install`;
+
+ const ast = mdxish(md, { components: { Terminal: mockModule as any } });
+
+ const terminal = findElement(ast, 'Terminal');
+ expect(terminal).toBeDefined();
+
+ // Single line text should be extracted as a text node
+ expect(terminal!.children).toHaveLength(1);
+ expect(terminal!.children[0].type).toBe('text');
+ expect((terminal!.children[0] as Text).value).toBe('$ npm install');
+ });
+ });
});
diff --git a/__tests__/lib/renderMdxish.test.tsx b/__tests__/lib/renderMdxish.test.tsx
index 3ec3e7d53..1f9eb717c 100644
--- a/__tests__/lib/renderMdxish.test.tsx
+++ b/__tests__/lib/renderMdxish.test.tsx
@@ -74,8 +74,10 @@ This should be outside`;
render();
const wrapper = screen.getByTestId('my-component');
- expect(wrapper.querySelectorAll('p')).toHaveLength(1);
+ // Text is passed directly to components as a text node (not wrapped in
)
+ // This ensures components like Terminal that expect string children work correctly
expect(screen.getByText('This is a component with a space in the content.')).toBeInTheDocument();
+ expect(wrapper).toContainElement(screen.getByText('This is a component with a space in the content.'));
expect(screen.getByText('This should be outside')).toBeInTheDocument();
expect(wrapper).not.toContainElement(screen.getByText('This should be outside'));
});
diff --git a/lib/utils/mdxish/mdxish-render-utils.tsx b/lib/utils/mdxish/mdxish-render-utils.tsx
index 49f237e49..6ad37317e 100644
--- a/lib/utils/mdxish/mdxish-render-utils.tsx
+++ b/lib/utils/mdxish/mdxish-render-utils.tsx
@@ -45,6 +45,18 @@ function createElementWithJsonProps(
...children: React.ReactNode[]
): React.ReactElement {
const parsedProps = parseJsonProps(props);
+
+ // Unwrap nested single-string arrays from hast-to-hyperscript
+ // Only unwrap [['string']] -> 'string', preserve all other cases
+ if (
+ children.length === 1 &&
+ Array.isArray(children[0]) &&
+ children[0].length === 1 &&
+ typeof children[0][0] === 'string'
+ ) {
+ return React.createElement(type, parsedProps, children[0][0]);
+ }
+
return React.createElement(type, parsedProps, ...children);
}
diff --git a/lib/utils/mdxish/protect-code-blocks.ts b/lib/utils/mdxish/protect-code-blocks.ts
index eea39f5eb..288d1f5f3 100644
--- a/lib/utils/mdxish/protect-code-blocks.ts
+++ b/lib/utils/mdxish/protect-code-blocks.ts
@@ -59,6 +59,17 @@ export function protectCodeBlocks(content: string): ProtectCodeBlocksResult {
return `___INLINE_CODE_${index}___`;
});
+ // Collapse multiline template literals in JSX expressions to single-line form.
+ // The micromark MDX text expression tokenizer exits on newlines, so `{`\nline\n`}`
+ // causes a parse error. Replacing actual newlines with \n escape sequences makes
+ // the expression single-line while preserving the evaluated string value.
+ // This runs after fenced blocks and inline code are protected, so their contents
+ // are not affected.
+ protectedContent = protectedContent.replace(/\{`((?:[^`\\]|\\.)*)`\}/g, (match, templateContent: string) => {
+ if (!/\n/.test(templateContent)) return match;
+ return `{\`${templateContent.replace(/\n/g, '\\n')}\`}`;
+ });
+
return { protectedCode: { codeBlocks, inlineCode }, protectedContent };
}
diff --git a/processor/plugin/mdxish-components.ts b/processor/plugin/mdxish-components.ts
index eb5f6802d..5d8a966b7 100644
--- a/processor/plugin/mdxish-components.ts
+++ b/processor/plugin/mdxish-components.ts
@@ -6,6 +6,7 @@ import type { VFile } from 'vfile';
import { visit } from 'unist-util-visit';
import { getComponentName } from '../../lib/utils/mdxish/mdxish-get-component-name';
+import { NEWLINE_MARKER } from '../plugin/mdxish-handlers';
import {
CUSTOM_PROP_BOUNDARIES,
CSS_STYLE_PROP_BOUNDARIES,
@@ -56,10 +57,27 @@ function isSingleParagraphTextNode(nodes: ElementContent[]): boolean {
nodes.length === 1 &&
nodes[0].type === 'element' &&
nodes[0].tagName === 'p' &&
- nodes[0].children?.every(child => child.type === 'text')
+ nodes[0].children?.every(child =>
+ child.type === 'text' || (child.type === 'element' && child.tagName === 'br'),
+ )
);
}
+/**
+ * Extract text content from a single paragraph element.
+ * Converts `
` elements to newlines.
+ */
+function extractTextFromParagraph(pElement: Element): string {
+ if (!pElement.children) return '';
+ return pElement.children
+ .map(child => {
+ if (child.type === 'text') return child.value;
+ if (child.type === 'element' && child.tagName === 'br') return '\n';
+ return '';
+ })
+ .join('');
+}
+
/**
* Convert lowercase compound words to camelCase using known word boundaries.
* e.g., "iconcolor" → "iconColor", "background-color" → "backgroundColor"
@@ -104,6 +122,17 @@ function parseTextChildren(node: Element, processMarkdown: (content: string) =>
node.children = node.children.flatMap(child => {
if (child.type !== 'text' || !child.value.trim()) return [child];
+ // Restore newlines encoded by evaluateExpressions to survive remarkBreaks and rehypeRaw.
+ // Preserve the text as-is instead of processing through markdown.
+ if (child.value.includes(NEWLINE_MARKER)) {
+ try {
+ return [{ type: 'text', value: child.value.replaceAll(NEWLINE_MARKER, '\n') } as ElementContent];
+ } catch {
+ // Fallback: return original text if decoding fails (should not happen)
+ return [child];
+ }
+ }
+
const hast = processMarkdown(child.value.trim());
const children = (hast.children ?? []).filter(isElementContentNode);
@@ -112,6 +141,15 @@ function parseTextChildren(node: Element, processMarkdown: (content: string) =>
return [child];
}
+ // For custom components with single paragraph text children, extract the text
+ // and pass it as a raw text node. This ensures components like Terminal that
+ // expect string children (e.g., `children.trim()`) receive strings, not React elements.
+ if (isSingleParagraphTextNode(children)) {
+ const pElement = children[0] as Element;
+ const textContent = extractTextFromParagraph(pElement);
+ return [{ type: 'text', value: textContent } as ElementContent];
+ }
+
return children;
});
diff --git a/processor/plugin/mdxish-handlers.ts b/processor/plugin/mdxish-handlers.ts
index bc06c55df..61e8e2166 100644
--- a/processor/plugin/mdxish-handlers.ts
+++ b/processor/plugin/mdxish-handlers.ts
@@ -5,6 +5,11 @@ import type { Handler, Handlers } from 'mdast-util-to-hast';
import { NodeTypes } from '../../enums';
+// Marker used to preserve newlines in JSX element text children through rehypeRaw.
+// rehypeRaw uses parse5 which normalizes \n in text nodes to spaces inside non-
+// elements. We encode \n as this marker before rehypeRaw, then decode in parseTextChildren.
+export const NEWLINE_MARKER = '___MDXISH_NL___';
+
// Convert MDX expressions to text nodes (evaluation happens earlier in pipeline)
const mdxExpressionHandler: Handler = (_state, node) => ({
type: 'text',
diff --git a/processor/transform/mdxish/evaluate-expressions.ts b/processor/transform/mdxish/evaluate-expressions.ts
index f4ed5cdbb..6900b52e3 100644
--- a/processor/transform/mdxish/evaluate-expressions.ts
+++ b/processor/transform/mdxish/evaluate-expressions.ts
@@ -4,6 +4,7 @@ import type { Plugin } from 'unified';
import { visit } from 'unist-util-visit';
+import { NEWLINE_MARKER } from '../../plugin/mdxish-handlers';
import { evaluateExpression, type JSXContext } from './preprocess-jsx-expressions';
/**
@@ -33,7 +34,14 @@ const evaluateExpressions: Plugin<[{ context?: JSXContext }], Root> =
} else if (typeof result === 'object') {
textValue = JSON.stringify(result);
} else {
- textValue = String(result).replace(/\s+/g, ' ').trim();
+ textValue = String(result).trim();
+ }
+
+ // Encode newlines when inside JSX elements to prevent remarkBreaks from
+ // splitting the text into separate nodes. Markers are decoded later in parseTextChildren.
+ const isJsxParent = parent.type === 'mdxJsxFlowElement' || parent.type === 'mdxJsxTextElement';
+ if (isJsxParent && textValue.includes('\n')) {
+ textValue = textValue.replace(/\n/g, NEWLINE_MARKER);
}
// Replace expression node with text node since the expression is conceptually a text