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
119 changes: 118 additions & 1 deletion __tests__/lib/mdxish/magic-blocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,122 @@ ${JSON.stringify(
expect((element.children[0] as Element).tagName).toBe('thead');
expect((element.children[1] as Element).tagName).toBe('tbody');
});
})

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>',
},
cols: 2,
rows: 1,
},
null,
2,
)}
[/block]`;

const ast = mdxish(md);
// Some extra children are added to the AST by the mdxish wrapper
expect(ast.children).toHaveLength(4);

// Table is the 3rd child
const element = ast.children[2] as Element;
expect(element.tagName).toBe('table');
expect(element.children).toHaveLength(2);
expect((element.children[1] as Element).tagName).toBe('tbody');

// Check that HTML in cells is parsed as element nodes
const tbody = element.children[1] as Element;
const row = tbody.children[0] as Element;
const cell0 = row.children[0] as Element;
const cell1 = row.children[1] as Element;

expect((cell0.children[0] as Element).tagName).toBe('h1');
expect((cell1.children[0] as Element).tagName).toBe('strong');
});

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*',
},
cols: 2,
rows: 1,
},
null,
2,
)}
[/block]`;

const ast = mdxish(md);
// Some extra children are added to the AST by the mdxish wrapper
expect(ast.children).toHaveLength(4);

// Table is the 3rd child
const element = ast.children[2] as Element;
expect(element.tagName).toBe('table');
expect(element.children).toHaveLength(2);
expect((element.children[1] as Element).tagName).toBe('tbody');

const tbody = element.children[1] as Element;
const row = tbody.children[0] as Element;
const cell0 = row.children[0] as Element;
const cell1 = row.children[1] as Element;

expect((cell0.children[0] as Element).tagName).toBe('strong');
expect((cell1.children[0] as Element).tagName).toBe('em');
});
});

describe('recipe block', () => {
it('should restore tutorial-tile block to Recipe component', () => {
const md = `[block:tutorial-tile]
{
"emoji": "🦉",
"slug": "whoaaa",
"title": "WHOAAA"
}
[/block]`;

const ast = mdxish(md);
expect(ast.children).toHaveLength(1);
expect(ast.children[0].type).toBe('element');

const recipeElement = ast.children[0] as Element;
expect(recipeElement.tagName).toBe('Recipe');
expect(recipeElement.properties.slug).toBe('whoaaa');
expect(recipeElement.properties.title).toBe('WHOAAA');
});

it('should restore recipe block to Recipe component', () => {
const md = `[block:recipe]
{
"slug": "test-recipe",
"title": "Test Recipe",
"emoji": "👉"
}
[/block]`;

const ast = mdxish(md);
expect(ast.children).toHaveLength(1);
expect(ast.children[0].type).toBe('element');

const recipeElement = ast.children[0] as Element;
expect(recipeElement.tagName).toBe('Recipe');
expect(recipeElement.properties.slug).toBe('test-recipe');
expect(recipeElement.properties.title).toBe('Test Recipe');
});
});
});
85 changes: 83 additions & 2 deletions processor/transform/mdxish/mdxish-magic-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,16 @@
*/
import type { BlockHit } from '../../../lib/utils/extractMagicBlocks';
import type { Code, Parent, Root as MdastRoot, RootContent } from 'mdast';
import type { MdxJsxFlowElement } from 'mdast-util-mdx';
import type { Plugin } from 'unified';

import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse';
import { unified } from 'unified';
import { visit } from 'unist-util-visit';

import { toAttributes } from '../../utils';

/**
* Matches legacy magic block syntax: [block:TYPE]...JSON...[/block]
* Group 1: block type (e.g., "image", "code", "callout")
Expand Down Expand Up @@ -73,12 +79,24 @@ interface HtmlJson extends MagicBlockJson {
html: string;
}

interface RecipeJson extends MagicBlockJson {
backgroundColor?: string;
emoji?: string;
id?: string;
link?: string;
slug: string;
title: string;
}

export interface ParseMagicBlockOptions {
alwaysThrow?: boolean;
compatibilityMode?: boolean;
safeMode?: boolean;
}

/** Parses markdown in table cells */
const cellParser = unified().use(remarkParse).use(remarkGfm);

/**
* Wraps a node in a "pinned" container if sidebar: true is set in the JSON.
* Pinned blocks are displayed in a sidebar/floating position in the UI.
Expand Down Expand Up @@ -116,6 +134,16 @@ const textToInline = (text: string): MdastNode[] => [{ type: 'text', value: text
// Simple text to block nodes (wraps in paragraph)
const textToBlock = (text: string): MdastNode[] => [{ children: textToInline(text), type: 'paragraph' }];

// Table cells may contain html or markdown content, so we need to parse it accordingly instead of keeping it as raw text
const parseInline = (text: string): MdastNode[] => {
if (!text.trim()) return [{ type: 'text', value: '' }];
const tree = cellParser.runSync(cellParser.parse(text)) as MdastRoot;
return tree.children.flatMap(n =>
// This unwraps the extra p node that might appear & wrapping the content
n.type === 'paragraph' && 'children' in n ? (n.children as MdastNode[]) : [n as MdastNode],
);
};

/**
* Parse a magic block string and return MDAST nodes.
*
Expand Down Expand Up @@ -284,7 +312,7 @@ function parseMagicBlock(raw: string, options: ParseMagicBlockOptions = {}): Mda
}, [] as string[][]);

// In compatibility mode, wrap cell content in paragraphs; otherwise inline text
const tokenizeCell = compatibilityMode ? textToBlock : textToInline;
const tokenizeCell = compatibilityMode ? textToBlock : parseInline;
const children = Array.from({ length: rows + 1 }, (_, y) => ({
children: Array.from({ length: cols }, (__, x) => ({
children: sparseData[y]?.[x] ? tokenizeCell(sparseData[y][x]) : [{ type: 'text', value: '' }],
Expand Down Expand Up @@ -343,6 +371,27 @@ function parseMagicBlock(raw: string, options: ParseMagicBlockOptions = {}): Mda
];
}

// Recipe/TutorialTile: renders as Recipe component
case 'recipe':
case 'tutorial-tile': {
const recipeJson = json as RecipeJson;
if (!recipeJson.slug || !recipeJson.title) return [];

// Create mdxJsxFlowElement directly for mdxish flow
// Note: Don't wrap in pinned blocks for mdxish - rehypeMdxishComponents handles component resolution
// The node structure matches what mdxishComponentBlocks creates for JSX tags
const recipeNode: MdxJsxFlowElement = {
type: 'mdxJsxFlowElement',
name: 'Recipe',
attributes: toAttributes(recipeJson, ['slug', 'title']),
children: [],
// Position is optional but helps with debugging
position: undefined,
};

return [recipeNode as unknown as MdastNode];
}

// Unknown block types: render as generic div with JSON properties
default: {
const text = (json as { html?: string; text?: string }).text || (json as { html?: string }).html || '';
Expand Down Expand Up @@ -372,6 +421,13 @@ const magicBlockRestorer: Plugin<[{ blocks: BlockHit[] }], MdastRoot> =
const magicBlockKeys = new Map(blocks.map(({ key, raw }) => [key, raw] as const));

// Find inlineCode nodes that match our placeholder tokens
// We need to collect modifications first to avoid index issues during iteration
const modifications: {
children: RootContent[];
paragraphIndex: number;
parent: Parent;
}[] = [];

visit(tree, 'inlineCode', (node: Code, index: number, parent: Parent) => {
if (!parent || index == null) return;
const raw = magicBlockKeys.get(node.value);
Expand All @@ -381,7 +437,32 @@ const magicBlockRestorer: Plugin<[{ blocks: BlockHit[] }], MdastRoot> =
const children = parseMagicBlock(raw) as unknown as RootContent[];
if (!children.length) return;

parent.children.splice(index, 1, ...children);
// Check if this is a Recipe component (recipe or tutorial-tile magic blocks)
const isRecipeComponent =
children[0].type === 'mdxJsxFlowElement' &&
'name' in children[0] &&
(children[0] as MdxJsxFlowElement).name === 'Recipe';

// Recipe components create mdxJsxFlowElement nodes that are flow (block-level) elements
// and cannot be children of paragraphs, so we need to unwrap the paragraph
if (isRecipeComponent && parent.type === 'paragraph') {
// Only use complex unwrapping logic for Recipe components
visit(tree, parent.type, (p, pIndex, pParent) => {
if (p === parent && pParent && typeof pIndex === 'number' && 'children' in pParent) {
modifications.push({ children, paragraphIndex: pIndex, parent: pParent as Parent });
return false;
}
return undefined;
});
} else {
// For all other magic blocks, use simple replacement
parent.children.splice(index, 1, ...children);
}
});

// Apply modifications (replacing paragraphs with flow elements)
modifications.reverse().forEach(({ children: modChildren, paragraphIndex, parent: modParent }) => {
modParent.children.splice(paragraphIndex, 1, ...modChildren);
});
};

Expand Down