From 7a72cc8891e7392089ae7f6d26afbdb0e25fc8cc Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 11 Oct 2026 00:47:10 +0200 Subject: [PATCH 1/2] feat(seo): use branded og images per section Section pages share one fixed 1200x630 card per locale, rendered from the ejected NuxtSeo.satori template by scripts/render-og-images.mjs and uploaded to images/og/
-.png. Home, blog, projects, community, community-poi, events, team, about and bluemap pass their card to usePageSeo; project detail pages without a logo fall back to the projects card. Claude-Session: https://claude.ai/code/session_019zws4ceJu2jctk3XT48fMX --- components/OgImage/NuxtSeo.satori.vue | 105 +++++++------- layers/content-core/utils/sectionOgImage.ts | 23 +++ pages/about.vue | 1 + pages/blog/index.vue | 1 + pages/bluemap.vue | 1 + pages/community-poi/index.vue | 1 + pages/community.vue | 1 + pages/events/index.vue | 1 + pages/index.vue | 2 +- pages/projects/[...slug].vue | 2 +- pages/projects/index.vue | 1 + pages/team/index.vue | 1 + scripts/render-og-images.mjs | 146 ++++++++++++++++++++ tests/seo/og-image-template.spec.ts | 56 ++++++++ tests/seo/section-og-images.spec.ts | 89 ++++++++++++ 15 files changed, 376 insertions(+), 55 deletions(-) create mode 100644 layers/content-core/utils/sectionOgImage.ts create mode 100644 scripts/render-og-images.mjs create mode 100644 tests/seo/og-image-template.spec.ts create mode 100644 tests/seo/section-og-images.spec.ts diff --git a/components/OgImage/NuxtSeo.satori.vue b/components/OgImage/NuxtSeo.satori.vue index 59549590..85c8af08 100644 --- a/components/OgImage/NuxtSeo.satori.vue +++ b/components/OgImage/NuxtSeo.satori.vue @@ -1,65 +1,64 @@ - diff --git a/layers/content-core/utils/sectionOgImage.ts b/layers/content-core/utils/sectionOgImage.ts new file mode 100644 index 00000000..83a169c9 --- /dev/null +++ b/layers/content-core/utils/sectionOgImage.ts @@ -0,0 +1,23 @@ +/** + * The fixed social card of each section page, one per locale. The PNGs are + * rendered by scripts/render-og-images.mjs and uploaded to the image bucket + * under images/og/, so the path resolves through the image provider like any + * other page image. Keep the list in step with the script's SECTIONS. + */ +export const OG_SECTIONS = [ + 'home', + 'blog', + 'projects', + 'community', + 'community-poi', + 'events', + 'team', + 'about', + 'bluemap' +] as const + +export type OgSection = typeof OG_SECTIONS[number] + +export function sectionOgImage(section: OgSection, locale: string): string { + return `/images/og/${section}-${locale}.png` +} diff --git a/pages/about.vue b/pages/about.vue index 6e8a1a3b..56d0a5b8 100644 --- a/pages/about.vue +++ b/pages/about.vue @@ -15,6 +15,7 @@ const { numbers } = useCommunityOverview() usePageSeo({ title: t('about.title'), description: t('about.description'), + image: sectionOgImage('about', locale.value), schemaType: 'AboutPage', keywords: [ 'OneLiteFeather', diff --git a/pages/blog/index.vue b/pages/blog/index.vue index 8602975e..09e937a8 100644 --- a/pages/blog/index.vue +++ b/pages/blog/index.vue @@ -14,6 +14,7 @@ const { top1Article, allPosts, authorsOf } = useBlogOverview() usePageSeo({ title: t('blog.overview.title'), description: t('blog.overview.description'), + image: sectionOgImage('blog', locale.value), schemaType: 'CollectionPage', keywords: [ 'OneLiteFeather blog', diff --git a/pages/bluemap.vue b/pages/bluemap.vue index 86a09323..f1a92806 100644 --- a/pages/bluemap.vue +++ b/pages/bluemap.vue @@ -10,6 +10,7 @@ definePageMeta({ usePageSeo({ title: t('bluemap.title'), description: t('bluemap.description'), + image: sectionOgImage('bluemap', locale.value), schemaType: 'WebPage', keywords: [ 'OneLiteFeather BlueMap', diff --git a/pages/community-poi/index.vue b/pages/community-poi/index.vue index bc21e817..0696161a 100644 --- a/pages/community-poi/index.vue +++ b/pages/community-poi/index.vue @@ -13,6 +13,7 @@ const { pois, total } = useCommunityPoiOverview() usePageSeo({ title: t('community_poi.overview.title'), description: t('community_poi.overview.description'), + image: sectionOgImage('community-poi', locale.value), schemaType: 'CollectionPage', keywords: [ 'OneLiteFeather community', diff --git a/pages/community.vue b/pages/community.vue index e254c8e3..5cb6feca 100644 --- a/pages/community.vue +++ b/pages/community.vue @@ -12,6 +12,7 @@ const { overview, numbers } = useCommunityOverview() usePageSeo({ title: t('community.title'), description: t('community.description'), + image: sectionOgImage('community', locale.value), schemaType: 'CollectionPage', keywords: [ 'OneLiteFeather community', diff --git a/pages/events/index.vue b/pages/events/index.vue index dd3a408c..ddc360d0 100644 --- a/pages/events/index.vue +++ b/pages/events/index.vue @@ -17,6 +17,7 @@ const discordUrl = (runtimeConfig.public?.discordUrl as string | undefined) usePageSeo({ title: t('events.title'), description: t('events.description'), + image: sectionOgImage('events', locale.value), schemaType: 'CollectionPage' }) diff --git a/pages/index.vue b/pages/index.vue index 0ce082f7..9a220ff7 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -30,7 +30,7 @@ const carouselSlides = computed(() => { const { sponsors } = useSponsoring() const { data: collective } = useOpenCollective() const { items: faqItems } = useFaqContent() -useHomeSeo({ title: t('index.title') }) +useHomeSeo({ title: t('index.title'), image: sectionOgImage('home', locale.value) }) // The h1 in the template is sr-only: the carousel is the visual opening and leaves no room for a heading. diff --git a/pages/projects/[...slug].vue b/pages/projects/[...slug].vue index 97893245..048d19f1 100644 --- a/pages/projects/[...slug].vue +++ b/pages/projects/[...slug].vue @@ -21,7 +21,7 @@ const { pois } = await useCommunityPoisByProject(computed(() => project.value?.s usePageSeo({ title: title.value, description: description.value, - image: project.value?.logo, + image: project.value?.logo || sectionOgImage('projects', locale.value), imageAlt: project.value?.logoAlt, ogType: 'article', schemaType: 'ItemPage' diff --git a/pages/projects/index.vue b/pages/projects/index.vue index f1130e89..04244eba 100644 --- a/pages/projects/index.vue +++ b/pages/projects/index.vue @@ -13,6 +13,7 @@ const { projects } = await useProjectsOverview() usePageSeo({ title: t('projects.overview.title'), description: t('projects.overview.description'), + image: sectionOgImage('projects', locale.value), schemaType: 'CollectionPage', keywords: [ 'OneLiteFeather Projekte', diff --git a/pages/team/index.vue b/pages/team/index.vue index 0423f4b5..a1cc1745 100644 --- a/pages/team/index.vue +++ b/pages/team/index.vue @@ -14,6 +14,7 @@ const supporterLinks = useLiteSupporterLinks() usePageSeo({ title: t('team.index.title'), description: t('team.index.description'), + image: sectionOgImage('team', locale.value), schemaType: 'CollectionPage', keywords: [ 'OneLiteFeather team', diff --git a/scripts/render-og-images.mjs b/scripts/render-og-images.mjs new file mode 100644 index 00000000..b3cde858 --- /dev/null +++ b/scripts/render-og-images.mjs @@ -0,0 +1,146 @@ +// Renders the 18 section cards (9 sections x de/en) from the real NuxtSeo.satori.vue +// template, without prerendering, and prints the rclone commands that upload them. +// pnpm build && node scripts/render-og-images.mjs [--out ] [--fonts ] + +import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { parseArgs } from 'node:util' +import * as cheerio from 'cheerio' +import satori from 'satori' +import { Resvg, initWasm } from '@resvg/resvg-wasm' +import { compileScript, parse } from 'vue/compiler-sfc' +import { createSSRApp } from 'vue' +import { renderToString } from 'vue/server-renderer' +import ts from 'typescript' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const TEMPLATE = join(ROOT, 'components/OgImage/NuxtSeo.satori.vue') +const WIDTH = 1200 +const HEIGHT = 630 +const LOCALES = ['de', 'en'] + +/** Section -> the i18n keys its page uses for the title and description today. */ +export const SECTIONS = [ + { section: 'home', title: 'index.title', description: 'seo.default_description' }, + { section: 'blog', title: 'blog.overview.title', description: 'blog.overview.description' }, + { section: 'projects', title: 'projects.overview.title', description: 'projects.overview.description' }, + { section: 'community', title: 'community.title', description: 'community.description' }, + { section: 'community-poi', title: 'community_poi.overview.title', description: 'community_poi.overview.description' }, + { section: 'events', title: 'events.title', description: 'events.description' }, + { section: 'team', title: 'team.index.title', description: 'team.index.description' }, + { section: 'about', title: 'about.title', description: 'about.description' }, + { section: 'bluemap', title: 'bluemap.title', description: 'bluemap.description' } +] + +/** Bucket key (and public path suffix) of one card. */ +export const uploadKey = (section, locale) => `images/og/${section}-${locale}.png` + +const lookup = (json, key) => key.split('.').reduce((node, part) => node?.[part], json) + +/** Compiles the real SFC with Vue's compiler; `?raw` imports are inlined as strings. */ +async function loadTemplate() { + const { descriptor, errors } = parse(readFileSync(TEMPLATE, 'utf8'), { filename: TEMPLATE }) + if (errors.length) throw new Error(`template does not parse: ${errors[0].message}`) + const script = compileScript(descriptor, { id: 'og-section-card', inlineTemplate: true, isProd: true }) + let code = ts.transpileModule(script.content, { + fileName: 'NuxtSeo.satori.ts', + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } + }).outputText + code = code.replace(/import (\w+) from ['"]([^'"]+)\?raw['"];?/g, (_, name, file) => `const ${name} = ${JSON.stringify(readFileSync(resolve(dirname(TEMPLATE), file), 'utf8'))};`) + // Next to the repo, so the bare `vue` import resolves to the repo's copy. + const dir = join(ROOT, 'node_modules/.cache/og-section-cards') + mkdirSync(dir, { recursive: true }) + const file = join(dir, 'NuxtSeo.satori.mjs') + writeFileSync(file, code) + return (await import(pathToFileURL(file).href)).default +} + +/** Inline style string from Vue's SSR output -> Satori style object. */ +function parseStyle(text) { + const style = {} + for (const declaration of text.split(';')) { + const at = declaration.indexOf(':') + if (at < 0) continue + const key = declaration.slice(0, at).trim().replace(/-([a-z])/g, (_, c) => c.toUpperCase()) + const value = declaration.slice(at + 1).trim() + style[key] = /^-?\d+(\.\d+)?$/.test(value) ? Number(value) : value + } + return style +} + +/** Rendered HTML -> the element tree Satori takes. Comments and blank text are dropped. */ +function toSatoriTree(html) { + const $ = cheerio.load(html, null, false) + const convert = (node) => { + if (node.type === 'text') { + const text = node.data.replace(/\s+/g, ' ').trim() + return text ? text : null + } + if (node.type !== 'tag') return null + const props = {} + for (const [name, value] of Object.entries(node.attribs)) { + if (name === 'style') props.style = parseStyle(value) + else props[name] = /^\d+$/.test(value) ? Number(value) : value + } + const children = node.children.map(convert).filter(child => child !== null) + if (children.length) props.children = children.length === 1 ? children[0] : children + return { type: node.name, props } + } + return convert($.root().contents().toArray().find(node => node.type === 'tag')) +} + +async function renderCard(component, props, fonts) { + const html = await renderToString(createSSRApp(component, props)) + const svg = await satori(toSatoriTree(html), { width: WIDTH, height: HEIGHT, fonts }) + return new Resvg(svg, { fitTo: { mode: 'width', value: WIDTH } }).render().asPng() +} + +function loadFonts(fontDir) { + const files = [[400, 'inter-400-latin.ttf'], [700, 'inter-700-latin.ttf']] + return files.map(([weight, file]) => { + const path = join(fontDir, file) + if (!existsSync(path)) throw new Error(`missing ${path}: run pnpm build once, or pass --fonts`) + return { name: 'Inter', data: readFileSync(path), weight, style: 'normal' } + }) +} + +export async function renderAll({ outDir, fontDir }) { + await initWasm(WebAssembly.compile(readFileSync(join(ROOT, 'node_modules/@resvg/resvg-wasm/index_bg.wasm')))) + const component = await loadTemplate() + const fonts = loadFonts(fontDir) + mkdirSync(outDir, { recursive: true }) + const written = [] + for (const locale of LOCALES) { + const json = JSON.parse(readFileSync(join(ROOT, `i18n/locales/${locale}.json`), 'utf8')) + for (const entry of SECTIONS) { + const title = lookup(json, entry.title) + const description = lookup(json, entry.description) + if (typeof title !== 'string' || typeof description !== 'string') { + throw new Error(`${locale}: ${entry.title} or ${entry.description} is missing`) + } + const file = join(outDir, `${entry.section}-${locale}.png`) + writeFileSync(file, await renderCard(component, { title, description }, fonts)) + written.push({ file, key: uploadKey(entry.section, locale) }) + } + } + return written +} + +async function main() { + const { values } = parseArgs({ + options: { + out: { type: 'string', default: join(ROOT, 'node_modules/.cache/og-section-cards/png') }, + fonts: { type: 'string', default: join(ROOT, '.output/public/_og-static-fonts') } + } + }) + const cards = await renderAll({ outDir: resolve(values.out), fontDir: resolve(values.fonts) }) + console.log('Upload once every card exists, before anything requests the URLs:') + for (const { file, key } of cards) { + console.log(`rclone copyto --s3-no-check-bucket --immutable ${file} img-onelitefeather-net:img-onelitefeather-net/${key}`) + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main() +} diff --git a/tests/seo/og-image-template.spec.ts b/tests/seo/og-image-template.spec.ts new file mode 100644 index 00000000..7e9bf544 --- /dev/null +++ b/tests/seo/og-image-template.spec.ts @@ -0,0 +1,56 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { repoRoot } from '../helpers/sources' +import { schemeColors } from '../helpers/theme' + +/** + * The ejected `NuxtSeo.satori` template is the card every page without its + * own image is shared with. It must carry OneLiteFeather branding, not the + * nuxt-og-image demo, and it renders inside an isolated Satori island, so it + * may not reach for i18n, routing or cookies (see the nuxt-seo skill, + * references/og-images.md). + */ + +const TEMPLATE = 'components/OgImage/NuxtSeo.satori.vue' +const source = readFileSync(join(repoRoot, TEMPLATE), 'utf8') + +/** Every `#rrggbb` literal in the template's inline styles, lowercase. */ +const hexLiterals = (text: string): string[] => [ + ...new Set([...text.matchAll(/#[0-9a-fA-F]{6}\b/g)].map(([hex]) => hex.toLowerCase())) +] + +describe('NuxtSeo.satori template branding', () => { + it('carries no Nuxt SEO wording', () => { + expect(source, 'the module demo name must be gone from the markup').not.toMatch(/nuxt\s*seo/i) + expect(source, 'the module demo name must be gone from the markup').not.toMatch(/nuxt-seo/i) + }) + + it('names the site as OneLiteFeather.net', () => { + expect(source).toContain('OneLiteFeather.net') + }) + + it('draws the repository logo rather than a placeholder mark', () => { + expect(source, 'the logo is imported from public/images/logo.svg as raw text').toMatch(/public\/images\/logo\.svg\?raw/) + }) + + it('takes its title and description from props only', () => { + expect(source).toMatch(/defineProps<\{\s*title: string;\s*description\?: string/) + expect(source, 'no i18n, route, cookie or composable access inside the island') + .not.toMatch(/useI18n|\$t\(|useRoute|useCookie|useRuntimeConfig|useSiteConfig/) + }) + + it('uses only literal colours that exist in the MD3 scheme', () => { + // Satori cannot resolve CSS variables, so the palette is copied as + // literals. Each literal must be a dark-scheme role from tailwind.css, + // so the card cannot drift from the site without a failing test. + const darkRoles = new Set([...schemeColors().values()].map(role => role.dark)) + const used = hexLiterals(source) + expect(used.length).toBeGreaterThan(0) + expect(used.filter(hex => !darkRoles.has(hex))).toEqual([]) + }) + + it('does not use the module demo colour switch for pro cards', () => { + expect(source).not.toMatch(/isPro|colorMode/) + }) +}) diff --git a/tests/seo/section-og-images.spec.ts b/tests/seo/section-og-images.spec.ts new file mode 100644 index 00000000..88d1e08c --- /dev/null +++ b/tests/seo/section-og-images.spec.ts @@ -0,0 +1,89 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { OG_SECTIONS, sectionOgImage } from '../../layers/content-core/utils/sectionOgImage' +import { SECTIONS, uploadKey } from '../../scripts/render-og-images.mjs' +import { repoRoot } from '../helpers/sources' + +/** + * Section pages share one fixed card per section and locale, rendered by + * scripts/render-og-images.mjs and uploaded to images/og/
-.png. + * A page that loses its `image:` argument silently falls back to + * og-default.png, which is the regression these tests guard against. + */ + +const LOCALES = ['de', 'en'] as const + +/** Section page -> the usePageSeo/useHomeSeo call that must pass its card. */ +const SECTION_PAGES: Array<[section: string, file: string]> = [ + ['home', 'pages/index.vue'], + ['blog', 'pages/blog/index.vue'], + ['projects', 'pages/projects/index.vue'], + ['community', 'pages/community.vue'], + ['community-poi', 'pages/community-poi/index.vue'], + ['events', 'pages/events/index.vue'], + ['team', 'pages/team/index.vue'], + ['about', 'pages/about.vue'], + ['bluemap', 'pages/bluemap.vue'] +] + +const read = (file: string) => readFileSync(join(repoRoot, file), 'utf8') +const i18n = (locale: string) => JSON.parse(read(`i18n/locales/${locale}.json`)) as Record +const lookup = (json: Record, key: string): unknown => key.split('.').reduce((node, part) => (node as Record | undefined)?.[part], json) + +describe('section og image paths', () => { + it('gives every section a card for each locale at the bucket path', () => { + for (const section of OG_SECTIONS) { + for (const locale of LOCALES) { + expect(sectionOgImage(section, locale)).toBe(`/images/og/${section}-${locale}.png`) + } + } + }) + + it('renders exactly the sections the composable can reference', () => { + expect(SECTIONS.map(entry => entry.section)).toEqual([...OG_SECTIONS]) + }) + + it('uploads one card per section and locale under the same key', () => { + const keys = SECTIONS.flatMap(entry => LOCALES.map(locale => uploadKey(entry.section, locale))) + expect(keys).toHaveLength(OG_SECTIONS.length * LOCALES.length) + expect(new Set(keys).size).toBe(keys.length) + expect(keys).toContain('images/og/community-poi-de.png') + }) +}) + +describe('section card text', () => { + it('reads every title and description from both locale files', () => { + for (const locale of LOCALES) { + const json = i18n(locale) + for (const entry of SECTIONS) { + for (const key of [entry.title, entry.description]) { + expect(typeof lookup(json, key), `${locale}: ${key}`).toBe('string') + expect(String(lookup(json, key)).length, `${locale}: ${key}`).toBeGreaterThan(0) + } + } + } + }) +}) + +describe('section pages pass their card', () => { + it.each(SECTION_PAGES)('%s (%s) sets its section card for the current locale', (section, file) => { + expect(read(file)).toContain(`image: sectionOgImage('${section}', locale.value)`) + }) + + it('finds every section page it checks', () => { + // Guards against a renamed file making the loop above check nothing. + expect(SECTION_PAGES).toHaveLength(OG_SECTIONS.length) + }) + + it('falls project detail pages without a logo back to the projects card', () => { + expect(read('pages/projects/[...slug].vue')).toContain('image: project.value?.logo || sectionOgImage(\'projects\', locale.value)') + }) + + it('keeps the own images of content pages', () => { + expect(read('pages/events/[...slug].vue')).toContain('image: event.value?.thumbnail') + expect(read('pages/community-poi/[...slug].vue')).toContain('image: poi.value?.thumbnail') + expect(read('pages/team/[slug].vue')).toContain('image: avatarSrc.value') + expect(read('layers/blog/composables/useArticleSeo.ts')).toContain('blog.value?.headerImage') + }) +}) From 693a3fa1ad2c56d28563eafecdbc603044609652 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 11 Oct 2026 00:52:50 +0200 Subject: [PATCH 2/2] ci(seo): fetch og images only when they are served from public Section og images live behind the image proxy, so the local check now decides by whether the file exists in public/ instead of a fixed list of routes. Claude-Session: https://claude.ai/code/session_019zws4ceJu2jctk3XT48fMX --- scripts/seo-check.mjs | 34 ++++++++++++---------------------- 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/scripts/seo-check.mjs b/scripts/seo-check.mjs index e2c2a30a..08c75c3e 100644 --- a/scripts/seo-check.mjs +++ b/scripts/seo-check.mjs @@ -21,6 +21,7 @@ */ import { load } from 'cheerio' +import { existsSync } from 'node:fs' import process from 'node:process' const args = process.argv.slice(2) @@ -53,17 +54,6 @@ const NOINDEX_ROUTES = [ /** The canonical origin every absolute SEO URL is written against. */ const SITE_ORIGIN = 'https://onelitefeather.net' -/** - * Routes whose og:image is the site default served from public/, so the image - * itself can be fetched from any build (see checkSocialImage). - */ -const FETCH_IMAGE_ROUTES = new Set([ - '/en', - '/de', - '/en/blog', - '/de/blog' -]) - const TITLE_MIN = 10 const TITLE_MAX = 70 const DESCRIPTION_MIN = 50 @@ -264,7 +254,7 @@ const checkPage = async ({ route, mustIndex }) => { const ids = nodes.map((n) => n['@id']).filter(Boolean) for (const id of new Set(ids.filter((id, i) => ids.indexOf(id) !== i))) err(route, `JSON-LD @id appears twice: ${id}`) - await checkSocialImage(route, $, { fetchImage: FETCH_IMAGE_ROUTES.has(route) }) + await checkSocialImage(route, $) } /** Width and height of a PNG, read from its IHDR chunk. */ @@ -280,12 +270,11 @@ const pngSize = (bytes) => { /** * og:image must be absolute, and a large card needs a large image. The image - * itself is fetched only for routes that use the site default from public/: - * article and build images live behind the production image proxy and are - * absent from a local build, so fetching them here would test the proxy, not - * this repository. + * is fetched only when it is served from public/: proxied images are absent + * from a local build, so fetching them here would test the proxy, not this + * repository. */ -const checkSocialImage = async (route, $, { fetchImage }) => { +const checkSocialImage = async (route, $) => { const image = headContent($, 'meta[property="og:image"]') if (!image) return // missing tag is already reported above if (!/^https?:\/\//.test(image)) { @@ -297,12 +286,13 @@ const checkSocialImage = async (route, $, { fetchImage }) => { if (card === 'summary_large_image' && declaredWidth && declaredWidth < 1200) { err(route, `twitter:card summary_large_image with a ${declaredWidth}px wide og:image`) } - if (!fetchImage) return - const imageUrl = new URL(image) - const servedLocally = IS_LOCAL && imageUrl.origin === SITE_ORIGIN - const target = servedLocally ? new URL(imageUrl.pathname, BASE) : imageUrl - const res = await fetch(target) + const servedLocally = IS_LOCAL + && imageUrl.origin === SITE_ORIGIN + && existsSync(new URL(`../public${imageUrl.pathname}`, import.meta.url)) + if (!servedLocally) return + + const res = await fetch(new URL(imageUrl.pathname, BASE)) if (res.status !== 200) { err(route, `og:image ${image} answered ${res.status}`) return