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 unelement
,text
ocomment
tagName
si es unelement
indica el nombre de la etiqueta HTMLattributes
es un objeto con todos los atributes de la etiqueta HTMLchildren
es una lista de más objetos, por ejemplo el contenido de texto o elementos internoscontent
si es untext
ocomment
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.