diff --git a/SEO_PLAN.md b/SEO_PLAN.md new file mode 100644 index 00000000..bbcb9813 --- /dev/null +++ b/SEO_PLAN.md @@ -0,0 +1,89 @@ +# SEO and Open Graph plan + +Status of the site-wide Open Graph refresh and the SEO work that remains. Check items off here as they land; `pnpm check:seo` (after `pnpm build`) +is the gate for titles, descriptions, canonical URLs, JSON-LD, and internal links. + +## How OG images work now + +- Cards are declared in `src/lib/ogImage.ts` (`OG_CARDS`). One entry per card: + headline, supporting line, optional product mark. Copy for product cards is + derived from `src/sitemap/product-metadata.ts` taglines. +- `pnpm render:og [ids...]` (`scripts/render-og-images.ts`) renders each card to + `public/images/og/.png` at 2400×1260 with Playwright and quantizes it with + sharp (160–530 KB each). The PNGs are committed so the build stays hermetic and + no upload credentials are needed. Re-run after editing copy or product taglines. +- Pages reference a card with `ogImageFor(id)` or `productOgImage(productId)`. + `BaseLayout` defaults to `default`; `MarketingLayout` falls back to the owning + product's card when `productId` is set; `DocsArticlePage` does the same and + falls back to `docs`. +- The `/secure-exec/` overview keeps its ported dark card at 1280×630 + (`SECURE_EXEC_OVERVIEW_OG_IMAGE`); its docs use the generated light card. + +## Done in this pass + +- [x] 18 cards rendered: default, actors, agentos, workflows, dynamic-apps, cloud, + secure-exec, docs, deploy, guides, integrations, registry, blog, enterprise, + startups, talk-to-an-engineer, support, brand. +- [x] Every page family wired: home and all uncustomized marketing pages (default), + pricing (cloud), agentOS overview and registry (agentos), compare pages + (product), product docs and overviews (product), site docs (docs), Deploy + (deploy), Guides (guides), Integrations (integrations), blog and changelog + index and posts without a hero image (blog), enterprise, startups, support, + talk-to-an-engineer, brand, registry. +- [x] Secure Exec overview `og:image:height` fixed (was 640 for a 630 px image). +- [x] Stale remote cards no longer referenced: + `assets.rivet.dev/website/public/promo/og-agentic-era.png` and + `promo/og/{cloud,agentos}.png`. +- [x] Company facts centralized in `src/data/company.ts` and emitted as the + site-wide Organization JSON-LD (legalName, foundingDate, address, founders + with sameAs, LinkedIn added to sameAs, support contact URL). +- [x] FAQ questions render as `

