Files
mes-budgets-participatifs/src/lib/markdown.ts
2025-08-27 12:21:09 +02:00

307 lines
9.3 KiB
TypeScript

import DOMPurify from 'dompurify';
// Fonction pour valider les URLs
function isValidUrl(url: string): boolean {
try {
const urlObj = new URL(url);
// Autoriser seulement http et https
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:';
} catch {
return false;
}
}
// Fonction pour nettoyer et valider le contenu markdown
export function parseMarkdown(content: string): string {
if (!content) return '';
// Nettoyer le contenu avant parsing
const cleanContent = content.trim();
// Diviser le contenu en lignes pour traiter les listes correctement
const lines = cleanContent.split('\n');
const processedLines: string[] = [];
let inUnorderedList = false;
let inOrderedList = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Échapper les caractères HTML
let processedLine = line
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
// Parser le markdown de base
processedLine = processedLine
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/__(.*?)__/g, '<u>$1</u>')
.replace(/~~(.*?)~~/g, '<del>$1</del>')
// Parser les liens avec validation
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
if (isValidUrl(url)) {
return `<a href="${url}" target="_blank" rel="noopener noreferrer">${text}</a>`;
}
return text; // Retourner juste le texte si l'URL n'est pas valide
});
// Traiter les titres
if (processedLine.match(/^### /)) {
processedLine = processedLine.replace(/^### (.*$)/, '<h3>$1</h3>');
} else if (processedLine.match(/^## /)) {
processedLine = processedLine.replace(/^## (.*$)/, '<h2>$1</h2>');
} else if (processedLine.match(/^# /)) {
processedLine = processedLine.replace(/^# (.*$)/, '<h1>$1</h1>');
}
// Traiter les listes à puce
if (processedLine.match(/^- /)) {
if (!inUnorderedList) {
processedLine = '<ul>' + processedLine.replace(/^- (.*$)/, '<li>$1</li>');
inUnorderedList = true;
} else {
processedLine = processedLine.replace(/^- (.*$)/, '<li>$1</li>');
}
} else if (processedLine.match(/^\d+\. /)) {
if (!inOrderedList) {
processedLine = '<ol>' + processedLine.replace(/^\d+\. (.*$)/, '<li>$1</li>');
inOrderedList = true;
} else {
processedLine = processedLine.replace(/^\d+\. (.*$)/, '<li>$1</li>');
}
} else {
// Ligne normale - fermer les listes si nécessaire
if (inUnorderedList) {
processedLine = '</ul>' + processedLine;
inUnorderedList = false;
}
if (inOrderedList) {
processedLine = '</ol>' + processedLine;
inOrderedList = false;
}
// Traiter les paragraphes
if (processedLine.trim() === '') {
processedLine = '</p><p>';
} else {
processedLine = processedLine + '<br>';
}
}
processedLines.push(processedLine);
}
// Fermer les listes ouvertes à la fin
if (inUnorderedList) {
processedLines.push('</ul>');
}
if (inOrderedList) {
processedLines.push('</ol>');
}
let htmlContent = processedLines.join('\n');
// Ajouter les balises de paragraphe si nécessaire
if (!htmlContent.startsWith('<h') && !htmlContent.startsWith('<ul') && !htmlContent.startsWith('<ol')) {
htmlContent = `<p>${htmlContent}</p>`;
}
// Configurer DOMPurify pour autoriser seulement les éléments sécurisés
const cleanHtml = DOMPurify.sanitize(htmlContent, {
ALLOWED_TAGS: [
// Texte de base
'p', 'br', 'strong', 'em', 'u', 'del',
// Listes
'ul', 'ol', 'li',
// Liens (avec validation)
'a',
// Titres (limités)
'h1', 'h2', 'h3',
// Saut de ligne
'hr'
],
ALLOWED_ATTR: ['href', 'title', 'target'],
ALLOW_DATA_ATTR: false,
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur'],
});
// Valider et nettoyer les liens
const tempDiv = document.createElement('div');
tempDiv.innerHTML = cleanHtml;
// Valider tous les liens
const links = tempDiv.querySelectorAll('a');
links.forEach(link => {
const href = link.getAttribute('href');
if (href && !isValidUrl(href)) {
// Supprimer le lien si l'URL n'est pas valide
link.removeAttribute('href');
link.style.pointerEvents = 'none';
link.style.color = '#999';
} else if (href) {
// Ajouter target="_blank" et rel="noopener noreferrer" pour la sécurité
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener noreferrer');
}
});
return tempDiv.innerHTML;
}
// Fonction pour prévisualiser le markdown (version simplifiée)
export function previewMarkdown(content: string): string {
if (!content) return '';
// Diviser le contenu en lignes pour traiter les listes correctement
const lines = content.split('\n');
const processedLines: string[] = [];
let inUnorderedList = false;
let inOrderedList = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Échapper les caractères HTML
let processedLine = line
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
// Traiter le markdown de base
processedLine = processedLine
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/__(.*?)__/g, '<u>$1</u>')
.replace(/~~(.*?)~~/g, '<del>$1</del>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
// Traiter les titres
if (processedLine.match(/^### /)) {
processedLine = processedLine.replace(/^### (.*$)/, '<h3>$1</h3>');
} else if (processedLine.match(/^## /)) {
processedLine = processedLine.replace(/^## (.*$)/, '<h2>$1</h2>');
} else if (processedLine.match(/^# /)) {
processedLine = processedLine.replace(/^# (.*$)/, '<h1>$1</h1>');
}
// Traiter les listes à puce
if (processedLine.match(/^- /)) {
if (!inUnorderedList) {
processedLine = '<ul>' + processedLine.replace(/^- (.*$)/, '<li>$1</li>');
inUnorderedList = true;
} else {
processedLine = processedLine.replace(/^- (.*$)/, '<li>$1</li>');
}
} else if (processedLine.match(/^\d+\. /)) {
if (!inOrderedList) {
processedLine = '<ol>' + processedLine.replace(/^\d+\. (.*$)/, '<li>$1</li>');
inOrderedList = true;
} else {
processedLine = processedLine.replace(/^\d+\. (.*$)/, '<li>$1</li>');
}
} else {
// Ligne normale - fermer les listes si nécessaire
if (inUnorderedList) {
processedLine = '</ul>' + processedLine;
inUnorderedList = false;
}
if (inOrderedList) {
processedLine = '</ol>' + processedLine;
inOrderedList = false;
}
// Traiter les paragraphes
if (processedLine.trim() === '') {
processedLine = '</p><p>';
} else {
processedLine = processedLine + '<br>';
}
}
processedLines.push(processedLine);
}
// Fermer les listes ouvertes à la fin
if (inUnorderedList) {
processedLines.push('</ul>');
}
if (inOrderedList) {
processedLines.push('</ol>');
}
let result = processedLines.join('\n');
// Ajouter les balises de paragraphe si nécessaire
if (!result.startsWith('<h') && !result.startsWith('<ul') && !result.startsWith('<ol')) {
result = '<p>' + result + '</p>';
}
return result;
}
// Fonction pour valider le contenu markdown avant sauvegarde
export function validateMarkdown(content: string): { isValid: boolean; errors: string[] } {
const errors: string[] = [];
if (!content) {
return { isValid: true, errors: [] };
}
// Vérifier la longueur
if (content.length > 5000) {
errors.push('Le contenu est trop long (maximum 5000 caractères)');
}
// Vérifier les liens malveillants
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
let match;
while ((match = linkRegex.exec(content)) !== null) {
const url = match[2];
if (!isValidUrl(url)) {
errors.push(`URL invalide détectée: ${url}`);
}
}
// Vérifier les balises HTML non autorisées
const forbiddenTags = ['<script', '<style', '<iframe', '<object', '<embed', '<form'];
forbiddenTags.forEach(tag => {
if (content.toLowerCase().includes(tag)) {
errors.push(`Balise non autorisée détectée: ${tag}`);
}
});
return {
isValid: errors.length === 0,
errors
};
}
// Fonction pour obtenir un aperçu du contenu (sans HTML)
export function getMarkdownPreview(content: string, maxLength: number = 150): string {
if (!content) return '';
// Supprimer le markdown pour l'aperçu
const plainText = content
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
.replace(/__(.*?)__/g, '$1')
.replace(/~~(.*?)~~/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/^[#\-\d\.\s]+/gm, '')
.replace(/\n+/g, ' ')
.trim();
if (plainText.length <= maxLength) {
return plainText;
}
return plainText.substring(0, maxLength) + '...';
}