wandfuldays

microCMSの埋め込みiframeにtitle属性を設定し、アクセシビリティを改善する

2025-01-112025-01-21

アクセシビリティを改善し、PageSpeed Insightsのスコアが満点に!

こんにちは、 wand です!

結論

  • SSGビルド時に Iframely API を呼び出して <iframe> への置換を済ませておく
  • この際に title=1 パラメータを指定すると title 属性が設定される

問題: 「ユーザー補助スコア」の低下

AWS + Next.js + microCMS でブログシステムを構築しました」の記事でご紹介したように、このブログシステムは、 microCMS を活用して自分で構築したものです。
読者の皆様に快適にご利用いただくために、日々改善を行っています。

先日、 Google PageSpeed Insights のスコアを計測したところ、「ユーザー補助」のスコアが低いことに気づきました。

原因: microCMS の埋め込み機能の <iframe>title 属性がない

詳細を確認してみると…

<frame> または <iframe> の要素にタイトルが指定されていません

原因は、 <iframe>title 属性が設定されていないことでした。
この <iframe> は、microCMS のリッチエディタの「埋め込み」機能に由来するものです。

リッチエディタにYouTubeやVimeoの動画を埋め込む方法は?

microCMS の埋め込み機能の仕組み -- Iframely と embed.js

microCMS の「埋め込み」機能は、内部的に Iframely というサービスを利用して実現されています。

Generate responsive embed codes - Iframely

Iframely は埋め込み用の HTML コードを生成する Iframely API と、埋め込み用 HTML コードを iframe に置換する embed.js からなります。

以下は、microCMS のリッチテキストの中の、埋め込み用 HTML コードの一例です:

<div class="iframely-embed">
  <div class="iframely-responsive" style="height: 140px; padding-bottom: 0;">
    <a
       href="https://github.com/wand2016/blog"
       data-iframely-url="//cdn.iframe.ly/api/iframe?card=small&url=https%3A%2F%2Fgithub.com%2Fwand2016%2Fblog&key=(md5hash)"
    ></a>
  </div>
</div>
<script async="" src="//cdn.iframe.ly/embed.js" charset="utf-8"></script>

上記の HTML コードをブラウザで開くと、 Iframely の embed.js が実行され、下記のように置換されます:

<div class="iframely-embed">
  <div class="iframely-responsive" style="height: 162px; padding-bottom: 0px;">
    <iframe
      allowfullscreen=""
      allow="autoplay *; encrypted-media *; ch-prefers-color-scheme *" 
      src="//cdn.iframe.ly/api/iframe?card=small&url=https%3A%2F%2Fgithub.com%2Fwand2016%2Fblog&key=(md5hash)&v=1&app=1" 
      style="box-shadow: rgba(0, 0, 0, 0.06) 0px 1px 3px;"
    ></iframe>
  </div>
</div>
<script async="" src="//cdn.iframe.ly/embed.js" charset="utf-8"></script>

<iframe>title 属性が設定されていません。この問題を解決する方法を探ってみます。

Iframely API なら <iframe>title 属性を設定できる

Iframely のドキュメントを調査したところ、 title 属性を設定する方法が判明しました。

Optional query string parameters for Iframely APIs

「Iframely API」 を呼び出す際に title=1 をクエリパラメータで指定すると、埋め込み先ページのタイトルが title 属性に設定されるようです。

まず、現状の <a data-iframely-url="..."> からクエリパラメータを抽出し、 Iframely API の呼び出しに指定してみます:

https://cdn.iframe.ly/api/iframely?card=small&url=https%3A%2F%2Fgithub.com%2Fwand2016%2Fblog&key=(md5hash)

結果、下記のレスポンスを得ました:

{
  // ...
  "html": "<div class=\"iframely-embed\"><div class=\"iframely-responsive\" style=\"height: 140px; padding-bottom: 0;\"><a href=\"https://github.com/wand2016/blog\" data-iframely-url=\"//cdn.iframe.ly/api/iframe?card=small&url=https%3A%2F%2Fgithub.com%2Fwand2016%2Fblog&key=(md5hash)\"></a></div></div><script async src=\"//cdn.iframe.ly/embed.js\" charset=\"utf-8\"></script>",
  // ...
}

この内容は、microCMS のリッチテキストの HTML と同一でした。内部的に同じ API 呼び出しを実行しているものとみられます。

