ブログに​リンクカードを​実装した

11 分
link-card

成果物

ブログのコード:

リンクカードのサーバー側実装:

↑こういう感じに表示される。

経緯

Zenn や note などのメジャーなブログサイトだと、記事内にリンクを配置したら、そのサイトの OG 情報を綺麗に表示してくれてかなりオシャレ。なので、自分のブログにも実装したいと思った。

リンクカードで表示する時と、単に URL を表示するのとでは印象がだいぶ変わるので、リンクカード実装するだけで見栄えが上がる。

実装方針

前回のブログは Next.js だったので、SSR とか API とか自前で完結したが、今回は Astro なのでそうはいかない。

そもそもリンクカードの実装に必要な要件は、

  • URL をフェッチ
  • HTML をパースして OG 情報を取得
  • 適切なスタイルと挙動のカードを表示

これをやらねばならない。また、リンク先の情報は更新される可能性がある ので、静的ビルド時にやるのは少し微妙。また、クライアント側で取得するのは CORS に阻まれてできない。なので、なるべく最新の情報を反映した状態で表示するには、<iframe> を使って埋め込むのが一番楽になると思う。

実際、Zenn はそのように実装されている。

なので以下の流れとなる。

  1. マークダウンからリンクを抽出
  2. <iframe> に置き換え
  3. サーバー側で情報を取得してレンダリング
  4. 表示

サーバー側の実装について見ていく。

サーバー

「サーバーを用意してリクエストに応じて HTML を返す」を実行する選択肢はいくつかあるが、Cloudflare 大好きなので Hono と Cloudflare Workers を使うことにした。

Hono には JSX の機能があって、サーバーサイドでレンダリングして HTML を返すことができる。すごい。

(今気づいたけど、なぜか hono の OGP をちゃんと取得できてないっぽいので後で修正…)

React みたいなフックもあるが、今回は使わなかった。

ここで、指定されたリンク先にアクセスして HTML をパースすることなんだが、HTML のパースは一筋縄ではいかない。 なぜなら、Workers ではメジャーな DOM 操作のライブラリは使えない からだ。

そこでどうするかというと、Workers 上で使用できる HTMLRewriter を応用して情報を抽出する。これは、「workers リンクカード」などで調べるとヒットする先人の方法に倣った。マジでめちゃくちゃ役に立った。

みんなやることは同じなんだね〜。

自分はこれらの記事を参考に、以下のように実装した。

ogp.ts
export interface OGPData {
title?: string | null;
description?: string | null;
image?: string | null;
url?: string | null;
siteName?: string | null;
type?: string | null;
favicon?: string | null;
}
class OGPParser {
origin: string = "";
ogp: OGPData = {};
constructor(url: string) {
this.origin = new URL(url).origin;
}
meta(element: Element) {
const property = element.getAttribute("property");
const content = element.getAttribute("content");
switch (property) {
case "og:title": {
this.ogp.title = content;
break;
}
case "og:description": {
this.ogp.description = content;
break;
}
case "og:image": {
this.ogp.image = new URL(content || "", this.origin).href;
break;
}
case "og:url": {
this.ogp.url = new URL(content || "", this.origin).href;
break;
}
case "og:site_name": {
this.ogp.siteName = content;
break;
}
case "og:type": {
this.ogp.type = content;
break;
}
}
}
link(element: Element) {
const rel = element.getAttribute("rel") ?? "";
const href = element.getAttribute("href");
const rels = rel.split(" ").map((r) => r.toLowerCase());
if (rels.includes("icon")) {
this.ogp.favicon = new URL(href || "", this.origin).href;
}
}
element(element: Element) {
const tagName = element.tagName.toLowerCase();
switch (tagName) {
case "meta": {
this.meta(element);
break;
}
case "link": {
this.link(element);
break;
}
}
}
}
export async function getOGP(url: string): Promise<OGPData> {
const host = new URL(url).host;
const userAgent = getDynamicUserAgent(host);
const res = await fetch(url, {
headers: {
"User-Agent": userAgent,
},
redirect: "follow",
});
if (!res.ok) {
throw new Error(`Failed to fetch OGP data from ${url}`);
}
const contentType = res.headers.get("content-type");
if (contentType?.startsWith("image/")) {
throw new Error(`The URL ${url} points to an image, not a webpage.`);
}
const ogpParser = new OGPParser(url);
// https://github.com/oven-sh/bun/issues/4408#issuecomment-1736976282
const _res = new HTMLRewriter().on("meta, link", ogpParser).transform(res);
await _res.text();
// パースしたものを取得
const ogp = ogpParser.ogp;
// OGP 画像がちゃんと存在するかチェック
if (ogp.image) {
const res = await fetch(ogp.image, {
method: "HEAD",
headers: {
"User-Agent": userAgent,
},
redirect: "follow",
});
// 適切に取得できなかったら削除
if (!res.ok) {
delete ogp.image;
}
}
// favicon がちゃんと存在するかチェック
if (ogp.favicon) {
const res = await fetch(ogp.favicon, {
method: "HEAD",
headers: {
"User-Agent": userAgent,
},
redirect: "follow",
});
// 適切に取得できなかったら削除
if (!res.ok) {
delete ogp.favicon;
}
}
return ogp;
}

