diff --git a/src/util/Util.js b/src/util/Util.js index 5ff5c6bea969..658864fff163 100644 --- a/src/util/Util.js +++ b/src/util/Util.js @@ -115,15 +115,20 @@ class Util extends null { /** * Options used to escape markdown. * @typedef {Object} EscapeMarkdownOptions - * @property {boolean} [codeBlock=true] Whether to escape code blocks or not - * @property {boolean} [inlineCode=true] Whether to escape inline code or not - * @property {boolean} [bold=true] Whether to escape bolds or not - * @property {boolean} [italic=true] Whether to escape italics or not - * @property {boolean} [underline=true] Whether to escape underlines or not - * @property {boolean} [strikethrough=true] Whether to escape strikethroughs or not - * @property {boolean} [spoiler=true] Whether to escape spoilers or not - * @property {boolean} [codeBlockContent=true] Whether to escape text inside code blocks or not - * @property {boolean} [inlineCodeContent=true] Whether to escape text inside inline code or not + * @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 */ /** @@ -144,6 +149,11 @@ class Util extends null { spoiler = true, codeBlockContent = true, inlineCodeContent = true, + escape = true, + heading = false, + bulletedList = false, + numberedList = false, + maskedLink = false, } = {}, ) { if (!codeBlockContent) { @@ -159,6 +169,11 @@ class Util extends null { strikethrough, spoiler, inlineCodeContent, + escape, + heading, + bulletedList, + numberedList, + maskedLink, }); }) .join(codeBlock ? '\\`\\`\\`' : '```'); @@ -175,6 +190,11 @@ class Util extends null { underline, strikethrough, spoiler, + escape, + heading, + bulletedList, + numberedList, + maskedLink, }); }) .join(inlineCode ? '\\`' : '`'); @@ -186,6 +206,11 @@ class Util extends null { if (underline) text = Util.escapeUnderline(text); if (strikethrough) text = Util.escapeStrikethrough(text); if (spoiler) text = Util.escapeSpoiler(text); + if (escape) text = Util.escapeEscape(text); + if (heading) text = Util.escapeHeading(text); + if (bulletedList) text = Util.escapeBulletedList(text); + if (numberedList) text = Util.escapeNumberedList(text); + if (maskedLink) text = Util.escapeMaskedLink(text); return text; } @@ -204,7 +229,7 @@ class Util extends null { * @returns {string} */ static escapeInlineCode(text) { - return text.replace(/(?<=^|[^`])`(?=[^`]|$)/g, '\\`'); + return text.replace(/(?<=^|[^`])``?(?=[^`]|$)/g, match => (match.length === 2 ? '\\`\\`' : '\\`')); } /** @@ -269,6 +294,51 @@ class Util extends null { return text.replaceAll('||', '\\|\\|'); } + /** + * Escapes escape characters in a string. + * @param {string} text Content to escape + * @returns {string} + */ + static escapeEscape(text) { + return text.replaceAll('\\', '\\\\'); + } + + /** + * Escapes heading characters in a string. + * @param {string} text Content to escape + * @returns {string} + */ + static 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} + */ + static escapeBulletedList(text) { + return text.replaceAll(/^( *)[*-]( +)/gm, '$1\\-$2'); + } + + /** + * Escapes numbered list characters in a string. + * @param {string} text Content to escape + * @returns {string} + */ + static escapeNumberedList(text) { + return text.replaceAll(/^( *\d+)\./gm, '$1\\.'); + } + + /** + * Escapes masked link characters in a string. + * @param {string} text Content to escape + * @returns {string} + */ + static escapeMaskedLink(text) { + return text.replaceAll(/\[.+\]\(.+\)/gm, '\\$&'); + } + /** * @typedef {Object} FetchRecommendedShardsOptions * @property {number} [guildsPerShard=1000] Number of guilds assigned per shard diff --git a/test/escapeMarkdown.test.js b/test/escapeMarkdown.test.js index 4c7ca180fe21..878df9f7deca 100644 --- a/test/escapeMarkdown.test.js +++ b/test/escapeMarkdown.test.js @@ -5,6 +5,8 @@ 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', () => { @@ -94,6 +96,48 @@ describe('escapeSpoiler', () => { }); }); +describe('escapeHeading', () => { + test('shared', () => { + expect(Util.escapeHeading(testStringForums)).toBe( + '\\# 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')).toBe('\\# test'); + }); +}); + +describe('escapeBulletedList', () => { + test('shared', () => { + expect(Util.escapeBulletedList(testStringForums)).toBe( + '# 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')).toBe('\\- test'); + }); +}); + +describe('escapeNumberedList', () => { + test('shared', () => { + expect(Util.escapeNumberedList(testStringForums)).toBe( + '# 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')).toBe('1\\. test'); + }); +}); + +describe('escapeMaskedLink', () => { + test('basic', () => { + expect(Util.escapeMaskedLink('[test](https://discord.js.org)')).toBe('\\[test](https://discord.js.org)'); + }); +}); + describe('escapeMarkdown', () => { test('shared', () => { expect(Util.escapeMarkdown(testString)).toBe( @@ -176,7 +220,7 @@ describe('escapeMarkdown', () => { ); }); - test('edge-case odd number of fenses with no code block content', () => { + test('edge-case odd number of fences with no code block content', () => { expect( Util.escapeMarkdown('**foo** ```**bar**``` **fizz** ``` **buzz**', { codeBlock: false, diff --git a/typings/index.d.ts b/typings/index.d.ts index 065486ce4725..1c8f35b208b3 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -2625,6 +2625,11 @@ export class Util extends null { public static escapeUnderline(text: string): string; public static escapeStrikethrough(text: string): string; public static escapeSpoiler(text: string): string; + public static escapeEscape(text: string): string; + public static escapeHeading(text: string): string; + public static escapeBulletedList(text: string): string; + public static escapeNumberedList(text: string): string; + public static escapeMaskedLink(text: string): string; public static cleanCodeBlockContent(text: string): string; public static fetchRecommendedShards(token: string, options?: FetchRecommendedShardsOptions): Promise; public static flatten(obj: unknown, ...props: Record[]): unknown; @@ -4650,8 +4655,13 @@ export interface EscapeMarkdownOptions { underline?: boolean; strikethrough?: boolean; spoiler?: boolean; - inlineCodeContent?: boolean; codeBlockContent?: boolean; + inlineCodeContent?: boolean; + escape?: boolean; + heading?: boolean; + bulletedList?: boolean; + numberedList?: boolean; + maskedLink?: boolean; } export type ExplicitContentFilterLevel = keyof typeof ExplicitContentFilterLevels;