本文書ではMarkdownを利用したブログサイトの構築をReactベースのフレームワークであるNext.jsを利用して行なっています。Next.jsとMarkdownファイルを利用して記事一覧、ブログ記事ページを表示するだけの手順はネット上に多数公開されています。しかし記事ページを表示させること以上の内容になると個々の機能についての記事はありますが複数の追加機能の設定方法を一度に記述した記事を見つけるのは難しいです。本文書ではSEOのためのheaderタグ、目次、Link、 Imageコンポーネント、コードハイライト、ページネーションの設定などブログを公開する上で実装しておきたい機能が含まれるブログサイトをスクラッチから作成していきます。

MarkdownのブログではHTMLへの変換が必須でライブラリは複数存在しますが本文書ではremark, rehypeを利用しています。他のライブラリについての設定方法についても簡単に説明を行っているので各自の要件にあったライブラリを選択してください。

Next.jsの準備

スクラッチからブログサイトの構築を行うためNext.jsプロジェクトの作成から行なっていきます。Next.jsのバージョンは12.2.2, Reactのバージョンは18.2.0, macOSで動作確認しています。

プロジェクトの作成

npx create-next-appコマンドを利用してNext.jsプロジェクトの作成を行います。プロジェクトの名前はnextjs-markdown-blogとしていますが任意の名前をつけてください。


 % npx create-next-app@latest nextjs-markdown-blog

プロジェクトの作成が完了したら作成されたフォルダに移動してnpm run devコマンドを実行します。


 % npx create-next-app@latest nextjs-markdown-blog
 % cd nextjs-markdown-blog
 % npm run dev

> nextjs-markdown-blog@0.1.0 dev
> next dev

ready - started server on 0.0.0.0:3000, url: http://localhost:3000
event - compiled client and server successfully in 405 ms (169 modules)

ブラウザからhttps://localhost:3000にアクセスするとNext.jsのWelcomeページが表示されます。

next.jsのデフォルトページ
next.jsのデフォルトページ

Tailwind CSSの設定

CSSにはTailwind CSSを利用します。npmコマンドでTailwind CSSに関連するパッケージのインストールを行います。


 % npm install -D tailwindcss postcss autoprefixer

インストール完了後、npxコマンドを利用して設定ファイルの作成を行います。コマンドを実行するとプロジェクトフォルダにはpostcss.config.js、tailwind.config.jsファイルが作成されます。


 % npx tailwindcss init -p

Created Tailwind CSS config file: tailwind.config.js
Created PostCSS config file: postcss.config.js

作成されたtailwind.config.jsファイルにこれから作成するjsx, jsファイルなどのパスの設定を行います。componentsフォルダはデフォルトでは存在しませんが後ほど作成しコンポーネントファイルを保存します。


/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    './pages/**/*.{js,ts,jsx,tsx}',
    './components/**/*.{js,ts,jsx,tsx}',
],
  theme: {
    extend: {},
  },
  plugins: [],
}

stylesフォルダにあるglobals.cssファイルにtailwindのディレクティブの設定を行います。


@tailwind base;
@tailwind components;
@tailwind utilities;

Tailwind CSSの設定が完了すると先ほどのデフォルトのWelcomeページのフォントが変わることが確認できます。ブラウザに表示されている内容はpages/index.jsファイルでスタイルにstyles/Home.module.cssを利用しているのでフォント以外についてはほとんどTailwind CSSの影響を受けていません。

Tailwind CSSを適用後のWelcomeページ
Tailwind CSSを適用後のWelcomeページ

プロジェクトの作成とCSSの初期設定が完了したのでここからブログサイトの構築を行なっていきます。

ブログサイトの構築

レイアウトの設定

ブログサイト全体で共有するレイアウトコンポーネントの作成を行います。プロジェクトフォルダ直下にcomponentsフォルダを作成し, Layout.js, Header.js, Footer.jsファイルを作成します。

Layout.jsファイルの中でHeaderとFooterの2つのコンポーネントをimportしています。


import Header from './Header';
import Footer from './Footer';

export default function Layout({ children }) {
  return (
    <div className="flex flex-col min-h-screen">
      <Header />
      <main className="flex-1 max-w-4xl w-full mx-auto">{children}</main>
      <Footer />
    </div>
  );
}

Headerコンポーネントには以下を記述します。positionのstickyを設定してヘッダーはページの上部に固定しています。


import Link from 'next/link';

const Header = () => {
  return (
    <header className="sticky top-0 border-b z-10 bg-white">
      <div className="max-w-4xl mx-auto flex justify-between items-center h-12">
        <Link href="/">
          <a>LOGO</a>
        </Link>
        <div>Link</div>
      </div>
    </header>
  );
};

export default Header;

Footerコンポーネントには以下を記述します。


const Footer = () => {
  return (
    <footer className="bg-gray-100">
      <div className="max-w-4xl w-full mx-auto h-24 flex items-center justify-center">
        <div>© My Blog</div>
      </div>
    </footer>
  );
};

export default Footer;

作成したLayoutコンポーネントはpagesフォルダの_app.jsファイル内でComponentを包む必要があります。


import Layout from '../components/layout';
import '../styles/globals.css';

function MyApp({ Component, pageProps }) {
  return (
    <Layout>
      <Component {...pageProps} />
    </Layout>
  );
}

export default MyApp;

pagesフォルダのindex.jsファイルを更新します。


export default function Home() {
  return <div className="my-8">コンテンツ</div>;
}

レイアウト適用後のページは以下のように表示されます。

レイアウト適用後のページ
レイアウト適用後のページ

記事一覧ページの作成

Markdownファイルの作成

ブログの記事はmarkdownを利用して作成するのでmarkdownファイルを保存するためのフォルダpostsを作成します。markdownファイルの拡張子はmdで、ファイル名は各記事にアクセスする際のURLの一部として利用するためファイル名をつける場合はアルファベットを利用します。

最初に記事に”next-js-markdown-blog.md”という名前をつけており下記の内容を記述しています。


---
title: 'Next.jsでmarkdownブログを構築'
date: '2022-07-13'
description: 'Next.jsでmarkdownファイルを利用したブログの構築手順を解説しています。'
---

Next.js を使って Markdown のブログサイトの構築を一から行なっていきます。

## Next.js の準備

### プロジェクトの作成

npx create-next-app コマンドを利用して Next.js プロジェクトの作成を行います。

mdファイル情報の取得

ブラウザから/(ルート)にアクセスするとブログの記事一覧が表示されるように設定を行なっていくためpagesフォルダのindex.jsファイルを更新していきます。

ブログ一覧を表示するためにはpostsフォルダに保存したmarkdownファイルをNext.jsから読み込む必要があります。markdownの読み込みにはfs(FileSystem)モジュールのreaddirSyncとreadFileSyncを利用します。

  • readdirSyncは引数に指定したフォルダのパス(ディレクトリ)の中の情報を同期処理で取得
  • readFileSyncは引数に指定したファイルパスからファイルの内容を同期処理で取得

readdirSyncメソッドを利用してpostsフォルダの中に保存されているファイルを取得しますが、ビルド時にブログの記事情報を取得しページを作成するためgetStaticPropsの中で利用します。

Next.jsではビルド時にページの作成を行うSSG(Static Site Generator)以外の機能にクライアント(ブラウザ)からアクセスの度にサーバ側でページを作成するSSR(Server Side Rendering)やブラウザ側でページの内容を作成するCSR(Client Sider Rendering)などがあります。ブログでは一度ページを作成すると内容が頻繁に更新されることはないのでSSGを利用します。SSRやCSRでもブログサイトは構築できます。

fs.readdirSyncでpostsフォルダのファイルが取得できるか実際にコードを利用して確認を行います。最終的にgetStaticProps内の処理ではpropsを渡す必要がありますがここでは空の配列を設定しています。


import fs from 'fs';

export const getStaticProps = () => {
  const posts = fs.readdirSync('posts');
  console.log('files:', posts);
  return {
    props: {
      posts: [],
    },
  };
};

export default function Home({ posts }) {
  return <div className="my-8">コンテンツ</div>;
}

設定後にページをリロードすると”npm run dev”を実行したコンソールにファイル名が表示されます。postsフォルダに存在するファイル名を取得することができました。


files: [ 'next-js-markdown-blog.md' ]

fs.readdirSyncは配列でpostsフォルダに保存されている情報を取得しますが取得する情報にはファイル名だけではなくフォルダ名も含まれます。

postsフォルダにJavaScriptファイルやフォルダを保存するとそれらの情報もfs.readdirSyncで取得できるのでmarkdownファイル以外は保存してはいけません。

fs.readdirSyncは配列として取得した情報を保存するためmap関数を利用して配列に保存された情報(ここではファイル名)を個別に取得することができます。取得したファイル名を元にファイルの中身をfs.readFileSyncメソッドを利用して取得します。


export const getStaticProps = () => {
  const files = fs.readdirSync('posts');
  const posts = files.map((fileName) => {
  const fileContent = fs.readFileSync(`posts/${fileName}`, 'utf-8');
    console.log('fileContent:', fileContent);
  });
  return {
    props: {
      posts: [],
    },
  };
};

設定後にページをリロードすると”npm run dev”コマンドを実行したコンソールにmarkdownファイルに保存されている内容が表示されます。

index.jsファイルではブログ記事の一覧を表示するため、必要になるものは記事のタイトルと記事にアクセスするためのURLです。それらの情報を取得していきます。

makrdownのファイル名はURLの一部として利用することを前提につけているので拡張子をファイル名から取り除くことでURLとして利用することができます。replaceメソッドを利用していますが正規表現を利用してファイル名の最後に.mdがついていたら削除するように設定してslug変数に保存しています。


export const getStaticProps = () => {
  const files = fs.readdirSync('posts');
  const posts = files.map((fileName) => {
    const slug = fileName.replace(/\.md$/, ''); //追加
    const fileContent = fs.readFileSync(`posts/${fileName}`, 'utf-8');
    console.log('slug:', slug);
  });
  return {
    props: {
      posts: [],
    },
  };
};

設定後にページをリロードすると”npm run dev”コマンドを実行したコンソールに拡張子のmdが含まれていないファイル名が表示されます。

gray-matterの設定

記事のページのURLの一部であるslugを取得することができましたが記事のタイトルは取得できていません。記事のタイトルはmarkdownのFront Matter(—で囲まれたメタ情報)に記述しているのでライブラリのgray-matterを利用することで取得することができます。

gray-matterライブラリのインストールを行います。


 % npm install gray-matter

gray-matterを利用することでfront-matterが入ったdataとfront-matter以外のcontentを取り出すことができます。


import fs from 'fs';
import matter from 'gray-matter';

export const getStaticProps = () => {
  const files = fs.readdirSync('posts');
  const posts = files.map((fileName) => {
    const slug = fileName.replace(/\.md$/, '');
    const fileContent = fs.readFileSync(`posts/${fileName}`, 'utf-8');
    const { data, content } = matter(fileContent);
    console.log('data:', data);
    console.log('content:', content);
  });
  return {
    props: {
      posts: [],
    },
  };
};

設定後にページをリロードすると”npm run dev”コマンドを実行したコンソールにFront Matterに記述した内容がオブジェクトして取得できます。ここ記事のタイトルを含んでいるdataのみ利用します。


data: {
  title: 'Next.jsでmarkdownブログを構築',
  date: '2022-07-13',
  description: 'Next.jsでmarkdownファイルを利用したブログの構築手順を解説しています。'
}
content: 
Next.js を利用して Markdown のブログサイトを一から作成します。

## プロジェクトの作成

npx create-next-app コマンドを利用して Next.js プロジェクトの作成を行います。

propsで渡す値の設定

ファイル名からslug, markdownファイルの中からFront Matterの情報を取得することができたので取得した値をpropsとしてコンポーネントに渡します。


import fs from 'fs';
import matter from 'gray-matter';

export const getStaticProps = () => {
  const files = fs.readdirSync('posts');
  const posts = files.map((fileName) => {
    const slug = fileName.replace(/\.md$/, '');
    const fileContent = fs.readFileSync(`posts/${fileName}`, 'utf-8');
    const { data } = matter(fileContent);
    return {
      frontMatter: data,
      slug,
    };
  });

  return {
    props: {
      posts,
    },
  };
};

propsで渡したpostsにFront Matterとslugが含まれているか確認します。


export default function Home({ posts }) {
  console.log(posts);
  return <div className="my-8">コンテンツ</div>;
}

設定後にページをリロードすると”npm run dev”コマンドを実行したコンソールにfrontMatterとslugを持つオブジェクトが配列で表示されます。


[
  {
    frontMatter: {
      title: 'Next.jsでmarkdownブログを構築',
      date: '2022-07-13',
      description: 'Next.jsでmarkdownファイルを利用したブログの構築手順を解説しています。'
    },
    slug: 'next-js-markdown-blog'
  }
]

propsで受け取ったpostsをmap関数を利用して展開してブラウザ上に表示させます。ページの詳細ページへのリンクを設定するためLinkコンポーネントをimportして利用しています。


import fs from 'fs';
import matter from 'gray-matter';
import Link from 'next/link';

//略

export default function Home({ posts }) {
  return (
    <div className="my-8">
      {posts.map((post) => (
        <div key={post.slug}>
          <Link href={`/post/${post.slug}`}>
            <a>{post.frontMatter.title}</a>
          </Link>
        </div>
      ))}
    </div>
  );
}

ブラウザ上には記事のタイトルが表示されます。

ブログのタイトルの表示
ブログのタイトルの表示

PostCardコンポーネントの作成

componentsフォルダにPostCardコンポーネントを作成しmap関数で展開したコードをPostCardに移動します。PostCardコンポーネントではpostの情報をpropsで受け取りLinkコンポーネントを利用して記事ページへのリンクを設定しています。


import Link from 'next/link';

const PostCard = ({ post }) => {
  return (
    <Link href={`/post/${post.slug}`}>
      <a>{post.frontMatter.title}</a>
    </Link>
  );
};

export default PostCard;

作成したPostCardコンポーネントを利用するためindex.jsファイルを更新します。


import fs from 'fs';
import matter from 'gray-matter';
import PostCard from '../components/PostCard';

export const getStaticProps = () => {
  const files = fs.readdirSync('posts');
  const posts = files.map((fileName) => {
    const slug = fileName.replace(/\.md$/, '');
    const fileContent = fs.readFileSync(`posts/${fileName}`, 'utf-8');
    const { data } = matter(fileContent);
    return {
      frontMatter: data,
      slug,
    };
  });

  return {
    props: {
      posts,
    },
  };
};

export default function Home({ posts }) {
  return (
    <div className="my-8">
      {posts.map((post) => (
        <PostCard key={post.slug} post={post} />
      ))}
    </div>
  );
}

ブラウザでアクセスすると下記の画面が表示されます。index.jsファイルから記事一覧の表示部分をPostCardコンポーネントとして取り出しただけなのでブラウザ上の表示に変化はありません。

ブログのタイトルの表示
ブログのタイトルの表示

ブログの記事のタイトルにリンクが貼られていますがリンク先のブログの記事ページが作成されていないためリンクをクリックするとブラウザ上には404 Not Foundエラーが表示されます。

404ページ
404ページ

記事のイメージ画像の設定

ブラウザ上にはタイトルしか表示されていませんが記事のイメージ画像が表示できるように変更を加えます。記事のイメージ画像を準備します。ここではnextjs.pngファイルを準備しpublicフォルダの中に保存します。 各自好きな画像を準備してください。

保存したファイルの情報は各記事のFront Matterに追加します。


---
title: 'Next.jsでmarkdownブログを構築'
date: '2022-07-13'
description: 'Next.jsでmarkdownファイルを利用したブログの構築手順を解説しています。'
image: nextjs.png
---

画像の情報はPostCardコンポーネントからpost.frontMatter.imageでアクセスすることができます。画像の表示にはNext.jsのImageコンポーネントを利用します。Imageコンポーネントを利用する場合はwidth, height, altを設定する必要があります。publicフォルダに保存したファイルには/nextjs.pngでアクセスすることができます。


import Image from 'next/image';
import Link from 'next/link';

const PostCard = ({ post }) => {
  return (
    <Link href={`/posts/${post.slug}`}>
      <a>
        <div className="border rounded-lg">
          <Image
            src={`/${post.frontMatter.image}`}
            width={1200}
            height={700}
            alt={post.frontMatter.title}
          />
        </div>
        <div className="px-2 py-4">
          <h1 className="font-bold text-lg">{post.frontMatter.title}</h1>
          <span>{post.frontMatter.date}</span>
        </div>
      </a>
    </Link>
  );
};

export default PostCard;

ブラウザで確認すると用意した画像が大きく表示されます。

記事用のイメージ画像の表示
記事用のイメージ画像の表示

複数の記事が横並びできるようにTailwind CSSを利用してgridを設定します。index.jsファイルのpostsのmap関数の外側にdiv要素を追加してgridを設定しています。grid-cols-3を設定しているので3つの記事が横並びすることになります。


export default function Home({ posts }) {
  return (
    <div className="my-8">
      <div className="grid grid-cols-3">
        {posts.map((post) => (
          <PostCard key={post.slug} post={post} />
        ))}
      </div>
    </div>
  );
}

ブラウザで確認すると画像が小さくなっていることが確認できます。右側には残り2つの記事が並べられるスペースが確保されています。記事を追加していくことで右に画像と記事のタイトル情報が表示されることになります。

記事一覧のページにGridを設定
記事一覧のページにGridを設定

記事ページの表示

ページコンポーネントの作成

記事ページのURLはslugの値によって変わるためダイナミックルーティングの仕組みを利用します。そのためファイル名には[]ブラケットをつける必要があります。記事ページのURLは/posts/ + slugとするためpagesフォルダの中に/postsフォルダを作成しその中に[slug].jsファイルを作成します。

[slug].jsファイルに以下を記述します。


const Post = () => {
  return <div>コンテンツ</div>;
};

export default Post;

記事一覧のページからタイトルまたは画像をクリックすると先ほどまでは404 Not Foundエラーが発生しましたが[slug].jsファイルを作成後の画面にはコンテンツという文字列が表示されます。

getStaticPathの設定

記事の内容について記事一覧と同様にgetStaticPropsを利用して取得しますがダイナミックルーティングを利用している場合はgetStaticPropsに加えてgetStaticPathsの設定が必要となります。getStaticPathsににはビルド処理の中でページを作成する際に必要となる個別の記事ページのパスを設定します。ダイナミックページではgetStaticPropsを利用する場合にgetStaticPathsがないとエラーになります。

記事ページへのパス情報は/postsフォルダに保存されているmarkdownファイルの名前から作成します。getStaticPathsの戻り値のオブジェクトにはpathsの他にfallbackが設定されています。fallbackにはfalseを設定していますがfalseを設定することで存在しないページへのアクセスがあった場合に404 Not Foundが表示されます。


import fs from 'fs';

export async function getStaticProps() {
  return { props: { post: '' } };
}

export async function getStaticPaths() {
  const files = fs.readdirSync('posts');
  const paths = files.map((fileName) => ({
    params: {
      slug: fileName.replace(/\.md$/, ''),
    },
  }));
  console.log('paths:', paths);
  return {
    paths,
    fallback: false,
  };
}

const Post = () => {
  return <div>コンテンツ</div>;
};

export default Post;

設定後にページをリロードすると”npm run dev”コマンドを実行したコンソールにpaths情報が表示されます。


paths: [ { params: { slug: 'next-js-markdown-blog' } } ]

slugの取得

getStaticPropsの中ではアクセスしたURLに含まれているslugを利用してどの記事を表示するか設定を行う必要があります。表示する内容はslugを利用して決めます。

getStaticPropsは引数にcontextを設定するとさまざまな情報を取得することができます。slugはcontextの中のparamsに含まれているのでparamsの内容を確認します。


