技術ブログをMarkdownで書く際、「画像の扱い」が面倒だと感じることはありませんか?
スクリーンショットを撮って、ファイル名を決めて保存して、パスを書いて…という作業は、執筆のリズムを崩してしまいます。また、Webサイトのパフォーマンスを考えると、生のPNGをそのまま配信するのではなく、WebPやAVIFといった次世代フォーマットに最適化することも不可欠です。
今回は、Astroで作られたブログ環境において、これらの面倒な作業をすべて自動化し、「ショートカットキーを一発押すだけ」で完結するストレスフリーなワークフローを構築しました。その全容と設定方法を紹介します。
## 目指すゴール
この記事で構築する環境のゴールはシンプルです。
「クリップボードの画像を、cmd+option+v を押すだけでMarkdownに貼り付け完了。裏側で勝手に最適化される」
これだけです。人間は執筆に集中し、面倒なことはすべてスクリプトに任せます。
### 実現する機能
- ワンタッチ貼り付け: VS Code拡張機能「Paste Image」を使い、ショートカットキーで画像をMarkdownに挿入。
- 自動最適化: 保存されたPNG画像を検知し、軽量なAVIFとWebPフォーマットを自動生成。
- ディレクトリ分離戦略:
- 人間が管理する元画像(PNG)は
src/以下に保存。 - 配信用の最適化画像(AVIF/WebP/PNG)はスクリプトが
public/以下に自動生成・ミラーリング。
- 人間が管理する元画像(PNG)は
<picture>タグ自動変換: Markdownの画像記法を、ブラウザが最適な形式を選べる<picture>タグのHTMLに自動変換。- 開発時の自動同期:
npm run dev中に画像が追加されたら、即座に変換スクリプトを走らせる。
### 成果物のイメージ
クリップボードに画像がある状態で cmd+option+v を押すと、Markdownには以下のように入力されます。
これがビルド時には、以下のようなAVIF -> WebP -> PNGの優先順位で表示可能なHTMLに変換されて出力されます。(“480x0” は高さ指定として処理されます)
<picture>
<source srcset="/blog/img/.../image.avif" type="image/avif" />
<source srcset="/blog/img/.../image.webp" type="image/webp" />
<img
src="/blog/img/.../image.png"
alt="画像の説明"
height="480"
loading="lazy"
decoding="async"
/>
</picture>## 構築ステップ
では、具体的な構築手順を見ていきましょう。
### Step 1. VS Code拡張「Paste Image」の導入と設定
まず、画像を簡単に貼り付けるためのVS Code拡張機能 Paste Image をインストールします。
この拡張機能のキモは、settings.json で保存先と挿入されるMarkdownのパスを細かく制御できる点です。今回は、「記事ファイルと同じ名前のフォルダ」に画像を保存するように設定します。
.vscode/settings.json(またはユーザー設定)に以下を追加します。
{
// 画像の保存先:src配下の、記事ファイル名と同名のディレクトリ
"pasteImage.path": "${projectRoot}/src/content/blog/img/${currentFileNameWithoutExt}",
// 保存ファイル名:日時ベース
"pasteImage.defaultName": "Y-MM-DD-HH-mm-ss",
// 挿入されるMarkdownのパターン:public配下のパスとして記述
"pasteImage.insertPattern": ""
}#### ポイント
pasteImage.path:元画像はビルド前のソースとして扱いたいので、src/content/blog/img/...に保存します。pasteImage.insertPattern:Markdownには、最終的に配信される public/ 起点のパス(/blog/img/...)を記述しておきます。
### Step 2. PNGからAVIF/WebPを生成する変換スクリプト
次に、src に保存されたPNG画像を元に、最適化されたAVIF/WebPファイルを生成して public ディレクトリに配置するスクリプトを作成します。画像処理には高速な sharp を使用します。
このスクリプトは以下の役割を担います。
publicの画像ディレクトリを一旦クリーンアップ。src内のPNGファイルを探索。- PNGファイルを
publicへコピー(ミラーリング)。 - 同時に、リサイズしたAVIFとWebP版を作成して
publicへ保存。
Attention
fast-glob, sharp などのパッケージが必要です。事前にインストールしてください。
convert-images.mjs
import fg from "fast-glob";
import fs from "fs/promises";
import sharp from "sharp";
import path from "node:path";
// 設定
const SRC_DIR = "src/content/blog";
const PUBLIC_DIR = "public/blog";
const MAX_WIDTH = 1600; // 最大幅(これより大きい場合はリサイズ)
const WEBP_QUALITY = 80;
const AVIF_QUALITY = 50;
// メイン処理
async function main() {
console.log(`Cleaning ${PUBLIC_DIR} ...`);
await fs.rm(PUBLIC_DIR, { recursive: true, force: true });
await fs.mkdir(PUBLIC_DIR, { recursive: true });
const pngFiles = await fg([`${SRC_DIR}/**/*.{png,PNG}`], {
onlyFiles: true,
});
let converted = 0,
mirrored = 0,
failed = 0;
for (const pngPath of pngFiles) {
try {
// 1. PNGをpublicへミラーリング
const rel = path.relative(SRC_DIR, pngPath);
const publicPngPath = path.join(PUBLIC_DIR, rel);
await fs.mkdir(path.dirname(publicPngPath), { recursive: true });
await fs.copyFile(pngPath, publicPngPath);
mirrored++;
// 2. sharpで最適化(リサイズ、AVIF/WebP変換)
const ext = path.extname(publicPngPath);
const basePublic = publicPngPath.slice(0, -ext.length);
const publicWebp = `${basePublic}.webp`;
const publicAvif = `${basePublic}.avif`;
const img = sharp(publicPngPath);
const meta = await img.metadata();
// 必要ならリサイズ
const resized =
meta.width && meta.width > MAX_WIDTH
? img.resize({ width: MAX_WIDTH, withoutEnlargement: true })
: img;
await resized
.clone()
.avif({ quality: AVIF_QUALITY })
.toFile(publicAvif);
await resized
.clone()
.webp({ quality: WEBP_QUALITY })
.toFile(publicWebp);
converted += 2;
} catch (e) {
failed++;
console.error("Failed:", pngPath, e.message);
}
}
console.log({ mirrored, converted, failed });
}
main();### Step 3. Markdownの画像を picture タグに変換するRemarkプラグイン
MarkdownにはPNGのパスが書かれていますが、ブラウザにはAVIFやWebPも提示したいです。そこで、ビルドプロセスでMarkdownの画像記法を <picture> タグのHTMLに置換するRemarkプラグインを作成します。
このプラグインは、Markdownの  を解析し、対応する .avif と .webp のソースタグを追加したHTMLに置き換えます。また、タイトルの “480x0” といった記述から width や height 属性を抽出する処理も入れています。
remark-picture.mjs
import { visit } from "unist-util-visit";
import path from "node:path";
export default function remarkPicture(options = {}) {
const {
exts = [".png"], // 対象とする拡張子
imgAttrs = { loading: "lazy", decoding: "async" }, // デフォルト属性
} = options;
return (tree) => {
visit(tree, "image", (node, index, parent) => {
if (!parent || typeof index !== "number") return;
if (!node.url) return;
const url = node.url;
const ext = path.extname(url).toLowerCase();
if (!exts.includes(ext)) return;
// ベースのパスからavif/webpのパスを生成
const base = url.slice(0, -ext.length);
const avif = `${base}.avif`;
const webp = `${base}.webp`;
const alt = (node.alt ?? "").replace(/"/g, """);
const title = (node.title ?? "").replace(/"/g, """);
// タイトルからサイズ指定を解析 ("WIDTHxHEIGHT")
let widthAttr = "";
let heightAttr = "";
let styleAttr = "";
const m = title.match(/^(\d+)\s*x\s*(\d+)$/i);
if (m) {
const a = Number(m[1]);
const b = Number(m[2]);
if (a > 0 && b > 0) {
// 両方指定があればwidth/height属性へ
widthAttr = ` width="${a}"`;
heightAttr = ` height="${b}"`;
} else if (a > 0 && b === 0) {
// 高さだけ0ならmax-heightスタイルへ(例)
styleAttr = ` style="max-height:${a}px;"`;
}
}
// その他の属性
const extraAttrs = Object.entries(imgAttrs)
.map(([k, v]) => ` ${k}="${v}"`)
.join("");
// HTMLを生成してノードを置き換え
const html = `
<picture>
<source srcset="${avif}" type="image/avif" />
<source srcset="${webp}" type="image/webp" />
<img src="${url}" alt="${alt}"${widthAttr}${heightAttr}${styleAttr}${extraAttrs} />
</picture>
`.trim();
parent.children[index] = { type: "html", value: html };
});
};
}作成したプラグインは astro.config.mjs の markdown.remarkPlugins に追加して有効化します。
### Step 4. 開発中の自動同期(Watcherの作成)
最後に、開発サーバー(astro dev)を動かしている間、画像が追加されたら自動的に Step 2 の変換スクリプトが走るように監視スクリプトを作成します。これがないと、画像を貼るたびに手動でコマンドを叩くことになり、ストレスフリーになりません。
chokidar を使って src ディレクトリを監視します。
watch-images.mjs
import chokidar from "chokidar";
import { spawn } from "node:child_process";
import path from "node:path";
const WATCH_DIR = "src/content/blog";
const DEBOUNCE_MS = 800; // 連続実行を防ぐためのデバウンス時間
let timer = null;
let running = false;
let queued = false;
// 変換スクリプト(npm run img:opt)を実行する関数
function runConvert() {
if (running) {
queued = true;
return;
}
running = true;
console.log("[img:watch] running img:opt...");
const p = spawn("npm", ["run", "img:opt"], {
stdio: "inherit",
shell: process.platform === "win32",
});
p.on("close", (code) => {
running = false;
console.log(
code === 0 ? "[img:watch] done." : "[img:watch] failed",
code,
);
// 実行中に次のリクエストが来ていたら再実行
if (queued) {
queued = false;
runConvert();
}
});
}
// デバウンス処理
function debounceRun() {
if (timer) clearTimeout(timer);
timer = setTimeout(runConvert, DEBOUNCE_MS);
}
// 監視の設定
const watcher = chokidar.watch(WATCH_DIR, {
ignoreInitial: true, // 起動時の既存ファイルスキャンは無視
persistent: true,
usePolling: true, // 環境によってはポーリングが必要
interval: 500,
});
// PNGファイルの追加・変更を検知
watcher.on("all", (event, p) => {
const ext = path.extname(p).toLowerCase();
if (ext !== ".png" || event === "unlink") return; // 削除は無視するなど調整
console.log("[img:watch]", event, p);
debounceRun();
});
console.log(`[img:watch] watching ${WATCH_DIR}...`);### Step 5. package.json でコマンドをまとめる
作成したスクリプトを package.json の scripts に登録し、Astroのコマンドと連携させます。
{
"scripts": {
"//": "画像変換の単体コマンド",
"img:opt": "node scripts/convert-images.mjs",
"//": "画像監視の単体コマンド",
"img:watch": "node scripts/watch-images.mjs",
"//": "devサーバー起動前に一度画像を最適化する",
"predev": "npm run img:opt",
"//": "監視スクリプトとAstroの開発サーバーを並列で実行する",
"dev": "npm run img:watch & astro dev",
"//": "ビルド前にも必ず画像を最適化する",
"prebuild": "npm run img:opt",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
}
}これで、普段どおり npm run dev を実行するだけで、画像の監視プロセスも同時に立ち上がるようになりました。
## まとめ
以上のステップで、理想としていた画像ワークフローが完成しました。
- 執筆者は、VS Codeで
cmd+option+vを押すだけ。 - PNGが
src/に保存される。 - Watcherがそれを検知し、変換スクリプトを実行。
- 最適化された画像(AVIF/WebP/PNG)が
public/に自動生成される。 - Astro(Remark)がMarkdownを
<picture>タグに変換して表示する。
この仕組みにより、画像の管理コストが劇的に下がり、執筆活動により集中できるようになりました。
また、将来的に別のフレームワークへ移行することになっても、元画像は src にきれいな状態で残っているため、移行コストを最小限に抑えられるというメリットもあります。
Astroでブログを構築している方は、ぜひ試してみてください。