affiliate
Version:
A platform agnostic tool to quickly add affiliate links onto your website
279 lines (227 loc) • 7.67 kB
text/typescript
import { hasMutationObserver, hasURL } from './shared/features';
import Log from './shared/log';
import { getNodeData, setNodeData } from './shared/nodeTools';
export interface AffiliateConfigTag {
hosts: string | string[];
query?: { [key: string]: string };
replace?: {
to: string;
from: string;
}[];
modify?: (url: URL) => URL | string;
}
export interface AffiliateConfig {
tags: AffiliateConfigTag[];
log?: boolean;
}
/**
* Manages stateful affiliation
*/
class Affiliate {
state: {
attached: boolean;
config: AffiliateConfig;
hosts: string[];
} = {
attached: false,
config: {
tags: [],
},
hosts: [],
};
observer: MutationObserver | undefined = undefined;
log: typeof Log;
constructor(config?: Partial<AffiliateConfig>) {
// Extend the configuration
config = config ?? {};
config.tags = config.tags ?? [];
config.tags.map((tag, i) => {
if (!config || !config.tags) return;
// Convert a single host to an array
if (typeof tag.hosts === 'string') tag.hosts = [tag.hosts];
// Extend proper tag configuration
config.tags[i] = {
query: {},
replace: [],
...tag,
};
// Append hosts to full list
this.state.hosts = [
...this.state.hosts,
...(<string[]>config.tags[i].hosts),
];
});
// Set logging function
this.log = config.log ? Log : () => undefined;
this.log(false, 'New Instance', config);
// Check is MutationObserver is supported
if (hasMutationObserver) {
// Initialize MutationObserver
this.observer = new window.MutationObserver((mutations) => {
// This function is called for every DOM mutation
// Has a mutation been logged
let emitted = false;
mutations.forEach((mutation) => {
// If the attributes of the link have been modified
if (mutation.type === 'attributes') {
// Skip links without an href
if (mutation.attributeName !== 'href') return;
const href = (<HTMLAnchorElement>mutation.target).href;
const linkData = getNodeData(mutation.target);
// Skip links without a modified href
if (linkData.is && linkData.is === href) return;
}
// Only calls on first mutation
if (!emitted) {
this.log(false, 'DOM Mutation', mutation);
emitted = true;
}
// Scan the node and subnodes if there are any
this.traverse(<HTMLElement>mutation.target);
});
});
}
// Set internal state
this.state.config = <AffiliateConfig>config;
}
/**
* Manual function to search the DOM for unaffiliated links
*/
traverse(nodeSet: HTMLElement = document.body): Affiliate {
if (
typeof nodeSet !== 'object' ||
typeof nodeSet.getElementsByTagName !== 'function'
)
return this;
if (!hasURL) {
this.log(true, 'This browser needs a URL polyfill.');
return this;
}
this.log(false, 'Traversing DOM...');
// Reduce link collection to array
const collection = nodeSet.getElementsByTagName('a');
let nodes = <HTMLElement[]>Object.values(collection);
// If the nodeSet is a single link, turn to array
if (nodeSet.nodeName.toLowerCase() === 'a') nodes = [nodeSet];
this.log(false, `Found ${nodes.length + 1} nodes...`);
// Go through each link
nodes.forEach((node) => {
// Check if it is actually linking
if (!node || !('href' in node)) return;
// Parse the URL natively
const url = new URL(
(<HTMLAnchorElement>node).href ?? '',
window.location.origin,
);
// Only modify hosts provided.
if (!this.state.hosts.includes(url.host)) return;
this.modifyURL(url, <HTMLAnchorElement>node);
});
return this;
}
/**
* Modify the URL of a matching link while preserving the original link state
*/
modifyURL = (url: URL, node: HTMLAnchorElement) => {
// Check if URL is already modified
const linkData = getNodeData(node);
if (linkData.is && linkData.is === url.href) return;
// Preserve the original URL
const originalURL = url.href;
this.log(false, 'Discovered URL: ' + url.href);
const modifiedUrl = this.convert(url);
// Update the href tag and save the url to the DOM node
node.href = modifiedUrl;
setNodeData(node, {
was: originalURL,
is: url.href,
});
};
/**
* Modify a manually provided URL
*/
convert = (url: string | URL): string => {
// Convert input URL object to string
if (typeof url === 'object') url = url.href;
// Check if URL global exists
if (!hasURL) {
this.log(true, 'This browser needs a URL polyfill.');
return url;
}
// Parse the URL natively
const modURL: URL = new URL(url, window.location.origin);
// Only modify host provided
if (!this.state.hosts.includes(modURL.host)) return modURL.href;
// Go through each tag
for (const tag of this.state.config.tags) {
// Check if the host matches
if (tag.hosts.includes(modURL.host)) {
// Change query variables
if (tag.query) {
Object.keys(tag.query ?? {}).forEach((key) => {
if (typeof tag.query === 'object')
modURL.searchParams.set(key, tag.query[key]);
});
}
// Run the modification function
if (typeof tag.modify === 'function') {
try {
let returnedURL = tag.modify(modURL);
if (typeof returnedURL === 'object') returnedURL = returnedURL.href;
modURL.href = returnedURL;
} catch (e) {
Log(true, e as Error);
}
}
// Replace certain parts of the url
tag.replace?.forEach((replacement) => {
modURL.href = modURL.href.replace(replacement.from, replacement.to);
});
return modURL.href;
}
}
return modURL.href;
};
/**
* Attach the mutation observer
*/
attach = (): Affiliate => {
// Cannot attach twice, cannot attach for node
if (this.state.attached || typeof document === 'undefined') return this;
// Get readyState, or the loading state of the DOM
const { readyState } = document;
if (readyState === 'complete' || readyState === 'interactive') {
// Set attached to true
this.state.attached = true;
// Run through the entire body tag
this.traverse();
if (hasMutationObserver && this.observer) {
// Attach the observer
this.observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
characterData: true,
attributeFilter: ['href'],
});
} else {
this.log(false, 'Browser does not support MutationObserver.');
}
} else {
// Wait until the DOM loads
window.addEventListener('DOMContentLoaded', this.attach);
}
return this;
};
/**
* Detach the mutation observer
*/
detach = (): Affiliate => {
if (!hasMutationObserver || !this.observer) return this;
this.state.attached = false;
this.observer.disconnect();
this.log(false, 'Observer disconnected.');
return this;
};
}
export default Affiliate;