Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
216 changes: 193 additions & 23 deletions assets/js/wpr-beacon.js
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,187 @@
return url;
}
}
/**
* Fetches external stylesheet links from known font providers, retrieves their CSS,
* parses them into in-memory CSSStyleSheet objects, and extracts font-family/font-face
* information into a structured object.
*
* @async
* @function externalStylesheetsDoc
* @returns {Promise<{styleSheets: CSSStyleSheet[], fontPairs: Object}>} An object containing:
* - styleSheets: Array of parsed CSSStyleSheet objects (not attached to the DOM).
* - fontPairs: An object mapping font URLs to arrays of font variation objects
* ({family, weight, style}).
*
* @example
* const { styleSheets, fontPairs } = await externalStylesheetsDoc();
* this.logger.logMessage(fontPairs);
*/
async externalStylesheetsDoc() {
function generateFontPairsFromStyleSheets(styleSheetsArray) {
const fontPairs = {};
function _extractFirstUrlFromSrc(srcValue) {
if (!srcValue) return null;
const urlMatch = srcValue.match(/url\s*\(\s*(['"]?)(.+?)\1\s*\)/);
return urlMatch ? urlMatch[2] : null;
}
function _cleanFontFamilyName(fontFamilyValue) {
if (!fontFamilyValue) return "";
return fontFamilyValue.replace(/^['"]+|['"]+$/g, "").trim();
}
if (!styleSheetsArray || !Array.isArray(styleSheetsArray)) {
console.warn(
"generateFontPairsFromStyleSheets: Input is not a valid array. Received:",
styleSheetsArray
);
return fontPairs;
}
if (styleSheetsArray.length === 0) {
return fontPairs;
}
styleSheetsArray.forEach((sheet) => {
if (sheet && sheet.cssRules) {
try {
for (const rule of sheet.cssRules) {
if (rule.type === CSSRule.FONT_FACE_RULE) {
const cssFontFaceRule = rule;
const fontFamily = _cleanFontFamilyName(
cssFontFaceRule.style.getPropertyValue("font-family")
);
const fontWeight = cssFontFaceRule.style.getPropertyValue("font-weight") || "normal";
const fontStyle = cssFontFaceRule.style.getPropertyValue("font-style") || "normal";
const src = cssFontFaceRule.style.getPropertyValue("src");
const fontUrl = _extractFirstUrlFromSrc(src);
if (fontFamily && fontUrl) {
const variation = {
family: fontFamily,
weight: fontWeight,
style: fontStyle
};
if (!fontPairs[fontUrl]) fontPairs[fontUrl] = [];
const variationExists = fontPairs[fontUrl].some(
(v) => v.family === variation.family && v.weight === variation.weight && v.style === variation.style
);
if (!variationExists) fontPairs[fontUrl].push(variation);
}
}
}
} catch (e) {
console.warn(
"Error processing CSS rules from a stylesheet:",
e,
sheet
);
}
} else if (sheet && !sheet.cssRules) {
console.warn(
"Skipping a stylesheet as its cssRules are not accessible or it is empty:",
sheet
);
}
});
return fontPairs;
}
const externalFontsProviders = [
"fonts.googleapis.com",
"fonts.gstatic.com",
"use.typekit.net",
"fonts.adobe.com",
"cdn.fonts.net"
// Add more known external font domains as needed
];
const links = [
...document.querySelectorAll('link[rel="stylesheet"]')
].filter(
(link) => externalFontsProviders.some((domain) => link.href.includes(domain))
);
if (links.length === 0) {
this.logger.logMessage("No external CSS links found to process.");
return {
// Consistent return structure
styleSheets: [],
// The retrievable CSSStyleSheet objects
fontPairs: {}
// Processed data from these sheets
};
}
const fetchedCssPromises = links.map(
(linkElement) => fetch(linkElement.href, { mode: "cors" }).then((response) => {
if (response.ok) {
return response.text();
}
console.warn(
`Failed to fetch external CSS from ${linkElement.href}: ${response.status} ${response.statusText}`
);
return null;
}).catch((error) => {
console.error(
`Network error fetching external CSS from ${linkElement.href}:`,
error
);
return null;
})
);
const cssTexts = await Promise.all(fetchedCssPromises);
const temporaryStyleSheets = [];
cssTexts.forEach((txt) => {
if (txt && txt.trim() !== "") {
try {
const sheet = new CSSStyleSheet();
sheet.replaceSync(txt);
temporaryStyleSheets.push(sheet);
} catch (error) {
console.error(
"Could not parse fetched CSS into a stylesheet:",
error,
`
CSS (first 200 chars): ${txt.substring(0, 200)}...`
);
}
}
});
if (temporaryStyleSheets.length > 0) {
this.logger.logMessage(
`[Beacon] ${temporaryStyleSheets.length} stylesheet(s) fetched and parsed into CSSStyleSheet objects.`
);
} else {
this.logger.logMessage(
"[Beacon] No stylesheets were successfully parsed from the fetched CSS."
);
}
const processedFontPairs = generateFontPairsFromStyleSheets(temporaryStyleSheets);
return {
styleSheets: temporaryStyleSheets,
fontPairs: processedFontPairs
};
}
/**
* Asynchronously initializes and parses external font stylesheets.
*
* Fetches external font stylesheets and font pairs using `externalStylesheetsDoc`,
* then stores the parsed results in `externalParsedSheets` and `externalParsedPairs`.
* Logs the process and handles errors by resetting `externalParsedSheets` to an empty array.
*
* @async
* @returns {Promise<void>} Resolves when external font stylesheets have been initialized.
*/
async _initializeExternalFontSheets() {
this.logger.logMessage("Initializing external font stylesheets...");
try {
const result = await this.externalStylesheetsDoc();
this.externalParsedSheets = result.styleSheets || [];
this.externalParsedPairs = result.fontPairs || [];
this.logger.logMessage(
`Successfully parsed ${this.externalParsedSheets.length} external font stylesheets.`
);
} catch (error) {
this.logger.logMessage(
"Error initializing external font stylesheets:",
error
);
this.externalParsedSheets = [];
}
}
/**
* Retrieves a map of network-loaded fonts.
*
Expand All @@ -477,7 +658,7 @@
*/
getFontFaceRules() {
const stylesheetFonts = {};
Array.from(document.styleSheets).forEach((sheet) => {
Array.from(Array.from(document.styleSheets)).filter((sheet) => !sheet.href || new URL(sheet.href).origin === location.origin).forEach((sheet) => {
try {
Array.from(sheet.cssRules || []).forEach((rule) => {
if (rule instanceof CSSFontFaceRule) {
Expand Down Expand Up @@ -541,6 +722,7 @@
*/
async run() {
await document.fonts.ready;
await this._initializeExternalFontSheets();
const networkLoadedFonts = this.getNetworkLoadedFonts();
const stylesheetFonts = this.getFontFaceRules();
const hostedFonts = /* @__PURE__ */ new Map();
Expand Down Expand Up @@ -856,12 +1038,8 @@
processElement(el) {
try {
const url = new URL(el.src || el.href || "", location.href);
if (this.isExcludedByAttribute(el)) {
this.excludedItems.add(this.createExclusionObject(url, el, "attribute"));
return;
}
if (this.isExcludedByDomain(url)) {
this.excludedItems.add(this.createExclusionObject(url, el, "domain"));
if (this.isExcluded(el)) {
this.excludedItems.add(this.createExclusionObject(url, el));
return;
}
if (this.isExternalDomain(url)) {
Expand All @@ -873,17 +1051,18 @@
}
}
/**
* Checks if an element is excluded based on attribute rules.
* Checks if an element is excluded based on exclusions patterns.
*
* This method iterates through the excludedPatterns array and checks if any pattern matches the element's attribute.
* This method iterates through the excludedPatterns array and checks if any pattern matches any of the element's attribute or values.
* If a match is found, it returns true, indicating the element is excluded.
*
* @param {Element} el - The element to check.
* @returns {boolean} True if the element is excluded by an attribute rule, false otherwise.
*/
isExcludedByAttribute(el) {
isExcluded(el) {
const outerHTML = el.outerHTML.substring(0, el.outerHTML.indexOf(">") + 1);
return this.excludedPatterns.some(
(pattern) => pattern.type === "attribute" && el.getAttribute(pattern.key) === pattern.value
(pattern) => outerHTML.includes(pattern)
);
}
/**
Expand Down Expand Up @@ -913,23 +1092,14 @@
return url.hostname !== location.hostname && url.hostname;
}
/**
* Creates an exclusion object based on the URL, element, and type.
*
* This method finds the pattern in the excludedPatterns array that matches the type and the element's attribute or the URL's hostname.
* It then constructs a reason string based on the type and the pattern.
* Finally, it returns an object with the URL's hostname, the element's tag name, and the reason.
* Creates an exclusion object based on the URL, element.
*
* @param {URL} url - The URL to create the exclusion object for.
* @param {Element} el - The element to create the exclusion object for.
* @param {string} type - The type of the exclusion (attribute or domain).
* @returns {Object} An object with the URL's hostname, the element's tag name, and the reason.
*/
createExclusionObject(url, el, type) {
const pattern = this.excludedPatterns.find(
(p) => type === "attribute" && el.getAttribute(p.key) === p.value || type === "domain" && url.hostname.includes(p.value)
);
let reason = type === "attribute" ? `${pattern.key}=${pattern.value}` : `domain-partial=${pattern.value}`;
return { domain: url.hostname, elementType: el.tagName.toLowerCase(), reason };
createExclusionObject(url, el) {
return { domain: url.hostname, elementType: el.tagName.toLowerCase() };
}
/**
* Returns an array of matched items, each item split into its domain and element type.
Expand Down
Loading
Loading