diff options
| author | Armand Philippot <git@armandphilippot.com> | 2023-12-12 18:50:03 +0100 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2023-12-12 18:50:03 +0100 |
| commit | 85c4c42bd601270d7be0f34a0767a34bb85e29bb (patch) | |
| tree | 16a07a89cf209139672592fd6988f0c028acb7e9 | |
| parent | 93f87c10783e3d76f1dec667779aedffcae33a39 (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
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, + }; + }), + }, + }; +}; |
