Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
146 changes: 146 additions & 0 deletions __tests__/lib/translateMagicBlocks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { afterEach, vi } from 'vitest';

import * as RDMD from '../../lib';

const { translateMagicBlocks } = RDMD;

function imageBlock(image: Record<string, unknown>) {
return `[block:image]\n${JSON.stringify({ images: [image] }, null, 2)}\n[/block]`;
}

function lineCount(content: string) {
return content.split('\n').length;
}

afterEach(() => {
vi.restoreAllMocks();
});

describe('translateMagicBlocks', () => {
it('translates uncaptioned image magic blocks into Image JSX', () => {
const input = imageBlock({
align: 'center',
border: true,
image: ['https://example.com/image.png', 'Example title', 'Example alt text'],
sizing: 'full',
});

const output = translateMagicBlocks(input);

expect(output).toContain(
'<Image align="center" alt="Example alt text" border={true} title="Example title" width="100%" src="https://example.com/image.png" />',
);
expect(lineCount(output)).toBe(lineCount(input));
});

it('translates captioned image magic blocks without losing the caption', () => {
const input = imageBlock({
align: 'left',
caption: 'Readable caption',
image: ['https://example.com/captioned.png', '', 'Captioned alt'],
});

const output = translateMagicBlocks(input);

expect(output).toContain(
'<Image align="left" alt="Captioned alt" caption="Readable caption" src="https://example.com/captioned.png" />',
);
expect(lineCount(output)).toBe(lineCount(input));
});

it('falls back to the caption when image alt text is missing', () => {
const input = imageBlock({
caption: 'Caption text',
image: ['https://example.com/caption-fallback.png', '', null],
});

const output = translateMagicBlocks(input);

expect(output).toContain(
'<Image alt="Caption text" caption="Caption text" src="https://example.com/caption-fallback.png" />',
);
expect(lineCount(output)).toBe(lineCount(input));
});

it('preserves empty alt text when no caption fallback exists', () => {
const input = imageBlock({
caption: '',
image: ['https://example.com/missing-alt.png', '', ''],
});

const output = translateMagicBlocks(input);

expect(output).toContain('<Image alt="" src="https://example.com/missing-alt.png" />');
expect(lineCount(output)).toBe(lineCount(input));
});

it('keeps surrounding markdown byte-for-byte intact', () => {
const block = imageBlock({
image: ['https://example.com/image.png', '', 'Example alt text'],
});
const prefix = '# Title\n\n- keep spacing *exactly*\n\n';
const suffix = '\n\n{user.name}\n<<project.name>>';
const input = `${prefix}${block}${suffix}`;

const output = translateMagicBlocks(input);

expect(output.startsWith(prefix)).toBe(true);
expect(output.endsWith(suffix)).toBe(true);
expect(lineCount(output)).toBe(lineCount(input));
});

it('uses the first image tuple in the magic block', () => {
const input = `[block:image]\n${JSON.stringify(
{
images: [
{ caption: 'no tuple here' },
{ image: ['https://example.com/second.png', '', 'Second alt'] },
{ image: ['https://example.com/third.png', '', 'Third alt'] },
],
},
null,
2,
)}\n[/block]`;

const output = translateMagicBlocks(input);

expect(output).toContain('src="https://example.com/second.png"');
expect(output).toContain('alt="Second alt"');
expect(output).not.toContain('third.png');
expect(lineCount(output)).toBe(lineCount(input));
});

it('escapes JSX-ambiguous attribute values with expression attributes', () => {
const input = imageBlock({
caption: 'Caption with "quotes" and {braces}',
image: ['https://example.com/unsafe.png', '', 'Alt with "quotes", <tags>, and {braces}'],
});

const output = translateMagicBlocks(input);

expect(output).toContain('alt={"Alt with \\"quotes\\", <tags>, and {braces}"}');
expect(output).toContain('caption={"Caption with \\"quotes\\" and {braces}"}');
expect(lineCount(output)).toBe(lineCount(input));
});

it('leaves malformed image magic blocks unchanged', () => {
const input = '[block:image]\n{not json}\n[/block]';

expect(translateMagicBlocks(input)).toBe(input);
});

it('leaves unsupported magic block types unchanged', () => {
const input = '[block:callout]\n{\n "type": "info",\n "body": "Keep me raw"\n}\n[/block]';

expect(translateMagicBlocks(input)).toBe(input);
});

it('does not call the full mdxish renderer', () => {
const mdxishSpy = vi.spyOn(RDMD, 'mdxish');
const input = ['Intro', imageBlock({ image: ['https://example.com/image.png', '', 'Alt'] }), 'Outro'].join('\n');

translateMagicBlocks(input);

expect(mdxishSpy).not.toHaveBeenCalled();
});
});
1 change: 1 addition & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ export { default as run } from './run';
export { default as tags } from './tags';
export { default as mdxishTags } from './mdxishTags';
export { default as stripComments } from './stripComments';
export { default as translateMagicBlocks } from './translateMagicBlocks';
174 changes: 174 additions & 0 deletions lib/translateMagicBlocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import remarkParse from 'remark-parse';
import { unified } from 'unified';

