Communitygithub.com

qalimero/qalim-portfolio

Guía completa para construir y desplegar sitios web estáticos con el framework Astro.

¿Qué es qalim-portfolio?

Esta skill proporciona una guía exhaustiva para construir y desplegar sitios web estáticos utilizando el framework Astro, cubriendo desde la configuración inicial hasta la generación de sitios estáticos (SSG) con soporte para Markdown, MDX, y la arquitectura de islas. Incluye instrucciones detalladas sobre el uso de Content Collections (tanto el patrón legacy de Astro 4.x como la nueva Content Layer API de Astro 5.0+), la configuración de resaltado de sintaxis con Shiki y Expressive Code, la integración de diagramas con Mermaid y PlantUML, y la implementación de búsqueda en el lado del cliente con Pagefind o Fuse.js. Además, aborda patrones avanzados como la documentación versionada, la internacionalización (i18n) con soporte para rutas y fallbacks, y la paginación de contenidos. Finalmente, proporciona una lista de verificación previa al despliegue y cubre el despliegue en plataformas como Netlify, Vercel, GitHub Pages y GCP/Firebase, así como prácticas de testing con Vitest y Playwright. Es compatible con Claude Code, Cursor, Codex y otros agentes de IA para desarrollo web.

Compatible con✓Claude Code~Codex CLI~Cursor
npx skills add https://github.com/qalimero/qalim-portfolio/tree/HEAD/.claude/skills/publishing-astro-websites

Preguntar en tu IA favorita

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

Vista previa

Del README del skill

qalim-portfolio preview

Documentación

Publicación de Sitios Web con Astro

Construye sitios web estáticos rápidos y centrados en contenido con el enfoque SSG sin runtime de Astro, hidratación parcial y amplio soporte para Markdown.

Contenido

Inicio Rápido

# Crear nuevo proyecto (usa la plantilla Blog para sitios Markdown)
npm create astro@latest

# Desarrollo
npm run dev          # Servidor local en http://localhost:4321
npm run build        # Generar archivos estáticos en dist/
npm run preview      # Vista previa de la compilación de producción

Cuándo No Usar

Esta skill se centra en generación de sitios estáticos (SSG). Considera otros enfoques para:

  • Aplicaciones con datos en tiempo real - Usa el modo SSR con conexiones a bases de datos
  • Flujos de autenticación de usuarios - Requiere manejo de sesiones del lado del servidor
  • Comercio electrónico con inventario dinámico - Usa modo híbrido o SSR completo
  • Aplicaciones de una sola página (SPA) - Considera frameworks React/Vue directamente

Para patrones híbridos SSG+SSR, consulta la documentación de adaptadores de Astro.

Estructura del Proyecto

src/
  components/     # Componentes Astro, React, Vue, Svelte
  content/        # Content Collections (Markdown/MDX)
    config.ts     # Esquemas de colecciones
    docs/         # Ejemplo de colección
  layouts/        # Wrappers de página con slots
  pages/          # Enrutamiento basado en archivos
public/           # Activos estáticos (imágenes, fuentes, favicons)
astro.config.mjs  # Configuración del framework

SSG vs SSR vs Híbrido

ModoCuándo se Renderizan las PáginasCaso de Uso
SSG (por defecto)En tiempo de compilaciónBlogs, documentación, sitios de marketing
SSREn cada solicitudDatos dinámicos, personalización
HíbridoMezcla de ambosPáginas estáticas + endpoints dinámicos

Para sitios puramente estáticos, usa el valor predeterminado output: 'static' - no se necesita adaptador.

Content Collections

Patrón Legacy (Astro 4.x)

Define esquemas en src/content/config.ts:

import { defineCollection, z } from "astro:content";

export const collections = {
  docs: defineCollection({
    schema: z.object({
      title: z.string(),
      description: z.string().optional(),
      tags: z.array(z.string()).optional(),
      order: z.number().optional(),
      draft: z.boolean().default(false)
    })
  })
};

Content Layer API (Astro 5.0+)

Nuevo patrón con el cargador glob() - hasta un 75% más rápido en compilaciones para sitios grandes:

// src/content.config.ts (nota: nombre de archivo diferente)
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';

const blog = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/data/blog' }),
  schema: ({ image }) => z.object({
    title: z.string(),
    pubDate: z.coerce.date(),
    draft: z.boolean().default(false),
    cover: image(),  // Valida que la imagen existe
    author: reference('authors'),  // Referencia entre colecciones
  })
});

export const collections = { blog };

Patrones de Esquema Avanzados

