ホームやさしいIT研究室

Studioの記事タイプCMSでURLを...

Studioの記事タイプCMSでURLをブログカード風に表示する

はじめに


Studioでブログ運用を始めてみて、記事タイプのCMSにあるリッチテキスト本文では、ブログカードをそのまま表示できないことに気づきました。

Zennやnoteをはじめ、よく見るブログ系サービスでは当たり前に使える機能だと思い込んでいたので、少し驚きました。とはいえ、できないならできないなりに、何とか同じようなことができないか試してみたので、今回はその実装メモとして残しておきます。

実装内容


今回実装したのは、記事タイプのCMSのリッチテキスト本文内に、記事URLを単独行で貼るだけで、ブログカード風に表示できるようにするためのカスタムコードです。

既存のデザイン要素への影響や、CMS運用時の手間をなるべく増やさないように、範囲や条件はかなり局所的に絞っています。環境によっては、もう少し緩い条件でも実装できると思うので、必要に応じて調整してみてください。

サンプルコード


以下が見た目を調整するためのCSSコードです。

/* 実装時、コメントは削除推奨 */
/* スタイルの対象範囲を独自ID「post_detail」に限定 */
/* 環境に合わせて、セレクタ先頭の「#post_detail」を削除または調整する */
#post_detail a.custom-blog-card,
#post_detail a.custom-blog-card:link,
#post_detail a.custom-blog-card:visited {
  /* 左にサムネイル、右にテキストエリアを並べる */
  display: grid !important;
  grid-template-columns: 192px minmax(0, 1fr) !important;
  align-items: center !important;
  gap: 16px !important;

  /* CMSの本文エリア幅に合わせて横幅いっぱいに表示する */
  width: 100% !important;
  max-width: none !important;
  min-height: 108px !important;
  margin: 28px 0 !important;
  padding: 0 !important;

  /* カードの基本外観 */
  border: 1px solid #dddddd !important;
  border-radius: 14px !important;
  background: #ffffff !important;
  color: inherit !important;
  text-decoration: none !important;
  box-sizing: border-box !important;
  overflow: hidden !important;
  font-family: "Inter", "Lato" !important;
}

/* ホバー時に少し薄くする */
#post_detail a.custom-blog-card:hover {
  opacity: 0.86 !important;
}

/* サムネイル */
#post_detail .custom-blog-card__thumb {
  display: block !important;
  width: 192px !important;
  height: 108px !important;
  min-width: 192px !important;
  border-radius: 0 !important;
  background: #f1f1f1 !important;
  overflow: hidden !important;
}

#post_detail .custom-blog-card__thumb img {
  display: block !important;
  width: 100% !important;
  height: 100% !important;
  max-width: none !important;
  object-fit: cover !important;
  object-position: center center !important;
}

/* テキストエリア */
#post_detail .custom-blog-card__content {
  display: flex !important;
  flex-direction: column !important;
  justify-content: center !important;
  gap: 6px !important;
  min-width: 0 !important;
  height: 108px !important;
  padding: 10px 16px 10px 0 !important;
  line-height: 1.5 !important;
  overflow: hidden !important;
  box-sizing: border-box !important;
  font-family: "Inter", "Lato" !important;
}

/* タイトル */
#post_detail .custom-blog-card__title {
  display: block !important;
  width: 100% !important;
  font-family: "Inter", "Lato" !important;
  font-size: 16px !important;
  font-weight: 700 !important;
  line-height: 1.45 !important;
  color: #222222 !important;
  text-decoration: none !important;
  white-space: nowrap !important;
  overflow: hidden !important;
  text-overflow: ellipsis !important;
}

/* 説明文 */
#post_detail .custom-blog-card__description {
  display: -webkit-box !important;
  width: 100% !important;
  font-family: "Inter", "Lato" !important;
  font-size: 13px !important;
  font-weight: 400 !important;
  line-height: 1.55 !important;
  color: #333333 !important;
  text-decoration: none !important;
  overflow: hidden !important;
  -webkit-line-clamp: 2 !important;
  -webkit-box-orient: vertical !important;
}

/* faviconとドメインを並べる下部エリア */
#post_detail .custom-blog-card__meta {
  display: flex !important;
  align-items: center !important;
  gap: 7px !important;
  margin-top: 2px !important;
  min-width: 0 !important;
  overflow: hidden !important;
  font-family: "Inter", "Lato" !important;
}

/* favicon */
#post_detail .custom-blog-card__favicon {
  display: block !important;
  width: 14px !important;
  height: 14px !important;
  min-width: 14px !important;
  border-radius: 2px !important;
  object-fit: contain !important;
}

/* ドメイン */
#post_detail .custom-blog-card__url {
  display: block !important;
  margin-top: 0 !important;
  font-family: "Inter", "Lato" !important;
  font-size: 12px !important;
  font-weight: 400 !important;
  color: #999999 !important;
  line-height: 1.4 !important;
  white-space: nowrap !important;
  overflow: hidden !important;
  text-overflow: ellipsis !important;
  text-decoration: none !important;
}

/* SP用 */
@media (max-width: 600px) {
  #post_detail a.custom-blog-card,
  #post_detail a.custom-blog-card:link,
  #post_detail a.custom-blog-card:visited {
    grid-template-columns: 102px minmax(0, 1fr) !important;
    gap: 10px !important;
    min-height: 102px !important;
    border-radius: 10px !important;
  }

  #post_detail .custom-blog-card__thumb {
    width: 102px !important;
    height: 102px !important;
    min-width: 102px !important;
  }

  #post_detail .custom-blog-card__thumb img {
    object-fit: cover !important;
    object-position: center center !important;
  }

  #post_detail .custom-blog-card__content {
    height: 102px !important;
    gap: 4px !important;
    padding: 8px 10px 8px 0 !important;
  }

  #post_detail .custom-blog-card__title {
    font-size: 13px !important;
    line-height: 1.35 !important;
  }

  #post_detail .custom-blog-card__description {
    font-size: 11px !important;
    line-height: 1.45 !important;
    -webkit-line-clamp: 2 !important;
  }

  #post_detail .custom-blog-card__meta {
    gap: 5px !important;
    margin-top: 1px !important;
  }

  #post_detail .custom-blog-card__favicon {
    width: 12px !important;
    height: 12px !important;
    min-width: 12px !important;
  }

  #post_detail .custom-blog-card__url {
    font-size: 11px !important;
  }
}

続いて、こちらは実際にカード化するためのJSコードです。

/* 実装時、コメントは削除推奨 */
(() => {
  /* カード化の対象範囲を独自ID「post_detail」に限定 */
  /* 環境に合わせて、このセレクタは変更する */
  const ROOT_SELECTOR = '#post_detail';

  /* カード化するリンクの条件 */
  /* example.com および /post/ は環境に合わせて置き換える */
  const TARGET_SELECTOR = 'p a[href*="example.com/post/"]';

  /* 同じURLを複数回取得しないためのキャッシュ */
  const metadataCache = new Map();

  function initCustomBlogCards(root = document) {
    /* 指定範囲内からカード化候補リンクを集める */
    const links = collectTargetLinks(root);

    links.forEach((link) => {
      /* 判定済みリンクは再処理しない */
      if (link.dataset.customBlogCardChecked === 'true') return;
      link.dataset.customBlogCardChecked = 'true';

      /* 箇条書き内のURLはカード化しない */
      if (link.closest('ul, ol, li')) return;

      /* すでに生成済みカード内のリンクは対象外 */
      if (link.closest('.custom-blog-card')) return;

      /* Studioのリッチテキストでは、単独URLがpタグ内のaタグとして出る想定 */
      const p = link.closest('p');
      if (!p) return;

      /* リンク文字列、段落全体、hrefを比較用に整える */
      const linkText = normalizeText(link.textContent);
      const parentText = normalizeText(p.textContent);
      const hrefText = normalizeUrlText(link.href);

      /* リンクの表示テキストがURLそのものではない場合は対象外 */
      if (normalizeUrlText(linkText) !== hrefText) return;

      /* 段落内にURL以外の文字がある場合は対象外 */
      if (normalizeUrlText(parentText) !== hrefText) return;

      /* 条件を満たした段落をブログカードに置き換える */
      createCustomBlogCard(p, link.href);
    });
  }

  function collectTargetLinks(root) {
    const links = [];

    /* 要素またはdocument以外は調べない */
    if (root.nodeType !== Node.ELEMENT_NODE && root.nodeType !== Node.DOCUMENT_NODE) {
      return links;
    }

    /* root自身が対象リンクだった場合も拾う */
    if (root.matches?.(TARGET_SELECTOR)) {
      links.push(root);
    }

    /* root配下の対象リンクを拾う */
    root.querySelectorAll?.(TARGET_SELECTOR).forEach((link) => {
      links.push(link);
    });

    return links;
  }

  async function createCustomBlogCard(wrapper, url) {
    /* 同じ段落を二重にカード化しない */
    if (wrapper.dataset.customBlogCardProcessed === 'true') return;
    wrapper.dataset.customBlogCardProcessed = 'true';

    try {
      /* 対象記事のメタ情報を取得する */
      const metadata = await getBlogMetadata(url);

      /* 取得した情報からカードHTMLを生成する */
      const card = buildCustomBlogCard(url, metadata);

      /* 元のURL段落をカードに差し替える */
      wrapper.replaceWith(card);
    } catch (error) {
      /* 失敗時は元リンクを残す */
      console.warn('Failed to create custom blog card:', url, error);

      /* 再試行できるように処理済みフラグを戻す */
      wrapper.dataset.customBlogCardProcessed = 'false';
    }
  }

  function getBlogMetadata(url) {
    /* URL表記の揺れをならして、キャッシュキーに使う */
    const cacheKey = normalizeUrlText(url);

    /* 未取得のURLだけfetchする */
    if (!metadataCache.has(cacheKey)) {
      const request = fetchBlogMetadata(url).catch((error) => {
        /* 失敗した結果はキャッシュしない */
        metadataCache.delete(cacheKey);
        throw error;
      });

      /* Promiseごと保存し、同時に同じURLが来ても取得を1回にまとめる */
      metadataCache.set(cacheKey, request);
    }

    return metadataCache.get(cacheKey);
  }

  async function fetchBlogMetadata(url) {
    /* 対象記事のHTMLを取得する。同一サイト内URLを想定 */
    const response = await fetch(url, {
      credentials: 'same-origin'
    });

    /* HTTPエラーならカード化を中断する */
    if (!response.ok) {
      throw new Error('Failed to fetch article HTML.');
    }

    /* 取得したHTML文字列をDOMとして解析する */
    const html = await response.text();
    const doc = new DOMParser().parseFromString(html, 'text/html');

    /* metaタグのcontent属性を取得する小さな補助関数 */
    const getMeta = (selector) => {
      const element = doc.querySelector(selector);
      return element ? element.getAttribute('content') : '';
    };

    /* faviconなどのlinkタグhrefを絶対URLに変換して取得する補助関数 */
    const getLinkHref = (selector) => {
      const element = doc.querySelector(selector);
      const href = element ? element.getAttribute('href') : '';

      if (!href) return '';

      try {
        return new URL(href, url).href;
      } catch (e) {
        return href;
      }
    };

    /* URLからドメイン表示用のhostnameを取り出す */
    const pageUrl = new URL(url);

    /* OGPやtitleタグから、カード表示に使う情報をまとめる */
    return {
      /* タイトル。OGP、Twitter Card、titleタグ、URLの順にfallback */
      title:
        getMeta('meta[property="og:title"]') ||
        getMeta('meta[name="twitter:title"]') ||
        doc.querySelector('title')?.textContent ||
        url,

      /* 説明文。OGP、description、Twitter Cardの順にfallback */
      description:
        getMeta('meta[property="og:description"]') ||
        getMeta('meta[name="description"]') ||
        getMeta('meta[name="twitter:description"]') ||
        '',

      /* サムネイル画像。OGP、Twitter Cardの順にfallback */
      image:
        getMeta('meta[property="og:image"]') ||
        getMeta('meta[name="twitter:image"]') ||
        '',

      /* favicon。取得できない場合はGoogle favicon APIを使う */
      favicon:
        getLinkHref('link[rel="icon"]') ||
        getLinkHref('link[rel="shortcut icon"]') ||
        getLinkHref('link[rel="apple-touch-icon"]') ||
        'https://www.google.com/s2/favicons?domain=' + pageUrl.hostname + '&sz=64',

      /* カード下部に表示するドメイン */
      hostname: pageUrl.hostname
    };
  }

  function buildCustomBlogCard(url, metadata) {
    /* カード全体をaタグとして生成する */
    const card = document.createElement('a');
    card.className = 'custom-blog-card';
    card.href = url;
    card.target = '_blank';
    card.rel = 'noopener';

    /* 左側のサムネイル枠を作る */
    const thumb = document.createElement('span');
    thumb.className = 'custom-blog-card__thumb';

    /* サムネイル画像がある場合だけimgタグを追加する */
    if (metadata.image) {
      const image = document.createElement('img');
      image.src = metadata.image;
      image.alt = '';
      thumb.appendChild(image);
    }

    /* 右側のテキストエリアを作る */
    const content = document.createElement('span');
    content.className = 'custom-blog-card__content';

    /* タイトルを作る。textContentで入れてHTMLとして解釈させない */
    const title = document.createElement('span');
    title.className = 'custom-blog-card__title';
    title.textContent = metadata.title;

    /* 説明文を作る */
    const description = document.createElement('span');
    description.className = 'custom-blog-card__description';
    description.textContent = metadata.description;

    /* faviconとドメインを並べる下部エリアを作る */
    const meta = document.createElement('span');
    meta.className = 'custom-blog-card__meta';

    /* favicon画像を作る。装飾扱いなのでaltは空 */
    const favicon = document.createElement('img');
    favicon.className = 'custom-blog-card__favicon';
    favicon.src = metadata.favicon;
    favicon.alt = '';

    /* ドメイン表示を作る */
    const hostname = document.createElement('span');
    hostname.className = 'custom-blog-card__url';
    hostname.textContent = metadata.hostname;

    /* 下部エリアにfaviconとドメインを入れる */
    meta.append(favicon, hostname);

    /* テキストエリアにタイトル、説明文、下部エリアを入れる */
    content.append(title, description, meta);

    /* カードにサムネイル枠とテキストエリアを入れる */
    card.append(thumb, content);

    return card;
  }

  function normalizeText(value) {
    /* 空値を安全に文字列化し、前後空白と途中の空白を取り除く */
    return String(value || '')
      .trim()
      .replace(/\s+/g, '');
  }

  function normalizeUrlText(value) {
    try {
      /* URLとして解釈できる場合は、origin + pathname だけで比較する */
      const url = new URL(value, location.origin);
      return url.origin.replace(/^https?:\/\/www\./, 'https://') + url.pathname.replace(/\/$/, '');
    } catch (e) {
      /* URLとして解釈できない場合も、最低限の表記揺れをならす */
      return normalizeText(value)
        .replace(/^https?:\/\/www\./, 'https://')
        .replace(/\/$/, '');
    }
  }

  function startCustomBlogCards() {
    /* すでに存在する本文エリアを処理する */
    initExistingRoots();

    /* 遅延描画対策で短時間だけ繰り返し探す */
    let count = 0;
    const timer = setInterval(() => {
      initExistingRoots();
      count++;

      /* 500ms x 20回 = 約10秒で停止する */
      if (count >= 20) {
        clearInterval(timer);
      }
    }, 500);

    /* あとから本文DOMが追加された場合に備えて監視する */
    const observer = new MutationObserver((mutations) => {
      mutations.forEach((mutation) => {
        mutation.addedNodes.forEach((node) => {
          /* 要素ノード以外は無視する */
          if (node.nodeType !== Node.ELEMENT_NODE) return;

          /* 追加された要素自身が本文エリアなら処理する */
          if (node.matches?.(ROOT_SELECTOR)) {
            initCustomBlogCards(node);
            return;
          }

          /* 追加された要素が本文エリア内なら、その要素だけ処理する */
          if (node.closest?.(ROOT_SELECTOR)) {
            initCustomBlogCards(node);
            return;
          }

          /* 追加された要素の中に本文エリアがあれば処理する */
          node.querySelectorAll?.(ROOT_SELECTOR).forEach((root) => {
            initCustomBlogCards(root);
          });
        });
      });
    });

    /* body配下の追加DOMを監視する */
    observer.observe(document.body, {
      childList: true,
      subtree: true
    });
  }

  function initExistingRoots() {
    /* 現在存在する本文エリアをすべて処理する */
    document.querySelectorAll(ROOT_SELECTOR).forEach((root) => {
      initCustomBlogCards(root);
    });
  }

  /* DOM読み込み前ならDOMContentLoaded後に開始する */
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', startCustomBlogCards);
  } else {
    /* すでにDOMが読める状態ならすぐ開始する */
    startCustomBlogCards();
  }
})();

