diff --git a/package.json b/package.json index 717fe0656..6c454decf 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@types/ws": "^6.0.1", "discord.js-docgen": "discordjs/docgen", "eslint": "^5.13.0", + "jest": "^24.7.1", "json-filter-loader": "^1.0.0", "terser-webpack-plugin": "^1.2.2", "tslint": "^5.12.1", diff --git a/src/structures/APIMessage.js b/src/structures/APIMessage.js index a925892d0..6edd65785 100644 --- a/src/structures/APIMessage.js +++ b/src/structures/APIMessage.js @@ -93,7 +93,7 @@ class APIMessage { if (content || mentionPart) { if (isCode) { const codeName = typeof this.options.code === 'string' ? this.options.code : ''; - content = `${mentionPart}\`\`\`${codeName}\n${Util.escapeMarkdown(content || '', true)}\n\`\`\``; + content = `${mentionPart}\`\`\`${codeName}\n${Util.cleanCodeBlockContent(content || '')}\n\`\`\``; if (isSplit) { splitOptions.prepend = `${splitOptions.prepend || ''}\`\`\`${codeName}\n`; splitOptions.append = `\n\`\`\`${splitOptions.append || ''}`; diff --git a/src/util/Util.js b/src/util/Util.js index 181d5589e..2f12d5870 100644 --- a/src/util/Util.js +++ b/src/util/Util.js @@ -75,14 +75,144 @@ class Util { /** * Escapes any Discord-flavour markdown in a string. * @param {string} text Content to escape - * @param {boolean} [onlyCodeBlock=false] Whether to only escape codeblocks (takes priority) - * @param {boolean} [onlyInlineCode=false] Whether to only escape inline code + * @param {Object} [options={}] What types of markdown to escape + * @param {boolean} [options.codeBlock=true] Whether to escape code blocks or not + * @param {boolean} [options.inlineCode=true] Whether to escape inline code or not + * @param {boolean} [options.bold=true] Whether to escape bolds or not + * @param {boolean} [options.italic=true] Whether to escape italics or not + * @param {boolean} [options.underline=true] Whether to escape underlines or not + * @param {boolean} [options.strikethrough=true] Whether to escape strikethroughs or not + * @param {boolean} [options.spoiler=true] Whether to escape spoilers or not + * @param {boolean} [options.codeBlockContent=true] Whether to escape text inside code blocks or not + * @param {boolean} [options.inlineCodeContent=true] Whether to escape text inside inline code or not * @returns {string} */ - static escapeMarkdown(text, onlyCodeBlock = false, onlyInlineCode = false) { - if (onlyCodeBlock) return text.replace(/```/g, '`\u200b``'); - if (onlyInlineCode) return text.replace(/\\(`|\\)/g, '$1').replace(/(`|\\)/g, '\\$1'); - return text.replace(/\\(\*|_|`|~|\\)/g, '$1').replace(/(\*|_|`|~|\\)/g, '\\$1'); + static escapeMarkdown(text, { + codeBlock = true, + inlineCode = true, + bold = true, + italic = true, + underline = true, + strikethrough = true, + spoiler = true, + codeBlockContent = true, + inlineCodeContent = true, + } = {}) { + if (!codeBlockContent) { + return text.split('```').map((subString, index, array) => { + if ((index % 2) && index !== array.length - 1) return subString; + return Util.escapeMarkdown(subString, { + inlineCode, + bold, + italic, + underline, + strikethrough, + spoiler, + inlineCodeContent, + }); + }).join(codeBlock ? '\\`\\`\\`' : '```'); + } + if (!inlineCodeContent) { + return text.split(/(?<=^|[^`])`(?=[^`]|$)/g).map((subString, index, array) => { + if ((index % 2) && index !== array.length - 1) return subString; + return Util.escapeMarkdown(subString, { + codeBlock, + bold, + italic, + underline, + strikethrough, + spoiler, + }); + }).join(inlineCode ? '\\`' : '`'); + } + if (inlineCode) text = Util.escapeInlineCode(text); + if (codeBlock) text = Util.escapeCodeBlock(text); + if (italic) text = Util.escapeItalic(text); + if (bold) text = Util.escapeBold(text); + if (underline) text = Util.escapeUnderline(text); + if (strikethrough) text = Util.escapeStrikethrough(text); + if (spoiler) text = Util.escapeSpoiler(text); + return text; + } + + /** + * Escapes code block markdown in a string. + * @param {string} text Content to escape + * @returns {string} + */ + static escapeCodeBlock(text) { + return text.replace(/```/g, '\\`\\`\\`'); + } + + /** + * Escapes inline code markdown in a string. + * @param {string} text Content to escape + * @returns {string} + */ + static escapeInlineCode(text) { + return text.replace(/(?<=^|[^`])`(?=[^`]|$)/g, '\\`'); + } + + /** + * Escapes italic markdown in a string. + * @param {string} text Content to escape + * @returns {string} + */ + static escapeItalic(text) { + let i = 0; + text = text.replace(/(?<=^|[^*])\*([^*]|\*\*|$)/g, (_, match) => { + if (match === '**') return ++i % 2 ? `\\*${match}` : `${match}\\*`; + return `\\*${match}`; + }); + i = 0; + return text.replace(/(?<=^|[^_])_([^_]|__|$)/g, (_, match) => { + if (match === '__') return ++i % 2 ? `\\_${match}` : `${match}\\_`; + return `\\_${match}`; + }); + } + + /** + * Escapes bold markdown in a string. + * @param {string} text Content to escape + * @returns {string} + */ + static escapeBold(text) { + let i = 0; + return text.replace(/\*\*(\*)?/g, (_, match) => { + if (match) return ++i % 2 ? `${match}\\*\\*` : `\\*\\*${match}`; + return '\\*\\*'; + }); + } + + /** + * Escapes underline markdown in a string. + * @param {string} text Content to escape + * @returns {string} + */ + static escapeUnderline(text) { + let i = 0; + return text.replace(/__(_)?/g, (_, match) => { + if (match) return ++i % 2 ? `${match}\\_\\_` : `\\_\\_${match}`; + return '\\_\\_'; + }); + } + + /** + * Escapes strikethrough markdown in a string. + * @param {string} text Content to escape + * @returns {string} + */ + static escapeStrikethrough(text) { + return text.replace(/~~/g, '\\~\\~'); + } + + /** + * Escapes spoiler markdown in a string. + * @param {string} text Content to escape + * @returns {string} + */ + static escapeSpoiler(text) { + return text.replace(/\|\|/g, '\\|\\|'); } /** @@ -421,6 +551,15 @@ class Util { }); } + /** + * The content to put in a codeblock with all codeblock fences replaced by the equivalent backticks. + * @param {string} text The string to be converted + * @returns {string} + */ + static cleanCodeBlockContent(text) { + return text.replace('```', '`\u200b``'); + } + /** * Creates a Promise that resolves after a specified duration. * @param {number} ms How long to wait before resolving (in milliseconds) diff --git a/test/escapeMarkdown.test.js b/test/escapeMarkdown.test.js new file mode 100644 index 000000000..b791fdb91 --- /dev/null +++ b/test/escapeMarkdown.test.js @@ -0,0 +1,184 @@ +'use strict'; + +/* eslint-disable max-len, no-undef */ + +const Util = require('../src/util/Util'); +const testString = '`_Behold!_`\n||___~~***```js\n`use strict`;\nrequire(\'discord.js\');```***~~___||'; + +describe('escapeCodeblock', () => { + test('shared', () => { + expect(Util.escapeCodeBlock(testString)) + .toBe('`_Behold!_`\n||___~~***\\`\\`\\`js\n`use strict`;\nrequire(\'discord.js\');\\`\\`\\`***~~___||'); + }); + + test('basic', () => { + expect(Util.escapeCodeBlock('```test```')) + .toBe('\\`\\`\\`test\\`\\`\\`'); + }); +}); + + +describe('escapeInlineCode', () => { + test('shared', () => { + expect(Util.escapeInlineCode(testString)) + .toBe('\\`_Behold!_\\`\n||___~~***```js\n\\`use strict\\`;\nrequire(\'discord.js\');```***~~___||'); + }); + + test('basic', () => { + expect(Util.escapeInlineCode('`test`')) + .toBe('\\`test\\`'); + }); +}); + + +describe('escapeBold', () => { + test('shared', () => { + expect(Util.escapeBold(testString)) + .toBe('`_Behold!_`\n||___~~*\\*\\*```js\n`use strict`;\nrequire(\'discord.js\');```\\*\\**~~___||'); + }); + + test('basic', () => { + expect(Util.escapeBold('**test**')) + .toBe('\\*\\*test\\*\\*'); + }); +}); + + +describe('escapeItalic', () => { + test('shared', () => { + expect(Util.escapeItalic(testString)) + .toBe('`\\_Behold!\\_`\n||\\___~~\\***```js\n`use strict`;\nrequire(\'discord.js\');```**\\*~~__\\_||'); + }); + + test('basic (_)', () => { + expect(Util.escapeItalic('_test_')) + .toBe('\\_test\\_'); + }); + + test('basic (*)', () => { + expect(Util.escapeItalic('*test*')) + .toBe('\\*test\\*'); + }); +}); + + +describe('escapeUnderline', () => { + test('shared', () => { + expect(Util.escapeUnderline(testString)) + .toBe('`_Behold!_`\n||_\\_\\_~~***```js\n`use strict`;\nrequire(\'discord.js\');```***~~\\_\\__||'); + }); + + test('basic', () => { + expect(Util.escapeUnderline('__test__')) + .toBe('\\_\\_test\\_\\_'); + }); +}); + + +describe('escapeStrikethrough', () => { + test('shared', () => { + expect(Util.escapeStrikethrough(testString)) + .toBe('`_Behold!_`\n||___\\~\\~***```js\n`use strict`;\nrequire(\'discord.js\');```***\\~\\~___||'); + }); + + test('basic', () => { + expect(Util.escapeStrikethrough('~~test~~')) + .toBe('\\~\\~test\\~\\~'); + }); +}); + + +describe('escapeSpoiler', () => { + test('shared', () => { + expect(Util.escapeSpoiler(testString)) + .toBe('`_Behold!_`\n\\|\\|___~~***```js\n`use strict`;\nrequire(\'discord.js\');```***~~___\\|\\|'); + }); + + test('basic', () => { + expect(Util.escapeSpoiler('||test||')) + .toBe('\\|\\|test\\|\\|'); + }); +}); + + +describe('escapeMarkdown', () => { + test('shared', () => { + expect(Util.escapeMarkdown(testString)) + .toBe('\\`\\_Behold!\\_\\`\n\\|\\|\\_\\_\\_\\~\\~\\*\\*\\*\\`\\`\\`js\n\\`use strict\\`;\nrequire(\'discord.js\');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|'); + }); + + test('no codeBlock', () => { + expect(Util.escapeMarkdown(testString, { codeBlock: false })) + .toBe('\\`\\_Behold!\\_\\`\n\\|\\|\\_\\_\\_\\~\\~\\*\\*\\*```js\n\\`use strict\\`;\nrequire(\'discord.js\');```\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|'); + }); + + test('no inlineCode', () => { + expect(Util.escapeMarkdown(testString, { inlineCode: false })) + .toBe('`\\_Behold!\\_`\n\\|\\|\\_\\_\\_\\~\\~\\*\\*\\*\\`\\`\\`js\n`use strict`;\nrequire(\'discord.js\');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|'); + }); + + test('no bold', () => { + expect(Util.escapeMarkdown(testString, { bold: false })) + .toBe('\\`\\_Behold!\\_\\`\n\\|\\|\\_\\_\\_\\~\\~\\***\\`\\`\\`js\n\\`use strict\\`;\nrequire(\'discord.js\');\\`\\`\\`**\\*\\~\\~\\_\\_\\_\\|\\|'); + }); + + test('no italic', () => { + expect(Util.escapeMarkdown(testString, { italic: false })) + .toBe('\\`_Behold!_\\`\n\\|\\|_\\_\\_\\~\\~*\\*\\*\\`\\`\\`js\n\\`use strict\\`;\nrequire(\'discord.js\');\\`\\`\\`\\*\\**\\~\\~\\_\\__\\|\\|'); + }); + + test('no underline', () => { + expect(Util.escapeMarkdown(testString, { underline: false })) + .toBe('\\`\\_Behold!\\_\\`\n\\|\\|\\___\\~\\~\\*\\*\\*\\`\\`\\`js\n\\`use strict\\`;\nrequire(\'discord.js\');\\`\\`\\`\\*\\*\\*\\~\\~__\\_\\|\\|'); + }); + + test('no strikethrough', () => { + expect(Util.escapeMarkdown(testString, { strikethrough: false })) + .toBe('\\`\\_Behold!\\_\\`\n\\|\\|\\_\\_\\_~~\\*\\*\\*\\`\\`\\`js\n\\`use strict\\`;\nrequire(\'discord.js\');\\`\\`\\`\\*\\*\\*~~\\_\\_\\_\\|\\|'); + }); + + test('no spoiler', () => { + expect(Util.escapeMarkdown(testString, { spoiler: false })) + .toBe('\\`\\_Behold!\\_\\`\n||\\_\\_\\_\\~\\~\\*\\*\\*\\`\\`\\`js\n\\`use strict\\`;\nrequire(\'discord.js\');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_||'); + }); + + describe('code content', () => { + test('no code block content', () => { + expect(Util.escapeMarkdown(testString, { codeBlockContent: false })) + .toBe('\\`\\_Behold!\\_\\`\n\\|\\|\\_\\_\\_\\~\\~\\*\\*\\*\\`\\`\\`js\n`use strict`;\nrequire(\'discord.js\');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|'); + }); + + test('no inline code content', () => { + expect(Util.escapeMarkdown(testString, { inlineCodeContent: false })) + .toBe('\\`_Behold!_\\`\n\\|\\|\\_\\_\\_\\~\\~\\*\\*\\*\\`\\`\\`js\n\\`use strict\\`;\nrequire(\'discord.js\');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|'); + }); + + test('neither inline code or code block content', () => { + expect(Util.escapeMarkdown(testString, { inlineCodeContent: false, codeBlockContent: false })) + // eslint-disable-next-line max-len + .toBe('\\`_Behold!_\\`\n\\|\\|\\_\\_\\_\\~\\~\\*\\*\\*\\`\\`\\`js\n`use strict`;\nrequire(\'discord.js\');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|'); + }); + + test('neither code blocks or code block content', () => { + expect(Util.escapeMarkdown(testString, { codeBlock: false, codeBlockContent: false })) + .toBe('\\`\\_Behold!\\_\\`\n\\|\\|\\_\\_\\_\\~\\~\\*\\*\\*```js\n`use strict`;\nrequire(\'discord.js\');```\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|'); + }); + + test('neither inline code or inline code content', () => { + expect(Util.escapeMarkdown(testString, { inlineCode: false, inlineCodeContent: false })) + .toBe('`_Behold!_`\n\\|\\|\\_\\_\\_\\~\\~\\*\\*\\*\\`\\`\\`js\n`use strict`;\nrequire(\'discord.js\');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|'); + }); + + test('edge-case odd number of fenses with no code block content', () => { + expect(Util.escapeMarkdown('**foo** ```**bar**``` **fizz** ``` **buzz**', { codeBlock: false, codeBlockContent: false })) + .toBe('\\*\\*foo\\*\\* ```**bar**``` \\*\\*fizz\\*\\* ``` \\*\\*buzz\\*\\*'); + }); + + test('edge-case odd number of backticks with no inline code content', () => { + expect(Util.escapeMarkdown('**foo** `**bar**` **fizz** ` **buzz**', { inlineCode: false, inlineCodeContent: false })) + .toBe('\\*\\*foo\\*\\* `**bar**` \\*\\*fizz\\*\\* ` \\*\\*buzz\\*\\*'); + }); + }); +}); + +/* eslint-enable max-len, no-undef */ diff --git a/typings/index.d.ts b/typings/index.d.ts index b97c62a82..168e84801 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -1160,7 +1160,15 @@ declare module 'discord.js' { public static convertToBuffer(ab: ArrayBuffer | string): Buffer; public static delayFor(ms: number): Promise; public static discordSort(collection: Collection): Collection; - public static escapeMarkdown(text: string, onlyCodeBlock?: boolean, onlyInlineCode?: boolean): string; + public static escapeMarkdown(text: string, options?: { codeBlock?: boolean, inlineCode?: boolean, bold?: boolean, italic?: boolean, underline?: boolean, strikethrough?: boolean, spoiler?: boolean, inlineCodeContent?: boolean, codeBlockContent?: boolean }): string; + public static escapeCodeBlock(text: string): string; + public static escapeInlineCode(text: string): string; + public static escapeBold(text: string): string; + public static escapeItalic(text: string): string; + public static escapeUnderline(text: string): string; + public static escapeStrikethrough(text: string): string; + public static escapeSpoiler(text: string): string; + public static cleanCodeBlockContent(text: string): string; public static fetchRecommendedShards(token: string, guildsPerShard?: number): Promise; public static flatten(obj: object, ...props: { [key: string]: boolean | string }[]): object; public static idToBinary(num: Snowflake): string;