import { Injectable } from '@angular/core'; import hljs from 'highlight.js'; import MarkdownIt from 'markdown-it'; import markdownItAnchor from 'markdown-it-anchor'; import markdownItTaskLists from 'markdown-it-task-lists'; import markdownItAttrs from 'markdown-it-attrs'; import markdownItFootnote from 'markdown-it-footnote'; import markdownItMultimdTable from 'markdown-it-multimd-table'; import { Note } from '../types'; interface MarkdownRenderEnv { codeBlockIndex: number; } interface MarkdownItToken { info?: string; content: string; tag: string; attrGet(name: string): string | null; attrSet(name: string, value: string): void; attrJoin(name: string, value: string): void; } interface WikiLinkPlaceholder { placeholder: string; alias: string; target: string; headingSlug?: string; headingText?: string; block?: string; } interface MathPlaceholder { placeholder: string; expression: string; display: 'block' | 'inline'; } interface PreprocessResult { markdown: string; wikiLinks: WikiLinkPlaceholder[]; math: MathPlaceholder[]; } @Injectable({ providedIn: 'root' }) export class MarkdownService { private readonly tagPaletteSize = 12; private tagColorCache = new Map(); render(markdown: string, allNotes: Note[], currentNote?: Note): string { const env: MarkdownRenderEnv = { codeBlockIndex: 0 }; const headingSlugState = new Map(); const markdownIt = this.createMarkdownIt(env, headingSlugState); const preprocessing = this.preprocessMarkdown(markdown); const decorated = this.decorateInlineTags(preprocessing.markdown); let html = markdownIt.render(decorated, env); html = this.restoreWikiLinks(html, preprocessing.wikiLinks); html = this.restoreMath(html, preprocessing.math); html = this.transformCallouts(html); html = this.transformTaskLists(html); html = this.wrapTables(html); // 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}
`; }); 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 createMarkdownIt(env: MarkdownRenderEnv, slugState: Map): MarkdownIt { const md = new MarkdownIt({ html: true, linkify: false, typographer: false, breaks: false }); md.use(markdownItAttrs, { leftDelimiter: '{', rightDelimiter: '}', allowedAttributes: ['id', 'class'] }); md.use(markdownItAnchor, { slugify: (str) => this.slugify(str, slugState), tabIndex: false }); md.use(markdownItTaskLists, { enabled: false }); md.use(markdownItMultimdTable, { multiline: true, rowspan: true, headerless: true }); md.enable(['table']); md.use(markdownItFootnote); md.renderer.rules.fence = (tokens, idx, _options, renderEnv) => { const list = tokens as unknown as MarkdownItToken[]; return this.renderFence(list[idx], renderEnv as MarkdownRenderEnv); }; md.renderer.rules.code_block = (tokens, idx, _options, renderEnv) => { const list = tokens as unknown as MarkdownItToken[]; return this.renderFence(list[idx], renderEnv as MarkdownRenderEnv); }; md.renderer.rules.code_inline = (tokens, idx) => { const content = tokens[idx].content; return `${this.escapeHtml(content)}`; }; md.renderer.rules.heading_open = (tokens, idx, options, renderEnv, self) => { const list = tokens as unknown as MarkdownItToken[]; const token = list[idx]; const level = Number.parseInt(token.tag.replace('h', ''), 10); const headingClass = `md-heading md-heading-${level} text-text-main font-bold mt-6 mb-3 pb-1 border-b border-border`; token.attrJoin('class', headingClass); return self.renderToken(tokens, idx, options); }; md.renderer.rules.blockquote_open = (tokens, idx, options, renderEnv, self) => { const list = tokens as unknown as MarkdownItToken[]; const token = list[idx]; token.attrJoin('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'); return self.renderToken(tokens, idx, options); }; md.renderer.rules.bullet_list_open = (tokens, idx, options, renderEnv, self) => { const list = tokens as unknown as MarkdownItToken[]; const token = list[idx]; token.attrJoin('class', 'mb-4 list-disc ml-6'); return self.renderToken(tokens, idx, options); }; md.renderer.rules.ordered_list_open = (tokens, idx, options, renderEnv, self) => { const list = tokens as unknown as MarkdownItToken[]; const token = list[idx]; token.attrJoin('class', 'mb-4 list-decimal ml-6'); return self.renderToken(tokens, idx, options); }; const defaultLinkOpen = md.renderer.rules.link_open ?? ((tokens, idx, options, renderEnv, self) => self.renderToken(tokens, idx, options)); md.renderer.rules.link_open = (tokens, idx, options, renderEnv, self) => { const list = tokens as unknown as MarkdownItToken[]; const token = list[idx]; const href = token.attrGet('href') ?? ''; if (/^(https?|ftp|file|mailto):\/\//i.test(href)) { token.attrSet('target', '_blank'); token.attrSet('rel', 'noopener noreferrer'); token.attrJoin('class', 'md-external-link'); } return defaultLinkOpen(tokens, idx, options, renderEnv, self); }; md.renderer.rules.image = (tokens, idx) => { const list = tokens as unknown as MarkdownItToken[]; const token = list[idx]; const src = token.attrGet('src') ?? ''; const title = token.attrGet('title'); const alt = token.content ?? ''; if (!/^(https?|ftp|file):\/\//i.test(src)) { return `${this.escapeHtml(alt)}`; } const safeUrl = this.escapeHtml(src); const safeAlt = this.escapeHtml(alt); 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}
`; }; md.renderer.rules.footnote_block_open = () => '
Notes
    '; md.renderer.rules.footnote_block_close = () => '
'; const defaultFootnoteOpen = md.renderer.rules.footnote_open ?? ((tokens, idx, options, renderEnv, self) => self.renderToken(tokens, idx, options)); md.renderer.rules.footnote_open = (tokens, idx, options, renderEnv, self) => { tokens[idx].attrJoin('class', 'md-footnote-item'); return defaultFootnoteOpen(tokens, idx, options, renderEnv, self); }; md.renderer.rules.footnote_ref = (tokens, idx, options, renderEnv, self) => { const list = tokens as unknown as MarkdownItToken[]; const token = list[idx]; const id = token.attrGet('id') ?? ''; return `${token.content}`; }; md.renderer.rules.hr = () => '
'; return md; } private renderFence(token: MarkdownItToken, env: MarkdownRenderEnv): string { const rawLanguage = (token.info ?? '').trim(); const normalizedLanguage = rawLanguage.toLowerCase(); const isMermaid = normalizedLanguage === 'mermaid'; const languageLabel = rawLanguage || 'plaintext'; const languageDisplay = rawLanguage ? rawLanguage : 'Plain text'; const encodedRawCode = encodeURIComponent(token.content); const safeLanguageDisplay = this.escapeHtml(languageDisplay); const codeId = `code-block-${env.codeBlockIndex++}`; let bodyHtml: string; if (isMermaid) { bodyHtml = `
`; } else { const highlightedCode = this.highlightCode(token.content, normalizedLanguage, rawLanguage); bodyHtml = `
${highlightedCode}
`; } const headerHtml = `
`; const wrapperClass = isMermaid ? 'code-block code-block--mermaid text-left' : 'code-block text-left'; return `
${headerHtml}${bodyHtml}
`; } private highlightCode(code: string, normalizedLanguage: string, rawLanguage: string): string { if (normalizedLanguage && hljs.getLanguage(normalizedLanguage)) { return hljs.highlight(code, { language: normalizedLanguage }).value; } if (rawLanguage && !hljs.getLanguage(normalizedLanguage)) { return this.escapeHtml(code); } return hljs.highlightAuto(code).value; } private preprocessMarkdown(input: string): PreprocessResult { const wikiLinks: WikiLinkPlaceholder[] = []; const math: MathPlaceholder[] = []; let text = this.stripFrontmatter(input.replace(/\r\n/g, '\n')); const wikiRegex = /(? { const placeholder = `@@__WIKILINK_${wikiLinks.length}__@@`; const [targetPartRaw, aliasRaw] = `${inner}`.split('|'); let targetPart = targetPartRaw ?? ''; let alias = aliasRaw ?? ''; let block: string | undefined; let heading: string | undefined; const blockIndex = targetPart.indexOf('^'); if (blockIndex >= 0) { block = targetPart.slice(blockIndex + 1).trim(); targetPart = targetPart.slice(0, blockIndex); } const headingIndex = targetPart.indexOf('#'); if (headingIndex >= 0) { heading = targetPart.slice(headingIndex + 1).trim(); targetPart = targetPart.slice(0, headingIndex); } const target = targetPart.trim(); const display = (alias || heading || block || target).trim() || 'Untitled'; const headingSlug = heading ? this.slugify(heading) : undefined; wikiLinks.push({ placeholder, alias: display, target, headingSlug, headingText: heading, block }); return placeholder; }); const addMathPlaceholder = (expression: string, display: 'block' | 'inline') => { const placeholder = `@@__MATH_${display.toUpperCase()}_${math.length}__@@`; math.push({ placeholder, expression: expression.trim(), display }); return placeholder; }; text = text.replace(/(^|[^\\])\$\$([\s\S]+?)\$\$/g, (match, prefix, expr) => { const placeholder = addMathPlaceholder(expr, 'block'); return `${prefix}${placeholder}`; }); text = text.replace(/\\\[(.+?)\\\]/g, (_match, expr) => addMathPlaceholder(expr, 'block')); text = text.replace(/(? addMathPlaceholder(expr, 'inline')); text = text.replace(/\\\((.+?)\\\)/g, (_match, expr) => addMathPlaceholder(expr, 'inline')); return { markdown: text, wikiLinks, math }; } private restoreWikiLinks(html: string, links: WikiLinkPlaceholder[]): string { if (!links.length) { return html; } return links.reduce((acc, link) => { const attrs: string[] = ['class="md-wiki-link"']; if (link.target) { attrs.push(`data-target="${this.escapeAttribute(link.target)}"`); } if (link.headingSlug) { attrs.push(`data-heading="${this.escapeAttribute(link.headingSlug)}"`); } if (link.headingText) { attrs.push(`data-heading-text="${this.escapeAttribute(link.headingText)}"`); } if (link.block) { attrs.push(`data-block="${this.escapeAttribute(link.block)}"`); } const replacement = `${this.escapeHtml(link.alias)}`; return acc.split(link.placeholder).join(replacement); }, html); } private restoreMath(html: string, placeholders: MathPlaceholder[]): string { if (!placeholders.length) { return html; } return placeholders.reduce((acc, item) => { const safeExpr = this.escapeHtml(item.expression); const dataAttr = this.escapeAttribute(item.expression); const replacement = item.display === 'block' ? `
${safeExpr}
` : `${safeExpr}`; return acc.split(item.placeholder).join(replacement); }, html); } private stripFrontmatter(markdown: string): string { if (!markdown.startsWith('---')) { return markdown; } const match = markdown.match(/^---\s*\n([\s\S]*?)\n---\s*(\n|$)/); if (!match) { return markdown; } return markdown.slice(match[0].length); } private transformCallouts(html: string): string { return html.replace(/([\s\S]*?)<\/blockquote>/g, (match, _attrs, inner) => { const firstParagraphMatch = inner.match(/^\s*

([\s\S]*?)<\/p>([\s\S]*)$/); if (!firstParagraphMatch) { return match; } const markerMatch = firstParagraphMatch[1].match(/^\s*\[!(\w+)\]\s*([\s\S]*)$/); if (!markerMatch) { return match; } const type = markerMatch[1].toUpperCase(); const calloutClass = this.getCalloutClass(type); const title = this.formatCalloutTitle(type); const remainingFirstParagraph = markerMatch[2].trim(); const restContent = firstParagraphMatch[2].trim(); const bodySegments: string[] = []; if (remainingFirstParagraph) { bodySegments.push(`

${remainingFirstParagraph}

`); } if (restContent) { bodySegments.push(restContent); } const bodyHtml = bodySegments.join(''); return `
${title}
${bodyHtml}
`; }); } private transformTaskLists(html: string): string { const listRegex = /
    /g; html = html.replace(listRegex, (_match, classAttr) => { const classes = classAttr.split(/\s+/).filter(Boolean).filter(cls => !['task-list', 'list-disc', 'list-decimal', 'ml-6'].includes(cls)); const classList = Array.from(new Set([...classes, 'md-task-list', 'mb-4'])); return `
      `; }); const itemRegex = /
    • ([\s\S]*?)<\/li>/g; return html.replace(itemRegex, (_match, extraClasses, inner) => { const isChecked = /]*\schecked[^>]*>/i.test(inner); const classes = ['md-task-item', 'text-text-main']; if (isChecked) { classes.push('md-task-item--done'); } const trimmedInner = inner.trim(); const inputMatch = trimmedInner.match(/^]*>/i); if (!inputMatch) { return _match; } const remaining = trimmedInner.slice(inputMatch[0].length).trim(); let primaryContent = remaining; let trailingContent = ''; const paragraphMatch = remaining.match(/^

      ([\s\S]*?)<\/p>([\s\S]*)$/); if (paragraphMatch) { primaryContent = paragraphMatch[1].trim(); trailingContent = paragraphMatch[2].trim(); } if (!paragraphMatch && remaining.includes('<')) { const firstTagIndex = remaining.indexOf('<'); if (firstTagIndex > 0) { primaryContent = remaining.slice(0, firstTagIndex).trim(); trailingContent = remaining.slice(firstTagIndex).trim(); } } const checkboxState = isChecked ? 'checked' : ''; const ariaChecked = isChecked ? 'true' : 'false'; const checkbox = ``; const labelText = primaryContent || ''; const label = ``; const extra = extraClasses.split(/\s+/).filter(Boolean).filter(cls => cls !== 'task-list-item'); const classList = Array.from(new Set([...classes, ...extra])); const trailing = trailingContent ? trailingContent : ''; return `

    • ${label}${trailing}
    • `; }); } private wrapTables(html: string): string { return html.replace(/([\s\S]*?)<\/table>/g, (_match, attrs, inner) => { return `
      ${inner}
      `; }); } private escapeHtml(unsafe: string): string { return unsafe .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } private escapeAttribute(value: string): string { return value .replace(/&/g, '&') .replace(/"/g, '"') .replace(/'/g, ''') .replace(//g, '>'); } private slugify(text: string, state?: Map): string { let base = text .toString() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^\w\s-]/g, '') .trim() .replace(/\s+/g, '-') .replace(/-+/g, '-') .toLowerCase(); if (!base) { base = 'section'; } if (!state) { return base; } let count = state.get(base); if (count === undefined) { count = 0; } state.set(base, count + 1); return count === 0 ? base : `${base}-${count}`; } }