📝

Next.jsでブログをつくった

自作ブログの実装について

(2022/03/03 追記)Next.js 13 / App Router対応をやった

(2023/10/05 追記)Astroで書き直した

はじめに

GitHub - haxibami/haxibami.net: haxibami's website.
haxibami's website. Contribute to haxibami/haxibami.net development by creating an account on GitHub.
github.com
GitHub - haxibami/haxibami.net: haxibami's website.

ブログを自作した。決め手は以下の四つ。

  1. 適度な距離
  2. メンテナンス性
  3. 高速性と拡張性
  4. 広告

1. 適度な距離

あらゆるものが最適化されて提供されるこの時代、遅配や誤配の確率はとても低い。大きなプラットフォームはユーザーのbuzzをすすんで後押しし、かれに向けて、かれのために、とパーソナライズに躍起だ。書き手と読み手の距離は透明に、コミュニケーションは確実に。だがそうではない形式もかつてはあった。ひょっとしたら誰かに拾われるかもしれない、あるいはGoogleのクローラにさえ拾われないかもしれない、そうした確率論的な雲のなかに自らの書いたものを打ち上げる。そして祈る。古き良き日々(グッド・オールド・インターネッツ)理想郷ではなかったかもしれないが、あの誰かのものになる前の世界の、その歪な手触りを覚えておくための、個人サイトという距離感。

2. メンテナンス性

先人たちが示してきたとおり、この手の個人サイトは管理・移行が億劫になった時点でエタる放置された「〇〇の部屋」、消えて還らない借りドメイン、むなしく刻む入室カウンターたちを眺めるたびに、せめて記事くらいは移行しやすい形式で扱いたいと思うようになった。そういうわけでMarkdown(コンテンツ) + tsx(テンプレート)。この組み合わせならそう簡単には廃れないだろうし、いつか別サービス・別フレームワークに移るときにもそれほど困らない。

3. 高速性と拡張性

Next.js。個人サイトには若干過剰の感もあるものの、ページ遷移の気持ちよさと画像の最適化が魅力的。あと少々複雑なことをしようとしてもフレームワークの守備範囲を出ないのは良い。

4. 広告や統計の排除

過剰な広告・統計に対して憎悪を抱いているため、このサイトには設置していない。唯一、ホスティング先であるVercelが行っているアナリティクスだけは確認している。こちらの記事参照。

機能一覧と実装

以下はこのブログの機能と実装の展覧会

Markdownの処理

(2022/12/28 更新)

next-mdx-remote処理している。

getStaticProps/pages場合)ないしfetch / cache/app場合)を通じてコンテンツを取得し、JSXにコンパイルして返却する仕様になっている。また内部ではremark / rehype / MDX系のAPIが用いられており、これらの系列のプラグインが利用できる。

記事メタデータの取得

---
slug: "blog-renewal"
title: "Next.jsでブログをつくった"
date: "20220326"
tags: ["tech", "web", "nextjs"]
---

GitHub Flavored Markdown

| just a | table |
| ------ | ----- |
| 1 | 2 |
| 3 | 4 |
https://www.haxibami.net
- [x] トゥードゥー
- [ ] リストや、脚注[^1]も書ける
[^1]: 脚注だよ〜
just atable
12
34
神話募集中
haxibamiのサイト
www.haxibami.net
神話募集中

絵文字

:v: ✌️ に。

数式

適当なところで KaTeX のスタイルシートを読み込む必要がある。

$$
( \sum*{k=1}^{n} a_k b_k )^2 \leq ( \sum*{k=1}^{n} {a*k}^2 ) ( \sum*{k=1}^{n} {b_k}^2 )
$$
(k=1nakbk)2(k=1nak2)(k=1nbk2)( \sum_{k=1}^{n} a_k b_k )^2 \leq ( \sum_{k=1}^{n} {a_k}^2 ) ( \sum_{k=1}^{n} {b_k}^2 )
$e^{i\pi} + 1 = 0$ :arrow_left: インライン数式

eiπ+1=0e^{i\pi} + 1 = 0 ⬅️ インライン数式

ルビ

> 昨日午後、\{†聖剣†|エクスカリバー\}を振り回す\{全裸中年男性|無敵の人\}が出現し……

昨日午後、†聖剣†(エクスカリバー)振り回す全裸年男性(無敵の人)出現し……

ページ内の見出しのリンク

:arrow_right: [はじめに](#はじめに)に飛べるよ

➡️ はじめに飛べるよ

Mermaid Diagram

(2023/05/07 更新)

rehype-mermaidjs使った。通常のMermaidの使い方では、クライアント側でJSを実行せざるを得ないが、このプラグインを使うとビルド時にヘッドレスChromiumで2 あらかじめSVGが描画され、静的にドキュメントに埋め込まれる。

```mermaid
sequenceDiagram
Alice->>John: Hello John, how are you?
loop Healthcheck
John->>John: Fight against hypochondria
end
Note right of John: Rational thoughts!
John-->>Alice: Great!
John->>Bob: How about you?
Bob-->>John: Jolly good!
```
```mermaid
stateDiagram-v2
state fork_state <<fork>>
[*] --> fork_state
fork_state --> State2
fork_state --> State3
state join_state <<join>>
State2 --> join_state
State3 --> join_state
join_state --> State4
State4 --> [*]
```
```mermaid
pie
"Dogs" : 386
"Cats" : 85
"Rats" : 15
```

シンタックスハイライト

rehype-pretty-code利用した。このプラグインは内部でshiki利用しており、スタイル適用がビルド時に行われる(= 追加CSS不要)、VSCodeのカラースキームが使えるなどの利点がある。

リンクカード

こういう ⬇️ カード。

自分のウェブサイトにブログカードを実装してみた
zenn.dev
自分のウェブサイトにブログカードを実装してみた
unified を使って Markdown を拡張する
zenn.dev
unified を使って Markdown を拡張する

上の記事を参考に、unifiedのTransformerプラグインを自作して実装したおおむね、

  1. 文書中に単独で貼られたリンクのNode検出
  2. リンク先にアクセスしてメタデータtitledescriptionog image)を取得
  3. これらの情報をNode属性に付加し、独自要素(例:<linkcard>)に置き換え
  4. 独自要素を、MDXの処理系next-mdx-remote側で自作コンポーネントに置換

