Écrire un convertisseur de Markdown en OCaml

Skelets numériques

Première publication le 22/12/2024
Dernière modification le 22/12/2024
Temps de lecture estimé: 13 m
Post précédent : Est-ce que tuer un PDG c'est juste?
Tags liés à cet article: outil

Écrire un convertisseur de Markdown en OCaml

J’ai précédemment écrit sur ce blog un billet sur l’intégration de mon Obsidian dans mon blog. C’était un peu foutraque: la page est pleine de liens morts et je vais probablement réorganiser le contenu de ce billet à terme. En relisant le code que j’avais écrit, je l’ai trouvé inutilement compliqué. Des discussions avec des experts en OCaml m’ont motivé à reprendre ce projet à zéro pour obtenir quelque chose de plus simple.

Plus largement, faire des projets plus simples me semble important pour me motiver et ainsi, progresser dans mes capacités.

L’objet de ce billet n’est pas de fournir un cours d’OCaml ou un tutoriel, en l’état. C’est surtout un prétexte pour documenter mes travaux. Il sera donc un peu brut de décoffrage.

Fonctionnalités

Première étape: que fait le programme?

Prend un fichier Markdown en entrée, le convertit en un autre fichier markdown. l’usage, c’est transformer un fichier d’un vault obsidian vers un format de générateur de site statique. Les deux grosses différences :

Il y a une spécification Markdown si on veut être exhaustif. Ce qui importe ici c’est d’avoir quelque chose qui marche de obsidian vers le format markdown Hugo. Concrètement, c’est surtout les liens vers les ressources locales qui doivent être convertis.

on voit donc qu’on souhaite manipuler:

Premier jet

Cette section est le résultat de trois séances de travail: les 30-11-2024, 12-12-2024 et 20-12-2024. Chaque séance m’a pris entre une heure et demie et deux heures de travail; principalement occupées à lire la documentation de la bibliothèque Angstrom.

Première représentation via type

On définit les types suivants. On ne va pas se préoccuper du contenu des métadonnées pour l’instant et considérer qu’il ne s’agit que de strings. Pareillement pour les liens. Les liens externes peuvent être doté d’un identifiant (la chaîne de caractères par lequel il devra être rendu dans le document final).

On considère pour l’instant qu’un contenu est une séquence linéaire de chaîne de caractère (pouvant être vide) et de liens. Donc un document Markdown est entièrement représenté par un couple métadonée et liste de contenu.

type text = string
type link = Local of string | Hyper of string option * string

type content = Text of text | Link of link

type metadata = Yaml of string | Toml of string

type markdown_doc = (metadata*text list)

On pourrait largement raffiner. Déjà, on sent qu’on devra donner une représentation de nos deux formats si on veut faire des conversions utiles. Pour l’instant on veut un proto qui marche, donc ça viendra plus tard.

Dans un premier temps, on veut construire une valeur markdown_doc depuis un fichier, la conversion en un autre fichier, et la conversion d’un type de métadonnée vers un autre.

val markdown_of_file : string -> markdown_doc

val markdown_to_file : markdown_doc -> string -> unit

val convert_metadata : metadata -> metadata 

val convert_markdown_to : markdown_doc -> markdown_doc 

La dernière fonction change le type de la métadonnée de Yaml vers Toml et vice-versa.

Ces fonctions auxiliaires nous seront forcément utiles.

val link_to_string : link -> metadata -> string
(** Renvoie la représentation textuelle du lien suivant le type de métadonnée. *)

Techniquement ici, je suis en train de confondre la saveur du Markdown et le format de la métadonnée. En fait, les deux sont séparées (c’est plutôt une affaire de langage de template source et cible, mais c’est un détail pour après).

Première implémentation

La seule lib qu’on va s’autoriser à utiliser pour le fun, c’est le parser combinateur Angstrom parce qu’il est rigolo. Le premier argument sur la ligne de commande sera le fichier d’entrée, le second le fichier de sortie. On va supposer que le fichier d’entrée a ses métadonnées en Yaml et qu’on les veut en Toml.

On veut que notre parser construise une métadonnée (un séparateur Yaml suivi d’un retour à la ligne, n’importe quel caractère qui ne soit pas un - pour s’assurer que ça ne soit pas un séparateur Yaml, et un séparateur Yaml suivi d’un retour à la ligne). Ensuite, tout le contenu qui reste est enregistré comme contenu textuel. Le parser résultant est simplement le couple de résultat renvoyé par ces parsers.

(* Our markdown parsers *)
let not_minuses = take_while (function '-' | '\n' -> false | _ -> true)
let all_content = take_while (fun _ -> true)

let metadata =
  let delim = string yaml_sep *> end_of_line in
  let entries = many (not_minuses <* end_of_line) in
  delim *> entries <* delim >>| fun s -> Yaml (String.concat ~sep:"\n" s)

