Communitygithub.com

asolis87/complementary-food-app

Convenções Astro para landing pages, sites de conteúdo e projetos SSG na Vera Sesiom. Zero JS por padrão, Vue Islands para interatividade e arquitetura centrada em conteúdo.

O que é complementary-food-app?

Este skill fornece convenções e padrões para construir sites estáticos e focados em conteúdo com Astro, na Vera Sesiom. É ideal para landing pages, blogs, documentação e sites de marketing, onde desempenho (Core Web Vitals) e SEO são críticos. O skill enfatiza uma abordagem "Zero JS por padrão", onde JavaScript só é enviado ao navegador quando estritamente necessário, usando Vue Islands para interatividade. A arquitetura do projeto segue o padrão "Screaming Architecture", onde os nomes das pastas refletem o domínio do conteúdo, não o framework. Também define regras claras para decidir entre Astro (para sites de leitura) e Vue (para aplicações interativas). Inclui padrões para componentes Astro, layouts, coleções de conteúdo com esquemas Zod, otimização de imagens e SEO, garantindo consistência e boas práticas em todos os projetos de landing page da Vera Sesiom.

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

Perguntar na sua IA favorita

Abre um novo chat com esta habilidade de agente já pré-carregada.

Documentação

Quando Usar

  • Landing pages e sites de marketing
  • Sites corporativos
  • Sites com muito conteúdo (blogs, documentação)
  • Qualquer projeto onde SEO e desempenho são críticos
  • Sites que são principalmente estáticos com pouca interatividade
  • Sites que precisam de carregamento inicial rápido e Core Web Vitals

NÃO para aplicações web completas — use vue-frontend para aplicações interativas (dashboards, CRUD, tempo real).


Regras Críticas

  1. Zero JS por padrão — Astro NÃO envia JavaScript a menos que você adicione uma diretiva client:
  2. TypeScript SEMPRE — igual a todos os projetos Vera Sesiom, modo estrito
  3. Use Content Collections para conteúdo estruturado — posts de blog, documentação, estudos de caso
  4. Arquitetura de Ilhas — componentes Vue/React apenas onde interatividade é necessária
  5. Saída estática (SSG) por padrão — SSR apenas quando conteúdo dinâmico exigir
  6. Tailwind CSS para estilização — igual ao frontend Vue
  7. Screaming Architecture — pastas gritam o domínio, não o framework

Critérios de Decisão: Astro vs Vue

Quando usar AstroQuando usar Vue
Landing page, site de marketingAplicação web (dashboard, admin)
Muito conteúdo (blog, docs)UI altamente interativa
SEO é críticoFuncionalidades em tempo real
Conteúdo majoritariamente estáticoGerenciamento de estado complexo
Interatividade mínima (formulários, carrosséis)Navegação SPA completa
Zero JS é uma funcionalidadeBenefícios do ecossistema Vue importam

Regra: Se o site é conteúdo que as pessoas LEEM → Astro. Se é um aplicativo que as pessoas USAM → Vue.


Estrutura do Projeto (Screaming Architecture)

