diff --git a/apps/web/app/[locale]/builders-hub/ideas/[slug]/page.tsx b/apps/web/app/[locale]/builders-hub/ideas/[slug]/page.tsx index e68a166b68..0a55f48e7c 100644 --- a/apps/web/app/[locale]/builders-hub/ideas/[slug]/page.tsx +++ b/apps/web/app/[locale]/builders-hub/ideas/[slug]/page.tsx @@ -5,16 +5,21 @@ import { ContentNotFoundError, getAllIdeas, getAllRfps, + getBuilderHubListingSettings, getIdeaBySlug, + getPageCopy, } from '@repo/content/loaders' import { BuildersHubDetailLayout } from '@/components/sections/builders-hub/builders-hub-detail-layout' import { RelatedLinksList } from '@/components/sections/builders-hub/related-links-list' +import { JsonLd } from '@/components/seo/json-ld' import { ROUTES } from '@/constants/routes' +import siteConfig from '@/constants/site-config' import { formatDateLong } from '@/lib/dates' import { createDefaultMetadata } from '@/lib/metadata' import { resolveLocale, type LocaleSlugParams } from '@/lib/route-params' import { formatReward } from '@/lib/reward' +import { createBreadcrumbListJsonLd } from '@/lib/structured-data' export async function generateStaticParams() { const params: Array<{ locale: string; slug: string }> = [] @@ -60,6 +65,11 @@ export default async function IdeaDetailPage({ params }: LocaleSlugParams) { } if (idea.status !== 'published') notFound() + const [buildersHub, listingSettings] = await Promise.all([ + getPageCopy(ROUTES.buildersHub, locale), + getBuilderHubListingSettings({ page: 'ideas', locale }), + ]) + const submitter = idea.submitter.name ? `${idea.submitter.name} (@${idea.submitter.handle})` : `@${idea.submitter.handle}` @@ -90,30 +100,46 @@ export default async function IdeaDetailPage({ params }: LocaleSlugParams) { } return ( - - } - /> + <> + + + } + /> + ) } diff --git a/apps/web/app/[locale]/builders-hub/ideas/page.tsx b/apps/web/app/[locale]/builders-hub/ideas/page.tsx index dc992f071b..e789dca51a 100644 --- a/apps/web/app/[locale]/builders-hub/ideas/page.tsx +++ b/apps/web/app/[locale]/builders-hub/ideas/page.tsx @@ -1,12 +1,16 @@ import { getAllIdeas, getBuilderHubListingSettings, + getPageCopy, } from '@repo/content/loaders' import { isActiveLocale } from '@repo/content/locales' import { BuildersHubListingClient } from '@/components/sections/builders-hub/builders-hub-listing-client' +import { JsonLd } from '@/components/seo/json-ld' import { ROUTES } from '@/constants/routes' +import siteConfig from '@/constants/site-config' import { createDefaultMetadata } from '@/lib/metadata' +import { createBreadcrumbListJsonLd } from '@/lib/structured-data' const ROUTE = ROUTES.ideas @@ -38,16 +42,32 @@ export default async function IdeasPage({ throw new Error(`IdeasPage received non-active locale "${locale}"`) } - const [settings, allIdeas] = await Promise.all([ + const [settings, allIdeas, buildersHub] = await Promise.all([ getBuilderHubListingSettings({ page: 'ideas', locale }), getAllIdeas({ locale, status: 'published' }), + getPageCopy(ROUTES.buildersHub, locale), ]) return ( - + <> + + + ) } diff --git a/apps/web/app/[locale]/builders-hub/rfps/[slug]/page.tsx b/apps/web/app/[locale]/builders-hub/rfps/[slug]/page.tsx index ddb341a469..92caa0a905 100644 --- a/apps/web/app/[locale]/builders-hub/rfps/[slug]/page.tsx +++ b/apps/web/app/[locale]/builders-hub/rfps/[slug]/page.tsx @@ -1,11 +1,17 @@ import { notFound } from 'next/navigation' import { getActiveLocales } from '@repo/content/locales' +import { + getBuilderHubListingSettings, + getPageCopy, +} from '@repo/content/loaders' import { BuildersHubDetailLayout } from '@/components/sections/builders-hub/builders-hub-detail-layout' +import { JsonLd } from '@/components/seo/json-ld' import { LegalMarkdown } from '@/components/sections/shared/legal-markdown' import { Button } from '@/components/ui' import { ROUTES } from '@/constants/routes' +import siteConfig from '@/constants/site-config' import { createDefaultMetadata } from '@/lib/metadata' import { resolveLocale, type LocaleSlugParams } from '@/lib/route-params' import { @@ -14,6 +20,7 @@ import { fetchGithubRfps, stripLeadingHeading, } from '@/lib/rfps-github' +import { createBreadcrumbListJsonLd } from '@/lib/structured-data' export async function generateStaticParams() { const rfps = await fetchGithubRfps() @@ -45,12 +52,17 @@ export async function generateMetadata({ params }: LocaleSlugParams) { } export default async function RfpDetailPage({ params }: LocaleSlugParams) { - await resolveLocale(params, 'RfpDetailPage') + const locale = await resolveLocale(params, 'RfpDetailPage') const { slug } = await params const rfp = await fetchGithubRfpBySlug(slug) if (!rfp) notFound() + const [buildersHub, listingSettings] = await Promise.all([ + getPageCopy(ROUTES.buildersHub, locale), + getBuilderHubListingSettings({ page: 'rfps', locale }), + ]) + const meta = [ { label: 'Status', value: rfp.status }, rfp.category ? { label: 'Category', value: rfp.category } : null, @@ -58,24 +70,40 @@ export default async function RfpDetailPage({ params }: LocaleSlugParams) { ].filter((x): x is { label: string; value: string } => Boolean(x)) return ( - } - primaryCta={{ - label: 'Apply', - href: RFP_APPLY_URL, - external: true, - }} - meta={meta} - footer={ - - } - /> + <> + + } + primaryCta={{ + label: 'Apply', + href: RFP_APPLY_URL, + external: true, + }} + meta={meta} + footer={ + + } + /> + ) } diff --git a/apps/web/app/[locale]/builders-hub/rfps/page.tsx b/apps/web/app/[locale]/builders-hub/rfps/page.tsx index d90346947e..2c196e9ca3 100644 --- a/apps/web/app/[locale]/builders-hub/rfps/page.tsx +++ b/apps/web/app/[locale]/builders-hub/rfps/page.tsx @@ -1,10 +1,16 @@ -import { getBuilderHubListingSettings } from '@repo/content/loaders' +import { + getBuilderHubListingSettings, + getPageCopy, +} from '@repo/content/loaders' import { isActiveLocale } from '@repo/content/locales' import { BuildersHubListingClient } from '@/components/sections/builders-hub/builders-hub-listing-client' +import { JsonLd } from '@/components/seo/json-ld' import { ROUTES } from '@/constants/routes' +import siteConfig from '@/constants/site-config' import { createDefaultMetadata } from '@/lib/metadata' import { fetchGithubRfps } from '@/lib/rfps-github' +import { createBreadcrumbListJsonLd } from '@/lib/structured-data' const ROUTE = ROUTES.rfps @@ -36,12 +42,32 @@ export default async function RfpsPage({ throw new Error(`RfpsPage received non-active locale "${locale}"`) } - const [settings, allRfps] = await Promise.all([ + const [settings, allRfps, buildersHub] = await Promise.all([ getBuilderHubListingSettings({ page: 'rfps', locale }), fetchGithubRfps(), + getPageCopy(ROUTES.buildersHub, locale), ]) return ( - + <> + + + ) } diff --git a/apps/web/app/[locale]/field-guide/[slug]/page.tsx b/apps/web/app/[locale]/field-guide/[slug]/page.tsx index ac58b25ba6..14792dfb6e 100644 --- a/apps/web/app/[locale]/field-guide/[slug]/page.tsx +++ b/apps/web/app/[locale]/field-guide/[slug]/page.tsx @@ -9,9 +9,12 @@ import { import { isActiveLocale } from '@repo/content/locales' import { FieldGuidePageView } from '@/components/sections/field-guide' +import { JsonLd } from '@/components/seo/json-ld' import { ROUTES } from '@/constants/routes' +import siteConfig from '@/constants/site-config' import { routing } from '@/i18n/routing' import { createDefaultMetadata } from '@/lib/metadata' +import { createBreadcrumbListJsonLd } from '@/lib/structured-data' const INDEX_SLUG = 'index' @@ -73,7 +76,26 @@ export default async function FieldGuideChapterPage({ getFieldGuideManifest(locale), getFieldGuideChapter(locale, slug), ]) - return + const item = flattenFieldGuideItems(manifest).find( + (chapter) => chapter.slug === slug + ) + if (!item) notFound() + + return ( + <> + + + + ) } catch (error) { if (error instanceof ContentNotFoundError) notFound() throw error diff --git a/apps/web/app/[locale]/page.tsx b/apps/web/app/[locale]/page.tsx index 7a43ffdbf2..8d8f07b9ef 100644 --- a/apps/web/app/[locale]/page.tsx +++ b/apps/web/app/[locale]/page.tsx @@ -16,6 +16,7 @@ import type { import AboutSection from '@/components/sections/home/about-section' import BuilderPortalSection from '@/components/sections/home/builder-portal-section' import DecideSection from '@/components/sections/home/decide-section' +import { JsonLd } from '@/components/seo/json-ld' import FeatureCardsSection from '@/components/sections/shared/feature-cards-section' import HeroSectionView from '@/components/sections/shared/hero-section' import BlogSection from '@/components/sections/home/blog-section' @@ -28,6 +29,7 @@ import { EXTERNAL_URLS, ROUTES } from '@/constants/routes' import { createPageMetadata } from '@/lib/page-metadata' import { createSectionFinder } from '@/lib/page-sections' import { getLatestBlogArticles } from '@/lib/blog-engine' +import { createOrganizationJsonLd } from '@/lib/structured-data' import { getSocialProofStats } from '@/lib/social-proof-stats' import { getWinnableIssuesCount } from '@/lib/winnable-issues' @@ -112,6 +114,7 @@ export default async function HomePage({ return ( <> + + diff --git a/apps/web/app/[locale]/technology-stack/messaging/page.tsx b/apps/web/app/[locale]/technology-stack/messaging/page.tsx index 834d9113dd..e87f694cc5 100644 --- a/apps/web/app/[locale]/technology-stack/messaging/page.tsx +++ b/apps/web/app/[locale]/technology-stack/messaging/page.tsx @@ -11,6 +11,7 @@ import MessagingCaseStudies from '@/components/sections/messaging/messaging-case import MessagingHero from '@/components/sections/messaging/messaging-hero' import MessagingIntro from '@/components/sections/messaging/messaging-intro' import MessagingTechStack from '@/components/sections/messaging/messaging-tech-stack' +import { JsonLd } from '@/components/seo/json-ld' import TechStackBuilderCta from '@/components/sections/shared/tech-stack-builder-cta' import { TechStackDetailPage, @@ -18,10 +19,12 @@ import { } from '@/components/sections/shared/tech-stack-detail-layout' import TechStackRelatedArticles from '@/components/sections/shared/tech-stack-related-articles' import { ROUTES } from '@/constants/routes' +import siteConfig from '@/constants/site-config' import { createPageMetadata } from '@/lib/page-metadata' import { createSectionFinder } from '@/lib/page-sections' import { getLatestBlogArticles } from '@/lib/blog-engine' import { TECH_STACK_RELATED_ARTICLE_TAGS } from '@/lib/tech-stack-related-articles' +import { createBreadcrumbListJsonLd } from '@/lib/structured-data' const ROUTE = ROUTES.messaging @@ -91,6 +94,16 @@ export default async function MessagingPage({ return ( + diff --git a/apps/web/app/[locale]/technology-stack/networking/page.tsx b/apps/web/app/[locale]/technology-stack/networking/page.tsx index 37613f9733..b9fa32b41e 100644 --- a/apps/web/app/[locale]/technology-stack/networking/page.tsx +++ b/apps/web/app/[locale]/technology-stack/networking/page.tsx @@ -10,6 +10,7 @@ import type { import NetworkingFeatures from '@/components/sections/networking/networking-features' import NetworkingHero from '@/components/sections/networking/networking-hero' import NetworkingIntro from '@/components/sections/networking/networking-intro' +import { JsonLd } from '@/components/seo/json-ld' import TechStackBuilderCta from '@/components/sections/shared/tech-stack-builder-cta' import { TechStackDetailPage, @@ -19,10 +20,12 @@ import TechStackRelatedArticles from '@/components/sections/shared/tech-stack-re import NetworkingTechStack from '@/components/sections/networking/networking-tech-stack' import { ROUTES } from '@/constants/routes' +import siteConfig from '@/constants/site-config' import { createPageMetadata } from '@/lib/page-metadata' import { createSectionFinder } from '@/lib/page-sections' import { getLatestBlogArticles } from '@/lib/blog-engine' import { TECH_STACK_RELATED_ARTICLE_TAGS } from '@/lib/tech-stack-related-articles' +import { createBreadcrumbListJsonLd } from '@/lib/structured-data' const ROUTE = ROUTES.networking @@ -74,6 +77,16 @@ export default async function NetworkingPage({ return ( + diff --git a/apps/web/app/[locale]/technology-stack/storage/page.tsx b/apps/web/app/[locale]/technology-stack/storage/page.tsx index 3e8e33791f..19217e8bda 100644 --- a/apps/web/app/[locale]/technology-stack/storage/page.tsx +++ b/apps/web/app/[locale]/technology-stack/storage/page.tsx @@ -11,6 +11,7 @@ import StorageHero from '@/components/sections/storage/storage-hero' import StorageAccess from '@/components/sections/storage/storage-access' import StorageMain from '@/components/sections/storage/storage-main' import StorageTechStack from '@/components/sections/storage/storage-tech-stack' +import { JsonLd } from '@/components/seo/json-ld' // Temporarily hidden — may be reused later. Do not delete. // import StorageUseCases from '@/components/sections/storage/storage-use-cases' import TechStackBuilderCta from '@/components/sections/shared/tech-stack-builder-cta' @@ -21,10 +22,12 @@ import { import TechStackRelatedArticles from '@/components/sections/shared/tech-stack-related-articles' import { ROUTES } from '@/constants/routes' +import siteConfig from '@/constants/site-config' import { createPageMetadata } from '@/lib/page-metadata' import { createSectionFinder } from '@/lib/page-sections' import { getLatestBlogArticles } from '@/lib/blog-engine' import { TECH_STACK_RELATED_ARTICLE_TAGS } from '@/lib/tech-stack-related-articles' +import { createBreadcrumbListJsonLd } from '@/lib/structured-data' const ROUTE = ROUTES.storage @@ -89,6 +92,16 @@ export default async function StoragePage({ return ( + diff --git a/apps/web/components/seo/json-ld.tsx b/apps/web/components/seo/json-ld.tsx new file mode 100644 index 0000000000..904e003c7d --- /dev/null +++ b/apps/web/components/seo/json-ld.tsx @@ -0,0 +1,16 @@ +import type { JsonLdObject } from '@/lib/structured-data' + +type Props = { + data: JsonLdObject +} + +export function JsonLd({ data }: Props) { + const json = JSON.stringify(data).replace(/ + ) +} diff --git a/apps/web/lib/__tests__/structured-data.test.tsx b/apps/web/lib/__tests__/structured-data.test.tsx new file mode 100644 index 0000000000..7468fd84b0 --- /dev/null +++ b/apps/web/lib/__tests__/structured-data.test.tsx @@ -0,0 +1,73 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' + +import { JsonLd } from '@/components/seo/json-ld' +import { + createBreadcrumbListJsonLd, + createOrganizationJsonLd, +} from '@/lib/structured-data' + +const BASE_URL = 'https://logos.co' + +describe('structured data', () => { + it('builds the canonical Logos organisation entity', () => { + expect(createOrganizationJsonLd()).toMatchObject({ + '@context': 'https://schema.org', + '@type': 'Organization', + '@id': `${BASE_URL}/#organization`, + name: 'Logos', + url: BASE_URL, + logo: `${BASE_URL}/apple-touch-icon.png`, + }) + }) + + it('builds ordered canonical breadcrumb URLs', () => { + expect( + createBreadcrumbListJsonLd( + [ + { name: 'Logos', path: '/' }, + { name: 'Technology Stack', path: '/technology-stack' }, + { name: 'Storage', path: '/technology-stack/storage' }, + ], + 'en' + ) + ).toMatchObject({ + '@type': 'BreadcrumbList', + itemListElement: [ + { + '@type': 'ListItem', + position: 1, + name: 'Logos', + item: `${BASE_URL}/`, + }, + { + '@type': 'ListItem', + position: 2, + name: 'Technology Stack', + item: `${BASE_URL}/technology-stack`, + }, + { + '@type': 'ListItem', + position: 3, + name: 'Storage', + item: `${BASE_URL}/technology-stack/storage`, + }, + ], + }) + }) + + it('escapes markup that could terminate the JSON-LD script', () => { + const html = renderToStaticMarkup( + ', + }} + /> + ) + + expect(html).not.toContain('') + expect(html).toContain('\\u003c/script>') + }) +}) diff --git a/apps/web/lib/structured-data.ts b/apps/web/lib/structured-data.ts new file mode 100644 index 0000000000..7d2eed8dd0 --- /dev/null +++ b/apps/web/lib/structured-data.ts @@ -0,0 +1,54 @@ +import siteConfig from '@/constants/site-config' +import { absoluteUrl } from '@/lib/metadata' + +import siteSettings from '../../../content/site/en/settings.json' + +type JsonLdPrimitive = boolean | null | number | string +type JsonLdValue = + | JsonLdPrimitive + | JsonLdObject + | ReadonlyArray + +export interface JsonLdObject { + readonly [key: string]: JsonLdValue | undefined +} + +export interface BreadcrumbItem { + name: string + path: string +} + +export function createOrganizationJsonLd(): JsonLdObject { + const sameAs = [ + siteSettings.social.twitter, + siteSettings.social.youtube, + siteSettings.social.github, + ].filter((url): url is string => Boolean(url)) + + return { + '@context': 'https://schema.org', + '@type': 'Organization', + '@id': `${siteConfig.url}/#organization`, + name: siteConfig.name, + url: siteConfig.url, + logo: absoluteUrl('/apple-touch-icon.png'), + description: siteConfig.description, + sameAs, + } +} + +export function createBreadcrumbListJsonLd( + items: ReadonlyArray, + locale: string +): JsonLdObject { + return { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: items.map((item, index) => ({ + '@type': 'ListItem', + position: index + 1, + name: item.name, + item: absoluteUrl(item.path, locale), + })), + } +} diff --git a/apps/web/scripts/static-export-smoke.ts b/apps/web/scripts/static-export-smoke.ts index 21fba140d9..f1aa73d0e1 100644 --- a/apps/web/scripts/static-export-smoke.ts +++ b/apps/web/scripts/static-export-smoke.ts @@ -121,6 +121,56 @@ const isLocalAssetHref = (href: string): boolean => { ) } +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const breadcrumbTechRoutes = new Set([ + ROUTES.blockchain, + ROUTES.messaging, + ROUTES.networking, + ROUTES.storage, +]) + +const assertStructuredData = (route: string, html: string): string[] => { + const failures: string[] = [] + const types = new Set() + const scripts = html.matchAll( + /]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi + ) + + for (const [, rawJson] of scripts) { + try { + const parsed: unknown = JSON.parse(rawJson ?? '') + const entries = Array.isArray(parsed) ? parsed : [parsed] + for (const entry of entries) { + if (isRecord(entry) && typeof entry['@type'] === 'string') { + types.add(entry['@type']) + } + } + } catch { + failures.push(`${route} contains invalid JSON-LD`) + } + } + + if (route === ROUTES.home && !types.has('Organization')) { + failures.push(`${route} is missing Organization JSON-LD`) + } + + const expectsBreadcrumb = + route === ROUTES.ideas || + route === ROUTES.rfps || + route.startsWith(`${ROUTES.ideas}/`) || + route.startsWith(`${ROUTES.rfps}/`) || + route.startsWith(`${ROUTES.fieldGuide}/`) || + breadcrumbTechRoutes.has(route) + + if (expectsBreadcrumb && !types.has('BreadcrumbList')) { + failures.push(`${route} is missing BreadcrumbList JSON-LD`) + } + + return failures +} + const assertHtmlPage = (route: string, filePath: string): string[] => { const html = readFileSync(filePath, 'utf8') const failures: string[] = [] @@ -133,6 +183,7 @@ const assertHtmlPage = (route: string, filePath: string): string[] => { if (html.includes(`href="/${locale}/`) || html.includes(`src="/${locale}/`)) { failures.push(`${route} still contains default-locale-prefixed asset paths`) } + failures.push(...assertStructuredData(route, html)) const refs = html.matchAll(/\b(?:href|src)=["']([^"']+)["']/g) for (const [, rawHref] of refs) {