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;