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.
Navegando en el blogver navegación en todos los blogs
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:
npm run images→process-images.js: genera 3 tamaños por imagen enpublic/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 decideEXTENSION_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 ensrc/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) ynpm run images:verify(regenera a un temporal y compara hash a hash conpublic/img).
- Una sola fuente por basename en
npm run build:astro→[[astro|astro build]]:content.config.tscarga y valida todos los.mdcon Zodremark-wikilink.jstransforma[[slug]]→<a href="/posts/slug">- Páginas Astro iteran sobre colecciones (
getCollection("posts")) y generan HTML - Se generan 192 páginas en
dist/
npm run build:search→[[doc-buscador|pagefind]] --site dist --force-language es: indexa 73 páginas (solo artículos condata-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.
| Componente | Props clave | Loading | Estados |
|---|---|---|---|
| BaseLayout | title, description, ogType, ogImage, ogImageAlt, preloadImage, breadcrumbTitle | — | OG tags, preload link, Breadcrumb, slot |
| PostHero | post: Post | eager + fetchpriority=high | Con/sin imagen, H1 = title SEO en el cuerpo, H2 = concept como rótulo grande sobre la foto, categorías, tags, metadatos |
| PostCard | post: Post | lazy + CARD_SIZES | Con/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 |
| ResponsiveImage | src, alt, loading, sizes, fetchpriority | configurable | Local: <picture> + srcset · Externo: <img> simple |
| Breadcrumb | pathname, title? | — | Ruta raíz (1 item), intermedia (2+ items), con título personalizado |
| Pagination | currentPage, totalPages, basePath | — | Botones prev/next, página activa, deshabilitado en extremos. Sin uso: los listados (secciones, tags, posts) muestran todos los artículos |
| Header | categories | — | Desktop: horizontal · Móvil: drawer con acordeones |
| TableOfContents | headings, showTOC | — | Colapsable, scroll-to-heading con Lenis |
| ForceGraph3D | data: string (JSON) | client:visible | Carga asíncrona Three.js, interactivo drag/zoom |
| ForceGraph2D | — | client:visible | Carga 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ón | Fondo | Motivo |
|---|---|---|
| Humano | fondofrio.webp | Tono frío, coherente con el trabajo corporal y la calma |
| Máquina | fondocalido.webp | Tono cálido, contrapunto a la frialdad técnica |
| Documentación | documentacion.webp | Imagen homónima de la sección |
| Literatura | literatura.webp | Imagen homónima de la sección |
| Opinión | placeholder.webp | Fallback 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.
| Elemento | Directiva | Por 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" + persist | Grupo 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 |
- 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 elmix-blend-mode: plus-lighter). Única excepción justificada:z-indexsobre un grupo, porque el orden de apilado entre grupos no se puede expresar con ninguna directiva de Astro. - Nunca dar nombre al
<body>ni al<html>. Elrootdebe quedar libre para el cross-fade nativo del navegador. - 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-preparationes el único punto donde se puede trabajar antes de la captura, y es donde vive la pausa denav-hold(ver abajo). - 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-preparationexponeloader, que Astroawaitea antes de fotografiar el estado viejo y de llamar astartViewTransition. Se envuelve con: subir arriba con el scroll suave del sitio (Lenis) → esperarNAV_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 conpreventDefaultserí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 reescribewindow.scrollYen cada frame y desharía unwindow.scrollTodirecto. La promesa se cierra cononCompletey con un tope de seguridad, porque Lenis se destruye enastro:before-swapyonCompleteno 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#hashni cuando el scroll es menor queNAV_SCROLL_MIN_PX. - Al volver atrás se conserva la posición. Astro escribe
window.scrollYenhistory.statejusto después de resolver el loader, así que anotaría 0 para el artículo abandonado. Un listener deastro:after-preparationreescribe 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:
| Guarda | Por 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.
| Evento | Uso |
|---|---|
astro:before-swap | Limpieza: anclas, Lenis, ripple, WebGL y Scroll Reveal |
astro:page-load | Único arranque (se dispara también en la primera carga) |
astro:after-swap | Arranque del reveal tras el swap (antes del snapshot): lo que ya está en el viewport entra en la transición |
astro:before-preparation | Subida 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:
- 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, conprefers-reduced-motiono si GSAP no carga, no se oculta nada — la página se ve entera. - 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. - Un guion por bloque. Cada
data-motion-blockes una sección con su propia timeline, que arranca cuando entra en pantalla. Dentro, cadadata-motiones 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.
| Fichero | Qué hace |
|---|---|
src/styles/motion/tokens.css | Duraciones, curvas y @property (ángulos, --m-mx/--m-my del puntero). |
src/styles/motion/primitives.css | Pre-estado de cada efecto + guardas (motion-armed, motion-static, movimiento reducido). |
src/styles/motion/scroll.css | Ambiente (aurora, rejilla, grano, viñeta) y capa de scroll (animation-timeline). |
src/styles/motion/interactions.css | Micro-interacciones: botones, foco de luz, subrayados, header compacto. |
src/styles/motion/home.css | Piezas 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.ts | El motor: bloques, beats, efectos, cifras y ciclo de vida. |
src/lib/motion/index.ts | Instalación en el ciclo de Astro, foco del puntero y header reactivo. |
src/components/motion/SplitText.astro | Reparto de texto en el build (palabras o letras). |
src/components/motion/MotionRoot.astro | Capa 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ón | Portada | /posts (97 tarjetas) | Artículo |
|---|---|---|---|
| Bloques · beats | 22 · 135 | 98 · 684 | 3 · 16 |
| Beats en pantalla que se quedan ocultos | 0 | 0 | 0 |
| Desplazamiento acumulado (CLS) | 0 px | 0 px | 0 px |
| Frames p95 / máximo | 16,8 / 16,8 ms | 16,8 / 16,8 ms | 16,8 / 16,8 ms |
| Beats simultáneos (pico) | 7 | 5 | 6 |
| 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 declararanimationaquí: pisaría elanimation: nonede 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
srcen el<img>provoca una descarga extra (el navegador baja elsrcy la variante delsrcset). - El
preloaddel<head>usaimagesrcset+imagesizespor 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.tscompara 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
| Comando | Función |
|---|---|
npm run dev | Servidor desarrollo localhost:4321 |
npm run build | Build completo: images + astro + pagefind |
npm run build:astro | Solo build Astro → dist/ |
npm run build:search | Solo índice Pagefind |
npm run preview | Previsualizar build producción |
npm test | Vitest (76 tests: posts, tags, graph, social-share, search-rank, search-graph) |
npm run validate:share | Audita OG + Twitter Cards + URLs de compartir en dist/ |
npm run og | Genera imágenes Open Graph 1200×630 (PNG) por post |
npm run images | Procesar imágenes (crop + variantes) |
npm run images:watch | Modo vigilancia de imágenes |
npm run images:collisions | Informe de fuentes con el mismo nombre (duplicados) |
npm run images:check | Falla si hay fuentes duplicadas (bloqueante en CI) |
npm run images:verify | Compara public/img con sus fuentes (hash a hash) |
npm run compress-audio | Comprimir audios con ffmpeg |
npm run export | Exportar 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).
| Componente | Dónde vive | Función |
|---|---|---|
| Hermes Agent | ~/.hermes/hermes-agent | El 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 vault | src/content/hermes/rules/ | Especificaciones normativas |
AGENTS.md | raíz del repo | Contexto que Hermes carga al trabajar aquí |
| Gateway | servicio launchd | Conecta Telegram con el agente |
| Terminal del Mac | servicio launchd ai.markeup.telegram-term | Bot 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
| Componente | Función | Instalación |
|---|---|---|
| Ollama | Servidor local de modelos | brew install ollama |
qwen2.5-coder:7b-64k | Modelo alternativo local | ollama pull qwen2.5-coder:7b-64k |
| Hermes Agent | Agente 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í:
- Syncthing lo sincroniza con Obsidian y el móvil
- El post entra en
npm run buildcomo cualquier otro contenido - Si
draft: false, se publica en el siguiente deploy - 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ón | Responsabilidad |
|---|---|
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):
| Red | Endpoint |
|---|---|
| X | https://twitter.com/intent/tweet?url=&text= |
https://www.linkedin.com/sharing/share-offsite/?url= | |
https://www.facebook.com/sharer/sharer.php?u= | |
https://wa.me/?text=<texto>%20<url> | |
| Telegram | https://t.me/share/url?url=&text= |
- Normaliza
urla absoluta contraAstro.siteantes de llamar abuildShareLinks. - Expone
data-share-url/data-share-titleen 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.clipboardcon fallback aexecCommand('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/heightañ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:profiley Schema.orgauthor[].sameAs = ["https://x.com/splitzforx"].
scripts/mjs/validate-share.mjs (→ npm run validate:share) recorre dist/ y falla si:
- Falta algún meta OG obligatorio (
og:title/type/url/image) o de Twitter Card (twitter:card/title/image/url). og:urluog:imageno son absolutas.- Algún botón de compartir propaga una ruta relativa (
%2F...sin dominio) — el bug de WhatsApp. twitter:site/twitter:creatorno 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 (
&,=,?).
| Documento | Contenido |
|---|---|
| doc-readme-MarkeUp | Visión general y stack |
| doc-guia-MarkeUp | Instalación y configuración |
| doc-operacion-MarkeUp | Comandos y troubleshooting |
| doc-auditoria-MarkeUp | Auditorí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 dev | Servidor desarrollo localhost:4321 |
| Build | |
|---|---|
npm run build | Build completo: images + astro + pagefind |
npm run build:astro | Solo build Astro → dist/ |
npm run build:search | Solo índice Pagefind |
| Imágenes | |
|---|---|
npm run images | Procesar imágenes (crop + WebP) |
npm run images:watch | Modo vigilancia de imágenes |
npm run images:collisions | Informe de fuentes duplicadas (mismo nombre, otra extensión) |
npm run images:check | Bloqueante: falla si hay fuentes duplicadas |
npm run images:verify | ¿public/img coincide con las fuentes? (hash a hash) |
npm run sync:images | Detectar imágenes huérfanas (dry-run, no borra) |
npm run sync:images:apply | Eliminar imágenes huérfanas (pide confirmación) |
npm run sync:images:all | Igual que :apply incluyendo src/content/posts/img/ |
| Audio | |
|---|---|
npm run compress-audio | Comprime audios pendientes/incorrectos con ffmpeg |
npm run compress-audio:watch | Modo vigilancia de audios |
npm run compress-audio:force | Recomprimir todos los audios |
| Tools | |
|---|---|
npm run tools | 🧠 Menú interactivo MarkeUP Command Center v6 |
npm run tools:crear | Crear post (atajo directo) |
npm run tools:auditar | Auditar vault (atajo directo) |
npm run tools:stats | Dashboard stats (atajo directo) |
npm run tools:hermes | Iniciar Hermes Agent (atajo directo) |
npm run tools:ollama | Estado Ollama en JSON |
npm run hermes | Arrancar Hermes + Telegram |
npm run telegram | Conectar/gestionar bot Telegram (setup, check, logs) |
npm run term | Terminal del Mac por Telegram (bot propio: init, check, install, policy…) |
npm run term:check · term:status | Diagnóstico y estado del bot de la terminal |
npm run syncthing | Conectar/gestionar Syncthing |
| Testing | |
|---|---|
npm run test | Ejecutar tests (Vitest) |
npm run test:watch | Tests en modo watch |
| Deploy | |
|---|---|
npm run preview | Previsualizar build producción |
npm run export | Exportar blog por categoría |
npm run auto-commit | Auto-sync Android: images → commit → push (rate limit 120 s) |
npm run autopublish | Servicio 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
