Build and deploy / build-and-deploy (push) Failing after 4s
D19 caps the loop at two rounds, and this is what the second round is for. BLOCKING. Round 1 made NO_RETAINER_NOTICE a requireEnv and added it to no document, while the fix's own comment claimed docs/06 named it. The deployment list said five variables for a handler that needs six, so an operator following the cutover checklist would have deployed a function that throws at cold start on every invocation — 5xx from API Gateway, every inquiry lost from the moment /api/* was wired, loud in CloudWatch and silent to Pouya. docs/05 and docs/06 now name all six, and the comment that asserted the documentation existed is corrected rather than deleted. The intake route check added in round 1 could not fail: curl -w already prints 000 on a failed transfer, so `|| echo 000` double-appended and the failure arm was unreachable, and the pass arm accepted anything that was not literally 404 — including the 403 CloudFront returns when the /api/* behaviour is missing, which is the one distinction the check exists to draw. It now sends the correct Origin and asserts a positive: 303 to /contact/could-not-send/, which the handler returns before any DynamoDB write or email. Probed on refused/501/403/303; the old version passed the first three. Fixed in both deploy paths. Removing priceRange left three statements saying it was present or pending, one of them the stated reason /fees/ emits no Offer node. Deleting overtimeStartsAfterSessionHours left AGENTS.md §9 naming it and left Q59 recorded as open. The Google-as-processor fix was applied to the privacy policy's "Where it is stored" and not to "Who can see it", which still read "Nobody else has access". And the variable removal was justified with a path-scoped git grep — which also cannot see untracked files. The unscoped sweep found docs/06's variable table, the OIDC example, and .env.example still carrying them; .env.example also restates the execute-api hostname, falsifying a live claim in intake.ts that has been corrected. That file is not edited here: this environment denies read access to it, and nothing may edit a file it cannot read. It is in the batched list. Also: og:image:alt was the page title rather than the card's headline on 20 pages; og-card.ts documented the wrong path and invocation for the contact sheet; deploy-local.sh still said Q22's deploy credential "does NOT yet exist"; and the round-1 fix comments were trimmed per D19, though the ratio held at 0.44. Round 2 also confirmed the round-1 fixes by measurement: all 56 .btn instances across 22 pages, the consent checkbox's computed accessible name, the radio labels hit-tested at 44px, and og:proof exercised against synthetic article pages in a sandbox. Verified: check/build/check:claims/og:proof/check:intake/lint/bio:pdf all exit 0 on a clean build; 22 pages; Lighthouse 99-100 / 100 / 100 / 100, CLS 0.000. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
199 lines
8.0 KiB
Plaintext
199 lines
8.0 KiB
Plaintext
---
|
||
/**
|
||
* 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 <title>. 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}</title>
|
||
<meta name="description" content={description} />
|
||
<link rel="canonical" href={canonicalUrl.href} />
|
||
<meta name="robots" content={noindex ? 'noindex,follow' : 'index,follow'} />
|
||
|
||
<meta property="og:type" content={ogType} />
|
||
<meta property="og:title" content={title} />
|
||
<meta property="og:description" content={description} />
|
||
<meta property="og:url" content={canonicalUrl.href} />
|
||
<meta property="og:site_name" content={SITE.name} />
|
||
<meta property="og:locale" content={SITE.locale} />
|
||
<meta property="og:image" content={ogImageUrl.href} />
|
||
<meta property="og:image:width" content="1200" />
|
||
<meta property="og:image:height" content="630" />
|
||
<meta property="og:image:alt" content={resolvedImageAlt} />
|
||
|
||
<meta name="twitter:card" content="summary_large_image" />
|
||
<meta name="twitter:title" content={title} />
|
||
<meta name="twitter:description" content={description} />
|
||
<meta name="twitter:image" content={ogImageUrl.href} />
|
||
<meta name="twitter:image:alt" content={resolvedImageAlt} />
|
||
|
||
{
|
||
jsonLdText && (
|
||
<script type="application/ld+json" is:inline set:html={jsonLdText} />
|
||
)
|
||
}
|