Skip to content

Instantly share code, notes, and snippets.

@bradley
Created October 27, 2020 03:27
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save bradley/1e21697cac1e58878556ae59fe992571 to your computer and use it in GitHub Desktop.
Save bradley/1e21697cac1e58878556ae59fe992571 to your computer and use it in GitHub Desktop.
Hack of gatsby-source-sanity's getGatsbyImageProps.ts to support more Sanity image options (https://www.sanity.io/docs/image-urls). Use at your own risk.
import parseUrl from 'url-parse'
/* What's with this file?
*
* This file is a hacked version of the `getGatsbyImageProps.ts` file provided
* by `gatsby-source-sanity` at:
* https://github.com/sanity-io/gatsby-source-sanity/blob/main/src/images/getGatsbyImageProps.ts.
*
* There are existing lacks in functionality at the time of this code being
* written that are having detrimental impacts on our ability to get acceptable
* image sizes into the frontend by utilizing Sanity's image URLs. You can
* follow along with that issue here:
* https://github.com/sanity-io/gatsby-source-sanity/issues/89
*
* If in the future it looks like the customization we need has been added to
* `gatsby-source-sanity`, by all means gut this thing and replace it with the
* better supported code.
* The *only* changes in this file are the addition of the `SanityImageArgs`
* (see below) as a set of arguments to the `#getFixedGatsbyImage` and
* `getFluidGatsbyImage` functions, and the use of `sanityParams` therein.
*
*/
// A subset of the options available for Sanity Image Transformations that
// probably wont bust the default behaviour here. We dont supply definition
// for the full list of options due to much of the work in these functions being
// specific to resizing and anticipating the widths. That is, we don't want
// to allow customization here that may impact expected image ratios when
// generating the fixed/fluid images for Gatsby.
//
// See more at https://www.sanity.io/docs/image-urls.
export type SanityImageArgs = {
bg?: string,
blur?: number,
fm?: string,
invert?: boolean,
q?: number,
sat?: number,
sharpen?: number
}
export const LOWEST_FLUID_BREAKPOINT_WIDTH = 100
export const DEFAULT_FIXED_WIDTH = 400
export const DEFAULT_FLUID_MAX_WIDTH = 800
export type ImageNode = ImageAsset | ImageObject | ImageRef | string | null | undefined
export enum ImageFormat {
NO_CHANGE = '',
WEBP = 'webp',
JPG = 'jpg',
PNG = 'png',
}
export interface GatsbyFixedImageProps {
width: number
height: number
src: string
srcSet: string
base64?: string
tracedSVG?: string
srcWebp?: string
srcSetWebp?: string
media?: string
}
export type GatsbyFluidImageProps = {
aspectRatio: number
src: string
srcSet: string
sizes: string
base64?: string
tracedSVG?: string
srcWebp?: string
srcSetWebp?: string
media?: string
}
type ImageDimensions = {
width: number
height: number
aspectRatio: number
}
type ImageMetadata = {
dimensions: ImageDimensions
lqip?: string
}
type ImageAssetStub = {
url: string
assetId: string
extension: string
metadata: ImageMetadata
}
type ImageAsset = ImageAssetStub & {
_id: string
}
type ImageRef = {
_ref: string
}
type ImageObject = {
asset: ImageRef | ImageAsset
}
export type FluidArgs = {
maxWidth?: number
maxHeight?: number
sizes?: string
toFormat?: ImageFormat
}
export type FixedArgs = {
width?: number
height?: number
toFormat?: ImageFormat
}
type SanityLocation = {
projectId: string
dataset: string
}
const idPattern = /^image-[A-Za-z0-9]+-\d+x\d+-[a-z]+$/
const sizeMultipliersFixed = [1, 1.5, 2, 3]
const sizeMultipliersFluid = [0.25, 0.5, 1, 1.5, 2, 3]
function buildImageUrl(loc: SanityLocation, stub: ImageAssetStub) {
const {projectId, dataset} = loc
const {assetId, extension, metadata} = stub
const {width, height} = metadata.dimensions
const base = 'https://cdn.sanity.io/images'
return `${base}/${projectId}/${dataset}/${assetId}-${width}x${height}.${extension}`
}
function getBasicImageProps(node: ImageNode, loc: SanityLocation): ImageAssetStub | false {
if (!node) {
return false
}
const obj = node as ImageObject
const ref = node as ImageRef
const img = node as ImageAsset
let id: string = ''
if (typeof node === 'string') {
id = node
} else if (obj.asset) {
id = (obj.asset as ImageRef)._ref || (obj.asset as ImageAsset)._id
} else {
id = ref._ref || img._id
}
const hasId = !id || idPattern.test(id)
if (!hasId) {
return false
}
const [, assetId, dimensions, extension] = id.split('-')
const [width, height] = dimensions.split('x').map((num) => parseInt(num, 10))
const aspectRatio = width / height
const metadata = img.metadata || {dimensions: {width, height, aspectRatio}}
const url = img.url || buildImageUrl(loc, {url: '', assetId, extension, metadata})
return {
url,
assetId,
extension,
metadata,
}
}
function convertToFormat(url: string, toFormat: string) {
const parsed = parseUrl(url, true)
const filename = parsed.pathname.replace(/.*\//, '')
const extension = filename.replace(/.*\./, '')
const isConvertedToTarget = parsed.query.fm === toFormat
const isOriginal = extension === toFormat
// If the original matches the target format, remove any explicit conversions
if (isConvertedToTarget && isOriginal) {
const {fm, ...params} = parsed.query
parsed.set('query', params)
return parsed.toString()
}
if (isConvertedToTarget || isOriginal) {
return url
}
const newQuery = {...parsed.query, fm: toFormat}
parsed.set('query', newQuery)
return parsed.toString()
}
function isWebP(url: string) {
const isConverted = url.includes('fm=webp')
const isOriginal = /[a-f0-9]+-\d+x\d+\.webp/.test(url)
return isConverted || isOriginal
}
export function getFixedGatsbyImage(
image: ImageNode,
args: FixedArgs,
sanityArgs: SanityImageArgs,
loc: SanityLocation,
): GatsbyFixedImageProps | null {
const props = getBasicImageProps(image, loc)
if (!props) {
return null
}
const width = args.width || DEFAULT_FIXED_WIDTH
const height = args.height
const {url, metadata, extension} = props
const {dimensions, lqip} = metadata
const isOriginalSize = (w: number, h: number) => w === dimensions.width && h === dimensions.height
let desiredAspectRatio = dimensions.aspectRatio
// If we're cropping, calculate the specified aspect ratio
if (args.height) {
desiredAspectRatio = width / args.height
}
let forceConvert: string | null = null
if (args.toFormat) {
forceConvert = args.toFormat
} else if (isWebP(props.url)) {
forceConvert = 'jpg'
}
const sanityParams = Object.keys(sanityArgs || {}).map(key => key + '=' + sanityArgs[key]).join('&')
const hasOriginalRatio = desiredAspectRatio === dimensions.aspectRatio
const outputHeight = Math.round(height ? height : width / desiredAspectRatio)
const imgUrl =
isOriginalSize(width, outputHeight) ||
(hasOriginalRatio && width > dimensions.width && outputHeight > dimensions.height)
? `${url}?${sanityParams}`
: `${url}?w=${width}&h=${outputHeight}&fit=crop&${sanityParams}`
const widths = sizeMultipliersFixed.map((scale) => Math.round(width * scale))
const initial = {webp: [] as string[], base: [] as string[]}
const srcSets = widths
.filter((currentWidth) => currentWidth <= dimensions.width)
.reduce((acc, currentWidth, i) => {
const resolution = `${sizeMultipliersFixed[i]}x`
const currentHeight = Math.round(currentWidth / desiredAspectRatio)
const imgUrl = isOriginalSize(currentWidth, currentHeight)
? `${url}?${sanityParams}`
: `${url}?w=${currentWidth}&h=${currentHeight}&fit=crop&${sanityParams}`
const webpUrl = convertToFormat(imgUrl, 'webp')
const baseUrl = convertToFormat(imgUrl, forceConvert || props.extension)
acc.webp.push(`${webpUrl} ${resolution}`)
acc.base.push(`${baseUrl} ${resolution}`)
return acc
}, initial)
const src = convertToFormat(imgUrl, forceConvert || extension)
const srcWebp = convertToFormat(imgUrl, 'webp')
return {
base64: lqip || undefined,
width: Math.round(width),
height: outputHeight,
src,
srcWebp,
srcSet: srcSets.base.join(',\n') || `${src} 1x`,
srcSetWebp: srcSets.webp.join(',\n') || `${srcWebp} 1x`,
}
}
export function getFluidGatsbyImage(
image: ImageNode,
args: FluidArgs,
sanityArgs: SanityImageArgs,
loc: SanityLocation,
): GatsbyFluidImageProps | null {
const props = getBasicImageProps(image, loc)
if (!props) {
return null
}
const {url, metadata, extension} = props
const {dimensions, lqip} = metadata
const isOriginalSize = (w: number, h: number) => w === dimensions.width && h === dimensions.height
const maxWidth = Math.min(args.maxWidth || DEFAULT_FLUID_MAX_WIDTH, dimensions.width)
const specifiedMaxHeight = args.maxHeight
? Math.min(args.maxHeight, dimensions.height)
: undefined
let desiredAspectRatio = dimensions.aspectRatio
// If we're cropping, calculate the specified aspect ratio
if (specifiedMaxHeight) {
desiredAspectRatio = maxWidth / specifiedMaxHeight
}
const maxHeight = specifiedMaxHeight || Math.round(maxWidth / dimensions.aspectRatio)
let forceConvert: string | null = null
if (args.toFormat) {
forceConvert = args.toFormat
} else if (isWebP(props.url)) {
forceConvert = 'jpg'
}
const sanityParams = Object.keys(sanityArgs || {}).map(key => key + '=' + sanityArgs[key]).join('&')
const baseSrc =
isOriginalSize(maxWidth, maxHeight) ||
(maxWidth >= dimensions.width && maxHeight >= dimensions.height)
? `${url}?${sanityParams}`
: `${url}?w=${maxWidth}&h=${maxHeight}&fit=crop&${sanityParams}`
const src = convertToFormat(baseSrc, forceConvert || extension)
const srcWebp = convertToFormat(baseSrc, 'webp')
const sizes = args.sizes || `(max-width: ${maxWidth}px) 100vw, ${maxWidth}px`
const widths = sizeMultipliersFluid
.map((scale) => Math.round(maxWidth * scale))
.filter((width) => width < dimensions.width && width > LOWEST_FLUID_BREAKPOINT_WIDTH)
.concat(dimensions.width)
const initial = {webp: [] as string[], base: [] as string[]}
const srcSets = widths
.filter((currentWidth) => currentWidth <= dimensions.width)
.reduce((acc, currentWidth) => {
const currentHeight = Math.round(currentWidth / desiredAspectRatio)
const imgUrl = isOriginalSize(currentWidth, currentHeight)
? `${url}?${sanityParams}`
: `${url}?w=${currentWidth}&h=${currentHeight}&fit=crop&${sanityParams}`
const webpUrl = convertToFormat(imgUrl, 'webp')
const baseUrl = convertToFormat(imgUrl, forceConvert || props.extension)
acc.webp.push(`${webpUrl} ${currentWidth}w`)
acc.base.push(`${baseUrl} ${currentWidth}w`)
return acc
}, initial)
return {
base64: lqip || undefined,
aspectRatio: desiredAspectRatio,
src,
srcWebp,
srcSet: srcSets.base.join(',\n'),
srcSetWebp: srcSets.webp.join(',\n'),
sizes,
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment