diff --git a/.github/workflows/bluesky.yaml b/.github/workflows/bluesky.yaml index 9397f14b..3f2aa3cb 100644 --- a/.github/workflows/bluesky.yaml +++ b/.github/workflows/bluesky.yaml @@ -18,6 +18,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v7 + with: + fetch-depth: 0 - name: Check if PR contains document changes id: check @@ -30,6 +32,7 @@ jobs: else echo "document_changes=false" >> "$GITHUB_OUTPUT" fi + echo "commit_hash=$(git log -1 --format=%H HEAD)" >> "$GITHUB_OUTPUT" - name: Extract commit description if: steps.check.outputs.document_changes == 'true' @@ -38,11 +41,20 @@ jobs: DESC=$(git log -1 --format=%B HEAD | awk -v RS='' 'NR == 2' | sed ':a;N;$!ba;s/\n/ /g') echo "raw_desc=$DESC" >> "$GITHUB_OUTPUT" + - name: Compute decree URL + if: steps.check.outputs.document_changes == 'true' + id: decree_url + run: | + # La página del decreto se genera con scripts/generar_reformas.js. + npm ci + DECREE_URL=$(node scripts/generar_reformas.js --url "${{ steps.check.outputs.commit_hash }}") + echo "url=$DECREE_URL" >> "$GITHUB_OUTPUT" + - name: Build Bluesky post if: steps.check.outputs.document_changes == 'true' id: post run: | - URL="${{ github.event.pull_request._links.html.href }}" + URL="${{ steps.decree_url.outputs.url }}" RAW_DESC='${{ steps.desc.outputs.raw_desc }}' MAX=300 URL_LEN=$(printf '%s' "$URL" | wc -m) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4b409e20..cd90a14b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,6 +9,8 @@ on: - '.github/workflows/main.yml' - 'pyproject.toml' - 'uv.lock' + - 'package.json' + - 'package-lock.json' pull_request: branches: [main] paths: @@ -18,6 +20,8 @@ on: - '.github/workflows/main.yml' - 'pyproject.toml' - 'uv.lock' + - 'package.json' + - 'package-lock.json' workflow_dispatch: jobs: @@ -56,6 +60,9 @@ jobs: - name: Check CSS syntax run: npx --package @biomejs/biome@latest biome check css/ + - name: Check JavaScript syntax + run: npx --package @biomejs/biome@latest biome check scripts/*.js + - name: Lint Python scripts run: uv run ruff check scripts/ @@ -90,6 +97,18 @@ jobs: uv run ../scripts/rst2html5.py toc.rst ../html/index.html uv run ../scripts/rst2html5.py acercade.rst ../html/acercade.html + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + cache: 'npm' + + - name: Install Node dependencies + run: npm ci + + - name: Generate reformas diff + run: node scripts/generar_reformas.js + - name: Validate generated HTML run: | tidy -e -q html/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..e9beb69c --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Node +node_modules/ +npm-debug.log* + +# Entorno +__pycache__/ +*.pyc +.venv/ +.ruff_cache/ + +# Salida generada +html/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f412b6ef..8a3e132a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -35,6 +35,9 @@ repos: rev: v2.5.5 hooks: - id: biome-check + - id: biome-check + name: biome-check-javascript + files: '\.(js)$' - repo: local hooks: - id: diccionario diff --git a/AGENTS.md b/AGENTS.md index 399f5a80..b57c1126 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,18 @@ partir de los decretos y otros cambios Constitucionales registrados en uv run ../scripts/rst2html5.py toc.rst ../html/index.html ``` +- `scripts/generar_reformas.js`: es un script en JavaScript que, usando + el paquete [`diff2html`](https://www.npmjs.com/package/diff2html), + genera una página HTML con el diff de cada reforma constitucional + (con una apariencia similar a la de GitHub) y un índice de todas las + reformas en `html/reformas/`. + + Para ejecutar el script: + + ```bash + node scripts/generar_reformas.js + ``` + ### Uso de Python Python se usa a través de un entorno virtual controlado con `uv`. El @@ -63,6 +75,26 @@ Usar `biome` para validar y corregir formato de archivos CSS. npx @biomejs/biome lint --write css/ ``` +### JavaScript + +Usar `biome` para validar y corregir formato de los scripts en +JavaScript. + +```bash +npx @biomejs/biome check scripts/*.js +npx @biomejs/biome lint --write scripts/*.js +npx @biomejs/biome format --write scripts/*.js +``` + +Las dependencias de los scripts en JavaScript se gestionan con `npm` y +se describen en `package.json`. Para instalar las dependencias y +generar los diffs de reformas se ejecuta: + +```bash +npm install +node scripts/generar_reformas.js +``` + ## pre-commit El proyecto utiliza `pre-commit` para realizar revisiones automáticas a diff --git a/css/cpeum.css b/css/cpeum.css index 4bc1cb73..905a0a15 100644 --- a/css/cpeum.css +++ b/css/cpeum.css @@ -153,20 +153,6 @@ aside.sidebar .sidebar-title { color: #222; } -aside.sidebar ul { - margin: 0; - padding: 0; - list-style: none; -} - -aside.sidebar ul > li { - border-bottom: thin solid #e0e0e0; -} - -aside.sidebar ul > li:last-child { - border-bottom: none; -} - aside.sidebar :where(li) { margin-bottom: 0; padding: 6px 0; @@ -431,3 +417,17 @@ main > p, main > section:first-of-type { display: block; } + +aside.sidebar ul { + margin: 0; + padding: 0; + list-style: none; +} + +aside.sidebar ul > li { + border-bottom: thin solid #e0e0e0; +} + +aside.sidebar ul > li:last-child { + border-bottom: none; +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..1fbf22a9 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,94 @@ +{ + "name": "cpeum-diffs", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cpeum-diffs", + "version": "0.1.0", + "dependencies": { + "diff": "^9.0.0", + "diff2html": "^3.4.56" + }, + "devDependencies": {} + }, + "node_modules/@profoundlogic/hogan": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@profoundlogic/hogan/-/hogan-3.0.4.tgz", + "integrity": "sha512-pmNVGuooS30Mm7YbZd5T7E5zYVO6D5Ct91sn4T39mUvMUc3sCGridcnhAufL1/Bz2QzAtzEn0agNrdk3+5yWzw==", + "license": "Apache-2.0", + "dependencies": { + "nopt": "1.0.10" + }, + "bin": { + "hulk": "bin/hulk" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, + "node_modules/diff": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diff2html": { + "version": "3.4.56", + "resolved": "https://registry.npmjs.org/diff2html/-/diff2html-3.4.56.tgz", + "integrity": "sha512-u9gfn+BlbHcyO7vItCIC4z49LJDUt31tODzOfAuJ5R1E7IdlRL6KjugcB9zOpejD+XiR+dDZbsnHSQ3g6A/u8A==", + "license": "MIT", + "dependencies": { + "@profoundlogic/hogan": "^3.0.4", + "diff": "^8.0.3" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "highlight.js": "11.11.1" + } + }, + "node_modules/diff2html/node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/nopt": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", + "integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==", + "license": "MIT", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "*" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..9fae242f --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "cpeum-diffs", + "version": "0.1.0", + "private": true, + "description": "Genera un diff HTML (estilo diff2html/github) por cada reforma constitucional", + "type": "commonjs", + "scripts": { + "reformas": "node scripts/generar_reformas.js" + }, + "dependencies": { + "diff": "^9.0.0", + "diff2html": "^3.4.56" + } +} diff --git a/run.sh b/run.sh index 280a95da..f95c96ec 100755 --- a/run.sh +++ b/run.sh @@ -29,7 +29,12 @@ cd "${SCRIPT_DIR}/CPEUM" uv run "${SCRIPTS_DIR}/rst2html5.py" toc.rst "${HTML_DIR}/index.html" uv run "${SCRIPTS_DIR}/rst2html5.py" acercade.rst "${HTML_DIR}/acercade.html" -# Paso 3: Iniciar servidor HTTP en segundo plano +# Paso 3: Generar los diffs de las reformas y su índice +echo "==> Generando reformas..." +cd "${SCRIPT_DIR}" +node "${SCRIPTS_DIR}/generar_reformas.js" + +# Paso 4: Iniciar servidor HTTP en segundo plano echo "==> Iniciando servidor HTTP en puerto 8000..." cd "${HTML_DIR}" python3 -m http.server 8000 & @@ -39,7 +44,7 @@ echo "==> PID del servidor: ${SERVER_PID}" # Limpiar al salir trap 'echo "==> Deteniendo servidor..."; kill ${SERVER_PID} 2>/dev/null' EXIT -# Paso 3: Abrir navegador +# Paso 5: Abrir navegador sleep 0.5 xdg-open http://127.0.0.1:8000 diff --git a/scripts/generar_reformas.js b/scripts/generar_reformas.js new file mode 100644 index 00000000..51cdd930 --- /dev/null +++ b/scripts/generar_reformas.js @@ -0,0 +1,705 @@ +#!/usr/bin/env node +/** + * Genera un diff HTML (estilo diff2html/github) por cada reforma + * constitucional representada en un commit, así como un índice de todas + * las reformas. + * + * Para cada commit cuyo asunto comienza con "Artículo" o "Artículos" + * se obtienen los archivos `.rst` modificados y se construye un diff + * unificado usando el paquete `diff`. Ese diff se renderiza con + * `diff2html` en su formato de líneas (outputFormat "line"), el mismo + * aspecto que produce diff2html-cli. + * + * Salida: + * html/decretos/index.html — índice de reformas (una por decreto) + * html/decretos/.html — diff individual de cada decreto + */ + +const { execSync } = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); +const { createPatch } = require("diff"); +const { html: diff2html } = require("diff2html"); + +function htmlEscape(text) { + return String(text).replace(/[&<>"']/g, (char) => { + switch (char) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return "'"; + } + }); +} + +/* Convierte en vínculos las URLs del Diario Oficial de la Federación + (dof.gob.mx). Recibe texto ya escapado con htmlEscape. */ +function linkifyDof(text) { + return text.replace( + /(https?:\/\/[^\s<>"']*dof\.gob\.mx[^\s<>"']*)/g, + '$1', + ); +} + +const OUTDIR = path.resolve(__dirname, "..", "html", "decretos"); +const DECRETOS_PATH = path.resolve(__dirname, "..", "html", "decretos.json"); +const GIT_LOG_FORMAT = "%H%n%ci%n%s%n%b%n---END---"; +const TITLE = "Decretos"; + +/* Hoja de estilo oficial de diff2html (la misma que usa diff2html-cli). */ +const DIFF2HTML_CSS_PATH = require.resolve( + "diff2html/bundles/css/diff2html.min.css", +); +const DIFF2HTML_CSS = fs.readFileSync(DIFF2HTML_CSS_PATH, "utf-8"); + +const GLOBAL_CSS = `\ +body { + font-family: Georgia, "Noto Serif", "DejaVu Serif", serif; + max-width: 100ch; + margin: 0 auto; + padding: 40px 1.5em 1.5em; + line-height: 1.8; + font-size: 1.05em; + color: #1a1a1a; + background: #fafafa; +} +h1 { font-size: 1.3em; border-bottom: 2px solid #333; padding-bottom: 0.3em; } +h2 { font-size: 1.1em; margin-top: 1.5em; } +.reforma-nav { + display: flex; + justify-content: space-between; + gap: 1em; + margin: 0.5em 0 1em; + font-size: 0.9em; +} +.reforma-nav a { color: #0066cc; text-decoration: none; } +.reforma-nav a:hover { text-decoration: underline; } +.reforma-nav .nav-disabled { color: #999; } +.reforma-nav .nav-prev { max-width: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.reforma-nav .nav-next { text-align: right; } +.reforma-nav .nav-next a { + display: inline-block; + max-width: 45vw; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.commit-meta { color: #666; font-size: 0.9em; margin-bottom: 0.5em; } +.reforma-numero { color: #0066cc; font-size: 0.85em; margin: 0.1em 0 0.5em; } +.commit-meta a { color: #0066cc; text-decoration: none; } +.commit-meta a:hover { text-decoration: underline; } +.commit-body { + white-space: pre-wrap; + font-size: 0.9em; + margin: 0.5em 0 1.5em 0; + padding: 0.75em 1em; + background: #f9f9f9; + border-left: 3px solid #ccc; + line-height: 1.5; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} +.commit-body a, .reforma-resumen a { + color: #0066cc; + text-decoration: none; +} +.commit-body a:hover, .reforma-resumen a:hover { + text-decoration: underline; +} +.reforma { margin-bottom: 1.2em; } +.reforma-titulo { font-size: 1em; margin: 0.1em 0; } +.reforma-titulo a { color: #0066cc; text-decoration: none; } +.reforma-titulo a:hover { text-decoration: underline; } +.reforma-fecha { color: #666; font-size: 0.9em; } +.reforma-resumen { + font-size: 1em; + color: #555; + margin: 0.2em 0 0 0; +} +.reforma-sep { + border: none; + border-top: 1px solid #d0d7de; + margin: 1.2em 0 0 0; +} +/* Banner superior fijo, igual que en las páginas del sitio */ +#top-banner { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 40px; + background: #1a3a5c; + color: #ffffff; + display: flex; + align-items: center; + padding: 0 20px; + box-sizing: border-box; + z-index: 200; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); +} +#top-banner .banner-link { + color: #ffffff; + text-decoration: none; + font-size: 1.1em; + font-weight: bold; + letter-spacing: 0.05em; +} +#top-banner .banner-links { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.9em; +} +#top-banner .banner-icon { + display: block; + fill: currentColor; +} +#top-banner .banner-link:hover { + text-decoration: underline; +} +#top-banner .banner-link:hover .banner-icon { + opacity: 0.8; +} +.menu-toggle { + display: none; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: #ffffff; + cursor: pointer; + padding: 4px; + margin-right: 8px; + line-height: 0; +} +.menu-toggle:hover { + opacity: 0.8; +} +@media (max-width: 768px) { + #top-banner { + padding: 0 12px; + gap: 4px; + } + #top-banner .banner-link { + font-size: 1em; + } + /* Sin tabla de contenidos, la hamburguesa no se muestra */ + body:not(:has(nav.contents)) .menu-toggle { + display: none; + } +} +`; + +const HTML_HEADER = `\ + + + + + + +`; + +const FOOTER = `\ + + +`; + +const GITHUB_URL = "https://github.com/ceyusa/cpeum"; + +/* Banner superior, igual que el de index.html y acercade.html. + Rutas relativas a html/decretos/index.html. */ +const BANNER = `\ +
+ + + +
+`; + +// --------------------------------------------------------------------------- +// Git helpers +// --------------------------------------------------------------------------- + +function runGit(args) { + return execSync(["git", ...args].join(" "), { + encoding: "utf-8", + timeout: 30000, + stdio: ["ignore", "pipe", "pipe"], + }); +} + +/** Devuelve los commits de decretos, en orden cronológico (antiguo → nuevo). */ +function getReformaCommits() { + const output = runGit([ + "log", + "--reverse", + "--grep=Artículo", + "--grep=Articulo", + "--grep=Artíclos", + `--format=${GIT_LOG_FORMAT}`, + ]); + const commits = []; + for (const block of output.split("---END---")) { + const trimmed = block.trim(); + if (!trimmed) continue; + const lines = trimmed.split("\n"); + if (lines.length < 3) continue; + const body = lines.slice(3).join("\n").trim(); + const pubDate = getPublicationDate(body); + commits.push({ + hash: lines[0].trim(), + date: lines[1].trim(), + subject: lines[2].trim(), + body, + pubDate, + iso: pubDateToIso(pubDate), + decreto: getDecreeSummary(body), + }); + } + return commits; +} + +/** + * Carga y devuelve los decretos de decretos.json ordenados por número. + * @returns {Array<{numero:number, decreto:string, publicacion:string}>} + */ +function loadDecretos() { + const raw = fs.readFileSync(DECRETOS_PATH, "utf8"); + const decretos = JSON.parse(raw); + decretos.sort((a, b) => a.numero - b.numero); + return decretos; +} + +/* Normaliza un texto para comparar títulos de decretos (ignora mayúsculas, + acentos y espacios/puntuación). */ +function normalizeText(text) { + return String(text || "") + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, ""); +} + +/** + * Relaciona los commits de reforma con los decretos de decretos.json y + * devuelve la lista de reformas (una por decreto) en orden cronológico. + * + * El emparejamiento usa primero la fecha de publicación (DOF) y, si la + * fecha no coincide, el título del decreto. Cada commit se asigna a un solo + * decreto; los decretos sin commit quedan con `commits` vacío (faltan en + * git). Se emiten avisos por discrepancias. + * + * @param {Array} decretos + * @param {Array} commits + * @returns {Array<*>} + */ +function buildReformas(decretos, commits) { + const byTitle = new Map(); + for (const d of decretos) { + const k = normalizeText(d.decreto); + if (k && !byTitle.has(k)) byTitle.set(k, d); + } + const byDate = new Map(); + for (const d of decretos) { + const arr = byDate.get(d.publicacion) || []; + arr.push(d); + byDate.set(d.publicacion, arr); + } + + const used = new Set(); + const asignar = (commit, decreto) => { + commit.numero = decreto.numero; + commit.publicacion = decreto.publicacion; + used.add(decreto.numero); + if (decreto.publicacion !== commit.iso) { + console.warn( + `AVISO: el commit ${commit.hash.slice(0, 8)} "${commit.subject}" ` + + `(fecha ${commit.iso}) no coincide con la fecha del decreto ` + + `${decreto.numero} (${decreto.publicacion}).`, + ); + } + }; + + // 1) Emparejamiento principal por fecha de publicación (DOF). + const pendientes = []; + for (const commit of commits) { + const grupo = commit.iso ? byDate.get(commit.iso) : null; + if (grupo) { + const noUsados = grupo.filter((d) => !used.has(d.numero)); + const titulo = normalizeText(commit.decreto); + let d = noUsados.find((x) => normalizeText(x.decreto) === titulo); + if (!d) d = noUsados[0]; + if (d) { + asignar(commit, d); + continue; + } + } + pendientes.push(commit); + } + + // 2) Los commits sin fecha coincidente se vinculan por el título del decreto. + for (const commit of pendientes) { + const d = byTitle.get(normalizeText(commit.decreto)); + if (d && !used.has(d.numero)) { + asignar(commit, d); + } else { + console.warn( + `AVISO: el commit ${commit.hash.slice(0, 8)} "${commit.subject}" ` + + "no corresponde a ningún decreto de decretos.json.", + ); + } + } + + const reformas = decretos.map((d) => ({ + numero: d.numero, + decreto: d.decreto, + publicacion: d.publicacion, + commits: commits.filter((c) => c.numero === d.numero), + })); + + for (const r of reformas) { + if (r.commits.length > 1) { + console.warn( + `AVISO: el decreto ${r.numero} tiene ${r.commits.length} commits asociados.`, + ); + } + } + + return reformas; +} + +/* Fecha de publicación en el DOF, extraída del cuerpo del commit. + El año puede aparecer en la línea siguiente (el renglón de la fecha + se parte en dos), por lo que se unen varias líneas antes de buscar. */ +function getPublicationDate(body) { + const lines = String(body).split("\n"); + const idx = lines.findIndex((line) => + /Publicado\s+en\s+el\s+Diario\s+Oficial/i.test(line), + ); + if (idx < 0) return null; + + const joined = lines.slice(idx, idx + 3).join(" "); + const datePattern = + /(\d{1,2}(?:ro|º)?\.?\s+(?:de\s+)?[A-Za-záéíóúñü]+\s+(?:(?:de|del)\s+)?(?:año\s+)?\d{4})/i; + const match = joined.match(datePattern); + return match ? match[1].trim() : null; +} + +/* Meses en español, igual que rst2html5.py. */ +const SPANISH_MONTHS = { + enero: 1, + febrero: 2, + marzo: 3, + abril: 4, + mayo: 5, + junio: 6, + julio: 7, + agosto: 8, + septiembre: 9, + setiembre: 9, + octubre: 10, + noviembre: 11, + diciembre: 12, +}; + +/* Convierte la fecha de publicación en español a ISO 8601 (YYYY-MM-DD), + imitando pub_date_to_iso de rst2html5.py. Devuelve null si no se puede. */ +function pubDateToIso(pubDate) { + if (!pubDate || pubDate === "Sin fecha") return null; + + const match = String(pubDate) + .trim() + .match( + /^(\d{1,2})(?:[roº]+)?\.?(?:\s+de)?\s+([A-Za-záéíóúñü]+)\s+(?:(?:de|del)\s+)?(?:año\s+)?(\d{3,4})$/i, + ); + if (!match) return null; + + const day = Number(match[1]); + const month = SPANISH_MONTHS[match[2].toLowerCase()]; + const year = Number(match[3]); + + if (!month || day < 1 || day > 31 || year < 1900) return null; + + return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; +} + +/* Resumen del decreto: el párrafo que comienza con DECRETO, REFORMA, + REFORMAS, DECLARATORIA o LEY (el resumen puede ocupar varias líneas). */ +function getDecreeSummary(body) { + const paragraphs = String(body).split(/\n\s*\n/); + for (const paragraph of paragraphs) { + if ( + /^(?:DECRETO|REFORMA|REFORMAS|DECLARATORIA|LEY)\b/i.test(paragraph.trim()) + ) { + return paragraph.trim(); + } + } + return null; +} + +/** Archivos .rst modificados por el commit dado. */ +function getRstFiles(commitHash) { + const output = runGit([ + "diff-tree", + "--no-commit-id", + "-r", + "--name-only", + commitHash, + ]); + return output + .trim() + .split("\n") + .filter((file) => file.endsWith(".rst") && file); +} + +/** Contenido de un archivo en un commit dado (o "" si no existe). */ +function getFileContent(commitHash, filepath) { + try { + return runGit(["show", `${commitHash}:${filepath}`]); + } catch { + return ""; + } +} + +// --------------------------------------------------------------------------- +// Diff generation +// --------------------------------------------------------------------------- + +function buildDiff(parentHash, commitHash, rstFile) { + const oldContent = getFileContent(parentHash, rstFile); + const newContent = getFileContent(commitHash, rstFile); + if (oldContent === newContent) return ""; + + const oldName = `${parentHash.slice(0, 8)}/${rstFile}`; + const newName = `${commitHash.slice(0, 8)}/${rstFile}`; + const patch = createPatch(rstFile, oldContent, newContent, oldName, newName); + + const htmlOutput = diff2html(patch, { + drawFileList: false, + matching: "none", + outputFormat: "line", + diffStyle: "word", + fileContentToggle: false, + }); + + // diff2html envuelve el resultado en un documento; extraemos solo el cuerpo. + const bodyMatch = htmlOutput.match(/([\s\S]*?)<\/body>/); + return bodyMatch ? bodyMatch[1] : htmlOutput; +} + +// --------------------------------------------------------------------------- +// HTML generation +// --------------------------------------------------------------------------- + +function writeIndex(reformas) { + const parts = [ + '\n\n\n', + '\n', + '\n', + `CPEUM — Índice de ${TITLE}\n`, + '\n', + '\n', + '\n', + '\n', + '\n', + '\n', + "\n\n\n", + BANNER, + `

