抹桥的博客
Language
Home
Archive
About
GitHub
Language
主题色
250
4093 文字
20 分
ブログのリファクタリング 〜 hexoからnext+contentLayerへの移行

Hexoベースのブログを長年使ってきましたが、多くのテーマやプラグインが利用でき、かなり快適に使っていました。しかし、最近Hexoから移行することを検討しており、その理由は以下の通りです。

  • ブログに対するコントロール感が弱すぎる。機能を追加したい場合、Hexoのアップデートやテーマ作者の更新を待つ必要がある。
  • 単純に何かをいじりたいだけ(これが主な理由でしょう)。

最終的にはNext.jsへの移行を考えています。Next.jsはフロントエンドとバックエンドが一体となっており、純粋な静的サイトとしても、通常のバックエンドサービスを持つウェブサイトとしても機能するため、私の要望に非常に合致します。また、Hexoを使うよりもサイトに対するコントロール力も格段に高まりますが、もちろんその分、より多くの時間を費やすことになります。

目標#

移行の目標:

  • 元のブログ記事のリンクを維持する。
  • コメントシステムを移行する:元々Disqusを使用していましたが、中国国内では使い勝手が悪すぎました。

バックアップ#

バックアップ:jiacai2050/blog-backup: Backup blogposts to PDF for offline storage, built with Puppeteer and ClojureScript (github.com) を利用して、古いブログをPDFファイルとしてバックアップし、記念として残します。

方案#

技術選定:

  • フレームワーク:Next.js
  • コンテンツ生成:Contentlayer.js
  • スタイル:Tailwind CSS

Hexo Markdown構文の互換性#

Hexo特有の構文、例えば <!-- more -->{% asset_img me.jpg 搬家 %} といったプライベートな構文に対応するため、remarkを使ってプラグインを開発し、互換性を持たせる必要があります。

<!-- more --> の互換性#

<!-- more --> の互換性については、当初remarkプラグインを書いてMarkdownファイル内の <!-- more --> を見つけ、その前の部分を抽出し、briefのようなカスタム変数に格納し、後でレンダリング時にそこから読み込むという方法を考えていました。

しかし、実装時にいくつかの問題に直面しました。取得できるのはレンダリング済みのHTMLではなく、未レンダリングのMarkdownファイルでした。Contentlayerを使用しているため、MarkdownからHTMLへの変換プロセスはContentlayerによって制御されており、そこにプラグインを追加することは可能です。しかし、プラグインの順序の問題なのか、どうにも調整がうまくいかず、この方法は諦めました。

そこで、Contentlayerに直接フィールドを追加し、既にレンダリングされたHTMLファイルから最初の500文字を記事の brief として切り出すことにしました。カスタマイズ性は劣りますが、まずは動くようになりました。具体的なコードは以下の通りです。