次に、omit_script=1, iframe=1 , title=1 を指定して Iframelly API を呼び出します:

https://cdn.iframe.ly/api/iframely?card=small&url=https%3A%2F%2Fgithub.com%2Fwand2016%2Fblog&key=(md5hash)&omit_script=1&iframe=1&title=1

すると以下のレスポンスが返されました:

{
  // ...
  "html": "<div><div style=\"left: 0; width: 100%; height: 140px; position: relative;\"><iframe title=\"GitHub - wand2016/blog: AWS infra + Next.js microCMS template\" src=\"//cdn.iframe.ly/api/iframe?card=small&app=1&url=https%3A%2F%2Fgithub.com%2Fwand2016%2Fblog&key=(md5hash)\" style=\"top: 0; left: 0; width: 100%; height: 100%; position: absolute; border: 0;\" allowfullscreen></iframe></div></div>",
  // ...
}

<iframe> タグを抜粋し、整形したものが以下:

  <iframe
+   title=\"GitHub - wand2016/blog: AWS infra + Next.js microCMS template\"
    src="..."
    ...

<iframe>title 属性が設定されました。この方法で問題を解決できそうです!

修正方針: SSG 時に埋め込み部分を Iframely API で置換する

本ブログシステムは静的サイト生成 (SSG) 方式を採用しているため、ビルド時に Iframely API を呼び出して <a data-iframely-url="..."> 要素を <iframe> に置換する方針としました。
これにより、クライアントサイドでの余分な処理を削減し、パフォーマンスや CLS (Cumulative Layout Shift) 改善にも寄与します。

制約としては、 Iframely に直接依存してしまうことが挙げられます。
microCMS の「埋め込み」機能が将来改修されて Iframely 以外を利用するようになったらこの方法は動作しなくなります。注意して運用する必要があります。

実装詳細

ソースコードは GitHub で公開しています。

GitHub - wand2016/blog: AWS infra + Next.js microCMS template

記事コンポーネント

以前の実装では、以下のように記事データをレンダリングしていましたが、今回の変更で修正を加えました。

