diff --git a/packages/markdown-preview/lib/renderer.js b/packages/markdown-preview/lib/renderer.js index 636ad68c54..4566df1213 100644 --- a/packages/markdown-preview/lib/renderer.js +++ b/packages/markdown-preview/lib/renderer.js @@ -30,25 +30,21 @@ exports.toDOMFragment = async function (text, filePath, grammar, callback) { } else { // We use the new parser! - const domFragment = atom.ui.markdown.render(text, + const domFragment = await atom.ui.markdown.render( + text, { renderMode: "fragment", filePath: filePath, - breaks: atom.config.get('markdown-preview.breakOnSingleNewline'), - useDefaultEmoji: true, - sanitizeAllowUnknownProtocols: atom.config.get('markdown-preview.allowUnsafeProtocols') + breaks: atom.config.get("markdown-preview.breakOnSingleNewline"), + emoji: true, + sanitize: true, + sanitizeAllowUnknownProtocols: atom.config.get("markdown-preview.allowUnsafeProtocols"), + highlight: scopeForFenceName, + defaultGrammar: grammar } - ); - const domHTMLFragment = atom.ui.markdown.convertToDOM(domFragment); - await atom.ui.markdown.applySyntaxHighlighting(domHTMLFragment, - { - renderMode: "fragment", - syntaxScopeNameFunc: scopeForFenceName, - grammar: grammar - } - ); + )(); - return domHTMLFragment; + return domFragment; } } @@ -71,29 +67,24 @@ exports.toHTML = async function (text, filePath, grammar) { return result } else { // We use the new parser! - const domFragment = atom.ui.markdown.render(text, + const domFragment = await atom.ui.markdown.render( + text, { renderMode: "full", filePath: filePath, - breaks: atom.config.get('markdown-preview.breakOnSingleNewline'), - useDefaultEmoji: true, - sanitizeAllowUnknownProtocols: atom.config.get('markdown-preview.allowUnsafeProtocols') + breaks: atom.config.get("markdown-preview.breakOnSingleNewline"), + emoji: true, + sanitize: true, + sanitizeAllowUnknownProtocols: atom.config.get("markdown-preview.allowUnsafeProtocols"), + highlight: scopeForFenceName, + grammar: grammar } - ); - const domHTMLFragment = atom.ui.markdown.convertToDOM(domFragment); + )(); const div = document.createElement("div"); - div.appendChild(domHTMLFragment); + div.appendChild(domFragment); document.body.appendChild(div); - await atom.ui.markdown.applySyntaxHighlighting(div, - { - renderMode: "full", - syntaxScopeNameFunc: scopeForFenceName, - grammar: grammar - } - ); - const result = div.innerHTML; div.remove(); diff --git a/packages/markdown-preview/package.json b/packages/markdown-preview/package.json index 67134120c8..724503bba3 100644 --- a/packages/markdown-preview/package.json +++ b/packages/markdown-preview/package.json @@ -91,7 +91,7 @@ "default": "" }, "useOriginalParser": { - "description": "Wether to use the original Markdown Parser, or the new Pulsar one.", + "description": "Whether to use the original Markdown Parser, or the new Pulsar internal one.", "type": "boolean", "default": "true" } diff --git a/packages/settings-view/lib/package-readme-view.js b/packages/settings-view/lib/package-readme-view.js index 1c47bec8ca..84653fb1cb 100644 --- a/packages/settings-view/lib/package-readme-view.js +++ b/packages/settings-view/lib/package-readme-view.js @@ -25,8 +25,10 @@ export default class PackageReadmeView { const markdownOpts = { breaks: false, - taskCheckboxDisabled: true, - useGitHubHeadings: true + taskCheckbox: true, + githubHeadings: true, + transformImageLinks: true, + transformNonFqdnLinks: true }; if (readmeIsLocal) { diff --git a/packages/settings-view/lib/rich-description.js b/packages/settings-view/lib/rich-description.js index a71f43cb63..b8983c72bc 100644 --- a/packages/settings-view/lib/rich-description.js +++ b/packages/settings-view/lib/rich-description.js @@ -9,7 +9,7 @@ module.exports = { return atom.ui.markdown.render( description, { - useTaskCheckbox: false, + taskCheckbox: false, disableMode: "strict", } ).replace(/
(.*)<\/p>/, "$1").trim(); diff --git a/spec/ui-spec.js b/spec/ui-spec.js index 8ad5e0e667..5b4c230b14 100644 --- a/spec/ui-spec.js +++ b/spec/ui-spec.js @@ -8,28 +8,41 @@ describe("Renders Markdown", () => { }); describe("transforms links correctly", () => { + const mdOpts = { + transformImageLinks: true, + transformNonFqdnLinks: true + }; + it("makes no changes to a fqdn link", () => { - expect(atom.ui.markdown.render("[Hello World](https://github.com)")) + expect(atom.ui.markdown.render("[Hello World](https://github.com)", mdOpts)) .toBe('
\n'); }); it("resolves package links to pulsar", () => { - expect(atom.ui.markdown.render("[Hello](https://atom.io/packages/hey-pane)")) + expect(atom.ui.markdown.render("[Hello](https://atom.io/packages/hey-pane)", mdOpts)) .toBe('\n'); }); it("resolves atom links to web archive", () => { - expect(atom.ui.markdown.render("[Hello](https://flight-manual.atom.io/some-docs)")) + expect(atom.ui.markdown.render("[Hello](https://flight-manual.atom.io/some-docs)", mdOpts)) .toBe('\n'); }); it("resolves incomplete local links", () => { expect(atom.ui.markdown.render( "[Hello](./readme.md)", - { rootDomain: "https://github.com/pulsar-edit/pulsar" } + { + rootDomain: "https://github.com/pulsar-edit/pulsar", + transformImageLinks: true, + transformNonFqdnLinks: true + } )).toBe('\n'); }); it("resolves incomplete root links", () => { expect(atom.ui.markdown.render( "[Hello](/readme.md)", - { rootDomain: "https://github.com/pulsar-edit/pulsar" } + { + rootDomain: "https://github.com/pulsar-edit/pulsar", + transformImageLinks: true, + transformNonFqdnLinks: true + } )).toBe('\n'); }); }); diff --git a/src/ui.js b/src/ui.js index 2a4662c443..a43370e0fb 100644 --- a/src/ui.js +++ b/src/ui.js @@ -30,108 +30,309 @@ const mdComponents = { }; /** - * @function renderMarkdown + * @function render * @memberof markdown - * @alias render - * @desc Takes a Markdown document and renders it as HTML. - * @param {string} content - The Markdown source material. - * @param {object} givenOpts - The optional arguments: - * @param {string} givenOpts.renderMode - Determines how the page is rendered. - * Valid values "full" or "fragment". + * @desc Processes the actual rendering of markdown content. + * @param {string} content - The string of Markdown. + * @param {object} givenOpts - The optional arguments + * @param {boolean} givenOpts.frontMatter - Whether frontmatter data should be + * processed and displayed. + * @param {boolean} givenOpts.sanitize - Whether sanitization should be applied. + * @param {boolean} givenOpts.sanitizeAllowUnknownProtocols - Whether DOMPurify's + * `ALLOW_UNKNOWN_PROTOCOLS` should be enabled. + * @param {boolean} givenOpts.sanitizeAllowSelfClose - Whether DOMPurify's + * `ALLOW_SELF_CLOSE_IN_ATTR` should be enabled. + * @param {string} givenOpts.renderMode - Determines how the page is returned. + * `full` or `fragment` applies only when Syntax Highlighting. + * @param {string|object} givenOpts.defaultGrammar - An instance of a Pulsar Grammar + * or string, which will be used as the default grammar to apply to code blocks. + * @param {boolean|function} givenOpts.highlight - Determines if Syntax Highlighting + * is applied. Can be a boolean, with true applying syntax highlighting. Or it can + * be a function, which will be used to resolve fenced code block scope names to + * a Pulsar language grammar. + * @param {object} mdInstance - An optional instance of MarkdownIT. Retreived from + * `atom.ui.markdown.buildRenderer()`. + */ +function render(content, givenOpts = {}, mdInstance) { + // Define our default opts to create a full options object + const defaultOpts = { + frontMatter: true, // Determines if Front Matter content should be parsed + sanitize: true, // Enable or disable sanitization of Markdown output + sanitizeAllowUnknownProtocols: true, // pass the value of `ALLOW_UNKNOWN_PROTOCOLS` to DomPurify + sanitizeAllowSelfClose: true, // pass the value of `ALLOW_SELF_CLOSE_IN_ATTR` to DomPurify + highlight: false, // This enables syntax highlighting. Can be true or a function + // to resolve scope names + defaultGrammar: null, // Allows passing a Pulsar Grammar to default to that + // language if applicable, or otherwise allows passing a new default language, + // if excluded, default becomes 'text'. This is an unresolved scope fence + renderMode: "full", // Determines what type of content is returned during + // syntax highlighting, can be `full` or `fragment`. `fragment` is recommended + // for most applications. + }; + + let opts = { ...defaultOpts, ...givenOpts }; + + // Some options have changed since the initial implmentation of the `atom.ui.markdown` + // feature. We will pass along the values of no longer used config options, to + // ensure backwards compatibility. + opts.frontMatter = givenOpts.handleFrontMatter ?? givenOpts.frontMatter ?? defaultOpts.frontMatter; + opts.highlight = givenOpts.syntaxScopeNameFunc ?? givenOpts.highlight ?? defaultOpts.highlight; + opts.defaultGrammar = givenOpts.grammar ?? givenOpts.defaultGrammar ?? defaultOpts.defaultGrammar; + // End of backwards compaitbility options + // Maybe we should emit a warning or deprecation when one is used? + + let md; + + if (mdInstance) { + // We have been provided a markdown instance from `buildRenderer()` so we + // can use that + md = mdInstance; + } else { + // No instance was provided, lets make our own + // We will pass all values that we were given onto the `buildRenderer` func + md = buildRenderer(givenOpts); + } + + let textContent; + + if (opts.frontMatter) { + mdComponents.deps.yamlFrontMatter ??= require("yaml-front-matter"); + const { __content, vars } = mdComponents.deps.yamlFrontMatter.loadFront(content); + + const renderYamlTable = (variables) => { + if (typeof variables === "undefined") { + return ""; + } + + const entries = Object.entries(variables); + + if (!entries.length) { + return ""; + } + + const markdownRows = [ + entries.map(entry => entry[0]), + entries.map(entry => '--'), + entries.map((entry) => { + if (typeof entry[1] === "object" && !Array.isArray(entry[1])) { + // Remove all newlines, or they ruin formatting of parent table + return md.render(renderYamlTable(entry[1])).replace(/\n/g, ""); + } else { + return entry[1]; + } + }) + ]; + + return ( + markdownRows.map(row => "| " + row.join(" | ") + " |").join("\n") + "\n" + ); + }; + + textContent = renderYamlTable(vars) + __content; + } else { + textContent = content; + } + + // Now time to render the content + let rendered = md.render(textContent); + + if (opts.sanitize) { + mdComponents.deps.domPurify ??= require("dompurify"); + + let domPurifyOpts = { + ALLOW_UNKNOWN_PROTOCOLS: opts.sanitizeAllowUnknownProtocols, + ALLOW_SELF_CLOSE_IN_ATTR: opts.sanitizeAllowSelfClose + }; + + rendered = mdComponents.deps.domPurify.sanitize(rendered, domPurifyOpts); + } + + // We now could return this text as ready to go, but lets check if we can + // apply any syntax highlighting + if (opts.highlight) { + // Checking above for truthy should match for if it's a function or true boolean + const convertToDOM = (data) => { + const template = document.createElement("template"); + template.innerHTML = data; + const fragment = template.content.cloneNode(true); + return fragment; + }; + + const domHTMLFragment = convertToDOM(rendered); + + // Now it's time to apply the actual syntax highlighting to our html fragment + const scopeForFenceName = (fence) => { + if (typeof opts.highlight === "function") { + return opts.highlight(fence); + } else { + // TODO mimick the system we built into `markdown-preview` for this + // We could build one in, or just return default + return "text.plain"; + } + }; + + let defaultLanguage; + const fontFamily = atom.config.get("editor.fontFamily"); + + if (opts.defaultGrammar?.scopeName === "source.litcoffee") { + // This is so that we can support defaulting to coffeescript if writing in + // 'source.litcoffee' and rendering our markdown + defaultLanguage = "coffee"; + } else if (typeof opts.defaultGrammar === "string") { + defaultLanguage = opts.defaultGrammar; + } else { + defaultLanguage = "text"; + } + + if (fontFamily) { + for (const codeElement of domHTMLFragment.querySelectorAll("code")) { + codeElement.style.fontFamily = fontFamily; + } + } + + let editorCallback; + + if (opts.renderMode === "fragment") { + editorCallback = makeAtomEditorNonInteractive; + } else { + editorCallback = convertAtomEditorToStandardElement; + } + + const promises = []; + for (const preElement of domHTMLFragment.querySelectorAll("pre")) { + const codeBlock = preElement.firstElementChild ?? preElement; + const className = codeBlock.getAttribute("class"); + const fenceName = + className != null ? className.replace(/^language-/, "") : defaultLanguage; + + const editor = new TextEditor({ + readonly: true, + keyboardInputEnabled: false + }); + const editorElement = editor.getElement(); + + preElement.classList.add("editor-colors", `lang-${fenceName}`); + editorElement.setUpdatedSynchronously(true); + preElement.innerHTML = ""; + preElement.parentNode.insertBefore(editorElement, preElement); + editor.setText(codeBlock.textContent.replace(/\r?\n$/, "")); + atom.grammars.assignLanguageMode(editor, scopeForFenceName(fenceName)); + editor.setVisible(true); + + promises.push(editorCallback(editorElement, preElement)); + } + + // Since we don't want to force this function to always be async, as it's only + // needed to be async for this syntax highlighting call, we will instead return + // an async function that can awaited on + return async () => { + await Promise.all(promises); + return domHTMLFragment; + }; + } else { + // We aren't preforming any syntax highlighting, so lets return our rendered + // text. + return rendered; + } +} + +/** + * @function buildRenderer + * @memberof markdown + * @desc Returns a Markdown Renderer instance with the provided options. + * Helpful to avoid having to build a new one over and over. + * @param {object} givenOpts - The optional arguments * @param {boolean} givenOpts.html - Whether HTML tags should be allowed. - * @param {boolean} givenOpts.sanitize - If the page content should be saniized via DOMPurify. - * @param {boolean} givenOpts.sanitizeAllowUnknownProtocols - Controls DOMPurify's - * own option of 'ALLOW_UNKNOWN_PROTOCOLS'. - * @param {boolean} givenOpts.sanitizeAllowSelfClose - Controls DOMPurify's - * own option of 'ALLOW_SELF_CLOSE' * @param {boolean} givenOpts.breaks - If newlines should always be converted * into breaklines. - * @param {boolean} givenOpts.handleFrontMatter - Whether frontmatter data should - * processed and displayed. - * @param {boolean} givenOpts.useDefaultEmoji - Whether `markdown-it-emoji` should be enabled. - * @param {boolean} givenOpts.useGitHubHeadings - Whether `markdown-it-github-headings` - * should be enabled. False by default. - * @param {boolean} givenOpts.useTaskCheckbox - Whether `markdown-it-task-checkbox` - * should be enabled. True by default. + * @param {boolean} givenOpts.emoji - If emojis should be included. + * @param {boolean} givenOpts.githubHeadings - Whether `markdown-it-github-headings` + * should be enabled. + * @param {boolean} givenOpts.taskCheckbox - Whether `markdown-it-task-checkbox` + * should be enabled. * @param {boolean} givenOpts.taskCheckboxDisabled - Controls `markdown-it-task-checkbox` - * `disabled` option. True by default. - * @param {boolean} givenOpts.taskCheckboxDivWrap - Controls `markdown-it-task-checkboc` - * `divWrap` option. False by default. - * @param {boolean} givenOpts.transformImageLinks - Attempt to resolve image URLs. - * True by default. - * @param {boolean} givenOpts.transformAtomLinks - Attempt to resolve links - * pointing to Atom. True by Default. - * @param {boolean} givenOpts.transformNonFqdnLinks - Attempt to resolve links - * that are not fully qualified domain names. True by Default. + * `disabled` option. + * @param {boolean} givenOpts.taskCheckboxDivWrap - Controls `markdown-it-task-checkbox` + * `divWrap` option. + * @param {boolean} givenOpts.transformImageLinks - If it should attempt to + * resolve links to images. + * @param {boolean} givenOpts.transformNonFqdnLinks - If non fully qualified + * domain name links should be resolved. + * @param {boolean} givenOpts.transformAtomLinks - If links to Atom pages should + * resolved to the Pulsar equivalent. * @param {string} givenOpts.rootDomain - The root URL of the online resource. - * Useful when attempting to resolve any links on the page. Only works for online - * resources. - * @param {string} givenOpts.filePath - The local alternative to `rootDomain`. - * Used to resolve incomplete paths, but locally on the file system. - * @param {string} givenOpts.disabledMode - The level of disabling of markdown features. - * `none` by default. But supports: "none", "strict" - * @returns {string} Parsed HTML content. + * Used when resolving links. + * @param {string} givenOpts.filePath - The path to the local resource. + * Used when resolving links. + * @param {string} givenOpts.disableMode - The level of disabling to apply. + * @return {object} An instance of a MarkdownIT. */ -function renderMarkdown(content, givenOpts = {}) { - // First we will setup our markdown renderer instance according to the opts provided +function buildRenderer(givenOpts = {}) { + // Define our default opts to create a full options object const defaultOpts = { - renderMode: "full", // Determines if we are rendering a fragment or full page. - // Valid values: 'full', 'fragment' html: true, // Enable HTML tags in source - sanitize: true, // Enable or disable sanitization - sanitizeAllowUnknownProtocols: true, - sanitizeAllowSelfClose: true, - breaks: false, // Convert `\n` in paragraphs into `${str}
`;
};
@@ -256,7 +459,6 @@ function renderMarkdown(content, givenOpts = {}) {
// Process disables
if (opts.disableMode === "strict") {
- // Easy Disable
md.disable("lheading");
// Disable Code Blocks
@@ -286,7 +488,7 @@ function renderMarkdown(content, givenOpts = {}) {
// Determine how to best handle this to only allow line breaks. Research needed
if (state.src.charAt(state.pos) === "<") {
// We only want to act once on the beginning of the inline element
- // Then confirm if it's the item we expect
+ // then confirm if it's the item we expect
const textAfterPending = state.src.replace(state.pending, "");
const match = textAfterPending.match(/^