Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/frontend/config/head.attrs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' } },
Expand Down
5 changes: 3 additions & 2 deletions src/frontend/src/components/starlight/Head.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug> endpoint), its dimensions/alt, and the Twitter card
equivalents (Twitter scrapers prefer name="twitter:*" over og:*).
the /og/<slug> endpoint), its MIME type/dimensions/alt, and the Twitter
card equivalents (Twitter scrapers prefer name="twitter:*" over og:*).
*/}
<meta property="og:image" content={ogMetadata.image} />
<meta property="og:image:type" content={ogMetadata.imageType} />
<meta property="og:image:alt" content={ogMetadata.imageAlt} />
<meta property="og:image:width" content={String(ogMetadata.imageWidth)} />
<meta property="og:image:height" content={String(ogMetadata.imageHeight)} />
Expand Down
34 changes: 34 additions & 0 deletions src/frontend/src/utils/page-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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/<slug>.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
Expand Down Expand Up @@ -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 {
Expand All @@ -300,6 +333,7 @@ export function getOgMetadata(
url,
type,
image,
imageType,
imageAlt,
imageWidth: DEFAULT_OG_IMAGE_WIDTH,
imageHeight: DEFAULT_OG_IMAGE_HEIGHT,
Expand Down
2 changes: 2 additions & 0 deletions src/frontend/tests/e2e/og-metadata.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(`<meta\\b[^>]*name="twitter:image"[^>]*content="[^"]*${escape(ogImagePath)}"`, 'i')
);
Expand Down
44 changes: 44 additions & 0 deletions src/frontend/tests/unit/page-metadata.vitest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
resolveCanonicalUrl,
resolveOgDescription,
resolveOgImage,
resolveOgImageType,
resolveOgTitle,
resolveOgType,
resolveSiteUrl,
Expand Down Expand Up @@ -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' });
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading