diff --git a/.github/actions/javascript/isDeployChecklistLocked/index.js b/.github/actions/javascript/isDeployChecklistLocked/index.js index b1e68e9965bf..ebdf2bf6961c 100644 --- a/.github/actions/javascript/isDeployChecklistLocked/index.js +++ b/.github/actions/javascript/isDeployChecklistLocked/index.js @@ -23112,6 +23112,7 @@ var require_ExpensiMark = __commonJS({ var Constants = __importStar(require_CONST()); var UrlPatterns = __importStar(require_Url()); var Logger_1 = __importDefault(require_Logger()); + var tlds_1 = __importDefault(require_tlds()); var Utils = __importStar(require_utils()); var EXTRAS_DEFAULT = {}; var ASCII_DIGIT_START = "0".charCodeAt(0); @@ -23124,6 +23125,9 @@ var require_ExpensiMark = __commonJS({ var NON_BREAKING_SPACE_CODE = 160; var URL_PROTOCOLS = ["https://", "http://", "ftps://", "ftp://"]; var URL_CANDIDATE_PREFIX_CHARACTERS = "@_*~"; + var URL_TLD_LIST = tlds_1.default.toLowerCase().split("|"); + var URL_TLDS = new Set(URL_TLD_LIST); + var MAX_URL_TLD_LENGTH = Math.max(...URL_TLD_LIST.map((tld) => tld.length)); var PROTECTED_TAG_NAMES = /* @__PURE__ */ new Set(["a", "code", "pre", "video"]); var MARKDOWN_LINK_REGEX = new RegExp(`\\[((?:[^\\[\\]\\r\\n]*(?:\\[[^\\[\\]\\r\\n]*][^\\[\\]\\r\\n]*)*))]\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); var MARKDOWN_IMAGE_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); @@ -23169,6 +23173,9 @@ var require_ExpensiMark = __commonJS({ } return text.replace(regexp, replacement); } + function canUseCandidateScanning(text, shouldEscapeText) { + return shouldEscapeText || !text.includes("<") && !text.includes(">"); + } function isAsciiAlphaNumeric(character) { if (!character) { return false; @@ -23240,15 +23247,59 @@ var require_ExpensiMark = __commonJS({ } return URL_PROTOCOLS.find((protocol) => text.slice(position, position + protocol.length).toLowerCase() === protocol); } - function findHostnameEnd(text, hostnameStart, dotPosition) { - let hostnameEnd = dotPosition + 1; - while (hostnameEnd < text.length && (isAsciiAlphaNumeric(text[hostnameEnd]) || text[hostnameEnd] === "-")) { - hostnameEnd++; + function isValidHostnameLabel(text, start, end) { + if (start >= end || !isAsciiAlphaNumeric(text[start]) || !isAsciiAlphaNumeric(text[end - 1])) { + return false; } - if (hostnameStart === dotPosition || hostnameEnd === dotPosition + 1) { - return void 0; + for (let index = start + 1; index < end - 1; index++) { + if (!isAsciiAlphaNumeric(text[index]) && text[index] !== "-") { + return false; + } + } + return true; + } + function findHostnameStart(text, dotPosition) { + let hostnameStart = dotPosition; + let labelEnd = dotPosition; + while (labelEnd > 0) { + let rawLabelStart = labelEnd - 1; + while (rawLabelStart >= 0 && text[rawLabelStart] !== "." && isHostnameCharacter(text[rawLabelStart])) { + rawLabelStart--; + } + rawLabelStart++; + let labelStart = rawLabelStart; + while (labelStart < labelEnd && text[labelStart] === "-") { + labelStart++; + } + if (!isValidHostnameLabel(text, labelStart, labelEnd)) { + break; + } + hostnameStart = labelStart; + if (labelStart !== rawLabelStart) { + break; + } + const separatorPosition = labelStart - 1; + if (separatorPosition < 0 || text[separatorPosition] !== ".") { + break; + } + labelEnd = separatorPosition; } - return hostnameEnd; + return hostnameStart === dotPosition ? void 0 : hostnameStart; + } + function findKnownTldEnd(text, dotPosition) { + const maximumEnd = Math.min(text.length, dotPosition + 1 + MAX_URL_TLD_LENGTH); + for (let end = dotPosition + 2; end <= maximumEnd; end++) { + const currentCharacter = text[end - 1]; + if (!isAsciiAlphaNumeric(currentCharacter) && currentCharacter !== "-") { + break; + } + const nextCharacter = text[end]; + const hasValidBoundary = !nextCharacter || nextCharacter === ":" || nextCharacter === "_" || !isWordCharacter(nextCharacter); + if (hasValidBoundary && URL_TLDS.has(text.slice(dotPosition + 1, end).toLowerCase())) { + return end; + } + } + return void 0; } function extendUrlCandidateBoundaries(text, start, end) { let candidateStart = start; @@ -23261,24 +23312,65 @@ var require_ExpensiMark = __commonJS({ } return { start: candidateStart, end: candidateEnd }; } + function startsWithIgnoreCase(text, expected, position) { + return text.slice(position, position + expected.length).toLowerCase() === expected; + } + function filterUrlCandidatesBlockedByFollowingHtml(text, candidates) { + var _a; + if (candidates.length === 0 || !text.includes("<") && !text.includes(">")) { + return candidates; + } + const validCandidates = []; + let candidateIndex = candidates.length - 1; + let nextLessThan = text.length; + let nextGreaterThan = text.length; + let nextOpeningAnchor = text.length; + let nextClosingAnchor = text.length; + for (let index = text.length; index >= 0 && candidateIndex >= 0; index--) { + if (text[index] === "<") { + nextLessThan = index; + if (((_a = text[index + 1]) === null || _a === void 0 ? void 0 : _a.toLowerCase()) === "a") { + nextOpeningAnchor = index; + } else if (startsWithIgnoreCase(text, "", index)) { + nextClosingAnchor = index; + } + } else if (text[index] === ">") { + nextGreaterThan = index; + } + while (candidateIndex >= 0 && candidates[candidateIndex].end === index) { + const firstHtmlBoundaryIsClosingTag = nextLessThan < nextGreaterThan && text.startsWith("", nextLessThan); + const firstTagIsProtectedClosingTag = startsWithIgnoreCase(text, "", nextLessThan) || startsWithIgnoreCase(text, "", nextLessThan); + const isBlockedByFollowingHtml = ( + // Mirrors `(?![^<]*>)`: reject when `>` appears before the next `<`. + nextGreaterThan < nextLessThan || // Mirrors `[^<>]*<\/(?!h1>)`: reject a later closing tag other than ``. + firstHtmlBoundaryIsClosingTag || // Mirrors `((?:(?!`: reject `` unless another `` appears first. + nextClosingAnchor < nextOpeningAnchor || // Mirrors `[^<]*(<\/pre>|<\/code>)`: reject a later protected closing tag. + firstTagIsProtectedClosingTag + ); + if (!isBlockedByFollowingHtml) { + validCandidates.push(candidates[candidateIndex]); + } + candidateIndex--; + } + } + return validCandidates.reverse(); + } function findUrlCandidates(text) { const candidates = []; const protectedTags = []; let index = 0; - let hostnameRunStart = 0; while (index < text.length) { if (text[index] === "<") { const nextIndex = updateProtectedTagStack(text, index, protectedTags); if (nextIndex === void 0) { - break; + index++; + continue; } index = nextIndex; - hostnameRunStart = index; continue; } if (protectedTags.length > 0) { index++; - hostnameRunStart = index; continue; } const matchedProtocol = getProtocolAt(text, index); @@ -23286,29 +23378,23 @@ var require_ExpensiMark = __commonJS({ const candidate2 = extendUrlCandidateBoundaries(text, index, index + matchedProtocol.length); candidates.push(candidate2); index = candidate2.end; - hostnameRunStart = candidate2.end; - continue; - } - if (!isHostnameCharacter(text[index])) { - hostnameRunStart = index + 1; - index++; continue; } if (text[index] !== ".") { index++; continue; } - const hostnameEnd = findHostnameEnd(text, hostnameRunStart, index); - if (hostnameEnd === void 0) { + const tldEnd = findKnownTldEnd(text, index); + const hostnameStart = tldEnd === void 0 ? void 0 : findHostnameStart(text, index); + if (tldEnd === void 0 || hostnameStart === void 0) { index++; continue; } - const candidate = extendUrlCandidateBoundaries(text, hostnameRunStart, hostnameEnd); + const candidate = extendUrlCandidateBoundaries(text, hostnameStart, tldEnd); candidates.push(candidate); index = candidate.end; - hostnameRunStart = candidate.end; } - return candidates; + return filterUrlCandidatesBlockedByFollowingHtml(text, candidates); } function replaceMarkdownCandidates(text, regexp, replacement, marker, canOpen) { if (!text.includes(marker)) { @@ -23381,6 +23467,14 @@ var require_ExpensiMark = __commonJS({ output.push(text.slice(outputStart)); return output.join(""); } + function processMarkdownRule(regex2, marker, canOpen) { + return (textToProcess, replacement, _shouldKeepRawInput, shouldEscapeText) => { + if (canUseCandidateScanning(textToProcess, shouldEscapeText)) { + return replaceMarkdownCandidates(textToProcess, regex2, replacement, marker, canOpen); + } + return replaceTextWithExtras(textToProcess, regex2, EXTRAS_DEFAULT, replacement); + }; + } function replaceBlockElementWithNewLine(htmlString) { let splitText = htmlString.replaceAll(/
> (|<\/div>||\n<\/comment>|<\/comment>|

|<\/h1>|

|<\/h2>|

|<\/h3>|

|<\/h4>|

|<\/h5>|
|<\/h6>|

|<\/p>|

  • |<\/li>)/gi, "
    > ").split(/|<\/div>||\n<\/comment>|<\/comment>|

    |<\/h1>|

    |<\/h2>|

    |<\/h3>|

    |<\/h4>|

    |<\/h5>|
    |<\/h6>|

    |<\/p>|

  • |<\/li>|
    |<\/blockquote>/); const stripHTML = (text) => str_1.default.stripHTML(text); @@ -23799,9 +23893,9 @@ var require_ExpensiMark = __commonJS({ */ { name: "autolink", - process: (textToProcess, replacement) => { + process: (textToProcess, replacement, _shouldKeepRawInput, shouldEscapeText) => { const regex2 = new RegExp(`(?![^<]*>|[^<>]*<\\/(?!h1>))([_*~]*?)${UrlPatterns.MARKDOWN_URL_REGEX}\\1(?!((?:(?!|[^<]*(<\\/pre>|<\\/code>))`, "gi"); - return this.modifyTextForUrlLinks(regex2, textToProcess, replacement, true); + return this.modifyTextForUrlLinks(regex2, textToProcess, replacement, canUseCandidateScanning(textToProcess, shouldEscapeText)); }, replacement: (_extras, _match, g1, g2) => { const href = str_1.default.sanitizeURL(g2); @@ -23870,7 +23964,8 @@ ${"
    ".repeat(i)}`, "\n"); name: "autoEmail", regex: new RegExp(`([^\\w'#%+-]|^)${Constants.CONST.REG_EXP.MARKDOWN_EMAIL}(?!((?:(?!|[^<>]*<\\/(?!em|h1|blockquote))`, "gim"), replacement: '$1$2', - rawInputReplacement: '$1$2' + rawInputReplacement: '$1$2', + shouldSkipProcessing: (textToCheck) => !textToCheck.includes("@") }, /** * This regex matches a short user mention in a string. @@ -23906,7 +24001,7 @@ ${"
    ".repeat(i)}`, "\n"); // \B will match everything that \b doesn't, so it works // for * and ~: https://www.rexegg.com/regex-boundaries.html#notb name: "bold", - process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, BOLD_MARKDOWN_REGEX, replacement, "*", canOpenBoldMarkdown), + process: processMarkdownRule(BOLD_MARKDOWN_REGEX, "*", canOpenBoldMarkdown), replacement: (_extras, match, g1, g2) => { if (g1.includes("_")) { return `${g1}${g2}`; @@ -23916,7 +24011,7 @@ ${"
    ".repeat(i)}`, "\n"); }, { name: "strikethrough", - process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, STRIKETHROUGH_MARKDOWN_REGEX, replacement, "~", canOpenStrikethroughMarkdown), + process: processMarkdownRule(STRIKETHROUGH_MARKDOWN_REGEX, "~", canOpenStrikethroughMarkdown), replacement: (_extras, match, g1) => g1.includes("") || containsNonPairTag(g1) ? match : `${g1}` }, { @@ -24285,7 +24380,7 @@ ${g2} } const replacement = shouldKeepRawInput && rule.rawInputReplacement ? rule.rawInputReplacement : rule.replacement; if ("process" in rule) { - replacedText = rule.process(replacedText, replacement, shouldKeepRawInput); + replacedText = rule.process(replacedText, replacement, shouldKeepRawInput, shouldEscapeText); } else { replacedText = replaceTextWithExtras(replacedText, rule.regex, extras, replacement); } diff --git a/.github/actions/javascript/proposalPoliceComment/index.js b/.github/actions/javascript/proposalPoliceComment/index.js index 6c66aba05ee9..126b34462df4 100644 --- a/.github/actions/javascript/proposalPoliceComment/index.js +++ b/.github/actions/javascript/proposalPoliceComment/index.js @@ -43926,6 +43926,7 @@ var require_ExpensiMark = __commonJS({ var Constants = __importStar(require_CONST()); var UrlPatterns = __importStar(require_Url()); var Logger_1 = __importDefault(require_Logger()); + var tlds_1 = __importDefault(require_tlds()); var Utils = __importStar(require_utils2()); var EXTRAS_DEFAULT = {}; var ASCII_DIGIT_START = "0".charCodeAt(0); @@ -43938,6 +43939,9 @@ var require_ExpensiMark = __commonJS({ var NON_BREAKING_SPACE_CODE = 160; var URL_PROTOCOLS = ["https://", "http://", "ftps://", "ftp://"]; var URL_CANDIDATE_PREFIX_CHARACTERS = "@_*~"; + var URL_TLD_LIST = tlds_1.default.toLowerCase().split("|"); + var URL_TLDS = new Set(URL_TLD_LIST); + var MAX_URL_TLD_LENGTH = Math.max(...URL_TLD_LIST.map((tld) => tld.length)); var PROTECTED_TAG_NAMES = /* @__PURE__ */ new Set(["a", "code", "pre", "video"]); var MARKDOWN_LINK_REGEX = new RegExp(`\\[((?:[^\\[\\]\\r\\n]*(?:\\[[^\\[\\]\\r\\n]*][^\\[\\]\\r\\n]*)*))]\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); var MARKDOWN_IMAGE_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); @@ -43983,6 +43987,9 @@ var require_ExpensiMark = __commonJS({ } return text.replace(regexp, replacement); } + function canUseCandidateScanning(text, shouldEscapeText) { + return shouldEscapeText || !text.includes("<") && !text.includes(">"); + } function isAsciiAlphaNumeric(character) { if (!character) { return false; @@ -44054,15 +44061,59 @@ var require_ExpensiMark = __commonJS({ } return URL_PROTOCOLS.find((protocol) => text.slice(position, position + protocol.length).toLowerCase() === protocol); } - function findHostnameEnd(text, hostnameStart, dotPosition) { - let hostnameEnd = dotPosition + 1; - while (hostnameEnd < text.length && (isAsciiAlphaNumeric(text[hostnameEnd]) || text[hostnameEnd] === "-")) { - hostnameEnd++; + function isValidHostnameLabel(text, start, end) { + if (start >= end || !isAsciiAlphaNumeric(text[start]) || !isAsciiAlphaNumeric(text[end - 1])) { + return false; } - if (hostnameStart === dotPosition || hostnameEnd === dotPosition + 1) { - return void 0; + for (let index = start + 1; index < end - 1; index++) { + if (!isAsciiAlphaNumeric(text[index]) && text[index] !== "-") { + return false; + } + } + return true; + } + function findHostnameStart(text, dotPosition) { + let hostnameStart = dotPosition; + let labelEnd = dotPosition; + while (labelEnd > 0) { + let rawLabelStart = labelEnd - 1; + while (rawLabelStart >= 0 && text[rawLabelStart] !== "." && isHostnameCharacter(text[rawLabelStart])) { + rawLabelStart--; + } + rawLabelStart++; + let labelStart = rawLabelStart; + while (labelStart < labelEnd && text[labelStart] === "-") { + labelStart++; + } + if (!isValidHostnameLabel(text, labelStart, labelEnd)) { + break; + } + hostnameStart = labelStart; + if (labelStart !== rawLabelStart) { + break; + } + const separatorPosition = labelStart - 1; + if (separatorPosition < 0 || text[separatorPosition] !== ".") { + break; + } + labelEnd = separatorPosition; } - return hostnameEnd; + return hostnameStart === dotPosition ? void 0 : hostnameStart; + } + function findKnownTldEnd(text, dotPosition) { + const maximumEnd = Math.min(text.length, dotPosition + 1 + MAX_URL_TLD_LENGTH); + for (let end = dotPosition + 2; end <= maximumEnd; end++) { + const currentCharacter = text[end - 1]; + if (!isAsciiAlphaNumeric(currentCharacter) && currentCharacter !== "-") { + break; + } + const nextCharacter = text[end]; + const hasValidBoundary = !nextCharacter || nextCharacter === ":" || nextCharacter === "_" || !isWordCharacter(nextCharacter); + if (hasValidBoundary && URL_TLDS.has(text.slice(dotPosition + 1, end).toLowerCase())) { + return end; + } + } + return void 0; } function extendUrlCandidateBoundaries(text, start, end) { let candidateStart = start; @@ -44075,24 +44126,65 @@ var require_ExpensiMark = __commonJS({ } return { start: candidateStart, end: candidateEnd }; } + function startsWithIgnoreCase(text, expected, position) { + return text.slice(position, position + expected.length).toLowerCase() === expected; + } + function filterUrlCandidatesBlockedByFollowingHtml(text, candidates) { + var _a3; + if (candidates.length === 0 || !text.includes("<") && !text.includes(">")) { + return candidates; + } + const validCandidates = []; + let candidateIndex = candidates.length - 1; + let nextLessThan = text.length; + let nextGreaterThan = text.length; + let nextOpeningAnchor = text.length; + let nextClosingAnchor = text.length; + for (let index = text.length; index >= 0 && candidateIndex >= 0; index--) { + if (text[index] === "<") { + nextLessThan = index; + if (((_a3 = text[index + 1]) === null || _a3 === void 0 ? void 0 : _a3.toLowerCase()) === "a") { + nextOpeningAnchor = index; + } else if (startsWithIgnoreCase(text, "", index)) { + nextClosingAnchor = index; + } + } else if (text[index] === ">") { + nextGreaterThan = index; + } + while (candidateIndex >= 0 && candidates[candidateIndex].end === index) { + const firstHtmlBoundaryIsClosingTag = nextLessThan < nextGreaterThan && text.startsWith("", nextLessThan); + const firstTagIsProtectedClosingTag = startsWithIgnoreCase(text, "", nextLessThan) || startsWithIgnoreCase(text, "", nextLessThan); + const isBlockedByFollowingHtml = ( + // Mirrors `(?![^<]*>)`: reject when `>` appears before the next `<`. + nextGreaterThan < nextLessThan || // Mirrors `[^<>]*<\/(?!h1>)`: reject a later closing tag other than `
  • `. + firstHtmlBoundaryIsClosingTag || // Mirrors `((?:(?!`: reject `` unless another `` appears first. + nextClosingAnchor < nextOpeningAnchor || // Mirrors `[^<]*(<\/pre>|<\/code>)`: reject a later protected closing tag. + firstTagIsProtectedClosingTag + ); + if (!isBlockedByFollowingHtml) { + validCandidates.push(candidates[candidateIndex]); + } + candidateIndex--; + } + } + return validCandidates.reverse(); + } function findUrlCandidates(text) { const candidates = []; const protectedTags = []; let index = 0; - let hostnameRunStart = 0; while (index < text.length) { if (text[index] === "<") { const nextIndex = updateProtectedTagStack(text, index, protectedTags); if (nextIndex === void 0) { - break; + index++; + continue; } index = nextIndex; - hostnameRunStart = index; continue; } if (protectedTags.length > 0) { index++; - hostnameRunStart = index; continue; } const matchedProtocol = getProtocolAt(text, index); @@ -44100,29 +44192,23 @@ var require_ExpensiMark = __commonJS({ const candidate2 = extendUrlCandidateBoundaries(text, index, index + matchedProtocol.length); candidates.push(candidate2); index = candidate2.end; - hostnameRunStart = candidate2.end; - continue; - } - if (!isHostnameCharacter(text[index])) { - hostnameRunStart = index + 1; - index++; continue; } if (text[index] !== ".") { index++; continue; } - const hostnameEnd = findHostnameEnd(text, hostnameRunStart, index); - if (hostnameEnd === void 0) { + const tldEnd = findKnownTldEnd(text, index); + const hostnameStart = tldEnd === void 0 ? void 0 : findHostnameStart(text, index); + if (tldEnd === void 0 || hostnameStart === void 0) { index++; continue; } - const candidate = extendUrlCandidateBoundaries(text, hostnameRunStart, hostnameEnd); + const candidate = extendUrlCandidateBoundaries(text, hostnameStart, tldEnd); candidates.push(candidate); index = candidate.end; - hostnameRunStart = candidate.end; } - return candidates; + return filterUrlCandidatesBlockedByFollowingHtml(text, candidates); } function replaceMarkdownCandidates(text, regexp, replacement, marker, canOpen) { if (!text.includes(marker)) { @@ -44195,6 +44281,14 @@ var require_ExpensiMark = __commonJS({ output.push(text.slice(outputStart)); return output.join(""); } + function processMarkdownRule(regex2, marker, canOpen) { + return (textToProcess, replacement, _shouldKeepRawInput, shouldEscapeText) => { + if (canUseCandidateScanning(textToProcess, shouldEscapeText)) { + return replaceMarkdownCandidates(textToProcess, regex2, replacement, marker, canOpen); + } + return replaceTextWithExtras(textToProcess, regex2, EXTRAS_DEFAULT, replacement); + }; + } function replaceBlockElementWithNewLine(htmlString) { let splitText = htmlString.replaceAll(/
    > (|<\/div>||\n<\/comment>|<\/comment>|

    |<\/h1>|

    |<\/h2>|

    |<\/h3>|

    |<\/h4>|

    |<\/h5>|
    |<\/h6>|

    |<\/p>|

  • |<\/li>)/gi, "
    > ").split(/|<\/div>||\n<\/comment>|<\/comment>|

    |<\/h1>|

    |<\/h2>|

    |<\/h3>|

    |<\/h4>|

    |<\/h5>|
    |<\/h6>|

    |<\/p>|

  • |<\/li>|
    |<\/blockquote>/); const stripHTML = (text) => str_1.default.stripHTML(text); @@ -44613,9 +44707,9 @@ var require_ExpensiMark = __commonJS({ */ { name: "autolink", - process: (textToProcess, replacement) => { + process: (textToProcess, replacement, _shouldKeepRawInput, shouldEscapeText) => { const regex2 = new RegExp(`(?![^<]*>|[^<>]*<\\/(?!h1>))([_*~]*?)${UrlPatterns.MARKDOWN_URL_REGEX}\\1(?!((?:(?!|[^<]*(<\\/pre>|<\\/code>))`, "gi"); - return this.modifyTextForUrlLinks(regex2, textToProcess, replacement, true); + return this.modifyTextForUrlLinks(regex2, textToProcess, replacement, canUseCandidateScanning(textToProcess, shouldEscapeText)); }, replacement: (_extras, _match, g1, g2) => { const href = str_1.default.sanitizeURL(g2); @@ -44684,7 +44778,8 @@ ${"
    ".repeat(i)}`, "\n"); name: "autoEmail", regex: new RegExp(`([^\\w'#%+-]|^)${Constants.CONST.REG_EXP.MARKDOWN_EMAIL}(?!((?:(?!|[^<>]*<\\/(?!em|h1|blockquote))`, "gim"), replacement: '$1$2', - rawInputReplacement: '$1$2' + rawInputReplacement: '$1$2', + shouldSkipProcessing: (textToCheck) => !textToCheck.includes("@") }, /** * This regex matches a short user mention in a string. @@ -44720,7 +44815,7 @@ ${"
    ".repeat(i)}`, "\n"); // \B will match everything that \b doesn't, so it works // for * and ~: https://www.rexegg.com/regex-boundaries.html#notb name: "bold", - process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, BOLD_MARKDOWN_REGEX, replacement, "*", canOpenBoldMarkdown), + process: processMarkdownRule(BOLD_MARKDOWN_REGEX, "*", canOpenBoldMarkdown), replacement: (_extras, match2, g1, g2) => { if (g1.includes("_")) { return `${g1}${g2}`; @@ -44730,7 +44825,7 @@ ${"
    ".repeat(i)}`, "\n"); }, { name: "strikethrough", - process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, STRIKETHROUGH_MARKDOWN_REGEX, replacement, "~", canOpenStrikethroughMarkdown), + process: processMarkdownRule(STRIKETHROUGH_MARKDOWN_REGEX, "~", canOpenStrikethroughMarkdown), replacement: (_extras, match2, g1) => g1.includes("") || containsNonPairTag(g1) ? match2 : `${g1}` }, { @@ -45099,7 +45194,7 @@ ${g2} } const replacement = shouldKeepRawInput && rule.rawInputReplacement ? rule.rawInputReplacement : rule.replacement; if ("process" in rule) { - replacedText = rule.process(replacedText, replacement, shouldKeepRawInput); + replacedText = rule.process(replacedText, replacement, shouldKeepRawInput, shouldEscapeText); } else { replacedText = replaceTextWithExtras(replacedText, rule.regex, extras, replacement); } diff --git a/ios/Podfile.lock b/ios/Podfile.lock index bdaa6442b817..5a54a64a1e5a 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -2954,7 +2954,7 @@ PODS: - RNGoogleSignin (10.0.1): - GoogleSignIn (~> 7.0) - React-Core - - RNLiveMarkdown (0.1.336): + - RNLiveMarkdown (0.1.342): - hermes-engine - RCTRequired - RCTTypeSafety @@ -4103,7 +4103,7 @@ SPEC CHECKSUMS: RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8 RNGestureHandler: 7b07d9192bc65c6883fb37fdecc05dc6b61c814f RNGoogleSignin: 89877c73f0fbf6af2038fbdb7b73b5a25b8330cc - RNLiveMarkdown: e1379ad7c01172909900127aff8108582c605200 + RNLiveMarkdown: f4c383d846d5d756f42bdf6006e85d5baf0ebfc8 RNLocalize: 82e7aa2857ce5dcd9e2a0c04f8c34c2ed50821df rnmapbox-maps: 54eb40428a4fd89f484a492fc7186bc1c1eea9ce RNNitroSQLite: e354c4a31133050e6bbde1c136b8ac4857830236 diff --git a/package-lock.json b/package-lock.json index 15793d5e4dd7..426dfad3e953 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "@expensify/nitro-utils": "file:./modules/ExpensifyNitroUtils", "@expensify/react-native-background-task": "file:./modules/background-task", "@expensify/react-native-hybrid-app": "file:./modules/hybrid-app", - "@expensify/react-native-live-markdown": "0.1.336", + "@expensify/react-native-live-markdown": "0.1.342", "@expensify/react-native-wallet": "0.1.22", "@expo/metro-config": "57.0.7", "@expo/metro-runtime": "57.0.7", @@ -67,7 +67,7 @@ "date-fns-tz": "^3.2.0", "dom-serializer": "^0.2.2", "domhandler": "^5.0.3", - "expensify-common": "2.0.204", + "expensify-common": "2.0.207", "expo": "57.0.8", "expo-asset": "57.0.7", "expo-audio": "57.0.3", @@ -5951,19 +5951,16 @@ "link": true }, "node_modules/@expensify/react-native-live-markdown": { - "version": "0.1.336", - "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.336.tgz", - "integrity": "sha512-rrbcQmiTNdv7iySVbQWoY7wMGpIf9e+Wx7LJkx23AGkGwAkq7Mfptsly/4a6GaunIQ3Qh5+D84cLxlI42rLlWQ==", + "version": "0.1.342", + "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.342.tgz", + "integrity": "sha512-xt8t0pUTHHPfY9G25wZ0rQUP44eFdVQp44Mo2Ji2H66zr/qjUnDqDOQPJxi5azucUwI/uORmmMJOvXMdKsEDNg==", "license": "MIT", "workspaces": [ "./example", "./WebExample" ], - "engines": { - "node": ">= 18.0.0" - }, "peerDependencies": { - "expensify-common": ">=2.0.189", + "expensify-common": ">=2.0.207", "react": "*", "react-native": "*", "react-native-worklets": ">=0.7.0" @@ -26967,9 +26964,9 @@ } }, "node_modules/expensify-common": { - "version": "2.0.204", - "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.204.tgz", - "integrity": "sha512-Myewoj4qwrIoPRbDJl/ywEZlTdg+h470RwXo0HTffbMtEpdgIlS5OZXpkerDSmCkjKgDS6qrpMELDP/kyUX0ow==", + "version": "2.0.207", + "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.207.tgz", + "integrity": "sha512-AHrgp9LZkS9z/37G5ZbKbIwa9GqSCPVwfwilS1yhSQmFyL6Jb/eDxlopMfthaTbXO0NHTPRH2UmImlUtdlNYvw==", "license": "MIT", "dependencies": { "awesome-phonenumber": "^5.4.0", @@ -26989,9 +26986,9 @@ } }, "node_modules/expensify-common/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -29711,6 +29708,8 @@ }, "node_modules/immediate": { "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, "node_modules/import-fresh": { diff --git a/package.json b/package.json index 477fc033ce9f..a9f584006527 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "@expensify/nitro-utils": "file:./modules/ExpensifyNitroUtils", "@expensify/react-native-background-task": "file:./modules/background-task", "@expensify/react-native-hybrid-app": "file:./modules/hybrid-app", - "@expensify/react-native-live-markdown": "0.1.336", + "@expensify/react-native-live-markdown": "0.1.342", "@expensify/react-native-wallet": "0.1.22", "@expo/metro-config": "57.0.7", "@expo/metro-runtime": "57.0.7", @@ -143,7 +143,7 @@ "date-fns-tz": "^3.2.0", "dom-serializer": "^0.2.2", "domhandler": "^5.0.3", - "expensify-common": "2.0.204", + "expensify-common": "2.0.207", "expo": "57.0.8", "expo-asset": "57.0.7", "expo-audio": "57.0.3", diff --git a/src/libs/EmojiUtils.tsx b/src/libs/EmojiUtils.tsx index 3d08161358a7..25d03720fec4 100644 --- a/src/libs/EmojiUtils.tsx +++ b/src/libs/EmojiUtils.tsx @@ -44,7 +44,7 @@ const findEmojiByCode = (code: string): Emoji => Emojis.emojiCodeTableWithSkinTo const CODE_RANGE_TYPES = new Set(['code', 'pre']); function getCodeRanges(text: string): MarkdownRange[] { - return parseExpensiMark(text).filter((range) => CODE_RANGE_TYPES.has(range.type)); + return parseExpensiMark(text, CONST.MAX_MARKUP_LENGTH).filter((range) => CODE_RANGE_TYPES.has(range.type)); } function isPositionInsideCodeRanges(ranges: MarkdownRange[], position: number): boolean { @@ -52,7 +52,7 @@ function isPositionInsideCodeRanges(ranges: MarkdownRange[], position: number): } function isPositionInsideCodeBlock(text: string, position: number): boolean { - return isPositionInsideCodeRanges(parseExpensiMark(text), position); + return isPositionInsideCodeRanges(parseExpensiMark(text, CONST.MAX_MARKUP_LENGTH), position); } /** @@ -463,7 +463,7 @@ function replaceEmojis(text: string, preferredSkinTone: OnyxEntry = []; const shortcodeSearchPositions: Record = {}; const englishTrie = normalizedLocale !== CONST.LOCALES.DEFAULT ? getEmojiTrie(CONST.LOCALES.DEFAULT) : null; diff --git a/src/libs/FormatSelectionUtils.ts b/src/libs/FormatSelectionUtils.ts index eaae9f14633e..d8b417dc33e1 100644 --- a/src/libs/FormatSelectionUtils.ts +++ b/src/libs/FormatSelectionUtils.ts @@ -1,3 +1,5 @@ +import CONST from '@src/CONST'; + import type {MarkdownType} from '@expensify/react-native-live-markdown'; import {parseExpensiMark} from '@expensify/react-native-live-markdown'; @@ -23,7 +25,7 @@ function applyFormatting(text: string, selectionStart: number, selectionEnd: num } function findMatchingFormat(text: string, selectionStart: number, selectionEnd: number, formatRule: FormatRule): Match | null { - const markdownRanges = parseExpensiMark(text); + const markdownRanges = parseExpensiMark(text, CONST.MAX_MARKUP_LENGTH); for (const range of markdownRanges) { if (range?.type === formatRule.markdownType && range.start != null && range.length != null) { const rangeEnd = range.start + range.length; diff --git a/src/libs/ParsingUtils.ts b/src/libs/ParsingUtils.ts index bfdbf409ff77..2d1b2010c1d9 100644 --- a/src/libs/ParsingUtils.ts +++ b/src/libs/ParsingUtils.ts @@ -61,7 +61,7 @@ function decorateRangesWithShortMentions(ranges: MarkdownRange[], text: string, function parseExpensiMarkWithShortMentions(text: string, availableMentions: string[], currentUserMentions?: string[]) { 'worklet'; - const parsedRanges = parseExpensiMark(text); + const parsedRanges = parseExpensiMark(text, CONST.MAX_MARKUP_LENGTH); return decorateRangesWithShortMentions(parsedRanges, text, availableMentions, currentUserMentions); } diff --git a/tests/unit/EmojiTest.ts b/tests/unit/EmojiTest.ts index 8272ceb9e5e4..9303e8c33803 100644 --- a/tests/unit/EmojiTest.ts +++ b/tests/unit/EmojiTest.ts @@ -5,6 +5,7 @@ import * as Browser from '@libs/Browser'; import emojiTrieForLocale, {buildEmojisTrie} from '@libs/EmojiTrie'; import * as EmojiUtils from '@libs/EmojiUtils'; +import CONST from '@src/CONST'; import type FrequentlyUsedEmoji from '@src/types/onyx/FrequentlyUsedEmoji'; import type {ReportActionReaction} from '@src/types/onyx/ReportActionReactions'; @@ -176,6 +177,18 @@ describe('EmojiTest', () => { expect(EmojiUtils.replaceEmojis(text).text).toBe('`:smile:`'); }); + it('should revert emoji unicode inside a code block at the maximum markup length', () => { + // Given a long message with an emoji inside inline code, because code boundaries must still work at the App markup limit. + const code = '`šŸ˜„`'; + const prefix = 'a'.repeat(CONST.MAX_MARKUP_LENGTH - code.length); + + // When the message is passed through emoji replacement. + const result = EmojiUtils.replaceEmojis(`${prefix}${code}`).text; + + // Then the emoji should become a shortcode without leaving the code range. + expect(result).toBe(`${prefix}\`:smile:\``); + }); + it('should revert multiple emojis inside code block', () => { const text = '`šŸ˜„šŸ‘‹`'; expect(EmojiUtils.replaceEmojis(text).text).toBe('`:smile::wave:`'); @@ -191,6 +204,18 @@ describe('EmojiTest', () => { expect(EmojiUtils.replaceEmojis(text).text).toBe('šŸ‘‹ hello `:smile:` world'); }); + it('should replace a shortcode outside code but preserve one inside code at the maximum markup length', () => { + // Given a long message with one shortcode outside code and another inside, because both cases must keep their existing behavior at the limit. + const markdown = '\n:smile: and `:wave:`'; + const prefix = 'a'.repeat(CONST.MAX_MARKUP_LENGTH - markdown.length); + + // When emoji replacement processes the message. + const result = EmojiUtils.replaceEmojis(`${prefix}${markdown}`).text; + + // Then only the shortcode outside code should become an emoji. + expect(result).toBe(`${prefix}\nšŸ˜„ and \`:wave:\``); + }); + it('should handle same shortcode both inside and outside code block', () => { // Regression test: indexOf was returning the first occurrence for both, // causing the shortcode outside to not be converted @@ -284,6 +309,19 @@ describe('EmojiTest', () => { expect(EmojiUtils.isPositionInsideCodeBlock(text, 1)).toBe(true); }); + it('should return true for a position inside inline code at the maximum markup length', () => { + // Given a long message with the cursor inside inline code, because suggestion logic must recognize protected code after a long prefix. + const code = '`:smi`'; + const prefix = 'a'.repeat(CONST.MAX_MARKUP_LENGTH - code.length); + const text = `${prefix}${code}`; + + // When code-range detection checks the cursor position. + const isInsideCode = EmojiUtils.isPositionInsideCodeBlock(text, prefix.length + 1); + + // Then it should report that the cursor is inside code. + expect(isInsideCode).toBe(true); + }); + it('should return false for position outside code block', () => { const text = 'hello `:joy:`'; // Position 0 is 'h' which is outside diff --git a/tests/unit/FormatSelectionUtilsTest.ts b/tests/unit/FormatSelectionUtilsTest.ts index a5bd1d80abd2..2e35c53a94bb 100644 --- a/tests/unit/FormatSelectionUtilsTest.ts +++ b/tests/unit/FormatSelectionUtilsTest.ts @@ -1,5 +1,7 @@ import toggleSelectionFormat from '@libs/FormatSelectionUtils'; +import CONST from '@src/CONST'; + jest.unmock('@expensify/react-native-live-markdown'); describe('FormatSelectionUtils', () => { @@ -79,6 +81,17 @@ describe('FormatSelectionUtils', () => { expect(toggleSelectionFormat('_aaa_ _*bbb*_ _ccc_', 7, 12, 'formatItalic')).toEqual({updatedText: '_aaa_ *bbb* _ccc_', cursorOffset: -1}); }); + it('remove formatting from a long Markdown range', () => { + // Given a bold Markdown range at the App markup limit, because selection formatting must still find ranges in long messages. + const text = `*${'a'.repeat(CONST.MAX_MARKUP_LENGTH - 2)}*`; + + // When bold formatting is toggled for the selected content. + const result = toggleSelectionFormat(text, 1, text.length - 1, 'formatBold'); + + // Then the Markdown markers should be removed and the selection cursor offset should be adjusted. + expect(result).toEqual({updatedText: 'a'.repeat(CONST.MAX_MARKUP_LENGTH - 2), cursorOffset: -1}); + }); + it('do nothing for unsupported command', () => { expect(toggleSelectionFormat('aaa', 0, 3, 'formatUnderline')).toEqual({updatedText: 'aaa', cursorOffset: 0}); expect(toggleSelectionFormat('_aaa_', 1, 4, 'formatUnderline')).toEqual({updatedText: '_aaa_', cursorOffset: 0}); diff --git a/tests/unit/libs/ParsingUtilsTest.ts b/tests/unit/libs/ParsingUtilsTest.ts index 22d2102a5f57..869cf83454e4 100644 --- a/tests/unit/libs/ParsingUtilsTest.ts +++ b/tests/unit/libs/ParsingUtilsTest.ts @@ -1,9 +1,13 @@ -import {decorateRangesWithShortMentions, getParsedMessageWithShortMentions} from '@libs/ParsingUtils'; +import {decorateRangesWithShortMentions, getParsedMessageWithShortMentions, parseExpensiMarkWithShortMentions} from '@libs/ParsingUtils'; + +import CONST from '@src/CONST'; import type {MarkdownRange} from '@expensify/react-native-live-markdown'; const TEST_COMPANY_DOMAIN = 'myCompany.com'; +jest.unmock('@expensify/react-native-live-markdown'); + describe('decorateRangesWithShortMentions', () => { test('returns empty list for empty text', () => { const result = decorateRangesWithShortMentions([], '', [], []); @@ -223,3 +227,16 @@ describe('getParsedMessageWithShortMentions', () => { expect(result).toEqual(`this is @john.doe@myCompany.com's mention`); }); }); + +describe('parseExpensiMarkWithShortMentions', () => { + test('parses Markdown up to the App markup limit', () => { + // Given a bold Markdown range at the App markup limit, because live formatting must still work through the configured limit. + const text = `*${'a'.repeat(CONST.MAX_MARKUP_LENGTH - 2)}*`; + + // When the message is parsed with short-mention support. + const result = parseExpensiMarkWithShortMentions(text, [], []); + + // Then the parser should return the bold range instead of stopping at the library's lower default limit. + expect(result).toContainEqual({type: 'bold', start: 1, length: CONST.MAX_MARKUP_LENGTH - 2}); + }); +});