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