schema: ({ image }) => z.object({
  cover: image(),                    // Valida imagen en src/
  category: z.enum(['tech', 'news']),
  author: reference('authors'),      // Ref. entre colecciones
  relatedPosts: z.array(reference('blog')).optional(),
})

Cargadores Personalizados (Contenido Remoto)

Obtén contenido de APIs externas (lanzamientos de GitHub, CMS, etc.):

// src/loaders/github-releases.ts
import type { Loader } from 'astro/loaders';

export function githubReleasesLoader(repo: string): Loader {
  return {
    name: 'github-releases',
    load: async ({ store, logger }) => {
      logger.info(`Obteniendo lanzamientos para ${repo}`);
      const response = await fetch(`https://api.github.com/repos/${repo}/releases`);
      const releases = await response.json();

      for (const release of releases) {
        store.set({
          id: release.tag_name,
          data: {
            version: release.tag_name,
            published_at: release.published_at,
            body: release.body  // Notas de lanzamiento en Markdown
          }
        });
      }
    }
  };
}

Registra en content.config.ts:

import { githubReleasesLoader } from './loaders/github-releases';

const releases = defineCollection({
  loader: githubReleasesLoader('owner/repo'),
  schema: z.object({
    version: z.string(),
    published_at: z.string(),
    body: z.string(),
  })
});

Consulta y renderiza colecciones:

---
import { getCollection } from "astro:content";

export async function getStaticPaths() {
  const docs = await getCollection("docs");
  return docs.map(doc => ({
    params: { slug: doc.slug },
    props: { doc }
  }));
}

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

<article>
  <h1>{doc.data.title}</h1>
  <Content />
</article>

Resaltado de Sintaxis

Configuración Básica de Shiki

import { defineConfig } from "astro/config";

export default defineConfig({
  markdown: {
    shikiConfig: {
      theme: "github-dark",
      wrap: true
    }
  }
});

Tema Dual Claro/Oscuro

shikiConfig: {
  themes: {
    light: 'github-light',
    dark: 'github-dark',
  },
}

Añade CSS para cambiar temas:

@media (prefers-color-scheme: dark) {
  .astro-code, .astro-code span {
    color: var(--shiki-dark) !important;
    background-color: var(--shiki-dark-bg) !important;
  }
}

Resaltado de Líneas y Transformadores

```typescript {2,4}
const a = 1;
const b = 2;  // resaltado
const c = 3;
console.log(a + b + c);  // resaltado
```

Transformadores Shiki (Astro 4.14+):

import { transformerNotationFocus, transformerNotationDiff } from '@shikijs/transformers';

shikiConfig: {
  transformers: [transformerNotationFocus(), transformerNotationDiff()],
}

Usa comentarios de notación en el código:

  • // [!code focus] - Enfoca esta línea
  • // [!code ++] - Marcar como adición (verde)
  • // [!code --] - Marcar como eliminación (rojo)

Expressive Code (Recomendado para Documentación)

Bloques de código enriquecidos con botones de copia, nombres de archivo, resaltado de diferencias:

npm install astro-expressive-code
import expressiveCode from 'astro-expressive-code';

export default defineConfig({
  integrations: [expressiveCode()],
});

Características: Botón de copia, pestañas de archivos, marcadores de línea, marcos de terminal, marcadores de texto.

Integración de Diagramas

Mermaid (Recomendado)

Instala la integración de Astro:

npm install astro-mermaid mermaid
// astro.config.mjs
import { defineConfig } from 'astro/config';
import mermaid from 'astro-mermaid';

export default defineConfig({
  integrations: [mermaid({ theme: 'default' })]
});

Usa en Markdown:

```mermaid
graph TD;
    A-->B;
    B-->C;
```

Características: Renderizado del lado del cliente, cambio automático de tema, funcionalidad sin conexión, no requiere Playwright.

Alternativa (SVG estático en tiempo de compilación): Usa rehype-mermaid con Playwright para diagramas pre-renderizados (requiere npx playwright install --with-deps).

Estrategias de Temas para Modo Oscuro:

  1. Variables CSS - Deja que el navegador resuelva los colores en tiempo de ejecución:
// configuración de mermaid
mermaid.initialize({
  theme: 'base',
  themeVariables: {
    primaryColor: 'var(--diagram-primary)',
    lineColor: 'var(--diagram-line)'
  }
});
  1. Elemento Picture - Genera ambos temas, intercambia con media query:
<picture>
  <source srcset="/diagrams/flow-dark.svg" media="(prefers-color-scheme: dark)">
  <img src="/diagrams/flow-light.svg" alt="Diagrama de flujo">