HTMLRewriter については以下の記事が詳しかった。

また、Workers じゃなくても Bun に同じものが実装されているので、そっちを使うこともできる。

ただし、Bun の HTMLRewriter の挙動にバグ?があるので共有しておく。 上記のコードで変に await しながら、返り値を使っていないものがある。

// https://github.com/oven-sh/bun/issues/4408#issuecomment-1736976282
const _res = new HTMLRewriter().on("meta, link", ogpParser).transform(res);
await _res.text();

これはこの issue にある通り、Response の body を消費しないと、HTMLRewriter が最後まで通らず、途中で抽出が終わって正しくパースされない。

ここら辺の情報が少なくて、Cloudflare Workers のサンプルコードを使っているとずっと解決しなくてしばらく困っていた。

(ちょくちょく正常に OGP 取得できないのは謎…後で原因究明…)

このようにして OGP 情報を抽出したら、あとは Hono の JSX 機能を使って HTML を返す。

renderer.ts
import { jsxRenderer } from "hono/jsx-renderer";
import { Link, ViteClient } from "vite-ssr-components/hono";
export const renderer = jsxRenderer(({ children }) => {
return (
<html>
<head>
<ViteClient />
<Link href="/src/style.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
</head>
<body>
{children}
<script src="/load-theme.js"></script>
</body>
</html>
);
});

JS を読み込んでるのは後述。

こういう TailwindCSS を挟んだレンダラーを用意して OGP 情報を描画するようにした。一応 KV も使ってキャッシュを行なっている。

index.ts
37 collapsed lines
import { Hono } from "hono";
import { renderer } from "./renderer";
import { getOGP, OGPData } from "./lib/ogp";
import { raw } from "hono/html";
type Bindings = {
OGP_CACHE: KVNamespace;
};
const app = new Hono<{
Bindings: Bindings;
}>();
const getCachedOGP = async (
url: string,
cache: KVNamespace
): Promise<OGPData> => {
const cached = await cache.get(url, "json");
if (cached) {
return cached as OGPData;
}
const ogp = await getOGP(url);
await cache.put(url, JSON.stringify(ogp), { expirationTtl: 60 * 60 }); // Cache for 1 hour
return ogp;
};
app.use(renderer);
app.get("/", (c) => {
return c.text("Hello!");
});
app.get("/message", (c) => {
return c.render(<></>);
});
app.get("/embed", async (c) => {
const { url } = c.req.query();
if (!url) {
return c.text("Please provide a URL query parameter.", 400);
}
try {
const ogp = await getCachedOGP(decodeURIComponent(url), c.env.OGP_CACHE);
return c.render(
<div
class={
"flex h-screen w-full max-h-40 rounded-xl tracking-tight group bg-linkcard"
}
>
<div
class={
"min-w-0 w-full h-full py-5 flex flex-col justify-between px-6"
}
>
<h1 class={"text-lg leading-[1.1] line-clamp-2 font-semibold"}>
{raw(ogp.title ?? "")}
</h1>
<p class={"text-sm truncate text-secondary w-full"}>
{raw(ogp.description ?? "")}
</p>
<p class={"text-sm group-hover:underline"}>{ogp.url ?? ""}</p>
</div>
{ogp.image && (
<div class={"max-w-1/3 w-80 flex flex-col items-center"}>
<img
class={"w-full h-full object-cover"}
src={raw(ogp.image)}
alt={ogp.title || "OGP Image"}
/>
</div>
)}
</div>
);
} catch (error) {
const message =
error instanceof Error ? error.message : "An error occurred";
return c.render(
<div
class={
"flex w-full justify-between gap-6 rounded-xl shadow-sm tracking-tight "
}
>
<div class={"grow min-w-80 py-6 flex flex-col gap-2 px-4"}>
<h1 class={"text-xl font-semibold"}>{message}</h1>
<p class={"text-sm"}>Please check the URL and try again.</p>
<p class={"text-sm"}>url={url}</p>
</div>
</div>
);
}
});
export default app;

