Custom Post Types y Taxonomías en Astro

Por ximo

Custom Post Types y Taxonomías en Astro

¿Qué son los Custom Post Types?

Los Custom Post Types (CPT) de WordPress te permiten crear tipos de contenido más allá de posts y páginas: portfolios, testimonios, productos, eventos, etc. Combinados con Astro, puedes crear sitios web altamente especializados manteniendo la facilidad de gestión de WordPress.

Configurar Custom Post Types en WordPress

Primero, registra tus tipos de contenido personalizados:

<?php
// wp-content/themes/tu-tema/functions.php

function registrar_portfolio() {
    $args = array(
        'label' => 'Portfolio',
        'public' => true,
        'show_in_graphql' => true, // Importante para GraphQL
        'graphql_single_name' => 'portfolioItem',
        'graphql_plural_name' => 'portfolioItems',
        'supports' => array('title', 'editor', 'thumbnail', 'custom-fields'),
        'has_archive' => true,
        'rewrite' => array('slug' => 'portfolio'),
        'menu_icon' => 'dashicons-portfolio'
    );
    register_post_type('portfolio', $args);
}

function registrar_testimonios() {
    $args = array(
        'label' => 'Testimonios',
        'public' => true,
        'show_in_graphql' => true,
        'graphql_single_name' => 'testimonial',
        'graphql_plural_name' => 'testimonials',
        'supports' => array('title', 'editor', 'thumbnail'),
        'menu_icon' => 'dashicons-testimonial'
    );
    register_post_type('testimonio', $args);
}

add_action('init', 'registrar_portfolio');
add_action('init', 'registrar_testimonios');

Registrar Taxonomías Personalizadas

Las taxonomías organizan tu contenido (como categorías y etiquetas):

<?php
function registrar_taxonomias_portfolio() {
    // Taxonomía: Tipo de proyecto
    register_taxonomy('tipo_proyecto', 'portfolio', array(
        'label' => 'Tipos de Proyecto',
        'hierarchical' => true, // Como categorías
        'show_in_graphql' => true,
        'graphql_single_name' => 'projectType',
        'graphql_plural_name' => 'projectTypes',
        'rewrite' => array('slug' => 'tipo-proyecto')
    ));

    // Taxonomía: Tecnologías
    register_taxonomy('tecnologia', 'portfolio', array(
        'label' => 'Tecnologías',
        'hierarchical' => false, // Como etiquetas
        'show_in_graphql' => true,
        'graphql_single_name' => 'technology',
        'graphql_plural_name' => 'technologies',
        'rewrite' => array('slug' => 'tecnologia')
    ));
}

add_action('init', 'registrar_taxonomias_portfolio');

Queries GraphQL para Custom Post Types

Crea queries específicas para cada tipo de contenido:

// src/queries/portfolio.js
import { gql } from '@apollo/client';

export const GET_PORTFOLIO_ITEMS = gql`
  query GetPortfolioItems {
    portfolioItems(first: 100) {
      nodes {
        id
        title
        slug
        content
        date
        featuredImage {
          node {
            sourceUrl
            altText
            mediaDetails {
              width
              height
            }
          }
        }
        projectTypes {
          nodes {
            name
            slug
          }
        }
        technologies {
          nodes {
            name
            slug
          }
        }
        portfolioFields {
          clientName
          projectUrl
          completionDate
        }
      }
    }
  }
`;

export const GET_PORTFOLIO_BY_SLUG = gql`
  query GetPortfolioBySlug($slug: ID!) {
    portfolioItem(id: $slug, idType: SLUG) {
      id
      title
      content
      date
      featuredImage {
        node {
          sourceUrl
          altText
        }
      }
      projectTypes {
        nodes {
          name
          slug
        }
      }
      technologies {
        nodes {
          name
          slug
        }
      }
      portfolioFields {
        clientName
        projectUrl
        completionDate
        challenge
        solution
        results
      }
    }
  }
`;

Queries para Testimonios

// src/queries/testimonials.js
import { gql } from '@apollo/client';

