+
+
diff --git a/extensions/amp-smartlinks/0.1/OWNERS.yaml b/extensions/amp-smartlinks/0.1/OWNERS.yaml
new file mode 100644
index 000000000000..0c53f75ee089
--- /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 000000000000..e2accbfbcf45
--- /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 000000000000..350524dfd15b
--- /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 000000000000..af3cf51d1cab
--- /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 000000000000..b736d31bce8d
--- /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