From 2a9ad51de2d3556c63e97b3bec1ad8bc0eb09370 Mon Sep 17 00:00:00 2001 From: Brandon Henry Date: Mon, 1 Apr 2024 11:40:50 -0500 Subject: [PATCH 01/33] initial concept --- lib/ExpensiMark.js | 176 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) diff --git a/lib/ExpensiMark.js b/lib/ExpensiMark.js index 03324013..2fa7e1ac 100644 --- a/lib/ExpensiMark.js +++ b/lib/ExpensiMark.js @@ -9,6 +9,42 @@ const MARKDOWN_IMAGE_REGEX = new RegExp(`\\!${MARKDOWN_LINK}`, 'gi'); const SLACK_SPAN_NEW_LINE_TAG = ''; +const ASTERISK_ITALIC = '*'; +const UNDERSCORE_ITALIC = '_'; +const ASTERISK_BOLD = '**'; +const UNDERSCORE_BOLD = '__'; +const ESCAPED_UNDERSCORE = '\\_'; +const ESCAPED_ASTERISK = '\\*' +const ASTERISK_PLACEHOLDER_REGEXP = /ASTERISKPLACEHOLDER/gm; +const UNDERSCORE_PLACEHOLDER_REGEXP = /UNDERSCOREPLACEHOLDER/gm; +const UNDERSCORE_BOLD_PLACEHOLDER_REGEXP = /UNDERSCOREBOLDPLACEHOLDER/gm; +const ASTERISK_BOLD_PLACEHOLDER_REGEXP = /ASTERISKBOLDPLACEHOLDER/gm; +const UNDERSCORE_ITALIC_PLACEHOLDER_REGEXP = /UNDERSCOREITALICPLACEHOLDER/gm; +const ASTERISK_ITALIC_PLACEHOLDER_REGEXP = /ASTERISKITALICPLACEHOLDER/gm; + + +const ESCAPED_ASTERISK_REGEXP = /\\\*/g; + +const ESCAPED_UNDERSCORE_REGEXP = /\\_/g; +const UNDERSCORE_BOLD_REGEXP = /(__)(.*?)(__)/g; +const ASTERISK_BOLD_REGEXP = /(\*\*)(.*?)(\*\*)/g; +const UNDERSCORE_ITALIC_REGEXP = /(_)(.*?)(_)/g; +const ASTERISK_ITALIC_REGEXP = /(\*)(.*?)(\*)/g; + +const HYPERLINK = /^\[([^[]+)\]\(([^)]+)\)/; + +const formatMarkers = [ + ASTERISK_BOLD_PLACEHOLDER_REGEXP.source, + UNDERSCORE_BOLD_PLACEHOLDER_REGEXP.source, + ASTERISK_ITALIC_PLACEHOLDER_REGEXP.source, + UNDERSCORE_ITALIC_PLACEHOLDER_REGEXP.source, +]; + +const formatPlaceholdersMap = { + [UNDERSCORE_PLACEHOLDER_REGEXP.source]: ESCAPED_UNDERSCORE.length, + [ASTERISK_PLACEHOLDER_REGEXP.source]: ESCAPED_ASTERISK.length, +} + export default class ExpensiMark { constructor() { /** @@ -976,4 +1012,144 @@ export default class ExpensiMark { return text.replace(pattern, char => entities[char] || char); } + + findFormatPlaceholderAhead(text) { + const formatPlaceholders = Object.keys(formatPlaceholdersMap); + + for (let i = 0, l = formatPlaceholders.length; i < l; i++) { + if (text.startsWith(formatPlaceholders[i])) { + return formatPlaceholders[i]; + } + } + + return null; + } + + findFormatMarkerAhead(text, formatStack) { + for (let i = 0, l = formatMarkers.length; i < l; i++) { + if (text.startsWith(formatMarkers[i])) { + if (formatStack[formatStack.length - 1] === formatMarkers[i]) { + formatStack.pop(); + } else { + formatStack.push(formatMarkers[i]); + } + return formatMarkers[i]; + } + } + + return null; + } + + replaceFormatMarkersWithPlaceholders(text) { + return text + .replace( + ESCAPED_UNDERSCORE_REGEXP, + UNDERSCORE_PLACEHOLDER_REGEXP.source + ) + .replace( + ESCAPED_ASTERISK_REGEXP, + ASTERISK_PLACEHOLDER_REGEXP.source + ) + .replace( + UNDERSCORE_BOLD_REGEXP, + `${UNDERSCORE_BOLD_PLACEHOLDER_REGEXP.source}$2${UNDERSCORE_BOLD_PLACEHOLDER_REGEXP.source}` + ) + .replace( + ASTERISK_BOLD_REGEXP, + `${ASTERISK_BOLD_PLACEHOLDER_REGEXP.source}$2${ASTERISK_BOLD_PLACEHOLDER_REGEXP.source}` + ) + .replace( + UNDERSCORE_ITALIC_REGEXP, + `${UNDERSCORE_ITALIC_PLACEHOLDER_REGEXP.source}$2${UNDERSCORE_ITALIC_PLACEHOLDER_REGEXP.source}` + ) + .replace( + ASTERISK_ITALIC_REGEXP, + `${ASTERISK_ITALIC_PLACEHOLDER_REGEXP.source}$2${ASTERISK_ITALIC_PLACEHOLDER_REGEXP.source}` + ); + } + + replaceFormatPlaceholdersWithMarkers(text) { + return text + .replace(UNDERSCORE_PLACEHOLDER_REGEXP, ESCAPED_UNDERSCORE) + .replace(ASTERISK_PLACEHOLDER_REGEXP, ESCAPED_ASTERISK) + .replace(UNDERSCORE_BOLD_PLACEHOLDER_REGEXP, UNDERSCORE_BOLD) + .replace(ASTERISK_BOLD_PLACEHOLDER_REGEXP, ASTERISK_BOLD) + .replace(UNDERSCORE_ITALIC_PLACEHOLDER_REGEXP, UNDERSCORE_ITALIC) + .replace(ASTERISK_ITALIC_PLACEHOLDER_REGEXP, ASTERISK_ITALIC); + } + + truncate(text, limit, ellipsis) { + let count = 0; + + const truncateString = (tText) => { + const formatStack = []; + let skipCountIncrement = false; + let outputText = ""; + let index = 0; + + while (count < limit && index < tText.length) { + const formatMarker = findFormatMarkerAhead( + tText.substring(index), + formatStack + ); + if (formatMarker) { + outputText += formatMarker; + index += formatMarker.length; + skipCountIncrement = true; + } + + const formatPlaceholder = findFormatPlaceholderAhead( + tText.substring(index) + ); + if (formatPlaceholder) { + outputText += formatPlaceholder; + index += formatPlaceholder.length; + skipCountIncrement = true; + count += formatPlaceholdersMap[formatPlaceholder]; + } + + const hyperlinkAheadRegexp = new RegExp(HYPERLINK); + const hyperlinkMatch = hyperlinkAheadRegexp.exec( + tText.substring(index) + ); + if (hyperlinkMatch) { + const hyperlinkText = hyperlinkMatch[1]; + const hyperlinkUrl = hyperlinkMatch[2]; + + outputText += `[${truncateString( + hyperlinkText + )}](${hyperlinkUrl})`; + index += hyperlinkMatch[0].length; + skipCountIncrement = true; + } + + if (!formatMarker && !hyperlinkMatch) { + outputText += tText[index]; + index++; + } + + if (!skipCountIncrement) { + count++; + } + + skipCountIncrement = false; + } + + outputText = outputText.trimEnd(); + + while (formatStack.length > 0) { + outputText += formatStack.pop(); + } + + return outputText; + }; + + let outputText = truncateString(text); + + if (ellipsis && outputText.length < text.length) { + outputText += "..."; + } + + return outputText; + } } From d6d5f72a2b8f3c37372048ea597423cdae17438b Mon Sep 17 00:00:00 2001 From: Brandon Henry Date: Mon, 1 Apr 2024 23:04:43 -0500 Subject: [PATCH 02/33] swap to for loop + fix multiple function calls --- lib/ExpensiMark.js | 112 ++++++++++++++++++++++++++++----------------- 1 file changed, 69 insertions(+), 43 deletions(-) diff --git a/lib/ExpensiMark.js b/lib/ExpensiMark.js index 2fa7e1ac..357cc137 100644 --- a/lib/ExpensiMark.js +++ b/lib/ExpensiMark.js @@ -1013,6 +1013,12 @@ export default class ExpensiMark { return text.replace(pattern, char => entities[char] || char); } + /** + * Find the format placeholder at the start of the given text, if any. + * + * @param {String} text + * @returns {String|null} + */ findFormatPlaceholderAhead(text) { const formatPlaceholders = Object.keys(formatPlaceholdersMap); @@ -1025,6 +1031,13 @@ export default class ExpensiMark { return null; } + /** + * Find the format marker at the start of the given text, if any, and update the format stack accordingly. + * + * @param {String} text + * @param {Array} formatStack + * @returns {String|null} + */ findFormatMarkerAhead(text, formatStack) { for (let i = 0, l = formatMarkers.length; i < l; i++) { if (text.startsWith(formatMarkers[i])) { @@ -1040,6 +1053,13 @@ export default class ExpensiMark { return null; } + /** + * Find the format marker at the start of the given text, if any, and update the format stack accordingly. + * + * @param {String} text + * @param {Array} formatStack + * @returns {String|null} + */ replaceFormatMarkersWithPlaceholders(text) { return text .replace( @@ -1068,6 +1088,13 @@ export default class ExpensiMark { ); } + /** + * Find the format marker at the start of the given text, if any, and update the format stack accordingly. + * + * @param {String} text + * @param {Array} formatStack + * @returns {String|null} + */ replaceFormatPlaceholdersWithMarkers(text) { return text .replace(UNDERSCORE_PLACEHOLDER_REGEXP, ESCAPED_UNDERSCORE) @@ -1078,69 +1105,68 @@ export default class ExpensiMark { .replace(ASTERISK_ITALIC_PLACEHOLDER_REGEXP, ASTERISK_ITALIC); } - truncate(text, limit, ellipsis) { + /** + * Truncate the given Markdown text to the specified character limit. + * + * @param {String} text + * @param {Number} limit + * @param {Boolean} ellipsis - Whether to add an ellipsis if the text is truncated + * @returns {String} + */ + truncateMarkdown(text, limit, ellipsis) { let count = 0; - const truncateString = (tText) => { + const truncateString = (tempText) => { const formatStack = []; - let skipCountIncrement = false; + let shouldSkipCountIncrement = false; let outputText = ""; - let index = 0; - - while (count < limit && index < tText.length) { - const formatMarker = findFormatMarkerAhead( - tText.substring(index), - formatStack - ); + + for (let index = 0; count < limit && index < tempText.length; index++) { + const substring = tempText.substring(index); + + const formatMarker = findFormatMarkerAhead(substring, formatStack); if (formatMarker) { outputText += formatMarker; - index += formatMarker.length; - skipCountIncrement = true; + index += formatMarker.length - 1; + shouldSkipCountIncrement = true; + continue; } - - const formatPlaceholder = findFormatPlaceholderAhead( - tText.substring(index) - ); + + const formatPlaceholder = findFormatPlaceholderAhead(substring); if (formatPlaceholder) { outputText += formatPlaceholder; - index += formatPlaceholder.length; - skipCountIncrement = true; + index += formatPlaceholder.length - 1; + shouldSkipCountIncrement = true; count += formatPlaceholdersMap[formatPlaceholder]; + continue; } - + const hyperlinkAheadRegexp = new RegExp(HYPERLINK); - const hyperlinkMatch = hyperlinkAheadRegexp.exec( - tText.substring(index) - ); + const hyperlinkMatch = hyperlinkAheadRegexp.exec(substring); if (hyperlinkMatch) { const hyperlinkText = hyperlinkMatch[1]; const hyperlinkUrl = hyperlinkMatch[2]; - - outputText += `[${truncateString( - hyperlinkText - )}](${hyperlinkUrl})`; - index += hyperlinkMatch[0].length; - skipCountIncrement = true; - } - - if (!formatMarker && !hyperlinkMatch) { - outputText += tText[index]; - index++; + outputText += `[${truncateString(hyperlinkText)}](${hyperlinkUrl})`; + index += hyperlinkMatch[0].length - 1; + shouldSkipCountIncrement = true; + continue; } - - if (!skipCountIncrement) { + + outputText += tempText[index]; + + if (!shouldSkipCountIncrement) { count++; } - - skipCountIncrement = false; + + shouldSkipCountIncrement = false; } - + outputText = outputText.trimEnd(); - - while (formatStack.length > 0) { - outputText += formatStack.pop(); - } - + + formatStack.forEach((marker) => { + outputText += marker; + }); + return outputText; }; From b21b4ecb8d9c65f5d04cb2a2f68088779bf7be04 Mon Sep 17 00:00:00 2001 From: Brandon Henry Date: Tue, 2 Apr 2024 22:40:00 -0500 Subject: [PATCH 03/33] lint updates --- lib/ExpensiMark.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/ExpensiMark.js b/lib/ExpensiMark.js index 357cc137..86e3dc37 100644 --- a/lib/ExpensiMark.js +++ b/lib/ExpensiMark.js @@ -14,7 +14,7 @@ const UNDERSCORE_ITALIC = '_'; const ASTERISK_BOLD = '**'; const UNDERSCORE_BOLD = '__'; const ESCAPED_UNDERSCORE = '\\_'; -const ESCAPED_ASTERISK = '\\*' +const ESCAPED_ASTERISK = '\\*'; const ASTERISK_PLACEHOLDER_REGEXP = /ASTERISKPLACEHOLDER/gm; const UNDERSCORE_PLACEHOLDER_REGEXP = /UNDERSCOREPLACEHOLDER/gm; const UNDERSCORE_BOLD_PLACEHOLDER_REGEXP = /UNDERSCOREBOLDPLACEHOLDER/gm; @@ -34,16 +34,16 @@ const ASTERISK_ITALIC_REGEXP = /(\*)(.*?)(\*)/g; const HYPERLINK = /^\[([^[]+)\]\(([^)]+)\)/; const formatMarkers = [ - ASTERISK_BOLD_PLACEHOLDER_REGEXP.source, - UNDERSCORE_BOLD_PLACEHOLDER_REGEXP.source, - ASTERISK_ITALIC_PLACEHOLDER_REGEXP.source, - UNDERSCORE_ITALIC_PLACEHOLDER_REGEXP.source, + ASTERISK_BOLD_PLACEHOLDER_REGEXP.source, + UNDERSCORE_BOLD_PLACEHOLDER_REGEXP.source, + ASTERISK_ITALIC_PLACEHOLDER_REGEXP.source, + UNDERSCORE_ITALIC_PLACEHOLDER_REGEXP.source, ]; const formatPlaceholdersMap = { - [UNDERSCORE_PLACEHOLDER_REGEXP.source]: ESCAPED_UNDERSCORE.length, - [ASTERISK_PLACEHOLDER_REGEXP.source]: ESCAPED_ASTERISK.length, -} + [UNDERSCORE_PLACEHOLDER_REGEXP.source]: ESCAPED_UNDERSCORE.length, + [ASTERISK_PLACEHOLDER_REGEXP.source]: ESCAPED_ASTERISK.length, +}; export default class ExpensiMark { constructor() { @@ -1119,12 +1119,12 @@ export default class ExpensiMark { const truncateString = (tempText) => { const formatStack = []; let shouldSkipCountIncrement = false; - let outputText = ""; + let outputText = ''; for (let index = 0; count < limit && index < tempText.length; index++) { const substring = tempText.substring(index); - const formatMarker = findFormatMarkerAhead(substring, formatStack); + const formatMarker = this.findFormatMarkerAhead(substring, formatStack); if (formatMarker) { outputText += formatMarker; index += formatMarker.length - 1; From 4cb7381032d92d617b6942ab499e5a923edb7902 Mon Sep 17 00:00:00 2001 From: Brandon Henry Date: Thu, 4 Apr 2024 18:27:04 -0500 Subject: [PATCH 04/33] Update lib/ExpensiMark.js Co-authored-by: Nikki Wines --- lib/ExpensiMark.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/ExpensiMark.js b/lib/ExpensiMark.js index 86e3dc37..cdec8c01 100644 --- a/lib/ExpensiMark.js +++ b/lib/ExpensiMark.js @@ -22,15 +22,12 @@ const ASTERISK_BOLD_PLACEHOLDER_REGEXP = /ASTERISKBOLDPLACEHOLDER/gm; const UNDERSCORE_ITALIC_PLACEHOLDER_REGEXP = /UNDERSCOREITALICPLACEHOLDER/gm; const ASTERISK_ITALIC_PLACEHOLDER_REGEXP = /ASTERISKITALICPLACEHOLDER/gm; - const ESCAPED_ASTERISK_REGEXP = /\\\*/g; - const ESCAPED_UNDERSCORE_REGEXP = /\\_/g; const UNDERSCORE_BOLD_REGEXP = /(__)(.*?)(__)/g; const ASTERISK_BOLD_REGEXP = /(\*\*)(.*?)(\*\*)/g; const UNDERSCORE_ITALIC_REGEXP = /(_)(.*?)(_)/g; const ASTERISK_ITALIC_REGEXP = /(\*)(.*?)(\*)/g; - const HYPERLINK = /^\[([^[]+)\]\(([^)]+)\)/; const formatMarkers = [ From 6f2c8234a85d64ca7aec0d65c874dd2e8c0df5a9 Mon Sep 17 00:00:00 2001 From: Brandon Henry Date: Thu, 4 Apr 2024 18:27:20 -0500 Subject: [PATCH 05/33] Update lib/ExpensiMark.js Co-authored-by: Nikki Wines --- lib/ExpensiMark.js | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/ExpensiMark.js b/lib/ExpensiMark.js index cdec8c01..c7acfc09 100644 --- a/lib/ExpensiMark.js +++ b/lib/ExpensiMark.js @@ -1120,7 +1120,6 @@ export default class ExpensiMark { for (let index = 0; count < limit && index < tempText.length; index++) { const substring = tempText.substring(index); - const formatMarker = this.findFormatMarkerAhead(substring, formatStack); if (formatMarker) { outputText += formatMarker; From 7c88b3ff10f92ab044fae7b4bac838b6d37fa1f0 Mon Sep 17 00:00:00 2001 From: Brandon Henry Date: Mon, 8 Apr 2024 18:20:05 -0500 Subject: [PATCH 06/33] Added several tests --- .../ExpensiMark-Markdown-Truncate-test.js | 279 ++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 __tests__/ExpensiMark-Markdown-Truncate-test.js diff --git a/__tests__/ExpensiMark-Markdown-Truncate-test.js b/__tests__/ExpensiMark-Markdown-Truncate-test.js new file mode 100644 index 00000000..7c1b2b8f --- /dev/null +++ b/__tests__/ExpensiMark-Markdown-Truncate-test.js @@ -0,0 +1,279 @@ +import ExpensiMark from "../lib/ExpensiMark"; + +const parser = new ExpensiMark(); + +const testCases = [ + // Basic text + "This is a simple text without any Markdown formatting.", + + // Italic + "*This* is *italic* text with *asterisks*.", + "_This_ is _italic_ text with _underscores_.", + + // Bold + "**This** is **bold** text with **asterisks**.", + "__This__ is __bold__ text with __underscores__.", + + // Bold and Italic + "***This*** is ***bold and italic*** text with ***asterisks***.", + "___This___ is ___bold and italic___ text with ___underscores___.", + "**_This_** is **_bold and italic_** text with **_mixed delimiters_**.", + + // Escaping + "This text contains an escaped asterisk: \\*not italic\\*.", + "This text contains an escaped underscore: \\_not italic\\_.", + + // Links + "This is an [inline link](https://example.com).", + 'This is a [link with title](https://example.com "Example Title").', + "This is a [reference-style link][example].", + "[example]: https://example.com", + + // Combinations + "*This* is *italic* and **this** is **bold**.", + "_This_ is _italic_ and __this__ is __bold__.", + "*This* is *italic*, **this** is **bold**, and ***this*** is ***both***.", + "_This_ is _italic_, __this__ is __bold__, and ___this___ is ___both___.", + "This is *italic with **bold** inside* and **bold with *italic* inside**.", + "This is _italic with __bold__ inside_ and __bold with _italic_ inside__.", + + // Edge Cases + "****Bold with four asterisks****", + "____Bold with four underscores____", + "*****Bold and italic with five asterisks*****", + "_____Bold and italic with five underscores_____", + "***Bold and italic with three asterisks**", + "___Bold and italic with three underscores__", + "**Bold with two asterisks*", + "__Bold with two underscores_", + "*Italic with one asterisk**", + "_Italic with one underscore__", + + // Complex Examples + "This is a ***complex*** _example_ with [multiple](https://example.com) **formatting** options \\*combined\\*.", + "*This* is *an **example** of* **_nested_** ~~formatting~~.", + "This is an *italic [link](https://example.com)* and a **bold [link](https://example.com)**.", + "This \\*is\\* an \\*example\\* with \\*escaped\\* \\*\\*formatting\\*\\*.", + + // Headers + "# This is a heading1", + "## This is a heading2", + "### This is a heading3", + "#### This is a heading4", + "##### This is a heading5", + "###### This is a heading6", + + // Blockquotes + "> This is a blockquote", + "> This is a blockquote\n> with multiple lines", + + // Lists + "- Item 1\n- Item 2\n- Item 3", + "1. Item 1\n2. Item 2\n3. Item 3", + + // Code + "This is `inline code`", + "```\nThis is a code block\n```", + + // Horizontal Rule + "---", + "***", + "___", + + // Tables + "| Column 1 | Column 2 |\n| -------- | -------- |\n| Row 1, Cell 1 | Row 1, Cell 2 |\n| Row 2, Cell 1 | Row 2, Cell 2 |", + + // Strikethrough + "This is ~~strikethrough~~ text.", + + // Task Lists + "- [x] Completed task\n- [ ] Incomplete task", + + // Emoji + "This is an :emoji:", + + // Mention + "This is a @mention", +]; + +describe("ExpensiMark.truncateMarkdown", () => { + test("should truncate Markdown text correctly", () => { + const expectedOutputs = [ + "This is a simple...", + "*This* is *italic*...", + "_This_ is _italic_...", + "**This** is **bold**...", + "__This__ is __bold__...", + "***This*** is...", + "___This___ is...", + "**_This_** is...", + "This text contains...", + "This text contains...", + "This is an [inline...", + "This is a [link...", + "This is a...", + "[example]:...", + "*This* is *italic*...", + "_This_ is _italic_...", + "*This* is *italic*,...", + "_This_ is _italic_,...", + "This is *italic...", + "This is _italic...", + "****Bold with four...", + "____Bold with four...", + "*****Bold and...", + "_____Bold and...", + "***Bold and italic...", + "___Bold and italic...", + "**Bold with two...", + "__Bold with two...", + "*Italic with one...", + "_Italic with one...", + "This is a...", + "*This* is *an...", + "This is an *italic...", + "This \\*is\\* an...", + "# This is a heading1", + "## This is a...", + "### This is a...", + "#### This is a...", + "##### This is a...", + "###### This is a...", + "> This is a...", + "> This is a...", + "- Item 1...", + "1. Item 1...", + "This is `inline...", + "```...", + "---", + "***", + "___", + "| Column 1 | Column...", + "This is...", + "- [x] Completed...", + "This is an :emoji:.", + "This is a @mention.", + ]; + + testCases.forEach((testCase, index) => { + const truncatedText = parser.truncateMarkdown(testCase, 20, true); + console.log(`Original Text: ${testCase}`); + console.log(`Truncated Text: ${truncatedText}`); + console.log("---"); + + expect(truncatedText).toEqual(expectedOutputs[index]); + }); + }); + + test("should handle empty text", () => { + const emptyText = ""; + const truncatedText = parser.truncateMarkdown(emptyText, 20, true); + expect(truncatedText).toEqual(""); + }); + + test("should handle text shorter than the limit", () => { + const shortText = "Short text"; + const truncatedText = parser.truncateMarkdown(shortText, 20, true); + expect(truncatedText).toEqual(shortText); + }); + + test("should add ellipsis when truncated and ellipsis option is true", () => { + const longText = "This is a long text that will be truncated."; + const truncatedText = parser.truncateMarkdown(longText, 20, true); + expect(truncatedText).toMatch(/\.\.\.$/); + }); + + test("should not add ellipsis when truncated and ellipsis option is false", () => { + const longText = "This is a long text that will be truncated."; + const truncatedText = parser.truncateMarkdown(longText, 20, false); + expect(truncatedText).not.toMatch(/\.\.\.$/); + }); + + test("should preserve formatting when truncating", () => { + const formattedText = + "This is *italic*, **bold**, and ***bold italic*** text."; + const truncatedText = parser.truncateMarkdown(formattedText, 20, true); + expect(truncatedText).toEqual("This is *italic*, **bold**..."); + }); + + test("should handle multiple paragraphs", () => { + const multiParagraphText = "Paragraph 1\n\nParagraph 2\n\nParagraph 3"; + const truncatedText = parser.truncateMarkdown( + multiParagraphText, + 20, + true + ); + expect(truncatedText).toEqual("Paragraph 1..."); + }); + + test("should handle headers", () => { + const headerText = "# Heading 1\n\n## Heading 2\n\nParagraph"; + const truncatedText = parser.truncateMarkdown(headerText, 20, true); + expect(truncatedText).toEqual("# Heading 1..."); + }); + + test("should handle blockquotes", () => { + const blockquoteText = + "> This is a blockquote\n>\n> With multiple lines"; + const truncatedText = parser.truncateMarkdown(blockquoteText, 20, true); + expect(truncatedText).toEqual("> This is a..."); + }); + + test("should handle lists", () => { + const listText = "- Item 1\n- Item 2\n- Item 3"; + const truncatedText = parser.truncateMarkdown(listText, 20, true); + expect(truncatedText).toEqual("- Item 1..."); + }); + + test("should handle code blocks", () => { + const codeBlockText = + "```\nThis is a code block\nWith multiple lines\n```"; + const truncatedText = parser.truncateMarkdown(codeBlockText, 20, true); + expect(truncatedText).toEqual("```\nThis is a code...\n```"); + }); + + test("should handle horizontal rules", () => { + const horizontalRuleText = "Paragraph\n\n---\n\nParagraph"; + const truncatedText = parser.truncateMarkdown( + horizontalRuleText, + 20, + true + ); + expect(truncatedText).toEqual("Paragraph\n\n---..."); + }); + + test("should handle tables", () => { + const tableText = + "| Column 1 | Column 2 |\n| -------- | -------- |\n| Row 1, Cell 1 | Row 1, Cell 2 |\n| Row 2, Cell 1 | Row 2, Cell 2 |"; + const truncatedText = parser.truncateMarkdown(tableText, 20, true); + expect(truncatedText).toEqual("| Column 1 | Column..."); + }); + + test("should handle strikethrough", () => { + const strikethroughText = "This is ~~strikethrough~~ text."; + const truncatedText = parser.truncateMarkdown( + strikethroughText, + 20, + true + ); + expect(truncatedText).toEqual("This is..."); + }); + + test("should handle task lists", () => { + const taskListText = "- [x] Completed task\n- [ ] Incomplete task"; + const truncatedText = parser.truncateMarkdown(taskListText, 20, true); + expect(truncatedText).toEqual("- [x] Completed task..."); + }); + + test("should handle emoji", () => { + const emojiText = "This is an :emoji:."; + const truncatedText = parser.truncateMarkdown(emojiText, 20, true); + expect(truncatedText).toEqual("This is an :emoji:."); + }); + + test("should handle mentions", () => { + const mentionText = "This is a @mention."; + const truncatedText = parser.truncateMarkdown(mentionText, 20, true); + expect(truncatedText).toEqual("This is a @mention."); + }); +}); From 156b994f0102d52cdd7e1049a025cbc83bcd02e1 Mon Sep 17 00:00:00 2001 From: Brandon Henry Date: Mon, 8 Apr 2024 18:22:34 -0500 Subject: [PATCH 07/33] Update ExpensiMark.js --- lib/ExpensiMark.js | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/ExpensiMark.js b/lib/ExpensiMark.js index c7acfc09..c6234739 100644 --- a/lib/ExpensiMark.js +++ b/lib/ExpensiMark.js @@ -41,7 +41,6 @@ const formatPlaceholdersMap = { [UNDERSCORE_PLACEHOLDER_REGEXP.source]: ESCAPED_UNDERSCORE.length, [ASTERISK_PLACEHOLDER_REGEXP.source]: ESCAPED_ASTERISK.length, }; - export default class ExpensiMark { constructor() { /** From 902c518c2a92576e1be58bc1fda77ad609c53167 Mon Sep 17 00:00:00 2001 From: Brandon Henry Date: Mon, 8 Apr 2024 18:24:25 -0500 Subject: [PATCH 08/33] Update ExpensiMark.js --- lib/ExpensiMark.js | 665 ++++++++++++++++++++++++++++++--------------- 1 file changed, 440 insertions(+), 225 deletions(-) diff --git a/lib/ExpensiMark.js b/lib/ExpensiMark.js index c6234739..e3ec58cb 100644 --- a/lib/ExpensiMark.js +++ b/lib/ExpensiMark.js @@ -1,20 +1,21 @@ -import _ from 'underscore'; -import Str from './str'; -import {MARKDOWN_URL_REGEX, LOOSE_URL_REGEX, URL_REGEX} from './Url'; -import {CONST} from './CONST'; +import _ from "underscore"; +import Str from "./str"; +import { MARKDOWN_URL_REGEX, LOOSE_URL_REGEX, URL_REGEX } from "./Url"; +import { CONST } from "./CONST"; const MARKDOWN_LINK = `\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)]\\(${MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`; -const MARKDOWN_LINK_REGEX = new RegExp(MARKDOWN_LINK, 'gi'); -const MARKDOWN_IMAGE_REGEX = new RegExp(`\\!${MARKDOWN_LINK}`, 'gi'); - -const SLACK_SPAN_NEW_LINE_TAG = ''; - -const ASTERISK_ITALIC = '*'; -const UNDERSCORE_ITALIC = '_'; -const ASTERISK_BOLD = '**'; -const UNDERSCORE_BOLD = '__'; -const ESCAPED_UNDERSCORE = '\\_'; -const ESCAPED_ASTERISK = '\\*'; +const MARKDOWN_LINK_REGEX = new RegExp(MARKDOWN_LINK, "gi"); +const MARKDOWN_IMAGE_REGEX = new RegExp(`\\!${MARKDOWN_LINK}`, "gi"); + +const SLACK_SPAN_NEW_LINE_TAG = + ''; + +const ASTERISK_ITALIC = "*"; +const UNDERSCORE_ITALIC = "_"; +const ASTERISK_BOLD = "**"; +const UNDERSCORE_BOLD = "__"; +const ESCAPED_UNDERSCORE = "\\_"; +const ESCAPED_ASTERISK = "\\*"; const ASTERISK_PLACEHOLDER_REGEXP = /ASTERISKPLACEHOLDER/gm; const UNDERSCORE_PLACEHOLDER_REGEXP = /UNDERSCOREPLACEHOLDER/gm; const UNDERSCORE_BOLD_PLACEHOLDER_REGEXP = /UNDERSCOREBOLDPLACEHOLDER/gm; @@ -52,9 +53,9 @@ export default class ExpensiMark { this.rules = [ // Apply the emoji first avoid applying any other formatting rules inside of it { - name: 'emoji', + name: "emoji", regex: CONST.REG_EXP.EMOJI_RULE, - replacement: match => `${match}` + replacement: (match) => `${match}`, }, /** @@ -62,7 +63,7 @@ export default class ExpensiMark { * (aka any rule with the '(?![^<]*<\/pre>)' avoidance in it */ { - name: 'codeFence', + name: "codeFence", // ` is a backtick symbol we are matching on three of them before then after a new line character regex: /(```(?:\r\n|\n)?)((?:\s*?(?!(?:\r\n|\n)?```(?!`))[\S])+\s*?)((?=(?:\r\n|\n)?)```)/g, @@ -74,14 +75,23 @@ export default class ExpensiMark { // want to do this anywhere else since that would break HTML. //   will create styling issues so use replacement: (match, __, textWithinFences) => { - const group = textWithinFences.replace(/(?:(?![\n\r])\s)/g, ' '); + const group = textWithinFences.replace( + /(?:(?![\n\r])\s)/g, + " " + ); return `
${group}
`; }, rawInputReplacement: (match, __, textWithinFences) => { - const withinFences = match.replace(/(?:```)([\s\S]*?)(?:```)/g, '$1'); - const group = textWithinFences.replace(/(?:(?![\n\r])\s)/g, ' '); + const withinFences = match.replace( + /(?:```)([\s\S]*?)(?:```)/g, + "$1" + ); + const group = textWithinFences.replace( + /(?:(?![\n\r])\s)/g, + " " + ); return `
${group}
`; - } + }, }, /** @@ -89,7 +99,7 @@ export default class ExpensiMark { * like we do for the multi-line code-blocks */ { - name: 'inlineCodeBlock', + name: "inlineCodeBlock", // Use the url escaped version of a backtick (`) symbol. Mobile platforms do not support lookbehinds, // so capture the first and third group and place them in the replacement. @@ -103,7 +113,7 @@ export default class ExpensiMark { return match; } return `${g1}${g2}${g3}`; - } + }, }, /** @@ -112,12 +122,18 @@ export default class ExpensiMark { * create a link from an existing anchor tag. */ { - name: 'email', + name: "email", process: (textToProcess, replacement, shouldKeepRawInput) => { const regex = new RegExp( - `(?!\\[\\s*\\])\\[([^[\\]]*)]\\((mailto:)?${CONST.REG_EXP.MARKDOWN_EMAIL}\\)`, 'gim' + `(?!\\[\\s*\\])\\[([^[\\]]*)]\\((mailto:)?${CONST.REG_EXP.MARKDOWN_EMAIL}\\)`, + "gim" + ); + return this.modifyTextForEmailLinks( + regex, + textToProcess, + replacement, + shouldKeepRawInput ); - return this.modifyTextForEmailLinks(regex, textToProcess, replacement, shouldKeepRawInput); }, replacement: (match, g1, g2) => { if (g1.match(CONST.REG_EXP.EMOJIS) || !g1.trim()) { @@ -136,16 +152,22 @@ export default class ExpensiMark { const dataRawHref = g2 ? g2 + g3 : g3; const href = `mailto:${g3}`; return `${g1}`; - } + }, }, { - name: 'heading1', - process: (textToProcess, replacement, shouldKeepRawInput = false) => { - const regexp = shouldKeepRawInput ? /^# ( *(?! )(?:(?!
|\n|\r\n).)+)/gm : /^# +(?! )((?:(?!
|\n|\r\n).)+)/gm;
+                name: "heading1",
+                process: (
+                    textToProcess,
+                    replacement,
+                    shouldKeepRawInput = false
+                ) => {
+                    const regexp = shouldKeepRawInput
+                        ? /^# ( *(?! )(?:(?!
|\n|\r\n).)+)/gm
+                        : /^# +(?! )((?:(?!
|\n|\r\n).)+)/gm;
                     return textToProcess.replace(regexp, replacement);
                 },
-                replacement: '

$1

', + replacement: "

$1

", }, /** @@ -155,10 +177,18 @@ export default class ExpensiMark { * Additional sanitization is done to the alt attribute to prevent parsing it further to html by later rules. */ { - name: 'image', + name: "image", regex: MARKDOWN_IMAGE_REGEX, - replacement: (match, g1, g2) => `${this.escapeMarkdownEntities(g1)}`, - rawInputReplacement: (match, g1, g2) => `${this.escapeMarkdownEntities(g1)}` + replacement: (match, g1, g2) => + `${this.escapeMarkdownEntities(g1)}`, + rawInputReplacement: (match, g1, g2) => + `${this.escapeMarkdownEntities(
+                        g1
+                    )}`, }, /** @@ -167,20 +197,29 @@ export default class ExpensiMark { * from an existing anchor tag. */ { - name: 'link', - process: (textToProcess, replacement) => this.modifyTextForUrlLinks(MARKDOWN_LINK_REGEX, textToProcess, replacement), + name: "link", + process: (textToProcess, replacement) => + this.modifyTextForUrlLinks( + MARKDOWN_LINK_REGEX, + textToProcess, + replacement + ), replacement: (match, g1, g2) => { if (g1.match(CONST.REG_EXP.EMOJIS) || !g1.trim()) { return match; } - return `${g1.trim()}`; + return `${g1.trim()}`; }, rawInputReplacement: (match, g1, g2) => { if (g1.match(CONST.REG_EXP.EMOJIS) || !g1.trim()) { return match; } - return `${g1.trim()}`; - } + return `${g1.trim()}`; + }, }, /** @@ -191,7 +230,7 @@ export default class ExpensiMark { * Also, apply the mention rule after email/link to prevent mention appears in an email/link. */ { - name: 'hereMentions', + name: "hereMentions", regex: /([a-zA-Z0-9.!$%&+/=?^`{|}_-]?)(@here)([.!$%&+/=?^`{|}_-]?)(?=\b)(?!([\w'#%+-]*@(?:[a-z\d-]+\.)+[a-z]{2,}(?:\s|$|@here))|((?:(?!|[^<]*(<\/pre>|<\/code>))/gm, replacement: (match, g1, g2, g3) => { if (!Str.isValidMention(match)) { @@ -211,21 +250,28 @@ export default class ExpensiMark { * underscores */ { - name: 'userMentions', - regex: new RegExp(`(@here|[a-zA-Z0-9.!$%&+=?^\`{|}-]?)(@${CONST.REG_EXP.EMAIL_PART}|@${CONST.REG_EXP.PHONE_PART})(?!((?:(?!|[^<]*(<\\/pre>|<\\/code>))`, 'gim'), + name: "userMentions", + regex: new RegExp( + `(@here|[a-zA-Z0-9.!$%&+=?^\`{|}-]?)(@${CONST.REG_EXP.EMAIL_PART}|@${CONST.REG_EXP.PHONE_PART})(?!((?:(?!|[^<]*(<\\/pre>|<\\/code>))`, + "gim" + ), replacement: (match, g1, g2) => { if (!Str.isValidMention(match)) { return match; } - const phoneRegex = new RegExp(`^@${CONST.REG_EXP.PHONE_PART}$`); - return `${g1}${g2}${phoneRegex.test(g2) ? `@${CONST.SMS.DOMAIN}` : ''}`; + const phoneRegex = new RegExp( + `^@${CONST.REG_EXP.PHONE_PART}$` + ); + return `${g1}${g2}${ + phoneRegex.test(g2) ? `@${CONST.SMS.DOMAIN}` : "" + }`; }, }, { - name: 'hereMentionAfterUserMentions', + name: "hereMentionAfterUserMentions", regex: /(<\/mention-user>)(@here)(?=\b)/gm, - replacement: '$1$2', + replacement: "$1$2", }, /** @@ -233,14 +279,18 @@ export default class ExpensiMark { * and we do not want to break emails. */ { - name: 'autolink', + name: "autolink", process: (textToProcess, replacement) => { const regex = new RegExp( `(?![^<]*>|[^<>]*<\\/(?!h1>))([_*~]*?)${MARKDOWN_URL_REGEX}\\1(?!((?:(?!|[^<]*(<\\/pre>|<\\/code>|.+\\/>))`, - 'gi', + "gi" + ); + return this.modifyTextForUrlLinks( + regex, + textToProcess, + replacement ); - return this.modifyTextForUrlLinks(regex, textToProcess, replacement); }, replacement: (match, g1, g2) => { @@ -250,46 +300,65 @@ export default class ExpensiMark { rawInputReplacement: (_match, g1, g2) => { const href = Str.sanitizeURL(g2); return `${g1}${g2}${g1}`; - } + }, }, { - name: 'quote', + name: "quote", // We also want to capture a blank line before or after the quote so that we do not add extra spaces. // block quotes naturally appear on their own line. Blockquotes should not appear in code fences or // inline code blocks. A single prepending space should be stripped if it exists - process: (textToProcess, replacement, shouldKeepRawInput = false) => { + process: ( + textToProcess, + replacement, + shouldKeepRawInput = false + ) => { const regex = new RegExp( - /^> *(?! )(?![^<]*(?:<\/pre>|<\/code>))([^\v\n\r]+)/gm, + /^> *(?! )(?![^<]*(?:<\/pre>|<\/code>))([^\v\n\r]+)/gm ); if (shouldKeepRawInput) { - return textToProcess.replace(regex, g1 => replacement(g1, shouldKeepRawInput)); + return textToProcess.replace(regex, (g1) => + replacement(g1, shouldKeepRawInput) + ); } - return this.modifyTextForQuote(regex, textToProcess, replacement); + return this.modifyTextForQuote( + regex, + textToProcess, + replacement + ); }, replacement: (g1, shouldKeepRawInput = false) => { // We want to enable 2 options of nested heading inside the blockquote: "># heading" and "> # heading". // To do this we need to parse body of the quote without first space let isStartingWithSpace = false; - const textToReplace = g1.replace(/^>( )?/gm, (match, g2) => { - if (shouldKeepRawInput) { - isStartingWithSpace = !!g2; - return ''; + const textToReplace = g1.replace( + /^>( )?/gm, + (match, g2) => { + if (shouldKeepRawInput) { + isStartingWithSpace = !!g2; + return ""; + } + return match; } - return match; - }); - const filterRules = ['heading1']; + ); + const filterRules = ["heading1"]; // if we don't reach the max quote depth we allow the recursive call to process possible quote if (this.currentQuoteDepth < this.maxQuoteDepth - 1) { - filterRules.push('quote'); + filterRules.push("quote"); this.currentQuoteDepth++; } - const replacedText = this.replace(textToReplace, {filterRules, shouldEscapeText: false, shouldKeepRawInput}); + const replacedText = this.replace(textToReplace, { + filterRules, + shouldEscapeText: false, + shouldKeepRawInput, + }); this.currentQuoteDepth = 0; - return `
${isStartingWithSpace ? ' ' : ''}${replacedText}
`; + return `
${ + isStartingWithSpace ? " " : "" + }${replacedText}
`; }, }, { @@ -302,15 +371,26 @@ export default class ExpensiMark { * `_https://www.test.com_` * Use [\s\S]* instead of .* to match newline */ - name: 'italic', + name: "italic", regex: /(\b_+|\b)(?!_blank")_((?![\s_])[\s\S]*?[^\s_])_(?![^\W_])(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/mention-user>|_blank))/g, // We want to add extraLeadingUnderscores back before the tag unless textWithinUnderscores starts with valid email - replacement: (match, extraLeadingUnderscores, textWithinUnderscores) => { - if (textWithinUnderscores.includes('
') || this.containsNonPairTag(textWithinUnderscores)) { + replacement: ( + match, + extraLeadingUnderscores, + textWithinUnderscores + ) => { + if ( + textWithinUnderscores.includes("
") || + this.containsNonPairTag(textWithinUnderscores) + ) { return match; } - if (String(textWithinUnderscores).match(`^${CONST.REG_EXP.MARKDOWN_EMAIL}`)) { + if ( + String(textWithinUnderscores).match( + `^${CONST.REG_EXP.MARKDOWN_EMAIL}` + ) + ) { return `${extraLeadingUnderscores}${textWithinUnderscores}`; } return `${extraLeadingUnderscores}${textWithinUnderscores}`; @@ -323,44 +403,51 @@ export default class ExpensiMark { * Prevent emails from starting with [~_*]. Such emails should not be supported. */ { - name: 'autoEmail', + name: "autoEmail", regex: new RegExp( `([^\\w'#%+-]|^)${CONST.REG_EXP.MARKDOWN_EMAIL}(?!((?:(?!|[^<>]*<\\/(?!em|h1|blockquote))`, - 'gim', + "gim" ), replacement: '$1$2', - rawInputReplacement: '$1$2', + rawInputReplacement: + '$1$2', }, { // Use \B in this case because \b doesn't match * or ~. // \B will match everything that \b doesn't, so it works // for * and ~: https://www.rexegg.com/regex-boundaries.html#notb - name: 'bold', + name: "bold", regex: /\B\*((?![\s*])[\s\S]*?[^\s*])\*\B(?![^<]*(<\/pre>|<\/code>|<\/a>))/g, - replacement: (match, g1) => (g1.includes('
') || this.containsNonPairTag(g1) ? match : `${g1}`), + replacement: (match, g1) => + g1.includes("
") || this.containsNonPairTag(g1) + ? match + : `${g1}`, }, { - name: 'strikethrough', + name: "strikethrough", regex: /\B~((?![\s~])[\s\S]*?[^\s~])~\B(?![^<]*(<\/pre>|<\/code>|<\/a>))/g, - replacement: (match, g1) => (g1.includes('') || this.containsNonPairTag(g1) ? match : `${g1}`), + replacement: (match, g1) => + g1.includes("") || this.containsNonPairTag(g1) + ? match + : `${g1}`, }, { - name: 'newline', + name: "newline", regex: /\r?\n/g, - replacement: '
', + replacement: "
", }, { // We're removing
because when and
occur together, an extra line is added. - name: 'replacepre', + name: "replacepre", regex: /<\/pre>\s*/gi, - replacement: '', + replacement: "", }, { // We're removing
because when

and
occur together, an extra line is added. - name: 'replaceh1br', + name: "replaceh1br", regex: /<\/h1>/gi, - replacement: '

', + replacement: "", }, ]; @@ -372,98 +459,106 @@ export default class ExpensiMark { this.htmlToMarkdownRules = [ // Used to Exclude tags { - name: 'replacepre', + name: "replacepre", regex: /<\/pre>(.)/gi, - replacement: '
$1', + replacement: "
$1", }, { - name: 'exclude', + name: "exclude", regex: new RegExp( [ - '<(script|style)(?:"[^"]*"|\'[^\']*\'|[^\'">])*>([\\s\\S]*?)<\\/\\1>', - '(?![^<]*(<\\/pre>|<\\/code>))(\n|\r\n)?', - ].join(''), - 'gim', + "<(script|style)(?:\"[^\"]*\"|'[^']*'|[^'\">])*>([\\s\\S]*?)<\\/\\1>", + "(?![^<]*(<\\/pre>|<\\/code>))(\n|\r\n)?", + ].join(""), + "gim" ), - replacement: '', + replacement: "", }, { - name: 'nested', + name: "nested", regex: /<(pre)(?:"[^"]*"|'[^']*'|[^'">])*><(div|code)(?:"[^"]*"|'[^']*'|[^'">])*>([\s\S]*?)<\/\2><\/pre>/gi, - replacement: '
$3
' + replacement: "
$3
", }, { - name: 'newline', + name: "newline", // Replaces open and closing

tags with a single
// Slack uses special tag for empty lines instead of
tag - pre: inputString => inputString.replace('

', '
').replace('

', '
') - .replace(/()/g, '$1
').replace('
', '') - .replace(SLACK_SPAN_NEW_LINE_TAG + SLACK_SPAN_NEW_LINE_TAG, '


') - .replace(SLACK_SPAN_NEW_LINE_TAG, '

'), + pre: (inputString) => + inputString + .replace("

", "
") + .replace("

", "
") + .replace(/()/g, "$1
") + .replace("
", "") + .replace( + SLACK_SPAN_NEW_LINE_TAG + SLACK_SPAN_NEW_LINE_TAG, + "


" + ) + .replace(SLACK_SPAN_NEW_LINE_TAG, "

"), // Include the immediately followed newline as `
\n` should be equal to one \n. regex: /<])*>\n?/gi, - replacement: '\n', + replacement: "\n", }, { - name: 'heading1', + name: "heading1", regex: /[^\S\r\n]*<(h1)(?:"[^"]*"|'[^']*'|[^'">])*>(.*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi, - replacement: '

# $2

', + replacement: "

# $2

", }, { - name: 'listItem', + name: "listItem", regex: /\s*<(li)(?:"[^"]*"|'[^']*'|[^'">])*>(.*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))\s*/gi, - replacement: '
  • $2
  • ', + replacement: "
  • $2
  • ", }, // Use [\s\S]* instead of .* to match newline { - name: 'italic', + name: "italic", regex: /<(em|i)(?:"[^"]*"|'[^']*'|[^'">])*>([\s\S]*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi, - replacement: '_$2_', + replacement: "_$2_", }, { - name: 'bold', + name: "bold", regex: /<(b|strong)(?:"[^"]*"|'[^']*'|[^'">])*>([\s\S]*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi, - replacement: '*$2*', + replacement: "*$2*", }, { - name: 'strikethrough', + name: "strikethrough", regex: /<(del|s)(?:"[^"]*"|'[^']*'|[^'">])*>([\s\S]*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi, - replacement: '~$2~', + replacement: "~$2~", }, { - name: 'quote', + name: "quote", regex: /<(blockquote|q)(?:"[^"]*"|'[^']*'|[^'">])*>([\s\S]*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi, replacement: (match, g1, g2) => { // We remove the line break before heading inside quote to avoid adding extra line - const resultString = g2.replace(/\n?(

    # )/g, '$1') - .replace(/(

    |<\/h1>)+/g, '\n') + const resultString = g2 + .replace(/\n?(

    # )/g, "$1") + .replace(/(

    |<\/h1>)+/g, "\n") .trim() - .split('\n') - .map(m => `> ${m.replace(/<\/?blockquote>/g, '> ')}`) - .join('\n'); + .split("\n") + .map((m) => `> ${m.replace(/<\/?blockquote>/g, "> ")}`) + .join("\n"); // We want to keep
    tag here and let method replaceBlockElementWithNewLine to handle the line break later return `
    ${resultString}
    `; }, }, { - name: 'inlineCodeBlock', + name: "inlineCodeBlock", regex: /<(code)(?:"[^"]*"|'[^']*'|[^'">])*>(.*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi, - replacement: '`$2`', + replacement: "`$2`", }, { - name: 'codeFence', + name: "codeFence", regex: /<(pre)(?:"[^"]*"|'[^']*'|[^'">])*>([\s\S]*?)(\n?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi, replacement: (match, g1, g2) => `\`\`\`\n${g2}\n\`\`\``, }, { - name: 'anchor', + name: "anchor", regex: /<(a)[^><]*href\s*=\s*(['"])(.*?)\2(?:".*?"|'.*?'|[^'"><])*>([\s\S]*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi, replacement: (match, g1, g2, g3, g4) => { - const email = g3.startsWith('mailto:') ? g3.slice(7) : ''; + const email = g3.startsWith("mailto:") ? g3.slice(7) : ""; if (email === g4) { return email; } @@ -471,10 +566,10 @@ export default class ExpensiMark { }, }, { - name: 'image', + name: "image", regex: /<]*src\s*=\s*(['"])(.*?)\1(?:[^><]*alt\s*=\s*(['"])(.*?)\3)?[^><]*>*(?![^<][\s\S]*?(<\/pre>|<\/code>))/gi, - replacement: (match, g1, g2, g3, g4) => `![${g4 || g2}](${g2})` - } + replacement: (match, g1, g2, g3, g4) => `![${g4 || g2}](${g2})`, + }, ]; /** @@ -484,44 +579,44 @@ export default class ExpensiMark { */ this.htmlToTextRules = [ { - name: 'breakline', + name: "breakline", regex: /]*>/gi, - replacement: '\n', + replacement: "\n", }, { - name: 'blockquoteWrapHeadingOpen', + name: "blockquoteWrapHeadingOpen", regex: /

    /gi, - replacement: '
    ', + replacement: "
    ", }, { - name: 'blockquoteWrapHeadingClose', + name: "blockquoteWrapHeadingClose", regex: /<\/h1><\/blockquote>/gi, - replacement: '
    ', + replacement: "
    ", }, { - name: 'blockElementOpen', + name: "blockElementOpen", regex: /(.|\s)<(blockquote|h1|pre)>/gi, - replacement: '$1\n', + replacement: "$1\n", }, { - name: 'blockElementClose', + name: "blockElementClose", regex: /<\/(blockquote|h1|pre)>(.|\s)/gm, - replacement: '\n$2', + replacement: "\n$2", }, { - name: 'removeStyle', + name: "removeStyle", regex: /