2019-09-30 20:56:32 +02:00
|
|
|
"use strict";
|
|
|
|
|
2025-04-05 02:31:52 +03:00
|
|
|
import TurndownService, { type Rule } from "turndown";
|
2025-03-19 18:31:45 +02:00
|
|
|
import { gfm } from "../../../packages/turndown-plugin-gfm/src/gfm.js";
|
2025-04-05 02:31:52 +03:00
|
|
|
import type { DOMElement } from "react";
|
2019-09-30 20:56:32 +02:00
|
|
|
|
2024-02-19 21:59:40 +02:00
|
|
|
let instance: TurndownService | null = null;
|
2019-09-30 20:56:32 +02:00
|
|
|
|
2025-03-15 11:58:11 +02:00
|
|
|
// TODO: Move this to a dedicated file someday.
|
|
|
|
export const ADMONITION_TYPE_MAPPINGS: Record<string, string> = {
|
|
|
|
note: "NOTE",
|
|
|
|
tip: "TIP",
|
|
|
|
important: "IMPORTANT",
|
|
|
|
caution: "CAUTION",
|
|
|
|
warning: "WARNING"
|
|
|
|
};
|
|
|
|
|
|
|
|
export const DEFAULT_ADMONITION_TYPE = ADMONITION_TYPE_MAPPINGS.note;
|
|
|
|
|
2024-12-17 23:45:37 +02:00
|
|
|
const fencedCodeBlockFilter: TurndownService.Rule = {
|
2025-01-09 18:07:02 +02:00
|
|
|
filter: function (node, options) {
|
|
|
|
return options.codeBlockStyle === "fenced" && node.nodeName === "PRE" && node.firstChild !== null && node.firstChild.nodeName === "CODE";
|
|
|
|
},
|
|
|
|
|
|
|
|
replacement: function (content, node, options) {
|
|
|
|
if (!node.firstChild || !("getAttribute" in node.firstChild) || typeof node.firstChild.getAttribute !== "function") {
|
|
|
|
return content;
|
|
|
|
}
|
2024-12-17 23:45:37 +02:00
|
|
|
|
2025-01-09 18:07:02 +02:00
|
|
|
const className = node.firstChild.getAttribute("class") || "";
|
|
|
|
const language = rewriteLanguageTag((className.match(/language-(\S+)/) || [null, ""])[1]);
|
2024-12-17 23:45:37 +02:00
|
|
|
|
2025-01-09 18:07:02 +02:00
|
|
|
return "\n\n" + options.fence + language + "\n" + node.firstChild.textContent + "\n" + options.fence + "\n\n";
|
|
|
|
}
|
2024-12-17 23:45:37 +02:00
|
|
|
};
|
|
|
|
|
2024-02-19 21:59:40 +02:00
|
|
|
function toMarkdown(content: string) {
|
2019-09-30 20:56:32 +02:00
|
|
|
if (instance === null) {
|
2025-02-22 12:45:21 +02:00
|
|
|
instance = new TurndownService({
|
|
|
|
headingStyle: "atx",
|
|
|
|
codeBlockStyle: "fenced"
|
|
|
|
});
|
2024-12-22 15:42:15 +02:00
|
|
|
// Filter is heavily based on: https://github.com/mixmark-io/turndown/issues/274#issuecomment-458730974
|
2025-01-09 18:07:02 +02:00
|
|
|
instance.addRule("fencedCodeBlock", fencedCodeBlockFilter);
|
2025-03-11 17:12:48 +02:00
|
|
|
instance.addRule("img", buildImageFilter());
|
2025-03-15 11:25:42 +02:00
|
|
|
instance.addRule("admonition", buildAdmonitionFilter());
|
2025-04-05 02:31:52 +03:00
|
|
|
instance.addRule("inlineLink", buildInlineLinkFilter());
|
2025-04-05 03:23:31 +03:00
|
|
|
instance.addRule("figure", buildFigureFilter());
|
2025-04-05 09:28:18 +03:00
|
|
|
instance.addRule("math", buildMathFilter());
|
2025-03-19 18:31:45 +02:00
|
|
|
instance.use(gfm);
|
2025-03-14 17:59:50 +02:00
|
|
|
instance.keep([ "kbd" ]);
|
2019-09-30 20:56:32 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
return instance.turndown(content);
|
|
|
|
}
|
|
|
|
|
2024-12-17 23:35:08 +02:00
|
|
|
function rewriteLanguageTag(source: string) {
|
2024-12-17 23:45:37 +02:00
|
|
|
if (!source) {
|
2025-01-09 18:07:02 +02:00
|
|
|
return source;
|
2024-12-17 23:45:37 +02:00
|
|
|
}
|
|
|
|
|
2025-01-11 14:11:18 +02:00
|
|
|
switch (source) {
|
|
|
|
case "text-x-trilium-auto":
|
|
|
|
return "";
|
|
|
|
case "application-javascript-env-frontend":
|
|
|
|
case "application-javascript-env-backend":
|
|
|
|
return "javascript";
|
2025-03-11 21:05:55 +02:00
|
|
|
case "text-x-nginx-conf":
|
|
|
|
return "nginx";
|
2025-01-11 14:11:18 +02:00
|
|
|
default:
|
|
|
|
return source.split("-").at(-1);
|
2024-12-17 23:40:39 +02:00
|
|
|
}
|
2024-12-17 23:35:08 +02:00
|
|
|
}
|
|
|
|
|
2025-03-11 17:12:48 +02:00
|
|
|
// TODO: Remove once upstream delivers a fix for https://github.com/mixmark-io/turndown/issues/467.
|
|
|
|
function buildImageFilter() {
|
|
|
|
const ESCAPE_PATTERNS = {
|
|
|
|
before: /([\\*`[\]_]|(?:^[-+>])|(?:^~~~)|(?:^#{1-6}))/g,
|
|
|
|
after: /((?:^\d+(?=\.)))/
|
|
|
|
}
|
|
|
|
|
|
|
|
const escapePattern = new RegExp('(?:' + ESCAPE_PATTERNS.before.source + '|' + ESCAPE_PATTERNS.after.source + ')', 'g');
|
|
|
|
|
|
|
|
function escapeMarkdown (content: string) {
|
|
|
|
return content.replace(escapePattern, function (match, before, after) {
|
|
|
|
return before ? '\\' + before : after + '\\'
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
function escapeLinkDestination(destination: string) {
|
|
|
|
return destination
|
|
|
|
.replace(/([()])/g, '\\$1')
|
|
|
|
.replace(/ /g, "%20");
|
|
|
|
}
|
|
|
|
|
|
|
|
function escapeLinkTitle (title: string) {
|
|
|
|
return title.replace(/"/g, '\\"')
|
|
|
|
}
|
|
|
|
|
|
|
|
const imageFilter: TurndownService.Rule = {
|
|
|
|
filter: "img",
|
2025-04-05 03:01:06 +03:00
|
|
|
replacement(content, _node) {
|
|
|
|
const node = _node as HTMLElement;
|
|
|
|
|
|
|
|
// Preserve image verbatim if it has a width or height attribute.
|
|
|
|
if (node.hasAttribute("width") || node.hasAttribute("height")) {
|
|
|
|
return node.outerHTML;
|
|
|
|
}
|
|
|
|
|
|
|
|
// TODO: Deduplicate with upstream.
|
2025-03-11 17:12:48 +02:00
|
|
|
const untypedNode = (node as any);
|
|
|
|
const alt = escapeMarkdown(cleanAttribute(untypedNode.getAttribute('alt')))
|
|
|
|
const src = escapeLinkDestination(untypedNode.getAttribute('src') || '')
|
|
|
|
const title = cleanAttribute(untypedNode.getAttribute('title'))
|
|
|
|
const titlePart = title ? ' "' + escapeLinkTitle(title) + '"' : ''
|
|
|
|
|
|
|
|
return src ? '![' + alt + ']' + '(' + src + titlePart + ')' : ''
|
|
|
|
}
|
|
|
|
};
|
|
|
|
return imageFilter;
|
|
|
|
}
|
|
|
|
|
2025-03-15 11:25:42 +02:00
|
|
|
function buildAdmonitionFilter() {
|
|
|
|
function parseAdmonitionType(_node: Node) {
|
|
|
|
if (!("getAttribute" in _node)) {
|
2025-03-15 11:58:11 +02:00
|
|
|
return DEFAULT_ADMONITION_TYPE;
|
2025-03-15 11:25:42 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
const node = _node as Element;
|
|
|
|
const classList = node.getAttribute("class")?.split(" ") ?? [];
|
|
|
|
|
|
|
|
for (const className of classList) {
|
|
|
|
if (className === "admonition") {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
2025-03-15 11:58:11 +02:00
|
|
|
const mappedType = ADMONITION_TYPE_MAPPINGS[className];
|
2025-03-15 11:25:42 +02:00
|
|
|
if (mappedType) {
|
|
|
|
return mappedType;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-03-15 11:58:11 +02:00
|
|
|
return DEFAULT_ADMONITION_TYPE;
|
2025-03-15 11:25:42 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
const admonitionFilter: TurndownService.Rule = {
|
|
|
|
filter(node, options) {
|
|
|
|
return node.nodeName === "ASIDE" && node.classList.contains("admonition");
|
|
|
|
},
|
|
|
|
replacement(content, node) {
|
|
|
|
// Parse the admonition type.
|
|
|
|
const admonitionType = parseAdmonitionType(node);
|
|
|
|
|
|
|
|
content = content.replace(/^\n+|\n+$/g, '');
|
|
|
|
content = content.replace(/^/gm, '> ');
|
|
|
|
content = `> [!${admonitionType}]\n` + content;
|
|
|
|
|
|
|
|
return "\n\n" + content + "\n\n";
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return admonitionFilter;
|
|
|
|
}
|
|
|
|
|
2025-04-05 02:31:52 +03:00
|
|
|
/**
|
|
|
|
* Variation of the original ruleset: https://github.com/mixmark-io/turndown/blob/master/src/commonmark-rules.js.
|
|
|
|
*
|
|
|
|
* Detects if the URL is a Trilium reference link and returns it verbatim if that's the case.
|
|
|
|
*
|
|
|
|
* @returns
|
|
|
|
*/
|
|
|
|
function buildInlineLinkFilter(): Rule {
|
|
|
|
return {
|
|
|
|
filter: function (node, options) {
|
|
|
|
return (
|
2025-04-05 02:42:24 +03:00
|
|
|
options.linkStyle === 'inlined' &&
|
|
|
|
node.nodeName === 'A' &&
|
|
|
|
!!node.getAttribute('href')
|
2025-04-05 02:31:52 +03:00
|
|
|
)
|
|
|
|
},
|
|
|
|
|
|
|
|
replacement: function (content, _node) {
|
|
|
|
const node = _node as HTMLElement;
|
|
|
|
|
|
|
|
// Return reference links verbatim.
|
|
|
|
if (node.classList.contains("reference-link")) {
|
|
|
|
return node.outerHTML;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Otherwise treat as normal.
|
|
|
|
// TODO: Call super() somehow instead of duplicating the implementation.
|
2025-04-05 02:42:24 +03:00
|
|
|
let href = node.getAttribute('href')
|
2025-04-05 02:31:52 +03:00
|
|
|
if (href) href = href.replace(/([()])/g, '\\$1')
|
2025-04-05 02:42:24 +03:00
|
|
|
let title = cleanAttribute(node.getAttribute('title'))
|
2025-04-05 02:31:52 +03:00
|
|
|
if (title) title = ' "' + title.replace(/"/g, '\\"') + '"'
|
|
|
|
return '[' + content + '](' + href + title + ')'
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-04-05 03:23:31 +03:00
|
|
|
function buildFigureFilter(): Rule {
|
|
|
|
return {
|
|
|
|
filter(node, options) {
|
|
|
|
return node.nodeName === 'FIGURE'
|
|
|
|
},
|
|
|
|
replacement(content, node) {
|
|
|
|
return (node as HTMLElement).outerHTML;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-04-05 09:28:18 +03:00
|
|
|
function buildMathFilter(): Rule {
|
|
|
|
return {
|
|
|
|
filter(node) {
|
|
|
|
return node.nodeName === "SPAN" && node.classList.contains("math-tex");
|
|
|
|
},
|
|
|
|
replacement(content) {
|
|
|
|
// Inline math
|
|
|
|
if (content.startsWith("(") && content.endsWith(")")) {
|
|
|
|
return `$${content.substring(1, content.length - 1)}$`;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Unknown.
|
|
|
|
return content;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-04-05 02:31:52 +03:00
|
|
|
// Taken from upstream since it's not exposed.
|
|
|
|
// https://github.com/mixmark-io/turndown/blob/master/src/commonmark-rules.js
|
|
|
|
function cleanAttribute(attribute: string | null | undefined) {
|
|
|
|
return attribute ? attribute.replace(/(\n+\s*)+/g, '\n') : ''
|
|
|
|
}
|
|
|
|
|
2024-07-18 21:42:44 +03:00
|
|
|
export default {
|
2019-09-30 20:56:32 +02:00
|
|
|
toMarkdown
|
2021-02-20 20:10:45 +01:00
|
|
|
};
|