Communitygithub.com

asolis87/vera-sesiom-skills

Vera Sesiom におけるランディングページ、コンテンツサイト、SSG プロジェクトのための Astro 規約。デフォルトでゼロ JS、インタラクティブ性は Vue Islands、コンテンツファーストなアーキテクチャ。

vera-sesiom-skills とは?

このスキルは、Vera Sesiom 社における静的サイト生成(SSG)プロジェクトのための Astro フレームワークの標準的な規約とパターンを定義します。主にランディングページ、マーケティングサイト、ブログ、ドキュメントサイトなど、SEO とパフォーマンスが重要で、コンテンツが多く、インタラクティブ性が限定的なサイトに適用されます。`vue-frontend` スキルが適用される本格的な Web アプリケーション(ダッシュボード、CRUD アプリなど)とは明確に区別されます。 このスキルの核心は「デフォルトでゼロ JS」という原則にあり、Astro は明示的に `client:*` ディレクティブを追加しない限り、クライアントサイドに JavaScript を一切送信しません。インタラクティブな要素(フォーム、カルーセル、モバイルメニュー)には Vue コンポーネントを使用した「Islands アーキテクチャ」を採用し、必要な部分だけを選択的にハイドレーションします。コンテンツ管理には Zod スキーマによる型安全な Content Collections を、スタイリングには Tailwind CSS を、そしてプロジェクト構造には「Screaming Architecture(フォルダ構造がフレームワークではなくドメインを表現する)」を採用しています。 プロジェクトのルート構造や Astro/Layout/Content Collection/Image/SEO の各パターンが詳細に定義されており、`client:visible`, `client:load`, `client:idle` などのクライアントディレクティブの使い分けやベストプラクティスも示されています。このスキールは主に Claude Code のような AI エージェントが Vera Sesiom のプロジェクトで一貫性のある高品質な Astro サイトを生成するために利用されます。

対応~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/asolis87/vera-sesiom-skills/tree/HEAD/skills/astro-landing

お気に入りのAIに質問する

このエージェントスキルを事前に読み込んだ状態で新しいチャットを開きます。

ドキュメント

使用するタイミング

  • ランディングページやマーケティングサイト
  • コーポレートサイト
  • コンテンツが豊富なサイト(ブログ、ドキュメント)
  • SEO とパフォーマンスが重要なプロジェクト
  • ほとんどが静的で、インタラクティブ性が最小限のサイト
  • 初回読み込みが速く、Core Web Vitals を満たす必要があるサイト

本格的な Web アプリケーションには非推奨 — インタラクティブなアプリケーション(ダッシュボード、CRUD アプリ、リアルタイムアプリ)には vue-frontend を使用してください。


重要なルール

  1. デフォルトでゼロ JS — Astro は client:* ディレクティブを追加しない限り、JavaScript を一切出力しません
  2. 常に TypeScript — 他の Vera Sesiom プロジェクトと同様、strict モード
  3. 構造化コンテンツには Content Collections を使用 — ブログ記事、ドキュメント、ケーススタディ
  4. Islands アーキテクチャ — インタラクティブ性が必要な箇所にのみ Vue/React コンポーネントを使用
  5. デフォルトで静的出力(SSG) — SSR は動的コンテンツが必要な場合のみ
  6. スタイリングには Tailwind CSS — Vue フロントエンドと同じ
  7. Screaming Architecture — フォルダ構造はフレームワークではなく、ドメインを表現する

判断基準:Astro vs Vue

Astro を使用する場合Vue を使用する場合
ランディングページ、マーケティングサイトWeb アプリケーション(ダッシュボード、管理画面)
コンテンツが豊富(ブログ、ドキュメント)高度にインタラクティブな UI
SEO が重要リアルタイム機能が必要
ほとんどが静的なコンテンツ複雑な状態管理
最小限のインタラクティブ性(フォーム、カルーセル)フル SPA ナビゲーション
ゼロ JS が利点となるVue エコシステムのメリットが活きる

ルール: サイトが人々が読むコンテンツなら → Astro。人々が使うアプリなら → Vue。


プロジェクト構造(Screaming Architecture)

apps/landing/   (または apps/website/)
├── src/
│   ├── pages/                    # ファイルベースルーティング(.astro ファイル)
│   │   ├── index.astro           # ホームページ
│   │   ├── about.astro           # アバウトページ
│   │   ├── pricing.astro         # 料金ページ
│   │   ├── blog/
│   │   │   ├── index.astro      # ブログ一覧
│   │   │   └── [slug].astro      # ブログ記事(動的)
│   │   └── contact.astro
│   ├── layouts/                  # ページレイアウト
│   │   ├── BaseLayout.astro      # ベース HTML 構造
│   │   ├── BlogLayout.astro      # ブログ記事レイアウト
│   │   └── LandingLayout.astro   # ランディングページレイアウト
│   ├── components/               # Astro コンポーネント(デフォルトで静的)
│   │   ├── sections/             # ページセクション
│   │   │   ├── Hero.astro
│   │   │   ├── Features.astro
│   │   │   ├── Pricing.astro
│   │   │   ├── Testimonials.astro
│   │   │   └── CTA.astro
│   │   ├── ui/                   # 再利用可能な UI アトム
│   │   │   ├── Button.astro
│   │   │   ├── Card.astro
│   │   │   ├── Badge.astro
│   │   │   └── Input.astro
│   │   └── interactive/          # Vue/React アイランド
│   │       ├── ContactForm.vue   # バリデーション付きフォーム
│   │       ├── Carousel.vue      # 画像カルーセル
│   │       └── MobileMenu.vue    # モバイルナビゲーション
│   ├── content/                  # コンテンツコレクション
│   │   ├── blog/                 # Markdown/MDX ブログ記事
│   │   │   ├── post-1.md
│   │   │   └── post-2.mdx
│   │   └── config.ts             # コレクションスキーマ(Zod)
│   ├── styles/                   # グローバルスタイル
│   │   └── global.css            # Tailwind インポート
│   ├── assets/                   # 最適化された画像、フォント
│   │   ├── images/
│   │   └── fonts/
│   └── utils/                    # ヘルパー関数
│       ├── date-format.ts
│       └── seo.ts
├── public/                       # 静的アセット(処理なし)
│   ├── favicon.ico
│   ├── robots.txt
│   └── fonts/
├── astro.config.mjs              # Astro 設定
├── tailwind.config.mjs           # Tailwind 設定
├── tsconfig.json
└── package.json

Astro コンポーネントパターン

すべての .astro ファイルは、フロントマター(サーバーサイド)とテンプレート(HTML)の 2 つの部分で構成されます。

---
// src/components/ui/Button.astro
// フロントマター: サーバーサイドで実行、ブラウザ JS はなし

export interface Props {
  variant?: 'primary' | 'secondary' | 'outline'
  size?: 'sm' | 'md' | 'lg'
  href?: string
}

const {
  variant = 'primary',
  size = 'md',
  href,
  ...rest
} = Astro.props

const baseClasses = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors'
const variantClasses = {
  primary: 'bg-blue-600 text-white hover:bg-blue-700',
  secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
  outline: 'border-2 border-blue-600 text-blue-600 hover:bg-blue-50'
}
const sizeClasses = {
  sm: 'px-3 py-1.5 text-sm',
  md: 'px-4 py-2 text-base',
  lg: 'px-6 py-3 text-lg'
}

