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 {
|
interface GetImageResult {
|
||||||
|
rawOptions: ImageTransform;
|
||||||
options: ImageTransform;
|
options: ImageTransform;
|
||||||
src: string;
|
src: string;
|
||||||
attributes: Record<string, any>;
|
attributes: Record<string, any>;
|
||||||
|
@ -50,17 +51,21 @@ interface GetImageResult {
|
||||||
*/
|
*/
|
||||||
export async function getImage(options: ImageTransform): Promise<GetImageResult> {
|
export async function getImage(options: ImageTransform): Promise<GetImageResult> {
|
||||||
const service = await getConfiguredImageService();
|
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
|
// 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) {
|
if (isLocalService(service) && globalThis.astroAsset.addStaticImage) {
|
||||||
imageURL = globalThis.astroAsset.addStaticImage(options);
|
imageURL = globalThis.astroAsset.addStaticImage(validatedOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
options,
|
rawOptions: options,
|
||||||
|
options: validatedOptions,
|
||||||
src: imageURL,
|
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.
|
* 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>;
|
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;
|
export type ExternalImageService = SharedServiceProps;
|
||||||
|
@ -69,7 +78,7 @@ export type BaseServiceTransform = {
|
||||||
src: string;
|
src: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
format?: string | null;
|
format: string;
|
||||||
quality?: string | null;
|
quality?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -94,6 +103,45 @@ export type BaseServiceTransform = {
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export const baseService: Omit<LocalImageService, 'transform'> = {
|
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) {
|
getHTMLAttributes(options) {
|
||||||
let targetWidth = options.width;
|
let targetWidth = options.width;
|
||||||
let targetHeight = options.height;
|
let targetHeight = options.height;
|
||||||
|
@ -123,39 +171,11 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
getURL(options: ImageTransform) {
|
getURL(options: ImageTransform) {
|
||||||
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.
|
// Both our currently available local services don't handle remote images, so we return the path as is.
|
||||||
|
if (!isESMImportedImage(options.src)) {
|
||||||
return options.src;
|
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();
|
const searchParams = new URLSearchParams();
|
||||||
searchParams.append('href', options.src.src);
|
searchParams.append('href', options.src.src);
|
||||||
|
|
||||||
|
@ -177,7 +197,7 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
|
||||||
src: params.get('href')!,
|
src: params.get('href')!,
|
||||||
width: params.has('w') ? parseInt(params.get('w')!) : undefined,
|
width: params.has('w') ? parseInt(params.get('w')!) : undefined,
|
||||||
height: params.has('h') ? parseInt(params.get('h')!) : 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'),
|
quality: params.get('q'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -23,19 +23,14 @@ async function loadSharp() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const sharpService: LocalImageService = {
|
const sharpService: LocalImageService = {
|
||||||
|
validateOptions: baseService.validateOptions,
|
||||||
getURL: baseService.getURL,
|
getURL: baseService.getURL,
|
||||||
parseURL: baseService.parseURL,
|
parseURL: baseService.parseURL,
|
||||||
getHTMLAttributes: baseService.getHTMLAttributes,
|
getHTMLAttributes: baseService.getHTMLAttributes,
|
||||||
async transform(inputBuffer, transformOptions) {
|
async transform(inputBuffer, transformOptions) {
|
||||||
if (!sharp) sharp = await loadSharp();
|
if (!sharp) sharp = await loadSharp();
|
||||||
|
|
||||||
const transform: BaseServiceTransform = transformOptions;
|
const transform: BaseServiceTransform = transformOptions as BaseServiceTransform;
|
||||||
|
|
||||||
// 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';
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = sharp(inputBuffer, { failOnError: false, pages: -1 });
|
let result = sharp(inputBuffer, { failOnError: false, pages: -1 });
|
||||||
|
|
||||||
|
|
|
@ -21,16 +21,14 @@ const qualityTable: Record<Exclude<OutputFormat, 'png'>, Record<ImageQualityPres
|
||||||
};
|
};
|
||||||
|
|
||||||
const service: LocalImageService = {
|
const service: LocalImageService = {
|
||||||
|
validateOptions: baseService.validateOptions,
|
||||||
getURL: baseService.getURL,
|
getURL: baseService.getURL,
|
||||||
parseURL: baseService.parseURL,
|
parseURL: baseService.parseURL,
|
||||||
getHTMLAttributes: baseService.getHTMLAttributes,
|
getHTMLAttributes: baseService.getHTMLAttributes,
|
||||||
async transform(inputBuffer, transformOptions) {
|
async transform(inputBuffer, transformOptions) {
|
||||||
const transform: BaseServiceTransform = transformOptions as BaseServiceTransform;
|
const transform: BaseServiceTransform = transformOptions as BaseServiceTransform;
|
||||||
|
|
||||||
let format = transform.format;
|
let format = transform.format!;
|
||||||
if (!format) {
|
|
||||||
format = 'webp';
|
|
||||||
}
|
|
||||||
|
|
||||||
const operations: Operation[] = [];
|
const operations: Operation[] = [];
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,7 @@ import type { Plugin } from 'vite';
|
||||||
import { normalizePath } from 'vite';
|
import { normalizePath } from 'vite';
|
||||||
import type { AstroSettings } from '../@types/astro';
|
import type { AstroSettings } from '../@types/astro';
|
||||||
import { imageMetadata } from '../assets/index.js';
|
import { imageMetadata } from '../assets/index.js';
|
||||||
|
import type { ImageService } from '../assets/services/service';
|
||||||
import imageSize from '../assets/vendor/image-size/index.js';
|
import imageSize from '../assets/vendor/image-size/index.js';
|
||||||
import { AstroError, AstroErrorData, MarkdownError } from '../core/errors/index.js';
|
import { AstroError, AstroErrorData, MarkdownError } from '../core/errors/index.js';
|
||||||
import type { LogOptions } from '../core/logger/core.js';
|
import type { LogOptions } from '../core/logger/core.js';
|
||||||
|
@ -62,6 +63,8 @@ const astroJsxRuntimeModulePath = normalizePath(
|
||||||
export default function markdown({ settings, logging }: AstroPluginOptions): Plugin {
|
export default function markdown({ settings, logging }: AstroPluginOptions): Plugin {
|
||||||
const markdownAssetMap = new Map<string, string>();
|
const markdownAssetMap = new Map<string, string>();
|
||||||
|
|
||||||
|
let imageService: ImageService | undefined = undefined;
|
||||||
|
|
||||||
async function resolveImage(this: PluginContext, fileId: string, path: string) {
|
async function resolveImage(this: PluginContext, fileId: string, path: string) {
|
||||||
const resolved = await this.resolve(path, fileId);
|
const resolved = await this.resolve(path, fileId);
|
||||||
if (!resolved) return path;
|
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 rawFile = await fs.promises.readFile(fileId, 'utf-8');
|
||||||
const raw = safeMatter(rawFile, id);
|
const raw = safeMatter(rawFile, id);
|
||||||
|
|
||||||
let imageService = undefined;
|
|
||||||
if (settings.config.experimental.assets) {
|
if (settings.config.experimental.assets) {
|
||||||
imageService = (await import(settings.config.image.service)).default;
|
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);
|
const fileName = this.getFileName(hash);
|
||||||
image.src = npath.join(settings.config.base, fileName);
|
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);
|
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);
|
const optimizedName = optimizedPaths.get(hash);
|
||||||
return optimizedName || this.getFileName(hash);
|
return optimizedName || this.getFileName(hash);
|
||||||
});
|
});
|
||||||
|
|
|
@ -308,6 +308,13 @@ describe('astro:image', () => {
|
||||||
expect(data).to.be.an.instanceOf(Buffer);
|
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 () => {
|
it('getImage() usage also written', async () => {
|
||||||
const html = await fixture.readFile('/get-image/index.html');
|
const html = await fixture.readFile('/get-image/index.html');
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
import squoosh from 'astro/assets/services/squoosh';
|
import squoosh from 'astro/assets/services/squoosh';
|
||||||
|
|
||||||
const service = {
|
const service = {
|
||||||
|
validateOptions(options) {
|
||||||
|
return squoosh.validateOptions(options);
|
||||||
|
},
|
||||||
getURL(options) {
|
getURL(options) {
|
||||||
return squoosh.getURL(options);
|
return squoosh.getURL(options);
|
||||||
},
|
},
|
||||||
|
|
|
@ -38,11 +38,15 @@ export function rehypeImages(imageService: any, assetsDir: URL | undefined, getI
|
||||||
alt: node.properties.alt,
|
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, {
|
node.properties = Object.assign(node.properties, {
|
||||||
src: imageURL,
|
src: imageURL,
|
||||||
...(imageService.getHTMLAttributes !== undefined
|
...(imageService.getHTMLAttributes !== undefined
|
||||||
? imageService.getHTMLAttributes(options)
|
? imageService.getHTMLAttributes(validatedOptions)
|
||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue