Communitygithub.com

asolis87/vera-sesiom-skills

Astro 约定,适用于 Vera Sesiom 的落地页、内容站点和 SSG 项目。默认零 JS,使用 Vue Islands 实现交互性,采用内容优先架构。

vera-sesiom-skills 是什么?

该技能定义了一套针对 Astro 框架的严格约定,专为 Vera Sesiom 团队构建落地页、营销站点、博客、文档等以内容为主的静态站点而设计。其核心理念是“默认零 JS”,即仅在有明确交互需求的组件(如表单、轮播图)上,通过 Vue 或 React 组件(Islands 架构)注入 JavaScript,从而在保证 SEO 和 Core Web Vitals 性能的同时,实现必要的交互功能。技能强制使用 TypeScript 和 Tailwind CSS,并推崇“尖叫架构”(Screaming Architecture)——项目文件夹名称应直接反映业务领域(如 `sections/`、`ui/`),而非技术栈。它通过清晰的决策树(Astro vs. Vue)指导开发者何时选择静态站点生成器(SSG)而非单页应用(SPA),并提供了组件模式、布局系统、内容集合(Content Collections,含 Zod 验证)、图片优化、SEO 元数据处理以及客户端指令(`client:visible`、`client:load` 等)的详细最佳实践。该技能可应用于 Claude Code 或 Cursor 等 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 中提问

打开一个已预加载此 Agent Skill 的新对话。

文档

使用时机

  • 落地页和营销站点
  • 企业官网
  • 内容密集型站点(博客、文档)
  • 任何对 SEO 和性能要求严苛的项目
  • 大部分为静态、交互性极少的站点
  • 需要快速首屏加载和良好 Core Web Vitals 的站点

不适用于全功能 Web 应用——交互式应用(仪表盘、CRUD 应用、实时应用)请使用 vue-frontend。


关键规则

  1. 默认零 JS——除非添加客户端指令,否则 Astro 不发送任何 JavaScript
  2. 始终使用 TypeScript——与所有 Vera Sesiom 项目一致,启用严格模式
  3. 对结构化内容使用内容集合——博客文章、文档、案例研究
  4. Islands 架构——仅在需要交互时使用 Vue/React 组件
  5. 默认静态输出 (SSG)——仅在需要动态内容时使用 SSR
  6. 使用 Tailwind CSS 进行样式设计——与 Vue 前端一致
  7. 尖叫架构——文件夹名称应反映业务领域,而非技术框架

决策标准:Astro vs Vue

何时使用 Astro何时使用 Vue
落地页、营销站点Web 应用(仪表盘、管理后台)
内容密集型(博客、文档)高交互性 UI
SEO 至关重要需要实时功能
大部分是静态内容复杂的状态管理
最小交互(表单、轮播图)完全 SPA 导航
零 JS 是一个特性Vue 生态系统的优势很重要

规则:如果站点内容是供用户阅读 → 使用 Astro。如果是供用户使用的应用 → 使用 Vue。


项目结构(尖叫架构)

apps/landing/   (or 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/                  # 内容集合
│   │   ├── 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)。

---
// 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 /> 插入子内容
  • 使用 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。使用客户端指令控制水合(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>

客户端指令决策树

指令使用时机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}
/>

规则:

  • 始终指定 width 和 height 以防止 CLS(累计布局偏移)
  • 对折叠区以下的图片使用 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)
---

<!-- 主要元标签 -->
<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} />
)}
{tags.length > 0 && (
  <>
    {tags.map(tag => (
      <meta property="article:tag" content={tag} />
    ))}
  </>
)}

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

使用方法:

---
// src/pages/blog/[slug].astro
import SEO from '../../components/SEO.astro'

<SEO 
  title={post.data.title}
  description={post.data.description}
  image={post.data.image?.url}
  article
  publishedTime={post.data.pubDate}
  author={post.data.author}
  tags={post.data.tags}
/>

路由模式

静态路由

---
// src/pages/about.astro
import BaseLayout from '../layouts/BaseLayout.astro'
---

<BaseLayout title="About Us" description="Learn about Vera Sesiom.">
  <h1>About Us</h1>
  <!-- Static content -->
</BaseLayout>

动态路由(SSG)

---
// 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>
    <Content />
  </article>
</BaseLayout>

重定向

---
// src/pages/old-page.astro
import { redirect } from 'astro/actions'

redirect('/new-page', 301)
---

表单处理

在 client:only 或 client:visible 表单中使用 Vue(参见上面的 ContactForm.vue 示例)。


样式指南

使用 Tailwind CSS 进行所有样式设计。

/* src/styles/global.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  body {
    @apply bg-white text-gray-900 antialiased;
  }
  
  h1 {
    @apply text-4xl font-bold tracking-tight;
  }
  
  h2 {
    @apply text-3xl font-semibold tracking-tight;
  }
  
  h3 {
    @apply text-2xl font-semibold;
  }
}

性能预算

每个页面的目标 Lighthouse 评分:

  • 性能: 95+ (移动端 & 桌面端)
  • 可访问性: 95+
  • 最佳实践: 95+
  • SEO: 100
  • Core Web Vitals: 全部通过

总结:Astro 工作流程

  1. 规划:确定是 Astro 还是 Vue(内容 vs 应用)
  2. 搭建:使用模式和尖叫结构创建项目
  3. 构建:静态组件放在 components/,交互式组件放在 components/interactive/
  4. 内容:在 src/content/ 中使用 Markdown/MDX 和 Zod 验证
  5. 优化:使用 astro:assets 处理图片,添加 SEO,检查性能
  6. 部署:静态输出到 Netlify/Vercel/Cloudflare (无服务器端依赖)

记住:

  • 默认零 JS
  • TypeScript 严格模式
  • 使用 client:visible 处理交互(默认)
  • 尖叫架构:文件夹应反映领域

相关技能