Skip to content

Instantly share code, notes, and snippets.

@ioxua
Last active June 20, 2023 16:37
Show Gist options
  • Save ioxua/1cb9cece7aa0ac13100bec23a9580b04 to your computer and use it in GitHub Desktop.
Save ioxua/1cb9cece7aa0ac13100bec23a9580b04 to your computer and use it in GitHub Desktop.
Unsplash cache for Astro (or any other SSG really)
UNSPLASH_ACCESS_KEY=<your_key>
.astro/*
# this file should be checked to git
!.astro/unsplash.json
import { readFile, writeFile } from 'node:fs/promises'
import { createApi } from 'unsplash-js';
type FullImage = NonNullable<Awaited<ReturnType<ReturnType<typeof createApi>['photos']['get']>>['response']>
export interface UnsplashImageInternal {
id: string;
description: string;
urls: {
raw: string;
full: string;
regular: string;
small: string;
thumb: string;
}
author: { name: string; username: string; }
}
export interface UnsplashImage extends UnsplashImageInternal {
attribution: {
authorProfileUrl: string
unsplashUrl: string
}
}
type Options = {
appName: string,
cacheFile: string,
}
class UnsplashCache {
#cache: Map<string, UnsplashImageInternal> = new Map()
#cacheLoaded: boolean = false
readonly #options: Options
readonly #api: ReturnType<typeof createApi> = createApi({
accessKey: import.meta.env.UNSPLASH_ACCESS_KEY ?? '',
})
constructor(options: Options) {
this.#options = options
}
async init(): Promise<void> {
return readFile(this.#options.cacheFile, {
encoding: 'utf-8',
})
.then(res => {
console.log('[Unsplash] cache loaded')
this.#cache = new Map(Object.entries(JSON.parse(res)))
})
// we ignore the error in case the file doesn't exist since it will be created later
.catch(() => {})
.finally(() => this.#cacheLoaded = true)
}
async get(imageId: string): Promise<UnsplashImage> {
if (!this.#checkInit()) throw new Error('cache not initialized')
const cached = this.#cache.get(imageId)
// console.log(`[unsplash] is ${imageId} in cache?`, !!cached)
// console.log(`[unsplash] current cache`, this.#cache)
if (cached) return this.#addExtraInfo(cached)
const res = await this.#api.photos.get({ photoId: imageId })
if (res.type === 'success') {
const photo = res.response;
this.#api.photos.trackDownload({
downloadLocation: photo.links.download_location,
});
return this.#addExtraInfo(this.#set(photo))
}
console.warn('An error occurred when requesting the API:', res.errors[0])
throw new Error(res.errors[0])
}
#checkInit(): boolean {
if (!this.#cacheLoaded) {
console.warn('[Unsplash] cache not initialized')
}
return this.#cacheLoaded
}
#addExtraInfo(image: UnsplashImageInternal): UnsplashImage {
const query = `?utm_source=${this.#options.appName}&utm_medium=referral`
return { ...image, attribution: {
authorProfileUrl: `https://unsplash.com/@${image.author.username}${query}`,
unsplashUrl: `https://unsplash.com${query}`,
} }
}
#set(image: FullImage): UnsplashImageInternal {
const converted: UnsplashImageInternal = {
id: image.id,
description: image.alt_description ?? image.description ?? '',
urls: image.urls,
author: { name: image.user.name, username: image.user.username },
}
this.#cache.set(image.id, converted)
this.#persist()
return converted
}
#persist() {
writeFile(this.#options.cacheFile, JSON.stringify(Object.fromEntries(this.#cache)), {
encoding: 'utf-8'
})
.then(() => console.log('[Unsplash] updated cache in disk'))
}
}
let cacheInstance: UnsplashCache | undefined
export async function useUnsplash() {
if (!cacheInstance) {
cacheInstance = new UnsplashCache({
cacheFile: '.astro/unsplash.json',
appName: 'your_app_name',
})
await cacheInstance.init()
}
return cacheInstance
}
---
import { getLangFromUrl } from '../i18n';
import { useUnsplash } from '../utils';
import Link from './Link.astro';
export interface Props {
id: string
quality?: 'raw' | 'full' | 'regular' | 'small' | 'thumb'
/**
* @see https://unsplash.com/documentation#supported-parameters
*/
imgix?: {
fit?: 'clamp' | 'clip' | 'crop' | 'facearea' | 'fill' | 'fillmax' | 'max' | 'min' | 'scale'
crop?: 'top' | 'bottom' | 'left' | 'right' | 'faces' | 'focalpoint' | 'edges' | 'entropy'
w?: number
h?: number
}
}
// hook usage
const unsplash = await useUnsplash()
const image = await unsplash.get(Astro.props.id)
// i18n
const lang = getLangFromUrl(Astro.url)
const trans = {
'br.part1': 'Foto por',
'br.part2': 'no',
'en.part1': 'Photo by',
'en.part2': 'on',
}
const quality = Astro.props.quality ?? 'full'
const url = new URL(Astro.props.imgix ? image.urls.raw : image.urls[quality])
// I _could_ generalize this, but is it worth it the effort?
if (Astro.props.imgix) {
if (Astro.props.imgix.crop) url.searchParams.append('crop', Astro.props.imgix.crop)
if (Astro.props.imgix.fit) url.searchParams.append('fit', Astro.props.imgix.fit)
if (Astro.props.imgix.h) url.searchParams.append('h', Astro.props.imgix.h.toString())
if (Astro.props.imgix.w) url.searchParams.append('w', Astro.props.imgix.w.toString())
}
---
<figure>
<img src={url.toString()} alt={image.description} />
<figcaption>
{trans[`${lang}.part1`]}
<Link
href={image.attribution.authorProfileUrl}
title={image.author.name}
rel="nofollow"
>{image.author.name}</Link>
{trans[`${lang}.part2`]}
<Link
href={image.attribution.unsplashUrl}
title='Unsplash'
rel="nofollow"
>Unsplash</Link>
</figcaption>
</figure>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment