feat: image variants per image
This commit is contained in:
1 parent
f567866161
commit
951fef18ba
92 files changed
+8695
-413
No files matched your search
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { withMediaRevision } from '@/lib/mediaRevision'
|
||||
import { bodyHasVariantImage, variantOfSrc, withVariant } from '../blockVariant'
|
||||
|
||||
describe('Reading the variant of an image src', () => {
|
||||
it.each([
|
||||
['/static/uploadedImages/1.jpg?v=hero', 'hero'],
|
||||
['/static/uploadedImages/1.jpg?v=hero&rev=3', 'hero'],
|
||||
['/static/uploadedImages/1.jpg?rev=3&v=thumb#top', 'thumb'],
|
||||
['/static/uploadedImages/1.jpg', null],
|
||||
['/static/uploadedImages/1.jpg?rev=3', null],
|
||||
['/static/uploadedImages/1.jpg?v=', null],
|
||||
[undefined, null],
|
||||
])('%s reads %s', (src, expected) => {
|
||||
expect(variantOfSrc(src)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rewriting the variant of an image src', () => {
|
||||
it.each([
|
||||
['/static/uploadedImages/1.jpg?v=hero', 'thumb', '/static/uploadedImages/1.jpg?v=thumb'],
|
||||
['/static/uploadedImages/1.jpg?v=hero', '', '/static/uploadedImages/1.jpg'],
|
||||
['/static/uploadedImages/1.jpg', 'hero', '/static/uploadedImages/1.jpg?v=hero'],
|
||||
[
|
||||
'/static/uploadedImages/1.jpg?rev=3&v=hero#a',
|
||||
'og',
|
||||
'/static/uploadedImages/1.jpg?rev=3&v=og#a',
|
||||
],
|
||||
])('%s with %s becomes %s', (src, name, expected) => {
|
||||
expect(withVariant(src, name)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Detecting variant images in a body', () => {
|
||||
it('finds an img whose src carries ?v=', () => {
|
||||
expect(bodyHasVariantImage('<p>x</p><img src="/static/uploadedImages/1.jpg?v=hero">')).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores plain images and text', () => {
|
||||
expect(bodyHasVariantImage('<p>?v=hero</p><img src="/static/uploadedImages/1.jpg">')).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The cache-busting parameter coexists with the variant', () => {
|
||||
it('is appended after ?v=', () => {
|
||||
expect(withMediaRevision('/static/uploadedImages/1.jpg?v=hero', 3)).toBe(
|
||||
'/static/uploadedImages/1.jpg?v=hero&rev=3',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import type { ImageVariant } from '@/composables/useImageVariants'
|
||||
import { buildOptions, insertedUrl, originalOption, variantOption } from '../variantOptions'
|
||||
|
||||
const NORTH_WALL = {
|
||||
url: '/static/uploadedImages/1.jpg',
|
||||
width: 4032,
|
||||
height: 3024,
|
||||
content_type: 'image/jpeg',
|
||||
}
|
||||
|
||||
function variant(overrides: Partial<ImageVariant>): ImageVariant {
|
||||
return {
|
||||
name: 'hero',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fit: 'cover',
|
||||
format: 'webp',
|
||||
quality: 82,
|
||||
generate_on_upload: false,
|
||||
enabled: true,
|
||||
generated_files: 0,
|
||||
cache_size: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
describe('Option labels', () => {
|
||||
it('reads the original from the image', () => {
|
||||
expect(originalOption(NORTH_WALL).label).toBe('Original · 4032×3024 · JPEG')
|
||||
expect(originalOption({ ...NORTH_WALL, content_type: 'image/webp' }).label).toBe(
|
||||
'Original · 4032×3024 · WEBP',
|
||||
)
|
||||
expect(
|
||||
originalOption({
|
||||
...NORTH_WALL,
|
||||
width: null,
|
||||
height: null,
|
||||
content_type: 'image/svg+xml',
|
||||
}).label,
|
||||
).toBe('Original · Vector · SVG')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[variant({}), 'Hero · 1200×630 · WebP'],
|
||||
[
|
||||
variant({ name: 'card', width: 800, height: null, format: 'webp' }),
|
||||
'Card · 800 × auto · WebP',
|
||||
],
|
||||
[
|
||||
variant({ name: 'tall', width: null, height: 2400, format: 'png' }),
|
||||
'Tall · auto × 2400 · PNG',
|
||||
],
|
||||
[variant({ name: 'keep', format: 'original' }), 'Keep · 1200×630 · Original'],
|
||||
])('reads a variant', (entry, label) => {
|
||||
expect(variantOption(entry).label).toBe(label)
|
||||
})
|
||||
|
||||
it('offers only Original for a vector image', () => {
|
||||
const options = buildOptions(
|
||||
{ ...NORTH_WALL, width: null, height: null, content_type: 'image/svg+xml' },
|
||||
[variant({})],
|
||||
)
|
||||
|
||||
expect(options.map((option) => option.label)).toEqual(['Original · Vector · SVG'])
|
||||
})
|
||||
|
||||
it('translates the original label', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(originalOption(NORTH_WALL).label).toBe('Original · 4032×3024 · JPEG')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The inserted url', () => {
|
||||
it('is the image url for Original and carries ?v= for a variant', () => {
|
||||
expect(insertedUrl(NORTH_WALL, '')).toBe('/static/uploadedImages/1.jpg')
|
||||
expect(insertedUrl(NORTH_WALL, 'hero')).toBe('/static/uploadedImages/1.jpg?v=hero')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
const VARIANT_PARAMETER = 'v'
|
||||
const HASH = '#'
|
||||
const QUERY = '?'
|
||||
|
||||
function splitSrc(src: string) {
|
||||
const hashAt = src.indexOf(HASH)
|
||||
const withoutHash = hashAt === -1 ? src : src.slice(0, hashAt)
|
||||
const hash = hashAt === -1 ? '' : src.slice(hashAt)
|
||||
const queryAt = withoutHash.indexOf(QUERY)
|
||||
if (queryAt === -1) {
|
||||
return { base: withoutHash, query: '', hash }
|
||||
}
|
||||
return { base: withoutHash.slice(0, queryAt), query: withoutHash.slice(queryAt + 1), hash }
|
||||
}
|
||||
|
||||
export function variantOfSrc(src: string | undefined | null) {
|
||||
if (!src) {
|
||||
return null
|
||||
}
|
||||
const name = new URLSearchParams(splitSrc(src).query).get(VARIANT_PARAMETER)
|
||||
return name === null || name === '' ? null : name
|
||||
}
|
||||
|
||||
export function withVariant(src: string, name: string) {
|
||||
const { base, query, hash } = splitSrc(src)
|
||||
const params = new URLSearchParams(query)
|
||||
params.delete(VARIANT_PARAMETER)
|
||||
const kept = params.toString()
|
||||
const parts = [kept, name === '' ? '' : `${VARIANT_PARAMETER}=${encodeURIComponent(name)}`]
|
||||
const next = parts.filter((part) => part !== '').join('&')
|
||||
return `${base}${next === '' ? '' : QUERY + next}${hash}`
|
||||
}
|
||||
|
||||
export function bodyHasVariantImage(html: string) {
|
||||
const document = new DOMParser().parseFromString(html, 'text/html')
|
||||
return Array.from(document.querySelectorAll('img')).some(
|
||||
(image) => variantOfSrc(image.getAttribute('src')) !== null,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { t } from '@/i18n'
|
||||
import type { VariantCause } from '@/composables/useMediaVariants'
|
||||
|
||||
const PAYLOAD_TOO_LARGE = 413
|
||||
const UNSUPPORTED_MEDIA_TYPE = 415
|
||||
const UNPROCESSABLE = 422
|
||||
|
||||
export function causeText(cause: string) {
|
||||
if (cause === 'too_large') {
|
||||
return t('Image too large')
|
||||
}
|
||||
if (cause === 'unsupported_format') {
|
||||
return t('Unsupported source format')
|
||||
}
|
||||
return t('Generation failed')
|
||||
}
|
||||
|
||||
export function causeOfStatus(status: number): VariantCause | null {
|
||||
if (status === PAYLOAD_TOO_LARGE) {
|
||||
return 'too_large'
|
||||
}
|
||||
if (status === UNSUPPORTED_MEDIA_TYPE || status === UNPROCESSABLE) {
|
||||
return 'unsupported_format'
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { t } from '@/i18n'
|
||||
import type { ImageVariant, VariantFormat } from '@/composables/useImageVariants'
|
||||
import { formatType } from '@/lib/mediaFormat'
|
||||
import { formatDimensions, formatLabel } from '@/lib/variants/variantFormat'
|
||||
|
||||
const SEPARATOR = ' · '
|
||||
const VECTOR = 'Vector'
|
||||
const SVG_TYPE = 'image/svg+xml'
|
||||
const ORIGINAL_VALUE = ''
|
||||
|
||||
export interface VariantOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface OptionSource {
|
||||
url: string
|
||||
width: number | null
|
||||
height: number | null
|
||||
content_type: string
|
||||
}
|
||||
|
||||
export const ORIGINAL_OPTION_VALUE = ORIGINAL_VALUE
|
||||
|
||||
export function isVector(source: OptionSource) {
|
||||
return source.content_type === SVG_TYPE
|
||||
}
|
||||
|
||||
function sourceSize(source: OptionSource) {
|
||||
if (source.width === null || source.height === null) {
|
||||
return t(VECTOR)
|
||||
}
|
||||
return `${source.width}×${source.height}`
|
||||
}
|
||||
|
||||
export function capitalised(name: string) {
|
||||
return name.charAt(0).toUpperCase() + name.slice(1)
|
||||
}
|
||||
|
||||
export function originalOption(source: OptionSource): VariantOption {
|
||||
return {
|
||||
value: ORIGINAL_VALUE,
|
||||
label: [t('Original'), sourceSize(source), formatType(source.content_type)].join(SEPARATOR),
|
||||
}
|
||||
}
|
||||
|
||||
export function bareOriginalOption(): VariantOption {
|
||||
return { value: ORIGINAL_VALUE, label: t('Original') }
|
||||
}
|
||||
|
||||
export function variantOption(variant: ImageVariant): VariantOption {
|
||||
return {
|
||||
value: variant.name,
|
||||
label: [
|
||||
capitalised(variant.name),
|
||||
formatDimensions(variant.width, variant.height),
|
||||
formatLabel(variant.format as VariantFormat),
|
||||
].join(SEPARATOR),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildOptions(source: OptionSource, variants: ImageVariant[]): VariantOption[] {
|
||||
const offered = isVector(source) ? [] : variants.map(variantOption)
|
||||
return [originalOption(source), ...offered]
|
||||
}
|
||||
|
||||
export function insertedUrl(source: OptionSource, variantName: string) {
|
||||
return variantName === ORIGINAL_VALUE
|
||||
? source.url
|
||||
: `${source.url}?v=${encodeURIComponent(variantName)}`
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
const MAX_MAGNITUDE = 99
|
||||
const PERCENT = 100
|
||||
|
||||
export function savedPercent(originalBytes: number, variantBytes: number) {
|
||||
if (originalBytes <= 0) {
|
||||
return 0
|
||||
}
|
||||
const change = ((variantBytes - originalBytes) / originalBytes) * PERCENT
|
||||
const rounded = Math.sign(change) * Math.round(Math.abs(change))
|
||||
if (variantBytes > 0 && rounded < -MAX_MAGNITUDE) {
|
||||
return -MAX_MAGNITUDE
|
||||
}
|
||||
return rounded
|
||||
}
|
||||
|
||||
export function signedPercent(originalBytes: number, variantBytes: number) {
|
||||
const percent = savedPercent(originalBytes, variantBytes)
|
||||
return `${percent > 0 ? '+' : ''}${percent}%`
|
||||
}
|
||||
|
||||
export function savesPercent(originalBytes: number, variantBytes: number) {
|
||||
return Math.max(0, -savedPercent(originalBytes, variantBytes))
|
||||
}
|
||||
Reference in new issue
Block a user