/
githubmirror
/
amphtml
Обзор
Документация
Войти
/
githubmirror
/
amphtml
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/service/navigation.js
798 строк
24 KB
Viktor Szépe
Fix typos in src/ (#39606)
12 авг 2025, 21:35
Не верифицирован
12 авг 2025, 21:35
fca692e
Код
Авторство
О чём код?
import {PriorityQueue} from '#core/data-structures/priority-queue'; import {isIframed} from '#core/dom'; import {escapeCssSelectorIdent} from '#core/dom/css-selectors'; import {closestAncestorElementBySelector} from '#core/dom/query'; import {getWin} from '#core/window'; import {Services} from '#service'; import {dev, user, userAssert} from '#utils/log'; import {getExtraParamsUrl, shouldAppendExtraParams} from '../impression'; import {getMode} from '../mode'; import {openWindowDialog} from '../open-window-dialog'; import {registerServiceBuilderForDoc} from '../service-helpers'; import {isLocalhostOrigin} from '../url'; const TAG = 'navigation'; /** @private @const {string} */ const EVENT_TYPE_CLICK = 'click'; /** @private @const {string} */ const EVENT_TYPE_CONTEXT_MENU = 'contextmenu'; const VALID_TARGETS = ['_top', '_blank']; /** @private @const {string} */ const ORIG_HREF_ATTRIBUTE = 'data-a4a-orig-href'; /** * Key used for retargeting event target originating from shadow DOM. * @const {string} */ const AMP_CUSTOM_LINKER_TARGET = '__AMP_CUSTOM_LINKER_TARGET__'; /** * @enum {number} Priority reserved for extensions in anchor mutations. * The higher the priority, the sooner it's invoked. */ export const Priority_Enum = { LINK_REWRITER_MANAGER: 0, ANALYTICS_LINKER: 2, }; /** * Install navigation service for ampdoc, which handles navigations from anchor * tag clicks and other runtime features like AMP.navigateTo(). * * Immediately instantiates the service. * * @param {!./ampdoc-impl.AmpDoc} ampdoc */ export function installGlobalNavigationHandlerForDoc(ampdoc) { registerServiceBuilderForDoc( ampdoc, TAG, Navigation, /* opt_instantiate */ true ); } /** * @param {!./ampdoc-impl.AmpDoc} ampdoc * @param {!Event} e * @visibleForTesting */ export function maybeExpandUrlParamsForTesting(ampdoc, e) { maybeExpandUrlParams(ampdoc, e); } /** * Intercept any click on the current document and prevent any * linking to an identifier from pushing into the history stack. * @visibleForTesting */ export class Navigation { /** * @param {!./ampdoc-impl.AmpDoc} ampdoc */ constructor(ampdoc) { /** @const {!./ampdoc-impl.AmpDoc} */ this.ampdoc = ampdoc; /** @private @const {!Document|!ShadowRoot} */ this.rootNode_ = ampdoc.getRootNode(); /** @private @const {!./viewport/viewport-interface.ViewportInterface} */ this.viewport_ = Services.viewportForDoc(this.ampdoc); /** @private @const {!./viewer-interface.ViewerInterface} */ this.viewer_ = Services.viewerForDoc(this.ampdoc); /** @private @const {!./history-impl.History} */ this.history_ = Services.historyForDoc(this.ampdoc); /** @private @const {!./platform-impl.Platform} */ this.platform_ = Services.platformFor(this.ampdoc.win); /** @private @const {boolean} */ this.isIosSafari_ = this.platform_.isIos() && this.platform_.isSafari(); /** @private @const {boolean} */ this.isIframed_ = isIframed(this.ampdoc.win) && this.viewer_.isOvertakeHistory(); /** @private @const {boolean} */ this.isEmbed_ = this.rootNode_ != this.ampdoc.getRootNode() || !!this.ampdoc.getParent(); /** @private @const {boolean} */ this.isInABox_ = getMode(this.ampdoc.win).runtime == 'inabox'; /** * Must use URL parsing scoped to `rootNode_` for correct FIE behavior. * @private @const {!Element|!ShadowRoot} */ this.serviceContext_ = /** @type {!Element|!ShadowRoot} */ ( this.rootNode_.nodeType == Node.DOCUMENT_NODE ? this.rootNode_.documentElement : this.rootNode_ ); /** @private @const {!function(!Event)|undefined} */ this.boundHandle_ = this.handle_.bind(this); this.rootNode_.addEventListener(EVENT_TYPE_CLICK, this.boundHandle_); this.rootNode_.addEventListener(EVENT_TYPE_CONTEXT_MENU, this.boundHandle_); /** @private {boolean} */ this.appendExtraParams_ = false; shouldAppendExtraParams(this.ampdoc).then((res) => { this.appendExtraParams_ = res; }); /** @private {boolean} */ this.isTrustedViewer_ = false; /** @private {boolean} */ this.isLocalViewer_ = false; Promise.all([ this.viewer_.isTrustedViewer(), this.viewer_.getViewerOrigin(), ]).then((values) => { this.isTrustedViewer_ = values[0]; this.isLocalViewer_ = isLocalhostOrigin(values[1]); }); /** * Lazy-generated list of A2A-enabled navigation features. * @private {?Array<string>} */ this.a2aFeatures_ = null; /** * @type {!PriorityQueue<function(!Element, !Event)>} * @private * @const */ this.anchorMutators_ = new PriorityQueue(); /** * @type {!PriorityQueue<function(string)>} * @private * @const */ this.navigateToMutators_ = new PriorityQueue(); } /** * Registers a handler that performs URL replacement on the href * of an ad click. * @param {!./ampdoc-impl.AmpDoc} ampdoc * @param {!Window} win */ static installAnchorClickInterceptor(ampdoc, win) { win.document.documentElement.addEventListener( 'click', maybeExpandUrlParams.bind(null, ampdoc), /* capture */ true ); } /** * Removes all event listeners. */ cleanup() { if (this.boundHandle_) { this.rootNode_.removeEventListener(EVENT_TYPE_CLICK, this.boundHandle_); this.rootNode_.removeEventListener( EVENT_TYPE_CONTEXT_MENU, this.boundHandle_ ); } } /** * Opens a new window with the specified target. * * @param {!Window} win A window to use to open a new window. * @param {string} url THe URL to open. * @param {string} target The target for the newly opened window. * @param {boolean} opener Whether or not the new window should have access * to the opener (win). */ openWindow(win, url, target, opener) { let options = ''; // We don't pass noopener for Chrome since it opens a new window without // tabs. Instead, we remove the opener property from the newly opened // window. // Note: for Safari, we need to use noopener instead of clearing the opener // property. if ((this.platform_.isIos() || !this.platform_.isChrome()) && !opener) { options += 'noopener'; } const newWin = openWindowDialog(win, url, target, options); // For Chrome, since we cannot use noopener. if (newWin && !opener) { newWin.opener = null; } } /** * Navigates a window to a URL. * * If opt_requestedBy matches a feature name in a <meta> tag with attribute * name="amp-to-amp-navigation", then treats the URL as an AMP URL (A2A). * * @param {!Window} win * @param {string} url * @param {string=} opt_requestedBy * @param {!{ * target: (string|undefined), * opener: (boolean|undefined), * }=} options */ navigateTo(win, url, opt_requestedBy, options = {}) { const {opener = false, target = '_top'} = options; url = this.applyNavigateToMutators_(url); const urlService = Services.urlForDoc(this.serviceContext_); if (!urlService.isProtocolValid(url)) { user().error(TAG, 'Cannot navigate to invalid protocol: ' + url); return; } userAssert( VALID_TARGETS.includes(target), `Target '${target}' not supported.` ); // If we're on cache, resolve relative URLs to the publisher (non-cache) origin. const sourceUrl = urlService.getSourceUrl(win.location); url = urlService.resolveRelativeUrl(url, sourceUrl); // If we have a target of "_blank", we will want to open a new window. A // target of "_top" should behave like it would on an anchor tag and // update the URL. if (target == '_blank') { this.openWindow(win, url, target, opener); return; } // If this redirect was requested by a feature that opted into A2A, // try to ask the viewer to navigate this AMP URL. if (opt_requestedBy) { if (!this.a2aFeatures_) { this.a2aFeatures_ = this.queryA2AFeatures_(); } if (this.a2aFeatures_.includes(opt_requestedBy)) { if (this.navigateToAmpUrl(url, opt_requestedBy)) { return; } } } // Otherwise, perform normal behavior of navigating the top frame. win.top.location.href = url; } /** * Requests A2A navigation to the given destination. If the viewer does * not support this operation, does nothing. * The URL is assumed to be in AMP Cache format already. * @param {string} url An AMP article URL. * @param {string} requestedBy Informational string about the entity that * requested the navigation. * @return {boolean} Returns true if navigation message was sent to viewer. * Otherwise, returns false. */ navigateToAmpUrl(url, requestedBy) { if (this.viewer_.hasCapability('a2a')) { this.viewer_.sendMessage('a2aNavigate', { 'url': url, 'requestedBy': requestedBy, }); return true; } return false; } /** * @return {!Array<string>} * @private */ queryA2AFeatures_() { const meta = this.rootNode_.querySelector( 'meta[name="amp-to-amp-navigation"]' ); if (meta && meta.hasAttribute('content')) { return meta .getAttribute('content') .split(',') .map((s) => s.trim()); } return []; } /** * Intercept any click on the current document and prevent any * linking to an identifier from pushing into the history stack. * * This also handles custom protocols (e.g. whatsapp://) when iframed * on iOS Safari. * * @param {!Event} e * @private */ handle_(e) { if (e.defaultPrevented) { return; } const element = dev().assertElement( e[AMP_CUSTOM_LINKER_TARGET] || e.target ); const target = closestAncestorElementBySelector(element, 'A'); if (!target || !target.href) { return; } if (e.type == EVENT_TYPE_CLICK) { this.handleClick_(target, e); } else if (e.type == EVENT_TYPE_CONTEXT_MENU) { this.handleContextMenuClick_(target, e); } } /** * @param {!Element} element * @param {!Event} e * @private */ handleClick_(element, e) { this.expandVarsForAnchor_(element); let toLocation = this.parseUrl_(element.href); // Handle AMP-to-AMP navigation and early-outs, if rel=amphtml. if (this.handleA2AClick_(e, element, toLocation)) { return; } // Handle navigating to custom protocol and early-outs, if applicable. if (this.handleCustomProtocolClick_(e, element, toLocation)) { return; } const fromLocation = this.getLocation_(); // Only apply anchor mutator if this is an external navigation. // Note that anchor mutators may theoretically change the navigation // from external to internal, so we re-parse the new targetLocation // in handleNavigation_(). if (getHrefMinusHash(toLocation) != getHrefMinusHash(fromLocation)) { this.applyAnchorMutators_(element, e); toLocation = this.parseUrl_(element.href); } // Finally, handle normal click-navigation behavior. this.handleNavigation_(e, element, toLocation, fromLocation); } /** * Handles "contextmenu" event e.g. right mouse button click. * @param {!Element} element * @param {!Event} e * @private */ handleContextMenuClick_(element, e) { // TODO(wg-performance): Handle A2A, custom link protocols, and ITP 2.3 mitigation. this.expandVarsForAnchor_(element); this.applyAnchorMutators_(element, e); } /** * Apply anchor transformations. * @param {!Element} element * @param {!Event} e */ applyAnchorMutators_(element, e) { this.anchorMutators_.forEach((anchorMutator) => { anchorMutator(element, e); }); } /** * Apply URL transformations for AMP.navigateTo. * @param {string} url * @return {string} */ applyNavigateToMutators_(url) { this.navigateToMutators_.forEach((mutator) => { url = mutator(url); }); return url; } /** * @param {!Element} el * @private */ expandVarsForAnchor_(el) { // First check if need to handle external link decoration. let defaultExpandParamsUrl = null; if (this.appendExtraParams_ && !this.isEmbed_) { // Only decorate outgoing link when needed to and is not in FIE. defaultExpandParamsUrl = getExtraParamsUrl(this.ampdoc.win, el); } const urlReplacements = Services.urlReplacementsForDoc(el); urlReplacements.maybeExpandLink(el, defaultExpandParamsUrl); } /** * Handles clicking on a custom protocol link. * Returns true if the navigation was handled. Otherwise, returns false. * @param {!Event} e * @param {!Element} element * @param {!Location} location * @return {boolean} * @private */ handleCustomProtocolClick_(e, element, location) { // Handle custom protocols only if the document is iframed. if (!this.isIframed_) { return false; } /** @const {!Window} */ const win = getWin(element); const url = element.href; const {protocol} = location; // On Safari iOS, custom protocol links will fail to open apps when the // document is iframed - in order to go around this, we set the top.location // to the custom protocol href. const isFTP = protocol == 'ftp:'; // In case of FTP Links in embedded documents always open then in _blank. if (isFTP) { openWindowDialog(win, url, '_blank'); e.preventDefault(); return true; } const isNormalProtocol = /^(https?|mailto):$/.test(protocol); if (this.isIosSafari_ && !isNormalProtocol) { openWindowDialog(win, url, '_top'); // Without preventing default the page would should an alert error twice // in the case where there's no app to handle the custom protocol. e.preventDefault(); return true; } return false; } /** * Handles clicking on an AMP link. * Returns true if the navigation was handled. Otherwise, returns false. * @param {!Event} e * @param {!Element} element * @param {!Location} location * @return {boolean} * @private */ handleA2AClick_(e, element, location) { if (!element.hasAttribute('rel')) { return false; } const relations = element .getAttribute('rel') .split(' ') .map((s) => s.trim()); if (!relations.includes('amphtml')) { return false; } // The viewer may not support the capability for navigating AMP links. if (this.navigateToAmpUrl(location.href, '<a rel=amphtml>')) { e.preventDefault(); return true; } return false; } /** * Handles click-navigation on a non-A2A, standard-protocol link. * @param {!Event} e * @param {!Element} element * @param {!Location} toLocation * @param {!Location} fromLocation * @private */ handleNavigation_(e, element, toLocation, fromLocation) { const to = getHrefMinusHash(toLocation); const from = getHrefMinusHash(fromLocation); // Handle same-page (hash) navigation separately. if (toLocation.hash && to == from) { this.handleHashNavigation_(e, toLocation, fromLocation); } else { // Otherwise, this is an other-page (external) navigation. let target = (element.getAttribute('target') || '').toLowerCase(); if (this.isEmbed_ || this.isInABox_) { // Target in the embed must be either _top or _blank (default). if (target != '_top' && target != '_blank') { target = '_blank'; element.setAttribute('target', target); } } // ITP 2.3 mitigation. See https://github.com/ampproject/amphtml/issues/25179. const {win} = this.ampdoc; const platform = Services.platformFor(win); const viewer = Services.viewerForDoc(element); if ( fromLocation.search && platform.isSafari() && platform.getMajorVersion() >= 13 && viewer.isProxyOrigin() && viewer.isEmbedded() ) { this.removeViewerQueryBeforeNavigation_(win, fromLocation, target); } if (this.viewerInterceptsNavigation(to, 'intercept_click')) { e.preventDefault(); } } } /** * Temporarily remove viewer query params from iframe (e.g. amp_js_v, usqp) * to prevent document.referrer from being reduced to eTLD+1 (e.g. ampproject.org). * @param {!Window} win * @param {!Location} fromLocation * @param {string} target * @private */ removeViewerQueryBeforeNavigation_(win, fromLocation, target) { dev().info( TAG, 'Removing iframe query string before navigation:', fromLocation.search ); const original = fromLocation.href; const noQuery = `${fromLocation.origin}${fromLocation.pathname}${fromLocation.hash}`; win.history.replaceState(null, '', noQuery); const restoreQuery = () => { const currentHref = win.location.href; if (currentHref == noQuery) { dev().info(TAG, 'Restored iframe URL with query string:', original); win.history.replaceState(null, '', original); } else { dev().error(TAG, 'Unexpected iframe URL change:', currentHref, noQuery); } }; // For blank_, restore query params after the new page opens. if (target === '_blank') { win.setTimeout(restoreQuery, 0); } else { // For _top etc., wait until page is restored from page cache (bfcache). // https://webkit.org/blog/516/webkit-page-cache-ii-the-unload-event/ win.addEventListener('pageshow', function onPageShow(e) { if (e.persisted) { restoreQuery(); win.removeEventListener('pageshow', onPageShow); } }); } } /** * Handles clicking on an internal link * @param {!Event} e * @param {!Location} toLocation * @param {!Location} fromLocation * @private */ handleHashNavigation_(e, toLocation, fromLocation) { // We prevent default so that the current click does not push // into the history stack as this messes up the external documents // history which contains the amp document. e.preventDefault(); // For an embed, do not perform scrolling or global history push - both have // significant UX and browser problems. if (this.isEmbed_) { return; } // Look for the referenced element. const hash = toLocation.hash.slice(1); let el = null; if (hash) { const escapedHash = escapeCssSelectorIdent(hash); el = this.rootNode_.getElementById(hash) || // Fallback to anchor[name] if element with id is not found. // Linking to an anchor element with name is obsolete in html5. this.rootNode_./*OK*/ querySelector(`a[name="${escapedHash}"]`); } // If possible do update the URL with the hash. As explained above // we do `replace` to avoid messing with the container's history. if (toLocation.hash != fromLocation.hash) { this.history_.replaceStateForTarget(toLocation.hash).then(() => { this.scrollToElement_(el, hash); }); } else { // If the hash did not update just scroll to the element. this.scrollToElement_(el, hash); } } /** * @param {function(!Element, !Event)} callback * @param {number} priority */ registerAnchorMutator(callback, priority) { this.anchorMutators_.enqueue(callback, priority); } /** * @param {function(string)} callback * @param {number} priority */ registerNavigateToMutator(callback, priority) { this.navigateToMutators_.enqueue(callback, priority); } /** * Scrolls the page to the given element. * @param {?Element} elem * @param {string} hash * @private */ scrollToElement_(elem, hash) { // Scroll to the element if found. if (elem) { // The first call to scrollIntoView overrides browsers' default scrolling // behavior. The second call insides setTimeout allows us to scroll to // that element properly. Without doing this, the viewport will not catch // the updated scroll position on iOS Safari and hence calculate the wrong // scrollTop for the scrollbar jumping the user back to the top for // failing to calculate the new jumped offset. Without the first call // there will be a visual jump due to browser scroll. See // https://github.com/ampproject/amphtml/issues/5334 for more details. this.viewport_./*OK*/ scrollIntoView(elem); Services.timerFor(this.ampdoc.win).delay( () => this.viewport_./*OK*/ scrollIntoView(dev().assertElement(elem)), 1 ); } else { dev().warn( TAG, `failed to find element with id=${hash} or a[name=${hash}]` ); } } /** * @param {string} url * @return {!Location} * @private */ parseUrl_(url) { return Services.urlForDoc(this.serviceContext_).parse(url); } /** * @return {!Location} * @private */ getLocation_() { // In test mode, we're not able to properly fix the anchor tag's base URL. // So, we have to use the (mocked) window's location instead. const baseHref = getMode().test && !this.isEmbed_ ? this.ampdoc.win.location.href : ''; return this.parseUrl_(baseHref); } /** * Requests navigation through a Viewer to the given destination. * * This function only proceeds if: * 1. The viewer supports the 'interceptNavigation' capability. * 2. The contained AMP doc has 'opted in' via including the 'allow-navigation-interception' * attribute on the <html> tag. * 3. The viewer is trusted or from localhost. * * @param {string} url A URL. * @param {string} requestedBy Informational string about the entity that * requested the navigation. * @return {boolean} Returns true if navigation message was sent to viewer. * Otherwise, returns false. */ viewerInterceptsNavigation(url, requestedBy) { const viewerHasCapability = this.viewer_.hasCapability( 'interceptNavigation' ); const docOptedIn = this.ampdoc.isSingleDoc() && this.ampdoc .getRootNode() .documentElement.hasAttribute('allow-navigation-interception'); if ( !viewerHasCapability || !docOptedIn || !(this.isTrustedViewer_ || this.isLocalViewer_) ) { return false; } this.viewer_.sendMessage('navigateTo', { 'url': url, 'requestedBy': requestedBy, }); return true; } } /** * Handle click on links and replace variables in the click URL. * The function changes the actual href value and stores the * template in the ORIGINAL_HREF_ATTRIBUTE attribute * @param {!./ampdoc-impl.AmpDoc} ampdoc * @param {!Event} e */ function maybeExpandUrlParams(ampdoc, e) { const target = closestAncestorElementBySelector( dev().assertElement(e.target), 'A' ); if (!target || !target.href) { // Not a click on a link. return; } const hrefToExpand = target.getAttribute(ORIG_HREF_ATTRIBUTE) || target.getAttribute('href'); if (!hrefToExpand) { return; } const vars = { 'CLICK_X': () => { return e.pageX; }, 'CLICK_Y': () => { return e.pageY; }, }; const newHref = Services.urlReplacementsForDoc(target).expandUrlSync( hrefToExpand, vars, /* opt_allowlist */ { // For now we only allow to replace the click location vars // and nothing else. // NOTE: Addition to this allowlist requires additional review. 'CLICK_X': true, 'CLICK_Y': true, } ); if (newHref != hrefToExpand) { // Store original value so that later clicks can be processed with // freshest values. if (!target.getAttribute(ORIG_HREF_ATTRIBUTE)) { target.setAttribute(ORIG_HREF_ATTRIBUTE, hrefToExpand); } target.setAttribute('href', newHref); } } /** * Returns href without hash. * @param {!Location} location * @return {string} */ function getHrefMinusHash(location) { return `${location.origin}${location.pathname}${location.search}`; }