From 46055a63e6874753e21701edc6c45c812a81c67c Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 12 Nov 2024 16:26:50 +0000 Subject: [PATCH] feat: add crop support to image services (#12414) * wip: add crop support to image services * Add tests * Strip crop attributes * Don't upscale * Format * Get build working properly * Changes from review --- packages/astro/components/Image.astro | 38 ++-- packages/astro/src/assets/consts.ts | 10 +- packages/astro/src/assets/internal.ts | 12 +- packages/astro/src/assets/layout.ts | 6 +- packages/astro/src/assets/services/service.ts | 23 +- packages/astro/src/assets/services/sharp.ts | 42 +++- packages/astro/src/assets/types.ts | 7 +- packages/astro/src/types/public/config.ts | 5 +- packages/astro/test/core-image-layout.test.js | 60 ++++- .../astro/test/core-image-service.test.js | 206 ++++++++++++++++++ .../core-image-layout/src/pages/build.astro | 66 ++++++ .../core-image-layout/src/pages/fit.astro | 35 +++ 12 files changed, 466 insertions(+), 44 deletions(-) create mode 100644 packages/astro/test/core-image-service.test.js create mode 100644 packages/astro/test/fixtures/core-image-layout/src/pages/build.astro create mode 100644 packages/astro/test/fixtures/core-image-layout/src/pages/fit.astro diff --git a/packages/astro/components/Image.astro b/packages/astro/components/Image.astro index b873e1ebad..70cbc0f456 100644 --- a/packages/astro/components/Image.astro +++ b/packages/astro/components/Image.astro @@ -23,6 +23,20 @@ if (typeof props.height === 'string') { props.height = parseInt(props.height); } +const { experimentalResponsiveImages } = imageConfig; + +const layoutClassMap = { + fixed: 'aim-fi', + responsive: 'aim-re', +}; + +if (experimentalResponsiveImages) { + // Apply defaults from imageConfig if not provided + props.layout ??= imageConfig.experimentalLayout; + props.fit ??= imageConfig.experimentalObjectFit ?? 'cover'; + props.position ??= imageConfig.experimentalObjectPosition ?? 'center'; +} + const image = await getImage(props); const additionalAttributes: HTMLAttributes<'img'> = {}; @@ -34,16 +48,7 @@ if (import.meta.env.DEV) { additionalAttributes['data-image-component'] = 'true'; } -const { experimentalResponsiveImages } = imageConfig; - -const layoutClassMap = { - fixed: 'aim-fi', - responsive: 'aim-re', -}; - const cssFitValues = ['fill', 'contain', 'cover', 'scale-down']; -const objectFit = props.fit ?? imageConfig.experimentalObjectFit ?? 'cover'; -const objectPosition = props.position ?? imageConfig.experimentalObjectPosition ?? 'center'; // The style prop can't be spread when using define:vars, so we need to extract it here // @see https://github.com/withastro/compiler/issues/1050 @@ -51,7 +56,7 @@ const { style = '', class: className, ...attrs } = { ...additionalAttributes, .. --- { - experimentalResponsiveImages ? ( + experimentalResponsiveImages && props.layout ? ( /* Shared by all Astro images */ .aim { diff --git a/packages/astro/src/assets/consts.ts b/packages/astro/src/assets/consts.ts index 15f9fe46fb..5fae641ae4 100644 --- a/packages/astro/src/assets/consts.ts +++ b/packages/astro/src/assets/consts.ts @@ -26,4 +26,12 @@ export const VALID_SUPPORTED_FORMATS = [ ] as const; export const DEFAULT_OUTPUT_FORMAT = 'webp' as const; export const VALID_OUTPUT_FORMATS = ['avif', 'png', 'webp', 'jpeg', 'jpg', 'svg'] as const; -export const DEFAULT_HASH_PROPS = ['src', 'width', 'height', 'format', 'quality']; +export const DEFAULT_HASH_PROPS = [ + 'src', + 'width', + 'height', + 'format', + 'quality', + 'fit', + 'position', +]; diff --git a/packages/astro/src/assets/internal.ts b/packages/astro/src/assets/internal.ts index 235f1115be..fb1733dcf7 100644 --- a/packages/astro/src/assets/internal.ts +++ b/packages/astro/src/assets/internal.ts @@ -2,6 +2,12 @@ import { isRemotePath } from '@astrojs/internal-helpers/path'; import { AstroError, AstroErrorData } from '../core/errors/index.js'; import type { AstroConfig } from '../types/public/config.js'; import { DEFAULT_HASH_PROPS } from './consts.js'; +import { + DEFAULT_RESOLUTIONS, + LIMITED_RESOLUTIONS, + getSizesAttribute, + getWidths, +} from './layout.js'; import { type ImageService, isLocalService } from './services/service.js'; import { type GetImageResult, @@ -12,12 +18,6 @@ import { } from './types.js'; import { isESMImportedImage, isRemoteImage, resolveSrc } from './utils/imageKind.js'; import { inferRemoteSize } from './utils/remoteProbe.js'; -import { - DEFAULT_RESOLUTIONS, - getSizesAttribute, - getWidths, - LIMITED_RESOLUTIONS, -} from './layout.js'; export async function getConfiguredImageService(): Promise { if (!globalThis?.astroAsset?.imageService) { diff --git a/packages/astro/src/assets/layout.ts b/packages/astro/src/assets/layout.ts index a7fabf4e13..adc117f399 100644 --- a/packages/astro/src/assets/layout.ts +++ b/packages/astro/src/assets/layout.ts @@ -1,4 +1,4 @@ -import type { ImageLayout } from '../types/public/index.js'; +import type { ImageLayout } from './types.js'; // Common screen widths. These will be filtered according to the image size and layout export const DEFAULT_RESOLUTIONS = [ @@ -33,9 +33,9 @@ export const LIMITED_RESOLUTIONS = [ /** * Gets the breakpoints for an image, based on the layout and width - * + * * The rules are as follows: - * + * * - For full-width layout we return all breakpoints smaller than the original image width * - For fixed layout we return 1x and 2x the requested width, unless the original image is smaller than that. * - For responsive layout we return all breakpoints smaller than 2x the requested width, unless the original image is smaller than that. diff --git a/packages/astro/src/assets/services/service.ts b/packages/astro/src/assets/services/service.ts index 23c983b8c5..d84ec1728e 100644 --- a/packages/astro/src/assets/services/service.ts +++ b/packages/astro/src/assets/services/service.ts @@ -2,7 +2,12 @@ import { AstroError, AstroErrorData } from '../../core/errors/index.js'; import { isRemotePath, joinPaths } from '../../core/path.js'; import type { AstroConfig } from '../../types/public/config.js'; import { DEFAULT_HASH_PROPS, DEFAULT_OUTPUT_FORMAT, VALID_SUPPORTED_FORMATS } from '../consts.js'; -import type { ImageOutputFormat, ImageTransform, UnresolvedSrcSetValue } from '../types.js'; +import type { + ImageFit, + ImageOutputFormat, + ImageTransform, + UnresolvedSrcSetValue, +} from '../types.js'; import { isESMImportedImage } from '../utils/imageKind.js'; import { isRemoteAllowed } from '../utils/remotePattern.js'; @@ -116,6 +121,8 @@ export type BaseServiceTransform = { height?: number; format: string; quality?: string | null; + fit?: ImageFit; + position?: string; }; const sortNumeric = (a: number, b: number) => a - b; @@ -221,7 +228,13 @@ export const baseService: Omit = { // 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); - + if (options.layout && options.width && options.height) { + options.fit ??= 'cover'; + delete options.layout; + } + if (options.fit === 'none') { + delete options.fit; + } return options; }, getHTMLAttributes(options) { @@ -237,6 +250,8 @@ export const baseService: Omit = { formats, layout, priority, + fit, + position, ...attributes } = options; return { @@ -344,6 +359,8 @@ export const baseService: Omit = { h: 'height', q: 'quality', f: 'format', + fit: 'fit', + position: 'position', }; Object.entries(params).forEach(([param, key]) => { @@ -366,6 +383,8 @@ export const baseService: Omit = { height: params.has('h') ? parseInt(params.get('h')!) : undefined, format: params.get('f') as ImageOutputFormat, quality: params.get('q'), + fit: params.get('fit') as ImageFit, + position: params.get('position') ?? undefined, }; return transform; diff --git a/packages/astro/src/assets/services/sharp.ts b/packages/astro/src/assets/services/sharp.ts index c9df4c269a..bbae39eb09 100644 --- a/packages/astro/src/assets/services/sharp.ts +++ b/packages/astro/src/assets/services/sharp.ts @@ -1,6 +1,6 @@ -import type { FormatEnum, SharpOptions } from 'sharp'; +import type { FitEnum, FormatEnum, SharpOptions } from 'sharp'; import { AstroError, AstroErrorData } from '../../core/errors/index.js'; -import type { ImageOutputFormat, ImageQualityPreset } from '../types.js'; +import type { ImageFit, ImageOutputFormat, ImageQualityPreset } from '../types.js'; import { type BaseServiceTransform, type LocalImageService, @@ -38,6 +38,16 @@ async function loadSharp() { return sharpImport; } +const fitMap: Record = { + fill: 'fill', + contain: 'inside', + cover: 'cover', + none: 'outside', + 'scale-down': 'inside', + outside: 'outside', + inside: 'inside', +}; + const sharpService: LocalImageService = { validateOptions: baseService.validateOptions, getURL: baseService.getURL, @@ -46,7 +56,6 @@ const sharpService: LocalImageService = { getSrcSet: baseService.getSrcSet, async transform(inputBuffer, transformOptions, config) { if (!sharp) sharp = await loadSharp(); - const transform: BaseServiceTransform = transformOptions as BaseServiceTransform; // Return SVGs as-is @@ -62,11 +71,30 @@ const sharpService: LocalImageService = { // always call rotate to adjust for EXIF data orientation result.rotate(); - // Never resize using both width and height at the same time, prioritizing width. - if (transform.height && !transform.width) { - result.resize({ height: Math.round(transform.height) }); + // If `fit` isn't set then use old behavior: + // - Do not use both width and height for resizing, and prioritize width over height + // - Allow enlarging images + + const withoutEnlargement = Boolean(transform.fit); + if (transform.width && transform.height && transform.fit) { + const fit: keyof FitEnum = fitMap[transform.fit] ?? 'inside'; + result.resize({ + width: Math.round(transform.width), + height: Math.round(transform.height), + fit, + position: transform.position, + withoutEnlargement, + }); + } else if (transform.height && !transform.width) { + result.resize({ + height: Math.round(transform.height), + withoutEnlargement, + }); } else if (transform.width) { - result.resize({ width: Math.round(transform.width) }); + result.resize({ + width: Math.round(transform.width), + withoutEnlargement, + }); } if (transform.format) { diff --git a/packages/astro/src/assets/types.ts b/packages/astro/src/assets/types.ts index e4687adfdf..0e46cfb451 100644 --- a/packages/astro/src/assets/types.ts +++ b/packages/astro/src/assets/types.ts @@ -1,4 +1,3 @@ -import type { ImageLayout } from '../types/public/index.js'; import type { OmitPreservingIndexSignature, Simplify, WithRequired } from '../type-utils.js'; import type { VALID_INPUT_FORMATS, VALID_OUTPUT_FORMATS } from './consts.js'; import type { ImageService } from './services/service.js'; @@ -7,6 +6,8 @@ export type ImageQualityPreset = 'low' | 'mid' | 'high' | 'max' | (string & {}); export type ImageQuality = ImageQualityPreset | number; export type ImageInputFormat = (typeof VALID_INPUT_FORMATS)[number]; export type ImageOutputFormat = (typeof VALID_OUTPUT_FORMATS)[number] | (string & {}); +export type ImageLayout = 'responsive' | 'fixed' | 'full-width' | 'none'; +export type ImageFit = 'fill' | 'contain' | 'cover' | 'none' | 'scale-down' | (string & {}); export type AssetsGlobalStaticImagesList = Map< string, @@ -87,6 +88,8 @@ export type ImageTransform = { height?: number | undefined; quality?: ImageQuality | undefined; format?: ImageOutputFormat | undefined; + fit?: ImageFit | undefined; + position?: string | undefined; [key: string]: any; }; @@ -157,7 +160,7 @@ type ImageSharedProps = T & { layout?: ImageLayout; - fit?: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down' | (string & {}); + fit?: ImageFit; position?: string; } & ( diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index ce4052b437..fd1617d897 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -6,6 +6,7 @@ import type { ShikiConfig, } from '@astrojs/markdown-remark'; import type { UserConfig as OriginalViteUserConfig, SSROptions as ViteSSROptions } from 'vite'; +import type { ImageFit, ImageLayout } from '../../assets/types.js'; import type { RemotePattern } from '../../assets/utils/remotePattern.js'; import type { AssetsPrefix } from '../../core/app/types.js'; import type { AstroConfigType } from '../../core/config/schema.js'; @@ -1091,7 +1092,7 @@ export interface ViteUserConfig extends OriginalViteUserConfig { * The default object-fit value for responsive images. Can be overridden by the `fit` prop on the image component. * Requires the `experimental.responsiveImages` flag to be enabled. */ - experimentalObjectFit?: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down' | (string & {}); + experimentalObjectFit?: ImageFit; /** * @docs * @name image.experimentalObjectPosition @@ -1766,8 +1767,6 @@ export interface ViteUserConfig extends OriginalViteUserConfig { }; } -export type ImageLayout = 'responsive' | 'fixed' | 'full-width' | 'none'; - /** * Resolved Astro Config * diff --git a/packages/astro/test/core-image-layout.test.js b/packages/astro/test/core-image-layout.test.js index 205336c0c7..e2f1c954e8 100644 --- a/packages/astro/test/core-image-layout.test.js +++ b/packages/astro/test/core-image-layout.test.js @@ -5,8 +5,8 @@ import * as cheerio from 'cheerio'; import parseSrcset from 'parse-srcset'; import { Logger } from '../dist/core/logger/core.js'; import { testImageService } from './test-image-service.js'; -import { loadFixture } from './test-utils.js'; import { testRemoteImageService } from './test-remote-image-service.js'; +import { loadFixture } from './test-utils.js'; describe('astro:image:layout', () => { /** @type {import('./test-utils').Fixture} */ @@ -15,8 +15,6 @@ describe('astro:image:layout', () => { describe('local image service', () => { /** @type {import('./test-utils').DevServer} */ let devServer; - /** @type {Array<{ type: any, level: 'error', message: string; }>} */ - let logs = []; before(async () => { fixture = await loadFixture({ @@ -134,7 +132,7 @@ describe('astro:image:layout', () => { assert.match(style, /\.aim\[/); assert.match(style, /\.aim-re\[/); assert.match(style, /\.aim-fi\[/); - }) + }); }); describe('srcsets', () => { @@ -180,6 +178,60 @@ describe('astro:image:layout', () => { }); }); + describe('generated URLs', () => { + let $; + before(async () => { + let res = await fixture.fetch('/fit'); + let html = await res.text(); + $ = cheerio.load(html); + }); + it('generates width and height in image URLs when both are provided', () => { + let $img = $('#local-both img'); + const aspectRatio = 300 / 400; + const srcset = parseSrcset($img.attr('srcset')); + for (const { url } of srcset) { + const params = new URL(url, 'https://example.com').searchParams; + const width = parseInt(params.get('w')); + const height = parseInt(params.get('h')); + assert.equal(width / height, aspectRatio); + } + }); + + it('does not pass through fit and position', async () => { + const fit = $('#fit-cover img'); + assert.ok(!fit.attr('fit')); + const position = $('#position img'); + assert.ok(!position.attr('position')); + }); + + it('sets a default fit of "cover" when no fit is provided', () => { + let $img = $('#fit-default img'); + const srcset = parseSrcset($img.attr('srcset')); + for (const { url } of srcset) { + const params = new URL(url, 'https://example.com').searchParams; + assert.equal(params.get('fit'), 'cover'); + } + }); + + it('sets a fit of "contain" when fit="contain" is provided', () => { + let $img = $('#fit-contain img'); + const srcset = parseSrcset($img.attr('srcset')); + for (const { url } of srcset) { + const params = new URL(url, 'https://example.com').searchParams; + assert.equal(params.get('fit'), 'contain'); + } + }); + + it('sets no fit when fit="none" is provided', () => { + let $img = $('#fit-none img'); + const srcset = parseSrcset($img.attr('srcset')); + for (const { url } of srcset) { + const params = new URL(url, 'https://example.com').searchParams; + assert.ok(!params.has('fit')); + } + }); + }); + describe('remote images', () => { describe('srcset', () => { let $; diff --git a/packages/astro/test/core-image-service.test.js b/packages/astro/test/core-image-service.test.js new file mode 100644 index 0000000000..0c75ed484f --- /dev/null +++ b/packages/astro/test/core-image-service.test.js @@ -0,0 +1,206 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { removeDir } from '@astrojs/internal-helpers/fs'; +import * as cheerio from 'cheerio'; +import { lookup as probe } from '../dist/assets/utils/vendor/image-size/lookup.js'; +import { loadFixture } from './test-utils.js'; + +async function getImageDimensionsFromFixture(fixture, path) { + /** @type { Response } */ + const res = await fixture.fetch(path instanceof URL ? path.pathname + path.search : path); + const buffer = await res.arrayBuffer(); + const { width, height } = await probe(new Uint8Array(buffer)); + return { width, height }; +} + +async function getImageDimensionsFromLocalFile(fixture, path) { + const buffer = await fixture.readFile(path, null); + const { width, height } = await probe(new Uint8Array(buffer)); + return { width, height }; +} + +describe('astro image service', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + describe('dev image service', () => { + /** @type {import('./test-utils').DevServer} */ + let devServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/core-image-layout/', + image: { + domains: ['unsplash.com'], + }, + }); + + devServer = await fixture.startDevServer({}); + }); + + after(async () => { + await devServer.stop(); + }); + + describe('generated images', () => { + let $; + let src; + before(async () => { + const res = await fixture.fetch('/fit'); + const html = await res.text(); + $ = cheerio.load(html); + let $img = $('#local-both img'); + src = new URL($img.attr('src'), 'http://localhost').href; + }); + + it('generates correct width and height when both are provided', async () => { + const url = new URL(src); + const { width, height } = await getImageDimensionsFromFixture(fixture, url); + assert.equal(width, 300); + assert.equal(height, 400); + }); + + it('generates correct height when only width is provided', async () => { + const url = new URL(src); + url.searchParams.delete('h'); + const { width, height } = await getImageDimensionsFromFixture(fixture, url); + assert.equal(width, 300); + assert.equal(height, 200); + }); + + it('generates correct width when only height is provided', async () => { + const url = new URL(src); + url.searchParams.delete('w'); + url.searchParams.set('h', '400'); + const { width, height } = await getImageDimensionsFromFixture(fixture, url); + assert.equal(width, 600); + assert.equal(height, 400); + }); + + it('preserves aspect ratio when fit=inside', async () => { + const url = new URL(src); + url.searchParams.set('fit', 'inside'); + const { width, height } = await getImageDimensionsFromFixture(fixture, url); + assert.equal(width, 300); + assert.equal(height, 200); + }); + + it('preserves aspect ratio when fit=contain', async () => { + const url = new URL(src); + url.searchParams.set('fit', 'contain'); + const { width, height } = await getImageDimensionsFromFixture(fixture, url); + assert.equal(width, 300); + assert.equal(height, 200); + }); + + it('preserves aspect ratio when fit=outside', async () => { + const url = new URL(src); + url.searchParams.set('fit', 'outside'); + const { width, height } = await getImageDimensionsFromFixture(fixture, url); + assert.equal(width, 600); + assert.equal(height, 400); + }); + const originalWidth = 2316; + const originalHeight = 1544; + it('does not upscale image if requested size is larger than original', async () => { + const url = new URL(src); + url.searchParams.set('w', '3000'); + url.searchParams.set('h', '2000'); + const { width, height } = await getImageDimensionsFromFixture(fixture, url); + assert.equal(width, originalWidth); + assert.equal(height, originalHeight); + }); + + // To match old behavior, we should upscale if the requested size is larger than the original + it('does upscale image if requested size is larger than original and fit is unset', async () => { + const url = new URL(src); + url.searchParams.set('w', '3000'); + url.searchParams.set('h', '2000'); + url.searchParams.delete('fit'); + const { width, height } = await getImageDimensionsFromFixture(fixture, url); + assert.equal(width, 3000); + assert.equal(height, 2000); + }); + + // To match old behavior, we should upscale if the requested size is larger than the original + it('does not upscale is only one dimension is provided and fit is set', async () => { + const url = new URL(src); + url.searchParams.set('w', '3000'); + url.searchParams.delete('h'); + url.searchParams.set('fit', 'cover'); + const { width, height } = await getImageDimensionsFromFixture(fixture, url); + assert.equal(width, originalWidth); + assert.equal(height, originalHeight); + }); + }); + }); + + describe('build image service', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/core-image-layout/', + }); + removeDir(new URL('./fixtures/core-image-ssg/node_modules/.astro', import.meta.url)); + + await fixture.build(); + }); + + describe('generated images', () => { + let $; + before(async () => { + const html = await fixture.readFile('/build/index.html'); + $ = cheerio.load(html); + }); + + it('generates correct width and height when both are provided', async () => { + const path = $('.both img').attr('src'); + const { width, height } = await getImageDimensionsFromLocalFile(fixture, path); + assert.equal(width, 300); + assert.equal(height, 400); + }); + + it('generates correct height when only width is provided', async () => { + const path = $('.width-only img').attr('src'); + const { width, height } = await getImageDimensionsFromLocalFile(fixture, path); + assert.equal(width, 300); + assert.equal(height, 200); + }); + + it('generates correct width when only height is provided', async () => { + const path = $('.height-only img').attr('src'); + const { width, height } = await getImageDimensionsFromLocalFile(fixture, path); + assert.equal(width, 600); + assert.equal(height, 400); + }); + + it('preserves aspect ratio when fit=inside', async () => { + const path = $('.fit-inside img').attr('src'); + const { width, height } = await getImageDimensionsFromLocalFile(fixture, path); + assert.equal(width, 300); + assert.equal(height, 200); + }); + + it('preserves aspect ratio when fit=contain', async () => { + const path = $('.fit-contain img').attr('src'); + const { width, height } = await getImageDimensionsFromLocalFile(fixture, path); + assert.equal(width, 300); + assert.equal(height, 200); + }); + + it('preserves aspect ratio when fit=outside', async () => { + const path = $('.fit-outside img').attr('src'); + const { width, height } = await getImageDimensionsFromLocalFile(fixture, path); + assert.equal(width, 600); + assert.equal(height, 400); + }); + const originalWidth = 2316; + const originalHeight = 1544; + it('does not upscale image if requested size is larger than original', async () => { + const path = $('.too-large img').attr('src'); + const { width, height } = await getImageDimensionsFromLocalFile(fixture, path); + assert.equal(width, originalWidth); + assert.equal(height, originalHeight); + }); + }); + }); +}); diff --git a/packages/astro/test/fixtures/core-image-layout/src/pages/build.astro b/packages/astro/test/fixtures/core-image-layout/src/pages/build.astro new file mode 100644 index 0000000000..a4a0cc9083 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-layout/src/pages/build.astro @@ -0,0 +1,66 @@ +--- +import { Image } from "astro:assets"; +import penguin from "../assets/penguin.jpg"; +--- + +
+ a penguin +
+ +
+ a penguin +
+ +
+ a penguin +
+ +
+ a penguin +
+ +
+ a penguin +
+ +
+ a penguin +
+ +
+ a penguin +
+ +
+ a penguin +
diff --git a/packages/astro/test/fixtures/core-image-layout/src/pages/fit.astro b/packages/astro/test/fixtures/core-image-layout/src/pages/fit.astro new file mode 100644 index 0000000000..442f4ffb0e --- /dev/null +++ b/packages/astro/test/fixtures/core-image-layout/src/pages/fit.astro @@ -0,0 +1,35 @@ +--- +import { Image } from "astro:assets"; +import penguin from "../assets/penguin.jpg"; +--- + +
+ a penguin +
+
+ a penguin +
+
+ a penguin +
+
+ a penguin +
+
+ a penguin +
+
+ a penguin +
+
+ a penguin +
+
+ a penguin +
+
+ a penguin +
+
+ a penguin +