diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 85a0c53..a27453d 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -10,13 +10,13 @@ on: permissions: contents: read -concurrency: - group: 'photos-${{ github.ref }}' - cancel-in-progress: true - jobs: validate: runs-on: docker + # Superseded validation runs can be cancelled freely. + concurrency: + group: 'photos-validate-${{ github.ref }}' + cancel-in-progress: true steps: - uses: actions/checkout@v4 @@ -49,6 +49,11 @@ jobs: runs-on: docker needs: validate if: github.event_name == 'push' && github.ref == 'refs/heads/main' + # Never cancel an in-flight deploy: rsync --delete must finish or the live + # site can be left half-deleted. Newer deploys queue behind this one. + concurrency: + group: 'photos-deploy-${{ github.ref }}' + cancel-in-progress: false steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index b3bc429..adbc559 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ node_modules dist -target .DS_Store public/static/photos src/generated diff --git a/README.md b/README.md index 62d8866..7183167 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Run the full local validation suite with: npm run lint:check ``` -This checks the photo catalog, generates assets, checks source photos for EXIF/XMP/IPTC metadata, type-checks, lints TypeScript and SCSS, and checks formatting. +This generates assets (validating the photo catalog against `src/pictures/` in the process), checks source photos for EXIF/XMP/IPTC metadata, type-checks, lints TypeScript and SCSS, and checks formatting. Run unit tests separately: diff --git a/og-image.jpg b/og-image.jpg deleted file mode 100644 index 3f5cbdb..0000000 Binary files a/og-image.jpg and /dev/null differ diff --git a/package.json b/package.json index 763d877..f9c746c 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "dev": "node scripts/generate-assets.mjs && vite --host 0.0.0.0", "lint": "eslint . --fix && stylelint \"src/**/*.scss\" --fix && prettier --write .", - "lint:check": "node scripts/check-photo-catalog.mjs && node scripts/generate-assets.mjs && node scripts/check-image-metadata.mjs && tsc --noEmit && eslint . && stylelint \"src/**/*.scss\" && prettier --check .", + "lint:check": "node scripts/generate-assets.mjs && node scripts/check-image-metadata.mjs && tsc --noEmit && eslint . && stylelint \"src/**/*.scss\" && prettier --check .", "test": "vitest run", "test:e2e": "playwright test", "build": "node scripts/generate-assets.mjs && vite build" @@ -32,9 +32,6 @@ "url": "https://github.com/schmelczerandras/photos/issues" }, "homepage": "https://schmelczer.dev/photos/", - "browserslist": [ - "defaults" - ], "devDependencies": { "@eslint/js": "^10.0.1", "@playwright/test": "^1.60.0", diff --git a/scripts/check-image-metadata.mjs b/scripts/check-image-metadata.mjs index d66ece3..a561237 100644 --- a/scripts/check-image-metadata.mjs +++ b/scripts/check-image-metadata.mjs @@ -8,7 +8,7 @@ const rootDir = path.resolve(__dirname, '..'); const pictureDir = path.join(rootDir, 'src/pictures'); const files = (await readdir(pictureDir)).filter((file) => - file.toLowerCase().endsWith('.jpg') + /\.jpe?g$/i.test(file) ); const offenders = []; diff --git a/scripts/check-photo-catalog.mjs b/scripts/check-photo-catalog.mjs deleted file mode 100644 index 9af4d0f..0000000 --- a/scripts/check-photo-catalog.mjs +++ /dev/null @@ -1,84 +0,0 @@ -import { readFile, readdir } from 'node:fs/promises'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const rootDir = path.resolve(__dirname, '..'); -const sourceDir = path.join(rootDir, 'src/pictures'); -const catalogPath = path.join(rootDir, 'src/photo-catalog.json'); - -const isJpeg = (file) => /\.(jpe?g)$/i.test(file); - -const formatList = (items) => items.map((item) => `- ${item}`).join('\n'); - -const main = async () => { - const sourceFiles = (await readdir(sourceDir)).filter(isJpeg).sort(); - const catalog = JSON.parse(await readFile(catalogPath, 'utf8')); - - if (!Array.isArray(catalog)) { - throw new Error('Photo catalog must be an array.'); - } - - const catalogFiles = catalog.map((entry, index) => { - if (!entry || typeof entry !== 'object') { - throw new Error(`Photo catalog entry ${index + 1} must be an object.`); - } - - if (typeof entry.file !== 'string' || entry.file.trim() === '') { - throw new Error(`Photo catalog entry ${index + 1} is missing "file".`); - } - - return entry.file; - }); - - const sourceFileSet = new Set(sourceFiles); - const catalogFileSet = new Set(catalogFiles); - const duplicateFiles = catalogFiles - .filter((file, index) => catalogFiles.indexOf(file) !== index) - .filter((file, index, files) => files.indexOf(file) === index) - .sort(); - const missingFromCatalog = sourceFiles.filter( - (file) => !catalogFileSet.has(file) - ); - const missingFromPictures = catalogFiles - .filter((file) => !sourceFileSet.has(file)) - .sort(); - - if ( - duplicateFiles.length > 0 || - missingFromCatalog.length > 0 || - missingFromPictures.length > 0 - ) { - const sections = []; - - if (duplicateFiles.length > 0) { - sections.push( - `Duplicate catalog file entries:\n${formatList(duplicateFiles)}` - ); - } - - if (missingFromCatalog.length > 0) { - sections.push( - `Photos missing from src/photo-catalog.json:\n${formatList( - missingFromCatalog - )}` - ); - } - - if (missingFromPictures.length > 0) { - sections.push( - `Catalog entries missing from src/pictures:\n${formatList( - missingFromPictures - )}` - ); - } - - throw new Error(sections.join('\n\n')); - } - - console.log( - `Photo catalog includes all ${sourceFiles.length} source photos.` - ); -}; - -await main(); diff --git a/scripts/generate-assets.mjs b/scripts/generate-assets.mjs index 812ec80..a23f92a 100644 --- a/scripts/generate-assets.mjs +++ b/scripts/generate-assets.mjs @@ -15,7 +15,7 @@ const formats = [ { type: 'image/avif', extension: 'avif', - options: { quality: 45, effort: 0 }, + options: { quality: 45, effort: 4 }, }, { type: 'image/webp', extension: 'webp', options: { quality: 78 } }, { @@ -55,6 +55,19 @@ const uniqueWidthsFor = (sourceWidth) => { const renderSource = ({ type, srcset }, sizes) => ``; +// The frame is bounded by BOTH the available width and the viewport height +// (CSS max-block-size). For a portrait, the height cap is what limits the +// rendered width, so the displayed width is min(widthCap, heightCap * aspect). +// Expressing that here stops tall photos from over-fetching a too-wide variant. +const frameSizes = (photo) => { + const aspect = photo.aspectRatio; + + return ( + `(max-width: 899px) min(calc(100vw - 2rem), calc((100vh - 12rem) * ${aspect})), ` + + `min(calc(100vw - 18rem), calc((100vh - 5rem) * ${aspect}))` + ); +}; + const renderPicture = ( photo, sizes, @@ -80,7 +93,17 @@ const renderThumbnail = (photo, isCurrent) => { (source) => source.type === 'image/jpeg' ).variants[0]; - return ` + // The frame is built at runtime by cloning this thumbnail's , so the + // data the frame needs but the thumbnail markup lacks is carried on data-*. + const dataAttributes = [ + `data-photo-id="${photo.id}"`, + `data-order="${photo.order}"`, + `data-caption="${escapeHtml(photo.caption)}"`, + `data-frame-src="${photo.fallback.src}"`, + `data-frame-sizes="${escapeHtml(frameSizes(photo))}"`, + ].join(' '); + + return ` ${renderPicture(photo, sizes, 'loading="lazy" decoding="async"', smallestJpeg.src)} `; }; @@ -88,17 +111,37 @@ const renderThumbnail = (photo, isCurrent) => { const renderFrame = (photo) => `
${renderPicture( photo, - '(max-width: 899px) calc(100vw - 2rem), calc(100vw - 18rem)', + frameSizes(photo), 'id="frame-image" decoding="async" fetchpriority="high"' )}
${escapeHtml(photo.caption)}
`; +const validateCatalog = (catalog) => { + if (!Array.isArray(catalog)) { + throw new Error('Photo catalog must be an array.'); + } + + const seen = new Set(); + + for (const [index, entry] of catalog.entries()) { + if (!entry || typeof entry.file !== 'string' || entry.file.trim() === '') { + throw new Error( + `Photo catalog entry ${index + 1} needs a non-empty "file".` + ); + } + + if (seen.has(entry.file)) { + throw new Error(`Duplicate photo catalog entry: ${entry.file}`); + } + + seen.add(entry.file); + } +}; + const assertCatalogMatchesFiles = async (catalog) => { const sourceFiles = new Set( - (await readdir(sourceDir)).filter((file) => - file.toLowerCase().endsWith('.jpg') - ) + (await readdir(sourceDir)).filter((file) => /\.jpe?g$/i.test(file)) ); const catalogFiles = new Set(catalog.map((entry) => entry.file)); @@ -116,13 +159,14 @@ const assertCatalogMatchesFiles = async (catalog) => { }; const generate = async () => { - const catalog = JSON.parse(await readFile(catalogPath, 'utf8')).map( - (entry, index) => ({ - ...entry, - id: slugify(entry.file), - order: index + 1, - }) - ); + const rawCatalog = JSON.parse(await readFile(catalogPath, 'utf8')); + validateCatalog(rawCatalog); + + const catalog = rawCatalog.map((entry, index) => ({ + ...entry, + id: slugify(entry.file), + order: index + 1, + })); await assertCatalogMatchesFiles(catalog); await rm(publicPhotoDir, { recursive: true, force: true }); @@ -205,11 +249,6 @@ const generate = async () => { }); } - const module = `import type { Photo } from '../photo-types'; - -export const photos = ${JSON.stringify(photos, null, 2)} as const satisfies readonly Photo[]; -`; - const firstPhoto = photos[0]; const portraits = photos .filter((photo) => photo.orientation === 'portrait') @@ -230,7 +269,6 @@ ${landscapes} `; - await writeFile(path.join(generatedDir, 'photos.ts'), module); await writeFile(path.join(generatedDir, 'gallery.html'), galleryHtml); console.log( `Generated ${photos.length} photos and ${photos.reduce( diff --git a/scripts/strip-image-metadata.mjs b/scripts/strip-image-metadata.mjs index fa1760d..361544d 100644 --- a/scripts/strip-image-metadata.mjs +++ b/scripts/strip-image-metadata.mjs @@ -8,7 +8,7 @@ const rootDir = path.resolve(__dirname, '..'); const pictureDir = path.join(rootDir, 'src/pictures'); const files = (await readdir(pictureDir)).filter((file) => - file.toLowerCase().endsWith('.jpg') + /\.jpe?g$/i.test(file) ); let stripped = 0; diff --git a/src/index.html b/src/index.html index 7b1f6f0..00e7388 100644 --- a/src/index.html +++ b/src/index.html @@ -56,7 +56,7 @@ -
+
diff --git a/src/index.scss b/src/index.scss index f02e448..626ee3e 100644 --- a/src/index.scss +++ b/src/index.scss @@ -32,7 +32,6 @@ html { } body { - min-height: 100dvh; block-size: 100dvh; margin: 0; display: grid; @@ -69,7 +68,6 @@ img { margin: 0; font-size: clamp(1.5rem, 4vmin, 3rem); font-weight: 300; - letter-spacing: 0; line-height: 1; writing-mode: vertical-rl; transform: rotate(180deg); @@ -206,18 +204,8 @@ img { background: #5f1724; } -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - scroll-behavior: auto !important; - } -} - @media (width < $breakpoint) { body { - min-height: 100dvh; - display: grid; grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); } diff --git a/src/index.ts b/src/index.ts index 6c06b9f..8ff05be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,4 @@ import './index.scss'; -import { photos } from './generated/photos'; import { PhotoGallery, requiredElement } from './photos'; document.documentElement.classList.add('js'); @@ -12,7 +11,6 @@ const toggle = requiredElement( ); new PhotoGallery({ - photos, gallery, frame, toggle, diff --git a/src/photo-types.ts b/src/photo-types.ts deleted file mode 100644 index d9cf124..0000000 --- a/src/photo-types.ts +++ /dev/null @@ -1,28 +0,0 @@ -export type PhotoOrientation = 'landscape' | 'portrait'; -export type ImageMimeType = 'image/avif' | 'image/webp' | 'image/jpeg'; - -export type ImageVariant = { - readonly src: string; - readonly width: number; - readonly height: number; -}; - -export type ImageSource = { - readonly type: ImageMimeType; - readonly variants: readonly ImageVariant[]; - readonly srcset: string; -}; - -export type Photo = { - readonly id: string; - readonly file: string; - readonly order: number; - readonly alt: string; - readonly caption: string; - readonly orientation: PhotoOrientation; - readonly width: number; - readonly height: number; - readonly aspectRatio: number; - readonly sources: readonly ImageSource[]; - readonly fallback: ImageVariant; -}; diff --git a/src/photos.test.ts b/src/photos.test.ts index 0629f71..695ebe9 100644 --- a/src/photos.test.ts +++ b/src/photos.test.ts @@ -1,47 +1,33 @@ import { describe, expect, it, vi } from 'vitest'; import { PhotoGallery, wrapIndex } from './photos'; -import type { Photo } from './photo-types'; -const variant = (name: string) => ({ - src: `static/photos/${name}.jpg`, - width: 320, - height: 240, -}); - -const photo = (id: string, alt: string): Photo => ({ - id, - file: `${id}.jpg`, - order: 1, - alt, - caption: alt, - orientation: 'landscape', - width: 320, - height: 240, - aspectRatio: 1.3333, - sources: [ - { - type: 'image/avif', - variants: [variant(`${id}.avif`)], - srcset: `static/photos/${id}.avif 320w`, - }, - { - type: 'image/webp', - variants: [variant(`${id}.webp`)], - srcset: `static/photos/${id}.webp 320w`, - }, - { - type: 'image/jpeg', - variants: [variant(`${id}.jpg`)], - srcset: `static/photos/${id}.jpg 320w`, - }, - ], - fallback: variant(`${id}.jpg`), -}); +const thumbnail = (id: string, order: number, alt: string): string => ` + + + + + ${alt} + + `; const setup = () => { - vi.stubGlobal('CSS', { - escape: (value: string) => value, - }); vi.stubGlobal('matchMedia', () => ({ matches: false, media: '(prefers-reduced-motion: reduce)', @@ -56,27 +42,22 @@ const setup = () => { document.body.innerHTML = `
- One - Two + ${thumbnail('one', 1, 'First photo')} + ${thumbnail('two', 2, 'Second photo')}
-
+
`; const gallery = document.querySelector('#gallery'); - const frame = document.querySelector('#frame'); + const frame = document.querySelector('#frame-content'); const toggle = document.querySelector('#toggle'); if (!gallery || !frame || !toggle) { throw new Error('Test DOM failed to initialize.'); } - return { - gallery, - frame, - toggle, - photos: [photo('one', 'First photo'), photo('two', 'Second photo')], - }; + return { gallery, frame, toggle }; }; describe('wrapIndex', () => { @@ -94,9 +75,9 @@ describe('wrapIndex', () => { }); describe('PhotoGallery', () => { - it('updates the frame and selected state when a thumbnail is clicked', () => { - const { gallery, frame, toggle, photos } = setup(); - new PhotoGallery({ photos, gallery, frame, toggle, autoAdvance: false }); + it('builds the frame from the clicked thumbnail and marks it current', () => { + const { gallery, frame, toggle } = setup(); + new PhotoGallery({ gallery, frame, toggle, autoAdvance: false }); gallery .querySelector('[data-photo-id="two"]') @@ -104,7 +85,11 @@ describe('PhotoGallery', () => { new MouseEvent('click', { bubbles: true, cancelable: true }) ); - expect(frame.querySelector('img')?.alt).toBe('Second photo'); + const image = frame.querySelector('img'); + expect(image?.alt).toBe('Second photo'); + expect(image?.id).toBe('frame-image'); + expect(image?.getAttribute('src')).toBe('static/photos/two-2400.jpg'); + expect(frame.querySelector('figcaption')?.textContent).toBe('Second photo'); expect( gallery .querySelector('[data-photo-id="two"]') @@ -112,9 +97,9 @@ describe('PhotoGallery', () => { ).toBe('true'); }); - it('uses scoped arrow-key navigation', () => { - const { gallery, frame, toggle, photos } = setup(); - new PhotoGallery({ photos, gallery, frame, toggle, autoAdvance: false }); + it('wraps with arrow-key navigation', () => { + const { gallery, frame, toggle } = setup(); + new PhotoGallery({ gallery, frame, toggle, autoAdvance: false }); gallery.dispatchEvent( new KeyboardEvent('keydown', { diff --git a/src/photos.ts b/src/photos.ts index 6c575b1..e29304a 100644 --- a/src/photos.ts +++ b/src/photos.ts @@ -1,11 +1,6 @@ -import type { ImageSource, Photo } from './photo-types'; - const AUTO_ADVANCE_DELAY_MS = 7000; -const FRAME_SIZES = - '(max-width: 899px) calc(100vw - 2rem), calc(100vw - 18rem)'; type GalleryOptions = { - readonly photos: readonly Photo[]; readonly gallery: HTMLElement; readonly frame: HTMLElement; readonly toggle: HTMLButtonElement; @@ -38,44 +33,44 @@ export const wrapIndex = (value: number, count: number): number => { return ((value % count) + count) % count; }; -const createSource = (source: ImageSource): HTMLSourceElement => { - const element = document.createElement('source'); - element.type = source.type; - element.srcset = source.srcset; - element.sizes = FRAME_SIZES; - - return element; -}; - -const createFrameFigure = (photo: Photo): HTMLElement => { +// Build the frame figure by cloning the thumbnail's own . The variant +// files are identical to the frame's, so the only differences are the larger +// fallback src, the frame-appropriate `sizes`, and the caption — all carried on +// the thumbnail's data-* attributes by the asset generator. +const createFrameFigure = (thumbnail: HTMLAnchorElement): HTMLElement => { const figure = document.createElement('figure'); figure.className = 'frame-figure'; - const picture = document.createElement('picture'); - - for (const source of photo.sources.filter( - ({ type }) => type !== 'image/jpeg' - )) { - picture.append(createSource(source)); + const original = thumbnail.querySelector('picture'); + if (!original) { + throw new Error( + `Thumbnail is missing a picture: ${thumbnail.dataset.photoId}` + ); } - const jpeg = photo.sources.find(({ type }) => type === 'image/jpeg'); - const image = document.createElement('img'); - image.id = 'frame-image'; - image.src = photo.fallback.src; - image.srcset = jpeg?.srcset ?? ''; - image.sizes = FRAME_SIZES; - image.width = photo.width; - image.height = photo.height; - image.alt = photo.alt; - image.decoding = 'async'; - image.fetchPriority = 'high'; + const picture = original.cloneNode(true) as HTMLElement; + const image = picture.querySelector('img'); + if (!image) { + throw new Error( + `Thumbnail is missing an image: ${thumbnail.dataset.photoId}` + ); + } - picture.append(image); + const sizes = thumbnail.dataset.frameSizes ?? ''; + for (const element of picture.querySelectorAll('source, img')) { + element.setAttribute('sizes', sizes); + } + + image.id = 'frame-image'; + if (thumbnail.dataset.frameSrc) { + image.src = thumbnail.dataset.frameSrc; + } + image.removeAttribute('loading'); + image.decoding = 'async'; const caption = document.createElement('figcaption'); caption.id = 'frame-caption'; - caption.textContent = photo.caption; + caption.textContent = thumbnail.dataset.caption ?? ''; figure.append(picture, caption); @@ -91,15 +86,19 @@ export class PhotoGallery { ); public constructor(private readonly options: GalleryOptions) { - if (options.photos.length === 0) { + this.thumbnails = Array.from( + options.gallery.querySelectorAll('[data-photo-id]') + ).sort((a, b) => Number(a.dataset.order) - Number(b.dataset.order)); + + if (this.thumbnails.length === 0) { throw new Error('The gallery needs at least one photo.'); } - this.thumbnails = this.options.photos.map((photo) => - this.thumbnailFor(photo) - ); this.addEventListeners(); - this.select(0); + + // The frame for photo 0 is already server-rendered, so just mark the + // selected thumbnail; rebuilding it would discard the in-flight LCP image. + this.updateSelectedThumbnail(0, false); if (this.options.autoAdvance !== false && !this.reducedMotion.matches) { this.startAutoAdvance(); @@ -123,10 +122,6 @@ export class PhotoGallery { }); this.options.gallery.addEventListener('keydown', (event) => { - if (!this.options.gallery.contains(event.target as Node)) { - return; - } - switch (event.key) { case 'ArrowLeft': event.preventDefault(); @@ -148,7 +143,7 @@ export class PhotoGallery { break; case 'End': event.preventDefault(); - this.select(this.options.photos.length - 1, { + this.select(this.thumbnails.length - 1, { focusThumbnail: true, pause: true, }); @@ -164,11 +159,6 @@ export class PhotoGallery { () => this.pauseAutoAdvance(), { passive: true } ); - this.options.gallery.addEventListener( - 'touchstart', - () => this.pauseAutoAdvance(), - { passive: true } - ); this.options.toggle.addEventListener('click', () => { if (this.timerId === null) { @@ -185,20 +175,10 @@ export class PhotoGallery { }); } - private thumbnailFor(photo: Photo): HTMLAnchorElement { - const thumbnail = this.options.gallery.querySelector( - `[data-photo-id="${CSS.escape(photo.id)}"]` - ); - - if (!thumbnail) { - throw new Error(`Missing thumbnail for ${photo.id}.`); - } - - return thumbnail; - } - private selectById(id: string | undefined, options: SelectOptions): void { - const index = this.options.photos.findIndex((photo) => photo.id === id); + const index = this.thumbnails.findIndex( + (thumbnail) => thumbnail.dataset.photoId === id + ); if (index >= 0) { this.select(index, options); @@ -206,24 +186,27 @@ export class PhotoGallery { } private select(value: number, options: SelectOptions = {}): void { - this.selectedIndex = wrapIndex(value, this.options.photos.length); - const photo = this.options.photos[this.selectedIndex]; + this.selectedIndex = wrapIndex(value, this.thumbnails.length); + const thumbnail = this.thumbnails[this.selectedIndex]; - if (!photo) { + if (!thumbnail) { throw new Error('Selected photo is missing.'); } - this.options.frame.replaceChildren(createFrameFigure(photo)); - this.updateSelectedThumbnail(photo, options.focusThumbnail ?? false); + this.options.frame.replaceChildren(createFrameFigure(thumbnail)); + this.updateSelectedThumbnail( + this.selectedIndex, + options.focusThumbnail ?? false + ); if (options.pause) { this.pauseAutoAdvance(); } } - private updateSelectedThumbnail(photo: Photo, shouldFocus: boolean): void { - for (const thumbnail of this.thumbnails) { - const selected = thumbnail.dataset.photoId === photo.id; + private updateSelectedThumbnail(index: number, shouldFocus: boolean): void { + this.thumbnails.forEach((thumbnail, i) => { + const selected = i === index; if (selected) { thumbnail.setAttribute('aria-current', 'true'); } else { @@ -238,7 +221,7 @@ export class PhotoGallery { inline: 'nearest', }); } - } + }); } private startAutoAdvance(): void {