refactor(Util.escapeMarkdown): allow separate escaping and add tests (#3241)

* wip refactor

* add escapeMarkdown tests

* italics can be done with a single underscore too

* more refined

* fix test name

* unnecessary eslint ignores

* use jest

* make eslint less annoying in this test file

* more testing

* fix lib usage

* more tests and a small fix
This commit is contained in:
bdistin
2019-07-11 15:08:40 -05:00
committed by SpaceEEC
parent f1433a2d97
commit 00c4098bb3
5 changed files with 340 additions and 8 deletions

View File

@@ -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",

View File

@@ -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 || ''}`;

View File

@@ -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)

184
test/escapeMarkdown.test.js Normal file
View File

@@ -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 */

10
typings/index.d.ts vendored
View File

@@ -1160,7 +1160,15 @@ declare module 'discord.js' {
public static convertToBuffer(ab: ArrayBuffer | string): Buffer;
public static delayFor(ms: number): Promise<void>;
public static discordSort<K, V extends { rawPosition: number; id: string; }>(collection: Collection<K, V>): Collection<K, V>;
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<number>;
public static flatten(obj: object, ...props: { [key: string]: boolean | string }[]): object;
public static idToBinary(num: Snowflake): string;