Saltar al contenido
← Todos los posts
Blog Meta · Arquitectura

Cómo está construido este sitio

Stack, decisiones arquitectónicas y trade-offs del propio sitio. Astro 6, content collections con Zod, relations recíprocas con validador automático, cero JS donde no aporta.

arquitectura astro cloudflare meta

Este sitio se reconstruyó desde cero entre mayo y junio de 2026, sobre un stack que ya empezó a divergir de su propio spec. Lo que sigue describe cómo está hecho ahora: cuáles fueron las decisiones, qué quedó descartado, y qué no está aún. El post existe porque uno de los principios del propio sitio es que las decisiones técnicas se escriben en público, no se asumen.

El stack

El motor del sitio es Astro 6 con Svelte 5 para islands interactivos. Tailwind v4 maneja CSS vía el plugin de Vite, con design tokens declarados directamente en un bloque @theme en lugar de en un tailwind.config.js. Despliegue en Cloudflare Workers con Static Assets: el mismo runtime sirve HTML/CSS/fonts estáticos y los endpoints dinámicos. MDX vía @astrojs/mdx.

La razón de Astro como motor — y no Next.js, no SvelteKit standalone — es la propiedad de “cero JS por defecto”. Astro renderiza HTML estático en build; cualquier interactividad requiere hidratar explícitamente un componente como island, declarándolo con la directiva client:visible (u otra similar). Esto significa que la mayoría del sitio entrega ~0 bytes de JavaScript al cliente: solo HTML, CSS y fonts. Las pocas piezas que necesitan estado vivo (booking widget, formulario de contacto) son Svelte islands hidratadas selectivamente.

La elección de Svelte 5 sobre React tiene tres razones: bundle más chico, sintaxis menos verbose para state interactivo, y el patrón de $state / $derived de runes en v5 es más predecible que useState + useEffect para los casos puntuales que tenemos. Svelte acá no es ideología; es la herramienta correcta para la cantidad de interactividad que necesitamos (poca).

Tailwind v4 con tokens en tokens.css significa que el design system completo del sitio vive en un solo archivo de variables CSS — colores, escalas tipográficas, spacing, rhythm. Cambiar la paleta no requiere tocar JavaScript ni reconstruir un build pipeline; es un PR de edición de CSS variables que valida automáticamente porque las clases que las consumen son arbitrary values tipados.

Cloudflare Workers con Static Assets cubre tres requisitos simultáneamente: CDN global para los assets, ejecución del mismo runtime para endpoints dinámicos (formulario de contacto que escribe a D1, webhook de Calendly), y costo ~$0–5/mes hasta tener tráfico real. Migrar desde la infra anterior fue uno de los disparadores de la reescritura completa.

Contenido como código

El sitio tiene cinco colecciones de contenido editorial: productos, servicios, playbooks, MCPs, verticales. Todas viven como archivos .mdx agrupados por colección bajo src/content/, una entrada por archivo, con frontmatter validado por schemas Zod en src/content.config.ts.

// src/content.config.ts — fragmento de la colección productos
const productos = defineCollection({
  loader: glob({
    pattern: "**/*.{md,mdx}",
    base: "./src/content/productos",
    generateId: ({ data }) => `${data["slug"]}-${data["language"]}`,
  }),
  schema: z.object({
    slug: z.string(),
    name: z.string(),
    language: languageSchema,
    state: maturitySchema,
    repo: z.url().optional(),
    relatedVerticals: z.array(verticalSchema).default([]),
    relatedProducts: z.array(z.string()).default([]),
    relatedPlaybooks: z.array(z.string()).default([]),
    seo: seoSchema,
    publishedAt: z.coerce.date(),
    updatedAt: z.coerce.date().optional(),
  }),
});

Cada producto tiene un MDX por locale (atenea-es-CL.mdx, atenea-en-US.mdx), con identidad compuesta por slug + language. Astro genera tipos TypeScript automáticamente desde el schema Zod, así que el resto del código (las páginas [slug].astro, el sidebar, los validadores) consume contenido tipado sin escribir interfaces a mano.

La decisión de mantener el contenido en el repo en lugar de un CMS externo viene de querer que las decisiones técnicas — cambiar el estado de madurez de un producto, agregar un playbook, ajustar la descripción de un servicio — sean PRs con historial git, no edits en una UI cerrada que vive en otra base de datos. El review humano ocurre en el diff, no en un dashboard.

