diff --git a/__tests__/lib/translate/translateMagicBlocks.test.ts b/__tests__/lib/translate/translateMagicBlocks.test.ts new file mode 100644 index 000000000..f38899ce8 --- /dev/null +++ b/__tests__/lib/translate/translateMagicBlocks.test.ts @@ -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) { + 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( + 'Example alt text\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( + '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('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'], + }); + 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 through the MDX stringifier', () => { + 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('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]'; + + 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( + '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'); + + translateMagicBlocks(input); + + expect(mdxishSpy).not.toHaveBeenCalled(); + }); +}); 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/index.ts b/lib/index.ts index d6f6b045d..6170dcbb0 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 './translate'; diff --git a/lib/translate/image.ts b/lib/translate/image.ts new file mode 100644 index 000000000..0a4e6c073 --- /dev/null +++ b/lib/translate/image.ts @@ -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 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; +} 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) : []; +} 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;