export default function Article({ data, shareUrl }: Props) {
  return (
    // ...
      <div
        className={styles.content}
        dangerouslySetInnerHTML={{
          __html: `${formatRichText(data.content)}`,
        }}
      />
    // ...

formatRichText 関数は、ブログ本文を受け取り、コードブロックのハイライト処理などを行う関数です。今回の対応で API 呼び出しを行うため、非同期関数に変更しました。

また、このコンポーネントは下書きプレビューでも使用されており、その場合はクライアントサイドレンダリング (CSR) で動作します。そのため、レンダリング中に非同期処理を行うことはできません。そこで、以下のように親コンポーネントから加工済みの本文を受け取る形に修正しました。

type Props = {
  data: Omit<Article, 'content'>;
  formattedContent: string;
  shareUrl?: string;
};

export default function Article({ data, formattedContent: content, shareUrl }: Props) {
  return (
    // ...
      <div
        className={styles.content}
        dangerouslySetInnerHTML={{
          __html: content,
        }}
      />

記事ページコンポーネント

静的サイト生成 (SSG) で動作するため、レンダリング時に非同期処理を実行できます。
以下のように修正を行いました。

export default async function Page({ params }: Props) {
  // ...
  const content = await formatRichText(data.content);
  return (
    <Article
      data={data}
      formattedContent={content}
      shareUrl={`${baseUrl}articles/${params.slug}`}
    />
  );
}

下書き記事ページコンポーネント

クライアントサイドレンダリング (CSR) で動作するため、useStateuseEffect を用いて formatRichText を実行します。

export default function Page({}: Props) {
  // ...
  const [content, setContent] = useState<string>('');
  useEffect(() => {
    let ignore = false;

    const handle = async () => {
      if (!ignore && data) {
        setContent(await formatRichText(data.content));
      }
    };

    void handle();

    return () => {
      ignore = true;
    };
  }, [data]);

  return content && data ? <Article formattedContent={content} data={data} /> : 'loading...';
}

formatRichText 関数

以下の処理を追加しました。

export const formatRichText = async (richText: string) => {
  const $ = cheerio.load(richText);
  // ...

  // A. iframely の置換対象の <a> 要素を探す
  const iframeAnchorElements = $(
    'div.iframely-embed > div.iframely-responsive > a[data-iframely-url]',
  ).get();

  for (const elm of iframeAnchorElements) {
    // B. Iframely API を利用して <iframe> 要素に置換する
    const iframelyUrl = $(elm).attr('data-iframely-url') ?? '';
    const iframelyUrlQueryParams = iframelyUrl.slice(iframelyUrl.indexOf('?'));
    const data = await fetch(
      `https://cdn.iframe.ly/api/iframely${iframelyUrlQueryParams}&omit_script=1&iframe=1&title=1`,
    );
    const json = await data.json();
    const html = json['html'];
    const $replacement = cheerio.load(html);

    // NOTE: iframely の iframe は静的にはリンクに見えないので、 a タグを併記する
    const params = parse(iframelyUrlQueryParams, { ignoreQueryPrefix: true });
    const url = decodeURI(params.url as string);
    const title = $replacement('iframe').attr('title');
    $replacement.root().append('<a target="_blank"></a>');
    $replacement('a')
      .attr('href', url)
      .text(title ?? '');

    $(iframelyAnchorElement).parent().parent().replaceWith($replacement.html());
  }

  // C. 不要なスクリプトを除去する
  $('script[src="//cdn.iframe.ly/embed.js"]').remove();

A. 置換対象の <a> 要素を抽出

microCMS から返却されたリッチテキスト内の特定要素を jQuery ライクな cheerio を用いて抽出しています。

B. Iframely API の呼び出し

Iframely API にリクエストを送信し、レスポンスで受け取った <iframe> タグに置き換えます。ここで title 属性が含まれる点がポイントです。

しかし、この <iframe> は静的にはリンクに見えないため、検索エンジンにフレンドリーではありません。また、 Iframely の障害時にリンクが正しく展開されないリスクがあります。
以上を踏まえて、 <a> を用いてプレーンなリンクを併記するようにしています。

なお、

.replaceWith(`${html}<a href="${url}" target="_blank">${title ?? ''}</a>`);

のようにしてしまうと XSS 脆弱性が生じてしまいますので、 attr , text メソッドを用いましょう:

    $replacement('a')
      .attr('href', url)
      .text(title ?? '');

    $(iframelyAnchorElement).parent().parent().replaceWith($replacement.html());

参考: cheerio の XSS 脆弱性の issue

unescaped attributes (XSS) · Issue #49 · cheeriojs/cheerio

C. 不要スクリプトの削除

ビルド時に <iframe> へ置換しているため、embed.js を削除します。

embed.js 読み込み部分

ビルド時に <iframe> へ置換済みであるため embed.js は不要かと思われましたが、実際にはレスポンシブ対応のために引き続き読み込む必要がありました。
ルートレイアウトで一度だけ読み込むように変更しました:

export default async function RootLayout({ children }: Props) {
  // ...
  return (
    <html lang="ja">
      <body>
+       {/*NOTE: iframely の responsive スタイリングで必要*/}
+       <Script src="https://cdn.iframe.ly/embed.js" />

結果


Google PageSpeed Insights のスコアで満点を達成しました 💯

大変満足のいく結果となりました!

microCMS 側の改善への期待

microCMS 側で埋め込み機能に iframe=1 , title=1を設定するオプションが追加されれば、以下の点が改善されます:

  • 開発者側でのカスタム実装が不要となるため、メンテナンスコストが軽減
  • Iframely への依存度を低減し、将来的な仕様変更の影響を最小化
  • Web アクセシビリティ向上により、ユーザー体験を強化

今後のアップデートに期待したいと思います!

まとめ

Google PageSpeed Insights の「ユーザー補助」スコアを改善するため、Iframely API を活用して <iframe>title 属性を付与しました。その結果、以下の成果が得られました:

  • スコア満点達成
  • アクセシビリティ向上
  • ビルド時処理でパフォーマンスを最適化
  • CLS の抑制による UX の改善

本ブログでは、引き続きユーザー体験向上を目指し改善を重ね、有益な情報をお届けしてまいります。それではまた!


wand

「wand」は魔法の杖を意味します。魔法のようにさまざまなものを自分の手で生み出せるようになりたい、そんな思いを込めました。 ハンドメイド、家庭菜園、DIY、プログラミング等、「つくる」をテーマに色々なことをしていきたいと思っています。 Amazonのアソシエイトとして、wand は適格販売により収入を得ています。 GitHub: https://github.com/wand2016