Next.js で Markdown ブログを作ってみた
目次
はじめに
Next.js で Markdown を投稿できるブログを作ってみた。コツコツ作ってきてようやく Markdown の投稿を反映できるようになったのでこの記事を投稿してみる。
環境
名前 | 説明 |
---|---|
ソースコード | https://github.com/jme-rs/next-app |
Next.js | React のフレームワークで静的レンダリングができる。 |
vercel | このブログをホスティングしているサービス。github と連携してデプロイできる。 |
scss | css の拡張言語。 |
unified | Markdown の処理に使用 |
デザイン
- sscs で一から頑張った。
- ダークモード: ヘッダー右上のアイコンで切り替えられる。デフォルトはシステム設定に従う。
- レスポンシブ
- フォント:
ライブラリ
- unified: 以下のライブラリを統一的に扱えるライブラリ。
- remark-parse: Markdown から MDAST に変換。
- remark-breaks: 改行を
<br>
に変換。 - remark-gfm: GitHub Flavored Markdown に対応。
- remark-frontmatter: Markdown のメタデータとしてファイルの先頭に書かれた YAML を JSON に変換。
- remark-extract-frontmatter: Markdown のメタデータを抽出。
- remark-rehype: MDAST から HAST に変換。
- rehype-react: HAST から React のコンポーネントに変換。
- rehype-slug: 見出しに id を付与。
- rehype-autolink-headings: 見出しにリンクを付与。
- rehype-toc: 目次を生成。
- shiki: VSCode のシンタックスハイライトを使えるライブラリ。
- Material Icons: ブログ内で使用するアイコン。
- その他のライブラリは github を参照。
実装
ディレクトリ構成
.
├── 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 のコンポーネントに変換する。
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"
というクラスが付与される。これを分解して lang
と fileName
に分けて CodeBlock
に渡す。
コードブロック
shiki を使って VSCode のシンタックスハイライトを実現する。prism よりも細かくハイライトを効かせることができるのでこちらを採用した。
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>
);
};
スタイルが上手く効くようにしたり、レスポンシブ対応でスクロールできるようにしたりしている。ファイル名の表示にも対応した。
見た目が結構気に入っている。
ダークモード
ヘッダー右上のアイコンでダークモードを切り替えられる。
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
属性で切り替えている。
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 といったフレームワークを利用することでかなり生産性が上がったことが実感できた。まだ実装しきれていないので機能を追加していきたい。