Imágenes responsivas y sostenibles en markdown (commonmark) usando librería Haskell MMark

En este artículo mostramos una posible solución para crear imágenes responsivas y sostenibles en Markdown (especificación CommonMark) usando la librería MMark en el lenguaje funcional Haskell.
NOTA: Este artículo asume conceptos de sostenibilidad y responsividad de imágenes en Markdown. Para una introducción a dichos conceptos leer imágenes responsivas y sostenibles en Markdown
Introducción
Librería MMark, creada por Mark Karpov, es una librería del lenguaje funcional Haskell que usa especificación Commonmark para transformar Markdown en HTML y esta basada en la siguiente filosofía:
- Estricta, especificando explícitamente dónde ocurren los errores de parseo y de que se tratan.
- Extensible, dónde el usuario puede componer extensiones que agregan funcionalidad.
Si intentamos renderizar el siguiente markdown:
MARKDOWN
35 bla bla
36 [](/estoproduceerror)
Se muestra el siguiente error en el HTML:
error parsing--> content/cl/blog/imagenes-sostenibles-markdown-haskell-mmark.md:36:2: | 36 | [](/estoproduceerror) | ^ unexpected ']' expecting inline content
En dónde se indica que falta un valor e indica en qué línea del archivo (36).
Requisitos
- Tener GHC y cabal instalados
- Importar las siguientes librerías
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE DeriveGeneric #-}
module Main where
import qualified Data.Text.IO as T
import qualified Data.Text.Lazy.IO as TL
import qualified Text.MMark as MMark
import qualified Text.Megaparsec as M
import qualified Text.MMark.Extension as Ex
import qualified Data.Text as Te
import qualified Text.URI as URI
import qualified System.FilePath as FP
import qualified Data.ByteString.Lazy as B
import GHC.Generics
import Lucid.Base (makeAttribute)
import Lucid
import Data.Maybe (fromMaybe)
import Text.URI.Lens (uriPath)
import Lens.Micro ((^.))
import Data.Aeson (ToJSON, FromJSON, decode)
import System.Environment ( getArgs )
Principios de esta solución
MMark es bien flexible y es posible crear multiples soluciones. Por lo mismo usaremos ciertos principios para detallar el ejemplo.
Principio I
Alinearse lo máximo posible con la filosofía Markdown, es decir preferir soluciones que sean fácil de escribir y leer dentro del archivo Markdown.
Por ejemplo, un markdown así:
MARKDOWN

Produzca una imagen responsiva y sostenible como esta:
HTML
<img alt="imagen ejemplo" src="archivo.avif" width="600" height="600" srcset="archivo_600.avif 600w, archivo_856.avif 856w, archivo_1000.avif 1000w" sizes="(max-width:800px) 90vw, 856px">
Sin ninguna intervención de parte del escritor del markdown es preferible.
O con mínima intervención vía “hacks” para usar atributos como title
y agregar atributos responsivos y sostenibles como:
MARKDOWN

Produza el siguiente HTML:
HTML
<img alt="alt" src="archivo.avif" fetchprioroty="high">
Principio II
Idealmente, maximizar cross compatibilidad con trade-off razonables. En Injeniero una de las ventajas que ofrecemos es 0 vendor lock-in en dónde el cliente puede irse cuando quiera. En dicho sentido el ideal es producir Markdown que sea compatible con otras librerías/sistemas en dónde en caso de sintaxis modificadas, se muestren como links usables que no quiebren la página.
Ejemplo, para crear un elemento audio, es preferible así:
MARKDOWN
[audio](music.m4a)
vs un markdown de este tipo:
MARKDOWN
<audio:music.m4a>
Si bien la librería nos permite crear extensiones para aceptar ambas sintaxis, al usar el mismo archivo en otras librerías, por ejemplo, librería cmark que también es commonmark, pero que obviamente no tiene dichas extensiones creadas, resultan respectivamente en:
HTML
<p><a src="music.m4a">audio</a></p>
<p><a href="audio:music.m4a">audio:music.m4a</a></p>
En dónde el primer link funciona, con el pequeño trade-off que no es un elemento audio sino que un link al archivo con nombre audio, en cambio el segundo colapsa a ciertos navegadores.
A su vez aprovechar atributos ya existentes (un hack) lo consideremos mejor que una sintaxis nueva. Ejemplo crear la siguiente nueva sintaxis:
MARKDOWN
!audio(music.m4a)
Produce el output, en otras librerías, como cmark:
MARKDOWN
<p> !audio(music.m4a) </p>
Lo que significa que el usuario vería un texto en vez de un audio en caso de usar el mismo archivo markdown en otra librería. Obviamente usando MMark es posible crear el elemento audio a partir de un link.
Por ejemplo podríamos crear una extensión llamada audioExt:
HASKELL
audioExt :: MMark.Extension
audioExt = Ex.inlineRender $ \old inline ->
case inline of
l@(Ex.Link txt uri _ ) ->
case (uri ^. uriPath, Ex.asPlainText txt) of
([], _) -> old l
(_, "audio") ->
audio_ [controls_ "controls", preload_ "none"] $ do
source_ [src_ (URI.render uri) , type_ "audio/mp4"]
(_,_) -> old l
other -> old other
Que recibiendo el siguiente markdown:
MARKDOWN
[audio](podcast.m4a)
Entregaría el siguiente HTML:
HTML
<audio controls="controls" preload="none"><source src="podcast.m4a" type="audio/mp4"></audio>
Con la ventaja mencionada de que es un poco más compatible con otras librerías.
Qué sintaxis es preferible usar? Va depender del caso de uso. No obstante para la solución implementada en este artículo usaremos los principios expuestos.
Quizas podrás pensar que usar el atributo title
de una imagen no es un buen patrón. No obstante dicho atributo es muy poco usado en la realidad. Sólo es visible en dispositivos con puntero y no es usado como elemento de accesibilidad. A su vez, su impacto en SEO es despreciable. Por tanto eliminar dicho atributo del HTML final es un trade-off muy razonable. MMark permite transformar elementos inline como <img>
de manera componible, por lo que no necesitamos eliminar dicho atributo sino al final, pudiendo utilizarlo para definir atributos.
Por ejemplo podemos crear una extensión que permita agregar el atributo HTML loading="lazy"
:
HASKELL
imgLazyExt :: MMark.Extension
imgLazyExt = Ex.inlineRender $ \old inline ->
case inline of
l@(Ex.Image txt url (Just attr)) -> fromMaybe (old l) $ do
let wo = (words $ Te.unpack attr)
let mattr = if Te.null attr then Nothing else Just attr
case "lazy" `elem` wo of
True -> return $ with (old (Ex.Image txt url mattr)) [loading_ "lazy"]
False -> return $ with (old (Ex.Image txt url mattr)) []
other -> old other
Así basta escribir:
MARKDOWN

