Code: Table des matières

Ceci est le genre d'exercice que vous pourriez passer dans l'un ou l'autre test technique. Chez Google par exemple, il est à réaliser en 30 minutes. Il est évalué comme difficile.

Copie d'écran montrant les caractéristiques du test

Table of Contents

Description de l'exercice

 
Sur les sites Web, les balises d'en-tête donnent une hiérarchie à la page et les informations d'en-tête peuvent être utilisées pour construire automatiquement une table des matières pour un document.
 
Sur base d'un document donné, écrire une fonction `tableOfContents` qui va générer une chaîne de caractères HTML représentant une table des matières basée sur les balises d'en-tête (<h1>, <h2>, ..., <h6>) du document. Suivant les bonnes pratiques, les niveaux d'en-tête ne peuvent pas être sautés, par ex. <h1> sera suivi par <h2>.
const doc = new DOMParser().parseFromString(`
<!DOCTYPE html>
<html>
  <head>
  <head>
  <body>
    <h1>Heading 1</h1>
	  <h2>Heading 2a</h1>
	  <h2>Heading 2b</h1>
	  <h3>Heading 3a</h1>
	  <h3>Heading 3b</h1>
	  <h4>Heading 4</h1>
	  <h2>Heading 2c</h1>
  </body>
</html>
`)
const htmlString = tableOfContents(doc);
console.log(htmlString);

<ul>
  <li>Heading 1
    <ul>
      <li>Heading 2a</li>
      <li>Heading 2b
      <ul>
        <li>Heading 3a</li>
        <li>Heading 3b
        <ul>
          <li>Heading 4a
        </ul>
        </li>
      </ul>
      </li>
      <li>Heading 2c</li> 
    </ul>
  </li>
</ul>

Analyse

En soi, l'exercice n'est pas très compliqué. Le paramètre de la fonction est de type document. Il faut récupérer les balises d'en-tête (<h1><h6>). Les balises <h2> doivent être imbriquées dans la balise <h1> qui les précède. Idem pour les <h3> envers les <h2>. Et ainsi de suite jusque <h6>.

Le seul piège réside dans le saut éventuel d'une balise d'en-tête <h[x]> à une balise supérieure (<h[x+2]>) sans passer par une balise <h[x+1]>. Que faut-il faire dans ce cas-là? On insère simplement la/les balise(s) manquante(s) pour que l'arborescence soit correcte. 

On retourne dans un premier temps une arborescence sous forme d'un tableau multidimensionnel (nested arrays). Dans un second temps, on se servira de cette structure pour rendre les données dans des listes imbriquées (<ul><li>). L'ultime étape consistera à pouvoir paramétrer les balises qui contiendront les données.

Code

Code d'amorce standard

L'ossature de base se compose de 3 fichiers.

index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Table Of Content</title>
</head>
<body>
    <script src="main.js" type="module"></script>
</body>
</html>
main.js
import tableOfContent from './TableOfContent.js'

const html = `
<!DOCTYPE html>
<html>
  <head>
  <head>
  <body>
    <h1>Heading 1</h1>
    <h2>Heading 2a</h2>
    <h2>Heading 2b</h2>
    <h3>Heading 3a</h3>
    <h3>Heading 3b</h3>
    <h4>Heading 4</h4>
    <h2>Heading 2c</h2>
  </body>
</html>
`

const doc = new DOMParser().parseFromString(html,"text/html")

console.log( tableOfContent(doc) )
TableOfContent.js
function TableOfContent(doc) {
    return "Hello world"
}

export default TableOfContent

En exécutant la page index.html et en ouvrant la console du navigateur, on devrait avoir le message Hello world affiché.

J'essaierai par la suite de convertir le contenu de main.js pour y exécuter la série de tests à l'aide d'un framework prévu à cet effet.

Obtenir les balises d'en-tête

Il semblerait que certains se demandent comment récupérer les balises spécifiques du document. Je ne me suis pas posé longtemps la question. Bon, j'avoue, j'utilise fréquemment cette méthode donc je n'ai aucun mérite à y avoir pensé.

document.querySelectorAll()

On doit spécifier les balises qu'on souhaite récupérer dans le document. Cette méthode nous renvoie un objet de type NodeList comprenant toutes les balises du document correspondant aux types de balises demandés. Il suffit alors d'itérer cet objet et effectuer le traitement nécessaire sur chaque item.

