Skip to content

Commit

Permalink
Change files
Browse files Browse the repository at this point in the history
  • Loading branch information
alexanderniebuhr committed Nov 6, 2023
1 parent 1941ccf commit 80693ff
Show file tree
Hide file tree
Showing 5 changed files with 76 additions and 401 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,6 @@
"prettier-plugin-astro": "^0.12.0",
"tiny-glob": "^0.2.9",
"turbo": "^1.10.12",
"typescript": "~5.1.6"
"typescript": "^5.2.2"
}
}
273 changes: 19 additions & 254 deletions packages/cloudflare/src/entrypoints/image-service.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,6 @@
import type {
AstroConfig,
ExternalImageService,
ImageMetadata,
ImageTransform,
RemotePattern,
} from 'astro';
import type { AstroConfig, ExternalImageService, ImageMetadata, RemotePattern } from 'astro';

type SrcSetValue = {
transform: ImageTransform;
descriptor?: string;
attributes?: Record<string, any>;
};
import { baseService } from 'astro/assets';

function isESMImportedImage(src: ImageMetadata | string): src is ImageMetadata {
return typeof src === 'object';
Expand Down Expand Up @@ -92,18 +82,18 @@ function isRemoteAllowed(
);
}

const VALID_SUPPORTED_FORMATS = [
'jpeg',
'jpg',
'png',
'tiff',
'webp',
'gif',
'svg',
'avif',
] as const;
// const VALID_SUPPORTED_FORMATS = [
// 'jpeg',
// 'jpg',
// 'png',
// 'tiff',
// 'webp',
// 'gif',
// 'svg',
// 'avif',
// ] as const;

const DEFAULT_OUTPUT_FORMAT = 'webp' as const;
// const DEFAULT_OUTPUT_FORMAT = 'webp' as const;

