From 29593134bf63784af48054cba9dd7ff390ad8891 Mon Sep 17 00:00:00 2001 From: Phil Winchester Date: Thu, 28 Feb 2019 09:55:18 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8Create=20new=20extension=20-=20AMP-sma?= =?UTF-8?q?rtlinks=20(#20967)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * creating new amp-smartlinks extension * BAM-2584 AMP-Smartlinks (#3) * adding example page and amp-smartlinks to bundles.config * creating amp-smartlinks scaffolding * BAM-2584 adding hardcoded POC implementation of amp-smartlinks * adding new constants file and linkmate file/class * adding linkmate call and association working with link-rewrite service * add amp config call and update workflow to use those values * setup initial amp-smartlinks and linkmate workflow * adding new config variables for link attribute and selector * add tests for amp-smartlinks * add test for linkmate.js * add more thorough anchorList check in runSmartlinks * adding code comments, new constants structure and better options validation * update amp-smartlinks to have helpful information * updating global vars and cleaning up main files * clean up tests and add more explicit type assertions * clean up jsdoc tags * BAM-2585 Whitelist import, fix type errors, and replace user.assert with userAssert (#4) * BAM-2585 whitelist navigation import and fix type errors * replace user.assert with userAssert * BAM-2585 fix validator and type check (#5) * more validator fixes (#6) * BAM-2585 Fix validator, copyright, and whitespace (#7) * alphabetize validator, fix whitespace, and add valid tag * update year in copyright statement * BAM-2585 move `link-rewriter` to import statements and updating types (#8) * BAM-2585 remove link-rewriter and switch to importing from skimlinks extension * clean up promise chain and more descriptive API comments * update xhr to pull from ampdoc.win and add types to constants.js * fix type in buildPageImpressionPayload_ * update validator with new empty value check (#9) * update validator for exclusive-links * switch page-impression API request to customEventReporter (#11) * BAM-2585 fix jsdoc in linkmate-options and unnecessary param in page_impression request (#12) * fix jsdoc for linkmate params and make runLinkmate more readable (#13) * add try/catch on amp_config fetch and updated constants.js * remove bad type in constants.js and add check for existing shop-links (#14) * add check for auction_id in mapLinks and add jsdoc for SMARTLINKS_REWRITER_ID * fix type notation in constants.js and linkmate-options.js * fix indentation in example file * add note to README describing link-rewriter priority queue behavior (#15) * add exception to compile.js for amp-skimlinks * update validator to use empty value as indicator linkmate param * fix validator and linkmate-options to use new config style (#16) * updating tests for linkmate-options.js * remove redundant userAssert in linkmate-options * update tests to reflect config changes * update tests to send accurate config params * update readme to refelct config changes and more accurate function names in linkmate-options --- build-system/dep-check-config.js | 4 + build-system/tasks/compile.js | 2 + bundles.config.js | 1 + examples/amp-smartlinks.html | 47 ++ extensions/amp-smartlinks/0.1/OWNERS.yaml | 4 + .../amp-smartlinks/0.1/amp-smartlinks.js | 220 +++++++++ extensions/amp-smartlinks/0.1/constants.js | 26 ++ .../amp-smartlinks/0.1/linkmate-options.js | 86 ++++ extensions/amp-smartlinks/0.1/linkmate.js | 192 ++++++++ .../0.1/test/test-amp-smartlinks.js | 246 +++++++++++ .../amp-smartlinks/0.1/test/test-linkmate.js | 418 ++++++++++++++++++ .../0.1/test/validator-amp-smartlinks.html | 38 ++ .../0.1/test/validator-amp-smartlinks.out | 39 ++ extensions/amp-smartlinks/amp-smartlinks.md | 102 +++++ .../validator-amp-smartlinks.protoascii | 54 +++ src/extension-analytics.js | 7 + test/unit/test-extension-analytics.js | 17 + 17 files changed, 1503 insertions(+) create mode 100644 examples/amp-smartlinks.html create mode 100644 extensions/amp-smartlinks/0.1/OWNERS.yaml create mode 100644 extensions/amp-smartlinks/0.1/amp-smartlinks.js create mode 100644 extensions/amp-smartlinks/0.1/constants.js create mode 100644 extensions/amp-smartlinks/0.1/linkmate-options.js create mode 100644 extensions/amp-smartlinks/0.1/linkmate.js create mode 100644 extensions/amp-smartlinks/0.1/test/test-amp-smartlinks.js create mode 100644 extensions/amp-smartlinks/0.1/test/test-linkmate.js create mode 100644 extensions/amp-smartlinks/0.1/test/validator-amp-smartlinks.html create mode 100644 extensions/amp-smartlinks/0.1/test/validator-amp-smartlinks.out create mode 100644 extensions/amp-smartlinks/amp-smartlinks.md create mode 100644 extensions/amp-smartlinks/validator-amp-smartlinks.protoascii diff --git a/build-system/dep-check-config.js b/build-system/dep-check-config.js index 92311b0353cb7..69ffc0487e4ca 100644 --- a/build-system/dep-check-config.js +++ b/build-system/dep-check-config.js @@ -259,6 +259,10 @@ exports.rules = [ 'extensions/amp-subscriptions-google/0.1/amp-subscriptions-google.js->extensions/amp-subscriptions/0.1/doc-impl.js', 'extensions/amp-subscriptions-google/0.1/amp-subscriptions-google.js->extensions/amp-subscriptions/0.1/entitlement.js', 'extensions/amp-subscriptions-google/0.1/amp-subscriptions-google.js->extensions/amp-subscriptions/0.1/score-factors.js', + + // amp-smartlinks depends on amp-skimlinks/link-rewriter + 'extensions/amp-smartlinks/0.1/amp-smartlinks.js->extensions/amp-skimlinks/0.1/link-rewriter/link-rewriter-manager.js', + 'extensions/amp-smartlinks/0.1/linkmate.js->extensions/amp-skimlinks/0.1/link-rewriter/two-steps-response.js', ], }, { diff --git a/build-system/tasks/compile.js b/build-system/tasks/compile.js index 37967a937b548..7046ae2d021b3 100644 --- a/build-system/tasks/compile.js +++ b/build-system/tasks/compile.js @@ -245,6 +245,8 @@ function compile(entryModuleFilenames, outputDir, outputFilename, options) { 'extensions/amp-viewer-assistance/**/*.js', // Needed for AmpViewerIntegrationVariableService 'extensions/amp-viewer-integration/**/*.js', + // Needed for amp-smartlinks dep on amp-skimlinks + 'extensions/amp-skimlinks/0.1/**/*.js', 'src/*.js', 'src/**/*.js', '!third_party/babel/custom-babel-helpers.js', diff --git a/bundles.config.js b/bundles.config.js index 04926eb402c52..e7856bfd8ceca 100644 --- a/bundles.config.js +++ b/bundles.config.js @@ -261,6 +261,7 @@ exports.extensionBundles = [ type: TYPES.MISC, }, {name: 'amp-skimlinks', version: '0.1', type: TYPES.MISC}, + {name: 'amp-smartlinks', version: '0.1', type: TYPES.MISC}, {name: 'amp-soundcloud', version: '0.1', type: TYPES.MEDIA}, {name: 'amp-springboard-player', version: '0.1', type: TYPES.MEDIA}, { diff --git a/examples/amp-smartlinks.html b/examples/amp-smartlinks.html new file mode 100644 index 0000000000000..d190c61432186 --- /dev/null +++ b/examples/amp-smartlinks.html @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + AMP Smartlinks +

Hello from Narrativ

+ + + + + + + + diff --git a/extensions/amp-smartlinks/0.1/OWNERS.yaml b/extensions/amp-smartlinks/0.1/OWNERS.yaml new file mode 100644 index 0000000000000..0c53f75ee0893 --- /dev/null +++ b/extensions/amp-smartlinks/0.1/OWNERS.yaml @@ -0,0 +1,4 @@ +- PhilWinchester +- pbecotte +- c-nichols +- zhouyx diff --git a/extensions/amp-smartlinks/0.1/amp-smartlinks.js b/extensions/amp-smartlinks/0.1/amp-smartlinks.js new file mode 100644 index 0000000000000..e2accbfbcf455 --- /dev/null +++ b/extensions/amp-smartlinks/0.1/amp-smartlinks.js @@ -0,0 +1,220 @@ +/** + * Copyright 2019 The AMP HTML Authors. All Rights Reserved. + * + * 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. + */ + +import {CommonSignals} from '../../../src/common-signals'; +import {CustomEventReporterBuilder} from '../../../src/extension-analytics.js'; +import {Services} from '../../../src/services'; +import {dict} from '../../../src/utils/object'; +import {getData} from './../../../src/event-helper'; + +import {ENDPOINTS} from './constants'; +import {LinkRewriterManager} from + '../../amp-skimlinks/0.1/link-rewriter/link-rewriter-manager'; +import {Linkmate} from './linkmate'; +import {getConfigOptions} from './linkmate-options'; + +const TAG = 'amp-smartlinks'; + + +export class AmpSmartlinks extends AMP.BaseElement { + /** + * @param {!AmpElement} element + */ + constructor(element) { + super(element); + + /** @private {?../../../src/service/xhr-impl.Xhr} */ + this.xhr_ = null; + + /** @private {?../../../src/service/ampdoc-impl.AmpDoc} */ + this.ampDoc_ = null; + + /** @private {?../../amp-skimlinks/0.1/link-rewriter/link-rewriter-manager.LinkRewriterManager} */ + this.linkRewriterService_ = null; + + /** @private {?../../amp-skimlinks/0.1/link-rewriter/link-rewriter.LinkRewriter} */ + this.smartLinkRewriter_ = null; + + /** + * This will store config attributes from the extension options and an API + * request. The attributes from options are: + * exclusiveLinks, linkAttribute, linkSelector, linkmateEnabled, nrtvSlug + * The attributes from the API are: + * linkmateExpected, publisherID + * @private {?Object} */ + this.linkmateOptions_ = null; + + /** @private {?./linkmate.Linkmate} */ + this.linkmate_ = null; + + /** @private {?string} */ + this.referrer_ = null; + } + + /** @override */ + buildCallback() { + this.ampDoc_ = this.getAmpDoc(); + this.xhr_ = Services.xhrFor(this.ampDoc_.win); + const viewer = Services.viewerForDoc(this.ampDoc_); + + this.linkmateOptions_ = getConfigOptions(this.element); + this.linkRewriterService_ = new LinkRewriterManager(this.ampDoc_); + + return this.ampDoc_.whenBodyAvailable() + .then(() => viewer.getReferrerUrl()) + .then(referrer => { + this.referrer_ = referrer; + viewer.whenFirstVisible().then(() => { + this.runSmartlinks_(); + }); + }); + } + + /** + * Wait for the config promise to resolve and then proceed to functionality + * @private + */ + runSmartlinks_() { + this.getLinkmateOptions_().then(config => { + this.linkmateOptions_.linkmateExpected = config['linkmate_enabled']; + this.linkmateOptions_.publisherID = config['publisher_id']; + + this.postPageImpression_(); + this.linkmate_ = new Linkmate( + /** @type {!../../../src/service/ampdoc-impl.AmpDoc} */ + (this.ampDoc_), + /** @type {!../../../src/service/xhr-impl.Xhr} */ + (this.xhr_), + /** @type {!Object} */ + (this.linkmateOptions_) + ); + this.smartLinkRewriter_ = this.initLinkRewriter_(); + + // If the config specified linkmate to run and our API is expecting + // linkmate to run + if (this.linkmateOptions_.linkmateEnabled && + this.linkmateOptions_.linkmateExpected) { + this.smartLinkRewriter_.getAnchorReplacementList(); + } + }); + } + + /** + * API call to retrieve the Narrativ config for this extension. + * API response will be a list containing nested json values. For the purpose + * of this extension there will only ever be one value in the list: + * {amp_config: {linkmate_enabled: , publisher_id: }} + * @return {?Promise} + * @private + */ + getLinkmateOptions_() { + const fetchUrl = ENDPOINTS.NRTV_CONFIG_ENDPOINT.replace( + '.nrtv_slug.', this.linkmateOptions_.nrtvSlug + ); + + try { + return this.xhr_.fetchJson(fetchUrl, { + method: 'GET', + ampCors: false, + }) + .then(res => res.json()) + .then(res => { + return getData(res)[0]['amp_config']; + }); + } catch (err) { + return null; + } + } + + + /** + * API call to indicate a page load event happened + * @private + */ + postPageImpression_() { + // When using layout='nodisplay' manually trigger CustomEventReporterBuilder + this.signals().signal(CommonSignals.LOAD_START); + const payload = this.buildPageImpressionPayload_(); + + const builder = new CustomEventReporterBuilder(this.element); + + builder.track('page-impression', ENDPOINTS.PAGE_IMPRESSION_ENDPOINT); + + builder.setTransportConfig(dict({ + 'beacon': true, + 'image': false, + 'xhrpost': true, + 'useBody': true, + })); + + builder.setExtraUrlParams(payload); + const reporter = builder.build(); + + reporter.trigger('page-impression'); + } + + /** + * Initialize and register a Narrativ LinkRewriter instance + * @return {!../../amp-skimlinks/0.1/link-rewriter/link-rewriter.LinkRewriter} + * @private + */ + initLinkRewriter_() { + const options = {linkSelector: this.linkmateOptions_.linkSelector}; + + return this.linkRewriterService_.registerLinkRewriter( + TAG, + anchorList => { + return this.linkmate_.runLinkmate(anchorList); + }, + options + ); + } + + /** + * Build the payload for our page load event. + * @return {!JsonObject} + * @private + */ + buildPageImpressionPayload_() { + return /** @type {!JsonObject} */ (dict({ + 'events': [{'is_amp': true}], + 'organization_id': this.linkmateOptions_.publisherID, + 'organization_type': 'publisher', + 'user': { + 'page_session_uuid': this.generateUUID_(), + 'source_url': this.ampDoc_.getUrl(), + 'previous_url': this.referrer_, + 'user_agent': this.ampDoc_.win.navigator.userAgent, + }, + })); + } + + /** + * Generate a unique UUID for this session. + * @return {string} + * @private + */ + generateUUID_() { + return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c => + (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4) + .toString(16) + ); + } +} + +AMP.extension('amp-smartlinks', '0.1', AMP => { + AMP.registerElement('amp-smartlinks', AmpSmartlinks); +}); diff --git a/extensions/amp-smartlinks/0.1/constants.js b/extensions/amp-smartlinks/0.1/constants.js new file mode 100644 index 0000000000000..350524dfd15b4 --- /dev/null +++ b/extensions/amp-smartlinks/0.1/constants.js @@ -0,0 +1,26 @@ +/** + * Copyright 2019 The AMP HTML Authors. All Rights Reserved. + * + * 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. + */ + +const BASE_API_URL = 'https://api.narrativ.com/api'; +/** @const @enum {string} */ +export const ENDPOINTS = { + PAGE_IMPRESSION_ENDPOINT: + `${BASE_API_URL}/v1/events/impressions/page_impression/`, + NRTV_CONFIG_ENDPOINT: + `${BASE_API_URL}/v0/publishers/.nrtv_slug./amp_config/`, + LINKMATE_ENDPOINT: + `${BASE_API_URL}/v1/publishers/.pub_id./linkmate/smart_links/`, +}; diff --git a/extensions/amp-smartlinks/0.1/linkmate-options.js b/extensions/amp-smartlinks/0.1/linkmate-options.js new file mode 100644 index 0000000000000..af3cf51d1cab4 --- /dev/null +++ b/extensions/amp-smartlinks/0.1/linkmate-options.js @@ -0,0 +1,86 @@ +/** + * Copyright 2019 The AMP HTML Authors. All Rights Reserved. + * + * 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. + */ + + +/** + * Get the config values from the tag on the amp page + * @param {!Element} element + * @return {!Object} + */ +export function getConfigOptions(element) { + return { + nrtvSlug: getNrtvAccountName_(element), + linkmateEnabled: hasLinkmateFlag_(element), + exclusiveLinks: hasExclusiveLinksFlag_(element), + linkAttribute: getLinkAttribute_(element), + linkSelector: getLinkSelector_(element), + }; +} + +/** + * The slug used to distinguish Narrativ accounts. + * @param {!Element} element + * @return {string} + * @private + */ +function getNrtvAccountName_(element) { + const nrtvSlug = element.getAttribute('nrtv-account-name'); + + return nrtvSlug.toLowerCase(); +} + +/** + * Flag to run the Linkmate service on an article. + * @param {!Element} element + * @return {boolean} + * @private + */ +function hasLinkmateFlag_(element) { + return !!element.hasAttribute('linkmate'); +} + +/** + * Flag to mark links as exclusive. + * @param {!Element} element + * @return {boolean} + */ +function hasExclusiveLinksFlag_(element) { + return !!element.hasAttribute('exclusive-links'); +} + +/** + * What attribute the outbound link variable is stored in an anchor. + * @param {!Element} element + * @return {string} + * @private + */ +function getLinkAttribute_(element) { + const linkAttribute = element.getAttribute('link-attribute'); + + return linkAttribute ? linkAttribute.toLowerCase() : 'href'; +} + +/** + * Selector used to get all links that are meant to be monetized. + * @param {!Element} element + * @return {string} + * @private + */ +function getLinkSelector_(element) { + const linkSelector = element.getAttribute('link-selector'); + + return linkSelector ? linkSelector.toLowerCase() : 'a'; +} diff --git a/extensions/amp-smartlinks/0.1/linkmate.js b/extensions/amp-smartlinks/0.1/linkmate.js new file mode 100644 index 0000000000000..b736d31bce8d6 --- /dev/null +++ b/extensions/amp-smartlinks/0.1/linkmate.js @@ -0,0 +1,192 @@ +/** + * Copyright 2019 The AMP HTML Authors. All Rights Reserved. + * + * 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. + */ + +import {deepEquals} from '../../../src/json'; +import {dict} from '../../../src/utils/object'; + +import {ENDPOINTS} from './constants'; +import {TwoStepsResponse} from + '../../amp-skimlinks/0.1/link-rewriter/two-steps-response'; +import {getData} from '../../../src/event-helper'; + + +export class Linkmate { + /** + * @param {!../../../src/service/ampdoc-impl.AmpDoc} ampDoc + * @param {!../../../src/service/xhr-impl.Xhr} xhr + * @param {!Object} linkmateOptions + */ + constructor(ampDoc, xhr, linkmateOptions) { + /** @private {!../../../src/service/ampdoc-impl.AmpDoc} */ + this.ampDoc_ = ampDoc; + + /** @private {!../../../src/service/xhr-impl.Xhr} */ + this.xhr_ = xhr; + + /** @private {?boolean} */ + this.requestExclusiveLinks_ = linkmateOptions.exclusiveLinks; + + /** @private {?number} */ + this.publisherID_ = linkmateOptions.publisherID; + + /** @private {?string} */ + this.linkAttribute_ = linkmateOptions.linkAttribute; + + /** @private {!Document|!ShadowRoot} */ + this.rootNode_ = this.ampDoc_.getRootNode(); + + /** @private {?Array} */ + this.anchorList_ = null; + + /** @private {?Array}*/ + this.linkmateResponse_ = null; + } + + /** + * Callback used by LinkRewriter. Whenever there is a change in the anchors + * on the page we want make a new API call. + * @param {!Array} anchorList + * @return {!../../amp-skimlinks/0.1/link-rewriter/two-steps-response.TwoStepsResponse} + * @public + */ + runLinkmate(anchorList) { + // If we already have an API response and the anchor list has + // changed since last API call then map any new anchors to existing + // API response + let syncMappedLinks = null; + const anchorListChanged = this.anchorList_ && + !deepEquals(this.anchorList_, anchorList); + + if (this.linkmateResponse_ && anchorListChanged) { + syncMappedLinks = this.mapLinks_(); + } + + // If we don't have an API response or the anchor list has changed since + // last API call then build a new payload and post to API + if (!this.linkmateResponse_ || anchorListChanged) { + const asyncMappedLinks = this.postToLinkmate_(anchorList) + .then(res => { + this.linkmateResponse_ = getData(res)[0]['smart_links']; + this.anchorList_ = anchorList; + return this.mapLinks_(); + }); + + return new TwoStepsResponse(syncMappedLinks, asyncMappedLinks); + } else { + // If we didn't need to make an API call return the synchronous response + this.anchorList_ = anchorList; + return new TwoStepsResponse(syncMappedLinks, null); + } + } + + /** + * Build the payload for the Linkmate API call and POST. + * @param {!Array} anchorList + * @private + * @return {?Promise} + */ + postToLinkmate_(anchorList) { + const linksPayload = this.buildLinksPayload_(anchorList); + const editPayload = this.getEditInfo_(); + + const payload = dict({ + 'article': editPayload, + 'links': linksPayload, + }); + + const fetchUrl = ENDPOINTS.LINKMATE_ENDPOINT.replace( + '.pub_id.', this.publisherID_.toString() + ); + const postOptions = { + method: 'POST', + ampCors: false, + headers: dict({'Content-Type': 'application/json'}), + body: payload, + }; + + return this.xhr_.fetchJson(fetchUrl, postOptions) + .then(res => res.json()); + } + + /** + * Build the links portion for Linkmate payload. We need to check each link + * if it has #donotlink to comply with business rules. + * @param {!Array} anchorList + * @return {!Array} + * @private + */ + buildLinksPayload_(anchorList) { + // raw links needs to be stored as a global somewhere + // for later association with the response + const postLinks = []; + anchorList.forEach(anchor => { + const link = anchor[this.linkAttribute_]; + // If a link is already a Narrativ link. + if (/shop-links.co/.test(link)) { + // Check if amp flag is there. Add if necessary. Don't add to payload. + if (!/\?amp=true$/.test(link)) { + anchor[this.linkAttribute_] = + `${anchor[this.linkAttribute_]}?amp=true`; + } + return; + } + + if (!/#donotlink$/.test(link)) { + const exclusive = this.requestExclusiveLinks_ || /#locklink$/.test(link); + const linkObj = { + 'raw_url': link, + 'exclusive_match_requested': exclusive, + }; + + postLinks.push(linkObj); + } + }); + + return postLinks; + } + + /** + * This is just article information used in the edit part of Linkmate payload. + * @return {!JsonObject} + * @private + */ + getEditInfo_() { + return dict({ + 'name': this.rootNode_.title || null, + 'url': this.ampDoc_.getUrl(), + }); + } + + /** + * The API response returns unique links. Map those unique links to as many + * urls in the anchorList as possible. Set the replacement url as a shop-link. + * @return {!../../amp-skimlinks/0.1/link-rewriter/link-rewriter.AnchorReplacementList} + * @public + */ + mapLinks_() { + return this.linkmateResponse_.map(smartLink => { + return Array.prototype.slice.call(this.anchorList_) + .map(anchor => { + return { + anchor, + replacementUrl: anchor[this.linkAttribute_] === smartLink['url'] + && smartLink['auction_id'] + ? `https://shop-links.co/${smartLink['auction_id']}/?amp=true` : null, + }; + }); + })[0]; + } +} diff --git a/extensions/amp-smartlinks/0.1/test/test-amp-smartlinks.js b/extensions/amp-smartlinks/0.1/test/test-amp-smartlinks.js new file mode 100644 index 0000000000000..1ef15100c64ff --- /dev/null +++ b/extensions/amp-smartlinks/0.1/test/test-amp-smartlinks.js @@ -0,0 +1,246 @@ +/** + * Copyright 2019 The AMP HTML Authors. All Rights Reserved. + * + * 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. + */ + +import * as DocumentReady from '../../../../src/document-ready'; +import * as LinkmateOptions from '../linkmate-options'; +import {AmpSmartlinks} from '../amp-smartlinks'; +import {LinkRewriterManager} from + '../../../amp-skimlinks/0.1/link-rewriter/link-rewriter-manager'; +import {Services} from '../../../../src/services'; + +const helpersFactory = env => { + return { + createAmpSmartlinks(extensionAttrs) { + const ampTag = document.createElement('amp-smartlinks'); + + for (const attr in extensionAttrs) { + ampTag.setAttribute(attr, extensionAttrs[attr]); + } + ampTag.getAmpDoc = () => env.ampdoc; + return new AmpSmartlinks(ampTag); + }, + }; +}; + +describes.fakeWin('amp-smartlinks', + {amp: {extensions: ['amp-smartlinks']}}, + env => { + let ampSmartlinks, helpers, xhr; + + beforeEach(() => { + xhr = Services.xhrFor(env.win); + helpers = helpersFactory(env); + env.sandbox + .stub(DocumentReady, 'whenDocumentReady') + .returns(Promise.reject()); + }); + + afterEach(() => { + env.sandbox.restore(); + }); + + describe('getConfigOptions', () => { + it('Should parse options', () => { + env.sandbox.spy(LinkmateOptions, 'getConfigOptions'); + + const smartlinkOptions = { + 'nrtv-account-name': 'thisisnotapublisher', + 'linkmate': '', + 'exclusive-links': '', + 'link-attribute': 'href', + 'link-selector': 'a', + }; + ampSmartlinks = helpers.createAmpSmartlinks(smartlinkOptions); + env.sandbox.stub(ampSmartlinks, 'runSmartlinks_'); + + return ampSmartlinks.buildCallback().then(() => { + expect(LinkmateOptions.getConfigOptions.calledOnce).to.be + .true; + expect(ampSmartlinks.linkmateOptions_).to.deep.equal({ + nrtvSlug: smartlinkOptions['nrtv-account-name'], + linkmateEnabled: true, + exclusiveLinks: true, + linkAttribute: smartlinkOptions['link-attribute'], + linkSelector: smartlinkOptions['link-selector'], + }); + }); + }); + + it('Should return handle bad options', () => { + env.sandbox.spy(LinkmateOptions, 'getConfigOptions'); + + const smartlinkOptions = { + 'nrtv-account-name': 'alwaysastring', + 'linkmate': 1234, + 'exclusive-links': 'monkeysatatypewriter', + }; + ampSmartlinks = helpers.createAmpSmartlinks(smartlinkOptions); + env.sandbox.stub(ampSmartlinks, 'runSmartlinks_'); + + return ampSmartlinks.buildCallback().then(() => { + expect(LinkmateOptions.getConfigOptions.calledOnce).to.be.true; + expect(ampSmartlinks.linkmateOptions_).to.deep.equal({ + nrtvSlug: 'alwaysastring', + linkmateEnabled: true, + exclusiveLinks: true, + linkAttribute: 'href', + linkSelector: 'a', + }); + }); + }); + }); + + describe('getLinkmateOptions_', () => { + it('Should fetch Linkmate Options from API', () => { + const options = { + 'nrtv-account-name': 'testingconfigpub', + 'linkmate': '', + 'exclusive-links': '', + }; + ampSmartlinks = helpers.createAmpSmartlinks(options); + + env.sandbox.spy(ampSmartlinks, 'getLinkmateOptions_'); + env.sandbox.stub(xhr, 'fetchJson'); + + return ampSmartlinks.buildCallback().then(() => { + expect(ampSmartlinks.getLinkmateOptions_.calledOnce).to.be.true; + }); + }); + }); + + describe('runSmartlinks_', () => { + let fakeViewer; + + beforeEach(() => { + const options = { + 'nrtv-account-name': 'thisisnotapublisher', + 'linkmate': '', + 'exclusive-links': '', + }; + + ampSmartlinks = helpers.createAmpSmartlinks(options); + fakeViewer = Services.viewerForDoc(env.ampdoc); + + env.sandbox + .stub(ampSmartlinks, 'getLinkmateOptions_') + .returns(Promise.resolve({'publisher_id': 999})); + env.sandbox.stub(xhr, 'fetchJson'); + }); + + it('Should call postPageImpression_', () => { + env.sandbox.spy(ampSmartlinks, 'postPageImpression_'); + + return ampSmartlinks.buildCallback().then(() => { + fakeViewer.whenFirstVisible().then(() => { + expect(ampSmartlinks.postPageImpression_.calledOnce).to.be.true; + }); + }); + }); + + it('Should call initLinkRewriter_', () => { + env.sandbox.spy(ampSmartlinks, 'initLinkRewriter_'); + + return ampSmartlinks.buildCallback().then(() => { + fakeViewer.whenFirstVisible().then(() => { + expect(ampSmartlinks.initLinkRewriter_.calledOnce).to.be.true; + }); + }); + }); + }); + + describe('buildPageImpressionPayload_', () => { + beforeEach(() => { + const options = { + 'nrtv-account-name': 'thisisnotapublisher', + 'linkmate': '', + 'exclusive-links': '', + }; + + ampSmartlinks = helpers.createAmpSmartlinks(options); + }); + + it('Should build body correctly', () => { + env.sandbox.spy(ampSmartlinks, 'buildPageImpressionPayload_'); + env.sandbox.stub(ampSmartlinks, 'postPageImpression_'); + env.sandbox + .stub(ampSmartlinks, 'generateUUID_') + .returns('acbacc4b-e171-4869-b32a-921f48659624'); + env.sandbox + .stub(env.ampdoc, 'getUrl') + .returns('http://fakewebsite.example/'); + + const mockPub = 999; + const mockUA = 'thisisnotauseragent'; + const expectedPayload = { + 'events': [{'is_amp': true}], + 'organization_id': mockPub, + 'organization_type': 'publisher', + 'user': { + 'page_session_uuid': 'acbacc4b-e171-4869-b32a-921f48659624', + 'source_url': 'http://fakewebsite.example/', + 'previous_url': '', + 'user_agent': mockUA, + }, + }; + + return ampSmartlinks.buildCallback().then(() => { + ampSmartlinks.linkmateOptions_.publisherID = mockPub; + ampSmartlinks.ampDoc_.win.navigator.userAgent = mockUA; + + const payload = ampSmartlinks.buildPageImpressionPayload_(); + expect(payload).to.deep.equals(expectedPayload); + }); + }); + }); + + describe('initSmartlinkRewriter_', () => { + beforeEach(() => { + const options = { + 'nrtv-account-name': 'thisisnotapublisher', + 'linkmate': '', + 'exclusive-links': '', + }; + + ampSmartlinks = helpers.createAmpSmartlinks(options); + env.sandbox + .stub(ampSmartlinks, 'getLinkmateOptions_') + .returns(Promise.resolve({'publisher_id': 999})); + }); + + it('Should register link rewriter', () => { + ampSmartlinks.linkRewriterService_ = new LinkRewriterManager( + env.ampdoc + ); + ampSmartlinks.linkmateOptions_ = { + linkSelector: 'a', + }; + env.sandbox.spy(ampSmartlinks.linkRewriterService_, + 'registerLinkRewriter'); + + ampSmartlinks.initLinkRewriter_(); + const args = ampSmartlinks.linkRewriterService_.registerLinkRewriter + .args[0]; + + expect(ampSmartlinks.linkRewriterService_.registerLinkRewriter + .calledOnce).to.be.true; + // This is a constant value in amp-smartlinks.js + expect(args[0]).to.equal('amp-smartlinks'); + expect(args[1]).to.be.a('function'); + expect(args[2].linkSelector).to.equal('a'); + }); + }); + } +); diff --git a/extensions/amp-smartlinks/0.1/test/test-linkmate.js b/extensions/amp-smartlinks/0.1/test/test-linkmate.js new file mode 100644 index 0000000000000..2f6d3482945f8 --- /dev/null +++ b/extensions/amp-smartlinks/0.1/test/test-linkmate.js @@ -0,0 +1,418 @@ +/** + * Copyright 2019 The AMP HTML Authors. All Rights Reserved. + * + * 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. + */ + +import * as DocumentReady from '../../../../src/document-ready'; +import {AmpSmartlinks} from '../amp-smartlinks'; +import {Linkmate} from '../linkmate'; +import {Services} from '../../../../src/services'; +import {TwoStepsResponse} from + '../../../amp-skimlinks/0.1/link-rewriter/two-steps-response'; + +const helpersFactory = env => { + const {win} = env; + + return { + createAmpSmartlinks(extensionAttrs) { + const ampTag = document.createElement('amp-smartlinks'); + + for (const attr in extensionAttrs) { + ampTag.setAttribute(attr, extensionAttrs[attr]); + } + ampTag.getAmpDoc = () => env.ampdoc; + + return new AmpSmartlinks(ampTag); + }, + createAnchor(href) { + const anchor = win.document.createElement('a'); + anchor.href = href; + + return anchor; + }, + }; +}; + +describes.fakeWin('amp-smartlinks', + {amp: {extensions: ['amp-smartlinks']}}, + env => { + let helpers, xhr, linkmate; + + beforeEach(() => { + xhr = Services.xhrFor(env.win); + helpers = helpersFactory(env); + }); + + beforeEach(() => { + env.sandbox + .stub(DocumentReady, 'whenDocumentReady') + .returns(Promise.reject()); + }); + + afterEach(() => { + env.sandbox.restore(); + }); + + describe('runLinkmate', () => { + let anchorList, mockFetch, response; + + beforeEach(() => { + const linkmateOptions = { + exclusiveLinks: false, + publisherID: 999, + linkAttribute: 'href', + }; + linkmate = new Linkmate( + env.ampdoc, + xhr, + linkmateOptions, + ); + anchorList = [ + 'http://fakelink.example', + 'http://fakelink2.example', + 'https://examplelocklink.example/#locklink', + ].map(helpers.createAnchor); + }); + + beforeEach(() => { + mockFetch = env.sandbox.mock(xhr); + + response = { + json: () => Promise.resolve({}), + }; + }); + + afterEach(() => { + mockFetch.verify(); + }); + + it('Should fire an API call if none exists', () => { + env.sandbox.spy(linkmate, 'postToLinkmate_'); + env.sandbox.stub(linkmate, 'mapLinks_'); + + mockFetch + .expects('fetchJson') + .once() + .returns(Promise.resolve(response)); + + const linkmateResponse = linkmate.runLinkmate(anchorList); + + expect(linkmate.postToLinkmate_.calledOnce).to.be.true; + expect(linkmateResponse).to.be.instanceof(TwoStepsResponse); + }); + + it('Should fire an API call if anchorList changed', () => { + env.sandbox.spy(linkmate, 'postToLinkmate_'); + env.sandbox.stub(linkmate, 'mapLinks_'); + + mockFetch + .expects('fetchJson') + .once() + .returns(Promise.resolve(response)); + const linkmateResponse = linkmate.runLinkmate(anchorList); + + linkmate.anchorList_ = anchorList; + const newAnchorList = [ + 'http://totallynewlink.example', + 'http://fakelink2.example', + 'https://examplelocklink.example/#locklink', + ].map(helpers.createAnchor); + + mockFetch + .expects('fetchJson') + .once() + .returns(Promise.resolve(response)); + const linkmateResponse2 = linkmate.runLinkmate(newAnchorList); + + expect(linkmate.postToLinkmate_.calledTwice).to.be.true; + expect(linkmateResponse).to.be.instanceof(TwoStepsResponse); + expect(linkmateResponse2).to.be.instanceof(TwoStepsResponse); + }); + + it('Should map new anchors', () => { + env.sandbox.spy(linkmate, 'postToLinkmate_'); + env.sandbox.spy(linkmate, 'mapLinks_'); + + mockFetch + .expects('fetchJson') + .once() + .returns(Promise.resolve(response)); + const linkmateResponse = linkmate.runLinkmate(anchorList); + + linkmate.anchorList_ = anchorList; + linkmate.linkmateResponse_ = [{a: 'b'}]; + const newAnchorList = [ + 'http://totallynewlink.example', + 'http://fakelink2.example', + 'https://examplelocklink.example/#locklink', + ].map(helpers.createAnchor); + + mockFetch + .expects('fetchJson') + .once() + .returns(Promise.resolve(response)); + const linkmateResponse2 = linkmate.runLinkmate(newAnchorList); + + expect(linkmate.postToLinkmate_.calledTwice).to.be.true; + expect(linkmate.mapLinks_.calledOnce).to.be.true; + expect(linkmateResponse).to.be.instanceof(TwoStepsResponse); + expect(linkmateResponse2).to.be.instanceof(TwoStepsResponse); + }); + + it('Should do nothing if no new anchors', () => { + env.sandbox.spy(linkmate, 'postToLinkmate_'); + env.sandbox.stub(linkmate, 'mapLinks_'); + + mockFetch + .expects('fetchJson') + .once() + .returns(Promise.resolve(response)); + const linkmateResponse = linkmate.runLinkmate(anchorList); + + linkmate.anchorList_ = anchorList; + linkmate.linkmateResponse_ = [{a: 'b'}]; + const syncResponse = linkmate.runLinkmate(anchorList); + + expect(linkmate.postToLinkmate_.calledOnce).to.be.true; + expect(linkmate.postToLinkmate_.calledTwice).to.be.false; + expect(syncResponse).to.not.be.null; + expect(linkmateResponse).to.be.instanceof(TwoStepsResponse); + }); + }); + + describe('postToLinkmate_', () => { + let mockFetch; + + beforeEach(() => { + mockFetch = env.sandbox.mock(xhr); + }); + + afterEach(() => { + mockFetch.verify(); + }); + + it('Should build payload', () => { + const linkmateOptions = { + exclusiveLinks: false, + publisherID: 999, + linkAttribute: 'href', + }; + linkmate = new Linkmate( + env.ampdoc, + xhr, + linkmateOptions, + ); + const response = { + json: () => Promise.resolve({}), + }; + + env.sandbox.spy(linkmate, 'postToLinkmate_'); + env.sandbox + .stub(linkmate, 'buildLinksPayload_') + .returns({}); + env.sandbox + .stub(linkmate, 'getEditInfo_') + .returns({}); + mockFetch + .expects('fetchJson') + .once() + .returns(Promise.resolve(response)); + linkmate.postToLinkmate_(); + + expect(linkmate.buildLinksPayload_.calledOnce).to.be.true; + expect(linkmate.getEditInfo_.calledOnce).to.be.true; + }); + }); + + describe('buildLinksPayload_', () => { + let anchorList; + + beforeEach(() => { + const linkmateOptions = { + exclusiveLinks: false, + publisherID: 999, + linkAttribute: 'href', + }; + linkmate = new Linkmate( + env.ampdoc, + xhr, + linkmateOptions, + ); + + anchorList = [ + 'http://fakelink.example', + 'http://fakelink2.example', + 'https://examplelocklink.example/#locklink', + ].map(helpers.createAnchor); + }); + + it('Should build payload from anchorList', () => { + env.sandbox.spy(linkmate, 'buildLinksPayload_'); + + const expectedPayload = [{ + 'raw_url': 'http://fakelink.example/', + 'exclusive_match_requested': false, + }, { + 'raw_url': 'http://fakelink2.example/', + 'exclusive_match_requested': false, + }, { + 'raw_url': 'https://examplelocklink.example/#locklink', + 'exclusive_match_requested': true, + }]; + + const linkPayload = linkmate.buildLinksPayload_(anchorList); + + expect(linkPayload).to.deep.equal(expectedPayload); + }); + + it('Should build all exclusive links if requested', () => { + env.sandbox.spy(linkmate, 'buildLinksPayload_'); + + linkmate.requestExclusiveLinks_ = true; + const expectedPayload = [{ + 'raw_url': 'http://fakelink.example/', + 'exclusive_match_requested': true, + }, { + 'raw_url': 'http://fakelink2.example/', + 'exclusive_match_requested': true, + }, { + 'raw_url': 'https://examplelocklink.example/#locklink', + 'exclusive_match_requested': true, + }]; + + const linkPayload = linkmate.buildLinksPayload_(anchorList); + + expect(linkPayload).to.deep.equal(expectedPayload); + }); + + it('Should skip existing shop-links', () => { + env.sandbox.spy(linkmate, 'buildLinksPayload_'); + + anchorList = [ + 'http://fakelink.example', + 'http://http://shop-links.co/999', + 'https://examplelocklink.example/#locklink', + ].map(helpers.createAnchor); + + const expectedPayload = [{ + 'raw_url': 'http://fakelink.example/', + 'exclusive_match_requested': false, + }, { + 'raw_url': 'https://examplelocklink.example/#locklink', + 'exclusive_match_requested': true, + }]; + + const linkPayload = linkmate.buildLinksPayload_(anchorList); + + expect(linkPayload).to.deep.equal(expectedPayload); + }); + + it('Should add amp flag to existing shop-links', () => { + env.sandbox.spy(linkmate, 'buildLinksPayload_'); + + anchorList = [ + 'http://http://shop-links.co/999', + ].map(helpers.createAnchor); + + const expectedAnchor = 'http://http//shop-links.co/999?amp=true'; + + const linkPayload = linkmate.buildLinksPayload_(anchorList); + + expect(linkPayload).to.deep.equal([]); + expect(anchorList[0].href).to.equal(expectedAnchor); + }); + }); + + describe('getEditInfo_', () => { + it('Should build edit info payload', () => { + const linkmateOptions = { + exclusiveLinks: false, + publisherID: 999, + linkAttribute: 'href', + }; + linkmate = new Linkmate( + env.ampdoc, + xhr, + linkmateOptions, + ); + const envRoot = env.ampdoc.getRootNode(); + envRoot.title = 'Fake Website Title'; + + env.sandbox + .stub(env.ampdoc, 'getUrl') + .returns('http://fakewebsite.example/'); + env.sandbox.spy(linkmate, 'getEditInfo_'); + + const expectedPayload = { + 'name': 'Fake Website Title', + 'url': 'http://fakewebsite.example/', + }; + + const editPayload = linkmate.getEditInfo_(); + + expect(editPayload).to.deep.equal(expectedPayload); + }); + }); + + describe('mapLinks_', () => { + let anchorList; + + beforeEach(() => { + const linkmateOptions = { + exclusiveLinks: false, + publisherID: 999, + linkAttribute: 'href', + }; + linkmate = new Linkmate( + env.ampdoc, + xhr, + linkmateOptions, + ); + + anchorList = [ + 'http://fakelink.example/', + 'http://fakelink2.example/', + 'https://examplelocklink.example/#locklink', + ].map(helpers.createAnchor); + }); + + it('Should map API response to anchorList', () => { + env.sandbox.spy(linkmate, 'mapLinks_'); + const linkmateResponse = [{ + 'auction_id': '1661245605416735203', + 'exclusive_match_requested': false, + 'pub_id': 999, + 'url': 'http://fakelink.example/', + }]; + linkmate.anchorList_ = anchorList; + linkmate.linkmateResponse_ = linkmateResponse; + + const expectedMapping = [{ + anchor: anchorList[0], + replacementUrl: `https://shop-links.co/${linkmateResponse[0]['auction_id']}/?amp=true`, + }, { + anchor: anchorList[1], + replacementUrl: null, + }, { + anchor: anchorList[2], + replacementUrl: null, + }]; + + const actualMapping = linkmate.mapLinks_(); + + expect(actualMapping).to.deep.equal(expectedMapping); + }); + }); + } +); diff --git a/extensions/amp-smartlinks/0.1/test/validator-amp-smartlinks.html b/extensions/amp-smartlinks/0.1/test/validator-amp-smartlinks.html new file mode 100644 index 0000000000000..d95c642bb34a5 --- /dev/null +++ b/extensions/amp-smartlinks/0.1/test/validator-amp-smartlinks.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + diff --git a/extensions/amp-smartlinks/0.1/test/validator-amp-smartlinks.out b/extensions/amp-smartlinks/0.1/test/validator-amp-smartlinks.out new file mode 100644 index 0000000000000..6a578a938f26e --- /dev/null +++ b/extensions/amp-smartlinks/0.1/test/validator-amp-smartlinks.out @@ -0,0 +1,39 @@ +PASS +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| \ No newline at end of file diff --git a/extensions/amp-smartlinks/amp-smartlinks.md b/extensions/amp-smartlinks/amp-smartlinks.md new file mode 100644 index 0000000000000..1d69c82d862d1 --- /dev/null +++ b/extensions/amp-smartlinks/amp-smartlinks.md @@ -0,0 +1,102 @@ + + +# `amp-smartlinks` + + + + + + + + + + + + + + +
DescriptionRun Narrativ's Linkmate process inside your AMP page
Required Script<script async custom-element="amp-smartlinks" src="https://cdn.ampproject.org/v0/amp-smartlinks-0.1.js"></script>
Supported Layoutsnodisplay
+ +## Overview + + At [Narrativ](https://narrativ.com/), we transform static commerce links into dynamic, multimerchant nodes. With a library of millions of products matched to expert reviews from top commerce publishers, we lift publisher revenue through real-time bidding and data solutions. + +This AMP extension is our Linkmate service in AMP. See the full documentation for Linkmate [here](http://docs.narrativ.com/en/stable/linkmate.html). + +## Getting started + +Your account must be a member of our Linkmate program to use this feature. For more information about this program, feel free to contact your account manager or [hello@narrativ.com](mailto:hello@narrativ.com). + +NOTE: If you plan to use `amp-smartlinks` alongside other affiliate partners you will need to specify the meta tag shown below. The tag will specify the order in which the affiliate tags fire. + +In your AMP page you will have to add the following snippets: + +```html + + + + ... + + + ... + + + + ... + + + ... + + +``` + +## Attributes + + + + + + + + + + + + + + + + + + + + + + + + + + + +
nrtv-account-nameRequiredYour Narrativ account name given to you by your account manager. Need to know your Narrativ account name? Log into dashboard.narrativ.com and go to setup to see your account name in the snippet, or reach out to your account manager for support as needed.
linkmateOptionalFlag to run our Linkmate service on an article. Inserting the attribute linkmate in the amp-smartlinks element will run our linkmate service.
exclusive-linksOptionalFlag to mark links as exclusive. Inserting the attribute exclusive-links in the amp-smartlinks element will generate exclusive links for the article.
link-attributeOptionalIf you store the "plain" url for a link in a different element attribute than href you can specify so here. Default value: href.
link-selectorOptionalA CSS selector to get all links you want monetized from an article. Default value: a.
+ +## Validation + +See [amp-smartlinks rules](validator-amp-smartlinks.protoascii) in the AMP validator specification. diff --git a/extensions/amp-smartlinks/validator-amp-smartlinks.protoascii b/extensions/amp-smartlinks/validator-amp-smartlinks.protoascii new file mode 100644 index 0000000000000..26b5c9d893aaf --- /dev/null +++ b/extensions/amp-smartlinks/validator-amp-smartlinks.protoascii @@ -0,0 +1,54 @@ +# +# Copyright 2019 The AMP HTML Authors. All Rights Reserved. +# +# 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. +# + +tags: { # amp-smartlinks + html_format: AMP + tag_name: "SCRIPT" + extension_spec: { + name: "amp-smartlinks" + version: "0.1" + version: "latest" + } + attr_lists: "common-extension-attrs" +} + +tags: { # + html_format: AMP + tag_name: "AMP-SMARTLINKS" + requires_extension: "amp-smartlinks" + attrs: { + name: "exclusive-links" + value: "" + } + attrs: { + name: "link-attribute" + } + attrs: { + name: "link-selector" + } + attrs: { + name: "linkmate" + value: "" + } + attrs: { + name: "nrtv-account-name" + mandatory: true + } + attr_lists: "extended-amp-global" + amp_layout: { + supported_layouts: NODISPLAY + } +} diff --git a/src/extension-analytics.js b/src/extension-analytics.js index e9f9d9f662081..c2b29c23daeb3 100644 --- a/src/extension-analytics.js +++ b/src/extension-analytics.js @@ -150,6 +150,13 @@ export class CustomEventReporterBuilder { this.config_['transport'] = transportConfig; } + /** + * @param {!JsonObject} extraUrlParamsConfig + */ + setExtraUrlParams(extraUrlParamsConfig) { + this.config_['extraUrlParams'] = extraUrlParamsConfig; + } + /** * The #track() method takes in a unique custom-event name, and the * corresponding request url (or an array of request urls). One can call diff --git a/test/unit/test-extension-analytics.js b/test/unit/test-extension-analytics.js index 0fa19e93e558f..5122e0ea80494 100644 --- a/test/unit/test-extension-analytics.js +++ b/test/unit/test-extension-analytics.js @@ -196,6 +196,23 @@ describes.realWin('extension-analytics', { 'xhrpost': false, }); }); + + it('Should allow to specify extraUrlParams config', () => { + parent.getResourceId = () => { return 1; }; + parent.signals = () => { + return { + whenSignal: () => { return Promise.resolve(); }, + }; + }; + builder.setExtraUrlParams({ + 'a': 'b', + }); + + const reporter = builder.build(); + expect(reporter.config_.extraUrlParams).to.jsonEqual({ + 'a': 'b', + }); + }); }); describe('CustomEventReporter test', () => {