diff --git a/.changeset/nine-lamps-cry.md b/.changeset/nine-lamps-cry.md new file mode 100644 index 00000000..8dd2ca5f --- /dev/null +++ b/.changeset/nine-lamps-cry.md @@ -0,0 +1,5 @@ +--- +'@astrojs/netlify': minor +--- + +Adds support for `image.remotePatterns` and `images.domains` with Netlify Image CDN diff --git a/packages/netlify/src/index.ts b/packages/netlify/src/index.ts index 765f40dd..598e0d2c 100644 --- a/packages/netlify/src/index.ts +++ b/packages/netlify/src/index.ts @@ -4,7 +4,7 @@ import type { IncomingMessage } from 'node:http'; import { fileURLToPath } from 'node:url'; import { createRedirectsFromAstroRoutes } from '@astrojs/underscore-redirects'; import type { Context } from '@netlify/functions'; -import type { AstroConfig, AstroIntegration, RouteData } from 'astro'; +import type { AstroConfig, AstroIntegration, AstroIntegrationLogger, RouteData } from 'astro'; import { AstroError } from 'astro/errors'; import { build } from 'esbuild'; import type { Args } from './ssr-function.js'; @@ -24,6 +24,106 @@ const isStaticRedirect = (route: RouteData) => const clearDirectory = (dir: URL) => rm(dir, { recursive: true }).catch(() => {}); +type RemotePattern = AstroConfig['image']['remotePatterns'][number]; + +/** + * Convert a remote pattern object to a regex string + */ +export function remotePatternToRegex( + pattern: RemotePattern, + logger: AstroIntegrationLogger +): string | undefined { + let { protocol, hostname, port, pathname } = pattern; + + let regexStr = ''; + + if (protocol) { + regexStr += `${protocol}://`; + } else { + // Default to matching any protocol + regexStr += '[a-z]+://'; + } + + if (hostname) { + if (hostname.startsWith('**.')) { + // match any number of subdomains + regexStr += '([a-z0-9]+\\.)*'; + hostname = hostname.substring(3); + } else if (hostname.startsWith('*.')) { + // match one subdomain + regexStr += '([a-z0-9]+\\.)?'; + hostname = hostname.substring(2); // Remove '*.' from the beginning + } + // Escape dots in the hostname + regexStr += hostname.replace(/\./g, '\\.'); + } else { + regexStr += '[a-z0-9.-]+'; + } + + if (port) { + regexStr += `:${port}`; + } else { + // Default to matching any port + regexStr += '(:[0-9]+)?'; + } + + if (pathname) { + if (pathname.endsWith('/**')) { + // Match any path. + regexStr += `(\\${pathname.replace('/**', '')}.*)`; + } + if (pathname.endsWith('/*')) { + // Match one level of path + regexStr += `(\\${pathname.replace('/*', '')}\/[^/?#]+)\/?`; + } else { + // Exact match + regexStr += `(\\${pathname})`; + } + } else { + // Default to matching any path + regexStr += '(\\/[^?#]*)?'; + } + if (!regexStr.endsWith('.*)')) { + // Match query, but only if it's not already matched by the pathname + regexStr += '([?][^#]*)?'; + } + try { + new RegExp(regexStr); + } catch (e) { + logger.warn( + `Could not generate a valid regex from the remotePattern "${JSON.stringify( + pattern + )}". Please check the syntax.` + ); + return undefined; + } + return regexStr; +} + +async function writeNetlifyDeployConfig(config: AstroConfig, logger: AstroIntegrationLogger) { + const remoteImages: Array = []; + // Domains get a simple regex match + remoteImages.push( + ...config.image.domains.map((domain) => `https?:\/\/${domain.replaceAll('.', '\\.')}\/.*`) + ); + // Remote patterns need to be converted to regexes + remoteImages.push( + ...config.image.remotePatterns + .map((pattern) => remotePatternToRegex(pattern, logger)) + .filter(Boolean as unknown as (pattern?: string) => pattern is string) + ); + + // See https://docs.netlify.com/image-cdn/create-integration/ + const deployConfigDir = new URL('.netlify/deploy/v1/', config.root); + await mkdir(deployConfigDir, { recursive: true }); + await writeFile( + new URL('./config.json', deployConfigDir), + JSON.stringify({ + images: { remote_images: remoteImages }, + }) + ); +} + export interface NetlifyIntegrationConfig { /** * If enabled, On-Demand-Rendered pages are cached for up to a year. @@ -127,7 +227,7 @@ export default function netlifyIntegration( await mkdir(middlewareOutputDir(), { recursive: true }); await writeFile( new URL('./entry.mjs', middlewareOutputDir()), - ` + /* ts */ ` import { onRequest } from "${fileURLToPath(entrypoint).replaceAll('\\', '/')}"; import { createContext, trySerializeLocals } from 'astro/middleware'; @@ -266,15 +366,12 @@ export default function netlifyIntegration( }, }); }, - 'astro:config:done': ({ config, setAdapter }) => { + 'astro:config:done': async ({ config, setAdapter, logger }) => { rootDir = config.root; _config = config; - if (config.image.domains.length || config.image.remotePatterns.length) { - throw new AstroError( - "config.image.domains and config.image.remotePatterns aren't supported by the Netlify adapter.", - 'See https://github.com/withastro/adapters/tree/main/packages/netlify#image-cdn for more.' - ); + if (config.image?.domains?.length || config.image?.remotePatterns?.length) { + await writeNetlifyDeployConfig(config, logger); } const edgeMiddleware = integrationConfig?.edgeMiddleware ?? false; diff --git a/packages/netlify/test/functions/fixtures/middleware/astro.config.mjs b/packages/netlify/test/functions/fixtures/middleware/astro.config.mjs index ac8593fc..0da6bf58 100644 --- a/packages/netlify/test/functions/fixtures/middleware/astro.config.mjs +++ b/packages/netlify/test/functions/fixtures/middleware/astro.config.mjs @@ -7,5 +7,13 @@ export default defineConfig({ edgeMiddleware: process.env.EDGE_MIDDLEWARE === 'true', imageCDN: process.env.DISABLE_IMAGE_CDN ? false : undefined, }), + image: { + remotePatterns: [{ + protocol: 'https', + hostname: '*.example.org', + pathname: '/images/*', + }], + domains: ['example.net', 'secret.example.edu'], + }, site: `http://example.com`, }); \ No newline at end of file diff --git a/packages/netlify/test/functions/image-cdn.test.js b/packages/netlify/test/functions/image-cdn.test.js index 31032363..776a275b 100644 --- a/packages/netlify/test/functions/image-cdn.test.js +++ b/packages/netlify/test/functions/image-cdn.test.js @@ -1,5 +1,6 @@ import * as assert from 'node:assert/strict'; -import { after, describe, it } from 'node:test'; +import { after, before, describe, it } from 'node:test'; +import { remotePatternToRegex } from '@astrojs/netlify'; import { loadFixture } from '@astrojs/test-utils'; describe('Image CDN', () => { @@ -40,4 +41,89 @@ describe('Image CDN', () => { assert.equal(astronautPage.includes(`src="/_astro/astronaut.`), true); }); }); + + describe('remote image config', () => { + let regexes; + + before(async () => { + const fixture = await loadFixture({ root }); + await fixture.build(); + + const config = await fixture.readFile('../.netlify/deploy/v1/config.json'); + if (config) { + regexes = JSON.parse(config).images.remote_images.map((pattern) => new RegExp(pattern)); + } + }); + + it('generates remote image config patterns', async () => { + assert.equal(regexes?.length, 3); + }); + + it('generates correct config for domains', async () => { + const domain = regexes[0]; + assert.equal(domain.test('https://example.net/image.jpg'), true); + assert.equal( + domain.test('https://www.example.net/image.jpg'), + false, + 'subdomain should not match' + ); + assert.equal(domain.test('http://example.net/image.jpg'), true, 'http should match'); + assert.equal( + domain.test('https://example.net/subdomain/image.jpg'), + true, + 'subpath should match' + ); + const subdomain = regexes[1]; + assert.equal( + subdomain.test('https://secret.example.edu/image.jpg'), + true, + 'should match subdomains' + ); + assert.equal( + subdomain.test('https://secretxexample.edu/image.jpg'), + false, + 'should not use dots in domains as wildcards' + ); + }); + + it('generates correct config for remotePatterns', async () => { + const patterns = regexes[2]; + assert.equal(patterns.test('https://example.org/images/1.jpg'), true, 'should match domain'); + assert.equal( + patterns.test('https://www.example.org/images/2.jpg'), + true, + 'www subdomain should match' + ); + assert.equal( + patterns.test('https://www.subdomain.example.org/images/2.jpg'), + false, + 'second level subdomain should not match' + ); + assert.equal( + patterns.test('https://example.org/not-images/2.jpg'), + false, + 'wrong path should not match' + ); + }); + + it('warns when remotepatterns generates an invalid regex', async (t) => { + const logger = { + warn: t.mock.fn(), + }; + const regex = remotePatternToRegex( + { + hostname: '*.examp[le.org', + pathname: '/images/*', + }, + logger + ); + assert.strictEqual(regex, undefined); + const calls = logger.warn.mock.calls; + assert.strictEqual(calls.length, 1); + assert.equal( + calls[0].arguments[0], + 'Could not generate a valid regex from the remotePattern "{"hostname":"*.examp[le.org","pathname":"/images/*"}". Please check the syntax.' + ); + }); + }); }); diff --git a/packages/netlify/test/hosted/hosted-astro-project/astro.config.mjs b/packages/netlify/test/hosted/hosted-astro-project/astro.config.mjs index 464c03a6..3d45722e 100644 --- a/packages/netlify/test/hosted/hosted-astro-project/astro.config.mjs +++ b/packages/netlify/test/hosted/hosted-astro-project/astro.config.mjs @@ -5,4 +5,13 @@ import { defineConfig } from 'astro/config'; export default defineConfig({ output: 'server', adapter: netlify(), + image: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'images.unsplash.com', + pathname: '/photo-1567674867291-b2595ac53ab4', + }, + ], + }, }); diff --git a/packages/netlify/test/hosted/hosted-astro-project/netlify.toml b/packages/netlify/test/hosted/hosted-astro-project/netlify.toml new file mode 100644 index 00000000..55c8404e --- /dev/null +++ b/packages/netlify/test/hosted/hosted-astro-project/netlify.toml @@ -0,0 +1,3 @@ +[build] +command = "pnpm run --filter @test/netlify-hosted-astro-project... build" +publish = "/packages/netlify/test/hosted/hosted-astro-project/dist" diff --git a/packages/netlify/test/hosted/hosted-astro-project/src/pages/index.astro b/packages/netlify/test/hosted/hosted-astro-project/src/pages/index.astro index 256bfb40..7d2cfcdc 100644 --- a/packages/netlify/test/hosted/hosted-astro-project/src/pages/index.astro +++ b/packages/netlify/test/hosted/hosted-astro-project/src/pages/index.astro @@ -4,3 +4,10 @@ import penguin from '../assets/penguin.png'; --- + +Astro