これをすると、/embed?url=... にアクセスすると、指定された URL の OGP 情報を取得してカードを表示してくれる。KV にキャッシュもするので、短時間内のアクセスならリンク先に大量のリクエストを送ることも回避して高速にレスポンスを返せる。欠点としては、OGP 情報が更新されてもすぐに反映できない点。ただ、そうそう頻繁には変わらないだろうし、そこまでリアルタイム性が要求されるような要件ではないので、あんま問題はないと思う。若干開発時にキャッシュ消したりが面倒になるくらい。

これでサーバー側でリンクカードをレンダリングする処理はできたので、クライアント側で表示する処理。

クライアント

クライアント側では、パースしたマークダウンのリンクを <iframe> を持ちいたリンクカードに置き換える処理を行う。

これを実現するには、rehype のカスタムプラグインを用いる。参考記事に上げてる記事が詳しいので、そちらを参照。

うちのサイトでは以下のような実装になった。

lib/rehype/link-card.ts
import type { ElementContent, Root } from "hast";
import { visit } from "unist-util-visit";
interface Options {
linkcardUrl: string;
target?: "_blank" | "_self" | "_parent" | "_top";
rel?: string[];
}
const rehypeLinkCard = ({ linkcardUrl, target, rel }: Options) => {
return (tree: Root) => {
visit(tree, "element", (node, index, parent) => {
// paragraph じゃなかったら return
if (!parent || typeof index !== "number" || node.tagName !== "p") {
return;
}
// 空のテキストノードを除外
const children = node.children.filter(
(child) => child.type !== "text" || child.value.trim() !== "",
);
// 子要素が<a>タグ一つだけかチェック
if (children.length !== 1) {
return;
}
const linkNode = children[0];
if (linkNode.type !== "element" || linkNode.tagName !== "a") {
return;
}
const href = linkNode.properties?.href;
if (typeof href !== "string") {
return;
}
// TODO: 内部リンクを特殊として扱う?
const src = new URL(`/embed?url=${encodeURIComponent(href)}`, linkcardUrl)
.href;
// 置き換える要素を object として定義 (jsx の代わり)
const elem = {
type: "element",
tagName: "span",
properties: {
class: "linkcard",
},
children: [
{
type: "element",
tagName: "a",
properties: {
target: target ?? "_blank",
rel: rel ?? ["nofollow", "noreferrer", "noopener"],
href: href,
},
children: [
{
type: "element",
tagName: "iframe",
properties: {
src: src,
},
children: [],
},
],
},
],
} satisfies ElementContent;
parent.children.splice(index, 1, elem);
});
};
};
export default rehypeLinkCard;
astro.config.ts
export default defineConfig({
...
markdown: {
syntaxHighlight: false,
rehypePlugins: [
[
rehypeLinkCard,
{
linkcardUrl: SITE.linkcard,
target: "_blank",
rel: ["nofollow", "noreferrer", "noopener"],
},
],
...
]
},
...
})

置き換え条件として、

  1. 親ノードが <p> タグであること
  2. その子要素が空のテキストノードを除いて <a> タグ一つだけであること
  3. href 属性が存在すること

を判定して、満たす要素を <iframe> のリンクカードに置き換えるようにした。これは Gemini か ChatGPT が書いたので、もしかしたら変な処理になっているかもしれない。まだちゃんと理解できていない…

リンクカードの URL はサイトの config で指定したベース URL に /embed?url=... を付与したものにしている。この時、encodeURIComponent/ などを含めて URL エンコードしている (そうしないとサーバーの Hono でちゃんとパースできなかった…)

サーバー側でカードの UI を設定したので、クライアントでは <iframe> を持った <a> タグを生成するだけで OK となる。最近知ったんだけど、<a> タグって結構いろんなもの入れてもいいらしい。Zenn は <a><iframe> が分離されていたので、てっきりダメなのかと思っていた。シンプルにネストした方が処理が楽なので嬉しい。

テーマの反映

クライアント側でリンクカードを表示するのはこれだけで完了となる。このブログサイトには、カスタムのテーマカラーを設定する機能があるのだが、<iframe> リンクカードの内側と外側でカラーテーマがそのままでは同期されない。しかも、クロスオリジンなのでダイレクトな操作ができないと思う。

