/
githubmirror
/
amphtml
Обзор
Документация
Войти
/
githubmirror
/
amphtml
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/service/action-impl.js
1 415 строк
40 KB
Shihua Zheng
commit (#39097)
06 июн 2023, 02:37
Не верифицирован
06 июн 2023, 02:37
649e4ac
Код
Авторство
О чём код?
import { ActionTrust_Enum, DEFAULT_ACTION, RAW_OBJECT_ARGS_KEY, actionTrustToString, } from '#core/constants/action-constants'; import {Keys_Enum} from '#core/constants/key-codes'; import {isAmp4Email} from '#core/document/format'; import {isEnabled} from '#core/dom'; import {isFiniteNumber} from '#core/types'; import {isArray, toArray} from '#core/types/array'; import {debounce, throttle} from '#core/types/function'; import {getValueForExpr, hasOwn, map} from '#core/types/object'; import {getWin} from '#core/window'; import {Services} from '#service'; import {getDetail} from '#utils/event-helper'; import {dev, devAssert, user, userAssert} from '#utils/log'; import {reportError} from '../error-reporting'; import {getMode} from '../mode'; import {registerServiceBuilderForDoc} from '../service-helpers'; /** @const {string} */ const TAG_ = 'Action'; /** @const {string} */ const ACTION_MAP_ = '__AMP_ACTION_MAP__' + Math.random(); /** @const {string} */ const ACTION_QUEUE_ = '__AMP_ACTION_QUEUE__'; /** @const {string} */ const ACTION_HANDLER_ = '__AMP_ACTION_HANDLER__'; /** @const {number} */ const DEFAULT_DEBOUNCE_WAIT = 300; // ms /** @const {number} */ const DEFAULT_THROTTLE_INTERVAL = 100; // ms /** @const {!{[key: string]: !Array<string}>} */ const NON_AMP_ELEMENTS_ACTIONS_ = { 'form': ['submit', 'clear'], }; const DEFAULT_EMAIL_ALLOWLIST = [ {tagOrTarget: 'AMP', method: 'setState'}, {tagOrTarget: '*', method: 'focus'}, {tagOrTarget: '*', method: 'hide'}, {tagOrTarget: '*', method: 'show'}, {tagOrTarget: '*', method: 'toggleClass'}, {tagOrTarget: '*', method: 'toggleChecked'}, {tagOrTarget: '*', method: 'toggleVisibility'}, ]; /** * Interactable widgets which should trigger tap events when the user clicks * or activates via the keyboard. Not all are here, e.g. progressbar, tabpanel, * since they are text inputs, readonly, or composite widgets that shouldn't * need to trigger tap events from spacebar or enter on their own. * See https://www.w3.org/TR/wai-aria-1.1/#widget_roles * @const {!{[key: string]: boolean}} */ export const TAPPABLE_ARIA_ROLES = { 'button': true, 'checkbox': true, 'link': true, 'listbox': true, 'menuitem': true, 'menuitemcheckbox': true, 'menuitemradio': true, 'option': true, 'radio': true, 'scrollbar': true, 'slider': true, 'spinbutton': true, 'switch': true, 'tab': true, 'treeitem': true, }; /** * An expression arg value, e.g. `foo.bar` in `e:t.m(arg=foo.bar)`. * @typedef {{expression: string}} */ let ActionInfoArgExpressionDef; /** * An arg value. * @typedef {(boolean|number|string|ActionInfoArgExpressionDef)} */ let ActionInfoArgValueDef; /** * Map of arg names to their values, e.g. {arg: 123} in `e:t.m(arg=123)`. * @typedef {{[key: string]: ActionInfoArgValueDef}} */ let ActionInfoArgsDef; /** * @typedef {{ * event: string, * target: string, * method: string, * args: ?ActionInfoArgsDef, * str: string * }} */ export let ActionInfoDef; /** * Function called when an action is invoked. * * Optionally, takes this action's position within all actions triggered by * the same event, as well as said action array, as params. * * If the action is chainable, returns a Promise which resolves when the * action is complete. Otherwise, returns null. * * @typedef {function(!ActionInvocation, number=, !Array<!ActionInfoDef>=):?Promise} */ let ActionHandlerDef; /** * @typedef {Event|DeferredEvent} */ export let ActionEventDef; /** * The structure that contains all details of the action method invocation. * @struct @const @package For type. */ export class ActionInvocation { /** * For example: * * <div id="div" on="tap:myForm.submit(foo=bar)"> * <button id="btn">Submit</button> * </div> * * `node` is #myForm. * `method` is "submit". * `args` is {'foo': 'bar'}. * `source` is #btn. * `caller` is #div. * `event` is a "click" Event object. * `actionEventType` is "tap". * `trust` depends on whether this action was a result of a user gesture. * `tagOrTarget` is "amp-form". * `sequenceId` is a pseudo-UUID. * * @param {!Node} node Element whose action is being invoked. * @param {string} method Name of the action being invoked. * @param {?JsonObject} args Named action arguments. * @param {?Element} source Element that generated the `event`. * @param {?Element} caller Element containing the on="..." action handler. * @param {?ActionEventDef} event The event object that triggered this action. * @param {!ActionTrust_Enum} trust The trust level of this invocation's trigger. * @param {?string} actionEventType The AMP event name that triggered this. * @param {?string} tagOrTarget The global target name or the element tagName. * @param {number} sequenceId An identifier for this action's sequence (all * actions triggered by one event e.g. "tap:form1.submit, form2.submit"). */ constructor( node, method, args, source, caller, event, trust, actionEventType = '?', tagOrTarget = null, sequenceId = Math.random() ) { /** @type {!Node} */ this.node = node; /** @const {string} */ this.method = method; /** @const {?JsonObject} */ this.args = args; /** @const {?Element} */ this.source = source; /** @const {?Element} */ this.caller = caller; /** @const {?ActionEventDef} */ this.event = event; /** @const {!ActionTrust_Enum} */ this.trust = trust; /** @const {?string} */ this.actionEventType = actionEventType; /** @const {string} */ this.tagOrTarget = tagOrTarget || node.tagName; /** @const {number} */ this.sequenceId = sequenceId; } /** * Returns true if the trigger event has a trust equal to or greater than * `minimumTrust`. Otherwise, logs a user error and returns false. * @param {ActionTrust_Enum} minimumTrust * @return {boolean} */ satisfiesTrust(minimumTrust) { // Sanity check. if (!isFiniteNumber(this.trust)) { dev().error(TAG_, `Invalid trust for '${this.method}': ${this.trust}`); return false; } if (this.trust < minimumTrust) { const t = actionTrustToString(this.trust); user().error( TAG_, `"${this.actionEventType}" event with "${t}" trust is not allowed to ` + `invoke "${this.tagOrTarget.toLowerCase()}.${this.method}".` ); return false; } return true; } } /** * TODO(dvoytenko): consider splitting this class into two: * 1. A class that has a method "trigger(element, eventType, data)" and * simply can search target in DOM and trigger methods on it. * 2. A class that configures event recognizers and rules and then * simply calls action.trigger. */ export class ActionService { /** * @param {!./ampdoc-impl.AmpDoc} ampdoc * @param {(!Document|!ShadowRoot)=} opt_root */ constructor(ampdoc, opt_root) { /** @const {!./ampdoc-impl.AmpDoc} */ this.ampdoc = ampdoc; /** @const {!Document|!ShadowRoot} */ this.root_ = opt_root || ampdoc.getRootNode(); /** @const {boolean} */ this.isEmail_ = this.ampdoc.isSingleDoc() && isAmp4Email(/** @type {!Document} */ (this.root_)); /** * Optional allowlist of actions, e.g.: * * [{tagOrTarget: 'AMP', method: 'navigateTo'}, * {tagOrTarget: 'AMP-FORM', method: 'submit'}, * {tagOrTarget: '*', method: 'show'}] * * If not null, any actions that are not in the allowlist will be ignored * and throw a user error at invocation time. Note that `tagOrTarget` is * always the canonical uppercased form (same as * `Element.prototype.tagName`). If `tagOrTarget` is the wildcard '*', then * the allowlisted method is allowed on any tag or target. * * For AMP4Email format documents, allowed actions are autogenerated. * @private {?Array<{tagOrTarget: string, method: string}>} */ this.allowlist_ = this.isEmail_ ? DEFAULT_EMAIL_ALLOWLIST : null; /** @const @private {!{[key: string]: ActionHandlerDef}} */ this.globalTargets_ = map(); /** * @const @private {!{[key: string]: {handler: ActionHandlerDef, minTrust: ActionTrust_Enum}}} */ this.globalMethodHandlers_ = map(); // Add core events. this.addEvent('tap'); this.addEvent('submit'); this.addEvent('change'); this.addEvent('input-debounced'); this.addEvent('input-throttled'); this.addEvent('valid'); this.addEvent('invalid'); } /** * @param {string} name * TODO(dvoytenko): switch to a system where the event recognizers are * registered with Action instead, e.g. "doubletap", "tap to zoom". */ addEvent(name) { if (name == 'tap') { // TODO(dvoytenko): if needed, also configure touch-based tap, e.g. for // fast-click. this.root_.addEventListener('click', (event) => { if (!event.defaultPrevented) { const element = dev().assertElement(event.target); this.trigger(element, name, event, ActionTrust_Enum.HIGH); } }); this.root_.addEventListener('keydown', (event) => { const {key, target} = event; const element = dev().assertElement(target); if (key == Keys_Enum.ENTER || key == Keys_Enum.SPACE) { const role = element.getAttribute('role'); const isTapEventRole = role && hasOwn(TAPPABLE_ARIA_ROLES, role.toLowerCase()); if (!event.defaultPrevented && isTapEventRole) { const hasAction = this.trigger( element, name, event, ActionTrust_Enum.HIGH ); // Only if the element has an action do we prevent the default. // In the absence of an action, e.g. on="[event].method", we do not // want to stop default behavior. if (hasAction) { event.preventDefault(); } } } }); } else if (name == 'submit') { this.root_.addEventListener(name, (event) => { const element = dev().assertElement(event.target); // For get requests, the delegating to the viewer needs to happen // before this. this.trigger(element, name, event, ActionTrust_Enum.HIGH); }); } else if (name == 'change') { this.root_.addEventListener(name, (event) => { const element = dev().assertElement(event.target); this.addTargetPropertiesAsDetail_(event); this.trigger(element, name, event, ActionTrust_Enum.HIGH); }); } else if (name == 'input-debounced') { const debouncedInput = debounce( this.ampdoc.win, (event) => { const target = dev().assertElement(event.target); this.trigger( target, name, /** @type {!ActionEventDef} */ (event), ActionTrust_Enum.HIGH ); }, DEFAULT_DEBOUNCE_WAIT ); this.root_.addEventListener('input', (event) => { // Create a DeferredEvent to avoid races where the browser cleans up // the event object before the async debounced function is called. const deferredEvent = new DeferredEvent(event); this.addTargetPropertiesAsDetail_(deferredEvent); debouncedInput(deferredEvent); }); } else if (name == 'input-throttled') { const throttledInput = throttle( this.ampdoc.win, (event) => { const target = dev().assertElement(event.target); this.trigger( target, name, /** @type {!ActionEventDef} */ (event), ActionTrust_Enum.HIGH ); }, DEFAULT_THROTTLE_INTERVAL ); this.root_.addEventListener('input', (event) => { const deferredEvent = new DeferredEvent(event); this.addTargetPropertiesAsDetail_(deferredEvent); throttledInput(deferredEvent); }); } else if (name == 'valid' || name == 'invalid') { this.root_.addEventListener(name, (event) => { const element = dev().assertElement(event.target); this.trigger(element, name, event, ActionTrust_Enum.HIGH); }); } } /** * Registers the action target that will receive all designated actions. * @param {string} name * @param {ActionHandlerDef} handler */ addGlobalTarget(name, handler) { this.globalTargets_[name] = handler; } /** * Registers the action handler for a common method. * @param {string} name * @param {ActionHandlerDef} handler * @param {ActionTrust_Enum} minTrust */ addGlobalMethodHandler(name, handler, minTrust = ActionTrust_Enum.DEFAULT) { this.globalMethodHandlers_[name] = {handler, minTrust}; } /** * Triggers the specified event on the target element. * @param {!Element} target * @param {string} eventType * @param {?ActionEventDef} event * @param {!ActionTrust_Enum} trust * @param {?JsonObject=} opt_args * @return {boolean} true if the target has an action. */ trigger(target, eventType, event, trust, opt_args) { return this.action_(target, eventType, event, trust, opt_args); } /** * Triggers execution of the method on a target/method. * @param {!Element} target * @param {string} method * @param {?JsonObject} args * @param {?Element} source * @param {?Element} caller * @param {?ActionEventDef} event * @param {ActionTrust_Enum} trust */ execute(target, method, args, source, caller, event, trust) { const invocation = new ActionInvocation( target, method, args, source, caller, event, trust ); this.invoke_(invocation); } /** * Installs action handler for the specified element. The action handler is * responsible for checking invocation trust. * * For AMP elements, use base-element.registerAction() instead. * * @param {!Element} target * @param {ActionHandlerDef} handler */ installActionHandler(target, handler) { // TODO(dvoytenko, #7063): switch back to `target.id` with form proxy. const targetId = target.getAttribute('id') || ''; devAssert( isAmpTagName(targetId) || target.tagName.toLowerCase() in NON_AMP_ELEMENTS_ACTIONS_, 'AMP or special element expected: %s', target.tagName + '#' + targetId ); if (target[ACTION_HANDLER_]) { dev().error(TAG_, `Action handler already installed for ${target}`); return; } target[ACTION_HANDLER_] = handler; /** @const {Array<!ActionInvocation>} */ const queuedInvocations = target[ACTION_QUEUE_]; if (isArray(queuedInvocations)) { // Invoke and clear all queued invocations now handler is installed. Services.timerFor(getWin(target)).delay(() => { // TODO(dvoytenko, #1260): dedupe actions. queuedInvocations.forEach((invocation) => { try { handler(invocation); } catch (e) { dev().error(TAG_, 'Action execution failed:', invocation, e); } }); target[ACTION_QUEUE_].length = 0; }, 1); } } /** * Checks if the given element has registered a particular action type. * @param {!Element} element * @param {string} actionEventType * @param {!Element=} opt_stopAt * @return {boolean} */ hasAction(element, actionEventType, opt_stopAt) { return !!this.findAction_(element, actionEventType, opt_stopAt); } /** * Checks if the given element's registered action resolves to at least one * existing element by id or a global target (e.g. "AMP"). * @param {!Element} element * @param {string} actionEventType * @param {!Element=} opt_stopAt * @return {boolean} */ hasResolvableAction(element, actionEventType, opt_stopAt) { const action = this.findAction_(element, actionEventType, opt_stopAt); if (!action) { return false; } return action.actionInfos.some((action) => { const {target} = action; return !!this.getActionNode_(target); }); } /** * Checks if the given element's registered action resolves to at least one * existing element by id or a global target (e.g. "AMP"). * @param {!Element} element * @param {string} actionEventType * @param {!Element} targetElement * @param {!Element=} opt_stopAt * @return {boolean} */ hasResolvableActionForTarget( element, actionEventType, targetElement, opt_stopAt ) { const action = this.findAction_(element, actionEventType, opt_stopAt); if (!action) { return false; } return action.actionInfos.some((actionInfo) => { const {target} = actionInfo; return this.getActionNode_(target) == targetElement; }); } /** * For global targets e.g. "AMP", returns the document root. Otherwise, * `target` is an element id and the corresponding element is returned. * @param {string} target * @return {?Document|?Element|?ShadowRoot} * @private */ getActionNode_(target) { return this.globalTargets_[target] ? this.root_ : this.root_.getElementById(target); } /** * Sets the action allowlist. Can be used to clear it. * @param {!Array<{tagOrTarget: string, method: string}>} allowlist */ setAllowlist(allowlist) { devAssert( allowlist.every((v) => v.tagOrTarget && v.method), 'Action allowlist entries should be of shape { tagOrTarget: string, method: string }' ); this.allowlist_ = allowlist; } /** * Adds an action to the allowlist. * @param {string} tagOrTarget The tag or target to allowlist, e.g. * 'AMP-LIST', '*'. * @param {string|Array<string>} methods The method(s) to allowlist, e.g. 'show', 'hide'. * @param {Array<string>=} opt_forFormat */ addToAllowlist(tagOrTarget, methods, opt_forFormat) { // TODO(wg-performance): When it becomes possible to getFormat(), // we can store `format_` instead of `isEmail_` and check // (opt_forFormat && !opt_forFormat.includes(this.format_)) if (opt_forFormat && opt_forFormat.includes('email') !== this.isEmail_) { return; } if (!this.allowlist_) { this.allowlist_ = []; } if (!isArray(methods)) { methods = [methods]; } methods.forEach((method) => { if ( this.allowlist_.some( (v) => v.tagOrTarget == tagOrTarget && v.method == method ) ) { return; } this.allowlist_.push({tagOrTarget, method}); }); } /** * @param {!Element} source * @param {string} actionEventType * @param {?ActionEventDef} event * @param {!ActionTrust_Enum} trust * @param {?JsonObject=} opt_args * @return {boolean} True if the element has an action. * @private */ action_(source, actionEventType, event, trust, opt_args) { const action = this.findAction_(source, actionEventType); if (!action) { return false; } // Use a pseudo-UUID to uniquely identify this sequence of actions. // A sequence is all actions triggered by a single event. const sequenceId = Math.random(); // Invoke actions serially, where each action waits for its predecessor // to complete. `currentPromise` is the i'th promise in the chain. /** @type {?Promise} */ let currentPromise = null; action.actionInfos.forEach((actionInfo) => { const {args, method, str, target} = actionInfo; const dereferencedArgs = dereferenceArgsVariables(args, event, opt_args); const invokeAction = () => { const node = this.getActionNode_(target); if (!node) { this.error_(`Target "${target}" not found for action [${str}].`); return; } const invocation = new ActionInvocation( node, method, dereferencedArgs, source, action.node, event, trust, actionEventType, node.tagName || target, sequenceId ); return this.invoke_(invocation); }; // Wait for the previous action, if any. currentPromise = currentPromise ? currentPromise.then(invokeAction) : invokeAction(); }); return action.actionInfos.length >= 1; } /** * @param {string} message * @param {?Element=} opt_element * @private */ error_(message, opt_element) { if (opt_element) { // reportError() supports displaying the element in dev console. const e = user().createError(`[${TAG_}] ${message}`); reportError(e, opt_element); throw e; } else { user().error(TAG_, message); } } /** * @param {!ActionInvocation} invocation * @return {?Promise} * @private */ invoke_(invocation) { const {method, tagOrTarget} = invocation; // Check that this action is allowlisted (if a allowlist is set). if (this.allowlist_) { if (!isActionAllowlisted(invocation, this.allowlist_)) { this.error_( `"${tagOrTarget}.${method}" is not allowlisted ${JSON.stringify( this.allowlist_ )}.` ); return null; } } // Handle global targets e.g. "AMP". const globalTarget = this.globalTargets_[tagOrTarget]; if (globalTarget) { return globalTarget(invocation); } // Subsequent handlers assume that invocation target is an Element. const node = dev().assertElement(invocation.node); // Handle global actions e.g. "<any-element-id>.toggle". const globalMethod = this.globalMethodHandlers_[method]; if (globalMethod && invocation.satisfiesTrust(globalMethod.minTrust)) { return globalMethod.handler(invocation); } // Handle element-specific actions. const lowerTagName = node.tagName.toLowerCase(); if (isAmpTagName(lowerTagName)) { if (node.enqueAction) { node.enqueAction(invocation); } else { this.error_(`Unrecognized AMP element "${lowerTagName}".`, node); } return null; } // Special non-AMP elements with AMP ID or known supported actions. const nonAmpActions = NON_AMP_ELEMENTS_ACTIONS_[lowerTagName]; // TODO(dvoytenko, #7063): switch back to `target.id` with form proxy. const targetId = node.getAttribute('id') || ''; if ( isAmpTagName(targetId) || (nonAmpActions && nonAmpActions.indexOf(method) > -1) ) { const handler = node[ACTION_HANDLER_]; if (handler) { handler(invocation); } else { node[ACTION_QUEUE_] = node[ACTION_QUEUE_] || []; node[ACTION_QUEUE_].push(invocation); } return null; } // Unsupported method. this.error_( `Target (${tagOrTarget}) doesn't support "${method}" action.`, invocation.caller ); return null; } /** * @param {!Element} target * @param {string} actionEventType * @param {!Element=} opt_stopAt * @return {?{node: !Element, actionInfos: !Array<!ActionInfoDef>}} */ findAction_(target, actionEventType, opt_stopAt) { // Go from target up the DOM tree and find the applicable action. let n = target; while (n) { if (opt_stopAt && n == opt_stopAt) { return null; } const actionInfos = this.matchActionInfos_(n, actionEventType); if (actionInfos && isEnabled(n)) { return {node: n, actionInfos: devAssert(actionInfos)}; } n = n.parentElement; } return null; } /** * @param {!Element} node * @param {string} actionEventType * @return {?Array<!ActionInfoDef>} */ matchActionInfos_(node, actionEventType) { const actionMap = this.getActionMap_(node, actionEventType); if (!actionMap) { return null; } return actionMap[actionEventType] || null; } /** * @param {!Element} node * @param {string} actionEventType * @return {?{[key: string]: !Array<!ActionInfoDef>}} */ getActionMap_(node, actionEventType) { let actionMap = node[ACTION_MAP_]; if (actionMap === undefined) { actionMap = null; if (node.hasAttribute('on')) { const action = node.getAttribute('on'); actionMap = parseActionMap(action, node); node[ACTION_MAP_] = actionMap; } else if (node.hasAttribute('execute')) { const action = node.getAttribute('execute'); actionMap = parseActionMap(`${actionEventType}:${action}`, node); node[ACTION_MAP_] = actionMap; } } return actionMap; } /** * Resets a node's actions with those defined in the given actions string. * @param {!Element} node * @param {string} actionsStr */ setActions(node, actionsStr) { node.setAttribute('on', actionsStr); // Clear cache. delete node[ACTION_MAP_]; } /** * Given a browser 'change' or 'input' event, add `detail` property to it * containing allowlisted properties of the target element. Noop if `detail` * is readonly. * @param {!ActionEventDef} event * @private */ addTargetPropertiesAsDetail_(event) { const detail = /** @type {!JsonObject} */ (map()); const {target} = event; if (target.value !== undefined) { detail['value'] = target.value; } // Check tagName instead since `valueAsNumber` isn't supported on IE. if (target.tagName == 'INPUT') { // Probably supported natively but convert anyways for consistency. detail['valueAsNumber'] = Number(target.value); } if (target.checked !== undefined) { detail['checked'] = target.checked; } if (target.min !== undefined || target.max !== undefined) { detail['min'] = target.min; detail['max'] = target.max; } if (target.files) { detail['files'] = toArray(target.files).map((file) => ({ 'name': file.name, 'size': file.size, 'type': file.type, })); } if (Object.keys(detail).length > 0) { try { event.detail = detail; } catch {} // event.detail is readonly } } } /** * @param {string} lowercaseTagName * @return {boolean} * @private */ function isAmpTagName(lowercaseTagName) { return lowercaseTagName.substring(0, 4) === 'amp-'; } /** * Returns `true` if the given action invocation is allowlisted in the given * allowlist. Default actions' alias, 'activate', are automatically * allowlisted if their corresponding registered alias is allowlisted. * @param {!ActionInvocation} invocation * @param {!Array<{tagOrTarget: string, method: string}>} allowlist * @return {boolean} * @private */ function isActionAllowlisted(invocation, allowlist) { let {method} = invocation; const {node, tagOrTarget} = invocation; // Use alias if default action is invoked. if ( method === DEFAULT_ACTION && typeof node.getDefaultActionAlias == 'function' ) { method = node.getDefaultActionAlias(); } const lcMethod = method.toLowerCase(); const lcTagOrTarget = tagOrTarget.toLowerCase(); return allowlist.some((w) => { if ( w.tagOrTarget.toLowerCase() === lcTagOrTarget || w.tagOrTarget === '*' ) { if (w.method.toLowerCase() === lcMethod) { return true; } } return false; }); } /** * A clone of an event object with its function properties replaced. * This is useful e.g. for event objects that need to be passed to an async * context, but the browser might have cleaned up the original event object. * This clone replaces functions with error throws since they won't behave * normally after the original object has been destroyed. * @private visible for testing */ export class DeferredEvent { /** * @param {!Event} event */ constructor(event) { /** @type {?Object} */ this.detail = null; cloneWithoutFunctions(event, this); } } /** * Clones an object and replaces its function properties with throws. * @param {!T} original * @param {!T=} opt_dest * @return {!T} * @template T * @private */ function cloneWithoutFunctions(original, opt_dest) { const clone = opt_dest || map(); for (const prop in original) { const value = original[prop]; if (typeof value === 'function') { clone[prop] = notImplemented; } else { clone[prop] = original[prop]; } } return clone; } /** @private */ function notImplemented() { devAssert(null, 'Deferred events cannot access native event functions.'); } /** * @param {string} action * @param {!Element} context * @return {?{[key: string]: !Array<!ActionInfoDef>}} * @private Visible for testing only. */ export function parseActionMap(action, context) { const assertAction = assertActionForParser.bind(null, action, context); const assertToken = assertTokenForParser.bind(null, action, context); let actionMap = null; const toks = new ParserTokenizer(action); let tok; let peek; do { tok = toks.next(); if ( tok.type == TokenType_Enum.EOF || (tok.type == TokenType_Enum.SEPARATOR && tok.value == ';') ) { // Expected, ignore. } else if ( tok.type == TokenType_Enum.LITERAL || tok.type == TokenType_Enum.ID ) { // Format: event:target.method // Event: "event:" const event = tok.value; // Target: ":target." separator assertToken(toks.next(), [TokenType_Enum.SEPARATOR], ':'); const actions = []; // Handlers for event. do { const target = assertToken(toks.next(), [ TokenType_Enum.LITERAL, TokenType_Enum.ID, ]).value; // Method: ".method". Method is optional. let method = DEFAULT_ACTION; let args = null; peek = toks.peek(); if (peek.type == TokenType_Enum.SEPARATOR && peek.value == '.') { toks.next(); // Skip '.' method = assertToken(toks.next(), [ TokenType_Enum.LITERAL, TokenType_Enum.ID, ]).value || method; // Optionally, there may be arguments: "(key = value, key = value)". peek = toks.peek(); if (peek.type == TokenType_Enum.SEPARATOR && peek.value == '(') { toks.next(); // Skip '(' args = tokenizeMethodArguments(toks, assertToken, assertAction); } } actions.push({ event, target, method, args: args && getMode().test && Object.freeze ? Object.freeze(args) : args, str: action, }); peek = toks.peek(); } while ( peek.type == TokenType_Enum.SEPARATOR && peek.value == ',' && toks.next() ); // skip "," when found if (!actionMap) { actionMap = map(); } actionMap[event] = actions; } else { // Unexpected token. assertAction(false, `; unexpected token [${tok.value || ''}]`); } } while (tok.type != TokenType_Enum.EOF); return actionMap; } /** * Tokenizes and returns method arguments, e.g. target.method(arguments). * @param {!ParserTokenizer} toks * @param {!Function} assertToken * @param {!Function} assertAction * @return {?ActionInfoArgsDef} * @private */ function tokenizeMethodArguments(toks, assertToken, assertAction) { let peek = toks.peek(); let tok; let args = null; // Object literal. Format: {...} if (peek.type == TokenType_Enum.OBJECT) { // Don't parse object literals. Tokenize as a single expression // fragment and delegate to specific action handler. args = map(); const {value} = toks.next(); args[RAW_OBJECT_ARGS_KEY] = value; assertToken(toks.next(), [TokenType_Enum.SEPARATOR], ')'); } else { // Key-value pairs. Format: key = value, .... do { tok = toks.next(); const {type, value} = tok; if (type == TokenType_Enum.SEPARATOR && (value == ',' || value == ')')) { // Expected: ignore. } else if (type == TokenType_Enum.LITERAL || type == TokenType_Enum.ID) { // Key: "key = " assertToken(toks.next(), [TokenType_Enum.SEPARATOR], '='); // Value is either a literal or an expression: "foo.bar.baz" tok = assertToken(toks.next(/* convertValue */ true), [ TokenType_Enum.LITERAL, TokenType_Enum.ID, ]); const argValueTokens = [tok]; // Expressions have one or more dereferences: ".identifier" if (tok.type == TokenType_Enum.ID) { for ( peek = toks.peek(); peek.type == TokenType_Enum.SEPARATOR && peek.value == '.'; peek = toks.peek() ) { toks.next(); // Skip '.'. tok = assertToken(toks.next(false), [TokenType_Enum.ID]); argValueTokens.push(tok); } } const argValue = argValueForTokens(argValueTokens); if (!args) { args = map(); } args[value] = argValue; peek = toks.peek(); assertAction( peek.type == TokenType_Enum.SEPARATOR && (peek.value == ',' || peek.value == ')'), 'Expected either [,] or [)]' ); } else { // Unexpected token. assertAction(false, `; unexpected token [${tok.value || ''}]`); } } while (!(tok.type == TokenType_Enum.SEPARATOR && tok.value == ')')); } return args; } /** * @param {Array<!TokenDef>} tokens * @return {?ActionInfoArgValueDef} * @private */ function argValueForTokens(tokens) { if (tokens.length == 0) { return null; } else if (tokens.length == 1) { return /** @type {(boolean|number|string)} */ (tokens[0].value); } else { const values = tokens.map((token) => token.value); const expression = values.join('.'); return /** @type {ActionInfoArgExpressionDef} */ ({expression}); } } /** * Dereferences expression args in `args` using values in data. * @param {?ActionInfoArgsDef} args * @param {?ActionEventDef} event * @param {?JsonObject=} opt_args * @return {?JsonObject} * @private */ export function dereferenceArgsVariables(args, event, opt_args) { if (!args) { return args; } const data = opt_args || {}; if (event) { const detail = getDetail(/** @type {!Event} */ (event)); if (detail) { data['event'] = detail; } } const applied = map(); Object.keys(args).forEach((key) => { let value = args[key]; // Only JSON expression strings that contain dereferences (e.g. `foo.bar`) // are processed as ActionInfoArgExpressionDef. We also support // dereferencing strings like `foo` iff there is a corresponding key in // `data`. Otherwise, `foo` is treated as a string "foo". if (typeof value == 'object' && value.expression) { const expr = /** @type {ActionInfoArgExpressionDef} */ (value).expression; const exprValue = getValueForExpr(data, expr); // If expr can't be found in data, use null instead of undefined. value = exprValue === undefined ? null : exprValue; } if (data[value]) { applied[key] = data[value]; } else { applied[key] = value; } }); return applied; } /** * @param {string} s * @param {!Element} context * @param {?T} condition * @param {string=} opt_message * @return {T} * @template T * @private */ function assertActionForParser(s, context, condition, opt_message) { return userAssert( condition, 'Invalid action definition in %s: [%s] %s', context, s, opt_message || '' ); } /** * @param {string} s * @param {!Element} context * @param {!TokenDef} tok * @param {Array<TokenType_Enum>} types * @param {*=} opt_value * @return {!TokenDef} * @private */ function assertTokenForParser(s, context, tok, types, opt_value) { if (opt_value !== undefined) { assertActionForParser( s, context, types.includes(tok.type) && tok.value == opt_value, `; expected [${opt_value}]` ); } else { assertActionForParser(s, context, types.includes(tok.type)); } return tok; } /** * @enum {number} */ const TokenType_Enum = { INVALID: 0, EOF: 1, SEPARATOR: 2, LITERAL: 3, ID: 4, OBJECT: 5, }; /** * @typedef {{type: TokenType_Enum, value: *}} */ let TokenDef; /** @private @const {string} */ const WHITESPACE_SET = ' \t\n\r\f\v\u00A0\u2028\u2029'; /** @private @const {string} */ const SEPARATOR_SET = ';:.()=,|!'; /** @private @const {string} */ const STRING_SET = '"\''; /** @private @const {string} */ const OBJECT_SET = '{}'; /** @private @const {string} */ const SPECIAL_SET = WHITESPACE_SET + SEPARATOR_SET + STRING_SET + OBJECT_SET; /** @private */ class ParserTokenizer { /** * @param {string} str */ constructor(str) { /** @private @const {string} */ this.str_ = str; /** @private {number} */ this.index_ = -1; } /** * Returns the next token and advances the position. * @param {boolean=} opt_convertValues * @return {!TokenDef} */ next(opt_convertValues) { const tok = this.next_(opt_convertValues || false); this.index_ = tok.index; return tok; } /** * Returns the next token but keeps the current position. * @param {boolean=} opt_convertValues * @return {!TokenDef} */ peek(opt_convertValues) { return this.next_(opt_convertValues || false); } /** * @param {boolean} convertValues * @return {!{type: TokenType_Enum, value: *, index: number}} */ next_(convertValues) { let newIndex = this.index_ + 1; if (newIndex >= this.str_.length) { return {type: TokenType_Enum.EOF, index: this.index_}; } let c = this.str_.charAt(newIndex); // Whitespace: standard set. if (WHITESPACE_SET.indexOf(c) != -1) { newIndex++; for (; newIndex < this.str_.length; newIndex++) { if (WHITESPACE_SET.indexOf(this.str_.charAt(newIndex)) == -1) { break; } } if (newIndex >= this.str_.length) { return {type: TokenType_Enum.EOF, index: newIndex}; } c = this.str_.charAt(newIndex); } // A numeric. Notice that it steals the `.` from separators. if ( convertValues && (isNum(c) || (c == '.' && newIndex + 1 < this.str_.length && isNum(this.str_[newIndex + 1]))) ) { let hasFraction = c == '.'; let end = newIndex + 1; for (; end < this.str_.length; end++) { const c2 = this.str_.charAt(end); if (c2 == '.') { hasFraction = true; continue; } if (!isNum(c2)) { break; } } const s = this.str_.substring(newIndex, end); const value = hasFraction ? parseFloat(s) : parseInt(s, 10); newIndex = end - 1; return {type: TokenType_Enum.LITERAL, value, index: newIndex}; } // Different separators. if (SEPARATOR_SET.indexOf(c) != -1) { return {type: TokenType_Enum.SEPARATOR, value: c, index: newIndex}; } // String literal. if (STRING_SET.indexOf(c) != -1) { let end = -1; for (let i = newIndex + 1; i < this.str_.length; i++) { if (this.str_.charAt(i) == c) { end = i; break; } } if (end == -1) { return {type: TokenType_Enum.INVALID, index: newIndex}; } const value = this.str_.substring(newIndex + 1, end); newIndex = end; return {type: TokenType_Enum.LITERAL, value, index: newIndex}; } // Object literal. if (c == '{') { let numberOfBraces = 1; let end = -1; for (let i = newIndex + 1; i < this.str_.length; i++) { const char = this.str_[i]; if (char == '{') { numberOfBraces++; } else if (char == '}') { numberOfBraces--; } if (numberOfBraces <= 0) { end = i; break; } } if (end == -1) { return {type: TokenType_Enum.INVALID, index: newIndex}; } const value = this.str_.substring(newIndex, end + 1); newIndex = end; return {type: TokenType_Enum.OBJECT, value, index: newIndex}; } // Advance until next special character. let end = newIndex + 1; for (; end < this.str_.length; end++) { if (SPECIAL_SET.indexOf(this.str_.charAt(end)) != -1) { break; } } const s = this.str_.substring(newIndex, end); newIndex = end - 1; // Boolean literal. if (convertValues && (s == 'true' || s == 'false')) { const value = s == 'true'; return {type: TokenType_Enum.LITERAL, value, index: newIndex}; } // Identifier. if (!isNum(s.charAt(0))) { return {type: TokenType_Enum.ID, value: s, index: newIndex}; } // Key. return {type: TokenType_Enum.LITERAL, value: s, index: newIndex}; } } /** * Tests whether a chacter is a number. * @param {string} c * @return {boolean} */ function isNum(c) { return c >= '0' && c <= '9'; } /** * @param {!./ampdoc-impl.AmpDoc} ampdoc */ export function installActionServiceForDoc(ampdoc) { registerServiceBuilderForDoc( ampdoc, 'action', ActionService, /* opt_instantiate */ true ); }