feat: add hero image processing and API for serving variants
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m44s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped

- Introduced a new script `prepare-hero-variants.mjs` to generate responsive hero image variants in WebP format.
- Added a fallback image `hero_fallback.png` for each variant.
- Created an API endpoint `hero-images.get.js` to retrieve available hero image variants and their fallback images.
- Implemented directory and file checks to ensure the existence of required images before serving.
This commit is contained in:
Torsten Schulz (local)
2026-05-31 14:07:14 +02:00
parent 7c93966878
commit 6983186caf
45 changed files with 422 additions and 28 deletions

View File

@@ -0,0 +1,157 @@
#!/usr/bin/env node
import { promises as fs } from 'fs'
import path from 'path'
import sharp from 'sharp'
const cwd = process.cwd()
const outputRoot = path.join(cwd, 'public', 'images', 'hero')
const inputRoots = [
path.join(cwd, 'assets', 'images', 'hero-originals'),
path.join(cwd, 'public', 'images', 'hero-originals')
]
const OUTPUT_VARIANTS = [
{ width: 960, height: 540, quality: 66, fileName: 'hero_960.webp' },
{ width: 1600, height: 900, quality: 72, fileName: 'hero_1600.webp' }
]
async function ensureDir(dirPath) {
await fs.mkdir(dirPath, { recursive: true })
}
async function listSubdirs(rootPath) {
const entries = await fs.readdir(rootPath, { withFileTypes: true })
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name)
}
async function listFiles(rootPath) {
const entries = await fs.readdir(rootPath, { withFileTypes: true })
return entries.filter((entry) => entry.isFile()).map((entry) => entry.name)
}
function normalizeVariantName(name) {
return String(name || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '') || 'hero'
}
function pickSourcePng(files) {
const pngFiles = files.filter((file) => /\.png$/i.test(file))
if (!pngFiles.length) return null
const prioritized = ['hero.png', 'hero-original.png', 'original.png']
for (const name of prioritized) {
const found = pngFiles.find((file) => file.toLowerCase() === name)
if (found) return found
}
return pngFiles.sort((a, b) => a.localeCompare(b, 'de'))[0]
}
async function convertVariant(sourcePath, targetDir, variant) {
const targetPath = path.join(targetDir, variant.fileName)
await sharp(sourcePath)
.resize(variant.width, variant.height, { fit: 'cover', position: 'centre' })
.webp({ quality: variant.quality })
.toFile(targetPath)
}
async function collectVariantSources(rootPath) {
const sources = []
const subdirs = await listSubdirs(rootPath)
for (const dirName of subdirs.sort((a, b) => a.localeCompare(b, 'de'))) {
const dirPath = path.join(rootPath, dirName)
const files = await fs.readdir(dirPath)
const sourceFile = pickSourcePng(files)
if (!sourceFile) continue
sources.push({
variant: normalizeVariantName(dirName),
sourcePath: path.join(dirPath, sourceFile)
})
}
if (sources.length) return sources
const rootFiles = await listFiles(rootPath)
const pngFiles = rootFiles.filter((file) => /\.png$/i.test(file)).sort((a, b) => a.localeCompare(b, 'de'))
for (const fileName of pngFiles) {
sources.push({
variant: normalizeVariantName(path.parse(fileName).name),
sourcePath: path.join(rootPath, fileName)
})
}
return sources
}
async function findInputRootWithSources() {
for (const rootPath of inputRoots) {
try {
const stats = await fs.stat(rootPath)
if (!stats.isDirectory()) continue
const sources = await collectVariantSources(rootPath)
if (sources.length) {
return { rootPath, sources }
}
} catch {
// ignore missing roots
}
}
return null
}
async function processVariantSource(source) {
const targetDir = path.join(outputRoot, source.variant)
await ensureDir(targetDir)
for (const variant of OUTPUT_VARIANTS) {
await convertVariant(source.sourcePath, targetDir, variant)
}
await fs.copyFile(source.sourcePath, path.join(targetDir, 'hero_fallback.png'))
return {
variant: source.variant,
source: path.relative(cwd, source.sourcePath),
target: path.relative(cwd, targetDir)
}
}
async function main() {
try {
await ensureDir(outputRoot)
const input = await findInputRootWithSources()
if (!input) {
console.error('Keine Hero-Quellen gefunden.')
console.error('Unterstuetzte Pfade:')
console.error(`- ${inputRoots[0]}/<variante>/*.png`)
console.error(`- ${inputRoots[1]}/*.png`)
process.exit(1)
}
const results = []
for (const source of input.sources) {
const result = await processVariantSource(source)
results.push(result)
}
console.log(`Input-Quelle: ${path.relative(cwd, input.rootPath)}`)
console.log('Hero-Varianten erfolgreich erstellt:')
for (const result of results) {
console.log(`- ${result.variant}: ${result.source} -> ${result.target}`)
}
} catch (error) {
console.error('Fehler bei der Hero-Bildaufbereitung:', error)
process.exit(1)
}
}
await main()