diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa9c9595..decc9aea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,11 @@ jobs: - name: Build run: pnpm build + - name: Check built SEO + run: pnpm check:seo + env: + CHECK_REMOTE_ASSETS: "1" + vendor-guard: # vendor/ is written only by the sync-docs action. A human edit here is # silently overwritten on the next sync, so reject it at review time. diff --git a/astro.config.ts b/astro.config.ts index 5c0497b2..5969303d 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -8,6 +8,7 @@ import sentry from "@sentry/astro"; // generation, inlined from the former @rivet-dev/docs-theme package. import { docsPipeline } from './src/integrations/docs-pipeline'; import { redirects } from './redirects.mjs'; +import { getRouteSeoPolicy } from './src/lib/routeSeoPolicy'; // Wildcard sub-path redirects (`wildcardRedirects` in redirects.mjs) are applied // only at the Caddy layer in production. Astro's static output treats a redirect @@ -44,19 +45,9 @@ export default defineConfig({ applyBaseStyles: false, }), sitemap({ - // Cookbooks and comparison guides are intentionally hidden from the site - // and kept out of SEO, so exclude them from the sitemap. - // - // Self-host guides are generated for every product from one source and - // all canonicalize to the Actors variant, so only that variant belongs - // in the sitemap. Submitting the copies asks Google to index pages the - // canonical tag then excludes. - filter: (page) => - !page.includes('/api/') && - !page.includes('/internal/') && - !page.includes('/cookbook') && - !page.includes('/compare') && - !/\/(?!actors\/)[^/]+\/self-host\//.test(page), + // The same policy emits page-level robots directives and decides sitemap + // membership, so a hidden route cannot accidentally be submitted. + filter: (page) => getRouteSeoPolicy(page).sitemap, }), sentry({ project: "website", diff --git a/package.json b/package.json index 52cb71e8..5cece1d2 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "gen:markdown": "tsx ./scripts/generate-markdown.ts", "gen:skills": "tsx ./scripts/generate-skills.ts", "check:sitemap": "tsx ./scripts/check-sitemap.ts", + "check:seo": "tsx ./scripts/check-seo.ts", "assemble": "tsx ./scripts/assemble.ts", "predev": "pnpm assemble" }, @@ -82,6 +83,7 @@ "@types/escape-html": "^1.0.4", "@types/lodash": "^4.17.21", "eslint": "9.39.1", + "parse5": "^8.0.1", "playwright": "^1.57.0", "prettier": "^3.6.2", "prettier-plugin-tailwindcss": "^0.7.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18bbc2d5..31d6af63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -180,6 +180,9 @@ importers: eslint: specifier: 9.39.1 version: 9.39.1(jiti@1.21.7) + parse5: + specifier: ^8.0.1 + version: 8.0.1 playwright: specifier: ^1.57.0 version: 1.62.1 @@ -3536,6 +3539,10 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -4617,6 +4624,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -8931,6 +8941,8 @@ snapshots: entities@7.0.1: {} + entities@8.0.0: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -10421,6 +10433,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-browserify@1.0.1: {} path-data-parser@0.1.0: {} diff --git a/redirects.caddy b/redirects.caddy index a1920ccd..17fe66c6 100644 --- a/redirects.caddy +++ b/redirects.caddy @@ -71,8 +71,6 @@ redir /docs/general/skill /actors/docs/general/skill/{http.request.uri.prefixed_ redir /docs/general/skill/ /actors/docs/general/skill/{http.request.uri.prefixed_query} 301 redir /docs/general/wasm-vs-native-sdk /actors/docs/general/wasm-vs-native-sdk/{http.request.uri.prefixed_query} 301 redir /docs/general/wasm-vs-native-sdk/ /actors/docs/general/wasm-vs-native-sdk/{http.request.uri.prefixed_query} 301 -redir /docs/actors/helper-types /actors/docs/helper-types/{http.request.uri.prefixed_query} 301 -redir /docs/actors/helper-types/ /actors/docs/helper-types/{http.request.uri.prefixed_query} 301 redir /docs/actors/http-api /actors/docs/http-api/{http.request.uri.prefixed_query} 301 redir /docs/actors/http-api/ /actors/docs/http-api/{http.request.uri.prefixed_query} 301 redir /docs/actors /actors/docs/{http.request.uri.prefixed_query} 301 @@ -109,12 +107,8 @@ redir /docs/actors/quickstart/supabase /actors/docs/quickstart/supabase/{http.re redir /docs/actors/quickstart/supabase/ /actors/docs/quickstart/supabase/{http.request.uri.prefixed_query} 301 redir /docs/actors/request-handler /actors/docs/request-handler/{http.request.uri.prefixed_query} 301 redir /docs/actors/request-handler/ /actors/docs/request-handler/{http.request.uri.prefixed_query} 301 -redir /docs/actors/scaling /actors/docs/scaling/{http.request.uri.prefixed_query} 301 -redir /docs/actors/scaling/ /actors/docs/scaling/{http.request.uri.prefixed_query} 301 redir /docs/actors/schedule /actors/docs/schedule/{http.request.uri.prefixed_query} 301 redir /docs/actors/schedule/ /actors/docs/schedule/{http.request.uri.prefixed_query} 301 -redir /docs/actors/sharing-and-joining-state /actors/docs/sharing-and-joining-state/{http.request.uri.prefixed_query} 301 -redir /docs/actors/sharing-and-joining-state/ /actors/docs/sharing-and-joining-state/{http.request.uri.prefixed_query} 301 redir /docs/actors/sqlite-drizzle /actors/docs/sqlite-drizzle/{http.request.uri.prefixed_query} 301 redir /docs/actors/sqlite-drizzle/ /actors/docs/sqlite-drizzle/{http.request.uri.prefixed_query} 301 redir /docs/actors/sqlite /actors/docs/sqlite/{http.request.uri.prefixed_query} 301 @@ -239,6 +233,18 @@ redir /docs/actors/external-sql /actors/docs/state/{http.request.uri.prefixed_qu redir /docs/actors/external-sql/ /actors/docs/state/{http.request.uri.prefixed_query} 301 redir /docs/actors/raw-sql /actors/docs/sqlite/{http.request.uri.prefixed_query} 301 redir /docs/actors/raw-sql/ /actors/docs/sqlite/{http.request.uri.prefixed_query} 301 +redir /actors/docs/helper-types /actors/docs/types/{http.request.uri.prefixed_query} 301 +redir /actors/docs/helper-types/ /actors/docs/types/{http.request.uri.prefixed_query} 301 +redir /docs/actors/helper-types /actors/docs/types/{http.request.uri.prefixed_query} 301 +redir /docs/actors/helper-types/ /actors/docs/types/{http.request.uri.prefixed_query} 301 +redir /actors/docs/scaling /actors/docs/design-patterns/{http.request.uri.prefixed_query} 301 +redir /actors/docs/scaling/ /actors/docs/design-patterns/{http.request.uri.prefixed_query} 301 +redir /docs/actors/scaling /actors/docs/design-patterns/{http.request.uri.prefixed_query} 301 +redir /docs/actors/scaling/ /actors/docs/design-patterns/{http.request.uri.prefixed_query} 301 +redir /actors/docs/sharing-and-joining-state /actors/docs/design-patterns/{http.request.uri.prefixed_query} 301 +redir /actors/docs/sharing-and-joining-state/ /actors/docs/design-patterns/{http.request.uri.prefixed_query} 301 +redir /docs/actors/sharing-and-joining-state /actors/docs/design-patterns/{http.request.uri.prefixed_query} 301 +redir /docs/actors/sharing-and-joining-state/ /actors/docs/design-patterns/{http.request.uri.prefixed_query} 301 redir /docs/actors/ephemeral-variables /actors/docs/state/{http.request.uri.prefixed_query} 301 redir /docs/actors/ephemeral-variables/ /actors/docs/state/{http.request.uri.prefixed_query} 301 redir /docs/actors/persistence /actors/docs/state/{http.request.uri.prefixed_query} 301 @@ -397,9 +403,27 @@ redir /agent-os/docs/apps /dynamic-apps/docs/{http.request.uri.prefixed_query} 3 redir /agent-os/docs/apps/ /dynamic-apps/docs/{http.request.uri.prefixed_query} 301 redir /sales /talk-to-an-engineer/{http.request.uri.prefixed_query} 301 redir /sales/ /talk-to-an-engineer/{http.request.uri.prefixed_query} 301 -redir /registry/* /agentos{http.request.uri.path}{http.request.uri.prefixed_query} 301 -redir /typedoc/* /actors/docs{http.request.uri.prefixed_query} 301 -redir /learn/* /actors{http.request.uri.path}{http.request.uri.prefixed_query} 301 -redir /compare/* /actors{http.request.uri.path}{http.request.uri.prefixed_query} 301 -redir /docs/agent-os/* /agentos/docs{http.request.uri.prefixed_query} 301 -redir /agent-os/* /agentos{http.request.uri.prefixed_query} 301 +@wildcard0Trailing path /registry/*/ +redir @wildcard0Trailing /agentos{http.request.uri.path}{http.request.uri.prefixed_query} 301 +@wildcard0Bare { + path /registry/* + not path */ +} +redir @wildcard0Bare /agentos{http.request.uri.path}/{http.request.uri.prefixed_query} 301 +redir /typedoc/* /actors/docs/{http.request.uri.prefixed_query} 301 +@wildcard2Trailing path /learn/*/ +redir @wildcard2Trailing /actors{http.request.uri.path}{http.request.uri.prefixed_query} 301 +@wildcard2Bare { + path /learn/* + not path */ +} +redir @wildcard2Bare /actors{http.request.uri.path}/{http.request.uri.prefixed_query} 301 +@wildcard3Trailing path /compare/*/ +redir @wildcard3Trailing /actors{http.request.uri.path}{http.request.uri.prefixed_query} 301 +@wildcard3Bare { + path /compare/* + not path */ +} +redir @wildcard3Bare /actors{http.request.uri.path}/{http.request.uri.prefixed_query} 301 +redir /docs/agent-os/* /agentos/docs/{http.request.uri.prefixed_query} 301 +redir /agent-os/* /agentos/{http.request.uri.prefixed_query} 301 diff --git a/redirects.mjs b/redirects.mjs index 4b62e0c1..3eee4013 100644 --- a/redirects.mjs +++ b/redirects.mjs @@ -55,6 +55,14 @@ const explicitRedirects = { '/docs/actors/scheduling': '/actors/docs/schedule/', '/docs/actors/external-sql': '/actors/docs/state/', '/docs/actors/raw-sql': '/actors/docs/sqlite/', + // Empty moved-page stubs were retired. Preserve both their current product + // URLs and legacy flat-docs spellings as one-hop redirects. + '/actors/docs/helper-types': '/actors/docs/types/', + '/docs/actors/helper-types': '/actors/docs/types/', + '/actors/docs/scaling': '/actors/docs/design-patterns/', + '/docs/actors/scaling': '/actors/docs/design-patterns/', + '/actors/docs/sharing-and-joining-state': '/actors/docs/design-patterns/', + '/docs/actors/sharing-and-joining-state': '/actors/docs/design-patterns/', '/docs/actors/ephemeral-variables': '/actors/docs/state/', '/docs/actors/persistence': '/actors/docs/state/', '/docs/actors/postgres': '/actors/docs/state/', diff --git a/scripts/check-seo.ts b/scripts/check-seo.ts new file mode 100644 index 00000000..9eef5b8d --- /dev/null +++ b/scripts/check-seo.ts @@ -0,0 +1,807 @@ +/** + * Crawls the static build and enforces the SEO invariants that are otherwise + * easy to break across Astro routes, product-owned docs, and shared chrome. + * + * Run after `pnpm build`. Set CHECK_REMOTE_ASSETS=1 to verify first-party + * remote images as well as files emitted into dist/. + */ +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import fg from "fast-glob"; +import { parse } from "parse5"; + +import { redirects, wildcardRedirects } from "../redirects.mjs"; + +interface HtmlAttribute { + name: string; + value: string; +} + +interface HtmlNode { + nodeName: string; + tagName?: string; + attrs?: HtmlAttribute[]; + childNodes?: HtmlNode[]; + content?: HtmlNode; + value?: string; +} + +interface Page { + file: string; + route: string; + root: HtmlNode; + redirect: boolean; + robots: string; + canonical?: URL; + canonicalPage: boolean; + indexable: boolean; +} + +const ROOT = fileURLToPath(new URL("..", import.meta.url)); +const DIST = path.join(ROOT, "dist"); +const SITE_ORIGIN = "https://rivet.dev"; +const MIN_TITLE = 20; +const MAX_TITLE = 65; +const MIN_DESCRIPTION = 100; +const MAX_DESCRIPTION = 160; +const MAX_STATIC_POST_IMAGE_BYTES = 350_000; +const MAX_ANIMATED_POST_IMAGE_BYTES = 900_000; +const MAX_IMAGE_BYTES = 1_000_000; + +// These paths intentionally leave the site at the edge and therefore do not +// have a generated HTML target. `/discord` is the public short link retained +// for dated posts and support documentation. +const ALLOWED_EDGE_REDIRECT_LINKS = new Set(["/discord", "/discord/"]); + +// Dated posts are records, so their original links remain in the article body +// and are served by the legacy redirect map. Keep the exemption explicit: a +// newly introduced redirect link in a post must be reviewed and added here. +const ALLOWED_HISTORICAL_REDIRECT_LINKS = new Set([ + "/docs/actors", + "/docs/actors/", + "/docs/actors/actions/", + "/docs/actors/ai-and-user-generated-actors/", + "/docs/actors/authentication/", + "/docs/actors/communicating-between-actors/", + "/docs/actors/connections/", + "/docs/actors/crash-course/", + "/docs/actors/debugging/", + "/docs/actors/ephemeral-variables/", + "/docs/actors/events/", + "/docs/actors/fetch-and-websocket-handler/", + "/docs/actors/keys/", + "/docs/actors/lifecycle/", + "/docs/actors/queues/", + "/docs/actors/quickstart/", + "/docs/actors/quickstart/effect/", + "/docs/actors/quickstart/next-js/", + "/docs/actors/quickstart/rust/", + "/docs/actors/scaling/", + "/docs/actors/schedule/", + "/docs/actors/sqlite-drizzle/", + "/docs/actors/sqlite/", + "/docs/actors/state/", + "/docs/actors/websocket-handler/", + "/docs/actors/workflows/", + "/docs/cli/", + "/docs/clients/javascript/", + "/docs/clients/react/", + "/docs/clients/swift/", + "/docs/clients/swiftui/", + "/docs/cloud/", + "/docs/cloud/api/actors/create/", + "/docs/cloud/api/routes/update/", + "/docs/cloud/self-hosting/single-container/", + "/docs/deploy/", + "/docs/deploy/rivet-compute/", + "/docs/self-hosting/", + "/integrations/better-auth/", + "/pricing/", + "/sales/", +]); + +const errors: string[] = []; +const warnings: string[] = []; + +const AUDITED_OVERSIZED_IMAGES = new Set([ + "https://assets.rivet.dev/website/blog/2026-07-20-introducing-cron-jobs-for-rivet-actors/image.png", + "https://assets.rivet.dev/website/blog/2025-1-12-rivet-inspector/rivet-inspector-launch.gif", + "https://assets.rivet.dev/website/blog/2026-07-28-introducing-agentos-execution-api-for-javascript-and-python/image.png", + "https://assets.rivet.dev/website/blog/2026-07-06-introducing-the-agentos-package-registry/image.png", + "https://assets.rivet.dev/website/blog/2026-06-17-introducing-the-rust-sdk/image.png", + "https://assets.rivet.dev/website/blog/2026-06-15-introducing-rivet-2-3/image.png", + "https://assets.rivet.dev/website/blog/2024-12-21-rivet-actors-launch/image.png", + "https://assets.rivet.dev/website/blog/godot-multiplayer-compared-to-unity/image.png", + "https://assets.rivet.dev/website/blog/2026-07-26-sandboxes-vs-webassembly-lambda-vs-workers-round-two/software-registry.png", + "https://assets.rivet.dev/website/blog/2026-06-16-introducing-the-effect-sdk/image.png", + "https://assets.rivet.dev/website/blog/2026-06-19-secure-exec-v0-3/image.png", +]); + +const redirectPaths = new Set(); +for (const from of Object.keys(redirects)) { + const bare = from === "/" ? "/" : from.replace(/\/$/, ""); + redirectPaths.add(bare); + redirectPaths.add(bare === "/" ? "/" : `${bare}/`); +} + +function walk(node: HtmlNode, visit: (node: HtmlNode) => void) { + visit(node); + for (const child of node.childNodes ?? []) walk(child, visit); + if (node.content) walk(node.content, visit); +} + +function elements(root: HtmlNode, tagName: string): HtmlNode[] { + const result: HtmlNode[] = []; + walk(root, (node) => { + if (node.tagName === tagName) result.push(node); + }); + return result; +} + +function attr(node: HtmlNode, name: string): string | undefined { + return node.attrs?.find((item) => item.name === name)?.value; +} + +function hasAttr(node: HtmlNode, name: string): boolean { + return node.attrs?.some((item) => item.name === name) ?? false; +} + +function textContent(node: HtmlNode): string { + if (node.nodeName === "#text") return node.value ?? ""; + return (node.childNodes ?? []).map(textContent).join(""); +} + +function normalizedText(node: HtmlNode): string { + return textContent(node).replace(/\s+/g, " ").trim(); +} + +function routeForFile(file: string): string { + const relative = path.relative(DIST, file).split(path.sep).join("/"); + if (relative === "index.html") return "/"; + if (relative.endsWith("/index.html")) { + return `/${relative.slice(0, -"index.html".length)}`; + } + return `/${relative}`; +} + +function isDirectoryPath(pathname: string): boolean { + if (pathname === "/") return true; + const lastSegment = pathname.slice(pathname.lastIndexOf("/") + 1); + return !lastSegment.includes("."); +} + +function ensureDirectorySlash(pathname: string): string { + if (!isDirectoryPath(pathname) || pathname.endsWith("/")) return pathname; + return `${pathname}/`; +} + +function pathFromHref(href: string, pageRoute: string): string | null { + if (!href || href.startsWith("#")) return null; + if (/^(?:mailto|tel|javascript|data):/i.test(href)) return null; + try { + const url = new URL(href, `${SITE_ORIGIN}${pageRoute}`); + if (url.origin !== SITE_ORIGIN) return null; + return url.pathname; + } catch { + return null; + } +} + +function isRedirectPath(pathname: string): boolean { + if (redirectPaths.has(pathname)) return true; + return wildcardRedirects.some(({ from }) => { + const prefix = from.endsWith("/") ? from : `${from}/`; + return pathname.startsWith(prefix); + }); +} + +function isHistoricalPage(route: string): boolean { + return /^\/(?:blog|changelog)\//.test(route); +} + +function metaByName(root: HtmlNode, name: string): HtmlNode[] { + return elements(root, "meta").filter( + (node) => attr(node, "name")?.toLowerCase() === name.toLowerCase(), + ); +} + +function metaByProperty(root: HtmlNode, property: string): HtmlNode[] { + return elements(root, "meta").filter( + (node) => attr(node, "property")?.toLowerCase() === property.toLowerCase(), + ); +} + +function canonicalNodes(root: HtmlNode): HtmlNode[] { + return elements(root, "link").filter((node) => + (attr(node, "rel") ?? "").toLowerCase().split(/\s+/).includes("canonical"), + ); +} + +function hasMarkdown(value: string): boolean { + return /(?:\*\*|__|`|\[[^\]]+\]\([^)]+\)|(?:^|\s)#{1,6}\s)/.test(value); +} + +function schemaTypes(value: unknown, out = new Set()): Set { + if (Array.isArray(value)) { + for (const item of value) schemaTypes(item, out); + return out; + } + if (!value || typeof value !== "object") return out; + for (const [key, child] of Object.entries(value)) { + if (key === "@type") { + for (const type of Array.isArray(child) ? child : [child]) { + if (typeof type === "string") out.add(type); + } + } else { + schemaTypes(child, out); + } + } + return out; +} + +function schemaContainsMarkdown(value: unknown): boolean { + if (typeof value === "string") return hasMarkdown(value); + if (Array.isArray(value)) return value.some(schemaContainsMarkdown); + if (!value || typeof value !== "object") return false; + return Object.values(value).some(schemaContainsMarkdown); +} + +function localAssetPath(urlPath: string): string { + return path.join(DIST, decodeURIComponent(urlPath.replace(/^\//, ""))); +} + +async function fileSize(urlPath: string): Promise { + try { + const stats = await stat(localAssetPath(urlPath)); + return stats.isFile() ? stats.size : null; + } catch { + return null; + } +} + +async function isAnimatedWebp(urlPath: string): Promise { + try { + const bytes = await readFile(localAssetPath(urlPath)); + return ( + bytes.subarray(0, 4).toString("ascii") === "RIFF" && + bytes.subarray(8, 12).toString("ascii") === "WEBP" && + bytes.includes(Buffer.from("ANIM")) + ); + } catch { + return false; + } +} + +function imageUrls(node: HtmlNode): string[] { + const urls = new Set(); + const src = attr(node, "src"); + if (src) urls.add(src); + const srcset = attr(node, "srcset"); + if (srcset) { + for (const candidate of srcset.split(",")) { + const url = candidate.trim().split(/\s+/)[0]; + if (url) urls.add(url); + } + } + return [...urls]; +} + +function srcsetWidths(node: HtmlNode): number[] { + const srcset = attr(node, "srcset"); + if (!srcset) return []; + return srcset + .split(",") + .map((candidate) => candidate.trim().match(/\s(\d+)w$/)?.[1]) + .filter((width): width is string => Boolean(width)) + .map(Number) + .sort((a, b) => a - b); +} + +function sameNumbers(actual: number[], expected: number[]): boolean { + return ( + actual.length === expected.length && + actual.every((value, index) => value === expected[index]) + ); +} + +function normalizedAuditedImage(url: string): string | null { + try { + const parsed = new URL(url, SITE_ORIGIN); + if (parsed.origin !== "https://assets.rivet.dev") return null; + return `${parsed.origin}${parsed.pathname}`; + } catch { + return null; + } +} + +function remotePayloadSize(response: Response): number | null { + const contentRange = response.headers.get("content-range"); + const rangeTotal = contentRange?.match(/\/(\d+)$/)?.[1]; + if (rangeTotal) return Number(rangeTotal); + const contentLength = response.headers.get("content-length"); + return contentLength ? Number(contentLength) : null; +} + +async function checkFirstPartyRemoteImage(url: string): Promise { + const parsed = new URL(url); + if (parsed.hostname !== "assets.rivet.dev") return null; + + let lastStatus: number | undefined; + for (let attempt = 0; attempt < 3; attempt++) { + try { + let response = await fetch(url, { + method: "HEAD", + headers: { "user-agent": "rivet-seo-check/1.0" }, + signal: AbortSignal.timeout(10_000), + }); + if ( + response.status === 403 || + response.status === 405 || + (response.ok && remotePayloadSize(response) === null) + ) { + response = await fetch(url, { + headers: { + "user-agent": "rivet-seo-check/1.0", + range: "bytes=0-0", + }, + signal: AbortSignal.timeout(10_000), + }); + } + lastStatus = response.status; + if (response.ok || response.status === 206) { + const size = remotePayloadSize(response); + await response.body?.cancel(); + if (size === null || !Number.isFinite(size)) { + return `${url} did not expose a verifiable payload size`; + } + if (size >= MAX_IMAGE_BYTES) { + return `${url} is ${size} bytes (limit is under ${MAX_IMAGE_BYTES})`; + } + return null; + } + } catch { + // Retry transient network failures before reporting the URL. + } + } + return `${url} returned ${lastStatus ?? "a network error"}`; +} + +async function mapWithConcurrency( + values: T[], + limit: number, + worker: (value: T) => Promise, +): Promise { + const results = new Array(values.length); + let cursor = 0; + async function run() { + while (cursor < values.length) { + const index = cursor++; + results[index] = await worker(values[index]); + } + } + await Promise.all( + Array.from({ length: Math.min(limit, values.length) }, () => run()), + ); + return results; +} + +const htmlFiles = await fg("**/*.html", { cwd: DIST, absolute: true }); +if (htmlFiles.length === 0) { + console.error( + "SEO check failed: dist/ contains no HTML. Run `pnpm build` first.", + ); + process.exit(1); +} + +const pages: Page[] = []; +for (const file of htmlFiles) { + const route = routeForFile(file); + const root = parse(await readFile(file, "utf8")) as unknown as HtmlNode; + const redirect = elements(root, "meta").some( + (node) => attr(node, "http-equiv")?.toLowerCase() === "refresh", + ); + const robots = + attr(metaByName(root, "robots")[0] ?? { nodeName: "" }, "content") ?? ""; + const canonicals = canonicalNodes(root); + let canonical: URL | undefined; + if (!redirect && canonicals.length !== 1) { + errors.push( + `${route}: expected one canonical link, found ${canonicals.length}`, + ); + } else if (canonicals.length === 1) { + const href = attr(canonicals[0], "href"); + try { + canonical = href ? new URL(href, SITE_ORIGIN) : undefined; + } catch { + errors.push(`${route}: canonical URL is invalid: ${href}`); + } + if (canonical?.origin !== SITE_ORIGIN) { + errors.push(`${route}: canonical must use ${SITE_ORIGIN}`); + } + if ( + canonical && + ensureDirectorySlash(canonical.pathname) !== canonical.pathname + ) { + errors.push(`${route}: canonical directory URL needs a trailing slash`); + } + } + const canonicalPage = canonical?.pathname === route; + const indexable = !robots.toLowerCase().includes("noindex") && canonicalPage; + pages.push({ + file, + route, + root, + redirect, + robots, + canonical, + canonicalPage, + indexable, + }); +} + +const pageRoutes = new Set(pages.map((page) => page.route)); +const sitemapFile = path.join(DIST, "sitemap-0.xml"); +let sitemapXml = ""; +try { + sitemapXml = await readFile(sitemapFile, "utf8"); +} catch { + errors.push("sitemap-0.xml is missing from dist/"); +} +const sitemapPaths = new Set( + [...sitemapXml.matchAll(/([^<]+)<\/loc>/g)].map((match) => { + try { + return new URL(match[1]).pathname; + } catch { + return match[1]; + } + }), +); + +const titleOwners = new Map(); +const descriptionOwners = new Map(); +const incoming = new Map>(); +const firstPartyRemoteImages = new Set(); + +for (const page of pages) { + if (page.redirect) { + if (sitemapPaths.has(page.route)) { + errors.push(`${page.route}: redirect page appears in the sitemap`); + } + continue; + } + + const titles = elements(page.root, "title"); + const descriptions = metaByName(page.root, "description"); + const h1s = elements(page.root, "h1").filter((node) => normalizedText(node)); + const title = titles.length === 1 ? normalizedText(titles[0]) : ""; + const description = + descriptions.length === 1 + ? (attr(descriptions[0], "content")?.trim() ?? "") + : ""; + + if (page.indexable && (titles.length !== 1 || !title)) { + errors.push( + `${page.route}: expected one non-empty title, found ${titles.length}`, + ); + } + if (page.indexable && (descriptions.length !== 1 || !description)) { + errors.push( + `${page.route}: expected one non-empty meta description, found ${descriptions.length}`, + ); + } + if (page.indexable && h1s.length !== 1) { + errors.push( + `${page.route}: expected one non-empty H1, found ${h1s.length}`, + ); + } + + if (page.indexable) { + for (const name of [ + "twitter:card", + "twitter:title", + "twitter:description", + "twitter:image", + ]) { + if (metaByName(page.root, name).length !== 1) { + errors.push(`${page.route}: expected one ${name} meta tag`); + } + } + for (const property of [ + "og:title", + "og:description", + "og:url", + "og:image", + ]) { + if (metaByProperty(page.root, property).length !== 1) { + errors.push(`${page.route}: expected one ${property} meta tag`); + } + } + } + + const noindex = page.robots.toLowerCase().includes("noindex"); + const nofollow = page.robots.toLowerCase().includes("nofollow"); + if (page.route.includes("/compare/") && (!noindex || nofollow)) { + errors.push(`${page.route}: comparison pages must use noindex, follow`); + } + if ((noindex || !page.canonicalPage) && sitemapPaths.has(page.route)) { + errors.push(`${page.route}: non-indexable page appears in the sitemap`); + } + if (page.indexable && !sitemapPaths.has(page.route)) { + errors.push( + `${page.route}: indexable canonical page is absent from the sitemap`, + ); + } + + if (page.indexable) { + if (title.length < MIN_TITLE || title.length > MAX_TITLE) { + errors.push( + `${page.route}: title length ${title.length} is outside ${MIN_TITLE}-${MAX_TITLE}`, + ); + } + if ( + description.length < MIN_DESCRIPTION || + description.length > MAX_DESCRIPTION + ) { + errors.push( + `${page.route}: description length ${description.length} is outside ${MIN_DESCRIPTION}-${MAX_DESCRIPTION}`, + ); + } + if (hasMarkdown(title)) { + errors.push(`${page.route}: title contains Markdown syntax`); + } + if (hasMarkdown(description)) { + errors.push(`${page.route}: meta description contains Markdown syntax`); + } + + const priorTitle = titleOwners.get(title); + if (priorTitle) + errors.push(`${page.route}: duplicates title from ${priorTitle}`); + else titleOwners.set(title, page.route); + + const priorDescription = descriptionOwners.get(description); + if (priorDescription) { + errors.push( + `${page.route}: duplicates meta description from ${priorDescription}`, + ); + } else { + descriptionOwners.set(description, page.route); + } + } + + for (const script of elements(page.root, "script").filter( + (node) => attr(node, "type") === "application/ld+json", + )) { + try { + const schema = JSON.parse(textContent(script)); + const types = schemaTypes(schema); + for (const forbidden of ["SoftwareApplication", "SearchAction"]) { + if (types.has(forbidden)) { + errors.push(`${page.route}: JSON-LD contains forbidden ${forbidden}`); + } + } + if (schemaContainsMarkdown(schema)) { + errors.push(`${page.route}: JSON-LD metadata contains Markdown syntax`); + } + } catch (error) { + errors.push(`${page.route}: invalid JSON-LD (${String(error)})`); + } + } + + for (const anchor of elements(page.root, "a")) { + const href = attr(anchor, "href"); + if (!href) continue; + const pathname = pathFromHref(href, page.route); + if (!pathname) continue; + if (ALLOWED_EDGE_REDIRECT_LINKS.has(pathname)) continue; + + const redirectLink = isRedirectPath(pathname); + const allowedHistoricalRedirect = + isHistoricalPage(page.route) && + ALLOWED_HISTORICAL_REDIRECT_LINKS.has(pathname); + if (redirectLink && !allowedHistoricalRedirect) { + errors.push(`${page.route}: links to redirect ${pathname}`); + } + if ( + !redirectLink && + isDirectoryPath(pathname) && + pathname !== "/" && + !pathname.endsWith("/") + ) { + errors.push( + `${page.route}: internal directory link lacks trailing slash: ${href}`, + ); + } + + const canonicalTarget = ensureDirectorySlash(pathname); + if (!redirectLink && canonicalTarget !== page.route) { + incoming.set( + canonicalTarget, + (incoming.get(canonicalTarget) ?? new Set()).add(page.route), + ); + } + + if ( + !redirectLink && + !pageRoutes.has(canonicalTarget) && + (await fileSize(pathname)) === null + ) { + errors.push( + `${page.route}: internal link target does not exist: ${href}`, + ); + } + } + + for (const image of elements(page.root, "img")) { + if (!hasAttr(image, "alt")) { + errors.push( + `${page.route}: image is missing an alt attribute (${attr(image, "src") ?? "unknown src"})`, + ); + } + + const postVariant = attr(image, "data-post-image"); + const postFormat = attr(image, "data-post-image-format"); + const blogContentImage = hasAttr(image, "data-blog-content-image"); + if ( + (postVariant || blogContentImage) && + (!attr(image, "srcset") || !attr(image, "sizes")) + ) { + errors.push( + `${page.route}: optimized content image is missing srcset or sizes`, + ); + } + if (postVariant || blogContentImage) { + const width = Number(attr(image, "width")); + const height = Number(attr(image, "height")); + if ( + !Number.isFinite(width) || + width <= 0 || + !Number.isFinite(height) || + height <= 0 + ) { + errors.push( + `${page.route}: optimized content image needs positive width and height`, + ); + } + if (attr(image, "decoding") !== "async") { + errors.push( + `${page.route}: optimized content image must decode asynchronously`, + ); + } + } + if (postVariant === "hero" && attr(image, "loading") !== "eager") { + errors.push(`${page.route}: post hero image must load eagerly`); + } + if ( + (postVariant === "card" || blogContentImage) && + attr(image, "loading") !== "lazy" + ) { + errors.push(`${page.route}: non-hero content image must load lazily`); + } + if (postVariant) { + const profileWidths = + postFormat === "gif" + ? postVariant === "hero" + ? [400, 600, 800] + : [320, 480] + : postVariant === "hero" + ? [480, 800, 1200, 1600] + : [320, 480, 640, 800]; + const targetWidth = Number(attr(image, "width")); + const expectedWidths = profileWidths.filter( + (candidate) => candidate <= targetWidth, + ); + if (!expectedWidths.includes(targetWidth)) + expectedWidths.push(targetWidth); + const widths = srcsetWidths(image); + if (!sameNumbers(widths, expectedWidths)) { + errors.push( + `${page.route}: ${postVariant} post image has srcset widths ${widths.join(",") || "none"}; expected ${expectedWidths.join(",")}`, + ); + } + } + if (blogContentImage) { + const widths = srcsetWidths(image); + const expectedWidths = [480, 800, 1200, 1600]; + if (!sameNumbers(widths, expectedWidths)) { + errors.push( + `${page.route}: blog content image has srcset widths ${widths.join(",") || "none"}; expected ${expectedWidths.join(",")}`, + ); + } + } + + for (const url of imageUrls(image)) { + const audited = normalizedAuditedImage(url); + if (audited && AUDITED_OVERSIZED_IMAGES.has(audited)) { + errors.push(`${page.route}: renders audited oversized original ${url}`); + } + + let parsed: URL; + try { + parsed = new URL(url, SITE_ORIGIN); + } catch { + errors.push(`${page.route}: invalid image URL ${url}`); + continue; + } + if (parsed.origin === SITE_ORIGIN) { + const size = await fileSize(parsed.pathname); + if (size === null) { + errors.push(`${page.route}: local image does not exist: ${url}`); + continue; + } + if ( + postFormat === "gif" && + (!parsed.pathname.endsWith(".webp") || + !(await isAnimatedWebp(parsed.pathname))) + ) { + errors.push( + `${page.route}: GIF-derived rendition is not an animated WebP: ${url}`, + ); + } + let limit = MAX_IMAGE_BYTES; + if (postVariant || blogContentImage) { + limit = + postFormat === "gif" + ? MAX_ANIMATED_POST_IMAGE_BYTES + : MAX_STATIC_POST_IMAGE_BYTES; + } + const overLimit = + limit === MAX_IMAGE_BYTES ? size >= limit : size > limit; + if (overLimit) { + errors.push( + `${page.route}: image ${url} is ${size} bytes (limit ${limit})`, + ); + } + } else if (parsed.hostname === "assets.rivet.dev") { + firstPartyRemoteImages.add(parsed.href); + } + } + } +} + +for (const sitemapPath of sitemapPaths) { + if (!pageRoutes.has(sitemapPath)) { + errors.push(`${sitemapPath}: sitemap URL has no generated HTML page`); + } +} + +for (const page of pages.filter( + (item) => item.indexable && item.route !== "/", +)) { + if ((incoming.get(page.route)?.size ?? 0) === 0) { + errors.push( + `${page.route}: indexable canonical page has no incoming internal links`, + ); + } +} + +if (process.env.CHECK_REMOTE_ASSETS === "1") { + const remoteErrors = await mapWithConcurrency( + [...firstPartyRemoteImages], + 8, + checkFirstPartyRemoteImage, + ); + for (const error of remoteErrors) { + if (error) errors.push(`remote image: ${error}`); + } +} else if (firstPartyRemoteImages.size > 0) { + warnings.push( + `${firstPartyRemoteImages.size} first-party remote image(s) were not fetched; set CHECK_REMOTE_ASSETS=1 to verify them`, + ); +} + +if (warnings.length > 0) { + console.warn(`SEO check warnings (${warnings.length}):`); + for (const warning of warnings) console.warn(` - ${warning}`); +} + +if (errors.length > 0) { + console.error(`SEO check failed with ${errors.length} problem(s):`); + for (const error of errors) console.error(` - ${error}`); + process.exit(1); +} + +console.log( + `SEO check passed: ${pages.length} HTML pages, ${sitemapPaths.size} sitemap URLs, ${firstPartyRemoteImages.size} first-party remote images`, +); diff --git a/scripts/check-sitemap.ts b/scripts/check-sitemap.ts index cdaa9757..d8931570 100644 --- a/scripts/check-sitemap.ts +++ b/scripts/check-sitemap.ts @@ -10,13 +10,18 @@ * 2. Every self-host sidebar href resolves to a guide the matrix generates. * 3. Every product docs/tutorials sidebar href resolves to a content file. * 4. Every sidebar href appears in exactly one tab. + * 5. Every routed, indexable product-doc page has a sidebar or content inlink. * * Run with `pnpm check:sitemap`. */ -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import fg from "fast-glob"; + +import { redirects, wildcardRedirects } from "../redirects.mjs"; +import { canonicalizeInternalHref } from "../src/lib/internalHref"; import { guidesFor } from "../src/sitemap/deployMatrix"; import { products } from "../src/sitemap/products"; import type { AnyPage, SidebarItem } from "../src/lib/sitemap"; @@ -27,99 +32,177 @@ const DOCS_CONTENT = path.join(ROOT, "src/content/docs"); const errors: string[] = []; +function normalizeHref(href: string) { + return canonicalizeInternalHref(href); +} + +function isRedirectRoute(href: string): boolean { + const bare = href === "/" ? "/" : href.replace(/\/$/, ""); + if (bare in redirects) return true; + return wildcardRedirects.some(({ from }) => + bare.startsWith(`${from.replace(/\/$/, "")}/`), + ); +} + function collectHrefs(items: (SidebarItem | AnyPage)[], out: string[] = []) { - for (const item of items) { - if ("href" in item && typeof item.href === "string") { - const external = - ("external" in item && item.external) || item.href.startsWith("http"); - if (!external) out.push(item.href); - } - if ("pages" in item && Array.isArray(item.pages)) { - collectHrefs(item.pages, out); - } - } - return out; + for (const item of items) { + if ("href" in item && typeof item.href === "string") { + const external = + ("external" in item && item.external) || item.href.startsWith("http"); + if (!external) out.push(normalizeHref(item.href)); + } + if ("pages" in item && Array.isArray(item.pages)) { + collectHrefs(item.pages, out); + } + } + return out; +} + +function collectContentInlinks(): Map> { + const inlinks = new Map>(); + for (const file of fg.sync("*/*/**/*.mdx", { cwd: DOCS_CONTENT })) { + const [product, tab, ...rest] = file.split("/"); + const stem = rest + .join("/") + .replace(/\.mdx$/, "") + .replace(/(^|\/)index$/, ""); + const sourceRoute = normalizeHref( + stem ? `/${product}/${tab}/${stem}` : `/${product}/${tab}`, + ); + const source = readFileSync(path.join(DOCS_CONTENT, file), "utf8"); + const hrefs = [ + ...source.matchAll(/\]\((\/[^)\s]+)(?:\s+["'][^)]*["'])?\)/g), + ...source.matchAll(/\bhref\s*=\s*["'](\/[^"']+)["']/g), + ]; + for (const match of hrefs) { + const pathname = match[1].split(/[?#]/, 1)[0]; + const target = normalizeHref(pathname); + if (target === sourceRoute) continue; + const owners = inlinks.get(target) ?? new Set(); + owners.add(sourceRoute); + inlinks.set(target, owners); + } + } + return inlinks; } /** `workers/kubernetes` -> src/content/self-host/workers/kubernetes.mdx */ function contentFileExists(base: string, slug: string) { - const stem = slug === "index" || slug === "" ? "index" : slug; - return ( - existsSync(path.join(base, `${stem}.mdx`)) || - existsSync(path.join(base, stem, "index.mdx")) - ); + const stem = slug === "index" || slug === "" ? "index" : slug; + return ( + existsSync(path.join(base, `${stem}.mdx`)) || + existsSync(path.join(base, stem, "index.mdx")) + ); } /** Products that actually render the Self-Host tab. Must match the route. */ const selfHostProducts = products.filter((product) => - product.tabs.some((tab) => tab.id === "self-host"), + product.tabs.some((tab) => tab.id === "self-host"), ); // 1. Every guide the matrix promises has a source file. for (const product of selfHostProducts) { - for (const guide of guidesFor(product.id)) { - if (!contentFileExists(SELF_HOST_CONTENT, guide.slug)) { - errors.push( - `matrix promises ${product.id}/self-host/${guide.slug} but src/content/self-host/${guide.slug}.mdx is missing`, - ); - } - } + for (const guide of guidesFor(product.id)) { + if (!contentFileExists(SELF_HOST_CONTENT, guide.slug)) { + errors.push( + `matrix promises ${product.id}/self-host/${guide.slug} but src/content/self-host/${guide.slug}.mdx is missing`, + ); + } + } } // 2 + 3. Every sidebar href resolves to something that gets generated. const seen = new Map(); +const contentInlinks = collectContentInlinks(); for (const product of products) { - for (const tab of product.tabs) { - const generated = new Set( - tab.id === "self-host" - ? guidesFor(product.id).map((guide) => - guide.slug === "index" - ? tab.href - : `${tab.href}/${guide.slug}`, - ) - : [], - ); - - for (const href of collectHrefs(tab.sidebar)) { - // 4. No href may belong to two tabs. - const owner = `${product.id}:${tab.id}`; - const previous = seen.get(href); - if (previous && previous !== owner) { - errors.push(`${href} appears in both ${previous} and ${owner}`); - } - seen.set(href, owner); - - if (!href.startsWith(`/${product.id}/`)) continue; - - if (tab.id === "self-host") { - if (!generated.has(href)) { - errors.push( - `${owner} sidebar links ${href}, which deployMatrix does not generate`, - ); - } - continue; - } - - const slug = href.slice(`/${product.id}/${tab.id}/`.length); - const base = path.join(DOCS_CONTENT, product.id, tab.id); - if (!contentFileExists(base, href === tab.href ? "index" : slug)) { - errors.push(`${owner} sidebar links ${href}, which has no content file`); - } - } - } + for (const tab of product.tabs) { + const owner = `${product.id}:${tab.id}`; + const generated = new Set( + tab.id === "self-host" + ? guidesFor(product.id).map((guide) => + guide.slug === "index" + ? normalizeHref(tab.href) + : normalizeHref(`${tab.href.replace(/\/$/, "")}/${guide.slug}`), + ) + : [], + ); + + for (const href of collectHrefs(tab.sidebar)) { + // 4. No href may belong to two tabs. + const previous = seen.get(href); + if (previous && previous !== owner) { + errors.push(`${href} appears in both ${previous} and ${owner}`); + } + seen.set(href, owner); + + if (!href.startsWith(`/${product.id}/`)) continue; + + if (tab.id === "self-host") { + if (!generated.has(href)) { + errors.push( + `${owner} sidebar links ${href}, which deployMatrix does not generate`, + ); + } + continue; + } + + const slug = href + .slice(`/${product.id}/${tab.id}/`.length) + .replace(/\/$/, ""); + const base = path.join(DOCS_CONTENT, product.id, tab.id); + if ( + !contentFileExists( + base, + href === normalizeHref(tab.href) ? "index" : slug, + ) + ) { + errors.push( + `${owner} sidebar links ${href}, which has no content file`, + ); + } + } + + // Hidden tabs remain routable for existing inbound links, but they are + // deliberately absent from navigation and noindexed. Everything else that + // the product-doc route generates must be discoverable from its sidebar. + if ( + tab.hidden || + tab.id === "overview" || + tab.id === "use-cases" || + tab.id === "self-host" + ) { + continue; + } + + const base = path.join(DOCS_CONTENT, product.id, tab.id); + if (!existsSync(base)) continue; + const sidebarHrefs = new Set(collectHrefs(tab.sidebar)); + for (const file of fg.sync("**/*.mdx", { cwd: base })) { + const stem = file.replace(/\.mdx$/, "").replace(/(^|\/)index$/, ""); + const href = normalizeHref( + stem ? `/${product.id}/${tab.id}/${stem}` : `/${product.id}/${tab.id}`, + ); + if (isRedirectRoute(href)) continue; + if (!sidebarHrefs.has(href) && !contentInlinks.has(href)) { + errors.push( + `${owner} routes ${href}, but neither its sidebar nor another docs page links it`, + ); + } + } + } } if (errors.length > 0) { - console.error(`sitemap check failed with ${errors.length} problem(s):\n`); - for (const error of errors) console.error(` - ${error}`); - process.exit(1); + console.error(`sitemap check failed with ${errors.length} problem(s):\n`); + for (const error of errors) console.error(` - ${error}`); + process.exit(1); } const guideCount = selfHostProducts.reduce( - (total, product) => total + guidesFor(product.id).length, - 0, + (total, product) => total + guidesFor(product.id).length, + 0, ); console.log( - `sitemap check passed: ${products.length} products, ${guideCount} generated self-host pages, ${seen.size} unique sidebar hrefs`, + `sitemap check passed: ${products.length} products, ${guideCount} generated self-host pages, ${seen.size} unique sidebar hrefs`, ); diff --git a/scripts/generate-caddy-redirects.mjs b/scripts/generate-caddy-redirects.mjs index 03f606b8..bb73f465 100644 --- a/scripts/generate-caddy-redirects.mjs +++ b/scripts/generate-caddy-redirects.mjs @@ -74,14 +74,29 @@ for (const [from, to] of Object.entries(redirects)) { // request path is kept and only the new parent is prepended, so // `/registry/pi` -> `/agentos/registry/pi`. Every other rule drops the captured // suffix and collapses all sub-paths onto the target. -for (const { from, to } of wildcardRedirects) { +for (const [index, { from, to }] of wildcardRedirects.entries()) { assertSafe(from, 'source'); assertSafeTarget(to); - const target = - to !== from && to.endsWith(from) - ? `${to.slice(0, -from.length)}{http.request.uri.path}` - : to; - lines.push(`redir ${from}/* ${target}${PREFIXED_QUERY} 301`); + if (to !== from && to.endsWith(from)) { + const parent = to.slice(0, -from.length); + const matcher = `wildcard${index}`; + // Preserve the suffix for re-parenting rules while normalizing both input + // forms directly to the canonical trailing-slash destination. + lines.push(`@${matcher}Trailing path ${from}/*/`); + lines.push( + `redir @${matcher}Trailing ${parent}{http.request.uri.path}${PREFIXED_QUERY} 301`, + ); + lines.push(`@${matcher}Bare {`); + lines.push(`\tpath ${from}/*`); + lines.push('\tnot path */'); + lines.push('}'); + lines.push( + `redir @${matcher}Bare ${parent}{http.request.uri.path}/${PREFIXED_QUERY} 301`, + ); + } else { + const target = to.endsWith('/') ? to : `${to}/`; + lines.push(`redir ${from}/* ${target}${PREFIXED_QUERY} 301`); + } } const outPath = fileURLToPath(new URL('../redirects.caddy', import.meta.url)); diff --git a/src/components/BlogArticle.astro b/src/components/BlogArticle.astro index 6997677f..fde73280 100644 --- a/src/components/BlogArticle.astro +++ b/src/components/BlogArticle.astro @@ -1,7 +1,10 @@ --- import { getCollection, render } from 'astro:content'; import { getPostImage } from '@/lib/postImage'; +import type { PostImage as PostImageData } from '@/lib/postImage'; import { ArticleSocials } from '@/components/ArticleSocials'; +import BlogContentImage from '@/components/BlogContentImage.astro'; +import PostImage from '@/components/PostImage.astro'; import { Prose } from '@/components/Prose'; import { formatTimestamp } from '@/lib/formatDate'; import { CATEGORIES } from '@/lib/article'; @@ -25,12 +28,13 @@ import { interface Props { // biome-ignore lint/suspicious/noExplicitAny: content collection entry entry: any; - image?: { src: string; width: number; height: number } | null; + image?: PostImageData | null; section: 'blog' | 'changelog'; } const { entry, image, section } = Astro.props; const { Content, headings } = await render(entry); +const blogMdxComponents = { ...mdxComponents, img: BlogContentImage }; const { title, description } = entry.data as unknown as { title: string; description: string }; @@ -86,13 +90,12 @@ const otherArticles = allPosts {image && ( - {title} - +
@@ -180,11 +183,10 @@ const otherArticles = allPosts class:list={[SITE_CARD_CLASS, 'group flex flex-col transition-colors hover:border-ink/25']} > {article.image && ( - {article.title}; +const resolvedSrc = typeof src === 'object' ? src.src : src; +const resolvedWidth = typeof src === 'object' ? src.width : width; +const resolvedHeight = typeof src === 'object' ? src.height : height; +const resolvedClass = classAttribute ?? className; + +const SOFTWARE_REGISTRY_IMAGE = + 'https://assets.rivet.dev/website/blog/2026-07-26-sandboxes-vs-webassembly-lambda-vs-workers-round-two/software-registry.png'; +const optimizeSoftwareRegistry = resolvedSrc.split('?', 1)[0] === SOFTWARE_REGISTRY_IMAGE; +--- + +{optimizeSoftwareRegistry ? ( + {alt +) : ( + {alt} +)} diff --git a/src/components/Card.tsx b/src/components/Card.tsx index 1d9dfac3..97166f96 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -1,6 +1,7 @@ import clsx from "clsx"; import type { PropsWithChildren } from "react"; import { Icon, faArrowRight } from "@rivet-gg/icons"; +import { canonicalizeInternalHref } from "@/lib/internalHref"; interface CardProps extends PropsWithChildren<{ className?: string }> { title?: string; @@ -70,7 +71,7 @@ export function Card({ if (href) { return ( - + {content} ); diff --git a/src/components/DocsNavigation.tsx b/src/components/DocsNavigation.tsx index 07224b83..dcbc6791 100644 --- a/src/components/DocsNavigation.tsx +++ b/src/components/DocsNavigation.tsx @@ -15,6 +15,7 @@ import { useEffect, useRef, } from "react"; +import { canonicalizeInternalHref } from "@/lib/internalHref"; /** * Accent of the product vertical this sidebar belongs to. @@ -192,7 +193,7 @@ export function NavLink({ return (
, 'alt' | 'height' | 'sizes' | 'src' | 'srcset' | 'width'> { + image: PostImage; + alt: string; + variant: Variant; +} + +const { image, alt, variant, ...attributes } = Astro.props; +const isAnimated = image.format === 'gif'; + +const profile = isAnimated + ? variant === 'hero' + ? { widths: [400, 600, 800], quality: 40 } + : { widths: [320, 480], quality: 60 } + : variant === 'hero' + ? { widths: [480, 800, 1200, 1600], quality: 80 } + : { widths: [320, 480, 640, 800], quality: 80 }; + +const widths = profile.widths.filter((width) => width <= image.width); +const targetWidth = Math.min(profile.widths.at(-1) ?? image.width, image.width); +if (!widths.includes(targetWidth)) widths.push(targetWidth); + +// Post imagery is displayed in a 2:1 frame everywhere. Crop at build time so +// non-2:1 sources (notably the Inspector animation) do not ship hidden pixels. +const targetHeight = Math.round(targetWidth / 2); +const sizes = variant === 'hero' + ? '(min-width: 800px) 800px, calc(100vw - 2rem)' + : '(min-width: 1280px) 360px, (min-width: 768px) 45vw, calc(100vw - 5rem)'; +--- + +{alt} diff --git a/src/components/ProductBar.tsx b/src/components/ProductBar.tsx index 9510ffdb..b9b48ee0 100644 --- a/src/components/ProductBar.tsx +++ b/src/components/ProductBar.tsx @@ -11,6 +11,7 @@ import { import { productLogos } from "@/sitemap/productLogos"; import { productAccent, wordmarkMaskStyle } from "@/lib/product-accent"; import { cn } from "@rivet-gg/components"; +import { canonicalizeInternalHref } from "@/lib/internalHref"; /** * A product's mark, carrying that product's accent color. @@ -155,7 +156,7 @@ export function ProductBar({ return ( } asChild > diff --git a/src/components/cookbook/CookbookCard.tsx b/src/components/cookbook/CookbookCard.tsx index 9dc6d7a9..32d700f3 100644 --- a/src/components/cookbook/CookbookCard.tsx +++ b/src/components/cookbook/CookbookCard.tsx @@ -1,3 +1,5 @@ +import { canonicalizeInternalHref } from "@/lib/internalHref"; + export interface CookbookCardCover { src: string; objectPosition?: string; @@ -35,7 +37,7 @@ export function CookbookCard({ page }: { page: CookbookPageCardData }) { {/* Soft ground beneath the card, mirroring the hero reel's hover glow. */}
diff --git a/src/components/docs/ConfigurationOptions.tsx b/src/components/docs/ConfigurationOptions.tsx index c3450a3c..9a1e12ec 100644 --- a/src/components/docs/ConfigurationOptions.tsx +++ b/src/components/docs/ConfigurationOptions.tsx @@ -2,22 +2,22 @@ export function ConfigurationOptions() { return (
  • - Server Setup: Different ways to run your server with serve(), handler(), or framework adapters. + Server Setup: Different ways to run your server with serve(), handler(), or framework adapters.
  • - Clients: Connect to actors from JavaScript, React, or other platforms. + Clients: Connect to actors from JavaScript, React, or other platforms.
  • - Authentication: Secure actor connections with custom authentication logic. + Authentication: Secure actor connections with custom authentication logic.
  • - CORS: Configure origin restrictions to secure your actors from unauthorized access. + CORS: Configure origin restrictions to secure your actors from unauthorized access.
  • - Logging: Configure logging output for debugging and monitoring. + Logging: Configure logging output for debugging and monitoring.
  • - Runtime Modes: Serverless vs runners for different deployment scenarios. + Runtime Modes: Serverless vs runners for different deployment scenarios.
); diff --git a/src/components/docs/DocsArticlePage.astro b/src/components/docs/DocsArticlePage.astro index 1516add8..fe893176 100644 --- a/src/components/docs/DocsArticlePage.astro +++ b/src/components/docs/DocsArticlePage.astro @@ -17,6 +17,13 @@ import { getContentSlugPath } from '@/lib/content-path'; import { productAccent } from '@/lib/product-accent'; import { getProductMetadata } from '@/sitemap/product-metadata'; import * as mdxComponents from '@/components/mdx'; +import { jsonLdString } from '@/lib/jsonLd'; +import { + contextualSeoDescription, + contextualSeoTitle, + resolveSeoMetadata, + type SeoOverrides, +} from '@/lib/seo'; const { entry, @@ -26,6 +33,7 @@ const { productId, tabId, sectionLabel, + seoContextLabel = sectionLabel ?? 'Documentation', // Set when the same guide body is generated for several products, so every // copy points search engines at one winner instead of letting Google pick. canonicalUrl: canonicalUrlOverride, @@ -57,7 +65,17 @@ const tableOfContents = headings return acc; }, [] as Array<{ title: string; id: string; children: Array<{ title: string; id: string; children: never[] }> }>); -const { title, description } = entry.data as { title: string; description: string }; +const { title, description, seoTitle, seoDescription } = entry.data as { + title: string; + description: string; +} & SeoOverrides; +const seo = resolveSeoMetadata({ + title: seoTitle ?? contextualSeoTitle(title, seoContextLabel), + description: contextualSeoDescription( + seoDescription ?? description, + `Explore ${title} in ${seoContextLabel} with practical guidance, configuration details, and production-ready examples.`, + ), +}); const entrySlugPath = getContentSlugPath(entry.id); // A section's own overview page has the section root as its id (the glob loader // collapses `/index.mdx`), so it resolves to an empty slug rather than @@ -141,8 +159,8 @@ const breadcrumbSchema = { --- -