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/3] 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/3] 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'); + }); + }); }); From 0963562edac3676605cc517fc4512190a19cbb0c Mon Sep 17 00:00:00 2001 From: Dimas Putra Anugerah <63914983+eaglethrost@users.noreply.github.com> Date: Wed, 17 Dec 2025 21:26:21 +0700 Subject: [PATCH 3/3] fix(mdxish): parse table cells in magic blocks (#1255) --- __tests__/lib/mdxish/magic-blocks.test.ts | 81 ++++++++++++++++++- .../transform/mdxish/mdxish-magic-blocks.ts | 18 ++++- 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/__tests__/lib/mdxish/magic-blocks.test.ts b/__tests__/lib/mdxish/magic-blocks.test.ts index 7943a6e68..30596908e 100644 --- a/__tests__/lib/mdxish/magic-blocks.test.ts +++ b/__tests__/lib/mdxish/magic-blocks.test.ts @@ -66,5 +66,82 @@ ${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'); + }); + }); +}); \ No newline at end of file diff --git a/processor/transform/mdxish/mdxish-magic-blocks.ts b/processor/transform/mdxish/mdxish-magic-blocks.ts index 2fd1c0bac..4a28a4d6c 100644 --- a/processor/transform/mdxish/mdxish-magic-blocks.ts +++ b/processor/transform/mdxish/mdxish-magic-blocks.ts @@ -8,6 +8,9 @@ import type { BlockHit } from '../../../lib/utils/extractMagicBlocks'; import type { Code, Parent, Root as MdastRoot, RootContent } from 'mdast'; 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'; /** @@ -79,6 +82,9 @@ export interface ParseMagicBlockOptions { 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 +122,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 +300,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: '' }],