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
47 changes: 47 additions & 0 deletions app/[...slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
//@ts-nocheck
import { getBreadcrumbItems } from 'fumadocs-core/breadcrumb';
import defaultMdxComponents from 'fumadocs-ui/mdx';
import matter from 'gray-matter';
import * as lucideIcons from 'lucide-react';
Expand All @@ -22,9 +23,11 @@ import { LLMShare } from '@/components/llm-share';
import { getMDXComponents } from '@/components/mdx';
import { Mermaid } from '@/components/mdx/mermaid';
import { APIPage } from '@/components/openapi/api-page';
import { BreadcrumbJsonLd, TechArticleJsonLd } from '@/components/page-jsonld';
import { Badge } from '@/components/ui/badge';
import * as customIcons from '@/components/ui/icon';
import { TagFilterSystem } from '@/components/ui/tag-filter-system';
import { getLastModified } from '@/lib/last-modified';
import { getAllFilterablePages, source } from '@/lib/source';
import type { HeadingProps } from '@/types';

Expand Down Expand Up @@ -107,6 +110,40 @@ export default async function Page(props: {
icon: link.icon ? getIconComponent(link.icon) : undefined,
}));

const canonicalPath = page.url.replace(/^\/en(\/|$)/, '/');

// BreadcrumbList JSON-LD trail: home + section hub + ancestor folders that
// have their own page + the page itself. Fumadocs resets the trail at
// `root: true` section folders, so the section crumb is prepended manually
// when the section has an index page (named by that page's title, since
// section index sidebarTitles are generic like "Home"). Folder nodes without
// an index page carry no url and are dropped: Google requires `item` on
// every ListItem except the last.
const stripEn = (url: string) => url.replace(/^\/en(\/|$)/, '/');
const sectionSlug = canonicalPath.split('/').filter(Boolean)[0];
const sectionPage = sectionSlug
? (source.getPage([sectionSlug]) ?? source.getPage(['en', sectionSlug]))
: undefined;
const sectionUrl = sectionPage ? stripEn(sectionPage.url) : undefined;
const crumbs = getBreadcrumbItems(page.url, source.pageTree, { includePage: true })
.filter((item) => typeof item.name === 'string' && !!item.url)
.map((item) => ({ name: item.name as string, url: stripEn(item.url as string) }))
.filter((item) => item.url !== sectionUrl);
const breadcrumbItems = [
{ name: 'Steel Docs', url: '/' },
...(sectionPage && sectionUrl !== canonicalPath
? [{ name: sectionPage.data.title as string, url: sectionUrl as string }]
: []),
...(crumbs.length > 0 ? crumbs : [{ name: page.data.title, url: canonicalPath }]),
];

// TechArticle JSON-LD on integration pages; cookbook recipes emit their own
// via RecipeJsonLd. The hub page at /integrations is a listing, not an article.
const isIntegrationArticle = /^\/integrations\/.+/.test(canonicalPath);
const lastModified = isIntegrationArticle
? await getLastModified(page.data._file?.absolutePath)
: undefined;

