/** * 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(''); }