This commit is contained in:
Andras Schmelczer 2026-05-31 14:29:43 +01:00
parent 5bdf68eabd
commit d5aa9c43bb
15 changed files with 166 additions and 285 deletions

View file

@ -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

1
.gitignore vendored
View file

@ -1,6 +1,5 @@
node_modules
dist
target
.DS_Store
public/static/photos
src/generated

View file

@ -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:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

View file

@ -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",

View file

@ -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 = [];

View file

@ -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();

View file

@ -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) =>
`<source type="${type}" srcset="${escapeHtml(srcset)}" sizes="${escapeHtml(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 `<a class="thumbnail thumbnail--${photo.orientation}" href="${photo.fallback.src}" data-photo-id="${photo.id}"${isCurrent ? ' aria-current="true"' : ''}>
// The frame is built at runtime by cloning this thumbnail's <picture>, 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 `<a class="thumbnail thumbnail--${photo.orientation}" href="${photo.fallback.src}" ${dataAttributes}${isCurrent ? ' aria-current="true"' : ''}>
${renderPicture(photo, sizes, 'loading="lazy" decoding="async"', smallestJpeg.src)}
</a>`;
};
@ -88,17 +111,37 @@ const renderThumbnail = (photo, isCurrent) => {
const renderFrame = (photo) => `<figure class="frame-figure">
${renderPicture(
photo,
'(max-width: 899px) calc(100vw - 2rem), calc(100vw - 18rem)',
frameSizes(photo),
'id="frame-image" decoding="async" fetchpriority="high"'
)}
<figcaption id="frame-caption">${escapeHtml(photo.caption)}</figcaption>
</figure>`;
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}
<!-- /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(

View file

@ -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;

View file

@ -56,7 +56,7 @@
<!-- gallery:portraits -->
</section>
<section class="frame" aria-live="polite">
<section class="frame">
<div id="frame-content" class="frame-content">
<!-- gallery:frame -->
</div>

View file

@ -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);
}

View file

@ -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<HTMLButtonElement>(
);
new PhotoGallery({
photos,
gallery,
frame,
toggle,

View file

@ -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;
};

View file

@ -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 => `
<a
class="thumbnail"
href="static/photos/${id}-2400.jpg"
data-photo-id="${id}"
data-order="${order}"
data-caption="${alt}"
data-frame-src="static/photos/${id}-2400.jpg"
data-frame-sizes="100vw"
>
<picture>
<source type="image/avif" srcset="static/photos/${id}-480.avif 480w" sizes="8vmin" />
<source type="image/webp" srcset="static/photos/${id}-480.webp 480w" sizes="8vmin" />
<img
src="static/photos/${id}-480.jpg"
srcset="static/photos/${id}-480.jpg 480w"
sizes="8vmin"
width="320"
height="240"
alt="${alt}"
loading="lazy"
decoding="async"
/>
</picture>
</a>`;
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 = `
<main id="gallery">
<a href="static/photos/one.jpg" data-photo-id="one">One</a>
<a href="static/photos/two.jpg" data-photo-id="two">Two</a>
${thumbnail('one', 1, 'First photo')}
${thumbnail('two', 2, 'Second photo')}
</main>
<section id="frame"></section>
<section class="frame"><div id="frame-content"></div></section>
<button id="toggle" type="button">Play</button>
`;
const gallery = document.querySelector<HTMLElement>('#gallery');
const frame = document.querySelector<HTMLElement>('#frame');
const frame = document.querySelector<HTMLElement>('#frame-content');
const toggle = document.querySelector<HTMLButtonElement>('#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<HTMLAnchorElement>('[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', {

View file

@ -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 <picture>. 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<HTMLAnchorElement>('[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<HTMLAnchorElement>(
`[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 {