chromium/third_party/google-closure-library/closure/goog/html/safehtmlformatter.js

/**
 * @license
 * Copyright The Closure Library Authors.
 * SPDX-License-Identifier: Apache-2.0
 */


goog.module('goog.html.SafeHtmlFormatter');
goog.module.declareLegacyNamespace();

const SafeHtml = goog.require('goog.html.SafeHtml');
const {ENABLE_ASSERTS, assert} = goog.require('goog.asserts');
const {getRandomString, htmlEscape} = goog.require('goog.string');
const {isVoidTag} = goog.require('goog.dom.tags');

/**
 * Formatter producing SafeHtml from a plain text format and HTML fragments.
 * Example usage:
 * var formatter = new SafeHtmlFormatter();
 * var safeHtml = formatter.format(
 *     formatter.startTag('b') +
 *     'User input:' +
 *     formatter.endTag('b') +
 *     ' ' +
 *     formatter.text(userInput));
 * The most common usage is with goog.getMsg:
 * var MSG_USER_INPUT = goog.getMsg(
 *     '{$startLink}Learn more{$endLink} about {$userInput}', {
 *       'startLink': formatter.startTag('a', {'href': url}),
 *       'endLink': formatter.endTag('a'),
 *       'userInput': formatter.text(userInput)
 *     });
 * var safeHtml = formatter.format(MSG_USER_INPUT);
 * The formatting string should be constant with all variables processed by
 * formatter.text().
 * @final
 */
class SafeHtmlFormatter {
  constructor() {
    /**
     * Mapping from a marker to a replacement.
     * @private {!Object<string, !SafeHtmlFormatter.Replacement>}
     */
    this.replacements_ = {};

    /** @private {number} Number of stored replacements. */
    this.replacementsCount_ = 0;
  }

  /**
   * Formats a plain text string with markers holding HTML fragments to
   * SafeHtml.
   * @param {string} format Plain text format, will be HTML-escaped.
   * @return {!SafeHtml}
   */
  format(format) {
    const openedTags = [];
    const marker = htmlEscape(MARKER);
    const html = htmlEscape(format).replace(
        new RegExp(`\\{${marker}[\\w&#;]+\\}`, 'g'),
        goog.bind(this.replaceFormattingString_, this, openedTags));
    assert(
        openedTags.length == 0,
        'Expected no unclosed tags, got <' + openedTags.join('>, <') + '>.');
    return SafeHtml.createSafeHtmlSecurityPrivateDoNotAccessOrElse(html, null);
  }

  /**
   * Replaces found formatting strings with saved tags.
   * @param {!Array<string>} openedTags The tags opened so far, modified by this
   *     function.
   * @param {string} match
   * @return {string}
   * @private
   */
  replaceFormattingString_(openedTags, match) {
    const replacement = this.replacements_[match];
    if (!replacement) {
      // Someone included a string looking like our internal marker in the
      // format.
      return match;
    }
    let result = '';
    if (replacement.startTag) {
      result += '<' + replacement.startTag + replacement.attributes + '>';
      if (ENABLE_ASSERTS) {
        if (!isVoidTag(replacement.startTag.toLowerCase())) {
          openedTags.push(replacement.startTag.toLowerCase());
        }
      }
    }
    if (replacement.html) {
      result += replacement.html;
    }
    if (replacement.endTag) {
      result += '</' + replacement.endTag + '>';
      if (ENABLE_ASSERTS) {
        const lastTag = openedTags.pop();
        assert(
            lastTag == replacement.endTag.toLowerCase(),
            `Expected </${lastTag}>, got </` + replacement.endTag + '>.');
      }
    }
    return result;
  }

  /**
   * Saves a start tag and returns its marker.
   * @param {string} tagName
   * @param {?Object<string, ?SafeHtml.AttributeValue>=} attributes
   *     Mapping from attribute names to their values. Only attribute names
   *     consisting of [a-zA-Z0-9-] are allowed. Value of null or undefined
   * causes the attribute to be omitted.
   * @return {string} Marker.
   * @throws {!Error} If invalid tag name, attribute name, or attribute value is
   *     provided. This function accepts the same tags and attributes as
   *     {@link SafeHtml.create}.
   */
  startTag(tagName, attributes = undefined) {
    SafeHtml.verifyTagName(tagName);
    return this.storeReplacement_({
      startTag: tagName,
      attributes: SafeHtml.stringifyAttributes(tagName, attributes),
    });
  }

  /**
   * Saves an end tag and returns its marker.
   * @param {string} tagName
   * @return {string} Marker.
   * @throws {!Error} If invalid tag name, attribute name, or attribute value is
   *     provided. This function accepts the same tags as {@link
   *     SafeHtml.create}.
   */
  endTag(tagName) {
    SafeHtml.verifyTagName(tagName);
    return this.storeReplacement_({endTag: tagName});
  }

  /**
   * Escapes a text, saves it and returns its marker.
   *
   * Wrapping any user input to .text() prevents the attacker with access to
   * the random number generator to duplicate tags used elsewhere in the format.
   *
   * @param {string} text
   * @return {string} Marker.
   */
  text(text) {
    return this.storeReplacement_({html: htmlEscape(text)});
  }

  /**
   * Saves SafeHtml and returns its marker.
   * @param {!SafeHtml} safeHtml
   * @return {string} Marker.
   */
  safeHtml(safeHtml) {
    return this.storeReplacement_({
      html: SafeHtml.unwrap(safeHtml),
    });
  }

  /**
   * Stores a replacement and returns its marker.
   * @param {!SafeHtmlFormatter.Replacement} replacement
   * @return {string} Marker.
   * @private
   */
  storeReplacement_(replacement) {
    this.replacementsCount_++;
    const marker =
        `{${MARKER}` + this.replacementsCount_ + '_' + getRandomString() + '}';
    this.replacements_[htmlEscape(marker)] = replacement;
    return marker;
  }
}


/**
 * @typedef {?{
 *   startTag: (string|undefined),
 *   attributes: (string|undefined),
 *   endTag: (string|undefined),
 *   html: (string|undefined)
 * }}
 */
SafeHtmlFormatter.Replacement;


/** @const {string} Marker used for replacements. */
const MARKER = 'SafeHtmlFormatter:';


exports = SafeHtmlFormatter;