export const GET_TESTIMONIALS = gql`
  query GetTestimonials {
    testimonials(first: 50) {
      nodes {
        id
        title
        content
        featuredImage {
          node {
            sourceUrl
            altText
          }
        }
        testimonialFields {
          authorName
          authorPosition
          company
          rating
        }
      }
    }
  }
`;

Página de Portfolio

Muestra todos los items del portfolio con filtros:

---
// src/pages/portfolio.astro
import { Image } from 'astro:assets';
import client from '../lib/wordpress';
import { GET_PORTFOLIO_ITEMS } from '../queries/portfolio';

const { data } = await client.query({
  query: GET_PORTFOLIO_ITEMS,
});

const portfolioItems = data.portfolioItems.nodes;

// Obtener todas las tecnologías únicas para filtros
const allTechnologies = [...new Set(
  portfolioItems.flatMap(item => 
    item.technologies.nodes.map(tech => tech.name)
  )
)];
---

<html lang="es">
  <head>
    <title>Portfolio</title>
  </head>
  <body>
    <div class="container">
      <h1>Nuestro Portfolio</h1>
      
      <div class="filters">
        <button class="filter-btn active" data-filter="all">
          Todos
        </button>
        {allTechnologies.map(tech => (
          <button class="filter-btn" data-filter={tech}>
            {tech}
          </button>
        ))}
      </div>

      <div class="portfolio-grid">
        {portfolioItems.map(item => (
          <article 
            class="portfolio-item"
            data-technologies={item.technologies.nodes.map(t => t.name).join(',')}
          >
            {item.featuredImage && (
              <div class="portfolio-image">
                <Image
                  src={item.featuredImage.node.sourceUrl}
                  alt={item.featuredImage.node.altText || item.title}
                  width={item.featuredImage.node.mediaDetails.width}
                  height={item.featuredImage.node.mediaDetails.height}
                  format="webp"
                  quality={80}
                />
              </div>
            )}
            
            <div class="portfolio-content">
              <h2>{item.title}</h2>
              
              <div class="meta">
                {item.projectTypes.nodes.map(type => (
                  <span class="badge">{type.name}</span>
                ))}
              </div>

              <div class="technologies">
                {item.technologies.nodes.map(tech => (
                  <span class="tech-tag">{tech.name}</span>
                ))}
              </div>

              {item.portfolioFields?.clientName && (
                <p class="client">Cliente: {item.portfolioFields.clientName}</p>
              )}

              <a href={`/portfolio/${item.slug}`} class="view-project">
                Ver Proyecto →
              </a>
            </div>
          </article>
        ))}
      </div>
    </div>
  </body>
</html>

<script>
  const filterButtons = document.querySelectorAll('.filter-btn');
  const portfolioItems = document.querySelectorAll('.portfolio-item');

  filterButtons.forEach(button => {
    button.addEventListener('click', () => {
      const filter = button.dataset.filter;
      
      // Actualizar botones activos
      filterButtons.forEach(btn => btn.classList.remove('active'));
      button.classList.add('active');

      // Filtrar items
      portfolioItems.forEach(item => {
        const technologies = item.dataset.technologies.split(',');
        
        if (filter === 'all' || technologies.includes(filter)) {
          item.style.display = 'block';
          item.style.animation = 'fadeIn 0.3s';
        } else {
          item.style.display = 'none';
        }
      });
    });
  });
</script>

<style>
  .container {
    max-width: 1200px;
    margin: 0 auto;
    padding: 2rem;
  }

  .filters {
    display: flex;
    gap: 1rem;
    flex-wrap: wrap;
    margin: 2rem 0;
  }

  .filter-btn {
    padding: 0.5rem 1rem;
    border: 2px solid #ddd;
    background: white;
    border-radius: 8px;
    cursor: pointer;
    transition: all 0.3s;
  }

  .filter-btn:hover,
  .filter-btn.active {
    background: #2563eb;
    color: white;
    border-color: #2563eb;
  }

  .portfolio-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
    gap: 2rem;
  }

  .portfolio-item {
    background: white;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 4px 6px rgba(0,0,0,0.1);
    transition: transform 0.3s;
  }

  .portfolio-item:hover {
    transform: translateY(-5px);
    box-shadow: 0 8px 12px rgba(0,0,0,0.15);
  }

  .portfolio-image {
    width: 100%;
    height: 250px;
    overflow: hidden;
  }

  .portfolio-image img {
    width: 100%;
    height: 100%;
    object-fit: cover;
  }

  .portfolio-content {
    padding: 1.5rem;
  }

  .meta {
    display: flex;
    gap: 0.5rem;
    margin: 1rem 0;
  }

  .badge {
    background: #e0e7ff;
    color: #3730a3;
    padding: 0.25rem 0.75rem;
    border-radius: 6px;
    font-size: 0.875rem;
    font-weight: 600;
  }

  .technologies {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
    margin: 1rem 0;
  }

  .tech-tag {
    background: #f3f4f6;
    padding: 0.25rem 0.5rem;
    border-radius: 4px;
    font-size: 0.75rem;
  }

  .view-project {
    display: inline-block;
    margin-top: 1rem;
    color: #2563eb;
    font-weight: 600;
    text-decoration: none;
  }

  @keyframes fadeIn {
    from { opacity: 0; transform: scale(0.95); }
    to { opacity: 1; transform: scale(1); }
  }
</style>

Página Individual de Portfolio

---
// src/pages/portfolio/[slug].astro
import { Image } from 'astro:assets';
import client from '../../lib/wordpress';
import { GET_PORTFOLIO_BY_SLUG, GET_PORTFOLIO_ITEMS } from '../../queries/portfolio';

export async function getStaticPaths() {
  const { data } = await client.query({
    query: GET_PORTFOLIO_ITEMS,
  });

  return data.portfolioItems.nodes.map(item => ({
    params: { slug: item.slug },
  }));
}

const { slug } = Astro.params;

const { data } = await client.query({
  query: GET_PORTFOLIO_BY_SLUG,
  variables: { slug },
});

const project = data.portfolioItem;
---

<html lang="es">
  <head>
    <title>{project.title} - Portfolio</title>
  </head>
  <body>
    <article class="project">
      <header class="project-header">
        {project.featuredImage && (
          <div class="hero-image">
            <Image
              src={project.featuredImage.node.sourceUrl}
              alt={project.featuredImage.node.altText || project.title}
              width={1200}
              height={600}
              format="webp"
              quality={90}
            />
          </div>
        )}

        <div class="container">
          <h1>{project.title}</h1>
          
          <div class="project-meta">
            {project.portfolioFields?.clientName && (
              <div class="meta-item">
                <strong>Cliente:</strong>
                <span>{project.portfolioFields.clientName}</span>
              </div>
            )}

            {project.portfolioFields?.completionDate && (
              <div class="meta-item">
                <strong>Fecha:</strong>
                <span>{project.portfolioFields.completionDate}</span>
              </div>
            )}

            {project.portfolioFields?.projectUrl && (
              <a 
                href={project.portfolioFields.projectUrl} 
                target="_blank"
                class="project-link"
              >
                Ver Proyecto en Vivo →
              </a>
            )}
          </div>

          <div class="taxonomies">
            <div class="taxonomy-group">
              <h3>Tipo de Proyecto</h3>
              <div class="tags">
                {project.projectTypes.nodes.map(type => (
                  <span class="tag">{type.name}</span>
                ))}
              </div>
            </div>

            <div class="taxonomy-group">
              <h3>Tecnologías</h3>
              <div class="tags">
                {project.technologies.nodes.map(tech => (
                  <span class="tag tech">{tech.name}</span>
                ))}
              </div>
            </div>
          </div>
        </div>
      </header>

      <div class="container">
        <div class="project-content">
          {project.portfolioFields?.challenge && (
            <section class="content-section">
              <h2>El Desafío</h2>
              <div set:html={project.portfolioFields.challenge} />
            </section>
          )}

          {project.portfolioFields?.solution && (
            <section class="content-section">
              <h2>La Solución</h2>
              <div set:html={project.portfolioFields.solution} />
            </section>
          )}

          {project.portfolioFields?.results && (
            <section class="content-section">
              <h2>Resultados</h2>
              <div set:html={project.portfolioFields.results} />
            </section>
          )}

          <section class="content-section">
            <h2>Sobre el Proyecto</h2>
            <div set:html={project.content} />
          </section>
        </div>
      </div>
    </article>

    <a href="/portfolio" class="back-link">← Volver al Portfolio</a>
  </body>
</html>

<style>
  .container {
    max-width: 1200px;
    margin: 0 auto;
    padding: 2rem;
  }

  .hero-image {
    width: 100%;
    height: 500px;
    overflow: hidden;
  }

  .hero-image img {
    width: 100%;
    height: 100%;
    object-fit: cover;
  }

  .project-header {
    margin-bottom: 3rem;
  }

  .project-header h1 {
    font-size: 3rem;
    margin: 2rem 0;
  }

  .project-meta {
    display: flex;
    gap: 2rem;
    align-items: center;
    margin: 2rem 0;
    padding: 1.5rem;
    background: #f9fafb;
    border-radius: 12px;
  }

  .meta-item {
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
  }

  .meta-item strong {
    font-size: 0.875rem;
    color: #6b7280;
    text-transform: uppercase;
  }

  .project-link {
    margin-left: auto;
    padding: 0.75rem 1.5rem;
    background: #2563eb;
    color: white;
    text-decoration: none;
    border-radius: 8px;
    font-weight: 600;
  }

  .taxonomies {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
    gap: 2rem;
    margin: 2rem 0;
  }

  .taxonomy-group h3 {
    font-size: 1rem;
    color: #6b7280;
    margin-bottom: 0.75rem;
  }

  .tags {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
  }

  .tag {
    padding: 0.5rem 1rem;
    background: #e0e7ff;
    color: #3730a3;
    border-radius: 6px;
    font-size: 0.875rem;
    font-weight: 600;
  }

  .tag.tech {
    background: #fef3c7;
    color: #92400e;
  }

  .content-section {
    margin: 3rem 0;
  }

  .content-section h2 {
    font-size: 2rem;
    margin-bottom: 1.5rem;
    color: #111827;
  }

  .back-link {
    display: inline-block;
    margin: 2rem;
    padding: 0.75rem 1.5rem;
    background: #f3f4f6;
    color: #374151;
    text-decoration: none;
    border-radius: 8px;
    font-weight: 600;
  }
</style>

Componente de Testimonios

---
// src/components/Testimonials.astro
import { Image } from 'astro:assets';
import client from '../lib/wordpress';
import { GET_TESTIMONIALS } from '../queries/testimonials';

const { data } = await client.query({
  query: GET_TESTIMONIALS,
});

const testimonials = data.testimonials.nodes;
---

<section class="testimonials">
  <div class="container">
    <h2>Lo que Dicen Nuestros Clientes</h2>
    
    <div class="testimonials-grid">
      {testimonials.map(testimonial => (
        <div class="testimonial-card">
          {testimonial.featuredImage && (
            <div class="author-image">
              <Image
                src={testimonial.featuredImage.node.sourceUrl}
                alt={testimonial.featuredImage.node.altText}
                width={80}
                height={80}
                format="webp"
              />
            </div>
          )}

          <div class="rating">
            {[...Array(testimonial.testimonialFields?.rating || 5)].map(() => (
              <span class="star">★</span>
            ))}
          </div>

          <div class="content" set:html={testimonial.content} />

          <div class="author-info">
            <strong>{testimonial.testimonialFields?.authorName || testimonial.title}</strong>
            {testimonial.testimonialFields?.authorPosition && (
              <span class="position">
                {testimonial.testimonialFields.authorPosition}
              </span>
            )}
            {testimonial.testimonialFields?.company && (
              <span class="company">
                {testimonial.testimonialFields.company}
              </span>
            )}
          </div>
        </div>
      ))}
    </div>
  </div>
