Integrar las reglas de filtrado de CKEditor (ACR) y Drupal

CKEditor proporciona un sistema de filtrado de HTML muy completo, llamado Advanced Content Filter. Por su parte, Drupal tiene sus propios filtros para el sanitizado, siendo el más usado FilterHTML. Ambos sistemas están preparados para cohabitar, pero hay un detalle que no es trivial ni parece estar documentado. Aquí explicamos cómo hacerlo.
Un plugin de CKEditor

El problema

El desarrollador incauto que haya realizado un widget personalizado para CKEditor usando un plugin de Drupal (ver cómo en este tutorial) se encontrará fácilmente con problemas al activarlo.

Espera... ¿de qué me hablas?

Un widget de CKEditor proporciona una estructura HTML que puede exponer varias áreas para su edición. Por ejemplo, un widget puede renderizar un pequeño componente tipo CTA que conste de un título, subtítulo y botón editables por el usuario. Puedes ver este ejemplo oficial de CKEditor para entenderlo mejor.

Con esto se consigue que un HTML potencialmente complejo sea editable e incluso arrastrable y configurable sin exponer código al usuario y sin que sea fácil de romper involuntariamente y generar HTML malformado.

Pongamos que nuestro widget consta de esta estructura HTML:


<div class="cta">
  <h3 class="cta__title">Insert title here...</h3>
  <p class="cta__text">Insert your text here</p>
  <div class="cta__link">
    <a href="#">Double click to edit link</a>
  </div>
</div>

Siguiendo la documentación de CKEditor, debemos incluir los elementos y propiedades HTML en la lista blanca de allowedContent o, si no, el sanitizado del editor lo borrará. Esto se hace en el propio widget:


CKEDITOR.plugins.add( 'cta_widget', {
    init: function( editor ) {
        editor.widgets.add( 'cta_widget', {
            // ...
            allowedContent: 'h3(cta__title); div(cta__link); p(cta__text); a[href]',
        });
    }
});

Esta entrada de allowedContent utiliza la notación de las Allowed Content Rules de CKEditor (desde ahora, ACR) para permitir la presencia de los elementos <h3>, <p> y <div> y las clases .cta__title, .cta__link y .cta__text en cada uno de ellos, así como el atributo href en el elemento <a>.

CKEditor debería encargarse de mezclar (merge) estas reglas con las que provengan del resto de widgets y cualquier otra configuración activa hasta acabar con una lista blanca que represente la unión de todo el HTML que ha de ser permitido.

¿De verdad a[href]?

Es bueno ser explícito: aunque es muy posible que una regla tan común como a[href] ya haya sido declarada en otro widget o plugin, no podemos darlo por sentado y es buena práctica que cada plugin de CKEditor se encargue de activar todas las reglas necesarias para funcionar. ¿Qué pasaría si mañana alguien quiere tener un CKEditor con un sólo botón activo — el tuyo?

Ahora habilitamos nuestro widget en Drupal y... voilá.

Drupal se carga nuestro HTML.

Si tenemos mala suerte, sólo queda en pie el elemento <p>. En la mayoría de los casos, nos quedamos con todos los elementos (<p>, <h3> y <a href>) pero sin ni una clase. Nuestro componente yace destrozado, la promesa del WYSIWYG se desvanece, prometemos no volver a tocar Drupal en la vida, lanzamos CKEditor a la basura y nos abrazamos a la bebida y al editor Gutenberg.

¿Qué ha podido pasar?

 

FilterHTML y ACR: amigos ma non troppo

Para empezar: Drupal no usa exactamente las reglas de CKEditor. O, mejor dicho, no las usa únicamente ni inmediatamente.

Para empezar, Drupal realiza un filtrado doble: usa las reglas de CKEditor en el lado del cliente, esto es, mientras el usuario está editando su texto en el navegador, pero también en el lado del servidor.

