hiparquia.dog — sitio F0-lite: Astro estático, cero JS, candado del protocolo en código

This commit is contained in:
2026-08-05 11:10:53 -06:00
commit 974823b9bb
23 changed files with 5294 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
node_modules/
dist/
.astro/
fotos/
# LA REGLA: se rastrea la fuente, no la salida — pero solo cuando la salida se
# puede volver a fabricar en el sitio donde hace falta.
#
# La fuente embarcable SE DERIVA en el propio Dockerfile (solo necesita fontTools):
# npm run fuente
public/Commissioner-wght.woff2
# Las tarjetas og SÍ se versionan, y es una excepción con razón: fabricarlas
# necesita Chromium, y meter Chromium en la imagen de construcción cuesta más de
# 300 MB para tres PNG que cambian una vez por ficha. Se regeneran a mano con
# npm run tarjetas
# y se comitean. Si algún día el generador cambia, se nota en el diff del PNG.
+21
View File
@@ -0,0 +1,21 @@
# hiparquia.dog — sitio estático. Se construye una vez y se sirve como archivos.
# No hay servidor de aplicación porque no hay aplicación: son tres HTML y una fuente.
FROM node:22-alpine AS construir
WORKDIR /app
# fontTools deriva la fuente embarcable del maestro (npm run fuente): quita los
# tres ejes que el sitio no usa y la deja en 36 KB en vez de 95. No necesita
# navegador, así que cabe en la imagen de construcción sin engordarla.
RUN apk add --no-cache python3 py3-pip \
&& pip3 install --break-system-packages --no-cache-dir fonttools brotli
COPY package*.json ./
RUN npm ci --omit=dev || npm install --omit=dev
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=construir /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+106
View File
@@ -0,0 +1,106 @@
# hiparquia.dog — la casa
El sitio que las piezas prometen. Cada carrusel termina diciendo *«la ficha completa vive en
la casa»* y cada pin lleva `hiparquia.dog` impreso; esto es la casa.
**Fase F0-lite del `9-1`.** La arquitectura dice que el corpus vive en Directus y las fichas
en `ink.hiparquia.dog`. Directus todavía no está en pie, así que el corpus son archivos
markdown y las fichas cuelgan de la raíz. **El esquema es el mismo contrato**: cuando Directus
exista se cambia el `loader` de `src/content.config.ts` y nada más — las páginas no se enteran.
## Qué hay
```
src/content/fichas/*.md el corpus. Una ficha pública por texto
src/content.config.ts el esquema + EL CANDADO del protocolo
src/pages/index.astro el manifiesto y el índice
src/pages/ficha/[slug].astro la ficha, con su schema.org
src/layouts/Base.astro cabeza, og:, tipografía
src/styles/global.css dos tintas y nada más
src/fuentes/ el MAESTRO de Commissioner (4 ejes, no se embarca)
scripts/fuente.py deriva la fuente embarcable
scripts/og.mjs fabrica las tarjetas al compartir
scripts/verificar.mjs los seis escenarios adversos
```
## El candado, en código
```js
.filter(f => f.data.semaforo === 'verde' || f.data.semaforo === 'propio')
```
Está en el índice **y** en `getStaticPaths`. Una ficha en ámbar o rojo no aparece en la lista
**y su página no se construye**: no existe la URL. El `9-1 §2` dice que el protocolo deja de
ser documento y se vuelve software; esto es esa línea.
## Nada se mueve
Ni una animación. La única transición del sitio es el color de un enlace bajo el cursor,
120 ms lineal — retroalimentación, no gesto. La pregunta de `ioon-gesto §0.5` es *¿qué se
pierde si esto no se mueve?*, y en un archivo de fichas la respuesta es: nada.
**No hay `font-weight` ni `letter-spacing` en ningún `:hover`.** Ensanchan el elemento y
empujan a sus vecinos. Medido: el ancho del enlace no cambia, delta 0.00 px.
## Las dos fuentes de Commissioner, y por qué son dos
| | ejes | peso | dónde |
|---|---|---|---|
| **maestro** | `wght` `slnt` `FLAR` `VOLM` | 95,144 B | `src/fuentes/` — no se embarca |
| **embarcable** | solo `wght` 100–900 | **36,400 B** | `public/` — se deriva |
**−61.7 % sin tocar un solo carácter.** El sitio nunca usa `slnt`, `FLAR` ni `VOLM`, así que
los tres se fijan en 0 y desaparecen del binario. Los 230 glifos del subconjunto latino se
quedan enteros: el corpus va a crecer con textos que todavía no existen, y recortar por los
caracteres de hoy rompería los de mañana.
Es la misma partición que en los SVG: un maestro que lo conserva todo y un embarcable que solo
lleva lo que la pieza usa.
## Qué se versiona y qué no
Se rastrea la fuente, no la salida — **pero solo cuando la salida se puede volver a fabricar
donde hace falta.**
- **La fuente embarcable NO se versiona.** Se deriva dentro del propio `Dockerfile`; fontTools
cabe en la imagen de construcción sin engordarla.
- **Las tarjetas og SÍ se versionan.** Fabricarlas necesita Chromium, y meterlo en la imagen de
construcción cuesta más de 300 MB para tres PNG que cambian una vez por ficha. Es una
excepción con razón escrita, no un descuido. Se regeneran con `npm run tarjetas`.
## Las tarjetas al compartir
`serclin.mx` no tiene `og:image`, y por eso su enlace viaja desnudo por WhatsApp. Aquí van
desde el primer día: 1200×630, una por ficha más la de la casa. **WhatsApp y Facebook no
ejecutan JavaScript** — leen las etiquetas `og:` y nada más.
## Cómo se corre
```
npm install
npm run build:completo # fuente + tarjetas + astro build
npm run verificar # los seis escenarios, contra dist/ servido en 127.0.0.1
npm run dev
```
## Cómo se despliega
**El paso 2 no es opcional** (`ioon-verificar §1.1`): mientras Coolify no esté expuesto por
Traefik con HTTPS, GitHub no tiene por dónde entregar el webhook. Un fix bien escrito estuvo
68 días sin salir del repositorio de serclin por creer lo contrario.
1. `git push`
2. **`Redeploy` manual en Coolify**
3. **Verificar contra producción**, con dos comprobaciones que no mienten: que cambió el
`last-modified` **y** que el HTML servido trae el texto nuevo. En este sitio el CSS va
incrustado en el HTML, así que no hay hash de bundle que mirar: se comprueba el HTML.
Antes de nada: **`hiparquia.dog` tiene que resolver.** Los dos pines lo llevan impreso y no
pueden publicarse hasta entonces.
## Pendientes
- DNS en Cloudflare + TLS por Traefik (`9-1 §1`).
- Cuando haya más de ~20 fichas: paginación o índice por autor.
- Cuando Directus esté en pie: cambiar el `loader`, mover a `ink.` y dejar redirecciones.
- `ask.` (AnythingLLM) y la tienda son F1/F2 — nada de esto los bloquea.
+85
View File
@@ -0,0 +1,85 @@
---
proyecto: "9. hiparquia"
subtema: "verificación · hiparquia.dog (F0-lite, antes de desplegar)"
slug: "verificacion-hiparquia-sitio"
version: "v20260804-2140"
autor: "Francisco León (ioon) + Claude"
nivel: "reporte de verificación"
estado: "PASA 12/12 · 1 BLOQUEADO (el dominio, que no es de la pieza)"
---
# VERIFICACIÓN · hiparquia.dog · 04-ago-2026 21:40, hora Oaxaca
**régimen:** automático · **sin licencias declaradas**
Medido contra `dist/` servido en `127.0.0.1`, no contra el disco. Tres rutas: `/`,
`/ficha/hombres-necios/`, `/ficha/nada-te-turbe/`.
---
## Los seis escenarios adversos
| # | escenario | resultado |
|---|---|---|
| 1 | **Sin JavaScript** | contenido **completo**: 1,108 · 3,827 · 3,874 caracteres visibles. `<h1>` presente, enlaces navegables ✔ |
| 2 | **`prefers-reduced-motion: reduce`** | `transition-duration: 1e-05s` ✔ |
| 3 | **Pestaña oculta + `rAF` congelado** | **0 elementos invisibles.** Nada nace en `opacity-0` ✔ |
| 4 | **360 px** | `scrollWidth === clientWidth` en las tres rutas ✔ |
| 5 | **Táctil 390 px** | sin desborde; nada depende de `:hover` para funcionar ✔ |
| 6 | **Recarga a media página** | no aplica: no hay estado de llegada que rearmar ✔ |
## PASA 12/12
- ✔ **Cero JavaScript.** Dos recursos en el cable y ya: el HTML y la fuente. No hay ni un
`<script>` que enviar.
- ✔ **HTML en el cable, comprimido: 2,425 B** el índice y **4,028 B** una ficha. La vara: el
primitivo del puente pesa 1,482 B y el sitio entero de Bruno Imbrizi, 27 KB.
- ✔ **Fuente: 36,400 B** — el maestro pesa 95,144 y se le quitaron los tres ejes que el sitio
no usa. **−61.7 % sin tocar un carácter.**
- ✔ **Objetivo de toque: 44 px el peor**, en las tres rutas y en los dos anchos. El imperativo
es 24 y la meta 44; aquí el peor caso ya es la meta.
- ✔ **Ancho durante el hover: 91.16 → 91.16 px, delta 0.00.** El enlace cambia de color y nada
más — ni `font-weight` ni `letter-spacing`, que ensanchan y empujan a los vecinos.
- ✔ **Contraste 21:1** en el texto (blanco puro sobre negro puro) y **9.7:1** en el texto tenue
(`#a6a6a6`). Imperativo: 4.5:1.
- ✔ **Un solo `<h1>` por página**, `lang="es"`, enlace «Saltar al contenido».
- ✔ **`og:image` presente desde el primer día** — 1200×630, una por ficha. WhatsApp y Facebook
no ejecutan JavaScript: leen las etiquetas y nada más. *(serclin.mx todavía no lo tiene.)*
- ✔ **Canónica y `schema.org`** en cada ficha. En «nada» el JSON-LD declara
`creativeWorkStatus: "Autoría tradicional no verificada paleográficamente"` — la reserva de
la ficha, legible por máquina.
- ✔ **El candado del protocolo está en código**, no en un documento: una ficha que no sea
`verde` o `propio` no aparece en el índice **y su página no se construye**.
- ✔ **Foco visible** con `:focus-visible`, contorno de 2 px y separación de 3.
- ✔ **CSP cerrada:** `default-src 'none'`. Se puede porque no hay JavaScript de nadie; el día
que deje de poderse será porque entró algo.
## FALLA 0
## BLOQUEADO 1 — y no es de la pieza
⛔ **`hiparquia.dog` no resuelve.** El sitio está listo; el dominio no. Bloquea también los dos
pines de S2, que lo llevan impreso.
## Alcance recortado, y se dice
- **No se probó en un navegador de teléfono real**, solo en Chromium con emulación táctil.
- **No se probó con lector de pantalla real.**
- **No se construyó la imagen Docker** — no hay demonio de Docker en este contenedor. El
`Dockerfile` está escrito y razonado, pero **su primera construcción es una comprobación
pendiente**, no un hecho.
- No se midió Safari ni Firefox: el sitio no usa nada que esté en discusión (sin `d` animada,
sin `offset-path`, sin `animation-timeline`), pero eso es un argumento, no una medición.
## Lo que sigue
1. **DNS de `hiparquia.dog`** en Cloudflare + TLS por Traefik.
2. Repo en Gitea, app en Coolify, y la primera construcción de la imagen.
3. `git push` → **`Redeploy` manual** → verificar contra producción. El paso 2 no es opcional
mientras Coolify no esté expuesto: es la regla que costó 68 días en serclin.
4. Con el sitio en pie, **los pines salen** y los carruseles dejan de prometer en falso.
---
*Verificación · 04-ago-2026 21:40, hora Oaxaca. Doce comprobaciones, cero fallos, y un bloqueo
que no depende de escribir más código.*
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'astro/config';
export default defineConfig({
site: 'https://hiparquia.dog',
output: 'static',
trailingSlash: 'always',
build: { format: 'directory' },
/* Sin integraciones. Sin JavaScript en el cliente: no hay ninguno que enviar. */
});
+35
View File
@@ -0,0 +1,35 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_vary on;
# El nivel por defecto de nginx es 1, no 6. Medido sobre una ficha de este
# sitio: nivel 1 = 4,433 B · nivel 6 = 4,092 B. 341 B (7.7 %) por una línea.
# Del 6 al 9 se gana 1 B y se paga CPU: el 6 es el punto correcto.
gzip_comp_level 6;
gzip_min_length 256;
gzip_types text/html text/css application/javascript application/json image/svg+xml;
# La fuente y las tarjetas no cambian: se cachean fuerte.
location ~* \.(woff2|png|svg)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# `trailingSlash: always` — cada ficha es un directorio con su index.html.
location / {
try_files $uri $uri/ $uri/index.html =404;
}
error_page 404 /index.html;
# Cabeceras mínimas. Sin JavaScript propio ni de terceros: la política puede
# ser así de cerrada, y si algún día deja de poder, es que algo entró.
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy
"default-src 'none'; img-src 'self'; font-src 'self'; style-src 'self' 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always;
}
+4204
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
{
"name": "hiparquia-sitio",
"type": "module",
"private": true,
"scripts": {
"fuente": "python3 scripts/fuente.py",
"tarjetas": "node scripts/og.mjs",
"build": "npm run fuente && astro build",
"build:completo": "npm run fuente && npm run tarjetas && astro build",
"dev": "astro dev",
"preview": "astro preview",
"verificar": "node scripts/verificar.mjs"
},
"dependencies": { "astro": "^7.1.6" }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+24
View File
@@ -0,0 +1,24 @@
import pw from '/home/claude/.npm-global/lib/node_modules/playwright/index.js';
import http from 'http'; import fs from 'fs'; import path from 'path';
const {chromium}=pw;
const TIPO={'.html':'text/html;charset=utf-8','.woff2':'font/woff2','.png':'image/png'};
const srv=http.createServer((req,res)=>{let p=decodeURIComponent(req.url.split('?')[0]);
if(p.endsWith('/'))p+='index.html'; const f=path.join('dist',p);
if(!fs.existsSync(f)||fs.statSync(f).isDirectory()){res.writeHead(404);return res.end()}
res.writeHead(200,{'content-type':TIPO[path.extname(f)]??'application/octet-stream'});
res.end(fs.readFileSync(f))});
await new Promise(r=>srv.listen(4322,'127.0.0.1',r));
const b=await chromium.launch({executablePath:'/opt/pw-browsers/chromium'});
fs.mkdirSync('fotos',{recursive:true});
for (const [n,vp,ruta,full] of [
['inicio-1280',{width:1280,height:1000},'/',true],
['ficha-1280',{width:1280,height:1000},'/ficha/nada-te-turbe/',false],
['inicio-390',{width:390,height:844},'/',true],
['ficha-390',{width:390,height:844},'/ficha/hombres-necios/',false]]){
const p=await b.newPage({viewport:vp,deviceScaleFactor:1});
await p.goto('http://127.0.0.1:4322'+ruta,{waitUntil:'networkidle'});
await p.screenshot({path:`fotos/${n}.png`,fullPage:full});
await p.close();
}
await b.close(); srv.close();
console.log('fotos listas');
+28
View File
@@ -0,0 +1,28 @@
"""Deriva la fuente que se embarca a partir del maestro.
El maestro (src/fuentes/) trae los cuatro ejes de Commissioner. El sitio solo usa
el peso — nunca FLAR, VOLM ni slnt —, así que los tres se fijan en 0 y desaparecen
del binario. El repertorio de caracteres NO se toca: 230 glifos, el subconjunto
latino entero, porque el corpus va a crecer con textos que todavía no existen y
recortar por los caracteres de hoy rompería los de mañana.
Es la misma partición que en los SVG: un maestro que lo conserva todo y un
embarcable que solo lleva lo que la pieza usa.
"""
from fontTools.ttLib import TTFont
from fontTools.varLib.instancer import instantiateVariableFont
import os
MAESTRO = "src/fuentes/Commissioner-latin.woff2"
SALIDA = "public/Commissioner-wght.woff2"
f = instantiateVariableFont(TTFont(MAESTRO),
{"FLAR": 0, "VOLM": 0, "slnt": 0, "wght": (100, 900)},
inplace=True, updateFontNames=False, optimize=True)
f.flavor = "woff2"
f.save(SALIDA)
a, b = os.path.getsize(MAESTRO), os.path.getsize(SALIDA)
g = TTFont(SALIDA)
print(f"maestro {a:,} B → embarcable {b:,} B ({(b-a)/a*100:+.1f} %)")
print(f"ejes: {[(x.axisTag, x.minValue, x.maxValue) for x in g['fvar'].axes]} · glifos: {len(g.getBestCmap())}")
+58
View File
@@ -0,0 +1,58 @@
import pw from '/home/claude/.npm-global/lib/node_modules/playwright/index.js';
import fs from 'fs';
const { chromium } = pw;
/* Las tarjetas al compartir. 1200×630 es lo que leen WhatsApp, Facebook,
Telegram y X — ninguno ejecuta JavaScript, así que la tarjeta es esta imagen
y las etiquetas og:. Sin ella, el enlace viaja desnudo.
(Lección de serclin.mx: el og:image ausente decide la tarjeta, no el sitio.) */
const TARJETAS = [
{ archivo: 'hiparquia', palabra: 'hiparquia',
pie: 'Cada verso con su ficha: la fuente, la edición y hasta dónde llegó el cotejo.',
tam: 128 },
{ archivo: 'hombres-necios', palabra: 'necios',
pie: 'Sor Juana Inés de la Cruz · Inundación castálida, Madrid, 1689',
tam: 172 },
{ archivo: 'nada-te-turbe', palabra: 'nada',
pie: 'Atribuido a Teresa de Ávila (1515–1582) · letrilla hallada en su breviario',
tam: 172 },
];
const W = 1200, H = 630;
const b64 = fs.readFileSync('src/fuentes/Commissioner-latin.woff2').toString('base64');
const pagina = (T) => `<!doctype html><meta charset="utf-8"><style>
@font-face{ font-family:Commissioner; src:url(data:font/woff2;base64,${b64}) format('woff2');
font-weight:100 900; font-display:block; }
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:${W}px;height:${H}px;background:#000}
body{font-family:Commissioner;color:#fff;font-variation-settings:"FLAR" 0,"VOLM" 0;
padding:78px;display:flex;flex-direction:column;justify-content:space-between;
-webkit-font-smoothing:antialiased}
.palabra{font-weight:800;font-size:${T.tam}px;line-height:1.02;letter-spacing:-0.025em;
margin-block:auto 0}
.pie{font-weight:300;font-size:29px;line-height:1.45;color:#a6a6a6;max-width:26em;
margin-block-start:30px}
</style>
<div class="palabra">${T.palabra}</div>
<div class="pie">${T.pie}</div>`;
const b = await chromium.launch({ executablePath:'/opt/pw-browsers/chromium',
args:['--force-device-scale-factor=1','--hide-scrollbars'] });
const p = await b.newPage({ viewport:{ width:W, height:H }, deviceScaleFactor:1 });
fs.mkdirSync('public/og', { recursive:true });
const out = [];
for (const T of TARJETAS) {
await p.setContent(pagina(T));
await p.evaluate(()=>document.fonts.ready);
const f = `public/og/${T.archivo}.png`;
await p.screenshot({ path:f });
const desborde = await p.evaluate(() => {
const d = document.documentElement;
return { h: d.scrollWidth - d.clientWidth, v: d.scrollHeight - d.clientHeight };
});
out.push({ archivo:T.archivo, bytes: fs.statSync(f).size, desborde });
}
await b.close();
console.log(JSON.stringify(out, null, 1));
+140
View File
@@ -0,0 +1,140 @@
import pw from '/home/claude/.npm-global/lib/node_modules/playwright/index.js';
import http from 'http'; import fs from 'fs'; import path from 'path';
import { execSync } from 'child_process';
const { chromium } = pw;
/* ioon-verificar · los seis escenarios adversos + lo que se mide con número.
Se sirve dist/ en 127.0.0.1 y se mide ahí, no en el disco. */
const RAIZ = 'dist';
const TIPO = { '.html':'text/html;charset=utf-8', '.css':'text/css', '.js':'text/javascript',
'.woff2':'font/woff2', '.png':'image/png', '.json':'application/json' };
const srv = http.createServer((req,res)=>{
let p = decodeURIComponent(req.url.split('?')[0]);
if (p.endsWith('/')) p += 'index.html';
const f = path.join(RAIZ, p);
if (!fs.existsSync(f) || fs.statSync(f).isDirectory()) { res.writeHead(404); return res.end('404'); }
res.writeHead(200, { 'content-type': TIPO[path.extname(f)] ?? 'application/octet-stream' });
res.end(fs.readFileSync(f));
});
await new Promise(r => srv.listen(4321, '127.0.0.1', r));
const BASE = 'http://127.0.0.1:4321';
const RUTAS = ['/', '/ficha/hombres-necios/', '/ficha/nada-te-turbe/'];
const b = await chromium.launch({ executablePath:'/opt/pw-browsers/chromium' });
const R = {};
/* ── 1 · sin JavaScript ─────────────────────────────────────────────────── */
{
const c = await b.newContext({ javaScriptEnabled:false, viewport:{width:1280,height:900} });
const p = await c.newPage();
R.sinJS = [];
for (const r of RUTAS) {
await p.goto(BASE+r, { waitUntil:'load' });
R.sinJS.push({ ruta:r,
textoVisible: await p.evaluate(()=>document.body.innerText.trim().length),
h1: await p.evaluate(()=>document.querySelector('h1')?.innerText ?? null),
enlaces: await p.evaluate(()=>document.querySelectorAll('a').length) });
}
await c.close();
}
/* ── 2 · movimiento reducido ────────────────────────────────────────────── */
{
const c = await b.newContext({ reducedMotion:'reduce', viewport:{width:1280,height:900} });
const p = await c.newPage(); await p.goto(BASE+RUTAS[1]);
R.movReducido = await p.evaluate(()=>{
const a = document.querySelector('main a') ?? document.querySelector('a');
return { duracion: getComputedStyle(a).transitionDuration,
textoVisible: document.body.innerText.trim().length };
});
await c.close();
}
/* ── 3 · pestaña oculta ─────────────────────────────────────────────────── */
{
const c = await b.newContext({ viewport:{width:1280,height:900} });
const p = await c.newPage();
await p.addInitScript(() => {
Object.defineProperty(document,'visibilityState',{get:()=>'hidden'});
Object.defineProperty(document,'hidden',{get:()=>true});
window.requestAnimationFrame = () => 0; // rAF congelado
});
await p.goto(BASE+RUTAS[1]);
R.oculta = await p.evaluate(()=>{
const inv = [...document.querySelectorAll('h1,h2,p,li')]
.filter(e=>e.innerText.trim() && getComputedStyle(e).opacity !== '1').length;
return { textoVisible: document.body.innerText.trim().length, elementosInvisibles: inv };
});
await c.close();
}
/* ── 4 · 360 px · 5 · táctil · 6 · objetivos de toque ───────────────────── */
{
for (const [nom, opc] of [
['ancho360', { viewport:{width:360,height:740} }],
['tactil', { viewport:{width:390,height:844}, hasTouch:true, isMobile:true,
userAgent:'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)' }]]) {
const c = await b.newContext(opc); const p = await c.newPage();
R[nom] = [];
for (const r of RUTAS) {
await p.goto(BASE+r);
R[nom].push(await p.evaluate(ruta => {
const d = document.documentElement;
const toques = [...document.querySelectorAll('a')].map(a=>{
const b = a.getBoundingClientRect();
return { texto:a.innerText.trim().slice(0,22), alto:+b.height.toFixed(1), ancho:+b.width.toFixed(1) };
}).filter(t=>t.alto>0);
return { ruta,
desbordeH: d.scrollWidth - d.clientWidth,
peorToque: Math.min(...toques.map(t=>t.alto)),
bajo24: toques.filter(t=>t.alto < 24).map(t=>t.texto),
bajo44: toques.filter(t=>t.alto < 44).map(t=>t.texto) };
}, r));
}
await c.close();
}
}
/* ── lo que se mide con número ──────────────────────────────────────────── */
{
const c = await b.newContext({ viewport:{width:1280,height:900} });
const p = await c.newPage();
const red = [];
p.on('response', r => red.push({ url:r.url(), tipo:r.request().resourceType() }));
await p.goto(BASE+RUTAS[1], { waitUntil:'networkidle' });
R.recursos = red.map(r=>({ tipo:r.tipo, url:r.url.replace(BASE,'') }));
R.js = red.filter(r=>r.tipo==='script').length;
/* anchos durante el hover: un enlace que ensancha empuja a sus vecinos */
R.hover = await p.evaluate(async () => {
const a = document.querySelector('main a') ?? document.querySelector('header a');
if (!a) return null;
const antes = a.getBoundingClientRect().width;
a.dispatchEvent(new MouseEvent('mouseover',{bubbles:true}));
a.classList.add('__h');
await new Promise(r=>setTimeout(r,200));
return { antes:+antes.toFixed(2), despues:+a.getBoundingClientRect().width.toFixed(2) };
});
R.meta = await p.evaluate(()=>({
lang: document.documentElement.lang,
titulo: document.title,
descripcion: document.querySelector('meta[name=description]')?.content?.length ?? 0,
ogImage: document.querySelector('meta[property="og:image"]')?.content ?? null,
canonica: document.querySelector('link[rel=canonical]')?.href ?? null,
jsonld: !!document.querySelector('script[type="application/ld+json"]'),
saltar: !!document.querySelector('.saltar'),
h1: document.querySelectorAll('h1').length,
}));
await c.close();
}
await b.close(); srv.close();
/* bytes en el cable, comprimidos */
const gz = (f) => +(execSync(`gzip -c9 ${f} | wc -c`).toString().trim());
R.bytes = {
indexHTML: gz('dist/index.html'),
fichaHTML: gz('dist/ficha/hombres-necios/index.html'),
fuenteWOFF2: fs.statSync('dist/Commissioner-wght.woff2').size,
ogPNG: fs.statSync('dist/og/hombres-necios.png').size,
};
console.log(JSON.stringify(R, null, 1));
+28
View File
@@ -0,0 +1,28 @@
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
/* El corpus. Hoy son archivos markdown; cuando Directus esté en pie (9-1 §2)
se cambia el `loader` y nada más — el esquema es el contrato. */
const fichas = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/fichas' }),
schema: z.object({
palabra: z.string(),
titulo: z.string(),
tituloCompleto: z.string().optional(),
autor: z.string(),
vida: z.string(),
obra: z.string(),
lugar: z.string().optional(),
anio: z.number(),
lengua: z.string(),
/* el candado del protocolo: solo verde y propio pueden publicarse */
semaforo: z.enum(['verde', 'ambar', 'rojo', 'propio']),
atribuido: z.boolean().default(false),
fragmento: z.string().optional(),
credito: z.string(),
resumen: z.string(),
orden: z.number(),
}),
});
export const collections = { fichas };
+93
View File
@@ -0,0 +1,93 @@
---
palabra: "necios"
titulo: "Hombres necios que acusáis"
tituloCompleto: "Arguye de inconsecuentes el gusto y la censura de los hombres que en las mujeres acusan lo que causan"
autor: "Sor Juana Inés de la Cruz"
vida: "1648–1695"
obra: "Inundación castálida"
lugar: "Madrid"
anio: 1689
lengua: "español"
semaforo: "verde"
credito: "Sor Juana Inés de la Cruz. «Arguye de inconsecuentes el gusto y la censura de los hombres que en las mujeres acusan lo que causan.» Inundación castálida, Madrid, 1689."
resumen: "La segunda palabra del poema es la que hace todo el trabajo. Qué edición imprimimos, por qué, y hasta dónde llegó nuestro cotejo."
orden: 1
---
## El texto
> Hombres necios que acusáis
> a la mujer sin razón,
> sin ver que sois la ocasión
> de lo mismo que culpáis.
Primera de diecisiete redondillas.
## La segunda palabra
La primera palabra nombra a quién le habla — es el vocativo, la puerta. **La segunda ya es la
sentencia.**
Para abrir, Sor Juana no eligió *crueles*. Ni *injustos*, ni *tiranos*, ni *villanos*: tenía a
mano todo el vocabulario del Siglo de Oro para el agravio. Eligió **tontos**.
Acusar de necedad humilla más y hace menos ruido que acusar de maldad. Al malvado se le puede
temer; al necio no se le puede ni discutir. Y el poema entero se sostiene sobre esa elección:
lo que sigue no es una denuncia, es la demostración paciente de una incoherencia — *sin ver que
sois la ocasión de lo mismo que culpáis*. La palabra pone el marco antes de que empiece el
argumento.
## El título que nadie usa
Todo el mundo lo llama «Hombres necios» o «las Redondillas». Su título es:
> Arguye de inconsecuentes el gusto y la censura de los hombres que en las mujeres acusan lo
> que causan.
Es un título de época: descriptivo, largo, hecho para orientar al lector en un índice, no para
memorizarse. Vale la pena saberlo por una razón práctica: buscarlo por ahí lleva a las ediciones
académicas; buscarlo por el primer verso lleva a las cuentas de citas.
## La procedencia
Apareció impreso por primera vez en *Inundación castálida* (Madrid, **1689**), la primera
recopilación de su obra publicada en España — costeada por Juan Camacho Gayna y dedicada a la
virreina de la Laguna. El volumen recogía loas, villancicos, el *Neptuno alegórico* y la poesía
personal: sonetos, décimas, redondillas y liras. Tuvo nueve reimpresiones, que para el siglo XVII
es una cifra de éxito de venta.
Sor Juana murió en 1695. Hace **331 años**. El texto está en dominio público sin discusión
posible.
## La coma que sí importa
Los cuatro versos son estables en todas las ediciones que consultamos. **Su puntuación, no.**
El segundo verso aparece como *«a la mujer sin razón»* en unas ediciones y como *«a la mujer,
sin razón»* en otras; el cuarto cierra con punto, con dos puntos o con punto y coma según quién
lo edite.
No es un detalle de erudito. La ortografía del siglo XVII no puntuaba como la nuestra, y **toda
puntuación moderna en este poema es editorial, no de Sor Juana.** Quien lo imprime está
eligiendo una edición, lo sepa o no.
La coma después de «mujer» cambia el énfasis: sin coma, *sin razón* califica a la acusación —
acusáis sin motivo. Con coma, se despega y suena a *injustamente*.
**Imprimimos sin coma.** Es la lectura más seca y la que sostiene el verso siguiente. No es la
única defendible; es la nuestra, y por eso va dicha.
## Hasta dónde llegó el cotejo
Esto es lo que sabemos y cómo lo sabemos. Lo que no alcanzamos también está aquí.
**Cotejado contra:** la transcripción de la Universidad de Pensilvania, que cita *Obras
completas* (México: Porrúa, 1997), p. 109 — de ahí sale el título completo. Los datos de
*Inundación castálida* contra la ficha de referencia de EBSCO.
**No consultado, y hay que decirlo:** el facsímil de la edición de 1689, la edición crítica de
Méndez Plancarte (*Obras completas* I, *Lírica personal*, FCE) y la Biblioteca Virtual Miguel de
Cervantes.
**Qué significa:** llegamos a edición académica citada a nivel de página, que es un escalón por
debajo de la fuente primaria. Suficiente para publicar la ficha. Cuando tengamos el tomo de
Méndez Plancarte a mano, esta nota se actualiza y lo diremos aquí.
+102
View File
@@ -0,0 +1,102 @@
---
palabra: "nada"
titulo: "Nada te turbe"
tituloCompleto: "Nada te turbe, nada te espante"
autor: "Atribuido a Teresa de Ávila"
vida: "1515–1582"
obra: "letrilla hallada en su breviario"
lugar: "Alba de Tormes"
anio: 1582
lengua: "español"
semaforo: "verde"
atribuido: true
fragmento: "los tres primeros versos de una letrilla de nueve"
credito: "Atribuido a Teresa de Ávila (1515–1582). Letrilla hallada en su breviario. Fragmento: los tres primeros versos de una letrilla de nueve."
resumen: "Cinco fuentes afirman que es de Santa Teresa. Ninguna lo argumenta. Lo imprimimos igual, con dos palabras de más."
orden: 2
---
## El texto
> Nada te turbe,
> nada te espante:
> todo se pasa.
Fragmento: los tres primeros versos de una letrilla de nueve.
## La pregunta que esta ficha existe para hacer
Todo el mundo sabe que este poema es de Santa Teresa. **Nadie lo argumenta.**
Consultamos cinco fuentes — carmelitas, especializadas y divulgativas. Las cinco afirman la
autoría. **Ninguna cita una edición crítica, un estudio paleográfico ni un análisis de la
letra.** Una dice que el papel estaba *«de su puño y letra»*, sin respaldo. El sitio más
especializado que encontramos, dedicado íntegramente a Teresa, no dice de quién es la letra:
dice solo que era *«una composición que Teresa llevaba consigo en su breviario cuando murió en
Alba de Tormes (1582)»*.
Llevar un papel en el breviario y haberlo escrito son dos cosas distintas. **La primera está
documentada; la segunda se repite.**
## Y hay un segundo texto que sí sabemos que no es suyo
La letrilla tiene **nueve versos**. Alrededor circula una **glosa poética extensa** — la de las
estampas, los imanes y las cuentas de citas — y no hay indicios de que Teresa la escribiera.
Son dos textos que se pegaron con el tiempo, y casi nadie los separa. La letrilla es lo
atribuido. Todo lo que le cuelga alrededor, no.
## La letrilla completa
> Nada te turbe, / nada te espante, / todo se pasa, / Dios no se muda, / la paciencia / todo lo
> alcanza. / Quien a Dios tiene / nada le falta. / Solo Dios basta.
## Por qué imprimimos tres versos de nueve
**Cortar cambia el género del texto**, y hay que decirlo.
La letrilla entera resuelve en *Dios no se muda… solo Dios basta*. Cortada en el tercer verso,
queda un consuelo **estoico**, no devocional: llega a quien cree y a quien no. Para una pieza de
pésame eso nos parece lo correcto — pero es un corte con consecuencia teológica, no un recorte
por espacio.
Por eso el fragmento va marcado como fragmento, en la pieza y aquí.
## La puntuación es nuestra
El original circula con **coma** tras *espante*. Nosotros imprimimos **dos puntos**: son más
secos y anuncian la conclusión.
Es puntuación editorial nuestra, no del siglo XVI — donde no se puntuaba así. Va declarada como
tal.
## Las dos palabras de más
En el dorso de cada pieza no dice «Teresa de Ávila». Dice:
> **Atribuido a Teresa de Ávila** (1515–1582). Letrilla hallada en su breviario.
Nadie imprime «atribuido a», porque le quita fuerza a la frase. Nosotros lo imprimimos porque se
la da a la casa.
## El semáforo es verde, y el riesgo no es legal
Teresa murió en 1582. Hace **444 años**. Aunque la letrilla resultara ser de una carmelita
anónima del siglo XVI, seguiría siendo de dominio público: no hay derechos que infringir ni
titular que pueda reclamar, en ningún escenario.
**El riesgo de esta pieza no es legal: es de credibilidad.** Por eso la respuesta no es no
publicarla. Es publicarla diciendo lo que se sabe.
## Hasta dónde llegó el cotejo
**Consultado:** Provincia Carmelita de San Juan de la Cruz · teresavila.com — de donde sale el
dato de la glosa · Jerónimo Alayón · Ciudad Seva · EWTN.
**No consultado, y es lo que cerraría el caso:** las *Obras completas* de Tomás Álvarez o de
Efrén de la Madre de Dios (BAC), que son las ediciones críticas teresianas; cualquier estudio
paleográfico del papel del breviario; y el propio manuscrito, conservado por la orden.
**Qué significa:** la atribución es tradicional y no está verificada paleográficamente. Lo
decimos en la ficha y lo imprimimos en el dorso. Si algún día podemos quitar esas dos palabras,
lo diremos aquí — y probablemente no haya que quitarlas nunca.
Binary file not shown.
+54
View File
@@ -0,0 +1,54 @@
---
import '../styles/global.css';
interface Props {
titulo: string;
descripcion: string;
ruta: string;
og?: string;
}
const { titulo, descripcion, ruta, og = '/og/hiparquia.png' } = Astro.props;
const SITIO = 'https://hiparquia.dog';
const canonica = new URL(ruta, SITIO).href;
---
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{titulo}</title>
<meta name="description" content={descripcion} />
<link rel="canonical" href={canonica} />
{/* La tarjeta al compartir. WhatsApp y Facebook no ejecutan JavaScript:
leen estas etiquetas y nada más. Sin og:image no hay tarjeta. */}
<meta property="og:type" content="article" />
<meta property="og:site_name" content="hiparquia" />
<meta property="og:locale" content="es_MX" />
<meta property="og:title" content={titulo} />
<meta property="og:description" content={descripcion} />
<meta property="og:url" content={canonica} />
<meta property="og:image" content={new URL(og, SITIO).href} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<link rel="preload" href="/Commissioner-wght.woff2" as="font" type="font/woff2" crossorigin />
</head>
<body>
<a class="saltar" href="#contenido">Saltar al contenido</a>
<header class="casa env">
<a href="/">hiparquia</a>
</header>
<main id="contenido" class="env">
<slot />
</main>
<footer class="pie env">
<p>
Cada texto de esta casa lleva su ficha: de quién es, de qué edición sale,
y hasta dónde llegó el cotejo. Cuando solo lo sabemos a medias, lo decimos.
</p>
<p>hiparquia · Oaxaca</p>
</footer>
</body>
</html>
+52
View File
@@ -0,0 +1,52 @@
---
import { getCollection, render } from 'astro:content';
import Base from '../../layouts/Base.astro';
export async function getStaticPaths() {
const fichas = (await getCollection('fichas'))
.filter(f => f.data.semaforo === 'verde' || f.data.semaforo === 'propio');
return fichas.map(f => ({ params: { slug: f.id }, props: { ficha: f } }));
}
const { ficha } = Astro.props;
const d = ficha.data;
const { Content } = await render(ficha);
/* schema.org — para que un buscador (o un modelo) pueda leer la procedencia
sin adivinarla. 9-1 §2: «fichas públicas + schema.org para GEO». */
const jsonld = {
'@context': 'https://schema.org',
'@type': 'CreativeWork',
name: d.tituloCompleto ?? d.titulo,
alternateName: d.titulo,
inLanguage: 'es',
datePublished: String(d.anio),
isPartOf: d.obra,
creditText: d.credito,
...(d.atribuido
? { author: { '@type': 'Person', name: d.autor.replace(/^Atribuido a /, '') },
creativeWorkStatus: 'Autoría tradicional no verificada paleográficamente' }
: { author: { '@type': 'Person', name: d.autor } }),
};
---
<Base
titulo={`${d.palabra} — ${d.autor}`}
descripcion={d.resumen}
ruta={`/ficha/${ficha.id}/`}
og={`/og/${ficha.id}.png`}
>
<h1>{d.palabra}</h1>
<p class="credito">{d.credito}</p>
<ul class="marcas">
<li>Dominio público</li>
{d.atribuido && <li>Atribuido</li>}
{d.fragmento && <li>Fragmento</li>}
<li>{d.anio}</li>
</ul>
<Content />
<script type="application/ld+json" set:html={JSON.stringify(jsonld)} is:inline />
</Base>
+48
View File
@@ -0,0 +1,48 @@
---
import { getCollection } from 'astro:content';
import Base from '../layouts/Base.astro';
/* El candado del protocolo, en código: solo verde y propio salen.
9-1 §2 — «el protocolo deja de ser documento y se vuelve software». */
const fichas = (await getCollection('fichas'))
.filter(f => f.data.semaforo === 'verde' || f.data.semaforo === 'propio')
.sort((a, b) => a.data.orden - b.data.orden);
---
<Base
titulo="hiparquia — cada verso con su ficha"
descripcion="Poemas de dominio público impresos con su procedencia: de quién es cada palabra, de qué edición sale, y hasta dónde llegó el cotejo."
ruta="/"
>
<h1>Cada verso<br />con su <b>ficha</b>.</h1>
<p>
Las frases circulan sin dueño. Se atribuyen a quien vende más, se cortan donde
conviene y se puntúan como caiga. Casi nadie dice de qué edición salieron, y
casi nadie dice cuándo no lo sabe.
</p>
<p>
Aquí cada texto lleva su ficha: la fuente, la edición, la fecha, las decisiones
editoriales que tomamos y <b>las que no pudimos cerrar</b>. Cuando la autoría es
tradicional y no está probada, se imprime «atribuido a». Cuando es un fragmento,
dice que es un fragmento.
</p>
<p>Es más trabajo y vende igual. Pero es la única forma de que la palabra valga algo.</p>
<hr />
<h2>El corpus</h2>
<ul class="corpus">
{fichas.map(f => (
<li>
<a href={`/ficha/${f.id}/`}>
<span class="palabra">{f.data.palabra}</span>
<span class="quien">{f.data.autor} · {f.data.vida}</span>
<span class="resumen">{f.data.resumen}</span>
</a>
</li>
))}
</ul>
</Base>
+175
View File
@@ -0,0 +1,175 @@
/* hiparquia.dog — dos tintas y nada más.
Cero JavaScript. Nada se mueve salvo la retroalimentación de un enlace bajo
el cursor: un archivo de fichas no gana nada moviéndose (ioon-gesto §0.5). */
@font-face {
font-family: Commissioner;
src: url("/Commissioner-wght.woff2") format("woff2");
font-weight: 100 900;
font-display: swap; /* swap, no block: el texto aparece aunque la fuente tarde */
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
:root {
--tinta: #ffffff;
--fondo: #000000;
--tenue: #a6a6a6; /* 9.7:1 sobre negro */
--linea: #2e2e2e;
--medida: 34rem;
}
*, *::before, *::after { box-sizing: border-box; }
html {
background: var(--fondo);
color: var(--tinta);
font-family: Commissioner, system-ui, sans-serif;
font-variation-settings: "FLAR" 0, "VOLM" 0;
font-weight: 300;
font-size: clamp(17px, 1.05vw + 14px, 20px);
line-height: 1.62;
-webkit-text-size-adjust: 100%;
}
body { margin: 0; }
/* ── esqueleto ─────────────────────────────────────────────────────────── */
.env {
max-inline-size: var(--medida);
margin-inline: auto;
padding-inline: 1.5rem;
}
.saltar {
position: absolute;
inset-block-start: -100vh;
inset-inline-start: 1rem;
background: var(--tinta);
color: var(--fondo);
padding: 0.6rem 1rem;
z-index: 9;
}
.saltar:focus { inset-block-start: 1rem; }
header.casa {
padding-block: 2.2rem 0;
}
header.casa a {
font-weight: 600;
font-size: 0.94rem;
letter-spacing: 0.06em;
text-transform: lowercase;
/* 44 px de alto de toque sin tocar el maquetado */
display: inline-flex;
align-items: center;
min-block-size: 44px;
}
main { padding-block: 1rem 5rem; }
footer.pie {
border-block-start: 1px solid var(--linea);
margin-block-start: 4rem;
padding-block: 2rem 4rem;
color: var(--tenue);
font-size: 0.86rem;
}
/* ── tipografía ────────────────────────────────────────────────────────── */
h1 {
font-weight: 300;
font-size: clamp(2.6rem, 6vw, 3.6rem);
line-height: 1.1;
letter-spacing: -0.02em;
margin-block: 1.4rem 0.6rem;
}
h1 b, h1 strong { font-weight: 800; }
h2 {
font-weight: 600;
font-size: 1.16rem;
line-height: 1.32;
letter-spacing: -0.005em;
margin-block: 2.8rem 0.7rem;
}
p { margin-block: 0 1.05rem; }
strong, b { font-weight: 700; }
em, i { font-style: normal; font-weight: 500; }
/* el verso: se respetan los saltos de línea del texto fuente */
blockquote {
margin: 1.6rem 0 1.6rem 0;
padding-inline-start: 1.15rem;
border-inline-start: 1px solid var(--linea);
font-weight: 400;
font-size: 1.16rem;
line-height: 1.42;
}
blockquote p { margin-block-end: 0; white-space: pre-line; }
hr { border: 0; border-block-start: 1px solid var(--linea); margin-block: 3rem; }
a {
color: inherit;
text-decoration-thickness: 1px;
text-underline-offset: 0.22em;
/* ficha de gesto · retroalimentar · eje: color · 120 ms lineal.
NO font-weight ni letter-spacing: ensanchan y empujan a los vecinos. */
transition: color 120ms linear;
}
a:hover { color: var(--tenue); }
a:focus-visible { outline: 2px solid var(--tinta); outline-offset: 3px; }
/* ── la cabecera de la ficha ───────────────────────────────────────────── */
.credito {
color: var(--tenue);
font-size: 0.94rem;
line-height: 1.5;
margin-block: 0 2.4rem;
}
.marcas {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-block: 1.1rem 2.4rem;
padding: 0;
list-style: none;
}
.marcas li {
border: 1px solid var(--linea);
padding: 0.28rem 0.66rem;
font-size: 0.8rem;
letter-spacing: 0.04em;
color: var(--tenue);
}
/* ── el índice ─────────────────────────────────────────────────────────── */
.corpus { list-style: none; padding: 0; margin: 2.6rem 0 0; }
.corpus li { border-block-start: 1px solid var(--linea); }
.corpus a {
display: block;
padding-block: 1.5rem; /* muy por encima de los 44 px de objetivo de toque */
text-decoration: none;
}
.corpus .palabra {
display: block;
font-weight: 700;
font-size: 1.9rem;
line-height: 1.15;
letter-spacing: -0.02em;
}
.corpus .quien { display: block; color: var(--tenue); font-size: 0.9rem; margin-block-start: 0.3rem; }
.corpus .resumen { display: block; margin-block-start: 0.55rem; }
.corpus a:hover .quien, .corpus a:hover .resumen { color: var(--tenue); }
.corpus a:hover .palabra { text-decoration: underline; text-underline-offset: 0.16em; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: 0.01ms !important; }
}