Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Amp skimlinks exclude selector #18

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion extensions/amp-skimlinks/0.1/amp-skimlinks.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,8 @@ export class AmpSkimlinks extends AMP.BaseElement {
*/
initSkimlinksLinkRewriter_() {
const options = {
linkSelector: this.skimOptions_.linkSelector,
linkSelector: this.skimOptions_.includeSelector,
excludeSelector: this.skimOptions_.excludeSelector,
};

const linkRewriter = this.linkRewriterService_.registerLinkRewriter(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,7 @@ export class LinkRewriterManager {
* @param {!function(!Array<!HTMLElement>): !./two-steps-response.TwoStepsResponse} resolveUnknownLinks
* - Function to determine which anchor should be replaced and by what URL.
* Should return an instance of './two-steps-response.TwoStepsResponse'.
* @param {?{linkSelector: string}=} options
* - linkSelector is an optional CSS selector to restrict
* which anchors the link rewriter should handle.
* Anchors not matching the CSS selector will be ignored.
* If not provided the link rewrite will handle all the links
* found on the page.
*
* @param {?./link-rewriter.LinkRewriterOptions=} options
* @return {!./link-rewriter.LinkRewriter}
* @public
*/
Expand Down
41 changes: 35 additions & 6 deletions extensions/amp-skimlinks/0.1/link-rewriter/link-rewriter.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,18 @@ import {EVENTS, ORIGINAL_URL_ATTRIBUTE} from './constants';
import {LinkReplacementCache} from './link-replacement-cache';
import {Observable} from '../../../../src/observable';
import {TwoStepsResponse} from './two-steps-response';
import {nodeListToArray} from '../utils';
import {userAssert} from '../../../../src/log';

/** @typedef {!Array<{anchor: !HTMLElement, replacementUrl: ?string}>}} */
export let AnchorReplacementList;

/** @typedef {{
* linkSelector: (string|undefined),
* excludeSelector: (string|undefined)
* }} */
export let LinkRewriterOptions;

/**
* LinkRewriter works together with LinkRewriterManager to allow rewriting
* links at click time. E.g: Replacing a link by its affiliate version only if
Expand All @@ -47,7 +54,14 @@ export class LinkRewriter {
* @param {!Document|!ShadowRoot} rootNode
* @param {string} id
* @param {function(!Array<!HTMLElement>):!TwoStepsResponse} resolveUnknownLinks
* @param {?{linkSelector: string}=} options
* @param {?LinkRewriterOptions=} options
* - "linkSelector" option is an optional CSS selector to restrict
* which anchors the link rewriter should handle.
* Only anchors matching the CSS selector will be considered for affiliate.
* If not provided the link rewrite will handle all the links
* found on the page.
* - "excludeSelector" option is an optional CSS selector. All anchors
* matching this selector will be ignored by amp-skimlinks.
*/
constructor(rootNode, id, resolveUnknownLinks, options) {
/** @public {!../../../../src/observable.Observable} */
Expand All @@ -65,6 +79,9 @@ export class LinkRewriter {
/** @private {string} */
this.linkSelector_ = options.linkSelector || 'a';

/** @private {string} */
this.excludeSelector_ = options.excludeSelector || '';

/** @private {number} */
this.restoreDelay_ = 300; //ms

Expand Down Expand Up @@ -188,8 +205,8 @@ export class LinkRewriter {

// Register all new anchors discovered as "unknown" status.
// Note: Only anchors with a status will be considered in the click
// handlers. (Other anchors are assumed to be the ones exluded by
// linkSelector_)
// handlers. (Other anchors are assumed to be the ones excluded by
// linkSelector_ and excludeSelector_)
this.anchorReplacementCache_.updateReplacementUrls(
unknownAnchors.map(anchor => ({anchor, replacementUrl: null}))
);
Expand Down Expand Up @@ -236,12 +253,24 @@ export class LinkRewriter {

/**
* Get the list of anchors element in the page.
* (Based on linkSelector option)
* (Based on linkSelector & excludeSelector options)
* @return {!Array<!HTMLElement>}
* @private
*/
getLinksInDOM_() {
const q = this.rootNode_.querySelectorAll(this.linkSelector_);
return [].slice.call(q);
const includedNodeList = this.rootNode_.querySelectorAll(
this.linkSelector_
);
// Convert from NodeList to array
let anchors = nodeListToArray(includedNodeList);

if (this.excludeSelector_) {
const excludedAnchors = nodeListToArray(
this.rootNode_.querySelectorAll(this.excludeSelector_)
);
anchors = anchors.filter(a => excludedAnchors.indexOf(a) === -1);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure how big of a deal it is, but you are not forcing the exludeSelector / linkSelector to match an actual anchor.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a bit of a concern, I put a WARNING in the documentation https://amp.dev/documentation/components/amp-skimlinks/ to tell the publisher to use this option carefully. I can't think of a reliable way to validate that a CSS selector is matching an a element. Maybe some regex but not sure if we want to go down that road.

For now we need to rely on the publisher being careful.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when you are filtering anchors you can check for .tagname but it will add some processing time (+ you will have to do the filter even if there are no excludeSelectors

}

return anchors;
}
}
33 changes: 27 additions & 6 deletions extensions/amp-skimlinks/0.1/skim-options.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ export function getAmpSkimlinksOptions(element, docInfo) {
excludedDomains: getExcludedDomains_(element, getInternalDomains_(docInfo)),
tracking: getTrackingStatus_(element),
customTrackingId: getCustomTrackingId_(element),
linkSelector: getLinkSelector_(element),
includeSelector: getIncludeSelector_(element),
excludeSelector: getExcludeSelector_(element),
waypointBaseUrl: getWaypointBaseUrl(element),
config: getConfig_(element),
};
Expand Down Expand Up @@ -89,7 +90,6 @@ function getPubCode_(element) {
}

/**
*
* @param {!Element} element
* @return {boolean}
*/
Expand Down Expand Up @@ -121,14 +121,35 @@ function getCustomTrackingId_(element) {
}

/**
*
* @param {!Element} element
* @return {?string}
*/
function getLinkSelector_(element) {
const linkSelector = element.getAttribute('link-selector');
function getIncludeSelector_(element) {
let includeSelector = element.getAttribute('include-selector');
if (!includeSelector) {
// 'link-selector' is a deprecated equivalent of 'include-selector'.
includeSelector = element.getAttribute('link-selector');
}

return includeSelector || null;
}

return linkSelector || null;
/**
*
* @param {!Element} element
* @return {?string}
*/
function getExcludeSelector_(element) {
// Always exclude:
// - Links with "noskimlinks" class.
// - Links within a parent container (direct or not) with a "noskimlinks" class.
const defaultExcludeSelector = 'a.noskimlinks, .noskimlinks a';
const userExcludeSelector = element.getAttribute('exclude-selector');
const excludeSelector = userExcludeSelector
? userExcludeSelector.concat(`, ${defaultExcludeSelector}`)
: defaultExcludeSelector;

return excludeSelector;
}

/**
Expand Down
8 changes: 4 additions & 4 deletions extensions/amp-skimlinks/0.1/test/test-amp-skimlinks.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ describes.fakeWin(
'excluded-domains': 'amazon.com amazon.fr ',
tracking: false,
'custom-tracking-id': 'campaignX',
'link-selector': '.content a',
'include-selector': '.content a',
};
ampSkimlinks = helpers.createAmpSkimlinks(options);
env.sandbox.stub(ampSkimlinks, 'startSkimcore_');
Expand All @@ -107,7 +107,7 @@ describes.fakeWin(
pubcode: options['publisher-code'],
tracking: options['tracking'],
customTrackingId: options['custom-tracking-id'],
linkSelector: options['link-selector'],
includeSelector: options['include-selector'],
});
expect(ampSkimlinks.skimOptions_.excludedDomains).to.include.members([
'amazon.com',
Expand All @@ -121,7 +121,7 @@ describes.fakeWin(

beforeEach(() => {
ampSkimlinks.skimOptions_ = {
linkSelector: '.article a',
includeSelector: '.article a',
};
ampSkimlinks.linkRewriterService_ = new LinkRewriterManager(
env.ampdoc
Expand Down Expand Up @@ -152,7 +152,7 @@ describes.fakeWin(
expect(args[0]).to.equal(SKIMLINKS_REWRITER_ID);
expect(args[1]).to.be.a('function');
expect(args[2].linkSelector).to.equal(
ampSkimlinks.skimOptions_.linkSelector
ampSkimlinks.skimOptions_.includeSelector
);
});

Expand Down
46 changes: 46 additions & 0 deletions extensions/amp-skimlinks/0.1/test/test-link-rewriter.js
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,52 @@ describes.fakeWin('Link Rewriter', {amp: true}, env => {

expect(resolveFunction.firstCall.args[0]).to.deep.equal([anchor2]);
});

it('Should ignore links matching excludeSelector', () => {
const anchor1 = rootDocument.createElement('a');
const anchor2 = rootDocument.createElement('a');
anchor2.classList.add('no-affiliate');
const anchor3 = rootDocument.createElement('a');

rootDocument.body.appendChild(anchor1);
rootDocument.body.appendChild(anchor2);
rootDocument.body.appendChild(anchor3);

const resolveFunction = createResolveResponseHelper();
createLinkRewriterHelper(resolveFunction, {
excludeSelector: 'a.no-affiliate',
}).scanLinksOnPage_();

expect(resolveFunction.firstCall.args[0]).to.deep.equal([
anchor1,
anchor3,
]);
});

it('excludeSelector should have priority over linkSelector', () => {
const anchor1 = rootDocument.createElement('a');
anchor1.classList.add('affiliate');
const anchor2 = rootDocument.createElement('a');
anchor2.classList.add('affiliate');
const anchor3 = rootDocument.createElement('a');

const noskimContainer = rootDocument.createElement('div');
noskimContainer.classList.add('no-affiliate');

rootDocument.body.appendChild(noskimContainer);
// Anchor1 has affiliate class but is inside a container with no-affiliate.
noskimContainer.appendChild(anchor1);
rootDocument.body.appendChild(anchor2);
rootDocument.body.appendChild(anchor3);

const resolveFunction = createResolveResponseHelper();
createLinkRewriterHelper(resolveFunction, {
linkSelector: 'a.affiliate',
excludeSelector: '.no-affiliate a',
}).scanLinksOnPage_();

expect(resolveFunction.firstCall.args[0]).to.deep.equal([anchor2]);
});
});

describe('In dynamic page', () => {
Expand Down
69 changes: 69 additions & 0 deletions extensions/amp-skimlinks/0.1/test/test-skim-options.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,5 +141,74 @@ describes.fakeWin(
expect(options.waypointBaseUrl).to.equal(`http://${cname}`);
});
});

describe('include-selector', () => {
it('Should be null by default', () => {
const element = helpers.createAmpSkimlinksElement({
'publisher-code': '123X123',
});
const options = getAmpSkimlinksOptions(element, docInfo);

expect(options.includeSelector).to.be.null;
});

it('Should read the "include-selector" option', () => {
const element = helpers.createAmpSkimlinksElement({
'publisher-code': '123X123',
'include-selector': 'article a',
});
const options = getAmpSkimlinksOptions(element, docInfo);

expect(options.includeSelector).to.equal('article a');
});

it('Should read deprecated "link-selector" option', () => {
const element = helpers.createAmpSkimlinksElement({
'publisher-code': '123X123',
// legacy equivalent of 'include-selector'
'link-selector': 'article a',
});
const options = getAmpSkimlinksOptions(element, docInfo);

expect(options.includeSelector).to.equal('article a');
});

it('Should prioritise "include-selector" over "link-selector" option', () => {
const element = helpers.createAmpSkimlinksElement({
'publisher-code': '123X123',
'include-selector': 'article a',
// legacy equivalent of 'include-selector'
'link-selector': 'article.press a',
});
const options = getAmpSkimlinksOptions(element, docInfo);

expect(options.includeSelector).to.equal('article a');
});
});

describe('exclude-selector', () => {
it('Should have the noskimlinks exclude selector by default', () => {
const element = helpers.createAmpSkimlinksElement({
'publisher-code': '123X123',
});
const options = getAmpSkimlinksOptions(element, docInfo);

expect(options.excludeSelector).to.equal(
'a.noskimlinks, .noskimlinks a'
);
});

it('Should have both the noskimlinks exclude selector and the custom exclude selector when defined', () => {
const element = helpers.createAmpSkimlinksElement({
'publisher-code': '123X123',
'exclude-selector': '.no-affiliate a',
});
const options = getAmpSkimlinksOptions(element, docInfo);

expect(options.excludeSelector).to.equal(
'.no-affiliate a, a.noskimlinks, .noskimlinks a'
);
});
});
}
);
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
layout="nodisplay"
publisher-code="123X123"
excluded-domains="samsung.com asos.com"
link-selector="article:not(.no-skimlinks) a"
link-selector="article a"
include-selector="article a"
exclude-selector=".no-affiliate a"
custom-tracking-id="phones"
custom-redirect-domain="go.publisher.com"
></amp-skimlinks>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ PASS
| layout="nodisplay"
| publisher-code="123X123"
| excluded-domains="samsung.com asos.com"
| link-selector="article:not(.no-skimlinks) a"
| link-selector="article a"
| include-selector="article a"
| exclude-selector=".no-affiliate a"
| custom-tracking-id="phones"
| custom-redirect-domain="go.publisher.com"
| ></amp-skimlinks>
Expand Down
9 changes: 9 additions & 0 deletions extensions/amp-skimlinks/0.1/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,12 @@ export function isExcludedAnchorUrl(anchor, skimOptions) {
const domain = getNormalizedHostnameFromAnchor(anchor);
return isExcludedDomain(domain, skimOptions);
}

/**
* Convert a NodeList to an Array.
* @param {NodeList} nodeList
* @return {!Array<!HTMLElement>}
*/
export function nodeListToArray(nodeList) {
return [].slice.call(nodeList);
}
6 changes: 6 additions & 0 deletions extensions/amp-skimlinks/validator-amp-skimlinks.protoascii
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ tags: { # <amp-skimlinks>
attrs: {
name: "link-selector"
}
attrs: {
name: "include-selector"
}
attrs: {
name: "exclude-selector"
}
attrs: {
name: "publisher-code"
value_regex_casei: "^[0-9]+X[0-9]+$"
Expand Down