jme Blog

Next.js で Markdown ブログを作ってみた

自作ブログの詳細
2023-11-11
2023-11-15
Next.js
Markdown
Tech
目次

はじめに

Next.js で Markdown を投稿できるブログを作ってみた。コツコツ作ってきてようやく Markdown の投稿を反映できるようになったのでこの記事を投稿してみる。

環境

名前説明
ソースコードhttps://github.com/jme-rs/next-app
Next.jsReact のフレームワークで静的レンダリングができる。
vercelこのブログをホスティングしているサービス。github と連携してデプロイできる。
scsscss の拡張言語。
unifiedMarkdown の処理に使用

デザイン

  • sscs で一から頑張った。
  • ダークモード: ヘッダー右上のアイコンで切り替えられる。デフォルトはシステム設定に従う。
  • レスポンシブ
  • フォント:

ライブラリ

実装

ディレクトリ構成

.
├── app
│   ├── about
│   ├── blog
│   ├── experimental
│   ├── favicon.png
│   ├── globals.scss
│   ├── layout.tsx
│   ├── manifest.ts
│   └── page.tsx
├── assets
│   ├── codes
│   ├── fonts
│   ├── images
│   └── posts
├── components
│   ├── _react-markdown.tsx
│   ├── article-header.module.scss
│   ├── ...
│   ├── tag.tsx
│   └── zenn-markdown.tsx
├── styles
│   ├── common.scss
│   └── variables.scss
├── types
│   ├── page-meta.d.ts
│   └── post.d.ts
└── utils
    ├── file.ts
    ├── md-processor.tsx
    ├── page-info.ts
    └── post.ts
  • app: Next.js の App router
  • assets: 画像や投稿を配置
  • components: React コンポーネントと スタイル
  • styles: 共通のスタイル
  • types: 型定義
  • utils: ユーティリティ関数を格納

マークダウンの処理

unified を使って Markdown を React のコンポーネントに変換する。

src/utils/md-processor.tsx
import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import { unified } from 'unified';
// import remarkMdx from 'remark-mdx'
import rehypeReact from "rehype-react";
import remarkFrontmatter from "remark-frontmatter";
import CodeBlock from '../components/code-block';
import remarkBreaks from "remark-breaks";
// import { inspect } from "unist-util-inspect";
import * as prod from "react/jsx-runtime";
import { visit } from "unist-util-visit";
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import rehypeToc from "rehype-toc";
import remarkExtractFrontmatter from "remark-extract-frontmatter";
import yaml from "yaml";
import { parseSelector } from "hast-util-parse-selector";
import LinkCard from '../components/link-card';

//
// extensions
//
function extractCodeBlock() {
  return (tree: any) => {
    visit(tree, 'element', (node, index, parent) => {
      if (node.tagName === 'code' && parent.tagName === 'pre') {
        // parent.tagName = node.tagName;
        parent.children = node.children;
        parent.properties.className = node.properties.className;
      }
      else if (node.tagName === "code") {
        node.properties.className = "inline-code-block"
      }
    });
  };
}

function chanegeFootnoteName() {
  return (tree: any) => {
    visit(tree, 'element', (node, index, parent) => {
      if (node.tagName === "h2" && node.properties.id === "footnote-label") {
        node.children[0].value = "参考文献";
      }
    });
  };
}

function tableWrapper() {
  return (tree: any) => {
    visit(tree, 'element', (node, index, parent) => {
      if (node.tagName === "table") {
        const wrapper = parseSelector("div") as any;
        wrapper.children = [node];
        wrapper.properties.className = "table-wrapper";
        parent.children[index as number] = wrapper;
      }
    });
  };
}

//
// processor
//
const processor = unified()
  .use(remarkParse)
  .use(remarkFrontmatter)
  .use(remarkExtractFrontmatter, {
    yaml: yaml.parse,
    name: 'frontMatter'
  })
  .use(remarkBreaks)
  .use(remarkGfm)
  // .use(remarkMdx)
  .use(remarkRehype)
  .use(extractCodeBlock)
  .use(rehypeReact, {
    ...prod,
    components: {
      pre: (props: any) => {
        var lang: string | undefined;
        var fileName: string | undefined;
        const className = props.className?.replace("language-", "").split(":");
        if (className && className.length === 1) {
          lang = className[0];
        }
        else if (className && className.length === 2) {
          lang = className[0];
          fileName = className[1];
        }
        return (
          <CodeBlock
            lang={lang}
            fileName={fileName}
          >
            {props.children}
          </CodeBlock>
        )
      },
    },
  } as any)
  .use(chanegeFootnoteName)
  .use(tableWrapper)
  .use(rehypeSlug)
  .use(rehypeAutolinkHeadings)
  .use(rehypeToc, { headings: ["h2", "h3"] });

export default processor;

extractCodeBlock()

ソースコードを表示するコードブロックを抽出する。

function extractCodeBlock() {
  return (tree: any) => {
    visit(tree, 'element', (node, index, parent) => {
      if (node.tagName === 'code' && parent.tagName === 'pre') {
        parent.children = node.children;
        parent.properties.className = node.properties.className;
      }
      else if (node.tagName === "code") {
        node.properties.className = "inline-code-block"
      }
    });
  };
}
`code`

    ```ts
    console.log("hello");
    ```

を区別するためのクラスを付与する。

chanegeFootnoteName()

footnote の見出しを変更する。

