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
76 changes: 76 additions & 0 deletions __tests__/lib/mdxish/mdxish-jsx-to-mdast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,33 @@ describe('mdxish-jsx-to-mdast transformer', () => {
expect(imageNode.data?.hProperties?.border).toBeUndefined();
});

it('should parse Image with unquoted attributes containing special characters', () => {
const md = '<Image src=https://example.com/image.png alt=test />';
const ast = processWithNewTypes(md);

expect(ast.children).toHaveLength(1);
expect(ast.children[0].type).toBe(NodeTypes.imageBlock);

const imageNode = ast.children[0] as ImageBlock;
expect(imageNode.data?.hProperties?.src).toBe('https://example.com/image.png');
expect(imageNode.data?.hProperties?.alt).toBe('test');
});

it('should parse Image with unquoted attributes spanning multiple lines', () => {
const md = `<Image
src=https://example.com/image.png
alt=test
/>`;
const ast = processWithNewTypes(md);

expect(ast.children).toHaveLength(1);
expect(ast.children[0].type).toBe(NodeTypes.imageBlock);

const imageNode = ast.children[0] as ImageBlock;
expect(imageNode.data?.hProperties?.src).toBe('https://example.com/image.png');
expect(imageNode.data?.hProperties?.alt).toBe('test');
});

it('should parse caption with markdown and HTML entities into children', () => {
const md = '<Image src="test.png" alt="test" caption="With **Default Handling** enabled, the `default` value &#x22;Buster&#x22; is used." />';
const ast = processWithNewTypes(md);
Expand Down Expand Up @@ -131,6 +158,20 @@ This is a warning message.
expect(calloutNode.children).toBeDefined();
expect(calloutNode.children.length).toBeGreaterThan(0);
});

it('should parse Callout with unquoted attributes containing special characters', () => {
const md = `<Callout icon=📘 theme=info>
content
</Callout>`;
const ast = processWithNewTypes(md);

expect(ast.children).toHaveLength(1);
expect(ast.children[0].type).toBe(NodeTypes.callout);

const calloutNode = ast.children[0] as Callout;
expect(calloutNode.data?.hProperties?.icon).toBe('📘');
expect(calloutNode.data?.hProperties?.theme).toBe('info');
});
});

describe('Embed component', () => {
Expand Down Expand Up @@ -197,6 +238,18 @@ This is a warning message.
expect(embedNode.data?.hProperties?.url).toBe(url);
});
});

it('should parse Embed with unquoted attributes containing special characters', () => {
const md = '<Embed url=https://example.com title=Example />';
const ast = processWithNewTypes(md);

expect(ast.children).toHaveLength(1);
expect(ast.children[0].type).toBe(NodeTypes.embedBlock);

const embedNode = ast.children[0] as EmbedBlock;
expect(embedNode.data?.hProperties?.url).toBe('https://example.com');
expect(embedNode.data?.hProperties?.title).toBe('Example');
});
});

describe('Anchor component', () => {
Expand Down Expand Up @@ -257,6 +310,17 @@ This is a warning message.
expect(anchorNode.data?.hProperties?.href).toBe('https://readme.com');
expect(anchorNode.children).toHaveLength(0);
});

it('should parse Anchor with unquoted attributes containing special characters', () => {
const md = '<Anchor href=https://readme.com>ReadMe</Anchor>';
const ast = processWithNewTypes(md);

const para = ast.children[0] as Paragraph;
const anchorNode = para.children.find(c => c.type === NodeTypes.anchor) as Anchor;
expect(anchorNode).toBeDefined();
expect(anchorNode.data?.hProperties?.href).toBe('https://readme.com');
expect(anchorNode.children[0]).toMatchObject({ type: 'text', value: 'ReadMe' });
});
});

describe('Recipe component', () => {
Expand All @@ -282,6 +346,18 @@ This is a warning message.
expect(recipeNode.emoji).toBe('🍳');
expect(recipeNode.backgroundColor).toBe('#fff');
});

it('should parse Recipe with unquoted attributes containing special characters', () => {
const md = '<Recipe slug=my-recipe title=Recipe link=https://example.com/recipe />';
const ast = processWithNewTypes(md);

expect(ast.children).toHaveLength(1);
expect(ast.children[0].type).toBe(NodeTypes.recipe);

const recipeNode = ast.children[0] as Recipe;
expect(recipeNode.slug).toBe('my-recipe');
expect(recipeNode.title).toBe('Recipe');
});
});

describe('unknown components', () => {
Expand Down
30 changes: 30 additions & 0 deletions __tests__/transformers/mdxish-component-blocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,36 @@ Some content
});
});