Esto ocurre si tenemos activo el filtro «Limita las etiquetas HTML permitidas y corrige el HTML incorrecto» (algo bastante normal y recomendable), en este caso, Drupal aplica un filtrado adicional al de CKEditor que busca y elimina cualquier HTML que no esté en la lista blanca. Este filtro utiliza una notación similar a ACR, quizá más sencilla, que será familiar a cualquier Drupalista avezado.

Reglas de filtrado de HTML en un Editor de Drupal 8
Reglas de filtrado de HTML en un editor que usa CKEditor. Parecidas pero no iguales que ACR.

Es decir, tenemos un doble filtrado tanto en el lado del cliente (CKEditor usando ACR) como en el lado del servidor (Filter module, usando FilterHTML)

¿Y si no uso FilterHTML?

En este caso felicidades, puedes dejar de leer: las reglas de CKEditor se aplican tal cual. No obstante, si quieres vivir una vida larga y saludable como desarrolladora no deberías dejar que el sanitizado del input dependa exclusivamente de javascript en el lado del cliente: no puede haber nada más fácil de engañar.

El lector avispado ya se habrá dado cuenta aquí de una extraña dependencia circular: si definimos lo que está permitido o prohibido en archivos javascript de plugins de CKEditor, ¿cómo sabe Drupal cuáles son las reglas que debe aplicar FilterHTML? ¿Cómo narices se mantienen en sincronía los dos sistemas para evitar que se pisen mutuamente? ¿No podría ocurrir fácilmente que un HTML declarado válido en CKEditor fuera eliminado en Drupal o viceversa?

 

Dábale arroz a la zorra el abad

La respuesta es: efectivamente y no.

Para empezar, el allowedContent que llega a CKEditor es interceptado y modificado por Drupal. Nuestro precioso allowedContent no funciona, en primer lugar, porque no estamos en una instalación estándar de CKEditor.

En realidad, el allowedContent que llega a CKEditor son las reglas de Filter HTML convertidas a formato ACRDrupal convierte las reglas que vimos en la captura anterior en algo que CKEditor pueda entender: por ejemplo, <aside data-url> se convertirá en aside[data-url], etcétera.

Es decir, para Drupal la única fuente de verdad respecto al allowedContent de CKEditor son las reglas de Filter HTML.

La conclusión natural es que nuestras reglas declaradas en el widget son inútiles y que lo que tenemos que hacer es expresarlas manualmente como reglas de Filter HTML. De hecho, esto es lo que tradicionalmente muchos módulos que dan de alta widgets y plugins de CKEditor recomiendan como solución al problema:

Un módulo recomendando alterar manualmente las reglas de HTML Filter
Un módulo recomendando dar de alta manualmente la regla en FIlterHTML

No obstante, esto es bastante mejorable.

Además, se debe de poder hacer ya que hay muchos módulos que consiguen declarar reglas de CKEditor que aparezcan automágicamente reflejadas en Drupal. Por ejemplo, el widget de imágenes del core de Drupal añade los atributos src, altdata-entity-type y data-entity-uuid al elemento img de modo automático.

En cuanto añades el plugin de Image, aparecen sus reglas AllowedContent. ¿Magia negra?
En cuanto añades el plugin de Image, aparecen sus reglas ACR convertidas a formato FilterHTML ¿Magia negra?

Por no hablar de los módulos complejos como Entity Embed o Media, los cuales utilizan profusamente placeholders, elementos personalizados que serían presa de cualquier sanitizado si no se interviera para permitir su marcado:

<!-- Esta jerga infernal un CKEditor estándar se lo cepilla en un abrir y cerrar de ojos... -->
<drupal-media 
   data-align="center" 
   data-entity-type="media" 
   data-entity-uuid="04df8b0a-a2e0-48ba-88b4-63a01fe0c84c" 
   data-view-mode="embedded_media">
</drupal-media>

 

Cómo conseguir que Drupal te entienda