export async function getStaticProps({ params }) {
  console.log('params:', params);
  return { props: { post: '' } };
}

設定後にページをリロードして記事一覧のタイトルか画像をクリックすると”npm run dev”コマンドを実行したコンソールにparams情報が表示されます。


params: { slug: 'next-js-markdown-blog' }

記事内容の取得

slugを持つファイルの内容はfs.readFileSyncを利用して取得します。


export async function getStaticProps({ params }) {
  const file = fs.readFileSync(`posts/${params.slug}.md`, 'utf-8');
  console.log(file);
  return { props: { post: '' } };
}

設定後にページをリロードすると”npm run dev”コマンドを実行したコンソールにmarkdownファイルの内容が表示されます。

取得したファイルの内容からFrontMatterとContent(記事の内容)に分けます。分け方についてはすでに確認済みでgray-matterを利用します。


import fs from 'fs';
import matter from 'gray-matter';

//略

export async function getStaticProps({ params }) {
  const file = fs.readFileSync(`posts/${params.slug}.md`, 'utf-8');
  const { data, content } = matter(file);
  return { props: { frontMatter: data, content } };
}

frontMatter, contentとしてpropsでコンポーネントの関数に渡します。コンポーネントの中では受け取ったpropsを利用して表示させます。


const Post = ({ frontMatter, content }) => {
  return (
    <div>
      <h1>{frontMatter.title}</h1>
      <div>{content}</div>
    </div>
  );
};

export default Post;

ブラウザ上にブログの記事のタイトルとコンテンツを確認することができます。

ブラウザ上に記事の内容を表示
ブラウザ上に記事の内容を表示

記事の内容を表示することが出来ましたがファイルに保存したmarkdownのそのままの状態で表示されます。HTMLとして表示させるためにはmarkdownからHTMLヘの変換が必要になります。

MarkdownからHTMLへの変換

markdownからHTMLヘ変換するためにライブラリをインストールする必要があります。ライブラリは複数存在しているのでいくつかのライブラリで動作確認を行います。

ここでは以下のライブラリで動作確認を行います。

  • marked
  • markdown-it
  • react-remark
  • remark

markedの利用

npmコマンドでインストールを行います。


 % npm install marked

インストールが完了したらmarkedを利用してcontentの内容をHTMLに変換します。dangerouslySetInnerHTMLを利用してdivの中に変換したHTMLを挿入しています。


import fs from 'fs';
import matter from 'gray-matter';
import { marked } from 'marked';

//略

const Post = ({ frontMatter, content }) => {
  return (
    <div>
      <h1>{frontMatter.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: marked(content) }}></div>
    </div>
  );
};

export default Post;

CSSの設定が何も設定されてないのでブラウザ上に表示されている内容を確認するだけではHTMLなのかどうかわかりませんが変換前の表示とは異なることが確認できます。

HTML変換後の表示
HTML変換後の表示

ページのソースを確認することで表示されている内容がHTMLであることが確認できます。markedを利用することでmarkdownからHTMLに変換できることがわかりました。

ソースからHTMLであることを確認
ソースからHTMLであることを確認

スタイルの適用については後ほど確認します。

markdown-itの利用

npmコマンドでインストールを行います。


 % npm install markdown-it

インストールが完了したらmarkdown-itを利用してcontentの内容をHTMLに変換します。

dangerouslySetInnerHTMLを利用してdivの中に変換したHTMLを挿入しています。markedとは異なりrenderメソッドの引数にcontentを入れています。


import fs from 'fs';
import matter from 'gray-matter';
import markdownit from 'markdown-it';

//略

const Post = ({ frontMatter, content }) => {
  return (
    <div>
      <h1>{frontMatter.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: markdownit().render(content) }}></div>
    </div>
  );
};

export default Post;

ブラウザ上にはmarkedと同じ内容が表示されます。

HTML変換後の表示
HTML変換後の表示

markdown-itはプラグインを利用することで機能を拡張することができます。利用できるプラグインについてはhttps://www.npmjs.com/search?q=keywords:markdown-it-pluginから確認することができます。

react-markの利用

npmコマンドでインストールを行います。


 % npm install react-mark

react-markではコンポーネントを利用するためmarked, markdown-itのようにdangerouslySetInnerHTMLを利用しません。importしたReactMarkdownコンポーネントの間にcontentを挿入します。


import fs from 'fs';
import matter from 'gray-matter';
import ReactMarkdown from 'react-markdown';

//略

const Post = ({ frontMatter, content }) => {
  return (
    <div>
      <h1>{frontMatter.title}</h1>
      <ReactMarkdown>{content}</ReactMarkdown>
    </div>
  );
};

export default Post;

表示される内容はmarked, markdown-itを利用した時と変わりません。react-markdownでもmarkdownからHTMLに変換できることがわかりました。

remark, rehypeの利用

remark, rehypeはこれまでのライブラリとは異なりmarkdownからHTMLに変換するために複数のライブラリをimportして利用する必要があります。

先ほど利用したreact-remarkは内部でremarkを利用しています。

4つのライブラリをインストールします。


 % npm install unified remark-parse remark-rehype rehype-stringify  

4つのライブラリの役割は下記の通りです。remark-parse, remark-rehype, rehype-stringifyはプラグインとも呼ばれます。

  • unified・・・コアのパッケージでコンテンツをASTに変換するために利用します
  • remark-parse・・・markdownをinputとして受け取り、syntax tree(mdast)に変換(parser)します。mdastのmdはmarkdownの略でastはabstract syntax tree(抽象構文木)の略です。
  • remark-rehype・・・markdownのmdastからHTMLのhastに変換(transformer)します。hastはHypertext abstract syntax treeの略です。
  • rehype-stringify・・・hastをinputとして受け取り、HTMLに変換(compiler)します。

複数のライブラリが関係しているので最初は難しいと思うかもしれませが、流れはシンプルでmarkdownをmdastに変換(remark-parse)し、mdastからhastに変換(remark-rehype)、最後にhastをHTMLに変換する(rehype-stringify)だけです。

markdownからHTMLへの変換の流れがわかればその逆も可能です。HTMLからhastに変換(rehype-parse), hastからmdastに変換(rehype-remark), mdastからmarkdownに変換する(remark-stringify)ことで実現できます。

インストールしたライブラリを順番に指定していくことで、markdownからHTMLヘの変換は以下のように行うことができます。


import fs from 'fs';
import matter from 'gray-matter';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import rehypeStringify from 'rehype-stringify';

export async function getStaticProps({ params }) {
  const file = fs.readFileSync(`posts/${params.slug}.md`, 'utf-8');
  const { data, content } = matter(file);

  const result = await unified()
    .use(remarkParse)
    .use(remarkRehype)
    .use(rehypeStringify)
    .process(content);
  console.log('result:',result);

  return { props: { frontMatter: data, content } };
}
//略

変換の結果を保存したresultの内容をconsole.logで確認します。VFileという名前のオブジェクトのvalueにHTMLが入っていることがわかります。


result: VFile {
  data: {},
  messages: [],
  history: [],
  cwd: '/Users/mac/Desktop/nextjs-markdown-blog',
  value: '<p>Next.js を利用して Markdown のブログサイトを一から作成します。</p>\n' +
    '<h2>プロジェクトの作成</h2>\n' +
    '<p>npx create-next-app コマンドを利用して Next.js プロジェクトの作成を行います。</p>'
} 

VfileはtoStringメソッドでvalueの値を取り出すことができます。


const result = await unified()
  .use(remarkParse)
  .use(remarkRehype)
  .use(rehypeStringify)
  .process(content);

console.log('html:', result.toString());

toStringメソッドの結果を確認するとHTMLのみ取得できていることがわかります。


html: <p>Next.js を利用して Markdown のブログサイトを一から作成します。</p>
<h2>プロジェクトの作成</h2>
<p>npx create-next-app コマンドを利用して Next.js プロジェクトの作成を行います。

HTMLが含まれるcontetをpropsで渡しdangerouslySetInnerHTMLでブラウザ上に表示させます。


import fs from 'fs';
import matter from 'gray-matter';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import rehypeStringify from 'rehype-stringify';

export async function getStaticProps({ params }) {
  const file = fs.readFileSync(`posts/${params.slug}.md`, 'utf-8');
  const { data, content } = matter(file);

  const result = await unified()
    .use(remarkParse)
    .use(remarkRehype)
    .use(rehypeStringify)
    .process(content);

  return { props: { frontMatter: data, content: result.toString() } };
}

export async function getStaticPaths() {
  const files = fs.readdirSync('posts');
  const paths = files.map((fileName) => ({
    params: {
      slug: fileName.replace(/\.md$/, ''),
    },
  }));
  return {
    paths,
    fallback: false,
  };
}

const Post = ({ frontMatter, content }) => {
  return (
    <div>
      <h1>{frontMatter.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: content }}></div>
    </div>
  );
};

export default Post;

remarkでも他のライブラリと同様にmarkdownからHTMLに変換してブラウザ上に表示することができました。

HTML変換後の表示
HTML変換後の表示

remarkのその他のプラグインについてはhttps://github.com/remarkjs/remark/blob/main/doc/plugins.md#list-of-pluginsから確認することができます。

mdast, hastの内容

remarkを使いこなすためにmdast(markdown abstract syntax tree)、hast(hypertext abstract syntax tree)はどのような形をしているのか確認しておきましょう。

checkASTという関数を作成しプラグインとして下記のように追加します。


import { visit } from 'unist-util-visit';
//略

