-
Notifications
You must be signed in to change notification settings - Fork 18
feat: export translateMagicBlocks helper #1473
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: next
Are you sure you want to change the base?
Changes from 1 commit
1391c75
ffc72d4
3e1d9c2
f6c1aa6
b6c7621
d73eeb0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| import { afterEach, vi } from 'vitest'; | ||
|
|
||
| import * as RDMD from '../../lib'; | ||
|
|
||
| const { translateMagicBlocks } = RDMD; | ||
|
|
||
| function imageBlock(image: Record<string, unknown>) { | ||
| return `[block:image]\n${JSON.stringify({ images: [image] }, null, 2)}\n[/block]`; | ||
| } | ||
|
|
||
| function lineCount(content: string) { | ||
| return content.split('\n').length; | ||
| } | ||
|
|
||
| afterEach(() => { | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| describe('translateMagicBlocks', () => { | ||
| it('translates uncaptioned image magic blocks into Image JSX', () => { | ||
| const input = imageBlock({ | ||
| align: 'center', | ||
| border: true, | ||
| image: ['https://example.com/image.png', 'Example title', 'Example alt text'], | ||
| sizing: 'full', | ||
| }); | ||
|
|
||
| const output = translateMagicBlocks(input); | ||
|
|
||
| expect(output).toContain( | ||
| '<Image align="center" alt="Example alt text" border={true} title="Example title" width="100%" src="https://example.com/image.png" />', | ||
| ); | ||
| expect(lineCount(output)).toBe(lineCount(input)); | ||
| }); | ||
|
|
||
| it('translates captioned image magic blocks without losing the caption', () => { | ||
| const input = imageBlock({ | ||
| align: 'left', | ||
| caption: 'Readable caption', | ||
| image: ['https://example.com/captioned.png', '', 'Captioned alt'], | ||
| }); | ||
|
|
||
| const output = translateMagicBlocks(input); | ||
|
|
||
| expect(output).toContain( | ||
| '<Image align="left" alt="Captioned alt" caption="Readable caption" src="https://example.com/captioned.png" />', | ||
| ); | ||
| expect(lineCount(output)).toBe(lineCount(input)); | ||
| }); | ||
|
|
||
| it('falls back to the caption when image alt text is missing', () => { | ||
| const input = imageBlock({ | ||
| caption: 'Caption text', | ||
| image: ['https://example.com/caption-fallback.png', '', null], | ||
| }); | ||
|
|
||
| const output = translateMagicBlocks(input); | ||
|
|
||
| expect(output).toContain( | ||
| '<Image alt="Caption text" caption="Caption text" src="https://example.com/caption-fallback.png" />', | ||
| ); | ||
| expect(lineCount(output)).toBe(lineCount(input)); | ||
| }); | ||
|
|
||
| it('preserves empty alt text when no caption fallback exists', () => { | ||
| const input = imageBlock({ | ||
| caption: '', | ||
| image: ['https://example.com/missing-alt.png', '', ''], | ||
| }); | ||
|
|
||
| const output = translateMagicBlocks(input); | ||
|
|
||
| expect(output).toContain('<Image alt="" src="https://example.com/missing-alt.png" />'); | ||
| expect(lineCount(output)).toBe(lineCount(input)); | ||
| }); | ||
|
|
||
| it('keeps surrounding markdown byte-for-byte intact', () => { | ||
| const block = imageBlock({ | ||
| image: ['https://example.com/image.png', '', 'Example alt text'], | ||
| }); | ||
| const prefix = '# Title\n\n- keep spacing *exactly*\n\n'; | ||
| const suffix = '\n\n{user.name}\n<<project.name>>'; | ||
| const input = `${prefix}${block}${suffix}`; | ||
|
|
||
| const output = translateMagicBlocks(input); | ||
|
|
||
| expect(output.startsWith(prefix)).toBe(true); | ||
| expect(output.endsWith(suffix)).toBe(true); | ||
| expect(lineCount(output)).toBe(lineCount(input)); | ||
| }); | ||
|
|
||
| it('uses the first image tuple in the magic block', () => { | ||
| const input = `[block:image]\n${JSON.stringify( | ||
| { | ||
| images: [ | ||
| { caption: 'no tuple here' }, | ||
| { image: ['https://example.com/second.png', '', 'Second alt'] }, | ||
| { image: ['https://example.com/third.png', '', 'Third alt'] }, | ||
| ], | ||
| }, | ||
| null, | ||
| 2, | ||
| )}\n[/block]`; | ||
|
|
||
| const output = translateMagicBlocks(input); | ||
|
|
||
| expect(output).toContain('src="https://example.com/second.png"'); | ||
| expect(output).toContain('alt="Second alt"'); | ||
| expect(output).not.toContain('third.png'); | ||
| expect(lineCount(output)).toBe(lineCount(input)); | ||
| }); | ||
|
|
||
| it('escapes JSX-ambiguous attribute values with expression attributes', () => { | ||
| const input = imageBlock({ | ||
| caption: 'Caption with "quotes" and {braces}', | ||
| image: ['https://example.com/unsafe.png', '', 'Alt with "quotes", <tags>, and {braces}'], | ||
| }); | ||
|
|
||
| const output = translateMagicBlocks(input); | ||
|
|
||
| expect(output).toContain('alt={"Alt with \\"quotes\\", <tags>, and {braces}"}'); | ||
| expect(output).toContain('caption={"Caption with \\"quotes\\" and {braces}"}'); | ||
| expect(lineCount(output)).toBe(lineCount(input)); | ||
| }); | ||
|
|
||
| it('leaves malformed image magic blocks unchanged', () => { | ||
| const input = '[block:image]\n{not json}\n[/block]'; | ||
|
|
||
| expect(translateMagicBlocks(input)).toBe(input); | ||
| }); | ||
|
|
||
| it('leaves unsupported magic block types unchanged', () => { | ||
| const input = '[block:callout]\n{\n "type": "info",\n "body": "Keep me raw"\n}\n[/block]'; | ||
|
|
||
| expect(translateMagicBlocks(input)).toBe(input); | ||
| }); | ||
|
|
||
| it('does not call the full mdxish renderer', () => { | ||
| const mdxishSpy = vi.spyOn(RDMD, 'mdxish'); | ||
| const input = ['Intro', imageBlock({ image: ['https://example.com/image.png', '', 'Alt'] }), 'Outro'].join('\n'); | ||
|
|
||
| translateMagicBlocks(input); | ||
|
|
||
| expect(mdxishSpy).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,174 @@ | ||
| import remarkParse from 'remark-parse'; | ||
| import { unified } from 'unified'; | ||
|
|
||
| import magicBlockTransformer from '../processor/transform/mdxish/magic-blocks/magic-block-transformer'; | ||
|
|
||
| import { magicBlockFromMarkdown } from './mdast-util/magic-block'; | ||
| import { magicBlock } from './micromark/magic-block'; | ||
| import { MAGIC_BLOCK_REGEX } from './utils/extractMagicBlocks'; | ||
|
|
||
| type AttributeValue = boolean | number | string; | ||
|
|
||
| interface SerializableNode { | ||
| alt?: unknown; | ||
| children?: SerializableNode[]; | ||
| data?: unknown; | ||
| title?: unknown; | ||
| type?: unknown; | ||
| url?: unknown; | ||
| value?: unknown; | ||
| } | ||
|
|
||
| const IMAGE_BLOCK_OPEN_RE = /^\[block:image\]/; | ||
| const IMAGE_BLOCK_BODY_RE = /^\[block:image\]([\s\S]*)\[\/block\]$/; | ||
| const UNSAFE_JSX_ATTRIBUTE_CHARS = new Set(['"', '\\', '<', '>', '{', '}']); | ||
|
|
||
| const processor = unified() | ||
| .data('micromarkExtensions', [magicBlock()]) | ||
| .data('fromMarkdownExtensions', [magicBlockFromMarkdown()]) | ||
| .use(remarkParse) | ||
| .use(magicBlockTransformer, { safeMode: true }); | ||
|
|
||
| function countNewlines(value: string) { | ||
| return value.split('\n').length - 1; | ||
| } | ||
|
|
||
| function isRecord(value: unknown): value is Record<string, unknown> { | ||
| return typeof value === 'object' && value !== null && !Array.isArray(value); | ||
| } | ||
|
|
||
| function isSerializableNode(value: unknown): value is SerializableNode { | ||
| return isRecord(value); | ||
| } | ||
|
|
||
| function getString(value: unknown) { | ||
| return typeof value === 'string' ? value : undefined; | ||
| } | ||
|
|
||
| function getChildren(value: unknown) { | ||
| return isRecord(value) && Array.isArray(value.children) ? value.children.filter(isSerializableNode) : []; | ||
| } | ||
|
|
||
| function getHProperties(node: SerializableNode) { | ||
| if (!isRecord(node.data) || !isRecord(node.data.hProperties)) return {}; | ||
| return node.data.hProperties; | ||
| } | ||
|
|
||
| function parseImageBlock(raw: string) { | ||
| const body = raw.match(IMAGE_BLOCK_BODY_RE)?.[1]; | ||
| if (!body) return null; | ||
|
|
||
| try { | ||
| const parsed: unknown = JSON.parse(body.trim()); | ||
| if (!isRecord(parsed) || !Array.isArray(parsed.images)) return null; | ||
|
|
||
| return parsed.images.some(image => { | ||
| if (!isRecord(image) || !Array.isArray(image.image)) return false; | ||
| return typeof image.image[0] === 'string'; | ||
| }) | ||
| ? parsed | ||
| : null; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| function requiresJsxExpressionAttribute(value: string) { | ||
| return [...value].some(character => { | ||
| const charCode = character.charCodeAt(0); | ||
| return UNSAFE_JSX_ATTRIBUTE_CHARS.has(character) || charCode < 32 || charCode === 127; | ||
| }); | ||
| } | ||
|
|
||
| function formatAttribute(key: string, value: AttributeValue) { | ||
| if (typeof value === 'boolean' || typeof value === 'number') return `${key}={${JSON.stringify(value)}}`; | ||
| if (requiresJsxExpressionAttribute(value)) return `${key}={${JSON.stringify(value)}}`; | ||
| return `${key}="${value}"`; | ||
| } | ||
|
|
||
| function formatAttributes(attributes: [string, AttributeValue | undefined][]) { | ||
| return attributes | ||
| .flatMap(([key, value]) => (value === undefined ? [] : [formatAttribute(key, value)])) | ||
| .join(' '); | ||
| } | ||
|
|
||
| function stringifyCaption(node: SerializableNode): string { | ||
| if (typeof node.value === 'string') return node.value; | ||
| if (node.type === 'break') return '\n'; | ||
|
|
||
| return getChildren(node).map(stringifyCaption).join(''); | ||
| } | ||
|
|
||
| function serializeImage(node: SerializableNode, caption?: string) { | ||
| const hProperties = getHProperties(node); | ||
| const alt = getString(node.alt); | ||
| const title = getString(node.title); | ||
| const src = getString(hProperties.src) || getString(node.url); | ||
|
|
||
| if (!src) return null; | ||
|
|
||
| const attributes: [string, AttributeValue | undefined][] = [ | ||
| ['align', getString(hProperties.align)], | ||
| ['alt', alt], | ||
| ['border', typeof hProperties.border === 'boolean' ? hProperties.border : undefined], | ||
| ['caption', caption], | ||
| ['title', title || undefined], | ||
| ['width', getString(hProperties.width)], | ||
| ['src', src], | ||
| ]; | ||
|
|
||
| return `<Image ${formatAttributes(attributes)} />`; | ||
| } | ||
|
|
||
| function serializeFigure(node: SerializableNode) { | ||
| const image = getChildren(node).find(child => child.type === 'image'); | ||
| const figcaption = getChildren(node).find(child => child.type === 'figcaption'); | ||
| const caption = figcaption ? getChildren(figcaption).map(stringifyCaption).join('').trim() : undefined; | ||
|
|
||
| return image ? serializeImage(image, caption || undefined) : null; | ||
| } | ||
|
|
||
| function serializeTransformedNode(node: SerializableNode): string | null { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we're doing too much manual serialization. I wonder if you can just directly use the So instead of manual serializers, you can extend the processor to use more transformers & stringify-ers to convert the image MDAST back to string.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i've moved off the string templating, we're building the |
||
| 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) { | ||
|
maximilianfalco marked this conversation as resolved.
Outdated
|
||
| const children = getChildren(tree); | ||
| const node = children.find(item => item.type === 'image' || item.type === 'figure' || item.type === 'rdme-pin'); | ||
|
|
||
| return node ? serializeTransformedNode(node) : null; | ||
| } | ||
|
|
||
| function translateImageBlock(raw: string) { | ||
| if (!IMAGE_BLOCK_OPEN_RE.test(raw) || !parseImageBlock(raw)) return raw; | ||
|
|
||
| try { | ||
| const translated = serializeTransformedBlock(processor.runSync(processor.parse(raw))); | ||
| if (!translated) return raw; | ||
|
|
||
| const originalNewlineCount = countNewlines(raw); | ||
| const translatedNewlineCount = countNewlines(translated); | ||
| if (translatedNewlineCount > originalNewlineCount) return raw; | ||
|
|
||
| return translated + '\n'.repeat(originalNewlineCount - translatedNewlineCount); | ||
| } catch { | ||
| return raw; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Translates supported legacy magic blocks into MDX-shaped markdown while | ||
| * preserving the source document's line count. | ||
| */ | ||
| export default function translateMagicBlocks(content: string) { | ||
| MAGIC_BLOCK_REGEX.lastIndex = 0; | ||
| return content.replace(MAGIC_BLOCK_REGEX, match => translateImageBlock(match)); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm curious on how we can make this scalable for other magic blocks. If we want to translate more types, is it as simple as to just extend this to include more translators?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yep that's the shape now. there's a |
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
good call, i've switched to the existing types so that image case uses
MagicBlockImagedirectly and the generic walker usesMdastNode