0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2024-12-23 21:53:55 -05:00

@astrojs/image: add a background option/prop to replace the alpha layer (#4642)

* Added `background` option and prop.
This optional color specifies which background to use when removing the
alpha channel if the output format doesn't support transparency.

* Modified existing tests

* Fixed wrong dimensions in tests

* Fixing a few instances of jpeg vs jpg

* Added color checking

* working on the tests

* tests are now passing

* Adding tests

* Added tests for background color

* no need to test with subpath

* Added fixture

* Renamed test fixture for background-color

* skipping test until fixed

* Typo

* Working on tests

* tests are passing

* Updated readme and added changeset

* Updated lockfile

* Updated lockfile

* Updated lockfile

Co-authored-by: Tony Sullivan <tony.f.sullivan@outlook.com>
This commit is contained in:
Valentin Bersier 2022-09-07 19:22:11 +02:00 committed by GitHub
parent 93c3aee01c
commit e4348a4eb4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 1623 additions and 1012 deletions

View file

@ -0,0 +1,5 @@
---
'@astrojs/image': minor
---
Added a `background` option to specify a background color to replace transparent pixels (alpha layer).

View file

@ -24,9 +24,9 @@ This integration provides `<Image />` and `<Picture>` components as well as a ba
### Quick Install ### Quick Install
The `astro add` command-line tool automates the installation for you. Run one of the following commands in a new terminal window. (If you aren't sure which package manager you're using, run the first command.) Then, follow the prompts, and type "y" in the terminal (meaning "yes") for each one. The `astro add` command-line tool automates the installation for you. Run one of the following commands in a new terminal window. (If you aren't sure which package manager you're using, run the first command.) Then, follow the prompts, and type "y" in the terminal (meaning "yes") for each one.
```sh ```sh
# Using NPM # Using NPM
npx astro add image npx astro add image
@ -35,13 +35,13 @@ yarn astro add image
# Using PNPM # Using PNPM
pnpm astro add image pnpm astro add image
``` ```
Finally, in the terminal window running Astro, press `CTRL+C` and then restart the dev server. Finally, in the terminal window running Astro, press `CTRL+C` and then restart the dev server.
If you run into any issues, [feel free to report them to us on GitHub](https://github.com/withastro/astro/issues) and try the manual installation steps below. If you run into any issues, [feel free to report them to us on GitHub](https://github.com/withastro/astro/issues) and try the manual installation steps below.
### Manual Install ### Manual Install
First, install the `@astrojs/image` package using your package manager. If you're using npm or aren't sure, run this in the terminal: First, install the `@astrojs/image` package using your package manager. If you're using npm or aren't sure, run this in the terminal:
```sh ```sh
npm install @astrojs/image npm install @astrojs/image
@ -57,7 +57,7 @@ export default {
// ... // ...
integrations: [image()], integrations: [image()],
} }
``` ```
Then, restart the dev server. Then, restart the dev server.
### Update `env.d.ts` ### Update `env.d.ts`
@ -190,6 +190,24 @@ A `string` can be provided in the form of `{width}:{height}`, ex: `16:9` or `3:4
A `number` can also be provided, useful when the aspect ratio is calculated at build time. This can be an inline number such as `1.777` or inlined as a JSX expression like `aspectRatio={16/9}`. A `number` can also be provided, useful when the aspect ratio is calculated at build time. This can be an inline number such as `1.777` or inlined as a JSX expression like `aspectRatio={16/9}`.
#### background
<p>
**Type:** `ColorDefinition`<br>
**Default:** `undefined`
</p>
The background color to use for replacing the alpha channel with `sharp`'s `flatten` method. In case the output format
doesn't support transparency (i.e. `jpeg`), it's advisable to include a background color, otherwise black will be used
as default replacement for transparent pixels.
The parameter accepts a `string` as value.
The parameter can be a [named HTML color](https://www.w3schools.com/tags/ref_colornames.asp), a hexadecimal
color representation with 3 or 6 hexadecimal characters in the form `#123[abc]`, or an RGB definition in the form
`rgb(100,100,100)`.
### `<Picture /`> ### `<Picture /`>
#### src #### src
@ -271,6 +289,24 @@ A `number` can also be provided, useful when the aspect ratio is calculated at b
The output formats to be used in the optimized image. If not provided, `webp` and `avif` will be used in addition to the original image format. The output formats to be used in the optimized image. If not provided, `webp` and `avif` will be used in addition to the original image format.
#### background
<p>
**Type:** `ColorDefinition`<br>
**Default:** `undefined`
</p>
The background color to use for replacing the alpha channel with `sharp`'s `flatten` method. In case the output format
doesn't support transparency (i.e. `jpeg`), it's advisable to include a background color, otherwise black will be used
as default replacement for transparent pixels.
The parameter accepts a `string` as value.
The parameter can be a [named HTML color](https://www.w3schools.com/tags/ref_colornames.asp), a hexadecimal
color representation with 3 or 6 hexadecimal characters in the form `#123[abc]`, or an RGB definition in the form
`rgb(100,100,100)`.
### `getImage` ### `getImage`
This is the helper function used by the `<Image />` component to build `<img />` attributes for the transformed image. This helper can be used directly for more complex use cases that aren't currently supported by the `<Image />` component. This is the helper function used by the `<Image />` component to build `<img />` attributes for the transformed image. This helper can be used directly for more complex use cases that aren't currently supported by the `<Image />` component.
@ -307,7 +343,7 @@ The integration can be configured to run with a different image service, either
### config.serviceEntryPoint ### config.serviceEntryPoint
The `serviceEntryPoint` should resolve to the image service installed from NPM. The default entry point is `@astrojs/image/sharp`, which resolves to the entry point exported from this integration's `package.json`. The `serviceEntryPoint` should resolve to the image service installed from NPM. The default entry point is `@astrojs/image/sharp`, which resolves to the entry point exported from this integration's `package.json`.
```js ```js
@ -342,7 +378,7 @@ export default {
## Examples ## Examples
### Local images ### Local images
Image files in your project's `src` directory can be imported in frontmatter and passed directly to the `<Image />` component. All other properties are optional and will default to the original image file's properties if not provided. Image files in your project's `src` directory can be imported in frontmatter and passed directly to the `<Image />` component. All other properties are optional and will default to the original image file's properties if not provided.
```astro ```astro
@ -371,7 +407,7 @@ import heroImage from '../assets/hero.png';
Files in the `/public` directory are always served or copied as-is, with no processing. We recommend that local images are always kept in `src/` so that Astro can transform, optimize and bundle them. But if you absolutely must keep an image in `public/`, use its relative URL path as the image's `src=` attribute. It will be treated as a remote image, which requires an `aspectRatio` attribute. Files in the `/public` directory are always served or copied as-is, with no processing. We recommend that local images are always kept in `src/` so that Astro can transform, optimize and bundle them. But if you absolutely must keep an image in `public/`, use its relative URL path as the image's `src=` attribute. It will be treated as a remote image, which requires an `aspectRatio` attribute.
Alternatively, you can import an image from your `public/` directory in your frontmatter and use a variable in your `src=` attribute. You cannot, however, import this directly inside the component as its `src` value. Alternatively, you can import an image from your `public/` directory in your frontmatter and use a variable in your `src=` attribute. You cannot, however, import this directly inside the component as its `src` value.
For example, use an image located at `public/social.png` in either static or SSR builds like so: For example, use an image located at `public/social.png` in either static or SSR builds like so:
@ -386,7 +422,7 @@ import socialImage from '/social.png';
``` ```
### Remote images ### Remote images
Remote images can be transformed with the `<Image />` component. The `<Image />` component needs to know the final dimensions for the `<img />` element to avoid content layout shifts. For remote images, this means you must either provide `width` and `height`, or one of the dimensions plus the required `aspectRatio`. Remote images can be transformed with the `<Image />` component. The `<Image />` component needs to know the final dimensions for the `<img />` element to avoid content layout shifts. For remote images, this means you must either provide `width` and `height`, or one of the dimensions plus the required `aspectRatio`.
```astro ```astro

View file

@ -27,6 +27,7 @@ interface RemoteImageProps
widths: number[]; widths: number[];
aspectRatio: TransformOptions['aspectRatio']; aspectRatio: TransformOptions['aspectRatio'];
formats?: OutputFormat[]; formats?: OutputFormat[];
background: TransformOptions['background'];
} }
export type Props = LocalImageProps | RemoteImageProps; export type Props = LocalImageProps | RemoteImageProps;
@ -37,6 +38,7 @@ const {
sizes, sizes,
widths, widths,
aspectRatio, aspectRatio,
background,
formats = ['avif', 'webp'], formats = ['avif', 'webp'],
loading = 'lazy', loading = 'lazy',
decoding = 'async', decoding = 'async',
@ -47,7 +49,7 @@ if (alt === undefined || alt === null) {
warnForMissingAlt(); warnForMissingAlt();
} }
const { image, sources } = await getPicture({ src, widths, formats, aspectRatio }); const { image, sources } = await getPicture({ src, widths, formats, aspectRatio, background });
--- ---
<picture {...attrs}> <picture {...attrs}>

View file

@ -1,5 +1,10 @@
/// <reference types="astro/astro-jsx" /> /// <reference types="astro/astro-jsx" />
import type { ImageService, OutputFormat, TransformOptions } from '../loaders/index.js'; import type {
ColorDefinition,
ImageService,
OutputFormat,
TransformOptions,
} from '../loaders/index.js';
import { isSSRService, parseAspectRatio } from '../loaders/index.js'; import { isSSRService, parseAspectRatio } from '../loaders/index.js';
import sharp from '../loaders/sharp.js'; import sharp from '../loaders/sharp.js';
import { isRemoteImage } from '../utils/paths.js'; import { isRemoteImage } from '../utils/paths.js';
@ -63,7 +68,7 @@ async function resolveTransform(input: GetImageTransform): Promise<TransformOpti
// resolve the metadata promise, usually when the ESM import is inlined // resolve the metadata promise, usually when the ESM import is inlined
const metadata = 'then' in input.src ? (await input.src).default : input.src; const metadata = 'then' in input.src ? (await input.src).default : input.src;
let { width, height, aspectRatio, format = metadata.format, ...rest } = input; let { width, height, aspectRatio, background, format = metadata.format, ...rest } = input;
if (!width && !height) { if (!width && !height) {
// neither dimension was provided, use the file metadata // neither dimension was provided, use the file metadata
@ -86,6 +91,7 @@ async function resolveTransform(input: GetImageTransform): Promise<TransformOpti
height, height,
aspectRatio, aspectRatio,
format: format as OutputFormat, format: format as OutputFormat,
background: background as ColorDefinition | undefined,
}; };
} }

