mirror of
https://github.com/logos-co/logos-web.git
synced 2026-08-27 12:11:14 +00:00
feat(web): add structured SEO data
This commit is contained in:
@@ -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 (
|
||||
<BuildersHubDetailLayout
|
||||
backHref={ROUTES.ideas}
|
||||
backLabel="All ideas"
|
||||
eyebrow={`Idea · ${idea.status}`}
|
||||
title={idea.title}
|
||||
tagline={idea.tagline}
|
||||
description={idea.description}
|
||||
primaryCta={
|
||||
idea.discussionUrl
|
||||
? {
|
||||
label: idea.ctaLabel ?? 'Discuss',
|
||||
href: idea.discussionUrl,
|
||||
external: true,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
meta={meta}
|
||||
footer={
|
||||
<RelatedLinksList
|
||||
heading="Related RFPs"
|
||||
hrefBase={ROUTES.rfps}
|
||||
items={related}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<JsonLd
|
||||
data={createBreadcrumbListJsonLd(
|
||||
[
|
||||
{ name: siteConfig.name, path: ROUTES.home },
|
||||
{
|
||||
name: buildersHub.heading ?? buildersHub.title,
|
||||
path: ROUTES.buildersHub,
|
||||
},
|
||||
{ name: listingSettings.breadcrumbLabel, path: ROUTES.ideas },
|
||||
{ name: idea.title, path: `${ROUTES.ideas}/${slug}` },
|
||||
],
|
||||
locale
|
||||
)}
|
||||
/>
|
||||
<BuildersHubDetailLayout
|
||||
backHref={ROUTES.ideas}
|
||||
backLabel="All ideas"
|
||||
eyebrow={`Idea · ${idea.status}`}
|
||||
title={idea.title}
|
||||
tagline={idea.tagline}
|
||||
description={idea.description}
|
||||
primaryCta={
|
||||
idea.discussionUrl
|
||||
? {
|
||||
label: idea.ctaLabel ?? 'Discuss',
|
||||
href: idea.discussionUrl,
|
||||
external: true,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
meta={meta}
|
||||
footer={
|
||||
<RelatedLinksList
|
||||
heading="Related RFPs"
|
||||
hrefBase={ROUTES.rfps}
|
||||
items={related}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<BuildersHubListingClient
|
||||
kind="ideas"
|
||||
settings={settings}
|
||||
items={allIdeas}
|
||||
/>
|
||||
<>
|
||||
<JsonLd
|
||||
data={createBreadcrumbListJsonLd(
|
||||
[
|
||||
{ name: siteConfig.name, path: ROUTES.home },
|
||||
{
|
||||
name: buildersHub.heading ?? buildersHub.title,
|
||||
path: ROUTES.buildersHub,
|
||||
},
|
||||
{ name: settings.breadcrumbLabel, path: ROUTE },
|
||||
],
|
||||
locale
|
||||
)}
|
||||
/>
|
||||
<BuildersHubListingClient
|
||||
kind="ideas"
|
||||
settings={settings}
|
||||
items={allIdeas}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<BuildersHubDetailLayout
|
||||
backHref={ROUTES.rfps}
|
||||
backLabel="All RFPs"
|
||||
eyebrow={`${rfp.number} · ${rfp.status}`}
|
||||
title={rfp.title}
|
||||
tagline={rfp.summary}
|
||||
body={<LegalMarkdown body={stripLeadingHeading(rfp.rawMarkdown)} />}
|
||||
primaryCta={{
|
||||
label: 'Apply',
|
||||
href: RFP_APPLY_URL,
|
||||
external: true,
|
||||
}}
|
||||
meta={meta}
|
||||
footer={
|
||||
<Button href={rfp.githubUrl} variant="secondary">
|
||||
View on GitHub
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<JsonLd
|
||||
data={createBreadcrumbListJsonLd(
|
||||
[
|
||||
{ name: siteConfig.name, path: ROUTES.home },
|
||||
{
|
||||
name: buildersHub.heading ?? buildersHub.title,
|
||||
path: ROUTES.buildersHub,
|
||||
},
|
||||
{ name: listingSettings.breadcrumbLabel, path: ROUTES.rfps },
|
||||
{ name: rfp.title, path: `${ROUTES.rfps}/${slug}` },
|
||||
],
|
||||
locale
|
||||
)}
|
||||
/>
|
||||
<BuildersHubDetailLayout
|
||||
backHref={ROUTES.rfps}
|
||||
backLabel="All RFPs"
|
||||
eyebrow={`${rfp.number} · ${rfp.status}`}
|
||||
title={rfp.title}
|
||||
tagline={rfp.summary}
|
||||
body={<LegalMarkdown body={stripLeadingHeading(rfp.rawMarkdown)} />}
|
||||
primaryCta={{
|
||||
label: 'Apply',
|
||||
href: RFP_APPLY_URL,
|
||||
external: true,
|
||||
}}
|
||||
meta={meta}
|
||||
footer={
|
||||
<Button href={rfp.githubUrl} variant="secondary">
|
||||
View on GitHub
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<BuildersHubListingClient kind="rfps" settings={settings} items={allRfps} />
|
||||
<>
|
||||
<JsonLd
|
||||
data={createBreadcrumbListJsonLd(
|
||||
[
|
||||
{ name: siteConfig.name, path: ROUTES.home },
|
||||
{
|
||||
name: buildersHub.heading ?? buildersHub.title,
|
||||
path: ROUTES.buildersHub,
|
||||
},
|
||||
{ name: settings.breadcrumbLabel, path: ROUTE },
|
||||
],
|
||||
locale
|
||||
)}
|
||||
/>
|
||||
<BuildersHubListingClient
|
||||
kind="rfps"
|
||||
settings={settings}
|
||||
items={allRfps}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 <FieldGuidePageView manifest={manifest} slug={slug} body={body} />
|
||||
const item = flattenFieldGuideItems(manifest).find(
|
||||
(chapter) => chapter.slug === slug
|
||||
)
|
||||
if (!item) notFound()
|
||||
|
||||
return (
|
||||
<>
|
||||
<JsonLd
|
||||
data={createBreadcrumbListJsonLd(
|
||||
[
|
||||
{ name: siteConfig.name, path: ROUTES.home },
|
||||
{ name: manifest.title, path: ROUTES.fieldGuide },
|
||||
{ name: item.title, path: ROUTES.fieldGuideChapter(slug) },
|
||||
],
|
||||
locale
|
||||
)}
|
||||
/>
|
||||
<FieldGuidePageView manifest={manifest} slug={slug} body={body} />
|
||||
</>
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof ContentNotFoundError) notFound()
|
||||
throw error
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<JsonLd data={createOrganizationJsonLd()} />
|
||||
<HeroSectionView data={hero} />
|
||||
<SocialProofSection
|
||||
data={socialProof}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
import BlockchainCryptarchia from '@/components/sections/blockchain/blockchain-cryptarchia'
|
||||
import BlockchainHero from '@/components/sections/blockchain/blockchain-hero'
|
||||
import BlockchainPrivacy from '@/components/sections/blockchain/blockchain-privacy'
|
||||
import { JsonLd } from '@/components/seo/json-ld'
|
||||
import TechStackBuilderCta from '@/components/sections/shared/tech-stack-builder-cta'
|
||||
import {
|
||||
TechStackDetailPage,
|
||||
@@ -18,10 +19,12 @@ import {
|
||||
import TechStackExplorer from '@/components/sections/shared/tech-stack-explorer'
|
||||
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.blockchain
|
||||
|
||||
@@ -73,6 +76,16 @@ export default async function BlockchainPage({
|
||||
|
||||
return (
|
||||
<TechStackDetailPage>
|
||||
<JsonLd
|
||||
data={createBreadcrumbListJsonLd(
|
||||
[
|
||||
{ name: siteConfig.name, path: ROUTES.home },
|
||||
{ name: hero.eyebrow ?? page.title, path: ROUTES.technologyStack },
|
||||
{ name: page.heading ?? page.title, path: ROUTE },
|
||||
],
|
||||
locale
|
||||
)}
|
||||
/>
|
||||
<BlockchainHero data={hero} backHref={ROUTES.technologyStack} />
|
||||
<TechStackDetailSection className="mt-0 border-t border-brand-dark-green/10 md:border-t-0">
|
||||
<BlockchainPrivacy data={privacy} />
|
||||
|
||||
@@ -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 (
|
||||
<TechStackDetailPage>
|
||||
<JsonLd
|
||||
data={createBreadcrumbListJsonLd(
|
||||
[
|
||||
{ name: siteConfig.name, path: ROUTES.home },
|
||||
{ name: hero.eyebrow ?? page.title, path: ROUTES.technologyStack },
|
||||
{ name: page.heading ?? page.title, path: ROUTE },
|
||||
],
|
||||
locale
|
||||
)}
|
||||
/>
|
||||
<MessagingHero data={hero} backHref={ROUTES.technologyStack} />
|
||||
<TechStackDetailSection>
|
||||
<MessagingIntro privacy={privacy} lmn={lmn} censorship={censorship} />
|
||||
|
||||
@@ -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 (
|
||||
<TechStackDetailPage>
|
||||
<JsonLd
|
||||
data={createBreadcrumbListJsonLd(
|
||||
[
|
||||
{ name: siteConfig.name, path: ROUTES.home },
|
||||
{ name: hero.eyebrow ?? page.title, path: ROUTES.technologyStack },
|
||||
{ name: page.heading ?? page.title, path: ROUTE },
|
||||
],
|
||||
locale
|
||||
)}
|
||||
/>
|
||||
<NetworkingHero data={hero} backHref={ROUTES.technologyStack} />
|
||||
<TechStackDetailSection>
|
||||
<NetworkingIntro data={intro} />
|
||||
|
||||
@@ -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 (
|
||||
<TechStackDetailPage>
|
||||
<JsonLd
|
||||
data={createBreadcrumbListJsonLd(
|
||||
[
|
||||
{ name: siteConfig.name, path: ROUTES.home },
|
||||
{ name: hero.eyebrow ?? page.title, path: ROUTES.technologyStack },
|
||||
{ name: page.heading ?? page.title, path: ROUTE },
|
||||
],
|
||||
locale
|
||||
)}
|
||||
/>
|
||||
<StorageHero data={hero} backHref={ROUTES.technologyStack} />
|
||||
<TechStackDetailSection>
|
||||
<StorageMain data={main} />
|
||||
|
||||
@@ -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(/</g, '\\u003c')
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: json }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
<JsonLd
|
||||
data={{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Thing',
|
||||
name: '</script><script>alert(1)</script>',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(html).not.toContain('</script><script>alert(1)</script>')
|
||||
expect(html).toContain('\\u003c/script>')
|
||||
})
|
||||
})
|
||||
@@ -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<JsonLdValue>
|
||||
|
||||
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<BreadcrumbItem>,
|
||||
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),
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,56 @@ const isLocalAssetHref = (href: string): boolean => {
|
||||
)
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
|
||||
const breadcrumbTechRoutes = new Set<string>([
|
||||
ROUTES.blockchain,
|
||||
ROUTES.messaging,
|
||||
ROUTES.networking,
|
||||
ROUTES.storage,
|
||||
])
|
||||
|
||||
const assertStructuredData = (route: string, html: string): string[] => {
|
||||
const failures: string[] = []
|
||||
const types = new Set<string>()
|
||||
const scripts = html.matchAll(
|
||||
/<script[^>]*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) {
|
||||
|
||||
Reference in New Issue
Block a user