mdxでカスタムコンポーネントを利用する(カード型リンク)
2024年9月28日

mdxでカスタムコンポーネントを利用する(カード型リンク)

5,371文字(読了まで約14分)
headerimage

本サイトのブログ記事は、mdx 形式のファイルとして作成しています。mdx とは、基本的にはマークダウン形式で記述するテキストファイルですが、その内で react コンポーネントを利用可能となっており、様々な拡張を行うことができます。

今回は、外部サイトへのリンクを OGP 情報を含めてカード形式で表示可能な「ExtOgpLink」コンポーネントを作成し、a タグのような単なるリンク表示よりもリッチなリンクを配置できるようにしてみます。

  • 以下に実装例を示します。

    • 注意点としては、mdx ファイル内から呼び出す場合、コンポーネント内で Hono が提供している context を使おうとして、useRequestContext を使うと、context が無いとしてエラーになってしまいます。 本来、Hono のコンテキスト経由で CSS のテーマカラー(cssTheme)といった値を渡して扱いたいのですが、ブログ記事内では保持していませんのでうまくいきません。

      graph LR renderer[レンダラ<br>(cssThemeを保持)] mdx[記事のmdx] CC[カスタムコンポーネント<br>(cssThemeを使いたい)] renderer --渡せない--> mdx mdx --渡せない--> CC
    • 対処法としては、カスタム context (react・hono/jsx での useContext フック拡張)を作成し、ブログ記事ページの _renderer で設定することで、カスタムコンポーネント側であ使えるようになります。

      graph LR renderer[レンダラ<br>(cssThemeを保持)] mdx[記事のmdx] CC[カスタムコンポーネント<br>(cssThemeを使いたい)] renderer --渡せない--> mdx mdx --渡せない--> CC renderer -.useTheme<br>(カスタムフックで<br>cssThemeを渡す).-> CC
ExtOgpLink.tsx
import { css } from "hono/css";
import { useTheme } from "@/app/components/context/useTheme";
 
export const ExtOgpLink = async ({ url }: { url: string }) => {
  const cssTheme = useTheme();
  const ogp = await getOgp(url);
 
  const cssRoot = css`
    height: 6.5rem;
    width: 100%;
    display: grid;
    grid-template-columns: 6.5rem 1fr;
    grid-template-rows: 6.5rem;
    border: solid 1px;
    border-color: ${cssTheme.borderColor};
    background-color: ${cssTheme.bodyBgColor};
    margin-top: 1rem;
    margin-bottom: 1rem;
  `;
 
  const cssAnchor = css`
    height: 100%;
    width: 100%;
    grid-column-start: 1;
    grid-column-end: 3;
    grid-row-start: 1;
    grid-row-end: 2;
    z-index: 100;
  `;
 
  const cssImgBase = css`
    height: 100%;
    width: 100%;
    grid-column-start: 1;
    grid-column-end: 2;
    grid-row-start: 1;
    grid-row-end: 2;
  `;
  const cssImg = css`
    ${cssImgBase}
    object-fit: cover;
  `;
  const cssNoImg = css`
    ${cssImgBase}
    border-right: solid 1px;
    border-color: ${cssTheme.borderColor};
    margin: 0px;
 
    display: grid;
    justify-items: center;
    align-items: center;
  `;
 
  const cssDescription = css`
    height: 100%;
    width: 100%;
    grid-column-start: 2;
    grid-column-end: 3;
    grid-row-start: 1;
    grid-row-end: 2;
 
    display: grid;
    grid-template-columns: 1fr;
    grid-template-rows: 1fr 2.5rem;
    align-items: center;
    padding-left: 10px;
    padding-right: 10px;
  `;
 
  const cssTitle = css`
    height: 100%;
    width: 85%;
    grid-column-start: 1;
    grid-column-end: 2;
    grid-row-start: 1;
    grid-row-end: 2;
 
    display: grid;
    text-align: left;
    align-items: center;
  `;
 
  const cssTitleText = css`
    margin-top: 0.5rem;
    margin-bottom: 0.5rem;
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    overflow: hidden;
  `;
 
  const cssWeakText = css`
    height: 100%;
    width: 85%;
    grid-column-start: 1;
    grid-column-end: 2;
    grid-row-start: 2;
    grid-row-end: 3;
    font-size: smaller;
    color: ${cssTheme.fontWeakColor};
    margin-top: 0rem;
    margin-bottom: 0.1rem;
    white-space: nowrap;
    overflow: hidden;
  `;
 
  return (
    <>
      {ogp && (
        <div class={cssRoot}>
          <a class={cssAnchor} href={url}></a>
          {ogp.image ? (
            <img class={cssImg} src={ogp.image} />
          ) : (
            <div class={cssNoImg}>no image</div>
          )}
          <div class={cssDescription}>
            <div class={cssTitle}>
              <p class={cssTitleText}>{ogp.title}</p>
            </div>
            <div class={cssWeakText}>
              {ogp.site_name}
              <br />
              {ogp.url}
            </div>
          </div>
        </div>
      )}
    </>
  );
};
 
type OgpData = {
  title: string;
  image?: string;
  url?: string;
  site_name?: string;
};
const getOgp = async (url: string): Promise<OgpData | undefined> => {
  type OgpDataTmp = {
    [key: string]: string;
  };
 
  const encodedUri = encodeURI(url);
  const headers = { "User-Agent": "bot" };
 
  try {
    const res = await fetch(encodedUri, { headers: headers });
    const htmlData = await res.text();
    const ogpRegex =
      /<meta\s+property="og:([^"]+)"\s+content="([^"]+)"\s*\/?>/g;
    let match;
    const ogpData: OgpDataTmp = {};
 
    while ((match = ogpRegex.exec(htmlData)) !== null) {
      ogpData[match[1]] = match[2];
    }
    if ("title" in ogpData) {
      return ogpData as OgpData;
    } else {
      return undefined;
    }
  } catch (error) {
    console.error(error);
    return undefined;
  }
};

mdx ファイル内では、コンポーネントを import したうえで、本文中でタグを使って記述します。

---
title: mdxでカスタムコンポーネントを利用する(カード型リンク)
createdAt: 2024-09-28
imageSrc: 20240928.webp
tags: [mdx]
---
 
import { ExtOgpLink } from "../../components/common/ExtOgpLink";
 
## タイトル
 
<ExtOgpLink url="https://blog.scaletools.net/posts/20240912_cfpages_honox" />

カード型リンク部分は以下のように表示されます。

Cloudflare Pages × HonoX によるブログサイト作成

Cloudflare Pages × HonoX によるブログサイト作成

scaletools Blog
https://blog.scaletools.net/posts/20240912_cfpages_honox

  • ブログ中の外部リンクを、OGP 情報を用いてリッチに表示するコンポーネントを作成しました。
  • テーマに応じた css 設定をする方法については、これがベストなのか分かりません・・・良い方法をご存じの方に教えてもらえるとうれしいです。

- コメント -

    このサイトではcookieを利用して、サイト訪問者の識別に利用します。 cookieの利用に同意いただくことで、サイト訪問者は記事のいいね機能等をご利用いただけます。 なお、サイト運営者はアクセス統計としてcookieの情報を利用する場合があります。