Building an external link previewer
Table of Contents
First of all, It is still a work in progress. I will update this post as I go along and make things easy to learn. In addition, I will be adding more features and making it more robust. So, stay tuned.
Why we should use link preview
While reading any article or blog post, there are many external links. However, we get distracted from the main content whenever we open it. So, we should have a preview of the link before opening it on hover or focus. It will help us to decide whether to open the link or not. Moreover, to keep the attention on the main content.
The main idea came from Rauno's uiwtf project. Whenever we hover over the link, it will show the preview screenshot
of the link. It is the main idea of the link preview.
First code approach
There are many ways of building this particular feature, but in the first approach, I tried to use it on the server side. Like building an /api/link-preview
endpoint and passing the link as a query parameter. Then, I used playwright to take the link's screenshot and return it as a base64 string. However, this approach has some drawbacks. Like, it will take more time to load the page and take more time to take the screenshot. So, I decided to use the client-side approach. Moreover, it is tricky to host it in vercel.
Playwright is a Node.js library to automate Chromium, Firefox, and WebKit with a single API. It enables cross-browser web automation that is ever-green, capable, reliable, and fast.
Extracting links from the content using regex
I use contentlayer to convert the content from the markdown files. So, I need to extract the links from the content.
Contentlayer is a content preprocessor that validates and transforms content into type-safe JSON that you can easily import into the application.
I used the following regex to extract the links from the content.
// https://morioh.com/p/2f455138edf8
const regXExternalLink =
/\[(.+)\]\((https?:\/\/[^\s]+)(?: "(.+)")?\)|(https?:\/\/[^\s]+)/gi;
This is what the contentlayer.config.ts
looks like. You can check the complete code here.
import { ComputedFields } from 'contentlayer/source-files';
const computedFields: ComputedFields = {
// ...
externalLinks: {
type: 'json',
resolve: async doc => {
// https://morioh.com/p/2f455138edf8
const regXExternalLink =
/\[(.+)\]\((https?:\/\/[^\s]+)(?: "(.+)")?\)|(https?:\/\/[^\s]+)/gi;
const externalLinks = Array.from(
doc.body.raw.matchAll(regXExternalLink)
).map((value: any) => {
const text = value[1];
const url = value[2];
if (!url) return;
// Replacing all the / with @ to avoid folder structure
const name = (url as string).replace(/[\/#]/g, '@');
return {
text,
url,
name,
};
});
return externalLinks;
},
},
};
Taking the screenshot using playwright
I am manually running the script to take a screenshot of all the links and store them in the public/previews
folder. However, I am planning to automate the process of taking screenshots.
import { readFileSync } from 'fs';
import { chromium } from 'playwright-core';
const allWritings = readFileSync(
'.contentlayer/generated/Writing/_index.json',
'utf8'
);
function previewImages() {
JSON.parse(allWritings).map(writing => {
writing.externalLinks.map(async externalLink => {
let browser = await chromium.launch();
let page = await browser.newPage();
await page.setViewportSize({ width: 1440, height: 1080 });
await page.emulateMedia({ colorScheme: 'dark' });
await page.goto(externalLink.url);
await page.waitForTimeout(1000);
await page.screenshot({
path: `public/previews/${externalLink.name}.png`,
});
await browser.close();
});
});
}
previewImages();
The script
"post:preview": "NODE_OPTIONS='--experimental-json-modules' node ./scripts/preview-images.mjs",
And then run pnpm post:preview
to take the screenshots.
Building the link preview component
I'm using radix-ui for the popover component. Furthermore, I am using tailwindcss for the styling.
import * as HoverCard from '@radix-ui/react-hover-card';
import cn from 'clsx';
import Image from 'next/image';
export default function LinkPreview({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) {
const name = href.replace(/[\/#]/g, '@');
return (
<HoverCard.Root openDelay={50} closeDelay={100}>
<HoverCard.Trigger asChild>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={cn(
'whitespace-nowrap text-[#5d676a] underline decoration-[#5d676a]/60 decoration-1 underline-offset-2 transition-colors duration-100 ease-in-out',
'opacity-100 hover:opacity-70',
'active:no-underline active:opacity-70'
)}
>
{children}
</a>
</HoverCard.Trigger>
<HoverCard.Portal>
<HoverCard.Content
side="top"
className="m-2 w-64 origin-bottom animate-preview-popup rounded-lg bg-white p-2 shadow-xl data-[side=bottom]:origin-top"
>
<figure className="relative aspect-[1.3333] w-full overflow-hidden rounded-md">
<Image
src={`/previews/${name}.png`}
className="absolute h-full w-full object-top"
layout="fill"
alt={name}
/>
</figure>
</HoverCard.Content>
</HoverCard.Portal>
</HoverCard.Root>
);
}
I am using a delay of 50ms
to open the popover and 100ms
to close the popover. So, it will not open the popover when the user is just hovering over the link. It will only open the popover when the user hovers over the link for 50ms
. It will close the popover when the user is not hovering over the link for 100ms
.
For the popover animations, I'm using CSS animations, and also I'm using the data-[side=bottom]
attribute to change the origin of the animation.
Here is what my tailwind config looks like.
animation: {
'preview-popup': '150ms cubic-bezier(0,.79,.19,.99) preview-popup',
},
keyframes: {
'preview-popup': {
'0%': {
transform: 'scale(0.3) translateY(2px)',
opacity: 0,
},
'100%': {
transform: 'scale(1) translateY(0)',
opacity: 1,
},
},
},
Using the link preview component
Now it's time to replace the normal a
tag with the LinkPreview
component in the markdown content.
export default function Post({
writing,
}: InferGetStaticPropsType<typeof getStaticProps>) {
const MDXComponent = useMDXComponent(writing.body.code);
return (
// ...
<MDXComponent
components={{
li: (props: any) => <li className="[&>p]:m-0">{props.children}</li>,
a: (props: any) => {
return (
<>
{props.className ? (
<a className={`${props.className} font-bold`} href={props.href}>
{props.children}
</a>
) : (
<LinkPreview href={props.href}>{props.children}</LinkPreview>
)}
</>
);
},
}}
/>
);
}
Conclusion and References
I know this is not the best way to do it. But I'm still learning. So, I'm open to suggestions and improvements. I'm constantly trying to improve the code and the design.
I hope you liked this article. Please let me know if you have any questions or suggestions via Twitter.
References:
- uiwtf - Main inspiration for this article
- Contentlayer - To generate the markdown content into JSON
- Playwright - To take the screenshot of the links
- Radix UI - For the popover component with great accessibility
- Tailwind CSS - For the styling and animations