import magicBlockTransformer from '../processor/transform/mdxish/magic-blocks/magic-block-transformer';

import { magicBlockFromMarkdown } from './mdast-util/magic-block';
import { magicBlock } from './micromark/magic-block';
import { MAGIC_BLOCK_REGEX } from './utils/extractMagicBlocks';

type AttributeValue = boolean | number | string;

interface SerializableNode {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

good call, i've switched to the existing types so that image case uses MagicBlockImage directly and the generic walker uses MdastNode

alt?: unknown;
children?: SerializableNode[];
data?: unknown;
title?: unknown;
type?: unknown;
url?: unknown;
value?: unknown;
}

const IMAGE_BLOCK_OPEN_RE = /^\[block:image\]/;
const IMAGE_BLOCK_BODY_RE = /^\[block:image\]([\s\S]*)\[\/block\]$/;
const UNSAFE_JSX_ATTRIBUTE_CHARS = new Set(['"', '\\', '<', '>', '{', '}']);

const processor = unified()
.data('micromarkExtensions', [magicBlock()])
.data('fromMarkdownExtensions', [magicBlockFromMarkdown()])
.use(remarkParse)
.use(magicBlockTransformer, { safeMode: true });

function countNewlines(value: string) {
return value.split('\n').length - 1;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function isSerializableNode(value: unknown): value is SerializableNode {
return isRecord(value);
}

function getString(value: unknown) {
return typeof value === 'string' ? value : undefined;
}

function getChildren(value: unknown) {
return isRecord(value) && Array.isArray(value.children) ? value.children.filter(isSerializableNode) : [];
}

function getHProperties(node: SerializableNode) {
if (!isRecord(node.data) || !isRecord(node.data.hProperties)) return {};
return node.data.hProperties;
}

function parseImageBlock(raw: string) {
const body = raw.match(IMAGE_BLOCK_BODY_RE)?.[1];
if (!body) return null;

try {
const parsed: unknown = JSON.parse(body.trim());
if (!isRecord(parsed) || !Array.isArray(parsed.images)) return null;

return parsed.images.some(image => {
if (!isRecord(image) || !Array.isArray(image.image)) return false;
return typeof image.image[0] === 'string';
})
? parsed
: null;
} catch {
return null;
}
}

function requiresJsxExpressionAttribute(value: string) {
return [...value].some(character => {
const charCode = character.charCodeAt(0);
return UNSAFE_JSX_ATTRIBUTE_CHARS.has(character) || charCode < 32 || charCode === 127;
});
}

function formatAttribute(key: string, value: AttributeValue) {
if (typeof value === 'boolean' || typeof value === 'number') return `${key}={${JSON.stringify(value)}}`;
if (requiresJsxExpressionAttribute(value)) return `${key}={${JSON.stringify(value)}}`;
return `${key}="${value}"`;
}

function formatAttributes(attributes: [string, AttributeValue | undefined][]) {
return attributes
.flatMap(([key, value]) => (value === undefined ? [] : [formatAttribute(key, value)]))
.join(' ');
}

function stringifyCaption(node: SerializableNode): string {
if (typeof node.value === 'string') return node.value;
if (node.type === 'break') return '\n';

return getChildren(node).map(stringifyCaption).join('');
}

function serializeImage(node: SerializableNode, caption?: string) {
const hProperties = getHProperties(node);
const alt = getString(node.alt);
const title = getString(node.title);
const src = getString(hProperties.src) || getString(node.url);

if (!src) return null;

const attributes: [string, AttributeValue | undefined][] = [
['align', getString(hProperties.align)],
['alt', alt],
['border', typeof hProperties.border === 'boolean' ? hProperties.border : undefined],
['caption', caption],
['title', title || undefined],
['width', getString(hProperties.width)],
['src', src],
];

return `<Image ${formatAttributes(attributes)} />`;
}

function serializeFigure(node: SerializableNode) {
const image = getChildren(node).find(child => child.type === 'image');
const figcaption = getChildren(node).find(child => child.type === 'figcaption');
const caption = figcaption ? getChildren(figcaption).map(stringifyCaption).join('').trim() : undefined;

return image ? serializeImage(image, caption || undefined) : null;
}

function serializeTransformedNode(node: SerializableNode): string | null {

@eaglethrost eaglethrost May 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think we're doing too much manual serialization. I wonder if you can just directly use the readmeToMdx function, or maybe mdxishMdastToMd.

So instead of manual serializers, you can extend the processor to use more transformers & stringify-ers to convert the image MDAST back to string.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

i've moved off the string templating, we're building the mdxJsxFlowElement and letting mdxishMdastToMd do the actual stringify pass. didn't pull in the full readmeToMdx since this path only needs the image branch, but the serializer is the same one now

if (node.type === 'image') return serializeImage(node);
if (node.type === 'figure') return serializeFigure(node);

if (node.type === 'rdme-pin') {
const child = getChildren(node).find(item => item.type === 'image' || item.type === 'figure');
return child ? serializeTransformedNode(child) : null;
}

return null;
}

function serializeTransformedBlock(tree: unknown) {
Comment thread
maximilianfalco marked this conversation as resolved.
Outdated
const children = getChildren(tree);
const node = children.find(item => item.type === 'image' || item.type === 'figure' || item.type === 'rdme-pin');

return node ? serializeTransformedNode(node) : null;
}

function translateImageBlock(raw: string) {
if (!IMAGE_BLOCK_OPEN_RE.test(raw) || !parseImageBlock(raw)) return raw;

try {
const translated = serializeTransformedBlock(processor.runSync(processor.parse(raw)));
if (!translated) return raw;

const originalNewlineCount = countNewlines(raw);
const translatedNewlineCount = countNewlines(translated);
if (translatedNewlineCount > originalNewlineCount) return raw;

return translated + '\n'.repeat(originalNewlineCount - translatedNewlineCount);
} catch {
return raw;
}
}

/**
* Translates supported legacy magic blocks into MDX-shaped markdown while
* preserving the source document's line count.
*/
export default function translateMagicBlocks(content: string) {
MAGIC_BLOCK_REGEX.lastIndex = 0;
return content.replace(MAGIC_BLOCK_REGEX, match => translateImageBlock(match));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm curious on how we can make this scalable for other magic blocks. If we want to translate more types, is it as simple as to just extend this to include more translators?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

yep that's the shape now. there's a translators map keyed by block type, so a new one is a function plus one line to register it. each translator still owns its own parse + validate + AST construction since the block shapes are all different

}