Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
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 through the MDX stringifier', () => {
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 &#x22;quotes&#x22;, <tags>, and {braces}"');
expect(output).toContain('caption="Caption with &#x22;quotes&#x22; 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();
});
});
20 changes: 20 additions & 0 deletions __tests__/processor/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,33 @@ describe('toAttributes', () => {
expect(attrs[0].name).toBe('name');
});

it('preserves selected empty string values', () => {
const attrs = toAttributes({ alt: '', caption: '' }, [], { preserveEmpty: ['alt'] });

expect(attrs).toHaveLength(1);
expect(attrs[0]).toStrictEqual({
type: 'mdxJsxAttribute',
name: 'alt',
value: '',
});
});

it('skips boolean false values', () => {
const attrs = toAttributes({ name: 'test', disabled: false, empty: false });

expect(attrs).toHaveLength(1);
expect(attrs[0].name).toBe('name');
});

it('preserves selected boolean false values', () => {
const attrs = toAttributes({ border: false, disabled: false }, [], { preserveFalse: ['border'] });

expect(attrs).toHaveLength(1);
expect(attrs[0].name).toBe('border');
expect(attrs[0].value).toHaveProperty('type', 'mdxJsxAttributeValueExpression');
expect(attrs[0].value).toHaveProperty('value', 'false');
});

it('converts boolean true to expression attribute', () => {
const attrs = toAttributes({ enabled: true });

Expand Down
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';
181 changes: 181 additions & 0 deletions lib/translateMagicBlocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import type { MdastNode, MagicBlockImage } from '../processor/transform/mdxish/magic-blocks/types';
import type { Root as MdastRoot } from 'mdast';
import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx';

import remarkParse from 'remark-parse';
import { unified } from 'unified';

import magicBlockTransformer from '../processor/transform/mdxish/magic-blocks/magic-block-transformer';
import { toAttributes } from '../processor/utils';

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

type MagicBlockTranslator = (raw: string) => string;

interface MagicBlockFigure extends MdastNode {
children: MdastNode[];
type: 'figure';
}

type MagicBlockImageNode = MagicBlockImage & MdastNode;

const MAGIC_BLOCK_OPEN_RE = /^\[block:([^\]]{1,100})\]/;

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 know this regex is part of the MAGIC_BLOCK_REGEX, can you add a comment mentioning that?

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.

added a one-liner at lib/translate/translateMagicBlocks.ts:12 calling out this is a prefix subset of MAGIC_BLOCK_REGEX, with a cross-ref to lib/utils/extractMagicBlocks.ts:14.

const IMAGE_BLOCK_BODY_RE = /^\[block:image\]([\s\S]*)\[\/block\]$/;

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 isMdastNode(value: unknown): value is MdastNode {
return isRecord(value) && typeof value.type === 'string';
}

function isMdastRoot(value: unknown): value is MdastRoot {
return isRecord(value) && value.type === 'root' && Array.isArray(value.children);
}

function isMagicBlockImage(node: MdastNode): node is MagicBlockImageNode {
return node.type === 'image';
}

function isMagicBlockFigure(node: MdastNode): node is MagicBlockFigure {
return node.type === 'figure' && Array.isArray(node.children);
}

function getChildren(value: MdastNode | MdastRoot) {
return Array.isArray(value.children) ? value.children.filter(isMdastNode) : [];
}

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

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

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

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

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

function imageToMdx(node: MagicBlockImageNode, caption?: string) {
const hProperties = node.data?.hProperties ?? {};
const src = node.url;

if (!src) return null;

const attributes = {
align: hProperties.align,
alt: node.alt ?? '',
border: hProperties.border,
caption,
title: node.title || undefined,
width: hProperties.width,
src,
};

return {
type: 'mdxJsxFlowElement',
name: 'Image',
attributes: toAttributes(attributes, ['align', 'alt', 'border', 'caption', 'title', 'width', 'src'], {
preserveEmpty: ['alt'],
preserveFalse: ['border'],
}),
children: [],
} satisfies MdxJsxFlowElement;
}

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

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

function transformedNodeToMdx(node: MdastNode) {
if (isMagicBlockImage(node)) return imageToMdx(node);
if (isMagicBlockFigure(node)) return figureToMdx(node);

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

return null;
}

function stringifyMdxImage(tree: MdastRoot) {
const children = getChildren(tree);
const node = children.find(item => item.type === 'image' || item.type === 'figure' || item.type === 'rdme-pin');
const mdxNode = node ? transformedNodeToMdx(node) : null;

if (!mdxNode) return null;

return mdxishMdastToMd({ type: 'root', children: [mdxNode] }).trim();
}

function translateImageBlock(raw: string) {
if (!hasImageBlockData(raw)) return raw;

try {
const tree = processor.runSync(processor.parse(raw));
if (!isMdastRoot(tree)) return raw;

const translated = stringifyMdxImage(tree);
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;
}
}

const translators: Partial<Record<string, MagicBlockTranslator>> = {
image: translateImageBlock,
};

function translateMagicBlock(raw: string) {
const blockType = raw.match(MAGIC_BLOCK_OPEN_RE)?.[1];
const translator = blockType ? translators[blockType] : undefined;

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 love this, thanks!


return translator ? translator(raw) : 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 => translateMagicBlock(match));
}
2 changes: 1 addition & 1 deletion processor/transform/mdxish/magic-blocks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export interface MagicBlockImage {
data?: {
hProperties?: {
align?: string;
border?: string;
border?: boolean;
Comment thread
maximilianfalco marked this conversation as resolved.
Outdated
width?: string;
};
};
Expand Down
Loading