// Prepare page data for context - only include serializable data
const pageData = {
toc: page.data.toc,
Expand All @@ -120,6 +157,16 @@ export default async function Page(props: {

return (
<DocsPage data={pageData}>
<BreadcrumbJsonLd items={breadcrumbItems} />
{isIntegrationArticle && (
<TechArticleJsonLd
title={page.data.title}
description={page.data.description}
path={canonicalPath}
datePublished={page.data.publishedAt}
dateModified={lastModified?.toISOString().slice(0, 10)}
/>
)}
{page.data.interactive ? (
<DocsPageLayout variant="interactive">
<DocsPageHeader>
Expand Down
30 changes: 1 addition & 29 deletions app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,9 @@
import { execSync } from 'node:child_process';
import { stat } from 'node:fs/promises';
import type { MetadataRoute } from 'next';
import { getLastModified } from '@/lib/last-modified';
import { source } from '@/lib/source';

const SITE_URL = 'https://docs.steel.dev';

function gitLastModified(absPath: string): Date | undefined {
try {
const out = execSync(`git log -1 --format=%aI -- "${absPath}"`, {
encoding: 'utf8',
timeout: 5000,
}).trim();
if (!out) return undefined;
const d = new Date(out);
return Number.isNaN(d.getTime()) ? undefined : d;
} catch {
return undefined;
}
}

async function fsLastModified(absPath: string): Promise<Date | undefined> {
try {
return (await stat(absPath)).mtime;
} catch {
return undefined;
}
}

async function getLastModified(absPath: string | undefined): Promise<Date | undefined> {
if (!absPath) return undefined;
return gitLastModified(absPath) ?? (await fsLastModified(absPath));
}

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const pages = source.getPages().filter((page) => !/^\/(en\/)?changelog\/.+/.test(page.url));

Expand Down
63 changes: 63 additions & 0 deletions components/page-jsonld.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// ABOUTME: BreadcrumbList and TechArticle JSON-LD emitted by the docs page renderer.
// ABOUTME: BreadcrumbJsonLd runs site-wide; TechArticleJsonLd covers integration pages.
const SITE_URL = 'https://docs.steel.dev';

interface CrumbItem {
name: string;
url: string;
}

// BreadcrumbList JSON-LD: home + named ancestors that have their own page +
// the page itself. Every ListItem carries `item` (Google requires it on all
// but the last), so url-less folder nodes are filtered out by the caller.
export function BreadcrumbJsonLd({ items }: { items: CrumbItem[] }) {
if (items.length < 2) return null;
const data = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: `${SITE_URL}${item.url}`,
})),
};
return (
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }} />
);
}

interface TechArticleProps {
title: string;
description?: string;
path: string; // canonical path, e.g. /integrations/selenium
datePublished?: string; // YYYY-MM-DD from frontmatter publishedAt
dateModified?: string; // YYYY-MM-DD from git history
}

// TechArticle JSON-LD for integration pages. Cookbook recipes emit their own
// TechArticle via RecipeJsonLd (with per-author Person entries); integration
// pages are authored by the team, so the author is the Steel organization.
export function TechArticleJsonLd({
title,
description,
path,
datePublished,
dateModified,
}: TechArticleProps) {
const url = `${SITE_URL}${path}`;
const data: Record<string, unknown> = {
'@context': 'https://schema.org',
'@type': 'TechArticle',
headline: title,
url,
mainEntityOfPage: { '@type': 'WebPage', '@id': url },
author: { '@type': 'Organization', name: 'Steel', url: 'https://steel.dev' },
};
if (description) data.description = description;
if (datePublished) data.datePublished = datePublished;
if (dateModified) data.dateModified = dateModified;
return (
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }} />
);
}
31 changes: 31 additions & 0 deletions lib/last-modified.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// ABOUTME: Resolves a content file's last-modified date from git history,
// ABOUTME: falling back to filesystem mtime. Shared by the sitemap and JSON-LD.
import { execSync } from 'node:child_process';
import { stat } from 'node:fs/promises';

function gitLastModified(absPath: string): Date | undefined {
try {
const out = execSync(`git log -1 --format=%aI -- "${absPath}"`, {
encoding: 'utf8',
timeout: 5000,
}).trim();
if (!out) return undefined;
const d = new Date(out);
return Number.isNaN(d.getTime()) ? undefined : d;
} catch {
return undefined;
}
}

async function fsLastModified(absPath: string): Promise<Date | undefined> {
try {
return (await stat(absPath)).mtime;
} catch {
return undefined;
}
}

export async function getLastModified(absPath: string | undefined): Promise<Date | undefined> {
if (!absPath) return undefined;
return gitLastModified(absPath) ?? (await fsLastModified(absPath));
}
Loading