Back to flin
flin

El error de renderizado de espacios en blanco HTML

Cómo el lexer de FLIN estaba eliminando silenciosamente los espacios entre texto y enlaces dinámicos, causando que 23 pruebas temporales fallaran con lógica correcta.

Juste Thales Gnimavo & Claude | March 26, 2026 6 min flin
EN/ FR/ ES
flinrust

Hay una frustración especial reservada para los errores donde la lógica es correcta pero la salida es incorrecta. Has verificado cada paso intermedio. Los datos fluyen por el sistema con precisión. La computación produce los valores correctos. Y sin embargo el resultado final, lo que el usuario realmente ve, está sutilmente roto -- no por un error de lógica sino por un defecto de renderizado.

El 7 de enero de 2026, acabábamos de corregir el error de manejo de None que desbloqueó el modelo temporal de FLIN. Cuatro de nuestras 27 pruebas de integración temporal ahora pasaban. Las 23 restantes fallaban, y a primera vista, los fallos parecían errores de lógica temporal. Pero no lo eran. Los valores temporales eran todos correctos. El error estaba en cómo se manejaban los espacios en blanco entre texto estático y enlaces dinámicos en la salida HTML.

El síntoma

Considera esta plantilla FLIN:

flin<div>
    Current: {user.name}<br>
    Previous: {previous.name}
</div>

Un desarrollador que escribe esto espera que la salida diga "Current: Alice Smith" con un espacio entre los dos puntos y el nombre. Las aserciones de las pruebas reflejaban esta expectativa:

rustassert_output_contains(&output, "Current: Alice Smith");

Pero la salida HTML real era:

html<div>Current:<span data-flin-bind="user.name">Alice Smith</span><br>
Previous:<span data-flin-bind="previous.name">Alice</span></div>

Sin espacio antes de la etiqueta <span>. "Current:" se pegaba directamente al enlace dinámico. Los valores temporales "Alice Smith" y "Alice" eran perfectamente correctos -- el error no tenía nada que ver con el modelo temporal. Era un defecto de renderizado que hacía que valores correctos parecieran incorrectos en las aserciones de las pruebas.

La causa raíz

El problema residía en el lexer de FLIN, específicamente en cómo manejaba los nodos de texto durante el escaneo en modo vista. Cuando el lexer encontraba contenido de texto en una plantilla, llamaba a .trim() sobre todo el nodo de texto:

rust// src/lexer/scanner.rs:1413 -- ANTES
let trimmed = text.trim();
if !trimmed.is_empty() {
    self.add_token(TokenKind::Text(trimmed.to_string()));
}

El método .trim() elimina todos los espacios en blanco iniciales y finales de una cadena. Para el nodo de texto "Current: ", esto eliminaba el espacio final, produciendo "Current:". El espacio que debería haber aparecido entre el texto estático y el enlace dinámico era consumido silenciosamente por el lexer.

Espacios en blanco en línea vs. multilínea

La idea clave fue que los espacios en blanco en las plantillas tienen dos significados diferentes dependiendo del contexto:

Espacios de indentación son formateo. Existen porque el desarrollador indentó su plantilla para legibilidad. Deben eliminarse.

Espacios en línea son contenido. Existen porque el desarrollador quiere un espacio entre elementos. Deben preservarse.

La diferencia es si el texto contiene saltos de línea. Los nodos de texto multilínea están formateados con indentación y deben recortarse. Los nodos de texto en línea son contenido y deben preservarse exactamente.

El algoritmo de recorte inteligente

Reemplazamos el .trim() general con una función smart_trim_text() que maneja ambos casos:

rustfn smart_trim_text(&self, text: &str) -> String {
    if !text.contains('\n') {
        // Texto en línea -- preservar todos los espacios
        return text.to_string();
    }

    // Texto multilínea -- recortar indentación
    let mut result = text.to_string();

    // Eliminar saltos de línea iniciales y su indentación siguiente
    loop {
        let before = result.len();
        result = result.trim_start_matches('\n')
                       .trim_start_matches('\r')
                       .to_string();
        if result.starts_with(' ') || result.starts_with('\t') {
            result = result.trim_start().to_string();
        }
        if result.len() == before { break; }
    }

    // Eliminar saltos de línea finales y su indentación precedente
    loop {
        let before = result.len();
        result = result.trim_end_matches('\n')
                       .trim_end_matches('\r')
                       .to_string();
        if result.ends_with(' ') || result.ends_with('\t') {
            result = result.trim_end().to_string();
        }
        if result.len() == before { break; }
    }

    result
}

