FranBosquet

Añadir syntax highlighting a un blog MDX

Mejorando los bloques de código en Next para añadir resaltado de código

splash
1 de diciembre de 2023

Mientras escribía el penúltimo post hace ya dos meses me dí cuenta que los snippets de código no se veían muy bien qué digamos en mi blog. Tirando de screenshot:

Snippet por defecto

Así es como se vé el tag code por defecto. Y tiene sentido, ya que el navegador no sabe ni tiene por qué saber que ese bloque de código es en typescript, rust o cualquier otro lenguaje. Si bien esto no es un problema grave, no tener resaltado de código hace muy difícil leer el mismo, y la idea es compartir mucho código por aquí.

Así que me puse a buscar una solución para añadir syntax highlighting a los snippets de código. He aprovechado la oportunidad para documentarlo y publicar un post sobre el tema, ya que me parece interesante el proceso de retorcer las herramientas que tenemos (en este caso MDXRemote) para adaptarlas a nuestras necesidades.

Alternativas

CodeHike es el niño mas cool del barrio. Es un plugin de MDX2 que permite utilizar temas de VsCode para resaltar código en archivos Markdown renderizados con React. Me gustaría darte una buena razón técnica para haberlo descartado, pero la verdad es que no fuí capaz de hacerlo funcionar y pasé a otra cosa.

Si quieres hacer algo similar desde el lado de servidor, los mismos creadores de CodeHike tienen Bright, que hace lo mismo en un RSC. En mi caso el MDX lo renderizo en el cliente ya que arranqué este proyecto con Next 12 y no he he migrado mi infraestructura de MDX a full server side.

Así que lo que me queda es React Syntax Highlighter, que es la solución ya un poco old school, pero que siempre funciona. Tiene un montón de temas predefinidos y es muy fácil de usar. Así que vamos a implementarlo en este repo.

Instalando RSH

Siguiendo las instrucciones de su repo:

pnpm i react-syntax-highlighter

Si usas typescript como la gente decente, puedes instalar los tipos de la librería:

pnpm i -d @types/react-syntax-highlighter

Puedes usar npm o yarn si prefieres. Una vez instalado, vamos a hacer que nuestro renderizador muestre el SyntaxHighlighter cada vez que se encuentre un bloque de código en nuestro Markdown. Tenemos dos opciones: Una es hacerlo con Prism, que es la opcion más sencilla y straightforward, o usar Light que es una opción más ligera pero que necesita mayor configuración.

Como lo que quiero es salir del paso, y ademas parece que Light da problemas renderizando JSX (que es la mayoría de código que voy a querer compartir en este blog), vamos a optar por Prism. En el archivo MdxContent.tsx es donde se define cómo se va a substituir el Markdown por HTML. El viejo snippet de código se veía así:

Snippet por defecto

Y se definía así:

const code = (props: HTMLAttributes<HTMLElement>) => {
  const { children } = props

  return <code {...props} className={"bg-gray-900 text-green-300 p-1 rounded-md".concat(isBlock ? 'w-full p-6 block overflow-x-scroll' : '')}>{props.children}</code>
}

...
const mdxComponents = {
 	...
  code,
	...
}

export function MdxContent({ source }: MdxContentProps) {
  return <MDXRemote {...source} components={mdxComponents} />;
}

Vamos a actualizarlo para que use Prism en lugar del tag code:

import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
...

const code = (props: HTMLAttributes<HTMLElement>) => {
  const { children } = props;

  return <SyntaxHighlighter>
    {children as string}
  </SyntaxHighlighter>
}

Con esto ya se ve así:

Snippet sin estilos

No hemos avanzado demasiado, ya que no tenemos estilos. Necesitamos añadir uno de los temas incluidos en Prism para darle color al bloque de código.

Añadiendo un tema

Puedes usar la demo para ver en acción cada uno de los temas incluidos y elegir el que mas te guste. En mi caso me decanto por dracula, ya que es el que uso en mi terminal, mi editor de código, en slack y básicamente en cualquier software donde esté disponible. Para utilizarlo, solo tenemos que importarlo y pasarlo como prop al SyntaxHighlighter:

import { dracula } from 'react-syntax-highlighter/dist/cjs/styles/prism';

