Commit Diff


commit - 4b3dbef4eb99c9a691e02a0ec65087a5a749c7d0
commit + eabf2e720ed1f9f352c93c56ace8614e9237a0c0
blob - 4f8dd7a0d1455b9bb9a8d6959c55c7b3ed419651
blob + 66e57bc2c10ac49bd82035c108a8988c79bc7773
--- README.md
+++ README.md
@@ -34,7 +34,14 @@ Validada de punta a punta contra un clone local descar
 real): las 4 pantallas (post, page, album, add-photos), edición con preview
 de Markdown en vivo, commit+push real, y confirmado que las fotos quedan
 como punteros git-lfs reales (no blobs crudos). Pantalla de configuración
-(animal de cowsay, ruta del repo) también probada.
+(animal de cowsay, ruta del repo) también probada. Editar entradas/álbumes
+existentes también probado.
 
+El preview del editor, además de Markdown, renderiza fórmulas LaTeX (subset
+mathtext de matplotlib) como imagen y muestra las imágenes referenciadas
+—locales del repo (`![]()`, shortcodes `photo()`/`gallery()` de Zola) o
+remotas por URL (shortcode `image()`)— usando el protocolo de gráficos de
+la terminal (requiere Kitty, iTerm2 o soporte Sixel; si no, cae a texto).
+
 Todavía no reemplaza a `website/scripts/ctl.py` en el uso diario — eso
 queda a decisión de Ale, una vez que la pruebe él mismo contra el repo real.
blob - 8a11d497d5c1f1169a6a3475579031de503e3ddb
blob + 4164dc12c58412d437c1704a8d0317575b8e7642
--- flake.nix
+++ flake.nix
@@ -12,11 +12,17 @@
     in
     {
       devShells.${system}.default = pkgs.mkShell {
-        # Deps del proyecto (textual/pyfiglet/cowsay) van por pip/pyproject.toml,
-        # no por nixpkgs: cowsay no está empaquetado ahí, y mezclar deps de nix
-        # con deps de pip para el mismo proyecto es frágil. Nix solo fija la
-        # versión de Python.
-        packages = [ pkgs.python313 ];
+        # Deps del proyecto (textual/pyfiglet/cowsay/matplotlib/textual-image)
+        # van por pip/pyproject.toml, no por nixpkgs: cowsay no está
+        # empaquetado ahí, y mezclar deps de nix con deps de pip para el mismo
+        # proyecto es frágil. Nix solo fija la versión de Python + las libs de
+        # sistema que los wheels compilados (numpy, vía matplotlib) esperan
+        # encontrar en rutas estándar que en NixOS no existen.
+        packages = [
+          pkgs.python313
+          pkgs.stdenv.cc.cc.lib  # libstdc++.so.6 — lo pide numpy al importar
+          pkgs.zlib
+        ];
 
         shellHook = ''
           if [ ! -d .venv ]; then
@@ -24,6 +30,7 @@
             python3 -m venv .venv
           fi
           source .venv/bin/activate
+          export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc.lib pkgs.zlib ]}:$LD_LIBRARY_PATH"
           pip install -q -e . --disable-pip-version-check
           echo "nezzontli-ctl listo. Corré 'ctl' o 'python3 -m nezzontli_ctl'."
           echo "Apunta a: ''${NEZZONTLI_REPO_PATH:-$HOME/projects/website}"
blob - 77afa72c4c380a08898428d2c7d5b293c68b7161
blob + c904fa90e277c1465f58d003acc673ab88e4b3bd
--- nezzontli_ctl/screens/editor.py
+++ nezzontli_ctl/screens/editor.py
@@ -1,11 +1,26 @@
+import asyncio
+
 from textual.containers import Horizontal, Vertical, VerticalScroll
 from textual.screen import ModalScreen
 from textual.widgets import Footer, Markdown, Static, TextArea
 
+from nezzontli_ctl import preview as preview_mod
 