El algoritmo es simple: si el texto no contiene saltos de línea, devolverlo sin cambios. Si contiene saltos de línea, eliminar iterativamente las secuencias de salto de línea más indentación iniciales y finales. Esto preserva los espacios en línea mientras elimina la indentación de formateo.

Ejemplos

EntradaSalidaRazón
"Current: ""Current: "Sin saltos de línea, preservar todo
"\n Hello\n ""Hello"Tiene saltos de línea, recortar indentación
"Hello ""Hello "Sin saltos de línea, preservar espacio final
" inline "" inline "Sin saltos de línea, preservar todo

Los resultados

El impacto fue inmediato y dramático:

MétricaAntesDespués
Pruebas temporales pasando4/27 (15%)11/27 (41%)
Pruebas de biblioteca1.010/1.0101.010/1.010
Espacios preservadosNoSí

Siete pruebas temporales adicionales comenzaron a pasar, no porque cambiamos alguna lógica temporal, sino porque el renderizado ahora mostraba correctamente los valores temporales que habían sido correctos todo el tiempo.

Las 16 pruebas restantes que fallaban eran problemas de lógica genuinos -- funcionalidades no implementadas como el acceso a la propiedad .history, el renderizado condicional con valores temporales, y casos límite para acceso temporal fuera de rango. Estos eran elementos de trabajo reales, no artefactos de renderizado.

La cadena de errores

Este fue el tercer error en una cadena que bloqueaba el modelo temporal:

  1. Error de manejo de None (Sesión 070): El acceso a propiedades en None lanzaba TypeError en lugar de propagar. Corregido con 10 líneas en la VM.
  1. Error de seguimiento de versiones (Sesión 073): Las versiones de las entidades no se sincronizaban desde la base de datos a la VM después de guardar, causando que el acceso temporal calculara versiones objetivo incorrectas. Corregido obteniendo los datos de la entidad después de guardar.
  1. Error de renderizado de espacios (Sesión 074): El lexer recortaba espacios en línea entre texto estático y enlaces dinámicos. Corregido con smart_trim_text().

Cada error era independiente. Cada uno tenía una causa raíz diferente en un componente diferente (VM, sincronización de base de datos, lexer). Sin embargo, juntos formaban una cadena que hacía que el modelo temporal pareciera completamente roto cuando, de hecho, la lógica temporal había estado funcionando correctamente desde la Sesión 070. Las correcciones fueron acumulativas -- cada una llevó más pruebas de fallar a pasar, revelando gradualmente que los cimientos eran sólidos y solo la superficie necesitaba reparación.


Esta es la Parte 159 de la serie "Cómo construimos FLIN", que documenta cómo un CEO en Abidjan y un CTO de IA diseñaron y construyeron un lenguaje de programación desde cero.

Navegación de la serie: - [158] El error de manejo de None - [159] El error de renderizado de espacios en blanco HTML (estás aquí) - [160] Cuando la VM se bloqueó en la creación de entidades

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles

Thales & Claude deblo

El prompt decía que lo anunciara. El modelo no lo hizo. Así que lo hizo el worker: cerrar un silencio de 15 segundos en Déblo Voice

Déblo Voice se quedaba en silencio unos 15 segundos cada vez que una pregunta difícil activaba una búsqueda profunda. El prompt ya exigía un anuncio, pero el modelo de audio nativo emite la llamada a la función como su primer token. Además, el silencio tenía dos partes, la ejecución de la herramienta y la latencia de la respuesta, así que ningún prompt podía cerrarlo. Llevamos la garantía al worker de LiveKit: un anuncio pregrabado con la voz y el idioma de la sesión, y un suave sonido de tecleo mientras el agente piensa. Probado en producción el 16 de octubre de 2026: 14 y 17,5 segundos de silencio del lado del modelo, unos 0,4 segundos percibidos por el usuario.

13 min Oct 16, 2026
deblodeblo-voicevoice-aigemini-live +17
Thales & Claude zerosuite

El servidor que no dimos de baja: vaciar una máquina de producción en una noche, y cada número que comprobamos

Una noche, seis aplicaciones migradas fila por fila, tres copias de seguridad vacías, una sin destino y un servidor cuyo futuro cambió tres veces.

19 min Oct 2, 2026
caspclaude-codeclaude-opus-5.5infrastructure +10
Thales & Claude zerosuite

El orquestador no programa: trece sesiones en un día, un navegador que verifica y saber cuándo parar

Un día, una plataforma privada de un cliente, trece sesiones headless de Claude encadenadas por un orquestador que nunca escribe código. Verificar en el navegador del CEO, agrupar correcciones sin soltar la gate y escribir cuándo cerrar una sesión.

19 min Sep 30, 2026
caspclaude-codeclaude-opus-5.5multi-session +12