diff --git a/packages/api-explorer-ui/__tests__/lib/__snapshots__/marked.test.js.snap b/packages/api-explorer-ui/__tests__/lib/__snapshots__/marked.test.js.snap new file mode 100644 index 000000000..a34914966 --- /dev/null +++ b/packages/api-explorer-ui/__tests__/lib/__snapshots__/marked.test.js.snap @@ -0,0 +1,41 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render markdown 1`] = ` +"

\\"Image\\"

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
TablesAreCool
col 3 isright-aligned$1600
col 2 iscentered$12
zebra stripesare neat$1
+

h1

+

h2

+

h3

+

link

+

\\":joy+:\\"

:unknown-emoji:

+
var a = 1;
+
+" +`; diff --git a/packages/api-explorer-ui/__tests__/lib/markdown.txt b/packages/api-explorer-ui/__tests__/lib/markdown.txt new file mode 100644 index 000000000..70c36422b --- /dev/null +++ b/packages/api-explorer-ui/__tests__/lib/markdown.txt @@ -0,0 +1,24 @@ +![Image](http://example.com/image.png) + +- listitem1 +- [ ] checklistitem1 + +| Tables | Are | Cool | +| ------------- |:-------------:| -----:| +| col 3 is | right-aligned | $1600 | +| col 2 is | centered | $12 | +| zebra stripes | are neat | $1 | + +# h1 +## h2 +### h3 + +[link](http://example.com) + +:joy: +:fa-lock: +:unknown-emoji: + +```js +var a = 1; +``` diff --git a/packages/api-explorer-ui/__tests__/lib/marked.test.js b/packages/api-explorer-ui/__tests__/lib/marked.test.js new file mode 100644 index 000000000..cd195ed16 --- /dev/null +++ b/packages/api-explorer-ui/__tests__/lib/marked.test.js @@ -0,0 +1,23 @@ +const fs = require('fs'); +const markdown = require('../../src/lib/markdown'); + +const fixture = fs.readFileSync(`${__dirname}/markdown.txt`, 'utf8'); + +test('should render markdown', () => { + expect(markdown(fixture)).toMatchSnapshot(); +}); + +test('should render empty string if nothing passed in', () => { + expect(markdown('')).toBe(''); +}); + +test('`correctnewlines` option', () => { + expect(markdown('test\ntest\ntest', { correctnewlines: true })).toBe('

test\ntest\ntest

\n'); + expect(markdown('test\ntest\ntest', { correctnewlines: false })).toBe('

test
test
test

\n'); +}); + +test('`stripHtml` option', () => { + expect(markdown('

Test

')).toBe('

Test

\n'); + expect(markdown('

Test

', { stripHtml: false })).toBe('

Test

\n'); + expect(markdown('

Test

', { stripHtml: true })).toBe('

<p>Test</p>

