Skip to content

Step 7 — SEO, OG images, sitemap & redirects #9

Open

Description

@nficano

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 the defineOgImage('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.vue template 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 the docs collection, not auto-discovery. Replicate its "enumerate every doc path" intent against Astro collections; drop the @nuxt/content queryCollection call.
  • /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 Nitro defineEventHandler/sendRedirect shell (replace with vercel.json redirects or Astro middleware).
  • /Users/nficano/code/arpc/www/nuxt.config.ts — source of the canonical site config and the OG font list. Copy url, name, description, and the font weights; drop the Nuxt ogImage/sitemap module config.

Additional context discovered while reading:

  • useArcpSeo is called from exactly three pages — capture each call's title/description/section:
    • /Users/nficano/code/arpc/www/app/pages/[...slug].vue:39 — title: page.title ?? 'Documentation', description: page.description, section: sectionLabel (computed; see LANG_LABELS + · Guides/· API/Specification logic 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.svg and the nuxt.config.ts app.head.link block (favicon image/svg+xml + mask-icon color #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.astro or a <head> slot in the base layout) taking props title: string, description?: string, section?: string.
  • Define canonical site config once (port from nuxt.config.ts site): 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.ts or astro.config site).
  • description defaults to the site description when a page omits it (matches getOptions().description ?? site.description).
  • Compute canonical url as site.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 useArcpSeo lines 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:image and twitter:image pointing at the OG endpoint for this route (the Nuxt version emitted these via defineOgImage — you must add them explicitly).
  • Add the favicon/mask-icon <link> tags from nuxt.config.ts app.head.link: rel="icon" type="image/svg+xml" href="/favicon.svg" and rel="mask-icon" href="/favicon.svg" color="#c2431c". Copy www/public/favicon.svg into site/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 = ported sectionLabel.
    • Home (src/pages/index.astro): title/description from the home data entry's seo, section: 'Home'.
    • Error/404 page: title = `${code} — ${headline}`, section: 'Error', no description.
  • Port the sectionLabel logic from [...slug].vue (lines 5–37) for the OG card section line:
    • LANG_LABELS map: 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`; if api → `${label} · API`; if lang is spec → 'Specification'; else → label.

OG image endpoint (replace ArcpCard.satori.vue + defineOgImage)

  • Add a satori-based OG endpoint (per spec: astro-og-canvas, or a custom satori + @resvg/resvg-js Astro endpoint). It must accept the same three props as the card: title, description, section.
  • Reproduce ArcpCard.satori.vue exactly — colors, sizes, and structure:
    • Canvas: paper background #f5f1e8, default text #1b1815, padding p-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); right section — 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;, container max-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;.
  • 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.ts ogImage.fonts). The card uses weight 600 (wordmark) and 500 (title), so 500/600 are mandatory. Read the .woff/.ttf from 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 the docs collection + home + error), and make the head partial's og:image/twitter:image resolve to the matching prerendered URL.

Sitemap

  • Add @astrojs/sitemap and set site in astro.config.* to https://agentruntimecontrolprotocol.io.
  • Ensure the sitemap enumerates every docs route + home — not just /. The Nuxt build fed queryCollection('docs').all() → { loc: doc.path } (filtering out empty paths). Replicate: provide the full doc-path list to @astrojs/sitemap (it picks up getStaticPaths-generated routes automatically, but verify the ~2,286 docs pages actually appear; add a serialize/customPages feed from the docs collection if any are missing).
  • Filter out entries with empty/falsy paths (matches .filter(doc => Boolean(doc.path))).

Lowercase 301 redirect

  • Port lowercase-redirect.ts as 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.
  • For the vercel.json route: 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.vue is 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 for var(--…).
  • .satori.vue is 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 supplies withDefaults prop 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 supports display: -webkit-box + line-clamp; keep it so long descriptions don't overflow the card.
  • og:image is no longer automatic. Nuxt's defineOgImage injected the image meta tags for you. After dropping it, the head partial must emit og:image/twitter:image itself, or social embeds get no card.
  • Canonical has no trailing slash. useArcpSeo builds ${site.url}${route.path}; the site serves no-trailing-slash routes. Match Astro's trailingSlash/build.format config so Astro.url.pathname and 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 /api paths are pre-skipped.
  • Sitemap default would be wrong. @astrojs/sitemap enumerates static routes, but verify the catch-all [...slug] actually emits all ~2,286 entries (kotlin ≈ 1,049, typescript ≈ 793). The Nuxt version needed an explicit urls.ts source 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/.ttf from 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_LABELS has no entry for plain docs/unknown roots; the code falls back to the raw segment, and spec maps to 'Spec' in the label map but sectionLabel overrides 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, and og:image/twitter:image.
  • Canonical og:url equals https://agentruntimecontrolprotocol.io + the page path with no trailing slash.
  • Favicon and mask-icon (color="#c2431c") <link> tags are present on every page; /favicon.svg resolves.
  • OG image endpoint renders a paper-bg card with ARCP wordmark, uppercase vermilion section label, serif title, optional 2-line description, and the 120×4px vermilion rule — visually matching ArcpCard.satori.vue and using the correct fonts.
  • section on the OG card matches the ported sectionLabel logic 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 build succeeds and the above hold on a Vercel preview deploy.

Dependencies & out of scope

  • Depends on Step 0 (scaffold: astro.config site, @astrojs/sitemap installed, pnpm), Step 1 (design tokens + Fontsource fonts the OG card reuses), Step 2 (Markdoc/Shiki + the docs and home collections this enumerates over), and Step 3 (the [...slug] route + sectionLabel/lang labels that supply per-page title/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; no robots.txt exists in www/public — don't invent one unless Step 8 calls for it); any redesign of the OG card or meta beyond 1:1 parity with www.

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

  1. added
    migrationNuxt → Astro+Markdoc migration
    seoSEO, OG images, sitemap
    infraBuild, sync, deploy infrastructure
    on Jun 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    infraBuild, sync, deploy infrastructuremigrationNuxt → Astro+Markdoc migrationseoSEO, OG images, sitemap

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions