Communitygithub.com

asolis87/complementary-food-app

Convenciones de Astro para landing pages, sitios de contenido y proyectos SSG en Vera Sesiom. Zero JS por defecto, Islas Vue para interactividad y arquitectura centrada en contenido.

¿Qué es complementary-food-app?

Este skill proporciona convenciones y patrones de proyecto para construir landing pages, sitios de marketing, blogs, documentación y cualquier sitio web estático con mucho contenido usando Astro en Vera Sesiom. Define una arquitectura clara de proyecto con 'Screaming Architecture', prioriza el rendimiento y SEO, y gestiona la interactividad mediante una arquitectura de islas con componentes Vue. El skill detalla cuándo usar Astro frente a Vue, muestra patrones de componentes Astro, layouts, colecciones de contenido con esquemas Zod, optimización de imágenes con 'astro:assets', SEO, y el uso de directivas cliente. Está diseñado para entornos de desarrollo con Astro, Tailwind CSS y TypeScript, y busca ofrecer un rendimiento óptimo con el mínimo JavaScript.

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

Preguntar en tu IA favorita

Abre un nuevo chat con esta habilidad de agente ya precargada.

Documentación

Cuándo Usar

  • Landing pages y sitios de marketing
  • Sitios corporativos
  • Sitios con mucho contenido (blogs, documentación)
  • Cualquier proyecto donde el SEO y el rendimiento sean críticos
  • Sitios mayormente estáticos con mínima interactividad
  • Sitios que necesitan carga inicial rápida y Core Web Vitals

NO para aplicaciones web completas — usa vue-frontend para aplicaciones interactivas (paneles, apps CRUD, apps en tiempo real).


Reglas Críticas

  1. Zero JS por defecto — Astro NO envía JavaScript a menos que añadas una directiva cliente
  2. TypeScript SIEMPRE — igual que todos los proyectos de Vera Sesiom, modo estricto
  3. Usa Colecciones de Contenido para contenido estructurado — posts de blog, documentación, casos de estudio
  4. Arquitectura de Islas — componentes Vue/React solo donde se necesita interactividad
  5. Salida estática (SSG) por defecto — SSR solo cuando el contenido dinámico lo requiere
  6. Tailwind CSS para estilos — igual que el frontend Vue
  7. Screaming Architecture — las carpetas gritan el dominio, no el framework

Criterios de Decisión: Astro vs Vue

Cuándo usar AstroCuándo usar Vue
Landing page, sitio de marketingAplicación web (panel, admin)
Mucho contenido (blog, docs)UI altamente interactiva
SEO es críticoSe necesitan funciones en tiempo real
Contenido mayormente estáticoGestión de estado compleja
Interactividad mínima (formularios, carruseles)Navegación SPA completa
Zero JS es una característicaLos beneficios del ecosistema Vue importan

Regla: Si el sitio es contenido que la gente LEE → Astro. Si es una app que la gente USA → Vue.


Estructura del Proyecto (Screaming Architecture)

apps/landing/   (o apps/website/)
├── src/
│   ├── pages/                    # Enrutamiento basado en archivos (.astro)
│   │   ├── index.astro           # Página de inicio
│   │   ├── about.astro           # Página 'Acerca de'
│   │   ├── pricing.astro         # Página de precios
│   │   ├── blog/
│   │   │   ├── index.astro      # Lista de blogs
│   │   │   └── [slug].astro      # Post de blog (dinámico)
│   │   └── contact.astro
│   ├── layouts/                  # Layouts de página
│   │   ├── BaseLayout.astro      # Estructura HTML base
│   │   ├── BlogLayout.astro      # Layout de post de blog
│   │   └── LandingLayout.astro   # Layout de landing page
│   ├── components/               # Componentes Astro (estáticos por defecto)
│   │   ├── sections/             # Secciones de página
│   │   │   ├── Hero.astro
│   │   │   ├── Features.astro
│   │   │   ├── Pricing.astro
│   │   │   ├── Testimonials.astro
│   │   │   └── CTA.astro
│   │   ├── ui/                   # Átomos UI reutilizables
│   │   │   ├── Button.astro
│   │   │   ├── Card.astro
│   │   │   ├── Badge.astro
│   │   │   └── Input.astro
│   │   └── interactive/          # Islas Vue/React
│   │       ├── ContactForm.vue   # Formulario con validación
│   │       ├── Carousel.vue      # Carrusel de imágenes
│   │       └── MobileMenu.vue    # Navegación móvil
│   ├── content/                  # Colecciones de contenido
│   │   ├── blog/                 # Posts de blog en Markdown/MDX
│   │   │   ├── post-1.md
│   │   │   └── post-2.mdx
│   │   └── config.ts             # Esquema de colección (Zod)
│   ├── styles/                   # Estilos globales
│   │   └── global.css            # Importaciones de Tailwind
│   ├── assets/                   # Imágenes optimizadas, fuentes
│   │   ├── images/
│   │   └── fonts/
│   └── utils/                    # Funciones auxiliares
│       ├── date-format.ts
│       └── seo.ts
├── public/                       # Assets estáticos (sin procesamiento)
│   ├── favicon.ico
│   ├── robots.txt
│   └── fonts/
├── astro.config.mjs              # Configuración de Astro
├── tailwind.config.mjs           # Configuración de Tailwind
├── tsconfig.json
└── package.json

