expensify-common 2.0.43 → 2.0.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,6 +29,12 @@ type ReplaceOptions = {
29
29
  shouldEscapeText?: boolean;
30
30
  shouldKeepRawInput?: boolean;
31
31
  };
32
+ type TruncateOptions = {
33
+ ellipsis?: string;
34
+ truncateLastWord?: boolean;
35
+ slop?: number;
36
+ removeImageTag?: boolean;
37
+ };
32
38
  export default class ExpensiMark {
33
39
  static Log: Logger;
34
40
  /**
@@ -138,6 +144,27 @@ export default class ExpensiMark {
138
144
  * @returns original MD content escaped for use in HTML attribute value
139
145
  */
140
146
  escapeAttributeContent(content: string): string;
147
+ /**
148
+ * Determines the end position to truncate the HTML content while considering word boundaries.
149
+ *
150
+ * @param {string} content - The HTML content to be truncated.
151
+ * @param {number} tailPosition - The position up to which the content should be considered.
152
+ * @param {number} maxLength - The maximum length of the truncated content.
153
+ * @param {number} totalLength - The length of the content processed so far.
154
+ * @param {Object} opts - Options to customize the truncation.
155
+ * @returns {number} The calculated position to truncate the content.
156
+ */
157
+ getEndPosition(content: string, tailPosition: number | undefined, maxLength: number, totalLength: number, opts: TruncateOptions): number;
158
+ /**
159
+ * Truncate HTML string and keep tag safe.
160
+ * pulled from https://github.com/huang47/nodejs-html-truncate/blob/master/lib/truncate.js
161
+ *
162
+ * @param {string} html - The string that needs to be truncated
163
+ * @param {number} maxLength - Length of truncated string
164
+ * @param {Object} [options] - Optional configuration options
165
+ * @returns {string} The truncated string
166
+ */
167
+ truncateHTML(html: string, maxLength: number, options?: TruncateOptions): string;
141
168
  /**
142
169
  * Replaces text with a replacement based on a regex
143
170
  * @param text - The text to replace
@@ -368,8 +368,13 @@ class ExpensiMark {
368
368
  // \B will match everything that \b doesn't, so it works
369
369
  // for * and ~: https://www.rexegg.com/regex-boundaries.html#notb
370
370
  name: 'bold',
371
- regex: /(?<!<[^>]*)\B\*(?![^<]*(?:<\/pre>|<\/code>|<\/a>))((?![\s*])[\s\S]*?[^\s*](?<!\s))\*\B(?![^<]*>)(?![^<]*(<\/pre>|<\/code>|<\/a>))/g,
372
- replacement: (_extras, match, g1) => (g1.includes('</pre>') || this.containsNonPairTag(g1) ? match : `<strong>${g1}</strong>`),
371
+ regex: /(?<!<[^>]*)(\b_|\B)\*(?![^<]*(?:<\/pre>|<\/code>|<\/a>))((?![\s*])[\s\S]*?[^\s*](?<!\s))\*\B(?![^<]*>)(?![^<]*(<\/pre>|<\/code>|<\/a>))/g,
372
+ replacement: (_extras, match, g1, g2) => {
373
+ if (g1.includes('_')) {
374
+ return `${g1}<strong>${g2}</strong>`;
375
+ }
376
+ return g2.includes('</pre>') || this.containsNonPairTag(g2) ? match : `<strong>${g2}</strong>`;
377
+ },
373
378
  },
374
379
  {
375
380
  name: 'strikethrough',
@@ -1093,6 +1098,172 @@ class ExpensiMark {
1093
1098
  originalContent = str_1.default.replaceAll(originalContent, '\n', '');
1094
1099
  return Utils.escape(originalContent);
1095
1100
  }
1101
+ /**
1102
+ * Determines the end position to truncate the HTML content while considering word boundaries.
1103
+ *
1104
+ * @param {string} content - The HTML content to be truncated.
1105
+ * @param {number} tailPosition - The position up to which the content should be considered.
1106
+ * @param {number} maxLength - The maximum length of the truncated content.
1107
+ * @param {number} totalLength - The length of the content processed so far.
1108
+ * @param {Object} opts - Options to customize the truncation.
1109
+ * @returns {number} The calculated position to truncate the content.
1110
+ */
1111
+ getEndPosition(content, tailPosition, maxLength, totalLength, opts) {
1112
+ const WORD_BREAK_REGEX = /\W+/g;
1113
+ // Calculate the default position to truncate based on the maximum length and the length of the content processed so far
1114
+ const defaultPosition = maxLength - totalLength;
1115
+ // Define the slop value, which determines the tolerance for cutting off content near the maximum length
1116
+ const slop = opts.slop;
1117
+ if (!slop)
1118
+ return defaultPosition;
1119
+ // Initialize the position to the default position
1120
+ let position = defaultPosition;
1121
+ // Determine if the default position is considered "short" based on the slop value
1122
+ const isShort = defaultPosition < slop;
1123
+ // Calculate the position within the slop range
1124
+ const slopPos = isShort ? defaultPosition : slop - 1;
1125
+ // Extract the substring to analyze for word boundaries, considering the slop and tail position
1126
+ const substr = content.slice(isShort ? 0 : defaultPosition - slop, tailPosition !== undefined ? tailPosition : defaultPosition + slop);
1127
+ // Find the first word boundary within the substring
1128
+ const wordBreakMatch = WORD_BREAK_REGEX.exec(substr);
1129
+ // Adjust the position to avoid truncating in the middle of a word if the option is enabled
1130
+ if (!opts.truncateLastWord) {
1131
+ if (tailPosition && substr.length <= tailPosition) {
1132
+ // If tail position is defined and the substring length is within the tail position, set position to the substring length
1133
+ position = substr.length;
1134
+ }
1135
+ else {
1136
+ // Iterate through word boundary matches to adjust the position
1137
+ while (wordBreakMatch !== null) {
1138
+ if (wordBreakMatch.index < slopPos) {
1139
+ // If the word boundary is before the slop position, adjust position backward
1140
+ position = defaultPosition - (slopPos - wordBreakMatch.index);
1141
+ if (wordBreakMatch.index === 0 && defaultPosition <= 1) {
1142
+ break;
1143
+ }
1144
+ }
1145
+ else if (wordBreakMatch.index === slopPos) {
1146
+ // If the word boundary is at the slop position, set position to the default position
1147
+ position = defaultPosition;
1148
+ break;
1149
+ }
1150
+ else {
1151
+ // If the word boundary is after the slop position, adjust position forward
1152
+ position = defaultPosition + (wordBreakMatch.index - slopPos);
1153
+ break;
1154
+ }
1155
+ }
1156
+ }
1157
+ // If the character at the determined position is a whitespace, adjust position backward
1158
+ if (content.charAt(position - 1).match(/\s$/)) {
1159
+ position--;
1160
+ }
1161
+ }
1162
+ // Return the calculated position to truncate the content
1163
+ return position;
1164
+ }
1165
+ /**
1166
+ * Truncate HTML string and keep tag safe.
1167
+ * pulled from https://github.com/huang47/nodejs-html-truncate/blob/master/lib/truncate.js
1168
+ *
1169
+ * @param {string} html - The string that needs to be truncated
1170
+ * @param {number} maxLength - Length of truncated string
1171
+ * @param {Object} [options] - Optional configuration options
1172
+ * @returns {string} The truncated string
1173
+ */
1174
+ truncateHTML(html, maxLength, options) {
1175
+ const EMPTY_STRING = '';
1176
+ const DEFAULT_TRUNCATE_SYMBOL = '...';
1177
+ const DEFAULT_SLOP = Math.min(10, maxLength);
1178
+ const tagsStack = [];
1179
+ const KEY_VALUE_REGEX = '((?:\\s+(?:\\w+|-)+(?:\\s*=\\s*(?:"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\'|[^\'">\\s]+))?)*)';
1180
+ const IS_CLOSE_REGEX = '\\s*\\/?\\s*';
1181
+ const CLOSE_REGEX = '\\s*\\/\\s*';
1182
+ const SELF_CLOSE_REGEX = new RegExp(`<\\/?(\\w+)${KEY_VALUE_REGEX}${CLOSE_REGEX}>`);
1183
+ const HTML_TAG_REGEX = new RegExp(`<\\/?(\\w+)${KEY_VALUE_REGEX}${IS_CLOSE_REGEX}>`);
1184
+ const URL_REGEX = /(((ftp|https?):\/\/)[\\-\w@:%_\\+.~#?,&\\/\\/=]+)|((mailto:)?[_.\w\\-]+@([\w][\w\\-]+\.)+[a-zA-Z]{2,3})/g;
1185
+ const IMAGE_TAG_REGEX = new RegExp(`<img\\s*${KEY_VALUE_REGEX}${CLOSE_REGEX}>`);
1186
+ let truncatedContent = EMPTY_STRING;
1187
+ let totalLength = 0;
1188
+ let matches = HTML_TAG_REGEX.exec(html);
1189
+ let endResult;
1190
+ let index;
1191
+ let tag;
1192
+ let selfClose = null;
1193
+ let htmlString = html;
1194
+ const opts = Object.assign({ ellipsis: DEFAULT_TRUNCATE_SYMBOL, truncateLastWord: true, slop: DEFAULT_SLOP }, options);
1195
+ function removeImageTag(content) {
1196
+ const match = IMAGE_TAG_REGEX.exec(content);
1197
+ if (!match) {
1198
+ return content;
1199
+ }
1200
+ const matchIndex = match.index;
1201
+ const matchLength = match[0].length;
1202
+ return content.substring(0, matchIndex) + content.substring(matchIndex + matchLength);
1203
+ }
1204
+ function closeTags(tags) {
1205
+ return tags
1206
+ .reverse()
1207
+ .map((mappedTag) => {
1208
+ return `</${mappedTag}>`;
1209
+ })
1210
+ .join('');
1211
+ }
1212
+ while (matches) {
1213
+ matches = HTML_TAG_REGEX.exec(htmlString);
1214
+ if (!matches) {
1215
+ if (totalLength >= maxLength) {
1216
+ break;
1217
+ }
1218
+ matches = URL_REGEX.exec(htmlString);
1219
+ if (!matches || matches.index >= maxLength) {
1220
+ truncatedContent += htmlString.substring(0, this.getEndPosition(htmlString, undefined, maxLength, totalLength, opts));
1221
+ break;
1222
+ }
1223
+ while (matches) {
1224
+ endResult = matches[0];
1225
+ if (endResult !== null) {
1226
+ index = matches.index;
1227
+ truncatedContent += htmlString.substring(0, index + endResult.length - totalLength);
1228
+ htmlString = htmlString.substring(index + endResult.length);
1229
+ matches = URL_REGEX.exec(htmlString);
1230
+ }
1231
+ }
1232
+ break;
1233
+ }
1234
+ endResult = matches[0];
1235
+ index = matches.index;
1236
+ if (totalLength + index > maxLength) {
1237
+ truncatedContent += htmlString.substring(0, this.getEndPosition(htmlString, index, maxLength, totalLength, opts));
1238
+ break;
1239
+ }
1240
+ else {
1241
+ totalLength += index;
1242
+ truncatedContent += htmlString.substring(0, index);
1243
+ }
1244
+ if (endResult[1] === '/') {
1245
+ tagsStack.pop();
1246
+ selfClose = null;
1247
+ }
1248
+ else {
1249
+ selfClose = SELF_CLOSE_REGEX.exec(endResult);
1250
+ if (!selfClose) {
1251
+ tag = matches[1];
1252
+ tagsStack.push(tag);
1253
+ }
1254
+ }
1255
+ truncatedContent += selfClose ? selfClose[0] : endResult;
1256
+ htmlString = htmlString.substring(index + endResult.length); // Update htmlString
1257
+ }
1258
+ if (htmlString.length > maxLength - totalLength && opts.ellipsis) {
1259
+ truncatedContent += opts.ellipsis ? '...' : '';
1260
+ }
1261
+ truncatedContent += closeTags(tagsStack);
1262
+ if (opts.removeImageTag) {
1263
+ truncatedContent = removeImageTag(truncatedContent);
1264
+ }
1265
+ return truncatedContent;
1266
+ }
1096
1267
  /**
1097
1268
  * Replaces text with a replacement based on a regex
1098
1269
  * @param text - The text to replace
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expensify-common",
3
- "version": "2.0.43",
3
+ "version": "2.0.45",
4
4
  "author": "Expensify, Inc.",
5
5
  "description": "Expensify libraries and components shared across different repos",
6
6
  "homepage": "https://expensify.com",