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 2c4c5c23d6 into the refactorization PR

Co-authored-by: space <spaceeec@yahoo.com>
This commit is contained in:
Cl00e9ment
2023-01-13 17:21:25 +01:00
committed by GitHub
parent b803a9a899
commit 13ce78af6e
6 changed files with 575 additions and 506 deletions

View File

@@ -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(/(?<=^|[^_])(?<!<a?:.+)_(?!:\d+>)([^_]|__|$)/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(/(?<!<a?:.+)__(_)?(?!:\d+>)/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,

View File

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

View File

@@ -3051,19 +3051,6 @@ export function cleanContent(str: string, channel: TextBasedChannel): string;
export function discordSort<K, V extends { rawPosition: number; id: Snowflake }>(
collection: Collection<K, V>,
): Collection<K, V>;
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<number>;
export function flatten(obj: unknown, ...props: Record<string, boolean | string>[]): unknown;

View File

@@ -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\\*\\*');
});
});
});
});

View File

@@ -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(/(?<=^|[^_])(?<!<a?:.+)_(?!:\d+>)([^_]|__|$)/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(/(?<!<a?:.+)__(_)?(?!:\d+>)/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, '\\$&');
}

View File

@@ -1 +1,2 @@
export * from './escapers.js';
export * from './formatters.js';