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`] = `
+"
+
+
+
+
+Tables |
+Are |
+Cool |
+
+
+
+
+col 3 is |
+right-aligned |
+$1600 |
+
+
+col 2 is |
+centered |
+$12 |
+
+
+zebra stripes |
+are neat |
+$1 |
+
+
+
+
+
+
+link
+
: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 ``;
+ }
+ 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 = `' : '>';
+ 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 (
+ `\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 = `' : '>';
- 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 (
- `\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 ``;
- }
- 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);
- };
-};