import InMemoryCache from './InMemoryCache'; import noteStyle from './noteStyle'; import { fileExtension } from './pathUtils'; import setupLinkify from './MdToHtml/setupLinkify'; import validateLinks from './MdToHtml/validateLinks'; import { ItemIdToUrlHandler } from './utils'; import { RenderResult, RenderResultPluginAsset } from './MarkupToHtml'; import { Options as NoteStyleOptions } from './noteStyle'; import hljs from './highlight'; import * as MarkdownIt from 'markdown-it'; const Entities = require('html-entities').AllHtmlEntities; const htmlentities = new Entities().encode; const md5 = require('md5'); export interface RenderOptions { contentMaxWidth?: number; bodyOnly?: boolean; splitted?: boolean; externalAssetsOnly?: boolean; postMessageSyntax?: string; highlightedKeywords?: string[]; codeTheme?: string; theme?: any; plugins?: Record; audioPlayerEnabled?: boolean; videoPlayerEnabled?: boolean; pdfViewerEnabled?: boolean; codeHighlightCacheKey?: string; plainResourceRendering?: boolean; mapsToLine?: boolean; useCustomPdfViewer?: boolean; noteId?: string; vendorDir?: string; settingValue?: (pluginId: string, key: string)=> any; } interface RendererRule { install(context: any, ruleOptions: any): any; assets?(theme: any): any; plugin?: any; assetPath?: string; assetPathIsAbsolute?: boolean; pluginId?: string; } 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, link_close: require('./MdToHtml/rules/link_close').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, source_map: require('./MdToHtml/rules/source_map').default, }; const uslug = require('@joplin/fork-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; assetPath: string; pluginId: string; } export interface Options { resourceBaseUrl?: string; ResourceModel?: any; pluginOptions?: any; tempDir?: string; fsDriver?: any; extraRendererRules?: ExtraRendererRule[]; customCss?: string; } 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[]; } export interface Link { href: string; resource: any; resourceReady: boolean; resourceFullPath: string; } interface PluginContext { css: any; pluginAssets: any; cache: any; userData: any; currentLinks: Link[]; } 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; checkboxDisabled?: boolean; // 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; // 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; // A list of MIME types for which an edit button appears on tap/hover. // Used by the image editor in the mobile app. editPopupFiletypes?: string[]; // Shoould be the string representation a function that accepts two arguments: // the target element to have the popup shown for and the id of the resource to edit. createEditPopupSyntax?: string; destroyEditPopupSyntax?: string; audioPlayerEnabled: boolean; videoPlayerEnabled: boolean; pdfViewerEnabled: boolean; useCustomPdfViewer: boolean; noteId?: string; vendorDir?: string; itemIdToUrl?: ItemIdToUrlHandler; platformName?: string; } 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 = {}; private customCss_ = ''; 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.assetPath, rule.module, rule.pluginId); } } this.customCss_ = options.customCss || ''; } 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) { // Currently link_close is only used to append the media player to // the resource links so we use the mediaPlayers plugin options for // it. if (name === 'link_close') name = 'mediaPlayers'; let o = this.pluginOptions_[name] ? this.pluginOptions_[name] : {}; o = { 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, assetPath: string, module: any, pluginId: string) { if (this.extraRendererRules_[id]) throw new Error(`A renderer rule with this ID has already been loaded: ${id}`); this.extraRendererRules_[id] = { ...module, assetPath, pluginId: pluginId, assetPathIsAbsolute: true, }; } private ruleByKey(key: string): RendererRule { if (rules[key]) return rules[key]; if (this.extraRendererRules_[key]) return this.extraRendererRules_[key]; if (key === 'highlight.js') return null; throw new Error(`No such rule: ${key}`); } private processPluginAssets(pluginAssets: PluginAssets): RenderResult { const files: RenderResultPluginAsset[] = []; const cssStrings = []; for (const pluginName in pluginAssets) { const rule = this.ruleByKey(pluginName); 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