From 400bb1c97119766cc2b011d7e430f69c7cd7e76d Mon Sep 17 00:00:00 2001 From: Rooh Afza <96720500+r0ohafza@users.noreply.github.com> Date: Thu, 17 Sep 2026 13:45:02 -0700 Subject: [PATCH] feat(seo): publish TechArticle, BreadcrumbList and SoftwareSourceCode JSON-LD Every page now emits one application/ld+json graph from the theme head: a TechArticle (title, frontmatter description, canonical URL, Splits as publisher, the docs WebSite as isPartOf), a BreadcrumbList built from the route and the root _meta.json section titles, and on core contract pages a SoftwareSourceCode node pointing at the Solidity repository. dateModified is omitted because the Vercel build has no truthful git date. --- theme.config.tsx | 171 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 137 insertions(+), 34 deletions(-) diff --git a/theme.config.tsx b/theme.config.tsx index edec0d4..d7f8b37 100644 --- a/theme.config.tsx +++ b/theme.config.tsx @@ -3,6 +3,102 @@ import type { DocsThemeConfig } from 'nextra-theme-docs' import { useConfig } from 'nextra-theme-docs' import { useRouter } from 'next/router' import Image from 'next/image' +import rootMeta from './pages/_meta.json' + +const SITE = 'https://splits.org' +const DOCS_URL = `${SITE}/protocol/docs/` +// The index H1 is "Docs"; keep it short on the page and in the OG image but +// give search results and structured data a descriptive title. +const INDEX_TITLE = + 'Splits Protocol docs: Split, Waterfall and Swapper contracts' + +// asPath excludes basePath and may carry a query/hash on client-side navigation. +const routeOf = (asPath: string) => asPath.split(/[?#]/)[0] +// The docs are reverse-proxied to splits.org/protocol/docs but the origin +// deployment is also directly reachable, so declare the splits.org URL as +// canonical. splits.org serves the trailing-slash form (308 otherwise), so +// the canonical points there. +const canonicalOf = (route: string) => + `${SITE}/protocol/docs${route === '/' ? '' : route}/` + +const sectionTitle = (segment: string) => { + const entry = (rootMeta as Record)[ + segment + ] + return typeof entry === 'string' ? entry : entry?.title +} + +const ORGANIZATION = { + '@type': 'Organization', + '@id': `${SITE}/#organization`, + name: 'Splits', + url: `${SITE}/`, +} + +// Entity signal for AI answer engines; no Google rich result is expected. +// dateModified is omitted because the Vercel build has no truthful git date. +const structuredData = ({ + route, + title, + description, +}: { + route: string + title: string + description?: string +}) => { + const url = canonicalOf(route) + const [section] = route.split('/').filter(Boolean) + const crumbs = [ + { name: 'Protocol docs', item: DOCS_URL }, + ...(route.split('/').length > 2 + ? [{ name: sectionTitle(section), item: canonicalOf(`/${section}`) }] + : []), + { name: title, item: url }, + ] + return { + '@context': 'https://schema.org', + '@graph': [ + { + '@type': 'TechArticle', + '@id': url, + headline: title, + description, + url, + publisher: ORGANIZATION, + isPartOf: { + '@type': 'WebSite', + '@id': `${DOCS_URL}#website`, + name: 'Splits Protocol docs', + url: DOCS_URL, + publisher: ORGANIZATION, + }, + }, + ...(route === '/' + ? [] + : [ + { + '@type': 'BreadcrumbList', + itemListElement: crumbs.map((crumb, index) => ({ + '@type': 'ListItem', + position: index + 1, + ...crumb, + })), + }, + ]), + ...(route.startsWith('/core/') + ? [ + { + '@type': 'SoftwareSourceCode', + name: title, + url, + programmingLanguage: 'Solidity', + codeRepository: 'https://github.com/0xSplits/splits-contracts', + }, + ] + : []), + ], + } +} const logo = ( <> @@ -43,15 +139,8 @@ const config: DocsThemeConfig = { useNextSeoProps() { const { asPath } = useRouter() const { title } = useConfig() - // The docs are reverse-proxied to splits.org/protocol/docs but the origin - // deployment is also directly reachable, so declare the splits.org URL as - // canonical. splits.org serves the trailing-slash form (308 otherwise), so - // the canonical points there. asPath excludes basePath and may carry a - // query/hash on client-side navigation. - const path = asPath.split(/[?#]/)[0] - const canonical = `https://splits.org/protocol/docs${ - path === '/' ? '' : path - }/` + const route = routeOf(asPath) + const canonical = canonicalOf(route) // Scrapers need an absolute URL. Production points at the public host; // previews point at their own deployment so cards can be checked in a // Discord or X embed before merge (same rule as next-sitemap.config.js). @@ -59,7 +148,7 @@ const config: DocsThemeConfig = { process.env.NEXT_PUBLIC_VERCEL_ENV && process.env.NEXT_PUBLIC_VERCEL_ENV !== 'production' ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` - : 'https://splits.org' + : SITE const ogImage = `${ogOrigin}/protocol/docs/api/og/?title=${encodeURIComponent( title, )}` @@ -73,34 +162,48 @@ const config: DocsThemeConfig = { }, twitter: { site: '@0xsplits', cardType: 'summary_large_image' }, } - if (path !== '/') { + if (route !== '/') { return { ...shared, titleTemplate: '%s | Protocol' } } - // The index H1 is "Docs"; keep it short on the page and in the OG image - // but give search results a descriptive title. next-seo remembers the last - // titleTemplate it saw across prerenders, so reset it explicitly. - return { - ...shared, - title: 'Splits Protocol docs: Split, Waterfall and Swapper contracts', - titleTemplate: '%s', - } + // next-seo remembers the last titleTemplate it saw across prerenders, so + // reset it explicitly. + return { ...shared, title: INDEX_TITLE, titleTemplate: '%s' } }, logo, - head: ( - <> - - - - - {/* Raw tags are not basePath-prefixed automatically, so the - '/protocol/docs' prefix is included explicitly. */} - - - ), + // Nextra calls this as a plain function while rendering its own , so + // hooks work here, but next/head drops nested components: emit raw tags. + head() { + const { asPath } = useRouter() + const { title, frontMatter } = useConfig() + const route = routeOf(asPath) + const data = structuredData({ + route, + title: route === '/' ? INDEX_TITLE : title, + description: frontMatter.description, + }) + return ( + <> + + + + + {/* Raw tags are not basePath-prefixed automatically, so the + '/protocol/docs' prefix is included explicitly. */} + +