docs: publicar el documento de alcance como pagina web privada en /doc/alcance-2f9k7d/
- scripts/generar-web-documento.py arma la pagina (barra con logotipo, boton de descarga del PDF, hoja centrada, tablas con scroll en pantallas chicas, modo oscuro automatico y estilos de impresion) y reutiliza el generador de PDF. - La pagina se sirve con un prefijo privado en NGINX: no se enlaza desde ninguna app, lleva X-Robots-Tag y meta noindex, y la cache se desactiva para que los cambios se vean al momento. - Se sincroniza deploy/nginx-host.conf con lo que estaba instalado en el host (faltaba el cache de assets immutable) y se agrega la ruta privada. - El logotipo se reduce a tamano de pantalla: venia de un JPEG de 959 px y era el 90% del peso del PDF (495 KB -> 240 KB), sin cambiar el diseno.
This commit is contained in:
265
scripts/generar-web-documento.py
Normal file
265
scripts/generar-web-documento.py
Normal file
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publica un documento de `docs/` como página web no listada.
|
||||
|
||||
Lee el mismo markdown que el generador de PDF, arma una página con el diseño
|
||||
institucional (barra superior con logotipo, hoja blanca, tablas adaptables y modo
|
||||
oscuro) y deja junto a ella el PDF para descargar. El resultado se sirve por
|
||||
NGINX desde una ruta privada: no se enlaza desde ninguna app, así que sólo se
|
||||
llega con el enlace.
|
||||
|
||||
Uso:
|
||||
/root/.venv-tools/bin/python scripts/generar-web-documento.py \\
|
||||
docs/ALCANCE-VS-ENTREGADO.md --slug alcance-2f9k7d
|
||||
|
||||
Salida: docs/web/<slug>/index.html + docs/web/<slug>/<documento>.pdf + logo.png
|
||||
|
||||
Requiere weasyprint, markdown y Pillow, que en esta máquina viven en el entorno
|
||||
aislado /root/.venv-tools (no en el python del sistema).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import html as html_mod
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import markdown
|
||||
|
||||
RAIZ = pathlib.Path(__file__).resolve().parent.parent
|
||||
AQUI = pathlib.Path(__file__).resolve().parent
|
||||
SCRIPT_PDF = AQUI / 'generar-pdf-documento.py'
|
||||
PROVEEDOR = 'Consult (AS) Technology'
|
||||
|
||||
|
||||
def cargar_generador_pdf():
|
||||
"""Importa el generador de PDF (el nombre del archivo lleva guiones)."""
|
||||
especificacion = importlib.util.spec_from_file_location('generador_pdf', SCRIPT_PDF)
|
||||
if especificacion is None or especificacion.loader is None:
|
||||
raise SystemExit(f'No se pudo cargar {SCRIPT_PDF}')
|
||||
modulo = importlib.util.module_from_spec(especificacion)
|
||||
especificacion.loader.exec_module(modulo)
|
||||
return modulo
|
||||
|
||||
|
||||
CSS_WEB = """
|
||||
:root {
|
||||
--marca: #023681;
|
||||
--acento: #1B4332;
|
||||
--acento-claro: #2D6A4F;
|
||||
--texto: #1F2937;
|
||||
--texto-suave: #6B7280;
|
||||
--fondo: #EEF1F0;
|
||||
--hoja: #FFFFFF;
|
||||
--borde: #DCE1E8;
|
||||
--zebra: #F6F8F7;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--marca: #7FA8E8;
|
||||
--acento: #4ADE9B;
|
||||
--acento-claro: #6EE7B7;
|
||||
--texto: #E2E8F0;
|
||||
--texto-suave: #94A3B8;
|
||||
--fondo: #0B1220;
|
||||
--hoja: #131C2B;
|
||||
--borde: #2A3648;
|
||||
--zebra: #182233;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--fondo);
|
||||
color: var(--texto);
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.barra {
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
padding: 12px max(20px, calc((100vw - 1040px) / 2));
|
||||
background: var(--hoja);
|
||||
border-bottom: 3px solid var(--acento);
|
||||
}
|
||||
.barra img { height: 38px; display: block; }
|
||||
.barra .rotulo { display: flex; flex-direction: column; line-height: 1.25; margin-right: auto; }
|
||||
.barra .rotulo strong { font-size: 15px; letter-spacing: .2px; }
|
||||
.barra .rotulo span { font-size: 12.5px; color: var(--texto-suave); }
|
||||
@media (max-width: 640px) { .barra .rotulo span { display: none; } }
|
||||
.barra a.boton {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 9px 16px; border-radius: 999px;
|
||||
background: var(--marca); color: #fff; text-decoration: none;
|
||||
font-size: 14px; font-weight: 600; white-space: nowrap;
|
||||
}
|
||||
.barra a.boton:hover { filter: brightness(1.12); }
|
||||
@media (prefers-color-scheme: dark) { .barra a.boton { color: #0B1220; } }
|
||||
|
||||
main { max-width: 1040px; margin: 28px auto 56px; padding: 0 20px; }
|
||||
.hoja {
|
||||
background: var(--hoja);
|
||||
border: 1px solid var(--borde);
|
||||
border-radius: 14px;
|
||||
padding: 44px 52px 36px;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, .06), 0 12px 32px -18px rgba(15, 23, 42, .25);
|
||||
}
|
||||
@media (max-width: 720px) { .hoja { padding: 26px 20px; border-radius: 10px; } }
|
||||
|
||||
.sello {
|
||||
display: inline-block; margin: 0 0 18px;
|
||||
padding: 5px 12px; border-radius: 999px;
|
||||
background: var(--zebra); border: 1px solid var(--borde);
|
||||
font-size: 12px; letter-spacing: .4px; text-transform: uppercase; color: var(--texto-suave);
|
||||
}
|
||||
h1 { font-size: 30px; line-height: 1.22; margin: 0 0 6px; color: var(--acento); letter-spacing: -.4px; }
|
||||
p.bajada { font-size: 17px; color: var(--texto-suave); margin: 0 0 26px; }
|
||||
|
||||
.ficha { border: 1px solid var(--borde); border-radius: 10px; overflow: hidden; margin: 0 0 34px; }
|
||||
.ficha div { display: flex; gap: 16px; padding: 10px 16px; border-bottom: 1px solid var(--borde); }
|
||||
.ficha div:last-child { border-bottom: none; }
|
||||
.ficha div:nth-child(odd) { background: var(--zebra); }
|
||||
.ficha dt {
|
||||
flex: 0 0 210px; margin: 0; font-size: 12px; letter-spacing: .5px;
|
||||
text-transform: uppercase; color: var(--texto-suave); padding-top: 3px;
|
||||
}
|
||||
.ficha dd { margin: 0; font-weight: 600; font-size: 14.5px; }
|
||||
@media (max-width: 620px) { .ficha div { display: block; } .ficha dt { margin-bottom: 2px; } }
|
||||
|
||||
h2 {
|
||||
font-size: 21px; color: var(--acento); margin: 44px 0 14px;
|
||||
padding-bottom: 8px; border-bottom: 2px solid var(--acento-claro);
|
||||
scroll-margin-top: 80px;
|
||||
}
|
||||
h3 { font-size: 17px; color: var(--acento-claro); margin: 30px 0 10px; }
|
||||
p { margin: 0 0 14px; }
|
||||
ul, ol { margin: 0 0 16px; padding-left: 24px; }
|
||||
li { margin-bottom: 6px; }
|
||||
strong { color: var(--acento); }
|
||||
@media (prefers-color-scheme: dark) { strong { color: var(--texto); } }
|
||||
hr { border: none; border-top: 1px solid var(--borde); margin: 34px 0; }
|
||||
|
||||
.tabla-scroll { overflow-x: auto; margin: 16px 0 26px; border: 1px solid var(--borde); border-radius: 10px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
th {
|
||||
background: var(--acento); color: #fff; text-align: left;
|
||||
padding: 10px 12px; font-size: 13px; font-weight: 600; white-space: nowrap;
|
||||
}
|
||||
td { padding: 9px 12px; border-top: 1px solid var(--borde); vertical-align: top; }
|
||||
tbody tr:nth-child(even) td { background: var(--zebra); }
|
||||
td:first-child { white-space: nowrap; }
|
||||
@media (max-width: 620px) { td:first-child { white-space: normal; } }
|
||||
|
||||
footer.pie {
|
||||
max-width: 1040px; margin: 0 auto 40px; padding: 0 20px;
|
||||
font-size: 13px; color: var(--texto-suave); text-align: center;
|
||||
}
|
||||
@media print {
|
||||
body { background: #fff; }
|
||||
.barra { position: static; border-bottom: 1px solid #ccc; }
|
||||
.barra a.boton { display: none; }
|
||||
main { max-width: none; margin: 0; padding: 0; }
|
||||
.hoja { border: none; box-shadow: none; padding: 0; }
|
||||
h2 { break-after: avoid; }
|
||||
.tabla-scroll { overflow: visible; border: none; }
|
||||
table { font-size: 11pt; }
|
||||
tr { break-inside: avoid; }
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def envolver_tablas(cuerpo: str) -> str:
|
||||
"""Las tablas se envuelven para poder desplazarse en pantallas chicas."""
|
||||
cuerpo = cuerpo.replace('<table>', '<div class="tabla-scroll"><table>')
|
||||
return cuerpo.replace('</table>', '</table></div>')
|
||||
|
||||
|
||||
def ficha_html(datos: list[tuple[str, str]]) -> str:
|
||||
if not datos:
|
||||
return ''
|
||||
filas = '\n'.join(
|
||||
f' <div><dt>{html_mod.escape(etiqueta)}</dt><dd>{html_mod.escape(valor)}</dd></div>'
|
||||
for etiqueta, valor in datos
|
||||
)
|
||||
return f' <dl class="ficha">\n{filas}\n </dl>\n'
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description='Publica un documento de docs/ como página web')
|
||||
parser.add_argument('markdown', type=pathlib.Path, help='ruta del .md de entrada')
|
||||
parser.add_argument('--slug', required=True, help='carpeta de la ruta privada (p. ej. alcance-2f9k7d)')
|
||||
parser.add_argument('--salida', type=pathlib.Path, help='carpeta destino (por defecto docs/web/<slug>)')
|
||||
args = parser.parse_args()
|
||||
|
||||
generador = cargar_generador_pdf()
|
||||
origen = args.markdown.resolve()
|
||||
destino = args.salida or RAIZ / 'docs' / 'web' / args.slug
|
||||
destino.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
titulo, subtitulo, datos, cuerpo_md = generador.partir_encabezado(origen.read_text(encoding='utf-8'))
|
||||
principal, _, secundario = titulo.partition(' — ')
|
||||
|
||||
# El PDF es la versión para imprimir y compartir: se genera aquí mismo.
|
||||
# Conserva el nombre del markdown de origen (sin prefijo duplicado del título).
|
||||
nombre_pdf = f'{origen.stem}.pdf'
|
||||
ruta_pdf = destino / nombre_pdf
|
||||
subprocess.run(
|
||||
[sys.executable, str(SCRIPT_PDF), str(origen), '--salida', str(ruta_pdf)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
logo_destino = destino / 'logo.png'
|
||||
shutil.copy2(generador.logo_con_transparencia(), logo_destino)
|
||||
|
||||
cuerpo = envolver_tablas(markdown.markdown(cuerpo_md, extensions=['tables', 'sane_lists', 'attr_list']))
|
||||
anio = next((v for k, v in datos if 'fecha' in k.lower()), '')
|
||||
pie = f'{PROVEEDOR} · {anio}' if anio else PROVEEDOR
|
||||
|
||||
pagina = f"""<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>{html_mod.escape(principal)} — {html_mod.escape(secundario or subtitulo)}</title>
|
||||
<link rel="icon" href="logo.png" />
|
||||
<style>{CSS_WEB}</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="barra">
|
||||
<img src="logo.png" alt="CRM360 FRASA" />
|
||||
<div class="rotulo">
|
||||
<strong>{html_mod.escape(principal)}</strong>
|
||||
<span>{html_mod.escape(secundario or subtitulo)}</span>
|
||||
</div>
|
||||
<a class="boton" href="{nombre_pdf}" download>Descargar PDF</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<article class="hoja">
|
||||
<p class="sello">Documento confidencial · Preparado para el cliente</p>
|
||||
<h1>{html_mod.escape(principal)}</h1>
|
||||
<p class="bajada">{html_mod.escape(secundario or subtitulo)}</p>
|
||||
{ficha_html(datos)}{cuerpo}
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<footer class="pie">Documento preparado por {PROVEEDOR}</footer>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
(destino / 'index.html').write_text(pagina, encoding='utf-8')
|
||||
|
||||
print(f'Página generada: {destino / "index.html"} ({(destino / "index.html").stat().st_size} bytes)')
|
||||
print(f'PDF para descarga: {ruta_pdf} ({ruta_pdf.stat().st_size} bytes)')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user