From a07729064790df13324dbe7f4d1629892070558b Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Fri, 15 Apr 2022 17:13:21 +0200
Subject: chore: add a SharingWidget component

---
 src/components/organisms/widgets/sharing.test.tsx | 31 +++++++++++++++++++++++
 1 file changed, 31 insertions(+)
 create mode 100644 src/components/organisms/widgets/sharing.test.tsx

(limited to 'src/components/organisms/widgets/sharing.test.tsx')

diff --git a/src/components/organisms/widgets/sharing.test.tsx b/src/components/organisms/widgets/sharing.test.tsx
new file mode 100644
index 0000000..265dbe1
--- /dev/null
+++ b/src/components/organisms/widgets/sharing.test.tsx
@@ -0,0 +1,31 @@
+import { render, screen } from '@test-utils';
+import Sharing, { type SharingData } from './sharing';
+
+const postData: SharingData = {
+  excerpt: 'A post excerpt',
+  title: 'A post title',
+  url: 'https://sharing-website.test',
+};
+
+describe('Sharing', () => {
+  it('renders a sharing widget', () => {
+    render(
+      <Sharing
+        data={postData}
+        media={['facebook', 'twitter']}
+        expanded={true}
+        title="Sharing"
+        level={2}
+      />
+    );
+    expect(
+      screen.getByRole('link', { name: 'Share on facebook' })
+    ).toBeInTheDocument();
+    expect(
+      screen.getByRole('link', { name: 'Share on twitter' })
+    ).toBeInTheDocument();
+    expect(
+      screen.queryByRole('link', { name: 'Share on linkedin' })
+    ).not.toBeInTheDocument();
+  });
+});
-- 
cgit v1.2.3


From 9c8921db92d16b07ffc2a63ff3c80c4dcdd9ff9d Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Tue, 10 May 2022 17:38:07 +0200
Subject: chore: add Project single pages

---
 .../organisms/widgets/links-list-widget.tsx        |   2 +-
 .../organisms/widgets/sharing.stories.tsx          |  25 +--
 src/components/organisms/widgets/sharing.test.tsx  |  10 +-
 src/components/organisms/widgets/sharing.tsx       |  25 ++-
 .../templates/page/page-layout.module.scss         |   5 +
 src/pages/projets.tsx                              | 150 -------------
 src/pages/projets/[slug].tsx                       | 249 +++++++++++++++++++++
 src/pages/projets/index.tsx                        | 143 ++++++++++++
 src/ts/types/app.ts                                |  17 +-
 src/ts/types/swr.ts                                |   5 +
 src/utils/helpers/projects.ts                      |  80 ++++---
 src/utils/helpers/slugify.ts                       |  18 --
 src/utils/helpers/strings.ts                       |  29 +++
 src/utils/hooks/use-github-api.tsx                 |  30 +++
 src/utils/hooks/use-headings-tree.tsx              |   2 +-
 15 files changed, 553 insertions(+), 237 deletions(-)
 delete mode 100644 src/pages/projets.tsx
 create mode 100644 src/pages/projets/[slug].tsx
 create mode 100644 src/pages/projets/index.tsx
 create mode 100644 src/ts/types/swr.ts
 delete mode 100644 src/utils/helpers/slugify.ts
 create mode 100644 src/utils/helpers/strings.ts
 create mode 100644 src/utils/hooks/use-github-api.tsx

(limited to 'src/components/organisms/widgets/sharing.test.tsx')

diff --git a/src/components/organisms/widgets/links-list-widget.tsx b/src/components/organisms/widgets/links-list-widget.tsx
index 37a20fc..3f291e3 100644
--- a/src/components/organisms/widgets/links-list-widget.tsx
+++ b/src/components/organisms/widgets/links-list-widget.tsx
@@ -4,7 +4,7 @@ import List, {
   type ListItem,
 } from '@components/atoms/lists/list';
 import Widget, { type WidgetProps } from '@components/molecules/layout/widget';
-import { slugify } from '@utils/helpers/slugify';
+import { slugify } from '@utils/helpers/strings';
 import { FC } from 'react';
 import styles from './links-list-widget.module.scss';
 
diff --git a/src/components/organisms/widgets/sharing.stories.tsx b/src/components/organisms/widgets/sharing.stories.tsx
index c3c3488..47213b6 100644
--- a/src/components/organisms/widgets/sharing.stories.tsx
+++ b/src/components/organisms/widgets/sharing.stories.tsx
@@ -22,9 +22,13 @@ export default {
         type: null,
       },
       description: 'Default widget state (expanded or collapsed).',
