import InMemoryCache from 'lib/InMemoryCache'; import noteStyle from './noteStyle'; import { fileExtension } from './pathUtils'; const MarkdownIt = require('markdown-it'); const md5 = require('md5'); interface RendererRule { install(context:any, ruleOptions:any):any, assets?(theme:any):any, plugin?: any, } interface RendererRules { [pluginName:string]: RendererRule, } interface RendererPlugin { module: any, options?: any, } interface RendererPlugins { [pluginName:string]: RendererPlugin, } // /!\/!\ Note: the order of rules is important!! /!\/!\ const rules:RendererRules = { fence: require('./MdToHtml/rules/fence').default, sanitize_html: require('./MdToHtml/rules/sanitize_html').default, image: require('./MdToHtml/rules/image').default, checkbox: require('./MdToHtml/rules/checkbox').default, katex: require('./MdToHtml/rules/katex').default, link_open: require('./MdToHtml/rules/link_open').default, html_image: require('./MdToHtml/rules/html_image').default, highlight_keywords: require('./MdToHtml/rules/highlight_keywords').default, code_inline: require('./MdToHtml/rules/code_inline').default, fountain: require('./MdToHtml/rules/fountain').default, mermaid: require('./MdToHtml/rules/mermaid').default, }; const setupLinkify = require('./MdToHtml/setupLinkify'); const hljs = require('highlight.js'); const uslug = require('uslug'); const markdownItAnchor = require('markdown-it-anchor'); // The keys must match the corresponding entry in Setting.js const plugins:RendererPlugins = { mark: { module: require('markdown-it-mark') }, footnote: { module: require('markdown-it-footnote') }, sub: { module: require('markdown-it-sub') }, sup: { module: require('markdown-it-sup') }, deflist: { module: require('markdown-it-deflist') }, abbr: { module: require('markdown-it-abbr') }, emoji: { module: require('markdown-it-emoji') }, insert: { module: require('markdown-it-ins') }, multitable: { module: require('markdown-it-multimd-table'), options: { multiline: true, rowspan: true, headerless: true } }, toc: { module: require('markdown-it-toc-done-right'), options: { listType: 'ul', slugify: slugify } }, expand_tabs: { module: require('markdown-it-expand-tabs'), options: { tabWidth: 4 } }, }; const defaultNoteStyle = require('./defaultNoteStyle'); function slugify(s:string):string { return uslug(s); } // Share across all instances of MdToHtml const inMemoryCache = new InMemoryCache(20); export interface ExtraRendererRule { id: string, module: any, } export interface Options { resourceBaseUrl?: string, ResourceModel?: any, pluginOptions?: any, tempDir?: string, fsDriver?: any, extraRendererRules?: ExtraRendererRule[], } interface PluginAsset { mime?: string, inline?: boolean, name?: string, text?: string, } // Types are a bit of a mess when it comes to plugin assets. Something // called "pluginAsset" in this class might refer to sublty different // types. The logic should be cleaned up before types are added. interface PluginAssets { [pluginName:string]: PluginAsset[]; } interface PluginContext { css: any pluginAssets: any, cache: any, userData: any, } interface RenderResultPluginAsset { name: string, path: string, mime: string, } interface RenderResult { html: string, pluginAssets: RenderResultPluginAsset[]; cssStrings: string[], } export interface RuleOptions { context: PluginContext, theme: any, postMessageSyntax: string, ResourceModel: any, resourceBaseUrl: string, resources: any, // resourceId: Resource // Used by checkboxes to specify how it should be rendered checkboxRenderingType?: number, // Used by the keyword highlighting plugin (mobile only) highlightedKeywords?: any[], // Use by resource-rendering logic to signify that it should be rendered // as a plain HTML string without any attached JavaScript. Used for example // when exporting to HTML. plainResourceRendering?: boolean, // Use in mobile app to enable long-pressing an image or a linkg // to display a context menu. Used in `image.ts` and `link_open.ts` enableLongPress?: boolean, // Used in mobile app when enableLongPress = true. Tells for how long // the resource should be pressed before the menu is shown. longPressDelay?: number, // Use by `link_open` rule. // linkRenderingType = 1 is the regular rendering and clicking on it is handled via embedded JS (in onclick attribute) // linkRenderingType = 2 gives a plain link with no JS. Caller needs to handle clicking on the link. linkRenderingType?: number, } export default class MdToHtml { private resourceBaseUrl_:string; private ResourceModel_:any; private contextCache_:any; private fsDriver_:any; private cachedOutputs_:any = {}; private lastCodeHighlightCacheKey_:string = null; private cachedHighlightedCode_:any = {}; // Markdown-It plugin options (not Joplin plugin options) private pluginOptions_:any = {}; private extraRendererRules_:RendererRules = {}; private allProcessedAssets_:any = {}; public constructor(options:Options = null) { if (!options) options = {}; // Must include last "/" this.resourceBaseUrl_ = 'resourceBaseUrl' in options ? options.resourceBaseUrl : null; this.ResourceModel_ = options.ResourceModel; this.pluginOptions_ = options.pluginOptions ? options.pluginOptions : {}; this.contextCache_ = inMemoryCache; this.fsDriver_ = { writeFile: (/* path, content, encoding = 'base64'*/) => { throw new Error('writeFile not set'); }, exists: (/* path*/) => { throw new Error('exists not set'); }, cacheCssToFile: (/* cssStrings*/) => { throw new Error('cacheCssToFile not set'); }, }; if (options.fsDriver) { if (options.fsDriver.writeFile) this.fsDriver_.writeFile = options.fsDriver.writeFile; if (options.fsDriver.exists) this.fsDriver_.exists = options.fsDriver.exists; if (options.fsDriver.cacheCssToFile) this.fsDriver_.cacheCssToFile = options.fsDriver.cacheCssToFile; } if (options.extraRendererRules) { for (const rule of options.extraRendererRules) { this.loadExtraRendererRule(rule.id, rule.module); } } } private fsDriver() { return this.fsDriver_; } public static pluginNames() { const output = []; for (const n in rules) output.push(n); for (const n in plugins) output.push(n); return output; } private pluginOptions(name:string) { let o = this.pluginOptions_[name] ? this.pluginOptions_[name] : {}; o = Object.assign({ enabled: true, }, o); return o; } private pluginEnabled(name:string) { return this.pluginOptions(name).enabled; } // `module` is a file that has already been `required()` public loadExtraRendererRule(id:string, module:any) { if (this.extraRendererRules_[id]) throw new Error(`A renderer rule with this ID has already been loaded: ${id}`); this.extraRendererRules_[id] = module; } private processPluginAssets(pluginAssets:PluginAssets):RenderResult { const files:RenderResultPluginAsset[] = []; const cssStrings = []; for (const pluginName in pluginAssets) { for (const asset of pluginAssets[pluginName]) { let mime = asset.mime; if (!mime && asset.inline) throw new Error('Mime type is required for inline assets'); if (!mime) { const ext = fileExtension(asset.name).toLowerCase(); // For now it's only useful to support CSS and JS because that's what needs to be added // by the caller with