Saltar a contenido

Gestor propio de colores de texto

Análisis del modelo para RF-009. Responde tres preguntas: por qué no se usa la función que Payload ya trae, qué habría que construir en su lugar, y qué riesgo real tiene que la lista de colores sea dinámica.

Todo lo que se afirma acá está verificado contra el código instalado, versión @payloadcms/richtext-lexical de Payload 3.88.

Por qué no se usa la función nativa

Payload trae TextStateFeature, pensada exactamente para esto: guardar atributos propios dentro de un nodo de texto y definirles estilos. La documentación de la propia función dice que los estilos no forman parte del contenido, "permitiendo migrar o adaptar los estilos más adelante". Suena a la solución.

Hay dos motivos para no usarla, y el segundo es el que decide.

Está marcada como experimental

node_modules/@payloadcms/richtext-lexical/dist/features/textState/feature.server.js lo declara en su propia documentación: "@experimental There may be breaking changes to this API". Construir encima de eso es aceptar que una actualización de Payload puede pedir retrabajo.

Por sí solo no alcanzaría para descartarla. Lo siguiente sí.

Descarta en silencio cualquier color que no esté en la lista de arranque

En dist/features/textState/textState.js, al registrar cada atributo:

const stateConfig = createState(stateKey, {
  parse: value => typeof value === 'string' && Object.keys(stateValues).includes(value)
    ? value
    : undefined
})

stateValues es la lista que se le pasó a la función cuando arrancó el servidor. Un valor que no esté ahí se convierte en undefined.

Dónde muerde eso, exactamente: en lexical, al cargar un documento, cada atributo registrado pasa por su parse (parseAndPruneNextUnknownState, en Lexical.dev.mjs). El valor deja de ser un dato crudo y pasa a ser el resultado de esa función. Si dio undefined, al volver a guardar ya no se escribe.

La secuencia concreta con una paleta administrable sería esta:

  1. Marketing agrega un cian a la colección.
  2. El selector lo ofrece, se aplica, se guarda. En pantalla se ve bien.
  3. El servidor sigue corriendo con la lista vieja, que no tiene el cian.
  4. Alguien reabre la página. El cian se convierte en undefined y el texto vuelve al color heredado.
  5. Si esa persona guarda, el color se perdió de verdad.

Es una pérdida silenciosa: nadie ve un error, el texto simplemente vuelve a negro. Es el mismo tipo de falla que costó contenido real en RF-007, donde una limpieza al guardar borraba lo cargado.

Dos precisiones importantes para no exagerar el problema:

  • La base no se toca al leer. Lo guardado sigue ahí hasta que alguien edite y guarde esa página. El storefront, que lee el JSON crudo, seguiría pintando el cian mientras tanto.
  • Reiniciar el servidor arregla la lista. Con lista fija en código y reinicio tras cada cambio, la función nativa funciona bien. El problema aparece únicamente al querer que la lista sea un dato administrable.

O sea: "lista dinámica + función nativa" es la única combinación que no cierra. Y es justo la que pide el requisito.

Qué hay que construir

La función nativa son tres piezas chicas. Reconstruirlas con la lista como dato es el trabajo.

Pieza Qué hace Cómo se resuelve
Extensión del lado del servidor Declara la función del editor y le pasa configuración al navegador createServerFeature, igual que la nativa. Unas veinte líneas
Registro del atributo Declara el atributo textColor del nodo de texto y cómo se interpreta al cargar createState con una comprobación permisiva: acepta cualquier texto. Acá está la diferencia con la nativa
Extensión del lado del navegador El botón de la barra, el selector de colores y la aplicación a la selección Componente propio, registrado en el importMap como ya se hace con los RowLabel
Pintado en el editor Que el texto se vea del color mientras se escribe El equivalente de StatePlugin: escucha cambios de nodo y aplica el color al elemento
Resolución al servir Cambiar el nombre del color por su valor en la respuesta de la API Gancho afterRead, mismo patrón que enmascararNavegacion.ts

No hay todavía ninguna extensión de editor propia en este repo; sí hay componentes propios del panel (RowLabel de menús, sliders, cabecera y pie), así que el mecanismo de registro ya es conocido y no hay que descubrirlo.

Un regalo de Lexical que conviene conocer

Un atributo que no está registrado no se toca: parseAndPruneNextUnknownState lo deja en unknownState tal cual vino y lo vuelve a escribir igual al guardar.

