logo hsb.horse
← ブログ一覧に戻る

ブログ

Astroプロジェクトに開発専用Liveエディタを実装する

Astroで開発モード限定のブラウザエディタを実装する方法。ViteミドルウェアとReactで、本番ビルドに一切影響を及ぼさずに、開発中のコンテンツ管理を快適にする手順を整理。

公開日:

Astroで静的サイトを作るとき、コンテンツ管理はGitベースが基本だ。 ただ開発中にブラウザ上で直接編集できると作業がはかどる。

エディタとブラウザを行き来せずコンテンツを編集できる。 変更したらすぐ画面に反映される。 画像もGUI経由でアップロードできる。

PromptLexというプロジェクトで、開発モード限定で動くエディタを作った。 本番ビルドには一切影響しない。

なぜLiveエディタを作ったか

本番でGitベースを使うのは当然として、開発中は別だ。

テストもコンテンツ作成もずっと楽になった。 プロトタイピング時の反復速度が上がり、画像管理も同じUIで完結する。 開発テスト用のダミーデータを手軽に作って消せる。

アーキテクチャ

3つのコンポーネントで構成している。

ReactコンポーネントがUI。 ViteミドルウェアがCRUD操作用のAPIエンドポイント。 AstroページがエディタをDEVモード時だけ読み込むエントリーポイント。

ステップ1: 管理画面ページの作成

開発モードでのみ動くAstroページを作る。

src/pages/admin/index.astro
---
import Base from '../../layouts/Base.astro';
import AdminEditor from '../../components/AdminEditor';
const isDev = import.meta.env.DEV;
---
<Base title="管理画面">
<meta slot="head" name="robots" content="noindex, nofollow" />
{isDev ? (
<AdminEditor client:only="react" />
) : (
<div>
<h1>管理画面</h1>
<p>管理画面は開発モードでのみ利用できます。</p>
<p><code>npm run dev</code>を実行してアクセスしてください。</p>
</div>
)}
</Base>

client:only="react"を指定すると、コンポーネントがクライアント側だけで読み込まれる。

ステップ2: APIミドルウェアの構築

開発サーバーにAPIエンドポイントを追加するAstroインテグレーションを作る。

src/integrations/admin-api.ts
import type { AstroIntegration } from 'astro';
import { readFileSync, writeFileSync, readdirSync } from 'fs';
import { join } from 'path';
export default function adminApi(): AstroIntegration {
return {
name: 'admin-api',
hooks: {
'astro:server:setup': ({ server }) => {
server.middlewares.use(async (req, res, next) => {
const url = req.url || '';
const method = req.method || 'GET';
if (!url.startsWith('/api/')) {
return next();
}
const contentDir = join(process.cwd(), 'src', 'content');
// GET /api/items - 全アイテムの一覧
if (url === '/api/items' && method === 'GET') {
const files = readdirSync(contentDir).filter(f => f.endsWith('.json'));
const items = files.map(file => {
const content = readFileSync(join(contentDir, file), 'utf-8');
return JSON.parse(content);
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(items));
return;
}
// POST /api/items - 新規アイテムの作成
if (url === '/api/items' && method === 'POST') {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
const data = JSON.parse(body);
const id = generateId(data.text);
const filePath = join(contentDir, `${id}.json`);
const item = {
id,
text: data.text,
createdAt: new Date().toISOString(),
};
writeFileSync(filePath, JSON.stringify(item, null, 2));
res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(item));
});
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found' }));
});
},
},
};
}
function generateId(text: string): string {
return text.slice(0, 12).replace(/\W/g, '').toLowerCase();
}

ステップ3: Reactコンポーネントの作成

APIを呼び出すReactコンポーネントを書く。

src/components/AdminEditor.tsx
import React, { useState, useEffect } from 'react';
interface Item {
id: string;
text: string;
createdAt: string;
}
export default function AdminEditor() {
const [items, setItems] = useState<Item[]>([]);
const [newText, setNewText] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchItems();
}, []);
const fetchItems = async () => {
const response = await fetch('/api/items');
const data = await response.json();
setItems(data);
setLoading(false);
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!newText.trim()) return;
const response = await fetch('/api/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: newText }),
});
const newItem = await response.json();
setItems([newItem, ...items]);
setNewText('');
};
if (loading) return <div>読み込み中...</div>;
return (
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '20px' }}>
<h1>コンテンツエディタ</h1>
<form onSubmit={handleCreate}>
<textarea
value={newText}
onChange={(e) => setNewText(e.target.value)}
placeholder="コンテンツを入力..."
rows={4}
style={{ width: '100%', padding: '10px' }}
/>
<button type="submit">作成</button>
</form>
<div>
<h2>アイテム ({items.length})</h2>
{items.map(item => (
<div key={item.id} style={{ border: '1px solid #ddd', padding: '10px', margin: '10px 0' }}>
<strong>{item.id}</strong>
<p>{item.text}</p>
<small>{new Date(item.createdAt).toLocaleString()}</small>
</div>
))}
</div>
</div>
);
}

ステップ4: インテグレーションの登録

astro.config.mjsに管理APIインテグレーションを追加する。

import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import adminApi from './src/integrations/admin-api';
export default defineConfig({
integrations: [
react(),
adminApi(),
],
});

高度な機能

ファイルアップロード対応

マルチパートフォームデータで画像をアップロードする。

async function parseMultipart(req) {
return new Promise((resolve) => {
const chunks = [];
req.on('data', chunk => chunks.push(chunk));
req.on('end', () => {
const buffer = Buffer.concat(chunks);
// boundaryを解析してファイルを抽出
// ...
resolve({ fields, files });
});
});
}

重複防止

コンテンツハッシュで重複を回避する。

import { createHash } from 'crypto';
function generateId(text: string): string {
const hash = createHash('sha256');
hash.update(text);
return hash.digest('hex').substring(0, 12);
}

削除機能

削除前に確認ダイアログを出す。

const handleDelete = async (id: string) => {
if (!confirm('このアイテムを削除しますか?')) return;
await fetch(`/api/items/${id}`, { method: 'DELETE' });
setItems(items.filter(item => item.id !== id));
};

ベストプラクティス

開発専用であることをimport.meta.env.DEVで必ず確認する。 管理ページにはnoindexメタタグをつける。 TypeScriptインターフェースで型を明示する。 エラーメッセージはわかりやすく書く。 保存前に入力を検証する。 ファイル操作にはpath.join()を使う。

本番環境への影響

本番ビルドに影響しないのがこの方法の利点だ。

エディタはastro dev中のみ動作する。 本番ビルドに余計なルートは含まれない。 APIエンドポイントも公開されない。 静的サイトは静的のままだ。

Astroプロジェクト用のLiveエディタを作ると開発体験が変わる。 ViteミドルウェアとReactを使えば、開発中だけ存在するコンテンツ管理インターフェースを作れる。

完全な実装はPromptLexリポジトリにある。

参考資料