Comprendre la syntaxe du Markdown

Part II : Les en-têtes

Cela fait près d’un mois que l’on n’avait pas parlé de Markdown. Continuons l’aventure folle qui nous mènera à un parseur Markdown complet.

La dernière fois, nous avons réussi à diviser notre fichier en plusieurs paragraphes. Mais des paragraphes seuls ne font pas un document complet. La prochaine étape logique est d’extraire les en-têtes de notre fichier Markdown.

En-têtes

Si vous lisez ce blog, ou n’importe quel autre blog d’ailleurs, ou bien la plupart des livres, ou des documents à votre boulot, vous avez déjà vu des en-têtes.

Ils structurent un document en un plan facile à lire, nous aident à nous souvenir du contenu et nous permettent de lire un document en diagonale et de quand même avoir une idée du contenu.

En Markdown, les en-têtes utilisent deux syntaxes différentes :

Parce que notre objectif est d’avoir un parseur qui implémente toute la syntaxe décrite sur le site de John Gruber, il nous faudra être capable d’extraire les deux types d’en-têtes.

Un chemin ensablé au milieu de végétation près de la plage

« Des en-têtes ? Là où nous allons, nous n’avons pas besoin d’en-têtes ! » ― Dr Emmett Brown

Setext

Les en-têtes de type Setext ne supportent que deux niveaux de hiérarchie (Les titres et les sous-titres).

Les en-têtes de type Setext sont « soulignées » en utilisant des signes égal (pour des titres) et des tirets (pour des sous-titres). […] Le nombre de = ou de - n’est pas important.

Voici un example de ce à quoi ressemble les en-têtes Setext :

Comprendre la syntaxe du Markdown
=================================

Les en-têtes
------------

Dans l’exemple ci-dessus, j’ai fait en sorte que la longueur des titres et des caractères qui les soulignent correspondent pour des raisons esthétiques. Mais comme indiqué dans les spécifications du Markdown, un seul = ou - suffit pour indiquer qu’il s’agit respectivement d’un titre ou d’un sous-titre.

Donc si je récapitule tout ce que l’on a appris, et en prenant en compte ce que l’on sait pour les lignes vides et paragraphes, un en-tête Setext peut être décrit comme suit :

Interpréter les en-têtes

Nous savons déjà comment traiter les paragraphes. Essayons de comprendre comment on peut interpréter la dernière ligne de l’en-tête Setext en transformant ce que l’on vient de décrire en une expression régulière.

// Soulignement de titre
/^=+[ \t]*$/

// Soulignement de sous-titre
/^-+[ \t]*$/

Afin de nous assurer que nos deux expressions régulières fonctionnent comme on veut, nous allons utiliser la même méthode que pour l’article précédent :  une page web avec un peu de javascript.

Voici le code javascript dont vous aurez besoin pour tester nos deux regex. Comme d’habitude, voici un lien vers la page qui utilise ce code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/*
 * On définit à nouveau un peu de contenu
 * afin de pouvoir tester notre code.
 */
const content = `
Premier titre
=============

Sous-titre
multiligne
------

Un autre titre
=

Un sous-titre simple
--------------------`;

const blankLine = /^[\t ]*$/;

const headers = {
  setext: {
    first: /^=+[ \t]*$/,
    second: /^-+[ \t]*$/,
  },
};

let lines = content.split('\n');
let headerFound = 0;
let currentParagraph = [];

lines.forEach((line, index) => {
  // Si on trouve des caractères de « soulignement »
  if (
    (line.match(headers.setext.first) || line.match(headers.setext.second)) &&
    currentParagraph.length > 0
  ) {
    // On récupère le paragraphe courant
    const header = currentParagraph
      .join('\n')
      .replace('  \n', '<br/>')
      .replace('\n', ' ')
      .trim();

    // Et on écrit ce que l’on a trouvé sur la page
    document.write(`En-tête ${++headerFound} : ${header}<br/>`);

    currentParagraph = [];
    return;
  }

  if (!line.match(blankLine)) {
    currentParagraph.push(line);
  } else {
    currentParagraph = [];
  }
});