Eso significa que el contenido marcado con nuestro atributo sobrevive intacto a cualquier herramienta que no lo conozca: otro editor, un script de migración, una exportación. Solo quien lo registra puede perderlo, y eso lo controlamos nosotros.

Es un argumento a favor de guardar el color como atributo del nodo y no como CSS suelto en style: el CSS suelto lo puede pisar cualquier cosa que toque el estilo.

Dónde se inyecta el valor resuelto

El nombre del color se guarda; el valor se agrega al servir. Dónde se lo agrega no es indistinto, y es lo que decide si el hexadecimal puede terminar guardado en la base.

TextNode.exportJSON(), en Lexical.dev.mjs, arma el JSON del nodo con una lista fija:

exportJSON() {
  return {
    detail: this.getDetail(),
    format: this.getFormat(),
    mode: this.getMode(),
    style: this.getStyle(),
    text: this.getTextContent(),
    ...super.exportJSON()   // type, version y el estado del nodo ($)
  }
}

Lo que no está en esa lista se descarta. De ahí salen tres comportamientos distintos:

Dónde inyectar Al abrir el panel y guardar
Dentro de $ El estado del nodo preserva las claves desconocidas tal cual. El valor se escribiría en la base. Es la peor opción, y es contraintuitiva: la misma propiedad que protege al nombre perjudica al valor
En style Es un campo real del nodo: vuelve a la base. El color queda congelado, que es justo lo que el modelo evita
En una clave suelta, textColorValue El editor la descarta. No puede llegar a la base por ninguna vía

La clave suelta gana además por un motivo que no tiene que ver con el congelamiento. La vista previa del storefront lee Payload autenticada, con un usuario de servicio (storefront/src/lib/payload.ts). Eso obliga a elegir:

  • Resolver solo en lecturas anónimas, como hace el enmascarado de los menús, deja la vista previa sin colores mientras la página publicada sí los tiene.
  • Resolver en todas las lecturas escribiendo en style hace que el panel reciba el hexadecimal y lo guarde.

Con la clave suelta no hay que elegir: se resuelve siempre, para todos, y el editor la descarta.

Una sola precaución: el nombre de la clave no debe coincidir con ningún atributo del nodo declarado como plano. $updateStateFromJSON levanta al estado del nodo las claves sueltas cuyo nombre coincida con uno de esos, y en ese caso sí volvería a la base.

Alternativa considerada: escribir el valor al guardar

Nada lo impide técnicamente. El editor podría escribir el hexadecimal al aplicar el color, o un gancho beforeChange podría resolverlo antes de guardar. Lexical lo conservaría y Payload lo guardaría sin quejarse. Se descartó por tres motivos.

Congela el valor. Es exactamente la capacidad que el requisito pide: corregir un color en un lugar y que se refleje en todo el contenido. Con el valor escrito adentro, cada retoque de la paleta obligaría a recorrer el contenido de todas las páginas.

Se lee en un lugar y se escribe en muchos. El gancho de lectura es un único punto por donde pasa todo lo que sale del CMS. Escriben, en cambio, el panel, la API, la vista previa, un guion de importación, una migración: cada uno tendría que acordarse de resolver, y el que se olvide deja contenido a medias sin aviso. Si además lo escribe el navegador, el valor vale lo que valga la paleta que esa pestaña tenía cargada — una pestaña abierta desde ayer escribiría el color viejo.

Es la regla que salió cara en RF-007. Ahí un gancho de guardado "ordenaba" los datos y terminó borrando configuración real del editor. Lo que quedó escrito de esa experiencia es no escribir datos derivados al guardar: se guarda lo que la persona cargó, y lo que se deduce se deduce al leer.

La alternativa igual tiene un caso legítimo, y conviene reconocerlo: si se quisiera que una página publicada quede como una foto fiel de cómo se veía el día que se publicó, guardar el valor es lo correcto. Se eligió lo contrario —contenido vivo— porque el pedido es que un cambio de paleta alcance a lo ya publicado.

La asimetría juega a favor de la decisión tomada: congelar más adelante es recorrer el contenido una vez y escribir los valores, un trabajo simple. El camino inverso, de valores congelados a nombres, exige adivinar a qué color correspondía cada hexadecimal.

La pregunta del selector dinámico

