diff --git a/src/BeaconPreloadFonts.js b/src/BeaconPreloadFonts.js index c22e503..67cf22e 100644 --- a/src/BeaconPreloadFonts.js +++ b/src/BeaconPreloadFonts.js @@ -96,6 +96,230 @@ class BeaconPreloadFonts { } } + /** + * 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 = {}; + + /** + * Extracts the first URL from a CSS `src` value string. + * + * @param {string} srcValue - The CSS `src` value containing one or more `url(...)` references. + * @returns {string|null} The first extracted URL if found, otherwise `null`. + */ + function _extractFirstUrlFromSrc(srcValue) { + if (!srcValue) return null; + const urlMatch = srcValue.match(/url\s*\(\s*(['"]?)(.+?)\1\s*\)/); + return urlMatch ? urlMatch[2] : null; + } + + /** + * Removes leading and trailing single or double quotes from a font family name and trims whitespace. + * + * @param {string} fontFamilyValue - The font family name to clean. + * @returns {string} The cleaned font family name without surrounding quotes and trimmed whitespace. + */ + 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; + } + + // --- Main logic for fetching Google Fonts --- + 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 = []; // These will hold the CSSStyleSheet objects + + cssTexts.forEach((txt) => { + if (txt && txt.trim() !== '') { + try { + const sheet = new CSSStyleSheet(); // Create a new CSSStyleSheet object + sheet.replaceSync(txt); // Parse the CSS text into it + temporaryStyleSheets.push(sheet); // Add to our array + // These sheets exist in memory only and are not applied to the document. + } catch (error) { + console.error( + 'Could not parse fetched CSS into a stylesheet:', + error, + `\nCSS (first 200 chars): ${txt.substring(0, 200)}...` + ); + } + } + }); + + // At this point, `temporaryStyleSheets` contains CSSStyleSheet objects. + 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.' + ); + } + + // You can now process these in-memory sheets (e.g., to get font pairs) + // This demonstrates their "processability". + const processedFontPairs = + generateFontPairsFromStyleSheets(temporaryStyleSheets); + + // Return the array of CSSStyleSheet objects and any processed data. + // This makes them "retrievable". + 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} Resolves when external font stylesheets have been initialized. + */ + async _initializeExternalFontSheets() { + this.logger.logMessage('Initializing external font stylesheets...'); + try { + // Assuming externalStylesheetsDoc is available in this scope + 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 = []; // Ensure it's an array even on error + } + } + /** * Retrieves a map of network-loaded fonts. * @@ -127,46 +351,48 @@ class BeaconPreloadFonts { getFontFaceRules() { const stylesheetFonts = {}; - Array.from(document.styleSheets).forEach((sheet) => { - try { - Array.from(sheet.cssRules || []).forEach((rule) => { - if (rule instanceof CSSFontFaceRule) { - const src = rule.style.getPropertyValue('src'); - const fontFamily = rule.style.getPropertyValue('font-family') - .replace(/['"]+/g, '') - .trim(); - const weight = rule.style.getPropertyValue('font-weight') || '400'; - const style = rule.style.getPropertyValue('font-style') || 'normal'; - - if (!stylesheetFonts[fontFamily]) { - stylesheetFonts[fontFamily] = { - urls: [], - variations: new Set() - }; - } - - const urls = src.match(/url\(['"]?([^'"]+)['"]?\)/g) || []; - urls.forEach((urlMatch) => { - let rawUrl = urlMatch.match(/url\(['"]?([^'"]+)['"]?\)/)[1]; - // Reconstruct url to absolute if stylesheet is not internal. - if (sheet.href) { - rawUrl = new URL(rawUrl, sheet.href).href; + 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) { + const src = rule.style.getPropertyValue('src'); + const fontFamily = rule.style.getPropertyValue('font-family') + .replace(/['"]+/g, '') + .trim(); + const weight = rule.style.getPropertyValue('font-weight') || '400'; + const style = rule.style.getPropertyValue('font-style') || 'normal'; + + if (!stylesheetFonts[fontFamily]) { + stylesheetFonts[fontFamily] = { + urls: [], + variations: new Set() + }; } - const normalizedUrl = this.cleanUrl(rawUrl); - if (!stylesheetFonts[fontFamily].urls.includes(normalizedUrl)) { - stylesheetFonts[fontFamily].urls.push(normalizedUrl); - stylesheetFonts[fontFamily].variations.add(JSON.stringify({ - weight, - style - })); - } - }); - } - }); - } catch (e) { - this.logger.logMessage(e); - } - }); + + const urls = src.match(/url\(['"]?([^'"]+)['"]?\)/g) || []; + urls.forEach((urlMatch) => { + let rawUrl = urlMatch.match(/url\(['"]?([^'"]+)['"]?\)/)[1]; + // Reconstruct url to absolute if stylesheet is not internal. + if (sheet.href) { + rawUrl = new URL(rawUrl, sheet.href).href; + } + const normalizedUrl = this.cleanUrl(rawUrl); + if (!stylesheetFonts[fontFamily].urls.includes(normalizedUrl)) { + stylesheetFonts[fontFamily].urls.push(normalizedUrl); + stylesheetFonts[fontFamily].variations.add(JSON.stringify({ + weight, + style + })); + } + }); + } + }); + } catch (e) { + this.logger.logMessage(e); + } + }); Object.values(stylesheetFonts).forEach(fontData => { fontData.variations = Array.from(fontData.variations).map(v => JSON.parse(v)); @@ -203,11 +429,11 @@ class BeaconPreloadFonts { async run() { // Wait for fonts to be loaded await document.fonts.ready; + await this._initializeExternalFontSheets(); const networkLoadedFonts = this.getNetworkLoadedFonts(); const stylesheetFonts = this.getFontFaceRules(); const hostedFonts = new Map(); - const externalFontPairs = this.config.font_data; - const externalFontsResults = await this.processExternalFonts(externalFontPairs); + const externalFontsResults = await this.processExternalFonts(this.externalParsedPairs); const elements = Array.from(document.getElementsByTagName('*')) .filter(el => this.isElementAboveFold(el)); diff --git a/test/BeaconPreloadFonts.test.js b/test/BeaconPreloadFonts.test.js index 2a7addb..6b41c55 100644 --- a/test/BeaconPreloadFonts.test.js +++ b/test/BeaconPreloadFonts.test.js @@ -383,7 +383,7 @@ describe('BeaconPreloadFonts', () => { const mockCSSFontFaceRule3 = createMockFontFaceRule('Open Sans', 'url("fonts/opensans.woff2")', '400', 'normal'); const mockStyleSheet = { - href: 'https://example.com/styles.css', + href: null, cssRules: [ mockCSSFontFaceRule1, { type: 1 }, // Some other rule type @@ -485,4 +485,106 @@ describe('BeaconPreloadFonts', () => { assert.strictEqual(result['Duplicate'].variations.length, 2, 'Should have two variations'); }); }); + + describe('_initializeExternalFontSheets', () => { + it('should set externalParsedSheets and externalParsedPairs from externalStylesheetsDoc', async () => { + const mockSheets = [{ cssRules: [] }]; + const mockPairs = { 'https://fonts.example.com/font.woff2': [{ family: 'MockFont', weight: '400', style: 'normal' }] }; + const externalStylesheetsDocStub = sinon.stub(beaconPreloadFonts, 'externalStylesheetsDoc').resolves({ + styleSheets: mockSheets, + fontPairs: mockPairs + }); + + await beaconPreloadFonts._initializeExternalFontSheets(); + + assert.deepStrictEqual(beaconPreloadFonts.externalParsedSheets, mockSheets, 'externalParsedSheets should be set'); + assert.deepStrictEqual(beaconPreloadFonts.externalParsedPairs, mockPairs, 'externalParsedPairs should be set'); + assert.ok(loggerMock.logMessage.calledWith('Initializing external font stylesheets...')); + assert.ok(loggerMock.logMessage.calledWith('Successfully parsed 1 external font stylesheets.')); + + externalStylesheetsDocStub.restore(); + }); + + it('should handle errors and reset externalParsedSheets to empty array', async () => { + const error = new Error('Test error'); + const externalStylesheetsDocStub = sinon.stub(beaconPreloadFonts, 'externalStylesheetsDoc').rejects(error); + + await beaconPreloadFonts._initializeExternalFontSheets(); + + assert.deepStrictEqual(beaconPreloadFonts.externalParsedSheets, [], 'externalParsedSheets should be empty array on error'); + assert.ok(loggerMock.logMessage.calledWith('Error initializing external font stylesheets:', error)); + + externalStylesheetsDocStub.restore(); + }); + }); + + describe('externalStylesheetsDoc', () => { + let originalQuerySelectorAll; + let originalFetch; + let originalCSSStyleSheet; + beforeEach(() => { + // Mock CSSRule.FONT_FACE_RULE + global.CSSRule = { FONT_FACE_RULE: 5 }; + // Mock document.querySelectorAll to return fake link elements + originalQuerySelectorAll = global.document.querySelectorAll; + global.document.querySelectorAll = sinon.stub().returns([ + { href: 'https://fonts.googleapis.com/css?family=Roboto', rel: 'stylesheet' } + ]); + + // Mock fetch to return a fake CSS response + originalFetch = global.fetch; + global.fetch = sinon.stub().resolves({ + ok: true, + text: () => Promise.resolve('@font-face { font-family: "Roboto"; src: url("https://fonts.gstatic.com/s/roboto.woff2"); font-weight: 400; font-style: normal; }') + }); + + // Mock CSSStyleSheet and replaceSync + originalCSSStyleSheet = global.CSSStyleSheet; + global.CSSStyleSheet = function() { + this.rules = []; + this.replaceSync = function(cssText) { + // Simulate parsing CSS text into cssRules + this.cssRules = [{ + type: 5, // CSSRule.FONT_FACE_RULE + style: { + getPropertyValue: (prop) => { + if (prop === 'font-family') return 'Roboto'; + if (prop === 'src') return 'url("https://fonts.gstatic.com/s/roboto.woff2")'; + if (prop === 'font-weight') return '400'; + if (prop === 'font-style') return 'normal'; + return ''; + } + } + }]; + }; + }; + }); + + afterEach(() => { + global.document.querySelectorAll = originalQuerySelectorAll; + global.fetch = originalFetch; + global.CSSStyleSheet = originalCSSStyleSheet; + }); + + it('should fetch, parse, and return external font CSS as styleSheets and fontPairs', async () => { + const result = await beaconPreloadFonts.externalStylesheetsDoc(); + // Should have one stylesheet with cssRules + assert.strictEqual(result.styleSheets.length, 1); + assert.ok(Array.isArray(result.styleSheets)); + // Should have fontPairs with the correct structure + assert.ok(result.fontPairs['https://fonts.gstatic.com/s/roboto.woff2']); + assert.deepStrictEqual(result.fontPairs['https://fonts.gstatic.com/s/roboto.woff2'][0], { + family: 'Roboto', + weight: '400', + style: 'normal' + }); + }); + + it('should return empty arrays/objects if no external links are found', async () => { + global.document.querySelectorAll = sinon.stub().returns([]); + const result = await beaconPreloadFonts.externalStylesheetsDoc(); + assert.deepStrictEqual(result.styleSheets, []); + assert.deepStrictEqual(result.fontPairs, {}); + }); + }); }); \ No newline at end of file