Para producir:
HTML
<img src="archivo.png" alt="alt" loading="lazy" title="lazy">
Notar que el atributo title con valor lazy se mantiene. Esto se debe a que with (old (Ex.Image txt url mattr))
“arrastra” los atributos anteriores pudiendo componer las extensiones. No obstante se puede eliminar el atributo title
en el caso de tener valor lazy conservandolo sólo si fuese otro valor (un valor de titulo de imagen verdadero por ejemplo) modificando dicha linea:
HASKELL
imgLazyExt :: MMark.Extension
imgLazyExt = Ex.inlineRender $ \old inline ->
case inline of
l@(Ex.Image txt url (Just attr)) -> fromMaybe (old l) $ do
let wo = (words $ Te.unpack attr)
let mattr = if Te.null attr then Nothing else Just attr
let src' = URI.render url
let alt' = Ex.asPlainText txt
case "lazy" `elem` wo of
-- linea modificada:
True -> return $ img_ [alt_ alt', src_ src', loading_ "lazy"]
False -> return $ with (old (Ex.Image txt url mattr)) []
other -> old other
Notar también que es necesario recrear atributo alt
pues no arrastramos dicho atributo pero si su valor (variable txt
). Idem con src
original.
Un lector atento percibirá que la solución anterior funciona, no obstante, si dicho markdown es procesado por otra librería commonmark, aparecerá necesariamente el atributo title
con un valor lazy. Esto es un trade-off que consideramos razonable, pues es fácilmente corregible con Javascript (eliminar atributo title
) o simplemente eliminarlo del archivo markdown, no obstante se renderiza sin problemas (a diferencia de haber creado una nueva sintaxis) y es fácil de leer en el archivo, alineandose a los dos principios mencionados. Con la gran ventaja de que usando MMark, podemos crear dicha funcionalidad que nos permite mayor control.
Para empresas como nosotros, en dónde nos dedicamos a crear sitios web y sistemas, tiene sentido crear extensiones componibles de atributos responsivos y sostenibles, tales como:
-
loading="lazy"
que permite evitar request de imágenes que requieren scroll para verse (bajo el “fold”) -
fetchpriority="high/low"
que permite jerarquizar prioridades de request de archivos criticos -
srcset
junto consizes
que entrega alternativas de imagenes e información sobre ancho y cómo se visualizaran las imágenes al navegador, pudiendo este optimizar ancho de banda y/o mejorar experiencia del usuario.
No obstante, autores y bloggers independientes con sitios simples quizá prefieran una solución sencilla, en dónde no se requiera pensar cómo optimizar sino que sea por defecto.
De los cuatro atributos responsivos y sostenibles para imagenes, tanto loading=lazy
como fetchprioroty
requieren necesariamente pensar en optimizar el sitio por lo que o bien se acepta ese hecho y se crean extensiones en el espíritu de las mencionadas, o se ignora en la capa de Markdown. No obstante los atributos de srcset
y sizes
son buenos candidatos para una solución simple1.
Por ejemplo consideremos un caso típico de un blog en dónde las imágenes se muestran a todo ancho en móvil y el contenido de la página no supera un ancho específico en desktop haciendo que la imagen en desktop sea igual a dicho ancho (imagina contenido centrado con harto margen por ambos lados en un monitor wide). Una regla simple de sizes
seria sizes = (max-width:600px) 100vw, 850px
que dice que hasta un ancho de pantalla de 600px la imagen se muestra al 100% del ancho del dispositivo, y cuando la pantalla es mayor, se muestra a 850px. A su vez, se podría definir dos tipos de archivos móvil y tablet/desktop, de 400px y 850px por ejemplo. Con ello srcset= imagen-small.avif 400w, imagen-big.avif 850w
. Por último, asumamos que tendremos una convención de nombre de las imágenes en dónde tendrá el ancho del archivo en el nombre. Por ejemplo imagen-small.avif
se renombraría como imagen_400.avif
Si bien este ejemplo de caso típico no es un sitio ultra optimizado, es una gran ganancia el mostrar la imagen de 400px vs 850px cada vez que se ve en pantallas menores a 600px, junto con permitir al navegador mostrar la imagen mayor si tuviese sentido (dispositivo con alta densidad de pixeles y velocidad de internet rápida).
HASKELL
import System.FilePath
imgResExt :: MMark.Extension
imgResExt = Ex.inlineRender $ \old inline ->
case inline of
l@(Ex.Image txt url _) -> fromMaybe (old l) $ do
let file = takeBaseName $ show $ URI.render url
let ext = takeExtension $ show $ URI.render url
return $ img_ [alt_ (Ex.asPlainText txt), srcset_ (imgSet file ext), sizes_ defSizes]
other -> old other
--defining sizes value
defSizes :: Te.Text
defSizes = Te.pack $ "(max-width:600px) 100vw, 850px"
imgSet :: String -> String -> String -> Int -> Int -> Te.Text
imgSet filebase ext path x y= Te.pack $ (path ++ "/" ++ filebase ++ "_" ++ (show x) ++ ext ++ " 400w,"
++ path ++ "/" ++ filebase ++ "_850" ++ ext ++ " 850w")
-- The @srcset@ attribute
-- Lucid provide makeAttribute that allow us to create a new attribute
srcset_ :: Te.Text -> Attribute
srcset_ val = makeAttribute "srcset" val
Con ello, un markdown:
MARKDOWN

