astro-lqip 1.4.1 → 1.6.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
@@ -9,6 +9,15 @@
9
9
 
10
10
  Native extended Astro components for generating low quality image placeholders (LQIP).
11
11
 
12
+ ## ✨ Features
13
+ - 🖼️ Supports both `<Image>` and `<Picture>` components.
14
+ - 🎨 Multiple LQIP techniques: base64, solid color, CSS via gradients and SVG.
15
+ - 🚀 Easy to use, just replace the native Astro components with the ones from [astro-lqip](https://astro-lqip.web.app/).
16
+ - ⚡️ Support images as static imports or using string paths.
17
+ - 🔧 Fully compatible with [Astro's image optimization features](https://docs.astro.build/en/guides/images/).
18
+ - 🌍 Supports both local and remote images.
19
+ - ⚙️ Supports SSR mode with [Node Adapter](https://docs.astro.build/en/guides/integrations-guide/node/).
20
+
12
21
  ## ⬇️ Installation
13
22
 
14
23
  NPM:
@@ -58,9 +67,47 @@ import otherImage from './path/to/other-image.png';
58
67
  ---
59
68
 
60
69
  <Image src={image} alt="Cover Image" width={220} height={220} />
61
- <Picture src={otherImage} alt="Other cover Image" width={220} height={220} />
70
+ <Picture src={otherImage} alt="Other Image" width={220} height={220} />
71
+ ```
72
+
73
+ > [!TIP]
74
+ > Since version `1.6.0`, you can also put the image path as string directly in the `src` prop. Support absolute paths in `src`, relative paths and alias.
75
+
76
+ Example with absolute path:
77
+
78
+ ```astro
79
+ ---
80
+ import { Image, Picture } from 'astro-lqip/components';
81
+ ---
82
+
83
+ <Image src="/src/path/to/image.png" alt="Cover Image" width={220} height={220} />
84
+ <Picture src="/src/path/to/other-image.png" alt="Other Image" width={220} height={220} />
85
+ ```
86
+
87
+ Example with relative path:
88
+
89
+ ```astro
90
+ ---
91
+ import { Image, Picture } from 'astro-lqip/components';
92
+ ---
93
+
94
+ <Image src="../path/to/image.png" alt="Cover Image" width={220} height={220} />
95
+ <Picture src="../path/to/other-image.png" alt="Other Image" width={220} height={220} />
62
96
  ```
63
97
 
98
+ Example with alias:
99
+
100
+ ```astro
101
+ ---
102
+ import { Image, Picture } from 'astro-lqip/components';
103
+ ---
104
+
105
+ <Image src="@/assets/image.png" alt="Cover Image" width={220} height={220} />
106
+ <Picture src="@/assets/other-image.png" alt="Other Image" width={220} height={220} />
107
+ ```
108
+
109
+ Learn how to configure path aliasing in the [Astro documentation](https://docs.astro.build/en/guides/typescript/#import-aliases).
110
+
64
111
  ## ⚙️ Props
65
112
 
66
113
  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:
@@ -73,7 +120,7 @@ Both `<Image>` and `<Picture>` components support all the props of the [native A
73
120
  - `lqipSize`: The size of the LQIP image, which can be any number from `4` to `64`. (default is 4)
74
121
 
75
122
  > [!WARNING]
76
- > A major size in the `lqipSize` prop can significantly impact the performance of your application.
123
+ > A high value for `lqipSize` can significantly increase the total size of your website.
77
124
 
78
125
  Example:
79
126
 
@@ -86,21 +133,12 @@ import otherImage from './path/to/other-image.png';
86
133
  ---
87
134
 
88
135
  <Image src={image} alt="Cover Image" width={220} height={220} lqip="svg" lqipSize={10} />
89
- <Picture src={otherImage} alt="Other cover Image" width={220} height={220} lqip="css" lqipSize={7} />
136
+ <Picture src={otherImage} alt="Other Image" width={220} height={220} lqip="css" lqipSize={7} />
90
137
  ```
91
138
 
92
139
  > [!TIP]
93
140
  > For the `<Image>` component, a `parentAttributes` prop similar to `pictureAttributes` has been added.
94
141
 
95
- ## 📝 ToDo
96
-
97
- - [x] Add support for Image component.
98
- - [x] Add support for more lqip techniques.
99
- - [x] Test for remote images.
100
- - [x] Optimize current CSS usage.
101
- - [x] Improve docs page.
102
- - [ ] Test support for SSR mode.
103
-
104
142
  ## 💡 Knowledge
105
143
 
106
144
  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,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-lqip",
3
- "version": "1.4.1",
3
+ "version": "1.6.0",
4
4
  "description": "Native extended Astro components for generating low quality image placeholders (LQIP).",
5
5
  "keywords": [
6
6
  "astro",
@@ -37,18 +37,18 @@
37
37
  "plaiceholder": "3.0.0"
38
38
  },
39
39
  "devDependencies": {
40
- "@eslint/js": "9.36.0",
41
- "@typescript-eslint/parser": "8.44.1",
42
- "astro": "5.13.11",
43
- "bumpp": "10.2.3",
44
- "eslint": "9.36.0",
40
+ "@eslint/js": "9.37.0",
41
+ "@typescript-eslint/parser": "8.46.1",
42
+ "astro": "5.14.5",
43
+ "bumpp": "10.3.1",
44
+ "eslint": "9.37.0",
45
45
  "eslint-plugin-astro": "1.3.1",
46
- "eslint-plugin-jsonc": "2.20.1",
46
+ "eslint-plugin-jsonc": "2.21.0",
47
47
  "eslint-plugin-jsx-a11y": "6.10.2",
48
- "eslint-plugin-package-json": "0.56.3",
49
- "eslint-plugin-yml": "1.18.0",
48
+ "eslint-plugin-package-json": "0.56.4",
49
+ "eslint-plugin-yml": "1.19.0",
50
50
  "globals": "16.4.0",
51
- "jiti": "2.6.0",
51
+ "jiti": "2.6.1",
52
52
  "nano-staged": "0.8.0",
53
53
  "neostandard": "0.12.2",
54
54
  "simple-git-hooks": "2.13.1"
@@ -16,15 +16,22 @@ type Props = (LocalImageProps | RemoteImageProps) & LqipProps & {
16
16
  const { class: className, lqip = 'base64', lqipSize = 4, parentAttributes = {}, ...props } = Astro.props as Props
17
17
 
18
18
  const isDevelopment = import.meta.env.MODE === 'development'
19
+ const isPrerendered = Astro.isPrerendered
19
20
 
20
- const { combinedStyle } = await useLqipImage({
21
+ const { combinedStyle, resolvedSrc } = await useLqipImage({
21
22
  src: props.src,
22
23
  lqip,
23
24
  lqipSize,
24
- styleProps: parentAttributes.style ?? {},
25
- isDevelopment
25
+ styleProps: (parentAttributes.style ?? {}) as Record<string, string | number | undefined>,
26
+ forbiddenVars: [],
27
+ isDevelopment,
28
+ isPrerendered
26
29
  })
27
30
 
31
+ const componentProps = {
32
+ ...props,
33
+ src: resolvedSrc ?? props.src
34
+ }
28
35
  const combinedParentAttributes = {
29
36
  ...parentAttributes,
30
37
  style: combinedStyle
@@ -33,7 +40,7 @@ const combinedParentAttributes = {
33
40
 
34
41
  <div class={className} data-astro-lqip {...combinedParentAttributes}>
35
42
  <ImageComponent
36
- {...props}
43
+ {...componentProps as LocalImageProps | RemoteImageProps}
37
44
  class={className}
38
45
  onload="parentElement.style.setProperty('--z-index', 1), parentElement.style.setProperty('--opacity', 0)"
39
46
  />
@@ -13,15 +13,22 @@ type Props = AstroPictureProps & LqipProps
13
13
  const { class: className, lqip = 'base64', lqipSize = 4, pictureAttributes = {}, ...props } = Astro.props as Props
14
14
 
15
15
  const isDevelopment = import.meta.env.MODE === 'development'
16
+ const isPrerendered = Astro.isPrerendered
16
17
 
17
- const { combinedStyle } = await useLqipImage({
18
+ const { combinedStyle, resolvedSrc } = await useLqipImage({
18
19
  src: props.src,
19
20
  lqip,
20
21
  lqipSize,
21
- styleProps: pictureAttributes.style ?? {},
22
- isDevelopment
22
+ styleProps: (pictureAttributes.style ?? {}) as Record<string, string | number | undefined>,
23
+ forbiddenVars: [],
24
+ isDevelopment,
25
+ isPrerendered
23
26
  })
24
27
 
28
+ const componentProps = {
29
+ ...props,
30
+ src: resolvedSrc ?? props.src
31
+ }
25
32
  const combinedPictureAttributes = {
26
33
  ...pictureAttributes,
27
34
  style: combinedStyle
@@ -29,7 +36,7 @@ const combinedPictureAttributes = {
29
36
  ---
30
37
 
31
38
  <PictureComponent
32
- {...props}
39
+ {...(componentProps as AstroPictureProps)}
33
40
  class={className}
34
41
  pictureAttributes={{ 'data-astro-lqip': '', ...combinedPictureAttributes }}
35
42
  onload="parentElement.style.setProperty('--z-index', 1), parentElement.style.setProperty('--opacity', 0)"
@@ -2,9 +2,10 @@ import type { LqipType } from './lqip.type'
2
2
 
3
3
  export type ComponentsOptions = {
4
4
  src: string | object
5
- lqip?: LqipType
6
- lqipSize?: number
7
- styleProps?: Record<string, any>
8
- forbiddenVars?: string[]
9
- isDevelopment?: boolean
5
+ lqip: LqipType
6
+ lqipSize: number
7
+ styleProps: Record<string, string | number | undefined>
8
+ forbiddenVars: string[]
9
+ isDevelopment: boolean | undefined
10
+ isPrerendered: boolean | undefined
10
11
  }
@@ -1 +1,4 @@
1
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>>
@@ -6,7 +6,12 @@ import { PREFIX } from '../constants'
6
6
 
7
7
  import { getPlaiceholder } from 'plaiceholder'
8
8
 
9
- export async function generateLqip(imagePath: string, isDevelopment: boolean, lqipType: LqipType, lqipSize: number) {
9
+ export async function generateLqip(
10
+ imagePath: string,
11
+ lqipType: LqipType,
12
+ lqipSize: number,
13
+ isDevelopment: boolean | undefined
14
+ ) {
10
15
  try {
11
16
  const buffer = await readFile(imagePath)
12
17
  const plaiceholderResult = await getPlaiceholder(buffer, { size: lqipSize })
@@ -18,7 +18,13 @@ async function ensureCacheDir() {
18
18
  }
19
19
  }
20
20
 
21
- export async function getLqip(imagePath: { src: string }, isDevelopment: boolean, lqipType: LqipType, lqipSize: number) {
21
+ export async function getLqip(
22
+ imagePath: { src: string },
23
+ lqipType: LqipType,
24
+ lqipSize: number,
25
+ isDevelopment: boolean | undefined,
26
+ isPrerendered: boolean | undefined
27
+ ) {
22
28
  if (!imagePath?.src) return undefined
23
29
 
24
30
  if (isRemoteUrl(imagePath.src)) {
@@ -33,7 +39,7 @@ export async function getLqip(imagePath: { src: string }, isDevelopment: boolean
33
39
  await writeFile(tempPath, buffer)
34
40
 
35
41
  try {
36
- const lqip = await generateLqip(tempPath, isDevelopment, lqipType, lqipSize)
42
+ const lqip = await generateLqip(tempPath, lqipType, lqipSize, isDevelopment)
37
43
  return lqip
38
44
  } finally {
39
45
  await unlink(tempPath)
@@ -42,12 +48,17 @@ export async function getLqip(imagePath: { src: string }, isDevelopment: boolean
42
48
 
43
49
  if (isDevelopment && imagePath.src.startsWith('/@fs/')) {
44
50
  const filePath = imagePath.src.replace(/^\/@fs/, '').split('?')[0]
45
- return await generateLqip(filePath, isDevelopment, lqipType, lqipSize)
51
+ return await generateLqip(filePath, lqipType, lqipSize, isDevelopment)
52
+ }
53
+
54
+ if (!isPrerendered && !isDevelopment) {
55
+ const filePath = join(process.cwd(), 'dist', 'client', imagePath.src)
56
+ return await generateLqip(filePath, lqipType, lqipSize, isDevelopment)
46
57
  }
47
58
 
48
59
  if (!isDevelopment && imagePath.src.startsWith('/_astro/')) {
49
60
  const buildPath = join(process.cwd(), 'dist', imagePath.src)
50
- return await generateLqip(buildPath, isDevelopment, lqipType, lqipSize)
61
+ return await generateLqip(buildPath, lqipType, lqipSize, isDevelopment)
51
62
  }
52
63
 
53
64
  return undefined
@@ -1,10 +1,128 @@
1
- import type { ImagePath } from '../types'
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
+ }
2
59
 
3
60
  export async function resolveImagePath(path: ImagePath) {
4
- // If it's a string, we can't resolve it here. ex: Remote images URLs
5
- if (typeof path === 'string') return null
6
- // Handle dynamic imports
7
- if ('then' in path && typeof path.then === 'function') return (await path).default
8
- if ('src' in path) return path
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
+
9
127
  return null
10
128
  }
@@ -1,4 +1,4 @@
1
- import type { ComponentsOptions, SVGNode } from '../types'
1
+ import type { ComponentsOptions, ImagePath, SVGNode } from '../types'
2
2
 
3
3
  import { PREFIX } from '../constants'
4
4
 
@@ -13,22 +13,18 @@ export async function useLqipImage({
13
13
  lqipSize = 4,
14
14
  styleProps = {},
15
15
  forbiddenVars = ['--lqip-background', '--z-index', '--opacity'],
16
- isDevelopment = false
16
+ isDevelopment,
17
+ isPrerendered
17
18
  }: ComponentsOptions) {
18
- let getImagePath: string | { src: string } | null
19
-
20
- if (typeof src === 'string') {
21
- getImagePath = src
22
- } else if (typeof src === 'object' && src !== null) {
23
- getImagePath = await resolveImagePath(src as unknown as string)
24
- } else {
25
- getImagePath = null
26
- }
19
+ // resolve any kind of src (string, alias, import result, dynamic import)
20
+ const resolved = await resolveImagePath(src as unknown as ImagePath)
21
+ // resolved may be an object (module-like), { src: '...' } or null
22
+ const resolvedSrc = resolved ?? null
27
23
 
28
24
  let lqipImage
29
- if (getImagePath) {
30
- const lqipInput = typeof getImagePath === 'string' ? { src: getImagePath } : getImagePath
31
- lqipImage = await getLqip(lqipInput, isDevelopment, lqip, lqipSize)
25
+ if (resolvedSrc) {
26
+ const lqipInput = typeof resolvedSrc === 'string' ? { src: resolvedSrc } : resolvedSrc
27
+ lqipImage = await getLqip(lqipInput, lqip, lqipSize, isDevelopment, isPrerendered)
32
28
  }
33
29
 
34
30
  let svgHTML = ''
@@ -51,5 +47,5 @@ export async function useLqipImage({
51
47
  ...lqipStyle
52
48
  }
53
49
 
54
- return { lqipImage, svgHTML, lqipStyle, combinedStyle }
50
+ return { lqipImage, svgHTML, lqipStyle, combinedStyle, resolvedSrc }
55
51
  }