Analyser un sous-ensemble de markdown, sans dépendance
Les notes sont du texte brut : une liste s'y écrit, rien ne la dessine. Une bibliothèque aurait été le premier `node_modules` de l'outil (décision 10) pour une syntaxe dont une note de suivi n'emploie qu'une poignée de formes. Le module ne touche ni au DOM ni au réseau — il transforme du texte en arbre, comme `model.js` transforme des objets — ce qui le rend testable sous Node et laisse le rendu à qui l'affichera. Deux écarts au standard : un retour à la ligne en est un, et ce qui n'est pas reconnu reste du texte, une note à moitié écrite étant l'état normal d'une note. Les URL passent une liste blanche de schémas, un planning s'échangeant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
568
js/markdown.js
Normal file
568
js/markdown.js
Normal file
@@ -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<object>} 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<object>} 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('');
|
||||
}
|
||||
252
tests/markdown.test.js
Normal file
252
tests/markdown.test.js
Normal file
@@ -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,<script>'), null);
|
||||
assert.equal(urlSure('vbscript:msgbox'), null);
|
||||
});
|
||||
|
||||
test('laisse passer une adresse sans schéma', () => {
|
||||
assert.equal(urlSure('docs/cr.pdf'), 'docs/cr.pdf');
|
||||
});
|
||||
});
|
||||
|
||||
// --- cases à cocher ---------------------------------------------------------
|
||||
|
||||
describe('basculerCase', () => {
|
||||
const note = 'Titre\n\n- [ ] premier\n- [x] second\n - [ ] fils';
|
||||
|
||||
test('coche une case vide', () => {
|
||||
assert.equal(basculerCase(note, 2).split('\n')[2], '- [x] premier');
|
||||
});
|
||||
|
||||
test('décoche une case pleine', () => {
|
||||
assert.equal(basculerCase(note, 3).split('\n')[3], '- [ ] second');
|
||||
});
|
||||
|
||||
test('conserve l indentation d une sous-liste', () => {
|
||||
assert.equal(basculerCase(note, 4).split('\n')[4], ' - [x] fils');
|
||||
});
|
||||
|
||||
test('ne touche à rien d autre que la ligne visé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 la mise en forme.
|
||||
const lignes = basculerCase(note, 2).split('\n');
|
||||
assert.equal(lignes[0], 'Titre');
|
||||
assert.equal(lignes[3], '- [x] second');
|
||||
assert.equal(lignes.length, 5);
|
||||
});
|
||||
|
||||
test('laisse la note intacte si la ligne ne porte pas de case', () => {
|
||||
assert.equal(basculerCase(note, 0), note);
|
||||
assert.equal(basculerCase(note, 42), note);
|
||||
});
|
||||
});
|
||||
|
||||
// --- aplatissement ----------------------------------------------------------
|
||||
|
||||
describe('aplatirMarkdown', () => {
|
||||
test('retire les marques d emphase et de titre', () => {
|
||||
assert.equal(aplatirMarkdown('## Point\n\nRelance **client**'), 'Point\n\nRelance client');
|
||||
});
|
||||
|
||||
test('remplace les marqueurs par des symboles qui portent le sens', () => {
|
||||
// Un `title` ne connaît que le texte : sans cela, survoler afficherait la
|
||||
// source markdown, astérisques comprises.
|
||||
assert.equal(aplatirMarkdown('- [ ] à faire\n- [x] fait\n- simple'), '☐ à faire\n☑ fait\n• simple');
|
||||
});
|
||||
|
||||
test('numérote une liste ordonnée', () => {
|
||||
assert.equal(aplatirMarkdown('1. un\n1. deux'), '1. un\n2. deux');
|
||||
});
|
||||
|
||||
test('indente une sous-liste', () => {
|
||||
assert.equal(aplatirMarkdown('- parent\n - enfant'), '• parent\n • enfant');
|
||||
});
|
||||
|
||||
test('marque une citation', () => {
|
||||
assert.equal(aplatirMarkdown('> cité'), '│ cité');
|
||||
});
|
||||
|
||||
test('écrit l adresse d un lien quand le libellé ne la dit pas', () => {
|
||||
assert.equal(aplatirMarkdown('[CR](https://ex.fr)'), 'CR (https://ex.fr)');
|
||||
assert.equal(aplatirMarkdown('[https://ex.fr](https://ex.fr)'), 'https://ex.fr');
|
||||
});
|
||||
|
||||
test('rend une note vide sans rien produire', () => {
|
||||
assert.equal(aplatirMarkdown(''), '');
|
||||
assert.equal(aplatirMarkdown(' \n\n '), '');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user