Vous avez déjà passé 20 minutes à renuméroter des footnotes après avoir ajouté une seule note en milieu d'article ? FootnoteManager automatise tout ça en 3 lignes de HTML.
Lorsqu'on rédige du contenu web avec des notes de bas de page, on se retrouve rapidement face à plusieurs défis :
- Numérotation manuelle : Il faut maintenir manuellement la correspondance entre les références (¹, ², ³...) et les notes en bas de page ;
- Liens fragiles : Les identifiants doivent être créés à la main et peuvent facilement se désynchroniser ;
- Navigation limitée : Pas de retour automatique vers le texte après avoir lu une note ;
- Accessibilité : Les attributs ARIA nécessaires pour les lecteurs d'écran sont souvent oubliés ;
- Maintenance pénible : Ajouter ou supprimer une note oblige à tout renuméroter.
Le problème principal est la redondance des références vers une note unique.
Une proposition technique, FootnoteManager.
FootnoteManager
FootnoteManager est un composant JavaScript léger qui automatise entièrement la gestion des notes de bas de page. Il crée automatiquement les liens bidirectionnels, gère l'accessibilité et s'adapte dynamiquement aux changements de contenu.
Exemple
Essayez ! Cliquez sur les ∗, ou 1 ci-dessous pour voir la navigation en action.
Les secrets d'un bon café ☕
Le café parfait nécessite trois ingrédients essentiels : des grains fraîchement moulus ∗, une eau à exactement 92°C ∗∗, et surtout beaucoup d'amour ∗∗∗.
Contrairement aux idées reçues, le café ne déshydrate pas 1. Par contre, en boire 12 tasses par jour reste déconseillé 2.
Une autre étude 3 réalisée en 2025, aurait démontré que prendre un café sans son téléphone à la main améliore de 100% la qualité de la communication orale et directe avec son entourage.
Le café le plus cher du monde ∗∗∗ coûte environ 600€ le kilo. Votre portefeuille appréciera moyennement.
Le cas le plus courant sur un site e-commerce, reste la fiche produit, ou son aperçu dans une liste de résultats, où chaque bloc peut avoir des références de mentions :
Texte fictif : Lorem ut mauris ac sed sollicitudin sit sit tincidunt
1141€ab
Texte fictif : Lorem imperdiet vehicula lorem vitae felis nulla leo non pretium sit tempus est ultricies enim tortor elementum diam leo non.
Texte fictif : Lorem aenean pellentesque vitae ligula risus augue mauris amet tincidunt ut non
1341€ab
Texte fictif : Lorem posuere nibh integer ligula ultrices mauris ac ante sit consectetur est nec lorem suspendisse vivamus curabitur proin leo sem nec in nulla aliquam pellentesque massa praesent eu lobortis sit ut nulla adipiscing mauris.
Texte fictif : Lorem praesent mauris at tincidunt felis aliquam et ante fermentum tempor consequat leo blandit
399€ab
Texte fictif : Lorem in massa dignissim rutrum euismod mi nibh fermentum orci pharetra in dolor libero non cras semper felis ipsum posuere podo curabitur nulla aliquam sit aliquet aliquam metus congue tempus quam ante fusce convallis egestas vehicula varius augue sit sed euismod euismod risus enim curabitur mauris mi eros in egestas dolor sodales adipiscing primis purus.
Texte fictif : Lorem egestas purus dolor consectetuer felis massa in varius aliquam
534€ab
Texte fictif : Lorem sed augue adipiscing sapien aenean enim non iaculis nulla nisl lacus mi suspendisse at vitae curabitur rhoncus pulvinar diam at adipiscing justo mauris varius nunc orci vitae dui a sodales nibh pellentesque nec mi vitae magna fusce convallis ligula podo in sodales orci lorem aliquam.
Texte fictif : Lorem sed non varius massa nulla ante vel at felis curabitur aliquam elit sodales maecenas augue
884€ab
Texte fictif : Lorem curabitur faucibus suspendisse dolor varius rhoncus felis lobortis nisl blandit et fusce pellentesque adipiscing semper augue sodales in praesent in nulla non diam eu adipiscing nisl molestie tellus.
Texte fictif : Lorem varius praesent et molestie et posuere vestibulum posuere
811€ab
Texte fictif : Lorem nisl leo egestas nulla blandit semper sit purus dignissim dui sit sodales ac enim orci euismod sed ultricies amet lectus nec integer mi imperdiet nunc lorem pellentesque mi sed in vestibulum augue vitae suscipit aliquam amet in adipiscing id bibendum erat adipiscing eu rhoncus sem.
Structure HTML simple
Le balisage est minimaliste et sémantique :
<!-- Dans votre contenu -->
<p>Prix: 1200€ <span data-footnote-ref>*</span></p>
<p>Stock limité <sup data-footnote-ref>**</sup></p>
<!-- En bas de page -->
<aside data-footnotes>
<ol>
<li data-footnote="*">TVA et frais de port inclus.</li>
<li data-footnote="**">Sous réserve de disponibilité.</li>
</ol>
</aside>
Le composant détecte automatiquement le symbole à utiliser depuis le contenu de la balise <sup>, <span> ou autre, tant que ça reste conforme sémantiquement.
Transformation automatique
Au chargement de la page, FootnoteManager :
Étape 1 : Indexation
- Scanne tous les éléments
[data-footnote]dans le<aside>ou autre… ; - Crée une Map interne pour associer chaque symbole à sa note ;
- Génère des IDs uniques pour chaque note.
Étape 2 : Création des liens
- Parcourt toutes les références
[data-footnote-ref]; - Transforme chaque élément en lien cliquable vers la note correspondante ;
- Ajoute les attributs ARIA appropriés (
role="doc-noteref").
Étape 3 : Liens de retour
- Ajoute automatiquement une flèche ⤴ à la fin de chaque note ;
- Permet de revenir exactement à la référence cliquée (important quand une même note est référencée plusieurs fois).
3. Exemple de résultat
Avant :
Texte lorem <sup data-footnote-ref>*</sup>
Après transformation :
<sup data-footnote-ref>
<a href="#footnote-xyz"
id="ref-footnote-xyz-1"
role="doc-noteref"
aria-label="Note 1">*</a>
</sup>
Cas d'usage
- E-commerce : Mentions légales sur les prix
- Articles techniques : Références bibliographiques
- Documentation : Notes et avertissements
- Sites d'actualités : Sources et précisions
- Landing pages : Conditions et petits caractères
Par exemple, prix avec mentions légales.
Sur une fiche produit, vous affichez "À partir de 299€" avec une note de bas de page " Offre valable jusqu'au 31/12". FootnoteManager crée automatiquement le lien, et si vous avez 50 produits sur la même page référençant cette note, chaque lien de retour ⤴ pointe vers le bon produit.
Fonctionnalités
-
Navigation fluide
Chaque clic sur une référence ou un lien de retour déclenche un scroll smooth avec effet de surbrillance temporaire. L'élément ciblé est automatiquement mis en focus pour les technologies d'assistance. -
Gestion intelligente des références multiples
Quand une même note est citée plusieurs fois, le lien de retour ⤴ s'adapte dynamiquement pour pointer vers la dernière référence cliquée :
<p>Prix: 1200€ <span data-footnote-ref>*</span></p>
<p>Prix: 1500€ <sup data-footnote-ref>*</sup></p>
<p>Prix: 2000€ <sup data-footnote-ref>*</sup></p>
Le système garde en mémoire quelle référence a été utilisée et ajuste le lien retour en conséquence.
Contenu dynamique avec MutationObserver
Le composant surveille automatiquement les modifications du DOM dans la balise <main>. Si vous ajoutez de nouvelles notes ou références dynamiquement (via JavaScript, AJAX, etc.), elles seront automatiquement traitées :
// Ajouter du contenu dynamiquement
document.querySelector('main').insertAdjacentHTML('beforeend', `
<p>Nouveau contenu <sup data-footnote-ref>***</sup></p>
`);
// FootnoteManager détecte et traite automatiquement la nouvelle référence
Accessibilité
Le composant respecte les standards d'accessibilité :
- Attributs ARIA :
role="doc-noteref",role="doc-backlink",aria-label; - Navigation au clavier : Tous les liens sont accessibles au clavier ;
- Focus management : Le focus est déplacé intelligemment lors de la navigation ;
- Structure sémantique : Utilise
<aside>avecrole="region", mais ce point peut être contourné.
A propos de Digital Publishing ARIA
Les rôles DPUB-ARIA font partie de ARIA 1.3 (spécification du W3C).
Support navigateurs : tous les navigateurs modernes le reconnaissent.
Support lecteurs d'écran : NVDA, JAWS, VoiceOver, etc. l'annoncent correctement.
Javascript
Taille brute : 23,86 kilo-octets (~5,99 kilo-octets gzip)
/**
* Source Kortic https://www.kortic.com/le-blog-kortic/2025/12/15/automatiser-et-rendre-accessibles-les-references-et-notes-de-bas-de-page.html
* licence Creative Commons CC BY-NC-SA 4.0
* https://creativecommons.org/licenses/by-nc-sa/4.0/deed.fr
* Attribution, partage dans les mêmes conditions, pas d'utilisation commerciale
*/
/**
* @fileoverview FootnoteManager - Gestionnaire automatique de notes de bas de page
* @version 1.0.0
*
*/
(() => {
'use strict';
/**
* @typedef {Object} FootnoteData
* @property {HTMLElement} element - L'élément HTML de la note (le <li>)
* @property {string} id - L'ID unique généré pour cette note
* @property {number} number - Le numéro de la note (position dans la liste)
* @property {string} symbol - Le symbole de la note (*, **, 1, 2, a, b, etc.)
* @property {string[]} refs - Liste des IDs de toutes les références pointant vers cette note
* @property {HTMLAnchorElement} [backlink] - Le lien de retour créé dans la note
*/
/**
* @typedef {Object} I18nTranslations
* @property {string} [footnotes_region_label] - Label ARIA pour la région des notes
* @property {string} [footnote_label] - Template pour le label des références (ex: "Note {number}")
* @property {string} [footnote_backlink_label] - Label du lien retour
* @property {string} [footnote_backlink_short] - Texte court du lien retour pour sr-only
* @example
* // Configuration i18n globale
* window.i18n = {
* footnotes_region_label: "Footnotes",
* footnote_label: "Note {number}",
* footnote_backlink_label: "Back to reference",
* footnote_backlink_short: "Back"
* };
*/
/**
* Récupère un texte traduit depuis l'objet i18n global ou retourne le fallback
*
* @private
* @param {string} key - La clé de traduction à rechercher
* @param {string} fallback - Le texte par défaut si la traduction n'existe pas
* @returns {string} Le texte traduit ou le fallback
*
* @example
* // Sans i18n configuré
* getI18nText('footnotes_region_label', 'Notes de bas de page');
* // => "Notes de bas de page"
*
* @example
* // Avec i18n configuré
* window.i18n = { footnotes_region_label: "Footnotes" };
* getI18nText('footnotes_region_label', 'Notes de bas de page');
* // => "Footnotes"
*/
function getI18nText(key, fallback) {
if (typeof window.i18n === 'object' && window.i18n !== null && key in window.i18n) {
return window.i18n[key];
}
return fallback;
}
/**
* FootnoteManager - Gestionnaire de notes de bas de page accessible
*
* Crée automatiquement les liens bidirectionnels entre références et notes de bas de page
* avec support complet ARIA pour l'accessibilité et gestion dynamique du contenu.
*
* Support de l'internationalisation via l'objet global `window.i18n`.
*
* @class
* @classdesc Transforme automatiquement les éléments [data-footnote-ref] en liens
* fonctionnels vers leurs notes correspondantes [data-footnote], avec navigation
* bidirectionnelle, smooth scroll, et conformité WCAG AA.
*
* @example
* // Structure HTML requise
* <main>
* <p>Prix: 1200€ <sup data-footnote-ref>*</sup></p>
* <aside data-footnotes>
* <ol>
* <li data-footnote="*">TVA incluse</li>
* </ol>
* </aside>
* </main>
*
* @example
* // Configuration i18n (optionnel)
* window.i18n = {
* footnotes_region_label: "Footnotes",
* footnote_label: "Note {number}",
* footnote_backlink_label: "Back to reference",
* footnote_backlink_short: "Back"
* };
*
* @example
* // Initialisation automatique (au chargement)
* // Aucun code nécessaire, le composant s'auto-initialise
*
* @example
* // Initialisation manuelle
* const manager = new FootnoteManager(document.querySelector('#article'));
*
* @example
* // Destruction d'une instance
* manager.destroy();
*/
class FootnoteManager {
/**
* Crée une nouvelle instance de FootnoteManager
*
* @constructor
* @param {Document|HTMLElement} [container=document] - Le conteneur à observer.
* Peut être le document entier ou un élément spécifique.
* Si un élément possède l'attribut [data-footnote-context],
* il créera un contexte isolé pour ses notes.
*
* @throws {Error} Lance un avertissement console si aucun [data-footnotes] n'est trouvé
*
* @example
* // Observer tout le document
* const manager = new FootnoteManager();
*
* @example
* // Observer un article spécifique
* const article = document.querySelector('#mon-article');
* const manager = new FootnoteManager(article);
*/
constructor(container = document) {
/**
* Le conteneur DOM observé par cette instance
* @type {Document|HTMLElement}
* @private
*/
this.container = container;
/**
* Map contenant toutes les notes indexées
* Clé: symbole de la note (ex: "*", "1", "a")
* Valeur: objet FootnoteData
* @type {Map<string, FootnoteData>}
* @private
*/
this.footnotes = new Map();
/**
* Compteur pour générer des IDs uniques de références
* @type {number}
* @private
*/
this.refCounter = 0;
/**
* MutationObserver pour détecter les changements dans le DOM
* @type {MutationObserver|null}
* @private
*/
this.observer = null;
/**
* Set des références déjà traitées pour éviter les doublons
* @type {Set<HTMLElement>}
* @private
*/
this.processedRefs = new Set();
this.init();
this.setupObserver();
}
/**
* Initialise le composant
* Exécute séquentiellement toutes les étapes d'initialisation
*
* @private
* @returns {void}
*/
init() {
this.setupFootnotesContainer();
this.indexFootnotes();
this.processReferences();
this.addBacklinks();
}
/**
* Configure le container <aside> avec les attributs ARIA appropriés
* Ajoute role="region" et aria-label si non présents
* Utilise les traductions i18n si disponibles
*
* @private
* @returns {void}
*
* @example
* // Avant
* <aside data-footnotes>...</aside>
*
* // Après (avec i18n)
* <aside data-footnotes role="region" aria-label="Footnotes">...</aside>
*
* @example
* // Après (sans i18n)
* <aside data-footnotes role="region" aria-label="Notes de bas de page">...</aside>
*/
setupFootnotesContainer() {
const container = this.container.querySelector('[data-footnotes]');
if (!container) {
console.warn('FootnoteManager: No [data-footnotes] container found');
return;
}
container.setAttribute('role', 'region');
if (!container.hasAttribute('aria-label') && !container.hasAttribute('aria-labelledby')) {
const label = getI18nText('footnotes_region_label', 'Notes de bas de page');
container.setAttribute('aria-label', label);
}
}
/**
* Indexe toutes les notes de bas de page disponibles dans le container
* Génère des IDs uniques et stocke les métadonnées dans this.footnotes
*
* @private
* @returns {void}
*
* @fires console#warn Si aucun élément [data-footnote] n'est trouvé
*
* @example
* // HTML
* <li data-footnote="*">TVA incluse</li>
*
* // Après indexation
* this.footnotes.get('*') // => {
* element: <li>,
* id: 'footnote-abc123',
* number: 1,
* symbol: '*',
* refs: []
* }
*/
indexFootnotes() {
const footnoteElements = this.container.querySelectorAll('[data-footnote]');
footnoteElements.forEach((element, index) => {
const symbol = element.dataset.footnote;
const footnoteNumber = index + 1;
const footnoteId = `footnote-${this.generateUniqueId()}`;
element.id = footnoteId;
this.footnotes.set(symbol, {
element: element,
id: footnoteId,
number: footnoteNumber,
symbol: symbol,
refs: []
});
});
if (this.footnotes.size === 0) {
console.warn('FootnoteManager: No [data-footnote] elements found');
}
}
/**
* Traite toutes les références dans le contenu
* Transforme chaque [data-footnote-ref] en lien fonctionnel
*
* @private
* @returns {void}
*
* @fires console#warn Si un symbole référencé n'existe pas dans les notes
*
* @example
* // HTML avant traitement
* <sup data-footnote-ref>*</sup>
*
* // HTML après traitement (avec i18n)
* <sup data-footnote-ref>
* <a href="#footnote-abc123"
* id="ref-footnote-abc123-1"
* role="doc-noteref"
* aria-label="Note 1">*</a>
* </sup>
*/
processReferences() {
const refs = this.container.querySelectorAll('[data-footnote-ref]');
refs.forEach((refElement) => {
if (this.processedRefs.has(refElement)) return;
let symbol = refElement.dataset.footnoteRef;
if (symbol === '' || symbol === undefined) {
symbol = refElement.textContent.trim();
}
if (!symbol) {
console.warn('FootnoteManager: Empty symbol in reference', refElement);
return;
}
const footnote = this.footnotes.get(symbol);
if (!footnote) {
console.warn(`FootnoteManager: Footnote "${symbol}" referenced but not found`);
return;
}
this.createReference(refElement, footnote, symbol);
this.processedRefs.add(refElement);
});
}
/**
* Crée un lien de référence vers une note
* Génère un lien <a> avec tous les attributs ARIA nécessaires
* Utilise les traductions i18n pour le label ARIA
*
* @private
* @param {HTMLElement} refElement - L'élément contenant data-footnote-ref (généralement un <sup>)
* @param {FootnoteData} footnote - Les données de la note cible
* @param {string} symbol - Le symbole à afficher dans le lien
* @returns {void}
*
* @example
* // Crée un lien comme celui-ci (avec i18n)
* <a href="#footnote-xyz"
* id="ref-footnote-xyz-1"
* role="doc-noteref"
* aria-label="Note 1">*</a>
*
* @example
* // Configuration i18n personnalisée
* window.i18n = { footnote_label: "Footnote {number}" };
* // Génère aria-label="Footnote 1"
*/
createReference(refElement, footnote, symbol) {
const refId = `ref-${footnote.id}-${footnote.refs.length + 1}`;
footnote.refs.push(refId);
const link = document.createElement('a');
link.href = `#${footnote.id}`;
link.id = refId;
link.setAttribute('role', 'doc-noteref');
// Label i18n avec remplacement du placeholder {number}
link.setAttribute('aria-label', getI18nText('footnote_label', 'Note {number}').replace('{number}', ` ${footnote.number}`));
link.textContent = symbol;
link.addEventListener('click', (e) => {
e.preventDefault();
this.scrollToFootnote(footnote, refId);
});
refElement.textContent = '';
refElement.appendChild(link);
}
/**
* Ajoute les liens retour (↩) dans chaque note
* Crée un lien pour revenir à la référence dans le texte
* Utilise les traductions i18n pour le label ARIA
*
* @private
* @returns {void}
*
* @example
* // Ajoute à la fin de chaque note (avec i18n)
* <li data-footnote="*">
* TVA incluse
* <span class="footnote-backlinks">
* <a href="#ref-footnote-xyz-1"
* role="doc-backlink"
* aria-label="Back to reference">
* <span class="sr-only">Back</span>
* </a>
* </span>
* </li>
*
* @example
* // Configuration i18n personnalisée
* window.i18n = {
* footnote_backlink_label: "Return to text",
* footnote_backlink_short: "Return"
* };
*/
addBacklinks() {
this.footnotes.forEach((footnote) => {
if (footnote.refs.length === 0) return;
const backlinksContainer = document.createElement('span');
backlinksContainer.className = 'footnote-backlinks';
backlinksContainer.innerHTML = ' ';
const backlink = document.createElement('a');
backlink.href = `#${footnote.refs[0]}`;
backlink.setAttribute('role', 'doc-backlink');
// Label i18n pour le lien retour
const backlinkLabel = getI18nText('footnote_backlink_label', 'Retour à la référence');
backlink.setAttribute('aria-label', backlinkLabel);
// Texte court sr-only
const shortText = getI18nText('footnote_backlink_short', 'Retour');
const srOnly = document.createElement('span');
srOnly.textContent = shortText;
backlink.appendChild(srOnly);
footnote.backlink = backlink;
backlink.addEventListener('click', (e) => {
e.preventDefault();
this.scrollToReference(backlink.href.split('#')[1]);
});
backlinksContainer.appendChild(backlink);
footnote.element.appendChild(backlinksContainer);
});
}
/**
* Scroll smooth vers une note avec highlight temporaire et gestion du focus
* Met à jour le lien de retour pour pointer vers la référence cliquée
*
* @private
* @param {FootnoteData} footnote - Les données de la note cible
* @param {string} refId - L'ID de la référence qui a été cliquée
* @returns {void}
*
* @example
* // Comportement
* // 1. Met à jour le lien retour pour pointer vers cette référence
* // 2. Scroll smooth jusqu'à la note
* // 3. Applique une classe .footnote-highlighted pendant 2 secondes
* // 4. Déplace le focus sur la note pour les lecteurs d'écran
*/
scrollToFootnote(footnote, refId) {
if (footnote.backlink) {
footnote.backlink.href = `#${refId}`;
}
// supprimer l'effet de scroll smooth si présent
const originalBehavior = getComputedStyle(document.documentElement).scrollBehavior;
document.documentElement.style.scrollBehavior = 'auto';
footnote.element.scrollIntoView({
behavior: 'auto',
block: 'center'
});
// remettre l'effet de scroll smooth si présent
setTimeout(() => {
document.documentElement.style.scrollBehavior = originalBehavior;
}, 0);
footnote.element.classList.add('footnote-highlighted');
setTimeout(() => {
footnote.element.classList.remove('footnote-highlighted');
}, 2000);
footnote.element.setAttribute('tabindex', '-1');
footnote.element.focus();
}
/**
* Scroll smooth vers une référence avec highlight temporaire
*
* @private
* @param {string} refId - L'ID de la référence cible
* @returns {void}
*
* @example
* // Comportement
* // 1. Scroll smooth jusqu'à la référence
* // 2. Applique .footnote-ref-highlighted sur le <sup> pendant 2 secondes
* // 3. Déplace le focus sur le lien
*/
scrollToReference(refId) {
const refElement = document.getElementById(refId);
if (!refElement) return;
// supprimer l'effet de scroll smooth si présent
const originalBehavior = getComputedStyle(document.documentElement).scrollBehavior;
document.documentElement.style.scrollBehavior = 'auto';
refElement.scrollIntoView({
behavior: 'auto',
block: 'center'
});
// remettre l'effet de scroll smooth si présent
setTimeout(() => {
document.documentElement.style.scrollBehavior = originalBehavior;
}, 0);
const sup = refElement.closest('sup, [data-footnote-ref]');
if (sup) {
sup.classList.add('footnote-ref-highlighted');
setTimeout(() => {
sup.classList.remove('footnote-ref-highlighted');
}, 2000);
}
refElement.focus();
}
/**
* Configure le MutationObserver pour détecter les nouveaux éléments
* Observe uniquement la balise <main> pour limiter la portée de surveillance
*
* @private
* @returns {void}
*
* @fires console#log Information sur l'élément observé
*
* @example
* // Observer détecte automatiquement les ajouts dynamiques
* document.querySelector('main').innerHTML += `
* <p>Nouveau contenu <sup data-footnote-ref>*</sup></p>
* `;
* // Le MutationObserver déclenche automatiquement le traitement
*/
setupObserver() {
let observeTarget = this.container.querySelector('main') || this.container;
if (this.container === document && document.querySelector('main')) {
observeTarget = document.querySelector('main');
}
this.observer = new MutationObserver((mutations) => {
let hasNewRefs = false;
let hasNewFootnotes = false;
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
if (node.hasAttribute?.('data-footnote-ref') ||
node.querySelector?.('[data-footnote-ref]')) {
hasNewRefs = true;
}
if (node.hasAttribute?.('data-footnote') ||
node.querySelector?.('[data-footnote]')) {
hasNewFootnotes = true;
}
});
});
if (hasNewFootnotes) {
this.indexFootnotes();
}
if (hasNewRefs || hasNewFootnotes) {
this.processReferences();
this.updateBacklinks();
}
});
this.observer.observe(observeTarget, {
childList: true,
subtree: true
});
}
/**
* Met à jour tous les backlinks sans tout recréer
* Utile après l'ajout de nouvelles références vers des notes existantes
*
* @private
* @returns {void}
*
* @example
* // Appelé automatiquement par le MutationObserver quand du contenu est ajouté
*/
updateBacklinks() {
this.footnotes.forEach((footnote) => {
const existingBacklinks = footnote.element.querySelector('.footnote-backlinks');
if (existingBacklinks) {
existingBacklinks.remove();
}
});
this.addBacklinks();
}
/**
* Génère un ID unique basé sur timestamp + random
* Format: [timestamp_base36][random_base36]
*
* @private
* @returns {string} Un ID unique (ex: "lh2k9xabc12")
*
* @example
* const id = this.generateUniqueId();
* // => "lh2k9xabc12"
*/
generateUniqueId() {
return Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
}
/**
* Nettoie complètement l'instance et restaure le DOM initial
* Déconnecte l'observer, supprime tous les liens créés et nettoie la mémoire
*
* @public
* @returns {void}
*
* @example
* const manager = new FootnoteManager();
* // ... utilisation ...
* manager.destroy(); // Nettoyage complet
*
* @example
* // Via l'API globale
* window.destroyFootnotes('#mon-article');
*/
destroy() {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
this.container.querySelectorAll('[role="doc-noteref"]').forEach(link => {
const parent = link.parentElement;
parent.textContent = link.textContent;
});
this.container.querySelectorAll('.footnote-backlinks').forEach(el => el.remove());
this.footnotes.forEach(footnote => {
footnote.element.removeAttribute('id');
footnote.element.removeAttribute('tabindex');
});
this.processedRefs.clear();
this.footnotes.clear();
instances.delete(this.container);
}
}
// ============================================================================
// GESTION DES INSTANCES ET INITIALISATION
// ============================================================================
/**
* Stockage des instances pour gestion du cycle de vie
* Utilise une WeakMap pour permettre le garbage collection automatique
* @type {WeakMap<Document|HTMLElement, FootnoteManager>}
* @private
*/
const instances = new WeakMap();
/**
* Initialise automatiquement tous les containers avec [data-footnotes]
* Appelée automatiquement au chargement du DOM
*
* @private
* @returns {void}
*
* @example
* // Cette fonction est appelée automatiquement, mais peut être rappelée manuellement
* init();
*/
function init() {
const containers = document.querySelectorAll('[data-footnotes]');
containers.forEach(container => {
// Chercher le contexte parent (ou document)
const context = container.closest('[data-footnote-context]') || document;
if (!instances.has(context)) {
const instance = new FootnoteManager(context);
instances.set(context, instance);
}
});
}
// Auto-init au chargement du DOM
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// ============================================================================
// API PUBLIQUE
// ============================================================================
/**
* Expose la classe FootnoteManager dans le scope global
* Permet l'instanciation manuelle
*
* @global
* @example
* const manager = new window.FootnoteManager(document.querySelector('#article'));
*/
window.FootnoteManager = FootnoteManager;
/**
* Initialise manuellement FootnoteManager sur un sélecteur spécifique
* Crée une nouvelle instance si elle n'existe pas déjà
*
* @global
* @function initFootnotes
* @param {string} [selector] - Sélecteur CSS pour cibler le container.
* Si omis, initialise sur le document entier.
* @returns {void}
*
* @example
* // Initialiser sur un article spécifique
* window.initFootnotes('#mon-article');
*
* @example
* // Initialiser sur le document entier
* window.initFootnotes();
*
* @example
* // Initialiser après chargement AJAX
* fetch('/article.html')
* .then(response => response.text())
* .then(html => {
* document.querySelector('#content').innerHTML = html;
* window.initFootnotes('#content');
* });
*/
window.initFootnotes = function (selector) {
const context = selector ? document.querySelector(selector) : document;
if (!context) return;
if (!instances.has(context)) {
const instance = new FootnoteManager(context);
instances.set(context, instance);
}
};
/**
* Détruit une instance de FootnoteManager et nettoie le DOM
* Supprime l'instance du cache
*
* @global
* @function destroyFootnotes
* @param {string} [selector] - Sélecteur CSS pour cibler le container.
* Si omis, détruit l'instance du document entier.
* @returns {void}
*
* @example
* // Détruire une instance spécifique
* window.destroyFootnotes('#mon-article');
*
* @example
* // Détruire l'instance globale
* window.destroyFootnotes();
*
* @example
* // Recréer après destruction
* window.destroyFootnotes('#article');
* // ... modifications du DOM ...
* window.initFootnotes('#article');
*/
window.destroyFootnotes = function (selector) {
const context = selector ? document.querySelector(selector) : document;
if (!context) return;
const instance = instances.get(context);
if (instance) {
instance.destroy();
}
};
})();
Télécharger kortic-footnotes.js
API publique
Initialisation automatique
Le composant s'initialise automatiquement au chargement de la page. Il détecte tous les containers [data-footnotes].
Initialisation manuelle
// Initialiser sur un container spécifique
window.initFootnotes('#mon-article');
// Détruire une instance
window.destroyFootnotes('#mon-article');
Styles
Pour les styles, à vous de voir ce qui vous convient.
Version implémentée ici :
Taille brute : 2,99 kilo-octets (~1,09 kilo-octets gzip)
:root {
--svg-backlink: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M20.55,27.4V5.44c0-.28.22-.5.5-.5h1.12c.28,0,.5.22.5.5v21.95c0,.28-.22.5-.5.5h-1.12c-.28,0-.5-.22-.5-.5ZM20.9,4.46c.4-.49,1.03-.49,1.43,0l2.06,2.51.91,1.11c.67.82.22,2.26-.71,2.26h-5.95c-.93,0-1.39-1.44-.71-2.26l.91-1.11,2.06-2.51ZM8.37,20.9v6.51c0,.28-.22.5-.5.5h-1c-.28,0-.5-.22-.5-.5v-6.51c0-.28.22-.5.5-.5h1c.28,0,.5.22.5.5ZM21.13,27.9H7.13v-1.8h14v1.8Z"/></svg>');
& [data-footnote-ref] {
all: unset;
font-size: 0.875rem;
vertical-align: text-top;
min-width: 1.5rem;
text-align: center;
display: inline-block;
line-height: 1.5rem;
min-height: 1.5rem;
font-family: sans-serif;
& a {
display: block;
font-family: inherit;
&:focus {
outline-offset: 0;
outline-color: currentColor;
outline-width: initial !important;
}
&:before,
&:after {
font-size: inherit;
font-weight: 200;
}
&:before {
content: "[" / "";
}
&:after {
content: "]" / "";
}
}
}
& li[data-footnote] {
all: unset;
display: list-item;
&:before {
content: attr(data-footnote) "\A0:";
font-size: 1rem;
color: inherit;
font-family: sans-serif;
font-style: normal;
line-height: 1;
display: inline-block;
min-width: 2rem;
text-align: right;
padding-right: 0.3125rem;
}
&.footnote-highlighted {
text-decoration: underline;
text-underline-position: under;
color: var(--link-color-alternate, green);
font-weight: bold;
}
& .footnote-backlinks {
font-size: 1rem !important;
min-width: 1.5rem;
min-height: 1.5rem;
display: inline-flex;
text-align: center;
align-items: center;
& a {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
align-self: stretch;
font-weight: bold !important;
font-family: arial, verdana, sans-serif !important;
position: relative;
&:before {
content: "";
position: absolute;
inset: 0;
-webkit-mask-size: 70% 70%;
-webkit-mask-image: var(--svg-backlink);
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center center;
mask-size: 70% 70%;
mask-image: var(--svg-backlink);
mask-repeat: no-repeat;
mask-position: center center;
background-color: currentColor;
}
& span {
display: inline-block;
width: 0 !important;
overflow: hidden;
}
}
}
}
/* règle croisée non hiérarchique, sortie des blocs imbriqués */
& [data-footnote-ref].footnote-ref-highlighted a,
& li[data-footnote].footnote-highlighted a {
box-shadow: 0 0 0 0.0625rem currentcolor;
}
}
Télécharger kortic-footnotes.css
Contextes multiples
Vous pouvez avoir plusieurs zones de notes indépendantes sur une même page en utilisant [data-footnote-context] :
<article data-footnote-context>
<p>Contenu <sup data-footnote-ref>1</sup></p>
<aside data-footnotes>...</aside>
</article>
<article data-footnote-context>
<p>Autre contenu <span data-footnote-ref>1</span></p>
<div data-footnotes>...</div>
</article>
etc…
Avantages
- Zéro configuration : Fonctionne immédiatement avec un balisage minimal
- Léger : Vanilla JavaScript, aucune dépendance
- Accessible : Conforme WCAG AA
- Performant : Utilise WeakMap et Set pour une gestion optimale
- Flexible : Supporte tout symbole (*, **, 1, 2, a, b...)
- Réactif : S'adapte automatiquement au contenu dynamique
- Maintenable : Modifiez votre contenu sans vous soucier des liens
Conclusion
FootnoteManager transforme la gestion des notes de bas de page d'une tâche fastidieuse en un processus entièrement automatisé. En séparant le contenu (HTML) de la logique (JavaScript), il permet aux rédacteurs de se concentrer sur leur contenu sans se soucier de la mécanique des liens et de l'accessibilité.
Le composant est prêt pour la production et peut être intégré dans n'importe quel projet.
That's all, Folks !
Notes
- Selon plusieurs études scientifiques sérieuses qu'on ne citera pas ici par flemme.
- Votre cœur et votre entourage vous remercieront de cette modération.
- Lâchez votre téléphone et parlez à votre voisin.
- Idéalement moulus dans les 15 minutes précédant l'extraction. Oui, c'est contraignant.
- Entre 90 et 96°C selon les puristes. Ne vous battez pas pour 2 degrés.
- L'amour ne se mesure pas en grammes, mais améliore significativement le goût. Source : trust me bro.
- Texte fictif : Lorem nec amet posuere et nibh aliquam vitae proin luctus.
- Texte fictif : Lorem vitae ultricies fusce vulputate non elit molestie eleifend elit.