chromium/ui/webui/resources/js/load_time_data.ts

// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * @fileoverview This file defines a singleton which provides access to all data
 * that is available as soon as the page's resources are loaded (before DOM
 * content has finished loading). This data includes both localized strings and
 * any data that is important to have ready from a very early stage (e.g. things
 * that must be displayed right away).
 *
 * Note that loadTimeData is not guaranteed to be consistent between page
 * refreshes (https://crbug.com/740629) and should not contain values that might
 * change if the page is re-opened later.
 */

import {assert} from './assert.js';

interface LoadTimeDataRaw {
  [key: string]: any;
}

class LoadTimeData {
  private data_: LoadTimeDataRaw|null = null;

  /**
   * Sets the backing object.
   *
   * Note that there is no getter for |data_| to discourage abuse of the form:
   *
   *     var value = loadTimeData.data()['key'];
   */
  set data(value: LoadTimeDataRaw) {
    assert(!this.data_, 'Re-setting data.');
    this.data_ = value;
  }

  /**
   * @param id An ID of a value that might exist.
   * @return True if |id| is a key in the dictionary.
   */
  valueExists(id: string): boolean {
    assert(this.data_, 'No data. Did you remember to include strings.js?');
    return id in this.data_;
  }

  /**
   * Fetches a value, expecting that it exists.
   * @param id The key that identifies the desired value.
   * @return The corresponding value.
   */
  getValue(id: string): any {
    assert(this.data_, 'No data. Did you remember to include strings.js?');
    const value = this.data_[id];
    assert(typeof value !== 'undefined', 'Could not find value for ' + id);
    return value;
  }

  /**
   * As above, but also makes sure that the value is a string.
   * @param id The key that identifies the desired string.
   * @return The corresponding string value.
   */
  getString(id: string): string {
    const value = this.getValue(id);
    assert(typeof value === 'string', `[${value}] (${id}) is not a string`);
    return value;
  }

  /**
   * Returns a formatted localized string where $1 to $9 are replaced by the
   * second to the tenth argument.
   * @param id The ID of the string we want.
   * @param args The extra values to include in the formatted output.
   * @return The formatted string.
   */
  getStringF(id: string, ...args: Array<string|number>): string {
    const value = this.getString(id);
    if (!value) {
      return '';
    }

    return this.substituteString(value, ...args);
  }

  /**
   * Returns a formatted localized string where $1 to $9 are replaced by the
   * second to the tenth argument. Any standalone $ signs must be escaped as
   * $$.
   * @param label The label to substitute through. This is not an resource ID.
   * @param args The extra values to include in the formatted output.
   * @return The formatted string.
   */
  substituteString(label: string, ...args: Array<string|number>): string {
    return label.replace(/\$(.|$|\n)/g, function(m) {
      assert(m.match(/\$[$1-9]/), 'Unescaped $ found in localized string.');
      if (m === '$$') {
        return '$';
      }

      const substitute = args[Number(m[1]) - 1];
      if (substitute === undefined || substitute === null) {
        // Not all callers actually provide values for all substitutes. Return
        // an empty value for this case.
        return '';
      }
      return substitute.toString();
    });
  }

  /**
   * Returns a formatted string where $1 to $9 are replaced by the second to
   * tenth argument, split apart into a list of pieces describing how the
   * substitution was performed. Any standalone $ signs must be escaped as $$.
   * @param label A localized string to substitute through.
   *     This is not an resource ID.
   * @param args The extra values to include in the formatted output.
   * @return The formatted string pieces.
   */
  getSubstitutedStringPieces(label: string, ...args: Array<string|number>):
      Array<{value: string, arg: (string|null)}> {
    // Split the string by separately matching all occurrences of $1-9 and of
    // non $1-9 pieces.
    const pieces = (label.match(/(\$[1-9])|(([^$]|\$([^1-9]|$))+)/g) ||
                    []).map(function(p) {
      // Pieces that are not $1-9 should be returned after replacing $$
      // with $.
      if (!p.match(/^\$[1-9]$/)) {
        assert(
            (p.match(/\$/g) || []).length % 2 === 0,
            'Unescaped $ found in localized string.');
        return {value: p.replace(/\$\$/g, '$'), arg: null};
      }

      // Otherwise, return the substitution value.
      const substitute = args[Number(p[1]) - 1];
      if (substitute === undefined || substitute === null) {
        // Not all callers actually provide values for all substitutes. Return
        // an empty value for this case.
        return {value: '', arg: p};
      }
      return {value: substitute.toString(), arg: p};
    });

    return pieces;
  }

  /**
   * As above, but also makes sure that the value is a boolean.
   * @param id The key that identifies the desired boolean.
   * @return The corresponding boolean value.
   */
  getBoolean(id: string): boolean {
    const value = this.getValue(id);
    assert(typeof value === 'boolean', `[${value}] (${id}) is not a boolean`);
    return value;
  }

  /**
   * As above, but also makes sure that the value is an integer.
   * @param id The key that identifies the desired number.
   * @return The corresponding number value.
   */
  getInteger(id: string): number {
    const value = this.getValue(id);
    assert(typeof value === 'number', `[${value}] (${id}) is not a number`);
    assert(value === Math.floor(value), 'Number isn\'t integer: ' + value);
    return value;
  }

  /**
   * Override values in loadTimeData with the values found in |replacements|.
   * @param replacements The dictionary object of keys to replace.
   */
  overrideValues(replacements: LoadTimeDataRaw) {
    assert(
        typeof replacements === 'object',
        'Replacements must be a dictionary object.');
    assert(this.data_, 'Data must exist before being overridden');
    for (const key in replacements) {
      this.data_[key] = replacements[key];
    }
  }

  /**
   * Reset loadTimeData's data. Should only be used in tests.
   * @param newData The data to restore to, when null restores to unset state.
   */
  resetForTesting(newData: LoadTimeDataRaw|null = null) {
    this.data_ = newData;
  }

  /**
   * @return Whether loadTimeData.data has been set.
   */
  isInitialized(): boolean {
    return this.data_ !== null;
  }
}

export const loadTimeData = new LoadTimeData();