やりたいこと
記事のMarkdownにベタ書きされたリンクをいい感じにカード表示したい!
## リンクカードに変換する
https://example.com
## リンクカードに変換しない
[変換しないよ](https://example.com/)
どうやるかを考える
このサイトはNext.jsで実装されていて、GithubにプッシュするとVercelにデプロイされる。
当初はビルドの際にベタ書きされたリンクがあったらカード化してしまえと考えたけど、VercelではServerless Functionsを使って自前のAPIを用意できる。 ページが表示されたタイミングでOGPタグの情報を取得するAPIにアクセスして、クライアント側でカードを表示する方式を採ってみることにした。
実装する
OGPの取得にはopen-graph-scraperを使おうと思ったけど、2022年10月25日時点ではTypescriptに非対応だった。
目的に対して少しい大きいけどjsdomを使おうと思ったら、それよりパフォーマンスが出るっぽいhappy-domを見つけた。 しかし、こちらはエラーが出てしまいシュッと回避できなかったため、結局jsdomを使うことにした。
pages/api/
の配下に、/api/xxx?url=https://example.com
でアクセスされたらリンクカードに必要な情報をJSONで返すAPIを実装する。
import { NextApiHandler, NextApiRequest, NextApiResponse } from 'next'
import { JSDOM } from 'jsdom'
export type OgData = {
url: string
siteName?: string
title?: string
description?: string
image?: string
type?: string
}
const handler: NextApiHandler = async (
req: NextApiRequest,
res: NextApiResponse
) => {
const url = Array.isArray(req.query.url) ? req.query.url[0] : req.query.url
if (!url) {
res.status(400)
return
}
const httpRes = await fetch(url)
if (!httpRes.ok) {
res.status(404)
return
}
const resHtml = await httpRes.text()
const jsdom = new JSDOM(resHtml)
const og: OgData = { url }
const metaTags = jsdom.window.document.getElementsByTagName('meta')
for (const metaTag of metaTags) {
const property = metaTag.getAttribute('property')
const content = metaTag.getAttribute('content')
switch (property) {
case 'og:site_name':
og.siteName = content || undefined
break
case 'og:title':
og.title = content || undefined
break
case 'og:description':
og.description = content || undefined
break
case 'og:image':
og.image = content || undefined
break
case 'og:type':
og.type = content || undefined
break
default:
// nop
}
}
res.setHeader('Cache-Control', 'max-age=86400')
res.send(og)
}
export default handler
リンクカードのコンポーネントではuseEffect
からAPIにアクセスする。
OGタグ取得中はローディング表示させたいのでisCompleted
のフラグで表示するコンポーネントを切り替えてあげる。
import { useEffect, useState } from 'react'
import {
Box,
Flex,
HStack,
Image,
Link,
Skeleton,
Stack,
Text,
useColorModeValue,
} from '@chakra-ui/react'
type OgState = {
ogData?: OgData
isCompleted: boolean
}
const LinkCard = ({href: string}) => {
const [state, setState] = useState<OgState>({
ogData: undefined,
isCompleted: false,
})
useEffect(() => {
fetch(`/api/xxx?url=${encodeURIComponent(href)}`)
.then((payload) => payload.json())
.then((data) => {
setState({
ogData: data,
isCompleted: true,
})
})
}, [href])
return !state.isCompleted ? (
<LinkCardLoading />
) : (
<LinkCardCompleted ogData={state.ogData as OgData} />
)
}
サイトのスタイルにはChakra-UIを使っていたので、読込中の表示はSkeletonで簡単に実装できた。
const LinkCardLoading = () => {
return (
<Box border="1px solid" borderRadius="lg" overflow="hidden">
<Stack>
<Skeleton height="20px" />
<Skeleton height="20px" />
<Skeleton height="20px" />
<Skeleton height="20px" />
</Stack>
</Box>
)
}
少し雑だが最終的に表示するカードの実装はこんな感じ。
const LinkCardCompleted = ({ ogData: OgData }) => {
return (
<Box border="1px solid" borderRadius="lg" overflow="hidden">
<Link href={ogData.url} isExternal>
<HStack>
{ogData.image && (
<Box maxW="38%" h={['20', '20', '28']}>
<Image
src={ogData.image}
alt={ogData.siteName}
width="full"
height="full"
objectFit="cover"
/>
</Box>
)}
<Flex direction="column" flex={1}>
<Text
fontSize={['xs', 'xs', 'sm']}
fontWeight="bold"
wordBreak="break-all"
noOfLines={2}
>
{ogData.title}
</Text>
<Text fontSize="xs" wordBreak="break-all" noOfLines={2}>
{ogData.description}
</Text>
</Flex>
</HStack>
</Link>
</Box>
)
}
前の記事で軽く触れたnext-mdx-remoteでReactコンポーネントに変換する部分で、こんな感じでリンクカードのコンポーネントをあてればリンクカードとして表示される。
p: (props: any) => {
if (
typeof props.children === 'object' &&
typeof props.children.props === 'object' &&
props.children.props.href
) {
return <LinkCard {...props.children.props} />
}
return <Text as="p" mb="4" lineHeight="140%" {...props} />
},
(<a>
タグであることをきちんと判定した方がよさそう。)
できあがり
これだけでもこんな具合にリンクカードが表示できる。
https://chakra-ui.com/docs/components/skeleton
おわりに
改善の余地ありだけど、割といい感じにリンクを表示できるようになったのでひとまず満足。