diff --git a/app/[...slug]/page.tsx b/app/[...slug]/page.tsx index 65d9ca18..7eb3f76a 100644 --- a/app/[...slug]/page.tsx +++ b/app/[...slug]/page.tsx @@ -29,6 +29,7 @@ import * as customIcons from '@/components/ui/icon'; import { TagFilterSystem } from '@/components/ui/tag-filter-system'; import { getGitLastModified } from '@/lib/last-modified'; import { getAllFilterablePages, source } from '@/lib/source'; +import { DOCS_URL } from '@/lib/structured-data'; import type { HeadingProps } from '@/types'; export default async function Page(props: { @@ -173,6 +174,7 @@ export default async function Page(props: { path={canonicalPath} datePublished={page.data.publishedAt} dateModified={lastModified?.toISOString().slice(0, 10)} + image={`${DOCS_URL}/og${canonicalPath}`} /> )} {page.data.interactive ? ( diff --git a/components/author-profile.tsx b/components/author-profile.tsx index efd41857..431adb2d 100644 --- a/components/author-profile.tsx +++ b/components/author-profile.tsx @@ -1,5 +1,6 @@ import { Github, Globe } from 'lucide-react'; import Link from 'next/link'; +import { getAuthorPersonId } from '@/lib/structured-data'; const SITE_URL = 'https://docs.steel.dev'; @@ -33,6 +34,7 @@ function profileJsonLd({ handle, name, website }: Props) { url: profileUrl, mainEntity: { '@type': 'Person', + '@id': getAuthorPersonId(handle), name, alternateName: handle, url: profileUrl, diff --git a/components/page-jsonld.tsx b/components/page-jsonld.tsx index 211447ca..925d345c 100644 --- a/components/page-jsonld.tsx +++ b/components/page-jsonld.tsx @@ -56,6 +56,7 @@ interface TechArticleProps { path: string; // canonical path, e.g. /integrations/selenium datePublished?: string; // YYYY-MM-DD from frontmatter publishedAt dateModified?: string; // YYYY-MM-DD from git history + image?: string; // absolute OG image URL } // TechArticle JSON-LD for integration pages. Cookbook recipes emit their own @@ -67,6 +68,7 @@ export function TechArticleJsonLd({ path, datePublished, dateModified, + image, }: TechArticleProps) { return ( ); diff --git a/components/recipe-jsonld.tsx b/components/recipe-jsonld.tsx index 658129d6..0e32cfea 100644 --- a/components/recipe-jsonld.tsx +++ b/components/recipe-jsonld.tsx @@ -1,4 +1,9 @@ -import { DOCS_URL, getWebPageId, STEEL_ORGANIZATION_ID } from '@/lib/structured-data'; +import { + DOCS_URL, + getAuthorPersonId, + getWebPageId, + STEEL_ORGANIZATION_ID, +} from '@/lib/structured-data'; interface AuthorRef { handle: string; @@ -38,8 +43,10 @@ export function RecipeJsonLd({ description, url: recipeUrl, mainEntityOfPage: { '@id': getWebPageId(recipePath) }, + image: `${DOCS_URL}/og${recipePath}`, author: authors.map((a) => ({ '@type': 'Person', + '@id': getAuthorPersonId(a.handle), name: a.name, url: `${DOCS_URL}/cookbook/authors/${a.handle}`, })), diff --git a/lib/structured-data.ts b/lib/structured-data.ts index f0133f1d..955054a3 100644 --- a/lib/structured-data.ts +++ b/lib/structured-data.ts @@ -8,6 +8,7 @@ export const DOCS_SITE_NAME = 'Steel Docs'; export const DOCS_SITE_DESCRIPTION = "Documentation for Steel, an open-source browser API for AI agents and automation. Create cloud browser sessions with Steel's APIs, SDKs, and integrations."; export const STEEL_SAME_AS = ['https://github.com/steel-dev', 'https://x.com/steeldotdev'] as const; +export const STEEL_LOGO_URL = `${DOCS_URL}/images/logo.png`; interface WebPageSchemaOptions { name: string; @@ -18,6 +19,7 @@ interface WebPageSchemaOptions { interface TechArticleSchemaOptions extends WebPageSchemaOptions { datePublished?: string; dateModified?: string; + image?: string; // absolute URL, e.g. `${DOCS_URL}/og/integrations/playwright` } export function getCanonicalPageUrl(path: string): string { @@ -28,6 +30,12 @@ export function getWebPageId(path: string): string { return `${getCanonicalPageUrl(path)}#webpage`; } +// Google reconciles entities on `@id`, not `url`, so the recipe author Person +// and the ProfilePage Person must share this ID to merge in the Search graph. +export function getAuthorPersonId(handle: string): string { + return `${DOCS_URL}/cookbook/authors/${handle}#person`; +} + export function buildSiteIdentitySchema() { return { '@context': 'https://schema.org', @@ -37,6 +45,7 @@ export function buildSiteIdentitySchema() { '@id': STEEL_ORGANIZATION_ID, name: 'Steel', url: STEEL_URL, + logo: STEEL_LOGO_URL, sameAs: STEEL_SAME_AS, }, { @@ -72,6 +81,7 @@ export function buildTechArticleSchema({ path, datePublished, dateModified, + image, }: TechArticleSchemaOptions) { const data: Record = { '@context': 'https://schema.org', @@ -85,5 +95,6 @@ export function buildTechArticleSchema({ if (description) data.description = description; if (datePublished) data.datePublished = datePublished; if (dateModified) data.dateModified = dateModified; + if (image) data.image = image; return data; } diff --git a/tests/structured-data.test.ts b/tests/structured-data.test.ts index 5a87d291..a39fbad6 100644 --- a/tests/structured-data.test.ts +++ b/tests/structured-data.test.ts @@ -1,11 +1,14 @@ // ABOUTME: Contract tests for shared schema.org entity IDs and pure JSON-LD builders. // ABOUTME: Prevents disconnected entities, unverified profiles, and invented freshness. import { describe, expect, test } from 'bun:test'; +import { AuthorProfile } from '@/components/author-profile'; +import { RecipeJsonLd } from '@/components/recipe-jsonld'; import { buildSiteIdentitySchema, buildTechArticleSchema, buildWebPageSchema, DOCS_WEBSITE_ID, + getAuthorPersonId, getWebPageId, STEEL_ORGANIZATION_ID, STEEL_SAME_AS, @@ -22,6 +25,7 @@ describe('structured data builders', () => { '@id': STEEL_ORGANIZATION_ID, name: 'Steel', url: 'https://steel.dev/', + logo: 'https://docs.steel.dev/images/logo.png', sameAs: STEEL_SAME_AS, }); expect(website).toMatchObject({ @@ -79,5 +83,45 @@ describe('structured data builders', () => { dateModified: '2026-07-30', }); expect(article).not.toHaveProperty('datePublished'); + expect(article).not.toHaveProperty('image'); + }); + + test('carries the absolute article image only when provided', () => { + const article = buildTechArticleSchema({ + name: 'Run Playwright on Steel Cloud Browsers', + path: '/integrations/playwright', + image: 'https://docs.steel.dev/og/integrations/playwright', + }); + + expect(article.image).toBe('https://docs.steel.dev/og/integrations/playwright'); + }); + + test('gives recipe authors and profile pages the same stable Person ID', () => { + expect(getAuthorPersonId('junhsss')).toBe( + 'https://docs.steel.dev/cookbook/authors/junhsss#person', + ); + + // Call the components as plain functions and parse their emitted JSON-LD: + // both Person entities must share the exact @id for Google to merge them. + const recipeElement = RecipeJsonLd({ + slug: 'scrape', + title: 'Scrape JavaScript-Rendered Pages to Markdown', + description: 'Scrape pages to Markdown.', + authors: [{ handle: 'junhsss', name: 'Jun Ryu' }], + }) as { props: { dangerouslySetInnerHTML: { __html: string } } }; + const recipe = JSON.parse(recipeElement.props.dangerouslySetInnerHTML.__html); + + const profileElement = AuthorProfile({ + handle: 'junhsss', + name: 'Jun Ryu', + avatar: 'https://github.com/junhsss.png?size=40', + }) as { props: { children: { props: { dangerouslySetInnerHTML: { __html: string } } }[] } }; + const profile = JSON.parse( + profileElement.props.children[0].props.dangerouslySetInnerHTML.__html, + ); + + expect(recipe.author[0]['@id']).toBe('https://docs.steel.dev/cookbook/authors/junhsss#person'); + expect(profile.mainEntity['@id']).toBe(recipe.author[0]['@id']); + expect(recipe.image).toBe('https://docs.steel.dev/og/cookbook/scrape'); }); });