diff --git a/packages/beasties/README.md b/packages/beasties/README.md index f7fa9d1..710bab3 100644 --- a/packages/beasties/README.md +++ b/packages/beasties/README.md @@ -120,6 +120,7 @@ All optional. Pass them to `new Beasties({ ... })`. - `path` **[String](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** Base path location of the CSS files _(default: `''`)_ - `publicPath` **[String](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** Public path of the CSS resources. This prefix is removed from the href _(default: `''`)_ - `external` **[Boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** Inline styles from external stylesheets _(default: `true`)_ +- `remote` **[Boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** Download and inline remote stylesheets (http://, https://, //) _(default: `false`)_ - `inlineThreshold` **[Number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number)** Inline external stylesheets smaller than a given size _(default: `0`)_ - `minimumExternalSize` **[Number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number)** If the non-critical external stylesheet would be below this size, just inline it _(default: `0`)_ - `pruneSource` **[Boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** Remove inlined rules from the external stylesheet _(default: `false`)_ diff --git a/packages/beasties/src/index.d.ts b/packages/beasties/src/index.d.ts index 10076f6..bda8754 100644 --- a/packages/beasties/src/index.d.ts +++ b/packages/beasties/src/index.d.ts @@ -50,6 +50,7 @@ export interface Options { path?: string publicPath?: string external?: boolean + remote?: boolean inlineThreshold?: number minimumExternalSize?: number pruneSource?: boolean diff --git a/packages/beasties/src/index.ts b/packages/beasties/src/index.ts index ab61eb0..ca63d8d 100644 --- a/packages/beasties/src/index.ts +++ b/packages/beasties/src/index.ts @@ -205,8 +205,28 @@ export default class Beasties { .replace(/^\//, '') } - // Ignore remote stylesheets - if (/^https?:\/\//.test(normalizedPath) || href.startsWith('//')) { + // Handle remote stylesheets + const isRemote = /^https?:\/\//.test(href) || href.startsWith('//') + if (isRemote) { + if (this.options.remote === true) { + try { + // Normalize protocol-relative URLs + const absoluteUrl = href.startsWith('//') ? `https:${href}` : href + + const response = await fetch(absoluteUrl) + + if (!response.ok) { + this.logger.warn?.(`Failed to fetch ${absoluteUrl} (${response.status})`) + return undefined + } + + return await response.text() + } + catch (error) { + this.logger.warn?.(`Error fetching ${href}: ${(error as Error).message}`) + return undefined + } + } return undefined } @@ -275,7 +295,9 @@ export default class Beasties { const href = link.getAttribute('href') // skip filtered resources, or network resources if no filter is provided - if (!href?.endsWith('.css')) { + // Strip query params and hashes before checking extension + const pathname = href?.split('?')[0]?.split('#')[0] + if (!pathname?.endsWith('.css')) { return undefined } diff --git a/packages/beasties/src/types.ts b/packages/beasties/src/types.ts index a52bdc0..c3301e1 100644 --- a/packages/beasties/src/types.ts +++ b/packages/beasties/src/types.ts @@ -40,6 +40,10 @@ export interface Options { * Inline styles from external stylesheets _(default: `true`)_ */ external?: boolean + /** + * Download and inline remote stylesheets _(default: `false`)_ + */ + remote?: boolean /** * Inline external stylesheets smaller than a given size _(default: `0`)_ */ diff --git a/packages/beasties/test/beasties.test.ts b/packages/beasties/test/beasties.test.ts index 7ff0010..183a472 100644 --- a/packages/beasties/test/beasties.test.ts +++ b/packages/beasties/test/beasties.test.ts @@ -497,4 +497,158 @@ describe('beasties', () => { expect(loggerWarnSpy).not.toHaveBeenCalled() expect(result).toMatchSnapshot() }) + + it('ignores remote stylesheets by default', async () => { + const beasties = new Beasties({ + reduceInlineStyles: false, + }) + + const result = await beasties.process(trim` + + + + + +

Hello World!

+ + + `) + + // Should not contain inlined critical CSS since remote is disabled + expect(result).not.toContain('') + expect(result).toContain('https://example.com/style.css') + } + finally { + globalThis.fetch = originalFetch + } + }) + + it('handles protocol-relative URLs when remote: true', async () => { + const originalFetch = globalThis.fetch + try { + const beasties = new Beasties({ + reduceInlineStyles: false, + remote: true, + }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + text: async () => 'h1 { color: green; }', + }) + globalThis.fetch = mockFetch as any + + const result = await beasties.process(trim` + + + + + +

Hello World!

+ + + `) + + expect(mockFetch).toHaveBeenCalledWith('https://example.com/style.css') + expect(result).toContain('') + } + finally { + globalThis.fetch = originalFetch + } + }) + + it('handles fetch 404 responses gracefully', async () => { + const originalFetch = globalThis.fetch + try { + const beasties = new Beasties({ + reduceInlineStyles: false, + remote: true, + }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + }) + globalThis.fetch = mockFetch as any + + const result = await beasties.process(trim` + + + + + +

Hello World!

+ + + `) + + expect(mockFetch).toHaveBeenCalledWith('https://example.com/missing.css') + // Should still produce valid HTML, just without inlined styles + expect(result).toContain('

Hello World!

') + } + finally { + globalThis.fetch = originalFetch + } + }) + + it('handles fetch network errors gracefully', async () => { + const originalFetch = globalThis.fetch + try { + const beasties = new Beasties({ + reduceInlineStyles: false, + remote: true, + }) + + const mockFetch = vi.fn().mockRejectedValue(new Error('Network error')) + globalThis.fetch = mockFetch as any + + const result = await beasties.process(trim` + + + + + +

Hello World!

+ + + `) + + expect(mockFetch).toHaveBeenCalledWith('https://example.com/style.css') + // Should still produce valid HTML, just without inlined styles + expect(result).toContain('

Hello World!

') + } + finally { + globalThis.fetch = originalFetch + } + }) })