diff --git a/__tests__/lib/mdxish/magic-blocks.test.ts b/__tests__/lib/mdxish/magic-blocks.test.ts
index 7943a6e68..3438254cd 100644
--- a/__tests__/lib/mdxish/magic-blocks.test.ts
+++ b/__tests__/lib/mdxish/magic-blocks.test.ts
@@ -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': '
this should be a h1 element node
',
+ '0-1': 'this should be a strong element node',
+ },
+ 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');
+ });
+ });
});
diff --git a/processor/transform/mdxish/mdxish-magic-blocks.ts b/processor/transform/mdxish/mdxish-magic-blocks.ts
index 2fd1c0bac..599f8b3a9 100644
--- a/processor/transform/mdxish/mdxish-magic-blocks.ts
+++ b/processor/transform/mdxish/mdxish-magic-blocks.ts
@@ -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")
@@ -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.
@@ -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.
*
@@ -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: '' }],
@@ -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 || '';
@@ -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);
@@ -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);
});
};