ゲーム開発
Unity
UnrealEngine
C++
Blender
ゲーム数学
ゲームAI
グラフィックス
サウンド
アニメーション
GBDK
制作日記
IT関連
ツール開発
フロントエンド関連
サーバサイド関連
WordPress関連
ソフトウェア設計
おすすめ技術書
音楽
DTM
楽器・機材
ピアノ
ラーメン日記
四コマ漫画
その他
おすすめアイテム
おもしろコラム
  • ゲーム開発
    • Unity
    • UnrealEngine
    • C++
    • Blender
    • ゲーム数学
    • ゲームAI
    • グラフィックス
    • サウンド
    • アニメーション
    • GBDK
    • 制作日記
  • IT関連
    • ツール開発
    • フロントエンド関連
    • サーバサイド関連
    • WordPress関連
    • ソフトウェア設計
    • おすすめ技術書
  • 音楽
    • DTM
    • 楽器・機材
    • ピアノ
  • ラーメン日記
    • 四コマ漫画
      • その他
        • おすすめアイテム
        • おもしろコラム
      1. ホーム
      2. 20231231_01_markdown_edit

      【Next.js】第三回 WordPressブログをNext.jsに移行する 〜Markdown執筆環境構築編〜

      Next.jsReactJavaScriptWordPress関連フロントエンド関連SSGStorybookEmotionNode.jsp5.js
      2023-12-31

      マイケル
      マイケル
      みなさんこんにちは! マイケルです!!
      エレキベア
      エレキベア
      こんにちクマ〜〜〜
      マイケル
      マイケル
      この度、WordPress製だった当ブログをNext.jsで作り直しました! 前回まででWordPressのデータ移行・表示まで完了したため、今回はMarkdownによる執筆環境の構築について紹介します。
      【都会のエレキベア】ブログを大幅リニューアル!WordPressからNext.jsに移行するまでの流れをまとめる
      2024-01-01
      【Next.js】第一回 WordPressブログをNext.jsに移行する 〜全体設計、環境構築編〜
      2023-12-31
      【Next.js】第二回 WordPressブログをNext.jsに移行する 〜WordPressデータの移行・表示編〜
      2023-12-31
      【Next.js】第三回 WordPressブログをNext.jsに移行する 〜Markdown執筆環境構築編〜
      2023-12-31
      【Next.js】第四回 WordPressブログをNext.jsに移行する 〜サーバ移行・SEO・広告設定編〜
      2023-12-31
      エレキベア
      エレキベア
      あと一息で新体系のブログが開発完了するクマ
      マイケル
      マイケル
      これまでMarsEditというツールを使用してブログを書いていました。 WordPressやMacOS依存な点は微妙でしたが、ショートカットや画像アップロードといった記事の執筆作業に関しては快適で、それ以上の執筆環境に改善したいという目標がありました。
      20231231_markdown_edit
      ▲WordPress時代はMarsEditを使用していた

      エレキベア
      エレキベア
      MarsEditはよくできたツールだったクマね
      マイケル
      マイケル
      ・Windows/Mac両対応で使えるようにしたい ・Markdown形式で執筆したい ・画像をドラッグ&ドロップでアップロード、リサイズしたい ・ホットリロードに対応したい 今回はこれらの要件を満たす環境を作ることを目指しました。 最終的に、VSCode上で下記のような形で執筆できるようになりました。
      20240101_01_next_elekibear_gif_01
      ▲ホットリロードによるMarkdown記事執筆

      エレキベア
      エレキベア
      おお〜〜〜〜 いい感じに執筆できてこの独特なブログにも対応できてるクマ〜〜
      マイケル
      マイケル
      めちゃくちゃ執筆しやすくなって気持ちいいね! ただ画像のリサイズやアップロードに関してはVSCodeの機能で対応できそうになかったから、Electronで別途ツールを開発して対応しました。
      20240101_01_next_elekibear_gif_02
      ▲画像アップロードは別途ツールを作成

      エレキベア
      エレキベア
      だいぶ本格的な執筆環境が整ったクマね
      マイケル
      マイケル
      今回はこの開発環境周りの構成について紹介していきます! また、コードについても公開できる範囲でGitHubに上げているので、こちらも合わせてご参考ください!

      GitHub - nextjs-elekibear-blog-scripts

      エレキベア
      エレキベア
      中々なボリュームクマ・・・

      Markdown変換処理の実装

      Markdown変換ライブラリのインストール

      マイケル
      マイケル
      まずはMarkdownファイルのReactElementへの変換処理についてです。 こちらは react-markdown をメインで使用することにしました。 合わせて、Markdown内にカテゴリ情報や日付情報等も付与できるようにするため gray-matter も使用しています。

      GitHub - react-markdown

      GitHub - gray-matter

      エレキベア
      エレキベア
      どちらも定番のライブラリクマね
      マイケル
      マイケル
      これらのライブラリを下記コマンドでインストールします。 今回はテーブル表示、HTML記述も行えるようにするため、remark-gfm rehype-rawも合わせて導入しました。
      # ReactMarkdown関連
      # remark-gfm: テーブル表示用
      # refype-raw: マークダウン内でのHTML記述用
      npm install react-markdown remark-gfm rehype-raw
      
      # Markdown内への情報追加用
      npm install gray-matter
      
      エレキベア
      エレキベア
      これで準備は完了クマ

      Markdown変換処理の実装

      マイケル
      マイケル
      Markdownは下記形式で記述することにしました。 「---」で囲んでいる部分がgray-matterによる付加情報の指定部分になります。
      ---
      type: 'post'
      title: '【テスト】Markdown記事1'
      featured_image: 'https://content.elekibear.com/content/_dummy.png'
      post_date: '2024-01-01 01:00:00'
      post_modified: '2024-01-01 00:00:00'
      categoryIds: '1'
      tagIds: '101'
      ---
      
      ## 見出し1
      
      <strong>これはテスト記事である・・・。</strong>
      
      <strong>【テスト】Markdown記事1 〜完〜</strong>
      
      
      ▲gray-matterで付加情報を追加した状態
      20231231_markdown_edit_02
      ▲変換して表示した状態

      エレキベア
      エレキベア
      Markdown内の情報で記事の執筆が完了できるようにしたクマね
      マイケル
      マイケル
      この変換処理について、まずMarkdownファイルの読込処理は下記のようになっています。 格納フォルダ内のMarkdownファイルを全て読込、ファイル名をslugとして管理することで、WordPress記事で使用していたmst_postsは不要になっています。
      import { apiMst, mstData } from './mst-api';
      
      export namespace markdownData {
        export type postMeta = {
          mstPost: apiMst.Post; // マスタデータと同じ情報を持たせる
        };
        export type postData = {
          content: string;
          meta: PostMeta;
        };
      }
      
      
      ▲Markdownデータの型定義
      import fs from 'fs';
      import {
        convertMarkdownPostMatterData,
        convertResponseMarkdownPostData,
        markdownPostMatterData,
      } from './markdown-api-utils';
      import SiteSettings from 'settings/SiteSettings';
      import { markdownData } from 'types/markdown-api';
      import { apiMst } from 'types/mst-api';
      
      /**
       * 全ての投稿データ取得
       * @param isOnlyPost Postページだけを取得するか?
       */
      export function getAllMarkdownPosts(
        allCategories: apiMst.Categories | null,
        allTags: apiMst.Tags | null,
        isOnlyPost: boolean = false,
      ): markdownData.postData[] {
        // 最終的にはMstDataの形式と合わせて返却する
        const result: markdownData.postData[] = [];
      
        // 読み込むフォルダ
        // 開発時のみプレビューフォルダも対象にする
        const readDirArray: string[] = [SiteSettings.MARKDOWN_POSTS_PATH];
        if (process.env.NODE_ENV === 'development') {
          readDirArray.push(SiteSettings.MARKDOWN_POSTS_PREVIEW_PATH);
        }
      
        // マークダウンファイルの読み込み
        let markdownDataArray: markdownPostMatterData[] = [];
        for (const readDir of readDirArray) {
          const filePathList = fs.readdirSync(readDir);
          const markdownData = filePathList
            .filter(
              (fileName) =>
                fileName.includes('.md') && !fileName.includes('_template'),
            )
            .map((fileName) => {
              // gray-matterでmeta情報とcontentを取得
              const filePath = `${readDir}/${fileName}`;
              const markdownWithMeta = fs.readFileSync(filePath, 'utf-8');
              return convertMarkdownPostMatterData(markdownWithMeta, filePath);
            });
          markdownDataArray = markdownDataArray.concat(markdownData);
        }
      
        // Postのみにフィルタ、作成日時降順で取得
        if (isOnlyPost) {
          markdownDataArray = markdownDataArray.filter(
            (data) => data.meta.type == 'post',
          );
        }
        markdownDataArray.sort((a, b) =>
          a.meta.post_date > b.meta.post_date ? 1 : -1,
        );
      
        // 記事データに変換して返却
        for (let i = 0; i < markdownDataArray.length; i++) {
          result.push(
            convertResponseMarkdownPostData(
              markdownDataArray[i],
              allCategories,
              allTags,
            ),
          );
        }
      
        return result;
      }
      
      
      ▲Markdownファイルの読込処理
      import path from 'path';
      import matter from 'gray-matter';
      import { markdownData } from 'types/markdown-api';
      import { apiMst } from 'types/mst-api';
      
      export type markdownPostMatterData = {
        content: string;
        meta: any;
        slug: string;
      };
      
      /**
       * Markdownデータを記事とメタデータに変換
       * @param filePath
       * @returns
       */
      export function convertMarkdownPostMatterData(
        markdownData: string,
        filePath: string,
      ): markdownPostMatterData {
        const { content: content, data: data } = matter(markdownData);
        const slug = path.basename(filePath).replace('.md', ''); // ファイル名をslugとする
        return { content: content, meta: data, slug: slug };
      }
      
      /**
       * Markdown記事データをレスポンス形式に変換
       * @param data
       * @returns
       */
      export function convertResponseMarkdownPostData(
        data: markdownPostMatterData,
        allCategories: apiMst.Categories | null,
        allTags: apiMst.Tags | null,
      ): markdownData.postData {
        const content = data.content;
        const meta = data.meta;
        const postSlug = data.slug;
      
        let mstPost: apiMst.Post = {
          id: postSlug, // idはslugを指定
          title: meta.title,
          slug: postSlug,
          date: meta.post_date,
          featuredImage: meta.featured_image,
          categories: [],
          tags: [],
        };
        mstPost = addTermsInfoForToPost(
          mstPost,
          meta.categoryIds,
          meta.tagIds,
          allCategories,
          allTags,
        );
      
        // メタデータ作成
        const postMeta: markdownData.postMeta = {
          mstPost: mstPost,
        };
      
        // 記事データとして設定
        const postData: markdownData.postData = {
          content: content,
          meta: postMeta,
        };
        return postData;
      }
      
      /**
       * 投稿データにカテゴリ、タグを付与して返却
       * MstTermsのCSVデータから検索して設定する
       */
      function addTermsInfoForToPost(
        post: apiMst.Post,
        metaCategoryIds: string,
        metaTagIds: string,
        allCategories: apiMst.Categories | null,
        allTags: apiMst.Tags | null,
      ): apiMst.Post {
        // カテゴリ
        if (metaCategoryIds && allCategories?.categories) {
          metaCategoryIds = metaCategoryIds.replaceAll(' ', '');
          const tmpCategoryIds = metaCategoryIds.split(',');
          const categoryIds = Array.isArray(tmpCategoryIds)
            ? tmpCategoryIds
            : new Array(metaCategoryIds);
          post.categories = getCategoriesByIds(categoryIds, allCategories);
        }
        // タグ
        if (metaTagIds && allTags?.tags) {
          metaTagIds = metaTagIds.replaceAll(' ', '');
          const tmpTagIds = metaTagIds.split(',');
          const tagIds = Array.isArray(tmpTagIds) ? tmpTagIds : new Array(metaTagIds);
          post.tags = getTagsByIds(tagIds, allTags);
        }
        return post;
      }
      
      /**
       * IDに一致するカテゴリ情報を返却
       * @param id
       * @returns
       */
      function getCategoriesByIds(
        ids: string[],
        allCategories: apiMst.Categories,
      ): apiMst.PostCategory[] {
        // 指定されたIDでフィルタ
        const filterCategories: apiMst.Category[] = [];
        ids.forEach((id) => {
          const category = allCategories.categories.find(
            (category) => category.id == id,
          );
          if (category) {
            filterCategories.push(category);
          }
        });
        // 記事のカテゴリ情報に変換して返却
        const result: apiMst.PostCategory[] = [];
        filterCategories.forEach((category) => {
          result.push({
            id: category.id,
            name: category.name,
            slug: category.slug,
          });
        });
        return result;
      }
      
      /**
       * IDに一致するタグ情報を返却
       * @param id
       * @returns
       */
      function getTagsByIds(ids: string[], allTags: apiMst.Tags): apiMst.PostTag[] {
        // 指定されたIDでフィルタ
        const filterTags: apiMst.Tag[] = [];
        ids.forEach((id) => {
          const tag = allTags.tags.find((tag) => tag.id == id);
          if (tag) {
            filterTags.push(tag);
          }
        });
        // 記事のカテゴリ情報に変換して返却
        const result: apiMst.PostTag[] = [];
        filterTags.forEach((tag) => {
          result.push({
            id: tag.id,
            name: tag.name,
            slug: tag.slug,
          });
        });
        return result;
      }
      
      
      ▲Markdownデータの変換処理
      マイケル
      マイケル
      あとはこのAPIから呼び出したMarkdownデータを、既存のテキストデータと合わせて表示するよう実装するだけです。
      
      ・・・略・・・
      
      /**
       * トップページ
       * @returns
       */
      const PostPage: NextPage<PostPageProps> = (props: PostPageProps) => {
        let post: apiMst.Post = props.post;
      
      ・・・略・・・
      
        // 記事データをReactComponentに変換する
        let postContentElement: string | JSX.Element | JSX.Element[] = '';
        if (props.isMarkdown) {
          // ========== Markdown記事 ==========
          let postContent = props.postContent;
      
      ・・・略・・・
      
          postContentElement = (
            <MarkdownPostContent
              allPosts={props.allPosts}
              postContent={postContent}
              postTocContents={postTocContent}
              pushPostTocContent={pushPostTocContent}
            />
          );
        } else {
      
      ・・・略・・・
      
        }
      
      ・・・略・・・
      
      };
      export default PostPage;
      
      ・・・略・・・
      
      export const getStaticProps: GetStaticProps = async ({ params }) => {
        const slug: string = typeof params?.slug === 'string' ? params?.slug : '';
        const allPosts: apiMst.Posts = getAllPosts(true);
        const allCategories = getAllCategories();
        const allCategoryIdIncludeChildInfo =
          getAllCategoryIdIncludeChildInfo(allCategories);
        const allTags: apiMst.Tags = getAllTags();
      
        // マークダウンの記事データも追加
        const allMarkdownPost: markdownData.postData[] = getAllMarkdownPosts(
          allCategories,
          allTags,
        );
        allMarkdownPost.forEach((markdownPost) => {
          allPosts.posts.unshift(markdownPost.meta.mstPost);
        });
      
        // マークダウン or HTMLText記事でそれぞれ取得する
        let post: apiMst.Post;
        let postContent: string;
        let isMarkdown: boolean = false;
      
        // 記事データ読込
        // Markdown記事を優先で読み込む
        const markdownPost = allMarkdownPost.find(
          (post) => encodeURI(slug).toLowerCase() == post.meta.mstPost.slug,
        );
        if (markdownPost) {
          // ===== Markdown記事 =====
          isMarkdown = true;
          post = markdownPost.meta.mstPost;
          postContent = markdownPost.content;
        } else {
      
      ・・・略・・・
      
        }
      
        return {
          props: {
            post,
            postContent,
            isMarkdown,
            slug,
            allPosts,
            allCategories,
            allCategoryIdIncludeChildInfo,
            allTags,
          },
        };
      };
      
      // 全てのslugを取得してページ生成
      export const getStaticPaths: GetStaticPaths = async () => {
        const allPosts: apiMst.Posts = getAllPosts();
      
        // マークダウンの記事データも追加
        const allMarkdownPost = getAllMarkdownPosts(null, null);
        allMarkdownPost.forEach((markdownPost) => {
          allPosts.posts.push(markdownPost.meta.mstPost);
        });
      
        return {
          paths: allPosts.posts.map((post) => `/post/${post.slug}`) || [],
          fallback: false,
        };
      };
      
      
      ▲記事ページでのMarkdownデータ読込処理
      
      ・・・略・・・
      
      /**
       * マークダウン用 記事コンテンツ
       * @param props
       * @returns
       */
      const MarkdownPostContent = (props: {
        allPosts: apiMst.Posts;
        postContent: string;
        postTocContents: TocContent[];
        pushPostTocContent: (tocContent: TocContent) => void;
      }) => {
        const postContent = props.postContent;
        const postTocContents = props.postTocContents;
        const pushPostTocContent = props.pushPostTocContent;
      
        return (
          <div className="entry-content">
            {/** 目次部分 ReactMarkdown内で要素を抽出してContext経由で設定、TocComponentで表示する */}
      
      ・・・略・・・
      
            {/** 記事部分 */}
            <PostsContext.Provider value={props.allPosts}>
              {/** オプション: https://github.com/remarkjs/react-markdown?tab=readme-ov-file#options */}
              <ReactMarkdown
                remarkPlugins={[remarkGfm]}
                components={{
                  h2: CustomH2Block,
                  h3: CustomH3Block,
                  h4: CustomH4Block,
                  h5: CustomH5Block,
                  h6: CustomH6Block,
                  code: CustomCodeBlock,
                  pre: CustomPreBlock,
                  p: CustomParagraphBlock,
                }}
                rehypePlugins={[rehypeRaw]} // HTMLを使用できるようにする
              >
                {postContent}
              </ReactMarkdown>
            </PostsContext.Provider>
          </div>
        );
      };
      export default MarkdownPostContent;
      
      
      ▲Markdown記事のコンポーネント
      エレキベア
      エレキベア
      これでMarkdown記事の表示もできたクマね
      マイケル
      マイケル
      ここで記載しているCustomブロックについては、この後紹介する目次抽出とカスタム記法の記述にて解説します。

      目次要素の抽出

      マイケル
      マイケル
      目次要素を抽出するにはどうすればいいか?については悩みましたが、 変換時に見出し要素を抽出、idを生成してStateに設定する 方法で実装することができました。
      20231231_markdown_edit_05
      ▲記事内に表示する目次要素

      マイケル
      マイケル
      少々無理矢理ではありますが、実装内容は下記になります。
      
      ・・・略・・・
      
      /**
       * トップページ
       * @returns
       */
      const PostPage: NextPage<PostPageProps> = (props: PostPageProps) => {
        let post: apiMst.Post = props.post;
      
      ・・・略・・・
      
        // 目次情報 (記事データ変換時に設定する)
        const [postTocContent, setPostTocContent] = useState<TocContent[]>([]);
        const pushPostTocContent = (tocContent: TocContent) => {
          if (postTocContent.some((content) => content.id === tocContent.id)) {
            return;
          }
          postTocContent.push(tocContent);
          setPostTocContent([...postTocContent]);
        };
      
      ・・・略・・・
      
      };
      export default PostPage;
      
      ・・・略・・・
      
      
      ▲記事ページにて目次情報設定用のStateを作成
      import { useContext } from 'react';
      import { TocContentsContext } from 'components/page/Post/Content/MarkdownPostContent';
      
      const CreateId = (prefix: string, node: any) => {
        return `${prefix}-${node?.position?.start.line}`;
      };
      
      /**
       * 目次要素の作成
       * @param props
       * @returns
       */
      const CreateTocContent = (props: { node: any; title: string; tag: string }) => {
        const id = CreateId(props.tag, props.node);
        const title = props.title;
        const tag = props.tag;
      
        // Contextにpush
        const tocContentsContext = useContext(TocContentsContext);
        tocContentsContext.pushPostTocContent({
          id: id,
          title: title,
          tag: tag,
        });
        return <></>;
      };
      
      /**
       * 目次部分
       * 表示はせず目次要素の抽出のみ行う
       * @param param0
       * @returns
       */
      const CreateH2TocContent = ({ children, node, ...rest }: any) => {
        return <CreateTocContent node={node} title={children} tag={'h2'} />;
      };
      const CreateH3TocContent = ({ children, node, ...rest }: any) => {
        return <CreateTocContent node={node} title={children} tag={'h3'} />;
      };
      const CreateH4TocContent = ({ children, node, ...rest }: any) => {
        return <CreateTocContent node={node} title={children} tag={'h4'} />;
      };
      const CreateH5TocContent = ({ children, node, ...rest }: any) => {
        return <CreateTocContent node={node} title={children} tag={'h5'} />;
      };
      const CreateH6TocContent = ({ children, node, ...rest }: any) => {
        return <CreateTocContent node={node} title={children} tag={'h6'} />;
      };
      
      /**
       * 実際の要素部分
       * idを付与する
       * @param param0
       * @returns
       */
      const CustomH2Block = ({ children, node, ...rest }: any) => {
        const id = CreateId('h2', node);
        return <h2 id={id}>{children}</h2>;
      };
      const CustomH3Block = ({ children, node, ...rest }: any) => {
        const id = CreateId('h3', node);
        return <h2 id={id}>{children}</h2>;
      };
      const CustomH4Block = ({ children, node, ...rest }: any) => {
        const id = CreateId('h4', node);
        return <h4 id={id}>{children}</h4>;
      };
      const CustomH5Block = ({ children, node, ...rest }: any) => {
        const id = CreateId('h5', node);
        return <h5 id={id}>{children}</h5>;
      };
      const CustomH6Block = ({ children, node, ...rest }: any) => {
        const id = CreateId('h6', node);
        return <h6 id={id}>{children}</h6>;
      };
      
      export {
        CreateH2TocContent,
        CreateH3TocContent,
        CreateH4TocContent,
        CreateH5TocContent,
        CreateH6TocContent,
        CustomH2Block,
        CustomH3Block,
        CustomH4Block,
        CustomH5Block,
        CustomH6Block,
      };
      
      
      ▲ReactMarkdownのカスタムブロックにて見出し要素の抽出とid付与を行う
      
      ・・・略・・・
      
      // ReactMarkdown カスタムComponentへの受け渡し用
      export const TocContentsContext = createContext({
        pushPostTocContent: (tocContent: TocContent) => {},
      });
      
      ・・・略・・・
      
      /**
       * マークダウン用 記事コンテンツ
       * @param props
       * @returns
       */
      const MarkdownPostContent = (props: {
        allPosts: apiMst.Posts;
        postContent: string;
        postTocContents: TocContent[];
        pushPostTocContent: (tocContent: TocContent) => void;
      }) => {
        const postContent = props.postContent;
        const postTocContents = props.postTocContents;
        const pushPostTocContent = props.pushPostTocContent;
      
        return (
          <div className="entry-content">
            {/** 目次部分 ReactMarkdown内で要素を抽出してContext経由で設定、TocComponentで表示する */}
            <TocContentsContext.Provider
              value={{
                pushPostTocContent,
              }}
            >
              <ReactMarkdown
                allowedElements={['h2', 'h3', 'h4', 'h5', 'h6']}
                components={{
                  h2: CreateH2TocContent,
                  h3: CreateH3TocContent,
                  h4: CreateH4TocContent,
                  h5: CreateH5TocContent,
                  h6: CreateH6TocContent,
                }}
                skipHtml={true}
              >
                {postContent}
              </ReactMarkdown>
              <TocContentItem tocContents={postTocContents} />
            </TocContentsContext.Provider>
            {/** 記事部分 */}
      
      ・・・略・・・
      
          </div>
        );
      };
      export default MarkdownPostContent;
      
      
      ▲作成したStateをContext経由で渡し、設定されたら目次を表示する
      マイケル
      マイケル
      ReactMarkdownのカスタムコンポーネントにStateを渡す方法が見つからなかったため、Context経由で無理矢理渡して更新する実装となっています。 他にいい方法があれば教えてください・・・
      エレキベア
      エレキベア
      まあこればかりは仕方ない気はするクマ・・・

      執筆環境の改善

      マイケル
      マイケル
      以上で記事の表示は完了ですが、使い心地をよくするために ・画像ファイル周りの工夫 ・吹き出し等のカスタム記法の対応 ・キーボードショートカットによる入力 を追加で対応しました。

      執筆中の画像ファイルの扱いについて

      マイケル
      マイケル
      まず画像周りの運用をどうするか?についてです。 画像ファイル周りは別のサーバで管理しているのですが、開発中はサーバにアップロードせずにローカルで管理したかったので、執筆中のみpublicフォルダ内で管理することにしました。 具体的には下記のような構成です。
      ./public
      ├── ...
      └── server-contents
          └── uploads
              ├── common
              ├── content
              └── wp-content
      
      ▲一時的にpublicフォルダに格納する
      マイケル
      マイケル
      /server-contents/public配下の資産がサーバへのアップロード対象になります。 記事執筆の完了後、アップロード処理と記事内のURL書き換えを行うようにすれば対応できそうです。
       
      URL
      アップロード前
      /server-contents/public/XXX
      アップロード後
      https://content.elekibear.com/XXX
      エレキベア
      エレキベア
      なるほどクマ それならローカルでの執筆ができそうクマね

      カスタム記法に対応する

      マイケル
      マイケル
      次にMarkdownでのカスタム記法の記述対応についてです。 例えば←のようなキャラクターが喋っている吹き出しなどですね。
      エレキベア
      エレキベア
      この辺のカスタマイズは簡単にできるクマ??
      マイケル
      マイケル
      unifiedというのを使ってMarkdownを拡張するのが王道みたいだけど、中々面倒そうだったので今回は下記で紹介されているコードブロックをカスタムして表示する方法を採用しました。

      Qiita - オレオレ記法のMarkdownを任意のReactElementとして変換する

      エレキベア
      エレキベア
      なるほどクマ これなら簡単にカスタムできそうクマ
      マイケル
      マイケル
      吹き出しを例にすると、例えば僕が喋っている「マイケル吹き出しコンポーネント」は下記のように記載するとします。
      
      ```talk:l:m:1
      テスト
      ```
      
      
      ▲コードブロックの記法にパラメータを追加している
      エレキベア
      エレキベア
      通常のコードブロック記法に「talk:m:1」を指定しているクマね
      マイケル
      マイケル
      この指定をコード側で受け取るために、下記のようなカスタムコンポーネントを作成します。 Markdownで「```」の後に指定された文字は「language-XXX」の形式でクラス指定されるため、そこから文字列を受け取っています。
      import { useContext } from 'react';
      import { ExtraProps } from 'react-markdown';
      import PointBoxContentItem from '../../components/page/Post/ContentItems/PointBoxContentItem';
      import PostCardContentItem from '../../components/page/Post/ContentItems/PostCardContentItem';
      import PrismCodeContentItem from '../../components/page/Post/ContentItems/PrismCodeContentITem';
      import TalkContentItem from '../../components/page/Post/ContentItems/TalkContentItem';
      import { PostsContext } from 'components/page/Post/Content/MarkdownPostContent';
      import TableContentItem from 'components/page/Post/ContentItems/TableContentItem';
      
      /**
       * コードブロックのカスタム
       * markdownのカスタム書式を一から作るのは手間だったので
       * コードブロックで指定された文字から無理やり変換
       * 参考: https://qiita.com/bigmon/items/de62335fbf8388192499
       * @param param0
       * @returns
       */
      const CustomCodeBlock = ({
        children,
        className,
        node,
        ...rest
      }: JSX.IntrinsicElements['code'] & ExtraProps) => {
      
        // カスタム値の判定材料を抽出
        const prefix = 'language-';
        const classes = className
          ?.split(' ')
          .find((c) => c.startsWith(prefix))
          ?.replace(prefix, '');
        const params = classes ? classes.split(':') : [];
      
        // ===== キャラクター会話コンポーネント =====
        if (params.length >= 4 && params[0] == 'talk') {
          const position = params[1];
          const character = params[2];
          const characterOption = params[3];
      
          return (
            <TalkContentItem
              position={position}
              character={character}
              characterOption={Number(characterOption)}
            >
              {children}
            </TalkContentItem>
          );
        }
      
      ・・・略・・・
      
        // 通常のコンポーネントを返却
        return <code className={className}>{children}</code>;
      };
      export default CustomCodeBlock;
      
      
      ▲コードブロックのカスタム処理
      マイケル
      マイケル
      あとは受け取ったパラメータをコンポーネントに渡して表示するのみ! 吹き出しコンポーネントは下記のようになっています。
      import { css } from '@emotion/react';
      
      const styleAddSpeechBaloon = () => css`
        // wordpress側の記事で指定してしまうと無駄に改行されてしまうためmarkdown専用
        white-space: pre-wrap;
      `;
      
      /**
       * ポジション
       * Left, Right
       */
      const PositionKinds = ['l', 'r'];
      
      /**
       * キャラクター
       * Michael, Elekibear, Jaggy, Goloyan, Plasmo
       */
      const CharacterKinds = ['m', 'e', 'j', 'g', 'p'];
      
      function getPositionClassName(position: string) {
        let className = '';
        switch (position) {
          case PositionKinds[0]:
            className = 'sbp-l';
            break;
          case PositionKinds[1]:
            className = 'sbp-r';
            break;
        }
        return className;
      }
      
      function getCharacterName(character: string) {
        let characterName = '';
        switch (character) {
          case CharacterKinds[0]:
            characterName = 'マイケル';
            break;
          case CharacterKinds[1]:
            characterName = 'エレキベア';
            break;
          case CharacterKinds[2]:
            characterName = 'ジャギィ';
            break;
          case CharacterKinds[3]:
            characterName = 'ゴロヤン';
            break;
          case CharacterKinds[4]:
            characterName = 'ぷらずも';
            break;
        }
        return characterName;
      }
      
      /**
       * キャラクターに対応する画像コンポーネントを返却する
       * @param character
       * @param option
       * @returns
       */
      function getCharacterImageComponent(character: string, option: number) {
        // /img/talk/talk_[character]_[option].png
        let src = '/img/talk/talk_';
        switch (character) {
          case CharacterKinds[0]:
            src += 'michael';
            break;
          case CharacterKinds[1]:
            src += 'elekibear';
            break;
          case CharacterKinds[2]:
            src += 'jaggy';
            break;
          case CharacterKinds[3]:
            src += 'goloyan';
            break;
          case CharacterKinds[4]:
            src += 'plasmo';
            break;
        }
        src += `_${option}.png`;
      
        return (
          <img
            decoding="async"
            src={src}
            alt={getCharacterName(character)}
            className="speech-icon-image"
          />
        );
      }
      
      /**
       * キャラクター会話コンポーネント
       * @param props
       * @returns
       */
      const TalkContentItem = (props: {
        children: React.ReactNode;
        position: string;
        character: string;
        characterOption: number;
      }) => {
        if (typeof props.children !== 'string') {
          console.log('TalkComponent contains an invalid element!!');
          return;
        }
      
        return (
          <div className={`speech-wrap ${getPositionClassName(props.position)}`}>
            <div className="speech-person">
              <figure className="speech-icon">
                {getCharacterImageComponent(props.character, props.characterOption)}
              </figure>
              <div className="speech-name">{getCharacterName(props.character)}</div>
            </div>
            <div className="speech-balloon" css={styleAddSpeechBaloon}>
              <div dangerouslySetInnerHTML={{ __html: props.children }}></div>
            </div>
          </div>
        );
      };
      export default TalkContentItem;
      
      
      ▲キャラ吹き出しコンポーネントの実装
      マイケル
      マイケル
      このようにすれば割と柔軟にカスタムで追加することができます。 整理すると当記事は下記のようにして記述しています。
      ---
      type: 'post'
      title: '【テスト】Markdown記事1'
      featured_image: 'https://content.elekibear.com/content/_dummy.png'
      post_date: '2024-01-11 11:00:00'
      post_modified: '2024-01-01 00:00:00'
      categoryIds: '1'
      tagIds: '101'
      ---
      
      ## 見出し1
      
      <strong>これはテスト記事である・・・。</strong>
      
      ```talk:l:m:1
      テスト
      ```
      
      ```talk:r:e:1
      テスト
      ```
      
      ## 見出し2
      
      ```talk:l:m:3
      テスト
      ```
      
      ```talk:r:e:1
      テスト
      ```
      
      <strong>【テスト】Markdown記事1 〜完〜</strong>
      
      
      ▲Markdown記事執筆例
      20231231_markdown_edit_01
      ▲Markdown記事を表示した状態

      エレキベア
      エレキベア
      この記事の実態はこんな感じになっていたクマか・・・

      キーボードショートカットで入力できるようにする (VSCode)

      マイケル
      マイケル
      カスタム記法を入力できるようにしたのはいいですが、これを毎回手動で入力していると大変です。 そのため今回はVSCodeのキーボードショートカット機能を編集する形でショートカット入力できるように対応しました。
      エレキベア
      エレキベア
      さすがVSCodeクマ〜〜〜〜
      マイケル
      マイケル
      キーボードショートカット設定は下記手順で開くことができます。 開いたkeybinding.jsonの内容を編集することで設定できます。
      20231231_markdown_edit_03
      ▲Preferences > Keyboard Shortcuts からショートカットメニューを開く

      20231231_markdown_edit_04
      ▲右上のファイルマークをクリックすると開ける

      マイケル
      マイケル
      吹き出しコンポーネントの例として下記のように設定しました。 しかし、keybinding.jsonは現状ワークスペース単位での設定ができないようだったため、今回は .vscode/settings.jsonでフラグが指定された時のみショートカットを有効にするよう対処を行いました。
      // Place your key bindings in this file to override the defaults
      [
        // 都会のエレキベア 執筆用カスタム定義
        // settings.json に "workspaceKeybindings.ElekibearBlogTask.enabled": true を指定すると有効になる
        {
          // マイケル
          "key": "cmd+left",
          "command": "type",
          "args": {
            "text": "```talk:l:m:1\n\n```"
          },
          "when": "config.workspaceKeybindings.ElekibearBlogTask.enabled"
        },
      
      ・・・略・・・
      
      ]
      
      
      {
      
      ・・・略・・・
      
        // Markdownの入力補完を有効にする
        "emmet.excludeLanguages": [],
        "emmet.includeLanguages": { "markdown": "html" },
        "emmet.showSuggestionsAsSnippets": true,
      
        // keybindings.json のwhenに指定することでプロジェクトでのみ有効にする
        "workspaceKeybindings.ElekibearBlogTask.enabled": true
      }
      
      
      エレキベア
      エレキベア
      ワークスペース設定でフラグを設定する方法があるクマね まあそもそもワークスペースに対して設定できるようにしてほしいクマ・・・(要望)

      執筆に必要なツール類の開発

      執筆用デスクトップアプリの作成

      マイケル
      マイケル
      以上でだいぶ快適な執筆環境になってきましたが、まだ下記の課題が残っていました。 ・画像のアップロード、リサイズが面倒くさい ・カテゴリ、タグの指定が行いにくい
      エレキベア
      エレキベア
      うーん、確かにこの辺は難しい問題クマ・・・
      マイケル
      マイケル
      この二点についてはVSCode上での対応は難しそうだと判断したため、Electronを用いて専用のデスクトップアプリを開発することにしました。
      画像リサイズツール
      20240101_01_next_elekibear_gif_02
      カテゴリ編集ツール
      20240101_01_next_elekibear_gif_03
      エレキベア
      エレキベア
      おおぅ・・・ ここまで作ったクマか・・・
      マイケル
      マイケル
      詳細については下記の記事にそれぞれまとめているため、興味がある方はご参照ください!
      【Electron × Vue3】画像をリサイズして任意の場所に保存するツールを作る
      2023-12-31
      【Electron × Vue3】カテゴリ情報のCSVデータを操作するツールを作る
      2023-12-31
      エレキベア
      エレキベア
      Electronでの開発も中々面白いクマね

      記事作成・アップロードスクリプトの作成

      マイケル
      マイケル
      そして最後に記事作成・アップロードの処理についてはshellで記述しました。 執筆開始時 -> create_new_post.sh 執筆完了時 -> upload_new_post.sh をそれぞれ実行する方向で対応しています。
      #!/bin/bash
      
      ##############################
      # templateファイルから新しいpostファイルを作成する.
      ##############################
      
      # 定数読込
      TOOL_PATH=$(cd $(dirname $0); pwd)
      source $TOOL_PATH/_settings.sh
      
      # slug入力を促す
      read -p "please input slug: " slug
      new_file_name=$slug".md"
      
      # 既に存在済のslugでないかチェック
      file_path_list=`find $POSTS_FILE_DIR -type f`
      for file_path in $file_path_list
      do
          file_name=`basename $file_path`
          if [ "$file_name" == "$new_file_name" ]; then
              echo "oh, already exist slug..."
              exit 1
          fi
      done
      
      # _templateをコピーslugのmdファイルを作成する
      template_file_path="$PREVIEW_FILE_DIR/$TEMPLATE_FILE_NAME"
      new_file_path="$PREVIEW_FILE_DIR/$new_file_name"
      cp $template_file_path $new_file_path
      echo "create => $new_file_path"
      
      # 対象の文字列を置換
      date=`date "+%Y-%m-%d"`
      sed -i '' -e "s/<DATE>/$date/g" $new_file_path
      sed -i '' -e "s/<SLUG>/$slug/g" $new_file_path
      
      
      ▲記事作成スクリプト
      #!/bin/bash
      
      ##############################
      # サーバに画像をアップロードし、プレビューファイルを正規のパスに移動する.
      ##############################
      
      # HELP
      function usage {
        cat <<EOM
      Usage: $(basename "$0") [OPTION]...
        -h          display help
        -s VALUE    target post slug (empty: all post target)
      EOM
      
        exit 2
      }
      
      # 引数取得
      PARAM_TARGET_POST_SLUG=''
      while getopts ":s:a:h" optKey; do
        case "$optKey" in
          s)
            PARAM_TARGET_POST_SLUG=${OPTARG}
            ;;
          '-h'|'--help'|* )
            usage
            ;;
        esac
      done
      
      # 画像サーバへのアップロード
      TOOL_PATH=$(cd $(dirname $0); pwd)
      source $TOOL_PATH/sync_server_contents.sh -f push
      
      # 置換する文字列
      REPLACE_FROM_URL="/server-contents/public/content"
      REPLACE_TO_URL="https://content.elekibear.com/content"
      
      # previewフォルダ内から指定されたslugのpostファイルを取得
      # slugが指定されていない場合、全てのpostファイルが対象
      file_path_list=()
      if [ -n "$PARAM_TARGET_POST_SLUG" ]; then
          TARGET_POST_FILE_PATH=${PREVIEW_FILE_DIR}/${PARAM_TARGET_POST_SLUG}.md
          # 指定されたslugのファイルが存在しなければ処理終了
          if [ ! -e $TARGET_POST_FILE_PATH ]; then
              echo "not exists => ${TARGET_POST_FILE_PATH}"
              exit 1
          fi
          file_path_list=($TARGET_POST_FILE_PATH)
      else
          file_path_list=`find $PREVIEW_FILE_DIR -type f`
      fi
      
      # ファイルをposts-mdフォルダに移動
      for file_path in $file_path_list
      do
          # templateファイルは省く
          file_name=`basename $file_path`
          if [ "$file_name" == "$TEMPLATE_FILE_NAME" ]; then
              continue;
          fi
          # 画像URLを正規のものに変換
          sed -i '' -e "s@$REPLACE_FROM_URL@$REPLACE_TO_URL@g" $file_path
          # posts-mdフォルダに移動
          mv $file_path $POSTS_FILE_DIR
          echo "move file => $file_name"
      done
      
      
      ▲記事アップロードスクリプト
      エレキベア
      エレキベア
      記事作成時はテンプレートを元に作成、 アップロードでは画像のアップロードとURL書き換えを行っているクマね
      マイケル
      マイケル
      アップロード完了後、差分を確認しつつgit pushすれば執筆が完了するフローです!

      ホットリロード編集できるようにする

      マイケル
      マイケル
      最後に、Markdown記事を編集した際にホットリロードで表示を更新する方法について紹介します! こちらは下記の記事を参考にさせていただきました。

      参考:
      Next.jsでMarkdown記事の快適なホットリロードを実現する

      マイケル
      マイケル
      Next.jsとは別にExpressプロジェクトを作成し、その中で chokidar を使用してファイルの変更を監視する手法です。 実装はシンプルで、下記のようになっています。
      import fs from 'fs';
      import { createServer } from 'http';
      import chokidar from 'chokidar';
      import express from 'express';
      import { Server } from 'socket.io';
      import ToolSettings from './settings';
      
      const app = express();
      const httpServer = createServer(app);
      const io = new Server(httpServer, {
        cors: {
          origin: ToolSettings.CLIENT_URL,
        },
      });
      
      // 変更されたファイルを通知する
      const onFileChange = async (filePath: string) => {
        const postContent = await fs.readFileSync(filePath, 'utf-8');
        const data = {
          content: postContent,
          filePath: filePath,
        };
        io.emit(ToolSettings.CHANGE_POST_EVENT_NAME, data);
      };
      
      // chokidarを用いた変更監視
      const watcher = chokidar.watch(ToolSettings.WATCH_POST_DIR, {
        ignoreInitial: true,
      });
      watcher.on('change', (path) => void onFileChange(path));
      
      // listen開始
      const host = 'localhost';
      const port = ToolSettings.SEAVER_PORT;
      httpServer.listen(port, host, () => {
        io.on('connection', (socket) => {
          // console.log(`connected: ${socket.id}`);
          socket.on('disconnect', () => {
            // console.log(`disconnected: ${socket.id}`);
          });
        });
      });
      
      
      ▲chokidarによるファイル変更監視
      /**
       * Tool設定
       */
      namespace ToolSettings {
        /**
         * 監視対象パス
         * 記事ファイルが格納されているパスを指定
         */
        export const WATCH_POST_DIR = 'data/posts-md';
      
        /**
         * 記事ファイルが変更された際に通知するイベント名
         */
        export const CHANGE_POST_EVENT_NAME = 'postMdChange';
      
        /**
         * 監視するクライアント側のURL
         */
        export const CLIENT_URL = 'http://localhost:3000';
      
        /**
         * サーバ側の起動ポート
         */
        export const SEAVER_PORT = 4000;
      }
      export default ToolSettings;
      
      
      ▲ファイル変更監視の設定
      マイケル
      マイケル
      ファイル変更が監視されたらpostMdChangeイベントとして変更内容を通知するよう実装してあります。 このイベントをNext.js側でsocketを経由して受け取り、コンテンツ表示を更新するようにすれば実装完了です。
      
      ・・・略・・・
      
      // 記事ファイルの変更監視(執筆用)
      // tools/express-post-watcher を別途立ち上げておく必要がある
      function watchPostMdContent(
        setChangePostContent: (postData: markdownData.postData) => void,
        allCategories: apiMst.Categories,
        allTags: apiMst.Tags,
      ) {
        if (process.env.NODE_ENV === 'development') {
          const socket = io('http://localhost:4000');
          socket.on('connect', () => console.log('post-watcher connected.'));
          socket.on('disconnect', () => console.log('post-watcher disconnected.'));
      
          // 記事データの更新を受け取る
          socket.on('postMdChange', (data: { content: string; filePath: string }) => {
            const matterData = convertMarkdownPostMatterData(
              data.content,
              data.filePath,
            );
            const markdownPostData = convertResponseMarkdownPostData(
              matterData,
              allCategories,
              allTags,
            );
            setChangePostContent(markdownPostData);
      
            // scriptsも再度読み込む
            removeAllScirpts();
            appendAllScripts();
          });
      
          return () => {
            socket.close();
          };
        }
      }
      
      /**
       * トップページ
       * @returns
       */
      const PostPage: NextPage<PostPageProps> = (props: PostPageProps) => {
        let post: apiMst.Post = props.post;
      
      ・・・略・・・
      
        // 記事ファイルの変更監視
        const [changeMarkdownPostData, setChangeMarkdownPostData] =
          useState<markdownData.postData>();
        const setChangePostContent = (postData: markdownData.postData) => {
          setChangeMarkdownPostData(postData);
          setPostTocContent([]);
        };
        useEffect(() => {
          watchPostMdContent(
            setChangePostContent,
            props.allCategories,
            props.allTags,
          );
        }, []);
      
      ・・・略・・・
      
        // 記事データをReactComponentに変換する
        let postContentElement: string | JSX.Element | JSX.Element[] = '';
        if (props.isMarkdown) {
          // ========== Markdown記事 ==========
          let postContent = props.postContent;
      
          // ローカルで変更されていたら反映する
          if (changeMarkdownPostData) {
            post = changeMarkdownPostData.meta.mstPost;
            postContent = changeMarkdownPostData?.content;
          }
      
          postContentElement = (
            <MarkdownPostContent
              allPosts={props.allPosts}
              postContent={postContent}
              postTocContents={postTocContent}
              pushPostTocContent={pushPostTocContent}
            />
          );
        } else {
      
      ・・・略・・・
      
        }
      
      ・・・略・・・
      
      };
      export default PostPage;
      
      ・・・略・・・
      
      
      ▲記事ページのスクリプトで変更されたファイル内容を受け取る
      エレキベア
      エレキベア
      ホットリロード用のプロジェクトを別途作成したクマね これは中々便利クマ〜〜

      おわりに

      マイケル
      マイケル
      というわけで今回はMarkdownによる執筆環境の構築についてでした! どうだったかな??
      エレキベア
      エレキベア
      画像アップロード、ホットリロード編集まで独自で実装できて楽しかったクマ〜〜 自分で作ると痒いところに手が届くからいいクマね
      マイケル
      マイケル
      これからも執筆しながら少しずつ改善していく形になるだろうね とりあえずは当初の目標としていた環境のレベルは実現できたから、今の所満足です!
      マイケル
      マイケル
      三回に分けての記事となり長くなりましたが、WordPressからNext.js移行についての記事は以上になります! 最後まで見てくださった方はありがとうございました!
      エレキベア
      エレキベア
      この数ヶ月でいろいろなことができて楽しかったクマね フロントエンド技術がだいぶ分かるようになってきたクマ
      マイケル
      マイケル
      しばらくWebメインで触ってきたので、改善したブログも活かしながらまたゲーム開発に戻っていろいろと遊んでいこうと思います! アデューー!!
      エレキベア
      エレキベア
      クマ〜〜〜

      【Next.js】第三回 WordPressブログをNext.jsに移行する 〜Markdown執筆環境構築編〜 〜完〜

      【都会のエレキベア】ブログを大幅リニューアル!WordPressからNext.jsに移行するまでの流れをまとめる
      2024-01-01
      【Next.js】第一回 WordPressブログをNext.jsに移行する 〜全体設計、環境構築編〜
      2023-12-31
      【Next.js】第二回 WordPressブログをNext.jsに移行する 〜WordPressデータの移行・表示編〜
      2023-12-31
      【Next.js】第三回 WordPressブログをNext.jsに移行する 〜Markdown執筆環境構築編〜
      2023-12-31
      【Next.js】第四回 WordPressブログをNext.jsに移行する 〜サーバ移行・SEO・広告設定編〜
      2023-12-31

      Next.jsReactJavaScriptWordPress関連フロントエンド関連SSGStorybookEmotionNode.jsp5.js
      2023-12-31

      関連記事
      【ゲーム数学】第九回 p5.jsで学ぶゲーム数学「フーリエ解析」
      2024-05-12
      【Node.js】廃止されたAmazonアソシエイト画像リンクをAmazon Product Advertising API経由で復活させる
      2024-01-08
      【都会のエレキベア】ブログを大幅リニューアル!WordPressからNext.jsに移行するまでの流れをまとめる
      2024-01-01
      【Next.js】第四回 WordPressブログをNext.jsに移行する 〜サーバ移行・SEO・広告設定編〜
      2023-12-31
      【Next.js】第二回 WordPressブログをNext.jsに移行する 〜WordPressデータの移行・表示編〜
      2023-12-31
      【Next.js】第一回 WordPressブログをNext.jsに移行する 〜全体設計、環境構築編〜
      2023-12-31
      【Electron × Vue3】カテゴリ情報のCSVデータを操作するツールを作る
      2023-12-31
      【Electron × Vue3】画像をリサイズして任意の場所に保存するツールを作る
      2023-12-31