Migrate to vite
This commit is contained in:
parent
1914991250
commit
5bdf68eabd
10 changed files with 611 additions and 56 deletions
81
README.md
81
README.md
|
|
@ -1 +1,82 @@
|
||||||
# Photos
|
# 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/`.
|
||||||
|
|
|
||||||
74
package.json
74
package.json
|
|
@ -1,61 +1,55 @@
|
||||||
{
|
{
|
||||||
"name": "portfolio",
|
"name": "photos",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "An easily configurable portfolio.",
|
"description": "A static, progressively enhanced photo portfolio.",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "webpack-dev-server --mode development",
|
"dev": "node scripts/generate-assets.mjs && vite --host 0.0.0.0",
|
||||||
"build": "webpack && find dist -type f -regex \".*\\(js\\|css\\|LICENSE.*\\)\" | xargs rm"
|
"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": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/schmelczerandras/timeline.git"
|
"url": "git+https://github.com/schmelczerandras/photos.git"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"CV",
|
|
||||||
"portfolio",
|
"portfolio",
|
||||||
"resume",
|
"photography",
|
||||||
"resumé"
|
"photos",
|
||||||
|
"gallery"
|
||||||
],
|
],
|
||||||
"author": "András Schmelczer",
|
"author": "András Schmelczer",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/schmelczerandras/timeline/issues"
|
"url": "https://github.com/schmelczerandras/photos/issues"
|
||||||
},
|
|
||||||
"postcss": {
|
|
||||||
"plugins": {
|
|
||||||
"autoprefixer": {}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
"homepage": "https://schmelczer.dev/photos/",
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"defaults"
|
"defaults"
|
||||||
],
|
],
|
||||||
"homepage": "https://github.com/schmelczerandras/timeline#readme",
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"autoprefixer": "^9.8.6",
|
"@eslint/js": "^10.0.1",
|
||||||
"clean-webpack-plugin": "^3.0.0",
|
"@playwright/test": "^1.60.0",
|
||||||
"css-loader": "^3.6.0",
|
"@types/node": "^25.9.1",
|
||||||
"cssnano": "latest",
|
"eslint": "^10.4.1",
|
||||||
"file-loader": "^5.1.0",
|
"globals": "^16.5.0",
|
||||||
"html-webpack-inline-source-plugin": "0.0.10",
|
"jsdom": "^29.1.1",
|
||||||
"html-webpack-plugin": "^3.2.0",
|
"prettier": "^3.8.3",
|
||||||
"image-webpack-loader": "^6.0.0",
|
"sass": "^1.95.0",
|
||||||
"mini-css-extract-plugin": "^0.9.0",
|
"sharp": "^0.34.5",
|
||||||
"optimize-css-assets-webpack-plugin": "^5.0.4",
|
"stylelint": "^17.12.0",
|
||||||
"postcss-loader": "^3.0.0",
|
"stylelint-config-standard-scss": "^17.0.0",
|
||||||
"prettier": "^1.19.1",
|
"typescript": "^6.0.3",
|
||||||
"resolve-url-loader": "^3.1.1",
|
"typescript-eslint": "^8.60.0",
|
||||||
"responsive-loader": "^1.2.0",
|
"vite": "^8.0.14",
|
||||||
"sass": "^1.26.11",
|
"vitest": "^4.1.7"
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ export default defineConfig({
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
},
|
},
|
||||||
webServer: {
|
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',
|
url: 'http://127.0.0.1:4173',
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
|
|
|
||||||
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.`);
|
||||||
|
|
@ -36,6 +36,14 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
|
<button
|
||||||
|
id="slideshow-toggle"
|
||||||
|
class="slideshow-toggle"
|
||||||
|
type="button"
|
||||||
|
aria-pressed="false"
|
||||||
|
>
|
||||||
|
Play
|
||||||
|
</button>
|
||||||
<h1>András Schmelczer</h1>
|
<h1>András Schmelczer</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -52,14 +60,6 @@
|
||||||
<div id="frame-content" class="frame-content">
|
<div id="frame-content" class="frame-content">
|
||||||
<!-- gallery:frame -->
|
<!-- gallery:frame -->
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
id="slideshow-toggle"
|
|
||||||
class="slideshow-toggle"
|
|
||||||
type="button"
|
|
||||||
aria-pressed="false"
|
|
||||||
>
|
|
||||||
Play
|
|
||||||
</button>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,11 @@ html {
|
||||||
|
|
||||||
body {
|
body {
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
|
block-size: 100dvh;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
|
|
@ -56,6 +58,8 @@ img {
|
||||||
.site-header {
|
.site-header {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
position: relative;
|
||||||
|
min-inline-size: 5rem;
|
||||||
background: var(--color-accent);
|
background: var(--color-accent);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
padding: var(--space-m) var(--space-s);
|
padding: var(--space-m) var(--space-s);
|
||||||
|
|
@ -135,7 +139,6 @@ img {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: minmax(0, 1fr) auto;
|
|
||||||
padding: var(--space-m);
|
padding: var(--space-m);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -180,14 +183,18 @@ img {
|
||||||
|
|
||||||
.slideshow-toggle {
|
.slideshow-toggle {
|
||||||
display: none;
|
display: none;
|
||||||
justify-self: end;
|
position: absolute;
|
||||||
margin-block-start: var(--space-s);
|
inset-block-start: var(--space-s);
|
||||||
|
inset-inline-start: var(--space-s);
|
||||||
|
justify-self: start;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 0.45rem 0.75rem;
|
padding: 0.45rem 0.75rem;
|
||||||
background: var(--color-accent);
|
background: var(--color-accent-strong);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -196,7 +203,7 @@ img {
|
||||||
}
|
}
|
||||||
|
|
||||||
.slideshow-toggle:hover {
|
.slideshow-toggle:hover {
|
||||||
background: var(--color-accent-strong);
|
background: #5f1724;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|
@ -216,13 +223,20 @@ img {
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-header {
|
.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 {
|
.site-header h1 {
|
||||||
|
justify-self: center;
|
||||||
writing-mode: horizontal-tb;
|
writing-mode: horizontal-tb;
|
||||||
transform: none;
|
transform: none;
|
||||||
font-size: 2rem;
|
font-size: clamp(1.5rem, 7vw, 2rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery {
|
.gallery {
|
||||||
|
|
|
||||||
58
vite.config.ts
Normal file
58
vite.config.ts
Normal 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()],
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue