diff --git a/__tests__/lib/mdxish/magic-blocks.test.ts b/__tests__/lib/mdxish/magic-blocks.test.ts index f22892985..4ffd6068d 100644 --- a/__tests__/lib/mdxish/magic-blocks.test.ts +++ b/__tests__/lib/mdxish/magic-blocks.test.ts @@ -69,22 +69,22 @@ ${JSON.stringify( it('should convert html content inside table cells as nodes in the ast', () => { const md = ` -[block:parameters] -${JSON.stringify( - { - data: { - 'h-0': 'Header 0', - 'h-1': 'Header 1', - '0-0': '

this should be a h1 element node

', - '0-1': 'this should be a strong element node', + [block:parameters] + ${JSON.stringify( + { + data: { + 'h-0': 'Header 0', + 'h-1': 'Header 1', + '0-0': '

this should be a h1 element node

', + '0-1': 'this should be a strong element node', + }, + cols: 2, + rows: 1, }, - cols: 2, - rows: 1, - }, - null, - 2, -)} -[/block]`; + null, + 2, + )} + [/block]`; const ast = mdxish(md); // Some extra children are added to the AST by the mdxish wrapper @@ -108,22 +108,22 @@ ${JSON.stringify( it('should restore markdown content inside table cells', () => { const md = ` -[block:parameters] -${JSON.stringify( - { - data: { - 'h-0': 'Header 0', - 'h-1': 'Header 1', - '0-0': '**Bold**', - '0-1': '*Italic*', + [block:parameters] + ${JSON.stringify( + { + data: { + 'h-0': 'Header 0', + 'h-1': 'Header 1', + '0-0': '**Bold**', + '0-1': '*Italic*', + }, + cols: 2, + rows: 1, }, - cols: 2, - rows: 1, - }, - null, - 2, -)} -[/block]`; + null, + 2, + )} + [/block]`; const ast = mdxish(md); // Some extra children are added to the AST by the mdxish wrapper @@ -145,6 +145,120 @@ ${JSON.stringify( }); }); + describe('code block', () => { + it('should create code-tabs for multiple code blocks', () => { + const md = `[block:code] + { + "codes": [ + { + "code": "echo 'Hello World'", + "language": "bash" + }, + { + "code": "print('Hello World')", + "language": "python" + } + ] + } + [/block]`; + + const ast = mdxish(md); + + // Find the code-tabs element + const codeTabsElement = ast.children.find( + child => child.type === 'element' && (child as Element).tagName === 'CodeTabs', + ) as Element; + + expect(codeTabsElement).toBeDefined(); + expect(codeTabsElement.tagName).toBe('CodeTabs'); + }); + + it('should not wrap code-tabs in paragraph tags', () => { + const md = `Some text before + + [block:code] + { + "codes": [ + { + "code": "echo 'Hello World'", + "language": "bash" + }, + { + "code": "print('Hello World')", + "language": "python" + } + ] + } + [/block] + + Some text after`; + + const ast = mdxish(md); + + // Find the code-tabs element + const codeTabsElement = ast.children.find( + child => child.type === 'element' && (child as Element).tagName === 'CodeTabs', + ) as Element; + + expect(codeTabsElement).toBeDefined(); + expect(codeTabsElement.tagName).toBe('CodeTabs'); + + // Verify code-tabs is NOT inside a paragraph + // Check all paragraph elements to ensure none contain CodeTabs + const paragraphs = ast.children.filter( + child => child.type === 'element' && (child as Element).tagName === 'p', + ) as Element[]; + + paragraphs.forEach(paragraph => { + const hasCodeTabs = paragraph.children.some( + child => child.type === 'element' && (child as Element).tagName === 'CodeTabs', + ); + expect(hasCodeTabs).toBe(false); + }); + + // Verify code-tabs is at the root level (not nested in a paragraph) + expect(ast.children).toContain(codeTabsElement); + }); + + it('should lift code-tabs out of paragraphs when inserted mid-paragraph', () => { + const md = `Before text [block:code] + { + "codes": [ + { + "code": "echo 'First command'", + "language": "bash" + }, + { + "code": "echo 'Second command'", + "language": "bash" + } + ] + } + [/block] after text`; + + const ast = mdxish(md); + + // Find the code-tabs element + const codeTabsElement = ast.children.find( + child => child.type === 'element' && (child as Element).tagName === 'CodeTabs', + ) as Element; + + expect(codeTabsElement).toBeDefined(); + + // Verify code-tabs is at root level, not inside a paragraph + const paragraphs = ast.children.filter( + child => child.type === 'element' && (child as Element).tagName === 'p', + ) as Element[]; + + paragraphs.forEach(paragraph => { + const hasCodeTabs = paragraph.children.some( + child => child.type === 'element' && (child as Element).tagName === 'CodeTabs', + ); + expect(hasCodeTabs).toBe(false); + }); + }); + }); + describe('callout block', () => { it('should restore callout block', () => { const md = '[block:callout]{"type":"info","title":"Note","body":"This is important"}[/block]'; diff --git a/processor/transform/mdxish/mdxish-magic-blocks.ts b/processor/transform/mdxish/mdxish-magic-blocks.ts index ad0a39bda..9547e478a 100644 --- a/processor/transform/mdxish/mdxish-magic-blocks.ts +++ b/processor/transform/mdxish/mdxish-magic-blocks.ts @@ -12,7 +12,7 @@ import type { Plugin } from 'unified'; import remarkGfm from 'remark-gfm'; import remarkParse from 'remark-parse'; import { unified } from 'unified'; -import { SKIP, visit } from 'unist-util-visit'; +import { visit } from 'unist-util-visit'; import { toAttributes } from '../../utils'; @@ -393,10 +393,28 @@ function parseMagicBlock(raw: string, options: ParseMagicBlockOptions = {}): Mda } /** - * Check if a child node is a flow element that needs unwrapping (mdxJsxFlowElement, etc.) + * Check if a node is a block-level node (cannot be inside a paragraph) */ -const needsUnwrapping = (child: RootContent): boolean => { - return child.type === 'mdxJsxFlowElement'; +const isBlockNode = (node: RootContent): boolean => { + const blockTypes = [ + 'heading', + 'code', + 'code-tabs', + 'paragraph', + 'blockquote', + 'list', + 'table', + 'thematicBreak', + 'html', + 'yaml', + 'toml', + 'rdme-pin', + 'rdme-callout', + 'html-block', + 'embed', + 'mdxJsxFlowElement', + ]; + return blockTypes.includes(node.type); }; /** @@ -414,9 +432,17 @@ const magicBlockRestorer: Plugin<[{ blocks: BlockHit[] }], MdastRoot> = // Map: key → original raw magic block content const magicBlockKeys = new Map(blocks.map(({ key, raw }) => [key, raw] as const)); - // Find inlineCode nodes that match our placeholder tokens - const modifications: { children: RootContent[]; index: number; parent: Parent }[] = []; - + // Collect replacements to apply (we need to visit in reverse to maintain indices) + const replacements: { + after: RootContent[]; + before: RootContent[]; + blockNodes: RootContent[]; + index: number; + inlineNodes: RootContent[]; + parent: Parent; + }[] = []; + + // First pass: collect all replacements visit(tree, 'inlineCode', (node: Code, index: number, parent: Parent) => { if (!parent || index == null) return undefined; const raw = magicBlockKeys.get(node.value); @@ -425,34 +451,76 @@ const magicBlockRestorer: Plugin<[{ blocks: BlockHit[] }], MdastRoot> = const children = parseMagicBlock(raw) as unknown as RootContent[]; if (!children.length) return undefined; - if (children[0] && needsUnwrapping(children[0]) && parent.type === 'paragraph') { - // Find paragraph's parent and unwrap - let paragraphParent: Parent | undefined; - visit(tree, 'paragraph', (p, pIndex, pParent) => { - if (p === parent && pParent && 'children' in pParent) { - paragraphParent = pParent as Parent; - return false; + // If parent is a paragraph and we're inserting block nodes (which must not be in paragraphs), lift them out + if (parent.type === 'paragraph' && children.some(child => isBlockNode(child))) { + const blockNodes: RootContent[] = []; + const inlineNodes: RootContent[] = []; + + // Separate block and inline nodes + children.forEach(child => { + if (isBlockNode(child)) { + blockNodes.push(child); + } else { + inlineNodes.push(child); } - return undefined; }); - if (paragraphParent) { - const paragraphIndex = paragraphParent.children.indexOf(parent as RootContent); - if (paragraphIndex !== -1) { - modifications.push({ children, index: paragraphIndex, parent: paragraphParent }); - } - } - return SKIP; - } + const before = parent.children.slice(0, index); + const after = parent.children.slice(index + 1); - parent.children.splice(index, 1, ...children); - return [SKIP, index + children.length]; + replacements.push({ + parent, + index, + blockNodes, + inlineNodes, + before, + after, + }); + } else { + // Normal case: just replace the inlineCode with the children + parent.children.splice(index, 1, ...children); + } + return undefined; }); - // Apply modifications in reverse order to avoid index shifting - modifications.reverse().forEach(({ children, index, parent }) => { - parent.children.splice(index, 1, ...children); - }); + // Second pass: apply replacements that require lifting block nodes out of paragraphs + // Process in reverse order to maintain correct indices + for (let i = replacements.length - 1; i >= 0; i -= 1) { + const { after, before, blockNodes, inlineNodes, parent } = replacements[i]; + + // Find the paragraph's position in the root + const rootChildren = (tree as unknown as { children: RootContent[] }).children; + const paraIndex = rootChildren.indexOf(parent as never); + if (paraIndex === -1) { + // Paragraph not found in root - fall back to normal replacement + // This shouldn't happen normally, but handle it gracefully + // Reconstruct the original index from before.length + const originalIndex = before.length; + parent.children.splice(originalIndex, 1, ...blockNodes, ...inlineNodes); + // eslint-disable-next-line no-continue + continue; + } + + // Update or remove the paragraph + if (inlineNodes.length > 0) { + // Keep paragraph with inline nodes + parent.children = [...before, ...inlineNodes, ...after]; + // Insert block nodes after the paragraph + if (blockNodes.length > 0) { + rootChildren.splice(paraIndex + 1, 0, ...blockNodes); + } + } else if (before.length === 0 && after.length === 0) { + // Remove empty paragraph and replace with block nodes + rootChildren.splice(paraIndex, 1, ...blockNodes); + } else { + // Keep paragraph with remaining content + parent.children = [...before, ...after]; + // Insert block nodes after the paragraph + if (blockNodes.length > 0) { + rootChildren.splice(paraIndex + 1, 0, ...blockNodes); + } + } + } }; export default magicBlockRestorer;