Skip to content

Next.jsのサイトにリンクカードを実装した

Posted on:October 25, 2022 at 09:15 AM

やりたいこと

記事の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

おわりに

改善の余地ありだけど、割といい感じにリンクを表示できるようになったのでひとまず満足。