Migrate to vite
This commit is contained in:
parent
1914991250
commit
5bdf68eabd
10 changed files with 611 additions and 56 deletions
37
scripts/check-image-metadata.mjs
Normal file
37
scripts/check-image-metadata.mjs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { readdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
const pictureDir = path.join(rootDir, 'src/pictures');
|
||||
|
||||
const files = (await readdir(pictureDir)).filter((file) =>
|
||||
file.toLowerCase().endsWith('.jpg')
|
||||
);
|
||||
|
||||
const offenders = [];
|
||||
|
||||
for (const file of files) {
|
||||
const metadata = await sharp(path.join(pictureDir, file)).metadata();
|
||||
|
||||
if (metadata.exif || metadata.xmp || metadata.iptc) {
|
||||
offenders.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (offenders.length > 0) {
|
||||
console.error('Private image metadata found in source photos:');
|
||||
for (const file of offenders) {
|
||||
console.error(`- ${file}`);
|
||||
}
|
||||
console.error(
|
||||
'Run `node scripts/strip-image-metadata.mjs` and review the image changes.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Checked ${files.length} source photos for EXIF/XMP/IPTC metadata.`
|
||||
);
|
||||
84
scripts/check-photo-catalog.mjs
Normal file
84
scripts/check-photo-catalog.mjs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
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();
|
||||
248
scripts/generate-assets.mjs
Normal file
248
scripts/generate-assets.mjs
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
const sourceDir = path.join(rootDir, 'src/pictures');
|
||||
const publicPhotoDir = path.join(rootDir, 'public/static/photos');
|
||||
const generatedDir = path.join(rootDir, 'src/generated');
|
||||
const catalogPath = path.join(rootDir, 'src/photo-catalog.json');
|
||||
const widths = [480, 960, 1600, 2400];
|
||||
const formats = [
|
||||
{
|
||||
type: 'image/avif',
|
||||
extension: 'avif',
|
||||
options: { quality: 45, effort: 0 },
|
||||
},
|
||||
{ type: 'image/webp', extension: 'webp', options: { quality: 78 } },
|
||||
{
|
||||
type: 'image/jpeg',
|
||||
extension: 'jpg',
|
||||
options: { quality: 82, mozjpeg: true, progressive: true },
|
||||
},
|
||||
];
|
||||
|
||||
const slugify = (value) =>
|
||||
value
|
||||
.replace(/\.[^.]+$/, '')
|
||||
.normalize('NFKD')
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[_\s]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
const escapeHtml = (value) =>
|
||||
value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
|
||||
const uniqueWidthsFor = (sourceWidth) => {
|
||||
const maxOutputWidth = Math.min(sourceWidth, widths.at(-1));
|
||||
const selected = widths.filter((width) => width <= maxOutputWidth);
|
||||
const withOriginal =
|
||||
selected.length > 0 ? selected : [Math.max(1, Math.round(sourceWidth))];
|
||||
|
||||
return [...new Set(withOriginal)].sort((a, b) => a - b);
|
||||
};
|
||||
|
||||
const renderSource = ({ type, srcset }, sizes) =>
|
||||
`<source type="${type}" srcset="${escapeHtml(srcset)}" sizes="${escapeHtml(sizes)}">`;
|
||||
|
||||
const renderPicture = (
|
||||
photo,
|
||||
sizes,
|
||||
imgAttributes = '',
|
||||
src = photo.fallback.src
|
||||
) => {
|
||||
const [avif, webp, jpeg] = photo.sources;
|
||||
|
||||
return `<picture>
|
||||
${renderSource(avif, sizes)}
|
||||
${renderSource(webp, sizes)}
|
||||
<img src="${src}" srcset="${escapeHtml(jpeg.srcset)}" sizes="${escapeHtml(sizes)}" width="${photo.width}" height="${photo.height}" alt="${escapeHtml(photo.alt)}" ${imgAttributes}>
|
||||
</picture>`;
|
||||
};
|
||||
|
||||
const renderThumbnail = (photo, isCurrent) => {
|
||||
const sizes =
|
||||
photo.orientation === 'landscape'
|
||||
? '(max-width: 899px) 32vmin, 12vmin'
|
||||
: '(max-width: 899px) 18vmin, 8vmin';
|
||||
|
||||
const smallestJpeg = photo.sources.find(
|
||||
(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"' : ''}>
|
||||
${renderPicture(photo, sizes, 'loading="lazy" decoding="async"', smallestJpeg.src)}
|
||||
</a>`;
|
||||
};
|
||||
|
||||
const renderFrame = (photo) => `<figure class="frame-figure">
|
||||
${renderPicture(
|
||||
photo,
|
||||
'(max-width: 899px) calc(100vw - 2rem), calc(100vw - 18rem)',
|
||||
'id="frame-image" decoding="async" fetchpriority="high"'
|
||||
)}
|
||||
<figcaption id="frame-caption">${escapeHtml(photo.caption)}</figcaption>
|
||||
</figure>`;
|
||||
|
||||
const assertCatalogMatchesFiles = async (catalog) => {
|
||||
const sourceFiles = new Set(
|
||||
(await readdir(sourceDir)).filter((file) =>
|
||||
file.toLowerCase().endsWith('.jpg')
|
||||
)
|
||||
);
|
||||
const catalogFiles = new Set(catalog.map((entry) => entry.file));
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
if (!catalogFiles.has(file)) {
|
||||
throw new Error(`Photo catalog is missing ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of catalogFiles) {
|
||||
if (!sourceFiles.has(file)) {
|
||||
throw new Error(`Photo catalog references missing file ${file}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const generate = async () => {
|
||||
const catalog = JSON.parse(await readFile(catalogPath, 'utf8')).map(
|
||||
(entry, index) => ({
|
||||
...entry,
|
||||
id: slugify(entry.file),
|
||||
order: index + 1,
|
||||
})
|
||||
);
|
||||
|
||||
await assertCatalogMatchesFiles(catalog);
|
||||
await rm(publicPhotoDir, { recursive: true, force: true });
|
||||
await mkdir(publicPhotoDir, { recursive: true });
|
||||
await mkdir(generatedDir, { recursive: true });
|
||||
|
||||
const photos = [];
|
||||
|
||||
for (const entry of catalog) {
|
||||
const inputPath = path.join(sourceDir, entry.file);
|
||||
const input = await readFile(inputPath);
|
||||
const hash = createHash('sha256').update(input).digest('hex').slice(0, 10);
|
||||
const metadata = await sharp(input).rotate().metadata();
|
||||
|
||||
if (!metadata.width || !metadata.height) {
|
||||
throw new Error(`Could not read dimensions for ${entry.file}`);
|
||||
}
|
||||
|
||||
const outputWidths = uniqueWidthsFor(metadata.width);
|
||||
const sources = [];
|
||||
|
||||
for (const format of formats) {
|
||||
const variants = outputWidths.map((width) => {
|
||||
const height = Math.round((width / metadata.width) * metadata.height);
|
||||
const filename = `${entry.id}-${hash}-${width}.${format.extension}`;
|
||||
|
||||
return {
|
||||
outputPath: path.join(publicPhotoDir, filename),
|
||||
src: `static/photos/${filename}`,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
variants.map(async ({ outputPath, width }) => {
|
||||
const pipeline = sharp(input).rotate().resize({
|
||||
width,
|
||||
withoutEnlargement: true,
|
||||
});
|
||||
|
||||
if (format.extension === 'avif') {
|
||||
await pipeline.avif(format.options).toFile(outputPath);
|
||||
} else if (format.extension === 'webp') {
|
||||
await pipeline.webp(format.options).toFile(outputPath);
|
||||
} else {
|
||||
await pipeline.jpeg(format.options).toFile(outputPath);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
sources.push({
|
||||
type: format.type,
|
||||
variants: variants.map(({ src, width, height }) => ({
|
||||
src,
|
||||
width,
|
||||
height,
|
||||
})),
|
||||
srcset: variants
|
||||
.map((variant) => `${variant.src} ${variant.width}w`)
|
||||
.join(', '),
|
||||
});
|
||||
}
|
||||
|
||||
const jpegSource = sources.find((source) => source.type === 'image/jpeg');
|
||||
const fallback = jpegSource.variants.at(-1);
|
||||
|
||||
photos.push({
|
||||
id: entry.id,
|
||||
file: entry.file,
|
||||
order: entry.order,
|
||||
alt: entry.alt,
|
||||
caption: entry.caption,
|
||||
orientation: metadata.width >= metadata.height ? 'landscape' : 'portrait',
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
aspectRatio: Number((metadata.width / metadata.height).toFixed(4)),
|
||||
sources,
|
||||
fallback,
|
||||
});
|
||||
}
|
||||
|
||||
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')
|
||||
.map((photo) => renderThumbnail(photo, photo.id === firstPhoto.id))
|
||||
.join('\n');
|
||||
const landscapes = photos
|
||||
.filter((photo) => photo.orientation === 'landscape')
|
||||
.map((photo) => renderThumbnail(photo, photo.id === firstPhoto.id))
|
||||
.join('\n');
|
||||
const galleryHtml = `<!-- portraits -->
|
||||
${portraits}
|
||||
<!-- /portraits -->
|
||||
<!-- frame -->
|
||||
${renderFrame(firstPhoto)}
|
||||
<!-- /frame -->
|
||||
<!-- landscapes -->
|
||||
${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(
|
||||
(count, photo) =>
|
||||
count +
|
||||
photo.sources.reduce(
|
||||
(sourceCount, source) => sourceCount + source.variants.length,
|
||||
0
|
||||
),
|
||||
0
|
||||
)} responsive assets.`
|
||||
);
|
||||
};
|
||||
|
||||
await generate();
|
||||
39
scripts/strip-image-metadata.mjs
Normal file
39
scripts/strip-image-metadata.mjs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { readdir, rename, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
const pictureDir = path.join(rootDir, 'src/pictures');
|
||||
|
||||
const files = (await readdir(pictureDir)).filter((file) =>
|
||||
file.toLowerCase().endsWith('.jpg')
|
||||
);
|
||||
|
||||
let stripped = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const sourcePath = path.join(pictureDir, file);
|
||||
const metadata = await sharp(sourcePath).metadata();
|
||||
|
||||
if (!metadata.exif && !metadata.xmp && !metadata.iptc) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tempPath = `${sourcePath}.stripped`;
|
||||
|
||||
try {
|
||||
await sharp(sourcePath)
|
||||
.rotate()
|
||||
.jpeg({ quality: 95, mozjpeg: true, progressive: true })
|
||||
.toFile(tempPath);
|
||||
await rename(tempPath, sourcePath);
|
||||
stripped += 1;
|
||||
} catch (error) {
|
||||
await rm(tempPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Stripped metadata from ${stripped} source photos.`);
|
||||
Loading…
Add table
Add a link
Reference in a new issue