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

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