From 13ce78af6e3aedc793f53a099a6a615df44311f7 Mon Sep 17 00:00:00 2001 From: Cl00e9ment Date: Fri, 13 Jan 2023 17:21:25 +0100 Subject: [PATCH] refactor: Moved the escapeX functions from discord.js to @discord.js/formatters (#8957) * refactor: moved escapeX funcs from discord.js to @discord.js/formatters - moved escapeX functions from discord.js to @discord.js/formatters - converted code from JS to TS (including JSDoc and TSDoc) - made linter happy - modified the escapeHeading's RegExp to pass the RegExp safety test - escapeBulletedList now conserves the bullet style (- or *) * fix: removed useless exports and eslint command removed useless exports and eslint command * fix(escapeX): emojis with underlines porting the fix made in 2c4c5c23d633009bd85ae7160bafa675e61f4ceb into the refactorization PR Co-authored-by: space --- packages/discord.js/src/util/Util.js | 239 -------------- .../discord.js/test/escapeMarkdown.test.js | 254 -------------- packages/discord.js/typings/index.d.ts | 13 - .../formatters/__tests__/escapers.test.ts | 264 +++++++++++++++ packages/formatters/src/escapers.ts | 310 ++++++++++++++++++ packages/formatters/src/index.ts | 1 + 6 files changed, 575 insertions(+), 506 deletions(-) delete mode 100644 packages/discord.js/test/escapeMarkdown.test.js create mode 100644 packages/formatters/__tests__/escapers.test.ts create mode 100644 packages/formatters/src/escapers.ts diff --git a/packages/discord.js/src/util/Util.js b/packages/discord.js/src/util/Util.js index 0b608210d0d0..220b8a3b38da 100644 --- a/packages/discord.js/src/util/Util.js +++ b/packages/discord.js/src/util/Util.js @@ -53,233 +53,6 @@ function flatten(obj, ...props) { return out; } -/** - * Options used to escape markdown. - * @typedef {Object} EscapeMarkdownOptions - * @property {boolean} [codeBlock=true] Whether to escape code blocks - * @property {boolean} [inlineCode=true] Whether to escape inline code - * @property {boolean} [bold=true] Whether to escape bolds - * @property {boolean} [italic=true] Whether to escape italics - * @property {boolean} [underline=true] Whether to escape underlines - * @property {boolean} [strikethrough=true] Whether to escape strikethroughs - * @property {boolean} [spoiler=true] Whether to escape spoilers - * @property {boolean} [codeBlockContent=true] Whether to escape text inside code blocks - * @property {boolean} [inlineCodeContent=true] Whether to escape text inside inline code - * @property {boolean} [escape=true] Whether to escape escape characters - * @property {boolean} [heading=false] Whether to escape headings - * @property {boolean} [bulletedList=false] Whether to escape bulleted lists - * @property {boolean} [numberedList=false] Whether to escape numbered lists - * @property {boolean} [maskedLink=false] Whether to escape masked links - */ - -/** - * Escapes any Discord-flavour markdown in a string. - * @param {string} text Content to escape - * @param {EscapeMarkdownOptions} [options={}] Options for escaping the markdown - * @returns {string} - */ -function escapeMarkdown( - text, - { - codeBlock = true, - inlineCode = true, - bold = true, - italic = true, - underline = true, - strikethrough = true, - spoiler = true, - codeBlockContent = true, - inlineCodeContent = true, - escape = true, - heading = false, - bulletedList = false, - numberedList = false, - maskedLink = false, - } = {}, -) { - if (!codeBlockContent) { - return text - .split('```') - .map((subString, index, array) => { - if (index % 2 && index !== array.length - 1) return subString; - return escapeMarkdown(subString, { - inlineCode, - bold, - italic, - underline, - strikethrough, - spoiler, - inlineCodeContent, - escape, - heading, - bulletedList, - numberedList, - maskedLink, - }); - }) - .join(codeBlock ? '\\`\\`\\`' : '```'); - } - if (!inlineCodeContent) { - return text - .split(/(?<=^|[^`])`(?=[^`]|$)/g) - .map((subString, index, array) => { - if (index % 2 && index !== array.length - 1) return subString; - return escapeMarkdown(subString, { - codeBlock, - bold, - italic, - underline, - strikethrough, - spoiler, - escape, - heading, - bulletedList, - numberedList, - maskedLink, - }); - }) - .join(inlineCode ? '\\`' : '`'); - } - if (escape) text = escapeEscape(text); - if (inlineCode) text = escapeInlineCode(text); - if (codeBlock) text = escapeCodeBlock(text); - if (italic) text = escapeItalic(text); - if (bold) text = escapeBold(text); - if (underline) text = escapeUnderline(text); - if (strikethrough) text = escapeStrikethrough(text); - if (spoiler) text = escapeSpoiler(text); - if (heading) text = escapeHeading(text); - if (bulletedList) text = escapeBulletedList(text); - if (numberedList) text = escapeNumberedList(text); - if (maskedLink) text = escapeMaskedLink(text); - return text; -} - -/** - * Escapes code block markdown in a string. - * @param {string} text Content to escape - * @returns {string} - */ -function escapeCodeBlock(text) { - return text.replaceAll('```', '\\`\\`\\`'); -} - -/** - * Escapes inline code markdown in a string. - * @param {string} text Content to escape - * @returns {string} - */ -function escapeInlineCode(text) { - return text.replace(/(?<=^|[^`])``?(?=[^`]|$)/g, match => (match.length === 2 ? '\\`\\`' : '\\`')); -} - -/** - * Escapes italic markdown in a string. - * @param {string} text Content to escape - * @returns {string} - */ -function 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} - */ -function 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} - */ -function 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} - */ -function escapeStrikethrough(text) { - return text.replaceAll('~~', '\\~\\~'); -} - -/** - * Escapes spoiler markdown in a string. - * @param {string} text Content to escape - * @returns {string} - */ -function escapeSpoiler(text) { - return text.replaceAll('||', '\\|\\|'); -} - -/** - * Escapes escape characters in a string. - * @param {string} text Content to escape - * @returns {string} - */ -function escapeEscape(text) { - return text.replaceAll('\\', '\\\\'); -} - -/** - * Escapes heading characters in a string. - * @param {string} text Content to escape - * @returns {string} - */ -function escapeHeading(text) { - return text.replaceAll(/^( {0,2}[*-] +)?(#{1,3} )/gm, '$1\\$2'); -} - -/** - * Escapes bulleted list characters in a string. - * @param {string} text Content to escape - * @returns {string} - */ -function escapeBulletedList(text) { - return text.replaceAll(/^( *)[*-]( +)/gm, '$1\\-$2'); -} - -/** - * Escapes numbered list characters in a string. - * @param {string} text Content to escape - * @returns {string} - */ -function escapeNumberedList(text) { - return text.replaceAll(/^( *\d+)\./gm, '$1\\.'); -} - -/** - * Escapes masked link characters in a string. - * @param {string} text Content to escape - * @returns {string} - */ -function escapeMaskedLink(text) { - return text.replaceAll(/\[.+\]\(.+\)/gm, '\\$&'); -} - /** * @typedef {Object} FetchRecommendedShardCountOptions * @property {number} [guildsPerShard=1000] Number of guilds assigned per shard @@ -600,18 +373,6 @@ function parseWebhookURL(url) { module.exports = { flatten, - escapeMarkdown, - escapeCodeBlock, - escapeInlineCode, - escapeItalic, - escapeBold, - escapeUnderline, - escapeStrikethrough, - escapeSpoiler, - escapeHeading, - escapeBulletedList, - escapeNumberedList, - escapeMaskedLink, fetchRecommendedShardCount, parseEmoji, resolvePartialEmoji, diff --git a/packages/discord.js/test/escapeMarkdown.test.js b/packages/discord.js/test/escapeMarkdown.test.js deleted file mode 100644 index 9cc6f731acd4..000000000000 --- a/packages/discord.js/test/escapeMarkdown.test.js +++ /dev/null @@ -1,254 +0,0 @@ -'use strict'; - -/* eslint-env jest */ -/* eslint-disable max-len */ - -const Util = require('../src/util/Util'); -const testString = "`_Behold!_`\n||___~~***```js\n`use strict`;\nrequire('discord.js');```***~~___||"; -const testStringForums = - '# Title\n## Subtitle\n### Subsubtitle\n- Bullet list\n - # Title with bullet\n * Subbullet\n1. Number list\n 1. Sub number list'; - -describe('escapeCodeblock', () => { - test('shared', () => { - expect(Util.escapeCodeBlock(testString)).toEqual( - "`_Behold!_`\n||___~~***\\`\\`\\`js\n`use strict`;\nrequire('discord.js');\\`\\`\\`***~~___||", - ); - }); - - test('basic', () => { - expect(Util.escapeCodeBlock('```test```')).toEqual('\\`\\`\\`test\\`\\`\\`'); - }); -}); - -describe('escapeInlineCode', () => { - test('shared', () => { - expect(Util.escapeInlineCode(testString)).toEqual( - "\\`_Behold!_\\`\n||___~~***```js\n\\`use strict\\`;\nrequire('discord.js');```***~~___||", - ); - }); - - test('basic', () => { - expect(Util.escapeInlineCode('`test`')).toEqual('\\`test\\`'); - }); -}); - -describe('escapeBold', () => { - test('shared', () => { - expect(Util.escapeBold(testString)).toEqual( - "`_Behold!_`\n||___~~*\\*\\*```js\n`use strict`;\nrequire('discord.js');```\\*\\**~~___||", - ); - }); - - test('basic', () => { - expect(Util.escapeBold('**test**')).toEqual('\\*\\*test\\*\\*'); - }); -}); - -describe('escapeItalic', () => { - test('shared', () => { - expect(Util.escapeItalic(testString)).toEqual( - "`\\_Behold!\\_`\n||\\___~~\\***```js\n`use strict`;\nrequire('discord.js');```**\\*~~__\\_||", - ); - }); - - test('basic (_)', () => { - expect(Util.escapeItalic('_test_')).toEqual('\\_test\\_'); - }); - - test('basic (*)', () => { - expect(Util.escapeItalic('*test*')).toEqual('\\*test\\*'); - }); - - test('emoji', () => { - const testOne = 'This is a test with _emojis_ <:Frost_ed_Wreath:1053399941210443826> and **bold text**.'; - expect(Util.escapeItalic(testOne)).toEqual( - 'This is a test with \\_emojis\\_ <:Frost_ed_Wreath:1053399941210443826> and **bold text**.', - ); - }); -}); - -describe('escapeUnderline', () => { - test('shared', () => { - expect(Util.escapeUnderline(testString)).toEqual( - "`_Behold!_`\n||_\\_\\_~~***```js\n`use strict`;\nrequire('discord.js');```***~~\\_\\__||", - ); - }); - - test('basic', () => { - expect(Util.escapeUnderline('__test__')).toEqual('\\_\\_test\\_\\_'); - }); - - test('emoji', () => { - const testTwo = 'This is a test with __emojis__ <:Frost__ed__Wreath:1053399939654352978> and **bold text**.'; - expect(Util.escapeUnderline(testTwo)).toBe( - 'This is a test with \\_\\_emojis\\_\\_ <:Frost__ed__Wreath:1053399939654352978> and **bold text**.', - ); - }); -}); - -describe('escapeStrikethrough', () => { - test('shared', () => { - expect(Util.escapeStrikethrough(testString)).toEqual( - "`_Behold!_`\n||___\\~\\~***```js\n`use strict`;\nrequire('discord.js');```***\\~\\~___||", - ); - }); - - test('basic', () => { - expect(Util.escapeStrikethrough('~~test~~')).toEqual('\\~\\~test\\~\\~'); - }); -}); - -describe('escapeSpoiler', () => { - test('shared', () => { - expect(Util.escapeSpoiler(testString)).toEqual( - "`_Behold!_`\n\\|\\|___~~***```js\n`use strict`;\nrequire('discord.js');```***~~___\\|\\|", - ); - }); - - test('basic', () => { - expect(Util.escapeSpoiler('||test||')).toEqual('\\|\\|test\\|\\|'); - }); -}); - -describe('escapeHeading', () => { - test('shared', () => { - expect(Util.escapeHeading(testStringForums)).toEqual( - '\\# Title\n\\## Subtitle\n\\### Subsubtitle\n- Bullet list\n - \\# Title with bullet\n * Subbullet\n1. Number list\n 1. Sub number list', - ); - }); - - test('basic', () => { - expect(Util.escapeHeading('# test')).toEqual('\\# test'); - }); -}); - -describe('escapeBulletedList', () => { - test('shared', () => { - expect(Util.escapeBulletedList(testStringForums)).toEqual( - '# Title\n## Subtitle\n### Subsubtitle\n\\- Bullet list\n \\- # Title with bullet\n \\* Subbullet\n1. Number list\n 1. Sub number list', - ); - }); - - test('basic', () => { - expect(Util.escapeBulletedList('- test')).toEqual('\\- test'); - }); -}); - -describe('escapeNumberedList', () => { - test('shared', () => { - expect(Util.escapeNumberedList(testStringForums)).toEqual( - '# Title\n## Subtitle\n### Subsubtitle\n- Bullet list\n - # Title with bullet\n * Subbullet\n1\\. Number list\n 1\\. Sub number list', - ); - }); - - test('basic', () => { - expect(Util.escapeNumberedList('1. test')).toEqual('1\\. test'); - }); -}); - -describe('escapeMaskedLink', () => { - test('basic', () => { - expect(Util.escapeMaskedLink('[test](https://discord.js.org)')).toEqual('\\[test](https://discord.js.org)'); - }); -}); - -describe('escapeMarkdown', () => { - test('shared', () => { - expect(Util.escapeMarkdown(testString)).toEqual( - "\\`\\_Behold!\\_\\`\n\\|\\|\\_\\_\\_\\~\\~\\*\\*\\*\\`\\`\\`js\n\\`use strict\\`;\nrequire('discord.js');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|", - ); - }); - - test('no codeBlock', () => { - expect(Util.escapeMarkdown(testString, { codeBlock: false })).toEqual( - "\\`\\_Behold!\\_\\`\n\\|\\|\\_\\_\\_\\~\\~\\*\\*\\*```js\n\\`use strict\\`;\nrequire('discord.js');```\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|", - ); - }); - - test('no inlineCode', () => { - expect(Util.escapeMarkdown(testString, { inlineCode: false })).toEqual( - "`\\_Behold!\\_`\n\\|\\|\\_\\_\\_\\~\\~\\*\\*\\*\\`\\`\\`js\n`use strict`;\nrequire('discord.js');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|", - ); - }); - - test('no bold', () => { - expect(Util.escapeMarkdown(testString, { bold: false })).toEqual( - "\\`\\_Behold!\\_\\`\n\\|\\|\\_\\_\\_\\~\\~\\***\\`\\`\\`js\n\\`use strict\\`;\nrequire('discord.js');\\`\\`\\`**\\*\\~\\~\\_\\_\\_\\|\\|", - ); - }); - - test('no italic', () => { - expect(Util.escapeMarkdown(testString, { italic: false })).toEqual( - "\\`_Behold!_\\`\n\\|\\|_\\_\\_\\~\\~*\\*\\*\\`\\`\\`js\n\\`use strict\\`;\nrequire('discord.js');\\`\\`\\`\\*\\**\\~\\~\\_\\__\\|\\|", - ); - }); - - test('no underline', () => { - expect(Util.escapeMarkdown(testString, { underline: false })).toEqual( - "\\`\\_Behold!\\_\\`\n\\|\\|\\___\\~\\~\\*\\*\\*\\`\\`\\`js\n\\`use strict\\`;\nrequire('discord.js');\\`\\`\\`\\*\\*\\*\\~\\~__\\_\\|\\|", - ); - }); - - test('no strikethrough', () => { - expect(Util.escapeMarkdown(testString, { strikethrough: false })).toEqual( - "\\`\\_Behold!\\_\\`\n\\|\\|\\_\\_\\_~~\\*\\*\\*\\`\\`\\`js\n\\`use strict\\`;\nrequire('discord.js');\\`\\`\\`\\*\\*\\*~~\\_\\_\\_\\|\\|", - ); - }); - - test('no spoiler', () => { - expect(Util.escapeMarkdown(testString, { spoiler: false })).toEqual( - "\\`\\_Behold!\\_\\`\n||\\_\\_\\_\\~\\~\\*\\*\\*\\`\\`\\`js\n\\`use strict\\`;\nrequire('discord.js');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_||", - ); - }); - - describe('code content', () => { - test('no code block content', () => { - expect(Util.escapeMarkdown(testString, { codeBlockContent: false })).toEqual( - "\\`\\_Behold!\\_\\`\n\\|\\|\\_\\_\\_\\~\\~\\*\\*\\*\\`\\`\\`js\n`use strict`;\nrequire('discord.js');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|", - ); - }); - - test('no inline code content', () => { - expect(Util.escapeMarkdown(testString, { inlineCodeContent: false })).toEqual( - "\\`_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 - .toEqual( - "\\`_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 })).toEqual( - "\\`\\_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 })).toEqual( - "`_Behold!_`\n\\|\\|\\_\\_\\_\\~\\~\\*\\*\\*\\`\\`\\`js\n`use strict`;\nrequire('discord.js');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|", - ); - }); - - test('edge-case odd number of fences with no code block content', () => { - expect( - Util.escapeMarkdown('**foo** ```**bar**``` **fizz** ``` **buzz**', { - codeBlock: false, - codeBlockContent: false, - }), - ).toEqual('\\*\\*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 }), - ).toEqual('\\*\\*foo\\*\\* `**bar**` \\*\\*fizz\\*\\* ` \\*\\*buzz\\*\\*'); - }); - }); -}); - -/* eslint-enable max-len, no-undef */ diff --git a/packages/discord.js/typings/index.d.ts b/packages/discord.js/typings/index.d.ts index 4b4c9dface72..128d20839db8 100644 --- a/packages/discord.js/typings/index.d.ts +++ b/packages/discord.js/typings/index.d.ts @@ -3051,19 +3051,6 @@ export function cleanContent(str: string, channel: TextBasedChannel): string; export function discordSort( collection: Collection, ): Collection; -export function escapeMarkdown(text: string, options?: EscapeMarkdownOptions): string; -export function escapeCodeBlock(text: string): string; -export function escapeInlineCode(text: string): string; -export function escapeBold(text: string): string; -export function escapeItalic(text: string): string; -export function escapeUnderline(text: string): string; -export function escapeStrikethrough(text: string): string; -export function escapeSpoiler(text: string): string; -export function escapeEscape(text: string): string; -export function escapeHeading(text: string): string; -export function escapeBulletedList(text: string): string; -export function escapeNumberedList(text: string): string; -export function escapeMaskedLink(text: string): string; export function cleanCodeBlockContent(text: string): string; export function fetchRecommendedShardCount(token: string, options?: FetchRecommendedShardCountOptions): Promise; export function flatten(obj: unknown, ...props: Record[]): unknown; diff --git a/packages/formatters/__tests__/escapers.test.ts b/packages/formatters/__tests__/escapers.test.ts new file mode 100644 index 000000000000..11cb83c1bff7 --- /dev/null +++ b/packages/formatters/__tests__/escapers.test.ts @@ -0,0 +1,264 @@ +import { describe, test, expect } from 'vitest'; +import { + escapeCodeBlock, + escapeInlineCode, + escapeItalic, + escapeBold, + escapeUnderline, + escapeStrikethrough, + escapeMaskedLink, + escapeSpoiler, + escapeHeading, + escapeBulletedList, + escapeNumberedList, + escapeMarkdown, +} from '../src/index.js'; + +const testString = "`_Behold!_`\n||___~~***```js\n`use strict`;\nrequire('discord.js');```***~~___||"; +const testStringForums = + '# Title\n## Subtitle\n### Subsubtitle\n- Bullet list\n - # Title with bullet\n * Subbullet\n1. Number list\n 1. Sub number list'; + +describe('Markdown escapers', () => { + describe('escapeCodeblock', () => { + test('shared', () => { + expect(escapeCodeBlock(testString)).toEqual( + "`_Behold!_`\n||___~~***\\`\\`\\`js\n`use strict`;\nrequire('discord.js');\\`\\`\\`***~~___||", + ); + }); + + test('basic', () => { + expect(escapeCodeBlock('```test```')).toEqual('\\`\\`\\`test\\`\\`\\`'); + }); + }); + + describe('escapeInlineCode', () => { + test('shared', () => { + expect(escapeInlineCode(testString)).toEqual( + "\\`_Behold!_\\`\n||___~~***```js\n\\`use strict\\`;\nrequire('discord.js');```***~~___||", + ); + }); + + test('basic', () => { + expect(escapeInlineCode('`test`')).toEqual('\\`test\\`'); + }); + }); + + describe('escapeBold', () => { + test('shared', () => { + expect(escapeBold(testString)).toEqual( + "`_Behold!_`\n||___~~*\\*\\*```js\n`use strict`;\nrequire('discord.js');```\\*\\**~~___||", + ); + }); + + test('basic', () => { + expect(escapeBold('**test**')).toEqual('\\*\\*test\\*\\*'); + }); + }); + + describe('escapeItalic', () => { + test('shared', () => { + expect(escapeItalic(testString)).toEqual( + "`\\_Behold!\\_`\n||\\___~~\\***```js\n`use strict`;\nrequire('discord.js');```**\\*~~__\\_||", + ); + }); + + test('basic (_)', () => { + expect(escapeItalic('_test_')).toEqual('\\_test\\_'); + }); + + test('basic (*)', () => { + expect(escapeItalic('*test*')).toEqual('\\*test\\*'); + }); + + test('emoji', () => { + const testOne = 'This is a test with _emojis_ <:Frost_ed_Wreath:1053399941210443826> and **bold text**.'; + expect(escapeItalic(testOne)).toEqual( + 'This is a test with \\_emojis\\_ <:Frost_ed_Wreath:1053399941210443826> and **bold text**.', + ); + }); + }); + + describe('escapeUnderline', () => { + test('shared', () => { + expect(escapeUnderline(testString)).toEqual( + "`_Behold!_`\n||_\\_\\_~~***```js\n`use strict`;\nrequire('discord.js');```***~~\\_\\__||", + ); + }); + + test('basic', () => { + expect(escapeUnderline('__test__')).toEqual('\\_\\_test\\_\\_'); + }); + + test('emoji', () => { + const testTwo = 'This is a test with __emojis__ <:Frost__ed__Wreath:1053399939654352978> and **bold text**.'; + expect(escapeUnderline(testTwo)).toBe( + 'This is a test with \\_\\_emojis\\_\\_ <:Frost__ed__Wreath:1053399939654352978> and **bold text**.', + ); + }); + }); + + describe('escapeStrikethrough', () => { + test('shared', () => { + expect(escapeStrikethrough(testString)).toEqual( + "`_Behold!_`\n||___\\~\\~***```js\n`use strict`;\nrequire('discord.js');```***\\~\\~___||", + ); + }); + + test('basic', () => { + expect(escapeStrikethrough('~~test~~')).toEqual('\\~\\~test\\~\\~'); + }); + }); + + describe('escapeSpoiler', () => { + test('shared', () => { + expect(escapeSpoiler(testString)).toEqual( + "`_Behold!_`\n\\|\\|___~~***```js\n`use strict`;\nrequire('discord.js');```***~~___\\|\\|", + ); + }); + + test('basic', () => { + expect(escapeSpoiler('||test||')).toEqual('\\|\\|test\\|\\|'); + }); + }); + + describe('escapeHeading', () => { + test('shared', () => { + expect(escapeHeading(testStringForums)).toEqual( + '\\# Title\n\\## Subtitle\n\\### Subsubtitle\n- Bullet list\n - \\# Title with bullet\n * Subbullet\n1. Number list\n 1. Sub number list', + ); + }); + + test('basic', () => { + expect(escapeHeading('# test')).toEqual('\\# test'); + }); + }); + + describe('escapeBulletedList', () => { + test('shared', () => { + expect(escapeBulletedList(testStringForums)).toEqual( + '# Title\n## Subtitle\n### Subsubtitle\n\\- Bullet list\n \\- # Title with bullet\n \\* Subbullet\n1. Number list\n 1. Sub number list', + ); + }); + + test('basic', () => { + expect(escapeBulletedList('- test')).toEqual('\\- test'); + }); + }); + + describe('escapeNumberedList', () => { + test('shared', () => { + expect(escapeNumberedList(testStringForums)).toEqual( + '# Title\n## Subtitle\n### Subsubtitle\n- Bullet list\n - # Title with bullet\n * Subbullet\n1\\. Number list\n 1\\. Sub number list', + ); + }); + + test('basic', () => { + expect(escapeNumberedList('1. test')).toEqual('1\\. test'); + }); + }); + + describe('escapeMaskedLink', () => { + test('basic', () => { + expect(escapeMaskedLink('[test](https://discord.js.org)')).toEqual('\\[test](https://discord.js.org)'); + }); + }); + + describe('escapeMarkdown', () => { + test('shared', () => { + expect(escapeMarkdown(testString)).toEqual( + "\\`\\_Behold!\\_\\`\n\\|\\|\\_\\_\\_\\~\\~\\*\\*\\*\\`\\`\\`js\n\\`use strict\\`;\nrequire('discord.js');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|", + ); + }); + + test('no codeBlock', () => { + expect(escapeMarkdown(testString, { codeBlock: false })).toEqual( + "\\`\\_Behold!\\_\\`\n\\|\\|\\_\\_\\_\\~\\~\\*\\*\\*```js\n\\`use strict\\`;\nrequire('discord.js');```\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|", + ); + }); + + test('no inlineCode', () => { + expect(escapeMarkdown(testString, { inlineCode: false })).toEqual( + "`\\_Behold!\\_`\n\\|\\|\\_\\_\\_\\~\\~\\*\\*\\*\\`\\`\\`js\n`use strict`;\nrequire('discord.js');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|", + ); + }); + + test('no bold', () => { + expect(escapeMarkdown(testString, { bold: false })).toEqual( + "\\`\\_Behold!\\_\\`\n\\|\\|\\_\\_\\_\\~\\~\\***\\`\\`\\`js\n\\`use strict\\`;\nrequire('discord.js');\\`\\`\\`**\\*\\~\\~\\_\\_\\_\\|\\|", + ); + }); + + test('no italic', () => { + expect(escapeMarkdown(testString, { italic: false })).toEqual( + "\\`_Behold!_\\`\n\\|\\|_\\_\\_\\~\\~*\\*\\*\\`\\`\\`js\n\\`use strict\\`;\nrequire('discord.js');\\`\\`\\`\\*\\**\\~\\~\\_\\__\\|\\|", + ); + }); + + test('no underline', () => { + expect(escapeMarkdown(testString, { underline: false })).toEqual( + "\\`\\_Behold!\\_\\`\n\\|\\|\\___\\~\\~\\*\\*\\*\\`\\`\\`js\n\\`use strict\\`;\nrequire('discord.js');\\`\\`\\`\\*\\*\\*\\~\\~__\\_\\|\\|", + ); + }); + + test('no strikethrough', () => { + expect(escapeMarkdown(testString, { strikethrough: false })).toEqual( + "\\`\\_Behold!\\_\\`\n\\|\\|\\_\\_\\_~~\\*\\*\\*\\`\\`\\`js\n\\`use strict\\`;\nrequire('discord.js');\\`\\`\\`\\*\\*\\*~~\\_\\_\\_\\|\\|", + ); + }); + + test('no spoiler', () => { + expect(escapeMarkdown(testString, { spoiler: false })).toEqual( + "\\`\\_Behold!\\_\\`\n||\\_\\_\\_\\~\\~\\*\\*\\*\\`\\`\\`js\n\\`use strict\\`;\nrequire('discord.js');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_||", + ); + }); + + describe('code content', () => { + test('no code block content', () => { + expect(escapeMarkdown(testString, { codeBlockContent: false })).toEqual( + "\\`\\_Behold!\\_\\`\n\\|\\|\\_\\_\\_\\~\\~\\*\\*\\*\\`\\`\\`js\n`use strict`;\nrequire('discord.js');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|", + ); + }); + + test('no inline code content', () => { + expect(escapeMarkdown(testString, { inlineCodeContent: false })).toEqual( + "\\`_Behold!_\\`\n\\|\\|\\_\\_\\_\\~\\~\\*\\*\\*\\`\\`\\`js\n\\`use strict\\`;\nrequire('discord.js');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|", + ); + }); + + test('neither inline code or code block content', () => { + expect(escapeMarkdown(testString, { inlineCodeContent: false, codeBlockContent: false })) + // eslint-disable-next-line max-len + .toEqual( + "\\`_Behold!_\\`\n\\|\\|\\_\\_\\_\\~\\~\\*\\*\\*\\`\\`\\`js\n`use strict`;\nrequire('discord.js');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|", + ); + }); + + test('neither code blocks or code block content', () => { + expect(escapeMarkdown(testString, { codeBlock: false, codeBlockContent: false })).toEqual( + "\\`\\_Behold!\\_\\`\n\\|\\|\\_\\_\\_\\~\\~\\*\\*\\*```js\n`use strict`;\nrequire('discord.js');```\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|", + ); + }); + + test('neither inline code or inline code content', () => { + expect(escapeMarkdown(testString, { inlineCode: false, inlineCodeContent: false })).toEqual( + "`_Behold!_`\n\\|\\|\\_\\_\\_\\~\\~\\*\\*\\*\\`\\`\\`js\n`use strict`;\nrequire('discord.js');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|", + ); + }); + + test('edge-case odd number of fences with no code block content', () => { + expect( + escapeMarkdown('**foo** ```**bar**``` **fizz** ``` **buzz**', { + codeBlock: false, + codeBlockContent: false, + }), + ).toEqual('\\*\\*foo\\*\\* ```**bar**``` \\*\\*fizz\\*\\* ``` \\*\\*buzz\\*\\*'); + }); + + test('edge-case odd number of backticks with no inline code content', () => { + expect( + escapeMarkdown('**foo** `**bar**` **fizz** ` **buzz**', { inlineCode: false, inlineCodeContent: false }), + ).toEqual('\\*\\*foo\\*\\* `**bar**` \\*\\*fizz\\*\\* ` \\*\\*buzz\\*\\*'); + }); + }); + }); +}); diff --git a/packages/formatters/src/escapers.ts b/packages/formatters/src/escapers.ts new file mode 100644 index 000000000000..3575c0a3d661 --- /dev/null +++ b/packages/formatters/src/escapers.ts @@ -0,0 +1,310 @@ +/* eslint-disable prefer-named-capture-group */ + +export interface EscapeMarkdownOptions { + /** + * Whether to escape bolds + * + * @defaultValue true + */ + bold?: boolean; + + /** + * Whether to escape bulleted lists + * + * @defaultValue false + */ + bulletedList?: boolean; + + /** + * Whether to escape code blocks + * + * @defaultValue true + */ + codeBlock?: boolean; + + /** + * Whether to escape text inside code blocks + * + * @defaultValue true + */ + codeBlockContent?: boolean; + + /** + * Whether to escape escape characters + * + * @defaultValue true + */ + escape?: boolean; + + /** + * Whether to escape headings + * + * @defaultValue false + */ + heading?: boolean; + + /** + * Whether to escape inline code + * + * @defaultValue true + */ + inlineCode?: boolean; + + /** + * Whether to escape text inside inline code + * + * @defaultValue true + */ + inlineCodeContent?: boolean; + /** + * Whether to escape italics + * + * @defaultValue true + */ + italic?: boolean; + + /** + * Whether to escape masked links + * + * @defaultValue false + */ + maskedLink?: boolean; + + /** + * Whether to escape numbered lists + * + * @defaultValue false + */ + numberedList?: boolean; + + /** + * Whether to escape spoilers + * + * @defaultValue true + */ + spoiler?: boolean; + + /** + * Whether to escape strikethroughs + * + * @defaultValue true + */ + strikethrough?: boolean; + + /** + * Whether to escape underlines + * + * @defaultValue true + */ + underline?: boolean; +} + +/** + * Escapes any Discord-flavour markdown in a string. + * + * @param text - Content to escape + * @param options - Options for escaping the markdown + */ +export function escapeMarkdown(text: string, options: EscapeMarkdownOptions = {}): string { + const { + codeBlock = true, + inlineCode = true, + bold = true, + italic = true, + underline = true, + strikethrough = true, + spoiler = true, + codeBlockContent = true, + inlineCodeContent = true, + escape = true, + heading = false, + bulletedList = false, + numberedList = false, + maskedLink = false, + } = options; + + if (!codeBlockContent) { + return text + .split('```') + .map((subString, index, array) => { + if (index % 2 && index !== array.length - 1) return subString; + return escapeMarkdown(subString, { + inlineCode, + bold, + italic, + underline, + strikethrough, + spoiler, + inlineCodeContent, + escape, + heading, + bulletedList, + numberedList, + maskedLink, + }); + }) + .join(codeBlock ? '\\`\\`\\`' : '```'); + } + + if (!inlineCodeContent) { + return text + .split(/(?<=^|[^`])`(?=[^`]|$)/g) + .map((subString, index, array) => { + if (index % 2 && index !== array.length - 1) return subString; + return escapeMarkdown(subString, { + codeBlock, + bold, + italic, + underline, + strikethrough, + spoiler, + escape, + heading, + bulletedList, + numberedList, + maskedLink, + }); + }) + .join(inlineCode ? '\\`' : '`'); + } + + let res = text; + if (escape) res = escapeEscape(res); + if (inlineCode) res = escapeInlineCode(res); + if (codeBlock) res = escapeCodeBlock(res); + if (italic) res = escapeItalic(res); + if (bold) res = escapeBold(res); + if (underline) res = escapeUnderline(res); + if (strikethrough) res = escapeStrikethrough(res); + if (spoiler) res = escapeSpoiler(res); + if (heading) res = escapeHeading(res); + if (bulletedList) res = escapeBulletedList(res); + if (numberedList) res = escapeNumberedList(res); + if (maskedLink) res = escapeMaskedLink(res); + return res; +} + +/** + * Escapes code block markdown in a string. + * + * @param text - Content to escape + */ +export function escapeCodeBlock(text: string): string { + return text.replaceAll('```', '\\`\\`\\`'); +} + +/** + * Escapes inline code markdown in a string. + * + * @param text - Content to escape + */ +export function escapeInlineCode(text: string): string { + return text.replaceAll(/(?<=^|[^`])``?(?=[^`]|$)/g, (match) => (match.length === 2 ? '\\`\\`' : '\\`')); +} + +/** + * Escapes italic markdown in a string. + * + * @param text - Content to escape + */ +export function escapeItalic(text: string): string { + let idx = 0; + const newText = text.replaceAll(/(?<=^|[^*])\*([^*]|\*\*|$)/g, (_, match) => { + if (match === '**') return ++idx % 2 ? `\\*${match}` : `${match}\\*`; + return `\\*${match}`; + }); + idx = 0; + return newText.replaceAll(/(?<=^|[^_])(?)([^_]|__|$)/g, (_, match) => { + if (match === '__') return ++idx % 2 ? `\\_${match}` : `${match}\\_`; + return `\\_${match}`; + }); +} + +/** + * Escapes bold markdown in a string. + * + * @param text - Content to escape + */ +export function escapeBold(text: string): string { + let idx = 0; + return text.replaceAll(/\*\*(\*)?/g, (_, match) => { + if (match) return ++idx % 2 ? `${match}\\*\\*` : `\\*\\*${match}`; + return '\\*\\*'; + }); +} + +/** + * Escapes underline markdown in a string. + * + * @param text - Content to escape + */ +export function escapeUnderline(text: string): string { + let idx = 0; + return text.replaceAll(/(?)/g, (_, match) => { + if (match) return ++idx % 2 ? `${match}\\_\\_` : `\\_\\_${match}`; + return '\\_\\_'; + }); +} + +/** + * Escapes strikethrough markdown in a string. + * + * @param text - Content to escape + */ +export function escapeStrikethrough(text: string): string { + return text.replaceAll('~~', '\\~\\~'); +} + +/** + * Escapes spoiler markdown in a string. + * + * @param text - Content to escape + */ +export function escapeSpoiler(text: string): string { + return text.replaceAll('||', '\\|\\|'); +} + +/** + * Escapes escape characters in a string. + * + * @param text - Content to escape + */ +export function escapeEscape(text: string): string { + return text.replaceAll('\\', '\\\\'); +} + +/** + * Escapes heading characters in a string. + * + * @param text - Content to escape + */ +export function escapeHeading(text: string): string { + return text.replaceAll(/^( {0,2})([*-] )?( *)(#{1,3} )/gm, '$1$2$3\\$4'); +} + +/** + * Escapes bulleted list characters in a string. + * + * @param text - Content to escape + */ +export function escapeBulletedList(text: string): string { + return text.replaceAll(/^( *)([*-])( +)/gm, '$1\\$2$3'); +} + +/** + * Escapes numbered list characters in a string. + * + * @param text - Content to escape + */ +export function escapeNumberedList(text: string): string { + return text.replaceAll(/^( *\d+)\./gm, '$1\\.'); +} + +/** + * Escapes masked link characters in a string. + * + * @param text - Content to escape + */ +export function escapeMaskedLink(text: string): string { + return text.replaceAll(/\[.+]\(.+\)/gm, '\\$&'); +} diff --git a/packages/formatters/src/index.ts b/packages/formatters/src/index.ts index 8e673d4c162b..ca131c9f6899 100644 --- a/packages/formatters/src/index.ts +++ b/packages/formatters/src/index.ts @@ -1 +1,2 @@ +export * from './escapers.js'; export * from './formatters.js';