--- /** * 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: "<Page> · 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 <h1>, 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 `<title>`. * * 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 "</script>" 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(/</g, '\\u003c'); --- <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="generator" content={Astro.generator} /> <title>{title} { jsonLdText && (