import InMemoryCache from './InMemoryCache'; import defaultNoteStyle from './defaultNoteStyle'; import noteStyle from './noteStyle'; import { fileExtension } from '@joplin/utils/path'; import setupLinkify from './MdToHtml/setupLinkify'; import validateLinks from './MdToHtml/validateLinks'; import { Options as NoteStyleOptions } from './noteStyle'; import { FsDriver, ItemIdToUrlHandler, MarkupRenderer, OptionsResourceModel, RenderOptions, RenderResult, RenderResultPluginAsset, RendererTheme, ResourceEntity, ResourceInfos } from './types'; import hljs from './highlight'; // Use a require() to support bundling on mobile: import MarkdownIt = require('markdown-it'); import type Token = require('markdown-it/lib/token'); import { AllHtmlEntities as Entities } from 'html-entities'; const htmlentities = new Entities().encode; const md5 = require('md5'); interface RendererRule { install?(context: PluginContext, ruleOptions: RuleOptions): unknown; assets?(theme: RendererTheme): PluginAsset[]; plugin?: MarkdownIt.PluginWithOptions; assetPath?: string; assetPathIsAbsolute?: boolean; pluginId?: string; } interface RendererRules { [pluginName: string]: RendererRule; } interface RendererPlugin { module: MarkdownIt.PluginWithOptions; options?: Record; } interface RendererPlugins { [pluginName: string]: RendererPlugin; } // /!\/!\ Note: the order of rules is important!! /!\/!\ const rules: RendererRules = { frontmatter: require('./MdToHtml/rules/frontmatter').default, 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, abc: require('./MdToHtml/rules/abc').default, mermaid: require('./MdToHtml/rules/mermaid').default, externalEmbed: require('./MdToHtml/rules/externalEmbed').default, source_map: require('./MdToHtml/rules/source_map').default, tableHorizontallyScrollable: require('./MdToHtml/rules/tableHorizontallyScrollable').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, uniqueSlugStartIndex: 2 } }, expand_tabs: { module: require('markdown-it-expand-tabs'), options: { tabWidth: 4 } }, }; function slugify(s: string): string { return uslug(s); } const removeEmptyHeadingId = (token: Token, { title }: { title: string }) => { if (title.trim()) return; token.attrs = token.attrs.filter(([name]) => name !== 'id'); }; // Share across all instances of MdToHtml const inMemoryCache = new InMemoryCache(20); export interface ExtraRendererRule { id: string; module: Omit; assetPath: string; pluginId: string; } export interface Options { resourceBaseUrl?: string; ResourceModel?: OptionsResourceModel; pluginOptions?: Record>; tempDir?: string; fsDriver?: FsDriver; extraRendererRules?: ExtraRendererRule[]; customCss?: string; } interface PluginAsset { source?: string; 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 subtly different // types. The logic should be cleaned up before types are added. interface PluginAssets { [pluginName: string]: PluginAsset[]; } export interface Link { href: string; resource: ResourceEntity | null; resourceReady: boolean; resourceFullPath: string; } interface PluginContext { css: Record; pluginAssets: Record; cache: InMemoryCache; userData: Record>; currentLinks: Link[]; // This must be set by the plugin to indicate whether the document contains markup that was // processed by the plugin or not. Currently this information is then used to remove unnecessary // plugin assets from the rendered document. This is particularly useful when exporting as HTML // since it can reduce the size from several MB to a few KB. pluginWasUsed: { mermaid: boolean; katex: boolean; abc: boolean; }; } export enum LinkRenderingType { // linkRenderingType = 1 is the regular rendering and clicking on it is handled via embedded JS (in onclick attribute) JavaScriptHandler = 1, // linkRenderingType = 2 gives a plain link with no JS. Caller needs to handle clicking on the link. HrefHandler = 2, } export interface RuleOptions { context: PluginContext; theme: RendererTheme; postMessageSyntax: string; ResourceModel: OptionsResourceModel; resourceBaseUrl: string; resources: ResourceInfos; // 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?: unknown[]; // 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?: LinkRenderingType; // Render native players below resource links while preserving the caller's // chosen link interaction model. renderMedia?: boolean; // Autoplay rendered videos silently. This is used by rich-text surfaces to // make a video attachment immediately recognizable without playing sound. autoplayVideo?: boolean; // 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[]; // Should 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; // Passed to the HTML sanitizer: Allows file:// URLs with // paths with the included prefixes. allowedFilePrefixes?: string[]; platformName?: string; showNoteLinkIcon?: boolean; globalSettings?: RenderOptions['globalSettings']; // Set by MdToHtml.render() when loading each plugin; takes the plugin's own // settings key and returns the stored value. settingValue?: (key: string)=> unknown; mapsToLine?: boolean; } export default class MdToHtml implements MarkupRenderer { private resourceBaseUrl_: string; private ResourceModel_: OptionsResourceModel; private contextCache_: InMemoryCache; private fsDriver_: FsDriver; private cachedOutputs_: Record = {}; private lastCodeHighlightCacheKey_: string = null; private cachedHighlightedCode_: Record = {}; // Markdown-It plugin options (not Joplin plugin options) private pluginOptions_: NonNullable = {}; private extraRendererRules_: RendererRules = {}; private allProcessedAssets_: Record = {}; 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: ExtraRendererRule['module'], 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