import hljs from 'highlight.js';
import MarkdownIt from 'markdown-it';
import { filterXSS, getDefaultCSSWhiteList, getDefaultWhiteList, IWhiteList } from 'xss';
import { mermaidRender, refreshMermaid } from '../plugins/mermaid';
import tocAndAnchor from '../plugins/toc';
import type { MdPlugin, ICustomXssRule, ICustomRenderRule } from '../editor-md-types';
export class MDRenderService {
private xssWhiteList = getDefaultWhiteList();
private cssWhiteList = getDefaultCSSWhiteList();
private mdt = new MarkdownIt({
linkify: true,
html: true,
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang }).value;
} catch (_) {}
}
return '';
},
}) as any;
private baseUrl = '';
private breaks = true;
private renderParse: Function | undefined;
constructor() {
this.setDefaultXss();
this.mdt
.use(tocAndAnchor, {
linkify: false,
anchorLink: false,
})
.use(mermaidRender, { id: 'devui-mermaid' });
}
private setDefaultXss() {
this.xssWhiteList['input'] = ['type', 'checked', 'disabled', 'class'];
this.xssWhiteList['label'] = ['for'];
this.xssWhiteList['ul'] = ['class'];
this.xssWhiteList['div'] = ['class'];
this.xssWhiteList['a'] = ['href', 'class', 'target', 'name'];
this.xssWhiteList['ol'] = ['start'];
this.xssWhiteList['p'] = ['class'];
this.xssWhiteList['span'] = ['style', 'class', 'title', 'id'];
this.xssWhiteList['svg'] = ['style', 'class', 'width', 'height', 'viewbox', 'preserveaspectratio', 'id', 'fill', 'stroke'];
this.xssWhiteList['path'] = ['style', 'class', 'd', 'id', 'fill', 'stroke'];
this.xssWhiteList['th'] = ['style'];
this.xssWhiteList['td'] = ['style'];
}
setBaseUrl(url: string) {
this.baseUrl = url;
}
setBreaks(breaks: boolean) {
this.breaks = breaks;
}
setRenderParse(func: Function) {
this.renderParse = func;
}
getXssWhiteList() {
return this.xssWhiteList;
}
setXssWhiteList(list: IWhiteList) {
this.xssWhiteList = list;
}
setCustomXssRules(rules: ICustomXssRule[]) {
if (rules) {
rules.forEach((rule) => {
if (rule['value'] === null) {
delete this.xssWhiteList[rule['key']];
} else {
this.xssWhiteList[rule['key']] = rule['value'];
}
});
}
}
setCustomRendererRules(rules: ICustomRenderRule[]) {
if (rules) {
rules.forEach((rule) => {
this.mdt.renderer.rules[rule['key']] = rule['value'];
});
}
}
setOptions(options = {}) {
this.mdt.set(options);
}
setPlugins(plugins: Array<MdPlugin>) {
plugins.forEach((item) => {
const { plugin, opts } = item;
this.mdt.use(plugin, opts);
});
}
private onIgnoreTagAttr(tag: string, name: string, value: string, isWhiteAttr: boolean) {
if (!isWhiteAttr && (name === 'id' || (tag === 'span' && name === 'style')
|| (tag === 'a' && name === 'href'))) {
return name + '=' + value;
}
}
private onTagAttr(tag: string, name: string, value: string, isWhiteAttr: boolean) {
if (isWhiteAttr && (tag === 'a' && name === 'href')) {
return name + '=' + value;
}
}
private replaceInternalUrl(html: string) {
return html.replace(/(<a[^>]+?href=["']?)#([^"']+)(["']?[^>]*)>/gi, `$1${this.baseUrl}#$2$3>`);
}
private handleHeaderId(html: string) {
const headerRecord = new Map<string, number>();
const reg = /<(h[1-6]) id="(.*?)">/gi;
return html.replace(reg, (match, p1, p2, offset, string): string => {
if (headerRecord.has(p2)) {
headerRecord.set(p2, headerRecord.get(p2)! + 1);
return `<${p1} id="${p2}-${headerRecord.get(p2)}">`;
} else {
headerRecord.set(p2, 0);
return `<${p1} id="${p2}">`;
}
});
}
generateHTML(text: string) {
this.mdt.set({
breaks: this.breaks,
});
let html = this.mdt.render(text);
html = this.handleHeaderId(html);
if (this.renderParse) {
html = this.renderParse(html);
}
html = filterXSS(html, {
whiteList: this.xssWhiteList,
onIgnoreTagAttr: this.onIgnoreTagAttr,
onTagAttr: this.onTagAttr,
css: {
whiteList: Object.assign({}, this.cssWhiteList, {
top: true,
left: true,
bottom: true,
right: true,
}),
},
});
setTimeout(() => {
refreshMermaid();
}, 0);
return this.replaceInternalUrl(html);
}
public setRules(mdRules: Record<string, any>): void {
if (mdRules) {
Object.keys(mdRules).forEach((rule) => {
this.mdt[rule].set(mdRules[rule]);
});
}
}
}