From 6d42f5dc58ce9705e7cde437581d24cff450db6e Mon Sep 17 00:00:00 2001 From: Maximilian Falco Widjaya Date: Wed, 17 Dec 2025 14:12:48 +0700 Subject: [PATCH 1/2] feat: add support for basic tutorial tile magic block in mdxish --- .../transform/mdxish/mdxish-magic-blocks.ts | 67 ++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/processor/transform/mdxish/mdxish-magic-blocks.ts b/processor/transform/mdxish/mdxish-magic-blocks.ts index 2fd1c0bac..3fc97aeb3 100644 --- a/processor/transform/mdxish/mdxish-magic-blocks.ts +++ b/processor/transform/mdxish/mdxish-magic-blocks.ts @@ -6,10 +6,13 @@ */ 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 { 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,6 +76,15 @@ 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; @@ -343,6 +355,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 +405,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 +421,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); }); }; From 9e51e8a91a848e956a148e73fc87c0dff91bdfe0 Mon Sep 17 00:00:00 2001 From: Maximilian Falco Widjaya Date: Wed, 17 Dec 2025 16:15:29 +0700 Subject: [PATCH 2/2] add test for tutorial tile and recipe magic blocks --- __tests__/lib/mdxish/magic-blocks.test.ts | 42 ++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/__tests__/lib/mdxish/magic-blocks.test.ts b/__tests__/lib/mdxish/magic-blocks.test.ts index 7943a6e68..0e806414c 100644 --- a/__tests__/lib/mdxish/magic-blocks.test.ts +++ b/__tests__/lib/mdxish/magic-blocks.test.ts @@ -66,5 +66,45 @@ ${JSON.stringify( expect((element.children[0] as Element).tagName).toBe('thead'); expect((element.children[1] as Element).tagName).toBe('tbody'); }); - }) + }); + + 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'); + }); + }); });