astro-lqip 1.7.2 → 1.8.1

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.
@@ -0,0 +1 @@
1
+ @layer astro-lqip{[data-astro-lqip]{--opacity:1;--z-index:0;isolation:isolate;width:fit-content;height:fit-content;line-height:0;display:inline-block;position:relative;overflow:clip;&:after{content:"";pointer-events:none;opacity:var(--opacity);z-index:var(--z-index);background:var(--lqip-background);background-position:50%;background-size:cover;transition:opacity 1s;position:absolute;inset:0}& img{z-index:1;position:relative}@media (scripting:none){--opacity:0;--z-index:1}}[data-astro-lqip-bg]{display:contents}}
@@ -0,0 +1,140 @@
1
+ import { LocalImageProps, Picture as Picture$1, RemoteImageProps } from "astro:assets";
2
+ import { GetPlaiceholderReturn } from "plaiceholder";
3
+ import { ImageMetadata } from "astro";
4
+ import { ComponentProps, HTMLAttributes } from "astro/types";
5
+
6
+ //#region src/types/lqip.type.d.ts
7
+ type LqipType = 'color' | 'css' | 'base64' | 'svg';
8
+ //#endregion
9
+ //#region src/types/style.type.d.ts
10
+ type StylePrimitive = string | number | undefined;
11
+ type StyleMap<TExtra = never> = Record<string, StylePrimitive | TExtra>;
12
+ type StyleInput<TExtra = never> = StyleMap<TExtra> | string;
13
+ //#endregion
14
+ //#region src/types/components-options.type.d.ts
15
+ type ComponentsOptions = {
16
+ src: string | object;
17
+ lqip: LqipType;
18
+ lqipSize: number;
19
+ styleProps: StyleMap;
20
+ forbiddenVars: string[];
21
+ isDevelopment: boolean | undefined;
22
+ };
23
+ //#endregion
24
+ //#region src/types/image-path.type.d.ts
25
+ type ImagePath = string | {
26
+ src: string;
27
+ } | Promise<{
28
+ default: {
29
+ src: string;
30
+ };
31
+ }>;
32
+ type ResolvedImage = {
33
+ src: string;
34
+ width?: number;
35
+ height?: number;
36
+ [k: string]: unknown;
37
+ };
38
+ type ImportModule = Record<string, unknown> & {
39
+ default?: unknown;
40
+ };
41
+ type GlobMap = Record<string, () => Promise<ImportModule>>;
42
+ //#endregion
43
+ //#region src/types/image-transform.type.d.ts
44
+ type ValidOutputFormats = ['avif', 'png', 'webp', 'jpeg', 'jpg', 'svg'];
45
+ type ImageQualityPreset = 'low' | 'mid' | 'high' | 'max' | (string & {});
46
+ type ImageQuality = ImageQualityPreset | number;
47
+ type ImageOutputFormat = ValidOutputFormats[number] | (string & {});
48
+ type ImageFit = 'fill' | 'contain' | 'cover' | 'none' | 'scale-down' | (string & {});
49
+ interface ImageTransform {
50
+ /**
51
+ * Path of the image that will be used as the background.
52
+ * It can be a relative path, an absolute path, an alias or ImageMetadata.
53
+ */
54
+ src: string | ImageMetadata;
55
+ /**
56
+ * CSS custom property that will receive the generated background.
57
+ * (defaults to --background)
58
+ */
59
+ cssVariable?: string;
60
+ /**
61
+ * LQIP placeholder strategy for Background (defaults to 'base64').
62
+ * Only 'base64' and 'color' are supported to keep CSS-friendly values.
63
+ */
64
+ lqip?: 'base64' | 'color';
65
+ /**
66
+ * Specifies one or more output formats (first entry is preferred fallback).
67
+ * Accepts a single `ImageOutputFormat` or an ordered array.
68
+ * (defaults to webp)
69
+ */
70
+ format?: ImageOutputFormat | ImageOutputFormat[] | undefined;
71
+ /**
72
+ * Explicit widths used for responsive variants.
73
+ */
74
+ widths?: number[] | undefined;
75
+ /**
76
+ * Single width fallback when `widths` is omitted.
77
+ */
78
+ width?: number | undefined;
79
+ /**
80
+ * Target height for generated assets.
81
+ */
82
+ height?: number | undefined;
83
+ /**
84
+ * Compression quality preset or numeric percentage.
85
+ */
86
+ quality?: ImageQuality | undefined;
87
+ /**
88
+ * Object-fit behavior applied during resizing.
89
+ */
90
+ fit?: ImageFit | undefined;
91
+ }
92
+ //#endregion
93
+ //#region src/types/plaiceholder.type.d.ts
94
+ type GetSVGReturn = GetPlaiceholderReturn['svg'];
95
+ //#endregion
96
+ //#region src/types/props.type.d.ts
97
+ type Props = {
98
+ /**
99
+ * LQIP type.
100
+ * This can be 'color', 'css', 'svg' or 'base64'.
101
+ * The default value is 'base64'.
102
+ */
103
+ lqip?: LqipType;
104
+ /**
105
+ * Size of the LQIP image in pixels.
106
+ * This value should be between 4 and 64.
107
+ * The default value is 4.
108
+ */
109
+ lqipSize?: number;
110
+ };
111
+ //#endregion
112
+ //#region src/types/svg-node.type.d.ts
113
+ type StyleAttrs = StyleInput<GetSVGReturn>;
114
+ type SVGNodeAttrs = {
115
+ style?: StyleAttrs;
116
+ } & Record<string, string | number>;
117
+ type SVGNode = [string, SVGNodeAttrs, SVGNode[]];
118
+ //#endregion
119
+ //#region src/components/Picture.d.ts
120
+ type AstroPictureProps = ComponentProps<typeof Picture$1>;
121
+ type Props$3 = AstroPictureProps & Props;
122
+ //#endregion
123
+ //#region src/components/Image.d.ts
124
+ type Props$2 = (LocalImageProps | RemoteImageProps) & Props & {
125
+ parentAttributes?: HTMLAttributes<'div'>;
126
+ };
127
+ //#endregion
128
+ //#region src/components/Background.d.ts
129
+ type Props$1 = ImageTransform;
130
+ //#endregion
131
+ //#region src/index.d.ts
132
+ type AstroTypedComponent<Props> = ((props: Props) => unknown) & {
133
+ isAstroComponentFactory?: boolean;
134
+ moduleId?: string;
135
+ };
136
+ declare const Image: AstroTypedComponent<Props$2>;
137
+ declare const Picture: AstroTypedComponent<Props$3>;
138
+ declare const Background: AstroTypedComponent<Props$1>;
139
+ //#endregion
140
+ export { Background, ComponentsOptions, GetSVGReturn, GlobMap, Image, ImagePath, ImageTransform, ImportModule, LqipType, Picture, Props, ResolvedImage, SVGNode, StyleAttrs, StyleInput, StyleMap };
package/dist/index.mjs ADDED
@@ -0,0 +1,18 @@
1
+ import './astro-lqip.css';
2
+ import{createComponent as e,renderComponent as t,renderSlotToString as n,renderTemplate as r,spreadAttributes as i}from"astro/runtime/server/index.js";import{Image as a,Picture as o,getImage as s,inferRemoteSize as c}from"astro:assets";import{existsSync as l,statSync as u}from"node:fs";import{mkdir as d,readFile as f,readdir as p,unlink as m,writeFile as h}from"node:fs/promises";import{basename as g,dirname as _,extname as v,join as y,relative as b,resolve as x,sep as S}from"node:path";import{fileURLToPath as C}from"node:url";import{getPlaiceholder as w}from"plaiceholder";const T=`[astro-lqip]`,E=process.cwd(),D=y(E,`src`),O=y(E,`public`),k=y(E,`dist`),ee=import.meta.env?.MODE===`development`,te=/(file:\/\/[^\s)]+|\/[^\s)]+|[A-Za-z]:[^\s)]+):\d+:\d+/,ne=[`${S}node_modules${S}`,`${S}dist${S}`,`${S}.astro${S}`,`${S}.prerender${S}`],re=new Set([`node_modules`,`dist`,`.astro`]),ie=new Set([`.astro`,`.vite`]),A=Object.assign({}),j=new Map,M=new Map;function N(e){if(!e)return;let t=e.toLowerCase();(t.includes(`/public/`)||e.startsWith(O))&&console.warn(`${T} Warning: image resolved from /public. Images should not be placed in /public - move them to /src so Astro can process them correctly.`),(t.endsWith(`.webp`)||t.endsWith(`.avif`))&&console.warn(`${T} Warning: image is in ${t.endsWith(`.webp`)?`webp`:`avif`} format. These formats are usually already optimized; using this component to re-process them may degrade quality.`)}function P(e){return typeof e==`object`&&!!e}function ae(e){return P(e)&&typeof e.then==`function`}function F(e){return P(e)&&typeof e.src==`string`}function oe(e){return/^https?:\/\//.test(e)}function se(e){let t=e.indexOf(`?`),n=e.indexOf(`#`),r=e.length;return t!==-1&&(r=Math.min(r,t)),n!==-1&&(r=Math.min(r,n)),e.slice(0,r)}function I(e){let t=se(e.trim()).replace(/\\/g,`/`);return t.startsWith(`@/`)?`src/${t.slice(2)}`:t.startsWith(`~@/`)?`src/${t.slice(3)}`:t}function L(e){return e.startsWith(`./`)||e.startsWith(`../`)}function R(e){let t=e;for(;t.startsWith(`./`)||t.startsWith(`../`);)t=t.startsWith(`./`)?t.slice(2):t.slice(3);return t}function ce(e){let t=e.match(te);if(!t?.[1])return null;let n=t[1];if(!n.startsWith(`file://`))return n;try{return C(n)}catch{return n.replace(/^file:\/\//,``)}}function le(e){return!e||!e.startsWith(`/`)&&!/^[A-Za-z]:/.test(e)||!e.startsWith(E)?!1:!ne.some(t=>e.includes(t))}function ue(){let e=Error().stack;if(!e)return null;for(let t of e.split(`
3
+ `).slice(2)){let e=ce(t);if(e&&le(e))return _(e)}return null}function z(e){let t=b(E,e);return!e||t.startsWith(`..`)||t.includes(`..${S}`)||t.includes(`node_modules`)?null:e}function de(e){let t=b(E,e).replace(/\\/g,`/`);return!t||t.startsWith(`../`)?null:`/${t}`}function fe(e,t){let n=I(e),r=new Set;if(n.startsWith(`/src/`)?r.add(n):n.startsWith(`src/`)&&r.add(`/${n}`),t&&L(n)){let e=de(x(t,n));e?.startsWith(`/src/`)&&r.add(e)}if(L(n)){let e=R(n);e&&r.add(e.startsWith(`src/`)?`/${e}`:`/src/${e}`);let t=`/${e}`;for(let e of Object.keys(A))e.endsWith(t)&&r.add(e)}return!n.startsWith(`/`)&&!n.startsWith(`src/`)&&!L(n)&&r.add(`/src/${n}`),Array.from(r)}function pe(e,t){let n=I(e),r=new Set,i=n.startsWith(`/`)?n.slice(1):n;if(t&&L(n)){let e=z(x(t,n));e&&r.add(e)}if(L(n)){let e=R(n);if(e){let t=z(y(D,e));t&&r.add(t);let n=z(y(O,e));n&&r.add(n)}}if(n.startsWith(`/src/`)?r.add(y(E,n.slice(1))):n.startsWith(`src/`)&&r.add(y(E,n)),n.startsWith(`/public/`)?r.add(y(E,n.slice(1))):n.startsWith(`public/`)&&r.add(y(E,n)),!n.startsWith(`/`)){let e=z(y(D,n));e&&r.add(e);let t=z(y(E,n));t&&r.add(t)}else if(i){let e=z(y(E,i));e&&r.add(e)}return Array.from(r)}async function B(e){if(!e)return null;let t=`${D}::${e}`;if(j.has(t))return j.get(t)??null;async function n(t){let r;try{r=await p(t,{withFileTypes:!0})}catch{return}for(let i of r){let r=y(t,i.name);if(i.isDirectory()){if(re.has(i.name))continue;let e=await n(r);if(e)return e}else if(i.name===e)return r}}let r=await n(D);return j.set(t,r??null),r??null}function me(e,t){if(e!=null)return String(e).toLowerCase();let n=v(t).replace(`.`,``);return n?n.toLowerCase():void 0}function he(e,t,n,r){return`/@fs${e.replace(/\\/g,`/`)}?origWidth=${t}&origHeight=${n}&origFormat=${r}`}function ge(e,t){let n=g(e);if(t===n)return!0;let r=v(n),i=r?n.slice(0,-r.length):n;return t.startsWith(`${i}.`)&&t.endsWith(r)}function _e(e){let t=e.replace(/\\/g,`/`),n=t.lastIndexOf(`/_astro/`);if(n!==-1)return t.slice(n);let r=b(k,e).replace(/\\/g,`/`),i=r.indexOf(`.prerender/`);return i===-1?`/${r}`:`/${r.slice(i+11)}`}async function ve(e){if(M.has(e))return M.get(e)??null;if(!l(k))return M.set(e,null),null;async function t(n){let r;try{r=await p(n,{withFileTypes:!0})}catch{return}for(let i of r){let r=y(n,i.name);if(i.isDirectory()){if(ie.has(i.name))continue;let e=await t(r);if(e)return e}else if(ge(e,i.name))return r}}let n=await t(k);if(!n)return M.set(e,null),null;let r=_e(n);return M.set(e,r),r}async function V(e){try{let{metadata:t}=await w(await f(e),{size:4}),n=t?.width??0,r=t?.height??0,i=me(t?.format,e);if(!n||!r||!i)return console.warn(`${T} Missing metadata for "${e}".`),null;let a=ee?he(e,n,r,i):await ve(e);if(!a)return null;let o={src:a,width:n,height:r,format:i};return Object.defineProperty(o,`fsPath`,{value:e,enumerable:!1,configurable:!1,writable:!1}),o}catch(t){return console.warn(`${T} Failed to derive metadata for "${e}".`,t),null}}async function ye(e,t){let n=fe(e,t);for(let e of n){let t=A[e];if(!t)continue;let n=await t(),r=n.default??n;if(F(r))return N(r.src),r}return null}async function be(e,t){let n=pe(e,t);for(let e of n){if(!e||!l(e))continue;let t=await V(e);if(t)return N(e),t}let r=I(e).split(`/`).pop();if(!r)return null;let i=await B(r);if(!i||!l(i))return null;let a=await V(i);return a?(N(i),a):null}async function H(e){if(e==null)return null;if(ae(e)){let t=await e,n=t.default??t;return F(n)?(N(n.src),n):typeof n==`string`?(N(n),n):null}if(P(e)){let t=e;return N(typeof t.src==`string`?t.src:void 0),F(t)?t:null}if(typeof e==`string`){if(oe(e))return e;let t=I(e),n=L(t)?ue():null;return await ye(t,n)||await be(t,n)||null}return null}const xe=/[A-Z]/g;function Se(e){return e.replace(xe,e=>`-${e.toLowerCase()}`)}function U(e){if(e==null)return;if(typeof e==`string`)return e;let t=[];for(let[n,r]of Object.entries(e))r!=null&&t.push(`${Se(n)}:${r}`);return t.length>0?t.join(`;`):void 0}function W([e,t,n=[]]){let r=``;for(let[e,n]of Object.entries(t||{}))e===`style`&&n&&typeof n==`object`?r+=` style="${U(n)}"`:n!==void 0&&(r+=` ${e}="${n}"`);return n.length>0?`<${e}${r}>${n.map(W).join(``)}</${e}>`:`<${e}${r} />`}function Ce(e,t,n=``){if(!t)return{};switch(e){case`css`:return{"--lqip-background":t};case`svg`:return{"--lqip-background":`url('data:image/svg+xml;utf8,${encodeURIComponent(n)}')`};case`color`:return{"--lqip-background":t};default:return{"--lqip-background":`url('${t}')`}}}function we(e){return process.platform===`win32`&&/^\/[A-Za-z]:\//.test(e)?e.slice(1):e}function Te(){return typeof process<`u`&&!!process.versions?.node}async function Ee(e){if(Te())try{return await f(e)}catch{return}}function De(e,t=4){let n=Number(e);return Number.isFinite(n)?Math.min(64,Math.max(4,Math.round(n))):t}async function G(e,t,n,r){try{let i=we(e),a=De(n),o=await Ee(i);if(!o){console.warn(`${T} image not found for:`,e);return}let s=await w(o,{size:a}),c;switch(t){case`color`:c=s.color?.hex;break;case`css`:c=typeof s.css==`object`&&s.css.backgroundImage?s.css.backgroundImage:String(s.css);break;case`svg`:c=s.svg;break;default:c=s.base64;break}return r?console.log(`${T} LQIP (${t}) successfully generated!`):console.log(`${T} LQIP (${t}) successfully generated for:`,e),c}catch(n){console.error(`${T} Error generating LQIP (${t}) in:`,e,`
4
+ `,n);return}}function Oe(e){return/^https?:\/\//.test(e)}const K=y(process.cwd(),`node_modules`,`.cache`,`astro-lqip`),ke=[`jpg`,`jpeg`,`png`,`webp`,`avif`],Ae=[`src`],je=/\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9]+$/,Me=/^(.+?)\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9]+$/,q=(()=>{try{let e=import.meta.env?.BASE_URL??`/`;return!e||e===`/`?`/`:e.endsWith(`/`)?e.slice(0,-1):e}catch{return`/`}})(),J=new Map;function Ne(e){if(typeof e!=`string`)return e;let t=e.indexOf(`?`),n=t>=0?e.slice(0,t):e;return q!==`/`&&n.startsWith(q)&&(n=n.slice(q.length)||`/`),n.startsWith(`/`)||(n=`/${n}`),n}async function Pe(){l(K)||await d(K,{recursive:!0})}function Fe(e){let t=e.split(`/`).pop()||``,n=t.match(Me);if(n)return n[1];let r=t.split(`.`);return r.length>=3?r.slice(0,r.length-2).join(`.`):r[0]}async function Ie(e){if(!e)return;if(J.has(e))return J.get(e)||void 0;let t=new Set([`node_modules`,`dist`,`.astro`]);async function n(r){let i;try{i=await p(r)}catch{return}for(let a of i){let i=y(r,a),o;try{o=u(i)}catch{continue}if(o.isDirectory()){if(t.has(a))continue;let e=await n(i);if(e)return e}else if(ke.some(t=>a===`${e}.${t}`))return i}}for(let t of Ae){let r=y(process.cwd(),t);if(l(r)){let t=await n(r);if(t)return J.set(e,t),t}}J.set(e,null)}async function Y(e,t,n,r){if(e?.src){if(Oe(e.src)){await Pe();let i=await fetch(e.src);if(!i.ok)return;let a=await i.arrayBuffer(),o=Buffer.from(a),s=y(K,`astro-lqip-${Math.random().toString(36).slice(2)}.jpg`);await h(s,o);try{return await G(s,t,n,r)}finally{await m(s)}}if(r&&e.src.startsWith(`/@fs/`)){let i=e.src.replace(/^\/@fs/,``).split(`?`)[0];return await G(i,t,n,r)}if(!r){let i=e.src,a=Ne(i),o=a.replace(/^\//,``);if(o){let e=[y(process.cwd(),`dist`,`client`,o),y(process.cwd(),`dist`,o)];for(let i of e)if(l(i))return await G(i,t,n,r)}let s=a.split(`/`).pop()??``;if(je.test(s)){let e=Fe(a),i=await Ie(e);if(i)return console.log(`${T} fallback recursive source found:`,i),await G(i,t,n,r);console.warn(`${T} original source not found recursively for basename:`,e)}}}}async function X({src:e,lqip:t=`base64`,lqipSize:n=4,styleProps:r={},forbiddenVars:i=[`--lqip-background`,`--z-index`,`--opacity`],isDevelopment:a}){let o=await H(e)??null,s;o&&(s=await Y(typeof o==`string`?{src:o}:o,t,n,a));let c=``;t===`svg`&&Array.isArray(s)&&(c=W(s));let l=Ce(t,s,c);for(let e of Object.keys(r))i.includes(e)&&console.warn(`${T} The CSS variable “${e}” should not be passed in style because it can override the functionality of LQIP.`);let u={...r,...l};return{lqipImage:s,svgHTML:c,lqipStyle:l,combinedStyle:u,resolvedSrc:o}}const Le=e({factory:async(e,n)=>{let{class:r,lqip:i=`base64`,lqipSize:a=4,pictureAttributes:s={},...c}=n,{combinedStyle:l,resolvedSrc:u}=await X({src:c.src,lqip:i,lqipSize:a,styleProps:s.style??{},forbiddenVars:[],isDevelopment:import.meta.env.MODE===`development`});return await t(e,`Picture`,o,{...c,class:r,src:u??c.src,pictureAttributes:{"data-astro-lqip":``,...s,style:l},onload:`
5
+ parentElement.style.setProperty("--z-index", 1);
6
+ parentElement.style.setProperty("--opacity", 0);
7
+ `})}}),Re=e({factory:async(e,n)=>{let{class:o,lqip:s=`base64`,lqipSize:c=4,parentAttributes:l={},...u}=n,{combinedStyle:d,resolvedSrc:f}=await X({src:u.src,lqip:s,lqipSize:c,styleProps:l.style??{},forbiddenVars:[],isDevelopment:import.meta.env.MODE===`development`});return r`
8
+ <div ${i({...l,class:[l.class,o].filter(Boolean).join(` `)||void 0,"data-astro-lqip":``,style:U(d)})}>
9
+ ${t(e,`Image`,a,{...u,class:o,src:f??u.src,onload:`
10
+ parentElement.style.setProperty("--z-index", 1);
11
+ parentElement.style.setProperty("--opacity", 0);
12
+ `})}
13
+ </div>
14
+ `}});async function ze({src:e,cssVariable:t=`--background`,format:n=`webp`,widths:r,width:i,height:a,quality:o,fit:l,lqip:u=`base64`,isDevelopment:d}){let f=i,p=a,m=await H(e);if(!m)throw Error(`${T} Could not resolve background image in "${e}"`);let h=typeof m==`string`&&Ue(m)?m:null;if(h&&(!f||!p)){try{let{width:e,height:t}=await c(h);f??=e,p??=t}catch(e){console.warn(`${T} Failed to infer remote background size for "${h}".`,e)}if(!f||!p)throw Error(`${T} Remote background images require width and height. Provide both props or ensure the URL is reachable in your Astro config 'image.domains'.`)}let g=m,_,v=typeof m==`string`?{src:m}:m;if(v){let e=await Y(v,u,12,d);_=qe(u,typeof e==`string`?e:void 0)}let y=He((Array.isArray(n)?n:[n]).filter(e=>typeof e==`string`&&e.trim().length>0)),b=y.length?y:[`webp`],x=Array.isArray(n),S={src:g,widths:r,width:f,height:p,quality:o,fit:l},C=await Promise.all(b.map(e=>s({...S,format:e}))),w=C.map((e,t)=>({format:b[t],mimeType:Z(b[t]),fallbackSrc:e.src,sources:We(e.srcSet?.attribute)})),E=Be(t),D=w[0]?.sources??[],O=Array.isArray(r)&&r.length>0,k=e=>Ke({formatSources:w,optimizedImages:C,isFormatArray:x,selector:e,layer:_});return{style:O?Ge({referenceSources:D,baseVariable:E,createValue:k}):`${E}: ${k()}`,resolvedSrc:m}}function Be(e){let t=e.trim();return t?t.startsWith(`--`)?t:`--${t}`:`--background`}function Ve(e){return e.trim().toLowerCase()}function He(e){let t=[`avif`,`webp`,`png`,`jpeg`,`jpg`,`svg`],n=new Set;return e.map(e=>Ve(e)).filter(e=>!e||n.has(e)?!1:(n.add(e),!0)).sort((e,n)=>{let r=t.indexOf(e),i=t.indexOf(n),a=r===-1?2**53-1:r,o=i===-1?2**53-1:i;return a===o?e.localeCompare(n):a-o})}function Ue(e){return typeof e==`string`&&/^https?:\/\//.test(e)}function Z(e){switch(e){case`avif`:return`image/avif`;case`webp`:return`image/webp`;case`png`:return`image/png`;case`jpeg`:case`jpg`:return`image/jpeg`;case`svg`:return`image/svg+xml`;default:return`image/${e}`}}function We(e){if(!e)return[];let t=e.split(`,`).map(e=>e.trim()).filter(Boolean).map(e=>{let t=e.lastIndexOf(` `);if(t===-1)return{url:e};let n=e.slice(0,t),r=e.slice(t+1).match(/(?<value>\d+(?:\.\d+)?)(?<unit>[wx])/i);return{url:n,width:r?.groups?.unit?.toLowerCase()===`w`?Number.parseFloat(r.groups.value):void 0}}),n=new Map(t.map(e=>[e.url,e]));return Array.from(n.values()).sort((e,t)=>(e.width??0)-(t.width??0))}function Ge({referenceSources:e,baseVariable:t,createValue:n}){if(!e.length)return`${t}: ${n()}`;let r=[`${t}: ${n(e=>e.sources[e.sources.length-1])}`],i=[{suffix:`-small`,match:e=>typeof e==`number`&&e<768},{suffix:`-medium`,match:e=>typeof e==`number`&&e>=768&&e<=1200},{suffix:`-large`,match:e=>typeof e==`number`&&e>1200&&e<=1920},{suffix:`-xlarge`,match:e=>typeof e==`number`&&e>1920}];for(let a of i){let i=[...e].reverse().find(e=>a.match(e.width));if(!i||typeof i.width!=`number`)continue;let o=n(e=>e.sources.find(e=>e.width===i.width));r.push(`${t}${a.suffix}: ${o}`)}return r.join(`; `)}function Q(e,t){return t?.(e)??e.sources[e.sources.length-1]??{url:e.fallbackSrc}}function $(e,t){return e&&t?`${e}, ${t}`:e||t||``}function Ke({formatSources:e,optimizedImages:t,isFormatArray:n,selector:r,layer:i}){if(!e.length){let e=t[0]?.src??``;return $(e?`url('${e}')`:``,i)}if(!n){let t=e[0];return $(`url('${Q(t,r).url??t.fallbackSrc}')`,i)}return $(`image-set(${e.map(e=>`url('${Q(e,r).url??e.fallbackSrc}') type('${e.mimeType}')`).join(`, `)})`,i)}function qe(e,t){if(!(!e||!t))return e===`color`?`linear-gradient(${t}, ${t})`:`url("${t}")`}const Je=e({factory:async(e,t,a)=>{let o=import.meta.env.MODE===`development`,{style:s}=await ze({...t,isDevelopment:o}),c=await n(e,a.default);return r`
15
+ <div data-astro-lqip-bg ${i({style:s})}>
16
+ ${c}
17
+ </div>
18
+ `}}),Ye=Re,Xe=Le,Ze=Je;export{Ze as Background,Ye as Image,Xe as Picture};
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "astro-lqip",
3
- "version": "1.7.2",
4
- "description": "Native extended Astro components for generating low quality image placeholders (LQIP).",
3
+ "version": "1.8.1",
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",
@@ -21,12 +23,21 @@
21
23
  "license": "MIT",
22
24
  "author": "Felix Icaza",
23
25
  "type": "module",
26
+ "types": "./dist/index.d.mts",
24
27
  "exports": {
25
- "./components": "./src/index.ts"
28
+ ".": {
29
+ "types": "./dist/index.d.mts",
30
+ "import": "./dist/index.mjs"
31
+ },
32
+ "./components": {
33
+ "types": "./dist/index.d.mts",
34
+ "import": "./dist/index.mjs"
35
+ }
26
36
  },
27
37
  "files": [
28
- "src"
38
+ "dist"
29
39
  ],
40
+ "sideEffects": false,
30
41
  "simple-git-hooks": {
31
42
  "pre-commit": "pnpm nano-staged"
32
43
  },
@@ -37,34 +48,53 @@
37
48
  "plaiceholder": "3.0.0"
38
49
  },
39
50
  "devDependencies": {
40
- "@eslint/js": "9.39.1",
41
- "@typescript-eslint/parser": "8.48.0",
42
- "astro": "5.16.1",
43
- "bumpp": "10.3.2",
44
- "eslint": "9.39.1",
45
- "eslint-plugin-astro": "1.5.0",
46
- "eslint-plugin-jsonc": "2.21.0",
51
+ "@eslint/js": "9.39.4",
52
+ "@tsdown/css": "0.21.7",
53
+ "@types/node": "25.5.0",
54
+ "@typescript-eslint/parser": "8.57.0",
55
+ "astro": "6.1.2",
56
+ "bumpp": "11.0.1",
57
+ "concurrently": "9.2.1",
58
+ "eslint": "9.39.4",
59
+ "eslint-plugin-astro": "1.6.0",
60
+ "eslint-plugin-jsonc": "3.1.2",
47
61
  "eslint-plugin-jsx-a11y": "6.10.2",
48
- "eslint-plugin-package-json": "0.85.0",
49
- "eslint-plugin-yml": "1.19.0",
50
- "globals": "16.5.0",
62
+ "eslint-plugin-package-json": "0.90.1",
63
+ "eslint-plugin-yml": "3.3.1",
64
+ "globals": "17.4.0",
51
65
  "jiti": "2.6.1",
66
+ "lightningcss": "1.32.0",
52
67
  "nano-staged": "0.9.0",
53
- "neostandard": "0.12.2",
54
- "simple-git-hooks": "2.13.1"
68
+ "neostandard": "0.13.0",
69
+ "simple-git-hooks": "2.13.1",
70
+ "tsdown": "0.21.7"
55
71
  },
56
72
  "peerDependencies": {
57
73
  "astro": ">=3.3.0"
58
74
  },
59
75
  "scripts": {
76
+ "build": "tsdown",
60
77
  "build:docs": "pnpm --filter ./docs -r build",
61
- "dev": "pnpm --filter=!./docs -r dev",
78
+ "build:watch": "tsdown --watch",
79
+ "dev": "concurrently \"pnpm dev:lib\" \"pnpm dev:fixtures\"",
62
80
  "dev:docs": "pnpm --filter ./docs -r dev",
81
+ "dev:fixtures": "pnpm --filter \"./tests/fixtures/**\" -r --parallel dev",
82
+ "dev:lib": "tsdown --watch",
63
83
  "lint": "eslint --cache .",
64
84
  "lint:fix": "eslint --cache --fix .",
65
- "preview": "pnpm -r preview",
66
85
  "release": "bumpp",
86
+ "sync:types": " pnpm --filter \"./tests/fixtures/**\" astro sync",
87
+ "test": "pnpm test:fixtures",
67
88
  "test:fixtures": "pnpm --filter \"./tests/fixtures/**\" -r build",
68
- "test:fixtures:preview": "pnpm --filter \"./tests/fixtures/**\" -r preview"
89
+ "test:fixtures:custom-base": "pnpm --filter \"./tests/fixtures/custom-base\" build",
90
+ "test:fixtures:default": "pnpm --filter \"./tests/fixtures/default\" build",
91
+ "test:fixtures:preview": "pnpm --filter \"./tests/fixtures/**\" -r preview",
92
+ "test:fixtures:preview:custom-base": "pnpm --filter \"./tests/fixtures/custom-base\" preview",
93
+ "test:fixtures:preview:default": "pnpm --filter \"./tests/fixtures/default\" preview",
94
+ "test:fixtures:preview:ssr": "pnpm --filter \"./tests/fixtures/ssr-node\" preview",
95
+ "test:fixtures:preview:ssr:custom-base": "pnpm --filter \"./tests/fixtures/ssr-node-custom-base\" preview",
96
+ "test:fixtures:ssr": "pnpm --filter \"./tests/fixtures/ssr-node\" build",
97
+ "test:fixtures:ssr:custom-base": "pnpm --filter \"./tests/fixtures/ssr-node-custom-base\" build",
98
+ "typecheck": "tsc --noEmit"
69
99
  }
70
100
  }
@@ -1,45 +0,0 @@
1
- ---
2
- import type { LocalImageProps, RemoteImageProps } from 'astro:assets'
3
- import type { HTMLAttributes } from 'astro/types'
4
- import type { Props as LqipProps } from '../types'
5
-
6
- import { useLqipImage } from '../utils/useLqipImage'
7
-
8
- import { Image as ImageComponent } from 'astro:assets'
9
-
10
- import '../styles/lqip.css'
11
-
12
- type Props = (LocalImageProps | RemoteImageProps) & LqipProps & {
13
- parentAttributes?: HTMLAttributes<'div'>
14
- }
15
-
16
- const { class: className, lqip = 'base64', lqipSize = 4, parentAttributes = {}, ...props } = Astro.props as Props
17
-
18
- const isDevelopment = import.meta.env.MODE === 'development'
19
-
20
- const { combinedStyle, resolvedSrc } = await useLqipImage({
21
- src: props.src,
22
- lqip,
23
- lqipSize,
24
- styleProps: (parentAttributes.style ?? {}) as Record<string, string | number | undefined>,
25
- forbiddenVars: [],
26
- isDevelopment
27
- })
28
-
29
- const componentProps = {
30
- ...props,
31
- src: resolvedSrc ?? props.src
32
- }
33
- const combinedParentAttributes = {
34
- ...parentAttributes,
35
- style: combinedStyle
36
- }
37
- ---
38
-
39
- <div class={className} data-astro-lqip {...combinedParentAttributes}>
40
- <ImageComponent
41
- {...componentProps as LocalImageProps | RemoteImageProps}
42
- class={className}
43
- onload="parentElement.style.setProperty('--z-index', 1), parentElement.style.setProperty('--opacity', 0)"
44
- />
45
- </div>
@@ -1,41 +0,0 @@
1
- ---
2
- import type { Props as AstroPictureProps } from 'astro/components/Picture.astro'
3
- import type { Props as LqipProps } from '../types'
4
-
5
- import { useLqipImage } from '../utils/useLqipImage'
6
-
7
- import { Picture as PictureComponent } from 'astro:assets'
8
-
9
- import '../styles/lqip.css'
10
-
11
- type Props = AstroPictureProps & LqipProps
12
-
13
- const { class: className, lqip = 'base64', lqipSize = 4, pictureAttributes = {}, ...props } = Astro.props as Props
14
-
15
- const isDevelopment = import.meta.env.MODE === 'development'
16
-
17
- const { combinedStyle, resolvedSrc } = await useLqipImage({
18
- src: props.src,
19
- lqip,
20
- lqipSize,
21
- styleProps: (pictureAttributes.style ?? {}) as Record<string, string | number | undefined>,
22
- forbiddenVars: [],
23
- isDevelopment
24
- })
25
-
26
- const componentProps = {
27
- ...props,
28
- src: resolvedSrc ?? props.src
29
- }
30
- const combinedPictureAttributes = {
31
- ...pictureAttributes,
32
- style: combinedStyle
33
- }
34
- ---
35
-
36
- <PictureComponent
37
- {...(componentProps as AstroPictureProps)}
38
- class={className}
39
- pictureAttributes={{ 'data-astro-lqip': '', ...combinedPictureAttributes }}
40
- onload="parentElement.style.setProperty('--z-index', 1), parentElement.style.setProperty('--opacity', 0)"
41
- />
@@ -1 +0,0 @@
1
- export const PREFIX = '[astro-lqip]'
package/src/index.ts DELETED
@@ -1,2 +0,0 @@
1
- export { default as Picture } from './components/Picture.astro'
2
- export { default as Image } from './components/Image.astro'
@@ -1,37 +0,0 @@
1
- [data-astro-lqip] {
2
- --opacity: 1;
3
- --z-index: 0;
4
-
5
- position: relative;
6
- display: inline-block;
7
- width: fit-content;
8
- height: fit-content;
9
- }
10
-
11
- [data-astro-lqip]::after {
12
- content: "";
13
- inset: 0;
14
- width: 100%;
15
- height: 100%;
16
- position: absolute;
17
- pointer-events: none;
18
- transition: opacity 1s;
19
- opacity: var(--opacity);
20
- z-index: var(--z-index);
21
- background: var(--lqip-background);
22
- background-size: cover;
23
- background-position: 50% 50%;
24
- }
25
-
26
- [data-astro-lqip] img {
27
- z-index: 1;
28
- position: relative;
29
- overflow: hidden;
30
- }
31
-
32
- @media (scripting: none) {
33
- [data-astro-lqip] {
34
- --opacity: 0;
35
- --z-index: 1;
36
- }
37
- }
@@ -1,10 +0,0 @@
1
- import type { LqipType } from './lqip.type'
2
-
3
- export type ComponentsOptions = {
4
- src: string | object
5
- lqip: LqipType
6
- lqipSize: number
7
- styleProps: Record<string, string | number | undefined>
8
- forbiddenVars: string[]
9
- isDevelopment: boolean | undefined
10
- }
@@ -1,4 +0,0 @@
1
- export type ImagePath = string | { src: string } | Promise<{ default: { src: string } }>
2
- export type ResolvedImage = { src: string, width?: number, height?: number, [k: string]: unknown }
3
- export type ImportModule = Record<string, unknown> & { default?: unknown }
4
- export type GlobMap = Record<string, () => Promise<ImportModule>>
@@ -1,6 +0,0 @@
1
- export * from './components-options.type'
2
- export * from './image-path.type'
3
- export * from './lqip.type'
4
- export * from './plaiceholder.type'
5
- export * from './props.type'
6
- export * from './svg-node.type'
@@ -1 +0,0 @@
1
- export type LqipType = 'color' | 'css' | 'base64' | 'svg'
@@ -1,3 +0,0 @@
1
- import type { GetPlaiceholderReturn } from 'plaiceholder'
2
-
3
- export type GetSVGReturn = GetPlaiceholderReturn['svg']
@@ -1,16 +0,0 @@
1
- import type { LqipType } from './lqip.type'
2
-
3
- export type Props = {
4
- /**
5
- * LQIP type.
6
- * This can be 'color', 'css', 'svg' or 'base64'.
7
- * The default value is 'base64'.
8
- */
9
- lqip?: LqipType
10
- /**
11
- * Size of the LQIP image in pixels.
12
- * This value should be between 4 and 64.
13
- * The default value is 4.
14
- */
15
- lqipSize?: number
16
- }
@@ -1,7 +0,0 @@
1
- export type StyleAttrs = Record<string, string | number>
2
-
3
- type SVGNodeAttrs = {
4
- style?: StyleAttrs
5
- } & Record<string, string | number>
6
-
7
- export type SVGNode = [string, SVGNodeAttrs, SVGNode[]]
@@ -1,77 +0,0 @@
1
- import { readFile } from 'node:fs/promises'
2
-
3
- import type { GetSVGReturn, LqipType } from '../types'
4
-
5
- import { PREFIX } from '../constants'
6
-
7
- import { getPlaiceholder } from 'plaiceholder'
8
-
9
- function normalizeFsPath(path: string) {
10
- if (process.platform === 'win32' && /^\/[A-Za-z]:\//.test(path)) {
11
- return path.slice(1)
12
- }
13
- return path
14
- }
15
-
16
- function isNode() {
17
- return typeof process !== 'undefined' && !!process.versions?.node
18
- }
19
-
20
- async function readIfExists(path: string): Promise<Buffer | undefined> {
21
- if (!isNode()) return undefined
22
- try {
23
- return await readFile(path)
24
- } catch {
25
- return undefined
26
- }
27
- }
28
-
29
- export async function generateLqip(
30
- imagePath: string,
31
- lqipType: LqipType,
32
- lqipSize: number,
33
- isDevelopment: boolean | undefined
34
- ) {
35
- try {
36
- const normalizedPath = normalizeFsPath(imagePath)
37
-
38
- const buffer = await readIfExists(normalizedPath)
39
-
40
- if (!buffer) {
41
- console.warn(`${PREFIX} image not found for:`, imagePath)
42
- return undefined
43
- }
44
-
45
- const plaiceholderResult = await getPlaiceholder(buffer, { size: lqipSize })
46
- let lqipValue: string | GetSVGReturn | undefined
47
-
48
- switch (lqipType) {
49
- case 'color':
50
- lqipValue = plaiceholderResult.color?.hex
51
- break
52
- case 'css':
53
- lqipValue = typeof plaiceholderResult.css === 'object' && plaiceholderResult.css.backgroundImage
54
- ? plaiceholderResult.css.backgroundImage
55
- : String(plaiceholderResult.css)
56
- break
57
- case 'svg':
58
- lqipValue = plaiceholderResult.svg
59
- break
60
- case 'base64':
61
- default:
62
- lqipValue = plaiceholderResult.base64
63
- break
64
- }
65
-
66
- if (isDevelopment) {
67
- console.log(`${PREFIX} LQIP (${lqipType}) successfully generated!`)
68
- } else {
69
- console.log(`${PREFIX} LQIP (${lqipType}) successfully generated for:`, imagePath)
70
- }
71
-
72
- return lqipValue
73
- } catch (err) {
74
- console.error(`${PREFIX} Error generating LQIP (${lqipType}) in:`, imagePath, '\n', err)
75
- return undefined
76
- }
77
- }
@@ -1,189 +0,0 @@
1
- import { join } from 'node:path'
2
- import { mkdir, writeFile, unlink, readdir } from 'node:fs/promises'
3
- import { existsSync, statSync } from 'node:fs'
4
-
5
- import type { LqipType } from '../types'
6
-
7
- import { generateLqip } from './generateLqip'
8
-
9
- import { PREFIX } from '../constants'
10
-
11
- function isRemoteUrl(url: string) {
12
- return /^https?:\/\//.test(url)
13
- }
14
-
15
- const CACHE_DIR = join(process.cwd(), 'node_modules', '.cache', 'astro-lqip')
16
- const EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'avif']
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
- })()
30
-
31
- const searchCache = new Map<string, string | null>()
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
-
50
- async function ensureCacheDir() {
51
- if (!existsSync(CACHE_DIR)) {
52
- await mkdir(CACHE_DIR, { recursive: true })
53
- }
54
- }
55
-
56
- function extractOriginalFileName(filename: string) {
57
- const file = filename.split('/').pop() || ''
58
-
59
- const match = file.match(HASHED_FILENAME_CAPTURE_REGEX)
60
- if (match) return match[1]
61
-
62
- const parts = file.split('.')
63
- if (parts.length >= 3) return parts.slice(0, parts.length - 2).join('.')
64
-
65
- return parts[0]
66
- }
67
-
68
- async function recursiveFind(basename: string): Promise<string | undefined> {
69
- if (!basename) return
70
-
71
- if (searchCache.has(basename)) {
72
- const cached = searchCache.get(basename)
73
- return cached || undefined
74
- }
75
-
76
- const ignoreDirs = new Set(['node_modules', 'dist', '.astro'])
77
-
78
- async function walk(dir: string): Promise<string | undefined> {
79
- let entries: string[]
80
-
81
- try {
82
- entries = await readdir(dir)
83
- } catch {
84
- return
85
- }
86
-
87
- for (const entry of entries) {
88
- const full = join(dir, entry)
89
- let st: ReturnType<typeof statSync>
90
-
91
- try {
92
- st = statSync(full)
93
- } catch {
94
- continue
95
- }
96
-
97
- if (st.isDirectory()) {
98
- if (ignoreDirs.has(entry)) continue
99
- const found = await walk(full)
100
- if (found) return found
101
- } else {
102
- // match by basename and extension
103
- if (EXTENSIONS.some((ext) => entry === `${basename}.${ext}`)) {
104
- return full
105
- }
106
- }
107
- }
108
- }
109
-
110
- for (const rootRel of SEARCH_ROOT) {
111
- const rootAbs = join(process.cwd(), rootRel)
112
-
113
- if (existsSync(rootAbs)) {
114
- const found = await walk(rootAbs)
115
- if (found) {
116
- searchCache.set(basename, found)
117
- return found
118
- }
119
- }
120
- }
121
-
122
- searchCache.set(basename, null)
123
- }
124
-
125
- export async function getLqip(
126
- imagePath: { src: string },
127
- lqipType: LqipType,
128
- lqipSize: number,
129
- isDevelopment: boolean | undefined
130
- ) {
131
- if (!imagePath?.src) return undefined
132
-
133
- if (isRemoteUrl(imagePath.src)) {
134
- await ensureCacheDir()
135
-
136
- const response = await fetch(imagePath.src)
137
- if (!response.ok) return undefined
138
-
139
- const arrayBuffer = await response.arrayBuffer()
140
- const buffer = Buffer.from(arrayBuffer)
141
- const tempPath = join(CACHE_DIR, `astro-lqip-${Math.random().toString(36).slice(2)}.jpg`)
142
- await writeFile(tempPath, buffer)
143
-
144
- try {
145
- return await generateLqip(tempPath, lqipType, lqipSize, isDevelopment)
146
- } finally {
147
- await unlink(tempPath)
148
- }
149
- }
150
-
151
- if (isDevelopment && imagePath.src.startsWith('/@fs/')) {
152
- const filePath = imagePath.src.replace(/^\/@fs/, '').split('?')[0]
153
- return await generateLqip(filePath, lqipType, lqipSize, isDevelopment)
154
- }
155
-
156
- if (!isDevelopment) {
157
- const src = imagePath.src
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
- }
171
- }
172
- }
173
-
174
- const fileName = normalizedSrc.split('/').pop() ?? ''
175
- if (HASHED_FILENAME_REGEX.test(fileName)) {
176
- const originalBase = extractOriginalFileName(normalizedSrc)
177
- const originalSource = await recursiveFind(originalBase)
178
-
179
- if (originalSource) {
180
- console.log(`${PREFIX} fallback recursive source found:`, originalSource)
181
- return await generateLqip(originalSource, lqipType, lqipSize, isDevelopment)
182
- } else {
183
- console.warn(`${PREFIX} original source not found recursively for basename:`, originalBase)
184
- }
185
- }
186
- }
187
-
188
- return undefined
189
- }
@@ -1,17 +0,0 @@
1
- import type { GetSVGReturn, LqipType } from '../types'
2
-
3
- export function getLqipStyle(lqipType: LqipType, lqipImage: string | GetSVGReturn | undefined, svgHTML: string = '') {
4
- if (!lqipImage) return {}
5
-
6
- switch (lqipType) {
7
- case 'css':
8
- return { '--lqip-background': lqipImage }
9
- case 'svg':
10
- return { '--lqip-background': `url('data:image/svg+xml;utf8,${encodeURIComponent(svgHTML)}')` }
11
- case 'color':
12
- return { '--lqip-background': lqipImage }
13
- case 'base64':
14
- default:
15
- return { '--lqip-background': `url('${lqipImage}')` }
16
- }
17
- }
@@ -1,25 +0,0 @@
1
- import type { StyleAttrs, SVGNode } from '../types'
2
-
3
- function styleToString(style: StyleAttrs) {
4
- return Object.entries(style)
5
- .map(([key, val]) => `${key.replace(/([A-Z])/g, '-$1').toLowerCase()}:${val}`)
6
- .join(';')
7
- }
8
-
9
- export function renderSVGNode([tag, attrs, children = []]: SVGNode): string {
10
- let attrString = ''
11
-
12
- for (const [k, v] of Object.entries(attrs || {})) {
13
- if (k === 'style' && v && typeof v === 'object') {
14
- attrString += ` style="${styleToString(v)}"`
15
- } else if (v !== undefined) {
16
- attrString += ` ${k}="${v}"`
17
- }
18
- }
19
-
20
- if (children.length > 0) {
21
- return `<${tag}${attrString}>${children.map(renderSVGNode).join('')}</${tag}>`
22
- } else {
23
- return `<${tag}${attrString} />`
24
- }
25
- }
@@ -1,128 +0,0 @@
1
- import { existsSync } from 'node:fs'
2
- import { join } from 'node:path'
3
-
4
- import type { GlobMap, ImagePath, ImportModule, ResolvedImage } from '../types'
5
-
6
- import { PREFIX } from '../constants'
7
-
8
- const PUBLIC_DIR = join(process.cwd(), 'public')
9
-
10
- const globFilesInSrc: GlobMap = ({ ...import.meta.glob('/src/**/*.{png,jpg,jpeg,svg}') } as unknown) as GlobMap
11
-
12
- function warnFiles(filePath: string | undefined) {
13
- if (!filePath) return
14
-
15
- const lowerPath = filePath.toLowerCase()
16
-
17
- if (lowerPath.includes(`${join('/', 'public')}`) || lowerPath.includes('/public/') || filePath.startsWith(PUBLIC_DIR)) {
18
- console.warn(
19
- `${PREFIX} Warning: image resolved from /public. Images should not be placed in /public — move them to /src so Astro can process them correctly.`
20
- )
21
- }
22
-
23
- if (lowerPath.endsWith('.webp') || lowerPath.endsWith('.avif')) {
24
- const extension = lowerPath.endsWith('.webp') ? 'webp' : 'avif'
25
- console.warn(
26
- `${PREFIX} Warning: image is in ${extension} format. These formats are usually already optimized; using this component to re-process them may degrade quality.`
27
- )
28
- }
29
- }
30
-
31
- function isObject(v: unknown): v is Record<string, unknown> {
32
- return typeof v === 'object' && v !== null
33
- }
34
-
35
- function isPromise(v: unknown): v is Promise<unknown> {
36
- if (!isObject(v)) return false
37
- const promise = v as { then?: unknown }
38
- return typeof promise.then === 'function'
39
- }
40
-
41
- function hasSrc(v: unknown): v is ResolvedImage {
42
- return isObject(v) && typeof (v as Record<string, unknown>)['src'] === 'string'
43
- }
44
-
45
- function isRemoteUrl(v: string) {
46
- return /^https?:\/\//.test(v)
47
- }
48
-
49
- function findGlobMatch(keys: string[], path: string) {
50
- const candidates = [path.replace(/^\//, ''), `/${path.replace(/^\//, '')}`]
51
- const match = keys.find((k) => candidates.includes(k) || k.endsWith(path) || k.endsWith(path.replace(/^\//, '')))
52
- if (match) return match
53
-
54
- const fileName = path.split('/').pop()
55
- if (!fileName) return null
56
-
57
- return keys.find((k) => k.endsWith(`/${fileName}`) || k.endsWith(fileName)) ?? null
58
- }
59
-
60
- export async function resolveImagePath(path: ImagePath) {
61
- if (path == null) return null
62
-
63
- // validate dynamic import (Promise-like)
64
- if (isPromise(path)) {
65
- const mod = (await (path as Promise<ImportModule>)) as ImportModule
66
- const resolved = (mod.default ?? mod) as unknown
67
- if (hasSrc(resolved)) {
68
- warnFiles(resolved.src)
69
- return resolved
70
- }
71
- if (typeof resolved === 'string') {
72
- warnFiles(resolved)
73
- return resolved
74
- }
75
- return null
76
- }
77
-
78
- // validate already-resolved object (import result or { src: ... })
79
- if (isObject(path)) {
80
- const obj = path as Record<string, unknown>
81
- const objSrc = typeof obj['src'] === 'string' ? (obj['src'] as string) : undefined
82
- warnFiles(objSrc)
83
- return hasSrc(obj) ? (obj as ResolvedImage) : null
84
- }
85
-
86
- // validate string path
87
- if (typeof path === 'string') {
88
- if (isRemoteUrl(path)) return path
89
-
90
- const keys = Object.keys(globFilesInSrc)
91
- const matchKey = findGlobMatch(keys, path)
92
-
93
- if (matchKey) {
94
- try {
95
- const mod = await globFilesInSrc[matchKey]()
96
- const resolved = (mod.default ?? mod) as unknown
97
-
98
- if (hasSrc(resolved)) {
99
- warnFiles((resolved as ResolvedImage).src)
100
- return resolved as ResolvedImage
101
- }
102
-
103
- if (typeof resolved === 'string') {
104
- warnFiles(resolved)
105
- return resolved
106
- }
107
- } catch (err) {
108
- console.log(`${PREFIX} resolveImagePath: failed to import glob match "${matchKey}" — falling back to filesystem.`, err)
109
- }
110
- }
111
-
112
- // If module doesn't expose a usable value, fall through to filesystem check
113
- try {
114
- const absCandidate = path.startsWith('/') ? join(process.cwd(), path) : join(process.cwd(), path)
115
-
116
- if (existsSync(absCandidate)) {
117
- warnFiles(absCandidate)
118
- return { src: `/@fs${absCandidate}` }
119
- }
120
- } catch (err) {
121
- console.debug(`${PREFIX} resolveImagePath: filesystem check failed for "${path}".`, err)
122
- }
123
-
124
- return null
125
- }
126
-
127
- return null
128
- }
@@ -1,50 +0,0 @@
1
- import type { ComponentsOptions, ImagePath, SVGNode } from '../types'
2
-
3
- import { PREFIX } from '../constants'
4
-
5
- import { resolveImagePath } from './resolveImagePath'
6
- import { renderSVGNode } from './renderSVGNode'
7
- import { getLqipStyle } from './getLqipStyle'
8
- import { getLqip } from './getLqip'
9
-
10
- export async function useLqipImage({
11
- src,
12
- lqip = 'base64',
13
- lqipSize = 4,
14
- styleProps = {},
15
- forbiddenVars = ['--lqip-background', '--z-index', '--opacity'],
16
- isDevelopment
17
- }: ComponentsOptions) {
18
- // resolve any kind of src (string, alias, import result, dynamic import)
19
- const resolved = await resolveImagePath(src as unknown as ImagePath)
20
- // resolved may be an object (module-like), { src: '...' } or null
21
- const resolvedSrc = resolved ?? null
22
-
23
- let lqipImage
24
- if (resolvedSrc) {
25
- const lqipInput = typeof resolvedSrc === 'string' ? { src: resolvedSrc } : resolvedSrc
26
- lqipImage = await getLqip(lqipInput, lqip, lqipSize, isDevelopment)
27
- }
28
-
29
- let svgHTML = ''
30
- if (lqip === 'svg' && Array.isArray(lqipImage)) {
31
- svgHTML = renderSVGNode(lqipImage as unknown as SVGNode)
32
- }
33
-
34
- const lqipStyle = getLqipStyle(lqip, lqipImage, svgHTML)
35
-
36
- for (const key of Object.keys(styleProps)) {
37
- if (forbiddenVars.includes(key)) {
38
- console.warn(
39
- `${PREFIX} The CSS variable “${key}” should not be passed in style because it can override the functionality of LQIP.`
40
- )
41
- }
42
- }
43
-
44
- const combinedStyle = {
45
- ...styleProps,
46
- ...lqipStyle
47
- }
48
-
49
- return { lqipImage, svgHTML, lqipStyle, combinedStyle, resolvedSrc }
50
- }