From 8e0d3bd1e20deb059236a4b6242b2239ea047a76 Mon Sep 17 00:00:00 2001 From: Rafael Oleza Date: Tue, 23 Apr 2019 15:49:09 +0200 Subject: [PATCH 1/9] Replace roaster by marked This allows us to use marked directly, but we need to implement two things that were provided by roaster: - Handle the yaml frontmatter tags. - Convert github emoji tags (e.g :rocket:) to the actual emojis. --- lib/renderer.js | 44 ++++++++++++++++++++++++-------------------- package.json | 2 +- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/lib/renderer.js b/lib/renderer.js index 2847a12..50c9478 100644 --- a/lib/renderer.js +++ b/lib/renderer.js @@ -2,7 +2,8 @@ const { TextEditor } = require('atom') const path = require('path') const createDOMPurify = require('dompurify') const fs = require('fs-plus') -let roaster = null // Defer until used +let marked = null // Defer until used + const { scopeForFenceName } = require('./extension-helper') const { resourcePath } = atom.getLoadSettings() const packagePath = path.dirname(__dirname) @@ -50,36 +51,39 @@ exports.toHTML = function (text, filePath, grammar, callback) { } var render = function (text, filePath, callback) { - if (roaster == null) { - roaster = require('roaster') + if (marked == null) { + marked = require('marked') } - const options = { + + marked.setOptions({ sanitize: false, breaks: atom.config.get('markdown-preview.breakOnSingleNewline') - } + }) // Remove the since otherwise marked will escape it // https://github.com/chjj/marked/issues/354 text = text.replace(/^\s*\s*/i, '') - return roaster(text, options, function (error, html) { - if (error != null) { - return callback(error) - } - - html = createDOMPurify().sanitize(html, { - ALLOW_UNKNOWN_PROTOCOLS: atom.config.get( - 'markdown-preview.allowUnsafeProtocols' - ) - }) + let html - const template = document.createElement('template') - template.innerHTML = html.trim() - const fragment = template.content.cloneNode(true) + try { + html = marked(text) + } catch (error) { + return callback(error) + } - resolveImagePaths(fragment, filePath) - return callback(null, fragment) + html = createDOMPurify().sanitize(html, { + ALLOW_UNKNOWN_PROTOCOLS: atom.config.get( + 'markdown-preview.allowUnsafeProtocols' + ) }) + + const template = document.createElement('template') + template.innerHTML = html.trim() + const fragment = template.content.cloneNode(true) + + resolveImagePaths(fragment, filePath) + callback(null, fragment) } var resolveImagePaths = function (element, filePath) { diff --git a/package.json b/package.json index 31b2a4e..42c9fd2 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "dependencies": { "dompurify": "^1.0.2", "fs-plus": "^3.0.0", - "roaster": "^1.2.1", + "marked": "^0.6.2", "underscore-plus": "^1.0.0" }, "devDependencies": { From 05ac5bbeb4f8e340843072aa7de9cbcead03eac8 Mon Sep 17 00:00:00 2001 From: Rafael Oleza Date: Tue, 23 Apr 2019 16:17:07 +0200 Subject: [PATCH 2/9] Fix sanitization test --- spec/markdown-preview-spec.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/markdown-preview-spec.js b/spec/markdown-preview-spec.js index 2434794..22bfea6 100644 --- a/spec/markdown-preview-spec.js +++ b/spec/markdown-preview-spec.js @@ -649,10 +649,10 @@ var x = y; runs(() => expect(preview.element.innerHTML).toBe(`\

hello

-

-

+ + -world

\ +world\ `) ) }) From acad6c340b89cac0f46c4d7bc61dba0ef2d28552 Mon Sep 17 00:00:00 2001 From: Rafael Oleza Date: Tue, 23 Apr 2019 16:24:26 +0200 Subject: [PATCH 3/9] Change spec since now all doctypes are removed --- lib/renderer.js | 4 ---- spec/markdown-preview-spec.js | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/lib/renderer.js b/lib/renderer.js index 50c9478..08760e5 100644 --- a/lib/renderer.js +++ b/lib/renderer.js @@ -60,10 +60,6 @@ var render = function (text, filePath, callback) { breaks: atom.config.get('markdown-preview.breakOnSingleNewline') }) - // Remove the since otherwise marked will escape it - // https://github.com/chjj/marked/issues/354 - text = text.replace(/^\s*\s*/i, '') - let html try { diff --git a/spec/markdown-preview-spec.js b/spec/markdown-preview-spec.js index 22bfea6..0cf4d5e 100644 --- a/spec/markdown-preview-spec.js +++ b/spec/markdown-preview-spec.js @@ -657,7 +657,7 @@ world\ ) }) - it('remove the first tag at the beginning of the file', function () { + it('remove any tag on markdown files', function () { waitsForPromise(() => atom.workspace.open('subdir/doctype-tag.md')) runs(() => atom.commands.dispatch( @@ -670,7 +670,7 @@ world\ runs(() => expect(preview.element.innerHTML).toBe(`\

