Last active
February 2, 2024 08:23
-
-
Save iamriajul/1ec2b195cf371d8fecf674945aad2196 to your computer and use it in GitHub Desktop.
An example for Qwik Image component which tries to be SEO Friendly. The image.tsx is the full-featured component, and the image-raw.tsx is bare minimum component.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import {component$, type PropsOf} from "@builder.io/qwik"; | |
import {imgSizes, type ImageSize} from "~/utils/img"; | |
type ImageAttributes = Omit<PropsOf<'img'>, 'children' | 'sizes'>; | |
export interface ImageRawProps extends ImageAttributes { | |
sizes?: string | ImageSize[]; | |
defaultSize?: boolean; | |
sizeMargin?: string | null; | |
} | |
/** | |
* Image which add some tying sugar to the `sizes` attribute. | |
* This component should be used instead of the native `img` tag. | |
*/ | |
export default component$<ImageRawProps>(({ | |
sizes = [], | |
defaultSize = true, | |
sizeMargin = '30px', | |
decoding = "async", | |
loading = "lazy", | |
...props | |
}) => { | |
return <img | |
decoding={decoding} | |
loading={loading} | |
sizes={ | |
typeof sizes as unknown == 'string' || sizes.length | |
? imgSizes(sizes, defaultSize, sizeMargin) | |
: undefined | |
} | |
{...props} | |
/>; | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import {component$} from "@builder.io/qwik"; | |
import {buildImageFillCropUrl, buildImageFillUrl, buildImageFitUrl} from "~/services/img-service"; | |
import ImageRaw, {type ImageRawProps} from "~/components/image/image-raw"; | |
interface ImageResolutionCrop { | |
width: number; | |
height: number; | |
} | |
// height is optional for fit and fill | |
type ImageResolution = number | ImageResolutionCrop; | |
export interface ImageProps extends Omit<ImageRawProps, 'src' | 'srcSet'> { | |
uri: string; | |
resolutions: ImageResolution[]; | |
type?: 'fit' | 'fill' | 'crop'; | |
alt: string, | |
hash?: string; | |
} | |
/** | |
* More sophisticated version of ImageRaw. | |
* which handles generating the `srcSet` attribute with ease. | |
* It is only useful when you use the IMG CDN service. | |
*/ | |
export default component$<ImageProps>(({ | |
uri: _uri, | |
type = 'crop', | |
resolutions: _resolutions, | |
hash: _hash, | |
...props | |
}) => { | |
let uri = _uri; | |
let resolutions = _resolutions; | |
let hash = _hash; | |
const buildSrc = (res: ImageResolution) => { | |
if (type === 'fit') { | |
const width = typeof res === 'number' ? res : res.width; | |
return buildImageFitUrl(uri, width); | |
} | |
if (type === 'fill') { | |
const width = typeof res === 'number' ? res : res.width; | |
return buildImageFillUrl(uri, width); | |
} | |
if (type === 'crop') { | |
if (typeof res === 'number') { | |
// if (import.meta.env.DEV) { | |
// throw new Error(`Invalid image resolution: ${res}, when type is crop it must be an object`); | |
// } | |
return buildImageFitUrl(uri, res); | |
} | |
return buildImageFillCropUrl(uri, res.width, res.height); | |
} | |
throw new Error(`Invalid image type: ${type}`); | |
} | |
if (uri.includes('#')) { | |
// Ensure better CLS without the need to specify the height or using fill or crop. | |
// API should provide the source resolution, to ensure the best CLS without loosing image information (by using crop). | |
// eg: {url}#r=500x500,hash=abc | |
const uriMeta = uri.split('#')[1].split('&') | |
.reduce((acc, optionString) => { | |
const [key, value] = optionString.split('='); | |
acc[key] = value; | |
return acc; | |
}, {} as Record<string, string>); | |
// Remove the hash from the uri. We don't need it anymore. It has conveyed its information. | |
uri = uri.split('#')[0]; | |
if (uriMeta['size']) { | |
const sourceResolution = (() => { | |
const [width, height] = uriMeta['size'].split('x'); | |
return {width: parseInt(width), height: parseInt(height)}; | |
})(); | |
const sourceRatio = sourceResolution.width / sourceResolution.height; | |
resolutions = resolutions.map((resolution) => { | |
if (typeof resolution === 'number') { | |
return { | |
width: resolution, | |
height: Math.round(resolution / sourceRatio), | |
}; | |
} | |
return resolution; | |
}); | |
} | |
// Use the hash from the uri if available. | |
if (uriMeta['hash']) { | |
hash = uriMeta['hash']; | |
} | |
} | |
const firstRes = resolutions[0]; | |
const lastRes = resolutions[resolutions.length - 1]; | |
// data-hash property is handled by src/image-hash.ts which is inlined in the SSR entry point. | |
return <ImageRaw | |
src={buildSrc(firstRes)} | |
srcset={ | |
resolutions.length > 1 | |
? resolutions.map((res) => { | |
if (typeof res === 'number') { | |
return `${buildSrc(res)} ${res}w`; | |
} | |
return `${buildSrc(res)} ${res.width}w`; | |
}).join(', ') | |
: undefined | |
} | |
width={typeof lastRes === 'number' ? lastRes : lastRes.width} | |
height={typeof lastRes === 'number' ? undefined : lastRes.height} | |
data-hash={hash} | |
{...props} | |
/>; | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Breakpoints, {type Breakpoint} from "~/utils/breakpoints"; | |
interface ImageSizeBase { | |
size: string; | |
} | |
interface ImageSizeByMin extends ImageSizeBase { | |
min: Breakpoint | string; | |
} | |
interface ImageSizeByMax extends ImageSizeBase { | |
max: Breakpoint | string; | |
} | |
export type ImageSize = ImageSizeByMin | ImageSizeByMax | string; | |
export const imgSizes = ( | |
sizes: string | ImageSize[], | |
defaultSize: boolean = true, | |
sizeMargin: string | null = '30px', | |
): string => { | |
const buildSize = (size: string) => sizeMargin ? `calc(${size} - ${sizeMargin})` : size; | |
defaultSize = defaultSize && typeof sizes !== 'string'; | |
sizes = typeof sizes === 'string' | |
? [sizes] | |
: sizes.map(size => { | |
if (typeof size === 'string') { | |
return size; | |
} | |
if ('max' in size) { | |
const breakpoint = size.max in Breakpoints ? Breakpoints[size.max as Breakpoint] : size.max; | |
return `(max-width: ${breakpoint}) ${buildSize(size.size)}`; | |
} | |
const breakpoint = size.min in Breakpoints ? Breakpoints[size.min as Breakpoint] : size.min; | |
return `(min-width: ${breakpoint}) ${buildSize(size.size)}`; | |
}); | |
if (defaultSize) { | |
sizes.push(`calc(100vw - ${sizeMargin || '30px'})`); | |
} | |
return sizes.join(', '); | |
} | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment