Skip to content

Commit e17dbc2

Browse files
committed
chore: render a json-ld object for search engines when the page are dynamic (and the data is originally fetched on frontend)
1 parent c367d79 commit e17dbc2

File tree

3 files changed

+117
-5
lines changed

3 files changed

+117
-5
lines changed

package-lock.json

Lines changed: 16 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@
147147
"react-use": "^17.4.0",
148148
"robots-parser": "^3.0.1",
149149
"rrweb": "^1.1.3",
150+
"schema-dts": "^1.1.2",
150151
"sharp": "^0.32.1",
151152
"simple-git": "^3.22.0",
152153
"superjson": "^1.13.3",

src/app/(public)/initiative/[initiativeId]/page.tsx

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import { Metadata } from 'next';
2+
import { headers } from 'next/headers';
3+
import { notFound } from 'next/navigation';
4+
import { userAgent } from 'next/server';
5+
import { CreativeWork, WithContext } from 'schema-dts';
26

37
import { InitiativePage, InitiativePageProps } from '@etabli/src/app/(public)/initiative/[initiativeId]/InitiativePage';
8+
import { useServerTranslation } from '@etabli/src/i18n';
49
import { GetInitiativeSchema } from '@etabli/src/models/actions/initiative';
510
import { prisma } from '@etabli/src/prisma';
11+
import { initiativePrismaToModel } from '@etabli/src/server/routers/mappers';
612
import { formatPageTitle } from '@etabli/src/utils/page';
713

814
export async function generateMetadata(props: InitiativePageProps): Promise<Metadata> {
@@ -27,6 +33,98 @@ export async function generateMetadata(props: InitiativePageProps): Promise<Meta
2733
};
2834
}
2935

30-
export default function Page(props: InitiativePageProps) {
31-
return <InitiativePage {...props} />;
36+
export default async function Page(props: InitiativePageProps) {
37+
const { t } = useServerTranslation('common');
38+
39+
const userAgentObject = userAgent({ headers: headers() });
40+
41+
// Since this page has a dynamic pathname and the data is fetched from the frontend we add a condition so search engines
42+
// still have content to index in case they don't do client rendering with `puppeteer` or equivalent
43+
let initiativeJsonLd: WithContext<CreativeWork> | null = null;
44+
if (userAgentObject.isBot) {
45+
const result = GetInitiativeSchema.shape.id.safeParse(props.params.initiativeId);
46+
if (!result.success) {
47+
return notFound();
48+
}
49+
50+
// We rely on the UUID validation from `generateMetadata()`
51+
const dbInitiative = await prisma.initiative.findUnique({
52+
where: {
53+
id: result.data,
54+
},
55+
include: {
56+
ToolsOnInitiatives: {
57+
include: {
58+
tool: {
59+
select: {
60+
name: true,
61+
},
62+
},
63+
},
64+
},
65+
BusinessUseCasesOnInitiatives: {
66+
include: {
67+
businessUseCase: {
68+
select: {
69+
name: true,
70+
},
71+
},
72+
},
73+
},
74+
},
75+
});
76+
77+
if (!dbInitiative) {
78+
return notFound();
79+
}
80+
81+
const initiative = initiativePrismaToModel({
82+
...dbInitiative,
83+
businessUseCases: dbInitiative.BusinessUseCasesOnInitiatives.map((bucOnI) => bucOnI.businessUseCase.name),
84+
tools: dbInitiative.ToolsOnInitiatives.map((toolOnI) => toolOnI.tool.name),
85+
});
86+
87+
initiativeJsonLd = {
88+
'@context': 'https://schema.org',
89+
'@type': 'CreativeWork',
90+
// TODO: the link registry cannot be used server-side so not specifying the id (https://github.com/zilch/type-route/issues/125)
91+
// '@id': linkRegistry.get('initiative', { initiativeId: initiative.id }, { absolute: true }), // Must be unique as IRI
92+
name: initiative.name,
93+
description: initiative.description,
94+
url: initiative.websites,
95+
about: [
96+
{
97+
'@type': 'SoftwareSourceCode',
98+
codeRepository: initiative.repositories,
99+
keywords: initiative.tools,
100+
},
101+
],
102+
potentialAction: [
103+
...initiative.businessUseCases.map((businessUseCase) => {
104+
return {
105+
'@type': 'Action' as 'Action',
106+
description: businessUseCase,
107+
disambiguatingDescription: `${t('model.initiative.businessUseCase', { count: 1 })} : ${businessUseCase}`,
108+
};
109+
}),
110+
...initiative.functionalUseCases.map((functionalUseCase) => {
111+
const functionalUseCaseName = t(`model.initiative.functionalUseCase.enum.${functionalUseCase}`);
112+
113+
return {
114+
'@type': 'Action' as 'Action',
115+
description: functionalUseCaseName,
116+
disambiguatingDescription: `${t('model.initiative.functionalUseCase.label', { count: 1 })} : ${functionalUseCaseName}`,
117+
};
118+
}),
119+
],
120+
dateModified: initiative.updatedAt.toISOString(),
121+
};
122+
}
123+
124+
return (
125+
<>
126+
{!!initiativeJsonLd && <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(initiativeJsonLd) }} />}
127+
<InitiativePage {...props} />
128+
</>
129+
);
32130
}

0 commit comments

Comments
 (0)