Skelets numériques
É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 :
- format de métadonnée différent : yaml (un JSON simplifié), Toml (un gros record avec des sections)
- format de lien différent: les liens hypertextes font référence à l’extérieur et ne changent pas, en revanche y’a un enjeu sur les ressources locales
- dans obsidian, on utilise des wikilinks pour dénoter une ressource stockée localement et vers laquelle on veut faire référence. la conversion vers un autre format de markdown implique donc de copier la ressource et de la rendre accessible
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:
- un document markdown, constitué de métadonnées, de texte et de liens entrelacés
- Les métadonnées doivent toujours être en en-tête de document. Il s’agit de texte arbitraire entouré par des délimiteurs
- les liens sont des texte décrivant un chemin vers des références internes au système de fichier, ou externe. On différencie ces deux cas de figure par la syntaxe. un lien externe peut optionnellement être affiché par un nom différent.
- les « saveurs » de markdown qui paramétrisent la représentation textuelle des liens
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:
- pas de séquence d’ouverture (
[[
pour l’interne,[
et(
pour l’externe) ni de fermeture (]]
pour l’interne,]
et)
pour l’externe) t1
peut être différent det2
.t1
peut aussi être vide.- pas de saut de ligne
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:
- si tu croises un lien, renvoies un lien et continue
- si tu ne croises pas un lien, continue jusqu’à croiser l’ouverture d’un lien
Une difficulté conceptuelle que j’ai, c’est:
- je traite tout comme une string, alors que je devrais traiter ligne par ligne pour rendre mon parser plus lisible (et séparer les cas)
- comment faire un parser d’une ligne avec un (ou plusieurs) liens
- comment spécifier un texte qui ne contient pas de lien?
- tout ce qui est compris entre un début de ligne et un
[
, un]
et une fin de ligne ou un autre[
- tout ce qui est compris entre un début de ligne et un
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
- une ligne de contenu sans lien
- une ligne de contenu qui contient au moins un lien.
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à:
- si il y a un deuxième
[
(on peut peek), alors on lance le parser de lien interne - si il y a autre chose, on parse jusqu’à
]
- si après
[
, on a immédiatement(
, alors on parse jusqu’à)
- si après
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:
take_till
n’échoue pas et renvoie une chaîne vide si la condition se vérifie; donc c’est plutôt source d’erreur (parfois, le parser renvoie juste une chaîne vide)<?>
aide beaucoup à debugger: ça sert à nommer un parser. Quand l’exécution d’un parser échoue,- une erreur courante, c’est le parser qui “globe” tout le reste de l’input. Il vaut mieux avoir un parser plus restrictif que pas assez
À 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).