function chanegeFootnoteName() {
  return (tree: any) => {
    visit(tree, 'element', (node, index, parent) => {
      if (node.tagName === "h2" && node.properties.id === "footnote-label") {
        node.children[0].value = "参考文献";
      }
    });
  };
}

tableWrapper()

表をラップするクラスを付与する。

function tableWrapper() {
  return (tree: any) => {
    visit(tree, 'element', (node, index, parent) => {
      if (node.tagName === "table") {
        const wrapper = parseSelector("div") as any;
        wrapper.children = [node];
        wrapper.properties.className = "table-wrapper";
        parent.children[index as number] = wrapper;
      }
    });
  };
}

.use(remarkExtractFrontmatter, {...})

Markdown のメタデータを抽出する。

.use(remarkExtractFrontmatter, {
    yaml: yaml.parse,
    name: 'frontMatter'
  })

この場合、設定した 'frontMatter' で取得できる。

const md = getLocalFile(srcPath);
const content = processor.processSync(md);
const frontMatter = content.data.frontMatter as PostMetadata;

.use(rehypeReact, {...})

タグに React コンポーネントをマッピングする。コードブロックを自作のコンポーネントに置き換える。

.use(rehypeReact, {
  ...prod,
  components: {
    pre: (props: any) => {
      var lang: string | undefined;
      var fileName: string | undefined;
      const className = props.className?.replace("language-", "").split(":");
      if (className && className.length === 1) {
        lang = className[0];
      }
      else if (className && className.length === 2) {
        lang = className[0];
        fileName = className[1];
      }
      return (
        <CodeBlock
        lang={lang}
        fileName={fileName}
        >
        {props.children}
        </CodeBlock>
      )
    },
  },
} as any)

Markdown で ts:file-name.ts と記述すると、class = "language-ts:file-name.ts" というクラスが付与される。これを分解して langfileName に分けて CodeBlock に渡す。

コードブロック

shiki を使って VSCode のシンタックスハイライトを実現する。prism よりも細かくハイライトを効かせることができるのでこちらを採用した。

src/components/code-block.tsx
import styles from "./code-block.module.scss";
import shiki from "shiki";

export default async function CodeBlock({
  children,
  lang,
  fileName,
}: {
  children: string,
  lang?: string,
  fileName?: string,
}) {

  console.log("CodeBlock", lang, fileName);

  const highlighter = await shiki.getHighlighter({ theme: "dark-plus" });
  const tokens = highlighter.codeToThemedTokens(children, lang);
  const htmlString = shiki.renderToHtml(tokens, {
    bg: highlighter.getBackgroundColor("dark-plus"),
    fg: highlighter.getForegroundColor("dark-plus"),
    elements: {
      pre({ className, style, children }) {
        return `<pre class="${className} ${styles.pre}" style="${style}" tabindex="0">${children}</pre>`;
      },
      code({ children }) {
        return `<code class="${styles.code}">${children}</code>`
      }
    }
  })

  return (
    <div className={styles.container}>
      {fileName &&
        <div className={styles.filenameSpace}>{fileName}</div>
      }
      <div
        className={`${styles.codeFrame} ${fileName ? styles.withFilename : ""}`}
        dangerouslySetInnerHTML={{ __html: htmlString }}
      >
      </div>
    </div>
  );
};

スタイルが上手く効くようにしたり、レスポンシブ対応でスクロールできるようにしたりしている。ファイル名の表示にも対応した。

code-block
見た目が結構気に入っている。

ダークモード

ヘッダー右上のアイコンでダークモードを切り替えられる。

html,
body {

  ...


  --light-text-color: gray;

  &[data-theme="light"] {
    --primary-color: #006ac6;
    --secondary-color: #e6f1ff;
    --border-color: #ddd;
    --text-color: black;
    --background-color: rgb(255, 255, 255);
    --tag-color: #399681;
    --backquote-color: #555;
  }

  &[data-theme="dark"] {
    --primary-color: #47a9ff;
    --secondary-color: #68a7ff24;
    --border-color: #404040;
    --text-color: #eee;
    --background-color: #222;
    --tag-color: #2c7564;
    --backquote-color: #bbb;
  }


  ...

}

body タグの data-theme 属性で切り替えている。

src/components/header.tsx
export function Header() {

  ...


  // dark mode
  const [isDarkMode, setIsDarkMode] = useState(false);
  const toggleDarkMode = () => {
    setIsDarkMode(!isDarkMode);
  };

  useEffect(() => {
    document.body.setAttribute("data-theme", isDarkMode ? "dark" : "light");
    console.log("dark mode: " + isDarkMode);
  }, [isDarkMode]);


  // initialize dark mode
  useEffect(() => {
    setIsDarkMode(window.matchMedia('(prefers-color-scheme: dark)').matches);
  }, []);


  ...

}

これはヘッダーコンポーネントの一部である。 useState でダークモードの状態管理を行う。

// initialize dark mode
useEffect(() => {
  setIsDarkMode(window.matchMedia('(prefers-color-scheme: dark)').matches);
}, []);

初期化時にシステム設定を取得してダークモードの初期値を設定する。システムがダークモードのときは prefers-color-scheme: dark が自動で設定されることを利用している。

最後に

前回初めてホームページを作ったときに javascript を使ってみたが、ヘッダーなど共通部分を実装するだけで大変だった。Next.js や React といったフレームワークを利用することでかなり生産性が上がったことが実感できた。まだ実装しきれていないので機能を追加していきたい。