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.