content -<!doctype html>

\ +

\ `) ) }) From 943e3c3475df9ab7beb5798a2018cd1f48608283 Mon Sep 17 00:00:00 2001 From: Rafael Oleza Date: Tue, 23 Apr 2019 16:32:32 +0200 Subject: [PATCH 4/9] Fix syntax highlighting --- lib/renderer.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/renderer.js b/lib/renderer.js index 08760e5..235aab7 100644 --- a/lib/renderer.js +++ b/lib/renderer.js @@ -150,7 +150,8 @@ var highlightCodeBlocks = function (domFragment, grammar, editorCallback) { : preElement const className = codeBlock.getAttribute('class') const fenceName = - className != null ? className.replace(/^lang-/, '') : defaultLanguage + className != null ? className.replace(/^language-/, '') : defaultLanguage + const editor = new TextEditor({ readonly: true, keyboardInputEnabled: false From 51b6496fa28dca6ef2c137bd920fdc569d5fea0d Mon Sep 17 00:00:00 2001 From: Rafael Oleza Date: Tue, 23 Apr 2019 18:13:31 +0200 Subject: [PATCH 5/9] Refactor logic to use async/await --- lib/main.js | 22 +++----- lib/markdown-preview-view.js | 97 ++++++++++++++++-------------------- lib/renderer.js | 60 ++++++++-------------- 3 files changed, 73 insertions(+), 106 deletions(-) diff --git a/lib/main.js b/lib/main.js index c057de5..0b87479 100644 --- a/lib/main.js +++ b/lib/main.js @@ -178,7 +178,7 @@ module.exports = { }) }, - copyHTML () { + async copyHTML () { const editor = atom.workspace.getActiveTextEditor() if (editor == null) { return @@ -188,19 +188,13 @@ module.exports = { renderer = require('./renderer') } const text = editor.getSelectedText() || editor.getText() - return new Promise(function (resolve) { - renderer.toHTML(text, editor.getPath(), editor.getGrammar(), function ( - error, - html - ) { - if (error) { - console.warn('Copying Markdown as HTML failed', error) - } else { - atom.clipboard.write(html) - resolve() - } - }) - }) + const html = await renderer.toHTML( + text, + editor.getPath(), + editor.getGrammar() + ) + + atom.clipboard.write(html) }, saveAsHTML () { diff --git a/lib/markdown-preview-view.js b/lib/markdown-preview-view.js index c6c70d9..40ee87b 100644 --- a/lib/markdown-preview-view.js +++ b/lib/markdown-preview-view.js @@ -287,40 +287,35 @@ module.exports = class MarkdownPreviewView { } } - getHTML (callback) { - return this.getMarkdownSource().then(source => { - if (source == null) { - return - } + async getHTML () { + const source = await this.getMarkdownSource() - return renderer.toHTML( - source, - this.getPath(), - this.getGrammar(), - callback - ) - }) + if (source == null) { + return + } + + return renderer.toHTML(source, this.getPath(), this.getGrammar()) } - renderMarkdownText (text) { + async renderMarkdownText (text) { const { scrollTop } = this.element - return renderer.toDOMFragment( - text, - this.getPath(), - this.getGrammar(), - (error, domFragment) => { - if (error) { - this.showError(error) - } else { - this.loading = false - this.loaded = true - this.element.textContent = '' - this.element.appendChild(domFragment) - this.emitter.emit('did-change-markdown') - this.element.scrollTop = scrollTop - } - } - ) + + try { + const domFragment = await renderer.toDOMFragment( + text, + this.getPath(), + this.getGrammar() + ) + + this.loading = false + this.loaded = true + this.element.textContent = '' + this.element.appendChild(domFragment) + this.emitter.emit('did-change-markdown') + this.element.scrollTop = scrollTop + } catch (error) { + this.showError(error) + } } getTitle () { @@ -439,7 +434,7 @@ module.exports = class MarkdownPreviewView { selection.addRange(range) } - copyToClipboard () { + async copyToClipboard () { if (this.loading) { return } @@ -456,16 +451,16 @@ module.exports = class MarkdownPreviewView { ) { atom.clipboard.write(selectedText) } else { - return this.getHTML(function (error, html) { - if (error != null) { - atom.notifications.addError('Copying Markdown as HTML failed', { - dismissable: true, - detail: error.message - }) - } else { - atom.clipboard.write(html) - } - }) + try { + const html = await this.getHTML() + + atom.clipboard.write(html) + } catch (error) { + atom.notifications.addError('Copying Markdown as HTML failed', { + dismissable: true, + detail: error.message + }) + } } } @@ -484,7 +479,7 @@ module.exports = class MarkdownPreviewView { return { defaultPath } } - saveAs (htmlFilePath) { + async saveAs (htmlFilePath) { if (this.loading) { atom.notifications.addWarning( 'Please wait until the Markdown Preview has finished loading before saving' @@ -498,13 +493,10 @@ module.exports = class MarkdownPreviewView { title = path.parse(filePath).name } - return new Promise((resolve, reject) => { - return this.getHTML((error, htmlBody) => { - if (error != null) { - throw error - } else { - const html = - `\ + const htmlBody = await this.getHTML() + + const html = + `\ @@ -515,10 +507,7 @@ module.exports = class MarkdownPreviewView { ${htmlBody} ` + '\n' // Ensure trailing newline - fs.writeFileSync(htmlFilePath, html) - return atom.workspace.open(htmlFilePath).then(resolve) - } - }) - }) + fs.writeFileSync(htmlFilePath, html) + return atom.workspace.open(htmlFilePath) } } diff --git a/lib/renderer.js b/lib/renderer.js index 235aab7..7d28f6a 100644 --- a/lib/renderer.js +++ b/lib/renderer.js @@ -8,49 +8,38 @@ const { scopeForFenceName } = require('./extension-helper') const { resourcePath } = atom.getLoadSettings() const packagePath = path.dirname(__dirname) -exports.toDOMFragment = function (text, filePath, grammar, callback) { +exports.toDOMFragment = async function (text, filePath, grammar, callback) { if (text == null) { text = '' } - render(text, filePath, function (error, domFragment) { - if (error != null) { - return callback(error) - } - highlightCodeBlocks( - domFragment, - grammar, - makeAtomEditorNonInteractive - ).then(() => callback(null, domFragment)) - }) + const domFragment = render(text, filePath) + + await highlightCodeBlocks(domFragment, grammar, makeAtomEditorNonInteractive) + + return domFragment } -exports.toHTML = function (text, filePath, grammar, callback) { +exports.toHTML = async function (text, filePath, grammar) { if (text == null) { text = '' } - return render(text, filePath, function (error, domFragment) { - if (error != null) { - return callback(error) - } + const domFragment = render(text, filePath) + const div = document.createElement('div') - const div = document.createElement('div') - div.appendChild(domFragment) - document.body.appendChild(div) - - return highlightCodeBlocks( - div, - grammar, - convertAtomEditorToStandardElement - ).then(function () { - callback(null, div.innerHTML) - return div.remove() - }) - }) + div.appendChild(domFragment) + document.body.appendChild(div) + + await highlightCodeBlocks(div, grammar, convertAtomEditorToStandardElement) + + const result = div.innerHTML + div.remove() + + return result } -var render = function (text, filePath, callback) { +var render = function (text, filePath) { if (marked == null) { marked = require('marked') } @@ -60,13 +49,7 @@ var render = function (text, filePath, callback) { breaks: atom.config.get('markdown-preview.breakOnSingleNewline') }) - let html - - try { - html = marked(text) - } catch (error) { - return callback(error) - } + let html = marked(text) html = createDOMPurify().sanitize(html, { ALLOW_UNKNOWN_PROTOCOLS: atom.config.get( @@ -79,7 +62,8 @@ var render = function (text, filePath, callback) { const fragment = template.content.cloneNode(true) resolveImagePaths(fragment, filePath) - callback(null, fragment) + + return fragment } var resolveImagePaths = function (element, filePath) { From 5c97c141a578d9191c40409814f88b42b3cfd15a Mon Sep 17 00:00:00 2001 From: Rafael Oleza Date: Tue, 23 Apr 2019 19:17:07 +0200 Subject: [PATCH 6/9] Add yaml frontmatter support --- lib/renderer.js | 22 +++++++++++++++++++++- package.json | 3 ++- spec/fixtures/subdir/file.markdown | 7 +++++++ spec/markdown-preview-view-spec.js | 21 +++++++++++++++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/lib/renderer.js b/lib/renderer.js index 7d28f6a..05a989d 100644 --- a/lib/renderer.js +++ b/lib/renderer.js @@ -3,6 +3,7 @@ const path = require('path') const createDOMPurify = require('dompurify') const fs = require('fs-plus') let marked = null // Defer until used +const yamlFrontMatter = require('yaml-front-matter') const { scopeForFenceName } = require('./extension-helper') const { resourcePath } = atom.getLoadSettings() @@ -49,7 +50,8 @@ var render = function (text, filePath) { breaks: atom.config.get('markdown-preview.breakOnSingleNewline') }) - let html = marked(text) + const { __content, ...vars } = yamlFrontMatter.loadFront(text) + let html = marked(renderYamlTable(vars) + __content) html = createDOMPurify().sanitize(html, { ALLOW_UNKNOWN_PROTOCOLS: atom.config.get( @@ -66,6 +68,24 @@ var render = function (text, filePath) { return fragment } +function renderYamlTable (variables) { + const entries = Object.entries(variables) + + if (!entries.length) { + return '' + } + + const markdownRows = [ + entries.map(entry => entry[0]), + entries.map(entry => '--'), + entries.map(entry => entry[1]) + ] + + return ( + markdownRows.map(row => '| ' + row.join(' | ') + ' |').join('\n') + '\n' + ) +} + var resolveImagePaths = function (element, filePath) { const [rootDirectory] = atom.project.relativizePath(filePath) diff --git a/package.json b/package.json index 42c9fd2..66b5583 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "dompurify": "^1.0.2", "fs-plus": "^3.0.0", "marked": "^0.6.2", - "underscore-plus": "^1.0.0" + "underscore-plus": "^1.0.0", + "yaml-front-matter": "^4.0.0" }, "devDependencies": { "coffeelint": "^1.9.7", diff --git a/spec/fixtures/subdir/file.markdown b/spec/fixtures/subdir/file.markdown index d8ec5df..ac8dc01 100644 --- a/spec/fixtures/subdir/file.markdown +++ b/spec/fixtures/subdir/file.markdown @@ -1,3 +1,10 @@ +--- +variable1: value1 +array: + - foo + - bar +--- + ## File.markdown :cool: diff --git a/spec/markdown-preview-view-spec.js b/spec/markdown-preview-view-spec.js index 5dbcfbb..45a81ea 100644 --- a/spec/markdown-preview-view-spec.js +++ b/spec/markdown-preview-view-spec.js @@ -327,6 +327,27 @@ end\ }) }) + describe('yaml front matter', function () { + it('creates a table with the YAML variables', function () { + atom.config.set('markdown-preview.breakOnSingleNewline', true) + + waitsForPromise(() => preview.renderMarkdown()) + + runs(() => { + expect( + [...preview.element.querySelectorAll('table th')].map( + el => el.textContent + ) + ).toEqual(['variable1', 'array']) + expect( + [...preview.element.querySelectorAll('table td')].map( + el => el.textContent + ) + ).toEqual(['value1', 'foo,bar']) + }) + }) + }) + describe('text selections', function () { it('adds the `has-selection` class to the preview depending on if there is a text selection', function () { expect(preview.element.classList.contains('has-selection')).toBe(false) From 1445a643042f8de65741e6855978762905025025 Mon Sep 17 00:00:00 2001 From: Rafael Oleza Date: Tue, 23 Apr 2019 19:58:51 +0200 Subject: [PATCH 7/9] Render lists with checkboxes correctly --- lib/renderer.js | 10 +++++++++- styles/markdown-preview.less | 8 +++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/lib/renderer.js b/lib/renderer.js index 05a989d..b8c3638 100644 --- a/lib/renderer.js +++ b/lib/renderer.js @@ -3,6 +3,7 @@ const path = require('path') const createDOMPurify = require('dompurify') const fs = require('fs-plus') let marked = null // Defer until used +let renderer = null const yamlFrontMatter = require('yaml-front-matter') const { scopeForFenceName } = require('./extension-helper') @@ -43,11 +44,18 @@ exports.toHTML = async function (text, filePath, grammar) { var render = function (text, filePath) { if (marked == null) { marked = require('marked') + renderer = new marked.Renderer() + renderer.listitem = function (text, isTask) { + const listAttributes = isTask ? ' class="task-list-item"' : '' + + return `
  • ${text}
  • \n` + } } marked.setOptions({ sanitize: false, - breaks: atom.config.get('markdown-preview.breakOnSingleNewline') + breaks: atom.config.get('markdown-preview.breakOnSingleNewline'), + renderer }) const { __content, ...vars } = yamlFrontMatter.loadFront(text) diff --git a/styles/markdown-preview.less b/styles/markdown-preview.less index b242f04..bff8cec 100644 --- a/styles/markdown-preview.less +++ b/styles/markdown-preview.less @@ -20,15 +20,13 @@ } // move task list checkboxes - .task-list-item-checkbox { + .task-list-item input[type=checkbox] { position: absolute; margin: .25em 0 0 -1.4em; } - // hide bullet for task lists - // TODO: Use more specific selector once https://github.com/gjtorikian/task-lists-js/issues/5 gets fixed - ul label { - vertical-align: top; + .task-list-item { + list-style-type: none; } } From ca190429277cafbaaa8b02f5bab2fc1b1b1359df Mon Sep 17 00:00:00 2001 From: Rafael Oleza Date: Tue, 23 Apr 2019 20:20:22 +0200 Subject: [PATCH 8/9] Add emoji support to markdown previews --- lib/renderer.js | 5 +++++ package.json | 1 + 2 files changed, 6 insertions(+) diff --git a/lib/renderer.js b/lib/renderer.js index b8c3638..f94498b 100644 --- a/lib/renderer.js +++ b/lib/renderer.js @@ -1,6 +1,7 @@ const { TextEditor } = require('atom') const path = require('path') const createDOMPurify = require('dompurify') +const emoji = require('emoji-images') const fs = require('fs-plus') let marked = null // Defer until used let renderer = null @@ -10,6 +11,8 @@ const { scopeForFenceName } = require('./extension-helper') const { resourcePath } = atom.getLoadSettings() const packagePath = path.dirname(__dirname) +const emojiFolder = path.join(path.dirname(require.resolve('emoji-images') ), "pngs") + exports.toDOMFragment = async function (text, filePath, grammar, callback) { if (text == null) { text = '' @@ -59,7 +62,9 @@ var render = function (text, filePath) { }) const { __content, ...vars } = yamlFrontMatter.loadFront(text) + let html = marked(renderYamlTable(vars) + __content) + html = emoji(html, emojiFolder, 20) html = createDOMPurify().sanitize(html, { ALLOW_UNKNOWN_PROTOCOLS: atom.config.get( diff --git a/package.json b/package.json index 66b5583..0c3537d 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "dompurify": "^1.0.2", + "emoji-images": "^0.1.1", "fs-plus": "^3.0.0", "marked": "^0.6.2", "underscore-plus": "^1.0.0", From 32453355657bafde0c5feb2ce41a98f0c3cb0405 Mon Sep 17 00:00:00 2001 From: Rafael Oleza Date: Tue, 23 Apr 2019 20:26:54 +0200 Subject: [PATCH 9/9] Do not add emojis on code tags --- lib/renderer.js | 19 +++++++++++++++++-- package.json | 1 + 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/lib/renderer.js b/lib/renderer.js index f94498b..e507026 100644 --- a/lib/renderer.js +++ b/lib/renderer.js @@ -5,13 +5,17 @@ const emoji = require('emoji-images') const fs = require('fs-plus') let marked = null // Defer until used let renderer = null +let cheerio = null const yamlFrontMatter = require('yaml-front-matter') const { scopeForFenceName } = require('./extension-helper') const { resourcePath } = atom.getLoadSettings() const packagePath = path.dirname(__dirname) -const emojiFolder = path.join(path.dirname(require.resolve('emoji-images') ), "pngs") +const emojiFolder = path.join( + path.dirname(require.resolve('emoji-images')), + 'pngs' +) exports.toDOMFragment = async function (text, filePath, grammar, callback) { if (text == null) { @@ -46,6 +50,7 @@ exports.toHTML = async function (text, filePath, grammar) { var render = function (text, filePath) { if (marked == null) { + cheerio = require('cheerio') marked = require('marked') renderer = new marked.Renderer() renderer.listitem = function (text, isTask) { @@ -64,7 +69,17 @@ var render = function (text, filePath) { const { __content, ...vars } = yamlFrontMatter.loadFront(text) let html = marked(renderYamlTable(vars) + __content) - html = emoji(html, emojiFolder, 20) + + // emoji-images is too aggressive, so replace images in monospace tags with the actual emoji text. + const $ = cheerio.load(emoji(html, emojiFolder, 20)) + $('pre img').each((index, element) => + $(element).replaceWith($(element).attr('title')) + ) + $('code img').each((index, element) => + $(element).replaceWith($(element).attr('title')) + ) + + html = $.html() html = createDOMPurify().sanitize(html, { ALLOW_UNKNOWN_PROTOCOLS: atom.config.get( diff --git a/package.json b/package.json index 0c3537d..5aa6f2d 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "atom": "*" }, "dependencies": { + "cheerio": "^1.0.0-rc.3", "dompurify": "^1.0.2", "emoji-images": "^0.1.1", "fs-plus": "^3.0.0",