/** @type {import('contentlayer/source-files').ComputedFields} */
const computedFields: import("contentlayer/source-files").ComputedFields = {
  permalink: {
    type: "string",
    resolve: (doc) => {
		...
  },
  brief: {
    type: "string",
    resolve: (doc) => {
      // TODO: 使用 remark 插件来处理文章中 <!-- more --> 注释
      const htmlContent = doc.body.html
      return htmlContent.substring(0, 500)
    },
  },
  readingTime: { type: "json", resolve: (doc) => readingTime(doc?.body?.raw) },
}

画像埋め込み構文の互換性#

Hexoには {% asset_img me.jpg 搬家 %} のような画像埋め込み構文が多くあります。当初はremarkプラグインを使ってこれらを通常のMarkdown構文に変換し、通常のMarkdown変換プロセスを通すことを試みました。しかし、実際にやってみると常に問題が発生し、最終的な出力は ![搬家](me.jpg) となり、期待する <img /> タグにはなりませんでした。

私の理解では、まずMarkdown内の非標準構文を標準構文に変換し、その後、標準構文から通常のMarkdownレンダリングを経てHTMLにするというものでした。しかし、どこに問題があるのか、プラグインの順序なのか、あるいはremarkのプラグイン原理に対する私の理解に誤りがあるのか、いずれにしても最終的な <img /> タグとしてレンダリングすることができませんでした。プラグイン内で直接 {% asset_img me.jpg 搬家 %}<img alt="搬家" src="/me.jpg" /> に変更しない限りは無理でしたが、これは他のremarkプラグインの処理に影響を与える可能性があり、良くないと感じました。

元のプラグインコード:

import { visit } from "unist-util-visit";
import fs from "fs";
import path from "path";

const targetImgDir = path.join(process.cwd(), "public/imgs");
const markdownPath = path.join(process.cwd(), "source/posts");
const isLoggingEnabled = true; // 设置为true以启用日志,为false则禁用日志

function log(message: string) {
  if (isLoggingEnabled) {
    console.log(message);
  }
}

function handleAssetReference(
  sourceFilePath: string,
  imgFileName: string
): string | null {
  // 获取当前Markdown文件的名称(不包括后缀)
  const filenameWithoutExtension = path.basename(
    sourceFilePath,
    path.extname(sourceFilePath)
  );

  // 获取当前Markdown文件的路径
  const mdDir = path.dirname(sourceFilePath);

  // 根据文件名动态构建可能的图片路径
  const possiblePaths = [
    path.join(mdDir, filenameWithoutExtension, imgFileName),
    path.join(mdDir, "../imgs", imgFileName),
  ];
  let sourcePath: string | null = null;

  for (const possiblePath of possiblePaths) {
    if (fs.existsSync(possiblePath)) {
      sourcePath = possiblePath;
      break;
    }
  }

  if (!sourcePath) {
    log(`Image ${imgFileName} not found for file ${sourceFilePath}`);
    return null;
  }

  // 复制图片到 public/imgs 文件夹下的相应目录
  const targetPath = path.join(
    targetImgDir,
    filenameWithoutExtension,
    imgFileName
  );
  fs.mkdirSync(path.dirname(targetPath), { recursive: true }); // 确保目录存在
  fs.copyFileSync(sourcePath, targetPath);
  log(`Copied image from ${sourcePath} to ${targetPath}`);

  // 返回新的相对路径
  return `/imgs/${filenameWithoutExtension}/${imgFileName}`;
}

function replaceAssetImgPlugin() {
  return (tree: any, file: any) => {
    visit(tree, "text", (node: any) => {
      // 处理自定义 asset_img 语法
      const customAssetImgPattern = /\{% asset_img (.*?)\s+(.*?) %\}/g;
      node.value = node.value.replace(
        customAssetImgPattern,
        (_, imgFileName, imgAltText) => {
          return `![${imgAltText}](${imgFileName})`;
        }
      );
    });

    // 处理标准的 markdown 图像引用
    visit(tree, "image", (node: any) => {
      const sourceFilePath = path.join(
        markdownPath,
        file.data.rawDocumentData.flattenedPath
      );
      const imgFileName = path.basename(node.url);
      const newURL = handleAssetReference(sourceFilePath, imgFileName);
      if (newURL) {
        node.url = newURL;
      }
    });
  };
}

export default replaceAssetImgPlugin;

ここではいくつかのことを行っています:

  • すべてのカスタム画像参照構文を見つけ、画像名を解析する。
  • 画像名に基づいて、指定されたディレクトリから具体的な画像ファイルを探す。
  • 見つかった画像ファイルを public/imgs/ ディレクトリにコピーし、カスタム構文を標準のMarkdown画像参照構文に修正する。同時に、画像参照アドレスをコピー後の画像アドレスに変更し、Next.jsがこれらの画像に正常にアクセスできるようにする。

しかし、この方法はうまくいきませんでした。最終的に、なぜこんなに遠回りをする必要があるのかと考えました。どうせHexoから移行するのだから、元の非標準構文はもはや存在意義がありません。それなら直接置き換えればいい、と。そこで、ディレクトリ内のすべてのMarkdownファイルから非標準の画像参照構文を標準構文に直接変更し、参照アドレスも置き換えるスクリプトを作成しました。これで一挙に解決です。スクリプトコードは以下の通りです。

const fs = require("fs");
const path = require("path");

// 定义目标图片文件夹路径
const targetImgDir = path.join(process.cwd(), "public/imgs");
// 定义 Markdown 文件路径
const markdownPath = path.join(process.cwd(), "source/posts");

function handleAssetReference(sourceFilePath, imgFileName) {
  // 获取当前Markdown文件的名称(不包括后缀)
  const filenameWithoutExtension = path.basename(
    sourceFilePath,
    path.extname(sourceFilePath)
  );
  // 获取当前Markdown文件的路径
  const mdDir = path.dirname(sourceFilePath);
  // 定义可能的图片路径数组
  const possiblePaths = [
    path.join(mdDir, filenameWithoutExtension, imgFileName),
    path.join(mdDir, "../imgs", imgFileName),
  ];
  let sourcePath = null;

  // 遍历所有可能的图片路径,找到存在的图片路径
  for (const possiblePath of possiblePaths) {
    if (fs.existsSync(possiblePath)) {
      sourcePath = possiblePath;
      break;
    }
  }

  // 如果没有找到图片路径,打印错误信息
  if (!sourcePath) {
    console.log(`Image ${imgFileName} not found for file ${sourceFilePath}`);
    return imgFileName;
  }

  // 定义目标图片路径,并创建该路径的文件夹
  const targetPath = path.join(
    targetImgDir,
    filenameWithoutExtension,
    imgFileName
  );
  fs.mkdirSync(path.dirname(targetPath), { recursive: true });
  // 将源图片复制到目标路径
  fs.copyFileSync(sourcePath, targetPath);
  console.log(`Copied image from ${sourcePath} to ${targetPath}`);

  // 返回新的图片URL
  return `/imgs/${filenameWithoutExtension}/${imgFileName}`;
}

function processMarkdownFile(filePath) {
  // 读取Markdown文件内容
  let content = fs.readFileSync(filePath, "utf-8");

  // 定义自定义图片引用的正则表达式
  const customAssetImgPattern = /\{% asset_img (.*?)\s+(.*?) %\}/g;
  // 替换Markdown文件中的自定义图片引用
  content = content.replace(
    customAssetImgPattern,
    (_, imgFileName, imgAltText) => {
      const newURL = handleAssetReference(filePath, imgFileName);
      return `![${imgAltText}](${newURL})`;
    }
  );

  // 将处理后的内容写回文件
  fs.writeFileSync(filePath, content);
  console.log(`Processed file: ${filePath}`);
}

function processMarkdownFilesInDir(dir) {
  // 读取目录下的所有文件
  const files = fs.readdirSync(dir);
  for (const file of files) {
    const filePath = path.join(dir, file);
    // 如果是Markdown文件,则处理该文件
    if (path.extname(filePath) === ".md") {
      processMarkdownFile(filePath);
    }
  }
}

// 处理指定目录下的所有Markdown文件
processMarkdownFilesInDir(markdownPath);

コンテンツ取得#

新しいソリューション(落とし穴でした。移行時には知らず、移行完了後にメンテナンスが終了していることに気づきました):Getting Started – Contentlayer を採用しました。これはコンテンツを型安全なJSONファイルに変換することで、システムでのインポートと呼び出しを容易にします。 特徴:

  • ファイル形式の検証:事前に必須の frontMeta フィールドを宣言することで、フィールドを標準化します。
// contentlayer.config.ts
import { defineDocumentType, makeSource } from "contentlayer/source-files";

export const Post = defineDocumentType(() => ({
  name: "Post",
  filePathPattern: `**/*.md`,
  fields: {
    title: { type: "string", required: true },
    date: { type: "date", required: true },
  },
  computedFields: {
    url: {
      type: "string",
      resolve: (post) => `/posts/${post._raw.flattenedPath}`,
    },
  },
}));

export default makeSource({ contentDirPath: "posts", documentTypes: [Post] });

使ってみると非常に便利でした。これは、非標準のMarkdownファイルを事前に定義されたスキーマで定義し、そのスキーマに基づいてTypeScriptの型定義ファイルを生成することで、コード内で型安全なドキュメントモデルを直接利用できるようにするものです。

最終的なContentlayerの設定ファイルは以下の通りです。

import { writeFileSync } from "fs"
import { defineDocumentType, makeSource } from "contentlayer/source-files"
import readingTime from "reading-time"
import rehypeAutolinkHeadings from "rehype-autolink-headings"
import rehypeCodeTitles from "rehype-code-titles"
import rehypeHighlight from "rehype-highlight"
import rehypePrism from "rehype-prism-plus"
import rehypeSlug from "rehype-slug"
import remarkGfm from "remark-gfm"

const root = process.cwd()
const isProduction = process.env.NODE_ENV === "production"
/**
 * Count the occurrences of all tags across blog posts and write to json file
 */
function createTagCount(allBlogs) {
  const tagCount: Record<string, number> = {}
  // const slugger = new GithubSlugger()
  allBlogs.forEach((file) => {
    if (file.tags && (!isProduction || file.draft !== true)) {
      file.tags.forEach((tag) => {
        if (tag in tagCount) {
          tagCount[tag] += 1
        } else {
          tagCount[tag] = 1
        }
      })
    }
  })
  writeFileSync("./app/tag-data.json", JSON.stringify(tagCount))
}

/** @type {import('contentlayer/source-files').ComputedFields} */
const computedFields: import("contentlayer/source-files").ComputedFields = {
  slug: {
    type: "string",
    // 返回当前文件所在目录的相对路径,比如 posts/2021-01-01-hello-world.md
    resolve: (doc) => `/${doc?._raw?.flattenedPath}`,
  },
  slugAsParams: {
    type: "string",
    resolve: (doc) => {
      // 返回当前文件所在目录的相对路径去除 posts 后的路径,比如 2021-01-01-hello-world.md
      return doc?._raw?.flattenedPath?.split("/")?.slice(1)?.join("/")
    },
  },
  permalink: {
    type: "string",
    resolve: (doc) => {
      const date = new Date(doc.date)
      const year = date.getFullYear()

      // 确保月份和日期始终有两位数字
      const month = (date.getMonth() + 1).toString().padStart(2, "0")
      const day = date.getDate().toString().padStart(2, "0")

      const slugAsParams = doc?._raw?.flattenedPath
        ?.split("/")
        ?.slice(1)
        ?.join("/")

      return `/${year}/${month}/${day}/${slugAsParams}`
    },
  },
  brief: {
    type: "string",
    resolve: (doc) => {
      // TODO: 使用 remark 插件来处理文章中 <!-- more --> 注释
      const htmlContent = doc.body.html
      return htmlContent.substring(0, 500)
    },
  },
  readingTime: { type: "json", resolve: (doc) => readingTime(doc?.body?.raw) },
}

export const Post = defineDocumentType(() => ({
  name: "Post",
  filePathPattern: `posts/**/*.md`,
  contentType: "markdown",
  fields: {
    title: {
      type: "string",
      required: true,
    },
    description: {
      type: "string",
    },
    date: {
      type: "date",
      required: true,
    },
    update: {
      type: "date",
      required: false,
    },
    tags: {
      type: "list",
      of: { type: "string" },
      default: [],
    },
    category: {
      type: "string",
      required: true,
      default: "",
    },
    image: {
      type: "string",
      required: false,
    },
    draft: {
      type: "boolean",
      required: false,
    },
  },
  computedFields,
}))

export default makeSource({
  contentDirPath: "./source",
  documentTypes: [Post],
  markdown: {
    rehypePlugins: [
	// 各种问题导致只能先把所有插件禁用掉了
      // rehypeSlug,
      // rehypeCodeTitles,
      // @ts-ignore
      // [rehypeHighlight],
      // rehypeHighlight,
      // [rehypePrism, { ignoreMissing: true }],
      // [
      //   rehypeAutolinkHeadings,
      //   {
      //     properties: {
      //       className: ["anchor"],
      //     },
      //   },
      // ],
    ],
    // remarkPlugins: [remarkGfm],
  },
  onSuccess: async (importData) => {
    const { allPosts } = await importData()
    createTagCount(allPosts)
    // createSearchIndex(allPosts)
  },
})

next-contentlayer プラグインと組み合わせることで、Next.jsアプリケーションで私が定義した post 型を直接使用できます。

import { Post } from "contentlayer/generated"

export function PostList({
  posts,
  pageSize = 10,
  currentPage = 1,
}: {
  posts: Post[]
  pageSize?: number
  currentPage?: number
}) {
  const currentPageList = getSpecificPagePosts(posts, {
    page: currentPage,
    pageSize,
  })
	……
}

ルーティング構造#

全体的なルーティング構造は概ね以下のようになります。

/[year]/[month]/[day]/[post]

/tag/
/tag/[tag]/
/tag/[tag]/page/[pageNum]

/category/
/category/[category]/
/category/[category]/page/[pageNum]

記事のパスに /[year]/[month]/[day]/{blogName} という形式を採用しているのは、Hexoでの記事のパーマリンクとの互換性を保つためです。

実装の考え方: 以前Hexoのパーマリンクを使用していた場合、記事には必ず date という frontMeta プロパティがあります。このプロパティに基づいて、Contentlayerですべての投稿に permalink プロパティを追加します。

  permalink: {
    type: "string",
    resolve: (doc) => {
      const date = new Date(doc.date)
      const year = date.getFullYear()
      const month = date.getMonth() + 1
      const day = date.getDate()
      const slugAsParams = doc?._raw?.flattenedPath
        ?.split("/")
        ?.slice(1)
        ?.join("/")
      console.log("permalink", `/${year}/${month}/${day}/${slugAsParams}`)
      return `/${year}/${month}/${day}/${slugAsParams}`
    },
  },

このプロパティが返すパスは、Hexoの以前のパーマリンクと一致し、2023/01/01/2022-year-end-summary/ のような形式になります。

その後、postList 内のすべての投稿のジャンプ先アドレスをこの permalink プロパティに指定します。

<CardHeader>
  <CardTitle className="m-0">
    // 使用 permalink 属性
    <Link href={post.permalink} className="no-underline">
      {post.title}
    </Link>
  </CardTitle>
  <CardDescription className="space-x-1 text-xs">
    <span>{format(parseISO(post.date), "MMMM dd, yyyy")}</span>
    <span>{` • `}</span>
    <span>{post.readingTime.text}</span>
    <span>{` • `}</span>
    <span>
      <Link
        href={`/categories/${encodeURIComponent(
          post.categories?.toLowerCase()
        )}`}
        className="underline underline-offset-2"
      >
        {post.categories}
      </Link>
    </span>
  </CardDescription>
</CardHeader>

そして、対応するルーティング構造である /[year]/[month]/[day]/[post] のファイルディレクトリを作成し、最下層のディレクトリに記事をレンダリングするための page.jsx を新規作成します。

実装上、Next.jsでは app ディレクトリ内に、このルーティング構造に対応するネストされたディレクトリを構築する必要があります。これは非常に面倒で、特にタグやカテゴリ関連のページは最終的にトップページと同じ記事リストになりますが、上記のディレクトリ構造を使用すると、基本的に重複するコンテンツを何度も参照し続ける必要があります。

ページネーションでURLパラメータではなくこのようなパス形式を使用する理由は、静的ページを生成するためです。これにより、ビルド後に静的ブログとしてデプロイでき、サーバーの実行が不要になり、Hexoと同様の運用が可能です。

コメントシステム#

当時、候補は3つありました。

最終的にはgiscusを選択しました。使い方が比較的簡単で、GitHubの機能を活用しているため、編集体験も非常に優れています。欠点としては、GitHubアカウントがないとコメントできない点です。

しかし、コメント自体は必須要件ではないため、まずは「あるかないか」の問題を解決しました。

デプロイ#

目標:コードをリポジトリのメインブランチにプッシュすると、自動デプロイ機能がトリガーされるようにする。

自分でサーバーを持っていますが、最終的にはVercelに直接デプロイすることにしました。あまりにも便利だったからです。私のブログのアクセス数は多くないので、Vercelの無料アカウントで十分です。時間があるときに、自分のサーバーへの自動デプロイプロセスを構築しようと思います。

まとめ#

ブログのリファクタリングをすると言ってからもうすぐ1年になりますが、ずっと先延ばしにしていました。リファクタリング自体の作業量はそれほど多くないものの、落ち着いてまとまった時間が必要で、結局今まで引き延ばしてきましたが、ようやくブログのリファクタリングをなんとか完了できました。まずは自分を褒めてあげたいと思います。

しかし、少し困ったことに、コードハイライト機能を調整している際に、rehype-highlight プラグインがどうやっても問題を起こすことがわかりました。後でContentlayerのドキュメントやIssueを調べてみると、なんとこのリポジトリはすでにメンテナンスされていないことが判明しました。もちろん、その理由は理解できます。プロジェクトをスポンサーしていた企業がスポンサーを辞めたため、経済的支援を失い、一時的にメンテナンスを中断するのは当然のことです。ただ、私の方では問題の解決策が見つからなかったので、今後はContentlayerの使用を取りやめる予定です。

しかし、それがいつになるかは未定です。

参考文章#

この記事は 2023年12月20日 に公開され、2023年12月20日 に最終更新されました。655 日が経過しており、内容が古くなっている可能性があります。

ブログのリファクタリング 〜 hexoからnext+contentLayerへの移行
https://blog.kisnows.com/ja-JP/2023/12/20/博客重构之-从hexo-迁移到-nextcontentlayer/
作者
Kisnows
公開日
2023-12-20
ライセンス
CC BY-NC-ND 4.0