Kabosu - Creando cosas

Logo de la página. Gato esférico con colores verdoso.

Conversor sencillo de Markdown a HTML

Publicado: 2024-01-17 (actualizado 2024-10-04)

Etiquetas: Proyectos, Python


Editado el día 2024-10-04 para corregir algunos errores ortográficos.

Ayer estaba buscando algún programa que convirtiese Markdown a HTML para usarlo en este blog. Escribo estos artículos en Markdown y luego los convierto a HTML antes de publicarlos. Hay montones de librerías que lo hacen (yo he usado markdown-it-py) pero sentí curiosidad y me puse a pensar cómo haría yo la conversión si por ejemplo me lo pidieran en una entrevista de trabajo.

Markdown tiene una sintaxis relativamente simple pero si te pones analizarla tiene muchísimos detalles sin explicar y casos especiales que hay considerar. Solo hay que echar un ojo a la especificación CommonMark que intenta definir un estándar robusto para el formato en el que no se deje ninguna situación sin especificar.

Nota importante: no recomiendo usar generadores o parsers hechos artesanalmente en cosas que estén "de cara al público" porque nos exponemos a un montón de problemas de seguridad. Sin ir más lejos mi generador es muy probablemente vulnerable a Javascript injection. Siempre va a ser mejor delegar las cosas críticas en alguien que en teoría sabe lo que está haciendo y dedicarnos a lo que realmente importa. En este caso escribir un blog.

Crear un conversor completo de Markdown creo que requeriría definir la gramática del formato y luego algún parser como LL(K). No creo que fuera capaz de hacer eso en un tiempo razonable y como esto es un pasatiempo decidí hacer un conversor solo para el pequeño subconjunto de Markdown que utilizo normalmente:

Por ejemplo, # Título se convierte en <h1>Título</h1>.

Mi generador utiliza una serie de expresiones regulares para ir transformando la sintaxis de Markdown en HTML. Por ejemplo para los títulos uso el siguiente código Python:

def createHeader1(match):
    return f'<h1>{match.group(1)}</h1>\n'

re.sub(r'^#(.*)$', createHeader1, text, flags=re.MULTILINE)

La expresión regular captura el texto que se encuentra entre # y el final de la línea y lo pone alrededor las etiquetas <h1> y </h1>.

Se pueden crear expresiones similares para el resto de elementos (encabezados, negrita, cursiva...). Las únicas expresiones que son un poco más complicadas son las de los enlaces y las imágenes que tienen que capturar dos cadenas: URL+texto en el caso de los enlaces y URL+alt para las imágenes.

def createImage(match):
    return f'<img alt="{match.group(1)}" src="{match.group(2)}" />\n'

re.sub(r'!\[(.*)\]\((.*)\)', createImage, text, flags=re.MULTILINE)

Tras aplicar una detrás de otra las transformaciones usando expresiones regulares ya tenía convertidos a HTML los elementos de Markdown que uso normalmente. Lo único que faltaba era dividir el resto del texto en párrafos poniéndoles alrededor <p> y </p>.

Que yo sepa, no es posible hacer eso con expresiones regulares así que escribí un bucle en el que proceso las líneas del texto una tras otra y voy añadiendo <p> o </p> si no he añadido ninguna otra etiqueta con las transformaciones anteriores. También tuve que añadir una pequeña comprobación para evitar crear párrafos dentro de etiquetas <pre></pre>.

A continuación listo el código completo que escribí. Vuelvo a repetir que no recomiendo a nadie usarlo ya que no es seguro.

import html, re

def createCodeBlock(match):
    return f'<pre>{match.group(1)}</pre>\n'

def createInlineCodeBlock(match):
    return f'<code>{match.group(1)}</code>\n'

def createStrongBlock(match):
    return f'<strong>{match.group(1)}</strong>\n'

def createHeader1(match):
    return f'<h1>{match.group(1)}</h1>\n'

def createHeader2(match):
    return f'<h2>{match.group(1)}</h2>\n'

def createHeader3(match):
    return f'<h3>{match.group(1)}</h3>\n'

def createImage(match):
    return f'<img alt="{match.group(1)}" src="{match.group(2)}" />\n'

def createLink(match):
    return f' <a href="{match.group(2)}">{match.group(1)}</a> '

def markdownToHtml(md):
    replacements = [
        (r'^```(.*?)^```', createCodeBlock, re.DOTALL|re.MULTILINE),
        (r'^###(.*)$', createHeader1, re.MULTILINE),
        (r'^##(.*)$', createHeader1, re.MULTILINE),
        (r'^#(.*)$', createHeader1, re.MULTILINE)
    ]

    inline_replacements = [
        (r'`(.*?)`', createInlineCodeBlock, re.DOTALL|re.MULTILINE),
        (r'\*\*(.*?)\*\*', createStrongBlock, re.DOTALL|re.MULTILINE),
        (r'!\[(.*)\]\((.*)\)', createImage, re.MULTILINE),
        (r'\[(.*)\]\((.*)\)', createLink, re.MULTILINE),
    ]

    md = html.escape(md)

    for pattern, replacement, flags in replacements:
        md = re.sub(pattern, replacement, md, flags=flags)

    lines = md.split('\n')
   
    inParagraph = False
    inBlock = False
    for i, l in enumerate(lines):
        l = l.strip()
        if inParagraph:
            if not l:
                lines[i] = '</p>'
                inParagraph = False
        else:
            if l.startswith('<pre>'):
                inBlock = True

            if not inBlock and not l.startswith('<'):
                inParagraph = True
                lines[i] = '<p>' + lines[i]

            if l.endswith('</pre>'):
                inBlock = False

        if not inBlock:
            for pattern, replacement, flags in inline_replacements:
                lines[i] = re.sub(pattern, replacement, lines[i], flags=flags)

    return str.join('\n', lines)

Como ejercicio para aprender no está mal pero para cualquier cosa seria es mejor buscar una librería de conversión más completa en vez de intentar reinventar la rueda.


Artículo siguiente: Reseña libro: The Mythical Man-month
Artículo anterior: Presentación