diff --git a/documentation/docs/30-advanced/60-images.md b/documentation/docs/30-advanced/60-images.md index 6471eb68b45e..b5726f59ef55 100644 --- a/documentation/docs/30-advanced/60-images.md +++ b/documentation/docs/30-advanced/60-images.md @@ -133,13 +133,77 @@ By default, enhanced images will be transformed to more efficient formats. Howev [See the imagetools repo for the full list of directives](https://github.com/JonasKruckenberg/imagetools/blob/main/docs/directives.md). -## Loading images dynamically from a CDN +## `$app/images` -In some cases, the images may not be accessible at build time — e.g. they may live inside a content management system or elsewhere. +In some cases, the images may not be accessible at build time — e.g. they may live inside a content management system or elsewhere. SvelteKit provides a helper through `$app/images` to make it possible to load these images using best practices. In its simplest form, you pass an image url and the intrinsic width and height of the image to `getImage` and spread the result onto an `img` tag: -Using a content delivery network (CDN) can allow you to optimize these images dynamically, and provides more flexibility with regards to sizes, but it may involve some setup overhead and usage costs. Depending on caching strategy, the browser may not be able to use a cached copy of the asset until a [304 response](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/304) is received from the CDN. Building HTML to target CDNs may result in slightly smaller and simpler HTML because they can serve the appropriate file format for an `` tag based on the `User-Agent` header whereas build-time optimizations must produce `` tags with multiple sources. Finally, some CDNs may generate images lazily, which could have a negative performance impact for sites with low traffic and frequently changing images. +```svelte + + +An alt text +``` + +The result in this case will be generated `src` and `srcset` attributes. The `srcset` will contain two resolution variants and the browser decides based on the device's pixel density which image to load. + +If you provide the `sizes` property or set `layout: "fill"` instead of `width` and `height`, the `srcset` will instead contain a list of URLs for various device widths and the browser will choose the appropriate one. + +```svelte + + +An alt text +``` + +Remember to give the largest image above the fold (i.e. the largest one that is immediately visible) a `fetchpriority` of `high` to ensure browsers download it earlier and set `loading: 'lazy'` for unimportant images, which both contributes to better web vital scores. + +```svelte + + +An alt text +``` + +Because `getImage` is just a simple function, you can wrap it in a function to set sensible defaults for your app, or use it as a base for a custom image component. + +### Configurating an image loader + +`getImage` calculates the variants for an image based on its input and the `kit.images.width` configuration, which contains an array of numbers corresponding to various screen widths. For each variant, the configured image loader is invoked when running in production - at dev time, the loader is not invoked to prevent accidental image CDN costs. + +By default, no loader is configured and as such the image url is handed back without any modifications. To configure an image loader, pass a string to `kit.images.loader` which represents a path to a file on disk (either a relative path, or referencing an npm package). Some adapters like [`adapter-vercel`](adapter-vercel) provide ready-made loaders. It's also very easy to create your own loader. Here's an example using [`unpic`](https://unpic.pics/lib/), which provides solutions for a wide range of image CDNs: + +```js +/// file: custom-loader.js +// @filename: ambient.d.ts +declare module 'unpic/transformers/shopify' { + export function transform(args: any): string; +} + +// @filename: index.js +// ---cut--- +// This example uses shopify as the target image CDN +import { transform } from 'unpic/transformers/shopify'; + +/** + * @param {string} url + * @param {number} width + * @param {Record} options + */ +export default function loader(url, width, options) { + return transform({ url, width, ...options }); +} +``` + +A loader optionally takes a third parameter which corresponds to the `options` property passed to `getImage`. This allows for more control over the image transformation process, if needed. + +### Other considerations + +Using a content delivery network (CDN) can allow you to optimize images dynamically, and provides more flexibility with regards to sizes, but it may involve some setup overhead and usage costs. Depending on caching strategy, the browser may not be able to use a cached copy of the asset until a [304 response](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/304) is received from the CDN. Building HTML to target CDNs may result in slightly smaller and simpler HTML because they can serve the appropriate file format for an `` tag based on the `User-Agent` header whereas build-time optimizations must produce `` tags with multiple sources. Finally, some CDNs may generate images lazily, which could have a negative performance impact for sites with low traffic and frequently changing images. -CDNs can generally be used without any need for a library. However, there are a number of libraries with Svelte support that make it easier. [`@unpic/svelte`](https://unpic.pics/img/svelte/) is a CDN-agnostic library with support for a large number of providers. You may also find that specific CDNs like [Cloudinary](https://svelte.cloudinary.dev/) have Svelte support. Finally, some content management systems (CMS) which support Svelte (such as [Contentful](https://www.contentful.com/sveltekit-starter-guide/), [Storyblok](https://github.com/storyblok/storyblok-svelte), and [Contentstack](https://www.contentstack.com/docs/developers/sample-apps/build-a-starter-website-with-sveltekit-and-contentstack)) have built-in support for image handling. +CDNs can generally be used without any need for a library, but calling the CDN and ensuring that smaller images are called for smaller screens is generally up to you. Besides using `$app/images`, there are a number of libraries with Svelte support that make it easier. [`@unpic/svelte`](https://unpic.pics/img/svelte/) is a CDN-agnostic library with support for a large number of providers. You may also find that specific CDNs like [Cloudinary](https://svelte.cloudinary.dev/) have Svelte support. Finally, some content management systems (CMS) which support Svelte (such as [Contentful](https://www.contentful.com/sveltekit-starter-guide/), [Storyblok](https://github.com/storyblok/storyblok-svelte), and [Contentstack](https://www.contentstack.com/docs/developers/sample-apps/build-a-starter-website-with-sveltekit-and-contentstack)) have built-in support for image handling. ## Best practices diff --git a/packages/adapter-vercel/image-loader.d.ts b/packages/adapter-vercel/image-loader.d.ts new file mode 100644 index 000000000000..894a467daced --- /dev/null +++ b/packages/adapter-vercel/image-loader.d.ts @@ -0,0 +1,8 @@ +/** + * https://vercel.com/docs/concepts/image-optimization + */ +export default function loader( + src: string, + width: number, + image_options?: { quality?: number } +): string; diff --git a/packages/adapter-vercel/image-loader.js b/packages/adapter-vercel/image-loader.js new file mode 100644 index 000000000000..c2bd7c0dc3bd --- /dev/null +++ b/packages/adapter-vercel/image-loader.js @@ -0,0 +1,55 @@ +// https://vercel.com/docs/concepts/image-optimization + +/** + * @param {string} src + * @param {number} width + * @param {{ quality?: number }} [image_options] + */ +export default function loader(src, width, image_options) { + const url = new URL(src, 'http://n'); // If the base is a relative URL, we need to add a dummy host to the URL + + if (url.pathname === '/_vercel/image') { + set_param(url, 'w', width); + if (image_options?.quality) { + set_param(url, 'q', image_options.quality, false); + } + } else { + url.pathname = '_vercel/image'; + set_param(url, 'url', src); + set_param(url, 'w', width); + if (image_options?.quality) { + set_param(url, 'q', image_options.quality); + } + } + + return src === url.href ? url.href : relative_url(url); +} + +/** + * @param {URL} url + */ +function relative_url(url) { + const { pathname, search } = url; + return `${pathname}${search}`; +} +/** + * @param {URL} url + * @param {string} param + * @param {any} value + * @param {boolean} [override] + */ +function set_param(url, param, value, override = true) { + if (value === undefined) { + return; + } + + if (value === null) { + if (override || url.searchParams.has(param)) { + url.searchParams.delete(param); + } + } else { + if (override || !url.searchParams.has(param)) { + url.searchParams.set(param, value); + } + } +} diff --git a/packages/adapter-vercel/index.d.ts b/packages/adapter-vercel/index.d.ts index 8e3a96bec132..6b13e21ee243 100644 --- a/packages/adapter-vercel/index.d.ts +++ b/packages/adapter-vercel/index.d.ts @@ -1,7 +1,42 @@ import { Adapter } from '@sveltejs/kit'; import './ambient.js'; -export default function plugin(config?: Config): Adapter; +export default function plugin( + config?: Config & { + /** + * Enable or disable Vercel's image optimization. This is enabled by default if you have + * defined the Vercel loader in `kit.images.loader`, else disabled by default. + * https://vercel.com/docs/concepts/image-optimization + */ + images?: ImageConfig; + } +): Adapter; + +/** + * Define how Vercel should optimize your images. Links to the documentation: + * - https://vercel.com/docs/concepts/image-optimization + * - https://vercel.com/docs/build-output-api/v3/configuration#images + */ +export interface ImageConfig { + /** Only set this if you're not using SvelteKit's `getImage` from `$app/images` */ + sizes?: number[]; + domains?: string[]; + remotePatterns?: RemotePattern[]; + minimumCacheTTL?: number; + formats?: ImageFormat[]; + dangerouslyAllowSVG?: boolean; + contentSecurityPolicy?: string; + contentDispositionType?: string; +} + +type ImageFormat = 'image/avif' | 'image/webp'; + +type RemotePattern = { + protocol?: 'http' | 'https'; + hostname: string; + port?: string; + pathname?: string; +}; export interface ServerlessConfig { /** diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index 40fe02c2b0d9..f42ddad0e63c 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -54,7 +54,7 @@ const plugin = function (defaults = {}) { functions: `${dir}/functions` }; - const static_config = static_vercel_config(builder); + const static_config = static_vercel_config(builder, defaults.images); builder.log.minor('Generating serverless function...'); @@ -366,9 +366,12 @@ function write(file, data) { fs.writeFileSync(file, data); } -// This function is duplicated in adapter-static -/** @param {import('@sveltejs/kit').Builder} builder */ -function static_vercel_config(builder) { +// This function is mostly duplicated in adapter-static +/** + * @param {import('@sveltejs/kit').Builder} builder + * @param {import('./index.js').ImageConfig | undefined} + */ +function static_vercel_config(builder, image_config) { /** @type {any[]} */ const prerendered_redirects = []; @@ -406,8 +409,21 @@ function static_vercel_config(builder) { overrides[page.file] = { path: overrides_path }; } + /** @type {Record | undefined} */ + let images = undefined; + const img_config = builder.config.kit.images; + const is_vercel_loader = img_config.loader === '@sveltejs/adapter-vercel/image-loader'; + if (image_config || is_vercel_loader) { + images = { + ...image_config, + sizes: is_vercel_loader ? img_config.widths : image_config?.sizes ?? img_config.widths + // TODO should we set some defaults? + }; + } + return { version: 3, + images, routes: [ ...prerendered_redirects, { diff --git a/packages/adapter-vercel/package.json b/packages/adapter-vercel/package.json index e8b8b139a7f7..aa514764024c 100644 --- a/packages/adapter-vercel/package.json +++ b/packages/adapter-vercel/package.json @@ -15,6 +15,10 @@ "types": "./index.d.ts", "import": "./index.js" }, + "./image-loader": { + "types": "./image-loader.d.ts", + "import": "./image-loader.js" + }, "./package.json": "./package.json" }, "types": "index.d.ts", diff --git a/packages/kit/scripts/generate-dts.js b/packages/kit/scripts/generate-dts.js index 8eeffb5b2f82..cc6b2f5c4c01 100644 --- a/packages/kit/scripts/generate-dts.js +++ b/packages/kit/scripts/generate-dts.js @@ -10,6 +10,7 @@ createBundle({ '@sveltejs/kit/vite': 'src/exports/vite/index.js', '$app/environment': 'src/runtime/app/environment.js', '$app/forms': 'src/runtime/app/forms.js', + '$app/images': 'src/runtime/app/images.js', '$app/navigation': 'src/runtime/app/navigation.js', '$app/paths': 'src/runtime/app/paths.js', '$app/stores': 'src/runtime/app/stores.js' diff --git a/packages/kit/src/core/config/index.js b/packages/kit/src/core/config/index.js index a6e37d794277..f39553140d5c 100644 --- a/packages/kit/src/core/config/index.js +++ b/packages/kit/src/core/config/index.js @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import * as url from 'node:url'; import options from './options.js'; +import { posixify } from '../../utils/filesystem.js'; /** * Loads the template (src/app.html by default) and validates that it has the @@ -91,6 +92,11 @@ function process_config(config, { cwd = process.cwd() } = {}) { } } + // Resolve relative loader urls + if (validated.kit.images.loader[0] === '.') { + validated.kit.images.loader = posixify(path.resolve(cwd, validated.kit.images.loader)); + } + return validated; } diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index faaf138aebb3..e4fa14c51a2f 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -91,6 +91,10 @@ const get_defaults = (prefix = '') => ({ appTemplate: join(prefix, 'src/app.html'), errorTemplate: join(prefix, 'src/error.html') }, + images: { + loader: '', + widths: [48, 128, 256, 540, 768, 1080, 1366, 1536, 1920, 2560, 3000, 4096, 5120] + }, inlineStyleThreshold: 0, moduleExtensions: ['.js', '.ts'], output: { preloadStrategy: 'modulepreload' }, diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index 2d0c928d9290..56a760b45451 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -138,6 +138,13 @@ const options = object( errorTemplate: string(join('src', 'error.html')) }), + images: object({ + loader: string(''), + widths: number_array([ + 48, 128, 256, 540, 768, 1080, 1366, 1536, 1920, 2560, 3000, 4096, 5120 + ]) + }), + inlineStyleThreshold: number(0), moduleExtensions: string_array(['.js', '.ts']), @@ -354,6 +361,20 @@ function string(fallback, allow_empty = true) { }); } +/** + * @param {number[] | undefined} [fallback] + * @returns {Validator} + */ +function number_array(fallback) { + return validate(fallback, (input, keypath) => { + if (!Array.isArray(input) || input.some((value) => typeof value !== 'number')) { + throw new Error(`${keypath} must be an array of numbers, if specified`); + } + + return input; + }); +} + /** * @param {string[] | undefined} [fallback] * @returns {Validator} diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 77f15a391898..4889e216bfde 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -430,6 +430,27 @@ export interface KitConfig { */ errorTemplate?: string; }; + /** + * Image optimization configuration + */ + images?: { + /** + * Path to a module that contains a loader that will be used to generate the an image URL out of the given source and width. + * It optionally also takes third parameter for options. Can be either a relative path or a reference to an npm packe. + * + * ```ts + * export default function loader(src: string, width: number, opts: any) { + * return `https://example.com/${src}?w=${width}&q=${opts.quality || 75}`; + * } + * ``` + */ + loader: string; + /** + * Which srcset sizes to generate + * @default [48, 128, 256, 540, 768, 1080, 1366, 1536, 1920, 2560, 3000, 4096, 5120] + */ + widths?: number[]; + }; /** * Inline CSS inside a `