Skip to content

Commit

Permalink
Allow client side implementations of SoyLogger to output `LoggingAt…
Browse files Browse the repository at this point in the history
…trs` a new small data object

For idom this is managed via a new optional property on the renderer, for jssrc we can manage this via local variables during traversal.

PiperOrigin-RevId: 705371675
  • Loading branch information
lukesandberg authored and copybara-github committed Dec 18, 2024
1 parent 32adf6a commit 81d4353
Show file tree
Hide file tree
Showing 2 changed files with 225 additions and 25 deletions.
16 changes: 15 additions & 1 deletion javascript/api_idom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
$$VisualElementData,
ElementMetadata,
Logger,
LoggingAttrs,
} from 'google3/javascript/template/soy/soyutils_velog';
import * as log from 'google3/third_party/javascript/closure/log/log';
import {SanitizedHtml} from 'google3/third_party/javascript/closure/soy/data';
Expand Down Expand Up @@ -159,6 +160,7 @@ export class IncrementalDomRendererImpl implements IncrementalDomRenderer {
// the items being `${SIZE OF KEY}${DELIMITER}${KEY}`.
private readonly keyStackHolder: string[] = [];
private logger: Logger | undefined;
private pendingAttrs: LoggingAttrs | undefined;

/**
* Pushes/pops the given key from `keyStack` (versus `Array#concat`)
Expand Down Expand Up @@ -426,6 +428,14 @@ export class IncrementalDomRendererImpl implements IncrementalDomRenderer {

applyAttrs() {
incrementaldom.applyAttrs(attributes);
const pendingAttrs = this.pendingAttrs;
if (pendingAttrs) {
const el = this.currentElement()!;
// If we have already been rendered, then we can overwrite the attrs.
pendingAttrs.applyToInternalOnly(el as HTMLElement, el.__hasBeenRendered);
el.__hasBeenRendered = true;
this.pendingAttrs = undefined;
}
}

applyStatics(statics: incrementaldom.Statics) {
Expand All @@ -441,7 +451,7 @@ export class IncrementalDomRendererImpl implements IncrementalDomRenderer {
): IncrementalDomRenderer {
const logger = this.logger;
if (logger) {
logger.enter(
this.pendingAttrs = logger.enter(
new ElementMetadata(
veData.getVe().getId(),
veData.getData(),
Expand All @@ -461,6 +471,10 @@ export class IncrementalDomRendererImpl implements IncrementalDomRenderer {
*/
exitVeLog(): IncrementalDomRenderer {
this.logger?.exit();
// If we somehow fail to apply to an element just clear them out. This
// would naturally happen for a logonly block, but could also happen if
// the developer simply failed to nest an element.
this.pendingAttrs = undefined;
return this;
}

Expand Down
234 changes: 210 additions & 24 deletions javascript/soyutils_velog.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,184 @@ goog.module('soy.velog');
const ImmutableLoggableElementMetadata = goog.require('proto.soy.ImmutableLoggableElementMetadata');
const ReadonlyLoggableElementMetadata = goog.requireType('proto.soy.ReadonlyLoggableElementMetadata');
const {Message} = goog.require('jspb');
const {assert} = goog.require('goog.asserts');
const {safeAttrPrefix} = goog.require('safevalues');
const {setElementPrefixedAttribute} = goog.require('safevalues.dom');
const {SafeUrl, safeAttrPrefix, trySanitizeUrl} = goog.require('safevalues');
const {assert, assertString} = goog.require('goog.asserts');
const {setAnchorHref, setElementPrefixedAttribute} = goog.require('safevalues.dom');
const {startsWith} = goog.require('goog.string');


/**
* @param {string} match The full match
* @param {string} g The captured group
* @return {string} The replaced string
*/
function camelCaseReplacer(match, g) {
return g.toUpperCase();
}

/**
* @param {string} key The data attribute key
* @return {string} The corresponding dataset property key
*/
function dataAttrToDataSetProperty(key) {
assert(key.startsWith('data-'), 'key must start with data-');
// turn data-foo-bar into fooBar
return key.substring(5).toLowerCase().replace(/-([a-z])/g, camelCaseReplacer);
}

/**
* @param {!HTMLElement} el
* @return {!HTMLAnchorElement}
*/
function asAnchor(el) {
if (el.tagName !== 'A') {
throw new Error(
'logger attempted to add anchor attributes to a non-anchor element.');
}
return /** @type {!HTMLAnchorElement} */ (el);
}


/**
* Checks if the given attribute already exists on the element.
* @param {!HTMLElement} element
* @param {string} attrName
* @return {boolean}
*/
function checkDuplicate(element, attrName) {
if (element.hasAttribute(attrName)) {
if (goog.DEBUG) {
throw new Error(
'logger attempted to override an attribute ' + attrName +
' that already exists.');
}
return true;
}
return false;
}

/**
* A type-safe wrapper for a set of attributes to be added to an element.
*
* Logger implementations can return this to add attributes to the root element
* of the VE tree. If for some reason the root element is not well defined,
* then neither is this.
* @final
*/
class LoggingAttrs {
constructor() {
/** @private @const {!Array<function(!HTMLElement, boolean=)>} */
this.setters = [];
}
/**
* Adds a `data-` attribute to the list of attributes to be added to the
* element.
* @param {string} key Must start with `data-`
* @param {string} value Can be any string
* @return {!LoggingAttrs} returns this
*/
addDataAttr(key, value) {
assertString(key, 'key');
assertString(value, 'value');
// turn data-foo-bar into fooBar
const propertyName = dataAttrToDataSetProperty(key);
this.setters.push(
(/** !HTMLElement */ el, /** boolean= */ allowOverwrites) => {
if (!allowOverwrites && checkDuplicate(el, key)) {
return; // otherwise just skip it.
}
el.dataset[propertyName] = value;
});
return this;
}
/**
* Adds an `href` attribute to the list of attributes to be added to the
* element. The element must be an anchor.
* @param {string|!SafeUrl} value Can be any string
* @return {!LoggingAttrs} returns this
*/
addAnchorHrefAttr(value) {
const checkedValue = trySanitizeUrl(value);
if (checkedValue == null) {
throw new Error('Invalid href attribute: ' + value);
}
// help out the type inference system.
const nonNullCheckedValue = checkedValue;
this.setters.push(
(/** !HTMLElement */ el, /** boolean= */ allowOverwrites) => {
const a = asAnchor(el);
if (!allowOverwrites && checkDuplicate(a, 'href')) {
return; // otherwise just skip it.
}
a.setAttribute('href', String(nonNullCheckedValue));
});
return this;
}

/**
* Adds a `ping` attribute to the list of attributes to be added to the
* element. The element must be an anchor.
* @param {...(string|!SafeUrl)} value Can be any string
* @return {!LoggingAttrs} returns this
*/
addAnchorPingAttr(...value) {
const final = value
.map(v => {
v = trySanitizeUrl(v);
if (v == null) {
throw new Error('Invalid ping attribute: ' + v);
}
return v;
})
.join(' ');
this.setters.push(
(/** !HTMLElement */ el, /** boolean= */ allowOverwrites) => {
const a = asAnchor(el);
if (!allowOverwrites && checkDuplicate(a, 'ping')) {
return; // otherwise just skip it.
}
a.setAttribute('ping', final);
});
return this;
}

/**
* Returns a string representation of the attributes to be added to the
* element.
* @package
* @return {string}
*/
toDebugStringForTesting() {
if (!goog.DEBUG) {
throw new Error();
}
const a = /** @type {!HTMLAnchorElement} */ (document.createElement('a'));
for (let setter of this.setters) {
setter(a, false);
}
const attrs = [];
a.getAttributeNames().forEach((attr) => {
attrs.push(attr + '=' + JSON.stringify(a.getAttribute(attr)));
});
return attrs.join(' ');
}

/**
* Applies the attributes to the element.
*
* @param {!HTMLElement} element The element to apply the attributes to.
* @param {boolean=} allowOverwrites Whether to allow overwriting existing
* attributes.
* @package
*/
applyToInternalOnly(element, allowOverwrites) {
const setters = this.setters;
for (let i = 0; i < setters.length; i++) {
setters[i](element, allowOverwrites);
}
}
}

/** @final */
class ElementMetadata {
/**
Expand Down Expand Up @@ -234,15 +407,16 @@ function emitLoggingCommands(element, logger) {
* element as is).
*/
function visit(element, logger) {
let logIndex = -1;
if (!(element instanceof Element)) {
return [element];
}
let logIndex = -1;
let pendingAttrs = undefined;
if (element.hasAttribute(ELEMENT_ATTR)) {
logIndex = getDataAttribute(element, ELEMENT_ATTR);
assert(metadata.elements.length > logIndex, 'Invalid logging attribute.');
if (logIndex != -1) {
logger.enter(metadata.elements[logIndex]);
pendingAttrs = logger.enter(metadata.elements[logIndex]);
}
}
replaceFunctionAttributes(element, logger);
Expand All @@ -266,13 +440,16 @@ function visit(element, logger) {
if (metadata.elements[logIndex].logOnly) {
return [];
}
let result = [element];
if (element.tagName !== 'VELOG') {
element.removeAttribute(ELEMENT_ATTR);
return [element];
} else if (element.childNodes) {
return Array.from(element.childNodes);
result = Array.from(element.childNodes);
}
if (pendingAttrs && result.length > 0) {
pendingAttrs.applyToInternalOnly(result[0]);
}
return [element];
return result;
}

/**
Expand Down Expand Up @@ -306,38 +483,45 @@ function replaceChild(parent, oldChild, newChildren) {
* @param {!Logger} logger
*/
function replaceFunctionAttributes(element, logger) {
const attributeMap = {};
let /** !Array<string>|undefined */ newAttrs;
// Iterates from the end to the beginning, since we are removing attributes
// in place.
let elementWithAttribute = element;
let elementToAddTo = element;
if (element.tagName === 'VEATTR') {
// The attribute being replaced belongs on the direct child.
elementWithAttribute = /** @type {!Element} */ (element.firstElementChild);
elementToAddTo = /** @type {!Element} */ (element.firstElementChild);
}
for (let i = element.attributes.length - 1; i >= 0; --i) {
const attributeName = element.attributes[i].name;
const attrs = element.attributes;
for (let i = attrs.length - 1; i >= 0; --i) {
const attr = attrs[i];
const attributeName = attr.name;
if (startsWith(attributeName, FUNCTION_ATTR)) {
// Delay evaluation of the attributes until we reach the element itself.
if (elementWithAttribute.hasAttribute(ELEMENT_ATTR) &&
if (elementToAddTo.hasAttribute(ELEMENT_ATTR) &&
element.tagName === 'VEATTR') {
elementWithAttribute.setAttribute(
attributeName, element.attributes[i].value);
elementToAddTo.setAttribute(
attributeName, attr.value);
continue;
}
const funcIndex = parseInt(element.attributes[i].value, 10);
const funcIndex = parseInt(attr.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);
elementWithAttribute.removeAttribute(attributeName);
const actualAttrName = attributeName.substring(FUNCTION_ATTR.length);
(newAttrs ??= [])
.push(
actualAttrName,
logger.evalLoggingFunction(funcMetadata.name, funcMetadata.args));
elementToAddTo.removeAttribute(attributeName);
}
}
for (const attributeName in attributeMap) {
elementWithAttribute.setAttribute(
attributeName, attributeMap[attributeName]);
if (newAttrs) {
for (let i = 0; i < newAttrs.length; i += 2) {
const attrName = newAttrs[i];
const attrValue = newAttrs[i + 1];
elementToAddTo.setAttribute(attrName, attrValue);
}
}
}

Expand Down Expand Up @@ -367,6 +551,7 @@ class Logger {
/**
* Called when a `{velog}` statement is entered.
* @param {!ElementMetadata} elementMetadata
* @return {!LoggingAttrs|undefined}
*/
enter(elementMetadata) {}

Expand Down Expand Up @@ -604,4 +789,5 @@ exports = {
$$getVeMetadata,
$$veHasSameId,
getNullLogger,
LoggingAttrs,
};

0 comments on commit 81d4353

Please sign in to comment.