Résume/CV Terminal

Contexte

Ca faisait un petit moment que je cherchais à réaliser un projet sympa qui soit en lien avec mon activité de développeur. J'ai donc penser à une façon original de représenter mon CV, le format sous forme de terminal m'a rapidement semblé répondre à ces critères. L'ensemble du code est disponible sur github.

Fonctionnement

Général

J'avais dans un premier temps réalisé une première version de ce projet sous la forme d'un terminal dans lequel les commandes se jouaient automatiquement. Après réflexion je me suis dit que ça serait plus sympa d'ajouter un peu d'interaction pour pousser plus loin le réalisme du terminal, c'est chose faite !

Dans ce projet, j'ai découpé les commandes en deux types. Les premières celles que je vais appeler "classiques" sont celles qui se situent dans le fichier commands.json j'associe à chaque commande un retour à afficher en réponse de la commande.

Il y a au moment ou j'écris ces lignes 4 types de retours possibles :

  • list: Une liste qui sera affichée sous forme de puces
  • text: Qui sera simplement affiché comme du texte
  • code: Qui sera affiché avec un format monospace
  • table: Qui affiche un tableau

Par exemple, ici pour la commande a-propos j'indique que ce qui va être affiché est de type code: ça sera mis entre des balises pre (on voit ça plus loin).

// Extrait du fichier commands.json
{
    "command":"a-propos",
    "responseType":"code",
    "value":[
        "{",
        "   \"nom\" : \"Antoine DAUTRY\",",
        "   \"poste\" : \"Développeur Fullstack\",",
        "   \"experience\" : \"2 ans\",",
        "   \"ville\" : \"Nantes, France\"",
        "}"
    ]
}

Le deuxième type de commande que j'appelle "custom" sont des commandes qui nécessitent un traitement javascript par exemple pour la commande clear je dois en JS supprimer les div en trop. 

Je vous propose de voir plus en détail dans la partie technique comment ça marche.

Technique

Pour ce projet j'utilise du Javascript et parcel pour pouvoir générer le code final.

Côté html j'ai quelques éléments important : .terminal__body contient toute le corps du terminal et la partie #terminal elle va me servir à injecter les nouvelles lignes, c'est donc dans cette div que tout se passe. 

<div class="terminal">
    <div class="terminal__header">
        <div class="fake-button fake-close"></div>
        <div class="fake-button fake-minimize"></div>
        <div class="fake-button fake-zoom"></div>
    </div>
    <div class="terminal__body">
        <div class="terminal__banner">
            <!-- Ici il y a la bannière, je la retire pour faire un peu de place :D -->
        </div>
        <div id="terminal"></div>
    </div>
</div>

Dans mon JS à chaque nouvelle ligne générée je vais créer une div.terminal__line qui va contenir la ligne où il y a l'input et une div.terminal__response qui contiendra la réponse associée, j'injecte tout ça dans la div#terminal.

Récupération des commandes

C'est dans le fichier app.js que tout se passe, dans un premier temps je vais récupérer l'ensemble des commandes disponibles:

// Tableau contenant les commandes (utile pour la complétion des commandes)
let commandsList = [];
// Je récupère toutes les commandes qui sont dans le fichier commands.json
commands.forEach((c) => {
  commandsList.push(c.command);
});

// Commandes qui nécessitent un traitement JS
const customCommands = ["clear", "dark", "light", "get cv"];
commandsList = commandsList.concat(customCommands);

// Commandes 'easter eggs' non disponibles à l'autocomplétion
const hiddenCommands = ["pif"];

Ajout des lignes

Au chargement de la page je dois générer une première ligne que j'injecte dans la div#terminal. Pour faire ça, j'ai créé une fonction qui porte bien son nom: addNewLine. C'est une des deux fonctions principales de ce projet.

J'ai du me poser une question:

Comment je me débrouille pour que quand un utilisateur tape une commande la réponse soit placée dans la div.terminal__response associée?

Pour résoudre ce problème j'ai opté pour l'uid.

