OGPを使ってMarkdownのリンクをカード型のリンクにマークアップする
はじめに
https://silurus.dev/articles/6qI4Ldg0VRdQJXDfxlqElf
一般的なブログサービスやQiita、Zennといったサービスでは、Markdownに記述した外部リンクがカード的なUIで表示されることがある。これは OGP (Open Graph Protocol) という仕組みを使った表現で、単なるテキストリンクよりもずっと見栄えがいいのでこのブログでも実現したいと思っていた。先日試行錯誤してひとまず形になったので、この記事ではその実装の方法についてまとめておきたいと思う。上に貼り付けたこのブログ自身へのリンクがそれにあたる。
このサイトのフロントエンドの技術スタック
Next.js
(ブログ記事のSSGに使用)marked
(Markdownのマークアップに使用)Material UI
(スタイリングに使用)
Markdownの何をカードに変換するか
marked
でMarkdownをHTMLにマークアップするとき、まず何も考えずに変換するとMarkdownのリンクはすべて <a>
タグのHTMLに変換される。いまやりたいことは、このうち URL !== innerText
のリンクをカードに置き換えることである。つまり、Markdownで言えば、[text](https://www.sample.com)
で書かれるリンクは通常通りテキストリンクとして残し、https://www.sample.com
のようなベタ置きのURLをカードに変換するということになる。
OGPでデータを取得するタイミング
現状、ブログ記事のReactコンポーネントは getStaticProps
でCMSから取得したMarkdown (つまり文字列) をPropsで受け取り、コンポーネント内部で marked
によってHTMLにマークアップしている。Markdown内に現れるリンクは各記事に依存するが、SSGする場合、コンポーネント内の副作用で外部と通信してリンク先情報を取得、はできないので、リンク先情報についても記事同様 getStaticProps
で事前に取得し、Props経由でコンポーネントに渡す必要がある。
リンクをカードに差し替えるタイミング
これは2つ考えられる。marked
でリンクのカスタム renderer
を定義し、適宜リンクをカードにマークアップする方法と、一旦HTMLでレンダリングしてしまった上で、副作用により対象の <a>
タグをカードに差し替える方法である。今回は前者で実装した。なお、この方法でカスタム renderer
を定義する場合は、URLのサニタイズに注意を払う必要がある。 marked
のデフォルト renderer
はそこをよしなにやってくれていた。
具体的な実装
ここからは具体的な実装について書いていく。突貫で作ったので雑なところは今後時間があるときにリファクタリングしていきたい。
いくつかのutility関数の用意
以下の関数はコンポーネントの中に書くと長くなるので外に切り出した。
リンク先情報の取得
今回OGPクライアントとして以下を使用している。
https://www.npmjs.com/package/open-graph-scraper
このOGPクライアントを使用してリンク先情報を取得する処理は以下の関数にまとめた。URLの配列を受け取り、OGPで取得したリンク先情報の配列を返す。
getOgpData.tsimport openGraphScraper, {
OpenGraphImage,
OpenGraphProperties,
} from 'open-graph-scraper';
export type OgpData = OpenGraphProperties & {
ogImage?: OpenGraphImage | OpenGraphImage[] | undefined;
};
const getOgpData = async (floatingUrls: string[]): Promise<OgpData[]> => {
const ogpDatas: OgpData[] = [];
if (floatingUrls.length === 0) return ogpDatas;
await Promise.all(
floatingUrls.map(async (url) => {
const options = { url, onlyGetOpenGraphInfo: true };
return openGraphScraper(options)
.then((data) => {
// OGP によるデータ取得が失敗した場合
if (!data.result.success) {
return;
}
// OGP によるデータ取得が成功した場合
ogpDatas.push(data.result);
})
.catch((error) => {
// error を throw するとビルドできないため、コンソールに出力して return する
// eslint-disable-next-line no-console
console.log(error);
});
})
);
return ogpDatas;
};
export default getOgpData;
Markdownからベタ置きリンクの抽出
一つ前のリンク先情報取得のために、Markdownからベタ置きリンクを抽出する関数を以下のように定義した。ネーミングはさておき、テキストの紐付かないベタ置きリンクのことをfloating urlと名付けている。引数にMarkdownの文字列を突っ込むと、ベタ置きリンク (URL) が配列で帰ってくる。
getFloatingUrls.tsconst getFloatingUrls = (md: string): string[] => {
const regFloatUrl = /(?<!\()https?:\/\/[-_.!~*\\'()a-zA-Z0-9;\\/?:\\@&=+\\$,%#]+/g;
const floatUrls = md.match(regFloatUrl);
return floatUrls ?? [];
};
export default getFloatingUrls;
URLからドメイン名の抽出
次の関数はリンクカード末尾にfaviconとドメインを記載するために使用する。
getDomainFromUrl.ts/* eslint-disable prefer-destructuring */
const getDomainFromUrl = (url: string | undefined): string | undefined => {
if (!url) return undefined;
let result;
let match;
match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?(?:www\.)?([^:/\n?=]+)/im);
if (match) {
result = match[1];
match = result.match(/^[^.]+\.(.+\..+)$/);
if (match) {
result = match[1];
}
}
return result;
};
export default getDomainFromUrl;
ブログ記事コンポーネント
リンク先情報の取得
記事コンポーネントの getStaticProps
内に、以下を追加し、取得したリンク先情報をPropsで渡すようにする。Propsの型定義などについてはこの記事では省略する。
getStaticPropsexport const getStaticProps: GetStaticProps<Props, Params> = async (
context
) => {
/**
* 省略
* ここでMarkdownの記事を取得する。
* 記事は post (string型) とする。
*/
const floatingUrls = getFloatingUrls(post ?? '');
const ogpDatas = await getOgpData(floatingUrls);
return {
props: {
// 略
},
};
};
ベタ置きリンクをカードに置き換え
marked
の renderer
をカスタマイズして、ベタ置きのリンクをカードに置き換える。ベタ置きリンクかどうかはリンクのURLがリンクのテキストと同じかどうかで判断している。CSRF対策としてURLはサニタイズしておく必要があるので、今回は以下のライブラリを利用した。
https://www.npmjs.com/package/@braintree/sanitize-url
faviconの取得にはGoogleのAPIを利用している。また、このブログはスタイリングにMaterial UIを使っているので、CSSの中で一部Material UIのクラスを参照しているところがある。
renderer.link// Link の Renderer
renderer.link = (href, title, text) => {
const sanitizedUrl = sanitizeUrl(href ?? undefined);
const ogpData = ogpDatas.find(
(data) => data.ogUrl && href?.startsWith(data.ogUrl)
);
if ((text !== href && `${text}/` !== href) || !ogpData) {
return `
<a href="${sanitizedUrl}" target="_blanck"
${title ? `title="${title}">` : ''}
>${text}</a>`;
}
const { ogImage } = ogpData;
const image = Array.isArray(ogImage) ? ogImage[0] : ogImage;
const domain = getDomainFromUrl(ogpData?.ogUrl);
return `
<div class="og-container">
<a href=${ogpData?.ogUrl} target="_blanck" class="og-link">
<div class="MuiPaper-root MuiPaper-outlined MuiPaper-rounded og-card">
<div class="og-thumbnail-container">
<img src="${image?.url}" alt="${ogpData?.ogTitle}" class="og-thumbnail"/>
</div>
<div class="og-text-container">
<h1 class="og-title">${ogpData?.ogTitle}</h1>
<p class="MuiTypography-colorTextSecondary og-description">${ogpData?.ogDescription}</p>
<div class="og-domain-container">
<img src="https://www.google.com/s2/u/0/favicons?domain=${domain}" alt="${domain}"/>
<div class="og-domain-name">${domain}</div>
</div>
</div>
</div>
</a>
</div>`;
};
スタイルシート
リンクカード用のスタイルシート。Next.jsなので、以下は _app.tsx
からimportする。
og.scss.og-container {
.og-link {
text-decoration: none;
word-break: break-all;
.og-card {
width: 100%;
height: 80px;
overflow: hidden;
display: flex;
margin: 12px 0px;
.og-thumbnail-container {
position: relative;
width: 80px;
height: 80px;
min-width: 80px;
min-height: 80px;
&::before {
display: block;
padding-top: 100%;
}
img.og-thumbnail {
position: absolute;
width: 100%;
height: 100%;
top: 0;
right: 0;
bottom: 0;
left: 0;
margin: auto;
object-fit: cover;
}
}
.og-text-container {
width: calc(100% - 80px);
transition: background-color 0.25s ease-in-out;
letter-spacing: -0.15px;
&:hover {
background-color: rgba(128, 128, 128, 0.1);
transition: background-color 0.25s ease-in-out;
}
.og-title {
font-size: 1.1rem;
line-height: 32px;
margin: 4px 8px 0px 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.og-description {
font-size: 0.85rem;
line-height: 1rem;
margin: 0px 8px 0px 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.og-domain-container {
height: 1rem;
margin: 4px;
display: flex;
align-items: center;
justify-content: flex-end;
.og-domain-name {
display: inline-block;
font-size: 0.85rem;
line-height: 1rem;
margin: 0px 4px 0px 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
}
}
以上の実装で、現状このサイトのリンクカードは実現されている。ブログを作るためにいま色々とMarkdownのマークアップ関連で実装をしているので、一段落したらまとめて公開もしたいなと思っていたり…。