+try:
+    from textual_image.widget import AutoImage
+except ImportError:  # pragma: no cover — el preview cae a texto sin esto
+    AutoImage = None
+
+# Debounce del rebuild del preview: correr el tokenizer + renderizar
+# LaTeX/imágenes en cada tecla sería carísimo, así que se espera una pausa
+# corta en el tipeo antes de reconstruirlo.
+_PREVIEW_DEBOUNCE = 0.5
+
+
 class EditorScreen(ModalScreen[str]):
-    """Editor de cuerpo Markdown con preview en vivo. Devuelve el texto final
-    al llamador (ctrl+s / botón Listo) o None si se cancela (escape)."""
+    """Editor de cuerpo Markdown con preview en vivo (Markdown + LaTeX
+    renderizado + imágenes locales/remotas). Devuelve el texto final al
+    llamador (ctrl+s / botón Listo) o None si se cancela (escape)."""
 
     BINDINGS = [
         ("ctrl+s", "submit", "Listo"),
@@ -16,6 +31,7 @@ class EditorScreen(ModalScreen[str]):
         super().__init__()
         self._initial_text = initial_text
         self._title = title
+        self._debounce_timer = None
 
     def compose(self):
         with Vertical():
@@ -28,8 +44,7 @@ class EditorScreen(ModalScreen[str]):
                     id="editor-textarea",
                     classes="editor-pane",
                 )
-                with VerticalScroll(id="editor-preview-scroll", classes="editor-pane"):
-                    yield Markdown(self._initial_text, id="editor-preview")
+                yield VerticalScroll(id="editor-preview-scroll", classes="editor-pane")
         yield Footer()
 
     def on_mount(self) -> None:
@@ -37,6 +52,9 @@ class EditorScreen(ModalScreen[str]):
         text_area.focus()
         text_area.cursor_location = text_area.document.end
         self.watch(text_area, "scroll_y", self._sync_preview_scroll)
+        self.run_worker(
+            self._rebuild_preview(self._initial_text), exclusive=True, group="preview"
+        )
 
     def _sync_preview_scroll(self, old_value: float, new_value: float) -> None:
         text_area = self.query_one("#editor-textarea", TextArea)
@@ -48,9 +66,48 @@ class EditorScreen(ModalScreen[str]):
         preview_scroll.scroll_y = ratio * preview_scroll.max_scroll_y
 
     def on_text_area_changed(self, event: TextArea.Changed) -> None:
-        preview = self.query_one("#editor-preview", Markdown)
-        preview.update(event.text_area.text)
+        if self._debounce_timer is not None:
+            self._debounce_timer.stop()
+        text = event.text_area.text
+        self._debounce_timer = self.set_timer(
+            _PREVIEW_DEBOUNCE, lambda: self._start_rebuild(text)
+        )
 
+    def _start_rebuild(self, text: str) -> None:
+        self.run_worker(self._rebuild_preview(text), exclusive=True, group="preview")
+
+    async def _rebuild_preview(self, text: str) -> None:
+        """Tokeniza `text` y remonta el preview como una mezcla de widgets
+        Markdown (texto normal) e Image (fórmulas LaTeX renderizadas a PNG,
+        imágenes locales del repo o remotas por URL)."""
+        segments = await asyncio.to_thread(preview_mod.tokenize, text)
+        widgets = []
+        for segment in segments:
+            kind = segment[0]
+            if kind == "text":
+                if segment[1].strip():
+                    widgets.append(Markdown(segment[1]))
+            elif kind == "math":
+                _, tex, is_display = segment
+                png_path = await asyncio.to_thread(preview_mod.render_math, tex, is_display)
+                if png_path is not None and AutoImage is not None:
+                    widgets.append(AutoImage(str(png_path), classes="preview-math"))
+                else:
+                    widgets.append(Static(f"[$] {tex}", classes="preview-fallback"))
+            elif kind == "image":
+                _, ref, alt = segment
+                img_path = await asyncio.to_thread(preview_mod.resolve_image, ref)
+                if img_path is not None and AutoImage is not None:
+                    widgets.append(AutoImage(str(img_path), classes="preview-image"))
+                else:
+                    widgets.append(Static(f"[img no encontrada: {ref}]", classes="preview-fallback"))
+                if alt:
+                    widgets.append(Static(alt, classes="preview-caption"))
+
+        preview_scroll = self.query_one("#editor-preview-scroll", VerticalScroll)
+        await preview_scroll.remove_children()
+        await preview_scroll.mount_all(widgets or [Markdown("")])
+
     def action_submit(self) -> None:
         text = self.query_one("#editor-textarea", TextArea).text
         self.dismiss(text)