Avec ce code, on récupère nos en-têtes comme suit :

En-tête 1 : Premier titre
En-tête 2 : Sous-titre multiligne
En-tête 3 : Un autre titre
En-tête 4 : Un sous-titre simple

Mais il nous manque quelque-chose d’essentiel à la structure de notre document : les niveaux d’en-têtes.

On a réussi à interpréter nos en-têtes, mais il nous faut aussi identifier et passer au moteur de rendu leurs niveaux respectifs. Tentons de faire ça. Avec nos en-têtes Setext, nous n’avons que deux niveaux différents d’en-têtes à gérer donc ça ne devrait pas être trop compliqué.

Détecter les niveaux des en-têtes

Ajoutez le code suivant sur la page précédente, au début de la boucle forEach, ou bien, utilisez ce lien, si vous êtes pris d’une flemme passagère.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
lines.forEach((line, index) => {
    // Disons que par défaut, notre en-tête est de niveau 1 (un titre)
    let headerLevel = 1;

    // Si on détecte un en-tête de niveau 2 (un sous-titre)
    if (
        line.match(headers.setext.second)
        && currentParagraph.length > 0
    ) {
        // On définit le niveau de l'en-tête à 2
        headerLevel = 2;
    }
});

Il faut également changer le code qui affiche le résultat comme suit :

1
2
// On affiche le niveau de l’en-tête
document.write(`En-tête ${++headerFound} (h${headerLevel}) : ${header}<br/>`);

Nos modifications nous donnent le résultat suivant :

En-tête 1 (h1) : Premier titre
En-tête 2 (h2) : Sous-titre multiligne
En-tête 3 (h1) : Un autre titre
En-tête 4 (h2) : Un sous-titre simple

Parfait ! On sait maintenant détecter le niveau de notre en-tête. Continuons avec le deuxième type d’en-tête : les en-têtes atx.

atx

Les en-têtes de type atx sont les en-têtes les plus utilisés en Markdown. Ils permettent d’utiliser six niveaux différents d’en-tête. Les en-têtes atx sont à privilégier pour les documents avec une structure complexe. Par exemple, cet article utilise quatre niveaux d’en-têtes.

Ces en-têtes utilisent un à six dièses en début de ligne pour exprimer le niveau de structure. Ils sont écrits comme suit :

# En-tête de premier niveau
## En-tête de second niveau
##### En-tête de cinquième niveau et ainsi de suite

Les en-têtes peuvent également contenir un nombre arbitraire de dièses en fin de ligne. Seuls les dièses en début de ligne définissent le niveau de structure.

# En-tête de premier niveau #
##### En-tête de cinquième niveau et ainsi de suite ################

En gardant tout ça à l’esprit (j’ai à nouveau seulement paraphrasé les spécifications officielles), ainsi que ce que l’on vient de découvrir avec les en-têtes de type Setext, on peut définir les en-têtes atx comme suit :

Traduisons ces deux phrases en une expression régulière :

