CLS改善のためにZolaの画像ショートコードを自作した

TL;DR

Zolaの画像にwidth/heightが未設定でCLSが悪化していたため、get_image_metadataで画像サイズを自動取得するショートコードを作成しました。caption指定時にはfigure/figcaption要素を出力します。

Table of Contents

CLSとは

Cloudflare Pagesにブログを移行したことで、Cloudflare Web Analyticsが利用できるようになりました。アクセス数だけでなくCore Web Vitalsの各指標も確認できるのですが、そのなかでCLS(Cumulative Layout Shift)のスコアが気になる結果でした。

CLS
Cloudflare Web Analytics画面

Core Web VitalsはGoogleが提唱するWebページのユーザー体験を測定する指標群で、LCP(Largest Contentful Paint:読み込み速度)、INP(Interaction to Next Paint:応答性)、CLS(Cumulative Layout Shift:視覚的安定性)の3つで構成されます。いずれもGoogleの検索ランキングに影響するため、サイト運営者にとって無視できない指標です。

CLSはページの読み込み中にコンテンツが予期せずずれる現象を数値化したもので、0.1以下が「良好」とされています。CLSが悪化する代表的な原因は、画像や広告などの要素にサイズ(widthheight)が指定されていないことです。サイズが未指定の画像は、読み込まれるまでブラウザが表示領域を確保できません。画像の読み込みが完了した瞬間にページ全体がガクッとずれてしまい、ユーザーが読んでいた箇所を見失うことになります。

私のブログを調べてみると、ほぼすべての記事画像にwidthheightが設定されていませんでした。

画像要素の改善

このブログは静的サイトジェネレーターのZolaを使用しており、記事はMarkdownで書いています。Markdownの画像記法は以下のとおりですが、widthheightなどのメタ情報を記述する手段がありません1

![カバー画像](cover.jpg)

そこでZolaのショートコードを利用して、画像サイズなどのメタ情報を自動でセットする仕組みを作りました。以下はコードの抜粋です。

    {%- set colocated_path = page.colocated_path | default(value="") -%}
    {%- set resolved_path = colocated_path ~ src -%}
    {%- set meta = get_image_metadata(path=resolved_path, allow_missing=true) -%}

    {%- if meta -%}
        {#- Colocated path resolved successfully -#}
        {%- set image_url = get_url(path=resolved_path, cachebust=true) -%}

Zolaのget_image_metadata関数はビルド時に画像ファイルを読み取り、幅と高さを返してくれます。これを利用してwidthheightimg要素に指定しています。あわせてget_url関数のcachebust=trueオプションでファイルのハッシュ値をURLに付与し、キャッシュバスティングにも対応しました。

あとはHTMLのimg要素として画像サイズが出力されるように組み立てるだけです。

<img
  src="{{ image_url }}"
  alt="{{ alt_text }}"
  {%- if meta and meta.width %} width="{{ meta.width }}"{% endif %}
  {%- if meta and meta.height %} height="{{ meta.height }}"{% endif %}
  {%- if lazy_loading %} loading="lazy"{% endif %}
/>

この結果、記事には以下のように書くだけで、画像サイズとキャッシュバスティングに対応したimg要素を自動出力できるようになりました。

{{ image(src="cover.jpg", alt="カバー画像") }}

Zola依存にはなりますが、記述を複雑にせずCLS対策ができました。

figure対応

記事内の画像にキャプションを付けたい場合もあります。そこでcaptionパラメータが指定された場合には、img要素をfigure要素で囲み、figcaption要素でキャプションを出力するようにしました。captionを省略した場合は従来どおりimg要素のみを出力します。

以下がショートコードの全容です。

imageショートコード
{#- ============================================================
    image.html - Image shortcode for Zola

    Copyright (c) 2026 Toshiyuki Yoshida
    Released under the MIT License.
    https://opensource.org/licenses/MIT

     Repository: https://github.com/yostos/blog-yostos


    Renders an <img> tag with automatic path resolution,
    image metadata (width/height), cache busting, and lazy loading.

    Parameters:
      src          - Image path or remote URL (required)
      alt          - Alt text for accessibility (recommended)
      caption      - Caption text displayed below the image (optional)
                     When specified, wraps output in <figure>/<figcaption>
      lazy_loading - Enable lazy loading (default: true)
    ============================================================ -#}

{#- Strip leading "./" from src to normalize colocated paths
    e.g. "./photo.jpg" -> "photo.jpg" -#}
{%- set src = src | trim_start_matches(pat="./") -%}

{#- ---- URL resolution ---- -#}
{%- if src is starting_with("http") -%}
    {#- Remote image: use src as-is. Metadata cannot be retrieved
        for external URLs, so width/height will not be set. -#}
    {%- set image_url = src -%}

{%- else -%}
    {#- Local image: first try resolving relative to the page's
        colocated directory (e.g. content/blog/my-post/photo.jpg).
        This supports the co-location pattern where content and
        assets live in the same directory. -#}
    {%- set colocated_path = page.colocated_path | default(value="") -%}
    {%- set resolved_path = colocated_path ~ src -%}
    {%- set meta = get_image_metadata(path=resolved_path, allow_missing=true) -%}

    {%- if meta -%}
        {#- Colocated path resolved successfully -#}
        {%- set image_url = get_url(path=resolved_path, cachebust=true) -%}

    {%- else -%}
        {#- Fallback: treat src as an absolute path from the project root.
            This handles images placed under the static/ directory,
            e.g. src="/images/shared.jpg" -#}
        {%- set meta = get_image_metadata(path=src, allow_missing=true) -%}

        {%- if meta -%}
            {%- set image_url = get_url(path=src, cachebust=true) -%}
        {%- else -%}
            {#- Neither path worked. Output the URL anyway so the page
                does not break, but width/height will be omitted.
                This may indicate a missing or misspelled image path. -#}
            {%- set image_url = get_url(path=src, cachebust=true) -%}
        {%- endif -%}

    {%- endif -%}
{%- endif -%}

{#- ---- Attribute defaults ---- -#}

{#- Default alt to empty string so the attribute is always present.
    An explicit alt="" is valid for decorative images and is preferred
    over omitting the attribute entirely for accessibility. -#}
{%- set alt_text = alt | default(value="") -%}

{#- Lazy loading is enabled by default for better page performance -#}
{%- set lazy_loading = lazy_loading | default(value=true) -%}

{#- Caption: when specified, wrap in <figure>/<figcaption> -#}
{%- set caption_text = caption | default(value="") -%}

{#- ---- Output ---- -#}
{%- if caption_text -%}
<figure>
<img
  src="{{ image_url }}"
  alt="{{ alt_text }}"
  {%- if meta and meta.width %} width="{{ meta.width }}"{% endif %}
  {%- if meta and meta.height %} height="{{ meta.height }}"{% endif %}
  {%- if lazy_loading %} loading="lazy"{% endif %}
/>
<figcaption>{{ caption_text }}</figcaption>
</figure>
{%- else -%}
<img
  src="{{ image_url }}"
  alt="{{ alt_text }}"
  {%- if meta and meta.width %} width="{{ meta.width }}"{% endif %}
  {%- if meta and meta.height %} height="{{ meta.height }}"{% endif %}
  {%- if lazy_loading %} loading="lazy"{% endif %}
/>
{%- endif -%}

References

  1. ZolaはCommonMarkをベースとしたpulldown-cmarkというRustのライブラリ を使用しているため画像にメタ情報を付与できませんが、Markdownの処理系によっては記述可能なものも存在します。