diff --git a/src/components/ChannelPreview/utils.tsx b/src/components/ChannelPreview/utils.tsx index e8903334d0..636a75a775 100644 --- a/src/components/ChannelPreview/utils.tsx +++ b/src/components/ChannelPreview/utils.tsx @@ -6,13 +6,14 @@ import type { Channel, PollVote, TranslationLanguages, UserResponse } from 'stre import type { TranslationContextValue } from '../../context/TranslationContext'; import type { ChatContextValue } from '../../context'; import type { PluggableList } from 'unified'; -import { htmlToTextPlugin, plusPlusToEmphasis } from '../Message'; +import { htmlToTextPlugin, imageToLink, plusPlusToEmphasis } from '../Message'; import remarkGfm from 'remark-gfm'; const remarkPlugins: PluggableList = [ htmlToTextPlugin, [remarkGfm, { singleTilde: false }], plusPlusToEmphasis, + imageToLink, ]; export const renderPreviewText = (text: string) => ( diff --git a/src/components/Message/renderText/__tests__/renderText.test.js b/src/components/Message/renderText/__tests__/renderText.test.js index be2161e282..8827aa073d 100644 --- a/src/components/Message/renderText/__tests__/renderText.test.js +++ b/src/components/Message/renderText/__tests__/renderText.test.js @@ -4,6 +4,7 @@ import { u } from 'unist-builder'; import { render, screen } from '@testing-library/react'; import { htmlToTextPlugin, + imageToLink, keepLineBreaksPlugin, plusPlusToEmphasis, } from '../remarkPlugins'; @@ -449,18 +450,53 @@ describe('plusPlusToEmphasis', () => { }; it('++…++ renders as and ignores code/links', () => { - renderTextPlusPlus('This is ++inserted++ and `++not++` and [x](y) ++also++'); + renderTextPlusPlus( + 'This is ++inserted++ and `++not++` and [x](https://octodex.github.com/images/minion.png) ++also++', + ); expect(screen.getByText('inserted', { selector: 'ins' })).toBeInTheDocument(); expect(screen.getByText('++not++', { selector: 'code' })).toBeInTheDocument(); // link text exists; its inner text shouldn't be transformed - // expect(screen.getByRole('link', { name: 'x' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'x' })).toBeInTheDocument(); }); it('does not render ++…++ as if not present', () => { - renderTextPlusPlus('This is ++inserted++ and `++not++` and [x](y) ++also++', false); + renderTextPlusPlus( + 'This is ++inserted++ and `++not++` and [x](https://octodex.github.com/images/minion.png) ++also++', + false, + ); expect(screen.queryByText('inserted', { selector: 'ins' })).not.toBeInTheDocument(); expect(screen.getByText('++not++', { selector: 'code' })).toBeInTheDocument(); // link text exists; its inner text shouldn't be transformed - // expect(screen.getByRole('link', { name: 'x' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'x' })).toBeInTheDocument(); + }); +}); + +describe('imageToLink', () => { + const renderImageToLink = (text, withPlugin = true) => { + const Markdown = renderText( + text, + {}, + { getRemarkPlugins: () => (withPlugin ? [imageToLink] : []) }, + ); + return render(Markdown).container; + }; + + it('converts image link to anchor link', () => { + renderImageToLink('Before ![x](https://octodex.github.com/images/minion.png) After'); + expect( + screen.getByRole('link', { name: 'https://octodex.github.com/images/minion.png' }), + ).toBeInTheDocument(); + }); + + it('does not convert image link to anchor link if plugin is missing', () => { + renderImageToLink( + 'Before ![x](https://octodex.github.com/images/minion.png) After', + false, + ); + expect( + screen.queryByRole('link', { + name: 'https://octodex.github.com/images/minion.png', + }), + ).not.toBeInTheDocument(); }); }); diff --git a/src/components/Message/renderText/remarkPlugins/imageToLink.ts b/src/components/Message/renderText/remarkPlugins/imageToLink.ts new file mode 100644 index 0000000000..6eca7d1342 --- /dev/null +++ b/src/components/Message/renderText/remarkPlugins/imageToLink.ts @@ -0,0 +1,43 @@ +import { SKIP, visit, type VisitorResult } from 'unist-util-visit'; +import type { Image, Link, Parent, Text } from 'mdast'; +import type { Node } from 'unist'; + +type ImgVisitor = ( + node: Image, + index: number | null, + parent: Parent | null, +) => VisitorResult; + +export type ImageToLinkPluginOptions = { + getTextLabelFrom?: 'alt' | 'title' | 'url'; +}; + +const text = (value: string): Text => ({ type: 'text', value }); + +/** + * Converts image Markdown links (![Minion](https://octodex.github.com/images/minion.png)) + * to HTML {url | title | alt} + * + * By default, the anchor text content is the image url so that image preview can be generated / enriched on the server. + * @param getTextLabelFrom + */ +export function imageToLink({ getTextLabelFrom = 'url' }: ImageToLinkPluginOptions = {}) { + return (tree: Node) => { + const visitor: ImgVisitor = (node, index, parent) => { + if (parent == null || index == null) return; + + const label = node[getTextLabelFrom] ?? node.url; // node.alt || node.title || node.url; + const link: Link = { + children: [text(label)], + title: node.title ?? node.alt ?? node.url, + type: 'link', + url: node.url, + }; + + parent.children.splice(index, 1, link); + return [SKIP, index + 1] as const; + }; + + visit(tree, 'image', visitor); + }; +} diff --git a/src/components/Message/renderText/remarkPlugins/index.ts b/src/components/Message/renderText/remarkPlugins/index.ts index 35cb04faf9..f77d648b88 100644 --- a/src/components/Message/renderText/remarkPlugins/index.ts +++ b/src/components/Message/renderText/remarkPlugins/index.ts @@ -1,3 +1,4 @@ export * from './htmlToTextPlugin'; +export * from './imageToLink'; export * from './keepLineBreaksPlugin'; export * from './plusPlusToEmphasis'; diff --git a/src/components/Message/renderText/renderText.tsx b/src/components/Message/renderText/renderText.tsx index 731e039014..aef7ba0ddf 100644 --- a/src/components/Message/renderText/renderText.tsx +++ b/src/components/Message/renderText/renderText.tsx @@ -12,6 +12,7 @@ import { detectHttp, matchMarkdownLinks, messageCodeBlocks } from './regex'; import { emojiMarkdownPlugin, mentionsMarkdownPlugin } from './rehypePlugins'; import { htmlToTextPlugin, + imageToLink, keepLineBreaksPlugin, plusPlusToEmphasis, } from './remarkPlugins'; @@ -175,6 +176,7 @@ export const renderText = ( keepLineBreaksPlugin, [remarkGfm, { singleTilde: false }], plusPlusToEmphasis, + imageToLink, ]; const rehypePlugins: PluggableList = [emojiMarkdownPlugin];