Skip to content

Commit 8e516ab

Browse files
committed
feat: add support to handle strings paths, module-like imports and dynamic imports
1 parent f770eea commit 8e516ab

File tree

6 files changed

+151
-24
lines changed

6 files changed

+151
-24
lines changed

src/components/Image.astro

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const { class: className, lqip = 'base64', lqipSize = 4, parentAttributes = {},
1818
const isDevelopment = import.meta.env.MODE === 'development'
1919
const isPrerendered = Astro.isPrerendered
2020
21-
const { combinedStyle } = await useLqipImage({
21+
const { combinedStyle, resolvedSrc } = await useLqipImage({
2222
src: props.src,
2323
lqip,
2424
lqipSize,
@@ -28,6 +28,10 @@ const { combinedStyle } = await useLqipImage({
2828
isPrerendered
2929
})
3030
31+
const componentProps = {
32+
...props,
33+
src: resolvedSrc ?? props.src
34+
}
3135
const combinedParentAttributes = {
3236
...parentAttributes,
3337
style: combinedStyle
@@ -36,7 +40,7 @@ const combinedParentAttributes = {
3640

3741
<div class={className} data-astro-lqip {...combinedParentAttributes}>
3842
<ImageComponent
39-
{...props}
43+
{...componentProps as LocalImageProps | RemoteImageProps}
4044
class={className}
4145
onload="parentElement.style.setProperty('--z-index', 1), parentElement.style.setProperty('--opacity', 0)"
4246
/>

src/components/Picture.astro

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const { class: className, lqip = 'base64', lqipSize = 4, pictureAttributes = {},
1515
const isDevelopment = import.meta.env.MODE === 'development'
1616
const isPrerendered = Astro.isPrerendered
1717
18-
const { combinedStyle } = await useLqipImage({
18+
const { combinedStyle, resolvedSrc } = await useLqipImage({
1919
src: props.src,
2020
lqip,
2121
lqipSize,
@@ -25,14 +25,18 @@ const { combinedStyle } = await useLqipImage({
2525
isPrerendered
2626
})
2727
28+
const componentProps = {
29+
...props,
30+
src: resolvedSrc ?? props.src
31+
}
2832
const combinedPictureAttributes = {
2933
...pictureAttributes,
3034
style: combinedStyle
3135
}
3236
---
3337

3438
<PictureComponent
35-
{...props}
39+
{...(componentProps as AstroPictureProps)}
3640
class={className}
3741
pictureAttributes={{ 'data-astro-lqip': '', ...combinedPictureAttributes }}
3842
onload="parentElement.style.setProperty('--z-index', 1), parentElement.style.setProperty('--opacity', 0)"

src/types/image-path.type.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
11
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>>

src/utils/resolveImagePath.ts

Lines changed: 124 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}
259

360
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+
9127
return null
10128
}

src/utils/useLqipImage.ts

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ComponentsOptions, SVGNode } from '../types'
1+
import type { ComponentsOptions, ImagePath, SVGNode } from '../types'
22

33
import { PREFIX } from '../constants'
44

@@ -16,19 +16,14 @@ export async function useLqipImage({
1616
isDevelopment,
1717
isPrerendered
1818
}: ComponentsOptions) {
19-
let getImagePath: string | { src: string } | null
20-
21-
if (typeof src === 'string') {
22-
getImagePath = src
23-
} else if (typeof src === 'object' && src !== null) {
24-
getImagePath = await resolveImagePath(src as unknown as string)
25-
} else {
26-
getImagePath = null
27-
}
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
2823

2924
let lqipImage
30-
if (getImagePath) {
31-
const lqipInput = typeof getImagePath === 'string' ? { src: getImagePath } : getImagePath
25+
if (resolvedSrc) {
26+
const lqipInput = typeof resolvedSrc === 'string' ? { src: resolvedSrc } : resolvedSrc
3227
lqipImage = await getLqip(lqipInput, lqip, lqipSize, isDevelopment, isPrerendered)
3328
}
3429

@@ -52,5 +47,5 @@ export async function useLqipImage({
5247
...lqipStyle
5348
}
5449

55-
return { lqipImage, svgHTML, lqipStyle, combinedStyle }
50+
return { lqipImage, svgHTML, lqipStyle, combinedStyle, resolvedSrc }
5651
}

tsconfig.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
{
2-
"extends": "astro/tsconfigs/strict"
2+
"extends": "astro/tsconfigs/strict",
3+
"compilerOptions": {
4+
"types": ["astro/client"]
5+
}
36
}

0 commit comments

Comments
 (0)