Repository navigation
Step 7 — SEO, OG images, sitemap & redirects #9
Description
Summary
Reimplement the site's SEO surface for Astro + Markdoc: per-page OpenGraph/Twitter/canonical meta (useArcpSeo → an Astro head partial), branded satori OG image cards (ArcpCard), a complete sitemap covering every docs route + home, and the lowercase-canonical 301 redirect for miscased doc URLs.
Why / context
The old Nuxt site got all of this "for free" from @nuxtjs/seo (meta + nuxt-og-image + @nuxtjs/sitemap) plus a server middleware. Astro's static build has none of that wiring, and the default @astrojs/sitemap would only emit /. This phase rebuilds the four pieces against Astro primitives so search engines, social embeds, and existing inbound links (often PascalCase from file-derived URLs) keep working after cutover. Target stack is Astro 5.x output: 'static', @astrojs/markdoc, @astrojs/sitemap, deployed on Vercel via Git integration.
Reference files (read these first)
/Users/nficano/code/arpc/www/app/composables/useArcpSeo.ts— the single SEO entry point. Copy its meta tag set and canonical-URL logic verbatim; drop thedefineOgImage('ArcpCard', …)Nuxt-module call and replace with an Astro OG endpoint./Users/nficano/code/arpc/www/app/components/OgImage/ArcpCard.satori.vue— the satori OG card layout. Copy the exact layout, hex colors, font sizes, and prop defaults; re-author as a satori JSX/h-tree (it's a.satori.vuetemplate today, but satori never sees Vue — port the markup to whatever your Astro OG endpoint renders)./Users/nficano/code/arpc/www/server/api/__sitemap__/urls.ts— proves the sitemap must be fed from thedocscollection, not auto-discovery. Replicate its "enumerate every doc path" intent against Astro collections; drop the@nuxt/contentqueryCollectioncall./Users/nficano/code/arpc/www/server/middleware/lowercase-redirect.ts— the 301 lowercase-canonical rule with its exact skip list and asset regex. Port the logic and regex verbatim; drop the NitrodefineEventHandler/sendRedirectshell (replace withvercel.jsonredirects or Astro middleware)./Users/nficano/code/arpc/www/nuxt.config.ts— source of the canonicalsiteconfig and the OG font list. Copyurl,name,description, and the font weights; drop the NuxtogImage/sitemapmodule config.
Additional context discovered while reading:
-
useArcpSeois called from exactly three pages — capture each call'stitle/description/section:-
/Users/nficano/code/arpc/www/app/pages/[...slug].vue:39—title: page.title ?? 'Documentation',description: page.description,section: sectionLabel(computed; seeLANG_LABELS+· Guides/· API/Specificationlogic at lines 5–37). -
/Users/nficano/code/arpc/www/app/pages/index.vue:8—title: home.seo.title,description: home.seo.description,section: 'Home'. -
/Users/nficano/code/arpc/www/app/error.vue:10—title: \${code} — ${headline}`,section: 'Error'` (no description).
-
-
/Users/nficano/code/arpc/www/public/favicon.svgand thenuxt.config.tsapp.head.linkblock (faviconimage/svg+xml+mask-iconcolor#c2431c) — fold these<link>tags into the head partial.
Tasks
SEO head partial (replace useArcpSeo)
- Create an Astro head partial/component (e.g.
src/components/seo/ArcpSeo.astroor a<head>slot in the base layout) taking propstitle: string,description?: string,section?: string. - Define canonical site config once (port from
nuxt.config.tssite):url: 'https://agentruntimecontrolprotocol.io',name: 'ARCP',description: 'A transport-agnostic wire protocol for submitting, observing, and controlling long-running AI agent jobs.'. Put it somewhere importable (e.g.src/site.config.tsorastro.configsite). -
descriptiondefaults to the site description when a page omits it (matchesgetOptions().description ?? site.description). - Compute canonical
urlassite.url + Astro.url.pathname(matches${site.url}${route.path}). Do not append a trailing slash — routes are no-trailing-slash. - Emit exactly these meta tags (port from
useArcpSeolines 18–28):-
<title>and<meta name="description"> -
og:title(= title),og:description(= description),og:type=website,og:url(= canonical url) -
twitter:card=summary_large_image,twitter:title(= title),twitter:description(= description) -
og:imageandtwitter:imagepointing at the OG endpoint for this route (the Nuxt version emitted these viadefineOgImage— you must add them explicitly).
-
- Add the favicon/mask-icon
<link>tags fromnuxt.config.tsapp.head.link:rel="icon" type="image/svg+xml" href="/favicon.svg"andrel="mask-icon" href="/favicon.svg" color="#c2431c". Copywww/public/favicon.svgintosite/public/. - Wire the three call sites in the new repo:
- Docs catch-all (
src/pages/[...slug].astro):title= page H1/title fallback'Documentation',description= page description,section= portedsectionLabel. - Home (
src/pages/index.astro):title/descriptionfrom thehomedata entry'sseo,section: 'Home'. - Error/404 page:
title=`${code} — ${headline}`,section: 'Error', no description.
- Docs catch-all (
- Port the
sectionLabellogic from[...slug].vue(lines 5–37) for the OG cardsectionline:-
LANG_LABELSmap:csharp → 'C#',fsharp → 'F#',go → 'Go',java → 'Java',kotlin → 'Kotlin',php → 'PHP',python → 'Python',ruby → 'Ruby',swift → 'Swift',typescript → 'TypeScript',spec → 'Spec'(fallback: the raw lang segment). - First path segment = lang. If only the lang segment, section = label. If second segment is
guides→`${label} · Guides`; ifapi→`${label} · API`; if lang isspec→'Specification'; else →label.
-
OG image endpoint (replace ArcpCard.satori.vue + defineOgImage)
- Add a satori-based OG endpoint (per spec:
astro-og-canvas, or a customsatori+@resvg/resvg-jsAstro endpoint). It must accept the same three props as the card:title,description,section. - Reproduce
ArcpCard.satori.vueexactly — colors, sizes, and structure:- Canvas: paper background
#f5f1e8, default text#1b1815, paddingp-16(64px), flex column, space-between. - Top row (space-between): left wordmark
ARCP—font-size: 28px; font-weight: 600; letter-spacing: 0.04em; color: #1b1815;(IBM Plex Mono); rightsection—font-size: 18px; letter-spacing: 0.14em; text-transform: uppercase; color: #c2431c;. - Title
h1(Newsreader serif):font-size: 64px; font-weight: 500; line-height: 1.08; letter-spacing: -0.018em; color: #1b1815;, containermax-width: 960px. - Description
p(only if present):font-size: 26px; line-height: 1.45; color: #5a5448;, clamped to 2 lines (-webkit-line-clamp: 2). - Bottom vermilion rule:
height: 4px; width: 120px; background-color: #c2431c;.
- Canvas: paper background
- Carry over the prop defaults from the Vue component (lines 9–12):
title='Agent Runtime Control Protocol',description='A transport-agnostic wire protocol for submitting, observing, and controlling long-running AI agent jobs.',section='ARCP'. - Load the two fonts manually for satori (no auto-fetch in static build): IBM Plex Mono and Newsreader, weights 400/500/600 (matches
nuxt.config.tsogImage.fonts). The card uses weight 600 (wordmark) and 500 (title), so 500/600 are mandatory. Read the.woff/.ttffrom the installed Fontsource packages (@fontsource/ibm-plex-mono,@fontsource-variable/newsreader) or vendored font files. - Prerender one OG image per route (static
getStaticPaths-style enumeration over thedocscollection + home + error), and make the head partial'sog:image/twitter:imageresolve to the matching prerendered URL.
Sitemap
- Add
@astrojs/sitemapand setsiteinastro.config.*tohttps://agentruntimecontrolprotocol.io. - Ensure the sitemap enumerates every docs route + home — not just
/. The Nuxt build fedqueryCollection('docs').all()→{ loc: doc.path }(filtering out empty paths). Replicate: provide the full doc-path list to@astrojs/sitemap(it picks upgetStaticPaths-generated routes automatically, but verify the ~2,286 docs pages actually appear; add aserialize/customPagesfeed from thedocscollection if any are missing). - Filter out entries with empty/falsy paths (matches
.filter(doc => Boolean(doc.path))).
Lowercase 301 redirect
- Port
lowercase-redirect.tsas a Vercel redirect/middleware (preferred for a static build) or Astro middleware. Preserve the exact behavior:- Only act when the path contains an uppercase letter (
/[A-Z]/.test(path)); else no-op. - Skip paths starting with
/_,/__, or/api. - Skip static assets via the asset regex (copy verbatim):
/\.(svg|png|jpe?g|gif|webp|avif|ico|css|js|mjs|cjs|json|xml|txt|map|woff2?|ttf|eot|webmanifest|pdf)$/i. - Redirect the path (sans query) to its
.toLowerCase(), 301, re-appending the original query string.
- Only act when the path contains an uppercase letter (
- For the
vercel.jsonroute: confirm a single regex-based 301 rule can express "any path with an uppercase char, not matching the skip list" — if Vercel's matcher can't, fall back to enumerating per-route redirects from the doc list (every doc has a known canonical lowercase path) or use Astro middleware running at the edge.
Details & gotchas
-
WCAG-driven hardcoded hex. Every color in
ArcpCard.satori.vueis a literal hex (#f5f1e8,#1b1815,#c2431c,#5a5448) — satori does not resolve CSS custom properties, so these stay hardcoded and must match the Inkwell tokens exactly. Do not swap them forvar(--…). -
.satori.vueis misleading. The card is authored as a Vue SFC but satori renders a plain element tree, not Vue. Port the<template>markup to your endpoint's render format (JSX/h-tree) — the<script setup>only supplieswithDefaultsprop defaults. -
Description line-clamp. The card clamps the description to 2 lines (
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;). satori supportsdisplay: -webkit-box+ line-clamp; keep it so long descriptions don't overflow the card. -
og:imageis no longer automatic. Nuxt'sdefineOgImageinjected the image meta tags for you. After dropping it, the head partial must emitog:image/twitter:imageitself, or social embeds get no card. -
Canonical has no trailing slash.
useArcpSeobuilds${site.url}${route.path}; the site serves no-trailing-slash routes. Match Astro'strailingSlash/build.formatconfig soAstro.url.pathnameand the canonical agree (this is the same constraint flagged in the memory note about synced-doc link 404s). -
The lowercase redirect exists because of PascalCase API URLs. The middleware comment cites
/csharp/api/Arcp.AspNetCore— file-derived/shared URLs carry original casing while Nuxt Content served lowercase. In the Astro build, docs are generated at lowercase paths (the sync pipeline / README→index rename lowercases), so this redirect is still required for inbound links that use the original casing. The.toLowerCase()of the full path is safe here because asset and/apipaths are pre-skipped. -
Sitemap default would be wrong.
@astrojs/sitemapenumerates static routes, but verify the catch-all[...slug]actually emits all ~2,286 entries (kotlin ≈ 1,049, typescript ≈ 793). The Nuxt version needed an expliciturls.tssource precisely because auto-discovery only found/. -
OG font pitfall. satori needs raw font buffers; in a static build there's no font-fetch endpoint. Pull the
.woff/.ttffrom the Fontsource packages at build time. Only weights 400/500/600 are installed per Step 1 — the card needs 500 and 600, so don't request 700. -
Section label fallback.
LANG_LABELShas no entry for plaindocs/unknown roots; the code falls back to the raw segment, andspecmaps to'Spec'in the label map butsectionLabeloverrides the body of a multi-segment spec path to'Specification'. Preserve both.
Acceptance criteria
- Every rendered page (docs, home, error) emits
<title>,description,og:title/og:description/og:type=website/og:url,twitter:card=summary_large_image/twitter:title/twitter:description, andog:image/twitter:image. - Canonical
og:urlequalshttps://agentruntimecontrolprotocol.io+ the page path with no trailing slash. - Favicon and mask-icon (
color="#c2431c")<link>tags are present on every page;/favicon.svgresolves. - OG image endpoint renders a paper-bg card with
ARCPwordmark, uppercase vermilion section label, serif title, optional 2-line description, and the 120×4px vermilion rule — visually matchingArcpCard.satori.vueand using the correct fonts. -
sectionon the OG card matches the portedsectionLabellogic for sample routes (e.g./python/guides/...→Python · Guides,/csharp/api/...→C# · API,/spec/...→Specification,/→Home). -
sitemap.xml(or sitemap index) contains the home route and every docs route (spot-check count is in the thousands, not 1). - Requesting a miscased doc URL (e.g.
/csharp/api/Arcp.AspNetCore) returns 301 to its lowercase canonical, with query string preserved;/_*,/__*,/api*, and asset URLs are not redirected. -
pnpm buildsucceeds and the above hold on a Vercel preview deploy.
Dependencies & out of scope
- Depends on Step 0 (scaffold:
astro.configsite,@astrojs/sitemapinstalled, pnpm), Step 1 (design tokens + Fontsource fonts the OG card reuses), Step 2 (Markdoc/Shiki + thedocsandhomecollections this enumerates over), and Step 3 (the[...slug]route +sectionLabel/lang labels that supply per-pagetitle/section). Step 6 supplies the error page that consumes the SEO partial. - Out of scope: the Vercel project/Git-integration setup and domain cutover (Step 8); the regression crawl/audit gate (Step 9); search/
robots.txt/RSS (no search today per the spec; norobots.txtexists inwww/public— don't invent one unless Step 8 calls for it); any redesign of the OG card or meta beyond 1:1 parity withwww.
For the big picture, see /Users/nficano/code/arpc/site/docs/markdoc-migration.md (section "Step 7 — SEO, OG images, sitemap, redirects").
Part of #1 · Full spec: docs/markdoc-migration.md
Activity