From fbdec3d828845a5205e3c0e33f4a43afb8409df9 Mon Sep 17 00:00:00 2001 From: Almeida Date: Mon, 6 Oct 2025 10:30:36 +0100 Subject: [PATCH] feat!: add `escapeQuote` and `escapeBlockQuote` (#11129) BREAKING CHANGE: `escapeMarkdown` now escapes quotes and block quotes by default. Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com> --- .../formatters/__tests__/escapers.test.ts | 127 +++++++++++++++--- packages/formatters/src/escapers.ts | 40 ++++++ 2 files changed, 146 insertions(+), 21 deletions(-) diff --git a/packages/formatters/__tests__/escapers.test.ts b/packages/formatters/__tests__/escapers.test.ts index 280dc4d1a..be609b4b1 100644 --- a/packages/formatters/__tests__/escapers.test.ts +++ b/packages/formatters/__tests__/escapers.test.ts @@ -12,9 +12,11 @@ import { escapeBulletedList, escapeNumberedList, escapeMarkdown, + escapeQuote, + escapeBlockQuote, } from '../src/index.js'; -const testString = "`_Behold!_`\n||___~~***```js\n`use strict`;\nrequire('discord.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'; const testURLs = [ @@ -29,7 +31,7 @@ describe('Markdown escapers', () => { describe('escapeCodeblock', () => { test('shared', () => { expect(escapeCodeBlock(testString)).toEqual( - "`_Behold!_`\n||___~~***\\`\\`\\`js\n`use strict`;\nrequire('discord.js');\\`\\`\\`***~~___||", + "> `_Behold!_`\n||___~~***\\`\\`\\`js\n`use strict`;\nrequire('discord.js');\\`\\`\\`***~~___||", ); }); @@ -41,7 +43,7 @@ describe('Markdown escapers', () => { describe('escapeInlineCode', () => { test('shared', () => { expect(escapeInlineCode(testString)).toEqual( - "\\`_Behold!_\\`\n||___~~***```js\n\\`use strict\\`;\nrequire('discord.js');```***~~___||", + "> \\`_Behold!_\\`\n||___~~***```js\n\\`use strict\\`;\nrequire('discord.js');```***~~___||", ); }); @@ -53,7 +55,7 @@ describe('Markdown escapers', () => { describe('escapeBold', () => { test('shared', () => { expect(escapeBold(testString)).toEqual( - "`_Behold!_`\n||___~~*\\*\\*```js\n`use strict`;\nrequire('discord.js');```\\*\\**~~___||", + "> `_Behold!_`\n||___~~*\\*\\*```js\n`use strict`;\nrequire('discord.js');```\\*\\**~~___||", ); }); @@ -65,7 +67,7 @@ describe('Markdown escapers', () => { describe('escapeItalic', () => { test('shared', () => { expect(escapeItalic(testString)).toEqual( - "`\\_Behold!\\_`\n||\\___~~\\***```js\n`use strict`;\nrequire('discord.js');```**\\*~~__\\_||", + "> `\\_Behold!\\_`\n||\\___~~\\***```js\n`use strict`;\nrequire('discord.js');```**\\*~~__\\_||", ); }); @@ -92,7 +94,7 @@ describe('Markdown escapers', () => { describe('escapeUnderline', () => { test('shared', () => { expect(escapeUnderline(testString)).toEqual( - "`_Behold!_`\n||_\\_\\_~~***```js\n`use strict`;\nrequire('discord.js');```***~~\\_\\__||", + "> `_Behold!_`\n||_\\_\\_~~***```js\n`use strict`;\nrequire('discord.js');```***~~\\_\\__||", ); }); @@ -115,7 +117,7 @@ describe('Markdown escapers', () => { describe('escapeStrikethrough', () => { test('shared', () => { expect(escapeStrikethrough(testString)).toEqual( - "`_Behold!_`\n||___\\~\\~***```js\n`use strict`;\nrequire('discord.js');```***\\~\\~___||", + "> `_Behold!_`\n||___\\~\\~***```js\n`use strict`;\nrequire('discord.js');```***\\~\\~___||", ); }); @@ -127,7 +129,7 @@ describe('Markdown escapers', () => { describe('escapeSpoiler', () => { test('shared', () => { expect(escapeSpoiler(testString)).toEqual( - "`_Behold!_`\n\\|\\|___~~***```js\n`use strict`;\nrequire('discord.js');```***~~___\\|\\|", + "> `_Behold!_`\n\\|\\|___~~***```js\n`use strict`;\nrequire('discord.js');```***~~___\\|\\|", ); }); @@ -178,83 +180,166 @@ describe('Markdown escapers', () => { }); }); + describe('escapeQuote', () => { + test('basic', () => { + expect(escapeQuote('> yeet')).toEqual('\\> yeet'); + expect(escapeQuote('> test')).toEqual('\\> test'); + expect(escapeQuote(' > leading spaces')).toEqual(' \\> leading spaces'); + }); + + test('not quotes', () => { + expect(escapeQuote('>')).toEqual('>'); + expect(escapeQuote('>test')).toEqual('>test'); + expect(escapeQuote('>> test')).toEqual('>> test'); + expect(escapeQuote('not a > quote')).toEqual('not a > quote'); + expect(escapeQuote('>>> yeet')).toEqual('>>> yeet'); + }); + + test('multiple lines', () => { + const input = `> quote +not a quote + > another quote +>> not a quote`; + + const expectedOutput = `\\> quote +not a quote + \\> another quote +>> not a quote`; + expect(escapeQuote(input)).toEqual(expectedOutput); + }); + }); + + describe('escapeBlockQuote', () => { + test('basic', () => { + expect(escapeBlockQuote('>>> block quote')).toEqual('\\>>> block quote'); + expect(escapeBlockQuote(' >>> leading spaces')).toEqual(' \\>>> leading spaces'); + }); + + test('not block quotes', () => { + expect(escapeBlockQuote('>>>')).toEqual('>>>'); + expect(escapeBlockQuote('>>>not block quote')).toEqual('>>>not block quote'); + expect(escapeBlockQuote('>> not block quote')).toEqual('>> not block quote'); + expect(escapeBlockQuote('not a block >>> quote')).toEqual('not a block >>> quote'); + expect(escapeBlockQuote('> yeet')).toEqual('> yeet'); + }); + + test('multiple lines', () => { + const input = `>>> block quote +part of it + + >>> another one + + >>>this is not`; + + const expectedOutput = `\\>>> block quote +part of it + + \\>>> another one + + >>>this is not`; + expect(escapeBlockQuote(input)).toEqual(expectedOutput); + }); + }); + describe('escapeMarkdown', () => { test('shared', () => { expect(escapeMarkdown(testString)).toEqual( - "\\`\\_Behold!\\_\\`\n\\|\\|\\_\\_\\_\\~\\~\\*\\*\\*\\`\\`\\`js\n\\`use strict\\`;\nrequire('discord.js');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|", + "\\> \\`\\_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');```\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|", + "\\> \\`\\_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');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|", + "\\> `\\_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');\\`\\`\\`**\\*\\~\\~\\_\\_\\_\\|\\|", + "\\> \\`\\_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');\\`\\`\\`\\*\\**\\~\\~\\_\\__\\|\\|", + "\\> \\`_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');\\`\\`\\`\\*\\*\\*\\~\\~__\\_\\|\\|", + "\\> \\`\\_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');\\`\\`\\`\\*\\*\\*~~\\_\\_\\_\\|\\|", + "\\> \\`\\_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');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_||", + "\\> \\`\\_Behold!\\_\\`\n||\\_\\_\\_\\~\\~\\*\\*\\*\\`\\`\\`js\n\\`use strict\\`;\nrequire('discord.js');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_||", ); }); + test('no quote', () => { + expect(escapeMarkdown(testString, { quote: false })).toEqual( + "> \\`\\_Behold!\\_\\`\n\\|\\|\\_\\_\\_\\~\\~\\*\\*\\*\\`\\`\\`js\n\\`use strict\\`;\nrequire('discord.js');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|", + ); + }); + + describe('block quotes', () => { + test('blockQuote', () => { + const testStringWithBlockQuote = `>>${testString}`; + expect(escapeMarkdown(testStringWithBlockQuote)).toEqual( + "\\>>> \\`\\_Behold!\\_\\`\n\\|\\|\\_\\_\\_\\~\\~\\*\\*\\*\\`\\`\\`js\n\\`use strict\\`;\nrequire('discord.js');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|", + ); + }); + + test('no blockQuote', () => { + const testStringWithBlockQuote = `>>${testString}`; + expect(escapeMarkdown(testStringWithBlockQuote, { blockQuote: 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');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|", + "\\> \\`\\_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');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|", + "\\> \\`_Behold!_\\`\n\\|\\|\\_\\_\\_\\~\\~\\*\\*\\*\\`\\`\\`js\n\\`use strict\\`;\nrequire('discord.js');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|", ); }); test('neither inline code or code block content', () => { expect(escapeMarkdown(testString, { inlineCodeContent: false, codeBlockContent: false })).toEqual( - "\\`_Behold!_\\`\n\\|\\|\\_\\_\\_\\~\\~\\*\\*\\*\\`\\`\\`js\n`use strict`;\nrequire('discord.js');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|", + "\\> \\`_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');```\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|", + "\\> \\`\\_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');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|", + "\\> `_Behold!_`\n\\|\\|\\_\\_\\_\\~\\~\\*\\*\\*\\`\\`\\`js\n`use strict`;\nrequire('discord.js');\\`\\`\\`\\*\\*\\*\\~\\~\\_\\_\\_\\|\\|", ); }); diff --git a/packages/formatters/src/escapers.ts b/packages/formatters/src/escapers.ts index bceaf484f..04daffb38 100644 --- a/packages/formatters/src/escapers.ts +++ b/packages/formatters/src/escapers.ts @@ -4,6 +4,13 @@ * The options that affect what will be escaped. */ export interface EscapeMarkdownOptions { + /** + * Whether to escape block quotes. + * + * @defaultValue `true` + */ + blockQuote?: boolean; + /** * Whether to escape bold text. * @@ -80,6 +87,13 @@ export interface EscapeMarkdownOptions { */ numberedList?: boolean; + /** + * Whether to escape block quotes. + * + * @defaultValue `true` + */ + quote?: boolean; + /** * Whether to escape spoilers. * @@ -124,6 +138,8 @@ export function escapeMarkdown(text: string, options: EscapeMarkdownOptions = {} bulletedList = true, numberedList = true, maskedLink = true, + blockQuote = true, + quote = true, } = options; if (!codeBlockContent) { @@ -144,6 +160,8 @@ export function escapeMarkdown(text: string, options: EscapeMarkdownOptions = {} bulletedList, numberedList, maskedLink, + blockQuote, + quote, }); }) .join(codeBlock ? '\\`\\`\\`' : '```'); @@ -166,6 +184,8 @@ export function escapeMarkdown(text: string, options: EscapeMarkdownOptions = {} bulletedList, numberedList, maskedLink, + blockQuote, + quote, }); }) .join(inlineCode ? '\\`' : '`'); @@ -184,6 +204,8 @@ export function escapeMarkdown(text: string, options: EscapeMarkdownOptions = {} if (bulletedList) res = escapeBulletedList(res); if (numberedList) res = escapeNumberedList(res); if (maskedLink) res = escapeMaskedLink(res); + if (quote) res = escapeQuote(res); + if (blockQuote) res = escapeBlockQuote(res); return res; } @@ -311,3 +333,21 @@ export function escapeNumberedList(text: string): string { export function escapeMaskedLink(text: string): string { return text.replaceAll(/\[.+]\(.+\)/gm, '\\$&'); } + +/** + * Escapes quote characters in a string. + * + * @param text - Content to escape + */ +export function escapeQuote(text: string): string { + return text.replaceAll(/^(\s*)>(\s+)/gm, '$1\\>$2'); +} + +/** + * Escapes block quote characters in a string. + * + * @param text - Content to escape + */ +export function escapeBlockQuote(text: string): string { + return text.replaceAll(/^(\s*)>>>(\s+)/gm, '$1\\>>>$2'); +}