From d73ee643c965dcbe1a9dfb247f7422e18d5af9cd Mon Sep 17 00:00:00 2001 From: David Pine Date: Wed, 10 Jun 2026 09:05:19 -0500 Subject: [PATCH] Fix sample page og:image to use stable, hash-free URLs Sample detail pages emitted og:image pointing at the optimized `_astro/..webp` thumbnail. Because og:image is an absolute URL against the canonical site (https://aspire.dev), the per-build content hash baked into that path 404s on any deployment whose build hash differs from production's (e.g. staging serves a hash production never built, and vice versa), producing invalid social-card images. Serve each sample's card at a stable, hash-free URL `/og/reference/samples/.png` via a new prerendered endpoint, mirroring the dynamic `/og/.png` docs cards. Samples with a primary thumbnail render that thumbnail resized to the canonical 1200x630 social dimensions; samples without one fall back to the same branded card the docs endpoint renders, so every sample resolves to a real PNG. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../og/reference/samples/[sample].png.ts | 105 ++++++++++++++++++ .../reference/samples/[sample]/index.astro | 26 ++--- src/frontend/tests/e2e/og-metadata.spec.ts | 53 +++++++++ 3 files changed, 167 insertions(+), 17 deletions(-) create mode 100644 src/frontend/src/pages/og/reference/samples/[sample].png.ts diff --git a/src/frontend/src/pages/og/reference/samples/[sample].png.ts b/src/frontend/src/pages/og/reference/samples/[sample].png.ts new file mode 100644 index 000000000..2155a591b --- /dev/null +++ b/src/frontend/src/pages/og/reference/samples/[sample].png.ts @@ -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/.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/.png` card generator skips them. Pointing + * `og:image` at the optimized `_astro/..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 { + 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', + }, + }); +}; diff --git a/src/frontend/src/pages/reference/samples/[sample]/index.astro b/src/frontend/src/pages/reference/samples/[sample]/index.astro index 34d2dfb75..8b9bc3ce7 100644 --- a/src/frontend/src/pages/reference/samples/[sample]/index.astro +++ b/src/frontend/src/pages/reference/samples/[sample]/index.astro @@ -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'; @@ -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/..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') @@ -44,7 +36,7 @@ const description = frontmatter={{ title: sample.title, description, - ...(sampleOgImage ? { ogImage: sampleOgImage } : {}), + ogImage: sampleOgImage, topic: 'reference', category: 'sample', prev: false, diff --git a/src/frontend/tests/e2e/og-metadata.spec.ts b/src/frontend/tests/e2e/og-metadata.spec.ts index ebe117779..0181a24d5 100644 --- a/src/frontend/tests/e2e/og-metadata.spec.ts +++ b/src/frontend/tests/e2e/og-metadata.spec.ts @@ -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/.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/..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(/]*property="og:image"[^>]*content="[^"]*\/_astro\//i); + expect(html).toMatch( + new RegExp(`]*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(`]*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(/]*property="og:image"[^>]*content="[^"]*\/_astro\//i); + expect(html).toMatch( + new RegExp(`]*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);