/**
* @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;