TL;DR
- 記事のMarkdownをClaude APIでSlidev形式に変換
- 生成→バリデーション→レビューの2パス構成で品質を担保
- Slidev CLIでSPAにビルドし、記事ページにiframeで埋め込み
- AI呼び出しはローカルのみ、ビルド時はGit管理済みのMarkdownだけ使用
はじめに
このWebサイトでは、各記事にスライドを用意しています。 スライドによって記事の理解を助けたり、何か話す機会があった時に役に立つかなと思い、作成しました。
と言いましたが実際は、「記事に伝えたい情報は全て載ってるんだから、そこからスライドを作ることもできるんじゃない?試してみよう!」という興味が先でした。
そこで、記事のMarkdownをClaude APIに渡してSlidev形式のスライドMarkdownを自動生成し、それをSPAにビルドして記事ページにiframeで埋め込むパイプラインを作りました。
この記事では、記事からスライドが生成され、画面に表示されるまでの技術的な流れを順に紹介します。
全体の流れ
大きく3段階に分かれます。
- 生成: 記事のMarkdownをClaude APIでSlidev Markdownに変換
- ビルド: Slidev CLIでMarkdownを静的SPAに変換
- 表示: Astroの記事ページでSPAをiframeとして埋め込み
以降、この流れに沿って各ステップの実装を見ていきます。
Step 1: 記事の読み取り
まず src/content/posts/ から記事のMarkdownファイルを読み取ります。gray-matterでfrontmatterを解析し、タイトル・日付・本文・下書きフラグを抽出します。
import matter from 'gray-matter';
function readPosts(): PostData[] {
const files = fs.readdirSync(POSTS_DIR).filter((f) => f.endsWith('.md'));
return files.map((file) => {
const content = fs.readFileSync(path.join(POSTS_DIR, file), 'utf-8');
const { data, content: body } = matter(content);
return {
slug: file.replace(/\.md$/, ''),
title: data.title as string,
date: String(data.date),
body,
draft: Boolean(data.draft),
};
});
}
draft: true の記事はスキップし、公開済みの記事だけを対象にします。
Step 2: プロンプトの構成
読み取った記事をClaude APIに渡すプロンプトを組み立てます。プロンプトは5つのセクションで構成しています。
完成例(complete_example)
理想的なスライドの具体例を1つ丸ごと提示します。headmatterからcover、default、statement、fact、two-cols、centerまで全レイアウトを使った完成例です。
AIに「こういうものを作ってほしい」と見せるのが、品質安定に最も効果がありました。抽象的なルールだけでは出力がブレやすく、具体例があると出力形式が安定します。
レイアウトカタログ(layout_catalog)
利用可能な6種類のレイアウトとその書き方を明示します。
■ cover — 表紙。最初の1枚。headmatterの直後に # タイトルと段落でサブタイトルを書く。
■ default — 通常の情報スライド。フロントマター不要で --- の後にそのまま内容を書く。
■ statement — 一言インパクト・セクション区切り。## 1つだけ。
■ fact — 数字・キーワード強調。# に数字やキーワード、段落に補足1〜2行。
■ two-cols — 2カラム比較。Before/After、コード+説明の対比に最適。
■ center — まとめ。最後の1枚。
各レイアウトに対してYAMLフロントマターの書き方も含めています。これがないと、AIが ## layout: statement のようにMarkdown見出しとしてレイアウトを記述してしまうことがありました。
構成ルール(composition_rules)
スライドの構成に関する具体的なルールです。
- 同じレイアウトを3枚連続させない(特にdefaultの連続は厳禁)
- 情報スライド2〜3枚の間に、statement/factを1枚挟む
- statement + fact を合計3回以上使う
- 箇条書きは1項目15文字以内、1スライド3〜5項目
- コードブロックは5〜12行に抜粋
単にルールを並べるだけでなく、推奨パターンも示しています。
推奨パターン: cover → statement → default → default → fact
→ two-cols → default → statement → default → default → center
出力フォーマット(output_format)
headmatterの固定値、スライド枚数(10〜18枚)、最初のスライドはcover、最後はcenter、といった出力制約です。
禁止事項(prohibited)
HTMLタグ、Vueコンポーネント(<v-clicks> など)、絵文字の使用を禁止しています。Slidev固有のVueコンポーネントは、ビルドではなく記事のMarkdownパーサーを通る可能性があるため、使わない方針にしています。
Step 3: Claude APIでスライド生成
プロンプトを組み立てたら、Claude APIを呼び出してスライドMarkdownを生成します。
async function generateSlide(client: Anthropic, post: PostData): Promise<string> {
const prompt = buildPrompt(post);
const message = await client.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 4096,
messages: [{ role: 'user', content: prompt }],
});
const textBlock = message.content.find((block) => block.type === 'text');
if (!textBlock || textBlock.type !== 'text') {
throw new Error(`No text response for ${post.slug}`);
}
return stripCodeFences(textBlock.text);
}
stripCodeFences は、AIがMarkdownコードフェンスで囲んで出力してしまった場合に外す処理です。プロンプトで「コードフェンスで囲まない」と指示していても、稀に囲んでくることがあるため、後処理で対応しています。
Step 4: レイアウト記法の修正
AIの生成結果には、レイアウト指定がYAMLフロントマターではなくMarkdown見出し(## layout: statement)として出力されるケースがあります。これを正しい形式に変換します。
function fixLayoutDirectives(content: string): string {
// "## layout: X" 見出しをYAMLフロントマターに変換
let fixed = content.replace(
/\n---\n\n## layout:[ \t]*(\S+)[ \t]*\n/g,
'\n---\nlayout: $1\n---\n',
);
// 空スライドの除去
fixed = fixed.replace(/\n---\nlayout: \S+\n---\n+---/g, '\n---');
// headmatterからlayout:を除去(Astro Content Collectionsとの競合回避)
fixed = fixed.replace(/^(---\n)layout: \S+\n/, '$1');
return fixed;
}
3つ目の正規表現は、先頭のheadmatterに layout: が含まれているとAstro 5のContent Collectionsがスキーマエラーを起こすため、除去しています。
Step 5: バリデーション
生成されたスライドMarkdownに対して、構造的なバリデーションを行います。
function validateSlides(content: string, slug: string): ValidationResult {
const warnings: string[] = [];
const layouts = extractLayouts(content);
const slideCount = layouts.length;
if (slideCount < 15)
warnings.push(`Slide count ${slideCount} is below minimum (15)`);
if (slideCount > 20)
warnings.push(`Slide count ${slideCount} exceeds maximum (20)`);
if (layouts[layouts.length - 1] !== 'center')
warnings.push(`Last slide layout is "${layouts[layouts.length - 1]}", expected "center"`);
const accentCount = layouts.filter(
(l) => l === 'statement' || l === 'fact'
).length;
if (accentCount < 3)
warnings.push(`statement + fact used ${accentCount} time(s), minimum is 3`);
// 3連続チェック
for (let i = 2; i < layouts.length; i++) {
if (layouts[i] === layouts[i - 1] && layouts[i] === layouts[i - 2])
warnings.push(`Three consecutive "${layouts[i]}" layouts at slides ${i - 1}–${i + 1}`);
}
return { slideCount, layouts, warnings };
}
extractLayouts はスライド区切り --- を辿り、各スライドのレイアウトを配列として抽出します。headmatter直後のスライドは cover、layout: がないスライドは default として扱います。
Step 6: レビューパス
バリデーションの結果、警告がある場合は2回目のAPI呼び出しでレビューを行います。
const draft = fixLayoutDirectives(await generateSlide(client, post));
const draftResult = validateSlides(draft, post.slug);
let final = draft;
if (!skipReview) {
const reviewed = fixLayoutDirectives(
await reviewSlide(client, draft, draftResult.warnings),
);
const reviewResult = validateSlides(reviewed, post.slug);
// レビューで改善された場合のみ採用
if (reviewResult.warnings.length < draftResult.warnings.length) {
final = reviewed;
} else if (reviewResult.warnings.length === 0 && draftResult.warnings.length === 0) {
final = reviewed;
}
}
レビュープロンプトには、バリデーションの警告をそのまま渡します。
function buildReviewPrompt(draft: string, warnings: string[]): string {
const warningBlock = warnings.length > 0
? `\n<validation_warnings>\n${warnings.map((w) => `- ${w}`).join('\n')}\n</validation_warnings>`
: '';
return `あなたはSlidevスライドのレビュアーです。
以下のスライドドラフトをレビューし、改善した完成版を出力してください。
${warningBlock}
<review_checklist>
■ フォーマット
- レイアウト指定がYAMLフロントマター形式か
- スライド区切り --- の前後に空行があるか
■ 構成リズム
- 同じレイアウトが3枚以上連続していないか
- statement/factが合計3回以上使われているか
■ テキスト品質
- 箇条書き1項目が15文字以内か
- 「〜です。〜ます。」の文章体が使われていないか
...
</review_checklist>
<draft>
${draft}
</draft>`;
}
レビューで改善されなかった場合はドラフトをそのまま採用するフォールバックも入れています。レビューが常にドラフトより良くなるとは限らないためです。
最終的なMarkdownは src/content/slides/<slug>.md に書き出され、Gitで管理されます。
Step 7: Slidev CLIでSPAビルド
ここからはAIの出番はありません。pnpm run build の prebuild フックで build-slides.ts が実行され、各スライドMarkdownをSlidev CLIでSPAにビルドします。
function buildSlide(file: string): boolean {
const slug = file.replace(/\.md$/, '');
const inputPath = path.join(SLIDES_DIR, file);
const outputDir = path.join(OUTPUT_BASE, slug);
execSync(
`npx slidev build "${inputPath}" --base "/slides/${slug}/" --out "${path.resolve(outputDir)}"`,
{ stdio: 'pipe', timeout: 120_000 },
);
return true;
}
--base オプションで /slides/<slug>/ をベースパスに指定しています。これにより、SPAのアセットパスがデプロイ先のURLと一致します。出力先は public/slides/<slug>/ で、Astroビルド時に静的アセットとしてそのまま配信されます。
public/slides/ はビルド成果物のため .gitignore 対象です。
Step 8: 記事ページでの表示判定
Astroの記事ページ src/pages/posts/[uuid].astro で、対応するスライドSPAが存在するかをビルド時にチェックします。
import { slideBuiltExists } from "@lib/slides";
const articleSlug = article.id.replace(/\.md$/, '');
const hasSlides = slideBuiltExists(articleSlug);
slideBuiltExists は public/slides/<slug>/index.html の存在をファイルシステムで確認するだけのシンプルな関数です。
export function slideBuiltExists(slug: string): boolean {
const builtPath = path.join(PUBLIC_SLIDES_DIR, slug, 'index.html');
return fs.existsSync(builtPath);
}
テンプレート側では、hasSlides が true の場合のみ SlideEmbed コンポーネントを描画します。
{hasSlides && <SlideEmbed slug={articleSlug} />}
スライドが存在しない記事では何も表示されないため、既存の記事ページに影響を与えません。
Step 9: iframe埋め込み
SlideEmbed.astro は、Slidev SPAをiframeで16
---
interface Props {
slug: string;
}
const { slug } = Astro.props;
const slidePath = `/slides/${slug}/index.html`;
---
<div class="slide-embed">
<div class="slide-embed-container">
<iframe
id="slide-iframe"
src={slidePath}
title="Slide presentation"
sandbox="allow-scripts allow-same-origin allow-popups"
allowfullscreen
loading="lazy"
></iframe>
</div>
<div class="slide-embed-footer">
<a href={slidePath} target="_blank" rel="noopener noreferrer">
全画面で開く
</a>
</div>
</div>
おわりに
記事のMarkdownを読み取り、Claude APIでSlidev形式に変換し、Slidev CLIでSPAにビルドし、記事ページにiframeで埋め込む。この一連のパイプラインにより、記事を書いた後は pnpm run gen:slides を実行するだけでスライドが手に入ります。
ビルド時にAIは呼ばないため、CI/CDへの影響はありません。生成されたスライドMarkdownはGitで管理し、SPA出力だけがビルド成果物として扱われます。
生成品質はプロンプトの設計に大きく依存します。完成例の提示、構成ルールの明示、バリデーションとレビューの2パス構成を組み合わせることで、ある程度安定した品質のスライドが生成できるようになりました。
今後もイケてるスライドの作り方が分かったら、レイアウトの種類を増やしたりプロンプトを改善したりして、品質を高めていけたらと思っています。