なので、クライアントから <iframe> に対してメッセージを送信して、サーバー側のフロント JS でハンドルする処理が必要となる。これが先ほどレンダラーに入っていた load-theme.js である。中身はブログサイトの方で使っているものとほぼ同じだが、メッセージの受信をトリガーに追加している。

public/load-theme.js
92 collapsed lines
// To avoid redeclaration errors
const theme = (() => {
const localStorageTheme = localStorage?.getItem("theme") ?? "";
if (["dark", "light"].includes(localStorageTheme)) {
return localStorageTheme;
}
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark";
}
return "light";
})();
const setTheme = (theme) => {
document.documentElement.setAttribute("data-theme", theme);
document.documentElement.classList.remove("scheme-dark", "scheme-light");
document.documentElement.classList.add(
theme === "dark" ? "scheme-dark" : "scheme-light",
);
window.localStorage.setItem("theme", theme);
};
// <!-- hue angle -->
// To avoid redeclaration errors
const DEFAULT_HUE = 256;
const applyThemeHue = (angle) => {
const dataTheme =
document.documentElement.getAttribute("data-theme") ?? "light";
const properties = {
light: {
background: `oklch(100% 0.01 ${angle}deg)`,
foreground: `oklch(14.5% 0.05 ${angle}deg)`,
secondary: `oklch(97% 0.01 ${angle}deg)`,
border: `oklch(92.2% 0.015 ${angle}deg)`,
input: `oklch(92.2% 0.015 ${angle}deg)`,
popover: `oklch(100% 0.01 ${angle}deg)`,
},
dark: {
background: `oklch(20% 0.01 ${angle}deg)`,
foreground: `oklch(98.5% 0.03 ${angle}deg)`,
secondary: `oklch(25% 0.01 ${angle}deg)`,
border: `oklch(25% 0.015 ${angle}deg)`,
input: `oklch(25% 0.015 ${angle}deg)`,
popover: `oklch(24% 0.01 ${angle}deg)`,
},
};
if (!Object.keys(properties).includes(dataTheme)) {
console.warn(
`Theme "${dataTheme}" not found. Using default properties.`,
);
return;
}
Object.entries(properties[dataTheme]).forEach(([key, value]) => {
document.documentElement.style.setProperty(`--${key}`, value);
});
};
const storeHue = (angle) => {
localStorage.setItem("theme-hue", angle.toString());
};
const getHue = () => {
const storedHue = localStorage?.getItem("theme-hue");
return storedHue ? parseFloat(storedHue) : DEFAULT_HUE; // Default to 256 if not set
};
// Initialize the theme hue
const hue = getHue();
// apply theme without flashing
const element = document.documentElement;
element.classList.add("[&_*]:transition-none"); // disable animation
// color theme
document.documentElement.setAttribute("data-theme", theme);
document.documentElement.classList.add(
theme === "dark" ? "scheme-dark" : "scheme-light",
);
window.localStorage.setItem("theme", theme);
// hue
applyThemeHue(hue);
storeHue(hue); // Store the initial hue if not already set
requestAnimationFrame(() => {
element.classList.remove("[&_*]:transition-none");
}); // enable animation
// handle messages
window.addEventListener("message", (event) => {
// TODO: origin check?
const { type, theme, hue } = event.data;
if (type === "set-theme" && typeof theme === "string" && typeof hue === "number") {
// color theme
setTheme(theme);
// hue
applyThemeHue(hue);
storeHue(hue); // Store the new hue
}
});

Hono の静的アセット配信機能を用いて、/load-theme.js にアクセスしたときにこれが返ってくるようにしている。Hono JSX だとクライアントサイドの JS を記述する方法がいまいちわからなかったのでこうしている… 機能しているのでまあヨシ。

このように、クライアント側と同じテーマの処理をすることで、最初の表示時やテーマの更新時に一緒に同じテーマカラーで表示されるようになる。

実際にこのサイトのテーマを変更してみるとわかりやすいと思う。

まとめ

HTMLRewriter を用いてパースした OGP 情報を元に、Workers でリンクカードをサーバーサイドレンダリングし、Astro で rehype プラグインを用いて <iframe> からそれを表示することで、ブログにリンクカードを表示することができた。また、リンクカードとブログサイトのテーマカラーを同期することもできた。


記事書き始めたのは 8/27 くらいのはずだったんだけど、忙しかったりで全然書き終わらなかった…