/^(#{1-6})[ ]*(.*?)[ ]+#*[ \t]*$/;

Cette expression régulière est légèrement plus compliquée que celles que nous avons utilisées par le passé. Il existe des outils tels que regex101 qui peuvent décrire le fonctionnement des expressions régulières. Vous pouvez donc vous en servir pour élucider le fonctionnement de celle ci-dessus si vous êtes curieux.

Testons cette regex. On peut utiliser le code que l’on a écrit plus tôt et le modifier avec le contenu de test suivant :

1
2
3
4
5
6
7
const content = `
## Un titre
# Un autre titre sur une ligne adjacente

### Un titre avec des dièses en fin de ligne #####

###### Un autre titre mais celui-ci fini avec un # #`;

On va aussi ajuster l’expression régulière utilisée avec celle que l’on a écrit quelques lignes plus haut. Voici la boucle logique que l’on va utiliser :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const headers = {
  atx: /^(#{1-6}) *(.*?) +#+[ \t]*$/,
  setext: {
    first: /^=+[ \t]*$/,
    second: /^-+[ \t]*$/,
  },
};

lines.forEach((line, index) => {
  if (line.match(headers.atx)) {
    // On récupère les résultats extraits par la regex
    let [_, hashes, headerContent] = line.match(headers.atx);

    const header = headerContent.replace('  \n', '<br/>').replace('\n', ' ').trim();

    // Le niveau de l’en-tête correspond au nombre de dièses
    let headerLevel = hashes.length;

    // On affiche le niveau de l’en-tête en même temps que son contenu
    document.write(`En-tête ${++headerFound} (h${headerLevel}) :  ${header}<br/>`);

    return;
  }
});

On sait maintenant extraire les en-têtes de type atx. En exécutant le code ci-dessus (ou en suivant ce lien), on obtient ce qui suit :

En-tête 1 (h2) : Un titre
En-tête 2 (h1) : Un autre titre sur une ligne adjacente
En-tête 3 (h3) : Un titre avec des dièses en fin de ligne
En-tête 4 (h6) : Un autre titre mais celui-ci fini avec un #

Faire le rendu des en-têtes

La dernière étape est de faire le rendu de nos en-têtes. On va fusionner le code de l’article précédent avec le code que l’on vient d’écrire pour extraire nos en-têtes.

Notre code devrait ressembler à ça :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
lines.forEach((line, index) => {
  if (line.match(headers.atx)) {
    let [_, hashes, headerContent] = line.match(headers.atx);
    let headerLevel = hashes.length;

    return renderHeader(headerContent, headerLevel);
  }

  // Si on trouve un en-tête de type Setext
  if (
    (line.match(headers.setext.first) || line.match(headers.setext.second)) &&
    currentParagraph.length > 0
  ) {
    // Disons que par défaut, notre en-tête est de niveau 1 (un titre)
    let headerLevel = 1;

    // Si on détecte un en-tête de niveau 2 (un sous-titre)
    if (line.match(headers.setext.second) && currentParagraph.length > 0) {
      // On défini le niveau de l'en-tête à 2
      headerLevel = 2;
    }

    headerContent = currentParagraph.join('\n');

    currentParagraph = [];
    return renderHeader(headerContent, headerLevel);
  }

  if (!line.match(blankLine)) {
    currentParagraph.push(line);
  }

  if (
    (line.match(blankLine) || index === lines.length - 1) &&
    currentParagraph.length > 0
  ) {
    let paragraph = document.createElement('p');
    paragraph.innerHTML = currentParagraph
      .join('\n')
      .replace('  \n', '<br/>')
      .replace('\n', ' ')
      .trim();

    body.appendChild(paragraph);

    currentParagraph = [];
    return;
  }
});

La fonction renderHeader contient le code suivant :

1
2
3
4
5
6
7
8
let renderHeader = function (content, level = 1) {
  header = content.replace('  \n', '<br/>').replace('\n', ' ').trim();

  let element = document.createElement(`h${level}`);
  element.innerHTML = header;

  body.appendChild(element);
};

Ce code commence à être un peu long. Dans le prochain article de cette série, nous le retravaillerons pour le simplifier. Mais au moins, il fonctionne ! Vous pourriez déjà vous en servir pour traiter un document Markdown composé d’en-têtes et de paragraphes.

Pas totalement utile pour l’instant. Pour voir le résultat, vous pouvez aller sur cette page.

Si vous êtes arrivé jusque-là, félicitations ! 🎉 Vous avez lu au moins 160 lignes de code. À partir de l’article suivant, je vais essayer de déplacer le code vers un dépôt sur GitHub pour faciliter votre lecture.

Une paire de lunettes posées sur une écharpe

Maintenant, reposez vos yeux et allez lire quelque-chose qui n’est pas sur un écran.