From 1391c75df9257a58d15a97c5aac2b73f317b4470 Mon Sep 17 00:00:00 2001 From: RohanLyra Date: Sat, 16 May 2026 01:33:15 +1000 Subject: [PATCH 1/6] feat: export magic block translation helper --- __tests__/lib/translateMagicBlocks.test.ts | 146 +++++++++++++++++ lib/index.ts | 1 + lib/translateMagicBlocks.ts | 174 +++++++++++++++++++++ 3 files changed, 321 insertions(+) create mode 100644 __tests__/lib/translateMagicBlocks.test.ts create mode 100644 lib/translateMagicBlocks.ts diff --git a/__tests__/lib/translateMagicBlocks.test.ts b/__tests__/lib/translateMagicBlocks.test.ts new file mode 100644 index 000000000..b91eaa097 --- /dev/null +++ b/__tests__/lib/translateMagicBlocks.test.ts @@ -0,0 +1,146 @@ +import { afterEach, vi } from 'vitest'; + +import * as RDMD from '../../lib'; + +const { translateMagicBlocks } = RDMD; + +function imageBlock(image: Record) { + 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( + 'Example alt text', + ); + 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( + 'Captioned alt', + ); + 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( + 'Caption text', + ); + 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(''); + 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<>'; + 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", , and {braces}'], + }); + + const output = translateMagicBlocks(input); + + expect(output).toContain('alt={"Alt with \\"quotes\\", , 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(); + }); +}); diff --git a/lib/index.ts b/lib/index.ts index d6f6b045d..c7a52eb5f 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -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'; diff --git a/lib/translateMagicBlocks.ts b/lib/translateMagicBlocks.ts new file mode 100644 index 000000000..b5da76779 --- /dev/null +++ b/lib/translateMagicBlocks.ts @@ -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 { + 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 { + 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 ``; +} + +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 { + 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) { + 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)); +} From ffc72d4a30d163dbe50543391d00eb781d9a5cb5 Mon Sep 17 00:00:00 2001 From: RohanLyra Date: Tue, 19 May 2026 02:06:00 +1000 Subject: [PATCH 2/6] refactor: simplify magic block translation serialization --- lib/translateMagicBlocks.ts | 154 +++++++++++------- .../transform/mdxish/magic-blocks/types.ts | 3 +- 2 files changed, 101 insertions(+), 56 deletions(-) diff --git a/lib/translateMagicBlocks.ts b/lib/translateMagicBlocks.ts index b5da76779..2be2343f6 100644 --- a/lib/translateMagicBlocks.ts +++ b/lib/translateMagicBlocks.ts @@ -1,25 +1,30 @@ +import type { MdastNode, MagicBlockImage } from '../processor/transform/mdxish/magic-blocks/types'; +import type { Root as MdastRoot } from 'mdast'; +import type { MdxJsxAttribute, MdxJsxAttributeValueExpression, MdxJsxFlowElement } from 'mdast-util-mdx-jsx'; + +import { valueToEstree } from 'estree-util-value-to-estree'; 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 { mdxishMdastToMd } from './mdxish'; import { magicBlock } from './micromark/magic-block'; import { MAGIC_BLOCK_REGEX } from './utils/extractMagicBlocks'; type AttributeValue = boolean | number | string; -interface SerializableNode { - alt?: unknown; - children?: SerializableNode[]; - data?: unknown; - title?: unknown; - type?: unknown; - url?: unknown; - value?: unknown; +type MagicBlockTranslator = (raw: string) => string; + +interface MagicBlockFigure extends MdastNode { + children: MdastNode[]; + type: 'figure'; } -const IMAGE_BLOCK_OPEN_RE = /^\[block:image\]/; +type MagicBlockImageNode = MagicBlockImage & MdastNode; + +const MAGIC_BLOCK_OPEN_RE = /^\[block:([^\]]{1,100})\]/; const IMAGE_BLOCK_BODY_RE = /^\[block:image\]([\s\S]*)\[\/block\]$/; const UNSAFE_JSX_ATTRIBUTE_CHARS = new Set(['"', '\\', '<', '>', '{', '}']); @@ -37,39 +42,40 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -function isSerializableNode(value: unknown): value is SerializableNode { - return isRecord(value); +function isMdastNode(value: unknown): value is MdastNode { + return isRecord(value) && typeof value.type === 'string'; } -function getString(value: unknown) { - return typeof value === 'string' ? value : undefined; +function isMdastRoot(value: unknown): value is MdastRoot { + return isRecord(value) && value.type === 'root' && Array.isArray(value.children); } -function getChildren(value: unknown) { - return isRecord(value) && Array.isArray(value.children) ? value.children.filter(isSerializableNode) : []; +function isMagicBlockImage(node: MdastNode): node is MagicBlockImageNode { + return node.type === 'image'; } -function getHProperties(node: SerializableNode) { - if (!isRecord(node.data) || !isRecord(node.data.hProperties)) return {}; - return node.data.hProperties; +function isMagicBlockFigure(node: MdastNode): node is MagicBlockFigure { + return node.type === 'figure' && Array.isArray(node.children); } -function parseImageBlock(raw: string) { +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 null; + if (!body) return false; try { const parsed: unknown = JSON.parse(body.trim()); - if (!isRecord(parsed) || !Array.isArray(parsed.images)) return null; + 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'; - }) - ? parsed - : null; + }); } catch { - return null; + return false; } } @@ -81,77 +87,104 @@ function requiresJsxExpressionAttribute(value: string) { } 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}"`; -} + if (typeof value === 'string' && !requiresJsxExpressionAttribute(value)) { + return { type: 'mdxJsxAttribute', name: key, value } satisfies MdxJsxAttribute; + } -function formatAttributes(attributes: [string, AttributeValue | undefined][]) { + const expressionValue = typeof value === 'string' ? JSON.stringify(value) : String(value); + return { + type: 'mdxJsxAttribute', + name: key, + value: { + type: 'mdxJsxAttributeValueExpression', + value: expressionValue, + data: { + estree: { + type: 'Program', + body: [{ type: 'ExpressionStatement', expression: valueToEstree(value) }], + sourceType: 'module', + comments: [], + }, + }, + } satisfies MdxJsxAttributeValueExpression, + } satisfies MdxJsxAttribute; +} + +function formatAttributes(attributes: [string, AttributeValue | undefined][]): MdxJsxAttribute[] { return attributes .flatMap(([key, value]) => (value === undefined ? [] : [formatAttribute(key, value)])) - .join(' '); + .filter(attribute => attribute.value !== undefined); } -function stringifyCaption(node: SerializableNode): string { +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 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); +function imageToMdx(node: MagicBlockImageNode, caption?: string) { + const hProperties = node.data?.hProperties ?? {}; + const src = hProperties.src || 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], + ['align', hProperties.align], + ['alt', node.alt ?? ''], + ['border', hProperties.border], ['caption', caption], - ['title', title || undefined], - ['width', getString(hProperties.width)], + ['title', node.title || undefined], + ['width', hProperties.width], ['src', src], ]; - return ``; + return { + type: 'mdxJsxFlowElement', + name: 'Image', + attributes: formatAttributes(attributes), + children: [], + } satisfies MdxJsxFlowElement; } -function serializeFigure(node: SerializableNode) { - const image = getChildren(node).find(child => child.type === 'image'); +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 ? serializeImage(image, caption || undefined) : null; + return image ? imageToMdx(image, caption || undefined) : null; } -function serializeTransformedNode(node: SerializableNode): string | null { - if (node.type === 'image') return serializeImage(node); - if (node.type === 'figure') return serializeFigure(node); +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 ? serializeTransformedNode(child) : null; + return child ? transformedNodeToMdx(child) : null; } return null; } -function serializeTransformedBlock(tree: unknown) { +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; - return node ? serializeTransformedNode(node) : null; + if (!mdxNode) return null; + + return mdxishMdastToMd({ type: 'root', children: [mdxNode] }).trim(); } function translateImageBlock(raw: string) { - if (!IMAGE_BLOCK_OPEN_RE.test(raw) || !parseImageBlock(raw)) return raw; + if (!hasImageBlockData(raw)) return raw; try { - const translated = serializeTransformedBlock(processor.runSync(processor.parse(raw))); + const tree = processor.runSync(processor.parse(raw)); + if (!isMdastRoot(tree)) return raw; + + const translated = stringifyMdxImage(tree); if (!translated) return raw; const originalNewlineCount = countNewlines(raw); @@ -164,11 +197,22 @@ function translateImageBlock(raw: string) { } } +const translators: Partial> = { + image: translateImageBlock, +}; + +function translateMagicBlock(raw: string) { + const blockType = raw.match(MAGIC_BLOCK_OPEN_RE)?.[1]; + const translator = blockType ? translators[blockType] : undefined; + + 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 => translateImageBlock(match)); + return content.replace(MAGIC_BLOCK_REGEX, match => translateMagicBlock(match)); } diff --git a/processor/transform/mdxish/magic-blocks/types.ts b/processor/transform/mdxish/magic-blocks/types.ts index 2925d2586..ee95af627 100644 --- a/processor/transform/mdxish/magic-blocks/types.ts +++ b/processor/transform/mdxish/magic-blocks/types.ts @@ -82,7 +82,8 @@ export interface MagicBlockImage { data?: { hProperties?: { align?: string; - border?: string; + border?: boolean; + src?: string; width?: string; }; }; From 3e1d9c25afda1283984239bab360151832343695 Mon Sep 17 00:00:00 2001 From: RohanLyra Date: Tue, 19 May 2026 02:49:17 +1000 Subject: [PATCH 3/6] refactor: reuse mdx attribute helpers --- __tests__/lib/translateMagicBlocks.test.ts | 6 +- __tests__/processor/utils.test.ts | 20 ++++++ lib/translateMagicBlocks.ts | 69 +++++-------------- .../transform/mdxish/magic-blocks/types.ts | 1 - processor/utils.ts | 16 ++++- 5 files changed, 53 insertions(+), 59 deletions(-) diff --git a/__tests__/lib/translateMagicBlocks.test.ts b/__tests__/lib/translateMagicBlocks.test.ts index b91eaa097..4e39a0caa 100644 --- a/__tests__/lib/translateMagicBlocks.test.ts +++ b/__tests__/lib/translateMagicBlocks.test.ts @@ -110,7 +110,7 @@ describe('translateMagicBlocks', () => { expect(lineCount(output)).toBe(lineCount(input)); }); - it('escapes JSX-ambiguous attribute values with expression attributes', () => { + 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", , and {braces}'], @@ -118,8 +118,8 @@ describe('translateMagicBlocks', () => { const output = translateMagicBlocks(input); - expect(output).toContain('alt={"Alt with \\"quotes\\", , and {braces}"}'); - expect(output).toContain('caption={"Caption with \\"quotes\\" and {braces}"}'); + expect(output).toContain('alt="Alt with "quotes", , and {braces}"'); + expect(output).toContain('caption="Caption with "quotes" and {braces}"'); expect(lineCount(output)).toBe(lineCount(input)); }); diff --git a/__tests__/processor/utils.test.ts b/__tests__/processor/utils.test.ts index 6408c40cf..7bb39844b 100644 --- a/__tests__/processor/utils.test.ts +++ b/__tests__/processor/utils.test.ts @@ -38,6 +38,17 @@ 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 }); @@ -45,6 +56,15 @@ describe('toAttributes', () => { 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 }); diff --git a/lib/translateMagicBlocks.ts b/lib/translateMagicBlocks.ts index 2be2343f6..3646d883e 100644 --- a/lib/translateMagicBlocks.ts +++ b/lib/translateMagicBlocks.ts @@ -1,20 +1,18 @@ import type { MdastNode, MagicBlockImage } from '../processor/transform/mdxish/magic-blocks/types'; import type { Root as MdastRoot } from 'mdast'; -import type { MdxJsxAttribute, MdxJsxAttributeValueExpression, MdxJsxFlowElement } from 'mdast-util-mdx-jsx'; +import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx'; -import { valueToEstree } from 'estree-util-value-to-estree'; 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 AttributeValue = boolean | number | string; - type MagicBlockTranslator = (raw: string) => string; interface MagicBlockFigure extends MdastNode { @@ -26,7 +24,6 @@ type MagicBlockImageNode = MagicBlockImage & MdastNode; const MAGIC_BLOCK_OPEN_RE = /^\[block:([^\]]{1,100})\]/; const IMAGE_BLOCK_BODY_RE = /^\[block:image\]([\s\S]*)\[\/block\]$/; -const UNSAFE_JSX_ATTRIBUTE_CHARS = new Set(['"', '\\', '<', '>', '{', '}']); const processor = unified() .data('micromarkExtensions', [magicBlock()]) @@ -79,43 +76,6 @@ function hasImageBlockData(raw: string) { } } -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 === 'string' && !requiresJsxExpressionAttribute(value)) { - return { type: 'mdxJsxAttribute', name: key, value } satisfies MdxJsxAttribute; - } - - const expressionValue = typeof value === 'string' ? JSON.stringify(value) : String(value); - return { - type: 'mdxJsxAttribute', - name: key, - value: { - type: 'mdxJsxAttributeValueExpression', - value: expressionValue, - data: { - estree: { - type: 'Program', - body: [{ type: 'ExpressionStatement', expression: valueToEstree(value) }], - sourceType: 'module', - comments: [], - }, - }, - } satisfies MdxJsxAttributeValueExpression, - } satisfies MdxJsxAttribute; -} - -function formatAttributes(attributes: [string, AttributeValue | undefined][]): MdxJsxAttribute[] { - return attributes - .flatMap(([key, value]) => (value === undefined ? [] : [formatAttribute(key, value)])) - .filter(attribute => attribute.value !== undefined); -} - function stringifyCaption(node: MdastNode): string { if (typeof node.value === 'string') return node.value; if (node.type === 'break') return '\n'; @@ -125,24 +85,27 @@ function stringifyCaption(node: MdastNode): string { function imageToMdx(node: MagicBlockImageNode, caption?: string) { const hProperties = node.data?.hProperties ?? {}; - const src = hProperties.src || node.url; + const src = node.url; if (!src) return null; - const attributes: [string, AttributeValue | undefined][] = [ - ['align', hProperties.align], - ['alt', node.alt ?? ''], - ['border', hProperties.border], - ['caption', caption], - ['title', node.title || undefined], - ['width', hProperties.width], - ['src', src], - ]; + 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: formatAttributes(attributes), + attributes: toAttributes(attributes, ['align', 'alt', 'border', 'caption', 'title', 'width', 'src'], { + preserveEmpty: ['alt'], + preserveFalse: ['border'], + }), children: [], } satisfies MdxJsxFlowElement; } diff --git a/processor/transform/mdxish/magic-blocks/types.ts b/processor/transform/mdxish/magic-blocks/types.ts index ee95af627..7169b7614 100644 --- a/processor/transform/mdxish/magic-blocks/types.ts +++ b/processor/transform/mdxish/magic-blocks/types.ts @@ -83,7 +83,6 @@ export interface MagicBlockImage { hProperties?: { align?: string; border?: boolean; - src?: string; width?: string; }; }; diff --git a/processor/utils.ts b/processor/utils.ts index 87e169592..dc8de2db1 100644 --- a/processor/utils.ts +++ b/processor/utils.ts @@ -246,15 +246,27 @@ export const reformatHTML = (html: string): string => { return cleaned; }; -export const toAttributes = (object: Record, keys: string[] = []): MdxJsxAttribute[] => { +interface ToAttributesOptions { + preserveEmpty?: string[]; + preserveFalse?: string[]; +} + +export const toAttributes = ( + object: Record, + keys: string[] = [], + options: ToAttributesOptions = {}, +): MdxJsxAttribute[] => { const attributes: MdxJsxAttribute[] = []; + const { preserveEmpty = [], preserveFalse = [] } = options; Object.entries(object).forEach(([name, v]) => { if (keys.length > 0 && !keys.includes(name)) return; let value: MdxJsxAttributeValueExpression | string; + const shouldSkipEmpty = v === '' && !preserveEmpty.includes(name); + const shouldSkipFalse = v === false && !preserveFalse.includes(name); - if (typeof v === 'undefined' || v === null || v === '' || v === false) { + if (typeof v === 'undefined' || v === null || shouldSkipEmpty || shouldSkipFalse) { return; } else if (typeof v === 'string') { value = v; From f6c1aa6f22aa2cfd6d22e1ffdd81414d3764ff61 Mon Sep 17 00:00:00 2001 From: RohanLyra Date: Tue, 2 Jun 2026 01:22:38 +1000 Subject: [PATCH 4/6] refactor(translateMagicBlocks): split into lib/translate/ folder Splits the single-file translateMagicBlocks helper into a four-file module under lib/translate/ with a per-type translator registry, per PR review feedback from @eaglethrost and @maximilianfalco on #1473. Adds JSDoc on the public entry and the image translator, plus a comment noting MAGIC_BLOCK_OPEN_RE is a prefix subset of MAGIC_BLOCK_REGEX in lib/utils/extractMagicBlocks.ts. Adding a new block-type translator is now one new file plus one line in the translators map. No behavior change for the image case; serializer (mdxishMdastToMd + extended toAttributes) is unchanged. --- lib/index.ts | 2 +- .../image.ts} | 106 ++++-------------- lib/translate/index.ts | 3 + lib/translate/translateMagicBlocks.ts | 66 +++++++++++ lib/translate/utils.ts | 27 +++++ 5 files changed, 121 insertions(+), 83 deletions(-) rename lib/{translateMagicBlocks.ts => translate/image.ts} (50%) create mode 100644 lib/translate/index.ts create mode 100644 lib/translate/translateMagicBlocks.ts create mode 100644 lib/translate/utils.ts diff --git a/lib/index.ts b/lib/index.ts index c7a52eb5f..6170dcbb0 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -19,4 +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'; +export { default as translateMagicBlocks } from './translate'; diff --git a/lib/translateMagicBlocks.ts b/lib/translate/image.ts similarity index 50% rename from lib/translateMagicBlocks.ts rename to lib/translate/image.ts index 3646d883e..0a4e6c073 100644 --- a/lib/translateMagicBlocks.ts +++ b/lib/translate/image.ts @@ -1,52 +1,19 @@ -import type { MdastNode, MagicBlockImage } from '../processor/transform/mdxish/magic-blocks/types'; +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 remarkParse from 'remark-parse'; -import { unified } from 'unified'; +import { toAttributes } from '../../processor/utils'; +import { mdxishMdastToMd } from '../mdxish'; -import magicBlockTransformer from '../processor/transform/mdxish/magic-blocks/magic-block-transformer'; -import { toAttributes } from '../processor/utils'; +import { + getChildren, + isRecord, + type MagicBlockFigure, + type MagicBlockImageNode, +} from './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})\]/; 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 { - 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'; } @@ -55,10 +22,6 @@ 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; @@ -85,6 +48,11 @@ function stringifyCaption(node: MdastNode): string { 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; @@ -92,7 +60,7 @@ function imageToMdx(node: MagicBlockImageNode, caption?: string) { const attributes = { align: hProperties.align, alt: node.alt ?? '', - border: hProperties.border, + border: borderValue, caption, title: node.title || undefined, width: hProperties.width, @@ -140,42 +108,16 @@ function stringifyMdxImage(tree: MdastRoot) { return mdxishMdastToMd({ type: 'root', children: [mdxNode] }).trim(); } -function translateImageBlock(raw: string) { +/** + * Translates a single [block:image] span into 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; - try { - const tree = processor.runSync(processor.parse(raw)); - if (!isMdastRoot(tree)) return raw; - - const translated = stringifyMdxImage(tree); - if (!translated) 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> = { - image: translateImageBlock, -}; - -function translateMagicBlock(raw: string) { - const blockType = raw.match(MAGIC_BLOCK_OPEN_RE)?.[1]; - const translator = blockType ? translators[blockType] : undefined; - - 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)); + return translated; } diff --git a/lib/translate/index.ts b/lib/translate/index.ts new file mode 100644 index 000000000..a9e2e44ff --- /dev/null +++ b/lib/translate/index.ts @@ -0,0 +1,3 @@ +import translateMagicBlocks from './translateMagicBlocks'; + +export default translateMagicBlocks; diff --git a/lib/translate/translateMagicBlocks.ts b/lib/translate/translateMagicBlocks.ts new file mode 100644 index 000000000..c95f14fcb --- /dev/null +++ b/lib/translate/translateMagicBlocks.ts @@ -0,0 +1,66 @@ +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'; + +import translateImageBlock from './image'; +import { isMdastRoot, type BlockTranslator } from './utils'; + +// Prefix subset of MAGIC_BLOCK_REGEX (lib/utils/extractMagicBlocks.ts:14); +// peeks at the block type so we can dispatch without re-scanning the full span. +const MAGIC_BLOCK_OPEN_RE = /^\[block:([^\]]{1,100})\]/; + +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; +} + +const translateImage: BlockTranslator = raw => { + try { + const tree = processor.runSync(processor.parse(raw)); + if (!isMdastRoot(tree)) return raw; + + const translated = translateImageBlock(raw, 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> = { + image: translateImage, +}; + +function translateMagicBlock(raw: string) { + const blockType = raw.match(MAGIC_BLOCK_OPEN_RE)?.[1]; + const translator = blockType ? translators[blockType] : undefined; + + return translator ? translator(raw) : raw; +} + +/** + * Translates supported legacy magic blocks into MDX-shaped markdown while + * preserving the source document's line count. + * + * Currently registered: `image`. Other block types pass through unchanged. + * To register a new translator, add an entry to the `translators` map in this + * file; each translator owns its own parse/validate/serialize per block shape. + */ +export default function translateMagicBlocks(content: string) { + MAGIC_BLOCK_REGEX.lastIndex = 0; + return content.replace(MAGIC_BLOCK_REGEX, match => translateMagicBlock(match)); +} diff --git a/lib/translate/utils.ts b/lib/translate/utils.ts new file mode 100644 index 000000000..cd6f131ba --- /dev/null +++ b/lib/translate/utils.ts @@ -0,0 +1,27 @@ +import type { MdastNode, MagicBlockImage } from '../../processor/transform/mdxish/magic-blocks/types'; +import type { Root as MdastRoot } from 'mdast'; + +export type BlockTranslator = (raw: string) => string; + +export interface MagicBlockFigure extends MdastNode { + children: MdastNode[]; + type: 'figure'; +} + +export type MagicBlockImageNode = MagicBlockImage & MdastNode; + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function isMdastNode(value: unknown): value is MdastNode { + return isRecord(value) && typeof value.type === 'string'; +} + +export function isMdastRoot(value: unknown): value is MdastRoot { + return isRecord(value) && value.type === 'root' && Array.isArray(value.children); +} + +export function getChildren(value: MdastNode | MdastRoot) { + return Array.isArray(value.children) ? value.children.filter(isMdastNode) : []; +} From b6c76210b5014bb5b855a10955b1cf6e73225c60 Mon Sep 17 00:00:00 2001 From: RohanLyra Date: Tue, 2 Jun 2026 01:22:46 +1000 Subject: [PATCH 5/6] revert(magic-blocks/types): restore MagicBlockImage border to string Reverts an unintended type change in #1473 that flipped MagicBlockImage.data.hProperties.border from string to boolean. @maximilianfalco flagged the blast radius on the magic-blocks types file; reverting here keeps this PR scoped to the translateMagicBlocks helper. The runtime in magic-block-transformer.ts actually emits a boolean, so the upstream type is unsound, but cleaning that up belongs in a separate focused PR. lib/translate/image.ts now coerces hProperties.border locally through an unknown cast. --- processor/transform/mdxish/magic-blocks/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/processor/transform/mdxish/magic-blocks/types.ts b/processor/transform/mdxish/magic-blocks/types.ts index 7169b7614..2925d2586 100644 --- a/processor/transform/mdxish/magic-blocks/types.ts +++ b/processor/transform/mdxish/magic-blocks/types.ts @@ -82,7 +82,7 @@ export interface MagicBlockImage { data?: { hProperties?: { align?: string; - border?: boolean; + border?: string; width?: string; }; }; From d73eeb0f9db136475f298593ab43c54436c48b8d Mon Sep 17 00:00:00 2001 From: RohanLyra Date: Tue, 2 Jun 2026 01:22:56 +1000 Subject: [PATCH 6/6] test(translateMagicBlocks): add golden, border-false, multi-block, regex isolation, transformer fallback cases Moves tests to __tests__/lib/translate/ to mirror the new module path and extends coverage: - Golden toBe assertion on a canonical Akamai-shaped fixture so any future serializer drift is caught byte-exact. - border={false} preservation after the upstream border-type revert. - Two image blocks in one document. - Mixed image / unsupported / image in one document. - [block:table] and [block:tutorial-tile] passthrough cases. - MAGIC_BLOCK_REGEX.lastIndex isolation (pre-set and assert correct output, also assert subsequent extractMagicBlocks unaffected). - Behavioral registry test through the public entry, no internal-map import. - Synthetic fixture where hasImageBlockData passes but the transformer produces no usable node, asserting raw passthrough. --- .../translateMagicBlocks.test.ts | 95 ++++++++++++++++++- 1 file changed, 91 insertions(+), 4 deletions(-) rename __tests__/lib/{ => translate}/translateMagicBlocks.test.ts (57%) diff --git a/__tests__/lib/translateMagicBlocks.test.ts b/__tests__/lib/translate/translateMagicBlocks.test.ts similarity index 57% rename from __tests__/lib/translateMagicBlocks.test.ts rename to __tests__/lib/translate/translateMagicBlocks.test.ts index 4e39a0caa..f38899ce8 100644 --- a/__tests__/lib/translateMagicBlocks.test.ts +++ b/__tests__/lib/translate/translateMagicBlocks.test.ts @@ -1,6 +1,7 @@ import { afterEach, vi } from 'vitest'; -import * as RDMD from '../../lib'; +import * as RDMD from '../../../lib'; +import { extractMagicBlocks, MAGIC_BLOCK_REGEX } from '../../../lib/utils/extractMagicBlocks'; const { translateMagicBlocks } = RDMD; @@ -27,10 +28,9 @@ describe('translateMagicBlocks', () => { const output = translateMagicBlocks(input); - expect(output).toContain( - 'Example alt text', + expect(output).toBe( + 'Example alt text\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n', ); - expect(lineCount(output)).toBe(lineCount(input)); }); it('translates captioned image magic blocks without losing the caption', () => { @@ -74,6 +74,18 @@ describe('translateMagicBlocks', () => { 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('No border'); + 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'], @@ -123,6 +135,40 @@ describe('translateMagicBlocks', () => { 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('First alt'); + expect(output).toContain('Second alt'); + 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('First alt'); + expect(output).toContain(unsupported); + expect(output).toContain('Second alt'); + expect(lineCount(output)).toBe(lineCount(input)); + }); + it('leaves malformed image magic blocks unchanged', () => { const input = '[block:image]\n{not json}\n[/block]'; @@ -135,6 +181,47 @@ describe('translateMagicBlocks', () => { 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( + 'Public entry', + ); + }); + + 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('Last index'); + 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');