HugoのテーマPaperModのスタイルに沿ったリンクカードをShortcodeとして追加して記事内で使えるようにする。また、OGP情報を手動でリンクカードに埋めるのは面倒なので指定したURLのOGP情報を元に自動でリンクカードを作成するNode.jsスクリプトも追加する。

動作環境

  • Windows 11 / Git Bash
  • Hugo v0.164.0 / 標準ビルドでも動作可能
  • PaperMod v8.0 / 執筆時点の最新コミットを使用
  • Node v24.18.0
$ hugo version
hugo v0.164.0-ce2470e7012b5ab5fc4e10ebe4027e9f8d9e00dc+extended windows/amd64 BuildDate=2026-07-06T16:39:30Z VendorInfo=gohugoio

$ cd themes/PaperMod && git log -1 --oneline
154d006 (HEAD -> master, origin/master, origin/HEAD) style(post-single): adjust padding for details summary element

$ node -v
v24.18.0

導入手順

1. リンクカードのShortcodeを追加

HugoはHTMLに直接Styleを記述した場合、同じ部品を同じ画面で複数回使ったときにCSSが重複する仕様があるのでHTMLとCSSを別ファイルにする形で実装する。

HTMLを追加

layouts/_shortcodes/linkCard.html を作成し、以下のコードをそのまま貼り付けて保存する。

{{- $url := .Get "url" -}}
{{- $title := .Get "title" -}}
{{- $description := .Get "description" -}}
{{- $image := .Get "image" -}}

<div class="link-card">
  <a href="{{ $url }}" target="_blank" rel="noopener noreferrer">
    {{- if $image }}
    <div class="link-card-image">
      <img src="{{ $image }}" alt="{{ $title }}" loading="lazy">
    </div>
    {{- end }}
    <div class="link-card-content">
      <div class="link-card-title">{{ $title }}</div>
      {{- if $description }}
      <div class="link-card-description">{{ $description }}</div>
      {{- end }}
    </div>
  </a>
</div>

CSSを追加

assets/css/extended/linkcard.css を作成し、以下のコードをそのまま貼り付けて保存する。

.link-card {
    margin: 1.5rem 0;
    border: 1px solid var(--border);
    border-radius: var(--radius);
    overflow: hidden;
    transition: box-shadow 0.2s ease, border-color 0.2s ease;
    background: var(--entry);
}