</section>

<style>
  .testimonials {
    padding: 4rem 0;
    background: #f9fafb;
  }

  .container {
    max-width: 1200px;
    margin: 0 auto;
    padding: 0 2rem;
  }

  .container h2 {
    text-align: center;
    font-size: 2.5rem;
    margin-bottom: 3rem;
  }

  .testimonials-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
    gap: 2rem;
  }

  .testimonial-card {
    background: white;
    padding: 2rem;
    border-radius: 12px;
    box-shadow: 0 4px 6px rgba(0,0,0,0.1);
  }

  .author-image {
    width: 80px;
    height: 80px;
    border-radius: 50%;
    overflow: hidden;
    margin-bottom: 1rem;
  }

  .author-image img {
    width: 100%;
    height: 100%;
    object-fit: cover;
  }

  .rating {
    color: #fbbf24;
    font-size: 1.5rem;
    margin-bottom: 1rem;
  }

  .content {
    margin: 1.5rem 0;
    font-style: italic;
    color: #4b5563;
    line-height: 1.6;
  }

  .author-info {
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
    margin-top: 1.5rem;
    padding-top: 1.5rem;
    border-top: 1px solid #e5e7eb;
  }

  .author-info strong {
    font-size: 1.125rem;
    color: #111827;
  }

  .position {
    color: #6b7280;
    font-size: 0.875rem;
  }

  .company {
    color: #2563eb;
    font-size: 0.875rem;
    font-weight: 600;
  }
</style>

Páginas de Archivo por Taxonomía

---
// src/pages/tecnologia/[slug].astro
import client from '../../lib/wordpress';
import { gql } from '@apollo/client';

export async function getStaticPaths() {
  const { data } = await client.query({
    query: gql`
      query GetTechnologies {
        technologies {
          nodes {
            slug
            name
          }
        }
      }
    `,
  });

  return data.technologies.nodes.map(tech => ({
    params: { slug: tech.slug },
    props: { techName: tech.name },
  }));
}

const { slug } = Astro.params;
const { techName } = Astro.props;

const { data } = await client.query({
  query: gql`
    query GetPortfolioByTechnology($slug: String!) {
      portfolioItems(where: { taxQuery: { 
        taxArray: { 
          taxonomy: TECHNOLOGY, 
          field: SLUG, 
          terms: [$slug] 
        } 
      }}) {
        nodes {
          id
          title
          slug
          excerpt
          featuredImage {
            node {
              sourceUrl
              altText
            }
          }
        }
      }
    }
  `,
  variables: { slug },
});

const projects = data.portfolioItems.nodes;
---

<html lang="es">
  <head>
    <title>Proyectos con {techName}</title>
  </head>
  <body>
    <div class="container">
      <h1>Proyectos usando {techName}</h1>
      <p class="count">{projects.length} proyectos encontrados</p>

      <div class="projects-grid">
        {projects.map(project => (
          <article class="project-card">
            {project.featuredImage && (
              <img 
                src={project.featuredImage.node.sourceUrl} 
                alt={project.featuredImage.node.altText}
              />
            )}
            <h2>{project.title}</h2>
            <div set:html={project.excerpt} />
            <a href={`/portfolio/${project.slug}`}>Ver Proyecto</a>
          </article>
        ))}
      </div>
    </div>
  </body>
</html>

Ventajas de Custom Post Types con Astro

  • Organización clara: Cada tipo de contenido tiene su propia estructura
  • Consultas optimizadas: GraphQL obtiene exactamente lo que necesitas
  • Flexibilidad total: Combina múltiples CPTs en una sola página
  • SEO mejorado: URLs semánticas y contenido bien estructurado
  • Escalabilidad: Añade nuevos tipos sin afectar los existentes

Este enfoque te permite crear sitios web complejos y especializados manteniendo WordPress como un CMS potente y familiar para los editores de contenido.

Categorías

Astro Desarrollo Web WordPress