Se transforma en:
HTML
<img src="imagen.avif" alt="alt" srcset="imagen_400.avif 400w, imagen_850.avif 850w" sizes="(max-width:600px) 100vw, 850px">
Es importante recalcar que es sólo un ejemplo y que puede ser mejorado. De hecho para un uso en producción, los valores de sizes y srcset podrían estar en un archivo de configuración y/o en yaml dentro del markdown. A su vez tener definido un proceso de transformación de las imágenes y su convención de nombres.
Por otro lado, podría ser interesante agregar un query string con un valor random al atributo src
para permitir hacer cache busting 2. A su vez es recomendable agregar los atributos width
y height
de <img>
, debido a que son usados para definir la carga inicial del sitio, lo cual podría hacerse vía una extensión similar a las mencionada para lazy
que permiten definir de forma explícita las dimensiones en el markdown 
o bien de forma automática, por ejemplo usando la librería JucyPixels o incluso desde mismo path o nombre del archivo si es que se respeta alguna convención en nombres.
HASKELL
import Codec.Picture --agregar JuicyPixels >= 3.3.9 al archivo cabal
-- esta funcion lee una imagen desde un directorio
readImage :: FilePath -> IO (Either String DynamicImage)
--esta funcion entrega el ancho como int
dynWidth :: DynamicImage -> Int
dynWidth img = dynamicMap imageWidth img
Queda tarea para el lector mejorar los ejemplos provistos tanto en código (mejorar su eficiencia y mejorar su mantenibilidad) como en requerimientos particulares de la organización que creará y mantendrá el markdown y posterior HTML.
Sourcecode
Ejemplo completo de las extensiones mencionadas agregando un archivo de configuración para fácil edición de srcset
y sizes
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE DeriveGeneric #-}
module Main where
import qualified Data.Text.IO as T
import qualified Data.Text.Lazy.IO as TL
import qualified Text.MMark as MMark
import qualified Text.Megaparsec as M
import qualified Text.MMark.Extension as Ex
import qualified Data.Text as Te
import qualified Text.URI as URI
import qualified System.FilePath as FP
import qualified Data.ByteString.Lazy as B
import GHC.Generics
import Lucid.Base (makeAttribute)
import Lucid
import Data.Maybe (fromMaybe)
import Text.URI.Lens (uriPath)
import Lens.Micro ((^.))
import Data.Aeson (ToJSON, FromJSON, decode)
import System.Environment ( getArgs )
-- Define Config data structure
data Config = Config
{ confSizes :: String
, confSet :: [Int]
} deriving (Show, Generic, Eq)
defaultConfig :: Config
defaultConfig = Config {confSizes = "(max-width:600px) 100vw, 850px", confSet = [400,850]}
-- Parsing the Config data
instance FromJSON Config
instance ToJSON Config
-- Function to read the config file
readConfig :: FilePath -> IO (Maybe Config)
readConfig path = do
jsonData <- B.readFile path
return (decode jsonData :: Maybe Config)
main :: IO ()
main = do
args <- getArgs
let input = head args
txt <- T.readFile input
config <- readConfig "config.json"
let conf = case config of
Just c -> c
Nothing -> defaultConfig
let sizes = confSizes conf
let set = confSet conf
case MMark.parse input txt of
Left bundle -> putStrLn (M.errorBundlePretty bundle)
Right r -> TL.writeFile (FP.takeBaseName input ++ ".html")
. renderText -- from Lucid
. MMark.render
. MMark.useExtensions
[ imgLazyExt
, imgResExt' set sizes
, audioExt
]
$ r
-- Common function to extract base URL components
extractImageAttributes :: URI.URI -> (String, String, String)
extractImageAttributes url =
let url' = clearStr $ show $ URI.render url
file = FP.takeBaseName url'
ext = FP.takeExtension url'
path = FP.takeDirectory url'
in (file, ext, path)
--EXTENSIONS
-- Adding lazy attribute to images composable
imgLazyExt :: MMark.Extension
imgLazyExt = Ex.inlineRender $ \old inline ->
case inline of
l@(Ex.Image txt url (Just attr)) -> fromMaybe (old l) $ do
let wo = words $ Te.unpack attr
let mattr = if Te.null attr then Nothing else Just attr
if "lazy" `elem` wo
then return $ with (old (Ex.Image txt url mattr)) [loading_ "lazy"]
else return $ old (Ex.Image txt url mattr)
other -> old other
-- Adding srcset and sizes attributes to images composable
imgResExt :: [Int] -> String -> MMark.Extension
imgResExt set sizes = Ex.inlineRender $ \old inline ->
case inline of
l@(Ex.Image txt url (Just attr)) -> fromMaybe (old l) $ do
let (file, ext, path) = extractImageAttributes url
let mattr = if Te.null attr then Nothing else Just attr
return $ with (old (Ex.Image txt url mattr))
[ srcset_ (imgSet file ext path set)
, sizes_ (Te.pack sizes)]
other -> old other
-- Extension for images without title attribute but adding srcset and sizes attributes
imgResExt' :: [Int] -> String -> MMark.Extension
imgResExt' set sizes= Ex.inlineRender $ \old inline ->
case inline of
l@(Ex.Image txt url _) -> fromMaybe (old l) $ do
let (file, ext, path) = extractImageAttributes url
let src' = URI.render url
return $ img_ [ alt_ (Ex.asPlainText txt)
, src_ src'
, srcset_ (imgSet file ext path set)
, sizes_ (Te.pack sizes)]
other -> old other
-- imgSet function
imgSet :: String -> String -> String -> [Int] -> Te.Text
imgSet filebase ext path set = Te.pack $ concatMap formatSize (init set) ++ formatSize (last set)
where
comma size = if size /= last set then "," else ""
formatSize size = path ++ "/" ++ filebase ++ "_" ++ show size ++ ext ++ " " ++ show size ++ "w" ++ comma size
-- Helper function to create srcset attribute
srcset_ :: Te.Text -> Attribute
srcset_ = makeAttribute "srcset"
-- Clear quotes from string
clearStr :: String -> String
clearStr = filter (not . (`elem` ("\"" :: String)))
-- Bonus: Audio extension to render audio links
audioExt :: MMark.Extension
audioExt = Ex.inlineRender $ \old inline ->
case inline of
l@(Ex.Link txt uri _) ->
case (uri ^. uriPath, Ex.asPlainText txt) of
([], _) -> old l
(_, "audio") ->
audio_ [controls_ "controls", preload_ "none"] $
source_ [src_ (URI.render uri), type_ "audio/mp4"]
(_, _) -> old l
other -> old other
Conclusión
Si bien MarkDown parece limitado a la hora de crear imágenes responsivas y sostenibles, librerías extensibles y estrictas como MMark permiten transformar el HTML final pudiendo agregar atributos a las imágenes de forma automática o de forma manual si se quiere mayor control sobre la publicación por parte del creador del contenido, junto con proveer de errores de sintaxis útiles.
En Injeniero seguimos los principios mencionados de 0 vendor lock-in y de alinearse al espíritu de Markdown de facilidad en lectura y escritura. No obstante podemos desarrollar la sintaxis que le haga más sentido a tu organización
Notas
-
sizes
es un atributo bien complejo en la práctica, no obstante para el caso de uso del ejemplo lo podemos automatizar ↩ - Estrategia que permite definir un cache con expiración de largo plazo pero que muestra correctamente las imágenes actualizadas pues tienen una url diferente. Ejemplo imagen.avif?47834-34073-356 ↩