Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 145 additions & 31 deletions __tests__/lib/mdxish/magic-blocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': '<h1>this should be a h1 element node</h1>',
'0-1': '<strong>this should be a strong element node</strong>',
[block:parameters]
${JSON.stringify(
{
data: {
'h-0': 'Header 0',
'h-1': 'Header 1',
'0-0': '<h1>this should be a h1 element node</h1>',
'0-1': '<strong>this should be a strong element node</strong>',
},
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
Expand All @@ -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
Expand All @@ -144,4 +144,118 @@ ${JSON.stringify(
expect((cell1.children[0] as Element).tagName).toBe('em');
});
});
});

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);
});
});
});
});
105 changes: 103 additions & 2 deletions processor/transform/mdxish/mdxish-magic-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,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.
*
Expand All @@ -387,7 +411,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);
Expand All @@ -397,8 +431,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;