commit - 4b3dbef4eb99c9a691e02a0ec65087a5a749c7d0
commit + eabf2e720ed1f9f352c93c56ace8614e9237a0c0
blob - 4f8dd7a0d1455b9bb9a8d6959c55c7b3ed419651
blob + 66e57bc2c10ac49bd82035c108a8988c79bc7773
--- README.md
+++ README.md
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
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
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
+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"),
super().__init__()
self._initial_text = initial_text
self._title = title
+ self._debounce_timer = None
def compose(self):
with Vertical():
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:
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)
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
+"""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
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
"textual>=0.80",
"pyfiglet>=1.0",
"cowsay>=6.1",
+ "textual-image>=0.8",
+ "matplotlib>=3.8",
]
[project.scripts]