Skip to content
Draft
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
231 changes: 163 additions & 68 deletions __tests__/lib/mdxish/magic-blocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,92 +56,187 @@ ${JSON.stringify(

const ast = mdxish(md);

// Some extra children are added to the AST by the mdxish wrapper
expect(ast.children).toHaveLength(4);
expect(ast.children[2].type).toBe('element');
expect(ast.children).toHaveLength(2);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks the AST looks cleaner

expect(ast.children[1].type).toBe('element');

const element = ast.children[2] as Element;
const element = ast.children[1] as Element;
expect(element.tagName).toBe('table');
expect(element.children).toHaveLength(2);
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', () => {
Copy link
Contributor

@eaglethrost eaglethrost Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this test & the one in line 109 deleted?

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]`;
describe('general tests', () => {
it('should restore image block inside a list item', () => {
const md = `- First item
- [block:image]{"images":[{"image":["https://example.com/img.png",null,null]}]}[/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 listElement = ast.children.find(c => c.type === 'element' && (c as Element).tagName === 'ul') as Element;
expect(listElement).toBeDefined();

// 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;
const imageElement = listElement.children
.filter((li): li is Element => li.type === 'element')
.flatMap((li: Element) => li.children || [])
.find((c): c is Element => c.type === 'element' && c.tagName === 'img');

expect((cell0.children[0] as Element).tagName).toBe('h1');
expect((cell1.children[0] as Element).tagName).toBe('strong');
expect(imageElement).toBeDefined();
expect(imageElement!.tagName).toBe('img');
expect(imageElement!.properties.src).toBe('https://example.com/img.png');
});

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]`;
it('should restore code block inside a list item', () => {
const md = `- First item
- [block:code]{"codes":[{"code":"const x = 1;","language":"javascript"}]}[/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 listElement = ast.children.find(c => c.type === 'element' && (c as Element).tagName === 'ul') as Element;
expect(listElement).toBeDefined();

const codeElement = listElement.children
.filter((li): li is Element => li.type === 'element')
.flatMap((li: Element) => li.children || [])
.find((c): c is Element => c.type === 'element' && c.tagName === 'CodeTabs');

expect(codeElement).toBeDefined();
expect(codeElement!.tagName).toBe('CodeTabs');
});

it('should restore api-header block inside a list item', () => {
const md = `- First item
- [block:api-header]{"title":"API Endpoint","level":2}[/block]`;

const ast = mdxish(md);

const listElement = ast.children.find(c => c.type === 'element' && (c as Element).tagName === 'ul') as Element;
expect(listElement).toBeDefined();

const headingElement = listElement.children
.filter((li): li is Element => li.type === 'element')
.flatMap((li: Element) => li.children || [])
.find((c): c is Element => c.type === 'element' && ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(c.tagName));

expect(headingElement).toBeDefined();
expect(headingElement!.tagName).toBe('h2');
});

// TODO: unskip this test once callout magic blocks are correctly supported
it.skip('should restore callout block inside a list item', () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is a small issue with callout magic blocks, Ive created a ticket here. will unskip this once that is resolved

const md = `- First item
- [block:callout]{"type":"info","title":"Note","body":"This is important"}[/block]`;

const ast = mdxish(md);

const listElement = ast.children.find(c => c.type === 'element' && (c as Element).tagName === 'ul') as Element;
expect(listElement).toBeDefined();

const calloutElement = listElement.children
.filter((li): li is Element => li.type === 'element')
.flatMap((li: Element) => li.children || [])
.find((c): c is Element => c.type === 'element' && c.tagName === 'Callout');

expect(calloutElement).toBeDefined();
// rehypeMdxishComponents maps rdme-callout -> Callout
expect(calloutElement!.tagName).toBe('Callout');
});

it('should restore parameters block inside a list item', () => {
const md = `- First item
- [block:parameters]{"data":{"h-0":"Name","h-1":"Type","0-0":"id","0-1":"string"},"cols":2,"rows":1}[/block]`;

const ast = mdxish(md);

const listElement = ast.children.find(c => c.type === 'element' && (c as Element).tagName === 'ul') as Element;
expect(listElement).toBeDefined();

const tableElement = listElement.children
.filter((li): li is Element => li.type === 'element')
.flatMap((li: Element) => li.children || [])
.find((c): c is Element => c.type === 'element' && c.tagName === 'table');

expect(tableElement).toBeDefined();
expect(tableElement!.tagName).toBe('table');
});

// TODO: unskip this test once embed magic blocks are supported
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will be unskipped once #1258 is merged in!

it.skip('should restore embed block inside a list item', () => {
const md = `- First item
- [block:embed]{"url":"https://www.youtube.com/watch?v=dQw4w9WgXcQ","title":"Video"}[/block]`;

const ast = mdxish(md);

const listElement = ast.children.find(c => c.type === 'element' && (c as Element).tagName === 'ul') as Element;
expect(listElement).toBeDefined();

const embedElement = listElement.children
.filter((li): li is Element => li.type === 'element')
.flatMap((li: Element) => li.children || [])
.find((c): c is Element => c.type === 'element' && c.tagName === 'rdme-embed');

expect(embedElement).toBeDefined();
expect(embedElement!.tagName).toBe('rdme-embed');
});

