ObsiViewer/src/services/markdown.service.ts

390 lines
16 KiB
TypeScript

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<string, number>();
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<string, { html: string; language: string }>();
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 = `
<div class="code-block__header">
<div class="code-block__controls" aria-hidden="true">
<span></span><span></span><span></span>
</div>
<div class="code-block__actions">
<button type="button" class="code-block__language-badge" data-language="${languageLabel.toLowerCase()}" data-code-id="${id}">${safeLanguageDisplay}</button>
<span class="code-block__copy-feedback" hidden></span>
</div>
</div>
`;
const codeHtml = isMermaid
? `
<div class="code-block__body mermaid-block__body">
<div class="mermaid-diagram" data-mermaid-code="${encodedRawCode}"></div>
<pre class="mermaid-source" hidden><code class="hljs" data-raw-code="${encodedRawCode}">${this.escapeHtml(code)}</code></pre>
</div>
`
: `<pre class="code-block__body"><code class="hljs" data-raw-code="${encodedRawCode}">${highlightedCode}</code></pre>`;
const wrapperClass = isMermaid ? 'code-block code-block--mermaid text-left' : 'code-block text-left';
const wrapper = `<div class="${wrapperClass}" style="max-width: 800px; width: 100%; margin: 0;" data-language="${languageLabel.toLowerCase()}" data-code-id="${id}">${headerHtml}${codeHtml}</div>`;
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 `<h${level} id="${id}" class="${headingClass} text-obs-l-text-main dark:text-white font-bold mt-6 mb-3 pb-1 border-b border-obs-l-border dark:border-obs-d-border">${content}</h${level}>`;
});
// Bold, Italic, Strikethrough
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
html = html.replace(/~~(.*?)~~/g, '<del>$1</del>');
// 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 => `<p>${line}</p>`)
.join('');
return `<div class="${calloutClass} text-left" style="max-width: 800px; width: 100%; margin: 0;" data-callout-type="${calloutType.toLowerCase()}"><div class="callout__title">${title}</div><div class="callout__body">${bodyHtml}</div></div>`;
});
html = html.replace(/^>\s(.*)/gm, '<blockquote class="border-l-4 border-gray-300 dark:border-gray-500 pl-4 py-2 my-4 italic text-gray-700 dark:text-gray-300">$1</blockquote>');
// 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 `<li class="${classes.join(' ')}"><label class="md-task"><input type="checkbox" class="md-task-checkbox" ${checkboxState} disabled aria-checked="${ariaChecked}" tabindex="-1"><span class="md-task-label-text">${content}</span></label></li>`;
};
html = html.replace(/^\s*(?:[\-\*]|\d+\.)\s+\[( |x|X)\]\s+(.*)$/gm, transformTaskItem);
// Lists (simple implementation)
html = html.replace(/^\s*[\-\*]\s(.*)/gm, '<li class="ml-6 list-disc">$1</li>');
html = html.replace(/^\s*\d+\.\s(.*)/gm, '<li class="ml-6 list-decimal">$1</li>');
html = html.replace(/<\/li>\n<li/g, '</li><li'); // Fix spacing
html = html.replace(/(<li.*<\/li>)/gs, (match) => {
if (match.includes('md-task-item')) return `<ul class="mb-4 md-task-list">${match}</ul>`;
if (match.includes('list-disc')) return `<ul class="mb-4">${match}</ul>`;
if (match.includes('list-decimal')) return `<ol class="mb-4">${match}</ol>`;
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 `<code class="inline-code">${safeContent}</code>`;
});
// 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
? `<figcaption class="text-center text-sm text-obs-l-text-muted dark:text-obs-d-text-muted">${safeCaption}</figcaption>`
: '';
return `<figure class="my-4 md-attachment-figure">
<img src="${safeUrl}" alt="${safeAlt}"${titleAttr} loading="lazy" class="rounded-lg max-w-full h-auto mx-auto">
${figcaption}
</figure>`;
});
// 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 `<a href="${safeUrl}"${titleAttr} target="_blank" rel="noopener noreferrer" class="md-external-link">${safeLabel}</a>`;
});
// 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)}&note=${encodeURIComponent(notePath)}&base=${encodeURIComponent(attachmentsBase)}`;
return `<figure class="my-4 md-attachment-figure">
<img src="${src}" alt="${safeAlt}" loading="lazy" class="rounded-lg max-w-full h-auto mx-auto md-attachment-image" data-attachment-name="${safeAlt}" data-error-message="&lt;div class=&#39;missing-attachment text-center text-sm text-red-500 dark:text-red-400&#39;&gt;Attachement ${this.escapeHtml(filename).replace(/'/g, '&#39;')} introuvable&lt;/div&gt;">
<figcaption class="text-center text-sm text-obs-l-text-muted dark:text-obs-d-text-muted">${safeAlt}</figcaption>
</figure>`;
});
// Horizontal Rule
html = html.replace(/^-{3,}/gm, '<hr class="my-6 border-obs-l-border dark:border-obs-d-border">');
// Paragraphs and line breaks
html = html.split('\n').map(p => p.trim() === '' ? '' : `<p>${p}</p>`).join('').replace(/<\/p><p>/g, '</p><br><p>');
// Restore code blocks
for (const [id, block] of codeBlocks.entries()) {
html = html.replace(id, block.html);
}
// Placeholder for unsupported elements
html = html.replace(/\$\$(.*?)\$\$/g, '<div class="my-4 p-4 bg-obs-l-bg-secondary text-obs-l-text-muted dark:bg-obs-d-bg-secondary dark:text-obs-d-text-muted rounded text-center">[LaTeX Equation: $1]</div>');
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}<button type="button" class="md-tag-badge ${colorClass}" data-tag="${trimmedTag}" data-origin="inline">#${trimmedTag}</button>`;
});
}
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 `<tr>${cells}</tr>`;
}).join('');
const tableHtml = `<div class="markdown-table"><table><thead><tr>${headerHtml}</tr></thead>${rows.length ? `<tbody>${bodyHtml}</tbody>` : '<tbody></tbody>'}</table></div>`;
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}</${tag}>`;
}
private escapeHtml(unsafe: string): string {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
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
}
}