astro-lqip 1.7.1 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,13 +4,13 @@
4
4
 
5
5
  # 🖼️ astro-lqip
6
6
 
7
- [![GitHub Release](https://img.shields.io/github/v/release/felixicaza/astro-lqip?logo=npm)](https://www.npmjs.com/package/astro-lqip)
7
+ [![GitHub Release](https://img.shields.io/github/v/release/felixicaza/astro-lqip?logo=npm)](https://npmx.dev/package/astro-lqip)
8
8
  [![GitHub License](https://img.shields.io/github/license/felixicaza/astro-lqip)](https://github.com/felixicaza/astro-lqip/blob/main/LICENSE)
9
9
 
10
- Native extended Astro components for generating low quality image placeholders (LQIP).
10
+ Native extended Astro components for generating low-quality image placeholders (LQIP), compatible with `<img>`, `<picture>` and CSS background images.
11
11
 
12
12
  ## ✨ Features
13
- - 🖼️ Supports both `<Image>` and `<Picture>` components.
13
+ - 🖼️ Supports both `<Image>` and `<Picture>` components and CSS background images.
14
14
  - 🎨 Multiple LQIP techniques: base64, solid color, CSS via gradients and SVG.
15
15
  - 🚀 Easy to use, just replace the native Astro components with the ones from [astro-lqip](https://astro-lqip.web.app/).
16
16
  - ⚡️ Support images as static imports or using string paths.
@@ -40,7 +40,7 @@ yarn add astro-lqip
40
40
 
41
41
  ## 🚀 Usage
42
42
 
43
- In your current Astro project, just replace the import of the native Astro `<Image>` or `<Picture>` components with the ones provided by [astro-lqip](https://astro-lqip.web.app/).
43
+ In your current Astro project, just replace the import of the native Astro `<Image>` or `<Picture>` components with the ones provided by [astro-lqip](https://astro-lqip.web.app/) or import the `<Background>` component for CSS background images.
44
44
 
45
45
  ### Image
46
46
 
@@ -56,18 +56,38 @@ In your current Astro project, just replace the import of the native Astro `<Ima
56
56
  + import { Picture } from 'astro-lqip/components';
57
57
  ```
58
58
 
59
+ ### Background
60
+
61
+ ```diff
62
+ + import { Background } from 'astro-lqip/components';
63
+ ```
64
+
59
65
  Example:
60
66
 
61
67
  ```astro
62
68
  ---
63
- import { Image, Picture } from 'astro-lqip/components';
69
+ import { Image, Picture, Background } from 'astro-lqip/components';
64
70
 
65
71
  import image from '/src/assets/images/image.png';
66
72
  import otherImage from '/src/assets/images/other-image.png';
73
+ import backgroundImage from '/src/assets/images/background-image.png';
67
74
  ---
68
75
 
69
76
  <Image src={image} alt="Cover Image" width={220} height={220} />
70
77
  <Picture src={otherImage} alt="Other Image" width={220} height={220} />
78
+ <Background src={backgroundImage}>
79
+ <section>
80
+ <p>Optimized background</p>
81
+ </section>
82
+ </Background>
83
+
84
+ <style>
85
+ section {
86
+ background-image: var(--background);
87
+ background-size: cover;
88
+ background-position: center;
89
+ }
90
+ </style>
71
91
  ```
72
92
 
73
93
  > [!TIP]
@@ -77,40 +97,81 @@ Example with absolute path:
77
97
 
78
98
  ```astro
79
99
  ---
80
- import { Image, Picture } from 'astro-lqip/components';
100
+ import { Image, Picture, Background } from 'astro-lqip/components';
81
101
  ---
82
102
 
83
103
  <Image src="/src/assets/images/image.png" alt="Cover Image" width={220} height={220} />
84
104
  <Picture src="/src/assets/images/other-image.png" alt="Other Image" width={220} height={220} />
105
+ <Background src="/src/assets/images/background-image.png">
106
+ <section>
107
+ <p>Optimized background</p>
108
+ </section>
109
+ </Background>
110
+
111
+ <style>
112
+ section {
113
+ background-image: var(--background);
114
+ background-size: cover;
115
+ background-position: center;
116
+ }
117
+ </style>
85
118
  ```
86
119
 
87
120
  Example with relative path:
88
121
 
89
122
  ```astro
90
123
  ---
91
- import { Image, Picture } from 'astro-lqip/components';
124
+ import { Image, Picture, Background } from 'astro-lqip/components';
92
125
  ---
93
126
 
94
127
  <!-- assuming you are on the path `/src/pages/index.astro` -->
95
128
  <Image src="../assets/images/image.png" alt="Cover Image" width={220} height={220} />
96
129
  <Picture src="../assets/images/other-image.png" alt="Other Image" width={220} height={220} />
130
+ <Background src="../assets/images/background-image.png">
131
+ <section>
132
+ <p>Optimized background</p>
133
+ </section>
134
+ </Background>
135
+
136
+ <style>
137
+ section {
138
+ background-image: var(--background);
139
+ background-size: cover;
140
+ background-position: center;
141
+ }
142
+ </style>
97
143
  ```
98
144
 
99
145
  Example with alias:
100
146
 
101
147
  ```astro
102
148
  ---
103
- import { Image, Picture } from 'astro-lqip/components';
149
+ import { Image, Picture, Background } from 'astro-lqip/components';
104
150
  ---
105
151
 
106
152
  <Image src="@/assets/images/image.png" alt="Cover Image" width={220} height={220} />
107
153
  <Picture src="@/assets/images/other-image.png" alt="Other Image" width={220} height={220} />
154
+ <Background src="@/assets/images/background-image.png">
155
+ <section>
156
+ <p>Optimized background</p>
157
+ </section>
158
+ </Background>
159
+
160
+ <style>
161
+ section {
162
+ background-image: var(--background);
163
+ background-size: cover;
164
+ background-position: center;
165
+ }
166
+ </style>
108
167
  ```
109
168
 
110
169
  Learn how to configure path aliasing in the [Astro documentation](https://docs.astro.build/en/guides/typescript/#import-aliases). If you want more examples of uses you can see the [Usage Tips](https://astro-lqip.web.app/usage-tips/) page.
111
170
 
112
171
  ## ⚙️ Props
113
172
 
173
+ ### Image and Picture
174
+
114
175
  Both `<Image>` and `<Picture>` components support all the props of the [native Astro components](https://docs.astro.build/en/reference/modules/astro-assets/), but adds a couple of props for LQIP management:
115
176
 
116
177
  - `lqip`: The LQIP type to use. It can be one of the following:
@@ -140,6 +201,102 @@ import otherImage from '/src/assets/images/other-image.png';
140
201
  > [!TIP]
141
202
  > For the `<Image>` component, a `parentAttributes` prop similar to `pictureAttributes` has been added.
142
203
 
204
+ ### Background
205
+
206
+ The `<Background>` component supports the following props:
207
+
208
+ - `src` (required): The source of the background image located in `src` folder. It can be a static import, absolute path, relative path, alias path or remote URL.
209
+ - `lqip`: The LQIP type to use. It can be one of the following:
210
+ - `base64`: Generates a Base64-encoded LQIP image. (default option)
211
+ - `color`: Generates a solid color placeholder.
212
+ - `cssVariable`: A string that represents the name of the CSS variable to store the background data.
213
+ - By default, the background data is stored in a CSS variable named `--background`.
214
+ - For responsive backgrounds, the CSS variable names are generated based on the provided widths, following the pattern `--background-small` (lower than 768px), `--background-medium` (768px to 1199px), `--background-large` (1200px to 1919px) and `--background-xlarge` (1920px and above). `--background` is also generated for the largest image for backward compatibility.
215
+ - If the `cssVariable` prop is provided, the generated CSS variable names will follow the pattern `--{cssVariable}-small`, `--{cssVariable}-medium`, `--{cssVariable}-large` and `--{cssVariable}-xlarge`.
216
+ - `format`: The image format to use for the background. It can be one of the following in string or an array of strings. If an array is provided, this generates multiple background images with the native [`image-set()`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/image/image-set) CSS function, which allows the browser to choose the best format to use based on its support:
217
+ - `avif`
218
+ - `webp` (default option)
219
+ - `jpeg`
220
+ - `png`
221
+ - `widths`: An array of numbers that represents the widths to use for responsive background images.
222
+ - `width`: The width to use for the background image. This prop is ignored if the `widths` prop is provided.
223
+ - `height`: The height to use for the background image. This prop is ignored if the `widths` prop is provided.
224
+ - `quality`: The quality to use for the background image. It can be a number from `1` to `100`.
225
+ - `fit`: The fit to use for the background image. It can be one of the following:
226
+ - `cover`
227
+ - `contain`
228
+ - `fill`
229
+ - `inside`
230
+ - `outside`
231
+
232
+ Example:
233
+
234
+ ```astro
235
+ ---
236
+ import { Background } from 'astro-lqip/components';
237
+ import backgroundImage from '/src/assets/images/background-image.png';
238
+ ---
239
+
240
+ <Background src={backgroundImage} lqip="color" cssVariable="--bg-lqip" format={["avif", "webp"]} width={500} height={300} quality={80} fit="cover">
241
+ <section>
242
+ <p>Optimized background</p>
243
+ </section>
244
+ </Background>
245
+
246
+ <style>
247
+ section {
248
+ background-image: var(--bg-lqip);
249
+ background-size: cover;
250
+ background-position: center;
251
+ }
252
+ </style>
253
+ ```
254
+
255
+ Example with responsive background:
256
+
257
+ ```astro
258
+ ---
259
+ import { Background } from 'astro-lqip/components';
260
+ import backgroundImage from '/src/assets/images/background-image.png';
261
+ ---
262
+
263
+ <Background src={backgroundImage} format="avif" widths={[475, 1000, 1536, 2100]}>
264
+ <section>
265
+ <p>Optimized background</p>
266
+ </section>
267
+ </Background>
268
+
269
+ <style>
270
+ section {
271
+ background-image: var(--background-small); /* 475px */
272
+ background-size: cover;
273
+ background-position: center;
274
+ }
275
+
276
+ @media (width >= 768px) {
277
+ section {
278
+ background-image: var(--background-medium); /* 1000px */
279
+ }
280
+ }
281
+
282
+ @media (width >= 1200px) {
283
+ section {
284
+ background-image: var(--background-large); /* 1536px */
285
+ }
286
+ }
287
+
288
+ @media (width >= 1920px) {
289
+ section {
290
+ /* or var(--background), since it's the default variable for the largest image */
291
+ background-image: var(--background-xlarge); /* 2100px */
292
+ }
293
+ }
294
+ </style>
295
+ ```
296
+
297
+ > [!NOTE]
298
+ > The `lqipSize` prop is not compatible with this component, to avoid large CSS outputs.
299
+
143
300
  ## 💡 Knowledge
144
301
 
145
302
  Since this integration is built on top of Astro native `<Image>` and `<Picture>` components, you can refer to the [Astro documentation](https://docs.astro.build/en/guides/images/) for more information on how to use it.
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "astro-lqip",
3
- "version": "1.7.1",
4
- "description": "Native extended Astro components for generating low quality image placeholders (LQIP).",
3
+ "version": "1.8.0",
4
+ "description": "🖼️ Native extended Astro components for generating low-quality image placeholders (LQIP), compatible with <img>, <picture> and CSS background images.",
5
5
  "keywords": [
6
6
  "astro",
7
7
  "astro-component",
8
8
  "astro-integration",
9
9
  "image",
10
10
  "picture",
11
+ "css",
12
+ "background",
11
13
  "lqip"
12
14
  ],
13
15
  "homepage": "https://astro-lqip.web.app",
@@ -27,6 +29,7 @@
27
29
  "files": [
28
30
  "src"
29
31
  ],
32
+ "sideEffects": false,
30
33
  "simple-git-hooks": {
31
34
  "pre-commit": "pnpm nano-staged"
32
35
  },
@@ -37,31 +40,34 @@
37
40
  "plaiceholder": "3.0.0"
38
41
  },
39
42
  "devDependencies": {
40
- "@eslint/js": "9.39.1",
41
- "@typescript-eslint/parser": "8.46.4",
42
- "astro": "5.15.5",
43
- "bumpp": "10.3.1",
44
- "eslint": "9.39.1",
45
- "eslint-plugin-astro": "1.5.0",
46
- "eslint-plugin-jsonc": "2.21.0",
43
+ "@eslint/js": "9.39.4",
44
+ "@typescript-eslint/parser": "8.57.0",
45
+ "astro": "6.0.4",
46
+ "bumpp": "10.4.1",
47
+ "eslint": "9.39.4",
48
+ "eslint-plugin-astro": "1.6.0",
49
+ "eslint-plugin-jsonc": "3.1.2",
47
50
  "eslint-plugin-jsx-a11y": "6.10.2",
48
- "eslint-plugin-package-json": "0.71.0",
49
- "eslint-plugin-yml": "1.19.0",
50
- "globals": "16.5.0",
51
+ "eslint-plugin-package-json": "0.90.1",
52
+ "eslint-plugin-yml": "3.3.1",
53
+ "globals": "17.4.0",
51
54
  "jiti": "2.6.1",
52
- "nano-staged": "0.8.0",
53
- "neostandard": "0.12.2",
55
+ "nano-staged": "0.9.0",
56
+ "neostandard": "0.13.0",
54
57
  "simple-git-hooks": "2.13.1"
55
58
  },
56
59
  "peerDependencies": {
57
60
  "astro": ">=3.3.0"
58
61
  },
59
62
  "scripts": {
60
- "build": "pnpm -r build",
61
- "dev": "pnpm -r dev",
63
+ "build:docs": "pnpm --filter ./docs -r build",
64
+ "dev": "pnpm --filter=!./docs -r dev",
65
+ "dev:docs": "pnpm --filter ./docs -r dev",
62
66
  "lint": "eslint --cache .",
63
67
  "lint:fix": "eslint --cache --fix .",
64
68
  "preview": "pnpm -r preview",
65
- "release": "bumpp -r"
69
+ "release": "bumpp",
70
+ "test:fixtures": "pnpm --filter \"./tests/fixtures/**\" -r build",
71
+ "test:fixtures:preview": "pnpm --filter \"./tests/fixtures/**\" -r preview"
66
72
  }
67
73
  }
@@ -0,0 +1,21 @@
1
+ ---
2
+ import type { ImageTransform } from '../types'
3
+
4
+ import { useLqipBackground } from '../utils/useLqipBackground'
5
+
6
+ import '../styles/background.css'
7
+
8
+ type Props = ImageTransform
9
+
10
+ const props = Astro.props as Props
11
+ const isDevelopment = import.meta.env.MODE === 'development'
12
+
13
+ const { style: backgroundStyle } = await useLqipBackground({
14
+ ...props,
15
+ isDevelopment
16
+ })
17
+ ---
18
+
19
+ <div data-astro-lqip-bg style={backgroundStyle}>
20
+ <slot />
21
+ </div>
package/src/index.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export { default as Picture } from './components/Picture.astro'
2
2
  export { default as Image } from './components/Image.astro'
3
+ export { default as Background } from './components/Background.astro'
@@ -0,0 +1,3 @@
1
+ [data-astro-lqip-bg] {
2
+ display: contents;
3
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * This file defines the types for the ImageTransform, which is used to optimize images for the Background component.
3
+ * Extracted from Astro's Types, reference: https://github.com/withastro/astro/blob/2dcd8d54c6fb00183228d757bf684e67c79029d8/packages/astro/src/assets/types.ts#L82
4
+ */
5
+
6
+ type ValidOutputFormats = ['avif', 'png', 'webp', 'jpeg', 'jpg', 'svg']
7
+ type ImageQualityPreset = 'low' | 'mid' | 'high' | 'max' | (string & {})
8
+ type ImageQuality = ImageQualityPreset | number
9
+ type ImageOutputFormat = ValidOutputFormats[number] | (string & {})
10
+ type ImageFit = 'fill' | 'contain' | 'cover' | 'none' | 'scale-down' | (string & {})
11
+
12
+ export interface ImageTransform {
13
+ /**
14
+ * Path of the image that will be used as the background.
15
+ * It can be a relative path, an absolute path, an alias or ImageMetadata.
16
+ */
17
+ src: string | ImageMetadata
18
+ /**
19
+ * CSS custom property that will receive the generated background.
20
+ * (defaults to --background)
21
+ */
22
+ cssVariable?: string
23
+ /**
24
+ * LQIP placeholder strategy for Background (defaults to 'base64').
25
+ * Only 'base64' and 'color' are supported to keep CSS-friendly values.
26
+ */
27
+ lqip?: 'base64' | 'color'
28
+ /**
29
+ * Specifies one or more output formats (first entry is preferred fallback).
30
+ * Accepts a single `ImageOutputFormat` or an ordered array.
31
+ * (defaults to webp)
32
+ */
33
+ format?: ImageOutputFormat | ImageOutputFormat[] | undefined
34
+ /**
35
+ * Explicit widths used for responsive variants.
36
+ */
37
+ widths?: number[] | undefined
38
+ /**
39
+ * Single width fallback when `widths` is omitted.
40
+ */
41
+ width?: number | undefined
42
+ /**
43
+ * Target height for generated assets.
44
+ */
45
+ height?: number | undefined
46
+ /**
47
+ * Compression quality preset or numeric percentage.
48
+ */
49
+ quality?: ImageQuality | undefined
50
+ /**
51
+ * Object-fit behavior applied during resizing.
52
+ */
53
+ fit?: ImageFit | undefined
54
+ }
@@ -1,5 +1,6 @@
1
1
  export * from './components-options.type'
2
2
  export * from './image-path.type'
3
+ export * from './image-transform.type'
3
4
  export * from './lqip.type'
4
5
  export * from './plaiceholder.type'
5
6
  export * from './props.type'
@@ -15,9 +15,38 @@ function isRemoteUrl(url: string) {
15
15
  const CACHE_DIR = join(process.cwd(), 'node_modules', '.cache', 'astro-lqip')
16
16
  const EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'avif']
17
17
  const SEARCH_ROOT = ['src']
18
+ const HASHED_FILENAME_REGEX = /\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9]+$/
19
+ const HASHED_FILENAME_CAPTURE_REGEX = /^(.+?)\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9]+$/
20
+
21
+ const BASE_URL = (() => {
22
+ try {
23
+ const base = (import.meta.env?.BASE_URL ?? '/') as string
24
+ if (!base || base === '/') return '/'
25
+ return base.endsWith('/') ? base.slice(0, -1) : base
26
+ } catch {
27
+ return '/'
28
+ }
29
+ })()
18
30
 
19
31
  const searchCache = new Map<string, string | null>()
20
32
 
33
+ function stripBasePath(src: string) {
34
+ if (typeof src !== 'string') return src
35
+
36
+ const queryIndex = src.indexOf('?')
37
+ let pathOnly = queryIndex >= 0 ? src.slice(0, queryIndex) : src
38
+
39
+ if (BASE_URL !== '/' && pathOnly.startsWith(BASE_URL)) {
40
+ pathOnly = pathOnly.slice(BASE_URL.length) || '/'
41
+ }
42
+
43
+ if (!pathOnly.startsWith('/')) {
44
+ pathOnly = `/${pathOnly}`
45
+ }
46
+
47
+ return pathOnly
48
+ }
49
+
21
50
  async function ensureCacheDir() {
22
51
  if (!existsSync(CACHE_DIR)) {
23
52
  await mkdir(CACHE_DIR, { recursive: true })
@@ -27,7 +56,7 @@ async function ensureCacheDir() {
27
56
  function extractOriginalFileName(filename: string) {
28
57
  const file = filename.split('/').pop() || ''
29
58
 
30
- const match = file.match(/^(.+?)\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9]+$/)
59
+ const match = file.match(HASHED_FILENAME_CAPTURE_REGEX)
31
60
  if (match) return match[1]
32
61
 
33
62
  const parts = file.split('.')
@@ -126,24 +155,25 @@ export async function getLqip(
126
155
 
127
156
  if (!isDevelopment) {
128
157
  const src = imagePath.src
129
-
130
- const clean = src.replace(/^\//, '')
131
-
132
- const candidatePaths = [
133
- join(process.cwd(), 'dist', 'client', clean),
134
- join(process.cwd(), 'dist', clean),
135
- join(process.cwd(), 'dist', '_astro', clean.replace(/^_astro\//, ''))
136
- ]
137
-
138
- for (const path of candidatePaths) {
139
- if (existsSync(path)) {
140
- return await generateLqip(path, lqipType, lqipSize, isDevelopment)
158
+ const normalizedSrc = stripBasePath(src)
159
+ const clean = normalizedSrc.replace(/^\//, '')
160
+
161
+ if (clean) {
162
+ const candidatePaths = [
163
+ join(process.cwd(), 'dist', 'client', clean),
164
+ join(process.cwd(), 'dist', clean)
165
+ ]
166
+
167
+ for (const path of candidatePaths) {
168
+ if (existsSync(path)) {
169
+ return await generateLqip(path, lqipType, lqipSize, isDevelopment)
170
+ }
141
171
  }
142
172
  }
143
173
 
144
- // if not found, try to recursively find the original source
145
- if (src.startsWith('/_astro/')) {
146
- const originalBase = extractOriginalFileName(src)
174
+ const fileName = normalizedSrc.split('/').pop() ?? ''
175
+ if (HASHED_FILENAME_REGEX.test(fileName)) {
176
+ const originalBase = extractOriginalFileName(normalizedSrc)
147
177
  const originalSource = await recursiveFind(originalBase)
148
178
 
149
179
  if (originalSource) {
@@ -0,0 +1,298 @@
1
+ import type { ImageMetadata } from 'astro'
2
+ import type { ImageTransform } from '../types'
3
+
4
+ import { getImage, inferRemoteSize } from 'astro:assets'
5
+
6
+ import { resolveImagePath } from './resolveImagePath'
7
+ import { getLqip } from './getLqip'
8
+ import { PREFIX } from '../constants'
9
+
10
+ type SourceEntry = {
11
+ url: string
12
+ width?: number
13
+ }
14
+
15
+ type FormatSourceSet = {
16
+ format: string
17
+ mimeType: string
18
+ fallbackSrc: string
19
+ sources: SourceEntry[]
20
+ }
21
+
22
+ type FormatSelector = (formatSource: FormatSourceSet) => SourceEntry | undefined
23
+
24
+ type BuildResponsiveBackgroundStyleOptions = {
25
+ referenceSources: SourceEntry[]
26
+ baseVariable: string
27
+ createValue: (selector?: FormatSelector) => string
28
+ }
29
+
30
+ type CreateBackgroundValueOptions = {
31
+ formatSources: FormatSourceSet[]
32
+ optimizedImages: Awaited<ReturnType<typeof getImage>>[]
33
+ isFormatArray: boolean
34
+ selector?: FormatSelector
35
+ layer?: string
36
+ }
37
+
38
+ export type UseLqipBackgroundOptions = ImageTransform & {
39
+ cssVariable?: string
40
+ lqip?: string
41
+ isDevelopment: boolean
42
+ }
43
+
44
+ export async function useLqipBackground({
45
+ src,
46
+ cssVariable = '--background',
47
+ format = 'webp',
48
+ widths,
49
+ width,
50
+ height,
51
+ quality,
52
+ fit,
53
+ lqip = 'base64',
54
+ isDevelopment
55
+ }: UseLqipBackgroundOptions) {
56
+ let normalizedWidth = width
57
+ let normalizedHeight = height
58
+
59
+ const resolvedSrc = await resolveImagePath(src)
60
+ if (!resolvedSrc) {
61
+ throw new Error(`${PREFIX} Could not resolve background image in "${src}"`)
62
+ }
63
+
64
+ const remoteBackgroundUrl = typeof resolvedSrc === 'string' && isRemoteSource(resolvedSrc) ? resolvedSrc : null
65
+ if (remoteBackgroundUrl && (!normalizedWidth || !normalizedHeight)) {
66
+ try {
67
+ const { width: inferredWidth, height: inferredHeight } = await inferRemoteSize(remoteBackgroundUrl)
68
+ normalizedWidth ??= inferredWidth
69
+ normalizedHeight ??= inferredHeight
70
+ } catch (error) {
71
+ console.warn(`${PREFIX} Failed to infer remote background size for "${remoteBackgroundUrl}".`, error)
72
+ }
73
+
74
+ if (!normalizedWidth || !normalizedHeight) {
75
+ throw new Error(
76
+ `${PREFIX} Remote background images require width and height. Provide both props or ensure the URL is reachable in your Astro config 'image.domains'.`
77
+ )
78
+ }
79
+ }
80
+
81
+ const imageInput: string | ImageMetadata = typeof resolvedSrc === 'string' ? resolvedSrc : (resolvedSrc as ImageMetadata)
82
+
83
+ let lqipLayer: string | undefined
84
+ const lqipSize = 12
85
+ const lqipInput = typeof resolvedSrc === 'string' ? { src: resolvedSrc } : resolvedSrc
86
+ if (lqipInput) {
87
+ const rawLqipValue = await getLqip(lqipInput, lqip, lqipSize, isDevelopment)
88
+ lqipLayer = formatLqipLayer(lqip, typeof rawLqipValue === 'string' ? rawLqipValue : undefined)
89
+ }
90
+
91
+ const formatValues = Array.isArray(format) ? format : [format]
92
+ const normalizedFormats = sortFormats(
93
+ formatValues.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
94
+ )
95
+ const targetFormats = normalizedFormats.length ? normalizedFormats : ['webp']
96
+ const isFormatArray = Array.isArray(format)
97
+
98
+ const sharedImageOptions = {
99
+ src: imageInput,
100
+ widths,
101
+ width: normalizedWidth,
102
+ height: normalizedHeight,
103
+ quality,
104
+ fit
105
+ }
106
+
107
+ const optimizedImages = await Promise.all(
108
+ targetFormats.map((currentFormat) => getImage({ ...sharedImageOptions, format: currentFormat }))
109
+ )
110
+
111
+ const formatSources: FormatSourceSet[] = optimizedImages.map((optimizedImage, index) => ({
112
+ format: targetFormats[index],
113
+ mimeType: mimeTypeFromFormat(targetFormats[index]),
114
+ fallbackSrc: optimizedImage.src,
115
+ sources: parseSrcSetAttribute(optimizedImage.srcSet?.attribute)
116
+ }))
117
+
118
+ const baseVariable = normalizeCssVariableName(cssVariable)
119
+ const referenceSources = formatSources[0]?.sources ?? []
120
+ const hasWidths = Array.isArray(widths) && widths.length > 0
121
+
122
+ const createValueWithLqip = (selector?: FormatSelector) =>
123
+ createBackgroundValue({
124
+ formatSources,
125
+ optimizedImages,
126
+ isFormatArray,
127
+ selector,
128
+ layer: lqipLayer
129
+ })
130
+
131
+ const backgroundStyle = hasWidths
132
+ ? buildResponsiveBackgroundStyle({
133
+ referenceSources,
134
+ baseVariable,
135
+ createValue: createValueWithLqip
136
+ })
137
+ : `${baseVariable}: ${createValueWithLqip()}`
138
+
139
+ return { style: backgroundStyle, resolvedSrc }
140
+ }
141
+
142
+ function normalizeCssVariableName(variable: string) {
143
+ const trimmed = variable.trim()
144
+ if (!trimmed) return '--background'
145
+ return trimmed.startsWith('--') ? trimmed : `--${trimmed}`
146
+ }
147
+
148
+ function normalizeFormatValue(value: string) {
149
+ return value.trim().toLowerCase()
150
+ }
151
+
152
+ function sortFormats(values: string[]) {
153
+ const formatPriority = ['avif', 'webp', 'png', 'jpeg', 'jpg', 'svg']
154
+ const seen = new Set<string>()
155
+ const unique = values
156
+ .map((value) => normalizeFormatValue(value))
157
+ .filter((value) => {
158
+ if (!value || seen.has(value)) return false
159
+ seen.add(value)
160
+ return true
161
+ })
162
+
163
+ return unique.sort((a, b) => {
164
+ const priorityA = formatPriority.indexOf(a)
165
+ const priorityB = formatPriority.indexOf(b)
166
+ const normalizedA = priorityA === -1 ? Number.MAX_SAFE_INTEGER : priorityA
167
+ const normalizedB = priorityB === -1 ? Number.MAX_SAFE_INTEGER : priorityB
168
+ if (normalizedA === normalizedB) {
169
+ return a.localeCompare(b)
170
+ }
171
+ return normalizedA - normalizedB
172
+ })
173
+ }
174
+
175
+ function isRemoteSource(value: unknown): value is string {
176
+ return typeof value === 'string' && /^https?:\/\//.test(value)
177
+ }
178
+
179
+ function mimeTypeFromFormat(value: string) {
180
+ switch (value) {
181
+ case 'avif':
182
+ return 'image/avif'
183
+ case 'webp':
184
+ return 'image/webp'
185
+ case 'png':
186
+ return 'image/png'
187
+ case 'jpeg':
188
+ case 'jpg':
189
+ return 'image/jpeg'
190
+ case 'svg':
191
+ return 'image/svg+xml'
192
+ default:
193
+ return `image/${value}`
194
+ }
195
+ }
196
+
197
+ function parseSrcSetAttribute(attribute?: string): SourceEntry[] {
198
+ if (!attribute) return []
199
+
200
+ const entries = attribute
201
+ .split(',')
202
+ .map((entry) => entry.trim())
203
+ .filter(Boolean)
204
+ .map((entry) => {
205
+ const lastSpaceIndex = entry.lastIndexOf(' ')
206
+ if (lastSpaceIndex === -1) {
207
+ return { url: entry } satisfies SourceEntry
208
+ }
209
+
210
+ const url = entry.slice(0, lastSpaceIndex)
211
+ const descriptor = entry.slice(lastSpaceIndex + 1)
212
+ const widthMatch = descriptor.match(/(?<value>\d+(?:\.\d+)?)(?<unit>[wx])/i)
213
+ const width
214
+ = widthMatch?.groups?.unit?.toLowerCase() === 'w' ? Number.parseFloat(widthMatch.groups.value) : undefined
215
+
216
+ return { url, width }
217
+ })
218
+
219
+ const unique = new Map(entries.map((entry) => [entry.url, entry]))
220
+ return Array.from(unique.values()).sort((a, b) => (a.width ?? 0) - (b.width ?? 0))
221
+ }
222
+
223
+ function buildResponsiveBackgroundStyle({ referenceSources, baseVariable, createValue }: BuildResponsiveBackgroundStyleOptions) {
224
+ if (!referenceSources.length) {
225
+ return `${baseVariable}: ${createValue()}`
226
+ }
227
+
228
+ const declarations = [
229
+ `${baseVariable}: ${createValue((formatSource) => formatSource.sources[formatSource.sources.length - 1])}`
230
+ ]
231
+
232
+ const buckets = [
233
+ { suffix: '-small', match: (width?: number) => typeof width === 'number' && width < 768 },
234
+ { suffix: '-medium', match: (width?: number) => typeof width === 'number' && width >= 768 && width <= 1200 },
235
+ { suffix: '-large', match: (width?: number) => typeof width === 'number' && width > 1200 && width <= 1920 },
236
+ { suffix: '-xlarge', match: (width?: number) => typeof width === 'number' && width > 1920 }
237
+ ] as const
238
+
239
+ for (const bucket of buckets) {
240
+ const referenceMatch = [...referenceSources].reverse().find((source) => bucket.match(source.width))
241
+ if (!referenceMatch || typeof referenceMatch.width !== 'number') continue
242
+
243
+ const value = createValue((formatSource) =>
244
+ formatSource.sources.find((source) => source.width === referenceMatch.width)
245
+ )
246
+ declarations.push(`${baseVariable}${bucket.suffix}: ${value}`)
247
+ }
248
+
249
+ return declarations.join('; ')
250
+ }
251
+
252
+ function selectSourceOrFallback(formatSource: FormatSourceSet, selector?: FormatSelector) {
253
+ return selector?.(formatSource) ?? formatSource.sources[formatSource.sources.length - 1] ?? { url: formatSource.fallbackSrc }
254
+ }
255
+
256
+ function appendLqipLayer(baseValue: string, layer?: string) {
257
+ if (baseValue && layer) {
258
+ return `${baseValue}, ${layer}`
259
+ }
260
+ return baseValue || layer || ''
261
+ }
262
+
263
+ function createBackgroundValue({
264
+ formatSources,
265
+ optimizedImages,
266
+ isFormatArray,
267
+ selector,
268
+ layer
269
+ }: CreateBackgroundValueOptions) {
270
+ if (!formatSources.length) {
271
+ const fallbackSrc = optimizedImages[0]?.src ?? ''
272
+ const baseValue = fallbackSrc ? `url('${fallbackSrc}')` : ''
273
+ return appendLqipLayer(baseValue, layer)
274
+ }
275
+
276
+ if (!isFormatArray) {
277
+ const primary = formatSources[0]
278
+ const selected = selectSourceOrFallback(primary, selector)
279
+ const baseValue = `url('${selected.url ?? primary.fallbackSrc}')`
280
+ return appendLqipLayer(baseValue, layer)
281
+ }
282
+
283
+ const parts = formatSources.map((formatSource) => {
284
+ const selected = selectSourceOrFallback(formatSource, selector)
285
+ const url = selected.url ?? formatSource.fallbackSrc
286
+ return `url('${url}') type('${formatSource.mimeType}')`
287
+ })
288
+
289
+ return appendLqipLayer(`image-set(${parts.join(', ')})`, layer)
290
+ }
291
+
292
+ function formatLqipLayer(lqipType?: string, value?: string) {
293
+ if (!lqipType || !value) return undefined
294
+ if (lqipType === 'color') {
295
+ return `linear-gradient(${value}, ${value})`
296
+ }
297
+ return `url("${value}")`
298
+ }