</picture>
  1. SVG en Línea - Apunta a clases SVG con CSS (riesgo: colisiones de estilo) - añade criterios de accesibilidad con posibilidad de tener role='image' o role="presentation":
.dark .mermaid-svg .node rect {
  fill: var(--bg-dark);
}

PlantUML

npx astro add plantuml

Usa en Markdown:

```plantuml
@startuml
Alice -> Bob: Hola
Bob --> Alice: ¡Hola!
@enduml
```

Búsqueda en el Lado del Cliente

Pagefind (Recomendado para Sitios Grandes)

Búsqueda estática sin configuración que indexa en tiempo de compilación:

npm install pagefind

Añade a package.json:

{
  "scripts": {
    "build": "astro build && npx pagefind --site dist",
    "postbuild": "pagefind --site dist"
  }
}

Usa en componentes:

<link href="/pagefind/pagefind-ui.css" rel="stylesheet" />
<script src="/pagefind/pagefind-ui.js" type="text/javascript"></script>

<div id="search"></div>
<script>
  window.addEventListener('DOMContentLoaded', () => {
    new PagefindUI({ element: '#search', showSubResults: true });
  });
</script>

Características: Sin servicio externo, funciona sin conexión, indexación automática, paquete pequeño (~10KB).

Control de Indexación Granular:

<!-- Solo indexa el contenido principal, no los encabezados/barras laterales -->
<main data-pagefind-body>
  <h1 data-pagefind-meta="title">Título de la Página</h1>
  <p data-pagefind-weight="10">Texto introductorio importante</p>

  <!-- Excluir de los fragmentos de búsqueda -->
  <nav data-pagefind-ignore>
    <a href="/related">Artículos Relacionados</a>
  </nav>
</main>
AtributoPropósito
data-pagefind-bodyLimitar la indexación solo a este elemento
data-pagefind-ignoreExcluir elemento del índice
data-pagefind-meta="key"Definir campo de metadatos
data-pagefind-weight="10"Aumentar relevancia (predeterminado: 1)

Pagefind vs Fuse.js

CaracterísticaPagefindFuse.js
ArquitecturaFragmentos binarios precompiladosEn memoria en tiempo de ejecución
Ancho de bandaBajo (carga solo fragmentos necesarios)Alto (descarga el índice completo)
EscalabilidadMás de 10,000 páginasMenos de 500 páginas
MultilingüeDerivación nativaConfiguración manual
Caso de usoBúsqueda global del sitioFiltrado de listas pequeñas

Fuse.js (Alternativa Ligera)

Para sitios más pequeños con necesidades de UI personalizadas:

---
import { getCollection } from "astro:content";
const posts = await getCollection('blog');
const searchIndex = JSON.stringify(posts.map(post => ({
  title: post.data.title,
  slug: post.slug,
  body: post.body.slice(0, 500)
})));
---

<input type="search" id="search" placeholder="Buscar..." />
<ul id="results"></ul>

<script define:vars={{ searchIndex }}>
  import Fuse from 'fuse.js';

  const fuse = new Fuse(JSON.parse(searchIndex), {
    keys: ['title', 'body'],
    threshold: 0.3
  });

  document.getElementById('search').addEventListener('input', (e) => {
    const results = fuse.search(e.target.value);
    const resultsEl = document.getElementById('results');

    // Limpiar resultados anteriores de forma segura
    resultsEl.replaceChildren();

    // Construir resultados usando métodos DOM seguros
    results.forEach(r => {
      const li = document.createElement('li');
      const link = document.createElement('a');
      link.href = `/blog/${r.item.slug}`;
      link.textContent = r.item.title;
      li.appendChild(link);
      resultsEl.appendChild(li);
    });
  });
</script>

Para necesidades empresariales, considera Algolia (API de búsqueda alojada).

Documentación Versionada

Patrón de Documentación Multi-Versión

Usa una estructura basada en carpetas:

src/content/docs/
  v1/
    getting-started.md
    api-reference.md
  v2/
    getting-started.md
    api-reference.md
    new-feature.md

Internacionalización (i18n)

Configura en astro.config.mjs:

export default defineConfig({
  i18n: {
    defaultLocale: "en",
    locales: ["en", "fr", "es"],
    routing: {
      prefixDefaultLocale: false
    }
  }
});

Estructura el contenido por locale:

src/content/docs/
  en/
    getting-started.md
  fr/
    getting-started.md

Detecta el locale en componentes:

---
const locale = Astro.currentLocale || 'en';
---

Fallback para Traducciones Faltantes

