diff --git a/package.json b/package.json index 4f62c76..553b4c1 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "ii": "cross-env NODE_OPTIONS=--no-deprecation pnpm --ignore-workspace install", "lint": "cross-env NODE_OPTIONS=--no-deprecation eslint .", "lint:fix": "cross-env NODE_OPTIONS=--no-deprecation eslint . --fix", + "migrate:spa-media": "cross-env NODE_OPTIONS=--no-deprecation tsx src/scripts/migrate-spa-media.ts", "payload": "cross-env NODE_OPTIONS=--no-deprecation payload", "reinstall": "cross-env NODE_OPTIONS=--no-deprecation rm -rf node_modules && rm pnpm-lock.yaml && pnpm --ignore-workspace install", "start": "cross-env NODE_OPTIONS=--no-deprecation next start", diff --git a/src/SpaMedia/config.ts b/src/SpaMedia/config.ts new file mode 100644 index 0000000..3878c35 --- /dev/null +++ b/src/SpaMedia/config.ts @@ -0,0 +1,44 @@ +import type { GlobalConfig } from 'payload' + +import { defaultSpaMediaSources } from '@/spa/cmsMedia' + +export const SpaMedia: GlobalConfig = { + slug: 'spa-media', + label: 'SPA Media', + access: { + read: () => true, + }, + admin: { + description: 'Central media map for legacy /spa images and videos. The migration script stores each original URL/path here and points it to a Payload media entry.', + }, + fields: [ + { + name: 'assets', + type: 'array', + labels: { + singular: 'Asset', + plural: 'Assets', + }, + defaultValue: defaultSpaMediaSources.map((source) => ({ source })), + fields: [ + { + name: 'source', + type: 'text', + required: true, + admin: { + description: 'Original /spa image/video path or external URL, e.g. /images/gala-dinner.jpg.', + }, + }, + { + name: 'media', + type: 'upload', + relationTo: 'media', + required: true, + }, + ], + admin: { + initCollapsed: true, + }, + }, + ], +} diff --git a/src/app/(frontend)/[[...slug]]/page.tsx b/src/app/(frontend)/[[...slug]]/page.tsx index eabc48a..638baba 100644 --- a/src/app/(frontend)/[[...slug]]/page.tsx +++ b/src/app/(frontend)/[[...slug]]/page.tsx @@ -3,6 +3,7 @@ import { getPayload } from 'payload' import NextApp from '@/spa/NextApp' import { normalizeFooterData, normalizeHeaderData } from '@/spa/cmsNavigation' +import { normalizeSpaMediaMap } from '@/spa/cmsMedia' export const dynamic = 'force-dynamic' @@ -14,10 +15,18 @@ export default async function Page({ params }: Args) { const { slug = [] } = await params const pathname = `/${slug.join('/')}`.replace(/\/$/, '') || '/' const payload = await getPayload({ config: configPromise }) - const [header, footer] = await Promise.all([ + const [header, footer, spaMedia] = await Promise.all([ payload.findGlobal({ slug: 'header', depth: 1 }).catch(() => undefined), payload.findGlobal({ slug: 'footer', depth: 1 }).catch(() => undefined), + payload.findGlobal({ slug: 'spa-media', depth: 1 }).catch(() => undefined), ]) - return + return ( + + ) } diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index c5ecaeb..2e7d273 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -30,51 +30,33 @@ import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } f /** @type import('payload').ImportMap */ export const importMap = { - '@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell': - RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e, - '@payloadcms/richtext-lexical/rsc#RscEntryLexicalField': - RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e, - '@payloadcms/richtext-lexical/rsc#LexicalDiffComponent': - LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e, - '@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient': - InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - '@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient': - FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - '@payloadcms/richtext-lexical/client#HeadingFeatureClient': - HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - '@payloadcms/richtext-lexical/client#ParagraphFeatureClient': - ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - '@payloadcms/richtext-lexical/client#UnderlineFeatureClient': - UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - '@payloadcms/richtext-lexical/client#BoldFeatureClient': - BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - '@payloadcms/richtext-lexical/client#ItalicFeatureClient': - ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - '@payloadcms/richtext-lexical/client#LinkFeatureClient': - LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - '@payloadcms/plugin-seo/client#OverviewComponent': - OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860, - '@payloadcms/plugin-seo/client#MetaTitleComponent': - MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860, - '@payloadcms/plugin-seo/client#MetaImageComponent': - MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860, - '@payloadcms/plugin-seo/client#MetaDescriptionComponent': - MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860, - '@payloadcms/plugin-seo/client#PreviewComponent': - PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860, - '@payloadcms/next/client#SlugField': SlugField_2b8867833a34864a02ddf429b0728a40, - '@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient': - HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - '@payloadcms/richtext-lexical/client#BlocksFeatureClient': - BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - '@payloadcms/next/rsc#FolderTableCell': FolderTableCell_f9c02e79a4aed9a3924487c0cd4cafb1, - '@payloadcms/next/rsc#FolderField': FolderField_f9c02e79a4aed9a3924487c0cd4cafb1, - '@payloadcms/plugin-search/client#LinkToDoc': LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634, - '@payloadcms/plugin-search/client#ReindexButton': ReindexButton_aead06e4cbf6b2620c5c51c9ab283634, - '@payloadcms/next/client#FolderTypeField': FolderTypeField_2b8867833a34864a02ddf429b0728a40, - '@/Header/RowLabel#RowLabel': RowLabel_ec255a65fa6fa8d1faeb09cf35284224, - '@/Footer/RowLabel#RowLabel': RowLabel_1f6ff6ff633e3695d348f4f3c58f1466, - '@/components/BeforeDashboard#default': default_1a7510af427896d367a49dbf838d2de6, - '@/components/BeforeLogin#default': default_8a7ab0eb7ab5c511aba12e68480bfe5e, - '@payloadcms/next/rsc#CollectionCards': CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1, + "@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e, + "@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e, + "@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e, + "@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient": FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/plugin-seo/client#OverviewComponent": OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860, + "@payloadcms/plugin-seo/client#MetaTitleComponent": MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860, + "@payloadcms/plugin-seo/client#MetaImageComponent": MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860, + "@payloadcms/plugin-seo/client#MetaDescriptionComponent": MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860, + "@payloadcms/plugin-seo/client#PreviewComponent": PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860, + "@payloadcms/next/client#SlugField": SlugField_2b8867833a34864a02ddf429b0728a40, + "@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#BlocksFeatureClient": BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/next/rsc#FolderTableCell": FolderTableCell_f9c02e79a4aed9a3924487c0cd4cafb1, + "@payloadcms/next/rsc#FolderField": FolderField_f9c02e79a4aed9a3924487c0cd4cafb1, + "@payloadcms/plugin-search/client#LinkToDoc": LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634, + "@payloadcms/plugin-search/client#ReindexButton": ReindexButton_aead06e4cbf6b2620c5c51c9ab283634, + "@payloadcms/next/client#FolderTypeField": FolderTypeField_2b8867833a34864a02ddf429b0728a40, + "@/Header/RowLabel#RowLabel": RowLabel_ec255a65fa6fa8d1faeb09cf35284224, + "@/Footer/RowLabel#RowLabel": RowLabel_1f6ff6ff633e3695d348f4f3c58f1466, + "@/components/BeforeDashboard#default": default_1a7510af427896d367a49dbf838d2de6, + "@/components/BeforeLogin#default": default_8a7ab0eb7ab5c511aba12e68480bfe5e, + "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } diff --git a/src/payload-types.ts b/src/payload-types.ts index 6c3fca0..d6ca460 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -112,10 +112,12 @@ export interface Config { globals: { header: Header; footer: Footer; + 'spa-media': SpaMedia; }; globalsSelect: { header: HeaderSelect | HeaderSelect; footer: FooterSelect | FooterSelect; + 'spa-media': SpaMediaSelect | SpaMediaSelect; }; locale: null; widgets: { @@ -1756,6 +1758,27 @@ export interface Footer { updatedAt?: string | null; createdAt?: string | null; } +/** + * Central media map for legacy /spa images and videos. The migration script stores each original URL/path here and points it to a Payload media entry. + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "spa-media". + */ +export interface SpaMedia { + id: number; + assets?: + | { + /** + * Original /spa image/video path or external URL, e.g. /images/gala-dinner.jpg. + */ + source: string; + media: number | Media; + id?: string | null; + }[] + | null; + updatedAt?: string | null; + createdAt?: string | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "header_select". @@ -1874,6 +1897,22 @@ export interface FooterSelect { createdAt?: T; globalType?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "spa-media_select". + */ +export interface SpaMediaSelect { + assets?: + | T + | { + source?: T; + media?: T; + id?: T; + }; + updatedAt?: T; + createdAt?: T; + globalType?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "collections_widget". diff --git a/src/payload.config.ts b/src/payload.config.ts index 66fddd9..23e76f7 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -11,6 +11,7 @@ import { Posts } from './collections/Posts' import { Users } from './collections/Users' import { Footer } from './Footer/config' import { Header } from './Header/config' +import { SpaMedia } from './SpaMedia/config' import { plugins } from './plugins' import { defaultLexical } from '@/fields/defaultLexical' import { getServerSideURL } from './utilities/getURL' @@ -64,7 +65,7 @@ export default buildConfig({ }), collections: [Pages, Posts, Media, Categories, Users], cors: [getServerSideURL()].filter(Boolean), - globals: [Header, Footer], + globals: [Header, Footer, SpaMedia], plugins, secret: process.env.PAYLOAD_SECRET, sharp, diff --git a/src/scripts/migrate-spa-media.ts b/src/scripts/migrate-spa-media.ts new file mode 100644 index 0000000..9b167e3 --- /dev/null +++ b/src/scripts/migrate-spa-media.ts @@ -0,0 +1,208 @@ +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 } 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', +]) + +const mimeByExt: Record = { + '.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', +} + +async function walk(dir: string): Promise { + 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() + const quotedUrl = /["'`]((?:https?:\/\/images\.unsplash\.com\/[^"'`\s)]+)|(?:\/(?:images\/)?[^"'`\s)]+\.(?:avif|gif|jpe?g|mov|mp4|png|svg|webm|webp)(?:\?[^"'`\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() + 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 { + 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: any, 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])) + + await payload.updateGlobal({ + slug: 'spa-media' as any, + data: { assets: [...bySource.values()] } as any, + context: { disableRevalidate: true }, + }) + + 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 any, + 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 any, + context: { disableRevalidate: true }, + }) + : Promise.resolve(), + ]) + + payload.logger.info('SPA media migration complete.') + process.exit(0) +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/src/spa/NextApp.tsx b/src/spa/NextApp.tsx index 6f42762..1245aa5 100644 --- a/src/spa/NextApp.tsx +++ b/src/spa/NextApp.tsx @@ -2,9 +2,11 @@ import React, { useEffect, useState } from 'react' +import CmsMediaRewriter from '@/spa/components/CmsMediaRewriter' import Layout from '@/spa/components/layout/Layout' import ScrollToTop from '@/spa/components/layout/ScrollToTop' import type { SpaFooterData, SpaHeaderData } from '@/spa/cmsNavigation' +import type { SpaMediaMap } from '@/spa/cmsMedia' import About from '@/spa/pages/About' import BlogDetail from '@/spa/pages/BlogDetail' import Contact from '@/spa/pages/Contact' @@ -46,10 +48,12 @@ export default function NextApp({ pathname, header, footer, + media, }: { pathname: string header?: SpaHeaderData footer?: SpaFooterData + media?: SpaMediaMap }) { const [loading, setLoading] = useState(true) @@ -78,6 +82,7 @@ export default function NextApp({ return ( <> + diff --git a/src/spa/cmsMedia.ts b/src/spa/cmsMedia.ts new file mode 100644 index 0000000..7e1c24a --- /dev/null +++ b/src/spa/cmsMedia.ts @@ -0,0 +1,48 @@ +import type { Media } from '@/payload-types' + +export type SpaMediaEntry = { + source: string + media?: { url?: string } | string | number | Media +} + +export type SpaMediaMap = Record + +export const defaultSpaMediaSources = [ + '/bmp-logo.png', + '/logo.png', + '/gala-preistraeger.jpg', + '/munich-skyline.jpg', + '/bavarian-flag.jpg', + '/rauten.webp', + '/website-template-OG.webp', + '/images/bmp-banner.jpg', + '/images/buehne-gewinner.jpg', + '/images/buehne-moderatoren.jpg', + '/images/ewif-logo.png', + '/images/gala-dinner.jpg', + '/images/gala-saal-overview.jpg', + '/images/gewinner-gruppenfoto.jpg', + '/images/mitglied-hero.jpg', + '/images/netzwerk-hero.jpg', + '/images/networking-innenhof.jpg', + '/images/partner-logos.png', + '/images/preisuebergabe.jpg', + '/images/preistraeger-award.jpg', + '/images/preistraeger-gruppe.jpg', + '/images/preistraeger-jubel.jpg', + '/images/saal-gedeckt.jpg', + '/images/saal-leer-atmosphaere.jpg', + '/images/tisch-detail.jpg', +] + +const isMedia = (value: unknown): value is Media => Boolean(value && typeof value === 'object' && 'url' in value) + +export function normalizeSpaMediaMap(global: any): SpaMediaMap { + const entries: SpaMediaEntry[] = Array.isArray(global?.assets) ? global.assets : [] + + return entries.reduce((acc: SpaMediaMap, entry: SpaMediaEntry) => { + if (!entry?.source) return acc + if (isMedia(entry.media) && entry.media.url) acc[entry.source] = entry.media.url + return acc + }, {}) +} diff --git a/src/spa/components/CmsMediaRewriter.tsx b/src/spa/components/CmsMediaRewriter.tsx new file mode 100644 index 0000000..4b22ee6 --- /dev/null +++ b/src/spa/components/CmsMediaRewriter.tsx @@ -0,0 +1,79 @@ +'use client' + +import { useEffect } from 'react' + +import type { SpaMediaMap } from '@/spa/cmsMedia' + +const CSS_URL_RE = /url\((['"]?)(.*?)\1\)/g + +function canonical(value: string) { + if (!value) return value + try { + const url = new URL(value, window.location.origin) + return url.origin === window.location.origin ? `${url.pathname}${url.search}` : value + } catch { + return value + } +} + +function rewriteCssUrls(value: string, media: SpaMediaMap) { + return value.replace(CSS_URL_RE, (_match, quote: string, url: string) => { + const replacement = media[url] || media[canonical(url)] + return replacement ? `url(${quote}${replacement}${quote})` : _match + }) +} + +function rewrite(root: ParentNode, media: SpaMediaMap) { + root.querySelectorAll('img, source, video').forEach((node) => { + const el = node as HTMLImageElement | HTMLSourceElement | HTMLVideoElement + const current = el.getAttribute('src') + if (current) { + const replacement = media[current] || media[canonical(current)] + if (replacement && current !== replacement) el.setAttribute('src', replacement) + } + + const poster = el.getAttribute('poster') + if (poster) { + const replacement = media[poster] || media[canonical(poster)] + if (replacement && poster !== replacement) el.setAttribute('poster', replacement) + } + }) + + root.querySelectorAll('[style*="url("]').forEach((el) => { + const backgroundImage = el.style.backgroundImage + if (backgroundImage) { + const rewritten = rewriteCssUrls(backgroundImage, media) + if (rewritten !== backgroundImage) el.style.backgroundImage = rewritten + } + }) +} + +export default function CmsMediaRewriter({ media }: { media?: SpaMediaMap }) { + useEffect(() => { + if (!media || Object.keys(media).length === 0) return + + rewrite(document, media) + + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + mutation.addedNodes.forEach((node) => { + if (node.nodeType === Node.ELEMENT_NODE) rewrite(node as Element, media) + }) + if (mutation.type === 'attributes' && mutation.target.nodeType === Node.ELEMENT_NODE) { + rewrite((mutation.target as Element).parentNode || document, media) + } + } + }) + + observer.observe(document.body, { + attributes: true, + attributeFilter: ['src', 'poster', 'style'], + childList: true, + subtree: true, + }) + + return () => observer.disconnect() + }, [media]) + + return null +}