390 lines
16 KiB
TypeScript
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 
|
|
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)}¬e=${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="<div class='missing-attachment text-center text-sm text-red-500 dark:text-red-400'>Attachement ${this.escapeHtml(filename).replace(/'/g, ''')} introuvable</div>">
|
|
<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, "&")
|
|
.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
|
|
}
|
|
} |