Relations recíprocas

La decisión más interesante del último ciclo está documentada en ADR-0017. Cada colección puede declarar relaciones a otras colecciones — un producto relacionado a un playbook, una vertical relacionada a varios productos — pero las relaciones se declaran en ambos extremos. Si Producto A apunta a Playbook P, el Playbook P debe apuntar de vuelta a Producto A.

La motivación es editorial. Cuando alguien lee la página de un playbook, debe poder descubrir los productos donde ese playbook se aplica, sin que el autor del producto sea el único que mantiene la información de la relación. El grafo se vuelve auditable y la red de cross-linking se cierra en ambas direcciones.

El costo es doble escritura. Una relación A → B requiere editar dos archivos (uno por extremo), y como cada uno tiene un MDX por locale, son cuatro ediciones por arista. La primera vez que se introdujo el patrón, el review humano dejó pasar dos omisiones de reciprocidad. La fase siguiente las heredó y agregó una tercera con un drift de slug entre locales.

Para evitar que esto se repita, el repositorio tiene un validador automático:

// scripts/validate-relations.mjs — extracto del catálogo de specs
const RELATION_SPECS = [
  {
    from: "productos",
    field: "relatedVerticals",
    to: "verticales",
    reciprocalField: "relatedProducts",
  },
  {
    from: "productos",
    field: "relatedProducts",
    to: "productos",
    reciprocalField: "relatedProducts",
  },
  {
    from: "productos",
    field: "relatedPlaybooks",
    to: "playbooks",
    reciprocalField: "relatedProducts",
  },
  // ... 13 specs en total cubriendo las 5 colecciones

  // Asimetrías intencionales del ADR-0017 — productos no tiene
  // relatedMcp ni relatedServicios por diseño:
  { from: "servicios", field: "relatedProducts", to: "productos", reciprocalField: null },
  { from: "mcp", field: "relatedProducts", to: "productos", reciprocalField: null },
];

for (const lang of ["es-CL", "en-US"]) {
  for (const spec of RELATION_SPECS) {
    allViolations.push(...validateRelationSpec(spec, collections, lang));
  }
  allViolations.push(...validateMcpVerticals(collections, lang));
}

El script corre como parte de pnpm check (después de astro check). Si una relación A → B no tiene recíproca, el build falla local y en CI con un reporte agrupado por archivo origen. Las asimetrías intencionales están declaradas explícitamente con reciprocalField: null, así que no se reportan como violaciones.

La primera ejecución del validador encontró tres omisiones reales pre-existentes que el review humano había dejado pasar. Las tres se fixearon en el mismo PR que introdujo el validador. Una de ellas era un drift de slug entre locales (productizado en es-CL pero productized en en-US para el mismo servicio) que llevaba semanas viviendo en main.

Cero JS donde no aporta

Una restricción explícita del sitio — declarada en CLAUDE.md, en los ADRs y aplicada en review — es que cualquier componente nuevo debe justificar por qué necesita hidratación. Es una regla operacional, no aspiracional.

El sidebar de las páginas detalle es ejemplo del estándar: sticky con CSS puro, render server-side, sin JavaScript salvo ~30 líneas de IntersectionObserver para marcar el item activo del TOC mientras el lector scrollea. No hay framework de TOC, no hay “smooth scroll” library. El comportamiento que necesita JS está aislado en un bloque chico que vive junto al componente que lo usa:

// src/components/ui/DetailSidebar.astro — script del TOC activo
const observer = new IntersectionObserver(
  (entries) => {
    const visible = entries
      .filter((e) => e.isIntersecting)
      .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
    if (visible.length > 0) {
      setActive(visible[0]?.target.id ?? null);
    }
  },
  {
    rootMargin: "-80px 0px -70% 0px",
    threshold: 0,
  },
);
headings.forEach((h) => observer.observe(h));

La métrica que se mide es Lighthouse ≥ 95 en todas las páginas. Cada cambio que reduce esto se rechaza en review. La baseline está versionada en docs/lighthouse-baseline.md y se cablea a pr-checks como advisory — todavía no como gate. La idea es promoverlo a bloqueante cuando esté estable por dos sprints seguidos.

Tipografía con personalidad

ADR-0014 reemplazó el stack tipográfico original (Fraunces + Geist Sans + JetBrains Mono) por Bricolage Grotesque como única familia para display y body, más JetBrains Mono para code. La razón principal: tener una sola familia para display + body reduce el contrato cognitivo del lector y el peso del payload de fonts. Bricolage tiene ritmo tipográfico y un dibujo de letra que funciona en hero (28–72px) y en párrafo (15–17px) sin sentir que son fuentes distintas.

El subset es agresivo. El TTF original de Bricolage pesa ~280KB; después del subset a latín extendido y drop del axis opsz (que no usábamos), el WOFF2 servido pesa 77KB. Lo mismo para JetBrains Mono con solo glyphs latinos y dos weights. Total fonts del sitio: bajo 120KB.

Las fuentes prohibidas explícitamente: Inter, Roboto, Arial, Space Grotesk, IBM Plex, Fraunces (la anterior), Geist, Mona Sans, Plus Jakarta, Recoleta, Instrument Sans. No por capricho — porque cada una representa una estética B2B SaaS 2023 reconocible y predecible, y el sitio busca señalizar otra cosa.

OG images pre-renderizados

ADR-0016: cada item del portafolio (producto, servicio, playbook, vertical, MCP) tiene su propio PNG OG generado en build vía Satori + resvg. El preview en Twitter, LinkedIn, Slack, WhatsApp es específico de la URL compartida. No es un único /og-default.png para todo el sitio.

El render ocurre en pnpm build, no en runtime, por dos razones: Satori + resvg corriendo en Cloudflare Workers tiene conflictos con el sandbox de WASM del runtime, y el contenido del sitio es prácticamente estático — agregar un producto ya implica un re-deploy, así que pre-renderizar en build no agrega friction. El script de build tiene cache vía hash del frontmatter; si el contenido no cambió, el PNG no se regenera.

Honestidad de posicionamiento

ADR-0013 establece una regla con dientes. El sitio no muestra “trusted by” con logos inventados, no inventa contadores tipo “3000 clientes felices”, no incluye testimonios sin caso real público, y cada producto del portafolio declara su madurez real en frontmatter MDX.

Las madureces declaradas hoy son explícitas:

  • Aether Telemetry está en public-beta
  • Thoth está en private-beta
  • Plutus, Daedalus, Themis y Atenea están en design

Las páginas de detalle muestran un Note explícito cuando un producto está en estado pre-GA: “X está en <madurez>. No está disponible comercialmente todavía — la fecha de apertura aún no está confirmada.”

El patrón viene de Linear, Cal.com early, Vercel/Zeit, Sentry early — boutiques técnicas que crecieron construyendo en público en lugar de aparentar tracción que no tenían. Para una boutique en formación, intentar simular escala atrae a los clientes equivocados.

Lo que no está acá

El sitio no tiene tracking de comportamiento más allá de Cloudflare Web Analytics sin cookies (ADR-0010). No hay GA4, no hay Hotjar, no hay session replay. No hay popups de cookies porque no se usan. Tampoco hay newsletter — todavía. Hay un endpoint de contacto, hay booking vía Calendly, y eso es todo.

El blog mismo no existía hasta hoy. La colección estaba declarada en el schema pero comentada como void blog para evitar warnings del glob-loader. Este post es el primer item de la colección.

Hay una lista de cosas que sí queremos pero quedaron para próximas fases: validar que la telemetría real de Cloudflare Analytics está aterrizando los eventos del Worker, subir Lighthouse CI de advisory a gate bloqueante, escribir un helper compartido para resolver relations desde las páginas [slug].astro.

El sitio como artefacto del proceso

Construir un sitio de portafolio para una boutique técnica en formación tiene una propiedad rara: el sitio mismo es muestra del trabajo. Si el sitio es prolijo, las decisiones están documentadas, el código es honesto sobre dónde está el estado real, entonces el lector tiene una primera señal — no de marketing, sino de proceso — de cómo trabajaría la boutique con un cliente.

El sitio se sustenta sobre 17 ADRs, un roadmap activo, y dos validadores cableados a pnpm check (uno de simetría de relations entre content collections, otro de patrones JSX-trigger en MDX). Las decisiones existen, son auditables, y dan forma a lo que el lector ve acá.

El siguiente post va a depender de lo que nos interese investigar a continuación. Mientras tanto, si esto resuena con algo que estás construyendo, hablemos.

¿Te resonó el post?

Si esto te interesa, hay más en los playbooks y los productos del laboratorio. O agenda una conversación si quieres discutir algo de lo escrito acá.