Skip to content
Open
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
105 changes: 105 additions & 0 deletions src/frontend/src/pages/og/reference/samples/[sample].png.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';

import type { APIRoute } from 'astro';
import sharp from 'sharp';

import samplesJson from '@data/samples.json';
import { renderOgImagePng } from '@utils/og-image-renderer';
import { DEFAULT_OG_IMAGE_HEIGHT, DEFAULT_OG_IMAGE_WIDTH } from '@utils/page-metadata';
import { sampleDescriptionText, sampleSlug, type Sample } from '@utils/samples';
import { getTopicForEntry } from '@utils/topic-resolver';

/**
* Per-sample Open Graph image endpoint.
*
* Emits one PNG per sample at the stable path `/og/reference/samples/<sample>.png`.
* The sample detail page (`src/pages/reference/samples/[sample]/index.astro`)
* points its `og:image` here.
*
* Why this exists: `og:image` is emitted as an absolute URL against the
* canonical `site` (`https://aspire.dev`). Sample detail pages are `src/pages`
* routes, so the dynamic `/og/<slug>.png` card generator skips them. Pointing
* `og:image` at the optimized `_astro/<name>.<hash>.webp` thumbnail instead
* bakes a per-build content hash into that absolute URL, which 404s on any
* deployment whose build hash differs from production's (e.g. staging serves a
* hash production never built, and vice versa). Serving the card at a stable,
* hash-free URL — the same approach the dynamic cards use — lets it resolve
* regardless of which deployment built the page.
*
* Samples with a primary thumbnail render that thumbnail (resized to the
* canonical 1200×630 social-card dimensions that `page-metadata.ts` declares in
* `og:image:width`/`og:image:height`). Samples without one fall back to the same
* branded card the dynamic docs endpoint renders, so every sample still gets a
* page-specific social card instead of a 404.
*/

export const prerender = true;

interface RouteProps {
sample: Sample;
}

interface StaticPath {
params: { sample: string };
props: RouteProps;
}

/**
* Resolve a `~/assets/...` thumbnail specifier to an on-disk path under the
* frontend project root. Paths are resolved against `process.cwd()` (which
* Astro sets to the frontend root at build time) to mirror `og-image-renderer`
* and keep working after Vite bundles this module.
*/
function resolveThumbnailPath(thumbnail: string): string {
const relativePath = thumbnail.replace('~/assets/', `${path.join('src', 'assets')}${path.sep}`);
return path.join(process.cwd(), relativePath);
}

/** First non-empty line of the cleaned sample description, if any. */
function sampleCardDescription(sample: Sample): string | undefined {
return (
sampleDescriptionText(sample.description)
?.split('\n')
.find((line) => line.trim().length > 0)
?.trim() ?? undefined
);
}

async function renderSampleOgPng(sample: Sample): Promise<Buffer> {
if (sample.thumbnail) {
const source = await readFile(resolveThumbnailPath(sample.thumbnail));
return sharp(source)
.resize(DEFAULT_OG_IMAGE_WIDTH, DEFAULT_OG_IMAGE_HEIGHT, {
fit: 'cover',
position: 'center',
})
.png()
.toBuffer();
}

return renderOgImagePng({
title: sample.title,
description: sampleCardDescription(sample),
topic: getTopicForEntry(`reference/samples/${sampleSlug(sample.name)}`),
});
}

export function getStaticPaths(): StaticPath[] {
return (samplesJson as Sample[]).map((sample) => ({
params: { sample: sampleSlug(sample.name) },
props: { sample },
}));
}

export const GET: APIRoute = async ({ props }) => {
const { sample } = props as RouteProps;
const png = await renderSampleOgPng(sample);

return new Response(new Uint8Array(png), {
headers: {
'content-type': 'image/png',
'cache-control': 'public, max-age=31536000, immutable',
},
});
};
26 changes: 9 additions & 17 deletions src/frontend/src/pages/reference/samples/[sample]/index.astro
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
---
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
import { getImage } from 'astro:assets';