Índice de ${TITLE}

\n`, + ]; + + for (const reforma of [...reformas].reverse()) { + const first = reforma.commits[0]; + const fuente = first ? { subject: first.subject } : null; + + parts.push('
\n'); + + if (fuente) { + parts.push( + '

', + ``, + `${reforma.numero} 🔗. ${htmlEscape(fuente.subject)}`, + ); + const iso = reforma.publicacion; + const datetimeAttr = iso ? ` datetime="${iso}"` : ""; + parts.push( + ` 📅 ${htmlEscape(iso || "")}

\n`, + ); + } else { + // Decretos sin commit: solo el número y el campo "decreto", sin vínculo. + parts.push(`

${reforma.numero}.

\n`); + } + + // En los decretos sin commit solo se muestra el contenido del campo + // "decreto" (sin vínculo). + parts.push( + `

${linkifyDof(htmlEscape(reforma.decreto))}

\n`, + ); + parts.push('
\n'); + parts.push("
\n"); + } + + parts.push("\n\n"); + const filepath = path.join(OUTDIR, "index.html"); + fs.writeFileSync(filepath, parts.join(""), "utf-8"); + console.log(` ${path.relative(process.cwd(), filepath)}`); +} + +function writeReformaFile(reforma, prev, next) { + const filename = `${reforma.numero}.html`; + const parts = []; + const first = reforma.commits[0]; + const title = `CPEUM — ${first.subject}`; + const commitUrl = `${GITHUB_URL}/commit/${first.hash}`; + + parts.push(HTML_HEADER); + parts.push(`${htmlEscape(title)}\n`); + parts.push( + `\n`, + ); + parts.push( + '\n', + ); + parts.push('\n'); + parts.push( + `\n`, + ); + parts.push( + '\n', + ); + parts.push( + '\n\n\n', + ); + parts.push(BANNER); + + // Navegación cronológica: anterior / siguiente (solo decretos con commit). + parts.push('\n"); + + parts.push(`