La respuesta es: sí, efectivamente, existe un modo por el que Drupal extrae las reglas ACR de nuestros plugins de CKEditor y las convierte en reglas de FilterHTML que, a su vez, son convertidas en reglas ACR de nuevo y enviadas al CKEditor de vuelta.

¿Wat?

Suena lioso porque es lioso y desde luego tiene cierto margen de mejora. Hay varias issues de Drupal quejándose de la falta de un modo mejor de extender las reglas de CKEditor. Por otra parte, si la última palabra la tienen que tener las reglas de FIlterHTML de Drupal, no parece que haya una mejor solución que esta operación en tres pasos.

No obstante, aquí viene el segundo problema: Drupal se come los atributos.

Por ejemplo, dado un widget o plugin de CKeditor que declare estas reglas de ACR:

allowedContent:  'h3(cta__title)[data-first]; a[href]; div(cta__text); p(cta__link); nacho-diaz'

Si lo añadimos a un CKEditor como su único botón (para asegurarnos que nada interfiere) nos encontraremos con esto:

REglas sin atributos
Nuestras reglas, sin atributo alguno...

La conversión a reglas de FilterHTML ignora los atributos y clases. Esto significa a su vez que cuando sean re-convertidas a reglas de CKEditor, el resultado será como si hubieramos escrito esto:

allowedContent:  'h3; a; div; p; nacho-diaz'

Y esto probablemente destroce nuestro widget o plugin y nos haga entrar de nuevo en el ciclo de la bebida y Wordpress y Gutenberg.

 

La solución

Esto es algo que llevaba meses molestándome y al final he encontrado el motivo, aunque no lo entienda.

Hay que añadir una exclamación delante de cada atributo o clase para que Drupal lo considere. Es decir, necesitamos escribir esto:

allowedContent:  'h3(!cta__title)[!data-first]; a[!href]; div(!cta__text); p(!cta__link); nacho-diaz'

¿Por qué? Aún no lo entiendo y puede incluso que sea un área donde el core de Drupal no anda muy fino.

Las reglas de ACR especifican que la exclamación se ha de usar para indicar que la propiedad es requerida. Es decir, escribir h3[!data-first] significa que sólo se permitirá el elemento h3 siempre y cuando incluya el atributo data-first y será descartado si no.

Sin embargo, esto no es lo que Drupal traduce ya que una regla ACR h3[!data-first] se convierte en una regla <h3 data-first> en FilterHTML, esto es, se permite pero no exige data-first (de hecho, las reglas de FIlterHTML no son capaces de reflejar el concepto de exigido).

De este modo, nuestras reglas con !exclamación se convierten, ahora sí, en nuestro comportamiento deseado:

Reglas de ACR convertidas

... y estas reglas se convertirán definitivamente en lo que queríamos al principio: permitir sin exigir la presencia de determinados atributos y clases.

 

El verdadero problema: un drupalismo sin documentar.

En mi opinión, esta implementación es bastante defectuosa ya que se está haciendo una interpretación bastante libre de las reglas de CKEditor que traicionan la intención original. 

Además, al no estar documentada en ninguna parte la necesidad de añadir la !exclamación, lo cual es un Drupalismo —una decisión exclusiva de Drupal en la que nada pincha ni corta el pobre CKEditor—  se corre el riesgo de enajenar y frustrar a los desarrolladores que no logran entender a santo de qué Drupal ignora sus reglas allowedContent y arrastra sus contenidos por el suelo.

Es posible que haya alguna explicación técnica: quizá la imposibilidad de capturar todos los matices de ACR en las más limitadas reglas de FilterHTML, o quizá fuera una idea ejecutada a vuelapluma, que quedó enterrada en código y no ha sido revisada a posteriori.

El problema es que esta decisión no parece estar explicada ni documentada en ningún sitio por lo que produce comportamientos indeseados y quebraderos de cabeza que los desarrolladores han intentado rodear al menos de tres modos:

Ahora ya conoces un modo de sortear este problema que es más sencillo, pertenece al core y es (aparentemente) la solución oficial: añadir la maldita !exclamación.