From 80a7382d5d143ff0a3e9103e94b57fcdbf174712 Mon Sep 17 00:00:00 2001 From: Maximilian Falco Widjaya Date: Fri, 12 Dec 2025 10:23:00 +0700 Subject: [PATCH 1/4] fix: add validation before accessing props in CodeTabs --- components/CodeTabs/index.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/components/CodeTabs/index.tsx b/components/CodeTabs/index.tsx index 81cde0dd5..0f4ba2786 100644 --- a/components/CodeTabs/index.tsx +++ b/components/CodeTabs/index.tsx @@ -16,7 +16,7 @@ interface Props { const CodeTabs = (props: Props) => { const { children } = props; const theme = useContext(ThemeContext); - const hasMermaid = !Array.isArray(children) && children.props?.children.props.lang === 'mermaid'; + const hasMermaid = !Array.isArray(children) && children?.props?.children?.props?.lang === 'mermaid'; // render Mermaid diagram useEffect(() => { @@ -48,7 +48,7 @@ const CodeTabs = (props: Props) => { // render single Mermaid diagram if (hasMermaid) { - const value = children.props.children.props.value; + const value = children?.props?.children?.props?.value; return
{value}
; } @@ -57,9 +57,9 @@ const CodeTabs = (props: Props) => {
{(Array.isArray(children) ? children : [children]).map((pre, i) => { // the first or only child should be our Code component - const codeComponent = Array.isArray(pre.props?.children) - ? pre.props.children[0] - : pre.props?.children; + const preProps = pre?.props || {}; + const preChildren = preProps.children; + const codeComponent = Array.isArray(preChildren) ? preChildren[0] : preChildren; const lang = codeComponent?.props?.lang; const meta = codeComponent?.props?.meta; From ee67709cd3721a173055644d8245e37de8de83ba Mon Sep 17 00:00:00 2001 From: Maximilian Falco Widjaya Date: Fri, 12 Dec 2025 11:28:44 +0700 Subject: [PATCH 2/4] Revert "fix: add validation before accessing props in CodeTabs" This reverts commit 80a7382d5d143ff0a3e9103e94b57fcdbf174712. --- components/CodeTabs/index.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/components/CodeTabs/index.tsx b/components/CodeTabs/index.tsx index 0f4ba2786..81cde0dd5 100644 --- a/components/CodeTabs/index.tsx +++ b/components/CodeTabs/index.tsx @@ -16,7 +16,7 @@ interface Props { const CodeTabs = (props: Props) => { const { children } = props; const theme = useContext(ThemeContext); - const hasMermaid = !Array.isArray(children) && children?.props?.children?.props?.lang === 'mermaid'; + const hasMermaid = !Array.isArray(children) && children.props?.children.props.lang === 'mermaid'; // render Mermaid diagram useEffect(() => { @@ -48,7 +48,7 @@ const CodeTabs = (props: Props) => { // render single Mermaid diagram if (hasMermaid) { - const value = children?.props?.children?.props?.value; + const value = children.props.children.props.value; return
{value}
; } @@ -57,9 +57,9 @@ const CodeTabs = (props: Props) => {
{(Array.isArray(children) ? children : [children]).map((pre, i) => { // the first or only child should be our Code component - const preProps = pre?.props || {}; - const preChildren = preProps.children; - const codeComponent = Array.isArray(preChildren) ? preChildren[0] : preChildren; + const codeComponent = Array.isArray(pre.props?.children) + ? pre.props.children[0] + : pre.props?.children; const lang = codeComponent?.props?.lang; const meta = codeComponent?.props?.meta; From 64175baf24c691d784a4768fbe29ca618a29c08d Mon Sep 17 00:00:00 2001 From: Maximilian Falco Widjaya Date: Fri, 12 Dec 2025 11:44:55 +0700 Subject: [PATCH 3/4] refactor: change validation to be in mdxish-magic-blocks instead --- .../transform/mdxish/mdxish-magic-blocks.ts | 105 +++++++++++++++++- 1 file changed, 103 insertions(+), 2 deletions(-) diff --git a/processor/transform/mdxish/mdxish-magic-blocks.ts b/processor/transform/mdxish/mdxish-magic-blocks.ts index 2fd1c0bac..96faff6fe 100644 --- a/processor/transform/mdxish/mdxish-magic-blocks.ts +++ b/processor/transform/mdxish/mdxish-magic-blocks.ts @@ -356,6 +356,30 @@ function parseMagicBlock(raw: string, options: ParseMagicBlockOptions = {}): Mda } } +/** + * Check if a node is a block-level node (cannot be inside a paragraph) + */ +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', + ]; + return blockTypes.includes(node.type); +}; + /** * Unified plugin that restores magic blocks from placeholder tokens. * @@ -371,7 +395,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 + // 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; const raw = magicBlockKeys.get(node.value); @@ -381,8 +415,75 @@ const magicBlockRestorer: Plugin<[{ blocks: BlockHit[] }], MdastRoot> = const children = parseMagicBlock(raw) as unknown as RootContent[]; if (!children.length) return; - parent.children.splice(index, 1, ...children); + // If parent is a paragraph and we're inserting code-tabs (which must not be in paragraphs), lift them out + if (parent.type === 'paragraph' && children.some(child => child.type === 'code-tabs')) { + const blockNodes: RootContent[] = []; + const inlineNodes: RootContent[] = []; + + // Separate block and inline nodes + children.forEach(child => { + if (isBlockNode(child)) { + blockNodes.push(child); + } else { + inlineNodes.push(child); + } + }); + + const before = parent.children.slice(0, index); + const after = parent.children.slice(index + 1); + + replacements.push({ + parent, + index, + blockNodes, + inlineNodes, + before, + after, + }); + } else { + // Normal case: just replace the inlineCode with the children + 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; From b4341acfe9874353465ef253137c6ce6cd6fb1ea Mon Sep 17 00:00:00 2001 From: Maximilian Falco Widjaya Date: Fri, 12 Dec 2025 13:35:44 +0700 Subject: [PATCH 4/4] add some tests --- __tests__/lib/mdxish/magic-blocks.test.ts | 116 +++++++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/__tests__/lib/mdxish/magic-blocks.test.ts b/__tests__/lib/mdxish/magic-blocks.test.ts index 7943a6e68..598710518 100644 --- a/__tests__/lib/mdxish/magic-blocks.test.ts +++ b/__tests__/lib/mdxish/magic-blocks.test.ts @@ -66,5 +66,119 @@ ${JSON.stringify( expect((element.children[0] as Element).tagName).toBe('thead'); expect((element.children[1] as Element).tagName).toBe('tbody'); }); - }) + }); + + 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); + }); + }); + }); });