TL;DR

  • 記事のMarkdownをClaude APIでSlidev形式に変換
  • 生成→バリデーション→レビューの2パス構成で品質を担保
  • Slidev CLIでSPAにビルドし、記事ページにiframeで埋め込み
  • AI呼び出しはローカルのみ、ビルド時はGit管理済みのMarkdownだけ使用

はじめに

このWebサイトでは、各記事にスライドを用意しています。 スライドによって記事の理解を助けたり、何か話す機会があった時に役に立つかなと思い、作成しました。

と言いましたが実際は、「記事に伝えたい情報は全て載ってるんだから、そこからスライドを作ることもできるんじゃない?試してみよう!」という興味が先でした。

そこで、記事のMarkdownをClaude APIに渡してSlidev形式のスライドMarkdownを自動生成し、それをSPAにビルドして記事ページにiframeで埋め込むパイプラインを作りました。

この記事では、記事からスライドが生成され、画面に表示されるまでの技術的な流れを順に紹介します。


全体の流れ

記事Markdown

src/content/posts/

gen-slides

Claude API × 2パス

スライドMarkdown

src/content/slides/

build-slides

Slidev CLI

静的SPA

public/slides/

記事ページ

iframe埋め込み

大きく3段階に分かれます。

  1. 生成: 記事のMarkdownをClaude APIでSlidev Markdownに変換
  2. ビルド: Slidev CLIでMarkdownを静的SPAに変換
  3. 表示: 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直後のスライドは coverlayout: がないスライドは 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);

slideBuiltExistspublic/slides/<slug>/index.html の存在をファイルシステムで確認するだけのシンプルな関数です。

export function slideBuiltExists(slug: string): boolean {
  const builtPath = path.join(PUBLIC_SLIDES_DIR, slug, 'index.html');
  return fs.existsSync(builtPath);
}

テンプレート側では、hasSlidestrue の場合のみ 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パス構成を組み合わせることで、ある程度安定した品質のスライドが生成できるようになりました。

今後もイケてるスライドの作り方が分かったら、レイアウトの種類を増やしたりプロンプトを改善したりして、品質を高めていけたらと思っています。