Diagrama de arquitectura del sistema MarkeUP

doc-arquitectura-MarkeUp

Arquitectura de MarkeUP: árbol del proyecto y flujo de build

La arquitectura de MarkeUP: el árbol completo del proyecto, el flujo de datos en el build, las dependencias entre módulos, los componentes Astro y la arquitectura CSS.

Admin15 de junio de 2026, 00:0034 min de lectura

Orden de lectura recomendado: doc-readme → doc-guia → doc-arquitectura → doc-buscador → doc-operacion → doc-publicar → doc-auditoria

Revisión: 2026-07-08 · Build: 192 páginas · Componentes Astro: 18 · Archivos fuente: 75+ (incluyendo hermes/)


markeup/
├── src/
│   ├── components/
│   │   ├── blog/
│   │   │   ├── Pagination.astro       ← Navegación entre páginas (latente: sin uso actual)
│   │   │   ├── BookReview.astro       ← Ficha del libro: toda la tarjeta es un enlace + ficha técnica plegable
│   │   │   ├── PostCard.astro         ← Tarjeta clickable con ResponsiveImage + lazy loading
│   │   │   └── PostHero.astro         ← Hero con imagen responsiva + fetchpriority="high"
│   │   ├── common/
│   │   │   ├── BackgroundImage.astro  ← Fondo con gradiente mask (top + bottom simultaneous)
│   │   │   ├── Breadcrumb.astro       ← Breadcrumb navegable + JSON-LD Schema.org
│   │   │   ├── Button.astro           ← Botón reutilizable DaisyUI
│   │   │   ├── CategoryHeader.astro   ← Cabecera con título, contador y opción back
│   │   │   ├── CookieConsent.astro    ← Banner cookies RGPD (solo esenciales/aceptar, sin trackers)
│   │   │   ├── DocCard.astro          ← Tarjeta para documentación (icono + label + título)
│   │   │   ├── FeatureCard.astro      ← Tarjeta con icono para sección About
│   │   │   ├── Footer.astro           ← Footer con enlaces (RSS, GitHub, FAQ, legal, sitemap)
│   │   │   ├── Header.astro           ← Menú responsive con drawer móvil + selector de temas
│   │   │   ├── Icon.astro             ← SVG icon inline (calendar, user, search, share, etc.)
│   │   │   ├── ResponsiveImage.astro  ← <picture> + srcset WebP + aspect-ratio + fetchpriority
│   │   │   ├── SocialShare.astro      ← Botones compartir (X, LinkedIn, Facebook, WhatsApp, Telegram, copiar)
│   │   │   ├── StyledBadge.astro      ← Badge primary/secondary unificado
│   │   │   ├── TableOfContents.astro  ← TOC colapsable con DaisyUI
│   │   │   └── Ventajas.astro         ← Banner rotativo de frases
│   │   └── graph/
│   │       ├── Graphify.astro         ← Grafo 2D avanzado (canvas, miniaturas, export PNG)
│   │       └── ForceGraph3D.astro     ← Grafo 3D con Three.js + 3d-force-graph
│   ├── content/
│   │   ├── posts/                     ← 97 posts en 5 categorías (Humano, Documentación, Literatura, Opinión, Máquina)
│   │   │   ├── Humano/                ← 56 posts (bienestar, energía, filosofía oriental)
│   │   │   ├── Documentación/         ← 5 docs del sistema
│   │   │   ├── Literatura/            ← 2 posts (Pessoa)
│   │   │   ├── Opinión/               ← 1 post
│   │   │   ├── Máquina/               ← 28 posts (dev, IA, herramientas)
│   │   │   ├── img/                   ← Imágenes fuente (34+ archivos) — procesadas a public/img/
│   │   │   └── hermes/                ← 🆕 Sistema de gestión IA del vault (12 archivos)
│   │   │       ├── SOUL.md            ← Personalidad y reglas de Hermes Agent
│   │   │       ├── memory/            ← vault-index.md, conventions.md, backlog.md
│   │   │       ├── skills/            ← crear-post, ampliar-post, generar-wikilinks, auditar-vault, documentar
│   │   │       ├── templates/         ← post-template.md (frontmatter YAML 22 campos)
│   │   │       ├── rules/             ← frontmatter-spec, wikilinks-spec, style-guide
│   │   │       └── logs/sessions/     ← Trazabilidad de cada interacción
│   │   └── content.config.ts          ← Define colección "posts": Zod schema + loader glob
│   ├── layouts/
│   │   └── BaseLayout.astro           ← Layout único: HTML shell, Header, Breadcrumb, Footer, OG tags, Lenis
│   ├── lib/
│   │   ├── manifest.ts                ← Manifiesto ligero de posts (slug, metadatos, minutos) para /api/posts.json
│   │   ├── posts.ts                   ← getAllPosts, getPostsByCategory, getAllCategories, paginate, cleanSlug, getAdjacentPosts + navegación por sección (resolveSection, getAdjacentPostsInSection, getSectionNavMap)
│   │   ├── tags.ts                    ← getAllTags, getPostsByTag
│   │   ├── themes.ts                  ← 🆕 Fuente única de los temas visuales (id + rótulo) que consumen el header y la mancheta de la portada
│   │   ├── graph.ts                   ← generateGraphData (nodos post/category/tag + enlaces categoría/tag/wikilink) y grafo por sección (filterGraphByCategory)
│   │   ├── search-rank.ts             ← 🆕 Núcleo de relevancia del buscador (puro): niveles, ranking, saneado (ver doc-buscador)
│   │   ├── search-graph.ts            ← 🆕 Grafo de búsqueda: relacionadas (wikilinks/Jaccard) + aliases
│   │   ├── aliases.ts                 ← 🆕 Extracción de slugs/aliases del frontmatter (compartido config + buscador)
│   │   ├── section.ts                 ← Contexto de sección PURO (resolveSection, withSectionContext) — sin astro:content
│   │   ├── section-nav.ts             ← Script de cliente: aplica `?from=` a flechas, backlinks e indicador de sección
│   │   ├── images.ts                  ← resolveImagePath (a .webp) para ResponsiveImage
│   │   ├── social-share.ts            ← buildShareLinks/buildShareText: URLs absolutas por red (X/LinkedIn/FB/WhatsApp/Telegram)
│   │   ├── smooth-scroll.js           ← Lenis (solo ratón) + interceptación de anclas + tema + --header-h
│   │   ├── remark-wikilink.js         ← Plugin Remark: [[slug]] → enlace, y ![[slug]] → rich card (Obsidian)
│   │   ├── posts.test.ts             ← 13 tests unitarios (Vitest)
│   │   ├── social-share.test.ts       ← 9 tests: URLs absolutas, endpoints por red, escape de caracteres
│   │   ├── tags.test.ts               ← tests de tags (Vitest)
│   │   ├── search-rank.test.ts        ← 🆕 40 tests: niveles, ranking, saneado, categorías, resumen
│   │   ├── search-graph.test.ts       ← 🆕 6 tests: wikilinks, Jaccard, aliases, límite de relacionadas
│   │   ├── section-nav.test.ts        ← 🆕 15 tests (jsdom): flechas, backlinks e indicador de sección con `?from=`
│   ├── pages/
│   │   ├── index.astro                ← / (home con hero, stats, categorías, últimos posts, CTA)
│   │   ├── 404.astro                  ← /404 (con categorías sugeridas + noindex)
│   │   ├── privacy.astro              ← /privacy (RGPD/LOPD)
│   │   ├── terms.astro                ← /terms (términos de uso)
│   │   ├── faq.astro                  ← /faq (preguntas frecuentes)
│   │   ├── posts/index.astro          ← /posts (listado completo)
│   │   ├── posts/[...slug].astro      ← /posts/:slug (post individual)
│   │   ├── category/[category]/index.astro     ← /category/:cat (todos los posts, sin paginar)
│   │   ├── category/[category]/graph.astro     ← /category/:cat/graph (grafo 3D SOLO de esa sección — ver doc-grafos)
│   │   ├── category/[category]/graphify.astro  ← /category/:cat/graphify (grafo 2D SOLO de esa sección — ver doc-grafos)
│   │   ├── categories/index.astro     ← /categories (grid de categorías)
│   │   ├── tags/index.astro           ← /tags (nube de tags)
│   │   ├── tags/[tag].astro           ← /tags/:tag
│   │   ├── graph.astro                ← /graph (grafo 3D — ver doc-grafos)
│   │   ├── graphify.astro             ← /graphify (grafo 2D avanzado — ver doc-grafos)
│   │   ├── search.astro               ← /search (Pagefind + grafo + ranking — ver doc-buscador)
│   │   ├── api/search-graph.json.ts   ← /api/search-graph.json (relacionadas + aliases)
│   │   ├── feed.xml.ts                ← /feed.xml (RSS + atom link + imagen + autor)
│   │   ├── sitemap.xml.ts             ← /sitemap.xml (changefreq × páginas, incl. /llms.txt)
│   │   ├── llms.txt.ts                ← /llms.txt (dataset estático para LLMs/agentes)
│   │   ├── api/posts.json.ts          ← /api/posts.json (manifiesto JSON de posts)
│   │   ├── animated-sections.astro    ← /animated-sections (demo)
│   │   └── admin/                     ← /admin, /admin/login
│   └── styles/
│       ├── global.css                 ← Barrel: @import base + components + utilities + rich
│       ├── base.css                   ← @tailwind, @font-face (Roboto Flex + Material Symbols), @layer base
│       ├── components.css             ← @layer components: glass-card, post-card, post-hero, etc.
│       ├── utilities.css              ← @layer utilities: text-responsive, container-responsive, foco 2.4.1
│       └── rich.css                   ← CSS declarativo (sin @layer): rich cards + tooltips hover
├── scripts/
│   ├── sh/                            ← Bash (utilidades en lib/common.sh)
│   │   ├── publish.sh                 ← Publicación (validación + commit + push)
│   │   ├── auto-commit.sh             ← Auto-sync Android (imágenes → commit → push)
│   │   ├── ci-setup.sh                ← Secrets de Cloudflare (wrangler manual)
│   │   ├── hermes-connect.sh          ← Arranca Hermes + su gateway de Telegram
│   │   ├── telegram-connect.sh        ← Terminal del Mac por Telegram: init, check, install, logs
│   │   ├── syncthing-connect.sh       ← Syncthing: detectar, arrancar, emparejar
│   │   └── lib/common.sh              ← Utilidades compartidas (colores, .env, PID, launchd)
│   ├── py/                            ← Python
│   │   ├── enrich-wikilinks.py        ← Enriquecido de wikilinks (dry-run por defecto)
│   │   ├── telegram-terminal.py       ← El bot de la terminal (solo stdlib, long-polling)
│   │   └── subset-fonts.py            ← Subset de fuentes a WOFF2
│   ├── js/                            ← JavaScript
│   │   ├── process-images.js          ← Crop 1920×1080 + 3 tamaños (1920/1280/640) WebP q82
│   │   ├── sync-images.js             ← Limpiador de imágenes huérfanas (dry-run por defecto; --apply borra)
│   │   ├── compress-audio.js          ← Compresión ffmpeg (límite 25MiB Cloudflare)
│   │   ├── export-blog.js             ← Exportación offline por categoría
│   │   └── dev-search.js              ← Pagefind index para desarrollo local
│   └── mjs/                           ← ES Modules
│       ├── og-cards.mjs               ← Genera imagen Open Graph 1200×630 (PNG) por post
│       ├── validate-share.mjs         ← Audita OG + Twitter Cards + URLs absolutas de compartir en dist/
│       ├── validate-slugs.mjs         ← Colisiones de slugs/aliases
│       ├── validate-wikilinks.mjs     ← Sintaxis y estilo de wikilinks
│       ├── validate-icons.mjs         ← Mapa de iconos vs tipos
│       ├── a11y-check.mjs             ← Accesibilidad sobre dist/
│       └── stamp-pagefind-version.mjs ← Fingerprint del índice (anti-caché obsoleta)
├── public/
│   ├── img/                           ← 200+ imágenes WebP (master 1920 + 1280 + 640 por imagen)
│   ├── fonts/                         ← Roboto Flex (woff2-variations) + Material Symbols
│   ├── pagefind/                      ← Índice de búsqueda (GENERADO; no versionado)
│   └── preview.js                     ← Rich previews (tooltips + hidratación de rich cards)
├── docs/archive/                      ← Material obsoleto conservado por trazabilidad
├── astro.config.mjs                   ← site, aliases, plugins, chunkSizeWarningLimit
├── src/styles/base.css                ← Tailwind v4 CSS-first (@plugin daisyui, 10 temas)
├── tsconfig.json                      ← strict + path aliases
├── vitest.config.ts                   ← Configuración de tests
└── .github/workflows/                 ← deploy.yml, lighthouse.yml

Scripts del build:

  1. npm run images → process-images.js: genera 3 tamaños por imagen en public/img/ (master 1920×1080 + variantes 1280w y 640w, WebP calidad 82)
    • Una sola fuente por basename en src/content/posts/img/. Dos archivos con el mismo nombre y distinta extensión escriben la MISMA salida, así que quién gana depende del orden de procesado: en local gana la edición más reciente (el watcher solo ve ese archivo) y en un clon limpio decide EXTENSION_PRIORITY, porque los mtimes empatan. Resultado real (2026-09-23): 18 de las 40 imágenes más recientes se servían distintas en producción y en local, con el deploy perfecto. Las fuentes que sobran se archivan en src/content/posts/img/_originales/.
    • Diagnóstico: npm run images:collisions (informe con pesos y fechas), npm run images:check (falla si hay duplicados; bloqueante en CI) y npm run images:verify (regenera a un temporal y compara hash a hash con public/img).
  2. npm run build:astro → [[astro|astro build]]:
    • content.config.ts carga y valida todos los .md con Zod
    • remark-wikilink.js transforma [[slug]] → <a href="/posts/slug">
    • Páginas Astro iteran sobre colecciones (getCollection("posts")) y generan HTML
    • Se generan 192 páginas en dist/
  3. npm run build:search → [[doc-buscador|pagefind]] --site dist --force-language es: indexa 73 páginas (solo artículos con data-pagefind-body), 9880 palabras. El buscador combina este índice con /api/search-graph.json (relacionadas + aliases) y un re-ranking jerárquico en cliente — ver doc-buscador.

content.config.ts (Zod schema)
      ↓
posts.ts  ←  tags.ts  ←  graph.ts
      ↓           ↓          ↓
pages/*.astro (16+ rutas)
      ↓
components/*.astro (18 componentes)
      ↓
BaseLayout.astro (HTML shell, OG, Breadcrumb, Lenis)

Regla: lib/ nunca importa de pages/ ni components/. La dependencia es unidireccional hacia arriba.


ComponenteProps claveLoadingEstados
BaseLayouttitle, description, ogType, ogImage, ogImageAlt, preloadImage, breadcrumbTitle—OG tags, preload link, Breadcrumb, slot
PostHeropost: Posteager + fetchpriority=highCon/sin imagen, H1 = title SEO en el cuerpo, H2 = concept como rótulo grande sobre la foto, categorías, tags, metadatos
PostCardpost: Postlazy + CARD_SIZESCon/sin imagen (16/9), concept sobre la foto (inferior izq., 20/24px, tratamiento del hero), h2 = title (11px negrita) como titular del cuerpo, descripción completa (sin recorte), overlay categoría, máx. 2 tags, sin fecha ni autor, hover scale
ResponsiveImagesrc, alt, loading, sizes, fetchpriorityconfigurableLocal: <picture> + srcset · Externo: <img> simple
Breadcrumbpathname, title?—Ruta raíz (1 item), intermedia (2+ items), con título personalizado
PaginationcurrentPage, totalPages, basePath—Botones prev/next, página activa, deshabilitado en extremos. Sin uso: los listados (secciones, tags, posts) muestran todos los artículos
Headercategories—Desktop: horizontal · Móvil: drawer con acordeones
TableOfContentsheadings, showTOC—Colapsable, scroll-to-heading con Lenis
ForceGraph3Ddata: string (JSON)client:visibleCarga asíncrona Three.js, interactivo drag/zoom
ForceGraph2D—client:visibleCarga asíncrona D3.js, nodos dragables, zoom

Principio: Máximo DaisyUI + Tailwind inline, mínimo CSS manual.

@layer base         ← base.css:       @font-face, headings, anchor links
@layer components   ← components.css: .glass-card, .post-card, .post-hero__image, .category-header
@layer utilities    ← utilities.css:  .text-responsive-*, .container-responsive

Overrides DaisyUI (fuera de @layer para máxima prioridad): botones, badges, headings, iconos, sombras de cards. Ver components.css para el código completo.

Carga condicional: animated-sections.css se importa solo desde animated-sections.astro.

Rich styles (no @layer): las tarjetas ricas y tooltips (rich-card, .lp-pop, .lp-figure, …) viven en styles/rich.css, importadas al final de global.css fuera de @layer utilities para que ninguna capa las purgue. Redefinen selectores solo con tokens DaisyUI (oklch(var(--p/--s/--bc/--b1/--b2/--b3))), por lo que se adaptan a los 10 temas.

Roboto Flex: Fuente variable cargada localmente (sin CDN) con 6 subsets + font-display: swap. Ejes tipográficos animados en hover: YTUC, YTCF, YTDE, slnt (500ms ease).

Orden de capas (crítico): base.css declara de forma explícita

@layer astro, theme, base, components, utilities;

astro es la capa donde Astro inyecta las animaciones de View Transition. Sin esta declaración, la capa quedaría sin orden y la cascada sería no determinista frente a Tailwind. La sentencia va después de los @import/@plugin porque Tailwind v4 exige que las importaciones abran la hoja.


Cada sección (/category/:cat) pinta un fondo decorativo con BackgroundImage.astro, que aplica una máscara de gradiente arriba y abajo sobre la misma imagen. La resolución del fichero vive en resolveCategoryImage() (src/lib/images.ts) y sigue tres pasos: mapa explícito, imagen homónima, y placeholder.

SecciónFondoMotivo
Humanofondofrio.webpTono frío, coherente con el trabajo corporal y la calma
Máquinafondocalido.webpTono cálido, contrapunto a la frialdad técnica
Documentacióndocumentacion.webpImagen homónima de la sección
Literaturaliteratura.webpImagen homónima de la sección
Opiniónplaceholder.webpFallback genérico de marca

El mapa es explícito a propósito: el nombre del fichero no siempre coincide con el de la categoría (Máquina no usa tecnologiablog.webp) y los slugs llevan tildes y mayúsculas que el sistema de ficheros no replica. Derivarlo del slug rompería justo los dos casos con fondo asignado a mano.

Los listados no paginan: cada sección, cada tag y /posts muestran todos sus artículos de una vez. Las URLs antiguas /category/:cat/page/:n redirigen 301 al índice de su sección desde redirects en astro.config.mjs. Como Astro rechaza un redirect dinámico cuyo destino no conserva los parámetros (InvalidRedirectDestination), los números de página se enumeran a mano.


La navegación cliente la habilita <ClientRouter /> (de astro:transitions) en BaseLayout.astro.

Implementación 100% nativa. No hay CSS propio ni JS propio que anime la transición: lo único que reacciona a su ciclo de vida es el Scroll Reveal (ver abajo), y la usa como punto de sincronización, no como animación.

ElementoDirectivaPor qué
<body>(ninguna)Es el elemento raíz: nunca debe llevar view-transition-name. Un nombre en el raíz crea un grupo anidado dentro de root y el navegador lo recorta con el viewport
<main>transition:animate={fade({ duration })}Solo se declara la animación. El nombre lo genera Astro automáticamente
<header>transition:name="header" + animate="none" + persistGrupo propio que no anima → el overlay de la transición no lo tapa (ver abajo)
.post-card(ninguna)Declarar nombres aquí multiplicaría los grupos por el número de tarjetas

  1. No declarar animaciones en ::view-transition-*. Astro las genera dentro de @layer astro. Una regla propia de animación queda fuera de capa, gana por cascada y descuadra lo que Astro calculó (perdiendo el mix-blend-mode: plus-lighter). Única excepción justificada: z-index sobre un grupo, porque el orden de apilado entre grupos no se puede expresar con ninguna directiva de Astro.
  2. Nunca dar nombre al <body> ni al <html>. El root debe quedar libre para el cross-fade nativo del navegador.
  3. Un solo listener por evento de ciclo de vida. Cada uno corre dentro del callback de startViewTransition; cualquier trabajo extra retrasa el primer frame. La regla aplica a los eventos que ocurren dentro del snapshot (before-swap, after-swap, page-load); before-preparation es el único punto donde se puede trabajar antes de la captura, y es donde vive la pausa de nav-hold (ver abajo).
  4. La duración se declara en la directiva, no en CSS.

PAGE_TRANSITION_MS vale 1800 ms (3 × el ritmo anterior) y el cross-fade no siempre se captura desde donde está el visitante. Si alguien termina de leer un artículo al final de la página y pulsa «siguiente», el snapshot de origen sería el pie del artículo y el cambio se percibiría como un salto. src/lib/nav-hold.ts decide cuándo hay que evitarlo y BaseLayout.astro lo aplica:

  • Punto de enganche: astro:before-preparation expone loader, que Astro awaitea antes de fotografiar el estado viejo y de llamar a startViewTransition. Se envuelve con: subir arriba con el scroll suave del sitio (Lenis) → esperar NAV_SCROLL_HOLD_MS (175 ms: la mitad de los 350 ms originales, que se percibían como un tirón entre el clic y la transición) → resolver el loader original, que ya se había lanzado en paralelo durante la subida (el tiempo de red se solapa con la animación en vez de sumarse). Interceptar el clic con preventDefault sería la alternativa obvia, pero competiría con el router y exigiría un clic sintético.
  • Con Lenis vivo el desplazamiento hay que pedírselo a Lenis (scrollTo(0, { duration, easing }), el mismo ritmo que el resto del sitio): su bucle de animación reescribe window.scrollY en cada frame y desharía un window.scrollTo directo. La promesa se cierra con onComplete y con un tope de seguridad, porque Lenis se destruye en astro:before-swap y onComplete no llegaría. En táctil (sin Lenis) se usa el scroll suave nativo y se espera a que la posición llegue arriba.
  • No se retiene la navegación al volver atrás/adelante (traverse), dentro de la misma página (?from=, anclas), cuando el destino trae #hash ni cuando el scroll es menor que NAV_SCROLL_MIN_PX.
  • Al volver atrás se conserva la posición. Astro escribe window.scrollY en history.state justo después de resolver el loader, así que anotaría 0 para el artículo abandonado. Un listener de astro:after-preparation reescribe el estado con la posición capturada antes de subir.

Lo único que sí necesita CSS propio: components.css anima color/background 600 ms para el cambio de tema, y una transición en curso sobre un elemento capturado invalida el snapshot. global.css lo silencia mientras dura la transición:

html[data-astro-transition] body *,
html[data-astro-transition] body *::before,
html[data-astro-transition] body *::after {
  transition: none !important;
}

El tema se aplica con data-theme en <html>, que es el elemento root de la View Transition nativa, así que setTheme() (src/lib/smooth-scroll.js) envuelve el cambio en document.startViewTransition() y marca <html data-theme-transition> mientras dura. Ese atributo hace dos cosas en global.css, y las dos fallan en silencio si falta:

GuardaPor qué
html[data-theme-transition] body * { transition: none !important }La misma congelación que Astro aplica con data-astro-transition, pero para un cambio de tema manual (que no navega y por tanto no recibe ese atributo). Sin ella la transición de color arranca dentro del callback, la captura del estado nuevo sale con los colores viejos y el cross-fade no cambia nada: se ve una pausa y luego el barrido de color
html[data-theme-transition] [data-astro-transition-scope] { view-transition-name: none }Un nombre propio sale del snapshot del root y se anima por su cuenta. <main> heredaba el fade de PAGE_TRANSITION_MS (1800 ms) y estiraba toda la transición a 1,8 s; el <header> (animate="none" → animation: none) dejaba su snapshot viejo encima y cambiaba de color al final, de golpe. Sin nombres queda un solo cross-fade del root de ~250 ms, que es el efecto buscado

Medido el 2026-09-26 en Chromium (sonda CDP sobre dist/, document.getAnimations()), sunset → light: 8 pseudo-animaciones y 1800 ms antes; 5 (solo root) y 250 ms después, con la guarda levantada antes de los 900 ms. El contrato lo fija src/lib/theme-transition.test.ts (lee global.css y ejecuta setTheme con un doble de startViewTransition en jsdom).

Especificidad: html[data-theme-transition] … es (0,2,1) frente al (0,1,0) de Astro, así que gana sin !important y cubre cualquier elemento que Astro nombre en el futuro.

EventoUso
astro:before-swapLimpieza: anclas, Lenis, ripple, WebGL y Scroll Reveal
astro:page-loadÚnico arranque (se dispara también en la primera carga)
astro:after-swapArranque del reveal tras el swap (antes del snapshot): lo que ya está en el viewport entra en la transición
astro:before-preparationSubida previa a la transición (nav-hold)

El 2026-09-25 se retiró por completo el sistema anterior (Scroll Reveal) y se construyó este. Tres ideas lo sostienen:

  1. CSS primero, JavaScript para coreografiar. El pre-estado de cada efecto vive en CSS (src/styles/motion/primitives.css) y el motor solo anima HACIA el estado natural: sin JavaScript, con prefers-reduced-motion o si GSAP no carga, no se oculta nada — la página se ve entera.
  2. Nada de librerías nuevas. El motor usa el GSAP que el layout ya cargaba en todas las páginas para el tooltip de preview (/vendor/gsap.min.js): 0 KB de JavaScript de terceros añadidos. El texto se reparte en el build (SplitText.astro), no en el cliente: el HTML ya sale troceado y en runtime el coste es cero.
  3. Un guion por bloque. Cada data-motion-block es una sección con su propia timeline, que arranca cuando entra en pantalla. Dentro, cada data-motion es un beat; los beats van en orden de lectura, el contenedor antes que sus subelementos, con un solape corto y diseñado (0,25 s) para que se lea como una cascada y no como una espera.
FicheroQué hace
src/styles/motion/tokens.cssDuraciones, curvas y @property (ángulos, --m-mx/--m-my del puntero).
src/styles/motion/primitives.cssPre-estado de cada efecto + guardas (motion-armed, motion-static, movimiento reducido).
src/styles/motion/scroll.cssAmbiente (aurora, rejilla, grano, viñeta) y capa de scroll (animation-timeline).
src/styles/motion/interactions.cssMicro-interacciones: botones, foco de luz, subrayados, header compacto.
src/styles/motion/home.cssPiezas de la portada (anillos del CTA, la mancheta de cifras con sus separadores y su pie, compás del acordeón).
src/lib/motion/choreograph.tsEl motor: bloques, beats, efectos, cifras y ciclo de vida.
src/lib/motion/index.tsInstalación en el ciclo de Astro, foco del puntero y header reactivo.
src/components/motion/SplitText.astroReparto de texto en el build (palabras o letras).
src/components/motion/MotionRoot.astroCapa de ambiente de la portada.

Vocabulario (data-motion): rise, mask, wipe, pop, fade, line, draw, words y letters. data-motion-block="load" arranca al cargar (el hero); ="manual" espera a que alguien lo pida (el panel de un acordeón, que se dibuja al abrirse).

Ambiente solo en la portada. MotionRoot.astro (aurora, rejilla, grano y viñeta) se pinta en index.astro, dentro del contenido de la portada, no en BaseLayout: el degradado es un rasgo de la portada, y al viajar dentro de <main> aparece y desaparece con la navegación de cliente (fuera de <main> no se re-renderiza, así que tampoco volvería al volver a la portada). Es una capa fija con z-index: -1, así que se pinta por detrás de todo, incluida la foto de fondo (BackgroundImage.astro, z-0). Dos reglas de coste, medidas con npm run verify:preview: sin mix-blend-mode (33 ms → 18 ms de p95 en la entrada del tooltip) y sin filter (un blur(60px) sobre la capa provocaba picos de 200 ms: el navegador re-desenfoca un área de viewport entero cada vez que algo se repinta).

Revelado del cuerpo. Cada pieza de .prose entra cuando asoma por abajo. Lo hace el CSS con animation-timeline: view() (también en /terms y /privacy), con solo opacidad: un ancestro medible por preview.js no puede moverse —la tarjeta del tooltip se coloca sobre su rect— y transform/clip-path sobre decenas de piezas van al hilo principal (81 ms de p95 medidos, frente a 18 ms con opacidad pura). Las animaciones dirigidas por scroll van en longhands: el minificador fusiona animation + animation-timeline en un shorthand que el navegador rechaza.

Bloques al final del documento. El observador exige un 12 % de margen inferior para no arrancar algo que apenas asoma. Un bloque que descansa al final del documento (el pie) no puede subir más: en ventanas de más de ~1030 px su top nunca cruza la línea del 88 % y su guion no arrancaba, así que el contenido se quedaba en su pre-estado hasta que el puntero lo terminaba. sweepPending() (lib/motion/choreograph.ts) revisa al hacer scroll —una vez por frame— los bloques pendientes y juega los que ya están a la vista.

Regla del tooltip. Cualquier elemento que preview.js mida (wikilinks, píldoras de backlinks) y sus ancestros animados entran solo con opacidad, nunca con transform (effectFor() fuerza fade), y el guion del bloque se termina en el pointerover/focusin —listener registrado en el <head>, antes que preview.js— para que la tarjeta se coloque sobre geometría final.

Medido (Chromium, 1280×900, npm run verify:motion):

ComprobaciónPortada/posts (97 tarjetas)Artículo
Bloques · beats22 · 13598 · 6843 · 16
Beats en pantalla que se quedan ocultos000
Desplazamiento acumulado (CLS)0 px0 px0 px
Frames p95 / máximo16,8 / 16,8 ms16,8 / 16,8 ms16,8 / 16,8 ms
Beats simultáneos (pico)756
Capa de scroll aplicada (computada)✅✅✅ cuerpo=m-prose-in

Y la entrada del tooltip (npm run verify:preview, artículo real): p95 18-20 ms con el ambiente activo en todas las páginas. Con mix-blend-mode en la aurora subía a 33 ms y con su filter: blur(60px) había picos de 200 ms; sin ninguno de los dos, 18 ms.

La mancheta de la portada. Cuatro cifras —posts, blogs, tags y conexiones—, cada una un enlace a la página donde se puede comprobar. Ninguna es un literal: salen en build time de getAllPosts(), getAllCategories(), getAllTags() y generateGraphData(), las mismas funciones que alimentan /posts, /categories, /tags y /graph, así que el número que se lee es el que se encuentra al pulsarlo. El pie lleva el reparto por blog y la cuenta de temas, que también sale de src/lib/themes.ts. (Antes «Temas 10» estaba escrito a mano y la lista de temas vivía en el header: src/lib/themes.test.ts impide que vuelva a pasar.)

El header es position: fixed. Eso plantea un problema específico:

La View Transition crea una capa de overlay que se dibuja por encima de todo el contenido, ignorando el z-index del documento. El z-index: 50 del header solo compite dentro del documento, nunca contra el overlay. Sin nombre propio el header cae dentro del grupo root → el overlay lo tapa durante la transición y reaparece al terminar.

Solución:

<header transition:persist transition:name="header" transition:animate="none">

animate="none" hace que Astro genere (dentro de @layer astro):

::view-transition-old(header)   { animation: none; opacity: 0; mix-blend-mode: normal; }
::view-transition-new(header)   { animation: none; mix-blend-mode: normal; }
::view-transition-group(header) { animation: none }

Única regla CSS propia de todo el subsistema: el orden de apilado entre grupos se decide por el orden en el árbol, y el header va antes que <main>. Subirlo requiere z-index sobre el grupo, y no hay directiva de Astro para ello:

::view-transition-group(header) {
  z-index: 1;
}

⚠️ Solo z-index. No declarar animation aquí: pisaría el animation: none de Astro y volvería el parpadeo.

ResponsiveImage genera <picture> con el srcset en el <source> y en el <img>, y el <img> sin src. Así el navegador resuelve una sola vez y descarga una única variante:

  • Un src en el <img> provoca una descarga extra (el navegador baja el src y la variante del srcset).
  • El preload del <head> usa imagesrcset + imagesizes por el mismo motivo.
  • El fondo decorativo del artículo usa la variante -640 (resolveSmallVariant): está desenfocado y no necesita el master.

  • site: https://markeup.pages.dev
  • trailingSlash: never
  • integrations: TailwindCSS
  • vite.aliases: @ → src, @layouts, @components, @lib, @styles
  • chunkSizeWarningLimit: 1000 kB
  • Remark: remarkWikilink (con validSlugs precargados del filesystem)
  • Rehype: rehypeSlug + rehypeAutolinkHeadings

  • plugins: @plugin 'daisyui' + @plugin '@[[librerias-tailwind-css|tailwindcss]]/typography'
  • themes: light (—default), cupcake, corporate, pastel, retro, dark (—prefersdark), dim, sunset, luxury, coffee
  • ⚠️ Esta lista y la de src/lib/themes.ts (la que usan el menú «Temas» y la mancheta de la portada) tienen que coincidir en orden: DaisyUI se configura en CSS y no puede importar el módulo TypeScript. src/lib/themes.test.ts compara las dos y falla si se separan.
  • Ya no existe tailwind.config.cjs (Tailwind v4 configura todo en CSS)

  • Extiende astro/tsconfigs/strict
  • Path aliases: @/*, @layouts/*, @components/*, @lib/*, @styles/*

on: push → branches: [main]
steps:
  - checkout
  - node 20, npm ci
  - npm run build
  - Cloudflare Pages deploy → markeup

on: push + pull_request → branches: [main]
steps:
  - build
  - Lighthouse CI con umbrales: performance ≥ 0.8, a11y ≥ 0.9, best-practices ≥ 0.9, seo ≥ 0.9

ComandoFunción
npm run devServidor desarrollo localhost:4321
npm run buildBuild completo: images + astro + pagefind
npm run build:astroSolo build Astro → dist/
npm run build:searchSolo índice Pagefind
npm run previewPrevisualizar build producción
npm testVitest (76 tests: posts, tags, graph, social-share, search-rank, search-graph)
npm run validate:shareAudita OG + Twitter Cards + URLs de compartir en dist/
npm run ogGenera imágenes Open Graph 1200×630 (PNG) por post
npm run imagesProcesar imágenes (crop + variantes)
npm run images:watchModo vigilancia de imágenes
npm run images:collisionsInforme de fuentes con el mismo nombre (duplicados)
npm run images:checkFalla si hay fuentes duplicadas (bloqueante en CI)
npm run images:verifyCompara public/img con sus fuentes (hash a hash)
npm run compress-audioComprimir audios con ffmpeg
npm run exportExportar blog por categoría

La creación automática de artículos ya no la hace un script suelto de este repositorio, sino Hermes Agent, que vive fuera del repo y se conecta al vault mediante skills.

El antiguo scripts/py/generar_articulo.py generaba posts desde una URL con Ollama, pero no conocía las reglas del vault: inventaba categorías fuera de las 5 válidas y sus wikilinks no pasaban el linter. Hermes, en cambio, carga el atajo /markeup-post, que aplica el pipeline completo (frontmatter de 22 campos, guía de estilo, Regla de Oro de wikilinks y validación final).

ComponenteDónde viveFunción
Hermes Agent~/.hermes/hermes-agentEl agente y sus herramientas
Skills MarkeUP~/.hermes/skills/markeup/<nombre>/El canon: 14 skills atómicas + 3 atajos (/markeup-post, /markeup-publish, /markeup-verify)
Reglas del vaultsrc/content/hermes/rules/Especificaciones normativas
AGENTS.mdraíz del repoContexto que Hermes carga al trabajar aquí
Gatewayservicio launchdConecta Telegram con el agente
Terminal del Macservicio launchd ai.markeup.telegram-termBot propio que ejecuta shell en el Mac desde el móvil, con política de seguridad

Petición (CLI o Telegram)
      |
      v
Hermes Agent
      |-- lee AGENTS.md + rules/ del vault
      |-- carga el atajo /markeup-post
      |-- escribe el .md en src/content/posts/<Categoría>/
      v
npm run validate:wikilinks   (gate)
npm run validate:books       (gate de bookIsbn, solo si la nota cita un libro)
npm run tools:index          (índice)
      v
Syncthing -> Obsidian / móvil

ComponenteFunciónInstalación
OllamaServidor local de modelosbrew install ollama
qwen2.5-coder:7b-64kModelo alternativo localollama pull qwen2.5-coder:7b-64k
Hermes AgentAgente con skills y herramientas(instalador propio)

El agente escribe el archivo .md directamente en la carpeta de su categoría (src/content/posts/<Categoría>/<slug>.md). A partir de ahí:

  1. Syncthing lo sincroniza con Obsidian y el móvil
  2. El post entra en npm run build como cualquier otro contenido
  3. Si draft: false, se publica en el siguiente deploy
  4. Las imágenes del frontmatter (image: '/img/<slug>.webp') se añaden aparte

Regla de oro: toda URL que se comparte debe ser ABSOLUTA (https://dominio/posts/slug). Las redes (WhatsApp, LinkedIn, Facebook, X, Telegram) no resuelven rutas relativas: si reciben /posts/x la comparten literalmente y el enlace llega roto. Este era el bug original de WhatsApp.

[...slug].astro  →  url={`${siteOrigin}/posts/${slug}`}    (URL absoluta)
        ↓
SocialShare.astro →  normaliza con new URL(url, Astro.site)  (defensa en profundidad)
        ↓
social-share.ts   →  buildShareLinks()  ← LANZA si la URL no es absoluta
        ↓
<a href>          →  endpoint de cada red con la URL codificada

Lógica pura (sin Astro) → testeable con Vitest. Exporta:

FunciónResponsabilidad
buildShareLinks({ url, title, summary, siteName })Devuelve ShareLink[] con href por red. Lanza si url no empieza por http(s)://
buildShareText(title, summary, siteName)Compone el texto compartido: título — resumen — MarkeUP

Endpoints usados (con encodeURIComponent):

RedEndpoint
Xhttps://twitter.com/intent/tweet?url=&text=
LinkedInhttps://www.linkedin.com/sharing/share-offsite/?url=
Facebookhttps://www.facebook.com/sharer/sharer.php?u=
WhatsApphttps://wa.me/?text=<texto>%20<url>
Telegramhttps://t.me/share/url?url=&text=

  • Normaliza url a absoluta contra Astro.site antes de llamar a buildShareLinks.
  • Expone data-share-url / data-share-title en el contenedor para el script cliente.
  • Web Share API (mejora progresiva): en dispositivos táctiles (hover: none and pointer: coarse) intercepta el clic y abre el panel nativo de compartir del sistema con la URL completa. Si el usuario cancela o el navegador no lo soporta, cae al <a href> clásico. En escritorio no se intercepta: funcionan los enlaces por red.
  • Botón “Copiar” usa navigator.clipboard con fallback a execCommand('copy').

  • og:site_name = MarkeUP (coherente con Footer y RSS).
  • twitter:site / twitter:creator = TWITTER_HANDLE (@splitzforx), configurable en una sola constante al inicio del frontmatter. Se normaliza a @usuario; si se pasa cadena vacía por prop, se omite. Nunca se emite texto libre (p. ej. Admin), porque X lo ignora.
  • twitter:url, twitter:domain, twitter:image:width/height añadidos para completar la card.
  • Dimensiones OG: por defecto 1200×630 (1.91:1), el ratio que esperan X/LinkedIn/WhatsApp para summary_large_image. Las imágenes de fallback son 1920×1080, pero se declara el marco 1.91:1 para que la red reescale dentro del tamaño correcto.
  • En posts: article:author:profile y Schema.org author[].sameAs = ["https://x.com/splitzforx"].

scripts/mjs/validate-share.mjs (→ npm run validate:share) recorre dist/ y falla si:

  1. Falta algún meta OG obligatorio (og:title/type/url/image) o de Twitter Card (twitter:card/title/image/url).
  2. og:url u og:image no son absolutas.
  3. Algún botón de compartir propaga una ruta relativa (%2F... sin dominio) — el bug de WhatsApp.
  4. twitter:site/twitter:creator no tienen formato @usuario.

Sale con código 1 ante errores → apto para CI.

src/lib/social-share.test.ts (Vitest, 9 casos):

  • Rechaza URLs relativas (fija el bug original).
  • Verifica que las 5 redes usan endpoints absolutos correctos.
  • WhatsApp incluye el texto + URL completa.
  • Escapado correcto de caracteres especiales (&, =, ?).

DocumentoContenido
doc-readme-MarkeUpVisión general y stack
doc-guia-MarkeUpInstalación y configuración
doc-operacion-MarkeUpComandos y troubleshooting
doc-auditoria-MarkeUpAuditoría y deuda técnica
markeup/
├── src/
│   ├── components/
│   ├── content/
│   ├── layouts/
│   ├── lib/
│   ├── markeup_tools/
│   ├── pages/
│   ├── styles/
│   └── types/
├── scripts/
├── public/
└── package.json
📂 Árbol completo (generado 2026-07-09 20:33)
        ├── content.d.ts
    ├── types.d.ts
        ├── lighthouserc.json
        ├── deploy.yml
        ├── lighthouse.yml
├── .gitignore
    ├── app.json
    ├── appearance.json
    ├── community-plugins.json
    ├── core-plugins.json
    ├── graph.json
            ├── data.json
            ├── embeddings.json
            ├── main.js
            ├── manifest.json
            ├── styles.css
            ├── data.json
            ├── main.js
            ├── manifest.json
            ├── styles.css
    ├── workspace.json
    ├── settings.json
├── astro.config.mjs
├── cline-instructions.md
├── commit-local.sh
├── deploy.sh
            ├── 2026-07-09.md
├── package.json
    ├── _headers
        ├── Presuncion.mp3
        ├── euritmia-steiner.mp3
        ├── glandula-pineal.mp3
        ├── registros-akashicos.mp3
        ├── teoria-polivagal.mp3
        ├── teosofia.mp3
        ├── roboto-flex.woff2                  ← subset latin+ES optimizado (~39 KB)
        ├── Neumorfismo.webp
        ├── Telegram-Ollama-Obsidian-Astro.webp
        ├── agni-yoga.webp
        ├── agua.webp
        ├── ancestros.webp
        ├── antroposofia-steiner-euritmia.webp
        ├── apps-chatgpt.webp
        ├── aura.webp
        ├── biodanza-danza-vida.webp
        ├── caretas-mascaras.webp
        ├── chackras.webp
        ├── chi.webp
        ├── cloudflare.webp
        ├── corrupcion.webp
        ├── docker.webp
        ├── documentacion.webp
        ├── el-ego.webp
        ├── el-libro-del-desasosiego-anatomia-del-tedio-existencial.webp
        ├── euritmia-steiner.webp
        ├── gestualidad-facial.webp
        ├── glandula-pineal.webp
        ├── grafo.webp
        ├── graphify.webp
        ├── grounding.webp
        ├── hermes-agent.webp
        ├── home.webp
        ├── inteligencia.webp
        ├── la-ansiedad.webp
        ├── la-mentira.webp
        ├── la_respiracion.webp
        ├── ladanzadelencuentro.webp
        ├── literatura.webp
        ├── material-design.webp
        ├── mesmerismo.webp
        ├── mirada.webp
        ├── motionsites.webp
        ├── musica-esferas.webp
        ├── npm-node.webp
        ├── nvidia-nim.webp
        ├── obsidian.webp
        ├── orgon.webp
        ├── pitagoras.webp
        ├── placeholder.webp
        ├── posts.webp
        ├── psicomotricidad-relacional.webp
        ├── roerich.webp
        ├── silva-mind-control.webp
        ├── sonidos-guturales.webp
        ├── syncthing.webp
        ├── tags.webp
        ├── tailwind.webp
        ├── tecnologiablog.webp
        ├── teoria-polivagal.webp
        ├── teosofia-desarrollo-personal.webp
        ├── trascendencia.webp
        ├── vault-cms-cover.webp
        ├── vitalidad.webp
Desarrollo
npm run devServidor desarrollo localhost:4321
Build
npm run buildBuild completo: images + astro + pagefind
npm run build:astroSolo build Astro → dist/
npm run build:searchSolo índice Pagefind
Imágenes
npm run imagesProcesar imágenes (crop + WebP)
npm run images:watchModo vigilancia de imágenes
npm run images:collisionsInforme de fuentes duplicadas (mismo nombre, otra extensión)
npm run images:checkBloqueante: falla si hay fuentes duplicadas
npm run images:verify¿public/img coincide con las fuentes? (hash a hash)
npm run sync:imagesDetectar imágenes huérfanas (dry-run, no borra)
npm run sync:images:applyEliminar imágenes huérfanas (pide confirmación)
npm run sync:images:allIgual que :apply incluyendo src/content/posts/img/
Audio
npm run compress-audioComprime audios pendientes/incorrectos con ffmpeg
npm run compress-audio:watchModo vigilancia de audios
npm run compress-audio:forceRecomprimir todos los audios
Tools
npm run tools🧠 Menú interactivo MarkeUP Command Center v6
npm run tools:crearCrear post (atajo directo)
npm run tools:auditarAuditar vault (atajo directo)
npm run tools:statsDashboard stats (atajo directo)
npm run tools:hermesIniciar Hermes Agent (atajo directo)
npm run tools:ollamaEstado Ollama en JSON
npm run hermesArrancar Hermes + Telegram
npm run telegramConectar/gestionar bot Telegram (setup, check, logs)
npm run termTerminal del Mac por Telegram (bot propio: init, check, install, policy…)
npm run term:check · term:statusDiagnóstico y estado del bot de la terminal
npm run syncthingConectar/gestionar Syncthing
Testing
npm run testEjecutar tests (Vitest)
npm run test:watchTests en modo watch
Deploy
npm run previewPrevisualizar build producción
npm run exportExportar blog por categoría
npm run auto-commitAuto-sync Android: images → commit → push (rate limit 120 s)
npm run autopublishServicio launchd de autopublicación (install · status · now · disable · logs)
npm run dev:searchÍndice Pagefind para desarrollo

Este documento complementa el README y la guía de operación. Para el sistema de enlaces, consulta la especificación de wikilinks.

Ver también: la auditoría técnica y la visión general del stack para el contexto completo del proyecto.

Este territorio conecta con EMDR, donde se explora francine Shapiro y el EMDR (Desensibilización y Reprocesamiento por Movimientos Oculares): la neurobiología del trauma, el.

doc-arquitectura-MarkeUp

Explora las ramas del artículo

Compartir este artículo