Communitygithub.com

asolis87/互補食品應用

針對著陸頁、內容網站和 SSG 專案設計的 Astro 慣例。預設零 JavaScript、使用 Vue Islands 實現互動性,並採用內容優先架構。

互補食品應用 是什麼?

這個技能為使用 Astro 建立靜態網站提供了完整的開發慣例和架構指南。它特別適用於著陸頁、行銷網站、部落格、文件以及任何 SEO 和效能至關重要的內容型網站。該技能強調「預設零 JavaScript」的策略,僅在需要互動性時,透過 Islands 架構加入 Vue 或 React 元件來實現客戶端互動。它包含了明確的決策標準,幫助開發者在 Astro(適合內容閱讀的網站)和 Vue(適合高度互動的 Web 應用程式)之間做出選擇。技能文件詳細說明了專案結構(採用 Screaming Architecture 命名慣例)、Astro 元件模式、佈局系統、Content Collections 的 Zod 驗證實作、Vue Islands 的使用方式以及用戶端指令的選擇樹。此外,還提供了圖片優化和 SEO 的實作模式,確保網站能達到最佳的 Core Web Vitals 指標。

相容平台~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/asolis87/complementary-food-app/tree/HEAD/.agents/skills/astro-landing

在你喜歡的 AI 中提問

開啟一個已預先載入此 Agent Skill 的新對話。

說明文件

使用時機

  • 著陸頁和行銷網站
  • 企業網站
  • 內容豐富的網站(部落格、文件)
  • 任何 SEO 和效能至關重要的專案
  • 大部分為靜態且互動性極低的網站
  • 需要快速初始載入和優異 Core Web Vitals 的網站

不適用於完整的 Web 應用程式 — 對於互動式應用程式(儀表板、CRUD 應用、即時應用),請使用 vue-frontend。


關鍵規則

  1. 預設零 JS — 除非加入 client:* 指令,否則 Astro 不會傳送任何 JavaScript
  2. 一律使用 TypeScript — 如同所有 Vera Sesiom 專案,啟用嚴格模式
  3. 對結構化內容使用 Content Collections — 部落格文章、文件、案例研究
  4. Islands 架構 — 僅在需要互動性的地方使用 Vue/React 元件
  5. 預設靜態輸出 (SSG) — 僅在需要動態內容時才使用 SSR
  6. 使用 Tailwind CSS 進行樣式設計 — 與 Vue 前端專案相同
  7. Screaming Architecture — 資料夾名稱應反映領域(domain),而非框架

決策標準:Astro vs Vue

何時使用 Astro何時使用 Vue
著陸頁、行銷網站Web 應用程式(儀表板、管理後台)
內容豐富(部落格、文件)高度互動的使用者介面
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 Islands
│   │       ├── ContactForm.vue   # 包含驗證的表單
│   │       ├── Carousel.vue      # 圖片輪播
│   │       └── MobileMenu.vue    # 行動版導航
│   ├── content/                  # Content collections
│   │   ├── blog/                 # Markdown/MDX 部落格文章
│   │   │   ├── post-1.md
│   │   │   └── post-2.mdx
│   │   └── config.ts             # Collection 結構定義 (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 檔案包含兩個部分:Frontmatter(伺服器端)和模板(HTML)。

---
// src/components/ui/Button.astro
// Frontmatter:在伺服器端執行,不產生瀏覽器 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>
)}

關鍵要點:

  • Frontmatter 在建置時(或請求時,若使用 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>

Content Collection 模式

使用 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 元件)

互動式元件僅在需要時才傳送 JavaScript。使用用戶端指令(client directives)來控制水合(hydration)時機。

<!-- 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
  
  // Submit to API
  const response = await fetch('/api/contact', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(form.value)
  })
  
  if (response.ok) {
    // Handle success
    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>

用戶端指令決策樹

指令使用時機JavaScript 傳送時機
無指令靜態內容,無互動性零 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}
/>

規則:

  • 務必指定 width 和 height 以防止 CLS(Cumulative Layout Shift)
  • 對於折頁以下的圖片使用 loading="lazy"
  • 優先使用 webp 或 avif 格式
  • 確保 Hero 圖片已最佳化且尺寸合適

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)
---

<!-- Primary Meta Tags -->
<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 name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={imageURL} />

<!-- Article specific -->
{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} />
))}

<!-- Structured Data (JSON-LD) -->
<script type="application/ld+json" set:html={JSON.stringify({
  '@context': 'https://schema.org',
  '@type': article ? 'Article' : 'WebPage',
  'name': title,
  'description': description,
  'url': canonicalURL,
  'image': imageURL,
  ...(article && publishedTime && { 'datePublished': publishedTime.toISOString() }),
  ...(article && author && { 'author': { '@type': 'Person', 'name': author } })
})} />

在佈局中使用:

---
// src/layouts/BaseLayout.astro
import SEO from '../components/SEO.astro'

const { title, description } = Astro.props
---

<html>
  <head>
    <SEO title={title} description={description} />
    <!-- 其他 head 內容 -->
  </head>
  <body>
    <slot />
  </body>
</html>

重要設定

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

export default defineConfig({
  site: 'https://example.com',
  output: 'static',  // SSG 模式
  integrations: [
    vue(),
    tailwind(),
    sitemap()
  ],
  image: {
    domains: ['images.unsplash.com', 'cdn.example.com'],
    remotePatterns: [{ protocol: 'https' }]
  }
})

關鍵規則:

  • output: 'static' 為預設值 — 僅在必要時才啟用 SSR
  • 整合 sitemap 以進行 SEO
  • 設定 image.domains 以允許遠端圖片

相關技能