closure-builder
Version:
Simple Closure, Soy and JavaScript Build system
428 lines (391 loc) • 12.4 kB
JavaScript
/*
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @fileoverview
* Utility functions and classes for supporting visual element logging in the
* client side.
*
* <p>
* This file contains utilities that should only be called by Soy-generated
* JS code. Please do not use these functions directly from your hand-written
* code. Their names all start with '$$'.
*/
goog.module('soy.velog');
goog.module.declareLegacyNamespace();
const Message = goog.require('jspb.Message');
const xid = goog.require('xid');
const {assert} = goog.require('goog.asserts');
const {getFirstElementChild, getNextElementSibling} = goog.require('goog.dom');
const {startsWith} = goog.require('goog.string');
/** @final */
class ElementMetadata {
/**
* @param {number} id
* @param {?Message} data
* @param {boolean} logOnly
*/
constructor(id, data, logOnly) {
/**
* The identifier for the logging element
* @const {number}
*/
this.id = id;
/**
* The optional payload from the `data` attribute. This is guaranteed to
* match the proto_type specified in the logging configuration.
* @const {?Message}
*/
this.data = data;
/**
* Whether or not this element is in logOnly mode. In logOnly mode the log
* records are collected but the actual elements are not rendered.
* @const {boolean}
*/
this.logOnly = logOnly;
}
}
/** @package @final */
class FunctionMetadata {
/**
* @param {string} name
* @param {!Array<?>} args
*/
constructor(name, args) {
this.name = name;
this.args = args;
}
}
/** @package @final */
class Metadata {
constructor() {
/** @type {!Array<!ElementMetadata>} */
this.elements = [];
/** @type {!Array<!FunctionMetadata>} */
this.functions = [];
}
}
/** @type {?Metadata} */ let metadata;
// NOTE: we need to use toLowerCase in case the xid contains upper case
// characters, browsers normalize keys to their ascii lowercase versions when
// accessing attributes via the programmatic APIs (as we do below).
/** @package */ const ELEMENT_ATTR = 'data-' + xid('soylog').toLowerCase();
/** @package */ const FUNCTION_ATTR =
'data-' + xid('soyloggingfunction').toLowerCase() + '-';
/** Sets up the global metadata object before rendering any templates. */
function setUpLogging() {
assert(
!$$hasMetadata(),
'Logging metadata already exists. Please call ' +
'soy.velog.tearDownLogging after rendering a template.');
metadata = new Metadata();
}
/**
* Clears the global metadata object after logging so that we won't leak any
* information between templates.
*/
function tearDownLogging() {
assert(
$$hasMetadata(),
'Logging metadata does not exist. ' +
'Please call soy.velog.setUpLogging before rendering a template.');
metadata = null;
}
/**
* Checks if the global metadata object exists. This is only used by generated
* code, to avoid directly access the object.
*
* @return {boolean}
*/
function $$hasMetadata() {
return !!metadata;
}
/**
* Testonly method that sets the fake meta data for testing.
* @param {!Metadata} testdata
* @package
*/
function setMetadataTestOnly(testdata) {
metadata = testdata;
}
/**
* Records the id and additional data into the global metadata structure.
*
* @param {!$$VisualElementData} veData The VE to log.
* @param {boolean} logOnly Whether to enable counterfactual logging.
*
* @return {string} The HTML attribute that will be stored in the DOM.
*/
function $$getLoggingAttribute(veData, logOnly) {
if ($$hasMetadata()) {
const dataIdx =
metadata.elements.push(
new ElementMetadata(
veData.getVe().getId(), veData.getData(), logOnly))
- 1;
// Insert a whitespace at the beginning. In VeLogInstrumentationVisitor,
// we insert the return value of this method as a plain string instead of a
// HTML attribute, therefore the desugaring pass does not know how to handle
// whitespaces.
// Trailing whitespace is not needed since we always add this at the end of
// a HTML tag.
return ' ' + ELEMENT_ATTR + '="' + dataIdx + '"';
} else if (logOnly) {
// If logonly is true but no longger has been configured, we throw an error
// since this is clearly a misconfiguration.
throw new Error(
'Cannot set logonly="true" unless there is a logger configured');
} else {
// If logger has not been configured, return an empty string to avoid adding
// unnecessary information in the DOM.
return '';
}
}
/**
* Registers the logging function in the metadata.
*
* @param {string} name Obfuscated logging function name.
* @param {!Array<?>} args List of arguments for the logging function.
* @param {string} attr The original HTML attribute name.
*
* @return {string} The HTML attribute that will be stored in the DOM.
*/
function $$getLoggingFunctionAttribute(name, args, attr) {
if ($$hasMetadata()) {
const functionIdx =
metadata.functions.push(new FunctionMetadata(name, args)) - 1;
return ' ' + FUNCTION_ATTR + attr + '="' + functionIdx + '"';
} else {
return '';
}
}
/**
* For a given rendered HTML element, go through the DOM tree and emits logging
* commands if necessary. This method also discards visual elements that are'
* marked as log only (counterfactual).
*
* @param {!Element|!DocumentFragment} element The rendered HTML element.
* @param {!Logger} logger The logger that actually does stuffs.
*/
function emitLoggingCommands(element, logger) {
const keep = preOrderDomTraversal(element, logger);
if (!keep) {
element.parentElement.removeChild(element);
}
}
/**
* Helper method that traverses the DOM tree in pre-order and returns false
* if the current element is log only.
*
* @param {!Element|!DocumentFragment} element The rendered HTML element.
* @param {!Logger} logger The logger that actually does stuffs.
* @return {boolean} indicating whether or not current should be removed.
*/
function preOrderDomTraversal(element, logger) {
let logIndex = -1;
if (element instanceof Element) {
logIndex = getDataAttribute(element, ELEMENT_ATTR);
assert(metadata.elements.length > logIndex, 'Invalid logging attribute.');
if (logIndex != -1) {
logger.enter(metadata.elements[logIndex]);
}
replaceFunctionAttributes(element, logger);
}
let current = getFirstElementChild(element);
while (current) {
// TODO(user): Maybe we should pass around logOnly so that children
// of logOnly VEs do not need to manipulate the DOM.
const keep = preOrderDomTraversal(current, logger);
const next = getNextElementSibling(current);
// Remove the current element after we obtain nextElementSibling.
if (!keep) {
// IE does not support ChildNode.remove().
element.removeChild(current);
}
current = next;
}
if (element instanceof Element) {
if (logIndex != -1) {
logger.exit();
// Remove logOnly elements from the DOM.
if (metadata.elements[logIndex].logOnly) {
return false;
}
}
// Always remove the data attribute.
element.removeAttribute(ELEMENT_ATTR);
}
return true;
}
/**
* Evaluates and replaces the data attributes related to logging functions.
*
* @param {!Element} element
* @param {!Logger} logger
*/
function replaceFunctionAttributes(element, logger) {
const attributeMap = {};
// Iterates from the end to the beginning, since we are removing attributes
// in place.
for (let i = element.attributes.length - 1; i >= 0; --i) {
const attributeName = element.attributes[i].name;
if (startsWith(attributeName, FUNCTION_ATTR)) {
const funcIndex = parseInt(element.attributes[i].value, 10);
assert(
!Number.isNaN(funcIndex) && funcIndex < metadata.functions.length,
'Invalid logging attribute.');
const funcMetadata = metadata.functions[funcIndex];
const attr = attributeName.substring(FUNCTION_ATTR.length);
attributeMap[attr] =
logger.evalLoggingFunction(funcMetadata.name, funcMetadata.args);
element.removeAttribute(attributeName);
}
}
for (const attributeName in attributeMap) {
element.setAttribute(attributeName, attributeMap[attributeName]);
}
}
/**
* Gets and parses the data-soylog attribute for a given element. Returns -1 if
* it does not contain related attributes.
*
* @param {!Element} element The current element.
* @param {string} attr The name of the data attribute.
* @return {number}
*/
function getDataAttribute(element, attr) {
let logIndex = element.getAttribute(attr);
if (logIndex) {
logIndex = parseInt(logIndex, 10);
assert(!Number.isNaN(logIndex), 'Invalid logging attribute.');
return logIndex;
}
return -1;
}
/**
* Logging interface for client side.
* @interface
*/
class Logger {
/**
* Called when a `{velog}` statement is entered.
* @param {!ElementMetadata} elementMetadata
*/
enter(elementMetadata) {}
/**
* Called when a `{velog}` statement is exited.
*/
exit() {}
/**
* Called when a logging function is evaluated.
* @param {string} name function name, as obfuscated by the `xid` function.
* @param {!Array<?>} args List of arguments needed for the function.
* @return {string} The evaluated return value that will be shown in the DOM.
*/
evalLoggingFunction(name, args) {}
}
/** The ID of the UndefinedVe. */
const UNDEFINED_VE_ID = -1;
/**
* Soy's runtime representation of objects of the Soy `ve` type.
*
* <p>This is for use only in Soy internal code and Soy generated JS. DO NOT use
* this from handwritten code.
*
* @final
*/
class $$VisualElement {
/**
* @param {number} id
* @param {string=} name
*/
constructor(id, name = undefined) {
/** @private @const {number} */
this.id_ = id;
/** @private @const {string|undefined} */
this.name_ = name;
}
/** @return {number} */
getId() {
return this.id_;
}
/** @package @return {string} */
toDebugString() {
return `ve(${this.name_})`;
}
/** @override */
toString() {
if (goog.DEBUG) {
return `**FOR DEBUGGING ONLY ${this.toDebugString()}, id: ${this.id_}**`;
} else {
return 'zSoyVez';
}
}
}
/**
* Soy's runtime representation of objects of the Soy `ve_data` type.
*
* <p>This is for use only in Soy internal code and Soy generated JS. DO NOT use
* this from handwritten code.
*
* @final
*/
class $$VisualElementData {
/**
* @param {!$$VisualElement} ve
* @param {?Message} data
*/
constructor(ve, data) {
/** @private @const {!$$VisualElement} */
this.ve_ = ve;
/** @private @const {?Message} */
this.data_ = data;
}
/** @return {!$$VisualElement} */
getVe() {
return this.ve_;
}
/** @return {?Message} */
getData() {
return this.data_;
}
/** @override */
toString() {
if (goog.DEBUG) {
return `**FOR DEBUGGING ONLY ve_data(${this.ve_.toDebugString()}, ${
this.data_})**`;
} else {
return 'zSoyVeDz';
}
}
}
exports = {
$$hasMetadata,
$$getLoggingAttribute,
$$getLoggingFunctionAttribute,
ELEMENT_ATTR,
FUNCTION_ATTR,
ElementMetadata,
FunctionMetadata,
Logger,
UNDEFINED_VE_ID,
Metadata,
$$VisualElement,
$$VisualElementData,
emitLoggingCommands,
setMetadataTestOnly,
setUpLogging,
tearDownLogging,
};