Skip to content

Commit 32fce17

Browse files
authored
feat: add imageToLink Remark plugin for converting image MD links to anchor tags (#2832)
1 parent 9135112 commit 32fce17

File tree

5 files changed

+88
-5
lines changed

5 files changed

+88
-5
lines changed

src/components/ChannelPreview/utils.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@ import type { Channel, PollVote, TranslationLanguages, UserResponse } from 'stre
66
import type { TranslationContextValue } from '../../context/TranslationContext';
77
import type { ChatContextValue } from '../../context';
88
import type { PluggableList } from 'unified';
9-
import { htmlToTextPlugin, plusPlusToEmphasis } from '../Message';
9+
import { htmlToTextPlugin, imageToLink, plusPlusToEmphasis } from '../Message';
1010
import remarkGfm from 'remark-gfm';
1111

1212
const remarkPlugins: PluggableList = [
1313
htmlToTextPlugin,
1414
[remarkGfm, { singleTilde: false }],
1515
plusPlusToEmphasis,
16+
imageToLink,
1617
];
1718

1819
export const renderPreviewText = (text: string) => (

src/components/Message/renderText/__tests__/renderText.test.js

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { u } from 'unist-builder';
44
import { render, screen } from '@testing-library/react';
55
import {
66
htmlToTextPlugin,
7+
imageToLink,
78
keepLineBreaksPlugin,
89
plusPlusToEmphasis,
910
} from '../remarkPlugins';
@@ -449,18 +450,53 @@ describe('plusPlusToEmphasis', () => {
449450
};
450451

451452
it('++…++ renders as <ins> and ignores code/links', () => {
452-
renderTextPlusPlus('This is ++inserted++ and `++not++` and [x](y) ++also++');
453+
renderTextPlusPlus(
454+
'This is ++inserted++ and `++not++` and [x](https://octodex.github.com/images/minion.png) ++also++',
455+
);
453456
expect(screen.getByText('inserted', { selector: 'ins' })).toBeInTheDocument();
454457
expect(screen.getByText('++not++', { selector: 'code' })).toBeInTheDocument();
455458
// link text exists; its inner text shouldn't be transformed
456-
// expect(screen.getByRole('link', { name: 'x' })).toBeInTheDocument();
459+
expect(screen.getByRole('link', { name: 'x' })).toBeInTheDocument();
457460
});
458461

459462
it('does not render ++…++ as <ins> if not present', () => {
460-
renderTextPlusPlus('This is ++inserted++ and `++not++` and [x](y) ++also++', false);
463+
renderTextPlusPlus(
464+
'This is ++inserted++ and `++not++` and [x](https://octodex.github.com/images/minion.png) ++also++',
465+
false,
466+
);
461467
expect(screen.queryByText('inserted', { selector: 'ins' })).not.toBeInTheDocument();
462468
expect(screen.getByText('++not++', { selector: 'code' })).toBeInTheDocument();
463469
// link text exists; its inner text shouldn't be transformed
464-
// expect(screen.getByRole('link', { name: 'x' })).toBeInTheDocument();
470+
expect(screen.getByRole('link', { name: 'x' })).toBeInTheDocument();
471+
});
472+
});
473+
474+
describe('imageToLink', () => {
475+
const renderImageToLink = (text, withPlugin = true) => {
476+
const Markdown = renderText(
477+
text,
478+
{},
479+
{ getRemarkPlugins: () => (withPlugin ? [imageToLink] : []) },
480+
);
481+
return render(Markdown).container;
482+
};
483+
484+
it('converts image link to anchor link', () => {
485+
renderImageToLink('Before ![x](https://octodex.github.com/images/minion.png) After');
486+
expect(
487+
screen.getByRole('link', { name: 'https://octodex.github.com/images/minion.png' }),
488+
).toBeInTheDocument();
489+
});
490+
491+
it('does not convert image link to anchor link if plugin is missing', () => {
492+
renderImageToLink(
493+
'Before ![x](https://octodex.github.com/images/minion.png) After',
494+
false,
495+
);
496+
expect(
497+
screen.queryByRole('link', {
498+
name: 'https://octodex.github.com/images/minion.png',
499+
}),
500+
).not.toBeInTheDocument();
465501
});
466502
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { SKIP, visit, type VisitorResult } from 'unist-util-visit';
2+
import type { Image, Link, Parent, Text } from 'mdast';
3+
import type { Node } from 'unist';
4+
5+
type ImgVisitor = (
6+
node: Image,
7+
index: number | null,
8+
parent: Parent | null,
9+
) => VisitorResult;
10+
11+
export type ImageToLinkPluginOptions = {
12+
getTextLabelFrom?: 'alt' | 'title' | 'url';
13+
};
14+
15+
const text = (value: string): Text => ({ type: 'text', value });
16+
17+
/**
18+
* Converts image Markdown links (![Minion](https://octodex.github.com/images/minion.png))
19+
* to HTML <a href={url}>{url | title | alt}</a>
20+
*
21+
* By default, the anchor text content is the image url so that image preview can be generated / enriched on the server.
22+
* @param getTextLabelFrom
23+
*/
24+
export function imageToLink({ getTextLabelFrom = 'url' }: ImageToLinkPluginOptions = {}) {
25+
return (tree: Node) => {
26+
const visitor: ImgVisitor = (node, index, parent) => {
27+
if (parent == null || index == null) return;
28+
29+
const label = node[getTextLabelFrom] ?? node.url; // node.alt || node.title || node.url;
30+
const link: Link = {
31+
children: [text(label)],
32+
title: node.title ?? node.alt ?? node.url,
33+
type: 'link',
34+
url: node.url,
35+
};
36+
37+
parent.children.splice(index, 1, link);
38+
return [SKIP, index + 1] as const;
39+
};
40+
41+
visit(tree, 'image', visitor);
42+
};
43+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './htmlToTextPlugin';
2+
export * from './imageToLink';
23
export * from './keepLineBreaksPlugin';
34
export * from './plusPlusToEmphasis';

src/components/Message/renderText/renderText.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { detectHttp, matchMarkdownLinks, messageCodeBlocks } from './regex';
1212
import { emojiMarkdownPlugin, mentionsMarkdownPlugin } from './rehypePlugins';
1313
import {
1414
htmlToTextPlugin,
15+
imageToLink,
1516
keepLineBreaksPlugin,
1617
plusPlusToEmphasis,
1718
} from './remarkPlugins';
@@ -175,6 +176,7 @@ export const renderText = (
175176
keepLineBreaksPlugin,
176177
[remarkGfm, { singleTilde: false }],
177178
plusPlusToEmphasis,
179+
imageToLink,
178180
];
179181
const rehypePlugins: PluggableList = [emojiMarkdownPlugin];
180182

0 commit comments

Comments
 (0)