From 5bdf68eabdcf4f066d7fa7982e613f4dae6f521f Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 31 May 2026 13:23:10 +0100 Subject: [PATCH] Migrate to vite --- README.md | 81 ++++++++++ package.json | 74 +++++---- playwright.config.ts | 2 +- scripts/check-image-metadata.mjs | 37 +++++ scripts/check-photo-catalog.mjs | 84 +++++++++++ scripts/generate-assets.mjs | 248 +++++++++++++++++++++++++++++++ scripts/strip-image-metadata.mjs | 39 +++++ src/index.html | 16 +- src/index.scss | 28 +++- vite.config.ts | 58 ++++++++ 10 files changed, 611 insertions(+), 56 deletions(-) create mode 100644 scripts/check-image-metadata.mjs create mode 100644 scripts/check-photo-catalog.mjs create mode 100644 scripts/generate-assets.mjs create mode 100644 scripts/strip-image-metadata.mjs create mode 100644 vite.config.ts diff --git a/README.md b/README.md index a410898..62d8866 100644 --- a/README.md +++ b/README.md @@ -1 +1,82 @@ # Photos + +A static, progressively enhanced photo portfolio for András Schmelczer. + +## Requirements + +- Node `22.13.0` or newer compatible with `.nvmrc` +- npm `10.9.0` or newer + +Install with: + +```sh +npm ci +``` + +## Development + +```sh +npm run dev +``` + +The dev server binds to `127.0.0.1` by default. This generates responsive image assets and the typed photo manifest before Vite starts. + +## Build + +```sh +npm run build +``` + +The build writes to `dist/`. Static files come from `public/`, and responsive photo assets are generated into `public/static/photos/`. + +## Quality Gates + +Run the full local validation suite with: + +```sh +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. + +Run unit tests separately: + +```sh +npm test +``` + +Use `npm run lint` to apply Prettier formatting and auto-fixable ESLint/Stylelint issues. + +End-to-end smoke tests are available separately: + +```sh +npm run test:e2e +``` + +Playwright requires a browser installation. CI installs Chromium before running the smoke test. + +## Photo Catalog + +Photo order, captions, and alt text live in `src/photo-catalog.json`. Add new source JPEGs to `src/pictures/`, then add a matching catalog entry. `npm run dev`, `npm run lint:check`, and `npm run build` validate that the catalog and source directory match. + +The generated files under `src/generated/` and `public/static/photos/` are intentionally ignored because they are reproducible from the source images and catalog. + +## Metadata + +Source photos should not contain private EXIF/XMP/IPTC metadata. Check with: + +```sh +npm run lint:check +``` + +Strip metadata with: + +```sh +node scripts/strip-image-metadata.mjs +``` + +Review image changes after stripping because this rewrites the JPEG files. + +## Deployment + +The Forgejo workflow validates pull requests and trusted pushes with `npm ci`, `npm run lint:check`, `npm test`, `npm run build`, and a Playwright smoke test. Only pushes to `main` deploy, by copying `dist/` to `/pages/photos/`. diff --git a/package.json b/package.json index 148d8a5..763d877 100644 --- a/package.json +++ b/package.json @@ -1,61 +1,55 @@ { - "name": "portfolio", + "name": "photos", "version": "1.0.0", - "description": "An easily configurable portfolio.", + "description": "A static, progressively enhanced photo portfolio.", "private": true, + "type": "module", "scripts": { - "start": "webpack-dev-server --mode development", - "build": "webpack && find dist -type f -regex \".*\\(js\\|css\\|LICENSE.*\\)\" | xargs rm" + "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 .", + "test": "vitest run", + "test:e2e": "playwright test", + "build": "node scripts/generate-assets.mjs && vite build" + }, + "engines": { + "node": ">=22.13.0", + "npm": ">=10.9.0" }, "repository": { "type": "git", - "url": "git+https://github.com/schmelczerandras/timeline.git" + "url": "git+https://github.com/schmelczerandras/photos.git" }, "keywords": [ - "CV", "portfolio", - "resume", - "resumé" + "photography", + "photos", + "gallery" ], "author": "András Schmelczer", "license": "UNLICENSED", "bugs": { - "url": "https://github.com/schmelczerandras/timeline/issues" - }, - "postcss": { - "plugins": { - "autoprefixer": {} - } + "url": "https://github.com/schmelczerandras/photos/issues" }, + "homepage": "https://schmelczer.dev/photos/", "browserslist": [ "defaults" ], - "homepage": "https://github.com/schmelczerandras/timeline#readme", "devDependencies": { - "autoprefixer": "^9.8.6", - "clean-webpack-plugin": "^3.0.0", - "css-loader": "^3.6.0", - "cssnano": "latest", - "file-loader": "^5.1.0", - "html-webpack-inline-source-plugin": "0.0.10", - "html-webpack-plugin": "^3.2.0", - "image-webpack-loader": "^6.0.0", - "mini-css-extract-plugin": "^0.9.0", - "optimize-css-assets-webpack-plugin": "^5.0.4", - "postcss-loader": "^3.0.0", - "prettier": "^1.19.1", - "resolve-url-loader": "^3.1.1", - "responsive-loader": "^1.2.0", - "sass": "^1.26.11", - "sass-loader": "^8.0.2", - "sharp": "^0.23.4", - "style-loader": "^1.2.1", - "svg-url-loader": "^3.0.3", - "terser-webpack-plugin": "^2.3.8", - "ts-loader": "^6.2.2", - "typescript": "^3.9.7", - "webpack": "^4.44.2", - "webpack-cli": "^3.3.12", - "webpack-dev-server": "^3.11.0" + "@eslint/js": "^10.0.1", + "@playwright/test": "^1.60.0", + "@types/node": "^25.9.1", + "eslint": "^10.4.1", + "globals": "^16.5.0", + "jsdom": "^29.1.1", + "prettier": "^3.8.3", + "sass": "^1.95.0", + "sharp": "^0.34.5", + "stylelint": "^17.12.0", + "stylelint-config-standard-scss": "^17.0.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.60.0", + "vite": "^8.0.14", + "vitest": "^4.1.7" } } diff --git a/playwright.config.ts b/playwright.config.ts index d71063c..d6d7d7d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ trace: 'on-first-retry', }, webServer: { - command: 'npm run build && npm run preview', + command: 'npm run build && npx vite preview --host 127.0.0.1', url: 'http://127.0.0.1:4173', reuseExistingServer: !process.env.CI, timeout: 120_000, diff --git a/scripts/check-image-metadata.mjs b/scripts/check-image-metadata.mjs new file mode 100644 index 0000000..d66ece3 --- /dev/null +++ b/scripts/check-image-metadata.mjs @@ -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.` +); diff --git a/scripts/check-photo-catalog.mjs b/scripts/check-photo-catalog.mjs new file mode 100644 index 0000000..9af4d0f --- /dev/null +++ b/scripts/check-photo-catalog.mjs @@ -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(); diff --git a/scripts/generate-assets.mjs b/scripts/generate-assets.mjs new file mode 100644 index 0000000..812ec80 --- /dev/null +++ b/scripts/generate-assets.mjs @@ -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) => + ``; + +const renderPicture = ( + photo, + sizes, + imgAttributes = '', + src = photo.fallback.src +) => { + const [avif, webp, jpeg] = photo.sources; + + return ` + ${renderSource(avif, sizes)} + ${renderSource(webp, sizes)} + ${escapeHtml(photo.alt)} + `; +}; + +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 ` + ${renderPicture(photo, sizes, 'loading="lazy" decoding="async"', smallestJpeg.src)} + `; +}; + +const renderFrame = (photo) => `
+ ${renderPicture( + photo, + '(max-width: 899px) calc(100vw - 2rem), calc(100vw - 18rem)', + 'id="frame-image" decoding="async" fetchpriority="high"' + )} +
${escapeHtml(photo.caption)}
+
`; + +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} + + +${renderFrame(firstPhoto)} + + +${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(); diff --git a/scripts/strip-image-metadata.mjs b/scripts/strip-image-metadata.mjs new file mode 100644 index 0000000..fa1760d --- /dev/null +++ b/scripts/strip-image-metadata.mjs @@ -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.`); diff --git a/src/index.html b/src/index.html index c3da238..7b1f6f0 100644 --- a/src/index.html +++ b/src/index.html @@ -36,6 +36,14 @@ @@ -52,14 +60,6 @@
-
{ + try { + const html = readFileSync( + resolve(__dirname, 'src/generated/gallery.html'), + 'utf8' + ); + const match = html.match( + new RegExp(`([\\s\\S]*?)`) + ); + + return match?.[1]?.trim() ?? ''; + } catch { + return ''; + } +}; + +const generatedGalleryPlugin = (): Plugin => ({ + name: 'generated-gallery', + transformIndexHtml(html) { + const ogImageUrl = new URL('og-image.jpg', SITE_URL).toString(); + + return html + .replaceAll('__SITE_URL__', SITE_URL) + .replaceAll('__OG_IMAGE_URL__', ogImageUrl) + .replace('', readGallerySection('portraits')) + .replace('', readGallerySection('frame')) + .replace('', readGallerySection('landscapes')); + }, +}); + +export default defineConfig({ + base: './', + root: 'src', + publicDir: '../public', + build: { + outDir: '../dist', + emptyOutDir: true, + assetsDir: 'assets', + sourcemap: false, + }, + server: { + host: '127.0.0.1', + port: 5173, + strictPort: false, + }, + preview: { + host: '127.0.0.1', + port: 4173, + strictPort: false, + }, + plugins: [generatedGalleryPlugin()], +});