Muestra contenido en inglés con un banner cuando no existan traducciones:

---
// src/pages/[lang]/[...slug].astro
import { getCollection, getEntry } from "astro:content";

const languages = ['en', 'es', 'fr'];
const defaultLang = 'en';

export async function getStaticPaths() {
  const englishDocs = await getCollection('docs', ({ id }) => id.startsWith('en/'));
  const paths = [];

  for (const doc of englishDocs) {
    const slug = doc.id.replace(/^en\//, '');

    for (const lang of languages) {
      const localizedId = `${lang}/${slug}`;
      const localizedDoc = await getEntry('docs', localizedId);

      paths.push({
        params: { lang, slug },
        props: {
          entry: localizedDoc || doc,  // Fallback a inglés
          isFallback: !localizedDoc
        }
      });
    }
  }
  return paths;
}

const { entry, isFallback } = Astro.props;
const { Content } = await entry.render();
---

{isFallback && (
  <div class="translation-notice">
    Esta página aún no está disponible en tu idioma.
  </div>
)}
<Content />

Patrones Comunes

Listados Paginados

Para sitios con más de 50 publicaciones, divide en páginas:

---
// src/pages/blog/page/[page].astro
import { getCollection } from "astro:content";

const POSTS_PER_PAGE = 10;

export async function getStaticPaths() {
  const allPosts = await getCollection("blog");
  const totalPages = Math.ceil(allPosts.length / POSTS_PER_PAGE);

  return Array.from({ length: totalPages }, (_, i) => ({
    params: { page: String(i + 1) },
    props: { page: i + 1, totalPages }
  }));
}

const { page, totalPages } = Astro.props;
const allPosts = await getCollection("blog");
const start = (page - 1) * POSTS_PER_PAGE;
const posts = allPosts.slice(start, start + POSTS_PER_PAGE);
---
<main>
  {posts.map(post => (
    <article>
      <h2><a href={`/blog/${post.slug}`}>{post.data.title}</a></h2>
      <p>{post.data.description}</p>
    </article>
  ))}
  <nav>
    {page > 1 && <a href={`/blog/page/${page - 1}`}>Anterior</a>}
    {page < totalPages && <a href={`/blog/page/${page + 1}`}>Siguiente</a>}
  </nav>
</main>

Etiquetas y Categorías

Genera páginas para cada categoría:

---
import { getCollection } from "astro:content";

const allPosts = await getCollection("blog");
const categories = [...new Set(allPosts.flatMap(p => p.data.tags))];

export async function getStaticPaths() {
  return categories.map(category => ({
    params: { category },
    props: {
      posts: allPosts.filter(p => p.data.tags?.includes(category))
    }
  }));
}

const { posts } = Astro.props;
---

RSS Feed

Genera el feed durante la compilación:

npm install @astrojs/rss
// 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: 'Mi Blog',
    description: 'Un blog sobre tecnología',
    site: context.site,
    items: posts.map((post) => ({
      title: post.data.title,
      pubDate: post.data.pubDate,
      description: post.data.description,
      link: `/blog/${post.slug}/`,
    })),
  });
}

Manejo de Formularios

Envíos de formularios estáticos:

<form netlify name="contact" method="POST" data-netlify="true">
  <input type="hidden" name="form-name" value="contact" />
  <input type="text" name="name" placeholder="Tu nombre" required />
  <input type="email" name="email" placeholder="Tu correo" required />
  <textarea name="message" placeholder="Mensaje"></textarea>
  <button type="submit">Enviar</button>
</form>

Alternativa: usa Web3Forms, Formspree o un endpoint de funciones serverless.

Mejores Prácticas de Rendimiento

Precarga con Speculation Rules

Precarga las páginas en segundo plano:

// astro.config.mjs
import { defineConfig } from 'astro/config';

export default defineConfig({
  prefetch: {
    prefetchAll: true,
    defaultStrategy: 'hover'
  }
});

Usa en enlaces:

<a href="/about" data-astro-prefetch="tap">Acerca de</a>

CSS Crítico

Extrae CSS en línea para la carga inicial:

npm install astro-critical-css

Optimización de Imágenes

Usa el componente Image de Astro:

---
import { Image } from 'astro:assets';
import heroImage from '../assets/hero.png';
---
<Image src={heroImage} alt="Hero" widths={[400, 800, 1200]} sizes="(max-width: 800px) 100vw, 800px" />

Optimización de Fuentes

Carga fuentes de manera eficiente:

<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet" />

Despliegue

El sitio estático se construye en dist/. Elige un adaptador según la plataforma:

