---
/**
* The single metadata component. Spec: docs/04-seo-spec.md.
*
* "Every page passes through one SEO component. A page without it is not
* finished." — so the length rules in that spec are ENFORCED here rather than
* described. An out-of-range title or description throws at build time and
* names the offending string and its length, the same way src/content.config.ts
* does for article frontmatter. A build that fails on unfinished metadata is a
* correct build.
*/
import { getImage } from 'astro:assets';
import ogDefault from '../assets/og-portrait.jpg';
import { SITE, PORTRAIT } from '../data/site';
import { OG_CARDS, PORTRAIT_PAGES, ogCardPath } from '../data/og-cards';
export interface Props {
/** The full rendered
. Pattern: " · Pouya Lajevardi". 50–60. */
title: string;
/** 140–160 characters, unique, written for a human. */
description: string;
/** Overrides the canonical path. Defaults to this page's own URL. */
canonical?: string;
ogType?: 'website' | 'article' | 'profile';
/**
* AN EXPLICIT PER-PAGE OVERRIDE, AND ALMOST NOTHING SHOULD PASS IT. Which
* pages take the portrait is decided by `PORTRAIT_PAGES` and everything else
* takes its generated card — both resolved below from the pathname, so the
* decision lives in `src/data/og-cards.ts` rather than in nineteen call sites.
* This exists for an article that sets its own `image` in frontmatter. Passing
* it to get the portrait onto a third page would reinstate the interim R15
* exists to end. `ImageMetadata` is an Astro ambient global — nothing to import.
*/
image?: ImageMetadata;
imageAlt?: string;
/** /legal/* and any temporary page. Emits noindex,follow per docs/04. */
noindex?: boolean;
/**
* Page-appropriate structured data — Person, ProfessionalService, Service,
* Article, BreadcrumbList, FAQPage. Passed in, never invented here: a default
* would be a claim this component is in no position to make.
*/
jsonLd?: unknown;
}
const {
title,
description,
canonical,
ogType = 'website',
image,
imageAlt,
noindex = false,
jsonLd,
} = Astro.props;
const TITLE_MIN = 50;
const TITLE_MAX = 60;
const DESC_MIN = 140;
const DESC_MAX = 160;
const problems: string[] = [];
if (title.length < TITLE_MIN || title.length > TITLE_MAX) {
problems.push(
`title is ${title.length} characters; docs/04-seo-spec.md requires ${TITLE_MIN}–${TITLE_MAX}.\n ${JSON.stringify(title)}`,
);
}
if (description.length < DESC_MIN || description.length > DESC_MAX) {
problems.push(
`description is ${description.length} characters; docs/04-seo-spec.md requires ${DESC_MIN}–${DESC_MAX}.\n ${JSON.stringify(description)}`,
);
}
if (problems.length > 0) {
throw new Error(
`SEO metadata out of range on ${Astro.url.pathname}\n - ${problems.join('\n - ')}\n` +
` Fix the string. Do not widen the range — these are the lengths Google renders.`,
);
}
// `site` drives canonical URLs, OG tags, and the sitemap. Without it every
// absolute URL below would silently become a relative one.
if (!Astro.site) {
throw new Error(
'astro.config.mjs must set `site`; SEO.astro needs it for canonical and OG URLs.',
);
}
const canonicalUrl = new URL(canonical ?? Astro.url.pathname, Astro.site);
/**
* THE OG IMAGE, AND THIS IS WHERE R15 IS DISCHARGED — build step 7b.
*
* Two kinds of card, per Q40 and docs/04, both resolved from the pathname: the
* pages in `PORTRAIT_PAGES` get the portrait crop, and every other page gets the
* card generated for it by `src/pages/og/[...slug].jpg.ts`.
*
* ⚠️ A MISSING REGISTRY ENTRY THROWS RATHER THAN FALLING BACK TO THE PORTRAIT.
* That is the whole mechanism. R15's failure mode is not that the wrong image
* ships — it is that the wrong image ships *invisibly*, because no one on this
* project ever sees a link preview. A silent fallback reproduces exactly that,
* and reads as intentional. Both sides derive the path from `ogCardPath()`, so a
* page with an entry cannot point at a card the endpoint did not generate.
*
* Articles are exempt from the registry check: their cards come from the same
* `getCollection('insights', not draft)` the article route pages come from, so
* a built article always has one and a draft has neither.
*/
const path = Astro.url.pathname;
const isArticle = /^\/insights\/[^/]+\/$/.test(path);
const usesPortrait = (PORTRAIT_PAGES as readonly string[]).includes(path);
const hasCard = isArticle || path in OG_CARDS;
if (!image && !usesPortrait && !hasCard) {
throw new Error(
`No Open Graph card for ${path}.\n` +
' Add an entry to OG_CARDS in src/data/og-cards.ts whose `headline` is ' +
"this page's own
, verbatim — `npm run og:proof` compares the two.\n" +
' Only the pages in PORTRAIT_PAGES use the portrait (AGENTS.md Q40, R15).',
);
}
// JPEG on purpose, for both kinds. Page images are AVIF/WebP with a fallback
// (CLAUDE.md), but link-preview crawlers are not browsers — LinkedIn and Slack
// do not negotiate content types, and several still do not decode WebP at all.
// The generated card is already a 1200×630 JPEG, so it takes no `getImage` pass;
// running one would re-encode a finished image for nothing.
const portraitSource = image ?? ogDefault;
const ogImageUrl =
image || usesPortrait
? new URL(
(
await getImage({
src: portraitSource,
format: 'jpeg',
width: 1200,
height: 630,
})
).src,
Astro.site,
)
: new URL(ogCardPath(path), Astro.site);
/**
* ⚠️ THE ALT IS THE CARD'S HEADLINE, AND IT WAS THE PAGE ``.
*
* The comment here claimed *"a typographic card's alt is its headline"* while
* the code fell back to `title`. Measured: `/fees/` emitted
* `og:image:alt="Fees · Mediation and Arbitration Rates · Pouya Lajevardi"`
* against a card reading *"Published in full, including what overruns cost."* —
* an alt that did not describe the image, on 20 pages, and it would have
* diverged further for the one article that sets `seoTitle`. Found by
* `adversarial-reviewer` round 2.
*
* `OG_CARDS[path]?.headline` is the card's actual text for a registry page.
* `title` remains the fallback for an article, where the card headline IS the
* title, and `PORTRAIT.alt` for the two portrait pages.
*/
const resolvedImageAlt =
imageAlt ??
(image || usesPortrait ? PORTRAIT.alt : (OG_CARDS[path]?.headline ?? title));
// JSON.stringify does not escape `<`, so a "" inside any string value
// would close this element early and hand the rest of the payload to the HTML
// parser. Escaping the angle bracket is the whole fix; JSON readers decode it.
const jsonLdText =
jsonLd === undefined ? null : JSON.stringify(jsonLd).replace(/
{title}
{
jsonLdText && (
)
}