する処理を行っている。4. は以下のような感じになっている。

src/components/MDXComponent/index.tsx
import LinkCard from "components/LinkCard";
import NextImage from "components/NextImage";
import NextLink from "components/NextLink";
import type { LinkCardProps } from "components/LinkCard";
import type { NextImageProps } from "components/NextImage";
import type { NextLinkProps } from "components/NextLink";
import type { MDXComponents } from "mdx/types";
type ProvidedComponents = MDXComponents & {
a: typeof NextLink;
img: typeof NextImage;
linkcard: typeof LinkCard;
};
const replaceComponents = {
a: (props: NextLinkProps) => <NextLink {...props} />,
img: (props: NextImageProps) => <NextImage {...props} />,
linkcard: (props: LinkCardProps) => <LinkCard {...props} />,
} as ProvidedComponents;
export default replaceComponents;
src/lib/compiler.ts
import MDXComponent from "components/MDXComponent";
const result = compileMDX({
source,
components: MDXComponent,
});

画像処理

Markdown内の画像をnext/image置き換えるremarkプラグインを書いた。置換に加え、画像のサイズ取得・プレースホルダー生成参考:公式ドキュメント)も行っている。

src/lib/remark-image-opt.ts
import { getPlaiceholder } from "plaiceholder";
import { visit } from "unist-util-visit";
import type { Image } from "mdast";
import type { Plugin, Transformer } from "unified";
import type { Node } from "unist";
const rehypeImageOpt: Plugin<[void]> = function imageOpt(): Transformer {
return async (tree: Node) => {
const promises: (() => Promise<void>)[] = [];
visit(tree, "image", (node: Image) => {
const src = node.url;
promises.push(async () => {
const blur = await getPlaiceholder(src);
node.data = {
hProperties: {
src: blur.img.src,
width: blur.img.width,
height: blur.img.height,
aspectRatio: `${blur.img.width} / ${blur.img.height}`,
blurDataURL: blur.base64,
},
};
});
});
await Promise.allSettled(promises.map((t) => t()));
};
};
export default rehypeImageOpt;

ダークモード

外部ライブラリ使用。

Open Graph画像の生成

(2022/05/07 更新)

別記事参照。

サイトマップ生成

(2023/05/07 更新)

別記事参照。

フィード対応

Feedいうライブラリを使って形式を整え、上と同じ要領でビルド時に RSS、Atom、JSON Feed用のファイルを吐かせている。

hooks/scripts/feed.mts
import fs from "fs";
import { Feed } from "feed";
import { dateConverter } from "./lib/build.js";
import { SITEDATA } from "./lib/constant.js";
import { getPostsData } from "./lib/fs.js";
// variables
const HOST = "https://www.haxibami.net";
// generate feed
const feedGenerator = async () => {
const author = {
name: "haxibami",
link: HOST,
};
const date = new Date();
const feed = new Feed({
title: SITEDATA.blog.title,
description: SITEDATA.blog.description,
id: HOST,
link: HOST,
language: "ja",
image: `${HOST}/kripcat.jpg`,
favicon: `${HOST}/favicon.ico`,
copyright: `All rights reserved ${date.getFullYear()}, ${author.name}`,
updated: date,
feedLinks: {
rss2: `${HOST}/rss/feed.xml`,
json: `${HOST}/rss/feed.json`,
atom: `${HOST}/rss/atom.xml`,
},
author: author,
});
const blogs = await getPostsData("articles/blog");
blogs.forEach((post) => {
const url = `${HOST}/blog/posts/${post.data?.slug}`;
feed.addItem({
title: `${post.data?.title}`,
description: `${post.preview}`,
id: url,
link: url,
guid: url,
date: new Date(dateConverter(post.data?.date)),
category: post.data?.tags
? post.data?.tags.map((tag) => ({
name: tag,
}))
: [],
enclosure: {
url: encodeURI(
`${HOST}/api/ogp?title=${post.data?.title}&date=${post.data?.date}.png`,
),
length: 0,
type: "image/png",
},
});
});
fs.mkdirSync("public/rss", { recursive: true });
await Promise.all([
fs.promises.writeFile(
"public/rss/feed.xml",
feed.rss2().replace(/&/g, "&amp;"),
),
fs.promises.writeFile("public/rss/atom.xml", feed.atom1()),
fs.promises.writeFile("public/rss/feed.json", feed.json1()),
]);
};
const GenFeed = () => {
return new Promise<void>((resolve) => {
feedGenerator();
resolve();
});
};
export default GenFeed;

感想

最高! はてなブログやQiita、Zennあたりと張り合える書き心地かもしれない。

脚注

  1. 脚注だよ〜

  2. こんなことのためにわざわざヘッドレスブラウザを使うのもアレだが、MermaidはNode.js上で動くDOMライブラリJSDOMhappy-dom等)には対応していないようなのでやむを得ずこうなっている。