aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-12-12 18:50:03 +0100
committerArmand Philippot <git@armandphilippot.com>2023-12-12 18:50:03 +0100
commit85c4c42bd601270d7be0f34a0767a34bb85e29bb (patch)
tree16a07a89cf209139672592fd6988f0c028acb7e9
parent93f87c10783e3d76f1dec667779aedffcae33a39 (diff)
refactor(hooks): rewrite useBreadcrumbs hook
* use next/router to get the slug instead of using props * handle cases where the current page title is not provided * update JSON-LD schema to match the example in documentation * add tests
-rw-r--r--src/components/organisms/nav/breadcrumbs/breadcrumbs.stories.tsx12
-rw-r--r--src/components/organisms/nav/breadcrumbs/breadcrumbs.test.tsx6
-rw-r--r--src/components/organisms/nav/breadcrumbs/breadcrumbs.tsx12
-rw-r--r--src/components/templates/page/page.stories.tsx4
-rw-r--r--src/components/templates/page/page.test.tsx4
-rw-r--r--src/i18n/en.json38
-rw-r--r--src/i18n/fr.json38
-rw-r--r--src/pages/404.tsx13
-rw-r--r--src/pages/article/[slug].tsx16
-rw-r--r--src/pages/blog/index.tsx25
-rw-r--r--src/pages/blog/page/[number].tsx28
-rw-r--r--src/pages/contact.tsx20
-rw-r--r--src/pages/cv.tsx22
-rw-r--r--src/pages/index.tsx15
-rw-r--r--src/pages/mentions-legales.tsx20
-rw-r--r--src/pages/projets/[slug].tsx20
-rw-r--r--src/pages/projets/index.tsx20
-rw-r--r--src/pages/recherche/index.tsx19
-rw-r--r--src/pages/sujet/[slug].tsx22
-rw-r--r--src/pages/thematique/[slug].tsx22
-rw-r--r--src/utils/constants.ts2
-rw-r--r--src/utils/hooks/index.ts2
-rw-r--r--src/utils/hooks/use-breadcrumb.ts124
-rw-r--r--src/utils/hooks/use-breadcrumbs/index.ts1
-rw-r--r--src/utils/hooks/use-breadcrumbs/use-breadcrumbs.test.tsx232
-rw-r--r--src/utils/hooks/use-breadcrumbs/use-breadcrumbs.ts144
26 files changed, 559 insertions, 322 deletions
diff --git a/src/components/organisms/nav/breadcrumbs/breadcrumbs.stories.tsx b/src/components/organisms/nav/breadcrumbs/breadcrumbs.stories.tsx
index 4736b26..0b6fd27 100644
--- a/src/components/organisms/nav/breadcrumbs/breadcrumbs.stories.tsx
+++ b/src/components/organisms/nav/breadcrumbs/breadcrumbs.stories.tsx
@@ -28,7 +28,7 @@ const Template: ComponentStory<typeof Breadcrumbs> = (args) => (
*/
export const OneItem = Template.bind({});
OneItem.args = {
- items: [{ id: 'home', url: '#', name: 'Home' }],
+ items: [{ id: 'home', slug: '#', label: 'Home' }],
};
/**
@@ -37,8 +37,8 @@ OneItem.args = {
export const TwoItems = Template.bind({});
TwoItems.args = {
items: [
- { id: 'home', url: '#', name: 'Home' },
- { id: 'blog', url: '#', name: 'Blog' },
+ { id: 'home', slug: '#', label: 'Home' },
+ { id: 'blog', slug: '#', label: 'Blog' },
],
};
@@ -48,8 +48,8 @@ TwoItems.args = {
export const ThreeItems = Template.bind({});
ThreeItems.args = {
items: [
- { id: 'home', url: '#', name: 'Home' },
- { id: 'blog', url: '#', name: 'Blog' },
- { id: 'post1', url: '#', name: 'A Post' },
+ { id: 'home', slug: '#', label: 'Home' },
+ { id: 'blog', slug: '#', label: 'Blog' },
+ { id: 'post1', slug: '#', label: 'A Post' },
],
};
diff --git a/src/components/organisms/nav/breadcrumbs/breadcrumbs.test.tsx b/src/components/organisms/nav/breadcrumbs/breadcrumbs.test.tsx
index 40bb1b8..ab72a31 100644
--- a/src/components/organisms/nav/breadcrumbs/breadcrumbs.test.tsx
+++ b/src/components/organisms/nav/breadcrumbs/breadcrumbs.test.tsx
@@ -3,9 +3,9 @@ import { render, screen as rtlScreen } from '@testing-library/react';
import { Breadcrumbs, type BreadcrumbsItem } from './breadcrumbs';
const items: BreadcrumbsItem[] = [
- { id: 'home', url: '#', name: 'Home' },
- { id: 'blog', url: '#', name: 'Blog' },
- { id: 'post1', url: '#', name: 'A Post' },
+ { id: 'home', slug: '#', label: 'Home' },
+ { id: 'blog', slug: '#', label: 'Blog' },
+ { id: 'post1', slug: '#', label: 'A Post' },
];
describe('Breadcrumbs', () => {
diff --git a/src/components/organisms/nav/breadcrumbs/breadcrumbs.tsx b/src/components/organisms/nav/breadcrumbs/breadcrumbs.tsx
index b6d3843..13434e1 100644
--- a/src/components/organisms/nav/breadcrumbs/breadcrumbs.tsx
+++ b/src/components/organisms/nav/breadcrumbs/breadcrumbs.tsx
@@ -9,13 +9,13 @@ export type BreadcrumbsItem = {
*/
id: string;
/**
- * The item URL.
+ * The item label.
*/
- url: string;
+ label: string;
/**
- * The item name.
+ * The item slug.
*/
- name: string;
+ slug: string;
};
export type BreadcrumbsProps = Omit<NavProps, 'children'> & {
@@ -46,10 +46,10 @@ const BreadcrumbsWithRef: ForwardRefRenderFunction<
return (
<NavItem key={item.id}>
{isLastItem ? (
- <VisuallyHidden>{item.name}</VisuallyHidden>
+ <VisuallyHidden>{item.label}</VisuallyHidden>
) : (
<>
- <NavLink href={item.url} label={item.name} />
+ <NavLink href={item.slug} label={item.label} />
<span aria-hidden className={styles.sep}>
{sep}
</span>
diff --git a/src/components/templates/page/page.stories.tsx b/src/components/templates/page/page.stories.tsx
index 8b1616b..3f03b44 100644
--- a/src/components/templates/page/page.stories.tsx
+++ b/src/components/templates/page/page.stories.tsx
@@ -173,8 +173,8 @@ HeaderBody.args = {
export const BreadcrumbsHeaderBody = Template.bind({});
BreadcrumbsHeaderBody.args = {
breadcrumbs: [
- { id: 'home', name: 'Home', url: '#home' },
- { id: 'blog', name: 'Blog', url: '#blog' },
+ { id: 'home', label: 'Home', slug: '#home' },
+ { id: 'blog', label: 'Blog', slug: '#blog' },
],
children: (
<>
diff --git a/src/components/templates/page/page.test.tsx b/src/components/templates/page/page.test.tsx
index fb06cb1..afe93ce 100644
--- a/src/components/templates/page/page.test.tsx
+++ b/src/components/templates/page/page.test.tsx
@@ -24,8 +24,8 @@ describe('Page', () => {
const body =
'Consequatur deleniti eligendi quidem sint et nobis ut qui. Dolores modi eos. Cupiditate aliquid sunt consequatur voluptatem laudantium.';
const breadcrumbs = [
- { id: 'home', name: 'Home', url: '#home' },
- { id: 'blog', name: 'Blog', url: '#blog' },
+ { id: 'home', label: 'Home', slug: '#home' },
+ { id: 'blog', label: 'Blog', slug: '#blog' },
] satisfies BreadcrumbsItem[];
render(
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 248c7db..f971c93 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -19,6 +19,10 @@
"defaultMessage": "Related topics",
"description": "ThematicPage: related topics list widget title"
},
+ "/5tytV": {
+ "defaultMessage": "Page {number}",
+ "description": "UseBreadcrumbs: paginated route label"
+ },
"/EfcyW": {
"defaultMessage": "It is now awaiting moderation.",
"description": "PageComments: comment awaiting moderation"
@@ -79,10 +83,6 @@
"defaultMessage": "Gitlab",
"description": "ProjectPage: Gitlab repo label"
},
- "28GZdv": {
- "defaultMessage": "Projects",
- "description": "Breadcrumb: projects label"
- },
"2By3AZ": {
"defaultMessage": "Open menu",
"description": "SiteNavbar: main nav button label in navbar"
@@ -231,9 +231,9 @@
"defaultMessage": "Loading the repository metadata...",
"description": "ProjectPage: loading repository metadata"
},
- "Es52wh": {
- "defaultMessage": "Blog",
- "description": "Breadcrumb: blog label"
+ "EH+dam": {
+ "defaultMessage": "404: Not found",
+ "description": "UseBreadcrumbs: page not found label"
},
"FCpPCm": {
"defaultMessage": "Comments:",
@@ -307,6 +307,10 @@
"defaultMessage": "Skip to content",
"description": "Layout: Skip to content link"
},
+ "K6aSZi": {
+ "defaultMessage": "Blog",
+ "description": "UseBreadcrumbs: blog label"
+ },
"KVSWGP": {
"defaultMessage": "Other thematics",
"description": "ThematicPage: other thematics list widget title"
@@ -491,6 +495,10 @@
"defaultMessage": "{topicsCount, plural, =0 {Topics:} one {Topic:} other {Topics:}}",
"description": "PostPreviewMeta: topics label"
},
+ "aZIuPO": {
+ "defaultMessage": "Home",
+ "description": "UseBreadcrumbs: home label"
+ },
"bAXtMT": {
"defaultMessage": "{postsCount, plural, =0 {No posts} one {# post} other {# posts}}",
"description": "PageHeader: total meta value"
@@ -547,6 +555,10 @@
"defaultMessage": "Code blocks:",
"description": "PrismThemeToggle: theme label"
},
+ "gSevGm": {
+ "defaultMessage": "Search results for \"{query}\"",
+ "description": "UseBreadcrumbs: search results label"
+ },
"gYbxP4": {
"defaultMessage": "The comments are loading...",
"description": "LoadingPageComments: loading message"
@@ -563,14 +575,14 @@
"defaultMessage": "{postTitle} cover",
"description": "PostPreview: an accessible name for the figure wrapping the cover"
},
+ "iHC3Qx": {
+ "defaultMessage": "Search",
+ "description": "UseBreadcrumbs: search label"
+ },
"iTLvLX": {
"defaultMessage": "CC BY SA",
"description": "SiteFooter: the license name"
},
- "j5k9Fe": {
- "defaultMessage": "Home",
- "description": "Breadcrumb: home label"
- },
"jJm8wd": {
"defaultMessage": "Reading time:",
"description": "PageHeader: reading time label"
@@ -671,6 +683,10 @@
"defaultMessage": "Thematics are loading...",
"description": "ThematicPage: loading thematics message"
},
+ "rkz8C6": {
+ "defaultMessage": "Projects",
+ "description": "UseBreadcrumbs: projects label"
+ },
"s57FTB": {
"defaultMessage": "Share",
"description": "Article: sharing widget title"
diff --git a/src/i18n/fr.json b/src/i18n/fr.json
index 4e8da8e..0989e07 100644
--- a/src/i18n/fr.json
+++ b/src/i18n/fr.json
@@ -19,6 +19,10 @@
"defaultMessage": "Sujets connexes",
"description": "ThematicPage: related topics list widget title"
},
+ "/5tytV": {
+ "defaultMessage": "Page {number}",
+ "description": "UseBreadcrumbs: paginated route label"
+ },
"/EfcyW": {
"defaultMessage": "Il est maintenant en attente de modération.",
"description": "PageComments: comment awaiting moderation"
@@ -79,10 +83,6 @@
"defaultMessage": "Gitlab",
"description": "ProjectPage: Gitlab repo label"
},
- "28GZdv": {
- "defaultMessage": "Projets",
- "description": "Breadcrumb: projects label"
- },
"2By3AZ": {
"defaultMessage": "Ouvrir le menu",
"description": "SiteNavbar: main nav button label in navbar"
@@ -231,9 +231,9 @@
"defaultMessage": "Chargement des métadonnées du dépôt…",
"description": "ProjectPage: loading repository metadata"
},
- "Es52wh": {
- "defaultMessage": "Blog",
- "description": "Breadcrumb: blog label"
+ "EH+dam": {
+ "defaultMessage": "404: Non trouvé",
+ "description": "UseBreadcrumbs: page not found label"
},
"FCpPCm": {
"defaultMessage": "Commentaires :",
@@ -307,6 +307,10 @@
"defaultMessage": "Aller au contenu",
"description": "Layout: Skip to content link"
},
+ "K6aSZi": {
+ "defaultMessage": "Blog",
+ "description": "UseBreadcrumbs: blog label"
+ },
"KVSWGP": {
"defaultMessage": "Autres thématiques",
"description": "ThematicPage: other thematics list widget title"
@@ -491,6 +495,10 @@
"defaultMessage": "{topicsCount, plural, =0 {Sujets :} one {Sujet :} other {Sujets :}}",
"description": "PostPreviewMeta: topics label"
},
+ "aZIuPO": {
+ "defaultMessage": "Accueil",
+ "description": "UseBreadcrumbs: home label"
+ },
"bAXtMT": {
"defaultMessage": "{postsCount, plural, =0 {Aucun article} one {# article} other {# articles}}",
"description": "PageHeader: total meta value"
@@ -547,6 +555,10 @@
"defaultMessage": "Blocs de code :",
"description": "PrismThemeToggle: theme label"
},
+ "gSevGm": {
+ "defaultMessage": "Résultats de la recherche pour « {query} »",
+ "description": "UseBreadcrumbs: search results label"
+ },
"gYbxP4": {
"defaultMessage": "Les commentaires sont en cours de chargement…",
"description": "LoadingPageComments: loading message"
@@ -563,14 +575,14 @@
"defaultMessage": "Illustration de {postTitle}",
"description": "PostPreview: an accessible name for the figure wrapping the cover"
},
+ "iHC3Qx": {
+ "defaultMessage": "Recherche",
+ "description": "UseBreadcrumbs: search label"
+ },
"iTLvLX": {
"defaultMessage": "CC BY SA",
"description": "SiteFooter: the license name"
},
- "j5k9Fe": {
- "defaultMessage": "Accueil",
- "description": "Breadcrumb: home label"
- },
"jJm8wd": {
"defaultMessage": "Temps de lecture :",
"description": "PageHeader: reading time label"
@@ -671,6 +683,10 @@
"defaultMessage": "Les thématiques sont en cours de chargement…",
"description": "ThematicPage: loading thematics message"
},
+ "rkz8C6": {
+ "defaultMessage": "Projets",
+ "description": "UseBreadcrumbs: projects label"
+ },
"s57FTB": {
"defaultMessage": "Partager",
"description": "Article: sharing widget title"
diff --git a/src/pages/404.tsx b/src/pages/404.tsx
index 6ef0c55..450859c 100644
--- a/src/pages/404.tsx
+++ b/src/pages/404.tsx
@@ -36,7 +36,11 @@ import { CONFIG } from '../utils/config';
import { ROUTES } from '../utils/constants';
import { getLinksItemData } from '../utils/helpers';
import { loadTranslation, type Messages } from '../utils/helpers/server';
-import { useBreadcrumb, useThematicsList, useTopicsList } from '../utils/hooks';
+import {
+ useBreadcrumbs,
+ useThematicsList,
+ useTopicsList,
+} from '../utils/hooks';
const link = (chunks: ReactNode) => <Link href={ROUTES.CONTACT}>{chunks}</Link>;
@@ -110,10 +114,9 @@ const Error404Page: NextPageWithLayout<Error404PageProps> = ({ data }) => {
}),
},
};
- const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
- title: messages.page.title,
- url: ROUTES.NOT_FOUND,
- });
+ const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumbs(
+ messages.page.title
+ );
const searchSubmitHandler: SearchFormSubmit = useCallback(
async ({ query }) => {
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
index bd102a9..6333056 100644
--- a/src/pages/article/[slug].tsx
+++ b/src/pages/article/[slug].tsx
@@ -46,7 +46,7 @@ import {
import { loadTranslation, type Messages } from '../../utils/helpers/server';
import {
useArticle,
- useBreadcrumb,
+ useBreadcrumbs,
useComments,
useHeadingsTree,
usePrism,
@@ -74,10 +74,9 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ data }) => {
contentId: article.id,
},
});
- const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
- title: data.post.title,
- url: data.post.slug,
- });
+ const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumbs(
+ article.title
+ );
const { ref, tree } = useHeadingsTree<HTMLDivElement>({ fromLevel: 2 });
const { attributes, className: prismClassName } = usePrism({
attributes: {
@@ -172,6 +171,7 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ data }) => {
webpageSchema,
blogSchema,
blogPostSchema,
+ breadcrumbSchema,
...getCommentsSchema(comments),
]);
@@ -208,12 +208,6 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ data }) => {
// eslint-disable-next-line react/no-danger -- Necessary for schema
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
/>
- <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={title}
intro={intro}
diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx
index df25cd2..49c16b1 100644
--- a/src/pages/blog/index.tsx
+++ b/src/pages/blog/index.tsx
@@ -37,7 +37,7 @@ import type {
WPTopicPreview,
} from '../../types';
import { CONFIG } from '../../utils/config';
-import { ROUTES } from '../../utils/constants';
+import { PAGINATED_ROUTE_PREFIX, ROUTES } from '../../utils/constants';
import {
getBlogSchema,
getLinksItemData,
@@ -48,13 +48,13 @@ import {
import { loadTranslation, type Messages } from '../../utils/helpers/server';
import {
useArticlesList,
- useBreadcrumb,
+ useBreadcrumbs,
useThematicsList,
useTopicsList,
} from '../../utils/hooks';
const renderPaginationLink: RenderPaginationLink = (pageNum) =>
- `${ROUTES.BLOG}/page/${pageNum}`;
+ `${ROUTES.BLOG}${PAGINATED_ROUTE_PREFIX}/${pageNum}`;
type BlogPageProps = {
data: {
@@ -156,10 +156,9 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ data }) => {
},
};
- const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
- title: messages.pageTitle,
- url: ROUTES.BLOG,
- });
+ const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumbs(
+ messages.pageTitle
+ );
const webpageSchema = getWebPageSchema({
description: messages.seo.metaDesc,
@@ -172,7 +171,11 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ data }) => {
locale: CONFIG.locales.defaultLocale,
slug: ROUTES.BLOG,
});
- const schemaJsonLd = getSchemaJson([webpageSchema, blogSchema]);
+ const schemaJsonLd = getSchemaJson([
+ webpageSchema,
+ blogSchema,
+ breadcrumbSchema,
+ ]);
const renderPaginationLabel: RenderPaginationItemAriaLabel = useCallback(
({ kind, pageNumber: number, isCurrentPage }) => {
@@ -240,12 +243,6 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ data }) => {
// eslint-disable-next-line react/no-danger -- Necessary for schema
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
/>
- <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.pageTitle}
meta={{ total: data.posts.pageInfo.total }}
diff --git a/src/pages/blog/page/[number].tsx b/src/pages/blog/page/[number].tsx
index ec465c2..906a08e 100644
--- a/src/pages/blog/page/[number].tsx
+++ b/src/pages/blog/page/[number].tsx
@@ -44,7 +44,7 @@ import type {
WPTopicPreview,
} from '../../../types';
import { CONFIG } from '../../../utils/config';
-import { ROUTES } from '../../../utils/constants';
+import { PAGINATED_ROUTE_PREFIX, ROUTES } from '../../../utils/constants';
import {
getBlogSchema,
getLinksItemData,
@@ -55,14 +55,14 @@ import {
import { loadTranslation, type Messages } from '../../../utils/helpers/server';
import {
useArticlesList,
- useBreadcrumb,
+ useBreadcrumbs,
useRedirection,
useThematicsList,
useTopicsList,
} from '../../../utils/hooks';
const renderPaginationLink: RenderPaginationLink = (pageNum) =>
- `${ROUTES.BLOG}/page/${pageNum}`;
+ `${ROUTES.BLOG}${PAGINATED_ROUTE_PREFIX}/${pageNum}`;
type BlogPageProps = {
data: {
@@ -86,7 +86,8 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
useRedirection({
isReplacing: true,
to: ROUTES.BLOG,
- whenPathMatches: (path) => path === `${ROUTES.BLOG}/page/1`,
+ whenPathMatches: (path) =>
+ path === `${ROUTES.BLOG}${PAGINATED_ROUTE_PREFIX}/1`,
});
const intl = useIntl();
@@ -184,10 +185,9 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
},
};
- const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
- title: messages.pageTitle,
- url: `${ROUTES.BLOG}/page/${pageNumber}`,
- });
+ const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumbs(
+ messages.pageTitle
+ );
const webpageSchema = getWebPageSchema({
description: messages.seo.metaDesc,
@@ -200,7 +200,11 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
locale: CONFIG.locales.defaultLocale,
slug: ROUTES.BLOG,
});
- const schemaJsonLd = getSchemaJson([webpageSchema, blogSchema]);
+ const schemaJsonLd = getSchemaJson([
+ webpageSchema,
+ blogSchema,
+ breadcrumbSchema,
+ ]);
const renderPaginationLabel: RenderPaginationItemAriaLabel = useCallback(
({ kind, pageNumber: number, isCurrentPage }) => {
@@ -270,12 +274,6 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
// eslint-disable-next-line react/no-danger -- Necessary for schema
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
/>
- <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.pageTitle}
meta={{ total: data.posts.pageInfo.total }}
diff --git a/src/pages/contact.tsx b/src/pages/contact.tsx
index 9394ee8..264ca56 100644
--- a/src/pages/contact.tsx
+++ b/src/pages/contact.tsx
@@ -25,15 +25,13 @@ import {
getWebPageSchema,
} from '../utils/helpers';
import { loadTranslation } from '../utils/helpers/server';
-import { useBreadcrumb } from '../utils/hooks';
+import { useBreadcrumbs } from '../utils/hooks';
const ContactPage: NextPageWithLayout = () => {
const { dates, intro, seo, title } = meta;
const intl = useIntl();
- const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
- title,
- url: ROUTES.CONTACT,
- });
+ const { items: breadcrumbItems, schema: breadcrumbSchema } =
+ useBreadcrumbs(title);
const messages = {
form: intl.formatMessage({
@@ -83,7 +81,11 @@ const ContactPage: NextPageWithLayout = () => {
slug: ROUTES.CONTACT,
title,
});
- const schemaJsonLd = getSchemaJson([webpageSchema, contactSchema]);
+ const schemaJsonLd = getSchemaJson([
+ webpageSchema,
+ contactSchema,
+ breadcrumbSchema,
+ ]);
const submitMail: ContactFormSubmit = useCallback(
async ({ email, message, name, object }) => {
@@ -148,12 +150,6 @@ const ContactPage: NextPageWithLayout = () => {
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
/>
- <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={title} intro={intro} />
<PageBody>
<ContactForm aria-label={messages.form} onSubmit={submitMail} />
diff --git a/src/pages/cv.tsx b/src/pages/cv.tsx
index b77aa8c..d08c121 100644
--- a/src/pages/cv.tsx
+++ b/src/pages/cv.tsx
@@ -22,14 +22,14 @@ 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, ROUTES } from '../utils/constants';
+import { PERSONAL_LINKS } from '../utils/constants';
import {
getSchemaJson,
getSinglePageSchema,
getWebPageSchema,
} from '../utils/helpers';
import { loadTranslation } from '../utils/helpers/server';
-import { useBreadcrumb, useHeadingsTree } from '../utils/hooks';
+import { useBreadcrumbs, useHeadingsTree } from '../utils/hooks';
const DownloadLink = (chunks: ReactNode) => (
<Link href={data.file} isDownload>
@@ -44,10 +44,8 @@ const CVPage: NextPageWithLayout = () => {
const intl = useIntl();
const { ref, tree } = useHeadingsTree<HTMLDivElement>({ fromLevel: 2 });
const { dates, intro, seo, title } = meta;
- const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
- title,
- url: ROUTES.CV,
- });
+ const { items: breadcrumbItems, schema: breadcrumbSchema } =
+ useBreadcrumbs(title);
const messages = {
image: {
caption: intl.formatMessage(
@@ -115,7 +113,11 @@ const CVPage: NextPageWithLayout = () => {
slug: asPath,
title,
});
- const schemaJsonLd = getSchemaJson([webpageSchema, cvSchema]);
+ const schemaJsonLd = getSchemaJson([
+ webpageSchema,
+ cvSchema,
+ breadcrumbSchema,
+ ]);
const page = {
title: `${seo.title} - ${CONFIG.name}`,
url: `${CONFIG.url}${asPath}`,
@@ -141,12 +143,6 @@ const CVPage: NextPageWithLayout = () => {
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
/>
- <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={title}
intro={intro}
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index f4d36c1..ade628a 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -29,7 +29,7 @@ import { CONFIG } from '../utils/config';
import { ROUTES } from '../utils/constants';
import { getSchemaJson, getWebPageSchema } from '../utils/helpers';
import { loadTranslation, type Messages } from '../utils/helpers/server';
-import { useBreadcrumb } from '../utils/hooks';
+import { useBreadcrumbs } from '../utils/hooks';
type RecentPostsProps = {
posts: RecentArticle[];
@@ -129,10 +129,7 @@ type HomeProps = {
* Home page.
*/
const HomePage: NextPageWithLayout<HomeProps> = ({ recentPosts }) => {
- const { schema: breadcrumbSchema } = useBreadcrumb({
- title: '',
- url: ROUTES.HOME,
- });
+ const { schema: breadcrumbSchema } = useBreadcrumbs();
const webpageSchema = getWebPageSchema({
description: meta.seo.description,
@@ -140,7 +137,7 @@ const HomePage: NextPageWithLayout<HomeProps> = ({ recentPosts }) => {
slug: ROUTES.HOME,
title: meta.seo.title,
});
- const schemaJsonLd = getSchemaJson([webpageSchema]);
+ const schemaJsonLd = getSchemaJson([webpageSchema, breadcrumbSchema]);
return (
<Page hasSections>
@@ -158,12 +155,6 @@ const HomePage: NextPageWithLayout<HomeProps> = ({ recentPosts }) => {
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
/>
- <Script
- // eslint-disable-next-line react/jsx-no-literals -- Id allowed
- id="schema-breadcrumb"
- type="application/ld+json"
- dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
- />
<HomePageContent components={getComponents(recentPosts)} />
</Page>
);
diff --git a/src/pages/mentions-legales.tsx b/src/pages/mentions-legales.tsx
index 176c8fe..8613898 100644
--- a/src/pages/mentions-legales.tsx
+++ b/src/pages/mentions-legales.tsx
@@ -22,7 +22,7 @@ import {
getWebPageSchema,
} from '../utils/helpers';
import { loadTranslation } from '../utils/helpers/server';
-import { useBreadcrumb, useHeadingsTree } from '../utils/hooks';
+import { useBreadcrumbs, useHeadingsTree } from '../utils/hooks';
/**
* Legal Notice page.
@@ -30,10 +30,8 @@ import { useBreadcrumb, useHeadingsTree } from '../utils/hooks';
const LegalNoticePage: NextPageWithLayout = () => {
const intl = useIntl();
const { dates, intro, seo, title } = meta;
- const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
- title,
- url: ROUTES.LEGAL_NOTICE,
- });
+ const { items: breadcrumbItems, schema: breadcrumbSchema } =
+ useBreadcrumbs(title);
const { ref, tree } = useHeadingsTree<HTMLDivElement>({ fromLevel: 2 });
const webpageSchema = getWebPageSchema({
@@ -52,7 +50,11 @@ const LegalNoticePage: NextPageWithLayout = () => {
slug: ROUTES.LEGAL_NOTICE,
title,
});
- const schemaJsonLd = getSchemaJson([webpageSchema, articleSchema]);
+ const schemaJsonLd = getSchemaJson([
+ webpageSchema,
+ articleSchema,
+ breadcrumbSchema,
+ ]);
const page = {
title: `${seo.title} - ${CONFIG.name}`,
@@ -82,12 +84,6 @@ const LegalNoticePage: NextPageWithLayout = () => {
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
/>
- <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={title}
intro={intro}
diff --git a/src/pages/projets/[slug].tsx b/src/pages/projets/[slug].tsx
index 0c750f9..8985f47 100644
--- a/src/pages/projets/[slug].tsx
+++ b/src/pages/projets/[slug].tsx
@@ -49,7 +49,7 @@ import {
loadTranslation,
} from '../../utils/helpers/server';
import {
- useBreadcrumb,
+ useBreadcrumbs,
useGithubRepoMeta,
useHeadingsTree,
} from '../../utils/hooks';
@@ -183,10 +183,8 @@ type ProjectPageProps = {
const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ data }) => {
const { id, intro, meta, slug, title } = data.project;
const intl = useIntl();
- const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
- title,
- url: slug,
- });
+ const { items: breadcrumbItems, schema: breadcrumbSchema } =
+ useBreadcrumbs(title);
const { ref, tree } = useHeadingsTree<HTMLDivElement>({ fromLevel: 2 });
const page = {
@@ -211,7 +209,11 @@ const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ data }) => {
slug,
title,
});
- const schemaJsonLd = getSchemaJson([webpageSchema, articleSchema]);
+ const schemaJsonLd = getSchemaJson([
+ webpageSchema,
+ articleSchema,
+ breadcrumbSchema,
+ ]);
const messages = {
repos: {
@@ -262,12 +264,6 @@ const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ data }) => {
// eslint-disable-next-line react/no-danger -- Necessary for schema
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
/>
- <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={title}
intro={intro}
diff --git a/src/pages/projets/index.tsx b/src/pages/projets/index.tsx
index 843374a..401c68c 100644
--- a/src/pages/projets/index.tsx
+++ b/src/pages/projets/index.tsx
@@ -35,7 +35,7 @@ import {
loadTranslation,
type Messages,
} from '../../utils/helpers/server';
-import { useBreadcrumb } from '../../utils/hooks';
+import { useBreadcrumbs } from '../../utils/hooks';
type ProjectsPageProps = {
data: {
@@ -49,10 +49,8 @@ type ProjectsPageProps = {
*/
const ProjectsPage: NextPageWithLayout<ProjectsPageProps> = ({ data }) => {
const { dates, seo, title } = meta;
- const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
- title,
- url: ROUTES.PROJECTS,
- });
+ const { items: breadcrumbItems, schema: breadcrumbSchema } =
+ useBreadcrumbs(title);
const intl = useIntl();
const webpageSchema = getWebPageSchema({
description: seo.description,
@@ -70,7 +68,11 @@ const ProjectsPage: NextPageWithLayout<ProjectsPageProps> = ({ data }) => {
slug: ROUTES.PROJECTS,
title,
});
- const schemaJsonLd = getSchemaJson([webpageSchema, articleSchema]);
+ const schemaJsonLd = getSchemaJson([
+ webpageSchema,
+ articleSchema,
+ breadcrumbSchema,
+ ]);
const page = {
title: `${seo.title} - ${CONFIG.name}`,
url: `${CONFIG.url}${ROUTES.PROJECTS}`,
@@ -95,12 +97,6 @@ const ProjectsPage: NextPageWithLayout<ProjectsPageProps> = ({ data }) => {
// eslint-disable-next-line react/no-danger -- Necessary for schema
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
/>
- <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={title}
intro={<PageContent components={mdxComponents} />}
diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx
index 9eaecba..fd7f9e1 100644
--- a/src/pages/recherche/index.tsx
+++ b/src/pages/recherche/index.tsx
@@ -46,7 +46,7 @@ import {
import { loadTranslation, type Messages } from '../../utils/helpers/server';
import {
useArticlesList,
- useBreadcrumb,
+ useBreadcrumbs,
useThematicsList,
useTopicsList,
} from '../../utils/hooks';
@@ -211,10 +211,7 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({ data }) => {
},
};
- const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
- title: messages.pageTitle,
- url: ROUTES.SEARCH,
- });
+ const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumbs();
const webpageSchema = getWebPageSchema({
description: messages.seo.metaDesc,
@@ -227,7 +224,11 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({ data }) => {
locale: CONFIG.locales.defaultLocale,
slug: asPath,
});
- const schemaJsonLd = getSchemaJson([webpageSchema, blogSchema]);
+ const schemaJsonLd = getSchemaJson([
+ webpageSchema,
+ blogSchema,
+ breadcrumbSchema,
+ ]);
const pageUrl = `${CONFIG.url}${asPath}`;
@@ -250,12 +251,6 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({ data }) => {
// eslint-disable-next-line react/no-danger -- Necessary for schema
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
/>
- <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.pageTitle}
meta={{ total: articles ? articles[0].pageInfo.total : undefined }}
diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx
index 43b5aa6..9d42644 100644
--- a/src/pages/sujet/[slug].tsx
+++ b/src/pages/sujet/[slug].tsx
@@ -34,7 +34,6 @@ import type {
WPTopicPreview,
} from '../../types';
import { CONFIG } from '../../utils/config';
-import { ROUTES } from '../../utils/constants';
import {
getLinksItemData,
getPostsWithUrl,
@@ -45,7 +44,7 @@ import {
} from '../../utils/helpers';
import { loadTranslation, type Messages } from '../../utils/helpers/server';
import {
- useBreadcrumb,
+ useBreadcrumbs,
useHeadingsTree,
useTopic,
useTopicsList,
@@ -71,10 +70,9 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({ data }) => {
fallback: data.otherTopics,
input: { first: data.totalTopics, where: { notIn: [topic.id] } },
});
- const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
- title: topic.title,
- url: `${ROUTES.TOPICS}/${topic.slug}`,
- });
+ const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumbs(
+ topic.title
+ );
const { ref, tree } = useHeadingsTree<HTMLDivElement>({ fromLevel: 2 });
if (isFallback || isLoading) return <LoadingPage />;
@@ -106,7 +104,11 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({ data }) => {
slug,
title,
});
- const schemaJsonLd = getSchemaJson([webpageSchema, articleSchema]);
+ const schemaJsonLd = getSchemaJson([
+ webpageSchema,
+ articleSchema,
+ breadcrumbSchema,
+ ]);
const messages = {
widgets: {
@@ -163,12 +165,6 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({ data }) => {
// eslint-disable-next-line react/no-danger -- Necessary for schema
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
/>
- <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={
<>
diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx
index 6ab349d..f019341 100644
--- a/src/pages/thematique/[slug].tsx
+++ b/src/pages/thematique/[slug].tsx
@@ -33,7 +33,6 @@ import type {
WPThematicPreview,
} from '../../types';
import { CONFIG } from '../../utils/config';
-import { ROUTES } from '../../utils/constants';
import {
getLinksItemData,
getPostsWithUrl,
@@ -44,7 +43,7 @@ import {
} from '../../utils/helpers';
import { loadTranslation, type Messages } from '../../utils/helpers/server';
import {
- useBreadcrumb,
+ useBreadcrumbs,
useHeadingsTree,
useThematic,
useThematicsList,
@@ -70,10 +69,9 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({ data }) => {
fallback: data.otherThematics,
input: { first: data.totalThematics, where: { notIn: [thematic.id] } },
});
- const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
- title: data.currentThematic.title,
- url: `${ROUTES.THEMATICS}/${data.currentThematic.slug}`,
- });
+ const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumbs(
+ thematic.title
+ );
const { ref, tree } = useHeadingsTree<HTMLDivElement>({ fromLevel: 2 });
if (isFallback || isLoading) return <LoadingPage />;
@@ -97,7 +95,11 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({ data }) => {
slug,
title,
});
- const schemaJsonLd = getSchemaJson([webpageSchema, articleSchema]);
+ const schemaJsonLd = getSchemaJson([
+ webpageSchema,
+ articleSchema,
+ breadcrumbSchema,
+ ]);
const messages = {
widgets: {
@@ -154,12 +156,6 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({ data }) => {
// eslint-disable-next-line react/no-danger -- Necessary for schema
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
/>
- <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={title}
intro={intro}
diff --git a/src/utils/constants.ts b/src/utils/constants.ts
index 9733b15..e968f31 100644
--- a/src/utils/constants.ts
+++ b/src/utils/constants.ts
@@ -26,6 +26,8 @@ export const ROUTES = {
TOPICS: '/sujet',
} as const;
+export const PAGINATED_ROUTE_PREFIX = '/page';
+
// cSpell:ignore legales thematique developpement
export const STORAGE_KEY = {
diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts
index 95cb717..c9ed01e 100644
--- a/src/utils/hooks/index.ts
+++ b/src/utils/hooks/index.ts
@@ -2,7 +2,7 @@ export * from './use-ackee';
export * from './use-article';
export * from './use-articles-list';
export * from './use-boolean';
-export * from './use-breadcrumb';
+export * from './use-breadcrumbs';
export * from './use-comments';
export * from './use-form';
export * from './use-github-repo-meta';
diff --git a/src/utils/hooks/use-breadcrumb.ts b/src/utils/hooks/use-breadcrumb.ts
deleted file mode 100644
index 8b23ff2..0000000
--- a/src/utils/hooks/use-breadcrumb.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-/* eslint-disable max-statements */
-import { useIntl } from 'react-intl';
-import type { BreadcrumbList } from 'schema-dts';
-import type { BreadcrumbsItem } from '../../components';
-import { CONFIG } from '../config';
-import { ROUTES } from '../constants';
-import { slugify } from '../helpers';
-
-const isArticle = (url: string) => url.startsWith(`${ROUTES.ARTICLE}/`);
-
-const isHome = (url: string) => url === '/';
-
-const isPageNumber = (url: string) => url.includes('/page/');
-
-const isProject = (url: string) => url.startsWith(`${ROUTES.PROJECTS}/`);
-
-const isSearch = (url: string) => url.startsWith(ROUTES.SEARCH);
-
-const isThematic = (url: string) => url.startsWith(`${ROUTES.THEMATICS}/`);
-
-const isTopic = (url: string) => url.startsWith(`${ROUTES.TOPICS}/`);
-
-const hasBlogAsParent = (url: string) =>
- isArticle(url) ||
- isPageNumber(url) ||
- isSearch(url) ||
- isThematic(url) ||
- isTopic(url);
-
-export type useBreadcrumbProps = {
- /**
- * The current page title.
- */
- title: string;
- /**
- * The current page url.
- */
- url: string;
-};
-
-export type useBreadcrumbReturn = {
- /**
- * The breadcrumb items.
- */
- items: BreadcrumbsItem[];
- /**
- * The breadcrumb JSON schema.
- */
- schema: BreadcrumbList['itemListElement'][];
-};
-
-/**
- * Retrieve the breadcrumb items.
- *
- * @param {useBreadcrumbProps} props - An object (the current page title & url).
- * @returns {useBreadcrumbReturn} The breadcrumb items and its JSON schema.
- */
-export const useBreadcrumb = ({
- title,
- url,
-}: useBreadcrumbProps): useBreadcrumbReturn => {
- const intl = useIntl();
- const labels = {
- home: intl.formatMessage({
- defaultMessage: 'Home',
- description: 'Breadcrumb: home label',
- id: 'j5k9Fe',
- }),
- blog: intl.formatMessage({
- defaultMessage: 'Blog',
- description: 'Breadcrumb: blog label',
- id: 'Es52wh',
- }),
- projects: intl.formatMessage({
- defaultMessage: 'Projects',
- description: 'Breadcrumb: projects label',
- id: '28GZdv',
- }),
- };
-
- const items: BreadcrumbsItem[] = [
- { id: 'home', name: labels.home, url: '/' },
- ];
- const schema: BreadcrumbList['itemListElement'][] = [
- {
- '@type': 'ListItem',
- position: 1,
- name: labels.home,
- item: CONFIG.url,
- },
- ];
-
- if (isHome(url)) return { items, schema };
-
- if (hasBlogAsParent(url)) {
- items.push({ id: 'blog', name: labels.blog, url: ROUTES.BLOG });
- schema.push({
- '@type': 'ListItem',
- position: 2,
- name: labels.blog,
- item: `${CONFIG.url}${ROUTES.BLOG}`,
- });
- }
-
- if (isProject(url)) {
- items.push({ id: 'projects', name: labels.projects, url: ROUTES.PROJECTS });
- schema.push({
- '@type': 'ListItem',
- position: 2,
- name: labels.projects,
- item: `${CONFIG.url}${ROUTES.PROJECTS}`,
- });
- }
-
- items.push({ id: slugify(title), name: title, url });
- schema.push({
- '@type': 'ListItem',
- position: schema.length + 1,
- name: title,
- item: `${CONFIG.url}${url}`,
- });
-
- return { items, schema };
-};
diff --git a/src/utils/hooks/use-breadcrumbs/index.ts b/src/utils/hooks/use-breadcrumbs/index.ts
new file mode 100644
index 0000000..87e5d79
--- /dev/null
+++ b/src/utils/hooks/use-breadcrumbs/index.ts
@@ -0,0 +1 @@
+export * from './use-breadcrumbs';
diff --git a/src/utils/hooks/use-breadcrumbs/use-breadcrumbs.test.tsx b/src/utils/hooks/use-breadcrumbs/use-breadcrumbs.test.tsx
new file mode 100644
index 0000000..9778aed
--- /dev/null
+++ b/src/utils/hooks/use-breadcrumbs/use-breadcrumbs.test.tsx
@@ -0,0 +1,232 @@
+import { describe, expect, it } from '@jest/globals';
+import { act, renderHook } from '@testing-library/react';
+import nextRouterMock from 'next-router-mock';
+import { MemoryRouterProvider } from 'next-router-mock/MemoryRouterProvider';
+import type { ReactNode } from 'react';
+import { IntlProvider } from 'react-intl';
+import { PAGINATED_ROUTE_PREFIX, ROUTES } from '../../constants';
+import { capitalize } from '../../helpers';
+import { useBreadcrumbs } from './use-breadcrumbs';
+
+const AllProviders = ({ children }: { children: ReactNode }) => (
+ <IntlProvider defaultLocale="en" locale="en">
+ <MemoryRouterProvider>{children}</MemoryRouterProvider>
+ </IntlProvider>
+);
+
+describe('useBreadcrumbs', () => {
+ it('returns the breadcrumbs items and its schema', async () => {
+ const currentSlug = '/current-slug';
+ const label = capitalize(
+ (currentSlug.split('/').pop() ?? currentSlug).replaceAll('-', ' ')
+ );
+
+ await act(async () => nextRouterMock.push(currentSlug));
+
+ const { result } = renderHook(() => useBreadcrumbs(), {
+ wrapper: AllProviders,
+ });
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect.assertions(4);
+
+ expect(result.current.items).toHaveLength(2);
+ expect(result.current.items[0]).toStrictEqual({
+ id: '/',
+ label: 'Home',
+ slug: '/',
+ });
+ expect(result.current.items[1]).toStrictEqual({
+ id: currentSlug,
+ label,
+ slug: currentSlug,
+ });
+ expect(result.current.schema).toStrictEqual({
+ '@type': 'BreadcrumbList',
+ '@id': 'breadcrumbs',
+ itemListElement: [
+ {
+ '@type': 'ListItem',
+ item: {
+ '@id': ROUTES.HOME,
+ name: 'Home',
+ },
+ position: 1,
+ },
+ {
+ '@type': 'ListItem',
+ item: {
+ '@id': currentSlug,
+ name: label,
+ },
+ position: 2,
+ },
+ ],
+ });
+ });
+
+ it('can render the items for the 404 page', async () => {
+ await act(async () => nextRouterMock.push(ROUTES.NOT_FOUND));
+
+ const { result } = renderHook(() => useBreadcrumbs(), {
+ wrapper: AllProviders,
+ });
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect.assertions(3);
+
+ expect(result.current.items).toHaveLength(2);
+ expect(result.current.items[0]).toStrictEqual({
+ id: '/',
+ label: 'Home',
+ slug: '/',
+ });
+ expect(result.current.items[1]).toStrictEqual({
+ id: ROUTES.NOT_FOUND,
+ label: '404: Not found',
+ slug: ROUTES.NOT_FOUND,
+ });
+ });
+
+ it('can render the items for the Blog page', async () => {
+ await act(async () => nextRouterMock.push(ROUTES.BLOG));
+
+ const { result } = renderHook(() => useBreadcrumbs(), {
+ wrapper: AllProviders,
+ });
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect.assertions(3);
+
+ expect(result.current.items).toHaveLength(2);
+ expect(result.current.items[0]).toStrictEqual({
+ id: '/',
+ label: 'Home',
+ slug: '/',
+ });
+ expect(result.current.items[1]).toStrictEqual({
+ id: ROUTES.BLOG,
+ label: 'Blog',
+ slug: ROUTES.BLOG,
+ });
+ });
+
+ it('can render the items for the paginated routes', async () => {
+ const pageNumber = 3;
+ const currentSlug = `${ROUTES.BLOG}${PAGINATED_ROUTE_PREFIX}/${pageNumber}`;
+ await act(async () => nextRouterMock.push(currentSlug));
+
+ const { result } = renderHook(() => useBreadcrumbs(), {
+ wrapper: AllProviders,
+ });
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect.assertions(4);
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect(result.current.items).toHaveLength(3);
+ expect(result.current.items[0]).toStrictEqual({
+ id: '/',
+ label: 'Home',
+ slug: '/',
+ });
+ expect(result.current.items[1]).toStrictEqual({
+ id: ROUTES.BLOG,
+ label: 'Blog',
+ slug: ROUTES.BLOG,
+ });
+ expect(result.current.items[2]).toStrictEqual({
+ id: currentSlug,
+ label: `Page ${pageNumber}`,
+ slug: currentSlug,
+ });
+ });
+
+ it('can render the items for the Projects page', async () => {
+ await act(async () => nextRouterMock.push(ROUTES.PROJECTS));
+
+ const { result } = renderHook(() => useBreadcrumbs(), {
+ wrapper: AllProviders,
+ });
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect.assertions(3);
+
+ expect(result.current.items).toHaveLength(2);
+ expect(result.current.items[0]).toStrictEqual({
+ id: '/',
+ label: 'Home',
+ slug: '/',
+ });
+ expect(result.current.items[1]).toStrictEqual({
+ id: ROUTES.PROJECTS,
+ label: 'Projects',
+ slug: ROUTES.PROJECTS,
+ });
+ });
+
+ it('can render the items for the Search page', async () => {
+ const query = 'similique';
+ const currentSlug = `${ROUTES.SEARCH}?s=${query}`;
+ await act(async () => nextRouterMock.push(currentSlug));
+
+ const { result } = renderHook(() => useBreadcrumbs(), {
+ wrapper: AllProviders,
+ });
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect.assertions(4);
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect(result.current.items).toHaveLength(3);
+ expect(result.current.items[0]).toStrictEqual({
+ id: '/',
+ label: 'Home',
+ slug: '/',
+ });
+ expect(result.current.items[1]).toStrictEqual({
+ id: ROUTES.SEARCH,
+ label: 'Search',
+ slug: ROUTES.SEARCH,
+ });
+ expect(result.current.items[2]).toStrictEqual({
+ id: currentSlug,
+ label: `Search results for "${query}"`,
+ slug: currentSlug,
+ });
+ });
+
+ it('can render the items for the Articles page', async () => {
+ const article = {
+ slug: '/the-article-slug',
+ title: 'qui ducimus rerum',
+ };
+ const currentSlug = `${ROUTES.ARTICLE}${article.slug}`;
+ await act(async () => nextRouterMock.push(currentSlug));
+
+ const { result } = renderHook(() => useBreadcrumbs(article.title), {
+ wrapper: AllProviders,
+ });
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect.assertions(4);
+
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect(result.current.items).toHaveLength(3);
+ expect(result.current.items[0]).toStrictEqual({
+ id: '/',
+ label: 'Home',
+ slug: '/',
+ });
+ expect(result.current.items[1]).toStrictEqual({
+ id: ROUTES.BLOG,
+ label: 'Blog',
+ slug: ROUTES.BLOG,
+ });
+ expect(result.current.items[2]).toStrictEqual({
+ id: currentSlug,
+ label: article.title,
+ slug: currentSlug,
+ });
+ });
+});
diff --git a/src/utils/hooks/use-breadcrumbs/use-breadcrumbs.ts b/src/utils/hooks/use-breadcrumbs/use-breadcrumbs.ts
new file mode 100644
index 0000000..a0132c0
--- /dev/null
+++ b/src/utils/hooks/use-breadcrumbs/use-breadcrumbs.ts
@@ -0,0 +1,144 @@
+import { useRouter } from 'next/router';
+import { useCallback } from 'react';
+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';
+
+const is404 = (slug: string) => slug === ROUTES.NOT_FOUND;
+const isArticle = (slug: string) => slug === ROUTES.ARTICLE;
+const isBlog = (slug: string) => slug === ROUTES.BLOG;
+const isHome = (slug: string) => slug === ROUTES.HOME;
+const isPaginated = (slug: string) =>
+ new RegExp(`${PAGINATED_ROUTE_PREFIX}/[0-9]+$`).test(slug);
+const isProjects = (slug: string) => slug === ROUTES.PROJECTS;
+const isSearch = (slug: string) => slug.startsWith(ROUTES.SEARCH);
+const isThematic = (slug: string) => slug === ROUTES.THEMATICS;
+const isTopic = (slug: string) => slug === ROUTES.TOPICS;
+
+const getCrumbsSlug = (
+ acc: string[],
+ current: string,
+ index: number
+): string[] => [
+ ...acc,
+ ...(isSearch(`/${current}`) ? [`/${current.split('?s=')[0]}`] : []),
+ `${acc[acc.length - 1]}${index === 0 ? '' : '/'}${current}`,
+];
+
+export type UseBreadcrumbsReturn = {
+ items: BreadcrumbsItem[];
+ schema: BreadcrumbList;
+};
+
+export const useBreadcrumbs = (
+ currentPageTitle?: string
+): UseBreadcrumbsReturn => {
+ const { asPath } = useRouter();
+ const intl = useIntl();
+
+ const getCrumbLabel = useCallback(
+ (slug: string) => {
+ switch (true) {
+ case is404(slug):
+ return intl.formatMessage({
+ defaultMessage: '404: Not found',
+ description: 'UseBreadcrumbs: page not found label',
+ id: 'EH+dam',
+ });
+ case isBlog(slug):
+ return intl.formatMessage({
+ defaultMessage: 'Blog',
+ description: 'UseBreadcrumbs: blog label',
+ id: 'K6aSZi',
+ });
+ case isHome(slug):
+ return intl.formatMessage({
+ defaultMessage: 'Home',
+ description: 'UseBreadcrumbs: home label',
+ id: 'aZIuPO',
+ });
+ case isPaginated(slug):
+ return intl.formatMessage(
+ {
+ defaultMessage: 'Page {number}',
+ description: 'UseBreadcrumbs: paginated route label',
+ id: '/5tytV',
+ },
+ { number: slug.split('/').pop() }
+ );
+ case isProjects(slug):
+ return intl.formatMessage({
+ defaultMessage: 'Projects',
+ description: 'UseBreadcrumbs: projects label',
+ id: 'rkz8C6',
+ });
+ case isSearch(slug):
+ if (slug.includes('?s='))
+ return intl.formatMessage(
+ {
+ defaultMessage: 'Search results for "{query}"',
+ description: 'UseBreadcrumbs: search results label',
+ id: 'gSevGm',
+ },
+ { query: slug.split('?s=').pop() }
+ );
+
+ return intl.formatMessage({
+ defaultMessage: 'Search',
+ description: 'UseBreadcrumbs: search label',
+ id: 'iHC3Qx',
+ });
+ default:
+ return capitalize(
+ (slug.split('/').pop() ?? slug).replaceAll('-', ' ')
+ );
+ }
+ },
+ [intl]
+ );
+
+ const items = asPath
+ .split('/')
+ .filter((part) => part)
+ .reduce(getCrumbsSlug, [ROUTES.HOME as string])
+ .filter((slug) => !slug.endsWith(PAGINATED_ROUTE_PREFIX))
+ .map((slug, index, arr) => {
+ if (isArticle(slug) || isThematic(slug) || isTopic(slug))
+ return {
+ id: ROUTES.BLOG,
+ label: getCrumbLabel(ROUTES.BLOG),
+ slug: ROUTES.BLOG,
+ };
+
+ const isLastSlug = index === arr.length - 1;
+
+ return {
+ id: slug,
+ label:
+ isLastSlug && currentPageTitle
+ ? currentPageTitle
+ : getCrumbLabel(slug),
+ slug,
+ };
+ });
+
+ return {
+ items,
+ schema: {
+ '@type': 'BreadcrumbList',
+ '@id': 'breadcrumbs',
+ itemListElement: items.map((item, index) => {
+ return {
+ '@type': 'ListItem',
+ item: {
+ '@id': item.slug,
+ name: item.label,
+ },
+ position: index + 1,
+ };
+ }),
+ },
+ };
+};