Added outbound link tagging for web posts (#16201)

fixes https://github.com/TryGhost/Team/issues/2433

- Moved all outbound link tagging code to separate OutboundLinkTagger
- Because a site can easily enable/disable this feature, we don't store
the ?refs in the HTML but add them on the fly for now in the Content
API.
This commit is contained in:
Simon Backx 2023-02-16 11:26:35 +01:00 committed by GitHub
parent a310ec2e3e
commit 8dfa490638
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 458 additions and 278 deletions

View File

@ -216,7 +216,7 @@
<div class="gh-expandable-block">
<div class="gh-expandable-header">
<div>
<h4 class="gh-expandable-title">External attribution</h4>
<h4 class="gh-expandable-title">Outbound Link Tagging</h4>
<p class="gh-expandable-description">
Adds ?ref to external links in web posts and adds a setting to control this for both web and newsletters.
</p>

View File

@ -18,6 +18,8 @@ const getPostServiceInstance = require('../../../../../../services/posts/posts-s
const postsService = getPostServiceInstance();
const commentsService = require('../../../../../../services/comments');
const memberAttribution = require('../../../../../../services/member-attribution');
const labs = require('../../../../../../../shared/labs');
module.exports = async (model, frame, options = {}) => {
const {tiers: tiersData} = options || {};
@ -65,6 +67,15 @@ module.exports = async (model, frame, options = {}) => {
} else {
jsonModel.comments = false;
}
// Add outbound link tags
if (labs.isSet('outboundLinkTagging')) {
// Only add it in the flag! Without the flag we only add it to emails.
if (jsonModel.html) {
// Only set if HTML was requested
jsonModel.html = await memberAttribution.outboundLinkTagger.addToHtml(jsonModel.html);
}
}
}
// Transforms post/page metadata to flat structure

View File

@ -67,7 +67,8 @@ class EmailServiceWrapper {
linkReplacer,
linkTracking,
memberAttributionService: memberAttribution.service,
audienceFeedbackService: audienceFeedback.service
audienceFeedbackService: audienceFeedback.service,
outboundLinkTagger: memberAttribution.outboundLinkTagger
});
const sendingService = new SendingService({

View File

@ -400,13 +400,13 @@ const PostEmailSerializer = {
if (isSite) {
// Add newsletter name as ref to the URL
url = memberAttribution.service.addOutboundLinkTagging(url, newsletter);
url = memberAttribution.outboundLinkTagger.addToUrl(url, newsletter);
// Only add post attribution to our own site (because external sites could/should not process this information)
url = memberAttribution.service.addPostAttributionTracking(url, post);
} else {
// Add email source attribution without the newsletter name
url = memberAttribution.service.addOutboundLinkTagging(url);
url = memberAttribution.outboundLinkTagger.addToUrl(url);
}
// Add link click tracking

View File

@ -12,7 +12,7 @@ class MemberAttributionServiceWrapper {
// Wire up all the dependencies
const {
MemberAttributionService, UrlTranslator, ReferrerTranslator, AttributionBuilder
MemberAttributionService, UrlTranslator, ReferrerTranslator, AttributionBuilder, OutboundLinkTagger
} = require('@tryghost/member-attribution');
const models = require('../../models');
@ -33,6 +33,12 @@ class MemberAttributionServiceWrapper {
this.attributionBuilder = new AttributionBuilder({urlTranslator, referrerTranslator});
this.outboundLinkTagger = new OutboundLinkTagger({
isEnabled: () => !labs.isSet('outboundLinkTagging') || !!settingsCache.get('outbound_link_tagging'),
getSiteTitle: () => settingsCache.get('title'),
urlUtils
});
// Expose the service
this.service = new MemberAttributionService({
models: {
@ -41,9 +47,7 @@ class MemberAttributionServiceWrapper {
Integration: models.Integration
},
attributionBuilder: this.attributionBuilder,
getTrackingEnabled: () => !!settingsCache.get('members_track_sources'),
getOutboundLinkTaggingEnabled: () => !labs.isSet('outboundLinkTagging') || !!settingsCache.get('outbound_link_tagging'),
getSiteTitle: () => settingsCache.get('title')
getTrackingEnabled: () => !!settingsCache.get('members_track_sources')
});
}
}

View File

@ -21,7 +21,7 @@ Hopefully you don't find it a bore.",
"feature_image_caption": null,
"featured": false,
"frontmatter": null,
"html": "<!--kg-card-begin: markdown--><h1>Static page test is what this is for.</h1><p>Hopefully you don't find it a bore.</p><!--kg-card-end: markdown-->",
"html": "<!--kg-card-begin: markdown--><h1>Static page test is what this is for.</h1><p>Hopefully you don&apos;t find it a bore.</p><!--kg-card-end: markdown-->",
"id": "618ba1ffbe2896088840a6e9",
"meta_description": null,
"meta_title": null,
@ -48,7 +48,7 @@ exports[`Pages Content API Can request page 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "*",
"cache-control": "public, max-age=0",
"content-length": "1083",
"content-length": "1088",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -90,7 +90,7 @@ Tip: If you're reading any post or page on your site and you notice something yo
"feature_image_caption": null,
"featured": false,
"frontmatter": null,
"html": "<p>Unlike posts, pages in Ghost don't appear in the main feed. They're separate, individual pages which only show up when you link to them. Great for content which is important, but separate from your usual posts.</p><p>An about page is a great example of one you might want to set up early on so people can find out more about you, and what you do. Why should people subscribe to your site and become a member? Details help!</p><blockquote><strong>Tip: </strong>If you're reading any post or page on your site and you notice something you want to edit, you can add <code>/edit</code> to the end of the URL and you'll be taken directly to the Ghost editor.</blockquote><p>Now tell the world what your site is all about.</p>",
"html": "<p>Unlike posts, pages in Ghost don&apos;t appear in the main feed. They&apos;re separate, individual pages which only show up when you link to them. Great for content which is important, but separate from your usual posts.</p><p>An about page is a great example of one you might want to set up early on so people can find out more about you, and what you do. Why should people subscribe to your site and become a member? Details help!</p><blockquote><strong>Tip: </strong>If you&apos;re reading any post or page on your site and you notice something you want to edit, you can add <code>/edit</code> to the end of the URL &#x2013; and you&apos;ll be taken directly to the Ghost editor.</blockquote><p>Now tell the world what your site is all about.</p>",
"id": "6194d3ce51e2700162531a78",
"meta_description": null,
"meta_title": null,
@ -134,7 +134,7 @@ If you prefer to use a contact form, almost all of the great embedded form servi
"feature_image_caption": null,
"featured": false,
"frontmatter": null,
"html": "<p>If you want to set up a contact page for people to be able to reach out to you, the simplest way is to set up a simple page like this and list the different ways people can reach out to you.</p><h3 id=\\"for-example-heres-how-to-reach-us\\">For example, here's how to reach us!</h3><ul><li><a href=\\"https://twitter.com/ghost\\">@Ghost</a> on Twitter</li><li><a href=\\"https://www.facebook.com/ghost\\">@Ghost</a> on Facebook</li><li><a href=\\"https://instagram.com/ghost\\">@Ghost</a> on Instagram</li></ul><p>If you prefer to use a contact form, almost all of the great embedded form services work great with Ghost and are easy to set up:</p><figure class=\\"kg-card kg-image-card\\"><a href=\\"https://ghost.org/integrations/?tag=forms\\"><img src=\\"https://static.ghost.org/v4.0.0/images/integrations.png\\" class=\\"kg-image\\" alt loading=\\"lazy\\" width=\\"2944\\" height=\\"1716\\"></a></figure>",
"html": "<p>If you want to set up a contact page for people to be able to reach out to you, the simplest way is to set up a simple page like this and list the different ways people can reach out to you.</p><h3 id=\\"for-example-heres-how-to-reach-us\\">For example, here&apos;s how to reach us!</h3><ul><li><a href=\\"https://twitter.com/ghost?ref=ghost\\">@Ghost</a> on Twitter</li><li><a href=\\"https://www.facebook.com/ghost\\">@Ghost</a> on Facebook</li><li><a href=\\"https://instagram.com/ghost?ref=ghost\\">@Ghost</a> on Instagram</li></ul><p>If you prefer to use a contact form, almost all of the great embedded form services work great with Ghost and are easy to set up:</p><figure class=\\"kg-card kg-image-card\\"><a href=\\"https://ghost.org/integrations/?tag=forms&amp;ref=ghost\\"><img src=\\"https://static.ghost.org/v4.0.0/images/integrations.png\\" class=\\"kg-image\\" alt loading=\\"lazy\\" width=\\"2944\\" height=\\"1716\\"></a></figure>",
"id": "6194d3ce51e2700162531a79",
"meta_description": null,
"meta_title": null,
@ -174,7 +174,7 @@ Ghost is a non-profit organization, and we give away all our intellectual proper
"feature_image_caption": null,
"featured": false,
"frontmatter": null,
"html": "<p>Oh hey, you clicked every link of our starter content and even clicked this small link in the footer! If you like Ghost and you're enjoying the product so far, we'd hugely appreciate your support in any way you care to show it.</p><p>Ghost is a non-profit organization, and we give away all our intellectual property as open source software. If you believe in what we do, there are a number of ways you can give us a hand, and we hugely appreciate all of them:</p><ul><li>Contribute code via <a href=\\"https://github.com/tryghost\\">GitHub</a></li><li>Contribute financially via <a href=\\"https://github.com/sponsors/TryGhost\\">GitHub Sponsors</a></li><li>Contribute financially via <a href=\\"https://opencollective.com/ghost\\">Open Collective</a></li><li>Contribute reviews via <strong>writing a blog post</strong></li><li>Contribute good vibes via <strong>telling your friends</strong> about us</li></ul><p>Thanks for checking us out!</p>",
"html": "<p>Oh hey, you clicked every link of our starter content and even clicked this small link in the footer! If you like Ghost and you&apos;re enjoying the product so far, we&apos;d hugely appreciate your support in any way you care to show it.</p><p>Ghost is a non-profit organization, and we give away all our intellectual property as open source software. If you believe in what we do, there are a number of ways you can give us a hand, and we hugely appreciate all of them:</p><ul><li>Contribute code via <a href=\\"https://github.com/tryghost?ref=ghost\\">GitHub</a></li><li>Contribute financially via <a href=\\"https://github.com/sponsors/TryGhost?ref=ghost\\">GitHub Sponsors</a></li><li>Contribute financially via <a href=\\"https://opencollective.com/ghost?ref=ghost\\">Open Collective</a></li><li>Contribute reviews via <strong>writing a blog post</strong></li><li>Contribute good vibes via <strong>telling your friends</strong> about us</li></ul><p>Thanks for checking us out!</p>",
"id": "6194d3ce51e2700162531a7b",
"meta_description": null,
"meta_title": null,
@ -211,7 +211,7 @@ You can integrate any products, services, ads or integrations with Ghost yoursel
"feature_image_caption": null,
"featured": false,
"frontmatter": null,
"html": "<p>Wondering how Ghost fares when it comes to privacy and GDPR rules? Good news: Ghost does not use any tracking cookies of any kind.</p><p>You can integrate any products, services, ads or integrations with Ghost yourself if you want to, but it's always a good idea to disclose how subscriber data will be used by putting together a privacy page.</p>",
"html": "<p>Wondering how Ghost fares when it comes to privacy and GDPR rules? Good news: Ghost does not use any tracking cookies of any kind.</p><p>You can integrate any products, services, ads or integrations with Ghost yourself if you want to, but it&apos;s always a good idea to disclose how subscriber data will be used by putting together a privacy page.</p>",
"id": "6194d3ce51e2700162531a7a",
"meta_description": null,
"meta_title": null,
@ -248,7 +248,7 @@ Hopefully you don't find it a bore.",
"feature_image_caption": null,
"featured": false,
"frontmatter": null,
"html": "<!--kg-card-begin: markdown--><h1>Static page test is what this is for.</h1><p>Hopefully you don't find it a bore.</p><!--kg-card-end: markdown-->",
"html": "<!--kg-card-begin: markdown--><h1>Static page test is what this is for.</h1><p>Hopefully you don&apos;t find it a bore.</p><!--kg-card-end: markdown-->",
"id": "618ba1ffbe2896088840a6e9",
"meta_description": null,
"meta_title": null,
@ -275,7 +275,7 @@ exports[`Pages Content API Can request pages 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "*",
"cache-control": "public, max-age=0",
"content-length": "9149",
"content-length": "9263",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,

File diff suppressed because one or more lines are too long

View File

@ -4,7 +4,7 @@ const moment = require('moment');
const testUtils = require('../../utils');
const models = require('../../../core/server/models');
const {agentProvider, fixtureManager, matchers} = require('../../utils/e2e-framework');
const {agentProvider, fixtureManager, matchers, mockManager} = require('../../utils/e2e-framework');
const {anyArray, anyContentVersion, anyEtag, anyUuid, anyISODateTimeWithTZ} = matchers;
const postMatcher = {
@ -342,4 +342,25 @@ describe('Posts Content API', function () {
.expectStatus(200)
.matchBodySnapshot();
});
it('Adds ?ref tags', async function () {
const post = await models.Post.add({
title: 'title',
status: 'published',
slug: 'add-ref-tags',
mobiledoc: JSON.stringify({version: '0.3.1',atoms: [],cards: [['html',{html: '<a href="https://example.com">Link</a><a href="invalid">Test</a>'}]],markups: [],sections: [[10,0],[1,'p',[]]],ghostVersion: '4.0'})
}, {context: {internal: true}});
let response = await agent
.get(`posts/${post.id}/`)
.expectStatus(200);
assert(response.body.posts[0].html.includes('<a href="https://example.com/?ref=ghost">Link</a><a href="invalid">Test</a>'), 'Html not expected (should contain ?ref): ' + response.body.posts[0].html);
// Disable outbound link tracking
mockManager.mockSetting('outbound_link_tagging', false);
response = await agent
.get(`posts/${post.id}/`)
.expectStatus(200);
assert(response.body.posts[0].html.includes('<a href="https://example.com">Link</a><a href="invalid">Test</a>'), 'Html not expected: ' + response.body.posts[0].html);
});
});

View File

@ -57,6 +57,7 @@ class EmailRenderer {
#linkReplacer;
#linkTracking;
#memberAttributionService;
#outboundLinkTagger;
#audienceFeedbackService;
/**
@ -74,6 +75,7 @@ class EmailRenderer {
* @param {object} dependencies.linkTracking
* @param {object} dependencies.memberAttributionService
* @param {object} dependencies.audienceFeedbackService
* @param {object} dependencies.outboundLinkTagger
*/
constructor({
settingsCache,
@ -86,7 +88,8 @@ class EmailRenderer {
linkReplacer,
linkTracking,
memberAttributionService,
audienceFeedbackService
audienceFeedbackService,
outboundLinkTagger
}) {
this.#settingsCache = settingsCache;
this.#settingsHelpers = settingsHelpers;
@ -99,6 +102,7 @@ class EmailRenderer {
this.#linkTracking = linkTracking;
this.#memberAttributionService = memberAttributionService;
this.#audienceFeedbackService = audienceFeedbackService;
this.#outboundLinkTagger = outboundLinkTagger;
}
getSubject(post) {
@ -243,13 +247,13 @@ class EmailRenderer {
if (isSite) {
// Add newsletter name as ref to the URL
url = this.#memberAttributionService.addOutboundLinkTagging(url, newsletter);
url = this.#outboundLinkTagger.addToUrl(url, newsletter);
// Only add post attribution to our own site (because external sites could/should not process this information)
url = this.#memberAttributionService.addPostAttributionTracking(url, post);
} else {
// Add email source attribution without the newsletter name
url = this.#memberAttributionService.addOutboundLinkTagging(url);
url = this.#outboundLinkTagger.addToUrl(url);
}
// Add link click tracking

View File

@ -391,10 +391,6 @@ describe('Email renderer', function () {
},
linkReplacer,
memberAttributionService: {
addOutboundLinkTagging: (u, newsletter) => {
u.searchParams.append('source_tracking', newsletter?.get('name') ?? 'site');
return u;
},
addPostAttributionTracking: (u) => {
u.searchParams.append('post_tracking', 'added');
return u;
@ -406,6 +402,12 @@ describe('Email renderer', function () {
return new URL('http://tracked-link.com/?m=' + encodeURIComponent(uuid) + '&url=' + encodeURIComponent(u.href));
}
}
},
outboundLinkTagger: {
addToUrl: (u, newsletter) => {
u.searchParams.append('source_tracking', newsletter?.get('name') ?? 'site');
return u;
}
}
});
let basePost;

View File

@ -7,26 +7,31 @@ class LinkReplacer {
*/
async replace(html, replaceLink) {
const cheerio = require('cheerio');
const $ = cheerio.load(html);
try {
const $ = cheerio.load(html);
for (const el of $('a').toArray()) {
const href = $(el).attr('href');
if (href) {
let url;
try {
url = new URL(href);
} catch (e) {
// Ignore invalid URLs
}
if (url) {
url = await replaceLink(url);
const str = url.toString();
$(el).attr('href', str);
for (const el of $('a').toArray()) {
const href = $(el).attr('href');
if (href) {
let url;
try {
url = new URL(href);
} catch (e) {
// Ignore invalid URLs
}
if (url) {
url = await replaceLink(url);
const str = url.toString();
$(el).attr('href', str);
}
}
}
}
return $.html();
return $.html();
} catch (e) {
// Catch errors from cheerio
return html;
}
}
}

View File

@ -2,5 +2,6 @@ module.exports = {
MemberAttributionService: require('./lib/service'),
AttributionBuilder: require('./lib/attribution'),
UrlTranslator: require('./lib/url-translator'),
ReferrerTranslator: require('./lib/referrer-translator')
ReferrerTranslator: require('./lib/referrer-translator'),
OutboundLinkTagger: require('./lib/outbound-link-tagger')
};

View File

@ -0,0 +1,87 @@
const {slugify} = require('@tryghost/string');
const LinkReplacer = require('@tryghost/link-replacer');
const blockedReferrerDomains = [
// Facebook has some restrictions on the 'ref' attribute (max 15 chars + restricted character set) that breaks links if we add ?ref=longer-string
'facebook.com',
'www.facebook.com'
];
/**
* Adds ?ref to outbound links
*/
class OutboundLinkTagger {
/**
*
* @param {Object} deps
* @param {() => boolean} deps.isEnabled
* @param {() => string} deps.getSiteTitle
* @param {{isSiteUrl(url, context): boolean}} deps.urlUtils
*/
constructor({isEnabled, getSiteTitle, urlUtils}) {
this._isEnabled = isEnabled;
this._getSiteTitle = getSiteTitle;
this._urlUtils = urlUtils;
}
get isEnabled() {
return this._isEnabled();
}
get siteTitle() {
return this._getSiteTitle();
}
/**
* Add some parameters to a URL that points to a site, so that site can detect that the traffic is coming from a Ghost site or newsletter.
* Note that this is disabled if outboundLinkTagging setting is disabled.
* @param {URL} url instance that will get updated
* @param {Object} [useNewsletter] Use the newsletter name instead of the site name as referrer source
* @returns {URL}
*/
addToUrl(url, useNewsletter) {
// Create a deep copy
url = new URL(url);
if (!this.isEnabled) {
return url;
}
if (url.searchParams.has('ref') || url.searchParams.has('utm_source') || url.searchParams.has('source')) {
// Don't overwrite + keep existing source attribution
return url;
}
// Check blocked domains
const referrerDomain = url.hostname;
if (blockedReferrerDomains.includes(referrerDomain)) {
return url;
}
if (useNewsletter) {
const name = slugify(useNewsletter.get('name'));
// If newsletter name ends with newsletter, don't add it again
const ref = name.endsWith('newsletter') ? name : `${name}-newsletter`;
url.searchParams.append('ref', ref);
} else {
url.searchParams.append('ref', slugify(this.siteTitle));
}
return url;
}
async addToHtml(html) {
if (!this.isEnabled) {
return html;
}
return await LinkReplacer.replace(html, (url) => {
const isSite = this._urlUtils.isSiteUrl(url);
if (isSite) {
return url;
}
return this.addToUrl(url);
});
}
}
module.exports = OutboundLinkTagger;

View File

@ -1,11 +1,4 @@
const UrlHistory = require('./history');
const {slugify} = require('@tryghost/string');
const blacklistedReferrerDomains = [
// Facebook has some restrictions on the 'ref' attribute (max 15 chars + restricted character set) that breaks links if we add ?ref=longer-string
'facebook.com',
'www.facebook.com'
];
class MemberAttributionService {
/**
@ -16,29 +9,17 @@ class MemberAttributionService {
* @param {Object} deps.models.MemberCreatedEvent
* @param {Object} deps.models.SubscriptionCreatedEvent
* @param {() => boolean} deps.getTrackingEnabled
* @param {() => boolean} deps.getOutboundLinkTaggingEnabled
* @param {() => string} deps.getSiteTitle
*/
constructor({attributionBuilder, models, getTrackingEnabled, getOutboundLinkTaggingEnabled, getSiteTitle}) {
constructor({attributionBuilder, models, getTrackingEnabled}) {
this.models = models;
this.attributionBuilder = attributionBuilder;
this._getTrackingEnabled = getTrackingEnabled;
this._getOutboundLinkTaggingEnabled = getOutboundLinkTaggingEnabled;
this._getSiteTitle = getSiteTitle;
}
get isTrackingEnabled() {
return this._getTrackingEnabled();
}
get isOutboundLinkTaggingEnabled() {
return this._getOutboundLinkTaggingEnabled();
}
get siteTitle() {
return this._getSiteTitle();
}
/**
*
* @param {Object} context instance of ghost framework context object
@ -100,44 +81,6 @@ class MemberAttributionService {
return await this.attributionBuilder.getAttribution(history);
}
/**
* Add some parameters to a URL that points to a site, so that site can detect that the traffic is coming from a Ghost site or newsletter.
* Note that this is disabled if outboundLinkTagging setting is disabled.
* @param {URL} url instance that will get updated
* @param {Object} [useNewsletter] Use the newsletter name instead of the site name as referrer source
* @returns {URL}
*/
addOutboundLinkTagging(url, useNewsletter) {
// Create a deep copy
url = new URL(url);
if (!this.isOutboundLinkTaggingEnabled) {
return url;
}
if (url.searchParams.has('ref') || url.searchParams.has('utm_source') || url.searchParams.has('source')) {
// Don't overwrite + keep existing source attribution
return url;
}
// Check blacklist domains
const referrerDomain = url.hostname;
if (blacklistedReferrerDomains.includes(referrerDomain)) {
return url;
}
if (useNewsletter) {
const name = slugify(useNewsletter.get('name'));
// If newsletter name ends with newsletter, don't add it again
const ref = name.endsWith('newsletter') ? name : `${name}-newsletter`;
url.searchParams.append('ref', ref);
} else {
url.searchParams.append('ref', slugify(this.siteTitle));
}
return url;
}
/**
* Add some parameters to a URL so that the frontend script can detect this and add the required records
* in the URLHistory.

View File

@ -0,0 +1,205 @@
// Switch these lines once there are useful utils
// const testUtils = require('./utils');
require('./utils');
const {OutboundLinkTagger} = require('../');
const assert = require('assert');
describe('OutboundLinkTagger', function () {
describe('Constructor', function () {
it('doesn\'t throw', function () {
new OutboundLinkTagger({});
});
});
describe('addToUrl', function () {
it('uses sluggified sitename for external urls', async function () {
const service = new OutboundLinkTagger({
getSiteTitle: () => 'Hello world',
isEnabled: () => true
});
const url = new URL('https://example.com/');
const updatedUrl = await service.addToUrl(url);
should(updatedUrl.toString()).equal('https://example.com/?ref=hello-world');
});
it('does not add if disabled', async function () {
const service = new OutboundLinkTagger({
getSiteTitle: () => 'Hello world',
isEnabled: () => false
});
const url = new URL('https://example.com/');
const updatedUrl = await service.addToUrl(url);
should(updatedUrl.toString()).equal('https://example.com/');
});
it('uses sluggified newsletter name for internal urls', async function () {
const service = new OutboundLinkTagger({
getSiteTitle: () => 'Hello world',
isEnabled: () => true
});
const url = new URL('https://example.com/');
const newsletterName = 'used newsletter name';
const newsletter = {
get: (t) => {
if (t === 'name') {
return newsletterName;
}
}
};
const updatedUrl = await service.addToUrl(url, newsletter);
should(updatedUrl.toString()).equal('https://example.com/?ref=used-newsletter-name-newsletter');
});
it('does not repeat newsletter at the end of the newsletter name', async function () {
const service = new OutboundLinkTagger({
getSiteTitle: () => 'Hello world',
isEnabled: () => true
});
const url = new URL('https://example.com/');
const newsletterName = 'Weekly newsletter';
const newsletter = {
get: (t) => {
if (t === 'name') {
return newsletterName;
}
}
};
const updatedUrl = await service.addToUrl(url, newsletter);
should(updatedUrl.toString()).equal('https://example.com/?ref=weekly-newsletter');
});
it('does not add ref to blocked domains', async function () {
const service = new OutboundLinkTagger({
getSiteTitle: () => 'Hello world',
isEnabled: () => true
});
const url = new URL('https://facebook.com/');
const updatedUrl = await service.addToUrl(url);
should(updatedUrl.toString()).equal('https://facebook.com/');
});
it('does not add ref if utm_source is present', async function () {
const service = new OutboundLinkTagger({
getSiteTitle: () => 'Hello world',
isEnabled: () => true
});
const url = new URL('https://example.com/?utm_source=hello');
const updatedUrl = await service.addToUrl(url);
should(updatedUrl.toString()).equal('https://example.com/?utm_source=hello');
});
it('does not add ref if ref is present', async function () {
const service = new OutboundLinkTagger({
getSiteTitle: () => 'Hello world',
isEnabled: () => true
});
const url = new URL('https://example.com/?ref=hello');
const updatedUrl = await service.addToUrl(url);
should(updatedUrl.toString()).equal('https://example.com/?ref=hello');
});
it('does not add ref if source is present', async function () {
const service = new OutboundLinkTagger({
getSiteTitle: () => 'Hello world',
isEnabled: () => true
});
const url = new URL('https://example.com/?source=hello');
const updatedUrl = await service.addToUrl(url);
should(updatedUrl.toString()).equal('https://example.com/?source=hello');
});
});
describe('addToHtml', function () {
it('adds refs to external links', async function () {
const service = new OutboundLinkTagger({
getSiteTitle: () => 'Hello world',
isEnabled: () => true,
urlUtils: {
isSiteUrl: () => false
}
});
const html = await service.addToHtml('<a href="https://example.com/test-site">Hello world</a><a href="https://other.com/test/">Hello world</a>');
assert.equal(html, '<a href="https://example.com/test-site?ref=hello-world">Hello world</a><a href="https://other.com/test/?ref=hello-world">Hello world</a>');
});
it('does not add refs to internal links', async function () {
const service = new OutboundLinkTagger({
getSiteTitle: () => 'Hello world',
isEnabled: () => true,
urlUtils: {
isSiteUrl: () => true
}
});
const html = await service.addToHtml('<a href="https://example.com/test-site">Hello world</a><a href="https://other.com/test/">Hello world</a>');
assert.equal(html, '<a href="https://example.com/test-site">Hello world</a><a href="https://other.com/test/">Hello world</a>');
});
it('does not add refs if disabled', async function () {
const service = new OutboundLinkTagger({
getSiteTitle: () => 'Hello world',
isEnabled: () => false,
urlUtils: {
isSiteUrl: () => false
}
});
const html = await service.addToHtml('<a href="https://example.com/test-site">Hello world</a><a href="https://other.com/test/">Hello world</a>');
assert.equal(html, '<a href="https://example.com/test-site">Hello world</a><a href="https://other.com/test/">Hello world</a>');
});
it('does not add refs to anchors', async function () {
const service = new OutboundLinkTagger({
getSiteTitle: () => 'Hello world',
isEnabled: () => true,
urlUtils: {
isSiteUrl: () => false
}
});
const html = await service.addToHtml('<a href="#test">Hello world</a><a href="#">Hello world</a>');
assert.equal(html, '<a href="#test">Hello world</a><a href="#">Hello world</a>');
});
it('does not add refs to relative links', async function () {
const service = new OutboundLinkTagger({
getSiteTitle: () => 'Hello world',
isEnabled: () => true,
urlUtils: {
isSiteUrl: () => false
}
});
const html = await service.addToHtml('<a href="test">Hello world</a><a href="">Hello world</a>');
assert.equal(html, '<a href="test">Hello world</a><a href>Hello world</a>');
});
it('keeps HTML if throws', async function () {
const service = new OutboundLinkTagger({
getSiteTitle: () => 'Hello world',
isEnabled: () => true,
urlUtils: {
isSiteUrl: () => {
throw new Error('Oops!');
}
}
});
const html = await service.addToHtml('<a href="https://example.com/test-site">Hello world</a><a href="https://other.com/test/">Hello world</a>');
assert.equal(html, '<a href="https://example.com/test-site">Hello world</a><a href="https://other.com/test/">Hello world</a>');
});
it('keeps HTML comments', async function () {
const service = new OutboundLinkTagger({
getSiteTitle: () => 'Hello world',
isEnabled: () => true,
urlUtils: {
isSiteUrl: () => false
}
});
const html = await service.addToHtml('<!-- comment -->');
assert.equal(html, '<!-- comment -->');
});
});
});

View File

@ -10,110 +10,6 @@ describe('MemberAttributionService', function () {
});
});
describe('addOutboundLinkTagging', function () {
it('uses sluggified sitename for external urls', async function () {
const service = new MemberAttributionService({
getSiteTitle: () => 'Hello world',
getOutboundLinkTaggingEnabled: () => true
});
const url = new URL('https://example.com/');
const updatedUrl = await service.addOutboundLinkTagging(url);
should(updatedUrl.toString()).equal('https://example.com/?ref=hello-world');
});
it('does not add if disabled', async function () {
const service = new MemberAttributionService({
getSiteTitle: () => 'Hello world',
getOutboundLinkTaggingEnabled: () => false
});
const url = new URL('https://example.com/');
const updatedUrl = await service.addOutboundLinkTagging(url);
should(updatedUrl.toString()).equal('https://example.com/');
});
it('uses sluggified newsletter name for internal urls', async function () {
const service = new MemberAttributionService({
getSiteTitle: () => 'Hello world',
getOutboundLinkTaggingEnabled: () => true
});
const url = new URL('https://example.com/');
const newsletterName = 'used newsletter name';
const newsletter = {
get: (t) => {
if (t === 'name') {
return newsletterName;
}
}
};
const updatedUrl = await service.addOutboundLinkTagging(url, newsletter);
should(updatedUrl.toString()).equal('https://example.com/?ref=used-newsletter-name-newsletter');
});
it('does not repeat newsletter at the end of the newsletter name', async function () {
const service = new MemberAttributionService({
getSiteTitle: () => 'Hello world',
getOutboundLinkTaggingEnabled: () => true
});
const url = new URL('https://example.com/');
const newsletterName = 'Weekly newsletter';
const newsletter = {
get: (t) => {
if (t === 'name') {
return newsletterName;
}
}
};
const updatedUrl = await service.addOutboundLinkTagging(url, newsletter);
should(updatedUrl.toString()).equal('https://example.com/?ref=weekly-newsletter');
});
it('does not add ref to blacklisted domains', async function () {
const service = new MemberAttributionService({
getSiteTitle: () => 'Hello world',
getOutboundLinkTaggingEnabled: () => true
});
const url = new URL('https://facebook.com/');
const updatedUrl = await service.addOutboundLinkTagging(url);
should(updatedUrl.toString()).equal('https://facebook.com/');
});
it('does not add ref if utm_source is present', async function () {
const service = new MemberAttributionService({
getSiteTitle: () => 'Hello world',
getOutboundLinkTaggingEnabled: () => true
});
const url = new URL('https://example.com/?utm_source=hello');
const updatedUrl = await service.addOutboundLinkTagging(url);
should(updatedUrl.toString()).equal('https://example.com/?utm_source=hello');
});
it('does not add ref if ref is present', async function () {
const service = new MemberAttributionService({
getSiteTitle: () => 'Hello world',
getOutboundLinkTaggingEnabled: () => true
});
const url = new URL('https://example.com/?ref=hello');
const updatedUrl = await service.addOutboundLinkTagging(url);
should(updatedUrl.toString()).equal('https://example.com/?ref=hello');
});
it('does not add ref if source is present', async function () {
const service = new MemberAttributionService({
getSiteTitle: () => 'Hello world',
getOutboundLinkTaggingEnabled: () => true
});
const url = new URL('https://example.com/?source=hello');
const updatedUrl = await service.addOutboundLinkTagging(url);
should(updatedUrl.toString()).equal('https://example.com/?source=hello');
});
});
describe('getAttributionFromContext', function () {
it('returns null if no context is provided', async function () {
const service = new MemberAttributionService({