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 +165 -8
- package/package.json +23 -17
- package/src/components/Background.astro +21 -0
- package/src/index.ts +1 -0
- package/src/styles/background.css +3 -0
- package/src/types/image-transform.type.ts +54 -0
- package/src/types/index.ts +1 -0
- package/src/utils/getLqip.ts +46 -16
- package/src/utils/useLqipBackground.ts +298 -0
package/README.md
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
# 🖼️ astro-lqip
|
|
6
6
|
|
|
7
|
-
[](https://
|
|
7
|
+
[](https://npmx.dev/package/astro-lqip)
|
|
8
8
|
[](https://github.com/felixicaza/astro-lqip/blob/main/LICENSE)
|
|
9
9
|
|
|
10
|
-
Native extended Astro components for generating low
|
|
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.
|
|
4
|
-
"description": "Native extended Astro components for generating low
|
|
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.
|
|
41
|
-
"@typescript-eslint/parser": "8.
|
|
42
|
-
"astro": "
|
|
43
|
-
"bumpp": "10.
|
|
44
|
-
"eslint": "9.39.
|
|
45
|
-
"eslint-plugin-astro": "1.
|
|
46
|
-
"eslint-plugin-jsonc": "
|
|
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.
|
|
49
|
-
"eslint-plugin-yml": "
|
|
50
|
-
"globals": "
|
|
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.
|
|
53
|
-
"neostandard": "0.
|
|
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
|
|
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
|
@@ -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
|
+
}
|
package/src/types/index.ts
CHANGED
package/src/utils/getLqip.ts
CHANGED
|
@@ -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(
|
|
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 =
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
145
|
-
if (
|
|
146
|
-
const originalBase = extractOriginalFileName(
|
|
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
|
+
}
|