blob - /dev/null
blob + 57cc5600720bd7c08d03c9adbcce665ab366bf00 (mode 644)
--- /dev/null
+++ nezzontli_ctl/preview.py
@@ -0,0 +1,145 @@
+"""Preview enriquecido para EditorScreen: parte el Markdown/Zola en
+segmentos (texto, fórmulas LaTeX, imágenes locales o remotas) para poder
+mezclar widgets Markdown + Image en la pantalla de edición.
+
+Nada de esto lo usa el build real del sitio (Zola) — es solo para que la
+TUI muestre una aproximación visual de lo que va a salir.
+"""
+
+import hashlib
+import re
+import urllib.request
+from pathlib import Path
+
+from nezzontli_ctl.config import REPO_ROOT
+
+CACHE_DIR = Path.home() / ".cache" / "nezzontli-ctl" / "preview"
+
+_DISPLAY_MATH_RE = re.compile(r"\$\$(.+?)\$\$", re.DOTALL)
+_INLINE_MATH_RE = re.compile(r"(?<!\$)\$(?!\$)(.+?)(?<!\$)\$(?!\$)")
+_MD_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
+_GALLERY_RE = re.compile(r"\{\{\s*gallery\(photos\s*=\s*\[(.*?)\]\s*\)\s*\}\}", re.DOTALL)
+_PHOTO_RE = re.compile(r"\{\{\s*photo\(([^)]*)\)\s*\}\}")
+_IMAGE_SHORTCODE_RE = re.compile(r"\{\{\s*image\(([^)]*)\)\s*\}\}")
+_QUOTED_RE = re.compile(r'"((?:[^"\\]|\\.)*)"')
+_KV_RE = re.compile(r'(\w+)\s*=\s*"((?:[^"\\]|\\.)*)"')
+
+
+def _parse_kv_args(raw):
+    return {m.group(1): m.group(2) for m in _KV_RE.finditer(raw)}
+
+
+def _resolve_local_image(path_str):
+    """'/images/x/y.jpg' o 'images/x/y.jpg' -> Path bajo REPO_ROOT/static,
+    o None si el archivo no existe."""
+    candidate = REPO_ROOT / "static" / path_str.lstrip("/")
+    return candidate if candidate.is_file() else None
+
+
+def _cache_path_for_url(url, prefix="remote"):
+    CACHE_DIR.mkdir(parents=True, exist_ok=True)
+    digest = hashlib.sha256(url.encode("utf-8")).hexdigest()[:24]
+    suffix = Path(url.split("?")[0]).suffix or ".img"
+    return CACHE_DIR / f"{prefix}-{digest}{suffix}"
+
+
+def _fetch_remote_image(url, timeout=5):
+    cached = _cache_path_for_url(url)
+    if cached.is_file():
+        return cached
+    # Sin User-Agent, varios hosts (ej. Wikimedia) devuelven 403.
+    req = urllib.request.Request(url, headers={"User-Agent": "nezzontli-ctl-preview/1.0"})
+    try:
+        with urllib.request.urlopen(req, timeout=timeout) as resp:  # noqa: S310
+            data = resp.read()
+    except Exception:
+        return None
+    cached.write_bytes(data)
+    return cached
+
+
+def resolve_image(path_or_url):
+    """Devuelve un Path local a la imagen (descargándola a caché si es
+    remota) o None si no se pudo resolver."""
+    if path_or_url.startswith("http://") or path_or_url.startswith("https://"):
+        return _fetch_remote_image(path_or_url)
+    return _resolve_local_image(path_or_url)
+
+
+def render_math(tex, display=False):
+    """Renderiza una fórmula LaTeX-ish (sintaxis mathtext de matplotlib,
+    subconjunto de LaTeX) a PNG, cacheado por contenido. Devuelve el Path
+    del PNG o None si matplotlib no pudo parsearla."""
+    from matplotlib import mathtext
+
+    CACHE_DIR.mkdir(parents=True, exist_ok=True)
+    key = f"{'display' if display else 'inline'}:{tex}"
+    digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:24]
+    cached = CACHE_DIR / f"math-{digest}.png"
+    if cached.is_file():
+        return cached
+    try:
+        mathtext.math_to_image(
+            f"${tex}$", str(cached), dpi=200 if display else 150, color="#ebdbb2"
+        )
+    except Exception:
+        return None
+    return cached
+
+
+def tokenize(text):
+    """Parte `text` en segmentos ordenados:
+    ("text", markdown), ("math", tex, es_display), ("image", ruta_o_url, alt)
+    """
+    matches = []
+    for m in _GALLERY_RE.finditer(text):
+        matches.append((m.start(), m.end(), "gallery", m))
+    for m in _PHOTO_RE.finditer(text):
+        matches.append((m.start(), m.end(), "photo", m))
+    for m in _IMAGE_SHORTCODE_RE.finditer(text):
+        matches.append((m.start(), m.end(), "image_shortcode", m))
+    for m in _MD_IMAGE_RE.finditer(text):
+        matches.append((m.start(), m.end(), "md_image", m))
+    for m in _DISPLAY_MATH_RE.finditer(text):
+        matches.append((m.start(), m.end(), "display_math", m))
+
+    display_spans = [(s, e) for s, e, k, _ in matches if k == "display_math"]
+    for m in _INLINE_MATH_RE.finditer(text):
+        if any(s <= m.start() < e for s, e in display_spans):
+            continue
+        matches.append((m.start(), m.end(), "inline_math", m))
+
+    matches.sort(key=lambda t: t[0])
+
+    segments = []
+    cursor = 0
+    for start, end, kind, m in matches:
+        if start < cursor:
+            continue
+        if start > cursor:
+            segments.append(("text", text[cursor:start]))
+
+        if kind == "gallery":
+            for qm in _QUOTED_RE.finditer(m.group(1)):
+                path, _, alt = qm.group(1).partition("::")
+                segments.append(("image", path, alt))
+        elif kind == "photo":
+            kv = _parse_kv_args(m.group(1))
+            if "path" in kv:
+                segments.append(("image", kv["path"], kv.get("alt", "")))
+        elif kind == "image_shortcode":
+            kv = _parse_kv_args(m.group(1))
+            if "url" in kv:
+                segments.append(("image", kv["url"], kv.get("alt", "")))
+        elif kind == "md_image":
+            segments.append(("image", m.group(2), m.group(1)))
+        elif kind == "display_math":
+            segments.append(("math", m.group(1).strip(), True))
+        elif kind == "inline_math":
+            segments.append(("math", m.group(1).strip(), False))
+
+        cursor = end
+
+    if cursor < len(text):
+        segments.append(("text", text[cursor:]))
+    return segments
blob - 60f5416419df679aa787d2359d83a41de5734327
blob + 5d85569a77942f99e135bf07b56a067f914c0561
--- nezzontli_ctl/styles.tcss
+++ nezzontli_ctl/styles.tcss
@@ -49,6 +49,29 @@
     overflow-y: auto;
 }
 
+.preview-image {
+    height: 18;
+    width: auto;
+    margin: 1 0;
+}
+
+.preview-math {
+    height: 3;
+    width: auto;
+    margin: 1 0;
+}
+
+.preview-caption {
+    color: $text-muted;
+    text-style: italic;
+    margin: 0 0 1 0;
+}
+
+.preview-fallback {
+    color: $warning;
+    margin: 1 0;
+}
+
 #confirm-diff {
     height: 1fr;
     border: solid $panel;
blob - 226e4bd680918eddfb44ed2d44479ded5788a298
blob + c8284457787f04b8f369ee95704cf71ff6128bec
--- pyproject.toml
+++ pyproject.toml
@@ -7,6 +7,8 @@ dependencies = [
     "textual>=0.80",
     "pyfiglet>=1.0",
     "cowsay>=6.1",
+    "textual-image>=0.8",
+    "matplotlib>=3.8",
 ]
 
 [project.scripts]