~/gtn74

$ cat posts/20260711-6bacrl.mdx

CMSを入れずに記事投稿を楽にする — plopでMDXをscaffoldする

2026-07-11 · #tech #nextjs

はじめに

このブログはCMSを入れず、記事はMDXファイルを直接書いている。エディタ(Neovim)から離れずに書けるのが気に入っているのだが、記事を1本作るたびに地味な手作業が発生していた。

  • ファイル名をどうするか毎回悩む(hello-world.mdx? 2026-07-11-hello.mdx?)
  • frontmatter(title / date / tags / lang…)を毎回コピペして埋める
  • date に今日の日付を手で書く

大した手間ではないが、毎回やる・間違えるとビルドやURLが壊れる種類の作業だ。こういうのはツールに任せたい。かといってCMSを導入するのは方向性が違う。

結論として、plop で対話式に雛形を生成するようにした。npm run new:post と打つと質問に答えるだけで、正しいファイル名とfrontmatter入りの .mdx ができる。

plopとは

plopは「マイクロジェネレータ」を名乗る小さなCLIツールだ。Handlebarsテンプレートと対話プロンプトを組み合わせて、ファイルを生成する。Reactコンポーネントの雛形生成などでよく使われるが、用途はファイル生成全般。

依存は開発時だけ:

npm i -D plop

plopfile.mjs にジェネレータを定義し、package.json にショートカットを足す:

{
  "scripts": {
    "new:post": "plop post"
  }
}

ジェネレータの定義

plopfile.mjs の中身はこう。「何を聞くか(prompts)」と「何を作るか(actions)」の2部構成になっている。

export default function (plop) {
  plop.setGenerator("post", {
    description: "新しい記事(mdx)をpostidファイル名で作成",
    prompts: [
      { type: "input", name: "title", message: "タイトル:" },
      { type: "input", name: "slug", message: "URL slug (空ならpostidがそのままURLになる):" },
      { type: "input", name: "tags", message: "タグ カンマ区切り:", default: "tech" },
      { type: "confirm", name: "unlisted", message: "限定公開にする?", default: false },
    ],
    actions: (answers) => {
      const now = new Date();
      const pad = (n) => String(n).padStart(2, "0");
      const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
      // postid = 日付 + 乱数6桁。ファイル名にも本文の日付にも使う
      const postid = `${date.replaceAll("-", "")}-${Math.random().toString(36).slice(2, 8)}`;
      answers.date = date;
      answers.tagsJson = JSON.stringify(
        answers.tags.split(",").map((t) => t.trim()).filter(Boolean),
      );
      return [
        {
          type: "add",
          path: `content/posts/${postid}.mdx`,
          templateFile: "templates/post.mdx.hbs",
        },
      ];
    },
  });
}

ポイントは actions関数にしていること。plopのactionは配列を直接返すこともできるが、関数にすると回答(answers)を受け取って加工できる。ここで日付とpostidを計算し、answers に足してからテンプレートに渡している。

テンプレート

templates/post.mdx.hbs はHandlebars。{{#if}} で、入力があったときだけ行を出すようにしている。

---
title: "{{title}}"
date: "{{date}}"
description: ""
tags: {{{tagsJson}}}
lang: "ja"
{{#if slug}}
slug: "{{slug}}"
{{/if}}
{{#if unlisted}}
unlisted: true
{{/if}}
---
 
## はじめに

小さなハマりどころ: 配列(tags)を埋めるとき {{tagsJson}} だと ["tech"] のクォートがHTMLエスケープされて " になってしまう。3重波括弧 {{{tagsJson}}} でエスケープを無効にすると素のJSONが入る。

設計判断: ファイル名 = ID、URL = slug

一番考えたのはファイル名の付け方だった。よくあるのは「ファイル名 = URL slug」だが、これだと:

  • 同じ話題で名前が被る
  • あとで改名するとURL(と被リンク)が壊れる

そこでファイル名は不変のID、URLは別で持つ方式にした。

  • ファイル名 = postid20260711-6bacrl のような、日付プレフィックス + 乱数)。ソートが効き、まず重複しない
  • URL = frontmatterの slug。人間可読にしたければ書く。未指定ならpostidがそのままURL

記事を読み込む側は「slug があればそれを、なければファイル名をURLにする」だけで対応できる。

slug: data.slug ?? filename.replace(/\.mdx$/, ""),

副次効果として、slug を空にすればURLが推測困難なpostidになるので、限定公開記事(一覧に出さない記事)のURLとしても都合がいい。

使い心地

$ npm run new:post
? タイトル: plopでMDXをscaffoldする
? URL slug (空ならpostidがそのままURLになる): plop-scaffold
? タグ カンマ区切り: tech, nextjs
? 限定公開にする? No
  ++ content/posts/20260711-6bacrl.mdx

content/posts/20260711-6bacrl.mdx が、frontmatterが埋まった状態で生成される。あとは ## はじめに の下に本文を書くだけ。毎回のコピペと日付入力から解放された。

CI(対話なしで実行したいとき)は、プロンプトの回答を引数で渡せる:

npx plop post "タイトル" "slug" "tech,nextjs" n

まとめ

  • MDX直編集の快適さは保ったまま、ファイル名とfrontmatterの手作業だけをplopに寄せた
  • actions を関数にすると回答を加工できる。日付・IDの自動生成はここでやる
  • Handlebarsで配列を埋めるときは {{{三重括弧}}} でエスケープを切る
  • ファイル名を不変IDに、URLをslugに分離すると、改名でURLが壊れず、限定公開URLの秘匿にも効く

CMSは大げさ、でも手作業は減らしたい——という個人ブログにちょうどいい塩梅だった。

$