Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/[...slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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 ? (
Expand Down
2 changes: 2 additions & 0 deletions components/author-profile.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -33,6 +34,7 @@ function profileJsonLd({ handle, name, website }: Props) {
url: profileUrl,
mainEntity: {
'@type': 'Person',
'@id': getAuthorPersonId(handle),
name,
alternateName: handle,
url: profileUrl,
Expand Down
3 changes: 3 additions & 0 deletions components/page-jsonld.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -67,6 +68,7 @@ export function TechArticleJsonLd({
path,
datePublished,
dateModified,
image,
}: TechArticleProps) {
return (
<JsonLd
Expand All @@ -76,6 +78,7 @@ export function TechArticleJsonLd({
path,
datePublished,
dateModified,
image,
})}
/>
);
Expand Down
9 changes: 8 additions & 1 deletion components/recipe-jsonld.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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}`,
})),
Expand Down
11 changes: 11 additions & 0 deletions lib/structured-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -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',
Expand All @@ -37,6 +45,7 @@ export function buildSiteIdentitySchema() {
'@id': STEEL_ORGANIZATION_ID,
name: 'Steel',
url: STEEL_URL,
logo: STEEL_LOGO_URL,
sameAs: STEEL_SAME_AS,
},
{
Expand Down Expand Up @@ -72,6 +81,7 @@ export function buildTechArticleSchema({
path,
datePublished,
dateModified,
image,
}: TechArticleSchemaOptions) {
const data: Record<string, unknown> = {
'@context': 'https://schema.org',
Expand All @@ -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;
}
44 changes: 44 additions & 0 deletions tests/structured-data.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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({
Expand Down Expand Up @@ -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');
});
});
Loading