Migrate to vite
All checks were successful
Check & deploy / validate (push) Successful in 3m0s
Check & deploy / deploy (push) Successful in 55s

This commit is contained in:
Andras Schmelczer 2026-05-31 13:23:10 +01:00
parent 1914991250
commit 5bdf68eabd
10 changed files with 611 additions and 56 deletions

View file

@ -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/`.

View file

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

View file

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

View 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.`
);

View 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
View 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('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
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();

View 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.`);

View file

@ -36,6 +36,14 @@
</head>
<body>
<header class="site-header">
<button
id="slideshow-toggle"
class="slideshow-toggle"
type="button"
aria-pressed="false"
>
Play
</button>
<h1>András Schmelczer</h1>
</header>
@ -52,14 +60,6 @@
<div id="frame-content" class="frame-content">
<!-- gallery:frame -->
</div>
<button
id="slideshow-toggle"
class="slideshow-toggle"
type="button"
aria-pressed="false"
>
Play
</button>
</section>
<section

View file

@ -33,9 +33,11 @@ html {
body {
min-height: 100dvh;
block-size: 100dvh;
margin: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
overflow: hidden;
}
img {
@ -56,6 +58,8 @@ img {
.site-header {
display: grid;
place-items: center;
position: relative;
min-inline-size: 5rem;
background: var(--color-accent);
color: var(--color-text);
padding: var(--space-m) var(--space-s);
@ -135,7 +139,6 @@ img {
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
padding: var(--space-m);
}
@ -180,14 +183,18 @@ img {
.slideshow-toggle {
display: none;
justify-self: end;
margin-block-start: var(--space-s);
position: absolute;
inset-block-start: var(--space-s);
inset-inline-start: var(--space-s);
justify-self: start;
border: 0;
border-radius: 4px;
padding: 0.45rem 0.75rem;
background: var(--color-accent);
background: var(--color-accent-strong);
color: var(--color-text);
font: inherit;
font-size: 0.875rem;
line-height: 1;
cursor: pointer;
}
@ -196,7 +203,7 @@ img {
}
.slideshow-toggle:hover {
background: var(--color-accent-strong);
background: #5f1724;
}
@media (prefers-reduced-motion: reduce) {
@ -216,13 +223,20 @@ img {
}
.site-header {
padding: var(--space-s) var(--space-m);
grid-template-columns: auto minmax(0, 1fr);
column-gap: var(--space-s);
padding: var(--space-s);
}
.slideshow-toggle {
position: static;
}
.site-header h1 {
justify-self: center;
writing-mode: horizontal-tb;
transform: none;
font-size: 2rem;
font-size: clamp(1.5rem, 7vw, 2rem);
}
.gallery {

58
vite.config.ts Normal file
View file

@ -0,0 +1,58 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { defineConfig, type Plugin } from 'vite';
const SITE_URL = 'https://schmelczer.dev/photos/';
const readGallerySection = (name: string): string => {
try {
const html = readFileSync(
resolve(__dirname, 'src/generated/gallery.html'),
'utf8'
);
const match = html.match(
new RegExp(`<!-- ${name} -->([\\s\\S]*?)<!-- /${name} -->`)
);
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('<!-- gallery:portraits -->', readGallerySection('portraits'))
.replace('<!-- gallery:frame -->', readGallerySection('frame'))
.replace('<!-- gallery:landscapes -->', 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()],
});