CSS・JSのコード内にあるコメントは、「どこで何をしているか」が分かるように、少し細かめに入れています。動作に必要なものではないので、実際に使うときは削除しておくと見やすいと思います。
また、表示の都合で style や script タグを省いているため、ご使用の際は必ずつけてください

このサンプルコードを設置すると、以下のようなブログカードが表示されます。

https://www.noronoro.blog/post/blog-00094

対象範囲


画像にある赤枠の通り、サイト全体ではなく、対象のブログ記事ページにカスタムコードを設置します。読み込み順は「CSS → JS」がおすすめです。CSSを先に読み込んでおくと、JSがカードHTMLを生成した直後から見た目が反映されます。

さらに適用範囲は、 リッチテキスト本文の中だけに限定しています。これは、Studioの標準機能を使って、リッチテキスト本文に独自ID post_detail を付けて指定しています。環境に応じて、サンプルコード内のCSSやJSのセレクタは消したり、変更したりして調整してください

カード化するURLは、「サイト内のブログ記事ページ」のみです。サンプルコード内のドメインやスラッグ部分は、自分の環境に合わせて書き換えてください。外部リンクを扱うのは、今回の実装方法ではセキュリティ面で困難そうでした。

リッチテキスト本文内にあるすべての「サイト内のブログ記事ページ」のURLを対象にしているわけではありません。以下の条件を全て満たしたURLだけをカード化します。

  1. pタグ内にある

  2. 段落全体のテキストがURLそのもの(pタグ内のテキスト= href)

  3. リンクの表示テキストがURLそのもの(aタグ内のテキスト= href)

  4. 箇条書き(ul / ol / li)ではない