Modifions le fichier TableOfContent.js comme suit :

function TableOfContent(doc) {
    return doc.querySelectorAll('h1, h2, h3, h4, h5, h6')
}

export default TableOfContent

Le résultat obtenu devrait être le suivant : NodeList(7) [ h1, h2, h2, h3, h3, h4, h2 ]

Itérer les balises

L'objet NodeList a la méthode forEach() classique de l'itération. Servons-nous en comme ci-dessous :

function TableOfContent(doc) {
    
    let result = []

    doc.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(function(item, index, all) {

        result.push({
            tagName: item.tagName,
            innerText: item.innerText
        })


    })
    return result
}

export default TableOfContent

Le résultat dans la console devrait être comme ci-après :

Array(7) [ {…}, {…}, {…}, {…}, {…}, {…}, {…} ]  
0: Object { tagName: "H1", innerText: "Heading 1" }
1: Object { tagName: "H2", innerText: "Heading 2a" }
2: Object { tagName: "H2", innerText: "Heading 2b" }
3: Object { tagName: "H3", innerText: "Heading 3a" }
4: Object { tagName: "H3", innerText: "Heading 3b" }
5: Object { tagName: "H4", innerText: "Heading 4" }
6: Object { tagName: "H2", innerText: "Heading 2c" }
length: 7
<prototype>: Array []

Pour le fun

Il était possible d'écrire d'une autre façon la fonction qui transforme chaque item en objet simple et l'ajoute dans le tableau. J'avais choisi cette manière préalablement. Puis je me suis ravisé. Utiliser ce code ne facilite pas sa maintenance, ni sa compréhension. Et il n'améliore pas la rapidité ou l'adaptabilité de la fonction. Mais ça a de la gueule quand même! 😀

explication d'une fonction

Imbriquer les nœuds

Il faut à présent transformer la structure linéaire en arborescence. Je ne vais pas me soucier dans un premier temps des éventuelles erreurs dans la structure du document. Si on passe de h1 à h3, ben, pas grave. Je me contente dans cette étape de gérer simplement les changements de niveaux.

Je me suis rapidement rendu compte que régler cette transformation nécessiterait l'utilisation d'objets. Le but est de pouvoir utiliser le niveau de l'en-tête actuellement pointée afin de savoir où placer le nœud suivant. Et grâce aux objets, il sera facile de remonter l'arborescence pour trouver l'ancêtre correspondant au niveau recherché.

Les nœuds doivent être traités un à la fois, séquentiellement, dans l'ordre de présence dans le DOM.
Il ne faut pas exclure la possibilité qu'un en-tête manque dans la séquence.
Il est également possible que l'affichage des nœuds du DOM soit modifié par le CSS. 
Lors du traitement d'un nœud, on compare le niveau du nœud traité défalqué de 1 avec celui du nœud ancêtre.
Si le niveau correspond, on ajoute le nœud traité au noeud ancêtre.
Le nœud nouvellement ajouté devient le nouveau nœud ancêtre.
On traite ainsi tous les nœuds de la séquence.
Si le niveau moins un du nœud en cours de traitement ne correspond pas au niveau du nœud ancêtre, on recherche le premier ancêtre dans la lignée qui correpond à ce niveau.
Lorsque le nœud ancêtre est trouvé, on lui ajoute le nœud en traitement.
Et le nœud juste ajouté devient le nouveau nœud ancêtre.

Créer un objet

Comme déjà écrit précédemment, il me semble logique et plus simple de créer une classe pour résoudre ce problème. Ici le but est de créer une arborescence. Chaque nœud sera donc une instance de la même classe. Chaque nœud pourra avoir d'autres nœuds enfants et un nœud parent.

Le nœud racine sera un singleton. Cet objet créera les nœuds enfants.

Pour aller plus loin

Ajouter des liens dans la structure de la table des matières. Chaque lien ferait dérouler la page jusqu'au niveau de la balise d'en-tête correpondante. Les balises d'en-tête doivent alors posséder un attribut id avec une valeur. Cette valeur servira à définir le fragment de l'URL du lien.

Mais ceci n'est possible que si le document passé en paramètre est le document d'origine!

Code final

Voir le lien ou le dépôt de code

Commentaires