describe('closing tag inside inline code span', () => {
it('should not treat a closing tag inside backticks as the real closing tag', () => {
const markdown = `<Callout>
Here is some code: \`</Callout>\` and more text.
</Callout>`;
const tree = parseWithPlugin(markdown);

const mdxNodes = findNodesByType(tree, 'mdxJsxFlowElement');
expect(mdxNodes).toHaveLength(1);
expect(mdxNodes[0]).toMatchObject({
type: 'mdxJsxFlowElement',
name: 'Callout',
});
});

it('should skip closing tags inside multi-backtick code spans', () => {
const markdown = `<Callout>
Here is some code: \`\`</Callout>\`\` and more text.
</Callout>`;
const tree = parseWithPlugin(markdown);

const mdxNodes = findNodesByType(tree, 'mdxJsxFlowElement');
expect(mdxNodes).toHaveLength(1);
expect(mdxNodes[0]).toMatchObject({
type: 'mdxJsxFlowElement',
name: 'Callout',
});
});
});

describe('Anchor component (inline, excluded)', () => {
it('should NOT convert <Anchor> to mdxJsxFlowElement', () => {
// Anchor is an inline component and must remain as raw html nodes so that
Expand Down
58 changes: 58 additions & 0 deletions lib/mdast-util/jsx-component/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { HTML } from 'mdast';
import type { CompileContext, Extension as FromMarkdownExtension, Handle, Token } from 'mdast-util-from-markdown';

const contextMap = new WeakMap<Token, { chunks: string[] }>();

function findBlockToken(this: CompileContext): Token | undefined {
const events = this.tokenStack;
for (let i = events.length - 1; i >= 0; i -= 1) {
if (events[i][0].type === 'jsxComponentBlock') return events[i][0];
}
return undefined;
}

function enterBlock(this: CompileContext, token: Parameters<Handle>[0]): void {
contextMap.set(token, { chunks: [] });
this.enter({ type: 'html', value: '' } as HTML, token);
}

function exitBlockData(this: CompileContext, token: Parameters<Handle>[0]): void {
const blockToken = findBlockToken.call(this);
if (!blockToken) return;
const ctx = contextMap.get(blockToken);
if (ctx) ctx.chunks.push(this.sliceSerialize(token));
}

function exitBlock(this: CompileContext, token: Parameters<Handle>[0]): void {
const ctx = contextMap.get(token);
const node = this.stack[this.stack.length - 1] as HTML;
if (ctx) {
node.value = ctx.chunks.join('\n');
contextMap.delete(token);
}
this.exit(token);
}

function enterText(this: CompileContext, token: Parameters<Handle>[0]): void {
this.enter({ type: 'html', value: '' } as HTML, token);
}

function exitText(this: CompileContext, token: Parameters<Handle>[0]): void {
const node = this.stack[this.stack.length - 1] as HTML;
node.value = this.sliceSerialize(token);
this.exit(token);
}

export function jsxComponentBlockFromMarkdown(): FromMarkdownExtension {
return {
enter: {
jsxComponentBlock: enterBlock,
jsxComponentText: enterText,
},
exit: {
jsxComponentBlockData: exitBlockData,
jsxComponentBlock: exitBlock,
jsxComponentText: exitText,
},
};
}
8 changes: 6 additions & 2 deletions lib/mdxish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,11 @@ import variablesTextTransformer from '../processor/transform/mdxish/variables-te
import tailwindTransformer from '../processor/transform/tailwind';

import { emptyTaskListItemFromMarkdown } from './mdast-util/empty-task-list-item';
import { jsxComponentBlockFromMarkdown } from './mdast-util/jsx-component';
import { jsxTableFromMarkdown } from './mdast-util/jsx-table';
import { legacyVariableFromMarkdown } from './mdast-util/legacy-variable';
import { magicBlockFromMarkdown } from './mdast-util/magic-block';
import { jsxComponentBlock } from './micromark/jsx-component';
import { jsxTable } from './micromark/jsx-table';
import { legacyVariable } from './micromark/legacy-variable';
import { looseHtmlEntity, looseHtmlEntityFromMarkdown } from './micromark/loose-html-entities';
Expand Down Expand Up @@ -155,20 +157,22 @@ export function mdxishAstProcessor(mdContent: string, opts: MdxishOpts = {}) {
.data(
'micromarkExtensions',
safeMode
? [jsxTable(), magicBlock(), legacyVariable(), looseHtmlEntity()]
: [jsxTable(), magicBlock(), mdxExprTextOnly, legacyVariable(), looseHtmlEntity()],
? [jsxComponentBlock(), jsxTable(), magicBlock(), legacyVariable(), looseHtmlEntity()]
: [jsxComponentBlock(), jsxTable(), magicBlock(), mdxExprTextOnly, legacyVariable(), looseHtmlEntity()],
)
.data(
'fromMarkdownExtensions',
safeMode
? [
jsxComponentBlockFromMarkdown(),
jsxTableFromMarkdown(),
magicBlockFromMarkdown(),
legacyVariableFromMarkdown(),
emptyTaskListItemFromMarkdown(),
looseHtmlEntityFromMarkdown(),
]
: [
jsxComponentBlockFromMarkdown(),
jsxTableFromMarkdown(),
magicBlockFromMarkdown(),
mdxExpressionFromMarkdown(),
Expand Down
1 change: 1 addition & 0 deletions lib/micromark/jsx-component/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { jsxComponentBlock } from './syntax';
Loading
Loading