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:
- Marketing agrega un cian a la colección.
- El selector lo ofrece, se aplica, se guarda. En pantalla se ve bien.
- El servidor sigue corriendo con la lista vieja, que no tiene el cian.
- Alguien reabre la página. El cian se convierte en
undefinedy el texto vuelve al color heredado. - 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
stylehace 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.