diff --git a/nextjs-app/__tests__/app/metadata.test.ts b/nextjs-app/__tests__/app/metadata.test.ts index 800c19d..edfbddb 100644 --- a/nextjs-app/__tests__/app/metadata.test.ts +++ b/nextjs-app/__tests__/app/metadata.test.ts @@ -2,6 +2,7 @@ import { metadata as homeMetadata } from '@/app/(frontend)/page'; import { metadata as rankingsMetadata } from '@/app/(frontend)/rankings/page'; import { metadata as admissionsMetadata } from '@/app/(frontend)/admissions/page'; import { generateMetadata as compareMetadata } from '@/app/(frontend)/compare/page'; +import { metadata as rootMetadata } from '@/app/(frontend)/layout'; describe('canonical URLs', () => { it('the homepage canonicalises to the bare root', () => { @@ -128,3 +129,41 @@ describe('C1 snippet copy', () => { } }); }); + +/** + * The share card must be declared, not inherited. + * + * `app/opengraph-image.tsx` is a metadata file convention, and it does attach + * to routes in the app root segment — `_not-found` gets an og:image from it. + * It does NOT attach to the site's pages, which live in the `(frontend)` + * route group whose own layout is a root layout. Staging served og:title, + * og:description, og:url, og:site_name and og:type and no og:image at all, + * so every link pasted into a chat rendered bare. + * + * The file stays at the app root, because /robots.txt and /icon.png depend on + * it being there. The site's root layout points at the route it generates. + */ +describe('the share card', () => { + it('declares an opengraph image on the site root layout', () => { + // No og:image means every link pasted into a chat renders bare. + const images = rootMetadata.openGraph?.images; + expect(images).toBeTruthy(); + expect(JSON.stringify(images)).toContain('/opengraph-image'); + }); + + it('declares a twitter image too', () => { + // twitter.card is summary_large_image. Claiming a large-image card and + // supplying no image is worse than claiming a summary card. + // Metadata['twitter'] is a union and `card` is not on every member, so + // this reads the serialised shape rather than narrowing the type. + const twitter = JSON.stringify(rootMetadata.twitter); + expect(twitter).toContain('summary_large_image'); + expect(twitter).toContain('/opengraph-image'); + }); + + it('resolves the card to an absolute url via metadataBase', () => { + // The e2e journey does `new URL(ogUrl)`, which throws on a relative path. + expect(rootMetadata.metadataBase?.toString()) + .toBe('https://www.schoolcompare.co.uk/'); + }); +}); diff --git a/nextjs-app/app/(frontend)/layout.tsx b/nextjs-app/app/(frontend)/layout.tsx index f41880d..7507073 100644 --- a/nextjs-app/app/(frontend)/layout.tsx +++ b/nextjs-app/app/(frontend)/layout.tsx @@ -59,14 +59,32 @@ export const metadata: Metadata = { authors: [{ name: 'schoolcompare' }], manifest: '/manifest.json', // No `icons` key on purpose: setting it here would override the file - // conventions. app/icon.svg and app/apple-icon.tsx are the source, and - // app/opengraph-image.tsx supplies og:image and twitter:image. + // conventions. app/icon.png and app/apple-icon.png are the source. + // + // og:image and twitter:image are NOT inherited from + // app/opengraph-image.tsx — see the note on openGraph.images below. The + // icon conventions do reach these pages; the opengraph-image one does not. metadataBase: new URL(SITE_URL), openGraph: { type: 'website', title: 'Compare Schools Side by Side | schoolcompare', description: 'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place.', + /* + * Declared, not inherited. + * + * app/opengraph-image.tsx is a metadata file convention, and it does + * attach to routes in the app root segment — _not-found gets an og:image + * from it. It does not reach the site's pages, which live in the + * (frontend) route group whose own layout.tsx is a root layout. Staging + * served og:title, og:description, og:url, og:site_name and og:type with + * no og:image at all, so every link pasted into a chat rendered bare. + * + * The file stays at the app root: /robots.txt and /icon.png depend on it + * being there, and moving it is what broke those before. This points at + * the route it generates instead. metadataBase makes it absolute. + */ + images: ['/opengraph-image'], url: SITE_URL, siteName: 'schoolcompare', }, @@ -76,6 +94,9 @@ export const metadata: Metadata = { title: 'Compare Schools Side by Side | schoolcompare', description: 'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place.', + // The card is summary_large_image; claiming that and supplying no image + // is worse than claiming a summary card. + images: ['/opengraph-image'], }, };