apps/landing/   (ou apps/website/)
├── src/
│   ├── pages/                    # Roteamento baseado em arquivos (.astro)
│   │   ├── index.astro           # Página inicial
│   │   ├── about.astro           # Página sobre
│   │   ├── pricing.astro         # Página de preços
│   │   ├── blog/
│   │   │   ├── index.astro      # Lista do blog
│   │   │   └── [slug].astro      # Post do blog (dinâmico)
│   │   └── contact.astro
│   ├── layouts/                  # Layouts de página
│   │   ├── BaseLayout.astro      # Estrutura HTML base
│   │   ├── BlogLayout.astro      # Layout de post do blog
│   │   └── LandingLayout.astro   # Layout de landing page
│   ├── components/               # Componentes Astro (estáticos por padrão)
│   │   ├── sections/             # Seções de página
│   │   │   ├── Hero.astro
│   │   │   ├── Features.astro
│   │   │   ├── Pricing.astro
│   │   │   ├── Testimonials.astro
│   │   │   └── CTA.astro
│   │   ├── ui/                   # Átomos de UI reutilizáveis
│   │   │   ├── Button.astro
│   │   │   ├── Card.astro
│   │   │   ├── Badge.astro
│   │   │   └── Input.astro
│   │   └── interactive/          # Ilhas Vue/React
│   │       ├── ContactForm.vue   # Formulário com validação
│   │       ├── Carousel.vue      # Carrossel de imagens
│   │       └── MobileMenu.vue    # Navegação móvel
│   ├── content/                  # Coleções de conteúdo
│   │   ├── blog/                 # Posts de blog em Markdown/MDX
│   │   │   ├── post-1.md
│   │   │   └── post-2.mdx
│   │   └── config.ts             # Esquema da coleção (Zod)
│   ├── styles/                   # Estilos globais
│   │   └── global.css            # Imports do Tailwind
│   ├── assets/                   # Imagens otimizadas, fontes
│   │   ├── images/
│   │   └── fonts/
│   └── utils/                    # Funções auxiliares
│       ├── date-format.ts
│       └── seo.ts
├── public/                       # Ativos estáticos (sem processamento)
│   ├── favicon.ico
│   ├── robots.txt
│   └── fonts/
├── astro.config.mjs              # Configuração do Astro
├── tailwind.config.mjs           # Configuração do Tailwind
├── tsconfig.json
└── package.json

Padrão de Componente Astro

Todo arquivo .astro tem duas partes: frontmatter (lado do servidor) e template (HTML).

---
// src/components/ui/Button.astro
// Frontmatter: executa no servidor, NÃO no JS do navegador

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

Pontos-chave:

  • Frontmatter executa em tempo de build (ou tempo de requisição para SSR)
  • Não precisa de export default — o template JÁ É o export
  • <slot /> para conteúdo filho
  • Props são tipadas com interfaces TypeScript

Padrão de Layout

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

Uso:

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

<BaseLayout title="Vera Sesiom - Desenvolvimento de Software" description="Construímos software que importa.">
  <Hero />
</BaseLayout>

Padrão de Coleção de Conteúdo

Defina conteúdo estruturado com validação de esquema 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: "Por que Escolhemos Astro para Landing Pages"
description: "Desempenho e experiência do desenvolvedor importam. Veja por que Astro é nossa escolha para sites de conteúdo."
pubDate: 2024-01-15
author: "Líder Técnico"
image:
  url: "/images/blog/astro-landing.jpg"
  alt: "Logo do Astro em um site moderno"
tags: ["astro", "desempenho", "arquitetura"]
---

Conteúdo vai aqui...
---
// 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="Artigos recentes da 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>Por {post.data.author}</p>
    </header>
    <Content />
  </article>
</BaseLayout>

Arquitetura de Ilhas (Componentes Vue)

Componentes interativos enviam JS apenas quando necessário. Use diretivas client: para controlar a hidratação.

<!-- 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
  
  // Enviar para API
  const response = await fetch('/api/contact', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(form.value)
  })
  
  if (response.ok) {
    // Lidar com sucesso
    form.value = { name: '', email: '', message: '' }
  }
}
</script>

<template>
  <form @submit.prevent="submitForm" class="space-y-4">
    <div>
      <label for="name">Nome</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">Mensagem</label>
      <textarea v-model="form.message" id="message" rows="4" required />
    </div>
    <button type="submit" :disabled="!isValid">
      Enviar Mensagem
    </button>
  </form>
</template>

Usando no Astro:

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

<BaseLayout title="Fale Conosco">
  <h1>Entre em Contato</h1>
  <p>Adoraríamos ouvir de você.</p>
  
  <!-- client:visible = hidrata quando scrollado para a visualização (MELHOR PRÁTICA) -->
  <ContactForm client:visible />
</BaseLayout>

Árvore de Decisão de Diretivas Client