` on every FAQ (`FaqList`), so FAQ sections read + as an outline to crawlers. + +## Needs a human decision + +- [ ] **Homepage positioning.** The hero says "The orchestrator for agentic + workloads."; `CLAUDE.md` and the footer still say "Infrastructure for the + agentic era." The default OG card and `COMPANY.description` follow the hero. + Pick one and update the footer, `CLAUDE.md` canon, and the pricing closing + CTA to match. +- [ ] **Founding date** in `src/data/company.ts` is 2022 (YC profile); LinkedIn + says January 2023. Confirm which the Organization JSON-LD should carry. +- [ ] **About page.** Built to the eight-section outline, then removed at the + founder's request. Revisit if a company page is wanted; the Organization + JSON-LD already carries the facts it would repeat. + +## Next: metadata and structured data + +- [ ] Author `Person` JSON-LD on blog posts (`BlogLayout`) using `AUTHORS` in + `src/lib/article.tsx`, linked to the founders' `sameAs` from + `src/data/company.ts`. +- [ ] Per-post OG cards for blog posts without a hero image: extend + `render-og-images.ts` to take a title and emit `public/images/og/blog/.png`, + or render at build time. +- [ ] Refresh the careers OG image + (`assets.rivet.dev/website/images/careers/careers-og-1200x630.*.webp`) as a + generated card so it matches the set. +- [ ] Title audit: `pnpm check:seo` enforces 20–65 characters; a spot check of the + docs titles that hit the ceiling (`contextualSeoTitle`) is worthwhile. +- [ ] Resolve the pre-existing `check:seo` failures on Secure Exec: forbidden + `SoftwareApplication` JSON-LD on `/secure-exec/` and five + `/secure-exec/docs/comparison/*` pages with no incoming internal links. + +## Next: content + +- [ ] Compare pages for systems the site does not cover yet (Kubernetes-per-tenant, + E2B, Modal). +- [ ] Customer stories or logos section once legal clears names. +- [ ] `llms.txt`: add a short company summary block above the URL list drawn from + `COMPANY.description`. + +## Operational + +- [ ] Optionally mirror `public/images/og/*.png` to `assets.rivet.dev` and switch + `ogImageFor` to absolute URLs if the CDN is preferred over the site origin. + Not required; social crawlers fetch from `rivet.dev` fine. +- [ ] After deploy, re-scrape the key URLs in the X Card Validator, LinkedIn Post + Inspector, and Facebook Sharing Debugger so cached previews update. +- [ ] Check the Organization rich result report in Google Search Console for the + new founder and address fields. diff --git a/package.json b/package.json index 14bc95b7..38b9c1b9 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "render-byoc-hero": "tsx ./.claude/skills/create-launch-post/scripts/render-byoc-hero.ts", "render-otel-hero": "tsx ./.claude/skills/create-launch-post/scripts/render-otel-hero.ts", "render-technical-image": "tsx ./.claude/skills/create-launch-post/scripts/render-technical-image.ts", + "render:og": "tsx ./scripts/render-og-images.ts", "gen:markdown": "tsx ./scripts/generate-markdown.ts", "index:search": "tsx ./scripts/index-search.ts", "gen:skills": "tsx ./scripts/generate-skills.ts", diff --git a/public/images/og/actors.png b/public/images/og/actors.png new file mode 100644 index 00000000..653d3ef3 Binary files /dev/null and b/public/images/og/actors.png differ diff --git a/public/images/og/agentos.png b/public/images/og/agentos.png new file mode 100644 index 00000000..5c2df3b9 Binary files /dev/null and b/public/images/og/agentos.png differ diff --git a/public/images/og/blog.png b/public/images/og/blog.png new file mode 100644 index 00000000..30dbbbfd Binary files /dev/null and b/public/images/og/blog.png differ diff --git a/public/images/og/brand.png b/public/images/og/brand.png new file mode 100644 index 00000000..dbcff666 Binary files /dev/null and b/public/images/og/brand.png differ diff --git a/public/images/og/cloud.png b/public/images/og/cloud.png new file mode 100644 index 00000000..3856845f Binary files /dev/null and b/public/images/og/cloud.png differ diff --git a/public/images/og/default.png b/public/images/og/default.png new file mode 100644 index 00000000..2cf79583 Binary files /dev/null and b/public/images/og/default.png differ diff --git a/public/images/og/deploy.png b/public/images/og/deploy.png new file mode 100644 index 00000000..9a6144a6 Binary files /dev/null and b/public/images/og/deploy.png differ diff --git a/public/images/og/docs.png b/public/images/og/docs.png new file mode 100644 index 00000000..ee1fd8e5 Binary files /dev/null and b/public/images/og/docs.png differ diff --git a/public/images/og/dynamic-apps.png b/public/images/og/dynamic-apps.png new file mode 100644 index 00000000..7f77f986 Binary files /dev/null and b/public/images/og/dynamic-apps.png differ diff --git a/public/images/og/enterprise.png b/public/images/og/enterprise.png new file mode 100644 index 00000000..9458ae5b Binary files /dev/null and b/public/images/og/enterprise.png differ diff --git a/public/images/og/guides.png b/public/images/og/guides.png new file mode 100644 index 00000000..7929a052 Binary files /dev/null and b/public/images/og/guides.png differ diff --git a/public/images/og/integrations.png b/public/images/og/integrations.png new file mode 100644 index 00000000..28951017 Binary files /dev/null and b/public/images/og/integrations.png differ diff --git a/public/images/og/registry.png b/public/images/og/registry.png new file mode 100644 index 00000000..fd9797d7 Binary files /dev/null and b/public/images/og/registry.png differ diff --git a/public/images/og/secure-exec.png b/public/images/og/secure-exec.png new file mode 100644 index 00000000..3123b399 Binary files /dev/null and b/public/images/og/secure-exec.png differ diff --git a/public/images/og/startups.png b/public/images/og/startups.png new file mode 100644 index 00000000..d53248fc Binary files /dev/null and b/public/images/og/startups.png differ diff --git a/public/images/og/support.png b/public/images/og/support.png new file mode 100644 index 00000000..33e5e008 Binary files /dev/null and b/public/images/og/support.png differ diff --git a/public/images/og/talk-to-an-engineer.png b/public/images/og/talk-to-an-engineer.png new file mode 100644 index 00000000..13ee993a Binary files /dev/null and b/public/images/og/talk-to-an-engineer.png differ diff --git a/public/images/og/workflows.png b/public/images/og/workflows.png new file mode 100644 index 00000000..7b674852 Binary files /dev/null and b/public/images/og/workflows.png differ diff --git a/scripts/render-og-images.ts b/scripts/render-og-images.ts new file mode 100644 index 00000000..486bc11e --- /dev/null +++ b/scripts/render-og-images.ts @@ -0,0 +1,231 @@ +/** + * Renders every Open Graph card in `src/lib/ogImage.ts` to + * `public/images/og/.png` (2400x1260, the 1200x630 social size at 2x). + * + * pnpm render:og # all cards + * pnpm render:og default # one or more card ids + * + * The card is plain HTML on the site's porcelain surface, set in Manrope from + * `public/fonts/`, with product marks from `public/images/brand/`. Playwright + * drives Chromium: set CHROMIUM_PATH to a browser binary, otherwise the script + * looks for the agent-browser Chrome, a system chromium, or Playwright's own + * download. Output PNGs are committed so the site build stays hermetic. + */ +import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { chromium } from "playwright"; +import sharp from "sharp"; + +import { + OG_CARDS, + OG_IMAGE_HEIGHT, + OG_IMAGE_WIDTH, + type OgCard, +} from "../src/lib/ogImage"; +import { getProductMetadata } from "../src/sitemap/product-metadata"; + +const ROOT = fileURLToPath(new URL("..", import.meta.url)); +const OUT_DIR = path.join(ROOT, "public/images/og"); +const FONT_DIR = path.join(ROOT, "public/fonts/manrope"); + +// Site palette (tailwind.config.ts / theme.css, light theme). +const PAPER = "#EFEFEF"; +const PAPER_DEEP = "#DCDCDE"; +const INK = "#1B1916"; +const INK_SOFT = "#56524A"; + +const dataUrl = (mime: string, bytes: Buffer) => + `data:${mime};base64,${bytes.toString("base64")}`; + +async function exists(file: string): Promise { + try { + await access(file); + return true; + } catch { + return false; + } +} + +async function findChromium(): Promise { + if (process.env.CHROMIUM_PATH) return process.env.CHROMIUM_PATH; + for (const candidate of ["/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/google-chrome"]) { + if (await exists(candidate)) return candidate; + } + // agent-browser (preinstalled in Amp orbs) keeps Chrome for Testing here. + const agentBrowser = path.join(process.env.HOME ?? "", ".agent-browser/browsers"); + if (await exists(agentBrowser)) { + const dirs = (await readdir(agentBrowser)).filter((d) => d.startsWith("chrome-")).sort(); + const latest = dirs.at(-1); + if (latest) { + const binary = path.join(agentBrowser, latest, "chrome"); + if (await exists(binary)) return binary; + } + } + // Fall back to Playwright's managed browser (`pnpm exec playwright install chromium`). + return undefined; +} + +const MIME_BY_EXT: Record = { + ".webp": "image/webp", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".svg": "image/svg+xml", +}; + +/** Inline a file under `public/` (given as its site path, e.g. `/images/x.webp`). */ +async function publicDataUrl(sitePath: string): Promise { + const mime = MIME_BY_EXT[path.extname(sitePath).toLowerCase()]; + if (!mime) throw new Error(`Unsupported image type: ${sitePath}`); + return dataUrl(mime, await readFile(path.join(ROOT, "public", sitePath))); +} + +interface Assets { + fontCss: string; + rivetLogo: string; +} + +async function loadAssets(): Promise { + const [manrope, rivetLogo] = await Promise.all([ + readFile(path.join(FONT_DIR, "Manrope-Variable-latin.woff2")), + readFile(path.join(ROOT, "src/images/rivet-logos/icon-text-black.svg")), + ]); + return { + fontCss: `@font-face { font-family: "Manrope"; font-weight: 200 800; src: url(${dataUrl("font/woff2", manrope)}) format("woff2"); }`, + rivetLogo: dataUrl("image/svg+xml", rivetLogo), + }; +} + +async function lockupHtml(card: OgCard, assets: Assets): Promise { + if (!card.productId) { + // 204x68 wordmark, sized to the same cap height as the product lockup. + return `Rivet`; + } + const meta = getProductMetadata(card.productId); + if (!meta) throw new Error(`Unknown product ${card.productId}`); + const mark = await readFile(path.join(ROOT, `public/images/brand/${card.productId}-mark.svg`)); + // The product owns the lockup; the parent brand sits quietly top-right. + return `
+ + ${escapeHtml(meta.name)} +
+ Rivet`; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +async function cardHtml(card: OgCard, assets: Assets): Promise { + const lockup = await lockupHtml(card, assets); + const art = card.art + ? `${escapeHtml(card.art.alt)}` + : ""; + // Text keeps to the left half when the cutout stands on the right; otherwise + // it can run wider, but never past a comfortable measure. + const textWidth = card.art ? 1140 : 1720; + const headlineSize = card.headline.length > 44 ? 108 : 124; + + return ` + + + +
+ ${lockup} +
+

