/ src / utils / string-formatting.ts
string-formatting.ts
  1  import type I18N from '@amp/web-apps-localization';
  2  import he from 'he';
  3  
  4  export function isString(string: unknown): string is string {
  5      return typeof string === 'string';
  6  }
  7  
  8  export function concatWithMiddot(pieces: string[], i18n: I18N): string {
  9      if (!pieces.length) {
 10          return '';
 11      }
 12  
 13      return (
 14          pieces.reduce((memo, current) => {
 15              return i18n.t('ASE.Web.AppStore.ContentA.Middot.ContentB', {
 16                  contentA: memo,
 17                  contentB: current,
 18              });
 19          }) || ''
 20      );
 21  }
 22  
 23  /**
 24   * Truncates a block of text to fit within a character limit, with a bias towards ending on a
 25   * full sentence. If no complete sentence fits within the limit, it falls back to a word-based
 26   * truncation with an ellipsis.
 27   *
 28   * @param {string} text - The text to truncate.
 29   * @param {number} limit - The maximum number of characters allowed before truncation.
 30   * @param {string} [locale=en_US] - The locale to use when breaking the text into segments.
 31   * @returns {string} Truncated text clipped to the limit, ideally ending on a natural stopping point.
 32   */
 33  export function truncateAroundLimit(
 34      text: string,
 35      limit: number,
 36      locale: string = 'en-US',
 37  ): string {
 38      // If the text is shorter than the limit, return all the text, unaltered.
 39      if (text.length <= limit) {
 40          return text;
 41      }
 42  
 43      const decodedText = he.decode(text);
 44  
 45      const isSegemnterSupported = typeof Intl.Segmenter === 'function';
 46      const terminatingPunctuation = '…';
 47  
 48      // A very naive fallback if the browser doesn't support `Segementer`,
 49      // which just truncates the text to the last space before the `limit`.
 50      if (!isSegemnterSupported) {
 51          const truncatedText = decodedText.slice(0, limit);
 52          const indexOfLastSpace = truncatedText.lastIndexOf(' ');
 53          if (indexOfLastSpace) {
 54              return (
 55                  truncatedText.slice(0, indexOfLastSpace).trim() +
 56                  terminatingPunctuation
 57              );
 58          } else {
 59              // If the text is an _exteremly_ long word or block of text, like a URL
 60              return truncatedText.trim() + terminatingPunctuation;
 61          }
 62      }
 63  
 64      const sentences = Array.from(
 65          new Intl.Segmenter(locale, { granularity: 'sentence' }).segment(text),
 66          (s) => s.segment,
 67      );
 68  
 69      let result = '';
 70      for (const sentence of sentences) {
 71          // If there is still room to add another sentence without going over the limit, add it.
 72          if (result.length + sentence.length <= limit) {
 73              result += sentence;
 74          } else {
 75              break;
 76          }
 77      }
 78  
 79      result = result.trim();
 80  
 81      // If the result we built based on full sentences is close-enough to the desired limit
 82      // (e.g. within the threshold of 75% of 160), we can use it.
 83      if (result.length >= limit * 0.75) {
 84          return result;
 85      }
 86  
 87      // Otherwise, fallback to building up single words until we approach the limit.
 88      const segments = Array.from(
 89          new Intl.Segmenter(locale, { granularity: 'word' }).segment(
 90              decodedText,
 91          ),
 92      );
 93  
 94      result = '';
 95      for (const { segment } of segments) {
 96          if (result.length + segment.length <= limit) {
 97              result += segment;
 98          } else {
 99              break;
100          }
101      }
102  
103      return result.trim() + terminatingPunctuation;
104  }
105  
106  export function escapeHtml(text: string): string {
107      return text
108          .replace(/&/g, '&amp;')
109          .replace(/</g, '&lt;')
110          .replace(/>/g, '&gt;');
111  }
112  
113  export function commaSeparatedList(items: Array<string>, locale = 'en') {
114      return new Intl.ListFormat(locale, {
115          style: 'long',
116          type: 'conjunction',
117      }).format(items);
118  }
119  
120  export function stripTags(text: string) {
121      return text.replace(/(<([^>]+)>)/gi, '');
122  }
123  
124  export function stripUnicodeWhitespace(text: string) {
125      return text.replace(/[\u0000-\u001F]/g, '');
126  }