diff --git a/.changeset/ripe-nights-feel.md b/.changeset/ripe-nights-feel.md new file mode 100644 index 000000000000..05b9147a57c4 --- /dev/null +++ b/.changeset/ripe-nights-feel.md @@ -0,0 +1,17 @@ +--- +'astro': minor +--- + +Responsive images are now supported when `security.csp` is enabled, out of the box. + +Before, the styles for responsive images were injected using the `style="""` attribute, and the image would look like this: + +```html + +``` + +After this change, styles now use a combination of `class=""` and data attributes. The image would look like this: + +```html + +``` diff --git a/.changeset/static-output-cloudflare.md b/.changeset/static-output-cloudflare.md new file mode 100644 index 000000000000..7561d9a6cb85 --- /dev/null +++ b/.changeset/static-output-cloudflare.md @@ -0,0 +1,5 @@ +--- +"@astrojs/cloudflare": patch +--- + +Fixes fully static sites to not output server-side worker code. When all routes are prerendered, the `_worker.js` directory is now removed from the build output. diff --git a/.changeset/upset-dodos-rhyme.md b/.changeset/upset-dodos-rhyme.md new file mode 100644 index 000000000000..57e53204f0d5 --- /dev/null +++ b/.changeset/upset-dodos-rhyme.md @@ -0,0 +1,5 @@ +--- +'astro': major +--- + +Changes how styles of responsive images are emitted - ([v6 upgrade guidance](https://v6.docs.astro.build/en/guides/upgrade-to/v6/#changed-how-responsive-image-styles-are-emitted)) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1707d07363d..35bd444d7d3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,29 @@ env: NODE_OPTIONS: --max-old-space-size=6144 jobs: + changes: + runs-on: ubuntu-latest + permissions: + pull-requests: read + outputs: + language-tools: ${{ steps.filter.outputs.language-tools }} + astro-types: ${{ steps.filter.outputs.astro-types }} + ci: ${{ steps.filter.outputs.ci }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter + with: + # When updating filters here, make sure to also add or remove them from the outputs block above. + filters: | + language-tools: + - 'packages/language-tools/**' + astro-types: + - 'packages/astro/types/**' + - 'packages/astro/astro-jsx.d.ts' + ci: + - '.github/workflows/ci.yml' + # Build primes out Turbo build cache and pnpm cache build: name: "Build: ${{ matrix.os }}" @@ -165,7 +188,10 @@ jobs: test-language-tools: runs-on: ${{ matrix.os }} timeout-minutes: 30 - needs: build + needs: + - changes + - build + if: needs.changes.outputs.language-tools == 'true' || needs.changes.outputs.astro-types == 'true' || needs.changes.outputs.ci == 'true' strategy: matrix: os: [ubuntu-latest] diff --git a/packages/astro/client.d.ts b/packages/astro/client.d.ts index 27666c60e29b..77f3a71c1511 100644 --- a/packages/astro/client.d.ts +++ b/packages/astro/client.d.ts @@ -84,6 +84,11 @@ declare module 'astro:assets' { }: AstroAssets; } +declare module 'virtual:astro:image-styles.css' { + const styles: string; + export default styles; +} + type ImageMetadata = import('./dist/assets/types.js').ImageMetadata; declare module '*.gif' { diff --git a/packages/astro/components/ResponsiveImage.astro b/packages/astro/components/ResponsiveImage.astro index 4a061e70eee8..e0f3713ac0a9 100644 --- a/packages/astro/components/ResponsiveImage.astro +++ b/packages/astro/components/ResponsiveImage.astro @@ -5,8 +5,6 @@ import Image from './Image.astro'; type Props = LocalImageProps | RemoteImageProps; const { class: className, ...props } = Astro.props; - -import './image.css'; --- {/* Applying class outside of the spread prevents it from applying unnecessary astro-* classes */} diff --git a/packages/astro/components/ResponsivePicture.astro b/packages/astro/components/ResponsivePicture.astro index f9c68ee0f01c..b43fcdbf869c 100644 --- a/packages/astro/components/ResponsivePicture.astro +++ b/packages/astro/components/ResponsivePicture.astro @@ -4,7 +4,6 @@ import { default as Picture, type Props as PictureProps } from './Picture.astro' type Props = PictureProps; const { class: className, ...props } = Astro.props; -import './image.css'; --- {/* Applying class outside of the spread prevents it from applying unnecessary astro-* classes */} diff --git a/packages/astro/components/image.css b/packages/astro/components/image.css deleted file mode 100644 index a515d331b662..000000000000 --- a/packages/astro/components/image.css +++ /dev/null @@ -1,11 +0,0 @@ -:where([data-astro-image]) { - object-fit: var(--fit); - object-position: var(--pos); - height: auto; -} -:where([data-astro-image="full-width"]) { - width: 100%; -} -:where([data-astro-image="constrained"]) { - max-width: 100%; -} diff --git a/packages/astro/e2e/core-image-styles.test.js b/packages/astro/e2e/core-image-styles.test.js new file mode 100644 index 000000000000..d946d4762f98 --- /dev/null +++ b/packages/astro/e2e/core-image-styles.test.js @@ -0,0 +1,42 @@ +import { expect } from '@playwright/test'; +import { testFactory } from './test-utils.js'; + +const test = testFactory(import.meta.url, { + root: '../test/fixtures/core-image-layout/', + devToolbar: { + enabled: false, + }, +}); + +let devServer; + +test.beforeAll(async ({ astro }) => { + devServer = await astro.startDevServer(); +}); + +test.afterAll(async () => { + await devServer.stop(); +}); + +test.describe('Image styles injection', () => { + test('injects a style tag with [data-astro-image] CSS', async ({ page, astro }) => { + await page.goto(astro.resolveUrl('/')); + + // Wait for client-side CSS injection + await page.waitForLoadState('networkidle'); + + // Check all style tags for image styles + const styleTags = await page.locator('style').all(); + let foundImageStyles = false; + + for (const styleTag of styleTags) { + const content = await styleTag.textContent(); + if (content && content.includes('[data-astro-image]')) { + foundImageStyles = true; + break; + } + } + + expect(foundImageStyles).toBe(true); + }); +}); diff --git a/packages/astro/src/assets/consts.ts b/packages/astro/src/assets/consts.ts index 4a6a2ae83a8d..67e99fbed0b5 100644 --- a/packages/astro/src/assets/consts.ts +++ b/packages/astro/src/assets/consts.ts @@ -1,6 +1,9 @@ export const VIRTUAL_MODULE_ID = 'astro:assets'; export const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID; export const VIRTUAL_SERVICE_ID = 'virtual:image-service'; +// Must keep the extension so we trigger the pipeline of CSS files +export const VIRTUAL_IMAGE_STYLES_ID = 'virtual:astro:image-styles.css'; +export const RESOLVED_VIRTUAL_IMAGE_STYLES_ID = '\0' + VIRTUAL_IMAGE_STYLES_ID; export const VALID_INPUT_FORMATS = [ 'jpeg', 'jpg', diff --git a/packages/astro/src/assets/internal.ts b/packages/astro/src/assets/internal.ts index 35a26783c39c..7595ace1660d 100644 --- a/packages/astro/src/assets/internal.ts +++ b/packages/astro/src/assets/internal.ts @@ -17,11 +17,12 @@ import { type SrcSetValue, type UnresolvedImageTransform, } from './types.js'; -import { addCSSVarsToStyle, cssFitValues } from './utils/imageAttributes.js'; import { isESMImportedImage, isRemoteImage, resolveSrc } from './utils/imageKind.js'; import { inferRemoteSize } from './utils/remoteProbe.js'; import { createPlaceholderURL, stringifyPlaceholderURL } from './utils/url.js'; +export const cssFitValues = ['fill', 'contain', 'cover', 'scale-down']; + export async function getConfiguredImageService(): Promise { if (!globalThis?.astroAsset?.imageService) { const { default: service }: { default: ImageService } = await import( @@ -149,14 +150,19 @@ export async function getImage( resolvedOptions.sizes ||= getSizesAttribute({ width: resolvedOptions.width, layout }); // The densities option is incompatible with the `layout` option delete resolvedOptions.densities; - resolvedOptions.style = addCSSVarsToStyle( - { - fit: cssFitValues.includes(resolvedOptions.fit ?? '') && resolvedOptions.fit, - pos: resolvedOptions.position, - }, - resolvedOptions.style, - ); + + // Set data attribute for layout resolvedOptions['data-astro-image'] = layout; + + // Set data attributes for fit and position for CSP-compliant styling + if (resolvedOptions.fit && cssFitValues.includes(resolvedOptions.fit)) { + resolvedOptions['data-astro-image-fit'] = resolvedOptions.fit; + } + + if (resolvedOptions.position) { + // Normalize position value for data attribute (spaces to dashes) + resolvedOptions['data-astro-image-pos'] = resolvedOptions.position.replace(/\s+/g, '-'); + } } const validatedOptions = service.validateOptions diff --git a/packages/astro/src/assets/utils/generateImageStylesCSS.ts b/packages/astro/src/assets/utils/generateImageStylesCSS.ts new file mode 100644 index 000000000000..78ae49163d97 --- /dev/null +++ b/packages/astro/src/assets/utils/generateImageStylesCSS.ts @@ -0,0 +1,48 @@ +import { cssFitValues } from '../internal.js'; + +export function generateImageStylesCSS( + defaultObjectFit?: string, + defaultObjectPosition?: string, +): string { + const fitStyles = cssFitValues + .map( + (fit) => ` +[data-astro-image-fit="${fit}"] { + object-fit: ${fit}; +}`, + ) + .join('\n'); + + const defaultFitStyle = + defaultObjectFit && cssFitValues.includes(defaultObjectFit) + ? ` +:where([data-astro-image]:not([data-astro-image-fit])) { + object-fit: ${defaultObjectFit}; +}` + : ''; + + const positionStyle = defaultObjectPosition + ? ` +[data-astro-image-pos="${defaultObjectPosition.replace(/\s+/g, '-')}"] { + object-position: ${defaultObjectPosition}; +} + +:where([data-astro-image]:not([data-astro-image-pos])) { + object-position: ${defaultObjectPosition}; +}` + : ''; + return ` +:where([data-astro-image]) { + height: auto; +} +:where([data-astro-image="full-width"]) { + width: 100%; +} +:where([data-astro-image="constrained"]) { + max-width: 100%; +} +${fitStyles} +${defaultFitStyle} +${positionStyle} +`.trim(); +} diff --git a/packages/astro/src/assets/utils/imageAttributes.ts b/packages/astro/src/assets/utils/imageAttributes.ts deleted file mode 100644 index e7d1b6949376..000000000000 --- a/packages/astro/src/assets/utils/imageAttributes.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { toStyleString } from '../../runtime/server/render/util.js'; - -export const cssFitValues = ['fill', 'contain', 'cover', 'scale-down']; - -export function addCSSVarsToStyle( - vars: Record, - styles?: string | Record, -) { - const cssVars = Object.entries(vars) - .filter(([_, value]) => value !== undefined && value !== false) - .map(([key, value]) => `--${key}: ${value};`) - .join(' '); - - if (!styles) { - return cssVars; - } - const style = typeof styles === 'string' ? styles : toStyleString(styles); - - return `${cssVars} ${style}`; -} diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts index 31af2f52a581..c949612791b8 100644 --- a/packages/astro/src/assets/vite-plugin-assets.ts +++ b/packages/astro/src/assets/vite-plugin-assets.ts @@ -15,8 +15,10 @@ import { normalizePath } from '../core/viteUtils.js'; import { isAstroServerEnvironment } from '../environments.js'; import type { AstroSettings } from '../types/astro.js'; import { + RESOLVED_VIRTUAL_IMAGE_STYLES_ID, RESOLVED_VIRTUAL_MODULE_ID, VALID_INPUT_FORMATS, + VIRTUAL_IMAGE_STYLES_ID, VIRTUAL_MODULE_ID, VIRTUAL_SERVICE_ID, } from './consts.js'; @@ -155,61 +157,61 @@ export default function assets({ fs, settings, sync, logger }: Options): vite.Pl handler() { return { code: ` - import { getConfiguredImageService as _getConfiguredImageService } from "astro/assets"; - export { isLocalService } from "astro/assets"; - import { getImage as getImageInternal } from "astro/assets"; - export { default as Image } from "astro/components/${imageComponentPrefix}Image.astro"; - export { default as Picture } from "astro/components/${imageComponentPrefix}Picture.astro"; - import { inferRemoteSize as inferRemoteSizeInternal } from "astro/assets/utils/inferRemoteSize.js"; + import { getConfiguredImageService as _getConfiguredImageService } from "astro/assets"; + export { isLocalService } from "astro/assets"; + import { getImage as getImageInternal } from "astro/assets"; + ${settings.config.image.responsiveStyles ? `import "${VIRTUAL_IMAGE_STYLES_ID}";` : ''} + export { default as Image } from "astro/components/${imageComponentPrefix}Image.astro"; + export { default as Picture } from "astro/components/${imageComponentPrefix}Picture.astro"; + import { inferRemoteSize as inferRemoteSizeInternal } from "astro/assets/utils/inferRemoteSize.js"; - export { default as Font } from "astro/components/Font.astro"; - export * from "${RUNTIME_VIRTUAL_MODULE_ID}"; - - export const getConfiguredImageService = _getConfiguredImageService; + export { default as Font } from "astro/components/Font.astro"; + export * from "${RUNTIME_VIRTUAL_MODULE_ID}"; + + export const getConfiguredImageService = _getConfiguredImageService; export const viteFSConfig = ${JSON.stringify(resolvedConfig.server.fs ?? {})}; - export const safeModulePaths = new Set(${JSON.stringify( - // @ts-expect-error safeModulePaths is internal to Vite - Array.from(resolvedConfig.safeModulePaths ?? []), - )}); + export const safeModulePaths = new Set(${JSON.stringify( + // @ts-expect-error safeModulePaths is internal to Vite + Array.from(resolvedConfig.safeModulePaths ?? []), + )}); - const assetQueryParams = ${ - settings.adapter?.client?.assetQueryParams - ? `new URLSearchParams(${JSON.stringify( - Array.from(settings.adapter.client.assetQueryParams.entries()), - )})` - : 'undefined' - }; - export const imageConfig = ${JSON.stringify(settings.config.image)}; - Object.defineProperty(imageConfig, 'assetQueryParams', { - value: assetQueryParams, - enumerable: false, - configurable: true, - }); - export const inferRemoteSize = async (url) => { + const assetQueryParams = ${ + settings.adapter?.client?.assetQueryParams + ? `new URLSearchParams(${JSON.stringify( + Array.from(settings.adapter.client.assetQueryParams.entries()), + )})` + : 'undefined' + }; + export const imageConfig = ${JSON.stringify(settings.config.image)}; + Object.defineProperty(imageConfig, 'assetQueryParams', { + value: assetQueryParams, + enumerable: false, + configurable: true, + }); +export const inferRemoteSize = async (url) => { const service = await _getConfiguredImageService() return service.getRemoteSize?.(url, imageConfig) ?? inferRemoteSizeInternal(url) - } - // This is used by the @astrojs/node integration to locate images. - // It's unused on other platforms, but on some platforms like Netlify (and presumably also Vercel) - // new URL("dist/...") is interpreted by the bundler as a signal to include that directory - // in the Lambda bundle, which would bloat the bundle with images. - // To prevent this, we mark the URL construction as pure, - // so that it's tree-shaken away for all platforms that don't need it. - export const outDir = /* #__PURE__ */ new URL(${JSON.stringify( - new URL( - settings.buildOutput === 'server' - ? settings.config.build.client - : settings.config.outDir, - ), - )}); + } // This is used by the @astrojs/node integration to locate images. + // It's unused on other platforms, but on some platforms like Netlify (and presumably also Vercel) + // new URL("dist/...") is interpreted by the bundler as a signal to include that directory + // in the Lambda bundle, which would bloat the bundle with images. + // To prevent this, we mark the URL construction as pure, + // so that it's tree-shaken away for all platforms that don't need it. + export const outDir = /* #__PURE__ */ new URL(${JSON.stringify( + new URL( + settings.buildOutput === 'server' + ? settings.config.build.client + : settings.config.outDir, + ), + )}); export const serverDir = /* #__PURE__ */ new URL(${JSON.stringify( new URL(settings.config.build.server), )}); - export const getImage = async (options) => await getImageInternal(options, imageConfig); - `, + export const getImage = async (options) => await getImageInternal(options, imageConfig); + `, }; }, }, @@ -317,5 +319,33 @@ export default function assets({ fs, settings, sync, logger }: Options): vite.Pl }, }, fontsPlugin({ settings, sync, logger }), + { + name: 'astro:image-styles', + resolveId: { + filter: { + id: new RegExp(`^${VIRTUAL_IMAGE_STYLES_ID}$`), + }, + handler(id) { + if (id === VIRTUAL_IMAGE_STYLES_ID) { + return RESOLVED_VIRTUAL_IMAGE_STYLES_ID; + } + }, + }, + load: { + filter: { + id: new RegExp(`^${RESOLVED_VIRTUAL_IMAGE_STYLES_ID}$`), + }, + async handler(id) { + if (id === RESOLVED_VIRTUAL_IMAGE_STYLES_ID) { + const { generateImageStylesCSS } = await import('./utils/generateImageStylesCSS.js'); + const css = generateImageStylesCSS( + settings.config.image.objectFit, + settings.config.image.objectPosition, + ); + return { code: css }; + } + }, + }, + }, ]; } diff --git a/packages/astro/src/container/index.ts b/packages/astro/src/container/index.ts index f9ca4f1f67e8..e4faa334965c 100644 --- a/packages/astro/src/container/index.ts +++ b/packages/astro/src/container/index.ts @@ -167,6 +167,7 @@ function createManifest( middleware: manifest?.middleware ?? middlewareInstance, key: createKey(), csp: manifest?.csp, + image: manifest?.image ?? {}, shouldInjectCspMetaTags: false, devToolbar: { enabled: false, @@ -264,6 +265,7 @@ type AstroContainerManifest = Pick< | 'allowedDomains' | 'serverLike' | 'assetsDir' + | 'image' >; type AstroContainerConstructor = { diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index 9c26103b7cad..bc5ccfecc304 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -109,6 +109,11 @@ export type SSRManifest = { buildClientDir: URL; buildServerDir: URL; csp: SSRManifestCSP | undefined; + image: { + objectFit?: string; + objectPosition?: string; + layout?: string; + }; shouldInjectCspMetaTags: boolean; devToolbar: { // This should always be false in prod/SSR diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index dc20a64e431e..5aab13b876e4 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -335,6 +335,11 @@ async function buildManifest( key: encodedKey, sessionConfig: sessionConfigToManifest(settings.config.session), csp, + image: { + objectFit: settings.config.image.objectFit, + objectPosition: settings.config.image.objectPosition, + layout: settings.config.image.layout, + }, devToolbar: { enabled: false, latestAstroVersion: undefined, diff --git a/packages/astro/src/manifest/serialized.ts b/packages/astro/src/manifest/serialized.ts index 58a0c985f7da..1837d95828ad 100644 --- a/packages/astro/src/manifest/serialized.ts +++ b/packages/astro/src/manifest/serialized.ts @@ -170,6 +170,11 @@ async function createSerializedManifest(settings: AstroSettings): Promise; root: URL; + image: Pick; }; export type ClientDeserializedManifest = Pick< @@ -35,4 +36,5 @@ export type ClientDeserializedManifest = Pick< > & { i18n: AstroConfig['i18n']; build: Pick; + image: Pick; }; diff --git a/packages/astro/src/vite-plugin-astro-server/plugin.ts b/packages/astro/src/vite-plugin-astro-server/plugin.ts index d84d23aa4315..91edffe613e5 100644 --- a/packages/astro/src/vite-plugin-astro-server/plugin.ts +++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts @@ -200,6 +200,11 @@ export async function createDevelopmentManifest(settings: AstroSettings): Promis }, sessionConfig: sessionConfigToManifest(settings.config.session), csp, + image: { + objectFit: settings.config.image.objectFit, + objectPosition: settings.config.image.objectPosition, + layout: settings.config.image.layout, + }, devToolbar: { enabled: settings.config.devToolbar.enabled && diff --git a/packages/astro/test/core-image-layout.test.js b/packages/astro/test/core-image-layout.test.js index 16c97eefd596..c793e5e42e58 100755 --- a/packages/astro/test/core-image-layout.test.js +++ b/packages/astro/test/core-image-layout.test.js @@ -125,11 +125,6 @@ describe('astro:image:layout', () => { let $img = $('#local-style-object img'); assert.match($img.attr('style'), /border:2px red solid/); }); - - it('injects a style tag', () => { - const style = $('style').text(); - assert.match(style, /\[data-astro-image\]/); - }); }); describe('srcsets', () => { @@ -408,11 +403,16 @@ describe('astro:image:layout', () => { assert.ok($picture.attr('class').includes('picture-comp')); }); - it('adds inline style attributes', () => { + it('adds data attributes instead of inline styles', () => { let $img = $('#picture-attributes img'); + // Should have data attributes for CSP compliance + assert.ok($img.attr('data-astro-image')); + // Should NOT have inline style CSS variables const style = $img.attr('style'); - assert.match(style, /--fit:/); - assert.match(style, /--pos:/); + if (style) { + assert.ok(!style.includes('--fit:')); + assert.ok(!style.includes('--pos:')); + } }); it('passing in style as an object', () => { diff --git a/packages/astro/test/csp.test.js b/packages/astro/test/csp.test.js index 8c5c4a483a4e..5337c0fe5f3f 100644 --- a/packages/astro/test/csp.test.js +++ b/packages/astro/test/csp.test.js @@ -396,6 +396,62 @@ describe('CSP', () => { assert.equal(meta.attr('content').toString().includes('font-src'), false); }); + it('should generate hashes for Image component inline styles when using layout', async () => { + fixture = await loadFixture({ + root: './fixtures/csp/', + outDir: './dist/csp-image', + }); + await fixture.build(); + const html = await fixture.readFile('/image/index.html'); + const $ = cheerio.load(html); + + // Check that the image has data attributes instead of inline styles (CSP-compliant) + const img = $('img'); + assert.ok(img.attr('data-astro-image'), 'Image should have data-astro-image attribute'); + assert.ok(img.attr('data-astro-image-fit'), 'Image should have data-astro-image-fit attribute'); + assert.ok(img.attr('data-astro-image-pos'), 'Image should have data-astro-image-pos attribute'); + + // Check that the CSP meta tag contains a hash for the style + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + const cspContent = meta.attr('content').toString(); + // The style-src directive should contain hashes (sha256- prefixed values) + assert.ok(cspContent.includes('style-src'), 'CSP should have style-src directive'); + // There should be at least one sha256 hash for the static CSS + const styleMatches = cspContent.match(/sha256-[A-Za-z0-9+/=]+/g); + assert.ok(styleMatches && styleMatches.length > 0, 'CSP should contain style hashes'); + }); + + it('should generate hashes for Picture component inline styles when using layout', async () => { + fixture = await loadFixture({ + root: './fixtures/csp/', + outDir: './dist/csp-picture', + }); + await fixture.build(); + const html = await fixture.readFile('/picture/index.html'); + const $ = cheerio.load(html); + + // Check that the img inside picture has data attributes instead of inline styles (CSP-compliant) + const img = $('picture img'); + assert.ok(img.attr('data-astro-image'), 'Picture img should have data-astro-image attribute'); + assert.ok( + img.attr('data-astro-image-fit'), + 'Picture img should have data-astro-image-fit attribute', + ); + assert.ok( + img.attr('data-astro-image-pos'), + 'Picture img should have data-astro-image-pos attribute', + ); + + // Check that the CSP meta tag contains a hash for the style + const meta = $('meta[http-equiv="Content-Security-Policy"]'); + const cspContent = meta.attr('content').toString(); + // The style-src directive should contain hashes (sha256- prefixed values) + assert.ok(cspContent.includes('style-src'), 'CSP should have style-src directive'); + // There should be at least one sha256 hash for the static CSS + const styleMatches = cspContent.match(/sha256-[A-Za-z0-9+/=]+/g); + assert.ok(styleMatches && styleMatches.length > 0, 'CSP should contain style hashes'); + }); + it('should return CSP header inside a hook', async () => { let routeToHeaders; fixture = await loadFixture({ diff --git a/packages/astro/test/fixtures/csp/src/assets/penguin.jpg b/packages/astro/test/fixtures/csp/src/assets/penguin.jpg new file mode 100644 index 000000000000..73f0ee316c01 Binary files /dev/null and b/packages/astro/test/fixtures/csp/src/assets/penguin.jpg differ diff --git a/packages/astro/test/fixtures/csp/src/pages/image.astro b/packages/astro/test/fixtures/csp/src/pages/image.astro new file mode 100644 index 000000000000..dd8f456bae68 --- /dev/null +++ b/packages/astro/test/fixtures/csp/src/pages/image.astro @@ -0,0 +1,18 @@ +--- +import { Image } from 'astro:assets'; +import penguin from '../assets/penguin.jpg'; +--- + + + + + + Image CSP Test + + +
+

Image with layout

+ A penguin +
+ + diff --git a/packages/astro/test/fixtures/csp/src/pages/picture.astro b/packages/astro/test/fixtures/csp/src/pages/picture.astro new file mode 100644 index 000000000000..56e2cf337575 --- /dev/null +++ b/packages/astro/test/fixtures/csp/src/pages/picture.astro @@ -0,0 +1,18 @@ +--- +import { Picture } from 'astro:assets'; +import penguin from '../assets/penguin.jpg'; +--- + + + + + + Picture CSP Test + + +
+

Picture with layout

+ +
+ + diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index 8e76cc99884f..412b4a5dddcf 100644 --- a/packages/integrations/cloudflare/src/index.ts +++ b/packages/integrations/cloudflare/src/index.ts @@ -1,15 +1,10 @@ import { createReadStream, existsSync, readFileSync } from 'node:fs'; -import { appendFile, stat } from 'node:fs/promises'; +import { appendFile, rm, stat } from 'node:fs/promises'; import { createInterface } from 'node:readline/promises'; import { removeLeadingForwardSlash } from '@astrojs/internal-helpers/path'; import { createRedirectsFromAstroRoutes, printAsRedirects } from '@astrojs/underscore-redirects'; import { cloudflare as cfVitePlugin, type PluginConfig } from '@cloudflare/vite-plugin'; -import type { - AstroConfig, - AstroIntegration, - HookParameters, - IntegrationResolvedRoute, -} from 'astro'; +import type { AstroConfig, AstroIntegration, IntegrationResolvedRoute } from 'astro'; import { astroFrontmatterScanPlugin } from './esbuild-plugin-astro-frontmatter.js'; import { createRoutesFile, getParts } from './utils/generate-routes-json.js'; import { type ImageService, setImageConfig } from './utils/image-config.js'; @@ -79,9 +74,9 @@ export type Options = { export default function createIntegration(args?: Options): AstroIntegration { let _config: AstroConfig; - let finalBuildOutput: HookParameters<'astro:config:done'>['buildOutput']; let _routes: IntegrationResolvedRoute[]; + let _isFullyStatic = false; const sessionKVBindingName = args?.sessionKVBindingName ?? DEFAULT_SESSION_KV_BINDING_NAME; const imagesBindingName = args?.imagesBindingName ?? DEFAULT_IMAGES_BINDING_NAME; @@ -233,10 +228,13 @@ export default function createIntegration(args?: Options): AstroIntegration { }, 'astro:routes:resolved': ({ routes }) => { _routes = routes; + // Check if all non-internal routes are prerendered (fully static site) + const nonInternalRoutes = routes.filter((route) => route.origin !== 'internal'); + _isFullyStatic = + nonInternalRoutes.length > 0 && nonInternalRoutes.every((route) => route.isPrerendered); }, - 'astro:config:done': ({ setAdapter, config, buildOutput, injectTypes, logger }) => { + 'astro:config:done': ({ setAdapter, config, injectTypes, logger }) => { _config = config; - finalBuildOutput = buildOutput; injectTypes({ filename: 'cloudflare.d.ts', @@ -254,7 +252,7 @@ export default function createIntegration(args?: Options): AstroIntegration { supportedAstroFeatures: { serverOutput: 'stable', hybridOutput: 'stable', - staticOutput: 'unsupported', + staticOutput: 'stable', i18nDomains: 'experimental', sharpImageService: { support: 'limited', @@ -387,7 +385,7 @@ export default function createIntegration(args?: Options): AstroIntegration { ), ), dir, - buildOutput: finalBuildOutput, + buildOutput: _isFullyStatic ? 'static' : 'server', assets, }); @@ -402,6 +400,11 @@ export default function createIntegration(args?: Options): AstroIntegration { } } + // For fully static sites, remove the worker directory as it's not needed + if (_isFullyStatic) { + await rm(_config.build.server, { recursive: true, force: true }); + } + // Delete this variable so the preview server opens the server build. delete process.env.CLOUDFLARE_VITE_BUILD; }, diff --git a/packages/integrations/cloudflare/test/fixtures/static/astro.config.mjs b/packages/integrations/cloudflare/test/fixtures/static/astro.config.mjs new file mode 100644 index 000000000000..71d2443eef99 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/static/astro.config.mjs @@ -0,0 +1,7 @@ +import cloudflare from '@astrojs/cloudflare'; +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + adapter: cloudflare(), + output: 'static', +}); diff --git a/packages/integrations/cloudflare/test/fixtures/static/package.json b/packages/integrations/cloudflare/test/fixtures/static/package.json new file mode 100644 index 000000000000..3bfc9cecbb29 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/static/package.json @@ -0,0 +1,12 @@ +{ + "name": "@test/astro-cloudflare-static", + "version": "0.0.0", + "private": true, + "scripts": { + "build": "astro build" + }, + "dependencies": { + "@astrojs/cloudflare": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/cloudflare/test/fixtures/static/src/pages/index.astro b/packages/integrations/cloudflare/test/fixtures/static/src/pages/index.astro new file mode 100644 index 000000000000..06d6094d506b --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/static/src/pages/index.astro @@ -0,0 +1,9 @@ + + + Static Site + + +

Static Site

+

This is a fully static Astro site using the Cloudflare adapter.

+ + diff --git a/packages/integrations/cloudflare/test/static.test.js b/packages/integrations/cloudflare/test/static.test.js new file mode 100644 index 000000000000..cc7def3167a7 --- /dev/null +++ b/packages/integrations/cloudflare/test/static.test.js @@ -0,0 +1,21 @@ +import { describe, it } from 'node:test'; +import { loadFixture } from './_test-utils.js'; +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +describe('Static output', () => { + let fixture; + + it('should not output a _worker.js directory for fully static sites', async () => { + fixture = await loadFixture({ + root: './fixtures/static', + }); + + await fixture.build(); + + const workerExists = existsSync(fileURLToPath(new URL('_worker.js', fixture.config.outDir))); + + assert.ok(!workerExists, '_worker.js directory should not exist for static sites'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be1b7697bd6e..d66757335799 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5125,6 +5125,15 @@ importers: specifier: ^2.1.3 version: 2.1.3 + packages/integrations/cloudflare/test/fixtures/static: + dependencies: + '@astrojs/cloudflare': + specifier: workspace:* + version: link:../../.. + astro: + specifier: workspace:* + version: link:../../../../../astro + packages/integrations/cloudflare/test/fixtures/vite-plugin: dependencies: '@astrojs/cloudflare':