import { Injectable } from '@angular/core'; import hljs from 'highlight.js'; import { Note } from '../types'; type TableAlignment = 'left' | 'center' | 'right' | null; @Injectable({ providedIn: 'root' }) export class MarkdownService { private readonly tagPaletteSize = 12; private tagColorCache = new Map(); render(markdown: string, allNotes: Note[], currentNote?: Note): string { let html = markdown; // Process code blocks first to prevent inner content from being parsed const codeBlocks = new Map(); html = html.replace(/```([^\n]*)\n([\s\S]*?)```/g, (match, lang, code) => { const id = `___CODEBLOCK_${codeBlocks.size}___`; const rawLanguage = (lang ?? '').trim(); const normalizedLanguage = rawLanguage.toLowerCase(); const isMermaid = normalizedLanguage === 'mermaid'; let highlightedCode = ''; if (!isMermaid) { if (normalizedLanguage && hljs.getLanguage(normalizedLanguage)) { highlightedCode = hljs.highlight(code, { language: normalizedLanguage }).value; } else if (normalizedLanguage) { highlightedCode = hljs.highlightAuto(code).value; } else { highlightedCode = hljs.highlightAuto(code).value; } } const languageLabel = rawLanguage || 'plaintext'; const languageDisplay = rawLanguage ? rawLanguage : 'Plain text'; const encodedRawCode = encodeURIComponent(code); const safeLanguageDisplay = this.escapeHtml(languageDisplay); const headerHtml = `
`; const codeHtml = isMermaid ? `
` : `
${highlightedCode}
`; const wrapperClass = isMermaid ? 'code-block code-block--mermaid text-left' : 'code-block text-left'; const wrapper = `
${headerHtml}${codeHtml}
`; codeBlocks.set(id, { html: wrapper, language: normalizedLanguage }); return id; }); html = this.convertTables(html); html = this.decorateInlineTags(html); // Headings (H1-H6) let firstLevelOneHeadingSkipped = false; html = html.replace(/^(\#{1,6})\s+(.*)/gm, (match, hashes, content) => { const level = hashes.length; if (level === 1 && !firstLevelOneHeadingSkipped) { firstLevelOneHeadingSkipped = true; return ''; } const id = this.slugify(content); const headingClass = `md-heading md-heading-${level}`; return `${content}`; }); // Bold, Italic, Strikethrough html = html.replace(/\*\*(.*?)\*\*/g, '$1'); html = html.replace(/\*(.*?)\*/g, '$1'); html = html.replace(/~~(.*?)~~/g, '$1'); // Blockquotes & Callouts html = html.replace(/^>\s?\[!(\w+)\]\n(>.*)/gm, (match, type, content) => { const calloutType = type.toUpperCase(); const calloutClass = this.getCalloutClass(calloutType); const cleanedContent = content.replace(/^>\s?/gm, ''); const title = this.formatCalloutTitle(calloutType); const bodyHtml = cleanedContent .split('\n') .map(line => line.trim()) .filter(line => line.length > 0) .map(line => `

${line}

`) .join(''); return `
${title}
${bodyHtml}
`; }); html = html.replace(/^>\s(.*)/gm, '
$1
'); // Task lists const transformTaskItem = (_match: string, marker: string, rawContent: string) => { const isChecked = marker.toLowerCase() === 'x'; const classes = [ 'md-task-item', 'text-obs-l-text-main', 'dark:text-obs-d-text-main' ]; if (isChecked) { classes.push('md-task-item--done'); } const content = rawContent.trim(); const checkboxState = isChecked ? 'checked' : ''; const ariaChecked = isChecked ? 'true' : 'false'; return `
  • `; }; html = html.replace(/^\s*(?:[\-\*]|\d+\.)\s+\[( |x|X)\]\s+(.*)$/gm, transformTaskItem); // Lists (simple implementation) html = html.replace(/^\s*[\-\*]\s(.*)/gm, '
  • $1
  • '); html = html.replace(/^\s*\d+\.\s(.*)/gm, '
  • $1
  • '); html = html.replace(/<\/li>\n
  • )/gs, (match) => { if (match.includes('md-task-item')) return `
      ${match}
    `; if (match.includes('list-disc')) return `
      ${match}
    `; if (match.includes('list-decimal')) return `
      ${match}
    `; return match; }); // Inline Code html = html.replace(/(`+)([\s\S]*?)\1/g, (match, fence, inlineCode) => { if (inlineCode.includes('\n')) { return match; } const safeContent = this.escapeHtml(inlineCode.trim()); return `${safeContent}`; }); // External images ![alt](url "title") html = html.replace(/!\[(.*?)\]\(((?:https?|ftp|file):\/\/[^\s\)]*)(?:\s+"(.*?)")?\)/g, (match, alt, url, title) => { const safeAlt = this.escapeHtml(alt ?? ''); const safeUrl = this.escapeHtml(url ?? ''); const safeTitle = title ? this.escapeHtml(title) : ''; const titleAttr = title ? ` title="${safeTitle}"` : ''; const captionSource = title && title.trim() ? title : alt; const safeCaption = captionSource ? this.escapeHtml(captionSource) : ''; const figcaption = safeCaption ? `
    ${safeCaption}
    ` : ''; return `
    ${safeAlt} ${figcaption}
    `; }); // External links [text](url "title") html = html.replace(/\[(.*?)\]\(((?:https?|ftp|file|mailto):\/\/[^\s\)]*)(?:\s+"(.*?)")?\)/g, (match, label, url, title) => { const safeLabel = this.escapeHtml(label ?? ''); const safeUrl = this.escapeHtml(url ?? ''); const safeTitle = title ? this.escapeHtml(title) : ''; const titleAttr = title ? ` title="${safeTitle}"` : ''; return `${safeLabel}`; }); // Embedded Files ![[file.png]] using Attachements-Path from HOME.md (traité avant les liens internes) const homeNote = allNotes.find(n => (n.fileName?.toLowerCase?.() === 'home.md') || (n.originalPath?.toLowerCase?.() === 'home') || (n.originalPath?.toLowerCase?.().endsWith('/home')) ); const attachmentsBase = (() => { if (!homeNote) return ''; const fm = homeNote.frontmatter || {}; const key = Object.keys(fm).find(k => k?.toLowerCase?.() === 'attachements-path' || k?.toLowerCase?.() === 'attachments-path'); const raw = key ? `${fm[key] ?? ''}` : ''; return raw; })(); html = html.replace(/!\[\[(.*?)\]\]/g, (match, rawName) => { const filename = `${rawName}`.trim(); const safeAlt = this.escapeHtml(filename); const notePath = (currentNote?.filePath || currentNote?.originalPath || '').replace(/\\/g, '/'); const src = `/api/attachments/resolve?name=${encodeURIComponent(filename)}¬e=${encodeURIComponent(notePath)}&base=${encodeURIComponent(attachmentsBase)}`; return `
    ${safeAlt}
    ${safeAlt}
    `; }); // Horizontal Rule html = html.replace(/^-{3,}/gm, '
    '); // Paragraphs and line breaks html = html.split('\n').map(p => p.trim() === '' ? '' : `

    ${p}

    `).join('').replace(/<\/p>

    /g, '


    '); // Restore code blocks for (const [id, block] of codeBlocks.entries()) { html = html.replace(id, block.html); } // Placeholder for unsupported elements html = html.replace(/\$\$(.*?)\$\$/g, '

    [LaTeX Equation: $1]
    '); return html; } private getCalloutClass(type: string): string { const baseClass = 'callout'; switch (type) { case 'NOTE': case 'INFO': return `${baseClass} callout-note`; case 'TIP': case 'TODO': return `${baseClass} callout-tip`; case 'WARNING': case 'CAUTION': return `${baseClass} callout-warning`; case 'DANGER': case 'ERROR': return `${baseClass} callout-danger`; default: return `${baseClass} callout-default`; } } private formatCalloutTitle(type: string): string { const lower = type.toLowerCase(); return lower.charAt(0).toUpperCase() + lower.slice(1); } private decorateInlineTags(markdown: string): string { const tagRegex = /(^|[\s(>)])#([^\s#.,;:!?"'(){}\[\]<>]+)/g; return markdown.replace(tagRegex, (match, prefix, tag) => { if (!tag) { return match; } const trimmedTag = tag.trim(); if (!trimmedTag) { return match; } const colorClass = this.getTagColorClass(trimmedTag); return `${prefix}`; }); } private getTagColorClass(tag: string): string { const normalized = tag.toLowerCase(); if (this.tagColorCache.has(normalized)) { return `md-tag-color-${this.tagColorCache.get(normalized)}`; } let hash = 0; for (let i = 0; i < normalized.length; i++) { hash = (hash * 31 + normalized.charCodeAt(i)) >>> 0; } const index = hash % this.tagPaletteSize; this.tagColorCache.set(normalized, index); return `md-tag-color-${index}`; } private convertTables(markdown: string): string { const lines = markdown.split('\n'); const result: string[] = []; let index = 0; while (index < lines.length) { const currentLine = lines[index]; if (this.isTableRow(currentLine) && this.isSeparatorRow(lines[index + 1] ?? '')) { const headerCells = this.splitTableRow(currentLine); const separatorLine = lines[index + 1]; const rows: string[][] = []; index += 2; while (index < lines.length && this.isTableRow(lines[index])) { rows.push(this.splitTableRow(lines[index])); index += 1; } const columnCount = Math.max( headerCells.length, ...rows.map(row => row.length) ); const alignments = this.parseAlignments(separatorLine, columnCount); const headerHtml = Array.from({ length: columnCount }, (_, colIndex) => { const content = headerCells[colIndex] ?? ''; return this.buildTableCell('th', content, alignments[colIndex]); }).join(''); const bodyHtml = rows.map(row => { const cells = Array.from({ length: columnCount }, (_, colIndex) => { const content = row[colIndex] ?? ''; return this.buildTableCell('td', content, alignments[colIndex]); }).join(''); return `${cells}`; }).join(''); const tableHtml = `
    ${headerHtml}${rows.length ? `${bodyHtml}` : ''}
    `; result.push(tableHtml); continue; } result.push(currentLine); index += 1; } return result.join('\n'); } private isTableRow(line: string): boolean { if (!line) { return false; } const trimmed = line.trim(); if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) { return false; } const segments = this.splitTableRow(trimmed); return segments.length > 0; } private isSeparatorRow(line: string): boolean { if (!line) { return false; } const segments = this.splitTableRow(line); if (!segments.length) { return false; } return segments.every(segment => { const normalized = segment.replace(/\s+/g, ''); return /^:?-{3,}:?$/.test(normalized); }); } private splitTableRow(line: string): string[] { let trimmed = line.trim(); if (trimmed.startsWith('|')) { trimmed = trimmed.slice(1); } if (trimmed.endsWith('|')) { trimmed = trimmed.slice(0, -1); } return trimmed.split('|').map(cell => cell.trim()); } private parseAlignments(separatorLine: string, columnCount: number): TableAlignment[] { const segments = this.splitTableRow(separatorLine); return Array.from({ length: columnCount }, (_, index) => { const segment = (segments[index] ?? '').trim(); const normalized = segment.replace(/\s+/g, ''); if (normalized.startsWith(':') && normalized.endsWith(':')) { return 'center'; } if (normalized.startsWith(':')) { return 'left'; } if (normalized.endsWith(':')) { return 'right'; } return null; }); } private buildTableCell(tag: 'th' | 'td', content: string, alignment: TableAlignment): string { const alignStyle = alignment ? ` style="text-align:${alignment};"` : ''; return `<${tag}${alignStyle}>${content}`; } private escapeHtml(unsafe: string): string { return unsafe .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } private slugify(text: string): string { return text.toString().toLowerCase() .replace(/\s+/g, '-') // Replace spaces with - .replace(/[^\w\-]+/g, '') // Remove all non-word chars .replace(/\-\-+/g, '-') // Replace multiple - with single - .replace(/^-+/, '') // Trim - from start of text .replace(/-+$/, ''); // Trim - from end of text } }