const classes = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]}`
---

{href ? (
  <a href={href} class={classes} {...rest}>
    <slot />
  </a>
) : (
  <button class={classes} {...rest}>
    <slot />
  </button>
)}

重要なポイント:

  • フロントマターはビルド時(SSR の場合はリクエスト時)に実行されます
  • export default は不要 — テンプレート自体がエクスポートです
  • <slot /> で子コンテンツを配置します
  • Props は TypeScript インターフェースで型付けされます

レイアウトパターン

---
// src/layouts/BaseLayout.astro
import Nav from '../components/Nav.astro'
import Footer from '../components/Footer.astro'

export interface Props {
  title: string
  description?: string
  image?: string
}

const { title, description = '', image = '/og-default.png' } = Astro.props
const canonicalURL = new URL(Astro.url.pathname, Astro.site)
---

<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="canonical" href={canonicalURL} />
    <title>{title}</title>
    <meta name="description" content={description} />
    
    <!-- OpenGraph -->
    <meta property="og:title" content={title} />
    <meta property="og:description" content={description} />
    <meta property="og:image" content={image} />
    <meta property="og:url" href={canonicalURL} />
    <meta property="og:type" content="website" />
    
    <!-- Twitter -->
    <meta name="twitter:card" content="summary_large_image" />
    
    <slot name="head" />
  </head>
  <body class="bg-white text-gray-900 antialiased">
    <Nav />
    <main>
      <slot />
    </main>
    <Footer />
  </body>
</html>

使用例:

---
// src/pages/index.astro
import BaseLayout from '../layouts/BaseLayout.astro'
import Hero from '../components/sections/Hero.astro'
---

<BaseLayout title="Vera Sesiom - Software Development" description="We build software that matters.">
  <Hero />
</BaseLayout>

コンテンツコレクションパターン

Zod スキーマのバリデーションで構造化コンテンツを定義します。

// src/content/config.ts
import { defineCollection, z } from 'astro:content'

const blogCollection = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.coerce.date(),
    updatedDate: z.coerce.date().optional(),
    author: z.string().default('Vera Sesiom'),
    image: z.object({
      url: z.string(),
      alt: z.string()
    }).optional(),
    tags: z.array(z.string()).default([]),
    draft: z.boolean().default(false)
  })
})

export const collections = {
  blog: blogCollection
}
---
title: "Why We Chose Astro for Landing Pages"
description: "Performance and developer experience matter. Here's why Astro is our go-to for content sites."
pubDate: 2024-01-15
author: "Tech Lead"
image:
  url: "/images/blog/astro-landing.jpg"
  alt: "Astro logo on a modern website"
tags: ["astro", "performance", "architecture"]
---

Content goes here...
---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content'
import BaseLayout from '../../layouts/BaseLayout.astro'

const posts = (await getCollection('blog'))
  .filter(post => !post.data.draft)
  .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
---

<BaseLayout title="Blog" description="Latest articles from Vera Sesiom">
  <h1>Blog</h1>
  <ul>
    {posts.map(post => (
      <li>
        <a href={`/blog/${post.slug}`}>
          <h2>{post.data.title}</h2>
          <p>{post.data.description}</p>
          <time>{post.data.pubDate.toLocaleDateString()}</time>
        </a>
      </li>
    ))}
  </ul>
</BaseLayout>
---
// src/pages/blog/[slug].astro
import { getCollection } from 'astro:content'
import BaseLayout from '../../layouts/BaseLayout.astro'

export async function getStaticPaths() {
  const posts = await getCollection('blog')
  return posts.map(post => ({
    params: { slug: post.slug },
    props: { post }
  }))
}

const { post } = Astro.props
const { Content } = await post.render()
---

<BaseLayout title={post.data.title} description={post.data.description}>
  <article>
    <header>
      <h1>{post.data.title}</h1>
      <time>{post.data.pubDate.toLocaleDateString()}</time>
      <p>By {post.data.author}</p>
    </header>
    <Content />
  </article>
</BaseLayout>

Islands アーキテクチャ(Vue コンポーネント)

インタラクティブなコンポーネントは、必要な場合にのみ JS を出力します。クライアントディレクティブを使用してハイドレーションを制御します。

<!-- src/components/interactive/ContactForm.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'

const form = ref({
  name: '',
  email: '',
  message: ''
})

const errors = ref<Record<string, string>>({})

const isValid = computed(() => {
  return form.value.name && 
         form.value.email.includes('@') && 
         form.value.message.length > 10
})

async function submitForm() {
  if (!isValid.value) return
  
  // API に送信
  const response = await fetch('/api/contact', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(form.value)
  })
  
  if (response.ok) {
    // 成功時の処理
    form.value = { name: '', email: '', message: '' }
  }
}
</script>

<template>
  <form @submit.prevent="submitForm" class="space-y-4">
    <div>
      <label for="name">Name</label>
      <input v-model="form.name" type="text" id="name" required />
    </div>
    <div>
      <label for="email">Email</label>
      <input v-model="form.email" type="email" id="email" required />
    </div>
    <div>
      <label for="message">Message</label>
      <textarea v-model="form.message" id="message" rows="4" required />
    </div>
    <button type="submit" :disabled="!isValid">
      Send Message
    </button>
  </form>
</template>

Astro での使用例:

---
// src/pages/contact.astro
import BaseLayout from '../layouts/BaseLayout.astro'
import ContactForm from '../components/interactive/ContactForm.vue'
---

<BaseLayout title="Contact Us">
  <h1>Get in Touch</h1>
  <p>We'd love to hear from you.</p>
  
  <!-- client:visible = スクロールして表示されたときにハイドレート(ベストプラクティス) -->
  <ContactForm client:visible />
</BaseLayout>

クライアントディレクティブの判断基準

ディレクティブ使用するタイミングJS の出力タイミング
ディレクティブなし静的なコンテンツ、インタラクティブ性なしJS ゼロ
client:visibleスクロールしないと見えないインタラクティブ要素(フォーム、カルーセル)— デフォルトスクロールして表示されたとき
client:loadファーストビューで重要なインタラクティブ要素すぐに
client:idle重要でない拡張機能(アナリティクス、チャット)ページがアイドル状態になった後
client:mediaブレークポイントによる条件付き(モバイルナビ)メディアクエリに一致したとき
client:onlySPA 的なコンポーネント(SSR をスキップ)すぐに、SSR なし

ベストプラクティス:

  • デフォルトは ディレクティブなし — 純粋な静的
  • インタラクティブ性が必要な場合はデフォルトで client:visible
  • client:load は控えめに — ファーストビューで重要なもののみ
  • ブロッキングしない拡張機能には client:idle を使用
---
<!-- 良い例 -->
import Hero from './Hero.astro'                    <!-- 静的、JS なし -->
import Carousel from './Carousel.vue'             
import MobileMenu from './MobileMenu.vue'         
import Chat from './Chat.vue'                     
import Stats from './Stats.astro'                  <!-- 静的なカウンター = CSS -->

<Hero />                                          <!-- 静的、ハイドレーションなし -->
<Carousel client:visible />                       <!-- 表示されたときにハイドレート -->
<MobileMenu client:media="(max-width: 768px)" />  <!-- モバイルのみ -->
<Chat client:idle />                               <!-- ページが落ち着いた後 -->
<Stats />                                          <!-- 純粋な CSS アニメーション、JS 不要 -->

画像最適化

自動最適化には astro:assets を使用します。

---
// src/pages/index.astro
import { Image } from 'astro:assets'
import heroImage from '../assets/images/hero.png'
---

<Image 
  src={heroImage} 
  alt="Hero image" 
  width={800} 
  height={600}
  loading="lazy"
  format="webp"
/>

CMS や外部ソースからの画像の場合:

---
import { Image } from 'astro:assets'
---

<Image 
  src="https://example.com/image.jpg" 
  alt="External image" 
  width={800}
  height={600}
/>

ルール:

  • CLS 防止のため、常に width と height を指定
  • ファーストビューより下の画像には loading="lazy" を使用
  • webp または avif 形式を推奨
  • ヒーロー画像は最適化し、適切なサイズにすること

SEO パターン

---
// src/components/SEO.astro
export interface Props {
  title: string
  description: string
  image?: string
  article?: boolean
  publishedTime?: Date
  author?: string
  tags?: string[]
}

const { 
  title, 
  description, 
  image = '/og-default.png',
  article = false,
  publishedTime,
  author,
  tags = []
} = Astro.props

const canonicalURL = new URL(Astro.url.pathname, Astro.site)
const imageURL = new URL(image, Astro.site)
---

<!-- プライマリメタタグ -->
<title>{title}</title>
<meta name="title" content={title} />
<meta name="description" content={description} />
<link rel="canonical" href={canonicalURL} />

<!-- Open Graph -->
<meta property="og:type" content={article ? 'article' : 'website'} />
<meta property="og:url" content={canonicalURL} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={imageURL} />

<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={canonicalURL} />
<meta property="twitter:title" content={title} />
<meta property="twitter:description" content={description} />
<meta property="twitter:image" content={imageURL} />

<!-- 記事特有のタグ -->
{article && publishedTime && (
  <meta property="article:published_time" content={publishedTime.toISOString()} />
)}
{article && author && (
  <meta property="article:author" content={author} />
)}
{article && tags.map(tag => (
  <meta property="article:tag" content={tag} />
))}

<!-- JSON-LD 構造化データ -->
{article && publishedTime && (
  <script type="application/ld+json" set:html={JSON.stringify({
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: title,
    description: description,
    image: imageURL.toString(),
    author: {
      '@type': 'Person',
      name: author
    },
    datePublished: publishedTime.toISOString()
  })} />
)}

Astro.config.mjs

// astro.config.mjs
import { defineConfig } from 'astro/config'
import tailwind from '@astrojs/tailwind'
import vue from '@astrojs/vue'
import sitemap from '@astrojs/sitemap'

// https://astro.build/config
export default defineConfig({
  site: 'https://verasesiom.com',
  output: 'static',
  integrations: [
    tailwind(),
    vue(),
    sitemap()
  ],
  build: {
    inlineStylesheets: 'auto'
  },
  image: {
    service: {
      entrypoint: 'astro/assets/services/sharp'
    }
  }
})

環境変数:

# .env
PUBLIC_SITE_URL=https://verasesiom.com

Vue アイランド:特別な考慮事項

Vue コンポーネントをアイランドとして使用する場合:

  1. client:* ディレクティブを常に含める(そうでないと JS は出力されない)
  2. 状態管理には Pinia ではなく ref()/reactive() を使用(アイランドは独立しているため)
  3. provide/inject はアイランド境界を越えない — Props のみ
  4. アイランド内のルーティングは不要(ページ遷移には Astro ナビゲーションを使用)
  5. Vue の <Teleport> は期待通りに動作しない場合があるため、注意して使用
---
// src/pages/index.astro
import ProductCard from '../components/interactive/ProductCard.vue'
import NewsletterForm from '../components/interactive/NewsletterForm.vue'
import Analytics from '../components/interactive/Analytics.vue'
---

<main>
  <ProductCard client:visible />
  <NewsletterForm client:load />
  <Analytics client:idle />
</main>

感動的な Astro プロジェクト向けベストプラクティス

  1. Astro.glob() ではなく getCollection() を使用 — 型安全でスキーマ検証が可能
  2. 部分的なハイドレーションには常に client:visible を優先 — デフォルトの選択肢
  3. コンポーネントをアトミックに保つ — 1 つのコンポーネントで 1 つのこと
  4. Screaming Architecture を維持する — フォルダ構造がドメインを表現する
  5. <style> ブロックのスコープがデフォルト — グローバルには Tailwind を使用
  6. パフォーマンスのために ViewTransitions を使用 — スムーズなページ遷移、JS 最小限
  7. <Image /> コンポーネントで画像を自動最適化 — width と height を必須に
  8. RSS フィードには @astrojs/rss を使用 — ブログやコンテンツサイトに
// astro.config.mjs
import { defineConfig } from 'astro/config'
import tailwind from '@astrojs/tailwind'
import vue from '@astrojs/vue'
import sitemap from '@astrojs/sitemap'
import rss from '@astrojs/rss'

export default defineConfig({
  site: 'https://verasesiom.com',
  output: 'static',
  integrations: [
    tailwind(),
    vue(),
    sitemap()
  ],
  build: {
    inlineStylesheets: 'auto'
  },
  image: {
    service: {
      entrypoint: 'astro/assets/services/sharp'
    }
  }
})

関連スキル