From bd8f260567685fed9e8cfdd5e91163f524e504fe Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:41:59 -0500 Subject: [PATCH] Add og:image:type meta tag to social cards Emit og:image:type so crawlers can validate/decode the OG image without content sniffing. The MIME type is inferred from the resolved og:image URL extension (defaulting to image/png), covering the dynamic /og/.png cards, the /og-image.png fallback, and any non-PNG ogImage frontmatter override. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/frontend/config/head.attrs.ts | 6 +-- .../src/components/starlight/Head.astro | 5 ++- src/frontend/src/utils/page-metadata.ts | 34 ++++++++++++++ src/frontend/tests/e2e/og-metadata.spec.ts | 2 + .../tests/unit/page-metadata.vitest.test.ts | 44 +++++++++++++++++++ 5 files changed, 86 insertions(+), 5 deletions(-) diff --git a/src/frontend/config/head.attrs.ts b/src/frontend/config/head.attrs.ts index ba3d93895..960bc0b17 100644 --- a/src/frontend/config/head.attrs.ts +++ b/src/frontend/config/head.attrs.ts @@ -35,9 +35,9 @@ export const headAttrs: HeadAttr[] = [ // Open Graph / Twitter card meta tags — only truly-global tags live here. // Per-page `og:title`, `og:description`, `og:url`, `og:image`, - // `og:image:alt`, `og:image:width`, `og:image:height`, `og:type`, - // `twitter:title`, `twitter:description`, `twitter:url`, `twitter:image`, - // and `twitter:image:alt` are emitted dynamically by + // `og:image:type`, `og:image:alt`, `og:image:width`, `og:image:height`, + // `og:type`, `twitter:title`, `twitter:description`, `twitter:url`, + // `twitter:image`, and `twitter:image:alt` are emitted dynamically by // `src/components/starlight/Head.astro` based on each page's frontmatter // (see `src/utils/page-metadata.ts` for the resolution logic). { tag: 'meta', attrs: { property: 'og:site_name', content: 'Aspire' } }, diff --git a/src/frontend/src/components/starlight/Head.astro b/src/frontend/src/components/starlight/Head.astro index d837dc66f..2cf4ad087 100644 --- a/src/frontend/src/components/starlight/Head.astro +++ b/src/frontend/src/components/starlight/Head.astro @@ -48,10 +48,11 @@ function computeSourceUrl() { og:description, og:url, og:type, and og:locale from frontmatter — see node_modules/@astrojs/starlight/components/Head.astro. We only emit the tags Starlight doesn't already cover: a per-page og:image (rendered by - the /og/ endpoint), its dimensions/alt, and the Twitter card - equivalents (Twitter scrapers prefer name="twitter:*" over og:*). + the /og/ endpoint), its MIME type/dimensions/alt, and the Twitter + card equivalents (Twitter scrapers prefer name="twitter:*" over og:*). */} + diff --git a/src/frontend/src/utils/page-metadata.ts b/src/frontend/src/utils/page-metadata.ts index f521b0237..7658efd5c 100644 --- a/src/frontend/src/utils/page-metadata.ts +++ b/src/frontend/src/utils/page-metadata.ts @@ -84,6 +84,8 @@ export interface OgMetadata { type: OgType; /** Resolved `og:image` URL. */ image: string; + /** `og:image:type` MIME type inferred from the image URL (e.g. `image/png`). */ + imageType: string; /** `og:image:alt` text. */ imageAlt: string; /** Image pixel dimensions (matches the dynamic template). */ @@ -252,6 +254,36 @@ export function resolveOgImage( return `${siteUrl}/og/${contentBasePath}.png`; } +/** + * Infer the `og:image:type` MIME type from an image URL's file extension. + * Every card the site emits today is a PNG (the dynamic `/og/.png` + * endpoint and the static `/og-image.png` fallback), but an explicit + * `ogImage` frontmatter override could point at another format, so we map + * the extension and fall back to `image/png` when it is unknown. + */ +export function resolveOgImageType(image: string): string { + const withoutQuery = image.split(/[?#]/, 1)[0]; + const match = /\.([a-z0-9]+)$/i.exec(withoutQuery); + const extension = match?.[1]?.toLowerCase(); + + switch (extension) { + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + case 'webp': + return 'image/webp'; + case 'gif': + return 'image/gif'; + case 'svg': + return 'image/svg+xml'; + case 'avif': + return 'image/avif'; + case 'png': + default: + return 'image/png'; + } +} + /** * Whether the dynamic OG image endpoint should skip a given entry. The * endpoint and the meta-tag emitter both consult this so the URL written into @@ -291,6 +323,7 @@ export function getOgMetadata( const description = resolveOgDescription(route); const type = resolveOgType(route, contentBasePath); const image = resolveOgImage(route, contentBasePath, siteUrl, isDefaultLocale); + const imageType = resolveOgImageType(image); const imageAlt = title; return { @@ -300,6 +333,7 @@ export function getOgMetadata( url, type, image, + imageType, imageAlt, imageWidth: DEFAULT_OG_IMAGE_WIDTH, imageHeight: DEFAULT_OG_IMAGE_HEIGHT, diff --git a/src/frontend/tests/e2e/og-metadata.spec.ts b/src/frontend/tests/e2e/og-metadata.spec.ts index c79f89c7d..067761d46 100644 --- a/src/frontend/tests/e2e/og-metadata.spec.ts +++ b/src/frontend/tests/e2e/og-metadata.spec.ts @@ -84,6 +84,7 @@ for (const page of PAGES) { ); expect(html).toMatch(metaTagPattern('property', 'og:image:width', '1200')); expect(html).toMatch(metaTagPattern('property', 'og:image:height', '630')); + expect(html).toMatch(metaTagPattern('property', 'og:image:type', 'image/png')); expect(html).toMatch(metaTagPattern('name', 'twitter:title', page.twitterTitle)); expect(html).toMatch( new RegExp( @@ -119,6 +120,7 @@ test('emits a stable, hash-free og:image for sample detail pages', async ({ requ ); expect(html).toMatch(metaTagPattern('property', 'og:image:width', '1200')); expect(html).toMatch(metaTagPattern('property', 'og:image:height', '630')); + expect(html).toMatch(metaTagPattern('property', 'og:image:type', 'image/png')); expect(html).toMatch( new RegExp(`]*name="twitter:image"[^>]*content="[^"]*${escape(ogImagePath)}"`, 'i') ); diff --git a/src/frontend/tests/unit/page-metadata.vitest.test.ts b/src/frontend/tests/unit/page-metadata.vitest.test.ts index 12a1483b1..a0758d0fe 100644 --- a/src/frontend/tests/unit/page-metadata.vitest.test.ts +++ b/src/frontend/tests/unit/page-metadata.vitest.test.ts @@ -10,6 +10,7 @@ import { resolveCanonicalUrl, resolveOgDescription, resolveOgImage, + resolveOgImageType, resolveOgTitle, resolveOgType, resolveSiteUrl, @@ -350,6 +351,35 @@ describe('resolveOgImage', () => { }); }); +describe('resolveOgImageType', () => { + it('defaults to image/png for the dynamic and fallback cards', () => { + expect(resolveOgImageType('https://aspire.dev/og/dashboard/enable-browser-telemetry.png')).toBe( + 'image/png' + ); + expect(resolveOgImageType('https://aspire.dev/og-image.png')).toBe('image/png'); + }); + + it('maps common raster extensions to their MIME types', () => { + expect(resolveOgImageType('https://aspire.dev/card.jpg')).toBe('image/jpeg'); + expect(resolveOgImageType('https://aspire.dev/card.jpeg')).toBe('image/jpeg'); + expect(resolveOgImageType('https://aspire.dev/card.webp')).toBe('image/webp'); + expect(resolveOgImageType('https://aspire.dev/card.gif')).toBe('image/gif'); + expect(resolveOgImageType('https://aspire.dev/card.avif')).toBe('image/avif'); + expect(resolveOgImageType('https://aspire.dev/card.svg')).toBe('image/svg+xml'); + }); + + it('is case-insensitive and ignores query strings and fragments', () => { + expect(resolveOgImageType('https://aspire.dev/card.PNG')).toBe('image/png'); + expect(resolveOgImageType('https://aspire.dev/card.JPG?v=2')).toBe('image/jpeg'); + expect(resolveOgImageType('https://aspire.dev/card.webp#hero')).toBe('image/webp'); + }); + + it('falls back to image/png for unknown or missing extensions', () => { + expect(resolveOgImageType('https://aspire.dev/card.bin')).toBe('image/png'); + expect(resolveOgImageType('https://aspire.dev/og/no-extension')).toBe('image/png'); + }); +}); + describe('shouldSkipDynamicOgImage', () => { it('skips splash pages', () => { const route = createRoute({ template: 'splash' }); @@ -456,6 +486,7 @@ describe('getOgMetadata', () => { expect(meta.url).toBe('https://aspire.dev/dashboard/enable-browser-telemetry/'); expect(meta.type).toBe('article'); expect(meta.image).toBe('https://aspire.dev/og/dashboard/enable-browser-telemetry.png'); + expect(meta.imageType).toBe('image/png'); expect(meta.imageAlt).toBe('Enable browser telemetry'); expect(meta.imageWidth).toBe(DEFAULT_OG_IMAGE_WIDTH); expect(meta.imageHeight).toBe(DEFAULT_OG_IMAGE_HEIGHT); @@ -504,6 +535,19 @@ describe('getOgMetadata', () => { ); expect(meta.image).toBe('https://aspire.dev/custom-image.png'); + expect(meta.imageType).toBe('image/png'); + }); + + it('infers the image MIME type from a non-PNG ogImage override', () => { + const route = createRoute({ ogImage: '/custom-image.jpg' }); + const meta = getOgMetadata( + route, + new URL('https://aspire.dev/dashboard/enable-browser-telemetry/'), + site + ); + + expect(meta.image).toBe('https://aspire.dev/custom-image.jpg'); + expect(meta.imageType).toBe('image/jpeg'); }); it('falls back to the static image for generated pages routes', () => {