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:
2026-08-07 18:29:15 +02:00
parent a0b8ef9dcf
commit e04877daae
2 changed files with 820 additions and 0 deletions

568
js/markdown.js Normal file
View 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
View 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 '), '');
});
});