Files
bmp-website-2026/src/scripts/migrate-spa-media.ts
2026-06-22 13:11:35 +02:00

211 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import config from '@payload-config'
import crypto from 'crypto'
import 'dotenv/config'
import fs from 'fs/promises'
import path from 'path'
import { getPayload, type File, type Payload } from 'payload'
const root = process.cwd()
const spaDir = path.join(root, 'src/spa')
const publicDir = path.join(root, 'public')
const mediaExtensions = new Set([
'.avif',
'.gif',
'.jpeg',
'.jpg',
'.mov',
'.mp4',
'.png',
'.svg',
'.webm',
'.webp',
'.zip',
])
const mimeByExt: Record<string, string> = {
'.avif': 'image/avif',
'.gif': 'image/gif',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.mov': 'video/quicktime',
'.mp4': 'video/mp4',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.webm': 'video/webm',
'.webp': 'image/webp',
'.zip': 'application/zip',
}
async function walk(dir: string): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true })
const files = await Promise.all(
entries.map((entry) => {
const full = path.join(dir, entry.name)
return entry.isDirectory() ? walk(full) : Promise.resolve([full])
}),
)
return files.flat()
}
function isMediaSource(value: string) {
try {
const parsed = value.startsWith('http') ? new URL(value) : undefined
const pathname = parsed?.pathname || value.split('?')[0]
return mediaExtensions.has(path.extname(pathname).toLowerCase()) || value.includes('images.unsplash.com')
} catch {
return false
}
}
async function findSpaSources() {
const files = (await walk(spaDir)).filter((file) => /\.(tsx?|jsx?)$/.test(file))
const sources = new Set<string>()
const quotedUrl = /["'`]((?:https?:\/\/images\.unsplash\.com\/[^"'`\s)]+)|(?:\/(?:images\/)?[^"'`\s)]+\.(?:avif|gif|jpe?g|mov|mp4|png|svg|webm|webp|zip)(?:\?[^"'`\s)]*)?))["'`]/gi
const cssUrl = /url\((['"]?)(.*?)\1\)/g
for (const file of files) {
const content = await fs.readFile(file, 'utf8')
for (const match of content.matchAll(quotedUrl)) {
if (isMediaSource(match[1])) sources.add(match[1])
}
for (const match of content.matchAll(cssUrl)) {
if (isMediaSource(match[2])) sources.add(match[2])
}
}
return sources
}
async function findPublicMedia() {
const sources = new Set<string>()
const files = await walk(publicDir)
for (const file of files) {
const relative = path.relative(publicDir, file).split(path.sep).join('/')
if (relative.startsWith('media/')) continue
const ext = path.extname(file).toLowerCase()
if (!mediaExtensions.has(ext)) continue
sources.add(`/${relative}`)
}
return sources
}
function safeRemoteName(url: string) {
const parsed = new URL(url)
const ext = path.extname(parsed.pathname) || '.jpg'
const hash = crypto.createHash('sha1').update(url).digest('hex').slice(0, 10)
return `${parsed.hostname.replace(/[^a-z0-9]+/gi, '-')}-${hash}${ext}`.toLowerCase()
}
async function fileForSource(source: string): Promise<File> {
if (source.startsWith('http')) {
const res = await fetch(source)
if (!res.ok) throw new Error(`Failed to fetch ${source}: ${res.status}`)
const data = Buffer.from(await res.arrayBuffer())
const name = safeRemoteName(source)
return {
name,
data,
mimetype: res.headers.get('content-type')?.split(';')[0] || mimeByExt[path.extname(name)] || 'application/octet-stream',
size: data.byteLength,
}
}
const cleanSource = source.split('?')[0]
const fullPath = path.join(publicDir, cleanSource.replace(/^\//, ''))
const data = await fs.readFile(fullPath)
const ext = path.extname(fullPath).toLowerCase()
return {
name: path.basename(fullPath),
data,
mimetype: mimeByExt[ext] || 'application/octet-stream',
size: data.byteLength,
}
}
function altForSource(source: string) {
const basename = source.startsWith('http') ? safeRemoteName(source) : path.basename(source.split('?')[0])
return basename.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ')
}
async function findExistingMedia(payload: Payload, filename: string) {
const existing = await payload.find({
collection: 'media',
depth: 0,
limit: 1,
where: { filename: { equals: filename } },
})
return existing.docs[0]
}
async function main() {
const payload = await getPayload({ config })
const sources = new Set([...(await findPublicMedia()), ...(await findSpaSources())])
const sortedSources = [...sources].sort((a, b) => a.localeCompare(b))
const assets: { source: string; media: number | string }[] = []
payload.logger.info(`Migrating ${sortedSources.length} SPA media assets into Payload...`)
for (const source of sortedSources) {
const file = await fileForSource(source)
let doc = await findExistingMedia(payload, file.name)
if (!doc) {
doc = await payload.create({
collection: 'media',
data: { alt: altForSource(source) },
file,
})
payload.logger.info(`created media: ${source} -> ${doc.filename}`)
} else {
payload.logger.info(`reused media: ${source} -> ${doc.filename}`)
}
assets.push({ source, media: doc.id })
}
const bySource = new Map(assets.map((asset) => [asset.source, asset]))
const assetId = (source: string) => bySource.get(source)?.media
await Promise.all([
assetId('/bmp-logo.png')
? payload.updateGlobal({
slug: 'header',
data: { logo: assetId('/bmp-logo.png') } as never,
context: { disableRevalidate: true },
})
: Promise.resolve(),
assetId('/bmp-logo.png')
? payload.updateGlobal({
slug: 'footer',
data: {
logo: assetId('/bmp-logo.png'),
partnerLogos: [
{ alt: 'EWIF Europäisches Wirtschaftsforum', image: assetId('/images/ewif-logo.png') },
{
alt: 'Hochschule für angewandtes Management · Gipfeldialog Altaussee',
image: assetId('/images/partner-logos.png'),
},
].filter((item) => item.image),
} as never,
context: { disableRevalidate: true },
})
: Promise.resolve(),
assetId('/munich-skyline.jpg')
? payload.updateGlobal({
slug: 'site-settings',
data: { skylineImage: assetId('/munich-skyline.jpg') } as never,
context: { disableRevalidate: true },
})
: Promise.resolve(),
])
payload.logger.info('SPA media migration complete.')
process.exit(0)
}
main().catch((error) => {
console.error(error)
process.exit(1)
})