chore: add media migration scripts, migrate media.

This commit is contained in:
syntaxbullet
2026-06-16 16:54:02 +02:00
parent cde2164773
commit 647967482b
10 changed files with 466 additions and 50 deletions

View File

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

44
src/SpaMedia/config.ts Normal file
View File

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

View File

@@ -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 <NextApp pathname={pathname} header={normalizeHeaderData(header)} footer={normalizeFooterData(footer)} />
return (
<NextApp
pathname={pathname}
header={normalizeHeaderData(header)}
footer={normalizeFooterData(footer)}
media={normalizeSpaMediaMap(spaMedia)}
/>
)
}

View File

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

View File

@@ -112,10 +112,12 @@ export interface Config {
globals: {
header: Header;
footer: Footer;
'spa-media': SpaMedia;
};
globalsSelect: {
header: HeaderSelect<false> | HeaderSelect<true>;
footer: FooterSelect<false> | FooterSelect<true>;
'spa-media': SpaMediaSelect<false> | SpaMediaSelect<true>;
};
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<T extends boolean = true> {
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "spa-media_select".
*/
export interface SpaMediaSelect<T extends boolean = true> {
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".

View File

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

View File

@@ -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<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',
}
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)(?:\?[^"'`\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: 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)
})

View File

@@ -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 (
<>
<CmsMediaRewriter media={media} />
<ScrollToTop />
<Layout header={header} footer={footer}>
<Page />

48
src/spa/cmsMedia.ts Normal file
View File

@@ -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<string, string>
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
}, {})
}

View File

@@ -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<HTMLElement>('[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
}