Renderizando Markdown en React.js

Markdown es una tecnología genial para poder escribir fácilmente contenido de texto, una de las mejores cosas es que si bien está pensado para ser transformado en HTML es posible usarlo para transformarlo en cualquier otra tecnología, por ejemplo componentes de React.

En este artículo vamos a ver como se puede crear un parser que transforme Markdown en componentes de React, pasando por HTML y JSON en el proceso

Markdown a HTML

Lo primero es tener un parser de Markdown que nos devuelva HTML, para esto podemos usar uno de los muchos que existen en npm, en nuestro caso vamos a usar markdown-it que no solo es rápido si no que nos permite extenderlo mediante plugins para agregar nuevas capacidades.

Para usarlo necesitamos instalarlo desde npm:

yarn add markdown-it

Luego debemos importarlo e instanciarlo en nuestro código:

import MarkdownIt from "markdown-it";

const parser = new MarkdownIt({
  html: false, // desactivamos el uso de HTML dentro del markdown
  breaks: true, // transforma los saltos de línea a un <br />
  linkify: true, // detecta enlaces y los vuelve enlaces
  xhtmlOut: true, // devuelve XHTML válido (por ejemplo <br /> en vez de <br>)
  typographer: true, // reemplaza ciertas palabras para mejorar el texto
  langPrefix: "language-" // agrega una clase `language-[lang]` a los bloques de código
});

Con eso ya creamos nuestra instancia, luego podríamos agregar plugins, por ejemplo podríamos crear un plugin para embeber tweets:

import regexp from "markdown-it-regexp";

// custom plugin for twitter cards
const tweet = regexp(/@\[twitter\]\(([^\)]*)\)/, match => {
  const id = match[1];
  return `<twitter-card id="${id}"></twitter-card>`;
});

export default tweet;

Ese plugin va a detectar @[twitter](id), donde id es el ID de un tweet que se ve en su URL, y en su lugar va a agregar <twitter-card id={id}></twitter-card>, podríamos crear un WebComponent que se encargue de renderizar el tweet o podemos luego detectar esa etiqueta y renderizar un componente de React personalizado.

Por último le decimos al parser que agregue use nuestro plugin con la siguiente línea:

parser.use(tweet);

Por último para convertir a HTML usamos la siguiente línea:

const html = parser.render(markdown);

HTML a JSON

Una vez tenemos nuestro HTML podemos hacer lo que queramos, por ejemplo insertarlo dentro de cualquier página web. Si usamos React.js la forma de usar el HTML sería con dangerouslySetInnerHTML.

return (
  <div
    dangerouslySetInnerHTML={{
      __html: parser.render(markdown))
    }}
  />
):

El problema de esto es que no podemos renderizar componentes personalizados en lugar de etiquetas HTML normales, por ejemplo para reemplazar <twitter-card>, además de eso tendríamos que agregar una clase al <div /> y para poder estilizar todas las etiquetas internamente mediante selectores anidados como .content h2.

Para solucionar esto vamos a convertir el HTML a un objecto de JavaScript (JSON), esto es posible usando una librería llamada himalaya. Simplemente debemos instalarla en nuestro proyecto como siempre:

yarn add himalaya

Y luego vamos a importar su parser de HTML a JSON.

import { parser } from "himalaya";

Ya que tenemos importardo himalaya podemos usarlo pasando el HTML que obtuvimos con nuestro parser de Markdown.

const json = parser(html);

El resultado va a ser un array con objetos por cada etiqueta HTML, algo similar a esto:

[
  {
    "type": "element",
    "tagName": "p",
    "attributes": {},
    "children": [
      {
        "type": "element",
        "tagName": "a",
        "attributes": {
          "href": "https://sergiodxa.com"
        },
        "children": [
          {
            "type": "text",
            "content": "HTML a JSON"
          }
        ]
      }
    ]
  }
]

Como podemos ver tenemos las siguientes propiedades:

  • type define si el objeto representa un element, text o comment
  • tagName si es un element indica el nombre de la etiqueta HTML
  • attributes es un objeto con todos los atributes de la etiqueta HTML
  • children es una lista de más objetos, por ejemplo el contenido de texto o elementos internos
  • content si es un text o comment define el contenido de text

Este JSON podemos luego recorrerlos para convertir cada elementos o texto en un componente de React.

JSON a React

Como dijimos, vamos a convertir nuestro JSON a etiquetas de React, para eso vamos a crear una función que nos permita definir que hacer con cada objeto dependiendo de su type, antes que todo vamos instalar React, ReactDOM y html-entities, este último nos va a servir para decodificar entidades HTML (como < y >) que sean parte del contenido y no etiquetas reales.

import { AllHtmlEntities } from "html-entities";
const entities = new AllHtmlEntities();

function mapElement(element, index) {
  switch (element.type) {
    case "text": {
      // si es un nodo de texto decodificamos el contenido y lo devolvemos
      return entities.decode(element.content);
    }
    case "element": {
      // si es un elemento lo pasamos (junto a su posición en el array) a matchElement
      return matchElement(element, index);
    }
    default: {
      // en cualquier otro caso (como que sea `comment`) devolvemos null
      return null;
    }
  }
}

Ahora podemos transformar los elementos de json con esta función.

const jsx = json.map(mapElement);

Si ejecutamos eso ahora mismo vamos a obtener un error debido a que nos falta definir matchElement. Esta función debe convertir los nodos de elementos a elementos de React.

// esta función va a convertir el atributo `class` a className` y combinar
// los atributos de con los props base
function mergeProps(baseProps, element) {
  return (element.attributes || [])
    .map(
      ({ key, value }) =>
        key === 'class' ? { key: 'className', value } : { key, value }
    )
    .reduce(
      (attributes, { key, value }) => ({ ...attributes, [key]: value }),
      baseProps
    );
}

function matchElement({ tagName children, attributes }, index) {
  // este objeto son todos los props que queramos incluir a todos los elementos
  // en este caso solo vamos a definir el prop especial `key` con el valor de `index`
  const baseProps = { key: index };

  const props = mergeProps(baseProps, { attributes });

  switch (tagName) {
    case 'br':
    case 'img':
    case 'hr': {
      // estas etiquetas no pueden tener elementos hijos por esa razón
      // solo creamos la etiqueta con props
      return React.createElement(tagName, props);
    }
    case 'twitter-card': {
      // como dijimos antes usamos un componente propio (Twitter)
      // para reemplazar la etiquieta <twitter-card>
      return React.createElement(Twitter, props);
    }
    default: {
      // para cualquier caso no manejado simplemente creamos un element
      // con el nombre de etiqueta, props y elementos hijos
      return React.createElement(tagName, props, children.map(mapElement));
    }
  }
}

Gracias a esta función vamos a convertir cualquier etiqueta HTML que generemos desde nuestro Markdown a elementos de React, incluso como vimos con <twitter-card> podemos renderizar cualquier componente para manejar casos especiales y únicos.

El resultado que obtuvimos en la constante jsx podemos ahora insertarlo dentro de un componente normal de React o simplemente renderizarlo con ReactDOM.

return <div>{jsx}</div>;

Palabras finales

Gracias a esto podemos mostrar contenido Markdown dentro de una aplicación de React usando elementos reales de React.

Esta misma técnica se podría usar para transformar Markdown a componentes de React Native y así usar elementos nativos de la UI en vez de mostrar un <WebView /> con el contenido y aplicar estilos mediante una hoja de estilos CSS embebida.