aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-12-07 18:48:53 +0100
committerArmand Philippot <git@armandphilippot.com>2023-12-08 19:13:47 +0100
commitd375e5c9f162cbd84a6e6462977db56519d09f75 (patch)
treeaed9bc81c426e3e9fb60292cb244613cb8083dea
parentb8eb008dd5927fb736e56699637f5f8549965eae (diff)
refactor(pages): refine Project pages
* refactor ProjectOverview component to let consumers handle the value * extract project overview depending on Github to avoid fetching Github API if the project is not on Github * wrap dynamic import in a useMemo hook to avoid infinite rerender * fix table of contents by adding a useMutationObserver hook to refresh headings tree (without it useHeadingsTree is not retriggered once the dynamic import is done) * add Cypress tests
-rw-r--r--mdx.d.ts6
-rw-r--r--src/components/molecules/meta-list/meta-item/meta-item.tsx3
-rw-r--r--src/components/organisms/project-overview/project-overview.stories.tsx4
-rw-r--r--src/components/organisms/project-overview/project-overview.test.tsx64
-rw-r--r--src/components/organisms/project-overview/project-overview.tsx93
m---------src/content0
-rw-r--r--src/i18n/en.json40
-rw-r--r--src/i18n/fr.json40
-rw-r--r--src/pages/article/[slug].tsx2
-rw-r--r--src/pages/cv.tsx2
-rw-r--r--src/pages/mentions-legales.tsx2
-rw-r--r--src/pages/projets/[slug].tsx356
-rw-r--r--src/pages/sujet/[slug].tsx2
-rw-r--r--src/pages/thematique/[slug].tsx2
-rw-r--r--src/styles/pages/project.module.scss11
-rw-r--r--src/styles/pages/projects.module.scss14
-rw-r--r--src/utils/constants.ts2
-rw-r--r--src/utils/helpers/strings.ts6
-rw-r--r--src/utils/hooks/index.ts1
-rw-r--r--src/utils/hooks/use-headings-tree/use-headings-tree.test.ts54
-rw-r--r--src/utils/hooks/use-headings-tree/use-headings-tree.ts50
-rw-r--r--src/utils/hooks/use-mutation-observer/index.ts1
-rw-r--r--src/utils/hooks/use-mutation-observer/use-mutation-observer.test.ts42
-rw-r--r--src/utils/hooks/use-mutation-observer/use-mutation-observer.ts35
-rw-r--r--tests/cypress/e2e/pages/project.cy.ts33
25 files changed, 535 insertions, 330 deletions
diff --git a/mdx.d.ts b/mdx.d.ts
index 91cf7ea..47abfeb 100644
--- a/mdx.d.ts
+++ b/mdx.d.ts
@@ -1,12 +1,12 @@
/* eslint-disable @typescript-eslint/consistent-type-imports */
declare module '*.mdx' {
- type MDXProps = import('mdx/types').MDXProps;
+ type MDXContent = import('mdx/types').MDXContent;
+ type ComponentType = import('react').ComponentType;
type MDXData = import('./src/types/data').MDXData;
type MDXPageMeta = import('./src/types/data').MDXPageMeta;
type MDXProjectMeta = import('./src/types/data').MDXProjectMeta;
- const MDXComponent: (props: MDXProps) => JSX.Element;
- export default MDXComponent;
+ export default ComponentType<MDXContent>;
export const data: MDXData;
export const meta: MDXPageMeta | MDXProjectMeta;
}
diff --git a/src/components/molecules/meta-list/meta-item/meta-item.tsx b/src/components/molecules/meta-list/meta-item/meta-item.tsx
index c5223c2..42a0801 100644
--- a/src/components/molecules/meta-list/meta-item/meta-item.tsx
+++ b/src/components/molecules/meta-list/meta-item/meta-item.tsx
@@ -1,13 +1,12 @@
import {
type ForwardRefRenderFunction,
- type ReactElement,
type ReactNode,
forwardRef,
} from 'react';
import { Description, Group, type GroupProps, Term } from '../../../atoms';
import styles from './meta-item.module.scss';
-export type MetaValue = string | ReactElement;
+export type MetaValue = ReactNode;
export type MetaValues = {
id: string;
diff --git a/src/components/organisms/project-overview/project-overview.stories.tsx b/src/components/organisms/project-overview/project-overview.stories.tsx
index 655dc3c..0c4138c 100644
--- a/src/components/organisms/project-overview/project-overview.stories.tsx
+++ b/src/components/organisms/project-overview/project-overview.stories.tsx
@@ -1,6 +1,6 @@
import type { ComponentMeta, ComponentStory } from '@storybook/react';
import NextImage from 'next/image';
-import { type ProjectMeta, ProjectOverview } from './project-overview';
+import { type OverviewMeta, ProjectOverview } from './project-overview';
/**
* ProjectOverview - Storybook Meta
@@ -49,7 +49,7 @@ const meta = {
creationDate: '2015-09-02',
lastUpdateDate: '2023-11-10',
license: 'MIT',
-} satisfies Partial<ProjectMeta>;
+} satisfies Partial<OverviewMeta>;
/**
* ProjectOverview Stories - Meta
diff --git a/src/components/organisms/project-overview/project-overview.test.tsx b/src/components/organisms/project-overview/project-overview.test.tsx
index 6234368..f798a7b 100644
--- a/src/components/organisms/project-overview/project-overview.test.tsx
+++ b/src/components/organisms/project-overview/project-overview.test.tsx
@@ -1,13 +1,14 @@
import { describe, expect, it } from '@jest/globals';
import NextImage from 'next/image';
import { render, screen as rtlScreen } from '../../../../tests/utils';
-import { type ProjectMeta, ProjectOverview } from './project-overview';
+import { type OverviewMeta, ProjectOverview } from './project-overview';
+import { SocialLink } from 'src/components/atoms';
describe('ProjectOverview', () => {
it('can render a meta for the creation date', () => {
const meta = {
creationDate: '2023-11-01',
- } satisfies Partial<ProjectMeta>;
+ } satisfies Partial<OverviewMeta>;
render(<ProjectOverview meta={meta} name="quo" />);
@@ -17,7 +18,7 @@ describe('ProjectOverview', () => {
it('can render a meta for the update date', () => {
const meta = {
lastUpdateDate: '2023-11-02',
- } satisfies Partial<ProjectMeta>;
+ } satisfies Partial<OverviewMeta>;
render(<ProjectOverview meta={meta} name="quo" />);
@@ -27,7 +28,7 @@ describe('ProjectOverview', () => {
it('can render a meta for the license', () => {
const meta = {
license: 'MIT',
- } satisfies Partial<ProjectMeta>;
+ } satisfies Partial<OverviewMeta>;
render(<ProjectOverview meta={meta} name="quo" />);
@@ -37,38 +38,26 @@ describe('ProjectOverview', () => {
it('can render a meta for the popularity', () => {
const meta = {
- popularity: { count: 5 },
- } satisfies Partial<ProjectMeta>;
+ popularity: '5 stars',
+ } satisfies Partial<OverviewMeta>;
render(<ProjectOverview meta={meta} name="quo" />);
expect(rtlScreen.getByRole('term')).toHaveTextContent('Popularity:');
expect(rtlScreen.getByRole('definition')).toHaveTextContent(
- `${meta.popularity.count} stars`
- );
- });
-
- it('can render a meta for the popularity with a link', () => {
- const meta = {
- popularity: { count: 3, url: '#popularity' },
- } satisfies Partial<ProjectMeta>;
-
- render(<ProjectOverview meta={meta} name="quo" />);
-
- expect(rtlScreen.getByRole('term')).toHaveTextContent('Popularity:');
- expect(rtlScreen.getByRole('definition')).toHaveTextContent(
- `${meta.popularity.count} stars`
- );
- expect(rtlScreen.getByRole('link')).toHaveAttribute(
- 'href',
- meta.popularity.url
+ meta.popularity
);
});
it('can render a meta for the technologies', () => {
const meta = {
- technologies: ['Javascript', 'React'],
- } satisfies Partial<ProjectMeta>;
+ technologies: ['Javascript', 'React'].map((techno) => {
+ return {
+ id: techno,
+ value: techno,
+ };
+ }),
+ } satisfies Partial<OverviewMeta>;
render(<ProjectOverview meta={meta} name="quo" />);
@@ -79,9 +68,22 @@ describe('ProjectOverview', () => {
});
it('can render a meta for the repositories', () => {
+ const repos = [{ id: 'Github' as const, label: 'Github', url: '#github' }];
const meta = {
- repositories: [{ id: 'Github', label: 'Github', url: '#github' }],
- } satisfies Partial<ProjectMeta>;
+ repositories: repos.map((repo) => {
+ return {
+ id: repo.id,
+ value: (
+ <SocialLink
+ icon={repo.id}
+ key={repo.id}
+ label={repo.label}
+ url={repo.url}
+ />
+ ),
+ };
+ }),
+ } satisfies Partial<OverviewMeta>;
render(<ProjectOverview meta={meta} name="quo" />);
@@ -90,8 +92,8 @@ describe('ProjectOverview', () => {
meta.repositories.length
);
expect(
- rtlScreen.getByRole('link', { name: meta.repositories[0].label })
- ).toHaveAttribute('href', meta.repositories[0].url);
+ rtlScreen.getByRole('link', { name: repos[0].label })
+ ).toHaveAttribute('href', repos[0].url);
});
it('can render a cover', () => {
@@ -118,7 +120,7 @@ describe('ProjectOverview', () => {
it('does not render a meta if the key is undefined', () => {
const meta = {
creationDate: undefined,
- } satisfies Partial<ProjectMeta>;
+ } satisfies Partial<OverviewMeta>;
render(<ProjectOverview meta={meta} name="quo" />);
diff --git a/src/components/organisms/project-overview/project-overview.tsx b/src/components/organisms/project-overview/project-overview.tsx
index f524120..d7416ec 100644
--- a/src/components/organisms/project-overview/project-overview.tsx
+++ b/src/components/organisms/project-overview/project-overview.tsx
@@ -6,35 +6,23 @@ import {
type ReactElement,
} from 'react';
import { useIntl } from 'react-intl';
-import type { ValueOf } from '../../../types';
+import { Figure } from '../../atoms';
import {
- Time,
- type SocialWebsite,
- Link,
- SocialLink,
- Figure,
-} from '../../atoms';
-import { MetaItem, type MetaItemProps, MetaList } from '../../molecules';
+ MetaItem,
+ type MetaItemProps,
+ MetaList,
+ type MetaValue,
+ type MetaValues,
+} from '../../molecules';
import styles from './project-overview.module.scss';
-export type Repository = {
- id: Extract<SocialWebsite, 'Github' | 'Gitlab'>;
- label: string;
- url: string;
-};
-
-export type ProjectPopularity = {
- count: number;
- url?: string;
-};
-
-export type ProjectMeta = {
- creationDate: string;
- lastUpdateDate: string;
- license: string;
- popularity: ProjectPopularity;
- repositories: Repository[];
- technologies: string[];
+export type OverviewMeta = {
+ creationDate: MetaValue;
+ lastUpdateDate: MetaValue;
+ license: MetaValue;
+ popularity: MetaValue;
+ repositories: MetaValue | MetaValues[];
+ technologies: MetaValues[];
};
const validMeta = [
@@ -44,9 +32,9 @@ const validMeta = [
'popularity',
'repositories',
'technologies',
-] satisfies (keyof ProjectMeta)[];
+] satisfies (keyof OverviewMeta)[];
-const isValidMetaKey = (key: string): key is keyof ProjectMeta =>
+const isValidMetaKey = (key: string): key is keyof OverviewMeta =>
(validMeta as string[]).includes(key);
export type ProjectOverviewProps = Omit<
@@ -60,7 +48,7 @@ export type ProjectOverviewProps = Omit<
/**
* The project meta.
*/
- meta: Partial<ProjectMeta>;
+ meta: Partial<OverviewMeta>;
/**
* The project name.
*/
@@ -112,48 +100,7 @@ const ProjectOverviewWithRef: ForwardRefRenderFunction<
description: 'ProjectOverview: technologies label',
id: 'OWkqXt',
}),
- } satisfies Record<keyof ProjectMeta, string>;
-
- const getMetaValue = useCallback(
- (key: keyof ProjectMeta, value: ValueOf<ProjectMeta>) => {
- if (typeof value === 'string') {
- return key === 'license' ? value : <Time date={value} />;
- }
-
- if (
- (value instanceof Object || typeof value === 'object') &&
- !Array.isArray(value)
- ) {
- const stars = intl.formatMessage(
- {
- defaultMessage:
- '{starsCount, plural, =0 {No stars} one {# star} other {# stars}}',
- description: 'ProjectOverview: stars count',
- id: 'PBdVsm',
- },
- { starsCount: value.count }
- );
-
- return value.url ? (
- <>
- ⭐&nbsp;<Link href={value.url}>{stars}</Link>
- </>
- ) : (
- `⭐\u00A0${stars}`
- );
- }
-
- return value.map((v) => {
- if (typeof v === 'string') return { id: v, value: v };
-
- return {
- id: v.id,
- value: <SocialLink icon={v.id} label={v.label} url={v.url} />,
- };
- });
- },
- [intl]
- );
+ } satisfies Record<keyof OverviewMeta, string>;
const getMetaItems = useCallback(() => {
const keys = Object.keys(meta).filter(isValidMetaKey);
@@ -172,7 +119,7 @@ const ProjectOverviewWithRef: ForwardRefRenderFunction<
}
key={key}
label={metaLabels[key]}
- value={getMetaValue(key, value)}
+ value={value}
/>
) : undefined;
})
@@ -180,7 +127,7 @@ const ProjectOverviewWithRef: ForwardRefRenderFunction<
(item): item is ReactElement<MetaItemProps> =>
typeof item !== 'undefined'
);
- }, [getMetaValue, meta, metaLabels]);
+ }, [meta, metaLabels]);
return (
<div {...props} className={wrapperClass} ref={ref}>
diff --git a/src/content b/src/content
-Subproject ff0f83536484454005340e9cd288f072639cc4e
+Subproject a7e4d94a2211651ddcf5524a25a93f52678877f
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 64b3214..935dcdc 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -75,6 +75,10 @@
"defaultMessage": "Name:",
"description": "ContactForm: name label"
},
+ "1msHuZ": {
+ "defaultMessage": "Gitlab",
+ "description": "ProjectPage: Gitlab repo label"
+ },
"28GZdv": {
"defaultMessage": "Projects",
"description": "Breadcrumb: projects label"
@@ -111,6 +115,10 @@
"defaultMessage": "Page not found.",
"description": "404Page: SEO - Meta description"
},
+ "4M71hp": {
+ "defaultMessage": "{starsCount, plural, =0 {No stars} one {# star} other {# stars}}",
+ "description": "ProjectPage: stars count"
+ },
"5C+1PP": {
"defaultMessage": "Blog",
"description": "SiteNavbar: main nav - blog link"
@@ -227,6 +235,10 @@
"defaultMessage": "No results found. Would you like to try a new search?",
"description": "SearchPage: no results"
},
+ "EET/tC": {
+ "defaultMessage": "Loading the repository metadata...",
+ "description": "ProjectPage: loading repository metadata"
+ },
"Es52wh": {
"defaultMessage": "Blog",
"description": "Breadcrumb: blog label"
@@ -247,10 +259,6 @@
"defaultMessage": "{commentsCount, plural, =0 {No comments} one {# comment} other {# comments}}",
"description": "PageComments: the section title of the comments list"
},
- "HKKkQk": {
- "defaultMessage": "Share",
- "description": "SharingWidget: widget title"
- },
"Hclr0a": {
"defaultMessage": "Share on Journal du Hacker",
"description": "SharingWidget: Journal du Hacker sharing link"
@@ -291,6 +299,10 @@
"defaultMessage": "Github profile",
"description": "CVPage: Github profile link"
},
+ "JnalJp": {
+ "defaultMessage": "Share",
+ "description": "ProjectPage: sharing widget title"
+ },
"JpC3JH": {
"defaultMessage": "Other topics",
"description": "TopicPage: other topics list widget title"
@@ -331,10 +343,6 @@
"defaultMessage": "Search - {websiteName}",
"description": "SearchPage: SEO - Page title"
},
- "Nx8Jo5": {
- "defaultMessage": "Github profile",
- "description": "ProjectsPage: Github profile link"
- },
"NzeU3V": {
"defaultMessage": "Published on:",
"description": "ApprovedComment: publication date label"
@@ -363,10 +371,6 @@
"defaultMessage": "Topics are loading...",
"description": "BlogPage: loading topics message"
},
- "PBdVsm": {
- "defaultMessage": "{starsCount, plural, =0 {No stars} one {# star} other {# stars}}",
- "description": "ProjectOverview: stars count"
- },
"PnrHgZ": {
"defaultMessage": "Home",
"description": "SiteNavbar: main nav - home link"
@@ -403,10 +407,6 @@
"defaultMessage": "License:",
"description": "ProjectOverview: license label"
},
- "RwI3B9": {
- "defaultMessage": "Loading the repository popularity...",
- "description": "ProjectsPage: loading repository popularity"
- },
"Sm2wCk": {
"defaultMessage": "LinkedIn profile",
"description": "CVPage: LinkedIn profile link"
@@ -595,6 +595,10 @@
"defaultMessage": "Open settings",
"description": "SiteNavbar: settings button label in navbar"
},
+ "l82UU5": {
+ "defaultMessage": "Github",
+ "description": "ProjectPage: Github repo label"
+ },
"lKhTGM": {
"defaultMessage": "Use Ctrl+c to copy",
"description": "usePrism: copy button error text"
@@ -675,10 +679,6 @@
"defaultMessage": "Object:",
"description": "ContactForm: object label"
},
- "sECHDg": {
- "defaultMessage": "Gitlab profile",
- "description": "ProjectsPage: Gitlab profile link"
- },
"sR5hah": {
"defaultMessage": "Updated on:",
"description": "PageHeader: update date label"
diff --git a/src/i18n/fr.json b/src/i18n/fr.json
index 41af99d..6e65be0 100644
--- a/src/i18n/fr.json
+++ b/src/i18n/fr.json
@@ -75,6 +75,10 @@
"defaultMessage": "Nom :",
"description": "ContactForm: name label"
},
+ "1msHuZ": {
+ "defaultMessage": "Gitlab",
+ "description": "ProjectPage: Gitlab repo label"
+ },
"28GZdv": {
"defaultMessage": "Projets",
"description": "Breadcrumb: projects label"
@@ -111,6 +115,10 @@
"defaultMessage": "Page non trouvée.",
"description": "404Page: SEO - Meta description"
},
+ "4M71hp": {
+ "defaultMessage": "{starsCount, plural, =0 {0 étoile} one {# étoile} other {# étoiles}}",
+ "description": "ProjectPage: stars count"
+ },
"5C+1PP": {
"defaultMessage": "Blog",
"description": "SiteNavbar: main nav - blog link"
@@ -227,6 +235,10 @@
"defaultMessage": "Aucun résultat. Souhaitez-vous tenter une nouvelle rechercher ?",
"description": "SearchPage: no results"
},
+ "EET/tC": {
+ "defaultMessage": "Chargement des métadonnées du dépôt…",
+ "description": "ProjectPage: loading repository metadata"
+ },
"Es52wh": {
"defaultMessage": "Blog",
"description": "Breadcrumb: blog label"
@@ -247,10 +259,6 @@
"defaultMessage": "{commentsCount, plural, =0 {0 commentaire} one {# commentaire} other {# commentaires}}",
"description": "PageComments: the section title of the comments list"
},
- "HKKkQk": {
- "defaultMessage": "Partager",
- "description": "SharingWidget: widget title"
- },
"Hclr0a": {
"defaultMessage": "Partager sur le Journal du Hacker",
"description": "SharingWidget: Journal du Hacker sharing link"
@@ -291,6 +299,10 @@
"defaultMessage": "Profil Github",
"description": "CVPage: Github profile link"
},
+ "JnalJp": {
+ "defaultMessage": "Partager",
+ "description": "ProjectPage: sharing widget title"
+ },
"JpC3JH": {
"defaultMessage": "Autres sujets",
"description": "TopicPage: other topics list widget title"
@@ -331,10 +343,6 @@
"defaultMessage": "Recherche - {websiteName}",
"description": "SearchPage: SEO - Page title"
},
- "Nx8Jo5": {
- "defaultMessage": "Profil Github",
- "description": "ProjectsPage: Github profile link"
- },
"NzeU3V": {
"defaultMessage": "Publié le :",
"description": "ApprovedComment: publication date label"
@@ -363,10 +371,6 @@
"defaultMessage": "Les sujets sont en cours de chargement…",
"description": "BlogPage: loading topics message"
},
- "PBdVsm": {
- "defaultMessage": "{starsCount, plural, =0 {0 étoile} one {# étoile} other {# étoiles}}",
- "description": "ProjectOverview: stars count"
- },
"PnrHgZ": {
"defaultMessage": "Accueil",
"description": "SiteNavbar: main nav - home link"
@@ -403,10 +407,6 @@
"defaultMessage": "Licence :",
"description": "ProjectOverview: license label"
},
- "RwI3B9": {
- "defaultMessage": "Chargement de la popularité du dépôt…",
- "description": "ProjectsPage: loading repository popularity"
- },
"Sm2wCk": {
"defaultMessage": "Profil LinkedIn",
"description": "CVPage: LinkedIn profile link"
@@ -595,6 +595,10 @@
"defaultMessage": "Ouvrir les réglages",
"description": "SiteNavbar: settings button label in navbar"
},
+ "l82UU5": {
+ "defaultMessage": "Github",
+ "description": "ProjectPage: Github repo label"
+ },
"lKhTGM": {
"defaultMessage": "Utilisez Ctrl+c pour copier",
"description": "usePrism: copy button error text"
@@ -675,10 +679,6 @@
"defaultMessage": "Sujet :",
"description": "ContactForm: object label"
},
- "sECHDg": {
- "defaultMessage": "Profil Gitlab",
- "description": "ProjectsPage: Gitlab profile link"
- },
"sR5hah": {
"defaultMessage": "Mis à jour le :",
"description": "PageHeader: update date label"
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
index 2a886aa..bd102a9 100644
--- a/src/pages/article/[slug].tsx
+++ b/src/pages/article/[slug].tsx
@@ -78,7 +78,7 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ data }) => {
title: data.post.title,
url: data.post.slug,
});
- const { ref, tree } = useHeadingsTree({ fromLevel: 2 });
+ const { ref, tree } = useHeadingsTree<HTMLDivElement>({ fromLevel: 2 });
const { attributes, className: prismClassName } = usePrism({
attributes: {
'data-toolbar-order': 'show-language,copy-to-clipboard,color-scheme',
diff --git a/src/pages/cv.tsx b/src/pages/cv.tsx
index edff59f..b77aa8c 100644
--- a/src/pages/cv.tsx
+++ b/src/pages/cv.tsx
@@ -42,7 +42,7 @@ const DownloadLink = (chunks: ReactNode) => (
*/
const CVPage: NextPageWithLayout = () => {
const intl = useIntl();
- const { ref, tree } = useHeadingsTree({ fromLevel: 2 });
+ const { ref, tree } = useHeadingsTree<HTMLDivElement>({ fromLevel: 2 });
const { dates, intro, seo, title } = meta;
const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
title,
diff --git a/src/pages/mentions-legales.tsx b/src/pages/mentions-legales.tsx
index d5958a6..7d46680 100644
--- a/src/pages/mentions-legales.tsx
+++ b/src/pages/mentions-legales.tsx
@@ -37,7 +37,7 @@ const LegalNoticePage: NextPageWithLayout = () => {
url: ROUTES.LEGAL_NOTICE,
});
- const { ref, tree } = useHeadingsTree({ fromLevel: 2 });
+ const { ref, tree } = useHeadingsTree<HTMLDivElement>({ fromLevel: 2 });
const { asPath } = useRouter();
const webpageSchema = getWebPageSchema({
description: seo.description,
diff --git a/src/pages/projets/[slug].tsx b/src/pages/projets/[slug].tsx
index cac6037..0c750f9 100644
--- a/src/pages/projets/[slug].tsx
+++ b/src/pages/projets/[slug].tsx
@@ -4,39 +4,49 @@ import type { GetStaticPaths, GetStaticProps } from 'next';
import dynamic from 'next/dynamic';
import Head from 'next/head';
import NextImage from 'next/image';
-import { useRouter } from 'next/router';
import Script from 'next/script';
-import type { ComponentType } from 'react';
+import { useMemo, type ComponentType, type FC } from 'react';
import { useIntl } from 'react-intl';
import {
- getLayout,
- SharingWidget,
- Spinner,
Heading,
- ProjectOverview,
- type ProjectMeta,
- type Repository,
+ Link,
+ LoadingPage,
+ type MetaValues,
Page,
+ PageBody,
PageHeader,
PageSidebar,
+ ProjectOverview,
+ SharingWidget,
+ SocialLink,
+ Spinner,
+ Time,
TocWidget,
- PageBody,
+ getLayout,
+ type ProjectOverviewProps,
} from '../../components';
import { mdxComponents } from '../../components/mdx';
-import styles from '../../styles/pages/project.module.scss';
-import type { NextPageWithLayout, Project, Repos } from '../../types';
+import { fetchGithubRepoMeta } from '../../services/github';
+import styles from '../../styles/pages/projects.module.scss';
+import type {
+ GithubRepositoryMeta,
+ Maybe,
+ NextPageWithLayout,
+ Project,
+ ProjectMeta,
+} from '../../types';
import { CONFIG } from '../../utils/config';
-import { GITHUB_PSEUDO, ROUTES } from '../../utils/constants';
import {
+ capitalize,
getSchemaJson,
getSinglePageSchema,
getWebPageSchema,
} from '../../utils/helpers';
import {
+ type Messages,
getProjectData,
getProjectFilenames,
loadTranslation,
- type Messages,
} from '../../utils/helpers/server';
import {
useBreadcrumb,
@@ -44,139 +54,201 @@ import {
useHeadingsTree,
} from '../../utils/hooks';
-type ProjectPageProps = {
- project: Project;
- translation: Messages;
-};
+const getGithubRepoInputFrom = (namespace: string) => {
+ const parts = namespace.split('/');
-/**
- * Project page.
- */
-const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ project }) => {
- const { id, intro, meta, title } = project;
- const { cover, dates, license, repos, seo, technologies } = meta;
- const intl = useIntl();
- const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
- title,
- url: `${ROUTES.PROJECTS}/${id}`,
- });
- const { ref, tree } = useHeadingsTree({ fromLevel: 2 });
+ if (parts.length !== 2)
+ throw new Error(
+ 'Invalid repo. It should use the following format: owner/name.'
+ );
- const ProjectContent: ComponentType<MDXComponents> = dynamic(
- async () => import(`../../content/projects/${id}.mdx`),
- {
- loading: () => <Spinner />,
- }
- );
+ return { owner: parts[0], name: parts[1] };
+};
- const { asPath } = useRouter();
- const page = {
- title: `${seo.title} - ${CONFIG.name}`,
- url: `${CONFIG.url}${asPath}`,
+const isValidRepo = (name: string): name is 'github' | 'gitlab' =>
+ ['github', 'gitlab'].includes(name);
+
+type GithubRepoOverviewProps = Omit<
+ ProjectOverviewProps,
+ 'cover' | 'meta' | 'name'
+> &
+ Pick<ProjectMeta, 'cover' | 'license' | 'technologies'> & {
+ repos: {
+ github: string;
+ gitlab?: string;
+ };
+ title: string;
};
- /**
- * Retrieve the project repositories.
- *
- * @param {Repos} repositories - A repositories object.
- * @returns {Repository[]} - An array of repositories.
- */
- const getRepos = (repositories: Repos): Repository[] => {
- const definedRepos: Repository[] = [];
+const GithubRepoOverview: FC<GithubRepoOverviewProps> = ({
+ cover,
+ license,
+ repos,
+ technologies,
+ title,
+ ...props
+}) => {
+ const intl = useIntl();
+ const { isLoading, meta: repoMeta } = useGithubRepoMeta(
+ getGithubRepoInputFrom(repos.github)
+ );
+ const reposLabels = {
+ github: intl.formatMessage({
+ defaultMessage: 'Github',
+ description: 'ProjectPage: Github repo label',
+ id: 'l82UU5',
+ }),
+ gitlab: intl.formatMessage({
+ defaultMessage: 'Gitlab',
+ description: 'ProjectPage: Gitlab repo label',
+ id: '1msHuZ',
+ }),
+ };
+ const stars = intl.formatMessage(
+ {
+ defaultMessage:
+ '{starsCount, plural, =0 {No stars} one {# star} other {# stars}}',
+ description: 'ProjectPage: stars count',
+ id: '4M71hp',
+ },
+ { starsCount: repoMeta?.stargazerCount }
+ );
+ const popularityURL = `https://github.com/${repos.github}/stargazers`;
- if (repositories.github)
- definedRepos.push({
- id: 'Github',
- label: intl.formatMessage({
- defaultMessage: 'Github profile',
- description: 'ProjectsPage: Github profile link',
- id: 'Nx8Jo5',
- }),
- url: repositories.github,
- });
+ return isLoading ? (
+ <Spinner>
+ {intl.formatMessage({
+ defaultMessage: 'Loading the repository metadata...',
+ description: 'ProjectPage: loading repository metadata',
+ id: 'EET/tC',
+ })}
+ </Spinner>
+ ) : (
+ <ProjectOverview
+ {...props}
+ cover={cover ? <NextImage {...cover} /> : undefined}
+ meta={{
+ creationDate: repoMeta?.createdAt ? (
+ <Time date={repoMeta.createdAt} />
+ ) : undefined,
+ lastUpdateDate: repoMeta?.updatedAt ? (
+ <Time date={repoMeta.updatedAt} />
+ ) : undefined,
+ license,
+ popularity: (
+ <>
+ ⭐&nbsp;<Link href={popularityURL}>{stars}</Link>
+ </>
+ ),
+ repositories: Object.entries(repos)
+ .map(([key, value]): Maybe<MetaValues> => {
+ if (!isValidRepo(key)) return undefined;
- if (repositories.gitlab)
- definedRepos.push({
- id: 'Gitlab',
- label: intl.formatMessage({
- defaultMessage: 'Gitlab profile',
- description: 'ProjectsPage: Gitlab profile link',
- id: 'sECHDg',
+ return {
+ id: key,
+ value: (
+ <SocialLink
+ icon={capitalize(key)}
+ key={key}
+ label={reposLabels[key]}
+ url={value}
+ />
+ ),
+ };
+ })
+ .filter((entry): entry is MetaValues => !!entry),
+ technologies: technologies?.map((techno) => {
+ return {
+ id: techno,
+ value: techno,
+ };
}),
- url: repositories.gitlab,
- });
+ }}
+ name={title}
+ />
+ );
+};
- return definedRepos;
+type ProjectPageProps = {
+ data: {
+ githubMeta: Maybe<GithubRepositoryMeta>;
+ project: Project;
};
+ translation: Messages;
+};
- const loadingRepoPopularity = intl.formatMessage({
- defaultMessage: 'Loading the repository popularity...',
- description: 'ProjectsPage: loading repository popularity',
- id: 'RwI3B9',
- });
-
- const {
- isError,
- isLoading,
- meta: githubMeta,
- } = useGithubRepoMeta({
- name: repos.github?.substring(repos.github.lastIndexOf('/') + 1) ?? '',
- owner: GITHUB_PSEUDO,
+/**
+ * Project page.
+ */
+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 { ref, tree } = useHeadingsTree<HTMLDivElement>({ fromLevel: 2 });
- if (isError) return 'Error';
- if (isLoading || !githubMeta)
- return <Spinner aria-label={loadingRepoPopularity} />;
-
- const overviewMeta: Partial<ProjectMeta> = {
- creationDate: githubMeta.createdAt,
- lastUpdateDate: githubMeta.updatedAt,
- license,
- popularity: repos.github
- ? {
- count: githubMeta.stargazerCount,
- url: `https://github.com/${repos.github}/stargazers`,
- }
- : undefined,
- repositories: getRepos(repos),
- technologies,
+ const page = {
+ title: `${meta.seo.title} - ${CONFIG.name}`,
+ url: `${CONFIG.url}${slug}`,
};
const webpageSchema = getWebPageSchema({
- description: seo.description,
+ description: meta.seo.description,
locale: CONFIG.locales.defaultLocale,
- slug: asPath,
- title: seo.title,
- updateDate: dates.update,
+ slug,
+ title: meta.seo.title,
+ updateDate: meta.dates.update,
});
const articleSchema = getSinglePageSchema({
cover: `/projects/${id}.jpg`,
- dates,
+ dates: meta.dates,
description: intro,
id: 'project',
kind: 'page',
locale: CONFIG.locales.defaultLocale,
- slug: asPath,
+ slug,
title,
});
const schemaJsonLd = getSchemaJson([webpageSchema, articleSchema]);
- const sharingWidgetTitle = intl.formatMessage({
- defaultMessage: 'Share',
- id: 'HKKkQk',
- description: 'SharingWidget: widget title',
- });
- const tocTitle = intl.formatMessage({
- defaultMessage: 'Table of Contents',
- description: 'PageLayout: table of contents title',
- id: 'eys2uX',
- });
+
+ const messages = {
+ repos: {
+ gitlab: intl.formatMessage({
+ defaultMessage: 'Gitlab',
+ description: 'ProjectPage: Gitlab repo label',
+ id: '1msHuZ',
+ }),
+ },
+ widgets: {
+ sharingTitle: intl.formatMessage({
+ defaultMessage: 'Share',
+ id: 'JnalJp',
+ description: 'ProjectPage: sharing widget title',
+ }),
+ tocTitle: intl.formatMessage({
+ defaultMessage: 'Table of Contents',
+ description: 'PageLayout: table of contents title',
+ id: 'eys2uX',
+ }),
+ },
+ };
+
+ const ProjectContent: ComponentType<MDXComponents> = useMemo(
+ () =>
+ dynamic(async () => import(`../../content/projects/${id}.mdx`), {
+ loading: () => <LoadingPage />,
+ }),
+ [id]
+ );
return (
<Page breadcrumbs={breadcrumbItems} isBodyLastChild>
<Head>
<title>{page.title}</title>
{/*eslint-disable-next-line react/jsx-no-literals -- Name allowed */}
- <meta name="description" content={seo.description} />
+ <meta name="description" content={meta.seo.description} />
<meta property="og:url" content={page.url} />
{/*eslint-disable-next-line react/jsx-no-literals -- Content allowed */}
<meta property="og:type" content="article" />
@@ -200,28 +272,57 @@ const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ project }) => {
heading={title}
intro={intro}
meta={{
- publicationDate: dates.publication,
- updateDate: dates.update,
+ publicationDate: meta.dates.publication,
+ updateDate: meta.dates.update,
}}
/>
<PageSidebar>
<TocWidget
- heading={<Heading level={3}>{tocTitle}</Heading>}
+ heading={<Heading level={2}>{messages.widgets.tocTitle}</Heading>}
tree={tree}
/>
</PageSidebar>
<PageBody ref={ref}>
- <ProjectOverview
- cover={cover ? <NextImage {...cover} /> : undefined}
- meta={overviewMeta}
- name={project.title}
- />
+ {meta.repos.github ? (
+ <GithubRepoOverview
+ className={styles.overview}
+ cover={meta.cover}
+ license={meta.license}
+ repos={{ github: meta.repos.github, gitlab: meta.repos.gitlab }}
+ technologies={meta.technologies}
+ title={title}
+ />
+ ) : (
+ <ProjectOverview
+ className={styles.overview}
+ cover={meta.cover ? <NextImage {...meta.cover} /> : undefined}
+ meta={{
+ license: meta.license,
+ repositories: meta.repos.gitlab ? (
+ <SocialLink
+ // eslint-disable-next-line react/jsx-no-literals
+ icon="Gitlab"
+ label={messages.repos.gitlab}
+ url={meta.repos.gitlab}
+ />
+ ) : undefined,
+ technologies: meta.technologies?.map((techno) => {
+ return {
+ id: techno,
+ value: techno,
+ };
+ }),
+ }}
+ name={title}
+ />
+ )}
<ProjectContent components={mdxComponents} />
</PageBody>
<PageSidebar>
<SharingWidget
+ className={styles['sharing-widget']}
data={{ excerpt: intro, title, url: page.url }}
- heading={<Heading level={3}>{sharingWidgetTitle}</Heading>}
+ heading={<Heading level={2}>{messages.widgets.sharingTitle}</Heading>}
media={[
'diaspora',
'email',
@@ -230,7 +331,6 @@ const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ project }) => {
'linkedin',
'twitter',
]}
- className={styles.widget}
/>
</PageSidebar>
</Page>
@@ -245,10 +345,18 @@ export const getStaticProps: GetStaticProps<ProjectPageProps> = async ({
}) => {
const translation = await loadTranslation(locale);
const project = await getProjectData(params ? (params.slug as string) : '');
+ const githubMeta = project.meta.repos.github
+ ? await fetchGithubRepoMeta(
+ getGithubRepoInputFrom(project.meta.repos.github)
+ )
+ : undefined;
return {
props: {
- project,
+ data: {
+ githubMeta,
+ project,
+ },
translation,
},
};
diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx
index 8a9c2f3..43b5aa6 100644
--- a/src/pages/sujet/[slug].tsx
+++ b/src/pages/sujet/[slug].tsx
@@ -75,7 +75,7 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({ data }) => {
title: topic.title,
url: `${ROUTES.TOPICS}/${topic.slug}`,
});
- const { ref, tree } = useHeadingsTree({ fromLevel: 2 });
+ const { ref, tree } = useHeadingsTree<HTMLDivElement>({ fromLevel: 2 });
if (isFallback || isLoading) return <LoadingPage />;
diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx
index e290782..6ab349d 100644
--- a/src/pages/thematique/[slug].tsx
+++ b/src/pages/thematique/[slug].tsx
@@ -74,7 +74,7 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({ data }) => {
title: data.currentThematic.title,
url: `${ROUTES.THEMATICS}/${data.currentThematic.slug}`,
});
- const { ref, tree } = useHeadingsTree({ fromLevel: 2 });
+ const { ref, tree } = useHeadingsTree<HTMLDivElement>({ fromLevel: 2 });
if (isFallback || isLoading) return <LoadingPage />;
diff --git a/src/styles/pages/project.module.scss b/src/styles/pages/project.module.scss
deleted file mode 100644
index 69c0f8d..0000000
--- a/src/styles/pages/project.module.scss
+++ /dev/null
@@ -1,11 +0,0 @@
-@use "../abstracts/mixins" as mix;
-
-.widget {
- @include mix.media("screen") {
- @include mix.dimensions("md") {
- > ul {
- width: min-content;
- }
- }
- }
-}
diff --git a/src/styles/pages/projects.module.scss b/src/styles/pages/projects.module.scss
index 33a0d42..ab5389d 100644
--- a/src/styles/pages/projects.module.scss
+++ b/src/styles/pages/projects.module.scss
@@ -13,3 +13,17 @@
.card {
height: 100%;
}
+
+.overview {
+ margin-block: var(--spacing-md);
+}
+
+.sharing-widget {
+ @include mix.media("screen") {
+ @include mix.dimensions("md") {
+ ul {
+ width: min-content;
+ }
+ }
+ }
+}
diff --git a/src/utils/constants.ts b/src/utils/constants.ts
index f9d6216..9733b15 100644
--- a/src/utils/constants.ts
+++ b/src/utils/constants.ts
@@ -1,7 +1,5 @@
export const GITHUB_API = 'https://api.github.com/graphql';
-export const GITHUB_PSEUDO = 'ArmandPhilippot';
-
export const PERSONAL_LINKS = {
GITHUB: 'https://github.com/ArmandPhilippot',
GITLAB: 'https://gitlab.com/ArmandPhilippot',
diff --git a/src/utils/helpers/strings.ts b/src/utils/helpers/strings.ts
index b8af61d..1f40b8f 100644
--- a/src/utils/helpers/strings.ts
+++ b/src/utils/helpers/strings.ts
@@ -23,8 +23,10 @@ export const slugify = (text: string): string =>
* @param {string} text - A text to capitalize.
* @returns {string} The capitalized text.
*/
-export const capitalize = (text: string): string =>
- text.replace(/^\w/, (firstLetter) => firstLetter.toUpperCase());
+export const capitalize = <T extends string>(text: T): Capitalize<T> =>
+ text.replace(/^\w/, (firstLetter) =>
+ firstLetter.toUpperCase()
+ ) as Capitalize<T>;
/**
* Convert a text from kebab case (foo-bar) to camel case (fooBar).
diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts
index f4d1583..95cb717 100644
--- a/src/utils/hooks/index.ts
+++ b/src/utils/hooks/index.ts
@@ -9,6 +9,7 @@ export * from './use-github-repo-meta';
export * from './use-headings-tree';
export * from './use-local-storage';
export * from './use-match-media';
+export * from './use-mutation-observer';
export * from './use-on-click-outside';
export * from './use-on-route-change';
export * from './use-pagination';
diff --git a/src/utils/hooks/use-headings-tree/use-headings-tree.test.ts b/src/utils/hooks/use-headings-tree/use-headings-tree.test.ts
index 2c8ff2d..ffc5dbe 100644
--- a/src/utils/hooks/use-headings-tree/use-headings-tree.test.ts
+++ b/src/utils/hooks/use-headings-tree/use-headings-tree.test.ts
@@ -1,5 +1,5 @@
-import { describe, expect, it } from '@jest/globals';
-import { act, renderHook } from '@testing-library/react';
+import { beforeEach, describe, expect, it } from '@jest/globals';
+import { act, renderHook, waitFor } from '@testing-library/react';
import { useHeadingsTree } from './use-headings-tree';
const labels = {
@@ -9,16 +9,23 @@ const labels = {
};
describe('useHeadingsTree', () => {
- it('returns a ref callback and the headings tree', () => {
- const wrapper = document.createElement('div');
-
- wrapper.innerHTML = `
+ const wrapper = document.createElement('div');
+ wrapper.innerHTML = `
<h1>${labels.h1}</h1>
<h2>${labels.firstH2}</h2>
<p>Expedita et necessitatibus qui numquam sunt et ut et. Earum nostrum esse nemo nisi qui. Ab in iure qui repellat voluptatibus nostrum odit aut qui. Architecto eum fugit quod excepturi numquam qui maxime accusantium. Fugit ipsam harum tempora.</p>
<h2>${labels.secondH2}</h2>
<p>Totam cumque aut ipsum. Necessitatibus magnam necessitatibus. Qui illo nulla non ab. Accusamus voluptatem ab fugiat voluptas aspernatur velit dolore reprehenderit. Voluptatem quod minima asperiores voluptatum distinctio cumque quo.</p>`;
+ beforeEach(() => {
+ document.body.appendChild(wrapper);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(wrapper);
+ });
+
+ it('returns a ref callback and the headings tree', () => {
const { result } = renderHook(() => useHeadingsTree());
act(() => result.current.ref(wrapper));
@@ -29,15 +36,6 @@ describe('useHeadingsTree', () => {
});
it('can return a headings tree starting at the specified level', () => {
- const wrapper = document.createElement('div');
-
- wrapper.innerHTML = `
-<h1>${labels.h1}</h1>
-<h2>${labels.firstH2}</h2>
-<p>Expedita et necessitatibus qui numquam sunt et ut et. Earum nostrum esse nemo nisi qui. Ab in iure qui repellat voluptatibus nostrum odit aut qui. Architecto eum fugit quod excepturi numquam qui maxime accusantium. Fugit ipsam harum tempora.</p>
-<h2>${labels.secondH2}</h2>
-<p>Totam cumque aut ipsum. Necessitatibus magnam necessitatibus. Qui illo nulla non ab. Accusamus voluptatem ab fugiat voluptas aspernatur velit dolore reprehenderit. Voluptatem quod minima asperiores voluptatum distinctio cumque quo.</p>`;
-
const { result } = renderHook(() => useHeadingsTree({ fromLevel: 2 }));
act(() => result.current.ref(wrapper));
@@ -48,15 +46,6 @@ describe('useHeadingsTree', () => {
});
it('can return a headings tree stopping at the specified level', () => {
- const wrapper = document.createElement('div');
-
- wrapper.innerHTML = `
-<h1>${labels.h1}</h1>
-<h2>${labels.firstH2}</h2>
-<p>Expedita et necessitatibus qui numquam sunt et ut et. Earum nostrum esse nemo nisi qui. Ab in iure qui repellat voluptatibus nostrum odit aut qui. Architecto eum fugit quod excepturi numquam qui maxime accusantium. Fugit ipsam harum tempora.</p>
-<h2>${labels.secondH2}</h2>
-<p>Totam cumque aut ipsum. Necessitatibus magnam necessitatibus. Qui illo nulla non ab. Accusamus voluptatem ab fugiat voluptas aspernatur velit dolore reprehenderit. Voluptatem quod minima asperiores voluptatum distinctio cumque quo.</p>`;
-
const { result } = renderHook(() => useHeadingsTree({ toLevel: 1 }));
act(() => result.current.ref(wrapper));
@@ -66,6 +55,23 @@ describe('useHeadingsTree', () => {
expect(result.current.tree[0].children).toStrictEqual([]);
});
+ it('uses a mutation observer to watch for DOM changes', async () => {
+ const newH2 = document.createElement('h2');
+ newH2.innerHTML = 'ut molestiae exercitationem';
+ const { result } = renderHook(() => useHeadingsTree({ fromLevel: 2 }));
+
+ act(() => result.current.ref(wrapper));
+
+ expect(result.current.tree.length).toBe(2);
+
+ act(() => wrapper.appendChild(newH2));
+
+ await waitFor(() => {
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers
+ expect(result.current.tree.length).toBe(3);
+ });
+ });
+
it('throws an error if the options are invalid', () => {
expect(() => useHeadingsTree({ fromLevel: 2, toLevel: 1 })).toThrowError(
'Invalid options: `fromLevel` must be lower or equal to `toLevel`.'
diff --git a/src/utils/hooks/use-headings-tree/use-headings-tree.ts b/src/utils/hooks/use-headings-tree/use-headings-tree.ts
index 68bdde8..802d843 100644
--- a/src/utils/hooks/use-headings-tree/use-headings-tree.ts
+++ b/src/utils/hooks/use-headings-tree/use-headings-tree.ts
@@ -1,5 +1,7 @@
-import { useState, useCallback, type RefCallback } from 'react';
+import { useState, useCallback, type RefCallback, useEffect } from 'react';
import type { HeadingLevel } from '../../../components';
+import type { Nullable } from '../../../types';
+import { useMutationObserver } from '../use-mutation-observer';
export type HeadingsTreeNode = {
/**
@@ -140,17 +142,43 @@ export const useHeadingsTree = <T extends HTMLElement = HTMLElement>(
'Invalid options: `fromLevel` must be lower or equal to `toLevel`.'
);
- const [tree, setTree] = useState<HeadingsTreeNode[]>([]);
+ const [headings, setHeadings] = useState<NodeListOf<HTMLHeadingElement>>();
const requestedHeadingTags = getHeadingTagsList(options);
const query = requestedHeadingTags.join(', ');
- const ref: RefCallback<T> = useCallback(
- (el) => {
- const headingNodes = el?.querySelectorAll<HTMLHeadingElement>(query);
- if (headingNodes) setTree(buildHeadingsTreeFrom(headingNodes));
- },
- [query]
- );
-
- return { ref, tree };
+ /*
+ * With a mutable ref, the headings are not always updated because of loading
+ * states. So we need to use a RefCallback to detect when the component is
+ * effectively rendered. However, to be able to compare the mutation records,
+ * we need to keep track of the current ref so we also need to use useState...
+ */
+ const [wrapper, setWrapper] = useState<Nullable<T>>();
+ const ref: RefCallback<T> = useCallback((el) => {
+ setWrapper(el);
+ }, []);
+
+ const updateHeadings = useCallback(() => {
+ const headingNodes = wrapper?.querySelectorAll<HTMLHeadingElement>(query);
+
+ if (headingNodes) setHeadings(headingNodes);
+ }, [query, wrapper]);
+
+ useEffect(() => {
+ if (wrapper) updateHeadings();
+ }, [updateHeadings, wrapper]);
+
+ useMutationObserver({
+ callback: useCallback(
+ (records) => {
+ for (const record of records) {
+ if (record.target === wrapper) updateHeadings();
+ }
+ },
+ [updateHeadings, wrapper]
+ ),
+ options: { childList: true, subtree: true },
+ ref: { current: typeof window === 'undefined' ? null : document.body },
+ });
+
+ return { ref, tree: headings ? buildHeadingsTreeFrom(headings) : [] };
};
diff --git a/src/utils/hooks/use-mutation-observer/index.ts b/src/utils/hooks/use-mutation-observer/index.ts
new file mode 100644
index 0000000..16780fe
--- /dev/null
+++ b/src/utils/hooks/use-mutation-observer/index.ts
@@ -0,0 +1 @@
+export * from './use-mutation-observer';
diff --git a/src/utils/hooks/use-mutation-observer/use-mutation-observer.test.ts b/src/utils/hooks/use-mutation-observer/use-mutation-observer.test.ts
new file mode 100644
index 0000000..62ed559
--- /dev/null
+++ b/src/utils/hooks/use-mutation-observer/use-mutation-observer.test.ts
@@ -0,0 +1,42 @@
+import { beforeEach, describe, expect, it, jest } from '@jest/globals';
+import { renderHook } from '@testing-library/react';
+import { useMutationObserver } from './use-mutation-observer';
+
+describe('useMutationObserver', () => {
+ beforeEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('can create a new observer', () => {
+ const callback = jest.fn();
+ const observerSpy = jest.spyOn(MutationObserver.prototype, 'observe');
+ const wrapper = document.createElement('div');
+ const options: MutationObserverInit = { childList: true };
+
+ renderHook(() =>
+ useMutationObserver({
+ callback,
+ options,
+ ref: { current: wrapper },
+ })
+ );
+
+ expect(observerSpy).toHaveBeenCalledTimes(1);
+ expect(observerSpy).toHaveBeenCalledWith(wrapper, options);
+ });
+
+ it('does not create a new observer when ref is null', () => {
+ const callback = jest.fn();
+ const observerSpy = jest.spyOn(MutationObserver.prototype, 'observe');
+
+ renderHook(() =>
+ useMutationObserver({
+ callback,
+ options: { childList: true },
+ ref: { current: null },
+ })
+ );
+
+ expect(observerSpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/utils/hooks/use-mutation-observer/use-mutation-observer.ts b/src/utils/hooks/use-mutation-observer/use-mutation-observer.ts
new file mode 100644
index 0000000..6043055
--- /dev/null
+++ b/src/utils/hooks/use-mutation-observer/use-mutation-observer.ts
@@ -0,0 +1,35 @@
+import { type RefObject, useEffect } from 'react';
+import type { Nullable } from '../../../types';
+
+type UseMutationObserverProps<T extends Nullable<HTMLElement>> = {
+ /**
+ * A callback to execute when mutations are observed.
+ */
+ callback: MutationCallback;
+ /**
+ * The options passed to mutation observer.
+ */
+ options: MutationObserverInit;
+ /**
+ * A reference to the DOM node to observe.
+ */
+ ref: RefObject<T>;
+};
+
+export const useMutationObserver = <T extends Nullable<HTMLElement>>({
+ callback,
+ options,
+ ref,
+}: UseMutationObserverProps<T>) => {
+ useEffect(() => {
+ if (!ref.current) return undefined;
+
+ const observer = new MutationObserver(callback);
+
+ observer.observe(ref.current, options);
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [callback, options, ref]);
+};
diff --git a/tests/cypress/e2e/pages/project.cy.ts b/tests/cypress/e2e/pages/project.cy.ts
new file mode 100644
index 0000000..0fce7fd
--- /dev/null
+++ b/tests/cypress/e2e/pages/project.cy.ts
@@ -0,0 +1,33 @@
+import { ROUTES } from '../../../../src/utils/constants';
+
+describe('Project Pages', () => {
+ beforeEach(() => {
+ cy.visit(`${ROUTES.PROJECTS}/coldark`);
+ });
+
+ it('successfully loads', () => {
+ cy.findByRole('heading', { level: 1 }).should('exist');
+ });
+
+ it('contains a breadcrumbs', () => {
+ cy.findByRole('navigation', { name: 'Fil d’Ariane' }).should('exist');
+ });
+
+ it('contains the project meta', () => {
+ cy.findAllByRole('term').should('have.length.at.least', 1);
+
+ /* The accessible name is not recognized while it should be the `dt` text
+ * content */
+ // cy.findByRole('term', { name: 'Publié le :' }).should('exist');
+ });
+
+ it('contains a table of contents', () => {
+ cy.findByRole('heading', { level: 2, name: 'Table des matières' }).should(
+ 'exist'
+ );
+ });
+
+ it('contains a sharing widget', () => {
+ cy.findByRole('heading', { level: 2, name: 'Partager' }).should('exist');
+ });
+});