+      table: {
+        category: 'Options',
+        defaultValue: { summary: true },
+      },
       type: {
         name: 'boolean',
-        required: true,
+        required: false,
       },
     },
     level: {
@@ -34,9 +38,13 @@ export default {
         max: 6,
       },
       description: 'The heading level.',
+      table: {
+        category: 'Options',
+        defaultValue: { summary: 2 },
+      },
       type: {
         name: 'number',
-        required: true,
+        required: false,
       },
     },
     media: {
@@ -49,16 +57,6 @@ export default {
         required: true,
       },
     },
-    title: {
-      control: {
-        type: 'text',
-      },
-      description: 'The widget title.',
-      type: {
-        name: 'string',
-        required: true,
-      },
-    },
   },
   decorators: [
     (Story) => (
@@ -78,14 +76,11 @@ const Template: ComponentStory<typeof SharingWidget> = (args) => (
  */
 export const Sharing = Template.bind({});
 Sharing.args = {
-  expanded: true,
   data: {
     excerpt:
       'Alias similique eius ducimus laudantium aspernatur. Est rem ut eum temporibus sit reprehenderit aut non molestias. Vel dolorem expedita labore quo inventore aliquid nihil nam. Possimus nobis enim quas corporis eos.',
     title: 'Accusantium totam nostrum',
     url: 'https://www.example.test',
   },
-  level: 2,
   media: ['diaspora', 'facebook', 'linkedin', 'twitter', 'email'],
-  title: 'Sharing',
 };
diff --git a/src/components/organisms/widgets/sharing.test.tsx b/src/components/organisms/widgets/sharing.test.tsx
index 265dbe1..48da49e 100644
--- a/src/components/organisms/widgets/sharing.test.tsx
+++ b/src/components/organisms/widgets/sharing.test.tsx
@@ -9,15 +9,7 @@ const postData: SharingData = {
 
 describe('Sharing', () => {
   it('renders a sharing widget', () => {
-    render(
-      <Sharing
-        data={postData}
-        media={['facebook', 'twitter']}
-        expanded={true}
-        title="Sharing"
-        level={2}
-      />
-    );
+    render(<Sharing data={postData} media={['facebook', 'twitter']} />);
     expect(
       screen.getByRole('link', { name: 'Share on facebook' })
     ).toBeInTheDocument();
diff --git a/src/components/organisms/widgets/sharing.tsx b/src/components/organisms/widgets/sharing.tsx
index 05a3f73..85dadb0 100644
--- a/src/components/organisms/widgets/sharing.tsx
+++ b/src/components/organisms/widgets/sharing.tsx
@@ -21,11 +21,19 @@ export type SharingData = {
   url: string;
 };
 
-export type SharingProps = Pick<WidgetProps, 'expanded' | 'level' | 'title'> & {
+export type SharingProps = {
   /**
    * The page data to share.
    */
   data: SharingData;
+  /**
+   * The widget default state.
+   */
+  expanded?: WidgetProps['expanded'];
+  /**
+   * The HTML heading level.
+   */
+  level?: WidgetProps['level'];
   /**
    * A list of active and ordered sharing medium.
    */
@@ -37,8 +45,19 @@ export type SharingProps = Pick<WidgetProps, 'expanded' | 'level' | 'title'> & {
  *
  * Render a list of sharing links inside a widget.
  */
-const Sharing: FC<SharingProps> = ({ data, media, ...props }) => {
+const Sharing: FC<SharingProps> = ({
+  data,
+  media,
+  expanded = true,
+  level = 2,
+  ...props
+}) => {
   const intl = useIntl();
+  const widgetTitle = intl.formatMessage({
+    defaultMessage: 'Share',
+    id: 'q3U6uI',
+    description: 'Sharing: widget title',
+  });
 
   /**
    * Build the Diaspora sharing url with provided data.
@@ -181,7 +200,7 @@ const Sharing: FC<SharingProps> = ({ data, media, ...props }) => {
   };
 
   return (
-    <Widget {...props}>
+    <Widget expanded={expanded} level={level} title={widgetTitle} {...props}>
       <ul className={styles.list}>{getItems()}</ul>
     </Widget>
   );
diff --git a/src/components/templates/page/page-layout.module.scss b/src/components/templates/page/page-layout.module.scss
index d5a1a2b..7602492 100644
--- a/src/components/templates/page/page-layout.module.scss
+++ b/src/components/templates/page/page-layout.module.scss
@@ -35,6 +35,11 @@
 
 .body {
   grid-column: 2;
+
+  > * + * {
+    margin-top: var(--spacing-sm);
+    margin-bottom: var(--spacing-sm);
+  }
 }
 
 .sidebar {
diff --git a/src/pages/projets.tsx b/src/pages/projets.tsx
deleted file mode 100644
index 996af74..0000000
--- a/src/pages/projets.tsx
+++ /dev/null
@@ -1,150 +0,0 @@
-import Link from '@components/atoms/links/link';
-import { BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
-import CardsList, {
-  CardsListItem,
-} from '@components/organisms/layout/cards-list';
-import PageLayout from '@components/templates/page/page-layout';
-import PageContent, { meta } from '@content/pages/projects.mdx';
-import styles from '@styles/pages/projects.module.scss';
-import { ProjectCard } from '@ts/types/app';
-import { loadTranslation, Messages } from '@utils/helpers/i18n';
-import { getProjectsCard } from '@utils/helpers/projects';
-import useSettings from '@utils/hooks/use-settings';
-import { NestedMDXComponents } from 'mdx/types';
-import { GetStaticProps, NextPage } from 'next';
-import Head from 'next/head';
-import { useRouter } from 'next/router';
-import Script from 'next/script';
-import { useIntl } from 'react-intl';
-import { Article, Graph, WebPage } from 'schema-dts';
-
-type ProjectsPageProps = {
-  projects: ProjectCard[];
-  translation?: Messages;
-};
-
-/**
- * Projects page.
- */
-const ProjectsPage: NextPage<ProjectsPageProps> = ({ projects }) => {
-  const intl = useIntl();
-  const { dates, seo, title } = meta;
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
-  });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'projects', name: title, url: '/projets' },
-  ];
-
-  const items: CardsListItem[] = projects.map(
-    ({ id, meta: projectMeta, slug, title: projectTitle }) => {
-      const { cover, tagline, ...remainingMeta } = projectMeta;
-      const formattedMeta: CardsListItem['meta'] = remainingMeta.technologies
-        ? [
-            {
-              id: 'technologies',
-              term: 'Technologies',
-              value: remainingMeta.technologies,
-            },
-          ]
-        : undefined;
-
-      return {
-        cover,
-        id: id as string,
-        meta: formattedMeta,
-        tagline,
-        title: projectTitle,
-        url: `/projets/${slug}`,
-      };
-    }
-  );
-
-  const components: NestedMDXComponents = {
-    Links: (props) => <Link {...props} />,
-  };
-
-  const { website } = useSettings();
-  const { asPath } = useRouter();
-  const pageUrl = `${website.url}${asPath}`;
-  const pagePublicationDate = new Date(dates.publication);
-  const pageUpdateDate = new Date(dates.update);
-
-  const webpageSchema: WebPage = {
-    '@id': `${pageUrl}`,
-    '@type': 'WebPage',
-    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
-    name: seo.title,
-    description: seo.description,
-    inLanguage: website.locales.default,
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    reviewedBy: { '@id': `${website.url}/#branding` },
-    url: `${pageUrl}`,
-    isPartOf: {
-      '@id': `${website.url}`,
-    },
-  };
-
-  const articleSchema: Article = {
-    '@id': `${website.url}/#projects`,
-    '@type': 'Article',
-    name: meta.title,
-    description: seo.description,
-    author: { '@id': `${website.url}/#branding` },
-    copyrightYear: pagePublicationDate.getFullYear(),
-    creator: { '@id': `${website.url}/#branding` },
-    dateCreated: pagePublicationDate.toISOString(),
-    dateModified: pageUpdateDate.toISOString(),
-    datePublished: pagePublicationDate.toISOString(),
-    editor: { '@id': `${website.url}/#branding` },
-    headline: meta.title,
-    inLanguage: website.locales.default,
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    mainEntityOfPage: { '@id': `${pageUrl}` },
-  };
-
-  const schemaJsonLd: Graph = {
-    '@context': 'https://schema.org',
-    '@graph': [webpageSchema, articleSchema],
-  };
-
-  return (
-    <PageLayout
-      title={title}
-      intro={<PageContent components={components} />}
-      breadcrumb={breadcrumb}
-    >
-      <Head>
-        <title>{seo.title}</title>
-        <meta name="description" content={seo.description} />
-        <meta property="og:url" content={`${pageUrl}`} />
-        <meta property="og:type" content="article" />
-        <meta property="og:title" content={`${seo.title} - ${website.name}`} />
-        <meta property="og:description" content={seo.description} />
-      </Head>
-      <Script
-        id="schema-projects"
-        type="application/ld+json"
-        dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
-      />
-      <CardsList items={items} titleLevel={2} className={styles.list} />
-    </PageLayout>
-  );
-};
-
-export const getStaticProps: GetStaticProps = async ({ locale }) => {
-  const projects = await getProjectsCard();
-  const translation = await loadTranslation(locale);
-
-  return {
-    props: {
-      projects,
-      translation,
-    },
-  };
-};
-
-export default ProjectsPage;
diff --git a/src/pages/projets/[slug].tsx b/src/pages/projets/[slug].tsx
new file mode 100644
index 0000000..711a5cd
--- /dev/null
+++ b/src/pages/projets/[slug].tsx
@@ -0,0 +1,249 @@
+import Link from '@components/atoms/links/link';
+import SocialLink, { SocialWebsite } from '@components/atoms/links/social-link';
+import Spinner from '@components/atoms/loaders/spinner';
+import ResponsiveImage from '@components/molecules/images/responsive-image';
+import Code from '@components/molecules/layout/code';
+import { BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
+import Gallery from '@components/organisms/images/gallery';
+import Overview, { OverviewMeta } from '@components/organisms/layout/overview';
+import Sharing from '@components/organisms/widgets/sharing';
+import PageLayout, {
+  PageLayoutProps,
+} from '@components/templates/page/page-layout';
+import { ProjectPreview, Repos } from '@ts/types/app';
+import { loadTranslation, Messages } from '@utils/helpers/i18n';
+import { getProjectData, getProjectFilenames } from '@utils/helpers/projects';
+import { capitalize } from '@utils/helpers/strings';
+import useGithubApi, { RepoData } from '@utils/hooks/use-github-api';
+import useSettings from '@utils/hooks/use-settings';
+import { MDXComponents, NestedMDXComponents } from 'mdx/types';
+import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
+import Head from 'next/head';
+import { useRouter } from 'next/router';
+import Script from 'next/script';
+import { ComponentType } from 'react';
+import { useIntl } from 'react-intl';
+import { Article, Graph, WebPage } from 'schema-dts';
+
+type ProjectPageProps = {
+  project: ProjectPreview;
+  translation: Messages;
+};
+
+/**
+ * Project page.
+ */
+const ProjectPage: NextPage<ProjectPageProps> = ({ project }) => {
+  const { id, intro, meta, title } = project;
+  const { cover, dates, license, repos, seo, technologies } = meta;
+  const intl = useIntl();
+  const homeLabel = intl.formatMessage({
+    defaultMessage: 'Home',
+    description: 'Breadcrumb: home label',
+    id: 'j5k9Fe',
+  });
+  const projectsLabel = intl.formatMessage({
+    defaultMessage: 'Projects',
+    description: 'Breadcrumb: projects label',
+    id: '28GZdv',
+  });
+  const breadcrumb: BreadcrumbItem[] = [
+    { id: 'home', name: homeLabel, url: '/' },
+    { id: 'projects', name: projectsLabel, url: '/projets' },
+    { id: 'project', name: title, url: `/projets/${id}` },
+  ];
+
+  const ProjectContent: ComponentType<MDXComponents> =
+    require(`../../content/projects/${id}.mdx`).default;
+
+  const components: NestedMDXComponents = {
+    Code: (props) => <Code {...props} />,
+    Gallery: (props) => <Gallery {...props} />,
+    Image: (props) => <ResponsiveImage {...props} />,
+    Link: (props) => <Link {...props} />,
+    pre: ({ children }) => <Code {...children.props} />,
+  };
+
+  const { website } = useSettings();
+  const { asPath } = useRouter();
+  const pageUrl = `${website.url}${asPath}`;
+  const pagePublicationDate = new Date(dates.publication);
+  const pageUpdateDate = dates.update ? new Date(dates.update) : undefined;
+
+  const headerMeta: PageLayoutProps['headerMeta'] = {
+    publication: { date: dates.publication },
+    update: dates.update ? { date: dates.update } : undefined,
+  };
+
+  /**
+   * Retrieve the repositories links.
+   *
+   * @param {Repos} repos - A repositories object.
+   * @returns {JSX.Element[]} - An array of SocialLink.
+   */
+  const getReposLinks = (repositories: Repos): JSX.Element[] => {
+    const links = [];
+
+    for (const [name, url] of Object.entries(repositories)) {
+      const socialWebsite = capitalize(name) as SocialWebsite;
+      const socialUrl = `https://${name}.com/${url}`;
+
+      links.push(<SocialLink name={socialWebsite} url={socialUrl} />);
+    }
+
+    return links;
+  };
+
+  const { isError, isLoading, data } = useGithubApi(meta.repos!.github!);
+
+  const getGithubData = (key: keyof RepoData) => {
+    if (isError) return 'Error';
+    if (isLoading || !data) return <Spinner />;
+
+    switch (key) {
+      case 'created_at':
+        return data.created_at;
+      case 'updated_at':
+        return data.updated_at;
+      case 'stargazers_count':
+        const stars = intl.formatMessage(
+          {
+            defaultMessage:
+              '{starsCount, plural, =0 {No stars on Github} one {# star on Github} other {# stars on Github}}',
+            id: 'Gnf1Si',
+            description: 'Projets: Github stars count',
+          },
+          { starsCount: data.stargazers_count }
+        );
+        return (
+          <>
+            ⭐&nbsp;
+            <Link href={`https://github.com/${repos!.github}/stargazers`}>
+              {stars}
+            </Link>
+          </>
+        );
+    }
+  };
+
+  const overviewData: OverviewMeta = {
+    creation: data && { date: getGithubData('created_at') as string },
+    update: data && { date: getGithubData('updated_at') as string },
+    license,
+    popularity: data && getGithubData('stargazers_count'),
+    repositories: repos ? getReposLinks(repos) : undefined,
+    technologies,
+  };
+
+  const webpageSchema: WebPage = {
+    '@id': `${pageUrl}`,
+    '@type': 'WebPage',
+    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
+    name: seo.title,
+    description: seo.description,
+    inLanguage: website.locales.default,
+    reviewedBy: { '@id': `${website.url}/#branding` },
+    url: `${website.url}`,
+    isPartOf: {
+      '@id': `${website.url}`,
+    },
+  };
+
+  const articleSchema: Article = {
+    '@id': `${website.url}/project`,
+    '@type': 'Article',
+    name: title,
+    description: intro,
+    author: { '@id': `${website.url}/#branding` },
+    copyrightYear: pagePublicationDate.getFullYear(),
+    creator: { '@id': `${website.url}/#branding` },
+    dateCreated: pagePublicationDate.toISOString(),
+    dateModified: pageUpdateDate && pageUpdateDate.toISOString(),
+    datePublished: pagePublicationDate.toISOString(),
+    editor: { '@id': `${website.url}/#branding` },
+    headline: title,
+    thumbnailUrl: `/projects/${id}.jpg`,
+    image: `/projects/${id}.jpg`,
+    inLanguage: website.locales.default,
+    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+    mainEntityOfPage: { '@id': `${pageUrl}` },
+  };
+
+  const schemaJsonLd: Graph = {
+    '@context': 'https://schema.org',
+    '@graph': [webpageSchema, articleSchema],
+  };
+
+  return (
+    <>
+      <Head>
+        <title>{`${seo.title} - ${website.name}`}</title>
+        <meta name="description" content={seo.description} />
+        <meta property="og:url" content={`${pageUrl}`} />
+        <meta property="og:type" content="article" />
+        <meta property="og:title" content={title} />
+        <meta property="og:description" content={intro} />
+      </Head>
+      <Script
+        id="schema-project"
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
+      />
+      <PageLayout
+        title={title}
+        intro={intro}
+        breadcrumb={breadcrumb}
+        headerMeta={headerMeta}
+        withToC={true}
+        widgets={[
+          <Sharing
+            key="sharing-widget"
+            data={{ excerpt: intro, title, url: pageUrl }}
+            media={[
+              'diaspora',
+              'email',
+              'facebook',
+              'journal-du-hacker',
+              'linkedin',
+              'twitter',
+            ]}
+          />,
+        ]}
+      >
+        <Overview cover={cover} meta={overviewData} />
+        <ProjectContent components={components} />
+      </PageLayout>
+    </>
+  );
+};
+
+export const getStaticProps: GetStaticProps = async ({ locale, params }) => {
+  const translation = await loadTranslation(locale);
+  const { slug } = params!;
+  const project = await getProjectData(slug as string);
+
+  return {
+    props: {
+      project,
+      translation,
+    },
+  };
+};
+
+export const getStaticPaths: GetStaticPaths = async () => {
+  const filenames = getProjectFilenames();
+  const paths = filenames.map((filename) => {
+    return {
+      params: {
+        slug: filename,
+      },
+    };
+  });
+
+  return {
+    paths,
+    fallback: false,
+  };
+};
+
+export default ProjectPage;
diff --git a/src/pages/projets/index.tsx b/src/pages/projets/index.tsx
new file mode 100644
index 0000000..a6858c8
--- /dev/null
+++ b/src/pages/projets/index.tsx
@@ -0,0 +1,143 @@
+import Link from '@components/atoms/links/link';
+import { BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
+import CardsList, {
+  CardsListItem,
+} from '@components/organisms/layout/cards-list';
+import PageLayout from '@components/templates/page/page-layout';
+import PageContent, { meta } from '@content/pages/projects.mdx';
+import styles from '@styles/pages/projects.module.scss';
+import { ProjectCard } from '@ts/types/app';
+import { loadTranslation, Messages } from '@utils/helpers/i18n';
+import { getProjectsCard } from '@utils/helpers/projects';
+import useSettings from '@utils/hooks/use-settings';
+import { NestedMDXComponents } from 'mdx/types';
+import { GetStaticProps, NextPage } from 'next';
+import Head from 'next/head';
+import { useRouter } from 'next/router';
+import Script from 'next/script';
+import { useIntl } from 'react-intl';
+import { Article, Graph, WebPage } from 'schema-dts';
+
+type ProjectsPageProps = {
+  projects: ProjectCard[];
+  translation?: Messages;
+};
+
+/**
+ * Projects page.
+ */
+const ProjectsPage: NextPage<ProjectsPageProps> = ({ projects }) => {
+  const intl = useIntl();
+  const { dates, seo, title } = meta;
+  const homeLabel = intl.formatMessage({
+    defaultMessage: 'Home',
+    description: 'Breadcrumb: home label',
+    id: 'j5k9Fe',
+  });
+  const breadcrumb: BreadcrumbItem[] = [
+    { id: 'home', name: homeLabel, url: '/' },
+    { id: 'projects', name: title, url: '/projets' },
+  ];
+
+  const items: CardsListItem[] = projects.map(
+    ({ id, meta: projectMeta, slug, title: projectTitle }) => {
+      const { cover, tagline, technologies } = projectMeta;
+
+      return {
+        cover,
+        id: id as string,
+        meta: { technologies: technologies },
+        tagline,
+        title: projectTitle,
+        url: `/projets/${slug}`,
+      };
+    }
+  );
+
+  const components: NestedMDXComponents = {
+    Links: (props) => <Link {...props} />,
+  };
+
+  const { website } = useSettings();
+  const { asPath } = useRouter();
+  const pageUrl = `${website.url}${asPath}`;
+  const pagePublicationDate = new Date(dates.publication);
+  const pageUpdateDate = dates.update ? new Date(dates.update) : undefined;
+
+  const webpageSchema: WebPage = {
+    '@id': `${pageUrl}`,
+    '@type': 'WebPage',
+    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
+    name: seo.title,
+    description: seo.description,
+    inLanguage: website.locales.default,
+    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+    reviewedBy: { '@id': `${website.url}/#branding` },
+    url: `${pageUrl}`,
+    isPartOf: {
+      '@id': `${website.url}`,
+    },
+  };
+
+  const articleSchema: Article = {
+    '@id': `${website.url}/#projects`,
+    '@type': 'Article',
+    name: meta.title,
+    description: seo.description,
+    author: { '@id': `${website.url}/#branding` },
+    copyrightYear: pagePublicationDate.getFullYear(),
+    creator: { '@id': `${website.url}/#branding` },
+    dateCreated: pagePublicationDate.toISOString(),
+    dateModified: pageUpdateDate && pageUpdateDate.toISOString(),
+    datePublished: pagePublicationDate.toISOString(),
+    editor: { '@id': `${website.url}/#branding` },
+    headline: meta.title,
+    inLanguage: website.locales.default,
+    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+    mainEntityOfPage: { '@id': `${pageUrl}` },
+  };
+
+  const schemaJsonLd: Graph = {
+    '@context': 'https://schema.org',
+    '@graph': [webpageSchema, articleSchema],
+  };
+
+  return (
+    <>
+      <Head>
+        <title>{`${seo.title} - ${website.name}`}</title>
+        <meta name="description" content={seo.description} />
+        <meta property="og:url" content={`${pageUrl}`} />
+        <meta property="og:type" content="article" />
+        <meta property="og:title" content={`${seo.title} - ${website.name}`} />
+        <meta property="og:description" content={seo.description} />
+      </Head>
+      <Script
+        id="schema-projects"
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
+      />
+      <PageLayout
+        title={title}
+        intro={<PageContent components={components} />}
+        breadcrumb={breadcrumb}
+      >
+        <CardsList items={items} titleLevel={2} className={styles.list} />
+      </PageLayout>
+    </>
+  );
+};
+
+export const getStaticProps: GetStaticProps = async ({ locale }) => {
+  const projects = await getProjectsCard();
+  const translation = await loadTranslation(locale);
+
+  return {
+    props: {
+      projects: JSON.parse(JSON.stringify(projects)),
+      translation,
+    },
+  };
+};
+
+export default ProjectsPage;
diff --git a/src/ts/types/app.ts b/src/ts/types/app.ts
index 87ab042..f354118 100644
--- a/src/ts/types/app.ts
+++ b/src/ts/types/app.ts
@@ -29,7 +29,7 @@ export type Comment = {
 
 export type Dates = {
   publication: string;
-  update: string;
+  update?: string;
 };
 
 export type Image = {
@@ -58,23 +58,23 @@ export type Meta<T extends PageKind> = {
   commentsCount?: T extends 'article' ? number : never;
   cover?: Image;
   dates: Dates;
-  license?: T extends 'projects' ? string : never;
-  readingTime: number;
-  repos?: T extends 'projects' ? Repos : never;
+  license?: T extends 'project' ? string : never;
+  readingTime?: number;
+  repos?: T extends 'project' ? Repos : never;
   seo: SEO;
-  tagline?: T extends 'projects' ? string : never;
-  technologies?: T extends 'projects' ? string[] : never;
+  tagline?: T extends 'project' ? string : never;
+  technologies?: T extends 'project' ? string[] : never;
   thematics?: T extends 'article' | 'topic' ? PageLink[] : never;
   topics?: T extends 'article' | 'thematic' ? PageLink[] : never;
   website?: T extends 'topic' ? string : never;
-  wordsCount: number;
+  wordsCount?: number;
 };
 
 export type Page<T extends PageKind> = {
   content: string;
   id: number | string;
   intro: string;
-  meta?: Meta<T>;
+  meta: Meta<T>;
   slug: string;
   title: string;
 };
@@ -89,6 +89,7 @@ export type Article = Page<'article'>;
 export type ArticleCard = Pick<Article, 'id' | 'slug' | 'title'> &
   Pick<Meta<'article'>, 'cover' | 'dates'>;
 export type Project = Page<'project'>;
+export type ProjectPreview = Omit<Page<'project'>, 'content'>;
 export type ProjectCard = Pick<Page<'project'>, 'id' | 'slug' | 'title'> & {
   meta: Pick<Meta<'project'>, 'cover' | 'dates' | 'tagline' | 'technologies'>;
 };
diff --git a/src/ts/types/swr.ts b/src/ts/types/swr.ts
new file mode 100644
index 0000000..4da6b2c
--- /dev/null
+++ b/src/ts/types/swr.ts
@@ -0,0 +1,5 @@
+export type SWRResult<T> = {
+  data?: T;
+  isLoading: boolean;
+  isError: boolean;
+};
diff --git a/src/utils/helpers/projects.ts b/src/utils/helpers/projects.ts
index 02e0e02..a0f0c04 100644
--- a/src/utils/helpers/projects.ts
+++ b/src/utils/helpers/projects.ts
@@ -1,4 +1,4 @@
-import { ProjectCard } from '@ts/types/app';
+import { ProjectCard, ProjectPreview } from '@ts/types/app';
 import { MDXProjectMeta } from '@ts/types/mdx';
 import { readdirSync } from 'fs';
 import path from 'path';
@@ -16,45 +16,61 @@ export const getProjectFilenames = (): string[] => {
 };
 
 /**
- * Retrieve project's data by filename.
+ * Retrieve the data of a project by filename.
+ *
+ * @param {string} filename - The project filename.
+ * @returns {Promise<ProjectPreview>}
+ */
+export const getProjectData = async (
+  filename: string
+): Promise<ProjectPreview> => {
+  try {
+    const {
+      meta,
+    }: {
+      meta: MDXProjectMeta;
+    } = await import(`../../content/projects/${filename}.mdx`);
+
+    const { dates, intro, title, ...projectMeta } = meta;
+    const { publication, update } = dates;
+    const cover = await import(`../../../public/projects/${filename}.jpg`);
+
+    return {
+      id: filename,
+      intro,
+      meta: {
+        ...projectMeta,
+        dates: { publication, update },
+        // Dynamic import source does not work so I use it only to get sizes
+        cover: {
+          ...cover.default,
+          alt: `${title} image`,
+          src: `/projects/${filename}.jpg`,
+        },
+      },
+      slug: filename,
+      title: title,
+    };
+  } catch (err) {
+    console.error(err);
+    throw err;
+  }
+};
+
+/**
+ * Retrieve all the projects data using filenames.
  *
  * @param {string[]} filenames - The filenames without extension.
- * @returns {Promise<ProjectCard[]>} - The project data.
+ * @returns {Promise<ProjectCard[]>} - An array of projects data.
  */
 export const getProjectsData = async (
   filenames: string[]
 ): Promise<ProjectCard[]> => {
   return Promise.all(
     filenames.map(async (filename) => {
-      try {
-        const {
-          meta,
-        }: {
-          meta: MDXProjectMeta;
-        } = await import(`../../content/projects/${filename}.mdx`);
-
-        const { intro: _intro, title, ...projectMeta } = meta;
-
-        const cover = await import(`../../../public/projects/${filename}.jpg`);
-
-        return {
-          id: filename,
-          meta: {
-            ...projectMeta,
-            // Dynamic import source does not work so I use it only to get sizes
-            cover: {
-              ...cover.default,
-              alt: `${title} image`,
-              src: `/projects/${filename}.jpg`,
-            },
-          },
-          slug: filename,
-          title: title,
-        };
-      } catch (err) {
-        console.error(err);
-        throw err;
-      }
+      const { id, meta, slug, title } = await getProjectData(filename);
+      const { cover, dates, tagline, technologies } = meta;
+      return { id, meta: { cover, dates, tagline, technologies }, slug, title };
     })
   );
 };
diff --git a/src/utils/helpers/slugify.ts b/src/utils/helpers/slugify.ts
deleted file mode 100644
index 55ff583..0000000
--- a/src/utils/helpers/slugify.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-/**
- * Convert a text into a slug or id.
- * https://gist.github.com/codeguy/6684588#gistcomment-3332719
- *
- * @param {string} text Text to slugify.
- */
-export const slugify = (text: string) => {
-  return text
-    .toString()
-    .normalize('NFD')
-    .replace(/[\u0300-\u036f]/g, '')
-    .toLowerCase()
-    .trim()
-    .replace(/\s+/g, '-')
-    .replace(/[^\w\-]+/g, '-')
-    .replace(/\-\-+/g, '-')
-    .replace(/(^-)|(-$)/g, '');
-};
diff --git a/src/utils/helpers/strings.ts b/src/utils/helpers/strings.ts
new file mode 100644
index 0000000..5d90161
--- /dev/null
+++ b/src/utils/helpers/strings.ts
@@ -0,0 +1,29 @@
+/**
+ * Convert a text into a slug or id.
+ * https://gist.github.com/codeguy/6684588#gistcomment-3332719
+ *
+ * @param {string} text - A text to slugify.
+ * @returns {string} The slug.
+ */
+export const slugify = (text: string): string => {
+  return text
+    .toString()
+    .normalize('NFD')
+    .replace(/[\u0300-\u036f]/g, '')
+    .toLowerCase()
+    .trim()
+    .replace(/\s+/g, '-')
+    .replace(/[^\w\-]+/g, '-')
+    .replace(/\-\-+/g, '-')
+    .replace(/(^-)|(-$)/g, '');
+};
+
+/**
+ * Capitalize the first letter of a string.
+ *
+ * @param {string} text - A text to capitalize.
+ * @returns {string} The capitalized text.
+ */
+export const capitalize = (text: string): string => {
+  return text.replace(/^\w/, (firstLetter) => firstLetter.toUpperCase());
+};
diff --git a/src/utils/hooks/use-github-api.tsx b/src/utils/hooks/use-github-api.tsx
new file mode 100644
index 0000000..edff974
--- /dev/null
+++ b/src/utils/hooks/use-github-api.tsx
@@ -0,0 +1,30 @@
+import { SWRResult } from '@ts/types/swr';
+import useSWR, { Fetcher } from 'swr';
+
+export type RepoData = {
+  created_at: string;
+  updated_at: string;
+  stargazers_count: number;
+};
+
+const fetcher: Fetcher<RepoData, string> = (...args) =>
+  fetch(...args).then((res) => res.json());
+
+/**
+ * Retrieve data from Github API.
+ *
+ * @param repo - The Github repo (`owner/repo-name`).
+ * @returns The repository data.
+ */
+const useGithubApi = (repo: string): SWRResult<RepoData> => {
+  const apiUrl = repo ? `https://api.github.com/repos/${repo}` : null;
+  const { data, error } = useSWR<RepoData>(apiUrl, fetcher);
+
+  return {
+    data,
+    isLoading: !error && !data,
+    isError: error,
+  };
+};
+
+export default useGithubApi;
diff --git a/src/utils/hooks/use-headings-tree.tsx b/src/utils/hooks/use-headings-tree.tsx
index 5506e8b..4646b4a 100644
--- a/src/utils/hooks/use-headings-tree.tsx
+++ b/src/utils/hooks/use-headings-tree.tsx
@@ -1,4 +1,4 @@
-import { slugify } from '@utils/helpers/slugify';
+import { slugify } from '@utils/helpers/strings';
 import { useCallback, useEffect, useMemo, useState } from 'react';
 
 export type Heading = {
-- 
cgit v1.2.3

