例えば、はてなブログだとブログカードと呼ばれるこういうの:

はてなブログのブログカード

が有ったり、wordpressだとプラグインでこういうの:

Wordpressのlinkcard

が有り、リンクをなんだか良い感じに表示してくれます。このブログでつい先日まで使っていたGhostでも、こういうの:

Ghostのbookmark card

が有りました。

一方、現在使っているHugoには標準機能でこういったものを表示する機能はありません(twitterとかYoutubeはあるんですけど・・・)。しかし、無ければ作れば良いじゃない、ができるのがHugoの良いところです。

Hugoにはshortcode という機能があり、例えば標準のtwitter shortcodeだと、

{{< tweet user="nasa9084" id="1519598305554362370" >}}

と書くと

の様に展開されます。なので今回は

{{< web-embed url="https://example.com" >}}

というshortcodeを作ってみようと思います。調べてみると同様の実装をしている人もいましたので、それを参考にしつつ実装していきます。

まず、URLからデータを取得してくるにはHugoのgetJSON を使うと良さそうです。残念ながらOGP情報などを取得する方法は用意されていないようなので、指定したURLからOGP情報をとってきてJSONとして返す様なプロキシ的なサーバが必要そうです。cloud functions for firebase + javascript で実装している人もいれば、Netlify Functions + javascript でやっている人もいるという感じでしたが、やはり個人的にはGoがシュッと読み書きできて早いし、Cloud Functionsなどで常時稼働させておくには認証とかのことも考える必要がありありそう(まぁ無くてもいいっちゃいいけど、よくわからん踏み台にされても面白くない)で面倒だな、ということでちょっと困ったんですが、OGPプロキシサーバは特に状態を持っておらず、hugo buildする間だけ存在してくれればいいので、GitHub Actionsのサービスコンテナとしてプロキシを動かすことにしました。ローカルでテストビルドするときもdocker runすれば良いだけなので簡単です。

ハンドラの実装は次の通りです:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
url := r.URL.Query().Get("url")
if url == "" {
	http.Error(w, `{"message": "url parameter is required"}`, http.StatusBadRequest)
	return
}
log.Printf("request URL: %s", url)

ogp, err := opengraph.Fetch(url)
if err != nil {
	http.Error(w, fmt.Sprintf(`{"message": "error fetching OGP", "error": "%s"}`, err.Error()), http.StatusInternalServerError)
	return
}

if err := ogp.ToAbs(); err != nil {
	http.Error(w, fmt.Sprintf(`{"message": "error converting relative URLs to absolute URLs", "error": "%s"}`, err.Error()), http.StatusInternalServerError)
	return
}

var body bytes.Buffer
if err := json.NewEncoder(&body).Encode(ogp); err != nil {
	http.Error(w, fmt.Sprintf(`{"message": "error encoding OGP info to JSON", "error": "%s"}`, err.Error()), http.StatusInternalServerError)
	return
}

w.WriteHeader(http.StatusOK)
body.WriteTo(w)

コード全体はここ にあります。リポジトリを作ったときにhugo用のディレクトリをルートディレクトリではなく一段掘ったサブディレクトリにしておいたおかげで気軽にディレクトリを追加できたので良かったですね。

あとはshortcodeを次の様に実装して:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{{ $url := .Get "url" }}

{{/* LINE Store specific config */}}
{{ $localePathSuffix := "" }}
{{ if (hasPrefix $url "https://store.line.me") }}{{ $localePathSuffix = "/ja" }}{{ end }}

{{ $data := getJSON "http://localhost:8080/ogp?url=" $url $localePathSuffix }}
{{ $title := $data.title }}
{{ $image := (index $data.image 0).url}}
{{ $description := $data.description }}
{{ $favicon := $data.favicon.url }}
{{ $siteName := $data.site_name }}

<div class="web-embed">
  <a href="{{ $url }}">
    <div class="web-embed-thumbnail">
      <img src="{{ $image }}" alt="{{ $title }}">
    </div>
    <div class="web-embed-content">
      <div class="web-embed-title">{{ $title }}</div>
      <div class="web-embed-description">{{ $description }}</div>
      <div class="web-embed-site-name">
        <img src="{{ $favicon }}" alt="{{ $siteName }}" class="favicon">
        {{ $siteName }}
      </div>
    </div>
  </a>
</div>

適当にCSSも付けて 、Actionsのジョブでサービスコンテナを起動するように設定すれば:

1
2
3
    services:
      getogp:
        image: ghcr.io/nasa9084/getogp:v0.0.1

完成です。ついでにOGP-JSONプロキシのDockerイメージも良い感じにビルドしてGitHub Packagesにpushする様にworkflowを追加しておきました 。GitHub Actionsは複数プロジェクトをきれいに扱えるので良いですね。

{{< web-embed url="https://store.line.me/stickershop/product/19279493" >}}

と書くと

の様に描画されます。良い感じ。