function addNewLine(previousUid = null) {
// Je génère ici un uid qui sera associé à mon input
  const uid = Math.random().toString(36).replace("0.", "");
  // Je crée l'élément div.terminal__line
  const terminalLineEl = document.createElement("div");
  terminalLineEl.classList.add("terminal__line");

  // l'élément div.terminal__response
  const terminalResponseEl = document.createElement("div");
  terminalResponseEl.classList.add("terminal__response"); 
  // Le donne comme id à ma réponse 'reponse-' puis j'ajoute l'uid qui me permettera de le retrouver facilement
  terminalResponseEl.id = `response-${uid}`;

  // input text
  const inputEl = document.createElement("input");
  inputEl.type = "text";
  inputEl.id = `input-${uid}`;  
  inputEl.autocapitalize = "off";
  // J'ajoute l'uid dans le dataset de l'input
  // Je sais donc que si une commande est tappée dans l'input avec un dataset-uid à 123
  // Je dois mettre la réponse dans la div#response-123
  inputEl.dataset.uid = uid; 
  // Utile pour le focus, quand l'utilisateur clique n'importe où sur la page ça va mettre
  // le focus sur l'élément qui a le dataset active
  inputEl.dataset.active = "1";
  inputEl.addEventListener("keydown", onCommandInput); // J'ajoute un listenner pour écouter quand l'utilisateur tape
  
  terminalLineEl.appendChild(inputEl);
  if (previousUid) {
    const previousInputEl = document.getElementById(previousUid);
    if (previousInputEl) {
      // Si il y a une ligne précédente je dois :
      // - Désactiver l'input
      previousInputEl.setAttribute("disabled", "true");
      // - Lui retirer le listenner
      previousInputEl.removeEventListener("keydown", onCommandInput);
      // - Supprimer le active, ce n'est plus ce champ qui dois être focus
      delete previousInputEl.dataset.active;
    }
  }
  document.getElementById("terminal").appendChild(terminalLineEl);
  document.getElementById("terminal").appendChild(terminalResponseEl);

  inputEl.focus(); // Ajoute le focus dès la création du champs
}

Gestion des commandes

Ja gestion des commandes va se passer dans la fonction qui écoute l'input: onCommandInput

function onCommandInput(e) {
  const commandValue = e.target.value.trim().toLowerCase();
  if (e.keyCode === 13) {
    // Si l'utilisateur appuie sur Entrer
    if (commandValue !== "") {
      historyMode = false;
      // On récupère déjà l'id de la réponse correspondant à l'input en question
      const idResponse = `response-${e.target.dataset.uid}`;
      const responseEl = document.getElementById(idResponse);
      if (commandValue === "clear") {
        // Ici je gère directement le clear mais je pourrai/vais le faire dans la fonction
        // handleCustomCommands
        terminalBody.innerHTML = `<div id="terminal"></div>`;
        addNewLine();
        return;
      }
      let html;
      if (hiddenCommands.includes(commandValue) || customCommands.includes(commandValue)) {
        // Ici la commande nécessite un traitement JS qui va être traitée par la fonction handleCustomCommands
        html = handleCustomCommands(commandValue);
      } else {
        // La commande ne nécessite pas de JS, donc on va récupérer le html correspondant dans la fonction getDomForCommand
        // J'explique en dessous son fonctionnement
        html = getDomForCommand(commandValue);
      }
      if (responseEl) {
        responseEl.innerHTML = html; // Ajout de la réponse dans la div associée
        commandsHistory.push(commandValue); // Ajout de la commande à l'historique
        addNewLine(e.target.id); // Ajoute d'une nouvelle ligne
      }
    }
  } 
  // [...]
}

Comme je l'ai expliqué plus haut, le fichier commands.json va contenir la liste des commandes disponibles et la valeur à afficher. Chaque valeur a un type particulier et doit être convertit en HTML en fonction de ce même type. Tout ça se passe dans la fonction getDomForCommand.

function getDomForCommand(command) {
// On récupère l'objet JSON associé à cette commande
  const commandObj = commands.find((el) => el.command === command);
  let html = "";
  if (commandObj === undefined) {
    // Si l'objet est vide c'est qu'il n'y a pas de commande et donc => message d'erreur.
    html = `'${
      command.split(" ")[0]
    }' n’est pas reconnu en tant que commande interne ou externe, un programme exécutable ou un fichier de commandes. Tapez la commande <code>help</code> pour afficher la liste des commandes disponibles.`;
  } else {
    if (commandObj.responseType === "list" && Array.isArray(commandObj.value)) {
      // Si c'est une liste on met les valeurs entre des balises ul
      html = "<ul>";
      html += commandObj.value.map((s) => `<li>${s}</li>`).join("");
      html += "</ul>";
    } else if (commandObj.responseType === "text") {
      html = commandObj.value;
    } else if (commandObj.responseType === "table") {
      // Si c'est un tableau on met les valeurs entre des balises table et on construit le tableau
      const headers = commandObj.headers;
      const rows = commandObj.rows;
      const thsHtml = headers.map((h) => `<th>${h}</th>`).join("");
      const tdsHtml = rows
        .map((r) => `<tr>${r.map((rtd) => `<td>${rtd}</td>`).join("")}</tr>`)
        .join("");
      html = `<table><thead><tr>${thsHtml}</tr></thead><tbody>${tdsHtml}</tbody></table>`;
    } else if (commandObj.responseType === "code") {
      // Et pour le code on met tout entre des balises pre
      html = `<pre>${commandObj.value.join("\n")}</pre>`;
    }
  }

  return html;
}

 

Voilà pour le fonctionnement global de ce petit projet 😀