Netlify (Sin Adaptador)

Netlify detecta automáticamente Astro. Configura:

# netlify.toml
[build]
  command = "npm run build"
  publish = "dist"

Adaptador de Vercel

npm install @astrojs/vercel
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel/static';

export default defineConfig({
  output: 'static',
  adapter: vercel(),
});

Adaptador de GitHub Pages

npm install @astrojs/netlify  # No, usa actions/gh-pages

Configura el flujo de trabajo:

# .github/workflows/deploy.yml
name: Deploy to GitHub Pages

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: withastro/action@v2

Adaptador de Firebase (GCP)

npm install @astrojs/firebase
import { defineConfig } from 'astro/config';
import firebase from '@astrojs/firebase';

export default defineConfig({
  output: 'static',
  adapter: firebase(),
});

Lista de Verificación Pre-Despliegue

  • Compilación de producción (npm run build) se completa sin errores
  • Vista previa de compilación (npm run preview) funciona localmente
  • Las rutas personalizadas 404/500 existen
  • Metadatos SEO (título, descripción, Open Graph) son correctos en todas las páginas
  • El sitemap se genera (npm install @astrojs/sitemap)
  • Los enlaces externos se abren con target="_blank" rel="noopener noreferrer"
  • Las imágenes tienen alt texto descriptivo
  • El contraste de colores cumple con WCAG AA
  • No hay enlaces rotos (usa npm run check-links)
  • La navegación por teclado funciona en todos los elementos interactivos
  • Los lectores de pantalla pueden acceder al contenido (prueba con NVDA/VoiceOver)
  • Los análisis/scripts de terceros son diferidos o asíncronos
  • Se configura la compresión de activos estáticos (Brotli/Gzip)
  • Las cabeceras de caché se configuran según el tipo de archivo (inmutable para hashes, corto para HTML)
  • Las redirecciones se prueban (por ejemplo, dominio antiguo a nuevo, trailing slashes)
  • SSL/TLS está habilitado (redirección automática HTTP a HTTPS)
  • Se prueba la funcionalidad sin conexión (offline) si se usa Service Worker
  • Los formularios se envían correctamente (Netlify/Formspree/Serverless)
  • El feed RSS se valida
  • El mapa del sitio se presenta a Google Search Console
  • Se verifica el rendimiento con Lighthouse (mínimo 90 en todas las categorías)

Testing y Calidad

Testing Unitario con Vitest

npm install -D vitest @astrojs/astro-test
// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'node',
    include: ['src/**/*.test.ts'],
  },
});

Testing de Integración con Playwright

npm install -D @playwright/test
npx playwright install
// tests/navigation.spec.js
import { test, expect } from '@playwright/test';

test('navegación funciona correctamente', async ({ page }) => {
  await page.goto('http://localhost:4321');
  await page.click('text=Blog');
  await expect(page).toHaveURL(/\/blog/);
  await expect(page.locator('h1')).toContainText('Blog');
});

Verificación de Enlaces

npm install -D hyperlink
// package.json
{
  "scripts": {
    "check-links": "hyperlink dist/ --internal --suggest"
  }
}

Solución de Problemas

Problemas Comunes

ProblemaCausaSolución
La compilación falla con errores de TypeScriptEsquemas de colecciones incorrectosVerifica las definiciones de Zod importadas desde astro:content
El contenido no aparece en producciónArchivos en src/content/ no son seguidos por gitAsegúrate de que git rastrea la carpeta content/
Las imágenes no se muestran en la compilaciónLas rutas de recursos son incorrectasUsa import para imágenes en lugar de cadenas de texto literales
El resaltado de sintaxis no funcionaConfiguración de Shiki no cargadaVerifica que shikiConfig esté dentro del bloque markdown en astro.config.mjs
Error de carga de Content Layer APINombre de archivo diferente (content.config.ts en lugar de config.ts)Usa src/content.config.ts (sin carpeta content/ intermedia)
El CSS no se minifica en producciónOpciones de compilación de ViteVerifica vite.css.minify en astro.config.mjs
El rendimiento de Lighthouse es bajoFalta CSS crítico, carga de fuentesImplementa extracción de CSS en línea y carga diferida de fuentes

Depuración

  • Compilaciones lentas: Ejecuta astro build --verbose para ver tiempos de cada paso
  • Colecciones vacías: Verifica que los archivos coincidan con el patrón del cargador glob()
  • Problemas de SSR: Revisa output config y el adaptador de despliegue
  • Errores de Pagefind: Asegúrate de que la compilación se completa antes de la indexación

Skills relacionados