カード化処理の挙動


実行トリガーは、主に以下の2つです。

  • 起動後しばらく post_detail を探す(0.5秒ごとに約10秒チェック)

  • MutationObserver で、あとから追加されたDOMも監視する

初回表示のタイミングでうまく拾えなかった場合でも、できるだけカード化されるようにしています。

同じURLがリッチテキスト本文内に複数回出てきた場合は、毎回処理が走らないように、取得結果をキャッシュしています。

また、OGP取得などがうまくいかずカード化に失敗した場合は、無理に置き換えず、元のURLリンクをそのまま記事上に表示します。

見た目


表示用の情報として、対象記事から以下を取得します。

  • タイトル

  • 説明文

  • サムネイル画像

  • ファビコン

  • ドメイン

カードのデザインは、PC版・SP版ともに画像のような構成です。細かい内容はサンプルコードを見てください。

サンプルコードのCSSには、多くのプロパティに !important を付けています。正直あまりきれいな書き方ではないので、実際に検証しながら、不要なものは外していくのがおすすめです。

おまけ


条件次第ではありますが、たとえば「合わせて読みたい」というテキストにリンクを付けたら、それ専用のラベル付きブログカードにする、みたいなこともできると思います。

ちなみに、記事本文内の内部リンクカードをJSであとから生成する方式は、SEO的に見ると少し注意が必要です。CLSや描画遅延が発生する場合は、減点要素になり得ます。
とはいえ、UIや回遊導線を良くするための装飾として考えれば、そこまで神経質にならなくてもよさそうです。ファーストビュー付近に置かなければ、影響もかなり抑えられるのではないかなと思います。

表示速度について少し気になったので、PageSpeed Insightsでも見てみました。ただ、StudioはもともとCMSを読み込むページだと、表示速度がやや遅くなりやすいようです。
Studio公式も、2026年1月末ごろから表示速度を大幅に向上させるために、新しい公開サイト基盤への切り替えを進めています。やはり、このあたりは課題として認識されているのかもしれません。ついでに新基盤へ切り替えてみたところ、かなり速くなりました。これは普通にすごいです。

さいごに


ひとまず形にはなりましたが、コードとしてはあまりきれいではないかもしれません。素人なりに試行錯誤したものなので、そのあたりはゆるく見てもらえると助かります。

また、意図せず旧サイト基盤で実装してから新サイト基盤に切り替えたため、結果的にどちらの環境でも動くことは確認できました。

この記事が、同じようなところで困っている方の参考になれば嬉しいです。

参考サイト