mythfinder

📝

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",
    email: "[email protected]",
    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等)には対応していないようなので、やむを得ずこうなっている。