import Breadcrumb from '@components/Breadcrumb.astro';
import SampleDetail from '@components/SampleDetail.astro';
Expand All @@ -17,22 +16,15 @@ export function getStaticPaths() {
const { sample } = Astro.props as { sample: Sample };
const base = import.meta.env.BASE_URL.replace(/\/$/, '');
const samplesHref = `${base}/reference/samples/`;
const allImages = import.meta.glob<{ default: ImageMetadata }>(
'/src/assets/samples/**/*.{png,jpg,jpeg,gif,svg,webp}',
{ eager: true }
);

function resolveImage(thumbnail: string | null): ImageMetadata | null {
if (!thumbnail) return null;
const importPath = thumbnail.replace('~/assets/', '/src/assets/');
const entry = allImages[importPath];
return entry?.default ?? null;
}

const resolvedThumbnail = resolveImage(sample.thumbnail);
const sampleOgImage = resolvedThumbnail
? (await getImage({ src: resolvedThumbnail, width: 1200 })).src
: undefined;
// Point `og:image` at the stable, hash-free PNG served by
// `pages/og/reference/samples/[sample].png.ts`. Referencing the optimized
// `_astro/<name>.<hash>.webp` asset here would bake a per-build content hash
// into the absolute (https://aspire.dev) `og:image`, which 404s on any
// deployment whose build hash differs from production's (e.g. staging). The
// endpoint serves a card for every sample (thumbnail when present, branded
// fallback otherwise), so this is always set.
const sampleOgImage = `${base}/og/reference/samples/${sampleSlug(sample.name)}.png`;
const description =
sampleDescriptionText(sample.description)
?.split('\n')
Expand All @@ -44,7 +36,7 @@ const description =
frontmatter={{
title: sample.title,
description,
...(sampleOgImage ? { ogImage: sampleOgImage } : {}),
ogImage: sampleOgImage,
topic: 'reference',
category: 'sample',
prev: false,
Expand Down
53 changes: 53 additions & 0 deletions src/frontend/tests/e2e/og-metadata.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,59 @@ for (const page of PAGES) {
});
}

test('emits a stable, hash-free og:image for sample detail pages', async ({ request, baseURL }) => {
// Sample detail pages (`src/pages/reference/samples/[sample]/index.astro`)
// are `src/pages` routes, so the dynamic `/og/<slug>.png` card generator
// skips them. They instead point `og:image` at the primary thumbnail served
// by `src/pages/og/reference/samples/[sample].png.ts`. This must be a stable,
// hash-free URL — referencing the optimized `_astro/<name>.<hash>.webp` asset
// bakes a per-build content hash into the absolute (aspire.dev) `og:image`
// that 404s on any deployment whose build hash differs from production's.
const ogImagePath = '/og/reference/samples/node-express-redis.png';
const response = await request.get('/reference/samples/node-express-redis/');
expect(response.ok(), 'sample detail page should return 200').toBe(true);
const html = await response.text();

expect(html).not.toMatch(/<meta\b[^>]*property="og:image"[^>]*content="[^"]*\/_astro\//i);
expect(html).toMatch(
new RegExp(`<meta\\b[^>]*property="og:image"[^>]*content="[^"]*${escape(ogImagePath)}"`, 'i')
);
expect(html).toMatch(metaTagPattern('property', 'og:image:width', '1200'));
expect(html).toMatch(metaTagPattern('property', 'og:image:height', '630'));
expect(html).toMatch(
new RegExp(`<meta\\b[^>]*name="twitter:image"[^>]*content="[^"]*${escape(ogImagePath)}"`, 'i')
);

// The thumbnail-backed card must actually resolve to a real PNG.
const imageUrl = new URL(ogImagePath, baseURL).toString();
const imageResponse = await request.get(imageUrl);
expect(imageResponse.ok(), `${imageUrl} should resolve to a real PNG`).toBe(true);
expect(imageResponse.headers()['content-type']).toMatch(/image\/png/i);
});

test('emits a resolvable og:image for samples without a primary thumbnail', async ({
request,
baseURL,
}) => {
// Samples without a primary thumbnail still need a social card. The endpoint
// renders the same branded fallback the dynamic docs cards use, so the
// stable og:image URL resolves instead of 404ing.
const ogImagePath = '/og/reference/samples/aspire-with-node.png';
const response = await request.get('/reference/samples/aspire-with-node/');
expect(response.ok(), 'thumbnail-less sample page should return 200').toBe(true);
const html = await response.text();

expect(html).not.toMatch(/<meta\b[^>]*property="og:image"[^>]*content="[^"]*\/_astro\//i);
expect(html).toMatch(
new RegExp(`<meta\\b[^>]*property="og:image"[^>]*content="[^"]*${escape(ogImagePath)}"`, 'i')
);

const imageUrl = new URL(ogImagePath, baseURL).toString();
const imageResponse = await request.get(imageUrl);
expect(imageResponse.ok(), `${imageUrl} should resolve to a real PNG`).toBe(true);
expect(imageResponse.headers()['content-type']).toMatch(/image\/png/i);
});

test('falls back to the site-wide image for the home page', async ({ request, baseURL }) => {
const response = await request.get('/');
expect(response.ok(), 'home page should return 200').toBe(true);
Expand Down