{
const checkAST = () => {
  return (tree) => {
    visit(tree, (node) => {
      console.log(node);
    });
  };
};
//略
  const result = await unified()
    .use(remarkParse)
   .use(checkAST) //mdastにアクセス
    .use(remarkRehype)
    .use(checkAST) //hastにアクセス
    .use(rehypeStringify)
    .process(content);

markdownの”## Next.jsの準備”がremarkParseによってmdastに変換されると下記のような構造になります。


{
  type: 'heading',
  depth: 2,
  children: [ { type: 'text', value: 'Next.js の準備', position: [Object] } ],
  position: {
    start: { line: 6, column: 1, offset: 55 },
    end: { line: 6, column: 15, offset: 69 }
  }
}
{
  type: 'text',
  value: 'Next.js の準備',
  position: {
    start: { line: 6, column: 4, offset: 58 },
    end: { line: 6, column: 15, offset: 69 }
  }
}

さらにremark-rehypeによってmdastからhastに変換されると下記の形となります。タグ名のh2を確認することができます。


{
  type: 'element',
  tagName: 'h2',
  properties: {},
  children: [ { type: 'text', value: 'Next.js の準備', position: [Object] } ],
  position: {
    start: { line: 6, column: 1, offset: 55 },
    end: { line: 6, column: 15, offset: 69 }
  }
}
{
  type: 'text',
  value: 'Next.js の準備',
  position: {
    start: { line: 6, column: 4, offset: 58 },
    end: { line: 6, column: 15, offset: 69 }
  }
}

スタイルの適用

記事ページのスタイルについてはTailwind CSSが持つTypography pluginを利用することで簡単に設定を行うことができます。

npmコマンドでプラグインのインストールを行います。


 % npm install -D @tailwindcss/typography

インストール後はtailwind.config.jsファイルに設定を追加します。


/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    './pages/**/*.{js,ts,jsx,tsx}',
    './components/**/*.{js,ts,jsx,tsx}',
  ],
  theme: {
    extend: {},
  },
  plugins: [require('@tailwindcss/typography')],
};

設定後、classNameにproseを追加します。


const Post = ({ frontMatter, content }) => {
  return (
    <div className="prose">
      <h1>{frontMatter.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: content }}></div>
    </div>
  );
};

h1, h2などのheadingタグに対するスタイルやタグ間の空白も追加されていることが確認できます。

スタイル適用後の画面
スタイル適用後の画面

Front Matterの画像の表示

Front Matterに設定した画像と日付を記事ページでも表示できるように設定を行います。フォントのサイズはprose-lg, prose-xlなどで変更を行うことができ、横幅をいっぱいに利用したい場合はmax-w-noneを利用することができます。Imageコンポーネントを利用しているのImageのimportも必要です。


//略
import Image from 'next/image';
//略

const Post = ({ frontMatter, content }) => {
  return (
    <div className="prose prose-lg max-w-none">
      <div className="border">
        <Image
          src={`/${frontMatter.image}`}
          width={1200}
          height={700}
          alt={frontMatter.title}
        />
      </div>
      <h1 className="mt-12">{frontMatter.title}</h1>
      <span>{frontMatter.date}</span>
      <div dangerouslySetInnerHTML={{ __html: content }}></div>
    </div>
  );
};

ブラウザで確認するとFront Matterの設定した画像が表示されました。

Front Matterの画像の表示
Front Matterの画像の表示

h1タグの色のみ変更を行いたいといったことも可能なのでTypographyのカスタマイズについてはhttps://tailwindcss.com/docs/typography-pluginを確認してください。

記事の追加

新たにmardownで作成した記事をpostsフォルダに保存します。記事のイメージ画像についてはpublicフォルダに保存します。

ファイル名はlaravel-vite.mdという名前にしています。publicフォルダにはlaravel.pngファイルを保存しています。


---
title: 'LaravelのデフォルトのフロントエンドアセットバンドラーはViteに'
date: '2022-07-12'
description: 'LaravelのデフォルトのフロントエンドアセットバンドラーはViteに変更したので動作確認をしています。'
image: laravel.png
---

Laravel のデフォルトのフロントエンドアセットバンドラーが Vite に変更になったので動作確認を行います。

## 目次

## Breeze のインストール

Vite の動作確認を行うため Laravel プロジェクトの作成後に Breeze パッケージのインストールを行います。

markdownファイルを保存して記事一覧を表示すると追加した記事が表示されます。先ほどgridを設定しているので2つの記事が横並びになっていることがわかります。

記事一覧ページの表示
記事一覧ページの表示

記事の間にスペースをあけるためgridのgapを設定します。pagesフォルダのindex.jsファイルを更新します。


export default function Home({ posts }) {
  return (
    <div className="my-8">
      <div className="grid grid-cols-3 gap-4">
        {posts.map((post) => (
          <PostCard key={post.slug} post={post} />
        ))}
      </div>
    </div>
  );
}

設定後、記事間にスペースが挿入されます。

記事間にスペースを挿入
記事間にスペースを挿入

追加した記事をクリックすると記事ページが表示されます。

記事ページの表示
記事ページの表示

記事の日付順表示

新しく作成された記事順に並ぶようにソートの機能を追加します。Front Matterに設定した日付のdateを利用してソートを行なっています。


export const getStaticProps = () => {
  const files = fs.readdirSync('posts');
  const posts = files.map((fileName) => {
    const slug = fileName.replace(/\.md$/, '');
    const fileContent = fs.readFileSync(`posts/${fileName}`, 'utf-8');
    const { data } = matter(fileContent);
    return {
      frontMatter: data,
      slug,
    };
  });

  const sortedPosts = posts.sort((postA, postB) =>
    new Date(postA.frontMatter.date) > new Date(postB.frontMatter.date) ? -1 : 1
  );

  return {
    props: {
      posts: sortedPosts,
    },
  };
};

ソート機能を追加後は新しい順に記事が並ぶことが確認できます。

記事をあらたしい順に並び替え
記事をあらたしい順に並び替え

SEOの設定(metaタグの設定:next-seo)

ブログが作成できたら多くの人に閲覧してもらうためにSEOの設定を行い正しい情報を検索エンジンに伝える必要があります。SEOの範囲は幅広いですがここで行うSEOはheadタグの中にmetaタグを設定することです。

本文書ではnext-seoを利用してmetaタグの設定を行います。next/headを利用することでもmetaタグの設定を行うことができます。

npmコマンドでnext-seoのインストールを行います。


 % npm install next-seo

ページ毎にmetaタグの設定を行うことができますが最初はサイト全体の初期値を設定するため_app.jsファイルで設定を行います。

初期値を設定するためプロジェクトフォルダの直下にnext-seo.config.jsファイルを作成します。各自のサイトに合わせた設定を行います。


export default {
  title: 'Next.js Blog',
  description: 'Next.jsなどの技術情報を発信するブログです。',
  openGraph: {
    type: 'website',
    locale: 'ja_JP',
    url: 'https://localhost:3000/',
    site_name: 'BLOG',
  },
  twitter: {
    handle: '@handle',
    site: '@site',
    cardType: 'summary_large_image',
  },
};

その他の設定値についてはnext-seoのサイトから確認できるので適切な値に設定をしてください。

next-seo.config.jsファイルを作成後exportしたデータを_app.jsでimportしてnext-seoからimportしたDefaultSeoコンポーネントに渡します。


import Layout from '../components/layout';
import '../styles/globals.css';
import SEO from '../next-seo.config';
import { DefaultSeo } from 'next-seo';

function MyApp({ Component, pageProps }) {
  return (
    <Layout>
      <DefaultSeo {...SEO} />
      <Component {...pageProps} />
    </Layout>
  );
}

export default MyApp;

デフォルトの設定は完了です。設定がページに反映されているか確認するためにブラウザのデベロッパーツールの要素を開いて確認します。設定した値が表示されていることが確認できます。

デフォルトのmetaタグの確認
デフォルトのmetaタグの確認

記事一覧のページを開いても記事ページを開いても設定されているmetaタグはすべて同じです。ページ毎にmetaタグの内容を変更するため[slug].jsファイルでもnext-seoの設定を行います。デフォルトと同じ項目については上書きを行ってくれます。urlでslugを利用するためgetStaticPropsからslugを渡すように追加設定を行なっています。


import fs from 'fs';
import matter from 'gray-matter';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import rehypeStringify from 'rehype-stringify';
import Image from 'next/image';
import { NextSeo } from 'next-seo';

export async function getStaticProps({ params }) {
  const file = fs.readFileSync(`posts/${params.slug}.md`, 'utf-8');
  const { data, content } = matter(file);

  const result = await unified()
    .use(remarkParse)
    .use(remarkRehype)
    .use(rehypeStringify)
    .process(content);

  return {
    props: { frontMatter: data, content: result.toString(), slug: params.slug },
  };
}

//略

const Post = ({ frontMatter, content, slug }) => {
  return (
    <>
      <NextSeo
        title={frontMatter.title}
        description={frontMatter.description}
        openGraph={{
          type: 'website',
          url: `http:localhost:3000/posts/${slug}`,
          title: frontMatter.title,
          description: frontMatter.description,
          images: [
            {
              url: `https://localhost:3000/${frontMatter.image}`,
              width: 1200,
              height: 700,
              alt: frontMatter.title,
            },
          ],
        }}
      />
      <div className="prose prose-lg max-w-none">
      //略
      </div>
    </>
  );
};

export default Post;

設定後、記事ページでmetaタグの確認を行います。og:imageのなども設定され、[slug].jsで設定した値については上書きされ、設定してない項目についてはデフォルトで設定した値が設定されていることが確認できます。

記事ページのmateタグの確認
記事ページのmateタグの確認

目次の追加

特に記事の内容が長い場合に重宝される目次の設定を行なっていきます。目次はremarkのプラグインのremark-toc(table of contetns)を利用することで設定することができます。

npmコマンドでremark-tocのインストールを行います。


 % npm install remark-toc

remark-tocを利用するためには2つの設定を行う必要があります。

1つ目は[slug].jsファイルでインストールしたremarkTocをimportしてuseでremarkParseのプラグインの設定の下に追加することです。2つ目がmarkdownファイルの中に目次を挿入したい場所に”## Table of Contents”もしくは”## toc”を入力することです。remarkParseの下に追加する理由はmarkdownからparseしたmdastの情報からheadingの情報を取得して目次を作成するためです。


//略
import remarkToc from 'remark-toc';
//略
const result = await unified()
  .use(remarkParse)
  .use(remarkToc)
  .use(remarkRehype)
  .use(rehypeStringify)
  .process(content);
//略

Front Matterの下に## Table of Contentsを追加します。


---
title: 'Next.jsでmarkdownブログを構築'
date: '2022-07-13'
description: 'Next.jsでmarkdownファイルを利用したブログの構築手順を解説しています。'
image: nextjs.png
---

## Table of Contents

Next.js を利用して Markdown のブログサイトを一から作成します。
//略

ブラウザで確認すると下記のように目次が表示されます。markdownに設定したTable of Contensが表示されその下に目次が表示されます。

目次の表示
目次の表示

Table of Contentsという名前を変更したい場合はremarkTocのオプションのheadingを利用することで変更が可能です。


.use(remarkToc, {
  heading: '目次',
})

headingを変更した場合はmarkdownファイル側で設定した”## Table of Contents”を”## 目次”に変更する必要があります。


---
title: 'Next.jsでmarkdownブログを構築'
date: '2022-07-13'
description: 'Next.jsでmarkdownファイルを利用したブログの構築手順を解説しています。'
image: nextjs.png
---

## 目次

Next.js を利用して Markdown のブログサイトを一から作成します。
//略

設定を変更すると”Table of Contents”が”目次”に変わったことが確認できます。

Table of Contentsから目次に変更
Table of Contentsから目次に変更

目次の項目はリンクが貼られているためaタグが設定されていますがクリックしても何も変化がありません。例えばNext.jsの準備は”http://localhost:3000/posts/next-js-markdown-blog#nextjs-の準備”へリンクが貼られていますがリンク先のidが設定されていません。

headingにidを設定する必要があります。idはrehype-slugプラグインを利用することで付与することができます。

npmコマンドをrehype-slugをインストールします。


 % npm install rehype-slug

rehype-slugはremarkRehypeの下に設定します。


//略
import rehypeSlug from 'rehype-slug';
//略
const result = await unified()
  .use(remarkParse)
  .use(remarkToc, {
    heading: '目次',
  })
  .use(remarkRehype)
  .use(rehypeSlug)
  .use(rehypeStringify)
  .process(content);

heading(h2, h3)にidが付与されていることが確認できます。

headingへのidの付与
headingへのidの付与

目次の項目をクリックするとクリックした項目場所に移動できるようになります。

項目への移動はできるようになりましたが現在の設定ではHeaderコンポーネントでヘッダーをstickyで固定されているため移動した際にheadingの内容がヘッダーの下に隠れます。隠れるのを防ぐためにstylesフォルダのglobal.cssファイルを以下のように設定します。


@tailwind base;
@tailwind components;
@tailwind utilities;

:target::before {
  content: '';
  display: block;
  height: 72px;
  margin-top: -72px;
  visibility: hidden;
}

コードのハイライト

ブログの記事の中でコードを記述する必要がない場合にや役に立ちませんが、本ブログのようにプログラムのコードを記述する場合はコードハイライトを利用することでコードがカラフルになり読みやすくなります。コードハイライトを利用するためのライブラリは複数存在しますがここではPrism.jsを利用します。

Prism.jsをremarkで利用するためにremark-prismaというプラグインがあります。プラグインを利用することでコードのハイライトも簡単に設定を行うことができます。

markdownでコードを記述する場合は下記のようにバッククォートを利用します。バッククォート3つの後ろに追加したjsはjavascriptの略で記述しているコードがJavaScriptであることを示しています。


//略
```js
import Layout from '../components/layout';
import '../styles/globals.css';
import '../styles/prism.css';
import SEO from '../next-seo.config';
import { DefaultSeo } from 'next-seo';

function MyApp({ Component, pageProps }) {
  return (
    <Layout>
      <DefaultSeo {...SEO} />
      <Component {...pageProps} />
    </Layout>
  );
}

export default MyApp;
```
//略

Tailwind CSSを利用しているのでコードをハイライトを利用していないデフォルト時でもブラウザ上では下記のように表示されます。

Tailwind CSSを利用している場合
Tailwind CSSを利用している場合

コードハイライトを利用するとどのように変化するか確認します。remark-prismをnpmコマンドを利用してインストールします。


 % npm install remark-prism

インストールしたremark-prismをremarkPaseの後ろに追加します。


//略
import remarkPrism from 'remark-prism';
//略
const result = await unified()
  .use(remarkParse)
  .use(remarkPrism, {
    /* options */
  })
  .use(remarkToc, {
    heading: '目次',
  })
  .use(remarkRehype)
  .use(rehypeStringify)
  .process(content);

remarkPrismaを設定しただけではブラウザ上には何も変化はありませんがページのソースコードを見ると記述したコードの中身が複数の要素に分けられそれぞれの要素にclassが設定されていることがわかります。token, keyword, module, string, punctuationというclassが確認できます。

remark-prism設定後のページソースの確認
remark-prism設定後のページソースの確認

コードのハイライトにはremark-prismにより変換に加えCSSが必要になります。prisma.jsには8つのthemeがありnode_modulesのprism\themesに保存されているcssを利用することでthemeのCSSを適用することができます。ここではOKAIDIAというthemeを利用します。

_app.jsファイルでCSSファイルのimportを行います。


import Layout from '../components/layout';
import '../styles/globals.css';
import 'prismjs/themes/prism-okaidia.css';
//略

themeをimport後に再度ブラウザで確認するとコードがハイライトされていることが確認できます。下記がブラウザ上で表示される内容です。

Prism.jsによるコードのハイライト
Prism.jsによるコードのハイライト

コードの行番号をつけたい場合はプラグインを利用することができます。remarkPrismのoptionにline-numbersを下記のように追加します。


const result = await unified()
  .use(remarkParse)
  .use(remarkPrism, {
    plugins: ['line-numbers'],
  })
  //略

プラグインを追加しただけでは動作せずmarkdownファイルにも追加が必要です。“`jsの横に[class=”line-numbers”]を追加します。


```js[class="line-numbers"]
import Layout from '../components/layout';
//略
```

この設定でブラウザのデベロッパーツールでページのソースを見るとpreのタグの中にline-numbersが追加されていることがわかります。設定前はpreタグにはlanguage-javascriptのみclassが設定されています。


<pre class="language-javascript  line-numbers">

行番号を表示させるためにCSSを適用することで設定は完了です。


import Layout from '../components/layout';
import '../styles/globals.css';
import 'prismjs/themes/prism-okaidia.css';
import 'prismjs/plugins/line-numbers/prism-line-numbers.css';
//略

ブラウザで確認すると左側に行番号が表示されていることが確認できます。

コードに行番号を表示
コードに行番号を表示

リンクでnext/linkを利用

markdownの記事の中にリンクを設定したい場合には下記のように設定します。[]の中にリンクの中の文字、()の中にリンク先を設定します。下記では記事一覧というリンクが設定され、リンク先は/(ルート)です。


[記事一覧](/)

ブラウザで確認すると記事一覧の文字列にリンクが貼られますがリンクをクリックするとページの再読み込みが行われ記事の一覧ページが表示されます。markdownの中にサイト内のリンクを設定した場合はnext/linkを利用することでページ再読み込みなしにスムーズにページ移動が行えるように設定を行います。

設定はaタグをLinkタグ(+aタグ)に変換することで行います。変換に利用するのはrehypeのプラグインのrehype-reactです。rehype-reactはHTMLをReactノードに変換する(compiler)ことができます。HTMLからReact Nodeに変換する際にaタグをLinkタグに変換します。

rehype-reactで変換する前にrehype-paseでHTMlLをhast(HyperText abstract syntax tree)に変換(transfer)しておく必要があります。

npmコマンドでrehype-reactとrehype-parseのインストールを行います。


 % npm install rehype-parse rehype-react

変換後のReact NodeをgetStaticPropsのpropsで渡すことはできないのでtoReactNode関数を追加します。toReactNode関数ではpropsで渡されたcontent(HTML)を引数に取りHTMLからReact Nodeへの変換を行います。


<div className="prose prose-lg max-w-none">
  <div className="border">
    <Image
      src={`/${frontMatter.image}`}
      width={1200}
      height={700}
      alt={frontMatter.title}
    />
  </div>
  <h1 className="mt-12">{frontMatter.title}</h1>
  <span>{frontMatter.date}</span>
  {toReactNode(content)} //変更
</div>

toReactNodeの変換の処理の中でrehype-reactのオプションにcreatElementを設定しないとエラーになるので設定を行っておきます。現時点ではaタグからLinkタグへの変換は行っていません。


//略
import { createElement } from 'react';
import rehypeParse from 'rehype-parse';
import rehypeReact from 'rehype-react';
//略
const toReactNode = (content) => {
  return unified()
    .use(rehypeParse)
    .use(rehypeReact, {
      createElement,
    })
    .processSync(content).result;
};

ブラウザで動作確認すると以下のエラーが発生します。


Unhandled Runtime Error
Error: Hydration failed because the initial UI does not match what was rendered on the server.

See more info here: https://nextjs.org/docs/messages/react-hydration-error

理由はrehype-parseのfragmentオプションがデフォルトのfalseに設定されているためhtml, head, bodyタグが作成されるReact Nodeと一緒に追加されていることです。デベロッパーツールの要素を確認するとそれらのタグを確認できます。

html, head, bodyタグが追加されていることの確認
html, head, bodyタグが追加されていることの確認

追加されるhtml, head, bodyタグはページのソースでも確認できます。

html, head, bodyを追加させないためにオプションのfragmentをtrueにします。


const toReactNode = (content) => {
  return unified()
    .use(rehypeParse, {
      fragment: true,
    })
    .use(rehypeReact, {
      createElement,
    })
    .processSync(content).result;
};

エラーが解消されブラウザ上にはtoReactNode関数を利用したReact Nodeへの変換前と同じ内容が表示されます。fragmentをtrueに変更した後の変換後のDOMの状態を確認するとhtml, head, bodyタグは消えています。

fragmentオプション設定後
fragmentオプション設定後

現在の表示に影響はありませんが外側にdiv要素が追加されているのでこのdiv要素を削除したい場合には以下のようにrehype-reactのオプションにFragmentを追加します。


import { createElement, Fragment } from 'react';
//略
const toReactNode = (content) => {
  return unified()
    .use(rehypeParse, {
      fragment: true,
    })
    .use(rehypeReact, {
      createElement,
      Fragment,
    })
    .processSync(content).result;
};

これでdiv要素は削除されます。

useEffect, useStateを利用した場合

rehype-reactのgithubのページを確認するとrehype-parseとrehype-reactの利用方法としてuseEffect, useStateを利用した方法がUseで掲載されています。

ドキュメントのUseを確認
ドキュメントのUseを確認

toReactNode関数を上記の方法に書き換えることもできます。書き換えた後もこれまでと同様にブラウザ上には記事の内容が表示されますがReactNodeへの変換作業がブラウザ側で行われるためソースコードを確認するとReactNodeの内容は含まれていません。


function toReactNode(content) {
  const [Content, setContent] = useState(Fragment);

  useEffect(() => {
    const processor = unified()
      .use(rehypeParse, {
        fragment: true,
      })
      .use(rehypeReact, {
        createElement,
        Fragment,
      })
      .processSync(content);

    setContent(processor.result);
  }, [content]);

  return Content;
}

aタグからLinkタグへの変換

hype-parseとhype-reactによるHTMLからReact Nodeへの変換の確認が行えたのでaタグからLinkタグへの変換処理を追加します。設定方法についてはrehype-reactのgithubのページのoptionsのcomponentsに記載されています。

optionのcomponentsの確認
optionのcomponentsの確認

aタグに限らず、pタグをReactコンポーネントに変換できることがわかります。

aタグを変換するためにMyLink関数を追加します。


import Link from 'next/link';
//略
const MyLink = ({ children, href }) => {
  return (
    <Link href={href}>
      <a>{children}</a>
    </Link>
  );
};

rehype-reactのoptionsのcomponentに追加したMyLinkを設定します。


const toReactNode = (content) => {
  return unified()
    .use(rehypeParse, {
      fragment: true,
    })
    .use(rehypeReact, {
      createElement,
      Fragment,
      components: {
        a: MyLink,
      },
    })
    .processSync(content).result;
};

設定後はサイト内のリンクをクリックしてもページの再読み込みを行わずスムーズなページ移動ができます。目次についてもリンクが貼られていますがこの設定によってクリックしてもページの再読み込みを行われません。

サイト内のリンクと外部へのリンクを分けたい場合は分岐を追加することで指定した条件に応じてLinkタグかaタグを利用するかを切り替えることができます。


function MyLink({ children, href }) {
  if (href === '') href = '/';
  return href.startsWith('/') || href.startsWith('#') ? (
    <Link href={href}>
      <a>{children}</a>
    </Link>
  ) : (
    <a href={href} target="_blank" rel="noopener noreferrer">
      {children}
    </a>
  );
}

markdownのリンク設定でURLを設定していない場合には/(ルート)が設定されてるようにしています。またURLの先頭に”/”か”#”の場合のみLinkタグに変更するように設定しています。#で始まるURLは目次のリンク先の設定で利用されているために設定を行なっています。目次を利用しない場合やページ内のリンクを設定しない場合には”#”の条件は必要ではありません。

画像にnext/imgを利用

markdownファイルの中で利用する画像を準備してpublicフォルダに保存します。ここではnextjs-welcome.pngファイルを利用します。

画像をmarkdownファイル内で利用するため下記の設定を行います。[]にはalt属性、()には画像の保存先を指定します。


![Next.jsのWelcomeページ](http://localhost:3000/nextjs-welcome.png)

上記の設定によりページ内に画像は設定されますがページのソースコードを確認するとimgタグが利用されていることがわかります。


<img src="http://localhost:3000/nextjs-welcome.png" alt="Next.jsのWelcomeページ"/>

imgタグをImageコンポーネントに変換できるように設定を行います。設定方法についてはLinkタグを設定した場合と同じです。imgタグを変換するためにMyImage関数を追加します。MyImage関数ではpropsでsrc, altを受け取ることができます。Imageコンポーネントではwidthとheightが必須なのでwidthとheightを設定しておきます。


import Image from 'next/image';
//略
const MyImage = ({ src, alt }) => {
  return <Image src={src} alt={alt} width="1200" height="700" />;
};

rehype-reactのoptionsのcomponentsに作成したMyImageを追加します。


const toReactNode = (content) => {
  return unified()
    .use(rehypeParse, {
      fragment: true,
    })
    .use(rehypeReact, {
      createElement,
      Fragment,
      components: {
        a: MyLink,
        img: MyImage,
      },
    })
    .processSync(content).result;
};

設定後ブラウザを確認すると以下のエラーが発生します。


Error: Invalid src prop (http://localhost:3000/nextjs-welcome.png) on `next/image`, hostname "localhost" is not configured under images in your `next.config.js`
See more info: https://nextjs.org/docs/messages/next-image-unconfigured-host

エラーを解消するためnext.config.jsにlocalhost:3000の設定を追加します。


/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  images: {
    domains: ['localhost'],
  },
};

module.exports = nextConfig;

next.config.jsファイルの更新後はnpm run devコマンドを再実行してください。

再実行後ブラウザでアクセスを行います。先ほどとは異なりデベロッパーツールを確認するとImageコンポーネントが設定されていることがわかりますがwidthとheightを画像の本来のサイズに合わせていないので画像が横長になっています。

変換後のimgタグの内容
変換後のimgタグの内容

MyImageのImageタグに本来の画像のサイズのwidthとheightを設定すると横長の問題は解消され綺麗に表示されます。利用する画像のサイズを決めている場合はwidthとheightに固定値を設定することで問題がありません。しかし画像のサイズが異なるため画像毎にサイズを指定したい場合はmarkdownではなくimgタグを利用することで設定は可能です。


<img src="http://localhost:3000/nextjs-welcome.png" alt="Next.jsのWelcomeページ" width="1024" height="679" />

imgタグから渡されるwidthとheightがImageコンポーネントで利用できるようにMyImageコンポーネントの更新を行います。


const MyImage = ({ src, alt, ...props }) => {
  return <Image src={src} alt={alt} {...props} />;
};
// or 
const MyImage = ({ src, alt, width, height}) => {
  return <Image src={src} alt={alt} width={width} height={height} />;
};

しかし設定後ブラウザで確認すると画像が表示されません。

markdownファイルにHTMLのimgタグを追加しimgタグとして利用するためには追加設定が必要となります。markdownからHTMLに変換するremarkRehypeとrehypeStringifyのオプションにallowDangerousHTMLを追加し値をtrueとします。


  const result = await unified()
    .use(remarkParse)
    .use(remarkPrism, {
      plugins: ['line-numbers'],
    })
    .use(remarkToc, {
      heading: '目次',
      tight: true,
    })
    .use(remarkUnwrapImages)
    .use(remarkRehype, { allowDangerousHtml: true })
    .use(rehypeSlug)
    .use(rehypeStringify, { allowDangerousHtml: true })
    .process(content);

設定完了後ブラウザを確認するとmarkdownのimgタグで指定したサイズで画像が表示されます。

remarkRehypeのみでallowDangerousHtmlをtrueにするとブラウザ上にはそのままimgタグが表示されます。rehypeStringifyのみでallowDangerousHtmlをtrueにするとブラウザ上には何も表示されません。画像を表示するためにはどちらの設定も必要です。

Imageコンポーネントではlayoutにfill、objectFitにcontainを利用することでwidthとheightを設定しなくてもエラーが発生することはありません。layoutにfillを利用する場合はpositionのrelativeとwidthとheightを親要素に設定する必要があります。


const MyImage = ({ src, alt }) => {
  return (
    <div className="relative max-w-full h-96">
      <Image src={src} alt={alt} layout="fill" objectFit="contain" />
    </div>
  );
};

上記の場合はmarkdownファイルでimgタグにwidthとheightを設定する必要はありません。markdownの画像の設定方法を行うことで画像が表示されます。

カテゴリー一覧ページの設定

/(ルート)にアクセスするとすべての記事の一覧が表示されていましたが記事毎にカテゴリーを設定することでカテゴリー一覧ページに記事が表示できるように設定を行なっていきます。

markdownファイルのFront Matterでカテゴリーを設定できるようにcategoriesを追加して複数のカテゴリーを設定できるように配列で設定を行います。


---
title: 'Next.jsでmarkdownブログを構築'
date: '2022-07-13'
description: 'Next.jsでmarkdownファイルを利用したブログの構築手順を解説しています。'
image: nextjs.png
categories: ['react']
---

カテゴリー一覧ページを作成するためには新たにページを追加する必要があります。pagesフォルダの中にcategoriesフォルダを作成し[category].jsファイルを作成します。/categories/の後ろにカテゴリー名を設定することでそれぞれのカテゴリーに属する記事の一覧を表示させるためページのファイル名は[]で囲んだダイナミックルーティングを利用します。

[category].jsではgetStaticPropsとgetStaticPathを利用します。


import fs from 'fs';
import matter from 'gray-matter';
import PostCard from '../../components/PostCard';

export const getStaticProps = ({ params }) => {
  const files = fs.readdirSync('posts');
  const posts = files.map((fileName) => {
    const slug = fileName.replace(/\.md$/, '');
    const fileContent = fs.readFileSync(`posts/${fileName}`, 'utf-8');
    const { data } = matter(fileContent);
    return {
      frontMatter: data,
      slug,
    };
  });

  const category = params.category;

  const filteredPosts = posts.filter((post) => {
    return post.frontMatter.categories.includes(category);
  });

  const sortedPosts = filteredPosts.sort((postA, postB) =>
    new Date(postA.frontMatter.date) > new Date(postB.frontMatter.date) ? -1 : 1
  );

  return {
    props: {
      posts: sortedPosts,
    },
  };
};

export const getStaticPaths = () => {
  const categories = ['react', 'laravel'];
  const paths = categories.map((category) => ({ params: { category } }));

  return {
    paths,
    fallback: false,
  };
};

const Category = ({ posts }) => {
  return (
    <div className="my-8">
      <div className="grid grid-cols-3 gap-4">
        {posts.map((post) => (
          <PostCard key={post.slug} post={post} />
        ))}
      </div>
    </div>
  );
};

export default Category;

getStaticPropsの処理はindex.jsと同様にpostsファイルからmarkdownファイルをすべて読み込みFront Matterの情報を取得しています。index.jsとの違いは取得した記事の中からURLのパラメータから取得したcategoryを利用してfilter関数でcategoryが設定されている記事のみ取り出している箇所(filteredPosts関数)です。

getStaticPathsではcategories変数を定義して記事のcategoriesで利用可能なカテゴリーを配列で設定し、map関数でpathの設定を行なっています。カテゴリーを手動で指定していますがFront Matterを利用することでmarkdownファイルから取得して設定することも可能です。

[category].jsファイルの設定が完了後はhttp://localhost:3000/categories/reactにブラウザからアクセスするとmarkdownファイルの中からcategoriesの配列に’react’を指定した記事のみ表示されます。

記事ページの中で設定したカテゴリーを日付の下で表示させることでカテゴリー一覧ページへのリンクを設定することができます。


<div className="prose prose-lg max-w-none">
  <div className="border">
    <Image
      src={`/${frontMatter.image}`}
      width={1200}
      height={700}
      alt={frontMatter.title}
    />
  </div>
  <h1 className="mt-12">{frontMatter.title}</h1>
  <span>{frontMatter.date}</span>
  <div className="space-x-2">
    {frontMatter.categories.map((category) => (
      <span key={category}>
        <Link href={`/categories/${category}`}>
          <a>{category}</a>
        </Link>
      </span>
    ))}

ブラウザで確認すると Front Matterのcategoriesでreactを設定した場合は下記のようにreactが表示されリンクが設定されます。

カテゴリーを記事ページに表示
カテゴリーを記事ページに表示

サイドバーに目次表示

プラグインのremark-tocを利用して記事の中に目次を表示することができますが記事とは別にサイドバーに目次を表示したい場合の方法を確認していきます。

remark-tocのソースコードを確認するとremark-tocの中で目次を作成しているは別のライブラリmdast-util-tocです。

remark-tocとは別に目次を作成するためmdast-util-tocを利用したプラグインを作成します。プラグインといっても内容はmdast-util-tocからimportしたtocにnodeとoptionsを設定するだけです。optionsについてはremark-tocで設定できるものと同じです。プラグインの名前はgetTocとしています。


//略
import { toc } from 'mdast-util-toc';

const getToc = (options) => {
  return (node) => {
    const result = toc(node, options);
    node.children = [result.map];
  };
};

目次だけを作成する際もmarkdownから目次のHTMLを作成することになるのでremark-parse, remark-rehype, rehype-stringifyを利用します。目次の作成はgetStaticPropsの中で行い、markdownから変換されて出力される情報はgetTocのプラグインを途中で挟むことで目次を持つHTMLとなります。


const toc = await unified()
  .use(remarkParse)
  .use(getToc, {
    heading: '目次',
    tight: true,
  })
  .use(remarkRehype)
  .use(rehypeStringify)
  .process(content);

作成した目次のHTMLはtocとしてpropsでコンポーネントに渡します。


return {
  props: {
    frontMatter: data,
    content: result.toString(),
    toc: toc.toString(), //追加
    slug: params.slug,
  },
};

作成した目次はサイドバーに表示させるためgridを利用して記事の内容の列とサイドバーの列を設定します。grid-cols-12で縦を12のコラムに分け、記事の内容には9コラムの幅、サイドバーには3コラムの幅を設定しています。サイドバーに表示する目次にstickyを設定することでページをスクロールしてもサイドバーに固定されて表示されるように設定しています。top-[50px]はヘッダーに隠れないように設定しています。


<div className="prose prose-lg max-w-none">
  <div className="border">
    <Image
      src={`/${frontMatter.image}`}
      width={1200}
      height={700}
      alt={frontMatter.title}
    />
  </div>
  <h1 className="mt-12">{frontMatter.title}</h1>
  <span>{frontMatter.date}</span>
  <div className="space-x-2">
    {frontMatter.categories.map((category) => (
      <span key={category}>
        <Link href={`/categories/${category}`}>
          <a>{category}</a>
        </Link>
      </span>
    ))}
  </div>
  <div className="grid grid-cols-12">
    <div className="col-span-9">{toReactNode(content)}</div>
    <div className="col-span-3">
      <div
        className="sticky top-[50px]"
        dangerouslySetInnerHTML={{ __html: toc }}
      ></div>
    </div>
  </div>
</div>

ブラウザで確認するとサイドバーが表示されます。目次は記事の上部とサイドバーの2箇所に設定しています。

サイドバーに表示された目次の確認
サイドバーに表示された目次の確認

ページネーションの実装

ブログの記事が増えてくるとページネーションの追加が必要になってきます。ページネーションの機能の追加を行なっていきます。

ページ数が少ないとページネーションの動作確認ができないので本文書ではmarkdownファイルを追加し記事を5にします。追加が完了すると記事一覧には5つの記事が表示されます。

記事を追加
記事を追加

ページネーションを設定して1つのページに2つの記事を表示するように設定を行うので設定後の画面は以下のようになります。

ページネーション追加後の画面
ページネーション追加後の画面

ページの追加

各ページへアクセスするためのURLは/page/1, /page/2, /page/3となるように設定を行うためpagesフォルダにpageフォルダを作成してその下に[page].jsファイルを作成します。/page/の後ろの数字は動的に変わるのでダイナミックルーティングを利用します。

ページネーションの理解

ページネーションを追加するためにはどのようにページネーションが動作するのか理解しておく必要があります。ここで作成するページネーションはシンプルなので簡単です。

最初にブログの記事数を取得する必要があります。記事数を取得したら1つのページにいくつの記事を表示させるかを決めます。1つのページに表示する記事数をPAGE_SIZEとします。PAGE_SIZEで記事数を割ることでページネーションで利用するページ数がわかります。

例えばブログ全体で5つの記事があり、PAGE_SIZEを2と設定した場合は5/2=2.5で少数を切り上げて3となります。この3がページ数でURLは/page/1, /page/2, /page/3となります。

ページ数がわかったら各URLにアクセスした場合に表示させる記事を取得する必要があります。今回のブログシステムのように記事の情報が配列に入っている場合はPAGE_SIZEが2とした場合は配列の0, 1に入っている記事を表示することになります。/pages/2の場合は配列の2,3に入っている記事を表示することになります。

外部のリソースに記事が保存されている場合で外部のリソースから取得できる記事の範囲を指定できる場合はその機能を利用して必要な記事のみ取得します。

基本的な考え方は上記の通りです。この考え方を利用してコードを記述していきます。

ページネーションの実装

[page].jsではダイナミックルーティングを利用しているのでgetStaticPropsとgetStaticPathsを設定する必要があります。

getStaticPropsではページネーションのページを作成するために必要なURL(/page/1, /page/2, …)を設定する必要があるため下記のようなコードとなります。


import fs from 'fs';
//略
const PAGE_SIZE = 2;

const range = (start, end, length = end - start + 1) =>
  Array.from({ length }, (_, i) => start + i);

export async function getStaticPaths() {
  const files = fs.readdirSync('posts');
  const count = files.length;

  const paths = range(1, Math.ceil(count / PAGE_SIZE)).map((i) => ({
    params: { page: i.toString() },
  }));

  return {
    paths,
    fallback: false,
  };
}
//略

PAGE_SIZEで1ページに表示する記事数を設定しています。通常は2よりも大きな数になるはずです。range関数を定義していますがこれは引数にstartとendを設定すると配列を戻すだけの関数です。

range(1,3)と設定すると戻される値は配列[1,2,3]です。

全体の記事数はfs.readdirSyncで/postsフォルダにあるmarkdownファイルの情報を配列で取得できるのでlengthを利用して取得しています。

range関数、全体の記事数とPAGE_SIZEを利用するとpathsは下記のような値となります。


[
  { params: { page: '1' } },
  { params: { page: '2' } },
  { params: { page: '3' } }
]

getStaticPropsではページに表示する記事の情報を取得します。これまでに作成したindex.jsや[category].jsと内容はほとんど同じです。異なるのはソートされた記事からsliceメソッドを利用して表示する記事を取り出している箇所です。current_pageはparams.pageから取得することができます。


export async function getStaticProps({ params }) {
  const current_page = params.page;
  const files = fs.readdirSync('posts');
  const posts = files.map((fileName) => {
    const slug = fileName.replace(/\.md$/, '');
    const fileContent = fs.readFileSync(`posts/${fileName}`, 'utf-8');
    const { data } = matter(fileContent);

    return {
      frontMatter: data,
      slug,
    };
  });

  const pages = range(1, Math.ceil(posts.length / PAGE_SIZE));

  const sortedPosts = posts.sort((postA, postB) =>
    new Date(postA.frontMatter.date) > new Date(postB.frontMatter.date) ? -1 : 1
  );

  const slicedPosts = sortedPosts.slice(
    PAGE_SIZE * (current_page - 1),
    PAGE_SIZE * current_page
  );

  return {
    props: {
      posts: slicedPosts,
      pages,
      current_page,
    },
  };
}

current_pageとページネーションをブラウザで表示するために利用するページ番号が配列で入ったpagesをpropsでコンポーネントに渡しています。

Paginationコンポーネント

propsで渡されるpagesとcurrent_pageを利用してページネーションをブラウザ上に表示させるためcomponentsフォルダにPagination.jsファイルを作成します。

下記のコードを記述します。内容はページ番号の入ったpagesの配列をmap関数で展開しています。LinkコンポーネントのhrefにはページのURLを設定しています。現在閲覧しているページがわかるように背景色と文字色を区別しています。


import Link from 'next/link';

const Pagination = ({ pages, current_page = 1 }) => {
  return (
    <div class="flex items-center space-x-1 mt-8">
      {pages.map((page) => (
        <Link href={`/page/${page}`} key={page}>
          <a
            className={`px-4 py-2 border hover:bg-black hover:text-white ${
              current_page == page && 'bg-black text-white'
            }`}
          >
            {page}
          </a>
        </Link>
      ))}
    </div>
  );
};

export default Pagination;

作成したPaginetionコンポーネントを[page].jsファイルでimportして利用します。


import fs from 'fs';
import matter from 'gray-matter';
import Pagination from '../../components/Pagination';
import PostCard from '../../components/PostCard';

const PAGE_SIZE = 2;

const range = (start, end, length = end - start + 1) =>
  Array.from({ length }, (_, i) => start + i);

export async function getStaticProps({ params }) {
//略
}

export async function getStaticPaths() {
//略
}

const Page = ({ posts, pages, current_page }) => {
  return (
    <div className="my-8">
      <div className="grid grid-cols-3 gap-4">
        {posts.map((post) => (
          <PostCard key={post.slug} post={post} />
        ))}
      </div>
      <Pagination pages={pages} current_page={current_page} />
    </div>
  );
};

export default Page;

設定完了後に/page/2にアクセスすると以下の画面が表示されます。

/page/2へのアクセス
/page/2へのアクセス

/page/1, /page/2, /page/3にアクセスするとそれぞれ別の記事が表示されます。/page/4にアクセスするとページが存在しないのでNot Found 404が表示されます。/(ルート)にアクセスするとページネーションは表示されないので表示されるように設定が必要になります。

/(ルート)にアクセスした場合にもページネーションが表示されるようにindex.jsを更新します。sliceメソッドを利用して取得した記事の配列の中の先頭からPAGE_SIZE分のみ表示するようにしています。


import fs from 'fs';
import matter from 'gray-matter';
import Pagination from '../components/Pagination';
import PostCard from '../components/PostCard';

const PAGE_SIZE = 2;

const range = (start, end, length = end - start + 1) =>
  Array.from({ length }, (_, i) => start + i);

export const getStaticProps = () => {
  const files = fs.readdirSync('posts');
  const posts = files.map((fileName) => {
    const slug = fileName.replace(/\.md$/, '');
    const fileContent = fs.readFileSync(`posts/${fileName}`, 'utf-8');
    const { data } = matter(fileContent);
    return {
      frontMatter: data,
      slug,
    };
  });

  const sortedPosts = posts.sort((postA, postB) =>
    new Date(postA.frontMatter.date) > new Date(postB.frontMatter.date) ? -1 : 1
  );

  const pages = range(1, Math.ceil(posts.length / PAGE_SIZE));

  return {
    props: {
      posts: sortedPosts.slice(0, PAGE_SIZE),
      pages,
    },
  };
};

export default function Home({ posts, pages }) {
  return (
    <div className="my-8">
      <div className="grid grid-cols-3 gap-4">
        {posts.map((post) => (
          <PostCard key={post.slug} post={post} />
        ))}
      </div>
      <Pagination pages={pages} />
    </div>
  );
}

設定が完了すると/page/1,/page/2,..だけではなく/(ルート)にアクセスしてもページネーションが表示されます。

ページネーション追加後の画面
ページネーション追加後の画面

カスタムコード機能

Markdownファイルの中に[hello]のよう文字を入力することでブラウザ上には”Hello World”と表示させるような決められた文字列を入力することでその文字列に応じた処理を行えるような機能を追加します。WordPressを利用したことがある人であればショートカットのような機能をイメージしてください。

同じような機能を持つものにremark-custom-blocksというプラグインがあります。原因は調べていませんがうまく動作しませんでした。

ここではプラグインを作成してhast(hyper text abstract syntax tree)に直接変更を書き変えるのでmarkdownからHTMLに変換されるのかの理解度が深まります。

プラグインの作成

[hello]という文字列をmarkdownファイルに追加した場合にhastではどのような情報として保存されているのか確認するためにcustomCode関数を作成します。


import { visit } from 'unist-util-visit';
//略
const customCode = () => {
  return (tree) => {
    visit(tree, (node) => {
      console.log(node);
    });
  };
};

作成した関数はプラグインとしてmdastからhastへの変換を行うremarkRehypeの下に設定を行います。


const result = await unified()
  .use(remarkParse)
  //略
  .use(remarkRehype)
  .use(customCode)
  //略

どのmarkdownファイルでもいいので[hello]を記述してください。ブラウザから[hello]を追加したmarkdownファイルに対応する記事にアクセスを行います。コンソールには下記のようなASTの情報が複数表示されます。[hello]に対応するASTもその情報の中に必ず含まれています。


{
  type: 'element',
  tagName: 'p',
  properties: {},
  children: [ { type: 'text', value: '[hello]', position: [Object] } ],
  position: {
    start: { line: 8, column: 1, offset: 26 },
    end: { line: 8, column: 8, offset: 33 }
  }
}
{
  type: 'text',
  value: '[hello]',
  position: {
    start: { line: 8, column: 1, offset: 26 },
    end: { line: 8, column: 8, offset: 33 }
  }
}

[hello]はpタグの要素として設定されていることがわかります。ブラウザ上では現段階では[hello]という文字列でそのまま表示され、ソースを確認するとpタグで囲まれていることがわかります。

valueに設定されている文字列がブラウザ上に表示されている文字列に対応します。valueの値を[hello]から”Hello World”に変更する必要があります。そのためにtypeがelementでtagNameがpでchildrenに入る1番目の要素のvalueが[hello]のものを探します。

visit関数の第2引数に’element’を設定することでtypeがelementのものだけ取得することができます。


const customCode = () => {
  return (tree) => {
    visit(tree, 'element', (node) => {
      console.log(node);
    });
  };
};

これでtypeが’element’を持つものだけコンソールに表示されます。typeが’element’のものの中でtagNameに’p’が設定されておりchildren[0]のtypeに’text’を持つものを残し、その中からvalueの先頭に[hello]が入っているnodeを取得します。[hello]を別の文字列Hello Worldに変更するため取得したnodeのchildren[0]のvalueにHello Worldを設定します。


const customCode = () => {
  return (tree) => {
    visit(tree, 'element', (node) => {
      if (node.tagName === 'p' && node.children[0].type === 'text') {
        if (node.children[0].value.startsWith('[hello]')) {
          node.children[0].value = 'Hello World';
        }
      }
    });
  };
};
文の途中に[hello]が入っている場合はvalueの先頭に[hello]という条件に一致しないため”Hello World”に変換されることはありません。

設定後はmarkdownに記述した[hello]がブラウザ上では”Hello World”に変換されていることが確認できます。

valueの値の更新だけではなくタグをpからdivに変更し、divタグにclassを追加するなども可能です。classはpropertiesを利用します。複数のclassを設定したい場合はclassとの間にスペースを入れることで複数設定が可能です。


const customCode = () => {
  return (tree) => {
    visit(tree, 'element', (node) => {
      if (node.tagName === 'p' && node.children[0].type === 'text') {
        if (node.children[0].value.startsWith('[hello]')) {
          node.tagName = 'div';
          node.properties = {
            className: ['font-bold'],
          };
          node.children[0].value = 'Hello World';
        }
      }
    });
  };
};

Tailwind CSSを利用しているのでfont-boldが何もせずに適用されブラウザ上には太線の”Hello World”が表示されます。Tailwind CSS以外のクラスを適用したい場合にはstylesフォルダのglobal.cssに記述することで適用できます。

このようにhastにアクセスすることで表示する内容をカスタマイズすることができます。

[hello]をHello Worldに変換することができるようになりましたが実際に利用することはないでしょう。より実践的なカスタムコードの設定方法について確認していきます。

markdownファイルの中で[ comment ]現在Teamsで障害が発生しています[ /comment ]と記述した場合に[ comment ]の間の文字列にclassを適用できるように設定を行います。今後はvalueの先頭に[ comment ]が入っているのもを取り出し、classにはalertを設定しています。valueに入った[ comment ]と[/ comment ]を削除するためにreplaceメソッドで正規表現を利用します。


const customCode = () => {
  return (tree) => {
    visit(tree, 'element', (node) => {
      if (node.tagName === 'p' && node.children[0].type === 'text') {
        if (node.children[0].value.startsWith('[cammen]')) {
          node.tagName = 'div';
          node.properties = {
            className: ['alert'],
          };
          node.children[0].value = node.children[0].value.replace(
            /\[\/?comment\]/g,
            ''
          );
        }
      }
    });
  };
};

alertのclassを利用していないのでブラウザ上には変化がありませんがブラウザのソースを見るとdiv要素にclassが適用されたコードが確認できます。


<div class="alert">現在 Teams で障害が発生しています</div>

global.cssにalertクラスを設定します。


@tailwind base;
.alert {
  font-weight: bold;
  padding: 0.5em;
  background-color: orange;
  color: white;
}

ブラウザ上にalertクラスが適用されて表示されます。

alertクラスを適用
alertクラスを適用

childrenを追加

さらにchildrenを追加することでdiv要素の中にさらにdiv要素を追加して別のclassを適用することもできます。


const customCode = () => {
  return (tree) => {
    visit(tree, 'element', (node) => {
      if (node.tagName === 'p' && node.children[0].type === 'text') {
        if (node.children[0].value.startsWith('[commen]')) {
          node.tagName = 'div';
          node.properties = {
            className: ['alert'],
          };
          const value = node.children[0].value.replace(/\[\/?comment\]/g, '');
          node.children = [
            {
              type: 'element',
              tagName: 'div',
              properties: { className: ['alert-2'] },
              children: [{ type: 'text', value }],
            },
          ];
        }
      }
    });
  };
};

ブラウザでソースを確認すると下記のようになっています。


<div class="alert"><div class="alert-2">現在 Teams で障害が発生しています</div></div>

ASTを直接操作することで子要素の追加など自由に変更することができます。

引き続き機能の追加方法を記述していく予定でしたがNext.jsのバージョンアップなどがあり次回は別のバージョンでのブログの作成方法を公開する予定です。