let doc = both metadata (list [ all_content ]) <* end_of_input

(* Our main loop *)
let () =
  let str = Stdio.In_channel.read_all @@ (Sys.get_argv ()).(1) in
  let out_file = (Sys.get_argv ()).(2) in
  let md = (* markdown_of_file *)
    match Angstrom.parse_string ~consume:All doc str with
    | Ok md -> md
    | Error msg -> failwith msg
  in
  let new_md = convert_markdown_to md in (* markdown_to_file *)
  Stdio.Out_channel.write_all out_file ~data:(markdown_to_string new_md)

Ça marche.

Voilà la liste des choses à faire pour les prochaines séances.

Prise en compte des liens dans le parsing

Pour l’instant, on a pas écrit de parser pour les liens. C’est l’objet de la séance du 08-12-2024.

Pourquoi faire? On voudrait gérer la conversion de lien d’un format de métadonnée vers l’autre. Mais en fait, on se rend compte que la notion de lien n’est pas une affaire de format de métadonnée, mais de “saveur” de Markdown.

Saveur Markdown

On va d’abord définir cette notion, là encore sous la forme d’un type variant pour commencer, et augmenter la description de notre type document. En effet, on souhaite attacher dans notre représentation intermédiaire la “saveur” du document pour informer la possibilité d’une transformation. On va devoir changer un peu notre code pour ça, pour l’instant on va supposer qu’on parse toujours un document Obsidian (inférer la saveur du markdown étant compliqué, c’est typiquement le genre de chose qu’on laissera en choix à l’utilisateur·ice). Pour ne pas changer trop notre parser, on rajoute la saveur après que le parsing ait réussi.

type flavour = Hugo | Obsidian
type markdown_doc = flavour*metadata*text

Parser un lien

Bien, maintenant, concrètement, c’est quoi un lien?

On a une notion de lien interne et de lien externe comme vu plus haut. Vu qu’on suppose partir d’obsidian, un lien interne aura toujours cette forme: [[t]]. Un lien externe aura toujours cette forme: [t1](t2). Ici, t est une chaîne de caractère avec les restrictions suivantes:

On remarque que la séquence d’ouverture/fermeture est symétrique, donc on peut écrire un parser qui renvoie le résultat d’un autre parser si celui-ci est compris entre une séquence d’ouverture et fermeture. Le contenu d’un lien interne, ça sera la conjonction des contraintes exprimées plus haut: une chaîne de caractère qui n’a pas de saut de ligne, ni [, ni ]. Ainsi, le contenu d’un document markdown est soit constitué de liens, soit de contenu qui ne sont pas des liens. On veut donc raffiner notre parser de contenu pour exclure les séquences d’ouverture de liens (sinon, notre parseur de contenu va glob tout le reste du document).

La structure de notre parsing devient alors:

Une difficulté conceptuelle que j’ai, c’est:

J’ai tenté de traiter deux choses à la fois: considérer le traitement ligne par ligne et le traitement des liens. Pour l’instant, on a quelque chose qui correspond au traitement ligne par ligne. Il faut enrichir ma représentation intermédiaire. Si je parse ligne par ligne, alors je dois avoir soit

En fait, mon type de document est trop pauvre.

type content = Text of text | Link of link
type node = content list
type doc = flavour * metadata * node list

