$ cat posts/20260711-6bacrl.mdx
CMSを入れずに記事投稿を楽にする — plopでMDXをscaffoldする
はじめに
このブログは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 plopplopfile.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は別で持つ方式にした。
- ファイル名 = postid(
20260711-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.mdxcontent/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は大げさ、でも手作業は減らしたい——という個人ブログにちょうどいい塩梅だった。