diff --git a/js/markdown.js b/js/markdown.js new file mode 100644 index 0000000..83859a4 --- /dev/null +++ b/js/markdown.js @@ -0,0 +1,568 @@ +/** + * Analyse du markdown des notes. + * + * Ce module ne touche ni au DOM ni au réseau : il transforme du texte en arbre, + * exactement comme `model.js` transforme des objets. C'est ce qui le rend + * testable sous Node (tests/markdown.test.js), et c'est aussi ce qui garde le + * rendu — forcément lié au DOM — dans `notes.js`. + * + * **Un sous-ensemble, pas CommonMark.** L'outil n'embarque aucune dépendance + * (décision 10) : écrire un analyseur complet coûterait plus cher que tout le + * reste de l'application réunie, pour une syntaxe que personne n'emploie dans + * une note de suivi de projet. On couvre donc ce qu'on écrit vraiment là : + * titres, emphase, code, listes, cases à cocher, liens, citations, séparateur. + * Les tableaux, les images et le HTML brut sont volontairement absents — les + * deux premiers ne tiendraient pas dans un volet de 340 pixels, le troisième + * rouvrirait la porte à l'injection que la construction du DOM ferme. + * + * **Ce qui n'est pas reconnu reste du texte.** Une note à moitié écrite est + * l'état normal d'une note : `**gras` sans fermeture s'affiche tel quel plutôt + * que d'avaler le reste du paragraphe. Aucune saisie n'est donc jamais perdue, + * et l'aperçu suit la frappe sans à-coups. + */ + +/** + * Schémas d'URL autorisés dans un lien. + * + * Liste blanche et non liste noire : `javascript:` et `data:` sont les vecteurs + * connus, mais c'est la logique qui compte — un fichier de planning s'échange, + * et rien ne garantit que la note qu'on affiche a été écrite par celui qui la + * lit. Un lien au schéma refusé n'est pas effacé, il redevient du texte. + */ +const SCHEMAS_AUTORISES = ['http:', 'https:', 'mailto:']; + +/** Élément de liste : indentation, marqueur, puis le reste de la ligne. */ +const MOTIF_ELEMENT = /^(\s*)([-*+]|\d+[.)])[ \t]+(.*)$/; + +/** Case à cocher, en tête du contenu d'un élément de liste. */ +const MOTIF_CASE = /^\[([ xX])\][ \t]+/; + +const MOTIF_TITRE = /^(#{1,6})[ \t]+(.*)$/; +const MOTIF_SEPARATEUR = /^[ \t]*(?:-{3,}|\*{3,}|_{3,})[ \t]*$/; +const MOTIF_CITATION = /^[ \t]*>[ \t]?(.*)$/; +const MOTIF_CLOTURE_CODE = /^[ \t]*(?:```|~~~)[ \t]*(.*)$/; + +/** Caractères qu'une contre-oblique peut neutraliser. */ +const ECHAPPABLES = '\\`*_[]()#>-+.!~'; + +// --------------------------------------------------------------------------- +// Analyse en blocs +// --------------------------------------------------------------------------- + +/** + * Transforme une note en une suite de blocs. + * + * @param {string} texte + * @returns {Array} blocs de type `titre`, `paragraphe`, `liste`, + * `citation`, `code` ou `separateur`. + * + * Chaque élément de liste porte le **numéro de sa ligne source**. C'est ce qui + * permet à `basculerCase` de retrouver la ligne à réécrire quand on clique une + * case dans l'aperçu : le rendu ne renvoie pas un texte reconstruit — qui + * normaliserait au passage l'écriture de l'utilisateur — mais désigne la ligne + * exacte à modifier dans la note d'origine. + */ +export function analyserMarkdown(texte) { + return analyserBlocs(String(texte ?? '').split('\n'), 0); +} + +/** + * @param {string[]} lignes lignes du niveau courant, déjà désindentées. + * @param {number} decalage numéro, dans la note d'origine, de `lignes[0]`. + * Une citation ou une sous-liste analyse un extrait : sans ce décalage, + * les numéros de ligne repartiraient de zéro et les cases à cocher + * imbriquées basculeraient une ligne au hasard. + */ +function analyserBlocs(lignes, decalage) { + const blocs = []; + let i = 0; + + while (i < lignes.length) { + const ligne = lignes[i]; + + if (!ligne.trim()) { + i += 1; + continue; + } + + const cloture = MOTIF_CLOTURE_CODE.exec(ligne); + if (cloture) { + i = lireBlocCode(lignes, i, cloture[1].trim(), blocs); + continue; + } + + // Le séparateur passe avant la liste : `---` répond aussi au motif d'un + // élément à marqueur `-`, et une ligne d'horizon n'est pas une puce vide. + if (MOTIF_SEPARATEUR.test(ligne)) { + blocs.push({ type: 'separateur' }); + i += 1; + continue; + } + + const titre = MOTIF_TITRE.exec(ligne); + if (titre) { + blocs.push({ + type: 'titre', + niveau: titre[1].length, + contenu: analyserInline(titre[2].trim()), + }); + i += 1; + continue; + } + + if (MOTIF_CITATION.test(ligne)) { + i = lireCitation(lignes, i, decalage, blocs); + continue; + } + + if (MOTIF_ELEMENT.test(ligne)) { + i = lireListe(lignes, i, decalage, blocs); + continue; + } + + i = lireParagraphe(lignes, i, blocs); + } + + return blocs; +} + +/** + * Bloc de code clôturé. Tout y est pris à la lettre jusqu'à la clôture, ou + * jusqu'à la fin de la note si elle manque — on est peut-être en train de + * l'écrire, et avaler la suite est moins déroutant que de voir la clôture + * ouvrante s'afficher comme un paragraphe. + */ +function lireBlocCode(lignes, debut, langage, blocs) { + const contenu = []; + let i = debut + 1; + + while (i < lignes.length && !MOTIF_CLOTURE_CODE.test(lignes[i])) { + contenu.push(lignes[i]); + i += 1; + } + + blocs.push({ type: 'code', langage, lignes: contenu }); + return i < lignes.length ? i + 1 : i; +} + +/** + * Citation : les lignes préfixées de `>` sont désindentées puis réanalysées. + * Une citation peut donc contenir une liste ou un titre, ce qui n'est pas de la + * générosité gratuite — on y colle des extraits de compte-rendu, qui en ont. + */ +function lireCitation(lignes, debut, decalage, blocs) { + const internes = []; + let i = debut; + + while (i < lignes.length) { + const citation = MOTIF_CITATION.exec(lignes[i]); + if (!citation) break; + internes.push(citation[1]); + i += 1; + } + + blocs.push({ type: 'citation', blocs: analyserBlocs(internes, decalage + debut) }); + return i; +} + +/** + * Liste, éventuellement imbriquée. + * + * L'imbrication se lit à l'indentation : une ligne plus indentée que le + * marqueur courant appartient à l'élément ouvert, et son contenu — sous-liste + * ou simple suite de texte — est réanalysé après désindentation. Deux espaces + * suffisent, comme partout ailleurs. + * + * Un changement de nature du marqueur ferme la liste : `- a` suivi de `1. b` + * donne deux listes, une à puces et une numérotée, ce qui est bien ce que + * l'écriture montrait. + */ +function lireListe(lignes, debut, decalage, blocs) { + const premier = MOTIF_ELEMENT.exec(lignes[debut]); + const indentBase = premier[1].length; + const ordonnee = /\d/.test(premier[2]); + const elements = []; + let i = debut; + + while (i < lignes.length) { + const element = MOTIF_ELEMENT.exec(lignes[i]); + if (!element || element[1].length !== indentBase) break; + if (/\d/.test(element[2]) !== ordonnee) break; + + const { contenu, coche } = detacherCase(element[3]); + const ligneMarqueur = i; + const interne = []; + i += 1; + + // Tout ce qui suit et déborde à droite du marqueur appartient à l'élément. + // Une ligne vide seule ne le ferme pas : elle sépare deux paragraphes d'un + // même élément, et c'est la ligne suivante qui tranche. + while (i < lignes.length) { + const suite = lignes[i]; + if (!suite.trim()) { + const apres = lignes[i + 1] ?? ''; + if (!apres.trim() || indentationDe(apres) <= indentBase) break; + interne.push(''); + i += 1; + continue; + } + if (indentationDe(suite) <= indentBase) break; + interne.push(suite); + i += 1; + } + + elements.push({ + ligne: decalage + ligneMarqueur, + coche, + contenu: analyserInline(contenu), + // Le décalage suit l'extrait : les lignes internes commencent juste après + // le marqueur, et une case à cocher imbriquée doit désigner sa vraie ligne + // dans la note d'origine, pas son rang dans l'extrait. + blocs: interne.length + ? analyserBlocs(desindenter(interne), decalage + ligneMarqueur + 1) + : [], + }); + } + + blocs.push({ type: 'liste', ordonnee, elements }); + return i; +} + +/** Indentation d'une ligne, en colonnes — une tabulation en vaut quatre. */ +function indentationDe(ligne) { + const blancs = /^[ \t]*/.exec(ligne)[0]; + return [...blancs].reduce((total, caractere) => total + (caractere === '\t' ? 4 : 1), 0); +} + +/** Retire à toutes les lignes l'indentation de la moins indentée d'entre elles. */ +function desindenter(lignes) { + const pleines = lignes.filter((ligne) => ligne.trim()); + if (!pleines.length) return lignes; + const marge = Math.min(...pleines.map((ligne) => /^[ \t]*/.exec(ligne)[0].length)); + return lignes.map((ligne) => ligne.slice(marge)); +} + +/** Case à cocher éventuelle en tête d'élément : `[ ]` ou `[x]`. */ +function detacherCase(texte) { + const trouvee = MOTIF_CASE.exec(texte); + if (!trouvee) return { contenu: texte, coche: null }; + return { contenu: texte.slice(trouvee[0].length), coche: trouvee[1].toLowerCase() === 'x' }; +} + +/** Suite de lignes sans marqueur : un paragraphe, sauts de ligne conservés. */ +function lireParagraphe(lignes, debut, blocs) { + const contenu = []; + let i = debut; + + while (i < lignes.length) { + const ligne = lignes[i]; + if ( + !ligne.trim() || + MOTIF_TITRE.test(ligne) || + MOTIF_SEPARATEUR.test(ligne) || + MOTIF_CITATION.test(ligne) || + MOTIF_CLOTURE_CODE.test(ligne) || + MOTIF_ELEMENT.test(ligne) + ) { + break; + } + contenu.push(ligne.trim()); + i += 1; + } + + blocs.push({ type: 'paragraphe', contenu: analyserInlineMultiligne(contenu) }); + return i; +} + +// --------------------------------------------------------------------------- +// Analyse du contenu d'une ligne +// --------------------------------------------------------------------------- + +/** + * Analyse plusieurs lignes d'un même paragraphe en insérant un saut entre + * elles. + * + * Markdown standard recolle ces lignes en une seule, et exige deux espaces en + * fin de ligne pour un vrai retour. C'est un piège invisible dans un champ de + * saisie : ces notes sont souvent une petite liste écrite à la volée, et voir + * ses retours à la ligne disparaître à l'aperçu ferait passer la fonction pour + * cassée. On respecte donc ce qui est tapé. + */ +function analyserInlineMultiligne(lignes) { + const noeuds = []; + lignes.forEach((ligne, rang) => { + if (rang > 0) noeuds.push({ type: 'saut' }); + noeuds.push(...analyserInline(ligne)); + }); + return noeuds; +} + +/** + * Analyse le contenu d'une ligne : emphase, code, liens. + * + * @returns {Array} nœuds `texte`, `gras`, `italique`, `code`, `lien` ou + * `saut`. + */ +export function analyserInline(texte) { + const noeuds = []; + let tampon = ''; + let i = 0; + + const vider = () => { + if (tampon) noeuds.push({ type: 'texte', valeur: tampon }); + tampon = ''; + }; + + while (i < texte.length) { + const caractere = texte[i]; + + // Une contre-oblique neutralise le caractère suivant : c'est le seul moyen + // d'écrire un astérisque littéral dans une note qui parle de multiplication. + if (caractere === '\\' && ECHAPPABLES.includes(texte[i + 1] ?? '')) { + tampon += texte[i + 1]; + i += 2; + continue; + } + + // Le code littéral passe en premier : `**` entre accents graves n'est pas + // du gras, c'est ce qu'on veut montrer. + if (caractere === '`') { + const fin = texte.indexOf('`', i + 1); + if (fin > i + 1) { + vider(); + noeuds.push({ type: 'code', valeur: texte.slice(i + 1, fin) }); + i = fin + 1; + continue; + } + } + + if (texte.startsWith('**', i)) { + const fin = texte.indexOf('**', i + 2); + if (fin > i + 2) { + vider(); + noeuds.push({ type: 'gras', contenu: analyserInline(texte.slice(i + 2, fin)) }); + i = fin + 2; + continue; + } + } + + if ((caractere === '*' || caractere === '_') && ouvreEmphase(texte, i)) { + const fin = chercherFermeture(texte, caractere, i + 1); + if (fin > i + 1) { + vider(); + noeuds.push({ type: 'italique', contenu: analyserInline(texte.slice(i + 1, fin)) }); + i = fin + 1; + continue; + } + } + + if (caractere === '[') { + const lien = lireLien(texte, i); + if (lien) { + vider(); + noeuds.push(lien.noeud); + i = lien.suite; + continue; + } + } + + tampon += caractere; + i += 1; + } + + vider(); + return noeuds; +} + +/** + * Vrai si le délimiteur peut ouvrir une emphase. + * + * Le tiret bas est le cas qui compte : sans cette garde, un identifiant comme + * `date_debut_reelle` verrait son milieu passer en italique. On exige donc + * qu'il soit précédé d'un blanc ou d'une ponctuation — un `_` collé à un mot + * est un caractère de nom, pas une marque de style. L'astérisque, lui, ne + * s'écrit pas au milieu d'un mot par accident ; on lui demande seulement de ne + * pas être suivi d'un blanc, faute de quoi « 3 * 4 » deviendrait de l'italique. + */ +function ouvreEmphase(texte, i) { + const suivant = texte[i + 1] ?? ''; + if (!suivant || /\s/.test(suivant)) return false; + if (texte[i] === '*') return true; + const precedent = texte[i - 1] ?? ''; + return !precedent || !/[\p{L}\p{N}_]/u.test(precedent); +} + +/** Cherche le délimiteur fermant d'une emphase, en sautant un éventuel doublon. */ +function chercherFermeture(texte, delimiteur, depuis) { + for (let i = depuis; i < texte.length; i += 1) { + if (texte[i] === '\\') { + i += 1; + continue; + } + if (texte[i] !== delimiteur) continue; + // Un délimiteur collé à un blanc ne ferme pas : « *a * b » n'est pas de + // l'italique, et le laisser fermer produirait une emphase à la traîne. + if (/\s/.test(texte[i - 1] ?? '')) continue; + return i; + } + return -1; +} + +/** + * Lit `[texte](url)` à partir du crochet ouvrant. + * + * @returns {{noeud: object, suite: number}|null} null si la forme n'est pas + * complète — le crochet redevient alors un caractère ordinaire, ce qui laisse + * écrire « [à voir] » sans que ça compte pour un lien manqué. + */ +function lireLien(texte, debut) { + const fermeCrochet = texte.indexOf(']', debut + 1); + if (fermeCrochet < 0 || texte[fermeCrochet + 1] !== '(') return null; + + const fermeParenthese = texte.indexOf(')', fermeCrochet + 2); + if (fermeParenthese < 0) return null; + + const libelle = texte.slice(debut + 1, fermeCrochet); + const url = urlSure(texte.slice(fermeCrochet + 2, fermeParenthese)); + const suite = fermeParenthese + 1; + + // URL refusée : on garde le libellé, sans le lien. Effacer la ligne entière + // punirait le lecteur d'une note qu'il n'a pas écrite. + if (!url) { + return { noeud: { type: 'texte', valeur: libelle }, suite }; + } + + return { noeud: { type: 'lien', url, contenu: analyserInline(libelle) }, suite }; +} + +/** URL nettoyée si son schéma est autorisé, null sinon. */ +export function urlSure(brute) { + const url = String(brute).trim(); + if (!url) return null; + + // Pas de schéma explicite : c'est une adresse relative ou un domaine nu. + // Aucune des deux ne peut exécuter quoi que ce soit, on la laisse passer. + const schema = /^([a-z][a-z0-9+.-]*):/i.exec(url); + if (!schema) return url; + + return SCHEMAS_AUTORISES.includes(schema[1].toLowerCase() + ':') ? url : null; +} + +// --------------------------------------------------------------------------- +// Cases à cocher +// --------------------------------------------------------------------------- + +/** + * Bascule la case à cocher de la ligne indiquée et renvoie la note modifiée. + * + * On réécrit **la seule ligne visée**, dans le texte d'origine : régénérer la + * note depuis l'arbre normaliserait au passage les marqueurs, l'indentation et + * l'emphase de l'utilisateur, qui verrait sa mise en forme réécrite pour avoir + * coché une case. Si la ligne ne porte pas de case — note modifiée entre le + * rendu et le clic — rien ne change. + */ +export function basculerCase(texte, numeroLigne) { + const lignes = String(texte ?? '').split('\n'); + const ligne = lignes[numeroLigne]; + if (ligne === undefined) return texte; + + const element = MOTIF_ELEMENT.exec(ligne); + if (!element) return texte; + + const trouvee = MOTIF_CASE.exec(element[3]); + if (!trouvee) return texte; + + const coche = trouvee[1].toLowerCase() === 'x'; + const marque = coche ? '[ ]' : '[x]'; + const avant = ligne.length - element[3].length; + lignes[numeroLigne] = ligne.slice(0, avant) + marque + element[3].slice(trouvee[1].length + 2); + + return lignes.join('\n'); +} + +// --------------------------------------------------------------------------- +// Aplatissement +// --------------------------------------------------------------------------- + +/** + * Rend une note lisible en texte pur, pour les infobulles. + * + * L'attribut `title` du navigateur ne connaît que le texte : impossible d'y + * mettre du gras. Sans aplatissement, survoler une barre afficherait la source + * markdown, astérisques comprises — soit une note *moins* lisible qu'avant + * qu'on ne l'enrichisse. On retire donc les marques et on remplace celles qui + * portent du sens par un caractère qui le porte aussi : une puce pour un tiret, + * une case dessinée pour une case à cocher. + */ +export function aplatirMarkdown(texte) { + const lignes = []; + aplatirBlocs(analyserMarkdown(texte), lignes, ''); + return lignes.join('\n').replace(/\n{3,}/g, '\n\n').trim(); +} + +function aplatirBlocs(blocs, lignes, prefixe) { + for (const bloc of blocs) { + switch (bloc.type) { + case 'titre': + if (lignes.length) lignes.push(''); + lignes.push(prefixe + aplatirInline(bloc.contenu)); + break; + + case 'paragraphe': + if (lignes.length) lignes.push(''); + for (const morceau of aplatirInline(bloc.contenu).split('\n')) { + lignes.push(prefixe + morceau); + } + break; + + case 'liste': + bloc.elements.forEach((element, rang) => { + const marque = + element.coche === null ? '•' : element.coche ? '☑' : '☐'; + const numero = bloc.ordonnee ? `${rang + 1}.` : marque; + lignes.push(`${prefixe}${numero} ${aplatirInline(element.contenu)}`); + aplatirBlocs(element.blocs, lignes, `${prefixe} `); + }); + break; + + case 'citation': + aplatirBlocs(bloc.blocs, lignes, `${prefixe}│ `); + break; + + case 'code': + for (const ligne of bloc.lignes) lignes.push(prefixe + ligne); + break; + + case 'separateur': + lignes.push(`${prefixe}———`); + break; + } + } +} + +function aplatirInline(noeuds) { + return noeuds + .map((noeud) => { + switch (noeud.type) { + case 'texte': + return noeud.valeur; + case 'code': + return noeud.valeur; + case 'saut': + return '\n'; + case 'gras': + case 'italique': + return aplatirInline(noeud.contenu); + case 'lien': { + const libelle = aplatirInline(noeud.contenu); + // L'adresse n'est répétée que si le libellé ne la dit pas déjà : dans + // une infobulle, « Compte-rendu (https://…) » informe, « https://… + // (https://…) » encombre. + return libelle && libelle !== noeud.url ? `${libelle} (${noeud.url})` : noeud.url; + } + default: + return ''; + } + }) + .join(''); +} diff --git a/tests/markdown.test.js b/tests/markdown.test.js new file mode 100644 index 0000000..c72875e --- /dev/null +++ b/tests/markdown.test.js @@ -0,0 +1,252 @@ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + analyserInline, + analyserMarkdown, + aplatirMarkdown, + basculerCase, + urlSure, +} from '../js/markdown.js'; + +// Ce module ne touche pas au DOM : c'est ce qui le rend testable ici, le rendu +// proprement dit vivant dans `notes.js`. + +// --- blocs ------------------------------------------------------------------ + +describe('analyserMarkdown, blocs', () => { + test('reconnaît un titre et son niveau', () => { + const [bloc] = analyserMarkdown('### Point hebdo'); + assert.equal(bloc.type, 'titre'); + assert.equal(bloc.niveau, 3); + assert.deepEqual(bloc.contenu, [{ type: 'texte', valeur: 'Point hebdo' }]); + }); + + test('un dièse sans espace n est pas un titre', () => { + const [bloc] = analyserMarkdown('#12 du bon de commande'); + assert.equal(bloc.type, 'paragraphe'); + }); + + test('garde les retours à la ligne d un paragraphe', () => { + // Markdown standard recolle ces deux lignes ; on ne le suit pas ici, voir + // le commentaire d'`analyserInlineMultiligne`. + const [bloc] = analyserMarkdown('première ligne\nseconde ligne'); + assert.equal(bloc.contenu.filter((noeud) => noeud.type === 'saut').length, 1); + }); + + test('sépare deux paragraphes par une ligne vide', () => { + const blocs = analyserMarkdown('un\n\ndeux'); + assert.equal(blocs.length, 2); + assert.equal(blocs[0].type, 'paragraphe'); + assert.equal(blocs[1].type, 'paragraphe'); + }); + + test('distingue le séparateur d une liste à tirets', () => { + assert.equal(analyserMarkdown('---')[0].type, 'separateur'); + assert.equal(analyserMarkdown('- ceci')[0].type, 'liste'); + }); + + test('un bloc de code prend son contenu à la lettre', () => { + const [bloc] = analyserMarkdown('```js\nconst a = **b**;\n```'); + assert.equal(bloc.type, 'code'); + assert.equal(bloc.langage, 'js'); + assert.deepEqual(bloc.lignes, ['const a = **b**;']); + }); + + test('un bloc de code non refermé court jusqu à la fin', () => { + // On est peut-être en train de l'écrire : avaler la suite est moins + // déroutant que d'afficher la clôture ouvrante comme un paragraphe. + const [bloc] = analyserMarkdown('```\nen cours'); + assert.equal(bloc.type, 'code'); + assert.deepEqual(bloc.lignes, ['en cours']); + }); + + test('une citation est réanalysée, listes comprises', () => { + const [bloc] = analyserMarkdown('> Le client dit :\n> - un\n> - deux'); + assert.equal(bloc.type, 'citation'); + assert.equal(bloc.blocs[0].type, 'paragraphe'); + assert.equal(bloc.blocs[1].type, 'liste'); + assert.equal(bloc.blocs[1].elements.length, 2); + }); +}); + +// --- listes ----------------------------------------------------------------- + +describe('analyserMarkdown, listes', () => { + test('sépare une liste à puces d une liste numérotée', () => { + const blocs = analyserMarkdown('- a\n1. b'); + assert.equal(blocs.length, 2); + assert.equal(blocs[0].ordonnee, false); + assert.equal(blocs[1].ordonnee, true); + }); + + test('détache les cases à cocher', () => { + const [bloc] = analyserMarkdown('- [ ] à faire\n- [x] fait\n- ni l un ni l autre'); + assert.deepEqual( + bloc.elements.map((element) => element.coche), + [false, true, null] + ); + assert.deepEqual(bloc.elements[0].contenu, [{ type: 'texte', valeur: 'à faire' }]); + }); + + test('imbrique une sous-liste à l indentation', () => { + const [bloc] = analyserMarkdown('- parent\n - enfant'); + assert.equal(bloc.elements.length, 1); + assert.equal(bloc.elements[0].blocs[0].type, 'liste'); + assert.equal(bloc.elements[0].blocs[0].elements[0].contenu[0].valeur, 'enfant'); + }); + + test('chaque élément porte le numéro de sa ligne source', () => { + // C'est ce numéro qui permet à `basculerCase` de réécrire la bonne ligne, + // y compris dans une sous-liste. + const [, bloc] = analyserMarkdown('intro\n\n- [ ] premier\n- [x] second\n - [ ] fils'); + assert.equal(bloc.elements[0].ligne, 2); + assert.equal(bloc.elements[1].ligne, 3); + assert.equal(bloc.elements[1].blocs[0].elements[0].ligne, 4); + }); +}); + +// --- contenu d une ligne ---------------------------------------------------- + +describe('analyserInline', () => { + test('reconnaît le gras, l italique et le code', () => { + const noeuds = analyserInline('**a** *b* `c`'); + assert.deepEqual( + noeuds.filter((noeud) => noeud.type !== 'texte').map((noeud) => noeud.type), + ['gras', 'italique', 'code'] + ); + }); + + test('le code littéral l emporte sur l emphase', () => { + const [noeud] = analyserInline('`**pas du gras**`'); + assert.equal(noeud.type, 'code'); + assert.equal(noeud.valeur, '**pas du gras**'); + }); + + test('laisse intact un identifiant à tirets bas', () => { + // Sans la garde d'`ouvreEmphase`, le milieu passerait en italique. + const noeuds = analyserInline('date_debut_reelle'); + assert.deepEqual(noeuds, [{ type: 'texte', valeur: 'date_debut_reelle' }]); + }); + + test('laisse intact un astérisque isolé', () => { + assert.deepEqual(analyserInline('3 * 4 = 12'), [{ type: 'texte', valeur: '3 * 4 = 12' }]); + }); + + test('une marque non refermée reste du texte', () => { + // Une note à moitié écrite est l'état normal d'une note. + assert.deepEqual(analyserInline('**en cours'), [{ type: 'texte', valeur: '**en cours' }]); + }); + + test('la contre-oblique neutralise la marque suivante', () => { + assert.deepEqual(analyserInline('\\*littéral\\*'), [{ type: 'texte', valeur: '*littéral*' }]); + }); + + test('lit un lien et son libellé', () => { + const [noeud] = analyserInline('[le CR](https://ex.fr/cr)'); + assert.equal(noeud.type, 'lien'); + assert.equal(noeud.url, 'https://ex.fr/cr'); + assert.deepEqual(noeud.contenu, [{ type: 'texte', valeur: 'le CR' }]); + }); + + test('des crochets seuls ne font pas un lien', () => { + assert.deepEqual(analyserInline('[à voir]'), [{ type: 'texte', valeur: '[à voir]' }]); + }); + + test('un lien au schéma refusé retombe sur son libellé', () => { + // Le texte survit, le lien non : effacer la ligne punirait le lecteur d'une + // note qu'il n'a pas écrite. + const [noeud] = analyserInline('[clic](javascript:alert(1))'); + assert.equal(noeud.type, 'texte'); + assert.equal(noeud.valeur, 'clic'); + }); +}); + +describe('urlSure', () => { + test('accepte http, https et mailto', () => { + assert.equal(urlSure('https://ex.fr'), 'https://ex.fr'); + assert.equal(urlSure('http://ex.fr'), 'http://ex.fr'); + assert.equal(urlSure('mailto:a@ex.fr'), 'mailto:a@ex.fr'); + }); + + test('refuse les schémas exécutables, casse et espaces compris', () => { + // Liste blanche et non liste noire : un planning s'échange, et rien ne + // garantit que la note affichée a été écrite par celui qui la lit. + assert.equal(urlSure('javascript:alert(1)'), null); + assert.equal(urlSure(' JavaScript:alert(1)'), null); + assert.equal(urlSure('data:text/html,