${escapeHtml(card.headline)}

+

${escapeHtml(card.supporting)}

+
+
+ ${art} +`; +} + +async function main() { + const requested = process.argv.slice(2).filter((arg) => arg !== "--"); + const cards = requested.length + ? OG_CARDS.filter((card) => requested.includes(card.id)) + : OG_CARDS; + const missing = requested.filter((id) => !OG_CARDS.some((card) => card.id === id)); + if (missing.length) throw new Error(`Unknown card id(s): ${missing.join(", ")}`); + + await mkdir(OUT_DIR, { recursive: true }); + const assets = await loadAssets(); + const executablePath = await findChromium(); + const browser = await chromium.launch({ executablePath }); + try { + const page = await browser.newPage({ + viewport: { width: OG_IMAGE_WIDTH, height: OG_IMAGE_HEIGHT }, + deviceScaleFactor: 1, + }); + for (const card of cards) { + await page.setContent(await cardHtml(card, assets), { waitUntil: "load" }); + await page.evaluate(() => document.fonts.ready); + const raw = await page.screenshot({ type: "png" }); + // Quantized PNG: flat surfaces plus grain compress far better as a + // palette image and stay well under the 1 MB crawler-friendly ceiling. + const png = await sharp(raw) + .png({ palette: true, quality: 80, dither: 0.4, compressionLevel: 9, effort: 10 }) + .toBuffer(); + const file = path.join(OUT_DIR, `${card.id}.png`); + await writeFile(file, png); + console.log(`${path.relative(ROOT, file)} ${(png.byteLength / 1024).toFixed(0)} KB`); + } + } finally { + await browser.close(); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/components/docs/DocsArticlePage.astro b/src/components/docs/DocsArticlePage.astro index dd571dee..ffeb8894 100644 --- a/src/components/docs/DocsArticlePage.astro +++ b/src/components/docs/DocsArticlePage.astro @@ -26,6 +26,7 @@ import { } from '@/sitemap/integrations'; import * as mdxComponents from '@/components/mdx'; import { jsonLdString } from '@/lib/jsonLd'; +import { ogImageFor, productOgImage } from '@/lib/ogImage'; import { contextualSeoDescription, contextualSeoTitle, @@ -50,6 +51,10 @@ const { routeSlugOverride, markdownPathOverride, editUrlOverride, + // Link preview card. Defaults to the owning product's card, or the site docs + // card when no product is set. Sections that live under a product id but are + // their own thing (Integrations, Deploy, Guides) pass their card explicitly. + ogImage = productId ? productOgImage(productId) : ogImageFor('docs'), // Extra MDX components for this page, e.g. the bound product slot renderer. extraComponents = {}, } = Astro.props; @@ -201,6 +206,7 @@ const breadcrumbSchema = { description={seo.description} canonicalUrl={canonicalUrl} robots={robots} + ogImage={ogImage} sidebar={sidebar} productId={productId} tabId={tabId} diff --git a/src/components/faq/FaqSection.tsx b/src/components/faq/FaqSection.tsx index 62927762..59a55f4f 100644 --- a/src/components/faq/FaqSection.tsx +++ b/src/components/faq/FaqSection.tsx @@ -63,29 +63,33 @@ export function FaqList({ items, theme = 'dark' }: FaqListProps) { const answerId = `faq-answer-${index}`; return (
- {/* The row's vertical padding sits on the button, not the wrapper, - so the whole row is a hit target rather than just the text line. */} - + {item.question} + + +

({ + "@type": "Person", + name: founder.name, + jobTitle: founder.role, + sameAs: founder.sameAs, + })), + sameAs: Object.values(COMPANY.social), + contactPoint: { + "@type": "ContactPoint", + contactType: "customer support", + email: COMPANY.email, + url: `${COMPANY.url}/support/`, + }, + }; +} diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 6ef6ac3c..dfc37cf0 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -12,6 +12,8 @@ import TabsScript from '@/components/TabsScript.astro'; import MermaidScript from '@/components/MermaidScript.astro'; import { ScrollObserver } from '@/components/ScrollObserver'; import { jsonLdString } from '@/lib/jsonLd'; +import { ogImageFor } from '@/lib/ogImage'; +import { organizationSchema } from '@/data/company'; import { stripSeoMarkup } from '@/lib/seo'; import ThemeScript from '@/components/ThemeScript.astro'; @@ -45,7 +47,7 @@ const { title, description = "Rivet is infrastructure for the agentic era. Orchestrate agents, operate their environment, automate their work, and deploy what they build.", canonicalUrl, - ogImage = "https://assets.rivet.dev/website/public/promo/og-agentic-era.png", + ogImage = ogImageFor("default"), ogImageAlt, ogType = "website", ogImageWidth = 2400, @@ -148,28 +150,7 @@ const footerOpenings = await fetchOpeningsCount(AbortSignal.timeout(4000));