aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-12-14 15:30:34 +0100
committerArmand Philippot <git@armandphilippot.com>2023-12-14 16:30:04 +0100
commit7063b199b4748a9c354ed37e64cdc84c512f2c0c (patch)
tree7506c3003c56b49a248e9adb40be610780bb540e
parent85c4c42bd601270d7be0f34a0767a34bb85e29bb (diff)
refactor(pages): rewrite helpers to output schema in json-ld format
* make sure url are absolutes * nest breadcrumb schema in webpage schema * trim HTML tags from content/description * use a regular script instead of next/script (with the latter the schema is not updated on route change) * place the script in document head * add keywords, wordCount and readingTime keys in BlogPosting schema * fix breadcrumbs in search page (without query) * add tests (a `MatchInlineSnapshot` will be better but Prettier 3 is not supported yet)
-rw-r--r--src/components/organisms/comment/approved-comment/approved-comment.test.tsx3
-rw-r--r--src/components/organisms/comment/approved-comment/approved-comment.tsx3
-rw-r--r--src/components/templates/layout/layout.tsx47
-rw-r--r--src/components/templates/layout/site-header/site-header.tsx7
-rw-r--r--src/components/templates/page/page-comments.tsx5
-rw-r--r--src/components/templates/page/page.tsx5
m---------src/content0
-rw-r--r--src/i18n/en.json20
-rw-r--r--src/i18n/fr.json20
-rw-r--r--src/pages/404.tsx27
-rw-r--r--src/pages/article/[slug].tsx100
-rw-r--r--src/pages/blog/index.tsx58
-rw-r--r--src/pages/blog/page/[number].tsx57
-rw-r--r--src/pages/contact.tsx47
-rw-r--r--src/pages/cv.tsx57
-rw-r--r--src/pages/index.tsx44
-rw-r--r--src/pages/mentions-legales.tsx47
-rw-r--r--src/pages/projets/[slug].tsx50
-rw-r--r--src/pages/projets/index.tsx50
-rw-r--r--src/pages/recherche/index.tsx57
-rw-r--r--src/pages/sujet/[slug].tsx50
-rw-r--r--src/pages/thematique/[slug].tsx48
-rw-r--r--src/utils/constants.ts5
-rw-r--r--src/utils/helpers/pages.tsx4
-rw-r--r--src/utils/helpers/schema-org.test.ts511
-rw-r--r--src/utils/helpers/schema-org.ts561
-rw-r--r--src/utils/helpers/strings.ts3
-rw-r--r--src/utils/hooks/use-breadcrumbs/use-breadcrumbs.test.tsx7
-rw-r--r--src/utils/hooks/use-breadcrumbs/use-breadcrumbs.ts21
29 files changed, 1284 insertions, 630 deletions
diff --git a/src/components/organisms/comment/approved-comment/approved-comment.test.tsx b/src/components/organisms/comment/approved-comment/approved-comment.test.tsx
index b244a63..473f845 100644
--- a/src/components/organisms/comment/approved-comment/approved-comment.test.tsx
+++ b/src/components/organisms/comment/approved-comment/approved-comment.test.tsx
@@ -1,6 +1,7 @@
import { describe, expect, it } from '@jest/globals';
import { userEvent } from '@testing-library/user-event';
import { render, screen as rtlScreen } from '../../../../../tests/utils';
+import { COMMENT_ID_PREFIX } from '../../../../utils/constants';
import { ApprovedComment, type CommentAuthor } from './approved-comment';
describe('ApprovedComment', () => {
@@ -30,7 +31,7 @@ describe('ApprovedComment', () => {
).toBeInTheDocument();
expect(rtlScreen.getByRole('link')).toHaveAttribute(
'href',
- `#comment-${id}`
+ `#${COMMENT_ID_PREFIX}${id}`
);
});
diff --git a/src/components/organisms/comment/approved-comment/approved-comment.tsx b/src/components/organisms/comment/approved-comment/approved-comment.tsx
index d834ba3..6611c11 100644
--- a/src/components/organisms/comment/approved-comment/approved-comment.tsx
+++ b/src/components/organisms/comment/approved-comment/approved-comment.tsx
@@ -1,6 +1,7 @@
import NextImage from 'next/image';
import { type ForwardRefRenderFunction, forwardRef, useCallback } from 'react';
import { useIntl } from 'react-intl';
+import { COMMENT_ID_PREFIX } from '../../../../utils/constants';
import { Button, Link, Time } from '../../../atoms';
import {
Card,
@@ -99,7 +100,7 @@ const ApprovedCommentWithRef: ForwardRefRenderFunction<
) => {
const intl = useIntl();
const commentClass = `${className}`;
- const commentId = `comment-${id}`;
+ const commentId = `${COMMENT_ID_PREFIX}${id}`;
const commentLink = `#${commentId}`;
const publicationDateLabel = intl.formatMessage({
defaultMessage: 'Published on:',
diff --git a/src/components/templates/layout/layout.tsx b/src/components/templates/layout/layout.tsx
index 4dfe5f3..2a6ac2e 100644
--- a/src/components/templates/layout/layout.tsx
+++ b/src/components/templates/layout/layout.tsx
@@ -1,19 +1,11 @@
-import Script from 'next/script';
import type { FC, ReactElement, ReactNode } from 'react';
import { useIntl } from 'react-intl';
-import type { Person, SearchAction, WebSite, WithContext } from 'schema-dts';
import type { NextPageWithLayoutOptions } from '../../../types';
-import { CONFIG } from '../../../utils/config';
-import { ROUTES } from '../../../utils/constants';
import { ButtonLink, Main } from '../../atoms';
import styles from './layout.module.scss';
import { SiteFooter } from './site-footer';
import { SiteHeader, type SiteHeaderProps } from './site-header';
-export type QueryAction = SearchAction & {
- 'query-input': string;
-};
-
export type LayoutProps = Pick<SiteHeaderProps, 'isHome'> & {
/**
* The layout main content.
@@ -27,7 +19,6 @@ export type LayoutProps = Pick<SiteHeaderProps, 'isHome'> & {
* Render the base layout used by all pages.
*/
export const Layout: FC<LayoutProps> = ({ children, isHome }) => {
- const { baseline, copyright, locales, name, url } = CONFIG;
const intl = useIntl();
const messages = {
noScript: intl.formatMessage({
@@ -43,49 +34,11 @@ export const Layout: FC<LayoutProps> = ({ children, isHome }) => {
}),
};
- const searchActionSchema: QueryAction = {
- '@type': 'SearchAction',
- target: {
- '@type': 'EntryPoint',
- urlTemplate: `${url}${ROUTES.SEARCH}?s={search_term_string}`,
- },
- query: 'required',
- 'query-input': 'required name=search_term_string',
- };
- const brandingSchema: Person = {
- '@type': 'Person',
- name,
- url,
- jobTitle: baseline,
- image: '/armand-philippot.jpg',
- subjectOf: { '@id': `${url}` },
- };
- const schemaJsonLd: WithContext<WebSite> = {
- '@context': 'https://schema.org',
- '@id': `${url}`,
- '@type': 'WebSite',
- name,
- description: baseline,
- url,
- author: brandingSchema,
- copyrightYear: Number(copyright.startYear),
- creator: brandingSchema,
- editor: brandingSchema,
- inLanguage: locales.defaultLocale,
- potentialAction: searchActionSchema,
- };
-
const topId = 'top';
const mainId = 'main';
return (
<>
- <Script
- dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
- // eslint-disable-next-line react/jsx-no-literals -- Id allowed
- id="schema-layout"
- type="application/ld+json"
- />
<span id={topId} />
<noscript>
<div className={styles['noscript-spacing']} />
diff --git a/src/components/templates/layout/site-header/site-header.tsx b/src/components/templates/layout/site-header/site-header.tsx
index 3e06350..91add77 100644
--- a/src/components/templates/layout/site-header/site-header.tsx
+++ b/src/components/templates/layout/site-header/site-header.tsx
@@ -1,4 +1,5 @@
import { type ForwardRefRenderFunction, forwardRef } from 'react';
+import { AUTHOR_ID } from '../../../../utils/constants';
import { Header, type HeaderProps } from '../../../atoms';
import { SiteBranding } from './site-branding';
import styles from './site-header.module.scss';
@@ -16,7 +17,11 @@ const SiteHeaderWithRef: ForwardRefRenderFunction<
return (
<Header {...props} className={headerClass} ref={ref}>
- <SiteBranding className={styles.branding} isHome={isHome} />
+ <SiteBranding
+ className={styles.branding}
+ id={AUTHOR_ID}
+ isHome={isHome}
+ />
<SiteNavbar className={styles.navbar} />
</Header>
);
diff --git a/src/components/templates/page/page-comments.tsx b/src/components/templates/page/page-comments.tsx
index 5f5208f..01c4eea 100644
--- a/src/components/templates/page/page-comments.tsx
+++ b/src/components/templates/page/page-comments.tsx
@@ -10,6 +10,7 @@ import {
createComment,
type CreateCommentInput,
} from '../../../services/graphql';
+import { COMMENTS_SECTION_ID } from '../../../utils/constants';
import { Heading, Link, Section } from '../../atoms';
import { Card, CardBody } from '../../molecules';
import {
@@ -27,7 +28,7 @@ const link = (chunks: ReactNode) => (
export type PageCommentsProps = Omit<
HTMLAttributes<HTMLDivElement>,
- 'children' | 'onSubmit'
+ 'children' | 'id' | 'onSubmit'
> &
Pick<CommentsListProps, 'depth'> & {
/**
@@ -139,7 +140,7 @@ const PageCommentsWithRef: ForwardRefRenderFunction<
);
return (
- <div {...props} className={wrapperClass} ref={ref}>
+ <div {...props} className={wrapperClass} id={COMMENTS_SECTION_ID} ref={ref}>
<Section className={styles.comments__body}>
<Heading className={styles.heading} level={2}>
{commentsListTitle}
diff --git a/src/components/templates/page/page.tsx b/src/components/templates/page/page.tsx
index b40c2f9..e3a4453 100644
--- a/src/components/templates/page/page.tsx
+++ b/src/components/templates/page/page.tsx
@@ -4,6 +4,7 @@ import {
type HTMLAttributes,
} from 'react';
import { useIntl } from 'react-intl';
+import { ARTICLE_ID } from '../../../utils/constants';
import { Article } from '../../atoms';
import { Breadcrumbs, type BreadcrumbsItem } from '../../organisms/nav';
import styles from './page.module.scss';
@@ -63,7 +64,9 @@ const PageWithRef: ForwardRefRenderFunction<HTMLDivElement, PageProps> = (
items={breadcrumbs}
/>
) : null}
- <Article className={pageClass}>{children}</Article>
+ <Article className={pageClass} id={ARTICLE_ID}>
+ {children}
+ </Article>
</div>
);
};
diff --git a/src/content b/src/content
-Subproject 1ec792edf94bc5f69e2b92c6b020804387920d1
+Subproject bc0c32fb3b59f854768da67bb3d0b607d87b056
diff --git a/src/i18n/en.json b/src/i18n/en.json
index f971c93..411bb06 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -507,6 +507,10 @@
"defaultMessage": "Contact form",
"description": "Contact: form accessible name"
},
+ "bW6Zda": {
+ "defaultMessage": "Discover search results for {query} on {websiteName} website.",
+ "description": "SearchPage: SEO - Meta description"
+ },
"c0Oecl": {
"defaultMessage": "Created on:",
"description": "ProjectOverview: creation date label"
@@ -583,6 +587,10 @@
"defaultMessage": "CC BY SA",
"description": "SiteFooter: the license name"
},
+ "j3+hB9": {
+ "defaultMessage": "Home",
+ "description": "HomePage: page title"
+ },
"jJm8wd": {
"defaultMessage": "Reading time:",
"description": "PageHeader: reading time label"
@@ -631,10 +639,6 @@
"defaultMessage": "Leave a reply to comment {id}",
"description": "ReplyCommentForm: an accessible name for the reply form"
},
- "npisb3": {
- "defaultMessage": "Search for a post on {websiteName}.",
- "description": "SearchPage: SEO - Meta description"
- },
"nsw6Th": {
"defaultMessage": "Copied!",
"description": "usePrism: copy button text (clicked)"
@@ -671,14 +675,14 @@
"defaultMessage": "Off",
"description": "MotionToggle: deactivate reduce motion label"
},
- "pg26sn": {
- "defaultMessage": "Discover search results for {query} on {websiteName}.",
- "description": "SearchPage: SEO - Meta description"
- },
"qFqWQH": {
"defaultMessage": "Thematics are loading...",
"description": "SearchPage: loading thematics message"
},
+ "rEp1mS": {
+ "defaultMessage": "Search for a post on {websiteName} website.",
+ "description": "SearchPage: SEO - Meta description"
+ },
"rVoW4G": {
"defaultMessage": "Thematics are loading...",
"description": "ThematicPage: loading thematics message"
diff --git a/src/i18n/fr.json b/src/i18n/fr.json
index 0989e07..399abcf 100644
--- a/src/i18n/fr.json
+++ b/src/i18n/fr.json
@@ -507,6 +507,10 @@
"defaultMessage": "Formulaire de contact",
"description": "Contact: form accessible name"
},
+ "bW6Zda": {
+ "defaultMessage": "Découvrez les résultats de recherche pour {query} sur le site d’{websiteName}.",
+ "description": "SearchPage: SEO - Meta description"
+ },
"c0Oecl": {
"defaultMessage": "Créé le :",
"description": "ProjectOverview: creation date label"
@@ -583,6 +587,10 @@
"defaultMessage": "CC BY SA",
"description": "SiteFooter: the license name"
},
+ "j3+hB9": {
+ "defaultMessage": "Accueil",
+ "description": "HomePage: page title"
+ },
"jJm8wd": {
"defaultMessage": "Temps de lecture :",
"description": "PageHeader: reading time label"
@@ -631,10 +639,6 @@
"defaultMessage": "Répondre au commentaire {id}",
"description": "ReplyCommentForm: an accessible name for the reply form"
},
- "npisb3": {
- "defaultMessage": "Rechercher un article sur {websiteName}.",
- "description": "SearchPage: SEO - Meta description"
- },
"nsw6Th": {
"defaultMessage": "Copié !",
"description": "usePrism: copy button text (clicked)"
@@ -671,14 +675,14 @@
"defaultMessage": "Arrêt",
"description": "MotionToggle: deactivate reduce motion label"
},
- "pg26sn": {
- "defaultMessage": "Découvrez les résultats de recherche pour {query} sur {websiteName}.",
- "description": "SearchPage: SEO - Meta description"
- },
"qFqWQH": {
"defaultMessage": "Les thématiques sont en cours de chargement…",
"description": "SearchPage: loading thematics message"
},
+ "rEp1mS": {
+ "defaultMessage": "Rechercher un article sur le site d’{websiteName}.",
+ "description": "SearchPage: SEO - Meta description"
+ },
"rVoW4G": {
"defaultMessage": "Les thématiques sont en cours de chargement…",
"description": "ThematicPage: loading thematics message"
diff --git a/src/pages/404.tsx b/src/pages/404.tsx
index 450859c..72c252e 100644
--- a/src/pages/404.tsx
+++ b/src/pages/404.tsx
@@ -1,7 +1,6 @@
import type { GetStaticProps } from 'next';
import Head from 'next/head';
import { useRouter } from 'next/router';
-import Script from 'next/script';
import { useCallback, type ReactNode } from 'react';
import { useIntl } from 'react-intl';
import {
@@ -34,7 +33,11 @@ import type {
} from '../types';
import { CONFIG } from '../utils/config';
import { ROUTES } from '../utils/constants';
-import { getLinksItemData } from '../utils/helpers';
+import {
+ getLinksItemData,
+ getSchemaFrom,
+ getWebPageGraph,
+} from '../utils/helpers';
import { loadTranslation, type Messages } from '../utils/helpers/server';
import {
useBreadcrumbs,
@@ -118,6 +121,15 @@ const Error404Page: NextPageWithLayout<Error404PageProps> = ({ data }) => {
messages.page.title
);
+ const jsonLd = getSchemaFrom([
+ getWebPageGraph({
+ breadcrumb: breadcrumbSchema,
+ description: messages.seo.metaDesc,
+ slug: ROUTES.NOT_FOUND,
+ title: messages.page.title,
+ }),
+ ]);
+
const searchSubmitHandler: SearchFormSubmit = useCallback(
async ({ query }) => {
if (!query)
@@ -145,13 +157,12 @@ const Error404Page: NextPageWithLayout<Error404PageProps> = ({ data }) => {
<title>{messages.seo.title}</title>
{/*eslint-disable-next-line react/jsx-no-literals -- Name allowed */}
<meta name="description" content={messages.seo.metaDesc} />
+ <script
+ // eslint-disable-next-line react/no-danger
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
+ type="application/ld+json"
+ />
</Head>
- <Script
- dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
- // eslint-disable-next-line react/jsx-no-literals -- Id allowed
- id="schema-breadcrumb"
- type="application/ld+json"
- />
<PageHeader heading={messages.page.title} />
<PageBody className={styles['no-results']}>
<p>
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
index 6333056..e18de75 100644
--- a/src/pages/article/[slug].tsx
+++ b/src/pages/article/[slug].tsx
@@ -3,7 +3,6 @@ import type { ParsedUrlQuery } from 'querystring';
import type { GetStaticPaths, GetStaticProps } from 'next';
import Head from 'next/head';
import { useRouter } from 'next/router';
-import Script from 'next/script';
import { useCallback } from 'react';
import { useIntl } from 'react-intl';
import {
@@ -36,11 +35,12 @@ import type {
} from '../../types';
import { CONFIG } from '../../utils/config';
import {
- getBlogSchema,
- getCommentsSchema,
- getSchemaJson,
- getSinglePageSchema,
- getWebPageSchema,
+ getBlogPostingGraph,
+ getCommentGraph,
+ getReadingTimeFrom,
+ getSchemaFrom,
+ getWebPageGraph,
+ trimHTMLTags,
updateWordPressCodeBlocks,
} from '../../utils/helpers';
import { loadTranslation, type Messages } from '../../utils/helpers/server';
@@ -129,9 +129,17 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ data }) => {
[intl]
);
+ const flattenComments = useCallback(
+ (allComments: SingleComment[]): SingleComment[] => [
+ ...allComments,
+ ...allComments.flatMap((comment) => flattenComments(comment.replies)),
+ ],
+ []
+ );
+
if (isFallback || isLoading) return <LoadingPage />;
- const { content, id, intro, meta, title } = article;
+ const { content, id, intro, meta, slug, title } = article;
const {
author,
commentsCount,
@@ -143,36 +151,42 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ data }) => {
wordsCount,
} = meta;
- const webpageSchema = getWebPageSchema({
- description: intro,
- locale: CONFIG.locales.defaultLocale,
- slug: article.slug,
- title,
- updateDate: dates.update,
- });
- const blogSchema = getBlogSchema({
- isSinglePage: true,
- locale: CONFIG.locales.defaultLocale,
- slug: article.slug,
- });
- const blogPostSchema = getSinglePageSchema({
- commentsCount,
- content,
- cover: cover?.src,
- dates,
- description: intro,
- id: 'article',
- kind: 'post',
- locale: CONFIG.locales.defaultLocale,
- slug: article.slug,
- title,
- });
- const schemaJsonLd = getSchemaJson([
- webpageSchema,
- blogSchema,
- blogPostSchema,
- breadcrumbSchema,
- ...getCommentsSchema(comments),
+ const jsonLd = getSchemaFrom([
+ getWebPageGraph({
+ breadcrumb: breadcrumbSchema,
+ copyrightYear: new Date(dates.publication).getFullYear(),
+ dates,
+ description: trimHTMLTags(intro),
+ slug,
+ title,
+ }),
+ getBlogPostingGraph({
+ body: trimHTMLTags(content),
+ comment: flattenComments(comments).map((comment) =>
+ getCommentGraph({
+ articleSlug: slug,
+ author: {
+ '@type': 'Person',
+ name: comment.meta.author.name,
+ url: comment.meta.author.website,
+ },
+ body: trimHTMLTags(comment.content),
+ id: `${comment.id}`,
+ parentId: comment.parentId ? `${comment.parentId}` : undefined,
+ publishedAt: comment.meta.date,
+ })
+ ),
+ commentCount: commentsCount,
+ copyrightYear: new Date(dates.publication).getFullYear(),
+ cover: cover?.src,
+ dates,
+ description: trimHTMLTags(intro),
+ keywords: topics?.map((topic) => topic.name).join(', '),
+ readingTime: `PT${getReadingTimeFrom(wordsCount).inMinutes()}M`,
+ slug,
+ title,
+ wordCount: meta.wordsCount,
+ }),
]);
const pageUrl = `${CONFIG.url}${article.slug}`;
@@ -200,14 +214,12 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ data }) => {
<meta property="og:type" content="article" />
<meta property="og:title" content={title} />
<meta property="og:description" content={intro} />
+ <script
+ // eslint-disable-next-line react/no-danger
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
+ type="application/ld+json"
+ />
</Head>
- <Script
- // eslint-disable-next-line react/jsx-no-literals -- Id allowed
- id="schema-article"
- type="application/ld+json"
- // eslint-disable-next-line react/no-danger -- Necessary for schema
- dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
- />
<PageHeader
heading={title}
intro={intro}
diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx
index 49c16b1..f58d36f 100644
--- a/src/pages/blog/index.tsx
+++ b/src/pages/blog/index.tsx
@@ -1,7 +1,5 @@
-/* eslint-disable max-statements */
import type { GetStaticProps } from 'next';
import Head from 'next/head';
-import Script from 'next/script';
import { useCallback } from 'react';
import { useIntl } from 'react-intl';
import {
@@ -37,13 +35,17 @@ import type {
WPTopicPreview,
} from '../../types';
import { CONFIG } from '../../utils/config';
-import { PAGINATED_ROUTE_PREFIX, ROUTES } from '../../utils/constants';
import {
- getBlogSchema,
+ ARTICLE_ID,
+ PAGINATED_ROUTE_PREFIX,
+ ROUTES,
+} from '../../utils/constants';
+import {
+ getBlogGraph,
getLinksItemData,
getPostsWithUrl,
- getSchemaJson,
- getWebPageSchema,
+ getSchemaFrom,
+ getWebPageGraph,
} from '../../utils/helpers';
import { loadTranslation, type Messages } from '../../utils/helpers/server';
import {
@@ -160,21 +162,23 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ data }) => {
messages.pageTitle
);
- const webpageSchema = getWebPageSchema({
- description: messages.seo.metaDesc,
- locale: CONFIG.locales.defaultLocale,
- slug: ROUTES.BLOG,
- title: messages.pageTitle,
- });
- const blogSchema = getBlogSchema({
- isSinglePage: false,
- locale: CONFIG.locales.defaultLocale,
- slug: ROUTES.BLOG,
- });
- const schemaJsonLd = getSchemaJson([
- webpageSchema,
- blogSchema,
- breadcrumbSchema,
+ const jsonLd = getSchemaFrom([
+ getWebPageGraph({
+ breadcrumb: breadcrumbSchema,
+ description: messages.seo.metaDesc,
+ slug: ROUTES.BLOG,
+ title: messages.pageTitle,
+ }),
+ getBlogGraph({
+ description: '',
+ posts: articles?.flatMap((page) =>
+ page.edges.map(({ node }) => {
+ return { '@id': `${node.slug}#${ARTICLE_ID}` };
+ })
+ ),
+ slug: ROUTES.BLOG,
+ title: messages.pageTitle,
+ }),
]);
const renderPaginationLabel: RenderPaginationItemAriaLabel = useCallback(
@@ -235,14 +239,12 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ data }) => {
<meta property="og:type" content="website" />
<meta property="og:title" content={messages.pageTitle} />
<meta property="og:description" content={messages.seo.metaDesc} />
+ <script
+ // eslint-disable-next-line react/no-danger
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
+ type="application/ld+json"
+ />
</Head>
- <Script
- // eslint-disable-next-line react/jsx-no-literals -- Id allowed
- id="schema-blog"
- type="application/ld+json"
- // eslint-disable-next-line react/no-danger -- Necessary for schema
- dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
- />
<PageHeader
heading={messages.pageTitle}
meta={{ total: data.posts.pageInfo.total }}
diff --git a/src/pages/blog/page/[number].tsx b/src/pages/blog/page/[number].tsx
index 906a08e..fa1123d 100644
--- a/src/pages/blog/page/[number].tsx
+++ b/src/pages/blog/page/[number].tsx
@@ -3,7 +3,6 @@ import type { ParsedUrlQuery } from 'querystring';
import type { GetStaticPaths, GetStaticProps } from 'next';
import Head from 'next/head';
import { useRouter } from 'next/router';
-import Script from 'next/script';
import { useCallback } from 'react';
import { useIntl } from 'react-intl';
import {
@@ -44,13 +43,17 @@ import type {
WPTopicPreview,
} from '../../../types';
import { CONFIG } from '../../../utils/config';
-import { PAGINATED_ROUTE_PREFIX, ROUTES } from '../../../utils/constants';
import {
- getBlogSchema,
+ ARTICLE_ID,
+ PAGINATED_ROUTE_PREFIX,
+ ROUTES,
+} from '../../../utils/constants';
+import {
+ getBlogGraph,
getLinksItemData,
getPostsWithUrl,
- getSchemaJson,
- getWebPageSchema,
+ getSchemaFrom,
+ getWebPageGraph,
} from '../../../utils/helpers';
import { loadTranslation, type Messages } from '../../../utils/helpers/server';
import {
@@ -189,21 +192,23 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
messages.pageTitle
);
- const webpageSchema = getWebPageSchema({
- description: messages.seo.metaDesc,
- locale: CONFIG.locales.defaultLocale,
- slug: ROUTES.BLOG,
- title: messages.pageTitle,
- });
- const blogSchema = getBlogSchema({
- isSinglePage: false,
- locale: CONFIG.locales.defaultLocale,
- slug: ROUTES.BLOG,
- });
- const schemaJsonLd = getSchemaJson([
- webpageSchema,
- blogSchema,
- breadcrumbSchema,
+ const jsonLd = getSchemaFrom([
+ getWebPageGraph({
+ breadcrumb: breadcrumbSchema,
+ description: messages.seo.metaDesc,
+ slug: ROUTES.BLOG,
+ title: messages.pageTitle,
+ }),
+ getBlogGraph({
+ description: '',
+ posts: articles?.flatMap((page) =>
+ page.edges.map(({ node }) => {
+ return { '@id': `${node.slug}#${ARTICLE_ID}` };
+ })
+ ),
+ slug: ROUTES.BLOG,
+ title: messages.pageTitle,
+ }),
]);
const renderPaginationLabel: RenderPaginationItemAriaLabel = useCallback(
@@ -266,14 +271,12 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
<meta property="og:type" content="website" />
<meta property="og:title" content={messages.pageTitle} />
<meta property="og:description" content={messages.seo.metaDesc} />
+ <script
+ // eslint-disable-next-line react/no-danger
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
+ type="application/ld+json"
+ />
</Head>
- <Script
- // eslint-disable-next-line react/jsx-no-literals -- Id allowed
- id="schema-blog"
- type="application/ld+json"
- // eslint-disable-next-line react/no-danger -- Necessary for schema
- dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
- />
<PageHeader
heading={messages.pageTitle}
meta={{ total: data.posts.pageInfo.total }}
diff --git a/src/pages/contact.tsx b/src/pages/contact.tsx
index 264ca56..7cf17f0 100644
--- a/src/pages/contact.tsx
+++ b/src/pages/contact.tsx
@@ -1,6 +1,5 @@
import type { GetStaticProps } from 'next';
import Head from 'next/head';
-import Script from 'next/script';
import { useCallback } from 'react';
import { useIntl } from 'react-intl';
import {
@@ -19,11 +18,7 @@ import { sendEmail } from '../services/graphql';
import type { NextPageWithLayout } from '../types';
import { CONFIG } from '../utils/config';
import { ROUTES } from '../utils/constants';
-import {
- getSchemaJson,
- getSinglePageSchema,
- getWebPageSchema,
-} from '../utils/helpers';
+import { getContactPageGraph, getSchemaFrom } from '../utils/helpers';
import { loadTranslation } from '../utils/helpers/server';
import { useBreadcrumbs } from '../utils/hooks';
@@ -65,26 +60,15 @@ const ContactPage: NextPageWithLayout = () => {
},
};
- const webpageSchema = getWebPageSchema({
- description: seo.description,
- locale: CONFIG.locales.defaultLocale,
- slug: ROUTES.CONTACT,
- title: seo.title,
- updateDate: dates.update,
- });
- const contactSchema = getSinglePageSchema({
- dates,
- description: intro,
- id: 'contact',
- kind: 'contact',
- locale: CONFIG.locales.defaultLocale,
- slug: ROUTES.CONTACT,
- title,
- });
- const schemaJsonLd = getSchemaJson([
- webpageSchema,
- contactSchema,
- breadcrumbSchema,
+ const jsonLd = getSchemaFrom([
+ getContactPageGraph({
+ breadcrumb: breadcrumbSchema,
+ copyrightYear: new Date(dates.publication).getFullYear(),
+ dates,
+ description: intro,
+ slug: ROUTES.CONTACT,
+ title,
+ }),
]);
const submitMail: ContactFormSubmit = useCallback(
@@ -143,13 +127,12 @@ const ContactPage: NextPageWithLayout = () => {
<meta property="og:type" content="article" />
<meta property="og:title" content={title} />
<meta property="og:description" content={intro} />
+ <script
+ // eslint-disable-next-line react/no-danger
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
+ type="application/ld+json"
+ />
</Head>
- <Script
- // eslint-disable-next-line react/jsx-no-literals -- Id allowed
- id="schema-contact"
- type="application/ld+json"
- dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
- />
<PageHeader heading={title} intro={intro} />
<PageBody>
<ContactForm aria-label={messages.form} onSubmit={submitMail} />
diff --git a/src/pages/cv.tsx b/src/pages/cv.tsx
index d08c121..92c3e9e 100644
--- a/src/pages/cv.tsx
+++ b/src/pages/cv.tsx
@@ -1,9 +1,6 @@
-/* eslint-disable max-statements */
import type { GetStaticProps } from 'next';
import Head from 'next/head';
import NextImage from 'next/image';
-import { useRouter } from 'next/router';
-import Script from 'next/script';
import React, { type ReactNode } from 'react';
import { useIntl } from 'react-intl';
import {
@@ -22,12 +19,8 @@ import { mdxComponents } from '../components/mdx';
import CVContent, { data, meta } from '../content/pages/cv.mdx';
import type { NextPageWithLayout } from '../types';
import { CONFIG } from '../utils/config';
-import { PERSONAL_LINKS } from '../utils/constants';
-import {
- getSchemaJson,
- getSinglePageSchema,
- getWebPageSchema,
-} from '../utils/helpers';
+import { PERSONAL_LINKS, ROUTES } from '../utils/constants';
+import { getAboutPageGraph, getSchemaFrom } from '../utils/helpers';
import { loadTranslation } from '../utils/helpers/server';
import { useBreadcrumbs, useHeadingsTree } from '../utils/hooks';
@@ -95,32 +88,21 @@ const CVPage: NextPageWithLayout = () => {
},
};
- const { asPath } = useRouter();
- const webpageSchema = getWebPageSchema({
- description: seo.description,
- locale: CONFIG.locales.defaultLocale,
- slug: asPath,
- title: seo.title,
- updateDate: dates.update,
- });
- const cvSchema = getSinglePageSchema({
- cover: data.image.src,
- dates,
- description: intro,
- id: 'cv',
- kind: 'about',
- locale: CONFIG.locales.defaultLocale,
- slug: asPath,
- title,
- });
- const schemaJsonLd = getSchemaJson([
- webpageSchema,
- cvSchema,
- breadcrumbSchema,
+ const jsonLd = getSchemaFrom([
+ getAboutPageGraph({
+ breadcrumb: breadcrumbSchema,
+ copyrightYear: new Date(dates.publication).getFullYear(),
+ cover: data.image.src,
+ dates,
+ description: intro,
+ slug: ROUTES.CV,
+ title,
+ }),
]);
+
const page = {
title: `${seo.title} - ${CONFIG.name}`,
- url: `${CONFIG.url}${asPath}`,
+ url: `${CONFIG.url}${ROUTES.CV}`,
};
return (
@@ -136,13 +118,12 @@ const CVPage: NextPageWithLayout = () => {
<meta property="og:description" content={intro} />
<meta property="og:image" content={data.image.src} />
<meta property="og:image:alt" content={title} />
+ <script
+ // eslint-disable-next-line react/no-danger
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
+ type="application/ld+json"
+ />
</Head>
- <Script
- // eslint-disable-next-line react/jsx-no-literals -- Id allowed
- id="schema-cv"
- type="application/ld+json"
- dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
- />
<PageHeader
heading={title}
intro={intro}
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index ade628a..0e6bb23 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -2,7 +2,6 @@ import type { MDXComponents } from 'mdx/types';
import type { GetStaticProps } from 'next';
import Head from 'next/head';
import NextImage from 'next/image';
-import Script from 'next/script';
import type { FC } from 'react';
import { useIntl } from 'react-intl';
import {
@@ -27,7 +26,11 @@ import {
import type { NextPageWithLayout, RecentArticle } from '../types';
import { CONFIG } from '../utils/config';
import { ROUTES } from '../utils/constants';
-import { getSchemaJson, getWebPageSchema } from '../utils/helpers';
+import {
+ getSchemaFrom,
+ getWebPageGraph,
+ getWebSiteGraph,
+} from '../utils/helpers';
import { loadTranslation, type Messages } from '../utils/helpers/server';
import { useBreadcrumbs } from '../utils/hooks';
@@ -129,15 +132,29 @@ type HomeProps = {
* Home page.
*/
const HomePage: NextPageWithLayout<HomeProps> = ({ recentPosts }) => {
+ const intl = useIntl();
const { schema: breadcrumbSchema } = useBreadcrumbs();
- const webpageSchema = getWebPageSchema({
- description: meta.seo.description,
- locale: CONFIG.locales.defaultLocale,
- slug: ROUTES.HOME,
- title: meta.seo.title,
+ const pageTitle = intl.formatMessage({
+ defaultMessage: 'Home',
+ description: 'HomePage: page title',
+ id: 'j3+hB9',
});
- const schemaJsonLd = getSchemaJson([webpageSchema, breadcrumbSchema]);
+
+ const jsonLd = getSchemaFrom([
+ getWebSiteGraph({
+ description: CONFIG.baseline,
+ title: CONFIG.name,
+ }),
+ getWebPageGraph({
+ breadcrumb: breadcrumbSchema,
+ copyrightYear: new Date(meta.dates.publication).getFullYear(),
+ dates: meta.dates,
+ description: meta.seo.description,
+ slug: ROUTES.HOME,
+ title: pageTitle,
+ }),
+ ]);
return (
<Page hasSections>
@@ -148,13 +165,12 @@ const HomePage: NextPageWithLayout<HomeProps> = ({ recentPosts }) => {
<meta property="og:url" content={CONFIG.url} />
<meta property="og:title" content={meta.seo.title} />
<meta property="og:description" content={meta.seo.description} />
+ <script
+ // eslint-disable-next-line react/no-danger
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
+ type="application/ld+json"
+ />
</Head>
- <Script
- // eslint-disable-next-line react/jsx-no-literals -- Id allowed
- id="schema-homepage"
- type="application/ld+json"
- dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
- />
<HomePageContent components={getComponents(recentPosts)} />
</Page>
);
diff --git a/src/pages/mentions-legales.tsx b/src/pages/mentions-legales.tsx
index 8613898..13fd919 100644
--- a/src/pages/mentions-legales.tsx
+++ b/src/pages/mentions-legales.tsx
@@ -1,6 +1,5 @@
import type { GetStaticProps } from 'next';
import Head from 'next/head';
-import Script from 'next/script';
import { useIntl } from 'react-intl';
import {
getLayout,
@@ -16,11 +15,7 @@ import LegalNoticeContent, { meta } from '../content/pages/legal-notice.mdx';
import type { NextPageWithLayout } from '../types';
import { CONFIG } from '../utils/config';
import { ROUTES } from '../utils/constants';
-import {
- getSchemaJson,
- getSinglePageSchema,
- getWebPageSchema,
-} from '../utils/helpers';
+import { getSchemaFrom, getWebPageGraph } from '../utils/helpers';
import { loadTranslation } from '../utils/helpers/server';
import { useBreadcrumbs, useHeadingsTree } from '../utils/hooks';
@@ -34,26 +29,15 @@ const LegalNoticePage: NextPageWithLayout = () => {
useBreadcrumbs(title);
const { ref, tree } = useHeadingsTree<HTMLDivElement>({ fromLevel: 2 });
- const webpageSchema = getWebPageSchema({
- description: seo.description,
- locale: CONFIG.locales.defaultLocale,
- slug: ROUTES.LEGAL_NOTICE,
- title: seo.title,
- updateDate: dates.update,
- });
- const articleSchema = getSinglePageSchema({
- dates,
- description: intro,
- id: 'legal-notice',
- kind: 'page',
- locale: CONFIG.locales.defaultLocale,
- slug: ROUTES.LEGAL_NOTICE,
- title,
- });
- const schemaJsonLd = getSchemaJson([
- webpageSchema,
- articleSchema,
- breadcrumbSchema,
+ const jsonLd = getSchemaFrom([
+ getWebPageGraph({
+ breadcrumb: breadcrumbSchema,
+ copyrightYear: new Date(dates.publication).getFullYear(),
+ dates,
+ description: intro,
+ slug: ROUTES.LEGAL_NOTICE,
+ title,
+ }),
]);
const page = {
@@ -77,13 +61,12 @@ const LegalNoticePage: NextPageWithLayout = () => {
<meta property="og:type" content="article" />
<meta property="og:title" content={page.title} />
<meta property="og:description" content={intro} />
+ <script
+ // eslint-disable-next-line react/no-danger
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
+ type="application/ld+json"
+ />
</Head>
- <Script
- // eslint-disable-next-line react/jsx-no-literals -- Id allowed
- id="schema-legal-notice"
- type="application/ld+json"
- dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
- />
<PageHeader
heading={title}
intro={intro}
diff --git a/src/pages/projets/[slug].tsx b/src/pages/projets/[slug].tsx
index 8985f47..1f9723a 100644
--- a/src/pages/projets/[slug].tsx
+++ b/src/pages/projets/[slug].tsx
@@ -1,10 +1,8 @@
-/* eslint-disable max-statements */
import type { MDXComponents } from 'mdx/types';
import type { GetStaticPaths, GetStaticProps } from 'next';
import dynamic from 'next/dynamic';
import Head from 'next/head';
import NextImage from 'next/image';
-import Script from 'next/script';
import { useMemo, type ComponentType, type FC } from 'react';
import { useIntl } from 'react-intl';
import {
@@ -38,9 +36,8 @@ import type {
import { CONFIG } from '../../utils/config';
import {
capitalize,
- getSchemaJson,
- getSinglePageSchema,
- getWebPageSchema,
+ getSchemaFrom,
+ getWebPageGraph,
} from '../../utils/helpers';
import {
type Messages,
@@ -192,27 +189,16 @@ const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ data }) => {
url: `${CONFIG.url}${slug}`,
};
- const webpageSchema = getWebPageSchema({
- description: meta.seo.description,
- locale: CONFIG.locales.defaultLocale,
- slug,
- title: meta.seo.title,
- updateDate: meta.dates.update,
- });
- const articleSchema = getSinglePageSchema({
- cover: `/projects/${id}.jpg`,
- dates: meta.dates,
- description: intro,
- id: 'project',
- kind: 'page',
- locale: CONFIG.locales.defaultLocale,
- slug,
- title,
- });
- const schemaJsonLd = getSchemaJson([
- webpageSchema,
- articleSchema,
- breadcrumbSchema,
+ const jsonLd = getSchemaFrom([
+ getWebPageGraph({
+ breadcrumb: breadcrumbSchema,
+ copyrightYear: new Date(meta.dates.publication).getFullYear(),
+ cover: `/projects/${id}.jpg`,
+ dates: meta.dates,
+ description: intro,
+ slug,
+ title,
+ }),
]);
const messages = {
@@ -256,14 +242,12 @@ const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ data }) => {
<meta property="og:type" content="article" />
<meta property="og:title" content={title} />
<meta property="og:description" content={intro} />
+ <script
+ // eslint-disable-next-line react/no-danger
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
+ type="application/ld+json"
+ />
</Head>
- <Script
- // eslint-disable-next-line react/jsx-no-literals -- Id allowed
- id="schema-project"
- type="application/ld+json"
- // eslint-disable-next-line react/no-danger -- Necessary for schema
- dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
- />
<PageHeader
heading={title}
intro={intro}
diff --git a/src/pages/projets/index.tsx b/src/pages/projets/index.tsx
index 401c68c..3815370 100644
--- a/src/pages/projets/index.tsx
+++ b/src/pages/projets/index.tsx
@@ -1,7 +1,6 @@
import type { GetStaticProps } from 'next';
import Head from 'next/head';
import NextImage from 'next/image';
-import Script from 'next/script';
import { useIntl } from 'react-intl';
import {
Card,
@@ -25,11 +24,7 @@ import styles from '../../styles/pages/projects.module.scss';
import type { NextPageWithLayout, ProjectPreview } from '../../types';
import { CONFIG } from '../../utils/config';
import { ROUTES } from '../../utils/constants';
-import {
- getSchemaJson,
- getSinglePageSchema,
- getWebPageSchema,
-} from '../../utils/helpers';
+import { getSchemaFrom, getWebPageGraph } from '../../utils/helpers';
import {
getAllProjects,
loadTranslation,
@@ -52,27 +47,18 @@ const ProjectsPage: NextPageWithLayout<ProjectsPageProps> = ({ data }) => {
const { items: breadcrumbItems, schema: breadcrumbSchema } =
useBreadcrumbs(title);
const intl = useIntl();
- const webpageSchema = getWebPageSchema({
- description: seo.description,
- locale: CONFIG.locales.defaultLocale,
- slug: ROUTES.PROJECTS,
- title: seo.title,
- updateDate: dates.update,
- });
- const articleSchema = getSinglePageSchema({
- dates,
- description: seo.description,
- id: 'projects',
- kind: 'page',
- locale: CONFIG.locales.defaultLocale,
- slug: ROUTES.PROJECTS,
- title,
- });
- const schemaJsonLd = getSchemaJson([
- webpageSchema,
- articleSchema,
- breadcrumbSchema,
+
+ const jsonLd = getSchemaFrom([
+ getWebPageGraph({
+ breadcrumb: breadcrumbSchema,
+ copyrightYear: new Date(dates.publication).getFullYear(),
+ dates,
+ description: seo.description,
+ slug: ROUTES.PROJECTS,
+ title,
+ }),
]);
+
const page = {
title: `${seo.title} - ${CONFIG.name}`,
url: `${CONFIG.url}${ROUTES.PROJECTS}`,
@@ -89,14 +75,12 @@ const ProjectsPage: NextPageWithLayout<ProjectsPageProps> = ({ data }) => {
<meta property="og:type" content="article" />
<meta property="og:title" content={page.title} />
<meta property="og:description" content={seo.description} />
+ <script
+ // eslint-disable-next-line react/no-danger
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
+ type="application/ld+json"
+ />
</Head>
- <Script
- // eslint-disable-next-line react/jsx-no-literals -- Id allowed
- id="schema-projects"
- type="application/ld+json"
- // eslint-disable-next-line react/no-danger -- Necessary for schema
- dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
- />
<PageHeader
heading={title}
intro={<PageContent components={mdxComponents} />}
diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx
index fd7f9e1..84e75af 100644
--- a/src/pages/recherche/index.tsx
+++ b/src/pages/recherche/index.tsx
@@ -1,8 +1,6 @@
-/* eslint-disable max-statements */
import type { GetStaticProps } from 'next';
import Head from 'next/head';
import { useRouter } from 'next/router';
-import Script from 'next/script';
import { useCallback } from 'react';
import { useIntl } from 'react-intl';
import {
@@ -37,11 +35,11 @@ import type {
import { CONFIG } from '../../utils/config';
import { ROUTES } from '../../utils/constants';
import {
- getBlogSchema,
getLinksItemData,
getPostsWithUrl,
- getSchemaJson,
- getWebPageSchema,
+ getSchemaFrom,
+ getSearchResultsPageGraph,
+ getWebPageGraph,
} from '../../utils/helpers';
import { loadTranslation, type Messages } from '../../utils/helpers/server';
import {
@@ -165,17 +163,17 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({ data }) => {
? intl.formatMessage(
{
defaultMessage:
- 'Discover search results for {query} on {websiteName}.',
+ 'Discover search results for {query} on {websiteName} website.',
description: 'SearchPage: SEO - Meta description',
- id: 'pg26sn',
+ id: 'bW6Zda',
},
{ query: query.s as string, websiteName: CONFIG.name }
)
: intl.formatMessage(
{
- defaultMessage: 'Search for a post on {websiteName}.',
+ defaultMessage: 'Search for a post on {websiteName} website.',
description: 'SearchPage: SEO - Meta description',
- id: 'npisb3',
+ id: 'rEp1mS',
},
{ websiteName: CONFIG.name }
),
@@ -213,21 +211,20 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({ data }) => {
const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumbs();
- const webpageSchema = getWebPageSchema({
- description: messages.seo.metaDesc,
- locale: CONFIG.locales.defaultLocale,
- slug: asPath,
- title: messages.pageTitle,
- });
- const blogSchema = getBlogSchema({
- isSinglePage: false,
- locale: CONFIG.locales.defaultLocale,
- slug: asPath,
- });
- const schemaJsonLd = getSchemaJson([
- webpageSchema,
- blogSchema,
- breadcrumbSchema,
+ const jsonLd = getSchemaFrom([
+ query.s
+ ? getSearchResultsPageGraph({
+ breadcrumb: breadcrumbSchema,
+ description: messages.seo.metaDesc,
+ slug: asPath,
+ title: messages.pageTitle,
+ })
+ : getWebPageGraph({
+ breadcrumb: breadcrumbSchema,
+ description: messages.seo.metaDesc,
+ slug: asPath,
+ title: messages.pageTitle,
+ }),
]);
const pageUrl = `${CONFIG.url}${asPath}`;
@@ -243,14 +240,12 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({ data }) => {
<meta property="og:type" content="website" />
<meta property="og:title" content={messages.pageTitle} />
<meta property="og:description" content={messages.seo.title} />
+ <script
+ // eslint-disable-next-line react/no-danger
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
+ type="application/ld+json"
+ />
</Head>
- <Script
- // eslint-disable-next-line react/jsx-no-literals -- Id allowed
- id="schema-blog"
- type="application/ld+json"
- // eslint-disable-next-line react/no-danger -- Necessary for schema
- dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
- />
<PageHeader
heading={messages.pageTitle}
meta={{ total: articles ? articles[0].pageInfo.total : undefined }}
diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx
index 9d42644..af78185 100644
--- a/src/pages/sujet/[slug].tsx
+++ b/src/pages/sujet/[slug].tsx
@@ -4,7 +4,6 @@ import type { GetStaticPaths, GetStaticProps } from 'next';
import Head from 'next/head';
import NextImage from 'next/image';
import { useRouter } from 'next/router';
-import Script from 'next/script';
import { useIntl } from 'react-intl';
import {
getLayout,
@@ -37,10 +36,10 @@ import { CONFIG } from '../../utils/config';
import {
getLinksItemData,
getPostsWithUrl,
- getSchemaJson,
- getSinglePageSchema,
- getWebPageSchema,
+ getSchemaFrom,
+ getWebPageGraph,
slugify,
+ trimHTMLTags,
} from '../../utils/helpers';
import { loadTranslation, type Messages } from '../../utils/helpers/server';
import {
@@ -87,27 +86,16 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({ data }) => {
website: officialWebsite,
} = meta;
- const webpageSchema = getWebPageSchema({
- description: seo.description,
- locale: CONFIG.locales.defaultLocale,
- slug,
- title: seo.title,
- updateDate: dates.update,
- });
- const articleSchema = getSinglePageSchema({
- cover: cover?.src,
- dates,
- description: intro,
- id: 'topic',
- kind: 'page',
- locale: CONFIG.locales.defaultLocale,
- slug,
- title,
- });
- const schemaJsonLd = getSchemaJson([
- webpageSchema,
- articleSchema,
- breadcrumbSchema,
+ const jsonLd = getSchemaFrom([
+ getWebPageGraph({
+ breadcrumb: breadcrumbSchema,
+ copyrightYear: new Date(dates.publication).getFullYear(),
+ cover: cover?.src,
+ dates,
+ description: trimHTMLTags(intro),
+ slug,
+ title,
+ }),
]);
const messages = {
@@ -157,14 +145,12 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({ data }) => {
<meta property="og:type" content="article" />
<meta property="og:title" content={title} />
<meta property="og:description" content={intro} />
+ <script
+ // eslint-disable-next-line react/no-danger
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
+ type="application/ld+json"
+ />
</Head>
- <Script
- // eslint-disable-next-line react/jsx-no-literals -- Id allowed
- id="schema-project"
- type="application/ld+json"
- // eslint-disable-next-line react/no-danger -- Necessary for schema
- dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
- />
<PageHeader
heading={
<>
diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx
index f019341..56b956f 100644
--- a/src/pages/thematique/[slug].tsx
+++ b/src/pages/thematique/[slug].tsx
@@ -3,7 +3,6 @@ import type { ParsedUrlQuery } from 'querystring';
import type { GetStaticPaths, GetStaticProps } from 'next';
import Head from 'next/head';
import { useRouter } from 'next/router';
-import Script from 'next/script';
import { useIntl } from 'react-intl';
import {
getLayout,
@@ -36,10 +35,10 @@ import { CONFIG } from '../../utils/config';
import {
getLinksItemData,
getPostsWithUrl,
- getSchemaJson,
- getSinglePageSchema,
- getWebPageSchema,
+ getSchemaFrom,
+ getWebPageGraph,
slugify,
+ trimHTMLTags,
} from '../../utils/helpers';
import { loadTranslation, type Messages } from '../../utils/helpers/server';
import {
@@ -79,26 +78,15 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({ data }) => {
const { content, intro, meta, slug, title } = thematic;
const { articles, dates, seo, relatedTopics } = meta;
- const webpageSchema = getWebPageSchema({
- description: seo.description,
- locale: CONFIG.locales.defaultLocale,
- slug,
- title: seo.title,
- updateDate: dates.update,
- });
- const articleSchema = getSinglePageSchema({
- dates,
- description: intro,
- id: 'thematic',
- kind: 'page',
- locale: CONFIG.locales.defaultLocale,
- slug,
- title,
- });
- const schemaJsonLd = getSchemaJson([
- webpageSchema,
- articleSchema,
- breadcrumbSchema,
+ const jsonLd = getSchemaFrom([
+ getWebPageGraph({
+ breadcrumb: breadcrumbSchema,
+ copyrightYear: new Date(dates.publication).getFullYear(),
+ dates,
+ description: trimHTMLTags(intro),
+ slug,
+ title,
+ }),
]);
const messages = {
@@ -148,14 +136,12 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({ data }) => {
<meta property="og:type" content="article" />
<meta property="og:title" content={title} />
<meta property="og:description" content={intro} />
+ <script
+ // eslint-disable-next-line react/no-danger
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
+ type="application/ld+json"
+ />
</Head>
- <Script
- // eslint-disable-next-line react/jsx-no-literals -- Id allowed
- id="schema-project"
- type="application/ld+json"
- // eslint-disable-next-line react/no-danger -- Necessary for schema
- dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
- />
<PageHeader
heading={title}
intro={intro}
diff --git a/src/utils/constants.ts b/src/utils/constants.ts
index e968f31..b6f0667 100644
--- a/src/utils/constants.ts
+++ b/src/utils/constants.ts
@@ -30,6 +30,11 @@ export const PAGINATED_ROUTE_PREFIX = '/page';
// cSpell:ignore legales thematique developpement
+export const ARTICLE_ID = 'article';
+export const AUTHOR_ID = 'branding';
+export const COMMENT_ID_PREFIX = 'comment-';
+export const COMMENTS_SECTION_ID = 'comments';
+
export const STORAGE_KEY = {
ACKEE: 'ackee-tracking',
MOTION: 'reduced-motion',
diff --git a/src/utils/helpers/pages.tsx b/src/utils/helpers/pages.tsx
index 24f5503..1f70e8e 100644
--- a/src/utils/helpers/pages.tsx
+++ b/src/utils/helpers/pages.tsx
@@ -1,7 +1,7 @@
import NextImage from 'next/image';
import type { LinksWidgetItemData, PostData } from '../../components';
import type { ArticlePreview, PageLink } from '../../types';
-import { ROUTES } from '../constants';
+import { COMMENTS_SECTION_ID, ROUTES } from '../constants';
export const getUniquePageLinks = (pageLinks: PageLink[]): PageLink[] => {
const pageLinksIds = pageLinks.map((pageLink) => pageLink.id);
@@ -64,7 +64,7 @@ export const getPostsWithUrl = (posts: ArticlePreview[]): PostData[] =>
comments: {
count: meta.commentsCount ?? 0,
postHeading: title,
- url: `${ROUTES.ARTICLE}/${slug}#comments`,
+ url: `${ROUTES.ARTICLE}/${slug}#${COMMENTS_SECTION_ID}`,
},
},
url: `${ROUTES.ARTICLE}/${slug}`,
diff --git a/src/utils/helpers/schema-org.test.ts b/src/utils/helpers/schema-org.test.ts
new file mode 100644
index 0000000..f33d408
--- /dev/null
+++ b/src/utils/helpers/schema-org.test.ts
@@ -0,0 +1,511 @@
+import { describe, expect, it } from '@jest/globals';
+import type { Graph } from 'schema-dts';
+import { CONFIG } from '../config';
+import {
+ ARTICLE_ID,
+ AUTHOR_ID,
+ COMMENTS_SECTION_ID,
+ COMMENT_ID_PREFIX,
+ ROUTES,
+} from '../constants';
+import {
+ type WebSiteData,
+ getWebSiteGraph,
+ type WebPageData,
+ getWebPageGraph,
+ type BlogData,
+ getBlogGraph,
+ type BlogPostingData,
+ getBlogPostingGraph,
+ type CommentData,
+ getCommentGraph,
+ getAuthorGraph,
+ getAboutPageGraph,
+ getContactPageGraph,
+ getSearchResultsPageGraph,
+ getSchemaFrom,
+} from './schema-org';
+import { trimTrailingChars } from './strings';
+
+const host = trimTrailingChars(CONFIG.url, '/');
+
+describe('getAuthorGraph', () => {
+ it('returns a Person schema in JSON-LD format', () => {
+ const result = getAuthorGraph();
+
+ expect(result).toStrictEqual({
+ '@type': 'Person',
+ '@id': `${host}#${AUTHOR_ID}`,
+ givenName: 'Armand',
+ image: `${host}/armand-philippot.jpg`,
+ jobTitle: CONFIG.baseline,
+ knowsLanguage: [
+ {
+ '@type': 'Language',
+ name: 'French',
+ alternateName: 'fr',
+ },
+ {
+ '@type': 'Language',
+ name: 'English',
+ alternateName: 'en',
+ },
+ {
+ '@type': 'Language',
+ name: 'Spanish',
+ alternateName: 'es',
+ },
+ ],
+ nationality: {
+ '@type': 'Country',
+ name: 'France',
+ },
+ name: 'Armand Philippot',
+ url: host,
+ });
+ });
+});
+
+describe('getWebSiteGraph', () => {
+ it('returns the WebSite schema in JSON-LD format', () => {
+ const data: WebSiteData = {
+ description: 'maxime ea et',
+ title: 'eius voluptates deserunt',
+ };
+ const result = getWebSiteGraph(data);
+
+ expect(result).toStrictEqual({
+ '@type': 'WebSite',
+ '@id': host,
+ potentialAction: {
+ '@type': 'SearchAction',
+ query: 'required',
+ 'query-input': 'required name=query',
+ target: `${host}${ROUTES.SEARCH}?s={query}`,
+ },
+ url: host,
+ author: { '@id': `${host}#${AUTHOR_ID}` },
+ copyrightHolder: { '@id': `${host}#${AUTHOR_ID}` },
+ copyrightYear: Number(CONFIG.copyright.startYear),
+ creator: { '@id': `${host}#${AUTHOR_ID}` },
+ description: data.description,
+ editor: { '@id': `${host}#${AUTHOR_ID}` },
+ image: `${host}/icon.svg`,
+ inLanguage: [
+ {
+ '@type': 'Language',
+ name: 'French',
+ alternateName: 'fr',
+ },
+ ],
+ isAccessibleForFree: true,
+ license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+ name: data.title,
+ publisher: { '@id': `${host}#${AUTHOR_ID}` },
+ thumbnailUrl: `${host}/icon.svg`,
+ });
+ });
+});
+
+describe('getWebPageGraph', () => {
+ it('returns the WebPage schema in JSON-LD format', () => {
+ const data: WebPageData = {
+ breadcrumb: undefined,
+ copyrightYear: 2011,
+ cover: 'https://picsum.photos/640/480',
+ dates: {
+ publication: '2022-04-21',
+ update: '2023-05-02',
+ },
+ description: 'maxime ea et',
+ readingTime: 'PT2M',
+ slug: '/harum',
+ title: 'eius voluptates deserunt',
+ };
+ const result = getWebPageGraph(data);
+
+ expect(result).toStrictEqual({
+ '@id': `${host}${data.slug}`,
+ '@type': 'WebPage',
+ author: { '@id': `${host}#${AUTHOR_ID}` },
+ breadcrumb: data.breadcrumb,
+ copyrightHolder: { '@id': `${host}#${AUTHOR_ID}` },
+ copyrightYear: data.copyrightYear,
+ dateCreated: data.dates?.publication,
+ dateModified: data.dates?.update,
+ datePublished: data.dates?.publication,
+ description: data.description,
+ editor: { '@id': `${host}#${AUTHOR_ID}` },
+ headline: data.title,
+ inLanguage: [
+ {
+ '@type': 'Language',
+ name: 'French',
+ alternateName: 'fr',
+ },
+ ],
+ isAccessibleForFree: true,
+ isPartOf: { '@id': host },
+ license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+ lastReviewed: data.dates?.update,
+ name: data.title,
+ publisher: { '@id': `${host}#${AUTHOR_ID}` },
+ reviewedBy: { '@id': `${host}#${AUTHOR_ID}` },
+ timeRequired: data.readingTime,
+ thumbnailUrl: data.cover,
+ url: `${host}${data.slug}`,
+ });
+ });
+});
+
+describe('getAboutPageGraph', () => {
+ it('returns the AboutPage schema in JSON-LD format', () => {
+ const data: WebPageData = {
+ breadcrumb: undefined,
+ copyrightYear: 2011,
+ cover: 'https://picsum.photos/640/480',
+ dates: {
+ publication: '2022-04-21',
+ update: '2023-05-02',
+ },
+ description: 'maxime ea et',
+ readingTime: 'PT2M',
+ slug: '/harum',
+ title: 'eius voluptates deserunt',
+ };
+ const result = getAboutPageGraph(data);
+
+ expect(result).toStrictEqual({
+ '@id': `${host}${data.slug}`,
+ '@type': 'AboutPage',
+ author: { '@id': `${host}#${AUTHOR_ID}` },
+ breadcrumb: data.breadcrumb,
+ copyrightHolder: { '@id': `${host}#${AUTHOR_ID}` },
+ copyrightYear: data.copyrightYear,
+ dateCreated: data.dates?.publication,
+ dateModified: data.dates?.update,
+ datePublished: data.dates?.publication,
+ description: data.description,
+ editor: { '@id': `${host}#${AUTHOR_ID}` },
+ headline: data.title,
+ inLanguage: [
+ {
+ '@type': 'Language',
+ name: 'French',
+ alternateName: 'fr',
+ },
+ ],
+ isAccessibleForFree: true,
+ isPartOf: { '@id': host },
+ license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+ lastReviewed: data.dates?.update,
+ name: data.title,
+ publisher: { '@id': `${host}#${AUTHOR_ID}` },
+ reviewedBy: { '@id': `${host}#${AUTHOR_ID}` },
+ timeRequired: data.readingTime,
+ thumbnailUrl: data.cover,
+ url: `${host}${data.slug}`,
+ });
+ });
+});
+
+describe('getContactPageGraph', () => {
+ it('returns the ContactPage schema in JSON-LD format', () => {
+ const data: WebPageData = {
+ breadcrumb: undefined,
+ copyrightYear: 2011,
+ cover: 'https://picsum.photos/640/480',
+ dates: {
+ publication: '2022-04-21',
+ update: '2023-05-02',
+ },
+ description: 'maxime ea et',
+ readingTime: 'PT2M',
+ slug: '/harum',
+ title: 'eius voluptates deserunt',
+ };
+ const result = getContactPageGraph(data);
+
+ expect(result).toStrictEqual({
+ '@id': `${host}${data.slug}`,
+ '@type': 'ContactPage',
+ author: { '@id': `${host}#${AUTHOR_ID}` },
+ breadcrumb: data.breadcrumb,
+ copyrightHolder: { '@id': `${host}#${AUTHOR_ID}` },
+ copyrightYear: data.copyrightYear,
+ dateCreated: data.dates?.publication,
+ dateModified: data.dates?.update,
+ datePublished: data.dates?.publication,
+ description: data.description,
+ editor: { '@id': `${host}#${AUTHOR_ID}` },
+ headline: data.title,
+ inLanguage: [
+ {
+ '@type': 'Language',
+ name: 'French',
+ alternateName: 'fr',
+ },
+ ],
+ isAccessibleForFree: true,
+ isPartOf: { '@id': host },
+ license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+ lastReviewed: data.dates?.update,
+ name: data.title,
+ publisher: { '@id': `${host}#${AUTHOR_ID}` },
+ reviewedBy: { '@id': `${host}#${AUTHOR_ID}` },
+ timeRequired: data.readingTime,
+ thumbnailUrl: data.cover,
+ url: `${host}${data.slug}`,
+ });
+ });
+});
+
+describe('getSearchResultsPageGraph', () => {
+ it('returns the SearchResultsPage schema in JSON-LD format', () => {
+ const data: WebPageData = {
+ breadcrumb: undefined,
+ copyrightYear: 2011,
+ cover: 'https://picsum.photos/640/480',
+ dates: {
+ publication: '2022-04-21',
+ update: '2023-05-02',
+ },
+ description: 'maxime ea et',
+ readingTime: 'PT2M',
+ slug: '/harum',
+ title: 'eius voluptates deserunt',
+ };
+ const result = getSearchResultsPageGraph(data);
+
+ expect(result).toStrictEqual({
+ '@id': `${host}${data.slug}`,
+ '@type': 'SearchResultsPage',
+ author: { '@id': `${host}#${AUTHOR_ID}` },
+ breadcrumb: data.breadcrumb,
+ copyrightHolder: { '@id': `${host}#${AUTHOR_ID}` },
+ copyrightYear: data.copyrightYear,
+ dateCreated: data.dates?.publication,
+ dateModified: data.dates?.update,
+ datePublished: data.dates?.publication,
+ description: data.description,
+ editor: { '@id': `${host}#${AUTHOR_ID}` },
+ headline: data.title,
+ inLanguage: [
+ {
+ '@type': 'Language',
+ name: 'French',
+ alternateName: 'fr',
+ },
+ ],
+ isAccessibleForFree: true,
+ isPartOf: { '@id': host },
+ license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+ lastReviewed: data.dates?.update,
+ name: data.title,
+ publisher: { '@id': `${host}#${AUTHOR_ID}` },
+ reviewedBy: { '@id': `${host}#${AUTHOR_ID}` },
+ timeRequired: data.readingTime,
+ thumbnailUrl: data.cover,
+ url: `${host}${data.slug}`,
+ });
+ });
+});
+
+describe('getBlogGraph', () => {
+ it('returns the Blog schema in JSON-LD format', () => {
+ const data: BlogData = {
+ copyrightYear: 2013,
+ cover: 'https://picsum.photos/640/480',
+ dates: {
+ publication: '2021-07-01',
+ update: '2022-12-03',
+ },
+ description: 'dolorem provident dolores',
+ posts: undefined,
+ readingTime: 'PT5M',
+ slug: '/laboriosam',
+ title: 'id odio rerum',
+ };
+ const result = getBlogGraph(data);
+
+ expect(result).toStrictEqual({
+ '@type': 'Blog',
+ '@id': `${host}${data.slug}`,
+ author: { '@id': `${host}#${AUTHOR_ID}` },
+ blogPost: data.posts,
+ copyrightHolder: { '@id': `${host}#${AUTHOR_ID}` },
+ copyrightYear: data.copyrightYear,
+ dateCreated: data.dates?.publication,
+ dateModified: data.dates?.update,
+ datePublished: data.dates?.publication,
+ description: data.description,
+ editor: { '@id': `${host}#${AUTHOR_ID}` },
+ headline: data.title,
+ inLanguage: [
+ {
+ '@type': 'Language',
+ name: 'French',
+ alternateName: 'fr',
+ },
+ ],
+ isAccessibleForFree: true,
+ isPartOf: { '@id': host },
+ license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+ name: data.title,
+ publisher: { '@id': `${host}#${AUTHOR_ID}` },
+ timeRequired: data.readingTime,
+ thumbnailUrl: data.cover,
+ url: `${host}${data.slug}`,
+ });
+ });
+});
+
+describe('getBlogPostingGraph', () => {
+ it('returns the BlogPosting schema in JSON-LD format', () => {
+ const data: BlogPostingData = {
+ author: undefined,
+ body: 'Veritatis dignissimos rerum quo est.',
+ comment: undefined,
+ commentCount: 5,
+ copyrightYear: 2013,
+ cover: 'https://picsum.photos/640/480',
+ dates: {
+ publication: '2021-07-01',
+ update: '2022-12-03',
+ },
+ description: 'dolorem provident dolores',
+ keywords: 'unde, aut',
+ readingTime: 'PT5M',
+ slug: '/laboriosam',
+ title: 'id odio rerum',
+ wordCount: 450,
+ };
+ const result = getBlogPostingGraph(data);
+
+ expect(result).toStrictEqual({
+ '@type': 'BlogPosting',
+ '@id': `${host}${data.slug}#${ARTICLE_ID}`,
+ articleBody: data.body,
+ author: { '@id': `${host}#${AUTHOR_ID}` },
+ comment: data.comment,
+ commentCount: data.commentCount,
+ copyrightHolder: { '@id': `${host}#${AUTHOR_ID}` },
+ copyrightYear: data.copyrightYear,
+ dateCreated: data.dates?.publication,
+ dateModified: data.dates?.update,
+ datePublished: data.dates?.publication,
+ description: data.description,
+ discussionUrl: data.comment,
+ editor: { '@id': `${host}#${AUTHOR_ID}` },
+ headline: data.title,
+ image: data.cover,
+ inLanguage: [
+ {
+ '@type': 'Language',
+ name: 'French',
+ alternateName: 'fr',
+ },
+ ],
+ isAccessibleForFree: true,
+ isPartOf: { '@id': `${host}${ROUTES.BLOG}#${ARTICLE_ID}` },
+ keywords: data.keywords,
+ license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+ mainEntityOfPage: { '@id': `${host}${data.slug}` },
+ name: data.title,
+ publisher: { '@id': `${host}#${AUTHOR_ID}` },
+ timeRequired: data.readingTime,
+ thumbnailUrl: data.cover,
+ url: `${host}${data.slug}`,
+ wordCount: data.wordCount,
+ });
+ });
+
+ it('can return a discussion url', () => {
+ const data: BlogPostingData = {
+ body: 'Veritatis dignissimos rerum quo est.',
+ comment: [],
+ commentCount: 5,
+ description: 'dolorem provident dolores',
+ slug: '/laboriosam',
+ title: 'id odio rerum',
+ };
+ const result = getBlogPostingGraph(data);
+
+ expect(result.discussionUrl).toBe(
+ `${host}${data.slug}#${COMMENTS_SECTION_ID}`
+ );
+ });
+});
+
+describe('getCommentGraph', () => {
+ it('returns the Comment schema in JSON-LD format', () => {
+ const data: CommentData = {
+ articleSlug: '/maiores',
+ author: {
+ '@type': 'Person',
+ name: 'Horacio_Johns22',
+ },
+ body: 'Perspiciatis maiores reiciendis tempore.',
+ id: 'itaque',
+ publishedAt: '2020-10-10',
+ parentId: undefined,
+ };
+ const result = getCommentGraph(data);
+
+ expect(result).toStrictEqual({
+ '@id': `${host}${data.articleSlug}#${COMMENT_ID_PREFIX}${data.id}`,
+ '@type': 'Comment',
+ about: { '@id': `${host}/${data.articleSlug}#${ARTICLE_ID}` },
+ author: data.author,
+ creator: data.author,
+ dateCreated: data.publishedAt,
+ datePublished: data.publishedAt,
+ parentItem: { '@id': `${host}/${data.articleSlug}#${ARTICLE_ID}` },
+ text: data.body,
+ });
+ });
+
+ it('can return a reference to the comment parent', () => {
+ const data: CommentData = {
+ articleSlug: '/maiores',
+ author: {
+ '@type': 'Person',
+ name: 'Horacio_Johns22',
+ },
+ body: 'Perspiciatis maiores reiciendis tempore.',
+ id: 'itaque',
+ publishedAt: '2020-10-10',
+ parentId: 'magnam',
+ };
+ const result = getCommentGraph(data);
+
+ expect(result).toStrictEqual({
+ '@id': `${host}${data.articleSlug}#${COMMENT_ID_PREFIX}${data.id}`,
+ '@type': 'Comment',
+ about: { '@id': `${host}/${data.articleSlug}#${ARTICLE_ID}` },
+ author: data.author,
+ creator: data.author,
+ dateCreated: data.publishedAt,
+ datePublished: data.publishedAt,
+ parentItem: {
+ '@id': `${host}${data.articleSlug}#${COMMENT_ID_PREFIX}${data.parentId}`,
+ },
+ text: data.body,
+ });
+ });
+});
+
+describe('getSchemaFrom', () => {
+ it('combines the given graphs with a Person graph', () => {
+ const graphs: Graph['@graph'] = [
+ { '@type': '3DModel' },
+ { '@type': 'AMRadioChannel' },
+ ];
+ const result = getSchemaFrom(graphs);
+
+ expect(result).toStrictEqual({
+ '@context': 'https://schema.org',
+ '@graph': [getAuthorGraph(), ...graphs],
+ });
+ });
+});
diff --git a/src/utils/helpers/schema-org.ts b/src/utils/helpers/schema-org.ts
index 633c35a..7710aba 100644
--- a/src/utils/helpers/schema-org.ts
+++ b/src/utils/helpers/schema-org.ts
@@ -1,261 +1,498 @@
import type {
AboutPage,
- Article,
Blog,
BlogPosting,
+ BreadcrumbList,
Comment as CommentSchema,
ContactPage,
+ Duration,
Graph,
+ ListItem,
+ Person,
+ SearchAction,
+ SearchResultsPage,
WebPage,
+ WebSite,
} from 'schema-dts';
-import type { Dates, SingleComment } from '../../types';
import { CONFIG } from '../config';
-import { ROUTES } from '../constants';
+import {
+ ARTICLE_ID,
+ AUTHOR_ID,
+ COMMENTS_SECTION_ID,
+ COMMENT_ID_PREFIX,
+ ROUTES,
+} from '../constants';
import { trimTrailingChars } from './strings';
const host = trimTrailingChars(CONFIG.url, '/');
-export type GetBlogSchemaProps = {
- /**
- * True if the page is part of the blog.
- */
- isSinglePage: boolean;
+/**
+ * Retrieve a Person schema in JSON-LD format for the website owner.
+ *
+ * @returns {Person} A Person graph.
+ */
+export const getAuthorGraph = (): Person => {
+ return {
+ '@type': 'Person',
+ '@id': `${host}#${AUTHOR_ID}`,
+ givenName: CONFIG.name.split(' ')[0],
+ image: `${host}/armand-philippot.jpg`,
+ jobTitle: CONFIG.baseline,
+ knowsLanguage: [
+ {
+ '@type': 'Language',
+ name: 'French',
+ alternateName: 'fr',
+ },
+ {
+ '@type': 'Language',
+ name: 'English',
+ alternateName: 'en',
+ },
+ {
+ '@type': 'Language',
+ name: 'Spanish',
+ alternateName: 'es',
+ },
+ ],
+ nationality: {
+ '@type': 'Country',
+ name: 'France',
+ },
+ name: CONFIG.name,
+ url: host,
+ };
+};
+
+export type WebSiteData = {
/**
- * The page locale.
+ * A description of the website.
*/
- locale: string;
+ description: string;
/**
- * The page slug with a leading slash.
+ * The website title.
*/
- slug: string;
+ title: string;
+};
+
+export type CustomSearchAction = SearchAction & {
+ 'query-input': string;
};
/**
- * Retrieve the JSON for Blog schema.
+ * Retrieve the Website schema in JSON-LD format.
*
- * @param props - The page data.
- * @returns {Blog} The JSON for Blog schema.
+ * @param {WebSiteData} data - The website data.
+ * @returns {Website} A Website graph.
*/
-export const getBlogSchema = ({
- isSinglePage,
- locale,
- slug,
-}: GetBlogSchemaProps): Blog => {
+export const getWebSiteGraph = ({
+ description,
+ title,
+}: WebSiteData): WebSite => {
+ const searchAction: CustomSearchAction = {
+ '@type': 'SearchAction',
+ query: 'required',
+ 'query-input': 'required name=query',
+ target: `${host}${ROUTES.SEARCH}?s={query}`,
+ };
+
return {
- '@id': `${host}/#blog`,
- '@type': 'Blog',
- author: { '@id': `${host}/#branding` },
- creator: { '@id': `${host}/#branding` },
- editor: { '@id': `${host}/#branding` },
- blogPost: isSinglePage ? { '@id': `${host}/#article` } : undefined,
- inLanguage: locale,
- isPartOf: isSinglePage
- ? {
- '@id': `${host}/${slug}`,
- }
- : undefined,
+ '@type': 'WebSite',
+ '@id': host,
+ potentialAction: searchAction,
+ url: host,
+ author: { '@id': `${host}#${AUTHOR_ID}` },
+ copyrightHolder: { '@id': `${host}#${AUTHOR_ID}` },
+ copyrightYear: Number(CONFIG.copyright.startYear),
+ creator: { '@id': `${host}#${AUTHOR_ID}` },
+ description,
+ editor: { '@id': `${host}#${AUTHOR_ID}` },
+ image: `${host}/icon.svg`,
+ inLanguage: [
+ {
+ '@type': 'Language',
+ name: 'French',
+ alternateName: 'fr',
+ },
+ ],
+ isAccessibleForFree: true,
license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
- mainEntityOfPage: isSinglePage ? undefined : { '@id': `${host}/${slug}` },
+ name: title,
+ publisher: { '@id': `${host}#${AUTHOR_ID}` },
+ thumbnailUrl: `${host}/icon.svg`,
};
};
+export type BreadcrumbItemData = {
+ label: string;
+ position: number;
+ slug: string;
+};
+
/**
- * Retrieve the JSON for Comment schema.
+ * Retrieve the BreadcrumbItem schema in JSON-LD format.
*
- * @param props - The comments.
- * @returns {CommentSchema[]} The JSON for Comment schema.
+ * @param {BreadcrumbItemData} data - The item data.
+ * @returns {ListItem} A ListItem graph.
*/
-export const getCommentsSchema = (comments: SingleComment[]): CommentSchema[] =>
- comments.map((comment) => {
- return {
- '@context': 'https://schema.org',
- '@id': `${CONFIG.url}/#comment-${comment.id}`,
- '@type': 'Comment',
- parentItem: comment.parentId
- ? { '@id': `${CONFIG.url}/#comment-${comment.parentId}` }
- : undefined,
- about: { '@type': 'Article', '@id': `${CONFIG.url}/#article` },
- author: {
- '@type': 'Person',
- name: comment.meta.author.name,
- image: comment.meta.author.avatar?.src,
- url: comment.meta.author.website,
- },
- creator: {
- '@type': 'Person',
- name: comment.meta.author.name,
- image: comment.meta.author.avatar?.src,
- url: comment.meta.author.website,
- },
- dateCreated: comment.meta.date,
- datePublished: comment.meta.date,
- text: comment.content,
- };
- });
-
-export type SinglePageSchemaReturn = {
- about: AboutPage;
- contact: ContactPage;
- page: Article;
- post: BlogPosting;
+export const getBreadcrumbItemGraph = ({
+ label,
+ position,
+ slug,
+}: BreadcrumbItemData): ListItem => {
+ return {
+ '@type': 'ListItem',
+ item: {
+ '@id': slug === ROUTES.HOME ? host : `${host}${slug}`,
+ name: label,
+ },
+ position,
+ };
};
-export type SinglePageSchemaKind = keyof SinglePageSchemaReturn;
-
-export type GetSinglePageSchemaProps<T extends SinglePageSchemaKind> = {
+type WebContentsDates = {
/**
- * The number of comments.
+ * A date value in ISO 8601 date format.
*/
- commentsCount?: number;
+ publication?: string;
/**
- * The page content.
+ * A date value in ISO 8601 date format..
*/
- content?: string;
+ update?: string;
+};
+
+type WebContentsData = {
/**
- * The url of the cover.
+ * The year during which the claimed copyright was first asserted.
*/
- cover?: string;
+ copyrightYear?: number;
/**
- * The page dates.
+ * The URL of the creative work cover.
*/
- dates: Dates;
+ cover?: string;
/**
- * The page description.
+ * A description of the contents.
*/
description: string;
/**
- * The page id.
- */
- id: string;
- /**
- * The page kind.
+ * The publication date and maybe the update date.
*/
- kind: T;
+ dates?: WebContentsDates;
/**
- * The page locale.
+ * Approximate time it usually takes to work through the contents.
*/
- locale: string;
+ readingTime?: Duration;
/**
- * The page slug with a leading slash.
+ * The page slug.
*/
slug: string;
/**
- * The page title.
+ * The contents title.
*/
title: string;
};
+export type WebPageData = WebContentsData & {
+ /**
+ * The breadcrumbs schema.
+ */
+ breadcrumb?: BreadcrumbList;
+};
+
/**
- * Retrieve the JSON schema depending on the page kind.
+ * Retrieve the WebPage schema in JSON-LD format.
*
- * @param props - The page data.
- * @returns {SinglePageSchemaReturn[T]} - Either AboutPage, ContactPage, Article or BlogPosting schema.
+ * @param {WebPageData} data - The page data.
+ * @returns {WebPage} A WebPage graph.
*/
-export const getSinglePageSchema = <T extends SinglePageSchemaKind>({
- commentsCount,
- content,
+export const getWebPageGraph = ({
+ breadcrumb,
+ copyrightYear,
cover,
dates,
description,
- id,
- kind,
- locale,
- title,
+ readingTime,
slug,
-}: GetSinglePageSchemaProps<T>): SinglePageSchemaReturn[T] => {
- const publicationDate = new Date(dates.publication);
- const updateDate = dates.update ? new Date(dates.update) : undefined;
- const singlePageSchemaType = {
- about: 'AboutPage',
- contact: 'ContactPage',
- page: 'Article',
- post: 'BlogPosting',
+ title,
+}: WebPageData): WebPage => {
+ return {
+ '@id': `${host}${slug}`,
+ '@type': 'WebPage',
+ author: { '@id': `${host}#${AUTHOR_ID}` },
+ breadcrumb,
+ copyrightHolder: { '@id': `${host}#${AUTHOR_ID}` },
+ copyrightYear,
+ dateCreated: dates?.publication,
+ dateModified: dates?.update,
+ datePublished: dates?.publication,
+ description,
+ editor: { '@id': `${host}#${AUTHOR_ID}` },
+ headline: title,
+ inLanguage: [
+ {
+ '@type': 'Language',
+ name: 'French',
+ alternateName: 'fr',
+ },
+ ],
+ isAccessibleForFree: true,
+ isPartOf: { '@id': host },
+ license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+ lastReviewed: dates?.update,
+ name: title,
+ publisher: { '@id': `${host}#${AUTHOR_ID}` },
+ reviewedBy: { '@id': `${host}#${AUTHOR_ID}` },
+ timeRequired: readingTime,
+ thumbnailUrl: cover,
+ url: `${host}${slug}`,
};
+};
+/**
+ * Retrieve the AboutPage schema in JSON-LD format.
+ *
+ * @param {WebPageData} data - The page data.
+ * @returns {AboutPage} A AboutPage graph.
+ */
+export const getAboutPageGraph = (data: WebPageData): AboutPage => {
return {
- '@id': `${host}/#${id}`,
- '@type': singlePageSchemaType[kind],
- name: title,
+ ...getWebPageGraph(data),
+ '@type': 'AboutPage',
+ };
+};
+
+/**
+ * Retrieve the ContactPage schema in JSON-LD format.
+ *
+ * @param {WebPageData} data - The page data.
+ * @returns {ContactPage} A ContactPage graph.
+ */
+export const getContactPageGraph = (data: WebPageData): ContactPage => {
+ return {
+ ...getWebPageGraph(data),
+ '@type': 'ContactPage',
+ };
+};
+
+/**
+ * Retrieve the SearchResultsPage schema in JSON-LD format.
+ *
+ * @param {WebPageData} data - The page data.
+ * @returns {SearchResultsPage} A SearchResultsPage graph.
+ */
+export const getSearchResultsPageGraph = (
+ data: WebPageData
+): SearchResultsPage => {
+ return {
+ ...getWebPageGraph(data),
+ '@type': 'SearchResultsPage',
+ };
+};
+
+export type BlogData = WebContentsData & {
+ posts?: Blog['blogPost'];
+};
+
+/**
+ * Retrieve the Blog schema in JSON-LD format.
+ *
+ * @param {BlogData} data - The blog data.
+ * @returns {Blog} A Blog graph.
+ */
+export const getBlogGraph = ({
+ copyrightYear,
+ cover,
+ dates,
+ description,
+ posts,
+ readingTime,
+ slug,
+ title,
+}: BlogData): Blog => {
+ return {
+ '@type': 'Blog',
+ '@id': `${host}${slug}`,
+ author: { '@id': `${host}#${AUTHOR_ID}` },
+ blogPost: posts,
+ copyrightHolder: { '@id': `${host}#${AUTHOR_ID}` },
+ copyrightYear,
+ dateCreated: dates?.publication,
+ dateModified: dates?.update,
+ datePublished: dates?.publication,
description,
- articleBody: content,
- author: { '@id': `${host}/#branding` },
- commentCount: commentsCount,
- copyrightYear: publicationDate.getFullYear(),
- creator: { '@id': `${host}/#branding` },
- dateCreated: publicationDate.toISOString(),
- dateModified: updateDate?.toISOString(),
- datePublished: publicationDate.toISOString(),
- editor: { '@id': `${host}/#branding` },
+ editor: { '@id': `${host}#${AUTHOR_ID}` },
headline: title,
- image: cover,
- inLanguage: locale,
+ inLanguage: [
+ {
+ '@type': 'Language',
+ name: 'French',
+ alternateName: 'fr',
+ },
+ ],
+ isAccessibleForFree: true,
+ isPartOf: { '@id': host },
license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+ name: title,
+ publisher: { '@id': `${host}#${AUTHOR_ID}` },
+ timeRequired: readingTime,
thumbnailUrl: cover,
- isPartOf:
- kind === 'post'
- ? {
- '@id': `${host}/${ROUTES.BLOG}`,
- }
- : undefined,
- mainEntityOfPage: { '@id': `${host}/${slug}` },
- } as SinglePageSchemaReturn[T];
+ url: `${host}${slug}`,
+ };
};
-export type GetWebPageSchemaProps = {
+export type BlogPostingData = WebContentsData & {
/**
- * The page description.
+ * The author of the article.
*/
- description: string;
+ author?: Person;
/**
- * The page locale.
+ * The article body.
*/
- locale: string;
+ body?: string;
/**
- * The page slug.
+ * The comments on this creative work.
*/
- slug: string;
+ comment?: CommentSchema[];
/**
- * The page title.
+ * The number of comments on this creative work.
*/
- title: string;
+ commentCount?: number;
/**
- * The page last update.
+ * A comma separated list of keywords.
*/
- updateDate?: string;
+ keywords?: string;
+ /**
+ * The number of words in the article.
+ */
+ wordCount?: number;
};
/**
- * Retrieve the JSON for WebPage schema.
+ * Retrieve the BlogPosting schema in JSON-LD format.
*
- * @param props - The page data.
- * @returns {WebPage} The JSON for WebPage schema.
+ * @param {BlogPostingData} data - The blog posting data.
+ * @returns {BlogPosting} A BlogPosting graph.
*/
-export const getWebPageSchema = ({
+export const getBlogPostingGraph = ({
+ author,
+ body,
+ comment,
+ commentCount,
+ copyrightYear,
+ cover,
+ dates,
description,
- locale,
+ keywords,
+ readingTime,
slug,
title,
- updateDate,
-}: GetWebPageSchemaProps): WebPage => {
+ wordCount,
+}: BlogPostingData): BlogPosting => {
return {
- '@id': `${host}/${slug}`,
- '@type': 'WebPage',
- breadcrumb: { '@id': `${host}/#breadcrumb` },
- lastReviewed: updateDate,
- name: title,
+ '@type': 'BlogPosting',
+ '@id': `${host}${slug}#${ARTICLE_ID}`,
+ articleBody: body,
+ author: author ?? { '@id': `${host}#${AUTHOR_ID}` },
+ comment,
+ commentCount,
+ copyrightHolder: author ?? { '@id': `${host}#${AUTHOR_ID}` },
+ copyrightYear,
+ dateCreated: dates?.publication,
+ dateModified: dates?.update,
+ datePublished: dates?.publication,
description,
- inLanguage: locale,
- reviewedBy: { '@id': `${host}/#branding` },
- url: `${host}/${slug}`,
- isPartOf: {
- '@id': `${host}`,
- },
+ discussionUrl: comment
+ ? `${host}${slug}#${COMMENTS_SECTION_ID}`
+ : undefined,
+ editor: author ?? { '@id': `${host}#${AUTHOR_ID}` },
+ headline: title,
+ image: cover,
+ inLanguage: [
+ {
+ '@type': 'Language',
+ name: 'French',
+ alternateName: 'fr',
+ },
+ ],
+ isAccessibleForFree: true,
+ isPartOf: { '@id': `${host}${ROUTES.BLOG}#${ARTICLE_ID}` },
+ keywords,
+ license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+ mainEntityOfPage: { '@id': `${host}${slug}` },
+ name: title,
+ publisher: { '@id': `${host}#${AUTHOR_ID}` },
+ timeRequired: readingTime,
+ thumbnailUrl: cover,
+ url: `${host}${slug}`,
+ wordCount,
+ };
+};
+
+export type CommentData = {
+ /**
+ * The slug of the commented article.
+ */
+ articleSlug: string;
+ /**
+ * The author of the comment.
+ */
+ author: Person;
+ /**
+ * The comment body.
+ */
+ body: string;
+ /**
+ * The comment id.
+ */
+ id: string;
+ /**
+ * The id of the parent.
+ */
+ parentId?: string;
+ /**
+ * A date value in ISO 8601 date format.
+ */
+ publishedAt: string;
+};
+
+/**
+ * Retrieve the Comment schema in JSON-LD format.
+ *
+ * @param {CommentData} data - The comment data.
+ * @returns {CommentSchema} A Comment graph.
+ */
+export const getCommentGraph = ({
+ articleSlug,
+ author,
+ body,
+ id,
+ parentId,
+ publishedAt,
+}: CommentData): CommentSchema => {
+ return {
+ '@id': `${host}${articleSlug}#${COMMENT_ID_PREFIX}${id}`,
+ '@type': 'Comment',
+ about: { '@id': `${host}/${articleSlug}#${ARTICLE_ID}` },
+ author,
+ creator: author,
+ dateCreated: publishedAt,
+ datePublished: publishedAt,
+ parentItem: parentId
+ ? { '@id': `${host}${articleSlug}#${COMMENT_ID_PREFIX}${parentId}` }
+ : { '@id': `${host}/${articleSlug}#${ARTICLE_ID}` },
+ text: body,
};
};
-export const getSchemaJson = (graphs: Graph['@graph']): Graph => {
+/**
+ * Retrieve a schema in JSON-LD format from the given graphs.
+ *
+ * @param {Graph['@graph']} graphs - The schema graphs.
+ * @returns {CommentSchema} The schema in JSON-LD format.
+ */
+export const getSchemaFrom = (graphs: Graph['@graph']): Graph => {
return {
'@context': 'https://schema.org',
- '@graph': graphs,
+ '@graph': [getAuthorGraph(), ...graphs],
};
};
diff --git a/src/utils/helpers/strings.ts b/src/utils/helpers/strings.ts
index 1f40b8f..d1de8ce 100644
--- a/src/utils/helpers/strings.ts
+++ b/src/utils/helpers/strings.ts
@@ -60,3 +60,6 @@ export const trimTrailingChars = (str: string, char: string): string => {
return str.replace(regExp, '');
};
+
+export const trimHTMLTags = (str: string) =>
+ str.replace(/(?:<(?:[^>]+)>)/gi, '').replaceAll('\n\n\n\n', '\n\n');
diff --git a/src/utils/hooks/use-breadcrumbs/use-breadcrumbs.test.tsx b/src/utils/hooks/use-breadcrumbs/use-breadcrumbs.test.tsx
index 9778aed..c80db1c 100644
--- a/src/utils/hooks/use-breadcrumbs/use-breadcrumbs.test.tsx
+++ b/src/utils/hooks/use-breadcrumbs/use-breadcrumbs.test.tsx
@@ -4,8 +4,9 @@ import nextRouterMock from 'next-router-mock';
import { MemoryRouterProvider } from 'next-router-mock/MemoryRouterProvider';
import type { ReactNode } from 'react';
import { IntlProvider } from 'react-intl';
+import { CONFIG } from '../../config';
import { PAGINATED_ROUTE_PREFIX, ROUTES } from '../../constants';
-import { capitalize } from '../../helpers';
+import { capitalize, trimTrailingChars } from '../../helpers';
import { useBreadcrumbs } from './use-breadcrumbs';
const AllProviders = ({ children }: { children: ReactNode }) => (
@@ -48,7 +49,7 @@ describe('useBreadcrumbs', () => {
{
'@type': 'ListItem',
item: {
- '@id': ROUTES.HOME,
+ '@id': trimTrailingChars(CONFIG.url, '/'),
name: 'Home',
},
position: 1,
@@ -56,7 +57,7 @@ describe('useBreadcrumbs', () => {
{
'@type': 'ListItem',
item: {
- '@id': currentSlug,
+ '@id': `${trimTrailingChars(CONFIG.url, '/')}${currentSlug}`,
name: label,
},
position: 2,
diff --git a/src/utils/hooks/use-breadcrumbs/use-breadcrumbs.ts b/src/utils/hooks/use-breadcrumbs/use-breadcrumbs.ts
index a0132c0..fd14e23 100644
--- a/src/utils/hooks/use-breadcrumbs/use-breadcrumbs.ts
+++ b/src/utils/hooks/use-breadcrumbs/use-breadcrumbs.ts
@@ -4,7 +4,7 @@ import { useIntl } from 'react-intl';
import type { BreadcrumbList } from 'schema-dts';
import type { BreadcrumbsItem } from '../../../components';
import { PAGINATED_ROUTE_PREFIX, ROUTES } from '../../constants';
-import { capitalize } from '../../helpers';
+import { capitalize, getBreadcrumbItemGraph } from '../../helpers';
const is404 = (slug: string) => slug === ROUTES.NOT_FOUND;
const isArticle = (slug: string) => slug === ROUTES.ARTICLE;
@@ -23,7 +23,9 @@ const getCrumbsSlug = (
index: number
): string[] => [
...acc,
- ...(isSearch(`/${current}`) ? [`/${current.split('?s=')[0]}`] : []),
+ ...(isSearch(`/${current}`) && current.includes('?s=')
+ ? [`/${current.split('?s=')[0]}`]
+ : []),
`${acc[acc.length - 1]}${index === 0 ? '' : '/'}${current}`,
];
@@ -129,16 +131,13 @@ export const useBreadcrumbs = (
schema: {
'@type': 'BreadcrumbList',
'@id': 'breadcrumbs',
- itemListElement: items.map((item, index) => {
- return {
- '@type': 'ListItem',
- item: {
- '@id': item.slug,
- name: item.label,
- },
+ itemListElement: items.map((item, index) =>
+ getBreadcrumbItemGraph({
+ label: item.label,
position: index + 1,
- };
- }),
+ slug: item.slug,
+ })
+ ),
},
};
};