Add validateOptions hook to Image Service API (#6555)
* feat(assets): Add a validateOptions hooks to set default and do error handling * chore: changeset
This commit is contained in:
parent
f413446a85
commit
f5fddafc24
9 changed files with 99 additions and 51 deletions
6
.changeset/tidy-dryers-add.md
Normal file
6
.changeset/tidy-dryers-add.md
Normal file
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
'astro': patch
|
||||
'@astrojs/markdown-remark': patch
|
||||
---
|
||||
|
||||
Add a `validateOptions` hook to the Image Service API in order to set default options and validate the passed options
|
|
@ -27,6 +27,7 @@ export async function getConfiguredImageService(): Promise<ImageService> {
|
|||
}
|
||||
|
||||
interface GetImageResult {
|
||||
rawOptions: ImageTransform;
|
||||
options: ImageTransform;
|
||||
src: string;
|
||||
attributes: Record<string, any>;
|
||||
|
@ -50,17 +51,21 @@ interface GetImageResult {
|
|||
*/
|
||||
export async function getImage(options: ImageTransform): Promise<GetImageResult> {
|
||||
const service = await getConfiguredImageService();
|
||||
let imageURL = service.getURL(options);
|
||||
const validatedOptions = service.validateOptions ? service.validateOptions(options) : options;
|
||||
|
||||
let imageURL = service.getURL(validatedOptions);
|
||||
|
||||
// In build and for local services, we need to collect the requested parameters so we can generate the final images
|
||||
if (isLocalService(service) && globalThis.astroAsset.addStaticImage) {
|
||||
imageURL = globalThis.astroAsset.addStaticImage(options);
|
||||
imageURL = globalThis.astroAsset.addStaticImage(validatedOptions);
|
||||
}
|
||||
|
||||
return {
|
||||
options,
|
||||
rawOptions: options,
|
||||
options: validatedOptions,
|
||||
src: imageURL,
|
||||
attributes: service.getHTMLAttributes !== undefined ? service.getHTMLAttributes(options) : {},
|
||||
attributes:
|
||||
service.getHTMLAttributes !== undefined ? service.getHTMLAttributes(validatedOptions) : {},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -39,6 +39,15 @@ interface SharedServiceProps {
|
|||
* In most cases, you'll want to return directly what your user supplied you, minus the attributes that were used to generate the image.
|
||||
*/
|
||||
getHTMLAttributes?: (options: ImageTransform) => Record<string, any>;
|
||||
/**
|
||||
* Validate and return the options passed by the user.
|
||||
*
|
||||
* This method is useful to present errors to users who have entered invalid options.
|
||||
* For instance, if they are missing a required property or have entered an invalid image format.
|
||||
*
|
||||
* This method should returns options, and can be used to set defaults (ex: a default output format to be used if the user didn't specify one.)
|
||||
*/
|
||||
validateOptions?: (options: ImageTransform) => ImageTransform;
|
||||
}
|
||||
|
||||
export type ExternalImageService = SharedServiceProps;
|
||||
|
@ -69,7 +78,7 @@ export type BaseServiceTransform = {
|
|||
src: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
format?: string | null;
|
||||
format: string;
|
||||
quality?: string | null;
|
||||
};
|
||||
|
||||
|
@ -94,6 +103,45 @@ export type BaseServiceTransform = {
|
|||
*
|
||||
*/
|
||||
export const baseService: Omit<LocalImageService, 'transform'> = {
|
||||
validateOptions(options) {
|
||||
if (!isESMImportedImage(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 AstroError({
|
||||
...AstroErrorData.MissingImageDimension,
|
||||
message: AstroErrorData.MissingImageDimension.message(missingDimension, options.src),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (!VALID_INPUT_FORMATS.includes(options.src.format as any)) {
|
||||
throw new AstroError({
|
||||
...AstroErrorData.UnsupportedImageFormat,
|
||||
message: AstroErrorData.UnsupportedImageFormat.message(
|
||||
options.src.format,
|
||||
options.src.src,
|
||||
VALID_INPUT_FORMATS
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = 'webp';
|
||||
}
|
||||
|
||||
return options;
|
||||
},
|
||||
getHTMLAttributes(options) {
|
||||
let targetWidth = options.width;
|
||||
let targetHeight = options.height;
|
||||
|
@ -123,39 +171,11 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
|
|||
};
|
||||
},
|
||||
getURL(options: ImageTransform) {
|
||||
// Both our currently available local services don't handle remote images, so we return the path as is.
|
||||
if (!isESMImportedImage(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 AstroError({
|
||||
...AstroErrorData.MissingImageDimension,
|
||||
message: AstroErrorData.MissingImageDimension.message(missingDimension, options.src),
|
||||
});
|
||||
}
|
||||
|
||||
// Both our currently available local services don't handle remote images, so we return the path as is.
|
||||
return options.src;
|
||||
}
|
||||
|
||||
if (!VALID_INPUT_FORMATS.includes(options.src.format as any)) {
|
||||
throw new AstroError({
|
||||
...AstroErrorData.UnsupportedImageFormat,
|
||||
message: AstroErrorData.UnsupportedImageFormat.message(
|
||||
options.src.format,
|
||||
options.src.src,
|
||||
VALID_INPUT_FORMATS
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.append('href', options.src.src);
|
||||
|
||||
|
@ -177,7 +197,7 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
|
|||
src: params.get('href')!,
|
||||
width: params.has('w') ? parseInt(params.get('w')!) : undefined,
|
||||
height: params.has('h') ? parseInt(params.get('h')!) : undefined,
|
||||
format: params.get('f') as OutputFormat | null,
|
||||
format: params.get('f') as OutputFormat,
|
||||
quality: params.get('q'),
|
||||
};
|
||||
|
||||
|
|
|
@ -23,19 +23,14 @@ async function loadSharp() {
|
|||
}
|
||||
|
||||
const sharpService: LocalImageService = {
|
||||
validateOptions: baseService.validateOptions,
|
||||
getURL: baseService.getURL,
|
||||
parseURL: baseService.parseURL,
|
||||
getHTMLAttributes: baseService.getHTMLAttributes,
|
||||
async transform(inputBuffer, transformOptions) {
|
||||
if (!sharp) sharp = await loadSharp();
|
||||
|
||||
const transform: BaseServiceTransform = transformOptions;
|
||||
|
||||
// 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 (!transform.format) {
|
||||
transform.format = 'webp';
|
||||
}
|
||||
const transform: BaseServiceTransform = transformOptions as BaseServiceTransform;
|
||||
|
||||
let result = sharp(inputBuffer, { failOnError: false, pages: -1 });
|
||||
|
||||
|
|
|
@ -21,16 +21,14 @@ const qualityTable: Record<Exclude<OutputFormat, 'png'>, Record<ImageQualityPres
|
|||
};
|
||||
|
||||
const service: LocalImageService = {
|
||||
validateOptions: baseService.validateOptions,
|
||||
getURL: baseService.getURL,
|
||||
parseURL: baseService.parseURL,
|
||||
getHTMLAttributes: baseService.getHTMLAttributes,
|
||||
async transform(inputBuffer, transformOptions) {
|
||||
const transform: BaseServiceTransform = transformOptions as BaseServiceTransform;
|
||||
|
||||
let format = transform.format;
|
||||
if (!format) {
|
||||
format = 'webp';
|
||||
}
|
||||
let format = transform.format!;
|
||||
|
||||
const operations: Operation[] = [];
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ import type { Plugin } from 'vite';
|
|||
import { normalizePath } from 'vite';
|
||||
import type { AstroSettings } from '../@types/astro';
|
||||
import { imageMetadata } from '../assets/index.js';
|
||||
import type { ImageService } from '../assets/services/service';
|
||||
import imageSize from '../assets/vendor/image-size/index.js';
|
||||
import { AstroError, AstroErrorData, MarkdownError } from '../core/errors/index.js';
|
||||
import type { LogOptions } from '../core/logger/core.js';
|
||||
|
@ -62,6 +63,8 @@ const astroJsxRuntimeModulePath = normalizePath(
|
|||
export default function markdown({ settings, logging }: AstroPluginOptions): Plugin {
|
||||
const markdownAssetMap = new Map<string, string>();
|
||||
|
||||
let imageService: ImageService | undefined = undefined;
|
||||
|
||||
async function resolveImage(this: PluginContext, fileId: string, path: string) {
|
||||
const resolved = await this.resolve(path, fileId);
|
||||
if (!resolved) return path;
|
||||
|
@ -93,7 +96,6 @@ export default function markdown({ settings, logging }: AstroPluginOptions): Plu
|
|||
const rawFile = await fs.promises.readFile(fileId, 'utf-8');
|
||||
const raw = safeMatter(rawFile, id);
|
||||
|
||||
let imageService = undefined;
|
||||
if (settings.config.experimental.assets) {
|
||||
imageService = (await import(settings.config.image.service)).default;
|
||||
}
|
||||
|
@ -221,10 +223,18 @@ export default function markdown({ settings, logging }: AstroPluginOptions): Plu
|
|||
}
|
||||
const fileName = this.getFileName(hash);
|
||||
image.src = npath.join(settings.config.base, fileName);
|
||||
const optimized = globalThis.astroAsset.addStaticImage!({ src: image });
|
||||
|
||||
// TODO: This part recreates code we already have for content collection and normal ESM imports.
|
||||
// It might be possible to refactor so it also uses `emitESMImage`? - erika, 2023-03-15
|
||||
const options = { src: image };
|
||||
const validatedOptions = imageService?.validateOptions
|
||||
? imageService.validateOptions(options)
|
||||
: options;
|
||||
|
||||
const optimized = globalThis.astroAsset.addStaticImage!(validatedOptions);
|
||||
optimizedPaths.set(hash, optimized);
|
||||
}
|
||||
output.code = output.code.replace(/ASTRO_ASSET_MD_([0-9a-z]{8})/, (_str, hash) => {
|
||||
output.code = output.code.replaceAll(/ASTRO_ASSET_MD_([0-9a-z]{8})/gm, (_str, hash) => {
|
||||
const optimizedName = optimizedPaths.get(hash);
|
||||
return optimizedName || this.getFileName(hash);
|
||||
});
|
||||
|
|
|
@ -308,6 +308,13 @@ describe('astro:image', () => {
|
|||
expect(data).to.be.an.instanceOf(Buffer);
|
||||
});
|
||||
|
||||
it('writes out images to dist folder with proper extension if no format was passed', async () => {
|
||||
const html = await fixture.readFile('/index.html');
|
||||
const $ = cheerio.load(html);
|
||||
const src = $('#local img').attr('src');
|
||||
expect(src.endsWith('.webp')).to.be.true;
|
||||
});
|
||||
|
||||
it('getImage() usage also written', async () => {
|
||||
const html = await fixture.readFile('/get-image/index.html');
|
||||
const $ = cheerio.load(html);
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
import squoosh from 'astro/assets/services/squoosh';
|
||||
|
||||
const service = {
|
||||
validateOptions(options) {
|
||||
return squoosh.validateOptions(options);
|
||||
},
|
||||
getURL(options) {
|
||||
return squoosh.getURL(options);
|
||||
},
|
||||
|
|
|
@ -38,11 +38,15 @@ export function rehypeImages(imageService: any, assetsDir: URL | undefined, getI
|
|||
alt: node.properties.alt,
|
||||
};
|
||||
|
||||
const imageURL = imageService.getURL(options);
|
||||
const validatedOptions = imageService.validateOptions
|
||||
? imageService.validateOptions(options)
|
||||
: options;
|
||||
|
||||
const imageURL = imageService.getURL(validatedOptions);
|
||||
node.properties = Object.assign(node.properties, {
|
||||
src: imageURL,
|
||||
...(imageService.getHTMLAttributes !== undefined
|
||||
? imageService.getHTMLAttributes(options)
|
||||
? imageService.getHTMLAttributes(validatedOptions)
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue