"use strict";
import { parse, Renderer, type Tokens } from "marked";
import htmlSanitizer from "../html_sanitizer.js";
import importUtils from "./utils.js";
import { getMimeTypeFromMarkdownName, MIME_TYPE_AUTO } from "@triliumnext/commons";
import { ADMONITION_TYPE_MAPPINGS } from "../export/markdown.js";
import utils from "../utils.js";
import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons";
/**
* Keep renderer code up to date with https://github.com/markedjs/marked/blob/master/src/Renderer.ts.
*/
class CustomMarkdownRenderer extends Renderer {
override heading(data: Tokens.Heading): string {
// Treat h1 as raw text.
if (data.depth === 1) {
return `
${data.text}
`;
}
return super.heading(data).trimEnd();
}
override paragraph(data: Tokens.Paragraph): string {
return super.paragraph(data).trimEnd();
}
override code({ text, lang }: Tokens.Code): string {
if (!text) {
return "";
}
// Escape the HTML.
text = utils.escapeHtml(text);
// Unescape "
text = text.replace(/"/g, '"');
const ckEditorLanguage = getNormalizedMimeFromMarkdownLanguage(lang);
return `
${text}
`;
}
override list(token: Tokens.List): string {
let result = super.list(token)
.replace("\n", "") // we replace the first one only.
.trimEnd();
// Handle todo-list in the CKEditor format.
if (token.items.some(item => item.task)) {
result = result.replace(/^
\[\!([A-Z]+)\]/.exec(body);
if (Array.isArray(admonitionMatch) && admonitionMatch.length === 2) {
const type = admonitionMatch[1].toLowerCase();
if (ADMONITION_TYPE_MAPPINGS[type]) {
const bodyWithoutHeader = body
.replace(/^
\[\!([A-Z]+)\]\s*/, "
")
.replace(/^
<\/p>/, ""); // Having a heading will generate an empty paragraph that we need to remove.
return ``;
}
}
return `
${body}
`;
}
text(token: Tokens.Text | Tokens.Escape): string {
let text = super.text(token);
text = processWikiLinks(text);
return text;
}
}
function renderToHtml(content: string, title: string) {
// Double-escape slashes in math expression because they are otherwise consumed by the parser somewhere.
content = content.replaceAll("\\$", "\\\\$");
// Extract formulas and replace them with placeholders to prevent interference from Markdown rendering
const { processedText, placeholderMap: formulaMap } = extractFormulas(content);
let html = parse(processedText, {
async: false,
renderer: renderer
}) as string;
// After rendering, replace placeholders back with the formula HTML
html = restoreFromMap(html, formulaMap);
// h1 handling needs to come before sanitization
html = importUtils.handleH1(html, title);
html = htmlSanitizer.sanitize(html);
// Add a trailing semicolon to CSS styles.
html = html.replaceAll(/(<(img|figure|col).*?style=".*?)"/g, "$1;\"");
// Remove slash for self-closing tags to match CKEditor's approach.
html = html.replace(/<(\w+)([^>]*)\s+\/>/g, "<$1$2>");
// Normalize non-breaking spaces to entity.
html = html.replaceAll("\u00a0", " ");
return html;
}
function getNormalizedMimeFromMarkdownLanguage(language: string | undefined) {
if (language) {
const mimeDefinition = getMimeTypeFromMarkdownName(language);
if (mimeDefinition) {
return normalizeMimeTypeForCKEditor(mimeDefinition.mime);
}
}
return MIME_TYPE_AUTO;
}
function extractCodeBlocks(text: string): { processedText: string, placeholderMap: Map } {
const codeMap = new Map();
let id = 0;
const timestamp = Date.now();
// Multi-line code block and Inline code
text = text.replace(/```[\s\S]*?```/g, (m) => {
const key = ``;
codeMap.set(key, m);
return key;
}).replace(/`[^`\n]+`/g, (m) => {
const key = ``;
codeMap.set(key, m);
return key;
});
return { processedText: text, placeholderMap: codeMap };
}
function extractFormulas(text: string): { processedText: string, placeholderMap: Map } {
// Protect the $ signs inside code blocks from being recognized as formulas.
const { processedText: noCodeText, placeholderMap: codeMap } = extractCodeBlocks(text);
const formulaMap = new Map();
let id = 0;
const timestamp = Date.now();
// Display math and Inline math
let processedText = noCodeText.replace(/(? {
const key = ``;
const rendered = `\\[${formula}\\]`;
formulaMap.set(key, rendered);
return key;
}).replace(/(? {
const key = ``;
const rendered = `\\(${formula}\\)`;
formulaMap.set(key, rendered);
return key;
});
processedText = restoreFromMap(processedText, codeMap);
return { processedText, placeholderMap: formulaMap };
}
function restoreFromMap(text: string, map: Map): string {
if (map.size === 0) return text;
const pattern = [...map.keys()]
.map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('|');
return text.replace(new RegExp(pattern, 'g'), match => map.get(match) ?? match);
}
function processWikiLinks(paragraph: string) {
paragraph = paragraph.replaceAll(/\[\[([^\[\]]+)\]\]/g, `$1`);
return paragraph;
}
const renderer = new CustomMarkdownRenderer({ async: false });
export default {
renderToHtml
};