La pregunta original: ¿qué riesgo hay en que la lista se lea de la colección y un color nuevo aparezca sin reiniciar el servidor?

Con extensión propia, el impedimento de la función nativa desaparece, porque la comprobación al cargar la escribimos nosotros y acepta cualquier texto. Quedan tres riesgos, los tres acotados.

Riesgo 1 — La comprobación permisiva deja pasar cualquier cosa

Es la contracara directa: si aceptamos cualquier texto para no perder colores nuevos, también aceptamos basura, nombres viejos y errores de tipeo.

No es grave, porque la validación se mueve de lugar. El nombre guardado no significa nada por sí solo: quien le da sentido es el gancho de lectura, que busca el color en la colección. Un nombre que no existe no encuentra valor, y el texto sale sin color. Se degrada sin romperse.

El principio es el mismo que en los menús: guardar es permisivo, servir es estricto. Nada se pierde y nada inválido llega al storefront.

Riesgo 2 — La barra del editor depende de una consulta

El selector tiene que pedir la lista al abrirse. Si esa consulta falla, el botón queda sin opciones. Es una molestia, no una pérdida: el contenido ya cargado no se ve afectado, y el selector se recupera solo al recargar.

Conviene igual: pedir la lista una vez por sesión del editor y no por cada apertura del menú, y que el botón muestre "sin colores disponibles" en vez de una lista vacía sin explicación.

Riesgo 3 — Colores huérfanos

Un color borrado o renombrado deja contenido apuntando a un nombre que ya no existe. Con la regla de arriba ese texto se dibuja sin color, que es lo previsto en RF-009, regla 3.

Lo que no conviene es bloquear el borrado de un color en uso, como sí se hace con los menús. Ahí la referencia rota rompía la navegación; acá solo cambia un color. El costo de buscar en todo el contenido de todas las páginas cada vez que alguien borra un color no se justifica.

Lo que no es un riesgo

El peso de la respuesta. Resolver el color al servir agrega unos pocos caracteres por nodo coloreado. El storefront cachea las respuestas de Payload, así que el costo se paga una vez cada tanto, no por visita. Verificado: las lecturas pasan por src/lib/payload.ts con un tiempo de revalidación por defecto de 60 segundos.

El costo de recorrer el contenido. El gancho de lectura recorre el árbol del contenido una vez por respuesta. Es el mismo orden de trabajo que ya hace el enmascarado de los menús.

Etapas propuestas

Las dos etapas entregan valor y la primera no es trabajo tirado.

Etapa 1 — Colorear, con paleta fija. Extensión propia con una lista corta declarada en código. Entrega la capacidad de colorear, que es lo que hoy no existe, y deja armada toda la maquinaria. La resolución al servir ya va acá.

Etapa 2 — Paleta administrable. La colección colors y el selector que la lee. La extensión no cambia: cambia de dónde sale la lista.

Si después de la etapa 1 se decide que una paleta fija alcanza, no se tiró nada.

Reversibilidad

Decisión Costo de revertirla
Construir extensión propia en vez de usar la nativa Bajo. El contenido queda igual en los dos casos: un atributo con el nombre del color. Cambiar de una a la otra es cambiar quién lo lee, no los datos
Guardar el nombre y no el valor Bajo en una dirección, alto en la otra. Pasar de nombre a valor congelado es un recorrido del contenido, una sola vez. Volver de valores congelados a nombres exige adivinar a qué color correspondía cada uno
Paleta como colección Bajo. Si se descarta, la lista vuelve a código y los nombres ya guardados siguen sirviendo
Resolver el valor al servir Bajo. Es un gancho de lectura: se saca y la respuesta vuelve a traer el nombre
Habilitar el color en todos los campos de texto enriquecido Medio. Sacarlo después deja contenido coloreado que deja de verse coloreado, sin aviso

La única decisión cara es guardar el valor en vez del nombre, que es justamente la que el requisito descarta.

Conclusión

La extensión propia no es la opción ambiciosa: es la única que soporta una paleta administrable. La nativa sirve solo con lista fija en código y reinicio, y encima es experimental.

El trabajo real es moderado — cinco piezas chicas, cuatro de las cuales son copia de lo que la nativa ya hace — y el riesgo del selector dinámico se reduce a degradarse bien cuando un color no existe, que es una regla de tres líneas en el gancho de lectura.