Ici, je marque qu’un noeud contient ou bien zéro ou plusieurs valeurs de text, ou bien zéro ou plusieurs valeurs de link. Un poil mieux. Construire une unité de markdown, c’est alors parcourir la chaîne de caractère de la ligne, créer une valeur de type text tant qu’on ne rencontre pas une chaîne de caractère qui correspond à l’ouverture d’un lien ([). De là:

J’en ai marre et j’ai l’impression de ne pas être arrivé à faire quelque chose d’utile. Je m’arrête pour cette fois en attaquant le problème sous un autre angle.

Après avoir discuté avec une personne experte d’Angstrom, je retente le 12-12-2024. Ici, j’utilise une technique au cœur de la bibliothèque: les opérateurs let* et let+. En substance, ce que fait let* c = parser, c’est assigner à la variable c la valeur résultat si parser réussit. L’idée est d’enchaîner les calculs de parsers jusqu’à renvoyer une dernière valeur avec let+. Avec un exemple pour parser un lien interne [[contenu]]:

let internal_link =
  let* opening = string "[[" in
  let* content = take_till1 P.is_link_delim in (*P.is_link_delim indique si un caractère sert à délimiter un lien*)
  let+ closing = string "]]" in Link (Internal content)

Si les parsers réussissent, on renvoie une valeur Link (Internal content). Si il échoue, par contre, l’erreur renvoyée par l’un des parsers sera renvoyée. La raison pour laquelle on a besoin de deux opérateurs let* et let+ est plutôt subtile (et honnêtement, j’ai du mal avec le concept), j’en parlerai peut-être une prochaine fois.

Dans la même logique, pour parser un lien externe ([id](dest)), ça pourrait ressembler à ça (écrit dans le métro donc j’ai pas vérifié si ça marche (astuce, take_till1 n’existe pas dans la bibliothèque)):

let external_link =
  let* opening = string "[" in
  let* next_char = match peek with
  (* Si on a deux [[, on renvoie le parser de lien internes *)
    | '[' -> internal_link
    | _ -> let* _ = advance in
           let* id = take_till1 P.is_link_delim in
           (* On observe depuis le premier "[" voir ce qui se
            trame *) 
           let* c1, c2 =
             peek >>| fun _ -> peek in
             match c1, c2 with
             (* Si on a l'enchaînement [](...
             alors c'est un lien externe sans id. *)
             | ']','(' ->
              (* On avance de deux caractères et on continue *)
              let* _ = advance 2 in
              let+ dst = take_till1 P.is_link_delim in
              Link (External None dst)
             | _,_ -> let* id = take_till1 P.is_link_delim in 
             let* _ = advance 2 in 
             let+ dst = take_till1 P.is_link_delim in
             Link (External (Some id) dst)


let eol = end_of_line <*> end_of_input 

let line_parse parser = parser <* eol

let markdown_line = line_parse (take_till P.is_link_opening <|> internal_link <|> external_link)

Le 20-12-2024, j’ai profité d’un long trajet pour obtenir le code suivant, qui marche:

module P = struct
  let is_minus_or_eol = function '-' | '\n' -> true | _ -> false
  let is_left_paren = function '(' -> true | _ -> false
  let is_right_bracket = function ']' -> true | _ -> false
  let is_not_left_bracket_or_eol = function '[' | '\n' -> false | _ -> true
  let eol = end_of_line <|> end_of_input (* Angstrom a un peu de mal avec la fin des fichiers*)
end

let enclosed sep p = sep *> p <* sep <?> "enclosed" (*Un parser qui prend le résultat du parser entouré par deux autres*)
let line parser = parser <* P.eol <?> "line" (*On parse une ligne avec un parser*)

let internal_link =
  (let* _ = string left_internal_sep in
   let* content = take_till P.is_right_bracket in
   let+ _ = string right_internal_sep in
   Link (Local content))
  <?> "internal_link"
(*Un lien interne est toute chaîne de caractère qui n'est pas un crochet ouvrant.*)

let not_link =
  (let+ s = take_while1 P.is_not_left_bracket_or_eol in
   Text s)
  <?> "not_link"
  (*Un peu hackish: on considère que tout ce qui n'est ni un crochet ouvrant ni une fin de ligne est du contenu valide*)

let metadata =
  (let entries = many (line (take_till P.is_minus_or_eol)) <?> "entries" in
  (*les métadonnées sont plusieurs lignes de chaîne qui ne sont ni des sauts de ligne, ni des fin de lignes*)
   let delim = string yaml_sep *> end_of_line in
   (*---\n*)
   let+ meta = enclosed delim entries in
   Yaml meta)
  <?> "metadata"

let doc =
  let content = (many1 (line (many1 (not_link <|> internal_link)))) in
  both metadata content <?> "doc"
(*un document est composé de métadonnées et d'au moins une ligne; une ligne est constituée d'au moins un lien interne ou ce qui ne relève pas de lien*)

Petites subtilités en vrac:

À noter que le code que j’ai écrit ici n’est pas sorti de ma tête d’un coup. J’ai fait beaucoup d’aller et retour entre tests et code. Mais j’ai galéré: en particulier, j’ai eu du mal à me rendre compte que je devais expliciter que le contenu de mon document, c’était plusieurs lignes (le premier many1 ( line ; chaque ligne contient au moins un lien ou… pas de lien (many1 (not_link <|> internal_link))).

Gérer l’absence potentielle de métadonnée

Tous mes fichiers n’ont pas de métadonnée. On devrait pouvoir traiter les métadonnées comme optionnelles (potentiellement, il suffit de renvoyer None si le parser échoue; où alors faire renvoyer au parser une valeur option de métadonnée? )

Paramétrer la conversion de format

Comment écrire un type tel que convert_markdown_to puisse prendre en entrée un format donné?

Représentation fine des métadonnées

On fait une représentation 1:1, mais on sait que le Toml et le Yaml ne représentent pas leurs données de la même façon. En l’état, on risque d’avoir une erreur. Il faudrait donc les représenter de manière plus fine et écrire les fonctions de conversion similaires

(hint pour le parsing de Yaml: il faudra probablement utiliser Angstrom.fix vu que c’est récursif).

Retour en haut de page Tous mes posts sont en licence CC-BY-NC-SA 4.0