From dcb7d031aa8ad7479d8a4abd73ecf0a1b42abdb4 Mon Sep 17 00:00:00 2001 From: Nikola Balic Date: Thu, 23 Jul 2026 18:12:22 +0200 Subject: [PATCH] feat(seo): add BreadcrumbList JSON-LD site-wide and TechArticle on integrations Second Days-0-14 item from SEO-OPPORTUNITIES.md (technical fix #2): - components/page-jsonld.tsx: BreadcrumbJsonLd + TechArticleJsonLd - app/[...slug]/page.tsx emits BreadcrumbList on every docs page: home + section hub + ancestor pages + the page. Fumadocs resets trails at root section folders, so the section crumb is prepended manually from the section index page. Url-less folders are dropped (Google requires item on all but the last ListItem). - TechArticle on /integrations/* leaf pages with Organization author, frontmatter publishedAt, and git-derived dateModified. Cookbook recipes already emit TechArticle via RecipeJsonLd; the /integrations hub is a listing, so it is excluded. - lib/last-modified.ts: git-date helper extracted from app/sitemap.ts, now shared by the sitemap and the TechArticle dateModified. --- app/[...slug]/page.tsx | 47 ++++++++++++++++++++++++++++ app/sitemap.ts | 30 +----------------- components/page-jsonld.tsx | 63 ++++++++++++++++++++++++++++++++++++++ lib/last-modified.ts | 31 +++++++++++++++++++ 4 files changed, 142 insertions(+), 29 deletions(-) create mode 100644 components/page-jsonld.tsx create mode 100644 lib/last-modified.ts diff --git a/app/[...slug]/page.tsx b/app/[...slug]/page.tsx index f6aef9c1..be407e12 100644 --- a/app/[...slug]/page.tsx +++ b/app/[...slug]/page.tsx @@ -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'; @@ -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'; @@ -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, @@ -120,6 +157,16 @@ export default async function Page(props: { return ( + + {isIntegrationArticle && ( + + )} {page.data.interactive ? ( diff --git a/app/sitemap.ts b/app/sitemap.ts index 8dcab183..97b5cdc9 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -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 { - try { - return (await stat(absPath)).mtime; - } catch { - return undefined; - } -} - -async function getLastModified(absPath: string | undefined): Promise { - if (!absPath) return undefined; - return gitLastModified(absPath) ?? (await fsLastModified(absPath)); -} - export default async function sitemap(): Promise { const pages = source.getPages().filter((page) => !/^\/(en\/)?changelog\/.+/.test(page.url)); diff --git a/components/page-jsonld.tsx b/components/page-jsonld.tsx new file mode 100644 index 00000000..3abcb60f --- /dev/null +++ b/components/page-jsonld.tsx @@ -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 ( +