はなゐろぐ

主に技術関係の覚え書きです。

AstroとMarkdownで作ったブログにoEmbedで埋め込み

2025年2月26日

概要

ひとつ前の記事でCodepenを埋め込みたかったので、oEmbedで埋め込んでみました。Astroでの実装をメモ書きします。

oEmbedとは

oEmbedとは何かというと、SNSやブログなどの埋め込みコードなどを返却する規格です。具体的には、各サイトのAPIに「このページのコンテンツを埋め込みたいよ」とリクエストを投げると、それに対して埋め込みコードを返します。詳しくはoEmbed公式サイトを見てみてください。

oEmbed
The oEmbed spec
oEmbed favicon https://oembed.com

この記事を書いている時点で331のプロバイダが存在します。有名どころだとYouTube、Instagram、Twitter(X)など。後ほど言及しますが、プロバイダ一覧がJSON形式でまとまっています。

実装

Astroはremarkを使ってMakrdownを処理しているため、remarkプラグインで機能を拡張できます。今回はURLを検知するremark-embedderプラグインを使います。

GitHub - remark-embedder/core: 🔗 Remark plugin to convert URLs to embed code in markdown.
🔗 Remark plugin to convert URLs to embed code in markdown. - remark-embedder/core
GitHub - remark-embedder/core: 🔗 Remark plugin to convert URLs to embed code in markdown. favicon https://github.com/remark-embedder/core
GitHub - remark-embedder/core: 🔗 Remark plugin to convert URLs to embed code in markdown.

このプラグインはURLを検知するコア部分のみを提供し、Transformerと呼ばれる埋め込みコードを取得・加工するプラグインが別途必要となります。公式Transformerは存在するのですが、なぜか私の環境ではうまく動かず、解決するのも難しかったので自作しました。

Transformerを作る

自作といっても公式Transformerとおおかたの処理は同じで、適宜cheerioでユーティリティクラスをつけるなど変更を加えています。なぜiframe要素をfigure要素で囲んでいるかというと、tailwindcss-typographyで上下の余白などをよしなにしてもらうためです。

src/libs/oembed.ts
import { URL } from 'node:url'

import { type Transformer } from '@remark-embedder/core'
import * as cheerio from 'cheerio'

type Provider = {
  provider_name: string
  provider_url: string
  endpoints: Array<{
    schemes?: string[]
    discovery?: boolean
    url: string
  }>
}

let providers: Provider[] = []

// oEmbed公式サイトからプロバイダ一覧を取得
const getProviders = async (): Promise<Provider[]> => {
  if (providers.length === 0) {
    try {
      const response = await fetch('https://oembed.com/providers.json')
      if (!response.ok) {
        throw new Error(
          `[remark-embedder] oEmbedプロバイダ一覧の取得に失敗しました: ${response.status}`
        )
      }
      providers = await response.json()
    } catch (error) {
      console.warn(error.message)
    }
  }

  return providers
}

// プロバイダ一覧からエンドポイントを取得
const getEndpointURL = async (url: string): string | null => {
  const providers = await getProviders()
  for (const provider of providers) {
    for (const endpoint of provider.endpoints) {
      if (
        endpoint.schemes?.some((scheme) =>
          new RegExp(scheme.replace(/\*/g, '(.*)')).test(url)
        )
      ) {
        return endpoint.url
      }
    }
  }

  return null
}

export const transformer: Transformer = {
  name: 'default',
  // 埋め込みコードに置換するかどうか
  async shouldTransform(urlString: string) {
    const response = await getEndpointURL(urlString)

    return Boolean(response)
  },
  // 埋め込みコードを取得
  async getHTML(urlString: string) {
    const endpoint = await getEndpointURL(urlString)

    if (!endpoint) return null

    const url = new URL(endpoint)
    url.searchParams.set('url', urlString)
    url.searchParams.set('format', 'json')

    try {
      const response = await fetch(url.toString())
      if (!response.ok) {
        throw new Error(
          `[remark-embedder] oEmbed情報の取得に失敗しました: ${urlString} ${response.status}`
        )
      }
      const data = await response.json()

      if (data.html) {
        const { host } = new URL(urlString)
        const $ = cheerio.load(data.html, null, false)
        switch (host) {
          case 'www.youtube.com':
          case 'youtu.be':
            $('iframe').addClass('w-full h-auto aspect-video')
            break

          case 'codepen.io':
            $('iframe').addClass('w-full h-[max(300px,50vh)]')
            break

          case 'speakerdeck.com':
            $('iframe').addClass('max-w-full h-auto')
            break

          default:
            break
        }
        $('iframe').wrap('<figure></figure>')

        return $.html()
      } else if (data.url) {
        const { host } = new URL(urlString)
        switch (host) {
          case 'gyazo.com':
            return `<img src="${data.url}" alt="${data.title}" width="${data.width}" height="${data.height}" />`
        }
      }
    } catch (error) {
      console.warn(error.message)
    }

    return urlString
  },
}

そして、Astro設定ファイルで次のように指定します。

astro.config.ts
import remarkEmbedder from '@remark-embedder/core'
import { transformer } from './src/libs/oembed'

export default defineConfig({
  markdown: {
    remarkPlugins: [
      [remarkEmbedder.default, { transformers: [transformer] }],
    ],
  },
})

こうすることで、Markdownファイル内のURLがoEmbedに対応していれば埋め込みコードに置換されます。

サンプル

https://www.youtube.com/watch?v=1jFEhVWIQIM

https://twitter.com/Interior/status/463440424141459456

https://www.flickr.com/photos/nasacommons/30648886821/

https://gyazo.com/8980c52421e452ac3355ca3e5cfe7a0c

https://speakerdeck.com/tsumugu1214/dokiyumento-sonoqian-ni

https://codepen.io/87oui/pen/wBvMdNG

https://open.spotify.com/intl-ja/album/11LqngEbNjOK5zKCqw3TiC

YouTube

余談ですが、アゲハ蝶のどの歌詞が好きかって結構分かれますよね〜。

Twitter(現X)

blockquote要素のみ返るためwidgets.jsをどこかに追記する必要があります。私はあまりTwitterを埋め込むことはなさそうなので、必要になったらコンポーネントを作って都度MDXファイルにインポートすればいいかな〜と考えています。

Flickr

Manned Maneuverability Unit MMU

Gyazo

Speakerdeck

余談ですが、ひろクリギルドメンバーつむさんのこちらの発表資料が素晴らしかったです。私もこのようなチームメンバーでありたい…まだまだ道半ばです。

Codepen

Spotify

https://open.spotify.com/intl-ja/album/11LqngEbNjOK5zKCqw3TiC

くるりの中ではこのアルバムの「虹色の天使」が一番好きです。

その他

Instagramはトークンを渡してやらないとエラーになります。Instagram APIを叩く時と同じですね。

oEmbed - Instagram Platform - Documentation - Meta for Developers
How to use the Instagram oEmbed endpoint
oEmbed - Instagram Platform - Documentation - Meta for Developers favicon https://developers.facebook.com/docs/instagram-platform/oembed/
oEmbed - Instagram Platform - Documentation - Meta for Developers