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:
@@ -4,8 +4,8 @@
|
|||||||
# index.html siempre se revalida (los assets de Vite llevan hash en el nombre,
|
# index.html siempre se revalida (los assets de Vite llevan hash en el nombre,
|
||||||
# así que solo el HTML necesita no-cache para que los deploys se vean al momento)
|
# así que solo el HTML necesita no-cache para que los deploys se vean al momento)
|
||||||
map $uri $frasa_cache_control {
|
map $uri $frasa_cache_control {
|
||||||
~*\.html$ "no-cache";
|
~*\.html$ "no-cache, no-store, must-revalidate";
|
||||||
default "";
|
default "public, max-age=31536000, immutable";
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
@@ -56,6 +56,17 @@ server {
|
|||||||
try_files $uri $uri/ /hoy/index.html;
|
try_files $uri $uri/ /hoy/index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Documento entregable publicado como página web privada.
|
||||||
|
# NO se enlaza desde ninguna app: sólo se llega con el enlace exacto, y lleva
|
||||||
|
# noindex para que no lo indexe ningún buscador. Se regenera con
|
||||||
|
# scripts/generar-web-documento.py
|
||||||
|
location /doc/alcance-2f9k7d/ {
|
||||||
|
alias /root/frasa-ecosystem/docs/web/alcance-2f9k7d/;
|
||||||
|
index index.html;
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||||
|
add_header X-Robots-Tag "noindex, nofollow, noarchive" always;
|
||||||
|
}
|
||||||
|
|
||||||
# Landing (raíz)
|
# Landing (raíz)
|
||||||
location / {
|
location / {
|
||||||
root /root/frasa-ecosystem/apps/landing/dist;
|
root /root/frasa-ecosystem/apps/landing/dist;
|
||||||
|
|||||||
Binary file not shown.
BIN
docs/web/alcance-2f9k7d/ALCANCE-VS-ENTREGADO.pdf
Normal file
BIN
docs/web/alcance-2f9k7d/ALCANCE-VS-ENTREGADO.pdf
Normal file
Binary file not shown.
1342
docs/web/alcance-2f9k7d/index.html
Normal file
1342
docs/web/alcance-2f9k7d/index.html
Normal file
File diff suppressed because it is too large
Load Diff
BIN
docs/web/alcance-2f9k7d/logo.png
Normal file
BIN
docs/web/alcance-2f9k7d/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
@@ -38,14 +38,22 @@ COLOR_FONDO_LOGO = (247, 247, 247)
|
|||||||
TOLERANCIA_FONDO = 8
|
TOLERANCIA_FONDO = 8
|
||||||
|
|
||||||
|
|
||||||
|
DIMENSION_LOGO = (480, 360)
|
||||||
|
|
||||||
|
|
||||||
def logo_con_transparencia() -> pathlib.Path:
|
def logo_con_transparencia() -> pathlib.Path:
|
||||||
"""El logo del repo trae fondo gris; se aplana a alfa 0 desde los bordes.
|
"""El logo del repo trae fondo gris; se aplana a alfa 0 desde los bordes.
|
||||||
|
|
||||||
Se rellena solo desde el exterior para no borrar los grises interiores del
|
Se rellena solo desde el exterior para no borrar los grises interiores del
|
||||||
arte (contrafuertes de letras). Sin transparencia, la portada blanca dibuja
|
arte (contrafuertes de letras). Sin transparencia, la portada blanca dibuja
|
||||||
un rectángulo gris alrededor del logotipo.
|
un rectángulo gris alrededor del logotipo.
|
||||||
|
|
||||||
|
La salida se reduce a `DIMENSION_LOGO`: el original de 959 px pesa 440 KB
|
||||||
|
(viene de un JPEG, con ruido que el PNG no comprime) y se dibuja a 24 mm, así
|
||||||
|
que sobra resolución. El nombre del archivo incluye el tamaño para no reusar
|
||||||
|
una versión vieja con otras medidas.
|
||||||
"""
|
"""
|
||||||
destino = pathlib.Path(tempfile.gettempdir()) / 'frasa-logo-transparente.png'
|
destino = pathlib.Path(tempfile.gettempdir()) / f'frasa-logo-transparente-{DIMENSION_LOGO[0]}x{DIMENSION_LOGO[1]}.png'
|
||||||
if destino.exists():
|
if destino.exists():
|
||||||
return destino
|
return destino
|
||||||
|
|
||||||
@@ -83,7 +91,8 @@ def logo_con_transparencia() -> pathlib.Path:
|
|||||||
if y < alto - 1:
|
if y < alto - 1:
|
||||||
cola.append((x, y + 1))
|
cola.append((x, y + 1))
|
||||||
|
|
||||||
imagen.save(destino)
|
imagen.thumbnail(DIMENSION_LOGO)
|
||||||
|
imagen.save(destino, optimize=True)
|
||||||
return destino
|
return destino
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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