${htmlEscape(first.subject)}

\n`); + parts.push(`

Decreto ${reforma.numero}

\n`); + parts.push( + '

', + ``, + "Ver commit en GitHub", + " — ", + htmlEscape(first.date), + "

\n", + ); + if (first.body) { + parts.push( + `
${linkifyDof(htmlEscape(first.body))}
\n`, + ); + } + + for (const commit of reforma.commits) { + const rstFiles = getRstFiles(commit.hash); + const parent = `${commit.hash}~1`; + + for (const rstFile of rstFiles) { + const diffHtml = buildDiff(parent, commit.hash, rstFile); + if (diffHtml) { + parts.push(diffHtml); + parts.push("\n"); + } else { + parts.push(`

Sin cambios en ${htmlEscape(rstFile)}.

\n`); + } + } + } + + parts.push(FOOTER); + const filepath = path.join(OUTDIR, filename); + fs.writeFileSync(filepath, parts.join(""), "utf-8"); + console.log(` ${path.relative(process.cwd(), filepath)}`); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +function generate() { + fs.mkdirSync(OUTDIR, { recursive: true }); + const decretos = loadDecretos(); + const commits = getReformaCommits(); + const reformas = buildReformas(decretos, commits); + + // Modo "url": busca el número de decreto del commit dado y escribe en + // stdout la URL de su página de diff (usada por el CI de BlueSky). + const hashArg = process.argv.indexOf("--url"); + if (hashArg !== -1 && process.argv.length > hashArg + 1) { + const target = process.argv[hashArg + 1]; + const commit = commits.find((c) => c.hash === target); + if (!commit || commit.numero === undefined) { + console.error( + `Error: no se encontró un decreto para el commit ${target}.`, + ); + process.exit(1); + } + console.log(`https://cpeum.mx/decretos/${commit.numero}.html`); + return; + } + + const conCommit = reformas.filter((r) => r.commits.length > 0); + const sinCommit = reformas.filter((r) => r.commits.length === 0); + + console.log( + `${reformas.length} decretos (${conCommit.length} con commit, ${sinCommit.length} sin).`, + ); + writeIndex(reformas); + for (let i = 0; i < conCommit.length; i++) { + writeReformaFile(conCommit[i], conCommit[i - 1], conCommit[i + 1]); + } + console.log(`\nGenerado en: ${OUTDIR}`); +} + +try { + generate(); +} catch (error) { + console.error("Error:", error.message); + process.exit(1); +} diff --git a/scripts/rst2html5.py b/scripts/rst2html5.py index f75e2ab5..d398195d 100755 --- a/scripts/rst2html5.py +++ b/scripts/rst2html5.py @@ -11,9 +11,11 @@ """A minimal front end to the Docutils Publisher, producing HTML5 documents.""" +import json import logging import re import subprocess +import unicodedata from pathlib import Path from docutils import nodes @@ -52,6 +54,84 @@ "diciembre": 12, } +# Ruta del catálogo de decretos, relativa a este script, usado por +# scripts/generar_reformas.js para generar las páginas de diffs. +_DECRETOS_JSON = Path(__file__).resolve().parent.parent / "html" / "decretos.json" + + +def _normalize_text(text: str) -> str: + """Normaliza un texto para comparar títulos de decretos (ignora + mayúsculas, acentos y espacios/puntuación). Imita normalizeText().""" + return ( + unicodedata.normalize("NFD", text or "") + .encode("ascii", "ignore") + .decode("ascii") + .lower() + .replace(" ", "") + ) + + +def _load_decretos() -> list[dict]: + """Carga decretos.json y devuelve la lista ordenada por número. + + Devuelve una lista vacía si el archivo no existe o no es válido. + """ + try: + with _DECRETOS_JSON.open(encoding="utf-8") as handle: + raw = json.load(handle) + except (OSError, ValueError) as exc: + logger.warning("No se pudo cargar %s: %s", _DECRETOS_JSON, exc) + return [] + return sorted(raw, key=lambda d: d.get("numero", 0)) + + +def _build_decreto_index() -> tuple[dict[str, list[dict]], dict[str, dict]]: + """Construye los índices de decretos por fecha y por título. + + Devuelve una tupla ((por_fecha, por_titulo)) para el emparejamiento + de los commits con su decreto correspondiente. + """ + por_fecha: dict[str, list[dict]] = {} + por_titulo: dict[str, dict] = {} + for decreto in _load_decretos(): + publicacion = decreto.get("publicacion") + if publicacion: + if publicacion not in por_fecha: + por_fecha[publicacion] = [] + por_fecha[publicacion].append(decreto) + titulo = _normalize_text(decreto.get("decreto", "")) + if titulo and titulo not in por_titulo: + por_titulo[titulo] = decreto + return por_fecha, por_titulo + + +_DECRETO_INDEX: dict = {} + + +def _match_decreto_numero(iso: str | None, decreto: str) -> int | None: + """Devuelve el número del decreto al que corresponde un commit. + + El emparejamiento imita a generar_reformas.js: primero por fecha de + publicación (DOF) y, si no coincide, por el título del decreto. + """ + if "_value" not in _DECRETO_INDEX: + _DECRETO_INDEX["_value"] = _build_decreto_index() + por_fecha, por_titulo = _DECRETO_INDEX["_value"] + + titulo = _normalize_text(decreto) + + if iso and iso in por_fecha: + grupo = por_fecha[iso] + if grupo: + coincidencia = next( + (d for d in grupo if _normalize_text(d.get("decreto", "")) == titulo), + None, + ) + return (coincidencia or grupo[0]).get("numero") + + d = por_titulo.get(titulo) + return d.get("numero") if d else None + class IncludeWithSection(misc.Include): """Custom include directive handler.""" @@ -221,10 +301,12 @@ def _parse_commit_block(block: str) -> dict[str, str] | None: f"REFORMAS, DECLARATORIA o LEY) en el commit {commit_hash[:8]}" ) decreto = match.group(0).strip() + iso = pub_date_to_iso(pub_date) return { "hash": commit_hash[:8], "pub_date": pub_date, + "iso": iso, "decreto": decreto, } except (IndexError, AttributeError, ValueError) as e: @@ -305,13 +387,16 @@ def visit_container(self, node: nodes.container) -> None: if commits: self.body.append("