it('should restore html block inside a list item', () => {
const md = `- First item
- [block:html]{"html":"<div>Hello World</div>"}[/block]`;

const ast = mdxish(md);
const listElement = ast.children.find(c => c.type === 'element' && (c as Element).tagName === 'ul') as Element;
expect(listElement).toBeDefined();

const htmlElement = listElement.children
.filter((li): li is Element => li.type === 'element')
.flatMap((li: Element) => li.children || [])
.find((c): c is Element => c.type === 'element' && c.tagName === 'HTMLBlock');

expect(htmlElement).toBeDefined();
expect(htmlElement!.tagName).toBe('HTMLBlock');
});

// TODO: unskip this test once recipe magic blocks are correctly supported
it.skip('should restore recipe block inside a list item', () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this and the test in L245 would only be unskipped once this PR (#1273) gets merged in!

const md = `- open
- [block:tutorial-tile]{"emoji":"🦉","slug":"whoaaa","title":"WHOAAA"}[/block]`;

const ast = mdxish(md);

const listElement = ast.children.find(c => c.type === 'element' && (c as Element).tagName === 'ul') as Element;
expect(listElement).toBeDefined();

const recipeElement = listElement.children
.filter((li): li is Element => li.type === 'element')
.flatMap((li: Element) => li.children || [])
.find((c): c is Element => c.type === 'element' && c.tagName === 'Recipe');

expect(recipeElement).toBeDefined();
expect(recipeElement!.tagName).toBe('Recipe');
expect(recipeElement!.properties.slug).toBe('whoaaa');
expect(recipeElement!.properties.title).toBe('WHOAAA');
});

// TODO: unskip this test once recipe magic blocks are correctly supported
it.skip('should restore recipe block (recipe type) inside a list item', () => {
const md = `- open
- [block:recipe]{"emoji":"👉","slug":"test-recipe","title":"Test Recipe"}[/block]`;

const ast = mdxish(md);

const listElement = ast.children.find(c => c.type === 'element' && (c as Element).tagName === 'ul') as Element;
expect(listElement).toBeDefined();

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;
const recipeElement = listElement.children
.filter((li): li is Element => li.type === 'element')
.flatMap((li: Element) => li.children || [])
.find((c): c is Element => c.type === 'element' && c.tagName === 'Recipe');

expect((cell0.children[0] as Element).tagName).toBe('strong');
expect((cell1.children[0] as Element).tagName).toBe('em');
expect(recipeElement).toBeDefined();
expect(recipeElement!.tagName).toBe('Recipe');
expect(recipeElement!.properties.slug).toBe('test-recipe');
expect(recipeElement!.properties.title).toBe('Test Recipe');
});
});
});
});
2 changes: 1 addition & 1 deletion lib/mdxish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export function mdxish(mdContent: string, opts: MdxishOpts = {}): Root {
};

// Preprocess content: extract legacy magic blocks and evaluate JSX attribute expressions
const { replaced, blocks } = extractMagicBlocks(mdContent);
const { replaced, blocks } = extractMagicBlocks(mdContent, false);
const processedContent = preprocessJSXExpressions(replaced, jsxContext);

// Create string map of components for tailwind transformer
Expand Down
8 changes: 4 additions & 4 deletions lib/utils/extractMagicBlocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const MAGIC_BLOCK_REGEX = /\[block:[^\]]{1,100}\](?:(?!\[block:)(?!\[\/block\])[
* Extract legacy magic block syntax from a markdown string.
* Returns the modified markdown and an array of extracted blocks.
*/
export function extractMagicBlocks(markdown: string) {
export function extractMagicBlocks(markdown: string, prependNewline: boolean = true) {
const blocks: BlockHit[] = [];
let index = 0;

Expand All @@ -31,7 +31,7 @@ export function extractMagicBlocks(markdown: string) {
* - Prepend a newline to the token to ensure it is parsed as a block level node
*/
const key = `__MAGIC_BLOCK_${index}__`;
const token = `\n\`${key}\``;
const token = prependNewline ? `\n\`${key}\`` : `\`${key}\``;

blocks.push({ key, raw: match, token });
index += 1;
Expand All @@ -44,10 +44,10 @@ export function extractMagicBlocks(markdown: string) {
/**
* Restore extracted magic blocks back into a markdown string.
*/
export function restoreMagicBlocks(replaced: string, blocks: BlockHit[]) {
export function restoreMagicBlocks(replaced: string, blocks: BlockHit[]) {
let content = replaced;

// If a magic block is at the start of the document, the extraction token's prepended
// If a magic block is at the start of the document, the extraction token's prepended
// newline will have been trimmed during processing. We need to account for that here
// to ensure the token is found and replaced correctly.
const isTokenAtStart = content.startsWith(blocks[0]?.token.trimStart());
Expand Down
Loading