DiretivaQuando UsarJS Enviado
Sem diretivaConteúdo estático, sem interatividadeZero JS
client:visibleInteratividade abaixo da dobra (formulários, carrosséis) — PADRÃOQuando scrollado para a visualização
client:loadInteratividade crítica acima da dobraImediatamente
client:idleMelhorias não críticas (analytics, chat)Após a página ficar ociosa
client:mediaCondicional por breakpoint (nav móvel)Quando a media query corresponde
client:onlyComponentes tipo SPA (pular SSR)Imediatamente, sem SSR

Melhores Práticas:

  • Padrão: sem diretiva — puramente estático
  • Padrão: client:visible quando interatividade é necessária
  • Use client:load com moderação — apenas para interatividade crítica acima da dobra
  • Use client:idle para melhorias não bloqueantes
---
<!-- Bons exemplos -->
import Hero from './Hero.astro'                    <!-- Estático, sem JS -->
import Carousel from './Carousel.vue'             
import MobileMenu from './MobileMenu.vue'         
import Chat from './Chat.vue'                     
import Stats from './Stats.astro'                  <!-- Contador estático = CSS -->

<Hero />                                          <!-- Estático, sem hidratação -->
<Carousel client:visible />                       <!-- Hidrata quando visível -->
<MobileMenu client:media="(max-width: 768px)" />  <!-- Apenas móvel -->
<Chat client:idle />                               <!-- Após a página se estabilizar -->
<Stats />                                          <!-- Animações CSS puras, sem JS necessário -->

Otimização de Imagens

Use astro:assets para otimização automática.

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

<Image 
  src={heroImage} 
  alt="Imagem do hero" 
  width={800} 
  height={600}
  loading="lazy"
  format="webp"
/>

Para imagens de CMS ou fontes externas:

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

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

Regras:

  • Sempre especifique width e height para prevenção de CLS
  • Use loading="lazy" para imagens abaixo da dobra
  • Prefira formatos webp ou avif
  • Mantenha imagens do hero otimizadas e dimensionadas adequadamente

Padrão de 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)
---

<!-- Meta Tags Primárias -->
<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 Card -->
<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 && publishedTime && (
  <meta property="article:published_time" content={publishedTime.toISOString()} />
)}
{article && author && (
  <meta property="article:author" content={author} />
)}
{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',
  headline: title,
  description,
  image: imageURL,
  url: canonicalURL,
  ...(publishedTime && { datePublished: publishedTime.toISOString() })
})}>
</script>

Bônus: RSS Feed

// src/pages/rss.xml.js
import rss from '@astrojs/rss'
import { getCollection } from 'astro:content'

export async function GET(context) {
  const posts = await getCollection('blog')
  return rss({
    title: 'Blog da Vera Sesiom',
    description: 'Artigos recentes e insights',
    site: context.site,
    items: posts.map(post => ({
      title: post.data.title,
      pubDate: post.data.pubDate,
      description: post.data.description,
      link: `/blog/${post.slug}/`
    }))
  })
}

Trabalhando com o Template

  1. Monorepo — Use pnpm workspaces com o projeto na pasta apps/
  2. Construir — pnpm run build (saída em dist/)
  3. Dev — pnpm run dev (com live reload e hidratação parcial)
  4. Analisar — Verifique se Zero JS padrão permanece (sem client:* a menos que necessário)
  5. Acessibilidade — Sempre use elementos semânticos e atributos alt
  6. Desempenho — Verifique Lighthouse/Web Vitals em cada build

Resumo de Melhores Práticas

  • Zero JS por padrão — Apenas hidrate quando necessário
  • TypeScript estrito — Sempre, sem exceções
  • Coleções de conteúdo para textos — Validasão de esquema Zod
  • Ilhas Vue para interatividade — Componentes mínimos e direcionados
  • Imagens otimizadas — Sempre especifique dimensões, prefira formatos modernos
  • SEO completo — Meta tags, OpenGraph, JSON-LD
  • Screaming Architecture — Pastas refletem o domínio do site
  • Acessível por padrão — HTML semântico, contraste, navegação por teclado

Habilidades Relacionadas