const code = (props: HTMLAttributes<HTMLElement>) => {
	...
  return <SyntaxHighlighter style={dracula}>
		...

El resultado ha mejorado algo, pero sigue sin ser lo que queremos:

Snippet sin lenguaje

El código esta plano y no utiliza distintos colores para objetos, metodos y valores. El problema en este caso viene del hecho de que no hemos especificado un lenguaje: No se resalta igual shell, que typescript que css. Vamos a ver como podemos solucionarlo.

Especificado el lenguaje

Para especificar el lenguaje, tenemos que añadir una prop language al componente SyntaxHighlighter que se renderiza en el Markdown. Desde el Markdown no tenemos acceso directamente al componente dado que usamos backticks para definir el bloque de código. Así que vamos a tener que hacer un poco de magia definiendo una interfaz. Esto es algo que hace el propio CodeHike en sus codeblocks. Vamos a intentar emularlo.

Tenemos que rescatar la palabra inmediatamente después de los backticks y usarla como prop lenguage en el componente SyntaxHighlighter. Probamos con un ejemplo:

```tsx
console.log('Soy un bloque de código')
```

Y comprobamos en MdxContent.tsx cómo llega esa información:

const code = (props: HTMLAttributes<HTMLElement>) => {
  const { children, ...rest } = props;

  console.log(rest)

  if (!children) return null
  ...

Encontrándonos esto en la consola:

{
  "className": "language-tsx"
}

Por tanto, podemos usar el valor dentro de ese className para rescatar el lenguaje y utilizarlo en el componente SyntaxHighlighter, simplemente eliminando el prefijo language- del className:

const code = (props: HTMLAttributes<HTMLElement>) => {
  const { children, className } = props

  const language = className?.replace('language-', '')

  return (
    <SyntaxHighlighter language={language} style={dracula}>
      {children as string}
    </SyntaxHighlighter>
  )
}

Y, ahora sí, tenemos el resultado que queríamos:

Snippet con highlighting

Añadiendo un filename

Codehike tiene un montón de features muy interesantes, pero una de las que más me gustan es que puedes especificar el nombre del archivo que estás mostrando. Esto resulta muy útil cuando estás mostrando un ejemplo de código que quieres que el usuario pueda descargar y probar. Así que vamos a emularlo.

Vamos a definir un contrato con nosotros mismos: Si incluimos una arroba en el lenguage del archivo, es que estamos especificando el nombre del fichero. Por ejemplo:

```tsx@index.tsx
console.log('Soy un bloque de código')
```

Representaría que el nombre del archivo es index.tsx. Vamos a modificar nuestro componente code para que sea capaz de identificar y extraer esta información:

const meta = className?.replace('language-', '')

const [language, filename] = meta?.split('@') ?? []

Utilizamos la arroba porque significa 'at', o 'en'. Este código está en este archivo. También porque es un símbolo que no aparece en el nombre de ningún lenguaje de programación ni personalmente uso en los nombres de mis archivos. Utilizando el nullish coalescing operator (??) puedo dar un valor por defecto a la metainformación que extraigo del className: De este modo, si no definimos lenguaje ni nombre de archivo, aun así tendremos un array que destructurar. Por tanto, ahora tenemos acceso al nombre del archivo y al lenguaje, pudiendo existir ambos, uno, o ninguno.

Vamos a añadir un wrapper a nuestro componente SyntaxHighlighter para que podamos añadir el nombre del archivo:

app/posts/[slug]/MdxContent.tsx
const code = (props: HTMLAttributes<HTMLElement>) => {
  const { children, className } = props

  ...

  return <article>
    {
      filename
        ? <header>{filename}</header>
        : null
    }
    <SyntaxHighlighter language={language} style={dracula}>
      {children as string}
    </SyntaxHighlighter>
  </article>
}

Y así queda:

Bloque de código con filename

Bastante pobre en mi opinión, pero nada que no podamos arreglar con un poco de Tailwind:

app/posts/[slug]/MdxContent.tsx
const code = (props: HTMLAttributes<HTMLElement>) => {
  ...

  return <article className='overflow-hidden rounded-xl bg-zinc-950 border border-teal-50/25 shadow-xl'>
    {
      filename
        ? <header className='text-xs text-zinc-500 p-2'>{filename}</header>
        : null
    }
    <SyntaxHighlighter wrapLongLines language={language} style={dracula} customStyle={{ borderRadius: 0, margin: 0 }}>
      {children as string}
    </SyntaxHighlighter>
  </article>
}

Nótese también que tenemos que tunear un poco el componente SyntaxHighlighter para que no tenga bordes redondeados y no tenga margen, ademas de indicar que queremos que las lineas largas deben saltar en lugar de que se muestre una barra de scroll horizontal. Y el resultado lo llevas viendo durante todo este post. Podríamos añadir aun más funcionalidad, como enlazar a github o el número de linea, pero creo que con esto ya tenemos suficiente para el propósito de este post.

Código en linea

Solo nos ha quedado pendiente el inline code: Markdown nos permite definir bloques de código con triple backtick, pero también podemos definir código en linea con un solo backtick. Conforme lo hemos dejado, siempre inyectamos el componente SyntaxHighlighter en lugar de un simple code, por lo que estamos partiendo los párrafos cada vez que usamos un backtick. Vamos a modificar nuestro componente code para que sea capaz de distinguir entre un bloque de código y código en linea:

app/posts/[slug]/MdxContent.tsx
  const isBlock = (children as string).includes('\n');

  if (!isBlock) return <code {...props} className="bg-gray-900 text-teal-300 p-1 rounded-md">{children}</code>

Y así es como podemos crear estos bloques de código en linea.

Y hasta aquí el post de hoy. Espero que te haya resultado interesante y que te haya servido para aprender algo nuevo. Si tienes cualquier duda o sugerencia, no dudes en dejar un comentario o contactarme por X. ¡Hasta la próxima!