Patrón de Componente Astro

Cada archivo .astro tiene dos partes: frontmatter (servidor) y template (HTML).

---
// src/components/ui/Button.astro
// Frontmatter: se ejecuta en el servidor, NO en el JS del 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>
)}

Puntos clave:

  • El frontmatter se ejecuta en tiempo de compilación (o tiempo de petición para SSR)
  • No se necesita export default — el template ES la exportación
  • <slot /> para contenido hijo
  • Las props se tipan con interfaces de TypeScript

Patrón 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="es">
  <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 - Desarrollo de Software" description="Construimos software que importa.">
  <Hero />
</BaseLayout>

Patrón de Colección de Contenido

Define contenido estructurado con validación 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 Qué Elegimos Astro para Landing Pages"
description: "El rendimiento y la experiencia de desarrollo importan. Aquí está por qué Astro es nuestra opción para sitios de contenido."
pubDate: 2024-01-15
author: "Líder Técnico"
image:
  url: "/images/blog/astro-landing.jpg"
  alt: "Logotipo de Astro en un sitio web moderno"
tags: ["astro", "rendimiento", "arquitectura"]
---

El contenido va aquí...
---
// 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="Últimos artículos de 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>

Arquitectura de Islas (Componentes Vue)

Los componentes interactivos envían JS solo cuando es necesario. Usa directivas cliente para controlar la hidratación.

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

<template>
  <form @submit.prevent="submitForm" class="space-y-4">
    <div>
      <label for="name">Nombre</label>
      <input v-model="form.name" type="text" id="name" required />
    </div>
    <div>
      <label for="email">Correo Electrónico</label>
      <input v-model="form.email" type="email" id="email" required />
    </div>
    <div>
      <label for="message">Mensaje</label>
      <textarea v-model="form.message" id="message" rows="4" required />
    </div>
    <button type="submit" :disabled="!isValid">
      Enviar Mensaje
    </button>
  </form>
</template>

Uso en Astro:

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

<BaseLayout title="Contáctanos">
  <h1>Ponte en Contacto</h1>
  <p>Nos encantaría saber de ti.</p>
  
  <!-- client:visible = hidratar cuando se vea al hacer scroll (MEJOR PRÁCTICA) -->
  <ContactForm client:visible />
</BaseLayout>

Árbol de Decisión de Directivas Cliente

DirectivaCuándo UsarJS se Envía
Sin directivaContenido estático, sin interactividadZero JS
client:visibleInteractividad bajo el pliegue (formularios, carruseles) — POR DEFECTOCuando se ve al hacer scroll
client:loadInteractividad crítica sobre el pliegueInmediatamente
client:idleMejoras no críticas (analíticas, chat)Después de que la página esté inactiva
client:mediaCondicional por punto de ruptura (nav móvil)Cuando el media query coincide
client:onlyComponentes tipo SPA (saltar SSR)Inmediatamente, sin SSR

Mejores Prácticas:

  • Por defecto sin directiva — puramente estático
  • Por defecto client:visible cuando se necesita interactividad
  • Usa client:load con moderación — solo para elementos críticos sobre el pliegue
  • Usa client:idle para mejoras que no bloquean
---
<!-- Buenos ejemplos -->
import Hero from './Hero.astro'                    <!-- Estático, sin 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, sin hidratación -->
<Carousel client:visible />                       <!-- Hidratar cuando sea visible -->
<MobileMenu client:media="(max-width: 768px)" />  <!-- Solo móvil -->
<Chat client:idle />                               <!-- Después de que la página se asiente -->
<Stats />                                          <!-- Animaciones CSS puras, no necesita JS -->

Optimización de Imágenes

Usa astro:assets para optimización automática.

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

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

Para imágenes de CMS o fuentes externas:

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

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

Reglas:

  • Siempre especifica width y height para prevenir CLS
  • Usa loading="lazy" para imágenes bajo el pliegue
  • Prefiere formatos webp o avif
  • Mantén las imágenes principales optimizadas y con el tamaño adecuado

Patrón 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 Primarios -->
<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} />

