
リンクカードを作る
最近のブログだったり、Qiita とか Zenn とかだと URL だけが書かれた行が自動的にリンクカードに変換されて、リンク先のタイトルや OGP 画像なんかが表示されたりしますよね。
自分も記事を作るときに参考にしたリンクとか使ったライブラリへのリンクを貼ることがありますが、そういうときにもこの機能があれば便利だよなってことで、このブログにも導入することにしました。
定番のライブラリ
今回は Markdown をパースして表示してるので、Remark + Rehype を使っています。
その場合だと、remark-link-card というのがあってそれが定番らしいのですが、いくつか不具合があったりして、それを更に機能改善した remark-link-card-plus というものが作られています。
私も当初はこれを使おうと思ったのですが、なぜかうまく組み込むことができず(このライブラリは取得した OGP 画像とかをキャッシュできるんですがキャッシュ自体は生成されてたり、データは取れてそうな感じなんですけど、表示ができず…)仕方がないので自作することにしました。
リンクカード生成機能を自作する
Important
自作する場合はセキュリティリスクや、相手のサーバーに迷惑がかからないように慎重に行う必要があります。
なので、基本的にはライブラリに乗っかるのが一番安全だと思います。
自分のようにどうしても自作する必要がある場合とか、学習目的であえて自分で作るという場合は、上記に気をつけながら作るようにしてください。
仕様を考える
今回は、remark 側のプラグインと rehype 側のプラグイン両方を作ります。
reamark 側でURLのみの行を検知したら、mdast を拡張して link-card という要素を追加し、
rehype 側はそれを見つけたら LinkCard コンポーネントを呼び出す、みたいな感じです
remark 側のプラグイン
export interface LinkCardNode {
type: 'link-card'
url: string
}
declare module 'mdast' {
interface RootContentMap {
'link-card': LinkCardNode
}
}
export const mdLinkCardRemark: Plugin<[], MdastRoot> = () => {
return (tree: MdastRoot) => {
return visit(tree, 'paragraph', (node: Paragraph, index, parent) => {
const targetChild = node.children[0]
if (node.children.length !== 1) return
if (targetChild.type !== 'link') return
if (targetChild.children.length !== 1) return
const isPlainLink =
targetChild.children[0].type === 'text' &&
targetChild.url === targetChild.children[0].value
if (!isPlainLink) return
if (!parent || index === undefined) return
parent.children[index] = {
type: 'link-card',
url: targetChild.url,
}
})
}
}
paragraph を順番にチェックしていき、条件にマッチするものが現れるまでスキップし続けます。
- 段落ノードの子要素が1つだけである(1行しか書かれてない)
- 子要素のノードタイプがリンクとなっている
- リンクノードの子も1つだけである
で、更に isPlainLink
のチェックは、リンク先のURLと、リンクのテキストが同じかをチェックしています。これは、Markdown でURLのみ貼ると自動的に URL へのリンクが作られて、リンクテキストも URL になる、つまりこんなじょうたいになってることを利用しています
<a href="https://example.com/" >https://exapmle.com</a>
最後に、parent
がなかったり index
が undefined みたいな場合も除外。
ここまでの条件をすべてクリアできたノードのみ、晴れて link-card
ノードに差し替えになります
parent.children[index] = {
type: 'link-card',
url: targetChild.url,
}
rehype 側のプラグイン
続いては rehype のプラグインを作ります
export function mdLinkCardRehype() {
return (tree: HastRoot) => {
visit(tree, 'link-card', (node: LinkCardNode, index, parent) => {
if (!parent || index === undefined) return
;(parent as Parent).children[index] = {
type: 'element',
tagName: 'link-card',
properties: { url: node.url },
children: [],
} as unknown as Element
})
}
}
こっちは、link-card
ノードを受け取ったら、カスタムHTMLタグの link-card
を持つ Element(というか React コンポーネント)を出力する感じにします。プロパティとして url を渡しておくことで、このあとコンポーネントの props から URL を受け取れるようになります。
ちなみに ;(parent as Parent)
と ;
からスタートしてる行がありますが、これは直前の行が return
で終わってるためで、
通常 ;
は省略できますが、予期しない動作を防ぐためにこのようにするらしいです
remarkRehype のハンドラー
ところで、link-card
というノードは、私が勝手に拡張したやつですので、rehype さんはそれが何者かを知りません。
なので、link-card
というタグが来た場合にどう変換するかを設定しておきます
export const mdLinkHandler = (_: unknown, node: LinkCardNode) => {
return {
type: 'element',
tagName: 'link-card',
properties: {
url: node.url,
},
children: [],
}
}
markdown パーサに組み込む
ということで、こんな感じでそれぞれプラグインとして読み込ませることで、URLのみの行のときに LinkCard コンポーネントが表示できるようになりました。
export async function remarkToReact(markdown: string) {
const file = await unified()
.use(remarkParse)
// remark plugins
.use(mdLinkCardRemark) // ←追加
.use(remarkGfm)
.use(remarkGithubAlerts)
.use(remarkBreaks)
// end -- remark plugins
.use(remarkRehype, {
handlers: { // ←追加
'link-card': mdLinkHandler,
},
})
// rehype plugins
.use(rehypeShiki, {
theme: 'catppuccin-mocha',
defaultLanguage: 'text',
} satisfies RehypeShikiOptions)
// end -- rehype plugins
.use(mdLinkCardRehype) // ←追加
.use(rehypeReact, {
createElement: React.createElement,
...jsxRuntime,
Fragment: React.Fragment,
components: {
'link-card': LinkCard,
},
})
.use(rehypeWrapTables, {
className: 'table-container',
})
.process(markdown)
return file.result as React.ReactNode
}
リンク先のサイトのメタデータを取得する
fetch してリンク先の HTML を取得し、それをスクレイピングライブラリの cheerio に入れて必要そうな要素のデータを集めます
この時点ではまだ何がほしいか手探りなのでいろいろ取ってますが、表示させたい内容が固まればこの辺はもっと減らせると思います
import * as cheerio from 'cheerio'
export type SiteMetadata = {
url: string
host?: string
siteName?: string
title?: string
description?: string
image?: string
type?: string
}
export async function getSiteMetadata(
url: string
): Promise<SiteMetadata | null> {
try {
const response = await fetch(url, {
next: {
revalidate: 60 * 60 * 24 * 7, // キャッシュを7日間保持
},
})
if (!response.ok) {
return null
}
const { host } = new URL(url)
const html = await response.text()
const metadata: SiteMetadata = { url, host }
const $ = cheerio.load(html)
metadata.title = $('title').text() || undefined
Object.assign(metadata, {
siteName:
$('meta[property="og:site_name"]').attr('content') || metadata.title,
title: $('meta[property="og:title"]').attr('content') || metadata.title,
description:
$('meta[property="og:description"]').attr('content') ||
$('meta[name="description"]').attr('content') ||
undefined,
image: $('meta[property="og:image"]').attr('content') || undefined,
type: $('meta[property="og:type"]').attr('content') || undefined,
})
return metadata
} catch (e) {
console.error(e)
return null
}
}
LinkCard コンポーネントを作る
最後に、React コンポーネントを作ります
import { Suspense } from 'react'
import { getSiteMetadata } from '@/lib/get-site-metadata'
import styles from './LinkCard.module.scss'
type LinkCardProps = {
url: string
}
export default function LinkCard({ url }: LinkCardProps) {
return (
<Suspense fallback={null}>
<LinkCardContent url={url} />
</Suspense>
)
}
async function LinkCardContent({ url }: LinkCardProps) {
const siteMeta = await getSiteMetadata(url)
return siteMeta ? (
<div className={styles['link-card']}>
<div className={styles['link-card-metadata']}>
<div className={styles['link-card-metadata--title']}>
<a href={siteMeta.url} target="_blank" rel="noopener noreferrer">
{siteMeta.title}
</a>
</div>
<div className={styles['link-card-metadata--description']}>
{siteMeta.description}
</div>
<div className={styles['link-card-metadata--host']}>
<a href={siteMeta.url} target="_blank" rel="noopener noreferrer">
{siteMeta.host}
</a>
</div>
</div>
{siteMeta.image && (
<div className={styles['link-card-thumbnail']}>
<img
className={styles['link-card-thumbnail--image']}
src={siteMeta.image}
alt={siteMeta.title}
/>
</div>
)}
</div>
) : (
<div>
<a href={url} target="_blank" rel="noopener noreferrer">
{url}
</a>
</div>
)
}
とりあえずこれで、それらしくはできたので、あとは細かな調整をしていけば、よさそうです
続く