.link-card:hover {
    border-color: var(--tertiary);
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

[data-theme="dark"] .link-card:hover {
    box-shadow: 0 2px 8px rgba(255, 255, 255, 0.1);
}

.link-card a {
    display: flex;
    height: 150px;
    text-decoration: none;
    color: var(--primary);
    box-shadow: none;
}

.link-card a:hover {
    text-decoration: none;
}

.link-card-image {
    flex-shrink: 0;
    width: 200px;
    height: 150px;
    overflow: hidden;
    background: var(--code-bg);
}

.link-card-image img {
    width: 100%;
    height: 100%;
    object-fit: contain;
    margin: 0;
    display: block;
}

.link-card-content {
    flex: 1;
    padding: 1rem;
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
    min-width: 0;
    overflow: hidden;
}

.link-card-title {
    font-weight: 600;
    font-size: 1.1em;
    line-height: 1.4;
    color: var(--primary);
    margin: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.link-card-description {
    color: var(--secondary);
    font-size: 0.9em;
    line-height: 1.5;
    overflow: hidden;
    display: -webkit-box;
    -webkit-line-clamp: 3;
    line-clamp: 3;
    -webkit-box-orient: vertical;
    margin: 0;
}

/* Responsive Design */
@media screen and (max-width: 768px) {
    .link-card-image {
        width: 120px;
        height: 150px;
    }
    
    .link-card-content {
        padding: 0.75rem;
    }
    
    .link-card-title {
        font-size: 1em;
    }
    
    .link-card-description {
        font-size: 0.85em;
    }
}

2. OGP情報を元に自動でリンクカードを作成するNode.jsスクリプトを追加

1. Node.js環境を初期化

npm init -y

2. 必要なライブラリをインストール

npm install cheerio

3. モジュール形式をESMに変更

package.json"type": "module"を追加する。

{
  "name": "rurigani.com",
  "version": "1.0.0",
  "type": "module",
  "dependencies": {
    "cheerio": "^1.0.0"
  }
}

4. スクリプトを追加

scripts/ogp2hugolinkcard.js を作成し、以下のコードをそのまま貼り付けて保存する。

import * as cheerio from "cheerio";

function esc(s = "") {
  return s.replaceAll('"', "&quot;").trim();
}

function getMeta($, prop) {
  return $(`meta[property='${prop}']`).attr("content") ??
    $(`meta[name='${prop}']`).attr("content") ??
    "";
}

function absolutize(baseUrl, imgUrl, $) {
  if (!imgUrl) return "";
  if (/^https?:\/\//i.test(imgUrl)) return imgUrl;
  if (/^\/\//.test(imgUrl)) {
    const u = new URL(baseUrl);
    return `${u.protocol}${imgUrl}`;
  }
  const baseTag = $("base").attr("href") || "";
  try {
    const context = baseTag ? new URL(baseTag, baseUrl).href : baseUrl;
    return new URL(imgUrl, context).href;
  } catch {
    try {
      return new URL(imgUrl, baseUrl).href;
    } catch {
      return imgUrl;
    }
  }
}

function buildShortcode(args) {
  const { url, ogTitle, ogDesc, ogImage } = args;
  const attrs = [
    `url="${esc(url)}"`,
    `title="${esc(ogTitle)}"`,
  ];
  if (ogDesc) attrs.push(`description="${esc(ogDesc)}"`);
  if (ogImage) attrs.push(`image="${esc(ogImage)}"`);

  return `{{< linkCard
    \${attrs.join("\n    ")}
>}}`;
}

async function main() {
  const url = process.argv[2];
  if (!url) {
    console.error("Usage: node ogp2hugolinkcard.js <URL>");
    process.exit(1);
  }

  try {
    const res = await fetch(url, {
      headers: { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) Chrome Safari" },
      redirect: "follow",
      cache: "no-store",
    });
    if (!res.ok) {
      console.error(`❌ Failed to fetch OGP info: HTTP ${res.status} ${res.statusText}`);
      process.exit(1);
    }
    const html = await res.text();
    const $ = cheerio.load(html);

    let ogTitle = (getMeta($, "og:title") || $("title").first().text() || "").trim();
    if (!ogTitle) console.warn("⚠️  No title found.");

    let ogDesc = (getMeta($, "og:description") || getMeta($, "twitter:description") || "").trim();
    if (!ogDesc) console.warn("⚠️  No og:description found.");
    let ogImage =
      getMeta($, "og:image:secure_url") ||
      getMeta($, "og:image") ||
      getMeta($, "twitter:image") ||
      "";
    if (!ogImage) {
      console.warn("⚠️  No og:image found.");
    } else {
      ogImage = absolutize(url, ogImage.trim(), $);
    }

    const output = buildShortcode({ url, ogTitle, ogDesc, ogImage });
    console.log(output);
  } catch (e) {
    console.error("❌ Failed to fetch OGP info:", e?.message ?? String(e));
    process.exit(1);
  }
}

main();

3. 短縮コマンドを追加

毎回フルパスを指定して実行するのは面倒なので、package.jsonscripts に短縮コマンドを追加する。

{
  "name": "rurigani.com",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "ogp": "node scripts/ogp2hugolinkcard.js"
  },
  "dependencies": {
    "cheerio": "^1.0.0"
  }
}

4. リンクカードを確認

実際にスクリプトを実行してリンクカードのショートコードを生成する。

npm run ogp https://rurigani.com/

生成されたショートコードをコピーし記事に貼り付けて hugo server で開発サーバーを立ち上げてリンクカードを確認する。

以下のように表示されればOK。

おまけ: README.mdに使い方を追加

あとはプロジェクトの README.md に使い方を書いておくと便利。

## Generate LinkCard from a URL (OGP fetch)

```bash
npm run ogp https://rurigani.com/
```