<!-- Artículo específico -->
{article && publishedTime && (
  <meta property="article:published_time" content={publishedTime.toISOString()} />
)}
{article && author && (
  <meta property="article:author" content={author} />
)}
{article && tags.length > 0 && tags.map(tag => (
  <meta property="article:tag" content={tag} />
))}

<!-- Breadcrumbs 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 } })
})} />

Rendimiento y Core Web Vitals

Carga de Fuentes

---
// En layouts/BaseLayout.astro (dentro de <head>)
---
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

<!-- Estrategia de fuentes: usar display=swap para FOUT -->
<link 
  href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" 
  rel="stylesheet" 
/>

<!-- Alternativa mejor: descargar fuentes y alojar localmente -->

<style is:global>
  @font-face {
    font-family: 'Inter';
    src: url('/fonts/Inter-Regular.woff2') format('woff2');
    font-display: swap;
    font-weight: 400;
  }
  @font-face {
    font-family: 'Inter';
    src: url('/fonts/Inter-Bold.woff2') format('woff2');
    font-display: swap;
    font-weight: 700;
  }
</style>

Optimización CSS

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

@layer components {
  .container {
    @apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
  }
  
  .section {
    @apply py-16 sm:py-24;
  }
  
  .prose {
    @apply max-w-none text-gray-600;
  }
}

@layer utilities {
  .text-balance {
    text-wrap: balance;
  }
}

Métricas Clave

MétricaObjetivoCómo Lograrlo en Astro
LCP< 2.5sImágenes optimizadas, fuentes alojadas localmente, HTML estático mínimo
FID< 100msMínimo JS, usar client:idle o client:visible
CLS< 0.1Tamaños width/height en todas las imágenes, reserva de espacio para fuentes
TTFB< 800msSSG, CDN, edge caching

Estrategia de Despliegue

Construcción SSG Estándar

# Construcción de producción
pnpm build

# Los archivos estáticos se generan en dist/
# Desplegar dist/ a cualquier hosting estático:
# - Netlify
# - Vercel
# - Cloudflare Pages
# - AWS S3 + CloudFront

Configuración de Netlify (netlify.toml)

[build]
  command = "pnpm build"
  publish = "dist"

[[headers]]
  for = "/*"
  [headers.values]
    X-Frame-Options = "DENY"
    X-Content-Type-Options = "nosniff"
    Referrer-Policy = "strict-origin-when-cross-origin"

[[headers]]
  for = "/assets/*"
  [headers.values]
    Cache-Control = "public, max-age=31536000, immutable"

[[headers]]
  for = "/*.html"
  [headers.values]
    Cache-Control = "public, max-age=0, must-revalidate"

[[redirects]]
  from = "/blog/*"
  to = "/blog/:splat"
  status = 200

Migración de Contenido: Markdown Frontmatter

Esto es lo que usamos para contenido/formato de blog en Vera Sesiom.

---
title: "Título del Post de Blog"
description: "Descripción breve para SEO y tarjetas de vista previa"
pubDate: 2024-01-15
author: "Nombre del Autor"
image:
  url: "/images/blog/post-image.jpg"
  alt: "Texto alternativo descriptivo"
tags: ["astro", "rendimiento", "tutorial"]
draft: false
canonicalURL: "https://example.com/post-url"  # Opcional
---

Resumen y Reglas de Oro

  1. Zero JS por defecto — No envíes JS a menos que sea esencial para la experiencia del usuario
  2. Arquitectura de Islas — Aísla la interactividad; solo se hidratan componentes específicos
  3. Colecciones de Contenido — Usa astro:content para todo el contenido estructurado con esquemas Zod
  4. Imágenes Optimizadas — Siempre usa Image de astro:assets con dimensiones explícitas
  5. SEO Primero — Meta tags, OpenGraph, datos estructurados, URLs canónicas en cada página
  6. Screaming Architecture — Nombra las carpetas por dominio (sections/, ui/, interactive/), no por tipo de archivo
  7. TypeScript Estricto — Sin modo any o strict:false
  8. Rendimiento — Apunta a < 50ms tiempo de bloqueo en dispositivos móviles
  9. Componentes Atómicos — UI atoms reutilizables (Button, Card, Badge) vs. secciones de página
  10. Caso de Uso Correcto — Contenido que se LEE → Astro. Aplicación que se USA → Vue

Referencia Rápida de Comandos

# Desarrollo
pnpm dev              # Iniciar servidor de desarrollo

# Construcción
pnpm build            # Construcción de producción
pnpm preview          # Vista previa de la construcción

# Gestión de Contenido
pnpm astro check      # Verificar tipos
pnpm astro sync       # Sincronizar tipos de colecciones de contenido

# Utilidades
pnpm astro --help     # Ver comandos de CLI

Skills relacionados