feat: image variants per image

This commit is contained in:
fsylvain committed 2026-10-08 19:57:51 +02:00
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')
})
})
+39
View File
@@ -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,
)
}
+26
View File
@@ -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
}
+71
View File
@@ -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)}`
}
+23
View File
@@ -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))
}