View file

@ -10,6 +10,7 @@ export interface GetPictureParams {
widths: number[]; widths: number[];
formats: OutputFormat[]; formats: OutputFormat[];
aspectRatio?: TransformOptions['aspectRatio']; aspectRatio?: TransformOptions['aspectRatio'];
background?: TransformOptions['background'];
} }
export interface GetPictureResult { export interface GetPictureResult {
@ -64,6 +65,7 @@ export async function getPicture(params: GetPictureParams): Promise<GetPictureRe
format, format,
width, width,
height: Math.round(width / aspectRatio!), height: Math.round(width / aspectRatio!),
background: params.background,
}); });
return `${img.src} ${width}w`; return `${img.src} ${width}w`;
}) })
@ -83,6 +85,7 @@ export async function getPicture(params: GetPictureParams): Promise<GetPictureRe
width: Math.max(...widths), width: Math.max(...widths),
aspectRatio, aspectRatio,
format: allFormats[allFormats.length - 1], format: allFormats[allFormats.length - 1],
background: params.background,
}); });
const sources = await Promise.all(allFormats.map((format) => getSource(format))); const sources = await Promise.all(allFormats.map((format) => getSource(format)));

View file

@ -0,0 +1,290 @@
export type NamedColor =
| 'aliceblue'
| 'antiquewhite'
| 'aqua'
| 'aquamarine'
| 'azure'
| 'beige'
| 'bisque'
| 'black'
| 'blanchedalmond'
| 'blue'
| 'blueviolet'
| 'brown'
| 'burlywood'
| 'cadetblue'
| 'chartreuse'
| 'chocolate'
| 'coral'
| 'cornflowerblue'
| 'cornsilk'
| 'crimson'
| 'cyan'
| 'darkblue'
| 'darkcyan'
| 'darkgoldenrod'
| 'darkgray'
| 'darkgreen'
| 'darkkhaki'
| 'darkmagenta'
| 'darkolivegreen'
| 'darkorange'
| 'darkorchid'
| 'darkred'
| 'darksalmon'
| 'darkseagreen'
| 'darkslateblue'
| 'darkslategray'
| 'darkturquoise'
| 'darkviolet'
| 'deeppink'
| 'deepskyblue'
| 'dimgray'
| 'dodgerblue'
| 'firebrick'
| 'floralwhite'
| 'forestgreen'
| 'fuchsia'
| 'gainsboro'
| 'ghostwhite'
| 'gold'
| 'goldenrod'
| 'gray'
| 'green'
| 'greenyellow'
| 'honeydew'
| 'hotpink'
| 'indianred'
| 'indigo'
| 'ivory'
| 'khaki'
| 'lavender'
| 'lavenderblush'
| 'lawngreen'
| 'lemonchiffon'
| 'lightblue'
| 'lightcoral'
| 'lightcyan'
| 'lightgoldenrodyellow'
| 'lightgray'
| 'lightgreen'
| 'lightpink'
| 'lightsalmon'
| 'lightsalmon'
| 'lightseagreen'
| 'lightskyblue'
| 'lightslategray'
| 'lightsteelblue'
| 'lightyellow'
| 'lime'
| 'limegreen'
| 'linen'
| 'magenta'
| 'maroon'
| 'mediumaquamarine'
| 'mediumblue'
| 'mediumorchid'
| 'mediumpurple'
| 'mediumseagreen'
| 'mediumslateblue'
| 'mediumslateblue'
| 'mediumspringgreen'
| 'mediumturquoise'
| 'mediumvioletred'
| 'midnightblue'
| 'mintcream'
| 'mistyrose'
| 'moccasin'
| 'navajowhite'
| 'navy'
| 'oldlace'
| 'olive'
| 'olivedrab'
| 'orange'
| 'orangered'
| 'orchid'
| 'palegoldenrod'
| 'palegreen'
| 'paleturquoise'
| 'palevioletred'
| 'papayawhip'
| 'peachpuff'
| 'peru'
| 'pink'
| 'plum'
| 'powderblue'
| 'purple'
| 'rebeccapurple'
| 'red'
| 'rosybrown'
| 'royalblue'
| 'saddlebrown'
| 'salmon'
| 'sandybrown'
| 'seagreen'
| 'seashell'
| 'sienna'
| 'silver'
| 'skyblue'
| 'slateblue'
| 'slategray'
| 'snow'
| 'springgreen'
| 'steelblue'
| 'tan'
| 'teal'
| 'thistle'
| 'tomato'
| 'turquoise'
| 'violet'
| 'wheat'
| 'white'
| 'whitesmoke'
| 'yellow'
| 'yellowgreen';
export const htmlColorNames: NamedColor[] = [
'aliceblue',
'antiquewhite',
'aqua',
'aquamarine',
'azure',
'beige',
'bisque',
'black',
'blanchedalmond',
'blue',
'blueviolet',
'brown',
'burlywood',
'cadetblue',
'chartreuse',
'chocolate',
'coral',
'cornflowerblue',
'cornsilk',
'crimson',
'cyan',
'darkblue',
'darkcyan',
'darkgoldenrod',
'darkgray',
'darkgreen',
'darkkhaki',
'darkmagenta',
'darkolivegreen',
'darkorange',
'darkorchid',
'darkred',
'darksalmon',
'darkseagreen',
'darkslateblue',
'darkslategray',
'darkturquoise',
'darkviolet',
'deeppink',
'deepskyblue',
'dimgray',
'dodgerblue',
'firebrick',
'floralwhite',
'forestgreen',
'fuchsia',
'gainsboro',
'ghostwhite',
'gold',
'goldenrod',
'gray',
'green',
'greenyellow',
'honeydew',
'hotpink',
'indianred',
'indigo',
'ivory',
'khaki',
'lavender',
'lavenderblush',
'lawngreen',
'lemonchiffon',
'lightblue',
'lightcoral',
'lightcyan',
'lightgoldenrodyellow',
'lightgray',
'lightgreen',
'lightpink',
'lightsalmon',
'lightsalmon',
'lightseagreen',
'lightskyblue',
'lightslategray',
'lightsteelblue',
'lightyellow',
'lime',
'limegreen',
'linen',
'magenta',
'maroon',
'mediumaquamarine',
'mediumblue',
'mediumorchid',
'mediumpurple',
'mediumseagreen',
'mediumslateblue',
'mediumslateblue',
'mediumspringgreen',
'mediumturquoise',
'mediumvioletred',
'midnightblue',
'mintcream',
'mistyrose',
'moccasin',
'navajowhite',
'navy',
'oldlace',
'olive',
'olivedrab',
'orange',
'orangered',
'orchid',
'palegoldenrod',
'palegreen',
'paleturquoise',
'palevioletred',
'papayawhip',
'peachpuff',
'peru',
'pink',
'plum',
'powderblue',
'purple',
'rebeccapurple',
'red',
'rosybrown',
'royalblue',
'saddlebrown',
'salmon',
'sandybrown',
'seagreen',
'seashell',
'sienna',
'silver',
'skyblue',
'slateblue',
'slategray',
'snow',
'springgreen',
'steelblue',
'tan',
'teal',
'thistle',
'tomato',
'turquoise',
'violet',
'wheat',
'white',
'whitesmoke',
'yellow',
'yellowgreen',
];

View file

@ -1,3 +1,5 @@
import { type NamedColor, htmlColorNames } from './colornames.js';
/// <reference types="astro/astro-jsx" /> /// <reference types="astro/astro-jsx" />
export type InputFormat = export type InputFormat =
| 'heic' | 'heic'
@ -10,16 +12,35 @@ export type InputFormat =
| 'webp' | 'webp'
| 'gif'; | 'gif';
export type OutputFormat = 'avif' | 'jpeg' | 'png' | 'webp'; export type OutputFormatSupportsAlpha = 'avif' | 'png' | 'webp';
export type OutputFormat = OutputFormatSupportsAlpha | 'jpeg';
export type ColorDefinition =
| NamedColor
| `#${string}`
| `rgb(${number}, ${number}, ${number})`
| `rgb(${number},${number},${number})`;
export function isOutputFormat(value: string): value is OutputFormat { export function isOutputFormat(value: string): value is OutputFormat {
return ['avif', 'jpeg', 'png', 'webp'].includes(value); return ['avif', 'jpeg', 'png', 'webp'].includes(value);
} }
export function isOutputFormatSupportsAlpha(value: string): value is OutputFormatSupportsAlpha {
return ['avif', 'png', 'webp'].includes(value);
}
export function isAspectRatioString(value: string): value is `${number}:${number}` { export function isAspectRatioString(value: string): value is `${number}:${number}` {
return /^\d*:\d*$/.test(value); return /^\d*:\d*$/.test(value);
} }
export function isColor(value: string): value is ColorDefinition {
return (
(htmlColorNames as string[]).includes(value.toLowerCase()) ||
/^#[0-9a-f]{3}([0-9a-f]{3})?$/i.test(value) ||
/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/i.test(value)
);
}
export function parseAspectRatio(aspectRatio: TransformOptions['aspectRatio']) { export function parseAspectRatio(aspectRatio: TransformOptions['aspectRatio']) {
if (!aspectRatio) { if (!aspectRatio) {
return undefined; return undefined;
@ -75,6 +96,15 @@ export interface TransformOptions {
* @example "16:9" - strings can be used in the format of `{ratioWidth}:{ratioHeight}`. * @example "16:9" - strings can be used in the format of `{ratioWidth}:{ratioHeight}`.
*/ */
aspectRatio?: number | `${number}:${number}`; aspectRatio?: number | `${number}:${number}`;
/**
* The background color to use when converting from a transparent image format to a
* non-transparent format. This is useful for converting PNGs to JPEGs.
*
* @example "white" - a named color
* @example "#ffffff" - a hex color
* @example "rgb(255, 255, 255)" - an rgb color
*/
background?: ColorDefinition;
} }
export interface HostedImageService<T extends TransformOptions = TransformOptions> { export interface HostedImageService<T extends TransformOptions = TransformOptions> {

View file

@ -1,11 +1,11 @@
import sharp from 'sharp'; import sharp from 'sharp';
import { isAspectRatioString, isOutputFormat } from '../loaders/index.js'; import { isAspectRatioString, isColor, isOutputFormat } from '../loaders/index.js';
import type { OutputFormat, SSRImageService, TransformOptions } from './index.js'; import type { OutputFormat, SSRImageService, TransformOptions } from './index.js';
class SharpService implements SSRImageService { class SharpService implements SSRImageService {
async getImageAttributes(transform: TransformOptions) { async getImageAttributes(transform: TransformOptions) {
// strip off the known attributes // strip off the known attributes
const { width, height, src, format, quality, aspectRatio, ...rest } = transform; const { width, height, src, format, quality, aspectRatio, background, ...rest } = transform;
return { return {
...rest, ...rest,
@ -37,6 +37,10 @@ class SharpService implements SSRImageService {
searchParams.append('ar', transform.aspectRatio.toString()); searchParams.append('ar', transform.aspectRatio.toString());
} }
if (transform.background) {
searchParams.append('bg', transform.background);
}
return { searchParams }; return { searchParams };
} }
@ -72,6 +76,13 @@ class SharpService implements SSRImageService {
} }
} }
if (searchParams.has('bg')) {
const background = searchParams.get('bg')!;
if (isColor(background)) {
transform.background = background;
}
}
return transform; return transform;
} }
@ -87,6 +98,11 @@ class SharpService implements SSRImageService {
sharpImage.resize(width, height); sharpImage.resize(width, height);
} }
// remove alpha channel and replace with background color if requested
if (transform.background) {
sharpImage.flatten({ background: transform.background });
}
if (transform.format) { if (transform.format) {
sharpImage.toFormat(transform.format, { quality: transform.quality }); sharpImage.toFormat(transform.format, { quality: transform.quality });
} }

View file

@ -0,0 +1,116 @@
import { expect } from 'chai';
import * as cheerio from 'cheerio';
import sharp from 'sharp';
import { fileURLToPath } from 'url';
import { loadFixture } from './test-utils.js';
describe('SSG image with background - dev', function () {
let fixture;
let devServer;
let $;
before(async () => {
fixture = await loadFixture({ root: './fixtures/background-color-image/' });
devServer = await fixture.startDevServer();
const html = await fixture.fetch('/').then((res) => res.text());
$ = cheerio.load(html);
});
after(async () => {
await devServer.stop();
});
[
{
title: 'Named color',
id: '#named',
bg: 'dimgray',
},
{
title: 'Hex color',
id: '#hex',
bg: '#696969',
},
{
title: 'Hex color short',
id: '#hex-short',
bg: '#666',
},
{
title: 'RGB color',
id: '#rgb',
bg: 'rgb(105,105,105)',
},
{
title: 'RGB color with spaces',
id: '#rgb-spaced',
bg: 'rgb(105, 105, 105)',
},
].forEach(({ title, id, bg }) => {
it(title, async () => {
const image = $(id);
const src = image.attr('src');
const [_, params] = src.split('?');
const searchParams = new URLSearchParams(params);
expect(searchParams.get('bg')).to.equal(bg);
});
});
});
describe('SSG image with background - build', function () {
let fixture;
let $;
let html;
before(async () => {
fixture = await loadFixture({ root: './fixtures/background-color-image/' });
await fixture.build();
html = await fixture.readFile('/index.html');
$ = cheerio.load(html);
});
async function verifyImage(pathname, expectedBg) {
const url = new URL('./fixtures/background-color-image/dist/' + pathname, import.meta.url);
const dist = fileURLToPath(url);
const data = await sharp(dist).raw().toBuffer();
// check that the first RGB pixel indeed has the requested background color
expect(data[0]).to.equal(expectedBg[0]);
expect(data[1]).to.equal(expectedBg[1]);
expect(data[2]).to.equal(expectedBg[2]);
}
[
{
title: 'Named color',
id: '#named',
bg: [105, 105, 105],
},
{
title: 'Hex color',
id: '#hex',
bg: [105, 105, 105],
},
{
title: 'Hex color short',
id: '#hex-short',
bg: [102, 102, 102],
},
{
title: 'RGB color',
id: '#rgb',
bg: [105, 105, 105],
},
{
title: 'RGB color with spaces',
id: '#rgb-spaced',
bg: [105, 105, 105],
},
].forEach(({ title, id, bg }) => {
it(title, async () => {
const image = $(id);
const src = image.attr('src');
await verifyImage(src, bg);
});
});
});

View file

@ -0,0 +1,98 @@
import { expect } from 'chai';
import * as cheerio from 'cheerio';
import { loadFixture } from './test-utils.js';
import testAdapter from '../../../astro/test/test-adapter.js';
let fixture;
describe('SSR image with background', function () {
before(async () => {
fixture = await loadFixture({
root: './fixtures/background-color-image/',
adapter: testAdapter({ streaming: false }),
output: 'server',
});
await fixture.build();
});
[
{
title: 'Named color',
id: '#named',
query: {
f: 'jpeg',
w: '256',
h: '256',
href: /^\/assets\/file-icon.\w{8}.png/,
bg: 'dimgray',
},
},
{
title: 'Hex color',
id: '#hex',
query: {
f: 'avif',
w: '256',
h: '256',
href: /^\/assets\/file-icon.\w{8}.png/,
bg: '#696969',
},
},
{
title: 'Hex color short',
id: '#hex-short',
query: {
f: 'png',
w: '256',
h: '256',
href: /^\/assets\/file-icon.\w{8}.png/,
bg: '#666',
},
},
{
title: 'RGB color',
id: '#rgb',
query: {
f: 'webp',
w: '256',
h: '256',
href: /^\/assets\/file-icon.\w{8}.png/,
bg: 'rgb(105,105,105)',
},
},
{
title: 'RGB color with spaces',
id: '#rgb-spaced',
query: {
f: 'jpeg',
w: '256',
h: '256',
href: /^\/assets\/file-icon.\w{8}.png/,
bg: 'rgb(105, 105, 105)',
},
},
].forEach(({ title, id, query }) => {
it(title, async () => {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/');
const response = await app.render(request);
const html = await response.text();
const $ = cheerio.load(html);
const image = $(id);
const src = image.attr('src');
const [_, params] = src.split('?');
const searchParams = new URLSearchParams(params);
for (const [key, value] of Object.entries(query)) {
if (typeof value === 'string') {
expect(searchParams.get(key)).to.equal(value);
} else {
expect(searchParams.get(key)).to.match(value);
}
}
});
});
});

View file

@ -0,0 +1,8 @@
import { defineConfig } from 'astro/config';
import image from '@astrojs/image';
// https://astro.build/config
export default defineConfig({
site: 'http://localhost:3000',
integrations: [image({ logLevel: 'silent' })]
});

View file

@ -0,0 +1,10 @@
{
"name": "@test/background-color-image",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/image": "workspace:*",
"@astrojs/node": "workspace:*",
"astro": "workspace:*"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -0,0 +1,44 @@
import { createServer } from 'http';
import fs from 'fs';
import mime from 'mime';
import { handler as ssrHandler } from '../dist/server/entry.mjs';
const clientRoot = new URL('../dist/client/', import.meta.url);
async function handle(req, res) {
ssrHandler(req, res, async (err) => {
if (err) {
res.writeHead(500);
res.end(err.stack);
return;
}
let local = new URL('.' + req.url, clientRoot);
try {
const data = await fs.promises.readFile(local);
res.writeHead(200, {
'Content-Type': mime.getType(req.url),
});
res.end(data);
} catch {
res.writeHead(404);
res.end();
}
});
}
const server = createServer((req, res) => {
handle(req, res).catch((err) => {
console.error(err);
res.writeHead(500, {
'Content-Type': 'text/plain',
});
res.end(err.toString());
});
});
server.listen(8085);
console.log('Serving at http://localhost:8085');
// Silence weird <time> warning
console.error = () => {};

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View file

@ -0,0 +1,21 @@
---
import { Image } from '@astrojs/image/components';
---
<html>
<head>
<!-- Head Stuff -->
</head>
<body>
<Image id="named" src={import('../assets/file-icon.png')} width={256} format="jpeg" background="dimgray" alt="named" />
<br />
<Image id="hex" src={import('../assets/file-icon.png')} width={256} format="avif" background="#696969" alt="hex" />
<br />
<Image id="hex-short" src={import('../assets/file-icon.png')} width={256} background="#666" alt="hex-short" />
<br />
<Image id="rgb" src={import('../assets/file-icon.png')} width={256} format="webp" background="rgb(105,105,105)" alt="rgb" />
<br />
<Image id="rgb-spaced" src={import('../assets/file-icon.png')} width={256} format="jpeg" background="rgb(105, 105, 105)" alt="rgb-spaced" />
<br />
</body>
</html>

View file

@ -21,6 +21,8 @@ import { Image } from '@astrojs/image/components';
<br /> <br />
<Image id="query" src="https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png?token=abc" width={544} height={184} format="webp" alt="query" /> <Image id="query" src="https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png?token=abc" width={544} height={184} format="webp" alt="query" />
<br /> <br />
<Image id="bg-color" src="https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png" width={544} height={184} format="jpeg" alt="Google" background="#333333" />
<br />
<Image id="ipsum" src="https://picsum.photos/200/300" width={200} height={300} alt="ipsum" format="jpeg" /> <Image id="ipsum" src="https://picsum.photos/200/300" width={200} height={300} alt="ipsum" format="jpeg" />
</body> </body>
</html> </html>

View file

@ -16,6 +16,8 @@ import { Picture } from '@astrojs/image/components';
<br /> <br />
<Picture id='inline' src={import('../assets/social.jpg')} sizes="(min-width: 640px) 50vw, 100vw" widths={[253, 506]} alt="Inline social image" /> <Picture id='inline' src={import('../assets/social.jpg')} sizes="(min-width: 640px) 50vw, 100vw" widths={[253, 506]} alt="Inline social image" />
<br /> <br />
<Picture id="bg-color" src="https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png" sizes="(min-width: 640px) 50vw, 100vw" widths={[272, 544]} aspectRatio={544/184} alt="Google logo" background="rgb(51, 51, 51)" formats={['avif', 'jpeg']} />
<br />
<Picture id="ipsum" src="https://picsum.photos/200/300" sizes="100vw" widths={[100, 200]} aspectRatio={2/3} formats={["avif", "webp", "jpg"]} alt="ipsum" /> <Picture id="ipsum" src="https://picsum.photos/200/300" sizes="100vw" widths={[100, 200]} aspectRatio={2/3} formats={["avif", "webp", "jpg"]} alt="ipsum" />
</body> </body>
</html> </html>

View file

@ -15,3 +15,5 @@ import { Image } from '@astrojs/image/components';
<Image id="inline" src={import('../assets/social.jpg')} width={506} alt="inline" /> <Image id="inline" src={import('../assets/social.jpg')} width={506} alt="inline" />
<br /> <br />
<Image id="query" src="https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png?token=abc" width={544} height={184} format="webp" alt="query" /> <Image id="query" src="https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png?token=abc" width={544} height={184} format="webp" alt="query" />
<br />
<Image id="bg-color" src="https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png" width={544} height={184} format="jpeg" alt="Google" background="#333333" />

View file

@ -66,6 +66,18 @@ describe('SSG images - dev', function () {
url: '/_image', url: '/_image',
query: { f: 'webp', w: '768', h: '414', href: '/hero.jpg' }, query: { f: 'webp', w: '768', h: '414', href: '/hero.jpg' },
}, },
{
title: 'Background color',
id: '#bg-color',
url: '/_image',
query: {
f: 'jpeg',
w: '544',
h: '184',
bg: '#333333',
href: 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png',
},
},
].forEach(({ title, id, url, query }) => { ].forEach(({ title, id, url, query }) => {
it(title, () => { it(title, () => {
const image = $(id); const image = $(id);
@ -147,6 +159,18 @@ describe('SSG images with subpath - dev', function () {
url: '/_image', url: '/_image',
query: { f: 'webp', w: '768', h: '414', href: '/hero.jpg' }, query: { f: 'webp', w: '768', h: '414', href: '/hero.jpg' },
}, },
{
title: 'Background color',
id: '#bg-color',
url: '/_image',
query: {
f: 'jpeg',
w: '544',
h: '184',
bg: '#333333',
href: 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png',
},
},
].forEach(({ title, id, url, query }) => { ].forEach(({ title, id, url, query }) => {
it(title, () => { it(title, () => {
const image = $(id); const image = $(id);
@ -222,6 +246,12 @@ describe('SSG images - build', function () {
regex: /^\/hero_\w{4,10}.webp/, regex: /^\/hero_\w{4,10}.webp/,
size: { width: 768, height: 414, type: 'webp' }, size: { width: 768, height: 414, type: 'webp' },
}, },
{
title: 'Remote images',
id: '#bg-color',
regex: /^\/googlelogo_color_272x92dp_\w{4,10}.jpeg/,
size: { width: 544, height: 184, type: 'jpg' },
},
].forEach(({ title, id, regex, size }) => { ].forEach(({ title, id, regex, size }) => {
it(title, () => { it(title, () => {
const image = $(id); const image = $(id);
@ -292,6 +322,12 @@ describe('SSG images with subpath - build', function () {
regex: /^\/docs\/hero_\w{4,10}.webp/, regex: /^\/docs\/hero_\w{4,10}.webp/,
size: { width: 768, height: 414, type: 'webp' }, size: { width: 768, height: 414, type: 'webp' },
}, },
{
title: 'Remote images',
id: '#bg-color',
regex: /^\/docs\/googlelogo_color_272x92dp_\w{4,10}.jpeg/,
size: { width: 544, height: 184, type: 'jpg' },
},
].forEach(({ title, id, regex, size }) => { ].forEach(({ title, id, regex, size }) => {
it(title, () => { it(title, () => {
const image = $(id); const image = $(id);

View file

@ -72,6 +72,18 @@ describe('SSR images - build', async function () {
url: '/_image', url: '/_image',
query: { f: 'webp', w: '768', h: '414', href: '/hero.jpg' }, query: { f: 'webp', w: '768', h: '414', href: '/hero.jpg' },
}, },
{
title: 'Background color',
id: '#bg-color',
url: '/_image',
query: {
f: 'jpeg',
w: '544',
h: '184',
bg: '#333333',
href: 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png',
},
},
].forEach(({ title, id, url, query }) => { ].forEach(({ title, id, url, query }) => {
it(title, async () => { it(title, async () => {
const app = await fixture.loadTestAdapterApp(); const app = await fixture.loadTestAdapterApp();
@ -176,6 +188,18 @@ describe('SSR images with subpath - build', function () {
url: '/_image', url: '/_image',
query: { f: 'webp', w: '768', h: '414', href: '/hero.jpg' }, query: { f: 'webp', w: '768', h: '414', href: '/hero.jpg' },
}, },
{
title: 'Background color',
id: '#bg-color',
url: '/_image',
query: {
f: 'jpeg',
w: '544',
h: '184',
bg: '#333333',
href: 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png',
},
},
].forEach(({ title, id, url, query }) => { ].forEach(({ title, id, url, query }) => {
it(title, async () => { it(title, async () => {
const app = await fixture.loadTestAdapterApp(); const app = await fixture.loadTestAdapterApp();

View file

@ -81,6 +81,19 @@ describe('SSR images - dev', function () {
}, },
contentType: 'image/webp', contentType: 'image/webp',
}, },
{
title: 'Background color',
id: '#bg-color',
url: '/_image',
query: {
f: 'jpeg',
w: '544',
h: '184',
bg: '#333333',
href: 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png',
},
contentType: 'image/jpeg',
},
].forEach(({ title, id, url, query, contentType }) => { ].forEach(({ title, id, url, query, contentType }) => {
it(title, async () => { it(title, async () => {
const image = $(id); const image = $(id);
@ -183,6 +196,19 @@ describe('SSR images with subpath - dev', function () {
}, },
contentType: 'image/webp', contentType: 'image/webp',
}, },
{
title: 'Background color',
id: '#bg-color',
url: '/_image',
query: {
f: 'jpeg',
w: '544',
h: '184',
bg: '#333333',
href: 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png',
},
contentType: 'image/jpeg',
},
].forEach(({ title, id, url, query, contentType }) => { ].forEach(({ title, id, url, query, contentType }) => {
it(title, async () => { it(title, async () => {
const image = $(id); const image = $(id);

View file

@ -56,6 +56,19 @@ describe('SSG pictures - dev', function () {
query: { f: 'jpg', w: '768', h: '414', href: '/hero.jpg' }, query: { f: 'jpg', w: '768', h: '414', href: '/hero.jpg' },
alt: 'Hero image', alt: 'Hero image',
}, },
{
title: 'Background color',
id: '#bg-color',
url: '/_image',
query: {
f: 'png',
w: '544',
h: '184',
bg: 'rgb(51, 51, 51)',
href: 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png',
},
alt: 'Google logo',
},
].forEach(({ title, id, url, query, alt }) => { ].forEach(({ title, id, url, query, alt }) => {
it(title, () => { it(title, () => {
const sources = $(`${id} source`); const sources = $(`${id} source`);

View file

@ -60,6 +60,19 @@ describe('SSR pictures - build', function () {
query: { f: 'jpg', w: '768', h: '414', href: '/hero.jpg' }, query: { f: 'jpg', w: '768', h: '414', href: '/hero.jpg' },
alt: 'Hero image', alt: 'Hero image',
}, },
{
title: 'Background color',
id: '#bg-color',
url: '/_image',
query: {
f: 'png',
w: '544',
h: '184',
bg: 'rgb(51, 51, 51)',
href: 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png',
},
alt: 'Google logo',
},
].forEach(({ title, id, url, query }) => { ].forEach(({ title, id, url, query }) => {
it(title, async () => { it(title, async () => {
const app = await fixture.loadTestAdapterApp(); const app = await fixture.loadTestAdapterApp();
@ -151,6 +164,19 @@ describe('SSR pictures with subpath - build', function () {
query: { f: 'jpg', w: '768', h: '414', href: '/hero.jpg' }, query: { f: 'jpg', w: '768', h: '414', href: '/hero.jpg' },
alt: 'Hero image', alt: 'Hero image',
}, },
{
title: 'Background color',
id: '#bg-color',
url: '/_image',
query: {
f: 'png',
w: '544',
h: '184',
bg: 'rgb(51, 51, 51)',
href: 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png',
},
alt: 'Google logo',
},
].forEach(({ title, id, url, query }) => { ].forEach(({ title, id, url, query }) => {
it(title, async () => { it(title, async () => {
const app = await fixture.loadTestAdapterApp(); const app = await fixture.loadTestAdapterApp();

View file

@ -80,6 +80,20 @@ describe('SSR pictures - dev', function () {
contentType: 'image/jpeg', contentType: 'image/jpeg',
alt: 'Hero image', alt: 'Hero image',
}, },
{
title: 'Background color',
id: '#bg-color',
url: '/_image',
query: {
f: 'png',
w: '544',
h: '184',
bg: 'rgb(51, 51, 51)',
href: 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png',
},
contentType: 'image/png',
alt: 'Google logo',
},
].forEach(({ title, id, url, query, alt, contentType }) => { ].forEach(({ title, id, url, query, alt, contentType }) => {
it(title, async () => { it(title, async () => {
const sources = $(`${id} source`); const sources = $(`${id} source`);

View file

@ -14,6 +14,7 @@ describe('Sharp service', () => {
['width & height', { src, height: 400, width: 200 }], ['width & height', { src, height: 400, width: 200 }],
['aspect ratio string', { src, aspectRatio: '16:9' }], ['aspect ratio string', { src, aspectRatio: '16:9' }],
['aspect ratio float', { src, aspectRatio: 1.7 }], ['aspect ratio float', { src, aspectRatio: 1.7 }],
['background color', { src, format: 'jpeg', background: '#333333' }],
].forEach(([description, props]) => { ].forEach(([description, props]) => {
it(description, async () => { it(description, async () => {
const { searchParams } = await sharp.serializeTransform(props); const { searchParams } = await sharp.serializeTransform(props);
@ -31,6 +32,7 @@ describe('Sharp service', () => {
verifyProp(props.width, 'w'); verifyProp(props.width, 'w');
verifyProp(props.height, 'h'); verifyProp(props.height, 'h');
verifyProp(props.aspectRatio, 'ar'); verifyProp(props.aspectRatio, 'ar');
verifyProp(props.background, 'bg');
}); });
}); });
}); });
@ -48,6 +50,11 @@ describe('Sharp service', () => {
['width & height', `w=200&h=400&href=${href}`, { src, height: 400, width: 200 }], ['width & height', `w=200&h=400&href=${href}`, { src, height: 400, width: 200 }],
['aspect ratio string', `ar=16:9&href=${href}`, { src, aspectRatio: '16:9' }], ['aspect ratio string', `ar=16:9&href=${href}`, { src, aspectRatio: '16:9' }],
['aspect ratio float', `ar=1.7&href=${href}`, { src, aspectRatio: 1.7 }], ['aspect ratio float', `ar=1.7&href=${href}`, { src, aspectRatio: 1.7 }],
[
'background color',
`f=jpeg&bg=%23333333&href=${href}`,
{ src, format: 'jpeg', background: '#333333' },
],
].forEach(([description, params, expected]) => { ].forEach(([description, params, expected]) => {
it(description, async () => { it(description, async () => {
const searchParams = new URLSearchParams(params); const searchParams = new URLSearchParams(params);

View file

@ -49,6 +49,12 @@ describe('Images in MDX - build', function () {
regex: /^\/hero_\w{4,10}.webp/, regex: /^\/hero_\w{4,10}.webp/,
size: { width: 768, height: 414, type: 'webp' }, size: { width: 768, height: 414, type: 'webp' },
}, },
{
title: 'Background color',
id: '#bg-color',
regex: /^\/googlelogo_color_272x92dp_\w{4,10}.jpeg/,
size: { width: 544, height: 184, type: 'jpg' },
},
].forEach(({ title, id, regex, size }) => { ].forEach(({ title, id, regex, size }) => {
it(title, () => { it(title, () => {
const image = $(id); const image = $(id);

File diff suppressed because it is too large Load diff