function isString(path: unknown): path is string {
return typeof path === 'string' || path instanceof String;
Expand Down Expand Up @@ -136,236 +126,10 @@ function joinPaths(...paths: (string | undefined)[]) {
.join('/');
}

/**
* Returns the final dimensions of an image based on the user's options.
*
* For local images:
* - If the user specified both width and height, we'll use those.
* - If the user specified only one of them, we'll use the original image's aspect ratio to calculate the other.
* - If the user didn't specify either, we'll use the original image's dimensions.
*
* For remote images:
* - Widths and heights are always required, so we'll use the user's specified width and height.
*/
function getTargetDimensions(options: ImageTransform) {
let targetWidth = options.width;
let targetHeight = options.height;
if (isESMImportedImage(options.src)) {
const aspectRatio = options.src.width / options.src.height;
if (targetHeight && !targetWidth) {
// If we have a height but no width, use height to calculate the width
targetWidth = Math.round(targetHeight * aspectRatio);
} else if (targetWidth && !targetHeight) {
// If we have a width but no height, use width to calculate the height
targetHeight = Math.round(targetWidth / aspectRatio);
} else if (!targetWidth && !targetHeight) {
// If we have neither width or height, use the original image's dimensions
targetWidth = options.src.width;
targetHeight = options.src.height;
}
}

// TypeScript doesn't know this, but because of previous hooks we always know that targetWidth and targetHeight are defined
return {
targetWidth: targetWidth!,
targetHeight: targetHeight!,
};
}

const service: ExternalImageService = {
validateOptions: (options /*, imageConfig: AstroConfig['image']*/) => {
// add custom global CF image service options
// const serviceConfig = imageConfig.service.config;

// need to add checks for limits
// https://developers.cloudflare.com/images/image-resizing/format-limitations/#format-limitations

// `src` is missing or is `undefined`.
if (!options.src || (typeof options.src !== 'string' && typeof options.src !== 'object')) {
throw new Error('ExpectedImage');
// throw new AstroError({
// ...AstroErrorData.ExpectedImage,
// message: AstroErrorData.ExpectedImage.message(
// JSON.stringify(options.src),
// typeof options.src,
// JSON.stringify(options, (_, v) => (v === undefined ? null : v))
// ),
// });
}

if (!isESMImportedImage(options.src)) {
// User passed an `/@fs/` path or a filesystem path instead of the full image.
if (
options.src.startsWith('/@fs/') ||
(!isRemotePath(options.src) && !options.src.startsWith('/'))
) {
throw new Error('LocalImageUsedWrongly');
// throw new AstroError({
// ...AstroErrorData.LocalImageUsedWrongly,
// message: AstroErrorData.LocalImageUsedWrongly.message(options.src),
// });
}

// For remote images, width and height are explicitly required as we can't infer them from the file
let missingDimension: 'width' | 'height' | 'both' | undefined;
if (!options.width && !options.height) {
missingDimension = 'both';
} else if (!options.width && options.height) {
missingDimension = 'width';
} else if (options.width && !options.height) {
missingDimension = 'height';
}

if (missingDimension) {
throw new Error('MissingImageDimension');
// throw new AstroError({
// ...AstroErrorData.MissingImageDimension,
// message: AstroErrorData.MissingImageDimension.message(missingDimension, options.src),
// });
}
} else {
if (!VALID_SUPPORTED_FORMATS.includes(options.src.format)) {
throw new Error('UnsupportedImageFormat');
// throw new AstroError({
// ...AstroErrorData.UnsupportedImageFormat,
// message: AstroErrorData.UnsupportedImageFormat.message(
// options.src.format,
// options.src.src,
// VALID_SUPPORTED_FORMATS
// ),
// });
}

if (options.widths && options.densities) {
throw new Error('IncompatibleDescriptorOptions');
// throw new AstroError(AstroErrorData.IncompatibleDescriptorOptions);
}

// We currently do not support processing SVGs, so whenever the input format is a SVG, force the output to also be one
if (options.src.format === 'svg') {
options.format = 'svg';
}

if (
(options.src.format === 'svg' && options.format !== 'svg') ||
(options.src.format !== 'svg' && options.format === 'svg')
) {
throw new Error('UnsupportedImageConversion');
// throw new AstroError(AstroErrorData.UnsupportedImageConversion);
}
}

// If the user didn't specify a format, we'll default to `webp`. It offers the best ratio of compatibility / quality
// In the future, hopefully we can replace this with `avif`, alas, Edge. See https://caniuse.com/avif
if (!options.format) {
options.format = DEFAULT_OUTPUT_FORMAT;
}

// Sometimes users will pass number generated from division, which can result in floating point numbers
if (options.width) options.width = Math.round(options.width);
if (options.height) options.height = Math.round(options.height);

return options;
},
getHTMLAttributes: (options) => {
const { targetWidth, targetHeight } = getTargetDimensions(options);
const { src, width, height, format, quality, densities, widths, formats, ...attributes } =
options;

return {
...attributes,
width: targetWidth,
height: targetHeight,
loading: attributes.loading ?? 'lazy',
decoding: attributes.decoding ?? 'async',
};
},
getSrcSet(options) {
const srcSet: SrcSetValue[] = [];
const { targetWidth } = getTargetDimensions(options);
const { widths, densities } = options;
const targetFormat = options.format ?? DEFAULT_OUTPUT_FORMAT;

// NOTE: Depending on the cloudflare fit value, the image might never be enlarged
// For remote images, we don't know the original image's dimensions, so we cannot know the maximum width
// It is ultimately the user's responsibility to make sure they don't request images larger than the original
let imageWidth = options.width;
let maxWidth = Infinity;

// However, if it's an imported image, we can use the original image's width as a maximum width
if (isESMImportedImage(options.src)) {
imageWidth = options.src.width;
maxWidth = imageWidth;
}

// Since `widths` and `densities` ultimately control the width and height of the image,
// we don't want the dimensions the user specified, we'll create those ourselves.
const {
width: transformWidth,
height: transformHeight,
...transformWithoutDimensions
} = options;

// Collect widths to generate from specified densities or widths
const allWidths: { maxTargetWidth: number; descriptor: `${number}x` | `${number}w` }[] = [];
if (densities) {
// Densities can either be specified as numbers, or descriptors (ex: '1x'), we'll convert them all to numbers
const densityValues = densities.map((density) => {
if (typeof density === 'number') {
return density;
} else {
return parseFloat(density);
}
});

// Calculate the widths for each density, rounding to avoid floats.
const densityWidths = densityValues
.sort()
.map((density) => Math.round(targetWidth * density));

allWidths.push(
...densityWidths.map((width, index) => ({
maxTargetWidth: Math.min(width, maxWidth),
descriptor: `${densityValues[index]}x` as const,
}))
);
} else if (widths) {
allWidths.push(
...widths.map((width) => ({
maxTargetWidth: Math.min(width, maxWidth),
descriptor: `${width}w` as const,
}))
);
}

// Caution: The logic below is a bit tricky, as we need to make sure we don't generate the same image multiple times
// When making changes, make sure to test with different combinations of local/remote images widths, densities, and dimensions etc.
for (const { maxTargetWidth, descriptor } of allWidths) {
const srcSetTransform: ImageTransform = { ...transformWithoutDimensions };

// Only set the width if it's different from the original image's width, to avoid generating the same image multiple times
if (maxTargetWidth !== imageWidth) {
srcSetTransform.width = maxTargetWidth;
} else {
// If the width is the same as the original image's width, and we have both dimensions, it probably means
// it's a remote image, so we'll use the user's specified dimensions to avoid recreating the original image unnecessarily
if (options.width && options.height) {
srcSetTransform.width = options.width;
srcSetTransform.height = options.height;
}
}

srcSet.push({
transform: srcSetTransform,
descriptor,
attributes: {
type: `image/${targetFormat}`,
},
});
}

return srcSet;
},
validateOptions: baseService.validateOptions,
getHTMLAttributes: baseService.getHTMLAttributes,
getSrcSet: baseService.getSrcSet,
getURL: (options, imageConfig) => {
const resizingParams = [];
if (options.width) resizingParams.push(`width=${options.width}`);
Expand All @@ -385,13 +149,14 @@ const service: ExternalImageService = {
}

const imageEndpoint = joinPaths(
//@ts-ignore
// FIXME: I have no idea why we get:
// Property 'env' does not exist on type 'ImportMeta'.ts(2339)
// @ts-ignore
import.meta.env.BASE_URL,
'/cdn-cgi/image',
resizingParams.join(','),
imageSource
);
console.log(imageEndpoint);

return imageEndpoint;
},
Expand Down
3 changes: 1 addition & 2 deletions packages/cloudflare/src/getAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ export function getAdapter({
serverOutput: 'stable',
assets: {
supportKind: 'stable',
// FIXME: UNDO THIS BEFORE RELEASE
isSharpCompatible: true,
isSharpCompatible: false,
isSquooshCompatible: false,
},
};
Expand Down
8 changes: 6 additions & 2 deletions packages/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type CF_RUNTIME = { mode: 'off' } | { mode: 'remote' } | { mode: 'local'; persis
type Options = {
mode?: 'directory' | 'advanced';
functionPerRoute?: boolean;
imageService?: boolean;
/** Configure automatic `routes.json` generation */
routes?: {
/** Strategy for generating `include` and `exclude` patterns
Expand Down Expand Up @@ -134,14 +135,17 @@ export default function createIntegration(args?: Options): AstroIntegration {
},
image: imageConfigOverwrite
? { ...config.image, service: passthroughImageService() }
: {
: args?.imageService === true
? {
...config.image,
service:
command === 'dev'
? sharpImageService()
: {
entrypoint: '@astrojs/cloudflare/image-service',
},
},
}
: { ...config.image },
});
},
'astro:config:done': ({ setAdapter, config, logger }) => {
Expand Down
Loading

0 comments on commit 80693ff

Please sign in to comment.