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) { // 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, ...(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 = (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(mutation.target); }); }); } // Set internal state this.state.config = 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 = 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( (node).href ?? '', window.location.origin, ); // Only modify hosts provided. if (!this.state.hosts.includes(url.host)) return; this.modifyURL(url, 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;