589 lines
22 KiB
TypeScript
589 lines
22 KiB
TypeScript
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<string, number>();
|
|
|
|
render(markdown: string, allNotes: Note[], currentNote?: Note): string {
|
|
const env: MarkdownRenderEnv = { codeBlockIndex: 0 };
|
|
const headingSlugState = new Map<string, number>();
|
|
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 `<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-text-muted">${safeAlt}</figcaption>
|
|
</figure>`;
|
|
});
|
|
|
|
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 createMarkdownIt(env: MarkdownRenderEnv, slugState: Map<string, number>): 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 `<code class="inline-code">${this.escapeHtml(content)}</code>`;
|
|
};
|
|
|
|
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 `<img src="${this.escapeHtml(src)}" alt="${this.escapeHtml(alt)}"${title ? ` title="${this.escapeHtml(title)}"` : ''}>`;
|
|
}
|
|
|
|
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 ? `<figcaption class="text-center text-sm text-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>`;
|
|
};
|
|
|
|
md.renderer.rules.footnote_block_open = () => '<section class="md-footnotes text-sm text-text-muted mt-12"><header class="font-semibold uppercase tracking-wide mb-2">Notes</header><ol class="space-y-2">';
|
|
md.renderer.rules.footnote_block_close = () => '</ol></section>';
|
|
|
|
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 `<sup class="md-footnote-ref"><a href="#${this.escapeHtml(id)}" class="md-external-link" rel="footnote">${token.content}</a></sup>`;
|
|
};
|
|
|
|
md.renderer.rules.hr = () => '<hr class="my-6 border-border">';
|
|
|
|
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 = `
|
|
<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(token.content)}</code></pre>
|
|
</div>
|
|
`;
|
|
} else {
|
|
const highlightedCode = this.highlightCode(token.content, normalizedLanguage, rawLanguage);
|
|
bodyHtml = `<pre class="code-block__body"><code class="hljs" data-raw-code="${encodedRawCode}">${highlightedCode}</code></pre>`;
|
|
}
|
|
|
|
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="${codeId}">${safeLanguageDisplay}</button>
|
|
<span class="code-block__copy-feedback" hidden></span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const wrapperClass = isMermaid ? 'code-block code-block--mermaid text-left' : 'code-block text-left';
|
|
return `<div class="${wrapperClass}" style="max-width: 800px; width: 100%; margin: 0;" data-language="${languageLabel.toLowerCase()}" data-code-id="${codeId}">${headerHtml}${bodyHtml}</div>`;
|
|
}
|
|
|
|
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 = /(?<!\!)\[\[([^\]]+)\]\]/g;
|
|
text = text.replace(wikiRegex, (_match, inner) => {
|
|
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(/(?<!\\)\$(?!\s)([^$]+?)(?<!\\)\$(?!\d)/g, (_match, expr) => 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 = `<a ${attrs.join(' ')}>${this.escapeHtml(link.alias)}</a>`;
|
|
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'
|
|
? `<div class="md-math-block" data-math="${dataAttr}">${safeExpr}</div>`
|
|
: `<span class="md-math-inline" data-math="${dataAttr}">${safeExpr}</span>`;
|
|
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(/<blockquote([\s\S]*?)>([\s\S]*?)<\/blockquote>/g, (match, _attrs, inner) => {
|
|
const firstParagraphMatch = inner.match(/^\s*<p>([\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(`<p>${remainingFirstParagraph}</p>`);
|
|
}
|
|
if (restContent) {
|
|
bodySegments.push(restContent);
|
|
}
|
|
|
|
const bodyHtml = bodySegments.join('');
|
|
|
|
return `<div class="${calloutClass} text-left" style="max-width: 800px; width: 100%; margin: 0;" data-callout-type="${type.toLowerCase()}"><div class="callout__title">${title}</div><div class="callout__body">${bodyHtml}</div></div>`;
|
|
});
|
|
}
|
|
|
|
private transformTaskLists(html: string): string {
|
|
const listRegex = /<ul class="([^"]*?task-list[^"]*?)">/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 `<ul class="${classList.join(' ')}">`;
|
|
});
|
|
|
|
const itemRegex = /<li class="task-list-item([^"]*)">([\s\S]*?)<\/li>/g;
|
|
return html.replace(itemRegex, (_match, extraClasses, inner) => {
|
|
const isChecked = /<input[^>]*\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(/^<input[^>]*>/i);
|
|
if (!inputMatch) {
|
|
return _match;
|
|
}
|
|
|
|
const remaining = trimmedInner.slice(inputMatch[0].length).trim();
|
|
let primaryContent = remaining;
|
|
let trailingContent = '';
|
|
|
|
const paragraphMatch = remaining.match(/^<p>([\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 = `<input type="checkbox" class="md-task-checkbox" ${checkboxState} disabled aria-checked="${ariaChecked}" tabindex="-1">`;
|
|
const labelText = primaryContent || '';
|
|
const label = `<label class="md-task">${checkbox}<span class="md-task-label-text">${labelText}</span></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 `<li class="${classList.join(' ')}">${label}${trailing}</li>`;
|
|
});
|
|
}
|
|
|
|
private wrapTables(html: string): string {
|
|
return html.replace(/<table([\s\S]*?)>([\s\S]*?)<\/table>/g, (_match, attrs, inner) => {
|
|
return `<div class="markdown-table"><table${attrs}>${inner}</table></div>`;
|
|
});
|
|
}
|
|
|
|
private escapeHtml(unsafe: string): string {
|
|
return unsafe
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
private escapeAttribute(value: string): string {
|
|
return value
|
|
.replace(/&/g, '&')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
}
|
|
|
|
private slugify(text: string, state?: Map<string, number>): 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}`;
|
|
}
|
|
} |