From 8a6f09b564d5d2f02d0a2605f6b52070a910aaa3 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Mon, 25 Apr 2022 12:57:12 +0200
Subject: chore: add a PageLayout component

---
 .../templates/page/page-layout.stories.tsx         | 520 +++++++++++++++++++++
 1 file changed, 520 insertions(+)
 create mode 100644 src/components/templates/page/page-layout.stories.tsx

(limited to 'src/components/templates/page/page-layout.stories.tsx')

diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx
new file mode 100644
index 0000000..1f72cb0
--- /dev/null
+++ b/src/components/templates/page/page-layout.stories.tsx
@@ -0,0 +1,520 @@
+import ButtonLink from '@components/atoms/buttons/button-link';
+import Heading from '@components/atoms/headings/heading';
+import Link from '@components/atoms/links/link';
+import ProgressBar from '@components/atoms/loaders/progress-bar';
+import PostsList from '@components/organisms/layout/posts-list';
+import LinksListWidget from '@components/organisms/widgets/links-list-widget';
+import Sharing from '@components/organisms/widgets/sharing';
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import PageLayoutComponent from './page-layout';
+
+/**
+ * PageLayout - Storybook Meta
+ */
+export default {
+  title: 'Templates/Page',
+  component: PageLayoutComponent,
+  args: {
+    allowComments: false,
+  },
+  argTypes: {
+    allowComments: {
+      control: {
+        type: 'boolean',
+      },
+      description: 'Determine if the comment form is displayed.',
+      table: {
+        category: 'Options',
+        defaultValue: { summary: false },
+      },
+      type: {
+        name: 'boolean',
+        required: false,
+      },
+    },
+    breadcrumb: {
+      description: 'The breadcrumb items.',
+      type: {
+        name: 'object',
+        required: true,
+        value: {},
+      },
+    },
+    children: {
+      control: {
+        type: 'text',
+      },
+      description: 'The page content.',
+      type: {
+        name: 'string',
+        required: true,
+      },
+    },
+    comments: {
+      description: 'The page comments.',
+      table: {
+        category: 'Options',
+      },
+      type: {
+        name: 'object',
+        required: false,
+        value: {},
+      },
+    },
+    footerMeta: {
+      description: 'The metadata to display in the page footer.',
+      table: {
+        category: 'Options',
+      },
+      type: {
+        name: 'object',
+        required: false,
+        value: {},
+      },
+    },
+    headerMeta: {
+      description: 'The metadata to display in the page header.',
+      table: {
+        category: 'Options',
+      },
+      type: {
+        name: 'object',
+        required: false,
+        value: {},
+      },
+    },
+    id: {
+      control: {
+        type: 'text',
+      },
+      description: 'The page id.',
+      type: {
+        name: 'string',
+        required: true,
+      },
+    },
+    intro: {
+      control: {
+        type: 'text',
+      },
+      description: 'The page introduction.',
+      table: {
+        category: 'Options',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
+    isHome: {
+      control: {
+        type: 'boolean',
+      },
+      description: 'Determine if the current page is the homepage.',
+      table: {
+        category: 'Options',
+        defaultValue: { summary: false },
+      },
+      type: {
+        name: 'boolean',
+        required: false,
+      },
+    },
+    title: {
+      control: {
+        type: 'text',
+      },
+      description: 'The page title.',
+      type: {
+        name: 'string',
+        required: true,
+      },
+    },
+    widgets: {
+      control: {
+        type: null,
+      },
+      description: 'An array of widgets to put inside the last sidebar.',
+      type: {
+        name: 'object',
+        required: false,
+        value: {},
+      },
+    },
+    withToC: {
+      control: {
+        type: 'boolean',
+      },
+      description: 'Determine if the Table of Contents should be in the page.',
+      table: {
+        category: 'Options',
+        defaultValue: { summary: false },
+      },
+      type: {
+        name: 'boolean',
+        required: false,
+      },
+    },
+  },
+  decorators: [
+    (Story) => (
+      <IntlProvider locale="en">
+        <div id="__next">
+          <Story />
+        </div>
+      </IntlProvider>
+    ),
+  ],
+  parameters: {
+    layout: 'fullscreen',
+  },
+} as ComponentMeta<typeof PageLayoutComponent>;
+
+const Template: ComponentStory<typeof PageLayoutComponent> = (args) => (
+  <PageLayoutComponent {...args} />
+);
+
+const pageTitle = 'Incidunt ad earum';
+const pageIntro =
+  'Recusandae mollitia enim quo omnis rerum enim corporis ratione quidem. Pariatur omnis quas est ut ut numquam totam. Sunt sapiente nostrum aut sunt provident perspiciatis magni illum. Quidem nihil velit quasi fugit minima sint.';
+const pageBreadcrumb = [
+  { id: 'home', url: '#', name: 'Home' },
+  { id: 'page', url: '#', name: pageTitle },
+];
+
+/**
+ * Page Layout Stories - Single Page
+ */
+export const SinglePage = Template.bind({});
+SinglePage.args = {
+  breadcrumb: pageBreadcrumb,
+  title: pageTitle,
+  intro: pageIntro,
+  children: (
+    <>
+      <Heading level={2}>Impedit commodi rerum</Heading>
+      <p>
+        Omnis vel earum cupiditate delectus reprehenderit perferendis distinctio
+        omnis. Laudantium rem tempore eligendi porro officia est dolorum
+        assumenda. Corrupti tempore quia ab. Quidem est inventore. Autem
+        nesciunt sed rerum praesentium.
+      </p>
+      <p>
+        Illo nostrum inventore tenetur quo repellendus autem nisi nostrum
+        dolore. Et velit assumenda. Veniam harum officia et. Blanditiis et et
+        qui cum. Rerum illum quo doloribus neque non velit. Unde iusto et eaque
+        a ut.
+      </p>
+      <Heading level={2}>Et omnis ducimus</Heading>
+      <p>
+        Dolor quidem quas perferendis in nam molestiae. Accusamus quidem
+        accusantium quaerat est praesentium accusamus ab dolorem. Beatae illum
+        totam et corrupti assumenda corporis aut illo animi.
+      </p>
+      <p>
+        Ad rem soluta. Est tenetur consequatur sequi voluptates autem. Molestiae
+        in neque dignissimos. Dolorum numquam quos quam voluptas atque facilis
+        et. Accusantium fuga architecto excepturi consequatur libero est.
+      </p>
+    </>
+  ),
+  widgets: [
+    <Sharing
+      key="sidebar2-widget1"
+      data={{ excerpt: pageIntro, title: pageTitle, url: '#' }}
+      media={[
+        'diaspora',
+        'email',
+        'facebook',
+        'journal-du-hacker',
+        'linkedin',
+        'twitter',
+      ]}
+      title="Share"
+      level={2}
+      expanded={true}
+    />,
+  ],
+  withToC: true,
+};
+
+const postBreadcrumb = [
+  { id: 'home', url: '#', name: 'Home' },
+  { id: 'blog', url: '#', name: 'Blog' },
+  { id: 'post', url: '#', name: pageTitle },
+];
+
+const comments = [
+  {
+    author: {
+      avatar: 'http://placeimg.com/640/480',
+      name: 'Author 1',
+    },
+    content:
+      'Voluptas ducimus inventore. Libero ut et doloribus. Earum nostrum ab. Aliquam rem dolores omnis voluptate. Sunt aut ut et.',
+    id: 1,
+    publication: '2021-04-03 18:04:11',
+    // @ts-ignore - Needed because of the placeholder image.
+    unoptimized: true,
+  },
+  {
+    child: [
+      {
+        author: {
+          avatar: 'http://placeimg.com/640/480',
+          name: 'Author 4',
+        },
+        content:
+          'Vel ullam in porro tempore. Maiores quos quia magnam beatae nemo libero velit numquam. Sapiente aliquid cumque. Velit neque in adipisci aut assumenda voluptates earum. Autem esse autem provident in tempore. Aut distinctio dolor qui repellat et et adipisci velit aspernatur.',
+        id: 4,
+        publication: '2021-04-03 23:04:24',
+        // @ts-ignore - Needed because of the placeholder image.
+        unoptimized: true,
+      },
+      {
+        author: {
+          avatar: 'http://placeimg.com/640/480',
+          name: 'Author 1',
+        },
+        content:
+          'Sed non omnis. Quam porro est. Quae tempore quae. Exercitationem eos non velit voluptatem velit voluptas iusto. Sit debitis qui ipsam quo asperiores numquam veniam praesentium ut.',
+        id: 5,
+        publication: '2021-04-04 08:05:14',
+        // @ts-ignore - Needed because of the placeholder image.
+        unoptimized: true,
+      },
+    ],
+    author: {
+      avatar: 'http://placeimg.com/640/480',
+      name: 'Author 2',
+      url: '#',
+    },
+    content:
+      'Sit sed error quasi voluptatem velit voluptas aut. Aut debitis eveniet. Praesentium dolores quia voluptate vero quis dicta quasi vel. Aut voluptas accusantium ut aut quidem consectetur itaque laboriosam occaecati.',
+    id: 2,
+    publication: '2021-04-03 23:30:20',
+    // @ts-ignore - Needed because of the placeholder image.
+    unoptimized: true,
+  },
+  {
+    author: {
+      avatar: 'http://placeimg.com/640/480',
+      name: 'Author 3',
+    },
+    content:
+      'Natus consequatur maiores aperiam dolore eius nesciunt ut qui et. Ab ea nobis est. Eaque dolor corrupti id aut. Impedit architecto autem qui neque rerum ab dicta dignissimos voluptates.',
+    id: 3,
+    publication: '2021-09-13 13:24:54',
+    // @ts-ignore - Needed because of the placeholder image.
+    unoptimized: true,
+  },
+];
+
+/**
+ * Page Layout Stories - Post
+ */
+export const Post = Template.bind({});
+Post.args = {
+  breadcrumb: postBreadcrumb,
+  title: pageTitle,
+  intro: pageIntro,
+  headerMeta: {
+    publication: { name: 'Published on:', value: 'March 14th 2020' },
+    categories: {
+      name: 'Categories:',
+      value: [
+        <Link key="cat1" href="#">
+          Cat 1
+        </Link>,
+        <Link key="cat2" href="#">
+          Cat 2
+        </Link>,
+      ],
+    },
+  },
+  footerMeta: {
+    tags: {
+      name: 'Read more about:',
+      value: <ButtonLink target="#">Topic 1</ButtonLink>,
+    },
+  },
+  children: (
+    <>
+      <Heading level={2}>Impedit commodi rerum</Heading>
+      <p>
+        Omnis vel earum cupiditate delectus reprehenderit perferendis distinctio
+        omnis. Laudantium rem tempore eligendi porro officia est dolorum
+        assumenda. Corrupti tempore quia ab. Quidem est inventore. Autem
+        nesciunt sed rerum praesentium.
+      </p>
+      <p>
+        Illo nostrum inventore tenetur quo repellendus autem nisi nostrum
+        dolore. Et velit assumenda. Veniam harum officia et. Blanditiis et et
+        qui cum. Rerum illum quo doloribus neque non velit. Unde iusto et eaque
+        a ut.
+      </p>
+      <Heading level={2}>Et omnis ducimus</Heading>
+      <p>
+        Dolor quidem quas perferendis in nam molestiae. Accusamus quidem
+        accusantium quaerat est praesentium accusamus ab dolorem. Beatae illum
+        totam et corrupti assumenda corporis aut illo animi.
+      </p>
+      <p>
+        Ad rem soluta. Est tenetur consequatur sequi voluptates autem. Molestiae
+        in neque dignissimos. Dolorum numquam quos quam voluptas atque facilis
+        et. Accusantium fuga architecto excepturi consequatur libero est.
+      </p>
+    </>
+  ),
+  widgets: [
+    <Sharing
+      key="sidebar2-widget1"
+      data={{ excerpt: pageIntro, title: pageTitle, url: '#' }}
+      media={[
+        'diaspora',
+        'email',
+        'facebook',
+        'journal-du-hacker',
+        'linkedin',
+        'twitter',
+      ]}
+      title="Share"
+      level={2}
+      expanded={true}
+    />,
+  ],
+  withToC: true,
+  comments,
+  allowComments: true,
+};
+
+const postsListBreadcrumb = [
+  { id: 'home', url: '#', name: 'Home' },
+  { id: 'blog', url: '#', name: 'Blog' },
+];
+
+const posts = [
+  {
+    excerpt:
+      'Esse et voluptas sapiente modi impedit unde et. Ducimus nulla ea impedit sit placeat nihil assumenda. Rem est fugiat amet quo hic. Corrupti fuga quod animi autem dolorem ullam corrupti vel aut.',
+    id: 'post-1',
+    meta: {
+      publication: {
+        name: 'Published on:',
+        value: '2022-02-26T00:42:02',
+      },
+      readingTime: { name: 'Reading time:', value: '5 minutes' },
+      categories: {
+        name: 'Categories:',
+        value: [
+          <a key="cat-1" href="#">
+            Cat 1
+          </a>,
+          <a key="cat-2" href="#">
+            Cat 2
+          </a>,
+        ],
+      },
+      comments: { name: 'Comments:', value: '1 comment' },
+    },
+    title: 'Ratione velit fuga',
+    url: '#',
+    cover: {
+      alt: 'cover',
+      height: 480,
+      src: 'http://placeimg.com/640/480',
+      width: 640,
+      // @ts-ignore - Needed because of the placeholder image.
+      unoptimized: true,
+    },
+  },
+  {
+    excerpt:
+      'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.',
+    id: 'post-2',
+    meta: {
+      publication: {
+        name: 'Published on:',
+        value: '2022-02-20T10:40:00',
+      },
+      readingTime: { name: 'Reading time:', value: '8 minutes' },
+      categories: {
+        name: 'Categories:',
+        value: [
+          <a key="cat-2" href="#">
+            Cat 2
+          </a>,
+        ],
+      },
+      comments: { name: 'Comments:', value: '0 comments' },
+    },
+    title: 'Debitis laudantium laudantium',
+    url: '#',
+  },
+  {
+    excerpt:
+      'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.',
+    id: 'post-3',
+    meta: {
+      publication: {
+        name: 'Published on:',
+        value: '2021-12-20T15:12:02',
+      },
+      readingTime: { name: 'Reading time:', value: '3 minutes' },
+      categories: {
+        name: 'Categories:',
+        value: [
+          <a key="cat-1" href="#">
+            Cat 1
+          </a>,
+        ],
+      },
+      comments: { name: 'Comments:', value: '3 comments' },
+    },
+    title: 'Quaerat ut corporis',
+    url: '#',
+    cover: {
+      alt: 'cover',
+      height: 480,
+      src: 'http://placeimg.com/640/480',
+      width: 640,
+      // @ts-ignore - Needed because of the placeholder image.
+      unoptimized: true,
+    },
+  },
+];
+
+const blogCategories = [
+  { name: 'Cat 1', url: '#' },
+  {
+    name: 'Cat 2',
+    url: '#',
+  },
+  { name: 'Cat 3', url: '#' },
+  { name: 'Cat 4', url: '#' },
+];
+
+/**
+ * Page Layout Stories - Posts list
+ */
+export const Blog = Template.bind({});
+Blog.args = {
+  breadcrumb: postsListBreadcrumb,
+  title: 'Blog',
+  headerMeta: { total: { name: 'Total:', value: `${posts.length} posts` } },
+  children: (
+    <>
+      <PostsList posts={posts} byYear={true} />
+      <ProgressBar min={1} max={1} current={1} info="1/1 page loaded." />
+    </>
+  ),
+  widgets: [
+    <LinksListWidget
+      key="sidebar-widget1"
+      items={blogCategories}
+      title="Categories"
+      level={2}
+    />,
+  ],
+};
-- 
cgit v1.2.3


From 0d59a6d2995b4119865271ed1908ede0bb96497c Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Mon, 9 May 2022 18:19:38 +0200
Subject: refactor: rewrite DescriptionList and Meta components

The meta can have different layout. The previous implementation was not
enough to easily change the layout. Also, I prefer to restrict the meta
types and it prevents me to repeat myself for the labels.
---
 .storybook/preview.js                              |   9 +
 .../atoms/lists/description-list-item.module.scss  |  38 +++
 .../atoms/lists/description-list-item.stories.tsx  | 132 +++++++++
 .../atoms/lists/description-list-item.test.tsx     |  17 ++
 .../atoms/lists/description-list-item.tsx          |  73 +++++
 .../atoms/lists/description-list.module.scss       |  49 +---
 .../atoms/lists/description-list.stories.tsx       |  80 +++++-
 .../atoms/lists/description-list.test.tsx          |   8 +-
 src/components/atoms/lists/description-list.tsx    |  71 ++---
 src/components/molecules/layout/card.module.scss   |  39 ++-
 src/components/molecules/layout/card.stories.tsx   |  38 ++-
 src/components/molecules/layout/card.test.tsx      |  13 +-
 src/components/molecules/layout/card.tsx           |  16 +-
 src/components/molecules/layout/meta.module.scss   |  18 --
 src/components/molecules/layout/meta.stories.tsx   |  57 ++--
 src/components/molecules/layout/meta.test.tsx      |  22 +-
 src/components/molecules/layout/meta.tsx           | 304 +++++++++++++++++++--
 .../molecules/layout/page-footer.stories.tsx       |  12 +-
 src/components/molecules/layout/page-footer.tsx    |   4 +-
 .../molecules/layout/page-header.stories.tsx       |  34 ++-
 src/components/molecules/layout/page-header.tsx    |  25 +-
 .../organisms/layout/cards-list.stories.tsx        |  16 +-
 .../organisms/layout/cards-list.test.tsx           |  26 +-
 .../organisms/layout/comment.module.scss           |  12 +-
 src/components/organisms/layout/comment.test.tsx   |   2 +-
 src/components/organisms/layout/comment.tsx        |  83 +++---
 .../organisms/layout/overview.module.scss          |  34 ++-
 .../organisms/layout/overview.stories.tsx          |  29 +-
 src/components/organisms/layout/overview.test.tsx  |  19 +-
 src/components/organisms/layout/overview.tsx       |  41 ++-
 .../organisms/layout/posts-list.stories.tsx        |  80 ++----
 .../organisms/layout/posts-list.test.tsx           |  76 ++----
 src/components/organisms/layout/posts-list.tsx     |   2 +-
 .../organisms/layout/summary.module.scss           |  13 +
 .../organisms/layout/summary.stories.tsx           |  33 +--
 src/components/organisms/layout/summary.test.tsx   |  27 +-
 src/components/organisms/layout/summary.tsx        |  33 +--
 .../templates/page/page-layout.stories.tsx         | 107 +++-----
 src/components/templates/page/page-layout.tsx      |  14 +-
 src/pages/contact.tsx                              |   4 +-
 src/pages/cv.tsx                                   |  26 +-
 src/pages/index.tsx                                |  12 +-
 src/pages/mentions-legales.tsx                     |  26 +-
 src/utils/helpers/dates.ts                         |  15 -
 44 files changed, 1182 insertions(+), 607 deletions(-)
 create mode 100644 src/components/atoms/lists/description-list-item.module.scss
 create mode 100644 src/components/atoms/lists/description-list-item.stories.tsx
 create mode 100644 src/components/atoms/lists/description-list-item.test.tsx
 create mode 100644 src/components/atoms/lists/description-list-item.tsx

(limited to 'src/components/templates/page/page-layout.stories.tsx')

diff --git a/.storybook/preview.js b/.storybook/preview.js
index 4bec022..344df2a 100644
--- a/.storybook/preview.js
+++ b/.storybook/preview.js
@@ -1,4 +1,5 @@
 import '@styles/globals.scss';
+import { IntlProvider } from 'react-intl';
 
 export const parameters = {
   actions: { argTypesRegex: '^on[A-Z].*' },
@@ -9,3 +10,11 @@ export const parameters = {
     },
   },
 };
+
+export const decorators = [
+  (Story) => (
+    <IntlProvider locale="en">
+      <Story />
+    </IntlProvider>
+  ),
+];
diff --git a/src/components/atoms/lists/description-list-item.module.scss b/src/components/atoms/lists/description-list-item.module.scss
new file mode 100644
index 0000000..60cad57
--- /dev/null
+++ b/src/components/atoms/lists/description-list-item.module.scss
@@ -0,0 +1,38 @@
+.term {
+  color: var(--color-fg-light);
+  font-weight: 600;
+}
+
+.description {
+  margin: 0;
+  word-break: break-all;
+}
+
+.wrapper {
+  display: flex;
+  width: fit-content;
+
+  &--has-separator {
+    .description:not(:first-of-type) {
+      &::before {
+        content: "/\0000a0";
+      }
+    }
+  }
+
+  &--inline,
+  &--inline-values {
+    flex-flow: row wrap;
+    column-gap: var(--spacing-2xs);
+  }
+
+  &--inline-values {
+    .term {
+      flex: 1 1 100%;
+    }
+  }
+
+  &--stacked {
+    flex-flow: column wrap;
+  }
+}
diff --git a/src/components/atoms/lists/description-list-item.stories.tsx b/src/components/atoms/lists/description-list-item.stories.tsx
new file mode 100644
index 0000000..e05493c
--- /dev/null
+++ b/src/components/atoms/lists/description-list-item.stories.tsx
@@ -0,0 +1,132 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import DescriptionListItemComponent from './description-list-item';
+
+export default {
+  title: 'Atoms/Typography/Lists/DescriptionList/Item',
+  component: DescriptionListItemComponent,
+  args: {
+    layout: 'stacked',
+    withSeparator: false,
+  },
+  argTypes: {
+    className: {
+      control: {
+        type: 'text',
+      },
+      description: 'Set additional classnames to the list item wrapper.',
+      table: {
+        category: 'Styles',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
+    descriptionClassName: {
+      control: {
+        type: 'text',
+      },
+      description: 'Set additional classnames to the list item description.',
+      table: {
+        category: 'Styles',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
+    label: {
+      control: {
+        type: 'text',
+      },
+      description: 'The item label.',
+      type: {
+        name: 'string',
+        required: true,
+      },
+    },
+    layout: {
+      control: {
+        type: 'select',
+      },
+      description: 'The item layout.',
+      options: ['inline', 'inline-values', 'stacked'],
+      table: {
+        category: 'Styles',
+        defaultValue: { summary: 'stacked' },
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
+    termClassName: {
+      control: {
+        type: 'text',
+      },
+      description: 'Set additional classnames to the list item term.',
+      table: {
+        category: 'Styles',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
+    value: {
+      description: 'The item value.',
+      type: {
+        name: 'object',
+        required: true,
+        value: {},
+      },
+    },
+    withSeparator: {
+      control: {
+        type: 'boolean',
+      },
+      description: 'Add a slash as separator between multiple values.',
+      table: {
+        category: 'Options',
+        defaultValue: { summary: false },
+      },
+      type: {
+        name: 'boolean',
+        required: false,
+      },
+    },
+  },
+} as ComponentMeta<typeof DescriptionListItemComponent>;
+
+const Template: ComponentStory<typeof DescriptionListItemComponent> = (
+  args
+) => <DescriptionListItemComponent {...args} />;
+
+export const SingleValueStacked = Template.bind({});
+SingleValueStacked.args = {
+  label: 'Recusandae vitae tenetur',
+  value: ['praesentium'],
+  layout: 'stacked',
+};
+
+export const SingleValueInlined = Template.bind({});
+SingleValueInlined.args = {
+  label: 'Recusandae vitae tenetur',
+  value: ['praesentium'],
+  layout: 'inline',
+};
+
+export const MultipleValuesStacked = Template.bind({});
+MultipleValuesStacked.args = {
+  label: 'Recusandae vitae tenetur',
+  value: ['praesentium', 'voluptate', 'tempore'],
+  layout: 'stacked',
+};
+
+export const MultipleValuesInlined = Template.bind({});
+MultipleValuesInlined.args = {
+  label: 'Recusandae vitae tenetur',
+  value: ['praesentium', 'voluptate', 'tempore'],
+  layout: 'inline-values',
+  withSeparator: true,
+};
diff --git a/src/components/atoms/lists/description-list-item.test.tsx b/src/components/atoms/lists/description-list-item.test.tsx
new file mode 100644
index 0000000..730a52f
--- /dev/null
+++ b/src/components/atoms/lists/description-list-item.test.tsx
@@ -0,0 +1,17 @@
+import { render, screen } from '@test-utils';
+import DescriptionListItem from './description-list-item';
+
+const itemLabel = 'Repellendus corporis facilis';
+const itemValue = ['quos', 'eum'];
+
+describe('DescriptionListItem', () => {
+  it('renders a couple of label', () => {
+    render(<DescriptionListItem label={itemLabel} value={itemValue} />);
+    expect(screen.getByRole('term')).toHaveTextContent(itemLabel);
+  });
+
+  it('renders the right number of values', () => {
+    render(<DescriptionListItem label={itemLabel} value={itemValue} />);
+    expect(screen.getAllByRole('definition')).toHaveLength(itemValue.length);
+  });
+});
diff --git a/src/components/atoms/lists/description-list-item.tsx b/src/components/atoms/lists/description-list-item.tsx
new file mode 100644
index 0000000..9505d01
--- /dev/null
+++ b/src/components/atoms/lists/description-list-item.tsx
@@ -0,0 +1,73 @@
+import { FC, ReactNode, useId } from 'react';
+import styles from './description-list-item.module.scss';
+
+export type ItemLayout = 'inline' | 'inline-values' | 'stacked';
+
+export type DescriptionListItemProps = {
+  /**
+   * Set additional classnames to the list item wrapper.
+   */
+  className?: string;
+  /**
+   * Set additional classnames to the list item description.
+   */
+  descriptionClassName?: string;
+  /**
+   * The item label.
+   */
+  label: string;
+  /**
+   * The item layout.
+   */
+  layout?: ItemLayout;
+  /**
+   * Set additional classnames to the list item term.
+   */
+  termClassName?: string;
+  /**
+   * The item value.
+   */
+  value: ReactNode | ReactNode[];
+  /**
+   * If true, use a slash to delimitate multiple values.
+   */
+  withSeparator?: boolean;
+};
+
+/**
+ * DescriptionListItem component
+ *
+ * Render a couple of dt/dd wrapped in a div.
+ */
+const DescriptionListItem: FC<DescriptionListItemProps> = ({
+  className = '',
+  descriptionClassName = '',
+  label,
+  termClassName = '',
+  value,
+  layout = 'stacked',
+  withSeparator = false,
+}) => {
+  const id = useId();
+  const layoutStyles = styles[`wrapper--${layout}`];
+  const separatorStyles = withSeparator ? styles['wrapper--has-separator'] : '';
+  const itemValues = Array.isArray(value) ? value : [value];
+
+  return (
+    <div
+      className={`${styles.wrapper} ${layoutStyles} ${separatorStyles} ${className}`}
+    >
+      <dt className={`${styles.term} ${termClassName}`}>{label}</dt>
+      {itemValues.map((currentValue, index) => (
+        <dd
+          key={`${id}-${index}`}
+          className={`${styles.description} ${descriptionClassName}`}
+        >
+          {currentValue}
+        </dd>
+      ))}
+    </div>
+  );
+};
+
+export default DescriptionListItem;
diff --git a/src/components/atoms/lists/description-list.module.scss b/src/components/atoms/lists/description-list.module.scss
index caa2711..9e913d4 100644
--- a/src/components/atoms/lists/description-list.module.scss
+++ b/src/components/atoms/lists/description-list.module.scss
@@ -2,53 +2,16 @@
 
 .list {
   display: flex;
-  flex-flow: column wrap;
-  gap: var(--spacing-2xs);
+  column-gap: var(--spacing-md);
+  row-gap: var(--spacing-2xs);
   margin: 0;
 
-  &__term {
-    flex: 0 0 max-content;
-    color: var(--color-fg-light);
-    font-weight: 600;
+  &--inline {
+    flex-flow: row wrap;
+    align-items: baseline;
   }
 
-  &__description {
-    flex: 0 0 auto;
-    margin: 0;
-  }
-
-  &__item {
-    display: flex;
-  }
-
-  &--inline &__item {
-    flex-flow: column wrap;
-
-    @include mix.media("screen") {
-      @include mix.dimensions("xs") {
-        flex-flow: row wrap;
-        gap: var(--spacing-2xs);
-
-        .list__description:not(:first-of-type) {
-          &::before {
-            content: "/";
-            margin-right: var(--spacing-2xs);
-          }
-        }
-      }
-    }
-  }
-
-  &--column#{&}--responsive {
-    @include mix.media("screen") {
-      @include mix.dimensions("xs") {
-        flex-flow: row wrap;
-        gap: var(--spacing-lg);
-      }
-    }
-  }
-
-  &--column &__item {
+  &--column {
     flex-flow: column wrap;
   }
 }
diff --git a/src/components/atoms/lists/description-list.stories.tsx b/src/components/atoms/lists/description-list.stories.tsx
index 43ee66e..347fd78 100644
--- a/src/components/atoms/lists/description-list.stories.tsx
+++ b/src/components/atoms/lists/description-list.stories.tsx
@@ -1,16 +1,15 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import DescriptionListComponent, {
-  DescriptionListItem,
-} from './description-list';
+import DescriptionList, { DescriptionListItem } from './description-list';
 
 /**
  * DescriptionList - Storybook Meta
  */
 export default {
-  title: 'Atoms/Typography/Lists',
-  component: DescriptionListComponent,
+  title: 'Atoms/Typography/Lists/DescriptionList',
+  component: DescriptionList,
   args: {
     layout: 'column',
+    withSeparator: false,
   },
   argTypes: {
     className: {
@@ -26,6 +25,19 @@ export default {
         required: false,
       },
     },
+    groupClassName: {
+      control: {
+        type: 'text',
+      },
+      description: 'Set additional classnames to the item wrapper.',
+      table: {
+        category: 'Styles',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     items: {
       control: {
         type: null,
@@ -37,6 +49,19 @@ export default {
         value: {},
       },
     },
+    labelClassName: {
+      control: {
+        type: 'text',
+      },
+      description: 'Set additional classnames to the label wrapper.',
+      table: {
+        category: 'Styles',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     layout: {
       control: {
         type: 'select',
@@ -52,28 +77,55 @@ export default {
         required: false,
       },
     },
+    valueClassName: {
+      control: {
+        type: 'text',
+      },
+      description: 'Set additional classnames to the value wrapper.',
+      table: {
+        category: 'Styles',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
+    withSeparator: {
+      control: {
+        type: 'boolean',
+      },
+      description: 'Add a slash as separator between multiple values.',
+      table: {
+        category: 'Options',
+        defaultValue: { summary: false },
+      },
+      type: {
+        name: 'boolean',
+        required: false,
+      },
+    },
   },
-} as ComponentMeta<typeof DescriptionListComponent>;
+} as ComponentMeta<typeof DescriptionList>;
 
-const Template: ComponentStory<typeof DescriptionListComponent> = (args) => (
-  <DescriptionListComponent {...args} />
+const Template: ComponentStory<typeof DescriptionList> = (args) => (
+  <DescriptionList {...args} />
 );
 
 const items: DescriptionListItem[] = [
-  { id: 'term-1', term: 'Term 1:', value: ['Value for term 1'] },
-  { id: 'term-2', term: 'Term 2:', value: ['Value for term 2'] },
+  { id: 'term-1', label: 'Term 1:', value: ['Value for term 1'] },
+  { id: 'term-2', label: 'Term 2:', value: ['Value for term 2'] },
   {
     id: 'term-3',
-    term: 'Term 3:',
+    label: 'Term 3:',
     value: ['Value 1 for term 3', 'Value 2 for term 3', 'Value 3 for term 3'],
   },
-  { id: 'term-4', term: 'Term 4:', value: ['Value for term 4'] },
+  { id: 'term-4', label: 'Term 4:', value: ['Value for term 4'] },
 ];
 
 /**
  * List Stories - Description list
  */
-export const DescriptionList = Template.bind({});
-DescriptionList.args = {
+export const List = Template.bind({});
+List.args = {
   items,
 };
diff --git a/src/components/atoms/lists/description-list.test.tsx b/src/components/atoms/lists/description-list.test.tsx
index d3f7045..83e405f 100644
--- a/src/components/atoms/lists/description-list.test.tsx
+++ b/src/components/atoms/lists/description-list.test.tsx
@@ -2,14 +2,14 @@ import { render } from '@test-utils';
 import DescriptionList, { DescriptionListItem } from './description-list';
 
 const items: DescriptionListItem[] = [
-  { id: 'term-1', term: 'Term 1:', value: ['Value for term 1'] },
-  { id: 'term-2', term: 'Term 2:', value: ['Value for term 2'] },
+  { id: 'term-1', label: 'Term 1:', value: ['Value for term 1'] },
+  { id: 'term-2', label: 'Term 2:', value: ['Value for term 2'] },
   {
     id: 'term-3',
-    term: 'Term 3:',
+    label: 'Term 3:',
     value: ['Value 1 for term 3', 'Value 2 for term 3', 'Value 3 for term 3'],
   },
-  { id: 'term-4', term: 'Term 4:', value: ['Value for term 4'] },
+  { id: 'term-4', label: 'Term 4:', value: ['Value for term 4'] },
 ];
 
 describe('DescriptionList', () => {
diff --git a/src/components/atoms/lists/description-list.tsx b/src/components/atoms/lists/description-list.tsx
index a60a6a1..a8e2d53 100644
--- a/src/components/atoms/lists/description-list.tsx
+++ b/src/components/atoms/lists/description-list.tsx
@@ -1,4 +1,7 @@
 import { FC } from 'react';
+import DescriptionListItem, {
+  type DescriptionListItemProps,
+} from './description-list-item';
 import styles from './description-list.module.scss';
 
 export type DescriptionListItem = {
@@ -7,13 +10,17 @@ export type DescriptionListItem = {
    */
   id: string;
   /**
-   * A list term.
+   * The list item layout.
    */
-  term: string;
+  layout?: DescriptionListItemProps['layout'];
   /**
-   * An array of values for the list term.
+   * A list label.
    */
-  value: any[];
+  label: DescriptionListItemProps['label'];
+  /**
+   * An array of values for the list item.
+   */
+  value: DescriptionListItemProps['value'];
 };
 
 export type DescriptionListProps = {
@@ -21,10 +28,6 @@ export type DescriptionListProps = {
    * Set additional classnames to the list wrapper.
    */
   className?: string;
-  /**
-   * Set additional classnames to the `dd` element.
-   */
-  descriptionClassName?: string;
   /**
    * Set additional classnames to the `dt`/`dd` couple wrapper.
    */
@@ -34,17 +37,21 @@ export type DescriptionListProps = {
    */
   items: DescriptionListItem[];
   /**
-   * The list items layout. Default: column.
+   * Set additional classnames to the `dt` element.
+   */
+  labelClassName?: string;
+  /**
+   * The list layout. Default: column.
    */
   layout?: 'inline' | 'column';
   /**
-   * Define if the layout should automatically create rows/columns.
+   * Set additional classnames to the `dd` element.
    */
-  responsiveLayout?: boolean;
+  valueClassName?: string;
   /**
-   * Set additional classnames to the `dt` element.
+   * If true, use a slash to delimitate multiple values.
    */
-  termClassName?: string;
+  withSeparator?: DescriptionListItemProps['withSeparator'];
 };
 
 /**
@@ -54,44 +61,40 @@ export type DescriptionListProps = {
  */
 const DescriptionList: FC<DescriptionListProps> = ({
   className = '',
-  descriptionClassName = '',
   groupClassName = '',
   items,
+  labelClassName = '',
   layout = 'column',
-  responsiveLayout = false,
-  termClassName = '',
+  valueClassName = '',
+  withSeparator,
 }) => {
   const layoutModifier = `list--${layout}`;
-  const responsiveModifier = responsiveLayout ? 'list--responsive' : '';
 
   /**
-   * Retrieve the description list items wrapped in a div element.
+   * Retrieve the description list items.
    *
-   * @param {DescriptionListItem[]} listItems - An array of term and description couples.
+   * @param {DescriptionListItem[]} listItems - An array of items.
    * @returns {JSX.Element[]} The description list items.
    */
   const getItems = (listItems: DescriptionListItem[]): JSX.Element[] => {
-    return listItems.map(({ id, term, value }) => {
+    return listItems.map(({ id, layout: itemLayout, label, value }) => {
       return (
-        <div key={id} className={`${styles.list__item} ${groupClassName}`}>
-          <dt className={`${styles.list__term} ${termClassName}`}>{term}</dt>
-          {value.map((currentValue, index) => (
-            <dd
-              key={`${id}-${index}`}
-              className={`${styles.list__description} ${descriptionClassName}`}
-            >
-              {currentValue}
-            </dd>
-          ))}
-        </div>
+        <DescriptionListItem
+          key={id}
+          label={label}
+          value={value}
+          layout={itemLayout}
+          className={groupClassName}
+          descriptionClassName={valueClassName}
+          termClassName={labelClassName}
+          withSeparator={withSeparator}
+        />
       );
     });
   };
 
   return (
-    <dl
-      className={`${styles.list} ${styles[layoutModifier]} ${styles[responsiveModifier]} ${className}`}
-    >
+    <dl className={`${styles.list} ${styles[layoutModifier]} ${className}`}>
       {getItems(items)}
     </dl>
   );
diff --git a/src/components/molecules/layout/card.module.scss b/src/components/molecules/layout/card.module.scss
index d5b9836..3af8f24 100644
--- a/src/components/molecules/layout/card.module.scss
+++ b/src/components/molecules/layout/card.module.scss
@@ -52,24 +52,35 @@
     margin-bottom: var(--spacing-md);
   }
 
-  .items {
-    flex-flow: row wrap;
-    place-content: center;
-    gap: var(--spacing-2xs);
-  }
+  .meta {
+    &__item {
+      flex-flow: row wrap;
+      place-content: center;
+      gap: var(--spacing-2xs);
+      margin: auto;
+    }
+
+    &__label {
+      flex: 0 0 100%;
+    }
 
-  .term {
-    flex: 0 0 100%;
+    &__value {
+      padding: fun.convert-px(2) var(--spacing-xs);
+      border: fun.convert-px(1) solid var(--color-primary-darker);
+      color: var(--color-fg);
+      font-weight: 400;
+
+      &::before {
+        display: none;
+      }
+    }
   }
 
-  .description {
-    padding: fun.convert-px(2) var(--spacing-xs);
-    border: fun.convert-px(1) solid var(--color-primary-darker);
-    color: var(--color-fg);
-    font-weight: 400;
+  &:not(:disabled):focus {
+    text-decoration: none;
 
-    &::before {
-      display: none;
+    .title {
+      text-decoration: underline solid var(--color-primary) 0.3ex;
     }
   }
 }
diff --git a/src/components/molecules/layout/card.stories.tsx b/src/components/molecules/layout/card.stories.tsx
index ed78d00..2e99bbb 100644
--- a/src/components/molecules/layout/card.stories.tsx
+++ b/src/components/molecules/layout/card.stories.tsx
@@ -8,6 +8,19 @@ export default {
   title: 'Molecules/Layout/Card',
   component: Card,
   argTypes: {
+    className: {
+      control: {
+        type: 'text',
+      },
+      description: 'Set additional classnames to the card wrapper.',
+      table: {
+        category: 'Styles',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     cover: {
       description: 'The card cover data (src, dimensions, alternative text).',
       table: {
@@ -19,6 +32,21 @@ export default {
         value: {},
       },
     },
+    coverFit: {
+      control: {
+        type: 'select',
+      },
+      description: 'The cover fit.',
+      options: ['contain', 'cover', 'fill', 'scale-down'],
+      table: {
+        category: 'Options',
+        defaultValue: { summary: 'cover' },
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     meta: {
       description: 'The card metadata (a publication date for example).',
       table: {
@@ -88,13 +116,9 @@ const cover = {
   unoptimized: true,
 };
 
-const meta = [
-  {
-    id: 'an-id',
-    term: 'Voluptates',
-    value: ['Autem', 'Eos'],
-  },
-];
+const meta = {
+  thematics: ['Autem', 'Eos'],
+};
 
 /**
  * Card Stories - Default
diff --git a/src/components/molecules/layout/card.test.tsx b/src/components/molecules/layout/card.test.tsx
index 404bc7a..07c01e9 100644
--- a/src/components/molecules/layout/card.test.tsx
+++ b/src/components/molecules/layout/card.test.tsx
@@ -8,13 +8,10 @@ const cover = {
   width: 640,
 };
 
-const meta = [
-  {
-    id: 'an-id',
-    term: 'Voluptates',
-    value: ['Autem', 'Eos'],
-  },
-];
+const meta = {
+  author: 'Possimus',
+  thematics: ['Autem', 'Eos'],
+};
 
 const tagline = 'Ut rerum incidunt';
 
@@ -47,6 +44,6 @@ describe('Card', () => {
 
   it('renders some meta', () => {
     render(<Card title={title} titleLevel={2} url={url} meta={meta} />);
-    expect(screen.getByText(meta[0].term)).toBeInTheDocument();
+    expect(screen.getByText(meta.author)).toBeInTheDocument();
   });
 });
diff --git a/src/components/molecules/layout/card.tsx b/src/components/molecules/layout/card.tsx
index 89f100e..e416bd5 100644
--- a/src/components/molecules/layout/card.tsx
+++ b/src/components/molecules/layout/card.tsx
@@ -1,13 +1,11 @@
 import ButtonLink from '@components/atoms/buttons/button-link';
 import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
-import DescriptionList, {
-  type DescriptionListItem,
-} from '@components/atoms/lists/description-list';
 import { FC } from 'react';
 import ResponsiveImage, {
   type ResponsiveImageProps,
 } from '../images/responsive-image';
 import styles from './card.module.scss';
+import Meta, { type MetaData } from './meta';
 
 export type Cover = {
   /**
@@ -44,7 +42,7 @@ export type CardProps = {
   /**
    * The card meta.
    */
-  meta?: DescriptionListItem[];
+  meta?: MetaData;
   /**
    * The card tagline.
    */
@@ -96,13 +94,13 @@ const Card: FC<CardProps> = ({
         <div className={styles.tagline}>{tagline}</div>
         {meta && (
           <footer className={styles.footer}>
-            <DescriptionList
-              items={meta}
+            <Meta
+              data={meta}
               layout="inline"
               className={styles.list}
-              groupClassName={styles.items}
-              termClassName={styles.term}
-              descriptionClassName={styles.description}
+              groupClassName={styles.meta__item}
+              labelClassName={styles.meta__label}
+              valueClassName={styles.meta__value}
             />
           </footer>
         )}
diff --git a/src/components/molecules/layout/meta.module.scss b/src/components/molecules/layout/meta.module.scss
index 0485545..4194a6e 100644
--- a/src/components/molecules/layout/meta.module.scss
+++ b/src/components/molecules/layout/meta.module.scss
@@ -1,23 +1,5 @@
 @use "@styles/abstracts/mixins" as mix;
 
-.list {
-  display: grid;
-  grid-template-columns: repeat(1, minmax(0, 1fr));
-  gap: var(--spacing-sm);
-
-  @include mix.media("screen") {
-    @include mix.dimensions("2xs") {
-      grid-template-columns: repeat(2, minmax(0, 1fr));
-    }
-
-    @include mix.dimensions("sm") {
-      display: flex;
-      flex-flow: column nowrap;
-      gap: var(--spacing-2xs);
-    }
-  }
-}
-
 .value {
   word-break: break-all;
 }
diff --git a/src/components/molecules/layout/meta.stories.tsx b/src/components/molecules/layout/meta.stories.tsx
index 0323f90..a1755a0 100644
--- a/src/components/molecules/layout/meta.stories.tsx
+++ b/src/components/molecules/layout/meta.stories.tsx
@@ -1,5 +1,5 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import MetaComponent from './meta';
+import MetaComponent, { MetaData } from './meta';
 
 /**
  * Meta - Storybook Meta
@@ -8,25 +8,41 @@ export default {
   title: 'Molecules/Layout',
   component: MetaComponent,
   argTypes: {
-    className: {
+    data: {
+      description: 'The page metadata.',
+      type: {
+        name: 'object',
+        required: true,
+        value: {},
+      },
+    },
+    itemsLayout: {
       control: {
-        type: 'text',
+        type: 'select',
       },
-      description: 'Set additional classnames to the meta wrapper.',
+      description: 'The items layout.',
+      options: ['inline', 'inline-values', 'stacked'],
       table: {
-        category: 'Styles',
+        category: 'Options',
+        defaultValue: { summary: 'inline-values' },
       },
       type: {
         name: 'string',
         required: false,
       },
     },
-    data: {
-      description: 'The page metadata.',
+    withSeparator: {
+      control: {
+        type: 'boolean',
+      },
+      description: 'Add a slash as separator between multiple values.',
+      table: {
+        category: 'Options',
+        defaultValue: { summary: true },
+      },
       type: {
-        name: 'object',
+        name: 'boolean',
         required: true,
-        value: {},
       },
     },
   },
@@ -36,19 +52,16 @@ const Template: ComponentStory<typeof MetaComponent> = (args) => (
   <MetaComponent {...args} />
 );
 
-const data = {
-  publication: { name: 'Published on:', value: 'April 9th 2022' },
-  categories: {
-    name: 'Categories:',
-    value: [
-      <a key="category1" href="#">
-        Category 1
-      </a>,
-      <a key="category2" href="#">
-        Category 2
-      </a>,
-    ],
-  },
+const data: MetaData = {
+  publication: { date: '2022-04-09', time: '01:04:00' },
+  thematics: [
+    <a key="category1" href="#">
+      Category 1
+    </a>,
+    <a key="category2" href="#">
+      Category 2
+    </a>,
+  ],
 };
 
 /**
diff --git a/src/components/molecules/layout/meta.test.tsx b/src/components/molecules/layout/meta.test.tsx
index a738bdb..fe66d97 100644
--- a/src/components/molecules/layout/meta.test.tsx
+++ b/src/components/molecules/layout/meta.test.tsx
@@ -1,8 +1,24 @@
-import { render } from '@test-utils';
+import { render, screen } from '@test-utils';
+import { getFormattedDate } from '@utils/helpers/dates';
 import Meta from './meta';
 
+const data = {
+  publication: { date: '2022-04-09' },
+  thematics: [
+    <a key="category1" href="#">
+      Category 1
+    </a>,
+    <a key="category2" href="#">
+      Category 2
+    </a>,
+  ],
+};
+
 describe('Meta', () => {
-  it('renders a Meta component', () => {
-    render(<Meta data={{}} />);
+  it('format a date string', () => {
+    render(<Meta data={data} />);
+    expect(
+      screen.getByText(getFormattedDate(data.publication.date))
+    ).toBeInTheDocument();
   });
 });
diff --git a/src/components/molecules/layout/meta.tsx b/src/components/molecules/layout/meta.tsx
index d05396e..1401ac4 100644
--- a/src/components/molecules/layout/meta.tsx
+++ b/src/components/molecules/layout/meta.tsx
@@ -1,67 +1,312 @@
+import Link from '@components/atoms/links/link';
 import DescriptionList, {
   type DescriptionListProps,
   type DescriptionListItem,
 } from '@components/atoms/lists/description-list';
+import { getFormattedDate, getFormattedTime } from '@utils/helpers/dates';
 import { FC, ReactNode } from 'react';
-import styles from './meta.module.scss';
+import { useIntl } from 'react-intl';
 
-export type MetaItem = {
+export type CustomMeta = {
+  label: string;
+  value: ReactNode | ReactNode[];
+};
+
+export type MetaDate = {
   /**
-   * The meta name.
+   * A date string. Ex: `2022-04-30`.
    */
-  name: string;
+  date: string;
   /**
-   * The meta value.
+   * A time string. Ex: `10:25:59`.
    */
-  value: ReactNode | ReactNode[];
-};
-
-export type MetaMap = {
-  [key: string]: MetaItem | undefined;
+  time?: string;
+  /**
+   * Wrap the date with a link to the given target.
+   */
+  target?: string;
 };
 
-export type MetaProps = {
+export type MetaData = {
+  /**
+   * The author name.
+   */
+  author?: string;
+  /**
+   * The comments count.
+   */
+  commentsCount?: string | JSX.Element;
+  /**
+   * The creation date.
+   */
+  creation?: MetaDate;
+  /**
+   * A custom label/value metadata.
+   */
+  custom?: CustomMeta;
+  /**
+   * The license name.
+   */
+  license?: string;
+  /**
+   * The popularity.
+   */
+  popularity?: string | JSX.Element;
+  /**
+   * The publication date.
+   */
+  publication?: MetaDate;
+  /**
+   * The estimated reading time.
+   */
+  readingTime?: string | JSX.Element;
+  /**
+   * An array of repositories.
+   */
+  repositories?: string[] | JSX.Element[];
+  /**
+   * An array of technologies.
+   */
+  technologies?: string[];
   /**
-   * Set additional classnames to the meta wrapper.
+   * An array of thematics.
    */
-  className?: DescriptionListProps['className'];
+  thematics?: string[] | JSX.Element[];
+  /**
+   * An array of thematics.
+   */
+  topics?: string[] | JSX.Element[];
+  /**
+   * A total.
+   */
+  total?: string;
+  /**
+   * The update date.
+   */
+  update?: MetaDate;
+};
+
+export type MetaProps = Omit<
+  DescriptionListProps,
+  'items' | 'withSeparator'
+> & {
   /**
    * The meta data.
    */
-  data: MetaMap;
+  data: MetaData;
   /**
-   * The meta layout.
+   * The items layout.
    */
-  layout?: DescriptionListProps['layout'];
+  itemsLayout?: DescriptionListItem['layout'];
   /**
-   * Determine if the layout should be responsive.
+   * If true, use a slash to delimitate multiple values. Default: true.
    */
-  responsiveLayout?: DescriptionListProps['responsiveLayout'];
+  withSeparator?: DescriptionListProps['withSeparator'];
 };
 
 /**
  * Meta component
  *
- * Renders the page metadata.
+ * Renders the given metadata.
  */
-const Meta: FC<MetaProps> = ({ className, data, ...props }) => {
+const Meta: FC<MetaProps> = ({
+  data,
+  itemsLayout = 'inline-values',
+  withSeparator = true,
+  ...props
+}) => {
+  const intl = useIntl();
+
+  /**
+   * Retrieve the item label based on its key.
+   *
+   * @param {keyof MetaData} key - The meta key.
+   * @returns {string} The item label.
+   */
+  const getLabel = (key: keyof MetaData): string => {
+    switch (key) {
+      case 'author':
+        return intl.formatMessage({
+          defaultMessage: 'Written by:',
+          id: 'OI0N37',
+          description: 'Meta: author label',
+        });
+      case 'commentsCount':
+        return intl.formatMessage({
+          defaultMessage: 'Comments:',
+          id: 'jTVIh8',
+          description: 'Meta: comments label',
+        });
+      case 'creation':
+        return intl.formatMessage({
+          defaultMessage: 'Created on:',
+          id: 'b4fdYE',
+          description: 'Meta: creation date label',
+        });
+      case 'license':
+        return intl.formatMessage({
+          defaultMessage: 'License:',
+          id: 'AuGklx',
+          description: 'Meta: license label',
+        });
+      case 'popularity':
+        return intl.formatMessage({
+          defaultMessage: 'Popularity:',
+          id: 'pWTj2W',
+          description: 'Meta: popularity label',
+        });
+      case 'publication':
+        return intl.formatMessage({
+          defaultMessage: 'Published on:',
+          id: 'QGi5uD',
+          description: 'Meta: publication date label',
+        });
+      case 'readingTime':
+        return intl.formatMessage({
+          defaultMessage: 'Reading time:',
+          id: 'EbFvsM',
+          description: 'Meta: reading time label',
+        });
+      case 'repositories':
+        return intl.formatMessage({
+          defaultMessage: 'Repositories:',
+          id: 'DssFG1',
+          description: 'Meta: repositories label',
+        });
+      case 'technologies':
+        return intl.formatMessage({
+          defaultMessage: 'Technologies:',
+          id: 'ADQmDF',
+          description: 'Meta: technologies label',
+        });
+      case 'thematics':
+        return intl.formatMessage({
+          defaultMessage: 'Thematics:',
+          id: 'bz53Us',
+          description: 'Meta: thematics label',
+        });
+      case 'topics':
+        return intl.formatMessage({
+          defaultMessage: 'Topics:',
+          id: 'gJNaBD',
+          description: 'Meta: topics label',
+        });
+      case 'total':
+        return intl.formatMessage({
+          defaultMessage: 'Total:',
+          id: '92zgdp',
+          description: 'Meta: total label',
+        });
+      case 'update':
+        return intl.formatMessage({
+          defaultMessage: 'Updated on:',
+          id: 'tLC7bh',
+          description: 'Meta: update date label',
+        });
+      default:
+        return '';
+    }
+  };
+
+  /**
+   * Retrieve a formatted date (and time).
+   *
+   * @param {MetaDate} dateTime - A date object.
+   * @returns {JSX.Element} The formatted date wrapped in a time element.
+   */
+  const getDate = (dateTime: MetaDate): JSX.Element => {
+    const { date, time, target } = dateTime;
+
+    if (!dateTime.time) {
+      const isoDate = new Date(`${date}`).toISOString();
+      return target ? (
+        <Link href={target}>
+          <time dateTime={isoDate}>{getFormattedDate(dateTime.date)}</time>
+        </Link>
+      ) : (
+        <time dateTime={isoDate}>{getFormattedDate(dateTime.date)}</time>
+      );
+    }
+
+    const isoDateTime = new Date(`${date}T${time}`).toISOString();
+
+    return target ? (
+      <Link href={target}>
+        <time dateTime={isoDateTime}>
+          {intl.formatMessage(
+            {
+              defaultMessage: '{date} at {time}',
+              description: 'Meta: publication date and time',
+              id: 'fcHeyC',
+            },
+            {
+              date: getFormattedDate(dateTime.date),
+              time: getFormattedTime(`${dateTime.date}T${dateTime.time}`),
+            }
+          )}
+        </time>
+      </Link>
+    ) : (
+      <time dateTime={isoDateTime}>
+        {intl.formatMessage(
+          {
+            defaultMessage: '{date} at {time}',
+            description: 'Meta: publication date and time',
+            id: 'fcHeyC',
+          },
+          {
+            date: getFormattedDate(dateTime.date),
+            time: getFormattedTime(`${dateTime.date}T${dateTime.time}`),
+          }
+        )}
+      </time>
+    );
+  };
+
+  /**
+   * Retrieve the formatted item value.
+   *
+   * @param {keyof MetaData} key - The meta key.
+   * @param {ValueOf<MetaData>} value - The meta value.
+   * @returns {string|ReactNode|ReactNode[]} - The formatted value.
+   */
+  const getValue = <T extends keyof MetaData>(
+    key: T,
+    value: MetaData[T]
+  ): string | ReactNode | ReactNode[] => {
+    if (key === 'creation' || key === 'publication' || key === 'update') {
+      return getDate(value as MetaDate);
+    }
+    return value as string | ReactNode | ReactNode[];
+  };
+
   /**
    * Transform the metadata to description list item format.
    *
-   * @param {MetaMap} items - The meta.
+   * @param {MetaData} items - The meta.
    * @returns {DescriptionListItem[]} The formatted description list items.
    */
-  const getItems = (items: MetaMap): DescriptionListItem[] => {
+  const getItems = (items: MetaData): DescriptionListItem[] => {
     const listItems: DescriptionListItem[] = Object.entries(items)
-      .map(([key, item]) => {
-        if (!item) return;
+      .map(([key, value]) => {
+        if (!key || !value) return;
 
-        const { name, value } = item;
+        const metaKey = key as keyof MetaData;
 
         return {
-          id: key,
-          term: name,
-          value: Array.isArray(value) ? value : [value],
+          id: metaKey,
+          label:
+            metaKey === 'custom'
+              ? (value as CustomMeta).label
+              : getLabel(metaKey),
+          layout: itemsLayout,
+          value:
+            metaKey === 'custom'
+              ? (value as CustomMeta).value
+              : getValue(
+                  metaKey,
+                  value as string | string[] | JSX.Element | JSX.Element[]
+                ),
         } as DescriptionListItem;
       })
       .filter((item): item is DescriptionListItem => !!item);
@@ -72,8 +317,7 @@ const Meta: FC<MetaProps> = ({ className, data, ...props }) => {
   return (
     <DescriptionList
       items={getItems(data)}
-      className={`${styles.list} ${className}`}
-      descriptionClassName={styles.value}
+      withSeparator={withSeparator}
       {...props}
     />
   );
diff --git a/src/components/molecules/layout/page-footer.stories.tsx b/src/components/molecules/layout/page-footer.stories.tsx
index da0a3fa..31b7a49 100644
--- a/src/components/molecules/layout/page-footer.stories.tsx
+++ b/src/components/molecules/layout/page-footer.stories.tsx
@@ -1,4 +1,5 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { MetaData } from './meta';
 import PageFooterComponent from './page-footer';
 
 /**
@@ -39,8 +40,15 @@ const Template: ComponentStory<typeof PageFooterComponent> = (args) => (
   <PageFooterComponent {...args} />
 );
 
-const meta = {
-  topics: { name: 'More posts about:', value: <a href="#">Topic name</a> },
+const meta: MetaData = {
+  custom: {
+    label: 'More posts about:',
+    value: [
+      <a key="topic-1" href="#">
+        Topic name
+      </a>,
+    ],
+  },
 };
 
 /**
diff --git a/src/components/molecules/layout/page-footer.tsx b/src/components/molecules/layout/page-footer.tsx
index f522482..e998b1e 100644
--- a/src/components/molecules/layout/page-footer.tsx
+++ b/src/components/molecules/layout/page-footer.tsx
@@ -1,5 +1,5 @@
 import { FC } from 'react';
-import Meta, { type MetaMap } from './meta';
+import Meta, { MetaData } from './meta';
 
 export type PageFooterProps = {
   /**
@@ -9,7 +9,7 @@ export type PageFooterProps = {
   /**
    * The footer metadata.
    */
-  meta?: MetaMap;
+  meta?: MetaData;
 };
 
 /**
diff --git a/src/components/molecules/layout/page-header.stories.tsx b/src/components/molecules/layout/page-header.stories.tsx
index 6054845..d58f8b5 100644
--- a/src/components/molecules/layout/page-header.stories.tsx
+++ b/src/components/molecules/layout/page-header.stories.tsx
@@ -8,6 +8,19 @@ export default {
   title: 'Molecules/Layout/PageHeader',
   component: PageHeader,
   argTypes: {
+    className: {
+      control: {
+        type: 'text',
+      },
+      description: 'Set additional classnames to the header element.',
+      table: {
+        category: 'Styles',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     intro: {
       control: {
         type: 'text',
@@ -50,18 +63,15 @@ const Template: ComponentStory<typeof PageHeader> = (args) => (
 );
 
 const meta = {
-  publication: { name: 'Published on:', value: 'April 9th 2022' },
-  categories: {
-    name: 'Categories:',
-    value: [
-      <a key="category1" href="#">
-        Category 1
-      </a>,
-      <a key="category2" href="#">
-        Category 2
-      </a>,
-    ],
-  },
+  publication: { date: '2022-04-09' },
+  thematics: [
+    <a key="category1" href="#">
+      Category 1
+    </a>,
+    <a key="category2" href="#">
+      Category 2
+    </a>,
+  ],
 };
 
 /**
diff --git a/src/components/molecules/layout/page-header.tsx b/src/components/molecules/layout/page-header.tsx
index 1663085..9abe9af 100644
--- a/src/components/molecules/layout/page-header.tsx
+++ b/src/components/molecules/layout/page-header.tsx
@@ -1,7 +1,7 @@
 import Heading from '@components/atoms/headings/heading';
-import styles from './page-header.module.scss';
-import Meta, { type MetaMap } from './meta';
 import { FC } from 'react';
+import Meta, { type MetaData } from './meta';
+import styles from './page-header.module.scss';
 
 export type PageHeaderProps = {
   /**
@@ -15,7 +15,7 @@ export type PageHeaderProps = {
   /**
    * The page metadata.
    */
-  meta?: MetaMap;
+  meta?: MetaData;
   /**
    * The page title.
    */
@@ -33,14 +33,29 @@ const PageHeader: FC<PageHeaderProps> = ({
   meta,
   title,
 }) => {
+  const getIntro = () => {
+    return typeof intro === 'string' ? (
+      <div dangerouslySetInnerHTML={{ __html: intro }} />
+    ) : (
+      <div>{intro}</div>
+    );
+  };
+
   return (
     <header className={`${styles.wrapper} ${className}`}>
       <div className={styles.body}>
         <Heading level={1} className={styles.title} withMargin={false}>
           {title}
         </Heading>
-        {meta && <Meta data={meta} className={styles.meta} layout="inline" />}
-        {intro && <div>{intro}</div>}
+        {meta && (
+          <Meta
+            data={meta}
+            className={styles.meta}
+            layout="column"
+            itemsLayout="inline"
+          />
+        )}
+        {intro && getIntro()}
       </div>
     </header>
   );
diff --git a/src/components/organisms/layout/cards-list.stories.tsx b/src/components/organisms/layout/cards-list.stories.tsx
index fe0ebfd..522d068 100644
--- a/src/components/organisms/layout/cards-list.stories.tsx
+++ b/src/components/organisms/layout/cards-list.stories.tsx
@@ -93,9 +93,7 @@ const items: CardsListItem[] = [
       // @ts-ignore - Needed because of the placeholder image.
       unoptimized: true,
     },
-    meta: [
-      { id: 'meta-1', term: 'Quibusdam', value: ['Velit', 'Ex', 'Alias'] },
-    ],
+    meta: { thematics: ['Velit', 'Ex', 'Alias'] },
     tagline: 'Molestias ut error',
     title: 'Et alias omnis',
     url: '#',
@@ -110,7 +108,7 @@ const items: CardsListItem[] = [
       // @ts-ignore - Needed because of the placeholder image.
       unoptimized: true,
     },
-    meta: [{ id: 'meta-1', term: 'Est', value: ['Voluptas'] }],
+    meta: { thematics: ['Voluptas'] },
     tagline: 'Quod vel accusamus',
     title: 'Laboriosam doloremque mollitia',
     url: '#',
@@ -125,13 +123,9 @@ const items: CardsListItem[] = [
       // @ts-ignore - Needed because of the placeholder image.
       unoptimized: true,
     },
-    meta: [
-      {
-        id: 'meta-1',
-        term: 'Omnis',
-        value: ['Quisquam', 'Quia', 'Sapiente', 'Perspiciatis'],
-      },
-    ],
+    meta: {
+      thematics: ['Quisquam', 'Quia', 'Sapiente', 'Perspiciatis'],
+    },
     tagline: 'Quo error eum',
     title: 'Magni rem nulla',
     url: '#',
diff --git a/src/components/organisms/layout/cards-list.test.tsx b/src/components/organisms/layout/cards-list.test.tsx
index 2df3f59..7d98844 100644
--- a/src/components/organisms/layout/cards-list.test.tsx
+++ b/src/components/organisms/layout/cards-list.test.tsx
@@ -1,7 +1,7 @@
 import { render, screen } from '@test-utils';
-import CardsList from './cards-list';
+import CardsList, { type CardsListItem } from './cards-list';
 
-const items = [
+const items: CardsListItem[] = [
   {
     id: 'card-1',
     cover: {
@@ -9,10 +9,10 @@ const items = [
       src: 'http://placeimg.com/640/480',
       width: 640,
       height: 480,
+      // @ts-ignore - Needed because of the placeholder image.
+      unoptimized: true,
     },
-    meta: [
-      { id: 'meta-1', term: 'Quibusdam', value: ['Velit', 'Ex', 'Alias'] },
-    ],
+    meta: { thematics: ['Velit', 'Ex', 'Alias'] },
     tagline: 'Molestias ut error',
     title: 'Et alias omnis',
     url: '#',
@@ -24,8 +24,10 @@ const items = [
       src: 'http://placeimg.com/640/480',
       width: 640,
       height: 480,
+      // @ts-ignore - Needed because of the placeholder image.
+      unoptimized: true,
     },
-    meta: [{ id: 'meta-1', term: 'Est', value: ['Voluptas'] }],
+    meta: { thematics: ['Voluptas'] },
     tagline: 'Quod vel accusamus',
     title: 'Laboriosam doloremque mollitia',
     url: '#',
@@ -37,14 +39,12 @@ const items = [
       src: 'http://placeimg.com/640/480',
       width: 640,
       height: 480,
+      // @ts-ignore - Needed because of the placeholder image.
+      unoptimized: true,
+    },
+    meta: {
+      thematics: ['Quisquam', 'Quia', 'Sapiente', 'Perspiciatis'],
     },
-    meta: [
-      {
-        id: 'meta-1',
-        term: 'Omnis',
-        value: ['Quisquam', 'Quia', 'Sapiente', 'Perspiciatis'],
-      },
-    ],
     tagline: 'Quo error eum',
     title: 'Magni rem nulla',
     url: '#',
diff --git a/src/components/organisms/layout/comment.module.scss b/src/components/organisms/layout/comment.module.scss
index 54201de..d2b68e1 100644
--- a/src/components/organisms/layout/comment.module.scss
+++ b/src/components/organisms/layout/comment.module.scss
@@ -60,16 +60,20 @@
   }
 
   .date {
-    flex-flow: row wrap;
-    justify-content: center;
     margin: var(--spacing-sm) 0;
     font-size: var(--font-size-sm);
-    text-align: center;
+
+    &__item {
+      justify-content: center;
+    }
 
     @include mix.media("screen") {
       @include mix.dimensions("sm") {
-        justify-content: left;
         margin: 0 0 var(--spacing-sm);
+
+        &__item {
+          justify-content: left;
+        }
       }
     }
   }
diff --git a/src/components/organisms/layout/comment.test.tsx b/src/components/organisms/layout/comment.test.tsx
index 9e537e5..4961722 100644
--- a/src/components/organisms/layout/comment.test.tsx
+++ b/src/components/organisms/layout/comment.test.tsx
@@ -1,5 +1,5 @@
 import { render, screen } from '@test-utils';
-import { getFormattedDate, getFormattedTime } from '@utils/helpers/format';
+import { getFormattedDate, getFormattedTime } from '@utils/helpers/dates';
 import Comment from './comment';
 
 const author = {
diff --git a/src/components/organisms/layout/comment.tsx b/src/components/organisms/layout/comment.tsx
index 6d41c00..248efc2 100644
--- a/src/components/organisms/layout/comment.tsx
+++ b/src/components/organisms/layout/comment.tsx
@@ -1,10 +1,12 @@
 import Button from '@components/atoms/buttons/button';
 import Link from '@components/atoms/links/link';
-import DescriptionList from '@components/atoms/lists/description-list';
-import { getFormattedDate, getFormattedTime } from '@utils/helpers/format';
+import Meta from '@components/molecules/layout/meta';
+import useSettings from '@utils/hooks/use-settings';
 import Image from 'next/image';
+import Script from 'next/script';
 import { FC, useState } from 'react';
 import { useIntl } from 'react-intl';
+import { type Comment as CommentSchema, type WithContext } from 'schema-dts';
 import CommentForm, { type CommentFormProps } from '../forms/comment-form';
 import styles from './comment.module.scss';
 
@@ -41,7 +43,11 @@ export type CommentProps = {
    */
   id: number | string;
   /**
-   * The comment date.
+   * The comment parent id.
+   */
+  parentId?: number | string;
+  /**
+   * The comment date and time separated with a space.
    */
   publication: string;
   /**
@@ -60,15 +66,14 @@ const Comment: FC<CommentProps> = ({
   canReply = true,
   content,
   id,
+  parentId,
   publication,
   saveComment,
   ...props
 }) => {
   const intl = useIntl();
   const [isReplying, setIsReplying] = useState<boolean>(false);
-  const commentDate = getFormattedDate(publication);
-  const commentTime = getFormattedTime(publication);
-  const commentDateTime = new Date(publication).toISOString();
+  const [publicationDate, publicationTime] = publication.split(' ');
 
   const avatarAltText = intl.formatMessage(
     {
@@ -89,37 +94,46 @@ const Comment: FC<CommentProps> = ({
         description: 'Comment: reply button',
         id: 'hzHuCc',
       });
-  const dateLabel = intl.formatMessage({
-    defaultMessage: 'Published on:',
-    description: 'Comment: publication date label',
-    id: 'soj7do',
-  });
-  const dateValue = intl.formatMessage(
-    {
-      defaultMessage: '{date} at {time}',
-      description: 'Comment: publication date and time',
-      id: 'Ld6yMP',
-    },
-    {
-      date: commentDate,
-      time: commentTime,
-    }
-  );
   const formTitle = intl.formatMessage({
     defaultMessage: 'Leave a reply',
     description: 'Comment: comment form title',
     id: '2fD5CI',
   });
 
-  const dateLink = (
-    <Link href={`#comment-${id}`}>
-      <time dateTime={commentDateTime}></time>
-      {dateValue}
-    </Link>
-  );
+  const { website } = useSettings();
+
+  const commentSchema: WithContext<CommentSchema> = {
+    '@context': 'https://schema.org',
+    '@id': `${website.url}/#comment-${id}`,
+    '@type': 'Comment',
+    parentItem: parentId
+      ? { '@id': `${website.url}/#comment-${parentId}` }
+      : undefined,
+    about: { '@type': 'Article', '@id': `${website.url}/#article` },
+    author: {
+      '@type': 'Person',
+      name: author.name,
+      image: author.avatar,
+      url: author.url,
+    },
+    creator: {
+      '@type': 'Person',
+      name: author.name,
+      image: author.avatar,
+      url: author.url,
+    },
+    dateCreated: publication,
+    datePublished: publication,
+    text: content,
+  };
 
   return (
     <>
+      <Script
+        id="schema-comments"
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(commentSchema) }}
+      />
       <article
         id={`comment-${id}`}
         className={`${styles.wrapper} ${styles['wrapper--comment']}`}
@@ -142,11 +156,18 @@ const Comment: FC<CommentProps> = ({
             <span className={styles.author}>{author.name}</span>
           )}
         </header>
-        <DescriptionList
-          items={[{ id: 'comment-date', term: dateLabel, value: [dateLink] }]}
+        <Meta
+          data={{
+            publication: {
+              date: publicationDate,
+              time: publicationTime,
+              target: `#comment-${id}`,
+            },
+          }}
           layout="inline"
+          itemsLayout="inline"
           className={styles.date}
-          groupClassName={styles.meta}
+          groupClassName={styles.date__item}
         />
         <div className={styles.body}>{content}</div>
         <footer className={styles.footer}>
diff --git a/src/components/organisms/layout/overview.module.scss b/src/components/organisms/layout/overview.module.scss
index 4d50ad1..e813625 100644
--- a/src/components/organisms/layout/overview.module.scss
+++ b/src/components/organisms/layout/overview.module.scss
@@ -1,12 +1,44 @@
 @use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
 
 .wrapper {
   padding: var(--spacing-sm) var(--spacing-md);
   border: fun.convert-px(1) solid var(--color-border);
+
+  .meta {
+    display: grid;
+    grid-template-columns: repeat(
+      auto-fit,
+      min(calc(100vw - (var(--spacing-md) * 2)), 23ch)
+    );
+    row-gap: var(--spacing-2xs);
+
+    @include mix.media("screen") {
+      @include mix.dimensions("md") {
+        grid-template-columns: repeat(
+          auto-fit,
+          min(calc(100vw - (var(--spacing-md) * 2)), 20ch)
+        );
+      }
+    }
+
+    &--has-techno {
+      div:last-child {
+        gap: var(--spacing-2xs);
+
+        dd {
+          padding: 0 var(--spacing-2xs);
+          border: fun.convert-px(1) solid var(--color-border-dark);
+        }
+      }
+    }
+  }
 }
 
 .cover {
-  max-height: fun.convert-px(150);
+  width: 100%;
+  height: fun.convert-px(175);
   margin: 0 auto var(--spacing-md);
+  padding: var(--spacing-2xs);
   border: fun.convert-px(1) solid var(--color-border);
 }
diff --git a/src/components/organisms/layout/overview.stories.tsx b/src/components/organisms/layout/overview.stories.tsx
index b18a6b6..1139350 100644
--- a/src/components/organisms/layout/overview.stories.tsx
+++ b/src/components/organisms/layout/overview.stories.tsx
@@ -1,5 +1,5 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import Overview from './overview';
+import Overview, { OverviewMeta } from './overview';
 
 /**
  * Overview - Storybook Meta
@@ -8,8 +8,21 @@ export default {
   title: 'Organisms/Layout/Overview',
   component: Overview,
   argTypes: {
+    className: {
+      control: {
+        type: 'text',
+      },
+      description: 'Set additional classnames to the overview wrapper.',
+      table: {
+        category: 'Styles',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     cover: {
-      description: 'The overview cover.',
+      description: 'The overview cover',
       table: {
         category: 'Options',
       },
@@ -20,7 +33,7 @@ export default {
       },
     },
     meta: {
-      description: 'The overview metadata.',
+      description: 'The overview meta.',
       type: {
         name: 'object',
         required: true,
@@ -42,12 +55,9 @@ const cover = {
   unoptimized: true,
 };
 
-const meta = {
-  publication: { name: 'Illo ut odio:', value: 'Sequi et excepturi' },
-  update: {
-    name: 'Perspiciatis vel laudantium:',
-    value: 'Dignissimos ratione veritatis',
-  },
+const meta: OverviewMeta = {
+  creation: { date: '2022-05-09' },
+  license: 'Dignissimos ratione veritatis',
 };
 
 /**
@@ -55,7 +65,6 @@ const meta = {
  */
 export const Default = Template.bind({});
 Default.args = {
-  cover,
   meta,
 };
 
diff --git a/src/components/organisms/layout/overview.test.tsx b/src/components/organisms/layout/overview.test.tsx
index 0738d3f..b40a785 100644
--- a/src/components/organisms/layout/overview.test.tsx
+++ b/src/components/organisms/layout/overview.test.tsx
@@ -1,5 +1,5 @@
 import { render, screen } from '@test-utils';
-import Overview from './overview';
+import Overview, { type OverviewMeta } from './overview';
 
 const cover = {
   alt: 'Incidunt unde quam',
@@ -8,22 +8,19 @@ const cover = {
   width: 640,
 };
 
-const meta = {
-  publication: { name: 'Illo ut odio:', value: 'Sequi et excepturi' },
-  update: {
-    name: 'Perspiciatis vel laudantium:',
-    value: 'Dignissimos ratione veritatis',
-  },
+const data: OverviewMeta = {
+  creation: { date: '2022-05-09' },
+  license: 'Dignissimos ratione veritatis',
 };
 
 describe('Overview', () => {
-  it('renders some meta', () => {
-    render(<Overview meta={meta} />);
-    expect(screen.getByText(meta['publication'].name)).toBeInTheDocument();
+  it('renders some data', () => {
+    render(<Overview meta={data} />);
+    expect(screen.getByText(data.license!)).toBeInTheDocument();
   });
 
   it('renders a cover', () => {
-    render(<Overview meta={meta} cover={cover} />);
+    render(<Overview cover={cover} meta={data} />);
     expect(screen.getByRole('img', { name: cover.alt })).toBeInTheDocument();
   });
 });
diff --git a/src/components/organisms/layout/overview.tsx b/src/components/organisms/layout/overview.tsx
index 42d0431..55ad0b5 100644
--- a/src/components/organisms/layout/overview.tsx
+++ b/src/components/organisms/layout/overview.tsx
@@ -1,13 +1,33 @@
 import ResponsiveImage, {
   type ResponsiveImageProps,
 } from '@components/molecules/images/responsive-image';
-import Meta, { type MetaMap } from '@components/molecules/layout/meta';
+import Meta, { type MetaData } from '@components/molecules/layout/meta';
 import { FC } from 'react';
 import styles from './overview.module.scss';
 
+export type OverviewMeta = Pick<
+  MetaData,
+  | 'creation'
+  | 'license'
+  | 'popularity'
+  | 'repositories'
+  | 'technologies'
+  | 'update'
+>;
+
 export type OverviewProps = {
+  /**
+   * Set additional classnames to the overview wrapper.
+   */
+  className?: string;
+  /**
+   * The overview cover.
+   */
   cover?: Pick<ResponsiveImageProps, 'alt' | 'src' | 'width' | 'height'>;
-  meta: MetaMap;
+  /**
+   * The overview meta.
+   */
+  meta: OverviewMeta;
 };
 
 /**
@@ -15,17 +35,26 @@ export type OverviewProps = {
  *
  * Render an overview.
  */
-const Overview: FC<OverviewProps> = ({ cover, meta }) => {
+const Overview: FC<OverviewProps> = ({ className = '', cover, meta }) => {
+  const { technologies, ...remainingMeta } = meta;
+  const metaModifier = technologies ? styles['meta--has-techno'] : '';
+
   return (
-    <div className={styles.wrapper}>
+    <div className={`${styles.wrapper} ${className}`}>
       {cover && (
         <ResponsiveImage
-          objectFit="cover"
+          layout="responsive"
+          objectFit="contain"
           className={styles.cover}
           {...cover}
         />
       )}
-      <Meta data={meta} layout="column" responsiveLayout={true} />
+      <Meta
+        data={{ ...remainingMeta, technologies }}
+        layout="inline"
+        className={`${styles.meta} ${metaModifier}`}
+        withSeparator={false}
+      />
     </div>
   );
 };
diff --git a/src/components/organisms/layout/posts-list.stories.tsx b/src/components/organisms/layout/posts-list.stories.tsx
index f80e1ca..d97ad03 100644
--- a/src/components/organisms/layout/posts-list.stories.tsx
+++ b/src/components/organisms/layout/posts-list.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import PostsList, { type Post } from './posts-list';
 
 /**
@@ -51,13 +50,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof PostsList>;
 
 const Template: ComponentStory<typeof PostsList> = (args) => (
@@ -70,23 +62,17 @@ const posts: Post[] = [
       'Esse et voluptas sapiente modi impedit unde et. Ducimus nulla ea impedit sit placeat nihil assumenda. Rem est fugiat amet quo hic. Corrupti fuga quod animi autem dolorem ullam corrupti vel aut.',
     id: 'post-1',
     meta: {
-      publication: {
-        name: 'Published on:',
-        value: '2022-02-26T00:42:02',
-      },
-      readingTime: { name: 'Reading time:', value: '5 minutes' },
-      categories: {
-        name: 'Categories:',
-        value: [
-          <a key="cat-1" href="#">
-            Cat 1
-          </a>,
-          <a key="cat-2" href="#">
-            Cat 2
-          </a>,
-        ],
-      },
-      comments: { name: 'Comments:', value: '1 comment' },
+      publication: { date: '2022-02-26' },
+      readingTime: '5 minutes',
+      thematics: [
+        <a key="cat-1" href="#">
+          Cat 1
+        </a>,
+        <a key="cat-2" href="#">
+          Cat 2
+        </a>,
+      ],
+      commentsCount: '1 comment',
     },
     title: 'Ratione velit fuga',
     url: '#',
@@ -104,20 +90,14 @@ const posts: Post[] = [
       'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.',
     id: 'post-2',
     meta: {
-      publication: {
-        name: 'Published on:',
-        value: '2022-02-20T10:40:00',
-      },
-      readingTime: { name: 'Reading time:', value: '8 minutes' },
-      categories: {
-        name: 'Categories:',
-        value: [
-          <a key="cat-2" href="#">
-            Cat 2
-          </a>,
-        ],
-      },
-      comments: { name: 'Comments:', value: '0 comments' },
+      publication: { date: '2022-02-20' },
+      readingTime: '8 minutes',
+      thematics: [
+        <a key="cat-2" href="#">
+          Cat 2
+        </a>,
+      ],
+      commentsCount: '0 comments',
     },
     title: 'Debitis laudantium laudantium',
     url: '#',
@@ -127,20 +107,14 @@ const posts: Post[] = [
       'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.',
     id: 'post-3',
     meta: {
-      publication: {
-        name: 'Published on:',
-        value: '2021-12-20T15:12:02',
-      },
-      readingTime: { name: 'Reading time:', value: '3 minutes' },
-      categories: {
-        name: 'Categories:',
-        value: [
-          <a key="cat-1" href="#">
-            Cat 1
-          </a>,
-        ],
-      },
-      comments: { name: 'Comments:', value: '3 comments' },
+      publication: { date: '2021-12-20' },
+      readingTime: '3 minutes',
+      thematics: [
+        <a key="cat-1" href="#">
+          Cat 1
+        </a>,
+      ],
+      commentsCount: '3 comments',
     },
     title: 'Quaerat ut corporis',
     url: '#',
diff --git a/src/components/organisms/layout/posts-list.test.tsx b/src/components/organisms/layout/posts-list.test.tsx
index aa6dffa..98af1c3 100644
--- a/src/components/organisms/layout/posts-list.test.tsx
+++ b/src/components/organisms/layout/posts-list.test.tsx
@@ -1,29 +1,23 @@
 import { render, screen } from '@test-utils';
-import PostsList from './posts-list';
+import PostsList, { Post } from './posts-list';
 
-const posts = [
+const posts: Post[] = [
   {
     excerpt:
       'Esse et voluptas sapiente modi impedit unde et. Ducimus nulla ea impedit sit placeat nihil assumenda. Rem est fugiat amet quo hic. Corrupti fuga quod animi autem dolorem ullam corrupti vel aut.',
     id: 'post-1',
     meta: {
-      publication: {
-        name: 'Published on:',
-        value: '2022-02-26T00:42:02',
-      },
-      readingTime: { name: 'Reading time:', value: '5 minutes' },
-      categories: {
-        name: 'Categories:',
-        value: [
-          <a key="cat-1" href="#">
-            Cat 1
-          </a>,
-          <a key="cat-2" href="#">
-            Cat 2
-          </a>,
-        ],
-      },
-      comments: { name: 'Comments:', value: '1 comment' },
+      publication: { date: '2022-02-26' },
+      readingTime: '5 minutes',
+      thematics: [
+        <a key="cat-1" href="#">
+          Cat 1
+        </a>,
+        <a key="cat-2" href="#">
+          Cat 2
+        </a>,
+      ],
+      commentsCount: '1 comment',
     },
     title: 'Ratione velit fuga',
     url: '#',
@@ -39,20 +33,14 @@ const posts = [
       'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.',
     id: 'post-2',
     meta: {
-      publication: {
-        name: 'Published on:',
-        value: '2022-02-20T10:40:00',
-      },
-      readingTime: { name: 'Reading time:', value: '8 minutes' },
-      categories: {
-        name: 'Categories:',
-        value: [
-          <a key="cat-2" href="#">
-            Cat 2
-          </a>,
-        ],
-      },
-      comments: { name: 'Comments:', value: '0 comments' },
+      publication: { date: '2022-02-20' },
+      readingTime: '8 minutes',
+      thematics: [
+        <a key="cat-2" href="#">
+          Cat 2
+        </a>,
+      ],
+      commentsCount: '0 comments',
     },
     title: 'Debitis laudantium laudantium',
     url: '#',
@@ -62,20 +50,14 @@ const posts = [
       'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.',
     id: 'post-3',
     meta: {
-      publication: {
-        name: 'Published on:',
-        value: '2021-12-20T15:12:02',
-      },
-      readingTime: { name: 'Reading time:', value: '3 minutes' },
-      categories: {
-        name: 'Categories:',
-        value: [
-          <a key="cat-1" href="#">
-            Cat 1
-          </a>,
-        ],
-      },
-      comments: { name: 'Comments:', value: '3 comments' },
+      publication: { date: '2021-12-20' },
+      readingTime: '3 minutes',
+      thematics: [
+        <a key="cat-1" href="#">
+          Cat 1
+        </a>,
+      ],
+      commentsCount: '3 comments',
     },
     title: 'Quaerat ut corporis',
     url: '#',
diff --git a/src/components/organisms/layout/posts-list.tsx b/src/components/organisms/layout/posts-list.tsx
index d67b03a..4855205 100644
--- a/src/components/organisms/layout/posts-list.tsx
+++ b/src/components/organisms/layout/posts-list.tsx
@@ -40,7 +40,7 @@ const sortPostsByYear = (data: Post[]): YearCollection => {
   const yearCollection: YearCollection = {};
 
   data.forEach((post) => {
-    const postYear = new Date(post.meta.publication.value as string)
+    const postYear = new Date(post.meta.publication!.date)
       .getFullYear()
       .toString();
     yearCollection[postYear] = [...(yearCollection[postYear] || []), post];
diff --git a/src/components/organisms/layout/summary.module.scss b/src/components/organisms/layout/summary.module.scss
index 1cdda98..6d19853 100644
--- a/src/components/organisms/layout/summary.module.scss
+++ b/src/components/organisms/layout/summary.module.scss
@@ -83,5 +83,18 @@
 }
 
 .meta {
+  display: grid;
+  grid-template-columns: repeat(
+    auto-fit,
+    min(100vw, calc(50% - var(--spacing-lg)))
+  );
+  margin-top: var(--spacing-lg);
   font-size: var(--font-size-sm);
+
+  @include mix.media("screen") {
+    @include mix.dimensions("sm") {
+      display: flex;
+      margin-top: 0;
+    }
+  }
 }
diff --git a/src/components/organisms/layout/summary.stories.tsx b/src/components/organisms/layout/summary.stories.tsx
index 05be7da..42f1d44 100644
--- a/src/components/organisms/layout/summary.stories.tsx
+++ b/src/components/organisms/layout/summary.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import Summary from './summary';
 
 /**
@@ -78,13 +77,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof Summary>;
 
 const Template: ComponentStory<typeof Summary> = (args) => (
@@ -100,20 +92,17 @@ const cover = {
 };
 
 const meta = {
-  publication: { name: 'Published on:', value: 'April 11th 2022' },
-  readingTime: { name: 'Reading time:', value: '5 minutes' },
-  categories: {
-    name: 'Categories:',
-    value: [
-      <a key="cat-1" href="#">
-        Cat 1
-      </a>,
-      <a key="cat-2" href="#">
-        Cat 2
-      </a>,
-    ],
-  },
-  comments: { name: 'Comments:', value: '1 comment' },
+  publication: { date: '2022-04-11' },
+  readingTime: '5 minutes',
+  thematics: [
+    <a key="cat-1" href="#">
+      Cat 1
+    </a>,
+    <a key="cat-2" href="#">
+      Cat 2
+    </a>,
+  ],
+  commentsCount: '1 comment',
 };
 
 /**
diff --git a/src/components/organisms/layout/summary.test.tsx b/src/components/organisms/layout/summary.test.tsx
index 4944805..09b797c 100644
--- a/src/components/organisms/layout/summary.test.tsx
+++ b/src/components/organisms/layout/summary.test.tsx
@@ -12,20 +12,17 @@ const excerpt =
   'Perspiciatis quasi libero nemo non eligendi nam minima. Deleniti expedita tempore. Praesentium explicabo molestiae eaque consectetur vero. Quae nostrum quisquam similique. Ut hic est quas ut esse quisquam nobis.';
 
 const meta = {
-  publication: { name: 'Published on:', value: 'April 11th 2022' },
-  readingTime: { name: 'Reading time:', value: '5 minutes' },
-  categories: {
-    name: 'Categories:',
-    value: [
-      <a key="cat-1" href="#">
-        Cat 1
-      </a>,
-      <a key="cat-2" href="#">
-        Cat 2
-      </a>,
-    ],
-  },
-  comments: { name: 'Comments:', value: '1 comment' },
+  publication: { date: '2022-04-11' },
+  readingTime: '5 minutes',
+  thematics: [
+    <a key="cat-1" href="#">
+      Cat 1
+    </a>,
+    <a key="cat-2" href="#">
+      Cat 2
+    </a>,
+  ],
+  commentsCount: '1 comment',
 };
 
 const title = 'Odio odit necessitatibus';
@@ -80,6 +77,6 @@ describe('Summary', () => {
 
   it('renders some meta', () => {
     render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />);
-    expect(screen.getByText(meta.publication.name)).toBeInTheDocument();
+    expect(screen.getByText(meta.readingTime)).toBeInTheDocument();
   });
 });
diff --git a/src/components/organisms/layout/summary.tsx b/src/components/organisms/layout/summary.tsx
index 28aac68..8b47833 100644
--- a/src/components/organisms/layout/summary.tsx
+++ b/src/components/organisms/layout/summary.tsx
@@ -5,7 +5,7 @@ import Link from '@components/atoms/links/link';
 import ResponsiveImage, {
   type ResponsiveImageProps,
 } from '@components/molecules/images/responsive-image';
-import Meta, { type MetaItem } from '@components/molecules/layout/meta';
+import Meta, { MetaData } from '@components/molecules/layout/meta';
 import { FC, ReactNode } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './summary.module.scss';
@@ -15,24 +15,15 @@ export type Cover = Pick<
   'alt' | 'src' | 'width' | 'height'
 >;
 
-export type RequiredMetaKey = 'publication';
-
-export type RequiredMeta = {
-  [key in RequiredMetaKey]: MetaItem;
-};
-
-export type OptionalMetaKey =
+export type SummaryMeta = Pick<
+  MetaData,
   | 'author'
-  | 'categories'
-  | 'comments'
+  | 'commentsCount'
+  | 'publication'
   | 'readingTime'
-  | 'update';
-
-export type OptionalMeta = {
-  [key in OptionalMetaKey]?: MetaItem;
-};
-
-export type Meta = RequiredMeta & OptionalMeta;
+  | 'thematics'
+  | 'update'
+>;
 
 export type SummaryProps = {
   /**
@@ -46,7 +37,7 @@ export type SummaryProps = {
   /**
    * The post meta.
    */
-  meta: Meta;
+  meta: SummaryMeta;
   /**
    * The post title.
    */
@@ -100,7 +91,11 @@ const Summary: FC<SummaryProps> = ({
         </Link>
       </header>
       <div className={styles.body}>
-        {excerpt}
+        {typeof excerpt === 'string' ? (
+          <div dangerouslySetInnerHTML={{ __html: excerpt }} />
+        ) : (
+          excerpt
+        )}
         <ButtonLink target={url} className={styles['read-more']}>
           <>
             {readMore}
diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx
index 1f72cb0..b4b0d68 100644
--- a/src/components/templates/page/page-layout.stories.tsx
+++ b/src/components/templates/page/page-layout.stories.tsx
@@ -6,7 +6,6 @@ import PostsList from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import Sharing from '@components/organisms/widgets/sharing';
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import PageLayoutComponent from './page-layout';
 
 /**
@@ -157,15 +156,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <div id="__next">
-          <Story />
-        </div>
-      </IntlProvider>
-    ),
-  ],
   parameters: {
     layout: 'fullscreen',
   },
@@ -231,7 +221,6 @@ SinglePage.args = {
         'linkedin',
         'twitter',
       ]}
-      title="Share"
       level={2}
       expanded={true}
     />,
@@ -320,22 +309,19 @@ Post.args = {
   title: pageTitle,
   intro: pageIntro,
   headerMeta: {
-    publication: { name: 'Published on:', value: 'March 14th 2020' },
-    categories: {
-      name: 'Categories:',
-      value: [
-        <Link key="cat1" href="#">
-          Cat 1
-        </Link>,
-        <Link key="cat2" href="#">
-          Cat 2
-        </Link>,
-      ],
-    },
+    publication: { date: '2020-03-14' },
+    thematics: [
+      <Link key="cat1" href="#">
+        Cat 1
+      </Link>,
+      <Link key="cat2" href="#">
+        Cat 2
+      </Link>,
+    ],
   },
   footerMeta: {
-    tags: {
-      name: 'Read more about:',
+    custom: {
+      label: 'Read more about:',
       value: <ButtonLink target="#">Topic 1</ButtonLink>,
     },
   },
@@ -379,7 +365,6 @@ Post.args = {
         'linkedin',
         'twitter',
       ]}
-      title="Share"
       level={2}
       expanded={true}
     />,
@@ -400,23 +385,17 @@ const posts = [
       'Esse et voluptas sapiente modi impedit unde et. Ducimus nulla ea impedit sit placeat nihil assumenda. Rem est fugiat amet quo hic. Corrupti fuga quod animi autem dolorem ullam corrupti vel aut.',
     id: 'post-1',
     meta: {
-      publication: {
-        name: 'Published on:',
-        value: '2022-02-26T00:42:02',
-      },
-      readingTime: { name: 'Reading time:', value: '5 minutes' },
-      categories: {
-        name: 'Categories:',
-        value: [
-          <a key="cat-1" href="#">
-            Cat 1
-          </a>,
-          <a key="cat-2" href="#">
-            Cat 2
-          </a>,
-        ],
-      },
-      comments: { name: 'Comments:', value: '1 comment' },
+      publication: { date: '2022-02-26' },
+      readingTime: '5 minutes',
+      categories: [
+        <a key="cat-1" href="#">
+          Cat 1
+        </a>,
+        <a key="cat-2" href="#">
+          Cat 2
+        </a>,
+      ],
+      commentsCount: '1 comment',
     },
     title: 'Ratione velit fuga',
     url: '#',
@@ -435,19 +414,15 @@ const posts = [
     id: 'post-2',
     meta: {
       publication: {
-        name: 'Published on:',
-        value: '2022-02-20T10:40:00',
+        date: '2022-02-20',
       },
-      readingTime: { name: 'Reading time:', value: '8 minutes' },
-      categories: {
-        name: 'Categories:',
-        value: [
-          <a key="cat-2" href="#">
-            Cat 2
-          </a>,
-        ],
-      },
-      comments: { name: 'Comments:', value: '0 comments' },
+      readingTime: '8 minutes',
+      categories: [
+        <a key="cat-2" href="#">
+          Cat 2
+        </a>,
+      ],
+      comments: '0 comments',
     },
     title: 'Debitis laudantium laudantium',
     url: '#',
@@ -458,19 +433,15 @@ const posts = [
     id: 'post-3',
     meta: {
       publication: {
-        name: 'Published on:',
-        value: '2021-12-20T15:12:02',
-      },
-      readingTime: { name: 'Reading time:', value: '3 minutes' },
-      categories: {
-        name: 'Categories:',
-        value: [
-          <a key="cat-1" href="#">
-            Cat 1
-          </a>,
-        ],
+        date: '2021-12-20',
       },
-      comments: { name: 'Comments:', value: '3 comments' },
+      readingTime: '3 minutes',
+      categories: [
+        <a key="cat-1" href="#">
+          Cat 1
+        </a>,
+      ],
+      comments: '3 comments',
     },
     title: 'Quaerat ut corporis',
     url: '#',
@@ -502,7 +473,7 @@ export const Blog = Template.bind({});
 Blog.args = {
   breadcrumb: postsListBreadcrumb,
   title: 'Blog',
-  headerMeta: { total: { name: 'Total:', value: `${posts.length} posts` } },
+  headerMeta: { total: `${posts.length} posts` },
   children: (
     <>
       <PostsList posts={posts} byYear={true} />
diff --git a/src/components/templates/page/page-layout.tsx b/src/components/templates/page/page-layout.tsx
index 24c4e50..ac021ba 100644
--- a/src/components/templates/page/page-layout.tsx
+++ b/src/components/templates/page/page-layout.tsx
@@ -133,9 +133,17 @@ const PageLayout: FC<PageLayoutProps> = ({
           )}
         </Sidebar>
       )}
-      <div ref={bodyRef} className={styles.body}>
-        {children}
-      </div>
+      {typeof children === 'string' ? (
+        <div
+          ref={bodyRef}
+          className={styles.body}
+          dangerouslySetInnerHTML={{ __html: children }}
+        />
+      ) : (
+        <div ref={bodyRef} className={styles.body}>
+          {children}
+        </div>
+      )}
       <PageFooter meta={footerMeta} className={styles.footer} />
       <Sidebar className={`${styles.sidebar} ${styles['sidebar--last']}`}>
         {widgets}
diff --git a/src/pages/contact.tsx b/src/pages/contact.tsx
index d88a8a1..fcfbe1d 100644
--- a/src/pages/contact.tsx
+++ b/src/pages/contact.tsx
@@ -41,7 +41,7 @@ const ContactPage: NextPage = () => {
   const { asPath } = useRouter();
   const pageUrl = `${website.url}${asPath}`;
   const pagePublicationDate = new Date(dates.publication);
-  const pageUpdateDate = new Date(dates.update);
+  const pageUpdateDate = dates.update ? new Date(dates.update) : undefined;
 
   const webpageSchema: WebPage = {
     '@id': `${pageUrl}`,
@@ -64,7 +64,7 @@ const ContactPage: NextPage = () => {
     author: { '@id': `${website.url}/#branding` },
     creator: { '@id': `${website.url}/#branding` },
     dateCreated: pagePublicationDate.toISOString(),
-    dateModified: pageUpdateDate.toISOString(),
+    dateModified: pageUpdateDate && pageUpdateDate.toISOString(),
     datePublished: pagePublicationDate.toISOString(),
     editor: { '@id': `${website.url}/#branding` },
     inLanguage: website.locales.default,
diff --git a/src/pages/cv.tsx b/src/pages/cv.tsx
index d47edc6..0e4765e 100644
--- a/src/pages/cv.tsx
+++ b/src/pages/cv.tsx
@@ -7,7 +7,6 @@ import PageLayout, {
 } from '@components/templates/page/page-layout';
 import CVContent, { data, meta } from '@content/pages/cv.mdx';
 import styles from '@styles/pages/cv.module.scss';
-import { getFormattedDate } from '@utils/helpers/dates';
 import { loadTranslation } from '@utils/helpers/i18n';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticProps, NextPage } from 'next';
@@ -46,24 +45,15 @@ const CVPage: NextPage = () => {
     id: '+Dre5J',
   });
 
-  const publicationLabel = intl.formatMessage({
-    defaultMessage: 'Published on:',
-    description: 'Meta: publication date label',
-    id: 'QGi5uD',
-  });
-
-  const updateLabel = intl.formatMessage({
-    defaultMessage: 'Updated on:',
-    description: 'Meta: update date label',
-    id: 'tLC7bh',
-  });
-
   const headerMeta: PageLayoutProps['headerMeta'] = {
     publication: {
-      name: publicationLabel,
-      value: getFormattedDate(dates.publication),
+      date: dates.publication,
     },
-    update: { name: updateLabel, value: getFormattedDate(dates.update) },
+    update: dates.update
+      ? {
+          date: dates.update,
+        }
+      : undefined,
   };
 
   const { website } = useSettings();
@@ -118,7 +108,7 @@ const CVPage: NextPage = () => {
   const { asPath } = useRouter();
   const pageUrl = `${website.url}${asPath}`;
   const pagePublicationDate = new Date(dates.publication);
-  const pageUpdateDate = new Date(dates.update);
+  const pageUpdateDate = dates.update ? new Date(dates.update) : undefined;
 
   const webpageSchema: WebPage = {
     '@id': `${pageUrl}`,
@@ -141,7 +131,7 @@ const CVPage: NextPage = () => {
     author: { '@id': `${website.url}/#branding` },
     creator: { '@id': `${website.url}/#branding` },
     dateCreated: pagePublicationDate.toISOString(),
-    dateModified: pageUpdateDate.toISOString(),
+    dateModified: pageUpdateDate && pageUpdateDate.toISOString(),
     datePublished: pagePublicationDate.toISOString(),
     editor: { '@id': `${website.url}/#branding` },
     image: image.src,
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index c965320..6108d2f 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -225,17 +225,7 @@ const HomePage: NextPage<HomeProps> = ({ recentPosts }) => {
       return {
         cover: post.cover,
         id: post.slug,
-        meta: [
-          {
-            id: 'publication',
-            term: intl.formatMessage({
-              defaultMessage: 'Published on:',
-              description: 'HomePage: publication date label',
-              id: 'pT5nHk',
-            }),
-            value: [post.dates.publication],
-          },
-        ],
+        meta: { publication: { date: post.dates.publication } },
         title: post.title,
         url: `/article/${post.slug}`,
       };
diff --git a/src/pages/mentions-legales.tsx b/src/pages/mentions-legales.tsx
index 8dd0a1d..9235e69 100644
--- a/src/pages/mentions-legales.tsx
+++ b/src/pages/mentions-legales.tsx
@@ -5,7 +5,6 @@ import PageLayout, {
   type PageLayoutProps,
 } from '@components/templates/page/page-layout';
 import LegalNoticeContent, { meta } from '@content/pages/legal-notice.mdx';
-import { getFormattedDate } from '@utils/helpers/dates';
 import { loadTranslation } from '@utils/helpers/i18n';
 import useSettings from '@utils/hooks/use-settings';
 import { NestedMDXComponents } from 'mdx/types';
@@ -32,24 +31,15 @@ const LegalNoticePage: NextPage = () => {
     { id: 'legal-notice', name: title, url: '/mentions-legales' },
   ];
 
-  const publicationLabel = intl.formatMessage({
-    defaultMessage: 'Published on:',
-    description: 'Meta: publication date label',
-    id: 'QGi5uD',
-  });
-
-  const updateLabel = intl.formatMessage({
-    defaultMessage: 'Updated on:',
-    description: 'Meta: update date label',
-    id: 'tLC7bh',
-  });
-
   const headerMeta: PageLayoutProps['headerMeta'] = {
     publication: {
-      name: publicationLabel,
-      value: getFormattedDate(dates.publication),
+      date: dates.publication,
     },
-    update: { name: updateLabel, value: getFormattedDate(dates.update) },
+    update: dates.update
+      ? {
+          date: dates.update,
+        }
+      : undefined,
   };
 
   const components: NestedMDXComponents = {
@@ -61,7 +51,7 @@ const LegalNoticePage: NextPage = () => {
   const { asPath } = useRouter();
   const pageUrl = `${website.url}${asPath}`;
   const pagePublicationDate = new Date(dates.publication);
-  const pageUpdateDate = new Date(dates.update);
+  const pageUpdateDate = dates.update ? new Date(dates.update) : undefined;
 
   const webpageSchema: WebPage = {
     '@id': `${pageUrl}`,
@@ -87,7 +77,7 @@ const LegalNoticePage: NextPage = () => {
     copyrightYear: pagePublicationDate.getFullYear(),
     creator: { '@id': `${website.url}/#branding` },
     dateCreated: pagePublicationDate.toISOString(),
-    dateModified: pageUpdateDate.toISOString(),
+    dateModified: pageUpdateDate && pageUpdateDate.toISOString(),
     datePublished: pagePublicationDate.toISOString(),
     editor: { '@id': `${website.url}/#branding` },
     headline: title,
diff --git a/src/utils/helpers/dates.ts b/src/utils/helpers/dates.ts
index fa167a7..cb56ad2 100644
--- a/src/utils/helpers/dates.ts
+++ b/src/utils/helpers/dates.ts
@@ -1,4 +1,3 @@
-import { Dates } from '@ts/types/app';
 import { settings } from '@utils/config';
 
 /**
@@ -39,17 +38,3 @@ export const getFormattedTime = (
 
   return locale === 'fr' ? formattedTime.replace(':', 'h') : formattedTime;
 };
-
-/**
- * Retrieve a Dates object.
- *
- * @param publication - The publication date.
- * @param update - The update date.
- * @returns {Dates} A Dates object.
- */
-export const getDates = (publication: string, update: string): Dates => {
-  return {
-    publication: getFormattedDate(publication),
-    update: getFormattedDate(update),
-  };
-};
-- 
cgit v1.2.3


From c5b516e2c933e77b2550fe6becebacb3fbdd30eb Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Wed, 11 May 2022 19:53:09 +0200
Subject: chore: add the Blog index page

---
 src/components/molecules/layout/meta.tsx           | 100 +++++++-----
 .../organisms/layout/posts-list.module.scss        |   5 +
 .../organisms/layout/posts-list.stories.tsx        |  67 ++++----
 .../organisms/layout/posts-list.test.tsx           |  60 ++++----
 src/components/organisms/layout/posts-list.tsx     |  36 ++++-
 .../organisms/layout/summary.stories.tsx           |  23 ++-
 src/components/organisms/layout/summary.test.tsx   |  16 +-
 src/components/organisms/layout/summary.tsx        |  64 ++++++--
 .../templates/page/page-layout.stories.tsx         |  62 +++-----
 src/pages/blog/index.tsx                           | 169 +++++++++++++++++++++
 src/styles/base/_typography.scss                   |  40 +----
 src/utils/hooks/use-reading-time.tsx               |  58 +++++++
 12 files changed, 489 insertions(+), 211 deletions(-)
 create mode 100644 src/pages/blog/index.tsx
 create mode 100644 src/utils/hooks/use-reading-time.tsx

(limited to 'src/components/templates/page/page-layout.stories.tsx')

diff --git a/src/components/molecules/layout/meta.tsx b/src/components/molecules/layout/meta.tsx
index 1401ac4..1d5e04b 100644
--- a/src/components/molecules/layout/meta.tsx
+++ b/src/components/molecules/layout/meta.tsx
@@ -12,6 +12,17 @@ export type CustomMeta = {
   value: ReactNode | ReactNode[];
 };
 
+export type MetaComments = {
+  /**
+   * The comments count.
+   */
+  count: number;
+  /**
+   * Wrap the comments count with a link to the given target.
+   */
+  target?: string;
+};
+
 export type MetaDate = {
   /**
    * A date string. Ex: `2022-04-30`.
@@ -35,7 +46,7 @@ export type MetaData = {
   /**
    * The comments count.
    */
-  commentsCount?: string | JSX.Element;
+  comments?: MetaComments;
   /**
    * The creation date.
    */
@@ -86,6 +97,8 @@ export type MetaData = {
   update?: MetaDate;
 };
 
+export type MetaKey = keyof MetaData;
+
 export type MetaProps = Omit<
   DescriptionListProps,
   'items' | 'withSeparator'
@@ -131,7 +144,7 @@ const Meta: FC<MetaProps> = ({
           id: 'OI0N37',
           description: 'Meta: author label',
         });
-      case 'commentsCount':
+      case 'comments':
         return intl.formatMessage({
           defaultMessage: 'Comments:',
           id: 'jTVIh8',
@@ -229,40 +242,48 @@ const Meta: FC<MetaProps> = ({
     }
 
     const isoDateTime = new Date(`${date}T${time}`).toISOString();
+    const dateString = intl.formatMessage(
+      {
+        defaultMessage: '{date} at {time}',
+        description: 'Meta: publication date and time',
+        id: 'fcHeyC',
+      },
+      {
+        date: getFormattedDate(dateTime.date),
+        time: getFormattedTime(`${dateTime.date}T${dateTime.time}`),
+      }
+    );
 
     return target ? (
       <Link href={target}>
-        <time dateTime={isoDateTime}>
-          {intl.formatMessage(
-            {
-              defaultMessage: '{date} at {time}',
-              description: 'Meta: publication date and time',
-              id: 'fcHeyC',
-            },
-            {
-              date: getFormattedDate(dateTime.date),
-              time: getFormattedTime(`${dateTime.date}T${dateTime.time}`),
-            }
-          )}
-        </time>
+        <time dateTime={isoDateTime}>{dateString}</time>
       </Link>
     ) : (
-      <time dateTime={isoDateTime}>
-        {intl.formatMessage(
-          {
-            defaultMessage: '{date} at {time}',
-            description: 'Meta: publication date and time',
-            id: 'fcHeyC',
-          },
-          {
-            date: getFormattedDate(dateTime.date),
-            time: getFormattedTime(`${dateTime.date}T${dateTime.time}`),
-          }
-        )}
-      </time>
+      <time dateTime={isoDateTime}>{dateString}</time>
     );
   };
 
+  /**
+   * Retrieve the formatted comments count.
+   *
+   * @param comments - The comments object.
+   * @returns {string | JSX.Element} - The comments count.
+   */
+  const getCommentsCount = (comments: MetaComments) => {
+    const { count, target } = comments;
+    const commentsCount = intl.formatMessage(
+      {
+        defaultMessage:
+          '{commentsCount, plural, =0 {No comments} one {# comment} other {# comments}}',
+        id: 'adTrj7',
+        description: 'Meta: comments count',
+      },
+      { commentsCount: count }
+    );
+
+    return target ? <Link href={target}>{commentsCount}</Link> : commentsCount;
+  };
+
   /**
    * Retrieve the formatted item value.
    *
@@ -270,14 +291,20 @@ const Meta: FC<MetaProps> = ({
    * @param {ValueOf<MetaData>} value - The meta value.
    * @returns {string|ReactNode|ReactNode[]} - The formatted value.
    */
-  const getValue = <T extends keyof MetaData>(
+  const getValue = <T extends MetaKey>(
     key: T,
     value: MetaData[T]
   ): string | ReactNode | ReactNode[] => {
-    if (key === 'creation' || key === 'publication' || key === 'update') {
-      return getDate(value as MetaDate);
+    switch (key) {
+      case 'comments':
+        return getCommentsCount(value as MetaComments);
+      case 'creation':
+      case 'publication':
+      case 'update':
+        return getDate(value as MetaDate);
+      default:
+        return value as string | ReactNode | ReactNode[];
     }
-    return value as string | ReactNode | ReactNode[];
   };
 
   /**
@@ -291,7 +318,7 @@ const Meta: FC<MetaProps> = ({
       .map(([key, value]) => {
         if (!key || !value) return;
 
-        const metaKey = key as keyof MetaData;
+        const metaKey = key as MetaKey;
 
         return {
           id: metaKey,
@@ -301,12 +328,9 @@ const Meta: FC<MetaProps> = ({
               : getLabel(metaKey),
           layout: itemsLayout,
           value:
-            metaKey === 'custom'
+            metaKey === 'custom' && (value as CustomMeta)
               ? (value as CustomMeta).value
-              : getValue(
-                  metaKey,
-                  value as string | string[] | JSX.Element | JSX.Element[]
-                ),
+              : getValue(metaKey, value),
         } as DescriptionListItem;
       })
       .filter((item): item is DescriptionListItem => !!item);
diff --git a/src/components/organisms/layout/posts-list.module.scss b/src/components/organisms/layout/posts-list.module.scss
index f072082..8021b2b 100644
--- a/src/components/organisms/layout/posts-list.module.scss
+++ b/src/components/organisms/layout/posts-list.module.scss
@@ -37,3 +37,8 @@
     }
   }
 }
+
+.btn {
+  display: flex;
+  margin: auto;
+}
diff --git a/src/components/organisms/layout/posts-list.stories.tsx b/src/components/organisms/layout/posts-list.stories.tsx
index d97ad03..de0478f 100644
--- a/src/components/organisms/layout/posts-list.stories.tsx
+++ b/src/components/organisms/layout/posts-list.stories.tsx
@@ -49,6 +49,16 @@ export default {
         required: false,
       },
     },
+    total: {
+      control: {
+        type: 'number',
+      },
+      description: 'The number of posts.',
+      type: {
+        name: 'number',
+        required: true,
+      },
+    },
   },
 } as ComponentMeta<typeof PostsList>;
 
@@ -56,23 +66,25 @@ const Template: ComponentStory<typeof PostsList> = (args) => (
   <PostsList {...args} />
 );
 
+const excerpt1 =
+  'Esse et voluptas sapiente modi impedit unde et. Ducimus nulla ea impedit sit placeat nihil assumenda. Rem est fugiat amet quo hic. Corrupti fuga quod animi autem dolorem ullam corrupti vel aut.';
+const excerpt2 =
+  'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.';
+const excerpt3 =
+  'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.';
+
 const posts: Post[] = [
   {
-    excerpt:
-      'Esse et voluptas sapiente modi impedit unde et. Ducimus nulla ea impedit sit placeat nihil assumenda. Rem est fugiat amet quo hic. Corrupti fuga quod animi autem dolorem ullam corrupti vel aut.',
+    excerpt: excerpt1,
     id: 'post-1',
     meta: {
-      publication: { date: '2022-02-26' },
-      readingTime: '5 minutes',
+      dates: { publication: '2022-02-26' },
+      readingTime: { wordsCount: excerpt1.split(' ').length },
       thematics: [
-        <a key="cat-1" href="#">
-          Cat 1
-        </a>,
-        <a key="cat-2" href="#">
-          Cat 2
-        </a>,
+        { id: 'cat-1', name: 'Cat 1', url: '#' },
+        { id: 'cat-2', name: 'Cat 2', url: '#' },
       ],
-      commentsCount: '1 comment',
+      commentsCount: 1,
     },
     title: 'Ratione velit fuga',
     url: '#',
@@ -86,35 +98,25 @@ const posts: Post[] = [
     },
   },
   {
-    excerpt:
-      'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.',
+    excerpt: excerpt2,
     id: 'post-2',
     meta: {
-      publication: { date: '2022-02-20' },
-      readingTime: '8 minutes',
-      thematics: [
-        <a key="cat-2" href="#">
-          Cat 2
-        </a>,
-      ],
-      commentsCount: '0 comments',
+      dates: { publication: '2022-02-20' },
+      readingTime: { wordsCount: excerpt2.split(' ').length },
+      thematics: [{ id: 'cat-2', name: 'Cat 2', url: '#' }],
+      commentsCount: 0,
     },
     title: 'Debitis laudantium laudantium',
     url: '#',
   },
   {
-    excerpt:
-      'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.',
+    excerpt: excerpt3,
     id: 'post-3',
     meta: {
-      publication: { date: '2021-12-20' },
-      readingTime: '3 minutes',
-      thematics: [
-        <a key="cat-1" href="#">
-          Cat 1
-        </a>,
-      ],
-      commentsCount: '3 comments',
+      dates: { publication: '2021-12-20' },
+      readingTime: { wordsCount: excerpt3.split(' ').length },
+      thematics: [{ id: 'cat-1', name: 'Cat 1', url: '#' }],
+      commentsCount: 3,
     },
     title: 'Quaerat ut corporis',
     url: '#',
@@ -135,6 +137,7 @@ const posts: Post[] = [
 export const Default = Template.bind({});
 Default.args = {
   posts,
+  total: posts.length,
 };
 
 /**
@@ -144,6 +147,7 @@ export const ByYears = Template.bind({});
 ByYears.args = {
   posts,
   byYear: true,
+  total: posts.length,
 };
 ByYears.decorators = [
   (Story) => (
@@ -159,4 +163,5 @@ ByYears.decorators = [
 export const NoResults = Template.bind({});
 NoResults.args = {
   posts: [],
+  total: posts.length,
 };
diff --git a/src/components/organisms/layout/posts-list.test.tsx b/src/components/organisms/layout/posts-list.test.tsx
index 98af1c3..9b226ac 100644
--- a/src/components/organisms/layout/posts-list.test.tsx
+++ b/src/components/organisms/layout/posts-list.test.tsx
@@ -1,23 +1,25 @@
 import { render, screen } from '@test-utils';
 import PostsList, { Post } from './posts-list';
 
+const excerpt1 =
+  'Esse et voluptas sapiente modi impedit unde et. Ducimus nulla ea impedit sit placeat nihil assumenda. Rem est fugiat amet quo hic. Corrupti fuga quod animi autem dolorem ullam corrupti vel aut.';
+const excerpt2 =
+  'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.';
+const excerpt3 =
+  'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.';
+
 const posts: Post[] = [
   {
-    excerpt:
-      'Esse et voluptas sapiente modi impedit unde et. Ducimus nulla ea impedit sit placeat nihil assumenda. Rem est fugiat amet quo hic. Corrupti fuga quod animi autem dolorem ullam corrupti vel aut.',
+    excerpt: excerpt1,
     id: 'post-1',
     meta: {
-      publication: { date: '2022-02-26' },
-      readingTime: '5 minutes',
+      dates: { publication: '2022-02-26' },
+      readingTime: { wordsCount: excerpt1.split(' ').length },
       thematics: [
-        <a key="cat-1" href="#">
-          Cat 1
-        </a>,
-        <a key="cat-2" href="#">
-          Cat 2
-        </a>,
+        { id: 'cat-1', name: 'Cat 1', url: '#' },
+        { id: 'cat-2', name: 'Cat 2', url: '#' },
       ],
-      commentsCount: '1 comment',
+      commentsCount: 1,
     },
     title: 'Ratione velit fuga',
     url: '#',
@@ -26,38 +28,30 @@ const posts: Post[] = [
       height: 480,
       src: 'http://placeimg.com/640/480',
       width: 640,
+      // @ts-ignore - Needed because of the placeholder image.
+      unoptimized: true,
     },
   },
   {
-    excerpt:
-      'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.',
+    excerpt: excerpt2,
     id: 'post-2',
     meta: {
-      publication: { date: '2022-02-20' },
-      readingTime: '8 minutes',
-      thematics: [
-        <a key="cat-2" href="#">
-          Cat 2
-        </a>,
-      ],
-      commentsCount: '0 comments',
+      dates: { publication: '2022-02-20' },
+      readingTime: { wordsCount: excerpt2.split(' ').length },
+      thematics: [{ id: 'cat-2', name: 'Cat 2', url: '#' }],
+      commentsCount: 0,
     },
     title: 'Debitis laudantium laudantium',
     url: '#',
   },
   {
-    excerpt:
-      'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.',
+    excerpt: excerpt3,
     id: 'post-3',
     meta: {
-      publication: { date: '2021-12-20' },
-      readingTime: '3 minutes',
-      thematics: [
-        <a key="cat-1" href="#">
-          Cat 1
-        </a>,
-      ],
-      commentsCount: '3 comments',
+      dates: { publication: '2021-12-20' },
+      readingTime: { wordsCount: excerpt3.split(' ').length },
+      thematics: [{ id: 'cat-1', name: 'Cat 1', url: '#' }],
+      commentsCount: 3,
     },
     title: 'Quaerat ut corporis',
     url: '#',
@@ -66,13 +60,15 @@ const posts: Post[] = [
       height: 480,
       src: 'http://placeimg.com/640/480',
       width: 640,
+      // @ts-ignore - Needed because of the placeholder image.
+      unoptimized: true,
     },
   },
 ];
 
 describe('PostsList', () => {
   it('renders the correct number of posts', () => {
-    render(<PostsList posts={posts} />);
+    render(<PostsList posts={posts} total={posts.length} />);
     expect(screen.getAllByRole('article')).toHaveLength(posts.length);
   });
 });
diff --git a/src/components/organisms/layout/posts-list.tsx b/src/components/organisms/layout/posts-list.tsx
index 4855205..daf4491 100644
--- a/src/components/organisms/layout/posts-list.tsx
+++ b/src/components/organisms/layout/posts-list.tsx
@@ -3,6 +3,8 @@ import { FC } from 'react';
 import { useIntl } from 'react-intl';
 import Summary, { type SummaryProps } from './summary';
 import styles from './posts-list.module.scss';
+import ProgressBar from '@components/atoms/loaders/progress-bar';
+import Button from '@components/atoms/buttons/button';
 
 export type Post = SummaryProps & {
   /**
@@ -28,6 +30,10 @@ export type PostsListProps = {
    * The posts heading level (hn).
    */
   titleLevel?: HeadingLevel;
+  /**
+   * The total posts number.
+   */
+  total: number;
 };
 
 /**
@@ -40,7 +46,7 @@ const sortPostsByYear = (data: Post[]): YearCollection => {
   const yearCollection: YearCollection = {};
 
   data.forEach((post) => {
-    const postYear = new Date(post.meta.publication!.date)
+    const postYear = new Date(post.meta.dates.publication)
       .getFullYear()
       .toString();
     yearCollection[postYear] = [...(yearCollection[postYear] || []), post];
@@ -58,6 +64,7 @@ const PostsList: FC<PostsListProps> = ({
   byYear = false,
   posts,
   titleLevel,
+  total,
 }) => {
   const intl = useIntl();
 
@@ -106,6 +113,22 @@ const PostsList: FC<PostsListProps> = ({
     });
   };
 
+  const progressInfo = intl.formatMessage(
+    {
+      defaultMessage:
+        '{articlesCount, plural, =0 {# loaded articles} one {# loaded article} other {# loaded articles}} out of a total of {total}',
+      description: 'PostsList: loaded articles progress',
+      id: '9MeLN3',
+    },
+    { articlesCount: posts.length, total: total }
+  );
+
+  const loadMore = intl.formatMessage({
+    defaultMessage: 'Load more articles?',
+    id: 'uaqd5F',
+    description: 'PostsList: load more button',
+  });
+
   return posts.length === 0 ? (
     <p>
       {intl.formatMessage({
@@ -115,7 +138,16 @@ const PostsList: FC<PostsListProps> = ({
       })}
     </p>
   ) : (
-    <>{getPosts()}</>
+    <>
+      {getPosts()}
+      <ProgressBar
+        min={1}
+        max={total}
+        current={posts.length}
+        info={progressInfo}
+      />
+      <Button className={styles.btn}>{loadMore}</Button>
+    </>
   );
 };
 
diff --git a/src/components/organisms/layout/summary.stories.tsx b/src/components/organisms/layout/summary.stories.tsx
index 42f1d44..92736b8 100644
--- a/src/components/organisms/layout/summary.stories.tsx
+++ b/src/components/organisms/layout/summary.stories.tsx
@@ -91,18 +91,17 @@ const cover = {
   unoptimized: true,
 };
 
+const excerpt =
+  'Perspiciatis quasi libero nemo non eligendi nam minima. Deleniti expedita tempore. Praesentium explicabo molestiae eaque consectetur vero. Quae nostrum quisquam similique. Ut hic est quas ut esse quisquam nobis.';
+
 const meta = {
-  publication: { date: '2022-04-11' },
-  readingTime: '5 minutes',
+  dates: { publication: '2022-04-11' },
+  readingTime: { wordsCount: excerpt.split(' ').length },
   thematics: [
-    <a key="cat-1" href="#">
-      Cat 1
-    </a>,
-    <a key="cat-2" href="#">
-      Cat 2
-    </a>,
+    { id: 'cat-1', name: 'Cat 1', url: '#' },
+    { id: 'cat-2', name: 'Cat 2', url: '#' },
   ],
-  commentsCount: '1 comment',
+  commentsCount: 1,
 };
 
 /**
@@ -110,8 +109,7 @@ const meta = {
  */
 export const Default = Template.bind({});
 Default.args = {
-  excerpt:
-    'Perspiciatis quasi libero nemo non eligendi nam minima. Deleniti expedita tempore. Praesentium explicabo molestiae eaque consectetur vero. Quae nostrum quisquam similique. Ut hic est quas ut esse quisquam nobis.',
+  excerpt,
   meta,
   title: 'Odio odit necessitatibus',
   url: '#',
@@ -123,8 +121,7 @@ Default.args = {
 export const WithCover = Template.bind({});
 WithCover.args = {
   cover,
-  excerpt:
-    'Perspiciatis quasi libero nemo non eligendi nam minima. Deleniti expedita tempore. Praesentium explicabo molestiae eaque consectetur vero. Quae nostrum quisquam similique. Ut hic est quas ut esse quisquam nobis.',
+  excerpt,
   meta,
   title: 'Odio odit necessitatibus',
   url: '#',
diff --git a/src/components/organisms/layout/summary.test.tsx b/src/components/organisms/layout/summary.test.tsx
index 09b797c..9e34254 100644
--- a/src/components/organisms/layout/summary.test.tsx
+++ b/src/components/organisms/layout/summary.test.tsx
@@ -12,17 +12,13 @@ const excerpt =
   'Perspiciatis quasi libero nemo non eligendi nam minima. Deleniti expedita tempore. Praesentium explicabo molestiae eaque consectetur vero. Quae nostrum quisquam similique. Ut hic est quas ut esse quisquam nobis.';
 
 const meta = {
-  publication: { date: '2022-04-11' },
-  readingTime: '5 minutes',
+  dates: { publication: '2022-04-11' },
+  readingTime: { wordsCount: excerpt.split(' ').length },
   thematics: [
-    <a key="cat-1" href="#">
-      Cat 1
-    </a>,
-    <a key="cat-2" href="#">
-      Cat 2
-    </a>,
+    { id: 'cat-1', name: 'Cat 1', url: '#' },
+    { id: 'cat-2', name: 'Cat 2', url: '#' },
   ],
-  commentsCount: '1 comment',
+  commentsCount: 1,
 };
 
 const title = 'Odio odit necessitatibus';
@@ -77,6 +73,6 @@ describe('Summary', () => {
 
   it('renders some meta', () => {
     render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />);
-    expect(screen.getByText(meta.readingTime)).toBeInTheDocument();
+    expect(screen.getByText(meta.thematics[0].name)).toBeInTheDocument();
   });
 });
diff --git a/src/components/organisms/layout/summary.tsx b/src/components/organisms/layout/summary.tsx
index 8b47833..1c4a38b 100644
--- a/src/components/organisms/layout/summary.tsx
+++ b/src/components/organisms/layout/summary.tsx
@@ -5,7 +5,9 @@ import Link from '@components/atoms/links/link';
 import ResponsiveImage, {
   type ResponsiveImageProps,
 } from '@components/molecules/images/responsive-image';
-import Meta, { MetaData } from '@components/molecules/layout/meta';
+import Meta, { type MetaData } from '@components/molecules/layout/meta';
+import { type Dates } from '@ts/types/app';
+import useReadingTime from '@utils/hooks/use-reading-time';
 import { FC, ReactNode } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './summary.module.scss';
@@ -15,15 +17,25 @@ export type Cover = Pick<
   'alt' | 'src' | 'width' | 'height'
 >;
 
-export type SummaryMeta = Pick<
-  MetaData,
-  | 'author'
-  | 'commentsCount'
-  | 'publication'
-  | 'readingTime'
-  | 'thematics'
-  | 'update'
->;
+export type SummaryMetaLink = {
+  id: number | string;
+  name: string;
+  url: string;
+};
+
+export type SummaryMetaReadingTime = {
+  wordsCount: number;
+  onlyMinutes?: boolean;
+};
+
+export type SummaryMeta = {
+  author?: string;
+  commentsCount?: number;
+  dates: Dates;
+  readingTime: SummaryMetaReadingTime;
+  thematics?: SummaryMetaLink[];
+  topics?: SummaryMetaLink[];
+};
 
 export type SummaryProps = {
   /**
@@ -79,6 +91,36 @@ const Summary: FC<SummaryProps> = ({
       ),
     }
   );
+  const { wordsCount, onlyMinutes } = meta.readingTime;
+  const readingTime = useReadingTime(wordsCount, onlyMinutes);
+
+  const getMeta = (data: SummaryMeta): MetaData => {
+    const { author, commentsCount, dates, thematics, topics } = data;
+
+    return {
+      author,
+      publication: { date: dates.publication },
+      update:
+        dates.update && dates.publication !== dates.update
+          ? { date: dates.update }
+          : undefined,
+      readingTime,
+      thematics: thematics?.map((thematic) => (
+        <Link key={thematic.id} href={thematic.url}>
+          {thematic.name}
+        </Link>
+      )),
+      topics: topics?.map((topic) => (
+        <Link key={topic.id} href={topic.url}>
+          {topic.name}
+        </Link>
+      )),
+      comments: {
+        count: commentsCount || 0,
+        target: `${url}#comments`,
+      },
+    };
+  };
 
   return (
     <article className={styles.wrapper}>
@@ -104,7 +146,7 @@ const Summary: FC<SummaryProps> = ({
         </ButtonLink>
       </div>
       <footer className={styles.footer}>
-        <Meta data={meta} layout="column" className={styles.meta} />
+        <Meta data={getMeta(meta)} layout="column" className={styles.meta} />
       </footer>
     </article>
   );
diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx
index b4b0d68..480c76e 100644
--- a/src/components/templates/page/page-layout.stories.tsx
+++ b/src/components/templates/page/page-layout.stories.tsx
@@ -379,23 +379,25 @@ const postsListBreadcrumb = [
   { id: 'blog', url: '#', name: 'Blog' },
 ];
 
+const excerpt1 =
+  'Esse et voluptas sapiente modi impedit unde et. Ducimus nulla ea impedit sit placeat nihil assumenda. Rem est fugiat amet quo hic. Corrupti fuga quod animi autem dolorem ullam corrupti vel aut.';
+const excerpt2 =
+  'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.';
+const excerpt3 =
+  'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.';
+
 const posts = [
   {
-    excerpt:
-      'Esse et voluptas sapiente modi impedit unde et. Ducimus nulla ea impedit sit placeat nihil assumenda. Rem est fugiat amet quo hic. Corrupti fuga quod animi autem dolorem ullam corrupti vel aut.',
+    excerpt: excerpt1,
     id: 'post-1',
     meta: {
-      publication: { date: '2022-02-26' },
-      readingTime: '5 minutes',
-      categories: [
-        <a key="cat-1" href="#">
-          Cat 1
-        </a>,
-        <a key="cat-2" href="#">
-          Cat 2
-        </a>,
+      dates: { publication: '2022-02-26' },
+      readingTime: { wordsCount: excerpt1.split(' ').length },
+      thematics: [
+        { id: 'cat-1', name: 'Cat 1', url: '#' },
+        { id: 'cat-2', name: 'Cat 2', url: '#' },
       ],
-      commentsCount: '1 comment',
+      commentsCount: 1,
     },
     title: 'Ratione velit fuga',
     url: '#',
@@ -409,39 +411,25 @@ const posts = [
     },
   },
   {
-    excerpt:
-      'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.',
+    excerpt: excerpt2,
     id: 'post-2',
     meta: {
-      publication: {
-        date: '2022-02-20',
-      },
-      readingTime: '8 minutes',
-      categories: [
-        <a key="cat-2" href="#">
-          Cat 2
-        </a>,
-      ],
-      comments: '0 comments',
+      dates: { publication: '2022-02-20' },
+      readingTime: { wordsCount: excerpt2.split(' ').length },
+      thematics: [{ id: 'cat-2', name: 'Cat 2', url: '#' }],
+      commentsCount: 0,
     },
     title: 'Debitis laudantium laudantium',
     url: '#',
   },
   {
-    excerpt:
-      'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.',
+    excerpt: excerpt3,
     id: 'post-3',
     meta: {
-      publication: {
-        date: '2021-12-20',
-      },
-      readingTime: '3 minutes',
-      categories: [
-        <a key="cat-1" href="#">
-          Cat 1
-        </a>,
-      ],
-      comments: '3 comments',
+      dates: { publication: '2021-12-20' },
+      readingTime: { wordsCount: excerpt3.split(' ').length },
+      thematics: [{ id: 'cat-1', name: 'Cat 1', url: '#' }],
+      commentsCount: 3,
     },
     title: 'Quaerat ut corporis',
     url: '#',
@@ -476,7 +464,7 @@ Blog.args = {
   headerMeta: { total: `${posts.length} posts` },
   children: (
     <>
-      <PostsList posts={posts} byYear={true} />
+      <PostsList posts={posts} byYear={true} total={posts.length} />
       <ProgressBar min={1} max={1} current={1} info="1/1 page loaded." />
     </>
   ),
diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx
new file mode 100644
index 0000000..dc72388
--- /dev/null
+++ b/src/pages/blog/index.tsx
@@ -0,0 +1,169 @@
+import ProgressBar from '@components/atoms/loaders/progress-bar';
+import { BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
+import PostsList, { Post } from '@components/organisms/layout/posts-list';
+import PageLayout from '@components/templates/page/page-layout';
+import { getArticles, getTotalArticles } from '@services/graphql/articles';
+import { Article, Meta } from '@ts/types/app';
+import { settings } from '@utils/config';
+import { loadTranslation, Messages } from '@utils/helpers/i18n';
+import useSettings from '@utils/hooks/use-settings';
+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 { Blog, Graph, WebPage } from 'schema-dts';
+
+type BlogPageProps = {
+  posts: Article[];
+  totalPosts: number;
+  translation: Messages;
+};
+
+/**
+ * Blog index page.
+ */
+const BlogPage: NextPage<BlogPageProps> = ({ posts, totalPosts }) => {
+  const intl = useIntl();
+  const title = intl.formatMessage({
+    defaultMessage: 'Blog',
+    description: 'BlogPage: page title',
+    id: '7TbbIk',
+  });
+  const homeLabel = intl.formatMessage({
+    defaultMessage: 'Home',
+    description: 'Breadcrumb: home label',
+    id: 'j5k9Fe',
+  });
+  const breadcrumb: BreadcrumbItem[] = [
+    { id: 'home', name: homeLabel, url: '/' },
+    { id: 'blog', name: title, url: '/blog' },
+  ];
+
+  const { website } = useSettings();
+  const { asPath } = useRouter();
+  const pageTitle = intl.formatMessage(
+    {
+      defaultMessage: 'Blog: development, open source - {websiteName}',
+      description: 'BlogPage: SEO - Page title',
+      id: '+Y+tLK',
+    },
+    { websiteName: website.name }
+  );
+  const pageDescription = intl.formatMessage(
+    {
+      defaultMessage:
+        "Discover {websiteName}'s writings. He talks about web development, Linux and open source mostly.",
+      description: 'BlogPage: SEO - Meta description',
+      id: '18h/t0',
+    },
+    { websiteName: website.name }
+  );
+  const pageUrl = `${website.url}${asPath}`;
+
+  const webpageSchema: WebPage = {
+    '@id': `${pageUrl}`,
+    '@type': 'WebPage',
+    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
+    name: pageTitle,
+    description: pageDescription,
+    inLanguage: website.locales.default,
+    reviewedBy: { '@id': `${website.url}/#branding` },
+    url: `${website.url}`,
+    isPartOf: {
+      '@id': `${website.url}`,
+    },
+  };
+
+  const blogSchema: Blog = {
+    '@id': `${website.url}/#blog`,
+    '@type': 'Blog',
+    author: { '@id': `${website.url}/#branding` },
+    creator: { '@id': `${website.url}/#branding` },
+    editor: { '@id': `${website.url}/#branding` },
+    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, blogSchema],
+  };
+
+  const postsCount = intl.formatMessage(
+    {
+      defaultMessage:
+        '{postsCount, plural, =0 {No articles} one {# article} other {# articles}}',
+      id: 'OF5cPz',
+      description: 'BlogPage: posts count meta',
+    },
+    { postsCount: totalPosts }
+  );
+
+  const getPostMeta = (data: Meta<'article'>): Post['meta'] => {
+    const { commentsCount, dates, thematics, wordsCount } = data;
+
+    return {
+      commentsCount,
+      dates,
+      readingTime: { wordsCount: wordsCount || 0, onlyMinutes: true },
+      thematics: thematics?.map((thematic) => {
+        return { ...thematic, url: `/thematique/${thematic.slug}` };
+      }),
+    };
+  };
+
+  const getPosts = (): Post[] => {
+    return posts.map((post) => {
+      return {
+        ...post,
+        cover: post.meta.cover,
+        excerpt: post.intro,
+        meta: getPostMeta(post.meta),
+        url: `/article/${post.slug}`,
+      };
+    });
+  };
+
+  return (
+    <>
+      <Head>
+        <title>{pageTitle}</title>
+        <meta name="description" content={pageDescription} />
+        <meta property="og:url" content={`${pageUrl}`} />
+        <meta property="og:type" content="website" />
+        <meta property="og:title" content={title} />
+        <meta property="og:description" content={pageDescription} />
+      </Head>
+      <Script
+        id="schema-blog"
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
+      />
+      <PageLayout
+        title={title}
+        breadcrumb={breadcrumb}
+        headerMeta={{ total: postsCount }}
+      >
+        <PostsList posts={getPosts()} byYear={true} total={totalPosts} />
+      </PageLayout>
+    </>
+  );
+};
+
+export const getStaticProps: GetStaticProps = async ({ locale }) => {
+  const posts = await getArticles({ first: settings.postsPerPage });
+  const totalPosts = await getTotalArticles();
+  const translation = await loadTranslation(locale);
+
+  return {
+    props: {
+      posts: JSON.parse(JSON.stringify(posts.articles)),
+      totalPosts,
+      translation,
+    },
+  };
+};
+
+export default BlogPage;
diff --git a/src/styles/base/_typography.scss b/src/styles/base/_typography.scss
index f1cb38a..7b7a695 100644
--- a/src/styles/base/_typography.scss
+++ b/src/styles/base/_typography.scss
@@ -1,5 +1,4 @@
 @use "@styles/abstracts/functions" as fun;
-@use "@styles/abstracts/variables" as var;
 
 h1 {
   font-size: var(--font-size-3xl);
@@ -116,7 +115,7 @@ dl {
 ul,
 ol,
 dl {
-  margin: var(--spacing-md) 0;
+  margin: var(--spacing-sm) 0;
 
   & & {
     margin: var(--spacing-2xs) 0 0;
@@ -137,13 +136,13 @@ a {
   background: linear-gradient(to top, var(--color-primary) 50%, transparent 50%)
     0 0 / 100% 200% no-repeat;
   color: var(--color-primary);
-  text-decoration-thickness: 13%;
+  text-decoration-thickness: 0.15em;
   text-underline-offset: 20%;
   transition: all 0.3s linear 0s, text-decoration 0.18s ease-in-out 0s;
 
   &:hover {
     color: var(--color-primary-light);
-    text-decoration-thickness: 23%;
+    text-decoration-thickness: 0.25em;
   }
 
   &:focus {
@@ -156,39 +155,6 @@ a {
     color: var(--color-primary-dark);
     text-decoration-thickness: 18%;
   }
-
-  &.external {
-    &::after {
-      display: inline-block;
-      content: "\0000a0"url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_blue}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
-    }
-
-    &:focus:not(:active)::after {
-      content: "\0000a0"url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_white}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_white}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
-    }
-  }
-
-  &[hreflang] {
-    &::after {
-      display: inline-block;
-      content: "\0000a0["attr(hreflang) "]";
-      font-size: var(--font-size-sm);
-    }
-
-    &.external {
-      &::after {
-        content: "\0000a0["attr(hreflang) "]\0000a0"url(fun.encode-svg(
-            '<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_blue}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'
-          ));
-      }
-
-      &:focus:not(:active)::after {
-        content: "\0000a0["attr(hreflang) "]\0000a0"url(fun.encode-svg(
-            '<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_white}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_white}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'
-          ));
-      }
-    }
-  }
 }
 
 button,
diff --git a/src/utils/hooks/use-reading-time.tsx b/src/utils/hooks/use-reading-time.tsx
new file mode 100644
index 0000000..fb54135
--- /dev/null
+++ b/src/utils/hooks/use-reading-time.tsx
@@ -0,0 +1,58 @@
+import { useIntl } from 'react-intl';
+
+/**
+ * Retrieve the estimated reading time by words count.
+ *
+ * @param {number} wordsCount - The number of words.
+ * @returns {string} The estimated reading time.
+ */
+const useReadingTime = (
+  wordsCount: number,
+  onlyMinutes: boolean = false
+): string => {
+  const intl = useIntl();
+  const wordsPerMinute = 245;
+  const wordsPerSecond = wordsPerMinute / 60;
+  const estimatedTimeInSeconds = wordsCount / wordsPerSecond;
+
+  if (onlyMinutes) {
+    const estimatedTimeInMinutes = Math.round(estimatedTimeInSeconds / 60);
+
+    return intl.formatMessage(
+      {
+        defaultMessage: '{minutesCount} minutes',
+        description: 'useReadingTime: rounded minutes count',
+        id: 's1i43J',
+      },
+      { minutesCount: estimatedTimeInMinutes }
+    );
+  } else {
+    const estimatedTimeInMinutes = Math.floor(estimatedTimeInSeconds / 60);
+
+    if (estimatedTimeInMinutes <= 0) {
+      return intl.formatMessage(
+        {
+          defaultMessage: '{count} seconds',
+          description: 'useReadingTime: seconds count',
+          id: 'i7Wq3G',
+        },
+        { count: estimatedTimeInSeconds.toFixed(0) }
+      );
+    }
+
+    const remainingSeconds = Math.round(
+      estimatedTimeInSeconds - estimatedTimeInMinutes * 60
+    ).toFixed(0);
+
+    return intl.formatMessage(
+      {
+        defaultMessage: '{minutesCount} minutes {secondsCount} seconds',
+        description: 'useReadingTime: minutes + seconds count',
+        id: 'OevMeU',
+      },
+      { minutesCount: estimatedTimeInMinutes, secondsCount: remainingSeconds }
+    );
+  }
+};
+
+export default useReadingTime;
-- 
cgit v1.2.3


From c95cce04393080a52a54191cff7be8fec68af4b0 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Sun, 15 May 2022 17:45:41 +0200
Subject: chore: add Article pages

---
 src/components/atoms/layout/notice.module.scss     |   1 -
 .../organisms/forms/comment-form.stories.tsx       |  30 +--
 .../organisms/forms/comment-form.test.tsx          |   7 +-
 src/components/organisms/forms/comment-form.tsx    |  19 +-
 .../organisms/layout/comment.stories.tsx           |  39 +++-
 src/components/organisms/layout/comment.test.tsx   |   6 +-
 src/components/organisms/layout/comment.tsx        |  14 +-
 .../organisms/layout/comments-list.stories.tsx     |  30 ++-
 .../organisms/layout/comments-list.test.tsx        |   6 +-
 src/components/organisms/layout/comments-list.tsx  |  10 +-
 .../templates/page/page-layout.stories.tsx         |   6 +-
 src/components/templates/page/page-layout.tsx      |  95 +++++++-
 src/pages/article/[slug].tsx                       | 251 +++++++++++++++++++++
 src/services/graphql/api.ts                        |  52 ++++-
 src/services/graphql/comments.mutation.ts          |  30 +++
 src/services/graphql/comments.ts                   | 102 +++++++++
 16 files changed, 634 insertions(+), 64 deletions(-)
 create mode 100644 src/pages/article/[slug].tsx
 create mode 100644 src/services/graphql/comments.mutation.ts
 create mode 100644 src/services/graphql/comments.ts

(limited to 'src/components/templates/page/page-layout.stories.tsx')

diff --git a/src/components/atoms/layout/notice.module.scss b/src/components/atoms/layout/notice.module.scss
index 38ec7ee..7fd972c 100644
--- a/src/components/atoms/layout/notice.module.scss
+++ b/src/components/atoms/layout/notice.module.scss
@@ -1,7 +1,6 @@
 @use "@styles/abstracts/functions" as fun;
 
 .wrapper {
-  width: max-content;
   padding: var(--spacing-2xs) var(--spacing-xs);
   border: fun.convert-px(2) solid;
   font-weight: bold;
diff --git a/src/components/organisms/forms/comment-form.stories.tsx b/src/components/organisms/forms/comment-form.stories.tsx
index f66d35c..8b11df7 100644
--- a/src/components/organisms/forms/comment-form.stories.tsx
+++ b/src/components/organisms/forms/comment-form.stories.tsx
@@ -1,13 +1,19 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import CommentForm from './comment-form';
 
+const saveComment = async () => {
+  /** Do nothing. */
+};
+
 /**
  * CommentForm - Storybook Meta
  */
 export default {
   title: 'Organisms/Forms',
   component: CommentForm,
+  args: {
+    saveComment,
+  },
   argTypes: {
     className: {
       control: {
@@ -35,6 +41,16 @@ export default {
         required: false,
       },
     },
+    parentId: {
+      control: {
+        type: null,
+      },
+      description: 'The parent id if it is a reply.',
+      type: {
+        name: 'number',
+        required: false,
+      },
+    },
     saveComment: {
       control: {
         type: null,
@@ -74,13 +90,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof CommentForm>;
 
 const Template: ComponentStory<typeof CommentForm> = (args) => (
@@ -91,8 +100,3 @@ const Template: ComponentStory<typeof CommentForm> = (args) => (
  * Forms Stories - Comment
  */
 export const Comment = Template.bind({});
-Comment.args = {
-  saveComment: (reset: () => void) => {
-    reset();
-  },
-};
diff --git a/src/components/organisms/forms/comment-form.test.tsx b/src/components/organisms/forms/comment-form.test.tsx
index 0d387b5..c67ad6b 100644
--- a/src/components/organisms/forms/comment-form.test.tsx
+++ b/src/components/organisms/forms/comment-form.test.tsx
@@ -1,17 +1,20 @@
 import { render, screen } from '@test-utils';
 import CommentForm from './comment-form';
 
+const saveComment = async () => {
+  /** Do nothing. */
+};
 const title = 'Cum voluptas voluptatibus';
 
 describe('CommentForm', () => {
   it('renders a form', () => {
-    render(<CommentForm saveComment={() => null} />);
+    render(<CommentForm saveComment={saveComment} />);
     expect(screen.getByRole('form')).toBeInTheDocument();
   });
 
   it('renders an optional title', () => {
     render(
-      <CommentForm saveComment={() => null} title={title} titleLevel={2} />
+      <CommentForm saveComment={saveComment} title={title} titleLevel={2} />
     );
     expect(
       screen.getByRole('heading', { level: 2, name: title })
diff --git a/src/components/organisms/forms/comment-form.tsx b/src/components/organisms/forms/comment-form.tsx
index d7cb0f5..9e0abdf 100644
--- a/src/components/organisms/forms/comment-form.tsx
+++ b/src/components/organisms/forms/comment-form.tsx
@@ -7,6 +7,14 @@ import { FC, ReactNode, useState } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './comment-form.module.scss';
 
+export type CommentFormData = {
+  comment: string;
+  email: string;
+  name: string;
+  parentId?: number;
+  website?: string;
+};
+
 export type CommentFormProps = {
   /**
    * Set additional classnames to the form wrapper.
@@ -16,11 +24,15 @@ export type CommentFormProps = {
    * Pass a component to print a success/error message.
    */
   Notice?: ReactNode;
+  /**
+   * The comment parent id.
+   */
+  parentId?: number;
   /**
    * A callback function to save comment. It takes a function as parameter to
    * reset the form.
    */
-  saveComment: (reset: () => void) => void;
+  saveComment: (data: CommentFormData, reset: () => void) => Promise<void>;
   /**
    * The form title.
    */
@@ -34,6 +46,7 @@ export type CommentFormProps = {
 const CommentForm: FC<CommentFormProps> = ({
   className = '',
   Notice,
+  parentId,
   saveComment,
   title,
   titleLevel = 2,
@@ -95,7 +108,9 @@ const CommentForm: FC<CommentFormProps> = ({
    */
   const submitHandler = () => {
     setIsSubmitting(true);
-    saveComment(resetForm);
+    saveComment({ comment, email, name, parentId, website }, resetForm).then(
+      () => setIsSubmitting(false)
+    );
   };
 
   return (
diff --git a/src/components/organisms/layout/comment.stories.tsx b/src/components/organisms/layout/comment.stories.tsx
index 3794b06..c31b77a 100644
--- a/src/components/organisms/layout/comment.stories.tsx
+++ b/src/components/organisms/layout/comment.stories.tsx
@@ -1,13 +1,19 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import CommentComponent from './comment';
 
+const saveComment = async () => {
+  /** Do nothing. */
+};
+
 /**
  * Comment - Storybook Meta
  */
 export default {
   title: 'Organisms/Layout',
   component: CommentComponent,
+  args: {
+    saveComment,
+  },
   argTypes: {
     author: {
       description: 'The author data.',
@@ -51,6 +57,29 @@ export default {
         required: true,
       },
     },
+    Notice: {
+      control: {
+        type: null,
+      },
+      description: 'A component to display a success or error message.',
+      table: {
+        category: 'Options',
+      },
+      type: {
+        name: 'function',
+        required: false,
+      },
+    },
+    parentId: {
+      control: {
+        type: null,
+      },
+      description: 'The parent id if it is a reply.',
+      type: {
+        name: 'number',
+        required: false,
+      },
+    },
     publication: {
       description: 'The publication date.',
       type: {
@@ -73,13 +102,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof CommentComponent>;
 
 const Template: ComponentStory<typeof CommentComponent> = (args) => (
@@ -100,7 +122,6 @@ Comment.args = {
     'Harum aut cumque iure fugit neque sequi cupiditate repudiandae laudantium. Ratione aut assumenda qui illum voluptas accusamus quis officiis exercitationem. Consectetur est harum eius perspiciatis officiis nihil. Aut corporis minima debitis adipisci possimus debitis et.',
   id: 2,
   publication: '2021-04-03 23:04:24',
-  saveComment: () => null,
   // @ts-ignore - Needed because of the placeholder image.
   unoptimized: true,
 };
diff --git a/src/components/organisms/layout/comment.test.tsx b/src/components/organisms/layout/comment.test.tsx
index 4961722..02a51dc 100644
--- a/src/components/organisms/layout/comment.test.tsx
+++ b/src/components/organisms/layout/comment.test.tsx
@@ -11,13 +11,15 @@ const content =
   'Harum aut cumque iure fugit neque sequi cupiditate repudiandae laudantium. Ratione aut assumenda qui illum voluptas accusamus quis officiis exercitationem. Consectetur est harum eius perspiciatis officiis nihil. Aut corporis minima debitis adipisci possimus debitis et.';
 const publication = '2021-04-03 23:04:24';
 const id = 5;
-
+const saveComment = async () => {
+  /** Do nothing. */
+};
 const data = {
   author,
   content,
   id,
   publication,
-  saveComment: () => null,
+  saveComment,
 };
 
 const formattedDate = getFormattedDate(publication);
diff --git a/src/components/organisms/layout/comment.tsx b/src/components/organisms/layout/comment.tsx
index 248efc2..6df393b 100644
--- a/src/components/organisms/layout/comment.tsx
+++ b/src/components/organisms/layout/comment.tsx
@@ -25,7 +25,7 @@ export type CommentAuthor = {
   url?: string;
 };
 
-export type CommentProps = {
+export type CommentProps = Pick<CommentFormProps, 'Notice' | 'saveComment'> & {
   /**
    * The comment author data.
    */
@@ -50,10 +50,6 @@ export type CommentProps = {
    * The comment date and time separated with a space.
    */
   publication: string;
-  /**
-   * A callback function to save comment form data.
-   */
-  saveComment: CommentFormProps['saveComment'];
 };
 
 /**
@@ -66,6 +62,7 @@ const Comment: FC<CommentProps> = ({
   canReply = true,
   content,
   id,
+  Notice,
   parentId,
   publication,
   saveComment,
@@ -169,7 +166,10 @@ const Comment: FC<CommentProps> = ({
           className={styles.date}
           groupClassName={styles.date__item}
         />
-        <div className={styles.body}>{content}</div>
+        <div
+          className={styles.body}
+          dangerouslySetInnerHTML={{ __html: content }}
+        />
         <footer className={styles.footer}>
           {canReply && (
             <Button kind="tertiary" onClick={() => setIsReplying(!isReplying)}>
@@ -180,6 +180,8 @@ const Comment: FC<CommentProps> = ({
       </article>
       {isReplying && (
         <CommentForm
+          Notice={Notice}
+          parentId={id as number}
           saveComment={saveComment}
           title={formTitle}
           className={`${styles.wrapper} ${styles['wrapper--form']}`}
diff --git a/src/components/organisms/layout/comments-list.stories.tsx b/src/components/organisms/layout/comments-list.stories.tsx
index 9edf368..4d95205 100644
--- a/src/components/organisms/layout/comments-list.stories.tsx
+++ b/src/components/organisms/layout/comments-list.stories.tsx
@@ -1,13 +1,19 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import CommentsListComponent, { Comment } from './comments-list';
 
+const saveComment = async () => {
+  /** Do nothing. */
+};
+
 /**
  * CommentsList - Storybook Meta
  */
 export default {
   title: 'Organisms/Layout/CommentsList',
   component: CommentsListComponent,
+  args: {
+    saveComment,
+  },
   argTypes: {
     comments: {
       control: {
@@ -30,6 +36,19 @@ export default {
         required: true,
       },
     },
+    Notice: {
+      control: {
+        type: null,
+      },
+      description: 'A component to display a success or error message.',
+      table: {
+        category: 'Options',
+      },
+      type: {
+        name: 'function',
+        required: false,
+      },
+    },
     saveComment: {
       control: {
         type: null,
@@ -44,13 +63,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof CommentsListComponent>;
 
 const Template: ComponentStory<typeof CommentsListComponent> = (args) => (
@@ -130,7 +142,6 @@ export const WithoutChildComments = Template.bind({});
 WithoutChildComments.args = {
   comments,
   depth: 0,
-  saveComment: () => null,
 };
 
 /**
@@ -140,5 +151,4 @@ export const WithChildComments = Template.bind({});
 WithChildComments.args = {
   comments,
   depth: 1,
-  saveComment: () => null,
 };
diff --git a/src/components/organisms/layout/comments-list.test.tsx b/src/components/organisms/layout/comments-list.test.tsx
index 542b1df..e135ec9 100644
--- a/src/components/organisms/layout/comments-list.test.tsx
+++ b/src/components/organisms/layout/comments-list.test.tsx
@@ -57,10 +57,14 @@ const comments: Comment[] = [
   },
 ];
 
+const saveComment = async () => {
+  /** Do nothing. */
+};
+
 describe('CommentsList', () => {
   it('renders a comments list', () => {
     render(
-      <CommentsList comments={comments} depth={1} saveComment={() => null} />
+      <CommentsList comments={comments} depth={1} saveComment={saveComment} />
     );
   });
 });
diff --git a/src/components/organisms/layout/comments-list.tsx b/src/components/organisms/layout/comments-list.tsx
index 03f508e..f04354c 100644
--- a/src/components/organisms/layout/comments-list.tsx
+++ b/src/components/organisms/layout/comments-list.tsx
@@ -8,7 +8,7 @@ export type Comment = Omit<CommentProps, 'canReply' | 'saveComment'> & {
   child?: Comment[];
 };
 
-export type CommentsListProps = {
+export type CommentsListProps = Pick<CommentProps, 'Notice' | 'saveComment'> & {
   /**
    * An array of comments.
    */
@@ -17,10 +17,6 @@ export type CommentsListProps = {
    * The maximum depth. Use `0` to not display nested comments.
    */
   depth: 0 | 1 | 2 | 3 | 4;
-  /**
-   * A callback function to save comment form data.
-   */
-  saveComment: CommentProps['saveComment'];
 };
 
 /**
@@ -31,6 +27,7 @@ export type CommentsListProps = {
 const CommentsList: FC<CommentsListProps> = ({
   comments,
   depth,
+  Notice,
   saveComment,
 }) => {
   /**
@@ -48,8 +45,9 @@ const CommentsList: FC<CommentsListProps> = ({
     return commentsList.map(({ child, ...comment }) => (
       <li key={comment.id} className={styles.item}>
         <SingleComment
-          saveComment={saveComment}
           canReply={!isLastLevel}
+          Notice={Notice}
+          saveComment={saveComment}
           {...comment}
         />
         {child && !isLastLevel && (
diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx
index 480c76e..8e518aa 100644
--- a/src/components/templates/page/page-layout.stories.tsx
+++ b/src/components/templates/page/page-layout.stories.tsx
@@ -85,12 +85,12 @@ export default {
     },
     id: {
       control: {
-        type: 'text',
+        type: 'number',
       },
       description: 'The page id.',
       type: {
-        name: 'string',
-        required: true,
+        name: 'number',
+        required: false,
       },
     },
     intro: {
diff --git a/src/components/templates/page/page-layout.tsx b/src/components/templates/page/page-layout.tsx
index ac021ba..045b8c1 100644
--- a/src/components/templates/page/page-layout.tsx
+++ b/src/components/templates/page/page-layout.tsx
@@ -1,4 +1,5 @@
 import Heading from '@components/atoms/headings/heading';
+import Notice, { type NoticeKind } from '@components/atoms/layout/notice';
 import Sidebar from '@components/atoms/layout/sidebar';
 import PageFooter, {
   type PageFooterProps,
@@ -9,15 +10,19 @@ import PageHeader, {
 import Breadcrumb, {
   type BreadcrumbItem,
 } from '@components/molecules/nav/breadcrumb';
-import CommentForm from '@components/organisms/forms/comment-form';
+import CommentForm, {
+  type CommentFormProps,
+} from '@components/organisms/forms/comment-form';
 import CommentsList, {
   type CommentsListProps,
 } from '@components/organisms/layout/comments-list';
 import TableOfContents from '@components/organisms/widgets/table-of-contents';
+import { type SendCommentVars } from '@services/graphql/api';
+import { sendComment } from '@services/graphql/comments';
 import useIsMounted from '@utils/hooks/use-is-mounted';
-import { FC, ReactNode, useRef } from 'react';
+import { FC, ReactNode, useRef, useState } from 'react';
 import { useIntl } from 'react-intl';
-import Layout, { LayoutProps } from '../layout/layout';
+import Layout, { type LayoutProps } from '../layout/layout';
 import styles from './page-layout.module.scss';
 
 export type PageLayoutProps = {
@@ -45,6 +50,10 @@ export type PageLayoutProps = {
    * The header metadata.
    */
   headerMeta?: PageHeaderProps['meta'];
+  /**
+   * The page id.
+   */
+  id?: number;
   /**
    * The page introduction.
    */
@@ -79,6 +88,7 @@ const PageLayout: FC<PageLayoutProps> = ({
   comments,
   footerMeta,
   headerMeta,
+  id,
   intro,
   isHome = false,
   widgets,
@@ -106,8 +116,56 @@ const PageLayout: FC<PageLayoutProps> = ({
     ? 'article--has-comments'
     : 'article--no-comments';
 
-  const saveComment = () => {
-    return null;
+  const [status, setStatus] = useState<NoticeKind>('info');
+  const [statusMessage, setStatusMessage] = useState<string>('');
+  const isReplyRef = useRef<boolean>(false);
+
+  const saveComment: CommentFormProps['saveComment'] = async (data, reset) => {
+    if (!id) throw new Error('Page id missing. Cannot save comment.');
+
+    const { comment: commentBody, email, name, parentId, website } = data;
+    const commentData: SendCommentVars = {
+      author: name,
+      authorEmail: email,
+      authorUrl: website || '',
+      clientMutationId: 'contact',
+      commentOn: id,
+      content: commentBody,
+      parent: parentId,
+    };
+    const { comment, success } = await sendComment(commentData);
+
+    isReplyRef.current = !!parentId;
+
+    if (success) {
+      setStatus('success');
+      const successPrefix = intl.formatMessage({
+        defaultMessage: 'Thanks, your comment was successfully sent.',
+        description: 'PageLayout: comment form success message',
+        id: 'B290Ph',
+      });
+      const successMessage = comment?.approved
+        ? intl.formatMessage({
+            defaultMessage: 'It has been approved.',
+            id: 'g3+Ahv',
+            description: 'PageLayout: comment approved.',
+          })
+        : intl.formatMessage({
+            defaultMessage: 'It is now awaiting moderation.',
+            id: 'Vmj5cw',
+            description: 'PageLayout: comment awaiting moderation',
+          });
+      setStatusMessage(`${successPrefix} ${successMessage}`);
+      reset();
+    } else {
+      const error = intl.formatMessage({
+        defaultMessage: 'An error occurred:',
+        description: 'PageLayout: comment form error message',
+        id: 'fkcTGp',
+      });
+      setStatus('error');
+      setStatusMessage(error);
+    }
   };
 
   return (
@@ -154,15 +212,36 @@ const PageLayout: FC<PageLayoutProps> = ({
             <section className={styles.comments__section}>
               <Heading level={2}>{commentsTitle}</Heading>
               <CommentsList
-                saveComment={saveComment}
                 comments={comments}
-                depth={2}
+                depth={1}
+                Notice={
+                  isReplyRef.current === true ? (
+                    <Notice
+                      kind={status}
+                      message={statusMessage}
+                      className={styles.notice}
+                    />
+                  ) : undefined
+                }
+                saveComment={saveComment}
               />
             </section>
           )}
           {allowComments && (
             <section className={styles.comments__section}>
-              <CommentForm saveComment={saveComment} title={commentFormTitle} />
+              <CommentForm
+                saveComment={saveComment}
+                title={commentFormTitle}
+                Notice={
+                  isReplyRef.current === false ? (
+                    <Notice
+                      kind={status}
+                      message={statusMessage}
+                      className={styles.notice}
+                    />
+                  ) : undefined
+                }
+              />
             </section>
           )}
         </div>
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
new file mode 100644
index 0000000..6a47c16
--- /dev/null
+++ b/src/pages/article/[slug].tsx
@@ -0,0 +1,251 @@
+import ButtonLink from '@components/atoms/buttons/button-link';
+import Link from '@components/atoms/links/link';
+import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
+import Sharing from '@components/organisms/widgets/sharing';
+import PageLayout, {
+  type PageLayoutProps,
+} from '@components/templates/page/page-layout';
+import {
+  getAllArticlesSlugs,
+  getArticleBySlug,
+} from '@services/graphql/articles';
+import { getPostComments } from '@services/graphql/comments';
+import { type Article, type Comment } from '@ts/types/app';
+import { loadTranslation, type Messages } from '@utils/helpers/i18n';
+import useSettings from '@utils/hooks/use-settings';
+import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
+import Head from 'next/head';
+import { useRouter } from 'next/router';
+import Script from 'next/script';
+import { ParsedUrlQuery } from 'querystring';
+import { useIntl } from 'react-intl';
+import { Blog, BlogPosting, Graph, WebPage } from 'schema-dts';
+import useSWR from 'swr';
+
+type ArticlePageProps = {
+  comments: Comment[];
+  post: Article;
+  translation: Messages;
+};
+
+/**
+ * Article page.
+ */
+const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
+  const { content, id, intro, meta, slug, title } = post;
+  const { author, commentsCount, cover, dates, seo, thematics, topics } = meta;
+  const { data } = useSWR(() => id, getPostComments, {
+    fallbackData: comments,
+  });
+  const intl = useIntl();
+  const homeLabel = intl.formatMessage({
+    defaultMessage: 'Home',
+    description: 'Breadcrumb: home label',
+    id: 'j5k9Fe',
+  });
+  const blogLabel = intl.formatMessage({
+    defaultMessage: 'Blog',
+    description: 'Breadcrumb: blog label',
+    id: 'Es52wh',
+  });
+  const breadcrumb: BreadcrumbItem[] = [
+    { id: 'home', name: homeLabel, url: '/' },
+    { id: 'blog', name: blogLabel, url: '/blog' },
+    { id: 'article', name: title, url: `/article/${slug}` },
+  ];
+
+  const headerMeta: PageLayoutProps['headerMeta'] = {
+    author: author?.name,
+    publication: { date: dates.publication },
+    update: dates.update ? { date: dates.update } : undefined,
+    thematics:
+      thematics &&
+      thematics.map((thematic) => (
+        <Link key={thematic.id} href={`/thematique/${thematic.slug}`}>
+          {thematic.name}
+        </Link>
+      )),
+  };
+
+  const footerMeta: PageLayoutProps['footerMeta'] = {
+    topics:
+      topics &&
+      topics.map((topic) => {
+        return (
+          <ButtonLink key={topic.id} target={`/sujet/${topic.slug}`}>
+            {topic.name}
+          </ButtonLink>
+        );
+      }),
+  };
+
+  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` },
+    lastReviewed: dates.update,
+    name: seo.title,
+    description: seo.description,
+    reviewedBy: { '@id': `${website.url}/#branding` },
+    url: `${pageUrl}`,
+    isPartOf: {
+      '@id': `${website.url}`,
+    },
+  };
+
+  const blogSchema: Blog = {
+    '@id': `${website.url}/#blog`,
+    '@type': 'Blog',
+    blogPost: { '@id': `${website.url}/#article` },
+    isPartOf: {
+      '@id': `${pageUrl}`,
+    },
+    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+  };
+
+  const blogPostSchema: BlogPosting = {
+    '@id': `${website.url}/#article`,
+    '@type': 'BlogPosting',
+    name: title,
+    description: intro,
+    articleBody: content,
+    author: { '@id': `${website.url}/#branding` },
+    commentCount: commentsCount,
+    copyrightYear: pagePublicationDate.getFullYear(),
+    creator: { '@id': `${website.url}/#branding` },
+    dateCreated: pagePublicationDate.toISOString(),
+    dateModified: pageUpdateDate && pageUpdateDate.toISOString(),
+    datePublished: pagePublicationDate.toISOString(),
+    discussionUrl: `${pageUrl}/#comments`,
+    editor: { '@id': `${website.url}/#branding` },
+    headline: title,
+    image: cover?.src,
+    inLanguage: website.locales.default,
+    isPartOf: {
+      '@id': `${website.url}/blog`,
+    },
+    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+    mainEntityOfPage: { '@id': `${pageUrl}` },
+    thumbnailUrl: cover?.src,
+  };
+
+  const schemaJsonLd: Graph = {
+    '@context': 'https://schema.org',
+    '@graph': [webpageSchema, blogSchema, blogPostSchema],
+  };
+
+  /**
+   * Convert the comments list to the right format.
+   *
+   * @param {Comment[]} list - The comments list.
+   * @returns {PageLayoutProps['comments']} - The formatted comments list.
+   */
+  const getCommentsList = (list: Comment[]): PageLayoutProps['comments'] => {
+    return list.map((comment) => {
+      const {
+        content: commentBody,
+        id: commentId,
+        meta: commentMeta,
+        parentId,
+        replies,
+      } = comment;
+      const { author: commentAuthor, date } = commentMeta;
+      const { name, avatar, website: authorUrl } = commentAuthor;
+
+      return {
+        author: { name, avatar: avatar!.src, url: authorUrl },
+        content: commentBody,
+        id: commentId,
+        publication: date,
+        child: getCommentsList(replies),
+        parentId,
+      };
+    });
+  };
+
+  return (
+    <>
+      <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={title} />
+        <meta property="og:description" content={intro} />
+      </Head>
+      <Script
+        id="schema-project"
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
+      />
+      <PageLayout
+        allowComments={true}
+        breadcrumb={breadcrumb}
+        comments={data && getCommentsList(data)}
+        footerMeta={footerMeta}
+        headerMeta={headerMeta}
+        id={id as number}
+        intro={intro}
+        title={title}
+        withToC={true}
+        widgets={[
+          <Sharing
+            key="sharing-widget"
+            data={{ excerpt: intro, title, url: pageUrl }}
+            media={[
+              'diaspora',
+              'email',
+              'facebook',
+              'journal-du-hacker',
+              'linkedin',
+              'twitter',
+            ]}
+          />,
+        ]}
+      >
+        {content}
+      </PageLayout>
+    </>
+  );
+};
+
+interface PostParams extends ParsedUrlQuery {
+  slug: string;
+}
+
+export const getStaticProps: GetStaticProps<ArticlePageProps> = async ({
+  locale,
+  params,
+}) => {
+  const post = await getArticleBySlug(params!.slug as PostParams['slug']);
+  const comments = await getPostComments(post.id as number);
+  const translation = await loadTranslation(locale);
+
+  return {
+    props: {
+      comments: JSON.parse(JSON.stringify(comments)),
+      post: JSON.parse(JSON.stringify(post)),
+      translation,
+    },
+  };
+};
+
+export const getStaticPaths: GetStaticPaths = async () => {
+  const slugs = await getAllArticlesSlugs();
+  const paths = slugs.map((slug) => {
+    return { params: { slug } };
+  });
+
+  return {
+    paths,
+    fallback: true,
+  };
+};
+
+export default ArticlePage;
diff --git a/src/services/graphql/api.ts b/src/services/graphql/api.ts
index 171ab23..9f68ddc 100644
--- a/src/services/graphql/api.ts
+++ b/src/services/graphql/api.ts
@@ -6,6 +6,7 @@ import {
   articlesSlugQuery,
   totalArticlesQuery,
 } from './articles.query';
+import { sendCommentMutation } from './comments.mutation';
 import { commentsQuery } from './comments.query';
 import { sendMailMutation } from './contact.mutation';
 import {
@@ -21,7 +22,7 @@ import {
   totalTopicsQuery,
 } from './topics.query';
 
-export type Mutations = typeof sendMailMutation;
+export type Mutations = typeof sendMailMutation | typeof sendCommentMutation;
 
 export type Queries =
   | typeof articlesQuery
@@ -51,6 +52,10 @@ export type CommentsResponse<T> = {
   comments: T;
 };
 
+export type SendCommentResponse<T> = {
+  createComment: T;
+};
+
 export type SendMailResponse<T> = {
   sendEmail: T;
 };
@@ -101,6 +106,7 @@ export type ResponseMap<T> = {
   [articlesQuery]: ArticlesResponse<EdgesResponse<T>>;
   [articlesSlugQuery]: ArticlesResponse<EdgesResponse<T>>;
   [commentsQuery]: CommentsResponse<NodesResponse<T>>;
+  [sendCommentMutation]: SendCommentResponse<T>;
   [sendMailMutation]: SendMailResponse<T>;
   [thematicBySlugQuery]: ThematicResponse<T>;
   [thematicsListQuery]: ThematicsResponse<EdgesResponse<T>>;
@@ -154,10 +160,53 @@ export type SearchVar = {
   search?: string;
 };
 
+export type SendCommentVars = {
+  /**
+   * The author name.
+   */
+  author: string;
+  /**
+   * The author e-mail address.
+   */
+  authorEmail: string;
+  /**
+   * The author website.
+   */
+  authorUrl: string;
+  /**
+   * A mutation id.
+   */
+  clientMutationId: string;
+  /**
+   * A post or page id.
+   */
+  commentOn: number;
+  /**
+   * The comment body.
+   */
+  content: string;
+  /**
+   * The comment parent.
+   */
+  parent?: number;
+};
+
 export type SendMailVars = {
+  /**
+   * The mail body.
+   */
   body: string;
+  /**
+   * A mutation id.
+   */
   clientMutationId: string;
+  /**
+   * The reply to e-mail address.
+   */
   replyTo: string;
+  /**
+   * The mail subject.
+   */
   subject: string;
 };
 
@@ -167,6 +216,7 @@ export type VariablesMap = {
   [articlesQuery]: EdgesVars;
   [articlesSlugQuery]: EdgesVars;
   [commentsQuery]: ByContentIdVar;
+  [sendCommentMutation]: SendCommentVars;
   [sendMailMutation]: SendMailVars;
   [thematicBySlugQuery]: BySlugVar;
   [thematicsListQuery]: EdgesVars;
diff --git a/src/services/graphql/comments.mutation.ts b/src/services/graphql/comments.mutation.ts
new file mode 100644
index 0000000..f52c7e9
--- /dev/null
+++ b/src/services/graphql/comments.mutation.ts
@@ -0,0 +1,30 @@
+/**
+ * Send comment mutation.
+ */
+export const sendCommentMutation = `mutation CreateComment(
+  $author: String!
+  $authorEmail: String!
+  $authorUrl: String!
+  $content: String!
+  $parent: ID = null
+  $commentOn: Int!
+  $clientMutationId: String!
+) {
+  createComment(
+    input: {
+      author: $author
+      authorEmail: $authorEmail
+      authorUrl: $authorUrl
+      content: $content
+      parent: $parent
+      commentOn: $commentOn
+      clientMutationId: $clientMutationId
+    }
+  ) {
+    clientMutationId
+    success
+    comment {
+      approved
+    }
+  }
+}`;
diff --git a/src/services/graphql/comments.ts b/src/services/graphql/comments.ts
new file mode 100644
index 0000000..28ddfd0
--- /dev/null
+++ b/src/services/graphql/comments.ts
@@ -0,0 +1,102 @@
+import { Comment } from '@ts/types/app';
+import { RawComment } from '@ts/types/raw-data';
+import { getAuthorFromRawData } from '@utils/helpers/author';
+import { fetchAPI, getAPIUrl, SendCommentVars } from './api';
+import { sendCommentMutation } from './comments.mutation';
+import { commentsQuery } from './comments.query';
+
+/**
+ * Create a comments tree with replies.
+ *
+ * @param {Comment[]} comments - A flatten comments list.
+ * @returns {Comment[]} An array of comments with replies.
+ */
+export const buildCommentsTree = (comments: Comment[]): Comment[] => {
+  type CommentsHashTable = {
+    [key: string]: Comment;
+  };
+
+  const hashTable: CommentsHashTable = Object.create(null);
+  const commentsTree: Comment[] = [];
+
+  comments.forEach(
+    (comment) => (hashTable[comment.id] = { ...comment, replies: [] })
+  );
+
+  comments.forEach((comment) => {
+    if (!comment.parentId) {
+      commentsTree.push(hashTable[comment.id]);
+    } else {
+      hashTable[comment.parentId].replies.push(hashTable[comment.id]);
+    }
+  });
+
+  return commentsTree;
+};
+
+/**
+ * Convert a comment from RawComment to Comment type.
+ *
+ * @param {RawComment} comment - A raw comment.
+ * @returns {Comment} A formatted comment.
+ */
+export const getCommentFromRawData = (comment: RawComment): Comment => {
+  const { author, databaseId, date, parentDatabaseId, ...data } = comment;
+
+  return {
+    id: databaseId,
+    meta: {
+      author: getAuthorFromRawData(author.node, 'comment'),
+      date,
+    },
+    parentId: parentDatabaseId,
+    replies: [],
+    ...data,
+  };
+};
+
+/**
+ * Retrieve a comments list by post id.
+ *
+ * @param {number} id - A post id.
+ * @returns {Promise<Comment[]>} The comments list.
+ */
+export const getPostComments = async (id: number): Promise<Comment[]> => {
+  const response = await fetchAPI<RawComment, typeof commentsQuery>({
+    api: getAPIUrl(),
+    query: commentsQuery,
+    variables: { contentId: id },
+  });
+
+  const comments = response.comments.nodes.map((comment) =>
+    getCommentFromRawData(comment)
+  );
+
+  return buildCommentsTree(comments);
+};
+
+export type SentComment = {
+  clientMutationId: string;
+  success: boolean;
+  comment: {
+    approved: boolean;
+  } | null;
+};
+
+/**
+ * Send a comment using GraphQL API.
+ *
+ * @param {SendCommentVars} data - The comment data.
+ * @returns {Promise<SentEmail>} The mutation response.
+ */
+export const sendComment = async (
+  data: SendCommentVars
+): Promise<SentComment> => {
+  const response = await fetchAPI<SentComment, typeof sendCommentMutation>({
+    api: getAPIUrl(),
+    query: sendCommentMutation,
+    variables: { ...data },
+  });
+
+  return response.createComment;
+};
-- 
cgit v1.2.3


From 2155550fa36a3bc3c8f66e0926530123b4018cd4 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Mon, 16 May 2022 12:46:38 +0200
Subject: refactor: use custom hook for breadcrumb items and schema

---
 src/components/templates/layout/layout.stories.tsx |  54 ++++++++---
 src/components/templates/layout/layout.test.tsx    |  12 ++-
 src/components/templates/layout/layout.tsx         |  26 ++++-
 .../templates/page/page-layout.stories.tsx         |  12 +++
 src/components/templates/page/page-layout.test.tsx |  41 ++++++--
 src/components/templates/page/page-layout.tsx      |  11 ++-
 .../sectioned/sectioned-layout.stories.tsx         |  24 +++--
 .../templates/sectioned/sectioned-layout.test.tsx  |   9 +-
 .../templates/sectioned/sectioned-layout.tsx       |   8 +-
 src/pages/404.tsx                                  |  16 ++--
 src/pages/article/[slug].tsx                       |  24 ++---
 src/pages/blog/index.tsx                           |  16 ++--
 src/pages/contact.tsx                              |  22 ++---
 src/pages/cv.tsx                                   |  22 ++---
 src/pages/index.tsx                                |   9 +-
 src/pages/mentions-legales.tsx                     |  22 ++---
 src/pages/projets/[slug].tsx                       |  27 ++----
 src/pages/projets/index.tsx                        |  22 ++---
 src/pages/recherche/index.tsx                      |  22 ++---
 src/pages/sujet/[slug].tsx                         |  22 ++---
 src/pages/thematique/[slug].tsx                    |  22 ++---
 src/utils/hooks/use-breadcrumb.tsx                 | 106 +++++++++++++++++++++
 22 files changed, 343 insertions(+), 206 deletions(-)
 create mode 100644 src/utils/hooks/use-breadcrumb.tsx

(limited to 'src/components/templates/page/page-layout.stories.tsx')

diff --git a/src/components/templates/layout/layout.stories.tsx b/src/components/templates/layout/layout.stories.tsx
index 2415412..105e808 100644
--- a/src/components/templates/layout/layout.stories.tsx
+++ b/src/components/templates/layout/layout.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import LayoutComponent from './layout';
 
 /**
@@ -8,6 +7,10 @@ import LayoutComponent from './layout';
 export default {
   title: 'Templates/LayoutBase',
   component: LayoutComponent,
+  args: {
+    breadcrumbSchema: [],
+    isHome: false,
+  },
   argTypes: {
     children: {
       control: {
@@ -19,6 +22,31 @@ export default {
         required: true,
       },
     },
+    breadcrumbSchema: {
+      control: {
+        type: 'null',
+      },
+      description: 'The JSON schema for breadcrumb items.',
+      type: {
+        name: 'object',
+        required: true,
+        value: {},
+      },
+    },
+    isHome: {
+      control: {
+        type: 'boolean',
+      },
+      description: 'Determine if it is the homepage.',
+      table: {
+        category: 'Options',
+        defaultValue: { summary: false },
+      },
+      type: {
+        name: 'boolean',
+        required: false,
+      },
+    },
     className: {
       control: {
         type: 'text',
@@ -35,19 +63,17 @@ export default {
   },
   decorators: [
     (Story) => (
-      <IntlProvider locale="en">
-        <div
-          id="__next"
-          style={{
-            flex: 1,
-            display: 'flex',
-            flexFlow: 'column nowrap',
-            minHeight: '100vh',
-          }}
-        >
-          <Story />
-        </div>
-      </IntlProvider>
+      <div
+        id="__next"
+        style={{
+          flex: 1,
+          display: 'flex',
+          flexFlow: 'column nowrap',
+          minHeight: '100vh',
+        }}
+      >
+        <Story />
+      </div>
     ),
   ],
   parameters: {
diff --git a/src/components/templates/layout/layout.test.tsx b/src/components/templates/layout/layout.test.tsx
index 914e1cd..94145ec 100644
--- a/src/components/templates/layout/layout.test.tsx
+++ b/src/components/templates/layout/layout.test.tsx
@@ -1,34 +1,36 @@
 import { render, screen } from '@test-utils';
+import { BreadcrumbList } from 'schema-dts';
 import Layout from './layout';
 
 const body =
   'Sit dolorem eveniet. Sit sit odio nemo vitae corrupti modi sint est rerum. Pariatur quidem maiores distinctio. Quia et illum aspernatur est cum.';
+const breadcrumbSchema: BreadcrumbList['itemListElement'][] = [];
 
 describe('Layout', () => {
   it('renders the website header', () => {
-    render(<Layout>{body}</Layout>);
+    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
     expect(screen.getByRole('banner')).toBeInTheDocument();
   });
 
   it('renders the website main content', () => {
-    render(<Layout>{body}</Layout>);
+    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
     expect(screen.getByRole('main')).toBeInTheDocument();
   });
 
   it('renders the website footer', () => {
-    render(<Layout>{body}</Layout>);
+    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
     expect(screen.getByRole('contentinfo')).toBeInTheDocument();
   });
 
   it('renders a skip to content link', () => {
-    render(<Layout>{body}</Layout>);
+    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
     expect(
       screen.getByRole('link', { name: 'Skip to content' })
     ).toBeInTheDocument();
   });
 
   it('renders an article', () => {
-    render(<Layout>{body}</Layout>);
+    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
     expect(screen.getByRole('article')).toHaveTextContent(body);
   });
 });
diff --git a/src/components/templates/layout/layout.tsx b/src/components/templates/layout/layout.tsx
index bfb918b..9e9282b 100644
--- a/src/components/templates/layout/layout.tsx
+++ b/src/components/templates/layout/layout.tsx
@@ -13,7 +13,13 @@ import useSettings from '@utils/hooks/use-settings';
 import Script from 'next/script';
 import { FC, ReactNode } from 'react';
 import { useIntl } from 'react-intl';
-import { Person, SearchAction, WebSite, WithContext } from 'schema-dts';
+import {
+  BreadcrumbList,
+  Person,
+  SearchAction,
+  WebSite,
+  WithContext,
+} from 'schema-dts';
 import styles from './layout.module.scss';
 
 export type QueryAction = SearchAction & {
@@ -21,6 +27,10 @@ export type QueryAction = SearchAction & {
 };
 
 export type LayoutProps = Pick<HeaderProps, 'isHome'> & {
+  /**
+   * The breadcrumb JSON schema.
+   */
+  breadcrumbSchema: BreadcrumbList['itemListElement'][];
   /**
    * The layout main content.
    */
@@ -36,7 +46,12 @@ export type LayoutProps = Pick<HeaderProps, 'isHome'> & {
  *
  * Render the base layout used by all pages.
  */
-const Layout: FC<LayoutProps> = ({ children, isHome, ...props }) => {
+const Layout: FC<LayoutProps> = ({
+  breadcrumbSchema,
+  children,
+  isHome,
+  ...props
+}) => {
   const intl = useIntl();
   const { website } = useSettings();
   const { baseline, copyright, locales, name, picture, url } = website;
@@ -153,12 +168,17 @@ const Layout: FC<LayoutProps> = ({ children, isHome, ...props }) => {
         id="schema-layout"
         type="application/ld+json"
         dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
-      ></Script>
+      />
       <Script
         id="schema-branding"
         type="application/ld+json"
         dangerouslySetInnerHTML={{ __html: JSON.stringify(brandingSchema) }}
       />
+      <Script
+        id="schema-breadcrumb"
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
+      />
       <noscript>
         <div className={styles['noscript-spacing']}></div>
       </noscript>
diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx
index 8e518aa..002b951 100644
--- a/src/components/templates/page/page-layout.stories.tsx
+++ b/src/components/templates/page/page-layout.stories.tsx
@@ -16,6 +16,7 @@ export default {
   component: PageLayoutComponent,
   args: {
     allowComments: false,
+    breadcrumbSchema: [],
   },
   argTypes: {
     allowComments: {
@@ -40,6 +41,17 @@ export default {
         value: {},
       },
     },
+    breadcrumbSchema: {
+      control: {
+        type: null,
+      },
+      description: 'The JSON schema for breadcrumb items.',
+      type: {
+        name: 'object',
+        required: true,
+        value: {},
+      },
+    },
     children: {
       control: {
         type: 'text',
diff --git a/src/components/templates/page/page-layout.test.tsx b/src/components/templates/page/page-layout.test.tsx
index b8fff6a..b3aea8b 100644
--- a/src/components/templates/page/page-layout.test.tsx
+++ b/src/components/templates/page/page-layout.test.tsx
@@ -1,4 +1,5 @@
 import { render, screen } from '@test-utils';
+import { BreadcrumbList } from 'schema-dts';
 import PageLayout from './page-layout';
 
 const title = 'Incidunt ad earum';
@@ -6,13 +7,18 @@ const breadcrumb = [
   { id: 'home', url: '#', name: 'Home' },
   { id: 'page', url: '#', name: title },
 ];
+const breadcrumbSchema: BreadcrumbList['itemListElement'][] = [];
 const children =
   'Reprehenderit aut quis aperiam magnam quia id. Vero enim animi placeat quia. Laborum sit odio minima. Dolores et debitis eaque iste quidem. Omnis aliquam illum porro ea non. Quaerat totam iste quos ex facilis officia accusantium.';
 
 describe('PageLayout', () => {
   it('renders the page title', () => {
     render(
-      <PageLayout breadcrumb={breadcrumb} title={title}>
+      <PageLayout
+        breadcrumb={breadcrumb}
+        breadcrumbSchema={breadcrumbSchema}
+        title={title}
+      >
         {children}
       </PageLayout>
     );
@@ -23,7 +29,11 @@ describe('PageLayout', () => {
 
   it('renders the page content', () => {
     render(
-      <PageLayout breadcrumb={breadcrumb} title={title}>
+      <PageLayout
+        breadcrumb={breadcrumb}
+        breadcrumbSchema={breadcrumbSchema}
+        title={title}
+      >
         {children}
       </PageLayout>
     );
@@ -32,7 +42,11 @@ describe('PageLayout', () => {
 
   it('renders the breadcrumb', () => {
     render(
-      <PageLayout breadcrumb={breadcrumb} title={title}>
+      <PageLayout
+        breadcrumb={breadcrumb}
+        breadcrumbSchema={breadcrumbSchema}
+        title={title}
+      >
         {children}
       </PageLayout>
     );
@@ -43,7 +57,12 @@ describe('PageLayout', () => {
 
   it('renders the table of contents', () => {
     render(
-      <PageLayout breadcrumb={breadcrumb} title={title} withToC={true}>
+      <PageLayout
+        breadcrumb={breadcrumb}
+        breadcrumbSchema={breadcrumbSchema}
+        title={title}
+        withToC={true}
+      >
         {children}
       </PageLayout>
     );
@@ -54,7 +73,12 @@ describe('PageLayout', () => {
 
   it('renders the comment form', () => {
     render(
-      <PageLayout breadcrumb={breadcrumb} title={title} allowComments={true}>
+      <PageLayout
+        breadcrumb={breadcrumb}
+        breadcrumbSchema={breadcrumbSchema}
+        title={title}
+        allowComments={true}
+      >
         {children}
       </PageLayout>
     );
@@ -79,7 +103,12 @@ describe('PageLayout', () => {
       },
     ];
     render(
-      <PageLayout breadcrumb={breadcrumb} title={title} comments={comments}>
+      <PageLayout
+        breadcrumb={breadcrumb}
+        breadcrumbSchema={breadcrumbSchema}
+        title={title}
+        comments={comments}
+      >
         {children}
       </PageLayout>
     );
diff --git a/src/components/templates/page/page-layout.tsx b/src/components/templates/page/page-layout.tsx
index 4e4ff00..bc90f4c 100644
--- a/src/components/templates/page/page-layout.tsx
+++ b/src/components/templates/page/page-layout.tsx
@@ -25,7 +25,10 @@ import { useIntl } from 'react-intl';
 import Layout, { type LayoutProps } from '../layout/layout';
 import styles from './page-layout.module.scss';
 
-export type PageLayoutProps = {
+export type PageLayoutProps = Pick<
+  LayoutProps,
+  'breadcrumbSchema' | 'isHome'
+> & {
   /**
    * True if the page accepts new comments. Default: false.
    */
@@ -58,10 +61,6 @@ export type PageLayoutProps = {
    * The page introduction.
    */
   intro?: PageHeaderProps['intro'];
-  /**
-   * True if it is homepage. Default: false.
-   */
-  isHome?: LayoutProps['isHome'];
   /**
    * The page title.
    */
@@ -85,6 +84,7 @@ const PageLayout: FC<PageLayoutProps> = ({
   children,
   allowComments = false,
   breadcrumb,
+  breadcrumbSchema,
   comments,
   footerMeta,
   headerMeta,
@@ -170,6 +170,7 @@ const PageLayout: FC<PageLayoutProps> = ({
 
   return (
     <Layout
+      breadcrumbSchema={breadcrumbSchema}
       isHome={isHome}
       className={`${styles.article} ${styles[articleModifier]}`}
     >
diff --git a/src/components/templates/sectioned/sectioned-layout.stories.tsx b/src/components/templates/sectioned/sectioned-layout.stories.tsx
index 9ff3b75..ce31a83 100644
--- a/src/components/templates/sectioned/sectioned-layout.stories.tsx
+++ b/src/components/templates/sectioned/sectioned-layout.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import SectionedLayoutComponent from './sectioned-layout';
 
 /**
@@ -8,7 +7,21 @@ import SectionedLayoutComponent from './sectioned-layout';
 export default {
   title: 'Templates/Sectioned',
   component: SectionedLayoutComponent,
+  args: {
+    breadcrumbSchema: [],
+  },
   argTypes: {
+    breadcrumbSchema: {
+      control: {
+        type: null,
+      },
+      description: 'The JSON schema for breadcrumb items.',
+      type: {
+        name: 'object',
+        required: true,
+        value: {},
+      },
+    },
     sections: {
       description: 'The different sections.',
       type: {
@@ -18,15 +31,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <div id="__next">
-          <Story />
-        </div>
-      </IntlProvider>
-    ),
-  ],
   parameters: {
     layout: 'fullscreen',
   },
diff --git a/src/components/templates/sectioned/sectioned-layout.test.tsx b/src/components/templates/sectioned/sectioned-layout.test.tsx
index 334d1cc..9b8bab5 100644
--- a/src/components/templates/sectioned/sectioned-layout.test.tsx
+++ b/src/components/templates/sectioned/sectioned-layout.test.tsx
@@ -1,6 +1,8 @@
 import { render, screen } from '@test-utils';
+import { BreadcrumbList } from 'schema-dts';
 import SectionedLayout from './sectioned-layout';
 
+const breadcrumbSchema: BreadcrumbList['itemListElement'][] = [];
 const sections = [
   {
     title: 'Section 1',
@@ -26,7 +28,12 @@ const sections = [
 
 describe('SectionedLayout', () => {
   it('renders the correct number of section', () => {
-    render(<SectionedLayout sections={sections} />);
+    render(
+      <SectionedLayout
+        breadcrumbSchema={breadcrumbSchema}
+        sections={sections}
+      />
+    );
     expect(screen.getAllByRole('heading', { name: /^Section/ })).toHaveLength(
       sections.length
     );
diff --git a/src/components/templates/sectioned/sectioned-layout.tsx b/src/components/templates/sectioned/sectioned-layout.tsx
index 36ca039..58d5ad0 100644
--- a/src/components/templates/sectioned/sectioned-layout.tsx
+++ b/src/components/templates/sectioned/sectioned-layout.tsx
@@ -3,11 +3,11 @@ import Section, {
   type SectionVariant,
 } from '@components/atoms/layout/section';
 import { FC } from 'react';
-import Layout from '../layout/layout';
+import Layout, { type LayoutProps } from '../layout/layout';
 
 export type Section = Pick<SectionProps, 'content' | 'title'>;
 
-export type SectionedLayoutProps = {
+export type SectionedLayoutProps = Pick<LayoutProps, 'breadcrumbSchema'> & {
   /**
    * An array of objects describing each section.
    */
@@ -19,7 +19,7 @@ export type SectionedLayoutProps = {
  *
  * Render a sectioned layout.
  */
-const SectionedLayout: FC<SectionedLayoutProps> = ({ sections }) => {
+const SectionedLayout: FC<SectionedLayoutProps> = ({ sections, ...props }) => {
   const getSections = (items: SectionProps[]) => {
     return items.map((section, index) => {
       const variant: SectionVariant = index % 2 ? 'light' : 'dark';
@@ -37,7 +37,7 @@ const SectionedLayout: FC<SectionedLayoutProps> = ({ sections }) => {
     });
   };
 
-  return <Layout>{getSections(sections)}</Layout>;
+  return <Layout {...props}>{getSections(sections)}</Layout>;
 };
 
 export default SectionedLayout;
diff --git a/src/pages/404.tsx b/src/pages/404.tsx
index 7459c80..4f6e22d 100644
--- a/src/pages/404.tsx
+++ b/src/pages/404.tsx
@@ -1,5 +1,4 @@
 import Link from '@components/atoms/links/link';
-import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import PageLayout from '@components/templates/page/page-layout';
 import {
@@ -16,6 +15,7 @@ import {
   getLinksListItems,
   getPageLinkFromRawData,
 } from '@utils/helpers/pages';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticProps, NextPage } from 'next';
 import Head from 'next/head';
@@ -53,15 +53,10 @@ const Error404Page: NextPage<Error404PageProps> = ({
       link: (chunks: ReactNode) => <Link href="/contact">{chunks}</Link>,
     }
   );
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/404`,
   });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'error-404', name: title, url: '/404' },
-  ];
   const pageTitle = intl.formatMessage(
     {
       defaultMessage: 'Error 404: Page not found - {websiteName}',
@@ -95,7 +90,8 @@ const Error404Page: NextPage<Error404PageProps> = ({
       </Head>
       <PageLayout
         title={title}
-        breadcrumb={breadcrumb}
+        breadcrumb={breadcrumbItems}
+        breadcrumbSchema={breadcrumbSchema}
         widgets={[
           <LinksListWidget
             key="thematics-list"
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
index 6a47c16..5eeabd9 100644
--- a/src/pages/article/[slug].tsx
+++ b/src/pages/article/[slug].tsx
@@ -1,6 +1,5 @@
 import ButtonLink from '@components/atoms/buttons/button-link';
 import Link from '@components/atoms/links/link';
-import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import Sharing from '@components/organisms/widgets/sharing';
 import PageLayout, {
   type PageLayoutProps,
@@ -12,13 +11,13 @@ import {
 import { getPostComments } from '@services/graphql/comments';
 import { type Article, type Comment } from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
 import { ParsedUrlQuery } from 'querystring';
-import { useIntl } from 'react-intl';
 import { Blog, BlogPosting, Graph, WebPage } from 'schema-dts';
 import useSWR from 'swr';
 
@@ -37,22 +36,10 @@ const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
   const { data } = useSWR(() => id, getPostComments, {
     fallbackData: comments,
   });
-  const intl = useIntl();
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/article/${slug}`,
   });
-  const blogLabel = intl.formatMessage({
-    defaultMessage: 'Blog',
-    description: 'Breadcrumb: blog label',
-    id: 'Es52wh',
-  });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'blog', name: blogLabel, url: '/blog' },
-    { id: 'article', name: title, url: `/article/${slug}` },
-  ];
 
   const headerMeta: PageLayoutProps['headerMeta'] = {
     author: author?.name,
@@ -186,7 +173,8 @@ const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
       />
       <PageLayout
         allowComments={true}
-        breadcrumb={breadcrumb}
+        breadcrumb={breadcrumbItems}
+        breadcrumbSchema={breadcrumbSchema}
         comments={data && getCommentsList(data)}
         footerMeta={footerMeta}
         headerMeta={headerMeta}
diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx
index 38fabd5..a5ef045 100644
--- a/src/pages/blog/index.tsx
+++ b/src/pages/blog/index.tsx
@@ -1,5 +1,4 @@
 import Notice from '@components/atoms/layout/notice';
-import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import PostsList, { type Post } from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import PageLayout from '@components/templates/page/page-layout';
@@ -26,6 +25,7 @@ import {
   getLinksListItems,
   getPageLinkFromRawData,
 } from '@utils/helpers/pages';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import usePagination from '@utils/hooks/use-pagination';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticProps, NextPage } from 'next';
@@ -58,15 +58,10 @@ const BlogPage: NextPage<BlogPageProps> = ({
     description: 'BlogPage: page title',
     id: '7TbbIk',
   });
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: '/blog',
   });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'blog', name: title, url: '/blog' },
-  ];
 
   const { blog, website } = useSettings();
   const { asPath } = useRouter();
@@ -234,7 +229,8 @@ const BlogPage: NextPage<BlogPageProps> = ({
       />
       <PageLayout
         title={title}
-        breadcrumb={breadcrumb}
+        breadcrumb={breadcrumbItems}
+        breadcrumbSchema={breadcrumbSchema}
         headerMeta={{ total: postsCount }}
         widgets={[
           <LinksListWidget
diff --git a/src/pages/contact.tsx b/src/pages/contact.tsx
index fcfbe1d..617117b 100644
--- a/src/pages/contact.tsx
+++ b/src/pages/contact.tsx
@@ -1,7 +1,6 @@
-import Notice, { NoticeKind } from '@components/atoms/layout/notice';
-import { BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
+import Notice, { type NoticeKind } from '@components/atoms/layout/notice';
 import ContactForm, {
-  ContactFormProps,
+  type ContactFormProps,
 } from '@components/organisms/forms/contact-form';
 import SocialMedia from '@components/organisms/widgets/social-media';
 import PageLayout from '@components/templates/page/page-layout';
@@ -17,19 +16,15 @@ import Script from 'next/script';
 import { useState } from 'react';
 import { useIntl } from 'react-intl';
 import { ContactPage as ContactPageSchema, Graph, WebPage } from 'schema-dts';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 
 const ContactPage: NextPage = () => {
   const { dates, intro, seo, title } = meta;
   const intl = useIntl();
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/contact`,
   });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'contact', name: title, url: '/contact' },
-  ];
 
   const socialMediaTitle = intl.formatMessage({
     defaultMessage: 'Find me elsewhere',
@@ -148,9 +143,10 @@ const ContactPage: NextPage = () => {
         dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
       />
       <PageLayout
-        title="Contact"
+        breadcrumb={breadcrumbItems}
+        breadcrumbSchema={breadcrumbSchema}
         intro={intro}
-        breadcrumb={breadcrumb}
+        title="Contact"
         widgets={widgets}
       >
         <ContactForm
diff --git a/src/pages/cv.tsx b/src/pages/cv.tsx
index 0e4765e..b3dec10 100644
--- a/src/pages/cv.tsx
+++ b/src/pages/cv.tsx
@@ -1,5 +1,4 @@
 import Link from '@components/atoms/links/link';
-import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import ImageWidget from '@components/organisms/widgets/image-widget';
 import SocialMedia from '@components/organisms/widgets/social-media';
 import PageLayout, {
@@ -8,6 +7,7 @@ import PageLayout, {
 import CVContent, { data, meta } from '@content/pages/cv.mdx';
 import styles from '@styles/pages/cv.module.scss';
 import { loadTranslation } from '@utils/helpers/i18n';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticProps, NextPage } from 'next';
 import Head from 'next/head';
@@ -24,15 +24,10 @@ const CVPage: NextPage = () => {
   const intl = useIntl();
   const { file, image } = data;
   const { dates, intro, seo, title } = meta;
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/cv`,
   });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'cv', name: title, url: '/cv' },
-  ];
 
   const imageWidgetTitle = intl.formatMessage({
     defaultMessage: 'Others formats',
@@ -148,12 +143,13 @@ const CVPage: NextPage = () => {
 
   return (
     <PageLayout
-      title={title}
-      intro={intro}
+      breadcrumb={breadcrumbItems}
+      breadcrumbSchema={breadcrumbSchema}
       headerMeta={headerMeta}
-      breadcrumb={breadcrumb}
-      withToC={true}
+      intro={intro}
+      title={title}
       widgets={widgets}
+      withToC={true}
     >
       <Head>
         <title>{`${seo.title} - ${website.name}`}</title>
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index bc9b572..1143a33 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -19,6 +19,7 @@ import { getArticlesCard } from '@services/graphql/articles';
 import styles from '@styles/pages/home.module.scss';
 import { ArticleCard } from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { NestedMDXComponents } from 'mdx/types';
 import { GetStaticProps, NextPage } from 'next';
@@ -38,6 +39,10 @@ type HomeProps = {
  */
 const HomePage: NextPage<HomeProps> = ({ recentPosts }) => {
   const intl = useIntl();
+  const { schema: breadcrumbSchema } = useBreadcrumb({
+    title: '',
+    url: `/`,
+  });
 
   /**
    * Retrieve a list of coding links.
@@ -322,7 +327,7 @@ const HomePage: NextPage<HomeProps> = ({ recentPosts }) => {
   };
 
   return (
-    <Layout isHome={true}>
+    <Layout breadcrumbSchema={breadcrumbSchema} isHome={true}>
       <Head>
         <title>{pageTitle}</title>
         <meta name="description" content={pageDescription} />
@@ -340,7 +345,7 @@ const HomePage: NextPage<HomeProps> = ({ recentPosts }) => {
   );
 };
 
-export const getStaticProps: GetStaticProps = async ({ locale }) => {
+export const getStaticProps: GetStaticProps<HomeProps> = async ({ locale }) => {
   const translation = await loadTranslation(locale);
   const recentPosts = await getArticlesCard({ first: 3 });
 
diff --git a/src/pages/mentions-legales.tsx b/src/pages/mentions-legales.tsx
index 9235e69..41bc218 100644
--- a/src/pages/mentions-legales.tsx
+++ b/src/pages/mentions-legales.tsx
@@ -1,35 +1,28 @@
 import Link from '@components/atoms/links/link';
 import ResponsiveImage from '@components/molecules/images/responsive-image';
-import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import PageLayout, {
   type PageLayoutProps,
 } from '@components/templates/page/page-layout';
 import LegalNoticeContent, { meta } from '@content/pages/legal-notice.mdx';
 import { loadTranslation } from '@utils/helpers/i18n';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 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';
 
 /**
  * Legal Notice page.
  */
 const LegalNoticePage: NextPage = () => {
-  const intl = useIntl();
   const { dates, intro, seo, title } = meta;
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/mentions-legales`,
   });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'legal-notice', name: title, url: '/mentions-legales' },
-  ];
 
   const headerMeta: PageLayoutProps['headerMeta'] = {
     publication: {
@@ -93,10 +86,11 @@ const LegalNoticePage: NextPage = () => {
 
   return (
     <PageLayout
-      title={title}
-      intro={intro}
+      breadcrumb={breadcrumbItems}
+      breadcrumbSchema={breadcrumbSchema}
       headerMeta={headerMeta}
-      breadcrumb={breadcrumb}
+      intro={intro}
+      title={title}
       withToC={true}
     >
       <Head>
diff --git a/src/pages/projets/[slug].tsx b/src/pages/projets/[slug].tsx
index 711a5cd..1a90e0f 100644
--- a/src/pages/projets/[slug].tsx
+++ b/src/pages/projets/[slug].tsx
@@ -3,7 +3,6 @@ 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';
@@ -14,6 +13,7 @@ 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 useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useGithubApi, { RepoData } from '@utils/hooks/use-github-api';
 import useSettings from '@utils/hooks/use-settings';
 import { MDXComponents, NestedMDXComponents } from 'mdx/types';
@@ -37,21 +37,10 @@ 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 { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/projets/${id}`,
   });
-  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;
@@ -192,7 +181,8 @@ const ProjectPage: NextPage<ProjectPageProps> = ({ project }) => {
       <PageLayout
         title={title}
         intro={intro}
-        breadcrumb={breadcrumb}
+        breadcrumb={breadcrumbItems}
+        breadcrumbSchema={breadcrumbSchema}
         headerMeta={headerMeta}
         withToC={true}
         widgets={[
@@ -217,7 +207,10 @@ const ProjectPage: NextPage<ProjectPageProps> = ({ project }) => {
   );
 };
 
-export const getStaticProps: GetStaticProps = async ({ locale, params }) => {
+export const getStaticProps: GetStaticProps<ProjectPageProps> = async ({
+  locale,
+  params,
+}) => {
   const translation = await loadTranslation(locale);
   const { slug } = params!;
   const project = await getProjectData(slug as string);
diff --git a/src/pages/projets/index.tsx b/src/pages/projets/index.tsx
index a6858c8..4a58269 100644
--- a/src/pages/projets/index.tsx
+++ b/src/pages/projets/index.tsx
@@ -1,5 +1,4 @@
 import Link from '@components/atoms/links/link';
-import { BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import CardsList, {
   CardsListItem,
 } from '@components/organisms/layout/cards-list';
@@ -9,13 +8,13 @@ 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 useBreadcrumb from '@utils/hooks/use-breadcrumb';
 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 = {
@@ -27,17 +26,11 @@ type ProjectsPageProps = {
  * 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 { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/projets`,
   });
-  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 }) => {
@@ -120,7 +113,8 @@ const ProjectsPage: NextPage<ProjectsPageProps> = ({ projects }) => {
       <PageLayout
         title={title}
         intro={<PageContent components={components} />}
-        breadcrumb={breadcrumb}
+        breadcrumb={breadcrumbItems}
+        breadcrumbSchema={breadcrumbSchema}
       >
         <CardsList items={items} titleLevel={2} className={styles.list} />
       </PageLayout>
@@ -128,7 +122,9 @@ const ProjectsPage: NextPage<ProjectsPageProps> = ({ projects }) => {
   );
 };
 
-export const getStaticProps: GetStaticProps = async ({ locale }) => {
+export const getStaticProps: GetStaticProps<ProjectsPageProps> = async ({
+  locale,
+}) => {
   const projects = await getProjectsCard();
   const translation = await loadTranslation(locale);
 
diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx
index bf14861..0a7dc60 100644
--- a/src/pages/recherche/index.tsx
+++ b/src/pages/recherche/index.tsx
@@ -1,6 +1,5 @@
 import Notice from '@components/atoms/layout/notice';
 import Spinner from '@components/atoms/loaders/spinner';
-import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import PostsList, { type Post } from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import PageLayout from '@components/templates/page/page-layout';
@@ -26,6 +25,7 @@ import {
   getLinksListItems,
   getPageLinkFromRawData,
 } from '@utils/helpers/pages';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useDataFromAPI from '@utils/hooks/use-data-from-api';
 import usePagination from '@utils/hooks/use-pagination';
 import useSettings from '@utils/hooks/use-settings';
@@ -65,21 +65,10 @@ const SearchPage: NextPage<SearchPageProps> = ({
         description: 'SearchPage: SEO - Page title',
         id: 'WDwNDl',
       });
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/recherche`,
   });
-  const blogLabel = intl.formatMessage({
-    defaultMessage: 'Blog',
-    description: 'Breadcrumb: blog label',
-    id: 'Es52wh',
-  });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'blog', name: blogLabel, url: '/blog' },
-    { id: 'search', name: title, url: '/recherche' },
-  ];
 
   const { blog, website } = useSettings();
   const pageTitle = `${title} - ${website.name}`;
@@ -253,7 +242,8 @@ const SearchPage: NextPage<SearchPageProps> = ({
       />
       <PageLayout
         title={title}
-        breadcrumb={breadcrumb}
+        breadcrumb={breadcrumbItems}
+        breadcrumbSchema={breadcrumbSchema}
         headerMeta={{ total: postsCount }}
         widgets={[
           <LinksListWidget
diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx
index 22fb531..447d969 100644
--- a/src/pages/sujet/[slug].tsx
+++ b/src/pages/sujet/[slug].tsx
@@ -1,5 +1,4 @@
 import Heading from '@components/atoms/headings/heading';
-import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import PostsList, { type Post } from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import PageLayout, {
@@ -18,6 +17,7 @@ import {
   getPageLinkFromRawData,
   getPostMeta,
 } from '@utils/helpers/pages';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
 import Head from 'next/head';
@@ -37,21 +37,10 @@ const TopicPage: NextPage<TopicPageProps> = ({ currentTopic, topics }) => {
   const { content, intro, meta, slug, title } = currentTopic;
   const { articles, dates, seo, thematics } = meta;
   const intl = useIntl();
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/sujet/${slug}`,
   });
-  const blogLabel = intl.formatMessage({
-    defaultMessage: 'Blog',
-    description: 'Breadcrumb: blog label',
-    id: 'Es52wh',
-  });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'blog', name: blogLabel, url: '/blog' },
-    { id: 'topic', name: title, url: `/sujet/${slug}` },
-  ];
 
   const headerMeta: PageLayoutProps['headerMeta'] = {
     publication: { date: dates.publication },
@@ -149,7 +138,8 @@ const TopicPage: NextPage<TopicPageProps> = ({ currentTopic, topics }) => {
         dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
       />
       <PageLayout
-        breadcrumb={breadcrumb}
+        breadcrumb={breadcrumbItems}
+        breadcrumbSchema={breadcrumbSchema}
         title={title}
         intro={intro}
         headerMeta={headerMeta}
diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx
index dd7e80d..13ef0da 100644
--- a/src/pages/thematique/[slug].tsx
+++ b/src/pages/thematique/[slug].tsx
@@ -1,5 +1,4 @@
 import Heading from '@components/atoms/headings/heading';
-import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import PostsList, { type Post } from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import PageLayout, {
@@ -18,6 +17,7 @@ import {
   getPageLinkFromRawData,
   getPostMeta,
 } from '@utils/helpers/pages';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
 import Head from 'next/head';
@@ -40,21 +40,10 @@ const ThematicPage: NextPage<ThematicPageProps> = ({
   const { content, intro, meta, slug, title } = currentThematic;
   const { articles, dates, seo, topics } = meta;
   const intl = useIntl();
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/thematique/${slug}`,
   });
-  const blogLabel = intl.formatMessage({
-    defaultMessage: 'Blog',
-    description: 'Breadcrumb: blog label',
-    id: 'Es52wh',
-  });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'blog', name: blogLabel, url: '/blog' },
-    { id: 'thematic', name: title, url: `/thematique/${slug}` },
-  ];
 
   const headerMeta: PageLayoutProps['headerMeta'] = {
     publication: { date: dates.publication },
@@ -152,7 +141,8 @@ const ThematicPage: NextPage<ThematicPageProps> = ({
         dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
       />
       <PageLayout
-        breadcrumb={breadcrumb}
+        breadcrumb={breadcrumbItems}
+        breadcrumbSchema={breadcrumbSchema}
         title={title}
         intro={intro}
         headerMeta={headerMeta}
diff --git a/src/utils/hooks/use-breadcrumb.tsx b/src/utils/hooks/use-breadcrumb.tsx
new file mode 100644
index 0000000..087d400
--- /dev/null
+++ b/src/utils/hooks/use-breadcrumb.tsx
@@ -0,0 +1,106 @@
+import { BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
+import { slugify } from '@utils/helpers/strings';
+import { useIntl } from 'react-intl';
+import { BreadcrumbList } from 'schema-dts';
+import useSettings from './use-settings';
+
+export type useBreadcrumbProps = {
+  /**
+   * The current page title.
+   */
+  title: string;
+  /**
+   * The current page url.
+   */
+  url: string;
+};
+
+export type useBreadcrumbReturn = {
+  /**
+   * The breadcrumb items.
+   */
+  items: BreadcrumbItem[];
+  /**
+   * 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.
+ */
+const useBreadcrumb = ({
+  title,
+  url,
+}: useBreadcrumbProps): useBreadcrumbReturn => {
+  const intl = useIntl();
+  const { website } = useSettings();
+  const isArticle = url.startsWith('/article/');
+  const isHome = url === '/';
+  const isProject = url.startsWith('/projets/');
+  const isSearch = url.startsWith('/recherche');
+  const isThematic = url.startsWith('/thematique/');
+  const isTopic = url.startsWith('/sujet/');
+
+  const homeLabel = intl.formatMessage({
+    defaultMessage: 'Home',
+    description: 'Breadcrumb: home label',
+    id: 'j5k9Fe',
+  });
+  const items: BreadcrumbItem[] = [{ id: 'home', name: homeLabel, url: '/' }];
+  const schema: BreadcrumbList['itemListElement'][] = [
+    {
+      '@type': 'ListItem',
+      position: 1,
+      name: homeLabel,
+      item: website.url,
+    },
+  ];
+
+  if (isHome) return { items, schema };
+
+  if (isArticle || isSearch || isThematic || isTopic) {
+    const blogLabel = intl.formatMessage({
+      defaultMessage: 'Blog',
+      description: 'Breadcrumb: blog label',
+      id: 'Es52wh',
+    });
+    items.push({ id: 'blog', name: blogLabel, url: '/blog' });
+    schema.push({
+      '@type': 'ListItem',
+      position: 2,
+      name: blogLabel,
+      item: `${website.url}/blog`,
+    });
+  }
+
+  if (isProject) {
+    const projectsLabel = intl.formatMessage({
+      defaultMessage: 'Projects',
+      description: 'Breadcrumb: projects label',
+      id: '28GZdv',
+    });
+    items.push({ id: 'blog', name: projectsLabel, url: '/projets' });
+    schema.push({
+      '@type': 'ListItem',
+      position: 2,
+      name: projectsLabel,
+      item: `${website.url}/projets`,
+    });
+  }
+
+  items.push({ id: slugify(title), name: title, url });
+  schema.push({
+    '@type': 'ListItem',
+    position: schema.length + 1,
+    name: title,
+    item: `${website.url}${url}`,
+  });
+
+  return { items, schema };
+};
+
+export default useBreadcrumb;
-- 
cgit v1.2.3


From c77c58e18143233be042c4980a6ed08ae9beac52 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Mon, 16 May 2022 19:40:23 +0200
Subject: chore: adjust and complete missing styles

* add logo to topics pages and links
* add Prism styles to articles
* and a few other adjustements
---
 src/components/atoms/forms/form.tsx                |   3 +-
 src/components/atoms/links/link.module.scss        |  23 --
 .../atoms/lists/description-list-item.module.scss  |   2 +
 src/components/molecules/layout/meta.tsx           |  59 ++--
 src/components/molecules/layout/page-footer.tsx    |   4 +-
 .../molecules/layout/page-header.module.scss       |   6 +
 src/components/molecules/layout/page-header.tsx    |  11 +-
 src/components/organisms/forms/comment-form.tsx    |  12 +-
 .../organisms/widgets/sharing.stories.tsx          |  21 +-
 src/components/organisms/widgets/sharing.tsx       |   7 +-
 .../templates/page/page-layout.module.scss         |   6 +
 .../templates/page/page-layout.stories.tsx         |   2 +-
 src/components/templates/page/page-layout.tsx      |  17 +-
 src/pages/article/[slug].tsx                       |  43 ++-
 src/pages/blog/index.tsx                           |  12 +-
 src/pages/cv.tsx                                   |  19 +-
 src/pages/recherche/index.tsx                      |  12 +-
 src/pages/sujet/[slug].tsx                         |  35 ++-
 src/services/graphql/articles.query.ts             |  11 +
 src/services/graphql/topics.query.ts               |  11 +
 src/styles/base/_typography.scss                   |  24 +-
 src/styles/components/_wp-blocks.scss              | 166 ------------
 src/styles/globals.scss                            |  10 +-
 src/styles/pages/article.module.scss               |  36 +++
 src/styles/pages/partials/_article-headings.scss   |  57 ++++
 src/styles/pages/partials/_article-links.scss      | 104 +++++++
 src/styles/pages/partials/_article-lists.scss      |  65 +++++
 src/styles/pages/partials/_article-media.scss      |  11 +
 src/styles/pages/partials/_article-prism.scss      | 301 +++++++++++++++++++++
 src/styles/pages/partials/_article-wp-blocks.scss  | 168 ++++++++++++
 src/styles/pages/topic.module.scss                 |   6 +
 src/ts/types/app.ts                                |   1 +
 src/ts/types/raw-data.ts                           |   7 +-
 src/utils/helpers/pages.ts                         |   5 +-
 src/utils/hooks/use-add-prism-class-attr.tsx       |  60 ++++
 35 files changed, 1036 insertions(+), 301 deletions(-)
 delete mode 100644 src/styles/components/_wp-blocks.scss
 create mode 100644 src/styles/pages/article.module.scss
 create mode 100644 src/styles/pages/partials/_article-headings.scss
 create mode 100644 src/styles/pages/partials/_article-links.scss
 create mode 100644 src/styles/pages/partials/_article-lists.scss
 create mode 100644 src/styles/pages/partials/_article-media.scss
 create mode 100644 src/styles/pages/partials/_article-prism.scss
 create mode 100644 src/styles/pages/partials/_article-wp-blocks.scss
 create mode 100644 src/styles/pages/topic.module.scss
 create mode 100644 src/utils/hooks/use-add-prism-class-attr.tsx

(limited to 'src/components/templates/page/page-layout.stories.tsx')

diff --git a/src/components/atoms/forms/form.tsx b/src/components/atoms/forms/form.tsx
index ef8dce4..b819aea 100644
--- a/src/components/atoms/forms/form.tsx
+++ b/src/components/atoms/forms/form.tsx
@@ -35,7 +35,6 @@ export type FormProps = {
  */
 const Form: FC<FormProps> = ({
   children,
-  className = '',
   grouped = true,
   onSubmit,
   ...props
@@ -68,7 +67,7 @@ const Form: FC<FormProps> = ({
   };
 
   return (
-    <form onSubmit={handleSubmit} className={className} {...props}>
+    <form onSubmit={handleSubmit} {...props}>
       {getFormItems()}
     </form>
   );
diff --git a/src/components/atoms/links/link.module.scss b/src/components/atoms/links/link.module.scss
index 5c97bd2..1b89727 100644
--- a/src/components/atoms/links/link.module.scss
+++ b/src/components/atoms/links/link.module.scss
@@ -2,29 +2,6 @@
 @use "@styles/abstracts/variables" as var;
 
 .link {
-  background: linear-gradient(to top, var(--color-primary) 50%, transparent 50%)
-    0 0 / 100% 201% no-repeat;
-  color: var(--color-primary);
-  text-decoration-thickness: 0.15em;
-  text-underline-offset: 20%;
-  transition: all 0.3s linear 0s, text-decoration 0.18s ease-in-out 0s;
-
-  &:hover {
-    color: var(--color-primary-light);
-    text-decoration-thickness: 0.25em;
-  }
-
-  &:focus {
-    background-position: 0 100%;
-    color: var(--color-fg-inverted);
-  }
-
-  &:active {
-    background-position: 0 0;
-    color: var(--color-primary-dark);
-    text-decoration-thickness: 18%;
-  }
-
   &[hreflang] {
     &::after {
       display: inline-block;
diff --git a/src/components/atoms/lists/description-list-item.module.scss b/src/components/atoms/lists/description-list-item.module.scss
index 60cad57..aba90ce 100644
--- a/src/components/atoms/lists/description-list-item.module.scss
+++ b/src/components/atoms/lists/description-list-item.module.scss
@@ -27,6 +27,8 @@
   }
 
   &--inline-values {
+    row-gap: var(--spacing-2xs);
+
     .term {
       flex: 1 1 100%;
     }
diff --git a/src/components/molecules/layout/meta.tsx b/src/components/molecules/layout/meta.tsx
index 1f6219a..74bd4ff 100644
--- a/src/components/molecules/layout/meta.tsx
+++ b/src/components/molecules/layout/meta.tsx
@@ -92,13 +92,17 @@ export type MetaData = {
    */
   topics?: string[] | JSX.Element[];
   /**
-   * A total.
+   * A total number of posts.
    */
-  total?: string;
+  total?: number;
   /**
    * The update date.
    */
   update?: MetaDate;
+  /**
+   * An url.
+   */
+  website?: string;
 };
 
 export type MetaKey = keyof MetaData;
@@ -145,80 +149,86 @@ const Meta: FC<MetaProps> = ({
       case 'author':
         return intl.formatMessage({
           defaultMessage: 'Written by:',
-          id: 'OI0N37',
           description: 'Meta: author label',
+          id: 'OI0N37',
         });
       case 'comments':
         return intl.formatMessage({
           defaultMessage: 'Comments:',
-          id: 'jTVIh8',
           description: 'Meta: comments label',
+          id: 'jTVIh8',
         });
       case 'creation':
         return intl.formatMessage({
           defaultMessage: 'Created on:',
-          id: 'b4fdYE',
           description: 'Meta: creation date label',
+          id: 'b4fdYE',
         });
       case 'license':
         return intl.formatMessage({
           defaultMessage: 'License:',
-          id: 'AuGklx',
           description: 'Meta: license label',
+          id: 'AuGklx',
         });
       case 'popularity':
         return intl.formatMessage({
           defaultMessage: 'Popularity:',
-          id: 'pWTj2W',
           description: 'Meta: popularity label',
+          id: 'pWTj2W',
         });
       case 'publication':
         return intl.formatMessage({
           defaultMessage: 'Published on:',
-          id: 'QGi5uD',
           description: 'Meta: publication date label',
+          id: 'QGi5uD',
         });
       case 'readingTime':
         return intl.formatMessage({
           defaultMessage: 'Reading time:',
-          id: 'EbFvsM',
           description: 'Meta: reading time label',
+          id: 'EbFvsM',
         });
       case 'repositories':
         return intl.formatMessage({
           defaultMessage: 'Repositories:',
-          id: 'DssFG1',
           description: 'Meta: repositories label',
+          id: 'DssFG1',
         });
       case 'technologies':
         return intl.formatMessage({
           defaultMessage: 'Technologies:',
-          id: 'ADQmDF',
           description: 'Meta: technologies label',
+          id: 'ADQmDF',
         });
       case 'thematics':
         return intl.formatMessage({
           defaultMessage: 'Thematics:',
-          id: 'bz53Us',
           description: 'Meta: thematics label',
+          id: 'bz53Us',
         });
       case 'topics':
         return intl.formatMessage({
           defaultMessage: 'Topics:',
-          id: 'gJNaBD',
           description: 'Meta: topics label',
+          id: 'gJNaBD',
         });
       case 'total':
         return intl.formatMessage({
           defaultMessage: 'Total:',
-          id: '92zgdp',
           description: 'Meta: total label',
+          id: '92zgdp',
         });
       case 'update':
         return intl.formatMessage({
           defaultMessage: 'Updated on:',
-          id: 'tLC7bh',
           description: 'Meta: update date label',
+          id: 'tLC7bh',
+        });
+      case 'website':
+        return intl.formatMessage({
+          defaultMessage: 'Official website:',
+          description: 'Meta: official website label',
+          id: 'GRyyfy',
         });
       default:
         return '';
@@ -279,8 +289,8 @@ const Meta: FC<MetaProps> = ({
       {
         defaultMessage:
           '{commentsCount, plural, =0 {No comments} one {# comment} other {# comments}}<a11y> about {title}</a11y>',
-        id: '02rgLO',
         description: 'Meta: comments count',
+        id: '02rgLO',
       },
       {
         a11y: (chunks: ReactNode) => (
@@ -316,6 +326,23 @@ const Meta: FC<MetaProps> = ({
       case 'publication':
       case 'update':
         return getDate(value as MetaDate);
+      case 'total':
+        return intl.formatMessage(
+          {
+            defaultMessage:
+              '{postsCount, plural, =0 {No articles} one {# article} other {# articles}}',
+            description: 'BlogPage: posts count meta',
+            id: 'OF5cPz',
+          },
+          { postsCount: value as number }
+        );
+      case 'website':
+        const url = value as string;
+        return (
+          <Link href={url} external={true}>
+            {url}
+          </Link>
+        );
       default:
         return value as string | ReactNode | ReactNode[];
     }
diff --git a/src/components/molecules/layout/page-footer.tsx b/src/components/molecules/layout/page-footer.tsx
index e998b1e..97e449f 100644
--- a/src/components/molecules/layout/page-footer.tsx
+++ b/src/components/molecules/layout/page-footer.tsx
@@ -19,7 +19,9 @@ export type PageFooterProps = {
  */
 const PageFooter: FC<PageFooterProps> = ({ meta, ...props }) => {
   return (
-    <footer {...props}>{meta && <Meta data={meta} layout="column" />}</footer>
+    <footer {...props}>
+      {meta && <Meta data={meta} withSeparator={false} />}
+    </footer>
   );
 };
 
diff --git a/src/components/molecules/layout/page-header.module.scss b/src/components/molecules/layout/page-header.module.scss
index 4c7df5f..232023a 100644
--- a/src/components/molecules/layout/page-header.module.scss
+++ b/src/components/molecules/layout/page-header.module.scss
@@ -56,3 +56,9 @@
 .meta {
   font-size: var(--font-size-sm);
 }
+
+.intro {
+  > *:last-child {
+    margin-bottom: 0;
+  }
+}
diff --git a/src/components/molecules/layout/page-header.tsx b/src/components/molecules/layout/page-header.tsx
index 9abe9af..6759c7f 100644
--- a/src/components/molecules/layout/page-header.tsx
+++ b/src/components/molecules/layout/page-header.tsx
@@ -1,5 +1,5 @@
 import Heading from '@components/atoms/headings/heading';
-import { FC } from 'react';
+import { FC, ReactNode } from 'react';
 import Meta, { type MetaData } from './meta';
 import styles from './page-header.module.scss';
 
@@ -19,7 +19,7 @@ export type PageHeaderProps = {
   /**
    * The page title.
    */
-  title: string;
+  title: ReactNode;
 };
 
 /**
@@ -35,9 +35,12 @@ const PageHeader: FC<PageHeaderProps> = ({
 }) => {
   const getIntro = () => {
     return typeof intro === 'string' ? (
-      <div dangerouslySetInnerHTML={{ __html: intro }} />
+      <div
+        className={styles.intro}
+        dangerouslySetInnerHTML={{ __html: intro }}
+      />
     ) : (
-      <div>{intro}</div>
+      <div className={styles.intro}>{intro}</div>
     );
   };
 
diff --git a/src/components/organisms/forms/comment-form.tsx b/src/components/organisms/forms/comment-form.tsx
index 9e0abdf..5ff4ea4 100644
--- a/src/components/organisms/forms/comment-form.tsx
+++ b/src/components/organisms/forms/comment-form.tsx
@@ -1,5 +1,5 @@
 import Button from '@components/atoms/buttons/button';
-import Form from '@components/atoms/forms/form';
+import Form, { type FormProps } from '@components/atoms/forms/form';
 import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
 import Spinner from '@components/atoms/loaders/spinner';
 import LabelledField from '@components/molecules/forms/labelled-field';
@@ -15,11 +15,7 @@ export type CommentFormData = {
   website?: string;
 };
 
-export type CommentFormProps = {
-  /**
-   * Set additional classnames to the form wrapper.
-   */
-  className?: string;
+export type CommentFormProps = Pick<FormProps, 'className'> & {
   /**
    * Pass a component to print a success/error message.
    */
@@ -44,12 +40,12 @@ export type CommentFormProps = {
 };
 
 const CommentForm: FC<CommentFormProps> = ({
-  className = '',
   Notice,
   parentId,
   saveComment,
   title,
   titleLevel = 2,
+  ...props
 }) => {
   const intl = useIntl();
   const [name, setName] = useState<string>('');
@@ -116,9 +112,9 @@ const CommentForm: FC<CommentFormProps> = ({
   return (
     <Form
       onSubmit={submitHandler}
-      className={className}
       aria-label={formAriaLabel}
       aria-labelledby={formLabelledBy}
+      {...props}
     >
       {title && (
         <Heading id={formId} level={titleLevel}>
diff --git a/src/components/organisms/widgets/sharing.stories.tsx b/src/components/organisms/widgets/sharing.stories.tsx
index 47213b6..59b86d3 100644
--- a/src/components/organisms/widgets/sharing.stories.tsx
+++ b/src/components/organisms/widgets/sharing.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import SharingWidget from './sharing';
 
 /**
@@ -9,6 +8,19 @@ export default {
   title: 'Organisms/Widgets',
   component: SharingWidget,
   argTypes: {
+    className: {
+      control: {
+        type: 'text',
+      },
+      description: 'Set additional classnames to the sharing links list.',
+      table: {
+        category: 'Styles',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     data: {
       description: 'The page data.',
       type: {
@@ -58,13 +70,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof SharingWidget>;
 
 const Template: ComponentStory<typeof SharingWidget> = (args) => (
diff --git a/src/components/organisms/widgets/sharing.tsx b/src/components/organisms/widgets/sharing.tsx
index 85dadb0..c63f5db 100644
--- a/src/components/organisms/widgets/sharing.tsx
+++ b/src/components/organisms/widgets/sharing.tsx
@@ -22,6 +22,10 @@ export type SharingData = {
 };
 
 export type SharingProps = {
+  /**
+   * Set additional classnames to the sharing links list.
+   */
+  className?: string;
   /**
    * The page data to share.
    */
@@ -46,6 +50,7 @@ export type SharingProps = {
  * Render a list of sharing links inside a widget.
  */
 const Sharing: FC<SharingProps> = ({
+  className = '',
   data,
   media,
   expanded = true,
@@ -201,7 +206,7 @@ const Sharing: FC<SharingProps> = ({
 
   return (
     <Widget expanded={expanded} level={level} title={widgetTitle} {...props}>
-      <ul className={styles.list}>{getItems()}</ul>
+      <ul className={`${styles.list} ${className}`}>{getItems()}</ul>
     </Widget>
   );
 };
diff --git a/src/components/templates/page/page-layout.module.scss b/src/components/templates/page/page-layout.module.scss
index 7602492..83e5a80 100644
--- a/src/components/templates/page/page-layout.module.scss
+++ b/src/components/templates/page/page-layout.module.scss
@@ -72,6 +72,7 @@
 
 .footer {
   grid-column: 2;
+  margin: var(--spacing-sm) 0 var(--spacing-2xs);
 }
 
 .comments {
@@ -87,4 +88,9 @@
     grid-column: 2;
     margin: var(--spacing-md) 0 0;
   }
+
+  &__form {
+    max-width: 40ch;
+    margin: auto;
+  }
 }
diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx
index 002b951..8af5f98 100644
--- a/src/components/templates/page/page-layout.stories.tsx
+++ b/src/components/templates/page/page-layout.stories.tsx
@@ -473,7 +473,7 @@ export const Blog = Template.bind({});
 Blog.args = {
   breadcrumb: postsListBreadcrumb,
   title: 'Blog',
-  headerMeta: { total: `${posts.length} posts` },
+  headerMeta: { total: posts.length },
   children: (
     <>
       <PostsList posts={posts} byYear={true} total={posts.length} />
diff --git a/src/components/templates/page/page-layout.tsx b/src/components/templates/page/page-layout.tsx
index bc90f4c..f3f3ea8 100644
--- a/src/components/templates/page/page-layout.tsx
+++ b/src/components/templates/page/page-layout.tsx
@@ -20,7 +20,7 @@ import TableOfContents from '@components/organisms/widgets/table-of-contents';
 import { type SendCommentVars } from '@services/graphql/api';
 import { sendComment } from '@services/graphql/comments';
 import useIsMounted from '@utils/hooks/use-is-mounted';
-import { FC, ReactNode, useRef, useState } from 'react';
+import { FC, HTMLAttributes, ReactNode, useRef, useState } from 'react';
 import { useIntl } from 'react-intl';
 import Layout, { type LayoutProps } from '../layout/layout';
 import styles from './page-layout.module.scss';
@@ -33,6 +33,11 @@ export type PageLayoutProps = Pick<
    * True if the page accepts new comments. Default: false.
    */
   allowComments?: boolean;
+  bodyAttributes?: HTMLAttributes<HTMLDivElement>;
+  /**
+   * Set additional classnames to the body wrapper.
+   */
+  bodyClassName?: string;
   /**
    * The breadcrumb items.
    */
@@ -83,6 +88,8 @@ export type PageLayoutProps = Pick<
 const PageLayout: FC<PageLayoutProps> = ({
   children,
   allowComments = false,
+  bodyAttributes,
+  bodyClassName = '',
   breadcrumb,
   breadcrumbSchema,
   comments,
@@ -91,8 +98,8 @@ const PageLayout: FC<PageLayoutProps> = ({
   id,
   intro,
   isHome = false,
-  widgets,
   title,
+  widgets,
   withToC = false,
 }) => {
   const intl = useIntl();
@@ -202,11 +209,12 @@ const PageLayout: FC<PageLayoutProps> = ({
       {typeof children === 'string' ? (
         <div
           ref={bodyRef}
-          className={styles.body}
+          className={`${styles.body} ${bodyClassName}`}
           dangerouslySetInnerHTML={{ __html: children }}
+          {...bodyAttributes}
         />
       ) : (
-        <div ref={bodyRef} className={styles.body}>
+        <div ref={bodyRef} className={`${styles.body} ${bodyClassName}`}>
           {children}
         </div>
       )}
@@ -245,6 +253,7 @@ const PageLayout: FC<PageLayoutProps> = ({
           {allowComments && (
             <section className={styles.comments__section}>
               <CommentForm
+                className={styles.comments__form}
                 saveComment={saveComment}
                 title={commentFormTitle}
                 Notice={
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
index 5eeabd9..995e3a9 100644
--- a/src/pages/article/[slug].tsx
+++ b/src/pages/article/[slug].tsx
@@ -1,5 +1,6 @@
 import ButtonLink from '@components/atoms/buttons/button-link';
 import Link from '@components/atoms/links/link';
+import ResponsiveImage from '@components/molecules/images/responsive-image';
 import Sharing from '@components/organisms/widgets/sharing';
 import PageLayout, {
   type PageLayoutProps,
@@ -9,15 +10,20 @@ import {
   getArticleBySlug,
 } from '@services/graphql/articles';
 import { getPostComments } from '@services/graphql/comments';
+import styles from '@styles/pages/article.module.scss';
 import { type Article, type Comment } from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
+import useAddPrismClassAttr from '@utils/hooks/use-add-prism-class-attr';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
+import usePrismPlugins, { PrismPlugin } from '@utils/hooks/use-prism-plugins';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
 import { ParsedUrlQuery } from 'querystring';
+import { HTMLAttributes } from 'react';
+import { useIntl } from 'react-intl';
 import { Blog, BlogPosting, Graph, WebPage } from 'schema-dts';
 import useSWR from 'swr';
 
@@ -54,16 +60,31 @@ const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
       )),
   };
 
+  const intl = useIntl();
+  const footerMetaLabel = intl.formatMessage({
+    defaultMessage: 'Read more articles about:',
+    description: 'ArticlePage: footer topics list label',
+    id: '50xc4o',
+  });
+
   const footerMeta: PageLayoutProps['footerMeta'] = {
-    topics:
-      topics &&
-      topics.map((topic) => {
+    custom: topics && {
+      label: footerMetaLabel,
+      value: topics.map((topic) => {
         return (
-          <ButtonLink key={topic.id} target={`/sujet/${topic.slug}`}>
+          <ButtonLink
+            key={topic.id}
+            target={`/sujet/${topic.slug}`}
+            className={styles.btn}
+          >
+            {topic.logo && (
+              <ResponsiveImage className={styles.btn__icon} {...topic.logo} />
+            )}{' '}
             {topic.name}
           </ButtonLink>
         );
       }),
+    },
   };
 
   const { website } = useSettings();
@@ -156,6 +177,15 @@ const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
     });
   };
 
+  const prismPlugins: PrismPlugin[] = ['command-line', 'line-numbers'];
+  const { pluginsAttribute, pluginsClassName } = usePrismPlugins(prismPlugins);
+  useAddPrismClassAttr({
+    attributes: {
+      'data-filter-output': '#output#"',
+    },
+    classNames: pluginsClassName,
+  });
+
   return (
     <>
       <Head>
@@ -173,6 +203,10 @@ const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
       />
       <PageLayout
         allowComments={true}
+        bodyAttributes={{
+          ...(pluginsAttribute as HTMLAttributes<HTMLDivElement>),
+        }}
+        bodyClassName={styles.body}
         breadcrumb={breadcrumbItems}
         breadcrumbSchema={breadcrumbSchema}
         comments={data && getCommentsList(data)}
@@ -185,6 +219,7 @@ const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
         widgets={[
           <Sharing
             key="sharing-widget"
+            className={styles.widget}
             data={{ excerpt: intro, title, url: pageUrl }}
             media={[
               'diaspora',
diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx
index a5ef045..b6ce221 100644
--- a/src/pages/blog/index.tsx
+++ b/src/pages/blog/index.tsx
@@ -114,16 +114,6 @@ const BlogPage: NextPage<BlogPageProps> = ({
     '@graph': [webpageSchema, blogSchema],
   };
 
-  const postsCount = intl.formatMessage(
-    {
-      defaultMessage:
-        '{postsCount, plural, =0 {No articles} one {# article} other {# articles}}',
-      id: 'OF5cPz',
-      description: 'BlogPage: posts count meta',
-    },
-    { postsCount: totalArticles }
-  );
-
   /**
    * Retrieve the formatted meta.
    *
@@ -231,7 +221,7 @@ const BlogPage: NextPage<BlogPageProps> = ({
         title={title}
         breadcrumb={breadcrumbItems}
         breadcrumbSchema={breadcrumbSchema}
-        headerMeta={{ total: postsCount }}
+        headerMeta={{ total: totalArticles }}
         widgets={[
           <LinksListWidget
             key="thematics-list"
diff --git a/src/pages/cv.tsx b/src/pages/cv.tsx
index b3dec10..7936c84 100644
--- a/src/pages/cv.tsx
+++ b/src/pages/cv.tsx
@@ -1,4 +1,6 @@
+import Heading from '@components/atoms/headings/heading';
 import Link from '@components/atoms/links/link';
+import List from '@components/atoms/lists/list';
 import ImageWidget from '@components/organisms/widgets/image-widget';
 import SocialMedia from '@components/organisms/widgets/social-media';
 import PageLayout, {
@@ -9,11 +11,12 @@ import styles from '@styles/pages/cv.module.scss';
 import { loadTranslation } from '@utils/helpers/i18n';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 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 { ReactNode } from 'react';
+import React, { ReactNode } from 'react';
 import { useIntl } from 'react-intl';
 import { AboutPage, Graph, WebPage } from 'schema-dts';
 
@@ -141,6 +144,18 @@ const CVPage: NextPage = () => {
     '@graph': [webpageSchema, cvSchema],
   };
 
+  const components: NestedMDXComponents = {
+    a: (props) => <Link external={true} {...props} />,
+    h1: (props) => <Heading level={1} {...props} />,
+    h2: (props) => <Heading level={2} {...props} />,
+    h3: (props) => <Heading level={3} {...props} />,
+    h4: (props) => <Heading level={4} {...props} />,
+    h5: (props) => <Heading level={5} {...props} />,
+    h6: (props) => <Heading level={6} {...props} />,
+    Link: (props) => <Link {...props} />,
+    List: (props) => <List {...props} />,
+  };
+
   return (
     <PageLayout
       breadcrumb={breadcrumbItems}
@@ -166,7 +181,7 @@ const CVPage: NextPage = () => {
         type="application/ld+json"
         dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
       />
-      <CVContent />
+      <CVContent components={components} />
     </PageLayout>
   );
 };
diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx
index 0a7dc60..d88a293 100644
--- a/src/pages/recherche/index.tsx
+++ b/src/pages/recherche/index.tsx
@@ -140,16 +140,6 @@ const SearchPage: NextPage<SearchPageProps> = ({
     getTotalArticles(query.s as string)
   );
 
-  const postsCount = intl.formatMessage(
-    {
-      defaultMessage:
-        '{postsCount, plural, =0 {No articles} one {# article} other {# articles}}',
-      id: 'LtsVOx',
-      description: 'SearchPage: posts count meta',
-    },
-    { postsCount: totalArticles || 0 }
-  );
-
   /**
    * Retrieve the formatted meta.
    *
@@ -244,7 +234,7 @@ const SearchPage: NextPage<SearchPageProps> = ({
         title={title}
         breadcrumb={breadcrumbItems}
         breadcrumbSchema={breadcrumbSchema}
-        headerMeta={{ total: postsCount }}
+        headerMeta={{ total: totalArticles }}
         widgets={[
           <LinksListWidget
             key="thematics-list"
diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx
index 447d969..348fe05 100644
--- a/src/pages/sujet/[slug].tsx
+++ b/src/pages/sujet/[slug].tsx
@@ -1,4 +1,5 @@
 import Heading from '@components/atoms/headings/heading';
+import ResponsiveImage from '@components/molecules/images/responsive-image';
 import PostsList, { type Post } from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import PageLayout, {
@@ -10,6 +11,7 @@ import {
   getTopicsPreview,
   getTotalTopics,
 } from '@services/graphql/topics';
+import styles from '@styles/pages/topic.module.scss';
 import { type Article, type PageLink, type Topic } from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import {
@@ -35,7 +37,14 @@ export type TopicPageProps = {
 
 const TopicPage: NextPage<TopicPageProps> = ({ currentTopic, topics }) => {
   const { content, intro, meta, slug, title } = currentTopic;
-  const { articles, dates, seo, thematics } = meta;
+  const {
+    articles,
+    cover,
+    dates,
+    seo,
+    thematics,
+    website: officialWebsite,
+  } = meta;
   const intl = useIntl();
   const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
     title,
@@ -45,6 +54,8 @@ const TopicPage: NextPage<TopicPageProps> = ({ currentTopic, topics }) => {
   const headerMeta: PageLayoutProps['headerMeta'] = {
     publication: { date: dates.publication },
     update: dates.update ? { date: dates.update } : undefined,
+    website: officialWebsite,
+    total: articles ? articles.length : undefined,
   };
 
   const { website } = useSettings();
@@ -98,10 +109,10 @@ const TopicPage: NextPage<TopicPageProps> = ({ currentTopic, topics }) => {
         ...remainingData
       } = article;
 
-      const { cover, ...remainingMeta } = articleMeta;
+      const { cover: articleCover, ...remainingMeta } = articleMeta;
 
       return {
-        cover,
+        cover: articleCover,
         excerpt: articleIntro,
         meta: getPostMeta(remainingMeta),
         url: `/article/${articleSlug}`,
@@ -122,6 +133,15 @@ const TopicPage: NextPage<TopicPageProps> = ({ currentTopic, topics }) => {
     id: '/sRqPT',
   });
 
+  const getPageHeading = () => {
+    return (
+      <>
+        {cover && <ResponsiveImage className={styles.logo} {...cover} />}
+        {title}
+      </>
+    );
+  };
+
   return (
     <>
       <Head>
@@ -140,7 +160,7 @@ const TopicPage: NextPage<TopicPageProps> = ({ currentTopic, topics }) => {
       <PageLayout
         breadcrumb={breadcrumbItems}
         breadcrumbSchema={breadcrumbSchema}
-        title={title}
+        title={getPageHeading()}
         intro={intro}
         headerMeta={headerMeta}
         widgets={
@@ -206,14 +226,15 @@ export const getStaticProps: GetStaticProps<TopicPageProps> = async ({
   const allTopics = allTopicsEdges.edges.map((edge) =>
     getPageLinkFromRawData(edge.node)
   );
+  const topicsLinks = allTopics.filter(
+    (topic) => topic.slug !== (params!.slug as TopicParams['slug'])
+  );
   const translation = await loadTranslation(locale);
 
   return {
     props: {
       currentTopic: JSON.parse(JSON.stringify(currentTopic)),
-      topics: allTopics.filter(
-        (topic) => topic.slug !== (params!.slug as TopicParams['slug'])
-      ),
+      topics: JSON.parse(JSON.stringify(topicsLinks)),
       translation,
     },
   };
diff --git a/src/services/graphql/articles.query.ts b/src/services/graphql/articles.query.ts
index d65bc9e..e02ca8e 100644
--- a/src/services/graphql/articles.query.ts
+++ b/src/services/graphql/articles.query.ts
@@ -14,6 +14,17 @@ export const articleBySlugQuery = `query PostBy($slug: ID!) {
       postsInTopic {
         ... on Topic {
           databaseId
+          featuredImage {
+            node {
+              altText
+              mediaDetails {
+                height
+                width
+              }
+              sourceUrl
+              title
+            }
+          }
           slug
           title
         }
diff --git a/src/services/graphql/topics.query.ts b/src/services/graphql/topics.query.ts
index 19be5d7..4574256 100644
--- a/src/services/graphql/topics.query.ts
+++ b/src/services/graphql/topics.query.ts
@@ -94,6 +94,17 @@ export const topicsListQuery = `query TopicsList($after: String = "", $first: In
       cursor
       node {
         databaseId
+        featuredImage {
+          node {
+            altText
+            mediaDetails {
+              height
+              width
+            }
+            sourceUrl
+            title
+          }
+        }
         slug
         title
       }
diff --git a/src/styles/base/_typography.scss b/src/styles/base/_typography.scss
index 7b7a695..2c3c8cc 100644
--- a/src/styles/base/_typography.scss
+++ b/src/styles/base/_typography.scss
@@ -120,21 +120,15 @@ dl {
   & & {
     margin: var(--spacing-2xs) 0 0;
   }
-}
-
-dt {
-  flex: 0 0 max-content;
-  font-weight: 600;
-}
 
-dd {
-  flex: 0 0 auto;
-  margin: 0;
+  ::marker {
+    color: var(--color-primary-dark);
+  }
 }
 
 a {
   background: linear-gradient(to top, var(--color-primary) 50%, transparent 50%)
-    0 0 / 100% 200% no-repeat;
+    0 0 / 100% 201% no-repeat;
   color: var(--color-primary);
   text-decoration-thickness: 0.15em;
   text-underline-offset: 20%;
@@ -199,13 +193,3 @@ pre {
   word-break: normal;
   word-wrap: normal;
 }
-
-figure {
-  margin: var(--spacing-md) 0;
-}
-
-figcaption {
-  margin-top: var(--spacing-xs);
-  font-size: var(--font-size-sm);
-  text-align: center;
-}
diff --git a/src/styles/components/_wp-blocks.scss b/src/styles/components/_wp-blocks.scss
deleted file mode 100644
index efd6db5..0000000
--- a/src/styles/components/_wp-blocks.scss
+++ /dev/null
@@ -1,166 +0,0 @@
-@use "@styles/abstracts/functions" as fun;
-@use "@styles/abstracts/mixins" as mix;
-@use "@styles/abstracts/placeholders";
-
-.wp-block-quote {
-  margin: var(--spacing-sm) 0;
-  padding: var(--spacing-sm);
-  position: relative;
-  border: fun.convert-px(1) solid var(--color-primary-lighter);
-  border-left: fun.convert-px(5) solid var(--color-primary-lighter);
-  box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
-    fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
-      var(--color-shadow-light),
-    fun.convert-px(3) fun.convert-px(3) fun.convert-px(6) 0
-      var(--color-shadow-light);
-  font-style: italic;
-
-  > *:last-child {
-    margin: 0;
-  }
-
-  cite {
-    font-size: var(--font-size-sm);
-    font-style: normal;
-    font-weight: 600;
-  }
-}
-
-.wp-block-code,
-.wp-block-preformatted {
-  margin: 0 auto var(--spacing-md);
-  padding: var(--spacing-xs) var(--spacing-sm);
-  background: var(--color-bg-secondary);
-  border: fun.convert-px(1) solid var(--color-border-light);
-  color: var(--color-fg);
-}
-
-.wp-block-columns {
-  display: grid;
-  grid-template-columns: minmax(0, 1fr);
-  gap: var(--spacing-md);
-  margin: var(--spacing-md) 0;
-
-  @include mix.media("screen") {
-    @include mix.dimensions("sm") {
-      grid-template-columns: repeat(2, minmax(0, 1fr));
-    }
-  }
-
-  &.are-vertically-aligned-center {
-    align-items: center;
-  }
-}
-
-.wp-block-column {
-  > *:first-child {
-    margin-top: 0;
-  }
-
-  > *:last-child {
-    margin-bottom: 0;
-  }
-}
-
-.wp-block-gallery {
-  display: grid;
-  grid-template-columns: minmax(0, 1fr);
-  gap: var(--spacing-sm);
-
-  .blocks-gallery-grid {
-    @extend %reset-list;
-
-    grid-column: 1 / -1;
-    grid-row: 1 / -1;
-    display: grid;
-    grid-template-columns: minmax(0, 1fr);
-    gap: var(--spacing-sm);
-  }
-
-  .blocks-gallery-item {
-    figure {
-      margin: 0;
-    }
-
-    a {
-      display: block;
-      box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
-        fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
-          var(--color-shadow-light),
-        fun.convert-px(3) fun.convert-px(3) fun.convert-px(6) 0
-          var(--color-shadow-light);
-
-      &:hover,
-      &:focus {
-        transform: scale(1.05);
-        box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
-          fun.convert-px(3) fun.convert-px(3) fun.convert-px(2) 0
-            var(--color-shadow-light),
-          fun.convert-px(5) fun.convert-px(5) fun.convert-px(8) 0
-            var(--color-shadow-light);
-      }
-
-      &:focus {
-        outline: solid var(--color-primary-light);
-      }
-
-      &:active {
-        transform: scale(0.95);
-        box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
-          fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
-            var(--color-shadow-light),
-          0 0 0 0 var(--color-shadow-light);
-        outline: none;
-      }
-    }
-  }
-
-  &.aligncenter {
-    .blocks-gallery-grid {
-      align-items: center;
-    }
-  }
-
-  @for $i from 0 to 6 {
-    &.columns-#{$i} {
-      @include mix.media("screen") {
-        @include mix.dimensions("xs") {
-          grid-template-columns: repeat(2, minmax(0, 1fr));
-
-          .blocks-gallery-grid {
-            grid-template-columns: repeat(2, minmax(0, 1fr));
-          }
-        }
-
-        @include mix.dimensions("sm") {
-          grid-template-columns: repeat(#{$i}, minmax(0, 1fr));
-
-          .blocks-gallery-grid {
-            grid-template-columns: repeat(3, minmax(0, 1fr));
-          }
-        }
-      }
-    }
-  }
-}
-
-.wp-block-image {
-  img {
-    display: block;
-    margin: auto;
-    box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
-      fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
-        var(--color-shadow-light),
-      fun.convert-px(3) fun.convert-px(3) fun.convert-px(6) 0
-        var(--color-shadow-light);
-    text-align: center;
-  }
-}
-
-.wp-block-video {
-  box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
-    fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
-      var(--color-shadow-light),
-    fun.convert-px(3) fun.convert-px(3) fun.convert-px(6) 0
-      var(--color-shadow-light);
-}
diff --git a/src/styles/globals.scss b/src/styles/globals.scss
index f9a1281..8ece909 100644
--- a/src/styles/globals.scss
+++ b/src/styles/globals.scss
@@ -6,7 +6,6 @@
  * Import each files separately to define vendors styles order.
  */
 @use "modern-normalize";
-@use "vendors/prism";
 
 /**
  * 2.0. Base
@@ -22,14 +21,7 @@
 @use "base/typography";
 
 /**
- * 3.0. Components
- *
- * Define styles for external components (like WordPress blocks).
- */
-@use "components/wp-blocks";
-
-/**
- * 4.0. Themes
+ * 3.0. Themes
  *
  * Define themes specific styles.
  */
diff --git a/src/styles/pages/article.module.scss b/src/styles/pages/article.module.scss
new file mode 100644
index 0000000..a42c633
--- /dev/null
+++ b/src/styles/pages/article.module.scss
@@ -0,0 +1,36 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+@use "@styles/abstracts/variables" as var;
+@use "partials/article-links";
+@use "partials/article-lists";
+@use "partials/article-media";
+@use "partials/article-prism";
+@use "partials/article-wp-blocks";
+
+.btn {
+  margin-right: var(--spacing-2xs);
+  padding: var(--spacing-2xs) var(--spacing-xs);
+
+  &__icon {
+    max-width: fun.convert-px(22);
+    margin-right: var(--spacing-2xs);
+  }
+}
+
+.body {
+  :global {
+    @include article-links.styles;
+    @include article-lists.styles;
+    @include article-media.styles;
+    @include article-prism.styles;
+    @include article-wp-blocks.styles;
+  }
+}
+
+.widget {
+  @include mix.media("screen") {
+    @include mix.dimensions("md") {
+      width: min-content;
+    }
+  }
+}
diff --git a/src/styles/pages/partials/_article-headings.scss b/src/styles/pages/partials/_article-headings.scss
new file mode 100644
index 0000000..c0c3519
--- /dev/null
+++ b/src/styles/pages/partials/_article-headings.scss
@@ -0,0 +1,57 @@
+@use "@styles/abstracts/functions" as fun;
+
+@mixin styles {
+  h1 {
+    font-size: var(--font-size-3xl);
+    font-weight: 500;
+  }
+
+  h2 {
+    padding-bottom: fun.convert-px(3);
+    background: linear-gradient(
+        to top,
+        var(--color-primary-dark) 0.3rem,
+        transparent 0.3rem
+      )
+      0 0 / 3rem 100% no-repeat;
+    font-size: var(--font-size-2xl);
+    font-weight: 500;
+    text-shadow: fun.convert-px(1) fun.convert-px(1) 0 var(--color-shadow-light);
+  }
+
+  h3 {
+    font-size: var(--font-size-xl);
+    font-weight: 500;
+  }
+
+  h4 {
+    font-size: var(--font-size-lg);
+    font-weight: 500;
+  }
+
+  h5 {
+    font-size: var(--font-size-md);
+    font-weight: 600;
+  }
+
+  h6 {
+    font-size: var(--font-size-md);
+    font-weight: 500;
+  }
+
+  h1,
+  h2,
+  h3,
+  h4,
+  h5,
+  h6 {
+    color: var(--color-primary-dark);
+    font-family: var(--font-family-secondary);
+    letter-spacing: 0.01ex;
+    margin: 0 0 var(--spacing-sm);
+
+    & + & {
+      margin-top: var(--spacing-md);
+    }
+  }
+}
diff --git a/src/styles/pages/partials/_article-links.scss b/src/styles/pages/partials/_article-links.scss
new file mode 100644
index 0000000..df86dcf
--- /dev/null
+++ b/src/styles/pages/partials/_article-links.scss
@@ -0,0 +1,104 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/variables" as var;
+
+@mixin styles {
+  a {
+    &[hreflang] {
+      &::after {
+        display: inline-block;
+
+        /* Prettier is removing spacing between content parts. */
+
+        /* prettier-ignore */
+        content: "\0000a0[" attr(hreflang) "]";
+        font-size: var(--font-size-sm);
+      }
+    }
+  }
+
+  a.download {
+    &::after {
+      display: inline-block;
+
+      /* Prettier is removing spacing between content parts. */
+
+      /* prettier-ignore */
+      content: "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_blue}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>'));
+    }
+
+    &:focus:not(:active)::after {
+      /* Prettier is removing spacing between content parts. */
+
+      /* prettier-ignore */
+      content: "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_white}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_white}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>'));
+    }
+
+    &[hreflang] {
+      &::after {
+        /* Prettier is removing spacing between content parts. */
+
+        /* prettier-ignore */
+        content: "\0000a0[" attr(hreflang) "]\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_blue}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>'));
+      }
+    }
+  }
+
+  a.external {
+    &::after {
+      display: inline-block;
+
+      /* Prettier is removing spacing between content parts. */
+
+      /* prettier-ignore */
+      content: "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_blue}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
+    }
+
+    &:focus:not(:active)::after {
+      /* Prettier is removing spacing between content parts. */
+
+      /* prettier-ignore */
+      content: "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_white}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_white}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
+    }
+
+    &[hreflang] {
+      &::after {
+        /* Prettier is removing spacing between content parts. */
+
+        /* prettier-ignore */
+        content: "\0000a0[" attr(hreflang) "]\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_blue}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
+      }
+
+      &:focus:not(:active)::after {
+        /* Prettier is removing spacing between content parts. */
+
+        /* prettier-ignore */
+        content: "\0000a0[" attr(hreflang) "]\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_white}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_white}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
+      }
+    }
+  }
+
+  a.external.download {
+    &::after {
+      /* Prettier is removing spacing between content parts. */
+
+      /* prettier-ignore */
+      content: "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_blue}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>')) "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_blue}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
+    }
+
+    &[hreflang] {
+      &::after {
+        /* Prettier is removing spacing between content parts. */
+
+        /* prettier-ignore */
+        content: "\0000a0[" attr(hreflang) "]\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_blue}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>')) "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_blue}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
+      }
+
+      &:focus:not(:active)::after {
+        /* Prettier is removing spacing between content parts. */
+
+        /* prettier-ignore */
+        content: "\0000a0[" attr(hreflang) "]\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_blue}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>')) "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_white}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_white}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
+      }
+    }
+  }
+}
diff --git a/src/styles/pages/partials/_article-lists.scss b/src/styles/pages/partials/_article-lists.scss
new file mode 100644
index 0000000..c0084b0
--- /dev/null
+++ b/src/styles/pages/partials/_article-lists.scss
@@ -0,0 +1,65 @@
+@mixin styles {
+  ol {
+    padding: 0;
+    list-style-type: none;
+    counter-reset: li;
+
+    > li {
+      display: table;
+      counter-increment: li;
+
+      &::before {
+        content: counters(li, ".") ". ";
+        display: table-cell;
+        padding-right: var(--spacing-2xs);
+        color: var(--color-secondary);
+      }
+    }
+
+    li ol > li::before {
+      content: counters(li, ".") ". ";
+    }
+  }
+
+  ul,
+  ol {
+    li:not(:last-child) {
+      margin-bottom: var(--spacing-2xs);
+    }
+
+    ::marker {
+      color: var(--color-primary-dark);
+    }
+  }
+
+  ul {
+    padding-left: var(--spacing-sm);
+  }
+
+  dl {
+    display: flex;
+    flex-flow: row wrap;
+    gap: var(--spacing-2xs);
+    width: fit-content;
+  }
+
+  ul,
+  ol,
+  dl {
+    margin: var(--spacing-sm) 0;
+
+    & & {
+      margin: var(--spacing-2xs) 0 0;
+    }
+  }
+
+  dt {
+    color: var(--color-fg-light);
+    font-weight: 600;
+  }
+
+  dd {
+    margin: 0;
+    word-break: break-all;
+  }
+}
diff --git a/src/styles/pages/partials/_article-media.scss b/src/styles/pages/partials/_article-media.scss
new file mode 100644
index 0000000..8359881
--- /dev/null
+++ b/src/styles/pages/partials/_article-media.scss
@@ -0,0 +1,11 @@
+@mixin styles {
+  figure {
+    margin: var(--spacing-md) 0;
+  }
+
+  figcaption {
+    margin-top: var(--spacing-xs);
+    font-size: var(--font-size-sm);
+    text-align: center;
+  }
+}
diff --git a/src/styles/pages/partials/_article-prism.scss b/src/styles/pages/partials/_article-prism.scss
new file mode 100644
index 0000000..025e0c0
--- /dev/null
+++ b/src/styles/pages/partials/_article-prism.scss
@@ -0,0 +1,301 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+@mixin styles {
+  .code-toolbar {
+    --gutter-size: clamp(#{fun.convert-px(75)}, 20vw, #{fun.convert-px(90)});
+    --toolbar-height: #{fun.convert-px(90)};
+
+    position: relative;
+    margin-top: calc(var(--toolbar-height) + var(--spacing-md));
+
+    @include mix.media("screen") {
+      @include mix.dimensions("2xs") {
+        --toolbar-height: #{fun.convert-px(60)};
+      }
+    }
+
+    .toolbar {
+      display: grid;
+      grid-template-columns: max-content minmax(0, 1fr);
+      justify-items: end;
+      width: 100%;
+      height: var(--toolbar-height);
+      position: absolute;
+      top: calc(var(--toolbar-height) * -1);
+      left: 0;
+      right: 0;
+      background: var(--color-bg-tertiary);
+      border: fun.convert-px(1) solid var(--color-border);
+
+      @include mix.media("screen") {
+        @include mix.dimensions("2xs") {
+          display: flex;
+          flex-flow: row wrap;
+        }
+      }
+    }
+
+    .toolbar-item {
+      display: flex;
+      align-items: center;
+    }
+
+    .toolbar-item:nth-child(1) {
+      grid-column: 1;
+      grid-row: 1 / 3;
+      margin-right: auto;
+      padding: 0 var(--spacing-sm);
+      background: var(--color-bg-code);
+      border-right: fun.convert-px(1) solid var(--color-border);
+      color: var(--color-primary-darker);
+      font-size: var(--font-size-sm);
+      font-weight: 600;
+    }
+
+    .toolbar-item:nth-child(2) {
+      grid-column: 2;
+      grid-row: 1;
+      margin: 0 var(--spacing-2xs);
+    }
+
+    .toolbar-item:nth-child(3) {
+      grid-column: 2;
+      grid-row: 2;
+      margin: 0 var(--spacing-2xs);
+    }
+  }
+
+  pre[class*="language-"] {
+    max-height: max(30vw, fun.convert-px(300));
+    margin: var(--spacing-md) 0;
+    padding: 0;
+    position: relative;
+    background: var(--color-bg-secondary);
+    color: var(--color-fg);
+    border: fun.convert-px(1) solid var(--color-border);
+
+    > code {
+      display: block;
+      padding: var(--spacing-xs) 0 var(--spacing-xs)
+        calc(var(--gutter-size) + var(--spacing-xs));
+    }
+
+    .line-numbers-rows,
+    .command-line-prompt {
+      width: var(--gutter-size);
+      min-height: 100%;
+      padding: var(--spacing-xs) var(--spacing-2xs);
+      position: absolute;
+      top: 0;
+      left: 0;
+      pointer-events: none;
+      user-select: none;
+      background: var(--color-bg);
+      border-right: fun.convert-px(1) solid var(--color-border);
+    }
+
+    .token {
+      &.comment,
+      &.doc-comment {
+        color: var(--color-fg-light);
+      }
+
+      &.punctuation {
+        color: var(--color-fg);
+      }
+
+      &.attr-name,
+      &.hexcode,
+      &.inserted,
+      &.string {
+        color: var(--color-token-green);
+      }
+
+      &.class,
+      &.coord,
+      &.id,
+      &.function {
+        color: var(--color-token-purple);
+      }
+
+      &.builtin,
+      &.builtin.class-name,
+      &.property-access,
+      &.regex,
+      &.scope {
+        color: var(--color-token-magenta);
+      }
+
+      &.class-name,
+      &.constant,
+      &.global,
+      &.interpolation,
+      &.key,
+      &.package,
+      &.this,
+      &.title,
+      &.variable {
+        color: var(--color-token-blue);
+      }
+
+      &.combinator,
+      &.keyword,
+      &.operator,
+      &.pseudo-class,
+      &.pseudo-element,
+      &.rule,
+      &.selector,
+      &.unit {
+        color: var(--color-token-orange);
+      }
+
+      &.attr-value,
+      &.boolean,
+      &.number {
+        color: var(--color-token-yellow);
+      }
+
+      &.delimiter,
+      &.doctype,
+      &.parameter,
+      &.parent,
+      &.property,
+      &.shebang,
+      &.tag {
+        color: var(--color-token-cyan);
+      }
+
+      &.deleted {
+        color: var(--color-token-red);
+      }
+
+      &.punctuation.brace-hover,
+      &.punctuation.brace-selected {
+        background: var(--color-bg);
+        outline: solid fun.convert-px(1) var(--color-primary-light);
+      }
+    }
+
+    span.inline-color-wrapper {
+      background: url(fun.encode-svg(
+        '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2 2"><path fill="gray" d="M0 0h2v2H0z"/><path fill="white" d="M0 0h1v1H0zM1 1h1v1H1z"/></svg>'
+      ));
+
+      /* Prevent glitches where 1px from the repeating pattern could be seen. */
+      background-position: center;
+      background-size: 110%;
+
+      display: inline-block;
+      height: 1.1ch;
+      width: 1.1ch;
+      margin: 0 0.5ch 0 0;
+      border: fun.convert-px(1) solid var(--color-bg);
+      outline: fun.convert-px(1) solid var(--color-border-dark);
+      overflow: hidden;
+    }
+
+    span.inline-color {
+      display: block;
+
+      /* To prevent visual glitches again */
+      height: 120%;
+      width: 120%;
+    }
+  }
+
+  pre.line-numbers {
+    counter-reset: lineNumber;
+
+    .line-numbers-rows {
+      > span {
+        counter-increment: lineNumber;
+
+        &::before {
+          display: block;
+          padding: 0 var(--spacing-xs);
+          content: counter(lineNumber);
+          color: var(--color-primary-darker);
+          text-align: right;
+          line-height: var(--line-height);
+        }
+      }
+    }
+  }
+
+  pre.command-line {
+    --gutter-size: clamp(#{fun.convert-px(195)}, 48vw, #{fun.convert-px(235)});
+
+    ~ .toolbar {
+      --gutter-size: clamp(
+        #{fun.convert-px(195)},
+        48vw,
+        #{fun.convert-px(235)}
+      );
+    }
+
+    .command-line-prompt {
+      > span {
+        &::before {
+          display: block;
+          content: "";
+        }
+
+        &[data-user]::before {
+          content: "[" attr(data-user) "@" attr(data-host) "] $";
+        }
+
+        &[data-user="root"]::before {
+          content: "[" attr(data-user) "@" attr(data-host) "] #";
+        }
+
+        &[data-prompt]::before {
+          content: attr(data-prompt);
+        }
+      }
+    }
+  }
+
+  .copy-to-clipboard-button,
+  .prism-color-scheme-button {
+    display: block;
+    padding: fun.convert-px(3) var(--spacing-xs);
+    background: var(--color-bg);
+    border: 0.4ex solid var(--color-primary);
+    border-radius: fun.convert-px(30);
+    box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1)
+        var(--color-shadow),
+      fun.convert-px(1) fun.convert-px(2) fun.convert-px(2) fun.convert-px(-2)
+        var(--color-shadow),
+      fun.convert-px(3) fun.convert-px(4) fun.convert-px(5) fun.convert-px(-4)
+        var(--color-shadow);
+    color: var(--color-primary);
+    font-size: var(--font-size-sm);
+    font-weight: 600;
+    transition: all 0.35s ease-in-out 0s;
+
+    &:hover,
+    &:focus {
+      transform: translateX(#{fun.convert-px(-2)})
+        translateY(#{fun.convert-px(-2)});
+      box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1)
+          var(--color-shadow-light),
+        fun.convert-px(1) fun.convert-px(2) fun.convert-px(2) fun.convert-px(-2)
+          var(--color-shadow-light),
+        fun.convert-px(3) fun.convert-px(4) fun.convert-px(5) fun.convert-px(-4)
+          var(--color-shadow-light),
+        fun.convert-px(4) fun.convert-px(7) fun.convert-px(8) fun.convert-px(-3)
+          var(--color-shadow-light);
+    }
+
+    &:focus {
+      text-decoration: underline var(--color-primary) fun.convert-px(3);
+    }
+
+    &:active {
+      text-decoration: none;
+      transform: translateY(#{fun.convert-px(2)});
+      box-shadow: 0 0 0 0 var(--color-shadow);
+    }
+  }
+}
diff --git a/src/styles/pages/partials/_article-wp-blocks.scss b/src/styles/pages/partials/_article-wp-blocks.scss
new file mode 100644
index 0000000..d4fed5a
--- /dev/null
+++ b/src/styles/pages/partials/_article-wp-blocks.scss
@@ -0,0 +1,168 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+@use "@styles/abstracts/placeholders";
+
+@mixin styles {
+  .wp-block-quote {
+    margin: var(--spacing-sm) 0;
+    padding: var(--spacing-sm);
+    position: relative;
+    border: fun.convert-px(1) solid var(--color-primary-lighter);
+    border-left: fun.convert-px(5) solid var(--color-primary-lighter);
+    box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
+      fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
+        var(--color-shadow-light),
+      fun.convert-px(3) fun.convert-px(3) fun.convert-px(6) 0
+        var(--color-shadow-light);
+    font-style: italic;
+
+    > *:last-child {
+      margin: 0;
+    }
+
+    cite {
+      font-size: var(--font-size-sm);
+      font-style: normal;
+      font-weight: 600;
+    }
+  }
+
+  .wp-block-code,
+  .wp-block-preformatted {
+    margin: 0 auto var(--spacing-md);
+    padding: var(--spacing-xs) var(--spacing-sm);
+    background: var(--color-bg-secondary);
+    border: fun.convert-px(1) solid var(--color-border-light);
+    color: var(--color-fg);
+  }
+
+  .wp-block-columns {
+    display: grid;
+    grid-template-columns: minmax(0, 1fr);
+    gap: var(--spacing-md);
+    margin: var(--spacing-md) 0;
+
+    @include mix.media("screen") {
+      @include mix.dimensions("sm") {
+        grid-template-columns: repeat(2, minmax(0, 1fr));
+      }
+    }
+
+    &.are-vertically-aligned-center {
+      align-items: center;
+    }
+  }
+
+  .wp-block-column {
+    > *:first-child {
+      margin-top: 0;
+    }
+
+    > *:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  .wp-block-gallery {
+    display: grid;
+    grid-template-columns: minmax(0, 1fr);
+    gap: var(--spacing-sm);
+
+    .blocks-gallery-grid {
+      @extend %reset-list;
+
+      grid-column: 1 / -1;
+      grid-row: 1 / -1;
+      display: grid;
+      grid-template-columns: minmax(0, 1fr);
+      gap: var(--spacing-sm);
+    }
+
+    .blocks-gallery-item {
+      figure {
+        margin: 0;
+      }
+
+      a {
+        display: block;
+        box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
+          fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
+            var(--color-shadow-light),
+          fun.convert-px(3) fun.convert-px(3) fun.convert-px(6) 0
+            var(--color-shadow-light);
+
+        &:hover,
+        &:focus {
+          transform: scale(1.05);
+          box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
+            fun.convert-px(3) fun.convert-px(3) fun.convert-px(2) 0
+              var(--color-shadow-light),
+            fun.convert-px(5) fun.convert-px(5) fun.convert-px(8) 0
+              var(--color-shadow-light);
+        }
+
+        &:focus {
+          outline: solid var(--color-primary-light);
+        }
+
+        &:active {
+          transform: scale(0.95);
+          box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
+            fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
+              var(--color-shadow-light),
+            0 0 0 0 var(--color-shadow-light);
+          outline: none;
+        }
+      }
+    }
+
+    &.aligncenter {
+      .blocks-gallery-grid {
+        align-items: center;
+      }
+    }
+
+    @for $i from 0 to 6 {
+      &.columns-#{$i} {
+        @include mix.media("screen") {
+          @include mix.dimensions("xs") {
+            grid-template-columns: repeat(2, minmax(0, 1fr));
+
+            .blocks-gallery-grid {
+              grid-template-columns: repeat(2, minmax(0, 1fr));
+            }
+          }
+
+          @include mix.dimensions("sm") {
+            grid-template-columns: repeat(#{$i}, minmax(0, 1fr));
+
+            .blocks-gallery-grid {
+              grid-template-columns: repeat(3, minmax(0, 1fr));
+            }
+          }
+        }
+      }
+    }
+  }
+
+  .wp-block-image {
+    img {
+      display: block;
+      margin: auto;
+      box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
+        fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
+          var(--color-shadow-light),
+        fun.convert-px(3) fun.convert-px(3) fun.convert-px(6) 0
+          var(--color-shadow-light);
+      text-align: center;
+    }
+  }
+
+  .wp-block-video {
+    box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
+      fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
+        var(--color-shadow-light),
+      fun.convert-px(3) fun.convert-px(3) fun.convert-px(6) 0
+        var(--color-shadow-light);
+  }
+}
diff --git a/src/styles/pages/topic.module.scss b/src/styles/pages/topic.module.scss
new file mode 100644
index 0000000..badb694
--- /dev/null
+++ b/src/styles/pages/topic.module.scss
@@ -0,0 +1,6 @@
+@use "@styles/abstracts/functions" as fun;
+
+.logo {
+  max-width: fun.convert-px(50);
+  margin-right: var(--spacing-xs);
+}
diff --git a/src/ts/types/app.ts b/src/ts/types/app.ts
index f354118..a3b9889 100644
--- a/src/ts/types/app.ts
+++ b/src/ts/types/app.ts
@@ -81,6 +81,7 @@ export type Page<T extends PageKind> = {
 
 export type PageLink = {
   id: number;
+  logo?: Image;
   name: string;
   slug: string;
 };
diff --git a/src/ts/types/raw-data.ts b/src/ts/types/raw-data.ts
index 7e12e7f..ba6d596 100644
--- a/src/ts/types/raw-data.ts
+++ b/src/ts/types/raw-data.ts
@@ -89,14 +89,17 @@ export type RawThematic = RawPage & {
 
 export type RawThematicPreview = Pick<
   RawThematic,
-  'databaseId' | 'slug' | 'title'
+  'databaseId' | 'featuredImage' | 'slug' | 'title'
 >;
 
 export type RawTopic = RawPage & {
   acfTopics: ACFTopics;
 };
 
-export type RawTopicPreview = Pick<RawTopic, 'databaseId' | 'slug' | 'title'>;
+export type RawTopicPreview = Pick<
+  RawTopic,
+  'databaseId' | 'featuredImage' | 'slug' | 'title'
+>;
 
 export type TotalItems = {
   pageInfo: Pick<PageInfo, 'total'>;
diff --git a/src/utils/helpers/pages.ts b/src/utils/helpers/pages.ts
index 93582f0..62337db 100644
--- a/src/utils/helpers/pages.ts
+++ b/src/utils/helpers/pages.ts
@@ -5,12 +5,14 @@ import {
   type RawThematicPreview,
   type RawTopicPreview,
 } from '@ts/types/raw-data';
+import { getImageFromRawData } from './images';
 
 /**
  * Convert raw data to a Link object.
  *
  * @param data - An object.
  * @param {number} data.databaseId - The data id.
+ * @param {number} [data.logo] - The data logo.
  * @param {string} data.slug - The data slug.
  * @param {string} data.title - The data name.
  * @returns {PageLink} The link data (id, slug and title).
@@ -18,10 +20,11 @@ import {
 export const getPageLinkFromRawData = (
   data: RawThematicPreview | RawTopicPreview
 ): PageLink => {
-  const { databaseId, slug, title } = data;
+  const { databaseId, featuredImage, slug, title } = data;
 
   return {
     id: databaseId,
+    logo: featuredImage ? getImageFromRawData(featuredImage?.node) : undefined,
     name: title,
     slug,
   };
diff --git a/src/utils/hooks/use-add-prism-class-attr.tsx b/src/utils/hooks/use-add-prism-class-attr.tsx
new file mode 100644
index 0000000..7d33cc2
--- /dev/null
+++ b/src/utils/hooks/use-add-prism-class-attr.tsx
@@ -0,0 +1,60 @@
+import { useCallback, useEffect, useState } from 'react';
+
+export type AttributesMap = {
+  [key: string]: string;
+};
+
+export type useAddPrismClassAttrProps = {
+  attributes?: AttributesMap;
+  classNames?: string;
+};
+
+/**
+ * Add classnames and/or attributes to pre elements.
+ *
+ * @param props - An object of attributes and classnames.
+ */
+const useAddPrismClassAttr = ({
+  attributes,
+  classNames,
+}: useAddPrismClassAttrProps) => {
+  const [elements, setElements] = useState<HTMLPreElement[]>([]);
+
+  useEffect(() => {
+    const targetElements = document.querySelectorAll('pre');
+    setElements(Array.from(targetElements));
+  }, []);
+
+  const setClassNameAndAttributes = useCallback(
+    (array: HTMLElement[]) => {
+      array.forEach((el) => {
+        if (classNames) {
+          const classNamesArray = classNames.split(' ');
+          const isCommandLine = el.classList.contains('command-line');
+          const removedClassName = isCommandLine
+            ? 'line-numbers'
+            : 'command-line';
+          const filteredClassNames = classNamesArray.filter(
+            (className) => className !== removedClassName
+          );
+          filteredClassNames.forEach((className) =>
+            el.classList.add(className)
+          );
+        }
+
+        if (attributes) {
+          for (const [key, value] of Object.entries(attributes)) {
+            el.setAttribute(key, value);
+          }
+        }
+      });
+    },
+    [attributes, classNames]
+  );
+
+  useEffect(() => {
+    if (elements.length > 0) setClassNameAndAttributes(elements);
+  }, [elements, setClassNameAndAttributes]);
+};
+
+export default useAddPrismClassAttr;
-- 
cgit v1.2.3


From 271ef6debaca7ed9a01829dcef3a37e90a2dff05 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Tue, 17 May 2022 22:48:41 +0200
Subject: chore: use persistent layout

It prevents to rerender the common components between pages (header,
footer...).
---
 src/components/templates/layout/layout.module.scss | 14 ++++++
 src/components/templates/layout/layout.test.tsx    | 11 ++---
 src/components/templates/layout/layout.tsx         | 54 +++++++++++++---------
 .../templates/page/page-layout.module.scss         | 11 -----
 .../templates/page/page-layout.stories.tsx         | 24 ++++------
 src/components/templates/page/page-layout.tsx      | 35 +++++++-------
 .../sectioned/sectioned-layout.stories.tsx         |  8 ++++
 .../templates/sectioned/sectioned-layout.tsx       | 25 ++++++++--
 src/pages/404.tsx                                  |  9 +++-
 src/pages/_app.tsx                                 |  7 +--
 src/pages/article/[slug].tsx                       | 16 +++++--
 src/pages/blog/index.tsx                           | 14 ++++--
 src/pages/contact.tsx                              | 11 +++--
 src/pages/cv.tsx                                   |  9 +++-
 src/pages/index.tsx                                | 20 +++++---
 src/pages/mentions-legales.tsx                     |  9 +++-
 src/pages/projets/[slug].tsx                       | 14 ++++--
 src/pages/projets/index.tsx                        | 10 ++--
 src/pages/recherche/index.tsx                      | 14 ++++--
 src/pages/sujet/[slug].tsx                         | 18 ++++++--
 src/pages/thematique/[slug].tsx                    | 15 ++++--
 src/ts/types/app.ts                                | 21 +++++++++
 22 files changed, 253 insertions(+), 116 deletions(-)

(limited to 'src/components/templates/page/page-layout.stories.tsx')

diff --git a/src/components/templates/layout/layout.module.scss b/src/components/templates/layout/layout.module.scss
index 09904dc..1080732 100644
--- a/src/components/templates/layout/layout.module.scss
+++ b/src/components/templates/layout/layout.module.scss
@@ -1,5 +1,6 @@
 @use "@styles/abstracts/functions" as fun;
 @use "@styles/abstracts/mixins" as mix;
+@use "@styles/abstracts/placeholders";
 
 .header {
   border-bottom: fun.convert-px(3) solid var(--color-border-light);
@@ -9,6 +10,19 @@
   flex: 1;
 }
 
+.article {
+  &--grid {
+    @extend %grid;
+
+    grid-auto-flow: column dense;
+    align-items: baseline;
+  }
+
+  &--padding {
+    padding-bottom: var(--spacing-lg);
+  }
+}
+
 .footer {
   border-top: fun.convert-px(3) solid var(--color-border-light);
 }
diff --git a/src/components/templates/layout/layout.test.tsx b/src/components/templates/layout/layout.test.tsx
index 94145ec..78547d4 100644
--- a/src/components/templates/layout/layout.test.tsx
+++ b/src/components/templates/layout/layout.test.tsx
@@ -4,33 +4,32 @@ import Layout from './layout';
 
 const body =
   'Sit dolorem eveniet. Sit sit odio nemo vitae corrupti modi sint est rerum. Pariatur quidem maiores distinctio. Quia et illum aspernatur est cum.';
-const breadcrumbSchema: BreadcrumbList['itemListElement'][] = [];
 
 describe('Layout', () => {
   it('renders the website header', () => {
-    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
+    render(<Layout>{body}</Layout>);
     expect(screen.getByRole('banner')).toBeInTheDocument();
   });
 
   it('renders the website main content', () => {
-    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
+    render(<Layout>{body}</Layout>);
     expect(screen.getByRole('main')).toBeInTheDocument();
   });
 
   it('renders the website footer', () => {
-    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
+    render(<Layout>{body}</Layout>);
     expect(screen.getByRole('contentinfo')).toBeInTheDocument();
   });
 
   it('renders a skip to content link', () => {
-    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
+    render(<Layout>{body}</Layout>);
     expect(
       screen.getByRole('link', { name: 'Skip to content' })
     ).toBeInTheDocument();
   });
 
   it('renders an article', () => {
-    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
+    render(<Layout>{body}</Layout>);
     expect(screen.getByRole('article')).toHaveTextContent(body);
   });
 });
diff --git a/src/components/templates/layout/layout.tsx b/src/components/templates/layout/layout.tsx
index 3eb02ee..559eaed 100644
--- a/src/components/templates/layout/layout.tsx
+++ b/src/components/templates/layout/layout.tsx
@@ -9,18 +9,13 @@ import Main from '@components/atoms/layout/main';
 import NoScript from '@components/atoms/layout/no-script';
 import Footer, { type FooterProps } from '@components/organisms/layout/footer';
 import Header, { type HeaderProps } from '@components/organisms/layout/header';
+import { type NextPageWithLayoutOptions } from '@ts/types/app';
 import useScrollPosition from '@utils/hooks/use-scroll-position';
 import useSettings from '@utils/hooks/use-settings';
 import Script from 'next/script';
-import { FC, ReactNode, useState } from 'react';
+import { FC, ReactElement, ReactNode, useState } from 'react';
 import { useIntl } from 'react-intl';
-import {
-  BreadcrumbList,
-  Person,
-  SearchAction,
-  WebSite,
-  WithContext,
-} from 'schema-dts';
+import { Person, SearchAction, WebSite, WithContext } from 'schema-dts';
 import styles from './layout.module.scss';
 
 export type QueryAction = SearchAction & {
@@ -28,18 +23,18 @@ export type QueryAction = SearchAction & {
 };
 
 export type LayoutProps = Pick<HeaderProps, 'isHome'> & {
-  /**
-   * The breadcrumb JSON schema.
-   */
-  breadcrumbSchema: BreadcrumbList['itemListElement'][];
   /**
    * The layout main content.
    */
   children: ReactNode;
   /**
-   * Set additional classnames to the article element.
+   * Determine if article has a comments section.
+   */
+  withExtraPadding?: boolean;
+  /**
+   * Determine if article should use grid. Default: false.
    */
-  className?: string;
+  useGrid?: boolean;
 };
 
 /**
@@ -48,14 +43,16 @@ export type LayoutProps = Pick<HeaderProps, 'isHome'> & {
  * Render the base layout used by all pages.
  */
 const Layout: FC<LayoutProps> = ({
-  breadcrumbSchema,
   children,
+  withExtraPadding = false,
   isHome,
-  ...props
+  useGrid = false,
 }) => {
   const intl = useIntl();
   const { website } = useSettings();
   const { baseline, copyright, locales, name, picture, url } = website;
+  const articleGridClass = useGrid ? 'article--grid' : '';
+  const articleCommentsClass = withExtraPadding ? 'article--padding' : '';
 
   const skipToContent = intl.formatMessage({
     defaultMessage: 'Skip to content',
@@ -188,11 +185,6 @@ const Layout: FC<LayoutProps> = ({
         type="application/ld+json"
         dangerouslySetInnerHTML={{ __html: JSON.stringify(brandingSchema) }}
       />
-      <Script
-        id="schema-breadcrumb"
-        type="application/ld+json"
-        dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
-      />
       <noscript>
         <div className={styles['noscript-spacing']}></div>
       </noscript>
@@ -211,7 +203,11 @@ const Layout: FC<LayoutProps> = ({
         className={styles.header}
       />
       <Main id="main" className={styles.main}>
-        <article {...props}>{children}</article>
+        <article
+          className={`${styles[articleGridClass]} ${styles[articleCommentsClass]}`}
+        >
+          {children}
+        </article>
       </Main>
       <Footer
         copyright={copyrightData}
@@ -227,4 +223,18 @@ const Layout: FC<LayoutProps> = ({
   );
 };
 
+/**
+ * Get the global layout.
+ *
+ * @param {ReactElement} page - A page.
+ * @param {boolean} [isHome] - Determine if it is the homepage.
+ * @returns A page wrapped with the global layout.
+ */
+export const getLayout = (
+  page: ReactElement,
+  props: NextPageWithLayoutOptions
+) => {
+  return <Layout {...props}>{page}</Layout>;
+};
+
 export default Layout;
diff --git a/src/components/templates/page/page-layout.module.scss b/src/components/templates/page/page-layout.module.scss
index c6b4e8d..c7674ae 100644
--- a/src/components/templates/page/page-layout.module.scss
+++ b/src/components/templates/page/page-layout.module.scss
@@ -2,17 +2,6 @@
 @use "@styles/abstracts/mixins" as mix;
 @use "@styles/abstracts/placeholders";
 
-.article {
-  @extend %grid;
-
-  grid-auto-flow: column dense;
-  align-items: baseline;
-
-  &--no-comments {
-    padding-bottom: var(--spacing-lg);
-  }
-}
-
 .breadcrumb {
   @extend %grid;
 
diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx
index 8af5f98..bec1066 100644
--- a/src/components/templates/page/page-layout.stories.tsx
+++ b/src/components/templates/page/page-layout.stories.tsx
@@ -1,11 +1,11 @@
 import ButtonLink from '@components/atoms/buttons/button-link';
 import Heading from '@components/atoms/headings/heading';
 import Link from '@components/atoms/links/link';
-import ProgressBar from '@components/atoms/loaders/progress-bar';
 import PostsList from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import Sharing from '@components/organisms/widgets/sharing';
 import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { LayoutBase } from '../layout/layout.stories';
 import PageLayoutComponent from './page-layout';
 
 /**
@@ -118,20 +118,6 @@ export default {
         required: false,
       },
     },
-    isHome: {
-      control: {
-        type: 'boolean',
-      },
-      description: 'Determine if the current page is the homepage.',
-      table: {
-        category: 'Options',
-        defaultValue: { summary: false },
-      },
-      type: {
-        name: 'boolean',
-        required: false,
-      },
-    },
     title: {
       control: {
         type: 'text',
@@ -168,6 +154,13 @@ export default {
       },
     },
   },
+  decorators: [
+    (Story) => (
+      <LayoutBase {...LayoutBase.args}>
+        <Story />
+      </LayoutBase>
+    ),
+  ],
   parameters: {
     layout: 'fullscreen',
   },
@@ -477,7 +470,6 @@ Blog.args = {
   children: (
     <>
       <PostsList posts={posts} byYear={true} total={posts.length} />
-      <ProgressBar min={1} max={1} current={1} info="1/1 page loaded." />
     </>
   ),
   widgets: [
diff --git a/src/components/templates/page/page-layout.tsx b/src/components/templates/page/page-layout.tsx
index 2ff2084..d171944 100644
--- a/src/components/templates/page/page-layout.tsx
+++ b/src/components/templates/page/page-layout.tsx
@@ -21,19 +21,20 @@ import TableOfContents from '@components/organisms/widgets/table-of-contents';
 import { type SendCommentVars } from '@services/graphql/api';
 import { sendComment } from '@services/graphql/comments';
 import useIsMounted from '@utils/hooks/use-is-mounted';
+import Script from 'next/script';
 import { FC, HTMLAttributes, ReactNode, useRef, useState } from 'react';
 import { useIntl } from 'react-intl';
-import Layout, { type LayoutProps } from '../layout/layout';
+import { BreadcrumbList } from 'schema-dts';
 import styles from './page-layout.module.scss';
 
-export type PageLayoutProps = Pick<
-  LayoutProps,
-  'breadcrumbSchema' | 'isHome'
-> & {
+export type PageLayoutProps = {
   /**
    * True if the page accepts new comments. Default: false.
    */
   allowComments?: boolean;
+  /**
+   * Set attributes to the page body.
+   */
   bodyAttributes?: HTMLAttributes<HTMLDivElement>;
   /**
    * Set additional classnames to the body wrapper.
@@ -43,6 +44,10 @@ export type PageLayoutProps = Pick<
    * The breadcrumb items.
    */
   breadcrumb: BreadcrumbItem[];
+  /**
+   * The breadcrumb JSON schema.
+   */
+  breadcrumbSchema: BreadcrumbList['itemListElement'][];
   /**
    * The main content of the page.
    */
@@ -98,7 +103,6 @@ const PageLayout: FC<PageLayoutProps> = ({
   headerMeta,
   id,
   intro,
-  isHome = false,
   title,
   widgets,
   withToC = false,
@@ -117,13 +121,7 @@ const PageLayout: FC<PageLayoutProps> = ({
 
   const bodyRef = useRef<HTMLDivElement>(null);
   const isMounted = useIsMounted(bodyRef);
-
   const hasComments = Array.isArray(comments) && comments.length > 0;
-  const articleModifier =
-    hasComments || allowComments
-      ? 'article--has-comments'
-      : 'article--no-comments';
-
   const [status, setStatus] = useState<NoticeKind>('info');
   const [statusMessage, setStatusMessage] = useState<string>('');
   const isReplyRef = useRef<boolean>(false);
@@ -186,11 +184,12 @@ const PageLayout: FC<PageLayoutProps> = ({
   };
 
   return (
-    <Layout
-      breadcrumbSchema={breadcrumbSchema}
-      isHome={isHome}
-      className={`${styles.article} ${styles[articleModifier]}`}
-    >
+    <>
+      <Script
+        id="schema-breadcrumb"
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
+      />
       <Breadcrumb
         items={breadcrumb}
         className={styles.breadcrumb}
@@ -291,7 +290,7 @@ const PageLayout: FC<PageLayoutProps> = ({
           </section>
         </div>
       )}
-    </Layout>
+    </>
   );
 };
 
diff --git a/src/components/templates/sectioned/sectioned-layout.stories.tsx b/src/components/templates/sectioned/sectioned-layout.stories.tsx
index ce31a83..689f9a7 100644
--- a/src/components/templates/sectioned/sectioned-layout.stories.tsx
+++ b/src/components/templates/sectioned/sectioned-layout.stories.tsx
@@ -1,4 +1,5 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { LayoutBase } from '../layout/layout.stories';
 import SectionedLayoutComponent from './sectioned-layout';
 
 /**
@@ -31,6 +32,13 @@ export default {
       },
     },
   },
+  decorators: [
+    (Story) => (
+      <LayoutBase {...LayoutBase.args}>
+        <Story />
+      </LayoutBase>
+    ),
+  ],
   parameters: {
     layout: 'fullscreen',
   },
diff --git a/src/components/templates/sectioned/sectioned-layout.tsx b/src/components/templates/sectioned/sectioned-layout.tsx
index 58d5ad0..f91c354 100644
--- a/src/components/templates/sectioned/sectioned-layout.tsx
+++ b/src/components/templates/sectioned/sectioned-layout.tsx
@@ -2,12 +2,17 @@ import Section, {
   type SectionProps,
   type SectionVariant,
 } from '@components/atoms/layout/section';
+import Script from 'next/script';
 import { FC } from 'react';
-import Layout, { type LayoutProps } from '../layout/layout';
+import { BreadcrumbList } from 'schema-dts';
 
 export type Section = Pick<SectionProps, 'content' | 'title'>;
 
-export type SectionedLayoutProps = Pick<LayoutProps, 'breadcrumbSchema'> & {
+export type SectionedLayoutProps = {
+  /**
+   * The breadcrumb JSON schema.
+   */
+  breadcrumbSchema: BreadcrumbList['itemListElement'][];
   /**
    * An array of objects describing each section.
    */
@@ -19,7 +24,10 @@ export type SectionedLayoutProps = Pick<LayoutProps, 'breadcrumbSchema'> & {
  *
  * Render a sectioned layout.
  */
-const SectionedLayout: FC<SectionedLayoutProps> = ({ sections, ...props }) => {
+const SectionedLayout: FC<SectionedLayoutProps> = ({
+  breadcrumbSchema,
+  sections,
+}) => {
   const getSections = (items: SectionProps[]) => {
     return items.map((section, index) => {
       const variant: SectionVariant = index % 2 ? 'light' : 'dark';
@@ -37,7 +45,16 @@ const SectionedLayout: FC<SectionedLayoutProps> = ({ sections, ...props }) => {
     });
   };
 
-  return <Layout {...props}>{getSections(sections)}</Layout>;
+  return (
+    <>
+      <Script
+        id="schema-breadcrumb"
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
+      />
+      {getSections(sections)}
+    </>
+  );
 };
 
 export default SectionedLayout;
diff --git a/src/pages/404.tsx b/src/pages/404.tsx
index 4f6e22d..4ab7784 100644
--- a/src/pages/404.tsx
+++ b/src/pages/404.tsx
@@ -1,11 +1,13 @@
 import Link from '@components/atoms/links/link';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout from '@components/templates/page/page-layout';
 import {
   getThematicsPreview,
   getTotalThematics,
 } from '@services/graphql/thematics';
 import { getTopicsPreview, getTotalTopics } from '@services/graphql/topics';
+import { type NextPageWithLayout } from '@ts/types/app';
 import {
   type RawThematicPreview,
   type RawTopicPreview,
@@ -17,7 +19,7 @@ import {
 } from '@utils/helpers/pages';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
-import { GetStaticProps, NextPage } from 'next';
+import { GetStaticProps } from 'next';
 import Head from 'next/head';
 import { ReactNode } from 'react';
 import { useIntl } from 'react-intl';
@@ -31,7 +33,7 @@ type Error404PageProps = {
 /**
  * Error 404 page.
  */
-const Error404Page: NextPage<Error404PageProps> = ({
+const Error404Page: NextPageWithLayout<Error404PageProps> = ({
   thematicsList,
   topicsList,
 }) => {
@@ -119,6 +121,9 @@ const Error404Page: NextPage<Error404PageProps> = ({
   );
 };
 
+Error404Page.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 export const getStaticProps: GetStaticProps<Error404PageProps> = async ({
   locale,
 }) => {
diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx
index 939b337..5bc9f85 100644
--- a/src/pages/_app.tsx
+++ b/src/pages/_app.tsx
@@ -1,15 +1,16 @@
+import { type AppPropsWithLayout } from '@ts/types/app';
 import { settings } from '@utils/config';
 import { AckeeProvider } from '@utils/providers/ackee';
 import { PrismThemeProvider } from '@utils/providers/prism-theme';
 import { ThemeProvider } from 'next-themes';
-import { AppProps } from 'next/app';
 import { useRouter } from 'next/router';
 import { IntlProvider } from 'react-intl';
 import '../styles/globals.scss';
 
-const App = ({ Component, pageProps }: AppProps) => {
+const App = ({ Component, pageProps }: AppPropsWithLayout) => {
   const { locale, defaultLocale } = useRouter();
   const appLocale: string = locale || settings.locales.defaultLocale;
+  const getLayout = Component.getLayout ?? ((page) => page);
 
   return (
     <AckeeProvider domain={settings.ackee.url} siteId={settings.ackee.siteId}>
@@ -24,7 +25,7 @@ const App = ({ Component, pageProps }: AppProps) => {
           enableSystem={true}
         >
           <PrismThemeProvider>
-            <Component {...pageProps} />
+            {getLayout(<Component {...pageProps} />, {})}
           </PrismThemeProvider>
         </ThemeProvider>
       </IntlProvider>
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
index a0fb7fc..a3df43b 100644
--- a/src/pages/article/[slug].tsx
+++ b/src/pages/article/[slug].tsx
@@ -2,6 +2,7 @@ import ButtonLink from '@components/atoms/buttons/button-link';
 import Link from '@components/atoms/links/link';
 import ResponsiveImage from '@components/molecules/images/responsive-image';
 import Sharing from '@components/organisms/widgets/sharing';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout, {
   type PageLayoutProps,
 } from '@components/templates/page/page-layout';
@@ -11,7 +12,11 @@ import {
 } from '@services/graphql/articles';
 import { getPostComments } from '@services/graphql/comments';
 import styles from '@styles/pages/article.module.scss';
-import { type Article, type Comment } from '@ts/types/app';
+import {
+  type Article,
+  type Comment,
+  type NextPageWithLayout,
+} from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import useAddPrismClassAttr from '@utils/hooks/use-add-prism-class-attr';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
@@ -20,7 +25,7 @@ import usePrismPlugins, {
 } from '@utils/hooks/use-prism-plugins';
 import useReadingTime from '@utils/hooks/use-reading-time';
 import useSettings from '@utils/hooks/use-settings';
-import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
+import { GetStaticPaths, GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
@@ -39,7 +44,10 @@ type ArticlePageProps = {
 /**
  * Article page.
  */
-const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
+const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
+  comments,
+  post,
+}) => {
   const { content, id, intro, meta, slug, title } = post;
   const {
     author,
@@ -252,6 +260,8 @@ const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
   );
 };
 
+ArticlePage.getLayout = (page) => getLayout(page, { useGrid: true });
+
 interface PostParams extends ParsedUrlQuery {
   slug: string;
 }
diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx
index b6ce221..2676305 100644
--- a/src/pages/blog/index.tsx
+++ b/src/pages/blog/index.tsx
@@ -1,6 +1,7 @@
 import Notice from '@components/atoms/layout/notice';
 import PostsList, { type Post } from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout from '@components/templates/page/page-layout';
 import { type EdgesResponse } from '@services/graphql/api';
 import {
@@ -13,7 +14,11 @@ import {
   getTotalThematics,
 } from '@services/graphql/thematics';
 import { getTopicsPreview, getTotalTopics } from '@services/graphql/topics';
-import { type Article, type Meta } from '@ts/types/app';
+import {
+  type Article,
+  type Meta,
+  type NextPageWithLayout,
+} from '@ts/types/app';
 import {
   RawThematicPreview,
   RawTopicPreview,
@@ -28,7 +33,7 @@ import {
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import usePagination from '@utils/hooks/use-pagination';
 import useSettings from '@utils/hooks/use-settings';
-import { GetStaticProps, NextPage } from 'next';
+import { GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
@@ -46,7 +51,7 @@ type BlogPageProps = {
 /**
  * Blog index page.
  */
-const BlogPage: NextPage<BlogPageProps> = ({
+const BlogPage: NextPageWithLayout<BlogPageProps> = ({
   articles,
   thematicsList,
   topicsList,
@@ -268,6 +273,9 @@ const BlogPage: NextPage<BlogPageProps> = ({
   );
 };
 
+BlogPage.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 export const getStaticProps: GetStaticProps<BlogPageProps> = async ({
   locale,
 }) => {
diff --git a/src/pages/contact.tsx b/src/pages/contact.tsx
index 617117b..c0d6c79 100644
--- a/src/pages/contact.tsx
+++ b/src/pages/contact.tsx
@@ -3,22 +3,24 @@ import ContactForm, {
   type ContactFormProps,
 } from '@components/organisms/forms/contact-form';
 import SocialMedia from '@components/organisms/widgets/social-media';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout from '@components/templates/page/page-layout';
 import { meta } from '@content/pages/contact.mdx';
 import styles from '@styles/pages/contact.module.scss';
 import { sendMail } from '@services/graphql/contact';
+import { type NextPageWithLayout } from '@ts/types/app';
 import { loadTranslation } from '@utils/helpers/i18n';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
-import { GetStaticProps, NextPage } from 'next';
+import { GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
 import { useState } from 'react';
 import { useIntl } from 'react-intl';
 import { ContactPage as ContactPageSchema, Graph, WebPage } from 'schema-dts';
-import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 
-const ContactPage: NextPage = () => {
+const ContactPage: NextPageWithLayout = () => {
   const { dates, intro, seo, title } = meta;
   const intl = useIntl();
   const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
@@ -164,6 +166,9 @@ const ContactPage: NextPage = () => {
   );
 };
 
+ContactPage.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 export const getStaticProps: GetStaticProps = async ({ locale }) => {
   const translation = await loadTranslation(locale);
 
diff --git a/src/pages/cv.tsx b/src/pages/cv.tsx
index 7936c84..3f035d8 100644
--- a/src/pages/cv.tsx
+++ b/src/pages/cv.tsx
@@ -3,16 +3,18 @@ import Link from '@components/atoms/links/link';
 import List from '@components/atoms/lists/list';
 import ImageWidget from '@components/organisms/widgets/image-widget';
 import SocialMedia from '@components/organisms/widgets/social-media';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout, {
   type PageLayoutProps,
 } from '@components/templates/page/page-layout';
 import CVContent, { data, meta } from '@content/pages/cv.mdx';
 import styles from '@styles/pages/cv.module.scss';
+import { type NextPageWithLayout } from '@ts/types/app';
 import { loadTranslation } from '@utils/helpers/i18n';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { NestedMDXComponents } from 'mdx/types';
-import { GetStaticProps, NextPage } from 'next';
+import { GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
@@ -23,7 +25,7 @@ import { AboutPage, Graph, WebPage } from 'schema-dts';
 /**
  * CV page.
  */
-const CVPage: NextPage = () => {
+const CVPage: NextPageWithLayout = () => {
   const intl = useIntl();
   const { file, image } = data;
   const { dates, intro, seo, title } = meta;
@@ -186,6 +188,9 @@ const CVPage: NextPage = () => {
   );
 };
 
+CVPage.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 export const getStaticProps: GetStaticProps = async ({ locale }) => {
   const translation = await loadTranslation(locale);
 
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index 1143a33..a831ea3 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -13,16 +13,16 @@ import Columns, {
 import CardsList, {
   type CardsListItem,
 } from '@components/organisms/layout/cards-list';
-import Layout from '@components/templates/layout/layout';
+import { getLayout } from '@components/templates/layout/layout';
 import HomePageContent from '@content/pages/homepage.mdx';
 import { getArticlesCard } from '@services/graphql/articles';
 import styles from '@styles/pages/home.module.scss';
-import { ArticleCard } from '@ts/types/app';
+import { type ArticleCard, type NextPageWithLayout } from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { NestedMDXComponents } from 'mdx/types';
-import { GetStaticProps, NextPage } from 'next';
+import { GetStaticProps } from 'next';
 import Head from 'next/head';
 import Script from 'next/script';
 import { ReactElement } from 'react';
@@ -37,7 +37,7 @@ type HomeProps = {
 /**
  * Home page.
  */
-const HomePage: NextPage<HomeProps> = ({ recentPosts }) => {
+const HomePage: NextPageWithLayout<HomeProps> = ({ recentPosts }) => {
   const intl = useIntl();
   const { schema: breadcrumbSchema } = useBreadcrumb({
     title: '',
@@ -327,7 +327,7 @@ const HomePage: NextPage<HomeProps> = ({ recentPosts }) => {
   };
 
   return (
-    <Layout breadcrumbSchema={breadcrumbSchema} isHome={true}>
+    <>
       <Head>
         <title>{pageTitle}</title>
         <meta name="description" content={pageDescription} />
@@ -340,11 +340,19 @@ const HomePage: NextPage<HomeProps> = ({ recentPosts }) => {
         type="application/ld+json"
         dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
       />
+      <Script
+        id="schema-breadcrumb"
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
+      />
       <HomePageContent components={components} />
-    </Layout>
+    </>
   );
 };
 
+HomePage.getLayout = (page) =>
+  getLayout(page, { isHome: true, withExtraPadding: false });
+
 export const getStaticProps: GetStaticProps<HomeProps> = async ({ locale }) => {
   const translation = await loadTranslation(locale);
   const recentPosts = await getArticlesCard({ first: 3 });
diff --git a/src/pages/mentions-legales.tsx b/src/pages/mentions-legales.tsx
index 41bc218..c8d1772 100644
--- a/src/pages/mentions-legales.tsx
+++ b/src/pages/mentions-legales.tsx
@@ -1,14 +1,16 @@
 import Link from '@components/atoms/links/link';
 import ResponsiveImage from '@components/molecules/images/responsive-image';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout, {
   type PageLayoutProps,
 } from '@components/templates/page/page-layout';
 import LegalNoticeContent, { meta } from '@content/pages/legal-notice.mdx';
+import { type NextPageWithLayout } from '@ts/types/app';
 import { loadTranslation } from '@utils/helpers/i18n';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { NestedMDXComponents } from 'mdx/types';
-import { GetStaticProps, NextPage } from 'next';
+import { GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
@@ -17,7 +19,7 @@ import { Article, Graph, WebPage } from 'schema-dts';
 /**
  * Legal Notice page.
  */
-const LegalNoticePage: NextPage = () => {
+const LegalNoticePage: NextPageWithLayout = () => {
   const { dates, intro, seo, title } = meta;
   const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
     title,
@@ -111,6 +113,9 @@ const LegalNoticePage: NextPage = () => {
   );
 };
 
+LegalNoticePage.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 export const getStaticProps: GetStaticProps = async ({ locale }) => {
   const translation = await loadTranslation(locale);
 
diff --git a/src/pages/projets/[slug].tsx b/src/pages/projets/[slug].tsx
index 1a90e0f..cf7d0dc 100644
--- a/src/pages/projets/[slug].tsx
+++ b/src/pages/projets/[slug].tsx
@@ -6,10 +6,15 @@ import Code from '@components/molecules/layout/code';
 import Gallery from '@components/organisms/images/gallery';
 import Overview, { OverviewMeta } from '@components/organisms/layout/overview';
 import Sharing from '@components/organisms/widgets/sharing';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout, {
   PageLayoutProps,
 } from '@components/templates/page/page-layout';
-import { ProjectPreview, Repos } from '@ts/types/app';
+import {
+  type NextPageWithLayout,
+  type ProjectPreview,
+  type 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';
@@ -17,7 +22,7 @@ import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 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 { GetStaticPaths, GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
@@ -33,7 +38,7 @@ type ProjectPageProps = {
 /**
  * Project page.
  */
-const ProjectPage: NextPage<ProjectPageProps> = ({ project }) => {
+const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ project }) => {
   const { id, intro, meta, title } = project;
   const { cover, dates, license, repos, seo, technologies } = meta;
   const intl = useIntl();
@@ -207,6 +212,9 @@ const ProjectPage: NextPage<ProjectPageProps> = ({ project }) => {
   );
 };
 
+ProjectPage.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 export const getStaticProps: GetStaticProps<ProjectPageProps> = async ({
   locale,
   params,
diff --git a/src/pages/projets/index.tsx b/src/pages/projets/index.tsx
index 4a58269..d500b6b 100644
--- a/src/pages/projets/index.tsx
+++ b/src/pages/projets/index.tsx
@@ -2,16 +2,17 @@ import Link from '@components/atoms/links/link';
 import CardsList, {
   CardsListItem,
 } from '@components/organisms/layout/cards-list';
+import { getLayout } from '@components/templates/layout/layout';
 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 { type NextPageWithLayout, type ProjectCard } from '@ts/types/app';
 import { loadTranslation, Messages } from '@utils/helpers/i18n';
 import { getProjectsCard } from '@utils/helpers/projects';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { NestedMDXComponents } from 'mdx/types';
-import { GetStaticProps, NextPage } from 'next';
+import { GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
@@ -25,7 +26,7 @@ type ProjectsPageProps = {
 /**
  * Projects page.
  */
-const ProjectsPage: NextPage<ProjectsPageProps> = ({ projects }) => {
+const ProjectsPage: NextPageWithLayout<ProjectsPageProps> = ({ projects }) => {
   const { dates, seo, title } = meta;
   const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
     title,
@@ -122,6 +123,9 @@ const ProjectsPage: NextPage<ProjectsPageProps> = ({ projects }) => {
   );
 };
 
+ProjectsPage.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 export const getStaticProps: GetStaticProps<ProjectsPageProps> = async ({
   locale,
 }) => {
diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx
index d88a293..09091c8 100644
--- a/src/pages/recherche/index.tsx
+++ b/src/pages/recherche/index.tsx
@@ -2,6 +2,7 @@ import Notice from '@components/atoms/layout/notice';
 import Spinner from '@components/atoms/loaders/spinner';
 import PostsList, { type Post } from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout from '@components/templates/page/page-layout';
 import { type EdgesResponse } from '@services/graphql/api';
 import {
@@ -14,7 +15,11 @@ import {
   getTotalThematics,
 } from '@services/graphql/thematics';
 import { getTopicsPreview, getTotalTopics } from '@services/graphql/topics';
-import { type Article, type Meta } from '@ts/types/app';
+import {
+  type Article,
+  type Meta,
+  type NextPageWithLayout,
+} from '@ts/types/app';
 import {
   RawThematicPreview,
   RawTopicPreview,
@@ -29,7 +34,7 @@ import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useDataFromAPI from '@utils/hooks/use-data-from-api';
 import usePagination from '@utils/hooks/use-pagination';
 import useSettings from '@utils/hooks/use-settings';
-import { GetStaticProps, NextPage } from 'next';
+import { GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
@@ -45,7 +50,7 @@ type SearchPageProps = {
 /**
  * Search page.
  */
-const SearchPage: NextPage<SearchPageProps> = ({
+const SearchPage: NextPageWithLayout<SearchPageProps> = ({
   thematicsList,
   topicsList,
 }) => {
@@ -283,6 +288,9 @@ const SearchPage: NextPage<SearchPageProps> = ({
   );
 };
 
+SearchPage.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 export const getStaticProps: GetStaticProps<SearchPageProps> = async ({
   locale,
 }) => {
diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx
index 348fe05..6277293 100644
--- a/src/pages/sujet/[slug].tsx
+++ b/src/pages/sujet/[slug].tsx
@@ -2,6 +2,7 @@ import Heading from '@components/atoms/headings/heading';
 import ResponsiveImage from '@components/molecules/images/responsive-image';
 import PostsList, { type Post } from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout, {
   type PageLayoutProps,
 } from '@components/templates/page/page-layout';
@@ -12,7 +13,12 @@ import {
   getTotalTopics,
 } from '@services/graphql/topics';
 import styles from '@styles/pages/topic.module.scss';
-import { type Article, type PageLink, type Topic } from '@ts/types/app';
+import {
+  type Article,
+  type NextPageWithLayout,
+  type PageLink,
+  type Topic,
+} from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import {
   getLinksListItems,
@@ -21,7 +27,7 @@ import {
 } from '@utils/helpers/pages';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
-import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
+import { GetStaticPaths, GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
@@ -35,7 +41,10 @@ export type TopicPageProps = {
   translation: Messages;
 };
 
-const TopicPage: NextPage<TopicPageProps> = ({ currentTopic, topics }) => {
+const TopicPage: NextPageWithLayout<TopicPageProps> = ({
+  currentTopic,
+  topics,
+}) => {
   const { content, intro, meta, slug, title } = currentTopic;
   const {
     articles,
@@ -208,6 +217,9 @@ const TopicPage: NextPage<TopicPageProps> = ({ currentTopic, topics }) => {
   );
 };
 
+TopicPage.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 interface TopicParams extends ParsedUrlQuery {
   slug: string;
 }
diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx
index 13ef0da..23e6a8b 100644
--- a/src/pages/thematique/[slug].tsx
+++ b/src/pages/thematique/[slug].tsx
@@ -1,6 +1,7 @@
 import Heading from '@components/atoms/headings/heading';
 import PostsList, { type Post } from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout, {
   type PageLayoutProps,
 } from '@components/templates/page/page-layout';
@@ -10,7 +11,12 @@ import {
   getThematicsPreview,
   getTotalThematics,
 } from '@services/graphql/thematics';
-import { type Article, type PageLink, type Thematic } from '@ts/types/app';
+import {
+  type Article,
+  type NextPageWithLayout,
+  type PageLink,
+  type Thematic,
+} from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import {
   getLinksListItems,
@@ -19,7 +25,7 @@ import {
 } from '@utils/helpers/pages';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
-import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
+import { GetStaticPaths, GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
@@ -33,7 +39,7 @@ export type ThematicPageProps = {
   translation: Messages;
 };
 
-const ThematicPage: NextPage<ThematicPageProps> = ({
+const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({
   currentThematic,
   thematics,
 }) => {
@@ -191,6 +197,9 @@ const ThematicPage: NextPage<ThematicPageProps> = ({
   );
 };
 
+ThematicPage.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 interface ThematicParams extends ParsedUrlQuery {
   slug: string;
 }
diff --git a/src/ts/types/app.ts b/src/ts/types/app.ts
index a3b9889..feff5a5 100644
--- a/src/ts/types/app.ts
+++ b/src/ts/types/app.ts
@@ -1,3 +1,24 @@
+import { NextPage } from 'next';
+import { AppProps } from 'next/app';
+import { ReactElement, ReactNode } from 'react';
+
+export type NextPageWithLayoutOptions = {
+  withExtraPadding?: boolean;
+  isHome?: boolean;
+  useGrid?: boolean;
+};
+
+export type NextPageWithLayout<T = {}> = NextPage<T> & {
+  getLayout?: (
+    page: ReactElement,
+    options: NextPageWithLayoutOptions
+  ) => ReactNode;
+};
+
+export type AppPropsWithLayout = AppProps & {
+  Component: NextPageWithLayout;
+};
+
 export type ContentKind =
   | 'article'
   | 'comment'
-- 
cgit v1.2.3


From 4581c262ca06704baaa3c0a172d509207f41f5c3 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Sat, 21 May 2022 18:42:19 +0200
Subject: chore: complete Storybook stories

---
 .../atoms/buttons/button-link.stories.tsx          | 14 ++++++
 src/components/atoms/icons/cc-by-sa.stories.tsx    |  8 ----
 src/components/atoms/layout/copyright.stories.tsx  |  8 ----
 src/components/atoms/layout/sidebar.stories.tsx    |  8 ----
 .../atoms/links/sharing-link.stories.tsx           |  5 +--
 .../atoms/lists/description-list-item.stories.tsx  |  2 +-
 src/components/atoms/lists/list.stories.tsx        | 13 ++++++
 src/components/atoms/loaders/spinner.stories.tsx   |  8 ----
 .../molecules/buttons/back-to-top.stories.tsx      |  8 ----
 .../molecules/buttons/heading-button.stories.tsx   |  1 +
 .../molecules/buttons/help-button.stories.tsx      |  8 ----
 src/components/molecules/buttons/help-button.tsx   | 11 ++---
 .../molecules/forms/labelled-field.stories.tsx     |  1 +
 .../forms/select-with-tooltip.stories.tsx          |  8 ----
 .../molecules/images/responsive-image.stories.tsx  | 13 ++++++
 .../molecules/layout/branding.stories.tsx          |  9 +---
 src/components/molecules/layout/code.stories.tsx   |  8 ----
 src/components/molecules/layout/code.tsx           | 36 ++--------------
 src/components/molecules/layout/meta.stories.tsx   | 34 +++++++--------
 src/components/molecules/layout/widget.stories.tsx | 50 +++++++++++++---------
 src/components/molecules/modals/modal.stories.tsx  | 13 ++++++
 .../molecules/nav/breadcrumb.stories.tsx           |  8 ----
 src/components/molecules/nav/nav.stories.tsx       |  8 ----
 .../molecules/nav/pagination.stories.tsx           | 12 ++----
 .../organisms/forms/comment-form.stories.tsx       | 21 +++++++++
 .../organisms/forms/contact-form.stories.tsx       | 11 ++---
 .../organisms/forms/search-form.stories.tsx        | 10 +++++
 .../organisms/forms/settings-form.stories.tsx      | 20 +++++++++
 .../organisms/layout/comment.stories.tsx           |  1 +
 .../organisms/layout/comments-list.stories.tsx     |  4 +-
 src/components/organisms/layout/header.stories.tsx | 10 +++++
 .../organisms/layout/posts-list.stories.tsx        | 44 +++++++++++++++++++
 .../organisms/modals/search-modal.stories.tsx      | 10 +++++
 .../organisms/modals/settings-modal.stories.tsx    | 20 +++++++++
 .../organisms/toolbar/main-nav.stories.tsx         | 11 ++---
 .../organisms/toolbar/search.stories.tsx           | 13 ++++++
 .../organisms/toolbar/settings.stories.tsx         |  3 ++
 src/components/organisms/toolbar/settings.tsx      |  9 +---
 .../organisms/toolbar/toolbar.stories.tsx          | 10 +++++
 .../organisms/widgets/image-widget.stories.tsx     | 21 +++++----
 .../widgets/links-list-widget.stories.tsx          | 21 +++++----
 .../organisms/widgets/social-media.stories.tsx     |  8 ----
 .../widgets/table-of-contents.stories.tsx          |  8 ----
 src/components/templates/layout/layout.stories.tsx | 26 +++++++++++
 .../templates/page/page-layout.stories.tsx         | 24 +++++++++++
 45 files changed, 362 insertions(+), 237 deletions(-)

(limited to 'src/components/templates/page/page-layout.stories.tsx')

diff --git a/src/components/atoms/buttons/button-link.stories.tsx b/src/components/atoms/buttons/button-link.stories.tsx
index 2e1c040..d06aff3 100644
--- a/src/components/atoms/buttons/button-link.stories.tsx
+++ b/src/components/atoms/buttons/button-link.stories.tsx
@@ -47,6 +47,20 @@ export default {
         required: false,
       },
     },
+    external: {
+      control: {
+        type: 'boolean',
+      },
+      description: 'Determine if the link is an external link.',
+      table: {
+        category: 'Options',
+        defaultValue: { summary: false },
+      },
+      type: {
+        name: 'boolean',
+        required: false,
+      },
+    },
     kind: {
       control: {
         type: 'select',
diff --git a/src/components/atoms/icons/cc-by-sa.stories.tsx b/src/components/atoms/icons/cc-by-sa.stories.tsx
index f2bc8f0..4229725 100644
--- a/src/components/atoms/icons/cc-by-sa.stories.tsx
+++ b/src/components/atoms/icons/cc-by-sa.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import CCBySAIcon from './cc-by-sa';
 
 /**
@@ -23,13 +22,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof CCBySAIcon>;
 
 const Template: ComponentStory<typeof CCBySAIcon> = (args) => (
diff --git a/src/components/atoms/layout/copyright.stories.tsx b/src/components/atoms/layout/copyright.stories.tsx
index b988165..612b114 100644
--- a/src/components/atoms/layout/copyright.stories.tsx
+++ b/src/components/atoms/layout/copyright.stories.tsx
@@ -1,6 +1,5 @@
 import CCBySA from '@components/atoms/icons/cc-by-sa';
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import CopyrightComponent from './copyright';
 
 /**
@@ -39,13 +38,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof CopyrightComponent>;
 
 const Template: ComponentStory<typeof CopyrightComponent> = (args) => (
diff --git a/src/components/atoms/layout/sidebar.stories.tsx b/src/components/atoms/layout/sidebar.stories.tsx
index 175af94..6876f95 100644
--- a/src/components/atoms/layout/sidebar.stories.tsx
+++ b/src/components/atoms/layout/sidebar.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import SidebarComponent from './sidebar';
 
 /**
@@ -46,13 +45,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof SidebarComponent>;
 
 const Template: ComponentStory<typeof SidebarComponent> = (args) => (
diff --git a/src/components/atoms/links/sharing-link.stories.tsx b/src/components/atoms/links/sharing-link.stories.tsx
index c91e938..e6bd11b 100644
--- a/src/components/atoms/links/sharing-link.stories.tsx
+++ b/src/components/atoms/links/sharing-link.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import SharingLinkComponent from './sharing-link';
 
 /**
@@ -41,9 +40,7 @@ export default {
 } as ComponentMeta<typeof SharingLinkComponent>;
 
 const Template: ComponentStory<typeof SharingLinkComponent> = (args) => (
-  <IntlProvider locale="en">
-    <SharingLinkComponent {...args} />
-  </IntlProvider>
+  <SharingLinkComponent {...args} />
 );
 
 /**
diff --git a/src/components/atoms/lists/description-list-item.stories.tsx b/src/components/atoms/lists/description-list-item.stories.tsx
index e05493c..c7beb0d 100644
--- a/src/components/atoms/lists/description-list-item.stories.tsx
+++ b/src/components/atoms/lists/description-list-item.stories.tsx
@@ -52,7 +52,7 @@ export default {
       description: 'The item layout.',
       options: ['inline', 'inline-values', 'stacked'],
       table: {
-        category: 'Styles',
+        category: 'Options',
         defaultValue: { summary: 'stacked' },
       },
       type: {
diff --git a/src/components/atoms/lists/list.stories.tsx b/src/components/atoms/lists/list.stories.tsx
index 54fdd3a..eac3cd3 100644
--- a/src/components/atoms/lists/list.stories.tsx
+++ b/src/components/atoms/lists/list.stories.tsx
@@ -35,6 +35,19 @@ export default {
         value: {},
       },
     },
+    itemsClassName: {
+      control: {
+        type: 'text',
+      },
+      description: 'Set additional classnames to the list items.',
+      table: {
+        category: 'Styles',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     kind: {
       control: {
         type: 'select',
diff --git a/src/components/atoms/loaders/spinner.stories.tsx b/src/components/atoms/loaders/spinner.stories.tsx
index dc577dc..1792c6c 100644
--- a/src/components/atoms/loaders/spinner.stories.tsx
+++ b/src/components/atoms/loaders/spinner.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import SpinnerComponent from './spinner';
 
 /**
@@ -23,13 +22,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof SpinnerComponent>;
 
 const Template: ComponentStory<typeof SpinnerComponent> = (args) => (
diff --git a/src/components/molecules/buttons/back-to-top.stories.tsx b/src/components/molecules/buttons/back-to-top.stories.tsx
index 7d4bc39..a338b8b 100644
--- a/src/components/molecules/buttons/back-to-top.stories.tsx
+++ b/src/components/molecules/buttons/back-to-top.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import BackToTopComponent from './back-to-top';
 
 /**
@@ -33,13 +32,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof BackToTopComponent>;
 
 const Template: ComponentStory<typeof BackToTopComponent> = (args) => (
diff --git a/src/components/molecules/buttons/heading-button.stories.tsx b/src/components/molecules/buttons/heading-button.stories.tsx
index d1b5ed4..59f7be9 100644
--- a/src/components/molecules/buttons/heading-button.stories.tsx
+++ b/src/components/molecules/buttons/heading-button.stories.tsx
@@ -1,3 +1,4 @@
+import headingStories from '@components/atoms/headings/heading.stories';
 import { ComponentMeta, ComponentStory } from '@storybook/react';
 import { useState } from 'react';
 import HeadingButtonComponent from './heading-button';
diff --git a/src/components/molecules/buttons/help-button.stories.tsx b/src/components/molecules/buttons/help-button.stories.tsx
index cfc603e..4968b27 100644
--- a/src/components/molecules/buttons/help-button.stories.tsx
+++ b/src/components/molecules/buttons/help-button.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import HelpButtonComponent from './help-button';
 
 /**
@@ -36,13 +35,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof HelpButtonComponent>;
 
 const Template: ComponentStory<typeof HelpButtonComponent> = (args) => (
diff --git a/src/components/molecules/buttons/help-button.tsx b/src/components/molecules/buttons/help-button.tsx
index e351453..ccf1ebd 100644
--- a/src/components/molecules/buttons/help-button.tsx
+++ b/src/components/molecules/buttons/help-button.tsx
@@ -3,12 +3,7 @@ import { forwardRef, ForwardRefRenderFunction } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './help-button.module.scss';
 
-export type HelpButtonProps = Pick<ButtonProps, 'onClick'> & {
-  /**
-   * Set additional classnames to the button wrapper.
-   */
-  className?: string;
-};
+export type HelpButtonProps = Pick<ButtonProps, 'className' | 'onClick'>;
 
 /**
  * HelpButton component
@@ -18,7 +13,7 @@ export type HelpButtonProps = Pick<ButtonProps, 'onClick'> & {
 const HelpButton: ForwardRefRenderFunction<
   HTMLButtonElement,
   HelpButtonProps
-> = ({ className = '', onClick }, ref) => {
+> = ({ className = '', ...props }, ref) => {
   const intl = useIntl();
   const text = intl.formatMessage({
     defaultMessage: 'Help',
@@ -29,9 +24,9 @@ const HelpButton: ForwardRefRenderFunction<
   return (
     <Button
       className={`${styles.btn} ${className}`}
-      onClick={onClick}
       ref={ref}
       shape="circle"
+      {...props}
     >
       <span className="screen-reader-text">{text}</span>
       <span className={styles.icon}>?</span>
diff --git a/src/components/molecules/forms/labelled-field.stories.tsx b/src/components/molecules/forms/labelled-field.stories.tsx
index 56eef00..795e785 100644
--- a/src/components/molecules/forms/labelled-field.stories.tsx
+++ b/src/components/molecules/forms/labelled-field.stories.tsx
@@ -10,6 +10,7 @@ export default {
   component: LabelledField,
   args: {
     disabled: false,
+    hideLabel: false,
     labelPosition: 'top',
     required: false,
   },
diff --git a/src/components/molecules/forms/select-with-tooltip.stories.tsx b/src/components/molecules/forms/select-with-tooltip.stories.tsx
index d757b2b..ddf5d4c 100644
--- a/src/components/molecules/forms/select-with-tooltip.stories.tsx
+++ b/src/components/molecules/forms/select-with-tooltip.stories.tsx
@@ -1,6 +1,5 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
 import { useState } from 'react';
-import { IntlProvider } from 'react-intl';
 import SelectWithTooltip from './select-with-tooltip';
 
 /**
@@ -178,13 +177,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof SelectWithTooltip>;
 
 const selectOptions = [
diff --git a/src/components/molecules/images/responsive-image.stories.tsx b/src/components/molecules/images/responsive-image.stories.tsx
index 4294208..ca69d4f 100644
--- a/src/components/molecules/images/responsive-image.stories.tsx
+++ b/src/components/molecules/images/responsive-image.stories.tsx
@@ -34,6 +34,19 @@ export default {
         required: false,
       },
     },
+    className: {
+      control: {
+        type: 'text',
+      },
+      description: 'Set additional classnames to the image wrapper.',
+      table: {
+        category: 'Styles',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     height: {
       control: {
         type: 'number',
diff --git a/src/components/molecules/layout/branding.stories.tsx b/src/components/molecules/layout/branding.stories.tsx
index 1637c99..d2e0694 100644
--- a/src/components/molecules/layout/branding.stories.tsx
+++ b/src/components/molecules/layout/branding.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import Branding from './branding';
 
 /**
@@ -10,6 +9,7 @@ export default {
   component: Branding,
   args: {
     isHome: false,
+    withLink: false,
   },
   argTypes: {
     baseline: {
@@ -72,13 +72,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof Branding>;
 
 const Template: ComponentStory<typeof Branding> = (args) => (
diff --git a/src/components/molecules/layout/code.stories.tsx b/src/components/molecules/layout/code.stories.tsx
index a2a6b2c..ac0e98f 100644
--- a/src/components/molecules/layout/code.stories.tsx
+++ b/src/components/molecules/layout/code.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import CodeComponent from './code';
 
 /**
@@ -70,13 +69,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof CodeComponent>;
 
 const Template: ComponentStory<typeof CodeComponent> = (args) => (
diff --git a/src/components/molecules/layout/code.tsx b/src/components/molecules/layout/code.tsx
index f5b60b9..30351b9 100644
--- a/src/components/molecules/layout/code.tsx
+++ b/src/components/molecules/layout/code.tsx
@@ -1,38 +1,10 @@
-import usePrism, { OptionalPrismPlugin } from '@utils/hooks/use-prism';
+import usePrism, {
+  type OptionalPrismPlugin,
+  type PrismLanguage,
+} from '@utils/hooks/use-prism';
 import { FC, useRef } from 'react';
 import styles from './code.module.scss';
 
-export type PrismLanguage =
-  | 'apacheconf'
-  | 'bash'
-  | 'css'
-  | 'diff'
-  | 'docker'
-  | 'editorconfig'
-  | 'ejs'
-  | 'git'
-  | 'graphql'
-  | 'html'
-  | 'ignore'
-  | 'ini'
-  | 'javascript'
-  | 'jsdoc'
-  | 'json'
-  | 'jsx'
-  | 'makefile'
-  | 'markup'
-  | 'php'
-  | 'phpdoc'
-  | 'regex'
-  | 'scss'
-  | 'shell-session'
-  | 'smarty'
-  | 'tcl'
-  | 'toml'
-  | 'tsx'
-  | 'twig'
-  | 'yaml';
-
 export type CodeProps = {
   /**
    * The code to highlight.
diff --git a/src/components/molecules/layout/meta.stories.tsx b/src/components/molecules/layout/meta.stories.tsx
index a1755a0..c33680f 100644
--- a/src/components/molecules/layout/meta.stories.tsx
+++ b/src/components/molecules/layout/meta.stories.tsx
@@ -1,3 +1,5 @@
+import descriptionListItemStories from '@components/atoms/lists/description-list-item.stories';
+import descriptionListStories from '@components/atoms/lists/description-list.stories';
 import { ComponentMeta, ComponentStory } from '@storybook/react';
 import MetaComponent, { MetaData } from './meta';
 
@@ -7,7 +9,12 @@ import MetaComponent, { MetaData } from './meta';
 export default {
   title: 'Molecules/Layout',
   component: MetaComponent,
+  args: {
+    itemsLayout: 'inline-values',
+    withSeparator: false,
+  },
   argTypes: {
+    className: descriptionListStories.argTypes?.className,
     data: {
       description: 'The page metadata.',
       type: {
@@ -16,34 +23,23 @@ export default {
         value: {},
       },
     },
+    groupClassName: descriptionListStories.argTypes?.groupClassName,
     itemsLayout: {
-      control: {
-        type: 'select',
-      },
-      description: 'The items layout.',
-      options: ['inline', 'inline-values', 'stacked'],
+      ...descriptionListItemStories.argTypes?.layout,
       table: {
-        category: 'Options',
+        ...descriptionListItemStories.argTypes?.layout?.table,
         defaultValue: { summary: 'inline-values' },
       },
-      type: {
-        name: 'string',
-        required: false,
-      },
     },
+    labelClassName: descriptionListStories.argTypes?.labelClassName,
+    layout: descriptionListStories.argTypes?.layout,
+    valueClassName: descriptionListStories.argTypes?.valueClassName,
     withSeparator: {
-      control: {
-        type: 'boolean',
-      },
-      description: 'Add a slash as separator between multiple values.',
+      ...descriptionListStories.argTypes?.withSeparator,
       table: {
-        category: 'Options',
+        ...descriptionListStories.argTypes?.withSeparator?.table,
         defaultValue: { summary: true },
       },
-      type: {
-        name: 'boolean',
-        required: true,
-      },
     },
   },
 } as ComponentMeta<typeof MetaComponent>;
diff --git a/src/components/molecules/layout/widget.stories.tsx b/src/components/molecules/layout/widget.stories.tsx
index c113db9..dd5a30b 100644
--- a/src/components/molecules/layout/widget.stories.tsx
+++ b/src/components/molecules/layout/widget.stories.tsx
@@ -1,5 +1,5 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
+import headingButtonStories from '../buttons/heading-button.stories';
 import Widget from './widget';
 
 /**
@@ -10,6 +10,7 @@ export default {
   component: Widget,
   args: {
     withBorders: false,
+    withScroll: false,
   },
   argTypes: {
     children: {
@@ -22,6 +23,19 @@ export default {
         required: true,
       },
     },
+    className: {
+      control: {
+        type: 'text',
+      },
+      description: 'Set additional classnames to the widget wrapper.',
+      table: {
+        category: 'Styles',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     expanded: {
       control: {
         type: 'boolean',
@@ -36,18 +50,7 @@ export default {
         required: false,
       },
     },
-    level: {
-      control: {
-        type: 'number',
-        min: 1,
-        max: 6,
-      },
-      description: 'The heading level.',
-      type: {
-        name: 'number',
-        required: true,
-      },
-    },
+    level: headingButtonStories.argTypes?.level,
     title: {
       control: {
         type: 'text',
@@ -72,14 +75,21 @@ export default {
         required: false,
       },
     },
+    withScroll: {
+      control: {
+        type: 'boolean',
+      },
+      description: 'Define if the widget should be scrollable',
+      table: {
+        category: 'Options',
+        defaultValue: { summary: false },
+      },
+      type: {
+        name: 'boolean',
+        required: false,
+      },
+    },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof Widget>;
 
 const Template: ComponentStory<typeof Widget> = (args) => <Widget {...args} />;
diff --git a/src/components/molecules/modals/modal.stories.tsx b/src/components/molecules/modals/modal.stories.tsx
index 55b7677..f6dd364 100644
--- a/src/components/molecules/modals/modal.stories.tsx
+++ b/src/components/molecules/modals/modal.stories.tsx
@@ -31,6 +31,19 @@ export default {
         required: false,
       },
     },
+    headingClassName: {
+      control: {
+        type: 'text',
+      },
+      description: 'Set additional classnames to the modal heading.',
+      table: {
+        category: 'Styles',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     icon: {
       control: {
         type: 'select',
diff --git a/src/components/molecules/nav/breadcrumb.stories.tsx b/src/components/molecules/nav/breadcrumb.stories.tsx
index e26b480..cf67e60 100644
--- a/src/components/molecules/nav/breadcrumb.stories.tsx
+++ b/src/components/molecules/nav/breadcrumb.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import Breadcrumb from './breadcrumb';
 
 /**
@@ -44,13 +43,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof Breadcrumb>;
 
 const Template: ComponentStory<typeof Breadcrumb> = (args) => (
diff --git a/src/components/molecules/nav/nav.stories.tsx b/src/components/molecules/nav/nav.stories.tsx
index 5cef5f0..f3a29a6 100644
--- a/src/components/molecules/nav/nav.stories.tsx
+++ b/src/components/molecules/nav/nav.stories.tsx
@@ -1,7 +1,6 @@
 import Envelop from '@components/atoms/icons/envelop';
 import Home from '@components/atoms/icons/home';
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import NavComponent, { type NavItem } from './nav';
 
 /**
@@ -73,13 +72,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof NavComponent>;
 
 const Template: ComponentStory<typeof NavComponent> = (args) => (
diff --git a/src/components/molecules/nav/pagination.stories.tsx b/src/components/molecules/nav/pagination.stories.tsx
index b31c2b5..2e86db4 100644
--- a/src/components/molecules/nav/pagination.stories.tsx
+++ b/src/components/molecules/nav/pagination.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import PaginationComponent from './pagination';
 
 /**
@@ -8,6 +7,10 @@ import PaginationComponent from './pagination';
 export default {
   title: 'Molecules/Navigation/Pagination',
   component: PaginationComponent,
+  args: {
+    baseUrl: '/page/',
+    siblings: 1,
+  },
   argTypes: {
     'aria-label': {
       control: {
@@ -95,13 +98,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof PaginationComponent>;
 
 const Template: ComponentStory<typeof PaginationComponent> = (args) => (
diff --git a/src/components/organisms/forms/comment-form.stories.tsx b/src/components/organisms/forms/comment-form.stories.tsx
index 8b11df7..1a9e7b7 100644
--- a/src/components/organisms/forms/comment-form.stories.tsx
+++ b/src/components/organisms/forms/comment-form.stories.tsx
@@ -13,6 +13,8 @@ export default {
   component: CommentForm,
   args: {
     saveComment,
+    titleAlignment: 'left',
+    titleLevel: 2,
   },
   argTypes: {
     className: {
@@ -56,6 +58,9 @@ export default {
         type: null,
       },
       description: 'A callback function to process the comment form data.',
+      table: {
+        category: 'Events',
+      },
       type: {
         name: 'function',
         required: true,
@@ -74,6 +79,21 @@ export default {
         required: false,
       },
     },
+    titleAlignment: {
+      control: {
+        type: 'select',
+      },
+      description: 'The heading alignment.',
+      options: ['center', 'left'],
+      table: {
+        category: 'Options',
+        defaultValue: { summary: 'left' },
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     titleLevel: {
       control: {
         type: 'number',
@@ -83,6 +103,7 @@ export default {
       description: 'The title level (hn).',
       table: {
         category: 'Options',
+        defaultValue: { summary: 2 },
       },
       type: {
         name: 'number',
diff --git a/src/components/organisms/forms/contact-form.stories.tsx b/src/components/organisms/forms/contact-form.stories.tsx
index 39d0b71..191d448 100644
--- a/src/components/organisms/forms/contact-form.stories.tsx
+++ b/src/components/organisms/forms/contact-form.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import ContactForm from './contact-form';
 
 /**
@@ -40,19 +39,15 @@ export default {
         type: null,
       },
       description: 'A callback function to process the contact form data.',
+      table: {
+        category: 'Events',
+      },
       type: {
         name: 'function',
         required: true,
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof ContactForm>;
 
 const Template: ComponentStory<typeof ContactForm> = (args) => (
diff --git a/src/components/organisms/forms/search-form.stories.tsx b/src/components/organisms/forms/search-form.stories.tsx
index 6ea6122..d8c8e1e 100644
--- a/src/components/organisms/forms/search-form.stories.tsx
+++ b/src/components/organisms/forms/search-form.stories.tsx
@@ -39,6 +39,16 @@ export default {
         required: false,
       },
     },
+    searchPage: {
+      control: {
+        type: 'text',
+      },
+      description: 'The search results page url.',
+      type: {
+        name: 'string',
+        required: true,
+      },
+    },
   },
 } as ComponentMeta<typeof SearchForm>;
 
diff --git a/src/components/organisms/forms/settings-form.stories.tsx b/src/components/organisms/forms/settings-form.stories.tsx
index 46305e7..70e1844 100644
--- a/src/components/organisms/forms/settings-form.stories.tsx
+++ b/src/components/organisms/forms/settings-form.stories.tsx
@@ -8,6 +8,16 @@ export default {
   title: 'Organisms/Forms',
   component: SettingsForm,
   argTypes: {
+    ackeeStorageKey: {
+      control: {
+        type: 'text',
+      },
+      description: 'The local storage key for Ackee setting.',
+      type: {
+        name: 'string',
+        required: true,
+      },
+    },
     className: {
       control: {
         type: 'text',
@@ -21,6 +31,16 @@ export default {
         required: false,
       },
     },
+    motionStorageKey: {
+      control: {
+        type: 'text',
+      },
+      description: 'The local storage key for reduced motion setting.',
+      type: {
+        name: 'string',
+        required: true,
+      },
+    },
     tooltipClassName: {
       control: {
         type: 'text',
diff --git a/src/components/organisms/layout/comment.stories.tsx b/src/components/organisms/layout/comment.stories.tsx
index c31b77a..c7649ab 100644
--- a/src/components/organisms/layout/comment.stories.tsx
+++ b/src/components/organisms/layout/comment.stories.tsx
@@ -12,6 +12,7 @@ export default {
   title: 'Organisms/Layout',
   component: CommentComponent,
   args: {
+    canReply: true,
     saveComment,
   },
   argTypes: {
diff --git a/src/components/organisms/layout/comments-list.stories.tsx b/src/components/organisms/layout/comments-list.stories.tsx
index 4d95205..1ed159a 100644
--- a/src/components/organisms/layout/comments-list.stories.tsx
+++ b/src/components/organisms/layout/comments-list.stories.tsx
@@ -1,5 +1,5 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import CommentsListComponent, { Comment } from './comments-list';
+import CommentsListComponent, { type Comment } from './comments-list';
 
 const saveComment = async () => {
   /** Do nothing. */
@@ -29,6 +29,8 @@ export default {
     depth: {
       control: {
         type: 'number',
+        min: 0,
+        max: 4,
       },
       description: 'The maximum depth. Use `0` to not display nested comments.',
       type: {
diff --git a/src/components/organisms/layout/header.stories.tsx b/src/components/organisms/layout/header.stories.tsx
index 3ceb337..a5233ea 100644
--- a/src/components/organisms/layout/header.stories.tsx
+++ b/src/components/organisms/layout/header.stories.tsx
@@ -92,6 +92,16 @@ export default {
         required: true,
       },
     },
+    searchPage: {
+      control: {
+        type: 'text',
+      },
+      description: 'The search results page url.',
+      type: {
+        name: 'string',
+        required: true,
+      },
+    },
     title: {
       control: {
         type: 'text',
diff --git a/src/components/organisms/layout/posts-list.stories.tsx b/src/components/organisms/layout/posts-list.stories.tsx
index 77318f4..96fb2f0 100644
--- a/src/components/organisms/layout/posts-list.stories.tsx
+++ b/src/components/organisms/layout/posts-list.stories.tsx
@@ -10,10 +10,26 @@ export default {
   args: {
     byYear: false,
     isLoading: false,
+    pageNumber: 1,
     showLoadMoreBtn: false,
+    siblings: 1,
     titleLevel: 2,
   },
   argTypes: {
+    baseUrl: {
+      control: {
+        type: 'text',
+      },
+      description: 'The pagination base url.',
+      table: {
+        category: 'Options',
+        defaultValue: { summary: '/page/' },
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     byYear: {
       control: {
         type: 'boolean',
@@ -55,6 +71,20 @@ export default {
         required: false,
       },
     },
+    pageNumber: {
+      control: {
+        type: 'number',
+      },
+      description: 'The current page number.',
+      table: {
+        category: 'Options',
+        defaultValue: { summary: 1 },
+      },
+      type: {
+        name: 'number',
+        required: false,
+      },
+    },
     posts: {
       description: 'The posts data.',
       type: {
@@ -77,6 +107,20 @@ export default {
         required: false,
       },
     },
+    siblings: {
+      control: {
+        type: 'number',
+      },
+      description: 'The number of page siblings inside pagination.',
+      table: {
+        category: 'Options',
+        defaultValue: { summary: 1 },
+      },
+      type: {
+        name: 'number',
+        required: false,
+      },
+    },
     titleLevel: {
       control: {
         type: 'number',
diff --git a/src/components/organisms/modals/search-modal.stories.tsx b/src/components/organisms/modals/search-modal.stories.tsx
index f40696c..5a607c6 100644
--- a/src/components/organisms/modals/search-modal.stories.tsx
+++ b/src/components/organisms/modals/search-modal.stories.tsx
@@ -24,6 +24,16 @@ export default {
         required: false,
       },
     },
+    searchPage: {
+      control: {
+        type: 'text',
+      },
+      description: 'The search results page url.',
+      type: {
+        name: 'string',
+        required: true,
+      },
+    },
   },
 } as ComponentMeta<typeof SearchModal>;
 
diff --git a/src/components/organisms/modals/settings-modal.stories.tsx b/src/components/organisms/modals/settings-modal.stories.tsx
index 0fe8c18..d263e2b 100644
--- a/src/components/organisms/modals/settings-modal.stories.tsx
+++ b/src/components/organisms/modals/settings-modal.stories.tsx
@@ -8,6 +8,16 @@ export default {
   title: 'Organisms/Modals',
   component: SettingsModal,
   argTypes: {
+    ackeeStorageKey: {
+      control: {
+        type: 'text',
+      },
+      description: 'A local storage key for Ackee.',
+      type: {
+        name: 'string',
+        required: true,
+      },
+    },
     className: {
       control: {
         type: 'text',
@@ -21,6 +31,16 @@ export default {
         required: false,
       },
     },
+    motionStorageKey: {
+      control: {
+        type: 'text',
+      },
+      description: 'A local storage key for reduced motion setting..',
+      type: {
+        name: 'string',
+        required: true,
+      },
+    },
     tooltipClassName: {
       control: {
         type: 'text',
diff --git a/src/components/organisms/toolbar/main-nav.stories.tsx b/src/components/organisms/toolbar/main-nav.stories.tsx
index 04b22cb..831636f 100644
--- a/src/components/organisms/toolbar/main-nav.stories.tsx
+++ b/src/components/organisms/toolbar/main-nav.stories.tsx
@@ -1,6 +1,5 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
 import { useState } from 'react';
-import { IntlProvider } from 'react-intl';
 import MainNav from './main-nav';
 
 /**
@@ -46,19 +45,15 @@ export default {
         type: null,
       },
       description: 'A callback function to change main nav state.',
+      table: {
+        category: 'Events',
+      },
       type: {
         name: 'function',
         required: true,
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof MainNav>;
 
 const Template: ComponentStory<typeof MainNav> = ({
diff --git a/src/components/organisms/toolbar/search.stories.tsx b/src/components/organisms/toolbar/search.stories.tsx
index 6aaffde..f0f65b4 100644
--- a/src/components/organisms/toolbar/search.stories.tsx
+++ b/src/components/organisms/toolbar/search.stories.tsx
@@ -35,11 +35,24 @@ export default {
         required: true,
       },
     },
+    searchPage: {
+      control: {
+        type: 'text',
+      },
+      description: 'The search results page url.',
+      type: {
+        name: 'string',
+        required: true,
+      },
+    },
     setIsActive: {
       control: {
         type: null,
       },
       description: 'A callback function to update modal state.',
+      table: {
+        category: 'Events',
+      },
       type: {
         name: 'function',
         required: true,
diff --git a/src/components/organisms/toolbar/settings.stories.tsx b/src/components/organisms/toolbar/settings.stories.tsx
index 9d04932..08ec579 100644
--- a/src/components/organisms/toolbar/settings.stories.tsx
+++ b/src/components/organisms/toolbar/settings.stories.tsx
@@ -41,6 +41,9 @@ export default {
         type: null,
       },
       description: 'Define the modal state: either opened or closed.',
+      table: {
+        category: 'Events',
+      },
       type: {
         name: 'boolean',
         required: true,
diff --git a/src/components/organisms/toolbar/settings.tsx b/src/components/organisms/toolbar/settings.tsx
index 9985ba0..f558eb7 100644
--- a/src/components/organisms/toolbar/settings.tsx
+++ b/src/components/organisms/toolbar/settings.tsx
@@ -9,14 +9,7 @@ import SettingsModal, {
 import settingsStyles from './settings.module.scss';
 import sharedStyles from './toolbar-items.module.scss';
 
-export type SettingsProps = Pick<
-  SettingsModalProps,
-  'ackeeStorageKey' | 'motionStorageKey' | 'tooltipClassName'
-> & {
-  /**
-   * Set additional classnames to the modal wrapper.
-   */
-  className?: string;
+export type SettingsProps = SettingsModalProps & {
   /**
    * The button state.
    */
diff --git a/src/components/organisms/toolbar/toolbar.stories.tsx b/src/components/organisms/toolbar/toolbar.stories.tsx
index 1f07e79..d613442 100644
--- a/src/components/organisms/toolbar/toolbar.stories.tsx
+++ b/src/components/organisms/toolbar/toolbar.stories.tsx
@@ -54,6 +54,16 @@ export default {
         value: {},
       },
     },
+    searchPage: {
+      control: {
+        type: 'text',
+      },
+      description: 'The search results page url.',
+      type: {
+        name: 'string',
+        required: true,
+      },
+    },
   },
   parameters: {
     layout: 'fullscreen',
diff --git a/src/components/organisms/widgets/image-widget.stories.tsx b/src/components/organisms/widgets/image-widget.stories.tsx
index 3014b36..fa39745 100644
--- a/src/components/organisms/widgets/image-widget.stories.tsx
+++ b/src/components/organisms/widgets/image-widget.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import ImageWidget from './image-widget';
 
 /**
@@ -27,6 +26,19 @@ export default {
         required: false,
       },
     },
+    className: {
+      control: {
+        type: 'text',
+      },
+      description: 'Set additional classnames to the widget wrapper.',
+      table: {
+        category: 'Styles',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     description: {
       control: {
         type: 'text',
@@ -107,13 +119,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof ImageWidget>;
 
 const Template: ComponentStory<typeof ImageWidget> = (args) => (
diff --git a/src/components/organisms/widgets/links-list-widget.stories.tsx b/src/components/organisms/widgets/links-list-widget.stories.tsx
index 0f03a2c..cdfa96a 100644
--- a/src/components/organisms/widgets/links-list-widget.stories.tsx
+++ b/src/components/organisms/widgets/links-list-widget.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import LinksListWidget from './links-list-widget';
 
 /**
@@ -12,6 +11,19 @@ export default {
     kind: 'unordered',
   },
   argTypes: {
+    className: {
+      control: {
+        type: 'text',
+      },
+      description: 'Set additional classnames to the list wrapper.',
+      table: {
+        category: 'Styles',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     items: {
       description: 'The widget data.',
       type: {
@@ -58,13 +70,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof LinksListWidget>;
 
 const Template: ComponentStory<typeof LinksListWidget> = (args) => (
diff --git a/src/components/organisms/widgets/social-media.stories.tsx b/src/components/organisms/widgets/social-media.stories.tsx
index 2fa8bd7..6c9de4d 100644
--- a/src/components/organisms/widgets/social-media.stories.tsx
+++ b/src/components/organisms/widgets/social-media.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import SocialMediaWidget, { Media } from './social-media';
 
 /**
@@ -40,13 +39,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof SocialMediaWidget>;
 
 const Template: ComponentStory<typeof SocialMediaWidget> = (args) => (
diff --git a/src/components/organisms/widgets/table-of-contents.stories.tsx b/src/components/organisms/widgets/table-of-contents.stories.tsx
index fba7c54..d8b3af7 100644
--- a/src/components/organisms/widgets/table-of-contents.stories.tsx
+++ b/src/components/organisms/widgets/table-of-contents.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import ToCWidget from './table-of-contents';
 
 /**
@@ -21,13 +20,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof ToCWidget>;
 
 const Template: ComponentStory<typeof ToCWidget> = (args) => (
diff --git a/src/components/templates/layout/layout.stories.tsx b/src/components/templates/layout/layout.stories.tsx
index 105e808..4666b07 100644
--- a/src/components/templates/layout/layout.stories.tsx
+++ b/src/components/templates/layout/layout.stories.tsx
@@ -60,6 +60,32 @@ export default {
         required: false,
       },
     },
+    useGrid: {
+      control: {
+        type: 'boolean',
+      },
+      description: 'Use the grid layout.',
+      table: {
+        category: 'Options',
+      },
+      type: {
+        name: 'boolean',
+        required: false,
+      },
+    },
+    withExtraPadding: {
+      control: {
+        type: 'boolean',
+      },
+      description: 'Set additional padding at the bottom of the page.',
+      table: {
+        category: 'Options',
+      },
+      type: {
+        name: 'boolean',
+        required: false,
+      },
+    },
   },
   decorators: [
     (Story) => (
diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx
index bec1066..12fe26a 100644
--- a/src/components/templates/page/page-layout.stories.tsx
+++ b/src/components/templates/page/page-layout.stories.tsx
@@ -33,6 +33,30 @@ export default {
         required: false,
       },
     },
+    bodyAttributes: {
+      description: 'Set additional HTML attributes to the main content body.',
+      table: {
+        category: 'Options',
+      },
+      type: {
+        name: 'object',
+        required: false,
+        value: {},
+      },
+    },
+    bodyClassName: {
+      control: {
+        type: 'text',
+      },
+      description: 'Set additional classnames to the main content body.',
+      table: {
+        category: 'Styles',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     breadcrumb: {
       description: 'The breadcrumb items.',
       type: {
-- 
cgit v1.2.3


From 321dae4a47594af83269fa560b375965d7f35763 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Sun, 22 May 2022 15:09:39 +0200
Subject: fix: render all images unoptimized in Storybook

There is a bug with next/image and Storybook. I was manually adding
`unoptimized` to images. Instead we can use a workaround by adding an
extra config in Storybook `preview.js` file.
---
 .storybook/preview.js                              | 18 +++++++
 .../molecules/images/flipping-logo.stories.tsx     |  3 --
 .../molecules/images/responsive-image.stories.tsx  | 57 ++++++++++++++++++++--
 .../molecules/layout/branding.stories.tsx          |  5 --
 src/components/molecules/layout/card.stories.tsx   |  1 -
 .../organisms/images/gallery.stories.tsx           |  8 +--
 .../organisms/layout/cards-list.stories.tsx        |  6 ---
 .../organisms/layout/cards-list.test.tsx           |  6 ---
 .../organisms/layout/comment.stories.tsx           |  2 -
 .../organisms/layout/comments-list.stories.tsx     | 10 ----
 src/components/organisms/layout/header.stories.tsx |  3 --
 .../organisms/layout/overview.stories.tsx          |  1 -
 .../organisms/layout/posts-list.stories.tsx        |  4 --
 .../organisms/layout/posts-list.test.tsx           |  4 --
 .../organisms/layout/summary.stories.tsx           |  1 -
 .../organisms/widgets/image-widget.stories.tsx     |  1 -
 .../templates/page/page-layout.stories.tsx         | 22 +++------
 src/components/templates/page/page-layout.test.tsx |  2 -
 18 files changed, 80 insertions(+), 74 deletions(-)

(limited to 'src/components/templates/page/page-layout.stories.tsx')

diff --git a/.storybook/preview.js b/.storybook/preview.js
index 344df2a..e30927b 100644
--- a/.storybook/preview.js
+++ b/.storybook/preview.js
@@ -1,6 +1,24 @@
 import '@styles/globals.scss';
+import * as NextImage from 'next/image';
 import { IntlProvider } from 'react-intl';
 
+const OriginalNextImage = NextImage.default;
+
+Object.defineProperty(NextImage, 'default', {
+  configurable: true,
+  value: (props) =>
+    typeof props.src === 'string' ? (
+      <OriginalNextImage {...props} unoptimized blurDataURL={props.src} />
+    ) : (
+      <OriginalNextImage {...props} unoptimized />
+    ),
+});
+
+Object.defineProperty(NextImage, '__esModule', {
+  configurable: true,
+  value: true,
+});
+
 export const parameters = {
   actions: { argTypesRegex: '^on[A-Z].*' },
   controls: {
diff --git a/src/components/molecules/images/flipping-logo.stories.tsx b/src/components/molecules/images/flipping-logo.stories.tsx
index 40a4c49..9d09293 100644
--- a/src/components/molecules/images/flipping-logo.stories.tsx
+++ b/src/components/molecules/images/flipping-logo.stories.tsx
@@ -54,7 +54,6 @@ export default {
         required: true,
       },
     },
-    unoptimized: { table: { disable: true } },
   },
 } as ComponentMeta<typeof FlippingLogoComponent>;
 
@@ -70,6 +69,4 @@ FlippingLogo.args = {
   altText: 'Website picture',
   logoTitle: 'Website logo',
   photo: 'http://placeimg.com/640/480',
-  // @ts-ignore - Needed because of the placeholder image.
-  unoptimized: true,
 };
diff --git a/src/components/molecules/images/responsive-image.stories.tsx b/src/components/molecules/images/responsive-image.stories.tsx
index ca69d4f..4917cde 100644
--- a/src/components/molecules/images/responsive-image.stories.tsx
+++ b/src/components/molecules/images/responsive-image.stories.tsx
@@ -80,7 +80,6 @@ export default {
         required: false,
       },
     },
-    unoptimized: { table: { disable: true } },
     width: {
       control: {
         type: 'number',
@@ -121,7 +120,18 @@ Default.args = {
   src: 'http://placeimg.com/640/480/transport',
   width: 640,
   height: 480,
-  unoptimized: true,
+};
+
+/**
+ * Responsive Image Stories - With borders
+ */
+export const WithBorders = Template.bind({});
+WithBorders.args = {
+  alt: 'An example',
+  src: 'http://placeimg.com/640/480/transport',
+  width: 640,
+  height: 480,
+  withBorders: true,
 };
 
 /**
@@ -133,10 +143,22 @@ WithLink.args = {
   src: 'http://placeimg.com/640/480/transport',
   width: 640,
   height: 480,
-  unoptimized: true,
   target: '#',
 };
 
+/**
+ * Responsive Image Stories - With link and borders
+ */
+export const WithLinkAndBorders = Template.bind({});
+WithLinkAndBorders.args = {
+  alt: 'An example',
+  src: 'http://placeimg.com/640/480/transport',
+  width: 640,
+  height: 480,
+  target: '#',
+  withBorders: true,
+};
+
 /**
  * Responsive Image Stories - With caption
  */
@@ -147,7 +169,19 @@ WithCaption.args = {
   width: 640,
   height: 480,
   caption: 'Omnis nulla labore',
-  unoptimized: true,
+};
+
+/**
+ * Responsive Image Stories - With caption and borders
+ */
+export const WithCaptionAndBorders = Template.bind({});
+WithCaptionAndBorders.args = {
+  alt: 'An example',
+  src: 'http://placeimg.com/640/480/transport',
+  width: 640,
+  height: 480,
+  caption: 'Omnis nulla labore',
+  withBorders: true,
 };
 
 /**
@@ -161,5 +195,18 @@ WithCaptionAndLink.args = {
   height: 480,
   caption: 'Omnis nulla labore',
   target: '#',
-  unoptimized: true,
+};
+
+/**
+ * Responsive Image Stories - With caption, link and borders
+ */
+export const WithCaptionLinkAndBorders = Template.bind({});
+WithCaptionLinkAndBorders.args = {
+  alt: 'An example',
+  src: 'http://placeimg.com/640/480/transport',
+  width: 640,
+  height: 480,
+  caption: 'Omnis nulla labore',
+  target: '#',
+  withBorders: true,
 };
diff --git a/src/components/molecules/layout/branding.stories.tsx b/src/components/molecules/layout/branding.stories.tsx
index d2e0694..94bb166 100644
--- a/src/components/molecules/layout/branding.stories.tsx
+++ b/src/components/molecules/layout/branding.stories.tsx
@@ -56,7 +56,6 @@ export default {
         required: true,
       },
     },
-    unoptimized: { table: { disable: true } },
     withLink: {
       control: {
         type: 'boolean',
@@ -85,8 +84,6 @@ export const Default = Template.bind({});
 Default.args = {
   title: 'Website title',
   photo: 'http://placeimg.com/640/480',
-  // @ts-ignore - Needed because of the placeholder image.
-  unoptimized: true,
 };
 
 /**
@@ -97,6 +94,4 @@ WithBaseline.args = {
   title: 'Website title',
   baseline: 'Maiores corporis qui',
   photo: 'http://placeimg.com/640/480',
-  // @ts-ignore - Needed because of the placeholder image.
-  unoptimized: true,
 };
diff --git a/src/components/molecules/layout/card.stories.tsx b/src/components/molecules/layout/card.stories.tsx
index 2e99bbb..0ad42c0 100644
--- a/src/components/molecules/layout/card.stories.tsx
+++ b/src/components/molecules/layout/card.stories.tsx
@@ -113,7 +113,6 @@ const cover = {
   height: 480,
   src: 'http://placeimg.com/640/480',
   width: 640,
-  unoptimized: true,
 };
 
 const meta = {
diff --git a/src/components/organisms/images/gallery.stories.tsx b/src/components/organisms/images/gallery.stories.tsx
index 786cfff..6fc278f 100644
--- a/src/components/organisms/images/gallery.stories.tsx
+++ b/src/components/organisms/images/gallery.stories.tsx
@@ -43,10 +43,10 @@ const image = {
 
 const Template: ComponentStory<typeof Gallery> = (args) => (
   <Gallery {...args}>
-    <ResponsiveImage unoptimized {...image} />
-    <ResponsiveImage unoptimized {...image} />
-    <ResponsiveImage unoptimized {...image} />
-    <ResponsiveImage unoptimized {...image} />
+    <ResponsiveImage {...image} />
+    <ResponsiveImage {...image} />
+    <ResponsiveImage {...image} />
+    <ResponsiveImage {...image} />
   </Gallery>
 );
 
diff --git a/src/components/organisms/layout/cards-list.stories.tsx b/src/components/organisms/layout/cards-list.stories.tsx
index 522d068..c19220a 100644
--- a/src/components/organisms/layout/cards-list.stories.tsx
+++ b/src/components/organisms/layout/cards-list.stories.tsx
@@ -90,8 +90,6 @@ const items: CardsListItem[] = [
       src: 'http://placeimg.com/640/480',
       width: 640,
       height: 480,
-      // @ts-ignore - Needed because of the placeholder image.
-      unoptimized: true,
     },
     meta: { thematics: ['Velit', 'Ex', 'Alias'] },
     tagline: 'Molestias ut error',
@@ -105,8 +103,6 @@ const items: CardsListItem[] = [
       src: 'http://placeimg.com/640/480',
       width: 640,
       height: 480,
-      // @ts-ignore - Needed because of the placeholder image.
-      unoptimized: true,
     },
     meta: { thematics: ['Voluptas'] },
     tagline: 'Quod vel accusamus',
@@ -120,8 +116,6 @@ const items: CardsListItem[] = [
       src: 'http://placeimg.com/640/480',
       width: 640,
       height: 480,
-      // @ts-ignore - Needed because of the placeholder image.
-      unoptimized: true,
     },
     meta: {
       thematics: ['Quisquam', 'Quia', 'Sapiente', 'Perspiciatis'],
diff --git a/src/components/organisms/layout/cards-list.test.tsx b/src/components/organisms/layout/cards-list.test.tsx
index 7d98844..8558fa6 100644
--- a/src/components/organisms/layout/cards-list.test.tsx
+++ b/src/components/organisms/layout/cards-list.test.tsx
@@ -9,8 +9,6 @@ const items: CardsListItem[] = [
       src: 'http://placeimg.com/640/480',
       width: 640,
       height: 480,
-      // @ts-ignore - Needed because of the placeholder image.
-      unoptimized: true,
     },
     meta: { thematics: ['Velit', 'Ex', 'Alias'] },
     tagline: 'Molestias ut error',
@@ -24,8 +22,6 @@ const items: CardsListItem[] = [
       src: 'http://placeimg.com/640/480',
       width: 640,
       height: 480,
-      // @ts-ignore - Needed because of the placeholder image.
-      unoptimized: true,
     },
     meta: { thematics: ['Voluptas'] },
     tagline: 'Quod vel accusamus',
@@ -39,8 +35,6 @@ const items: CardsListItem[] = [
       src: 'http://placeimg.com/640/480',
       width: 640,
       height: 480,
-      // @ts-ignore - Needed because of the placeholder image.
-      unoptimized: true,
     },
     meta: {
       thematics: ['Quisquam', 'Quia', 'Sapiente', 'Perspiciatis'],
diff --git a/src/components/organisms/layout/comment.stories.tsx b/src/components/organisms/layout/comment.stories.tsx
index c7649ab..fa4bba9 100644
--- a/src/components/organisms/layout/comment.stories.tsx
+++ b/src/components/organisms/layout/comment.stories.tsx
@@ -123,6 +123,4 @@ Comment.args = {
     'Harum aut cumque iure fugit neque sequi cupiditate repudiandae laudantium. Ratione aut assumenda qui illum voluptas accusamus quis officiis exercitationem. Consectetur est harum eius perspiciatis officiis nihil. Aut corporis minima debitis adipisci possimus debitis et.',
   id: 2,
   publication: '2021-04-03 23:04:24',
-  // @ts-ignore - Needed because of the placeholder image.
-  unoptimized: true,
 };
diff --git a/src/components/organisms/layout/comments-list.stories.tsx b/src/components/organisms/layout/comments-list.stories.tsx
index 1ed159a..22cc9d9 100644
--- a/src/components/organisms/layout/comments-list.stories.tsx
+++ b/src/components/organisms/layout/comments-list.stories.tsx
@@ -81,8 +81,6 @@ const comments: Comment[] = [
       'Voluptas ducimus inventore. Libero ut et doloribus. Earum nostrum ab. Aliquam rem dolores omnis voluptate. Sunt aut ut et.',
     id: 1,
     publication: '2021-04-03 18:04:11',
-    // @ts-ignore - Needed because of the placeholder image.
-    unoptimized: true,
   },
   {
     child: [
@@ -95,8 +93,6 @@ const comments: Comment[] = [
           'Vel ullam in porro tempore. Maiores quos quia magnam beatae nemo libero velit numquam. Sapiente aliquid cumque. Velit neque in adipisci aut assumenda voluptates earum. Autem esse autem provident in tempore. Aut distinctio dolor qui repellat et et adipisci velit aspernatur.',
         id: 4,
         publication: '2021-04-03 23:04:24',
-        // @ts-ignore - Needed because of the placeholder image.
-        unoptimized: true,
       },
       {
         author: {
@@ -107,8 +103,6 @@ const comments: Comment[] = [
           'Sed non omnis. Quam porro est. Quae tempore quae. Exercitationem eos non velit voluptatem velit voluptas iusto. Sit debitis qui ipsam quo asperiores numquam veniam praesentium ut.',
         id: 5,
         publication: '2021-04-04 08:05:14',
-        // @ts-ignore - Needed because of the placeholder image.
-        unoptimized: true,
       },
     ],
     author: {
@@ -120,8 +114,6 @@ const comments: Comment[] = [
       'Sit sed error quasi voluptatem velit voluptas aut. Aut debitis eveniet. Praesentium dolores quia voluptate vero quis dicta quasi vel. Aut voluptas accusantium ut aut quidem consectetur itaque laboriosam occaecati.',
     id: 2,
     publication: '2021-04-03 23:30:20',
-    // @ts-ignore - Needed because of the placeholder image.
-    unoptimized: true,
   },
   {
     author: {
@@ -132,8 +124,6 @@ const comments: Comment[] = [
       'Natus consequatur maiores aperiam dolore eius nesciunt ut qui et. Ab ea nobis est. Eaque dolor corrupti id aut. Impedit architecto autem qui neque rerum ab dicta dignissimos voluptates.',
     id: 3,
     publication: '2021-09-13 13:24:54',
-    // @ts-ignore - Needed because of the placeholder image.
-    unoptimized: true,
   },
 ];
 
diff --git a/src/components/organisms/layout/header.stories.tsx b/src/components/organisms/layout/header.stories.tsx
index a5233ea..0507e89 100644
--- a/src/components/organisms/layout/header.stories.tsx
+++ b/src/components/organisms/layout/header.stories.tsx
@@ -112,7 +112,6 @@ export default {
         required: true,
       },
     },
-    unoptimized: { table: { disable: true } },
     withLink: {
       control: {
         type: 'boolean',
@@ -151,6 +150,4 @@ Header.args = {
   nav,
   photo: 'http://placeimg.com/640/480/people',
   title: 'Website title',
-  // @ts-ignore - Needed because of the placeholder image.
-  unoptimized: true,
 };
diff --git a/src/components/organisms/layout/overview.stories.tsx b/src/components/organisms/layout/overview.stories.tsx
index 1139350..26f7ba0 100644
--- a/src/components/organisms/layout/overview.stories.tsx
+++ b/src/components/organisms/layout/overview.stories.tsx
@@ -52,7 +52,6 @@ const cover = {
   height: 480,
   src: 'http://placeimg.com/640/480/cats',
   width: 640,
-  unoptimized: true,
 };
 
 const meta: OverviewMeta = {
diff --git a/src/components/organisms/layout/posts-list.stories.tsx b/src/components/organisms/layout/posts-list.stories.tsx
index 96fb2f0..360d441 100644
--- a/src/components/organisms/layout/posts-list.stories.tsx
+++ b/src/components/organisms/layout/posts-list.stories.tsx
@@ -181,8 +181,6 @@ const posts: Post[] = [
       height: 480,
       src: 'http://placeimg.com/640/480',
       width: 640,
-      // @ts-ignore - Needed because of the placeholder image.
-      unoptimized: true,
     },
   },
   {
@@ -213,8 +211,6 @@ const posts: Post[] = [
       height: 480,
       src: 'http://placeimg.com/640/480',
       width: 640,
-      // @ts-ignore - Needed because of the placeholder image.
-      unoptimized: true,
     },
   },
 ];
diff --git a/src/components/organisms/layout/posts-list.test.tsx b/src/components/organisms/layout/posts-list.test.tsx
index 7429cbd..dfb9825 100644
--- a/src/components/organisms/layout/posts-list.test.tsx
+++ b/src/components/organisms/layout/posts-list.test.tsx
@@ -28,8 +28,6 @@ const posts: Post[] = [
       height: 480,
       src: 'http://placeimg.com/640/480',
       width: 640,
-      // @ts-ignore - Needed because of the placeholder image.
-      unoptimized: true,
     },
   },
   {
@@ -60,8 +58,6 @@ const posts: Post[] = [
       height: 480,
       src: 'http://placeimg.com/640/480',
       width: 640,
-      // @ts-ignore - Needed because of the placeholder image.
-      unoptimized: true,
     },
   },
 ];
diff --git a/src/components/organisms/layout/summary.stories.tsx b/src/components/organisms/layout/summary.stories.tsx
index 92736b8..2512051 100644
--- a/src/components/organisms/layout/summary.stories.tsx
+++ b/src/components/organisms/layout/summary.stories.tsx
@@ -88,7 +88,6 @@ const cover = {
   height: 480,
   src: 'http://placeimg.com/640/480',
   width: 640,
-  unoptimized: true,
 };
 
 const excerpt =
diff --git a/src/components/organisms/widgets/image-widget.stories.tsx b/src/components/organisms/widgets/image-widget.stories.tsx
index fa39745..2271c03 100644
--- a/src/components/organisms/widgets/image-widget.stories.tsx
+++ b/src/components/organisms/widgets/image-widget.stories.tsx
@@ -130,7 +130,6 @@ const image = {
   height: 480,
   src: 'http://placeimg.com/640/480/nature',
   width: 640,
-  unoptimized: true,
 };
 
 /**
diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx
index 12fe26a..bc1d7b6 100644
--- a/src/components/templates/page/page-layout.stories.tsx
+++ b/src/components/templates/page/page-layout.stories.tsx
@@ -179,8 +179,12 @@ export default {
     },
   },
   decorators: [
-    (Story) => (
-      <LayoutBase {...LayoutBase.args}>
+    (Story, context) => (
+      <LayoutBase
+        useGrid={true}
+        withExtraPadding={!context.args.allowComments && !context.args.comments}
+        {...LayoutBase.args}
+      >
         <Story />
       </LayoutBase>
     ),
@@ -273,8 +277,6 @@ const comments = [
       'Voluptas ducimus inventore. Libero ut et doloribus. Earum nostrum ab. Aliquam rem dolores omnis voluptate. Sunt aut ut et.',
     id: 1,
     publication: '2021-04-03 18:04:11',
-    // @ts-ignore - Needed because of the placeholder image.
-    unoptimized: true,
   },
   {
     child: [
@@ -287,8 +289,6 @@ const comments = [
           'Vel ullam in porro tempore. Maiores quos quia magnam beatae nemo libero velit numquam. Sapiente aliquid cumque. Velit neque in adipisci aut assumenda voluptates earum. Autem esse autem provident in tempore. Aut distinctio dolor qui repellat et et adipisci velit aspernatur.',
         id: 4,
         publication: '2021-04-03 23:04:24',
-        // @ts-ignore - Needed because of the placeholder image.
-        unoptimized: true,
       },
       {
         author: {
@@ -299,8 +299,6 @@ const comments = [
           'Sed non omnis. Quam porro est. Quae tempore quae. Exercitationem eos non velit voluptatem velit voluptas iusto. Sit debitis qui ipsam quo asperiores numquam veniam praesentium ut.',
         id: 5,
         publication: '2021-04-04 08:05:14',
-        // @ts-ignore - Needed because of the placeholder image.
-        unoptimized: true,
       },
     ],
     author: {
@@ -312,8 +310,6 @@ const comments = [
       'Sit sed error quasi voluptatem velit voluptas aut. Aut debitis eveniet. Praesentium dolores quia voluptate vero quis dicta quasi vel. Aut voluptas accusantium ut aut quidem consectetur itaque laboriosam occaecati.',
     id: 2,
     publication: '2021-04-03 23:30:20',
-    // @ts-ignore - Needed because of the placeholder image.
-    unoptimized: true,
   },
   {
     author: {
@@ -324,8 +320,6 @@ const comments = [
       'Natus consequatur maiores aperiam dolore eius nesciunt ut qui et. Ab ea nobis est. Eaque dolor corrupti id aut. Impedit architecto autem qui neque rerum ab dicta dignissimos voluptates.',
     id: 3,
     publication: '2021-09-13 13:24:54',
-    // @ts-ignore - Needed because of the placeholder image.
-    unoptimized: true,
   },
 ];
 
@@ -435,8 +429,6 @@ const posts = [
       height: 480,
       src: 'http://placeimg.com/640/480',
       width: 640,
-      // @ts-ignore - Needed because of the placeholder image.
-      unoptimized: true,
     },
   },
   {
@@ -467,8 +459,6 @@ const posts = [
       height: 480,
       src: 'http://placeimg.com/640/480',
       width: 640,
-      // @ts-ignore - Needed because of the placeholder image.
-      unoptimized: true,
     },
   },
 ];
diff --git a/src/components/templates/page/page-layout.test.tsx b/src/components/templates/page/page-layout.test.tsx
index a414e14..a0c8923 100644
--- a/src/components/templates/page/page-layout.test.tsx
+++ b/src/components/templates/page/page-layout.test.tsx
@@ -98,8 +98,6 @@ describe('PageLayout', () => {
           'Voluptas ducimus inventore. Libero ut et doloribus. Earum nostrum ab. Aliquam rem dolores omnis voluptate. Sunt aut ut et.',
         id: 1,
         publication: '2021-04-03 18:04:11',
-        // @ts-ignore - Needed because of the placeholder image.
-        unoptimized: true,
       },
     ];
     render(
-- 
cgit v1.2.3


From 34e216546151eaf8a0a3cbb0bc8b65dae4c63bf2 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Mon, 23 May 2022 14:07:02 +0200
Subject: refactor: reduce the number of data transformation

---
 src/components/molecules/layout/card.tsx           |  22 +---
 src/components/organisms/layout/cards-list.tsx     |  36 +++---
 .../organisms/layout/comment.fixture.tsx           |  41 +++++++
 .../organisms/layout/comment.stories.tsx           |  28 ++---
 src/components/organisms/layout/comment.test.tsx   |  39 ++-----
 src/components/organisms/layout/comment.tsx        |  98 ++++++++---------
 .../organisms/layout/comments-list.fixture.tsx     | 106 ++++++++++++++++++
 .../organisms/layout/comments-list.stories.tsx     |  59 +---------
 .../organisms/layout/comments-list.test.tsx        |  64 +----------
 src/components/organisms/layout/comments-list.tsx  |  11 +-
 .../organisms/layout/posts-list.fixture.tsx        |  61 +++++++++++
 .../organisms/layout/posts-list.stories.tsx        |  64 +----------
 .../organisms/layout/posts-list.test.tsx           |  64 +----------
 src/components/organisms/layout/posts-list.tsx     |   4 +-
 .../organisms/layout/summary.fixture.tsx           |  25 +++++
 .../organisms/layout/summary.stories.tsx           |  28 +----
 src/components/organisms/layout/summary.test.tsx   |  44 ++------
 src/components/organisms/layout/summary.tsx        |  73 ++++---------
 .../templates/page/page-layout.stories.tsx         | 121 +--------------------
 src/components/templates/page/page-layout.test.tsx |  13 +--
 src/pages/article/[slug].tsx                       |  43 ++------
 src/pages/blog/index.tsx                           |  75 +------------
 src/pages/blog/page/[number].tsx                   |  66 +----------
 src/pages/projets/[slug].tsx                       |  14 ++-
 src/pages/projets/index.tsx                        |   4 +-
 src/pages/recherche/index.tsx                      |  76 +------------
 src/pages/sujet/[slug].tsx                         |  28 +----
 src/pages/thematique/[slug].tsx                    |  28 +----
 src/services/graphql/articles.query.ts             |   7 --
 src/services/graphql/articles.ts                   |   3 +-
 src/services/graphql/thematics.query.ts            |   9 --
 src/services/graphql/thematics.ts                  |   1 -
 src/services/graphql/topics.query.ts               |   9 --
 src/services/graphql/topics.ts                     |   1 -
 src/ts/types/app.ts                                |   5 +-
 src/ts/types/mdx.ts                                |   5 +-
 src/ts/types/raw-data.ts                           |   3 +-
 src/utils/helpers/pages.ts                         |  49 ++++++---
 38 files changed, 456 insertions(+), 971 deletions(-)
 create mode 100644 src/components/organisms/layout/comment.fixture.tsx
 create mode 100644 src/components/organisms/layout/comments-list.fixture.tsx
 create mode 100644 src/components/organisms/layout/posts-list.fixture.tsx
 create mode 100644 src/components/organisms/layout/summary.fixture.tsx

(limited to 'src/components/templates/page/page-layout.stories.tsx')

diff --git a/src/components/molecules/layout/card.tsx b/src/components/molecules/layout/card.tsx
index e465f76..68e364a 100644
--- a/src/components/molecules/layout/card.tsx
+++ b/src/components/molecules/layout/card.tsx
@@ -1,5 +1,6 @@
 import ButtonLink from '@components/atoms/buttons/button-link';
 import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
+import { type Image } from '@ts/types/app';
 import { FC } from 'react';
 import ResponsiveImage, {
   type ResponsiveImageProps,
@@ -7,25 +8,6 @@ import ResponsiveImage, {
 import styles from './card.module.scss';
 import Meta, { type MetaData } from './meta';
 
-export type Cover = {
-  /**
-   * The cover alternative text.
-   */
-  alt: string;
-  /**
-   * The cover height.
-   */
-  height: number;
-  /**
-   * The cover source.
-   */
-  src: string;
-  /**
-   * The cover width.
-   */
-  width: number;
-};
-
 export type CardProps = {
   /**
    * Set additional classnames to the card wrapper.
@@ -34,7 +16,7 @@ export type CardProps = {
   /**
    * The card cover.
    */
-  cover?: Cover;
+  cover?: Image;
   /**
    * The cover fit. Default: cover.
    */
diff --git a/src/components/organisms/layout/cards-list.tsx b/src/components/organisms/layout/cards-list.tsx
index 0168bb2..1feddd0 100644
--- a/src/components/organisms/layout/cards-list.tsx
+++ b/src/components/organisms/layout/cards-list.tsx
@@ -10,32 +10,24 @@ export type CardsListItem = Omit<
   CardProps,
   'className' | 'coverFit' | 'titleLevel'
 > & {
-  id: string;
-};
-
-export type CardsListProps = {
-  /**
-   * Set additional classnames to the list wrapper.
-   */
-  className?: string;
-  /**
-   * The cover fit.
-   */
-  coverFit?: CardProps['coverFit'];
   /**
-   * The cards data.
+   * The card id.
    */
-  items: CardsListItem[];
-  /**
-   * The list kind. Either ordered or unordered.
-   */
-  kind?: ListProps['kind'];
-  /**
-   * The title level (hn).
-   */
-  titleLevel: CardProps['titleLevel'];
+  id: string;
 };
 
+export type CardsListProps = Pick<CardProps, 'coverFit' | 'titleLevel'> &
+  Pick<ListProps, 'kind'> & {
+    /**
+     * Set additional classnames to the list wrapper.
+     */
+    className?: string;
+    /**
+     * The cards data.
+     */
+    items: CardsListItem[];
+  };
+
 /**
  * CardsList component
  *
diff --git a/src/components/organisms/layout/comment.fixture.tsx b/src/components/organisms/layout/comment.fixture.tsx
new file mode 100644
index 0000000..0118139
--- /dev/null
+++ b/src/components/organisms/layout/comment.fixture.tsx
@@ -0,0 +1,41 @@
+import { getFormattedDate, getFormattedTime } from '@utils/helpers/dates';
+import { CommentProps } from './comment';
+
+export const author = {
+  avatar: {
+    alt: 'Author avatar',
+    height: 480,
+    src: 'http://placeimg.com/640/480',
+    width: 640,
+  },
+  name: 'Armand',
+  website: 'https://www.armandphilippot.com/',
+};
+
+export const content =
+  'Harum aut cumque iure fugit neque sequi cupiditate repudiandae laudantium. Ratione aut assumenda qui illum voluptas accusamus quis officiis exercitationem. Consectetur est harum eius perspiciatis officiis nihil. Aut corporis minima debitis adipisci possimus debitis et.';
+
+export const date = '2021-04-03 23:04:24';
+
+export const meta = {
+  author,
+  date,
+};
+
+export const id = 5;
+
+export const saveComment = async () => {
+  /** Do nothing. */
+};
+
+export const data: CommentProps = {
+  approved: true,
+  content,
+  id,
+  meta,
+  parentId: 0,
+  saveComment,
+};
+
+export const formattedDate = getFormattedDate(date);
+export const formattedTime = getFormattedTime(date);
diff --git a/src/components/organisms/layout/comment.stories.tsx b/src/components/organisms/layout/comment.stories.tsx
index fa4bba9..7a8ac95 100644
--- a/src/components/organisms/layout/comment.stories.tsx
+++ b/src/components/organisms/layout/comment.stories.tsx
@@ -1,5 +1,6 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
 import CommentComponent from './comment';
+import { data } from './comment.fixture';
 
 const saveComment = async () => {
   /** Do nothing. */
@@ -9,7 +10,7 @@ const saveComment = async () => {
  * Comment - Storybook Meta
  */
 export default {
-  title: 'Organisms/Layout',
+  title: 'Organisms/Layout/Comment',
   component: CommentComponent,
   args: {
     canReply: true,
@@ -110,17 +111,18 @@ const Template: ComponentStory<typeof CommentComponent> = (args) => (
 );
 
 /**
- * Layout Stories - Comment
+ * Layout Stories - Approved
  */
-export const Comment = Template.bind({});
-Comment.args = {
-  author: {
-    avatar: 'http://placeimg.com/640/480',
-    name: 'Armand',
-    url: 'https://www.armandphilippot.com/',
-  },
-  content:
-    'Harum aut cumque iure fugit neque sequi cupiditate repudiandae laudantium. Ratione aut assumenda qui illum voluptas accusamus quis officiis exercitationem. Consectetur est harum eius perspiciatis officiis nihil. Aut corporis minima debitis adipisci possimus debitis et.',
-  id: 2,
-  publication: '2021-04-03 23:04:24',
+export const Approved = Template.bind({});
+Approved.args = {
+  ...data,
+};
+
+/**
+ * Layout Stories - Unapproved
+ */
+export const Unapproved = Template.bind({});
+Unapproved.args = {
+  ...data,
+  approved: false,
 };
diff --git a/src/components/organisms/layout/comment.test.tsx b/src/components/organisms/layout/comment.test.tsx
index 02a51dc..490a52b 100644
--- a/src/components/organisms/layout/comment.test.tsx
+++ b/src/components/organisms/layout/comment.test.tsx
@@ -1,48 +1,31 @@
 import { render, screen } from '@test-utils';
-import { getFormattedDate, getFormattedTime } from '@utils/helpers/dates';
 import Comment from './comment';
-
-const author = {
-  avatar: 'http://placeimg.com/640/480',
-  name: 'Your name',
-  url: 'https://www.example.test/',
-};
-const content =
-  'Harum aut cumque iure fugit neque sequi cupiditate repudiandae laudantium. Ratione aut assumenda qui illum voluptas accusamus quis officiis exercitationem. Consectetur est harum eius perspiciatis officiis nihil. Aut corporis minima debitis adipisci possimus debitis et.';
-const publication = '2021-04-03 23:04:24';
-const id = 5;
-const saveComment = async () => {
-  /** Do nothing. */
-};
-const data = {
+import {
   author,
-  content,
+  data,
+  formattedDate,
+  formattedTime,
   id,
-  publication,
-  saveComment,
-};
-
-const formattedDate = getFormattedDate(publication);
-const formattedTime = getFormattedTime(publication);
+} from './comment.fixture';
 
 describe('Comment', () => {
   it('renders an avatar', () => {
-    render(<Comment {...data} />);
+    render(<Comment canReply={true} {...data} />);
     expect(
       screen.getByRole('img', { name: 'Your name avatar' })
     ).toBeInTheDocument();
   });
 
   it('renders the author website url', () => {
-    render(<Comment {...data} />);
+    render(<Comment canReply={true} {...data} />);
     expect(screen.getByRole('link', { name: author.name })).toHaveAttribute(
       'href',
-      author.url
+      author.website
     );
   });
 
   it('renders a permalink to the comment', () => {
-    render(<Comment {...data} />);
+    render(<Comment canReply={true} {...data} />);
     expect(
       screen.getByRole('link', {
         name: `${formattedDate} at ${formattedTime}`,
@@ -51,12 +34,12 @@ describe('Comment', () => {
   });
 
   it('renders a reply button', () => {
-    render(<Comment {...data} canReply={true} />);
+    render(<Comment canReply={true} {...data} />);
     expect(screen.getByRole('button', { name: 'Reply' })).toBeInTheDocument();
   });
 
   it('does not render a reply button', () => {
-    render(<Comment {...data} canReply={false} />);
+    render(<Comment canReply={false} {...data} />);
     expect(
       screen.queryByRole('button', { name: 'Reply' })
     ).not.toBeInTheDocument();
diff --git a/src/components/organisms/layout/comment.tsx b/src/components/organisms/layout/comment.tsx
index 6df393b..1dc2c71 100644
--- a/src/components/organisms/layout/comment.tsx
+++ b/src/components/organisms/layout/comment.tsx
@@ -1,6 +1,7 @@
 import Button from '@components/atoms/buttons/button';
 import Link from '@components/atoms/links/link';
 import Meta from '@components/molecules/layout/meta';
+import { type Comment as CommentType } from '@ts/types/app';
 import useSettings from '@utils/hooks/use-settings';
 import Image from 'next/image';
 import Script from 'next/script';
@@ -25,32 +26,16 @@ export type CommentAuthor = {
   url?: string;
 };
 
-export type CommentProps = Pick<CommentFormProps, 'Notice' | 'saveComment'> & {
-  /**
-   * The comment author data.
-   */
-  author: CommentAuthor;
-  /**
-   * Enable or disable the reply button. Default: true.
-   */
-  canReply?: boolean;
-  /**
-   * The comment body.
-   */
-  content: string;
-  /**
-   * The comment id.
-   */
-  id: number | string;
-  /**
-   * The comment parent id.
-   */
-  parentId?: number | string;
-  /**
-   * The comment date and time separated with a space.
-   */
-  publication: string;
-};
+export type CommentProps = Pick<
+  CommentType,
+  'approved' | 'content' | 'id' | 'meta' | 'parentId'
+> &
+  Pick<CommentFormProps, 'Notice' | 'saveComment'> & {
+    /**
+     * Enable or disable the reply button. Default: true.
+     */
+    canReply?: boolean;
+  };
 
 /**
  * Comment component
@@ -58,19 +43,34 @@ export type CommentProps = Pick<CommentFormProps, 'Notice' | 'saveComment'> & {
  * Render a single comment.
  */
 const Comment: FC<CommentProps> = ({
-  author,
+  approved,
   canReply = true,
   content,
   id,
+  meta,
   Notice,
   parentId,
-  publication,
   saveComment,
   ...props
 }) => {
   const intl = useIntl();
+  const { website } = useSettings();
   const [isReplying, setIsReplying] = useState<boolean>(false);
-  const [publicationDate, publicationTime] = publication.split(' ');
+
+  if (!approved) {
+    return (
+      <div className={styles.wrapper}>
+        {intl.formatMessage({
+          defaultMessage: 'This comment is awaiting moderation...',
+          description: 'Comment: awaiting moderation',
+          id: '6a1Uo6',
+        })}
+      </div>
+    );
+  }
+
+  const { author, date } = meta;
+  const [publicationDate, publicationTime] = date.split(' ');
 
   const avatarAltText = intl.formatMessage(
     {
@@ -97,8 +97,6 @@ const Comment: FC<CommentProps> = ({
     id: '2fD5CI',
   });
 
-  const { website } = useSettings();
-
   const commentSchema: WithContext<CommentSchema> = {
     '@context': 'https://schema.org',
     '@id': `${website.url}/#comment-${id}`,
@@ -110,17 +108,17 @@ const Comment: FC<CommentProps> = ({
     author: {
       '@type': 'Person',
       name: author.name,
-      image: author.avatar,
-      url: author.url,
+      image: author.avatar?.src,
+      url: author.website,
     },
     creator: {
       '@type': 'Person',
       name: author.name,
-      image: author.avatar,
-      url: author.url,
+      image: author.avatar?.src,
+      url: author.website,
     },
-    dateCreated: publication,
-    datePublished: publication,
+    dateCreated: date,
+    datePublished: date,
     text: content,
   };
 
@@ -136,17 +134,19 @@ const Comment: FC<CommentProps> = ({
         className={`${styles.wrapper} ${styles['wrapper--comment']}`}
       >
         <header className={styles.header}>
-          <div className={styles.avatar}>
-            <Image
-              src={author.avatar}
-              alt={avatarAltText}
-              layout="fill"
-              objectFit="cover"
-              {...props}
-            />
-          </div>
-          {author.url ? (
-            <Link href={author.url} className={styles.author}>
+          {author.avatar && (
+            <div className={styles.avatar}>
+              <Image
+                src={author.avatar?.src}
+                alt={avatarAltText}
+                layout="fill"
+                objectFit="cover"
+                {...props}
+              />
+            </div>
+          )}
+          {author.website ? (
+            <Link href={author.website} className={styles.author}>
               {author.name}
             </Link>
           ) : (
@@ -181,7 +181,7 @@ const Comment: FC<CommentProps> = ({
       {isReplying && (
         <CommentForm
           Notice={Notice}
-          parentId={id as number}
+          parentId={id}
           saveComment={saveComment}
           title={formTitle}
           className={`${styles.wrapper} ${styles['wrapper--form']}`}
diff --git a/src/components/organisms/layout/comments-list.fixture.tsx b/src/components/organisms/layout/comments-list.fixture.tsx
new file mode 100644
index 0000000..2618f77
--- /dev/null
+++ b/src/components/organisms/layout/comments-list.fixture.tsx
@@ -0,0 +1,106 @@
+import { Comment } from '@ts/types/app';
+
+export const comments: Comment[] = [
+  {
+    approved: true,
+    content:
+      'Voluptas ducimus inventore. Libero ut et doloribus. Earum nostrum ab. Aliquam rem dolores omnis voluptate. Sunt aut ut et.',
+    id: 1,
+    meta: {
+      author: {
+        avatar: {
+          alt: 'Author 1 avatar',
+          height: 480,
+          src: 'http://placeimg.com/640/480',
+          width: 640,
+        },
+        name: 'Author 1',
+      },
+      date: '2021-04-03 18:04:11',
+    },
+    parentId: 0,
+    replies: [],
+  },
+  {
+    approved: true,
+    content:
+      'Sit sed error quasi voluptatem velit voluptas aut. Aut debitis eveniet. Praesentium dolores quia voluptate vero quis dicta quasi vel. Aut voluptas accusantium ut aut quidem consectetur itaque laboriosam occaecati.',
+    id: 2,
+    meta: {
+      author: {
+        avatar: {
+          alt: 'Author 2 avatar',
+          height: 480,
+          src: 'http://placeimg.com/640/480',
+          width: 640,
+        },
+        name: 'Author 2',
+        website: '#',
+      },
+      date: '2021-04-03 23:30:20',
+    },
+    parentId: 0,
+    replies: [
+      {
+        approved: true,
+        content:
+          'Vel ullam in porro tempore. Maiores quos quia magnam beatae nemo libero velit numquam. Sapiente aliquid cumque. Velit neque in adipisci aut assumenda voluptates earum. Autem esse autem provident in tempore. Aut distinctio dolor qui repellat et et adipisci velit aspernatur.',
+        id: 4,
+        meta: {
+          author: {
+            avatar: {
+              alt: 'Author 4 avatar',
+              height: 480,
+              src: 'http://placeimg.com/640/480',
+              width: 640,
+            },
+            name: 'Author 4',
+          },
+          date: '2021-04-03 23:04:24',
+        },
+        parentId: 2,
+        replies: [],
+      },
+      {
+        approved: true,
+        content:
+          'Sed non omnis. Quam porro est. Quae tempore quae. Exercitationem eos non velit voluptatem velit voluptas iusto. Sit debitis qui ipsam quo asperiores numquam veniam praesentium ut.',
+        id: 5,
+        meta: {
+          author: {
+            avatar: {
+              alt: 'Author 1 avatar',
+              height: 480,
+              src: 'http://placeimg.com/640/480',
+              width: 640,
+            },
+            name: 'Author 1',
+          },
+          date: '2021-04-04 08:05:14',
+        },
+        parentId: 2,
+        replies: [],
+      },
+    ],
+  },
+  {
+    approved: false,
+    content:
+      'Natus consequatur maiores aperiam dolore eius nesciunt ut qui et. Ab ea nobis est. Eaque dolor corrupti id aut. Impedit architecto autem qui neque rerum ab dicta dignissimos voluptates.',
+    id: 3,
+    meta: {
+      author: {
+        avatar: {
+          alt: 'Author 3',
+          height: 480,
+          src: 'http://placeimg.com/640/480',
+          width: 640,
+        },
+        name: 'Author 3',
+      },
+      date: '2021-09-13 13:24:54',
+    },
+    parentId: 0,
+    replies: [],
+  },
+];
diff --git a/src/components/organisms/layout/comments-list.stories.tsx b/src/components/organisms/layout/comments-list.stories.tsx
index 22cc9d9..5ed0f2a 100644
--- a/src/components/organisms/layout/comments-list.stories.tsx
+++ b/src/components/organisms/layout/comments-list.stories.tsx
@@ -1,5 +1,6 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import CommentsListComponent, { type Comment } from './comments-list';
+import CommentsListComponent from './comments-list';
+import { comments } from './comments-list.fixture';
 
 const saveComment = async () => {
   /** Do nothing. */
@@ -71,62 +72,6 @@ const Template: ComponentStory<typeof CommentsListComponent> = (args) => (
   <CommentsListComponent {...args} />
 );
 
-const comments: Comment[] = [
-  {
-    author: {
-      avatar: 'http://placeimg.com/640/480',
-      name: 'Author 1',
-    },
-    content:
-      'Voluptas ducimus inventore. Libero ut et doloribus. Earum nostrum ab. Aliquam rem dolores omnis voluptate. Sunt aut ut et.',
-    id: 1,
-    publication: '2021-04-03 18:04:11',
-  },
-  {
-    child: [
-      {
-        author: {
-          avatar: 'http://placeimg.com/640/480',
-          name: 'Author 4',
-        },
-        content:
-          'Vel ullam in porro tempore. Maiores quos quia magnam beatae nemo libero velit numquam. Sapiente aliquid cumque. Velit neque in adipisci aut assumenda voluptates earum. Autem esse autem provident in tempore. Aut distinctio dolor qui repellat et et adipisci velit aspernatur.',
-        id: 4,
-        publication: '2021-04-03 23:04:24',
-      },
-      {
-        author: {
-          avatar: 'http://placeimg.com/640/480',
-          name: 'Author 1',
-        },
-        content:
-          'Sed non omnis. Quam porro est. Quae tempore quae. Exercitationem eos non velit voluptatem velit voluptas iusto. Sit debitis qui ipsam quo asperiores numquam veniam praesentium ut.',
-        id: 5,
-        publication: '2021-04-04 08:05:14',
-      },
-    ],
-    author: {
-      avatar: 'http://placeimg.com/640/480',
-      name: 'Author 2',
-      url: '#',
-    },
-    content:
-      'Sit sed error quasi voluptatem velit voluptas aut. Aut debitis eveniet. Praesentium dolores quia voluptate vero quis dicta quasi vel. Aut voluptas accusantium ut aut quidem consectetur itaque laboriosam occaecati.',
-    id: 2,
-    publication: '2021-04-03 23:30:20',
-  },
-  {
-    author: {
-      avatar: 'http://placeimg.com/640/480',
-      name: 'Author 3',
-    },
-    content:
-      'Natus consequatur maiores aperiam dolore eius nesciunt ut qui et. Ab ea nobis est. Eaque dolor corrupti id aut. Impedit architecto autem qui neque rerum ab dicta dignissimos voluptates.',
-    id: 3,
-    publication: '2021-09-13 13:24:54',
-  },
-];
-
 /**
  * Layout Stories - Without child comments
  */
diff --git a/src/components/organisms/layout/comments-list.test.tsx b/src/components/organisms/layout/comments-list.test.tsx
index e135ec9..b0a2467 100644
--- a/src/components/organisms/layout/comments-list.test.tsx
+++ b/src/components/organisms/layout/comments-list.test.tsx
@@ -1,65 +1,7 @@
 import { render } from '@test-utils';
-import CommentsList, { type Comment } from './comments-list';
-
-const comments: Comment[] = [
-  {
-    author: {
-      avatar: 'http://placeimg.com/640/480',
-      name: 'Author 1',
-    },
-    content:
-      'Voluptas ducimus inventore. Libero ut et doloribus. Earum nostrum ab. Aliquam rem dolores omnis voluptate. Sunt aut ut et.',
-    id: 1,
-    publication: '2021-04-03 18:04:11',
-  },
-  {
-    child: [
-      {
-        author: {
-          avatar: 'http://placeimg.com/640/480',
-          name: 'Author 4',
-        },
-        content:
-          'Vel ullam in porro tempore. Maiores quos quia magnam beatae nemo libero velit numquam. Sapiente aliquid cumque. Velit neque in adipisci aut assumenda voluptates earum. Autem esse autem provident in tempore. Aut distinctio dolor qui repellat et et adipisci velit aspernatur.',
-        id: 4,
-        publication: '2021-04-03 23:04:24',
-      },
-      {
-        author: {
-          avatar: 'http://placeimg.com/640/480',
-          name: 'Author 1',
-        },
-        content:
-          'Sed non omnis. Quam porro est. Quae tempore quae. Exercitationem eos non velit voluptatem velit voluptas iusto. Sit debitis qui ipsam quo asperiores numquam veniam praesentium ut.',
-        id: 5,
-        publication: '2021-04-04 08:05:14',
-      },
-    ],
-    author: {
-      avatar: 'http://placeimg.com/640/480',
-      name: 'Author 2',
-      url: '#',
-    },
-    content:
-      'Sit sed error quasi voluptatem velit voluptas aut. Aut debitis eveniet. Praesentium dolores quia voluptate vero quis dicta quasi vel. Aut voluptas accusantium ut aut quidem consectetur itaque laboriosam occaecati.',
-    id: 2,
-    publication: '2021-04-03 23:30:20',
-  },
-  {
-    author: {
-      avatar: 'http://placeimg.com/640/480',
-      name: 'Author 3',
-    },
-    content:
-      'Natus consequatur maiores aperiam dolore eius nesciunt ut qui et. Ab ea nobis est. Eaque dolor corrupti id aut. Impedit architecto autem qui neque rerum ab dicta dignissimos voluptates.',
-    id: 3,
-    publication: '2021-05-13 13:24:54',
-  },
-];
-
-const saveComment = async () => {
-  /** Do nothing. */
-};
+import { saveComment } from './comment.fixture';
+import CommentsList from './comments-list';
+import { comments } from './comments-list.fixture';
 
 describe('CommentsList', () => {
   it('renders a comments list', () => {
diff --git a/src/components/organisms/layout/comments-list.tsx b/src/components/organisms/layout/comments-list.tsx
index f04354c..97eccb7 100644
--- a/src/components/organisms/layout/comments-list.tsx
+++ b/src/components/organisms/layout/comments-list.tsx
@@ -1,13 +1,10 @@
 import SingleComment, {
   type CommentProps,
 } from '@components/organisms/layout/comment';
+import { Comment } from '@ts/types/app';
 import { FC } from 'react';
 import styles from './comments-list.module.scss';
 
-export type Comment = Omit<CommentProps, 'canReply' | 'saveComment'> & {
-  child?: Comment[];
-};
-
 export type CommentsListProps = Pick<CommentProps, 'Notice' | 'saveComment'> & {
   /**
    * An array of comments.
@@ -42,7 +39,7 @@ const CommentsList: FC<CommentsListProps> = ({
   ): JSX.Element[] => {
     const isLastLevel = startLevel === depth;
 
-    return commentsList.map(({ child, ...comment }) => (
+    return commentsList.map(({ replies, ...comment }) => (
       <li key={comment.id} className={styles.item}>
         <SingleComment
           canReply={!isLastLevel}
@@ -50,8 +47,8 @@ const CommentsList: FC<CommentsListProps> = ({
           saveComment={saveComment}
           {...comment}
         />
-        {child && !isLastLevel && (
-          <ol className={styles.list}>{getItems(child, startLevel + 1)}</ol>
+        {replies && !isLastLevel && (
+          <ol className={styles.list}>{getItems(replies, startLevel + 1)}</ol>
         )}
       </li>
     ));
diff --git a/src/components/organisms/layout/posts-list.fixture.tsx b/src/components/organisms/layout/posts-list.fixture.tsx
new file mode 100644
index 0000000..b0afdf8
--- /dev/null
+++ b/src/components/organisms/layout/posts-list.fixture.tsx
@@ -0,0 +1,61 @@
+import { type Post } from './posts-list';
+
+export const introPost1 =
+  'Esse et voluptas sapiente modi impedit unde et. Ducimus nulla ea impedit sit placeat nihil assumenda. Rem est fugiat amet quo hic. Corrupti fuga quod animi autem dolorem ullam corrupti vel aut.';
+
+export const introPost2 =
+  'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.';
+
+export const introPost3 =
+  'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.';
+
+export const cover = {
+  alt: 'cover',
+  height: 480,
+  src: 'http://placeimg.com/640/480',
+  width: 640,
+};
+
+export const posts: Post[] = [
+  {
+    intro: introPost1,
+    id: 'post-1',
+    meta: {
+      cover,
+      dates: { publication: '2022-02-26' },
+      wordsCount: introPost1.split(' ').length,
+      thematics: [
+        { id: 1, name: 'Cat 1', slug: '#' },
+        { id: 2, name: 'Cat 2', slug: '#' },
+      ],
+      commentsCount: 1,
+    },
+    title: 'Ratione velit fuga',
+    url: '#',
+  },
+  {
+    intro: introPost2,
+    id: 'post-2',
+    meta: {
+      dates: { publication: '2022-02-20' },
+      wordsCount: introPost2.split(' ').length,
+      thematics: [{ id: 2, name: 'Cat 2', slug: '#' }],
+      commentsCount: 0,
+    },
+    title: 'Debitis laudantium laudantium',
+    url: '#',
+  },
+  {
+    intro: introPost3,
+    id: 'post-3',
+    meta: {
+      cover,
+      dates: { publication: '2021-12-20' },
+      wordsCount: introPost3.split(' ').length,
+      thematics: [{ id: 1, name: 'Cat 1', slug: '#' }],
+      commentsCount: 3,
+    },
+    title: 'Quaerat ut corporis',
+    url: '#',
+  },
+];
diff --git a/src/components/organisms/layout/posts-list.stories.tsx b/src/components/organisms/layout/posts-list.stories.tsx
index 360d441..68e3dc0 100644
--- a/src/components/organisms/layout/posts-list.stories.tsx
+++ b/src/components/organisms/layout/posts-list.stories.tsx
@@ -1,5 +1,6 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import PostsList, { type Post } from './posts-list';
+import PostsList from './posts-list';
+import { posts } from './posts-list.fixture';
 
 /**
  * PostsList - Storybook Meta
@@ -154,67 +155,6 @@ const Template: ComponentStory<typeof PostsList> = (args) => (
   <PostsList {...args} />
 );
 
-const excerpt1 =
-  'Esse et voluptas sapiente modi impedit unde et. Ducimus nulla ea impedit sit placeat nihil assumenda. Rem est fugiat amet quo hic. Corrupti fuga quod animi autem dolorem ullam corrupti vel aut.';
-const excerpt2 =
-  'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.';
-const excerpt3 =
-  'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.';
-
-const posts: Post[] = [
-  {
-    excerpt: excerpt1,
-    id: 'post-1',
-    meta: {
-      dates: { publication: '2022-02-26' },
-      readingTime: { wordsCount: excerpt1.split(' ').length },
-      thematics: [
-        { id: 'cat-1', name: 'Cat 1', url: '#' },
-        { id: 'cat-2', name: 'Cat 2', url: '#' },
-      ],
-      commentsCount: 1,
-    },
-    title: 'Ratione velit fuga',
-    url: '#',
-    cover: {
-      alt: 'cover',
-      height: 480,
-      src: 'http://placeimg.com/640/480',
-      width: 640,
-    },
-  },
-  {
-    excerpt: excerpt2,
-    id: 'post-2',
-    meta: {
-      dates: { publication: '2022-02-20' },
-      readingTime: { wordsCount: excerpt2.split(' ').length },
-      thematics: [{ id: 'cat-2', name: 'Cat 2', url: '#' }],
-      commentsCount: 0,
-    },
-    title: 'Debitis laudantium laudantium',
-    url: '#',
-  },
-  {
-    excerpt: excerpt3,
-    id: 'post-3',
-    meta: {
-      dates: { publication: '2021-12-20' },
-      readingTime: { wordsCount: excerpt3.split(' ').length },
-      thematics: [{ id: 'cat-1', name: 'Cat 1', url: '#' }],
-      commentsCount: 3,
-    },
-    title: 'Quaerat ut corporis',
-    url: '#',
-    cover: {
-      alt: 'cover',
-      height: 480,
-      src: 'http://placeimg.com/640/480',
-      width: 640,
-    },
-  },
-];
-
 /**
  * PostsList Stories - Default
  */
diff --git a/src/components/organisms/layout/posts-list.test.tsx b/src/components/organisms/layout/posts-list.test.tsx
index dfb9825..1bab466 100644
--- a/src/components/organisms/layout/posts-list.test.tsx
+++ b/src/components/organisms/layout/posts-list.test.tsx
@@ -1,66 +1,6 @@
 import { render, screen } from '@test-utils';
-import PostsList, { Post } from './posts-list';
-
-const excerpt1 =
-  'Esse et voluptas sapiente modi impedit unde et. Ducimus nulla ea impedit sit placeat nihil assumenda. Rem est fugiat amet quo hic. Corrupti fuga quod animi autem dolorem ullam corrupti vel aut.';
-const excerpt2 =
-  'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.';
-const excerpt3 =
-  'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.';
-
-const posts: Post[] = [
-  {
-    excerpt: excerpt1,
-    id: 'post-1',
-    meta: {
-      dates: { publication: '2022-02-26' },
-      readingTime: { wordsCount: excerpt1.split(' ').length },
-      thematics: [
-        { id: 'cat-1', name: 'Cat 1', url: '#' },
-        { id: 'cat-2', name: 'Cat 2', url: '#' },
-      ],
-      commentsCount: 1,
-    },
-    title: 'Ratione velit fuga',
-    url: '#',
-    cover: {
-      alt: 'cover',
-      height: 480,
-      src: 'http://placeimg.com/640/480',
-      width: 640,
-    },
-  },
-  {
-    excerpt: excerpt2,
-    id: 'post-2',
-    meta: {
-      dates: { publication: '2022-02-20' },
-      readingTime: { wordsCount: excerpt2.split(' ').length },
-      thematics: [{ id: 'cat-2', name: 'Cat 2', url: '#' }],
-      commentsCount: 0,
-    },
-    title: 'Debitis laudantium laudantium',
-    url: '#',
-  },
-  {
-    excerpt: excerpt3,
-    id: 'post-3',
-    meta: {
-      dates: { publication: '2021-12-20' },
-      readingTime: { wordsCount: excerpt3.split(' ').length },
-      thematics: [{ id: 'cat-1', name: 'Cat 1', url: '#' }],
-      commentsCount: 3,
-    },
-    title: 'Quaerat ut corporis',
-    url: '#',
-    cover: {
-      alt: 'cover',
-      height: 480,
-      src: 'http://placeimg.com/640/480',
-      width: 640,
-    },
-  },
-];
+import PostsList from './posts-list';
+import { posts } from './posts-list.fixture';
 
 describe('PostsList', () => {
   it('renders the correct number of posts', () => {
diff --git a/src/components/organisms/layout/posts-list.tsx b/src/components/organisms/layout/posts-list.tsx
index 608130e..91fc62d 100644
--- a/src/components/organisms/layout/posts-list.tsx
+++ b/src/components/organisms/layout/posts-list.tsx
@@ -3,7 +3,7 @@ import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
 import ProgressBar from '@components/atoms/loaders/progress-bar';
 import Spinner from '@components/atoms/loaders/spinner';
 import Pagination, {
-  PaginationProps,
+  type PaginationProps,
 } from '@components/molecules/nav/pagination';
 import useIsMounted from '@utils/hooks/use-is-mounted';
 import useSettings from '@utils/hooks/use-settings';
@@ -12,7 +12,7 @@ import { useIntl } from 'react-intl';
 import styles from './posts-list.module.scss';
 import Summary, { type SummaryProps } from './summary';
 
-export type Post = SummaryProps & {
+export type Post = Omit<SummaryProps, 'titleLevel'> & {
   /**
    * The post id.
    */
diff --git a/src/components/organisms/layout/summary.fixture.tsx b/src/components/organisms/layout/summary.fixture.tsx
new file mode 100644
index 0000000..fbc46a5
--- /dev/null
+++ b/src/components/organisms/layout/summary.fixture.tsx
@@ -0,0 +1,25 @@
+import { type SummaryMeta } from './summary';
+
+export const cover = {
+  alt: 'A cover',
+  height: 480,
+  src: 'http://placeimg.com/640/480',
+  width: 640,
+};
+
+export const intro =
+  'Perspiciatis quasi libero nemo non eligendi nam minima. Deleniti expedita tempore. Praesentium explicabo molestiae eaque consectetur vero. Quae nostrum quisquam similique. Ut hic est quas ut esse quisquam nobis.';
+
+export const meta: SummaryMeta = {
+  dates: { publication: '2022-04-11' },
+  wordsCount: intro.split(' ').length,
+  thematics: [
+    { id: 1, name: 'Cat 1', slug: '#' },
+    { id: 2, name: 'Cat 2', slug: '#' },
+  ],
+  commentsCount: 1,
+};
+
+export const title = 'Odio odit necessitatibus';
+
+export const url = '#';
diff --git a/src/components/organisms/layout/summary.stories.tsx b/src/components/organisms/layout/summary.stories.tsx
index 2512051..0b91e24 100644
--- a/src/components/organisms/layout/summary.stories.tsx
+++ b/src/components/organisms/layout/summary.stories.tsx
@@ -1,5 +1,6 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
 import Summary from './summary';
+import { cover, intro, meta } from './summary.fixture';
 
 /**
  * Summary - Storybook Meta
@@ -83,32 +84,12 @@ const Template: ComponentStory<typeof Summary> = (args) => (
   <Summary {...args} />
 );
 
-const cover = {
-  alt: 'A cover',
-  height: 480,
-  src: 'http://placeimg.com/640/480',
-  width: 640,
-};
-
-const excerpt =
-  'Perspiciatis quasi libero nemo non eligendi nam minima. Deleniti expedita tempore. Praesentium explicabo molestiae eaque consectetur vero. Quae nostrum quisquam similique. Ut hic est quas ut esse quisquam nobis.';
-
-const meta = {
-  dates: { publication: '2022-04-11' },
-  readingTime: { wordsCount: excerpt.split(' ').length },
-  thematics: [
-    { id: 'cat-1', name: 'Cat 1', url: '#' },
-    { id: 'cat-2', name: 'Cat 2', url: '#' },
-  ],
-  commentsCount: 1,
-};
-
 /**
  * Summary Stories - Default
  */
 export const Default = Template.bind({});
 Default.args = {
-  excerpt,
+  intro,
   meta,
   title: 'Odio odit necessitatibus',
   url: '#',
@@ -119,9 +100,8 @@ Default.args = {
  */
 export const WithCover = Template.bind({});
 WithCover.args = {
-  cover,
-  excerpt,
-  meta,
+  intro,
+  meta: { ...meta, cover },
   title: 'Odio odit necessitatibus',
   url: '#',
 };
diff --git a/src/components/organisms/layout/summary.test.tsx b/src/components/organisms/layout/summary.test.tsx
index 9e34254..7617c26 100644
--- a/src/components/organisms/layout/summary.test.tsx
+++ b/src/components/organisms/layout/summary.test.tsx
@@ -1,35 +1,12 @@
 import { render, screen } from '@test-utils';
 import Summary from './summary';
-
-const cover = {
-  alt: 'A cover',
-  height: 480,
-  src: 'http://placeimg.com/640/480',
-  width: 640,
-};
-
-const excerpt =
-  'Perspiciatis quasi libero nemo non eligendi nam minima. Deleniti expedita tempore. Praesentium explicabo molestiae eaque consectetur vero. Quae nostrum quisquam similique. Ut hic est quas ut esse quisquam nobis.';
-
-const meta = {
-  dates: { publication: '2022-04-11' },
-  readingTime: { wordsCount: excerpt.split(' ').length },
-  thematics: [
-    { id: 'cat-1', name: 'Cat 1', url: '#' },
-    { id: 'cat-2', name: 'Cat 2', url: '#' },
-  ],
-  commentsCount: 1,
-};
-
-const title = 'Odio odit necessitatibus';
-
-const url = '#';
+import { cover, intro, meta, title, url } from './summary.fixture';
 
 describe('Summary', () => {
   it('renders a title wrapped in a h2 element', () => {
     render(
       <Summary
-        excerpt={excerpt}
+        intro={intro}
         meta={meta}
         title={title}
         titleLevel={2}
@@ -42,16 +19,15 @@ describe('Summary', () => {
   });
 
   it('renders an excerpt', () => {
-    render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />);
-    expect(screen.getByText(excerpt)).toBeInTheDocument();
+    render(<Summary intro={intro} meta={meta} title={title} url={url} />);
+    expect(screen.getByText(intro)).toBeInTheDocument();
   });
 
   it('renders a cover', () => {
     render(
       <Summary
-        cover={cover}
-        excerpt={excerpt}
-        meta={meta}
+        intro={intro}
+        meta={{ ...meta, cover }}
         title={title}
         url={url}
       />
@@ -60,19 +36,19 @@ describe('Summary', () => {
   });
 
   it('renders a link to the full post', () => {
-    render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />);
+    render(<Summary intro={intro} meta={meta} title={title} url={url} />);
     expect(screen.getByRole('link', { name: title })).toBeInTheDocument();
   });
 
   it('renders a read more link', () => {
-    render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />);
+    render(<Summary intro={intro} meta={meta} title={title} url={url} />);
     expect(
       screen.getByRole('link', { name: `Read more about ${title}` })
     ).toBeInTheDocument();
   });
 
   it('renders some meta', () => {
-    render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />);
-    expect(screen.getByText(meta.thematics[0].name)).toBeInTheDocument();
+    render(<Summary intro={intro} meta={meta} title={title} url={url} />);
+    expect(screen.getByText(meta.thematics![0].name)).toBeInTheDocument();
   });
 });
diff --git a/src/components/organisms/layout/summary.tsx b/src/components/organisms/layout/summary.tsx
index 3831c0c..ca51cd6 100644
--- a/src/components/organisms/layout/summary.tsx
+++ b/src/components/organisms/layout/summary.tsx
@@ -6,7 +6,7 @@ import ResponsiveImage, {
   type ResponsiveImageProps,
 } from '@components/molecules/images/responsive-image';
 import Meta, { type MetaData } from '@components/molecules/layout/meta';
-import { type Dates } from '@ts/types/app';
+import { type Article, type Meta as MetaType } from '@ts/types/app';
 import useReadingTime from '@utils/hooks/use-reading-time';
 import { FC, ReactNode } from 'react';
 import { useIntl } from 'react-intl';
@@ -17,43 +17,22 @@ export type Cover = Pick<
   'alt' | 'src' | 'width' | 'height'
 >;
 
-export type SummaryMetaLink = {
-  id: number | string;
-  name: string;
-  url: string;
-};
-
-export type SummaryMetaReadingTime = {
-  wordsCount: number;
-  onlyMinutes?: boolean;
-};
-
-export type SummaryMeta = {
-  author?: string;
-  commentsCount?: number;
-  dates: Dates;
-  readingTime: SummaryMetaReadingTime;
-  thematics?: SummaryMetaLink[];
-  topics?: SummaryMetaLink[];
-};
+export type SummaryMeta = Pick<
+  MetaType<'article'>,
+  | 'author'
+  | 'commentsCount'
+  | 'cover'
+  | 'dates'
+  | 'thematics'
+  | 'topics'
+  | 'wordsCount'
+>;
 
-export type SummaryProps = {
+export type SummaryProps = Pick<Article, 'intro' | 'title'> & {
   /**
-   * The post cover.
-   */
-  cover?: Cover;
-  /**
-   * The post excerpt.
-   */
-  excerpt: string;
-  /**
-   * The post meta.
+   * The post metadata.
    */
   meta: SummaryMeta;
-  /**
-   * The post title.
-   */
-  title: string;
   /**
    * The heading level (hn).
    */
@@ -70,8 +49,7 @@ export type SummaryProps = {
  * Render a page summary.
  */
 const Summary: FC<SummaryProps> = ({
-  cover,
-  excerpt,
+  intro,
   meta,
   title,
   titleLevel = 2,
@@ -91,14 +69,13 @@ const Summary: FC<SummaryProps> = ({
       ),
     }
   );
-  const { wordsCount, onlyMinutes } = meta.readingTime;
-  const readingTime = useReadingTime(wordsCount, onlyMinutes);
-
-  const getMeta = (data: SummaryMeta): MetaData => {
-    const { author, commentsCount, dates, thematics, topics } = data;
+  const { author, commentsCount, cover, dates, thematics, topics, wordsCount } =
+    meta;
+  const readingTime = useReadingTime(wordsCount, true);
 
+  const getMeta = (): MetaData => {
     return {
-      author,
+      author: author?.name,
       publication: { date: dates.publication },
       update:
         dates.update && dates.publication !== dates.update
@@ -106,12 +83,12 @@ const Summary: FC<SummaryProps> = ({
           : undefined,
       readingTime,
       thematics: thematics?.map((thematic) => (
-        <Link key={thematic.id} href={thematic.url}>
+        <Link key={thematic.id} href={thematic.slug}>
           {thematic.name}
         </Link>
       )),
       topics: topics?.map((topic) => (
-        <Link key={topic.id} href={topic.url}>
+        <Link key={topic.id} href={topic.slug}>
           {topic.name}
         </Link>
       )),
@@ -134,11 +111,7 @@ const Summary: FC<SummaryProps> = ({
         </Link>
       </header>
       <div className={styles.body}>
-        {typeof excerpt === 'string' ? (
-          <div dangerouslySetInnerHTML={{ __html: excerpt }} />
-        ) : (
-          excerpt
-        )}
+        <div dangerouslySetInnerHTML={{ __html: intro }} />
         <ButtonLink target={url} className={styles['read-more']}>
           <>
             {readMore}
@@ -148,7 +121,7 @@ const Summary: FC<SummaryProps> = ({
       </div>
       <footer className={styles.footer}>
         <Meta
-          data={getMeta(meta)}
+          data={getMeta()}
           layout="column"
           itemsLayout="stacked"
           withSeparator={false}
diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx
index bc1d7b6..88e3785 100644
--- a/src/components/templates/page/page-layout.stories.tsx
+++ b/src/components/templates/page/page-layout.stories.tsx
@@ -1,7 +1,9 @@
 import ButtonLink from '@components/atoms/buttons/button-link';
 import Heading from '@components/atoms/headings/heading';
 import Link from '@components/atoms/links/link';
+import { comments } from '@components/organisms/layout/comments-list.fixture';
 import PostsList from '@components/organisms/layout/posts-list';
+import { posts } from '@components/organisms/layout/posts-list.fixture';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import Sharing from '@components/organisms/widgets/sharing';
 import { ComponentMeta, ComponentStory } from '@storybook/react';
@@ -267,62 +269,6 @@ const postBreadcrumb = [
   { id: 'post', url: '#', name: pageTitle },
 ];
 
-const comments = [
-  {
-    author: {
-      avatar: 'http://placeimg.com/640/480',
-      name: 'Author 1',
-    },
-    content:
-      'Voluptas ducimus inventore. Libero ut et doloribus. Earum nostrum ab. Aliquam rem dolores omnis voluptate. Sunt aut ut et.',
-    id: 1,
-    publication: '2021-04-03 18:04:11',
-  },
-  {
-    child: [
-      {
-        author: {
-          avatar: 'http://placeimg.com/640/480',
-          name: 'Author 4',
-        },
-        content:
-          'Vel ullam in porro tempore. Maiores quos quia magnam beatae nemo libero velit numquam. Sapiente aliquid cumque. Velit neque in adipisci aut assumenda voluptates earum. Autem esse autem provident in tempore. Aut distinctio dolor qui repellat et et adipisci velit aspernatur.',
-        id: 4,
-        publication: '2021-04-03 23:04:24',
-      },
-      {
-        author: {
-          avatar: 'http://placeimg.com/640/480',
-          name: 'Author 1',
-        },
-        content:
-          'Sed non omnis. Quam porro est. Quae tempore quae. Exercitationem eos non velit voluptatem velit voluptas iusto. Sit debitis qui ipsam quo asperiores numquam veniam praesentium ut.',
-        id: 5,
-        publication: '2021-04-04 08:05:14',
-      },
-    ],
-    author: {
-      avatar: 'http://placeimg.com/640/480',
-      name: 'Author 2',
-      url: '#',
-    },
-    content:
-      'Sit sed error quasi voluptatem velit voluptas aut. Aut debitis eveniet. Praesentium dolores quia voluptate vero quis dicta quasi vel. Aut voluptas accusantium ut aut quidem consectetur itaque laboriosam occaecati.',
-    id: 2,
-    publication: '2021-04-03 23:30:20',
-  },
-  {
-    author: {
-      avatar: 'http://placeimg.com/640/480',
-      name: 'Author 3',
-    },
-    content:
-      'Natus consequatur maiores aperiam dolore eius nesciunt ut qui et. Ab ea nobis est. Eaque dolor corrupti id aut. Impedit architecto autem qui neque rerum ab dicta dignissimos voluptates.',
-    id: 3,
-    publication: '2021-09-13 13:24:54',
-  },
-];
-
 /**
  * Page Layout Stories - Post
  */
@@ -393,7 +339,7 @@ Post.args = {
     />,
   ],
   withToC: true,
-  comments,
+  comments: comments,
   allowComments: true,
 };
 
@@ -402,67 +348,6 @@ const postsListBreadcrumb = [
   { id: 'blog', url: '#', name: 'Blog' },
 ];
 
-const excerpt1 =
-  'Esse et voluptas sapiente modi impedit unde et. Ducimus nulla ea impedit sit placeat nihil assumenda. Rem est fugiat amet quo hic. Corrupti fuga quod animi autem dolorem ullam corrupti vel aut.';
-const excerpt2 =
-  'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.';
-const excerpt3 =
-  'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.';
-
-const posts = [
-  {
-    excerpt: excerpt1,
-    id: 'post-1',
-    meta: {
-      dates: { publication: '2022-02-26' },
-      readingTime: { wordsCount: excerpt1.split(' ').length },
-      thematics: [
-        { id: 'cat-1', name: 'Cat 1', url: '#' },
-        { id: 'cat-2', name: 'Cat 2', url: '#' },
-      ],
-      commentsCount: 1,
-    },
-    title: 'Ratione velit fuga',
-    url: '#',
-    cover: {
-      alt: 'cover',
-      height: 480,
-      src: 'http://placeimg.com/640/480',
-      width: 640,
-    },
-  },
-  {
-    excerpt: excerpt2,
-    id: 'post-2',
-    meta: {
-      dates: { publication: '2022-02-20' },
-      readingTime: { wordsCount: excerpt2.split(' ').length },
-      thematics: [{ id: 'cat-2', name: 'Cat 2', url: '#' }],
-      commentsCount: 0,
-    },
-    title: 'Debitis laudantium laudantium',
-    url: '#',
-  },
-  {
-    excerpt: excerpt3,
-    id: 'post-3',
-    meta: {
-      dates: { publication: '2021-12-20' },
-      readingTime: { wordsCount: excerpt3.split(' ').length },
-      thematics: [{ id: 'cat-1', name: 'Cat 1', url: '#' }],
-      commentsCount: 3,
-    },
-    title: 'Quaerat ut corporis',
-    url: '#',
-    cover: {
-      alt: 'cover',
-      height: 480,
-      src: 'http://placeimg.com/640/480',
-      width: 640,
-    },
-  },
-];
-
 const blogCategories = [
   { name: 'Cat 1', url: '#' },
   {
diff --git a/src/components/templates/page/page-layout.test.tsx b/src/components/templates/page/page-layout.test.tsx
index a0c8923..f2d07d7 100644
--- a/src/components/templates/page/page-layout.test.tsx
+++ b/src/components/templates/page/page-layout.test.tsx
@@ -1,3 +1,4 @@
+import { comments } from '@components/organisms/layout/comments-list.fixture';
 import { render, screen } from '@test-utils';
 import { BreadcrumbList } from 'schema-dts';
 import PageLayout from './page-layout';
@@ -88,18 +89,6 @@ describe('PageLayout', () => {
   });
 
   it('renders the comments list', () => {
-    const comments = [
-      {
-        author: {
-          avatar: 'http://placeimg.com/640/480',
-          name: 'Author 1',
-        },
-        content:
-          'Voluptas ducimus inventore. Libero ut et doloribus. Earum nostrum ab. Aliquam rem dolores omnis voluptate. Sunt aut ut et.',
-        id: 1,
-        publication: '2021-04-03 18:04:11',
-      },
-    ];
     render(
       <PageLayout
         breadcrumb={breadcrumb}
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
index 812fffe..2878538 100644
--- a/src/pages/article/[slug].tsx
+++ b/src/pages/article/[slug].tsx
@@ -18,11 +18,8 @@ import {
   type NextPageWithLayout,
 } from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
-import useAddClassName from '@utils/hooks/use-add-classname';
-import useAttributes from '@utils/hooks/use-attributes';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import usePrism, { type OptionalPrismPlugin } from '@utils/hooks/use-prism';
-import useQuerySelectorAll from '@utils/hooks/use-query-selector-all';
 import useReadingTime from '@utils/hooks/use-reading-time';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticPaths, GetStaticProps } from 'next';
@@ -66,12 +63,15 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
     title,
     url: `/article/${slug}`,
   });
-  const readingTime = useReadingTime(wordsCount || 0, true);
+  const readingTime = useReadingTime(wordsCount, true);
 
   const headerMeta: PageLayoutProps['headerMeta'] = {
     author: author?.name,
     publication: { date: dates.publication },
-    update: dates.update ? { date: dates.update } : undefined,
+    update:
+      dates.update && dates.publication !== dates.update
+        ? { date: dates.update }
+        : undefined,
     readingTime,
     thematics:
       thematics &&
@@ -167,35 +167,6 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
     '@graph': [webpageSchema, blogSchema, blogPostSchema],
   };
 
-  /**
-   * Convert the comments list to the right format.
-   *
-   * @param {Comment[]} list - The comments list.
-   * @returns {PageLayoutProps['comments']} - The formatted comments list.
-   */
-  const getCommentsList = (list: Comment[]): PageLayoutProps['comments'] => {
-    return list.map((comment) => {
-      const {
-        content: commentBody,
-        id: commentId,
-        meta: commentMeta,
-        parentId,
-        replies,
-      } = comment;
-      const { author: commentAuthor, date } = commentMeta;
-      const { name, avatar, website: authorUrl } = commentAuthor;
-
-      return {
-        author: { name, avatar: avatar!.src, url: authorUrl },
-        content: commentBody,
-        id: commentId,
-        publication: date,
-        child: getCommentsList(replies),
-        parentId,
-      };
-    });
-  };
-
   const prismPlugins: OptionalPrismPlugin[] = ['command-line', 'line-numbers'];
   const { attributes, className } = usePrism({ plugins: prismPlugins });
   const lineNumbersClassName = className
@@ -254,7 +225,7 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
         bodyClassName={styles.body}
         breadcrumb={breadcrumbItems}
         breadcrumbSchema={breadcrumbSchema}
-        comments={data && getCommentsList(data)}
+        comments={data}
         footerMeta={footerMeta}
         headerMeta={headerMeta}
         id={id as number}
@@ -314,7 +285,7 @@ export const getStaticPaths: GetStaticPaths = async () => {
 
   return {
     paths,
-    fallback: true,
+    fallback: false,
   };
 };
 
diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx
index 90f56be..dd04fad 100644
--- a/src/pages/blog/index.tsx
+++ b/src/pages/blog/index.tsx
@@ -1,34 +1,27 @@
 import Notice from '@components/atoms/layout/notice';
-import PostsList, { type Post } from '@components/organisms/layout/posts-list';
+import PostsList from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import { getLayout } from '@components/templates/layout/layout';
 import PageLayout from '@components/templates/page/page-layout';
 import { type EdgesResponse } from '@services/graphql/api';
-import {
-  getArticleFromRawData,
-  getArticles,
-  getTotalArticles,
-} from '@services/graphql/articles';
+import { getArticles, getTotalArticles } from '@services/graphql/articles';
 import {
   getThematicsPreview,
   getTotalThematics,
 } from '@services/graphql/thematics';
 import { getTopicsPreview, getTotalTopics } from '@services/graphql/topics';
+import { type NextPageWithLayout } from '@ts/types/app';
 import {
-  type Article,
-  type Meta,
-  type NextPageWithLayout,
-} from '@ts/types/app';
-import {
-  RawThematicPreview,
-  RawTopicPreview,
   type RawArticle,
+  type RawThematicPreview,
+  type RawTopicPreview,
 } from '@ts/types/raw-data';
 import { settings } from '@utils/config';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import {
   getLinksListItems,
   getPageLinkFromRawData,
+  getPostsList,
 } from '@utils/helpers/pages';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import usePagination from '@utils/hooks/use-pagination';
@@ -119,62 +112,6 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
     '@graph': [webpageSchema, blogSchema],
   };
 
-  /**
-   * Retrieve the formatted meta.
-   *
-   * @param {Meta<'article'>} meta - The article meta.
-   * @returns {Post['meta']} The formatted meta.
-   */
-  const getPostMeta = (meta: Meta<'article'>): Post['meta'] => {
-    const { commentsCount, dates, thematics, wordsCount } = meta;
-
-    return {
-      commentsCount,
-      dates,
-      readingTime: { wordsCount: wordsCount || 0, onlyMinutes: true },
-      thematics: thematics?.map((thematic) => {
-        return { ...thematic, url: `/thematique/${thematic.slug}` };
-      }),
-    };
-  };
-
-  /**
-   * Retrieve the formatted posts.
-   *
-   * @param {Article[]} posts - An array of articles.
-   * @returns {Post[]} An array of formatted posts.
-   */
-  const getPosts = (posts: Article[]): Post[] => {
-    return posts.map((post) => {
-      return {
-        ...post,
-        cover: post.meta.cover,
-        excerpt: post.intro,
-        meta: getPostMeta(post.meta),
-        url: `/article/${post.slug}`,
-      };
-    });
-  };
-
-  /**
-   * Retrieve the posts list from raw data.
-   *
-   * @param {EdgesResponse<RawArticle>[]} rawData - The raw data.
-   * @returns {Post[]} An array of posts.
-   */
-  const getPostsList = (rawData: EdgesResponse<RawArticle>[]): Post[] => {
-    const articlesList: RawArticle[] = [];
-    rawData.forEach((articleData) =>
-      articleData.edges.forEach((edge) => {
-        articlesList.push(edge.node);
-      })
-    );
-
-    return getPosts(
-      articlesList.map((article) => getArticleFromRawData(article))
-    );
-  };
-
   const {
     data,
     error,
diff --git a/src/pages/blog/page/[number].tsx b/src/pages/blog/page/[number].tsx
index e8c93f7..8f50ddd 100644
--- a/src/pages/blog/page/[number].tsx
+++ b/src/pages/blog/page/[number].tsx
@@ -1,10 +1,9 @@
-import PostsList, { type Post } from '@components/organisms/layout/posts-list';
+import PostsList from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import { getLayout } from '@components/templates/layout/layout';
 import PageLayout from '@components/templates/page/page-layout';
 import { type EdgesResponse } from '@services/graphql/api';
 import {
-  getArticleFromRawData,
   getArticles,
   getArticlesEndCursor,
   getTotalArticles,
@@ -14,11 +13,7 @@ import {
   getTotalThematics,
 } from '@services/graphql/thematics';
 import { getTopicsPreview, getTotalTopics } from '@services/graphql/topics';
-import {
-  type Article,
-  type Meta,
-  type NextPageWithLayout,
-} from '@ts/types/app';
+import { type NextPageWithLayout } from '@ts/types/app';
 import {
   type RawArticle,
   type RawThematicPreview,
@@ -29,6 +24,7 @@ import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import {
   getLinksListItems,
   getPageLinkFromRawData,
+  getPostsList,
 } from '@utils/helpers/pages';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useRedirection from '@utils/hooks/use-redirection';
@@ -131,62 +127,6 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
     '@graph': [webpageSchema, blogSchema],
   };
 
-  /**
-   * Retrieve the formatted meta.
-   *
-   * @param {Meta<'article'>} meta - The article meta.
-   * @returns {Post['meta']} The formatted meta.
-   */
-  const getPostMeta = (meta: Meta<'article'>): Post['meta'] => {
-    const { commentsCount, dates, thematics, wordsCount } = meta;
-
-    return {
-      commentsCount,
-      dates,
-      readingTime: { wordsCount: wordsCount || 0, onlyMinutes: true },
-      thematics: thematics?.map((thematic) => {
-        return { ...thematic, url: `/thematique/${thematic.slug}` };
-      }),
-    };
-  };
-
-  /**
-   * Retrieve the formatted posts.
-   *
-   * @param {Article[]} posts - An array of articles.
-   * @returns {Post[]} An array of formatted posts.
-   */
-  const getPosts = (posts: Article[]): Post[] => {
-    return posts.map((post) => {
-      return {
-        ...post,
-        cover: post.meta.cover,
-        excerpt: post.intro,
-        meta: getPostMeta(post.meta),
-        url: `/article/${post.slug}`,
-      };
-    });
-  };
-
-  /**
-   * Retrieve the posts list from raw data.
-   *
-   * @param {EdgesResponse<RawArticle>[]} rawData - The raw data.
-   * @returns {Post[]} An array of posts.
-   */
-  const getPostsList = (rawData: EdgesResponse<RawArticle>[]): Post[] => {
-    const articlesList: RawArticle[] = [];
-    rawData.forEach((articleData) =>
-      articleData.edges.forEach((edge) => {
-        articlesList.push(edge.node);
-      })
-    );
-
-    return getPosts(
-      articlesList.map((article) => getArticleFromRawData(article))
-    );
-  };
-
   const thematicsListTitle = intl.formatMessage({
     defaultMessage: 'Thematics',
     description: 'BlogPage: thematics list widget title',
diff --git a/src/pages/projets/[slug].tsx b/src/pages/projets/[slug].tsx
index e9b36fa..27c715d 100644
--- a/src/pages/projets/[slug].tsx
+++ b/src/pages/projets/[slug].tsx
@@ -1,14 +1,18 @@
 import Link from '@components/atoms/links/link';
-import SocialLink, { SocialWebsite } from '@components/atoms/links/social-link';
+import SocialLink, {
+  type 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 Gallery from '@components/organisms/images/gallery';
-import Overview, { OverviewMeta } from '@components/organisms/layout/overview';
+import Overview, {
+  type OverviewMeta,
+} from '@components/organisms/layout/overview';
 import Sharing from '@components/organisms/widgets/sharing';
 import { getLayout } from '@components/templates/layout/layout';
 import PageLayout, {
-  PageLayoutProps,
+  type PageLayoutProps,
 } from '@components/templates/page/page-layout';
 import styles from '@styles/pages/project.module.scss';
 import {
@@ -16,11 +20,11 @@ import {
   type ProjectPreview,
   type Repos,
 } from '@ts/types/app';
-import { loadTranslation, Messages } from '@utils/helpers/i18n';
+import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import { getProjectData, getProjectFilenames } from '@utils/helpers/projects';
 import { capitalize } from '@utils/helpers/strings';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
-import useGithubApi, { RepoData } from '@utils/hooks/use-github-api';
+import useGithubApi, { type RepoData } from '@utils/hooks/use-github-api';
 import useSettings from '@utils/hooks/use-settings';
 import { MDXComponents, NestedMDXComponents } from 'mdx/types';
 import { GetStaticPaths, GetStaticProps } from 'next';
diff --git a/src/pages/projets/index.tsx b/src/pages/projets/index.tsx
index d500b6b..9ca289a 100644
--- a/src/pages/projets/index.tsx
+++ b/src/pages/projets/index.tsx
@@ -1,13 +1,13 @@
 import Link from '@components/atoms/links/link';
 import CardsList, {
-  CardsListItem,
+  type CardsListItem,
 } from '@components/organisms/layout/cards-list';
 import { getLayout } from '@components/templates/layout/layout';
 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 { type NextPageWithLayout, type ProjectCard } from '@ts/types/app';
-import { loadTranslation, Messages } from '@utils/helpers/i18n';
+import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import { getProjectsCard } from '@utils/helpers/projects';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx
index 8895015..c69d931 100644
--- a/src/pages/recherche/index.tsx
+++ b/src/pages/recherche/index.tsx
@@ -1,34 +1,26 @@
 import Notice from '@components/atoms/layout/notice';
 import Spinner from '@components/atoms/loaders/spinner';
-import PostsList, { type Post } from '@components/organisms/layout/posts-list';
+import PostsList from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import { getLayout } from '@components/templates/layout/layout';
 import PageLayout from '@components/templates/page/page-layout';
-import { type EdgesResponse } from '@services/graphql/api';
-import {
-  getArticleFromRawData,
-  getArticles,
-  getTotalArticles,
-} from '@services/graphql/articles';
+import { getArticles, getTotalArticles } from '@services/graphql/articles';
 import {
   getThematicsPreview,
   getTotalThematics,
 } from '@services/graphql/thematics';
 import { getTopicsPreview, getTotalTopics } from '@services/graphql/topics';
+import { type NextPageWithLayout } from '@ts/types/app';
 import {
-  type Article,
-  type Meta,
-  type NextPageWithLayout,
-} from '@ts/types/app';
-import {
-  RawThematicPreview,
-  RawTopicPreview,
   type RawArticle,
+  type RawThematicPreview,
+  type RawTopicPreview,
 } from '@ts/types/raw-data';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import {
   getLinksListItems,
   getPageLinkFromRawData,
+  getPostsList,
 } from '@utils/helpers/pages';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useDataFromAPI from '@utils/hooks/use-data-from-api';
@@ -145,62 +137,6 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({
     getTotalArticles(query.s as string)
   );
 
-  /**
-   * Retrieve the formatted meta.
-   *
-   * @param {Meta<'article'>} meta - The article meta.
-   * @returns {Post['meta']} The formatted meta.
-   */
-  const getPostMeta = (meta: Meta<'article'>): Post['meta'] => {
-    const { commentsCount, dates, thematics, wordsCount } = meta;
-
-    return {
-      commentsCount,
-      dates,
-      readingTime: { wordsCount: wordsCount || 0, onlyMinutes: true },
-      thematics: thematics?.map((thematic) => {
-        return { ...thematic, url: `/thematique/${thematic.slug}` };
-      }),
-    };
-  };
-
-  /**
-   * Retrieve the formatted posts.
-   *
-   * @param {Article[]} posts - An array of articles.
-   * @returns {Post[]} An array of formatted posts.
-   */
-  const getPosts = (posts: Article[]): Post[] => {
-    return posts.map((post) => {
-      return {
-        ...post,
-        cover: post.meta.cover,
-        excerpt: post.intro,
-        meta: getPostMeta(post.meta),
-        url: `/article/${post.slug}`,
-      };
-    });
-  };
-
-  /**
-   * Retrieve the posts list from raw data.
-   *
-   * @param {EdgesResponse<RawArticle>[]} rawData - The raw data.
-   * @returns {Post[]} An array of posts.
-   */
-  const getPostsList = (rawData: EdgesResponse<RawArticle>[]): Post[] => {
-    const articlesList: RawArticle[] = [];
-    rawData.forEach((articleData) =>
-      articleData.edges.forEach((edge) => {
-        articlesList.push(edge.node);
-      })
-    );
-
-    return getPosts(
-      articlesList.map((article) => getArticleFromRawData(article))
-    );
-  };
-
   /**
    * Load more posts handler.
    */
diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx
index b4845eb..95843cd 100644
--- a/src/pages/sujet/[slug].tsx
+++ b/src/pages/sujet/[slug].tsx
@@ -1,6 +1,6 @@
 import Heading from '@components/atoms/headings/heading';
 import ResponsiveImage from '@components/molecules/images/responsive-image';
-import PostsList, { type Post } from '@components/organisms/layout/posts-list';
+import PostsList from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import { getLayout } from '@components/templates/layout/layout';
 import PageLayout, {
@@ -14,7 +14,6 @@ import {
 } from '@services/graphql/topics';
 import styles from '@styles/pages/topic.module.scss';
 import {
-  type Article,
   type NextPageWithLayout,
   type PageLink,
   type Topic,
@@ -23,7 +22,7 @@ import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import {
   getLinksListItems,
   getPageLinkFromRawData,
-  getPostMeta,
+  getPostsWithUrl,
 } from '@utils/helpers/pages';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
@@ -109,27 +108,6 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({
     '@graph': [webpageSchema, articleSchema],
   };
 
-  const getPosts = (array: Article[]): Post[] => {
-    return array.map((article) => {
-      const {
-        intro: articleIntro,
-        meta: articleMeta,
-        slug: articleSlug,
-        ...remainingData
-      } = article;
-
-      const { cover: articleCover, ...remainingMeta } = articleMeta;
-
-      return {
-        cover: articleCover,
-        excerpt: articleIntro,
-        meta: getPostMeta(remainingMeta),
-        url: `/article/${articleSlug}`,
-        ...remainingData,
-      };
-    });
-  };
-
   const topicsListTitle = intl.formatMessage({
     defaultMessage: 'Other topics',
     description: 'TopicPage: other topics list widget title',
@@ -206,7 +184,7 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({
             </Heading>
             <PostsList
               baseUrl="/sujet/page/"
-              posts={getPosts(articles)}
+              posts={getPostsWithUrl(articles)}
               total={articles.length}
               titleLevel={3}
               byYear={true}
diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx
index ce4eccf..a91483e 100644
--- a/src/pages/thematique/[slug].tsx
+++ b/src/pages/thematique/[slug].tsx
@@ -1,5 +1,5 @@
 import Heading from '@components/atoms/headings/heading';
-import PostsList, { type Post } from '@components/organisms/layout/posts-list';
+import PostsList from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import { getLayout } from '@components/templates/layout/layout';
 import PageLayout, {
@@ -12,7 +12,6 @@ import {
   getTotalThematics,
 } from '@services/graphql/thematics';
 import {
-  type Article,
   type NextPageWithLayout,
   type PageLink,
   type Thematic,
@@ -21,7 +20,7 @@ import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import {
   getLinksListItems,
   getPageLinkFromRawData,
-  getPostMeta,
+  getPostsWithUrl,
 } from '@utils/helpers/pages';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
@@ -99,27 +98,6 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({
     '@graph': [webpageSchema, articleSchema],
   };
 
-  const getPosts = (array: Article[]): Post[] => {
-    return array.map((article) => {
-      const {
-        intro: articleIntro,
-        meta: articleMeta,
-        slug: articleSlug,
-        ...remainingData
-      } = article;
-
-      const { cover, ...remainingMeta } = articleMeta;
-
-      return {
-        cover,
-        excerpt: articleIntro,
-        meta: getPostMeta(remainingMeta),
-        url: `/article/${articleSlug}`,
-        ...remainingData,
-      };
-    });
-  };
-
   const thematicsListTitle = intl.formatMessage({
     defaultMessage: 'Other thematics',
     description: 'ThematicPage: other thematics list widget title',
@@ -187,7 +165,7 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({
             </Heading>
             <PostsList
               baseUrl="/thematique/page/"
-              posts={getPosts(articles)}
+              posts={getPostsWithUrl(articles)}
               total={articles.length}
               titleLevel={3}
               byYear={true}
diff --git a/src/services/graphql/articles.query.ts b/src/services/graphql/articles.query.ts
index 7bd0901..3e1f575 100644
--- a/src/services/graphql/articles.query.ts
+++ b/src/services/graphql/articles.query.ts
@@ -56,7 +56,6 @@ export const articleBySlugQuery = `query PostBy($slug: ID!) {
       }
     }
     info {
-      readingTime
       wordsCount
     }
     modified
@@ -90,11 +89,6 @@ export const articlesQuery = `query Articles($after: String = "", $first: Int =
             }
           }
         }
-        author {
-          node {
-            name
-          }
-        }
         commentCount
         contentParts {
           beforeMore
@@ -113,7 +107,6 @@ export const articlesQuery = `query Articles($after: String = "", $first: Int =
           }
         }
         info {
-          readingTime
           wordsCount
         }
         modified
diff --git a/src/services/graphql/articles.ts b/src/services/graphql/articles.ts
index f130872..f0ba4e4 100644
--- a/src/services/graphql/articles.ts
+++ b/src/services/graphql/articles.ts
@@ -71,13 +71,12 @@ export const getArticleFromRawData = (data: RawArticle): Article => {
     id: databaseId,
     intro: contentParts.beforeMore,
     meta: {
-      author: getAuthorFromRawData(author.node, 'page'),
+      author: author && getAuthorFromRawData(author.node, 'page'),
       commentsCount: commentCount || 0,
       cover: featuredImage?.node
         ? getImageFromRawData(featuredImage.node)
         : undefined,
       dates: { publication: date, update: modified },
-      readingTime: info.readingTime,
       seo: {
         description: seo?.metaDesc || '',
         title: seo?.title || '',
diff --git a/src/services/graphql/thematics.query.ts b/src/services/graphql/thematics.query.ts
index 3b3ebd6..5a82133 100644
--- a/src/services/graphql/thematics.query.ts
+++ b/src/services/graphql/thematics.query.ts
@@ -15,13 +15,6 @@ export const thematicBySlugQuery = `query ThematicBy($slug: ID!) {
               }
             }
           }
-          author {
-            node {
-              gravatarUrl
-              name
-              url
-            }
-          }
           commentCount
           contentParts {
             beforeMore
@@ -40,7 +33,6 @@ export const thematicBySlugQuery = `query ThematicBy($slug: ID!) {
             }
           }
           info {
-            readingTime
             wordsCount
           }
           modified
@@ -67,7 +59,6 @@ export const thematicBySlugQuery = `query ThematicBy($slug: ID!) {
       }
     }
     info {
-      readingTime
       wordsCount
     }
     modified
diff --git a/src/services/graphql/thematics.ts b/src/services/graphql/thematics.ts
index e526db8..fd9d9f5 100644
--- a/src/services/graphql/thematics.ts
+++ b/src/services/graphql/thematics.ts
@@ -117,7 +117,6 @@ export const getThematicFromRawData = (data: RawThematic): Thematic => {
         ? getImageFromRawData(featuredImage.node)
         : undefined,
       dates: { publication: date, update: modified },
-      readingTime: info.readingTime,
       seo: {
         description: seo?.metaDesc || '',
         title: seo?.title || '',
diff --git a/src/services/graphql/topics.query.ts b/src/services/graphql/topics.query.ts
index 4574256..57b2569 100644
--- a/src/services/graphql/topics.query.ts
+++ b/src/services/graphql/topics.query.ts
@@ -16,13 +16,6 @@ export const topicBySlugQuery = `query TopicBy($slug: ID!) {
               }
             }
           }
-          author {
-            node {
-              gravatarUrl
-              name
-              url
-            }
-          }
           commentCount
           contentParts {
             beforeMore
@@ -41,7 +34,6 @@ export const topicBySlugQuery = `query TopicBy($slug: ID!) {
             }
           }
           info {
-            readingTime
             wordsCount
           }
           modified
@@ -68,7 +60,6 @@ export const topicBySlugQuery = `query TopicBy($slug: ID!) {
       }
     }
     info {
-      readingTime
       wordsCount
     }
     modified
diff --git a/src/services/graphql/topics.ts b/src/services/graphql/topics.ts
index 3c92442..277580f 100644
--- a/src/services/graphql/topics.ts
+++ b/src/services/graphql/topics.ts
@@ -119,7 +119,6 @@ export const getTopicFromRawData = (data: RawTopic): Topic => {
         : undefined,
       dates: { publication: date, update: modified },
       website: acfTopics.officialWebsite,
-      readingTime: info.readingTime,
       seo: {
         description: seo?.metaDesc || '',
         title: seo?.title || '',
diff --git a/src/ts/types/app.ts b/src/ts/types/app.ts
index feff5a5..cb5429b 100644
--- a/src/ts/types/app.ts
+++ b/src/ts/types/app.ts
@@ -44,7 +44,7 @@ export type Comment = {
   content: string;
   id: number;
   meta: CommentMeta;
-  parentId: number;
+  parentId?: number;
   replies: Comment[];
 };
 
@@ -80,7 +80,6 @@ export type Meta<T extends PageKind> = {
   cover?: Image;
   dates: Dates;
   license?: T extends 'project' ? string : never;
-  readingTime?: number;
   repos?: T extends 'project' ? Repos : never;
   seo: SEO;
   tagline?: T extends 'project' ? string : never;
@@ -88,7 +87,7 @@ export type Meta<T extends PageKind> = {
   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> = {
diff --git a/src/ts/types/mdx.ts b/src/ts/types/mdx.ts
index 6b72f21..16538c1 100644
--- a/src/ts/types/mdx.ts
+++ b/src/ts/types/mdx.ts
@@ -11,10 +11,7 @@ export type MDXPageMeta = Pick<Meta<'page'>, 'cover' | 'dates' | 'seo'> & {
   title: string;
 };
 
-export type MDXProjectMeta = Omit<
-  Meta<'project'>,
-  'readingTime' | 'wordsCount'
-> & {
+export type MDXProjectMeta = Exclude<Meta<'project'>, 'wordsCount'> & {
   intro: string;
   title: string;
 };
diff --git a/src/ts/types/raw-data.ts b/src/ts/types/raw-data.ts
index ba6d596..dc3db90 100644
--- a/src/ts/types/raw-data.ts
+++ b/src/ts/types/raw-data.ts
@@ -25,7 +25,6 @@ export type ContentParts = {
 };
 
 export type Info = {
-  readingTime: number;
   wordsCount: number;
 };
 
@@ -66,7 +65,7 @@ export type RawArticlePreview = Pick<
 >;
 
 export type RawPage = {
-  author: NodeResponse<RawAuthor<'page'>>;
+  author?: NodeResponse<RawAuthor<'page'>>;
   contentParts: ContentParts;
   databaseId: number;
   date: string;
diff --git a/src/utils/helpers/pages.ts b/src/utils/helpers/pages.ts
index 62337db..1a388e1 100644
--- a/src/utils/helpers/pages.ts
+++ b/src/utils/helpers/pages.ts
@@ -1,7 +1,10 @@
 import { type Post } from '@components/organisms/layout/posts-list';
 import { type LinksListItems } from '@components/organisms/widgets/links-list-widget';
-import { type Meta, type PageLink } from '@ts/types/app';
+import { type EdgesResponse } from '@services/graphql/api';
+import { getArticleFromRawData } from '@services/graphql/articles';
+import { type Article, type PageLink } from '@ts/types/app';
 import {
+  type RawArticle,
   type RawThematicPreview,
   type RawTopicPreview,
 } from '@ts/types/raw-data';
@@ -52,23 +55,35 @@ export const getLinksListItems = (
 };
 
 /**
- * Retrieve the formatted meta.
+ * Retrieve the posts list with the article URL.
  *
- * @param {Meta<'article'>} meta - The article meta.
- * @returns {Post['meta']} The formatted meta.
+ * @param {Article[]} posts - An array of articles.
+ * @returns {Post[]} An array of posts with full article URL.
  */
-export const getPostMeta = (meta: Meta<'article'>): Post['meta'] => {
-  const { commentsCount, dates, thematics, topics, wordsCount } = meta;
+export const getPostsWithUrl = (posts: Article[]): Post[] => {
+  return posts.map((post) => {
+    return {
+      ...post,
+      url: `/article/${post.slug}`,
+    };
+  });
+};
 
-  return {
-    commentsCount,
-    dates,
-    readingTime: { wordsCount: wordsCount || 0, onlyMinutes: true },
-    thematics: thematics?.map((thematic) => {
-      return { ...thematic, url: `/thematique/${thematic.slug}` };
-    }),
-    topics: topics?.map((topic) => {
-      return { ...topic, url: `/sujet/${topic.slug}` };
-    }),
-  };
+/**
+ * Retrieve the posts list from raw data.
+ *
+ * @param {EdgesResponse<RawArticle>[]} rawData - The raw data.
+ * @returns {Post[]} An array of posts.
+ */
+export const getPostsList = (rawData: EdgesResponse<RawArticle>[]): Post[] => {
+  const articlesList: RawArticle[] = [];
+  rawData.forEach((articleData) =>
+    articleData.edges.forEach((edge) => {
+      articlesList.push(edge.node);
+    })
+  );
+
+  return getPostsWithUrl(
+    articlesList.map((article) => getArticleFromRawData(article))
+  );
 };
-- 
cgit v1.2.3


From 1e982fb02a9967e0efdc76c93a44798a9f2dcb43 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Tue, 24 May 2022 16:44:29 +0200
Subject: chore: add a search form when posts list prints no results

---
 .../organisms/layout/no-results.stories.tsx        | 28 ++++++++
 .../organisms/layout/no-results.test.tsx           | 14 ++++
 src/components/organisms/layout/no-results.tsx     | 38 ++++++++++
 .../organisms/layout/posts-list.fixture.tsx        |  2 +
 .../organisms/layout/posts-list.stories.tsx        |  5 +-
 .../organisms/layout/posts-list.test.tsx           | 29 ++++++--
 src/components/organisms/layout/posts-list.tsx     | 81 ++++++++++------------
 .../templates/page/page-layout.stories.tsx         |  7 +-
 src/pages/blog/index.tsx                           |  1 +
 src/pages/blog/page/[number].tsx                   |  1 +
 src/pages/recherche/index.tsx                      |  1 +
 src/pages/sujet/[slug].tsx                         |  5 +-
 src/pages/thematique/[slug].tsx                    |  5 +-
 13 files changed, 164 insertions(+), 53 deletions(-)
 create mode 100644 src/components/organisms/layout/no-results.stories.tsx
 create mode 100644 src/components/organisms/layout/no-results.test.tsx
 create mode 100644 src/components/organisms/layout/no-results.tsx

(limited to 'src/components/templates/page/page-layout.stories.tsx')

diff --git a/src/components/organisms/layout/no-results.stories.tsx b/src/components/organisms/layout/no-results.stories.tsx
new file mode 100644
index 0000000..aa2e51e
--- /dev/null
+++ b/src/components/organisms/layout/no-results.stories.tsx
@@ -0,0 +1,28 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import NoResultsComponent from './no-results';
+
+export default {
+  title: 'Organisms/Layout',
+  component: NoResultsComponent,
+  argTypes: {
+    searchPage: {
+      control: {
+        type: 'text',
+      },
+      description: 'The search results page.',
+      type: {
+        name: 'string',
+        required: true,
+      },
+    },
+  },
+} as ComponentMeta<typeof NoResultsComponent>;
+
+const Template: ComponentStory<typeof NoResultsComponent> = (args) => (
+  <NoResultsComponent {...args} />
+);
+
+export const NoResults = Template.bind({});
+NoResults.args = {
+  searchPage: '#',
+};
diff --git a/src/components/organisms/layout/no-results.test.tsx b/src/components/organisms/layout/no-results.test.tsx
new file mode 100644
index 0000000..7f57177
--- /dev/null
+++ b/src/components/organisms/layout/no-results.test.tsx
@@ -0,0 +1,14 @@
+import { render, screen } from '@test-utils';
+import NoResults from './no-results';
+
+describe('NoResults', () => {
+  it('renders a no results text', () => {
+    render(<NoResults searchPage="#" />);
+    expect(screen.getByText(/No results/gi)).toBeInTheDocument();
+  });
+
+  it('renders a search form', () => {
+    render(<NoResults searchPage="#" />);
+    expect(screen.getByRole('searchbox')).toBeInTheDocument();
+  });
+});
diff --git a/src/components/organisms/layout/no-results.tsx b/src/components/organisms/layout/no-results.tsx
new file mode 100644
index 0000000..2245dbf
--- /dev/null
+++ b/src/components/organisms/layout/no-results.tsx
@@ -0,0 +1,38 @@
+import SearchForm, {
+  type SearchFormProps,
+} from '@components/organisms/forms/search-form';
+import { FC } from 'react';
+import { useIntl } from 'react-intl';
+
+export type NoResultsProps = Pick<SearchFormProps, 'searchPage'>;
+
+/**
+ * NoResults component
+ *
+ * Renders a no results text with a search form.
+ */
+const NoResults: FC<NoResultsProps> = ({ searchPage }) => {
+  const intl = useIntl();
+
+  return (
+    <>
+      <p>
+        {intl.formatMessage({
+          defaultMessage: 'No results found.',
+          description: 'NoResults: no results',
+          id: '5O2vpy',
+        })}
+      </p>
+      <p>
+        {intl.formatMessage({
+          defaultMessage: 'Would you like to try a new search?',
+          description: 'NoResults: try a new search message',
+          id: 'DVBwfu',
+        })}
+      </p>
+      <SearchForm hideLabel={true} searchPage={searchPage} />
+    </>
+  );
+};
+
+export default NoResults;
diff --git a/src/components/organisms/layout/posts-list.fixture.tsx b/src/components/organisms/layout/posts-list.fixture.tsx
index e3f2378..97a746f 100644
--- a/src/components/organisms/layout/posts-list.fixture.tsx
+++ b/src/components/organisms/layout/posts-list.fixture.tsx
@@ -59,3 +59,5 @@ export const posts: Post[] = [
     url: '#',
   },
 ];
+
+export const searchPage = '#';
diff --git a/src/components/organisms/layout/posts-list.stories.tsx b/src/components/organisms/layout/posts-list.stories.tsx
index 68e3dc0..bff1f28 100644
--- a/src/components/organisms/layout/posts-list.stories.tsx
+++ b/src/components/organisms/layout/posts-list.stories.tsx
@@ -1,6 +1,6 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
 import PostsList from './posts-list';
-import { posts } from './posts-list.fixture';
+import { posts, searchPage } from './posts-list.fixture';
 
 /**
  * PostsList - Storybook Meta
@@ -161,6 +161,7 @@ const Template: ComponentStory<typeof PostsList> = (args) => (
 export const Default = Template.bind({});
 Default.args = {
   posts,
+  searchPage,
   total: posts.length,
 };
 
@@ -171,6 +172,7 @@ export const ByYears = Template.bind({});
 ByYears.args = {
   posts,
   byYear: true,
+  searchPage,
   total: posts.length,
 };
 ByYears.decorators = [
@@ -187,5 +189,6 @@ ByYears.decorators = [
 export const NoResults = Template.bind({});
 NoResults.args = {
   posts: [],
+  searchPage,
   total: posts.length,
 };
diff --git a/src/components/organisms/layout/posts-list.test.tsx b/src/components/organisms/layout/posts-list.test.tsx
index 1bab466..e58a974 100644
--- a/src/components/organisms/layout/posts-list.test.tsx
+++ b/src/components/organisms/layout/posts-list.test.tsx
@@ -1,25 +1,46 @@
 import { render, screen } from '@test-utils';
 import PostsList from './posts-list';
-import { posts } from './posts-list.fixture';
+import { posts, searchPage } from './posts-list.fixture';
 
 describe('PostsList', () => {
   it('renders the correct number of posts', () => {
-    render(<PostsList posts={posts} total={posts.length} />);
+    render(
+      <PostsList posts={posts} total={posts.length} searchPage={searchPage} />
+    );
     expect(screen.getAllByRole('article')).toHaveLength(posts.length);
   });
 
   it('renders the number of loaded posts', () => {
-    render(<PostsList posts={posts} total={posts.length} />);
+    render(
+      <PostsList posts={posts} total={posts.length} searchPage={searchPage} />
+    );
     const info = `${posts.length} loaded articles out of a total of ${posts.length}`;
     expect(screen.getByText(info)).toBeInTheDocument();
   });
 
   it('renders a load more button', () => {
     render(
-      <PostsList posts={posts} total={posts.length} showLoadMoreBtn={true} />
+      <PostsList
+        posts={posts}
+        total={posts.length}
+        showLoadMoreBtn={true}
+        searchPage={searchPage}
+      />
     );
     expect(
       screen.getByRole('button', { name: /Load more/i })
     ).toBeInTheDocument();
   });
+
+  it('renders a search form if no results', () => {
+    render(
+      <PostsList
+        posts={[]}
+        total={0}
+        showLoadMoreBtn={true}
+        searchPage={searchPage}
+      />
+    );
+    expect(screen.getByRole('searchbox')).toBeInTheDocument();
+  });
 });
diff --git a/src/components/organisms/layout/posts-list.tsx b/src/components/organisms/layout/posts-list.tsx
index 91fc62d..24869fd 100644
--- a/src/components/organisms/layout/posts-list.tsx
+++ b/src/components/organisms/layout/posts-list.tsx
@@ -9,6 +9,7 @@ import useIsMounted from '@utils/hooks/use-is-mounted';
 import useSettings from '@utils/hooks/use-settings';
 import { FC, Fragment, useRef } from 'react';
 import { useIntl } from 'react-intl';
+import NoResults, { NoResultsProps } from './no-results';
 import styles from './posts-list.module.scss';
 import Summary, { type SummaryProps } from './summary';
 
@@ -23,40 +24,41 @@ export type YearCollection = {
   [key: string]: Post[];
 };
 
-export type PostsListProps = Pick<PaginationProps, 'baseUrl' | 'siblings'> & {
-  /**
-   * True to display the posts by year. Default: false.
-   */
-  byYear?: boolean;
-  /**
-   * Determine if the data is loading.
-   */
-  isLoading?: boolean;
-  /**
-   * Load more button handler.
-   */
-  loadMore?: () => void;
-  /**
-   * The current page number. Default: 1.
-   */
-  pageNumber?: number;
-  /**
-   * The posts data.
-   */
-  posts: Post[];
-  /**
-   * Determine if the load more button should be visible.
-   */
-  showLoadMoreBtn?: boolean;
-  /**
-   * The posts heading level (hn).
-   */
-  titleLevel?: HeadingLevel;
-  /**
-   * The total posts number.
-   */
-  total: number;
-};
+export type PostsListProps = Pick<PaginationProps, 'baseUrl' | 'siblings'> &
+  Pick<NoResultsProps, 'searchPage'> & {
+    /**
+     * True to display the posts by year. Default: false.
+     */
+    byYear?: boolean;
+    /**
+     * Determine if the data is loading.
+     */
+    isLoading?: boolean;
+    /**
+     * Load more button handler.
+     */
+    loadMore?: () => void;
+    /**
+     * The current page number. Default: 1.
+     */
+    pageNumber?: number;
+    /**
+     * The posts data.
+     */
+    posts: Post[];
+    /**
+     * Determine if the load more button should be visible.
+     */
+    showLoadMoreBtn?: boolean;
+    /**
+     * The posts heading level (hn).
+     */
+    titleLevel?: HeadingLevel;
+    /**
+     * The total posts number.
+     */
+    total: number;
+  };
 
 /**
  * Create a collection of posts sorted by year.
@@ -89,6 +91,7 @@ const PostsList: FC<PostsListProps> = ({
   loadMore,
   pageNumber = 1,
   posts,
+  searchPage,
   showLoadMoreBtn = false,
   siblings,
   titleLevel,
@@ -221,15 +224,7 @@ const PostsList: FC<PostsListProps> = ({
   };
 
   if (posts.length === 0) {
-    return (
-      <p>
-        {intl.formatMessage({
-          defaultMessage: 'No results found.',
-          description: 'PostsList: no results',
-          id: 'vK7Sxv',
-        })}
-      </p>
-    );
+    return <NoResults searchPage={searchPage} />;
   }
 
   return (
diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx
index 88e3785..06c6c24 100644
--- a/src/components/templates/page/page-layout.stories.tsx
+++ b/src/components/templates/page/page-layout.stories.tsx
@@ -368,7 +368,12 @@ Blog.args = {
   headerMeta: { total: posts.length },
   children: (
     <>
-      <PostsList posts={posts} byYear={true} total={posts.length} />
+      <PostsList
+        posts={posts}
+        byYear={true}
+        total={posts.length}
+        searchPage="#"
+      />
     </>
   ),
   widgets: [
diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx
index 3956308..3f7eefd 100644
--- a/src/pages/blog/index.tsx
+++ b/src/pages/blog/index.tsx
@@ -177,6 +177,7 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
             isLoading={isLoadingMore || isLoadingInitialData}
             loadMore={loadMore}
             posts={getPostsList(data)}
+            searchPage="/recherche/"
             showLoadMoreBtn={hasNextPage}
             total={totalArticles}
           />
diff --git a/src/pages/blog/page/[number].tsx b/src/pages/blog/page/[number].tsx
index b5efd27..1e1240a 100644
--- a/src/pages/blog/page/[number].tsx
+++ b/src/pages/blog/page/[number].tsx
@@ -170,6 +170,7 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
           byYear={true}
           pageNumber={pageNumber}
           posts={getPostsList([articles])}
+          searchPage="/recherche/"
           total={totalArticles}
         />
       </PageLayout>
diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx
index ffdbf44..dbbec55 100644
--- a/src/pages/recherche/index.tsx
+++ b/src/pages/recherche/index.tsx
@@ -189,6 +189,7 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({
             isLoading={isLoadingMore || isLoadingInitialData}
             loadMore={loadMore}
             posts={getPostsList(data)}
+            searchPage="/recherche/"
             showLoadMoreBtn={hasNextPage}
             total={totalArticles || 0}
           />
diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx
index 5c86720..48924e5 100644
--- a/src/pages/sujet/[slug].tsx
+++ b/src/pages/sujet/[slug].tsx
@@ -167,10 +167,11 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({
             </Heading>
             <PostsList
               baseUrl="/sujet/page/"
+              byYear={true}
               posts={getPostsWithUrl(articles)}
-              total={articles.length}
+              searchPage="/recherche/"
               titleLevel={3}
-              byYear={true}
+              total={articles.length}
             />
           </>
         )}
diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx
index 0519731..7aa6c1c 100644
--- a/src/pages/thematique/[slug].tsx
+++ b/src/pages/thematique/[slug].tsx
@@ -147,10 +147,11 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({
             </Heading>
             <PostsList
               baseUrl="/thematique/page/"
+              byYear={true}
               posts={getPostsWithUrl(articles)}
-              total={articles.length}
+              searchPage="/recherche/"
               titleLevel={3}
-              byYear={true}
+              total={articles.length}
             />
           </>
         )}
-- 
cgit v1.2.3