\n'); +}); diff --git a/packages/api-explorer-ui/src/lib/emojis.js b/packages/api-explorer-ui/src/lib/markdown/emojis.js similarity index 100% rename from packages/api-explorer-ui/src/lib/emojis.js rename to packages/api-explorer-ui/src/lib/markdown/emojis.js diff --git a/packages/api-explorer-ui/src/lib/markdown/index.js b/packages/api-explorer-ui/src/lib/markdown/index.js new file mode 100644 index 000000000..587ac6b62 --- /dev/null +++ b/packages/api-explorer-ui/src/lib/markdown/index.js @@ -0,0 +1,34 @@ +const marked = require('marked'); +const Emoji = require('./emojis.js').emoji; +const syntaxHighlighter = require('../../../../readme-syntax-highlighter'); +const sanitizer = require('./sanitizer'); +const renderer = require('./renderer'); + +const emojis = new Emoji(); + +module.exports = function markdown(text, opts = {}) { + marked.setOptions({ + sanitize: true, + preserveNumbering: true, + renderer, + emoji(emojiText) { + const emoji = emojiText.replace(/[^-_+a-zA-Z0-9]/g, '').toLowerCase(); + if (emoji.substr(0, 3) === 'fa-') { + return ``; + } + if (emojis.is(emoji)) { + return `:${emoji}+:`; + } + return `:${emoji}:`; + }, + highlight(code, language) { + if (!language) return undefined; + return syntaxHighlighter(code, language); + }, + gfm: true, + breaks: !opts.correctnewlines, + sanitizer: opts.stripHtml ? undefined : sanitizer, + }); + return marked(text); +}; + diff --git a/packages/api-explorer-ui/src/lib/markdown/renderer.js b/packages/api-explorer-ui/src/lib/markdown/renderer.js new file mode 100644 index 000000000..4183f2150 --- /dev/null +++ b/packages/api-explorer-ui/src/lib/markdown/renderer.js @@ -0,0 +1,125 @@ +const marked = require('marked'); + +const renderer = new marked.Renderer(); + +renderer.image = function image(href, title, text) { + let out = `${text}' : '>'; + return out; +}; + +renderer.listitem = function listitem(text, val) { + const valAttr = val ? ` value="${val}"` : ''; + if (/^\s*\[[x ]\]\s*/.test(text)) { + text = text + .replace(/^\s*\[ \]\s*/, ' ') + .replace(/^\s*\[x\]\s*/, ' '); + return `
  • ${text}
  • `; + } + + return `
  • ${text}
  • `; +}; + +renderer.table = function table(header, body) { + return ( + `${'
    \n\n'}${header}\n` + + `\n${body}\n
    \n` + ); +}; + +renderer.heading = function heading(text, level, raw) { + const id = this.options.headerPrefix + raw.toLowerCase().replace(/[^\w]+/g, '-'); + + return ( + `` + + `
    ${text}` + + `
    \n` + ); +}; + +renderer.link = function link(href, title, text) { + /* eslint no-param-reassign: 0 */ + const doc = href.match(/^doc:([-_a-zA-Z0-9#]*)$/); + let isDoc = false; + let uiSref = false; + + if (href.match(/^(data|javascript)[^a-zA-Z0-9/_-]/i)) { + // Avoid XSS + href = ''; + } + + if (doc) { + uiSref = `docs.show({'doc': '${doc[1]}'})`; + href = ''; + isDoc = doc[1]; + } + + const ref = href.match(/^ref:([-_a-zA-Z0-9#]*)$/); + if (ref) { + const cat = ''; + // TODO https://github.com/readmeio/api-explorer/issues/28 + // if (req && req.project.appearance.categoriesAsDropdown) { + // cat = `/${req._referenceCategoryMap[ref[1]]}`; + // } + href = `/reference${cat}#${ref[1]}`; + } + + const blog = href.match(/^blog:([-_a-zA-Z0-9#]*)$/); + if (blog) { + uiSref = `blog.show({'blog': '${blog[1]}'})`; + href = ''; + } + + const custompage = href.match(/^page:([-_a-zA-Z0-9#]*)$/); + if (custompage) { + uiSref = `custompages.show({'custompage': '${custompage[1]}'})`; + } + + if (this.options.sanitize) { + let prot; + try { + prot = decodeURIComponent(unescape(href)) + .replace(/[^\w:]/g, '') + .toLowerCase(); + } catch (e) { + return ''; + } + // eslint-disable-next-line no-script-url + if (prot.indexOf('javascript:') === 0) { + return ''; + } + } + + let out = '${text}`; + return out; +}; + +module.exports = renderer; diff --git a/packages/api-explorer-ui/src/lib/markdown/sanitizer.js b/packages/api-explorer-ui/src/lib/markdown/sanitizer.js new file mode 100644 index 000000000..2866b4b2b --- /dev/null +++ b/packages/api-explorer-ui/src/lib/markdown/sanitizer.js @@ -0,0 +1,79 @@ +function sanitizer(tag) { + // TODO: This is probably not secure enough; use html-sanatize when we move to the backend in hub2. + const tagName = tag.match(/<\/?([^>\s]+)/); + + const allowedTags = [ + 'img', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'span', + 'blockquote', + 'p', + 'a', + 'ul', + 'ol', + 'nl', + 'li', + 'b', + 'i', + 'strong', + 'em', + 'strike', + 'code', + 'hr', + 'br', + 'div', + 'table', + 'thead', + 'caption', + 'tbody', + 'tr', + 'th', + 'td', + 'pre', + 'dl', + 'dd', + 'dt', + 'sub', + 'sup', + 'section', + ]; + + const allowedAttrs = [ + 'class', + 'id', + 'style', + 'cellpadding', + 'cellspacing', + 'width', + 'align', + 'height', + 'colspan', + 'href', + 'name', + 'target', + 'src', + 'title', + 'alt', + ]; + + if (allowedTags.indexOf(tagName[1]) <= -1) { + return tag.replace('<', '<').replace('>', '>'); + } + + let tagClean = tagName[0]; // add the tag here + tag.replace(/\s+([a-zA-Z0-9]+)=('.*?'|".*?")/g, (full, attr) => { + if (allowedAttrs.indexOf(attr) > -1) { + tagClean += full; + } + return ''; + }); + + return `${tagClean}>`; +}; + +module.exports = sanitizer; diff --git a/packages/api-explorer-ui/src/lib/marked.js b/packages/api-explorer-ui/src/lib/marked.js deleted file mode 100644 index 7198dc9bd..000000000 --- a/packages/api-explorer-ui/src/lib/marked.js +++ /dev/null @@ -1,235 +0,0 @@ -const marked = require('marked'); -const Emoji = require('./emojis.js').emoji; -const syntaxHighlighter = require('../../../readme-syntax-highlighter'); -// Configure marked -exports.configure = function(req) { - const renderer = new marked.Renderer(); - - renderer.image = function(href, title, text) { - let out = `${text}' : '>'; - return out; - }; - - renderer.listitem = function(text, val) { - const valAttr = val ? ` value="${val}"` : ''; - if (/^\s*\[[x ]\]\s*/.test(text)) { - text = text - .replace(/^\s*\[ \]\s*/, ' ') - .replace(/^\s*\[x\]\s*/, ' '); - return `
  • ${text}
  • `; - } - - return `
  • ${text}
  • `; - }; - - renderer.table = function(header, body) { - return ( - `${'
    \n\n'}${header}\n` + - `\n${body}\n
    \n` - ); - }; - - renderer.heading = function(text, level, raw) { - const id = this.options.headerPrefix + raw.toLowerCase().replace(/[^\w]+/g, '-'); - - return ( - `` + - `
    ${text}` + - `
    \n` - ); - }; - - renderer.link = function(href, title, text) { - const doc = href.match(/^doc:([-_a-zA-Z0-9#]*)$/); - let isDoc = false; - let uiSref = false; - - if (href.match(/^(data|javascript)[^a-zA-Z0-9\/_-]/i)) { - // Avoid XSS - href = ''; - } - - if (doc) { - uiSref = `docs.show({'doc': '${doc[1]}'})`; - href = ''; - isDoc = doc[1]; - } - - const ref = href.match(/^ref:([-_a-zA-Z0-9#]*)$/); - if (ref) { - let cat = ''; - if (req && req.project.appearance.categoriesAsDropdown) { - cat = `/${req._referenceCategoryMap[ref[1]]}`; - } - href = `/reference${cat}#${ref[1]}`; - } - - const blog = href.match(/^blog:([-_a-zA-Z0-9#]*)$/); - if (blog) { - uiSref = `blog.show({'blog': '${blog[1]}'})`; - href = ''; - } - - const custompage = href.match(/^page:([-_a-zA-Z0-9#]*)$/); - if (custompage) { - uiSref = `custompages.show({'custompage': '${custompage[1]}'})`; - } - - if (this.options.sanitize) { - let prot; - try { - prot = decodeURIComponent(unescape(href)) - .replace(/[^\w:]/g, '') - .toLowerCase(); - } catch (e) { - return ''; - } - if (prot.indexOf('javascript:') === 0) { - // eslint-disable-line no-script-url - return ''; - } - } - - let out = '${text}`; - return out; - }; - - const emojis = new Emoji(); - - marked.setOptions({ - sanitize: true, - breaks: req && req.project ? !req.project.flags.correctnewlines : true, - preserveNumbering: true, - renderer, - emoji(emoji) { - emoji = emoji.replace(/[^-_+a-zA-Z0-9]/g, '').toLowerCase(); - if (emoji.substr(0, 3) === 'fa-') { - return ``; - } - if (emojis.is(emoji)) { - return `:${emoji}+:`; - } - return `:${emoji}:`; - }, - highlight(code, language) { - if (!language) return undefined; - return syntaxHighlighter(code, language); - }, - gfm: true, - }); - - const sanitizer = function(tag) { - // TODO: This is probably not secure enough; use html-sanatize when we move to the backend in hub2. - const tagName = tag.match(/<\/?([^>\s]+)/); - - const allowedTags = [ - 'img', - 'h1', - 'h2', - 'h3', - 'h4', - 'h5', - 'h6', - 'span', - 'blockquote', - 'p', - 'a', - 'ul', - 'ol', - 'nl', - 'li', - 'b', - 'i', - 'strong', - 'em', - 'strike', - 'code', - 'hr', - 'br', - 'div', - 'table', - 'thead', - 'caption', - 'tbody', - 'tr', - 'th', - 'td', - 'pre', - 'dl', - 'dd', - 'dt', - 'sub', - 'sup', - 'section', - ]; - - const allowedAttrs = [ - 'class', - 'id', - 'style', - 'cellpadding', - 'cellspacing', - 'width', - 'align', - 'height', - 'colspan', - 'href', - 'name', - 'target', - 'src', - 'title', - 'alt', - ]; - - if (allowedTags.indexOf(tagName[1]) <= -1) { - return tag.replace('<', '<').replace('>', '>'); - } - - let tagClean = tagName[0]; // add the tag here - tag.replace(/\s+([a-zA-Z0-9]+)=('.*?'|".*?")/g, (full, attr) => { - if (allowedAttrs.indexOf(attr) > -1) { - tagClean += full; - } - return ''; - }); - - return `${tagClean}>`; - }; - - return function(text, stripHTML) { - if (!text) return ''; - marked.setOptions({ - sanitizer: stripHTML ? undefined : sanitizer, - }); - return marked(text); - }; -};