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

import * as RDMD from '../../../lib';
import { extractMagicBlocks, MAGIC_BLOCK_REGEX } from '../../../lib/utils/extractMagicBlocks';

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).toBe(
'<Image align="center" alt="Example alt text" border={true} title="Example title" width="100%" src="https://example.com/image.png" />\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n',
);
});

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('preserves border false after magic block image transformation', () => {
const input = imageBlock({
border: false,
image: ['https://example.com/no-border.png', '', 'No border'],
});

const output = translateMagicBlocks(input);

expect(output).toContain('<Image alt="No border" border={false} src="https://example.com/no-border.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('translates two image blocks in one document', () => {
const first = imageBlock({
image: ['https://example.com/first.png', '', 'First alt'],
});
const second = imageBlock({
image: ['https://example.com/second.png', '', 'Second alt'],
});
const input = [first, second].join('\n\n');

const output = translateMagicBlocks(input);

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

it('translates supported image blocks while passing unsupported blocks through', () => {
const first = imageBlock({
image: ['https://example.com/first.png', '', 'First alt'],
});
const unsupported = '[block:callout]\n{\n "type": "info",\n "body": "Keep me raw"\n}\n[/block]';
const second = imageBlock({
image: ['https://example.com/second.png', '', 'Second alt'],
});
const input = [first, unsupported, second].join('\n\n');

const output = translateMagicBlocks(input);

expect(output).toContain('<Image alt="First alt" src="https://example.com/first.png" />');
expect(output).toContain(unsupported);
expect(output).toContain('<Image alt="Second alt" src="https://example.com/second.png" />');
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.each(['table', 'tutorial-tile'])('leaves %s magic blocks unchanged', blockType => {
const input = `[block:${blockType}]\n{\n "body": "Keep me raw"\n}\n[/block]`;

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

it('dispatches through the public entry without exposing the translator registry', () => {
const unsupported = '[block:callout]\n{\n "type": "info"\n}\n[/block]';
const supported = imageBlock({
image: ['https://example.com/public-entry.png', '', 'Public entry'],
});

expect(translateMagicBlocks(unsupported)).toBe(unsupported);
expect(translateMagicBlocks(supported)).toContain(
'<Image alt="Public entry" src="https://example.com/public-entry.png" />',
);
});

it('resets MAGIC_BLOCK_REGEX lastIndex before translating', () => {
const input = imageBlock({
image: ['https://example.com/last-index.png', '', 'Last index'],
});

MAGIC_BLOCK_REGEX.lastIndex = 42;

const output = translateMagicBlocks(input);
const extracted = extractMagicBlocks(input);

expect(output).toContain('<Image alt="Last index" src="https://example.com/last-index.png" />');
expect(extracted.blocks).toHaveLength(1);
expect(extracted.blocks[0]?.raw).toBe(input);
});

it('leaves image blocks unchanged when precheck passes but no usable image node is produced', () => {
const input = imageBlock({
image: ['', '', 'Missing source'],
});

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 './translate';
123 changes: 123 additions & 0 deletions lib/translate/image.ts

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 this file mixes a lot of different code & use cases, so I think we should split up this file & move the utilities, transformers elsewhere. One idea is to have a lib/translate folder, under which you can have various block translators, utility functions, etc. Currently it's a bit confusing to read this

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.

agree on this!

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.

yea or maybe you can just create a general translator class for magic blocks and do like a decorator pattern for each magic block type

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.

split into lib/translate/ as suggested. translateMagicBlocks.ts is the entry point + dispatcher now, image translator lives in lib/translate/image.ts, shared helpers in lib/translate/utils.ts.

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.

went with a translators map keyed by block type instead of a base class with decorators. each block has its own parse / validate / build so a base class would mostly be a hollow contract, but happy to revisit if we add more block types and start repeating ourselves.

Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import type { MdastNode } from '../../processor/transform/mdxish/magic-blocks/types';
import type { Root as MdastRoot } from 'mdast';
import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx';

import { toAttributes } from '../../processor/utils';
import { mdxishMdastToMd } from '../mdxish';

import {
getChildren,
isRecord,
type MagicBlockFigure,
type MagicBlockImageNode,
} from './utils';

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

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 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 ?? {};
// hProperties.border declared as `string` upstream; runtime is actually boolean
// (see magic-block-transformer.ts:424). Read as unknown and coerce here rather
// than widen the upstream type. Drift cleanup is filed as a follow-up.
const rawBorder = (hProperties as { border?: unknown }).border;
const borderValue = typeof rawBorder === 'boolean' ? rawBorder : undefined;
const src = node.url;

if (!src) return null;

const attributes = {
align: hProperties.align,
alt: node.alt ?? '',
border: borderValue,
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();
}

/**
* Translates a single [block:image] span into <Image /> JSX via the magic-block
* transformer + mdxishMdastToMd. Returns the original span on parse failure or
* when no figure/image node is produced. Newline padding is applied by the caller.
*/
export default function translateImageBlock(raw: string, tree: MdastRoot) {
if (!hasImageBlockData(raw)) return raw;

const translated = stringifyMdxImage(tree);
if (!translated) return raw;

return translated;
}
Loading