From ae62a44e10ac48fdac4478825dad1e8344a560a6 Mon Sep 17 00:00:00 2001 From: Josh Fester Date: Mon, 10 Nov 2025 20:21:05 +0000 Subject: [PATCH 1/4] Add 'remote' option to download remote stylesheets --- packages/beasties/README.md | 1 + packages/beasties/src/index.d.ts | 1 + packages/beasties/src/index.ts | 28 ++++++++++++-- packages/beasties/src/types.ts | 4 ++ packages/beasties/test/beasties.test.ts | 50 +++++++++++++++++++++++++ 5 files changed, 81 insertions(+), 3 deletions(-) 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 2a28a03..0b7f5e3 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 7060f61..7aa445e 100644 --- a/packages/beasties/src/index.ts +++ b/packages/beasties/src/index.ts @@ -181,8 +181,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 } @@ -251,7 +271,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 03c7a03..0f2d000 100644 --- a/packages/beasties/test/beasties.test.ts +++ b/packages/beasties/test/beasties.test.ts @@ -383,4 +383,54 @@ 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') + }) }) From 0ed48ba28de46fbad037be7f9dd0f12d2eb6355e Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Tue, 20 Jan 2026 14:39:22 +0000 Subject: [PATCH 2/4] test: restore global fetch --- packages/beasties/test/beasties.test.ts | 52 ++++++++++++++----------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/packages/beasties/test/beasties.test.ts b/packages/beasties/test/beasties.test.ts index 518fa2b..e6a5a32 100644 --- a/packages/beasties/test/beasties.test.ts +++ b/packages/beasties/test/beasties.test.ts @@ -520,31 +520,37 @@ describe('beasties', () => { }) it('fetches remote stylesheets when remote: true', async () => { - const beasties = new Beasties({ - reduceInlineStyles: false, - remote: true, - }) + const originalFetch = globalThis.fetch + try { + const beasties = new Beasties({ + reduceInlineStyles: false, + remote: true, + }) - // Mock fetch - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - text: async () => 'h1 { color: blue; } h2.unused { color: red; }', - }) - globalThis.fetch = mockFetch as any + // Mock fetch + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + text: async () => 'h1 { color: blue; } h2.unused { color: red; }', + }) + globalThis.fetch = mockFetch as any - const result = await beasties.process(trim` - - - - - -

Hello World!

- - - `) + const result = await beasties.process(trim` + + + + + +

Hello World!

+ + + `) - expect(mockFetch).toHaveBeenCalledWith('https://example.com/style.css') - expect(result).toContain('') - expect(result).toContain('https://example.com/style.css') + expect(mockFetch).toHaveBeenCalledWith('https://example.com/style.css') + expect(result).toContain('') + expect(result).toContain('https://example.com/style.css') + } + finally { + globalThis.fetch = originalFetch + } }) }) From 7d3ebda6fa73bf853ec896ba8997e32420cae1ce Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Tue, 20 Jan 2026 14:44:32 +0000 Subject: [PATCH 3/4] test: add some more tests --- packages/beasties/test/beasties.test.ts | 98 +++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/packages/beasties/test/beasties.test.ts b/packages/beasties/test/beasties.test.ts index e6a5a32..183a472 100644 --- a/packages/beasties/test/beasties.test.ts +++ b/packages/beasties/test/beasties.test.ts @@ -553,4 +553,102 @@ describe('beasties', () => { 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 + } + }) }) From f2cb9a3f8810e39aaa5fa3769ddd8e24541b6b54 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Tue, 20 Jan 2026 14:52:07 +0000 Subject: [PATCH 4/4] fix: handle undefined chunk --- packages/beasties/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beasties/src/index.ts b/packages/beasties/src/index.ts index 9ddbdd0..ca63d8d 100644 --- a/packages/beasties/src/index.ts +++ b/packages/beasties/src/index.ts @@ -296,7 +296,7 @@ export default class Beasties { // skip filtered resources, or network resources if no filter is provided // Strip query params and hashes before checking extension - const pathname = href?.split('?')[0].split('#')[0] + const pathname = href?.split('?')[0]?.split('#')[0] if (!pathname?.endsWith('.css')) { return undefined }